@nextblock-cms/db 0.22.1 → 0.22.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +47 -47
  2. package/lib/media-actions.cjs.js +1 -1
  3. package/lib/media-actions.d.ts +13 -35
  4. package/lib/media-actions.es.js +25 -49
  5. package/lib/media-actions.test.d.ts +1 -0
  6. package/lib/media-record.cjs.js +1 -0
  7. package/lib/media-record.d.ts +46 -0
  8. package/lib/media-record.es.js +47 -0
  9. package/lib/package-validation.cjs.js +1 -1
  10. package/lib/package-validation.es.js +1 -1
  11. package/lib/secret-crypto.cjs.js +1 -1
  12. package/lib/secret-crypto.es.js +1 -0
  13. package/lib/supabase/server.cjs.js +1 -1
  14. package/lib/supabase/server.es.js +1 -0
  15. package/package.json +1 -1
  16. package/server.cjs.js +1 -1
  17. package/server.d.ts +1 -0
  18. package/server.es.js +6 -5
  19. package/supabase/migrations/02000_catchup_gen1.sql +5880 -5880
  20. package/supabase/migrations/02001_baseline_schema.sql +2734 -2734
  21. package/supabase/migrations/02002_baseline_constraints_and_indexes.sql +1262 -1262
  22. package/supabase/migrations/02003_baseline_security_and_grants.sql +1577 -1577
  23. package/supabase/migrations/02004_baseline_seed.sql +565 -565
  24. package/supabase/migrations/02005_home_live_demo_promo_cadence_15min.sql +53 -53
  25. package/supabase/migrations/02006_home_marketing_contrast.sql +56 -56
  26. package/supabase/migrations/02007_home_pillar_caption_contrast.sql +44 -44
  27. package/supabase/migrations/02008_home_fr_parity.sql +89 -89
  28. package/supabase/migrations/02009_fr_cortex_posts.sql +67 -67
  29. package/supabase/migrations/02010_shop_hero_redesign.sql +72 -72
  30. package/supabase/migrations/02011_drop_backdrop_blur_on_pages.sql +26 -26
  31. package/supabase/migrations/02012_drop_variant_backdrop_blur.sql +17 -17
  32. package/supabase/migrations/02013_advisor_rls_and_trigger_grants.sql +102 -102
  33. package/supabase/migrations/02014_package_activations_staff_read.sql +18 -18
  34. package/supabase/migrations/02018_install_paths_and_seed_copy_accuracy.sql +1820 -1820
  35. package/supabase/migrations/02019_open_handed_licensing_and_mcp_clients.sql +448 -448
  36. package/supabase/migrations/02020_commerce_product_accuracy.sql +93 -93
  37. package/supabase/migrations/02021_profiles_staff_read_and_role_guard.sql +362 -362
  38. package/supabase/migrations/02022_security_hardening_writer_boundary_and_aal.sql +504 -504
  39. package/supabase/migrations/02024_advisor_trigger_grants_and_pg_net_schema.sql +113 -0
  40. package/supabase/migrations/02025_api_role_grant_hardening.sql +66 -0
  41. package/supabase/migrations/02026_authenticated_write_grant_hardening.sql +91 -0
  42. package/supabase/migrations/02027_api_role_select_grant_hardening.sql +150 -0
  43. package/supabase/migrations/02028_writer_settings_allowlist_email_factor_aal_and_media_keys.sql +284 -0
@@ -0,0 +1,113 @@
1
+ -- 02024: clear the Supabase Security Advisor warnings that came back after the 02022/02023
2
+ -- deploys, and move a `pg_net` that was installed in `public` into `extensions`.
3
+ --
4
+ -- Why the SECURITY DEFINER warnings (lints 0028/0029) returned
5
+ -- 02013 revoked anon/authenticated EXECUTE on the two SECURITY DEFINER trigger functions
6
+ -- (handle_new_user(), update_product_ratings()) and the advisor was clean. But every
7
+ -- production build runs apps/nextblock/tools/build-migrate.mjs, whose migrate-core.mjs
8
+ -- ends with a blanket `grant execute on all functions in schema public to anon,
9
+ -- authenticated, service_role` and, unlike lib/setup/schema-apply.ts (the /setup path),
10
+ -- had no pass taking that EXECUTE back off trigger functions or re-applying the API-role
11
+ -- revokes of 02021/02022. So each deploy silently handed the privilege back. The hook is
12
+ -- fixed in the same change (and a test now keeps the two files' SQL identical); this
13
+ -- migration repairs the databases that were already re-granted. Postgres checks EXECUTE
14
+ -- on a trigger function only at CREATE TRIGGER time, never when the trigger fires, and
15
+ -- PostgREST cannot call a `RETURNS trigger` function via /rpc, so nothing loses access.
16
+ --
17
+ -- Why `pg_net` (lint 0014, "Extension in Public")
18
+ -- A bare `create extension pg_net` lands in `public` (the first search_path entry), which
19
+ -- exposes its objects through the API schema. pg_net is NOT relocatable, so the lint's
20
+ -- `alter extension … set schema` is refused; the only way out is drop + re-create WITH
21
+ -- SCHEMA extensions. Its own objects live in the `net` schema either way; only the
22
+ -- extension's registration moves, and the request queue / response log are transient.
23
+ -- pg_cron job commands are plain text (no dependency), so a scheduled `net.http_get`
24
+ -- keeps working. Guarded: a database without pg_net, or with it already outside
25
+ -- `public`, is untouched. Same statement tools/scripts/schedule-sandbox-reset.js runs.
26
+ --
27
+ -- Everything here is idempotent and safe on a fresh install, the sandbox replay and a
28
+ -- /setup-installed database. The two Auth-dashboard warnings (leaked password protection,
29
+ -- MFA options) are project settings, not schema, and are not addressed here.
30
+
31
+ -- ---------------------------------------------------------------------------
32
+ -- 1. Trigger functions: the API roles never need EXECUTE
33
+ -- (identical to the post-apply pass in migrate-core.mjs / schema-apply.ts)
34
+ -- ---------------------------------------------------------------------------
35
+ do $nb_trg$
36
+ declare r record;
37
+ begin
38
+ for r in
39
+ select p.oid::regprocedure as sig
40
+ from pg_proc p
41
+ join pg_type t on t.oid = p.prorettype
42
+ where p.pronamespace = 'public'::regnamespace and t.typname = 'trigger'
43
+ loop
44
+ execute format('revoke execute on function %s from public, anon, authenticated', r.sig);
45
+ end loop;
46
+ end $nb_trg$;
47
+
48
+ -- The two the advisor names, stated explicitly so the intent survives a grep (02013 parity).
49
+ REVOKE EXECUTE ON FUNCTION public.handle_new_user() FROM PUBLIC, anon, authenticated;
50
+ GRANT EXECUTE ON FUNCTION public.handle_new_user() TO service_role;
51
+
52
+ REVOKE EXECUTE ON FUNCTION public.update_product_ratings() FROM PUBLIC, anon, authenticated;
53
+ GRANT EXECUTE ON FUNCTION public.update_product_ratings() TO service_role;
54
+
55
+ -- ---------------------------------------------------------------------------
56
+ -- 2. The API-role revokes of 02021 / 02022 that the same blanket grant handed back
57
+ -- (identical to API_ROLE_REVOKE_SQL in migrate-core.mjs / schema-apply.ts)
58
+ -- ---------------------------------------------------------------------------
59
+ do $nb_api$
60
+ begin
61
+ revoke truncate on all tables in schema public from anon, authenticated;
62
+ if to_regprocedure('public.generate_order_invoice_number()') is not null then
63
+ revoke execute on function public.generate_order_invoice_number() from public, anon, authenticated;
64
+ end if;
65
+ if to_regprocedure('public.assign_order_invoice_metadata(uuid, timestamp with time zone)') is not null then
66
+ revoke execute on function public.assign_order_invoice_metadata(uuid, timestamp with time zone) from public, anon, authenticated;
67
+ end if;
68
+ if to_regclass('public.order_invoice_number_seq') is not null then
69
+ revoke all on sequence public.order_invoice_number_seq from anon, authenticated;
70
+ end if;
71
+ if to_regprocedure('public.upsert_product_with_variants(jsonb)') is not null then
72
+ revoke execute on function public.upsert_product_with_variants(jsonb) from public, anon;
73
+ end if;
74
+ if to_regprocedure('public.bump_email_2fa_attempts(uuid)') is not null then
75
+ revoke execute on function public.bump_email_2fa_attempts(uuid) from public, anon, authenticated;
76
+ end if;
77
+ if to_regprocedure('public.redeem_coupon_atomically(uuid)') is not null then
78
+ revoke execute on function public.redeem_coupon_atomically(uuid) from public, anon, authenticated;
79
+ end if;
80
+ end $nb_api$;
81
+
82
+ -- ---------------------------------------------------------------------------
83
+ -- 3. pg_net out of `public` (not relocatable: drop + re-create WITH SCHEMA extensions)
84
+ -- ---------------------------------------------------------------------------
85
+ do $nb_pg_net$
86
+ declare
87
+ v_version text;
88
+ begin
89
+ select e.extversion
90
+ into v_version
91
+ from pg_extension e
92
+ where e.extname = 'pg_net'
93
+ and e.extnamespace = 'public'::regnamespace;
94
+
95
+ if v_version is null then
96
+ return; -- not installed, or already registered outside public
97
+ end if;
98
+
99
+ if to_regnamespace('extensions') is null then
100
+ raise notice '02024: pg_net % is registered in schema public and this database has no extensions schema; reinstall it by hand with `create extension pg_net with schema <schema>`', v_version;
101
+ return;
102
+ end if;
103
+
104
+ -- The pg_net worker holds net.http_request_queue while a request is in flight. Fail fast
105
+ -- rather than queue behind it; re-running the migration is cheap.
106
+ perform set_config('lock_timeout', '15s', true);
107
+
108
+ drop extension pg_net;
109
+ create extension pg_net with schema extensions;
110
+
111
+ raise notice '02024: pg_net re-registered in schema extensions (was % in public, now %)',
112
+ v_version, (select extversion from pg_extension where extname = 'pg_net');
113
+ end $nb_pg_net$;
@@ -0,0 +1,66 @@
1
+ -- 02025: grant-level hardening for the API roles (2026-10-21 posture review of 02021-02024).
2
+ --
3
+ -- Grants decide whether a role may touch a table at all; RLS decides which rows. This file
4
+ -- removes three places where RLS had become the only barrier. It changes no row a legitimate
5
+ -- request can reach: every statement below takes away a privilege that no RLS policy lets the
6
+ -- role use.
7
+ --
8
+ -- 1. anon is read-only at the grant level.
9
+ -- Supabase's historical default privileges, and the baseline (02003), granted anon
10
+ -- INSERT/UPDATE/DELETE/REFERENCES/TRIGGER on every table in `public`. No policy in the
11
+ -- schema lets anon write anything (checked against pg_policies on 2026-10-21: zero
12
+ -- INSERT/UPDATE/DELETE/ALL policies reach anon or PUBLIC), so those grants only meant that
13
+ -- a single permissive policy added by mistake would publish a table for writing to anyone
14
+ -- with the public key. Every anonymous write path in the app (forms, consent, comments,
15
+ -- checkout) already goes through a server route with the service role. Sequence USAGE and
16
+ -- UPDATE go too: without INSERT, anon never draws a value.
17
+ --
18
+ -- 2. cortex_request_log is service-role only again.
19
+ -- 02022 created it with REVOKE ALL FROM anon, authenticated and no API policy. The
20
+ -- post-apply pass of both apply paths (lib/setup/schema-apply.ts and the build hook in
21
+ -- tools/lib/migrate-core.mjs) then ran `grant all on all tables ... to authenticated` and
22
+ -- `grant select ... to anon` on every deploy, handing the table back to both roles. RLS
23
+ -- still returned no row, but a signed-in WRITER could issue DELETEs against the counter
24
+ -- that rate-limits the AI routes, and tools/scripts/security-verify.sql failed three checks
25
+ -- on any deployed database. Both apply paths now repeat this revoke (API_ROLE_REVOKE_SQL).
26
+ --
27
+ -- 3. profiles.role is pinned by grants, not only by policy and trigger.
28
+ -- The column was protected by profiles_update_policy's WITH CHECK and the
29
+ -- protect_profile_role trigger (02021). authenticated now holds INSERT and UPDATE on every
30
+ -- profiles column EXCEPT `role` (and `id` on UPDATE), so a policy regression can no longer
31
+ -- reopen self-promotion to ADMIN. User-session writes only ever touch full_name,
32
+ -- avatar_url, website, phone and updated_at (account page, checkout autofill); role changes
33
+ -- go through the service role in app/cms/users/actions.ts, which these grants do not
34
+ -- affect. DELETE, REFERENCES and TRIGGER on profiles are revoked from the API roles: no
35
+ -- policy grants DELETE to them and account deletion runs through auth.admin. The column
36
+ -- list is read from the catalog so a column added later keeps today's behaviour.
37
+ --
38
+ -- Both apply paths repeat all three after their blanket grants, so a deploy cannot undo
39
+ -- them; tools/scripts/security-verify.sql asserts them. Idempotent: safe to re-run.
40
+
41
+ -- 1. anon: read-only at the grant level
42
+ REVOKE INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER ON ALL TABLES IN SCHEMA public FROM anon;
43
+ REVOKE USAGE, UPDATE ON ALL SEQUENCES IN SCHEMA public FROM anon;
44
+
45
+ -- 2. cortex_request_log: service_role only (its SELECT, INSERT, DELETE grant from 02022 stays)
46
+ REVOKE ALL ON TABLE public.cortex_request_log FROM PUBLIC, anon, authenticated;
47
+
48
+ -- 3. profiles: every column but `role` (and `id` on UPDATE) for authenticated
49
+ DO $nb_profiles$
50
+ DECLARE
51
+ v_insert_cols text;
52
+ v_update_cols text;
53
+ BEGIN
54
+ SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY a.attnum) FILTER (WHERE a.attname <> 'role'),
55
+ string_agg(quote_ident(a.attname), ', ' ORDER BY a.attnum) FILTER (WHERE a.attname NOT IN ('id', 'role'))
56
+ INTO v_insert_cols, v_update_cols
57
+ FROM pg_attribute a
58
+ WHERE a.attrelid = 'public.profiles'::regclass AND a.attnum > 0 AND NOT a.attisdropped;
59
+
60
+ -- Revoking the table-level privilege also revokes every column-level grant of that kind,
61
+ -- so the column grants below are the complete list afterwards.
62
+ REVOKE INSERT, UPDATE, DELETE, REFERENCES, TRIGGER ON TABLE public.profiles FROM PUBLIC, anon, authenticated;
63
+ EXECUTE format('GRANT INSERT (%s) ON TABLE public.profiles TO authenticated', v_insert_cols);
64
+ EXECUTE format('GRANT UPDATE (%s) ON TABLE public.profiles TO authenticated', v_update_cols);
65
+ END
66
+ $nb_profiles$;
@@ -0,0 +1,91 @@
1
+ -- 02026: authenticated holds no table write privilege that no RLS policy lets it use
2
+ -- (2026-10-21, the follow-up 02025 left open).
3
+ --
4
+ -- 02025 made anon read-only at the grant level. authenticated still held SELECT, INSERT,
5
+ -- UPDATE, DELETE, REFERENCES and TRIGGER on every table in `public` (the baseline 02003, then
6
+ -- `grant all on all tables in schema public to authenticated` in the post-apply pass on every
7
+ -- deploy). On the tables below RLS was the only barrier between a signed-in session and a
8
+ -- write: no permissive policy for authenticated or PUBLIC covers the listed commands, only a
9
+ -- `TO service_role` policy or none at all. Every app write to them runs server-side with the
10
+ -- service role after its own checks: 2FA challenges, trusted devices and security settings
11
+ -- (lib/auth, settings/security), consent logs, contact-seller enquiries and message threads,
12
+ -- UCP carts, coupon redemptions (checkout providers), license activations (lib/packages),
13
+ -- the Freemius catalog sync, the update checker's alerts and Cortex's mutation audit.
14
+ --
15
+ -- Behaviour-neutral by construction: authenticated could never use a revoked privilege. Its
16
+ -- INSERT failed the RLS check and its UPDATE or DELETE matched no row; now the grant refuses
17
+ -- the statement before RLS is consulted, and no row any request reaches changes. (One CMS
18
+ -- action, updateFreemiusOverride in libs/ecommerce, updates freemius_pricing through the
19
+ -- cookie client: RLS has always discarded that UPDATE while the action reported success, and
20
+ -- it now reports the permission error instead.) Every command that has a policy keeps its
21
+ -- grant: the ADMIN UPDATE on message_threads, product_inquiries and system_alerts, the ADMIN
22
+ -- INSERT on site_script_revisions, the owner DELETE on user_trusted_devices, and every SELECT.
23
+ -- The pre-flight block re-checks that against pg_policies on the target database and aborts
24
+ -- without changing anything if a policy has appeared since this list was taken (a replay
25
+ -- through 02025 plus the post-apply pass, 2026-10-21).
26
+ --
27
+ -- REFERENCES and TRIGGER go from every table, as 02025 did for anon: they only let a role
28
+ -- create a foreign key or a trigger, and no API request runs DDL. Foreign-key actions are not
29
+ -- affected by any of this: ON DELETE CASCADE / SET NULL run as the owner of the referencing
30
+ -- table, so an ADMIN deleting a product still removes its freemius_plans and freemius_pricing
31
+ -- rows, and deleting an order still removes its coupon_redemptions.
32
+ --
33
+ -- Both apply paths repeat these revokes after their blanket grants (API_ROLE_REVOKE_SQL). To
34
+ -- give authenticated one of these privileges later, add the policy and the GRANT in the same
35
+ -- migration and take the table out of both copies of that list, or the next deploy revokes it
36
+ -- again. tools/scripts/security-verify.sql sections 26-27 fail on a write grant no policy uses
37
+ -- and on a write policy whose grant is missing. Idempotent: safe to re-run.
38
+
39
+ DO $nb_preflight$
40
+ DECLARE
41
+ offenders text;
42
+ BEGIN
43
+ SELECT string_agg(format('%s %s (policy %s)', l.tbl, r.cmd, p.policyname), ', ' ORDER BY l.tbl, r.cmd, p.policyname)
44
+ INTO offenders
45
+ FROM (VALUES
46
+ ('cortex_ai_db_mutation_audit', ARRAY['INSERT', 'UPDATE', 'DELETE']),
47
+ ('coupon_redemptions', ARRAY['INSERT', 'UPDATE', 'DELETE']),
48
+ ('email_2fa_challenges', ARRAY['INSERT', 'UPDATE', 'DELETE']),
49
+ ('freemius_plans', ARRAY['INSERT', 'UPDATE', 'DELETE']),
50
+ ('freemius_pricing', ARRAY['INSERT', 'UPDATE', 'DELETE']),
51
+ ('package_activations', ARRAY['INSERT', 'UPDATE', 'DELETE']),
52
+ ('privacy_consent_logs', ARRAY['INSERT', 'UPDATE', 'DELETE']),
53
+ ('thread_messages', ARRAY['INSERT', 'UPDATE', 'DELETE']),
54
+ ('ucp_cart_sessions', ARRAY['INSERT', 'UPDATE', 'DELETE']),
55
+ ('user_security_settings', ARRAY['INSERT', 'UPDATE', 'DELETE']),
56
+ ('message_threads', ARRAY['INSERT', 'DELETE']),
57
+ ('product_inquiries', ARRAY['INSERT', 'DELETE']),
58
+ ('system_alerts', ARRAY['INSERT', 'DELETE']),
59
+ ('site_script_revisions', ARRAY['UPDATE', 'DELETE']),
60
+ ('user_trusted_devices', ARRAY['INSERT', 'UPDATE'])
61
+ ) AS l(tbl, cmds)
62
+ CROSS JOIN LATERAL unnest(l.cmds) AS r(cmd)
63
+ JOIN pg_policies p
64
+ ON p.schemaname = 'public' AND p.tablename = l.tbl
65
+ AND p.permissive = 'PERMISSIVE' AND p.cmd IN (r.cmd, 'ALL')
66
+ AND p.roles && ARRAY['authenticated', 'public']::name[];
67
+ IF offenders IS NOT NULL THEN
68
+ RAISE EXCEPTION '02026 would revoke a privilege that an RLS policy lets authenticated use: %. Nothing was changed; drop that table/command from this file and from API_ROLE_REVOKE_SQL, or drop the policy.', offenders;
69
+ END IF;
70
+ END
71
+ $nb_preflight$;
72
+
73
+ -- 1. writes that no policy lets authenticated use
74
+ REVOKE INSERT, UPDATE, DELETE ON TABLE
75
+ public.cortex_ai_db_mutation_audit,
76
+ public.coupon_redemptions,
77
+ public.email_2fa_challenges,
78
+ public.freemius_plans,
79
+ public.freemius_pricing,
80
+ public.package_activations,
81
+ public.privacy_consent_logs,
82
+ public.thread_messages,
83
+ public.ucp_cart_sessions,
84
+ public.user_security_settings
85
+ FROM authenticated;
86
+ REVOKE INSERT, DELETE ON TABLE public.message_threads, public.product_inquiries, public.system_alerts FROM authenticated;
87
+ REVOKE UPDATE, DELETE ON TABLE public.site_script_revisions FROM authenticated;
88
+ REVOKE INSERT, UPDATE ON TABLE public.user_trusted_devices FROM authenticated;
89
+
90
+ -- 2. REFERENCES / TRIGGER: DDL-only, unused by every API request
91
+ REVOKE REFERENCES, TRIGGER ON ALL TABLES IN SCHEMA public FROM authenticated;
@@ -0,0 +1,150 @@
1
+ -- 02027: anon and authenticated hold SELECT only where an RLS policy lets them read
2
+ -- (2026-10-21, the read side of 02025/02026).
3
+ --
4
+ -- 02025 made anon read-only and 02026 left authenticated only the writes some policy uses.
5
+ -- SELECT was still granted to both roles on every table in `public` (the baseline 02003, then
6
+ -- `grant select on all tables in schema public to anon` and `grant all ... to authenticated` in
7
+ -- the post-apply pass on every deploy). On the tables below no permissive SELECT or ALL policy
8
+ -- reaches the role (or PUBLIC), so RLS was the only barrier between the public key and orders,
9
+ -- profiles, coupons, licenses, MCP tokens, revisions, drafts, 2FA challenges, carts and
10
+ -- messages: one permissive policy added by mistake, or a table created with RLS off, would
11
+ -- have published it to anyone holding that key.
12
+ --
13
+ -- anon: every table that only staff, an owner or the service role reads (29, listed below).
14
+ -- authenticated: email_2fa_challenges and ucp_cart_sessions, whose only policy is TO
15
+ -- service_role (lib/auth/twoFactor and the UCP routes read them with the service role;
16
+ -- 02026 already took authenticated's writes on both).
17
+ -- profile_cards (02021): anon loses SELECT, authenticated loses INSERT/UPDATE/DELETE. It is a
18
+ -- security_invoker view over profiles, so anon could only ever read it through a SELECT on
19
+ -- profiles, which goes here. The public author card does not read the view: author()
20
+ -- returns its row type from private.author_card(), a SECURITY DEFINER function, so
21
+ -- `cms_interactions?select=*,author(full_name,avatar_url)` keeps working for anon (checked
22
+ -- through PostgREST on 2026-10-21). The view is a read card; nothing writes through it.
23
+ --
24
+ -- Unlike a write, a SELECT can be needed on a table the request never names: a policy
25
+ -- subquery (`EXISTS (SELECT ... FROM x)`), a security_invoker view, an invoker function or a
26
+ -- PostgREST embed (`select=*,x(...)`) all read x as the calling role, and they fail with 42501
27
+ -- once the grant is gone, where RLS used to return no row (or a null embed). The census behind
28
+ -- this list (a replay through 02026 plus the post-apply pass, 2026-10-21) found:
29
+ -- - one policy that reads another of these tables, order_items' owner policy (orders): it is
30
+ -- TO authenticated, which keeps SELECT on orders;
31
+ -- - one view, profile_cards (above);
32
+ -- - invoker functions reading them: apply_order_inventory_deduction (orders, order_items),
33
+ -- called by the app only with the service role and already refused to anon by its
34
+ -- SELECT ... FOR UPDATE since 02025 (FOR UPDATE needs UPDATE), and the trigger functions of
35
+ -- site_script_revisions / thread_messages, which fire only on writes anon cannot make;
36
+ -- - no anonymous query in the app (getSsgSupabaseClient, the browser client, the cookie client
37
+ -- on signed-out paths) embeds a listed table; the public author card goes through author().
38
+ -- The anonymous paths that need one of these tables (checkout success, order access tokens,
39
+ -- contact seller, threads, consent, forms, UCP, MCP tokens, the sign-up read of
40
+ -- system_configuration) read it with the service role after their own checks. The few
41
+ -- cookie-client reads a signed-out request can reach (a draft lookup in draft mode or with
42
+ -- NEXTBLOCK_VISUAL_EDITING_ENABLED, verifyPackageOnline on an install with no service key,
43
+ -- the MCP route's revision capture, a custom block's `profiles` relation) already got no
44
+ -- row and handle the error the same way: no draft, not licensed, no revision, no record.
45
+ -- Behaviour-neutral for every request that worked: an anon or authenticated read of a listed
46
+ -- table returned no row before and is refused by the grant now. The pre-flight block re-checks
47
+ -- the catalog side on the target database and aborts without changing anything if a policy
48
+ -- lets the role read a listed table, if a policy the role is subject to reads one, or if a view
49
+ -- the role can read depends on one.
50
+ --
51
+ -- Both apply paths repeat these revokes after their blanket grants (API_ROLE_REVOKE_SQL). To let
52
+ -- one of these roles read a listed table later, add the policy and GRANT SELECT in the same
53
+ -- migration and take the table out of both copies of that list, or the next deploy revokes it
54
+ -- again. tools/scripts/security-verify.sql sections 28-29 fail on a SELECT grant no policy uses
55
+ -- and on a read policy whose grant is missing. Idempotent: safe to re-run.
56
+
57
+ DO $nb_preflight$
58
+ DECLARE
59
+ offenders text;
60
+ BEGIN
61
+ WITH l(role, tbl) AS (
62
+ SELECT 'anon'::name, t
63
+ FROM unnest(ARRAY[
64
+ 'content_drafts', 'cortex_ai_db_mutation_audit', 'coupon_freemius_mappings',
65
+ 'coupon_products', 'coupon_redemptions', 'coupons', 'email_2fa_challenges',
66
+ 'form_endpoints', 'mcp_access_tokens', 'message_threads', 'order_items', 'orders',
67
+ 'package_activations', 'page_revisions', 'post_revisions', 'privacy_consent_logs',
68
+ 'product_drafts', 'product_freemius_sale_coupons', 'product_inquiries',
69
+ 'product_revisions', 'profile_cards', 'profiles', 'site_script_revisions',
70
+ 'system_alerts', 'system_configuration', 'thread_messages', 'ucp_cart_sessions',
71
+ 'user_addresses', 'user_security_settings', 'user_trusted_devices']::name[]) AS t
72
+ UNION ALL
73
+ SELECT 'authenticated'::name, t
74
+ FROM unnest(ARRAY['email_2fa_challenges', 'ucp_cart_sessions']::name[]) AS t
75
+ ), r AS (
76
+ SELECT l.role, l.tbl, to_regclass('public.' || quote_ident(l.tbl)) AS oid FROM l
77
+ ), hits(what) AS (
78
+ -- a policy that lets the role read a listed table
79
+ SELECT format('%s reads %s through policy %s', r.role, r.tbl, p.policyname)
80
+ FROM r
81
+ JOIN pg_policies p
82
+ ON p.schemaname = 'public' AND p.tablename = r.tbl
83
+ AND p.permissive = 'PERMISSIVE' AND p.cmd IN ('SELECT', 'ALL')
84
+ AND p.roles && ARRAY[r.role, 'public']::name[]
85
+ UNION ALL
86
+ -- a policy the role is subject to, on any other table, whose expression reads a listed table
87
+ SELECT format('policy %s on %s reads %s as %s', pol.polname, pol.polrelid::regclass, r.tbl, r.role)
88
+ FROM r
89
+ JOIN pg_depend d
90
+ ON d.classid = 'pg_policy'::regclass AND d.refclassid = 'pg_class'::regclass AND d.refobjid = r.oid
91
+ JOIN pg_policy pol ON pol.oid = d.objid
92
+ WHERE pol.polrelid <> r.oid
93
+ AND (0::oid = ANY (pol.polroles) OR (SELECT oid FROM pg_roles WHERE rolname = r.role) = ANY (pol.polroles))
94
+ UNION ALL
95
+ -- a view the role keeps SELECT on that reads a listed table
96
+ SELECT format('view %s reads %s and stays readable by %s', rw.ev_class::regclass, r.tbl, r.role)
97
+ FROM r
98
+ JOIN pg_depend d
99
+ ON d.classid = 'pg_rewrite'::regclass AND d.refclassid = 'pg_class'::regclass AND d.refobjid = r.oid
100
+ JOIN pg_rewrite rw ON rw.oid = d.objid
101
+ WHERE rw.ev_class <> r.oid
102
+ AND has_table_privilege(r.role, rw.ev_class, 'SELECT')
103
+ AND NOT EXISTS (SELECT 1 FROM r r2 WHERE r2.role = r.role AND r2.oid = rw.ev_class)
104
+ )
105
+ SELECT string_agg(DISTINCT what, '; ') INTO offenders FROM hits;
106
+ IF offenders IS NOT NULL THEN
107
+ RAISE EXCEPTION '02027 would revoke a SELECT that a request still needs: %. Nothing was changed; drop that table from this file and from API_ROLE_REVOKE_SQL, or change the policy or view.', offenders;
108
+ END IF;
109
+ END
110
+ $nb_preflight$;
111
+
112
+ -- 1. anon: SELECT on every table no policy lets anon read
113
+ REVOKE SELECT ON TABLE
114
+ public.content_drafts,
115
+ public.cortex_ai_db_mutation_audit,
116
+ public.coupon_freemius_mappings,
117
+ public.coupon_products,
118
+ public.coupon_redemptions,
119
+ public.coupons,
120
+ public.email_2fa_challenges,
121
+ public.form_endpoints,
122
+ public.mcp_access_tokens,
123
+ public.message_threads,
124
+ public.order_items,
125
+ public.orders,
126
+ public.package_activations,
127
+ public.page_revisions,
128
+ public.post_revisions,
129
+ public.privacy_consent_logs,
130
+ public.product_drafts,
131
+ public.product_freemius_sale_coupons,
132
+ public.product_inquiries,
133
+ public.product_revisions,
134
+ public.profiles,
135
+ public.site_script_revisions,
136
+ public.system_alerts,
137
+ public.system_configuration,
138
+ public.thread_messages,
139
+ public.ucp_cart_sessions,
140
+ public.user_addresses,
141
+ public.user_security_settings,
142
+ public.user_trusted_devices
143
+ FROM anon;
144
+
145
+ -- 2. authenticated: the two tables only the service role reads
146
+ REVOKE SELECT ON TABLE public.email_2fa_challenges, public.ucp_cart_sessions FROM authenticated;
147
+
148
+ -- 3. profile_cards: a read card for staff and owners (through profiles' policies)
149
+ REVOKE SELECT ON TABLE public.profile_cards FROM anon;
150
+ REVOKE INSERT, UPDATE, DELETE ON TABLE public.profile_cards FROM authenticated;