Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions scripts/agent-stack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
# project it starts is an overlay directory: symlinks to this checkout's
# migrations, tests, and templates, a copy of its functions (the stack does
# not find functions behind a symlink), and a copy of config.toml without the
# magic-link template block. Run `reset` after changing migrations and
# auth email template blocks. Run `reset` after changing migrations and
# `serve` again after changing functions.
#
# usage: scripts/agent-stack.sh [--name NAME] start|env|reset|test|advisors|psql|serve|stop|destroy
Expand Down Expand Up @@ -57,7 +57,7 @@ prepare_overlay() {
if [ -L "$dir/supabase/functions" ]; then rm "$dir/supabase/functions"; fi
mkdir -p "$dir/supabase/functions"
rsync -a --delete --exclude .env "$root/supabase/functions/" "$dir/supabase/functions/"
awk '/^\[/ { skip = ($0 == "[auth.email.template.magic_link]") } !skip' \
awk '/^\[/ { skip = ($0 ~ /^\[auth\.email\.template\./) } !skip' \
"$root/supabase/config.toml" > "$dir/supabase/config.toml"
}

Expand Down
2 changes: 2 additions & 0 deletions supabase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ supabase/functions/ Deno Edge Functions
supabase/functions/_shared shared auth, validation, prompts, research, and cost logic
supabase/migrations/ ordered database history
supabase/rollbacks/ down scripts for data migrations; never applied by db push
supabase/proposed/ destructive SQL awaiting authorization; never applied by db push
supabase/tests/ pgTAP database and policy tests
```

Expand Down Expand Up @@ -108,6 +109,7 @@ Do not use wildcard origins to hide a failed browser test. Before protected stag

Migration filenames define execution order. Never edit an applied migration to change production history; add a new forward migration.
When creating a table, sequence or function in `public`, grant only the Data API roles and operations it needs in the same migration, alongside RLS and its policies. Since `20261016120000_security_preship_hardening.sql`, tables and sequences that `postgres` creates in `public` grant `anon` and `authenticated` nothing by default, so a missing grant shows up as `permission denied`, not as an open table. Functions still get Postgres's global `PUBLIC EXECUTE`, so every new function needs its own `REVOKE ... FROM PUBLIC`. `supabase/tests/client_grants_test.sql` checks both.
Anything destructive (dropping a policy, function, index or column, deleting rows, or a constraint existing rows might break) goes in `supabase/proposed/` instead, with a header saying what it removes and what covers it; see [proposed/README.md](proposed/README.md).

Local gate:

Expand Down
87 changes: 87 additions & 0 deletions supabase/migrations/20261023060000_share_view_dedup.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
-- SC-04: get_shared_item counted every call, so reloading a share link
-- inflated its view count. Count each viewer at most once per share per UTC
-- day instead. The share page and the function's signature and result stay
-- the same. The number shown on the share page now means distinct viewers
-- per day, not page loads.
--
-- A viewer is the signed-in user, otherwise the client IP the API gateway
-- forwards. The stored key is a SHA-256 of share, day and viewer. It keeps
-- raw IPs out of the table, but it is not anonymous: with no secret in it,
-- an IPv4 address can be recovered by trying every address. Only server
-- roles can read the table, and each counted view deletes up to 500 rows
-- from earlier days. This stops reload inflation; it is not abuse
-- protection: a client that varies its IP still counts again, and viewers
-- behind one NAT address count once.

CREATE TABLE public.share_views (
share_id uuid NOT NULL REFERENCES public.shared_items(id) ON DELETE CASCADE,
viewed_on date NOT NULL,
viewer_key bytea NOT NULL,
PRIMARY KEY (share_id, viewed_on, viewer_key)
);
CREATE INDEX share_views_viewed_on_idx ON public.share_views (viewed_on);
ALTER TABLE public.share_views ENABLE ROW LEVEL SECURITY;
-- Only get_shared_item (SECURITY DEFINER) touches this table.
REVOKE ALL ON TABLE public.share_views FROM PUBLIC, anon, authenticated;

CREATE OR REPLACE FUNCTION public.get_shared_item(p_share_code text)
RETURNS TABLE (id uuid, share_code text, item_type text, item_data jsonb, title text, created_at timestamptz, view_count integer)
LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
#variable_conflict use_column
DECLARE
v_item public.shared_items%ROWTYPE;
v_today date := (now() AT TIME ZONE 'UTC')::date;
v_headers jsonb;
v_viewer text;
BEGIN
SELECT * INTO v_item FROM public.shared_items AS s
WHERE s.share_code = p_share_code
AND (s.expires_at IS NULL OR s.expires_at > now());
IF NOT FOUND THEN RETURN; END IF;

v_viewer := 'user:' || (SELECT auth.uid())::text;
IF v_viewer IS NULL THEN
-- PostgREST sets request.headers; it is unset for direct SQL callers,
-- which then share one 'unknown' viewer per share per day.
BEGIN
v_headers := nullif(current_setting('request.headers', true), '')::jsonb;
EXCEPTION WHEN others THEN
v_headers := NULL;
END;
-- On hosted Supabase, Cloudflare sets cf-connecting-ip and overwrites any
-- client value; the first x-forwarded-for entry is the fallback.
v_viewer := 'ip:' || coalesce(
nullif(btrim(v_headers ->> 'cf-connecting-ip'), ''),
nullif(btrim(split_part(v_headers ->> 'x-forwarded-for', ',', 1)), ''),
nullif(btrim(v_headers ->> 'x-real-ip'), ''),
'unknown'
);
END IF;

INSERT INTO public.share_views (share_id, viewed_on, viewer_key)
VALUES (
v_item.id,
v_today,
sha256(convert_to(v_item.id::text || ':' || to_char(v_today, 'YYYY-MM-DD') || ':' || v_viewer, 'UTF8'))
)
ON CONFLICT DO NOTHING;

IF FOUND THEN
UPDATE public.shared_items AS s
SET view_count = s.view_count + 1
WHERE s.id = v_item.id
RETURNING s.view_count INTO v_item.view_count;
-- Bounded, so a busy day never makes one viewer's request slow; the
-- next counted views delete the rest.
DELETE FROM public.share_views AS v
WHERE v.ctid = ANY (ARRAY(
SELECT o.ctid FROM public.share_views AS o WHERE o.viewed_on < v_today LIMIT 500
));
END IF;

RETURN QUERY SELECT v_item.id, v_item.share_code, v_item.item_type,
v_item.item_data, v_item.title, v_item.created_at, v_item.view_count;
END;
$$;
REVOKE ALL ON FUNCTION public.get_shared_item(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.get_shared_item(text) TO anon, authenticated;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- SC-08: roles are written only by the admin and demo-code-login Edge
-- Functions (service role). Clients only read their own row
-- (SettingsPage). Without write grants, a permissive policy left on a hosted
-- project (DB-03) can no longer let a user change their own role.
REVOKE INSERT, UPDATE, DELETE ON TABLE public.user_roles FROM anon, authenticated;
REVOKE SELECT ON TABLE public.user_roles FROM anon;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- SC-08: subscriptions are written only by the Stripe webhook and checkout
-- Edge Functions (service role). Clients read their own rows
-- (use-subscription.ts, settings-store.ts).
REVOKE INSERT, UPDATE, DELETE ON TABLE public.subscriptions FROM anon, authenticated;
REVOKE SELECT ON TABLE public.subscriptions FROM anon;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- SC-08: redemptions are written by redeem_promo_code_atomic (SECURITY
-- DEFINER) and read by create-checkout (service role). Signed-in clients keep
-- read access to their own rows.
REVOKE INSERT, UPDATE, DELETE ON TABLE public.user_promos FROM anon, authenticated;
REVOKE SELECT ON TABLE public.user_promos FROM anon;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- SC-08: admin grants are written only by the admin Edge Function (service
-- role). Signed-in clients keep the admin-only read policy.
REVOKE INSERT, UPDATE, DELETE ON TABLE public.admin_grants FROM anon, authenticated;
REVOKE SELECT ON TABLE public.admin_grants FROM anon;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- SC-08: promo codes are read and written by the admin and create-checkout
-- Edge Functions (service role) and redeem_promo_code_atomic (SECURITY
-- DEFINER). No client reads them, and RLS has no policy for them.
REVOKE ALL ON TABLE public.promo_codes FROM anon, authenticated;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- SC-08: usage counters are written by the quota RPCs (service role) and read
-- by clients only through ai_usage_today() (SECURITY DEFINER). RLS has no
-- policy for them.
REVOKE ALL ON TABLE public.daily_usage_counters FROM anon, authenticated;
38 changes: 38 additions & 0 deletions supabase/migrations/20261023060700_index_foreign_keys.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
-- DB-05: cover foreign keys that had no index starting with their columns.
-- Without one, deleting a user (auth.users cascades and SET NULLs), a source,
-- a promo code, a share or a workspace document scans the whole child table.
-- Skipped: global_prompts, promo_codes and curated_sources created_by, which
-- reference admin authors on tables with a handful of rows, and the
-- (workspace_id, document_id) keys of private.workspace_document_state and
-- private.workspace_document_updates, whose primary keys already lead with
-- document_id (the advisor still lists those two).
--
-- Plain CREATE INDEX: the CLI runs each migration file in a transaction, so
-- CONCURRENTLY is not available. Each build blocks writes to its table until
-- the whole file commits; every table here is small or, for the hidden
-- workspace feature, empty in production.

-- squawk-ignore-file require-concurrent-index-creation

CREATE INDEX IF NOT EXISTS source_files_source_id_idx ON public.source_files (source_id);
CREATE INDEX IF NOT EXISTS source_files_user_id_idx ON public.source_files (user_id);
CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON public.api_keys (user_id);
CREATE INDEX IF NOT EXISTS user_promos_promo_code_id_idx ON public.user_promos (promo_code_id);
CREATE INDEX IF NOT EXISTS shared_items_created_by_idx ON public.shared_items (created_by);
CREATE INDEX IF NOT EXISTS demo_access_codes_created_by_idx ON public.demo_access_codes (created_by);

CREATE INDEX IF NOT EXISTS workspace_audit_events_version_id_idx ON public.workspace_audit_events (version_id);
CREATE INDEX IF NOT EXISTS workspace_document_updates_author_id_idx ON private.workspace_document_updates (author_id);
CREATE INDEX IF NOT EXISTS workspace_versions_created_by_idx ON public.workspace_versions (created_by);
CREATE INDEX IF NOT EXISTS workspace_proposals_author_id_idx ON public.workspace_proposals (author_id);
CREATE INDEX IF NOT EXISTS workspace_comments_author_id_idx ON public.workspace_comments (author_id);
CREATE INDEX IF NOT EXISTS workspace_comments_resolved_by_idx ON public.workspace_comments (resolved_by);
CREATE INDEX IF NOT EXISTS workspace_comment_replies_author_id_idx ON public.workspace_comment_replies (author_id);
CREATE INDEX IF NOT EXISTS workspace_comment_replies_workspace_id_idx ON public.workspace_comment_replies (workspace_id);
CREATE INDEX IF NOT EXISTS workspace_notifications_actor_id_idx ON public.workspace_notifications (actor_id);
CREATE INDEX IF NOT EXISTS workspace_notifications_document_idx ON public.workspace_notifications (workspace_id, document_id);
CREATE INDEX IF NOT EXISTS workspace_sources_created_by_idx ON public.workspace_sources (created_by);
CREATE INDEX IF NOT EXISTS workspace_threads_created_by_idx ON public.workspace_threads (created_by);
CREATE INDEX IF NOT EXISTS workspace_thread_messages_author_id_idx ON public.workspace_thread_messages (author_id);
CREATE INDEX IF NOT EXISTS workspace_thread_messages_thread_idx ON public.workspace_thread_messages (workspace_id, thread_id);
CREATE INDEX IF NOT EXISTS workspace_artifacts_document_idx ON public.workspace_artifacts (workspace_id, document_id);
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- SC-08: ai_requests predates the closed default privileges, so anon kept
-- SELECT. No signed-out client reads it (use-subscription.ts runs signed in),
-- and a permissive SELECT policy left on a hosted project (DB-03) would
-- otherwise expose usage rows to anyone with the anon key.
REVOKE SELECT ON TABLE public.ai_requests FROM anon;
68 changes: 68 additions & 0 deletions supabase/proposed/20261023060000_drop_legacy_policies.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
-- DB-03 (proposed, destructive): drop policies that no migration created.
--
-- The QA run on 22 Oct 2026 found policies on production that are not in
-- staging or in supabase/migrations, including duplicate permissive ones on
-- shared_items, user_roles and ai_requests, and two auth_rls_initplan
-- warnings on shared_items. The repo intends exactly these policies:
--
-- shared_items none. Clients have no table grants; reads go through
-- get_shared_item(code) and writes through the share Edge
-- Function (20260819000000_security_hardening.sql).
-- user_roles "user_roles_select_own" (SELECT own row).
-- ai_requests "ai_requests_select_own" (SELECT own rows).
--
-- Write policies on these tables are inert: clients hold no INSERT, UPDATE
-- or DELETE grant on them (user_roles from 20261023060100), and no grant at
-- all on shared_items. SELECT policies are not: signed-in users keep SELECT
-- on user_roles and ai_requests, so a legacy permissive SELECT policy there
-- is live until this runs (anon lost SELECT in 20261023060100 and
-- 20261023060800). Read the NOTICE lines with that in mind.
--
-- The policy names were in the QA snapshot, which is not in the repo, so
-- this drops by exclusion and prints each definition first. It stops before
-- dropping anything unless both kept policies exist and read only the
-- caller's own rows: without user_roles_select_own, signed-in users could no
-- longer read their own role (SettingsPage and the curated_sources admin
-- policies depend on it), and a drifted copy would keep the leak this fixes.
-- Do a dry run first (BEGIN; ...; ROLLBACK;).

DO $$
DECLARE
p record;
BEGIN
-- The kept policies must exist and read only the caller's own rows, as
-- 20260819000000 and 20261016120000 define them.
FOR p IN
SELECT k.tablename, k.policyname, pol.cmd, pol.permissive, pol.qual
FROM (VALUES ('user_roles', 'user_roles_select_own'),
('ai_requests', 'ai_requests_select_own')) AS k(tablename, policyname)
LEFT JOIN pg_policies AS pol
ON pol.schemaname = 'public' AND pol.tablename = k.tablename AND pol.policyname = k.policyname
LOOP
RAISE NOTICE 'keeping policy % on %: % % USING (%)', p.policyname, p.tablename, p.permissive, p.cmd, p.qual;
IF p.cmd IS DISTINCT FROM 'SELECT'
OR p.qual IS DISTINCT FROM '(user_id = ( SELECT auth.uid() AS uid))' THEN
RAISE EXCEPTION 'policy % on % is missing or not own-rows SELECT; fix it before dropping the others',
p.policyname, p.tablename;
END IF;
END LOOP;

FOR p IN
SELECT pol.polname, cls.relname,
pg_get_expr(pol.polqual, pol.polrelid) AS using_expr,
pg_get_expr(pol.polwithcheck, pol.polrelid) AS check_expr
FROM pg_policy AS pol
JOIN pg_class AS cls ON cls.oid = pol.polrelid
WHERE cls.relnamespace = 'public'::regnamespace
AND (
cls.relname = 'shared_items'
OR (cls.relname = 'user_roles' AND pol.polname <> 'user_roles_select_own')
OR (cls.relname = 'ai_requests' AND pol.polname <> 'ai_requests_select_own')
)
LOOP
RAISE NOTICE 'dropping policy % on %: USING (%) WITH CHECK (%)',
p.polname, p.relname, p.using_expr, p.check_expr;
EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', p.polname, p.relname);
END LOOP;
END;
$$;
15 changes: 15 additions & 0 deletions supabase/proposed/20261023060100_drop_staging_auto_admin.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- DB-04 (proposed, destructive, STAGING ONLY: brifeyypjauyusmkptkx).
--
-- staging_auto_admin() is left over from a trigger that was dropped by hand
-- on staging. It does not exist on production or anywhere in
-- supabase/migrations, so it must never be added to migrations. Run this by
-- hand in the staging SQL editor. Check first that no trigger still uses it:
--
-- SELECT tgname, tgrelid::regclass FROM pg_trigger t
-- JOIN pg_proc p ON p.oid = t.tgfoid WHERE p.proname = 'staging_auto_admin';
--
-- That query must return no rows; without CASCADE the drop fails otherwise.
-- `\df public.staging_auto_admin` should show it with no arguments; if it
-- takes any, add them to the DROP below, or IF EXISTS skips it silently.

DROP FUNCTION IF EXISTS public.staging_auto_admin();
55 changes: 55 additions & 0 deletions supabase/proposed/20261023060200_drop_duplicate_indexes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
-- DB-05 (proposed, destructive): drop indexes that duplicate another index
-- on the same table and column.
--
-- subscriptions idx_subscriptions_stripe_customer duplicates
-- idx_subscriptions_stripe_customer_id (both plain
-- btree on stripe_customer_id); the QA advisor finding.
-- subscriptions idx_subscriptions_stripe_sub duplicates the unique
-- constraint index subscriptions_stripe_subscription_id_key.
-- promo_codes idx_promo_codes_code duplicates promo_codes_code_key.
-- global_prompts idx_global_prompts_feature duplicates global_prompts_feature_key.
-- user_roles idx_user_roles_user_id duplicates user_roles_user_id_key.
-- demo_access_codes demo_access_codes_email_idx duplicates demo_access_codes_email_key.
--
-- Each drop runs only if the index that stays exists, is valid, is unique
-- whenever the dropped one is, and has the same columns, operator classes,
-- collations and no expression or predicate. A project whose indexes differ
-- keeps both. Run it outside
-- peak hours: DROP INDEX briefly locks its table.

DO $$
DECLARE
pair record;
BEGIN
FOR pair IN
SELECT * FROM (VALUES
('idx_subscriptions_stripe_customer', 'idx_subscriptions_stripe_customer_id'),
('idx_subscriptions_stripe_sub', 'subscriptions_stripe_subscription_id_key'),
('idx_promo_codes_code', 'promo_codes_code_key'),
('idx_global_prompts_feature', 'global_prompts_feature_key'),
('idx_user_roles_user_id', 'user_roles_user_id_key'),
('demo_access_codes_email_idx', 'demo_access_codes_email_key')
) AS v(dropped, kept)
LOOP
IF EXISTS (
SELECT 1
FROM pg_index AS d
JOIN pg_index AS k ON k.indrelid = d.indrelid
WHERE d.indexrelid = to_regclass('public.' || pair.dropped)
AND k.indexrelid = to_regclass('public.' || pair.kept)
AND k.indisvalid
AND (NOT d.indisunique OR k.indisunique)
AND d.indkey::text = k.indkey::text
AND d.indclass::text = k.indclass::text
AND d.indcollation::text = k.indcollation::text
AND d.indexprs IS NULL AND k.indexprs IS NULL
AND d.indpred IS NULL AND k.indpred IS NULL
) THEN
RAISE NOTICE 'dropping % (kept: %)', pair.dropped, pair.kept;
EXECUTE format('DROP INDEX IF EXISTS public.%I', pair.dropped);
ELSE
RAISE NOTICE 'keeping %: % is missing or covers other columns', pair.dropped, pair.kept;
END IF;
END LOOP;
END;
$$;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- DB-05 (proposed): give private.promo_redeem_failures a primary key.
--
-- It is the one table without a primary key. Its natural columns
-- (user_id, failed_at) can repeat when two failures share a timestamp, so a
-- key on them could fail on existing rows. A surrogate identity column cannot
-- fail: it fills every existing row. redeem_promo_code_atomic inserts only
-- user_id, so it needs no change. The table is rewritten while it holds an
-- ACCESS EXCLUSIVE lock; it holds only failed promo attempts, pruned per user
-- after a day, so that is brief. If the table already has an id column,
-- IF NOT EXISTS skips the whole statement, primary key included; check with
-- `\d private.promo_redeem_failures` first.

ALTER TABLE private.promo_redeem_failures
ADD COLUMN IF NOT EXISTS id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY;
5 changes: 5 additions & 0 deletions supabase/proposed/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Proposed destructive SQL

**Status:** Proposals only. Nothing in this directory is applied by `supabase db push`, `db reset` or CI.

Each file here drops or constrains something that already exists on a hosted project, so it needs Adam's explicit authorization for each environment. To apply one, check the target project first, read the file's header, and run it by hand in the SQL editor (or copy it into `supabase/migrations/` under a new timestamp if it should become history). Every statement is guarded with `IF EXISTS` or a catalog check, so running a file twice changes nothing the second time.
Loading
Loading