Skip to main content
Notifications are server-owned. Web, satellite and mobile clients read and update them only through the notification API routes, which use the service role after resolving the signed-in user. The anon and authenticated API roles hold no privileges on public.notifications. The row-level policies are kept as defence in depth.

Who sees a notification

  • A notification with a recipient account (user_id) is visible only to that account. Email is never matched on such rows, so later address changes or reuse of the address by another account do not expose it. New and updated rows do not keep a copy of the recipient’s address; copies stored before this rule are inert and left for a separate batched cleanup.
  • A row is addressed by email only while it is an unclaimed workspace invitation (type = 'workspace_invite' with no user_id). It is shown to the account whose confirmed address matches. Unconfirmed addresses never match.
  • Addresses are stored trimmed and lowercased, and compared the same way.

Claiming invitations

An unclaimed invitation is assigned to an account when:
  • the account is created with an already-confirmed address (for example OAuth),
  • the account confirms its address, or
  • a confirmed account changes to a new confirmed address.
Only unclaimed invitation rows are claimed; rows that belong to another account are never reassigned. sync_my_notifications() applies the same rule on demand for the caller’s confirmed address.

Account deletion

Deleting a public.users row deletes that account’s notifications instead of leaving them without a recipient. The rollout migration removed rows left behind by deleted accounts: recipient-less rows that were not invitations, and recipient-less invitations with no pending email invite for that workspace and address.

Scope and exceptions

  • Applies to every app that shows the notification inbox: web, satellites through the shared notification popover, and mobile through the API.
  • The account email helper used by these checks, public.get_current_user_email(), resolves only the caller’s own confirmed address.
  • Delivery (email and push batches) is unchanged; batch rows keep the delivery address they need.
  • Notifications are no longer published to Supabase Realtime; clients refresh through the API.
Regression coverage: apps/database/supabase/tests/security-notification-email-access.sql and apps/database/supabase/tests/security-user-email-helper.sql, run by the security database contract job (apps/database/scripts/verify-security-egress-contract.mjs).