Skip to content

Add local dev environment: Supabase config, base schema bootstrap, AGENTS.md#412

Merged
leerob merged 2 commits into
mainfrom
cursor/local-dev-environment-setup-f1c9
Jun 9, 2026
Merged

Add local dev environment: Supabase config, base schema bootstrap, AGENTS.md#412
leerob merged 2 commits into
mainfrom
cursor/local-dev-environment-setup-f1c9

Conversation

@leerob

@leerob leerob commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Makes the repo runnable locally with the Supabase CLI (supabase startbun dev), and documents cloud-agent dev environment caveats.

  • supabase/config.toml (+ generated supabase/.gitignore): local stack config — exposes pgmq_public to PostgREST (required by src/lib/plugins/queue.ts), declares the public avatars bucket, allows http://localhost:3000 auth redirects, and disables the unused edge runtime.
  • supabase/migrations/00000000000000_local_base_schema.sql: guarded reconstruction of the hosted project's pre-existing base schema (tables, signup/slug/follow-count triggers, increment_install_count & legacy star RPCs, pgmq_public wrappers, storage policies). Every statement is existence-checked so it is a no-op where objects already exist; nothing is ever replaced.
  • Renames 20260515_plugin_similar_search.sql20260516_... and 20260526_companies_unique_name.sql20260527_... (contents unchanged, both idempotent): two pairs of migrations shared a version prefix, which violates the Supabase CLI's unique-version tracking and breaks supabase db reset / db push.
  • AGENTS.md: Cursor Cloud specific instructions (service startup, seeding gotchas, auth-locally notes).

Verification

Full local flow verified end to end: fresh supabase db reset applies all migrations cleanly; seeded 9 real plugins via the repo's seed:extract/seed:insert scripts; submitted cursor/plugin-template through the UI, approved it from /admin/plugins, starred it, and confirmed the pgmq scan queue enqueue/drain works (scan itself errors without CURSOR_API_KEY, as expected locally).

plugin_submit_approve_star_demo.mp4

Homepage with seeded plugins

  • bunx biome ci .
  • bun run typecheck (apps/cursor)
  • supabase db reset (all migrations apply on fresh DB)
  • ✅ Manual GUI test: plugin submission → admin approval → star (see video)
Open in Web Open in Cursor 

…ENTS.md

- supabase/config.toml: local stack config (pgmq_public exposed to PostgREST,
  avatars bucket, localhost auth redirects, edge runtime disabled)
- 00000000000000_local_base_schema.sql: guarded reconstruction of the hosted
  base schema (tables, triggers, RPCs, pgmq_public wrappers, storage policies)
  so fresh local supabase start/db reset works
- Rename two migrations that shared a version prefix (20260515, 20260526) with
  another file, which breaks the Supabase CLI's unique-version tracking;
  contents unchanged and idempotent
- AGENTS.md: Cursor Cloud dev environment notes

Co-authored-by: Lee Robinson <lee@leerob.com>
@vercel

vercel Bot commented Jun 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cursor-directory Ready Ready Preview, Comment Jun 9, 2026 3:35pm

Request Review

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Install RPC publicly callable
    • Revoked the default PUBLIC/anon/authenticated EXECUTE on increment_install_count and re-granted only to service_role, matching how it is invoked from the admin client.
  • ✅ Fixed: Avatars bucket lacks path ownership
    • Scoped the avatars insert/update policies to owner_id = auth.uid() (the bucket is shared across user/company/plugin/mcp uploads with mixed path prefixes, so ownership is the correct guard rather than the path segment).
  • ✅ Fixed: Public tables missing RLS
    • Enabled RLS on all seven public tables and added owner-scoped policies (public reads where the app relies on them), verified to preserve every app flow and admin-client bypass while blocking cross-user reads/writes.

Create PR

Or push these changes by commenting:

@cursor push 78ce43b59f
Preview (78ce43b59f)
diff --git a/supabase/migrations/00000000000000_local_base_schema.sql b/supabase/migrations/00000000000000_local_base_schema.sql
--- a/supabase/migrations/00000000000000_local_base_schema.sql
+++ b/supabase/migrations/00000000000000_local_base_schema.sql
@@ -528,11 +528,126 @@
 end$$;
 
 -- ---------------------------------------------------------------------------
--- Storage: policies for the public `avatars` bucket (bucket itself is
--- declared in supabase/config.toml for local dev).
+-- Function privileges: increment_install_count is invoked only from the
+-- service-role admin client (src/actions/track-install.ts). Postgres grants
+-- EXECUTE to PUBLIC by default, so without this any anon/authenticated caller
+-- could inflate plugins.install_count via PostgREST. Revoke it and re-grant to
+-- service_role only (mirrors the hardening on later RPCs such as
+-- update_plugin_with_components and toggle_plugin_star).
 -- ---------------------------------------------------------------------------
+revoke execute on function public.increment_install_count(uuid) from public, anon, authenticated;
+grant execute on function public.increment_install_count(uuid) to service_role;
+
+-- ---------------------------------------------------------------------------
+-- Row Level Security. The hosted base schema enforces RLS on these tables and
+-- the app's browser/cookie-scoped clients (anon / authenticated) rely on it,
+-- while the service-role admin client bypasses RLS. Enable RLS and add policies
+-- that mirror that posture so a fresh local database matches production instead
+-- of leaving every row world-readable/writable.
+-- ---------------------------------------------------------------------------
 do $$
+declare t text;
 begin
+  foreach t in array array[
+    'users', 'companies', 'plugins', 'plugin_components', 'plugin_stars',
+    'followers', 'mcps'
+  ] loop
+    if not exists (
+      select 1 from pg_class c join pg_namespace n on n.oid = c.relnamespace
+      where n.nspname = 'public' and c.relname = t and c.relrowsecurity
+    ) then
+      execute format('alter table public.%I enable row level security', t);
+    end if;
+  end loop;
+end$$;
+
+do $$
+begin
+  -- users: a session may read and edit only its own profile row. Public
+  -- profile and directory reads run through the service-role admin client.
+  if not exists (select 1 from pg_policies where schemaname = 'public' and tablename = 'users' and policyname = 'users_select_own') then
+    create policy users_select_own on public.users
+      for select to authenticated using ((select auth.uid()) = id);
+  end if;
+  if not exists (select 1 from pg_policies where schemaname = 'public' and tablename = 'users' and policyname = 'users_update_own') then
+    create policy users_update_own on public.users
+      for update to authenticated using ((select auth.uid()) = id) with check ((select auth.uid()) = id);
+  end if;
+
+  -- companies: public rows are readable by anyone (directory search) and a user
+  -- always sees and owns the companies they created.
+  if not exists (select 1 from pg_policies where schemaname = 'public' and tablename = 'companies' and policyname = 'companies_select_public_or_own') then
+    create policy companies_select_public_or_own on public.companies
+      for select using (public = true or (select auth.uid()) = owner_id);
+  end if;
+  if not exists (select 1 from pg_policies where schemaname = 'public' and tablename = 'companies' and policyname = 'companies_insert_own') then
+    create policy companies_insert_own on public.companies
+      for insert to authenticated with check ((select auth.uid()) = owner_id);
+  end if;
+  if not exists (select 1 from pg_policies where schemaname = 'public' and tablename = 'companies' and policyname = 'companies_update_own') then
+    create policy companies_update_own on public.companies
+      for update to authenticated using ((select auth.uid()) = owner_id) with check ((select auth.uid()) = owner_id);
+  end if;
+
+  -- plugins: active listings are world-readable; owners read/modify their own
+  -- rows (seed rows have a null owner_id and are reachable only via active).
+  if not exists (select 1 from pg_policies where schemaname = 'public' and tablename = 'plugins' and policyname = 'plugins_select_active_or_own') then
+    create policy plugins_select_active_or_own on public.plugins
+      for select using (active = true or (select auth.uid()) = owner_id);
+  end if;
+  if not exists (select 1 from pg_policies where schemaname = 'public' and tablename = 'plugins' and policyname = 'plugins_update_own') then
+    create policy plugins_update_own on public.plugins
+      for update to authenticated using ((select auth.uid()) = owner_id) with check ((select auth.uid()) = owner_id);
+  end if;
+  if not exists (select 1 from pg_policies where schemaname = 'public' and tablename = 'plugins' and policyname = 'plugins_delete_own') then
+    create policy plugins_delete_own on public.plugins
+      for delete to authenticated using ((select auth.uid()) = owner_id);
+  end if;
+
+  -- followers: a user manages only the rows where they are the follower.
+  if not exists (select 1 from pg_policies where schemaname = 'public' and tablename = 'followers' and policyname = 'followers_select_own') then
+    create policy followers_select_own on public.followers
+      for select to authenticated using ((select auth.uid()) = follower_id);
+  end if;
+  if not exists (select 1 from pg_policies where schemaname = 'public' and tablename = 'followers' and policyname = 'followers_insert_own') then
+    create policy followers_insert_own on public.followers
+      for insert to authenticated with check ((select auth.uid()) = follower_id);
+  end if;
+  if not exists (select 1 from pg_policies where schemaname = 'public' and tablename = 'followers' and policyname = 'followers_delete_own') then
+    create policy followers_delete_own on public.followers
+      for delete to authenticated using ((select auth.uid()) = follower_id);
+  end if;
+
+  -- plugin_stars: a user reads only their own stars; writes go through the
+  -- SECURITY DEFINER toggle_plugin_star RPC (20260523_plugin_star_atomic.sql).
+  if not exists (select 1 from pg_policies where schemaname = 'public' and tablename = 'plugin_stars' and policyname = 'plugin_stars_select_own') then
+    create policy plugin_stars_select_own on public.plugin_stars
+      for select to authenticated using ((select auth.uid()) = user_id);
+  end if;
+
+  -- mcps: active listings are world-readable; owners modify their own rows.
+  if not exists (select 1 from pg_policies where schemaname = 'public' and tablename = 'mcps' and policyname = 'mcps_select_active_or_own') then
+    create policy mcps_select_active_or_own on public.mcps
+      for select using (active = true or (select auth.uid()) = owner_id);
+  end if;
+  if not exists (select 1 from pg_policies where schemaname = 'public' and tablename = 'mcps' and policyname = 'mcps_update_own') then
+    create policy mcps_update_own on public.mcps
+      for update to authenticated using ((select auth.uid()) = owner_id) with check ((select auth.uid()) = owner_id);
+  end if;
+
+  -- plugin_components is intentionally left without an anon/authenticated
+  -- policy: it is written by the admin client and read via plugin joins on the
+  -- server, so RLS denies all direct Data API access (service_role bypasses it).
+end$$;
+
+-- ---------------------------------------------------------------------------
+-- Storage: policies for the public `avatars` bucket (bucket itself is declared
+-- in supabase/config.toml). The bucket is shared across user, company, plugin
+-- and mcp uploads with mixed path prefixes, so write access is scoped to the
+-- object owner (owner_id) rather than the first path segment.
+-- ---------------------------------------------------------------------------
+do $$
+begin
   if not exists (
     select 1 from pg_policies
     where schemaname = 'storage' and tablename = 'objects'
@@ -548,7 +663,8 @@
       and policyname = 'avatars_authenticated_insert'
   ) then
     create policy avatars_authenticated_insert on storage.objects
-      for insert to authenticated with check (bucket_id = 'avatars');
+      for insert to authenticated
+      with check (bucket_id = 'avatars' and owner_id = (select auth.uid())::text);
   end if;
 
   if not exists (
@@ -557,6 +673,8 @@
       and policyname = 'avatars_authenticated_update'
   ) then
     create policy avatars_authenticated_update on storage.objects
-      for update to authenticated using (bucket_id = 'avatars');
+      for update to authenticated
+      using (bucket_id = 'avatars' and owner_id = (select auth.uid())::text)
+      with check (bucket_id = 'avatars' and owner_id = (select auth.uid())::text);
   end if;
 end$$;

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 4069c01. Configure here.

Comment thread supabase/migrations/00000000000000_local_base_schema.sql
Comment thread supabase/migrations/00000000000000_local_base_schema.sql Outdated
Comment thread supabase/migrations/00000000000000_local_base_schema.sql
@leerob

leerob commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

@cursor push 78ce43b

…rage)

The local bootstrap schema diverged from the hosted project's security
posture, leaving a fresh local database far more permissive than production:

- Enable Row Level Security on the public application tables (users,
  companies, plugins, plugin_components, plugin_stars, followers, mcps) and
  add owner-scoped policies. The app's browser/cookie-scoped clients
  (anon/authenticated) rely on RLS; the service-role admin client bypasses
  it. Without RLS, authenticated clients could read and mutate rows they
  do not own.

- Revoke the default PUBLIC EXECUTE grant on increment_install_count and
  re-grant only to service_role. It is called solely from the admin client
  (track-install.ts), so leaving it PUBLIC let any key holder inflate
  install_count via PostgREST (mirrors update_plugin_with_components /
  toggle_plugin_star hardening).

- Scope the avatars bucket insert/update policies to the object owner_id
  instead of bucket-only. The bucket is shared across user/company/plugin/
  mcp uploads with mixed path prefixes, so ownership (not the path prefix)
  is the correct guard against overwriting other users' files.

All guarded for idempotency (no-op when objects already exist).

Applied via @cursor push command
@leerob leerob marked this pull request as ready for review June 9, 2026 15:32
@leerob leerob merged commit 956b4d4 into main Jun 9, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants