From 4069c01c511e58159bb675f35febb6feb58e47ad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 9 Jun 2026 14:31:49 +0000 Subject: [PATCH 1/2] Add local dev environment: Supabase config, base schema bootstrap, AGENTS.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 --- AGENTS.md | 24 + supabase/.gitignore | 8 + supabase/config.toml | 416 +++++++++++++ .../00000000000000_local_base_schema.sql | 562 ++++++++++++++++++ ...sql => 20260516_plugin_similar_search.sql} | 0 ...sql => 20260527_companies_unique_name.sql} | 0 6 files changed, 1010 insertions(+) create mode 100644 AGENTS.md create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml create mode 100644 supabase/migrations/00000000000000_local_base_schema.sql rename supabase/migrations/{20260515_plugin_similar_search.sql => 20260516_plugin_similar_search.sql} (100%) rename supabase/migrations/{20260526_companies_unique_name.sql => 20260527_companies_unique_name.sql} (100%) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..b2d1a7b2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# AGENTS.md + +## Cursor Cloud specific instructions + +### Services + +| Service | How to run | Port | +|---|---|---| +| Supabase stack (Postgres, Auth, PostgREST, Storage, pgmq) | `sudo supabase start` from repo root (Docker; start the daemon first with `sudo service docker start` if needed) | 54321 (API), 54322 (DB), 54323 (Studio) | +| Next.js app (`apps/cursor`) | `bun dev` from `apps/cursor` (see README and `apps/cursor/package.json` for standard commands: `lint`, `typecheck`, `seed:*`) | 3000 | + +Lint from repo root: `bunx biome ci .` (matches CI in `.github/workflows/ci.yml`). There is no automated test suite. + +### Non-obvious caveats + +- `bun` is installed via npm into `~/.local/bin` (on `PATH` via `~/.bashrc`); the standalone bun.sh installer is blocked by the network egress policy. +- The repo's dated migrations assume a pre-existing hosted base schema. `supabase/migrations/00000000000000_local_base_schema.sql` reconstructs it (tables, signup trigger, slug triggers, `pgmq_public` wrappers, storage policies) so a fresh `supabase start` / `supabase db reset` works locally. It is guarded to be a no-op where objects already exist. +- App env lives in `apps/cursor/.env` (gitignored). For local dev it uses the Supabase CLI's well-known local default keys (`supabase status` prints them) with `NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321`. `CRON_SECRET` guards `/api/queue/plugin-scans/drain` and `/api/cron/*`. +- The login page only offers GitHub/Google OAuth, which are not configured locally. Create a confirmed user via the GoTrue admin API (`POST {SUPABASE_URL}/auth/v1/admin/users` with the secret key, `"email_confirm": true`); a DB trigger provisions the `public.users` profile row. To get a browser session, add a temporary route that calls `supabase.auth.signInWithPassword(...)` with the server client (do not commit it). Put the user's id in `ADMIN_USER_IDS`/`NEXT_PUBLIC_ADMIN_USER_IDS` to use `/admin/plugins`. +- Cache Components (`cacheComponents: true`) makes pages with `generateStaticParams` throw 500 on an empty database — seed at least one active plugin. Use `bun run seed:extract` / `seed:insert`, but note: the insert script must be run as `bun run --conditions=react-server --env-file=apps/cursor/.env apps/cursor/src/scripts/insert-from-jsonl.ts` or the `server-only` import throws under plain `bun run`. GitHub Code Search (used by `seed:extract` discovery) is heavily rate-limited; hand-writing `apps/cursor/.seed/candidates.json` with `{"candidates":[{"owner":...,"repo":...,"source":"seed:topic","matchedQuery":"manual"}]}` skips discovery. +- Seeded plugin logos hosted on `raw.githubusercontent.com` 500 plugin pages because that host is not in `next.config.mjs` `images.remotePatterns`; null them: `update plugins set logo = null where logo like 'https://raw.githubusercontent.com%'`. +- After changing `.env` or database content backing cached pages, restart `bun dev` (and `rm -rf apps/cursor/.next` if stale renders persist) — hot reload does not invalidate Cache Components output. +- Without `CURSOR_API_KEY`, plugin submission works but the security scan errors (plugin lands in the admin "Scan issues" queue, where an admin can publish it manually). This is expected locally. +- Supabase `edge_runtime` is disabled in `supabase/config.toml` (no edge functions in this app; its boot probe needs deno.land which is blocked). diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 00000000..ad9264f0 --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 00000000..ffa7fc71 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,416 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "workspace" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +# `pgmq_public` is exposed so the app's Supabase Queues wrappers +# (src/lib/plugins/queue.ts) work against local PostgREST, mirroring the +# hosted Queues integration. +schemas = ["public", "graphql_public", "pgmq_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 +# Controls whether new tables, views, sequences and functions created in the `public` schema by +# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`) +# without explicit GRANTs. Leave unset today to preserve local behaviour. The implicit default +# flips to `false` on 2026-05-30 to match the new cloud default, and the field is removed in +# 2026-10-30 once the always-revoked behaviour is permanent. Set to `false` to opt in early. +# auto_expose_new_tables = false + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +# Uncomment to reject non-secure connections to the database. +# [db.ssl_enforcement] +# enabled = true + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Public bucket backing all avatar/hero/logo uploads (see +# src/components/upload-logo.tsx and editable-avatar.tsx). +[storage.buckets.avatars] +public = true + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +[storage.vector] +enabled = true +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://localhost:3000" +# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended. +# external_url = "" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["http://localhost:3000", "http://localhost:3000/auth/callback", "https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to auth.external_url. +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +# Configure passkey sign-ins. +# [auth.passkey] +# enabled = false + +# Configure WebAuthn relying party settings (required when passkey is enabled). +# [auth.webauthn] +# rp_display_name = "Supabase" +# rp_id = "localhost" +# rp_origins = ["http://127.0.0.1:3000"] + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ `{{ .Code }}` }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ `{{ .Code }}` }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth callback URL derived from auth.external_url. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +# Disabled: this app has no edge functions, and the runtime's boot probe +# fetches from deno.land which is blocked in restricted-network environments. +enabled = false +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" + +# [experimental.pgdelta] +# When enabled, pg-delta becomes the active engine for supported schema flows. +# enabled = false +# Directory under `supabase/` where declarative files are written. +# declarative_schema_path = "./database" +# JSON string passed through to pg-delta SQL formatting. +# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" diff --git a/supabase/migrations/00000000000000_local_base_schema.sql b/supabase/migrations/00000000000000_local_base_schema.sql new file mode 100644 index 00000000..931616cc --- /dev/null +++ b/supabase/migrations/00000000000000_local_base_schema.sql @@ -0,0 +1,562 @@ +-- LOCAL DEVELOPMENT BOOTSTRAP — reconstruction of the base schema that the +-- hosted Supabase project already has. The repo's dated migrations +-- (20260510_* onwards) only contain incremental changes and assume these +-- tables/functions/triggers pre-exist. This file recreates them so a fresh +-- `supabase start` / `supabase db reset` works locally. +-- +-- Safety: every statement is guarded (`if not exists` / existence checks in +-- DO blocks) so this file is a no-op when the objects already exist (e.g. if +-- it is ever pushed to the hosted project by mistake). It never replaces an +-- existing object. + +create extension if not exists pgcrypto; + +-- --------------------------------------------------------------------------- +-- pgmq + pgmq_public wrappers (normally installed by the hosted "Queues" +-- integration; see 20260515_plugin_scan_queue.sql which assumes they exist). +-- --------------------------------------------------------------------------- +create extension if not exists pgmq; + +create schema if not exists pgmq_public; +grant usage on schema pgmq_public to postgres, anon, authenticated, service_role; + +do $$ +begin + if not exists ( + select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'pgmq_public' and p.proname = 'send' + ) then + create function pgmq_public.send( + queue_name text, + message jsonb, + sleep_seconds integer default 0 + ) + returns setof bigint + language plpgsql + security definer + set search_path = '' + as $fn$ + begin + return query + select * from pgmq.send( + queue_name => send.queue_name, + msg => send.message, + delay => send.sleep_seconds + ); + end; + $fn$; + end if; + + if not exists ( + select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'pgmq_public' and p.proname = 'send_batch' + ) then + create function pgmq_public.send_batch( + queue_name text, + messages jsonb[], + sleep_seconds integer default 0 + ) + returns setof bigint + language plpgsql + security definer + set search_path = '' + as $fn$ + begin + return query + select * from pgmq.send_batch( + queue_name => send_batch.queue_name, + msgs => send_batch.messages, + delay => send_batch.sleep_seconds + ); + end; + $fn$; + end if; + + if not exists ( + select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'pgmq_public' and p.proname = 'read' + ) then + create function pgmq_public.read( + queue_name text, + sleep_seconds integer, + n integer + ) + returns setof pgmq.message_record + language plpgsql + security definer + set search_path = '' + as $fn$ + begin + return query + select * from pgmq.read( + queue_name => read.queue_name, + vt => read.sleep_seconds, + qty => read.n + ); + end; + $fn$; + end if; + + if not exists ( + select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'pgmq_public' and p.proname = 'pop' + ) then + create function pgmq_public.pop(queue_name text) + returns setof pgmq.message_record + language plpgsql + security definer + set search_path = '' + as $fn$ + begin + return query + select * from pgmq.pop(queue_name => pop.queue_name); + end; + $fn$; + end if; + + if not exists ( + select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'pgmq_public' and p.proname = 'archive' + ) then + create function pgmq_public.archive( + queue_name text, + message_id bigint + ) + returns boolean + language plpgsql + security definer + set search_path = '' + as $fn$ + begin + return pgmq.archive( + queue_name => archive.queue_name, + msg_id => archive.message_id + ); + end; + $fn$; + end if; + + if not exists ( + select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'pgmq_public' and p.proname = 'delete' + ) then + create function pgmq_public."delete"( + queue_name text, + message_id bigint + ) + returns boolean + language plpgsql + security definer + set search_path = '' + as $fn$ + begin + return pgmq."delete"( + queue_name => "delete".queue_name, + msg_id => "delete".message_id + ); + end; + $fn$; + end if; +end$$; + +-- 20260515_plugin_scan_queue.sql re-scopes these grants to service_role only. +grant execute on all functions in schema pgmq_public to service_role; + +-- --------------------------------------------------------------------------- +-- Tables +-- --------------------------------------------------------------------------- + +create table if not exists public.users ( + id uuid primary key references auth.users (id) on delete cascade, + name text not null default 'unknown user', + email text, + slug text not null unique, + image text, + hero text, + status text, + bio text, + work text, + website text, + social_x_link text, + public boolean not null default true, + follow_email boolean not null default true, + is_ambassador boolean not null default false, + is_following boolean not null default false, + follower_count integer not null default 0, + following_count integer not null default 0, + created_at timestamptz not null default now() +); + +create table if not exists public.companies ( + id text primary key, + name text not null, + slug text not null unique, + image text, + location text, + bio text, + website text, + social_x_link text, + hero text, + public boolean not null default true, + owner_id uuid references public.users (id) on delete cascade, + created_at timestamptz not null default now() +); + +create table if not exists public.plugins ( + id uuid primary key default gen_random_uuid(), + name text not null unique, + slug text not null unique, + version text not null default '1.0.0', + description text, + homepage text, + repository text, + license text, + logo text, + keywords text[] not null default '{}', + author_name text, + author_url text, + author_avatar text, + owner_id uuid not null references public.users (id) on delete cascade, + active boolean not null default false, + plan text not null default 'standard', + "order" integer not null default 0, + install_count integer not null default 0, + star_count integer not null default 0, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.plugin_components ( + id uuid primary key default gen_random_uuid(), + plugin_id uuid not null references public.plugins (id) on delete cascade, + type text not null check ( + type in ('rule', 'mcp_server', 'skill', 'agent', 'hook', 'lsp_server', 'command') + ), + name text not null, + slug text not null, + description text, + content text, + metadata jsonb not null default '{}'::jsonb, + sort_order integer not null default 0, + created_at timestamptz not null default now() +); + +-- No unique constraint on (plugin_id, slug): the hosted schema tolerates +-- duplicate component slugs (20260513 disambiguates data instead of +-- enforcing uniqueness). + +create table if not exists public.plugin_stars ( + id uuid primary key default gen_random_uuid(), + plugin_id uuid not null references public.plugins (id) on delete cascade, + user_id uuid not null references public.users (id) on delete cascade, + created_at timestamptz not null default now() +); + +create table if not exists public.followers ( + id uuid primary key default gen_random_uuid(), + follower_id uuid not null references public.users (id) on delete cascade, + following_id uuid not null references public.users (id) on delete cascade, + created_at timestamptz not null default now(), + unique (follower_id, following_id) +); + +create table if not exists public.mcps ( + id text primary key, + name text not null, + slug text not null unique, + description text, + link text, + logo text, + config jsonb, + company_id text references public.companies (id) on delete set null, + owner_id uuid references public.users (id) on delete set null, + active boolean not null default true, + plan text not null default 'standard', + "order" integer not null default 0, + created_at timestamptz not null default now(), + fts tsvector generated always as ( + to_tsvector('english', coalesce(name, '') || ' ' || coalesce(description, '')) + ) stored +); + +-- --------------------------------------------------------------------------- +-- Functions + triggers (only created when absent, never replaced) +-- --------------------------------------------------------------------------- + +do $$ +begin + -- Provision a public.users profile row when an auth user signs up. + if not exists ( + select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'handle_new_user' + ) then + create function public.handle_new_user() + returns trigger + language plpgsql + security definer + set search_path = public + as $fn$ + declare + v_name text := coalesce( + nullif(new.raw_user_meta_data ->> 'name', ''), + nullif(new.raw_user_meta_data ->> 'full_name', ''), + nullif(new.raw_user_meta_data ->> 'user_name', ''), + 'unknown user' + ); + v_slug text; + begin + v_slug := btrim( + regexp_replace( + lower(coalesce(nullif(v_name, 'unknown user'), split_part(new.email, '@', 1), 'user')), + '[^a-z0-9]+', '-', 'g' + ), + '-' + ); + if v_slug is null or v_slug = '' then + v_slug := 'user'; + end if; + if exists (select 1 from public.users u where u.slug = v_slug) then + v_slug := v_slug || '-' || substr(md5(random()::text), 1, 6); + end if; + + insert into public.users (id, name, email, slug, image) + values ( + new.id, + v_name, + new.email, + v_slug, + new.raw_user_meta_data ->> 'avatar_url' + ) + on conflict (id) do nothing; + + return new; + end; + $fn$; + end if; + + if not exists ( + select 1 from pg_trigger where tgname = 'on_auth_user_created' + ) then + create trigger on_auth_user_created + after insert on auth.users + for each row execute function public.handle_new_user(); + end if; + + -- Slug generation for plugins (app inserts without a slug). + if not exists ( + select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'generate_plugin_slug' + ) then + create function public.generate_plugin_slug() + returns trigger + language plpgsql + set search_path = public + as $fn$ + declare + v_slug text; + begin + if new.slug is not null and new.slug <> '' then + return new; + end if; + v_slug := btrim(regexp_replace(lower(new.name), '[^a-z0-9]+', '-', 'g'), '-'); + if v_slug is null or v_slug = '' then + v_slug := 'plugin'; + end if; + v_slug := left(v_slug, 80); + if exists (select 1 from public.plugins p where p.slug = v_slug) then + v_slug := left(v_slug, 73) || '-' || substr(md5(random()::text), 1, 6); + end if; + new.slug := v_slug; + return new; + end; + $fn$; + end if; + + if not exists ( + select 1 from pg_trigger where tgname = 'plugins_generate_slug' + ) then + create trigger plugins_generate_slug + before insert on public.plugins + for each row execute function public.generate_plugin_slug(); + end if; + + -- Slug generation for companies (referenced by src/actions/upsert-company.ts). + if not exists ( + select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'generate_company_slug' + ) then + create function public.generate_company_slug() + returns trigger + language plpgsql + set search_path = public + as $fn$ + declare + v_slug text; + begin + if new.slug is not null and new.slug <> '' then + return new; + end if; + v_slug := btrim(regexp_replace(lower(new.name), '[^a-z0-9]+', '-', 'g'), '-'); + if v_slug is null or v_slug = '' then + v_slug := 'company'; + end if; + if exists (select 1 from public.companies c where c.slug = v_slug) then + v_slug := v_slug || '-' || substr(md5(random()::text), 1, 6); + end if; + new.slug := v_slug; + return new; + end; + $fn$; + end if; + + if not exists ( + select 1 from pg_trigger where tgname = 'companies_generate_slug' + ) then + create trigger companies_generate_slug + before insert on public.companies + for each row execute function public.generate_company_slug(); + end if; + + -- Keep plugins.updated_at fresh (used by the recover-stuck-scans cron). + if not exists ( + select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'set_updated_at' + ) then + create function public.set_updated_at() + returns trigger + language plpgsql + as $fn$ + begin + new.updated_at := now(); + return new; + end; + $fn$; + end if; + + if not exists ( + select 1 from pg_trigger where tgname = 'plugins_set_updated_at' + ) then + create trigger plugins_set_updated_at + before update on public.plugins + for each row execute function public.set_updated_at(); + end if; + + -- Denormalized follower/following counts on public.users. + if not exists ( + select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'update_follow_counts' + ) then + create function public.update_follow_counts() + returns trigger + language plpgsql + security definer + set search_path = public + as $fn$ + begin + if tg_op = 'INSERT' then + update public.users set follower_count = follower_count + 1 + where id = new.following_id; + update public.users set following_count = following_count + 1 + where id = new.follower_id; + return new; + elsif tg_op = 'DELETE' then + update public.users set follower_count = greatest(follower_count - 1, 0) + where id = old.following_id; + update public.users set following_count = greatest(following_count - 1, 0) + where id = old.follower_id; + return old; + end if; + return null; + end; + $fn$; + end if; + + if not exists ( + select 1 from pg_trigger where tgname = 'followers_update_counts' + ) then + create trigger followers_update_counts + after insert or delete on public.followers + for each row execute function public.update_follow_counts(); + end if; + + -- Atomic install counter, called via .rpc("increment_install_count", ...). + if not exists ( + select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'increment_install_count' + ) then + create function public.increment_install_count(plugin_id_input uuid) + returns void + language sql + set search_path = public + as $fn$ + update public.plugins + set install_count = install_count + 1 + where id = plugin_id_input; + $fn$; + end if; + + -- Star counters; superseded (and dropped) by 20260523_plugin_star_atomic.sql. + if not exists ( + select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'increment_star_count' + ) then + create function public.increment_star_count(plugin_id_input uuid) + returns void + language sql + set search_path = public + as $fn$ + update public.plugins + set star_count = star_count + 1 + where id = plugin_id_input; + $fn$; + end if; + + if not exists ( + select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'decrement_star_count' + ) then + create function public.decrement_star_count(plugin_id_input uuid) + returns void + language sql + set search_path = public + as $fn$ + update public.plugins + set star_count = greatest(star_count - 1, 0) + where id = plugin_id_input; + $fn$; + end if; +end$$; + +-- --------------------------------------------------------------------------- +-- Storage: policies for the public `avatars` bucket (bucket itself is +-- declared in supabase/config.toml for local dev). +-- --------------------------------------------------------------------------- +do $$ +begin + if not exists ( + select 1 from pg_policies + where schemaname = 'storage' and tablename = 'objects' + and policyname = 'avatars_public_read' + ) then + create policy avatars_public_read on storage.objects + for select using (bucket_id = 'avatars'); + end if; + + if not exists ( + select 1 from pg_policies + where schemaname = 'storage' and tablename = 'objects' + and policyname = 'avatars_authenticated_insert' + ) then + create policy avatars_authenticated_insert on storage.objects + for insert to authenticated with check (bucket_id = 'avatars'); + end if; + + if not exists ( + select 1 from pg_policies + where schemaname = 'storage' and tablename = 'objects' + and policyname = 'avatars_authenticated_update' + ) then + create policy avatars_authenticated_update on storage.objects + for update to authenticated using (bucket_id = 'avatars'); + end if; +end$$; diff --git a/supabase/migrations/20260515_plugin_similar_search.sql b/supabase/migrations/20260516_plugin_similar_search.sql similarity index 100% rename from supabase/migrations/20260515_plugin_similar_search.sql rename to supabase/migrations/20260516_plugin_similar_search.sql diff --git a/supabase/migrations/20260526_companies_unique_name.sql b/supabase/migrations/20260527_companies_unique_name.sql similarity index 100% rename from supabase/migrations/20260526_companies_unique_name.sql rename to supabase/migrations/20260527_companies_unique_name.sql From 6a506e4dc99b2d1665b1738b9c6bb869c3d1aea5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 9 Jun 2026 15:30:27 +0000 Subject: [PATCH 2/2] fix(supabase): harden local base schema (RLS, install RPC, avatar storage) 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 --- .../00000000000000_local_base_schema.sql | 126 +++++++++++++++++- 1 file changed, 122 insertions(+), 4 deletions(-) diff --git a/supabase/migrations/00000000000000_local_base_schema.sql b/supabase/migrations/00000000000000_local_base_schema.sql index 931616cc..ff9ffcd6 100644 --- a/supabase/migrations/00000000000000_local_base_schema.sql +++ b/supabase/migrations/00000000000000_local_base_schema.sql @@ -528,8 +528,123 @@ begin 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 @@ -548,7 +663,8 @@ begin 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 @@ begin 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$$;