Repository navigation
Remove prod-only drift: four functions and the PushSentLog table #320
Description
Key: SPR-10 · Type: HITL · Part of: #327 · Blocked by: #314 · Checked against: 3bb26de
Problem
Prod holds five objects that no file at HEAD creates: four public functions and the table public."PushSentLog". Nobody has read their prod bodies.
The Supabase security linter found them on 2026-09-28. Before the emergency fix at about 13:38Z, anon could execute all four functions. Anon could also read, write and delete PushSentLog.
That fix closed browser access, but the objects are still there. The four functions are unreviewed SECURITY DEFINER code. A fresh db reset reproduces none of the five objects. No repo file records them, so no reviewed code controls their grants or bodies.
This issue asks the maintainer to read the prod bodies and rule "drop" or "keep" per object. An agent then ships the ruling as a scripts edit plus one paired migration.
Context
The five objects
| Object | What the repo knows | Prod state now | Known callers |
|---|---|---|---|
public.cleanup_push_subscriptions() |
Last repo body: git show a24b57624^:packages/supabase/scripts/19-push-notifications.sql, ~394. PL/pgSQL, SECURITY DEFINER. It updates push_subscriptions for every user. It deactivates rows with failed_count >= 5 and resets failed_count on recently used rows. Commit a24b57624 moved the logic to internal.cleanup_push_subscriptions() and deleted the script. No migration dropped the public copy. |
service_role only since 2026-09-28. |
None in the repo. The live cron job calls the internal copy (see below). |
public.get_push_subscriptions() |
Same file and revision, ~493. SQL, SECURITY DEFINER. It returns the caller's own rows (where user_id = auth.uid()), without push_credentials. |
service_role only. |
None. apps/webapp/src/types/supabase.ts does not list it. |
public.get_push_notification_stats() |
Same file and revision, ~518, from commit c29b36426 (2026-01-30). PL/pgSQL, SECURITY DEFINER. It returns jsonb: subscription counts, last_push_attempt, a config_status and the configured gateway_url. It reads config through internal.get_config, which HEAD no longer defines. It checks that a service key is set but does not return it. |
service_role only. |
None at HEAD. The admin dashboard called it from c29b36426 until 7f933a798 (2026-02-04) removed the call. |
public.reset_workspace_unread_counts(...) |
Nothing. git log --all -S reset_workspace_unread_counts returns no commit. Signature and body are unknown. It was probably made in the SQL editor. |
service_role only. |
None in the repo. By its name, it may reset channel_members.unread_message_count for other users. That is a guess until the body is read. |
public."PushSentLog" (table) |
Prod columns: id, notificationId, subscriptionId, sentAt, expiresAt. Prod also has an index PushSentLog_expiresAt_idx, which the performance linter lists as unused. |
0 rows. RLS on and all rights revoked from public, anon and authenticated since 2026-09-28. |
None on Supabase. |
The security review says git log -S finds none of the four. That holds only for reset_workspace_unread_counts. Git history has legacy bodies for the other three, shown above. The prod bodies may still differ from them.
Commit a8b6ea9d9 (2026-05-13) removed ALTER FUNCTION lines for three of these functions. Its message says get_push_subscriptions and get_push_notification_stats are "never defined". It says cleanup_push_subscriptions lives in internal. Both claims hold for a fresh local reset, but not for prod.
Why PushSentLog on Supabase is an orphan
- The real
PushSentLoglives in the Prisma database, not in Supabase. SeeAGENTS.md~377: the app'sDATABASE_URLis the DigitalOcean Postgres. - The Prisma model has a different shape:
id,idempotencyKey,userId,subscriptionCount,sentAt,expiresAt. Seeapps/hocuspocus.server/prisma/schema.prisma~123 (model PushSentLog) andapps/hocuspocus.server/prisma/migrations/20260201083215_schema_improvements/migration.sql~35-44. - No committed Prisma file ever held
notificationIdorsubscriptionId.git log --all -S notificationId -- apps/hocuspocus.server/prismareturns nothing. - The push worker reads and writes the Prisma table by
idempotencyKey:apps/hocuspocus.server/src/lib/push/queue.ts~122 (prisma.pushSentLog.findUnique) and ~145 (prisma.pushSentLog.create).apps/hocuspocus.server/src/lib/retention.ts~45 deletes expired rows there. - So dropping the Supabase table cannot break the push worker.
- Measured: RLS was off on it before the fix. Inferred, not measured: it was made by hand before the prod-only
ensure_rlsevent trigger existed. That trigger's origin is unverified (Make newdocument_viewspartitions start closed #315). The review dates its install between 2026-02-01 and 2026-03-01.
The cron job that must keep working
- The live job is
cleanup-push-subscriptions(hyphens), every 5 minutes. It callsinternal.cleanup_push_subscriptions(). Seepackages/supabase/scripts/16-cron-jobs.sql~20-24 andpackages/supabase/migrations/20260806100000_cron_schedule_upsert_preserves_jobid.sql~22. - The
internalbody is atpackages/supabase/scripts/07-4-push-notifications-pgmq.sql~643 (create or replace function internal.cleanup_push_subscriptions()). - The old job
cleanup_push_subscriptions(underscores) called thepubliccopy. Migration20260717015500_cron_health_single_pass_and_schedule_fixes.sql~12-13 unscheduled it. - Any drop must touch schema
publiconly. Droppinginternal.cleanup_push_subscriptions()would stop the cron job.
What was measured on prod on 2026-09-28 (read-only)
- All four functions are owned by
postgres. None returnstriggerorevent_trigger. PushSentLogis owned bypostgres. RLS was off before the fix.pg_stat_statementsshows anon ran full-column selects onPushSentLog7 times. They match no repo code and look like a scanner. The table had 0 rows, so they returned nothing. One more anon count was the review's own grant check.- Prod bodies were NOT read. Every body claim above comes from git history.
What the emergency fix did (already applied to prod)
For functions, it looped over every overload of the four names in schema public. For each, it ran grant execute ... to service_role, then revoke all ... from public, anon, authenticated. It asserted the result.
For the table, it ran this:
alter table if exists public."PushSentLog" enable row level security;
do $$ begin
if to_regclass('public."PushSentLog"') is not null then
revoke all on public."PushSentLog" from public, anon, authenticated;
end if;
end $$;#314 ports those grants into scripts/*.sql and a paired migration. This issue removes or records the objects themselves.
Recommendation
Drop all five, unless Step 1 finds a caller. The maintainer's local security report advises the same: read the bodies, then drop. It leaves the final call to the maintainer. No repo path, cron job or migration uses any of them.
What to do
-
[maintainer] Read the prod facts. Run these read-only queries in the Supabase SQL editor. Record the results in a comment on this issue.
Before you paste a function body into this public issue, check that it holds no key, token, URL with a key, or personal data. If in doubt, summarize the body instead.
-- 1a. Signature, return type, language, owner, ACL and body of each function. select p.oid::regprocedure as sig, pg_get_function_result(p.oid) as returns, l.lanname, p.prosecdef, pg_get_userbyid(p.proowner) as owner, p.proacl, pg_get_functiondef(p.oid) as body from pg_proc p join pg_namespace n on n.oid = p.pronamespace join pg_language l on l.oid = p.prolang where n.nspname = 'public' and p.proname in ('reset_workspace_unread_counts','get_push_notification_stats', 'cleanup_push_subscriptions','get_push_subscriptions') order by 1; -- 1b. Objects that depend on those functions (triggers, views, defaults). select p.oid::regprocedure as fn, d.deptype, pg_describe_object(d.classid, d.objid, d.objsubid) as dependent from pg_proc p join pg_namespace n on n.oid = p.pronamespace join pg_depend d on d.refclassid = 'pg_proc'::regclass and d.refobjid = p.oid where n.nspname = 'public' and p.proname in ('reset_workspace_unread_counts','get_push_notification_stats', 'cleanup_push_subscriptions','get_push_subscriptions'); -- 1c. Text references in cron jobs, other function bodies and policies. select 'cron' as kind, jobname::text as name, command as text from cron.job where command ~* '(reset_workspace_unread_counts|get_push_notification_stats|cleanup_push_subscriptions|get_push_subscriptions|pushsentlog)' union all select 'function', p.oid::regprocedure::text, left(p.prosrc, 200) from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname not in ('pg_catalog','information_schema') and p.proname not in ('reset_workspace_unread_counts','get_push_notification_stats', 'cleanup_push_subscriptions','get_push_subscriptions') and p.prosrc ~* '(reset_workspace_unread_counts|get_push_notification_stats|cleanup_push_subscriptions|get_push_subscriptions|pushsentlog)' union all select 'policy', schemaname || '.' || tablename || '.' || policyname, coalesce(qual, '') || ' ' || coalesce(with_check, '') from pg_policies where coalesce(qual, '') || ' ' || coalesce(with_check, '') ~* '(reset_workspace_unread_counts|get_push_notification_stats|cleanup_push_subscriptions|get_push_subscriptions|pushsentlog)'; -- 1d. Who has ever called them (normalized query text, no values). select r.rolname, s.calls, left(regexp_replace(s.query, '\s+', ' ', 'g'), 160) as query from pg_stat_statements s join pg_roles r on r.oid = s.userid where s.query ~* '(reset_workspace_unread_counts|get_push_notification_stats|cleanup_push_subscriptions|get_push_subscriptions|pushsentlog)' order by s.calls desc; -- 1e. The table: owner, RLS, ACL, live rows, write counters, dependents, foreign keys. select c.oid::regclass as tbl, pg_get_userbyid(c.relowner) as owner, c.relrowsecurity, c.relacl, c.reltuples, (select count(*) from public."PushSentLog") as live_rows from pg_class c where c.oid = to_regclass('public."PushSentLog"'); select n_tup_ins, n_tup_upd, n_tup_del, seq_scan, idx_scan from pg_stat_user_tables where relid = to_regclass('public."PushSentLog"'); select pg_describe_object(d.classid, d.objid, d.objsubid) as dependent, d.deptype from pg_depend d where d.refclassid = 'pg_class'::regclass and d.refobjid = to_regclass('public."PushSentLog"') and d.deptype = 'n'; select conname, conrelid::regclass from pg_constraint where confrelid = to_regclass('public."PushSentLog"');
How to read the results:
- 1c: expect exactly one
cronrow,cleanup-push-subscriptions, whose command namesinternal.cleanup_push_subscriptions(). That is not a caller of thepubliccopy. Any other row is a caller. - 1d: rows that name
internal.cleanup_push_subscriptionscome from that cron job. The review's own checks on 2026-09-28 also appear, and so do the Step 1 queries themselves.pg_stat_statementshas no timestamps. Soget_push_notification_statscalls may be the old admin dashboard, which stopped on 2026-02-04. Any other role or shape is a caller to explain. - 1b and 1e: any row with
deptype = 'n', or any foreign key, blocks a plain drop.
- 1c: expect exactly one
-
[maintainer] Rule per object. Post a comment with one line per object:
droporkeep, and the reason. Use this rule:Step 1 shows Ruling No dependent, no text reference, no unexplained caller, and (for the table) 0 rows dropA dependent object or a real caller keep. The body moves intoscripts/(step 4).A body that writes other users' rows and has a caller keep, and open a follow-up to review its logic -
[agent] Write the drop in the script first. In
packages/supabase/scripts/07-4-push-notifications-pgmq.sql, find section 9 (~672, header-- 9. Drop Deprecated Functions (from pg_net architecture)). It already drops v1 functions at ~676-678. After those lines, add one block for every object ruleddrop. List only the ruled names.-- Prod-only leftovers that no repo file creates (#320). Schema public only: -- internal.cleanup_push_subscriptions() runs the live cron job and must stay. -- No CASCADE: a dependent object must stop this, not vanish with it. do $$ declare f record; begin for f in select p.oid::regprocedure as sig from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname = any (array['cleanup_push_subscriptions', 'get_push_notification_stats','get_push_subscriptions', 'reset_workspace_unread_counts']) loop execute format('drop function %s', f.sig); end loop; end $$; drop table if exists public."PushSentLog";
The loop drops every overload, so the unknown signature of
reset_workspace_unread_countsdoes not matter. It is a no-op on a fresh local reset, because local never had these objects. -
[agent] Only for an object ruled
keep. Copy its prod body from the Step 1 comment into a script. Push functions go in07-4-push-notifications-pgmq.sql.reset_workspace_unread_countsgoes at the end ofpackages/supabase/scripts/09-message_counter.sql(~283). Usecreate or replace, never drop and create. A new function gets the open default grants on prod, soanoncould run it again. Follow the body with explicit grants:revoke all on function public.<name>(<args>) from public, anon, authenticated; grant execute on function public.<name>(<args>) to service_role;
A kept table stays as it is on prod. Do not add it to
scripts/. Its RLS and revoke come from Port the 2026-09-28 prod grant and RLS fix into scripts and a paired migration #314. -
[agent] Write one paired migration. Create
packages/supabase/migrations/<YYYYMMDDHHMMSS>_drop_prod_only_drift.sql. Its timestamp must sort after every file in that folder, including Port the 2026-09-28 prod grant and RLS fix into scripts and a paired migration #314's migration. Put the same statements as steps 3 and 4 in it. Start it with a comment that names the paired script files and says "Idempotent." End it with this assert, trimmed to the dropped names:do $$ begin if exists (select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname = any (array['cleanup_push_subscriptions', 'get_push_notification_stats','get_push_subscriptions', 'reset_workspace_unread_counts'])) then raise exception '#320: a dropped function still exists'; end if; if to_regclass('public."PushSentLog"') is not null then raise exception '#320: public."PushSentLog" still exists'; end if; if to_regprocedure('internal.cleanup_push_subscriptions()') is null then raise exception '#320: internal.cleanup_push_subscriptions() is missing'; end if; end $$;
-
[agent] Regenerate the seed and types. From the repo root:
bun run --filter @docs.plus/supabase_back seed bun run --filter @docs.plus/supabase_back types
The seed diff should hold only the drop block, plus any kept bodies and their grants. Expect no change to
apps/webapp/src/types/supabase.ts, because local never had these objects. If it does change, include the regenerated file. -
[agent] Prove the migration locally. Local has none of these objects, so a plain run proves nothing. Build a scratch file outside the repo that creates stand-ins, runs the migration, checks, and rolls back:
begin; create function public.reset_workspace_unread_counts(p uuid) returns void language sql as $$ select $$; create function public.get_push_subscriptions() returns int language sql as $$ select 1 $$; create table public."PushSentLog" (id text primary key); -- paste the migration body here select count(*) as left_over from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname in ('reset_workspace_unread_counts','get_push_subscriptions'); select to_regclass('public."PushSentLog"') as table_left, to_regprocedure('internal.cleanup_push_subscriptions()') as internal_kept; rollback;
Run it with
docker exec -i supabase_db_docsplus_supabase psql -v ON_ERROR_STOP=1 -U postgres -d postgres < <scratch file>. Then apply the real migration twice with the same command. Both runs must succeed.Do not run
bun run --filter @docs.plus/supabase_back resetwithout asking the maintainer. It wipes shared local state. -
[maintainer] Commit and push. Review the diff and commit through
/commit-review. Push the migration to prod with the house procedure that Ship the three migrations that prod's history lacks #313 describes:- Set
enabled = trueunder[db.migrations]inpackages/supabase/config.toml(~53-58) only while pushing. Never commit it astrue. - Run
bunx supabase migration list. Expect only the new file with an empty Remote column. If any other row differs, stop and followpackages/supabase/CLAUDE.md~30. - Run
bunx supabase db push --dry-run, thenbunx supabase db push. - Set
enabledback tofalse. Never repair-apply a new version.
- Set
-
[maintainer] Check prod after the push. Run the queries under Verification. Wait for the next 5-minute run of
cleanup-push-subscriptions.
Out of scope
- Porting the 2026-09-28 grants and RLS into scripts: Port the 2026-09-28 prod grant and RLS fix into scripts and a paired migration #314. Its migration must land first.
- Shipping the migrations that prod's history lacks: Ship the three migrations that prod's history lacks #313.
- Partition RLS, and the prod-only
ensure_rlsevent trigger andrls_auto_enablefunction: Make newdocument_viewspartitions start closed #315. - Default privileges for new tables and functions: Stop new tables and functions from opening by default #316.
- The read-only grant guard: Add a read-only grant guard to run after each
db push#317. After the 2026-09-28 fix, none of its checks should match these five objects. - The other lint 0028 warnings on functions: Close the remaining 47 linter warnings on functions #318.
- Other objects from the deleted v1 push script, such as
internal.app_config,internal.get_configandinternal.set_config. HEAD defines none of them, and prod was not checked. Open a follow-up if wanted. - A guard that stops Prisma scripts from running against a Supabase host. The security review suggested it for
PushSentLog. It is not in this issue; open a follow-up if wanted. - Do not run the
search_pathsweep (29-lint-hardening.sql§1) on these functions. It can break drift code that calls extensions without a schema prefix.
Acceptance criteria
- A comment on this issue records the Step 1 results and one
droporkeepruling per object, five in all. -
packages/supabase/scripts/07-4-push-notifications-pgmq.sqlsection 9 holds a drop block that names only the objects ruleddrop. - Every function ruled
keephas its body in a script, created withcreate or replace. A revoke frompublic, anon, authenticatedand a grant toservice_rolefollow it. - Exactly one new file exists under
packages/supabase/migrations/. Its timestamp sorts after Port the 2026-09-28 prod grant and RLS fix into scripts and a paired migration #314's migration, and it holds the same statements plus the assert block. - No drop uses
CASCADE. No statement changes an object in schemainternal. -
git status --short packages/supabase/migrationslists only the new file, as untracked (??). No applied migration changed. -
packages/supabase/seed.sqlwas regenerated with theseedscript, not edited by hand. - The
typesscript ran.apps/webapp/src/types/supabase.tsis unchanged, or its regenerated diff is included. - The Step 7 scratch run shows
left_over = 0,table_leftnull andinternal_keptnot null. - The real migration applied twice to local Supabase with no error.
- After the prod push, the Verification queries return the expected results.
Verification
Local, by the agent:
git status --short packages/supabase/migrations # only '?? <new file>'
git diff --stat apps/webapp/src/types/supabase.ts # expect empty
docker exec -i supabase_db_docsplus_supabase psql -v ON_ERROR_STOP=1 -U postgres -d postgres \
< packages/supabase/migrations/<new file>.sql # run twice; both exit 0Prod, read-only, by the maintainer after the push:
-- Expect 0 rows for every name ruled "drop".
select p.oid::regprocedure from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and p.proname in ('reset_workspace_unread_counts','get_push_notification_stats',
'cleanup_push_subscriptions','get_push_subscriptions');
-- Expect null if the table was ruled "drop".
select to_regclass('public."PushSentLog"');
-- Expect not null: the cron job's function survives.
select to_regprocedure('internal.cleanup_push_subscriptions()');
-- Expect the newest runs to say 'succeeded'.
select d.status, d.start_time
from cron.job_run_details d join cron.job j on j.jobid = d.jobid
where j.jobname = 'cleanup-push-subscriptions'
order by d.start_time desc limit 3;
-- Expect the new version in the history.
select version from supabase_migrations.schema_migrations order by version desc limit 3;Then re-run the security linter (Dashboard, Advisors, Security Advisor). No row may name any dropped object.
House rules
- Scripts first, then one paired migration. Regenerate
seed.sqlwith theseedscript, and runbun run --filter @docs.plus/supabase_back types. Seepackages/supabase/CLAUDE.md. - Never edit an applied migration. Never hand-edit
packages/supabase/seed.sqlorapps/webapp/src/types/supabase.ts. - No commits, pushes or branches by an agent. The maintainer commits through
/commit-review. - Bun only: no
npm,yarn,pnpmornpx. - Tests are opt-in. The Step 7 scratch run is a proof, not a committed test.
Activity