Skip to content

OUT-3846: broadcast realtime status via DB instead of postgres_changes#112

Merged
SandipBajracharya merged 6 commits into
mainfrom
OUT-3846
Jun 12, 2026
Merged

OUT-3846: broadcast realtime status via DB instead of postgres_changes#112
SandipBajracharya merged 6 commits into
mainfrom
OUT-3846

Conversation

@SandipBajracharya

Copy link
Copy Markdown
Collaborator

Summary

Replaces whole-row postgres_changes realtime subscriptions with curated, secret-free DB broadcasts over private channels, so the browser (anon key) gets live status updates without ever reading our tables directly.

What changed

  • Trigger functions (AFTER UPDATE) call realtime.send() with hand-picked, non-secret columns only:
    • channel_sync:<portal_id> / sync_update — suppresses no-op (cursor-only) updates so the sync hot path doesn't flood clients
    • dropbox_connection:<portal_id> / connection_update — fires only on status change
  • Client (useRealtime) subscribes to per-portal private channels; useRealtimeSync / useRealtimeDropboxConnections updated to the curated payload shape
  • supabase/snippets/2026-06-12-rls_and_anon_policy_to_realtime.sql (run manually in the SQL editor): enables RLS on all public tables, revokes anon access, and adds the anon SELECT policy on realtime.messages scoped to the two topics
  • docs/realtime-rls.md documents the model
  • .gitignore: track docs/, ignore .claude/settings.local.json

Why it's safe

Layer Guarantee
RLS on + anon revoked browser can't touch app tables
Curated payload only non-secret columns leave the DB
Private channel + topic-scoped policy anon receives only our two topics
postgres role BYPASSRLS server sync jobs unaffected

Verified anon already has the default SELECT grant on realtime.messages, so the RLS policy alone is sufficient (no extra GRANT needed).

Deploy notes

The Drizzle migration adds the triggers/functions. The supabase/snippets/...sql file must be run manually in the Supabase SQL editor (it touches the realtime schema and performs one-time RLS/REVOKE ops) — it is not auto-applied.

🤖 Generated with Claude Code

…changes

Replace whole-row postgres_changes subscriptions with curated, secret-free
DB broadcasts over private channels:

- AFTER UPDATE triggers call realtime.send() with hand-picked columns only
- channel_sync broadcast suppresses no-op (cursor-only) updates
- client subscribes to per-portal private topics via useRealtime
- snippet enables RLS on all public tables, revokes anon, and adds the
  anon SELECT policy on realtime.messages (run manually in SQL editor)
- docs/realtime-rls.md explains the model

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jun 12, 2026

Copy link
Copy Markdown

OUT-3846

@vercel

vercel Bot commented Jun 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dropbox-integration Ready Ready Preview, Comment Jun 12, 2026 11:40am

Request Review

@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown

Greptile Summary

Replaces postgres_changes realtime subscriptions with DB-broadcast private channels, so the browser (anon key) receives live sync and connection-status updates without any direct table access. Trigger functions emit hand-picked, secret-free payloads; a manually-run SQL snippet enables RLS on all public tables and revokes anon grants.

  • Trigger layer (20260611081157_realtime_broadcast_triggers.sql): two AFTER UPDATE triggers call realtime.send() with curated payloads; channel_sync suppresses cursor-only no-ops to avoid flooding clients.
  • Client layer (useRealtime, useRealtimeSync, useRealtimeDropboxConnections): migrated to private-channel broadcast subscriptions with dedicated narrow types matching the trigger payload shapes.
  • Security snippet (supabase/snippets/...sql): enables RLS on realtime.messages, creates a topic-scoped anon SELECT policy, and revokes anon on all current public tables — but does not cover tables added in the future (missing ALTER DEFAULT PRIVILEGES REVOKE).

Confidence Score: 4/5

Safe to merge for the changes it makes today; the security model works correctly for all existing tables, but the anon revocation is incomplete going forward.

The trigger functions, private-channel client, and realtime.messages policy are all correct for the current table set. The one concrete gap is that REVOKE ... ON ALL TABLES in the manual snippet only covers tables that exist now — the next table added via a Drizzle migration will inherit Supabase's default anon grants unless ALTER DEFAULT PRIVILEGES REVOKE is also added.

supabase/snippets/2026-06-12-rls_and_anon_policy_to_realtime.sql — the REVOKE statement needs ALTER DEFAULT PRIVILEGES to cover future tables.

Important Files Changed

Filename Overview
supabase/snippets/2026-06-12-rls_and_anon_policy_to_realtime.sql Enables RLS on realtime.messages, revokes anon on existing public tables, and creates the broadcast policy — but lacks ALTER DEFAULT PRIVILEGES REVOKE so future tables inherit anon grants by default.
src/db/migrations/20260611081157_realtime_broadcast_triggers.sql Adds two AFTER UPDATE trigger functions that call realtime.send() with curated, secret-free payloads; correctly suppresses no-op cursor-only writes for channel_sync.
src/lib/supabase/hooks/useRealtime.tsx Refactored from postgres_changes to private-channel broadcast subscription; stable-ref callback pattern is correct, cleanup is handled.
src/features/sync/hooks/useRealtimeSync.ts Updated to broadcast pattern with ChannelSyncStatusBroadcast typed via Pick<ChannelSyncSelectType, ...>; camelcaseKeys correctly bridges the snake_case trigger payload to the camelCase type.
src/features/auth/hooks/useRealtimeDropboxConnetions.ts Clean migration to broadcast; dedicated DropboxConnectionStatusBroadcast type exactly mirrors the trigger payload.
src/db/migrations/meta/20260611081157_snapshot.json Drizzle snapshot records isRLSEnabled: false for all tables, diverging from the actual post-snippet DB state; no .enableRLS() in the schema means Drizzle has no awareness of the manually applied RLS.

Reviews (2): Last reviewed commit: "docs(OUT-3846): document cross-portal br..." | Re-trigger Greptile

Comment thread supabase/snippets/2026-06-12-rls_and_anon_policy_to_realtime.sql
Comment thread src/features/sync/hooks/useRealtimeSync.ts Outdated
Comment thread supabase/snippets/2026-06-12-rls_and_anon_policy_to_realtime.sql
Make the snippet self-contained: enable RLS before creating the policy so
it can't silently become a no-op in a fresh or rolled-back environment.
Idempotent and safe to re-run (postgres owns it via supabase_realtime_admin).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the Record<string, unknown> + cast-to-full-row with a dedicated
Pick<ChannelSyncSelectType, ...> mirroring the trigger's exact payload, so
fields the broadcast never sends (dbxAccountId/dbxCursor/dbxRootId) aren't
falsely typed as present. Mirrors useRealtimeDropboxConnections.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make explicit that topic ACLs aren't per-portal (anon has no JWT), so any
portal ID grants read access to that portal's curated payload. Add a warning
against adding sensitive fields to the broadcast payloads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@SandipBajracharya SandipBajracharya changed the title feat(OUT-3846): broadcast realtime status via DB instead of postgres_changes OUT-3846: broadcast realtime status via DB instead of postgres_changes Jun 12, 2026
@SandipBajracharya

Copy link
Copy Markdown
Collaborator Author

@greptileai

Comment thread supabase/snippets/2026-06-12-rls_and_anon_policy_to_realtime.sql
Comment thread src/lib/supabase/hooks/useRealtime.tsx Outdated
Comment thread src/lib/supabase/hooks/useRealtime.tsx
SandipBajracharya and others added 2 commits June 12, 2026 17:23
…g casts

- useRealtime: use REALTIME_LISTEN_TYPES.BROADCAST (correct overload) instead
  of casting 'broadcast' to the SYSTEM type, which silently typed payload as any
- useRealtimeSync: type TPayload as the snake_case wire payload and derive the
  camelCased shape via CamelCaseKeys, removing the as-ChannelSyncSelectType cast

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add drop trigger if exists before each create trigger so the migration is
safe to re-run, matching the create-or-replace style of the functions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@priosshrsth priosshrsth left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@SandipBajracharya SandipBajracharya merged commit e7624d6 into main Jun 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants