Skip to content

Security: private-by-default boards with membership + capability share tokens#20

Merged
pedrobritx merged 1 commit into
mainfrom
claude/notux-security-privacy-5lyohk
Jun 13, 2026
Merged

Security: private-by-default boards with membership + capability share tokens#20
pedrobritx merged 1 commit into
mainfrom
claude/notux-security-privacy-5lyohk

Conversation

@pedrobritx

Copy link
Copy Markdown
Owner

Why

This is the Stage 0 security gate from the requirements report. The old "free-for-all public board" model was the product's biggest liability: a leaked or guessed board UUID granted full read/write/delete on another tutor's lesson, uploaded materials (PDFs/images/audio — potentially a minor's) were world-readable, and Realtime was unauthenticated. For a product whose students may be minors this is a GDPR/COPPA/Children's-Code exposure, not just a bug.

Concretely, this closes:

  • boards_insert_any WITH CHECK (true) — anyone (incl. anon) could insert unlimited boards.
  • is_public granting read and write/delete to anyone holding a UUID.
  • the public board-assets Storage bucket (world-readable + writable).
  • anon-role Realtime broadcast with no authorization.

What changed

Server — supabase/migrations/0005_security_membership.sql

  • board_members (owner / co_teach / draw / view) is the access source of truth; a board's creator is auto-enrolled as owner via trigger + backfill.
  • board_shares holds hashed, expiring, revocable capability tokens. create_board_share / redeem_share_token are SECURITY DEFINER RPCs — the raw token is returned once and only its SHA-256 hash is stored, so leaking the table doesn't leak access.
  • RLS rewritten: reads for owner/member (is_public kept only as a transitional read path); writes/deletes require an editing membership. Board creation now requires auth.
  • board-assets flipped to a private bucket with membership-gated storage RLS.
  • realtime.messages policies authorize private Broadcast channels by membership.

Client

  • Assets served via short-lived signed URLs instead of a permanent public URL (storage.ts, async audio resolution in MediaOverlayLayer).
  • Owner can mint an "Invite to edit" capability link; opening it redeems the token into a membership, minting an anonymous guest session first so account-less students still get a stable id for RLS (boardOwnership, Board, CollabBar).
  • Private Realtime channels gated behind VITE_REALTIME_PRIVATE (off by default) for a safe transport cutover.

DocsSETUP.md / README.md / ROADMAP.md updated, including the required Dashboard steps.

⚠️ Operator actions required after merge

  1. Apply 0005_security_membership.sql (supabase db push).
  2. Auth → enable Anonymous sign-ins (so guests can redeem invite links).
  3. (Optional, recommended) Realtime → disable "Allow public access" and build with VITE_REALTIME_PRIVATE=true to authorize channels by membership.

Existing is_public boards keep working read-only during the transition; sharing moves to capability links. A later migration can drop the is_public read path to make boards strictly private.

Verification

Typecheck + production build pass. The migration was applied to a throwaway local Postgres with Supabase stubs (auth/storage/realtime) and the RLS model was exercised end-to-end, asserting the intended allow/deny for owner, stranger, anon, and invited guest/viewer:

  • private board invisible to stranger & anon; visible to owner/members ✅
  • stranger/anon writes to snapshots/assets → RLS denied ✅
  • anon board insert → denied (free-for-all hole closed) ✅
  • guest redeems a draw token → gains read + autosave write; cannot create named snapshots ✅
  • view member is read-only (write denied) ✅
  • invalid / expired / revoked tokens → rejected; non-owner cannot mint a share ✅
  • private-bucket storage objects invisible to non-members, visible to members ✅
  • realtime topic parser maps notux-board-<uuid> correctly and denies foreign/malformed topics ✅

https://claude.ai/code/session_01Q9XUUYNL5QJtVQRr4iYFFE


Generated by Claude Code

…e tokens

Replaces the free-for-all public-board model with explicit authorization,
closing the core defect: a leaked/guessed board UUID granted read/write/delete
on another tutor's lesson, uploaded materials were world-readable, and Realtime
was unauthenticated.

Server (0005_security_membership.sql):
- board_members (owner/co_teach/draw/view) is the access source of truth; a
  board's creator is auto-enrolled as owner via trigger + backfill.
- board_shares holds hashed, expiring, revocable capability tokens; create/redeem
  via SECURITY DEFINER RPCs (raw token returned once, only its SHA-256 stored).
- RLS rewritten: reads for owner/member (is_public kept only as a transitional
  read path); writes/deletes require an editing membership. Removes
  boards_insert_any WITH CHECK (true) — board creation now requires auth.
- board-assets flipped to a private bucket with membership-gated storage RLS.
- realtime.messages policies authorize private Broadcast channels by membership.

Client:
- Assets served via short-lived signed URLs instead of a permanent public URL
  (storage.ts, MediaOverlayLayer async audio resolution).
- Owner can mint an "Invite to edit" capability link; opening it redeems the
  token into a membership, minting an anonymous guest session first so
  account-less students still get a stable id for RLS (boardOwnership, Board,
  CollabBar).
- Private Realtime channels gated behind VITE_REALTIME_PRIVATE (off by default)
  for a safe transport cutover after the Dashboard toggle + anon sign-in.

Docs: SETUP/README/ROADMAP updated with the new model and required settings.

Verified by applying 0001-0005 to a local Postgres with Supabase stubs and
asserting allow/deny for owner, stranger, anon, and invited guest/viewer across
boards, snapshots, assets, storage objects, and token redemption (valid /
invalid / expired / revoked / non-owner-mint).
@pedrobritx pedrobritx marked this pull request as ready for review June 13, 2026 23:27
@pedrobritx pedrobritx merged commit 4c747dc into main Jun 13, 2026
4 checks passed
@pedrobritx pedrobritx deleted the claude/notux-security-privacy-5lyohk branch June 13, 2026 23:27

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

await useShapeStore.getState().initBoard(boardId);

P1 Badge Create the board before opening private Realtime

When VITE_REALTIME_PRIVATE=true and a signed-in user creates a new board, this initBoard call constructs the private SupabaseProvider before ensureBoardOwnership has inserted the boards row and owner membership. The provider subscribes only once (packages/sync/src/supabaseProvider.ts:76-92), while the new Realtime policy requires can_read_board(...), so the initial authorization can be denied and collaboration stays disconnected until a reload even though ownership is granted a few lines later.



P2 Badge Refresh signed audio URLs before they expire

For audio embeds, the signed URL is minted only when assetId changes, but assetSignedUrl uses a one-hour TTL. If a lesson remains open longer than that and the audio element later starts playback or needs another range request, it will keep using an expired URL and the controls fail until the component remounts; schedule a refresh before expiry or retry signing on media errors.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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