From 4245b76d532ff8bc28fc88d1397f3e6f4f655dbe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 23:12:51 +0000 Subject: [PATCH] security: private-by-default boards with membership + capability share tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- README.md | 2 +- apps/web/src/env.ts | 6 + apps/web/src/features/board/boardOwnership.ts | 56 +++ apps/web/src/features/canvas/CollabBar.tsx | 31 +- apps/web/src/routes/Board.tsx | 60 ++- docs/ROADMAP.md | 27 ++ docs/SETUP.md | 41 +- packages/canvas/src/MediaOverlayLayer.tsx | 41 +- packages/canvas/src/assets/storage.ts | 26 +- packages/canvas/src/store/shapeStore.ts | 12 +- packages/sync/src/supabaseProvider.ts | 8 +- .../migrations/0005_security_membership.sql | 409 ++++++++++++++++++ 12 files changed, 668 insertions(+), 51 deletions(-) create mode 100644 supabase/migrations/0005_security_membership.sql diff --git a/README.md b/README.md index 52dfc59..e4bdea5 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ SF Symbols, and the folder-based board library) are in place. The product-wide UX redesign — audit, design system, interaction specs, benchmarks, and the prioritised roadmap — lives in [`docs/redesign/`](docs/redesign/README.md). -Realtime requires Supabase Realtime to be reachable by the `anon` role for the `notux-board-*` broadcast topics (the default, no-authorization Realtime mode works out of the box). Late-joiners get current board state from connected peers; persisting snapshots server-side for offline late-joiners is a later milestone. +Access is membership-based: boards are private to their owner and shared through revocable, expiring **capability links** (see `0005_security_membership.sql` and [`docs/SETUP.md`](docs/SETUP.md)); uploaded materials live in a private Storage bucket served via signed URLs. Realtime uses the `notux-board-*` topics — public channels by default, or RLS-authorized **private channels** when `VITE_REALTIME_PRIVATE=true` and "Allow public access" is disabled in Realtime settings. Late-joiners get current board state from connected peers and the durable autosave snapshot. ## Repo layout diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 42a00b9..38743fa 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -1,14 +1,20 @@ interface EnvShape { supabaseUrl: string; supabaseAnonKey: string; + // Opt in to RLS-authorized private Realtime channels. Enable only after + // disabling "Allow public access" in the project's Realtime settings and + // applying 0005_security_membership.sql. + realtimePrivate: boolean; } const url = import.meta.env.VITE_SUPABASE_URL as string | undefined; const key = import.meta.env.VITE_SUPABASE_ANON_KEY as string | undefined; +const realtimePrivate = import.meta.env.VITE_REALTIME_PRIVATE as string | undefined; export const env: EnvShape = { supabaseUrl: url ?? "", supabaseAnonKey: key ?? "", + realtimePrivate: realtimePrivate === "true" || realtimePrivate === "1", }; export const supabaseConfigured = Boolean(env.supabaseUrl && env.supabaseAnonKey); diff --git a/apps/web/src/features/board/boardOwnership.ts b/apps/web/src/features/board/boardOwnership.ts index 3284091..031f8f9 100644 --- a/apps/web/src/features/board/boardOwnership.ts +++ b/apps/web/src/features/board/boardOwnership.ts @@ -86,3 +86,59 @@ export async function setBoardVisibility( .eq("id", boardId); return error ? { ok: false, error: error.message } : { ok: true }; } + +// Capability-token sharing (0005_security_membership.sql). Instead of flipping a +// board world-readable, the owner mints a high-entropy token bound to a role and +// expiry; only its hash is stored server-side. The raw token rides in the URL +// fragment (never sent to the server as a query param / never logged) and is +// redeemed once into a scoped membership. +export type ShareRole = "co_teach" | "draw" | "view"; + +const ACCESS_HASH_KEY = "access"; + +// Build a share URL carrying a fresh capability token in its fragment. Owner-only +// (enforced by the create_board_share RPC). Returns null when not connected or +// the mint is rejected. +export async function createShareLink( + client: SupabaseClient | null, + boardId: string, + role: ShareRole = "draw", + ttlSeconds = 7 * 24 * 60 * 60, +): Promise<{ url: string } | { error: string }> { + if (!client) return { error: "Not connected." }; + const { data, error } = await client.rpc("create_board_share", { + p_board_id: boardId, + p_role: role, + p_ttl_seconds: ttlSeconds, + }); + if (error || !data) return { error: error?.message ?? "Could not create link." }; + const base = `${window.location.origin}${import.meta.env.BASE_URL}board/${boardId}`; + return { url: `${base}#${ACCESS_HASH_KEY}=${data as string}` }; +} + +// If the current URL fragment carries a capability token, redeem it into a +// membership before the board loads, minting an anonymous guest session first +// when the visitor isn't signed in (so even an account-less student gets a +// stable id for RLS instead of relying on open public access). The token is +// stripped from the URL on success so it isn't re-shared or left in history. +export async function redeemAccessFromHash( + client: SupabaseClient | null, +): Promise { + if (!client || typeof window === "undefined") return; + const hash = window.location.hash.replace(/^#/, ""); + const token = new URLSearchParams(hash).get(ACCESS_HASH_KEY); + if (!token) return; + + const { data: auth } = await client.auth.getUser(); + if (!auth.user) { + // Anonymous sign-in must be enabled in the project's Auth settings. + const { error } = await client.auth.signInAnonymously(); + if (error) return; + } + + await client.rpc("redeem_share_token", { p_token: token }); + + // Remove the token from the address bar / history regardless of outcome. + const clean = `${window.location.pathname}${window.location.search}`; + window.history.replaceState(null, "", clean); +} diff --git a/apps/web/src/features/canvas/CollabBar.tsx b/apps/web/src/features/canvas/CollabBar.tsx index 305c9ab..3d87927 100644 --- a/apps/web/src/features/canvas/CollabBar.tsx +++ b/apps/web/src/features/canvas/CollabBar.tsx @@ -7,7 +7,7 @@ import { useRemoteCursors, type RemoteCursor, } from "@notux/canvas"; -import { setBoardVisibility } from "../board/boardOwnership"; +import { createShareLink, setBoardVisibility } from "../board/boardOwnership"; import type { Identity } from "./useIdentity"; interface Props { @@ -83,6 +83,7 @@ export function CollabBar({ boardId, client, owned, isPublic, identity }: Props) const [shareOpen, setShareOpen] = useState(false); const [linkCopied, setLinkCopied] = useState(false); + const [inviteCopied, setInviteCopied] = useState(false); const [pub, setPub] = useState(isPublic); const [busy, setBusy] = useState(false); const shareBtnRef = useRef(null); @@ -97,6 +98,18 @@ export function CollabBar({ boardId, client, owned, isPublic, identity }: Props) }); } + async function copyInvite() { + if (!client) return; + setBusy(true); + const res = await createShareLink(client, boardId, "draw"); + setBusy(false); + if ("url" in res) { + await navigator.clipboard?.writeText(res.url); + setInviteCopied(true); + window.setTimeout(() => setInviteCopied(false), 1500); + } + } + async function toggleVisibility() { if (!client) return; setBusy(true); @@ -198,6 +211,22 @@ export function CollabBar({ boardId, client, owned, isPublic, identity }: Props) {linkCopied ? "Link copied" : "Copy link"} + {client && owned && ( + + )} {client && owned && (