Repository navigation
Drop the dead create_direct_message_channel function #319
Description
Key: SPR-09 · Type: AFK · Part of: #327 · Blocked by: #314 · Checked against: 3bb26de
Problem
public.create_direct_message_channel is dead code with a broken access check, and we should drop it. Until 2026-09-28, any signed-in user could call it. They could create a DIRECT channel in any workspace, with any user as the second member. Nothing in the repo calls it. On prod it is now service_role only, but the function, its webapp wrapper, and its generated type are still in the repo. The next grant sweep could reopen it.
This is finding UF-03 of the 2026-09-28 security review: severity MEDIUM, OWASP A01 (Broken Access Control) and A04 (Insecure Design).
Context
What the function does (packages/supabase/scripts/10-functions.sql ~421-525, CREATE OR REPLACE FUNCTION create_direct_message_channel(workspace_uid VARCHAR(36), user_id UUID) RETURNS JSONB):
- It is
SECURITY DEFINERwithsearch_path = public. Migration20260512094335_dm_channel_function_definer.sqlset both for remote databases. - It takes the caller from
auth.uid(). If that is null, it falls back tocurrent_setting('app.current_user_id', TRUE)(~455). The comment says this is "for testing purposes". - It checks only three things: the workspace exists, both users exist, and the caller is not the target.
- It does not check that the caller or the target is a workspace member.
- It inserts a
channelsrow of typeDIRECTand achannel_membersrow for the target, not the caller (the caller insert is commented out).
Who could do what, before the 2026-09-28 fix:
- Any signed-in user could create a DM channel in any workspace, with any user as the member. The target did not consent and did not need to belong to the workspace.
- The target then got that channel's traffic and notifications.
- Anon calls normally raised an exception.
- PostgREST does not let a client set
app.*settings, so the GUC fallback is not reachable over HTTP. It is still an unsafe identity source inside a definer function. - The email fallback in the channel name never runs, because
users.usernameisNOT NULL.
State today (measured):
| Where | State |
|---|---|
| Prod | Function exists. The 2026-09-28 emergency fix (block E) made EXECUTE service_role only, for every overload. Prod body was not read (no snapshot). |
| Repo scripts | Function body at 10-functions.sql ~421-525; ALTER FUNCTION ... SET search_path at ~1003. |
29-lint-hardening.sql §6 |
Name listed in user_facing_names at ~216 at HEAD. On a local seed, this grants authenticated again. #314 owns its removal; step 2 checks it. |
| Webapp | Wrapper apps/webapp/src/api/rpc/createDirectMessageChannel.ts ~7 (createDirectMessageChannel). Only re-exported from apps/webapp/src/api/rpc/index.ts ~2. Nothing imports it. |
| Generated files | apps/webapp/src/types/supabase.ts ~1192 and packages/supabase/seed.sql ~8061, ~8638, ~9409 (the 13-RLS.sql comment), ~11068. |
| Comment | packages/supabase/scripts/13-RLS.sql ~50 names the function as an example of an RPC that needs email. |
| Applied migrations | 20260512094335_dm_channel_function_definer.sql, 20260513130000_chat_rls_rollout_catchup.sql (comment ~96), 20260519200000_scripts_functions_triggers_parity.sql (~5504, ~6077). These stay untouched. |
git grep -n "create_direct_message_channel\|createDirectMessageChannel" at HEAD finds no SQL caller, no cron job, and no app caller. It finds only the rows above.
What stays. The DIRECT value of channel_type stays. The UI branches stay: apps/webapp/src/components/chatroom/components/ChannelComposer/ChannelComposer.tsx ~37 (case 'DIRECT') and ChannelComposer/components/JoinDirectChannel.tsx. CHANNEL_TYPES in apps/hocuspocus.server/src/api/controllers/admin-stats.controller.ts ~13 stays. A future DM feature needs a new function that checks membership for both users and has no GUC fallback.
What to do
-
[maintainer] Read-only precheck on prod. Run in the SQL editor. Expect: query 1 returns 1 row, and
proaclnames only the owner andservice_role. Queries 2, 3 and 4 each return 0 rows. If any returns rows, stop and report on this issue.-- 1. Overloads and grants select p.oid::regprocedure as sig, p.prosecdef, p.proacl from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname = 'create_direct_message_channel'; -- 2. Tracked dependents select d.classid::regclass, d.objid, d.deptype from pg_depend d join pg_proc p on p.oid = d.refobjid and d.refclassid = 'pg_proc'::regclass join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname = 'create_direct_message_channel'; -- 3. Other function bodies that name it (plpgsql calls are not in pg_depend) select p.oid::regprocedure from pg_proc p where p.prosrc ilike '%create_direct_message_channel%' and p.proname <> 'create_direct_message_channel'; -- 4. Cron jobs that name it select jobid, jobname from cron.job where command ilike '%create_direct_message_channel%';
-
[agent] Confirm Port the 2026-09-28 prod grant and RLS fix into scripts and a paired migration #314 has landed. Port the 2026-09-28 prod grant and RLS fix into scripts and a paired migration #314 removes
'create_direct_message_channel'fromuser_facing_namesinpackages/supabase/scripts/29-lint-hardening.sql(~216 at HEAD). Rungrep -n create_direct_message_channel packages/supabase/scripts/29-lint-hardening.sql. If it prints a line, Port the 2026-09-28 prod grant and RLS fix into scripts and a paired migration #314 has not landed: stop and report on this issue. -
[agent] Remove the function from the script. In
packages/supabase/scripts/10-functions.sql:- Delete the comment block and the whole function, ~421-525 (from
-- SECURITY DEFINER: the function needs to read u.emailto the closing$$;). - In its place, add
drop function if exists public.create_direct_message_channel(varchar, uuid);. This follows the precedent at10-4-func-chat-media-features.sql~168-170 and07-4-push-notifications-pgmq.sql~676-678. Do not copy thecascadefrom the second one. A local DB that re-runs the script, without a reset, then loses the function too. - Delete
ALTER FUNCTION public.create_direct_message_channel(workspace_uid character varying, user_id uuid) SET search_path = public;at ~1003. Do this in the same edit. If this ALTER stays, the regenerated seed alters a missing function, anddb resetaborts.
- Delete the comment block and the whole function, ~421-525 (from
-
[agent] Fix the comment. In
packages/supabase/scripts/13-RLS.sql~50, remove the parenthetical(e.g. create_direct_message_channel). End the sentence at "RPCs that legitimately need it." Make newdocument_viewspartitions start closed #315 owns the false partition comment in that file (~271); do not touch it. -
[agent] Remove the webapp wrapper. Delete
apps/webapp/src/api/rpc/createDirectMessageChannel.ts. Removeexport * from './createDirectMessageChannel'fromapps/webapp/src/api/rpc/index.ts~2. Do this before step 8, because the wrapper reads the generated type. -
[agent] Write the paired migration by hand. Create
packages/supabase/migrations/<timestamp>_drop_create_direct_message_channel.sql. The timestamp must be later than20260928130000and later than the Port the 2026-09-28 prod grant and RLS fix into scripts and a paired migration #314 migration. It drops every overload by name, with noCASCADE. An unknown dependent then stops the push, and does not remove something silently.-- Drops create_direct_message_channel (UF-03). No caller in the repo. -- Every overload, no CASCADE. A no-op when the function is already gone. do $$ declare f record; begin for f in select p.proname, pg_get_function_identity_arguments(p.oid) as args from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname = 'create_direct_message_channel' loop execute format('drop function public.%I(%s)', f.proname, f.args); end loop; if exists ( select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname = 'create_direct_message_channel' ) then raise exception 'create_direct_message_channel still exists'; end if; end $$;
-
[agent] Prove the migration locally. The local DB still has the function from the old seed.
db resetskips migrations (packages/supabase/config.toml~53-58,enabled = false), so apply the file withpsql. Run it twice, from the repo root, with the real timestamp. The first run drops the function. The second run is a no-op.docker exec -i supabase_db_docsplus_supabase psql -U postgres -d postgres -v ON_ERROR_STOP=1 < packages/supabase/migrations/<timestamp>_drop_create_direct_message_channel.sql
-
[agent] Regenerate seed and types. From the repo root, run the commands below.
resetregeneratesseed.sqlfromscripts/, then resets the local DB. It wipes local state for every worktree that shares this DB.bun run --filter @docs.plus/supabase_back reset bun run --filter @docs.plus/supabase_back types
-
[agent] Check the webapp still builds. Run
bun run --filter @docs.plus/webapp typecheckandbun run lint. -
[agent] Stop at a review checkpoint. Post the diff summary and the step 7-9 results as a comment on this issue. Do not commit.
-
[maintainer] Commit with
/commit-review. Commit the script edits, the new migration, and the regeneratedseed.sqlandsupabase.tstogether. -
[maintainer] Push to prod after Ship the three migrations that prod's history lacks #313 and Port the 2026-09-28 prod grant and RLS fix into scripts and a paired migration #314 land. Follow the house procedure in
packages/supabase/CLAUDE.md§"Remotedb pushvs local reset". Runbunx supabase migration listfirst. Set[db.migrations] enabled = trueonly while pushing, then set it back tofalse. Never mark this new version applied withmigration repair; it must be pushed. After the push, run precheck query 1 again. Expect 0 rows.
Out of scope
- Porting the 2026-09-28 grant fix into scripts, including the §6 removal of
create_direct_message_channelandget_bookmark_count: Port the 2026-09-28 prod grant and RLS fix into scripts and a paired migration #314. - Pushing the three missing migrations: Ship the three migrations that prod's history lacks #313.
- Default privileges: Stop new tables and functions from opening by default #316. The grant guard: Add a read-only grant guard to run after each
db push#317. - Other lint 0028 functions: Close the remaining 47 linter warnings on functions #318.
- Prod-only functions such as
reset_workspace_unread_counts: Remove prod-only drift: four functions and thePushSentLogtable #320. - The
DIRECTenum value, the chat UI branches, andadmin-stats.controller.ts. They stay. - Building a DM feature.
Acceptance criteria
-
packages/supabase/scripts/10-functions.sqlhas noCREATEand noALTERforcreate_direct_message_channel. It has onedrop function if exists public.create_direct_message_channel(varchar, uuid);. -
29-lint-hardening.sql§6 no longer listscreate_direct_message_channel. -
13-RLS.sql~50 no longer names the function. -
apps/webapp/src/api/rpc/createDirectMessageChannel.tsis deleted, andapps/webapp/src/api/rpc/index.tsno longer exports it. - One new migration drops every overload by name, without
CASCADE, and raises if the function remains. It ran twice locally without error. -
seed.sqlandapps/webapp/src/types/supabase.tswere regenerated by the commands in step 8, not edited by hand. Neither names the function. -
bun run --filter @docs.plus/supabase_back resetcompletes without error. -
bun run --filter @docs.plus/webapp typecheckandbun run lintpass. - No applied migration file changed.
git status --short -- packages/supabase/migrationsshows only the new file, as??. - The grep in Verification returns exactly the expected files.
- [maintainer] After the prod push, precheck query 1 returns 0 rows.
Verification
-
Grep proof. Run
git grep --untracked -l "create_direct_message_channel\|createDirectMessageChannel".--untrackedmakes it see the new migration before anyone stages it. Expect exactly these five files, and nothing inapps/,seed.sql, orsupabase.ts:packages/supabase/migrations/20260512094335_dm_channel_function_definer.sqlpackages/supabase/migrations/20260513130000_chat_rls_rollout_catchup.sqlpackages/supabase/migrations/20260519200000_scripts_functions_triggers_parity.sql- the new
<timestamp>_drop_create_direct_message_channel.sql packages/supabase/scripts/10-functions.sql(thedrop function if existsline only)
-
Local DB after reset. Run this through
docker exec -i supabase_db_docsplus_supabase psql -U postgres -d postgres. Expect0.select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname = 'create_direct_message_channel';
-
Chat still works. Start the local stack with
make dev-local. Then run the chatroom specs withbash scripts/run-tests.sh --e2e --scope chatroom. Expect every spec to pass. Or open a pad as a guest and as a signed-in user, and send a message as the signed-in user. No console error should name the function. -
[maintainer] On prod after the push: precheck query 1 returns 0 rows. The security linter shows no row for
create_direct_message_channel.
House rules
- Scripts first, with a paired migration. Edit
scripts/*.sql, then write the migration, then regenerate seed and types in the same change (packages/supabase/CLAUDE.md). - Never hand-edit
packages/supabase/seed.sqlorapps/webapp/src/types/supabase.ts. Onlyreset/seedandtypeswrite them. - Never edit an applied migration. Trap: do not run
generate:functions-parity-migration. It rewrites20260519200000_scripts_functions_triggers_parity.sqlin place (generate-functions-parity-migration.ts~35MIGRATION_BASENAME, ~154), and that migration is already applied on prod. This drop migration is hand-written. - Bun only. No
npm,npx,yarnorpnpm. - No commits unless the maintainer asks. End at a review checkpoint. Tests are opt-in; this change needs none.
Activity
Kart-ing commentedon Sep 30, 2026
Step 8's reset wipes local state for every worktree that shares the DB. When an agent runs it, how do the other worktrees get their state back?