You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 5c2da5f
Browse filesBrowse the repository at this point in the historyBrowse files
fix(supabase): close chat mention, media, avatar and delete leaks
A picked chat mention now notifies the user it names by data-id. Before, a rename sent it to the next holder of that name. A typed @name still notifies the current holder. One helper, internal.mentioned_user_ids, serves both fan-outs.
Anon and signed-in non-members now read only chat media that a live message names. Before, the public key could list unsent uploads. Channel members still read every object.
The two public avatar buckets no longer accept SVG. Settings no longer uploads an SVG as it is.
A soft delete now clears the text, files and comment quote of a message. A one-shot backfill clears rows that were deleted before.
Each migration pairs with its script, and seed.sql is regenerated. Push the four migrations by hand.
Refs #415
Refs #431
Refs #432
Refs #435
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Copy file name to clipboardExpand all lines: packages/supabase/CLAUDE.md
+4-2Lines changed: 4 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -29,6 +29,7 @@ Moved verbatim out of the repo-root [AGENTS.md](../../AGENTS.md) so it loads onl
29
29
-**A `workspaces` row implies a `channels` row, so a read path may guard on the channel and must never create one.**`join_workspace` is the only writer of `public.workspaces`, and it always follows that insert with a `workspace_members` insert, whose `notify_user_join_workspace` trigger mints the channel. `notify_document_content_change` shipped with a `public.channels` insert that was dead on the normal path, cascaded into `channel_creator_as_admin` and `increment_member_count`, and raced two concurrent callers into `23505`, which aborts the whole RPC.
30
30
-**Clients do not UPDATE `channels` and do not INSERT or UPDATE `workspaces`.**`join_workspace` and the `SECURITY DEFINER` triggers are the writers. `13-RLS.sql` has no UPDATE policy on `channels` and no write policy on `workspaces` (#401, #409). Clients still INSERT a channel through `channels_member_insert`. The channel-wide mute is not client-writable; the per-user mute on `channel_members` is. Do not add a client write grant back. Route a new write through a definer RPC.
31
31
-**Clients INSERT and UPDATE only the granted `public.messages` columns (#400).** INSERT covers `id`, `channel_id`, `user_id`, `content`, `html`, `medias`, `type`, `metadata` and `reply_to_message_id`. UPDATE covers `content`, `html`, `medias`, `type` and `deleted_at`. Migration `20261008120000_lock_message_write_columns.sql` is the current mirror for these grants. A new client-written column needs a grant in `13-RLS.sql` and a migration. Without both, the write fails with `42501`, which PostgREST returns as 403. Cypress intercepts Supabase HTTP, so no test catches this. Notice rows (`type = 'notification'`) come only from definer or service-role writers; both message policies refuse them. SELECT stays table-wide, or realtime drops rows.
32
+
-**A soft delete clears the text of a message (#435).** The BEFORE trigger `clear_deleted_message_text` (`scripts/10-3-func-message.sql`) sets `content`, `html` and `medias` to null and drops `metadata.comment`. It keeps the row, `metadata.replied` and `reply_to_message_id`. The AFTER trigger `message_soft_delete` still reads `OLD.medias` for media cleanup. To clear rows that are already deleted, write `deleted_at = deleted_at`, never `content`: a `content` write fires `update_message_previews` and overwrites reply and channel previews. Keep `messages_visible_select` without a `deleted_at` filter. The delete's `.select()` and the realtime delete event need the deleted row. The trigger drops `metadata.comment` only from an object, because the jsonb minus operator raises on a scalar.
32
33
- **`public.document_access` is the Private mirror (#396).** It holds `is_private` and `owner_id` for each documentId, the same value as `channels.workspace_id`. Hocuspocus writes it with the service role, and clients have no grant. A missing row means public. `internal.can_open_document(document_id, user_id)` reads it. Chat policies, `join_workspace` and the unread trigger call it, and the anon policies pass a null user. Chat media policies reach it through `internal.can_read_channel` and `internal.is_channel_member`. Notification rows have one gate: the `BEFORE INSERT` trigger `skip_unopenable_notification` on `public.notifications` drops a row whose receiver cannot open the channel's document. Chat and `content_change` rows both pass through it, so a new fan-out needs no check of its own. It costs about 19 µs per row (measured 2026-10-09 at 10,000 rows). The three primitives copy its predicate inline, because a nested definer call doubled chat read time. Keep all four copies identical. The table lives in `03-2-document_access.sql`, so it seeds before `10-0`. `purge_document_footprint` deletes the row. It matches the documentId verbatim, because the mirror does not store `lower(documentId)`.
33
34
-**`notify_document_title_change` is service-role only.** REST calls it after a signed-in Pad title rename. It returns 0 and does not raise. It does not create a workspace or a channel. Keep it out of `user_facing_names`. Mention, everyone, and regular-message INSERT triggers skip `type = notification`, so this notice cannot mail or write inbox rows.
34
35
-**`idx_notifications_content_change_open` is load-bearing, not decoration.** The 24-hour dedupe `not exists` in the fan-out is not evaluated per row: the planner de-correlates it into a plain Join Filter. Measured on a 600k-row table at 10,000 followers, a second call inside the window took 2081 ms with `Rows Removed by Join Filter: 49,995,000`; with the index it is a Hash Anti Join at 4.9 ms. It is partial (`where type = 'content_change' and readed_at is null`), so it holds 592 kB, and the first-call insert measured 855.8 ms without it and 833.8 ms with it. Reading the index list suggests `idx_notifications_receiver_readed` covers this. It does not — that index cannot serve an inner side reached by a Join Filter.
@@ -39,9 +40,10 @@ Moved verbatim out of the repo-root [AGENTS.md](../../AGENTS.md) so it loads onl
39
40
-**`supabaseClient.rpc(...)` is lazy.** It returns a `PostgrestBuilder`; the request only leaves the browser when `.then(...)` / `await` / `.single()` / `.maybeSingle()` is attached. Fire-and-forget calls that drop the result (e.g. a debounced cursor advance) MUST attach `.then((res) => { if (res.error) console.warn(...) })`, or the network call silently never dispatches. Any `supabaseClient.rpc(` without a continuation is a bug.
40
41
-**Storage RLS is path-based, not column-based.** Bucket policies for `user_avatars` / `channel_avatars` check `(storage.foldername(name))[1] = (select auth.uid()::text)`, NOT `owner` (uuid) or `owner_id` (text). Storage-api v1.54.1 doesn't reliably auto-populate either column on insert; predicates against them evaluate `NULL = …` → false → 403 "new row violates RLS policy". The FE writes paths as `{userId}/{filename}` (`useAvatarUpload` uses `${user.id}/avatar.png`; `Config.app.profile.getAvatarURL` emits the matching read URL). Encoding ownership in the path is Supabase's canonical pattern and matches Slack/Discord/Linear conventions.
41
42
-**Storage bucket policies are CRUD-complete, not write-only.** Migrations that re-declare INSERT / UPDATE / DELETE policies on `storage.objects` MUST also confirm a matching SELECT policy exists. Supabase Storage runs `INSERT … RETURNING` (or INSERT followed by SELECT) on every upload. A missing SELECT policy then returns the same generic "new row violates row-level security policy" 403, which misleadingly points at the INSERT. The actual denial is on the readback. `scripts/12-buckets.sql` declares SELECT policies for `user_avatars` / `channel_avatars` / `media`. Each is scoped by `bucket_id` plus the same path/owner predicate as its write policies. The migration tree must keep parity. Public-URL reads (`/storage/v1/object/public/...`) take a different code path that doesn't go through PostgREST RLS. The avatar display can therefore work while uploads silently 403. That is exactly how this gap survived three rounds of "fix the RLS" migrations.
42
-
-**`storage.objects` EXISTS subqueries:** inside `exists (select 1 from public.channels c where …)`, unqualified `name` binds to **`c.name`** (channel slug), not the storage row — correlate media paths with **`objects.name`** (e.g. `(storage.foldername(objects.name))[2]` for `{userId}/{channelId}/…`). Bare `foldername(name)` in public-channel chat media SELECT policies broke anon and non-member signed-URL reads. The signed-in read policy now passes that segment to `internal.can_read_channel`.
43
+
-**`storage.objects` EXISTS subqueries:** inside `exists (select 1 from public.channels c where …)`, unqualified `name` binds to **`c.name`** (channel slug), not the storage row — correlate media paths with **`objects.name`** (e.g. `(storage.foldername(objects.name))[2]` for `{userId}/{channelId}/…`). Bare `foldername(name)` in public-channel chat media SELECT policies broke anon and non-member signed-URL reads. The signed-in read policy passes that segment to `internal.is_channel_member` and `internal.can_read_channel`.
44
+
-**Anon and signed-in non-members read only chat media that a live message names (#432).** Listing and signing both need SELECT on `storage.objects`, so the old PUBLIC check let the anon key list unsent uploads. Both `media` read policies in `scripts/12-buckets.sql` now also need a live `messages` row in that channel. One of its `medias` entries must have `path` (or `url`) equal to `objects.name`. Active channel members keep read on every object. That member arm is load-bearing: the upload readback and `validate_message_medias` (SECURITY INVOKER) read an unsent object as the uploader. A signed-in viewer joins a PUBLIC channel when they open it, so the gain is mostly for anon. Measured 2026-10-09 on a 5,000-message channel: one sign takes 6.7 ms as anon and 33 ms as a non-member at worst. A 100-row list takes 136 ms as anon and 303 ms as a non-member. The match is raw, not `internal.normalize_chat_media_path`, because that helper stops inlining and keeps signed URLs as they are.
43
45
-**Avatar source-of-truth is a two-field hybrid.**`users.avatar_url` holds the OAuth provider URL (set by `handle_new_user` from Google's `picture` claim on signup); `users.avatar_updated_at` is the "user has uploaded a custom avatar" indicator. `<Avatar>` reads `avatar_updated_at` first — if set, derives the bucket URL `${BUCKET}/${id}/avatar.png?${avatar_updated_at}` (the timestamp doubles as cache-buster); if null, falls back to `src` (= `avatar_url` = OAuth URL). `useAvatarUpload.handleUpload` overwrites `avatar_url` with the bucket URL. After a custom upload, `avatar_url` is the bucket URL, not the OAuth one. `handleRemove` nulls BOTH `avatar_url` and `avatar_updated_at`. It writes DB-first, before deleting the storage object, so a render never points at a deleted file. Removing a custom avatar therefore drops to the default avatar; it does not restore the OAuth picture.
44
-
-**`user_avatars` rejects AVIF.** Bucket allowlist in `scripts/12-buckets.sql` is jpeg/png/gif/webp/svg only; the file picker’s `image/*` still admits AVIF on macOS. Always run `prepareAvatarFile` (`settings/utils/`) before upload, so unsupported/oversized images become bucket-safe PNG/WebP/JPEG. Do not widen the bucket MIME list, and do not merge this helper into chat-media downscale.
46
+
-**`user_avatars` rejects AVIF.** Bucket allowlist in `scripts/12-buckets.sql` is jpeg/png/gif/webp only. SVG was removed because a public bucket serves it inline, and an SVG can carry script (#431). The file picker’s `image/*` still admits AVIF on macOS. Always run `prepareAvatarFile` (`settings/utils/`) before upload, so unsupported/oversized images become bucket-safe PNG/WebP/JPEG. Do not widen the bucket MIME list, and do not merge this helper into chat-media downscale.
45
47
-**Channel/workspace IDs are `varchar(36)`, not `uuid`.**`channels.id`, `channels.workspace_id`, and `workspaces.id` are declared `varchar(36) default uuid_generate_v4()` (`scripts/04-channels.sql:5-6`) — they hold UUID-shaped text in a `varchar` column, and `messages.channel_id` follows the same typing via its FK. SQL that compares them against a real `uuid` (e.g. `auth.uid()`, a `uuid`-typed column, or a `::uuid` literal) must cast both sides to the same type or Postgres throws `operator does not exist: character varying = uuid`. This bit the chat-media storage RLS / `validate_message_medias` path, where the `media` object path segment (text) is compared to `channel_id`. Keep those comparisons text-vs-text (or cast explicitly), never `varchar = uuid`.
46
48
-**RLS predicates initplan-wrap auth calls** (Supabase lint 0003): write `(select auth.uid())`, never bare `auth.uid()`. For row-independent helper calls, wrap the whole call — `(select public.is_admin((select auth.uid())))`. The linter also flags a bare `auth.<fn>()` nested inside helper-call arguments. The 2026-07-17 sweep cleared all 21; keep new policies clean.
47
49
- **`users.profile_data` is public; private settings live in `users.notification_preferences`** (moved 2026-09-30, migration `20260930120000_private_notification_preferences.sql`). `users_select` is `USING (true)` and `profile_data` is in the `anon` and `authenticated` SELECT grants, so every visitor reads it. The new column is in no client grant, and Realtime drops it from `postgres_changes` too, because `realtime.apply_rls` checks `has_column_privilege`. The owner reads it through `get_notification_preferences()` and writes it through `update_notification_preferences()`. The `profile_data_has_no_notification_preferences` check keeps the key from coming back, and a stale client that sends it gets `23514`. Never put a private field in `profile_data`, and never add the column to a grant list. `users_profile_changed` sends a value-free `profile_changed` on the private `profile:<uid>` topic (`profile_topic_access`). Its column list and `IS DISTINCT FROM` guard keep the status heartbeat and cron out; keep both when adding a column. `update_notification_preferences()` checks each key that the push and email triggers cast, and refuses a bad value with `22023` `invalid_preference_value` (#417). A bad value used to abort message inserts. A new key that a trigger casts needs a check there too.
0 commit comments