Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/env.ts
Original file line number Diff line number Diff line change
@@ -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);
56 changes: 56 additions & 0 deletions apps/web/src/features/board/boardOwnership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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);
}
31 changes: 30 additions & 1 deletion apps/web/src/features/canvas/CollabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<HTMLButtonElement>(null);
Expand All @@ -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);
Expand Down Expand Up @@ -198,6 +211,22 @@ export function CollabBar({ boardId, client, owned, isPublic, identity }: Props)
{linkCopied ? "Link copied" : "Copy link"}
</span>
</button>
{client && owned && (
<button
type="button"
className="menu__item"
disabled={busy}
onClick={() => void copyInvite()}
>
<span className="menu__item-icon">
<Icon name={inviteCopied ? "check" : "link"} size={18} />
</span>
<span className="menu__item-label">
{inviteCopied ? "Invite copied" : "Invite to edit"}
</span>
<span className="menu__item-shortcut">expires 7d</span>
</button>
)}
{client && owned && (
<button
type="button"
Expand Down
60 changes: 38 additions & 22 deletions apps/web/src/routes/Board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ import { FollowPill } from "../features/canvas/FollowPill";
import { SaveStatus } from "../features/canvas/SaveStatus";
import { SelectionToolbar } from "../features/canvas/SelectionToolbar";
import { useIdentity } from "../features/canvas/useIdentity";
import { ensureBoardOwnership } from "../features/board/boardOwnership";
import {
ensureBoardOwnership,
redeemAccessFromHash,
} from "../features/board/boardOwnership";
import { useLibraryStore } from "../features/library/libraryStore";
import { getSupabase } from "../lib/supabase";
import { env } from "../env";

export default function Board() {
const { boardId } = useParams<{ boardId: string }>();
Expand All @@ -38,27 +42,39 @@ export default function Board() {
if (!boardId) return;
setReady(false);
setOwned(false);
// Enable realtime collaboration when Supabase is configured; otherwise the
// board runs in local-only mode against IndexedDB.
useShapeStore
.getState()
.configureRealtime(client ? { client, identity } : null);
// Bytes live in Supabase Storage; import is disabled when client is null.
useAssetStore.getState().configure({ boardId, client });
useShapeStore
.getState()
.initBoard(boardId)
.then(() => {
// Seed/migrate the page list against the IndexedDB-hydrated doc.
usePageStore.getState().initPages(boardId);
useSettingsStore.getState().initSettings(boardId);
setReady(true);
// Claim board ownership when signed in — gates named snapshots.
void ensureBoardOwnership(client, boardId).then((r) => {
setOwned(r.owned);
setIsPublic(r.isPublic);
});
});
let cancelled = false;
void (async () => {
// Redeem a capability token from the URL fragment (and mint a guest
// session if needed) BEFORE any board read, so membership-gated RLS lets
// this client load assets/snapshots and join the realtime channel.
await redeemAccessFromHash(client);
if (cancelled) return;
// Enable realtime collaboration when Supabase is configured; otherwise the
// board runs in local-only mode against IndexedDB.
useShapeStore
.getState()
.configureRealtime(
client
? { client, identity, privateChannel: env.realtimePrivate }
: null,
);
// Bytes live in Supabase Storage; import is disabled when client is null.
useAssetStore.getState().configure({ boardId, client });
await useShapeStore.getState().initBoard(boardId);
if (cancelled) return;
// Seed/migrate the page list against the IndexedDB-hydrated doc.
usePageStore.getState().initPages(boardId);
useSettingsStore.getState().initSettings(boardId);
setReady(true);
// Claim board ownership when signed in — gates named snapshots.
const r = await ensureBoardOwnership(client, boardId);
if (cancelled) return;
setOwned(r.owned);
setIsPublic(r.isPublic);
})();
return () => {
cancelled = true;
};
}, [boardId, identity, client]);

if (!ready) {
Expand Down
27 changes: 27 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,33 @@ direction but re-prioritizes around the genuine, verified gaps.
Strategic posture is unchanged: keep the Konva + Yjs + Supabase Realtime + Liquid Glass
stack; treat Excalidraw/tldraw/OpenBoard as reference implementations, not dependencies.

## M-Sec — Private-by-default authorization ✅ (this PR)

**The security gate.** Replaces the "free-for-all public board" model (a leaked
board UUID granted read/write/delete on another tutor's lesson, world-readable
uploads, and unauthenticated Realtime) with an explicit membership model.

- `supabase/migrations/0005_security_membership.sql`: `board_members` +
`board_shares` (hashed, expiring, revocable capability tokens); an owner-
enrollment trigger; RLS rewritten so reads are owner/member (legacy `is_public`
kept only as a transitional read path) and **writes/deletes require an editing
membership**; `board-assets` flipped to a **private** bucket with membership-
gated storage RLS; `create_board_share` / `redeem_share_token` RPCs; and
`realtime.messages` policies for private channel authorization.
- `packages/canvas/src/assets/storage.ts` + `MediaOverlayLayer.tsx`: assets are
served via short-lived **signed URLs** instead of a permanent public URL.
- `apps/web/.../boardOwnership.ts` + `Board.tsx` + `CollabBar.tsx`: an 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.
- Private Realtime channels are gated behind `VITE_REALTIME_PRIVATE` (off by
default) so the transport can cut over after the Dashboard toggle + anon
sign-in are in place.

*Exit criterion:* an anon client with only a board UUID (no invite) is denied
read/write on another board's rows, assets, and realtime topic; an invited guest
gains exactly the shared role.

## M10 — Durable Yjs autosave persistence ✅ (this PR)

**The critical architectural fix.** Board state previously lived only in each client's
Expand Down
41 changes: 39 additions & 2 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,35 @@ supabase db push
user's account info is readable only by themselves) and a trigger that
auto-creates/refreshes a profile from auth metadata on every sign-in.

`0005_security_membership.sql` is the security hardening migration. It replaces
the old "free-for-all public board" model with explicit membership
(`board_members`) and hashed, expiring **capability share tokens**
(`board_shares`). After applying it:

- A board's creator is auto-enrolled as `owner`. Reads are allowed for
owners/members (and, transitionally, legacy `is_public` boards); **writes and
deletes require an owning or editing membership** — a leaked board UUID no
longer lets a stranger overwrite or wipe a lesson.
- The **`board-assets` bucket is flipped to private**. Asset bytes are served
only via short-lived signed URLs whose issuance is gated by the same
membership RLS.
- `realtime.messages` policies are added so Realtime channels can be authorized
by membership once you switch them to private (see step 2a).

### 2a. Enable guest access and (optionally) private Realtime — **manual**

The capability-token flow lets an account-less student join a shared board:

1. **Authentication → Sign In / Providers → Anonymous sign-ins: enable.** When a
visitor opens an invite link, the app mints an anonymous session and redeems
the token into a scoped membership. Without this, only signed-in users can
redeem invites.
2. *(Recommended)* **Realtime → Settings → disable "Allow public access"**, then
build the web app with `VITE_REALTIME_PRIVATE=true`. The client then opens
each board channel as a private, RLS-authorized channel so only members can
subscribe or broadcast. Leave this off until the policies in 0005 are applied
and anonymous sign-in works, or realtime collaboration will fail to connect.

## 3. Google OAuth (required for "Sign in with Google")

### 3a. Create a Google OAuth client — **you must do this manually**
Expand Down Expand Up @@ -107,8 +136,16 @@ pnpm dev # http://localhost:5173

- **Account/login info** lives in `profiles`, gated by RLS to the owning user
only — no other user can read it.
- **Annotations** on a private board are reachable only by the board owner
(RLS predicate: `is_public OR owner_id = auth.uid()`).
- **Access is membership-based** (`board_members`). The owner and invited
collaborators can read a board; only owners and editing members can write or
delete. A board UUID alone grants nothing — the old enumeration/leak path is
closed.
- **Sharing uses capability tokens** (`board_shares`): the owner mints a
high-entropy, role-scoped, expiring link; only its SHA-256 hash is stored, and
it's redeemed once into a membership. A guest student redeems via an anonymous
session rather than open public access.
- **Uploaded materials** (PDF/image/audio) live in a **private** Storage bucket
and are reachable only through short-lived signed URLs gated by membership.
- **Presence** (collaborator names/colors/avatars) is derived from each peer's
own session and broadcast peer-to-peer over Realtime awareness — never by
reading another user's profile row.
Expand Down
41 changes: 26 additions & 15 deletions packages/canvas/src/MediaOverlayLayer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { CSSProperties } from "react";
import { useEffect, useState, type CSSProperties } from "react";
import type { YEmbed, YShape } from "@notux/types";
import { assetPublicUrl } from "./assets/storage";
import { assetSignedUrl } from "./assets/storage";
import { useAssetStore } from "./store/assetStore";
import type { ViewportState } from "./viewport/Viewport";

Expand Down Expand Up @@ -72,16 +72,9 @@ function EmbedPlayer({
};

if (shape.embedType === "audio") {
const src = resolveAudioSrc(shape.assetId);
return (
<div style={wrap}>
{src ? (
<audio
controls
src={src}
style={{ width: "100%", display: "block" }}
/>
) : null}
<AudioPlayer assetId={shape.assetId} />
</div>
);
}
Expand All @@ -102,9 +95,27 @@ function EmbedPlayer({
);
}

function resolveAudioSrc(assetId: string | undefined): string | null {
if (!assetId) return null;
const { _client, _boardId } = useAssetStore.getState();
if (!_client || !_boardId) return null;
return assetPublicUrl(_client, _boardId, assetId);
// The bucket is private, so an audio src is a short-lived signed URL minted on
// demand. Resolve it asynchronously and render an empty player until it lands
// (or stays empty if signing is denied/fails).
function AudioPlayer({ assetId }: { assetId: string | undefined }) {
const [src, setSrc] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
setSrc(null);
const { _client, _boardId } = useAssetStore.getState();
if (!assetId || !_client || !_boardId) return;
void assetSignedUrl(_client, _boardId, assetId).then((url) => {
if (!cancelled) setSrc(url);
});
return () => {
cancelled = true;
};
}, [assetId]);

if (!src) return null;
return (
<audio controls src={src} style={{ width: "100%", display: "block" }} />
);
}
Loading
Loading