diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index c0f5b6f35..7d69a998a 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -85,15 +85,28 @@ jobs:
with:
version: 2.116.0
- - name: Start isolated database and apply migrations
- run: supabase db start
+ - name: Install dependencies
+ run: npm ci
- - name: Database policy tests
- run: npm run test:db
+ - name: Install Chromium
+ run: npx playwright install --with-deps chromium
- - name: Stop isolated database
- if: always()
- run: supabase stop --no-backup
+ - name: Database and real workspace journey
+ env:
+ WORKSPACE_GATE_ARTIFACT_ROOT: ${{ runner.temp }}/workspace-gate
+ run: ./.superset/db-gate.sh
+
+ - name: Upload workspace failure artifacts
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: workspace-gate-failure-${{ github.run_id }}
+ path: |
+ ${{ runner.temp }}/workspace-gate/
+ test-results/
+ playwright-report/
+ if-no-files-found: ignore
+ retention-days: 7
browser-tests:
if: github.event_name == 'pull_request'
diff --git a/.superset/db-gate.sh b/.superset/db-gate.sh
index aee389d45..f3e5b883f 100755
--- a/.superset/db-gate.sh
+++ b/.superset/db-gate.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
-# Runs the Docker-backed Supabase migration/pgTAP gate
-# (`supabase db start` + `npm run test:db`) with exclusive access across
+# Runs the Docker-backed Supabase, database, collaboration, and real-Auth browser gate
+# with exclusive access across
# Superset workspaces of this repo. `supabase/config.toml` sets no
# project_id or custom port block, so `supabase db start` always binds the
# CLI's default ports and container names - two workspaces running it at
@@ -14,14 +14,37 @@ source "$DIR/lib/lock.sh"
DB_LOCK="$HOME/.superset/debatelab-db.lock"
stack_started=false
+runtime_dir=""
+owned_pids=()
+
+stop_owned_processes() {
+ local index
+ for ((index=${#owned_pids[@]}-1; index>=0; index--)); do
+ kill -- "-${owned_pids[$index]}" 2>/dev/null || true
+ done
+ for ((index=${#owned_pids[@]}-1; index>=0; index--)); do
+ wait "${owned_pids[$index]}" 2>/dev/null || true
+ done
+}
cleanup() {
local status=$?
trap - EXIT
+ stop_owned_processes
+
+ if [ -n "$runtime_dir" ] && [ -f "$runtime_dir/function.env" ]; then
+ rm -- "$runtime_dir/function.env"
+ fi
+
if [ "$stack_started" = true ] && ! supabase stop --no-backup; then
[ "$status" -ne 0 ] || status=1
fi
+ if [ "$status" -ne 0 ] && [ -n "$runtime_dir" ]; then
+ echo "Workspace gate failed; local service logs retained at $runtime_dir"
+ elif [ -n "$runtime_dir" ] && [ -d "$runtime_dir" ]; then
+ rm -r -- "$runtime_dir"
+ fi
lock_release "$DB_LOCK"
exit "$status"
}
@@ -30,6 +53,69 @@ echo "Waiting for exclusive access to the local Supabase stack..."
lock_acquire "$DB_LOCK" 600
trap cleanup EXIT
-supabase db start
+if [ -n "${WORKSPACE_GATE_ARTIFACT_ROOT:-}" ]; then
+ mkdir -p -- "$WORKSPACE_GATE_ARTIFACT_ROOT"
+ runtime_dir="$(mktemp -d "$WORKSPACE_GATE_ARTIFACT_ROOT/workspace-gate.XXXXXX")"
+else
+ runtime_dir="$(mktemp -d)"
+fi
+supabase start >"$runtime_dir/supabase.log" 2>&1
stack_started=true
npm run test:db
+
+status_json="$(supabase status -o json 2>/dev/null)"
+status_value() {
+ node -e 'const value=JSON.parse(process.argv[1])[process.argv[2]]; if (!value) process.exit(1); process.stdout.write(String(value))' "$status_json" "$1"
+}
+api_url="$(status_value API_URL)"
+anon_key="$(status_value ANON_KEY)"
+service_role_key="$(status_value SERVICE_ROLE_KEY)"
+database_url="$(status_value DB_URL)"
+app_origin="http://127.0.0.1:18081"
+project_id="$(basename "$(pwd)")"
+mailer_host="$(docker ps --filter "name=supabase_inbucket_$project_id" --format '{{.Names}}')"
+if [ -z "$mailer_host" ]; then
+ echo "Local Mailpit container is unavailable" >&2
+ exit 1
+fi
+LOCAL_SERVICE_ROLE_KEY="$service_role_key" \
+LOCAL_MAILER_HOST="$mailer_host" \
+ node scripts/workspace-test-env.mjs .superset/workspace-test.env "$runtime_dir/function.env"
+
+setsid supabase functions serve workspace-invites --no-verify-jwt \
+ --env-file "$runtime_dir/function.env" >"$runtime_dir/functions.log" 2>&1 &
+owned_pids+=("$!")
+
+VITE_LOCAL_PREVIEW=true \
+VITE_LOCAL_SUPABASE_URL="$api_url" \
+VITE_LOCAL_SUPABASE_ANON_KEY="$anon_key" \
+VITE_COLLABORATION_URL=ws://127.0.0.1:12345 \
+ setsid npm run dev -- --host 127.0.0.1 --port 18081 --strictPort >"$runtime_dir/vite.log" 2>&1 &
+owned_pids+=("$!")
+
+mailer_url="$(status_value MAILPIT_URL)"
+for url in "$mailer_url/api/v1/messages" "$app_origin"; do
+ for _ in $(seq 1 200); do
+ if curl --fail --silent --output /dev/null "$url"; then break; fi
+ sleep 0.1
+ done
+ curl --fail --silent --output /dev/null "$url"
+done
+function_url="$api_url/functions/v1/workspace-invites"
+for _ in $(seq 1 200); do
+ if curl --fail --silent --output /dev/null --request OPTIONS --header "Origin: $app_origin" "$function_url"; then break; fi
+ sleep 0.1
+done
+curl --fail --silent --output /dev/null --request OPTIONS --header "Origin: $app_origin" "$function_url"
+
+LOCAL_SUPABASE_URL="$api_url" \
+LOCAL_SUPABASE_ANON_KEY="$anon_key" \
+LOCAL_SUPABASE_SERVICE_ROLE_KEY="$service_role_key" \
+WORKSPACE_TEST_MAILER_URL="$mailer_url" \
+COLLABORATION_DATABASE_URL="$database_url" \
+COLLABORATION_PORT=12345 \
+COLLABORATION_ALLOWED_ORIGINS="$app_origin" \
+SUPABASE_URL="$api_url" \
+SUPABASE_ANON_KEY="$anon_key" \
+PLAYWRIGHT_BASE_URL="$app_origin" \
+ npx playwright test --config=playwright.workspace.config.ts
diff --git a/.superset/workspace-test.env b/.superset/workspace-test.env
new file mode 100644
index 000000000..ee09e2f1b
--- /dev/null
+++ b/.superset/workspace-test.env
@@ -0,0 +1,7 @@
+# Test-only values for the locked local workspace browser gate.
+WORKSPACE_INVITE_SECRET=local-workspace-invite-secret-for-browser-gate
+WORKSPACE_INVITE_MAILER_URL=http://workspace-mailer.invalid:8025/api/v1/send
+WORKSPACE_INVITE_MAILER_TOKEN=local-workspace-mailer
+WORKSPACE_INVITE_MAILER_FORMAT=mailpit
+WORKSPACE_INVITE_BASE_URL=http://127.0.0.1:18081
+WORKSPACE_INVITE_ALLOWED_ORIGINS=http://127.0.0.1:18081
diff --git a/docs/development.md b/docs/development.md
index f5b2997bb..39a378f8f 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -77,15 +77,15 @@ Follow existing functional React, Zustand, Zod, and shadcn/Radix patterns. Reuse
Run the closest unit or component test while changing behavior. Run lint, type checking, and build before handing work off.
-## Collaboration service spike
+## Collaboration service
-The Phase 0 service evidence lives under `services/collaboration/`. Run:
+The local PostgreSQL-backed Hocuspocus service lives under `services/collaboration/`. Run its focused tests with:
```bash
npm run test:collaboration
```
-The spike is not a deployed service. Its in-memory writer lock is single-process evidence; the database-backed service and advisory-lock lifecycle belong to P2.1.
+Start it only with explicit local Supabase values for `COLLABORATION_DATABASE_URL`, `COLLABORATION_PORT`, `COLLABORATION_ALLOWED_ORIGINS`, `SUPABASE_URL`, and `SUPABASE_ANON_KEY`. The service binds loopback, takes one PostgreSQL session advisory lock per loaded document epoch, and closes sessions on access or restore notifications. It is not a production deployment.
## Edge Function workflow
@@ -119,7 +119,7 @@ npm run test:db
supabase stop --no-backup
```
-The default local stack begins at port `54321` and uses shared container names, so it is not parallel-safe across worktrees. The wrapper holds `~/.superset/debatelab-db.lock` and stops the stack on exit, including after a test failure. Outside Superset, coordinate ownership before starting it. The agent that starts the stack stops it before finishing, including after failures when safe.
+The default local stack begins at port `54321` and uses shared container names, so it is not parallel-safe across worktrees. The wrapper holds `~/.superset/debatelab-db.lock` through database tests and the real-Auth collaboration browser journey, and stops the stack and owned processes on exit. Outside Superset, coordinate ownership before starting it. The agent that starts the stack stops it before finishing, including after failures when safe.
Never point local tests at staging or production. Never apply a migration to an unverified project.
diff --git a/docs/testing.md b/docs/testing.md
index 3bdf38aa2..e0bef88d1 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -54,7 +54,7 @@ For a bug, watch the reproduction fail for the expected reason before implementi
npm run test:collaboration
```
-This runs the isolated Node service spike with real Hocuspocus providers and Yjs documents. It covers durable acknowledgement ordering, reconnect sync, duplicate delivery, schema and size rejection, writer serialization, stale epochs, and process-kill recovery. It does not prove PostgreSQL advisory locks or a deployed collaboration service.
+This runs the Node collaboration service with real Hocuspocus providers and Yjs documents. It covers durable acknowledgement ordering, reconnect sync, duplicate delivery, schema and size rejection, PostgreSQL adapter behavior, proposal decision rollback, writer serialization, stale epochs, and process-kill recovery. It does not prove a deployed service.
## Edge Function tests
@@ -136,7 +136,7 @@ In Superset, run:
./.superset/db-gate.sh
```
-The wrapper acquires `~/.superset/debatelab-db.lock`, starts the stack, runs `npm run test:db`, and stops the stack on exit, including after a test failure. Outside Superset, run:
+The wrapper acquires `~/.superset/debatelab-db.lock`, starts full local Supabase, runs `npm run test:db`, then holds the same lock while a controlled Mailpit relay, collaboration process, and Vite client run the real-Auth desktop/mobile workspace journey. Pull requests run this same gate in the `database-tests` job and retain its logs and browser traces on failure. It stops every owned process and the stack on exit, including after failure. Outside Superset, run only the database portion with:
```bash
supabase db start
@@ -144,7 +144,7 @@ npm run test:db
supabase stop --no-backup
```
-The stack replays migrations and runs pgTAP tests. `CREATE INDEX CONCURRENTLY` requires the stated CLI floor so it runs outside the migration batch.
+The stack replays migrations and runs pgTAP tests. The browser portion creates isolated local users and deletes them, captures mail only in local Mailpit, restarts the collaboration process, and proves recovery and revocation. `CREATE INDEX CONCURRENTLY` requires the stated CLI floor so it runs outside the migration batch.
The local stack begins at port `54321` and is not parallel-safe across worktrees. The task that starts it must stop it before finishing or abandoning the run. Use `supabase stop --no-backup` unless another active task explicitly owns the same stack.
diff --git a/e2e/fixtures/workspace-review-ime.html b/e2e/fixtures/workspace-review-ime.html
index c16d77f29..1a5ed9b06 100644
--- a/e2e/fixtures/workspace-review-ime.html
+++ b/e2e/fixtures/workspace-review-ime.html
@@ -12,22 +12,33 @@
import { createWorkspaceEditor } from "/src/lib/workspace/editor-schema.ts";
import * as review from "/src/lib/workspace/review.ts";
- const canonicalDocument = new Y.Doc();
- const suggestionDocument = new Y.Doc();
- const canonical = createWorkspaceEditor(canonicalDocument);
- const suggestion = createWorkspaceEditor(suggestionDocument);
- canonical.commands.setContent("
Accepted:
");
- suggestion.commands.setContent("
");
- document.querySelector("#suggestion-editor").append(suggestion.view.dom);
-
let phase = "idle";
let proposal = null;
+ let proposalId = "ime";
let canonicalUpdates = 0;
const events = [];
- canonicalDocument.on("update", () => canonicalUpdates += 1);
+ let suggesting = false;
+ const yDocument = new Y.Doc();
+ const editor = createWorkspaceEditor(yDocument, {
+ isSuggesting: () => suggesting,
+ onSuggestion(hunk) {
+ phase = "settled";
+ proposal = review.createProposal({
+ id: proposalId,
+ authorId: "ime-member",
+ documentEpoch: 1,
+ editor,
+ hunks: [hunk],
+ });
+ },
+ });
+ editor.commands.setContent("Accepted:
");
+ yDocument.on("update", () => canonicalUpdates += 1);
+ suggesting = true;
+ document.querySelector("#suggestion-editor").append(editor.view.dom);
for (const type of ["compositionstart", "beforeinput", "input", "compositionend"]) {
- suggestion.view.dom.addEventListener(type, (event) => {
+ editor.view.dom.addEventListener(type, (event) => {
if (type === "compositionstart") phase = "composing";
if (type === "compositionend") phase = "settled";
events.push({
@@ -40,10 +51,10 @@
}
const snapshot = () => ({
- canonical: canonical.getText(),
+ canonical: editor.getText(),
canonicalUpdates,
- suggestion: suggestion.getText(),
- editorComposing: suggestion.view.composing,
+ suggestion: editor.getText(),
+ editorComposing: editor.view.composing,
phase,
events: [...events],
proposal: proposal && {
@@ -55,31 +66,18 @@
window.workspaceIme = {
snapshot,
capture(id) {
- const at = canonical.state.doc.content.size - 1;
- proposal = review.createProposalAfterComposition({
- compositionState: phase,
- proposal: {
- id,
- authorId: "ime-member",
- documentEpoch: 1,
- editor: canonical,
- hunks: [{
- id: `${id}-insert`,
- kind: "insert",
- at,
- content: [{ type: "text", text: suggestion.getText() }],
- }],
- },
- });
+ proposalId = id;
return snapshot();
},
accept() {
+ suggesting = false;
proposal = review.acceptProposalHunks({
- editor: canonical,
+ editor,
documentEpoch: 1,
proposal,
hunkIds: [proposal.hunks[0].id],
});
+ suggesting = true;
return snapshot();
},
reject() {
@@ -90,7 +88,6 @@
return snapshot();
},
resetSuggestion() {
- suggestion.commands.clearContent();
phase = "idle";
proposal = null;
events.length = 0;
diff --git a/e2e/workspace-collaboration.spec.ts b/e2e/workspace-collaboration.spec.ts
new file mode 100644
index 000000000..8671c2a17
--- /dev/null
+++ b/e2e/workspace-collaboration.spec.ts
@@ -0,0 +1,350 @@
+import { createClient, type Session } from "@supabase/supabase-js";
+import { expect, test, type Browser, type BrowserContext, type Page } from "@playwright/test";
+import { spawn, type ChildProcess } from "node:child_process";
+import { createConnection } from "node:net";
+import { kill as killProcess } from "node:process";
+
+const supabaseUrl = process.env.LOCAL_SUPABASE_URL;
+const anonKey = process.env.LOCAL_SUPABASE_ANON_KEY;
+const serviceKey = process.env.LOCAL_SUPABASE_SERVICE_ROLE_KEY;
+const mailerUrl = process.env.WORKSPACE_TEST_MAILER_URL;
+const collaborationPort = Number(process.env.COLLABORATION_PORT ?? 12345);
+
+test.skip(!supabaseUrl || !anonKey || !serviceKey || !mailerUrl, "Local workspace services are required");
+
+function waitForPort(port: number) {
+ return new Promise((resolve, reject) => {
+ const deadline = Date.now() + 20_000;
+ const attempt = () => {
+ const socket = createConnection({ host: "127.0.0.1", port });
+ socket.once("connect", () => { socket.destroy(); resolve(); });
+ socket.once("error", () => {
+ socket.destroy();
+ if (Date.now() >= deadline) reject(new Error(`Port ${port} did not open`));
+ else setTimeout(attempt, 100);
+ });
+ };
+ attempt();
+ });
+}
+
+async function stop(child: ChildProcess | undefined) {
+ if (!child || child.exitCode !== null) return;
+ const exited = new Promise((resolve) => child.once("exit", () => resolve()));
+ killProcess(-child.pid!, "SIGTERM");
+ await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 2_000))]);
+ try {
+ killProcess(-child.pid!, "SIGKILL");
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
+ }
+}
+
+async function startCollaboration() {
+ const child = spawn("npm", ["run", "start:collaboration"], {
+ cwd: process.cwd(),
+ detached: true,
+ env: process.env,
+ stdio: ["ignore", "inherit", "inherit"],
+ });
+ await waitForPort(collaborationPort);
+ return child;
+}
+
+async function authenticatedPage(browser: Browser, session: Session) {
+ const context = await browser.newContext();
+ await context.addInitScript((value) => {
+ localStorage.setItem("debate-auth-local", JSON.stringify(value));
+ }, session);
+ const page = await context.newPage();
+ await page.route("**/functions/v1/announcements*", (route) => route.fulfill({
+ body: JSON.stringify({ announcements: [] }),
+ contentType: "application/json",
+ status: 200,
+ }));
+ return { context, page };
+}
+
+async function signedOutPage(browser: Browser) {
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ await page.route("**/functions/v1/announcements*", (route) => route.fulfill({
+ body: JSON.stringify({ announcements: [] }),
+ contentType: "application/json",
+ status: 200,
+ }));
+ return { context, page };
+}
+
+async function openPanel(page: Page, name: string) {
+ const summary = page.locator("summary").filter({ hasText: name });
+ if (!await summary.evaluate((element) => (element.parentElement as HTMLDetailsElement).open)) {
+ await summary.click();
+ }
+}
+
+async function showPane(page: Page, name: "Files" | "Document" | "Working panel", mobile: boolean) {
+ if (!mobile) return;
+ const button = page.getByRole("button", { name, exact: true });
+ await expect(button).toBeVisible();
+ await button.click();
+}
+
+test("real local users collaborate, recover, review, restore, and revoke access", async ({ browser, request }, testInfo) => {
+ test.setTimeout(testInfo.project.name === "mobile-chromium" ? 120_000 : 240_000);
+ const mobile = testInfo.project.name === "mobile-chromium";
+ const admin = createClient(supabaseUrl!, serviceKey!, { auth: { persistSession: false } });
+ const suffix = `${testInfo.project.name}-${Date.now()}@workspace.test`;
+ const emails = [`owner-${suffix}`, `member-${suffix}`, `email-${suffix}`];
+ const password = `Local-${crypto.randomUUID()}!`;
+ const ids: string[] = [];
+ const contexts: BrowserContext[] = [];
+ let collaboration: ChildProcess | undefined;
+
+ try {
+ const sessions: Session[] = [];
+ for (const email of emails) {
+ const created = await admin.auth.admin.createUser({ email, email_confirm: true, password });
+ if (created.error || !created.data.user) throw created.error ?? new Error("User creation failed");
+ ids.push(created.data.user.id);
+ const client = createClient(supabaseUrl!, anonKey!, { auth: { persistSession: false } });
+ const signedIn = await client.auth.signInWithPassword({ email, password });
+ if (signedIn.error || !signedIn.data.session) throw signedIn.error ?? new Error("Sign in failed");
+ sessions.push(signedIn.data.session);
+ }
+
+ collaboration = await startCollaboration();
+ const owner = await authenticatedPage(browser, sessions[0]);
+ const member = await authenticatedPage(browser, sessions[1]);
+ contexts.push(owner.context, member.context);
+
+ await owner.page.goto("/app/workspaces");
+ await owner.page.getByRole("textbox", { name: "Motion" }).fill("Schools should ban phones");
+ await owner.page.getByRole("button", { name: "Create workspace" }).click();
+ await expect(owner.page).toHaveURL(/\/app\/workspaces\/[0-9a-f-]+$/);
+ const workspaceUrl = owner.page.url();
+ const workspaceId = workspaceUrl.split("/").at(-1)!;
+
+ await showPane(owner.page, "Working panel", mobile);
+ await openPanel(owner.page, "Invite people");
+ await owner.page.getByRole("button", { name: "Create join code" }).click();
+ await expect(owner.page.getByRole("textbox", { name: "Join code" })).toBeVisible({ timeout: 10_000 });
+ const code = await owner.page.getByRole("textbox", { name: "Join code" }).inputValue();
+ await member.page.goto("/app/workspaces/invite");
+ await member.page.getByLabel("Join code").fill(code);
+ await member.page.getByRole("button", { name: "Join", exact: true }).click();
+ await expect(member.page).toHaveURL(workspaceUrl);
+
+ await showPane(owner.page, "Files", mobile);
+ await owner.page.getByRole("button", { name: "R1" }).click();
+ await expect(owner.page.getByLabel("Assignee").locator(`option[value="${ids[1]}"]`)).toHaveCount(1, { timeout: 10_000 });
+ await owner.page.getByLabel("Assignee").selectOption(ids[1]);
+ await expect(owner.page.getByLabel("Assignee")).toHaveValue(ids[1]);
+ await showPane(member.page, "Files", mobile);
+ await member.page.getByRole("button", { name: "R1" }).click();
+ const ownerEditor = owner.page.locator(".ProseMirror");
+ const memberEditor = member.page.locator(".ProseMirror");
+ await ownerEditor.fill("Phones interrupt learning");
+ await expect(owner.page.getByText("Saved", { exact: true })).toBeVisible();
+ await expect(memberEditor).toContainText("Phones interrupt learning");
+ await memberEditor.click();
+ await memberEditor.press("End");
+ await memberEditor.pressSequentially(" and sleep");
+ await expect(ownerEditor).toContainText("and sleep");
+
+ await stop(collaboration);
+ collaboration = await startCollaboration();
+ await owner.page.reload();
+ await showPane(owner.page, "Files", mobile);
+ await owner.page.getByRole("button", { name: "R1" }).click();
+ await expect(owner.page.locator(".ProseMirror")).toContainText("Phones interrupt learning and sleep");
+ await member.page.reload();
+ await showPane(member.page, "Files", mobile);
+ await member.page.getByRole("button", { name: "R1" }).click();
+ await expect(member.page.locator(".ProseMirror")).toContainText("Phones interrupt learning and sleep");
+
+ if (testInfo.project.name === "mobile-chromium") {
+ await owner.page.getByRole("button", { name: "Editing" }).click();
+ await owner.page.locator(".ProseMirror").click();
+ await owner.page.locator(".ProseMirror").press("ControlOrMeta+A");
+ await owner.page.keyboard.insertText("Focused phone policy");
+ await showPane(member.page, "Working panel", mobile);
+ await openPanel(member.page, "Review");
+ await expect(member.page.getByRole("button", { name: "Accept" })).toHaveCount(1);
+ await member.page.getByRole("button", { name: "Accept" }).click();
+ await showPane(member.page, "Document", mobile);
+ await expect(member.page.locator(".ProseMirror")).toContainText("Focused phone policy");
+ return;
+ }
+
+ await openPanel(owner.page, "Invite people");
+ await owner.page.getByLabel("Email").fill(emails[2]);
+ await owner.page.getByRole("button", { name: "Send", exact: true }).click();
+ await expect(owner.page.getByText("Email invitation sent.")).toBeVisible();
+ let inviteUrl = "";
+ await expect.poll(async () => {
+ const response = await request.get(`${mailerUrl}/api/v1/messages`);
+ const payload = await response.json() as { messages?: Array> } | Array>;
+ const messages = Array.isArray(payload) ? payload : payload.messages ?? [];
+ const message = [...messages].reverse().find((item) => JSON.stringify(item).includes(emails[2]));
+ const id = message?.ID ?? message?.Id ?? message?.id;
+ if (typeof id === "string") {
+ const detail = await (await request.get(`${mailerUrl}/api/v1/message/${id}`)).text();
+ inviteUrl = detail.match(/https?:\/\/[^\s"<>]+token=[^\s"<>]+/)?.[0] ?? "";
+ }
+ return inviteUrl;
+ }).toMatch(/token=/);
+ const emailMember = await signedOutPage(browser);
+ contexts.push(emailMember.context);
+ const invitation = new URL(inviteUrl);
+ await emailMember.page.goto(`${invitation.pathname}${invitation.search}`);
+ await expect(emailMember.page).toHaveURL(/\/login$/);
+ await expect(emailMember.page).not.toHaveURL(/token=/);
+ await emailMember.page.getByLabel("Email address").fill(emails[2]);
+ await emailMember.page.getByRole("button", { name: "Send code" }).click();
+ let otp = "";
+ await expect.poll(async () => {
+ const response = await request.get(`${mailerUrl}/api/v1/messages`);
+ const payload = await response.json() as { messages?: Array> } | Array>;
+ const messages = Array.isArray(payload) ? payload : payload.messages ?? [];
+ for (const message of [...messages].reverse()) {
+ if (!JSON.stringify(message).includes(emails[2])) continue;
+ const id = message.ID ?? message.Id ?? message.id;
+ if (typeof id !== "string") continue;
+ const detail = await (await request.get(`${mailerUrl}/api/v1/message/${id}`)).text();
+ otp = detail.match(/\b(\d{6})\b/)?.[1] ?? "";
+ if (otp) break;
+ }
+ return otp;
+ }).toMatch(/^\d{6}$/);
+ await emailMember.page.getByRole("textbox", { name: "Verification code" }).fill(otp);
+ await emailMember.page.getByRole("button", { name: "Verify code" }).click();
+ await expect(emailMember.page).toHaveURL(workspaceUrl);
+ await expect(emailMember.page).not.toHaveURL(/token=/);
+
+ await owner.page.getByLabel("New file name").fill("Rebuttal");
+ await owner.page.getByRole("button", { name: "Add" }).click();
+ await owner.page.getByRole("button", { name: "Rebuttal" }).click();
+ await owner.page.getByLabel("File name", { exact: true }).fill("Rebuttal notes");
+ await owner.page.getByRole("button", { name: "Rename" }).click();
+ await owner.page.getByRole("button", { name: "Duplicate" }).click();
+ await owner.page.getByRole("region", { name: "Rebuttal notes" }).getByRole("button", { name: "Trash" }).click();
+ await owner.page.getByRole("complementary", { name: "Files" }).getByRole("button", { name: "Trash" }).click();
+ await owner.page.getByRole("region", { name: "Rebuttal notes" }).getByRole("button", { name: "Restore" }).click();
+
+ const beforeScopedDraft = await admin.from("workspace_documents")
+ .select("durable_sequence,id")
+ .eq("workspace_id", workspaceId)
+ .eq("side", "pro")
+ .eq("title", "R1")
+ .single();
+ if (beforeScopedDraft.error) throw beforeScopedDraft.error;
+ await member.context.setOffline(true);
+ await member.page.locator(".ProseMirror").click();
+ await member.page.locator(".ProseMirror").press("End");
+ await member.page.locator(".ProseMirror").pressSequentially(" scoped draft");
+ await member.page.getByRole("button", { name: "Intro" }).click();
+ await member.context.setOffline(false);
+ await expect(member.page.getByText("Saved", { exact: true })).toBeVisible({ timeout: 15_000 });
+ await expect(member.page.locator(".ProseMirror")).not.toContainText("scoped draft");
+ await member.page.getByRole("button", { name: "R1" }).click();
+ await expect(member.page.locator(".ProseMirror")).toContainText("scoped draft");
+ await expect.poll(async () => {
+ const current = await admin.from("workspace_documents")
+ .select("durable_sequence")
+ .eq("id", beforeScopedDraft.data.id)
+ .single();
+ if (current.error) throw current.error;
+ return current.data.durable_sequence;
+ }).toBeGreaterThan(beforeScopedDraft.data.durable_sequence);
+ await expect(member.page.getByText("Saved", { exact: true })).toBeVisible({ timeout: 15_000 });
+
+ await owner.page.getByRole("button", { name: "R1" }).click();
+ await owner.page.getByRole("button", { name: "Editing" }).click();
+ await owner.page.locator(".ProseMirror").click();
+ await owner.page.locator(".ProseMirror").press("ControlOrMeta+A");
+ await owner.page.keyboard.insertText("Phones reduce attention");
+ await openPanel(member.page, "Review");
+ await expect(member.page.getByLabel("Proposed change preview")).toContainText("reduce attention");
+ await expect(member.page.getByLabel("Proposed change preview")).toContainText("interrupt learning");
+ await expect(member.page.getByText("Original", { exact: true })).toBeVisible();
+ await expect(member.page.getByText("Proposed", { exact: true })).toBeVisible();
+ await expect(member.page.getByRole("button", { name: "Accept" })).toHaveCount(1);
+ await member.page.getByRole("button", { name: "Accept" }).click();
+ await expect(member.page.locator(".ProseMirror")).toContainText("Phones reduce attention");
+
+ await owner.page.locator(".ProseMirror").click();
+ await owner.page.locator(".ProseMirror").press("End");
+ const cdp = await owner.context.newCDPSession(owner.page);
+ await cdp.send("Input.imeSetComposition", { text: " t", selectionStart: 2, selectionEnd: 2 });
+ await cdp.send("Input.imeSetComposition", { text: " tűrő", selectionStart: 5, selectionEnd: 5 });
+ await expect(owner.page.locator(".ProseMirror")).not.toContainText("tűrő");
+ await cdp.send("Input.imeSetComposition", { text: "", selectionStart: 0, selectionEnd: 0 });
+ await owner.page.waitForTimeout(500);
+ await expect(member.page.getByRole("button", { name: "Reject" })).toHaveCount(0);
+ await expect(owner.page.locator(".ProseMirror")).not.toContainText("tűrő");
+
+ await owner.page.getByRole("button", { name: "Suggesting" }).click();
+ await owner.page.locator(".ProseMirror").click();
+ await owner.page.locator(".ProseMirror").press("ControlOrMeta+A");
+ await owner.page.getByRole("button", { name: "Comment" }).click();
+ await owner.page.getByLabel("Comment text").fill("Can you verify this?");
+ await owner.page.getByLabel("Mention member").selectOption(ids[1]);
+ await owner.page.getByRole("button", { name: "Add comment" }).click();
+ await expect(member.page.getByText("A teammate mentioned you in a comment.")).toBeVisible();
+ await member.page.getByLabel("Reply").fill("Verified.");
+ await member.page.getByRole("button", { name: "Reply" }).click();
+ await member.page.getByRole("button", { name: "Resolve" }).click();
+
+ await openPanel(owner.page, "Version history");
+ await owner.page.getByLabel("Version name").fill("Ready for coach");
+ await owner.page.getByRole("button", { name: "Save version" }).click();
+ await expect(owner.page.getByText("Ready for coach")).toBeVisible();
+ await member.context.setOffline(true);
+ await member.page.locator(".ProseMirror").click();
+ await member.page.locator(".ProseMirror").press("End");
+ await member.page.locator(".ProseMirror").pressSequentially(" offline draft");
+ const beforeRewrite = await admin.from("workspace_documents")
+ .select("durable_sequence,id")
+ .eq("workspace_id", workspaceId)
+ .eq("side", "pro")
+ .eq("title", "R1")
+ .single();
+ if (beforeRewrite.error) throw beforeRewrite.error;
+ await owner.page.locator(".ProseMirror").fill("Temporary rewrite");
+ await expect.poll(async () => {
+ const current = await admin.from("workspace_documents")
+ .select("durable_sequence")
+ .eq("id", beforeRewrite.data.id)
+ .single();
+ if (current.error) throw current.error;
+ return current.data.durable_sequence;
+ }).toBeGreaterThan(beforeRewrite.data.durable_sequence);
+ await owner.page.getByRole("button", { name: "Preview" }).first().click();
+ await owner.page.getByRole("button", { name: "Restore this version" }).click();
+ await member.context.setOffline(false);
+ await member.page.reload();
+ await expect(member.page.getByRole("alert").filter({ hasText: "Recovery" })).toBeVisible();
+ await expect(member.page.locator(".ProseMirror")).not.toContainText("offline draft");
+ await expect(member.page.getByText("Saved", { exact: true })).toBeVisible({ timeout: 15_000 });
+
+ await openPanel(owner.page, "Invite people");
+ const memberRow = owner.page.getByText(`Member ${ids[1].slice(0, 4)}`, { exact: false }).last().locator("..");
+ await memberRow.getByRole("button", { name: "Remove" }).click();
+ await member.page.reload();
+ await expect(member.page.getByRole("alert")).toContainText("Workspace unavailable");
+
+ await owner.page.getByRole("button", { name: "Archive workspace" }).click();
+ await owner.page.goto("/app/workspaces");
+ await owner.page.getByRole("textbox", { name: "Motion" }).fill("Homework should be optional");
+ await owner.page.getByRole("button", { name: "Create workspace" }).click();
+ await owner.page.goto("/app/workspaces");
+ await owner.page.getByRole("button", { name: "Archived" }).click();
+ await owner.page.getByRole("link", { name: /Schools should ban phones/ }).click();
+ await owner.page.getByRole("button", { name: "Reopen workspace" }).click();
+ } finally {
+ await stop(collaboration);
+ await Promise.allSettled(contexts.map((context) => context.close()));
+ await Promise.allSettled(ids.map((id) => admin.auth.admin.deleteUser(id)));
+ }
+});
diff --git a/e2e/workspace-review-ime.spec.ts b/e2e/workspace-review-ime.spec.ts
index 2caac968e..4a83172ed 100644
--- a/e2e/workspace-review-ime.spec.ts
+++ b/e2e/workspace-review-ime.spec.ts
@@ -45,6 +45,7 @@ async function compose(
acceptedUpdates: number,
) {
await page.locator(".ProseMirror").focus();
+ await page.locator(".ProseMirror").press("End");
for (const text of steps) {
await cdp.send("Input.imeSetComposition", {
text,
@@ -61,7 +62,7 @@ async function compose(
}
await cdp.send("Input.insertText", { text: steps[steps.length - 1] });
await expect.poll(async () => (await snapshot(page)).phase).toBe("settled");
- await expect.poll(async () => (await snapshot(page)).editorComposing).toBe(false);
+ await expect.poll(async () => (await snapshot(page)).proposal).not.toBeNull();
}
test("captures Spanish and Hungarian IME text only after composition settles", async ({
@@ -80,11 +81,12 @@ test("captures Spanish and Hungarian IME text only after composition settles", a
"compositionstart",
"beforeinput",
"input",
- "compositionend",
]));
- expect(spanishSettled.events.filter((event) => event.type === "beforeinput").every((event) =>
- event.inputType === "insertCompositionText" && event.isComposing,
- )).toBe(true);
+ const compositionInputs = spanishSettled.events.filter((event) =>
+ event.type === "beforeinput" && event.inputType === "insertCompositionText",
+ );
+ expect(compositionInputs.length).toBeGreaterThan(0);
+ expect(compositionInputs.every((event) => event.isComposing)).toBe(true);
expect(spanishSettled.proposal).toEqual({
status: "pending",
replacement: [{ type: "text", text: "niño" }],
diff --git a/package-lock.json b/package-lock.json
index d1b3c95e4..09f7c18ba 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -41,8 +41,10 @@
"@tanstack/react-query": "^5.83.0",
"@tiptap/core": "3.31.3",
"@tiptap/extension-collaboration": "3.31.3",
+ "@tiptap/extension-collaboration-caret": "3.31.3",
"@tiptap/extension-table": "3.31.3",
"@tiptap/pm": "3.31.3",
+ "@tiptap/react": "3.31.3",
"@tiptap/starter-kit": "3.31.3",
"@tiptap/y-tiptap": "3.0.9",
"@vercel/analytics": "^2.0.1",
@@ -58,10 +60,12 @@
"i18next": "^23.16.8",
"jspdf": "^4.1.0",
"jspdf-autotable": "^5.0.7",
+ "lib0": "0.2.117",
"lucide-react": "^0.462.0",
"mammoth": "^1.12.0",
"next-themes": "^0.3.0",
"pdfjs-dist": "^6.3.289",
+ "pg": "8.23.0",
"posthog-js": "^1.399.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -89,6 +93,7 @@
"@testing-library/react": "^16.0.0",
"@types/file-saver": "^2.0.7",
"@types/node": "^22.16.5",
+ "@types/pg": "8.23.1",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.11.0",
@@ -3941,6 +3946,24 @@
"@tiptap/core": "3.31.3"
}
},
+ "node_modules/@tiptap/extension-bubble-menu": {
+ "version": "3.31.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.3.tgz",
+ "integrity": "sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@floating-ui/dom": "^1.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.3",
+ "@tiptap/pm": "3.31.3"
+ }
+ },
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.3.tgz",
@@ -3997,6 +4020,21 @@
"yjs": "^13"
}
},
+ "node_modules/@tiptap/extension-collaboration-caret": {
+ "version": "3.31.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration-caret/-/extension-collaboration-caret-3.31.3.tgz",
+ "integrity": "sha512-ub2SsYwmEcmTbqk4aC55M6AnU5/jJcr85lk0jQTqoo1h4ADGYcVXKaJY/CgDCxJgPxr6e/+IiNSYTLjW0nOEgg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.3",
+ "@tiptap/pm": "3.31.3",
+ "@tiptap/y-tiptap": "^3.0.7"
+ }
+ },
"node_modules/@tiptap/extension-document": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz",
@@ -4023,6 +4061,22 @@
"@tiptap/extensions": "3.31.3"
}
},
+ "node_modules/@tiptap/extension-floating-menu": {
+ "version": "3.31.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.3.tgz",
+ "integrity": "sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==",
+ "license": "MIT",
+ "optional": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@floating-ui/dom": "^1.0.0",
+ "@tiptap/core": "3.31.3",
+ "@tiptap/pm": "3.31.3"
+ }
+ },
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.3.tgz",
@@ -4264,6 +4318,33 @@
"url": "https://github.com/sponsors/ueberdosis"
}
},
+ "node_modules/@tiptap/react": {
+ "version": "3.31.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.3.tgz",
+ "integrity": "sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "fast-equals": "^5.3.3",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "optionalDependencies": {
+ "@tiptap/extension-bubble-menu": "^3.31.3",
+ "@tiptap/extension-floating-menu": "^3.31.3"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.3",
+ "@tiptap/pm": "3.31.3",
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/@tiptap/starter-kit": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.3.tgz",
@@ -4701,6 +4782,18 @@
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
+ "node_modules/@types/pg": {
+ "version": "8.23.1",
+ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
+ "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "pg-protocol": "*",
+ "pg-types": "^2.2.0"
+ }
+ },
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -4728,7 +4821,6 @@
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
- "devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
@@ -4753,6 +4845,12 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz",
@@ -9453,6 +9551,95 @@
"license": "MIT",
"optional": true
},
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -9743,6 +9930,45 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/posthog-js": {
"version": "1.425.1",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.425.1.tgz",
@@ -12777,6 +13003,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
"node_modules/y-protocols": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
diff --git a/package.json b/package.json
index 52097de90..583b4fa47 100644
--- a/package.json
+++ b/package.json
@@ -19,9 +19,10 @@
"smoke:jev": "node scripts/smoke-jev.mjs",
"test": "vitest run",
"test:collaboration": "vitest run --config services/collaboration/vitest.config.ts",
+ "start:collaboration": "tsx services/collaboration/src/index.ts",
"test:watch": "vitest",
- "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENROUTER_API_KEY,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_BASE_URL --allow-net=127.0.0.1 supabase/functions/",
- "test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-workspace-invite-concurrency.sh",
+ "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENROUTER_API_KEY,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/",
+ "test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh",
"test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium",
"test:e2e:webkit": "playwright test --project=webkit",
"test:e2e:staging": "playwright test --project=staging",
@@ -61,8 +62,10 @@
"@tanstack/react-query": "^5.83.0",
"@tiptap/core": "3.31.3",
"@tiptap/extension-collaboration": "3.31.3",
+ "@tiptap/extension-collaboration-caret": "3.31.3",
"@tiptap/extension-table": "3.31.3",
"@tiptap/pm": "3.31.3",
+ "@tiptap/react": "3.31.3",
"@tiptap/starter-kit": "3.31.3",
"@tiptap/y-tiptap": "3.0.9",
"@vercel/analytics": "^2.0.1",
@@ -78,10 +81,12 @@
"i18next": "^23.16.8",
"jspdf": "^4.1.0",
"jspdf-autotable": "^5.0.7",
+ "lib0": "0.2.117",
"lucide-react": "^0.462.0",
"mammoth": "^1.12.0",
"next-themes": "^0.3.0",
"pdfjs-dist": "^6.3.289",
+ "pg": "8.23.0",
"posthog-js": "^1.399.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -109,6 +114,7 @@
"@testing-library/react": "^16.0.0",
"@types/file-saver": "^2.0.7",
"@types/node": "^22.16.5",
+ "@types/pg": "8.23.1",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.11.0",
diff --git a/playwright.workspace.config.ts b/playwright.workspace.config.ts
new file mode 100644
index 000000000..3f1c69452
--- /dev/null
+++ b/playwright.workspace.config.ts
@@ -0,0 +1,24 @@
+import { defineConfig, devices } from "@playwright/test";
+
+const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:18081";
+
+export default defineConfig({
+ testDir: "./e2e",
+ testMatch: /workspace-collaboration\.spec\.ts/,
+ fullyParallel: false,
+ workers: 1,
+ reporter: "list",
+ use: {
+ actionTimeout: 10_000,
+ baseURL,
+ locale: "en-US",
+ screenshot: "only-on-failure",
+ trace: "retain-on-failure",
+ video: "retain-on-failure",
+ },
+ expect: { timeout: 10_000 },
+ projects: [
+ { name: "desktop-chromium", use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 800 } } },
+ { name: "mobile-chromium", use: { ...devices["Pixel 5"] } },
+ ],
+});
diff --git a/scripts/test-workspace-mutation-concurrency.sh b/scripts/test-workspace-mutation-concurrency.sh
new file mode 100755
index 000000000..04dc7b779
--- /dev/null
+++ b/scripts/test-workspace-mutation-concurrency.sh
@@ -0,0 +1,129 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+database_url="${SUPABASE_DB_URL:-postgresql://postgres:postgres@127.0.0.1:54322/postgres}"
+database_password="${SUPABASE_DB_PASSWORD:-postgres}"
+owner_id="84444444-4444-4444-8444-444444444444"
+member_id="85555555-5555-4555-8555-555555555555"
+request_id="86666666-6666-4666-8666-666666666666"
+result_dir="$(mktemp -d)"
+first_pid=""
+second_pid=""
+
+cleanup() {
+ touch "$result_dir/release-removal"
+ if [ -n "$first_pid" ]; then wait "$first_pid" 2>/dev/null || true; fi
+ if [ -n "$second_pid" ]; then wait "$second_pid" 2>/dev/null || true; fi
+ PGPASSWORD="$database_password" psql "$database_url" -qX --set ON_ERROR_STOP=1 </dev/null
+DELETE FROM public.workspaces WHERE create_request_id = '$request_id';
+DELETE FROM auth.users WHERE id IN ('$owner_id', '$member_id');
+SQL
+ rm -rf "$result_dir"
+}
+trap cleanup EXIT
+
+PGPASSWORD="$database_password" psql "$database_url" -qX --set ON_ERROR_STOP=1 </dev/null
+DELETE FROM public.workspaces WHERE create_request_id = '$request_id';
+DELETE FROM auth.users WHERE id IN ('$owner_id', '$member_id');
+INSERT INTO auth.users (id) VALUES ('$owner_id'), ('$member_id');
+BEGIN;
+SET LOCAL ROLE authenticated;
+SELECT set_config('request.jwt.claim.sub', '$owner_id', true);
+SELECT set_config('request.jwt.claim.role', 'authenticated', true);
+SELECT public.create_workspace('Concurrent mutation', NULL, 'en', '$request_id');
+COMMIT;
+INSERT INTO public.workspace_members (workspace_id, user_id)
+SELECT id, '$member_id' FROM public.workspaces WHERE create_request_id = '$request_id';
+SQL
+
+workspace_id="$(PGPASSWORD="$database_password" psql "$database_url" -qAtX --set ON_ERROR_STOP=1 --command \
+ "SELECT id FROM public.workspaces WHERE create_request_id = '$request_id'")"
+document_id="$(PGPASSWORD="$database_password" psql "$database_url" -qAtX --set ON_ERROR_STOP=1 --command \
+ "SELECT id FROM public.workspace_documents WHERE workspace_id = '$workspace_id' ORDER BY side, position LIMIT 1")"
+
+remove_member() {
+ PGPASSWORD="$database_password" psql "$database_url" -qAtX --set ON_ERROR_STOP=1 <"$result_dir/removal" 2>&1 &
+first_pid=$!
+for _ in {1..100}; do
+ if [ -e "$result_dir/removal-holds-lock" ]; then break; fi
+ if ! kill -0 "$first_pid" 2>/dev/null; then wait "$first_pid"; fi
+ sleep 0.05
+done
+if [ ! -e "$result_dir/removal-holds-lock" ]; then
+ echo "member removal did not acquire the workspace lock"
+ exit 1
+fi
+
+write_after_removal >"$result_dir/write" 2>&1 &
+second_pid=$!
+second_is_waiting=false
+for _ in {1..100}; do
+ if [ "$(PGPASSWORD="$database_password" psql "$database_url" -qAtX --set ON_ERROR_STOP=1 --command \
+ "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE pid <> pg_backend_pid() AND wait_event_type = 'Lock' AND query LIKE '%workspace_mutation_concurrency_second%')")" = "t" ]; then
+ second_is_waiting=true
+ break
+ fi
+ if ! kill -0 "$second_pid" 2>/dev/null; then break; fi
+ sleep 0.05
+done
+if [ "$second_is_waiting" != true ]; then
+ echo "workspace mutation did not wait for removal"
+ exit 1
+fi
+
+touch "$result_dir/release-removal"
+wait "$first_pid"
+if wait "$second_pid"; then
+ echo "removed member mutation unexpectedly committed"
+ exit 1
+fi
+if ! grep -q "workspace unavailable" "$result_dir/write"; then
+ cat "$result_dir/write"
+ exit 1
+fi
+
+stored_comments="$(PGPASSWORD="$database_password" psql "$database_url" -qAtX --set ON_ERROR_STOP=1 --command \
+ "SELECT count(*) FROM public.workspace_comments WHERE workspace_id = '$workspace_id'")"
+if [ "$stored_comments" != "0" ]; then
+ echo "expected no post-removal comment, got: $stored_comments"
+ exit 1
+fi
+
+echo "Concurrent removal: later workspace mutation waited and was rejected"
diff --git a/scripts/workspace-test-env.mjs b/scripts/workspace-test-env.mjs
new file mode 100644
index 000000000..48b67bb6f
--- /dev/null
+++ b/scripts/workspace-test-env.mjs
@@ -0,0 +1,8 @@
+import { readFile, writeFile } from "node:fs/promises";
+
+const [source, target] = process.argv.slice(2);
+const serviceRoleKey = process.env.LOCAL_SERVICE_ROLE_KEY;
+const mailerHost = process.env.LOCAL_MAILER_HOST;
+if (!source || !target || !serviceRoleKey || !mailerHost) throw new Error("Local Edge environment is incomplete");
+const fixture = (await readFile(source, "utf8")).replace("workspace-mailer.invalid", mailerHost);
+await writeFile(target, `${fixture.trim()}\nWORKSPACE_INVITE_SERVICE_ROLE_KEY=${serviceRoleKey}\n`, { mode: 0o600 });
diff --git a/services/collaboration/README.md b/services/collaboration/README.md
index 3a4d42e20..802aacd78 100644
--- a/services/collaboration/README.md
+++ b/services/collaboration/README.md
@@ -1,40 +1,32 @@
-# Collaboration service spike
+# Collaboration service
-**Status:** Phase 0 evidence only. No production route or deployment uses this service.
+The Node 22 service is the durable workspace editor boundary. Hocuspocus carries Yjs updates; Supabase Auth and PostgreSQL remain authoritative for identity, current membership, metadata, snapshots, updates, versions, and review decisions.
-## Selected stack
+For every loaded document epoch, one dedicated PostgreSQL session holds an advisory lock. Incoming reconnect and live updates are schema-checked and persisted before broadcast or the custom `durable-ack`. Access is rechecked for reads, writes, and awareness. Database lock loss, member removal, and version restore close writable sessions. The 2 MiB update/document ceiling is intentional for the trial.
-- Tiptap 3.31.3
-- Hocuspocus 4.7.0
-- Yjs 13.6.32
-- y-protocols 1.0.7
-- async-mutex 0.5.0
+The same loopback server exposes two authenticated HTTP operations:
-These exact versions are MIT, support Node 22, and work with DebateLab's React 18 client. Tiptap 3 uses `@tiptap/y-tiptap`; the spike does not install redundant `y-prosemirror`, React provider bindings, or IndexedDB support.
+- `/access/remove` waits for queued writes, changes membership, fences sockets, then acknowledges;
+- `/review/decide` commits proposal status, accepted Yjs content, and the pre-decision version in one database transaction.
-## Proven write order
+Required configuration names:
-Both reconnect `SyncStep2` and live `Update` messages use the same public Hocuspocus hook path:
+- `COLLABORATION_DATABASE_URL`
+- `COLLABORATION_PORT`
+- `COLLABORATION_ALLOWED_ORIGINS`
+- `SUPABASE_URL`
+- `SUPABASE_ANON_KEY`
-1. authenticate the room's document and epoch;
-2. acquire the per-document lock;
-3. apply the candidate to a bounded scratch Y.Doc and validate the editor schema;
-4. append the update with authenticated author, epoch, SHA-256 mutation ID, and server sequence;
-5. let Hocuspocus apply and broadcast synchronously;
-6. verify the live document contains the update;
-7. send the stateless durable acknowledgement and release the lock.
+Run focused checks with:
-The UI must use the custom acknowledgement for Saved. Hocuspocus `synced` and its boolean sync status do not prove database persistence. A reconnecting client queries pending mutation IDs and receives the original durable sequence.
-
-The tests reject append failures, invalid schema, stale epochs, and frames above 2 MiB before broadcast or acknowledgement. They also kill the service after commit before acknowledgement and after acknowledgement, then reload the stored update in a new process.
-
-## Remaining production boundary
-
-The in-memory mutex proves single-process serialization. P2.1 must replace it with the planned dedicated-session PostgreSQL advisory lock and terminate writable sessions when that database connection is lost. P2.2 owns account-scoped IndexedDB recovery and presence.
+```sh
+npm run test:collaboration
+```
-Run:
+Run the complete local database/Auth/browser lifecycle under the repository lock with:
```sh
-npm run test:collaboration
-npx vitest run src/test/workspace/collaboration.test.ts
+./.superset/db-gate.sh
```
+
+This service has no production deployment in Milestone 1.
diff --git a/services/collaboration/src/auth.test.ts b/services/collaboration/src/auth.test.ts
new file mode 100644
index 000000000..fc294e26d
--- /dev/null
+++ b/services/collaboration/src/auth.test.ts
@@ -0,0 +1,46 @@
+// @vitest-environment node
+
+import { describe, expect, it, vi } from "vitest";
+import { createWorkspaceAuthenticator } from "./auth";
+
+describe("collaboration authentication", () => {
+ it("binds the verified user to a current document membership", async () => {
+ const verifyToken = vi.fn().mockResolvedValue({
+ email: "member@example.com",
+ id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+ name: "Ada Member",
+ });
+ const isAuthorized = vi.fn().mockResolvedValue(true);
+ const authenticate = createWorkspaceAuthenticator({ isAuthorized, verifyToken });
+
+ await expect(authenticate({
+ documentName: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa@3",
+ token: "signed-token",
+ })).resolves.toEqual({
+ authorId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+ displayName: "Ada Member",
+ documentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ epoch: 3,
+ });
+ expect(isAuthorized).toHaveBeenCalledWith({
+ authorId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+ documentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ epoch: 3,
+ });
+ });
+
+ it("rejects a valid but stale token after membership removal", async () => {
+ const authenticate = createWorkspaceAuthenticator({
+ verifyToken: vi.fn().mockResolvedValue({
+ email: "removed@example.com",
+ id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+ }),
+ isAuthorized: vi.fn().mockResolvedValue(false),
+ });
+
+ await expect(authenticate({
+ documentName: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa@3",
+ token: "still-valid-token",
+ })).rejects.toThrow("Workspace unavailable");
+ });
+});
diff --git a/services/collaboration/src/auth.ts b/services/collaboration/src/auth.ts
new file mode 100644
index 000000000..a27082680
--- /dev/null
+++ b/services/collaboration/src/auth.ts
@@ -0,0 +1,44 @@
+import { z } from "zod";
+
+const roomSchema = z.string().regex(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}@(0|[1-9][0-9]*)$/,
+);
+const userSchema = z.object({
+ email: z.string().email().nullable().optional(),
+ id: z.string().uuid(),
+ name: z.string().trim().min(1).max(120).optional(),
+});
+
+type AccessContext = {
+ authorId: string;
+ documentId: string;
+ epoch: number;
+};
+
+export function createWorkspaceAuthenticator({
+ isAuthorized,
+ verifyToken,
+}: {
+ isAuthorized: (context: AccessContext) => Promise;
+ verifyToken: (token: string) => Promise;
+}) {
+ return async ({ documentName, token }: { documentName: string; token: string }) => {
+ const room = roomSchema.safeParse(documentName);
+ if (!room.success || !token) throw new Error("Workspace unavailable");
+ const separator = room.data.lastIndexOf("@");
+ const epoch = Number(room.data.slice(separator + 1));
+ if (!Number.isSafeInteger(epoch)) throw new Error("Workspace unavailable");
+ const user = userSchema.parse(await verifyToken(token));
+ const context = {
+ authorId: user.id,
+ documentId: room.data.slice(0, separator),
+ epoch,
+ };
+ if (!await isAuthorized(context)) throw new Error("Workspace unavailable");
+
+ return {
+ ...context,
+ displayName: user.name ?? user.email?.split("@")[0] ?? `Member ${user.id.slice(0, 4)}`,
+ };
+ };
+}
diff --git a/services/collaboration/src/index.ts b/services/collaboration/src/index.ts
new file mode 100644
index 000000000..2dfa67312
--- /dev/null
+++ b/services/collaboration/src/index.ts
@@ -0,0 +1,84 @@
+import { createClient } from "@supabase/supabase-js";
+import { Pool } from "pg";
+import { z } from "zod";
+import { createWorkspaceAuthenticator } from "./auth";
+import { PostgresDurableUpdateStore } from "./postgres-store";
+import { parseRuntimeConfig } from "./runtime-config";
+import { createCollaborationServer } from "./server";
+
+const config = parseRuntimeConfig(process.env);
+const pool = new Pool({ connectionString: config.databaseUrl, max: 20 });
+const store = new PostgresDurableUpdateStore(pool);
+const supabase = createClient(config.supabaseUrl, config.supabaseAnonKey, {
+ auth: { autoRefreshToken: false, detectSessionInUrl: false, persistSession: false },
+});
+const verifyToken = async (token: string) => {
+ const { data, error } = await supabase.auth.getUser(token);
+ if (error || !data.user) throw new Error("Workspace unavailable");
+ const metadata = data.user.user_metadata;
+ const name = typeof metadata.full_name === "string"
+ ? metadata.full_name
+ : typeof metadata.name === "string"
+ ? metadata.name
+ : undefined;
+ return { email: data.user.email, id: data.user.id, name };
+};
+const authenticate = createWorkspaceAuthenticator({
+ isAuthorized: (context) => store.isAuthorized(context),
+ verifyToken,
+});
+const server = createCollaborationServer({
+ allowedOrigins: new Set(config.allowedOrigins),
+ authenticate,
+ authorize: (context, operation) => store.isAuthorized(context, operation === "write"),
+ changeAccess: async ({ expectedRevision, requestId, targetUserId, token, workspaceId }) => {
+ await verifyToken(token);
+ const authenticated = createClient(config.supabaseUrl, config.supabaseAnonKey, {
+ auth: { autoRefreshToken: false, detectSessionInUrl: false, persistSession: false },
+ global: { headers: { Authorization: `Bearer ${token}` } },
+ });
+ const { data, error } = await authenticated.rpc("change_workspace_access", {
+ p_action: "remove",
+ p_expected_revision: expectedRevision,
+ p_request_id: requestId,
+ p_target_user_id: targetUserId,
+ p_workspace_id: workspaceId,
+ });
+ if (error) throw error;
+ return z.coerce.number().int().nonnegative().parse(data);
+ },
+ decideProposal: async ({ decision, documentId, epoch, hunkId, proposalId, token }) => {
+ const user = await verifyToken(token);
+ return store.decideProposal({
+ authorId: user.id,
+ decision,
+ documentId,
+ epoch,
+ hunkId,
+ proposalId,
+ });
+ },
+ store,
+});
+
+const accessListener = await pool.connect();
+await accessListener.query("LISTEN workspace_access_revoked; LISTEN workspace_document_restored");
+accessListener.on("notification", (message) => {
+ if (message.channel !== "workspace_access_revoked" && message.channel !== "workspace_document_restored") return;
+ // ponytail: global fence is safe for one trial process; target by workspace if disconnect churn matters.
+ server.hocuspocus.closeConnections();
+});
+
+await server.listen(config.port);
+console.info(`Collaboration service listening on 127.0.0.1:${config.port}`);
+
+let stopping = false;
+const stop = async () => {
+ if (stopping) return;
+ stopping = true;
+ accessListener.release();
+ await server.destroy();
+ await pool.end();
+};
+process.once("SIGINT", () => void stop());
+process.once("SIGTERM", () => void stop());
diff --git a/services/collaboration/src/postgres-store.test.ts b/services/collaboration/src/postgres-store.test.ts
new file mode 100644
index 000000000..8a40673b0
--- /dev/null
+++ b/services/collaboration/src/postgres-store.test.ts
@@ -0,0 +1,299 @@
+// @vitest-environment node
+
+import { EventEmitter } from "node:events";
+import { describe, expect, it, vi } from "vitest";
+import * as Y from "yjs";
+import { prosemirrorJSONToYDoc, yXmlFragmentToProsemirrorJSON } from "@tiptap/y-tiptap";
+import { PostgresDurableUpdateStore } from "./postgres-store";
+import { serializeProposal, type WorkspaceProposal } from "../../../src/lib/workspace/review";
+import { WORKSPACE_CONTENT_FIELD, workspaceSchema } from "../../../src/lib/workspace/schema";
+
+class FakeClient extends EventEmitter {
+ readonly query = vi.fn().mockResolvedValue({ rows: [] });
+ readonly release = vi.fn();
+}
+
+describe("Postgres collaboration writer locks", () => {
+ function proposalFixture() {
+ const document = prosemirrorJSONToYDoc(workspaceSchema, {
+ type: "doc",
+ content: [{ type: "paragraph", content: [{ type: "text", text: "Hello world" }] }],
+ }, WORKSPACE_CONTENT_FIELD);
+ const proposal: WorkspaceProposal = {
+ authorId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+ documentEpoch: 3,
+ id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
+ baseStateVector: new Uint8Array(),
+ hunks: [{
+ absoluteFrom: 7,
+ absoluteTo: 12,
+ contextAfter: [{ type: "paragraph" }],
+ contextBefore: [{ type: "text", text: " " }],
+ from: new Uint8Array(),
+ id: "replace",
+ kind: "format",
+ original: [{ type: "text", text: "world" }],
+ replacement: [{ type: "text", text: "team" }],
+ status: "pending",
+ to: new Uint8Array(),
+ }],
+ };
+ const snapshot = Y.encodeStateAsUpdate(document);
+ document.destroy();
+ return { proposal, snapshot };
+ }
+ it("holds one session advisory lock and fences writers when that session dies", async () => {
+ const client = new FakeClient();
+ const pool = { connect: vi.fn().mockResolvedValue(client) };
+ const store = new PostgresDurableUpdateStore(pool);
+ const onLost = vi.fn();
+
+ const release = await store.acquireWriter(
+ "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ 3,
+ onLost,
+ );
+
+ expect(client.query).toHaveBeenCalledWith(
+ "SELECT pg_advisory_lock(hashtextextended($1, 0))",
+ ["workspace-document:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa@3"],
+ );
+
+ client.emit("error", new Error("lock connection lost"));
+ expect(onLost).toHaveBeenCalledTimes(1);
+ expect(client.release).toHaveBeenCalledTimes(1);
+
+ await release();
+ expect(client.release).toHaveBeenCalledTimes(1);
+ });
+
+ it("destroys the dedicated session when advisory lock acquisition fails", async () => {
+ const client = new FakeClient();
+ client.query.mockRejectedValueOnce(new Error("database unavailable"));
+ const store = new PostgresDurableUpdateStore({ connect: vi.fn().mockResolvedValue(client) });
+
+ await expect(store.acquireWriter(
+ "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ 3,
+ vi.fn(),
+ )).rejects.toThrow("database unavailable");
+ expect(client.release).toHaveBeenCalledWith(true);
+ });
+
+ it("appends and snapshots only while author membership and epoch are current", async () => {
+ const client = new FakeClient();
+ const pool = { connect: vi.fn().mockResolvedValue(client) };
+ const store = new PostgresDurableUpdateStore(pool);
+ const release = await store.acquireWriter(
+ "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ 3,
+ vi.fn(),
+ );
+ client.query.mockReset();
+ client.query.mockImplementation(async (text: string) => {
+ if (text.includes("FROM private.workspace_document_state")) {
+ return { rows: [{ durable_sequence: 7, workspace_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" }] };
+ }
+ if (text.includes("FROM private.workspace_document_updates")) return { rows: [] };
+ return { rows: [] };
+ });
+
+ const durable = await store.append({
+ authorId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+ bytes: new Uint8Array([1, 2]),
+ documentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ epoch: 3,
+ mutationId: "a".repeat(43),
+ snapshot: new Uint8Array([3, 4]),
+ syncType: 2,
+ });
+
+ expect(durable.sequence).toBe(8);
+ const appendQueries = client.query.mock.calls.map(([text]) => String(text));
+ expect(appendQueries.findIndex((text) => text.includes("FOR UPDATE OF workspace")))
+ .toBeLessThan(appendQueries.findIndex((text) => text.includes("SELECT state.workspace_id, state.durable_sequence")));
+ expect(client.query.mock.calls.some(([text]) => String(text).startsWith("INSERT INTO private.workspace_document_updates"))).toBe(true);
+ expect(client.query.mock.calls.some(([text]) => String(text).startsWith("UPDATE private.workspace_document_state"))).toBe(true);
+ expect(client.query).toHaveBeenCalledWith("COMMIT");
+ await release();
+ });
+
+ it("rejects an append when the database membership check fails", async () => {
+ const client = new FakeClient();
+ const pool = { connect: vi.fn().mockResolvedValue(client) };
+ const store = new PostgresDurableUpdateStore(pool);
+ const release = await store.acquireWriter(
+ "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ 3,
+ vi.fn(),
+ );
+ client.query.mockReset();
+ client.query.mockResolvedValue({ rows: [] });
+
+ await expect(store.append({
+ authorId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+ bytes: new Uint8Array([1, 2]),
+ documentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ epoch: 3,
+ mutationId: "b".repeat(43),
+ snapshot: new Uint8Array([3, 4]),
+ syncType: 2,
+ })).rejects.toThrow("Workspace access revoked");
+ expect(client.query).toHaveBeenCalledWith("ROLLBACK");
+ expect(client.query.mock.calls.some(([text]) => String(text).startsWith("INSERT"))).toBe(false);
+ await release();
+ });
+
+ it("loads the current snapshot and durable acknowledgement from the writer session", async () => {
+ const client = new FakeClient();
+ const pool = { connect: vi.fn().mockResolvedValue(client) };
+ const store = new PostgresDurableUpdateStore(pool);
+ const release = await store.acquireWriter(
+ "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ 3,
+ vi.fn(),
+ );
+ client.query.mockReset();
+ client.query.mockImplementation(async (text: string) => {
+ if (text.includes("SELECT snapshot")) {
+ return { rows: [{ snapshot: new Uint8Array([9, 8]) }] };
+ }
+ return {
+ rows: [{
+ author_id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+ bytes: new Uint8Array([1, 2]),
+ sequence: "7",
+ sync_type: 2,
+ }],
+ };
+ });
+
+ await expect(store.loadSnapshot(
+ "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ 3,
+ )).resolves.toEqual(new Uint8Array([9, 8]));
+ await expect(store.find(
+ "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ 3,
+ "a".repeat(43),
+ )).resolves.toMatchObject({
+ authorId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+ mutationId: "a".repeat(43),
+ sequence: 7,
+ });
+ await release();
+ });
+
+ it("stores one automatic version for the durable sequence after the idle window", async () => {
+ const client = new FakeClient();
+ client.query.mockImplementation(async (text: string) => ({
+ rows: text.includes("SELECT workspace.id AS workspace_id")
+ ? [{ workspace_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" }]
+ : [],
+ }));
+ const store = new PostgresDurableUpdateStore({ connect: vi.fn().mockResolvedValue(client) });
+ const release = await store.acquireWriter(
+ "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ 3,
+ vi.fn(),
+ );
+ client.query.mockClear();
+
+ await store.snapshotVersion("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", 3);
+
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringContaining("INSERT INTO public.workspace_versions"),
+ ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", 3],
+ );
+ await release();
+ });
+
+ it("checks current membership and epoch on a fresh database session", async () => {
+ const client = new FakeClient();
+ client.query.mockResolvedValueOnce({ rows: [{ allowed: true }] });
+ const pool = { connect: vi.fn().mockResolvedValue(client) };
+ const store = new PostgresDurableUpdateStore(pool);
+
+ await expect(store.isAuthorized({
+ authorId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+ documentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ epoch: 3,
+ })).resolves.toBe(true);
+ expect(client.release).toHaveBeenCalledTimes(1);
+ });
+
+ it("persists accepted content, its pre-decision version, and the decision in one transaction", async () => {
+ const { proposal, snapshot } = proposalFixture();
+ const client = new FakeClient();
+ client.query.mockImplementation(async (text: string) => {
+ if (text.includes("SELECT workspace.id AS workspace_id")) {
+ return { rows: [{ workspace_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" }] };
+ }
+ if (text.includes("SELECT proposal.payload")) {
+ return { rows: [{
+ durable_sequence: 4,
+ payload: serializeProposal(proposal),
+ proposal_sequence: 4,
+ snapshot,
+ workspace_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
+ }] };
+ }
+ return { rows: [] };
+ });
+ const store = new PostgresDurableUpdateStore({ connect: vi.fn().mockResolvedValue(client) });
+
+ const result = await store.decideProposal({
+ authorId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+ decision: "accepted",
+ documentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ epoch: 3,
+ hunkId: "replace",
+ proposalId: proposal.id,
+ });
+
+ expect(result.proposal.hunks[0].status).toBe("accepted");
+ expect(result.sequence).toBe(5);
+ const decisionQueries = client.query.mock.calls.map(([text]) => String(text));
+ expect(decisionQueries.findIndex((text) => text.includes("FOR UPDATE OF workspace")))
+ .toBeLessThan(decisionQueries.findIndex((text) => text.includes("FOR UPDATE OF proposal, state")));
+ const accepted = new Y.Doc();
+ Y.applyUpdate(accepted, snapshot);
+ Y.applyUpdate(accepted, result.update!);
+ expect(JSON.stringify(yXmlFragmentToProsemirrorJSON(accepted.getXmlFragment(WORKSPACE_CONTENT_FIELD)))).toContain("Hello team");
+ accepted.destroy();
+ expect(client.query.mock.calls.some(([text]) => String(text).includes("INSERT INTO public.workspace_versions"))).toBe(true);
+ expect(client.query.mock.calls.some(([text]) => String(text).includes("INSERT INTO private.workspace_document_updates"))).toBe(true);
+ expect(client.query.mock.calls.some(([text]) => String(text).includes("UPDATE public.workspace_proposals"))).toBe(true);
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringContaining("durable_sequence = $3"),
+ [proposal.id, expect.any(String), 5],
+ );
+ expect(client.query).toHaveBeenCalledWith("COMMIT");
+ });
+
+ it("rolls back accepted content when persisting the decision fails", async () => {
+ const { proposal, snapshot } = proposalFixture();
+ const client = new FakeClient();
+ client.query.mockImplementation(async (text: string) => {
+ if (text.includes("SELECT workspace.id AS workspace_id")) {
+ return { rows: [{ workspace_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" }] };
+ }
+ if (text.includes("SELECT proposal.payload")) {
+ return { rows: [{ durable_sequence: 4, payload: serializeProposal(proposal), proposal_sequence: 4, snapshot, workspace_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" }] };
+ }
+ if (text.includes("UPDATE public.workspace_proposals")) throw new Error("decision write failed");
+ return { rows: [] };
+ });
+ const store = new PostgresDurableUpdateStore({ connect: vi.fn().mockResolvedValue(client) });
+
+ await expect(store.decideProposal({
+ authorId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+ decision: "accepted",
+ documentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ epoch: 3,
+ hunkId: "replace",
+ proposalId: proposal.id,
+ })).rejects.toThrow("decision write failed");
+ expect(client.query).toHaveBeenCalledWith("ROLLBACK");
+ });
+});
diff --git a/services/collaboration/src/postgres-store.ts b/services/collaboration/src/postgres-store.ts
new file mode 100644
index 000000000..2ba4d6dfc
--- /dev/null
+++ b/services/collaboration/src/postgres-store.ts
@@ -0,0 +1,479 @@
+import { createHash } from "node:crypto";
+import { z } from "zod";
+import * as Y from "yjs";
+import {
+ acceptProposalHunkInDocument,
+ deserializeProposal,
+ rejectProposalHunks,
+ serializeProposal,
+} from "../../../src/lib/workspace/review";
+import { validateWorkspaceDocument } from "../../../src/lib/workspace/schema";
+import type { DurableAppend, DurableUpdate, DurableUpdateStore } from "./server";
+
+export interface PgClient {
+ query(text: string, values?: unknown[]): Promise<{ rows: unknown[] }>;
+ release(destroy?: boolean): void;
+ once(event: "error", listener: (error: Error) => void): this;
+ removeListener(event: "error", listener: (error: Error) => void): this;
+}
+
+export interface PgPool {
+ connect(): Promise;
+}
+
+type Writer = {
+ client: PgClient;
+ lost: boolean;
+ refs: number;
+ onLost: Set<() => void>;
+};
+
+const bigintSchema = z.union([z.number(), z.string()]).transform(Number);
+const stateRowSchema = z.object({
+ durable_sequence: bigintSchema,
+ workspace_id: z.string().uuid(),
+});
+const workspaceRowSchema = z.object({ workspace_id: z.string().uuid() });
+const durableRowSchema = z.object({
+ author_id: z.string().uuid().nullable(),
+ bytes: z.instanceof(Uint8Array),
+ created_at: z.unknown().optional(),
+ sequence: bigintSchema,
+ sync_type: z.number(),
+});
+const storedUpdateRowSchema = durableRowSchema.extend({ mutation_id: z.string() });
+const snapshotRowSchema = z.object({ snapshot: z.instanceof(Uint8Array) });
+const authorizationRowSchema = z.object({ allowed: z.boolean() });
+const proposalRowSchema = z.object({
+ durable_sequence: bigintSchema,
+ payload: z.unknown(),
+ proposal_sequence: bigintSchema,
+ snapshot: z.instanceof(Uint8Array),
+ workspace_id: z.string().uuid(),
+});
+
+export class PostgresDurableUpdateStore implements DurableUpdateStore {
+ private readonly writers = new Map();
+
+ constructor(private readonly pool: PgPool) {}
+
+ async isAuthorized(
+ context: { authorId: string; documentId: string; epoch: number },
+ write = false,
+ ) {
+ const client = await this.pool.connect();
+ try {
+ const result = await client.query(
+ `SELECT EXISTS (
+ SELECT 1
+ FROM private.workspace_document_state AS state
+ JOIN public.workspaces AS workspace ON workspace.id = state.workspace_id
+ JOIN public.workspace_members AS member ON member.workspace_id = state.workspace_id
+ WHERE state.document_id = $1
+ AND state.epoch = $2
+ AND member.user_id = $3
+ AND workspace.status <> 'deleted'
+ AND (NOT $4 OR workspace.status = 'active')
+ ) AS allowed`,
+ [context.documentId, context.epoch, context.authorId, write],
+ );
+ return authorizationRowSchema.parse(result.rows[0]).allowed;
+ } finally {
+ client.release();
+ }
+ }
+
+ async loadSnapshot(documentId: string, epoch: number) {
+ const result = await this.getWriter(documentId, epoch).query(
+ `SELECT snapshot
+ FROM private.workspace_document_state
+ WHERE document_id = $1 AND epoch = $2`,
+ [documentId, epoch],
+ );
+ const input = result.rows[0];
+ return input ? new Uint8Array(snapshotRowSchema.parse(input).snapshot) : undefined;
+ }
+
+ async load(documentId: string, epoch: number): Promise {
+ const result = await this.getWriter(documentId, epoch).query(
+ `SELECT author_id, bytes, mutation_id, sequence, sync_type
+ FROM private.workspace_document_updates
+ WHERE document_id = $1 AND epoch = $2
+ ORDER BY sequence`,
+ [documentId, epoch],
+ );
+ return storedUpdateRowSchema.array().parse(result.rows).map((row) => ({
+ authorId: row.author_id ?? "",
+ bytes: new Uint8Array(row.bytes),
+ documentId,
+ epoch,
+ mutationId: row.mutation_id,
+ sequence: row.sequence,
+ syncType: row.sync_type,
+ }));
+ }
+
+ async find(documentId: string, epoch: number, mutationId: string) {
+ const result = await this.getWriter(documentId, epoch).query(
+ `SELECT author_id, bytes, sequence, sync_type
+ FROM private.workspace_document_updates
+ WHERE document_id = $1 AND epoch = $2 AND mutation_id = $3`,
+ [documentId, epoch, mutationId],
+ );
+ const input = result.rows[0];
+ if (!input) return undefined;
+ const row = durableRowSchema.parse(input);
+ return {
+ authorId: row.author_id ?? "",
+ bytes: new Uint8Array(row.bytes),
+ documentId,
+ epoch,
+ mutationId,
+ sequence: row.sequence,
+ syncType: row.sync_type,
+ };
+ }
+
+ async append(update: DurableAppend): Promise {
+ const client = this.getWriter(update.documentId, update.epoch);
+
+ await client.query("BEGIN");
+ try {
+ const workspaceResult = await client.query(
+ `SELECT workspace.id AS workspace_id
+ FROM private.workspace_document_state AS state
+ JOIN public.workspaces AS workspace ON workspace.id = state.workspace_id
+ WHERE state.document_id = $1
+ AND state.epoch = $2
+ AND workspace.status = 'active'
+ FOR UPDATE OF workspace`,
+ [update.documentId, update.epoch],
+ );
+ const workspaceInput = workspaceResult.rows[0];
+ if (!workspaceInput) throw new Error("Workspace access revoked");
+ const workspace = workspaceRowSchema.parse(workspaceInput);
+ const stateResult = await client.query(
+ `SELECT state.workspace_id, state.durable_sequence
+ FROM private.workspace_document_state AS state
+ JOIN public.workspace_members AS member
+ ON member.workspace_id = state.workspace_id AND member.user_id = $4
+ WHERE state.document_id = $1 AND state.epoch = $2 AND state.workspace_id = $3
+ FOR UPDATE OF state`,
+ [update.documentId, update.epoch, workspace.workspace_id, update.authorId],
+ );
+ const stateInput = stateResult.rows[0];
+ if (!stateInput) throw new Error("Workspace access revoked");
+ const state = stateRowSchema.parse(stateInput);
+
+ const existingResult = await client.query(
+ `SELECT author_id, bytes, sequence, sync_type
+ FROM private.workspace_document_updates
+ WHERE document_id = $1 AND epoch = $2 AND mutation_id = $3`,
+ [update.documentId, update.epoch, update.mutationId],
+ );
+ const existingInput = existingResult.rows[0];
+ if (existingInput) {
+ const existing = durableRowSchema.parse(existingInput);
+ await client.query("COMMIT");
+ return {
+ authorId: existing.author_id ?? "",
+ bytes: new Uint8Array(existing.bytes),
+ documentId: update.documentId,
+ epoch: update.epoch,
+ mutationId: update.mutationId,
+ sequence: existing.sequence,
+ syncType: existing.sync_type,
+ };
+ }
+
+ const sequence = state.durable_sequence + 1;
+ await client.query(
+ `INSERT INTO private.workspace_document_updates (
+ workspace_id, document_id, epoch, sequence, mutation_id, author_id, sync_type, bytes
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
+ [
+ state.workspace_id,
+ update.documentId,
+ update.epoch,
+ sequence,
+ update.mutationId,
+ update.authorId,
+ update.syncType,
+ Buffer.from(update.bytes),
+ ],
+ );
+ await client.query(
+ `UPDATE private.workspace_document_state
+ SET durable_sequence = $2, snapshot = $3, updated_at = now()
+ WHERE document_id = $1`,
+ [update.documentId, sequence, Buffer.from(update.snapshot)],
+ );
+ await client.query(
+ `UPDATE public.workspace_documents
+ SET durable_sequence = $2, updated_at = now()
+ WHERE id = $1`,
+ [update.documentId, sequence],
+ );
+ await client.query("COMMIT");
+ return { ...update, sequence };
+ } catch (error) {
+ await client.query("ROLLBACK");
+ throw error;
+ }
+ }
+
+ async snapshotVersion(documentId: string, epoch: number) {
+ const client = this.getWriter(documentId, epoch);
+ await client.query("BEGIN");
+ try {
+ const workspaceResult = await client.query(
+ `SELECT workspace.id AS workspace_id
+ FROM private.workspace_document_state AS state
+ JOIN public.workspaces AS workspace ON workspace.id = state.workspace_id
+ WHERE state.document_id = $1 AND state.epoch = $2
+ AND workspace.status = 'active'
+ FOR UPDATE OF workspace`,
+ [documentId, epoch],
+ );
+ if (!workspaceResult.rows[0]) {
+ await client.query("COMMIT");
+ return;
+ }
+ await client.query(
+ `WITH version AS (
+ INSERT INTO public.workspace_versions (
+ workspace_id, document_id, kind, document_epoch, durable_sequence
+ )
+ SELECT state.workspace_id, state.document_id, 'automatic', state.epoch,
+ state.durable_sequence
+ FROM private.workspace_document_state AS state
+ WHERE state.document_id = $1
+ AND state.epoch = $2
+ AND state.durable_sequence > 0
+ AND NOT EXISTS (
+ SELECT 1 FROM public.workspace_versions AS existing
+ WHERE existing.document_id = state.document_id
+ AND existing.document_epoch = state.epoch
+ AND existing.durable_sequence = state.durable_sequence
+ )
+ RETURNING id, document_id
+ )
+ INSERT INTO private.workspace_version_data (version_id, snapshot)
+ SELECT version.id, state.snapshot
+ FROM version
+ JOIN private.workspace_document_state AS state
+ ON state.document_id = version.document_id`,
+ [documentId, epoch],
+ );
+ await client.query("COMMIT");
+ } catch (error) {
+ await client.query("ROLLBACK");
+ throw error;
+ }
+ }
+
+ async decideProposal(input: {
+ authorId: string;
+ decision: "accepted" | "rejected";
+ documentId: string;
+ epoch: number;
+ hunkId: string;
+ proposalId: string;
+ }) {
+ const key = `${input.documentId}@${input.epoch}`;
+ const temporaryRelease = this.writers.has(key)
+ ? undefined
+ : await this.acquireWriter(input.documentId, input.epoch, () => undefined);
+ const client = this.getWriter(input.documentId, input.epoch);
+ await client.query("BEGIN");
+ try {
+ const workspaceResult = await client.query(
+ `SELECT workspace.id AS workspace_id
+ FROM public.workspace_proposals AS proposal
+ JOIN public.workspaces AS workspace ON workspace.id = proposal.workspace_id
+ WHERE proposal.id = $1 AND proposal.document_id = $2
+ AND proposal.document_epoch = $3 AND workspace.status = 'active'
+ FOR UPDATE OF workspace`,
+ [input.proposalId, input.documentId, input.epoch],
+ );
+ const workspaceInput = workspaceResult.rows[0];
+ if (!workspaceInput) throw new Error("Workspace unavailable");
+ const workspace = workspaceRowSchema.parse(workspaceInput);
+ const result = await client.query(
+ `SELECT proposal.payload, proposal.durable_sequence AS proposal_sequence,
+ state.durable_sequence, state.snapshot, state.workspace_id
+ FROM public.workspace_proposals AS proposal
+ JOIN private.workspace_document_state AS state
+ ON state.workspace_id = proposal.workspace_id
+ AND state.document_id = proposal.document_id
+ AND state.epoch = proposal.document_epoch
+ JOIN public.workspace_members AS member
+ ON member.workspace_id = proposal.workspace_id AND member.user_id = $5
+ WHERE proposal.id = $1 AND proposal.document_id = $2
+ AND proposal.document_epoch = $3 AND proposal.workspace_id = $4
+ FOR UPDATE OF proposal, state`,
+ [input.proposalId, input.documentId, input.epoch, workspace.workspace_id, input.authorId],
+ );
+ if (!result.rows[0]) throw new Error("Workspace unavailable");
+ const row = proposalRowSchema.parse(result.rows[0]);
+ let proposal = deserializeProposal(row.payload);
+ const hunk = proposal.hunks.find(({ id }) => id === input.hunkId);
+ if (!hunk) throw new Error("Proposal unavailable");
+ if (hunk.status !== "pending") {
+ if (hunk.status !== input.decision) throw new Error("Proposal already decided");
+ await client.query("COMMIT");
+ return { proposal: serializeProposal(proposal), sequence: row.durable_sequence };
+ }
+
+ let update: Uint8Array | undefined;
+ if (input.decision === "rejected") {
+ proposal = rejectProposalHunks({ proposal, hunkIds: [input.hunkId] });
+ } else {
+ const document = new Y.Doc();
+ try {
+ if (row.snapshot.byteLength) Y.applyUpdate(document, row.snapshot);
+ const before = Y.encodeStateVector(document);
+ proposal = row.proposal_sequence === row.durable_sequence
+ ? acceptProposalHunkInDocument({
+ document,
+ documentEpoch: input.epoch,
+ hunkId: input.hunkId,
+ proposal,
+ })
+ : {
+ ...proposal,
+ hunks: proposal.hunks.map((candidate) =>
+ candidate.id === input.hunkId ? { ...candidate, status: "stale" as const } : candidate,
+ ),
+ };
+ if (proposal.hunks.find(({ id }) => id === input.hunkId)?.status === "accepted") {
+ update = Y.encodeStateAsUpdate(document, before);
+ const snapshot = Y.encodeStateAsUpdate(document);
+ validateWorkspaceDocument(document);
+ if (!update.byteLength || update.byteLength > 2 * 1024 * 1024 || snapshot.byteLength > 2 * 1024 * 1024) {
+ throw new Error("Collaboration document exceeds 2 MiB");
+ }
+ const sequence = row.durable_sequence + 1;
+ const mutationId = createHash("sha256").update(update).digest("base64url");
+ await client.query(
+ `WITH version AS (
+ INSERT INTO public.workspace_versions (
+ workspace_id, document_id, created_by, kind, document_epoch, durable_sequence
+ ) VALUES ($1, $2, $3, 'automatic', $4, $5)
+ RETURNING id
+ )
+ INSERT INTO private.workspace_version_data (version_id, snapshot)
+ SELECT id, $6 FROM version`,
+ [row.workspace_id, input.documentId, input.authorId, input.epoch, row.durable_sequence, Buffer.from(row.snapshot)],
+ );
+ await client.query(
+ `INSERT INTO private.workspace_document_updates (
+ workspace_id, document_id, epoch, sequence, mutation_id, author_id, sync_type, bytes
+ ) VALUES ($1, $2, $3, $4, $5, $6, 2, $7)`,
+ [row.workspace_id, input.documentId, input.epoch, sequence, mutationId, input.authorId, Buffer.from(update)],
+ );
+ await client.query(
+ `UPDATE private.workspace_document_state
+ SET durable_sequence = $2, snapshot = $3, updated_at = now()
+ WHERE document_id = $1`,
+ [input.documentId, sequence, Buffer.from(snapshot)],
+ );
+ await client.query(
+ `UPDATE public.workspace_documents
+ SET durable_sequence = $2, updated_at = now()
+ WHERE id = $1`,
+ [input.documentId, sequence],
+ );
+ }
+ } finally {
+ document.destroy();
+ }
+ }
+
+ const sequence = row.durable_sequence + (update ? 1 : 0);
+ await client.query(
+ `UPDATE public.workspace_proposals
+ SET payload = $2, durable_sequence = $3, updated_at = now()
+ WHERE id = $1`,
+ [input.proposalId, JSON.stringify(serializeProposal(proposal)), sequence],
+ );
+ await client.query("COMMIT");
+ return {
+ proposal: serializeProposal(proposal),
+ sequence,
+ update,
+ };
+ } catch (error) {
+ await client.query("ROLLBACK");
+ throw error;
+ } finally {
+ await temporaryRelease?.();
+ }
+ }
+
+ private getWriter(documentId: string, epoch: number) {
+ const writer = this.writers.get(`${documentId}@${epoch}`);
+ if (!writer || writer.lost) throw new Error("Collaboration writer unavailable");
+ return writer.client;
+ }
+
+ async acquireWriter(documentId: string, epoch: number, onLost: () => void) {
+ const key = `${documentId}@${epoch}`;
+ const existing = this.writers.get(key);
+ if (existing) {
+ existing.refs += 1;
+ existing.onLost.add(onLost);
+ return this.releaseWriter(key, existing, onLost);
+ }
+
+ const client = await this.pool.connect();
+ const writer: Writer = { client, lost: false, refs: 1, onLost: new Set([onLost]) };
+ const handleLoss = () => {
+ if (writer.lost) return;
+ writer.lost = true;
+ this.writers.delete(key);
+ writer.onLost.forEach((notify) => notify());
+ client.release(true);
+ };
+ client.once("error", handleLoss);
+
+ try {
+ await client.query("SELECT pg_advisory_lock(hashtextextended($1, 0))", [
+ `workspace-document:${key}`,
+ ]);
+ } catch (error) {
+ writer.lost = true;
+ client.removeListener("error", handleLoss);
+ client.release(true);
+ throw error;
+ }
+ this.writers.set(key, writer);
+ return this.releaseWriter(key, writer, onLost, handleLoss);
+ }
+
+ private releaseWriter(
+ key: string,
+ writer: Writer,
+ onLost: () => void,
+ handleLoss?: (error: Error) => void,
+ ) {
+ let released = false;
+ return async () => {
+ if (released) return;
+ released = true;
+ writer.refs -= 1;
+ writer.onLost.delete(onLost);
+ if (writer.lost || writer.refs > 0) return;
+
+ this.writers.delete(key);
+ if (handleLoss) writer.client.removeListener("error", handleLoss);
+ try {
+ await writer.client.query("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [
+ `workspace-document:${key}`,
+ ]);
+ } finally {
+ writer.client.release();
+ }
+ };
+ }
+}
diff --git a/services/collaboration/src/runtime-config.test.ts b/services/collaboration/src/runtime-config.test.ts
new file mode 100644
index 000000000..f42d85448
--- /dev/null
+++ b/services/collaboration/src/runtime-config.test.ts
@@ -0,0 +1,29 @@
+// @vitest-environment node
+
+import { describe, expect, it } from "vitest";
+import { parseRuntimeConfig } from "./runtime-config";
+
+describe("collaboration runtime configuration", () => {
+ it("fails closed without an exact preview origin", () => {
+ expect(() => parseRuntimeConfig({
+ COLLABORATION_DATABASE_URL: "postgresql://postgres:postgres@127.0.0.1:54322/postgres",
+ COLLABORATION_PORT: "1234",
+ SUPABASE_ANON_KEY: "public-key",
+ SUPABASE_URL: "http://127.0.0.1:54321",
+ })).toThrow("COLLABORATION_ALLOWED_ORIGINS");
+ });
+
+ it("parses only explicit HTTP origins and a loopback bind", () => {
+ expect(parseRuntimeConfig({
+ COLLABORATION_ALLOWED_ORIGINS: "https://trial.example.ts.net:18443",
+ COLLABORATION_DATABASE_URL: "postgresql://postgres:postgres@127.0.0.1:54322/postgres",
+ COLLABORATION_PORT: "1234",
+ SUPABASE_ANON_KEY: "public-key",
+ SUPABASE_URL: "http://127.0.0.1:54321",
+ })).toMatchObject({
+ address: "127.0.0.1",
+ allowedOrigins: ["https://trial.example.ts.net:18443"],
+ port: 1234,
+ });
+ });
+});
diff --git a/services/collaboration/src/runtime-config.ts b/services/collaboration/src/runtime-config.ts
new file mode 100644
index 000000000..c08f22f91
--- /dev/null
+++ b/services/collaboration/src/runtime-config.ts
@@ -0,0 +1,32 @@
+import { z } from "zod";
+
+const runtimeEnvironmentSchema = z.object({
+ COLLABORATION_ALLOWED_ORIGINS: z.string().min(1),
+ COLLABORATION_DATABASE_URL: z.string().url(),
+ COLLABORATION_PORT: z.coerce.number().int().min(1).max(65_535),
+ SUPABASE_ANON_KEY: z.string().min(1),
+ SUPABASE_URL: z.string().url(),
+});
+
+export function parseRuntimeConfig(environment: Record) {
+ const parsed = runtimeEnvironmentSchema.parse(environment);
+ const allowedOrigins = parsed.COLLABORATION_ALLOWED_ORIGINS
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean)
+ .map((value) => {
+ const url = new URL(value);
+ if (url.origin !== value) throw new Error("COLLABORATION_ALLOWED_ORIGINS must contain exact origins");
+ return url.origin;
+ });
+ if (allowedOrigins.length === 0) throw new Error("COLLABORATION_ALLOWED_ORIGINS is required");
+
+ return {
+ address: "127.0.0.1",
+ allowedOrigins,
+ databaseUrl: parsed.COLLABORATION_DATABASE_URL,
+ port: parsed.COLLABORATION_PORT,
+ supabaseAnonKey: parsed.SUPABASE_ANON_KEY,
+ supabaseUrl: parsed.SUPABASE_URL,
+ };
+}
diff --git a/services/collaboration/src/server.test.ts b/services/collaboration/src/server.test.ts
index 5d87ae286..bfabd2e29 100644
--- a/services/collaboration/src/server.test.ts
+++ b/services/collaboration/src/server.test.ts
@@ -8,6 +8,7 @@ import {
type DurableUpdate,
type DurableUpdateStore,
} from "./server";
+import { createWorkspaceAuthenticator } from "./auth";
import { appendText, documentText, insertParagraph } from "./test/document";
function deferred() {
@@ -106,6 +107,42 @@ describe("collaboration durability", () => {
await Promise.all(cleanup.splice(0).map((dispose) => dispose()));
});
+ it("rejects a second provider using a noncanonical alias for an open room", async () => {
+ const server = createCollaborationServer({
+ authenticate: createWorkspaceAuthenticator({
+ isAuthorized: async () => true,
+ verifyToken: async (token) => ({
+ id: token === "author"
+ ? "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
+ : "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+ }),
+ }),
+ store: new MemoryStore(),
+ });
+ await server.listen();
+ cleanup.push(() => server.destroy());
+
+ const canonical = new HocuspocusProvider({
+ document: new Y.Doc(),
+ name: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa@1",
+ token: "author",
+ url: `ws://127.0.0.1:${server.address.port}`,
+ });
+ let aliasRejected = false;
+ const alias = new HocuspocusProvider({
+ document: new Y.Doc(),
+ name: "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA@01",
+ onAuthenticationFailed: () => { aliasRejected = true; },
+ token: "peer",
+ url: `ws://127.0.0.1:${server.address.port}`,
+ });
+ cleanup.push(() => canonical.destroy(), () => alias.destroy());
+
+ await vi.waitFor(() => expect(canonical.isSynced).toBe(true));
+ await vi.waitFor(() => expect(aliasRejected).toBe(true));
+ expect(alias.isSynced).toBe(false);
+ });
+
it("does not broadcast or acknowledge an update before append commits", async () => {
const store = new BlockingStore();
const server = createCollaborationServer({
@@ -164,6 +201,66 @@ describe("collaboration durability", () => {
});
});
+ it("acknowledges member removal only after queued writes are fenced and sockets close", async () => {
+ const store = new BlockingStore();
+ const changeAccess = vi.fn().mockResolvedValue(4);
+ const server = createCollaborationServer({
+ authenticate: async ({ documentName, token }) => ({
+ authorId: token,
+ documentId: documentName.split("@")[0],
+ epoch: Number(documentName.split("@")[1]),
+ }),
+ changeAccess,
+ store,
+ });
+ await server.listen();
+ cleanup.push(() => server.destroy());
+
+ const document = new Y.Doc();
+ let closed = 0;
+ const provider = new HocuspocusProvider({
+ document,
+ name: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa@1",
+ onClose: () => { closed += 1; },
+ token: "author",
+ url: `ws://127.0.0.1:${server.address.port}`,
+ });
+ cleanup.push(() => provider.destroy(), () => document.destroy());
+ await vi.waitFor(() => expect(provider.isSynced).toBe(true));
+
+ insertParagraph(document, "Queued claim");
+ await store.appendStarted.promise;
+ const removal = fetch(`http://127.0.0.1:${server.address.port}/access/remove`, {
+ body: JSON.stringify({
+ expectedRevision: 3,
+ requestId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
+ targetUserId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
+ workspaceId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+ }),
+ headers: {
+ Authorization: "Bearer owner-token",
+ "Content-Type": "application/json",
+ Origin: "https://trial.test",
+ },
+ method: "POST",
+ });
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(changeAccess).not.toHaveBeenCalled();
+
+ store.releaseAppend.resolve();
+ const response = await removal;
+ expect(response.status).toBe(200);
+ await expect(response.json()).resolves.toEqual({ revision: 4 });
+ expect(changeAccess).toHaveBeenCalledWith({
+ expectedRevision: 3,
+ requestId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
+ targetUserId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
+ token: "owner-token",
+ workspaceId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+ });
+ await vi.waitFor(() => expect(closed).toBeGreaterThan(0));
+ });
+
it("persists reconnect SyncStep2 and live Update messages through one gate", async () => {
const store = new MemoryStore();
const server = createCollaborationServer({
@@ -198,6 +295,35 @@ describe("collaboration durability", () => {
expect(store.updates[1].syncType).toBe(2);
});
+ it("allows an archived workspace to complete a read-only reconnect", async () => {
+ const store = new MemoryStore();
+ const authorize = vi.fn(async (_context: unknown, operation: "awareness" | "read" | "write") => operation !== "write");
+ const server = createCollaborationServer({
+ authenticate: async ({ documentName, token }) => ({
+ authorId: token,
+ documentId: documentName.split("@")[0],
+ epoch: Number(documentName.split("@")[1]),
+ }),
+ authorize,
+ store,
+ });
+ await server.listen();
+ cleanup.push(() => server.destroy());
+
+ const document = new Y.Doc();
+ const provider = new HocuspocusProvider({
+ document,
+ name: "archived-speech@1",
+ token: "archived-member",
+ url: `ws://127.0.0.1:${server.address.port}`,
+ });
+ cleanup.push(() => provider.destroy(), () => document.destroy());
+
+ await vi.waitFor(() => expect(provider.isSynced).toBe(true));
+ expect(store.updates).toHaveLength(0);
+ expect(authorize.mock.calls.some(([, operation]) => operation === "write")).toBe(false);
+ });
+
it("deduplicates identical update bytes within each document and epoch scope", async () => {
const store = new MemoryStore();
const server = createCollaborationServer({
@@ -549,4 +675,178 @@ describe("collaboration durability", () => {
expect(store.updates).toEqual([]);
expect(acknowledgements).toEqual([]);
});
+
+ it("rechecks membership before accepting a document update", async () => {
+ const store = new MemoryStore();
+ let currentMember = true;
+ const server = createCollaborationServer({
+ authenticate: async ({ documentName, token }) => ({
+ authorId: token,
+ documentId: documentName.split("@")[0],
+ epoch: Number(documentName.split("@")[1]),
+ }),
+ authorize: async () => currentMember,
+ store,
+ });
+ await server.listen();
+ cleanup.push(() => server.destroy());
+
+ const authorDocument = new Y.Doc();
+ const peerDocument = new Y.Doc();
+ const author = new HocuspocusProvider({
+ document: authorDocument,
+ name: "speech-revoked@1",
+ token: "author",
+ url: `ws://127.0.0.1:${server.address.port}`,
+ });
+ const peer = new HocuspocusProvider({
+ document: peerDocument,
+ name: "speech-revoked@1",
+ token: "peer",
+ url: `ws://127.0.0.1:${server.address.port}`,
+ });
+ const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ cleanup.push(() => author.destroy(), () => peer.destroy(), () => errorLog.mockRestore());
+
+ await vi.waitFor(() => {
+ expect(author.isSynced).toBe(true);
+ expect(peer.isSynced).toBe(true);
+ });
+ currentMember = false;
+ insertParagraph(authorDocument, "Blocked after removal");
+
+ await vi.waitFor(() => expect(author.isSynced).toBe(false));
+ expect(documentText(peerDocument)).toBe("");
+ expect(store.updates).toEqual([]);
+ });
+
+ it("rechecks membership before broadcasting awareness", async () => {
+ const store = new MemoryStore();
+ let currentMember = true;
+ const server = createCollaborationServer({
+ authenticate: async ({ documentName, token }) => ({
+ authorId: token,
+ documentId: documentName.split("@")[0],
+ epoch: Number(documentName.split("@")[1]),
+ }),
+ authorize: async () => currentMember,
+ store,
+ });
+ await server.listen();
+ cleanup.push(() => server.destroy());
+
+ const author = new HocuspocusProvider({
+ document: new Y.Doc(),
+ name: "speech-awareness@1",
+ token: "author",
+ url: `ws://127.0.0.1:${server.address.port}`,
+ });
+ const peer = new HocuspocusProvider({
+ document: new Y.Doc(),
+ name: "speech-awareness@1",
+ token: "peer",
+ url: `ws://127.0.0.1:${server.address.port}`,
+ });
+ const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ cleanup.push(() => author.destroy(), () => peer.destroy(), () => errorLog.mockRestore());
+
+ await vi.waitFor(() => {
+ expect(author.isSynced).toBe(true);
+ expect(peer.isSynced).toBe(true);
+ });
+ currentMember = false;
+ author.setAwarenessField("user", { name: "Removed member" });
+
+ await vi.waitFor(() => expect(author.isSynced).toBe(false));
+ expect(
+ [...(peer.awareness?.getStates().values() ?? [])]
+ .some((state) => state.user?.name === "Removed member"),
+ ).toBe(false);
+ });
+
+ it("closes every writer when the dedicated database lock session dies", async () => {
+ const memory = new MemoryStore();
+ const released = vi.fn();
+ let loseLock: (() => void) | undefined;
+ const store = {
+ append: memory.append.bind(memory),
+ find: memory.find.bind(memory),
+ load: memory.load.bind(memory),
+ acquireWriter: vi.fn(async (_documentId: string, _epoch: number, onLost: () => void) => {
+ loseLock = onLost;
+ return released;
+ }),
+ };
+ const server = createCollaborationServer({
+ authenticate: async ({ documentName, token }) => ({
+ authorId: token,
+ documentId: documentName.split("@")[0],
+ epoch: Number(documentName.split("@")[1]),
+ }),
+ store,
+ });
+ await server.listen();
+ cleanup.push(() => server.destroy());
+
+ const provider = new HocuspocusProvider({
+ document: new Y.Doc(),
+ name: "speech-lock-loss@4",
+ token: "author",
+ url: `ws://127.0.0.1:${server.address.port}`,
+ });
+ cleanup.push(() => provider.destroy());
+
+ await vi.waitFor(() => expect(provider.isSynced).toBe(true));
+ expect(store.acquireWriter).toHaveBeenCalledWith(
+ "speech-lock-loss",
+ 4,
+ expect.any(Function),
+ );
+ loseLock?.();
+
+ await vi.waitFor(() => expect(provider.isSynced).toBe(false));
+ });
+
+ it("replaces forged cursor identity with the authenticated label", async () => {
+ const store = new MemoryStore();
+ const server = createCollaborationServer({
+ authenticate: async ({ documentName, token }) => ({
+ authorId: token,
+ displayName: "Authenticated author",
+ documentId: documentName.split("@")[0],
+ epoch: Number(documentName.split("@")[1]),
+ }),
+ store,
+ });
+ await server.listen();
+ cleanup.push(() => server.destroy());
+
+ const author = new HocuspocusProvider({
+ document: new Y.Doc(),
+ name: "speech-cursor-label@1",
+ token: "author",
+ url: `ws://127.0.0.1:${server.address.port}`,
+ });
+ const peer = new HocuspocusProvider({
+ document: new Y.Doc(),
+ name: "speech-cursor-label@1",
+ token: "peer",
+ url: `ws://127.0.0.1:${server.address.port}`,
+ });
+ cleanup.push(() => author.destroy(), () => peer.destroy());
+
+ await vi.waitFor(() => {
+ expect(author.isSynced).toBe(true);
+ expect(peer.isSynced).toBe(true);
+ });
+ author.setAwarenessField("user", { id: "victim", name: "Forged name" });
+
+ await vi.waitFor(() => {
+ expect([...(peer.awareness?.getStates().values() ?? [])]).toContainEqual(
+ expect.objectContaining({
+ user: expect.objectContaining({ id: "author", name: "Authenticated author" }),
+ }),
+ );
+ });
+ });
});
diff --git a/services/collaboration/src/server.ts b/services/collaboration/src/server.ts
index b8ba57663..2f15dbe6f 100644
--- a/services/collaboration/src/server.ts
+++ b/services/collaboration/src/server.ts
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
+import type { IncomingMessage, ServerResponse } from "node:http";
import { Server, type Connection } from "@hocuspocus/server";
import { Mutex } from "async-mutex";
import {
@@ -27,30 +28,98 @@ export interface DurableUpdate {
syncType: number;
}
+export interface DurableAppend extends Omit {
+ snapshot: Uint8Array;
+}
+
export interface DurableUpdateStore {
- append(update: Omit): Promise;
+ append(update: DurableAppend): Promise;
find(
documentId: string,
epoch: number,
mutationId: string,
): Promise;
load(documentId: string, epoch: number): Promise;
+ acquireWriter?(
+ documentId: string,
+ epoch: number,
+ onLost: () => void,
+ ): Promise<() => void | Promise>;
+ loadSnapshot?(documentId: string, epoch: number): Promise;
+ snapshotVersion?(documentId: string, epoch: number): Promise;
}
interface AuthenticatedDocument {
authorId: string;
+ displayName?: string;
documentId: string;
epoch: number;
}
interface CollaborationServerOptions {
+ changeAccess?(input: {
+ expectedRevision: number;
+ requestId: string;
+ targetUserId: string;
+ token: string;
+ workspaceId: string;
+ }): Promise;
+ decideProposal?(input: {
+ decision: "accepted" | "rejected";
+ documentId: string;
+ epoch: number;
+ hunkId: string;
+ proposalId: string;
+ token: string;
+ }): Promise<{ proposal: unknown; sequence: number; update?: Uint8Array }>;
authenticate(input: {
documentName: string;
token: string;
}): Promise;
+ allowedOrigins?: ReadonlySet;
+ authorize?(
+ context: AuthenticatedDocument,
+ operation: "awareness" | "read" | "write",
+ ): Promise;
store: DurableUpdateStore;
}
+const accessChangeSchema = z.object({
+ expectedRevision: z.number().int().nonnegative(),
+ requestId: z.string().uuid(),
+ targetUserId: z.string().uuid(),
+ workspaceId: z.string().uuid(),
+});
+const proposalDecisionSchema = z.object({
+ decision: z.enum(["accepted", "rejected"]),
+ documentId: z.string().uuid(),
+ epoch: z.number().int().nonnegative(),
+ hunkId: z.string().min(1).max(100),
+ proposalId: z.string().uuid(),
+});
+
+async function requestBody(request: IncomingMessage) {
+ const chunks: Buffer[] = [];
+ let size = 0;
+ for await (const chunk of request) {
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
+ size += bytes.byteLength;
+ if (size > 16 * 1024) throw new Error("Request too large");
+ chunks.push(bytes);
+ }
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
+}
+
+function json(response: ServerResponse, status: number, body: unknown, origin?: string) {
+ response.writeHead(status, {
+ ...(origin ? { "Access-Control-Allow-Origin": origin } : {}),
+ "Access-Control-Allow-Headers": "authorization, content-type",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Content-Type": "application/json",
+ });
+ response.end(JSON.stringify(body));
+}
+
function sendAcknowledgement(
connection: Connection,
update: DurableUpdate,
@@ -73,26 +142,65 @@ export function createCollaborationServer(
{ durable: DurableUpdate; release: () => void }
>();
const locks = new Map();
+ const writerReleases = new Map void | Promise>();
+ // ponytail: one process-wide fence keeps removal ordering obvious; scope per workspace if trial traffic warrants it.
+ const accessFence = new Mutex();
+ const authorize = options.authorize ?? (async () => true);
- return new Server({
+ const server = new Server({
address: "127.0.0.1",
+ debounce: 3_000,
flushDelay: false,
+ maxDebounce: 30_000,
port: 0,
quiet: true,
stopOnSignals: false,
+ async onConnect({ requestHeaders }) {
+ if (options.allowedOrigins?.size) {
+ const origin = requestHeaders.get("origin");
+ if (!origin || !options.allowedOrigins.has(origin)) {
+ throw new Error("Origin unavailable");
+ }
+ }
+ },
onAuthenticate: ({ documentName, token }) =>
options.authenticate({ documentName, token }),
- async onLoadDocument({ context }) {
+ async onLoadDocument({ context, documentName }) {
+ if (options.store.acquireWriter && !writerReleases.has(documentName)) {
+ const release = await options.store.acquireWriter(
+ context.documentId,
+ context.epoch,
+ () => {
+ writerReleases.delete(documentName);
+ server.hocuspocus.closeConnections(documentName);
+ },
+ );
+ writerReleases.set(documentName, release);
+ }
+
const document = new Y.Doc();
- const updates = await options.store.load(context.documentId, context.epoch);
- for (const update of [...updates].sort(
- (a, b) => a.sequence - b.sequence,
- )) {
- Y.applyUpdate(document, update.bytes);
+ try {
+ const snapshot = await options.store.loadSnapshot?.(context.documentId, context.epoch);
+ if (snapshot?.byteLength) {
+ Y.applyUpdate(document, snapshot);
+ } else {
+ const updates = await options.store.load(context.documentId, context.epoch);
+ for (const update of [...updates].sort(
+ (a, b) => a.sequence - b.sequence,
+ )) {
+ Y.applyUpdate(document, update.bytes);
+ }
+ }
+ return document;
+ } catch (error) {
+ const release = writerReleases.get(documentName);
+ writerReleases.delete(documentName);
+ await release?.();
+ throw error;
}
- return document;
},
async onStateless({ connection, payload }) {
+ if (!await authorize(connection.context, "read")) throw new Error("Workspace access revoked");
if (payload.length > MAX_ACK_QUERY_BYTES) return;
let input: unknown;
try {
@@ -115,45 +223,83 @@ export function createCollaborationServer(
}
}
},
+ async beforeHandleAwareness({ connection, context, states }) {
+ if (connection && context && !await authorize(context, "awareness")) {
+ throw new Error("Workspace access revoked");
+ }
+ if (context) {
+ states.forEach((state) => {
+ const color = typeof state.user?.color === "string" ? state.user.color : undefined;
+ state.user = {
+ ...(color ? { color } : {}),
+ id: context.authorId,
+ name: context.displayName ?? `Member ${context.authorId.slice(0, 4)}`,
+ };
+ });
+ }
+ },
async beforeSync({ connection, context, document, payload, type }) {
- if (type !== messageYjsSyncStep2 && type !== messageYjsUpdate) return;
+ if (!await authorize(context, "read")) throw new Error("Workspace access revoked");
+ const isUpdate = type === messageYjsSyncStep2 || type === messageYjsUpdate;
+ if (!isUpdate) return;
if (payload.byteLength > MAX_UPDATE_BYTES) {
throw new Error("Collaboration update exceeds 2 MiB");
}
const decoded = Y.decodeUpdate(payload);
if (decoded.structs.length === 0 && decoded.ds.clients.size === 0) return;
+ const mutationId = createHash("sha256").update(payload).digest("base64url");
+ const probe = new Y.Doc();
+ try {
+ Y.applyUpdate(probe, Y.encodeStateAsUpdate(document));
+ const before = Y.encodeStateAsUpdate(probe);
+ Y.applyUpdate(probe, payload);
+ const after = Y.encodeStateAsUpdate(probe);
+ if (Buffer.from(before).equals(Buffer.from(after))) {
+ const duplicate = await options.store.find(context.documentId, context.epoch, mutationId);
+ if (duplicate?.authorId === context.authorId) sendAcknowledgement(connection, duplicate);
+ return;
+ }
+ } finally {
+ probe.destroy();
+ }
+ if (!await authorize(context, "write")) throw new Error("Workspace access revoked");
+ const releaseAccess = await accessFence.acquire();
const key = `${context.documentId}@${context.epoch}`;
const mutex = locks.get(key) ?? new Mutex();
locks.set(key, mutex);
const release = await mutex.acquire();
try {
const candidate = new Y.Doc();
+ let snapshot: Uint8Array;
try {
Y.applyUpdate(candidate, Y.encodeStateAsUpdate(document));
Y.applyUpdate(candidate, payload);
validateWorkspaceDocument(candidate);
- if (Y.encodeStateAsUpdate(candidate).byteLength > MAX_UPDATE_BYTES) {
+ snapshot = Y.encodeStateAsUpdate(candidate);
+ if (snapshot.byteLength > MAX_UPDATE_BYTES) {
throw new Error("Collaboration document exceeds spike limit");
}
} finally {
candidate.destroy();
}
- const mutationId = createHash("sha256")
- .update(payload)
- .digest("base64url");
const durable = await options.store.append({
authorId: context.authorId,
bytes: payload,
documentId: context.documentId,
epoch: context.epoch,
mutationId,
+ snapshot,
syncType: type,
});
- pending.set(connection, { durable, release });
+ pending.set(connection, { durable, release: () => {
+ release();
+ releaseAccess();
+ } });
} catch (error) {
release();
+ releaseAccess();
if (!mutex.isLocked()) locks.delete(key);
throw error;
}
@@ -178,5 +324,70 @@ export function createCollaborationServer(
if (mutex && !mutex.isLocked()) locks.delete(key);
}
},
+ async onStoreDocument({ documentName }) {
+ const [documentId, epoch] = documentName.split("@");
+ await options.store.snapshotVersion?.(documentId, Number(epoch));
+ },
+ async onRequest({ instance, request, response }) {
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
+ if (url.pathname !== "/access/remove" && url.pathname !== "/review/decide") return;
+ const origin = request.headers.origin;
+ if (options.allowedOrigins?.size && (!origin || !options.allowedOrigins.has(origin))) {
+ json(response, 403, { error: "Origin unavailable" });
+ throw null;
+ }
+ if (request.method === "OPTIONS") {
+ json(response, 204, null, origin);
+ throw null;
+ }
+ if (request.method !== "POST") {
+ json(response, 405, { error: "Method unavailable" }, origin);
+ throw null;
+ }
+ const authorization = request.headers.authorization;
+ const token = authorization?.startsWith("Bearer ") ? authorization.slice(7) : "";
+ try {
+ if (!token) throw new Error("Authentication required");
+ const body = await requestBody(request);
+ const release = await accessFence.acquire();
+ try {
+ if (url.pathname === "/access/remove") {
+ if (!options.changeAccess) throw new Error("Method unavailable");
+ const revision = await options.changeAccess({ ...accessChangeSchema.parse(body), token });
+ instance.closeConnections();
+ json(response, 200, { revision }, origin);
+ } else {
+ if (!options.decideProposal) throw new Error("Method unavailable");
+ const input = proposalDecisionSchema.parse(body);
+ const result = await options.decideProposal({ ...input, token });
+ if (result.update) {
+ const documentName = `${input.documentId}@${input.epoch}`;
+ const direct = await instance.openDirectConnection(documentName);
+ try {
+ await direct.transact((document) => Y.applyUpdate(document, result.update!));
+ } catch {
+ instance.closeConnections(documentName);
+ } finally {
+ await direct.disconnect();
+ }
+ }
+ json(response, 200, { proposal: result.proposal, sequence: result.sequence }, origin);
+ }
+ } finally {
+ release();
+ }
+ } catch {
+ json(response, 403, { error: "Workspace unavailable" }, origin);
+ }
+ throw null;
+ },
+ async onDisconnect({ clientsCount, documentName }) {
+ if (clientsCount > 0) return;
+ const release = writerReleases.get(documentName);
+ writerReleases.delete(documentName);
+ await release?.();
+ },
});
+
+ return server;
}
diff --git a/src/App.tsx b/src/App.tsx
index 10237c792..ec3763dff 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -9,6 +9,7 @@ import { MotionConfig } from "framer-motion";
import { AppLayout } from "@/components/layout/AppLayout";
import { AuthProvider } from "@/lib/supabase/AuthProvider";
import { RequireAuth } from "@/components/auth/RequireAuth";
+import { AccountQueryCache } from "@/components/auth/AccountQueryCache";
import { ThemeProvider } from "@/components/ThemeProvider";
import { useSessionStorageSync } from "@/hooks/use-session-storage-sync";
import { useOnboardingStore } from "@/hooks/use-onboarding";
@@ -23,6 +24,9 @@ const LandingPage = lazy(routeLoaders["/"]);
const TermsPage = lazy(routeLoaders["/terms"]);
const PrivacyPage = lazy(routeLoaders["/privacy"]);
const HomePage = lazy(routeLoaders["/app"]);
+const WorkspacesPage = lazy(routeLoaders["/app/workspaces"]);
+const WorkspaceInvitePage = lazy(routeLoaders["/app/workspaces/invite"]);
+const WorkspacePage = lazy(routeLoaders["/app/workspaces/:workspaceId"]);
const GeneratePage = lazy(routeLoaders["/app/generate"]);
const AttackPage = lazy(routeLoaders["/app/attack"]);
const BrainstormPage = lazy(routeLoaders["/app/brainstorm"]);
@@ -93,6 +97,7 @@ const ROUTE_FALLBACKS: Record = {
"/expocit/sources": { variant: "public", label: "Sources", labelKey: "crossfire.sources" },
"/login": { variant: "public", label: "Sign in", labelKey: "login.title" },
"/app": { variant: "home-grid", label: "Home", labelKey: "nav.home" },
+ "/app/workspaces": { variant: "settings/list", label: "Workspaces", labelKey: "nav.workspaces" },
"/app/generate": { variant: "tool-form", label: "Generate", labelKey: "nav.generate" },
"/app/attack": { variant: "tool-form", label: "Attack", labelKey: "nav.attack" },
"/app/brainstorm": { variant: "tool-form", label: "Brainstorm", labelKey: "nav.brainstorm" },
@@ -235,6 +240,9 @@ function AppRoutes() {
}
>
} />
+ } />
+ } />
+ } />
} />
} />
} />
@@ -257,6 +265,7 @@ function AppRoutes() {
const App = () => (
+
diff --git a/src/components/auth/AccountQueryCache.tsx b/src/components/auth/AccountQueryCache.tsx
new file mode 100644
index 000000000..2f54dfa09
--- /dev/null
+++ b/src/components/auth/AccountQueryCache.tsx
@@ -0,0 +1,17 @@
+import { useEffect, useRef } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { useAuth } from "@/lib/supabase/AuthProvider";
+
+export function AccountQueryCache(): null {
+ const { user } = useAuth();
+ const queryClient = useQueryClient();
+ const previousUserId = useRef(user?.id ?? null);
+
+ useEffect(() => {
+ const nextUserId = user?.id ?? null;
+ if (previousUserId.current !== nextUserId) queryClient.clear();
+ previousUserId.current = nextUserId;
+ }, [queryClient, user?.id]);
+
+ return null;
+}
diff --git a/src/components/auth/RequireAuth.tsx b/src/components/auth/RequireAuth.tsx
index 27c7efdaf..22e876317 100644
--- a/src/components/auth/RequireAuth.tsx
+++ b/src/components/auth/RequireAuth.tsx
@@ -1,6 +1,7 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/lib/supabase/AuthProvider';
import { Loader2 } from 'lucide-react';
+import { consumeWorkspaceInvite, stashWorkspaceInvite } from '@/lib/workspace/invite-handoff';
export function RequireAuth({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
@@ -15,8 +16,14 @@ export function RequireAuth({ children }: { children: React.ReactNode }) {
}
if (!user) {
- return ;
+ const invite = stashWorkspaceInvite(location);
+ return ;
}
+ const invite = location.pathname === "/app/workspaces/invite"
+ ? null
+ : consumeWorkspaceInvite();
+ if (invite) return ;
+
return <>{children}>;
}
diff --git a/src/components/features/workspace/WorkspaceAccessPanel.tsx b/src/components/features/workspace/WorkspaceAccessPanel.tsx
new file mode 100644
index 000000000..56371f2f0
--- /dev/null
+++ b/src/components/features/workspace/WorkspaceAccessPanel.tsx
@@ -0,0 +1,167 @@
+import { useRef, useState, type FormEvent } from "react";
+import { useTranslation } from "react-i18next";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { createCodeInvite, revokeInvite, sendEmailInvite } from "@/lib/workspace/invite-client";
+import { removeWorkspaceMember } from "@/lib/workspace/client";
+import { changeWorkspaceAccess } from "@/lib/workspace/client";
+import { useOnlineStatus } from "@/hooks/use-online-status";
+
+export function WorkspaceAccessPanel({
+ members,
+ onChanged,
+ ownerId,
+ pendingOwnerId,
+ revision,
+ token,
+ userId,
+ workspaceId,
+}: {
+ members: Array<{ user_id: string }>;
+ onChanged: () => Promise;
+ ownerId: string;
+ pendingOwnerId: string | null;
+ revision: number;
+ token: string;
+ userId: string;
+ workspaceId: string;
+}) {
+ const { t } = useTranslation();
+ const online = useOnlineStatus();
+ const [busy, setBusy] = useState(false);
+ const [code, setCode] = useState(null);
+ const [inviteId, setInviteId] = useState(null);
+ const [email, setEmail] = useState("");
+ const [message, setMessage] = useState(null);
+ const emailRequestId = useRef(null);
+
+ const issueCode = async () => {
+ setBusy(true);
+ setMessage(null);
+ try {
+ const invite = await createCodeInvite(workspaceId, inviteId ?? undefined);
+ setCode(invite.code);
+ setInviteId(invite.inviteId);
+ } catch {
+ setMessage(t("workspaces.inviteFailed", "Invitation could not be created."));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const sendEmail = async (event: FormEvent) => {
+ event.preventDefault();
+ if (!email.trim()) return;
+ setBusy(true);
+ setMessage(null);
+ emailRequestId.current ??= crypto.randomUUID();
+ try {
+ await sendEmailInvite(workspaceId, email.trim(), emailRequestId.current);
+ emailRequestId.current = null;
+ setEmail("");
+ setMessage(t("workspaces.emailInviteSent", "Email invitation sent."));
+ } catch {
+ setMessage(t("workspaces.emailInviteRetry", "Delivery failed. Retry to continue the same invitation."));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+ {t("workspaces.invitePeople", "Invite people")}
+
+
void issueCode()}>
+ {inviteId ? t("workspaces.rotateCode", "Rotate code") : t("workspaces.createCode", "Create join code")}
+
+ {code && (
+
+
+ void navigator.clipboard.writeText(code)}>{t("workspaces.copy", "Copy")}
+ void (async () => {
+ setBusy(true);
+ try {
+ await revokeInvite(workspaceId, inviteId!);
+ setCode(null);
+ setInviteId(null);
+ } catch {
+ setMessage(t("workspaces.inviteFailed", "Invitation could not be created."));
+ } finally {
+ setBusy(false);
+ }
+ })()}>{t("workspaces.revoke", "Revoke")}
+
+ )}
+
+
+
{t("workspaces.members", "Members")}
+
+ {members.map((member) => (
+
+
{t("workspaces.memberLabel", "Member {{id}}", { id: member.user_id.slice(0, 4) })}{member.user_id === ownerId ? ` · ${t("workspaces.owner", "Owner")}` : ""}
+ {member.user_id !== ownerId && (
+
+ {userId === ownerId && (
+ void (async () => {
+ setBusy(true);
+ try {
+ await changeWorkspaceAccess({ action: "propose_transfer", expectedRevision: revision, targetUserId: member.user_id, workspaceId });
+ await onChanged();
+ } catch {
+ setMessage(t("workspaces.transferFailed", "Ownership transfer could not be started."));
+ } finally {
+ setBusy(false);
+ }
+ })()}>{t("workspaces.transferOwner", "Make owner")}
+ )}
+ void (async () => {
+ setBusy(true);
+ setMessage(null);
+ try {
+ await removeWorkspaceMember({
+ expectedRevision: revision,
+ targetUserId: member.user_id,
+ token,
+ workspaceId,
+ });
+ await onChanged();
+ } catch {
+ setMessage(t("workspaces.removeFailed", "Member could not be removed."));
+ } finally {
+ setBusy(false);
+ }
+ })()}>{member.user_id === userId ? t("workspaces.leave", "Leave") : t("workspaces.removeMember", "Remove")}
+
+ )}
+
+ ))}
+
+ {pendingOwnerId === userId && (
+
void (async () => {
+ setBusy(true);
+ try {
+ await changeWorkspaceAccess({ action: "accept_transfer", expectedRevision: revision, targetUserId: userId, workspaceId });
+ await onChanged();
+ } catch {
+ setMessage(t("workspaces.transferFailed", "Ownership transfer could not be started."));
+ } finally {
+ setBusy(false);
+ }
+ })()}>{t("workspaces.acceptOwnership", "Accept ownership")}
+ )}
+
+ {message &&
{message}
}
+
+
+ );
+}
diff --git a/src/components/features/workspace/WorkspaceEditor.tsx b/src/components/features/workspace/WorkspaceEditor.tsx
new file mode 100644
index 000000000..353bd3e29
--- /dev/null
+++ b/src/components/features/workspace/WorkspaceEditor.tsx
@@ -0,0 +1,341 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { HocuspocusProvider } from "@hocuspocus/provider";
+import { EditorContent } from "@tiptap/react";
+import { z } from "zod";
+import * as Y from "yjs";
+import { useQueryClient } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { COLLABORATION_URL } from "@/lib/workspace/collaboration-config";
+import {
+ deleteWorkspaceDraft,
+ listWorkspaceDrafts,
+ loadWorkspaceDraft,
+ saveWorkspaceDraft,
+ type WorkspaceDraft,
+} from "@/lib/workspace/draft-store";
+import { durableMutationFromMessage } from "@/lib/workspace/durable-ack";
+import { createWorkspaceEditor } from "@/lib/workspace/editor-schema";
+import { createProposal, createQuoteAnchor, type ProposalHunkInput } from "@/lib/workspace/review";
+import { createWorkspaceComment, saveWorkspaceProposal } from "@/lib/workspace/client";
+import { useOnlineStatus } from "@/hooks/use-online-status";
+
+const acknowledgementSchema = z.object({
+ epoch: z.number().int().nonnegative(),
+ kind: z.literal("durable-ack"),
+ mutationId: z.string(),
+ sequence: z.number().int().positive(),
+});
+
+export type SaveStatus = "Offline" | "Saved" | "Saving";
+
+export function WorkspaceEditor({
+ documentId,
+ editable,
+ epoch,
+ members,
+ token,
+ userId,
+ userName,
+ workspaceId,
+ onDurableSequence,
+ onSaveStatus,
+}: {
+ documentId: string;
+ editable: boolean;
+ epoch: number;
+ members: Array<{ user_id: string }>;
+ token: string;
+ userId: string;
+ userName: string;
+ workspaceId: string;
+ onDurableSequence?: (sequence: number) => void;
+ onSaveStatus?: (status: SaveStatus) => void;
+}) {
+ const { t } = useTranslation();
+ const online = useOnlineStatus();
+ const queryClient = useQueryClient();
+ const identity = useMemo(
+ () => ({ documentId, epoch, userId, workspaceId }),
+ [documentId, epoch, userId, workspaceId],
+ );
+ const [editor, setEditor] = useState | null>(null);
+ const [link, setLink] = useState("");
+ const [status, setStatus] = useState(navigator.onLine ? "Saving" : "Offline");
+ const [storageWarning, setStorageWarning] = useState(false);
+ const [suggesting, setSuggesting] = useState(false);
+ const [reviewError, setReviewError] = useState(null);
+ const [commentOpen, setCommentOpen] = useState(false);
+ const [commentBody, setCommentBody] = useState("");
+ const [mentionId, setMentionId] = useState("");
+ const [recoveryDrafts, setRecoveryDrafts] = useState([]);
+ const suggestingRef = useRef(false);
+
+ useEffect(() => {
+ suggestingRef.current = suggesting;
+ }, [suggesting]);
+
+ useEffect(() => {
+ onSaveStatus?.(status);
+ }, [onSaveStatus, status]);
+
+ useEffect(() => {
+ let disposed = false;
+ setEditor(null);
+ setRecoveryDrafts([]);
+ setStatus(navigator.onLine ? "Saving" : "Offline");
+ let provider: HocuspocusProvider | undefined;
+ let activeEditor: ReturnType | undefined;
+ const pending = new Set();
+ const loadedPending = new Set();
+ let bufferedHunk: ProposalHunkInput | undefined;
+ let suggestionTimer: ReturnType | undefined;
+ const flushSuggestion = () => {
+ if (!bufferedHunk || !activeEditor) return;
+ const hunk = bufferedHunk;
+ bufferedHunk = undefined;
+ if (suggestionTimer) clearTimeout(suggestionTimer);
+ suggestionTimer = undefined;
+ if (!online || pending.size > 0) {
+ if (!disposed) setReviewError(t("workspaces.saveBeforeSuggesting", "Wait for Saved before suggesting a change."));
+ return;
+ }
+ const proposal = createProposal({
+ authorId: userId,
+ documentEpoch: epoch,
+ editor: activeEditor,
+ hunks: [hunk],
+ id: crypto.randomUUID(),
+ });
+ void saveWorkspaceProposal({
+ documentId,
+ proposal,
+ workspaceId,
+ }).then(() => {
+ if (disposed) return;
+ setReviewError(null);
+ void queryClient.invalidateQueries({ queryKey: ["workspace-proposals", userId, documentId] });
+ }).catch(() => {
+ if (!disposed) setReviewError(t("workspaces.suggestionFailed", "Suggestion could not be saved."));
+ });
+ };
+ const queueSuggestion = (hunk: ProposalHunkInput) => {
+ const previous = bufferedHunk;
+ const sameRange = previous?.kind === "insert" && hunk.kind === "insert"
+ ? previous.at === hunk.at
+ : previous?.kind === "format" && hunk.kind === "format"
+ ? previous.from === hunk.from && previous.to === hunk.to
+ : false;
+ if (
+ previous && previous.kind !== "delete" && hunk.kind === previous.kind && sameRange &&
+ previous.content.length === 1 && hunk.content.length === 1 &&
+ previous.content[0].type === "text" && hunk.content[0].type === "text" &&
+ JSON.stringify(previous.content[0].marks ?? []) === JSON.stringify(hunk.content[0].marks ?? [])
+ ) {
+ bufferedHunk = {
+ ...previous,
+ content: [{ ...previous.content[0], text: `${previous.content[0].text ?? ""}${hunk.content[0].text ?? ""}` }],
+ } as ProposalHunkInput;
+ } else {
+ flushSuggestion();
+ bufferedHunk = hunk;
+ }
+ if (suggestionTimer) clearTimeout(suggestionTimer);
+ suggestionTimer = setTimeout(flushSuggestion, 400);
+ };
+ const document = new Y.Doc();
+ const persist = async () => {
+ try {
+ await saveWorkspaceDraft({
+ ...identity,
+ pendingMutationIds: [...pending],
+ snapshot: Y.encodeStateAsUpdate(document),
+ updatedAt: Date.now(),
+ });
+ if (!disposed) setStorageWarning(false);
+ } catch {
+ if (!disposed) setStorageWarning(true);
+ }
+ };
+ const start = async () => {
+ try {
+ const drafts = await listWorkspaceDrafts(identity);
+ if (!disposed) setRecoveryDrafts(drafts.filter((draft) => draft.epoch !== epoch));
+ const draft = await loadWorkspaceDraft(identity);
+ if (draft?.snapshot.byteLength) Y.applyUpdate(document, draft.snapshot);
+ draft?.pendingMutationIds.forEach((mutationId) => {
+ loadedPending.add(mutationId);
+ pending.add(mutationId);
+ });
+ } catch {
+ if (!disposed) setStorageWarning(true);
+ }
+ if (disposed || !COLLABORATION_URL) return;
+
+ provider = new HocuspocusProvider({
+ document,
+ name: `${documentId}@${epoch}`,
+ onOutgoingMessage: ({ message }) => {
+ void durableMutationFromMessage(message.toUint8Array()).then((mutation) => {
+ if (!mutation || disposed) return;
+ pending.add(mutation.mutationId);
+ setStatus("Saving");
+ void persist();
+ });
+ },
+ onStateless: ({ payload }) => {
+ if (disposed) return;
+ let input: unknown;
+ try {
+ input = JSON.parse(payload);
+ } catch {
+ return;
+ }
+ const acknowledgement = acknowledgementSchema.safeParse(input);
+ if (!acknowledgement.success || acknowledgement.data.epoch !== epoch) return;
+ if (loadedPending.size > 0 && !loadedPending.has(acknowledgement.data.mutationId)) {
+ loadedPending.forEach((mutationId) => pending.delete(mutationId));
+ loadedPending.clear();
+ }
+ loadedPending.delete(acknowledgement.data.mutationId);
+ pending.delete(acknowledgement.data.mutationId);
+ onDurableSequence?.(acknowledgement.data.sequence);
+ setStatus(pending.size === 0 ? "Saved" : "Saving");
+ if (pending.size === 0) void deleteWorkspaceDraft(identity);
+ else void persist();
+ },
+ onStatus: ({ status: nextStatus }) => {
+ if (!disposed && nextStatus === "disconnected") setStatus("Offline");
+ },
+ onSynced: () => {
+ if (disposed) return;
+ if (pending.size > 0) {
+ provider?.sendStateless(JSON.stringify({
+ epoch,
+ kind: "durable-ack-query",
+ mutationIds: [...pending].slice(0, 100),
+ }));
+ } else {
+ setStatus("Saved");
+ }
+ },
+ token,
+ url: COLLABORATION_URL,
+ });
+ activeEditor = createWorkspaceEditor(document, {
+ ariaLabel: t("workspaces.documentEditor", "Document editor"),
+ editable,
+ isSuggesting: () => suggestingRef.current,
+ onSuggestion: queueSuggestion,
+ provider,
+ user: { color: `hsl(${Math.abs(userId.charCodeAt(0) * 47) % 360} 70% 45%)`, name: userName },
+ });
+ document.on("update", (_update, origin) => {
+ if (origin !== provider) setStatus("Saving");
+ void persist();
+ });
+ if (!disposed) setEditor(activeEditor);
+ };
+ const offline = () => setStatus("Offline");
+ const online = () => provider?.connect();
+ window.addEventListener("offline", offline);
+ window.addEventListener("online", online);
+ void start();
+
+ return () => {
+ disposed = true;
+ flushSuggestion();
+ window.removeEventListener("offline", offline);
+ window.removeEventListener("online", online);
+ activeEditor?.destroy();
+ provider?.destroy();
+ document.destroy();
+ };
+ }, [documentId, editable, epoch, identity, onDurableSequence, online, queryClient, t, token, userId, userName, workspaceId]);
+
+ if (!COLLABORATION_URL) {
+ return {t("workspaces.collaborationUnavailable", "Collaboration service is not configured.")}
;
+ }
+ if (!editor) return {t("workspaces.openingEditor", "Opening editor...")}
;
+
+ return (
+
+
+ setSuggesting((current) => !current)}>
+ {suggesting ? t("workspaces.suggesting", "Suggesting") : t("workspaces.editing", "Editing")}
+
+ editor.chain().focus().toggleBold().run()}>{t("workspaces.bold", "Bold")}
+ editor.chain().focus().toggleItalic().run()}>{t("workspaces.italic", "Italic")}
+ editor.chain().focus().toggleHeading({ level: 2 }).run()}>{t("workspaces.heading", "Heading")}
+ editor.chain().focus().toggleBulletList().run()}>{t("workspaces.bullets", "Bullets")}
+ editor.chain().focus().toggleOrderedList().run()}>{t("workspaces.numberedList", "Numbered list")}
+ editor.chain().focus().insertTable({ cols: 2, rows: 2, withHeaderRow: true }).run()}>{t("workspaces.table", "Table")}
+ setLink(event.target.value)} />
+ editor.chain().focus().setLink({ href: link }).run()}>{t("workspaces.link", "Link")}
+ editor.commands.undo()}>{t("workspaces.undo", "Undo")}
+ editor.commands.redo()}>{t("workspaces.redo", "Redo")}
+ setCommentOpen((current) => !current)}>{t("workspaces.comment", "Comment")}
+ {t(`workspaces.status${status}`, status)}
+
+ {commentOpen && (
+
+ )}
+ {storageWarning &&
{t("workspaces.storageUnavailable", "Local recovery storage is unavailable.")}
}
+ {reviewError &&
{reviewError}
}
+ {recoveryDrafts.map((draft) => (
+
+ {t("workspaces.recovery", "Recovery")}: {t("workspaces.oldDraft", "An offline draft from before the restore is available.")}
+ {
+ const url = URL.createObjectURL(new Blob([Uint8Array.from(draft.snapshot).buffer], { type: "application/octet-stream" }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `workspace-draft-${documentId}-epoch-${draft.epoch}.bin`;
+ link.click();
+ URL.revokeObjectURL(url);
+ }}>{t("workspaces.exportDraft", "Export draft")}
+ void deleteWorkspaceDraft(draft).then(() => setRecoveryDrafts((current) => current.filter((item) => item.epoch !== draft.epoch)))}>{t("workspaces.discardDraft", "Discard")}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/features/workspace/WorkspaceReviewPanel.tsx b/src/components/features/workspace/WorkspaceReviewPanel.tsx
new file mode 100644
index 000000000..c4fc81efb
--- /dev/null
+++ b/src/components/features/workspace/WorkspaceReviewPanel.tsx
@@ -0,0 +1,149 @@
+import { useState, type FormEvent } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ decideWorkspaceProposal,
+ listWorkspaceComments,
+ listWorkspaceNotifications,
+ listWorkspaceProposals,
+ replyWorkspaceComment,
+ resolveWorkspaceComment,
+} from "@/lib/workspace/client";
+import { useOnlineStatus } from "@/hooks/use-online-status";
+
+function summarizeContent(value: unknown): string {
+ if (Array.isArray(value)) return value.map(summarizeContent).filter(Boolean).join(" ");
+ if (!value || typeof value !== "object") return "";
+ const node = value as { content?: unknown; marks?: Array<{ type?: unknown }>; text?: unknown; type?: unknown };
+ const content = typeof node.text === "string" ? node.text : summarizeContent(node.content);
+ const marks = node.marks?.map(({ type }) => typeof type === "string" ? type : "").filter(Boolean) ?? [];
+ return marks.length ? `${content} [${marks.join(", ")}]` : content;
+}
+
+export function WorkspaceReviewPanel({
+ documentId,
+ editable,
+ epoch,
+ onDurableSequence,
+ token,
+ userId,
+ workspaceId,
+}: {
+ documentId: string;
+ editable: boolean;
+ epoch: number;
+ onDurableSequence: (sequence: number) => void;
+ token: string;
+ userId: string;
+ workspaceId: string;
+}) {
+ const { t } = useTranslation();
+ const online = useOnlineStatus();
+ const queryClient = useQueryClient();
+ const [reply, setReply] = useState>({});
+ const [error, setError] = useState(null);
+ const proposals = useQuery({
+ queryKey: ["workspace-proposals", userId, documentId],
+ queryFn: () => listWorkspaceProposals(documentId),
+ refetchInterval: 2_000,
+ });
+ const comments = useQuery({
+ queryKey: ["workspace-comments", userId, documentId],
+ queryFn: () => listWorkspaceComments(documentId, workspaceId),
+ refetchInterval: 2_000,
+ });
+ const notifications = useQuery({
+ queryKey: ["workspace-notifications", userId, documentId],
+ queryFn: () => listWorkspaceNotifications(documentId),
+ refetchInterval: 2_000,
+ });
+ const decision = useMutation({
+ mutationFn: (input: { decision: "accepted" | "rejected"; hunkId: string; proposalId: string }) =>
+ decideWorkspaceProposal({ ...input, documentId, epoch, token }),
+ onError: () => setError(t("workspaces.proposalDecisionFailed", "The change could not be decided.")),
+ onSuccess: (result) => {
+ setError(null);
+ onDurableSequence(result.sequence);
+ void queryClient.invalidateQueries({ queryKey: ["workspace-proposals", userId, documentId] });
+ void queryClient.invalidateQueries({ queryKey: ["workspace-versions", userId, documentId] });
+ },
+ });
+
+ const submitReply = async (event: FormEvent, commentId: string) => {
+ event.preventDefault();
+ const body = reply[commentId]?.trim();
+ if (!body) return;
+ try {
+ await replyWorkspaceComment(workspaceId, commentId, body);
+ setReply((current) => ({ ...current, [commentId]: "" }));
+ await queryClient.invalidateQueries({ queryKey: ["workspace-comments", userId, documentId] });
+ } catch {
+ setError(t("workspaces.commentFailed", "Comment could not be saved."));
+ }
+ };
+
+ return (
+
+ {t("workspaces.review", "Review")}
+ {error && {error}
}
+
+ {notifications.data?.map((notification) => (
+
+ {t("workspaces.mentionedYou", "A teammate mentioned you in a comment.")}
+
+ ))}
+ {proposals.data?.flatMap((proposal) => proposal.hunks.map((hunk) => (
+
+
{t(`workspaces.proposal.${hunk.kind}`, hunk.kind)}
+
{t(`workspaces.proposal.${hunk.status}`, hunk.status)}
+
+
+ {t("workspaces.changePosition", "Characters {{from}}–{{to}}", {
+ from: hunk.absoluteFrom,
+ to: hunk.absoluteTo,
+ })}
+
+
{t("workspaces.original", "Original")}
+
+ {summarizeContent(hunk.contextBefore)}
+ {summarizeContent(hunk.original) || t("workspaces.emptyChange", "Empty")}
+ {summarizeContent(hunk.contextAfter)}
+
+
{t("workspaces.proposed", "Proposed")}
+
+ {summarizeContent(hunk.contextBefore)}
+ {summarizeContent(hunk.replacement) || t("workspaces.emptyChange", "Empty")}
+ {summarizeContent(hunk.contextAfter)}
+
+
+ {hunk.status === "pending" && (
+
+ decision.mutate({ decision: "accepted", hunkId: hunk.id, proposalId: proposal.id })}>{t("workspaces.accept", "Accept")}
+ decision.mutate({ decision: "rejected", hunkId: hunk.id, proposalId: proposal.id })}>{t("workspaces.reject", "Reject")}
+
+ )}
+
+ )))}
+ {comments.data?.map((comment) => (
+
+
“{comment.anchor.quote}”
+
{comment.body}
+ {comment.replies.map((item) =>
{item.body}
)}
+
+
void resolveWorkspaceComment(workspaceId, comment.id, !comment.resolved_at)
+ .then(() => queryClient.invalidateQueries({ queryKey: ["workspace-comments", userId, documentId] }))
+ .catch(() => setError(t("workspaces.commentFailed", "Comment could not be saved.")))}>
+ {comment.resolved_at ? t("workspaces.reopenComment", "Reopen") : t("workspaces.resolveComment", "Resolve")}
+
+
+ ))}
+ {!proposals.data?.length && !comments.data?.length &&
{t("workspaces.noReviewItems", "No review items yet.")}
}
+
+
+ );
+}
diff --git a/src/components/features/workspace/WorkspaceVersions.tsx b/src/components/features/workspace/WorkspaceVersions.tsx
new file mode 100644
index 000000000..937ccc2dd
--- /dev/null
+++ b/src/components/features/workspace/WorkspaceVersions.tsx
@@ -0,0 +1,114 @@
+import { useEffect, useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { EditorContent } from "@tiptap/react";
+import { useTranslation } from "react-i18next";
+import * as Y from "yjs";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ createWorkspaceVersion,
+ getWorkspaceVersionPreview,
+ listWorkspaceVersions,
+ restoreWorkspaceVersion,
+ type WorkspaceVersionPreview,
+} from "@/lib/workspace/client";
+import { createWorkspaceEditor } from "@/lib/workspace/editor-schema";
+
+function VersionPreview({ ariaLabel, preview }: { ariaLabel: string; preview: WorkspaceVersionPreview }) {
+ const [editor, setEditor] = useState | null>(null);
+
+ useEffect(() => {
+ const document = new Y.Doc();
+ Y.applyUpdate(document, preview.snapshot);
+ const nextEditor = createWorkspaceEditor(document, { editable: false, ariaLabel });
+ setEditor(nextEditor);
+ return () => {
+ nextEditor.destroy();
+ document.destroy();
+ };
+ }, [ariaLabel, preview]);
+
+ return editor ? : null;
+}
+
+export function WorkspaceVersions({
+ canWrite,
+ documentId,
+ userId,
+ workspaceId,
+}: {
+ canWrite: boolean;
+ documentId: string;
+ userId: string;
+ workspaceId: string;
+}) {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const [name, setName] = useState("");
+ const [error, setError] = useState(null);
+ const [selectedVersionId, setSelectedVersionId] = useState(null);
+ const versions = useQuery({
+ queryKey: ["workspace-versions", userId, documentId],
+ queryFn: () => listWorkspaceVersions(documentId),
+ });
+ const save = useMutation({
+ mutationFn: () => createWorkspaceVersion({ documentId, name: name.trim() || null, workspaceId }),
+ onError: () => setError(t("workspaces.versionSaveFailed", "Version could not be saved.")),
+ onSuccess: () => {
+ setError(null);
+ setName("");
+ void queryClient.invalidateQueries({ queryKey: ["workspace-versions", userId, documentId] });
+ },
+ });
+ const preview = useQuery({
+ queryKey: ["workspace-version-preview", userId, documentId, selectedVersionId],
+ queryFn: () => getWorkspaceVersionPreview({
+ documentId,
+ versionId: selectedVersionId!,
+ workspaceId,
+ }),
+ enabled: selectedVersionId !== null,
+ });
+ const restore = useMutation({
+ mutationFn: () => restoreWorkspaceVersion({
+ documentId,
+ previewedSequence: preview.data!.currentSequence,
+ versionId: selectedVersionId!,
+ workspaceId,
+ }),
+ onError: () => setError(t("workspaces.versionRestoreConflict", "The document changed. Preview the version again before restoring.")),
+ onSuccess: () => window.location.reload(),
+ });
+
+ return (
+
+ {t("workspaces.versionHistory", "Version history")}
+
+ setName(event.target.value)} placeholder={t("workspaces.versionName", "Version name")} />
+ save.mutate()} disabled={!canWrite || save.isPending}>{t("workspaces.saveVersion", "Save version")}
+
+ {error && {error}
}
+
+ {versions.data?.map((version) => (
+
+ {version.name ?? t(`workspaces.versionKind.${version.kind}`, version.kind)}
+ setSelectedVersionId(version.id)} aria-pressed={selectedVersionId === version.id}>
+ {t("workspaces.preview", "Preview")}
+
+
+ ))}
+
+ {preview.isError && {t("workspaces.versionPreviewFailed", "Version preview could not be loaded.")}
}
+ {preview.data && (
+
+
+
+ restore.mutate()} disabled={!canWrite || restore.isPending}>
+ {t("workspaces.restoreVersion", "Restore this version")}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx
index 28567a438..c41ae5a53 100644
--- a/src/components/layout/AppLayout.tsx
+++ b/src/components/layout/AppLayout.tsx
@@ -18,7 +18,8 @@ import {
Dumbbell,
Database,
GitBranch,
- Crosshair
+ Crosshair,
+ Files,
} from 'lucide-react';
import { NavLink } from '@/components/NavLink';
import { Button } from '@/components/ui/button';
@@ -36,6 +37,7 @@ import {
const navItems = [
{ path: '/app', key: 'home', icon: Home },
+ { path: '/app/workspaces', key: 'workspaces', icon: Files },
{ path: '/app/generate', key: 'generate', icon: Sparkles },
{ path: '/app/attack', key: 'attack', icon: Swords },
{ path: '/app/brainstorm', key: 'brainstorm', icon: Lightbulb },
@@ -50,7 +52,7 @@ const navItems = [
];
const primaryNavItems = navItems.filter((item) =>
- ['home', 'generate', 'attack', 'ask', 'crossfire', 'sparring'].includes(item.key),
+ ['home', 'workspaces', 'generate', 'attack', 'ask', 'crossfire', 'sparring'].includes(item.key),
);
const moreNavItems = navItems.filter((item) => !primaryNavItems.includes(item));
diff --git a/src/hooks/use-online-status.ts b/src/hooks/use-online-status.ts
new file mode 100644
index 000000000..583aee870
--- /dev/null
+++ b/src/hooks/use-online-status.ts
@@ -0,0 +1,15 @@
+import { useEffect, useState } from "react";
+
+export function useOnlineStatus() {
+ const [online, setOnline] = useState(() => navigator.onLine);
+ useEffect(() => {
+ const update = () => setOnline(navigator.onLine);
+ window.addEventListener("online", update);
+ window.addEventListener("offline", update);
+ return () => {
+ window.removeEventListener("online", update);
+ window.removeEventListener("offline", update);
+ };
+ }, []);
+ return online;
+}
diff --git a/src/lib/route-modules.ts b/src/lib/route-modules.ts
index 58d4620a0..d07253de3 100644
--- a/src/lib/route-modules.ts
+++ b/src/lib/route-modules.ts
@@ -20,6 +20,9 @@ export const routeLoaders = {
"/login": cacheLoader(() => import("@/pages/LoginPage")),
"/share/:code": cacheLoader(() => import("@/pages/SharedPage")),
"/app": cacheLoader(() => import("@/pages/HomePage")),
+ "/app/workspaces": cacheLoader(() => import("@/pages/WorkspacesPage")),
+ "/app/workspaces/invite": cacheLoader(() => import("@/pages/WorkspaceInvitePage")),
+ "/app/workspaces/:workspaceId": cacheLoader(() => import("@/pages/WorkspacePage")),
"/app/generate": cacheLoader(() => import("@/pages/GeneratePage")),
"/app/attack": cacheLoader(() => import("@/pages/AttackPage")),
"/app/brainstorm": cacheLoader(() => import("@/pages/BrainstormPage")),
diff --git a/src/lib/supabase/env.ts b/src/lib/supabase/env.ts
index 7213e5d31..a5061c540 100644
--- a/src/lib/supabase/env.ts
+++ b/src/lib/supabase/env.ts
@@ -1,41 +1,67 @@
-/**
- * Runtime environment resolver for Supabase.
- * Reads localStorage to determine whether to use prod or staging credentials.
- * Only admin users should toggle this via the Settings → Developer panel.
- */
+/** Runtime environment resolver for Supabase. */
-export type SupabaseEnv = 'production' | 'staging';
+export type SupabaseEnv = "local" | "production" | "staging";
-const STORAGE_KEY = 'debate-supabase-env';
+type EnvironmentInput = {
+ VITE_LOCAL_PREVIEW?: string;
+ VITE_LOCAL_SUPABASE_ANON_KEY?: string;
+ VITE_LOCAL_SUPABASE_URL?: string;
+};
+
+type StorageReader = { getItem(key: string): string | null };
+type SupabaseConfig = { anonKey: string; env: SupabaseEnv; url: string };
-const ENVS: Record = {
+const STORAGE_KEY = "debate-supabase-env";
+
+const ENVS: Record, { anonKey: string; url: string }> = {
production: {
- url: 'https://uziwdtkzofptihahfooo.supabase.co',
- anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV6aXdkdGt6b2ZwdGloYWhmb29vIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njk3MDM0MzAsImV4cCI6MjA4NTI3OTQzMH0.caJvRcwlc5w_xGO_HpYxDobmibLbboa_kB2M1IjO3jw',
+ url: "https://uziwdtkzofptihahfooo.supabase.co",
+ anonKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV6aXdkdGt6b2ZwdGloYWhmb29vIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njk3MDM0MzAsImV4cCI6MjA4NTI3OTQzMH0.caJvRcwlc5w_xGO_HpYxDobmibLbboa_kB2M1IjO3jw",
},
staging: {
- url: 'https://brifeyypjauyusmkptkx.supabase.co',
- anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJyaWZleXlwamF1eXVzbWtwdGt4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzkyNTI5NjEsImV4cCI6MjA5NDgyODk2MX0.xKYcvponjw5b_ouPhJDnSBBB1CUSw1Jpz35zGiMmH6A',
+ url: "https://brifeyypjauyusmkptkx.supabase.co",
+ anonKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJyaWZleXlwamF1eXVzbWtwdGt4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzkyNTI5NjEsImV4cCI6MjA5NDgyODk2MX0.xKYcvponjw5b_ouPhJDnSBBB1CUSw1Jpz35zGiMmH6A",
},
};
-export function getActiveEnv(): SupabaseEnv {
- try {
- const stored = localStorage.getItem(STORAGE_KEY);
- if (stored === 'staging') return 'staging';
- } catch { /* SSR or restricted storage */ }
- return 'production';
-}
+export function resolveSupabaseConfig(environment: EnvironmentInput, storage: StorageReader): SupabaseConfig {
+ if (environment.VITE_LOCAL_PREVIEW === "true") {
+ const url = environment.VITE_LOCAL_SUPABASE_URL?.trim();
+ const anonKey = environment.VITE_LOCAL_SUPABASE_ANON_KEY?.trim();
+ if (!url || !anonKey) throw new Error("Local preview configuration is incomplete");
-export function setActiveEnv(env: SupabaseEnv): void {
- if (env === 'production') {
- localStorage.removeItem(STORAGE_KEY);
- } else {
- localStorage.setItem(STORAGE_KEY, env);
+ const parsedUrl = new URL(url);
+ if (Object.values(ENVS).some((remote) => remote.url === parsedUrl.origin)) {
+ throw new Error("Local preview cannot use a remote Supabase project");
+ }
+ return { anonKey, env: "local", url: parsedUrl.href.replace(/\/$/, "") };
}
+
+ return storage.getItem(STORAGE_KEY) === "staging"
+ ? { ...ENVS.staging, env: "staging" }
+ : { ...ENVS.production, env: "production" };
}
-const active = getActiveEnv();
+const storageReader: StorageReader = {
+ getItem(key) {
+ try {
+ return localStorage.getItem(key);
+ } catch {
+ return null;
+ }
+ },
+};
+const active = resolveSupabaseConfig(import.meta.env, storageReader);
+
+export function getActiveEnv(): SupabaseEnv {
+ return active.env;
+}
+
+export function setActiveEnv(env: Exclude): void {
+ if (active.env === "local") return;
+ if (env === "production") localStorage.removeItem(STORAGE_KEY);
+ else localStorage.setItem(STORAGE_KEY, env);
+}
-export const SUPABASE_URL = ENVS[active].url;
-export const SUPABASE_ANON_KEY = ENVS[active].anonKey;
+export const SUPABASE_URL = active.url;
+export const SUPABASE_ANON_KEY = active.anonKey;
diff --git a/src/lib/workspace/client.ts b/src/lib/workspace/client.ts
new file mode 100644
index 000000000..9b8b65ed4
--- /dev/null
+++ b/src/lib/workspace/client.ts
@@ -0,0 +1,453 @@
+import { z } from "zod";
+import { supabase } from "@/lib/supabase/client";
+import { COLLABORATION_URL } from "@/lib/workspace/collaboration-config";
+import {
+ deserializeProposal,
+ serializeProposal,
+ type QuoteAnchor,
+ type WorkspaceProposal,
+} from "@/lib/workspace/review";
+
+const workspaceSummarySchema = z.object({
+ id: z.string().uuid(),
+ motion: z.string(),
+ status: z.enum(["active", "archived", "deleted"]),
+ title: z.string().nullable(),
+ updated_at: z.string(),
+});
+
+export type WorkspaceSummary = z.infer;
+
+const workspaceSchema = z.object({
+ access_epoch: z.number().int().nonnegative(),
+ id: z.string().uuid(),
+ motion: z.string(),
+ owner_id: z.string().uuid(),
+ pending_owner_id: z.string().uuid().nullable(),
+ revision: z.number().int().nonnegative(),
+ status: z.enum(["active", "archived", "deleted"]),
+ title: z.string().nullable(),
+});
+
+const workspaceMemberSchema = z.object({
+ joined_at: z.string(),
+ user_id: z.string().uuid(),
+});
+
+const workspaceDocumentSchema = z.object({
+ assignee_id: z.string().uuid().nullable(),
+ document_epoch: z.number().int().nonnegative(),
+ durable_sequence: z.number().int().nonnegative(),
+ id: z.string().uuid(),
+ position: z.number().int().nonnegative(),
+ side: z.enum(["pro", "con"]),
+ state: z.enum(["active", "archived", "trashed"]),
+ title: z.string(),
+});
+
+export type WorkspaceDocument = z.infer;
+
+export type WorkspaceBundle = {
+ workspace: z.infer;
+ members: z.infer[];
+ documents: z.infer[];
+};
+
+const workspaceVersionSchema = z.object({
+ created_at: z.string(),
+ durable_sequence: z.number().int().nonnegative(),
+ id: z.string().uuid(),
+ kind: z.enum(["automatic", "named", "pre_restore"]),
+ name: z.string().nullable(),
+});
+
+export type WorkspaceVersion = z.infer;
+
+const workspaceVersionPreviewSchema = z.object({
+ current_sequence: z.number().int().nonnegative(),
+ snapshot_base64: z.string(),
+});
+
+export type WorkspaceVersionPreview = {
+ currentSequence: number;
+ snapshot: Uint8Array;
+};
+
+function decodeBase64(value: string) {
+ const binary = atob(value.replace(/\s/g, ""));
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
+}
+
+export async function listWorkspaces(): Promise {
+ const { data, error } = await supabase
+ .from("workspaces")
+ .select("id,motion,status,title,updated_at")
+ .order("updated_at", { ascending: false });
+ if (error) throw error;
+ return workspaceSummarySchema.array().parse(data ?? []);
+}
+
+export async function getWorkspaceBundle(workspaceId: string): Promise {
+ const [workspaceResult, membersResult, documentsResult] = await Promise.all([
+ supabase
+ .from("workspaces")
+ .select("access_epoch,id,motion,owner_id,pending_owner_id,revision,status,title")
+ .eq("id", workspaceId)
+ .single(),
+ supabase
+ .from("workspace_members")
+ .select("joined_at,user_id")
+ .eq("workspace_id", workspaceId),
+ supabase
+ .from("workspace_documents")
+ .select("assignee_id,document_epoch,durable_sequence,id,position,side,state,title")
+ .eq("workspace_id", workspaceId),
+ ]);
+
+ if (workspaceResult.error) throw workspaceResult.error;
+ if (membersResult.error) throw membersResult.error;
+ if (documentsResult.error) throw documentsResult.error;
+
+ return {
+ workspace: workspaceSchema.parse(workspaceResult.data),
+ members: workspaceMemberSchema.array().parse(membersResult.data ?? []),
+ documents: workspaceDocumentSchema.array().parse(documentsResult.data ?? [])
+ .sort((left, right) => left.position - right.position),
+ };
+}
+
+export async function listWorkspaceVersions(documentId: string) {
+ const { data, error } = await supabase
+ .from("workspace_versions")
+ .select("created_at,durable_sequence,id,kind,name")
+ .eq("document_id", documentId)
+ .order("created_at", { ascending: false });
+ if (error) throw error;
+ return workspaceVersionSchema.array().parse(data ?? []);
+}
+
+export async function createWorkspaceVersion(input: {
+ documentId: string;
+ name: string | null;
+ workspaceId: string;
+}) {
+ const current = await supabase
+ .from("workspace_documents")
+ .select("durable_sequence")
+ .eq("id", input.documentId)
+ .single();
+ if (current.error) throw current.error;
+ const sequence = z.object({ durable_sequence: z.number().int().nonnegative() })
+ .parse(current.data).durable_sequence;
+ const { data, error } = await supabase.rpc("create_workspace_version", {
+ p_document_id: input.documentId,
+ p_expected_sequence: sequence,
+ p_name: input.name,
+ p_request_id: crypto.randomUUID(),
+ p_workspace_id: input.workspaceId,
+ });
+ if (error) throw error;
+ return z.string().uuid().parse(data);
+}
+
+export async function getWorkspaceVersionPreview(input: {
+ documentId: string;
+ versionId: string;
+ workspaceId: string;
+}): Promise {
+ const { data, error } = await supabase.rpc("get_workspace_version_snapshot", {
+ p_document_id: input.documentId,
+ p_version_id: input.versionId,
+ p_workspace_id: input.workspaceId,
+ });
+ if (error) throw error;
+ const preview = workspaceVersionPreviewSchema.parse(data?.[0]);
+ return {
+ currentSequence: preview.current_sequence,
+ snapshot: decodeBase64(preview.snapshot_base64),
+ };
+}
+
+export async function restoreWorkspaceVersion(input: {
+ documentId: string;
+ previewedSequence: number;
+ versionId: string;
+ workspaceId: string;
+}) {
+ const { data, error } = await supabase.rpc("restore_workspace_version", {
+ p_document_id: input.documentId,
+ p_previewed_sequence: input.previewedSequence,
+ p_request_id: crypto.randomUUID(),
+ p_version_id: input.versionId,
+ p_workspace_id: input.workspaceId,
+ });
+ if (error) throw error;
+ return z.number().int().nonnegative().parse(data);
+}
+
+export async function createWorkspaceDocument(input: {
+ duplicateDocumentId?: string;
+ expectedRevision: number;
+ side: "pro" | "con";
+ title: string;
+ workspaceId: string;
+}) {
+ const { data, error } = await supabase.rpc("create_workspace_document", {
+ p_duplicate_document_id: input.duplicateDocumentId ?? null,
+ p_expected_revision: input.expectedRevision,
+ p_request_id: crypto.randomUUID(),
+ p_side: input.side,
+ p_title: input.title,
+ p_workspace_id: input.workspaceId,
+ });
+ if (error) throw error;
+ return z.object({ document_id: z.string().uuid(), revision: z.number().int() }).parse(data?.[0]);
+}
+
+export async function updateWorkspaceDocument(input: {
+ assigneeChanged?: boolean;
+ assigneeId?: string | null;
+ documentId: string;
+ expectedRevision: number;
+ orderedIds?: string[];
+ side?: "pro" | "con";
+ state?: "active" | "archived" | "trashed";
+ title?: string;
+ workspaceId: string;
+}) {
+ const { data, error } = await supabase.rpc("update_workspace_document", {
+ p_assignee_changed: input.assigneeChanged ?? false,
+ p_assignee_id: input.assigneeChanged ? input.assigneeId ?? null : null,
+ p_document_id: input.documentId,
+ p_expected_revision: input.expectedRevision,
+ p_ordered_ids: input.orderedIds ?? null,
+ p_request_id: crypto.randomUUID(),
+ p_side: input.side ?? null,
+ p_state: input.state ?? null,
+ p_title: input.title ?? null,
+ p_workspace_id: input.workspaceId,
+ });
+ if (error) throw error;
+ return z.number().int().nonnegative().parse(data);
+}
+
+export async function changeWorkspaceState(input: {
+ action: "archive" | "reopen" | "delete" | "restore";
+ expectedRevision: number;
+ workspaceId: string;
+}) {
+ const { data, error } = await supabase.rpc("change_workspace_state", {
+ p_action: input.action,
+ p_expected_revision: input.expectedRevision,
+ p_request_id: crypto.randomUUID(),
+ p_workspace_id: input.workspaceId,
+ });
+ if (error) throw error;
+ return z.number().int().nonnegative().parse(data);
+}
+
+export async function changeWorkspaceAccess(input: {
+ action: "remove" | "propose_transfer" | "accept_transfer";
+ expectedRevision: number;
+ targetUserId: string;
+ workspaceId: string;
+}) {
+ const { data, error } = await supabase.rpc("change_workspace_access", {
+ p_action: input.action,
+ p_expected_revision: input.expectedRevision,
+ p_request_id: crypto.randomUUID(),
+ p_target_user_id: input.targetUserId,
+ p_workspace_id: input.workspaceId,
+ });
+ if (error) throw error;
+ return z.number().int().nonnegative().parse(data);
+}
+
+export async function removeWorkspaceMember(input: {
+ expectedRevision: number;
+ requestId?: string;
+ targetUserId: string;
+ token: string;
+ workspaceId: string;
+}) {
+ if (!COLLABORATION_URL) throw new Error("Collaboration service unavailable");
+ const url = new URL(COLLABORATION_URL);
+ url.protocol = url.protocol === "wss:" ? "https:" : "http:";
+ url.pathname = "/access/remove";
+ url.search = "";
+ const requestId = input.requestId ?? crypto.randomUUID();
+ const request = () => fetch(url, {
+ body: JSON.stringify({
+ expectedRevision: input.expectedRevision,
+ requestId,
+ targetUserId: input.targetUserId,
+ workspaceId: input.workspaceId,
+ }),
+ headers: {
+ Authorization: `Bearer ${input.token}`,
+ "Content-Type": "application/json",
+ },
+ method: "POST",
+ });
+ let response: Response;
+ try {
+ response = await request();
+ } catch {
+ response = await request();
+ }
+ if (!response.ok) throw new Error("Workspace unavailable");
+ return z.object({ revision: z.number().int().nonnegative() }).parse(await response.json()).revision;
+}
+
+const proposalRowSchema = z.object({ payload: z.unknown() });
+
+export async function listWorkspaceProposals(documentId: string) {
+ const { data, error } = await supabase
+ .from("workspace_proposals")
+ .select("payload")
+ .eq("document_id", documentId)
+ .order("created_at", { ascending: false });
+ if (error) throw error;
+ return proposalRowSchema.array().parse(data ?? []).map(({ payload }) => deserializeProposal(payload));
+}
+
+export async function saveWorkspaceProposal(input: {
+ documentId: string;
+ proposal: WorkspaceProposal;
+ workspaceId: string;
+}) {
+ const current = await supabase
+ .from("workspace_documents")
+ .select("durable_sequence")
+ .eq("id", input.documentId)
+ .single();
+ if (current.error) throw current.error;
+ const sequence = z.object({ durable_sequence: z.number().int().nonnegative() })
+ .parse(current.data).durable_sequence;
+ const { data, error } = await supabase.rpc("create_workspace_proposal", {
+ p_document_epoch: input.proposal.documentEpoch,
+ p_document_id: input.documentId,
+ p_expected_sequence: sequence,
+ p_payload: serializeProposal(input.proposal),
+ p_proposal_id: input.proposal.id,
+ p_workspace_id: input.workspaceId,
+ });
+ if (error) throw error;
+ return z.string().uuid().parse(data);
+}
+
+export async function decideWorkspaceProposal(input: {
+ decision: "accepted" | "rejected";
+ documentId: string;
+ epoch: number;
+ hunkId: string;
+ proposalId: string;
+ token: string;
+}) {
+ if (!COLLABORATION_URL) throw new Error("Collaboration service unavailable");
+ const url = new URL(COLLABORATION_URL);
+ url.protocol = url.protocol === "wss:" ? "https:" : "http:";
+ url.pathname = "/review/decide";
+ url.search = "";
+ const response = await fetch(url, {
+ body: JSON.stringify({
+ decision: input.decision,
+ documentId: input.documentId,
+ epoch: input.epoch,
+ hunkId: input.hunkId,
+ proposalId: input.proposalId,
+ }),
+ headers: { Authorization: `Bearer ${input.token}`, "Content-Type": "application/json" },
+ method: "POST",
+ });
+ if (!response.ok) throw new Error("Proposal unavailable");
+ const result = z.object({ proposal: z.unknown(), sequence: z.number().int().nonnegative() }).parse(await response.json());
+ return { proposal: deserializeProposal(result.proposal), sequence: result.sequence };
+}
+
+const commentSchema = z.object({
+ anchor: z.object({ quote: z.string() }).passthrough(),
+ author_id: z.string().uuid(),
+ body: z.string(),
+ created_at: z.string(),
+ id: z.string().uuid(),
+ resolved_at: z.string().nullable(),
+});
+const replySchema = z.object({
+ author_id: z.string().uuid(),
+ body: z.string(),
+ comment_id: z.string().uuid(),
+ created_at: z.string(),
+ id: z.string().uuid(),
+});
+
+function serializeAnchor(anchor: QuoteAnchor) {
+ return { ...anchor, from: Array.from(anchor.from), to: Array.from(anchor.to) };
+}
+
+export async function listWorkspaceComments(documentId: string, workspaceId: string) {
+ const [comments, replies] = await Promise.all([
+ supabase.from("workspace_comments").select("anchor,author_id,body,created_at,id,resolved_at").eq("document_id", documentId).order("created_at"),
+ supabase.from("workspace_comment_replies").select("author_id,body,comment_id,created_at,id").eq("workspace_id", workspaceId).order("created_at"),
+ ]);
+ if (comments.error) throw comments.error;
+ if (replies.error) throw replies.error;
+ const parsedReplies = replySchema.array().parse(replies.data ?? []);
+ return commentSchema.array().parse(comments.data ?? []).map((comment) => ({
+ ...comment,
+ replies: parsedReplies.filter((reply) => reply.comment_id === comment.id),
+ }));
+}
+
+export async function createWorkspaceComment(input: {
+ anchor: QuoteAnchor;
+ body: string;
+ documentId: string;
+ mentionIds: string[];
+ workspaceId: string;
+}) {
+ const { data, error } = await supabase.rpc("create_workspace_comment", {
+ p_anchor: serializeAnchor(input.anchor),
+ p_body: input.body,
+ p_comment_id: input.anchor.id,
+ p_document_id: input.documentId,
+ p_mention_ids: input.mentionIds,
+ p_workspace_id: input.workspaceId,
+ });
+ if (error) throw error;
+ return z.string().uuid().parse(data);
+}
+
+export async function replyWorkspaceComment(workspaceId: string, commentId: string, body: string) {
+ const { error } = await supabase.rpc("reply_workspace_comment", {
+ p_body: body,
+ p_comment_id: commentId,
+ p_reply_id: crypto.randomUUID(),
+ p_workspace_id: workspaceId,
+ });
+ if (error) throw error;
+}
+
+export async function resolveWorkspaceComment(workspaceId: string, commentId: string, resolved: boolean) {
+ const { error } = await supabase.rpc("resolve_workspace_comment", {
+ p_comment_id: commentId,
+ p_resolved: resolved,
+ p_workspace_id: workspaceId,
+ });
+ if (error) throw error;
+}
+
+export async function listWorkspaceNotifications(documentId: string) {
+ const { data, error } = await supabase
+ .from("workspace_notifications")
+ .select("comment_id,created_at,id,read_at")
+ .eq("document_id", documentId)
+ .order("created_at", { ascending: false });
+ if (error) throw error;
+ return z.object({
+ comment_id: z.string().uuid(),
+ created_at: z.string(),
+ id: z.string().uuid(),
+ read_at: z.string().nullable(),
+ }).array().parse(data ?? []);
+}
diff --git a/src/lib/workspace/collaboration-config.ts b/src/lib/workspace/collaboration-config.ts
new file mode 100644
index 000000000..2ef9b4c40
--- /dev/null
+++ b/src/lib/workspace/collaboration-config.ts
@@ -0,0 +1,26 @@
+type CollaborationEnvironment = {
+ VITE_COLLABORATION_URL?: string;
+ VITE_LOCAL_PREVIEW?: string;
+};
+
+export function resolveCollaborationUrl(environment: CollaborationEnvironment) {
+ const value = environment.VITE_COLLABORATION_URL?.trim();
+ if (!value) {
+ if (environment.VITE_LOCAL_PREVIEW === "true") {
+ throw new Error("Local collaboration configuration is incomplete");
+ }
+ return null;
+ }
+
+ const url = new URL(value);
+ const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost";
+ if (environment.VITE_LOCAL_PREVIEW === "true" && url.protocol !== "wss:" && !(loopback && url.protocol === "ws:")) {
+ throw new Error("Local collaboration URL must use WSS outside loopback");
+ }
+ if (url.protocol !== "ws:" && url.protocol !== "wss:") {
+ throw new Error("Collaboration URL must use WebSocket transport");
+ }
+ return url.href.replace(/\/$/, "");
+}
+
+export const COLLABORATION_URL = resolveCollaborationUrl(import.meta.env);
diff --git a/src/lib/workspace/draft-store.ts b/src/lib/workspace/draft-store.ts
new file mode 100644
index 000000000..dbe558792
--- /dev/null
+++ b/src/lib/workspace/draft-store.ts
@@ -0,0 +1,93 @@
+export type WorkspaceDraft = {
+ documentId: string;
+ epoch: number;
+ pendingMutationIds: string[];
+ snapshot: Uint8Array;
+ updatedAt: number;
+ userId: string;
+ workspaceId: string;
+};
+
+const DATABASE = "debatelab-workspace";
+const STORE = "drafts";
+
+export function workspaceDraftKey(draft: Pick) {
+ return `${draft.userId}:${draft.workspaceId}:${draft.documentId}:${draft.epoch}`;
+}
+
+function openDraftDatabase() {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(DATABASE, 1);
+ request.onupgradeneeded = () => {
+ if (!request.result.objectStoreNames.contains(STORE)) request.result.createObjectStore(STORE);
+ };
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error ?? new Error("Draft storage unavailable"));
+ });
+}
+
+export async function loadWorkspaceDraft(
+ identity: Pick,
+) {
+ const database = await openDraftDatabase();
+ try {
+ return await new Promise((resolve, reject) => {
+ const request = database.transaction(STORE).objectStore(STORE).get(workspaceDraftKey(identity));
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error ?? new Error("Draft storage unavailable"));
+ });
+ } finally {
+ database.close();
+ }
+}
+
+export async function saveWorkspaceDraft(draft: WorkspaceDraft) {
+ const database = await openDraftDatabase();
+ try {
+ await new Promise((resolve, reject) => {
+ const transaction = database.transaction(STORE, "readwrite");
+ transaction.objectStore(STORE).put(draft, workspaceDraftKey(draft));
+ transaction.oncomplete = () => resolve();
+ transaction.onerror = () => reject(transaction.error ?? new Error("Draft storage unavailable"));
+ transaction.onabort = () => reject(transaction.error ?? new Error("Draft storage unavailable"));
+ });
+ } finally {
+ database.close();
+ }
+}
+
+export async function listWorkspaceDrafts(
+ identity: Pick,
+) {
+ const database = await openDraftDatabase();
+ try {
+ const drafts = await new Promise((resolve, reject) => {
+ const request = database.transaction(STORE).objectStore(STORE).getAll();
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error ?? new Error("Draft storage unavailable"));
+ });
+ return drafts.filter((draft) =>
+ draft.userId === identity.userId &&
+ draft.workspaceId === identity.workspaceId &&
+ draft.documentId === identity.documentId
+ );
+ } finally {
+ database.close();
+ }
+}
+
+export async function deleteWorkspaceDraft(
+ identity: Pick,
+) {
+ const database = await openDraftDatabase();
+ try {
+ await new Promise((resolve, reject) => {
+ const transaction = database.transaction(STORE, "readwrite");
+ transaction.objectStore(STORE).delete(workspaceDraftKey(identity));
+ transaction.oncomplete = () => resolve();
+ transaction.onerror = () => reject(transaction.error ?? new Error("Draft storage unavailable"));
+ });
+ } finally {
+ database.close();
+ }
+}
diff --git a/src/lib/workspace/durable-ack.ts b/src/lib/workspace/durable-ack.ts
new file mode 100644
index 000000000..ca1450fbb
--- /dev/null
+++ b/src/lib/workspace/durable-ack.ts
@@ -0,0 +1,19 @@
+import { createDecoder, readVarString, readVarUint, readVarUint8Array } from "lib0/decoding";
+
+export async function durableMutationFromMessage(message: Uint8Array) {
+ const decoder = createDecoder(message);
+ readVarString(decoder);
+ if (readVarUint(decoder) !== 0) return null;
+ const syncType = readVarUint(decoder);
+ if (syncType !== 1 && syncType !== 2) return null;
+
+ const payload = readVarUint8Array(decoder);
+ const digestInput = new Uint8Array(payload.byteLength);
+ digestInput.set(payload);
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", digestInput));
+ const mutationId = btoa(String.fromCharCode(...digest))
+ .replaceAll("+", "-")
+ .replaceAll("/", "_")
+ .replace(/=+$/, "");
+ return { mutationId, syncType };
+}
diff --git a/src/lib/workspace/editor-schema.ts b/src/lib/workspace/editor-schema.ts
index 13108c9af..e4f22be57 100644
--- a/src/lib/workspace/editor-schema.ts
+++ b/src/lib/workspace/editor-schema.ts
@@ -1,19 +1,129 @@
-import { Editor } from "@tiptap/core";
+import { Editor, Extension } from "@tiptap/core";
import Collaboration from "@tiptap/extension-collaboration";
+import CollaborationCaret from "@tiptap/extension-collaboration-caret";
+import type { HocuspocusProvider } from "@hocuspocus/provider";
import type * as Y from "yjs";
+import { Plugin } from "@tiptap/pm/state";
+import { ySyncPluginKey } from "@tiptap/y-tiptap";
+import type { ProposalHunkInput } from "./review";
import {
WORKSPACE_CONTENT_FIELD,
workspaceSchemaExtensions,
} from "./schema";
-export function createWorkspaceEditor(document: Y.Doc): Editor {
+export function createWorkspaceEditor(
+ document: Y.Doc,
+ options: {
+ editable?: boolean;
+ ariaLabel?: string;
+ provider?: HocuspocusProvider;
+ isSuggesting?: () => boolean;
+ onSuggestion?: (hunk: ProposalHunkInput) => void;
+ user?: { color: string; name: string };
+ } = {},
+): Editor {
+ const collaborationCaret = options.provider && options.user
+ ? [CollaborationCaret.configure({ provider: options.provider, user: options.user })]
+ : [];
+ const suggestionInterceptor = options.isSuggesting && options.onSuggestion
+ ? [Extension.create({
+ name: "workspaceSuggestionInterceptor",
+ addProseMirrorPlugins() {
+ let composing = false;
+ let compositionFrom = 0;
+ let compositionTo = 0;
+ let compositionText = "";
+ const finishComposition = (eventText = "") => {
+ const wasComposing = composing;
+ composing = false;
+ const text = (compositionText || eventText).normalize("NFC");
+ compositionText = "";
+ if (!wasComposing || !options.isSuggesting?.()) return;
+ const hunk: ProposalHunkInput | undefined = text
+ ? compositionFrom === compositionTo
+ ? { at: compositionFrom, content: [{ type: "text", text }], id: crypto.randomUUID(), kind: "insert" }
+ : { content: [{ type: "text", text }], from: compositionFrom, id: crypto.randomUUID(), kind: "format", to: compositionTo }
+ : compositionFrom === compositionTo
+ ? undefined
+ : { from: compositionFrom, id: crypto.randomUUID(), kind: "delete", to: compositionTo };
+ if (hunk) queueMicrotask(() => options.onSuggestion?.(hunk));
+ };
+ return [new Plugin({
+ filterTransaction: (transaction, state) => {
+ if (!options.isSuggesting?.() || !transaction.docChanged || transaction.getMeta(ySyncPluginKey)) {
+ return true;
+ }
+ const from = state.doc.content.findDiffStart(transaction.doc.content);
+ const ends = state.doc.content.findDiffEnd(transaction.doc.content);
+ if (from === null || !ends) return true;
+ const to = ends.a;
+ const replacementTo = ends.b;
+ const kind = from === to ? "insert" : from === replacementTo ? "delete" : "format";
+ const hunk: ProposalHunkInput = kind === "insert"
+ ? { at: from, content: transaction.doc.slice(from, replacementTo).content.toJSON(), id: crypto.randomUUID(), kind }
+ : kind === "delete"
+ ? { from, id: crypto.randomUUID(), kind, to }
+ : { content: transaction.doc.slice(from, replacementTo).content.toJSON(), from, id: crypto.randomUUID(), kind, to };
+ if (composing || transaction.getMeta("composition") !== undefined) return false;
+ queueMicrotask(() => options.onSuggestion?.(hunk));
+ return false;
+ },
+ props: {
+ handleDOMEvents: {
+ compositionend: (_view, rawEvent) => {
+ finishComposition((rawEvent as CompositionEvent).data);
+ return false;
+ },
+ compositionstart: (view) => {
+ composing = Boolean(options.isSuggesting?.());
+ compositionFrom = view.state.selection.from;
+ compositionTo = view.state.selection.to;
+ compositionText = "";
+ return false;
+ },
+ compositionupdate: (_view, rawEvent) => {
+ compositionText = (rawEvent as CompositionEvent).data ?? compositionText;
+ return false;
+ },
+ beforeinput: (_view, rawEvent) => {
+ const event = rawEvent as InputEvent;
+ if (!options.isSuggesting?.() || !composing) return false;
+ if (event.inputType === "insertCompositionText") {
+ compositionText = event.data ?? compositionText;
+ event.preventDefault();
+ return true;
+ }
+ if (event.inputType === "insertText" && !event.isComposing) {
+ finishComposition(event.data ?? "");
+ event.preventDefault();
+ return true;
+ }
+ return false;
+ },
+ },
+ },
+ })];
+ },
+ })]
+ : [];
return new Editor({
+ content: { type: "doc", content: [] },
+ editable: options.editable ?? true,
+ editorProps: {
+ attributes: {
+ "aria-label": options.ariaLabel ?? "Document editor",
+ class: "min-h-64 px-4 py-3 focus:outline-none",
+ role: "textbox",
+ },
+ },
extensions: [
...workspaceSchemaExtensions(),
Collaboration.configure({
document,
field: WORKSPACE_CONTENT_FIELD,
}),
+ ...collaborationCaret,
+ ...suggestionInterceptor,
],
});
}
diff --git a/src/lib/workspace/invite-client.ts b/src/lib/workspace/invite-client.ts
new file mode 100644
index 000000000..c006140e2
--- /dev/null
+++ b/src/lib/workspace/invite-client.ts
@@ -0,0 +1,47 @@
+import { z } from "zod";
+import { supabase } from "@/lib/supabase/client";
+
+const codeInviteSchema = z.object({
+ code: z.string(),
+ expiresAt: z.string(),
+ inviteId: z.string().uuid(),
+ maxUses: z.number().int().nullable(),
+});
+
+const redemptionSchema = z.object({
+ alreadyMember: z.boolean(),
+ member: z.literal(true),
+ workspaceId: z.string().uuid(),
+});
+
+async function invoke(body: Record, schema: z.ZodType) {
+ const { data, error } = await supabase.functions.invoke("workspace-invites", { body });
+ if (error) throw error;
+ return schema.parse(data);
+}
+
+export function createCodeInvite(workspaceId: string, inviteId?: string) {
+ return invoke({
+ action: inviteId ? "rotate_code" : "create_code",
+ ...(inviteId ? { inviteId } : {}),
+ requestId: crypto.randomUUID(),
+ workspaceId,
+ }, codeInviteSchema);
+}
+
+export async function revokeInvite(workspaceId: string, inviteId: string) {
+ await invoke({
+ action: "revoke",
+ inviteId,
+ requestId: crypto.randomUUID(),
+ workspaceId,
+ }, z.object({ revoked: z.literal(true) }));
+}
+
+export async function sendEmailInvite(workspaceId: string, email: string, requestId: string) {
+ await invoke({ action: "create_email", email, requestId, workspaceId }, z.object({ accepted: z.literal(true) }));
+}
+
+export function redeemInvite(input: { code?: string; token?: string; requestId: string }) {
+ return invoke({ action: "redeem", ...input }, redemptionSchema);
+}
diff --git a/src/lib/workspace/invite-handoff.ts b/src/lib/workspace/invite-handoff.ts
new file mode 100644
index 000000000..ce595631c
--- /dev/null
+++ b/src/lib/workspace/invite-handoff.ts
@@ -0,0 +1,29 @@
+const STORAGE_KEY = "debate-workspace-invite-handoff";
+const INVITE_PATH = "/app/workspaces/invite";
+const TOKEN = /^wi_[A-Za-z0-9_-]{32}$/;
+
+type Storage = Pick & {
+ getItem(key: string): string | null;
+ removeItem(key: string): void;
+ setItem(key: string, value: string): void;
+};
+
+export function stashWorkspaceInvite(
+ location: { pathname: string; search: string },
+ storage: Storage = sessionStorage,
+) {
+ const token = location.pathname === INVITE_PATH
+ ? new URLSearchParams(location.search).get("token")
+ : null;
+ if (!token || !TOKEN.test(token)) return false;
+ storage.setItem(STORAGE_KEY, token);
+ return true;
+}
+
+export function consumeWorkspaceInvite(storage: Storage = sessionStorage) {
+ const token = storage.getItem(STORAGE_KEY);
+ storage.removeItem(STORAGE_KEY);
+ return token && TOKEN.test(token)
+ ? `${INVITE_PATH}?${new URLSearchParams({ token })}`
+ : null;
+}
diff --git a/src/lib/workspace/preferences.ts b/src/lib/workspace/preferences.ts
new file mode 100644
index 000000000..9d7239c19
--- /dev/null
+++ b/src/lib/workspace/preferences.ts
@@ -0,0 +1,28 @@
+import { z } from "zod";
+
+const preferenceSchema = z.object({
+ documentId: z.string().uuid().nullable(),
+ side: z.enum(["pro", "con"]),
+});
+
+export type WorkspacePreference = z.infer;
+
+function key(userId: string, workspaceId: string) {
+ return `debatelab:workspace:${userId}:${workspaceId}`;
+}
+
+export function loadWorkspacePreference(userId: string, workspaceId: string): WorkspacePreference | null {
+ try {
+ return preferenceSchema.parse(JSON.parse(localStorage.getItem(key(userId, workspaceId)) ?? "null"));
+ } catch {
+ return null;
+ }
+}
+
+export function saveWorkspacePreference(userId: string, workspaceId: string, preference: WorkspacePreference) {
+ try {
+ localStorage.setItem(key(userId, workspaceId), JSON.stringify(preference));
+ } catch {
+ // Non-critical preference; document recovery reports its own storage failures.
+ }
+}
diff --git a/src/lib/workspace/review.ts b/src/lib/workspace/review.ts
index 666c709f7..c7b921df5 100644
--- a/src/lib/workspace/review.ts
+++ b/src/lib/workspace/review.ts
@@ -2,14 +2,19 @@ import type { Editor, JSONContent } from "@tiptap/core";
import { createMappablePosition } from "@tiptap/extension-collaboration";
import { Fragment, Slice } from "@tiptap/pm/model";
import {
+ prosemirrorJSONToYXmlFragment,
relativePositionToAbsolutePosition,
+ yXmlFragmentToProsemirrorJSON,
ySyncPluginKey,
} from "@tiptap/y-tiptap";
import * as Y from "yjs";
+import { z } from "zod";
+import { EditorState } from "@tiptap/pm/state";
+import { WORKSPACE_CONTENT_FIELD, workspaceSchema } from "./schema";
export type ProposalHunkStatus = "pending" | "accepted" | "rejected" | "stale";
-type ProposalHunkInput =
+export type ProposalHunkInput =
| { id: string; kind: "insert"; at: number; content: JSONContent[] }
| { id: string; kind: "delete"; from: number; to: number }
| {
@@ -21,6 +26,8 @@ type ProposalHunkInput =
};
export interface ProposalHunk {
+ absoluteFrom: number;
+ absoluteTo: number;
id: string;
kind: ProposalHunkInput["kind"];
status: ProposalHunkStatus;
@@ -40,6 +47,45 @@ export interface WorkspaceProposal {
hunks: ProposalHunk[];
}
+const bytesSchema = z.array(z.number().int().min(0).max(255)).transform((bytes) => Uint8Array.from(bytes));
+const serializedProposalSchema = z.object({
+ authorId: z.string().uuid(),
+ baseStateVector: bytesSchema,
+ documentEpoch: z.number().int().nonnegative(),
+ hunks: z.array(z.object({
+ contextAfter: z.array(z.unknown()),
+ contextBefore: z.array(z.unknown()),
+ absoluteFrom: z.number().int().nonnegative(),
+ absoluteTo: z.number().int().nonnegative(),
+ from: bytesSchema,
+ id: z.string().min(1).max(100),
+ kind: z.enum(["insert", "delete", "format"]),
+ original: z.array(z.unknown()),
+ replacement: z.array(z.unknown()),
+ status: z.enum(["pending", "accepted", "rejected", "stale"]),
+ to: bytesSchema,
+ })).min(1).max(100),
+ id: z.string().uuid(),
+});
+
+export type SerializedWorkspaceProposal = ReturnType;
+
+export function serializeProposal(proposal: WorkspaceProposal) {
+ return {
+ ...proposal,
+ baseStateVector: Array.from(proposal.baseStateVector),
+ hunks: proposal.hunks.map((hunk) => ({
+ ...hunk,
+ from: Array.from(hunk.from),
+ to: Array.from(hunk.to),
+ })),
+ };
+}
+
+export function deserializeProposal(input: unknown): WorkspaceProposal {
+ return serializedProposalSchema.parse(input) as WorkspaceProposal;
+}
+
export interface QuoteAnchor {
id: string;
documentEpoch: number;
@@ -65,7 +111,12 @@ function documentFor(editor: Editor): Y.Doc {
}
function sameContent(left: JSONContent[], right: JSONContent[]) {
- return JSON.stringify(left) === JSON.stringify(right);
+ const canonical = (value: unknown): unknown => Array.isArray(value)
+ ? value.map(canonical)
+ : value && typeof value === "object"
+ ? Object.fromEntries(Object.entries(value).sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)).map(([key, item]) => [key, canonical(item)]))
+ : value;
+ return JSON.stringify(canonical(left)) === JSON.stringify(canonical(right));
}
function resolveAnchors(editor: Editor, fromAnchor: Uint8Array, toAnchor: Uint8Array) {
@@ -162,6 +213,8 @@ export function createProposal({
input.kind === "delete" ? [] : normalizedContent(editor, input.content);
return {
+ absoluteFrom: from,
+ absoluteTo: to,
id: input.id,
kind: input.kind,
status: "pending",
@@ -184,6 +237,62 @@ export function createProposal({
};
}
+export function acceptProposalHunkInDocument({
+ document,
+ documentEpoch,
+ hunkId,
+ proposal,
+}: {
+ document: Y.Doc;
+ documentEpoch: number;
+ hunkId: string;
+ proposal: WorkspaceProposal;
+}) {
+ const hunk = proposal.hunks.find(({ id }) => id === hunkId);
+ if (!hunk || hunk.status !== "pending") throw new Error("Proposal unavailable");
+ const state = EditorState.create({
+ doc: workspaceSchema.nodeFromJSON(
+ yXmlFragmentToProsemirrorJSON(document.getXmlFragment(WORKSPACE_CONTENT_FIELD)),
+ ),
+ schema: workspaceSchema,
+ });
+ const { absoluteFrom: from, absoluteTo: to } = hunk;
+ const documentEnd = state.doc.content.size;
+ const original = state.doc.slice(from, to).content.toJSON();
+ const contextBefore = state.doc.slice(Math.max(0, from - 1), from).content.toJSON();
+ const contextAfter = state.doc.slice(to, Math.min(documentEnd, to + 1)).content.toJSON();
+ if (
+ documentEpoch !== proposal.documentEpoch ||
+ !sameContent(original, hunk.original) ||
+ !sameContent(contextBefore, hunk.contextBefore) ||
+ !sameContent(contextAfter, hunk.contextAfter)
+ ) {
+ return withStatuses(proposal, new Set([hunkId]), "stale");
+ }
+ const transaction = state.tr.replace(
+ from,
+ to,
+ new Slice(Fragment.fromJSON(workspaceSchema, hunk.replacement), 0, 0),
+ );
+ prosemirrorJSONToYXmlFragment(
+ workspaceSchema,
+ transaction.doc.toJSON(),
+ document.getXmlFragment(WORKSPACE_CONTENT_FIELD),
+ );
+ return {
+ ...proposal,
+ hunks: proposal.hunks.map((candidate) => candidate.id === hunkId
+ ? { ...candidate, status: "accepted" as const }
+ : candidate.status === "pending"
+ ? {
+ ...candidate,
+ absoluteFrom: transaction.mapping.map(candidate.absoluteFrom, -1),
+ absoluteTo: transaction.mapping.map(candidate.absoluteTo, 1),
+ }
+ : candidate),
+ };
+}
+
export function createProposalAfterComposition({
compositionState,
proposal,
diff --git a/src/locales/en.json b/src/locales/en.json
index 3c826a26e..c07090379 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -1,6 +1,7 @@
{
"nav": {
"home": "Home",
+ "workspaces": "Workspaces",
"generate": "Generate",
"attack": "Attack",
"brainstorm": "Brainstorm",
@@ -26,6 +27,7 @@
},
"navDesc": {
"home": "Choose a mode",
+ "workspaces": "Write cases together",
"generate": "Build arguments",
"attack": "Find weaknesses",
"brainstorm": "Explore ideas",
@@ -38,6 +40,158 @@
"flowchart": "Map arguments",
"history": "Saved items"
},
+ "workspaces": {
+ "title": "Workspaces",
+ "new": "New workspace",
+ "motion": "Motion",
+ "motionPlaceholder": "Schools should ban phones",
+ "create": "Create workspace",
+ "creating": "Creating...",
+ "createFailed": "Workspace creation failed. Try again.",
+ "yours": "Your workspaces",
+ "loading": "Loading workspaces...",
+ "loadFailed": "Workspaces could not be loaded.",
+ "empty": "Create your first workspace above.",
+ "active": "Active",
+ "archived": "Archived",
+ "deleted": "Deleted",
+ "all": "All",
+ "workspaceView": "Workspace view",
+ "loadingOne": "Opening workspace...",
+ "unavailable": "Workspace unavailable.",
+ "editorTitle": "Debate workspace",
+ "side": "Side",
+ "proSide": "For",
+ "conSide": "Against",
+ "files": "Files",
+ "assignedTo": "Assigned to {{member}}",
+ "assigned": "Assigned",
+ "document": "Document",
+ "documentEditor": "Document editor",
+ "workingPanel": "Working panel",
+ "versionHistory": "Version history",
+ "versionName": "Version name",
+ "saveVersion": "Save version",
+ "preview": "Preview",
+ "versionPreview": "Version preview",
+ "restoreVersion": "Restore this version",
+ "versionSaveFailed": "Version could not be saved.",
+ "versionPreviewFailed": "Version preview could not be loaded.",
+ "versionRestoreConflict": "The document changed. Preview the version again before restoring.",
+ "onlineRequired": "This action needs a connection.",
+ "updateFailed": "The workspace changed. Reload and try again.",
+ "archiveWorkspace": "Archive workspace",
+ "reopenWorkspace": "Reopen workspace",
+ "fileView": "File view",
+ "activeFiles": "Active",
+ "archivedFiles": "Archived",
+ "trashedFiles": "Trash",
+ "noFiles": "No files here.",
+ "newFileName": "New file name",
+ "addFile": "Add",
+ "fileName": "File name",
+ "rename": "Rename",
+ "moveUp": "Move up",
+ "moveDown": "Move down",
+ "duplicate": "Duplicate",
+ "copySuffix": "copy",
+ "archiveFile": "Archive",
+ "trashFile": "Trash",
+ "restoreFile": "Restore",
+ "assignee": "Assignee",
+ "unassigned": "Unassigned",
+ "invitePeople": "Invite people",
+ "createCode": "Create join code",
+ "rotateCode": "Rotate code",
+ "joinCode": "Join code",
+ "copy": "Copy",
+ "revoke": "Revoke",
+ "email": "Email",
+ "sendInvite": "Send",
+ "inviteFailed": "Invitation could not be created.",
+ "emailInviteSent": "Email invitation sent.",
+ "emailInviteRetry": "Delivery failed. Retry to continue the same invitation.",
+ "inviteUnavailable": "Invitation unavailable.",
+ "joinWorkspace": "Join workspace",
+ "joining": "Joining...",
+ "join": "Join",
+ "members": "Members",
+ "owner": "Owner",
+ "removeMember": "Remove",
+ "removeFailed": "Member could not be removed.",
+ "editing": "Editing",
+ "suggesting": "Suggesting",
+ "saveBeforeSuggesting": "Wait for Saved before suggesting a change.",
+ "suggestionFailed": "Suggestion could not be saved.",
+ "comment": "Comment",
+ "commentText": "Comment text",
+ "mentionMember": "Mention member",
+ "noMention": "No mention",
+ "addComment": "Add comment",
+ "selectCommentText": "Select text before adding a comment.",
+ "commentFailed": "Comment could not be saved.",
+ "review": "Review",
+ "accept": "Accept",
+ "reject": "Reject",
+ "reply": "Reply",
+ "resolveComment": "Resolve",
+ "reopenComment": "Reopen",
+ "noReviewItems": "No review items yet.",
+ "proposalDecisionFailed": "The change could not be decided.",
+ "mentionedYou": "A teammate mentioned you in a comment.",
+ "recovery": "Recovery",
+ "oldDraft": "An offline draft from before the restore is available.",
+ "exportDraft": "Export draft",
+ "discardDraft": "Discard",
+ "transferOwner": "Make owner",
+ "acceptOwnership": "Accept ownership",
+ "transferFailed": "Ownership transfer could not be started.",
+ "leave": "Leave",
+ "restoreWorkspace": "Restore workspace",
+ "deleteWorkspace": "Delete workspace",
+ "deleteWorkspaceConfirm": "Delete this workspace? It can be restored for 30 days.",
+ "collaborationUnavailable": "Collaboration service is not configured.",
+ "openingEditor": "Opening editor...",
+ "formatting": "Formatting",
+ "bold": "Bold",
+ "italic": "Italic",
+ "heading": "Heading",
+ "bullets": "Bullets",
+ "numberedList": "Numbered list",
+ "table": "Table",
+ "linkUrl": "Link URL",
+ "link": "Link",
+ "undo": "Undo",
+ "redo": "Redo",
+ "statusSaving": "Saving",
+ "statusSaved": "Saved",
+ "statusOffline": "Offline",
+ "storageUnavailable": "Local recovery storage is unavailable.",
+ "memberLabel": "Member {{id}}",
+ "mobilePane": "Workspace pane",
+ "filesPane": "Files",
+ "documentPane": "Document",
+ "workingPane": "Working panel",
+ "changePreview": "Proposed change preview",
+ "changePosition": "Characters {{from}}–{{to}}",
+ "original": "Original",
+ "proposed": "Proposed",
+ "emptyChange": "Empty",
+ "proposal": {
+ "insert": "Proposed insertion",
+ "delete": "Proposed deletion",
+ "format": "Proposed replacement or formatting",
+ "pending": "Pending",
+ "accepted": "Accepted",
+ "rejected": "Rejected",
+ "stale": "Detached from current text"
+ },
+ "versionKind": {
+ "automatic": "Automatic",
+ "named": "Named",
+ "pre_restore": "Before restore"
+ }
+ },
"common": {
"export": "Export",
"pdf": "PDF",
diff --git a/src/locales/es.json b/src/locales/es.json
index edb11107a..8935ddc47 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -1,6 +1,7 @@
{
"nav": {
"home": "Inicio",
+ "workspaces": "Espacios",
"generate": "Generar",
"attack": "Atacar",
"brainstorm": "Ideas",
@@ -26,6 +27,7 @@
},
"navDesc": {
"home": "Elegir un modo",
+ "workspaces": "Redactar casos en equipo",
"generate": "Construir argumentos",
"attack": "Encontrar debilidades",
"brainstorm": "Explorar ideas",
@@ -38,6 +40,158 @@
"flowchart": "Mapear argumentos",
"history": "Elementos guardados"
},
+ "workspaces": {
+ "title": "Espacios de trabajo",
+ "new": "Nuevo espacio",
+ "motion": "Moción",
+ "motionPlaceholder": "Los colegios deberían prohibir los móviles",
+ "create": "Crear espacio",
+ "creating": "Creando...",
+ "createFailed": "No se pudo crear el espacio. Inténtalo de nuevo.",
+ "yours": "Tus espacios",
+ "loading": "Cargando espacios...",
+ "loadFailed": "No se pudieron cargar los espacios.",
+ "empty": "Crea tu primer espacio arriba.",
+ "active": "Activo",
+ "archived": "Archivado",
+ "deleted": "Eliminado",
+ "all": "Todos",
+ "workspaceView": "Vista de espacios",
+ "loadingOne": "Abriendo el espacio...",
+ "unavailable": "Espacio no disponible.",
+ "editorTitle": "Espacio de debate",
+ "side": "Lado",
+ "proSide": "A favor",
+ "conSide": "En contra",
+ "files": "Archivos",
+ "assignedTo": "Asignado a {{member}}",
+ "assigned": "Asignado",
+ "document": "Documento",
+ "documentEditor": "Editor del documento",
+ "workingPanel": "Panel de trabajo",
+ "versionHistory": "Historial de versiones",
+ "versionName": "Nombre de la versión",
+ "saveVersion": "Guardar versión",
+ "preview": "Vista previa",
+ "versionPreview": "Vista previa de la versión",
+ "restoreVersion": "Restaurar esta versión",
+ "versionSaveFailed": "No se pudo guardar la versión.",
+ "versionPreviewFailed": "No se pudo cargar la vista previa.",
+ "versionRestoreConflict": "El documento cambió. Vuelve a previsualizar la versión antes de restaurarla.",
+ "onlineRequired": "Esta acción necesita conexión.",
+ "updateFailed": "El espacio cambió. Recarga e inténtalo de nuevo.",
+ "archiveWorkspace": "Archivar espacio",
+ "reopenWorkspace": "Reabrir espacio",
+ "fileView": "Vista de archivos",
+ "activeFiles": "Activos",
+ "archivedFiles": "Archivados",
+ "trashedFiles": "Papelera",
+ "noFiles": "No hay archivos aquí.",
+ "newFileName": "Nombre del archivo nuevo",
+ "addFile": "Añadir",
+ "fileName": "Nombre del archivo",
+ "rename": "Renombrar",
+ "moveUp": "Subir",
+ "moveDown": "Bajar",
+ "duplicate": "Duplicar",
+ "copySuffix": "copia",
+ "archiveFile": "Archivar",
+ "trashFile": "Mover a la papelera",
+ "restoreFile": "Restaurar",
+ "assignee": "Responsable",
+ "unassigned": "Sin asignar",
+ "invitePeople": "Invitar personas",
+ "createCode": "Crear código de acceso",
+ "rotateCode": "Cambiar código",
+ "joinCode": "Código de acceso",
+ "copy": "Copiar",
+ "revoke": "Revocar",
+ "email": "Correo electrónico",
+ "sendInvite": "Enviar",
+ "inviteFailed": "No se pudo crear la invitación.",
+ "emailInviteSent": "Invitación enviada por correo.",
+ "emailInviteRetry": "La entrega falló. Reintenta para continuar la misma invitación.",
+ "inviteUnavailable": "Invitación no disponible.",
+ "joinWorkspace": "Unirse al espacio",
+ "joining": "Uniéndose...",
+ "join": "Unirse",
+ "members": "Miembros",
+ "owner": "Propietario",
+ "removeMember": "Eliminar",
+ "removeFailed": "No se pudo eliminar al miembro.",
+ "editing": "Editando",
+ "suggesting": "Sugiriendo",
+ "saveBeforeSuggesting": "Espera a que aparezca Guardado antes de sugerir un cambio.",
+ "suggestionFailed": "No se pudo guardar la sugerencia.",
+ "comment": "Comentar",
+ "commentText": "Texto del comentario",
+ "mentionMember": "Mencionar miembro",
+ "noMention": "Sin mención",
+ "addComment": "Añadir comentario",
+ "selectCommentText": "Selecciona texto antes de añadir un comentario.",
+ "commentFailed": "No se pudo guardar el comentario.",
+ "review": "Revisión",
+ "accept": "Aceptar",
+ "reject": "Rechazar",
+ "reply": "Responder",
+ "resolveComment": "Resolver",
+ "reopenComment": "Reabrir",
+ "noReviewItems": "Aún no hay elementos de revisión.",
+ "proposalDecisionFailed": "No se pudo decidir el cambio.",
+ "mentionedYou": "Un compañero te mencionó en un comentario.",
+ "recovery": "Recuperación",
+ "oldDraft": "Hay un borrador sin conexión anterior a la restauración.",
+ "exportDraft": "Exportar borrador",
+ "discardDraft": "Descartar",
+ "transferOwner": "Hacer propietario",
+ "acceptOwnership": "Aceptar propiedad",
+ "transferFailed": "No se pudo iniciar la transferencia de propiedad.",
+ "leave": "Salir",
+ "restoreWorkspace": "Restaurar espacio",
+ "deleteWorkspace": "Eliminar espacio",
+ "deleteWorkspaceConfirm": "¿Eliminar este espacio? Se puede restaurar durante 30 días.",
+ "collaborationUnavailable": "El servicio de colaboración no está configurado.",
+ "openingEditor": "Abriendo editor...",
+ "formatting": "Formato",
+ "bold": "Negrita",
+ "italic": "Cursiva",
+ "heading": "Encabezado",
+ "bullets": "Viñetas",
+ "numberedList": "Lista numerada",
+ "table": "Tabla",
+ "linkUrl": "URL del enlace",
+ "link": "Enlace",
+ "undo": "Deshacer",
+ "redo": "Rehacer",
+ "statusSaving": "Guardando",
+ "statusSaved": "Guardado",
+ "statusOffline": "Sin conexión",
+ "storageUnavailable": "El almacenamiento de recuperación local no está disponible.",
+ "memberLabel": "Miembro {{id}}",
+ "mobilePane": "Panel del espacio",
+ "filesPane": "Archivos",
+ "documentPane": "Documento",
+ "workingPane": "Panel",
+ "changePreview": "Vista previa del cambio propuesto",
+ "changePosition": "Caracteres {{from}}–{{to}}",
+ "original": "Original",
+ "proposed": "Propuesto",
+ "emptyChange": "Vacío",
+ "proposal": {
+ "insert": "Inserción propuesta",
+ "delete": "Eliminación propuesta",
+ "format": "Reemplazo o formato propuesto",
+ "pending": "Pendiente",
+ "accepted": "Aceptado",
+ "rejected": "Rechazado",
+ "stale": "Desvinculado del texto actual"
+ },
+ "versionKind": {
+ "automatic": "Automática",
+ "named": "Nombrada",
+ "pre_restore": "Antes de restaurar"
+ }
+ },
"common": {
"export": "Exportar",
"pdf": "PDF",
diff --git a/src/locales/hu.json b/src/locales/hu.json
index 0e6cd0147..f4da98fc4 100644
--- a/src/locales/hu.json
+++ b/src/locales/hu.json
@@ -1,6 +1,7 @@
{
"nav": {
"home": "Kezdőlap",
+ "workspaces": "Munkaterek",
"generate": "Generálás",
"attack": "Támadás",
"brainstorm": "Ötletelés",
@@ -26,6 +27,7 @@
},
"navDesc": {
"home": "Mód választása",
+ "workspaces": "Közös esetírás",
"generate": "Érvek építése",
"attack": "Gyengeségek keresése",
"brainstorm": "Ötletek felfedezése",
@@ -38,6 +40,158 @@
"flowchart": "Érvek térképezése",
"history": "Mentett elemek"
},
+ "workspaces": {
+ "title": "Munkaterek",
+ "new": "Új munkatér",
+ "motion": "Tételmondat",
+ "motionPlaceholder": "Az iskoláknak be kellene tiltaniuk a mobiltelefonokat",
+ "create": "Munkatér létrehozása",
+ "creating": "Létrehozás...",
+ "createFailed": "A munkatér létrehozása sikertelen. Próbáld újra.",
+ "yours": "Saját munkaterek",
+ "loading": "Munkaterek betöltése...",
+ "loadFailed": "A munkaterek nem tölthetők be.",
+ "empty": "Hozd létre fent az első munkateredet.",
+ "active": "Aktív",
+ "archived": "Archivált",
+ "deleted": "Törölt",
+ "all": "Mind",
+ "workspaceView": "Munkatérnézet",
+ "loadingOne": "Munkatér megnyitása...",
+ "unavailable": "A munkatér nem érhető el.",
+ "editorTitle": "Vitamunkatér",
+ "side": "Oldal",
+ "proSide": "Mellette",
+ "conSide": "Ellene",
+ "files": "Fájlok",
+ "assignedTo": "Felelős: {{member}}",
+ "assigned": "Kiosztva",
+ "document": "Dokumentum",
+ "documentEditor": "Dokumentumszerkesztő",
+ "workingPanel": "Munkapanel",
+ "versionHistory": "Verzióelőzmények",
+ "versionName": "Verzió neve",
+ "saveVersion": "Verzió mentése",
+ "preview": "Előnézet",
+ "versionPreview": "Verzió előnézete",
+ "restoreVersion": "Verzió visszaállítása",
+ "versionSaveFailed": "A verzió nem menthető.",
+ "versionPreviewFailed": "A verzió előnézete nem tölthető be.",
+ "versionRestoreConflict": "A dokumentum megváltozott. Visszaállítás előtt nyisd meg újra az előnézetet.",
+ "onlineRequired": "Ehhez a művelethez kapcsolat szükséges.",
+ "updateFailed": "A munkatér megváltozott. Töltsd újra és próbáld meg ismét.",
+ "archiveWorkspace": "Munkatér archiválása",
+ "reopenWorkspace": "Munkatér újranyitása",
+ "fileView": "Fájlnézet",
+ "activeFiles": "Aktív",
+ "archivedFiles": "Archivált",
+ "trashedFiles": "Kuka",
+ "noFiles": "Itt nincs fájl.",
+ "newFileName": "Új fájl neve",
+ "addFile": "Hozzáadás",
+ "fileName": "Fájl neve",
+ "rename": "Átnevezés",
+ "moveUp": "Fel",
+ "moveDown": "Le",
+ "duplicate": "Másolat",
+ "copySuffix": "másolat",
+ "archiveFile": "Archiválás",
+ "trashFile": "Kukába",
+ "restoreFile": "Visszaállítás",
+ "assignee": "Felelős",
+ "unassigned": "Nincs kiosztva",
+ "invitePeople": "Tagok meghívása",
+ "createCode": "Csatlakozási kód létrehozása",
+ "rotateCode": "Kód cseréje",
+ "joinCode": "Csatlakozási kód",
+ "copy": "Másolás",
+ "revoke": "Visszavonás",
+ "email": "E-mail",
+ "sendInvite": "Küldés",
+ "inviteFailed": "A meghívó nem hozható létre.",
+ "emailInviteSent": "Az e-mailes meghívó elküldve.",
+ "emailInviteRetry": "A kézbesítés sikertelen. Próbáld újra ugyanazzal a meghívóval.",
+ "inviteUnavailable": "A meghívó nem érhető el.",
+ "joinWorkspace": "Csatlakozás munkatérhez",
+ "joining": "Csatlakozás...",
+ "join": "Csatlakozás",
+ "members": "Tagok",
+ "owner": "Tulajdonos",
+ "removeMember": "Eltávolítás",
+ "removeFailed": "A tag nem távolítható el.",
+ "editing": "Szerkesztés",
+ "suggesting": "Javaslattétel",
+ "saveBeforeSuggesting": "Várd meg a Mentve állapotot a javaslat előtt.",
+ "suggestionFailed": "A javaslat nem menthető.",
+ "comment": "Megjegyzés",
+ "commentText": "Megjegyzés szövege",
+ "mentionMember": "Tag megemlítése",
+ "noMention": "Nincs megemlítés",
+ "addComment": "Megjegyzés hozzáadása",
+ "selectCommentText": "Jelölj ki szöveget a megjegyzés előtt.",
+ "commentFailed": "A megjegyzés nem menthető.",
+ "review": "Véleményezés",
+ "accept": "Elfogadás",
+ "reject": "Elutasítás",
+ "reply": "Válasz",
+ "resolveComment": "Lezárás",
+ "reopenComment": "Újranyitás",
+ "noReviewItems": "Még nincs véleményezési elem.",
+ "proposalDecisionFailed": "A módosításról nem lehetett dönteni.",
+ "mentionedYou": "Egy csapattárs megemlített egy megjegyzésben.",
+ "recovery": "Helyreállítás",
+ "oldDraft": "Elérhető egy visszaállítás előtti offline piszkozat.",
+ "exportDraft": "Piszkozat exportálása",
+ "discardDraft": "Elvetés",
+ "transferOwner": "Tulajdonossá tétel",
+ "acceptOwnership": "Tulajdonjog elfogadása",
+ "transferFailed": "A tulajdonjog átadása nem indítható el.",
+ "leave": "Kilépés",
+ "restoreWorkspace": "Munkatér visszaállítása",
+ "deleteWorkspace": "Munkatér törlése",
+ "deleteWorkspaceConfirm": "Törlöd ezt a munkateret? 30 napig visszaállítható.",
+ "collaborationUnavailable": "Az együttműködési szolgáltatás nincs beállítva.",
+ "openingEditor": "Szerkesztő megnyitása...",
+ "formatting": "Formázás",
+ "bold": "Félkövér",
+ "italic": "Dőlt",
+ "heading": "Címsor",
+ "bullets": "Felsorolás",
+ "numberedList": "Számozott lista",
+ "table": "Táblázat",
+ "linkUrl": "Hivatkozás URL-je",
+ "link": "Hivatkozás",
+ "undo": "Visszavonás",
+ "redo": "Újra",
+ "statusSaving": "Mentés",
+ "statusSaved": "Mentve",
+ "statusOffline": "Kapcsolat nélkül",
+ "storageUnavailable": "A helyi helyreállítási tár nem érhető el.",
+ "memberLabel": "Tag {{id}}",
+ "mobilePane": "Munkatér panel",
+ "filesPane": "Fájlok",
+ "documentPane": "Dokumentum",
+ "workingPane": "Munkapanel",
+ "changePreview": "A javasolt módosítás előnézete",
+ "changePosition": "{{from}}–{{to}}. karakter",
+ "original": "Eredeti",
+ "proposed": "Javasolt",
+ "emptyChange": "Üres",
+ "proposal": {
+ "insert": "Javasolt beszúrás",
+ "delete": "Javasolt törlés",
+ "format": "Javasolt csere vagy formázás",
+ "pending": "Függőben",
+ "accepted": "Elfogadva",
+ "rejected": "Elutasítva",
+ "stale": "Leválasztva az aktuális szövegről"
+ },
+ "versionKind": {
+ "automatic": "Automatikus",
+ "named": "Elnevezett",
+ "pre_restore": "Visszaállítás előtt"
+ }
+ },
"common": {
"export": "Exportálás",
"pdf": "PDF",
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx
index 3af133898..3f1c02a6e 100644
--- a/src/pages/LoginPage.tsx
+++ b/src/pages/LoginPage.tsx
@@ -9,6 +9,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Sparkles } from 'lucide-react';
import { useAuth } from '@/lib/supabase/AuthProvider';
import { useToast } from '@/hooks/use-toast';
+import { consumeWorkspaceInvite } from '@/lib/workspace/invite-handoff';
type AuthMode = 'email' | 'demo';
type AuthStatus = 'idle' | 'submitting' | 'code-sent' | 'verifying' | 'success' | 'error';
@@ -38,7 +39,7 @@ export default function LoginPage() {
const from = (location.state as { from?: { pathname?: string } } | null)?.from?.pathname || '/app';
useEffect(() => {
- if (user) navigate(from, { replace: true });
+ if (user) navigate(consumeWorkspaceInvite() ?? from, { replace: true });
}, [user, navigate, from]);
useEffect(() => {
diff --git a/src/pages/WorkspaceInvitePage.tsx b/src/pages/WorkspaceInvitePage.tsx
new file mode 100644
index 000000000..30556904b
--- /dev/null
+++ b/src/pages/WorkspaceInvitePage.tsx
@@ -0,0 +1,56 @@
+import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
+import { useTranslation } from "react-i18next";
+import { useLocation, useNavigate } from "react-router-dom";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { redeemInvite } from "@/lib/workspace/invite-client";
+
+export default function WorkspaceInvitePage() {
+ const { t } = useTranslation();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const token = useRef(new URLSearchParams(location.search).get("token"));
+ const started = useRef(false);
+ const [code, setCode] = useState("");
+ const [busy, setBusy] = useState(Boolean(token.current));
+ const [error, setError] = useState(null);
+
+ const redeem = useCallback(async (input: { code?: string; token?: string }) => {
+ setBusy(true);
+ setError(null);
+ try {
+ const result = await redeemInvite({ ...input, requestId: crypto.randomUUID() });
+ navigate(`/app/workspaces/${result.workspaceId}`, { replace: true });
+ } catch {
+ setError(t("workspaces.inviteUnavailable", "Invitation unavailable."));
+ setBusy(false);
+ }
+ }, [navigate, t]);
+
+ useEffect(() => {
+ if (!token.current || started.current) return;
+ started.current = true;
+ window.history.replaceState(window.history.state, "", "/app/workspaces/invite");
+ void redeem({ token: token.current });
+ }, [redeem]);
+
+ const submit = (event: FormEvent) => {
+ event.preventDefault();
+ if (code.trim()) void redeem({ code });
+ };
+
+ return (
+
+
+
{t("workspaces.joinWorkspace", "Join workspace")}
+
+ {error &&
{error}
}
+
+
+ );
+}
diff --git a/src/pages/WorkspacePage.tsx b/src/pages/WorkspacePage.tsx
new file mode 100644
index 000000000..aa46276ec
--- /dev/null
+++ b/src/pages/WorkspacePage.tsx
@@ -0,0 +1,410 @@
+import { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useParams } from "react-router-dom";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { WorkspaceEditor, type SaveStatus } from "@/components/features/workspace/WorkspaceEditor";
+import { WorkspaceAccessPanel } from "@/components/features/workspace/WorkspaceAccessPanel";
+import { WorkspaceReviewPanel } from "@/components/features/workspace/WorkspaceReviewPanel";
+import { WorkspaceVersions } from "@/components/features/workspace/WorkspaceVersions";
+import { useAuth } from "@/lib/supabase/AuthProvider";
+import {
+ changeWorkspaceState,
+ createWorkspaceDocument,
+ getWorkspaceBundle,
+ updateWorkspaceDocument,
+} from "@/lib/workspace/client";
+import { loadWorkspacePreference, saveWorkspacePreference } from "@/lib/workspace/preferences";
+
+type DocumentView = "active" | "archived" | "trashed";
+
+export default function WorkspacePage() {
+ const { t } = useTranslation();
+ const { session, user } = useAuth();
+ const { workspaceId = "" } = useParams();
+ const queryClient = useQueryClient();
+ const [side, setSide] = useState<"pro" | "con">("pro");
+ const [view, setView] = useState("active");
+ const [mobilePane, setMobilePane] = useState<"files" | "document" | "working">("files");
+ const [selectedDocumentId, setSelectedDocumentId] = useState(null);
+ const [durableSequence, setDurableSequence] = useState(0);
+ const [editorStatus, setEditorStatus] = useState("Saving");
+ const [newTitle, setNewTitle] = useState("");
+ const [renameTitle, setRenameTitle] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [operationError, setOperationError] = useState(null);
+ const [preferenceLoaded, setPreferenceLoaded] = useState(false);
+ const workspace = useQuery({
+ queryKey: ["workspace", user?.id, workspaceId],
+ queryFn: () => getWorkspaceBundle(workspaceId),
+ enabled: Boolean(user && workspaceId),
+ refetchInterval: 5_000,
+ });
+ const documents = useMemo(
+ () => workspace.data?.documents.filter((document) => document.side === side && document.state === view) ?? [],
+ [side, view, workspace.data?.documents],
+ );
+ const selectedDocument = documents.find((document) => document.id === selectedDocumentId) ?? documents[0];
+
+ useEffect(() => {
+ if (user && workspaceId && !preferenceLoaded) {
+ const preference = loadWorkspacePreference(user.id, workspaceId);
+ if (preference) {
+ setSide(preference.side);
+ setSelectedDocumentId(preference.documentId);
+ }
+ setPreferenceLoaded(true);
+ }
+ }, [preferenceLoaded, user, workspaceId]);
+
+ useEffect(() => {
+ if (!preferenceLoaded || workspace.isLoading || !workspace.data) return;
+ if (!selectedDocumentId || !documents.some(({ id }) => id === selectedDocumentId)) {
+ setSelectedDocumentId(documents[0]?.id ?? null);
+ }
+ }, [documents, preferenceLoaded, selectedDocumentId, workspace.data, workspace.isLoading]);
+
+ useEffect(() => {
+ if (preferenceLoaded && user && workspaceId && view === "active") {
+ saveWorkspacePreference(user.id, workspaceId, { documentId: selectedDocument?.id ?? null, side });
+ }
+ }, [preferenceLoaded, selectedDocument?.id, side, user, view, workspaceId]);
+
+ useEffect(() => {
+ setDurableSequence(selectedDocument?.durable_sequence ?? 0);
+ }, [selectedDocument?.durable_sequence]);
+
+ useEffect(() => {
+ setEditorStatus("Saving");
+ }, [selectedDocument?.id]);
+
+ useEffect(() => {
+ setRenameTitle(selectedDocument?.title ?? "");
+ }, [selectedDocument?.id, selectedDocument?.title]);
+
+ const refresh = async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["workspace", user?.id, workspaceId] }),
+ queryClient.invalidateQueries({ queryKey: ["workspaces", user?.id] }),
+ ]);
+ };
+
+ const run = async (operation: () => Promise) => {
+ if (!navigator.onLine || busy) {
+ setOperationError(t("workspaces.onlineRequired", "This action needs a connection."));
+ return;
+ }
+ setBusy(true);
+ setOperationError(null);
+ try {
+ await operation();
+ await refresh();
+ } catch {
+ setOperationError(t("workspaces.updateFailed", "The workspace changed. Reload and try again."));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const reorder = (offset: -1 | 1) => {
+ if (!workspace.data || !selectedDocument) return;
+ const active = workspace.data.documents
+ .filter((document) => document.side === selectedDocument.side && document.state === "active")
+ .sort((left, right) => left.position - right.position);
+ const from = active.findIndex(({ id }) => id === selectedDocument.id);
+ const to = from + offset;
+ if (from < 0 || to < 0 || to >= active.length) return;
+ [active[from], active[to]] = [active[to], active[from]];
+ void run(() => updateWorkspaceDocument({
+ documentId: selectedDocument.id,
+ expectedRevision: workspace.data!.workspace.revision,
+ orderedIds: active.map(({ id }) => id),
+ workspaceId,
+ }));
+ };
+
+ if (workspace.isLoading) {
+ return {t("workspaces.loadingOne", "Opening workspace...")}
;
+ }
+ if (workspace.isError || !workspace.data) {
+ return {t("workspaces.unavailable", "Workspace unavailable.")}
;
+ }
+
+ return (
+
+
+
+
+
{workspace.data.workspace.motion}
+
+ {workspace.data.workspace.title ?? t("workspaces.editorTitle", "Debate workspace")}
+
+
+
+ {(workspace.data.workspace.status !== "deleted" || workspace.data.workspace.owner_id === user?.id) && void run(() => changeWorkspaceState({
+ action: workspace.data!.workspace.status === "active"
+ ? "archive"
+ : workspace.data!.workspace.status === "archived" ? "reopen" : "restore",
+ expectedRevision: workspace.data!.workspace.revision,
+ workspaceId,
+ }))}
+ >
+ {workspace.data.workspace.status === "active"
+ ? t("workspaces.archiveWorkspace", "Archive workspace")
+ : workspace.data.workspace.status === "archived"
+ ? t("workspaces.reopenWorkspace", "Reopen workspace")
+ : t("workspaces.restoreWorkspace", "Restore workspace")}
+ }
+ {workspace.data.workspace.owner_id === user?.id && workspace.data.workspace.status !== "deleted" && (
+ {
+ if (window.confirm(t("workspaces.deleteWorkspaceConfirm", "Delete this workspace? It can be restored for 30 days."))) {
+ void run(() => changeWorkspaceState({ action: "delete", expectedRevision: workspace.data!.workspace.revision, workspaceId }));
+ }
+ }}>{t("workspaces.deleteWorkspace", "Delete workspace")}
+ )}
+
+
+ {operationError &&
{operationError}
}
+
+
+ setSide("pro")}>
+ {t("workspaces.proSide", "A favor")}
+
+ setSide("con")}>
+ {t("workspaces.conSide", "En contra")}
+
+
+
+
+ {(["files", "document", "working"] as const).map((pane) => (
+ setMobilePane(pane)}>
+ {t(`workspaces.${pane}Pane`, pane)}
+
+ ))}
+
+
+
+
+
+ {(["active", "archived", "trashed"] as const).map((state) => (
+ setView(state)}>
+ {t(`workspaces.${state}Files`, state)}
+
+ ))}
+
+
+ {documents.map((document) => (
+
{
+ setSelectedDocumentId(document.id);
+ setMobilePane("document");
+ }}
+ className="motion-press rounded-md px-3 py-2 text-left hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ >
+ {document.title}
+ {document.assignee_id && (
+
+ {t("workspaces.assignedTo", "Assigned to {{member}}", {
+ member: t("workspaces.memberLabel", "Member {{id}}", { id: document.assignee_id.slice(0, 4) }),
+ })}
+
+ )}
+
+ ))}
+ {documents.length === 0 && (
+
{t("workspaces.noFiles", "No files here.")}
+ )}
+
+ {view === "active" && workspace.data.workspace.status === "active" && (
+
+ )}
+
+
+
+
+
{selectedDocument?.title}
+ {selectedDocument?.assignee_id && {t("workspaces.assigned", "Assigned")} }
+
+ {selectedDocument && workspace.data.workspace.status === "active" && (
+
+
+ setRenameTitle(event.target.value)} />
+ void run(() => updateWorkspaceDocument({
+ documentId: selectedDocument.id,
+ expectedRevision: workspace.data!.workspace.revision,
+ title: renameTitle,
+ workspaceId,
+ }))}
+ >
+ {t("workspaces.rename", "Rename")}
+
+
+
+ {view === "active" ? (
+ <>
+ reorder(-1)} disabled={busy}>{t("workspaces.moveUp", "Move up")}
+ reorder(1)} disabled={busy}>{t("workspaces.moveDown", "Move down")}
+ void run(() => createWorkspaceDocument({
+ duplicateDocumentId: selectedDocument.id,
+ expectedRevision: workspace.data!.workspace.revision,
+ side: selectedDocument.side,
+ title: `${selectedDocument.title} ${t("workspaces.copySuffix", "copy")}`,
+ workspaceId,
+ }))}>{t("workspaces.duplicate", "Duplicate")}
+ void run(() => updateWorkspaceDocument({
+ documentId: selectedDocument.id,
+ expectedRevision: workspace.data!.workspace.revision,
+ state: "archived",
+ workspaceId,
+ }))}>{t("workspaces.archiveFile", "Archive")}
+ void run(() => updateWorkspaceDocument({
+ documentId: selectedDocument.id,
+ expectedRevision: workspace.data!.workspace.revision,
+ state: "trashed",
+ workspaceId,
+ }))}>{t("workspaces.trashFile", "Trash")}
+ >
+ ) : (
+ void run(async () => {
+ const orderedIds = workspace.data!.documents
+ .filter((document) => document.side === selectedDocument.side && document.state === "active")
+ .sort((left, right) => left.position - right.position)
+ .map(({ id }) => id)
+ .concat(selectedDocument.id);
+ await updateWorkspaceDocument({
+ documentId: selectedDocument.id,
+ expectedRevision: workspace.data!.workspace.revision,
+ orderedIds,
+ state: "active",
+ workspaceId,
+ });
+ setView("active");
+ setSelectedDocumentId(selectedDocument.id);
+ })}>{t("workspaces.restoreFile", "Restore")}
+ )}
+
+ {view === "active" && (
+
+ {t("workspaces.assignee", "Assignee")}
+ void run(() => updateWorkspaceDocument({
+ assigneeChanged: true,
+ assigneeId: event.target.value || null,
+ documentId: selectedDocument.id,
+ expectedRevision: workspace.data!.workspace.revision,
+ workspaceId,
+ }))}
+ >
+ {t("workspaces.unassigned", "Unassigned")}
+ {workspace.data.members.map((member) => (
+ {t("workspaces.memberLabel", "Member {{id}}", { id: member.user_id.slice(0, 4) })}
+ ))}
+
+
+ )}
+
+ )}
+ {selectedDocument && session && user ? (
+
+ ) : (
+
+ {t("workspaces.unavailable", "Workspace unavailable.")}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/pages/WorkspacesPage.tsx b/src/pages/WorkspacesPage.tsx
new file mode 100644
index 000000000..256de4584
--- /dev/null
+++ b/src/pages/WorkspacesPage.tsx
@@ -0,0 +1,142 @@
+import { useRef, useState, type FormEvent } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+import { Link } from "react-router-dom";
+import { useQuery } from "@tanstack/react-query";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Badge } from "@/components/ui/badge";
+import { supabase } from "@/lib/supabase/client";
+import { listWorkspaces } from "@/lib/workspace/client";
+import { useAuth } from "@/lib/supabase/AuthProvider";
+
+export default function WorkspacesPage() {
+ const { t, i18n } = useTranslation();
+ const { user } = useAuth();
+ const navigate = useNavigate();
+ const [motion, setMotion] = useState("");
+ const [creating, setCreating] = useState(false);
+ const [error, setError] = useState(null);
+ const [view, setView] = useState<"active" | "archived" | "all">("active");
+ const requestId = useRef(null);
+ const workspaces = useQuery({ queryKey: ["workspaces", user?.id], queryFn: listWorkspaces, enabled: Boolean(user) });
+ const visibleWorkspaces = workspaces.data?.filter((workspace) => view === "all" || workspace.status === view);
+
+ const createWorkspace = async (event: FormEvent) => {
+ event.preventDefault();
+ const normalizedMotion = motion.trim();
+ if (!normalizedMotion || creating) return;
+
+ setCreating(true);
+ setError(null);
+ requestId.current ??= crypto.randomUUID();
+ const language = ["en", "es", "hu"].includes(i18n.language)
+ ? i18n.language
+ : "en";
+ const { data, error: createError } = await supabase.rpc("create_workspace", {
+ p_language: language,
+ p_motion: normalizedMotion,
+ p_request_id: requestId.current,
+ p_title: null,
+ });
+
+ if (createError || typeof data !== "string") {
+ setError(t("workspaces.createFailed", "Workspace creation failed. Try again."));
+ setCreating(false);
+ return;
+ }
+
+ requestId.current = null;
+ navigate(`/app/workspaces/${data}`);
+ };
+
+ return (
+
+
+
+ {t("workspaces.title", "Workspaces")}
+
+
+
+ {t("workspaces.new", "New workspace")}
+
+
+
+
+
+
+
+
+ {t("workspaces.yours", "Your workspaces")}
+
+
+ {(["active", "archived", "all"] as const).map((option) => (
+ setView(option)}>
+ {t(`workspaces.${option}`, option)}
+
+ ))}
+
+ {workspaces.isLoading && (
+
+ {t("workspaces.loading", "Loading workspaces...")}
+
+ )}
+ {workspaces.isError && (
+
+ {t("workspaces.loadFailed", "Workspaces could not be loaded.")}
+
+ )}
+ {visibleWorkspaces?.length === 0 && (
+
+ {t("workspaces.empty", "Create your first workspace above.")}
+
+ )}
+
+ {visibleWorkspaces?.map((workspace) => (
+
+
+
+ {workspace.title ?? workspace.motion}
+
+ {workspace.title && (
+
+ {workspace.motion}
+
+ )}
+
+
+ {t(`workspaces.${workspace.status}`, workspace.status)}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/test/auth/AccountQueryCache.test.tsx b/src/test/auth/AccountQueryCache.test.tsx
new file mode 100644
index 000000000..640c3e09b
--- /dev/null
+++ b/src/test/auth/AccountQueryCache.test.tsx
@@ -0,0 +1,29 @@
+import { render, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const auth = vi.hoisted(() => ({ userId: "user-a" as string | null }));
+vi.mock("@/lib/supabase/AuthProvider", () => ({
+ useAuth: () => ({ user: auth.userId ? { id: auth.userId } : null }),
+}));
+
+import { AccountQueryCache } from "@/components/auth/AccountQueryCache";
+
+describe("account query cache", () => {
+ beforeEach(() => { auth.userId = "user-a"; });
+
+ it("removes prior account data when the authenticated identity changes", async () => {
+ const client = new QueryClient();
+ client.setQueryData(["workspace", "user-a", "secret"], { motion: "private" });
+ const view = render(
+ ,
+ );
+
+ auth.userId = "user-b";
+ view.rerender(
+ ,
+ );
+
+ await waitFor(() => expect(client.getQueryCache().getAll()).toHaveLength(0));
+ });
+});
diff --git a/src/test/scripts/db-gate.test.ts b/src/test/scripts/db-gate.test.ts
index 2bffa3f50..527a82c4f 100644
--- a/src/test/scripts/db-gate.test.ts
+++ b/src/test/scripts/db-gate.test.ts
@@ -42,6 +42,6 @@ it("stops local Supabase when database tests fail", async () => {
expect(result.status).toBe(17);
await expect(readFile(log, "utf8")).resolves.toBe(
- "supabase db start\nnpm run test:db\nsupabase stop --no-backup\n",
+ "supabase start\nnpm run test:db\nsupabase stop --no-backup\n",
);
});
diff --git a/src/test/supabase/local-preview-env.test.ts b/src/test/supabase/local-preview-env.test.ts
new file mode 100644
index 000000000..f4e8a887e
--- /dev/null
+++ b/src/test/supabase/local-preview-env.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+import { resolveSupabaseConfig } from "@/lib/supabase/env";
+import { resolveCollaborationUrl } from "@/lib/workspace/collaboration-config";
+
+describe("local preview Supabase boundary", () => {
+ it("fails closed when local preview configuration is incomplete", () => {
+ expect(() => resolveSupabaseConfig(
+ { VITE_LOCAL_PREVIEW: "true" },
+ { getItem: () => "staging" },
+ )).toThrow("Local preview configuration is incomplete");
+ });
+
+ it("ignores the production and staging toggle in local preview mode", () => {
+ expect(resolveSupabaseConfig(
+ {
+ VITE_LOCAL_PREVIEW: "true",
+ VITE_LOCAL_SUPABASE_ANON_KEY: "local-public-key",
+ VITE_LOCAL_SUPABASE_URL: "https://trial.example.ts.net:18443/supabase",
+ },
+ { getItem: () => "staging" },
+ )).toEqual({
+ anonKey: "local-public-key",
+ env: "local",
+ url: "https://trial.example.ts.net:18443/supabase",
+ });
+ });
+
+ it("requires an encrypted collaboration URL in local preview mode", () => {
+ expect(() => resolveCollaborationUrl({ VITE_LOCAL_PREVIEW: "true" }))
+ .toThrow("Local collaboration configuration is incomplete");
+ expect(resolveCollaborationUrl({
+ VITE_COLLABORATION_URL: "wss://trial.example.ts.net:18443/collaboration",
+ VITE_LOCAL_PREVIEW: "true",
+ })).toBe("wss://trial.example.ts.net:18443/collaboration");
+ expect(resolveCollaborationUrl({
+ VITE_COLLABORATION_URL: "ws://127.0.0.1:12345",
+ VITE_LOCAL_PREVIEW: "true",
+ })).toBe("ws://127.0.0.1:12345");
+ expect(() => resolveCollaborationUrl({
+ VITE_COLLABORATION_URL: "ws://trial.example.ts.net:18443/collaboration",
+ VITE_LOCAL_PREVIEW: "true",
+ })).toThrow("Local collaboration URL must use WSS outside loopback");
+ });
+});
diff --git a/src/test/workspace/durable-ack.test.ts b/src/test/workspace/durable-ack.test.ts
new file mode 100644
index 000000000..f51d33933
--- /dev/null
+++ b/src/test/workspace/durable-ack.test.ts
@@ -0,0 +1,27 @@
+import { createEncoder, toUint8Array, writeVarString, writeVarUint } from "lib0/encoding";
+import { writeUpdate } from "y-protocols/sync";
+import { describe, expect, it } from "vitest";
+import { durableMutationFromMessage } from "@/lib/workspace/durable-ack";
+
+describe("workspace durable acknowledgements", () => {
+ it("hashes the exact Yjs payload sent in a live update message", async () => {
+ const encoder = createEncoder();
+ writeVarString(encoder, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa@3");
+ writeVarUint(encoder, 0);
+ writeUpdate(encoder, new Uint8Array([1, 2, 3]));
+
+ await expect(durableMutationFromMessage(toUint8Array(encoder))).resolves.toMatchObject({
+ mutationId: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/),
+ syncType: 2,
+ });
+ });
+
+ it("ignores non-write sync messages", async () => {
+ const encoder = createEncoder();
+ writeVarString(encoder, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa@3");
+ writeVarUint(encoder, 0);
+ writeVarUint(encoder, 0);
+
+ await expect(durableMutationFromMessage(toUint8Array(encoder))).resolves.toBeNull();
+ });
+});
diff --git a/src/test/workspace/invite-handoff.test.ts b/src/test/workspace/invite-handoff.test.ts
new file mode 100644
index 000000000..6b376acf1
--- /dev/null
+++ b/src/test/workspace/invite-handoff.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest";
+import { consumeWorkspaceInvite, stashWorkspaceInvite } from "@/lib/workspace/invite-handoff";
+
+function memoryStorage() {
+ const values = new Map();
+ return {
+ getItem: (key: string) => values.get(key) ?? null,
+ removeItem: (key: string) => values.delete(key),
+ setItem: (key: string, value: string) => values.set(key, value),
+ };
+}
+
+describe("signed-out workspace invitation handoff", () => {
+ it("stores only a valid invite token and consumes it once after authentication", () => {
+ const storage = memoryStorage();
+ const token = `wi_${"a".repeat(32)}`;
+
+ expect(stashWorkspaceInvite({
+ pathname: "/app/workspaces/invite",
+ search: `?token=${token}`,
+ }, storage)).toBe(true);
+ expect(consumeWorkspaceInvite(storage)).toBe(`/app/workspaces/invite?token=${token}`);
+ expect(consumeWorkspaceInvite(storage)).toBeNull();
+ });
+
+ it("does not retain unrelated or malformed URLs", () => {
+ const storage = memoryStorage();
+ expect(stashWorkspaceInvite({ pathname: "/app", search: "?token=secret" }, storage)).toBe(false);
+ expect(stashWorkspaceInvite({ pathname: "/app/workspaces/invite", search: "?token=secret" }, storage)).toBe(false);
+ expect(consumeWorkspaceInvite(storage)).toBeNull();
+ });
+});
diff --git a/src/test/workspace/member-removal.test.ts b/src/test/workspace/member-removal.test.ts
new file mode 100644
index 000000000..7e4bf5d75
--- /dev/null
+++ b/src/test/workspace/member-removal.test.ts
@@ -0,0 +1,30 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/workspace/collaboration-config", () => ({
+ COLLABORATION_URL: "ws://127.0.0.1:12345",
+}));
+
+import { removeWorkspaceMember } from "@/lib/workspace/client";
+
+describe("workspace member removal", () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("reuses one operation ID when the committed response is lost", async () => {
+ const fetchMock = vi.fn()
+ .mockRejectedValueOnce(new TypeError("connection closed"))
+ .mockResolvedValueOnce(new Response(JSON.stringify({ revision: 4 }), { status: 200 }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await expect(removeWorkspaceMember({
+ expectedRevision: 3,
+ targetUserId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
+ token: "signed-token",
+ workspaceId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ })).resolves.toBe(4);
+
+ const bodies = fetchMock.mock.calls.map(([, init]) => JSON.parse(String(init?.body)));
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(bodies[0].requestId).toMatch(/^[0-9a-f-]{36}$/);
+ expect(bodies[1]).toEqual(bodies[0]);
+ });
+});
diff --git a/src/test/workspace/navigation.test.tsx b/src/test/workspace/navigation.test.tsx
new file mode 100644
index 000000000..8b90d7f96
--- /dev/null
+++ b/src/test/workspace/navigation.test.tsx
@@ -0,0 +1,201 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { routeLoaders } from "@/lib/route-modules";
+import WorkspacesPage from "@/pages/WorkspacesPage";
+import WorkspacePage from "@/pages/WorkspacePage";
+
+const { mockFrom, mockRpc } = vi.hoisted(() => ({
+ mockFrom: vi.fn(),
+ mockRpc: vi.fn(),
+}));
+
+vi.mock("@/lib/supabase/client", () => ({
+ supabase: { from: mockFrom, rpc: mockRpc },
+}));
+
+vi.mock("@/lib/supabase/AuthProvider", () => ({
+ useAuth: () => ({
+ session: { access_token: "signed-token" },
+ user: { id: "11111111-1111-4111-8111-111111111111" },
+ }),
+}));
+
+vi.mock("@/components/features/workspace/WorkspaceEditor", () => ({
+ WorkspaceEditor: ({ editable }: { editable: boolean }) => (
+
+ ),
+}));
+
+function LocationProbe() {
+ const location = useLocation();
+ return {location.pathname} ;
+}
+
+function renderWorkspaces(children = ) {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+ {children}
+ ,
+ );
+}
+
+describe("workspace navigation", () => {
+ beforeEach(() => {
+ mockRpc.mockReset();
+ mockFrom.mockReset();
+ });
+
+ it("lazy loads the workspace index and editor routes", () => {
+ expect(Object.keys(routeLoaders)).toEqual(expect.arrayContaining([
+ "/app/workspaces",
+ "/app/workspaces/:workspaceId",
+ ]));
+ });
+
+ it("collects the motion once and exposes workspace creation", () => {
+ renderWorkspaces();
+
+ expect(screen.getByRole("textbox", { name: "Motion" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Create workspace" })).toBeInTheDocument();
+ });
+
+ it("creates a workspace and opens its Intro immediately", async () => {
+ mockRpc.mockResolvedValue({ data: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", error: null });
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render(
+
+
+
+ } />
+ } />
+
+
+ ,
+ );
+
+ fireEvent.change(screen.getByRole("textbox", { name: "Motion" }), {
+ target: { value: "Schools should ban phones" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Create workspace" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("status", { name: "Current path" })).toHaveTextContent(
+ "/app/workspaces/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ );
+ });
+ });
+
+ it("lists two memberships and reopens an archived workspace", async () => {
+ const order = vi.fn().mockResolvedValue({
+ data: [
+ {
+ id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
+ motion: "AI tutors should grade homework",
+ status: "active",
+ title: null,
+ updated_at: "2026-09-21T08:00:00.000Z",
+ },
+ {
+ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ motion: "Schools should ban phones",
+ status: "archived",
+ title: null,
+ updated_at: "2026-09-20T08:00:00.000Z",
+ },
+ ],
+ error: null,
+ });
+ mockFrom.mockReturnValue({ select: () => ({ order }) });
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+ render(
+
+
+
+ } />
+ } />
+
+
+ ,
+ );
+
+ expect(await screen.findByRole("link", { name: /AI tutors should grade homework/ })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "All" }));
+ expect(screen.getByRole("link", { name: /Schools should ban phones/ })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("link", { name: /Schools should ban phones/ }));
+
+ expect(screen.getByRole("status", { name: "Current path" })).toHaveTextContent(
+ "/app/workspaces/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ );
+ });
+
+ it("shows both sides and keeps an assigned file editable by every member", async () => {
+ mockFrom.mockImplementation((table: string) => ({
+ select: () => ({
+ eq: () => table === "workspaces"
+ ? { single: async () => ({
+ data: {
+ access_epoch: 0,
+ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ motion: "Schools should ban phones",
+ owner_id: "11111111-1111-4111-8111-111111111111",
+ pending_owner_id: null as string | null,
+ revision: 0,
+ status: "active",
+ title: null as string | null,
+ },
+ error: null as Error | null,
+ }) }
+ : Promise.resolve({
+ data: table === "workspace_members"
+ ? [
+ { joined_at: "2026-09-21T08:00:00.000Z", user_id: "11111111-1111-4111-8111-111111111111" },
+ { joined_at: "2026-09-21T08:01:00.000Z", user_id: "22222222-2222-4222-8222-222222222222" },
+ ]
+ : [
+ {
+ assignee_id: "22222222-2222-4222-8222-222222222222",
+ document_epoch: 0,
+ durable_sequence: 0,
+ id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
+ position: 1,
+ side: "pro",
+ state: "active",
+ title: "R1",
+ },
+ {
+ assignee_id: null,
+ document_epoch: 0,
+ durable_sequence: 0,
+ id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
+ position: 1,
+ side: "con",
+ state: "active",
+ title: "R1",
+ },
+ ],
+ error: null,
+ }),
+ }),
+ }));
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+ render(
+
+
+
+ } />
+
+
+ ,
+ );
+
+ expect(await screen.findByRole("button", { name: "For" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Against" })).toBeInTheDocument();
+ expect(screen.getByText("Assigned to Member 2222")).toBeInTheDocument();
+ expect(screen.getByRole("textbox", { name: "Document editor" })).toHaveAttribute("contenteditable", "true");
+ });
+});
diff --git a/src/test/workspace/review-panel.test.tsx b/src/test/workspace/review-panel.test.tsx
new file mode 100644
index 000000000..84724d38c
--- /dev/null
+++ b/src/test/workspace/review-panel.test.tsx
@@ -0,0 +1,67 @@
+import { render, screen } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const api = vi.hoisted(() => ({
+ listComments: vi.fn(),
+ listNotifications: vi.fn(),
+ listProposals: vi.fn(),
+}));
+vi.mock("@/lib/workspace/client", () => ({
+ decideWorkspaceProposal: vi.fn(),
+ listWorkspaceComments: api.listComments,
+ listWorkspaceNotifications: api.listNotifications,
+ listWorkspaceProposals: api.listProposals,
+ replyWorkspaceComment: vi.fn(),
+ resolveWorkspaceComment: vi.fn(),
+}));
+vi.mock("@/hooks/use-online-status", () => ({ useOnlineStatus: () => true }));
+
+import { WorkspaceReviewPanel } from "@/components/features/workspace/WorkspaceReviewPanel";
+
+describe("workspace review panel", () => {
+ beforeEach(() => {
+ api.listComments.mockResolvedValue([]);
+ api.listNotifications.mockResolvedValue([]);
+ api.listProposals.mockResolvedValue([{
+ id: "proposal",
+ hunks: [{
+ id: "hunk",
+ kind: "format",
+ absoluteFrom: 7,
+ absoluteTo: 16,
+ contextBefore: [{ type: "text", text: "Before " }],
+ contextAfter: [{ type: "text", text: " after" }],
+ original: [{ type: "text", text: "Old claim" }],
+ replacement: [{ type: "text", text: "New claim", marks: [{ type: "bold" }] }],
+ status: "pending",
+ }],
+ }]);
+ });
+
+ it("shows a labelled non-color diff and disables decisions when read-only", async () => {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render(
+
+
+ ,
+ );
+
+ expect(await screen.findByText("Old claim")).toBeInTheDocument();
+ expect(screen.getByText("New claim [bold]")).toBeInTheDocument();
+ expect(screen.getByText("Original")).toBeInTheDocument();
+ expect(screen.getByText("Proposed")).toBeInTheDocument();
+ expect(screen.getByText("Characters 7–16")).toBeInTheDocument();
+ expect(screen.getByText((_, element) => element?.textContent === "Before Old claim after")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Accept" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Reject" })).toBeDisabled();
+ });
+});
diff --git a/src/test/workspace/review.test.ts b/src/test/workspace/review.test.ts
index a94b07e98..1b9461e0e 100644
--- a/src/test/workspace/review.test.ts
+++ b/src/test/workspace/review.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { yUndoPluginKey } from "@tiptap/y-tiptap";
import * as Y from "yjs";
import { createWorkspaceEditor } from "@/lib/workspace/editor-schema";
@@ -111,6 +111,58 @@ describe("workspace review model", () => {
]);
});
+ it("intercepts paste and emits only settled native composition without changing accepted text", async () => {
+ const document = new Y.Doc();
+ const onSuggestion = vi.fn();
+ let suggesting = false;
+ const editor = createWorkspaceEditor(document, {
+ isSuggesting: () => suggesting,
+ onSuggestion,
+ });
+ cleanup.push(() => editor.destroy(), () => document.destroy());
+ editor.commands.setContent("Alpha
");
+ const accepted = editor.getJSON();
+ suggesting = true;
+
+ editor.view.dispatch(editor.state.tr.insertText(" pasted", 6).setMeta("uiEvent", "paste"));
+ await vi.waitFor(() => expect(onSuggestion).toHaveBeenCalledTimes(1));
+ editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart"));
+ editor.view.dom.dispatchEvent(new InputEvent("beforeinput", { bubbles: true, cancelable: true, data: " t", inputType: "insertCompositionText" }));
+ editor.view.dom.dispatchEvent(new InputEvent("beforeinput", { bubbles: true, cancelable: true, data: " tűrő", inputType: "insertCompositionText" }));
+ expect(onSuggestion).toHaveBeenCalledTimes(1);
+ editor.view.dom.dispatchEvent(new CompositionEvent("compositionend"));
+
+ await vi.waitFor(() => expect(onSuggestion).toHaveBeenCalledTimes(2));
+ expect(editor.getJSON()).toEqual(accepted);
+ expect(onSuggestion.mock.calls.map(([hunk]) => hunk.kind)).toEqual(["insert", "insert"]);
+ expect(onSuggestion.mock.calls[1][0].content).toEqual([{ type: "text", text: " tűrő" }]);
+
+ editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart"));
+ editor.view.dom.dispatchEvent(new CompositionEvent("compositionend", { data: " vég" }));
+ await vi.waitFor(() => expect(onSuggestion).toHaveBeenCalledTimes(3));
+ expect(onSuggestion.mock.calls[2][0].content).toEqual([{ type: "text", text: " vég" }]);
+
+ editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart"));
+ editor.view.dom.dispatchEvent(new CompositionEvent("compositionupdate", { data: " ősz" }));
+ editor.view.dom.dispatchEvent(new CompositionEvent("compositionend"));
+ await vi.waitFor(() => expect(onSuggestion).toHaveBeenCalledTimes(4));
+ expect(onSuggestion.mock.calls[3][0].content).toEqual([{ type: "text", text: " ősz" }]);
+
+ editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart"));
+ editor.view.dom.dispatchEvent(new CompositionEvent("compositionupdate", { data: " cancelled" }));
+ editor.view.dom.dispatchEvent(new CompositionEvent("compositionupdate", { data: "" }));
+ editor.view.dom.dispatchEvent(new CompositionEvent("compositionend"));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(onSuggestion).toHaveBeenCalledTimes(4);
+
+ suggesting = false;
+ editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart"));
+ editor.view.dom.dispatchEvent(new CompositionEvent("compositionupdate", { data: " committed" }));
+ editor.view.dom.dispatchEvent(new CompositionEvent("compositionend", { data: " committed" }));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(onSuggestion).toHaveBeenCalledTimes(4);
+ });
+
it("accepts one independent hunk and keeps teammate text outside local undo", async () => {
expect(review.acceptProposalHunks).toBeTypeOf("function");
expect(review.rejectProposalHunks).toBeTypeOf("function");
@@ -137,6 +189,7 @@ describe("workspace review model", () => {
{ id: "delete-gamma", kind: "delete", from: 12, to: 17 },
],
});
+ proposal.hunks[0].original = [{ text: "Beta", type: "text" }];
yUndoPluginKey.getState(left.state).undoManager.clear();
yUndoPluginKey.getState(right.state).undoManager.clear();
@@ -183,6 +236,29 @@ describe("workspace review model", () => {
expect(JSON.stringify(left.getJSON())).toContain('"type":"bold"');
});
+ it("accepts independent hunks sequentially after the first shifts the document", () => {
+ const document = new Y.Doc();
+ const editor = createEditor(document);
+ editor.commands.setContent("Alpha Beta
");
+ let proposal = review.createProposal({
+ id: "proposal-sequential",
+ authorId: "member-1",
+ documentEpoch: 1,
+ editor,
+ hunks: [
+ { id: "prefix", kind: "insert", at: 1, content: [{ type: "text", text: "Remote " }] },
+ { id: "beta", kind: "format", from: 7, to: 11, content: [{ type: "text", text: "Beta", marks: [{ type: "bold" }] }] },
+ ],
+ });
+
+ proposal = review.acceptProposalHunkInDocument({ document, documentEpoch: 1, hunkId: "prefix", proposal });
+ proposal = review.acceptProposalHunkInDocument({ document, documentEpoch: 1, hunkId: "beta", proposal });
+
+ expect(proposal.hunks.map(({ status }) => status)).toEqual(["accepted", "accepted"]);
+ expect(editor.getText()).toBe("Remote Alpha Beta");
+ expect(JSON.stringify(editor.getJSON())).toContain('"type":"bold"');
+ });
+
it("stales a remotely deleted and reinserted identical target", () => {
const { left, right, rightDocument } = createSyncedEditors();
left.commands.setContent("Alpha Beta Gamma
");
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index 11f02fe2a..728f0c534 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -1 +1,8 @@
///
+
+interface ImportMetaEnv {
+ readonly VITE_LOCAL_PREVIEW?: string;
+ readonly VITE_LOCAL_SUPABASE_ANON_KEY?: string;
+ readonly VITE_LOCAL_SUPABASE_URL?: string;
+ readonly VITE_COLLABORATION_URL?: string;
+}
diff --git a/supabase/README.md b/supabase/README.md
index e8ec17265..d4e44e665 100644
--- a/supabase/README.md
+++ b/supabase/README.md
@@ -138,7 +138,7 @@ Document names, never values. Depending on deployed features, server-side config
- Stripe secret, webhook secret, and price configuration;
- demo-code hashing material;
- `WORKSPACE_INVITE_SECRET` for invite derivation, keyed hashes, and target-email encryption;
-- `WORKSPACE_INVITE_MAILER_URL`, `WORKSPACE_INVITE_MAILER_TOKEN`, and `WORKSPACE_INVITE_BASE_URL` for the operator-supplied email relay;
+- `WORKSPACE_INVITE_MAILER_URL`, `WORKSPACE_INVITE_MAILER_TOKEN`, and `WORKSPACE_INVITE_BASE_URL` for the operator-supplied email relay, plus `WORKSPACE_INVITE_ALLOWED_ORIGINS` for an exact preview origin;
- sparring integrity material;
- Google export configuration;
- allowed origins and environment-specific application URLs.
@@ -147,7 +147,7 @@ Use `supabase secrets` or protected deployment configuration for server values.
Jev uses the same `OPENROUTER_API_KEY` as every other model request; there is no separate TypeSafe credential. `npm run smoke:jev` exercises one fixed, sanitized typed decision against the live Decisions endpoint and incurs a negligible provider charge.
-The invitation handler does not select an email provider. Its mail boundary sends `{ to, inviteUrl, idempotencyKey }` to the configured relay. The relay must deduplicate repeated acceptance of the same key, which stays stable across pending-delivery retries. The handler marks delivery only after relay acceptance and suppresses later sends only after that mark commits. Email creation fails before storing an invite when the three mailer values are absent. Local tests inject an in-memory sender and make no external mail request.
+The invitation handler does not select a production email provider. Its default mail boundary sends `{ to, inviteUrl, idempotencyKey }` to the configured relay. The relay must deduplicate repeated acceptance of the same key, which stays stable across pending-delivery retries. The handler marks delivery only after relay acceptance and suppresses later sends only after that mark commits. Email creation fails before storing an invite when the three required mailer values are absent. The locked local gate alone sets `WORKSPACE_INVITE_MAILER_FORMAT=mailpit` and captures messages in local Supabase Mailpit; it makes no external mail request.
Public browser configuration in [`../src/lib/supabase/env.ts`](../src/lib/supabase/env.ts) is not a replacement for server authorization.
diff --git a/supabase/config.toml b/supabase/config.toml
index d21b5bbaf..9314aec59 100644
--- a/supabase/config.toml
+++ b/supabase/config.toml
@@ -45,3 +45,11 @@ verify_jwt = false
# there is no JWT yet to verify at the gateway.
[functions.demo-code-login]
verify_jwt = false
+
+[auth.email]
+otp_length = 6
+max_frequency = "1s"
+
+[auth.email.template.magic_link]
+subject = "Your DebateLab sign-in code"
+content_path = "./supabase/templates/magic_link.html"
diff --git a/supabase/functions/_shared/auth.ts b/supabase/functions/_shared/auth.ts
index 4ccee510d..5f06fe186 100644
--- a/supabase/functions/_shared/auth.ts
+++ b/supabase/functions/_shared/auth.ts
@@ -54,9 +54,11 @@ export function getBearerToken(req: Request) {
return { authHeader, token };
}
-export async function requireUser(req: Request): Promise {
+export async function requireUser(req: Request, adminKey = supabaseServiceRoleKey): Promise {
const { authHeader, token } = getBearerToken(req);
- const adminClient = createAdminClient();
+ const adminClient = createClient(supabaseUrl, adminKey, {
+ auth: { persistSession: false, autoRefreshToken: false },
+ });
const { data: { user }, error } = await adminClient.auth.getUser(token);
if (error || !user) {
diff --git a/supabase/functions/workspace-invites/index.ts b/supabase/functions/workspace-invites/index.ts
index 146f42881..09f9c2b6f 100644
--- a/supabase/functions/workspace-invites/index.ts
+++ b/supabase/functions/workspace-invites/index.ts
@@ -24,8 +24,19 @@ type Dependencies = {
};
function corsHeaders(origin: string | null) {
+ const configured = (Deno.env.get("WORKSPACE_INVITE_ALLOWED_ORIGINS") ?? "")
+ .split(",")
+ .map((value) => value.trim())
+ .filter((value) => {
+ try {
+ return new URL(value).origin === value;
+ } catch {
+ return false;
+ }
+ });
+ const allowed = [...ALLOWED_ORIGINS, ...configured];
return {
- "Access-Control-Allow-Origin": origin && ALLOWED_ORIGINS.includes(origin)
+ "Access-Control-Allow-Origin": origin && allowed.includes(origin)
? origin
: ALLOWED_ORIGINS[0],
"Access-Control-Allow-Headers":
@@ -204,6 +215,7 @@ function configuredSender(): { sender: InviteSender; baseUrl: string } {
const endpoint = Deno.env.get("WORKSPACE_INVITE_MAILER_URL");
const token = Deno.env.get("WORKSPACE_INVITE_MAILER_TOKEN");
const baseUrl = Deno.env.get("WORKSPACE_INVITE_BASE_URL");
+ const format = Deno.env.get("WORKSPACE_INVITE_MAILER_FORMAT");
if (!endpoint || !token || !baseUrl) {
throw new HttpError(503, "Invitation email is unavailable");
}
@@ -213,11 +225,16 @@ function configuredSender(): { sender: InviteSender; baseUrl: string } {
sender: async (message) => {
const response = await fetch(endpoint, {
method: "POST",
- headers: {
- Authorization: `Bearer ${token}`,
- "Content-Type": "application/json",
- },
- body: JSON.stringify(message),
+ headers: format === "mailpit"
+ ? { "Content-Type": "application/json" }
+ : { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
+ body: JSON.stringify(format === "mailpit" ? {
+ From: { Email: "workspace@debatelab.local", Name: "DebateLab" },
+ To: [{ Email: message.to }],
+ Subject: "Join your DebateLab workspace",
+ Text: `Join your workspace: ${message.inviteUrl}`,
+ Headers: { "X-Idempotency-Key": message.idempotencyKey },
+ } : message),
});
if (!response.ok) throw new Error("Invitation email delivery failed");
},
@@ -232,7 +249,10 @@ export async function handleRequest(req: Request, dependencies?: Dependencies) {
}
try {
- const auth = await requireUser(req);
+ const auth = await requireUser(
+ req,
+ Deno.env.get("WORKSPACE_INVITE_SERVICE_ROLE_KEY") || undefined,
+ );
const parsedBody: unknown = await req.json().catch(() => null);
if (!isRecord(parsedBody) || typeof parsedBody.action !== "string") {
throw new HttpError(400, "Invalid invitation request");
@@ -346,7 +366,11 @@ export async function handleRequest(req: Request, dependencies?: Dependencies) {
}`,
idempotencyKey: `workspace-invite:${result.invite_id}`,
});
- } catch {
+ } catch (error) {
+ const message = error instanceof Error
+ ? error.message.replace(/WS-[A-Z2-7-]+|wi_[A-Za-z0-9_-]+/g, "[redacted]")
+ : "unknown delivery error";
+ console.error(`[workspace-invites] delivery failed: ${message}`);
throw new HttpError(503, "Invitation email is unavailable");
}
@@ -413,6 +437,10 @@ export async function handleRequest(req: Request, dependencies?: Dependencies) {
if (error instanceof HttpError) {
return json({ error: error.message }, error.status, cors);
}
+ const diagnostic = error instanceof Error
+ ? error.message.replace(/WS-[A-Z2-7-]+|wi_[A-Za-z0-9_-]+/g, "[redacted]")
+ : "unknown error";
+ console.error(`[workspace-invites] ${diagnostic}`);
return json(
{ error: "Invitations are temporarily unavailable" },
500,
diff --git a/supabase/migrations/20260921082537_workspace_collaboration_release.sql b/supabase/migrations/20260921082537_workspace_collaboration_release.sql
new file mode 100644
index 000000000..9ca16ff5e
--- /dev/null
+++ b/supabase/migrations/20260921082537_workspace_collaboration_release.sql
@@ -0,0 +1,808 @@
+ALTER TABLE public.workspace_documents
+ ADD COLUMN document_epoch bigint NOT NULL DEFAULT 0 CHECK (document_epoch >= 0),
+ ADD COLUMN durable_sequence bigint NOT NULL DEFAULT 0 CHECK (durable_sequence >= 0);
+
+-- PostgREST 16 supplies modern JWT claims through auth.role(). Keep invite
+-- service RPCs compatible with that path as well as pgTAP's legacy role GUC.
+CREATE OR REPLACE FUNCTION private.require_workspace_invite_service_role()
+RETURNS void
+LANGUAGE plpgsql
+STABLE
+SET search_path = ''
+AS $$
+BEGIN
+ IF coalesce(auth.role(), '') <> 'service_role' THEN
+ RAISE EXCEPTION USING ERRCODE = '42501', MESSAGE = 'service role required';
+ END IF;
+END;
+$$;
+REVOKE ALL ON FUNCTION private.require_workspace_invite_service_role()
+ FROM PUBLIC, anon, authenticated, service_role;
+
+CREATE TABLE private.workspace_document_state (
+ workspace_id uuid NOT NULL,
+ document_id uuid PRIMARY KEY,
+ epoch bigint NOT NULL DEFAULT 0 CHECK (epoch >= 0),
+ durable_sequence bigint NOT NULL DEFAULT 0 CHECK (durable_sequence >= 0),
+ snapshot bytea NOT NULL DEFAULT '\x'::bytea CHECK (octet_length(snapshot) <= 2097152),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT workspace_document_state_document_fkey
+ FOREIGN KEY (workspace_id, document_id)
+ REFERENCES public.workspace_documents(workspace_id, id)
+ ON DELETE CASCADE
+);
+
+CREATE TABLE private.workspace_document_updates (
+ workspace_id uuid NOT NULL,
+ document_id uuid NOT NULL,
+ epoch bigint NOT NULL CHECK (epoch >= 0),
+ sequence bigint NOT NULL CHECK (sequence > 0),
+ mutation_id text NOT NULL CHECK (mutation_id ~ '^[A-Za-z0-9_-]{43}$'),
+ author_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
+ sync_type smallint NOT NULL CHECK (sync_type IN (1, 2)),
+ bytes bytea NOT NULL CHECK (octet_length(bytes) BETWEEN 1 AND 2097152),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (document_id, epoch, sequence),
+ UNIQUE (document_id, epoch, mutation_id),
+ CONSTRAINT workspace_document_updates_document_fkey
+ FOREIGN KEY (workspace_id, document_id)
+ REFERENCES public.workspace_documents(workspace_id, id)
+ ON DELETE CASCADE
+);
+
+CREATE INDEX workspace_document_updates_workspace_created_idx
+ ON private.workspace_document_updates(workspace_id, created_at DESC);
+
+ALTER TABLE private.workspace_document_state ENABLE ROW LEVEL SECURITY;
+ALTER TABLE private.workspace_document_updates ENABLE ROW LEVEL SECURITY;
+REVOKE ALL ON TABLE
+ private.workspace_document_state,
+ private.workspace_document_updates
+FROM PUBLIC, anon, authenticated, service_role;
+
+CREATE FUNCTION private.initialize_workspace_document_state()
+RETURNS trigger
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+BEGIN
+ INSERT INTO private.workspace_document_state (workspace_id, document_id, epoch)
+ VALUES (NEW.workspace_id, NEW.id, NEW.document_epoch)
+ ON CONFLICT (document_id) DO NOTHING;
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER workspace_documents_initialize_state_after_insert
+AFTER INSERT ON public.workspace_documents
+FOR EACH ROW EXECUTE FUNCTION private.initialize_workspace_document_state();
+
+REVOKE ALL ON FUNCTION private.initialize_workspace_document_state()
+ FROM PUBLIC, anon, authenticated, service_role;
+
+INSERT INTO private.workspace_document_state (workspace_id, document_id, epoch)
+SELECT workspace_id, id, document_epoch
+FROM public.workspace_documents
+ON CONFLICT (document_id) DO NOTHING;
+
+CREATE FUNCTION private.notify_workspace_access_revoked()
+RETURNS trigger
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+BEGIN
+ PERFORM pg_catalog.pg_notify('workspace_access_revoked', OLD.workspace_id::text);
+ RETURN OLD;
+END;
+$$;
+
+CREATE TRIGGER workspace_members_notify_access_revoked_after_delete
+AFTER DELETE ON public.workspace_members
+FOR EACH ROW EXECUTE FUNCTION private.notify_workspace_access_revoked();
+
+REVOKE ALL ON FUNCTION private.notify_workspace_access_revoked()
+ FROM PUBLIC, anon, authenticated, service_role;
+
+CREATE FUNCTION public.create_workspace_document(
+ p_workspace_id uuid,
+ p_title text,
+ p_side text,
+ p_duplicate_document_id uuid,
+ p_expected_revision bigint,
+ p_request_id uuid
+)
+RETURNS TABLE (document_id uuid, revision bigint)
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ v_user_id uuid := auth.uid();
+ v_title text := btrim(p_title);
+ v_fingerprint bytea;
+ v_existing public.workspace_audit_events%ROWTYPE;
+ v_workspace public.workspaces%ROWTYPE;
+ v_source public.workspace_documents%ROWTYPE;
+ v_document_id uuid;
+ v_new_revision bigint;
+ v_position integer;
+BEGIN
+ IF v_user_id IS NULL OR p_request_id IS NULL OR p_expected_revision IS NULL THEN
+ RAISE EXCEPTION 'authentication required';
+ END IF;
+
+ v_fingerprint := private.workspace_request_fingerprint(jsonb_build_object(
+ 'rpc', 'create_workspace_document',
+ 'actor', v_user_id,
+ 'workspace', p_workspace_id,
+ 'title', v_title,
+ 'side', p_side,
+ 'duplicate_document', p_duplicate_document_id,
+ 'expected_revision', p_expected_revision
+ ));
+
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_request_id::text, 0));
+ SELECT * INTO v_existing
+ FROM public.workspace_audit_events
+ WHERE operation_id = p_request_id;
+
+ IF FOUND THEN
+ IF v_existing.request_fingerprint IS DISTINCT FROM v_fingerprint
+ OR v_existing.document_id IS NULL THEN
+ RAISE EXCEPTION 'operation ID conflict';
+ END IF;
+ RETURN QUERY SELECT v_existing.document_id, v_existing.after_revision;
+ RETURN;
+ END IF;
+
+ IF p_workspace_id IS NULL OR p_expected_revision < 0
+ OR v_title IS NULL OR length(v_title) NOT BETWEEN 1 AND 200
+ OR p_side NOT IN ('pro', 'con') THEN
+ RAISE EXCEPTION 'invalid document request';
+ END IF;
+
+ SELECT w.* INTO v_workspace
+ FROM public.workspaces AS w
+ WHERE w.id = p_workspace_id
+ AND EXISTS (
+ SELECT 1 FROM public.workspace_members AS m
+ WHERE m.workspace_id = w.id AND m.user_id = v_user_id
+ )
+ FOR UPDATE;
+
+ IF NOT FOUND OR v_workspace.status <> 'active' THEN
+ RAISE EXCEPTION 'workspace unavailable';
+ END IF;
+ IF v_workspace.revision <> p_expected_revision THEN
+ RAISE EXCEPTION 'workspace conflict';
+ END IF;
+
+ IF p_duplicate_document_id IS NOT NULL THEN
+ SELECT d.* INTO v_source
+ FROM public.workspace_documents AS d
+ WHERE d.workspace_id = p_workspace_id
+ AND d.id = p_duplicate_document_id
+ AND d.state <> 'trashed'
+ FOR UPDATE;
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'workspace unavailable';
+ END IF;
+ END IF;
+
+ SELECT coalesce(max(position), -1) + 1 INTO v_position
+ FROM public.workspace_documents
+ WHERE workspace_id = p_workspace_id AND side = p_side AND state = 'active';
+
+ INSERT INTO public.workspace_documents (
+ workspace_id, created_by, side, title, position, assignee_id
+ ) VALUES (
+ p_workspace_id, v_user_id, p_side, v_title, v_position, NULL
+ )
+ RETURNING id INTO v_document_id;
+
+ IF p_duplicate_document_id IS NOT NULL THEN
+ UPDATE private.workspace_document_state AS destination
+ SET snapshot = source.snapshot,
+ updated_at = now()
+ FROM private.workspace_document_state AS source
+ WHERE destination.document_id = v_document_id
+ AND source.document_id = p_duplicate_document_id;
+ END IF;
+
+ v_new_revision := v_workspace.revision + 1;
+ UPDATE public.workspaces
+ SET revision = v_new_revision, updated_at = now()
+ WHERE id = p_workspace_id;
+
+ INSERT INTO public.workspace_audit_events (
+ operation_id, request_fingerprint, workspace_id, document_id, actor_id,
+ action, before_revision, after_revision
+ ) VALUES (
+ p_request_id, v_fingerprint, p_workspace_id, v_document_id, v_user_id,
+ CASE WHEN p_duplicate_document_id IS NULL THEN 'document_create' ELSE 'document_duplicate' END,
+ v_workspace.revision, v_new_revision
+ );
+
+ RETURN QUERY SELECT v_document_id, v_new_revision;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.create_workspace_document(uuid, text, text, uuid, bigint, uuid)
+ FROM PUBLIC, anon;
+GRANT EXECUTE ON FUNCTION public.create_workspace_document(uuid, text, text, uuid, bigint, uuid)
+ TO authenticated;
+
+CREATE TABLE public.workspace_versions (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ workspace_id uuid NOT NULL,
+ document_id uuid NOT NULL,
+ created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
+ name text CHECK (name IS NULL OR length(btrim(name)) BETWEEN 1 AND 200),
+ kind text NOT NULL CHECK (kind IN ('automatic', 'named', 'pre_restore')),
+ document_epoch bigint NOT NULL CHECK (document_epoch >= 0),
+ durable_sequence bigint NOT NULL CHECK (durable_sequence >= 0),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT workspace_versions_document_fkey
+ FOREIGN KEY (workspace_id, document_id)
+ REFERENCES public.workspace_documents(workspace_id, id)
+ ON DELETE CASCADE
+);
+
+CREATE TABLE private.workspace_version_data (
+ version_id uuid PRIMARY KEY REFERENCES public.workspace_versions(id) ON DELETE CASCADE,
+ snapshot bytea NOT NULL CHECK (octet_length(snapshot) <= 2097152)
+);
+
+CREATE INDEX workspace_versions_document_created_idx
+ ON public.workspace_versions(workspace_id, document_id, created_at DESC);
+ALTER TABLE public.workspace_versions ENABLE ROW LEVEL SECURITY;
+ALTER TABLE private.workspace_version_data ENABLE ROW LEVEL SECURITY;
+CREATE POLICY workspace_versions_select_member ON public.workspace_versions
+ FOR SELECT TO authenticated
+ USING ((SELECT private.is_current_workspace_member(workspace_id)));
+REVOKE ALL ON TABLE public.workspace_versions FROM PUBLIC, anon, authenticated;
+GRANT SELECT ON TABLE public.workspace_versions TO authenticated;
+REVOKE ALL ON TABLE private.workspace_version_data FROM PUBLIC, anon, authenticated, service_role;
+
+ALTER TABLE public.workspace_audit_events
+ ADD COLUMN version_id uuid REFERENCES public.workspace_versions(id) ON DELETE SET NULL,
+ ADD COLUMN payload jsonb;
+
+CREATE FUNCTION public.create_workspace_version(
+ p_workspace_id uuid,
+ p_document_id uuid,
+ p_name text,
+ p_expected_sequence bigint,
+ p_request_id uuid
+)
+RETURNS uuid
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ v_user_id uuid := auth.uid();
+ v_name text := nullif(btrim(p_name), '');
+ v_fingerprint bytea;
+ v_existing public.workspace_audit_events%ROWTYPE;
+ v_state private.workspace_document_state%ROWTYPE;
+ v_version_id uuid;
+BEGIN
+ IF v_user_id IS NULL OR p_request_id IS NULL OR p_expected_sequence IS NULL THEN
+ RAISE EXCEPTION 'authentication required';
+ END IF;
+ IF p_workspace_id IS NULL OR p_document_id IS NULL OR p_expected_sequence < 0
+ OR (v_name IS NOT NULL AND length(v_name) > 200) THEN
+ RAISE EXCEPTION 'invalid version request';
+ END IF;
+
+ v_fingerprint := private.workspace_request_fingerprint(jsonb_build_object(
+ 'rpc', 'create_workspace_version',
+ 'actor', v_user_id,
+ 'workspace', p_workspace_id,
+ 'document', p_document_id,
+ 'name', v_name,
+ 'expected_sequence', p_expected_sequence
+ ));
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_request_id::text, 0));
+ SELECT * INTO v_existing FROM public.workspace_audit_events WHERE operation_id = p_request_id;
+ IF FOUND THEN
+ IF v_existing.request_fingerprint IS DISTINCT FROM v_fingerprint
+ OR v_existing.version_id IS NULL THEN
+ RAISE EXCEPTION 'operation ID conflict';
+ END IF;
+ RETURN v_existing.version_id;
+ END IF;
+
+ PERFORM 1
+ FROM public.workspaces AS workspace
+ WHERE workspace.id = p_workspace_id
+ FOR UPDATE OF workspace;
+ IF NOT FOUND THEN RAISE EXCEPTION 'workspace unavailable'; END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM public.workspaces AS workspace
+ JOIN public.workspace_members AS member
+ ON member.workspace_id = workspace.id AND member.user_id = v_user_id
+ WHERE workspace.id = p_workspace_id AND workspace.status = 'active'
+ ) THEN RAISE EXCEPTION 'workspace unavailable'; END IF;
+
+ SELECT state.* INTO v_state
+ FROM private.workspace_document_state AS state
+ JOIN public.workspace_documents AS document
+ ON document.workspace_id = state.workspace_id AND document.id = state.document_id
+ WHERE state.workspace_id = p_workspace_id AND state.document_id = p_document_id
+ FOR UPDATE OF state;
+ IF NOT FOUND THEN RAISE EXCEPTION 'workspace unavailable'; END IF;
+ IF v_state.durable_sequence <> p_expected_sequence THEN RAISE EXCEPTION 'document conflict'; END IF;
+
+ INSERT INTO public.workspace_versions (
+ workspace_id, document_id, created_by, name, kind, document_epoch, durable_sequence
+ ) VALUES (
+ p_workspace_id, p_document_id, v_user_id, v_name,
+ CASE WHEN v_name IS NULL THEN 'automatic' ELSE 'named' END,
+ v_state.epoch, v_state.durable_sequence
+ ) RETURNING id INTO v_version_id;
+ INSERT INTO private.workspace_version_data (version_id, snapshot)
+ VALUES (v_version_id, v_state.snapshot);
+ INSERT INTO public.workspace_audit_events (
+ operation_id, request_fingerprint, workspace_id, document_id, actor_id,
+ action, before_revision, after_revision, version_id
+ )
+ SELECT p_request_id, v_fingerprint, p_workspace_id, p_document_id, v_user_id,
+ 'version_create', revision, revision, v_version_id
+ FROM public.workspaces WHERE id = p_workspace_id;
+ RETURN v_version_id;
+END;
+$$;
+
+CREATE FUNCTION public.restore_workspace_version(
+ p_workspace_id uuid,
+ p_document_id uuid,
+ p_version_id uuid,
+ p_previewed_sequence bigint,
+ p_request_id uuid
+)
+RETURNS bigint
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ v_user_id uuid := auth.uid();
+ v_fingerprint bytea;
+ v_existing public.workspace_audit_events%ROWTYPE;
+ v_workspace public.workspaces%ROWTYPE;
+ v_state private.workspace_document_state%ROWTYPE;
+ v_target private.workspace_version_data%ROWTYPE;
+ v_pre_restore_id uuid;
+ v_new_epoch bigint;
+BEGIN
+ IF v_user_id IS NULL OR p_request_id IS NULL OR p_previewed_sequence IS NULL THEN
+ RAISE EXCEPTION 'authentication required';
+ END IF;
+ IF p_workspace_id IS NULL OR p_document_id IS NULL OR p_version_id IS NULL
+ OR p_previewed_sequence < 0 THEN
+ RAISE EXCEPTION 'invalid version request';
+ END IF;
+
+ v_fingerprint := private.workspace_request_fingerprint(jsonb_build_object(
+ 'rpc', 'restore_workspace_version',
+ 'actor', v_user_id,
+ 'workspace', p_workspace_id,
+ 'document', p_document_id,
+ 'version', p_version_id,
+ 'previewed_sequence', p_previewed_sequence
+ ));
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_request_id::text, 0));
+ SELECT * INTO v_existing FROM public.workspace_audit_events WHERE operation_id = p_request_id;
+ IF FOUND THEN
+ IF v_existing.request_fingerprint IS DISTINCT FROM v_fingerprint THEN
+ RAISE EXCEPTION 'operation ID conflict';
+ END IF;
+ RETURN (v_existing.payload->>'epoch')::bigint;
+ END IF;
+
+ SELECT workspace.* INTO v_workspace
+ FROM public.workspaces AS workspace
+ WHERE workspace.id = p_workspace_id
+ AND workspace.status = 'active'
+ AND EXISTS (
+ SELECT 1 FROM public.workspace_members AS member
+ WHERE member.workspace_id = workspace.id AND member.user_id = v_user_id
+ )
+ FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION 'workspace unavailable'; END IF;
+
+ SELECT state.* INTO v_state
+ FROM private.workspace_document_state AS state
+ WHERE state.workspace_id = p_workspace_id AND state.document_id = p_document_id
+ FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION 'workspace unavailable'; END IF;
+ IF v_state.durable_sequence <> p_previewed_sequence THEN RAISE EXCEPTION 'document conflict'; END IF;
+
+ SELECT data.* INTO v_target
+ FROM private.workspace_version_data AS data
+ JOIN public.workspace_versions AS version ON version.id = data.version_id
+ WHERE version.id = p_version_id
+ AND version.workspace_id = p_workspace_id
+ AND version.document_id = p_document_id;
+ IF NOT FOUND THEN RAISE EXCEPTION 'workspace unavailable'; END IF;
+
+ INSERT INTO public.workspace_versions (
+ workspace_id, document_id, created_by, kind, document_epoch, durable_sequence
+ ) VALUES (
+ p_workspace_id, p_document_id, v_user_id, 'pre_restore',
+ v_state.epoch, v_state.durable_sequence
+ ) RETURNING id INTO v_pre_restore_id;
+ INSERT INTO private.workspace_version_data (version_id, snapshot)
+ VALUES (v_pre_restore_id, v_state.snapshot);
+
+ v_new_epoch := v_state.epoch + 1;
+ UPDATE private.workspace_document_state
+ SET epoch = v_new_epoch, durable_sequence = 0, snapshot = v_target.snapshot, updated_at = now()
+ WHERE document_id = p_document_id;
+ UPDATE public.workspace_documents
+ SET document_epoch = v_new_epoch, durable_sequence = 0, updated_at = now()
+ WHERE workspace_id = p_workspace_id AND id = p_document_id;
+ UPDATE public.workspaces
+ SET revision = revision + 1, updated_at = now()
+ WHERE id = p_workspace_id;
+
+ INSERT INTO public.workspace_audit_events (
+ operation_id, request_fingerprint, workspace_id, document_id, actor_id,
+ action, before_revision, after_revision, version_id, payload
+ ) VALUES (
+ p_request_id, v_fingerprint, p_workspace_id, p_document_id, v_user_id,
+ 'version_restore', v_workspace.revision, v_workspace.revision + 1,
+ p_version_id, jsonb_build_object('epoch', v_new_epoch, 'pre_restore_version', v_pre_restore_id)
+ );
+ PERFORM pg_catalog.pg_notify('workspace_document_restored', p_document_id::text);
+ RETURN v_new_epoch;
+END;
+$$;
+
+CREATE FUNCTION public.get_workspace_version_snapshot(
+ p_workspace_id uuid,
+ p_document_id uuid,
+ p_version_id uuid
+)
+RETURNS TABLE(snapshot_base64 text, current_sequence bigint)
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+ SELECT pg_catalog.encode(data.snapshot, 'base64'), state.durable_sequence
+ FROM public.workspace_versions AS version
+ JOIN private.workspace_version_data AS data ON data.version_id = version.id
+ JOIN private.workspace_document_state AS state
+ ON state.workspace_id = version.workspace_id
+ AND state.document_id = version.document_id
+ WHERE version.id = p_version_id
+ AND version.workspace_id = p_workspace_id
+ AND version.document_id = p_document_id
+ AND EXISTS (
+ SELECT 1
+ FROM public.workspace_members AS member
+ WHERE member.workspace_id = version.workspace_id
+ AND member.user_id = auth.uid()
+ );
+$$;
+
+REVOKE ALL ON FUNCTION public.create_workspace_version(uuid, uuid, text, bigint, uuid)
+ FROM PUBLIC, anon;
+REVOKE ALL ON FUNCTION public.restore_workspace_version(uuid, uuid, uuid, bigint, uuid)
+ FROM PUBLIC, anon;
+REVOKE ALL ON FUNCTION public.get_workspace_version_snapshot(uuid, uuid, uuid)
+ FROM PUBLIC, anon;
+GRANT EXECUTE ON FUNCTION public.create_workspace_version(uuid, uuid, text, bigint, uuid)
+ TO authenticated;
+GRANT EXECUTE ON FUNCTION public.restore_workspace_version(uuid, uuid, uuid, bigint, uuid)
+ TO authenticated;
+GRANT EXECUTE ON FUNCTION public.get_workspace_version_snapshot(uuid, uuid, uuid)
+ TO authenticated;
+
+CREATE TABLE public.workspace_proposals (
+ id uuid PRIMARY KEY,
+ workspace_id uuid NOT NULL,
+ document_id uuid NOT NULL,
+ author_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+ document_epoch bigint NOT NULL CHECK (document_epoch >= 0),
+ durable_sequence bigint NOT NULL CHECK (durable_sequence >= 0),
+ payload jsonb NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT workspace_proposals_document_fkey
+ FOREIGN KEY (workspace_id, document_id)
+ REFERENCES public.workspace_documents(workspace_id, id)
+ ON DELETE CASCADE,
+ CONSTRAINT workspace_proposals_payload_size
+ CHECK (octet_length(convert_to(payload::text, 'UTF8')) <= 262144)
+);
+
+CREATE TABLE public.workspace_comments (
+ id uuid PRIMARY KEY,
+ workspace_id uuid NOT NULL,
+ document_id uuid NOT NULL,
+ author_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+ anchor jsonb NOT NULL,
+ body text NOT NULL CHECK (length(btrim(body)) BETWEEN 1 AND 2000),
+ resolved_at timestamptz,
+ resolved_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT workspace_comments_document_fkey
+ FOREIGN KEY (workspace_id, document_id)
+ REFERENCES public.workspace_documents(workspace_id, id)
+ ON DELETE CASCADE,
+ CONSTRAINT workspace_comments_anchor_size
+ CHECK (octet_length(convert_to(anchor::text, 'UTF8')) <= 32768)
+);
+
+CREATE TABLE public.workspace_comment_replies (
+ id uuid PRIMARY KEY,
+ comment_id uuid NOT NULL REFERENCES public.workspace_comments(id) ON DELETE CASCADE,
+ workspace_id uuid NOT NULL REFERENCES public.workspaces(id) ON DELETE CASCADE,
+ author_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+ body text NOT NULL CHECK (length(btrim(body)) BETWEEN 1 AND 2000),
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE public.workspace_notifications (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ workspace_id uuid NOT NULL REFERENCES public.workspaces(id) ON DELETE CASCADE,
+ document_id uuid NOT NULL,
+ comment_id uuid NOT NULL REFERENCES public.workspace_comments(id) ON DELETE CASCADE,
+ recipient_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+ actor_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
+ kind text NOT NULL CHECK (kind = 'mention'),
+ read_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (comment_id, recipient_id),
+ CONSTRAINT workspace_notifications_document_fkey
+ FOREIGN KEY (workspace_id, document_id)
+ REFERENCES public.workspace_documents(workspace_id, id)
+ ON DELETE CASCADE
+);
+
+CREATE INDEX workspace_proposals_document_created_idx
+ ON public.workspace_proposals(workspace_id, document_id, created_at DESC);
+CREATE INDEX workspace_comments_document_created_idx
+ ON public.workspace_comments(workspace_id, document_id, created_at DESC);
+CREATE INDEX workspace_comment_replies_comment_created_idx
+ ON public.workspace_comment_replies(comment_id, created_at);
+CREATE INDEX workspace_notifications_recipient_created_idx
+ ON public.workspace_notifications(recipient_id, created_at DESC);
+
+ALTER TABLE public.workspace_proposals ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.workspace_comments ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.workspace_comment_replies ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.workspace_notifications ENABLE ROW LEVEL SECURITY;
+CREATE POLICY workspace_proposals_select_member ON public.workspace_proposals
+ FOR SELECT TO authenticated USING ((SELECT private.is_current_workspace_member(workspace_id)));
+CREATE POLICY workspace_comments_select_member ON public.workspace_comments
+ FOR SELECT TO authenticated USING ((SELECT private.is_current_workspace_member(workspace_id)));
+CREATE POLICY workspace_comment_replies_select_member ON public.workspace_comment_replies
+ FOR SELECT TO authenticated USING ((SELECT private.is_current_workspace_member(workspace_id)));
+CREATE POLICY workspace_notifications_select_recipient ON public.workspace_notifications
+ FOR SELECT TO authenticated USING (
+ recipient_id = (SELECT auth.uid())
+ AND (SELECT private.is_current_workspace_member(workspace_id))
+ );
+REVOKE ALL ON TABLE public.workspace_proposals, public.workspace_comments,
+ public.workspace_comment_replies, public.workspace_notifications
+FROM PUBLIC, anon, authenticated;
+GRANT SELECT ON TABLE public.workspace_proposals, public.workspace_comments,
+ public.workspace_comment_replies, public.workspace_notifications
+TO authenticated;
+
+CREATE FUNCTION private.require_workspace_document_member(
+ p_workspace_id uuid,
+ p_document_id uuid,
+ p_require_active boolean DEFAULT true
+)
+RETURNS private.workspace_document_state
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ v_state private.workspace_document_state%ROWTYPE;
+BEGIN
+ PERFORM 1
+ FROM public.workspaces AS workspace
+ WHERE workspace.id = p_workspace_id
+ FOR UPDATE OF workspace;
+ IF NOT FOUND THEN RAISE EXCEPTION 'workspace unavailable'; END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM public.workspaces AS workspace
+ JOIN public.workspace_members AS member
+ ON member.workspace_id = workspace.id AND member.user_id = auth.uid()
+ WHERE workspace.id = p_workspace_id
+ AND (NOT p_require_active OR workspace.status = 'active')
+ AND workspace.status <> 'deleted'
+ ) THEN RAISE EXCEPTION 'workspace unavailable'; END IF;
+
+ SELECT state.* INTO v_state
+ FROM private.workspace_document_state AS state
+ WHERE state.workspace_id = p_workspace_id AND state.document_id = p_document_id
+ FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION 'workspace unavailable'; END IF;
+ RETURN v_state;
+END;
+$$;
+REVOKE ALL ON FUNCTION private.require_workspace_document_member(uuid, uuid, boolean)
+ FROM PUBLIC, anon, authenticated;
+
+CREATE FUNCTION public.create_workspace_proposal(
+ p_workspace_id uuid,
+ p_document_id uuid,
+ p_proposal_id uuid,
+ p_document_epoch bigint,
+ p_expected_sequence bigint,
+ p_payload jsonb
+)
+RETURNS uuid
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ v_user_id uuid := auth.uid();
+ v_state private.workspace_document_state%ROWTYPE;
+ v_existing public.workspace_proposals%ROWTYPE;
+BEGIN
+ IF v_user_id IS NULL OR p_proposal_id IS NULL OR p_payload IS NULL
+ OR p_document_epoch IS NULL OR p_expected_sequence IS NULL
+ OR p_document_epoch < 0 OR p_expected_sequence < 0
+ OR octet_length(convert_to(p_payload::text, 'UTF8')) > 262144
+ OR p_payload->>'id' IS DISTINCT FROM p_proposal_id::text
+ OR p_payload->>'authorId' IS DISTINCT FROM v_user_id::text
+ OR (p_payload->>'documentEpoch')::bigint IS DISTINCT FROM p_document_epoch
+ OR coalesce(jsonb_typeof(p_payload->'hunks'), 'null') <> 'array'
+ OR jsonb_array_length(p_payload->'hunks') NOT BETWEEN 1 AND 100 THEN
+ RAISE EXCEPTION 'invalid proposal request';
+ END IF;
+ v_state := private.require_workspace_document_member(p_workspace_id, p_document_id, true);
+ IF v_state.epoch <> p_document_epoch OR v_state.durable_sequence <> p_expected_sequence THEN
+ RAISE EXCEPTION 'document conflict';
+ END IF;
+ SELECT * INTO v_existing FROM public.workspace_proposals WHERE id = p_proposal_id;
+ IF FOUND THEN
+ IF v_existing.workspace_id IS DISTINCT FROM p_workspace_id
+ OR v_existing.document_id IS DISTINCT FROM p_document_id
+ OR v_existing.author_id IS DISTINCT FROM v_user_id
+ OR v_existing.payload IS DISTINCT FROM p_payload THEN
+ RAISE EXCEPTION 'operation ID conflict';
+ END IF;
+ RETURN v_existing.id;
+ END IF;
+ INSERT INTO public.workspace_proposals (
+ id, workspace_id, document_id, author_id, document_epoch, durable_sequence, payload
+ ) VALUES (
+ p_proposal_id, p_workspace_id, p_document_id, v_user_id,
+ p_document_epoch, p_expected_sequence, p_payload
+ );
+ RETURN p_proposal_id;
+END;
+$$;
+
+CREATE FUNCTION public.create_workspace_comment(
+ p_workspace_id uuid,
+ p_document_id uuid,
+ p_comment_id uuid,
+ p_anchor jsonb,
+ p_body text,
+ p_mention_ids uuid[]
+)
+RETURNS uuid
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ v_user_id uuid := auth.uid();
+ v_body text := btrim(p_body);
+BEGIN
+ IF v_user_id IS NULL OR p_comment_id IS NULL OR p_anchor IS NULL OR p_body IS NULL
+ OR length(v_body) NOT BETWEEN 1 AND 2000
+ OR octet_length(convert_to(p_anchor::text, 'UTF8')) > 32768
+ OR p_anchor->>'id' IS DISTINCT FROM p_comment_id::text
+ OR coalesce(jsonb_typeof(p_anchor->'from'), 'null') <> 'array'
+ OR coalesce(jsonb_typeof(p_anchor->'to'), 'null') <> 'array'
+ OR coalesce(jsonb_typeof(p_anchor->'quote'), 'null') <> 'string'
+ OR length(p_anchor->>'quote') NOT BETWEEN 1 AND 2000 THEN
+ RAISE EXCEPTION 'invalid comment request';
+ END IF;
+ PERFORM private.require_workspace_document_member(p_workspace_id, p_document_id, true);
+ IF EXISTS (
+ SELECT 1 FROM unnest(coalesce(p_mention_ids, ARRAY[]::uuid[])) AS mentioned(user_id)
+ WHERE NOT EXISTS (
+ SELECT 1 FROM public.workspace_members AS member
+ WHERE member.workspace_id = p_workspace_id AND member.user_id = mentioned.user_id
+ )
+ ) THEN RAISE EXCEPTION 'workspace unavailable'; END IF;
+ INSERT INTO public.workspace_comments (id, workspace_id, document_id, author_id, anchor, body)
+ VALUES (p_comment_id, p_workspace_id, p_document_id, v_user_id, p_anchor, v_body);
+ INSERT INTO public.workspace_notifications (
+ workspace_id, document_id, comment_id, recipient_id, actor_id, kind
+ )
+ SELECT p_workspace_id, p_document_id, p_comment_id, mentioned.user_id, v_user_id, 'mention'
+ FROM (SELECT DISTINCT unnest(coalesce(p_mention_ids, ARRAY[]::uuid[])) AS user_id) AS mentioned
+ WHERE mentioned.user_id <> v_user_id;
+ RETURN p_comment_id;
+END;
+$$;
+
+CREATE FUNCTION public.reply_workspace_comment(
+ p_workspace_id uuid,
+ p_comment_id uuid,
+ p_reply_id uuid,
+ p_body text
+)
+RETURNS uuid
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ v_user_id uuid := auth.uid();
+ v_body text := btrim(p_body);
+ v_document_id uuid;
+BEGIN
+ IF v_user_id IS NULL OR p_reply_id IS NULL OR length(v_body) NOT BETWEEN 1 AND 2000 THEN
+ RAISE EXCEPTION 'workspace unavailable';
+ END IF;
+ SELECT document_id INTO v_document_id
+ FROM public.workspace_comments
+ WHERE id = p_comment_id AND workspace_id = p_workspace_id;
+ IF NOT FOUND THEN RAISE EXCEPTION 'workspace unavailable'; END IF;
+ PERFORM private.require_workspace_document_member(p_workspace_id, v_document_id, true);
+ INSERT INTO public.workspace_comment_replies (id, comment_id, workspace_id, author_id, body)
+ VALUES (p_reply_id, p_comment_id, p_workspace_id, v_user_id, v_body);
+ RETURN p_reply_id;
+END;
+$$;
+
+CREATE FUNCTION public.resolve_workspace_comment(
+ p_workspace_id uuid,
+ p_comment_id uuid,
+ p_resolved boolean
+)
+RETURNS boolean
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ v_user_id uuid := auth.uid();
+ v_document_id uuid;
+BEGIN
+ IF v_user_id IS NULL OR p_resolved IS NULL THEN
+ RAISE EXCEPTION 'workspace unavailable';
+ END IF;
+ SELECT document_id INTO v_document_id
+ FROM public.workspace_comments
+ WHERE id = p_comment_id AND workspace_id = p_workspace_id;
+ IF NOT FOUND THEN RAISE EXCEPTION 'workspace unavailable'; END IF;
+ PERFORM private.require_workspace_document_member(p_workspace_id, v_document_id, true);
+ UPDATE public.workspace_comments
+ SET resolved_at = CASE WHEN p_resolved THEN now() ELSE NULL END,
+ resolved_by = CASE WHEN p_resolved THEN v_user_id ELSE NULL END
+ WHERE id = p_comment_id AND workspace_id = p_workspace_id;
+ IF NOT FOUND THEN RAISE EXCEPTION 'workspace unavailable'; END IF;
+ RETURN p_resolved;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.create_workspace_proposal(uuid, uuid, uuid, bigint, bigint, jsonb),
+ public.create_workspace_comment(uuid, uuid, uuid, jsonb, text, uuid[]),
+ public.reply_workspace_comment(uuid, uuid, uuid, text),
+ public.resolve_workspace_comment(uuid, uuid, boolean)
+FROM PUBLIC, anon;
+GRANT EXECUTE ON FUNCTION public.create_workspace_proposal(uuid, uuid, uuid, bigint, bigint, jsonb),
+ public.create_workspace_comment(uuid, uuid, uuid, jsonb, text, uuid[]),
+ public.reply_workspace_comment(uuid, uuid, uuid, text),
+ public.resolve_workspace_comment(uuid, uuid, boolean)
+TO authenticated;
diff --git a/supabase/templates/magic_link.html b/supabase/templates/magic_link.html
new file mode 100644
index 000000000..c4f6e3cd2
--- /dev/null
+++ b/supabase/templates/magic_link.html
@@ -0,0 +1,6 @@
+
+
+
+ Your DebateLab sign-in code is {{ .Token }} .
+
+
diff --git a/supabase/tests/workspace_collaboration_test.sql b/supabase/tests/workspace_collaboration_test.sql
new file mode 100644
index 000000000..ff475bc48
--- /dev/null
+++ b/supabase/tests/workspace_collaboration_test.sql
@@ -0,0 +1,180 @@
+BEGIN;
+SELECT plan(19);
+
+SELECT has_table(
+ 'private',
+ 'workspace_document_state',
+ 'canonical workspace document state is private'
+);
+SELECT has_table(
+ 'private',
+ 'workspace_document_updates',
+ 'durable workspace updates are private'
+);
+SELECT has_function(
+ 'public',
+ 'create_workspace_document',
+ ARRAY['uuid', 'text', 'text', 'uuid', 'bigint', 'uuid'],
+ 'members create or duplicate files through one transactional RPC'
+);
+SELECT has_trigger(
+ 'public',
+ 'workspace_members',
+ 'workspace_members_notify_access_revoked_after_delete',
+ 'member removal notifies the collaboration fence after commit'
+);
+SELECT has_table('public', 'workspace_versions', 'version metadata is member-readable');
+SELECT has_function(
+ 'public', 'create_workspace_version',
+ ARRAY['uuid', 'uuid', 'text', 'bigint', 'uuid'],
+ 'members create named or automatic versions through one RPC'
+);
+SELECT has_function(
+ 'public', 'restore_workspace_version',
+ ARRAY['uuid', 'uuid', 'uuid', 'bigint', 'uuid'],
+ 'restore compares the previewed sequence through one RPC'
+);
+SELECT has_function(
+ 'public', 'get_workspace_version_snapshot',
+ ARRAY['uuid', 'uuid', 'uuid'],
+ 'preview returns private version bytes only through an authorized RPC'
+);
+
+INSERT INTO auth.users (id) VALUES
+ ('71111111-1111-4111-8111-111111111111'),
+ ('72222222-2222-4222-8222-222222222222');
+SET LOCAL ROLE authenticated;
+SELECT set_config('request.jwt.claim.sub', '71111111-1111-4111-8111-111111111111', true);
+SELECT set_config('request.jwt.claim.role', 'authenticated', true);
+SELECT lives_ok(
+ $$SELECT public.create_workspace(
+ 'Version motion', 'Version workspace', 'en',
+ '70000000-0000-4000-8000-000000000001'
+ )$$,
+ 'version tests create one workspace'
+);
+RESET ROLE;
+SELECT set_config(
+ 'test.version_workspace_id',
+ (SELECT id::text FROM public.workspaces WHERE owner_id = '71111111-1111-4111-8111-111111111111'),
+ true
+);
+SELECT set_config(
+ 'test.version_document_id',
+ (SELECT id::text FROM public.workspace_documents
+ WHERE workspace_id = current_setting('test.version_workspace_id')::uuid
+ ORDER BY side, position LIMIT 1),
+ true
+);
+UPDATE private.workspace_document_state
+SET snapshot = decode('01', 'hex'), durable_sequence = 5
+WHERE document_id = current_setting('test.version_document_id')::uuid;
+
+SET LOCAL ROLE authenticated;
+SELECT set_config('request.jwt.claim.sub', '71111111-1111-4111-8111-111111111111', true);
+SELECT set_config('request.jwt.claim.role', 'authenticated', true);
+SELECT ok(
+ public.create_workspace_version(
+ current_setting('test.version_workspace_id')::uuid,
+ current_setting('test.version_document_id')::uuid,
+ 'Before edits', 5, '70000000-0000-4000-8000-000000000002'
+ ) IS NOT NULL,
+ 'a current member saves a named version at the durable sequence'
+);
+RESET ROLE;
+SELECT set_config(
+ 'test.version_id',
+ (SELECT version_id::text FROM public.workspace_audit_events
+ WHERE operation_id = '70000000-0000-4000-8000-000000000002'),
+ true
+);
+UPDATE private.workspace_document_state
+SET snapshot = decode('02', 'hex'), durable_sequence = 6
+WHERE document_id = current_setting('test.version_document_id')::uuid;
+
+SET LOCAL ROLE authenticated;
+SELECT set_config('request.jwt.claim.sub', '71111111-1111-4111-8111-111111111111', true);
+SELECT set_config('request.jwt.claim.role', 'authenticated', true);
+SELECT results_eq(
+ $$SELECT snapshot_base64, current_sequence
+ FROM public.get_workspace_version_snapshot(
+ current_setting('test.version_workspace_id')::uuid,
+ current_setting('test.version_document_id')::uuid,
+ current_setting('test.version_id')::uuid
+ )$$,
+ $$VALUES ('AQ=='::text, 6::bigint)$$,
+ 'preview returns the selected snapshot and captures the current durable sequence'
+);
+SELECT throws_ok(
+ $$SELECT public.restore_workspace_version(
+ current_setting('test.version_workspace_id')::uuid,
+ current_setting('test.version_document_id')::uuid,
+ current_setting('test.version_id')::uuid,
+ 5, '70000000-0000-4000-8000-000000000003'
+ )$$,
+ 'P0001', 'document conflict',
+ 'restore rejects a stale previewed sequence'
+);
+SELECT results_eq(
+ $$SELECT public.restore_workspace_version(
+ current_setting('test.version_workspace_id')::uuid,
+ current_setting('test.version_document_id')::uuid,
+ current_setting('test.version_id')::uuid,
+ 6, '70000000-0000-4000-8000-000000000004'
+ )$$,
+ ARRAY[1::bigint],
+ 'restore starts a new document epoch'
+);
+RESET ROLE;
+SELECT results_eq(
+ $$SELECT state.epoch, state.durable_sequence, state.snapshot, document.document_epoch
+ FROM private.workspace_document_state state
+ JOIN public.workspace_documents document ON document.id = state.document_id
+ WHERE state.document_id = current_setting('test.version_document_id')::uuid$$,
+ $$VALUES (1::bigint, 0::bigint, decode('01', 'hex'), 1::bigint)$$,
+ 'restore installs the previewed snapshot and synchronizes the public epoch'
+);
+SELECT results_eq(
+ $$SELECT string_agg(kind, ',' ORDER BY created_at, kind)
+ FROM public.workspace_versions
+ WHERE document_id = current_setting('test.version_document_id')::uuid$$,
+ ARRAY['named,pre_restore'::text],
+ 'restore keeps both the named target and a pre-restore version'
+);
+SELECT results_eq(
+ $$SELECT data.snapshot
+ FROM private.workspace_version_data data
+ JOIN public.workspace_versions version ON version.id = data.version_id
+ WHERE version.document_id = current_setting('test.version_document_id')::uuid
+ AND version.kind = 'pre_restore'$$,
+ ARRAY[decode('02', 'hex')],
+ 'the pre-restore version preserves the replaced content'
+);
+SELECT results_eq(
+ $$SELECT count(*)::bigint FROM private.workspace_document_updates
+ WHERE document_id = current_setting('test.version_document_id')::uuid AND epoch = 1$$,
+ ARRAY[0::bigint],
+ 'old updates never enter the restored epoch'
+);
+
+SET LOCAL ROLE authenticated;
+SELECT set_config('request.jwt.claim.sub', '72222222-2222-4222-8222-222222222222', true);
+SELECT set_config('request.jwt.claim.role', 'authenticated', true);
+SELECT results_eq(
+ $$SELECT count(*)::bigint FROM public.workspace_versions$$,
+ ARRAY[0::bigint],
+ 'an outsider reads no version metadata'
+);
+SELECT results_eq(
+ $$SELECT count(*)::bigint
+ FROM public.get_workspace_version_snapshot(
+ current_setting('test.version_workspace_id')::uuid,
+ current_setting('test.version_document_id')::uuid,
+ current_setting('test.version_id')::uuid
+ )$$,
+ ARRAY[0::bigint],
+ 'an outsider cannot retrieve private version bytes'
+);
+
+SELECT * FROM finish();
+ROLLBACK;
diff --git a/supabase/tests/workspace_lifecycle_test.sql b/supabase/tests/workspace_lifecycle_test.sql
index 9198490ee..874815d14 100644
--- a/supabase/tests/workspace_lifecycle_test.sql
+++ b/supabase/tests/workspace_lifecycle_test.sql
@@ -1,5 +1,5 @@
BEGIN;
-SELECT plan(82);
+SELECT plan(92);
INSERT INTO auth.users (id)
VALUES
@@ -900,6 +900,128 @@ SELECT results_eq(
$$VALUES ('active'::text, NULL::timestamptz)$$,
'workspace restore clears deleted state'
);
+
+SELECT results_eq(
+ $$SELECT revision FROM public.create_workspace_document(
+ (SELECT id FROM public.workspaces WHERE initial_language = 'en'),
+ 'Custom rebuttal', 'pro', NULL, 17,
+ 'baaaaaaa-0000-4000-8000-000000000044'
+ )$$,
+ ARRAY[18::bigint],
+ 'a member creates a custom file at the end of one side'
+);
+RESET ROLE;
+SELECT set_config(
+ 'test.custom_document_id',
+ (SELECT document_id::text FROM public.workspace_audit_events
+ WHERE operation_id = 'baaaaaaa-0000-4000-8000-000000000044'),
+ true
+);
+SELECT results_eq(
+ $$SELECT title, side,
+ position = (SELECT max(position) FROM public.workspace_documents sibling
+ WHERE sibling.workspace_id = created.workspace_id
+ AND sibling.side = created.side
+ AND sibling.state = 'active'),
+ state
+ FROM public.workspace_documents
+ AS created
+ WHERE id = current_setting('test.custom_document_id')::uuid$$,
+ $$VALUES ('Custom rebuttal'::text, 'pro'::text, true, 'active'::text)$$,
+ 'custom creation stores the requested metadata without disturbing default files'
+);
+UPDATE private.workspace_document_state
+SET snapshot = decode('01020304', 'hex')
+WHERE document_id = current_setting('test.custom_document_id')::uuid;
+UPDATE public.workspace_documents
+SET assignee_id = '55555555-5555-4555-8555-555555555555'
+WHERE id = current_setting('test.custom_document_id')::uuid;
+
+SET LOCAL ROLE authenticated;
+SELECT set_config('request.jwt.claim.sub', '53333333-3333-4333-8333-333333333333', true);
+SELECT set_config('request.jwt.claim.role', 'authenticated', true);
+SELECT results_eq(
+ $$SELECT revision FROM public.create_workspace_document(
+ current_setting('test.english_workspace_id')::uuid,
+ 'Custom rebuttal copy', 'con', current_setting('test.custom_document_id')::uuid, 18,
+ 'baaaaaaa-0000-4000-8000-000000000045'
+ )$$,
+ ARRAY[19::bigint],
+ 'duplication creates one independent file and revision'
+);
+RESET ROLE;
+SELECT set_config(
+ 'test.duplicate_document_id',
+ (SELECT document_id::text FROM public.workspace_audit_events
+ WHERE operation_id = 'baaaaaaa-0000-4000-8000-000000000045'),
+ true
+);
+SELECT results_eq(
+ $$SELECT snapshot, epoch, durable_sequence
+ FROM private.workspace_document_state
+ WHERE document_id = current_setting('test.duplicate_document_id')::uuid$$,
+ $$VALUES (decode('01020304', 'hex'), 0::bigint, 0::bigint)$$,
+ 'duplication copies only the accepted content snapshot into a fresh epoch'
+);
+SELECT results_eq(
+ $$SELECT title, side, position, assignee_id, state
+ FROM public.workspace_documents
+ WHERE id = current_setting('test.duplicate_document_id')::uuid$$,
+ $$VALUES ('Custom rebuttal copy'::text, 'con'::text, 4, NULL::uuid, 'active'::text)$$,
+ 'duplication does not copy assignment or lifecycle state'
+);
+SELECT results_eq(
+ $$SELECT count(*)::bigint FROM private.workspace_document_updates
+ WHERE document_id = current_setting('test.duplicate_document_id')::uuid$$,
+ ARRAY[0::bigint],
+ 'duplication does not copy live update history'
+);
+
+SET LOCAL ROLE authenticated;
+SELECT set_config('request.jwt.claim.sub', '53333333-3333-4333-8333-333333333333', true);
+SELECT set_config('request.jwt.claim.role', 'authenticated', true);
+SELECT results_eq(
+ $$SELECT document_id, revision FROM public.create_workspace_document(
+ (SELECT id FROM public.workspaces WHERE initial_language = 'en'),
+ 'Custom rebuttal copy', 'con', current_setting('test.custom_document_id')::uuid, 18,
+ 'baaaaaaa-0000-4000-8000-000000000045'
+ )$$,
+ $$VALUES (current_setting('test.duplicate_document_id')::uuid, 19::bigint)$$,
+ 'an identical duplicate request replays the committed file'
+);
+SELECT throws_ok(
+ $$SELECT * FROM public.create_workspace_document(
+ (SELECT id FROM public.workspaces WHERE initial_language = 'en'),
+ 'Foreign copy', 'pro', current_setting('test.foreign_document_id')::uuid, 19,
+ 'baaaaaaa-0000-4000-8000-000000000046'
+ )$$,
+ 'P0001', 'workspace unavailable',
+ 'a cross-workspace duplicate source returns the uniform unavailable error'
+);
+SELECT results_eq(
+ $$SELECT revision FROM public.workspaces WHERE initial_language = 'en'$$,
+ ARRAY[19::bigint],
+ 'a rejected cross-workspace duplicate leaves the revision unchanged'
+);
+
+RESET ROLE;
+SET LOCAL ROLE authenticated;
+SELECT set_config('request.jwt.claim.sub', '54444444-4444-4444-8444-444444444444', true);
+SELECT set_config('request.jwt.claim.role', 'authenticated', true);
+SELECT throws_ok(
+ $$SELECT * FROM public.create_workspace_document(
+ current_setting('test.english_workspace_id')::uuid,
+ 'Outsider file', 'pro', NULL, 19,
+ 'baaaaaaa-0000-4000-8000-000000000047'
+ )$$,
+ 'P0001', 'workspace unavailable',
+ 'an outsider cannot create a file in a foreign workspace'
+);
+
+RESET ROLE;
+SET LOCAL ROLE authenticated;
+SELECT set_config('request.jwt.claim.sub', '53333333-3333-4333-8333-333333333333', true);
+SELECT set_config('request.jwt.claim.role', 'authenticated', true);
SELECT lives_ok(
$$SELECT public.clear_current_user_data()$$,
'personal data clearing remains available to a workspace owner'
@@ -910,7 +1032,7 @@ SELECT results_eq(
(SELECT count(*) FROM public.workspace_members WHERE workspace_id = (SELECT id FROM public.workspaces WHERE initial_language = 'en'))::bigint,
(SELECT count(*) FROM public.workspace_documents WHERE workspace_id = (SELECT id FROM public.workspaces WHERE initial_language = 'en'))::bigint,
(SELECT count(*) FROM public.workspace_audit_events WHERE workspace_id = (SELECT id FROM public.workspaces WHERE initial_language = 'en'))::bigint$$,
- $$VALUES (1::bigint, 2::bigint, 8::bigint, 18::bigint)$$,
+ $$VALUES (1::bigint, 2::bigint, 10::bigint, 20::bigint)$$,
'personal data clearing preserves shared workspace rows and audit history'
);
diff --git a/supabase/tests/workspace_review_test.sql b/supabase/tests/workspace_review_test.sql
new file mode 100644
index 000000000..bb49f8707
--- /dev/null
+++ b/supabase/tests/workspace_review_test.sql
@@ -0,0 +1,202 @@
+BEGIN;
+SELECT plan(25);
+
+SELECT has_table('public', 'workspace_proposals', 'proposal metadata is persisted');
+SELECT has_table('public', 'workspace_comments', 'inline comments are persisted');
+SELECT has_table('public', 'workspace_comment_replies', 'comment replies are persisted');
+SELECT has_table('public', 'workspace_notifications', 'mention notifications are persisted');
+SELECT has_function('public', 'create_workspace_proposal', ARRAY['uuid', 'uuid', 'uuid', 'bigint', 'bigint', 'jsonb'], 'proposal creation is authorized');
+SELECT has_function('public', 'create_workspace_comment', ARRAY['uuid', 'uuid', 'uuid', 'jsonb', 'text', 'uuid[]'], 'comment creation is authorized');
+SELECT has_function('public', 'reply_workspace_comment', ARRAY['uuid', 'uuid', 'uuid', 'text'], 'comment replies are authorized');
+SELECT has_function('public', 'resolve_workspace_comment', ARRAY['uuid', 'uuid', 'boolean'], 'comment resolution is authorized');
+
+INSERT INTO auth.users (id) VALUES
+ ('81111111-1111-4111-8111-111111111111'),
+ ('82222222-2222-4222-8222-222222222222'),
+ ('83333333-3333-4333-8333-333333333333');
+SET LOCAL ROLE authenticated;
+SELECT set_config('request.jwt.claim.sub', '81111111-1111-4111-8111-111111111111', true);
+SELECT set_config('request.jwt.claim.role', 'authenticated', true);
+SELECT lives_ok(
+ $$SELECT public.create_workspace('Review motion', 'Review workspace', 'en', '80000000-0000-4000-8000-000000000001')$$,
+ 'review tests create a workspace'
+);
+RESET ROLE;
+SELECT set_config('test.review_workspace', (SELECT id::text FROM public.workspaces WHERE owner_id = '81111111-1111-4111-8111-111111111111'), true);
+SELECT set_config('test.review_document', (SELECT id::text FROM public.workspace_documents WHERE workspace_id = current_setting('test.review_workspace')::uuid ORDER BY side, position LIMIT 1), true);
+INSERT INTO public.workspace_members (workspace_id, user_id)
+VALUES (current_setting('test.review_workspace')::uuid, '82222222-2222-4222-8222-222222222222');
+
+SET LOCAL ROLE authenticated;
+SELECT set_config('request.jwt.claim.sub', '81111111-1111-4111-8111-111111111111', true);
+SELECT set_config('request.jwt.claim.role', 'authenticated', true);
+SELECT results_eq(
+ $$SELECT public.create_workspace_proposal(
+ current_setting('test.review_workspace')::uuid,
+ current_setting('test.review_document')::uuid,
+ '80000000-0000-4000-8000-000000000002', 0, 0,
+ jsonb_build_object(
+ 'id', '80000000-0000-4000-8000-000000000002',
+ 'authorId', '81111111-1111-4111-8111-111111111111',
+ 'documentEpoch', 0,
+ 'baseStateVector', jsonb_build_array(),
+ 'hunks', jsonb_build_array(jsonb_build_object('id', 'hunk-1'))
+ )
+ )$$,
+ ARRAY['80000000-0000-4000-8000-000000000002'::uuid],
+ 'a member persists a proposal at the current durable sequence'
+);
+SELECT results_eq(
+ $$SELECT public.create_workspace_comment(
+ current_setting('test.review_workspace')::uuid,
+ current_setting('test.review_document')::uuid,
+ '80000000-0000-4000-8000-000000000003',
+ jsonb_build_object(
+ 'id', '80000000-0000-4000-8000-000000000003',
+ 'documentEpoch', 0,
+ 'from', jsonb_build_array(1),
+ 'to', jsonb_build_array(2),
+ 'quote', 'claim'
+ ),
+ 'Please verify this claim.',
+ ARRAY['82222222-2222-4222-8222-222222222222'::uuid]
+ )$$,
+ ARRAY['80000000-0000-4000-8000-000000000003'::uuid],
+ 'a member creates an anchored comment with a member mention'
+);
+RESET ROLE;
+SELECT results_eq(
+ $$SELECT recipient_id FROM public.workspace_notifications WHERE comment_id = '80000000-0000-4000-8000-000000000003'$$,
+ ARRAY['82222222-2222-4222-8222-222222222222'::uuid],
+ 'a mention notifies only the selected workspace member'
+);
+
+SET LOCAL ROLE authenticated;
+SELECT set_config('request.jwt.claim.sub', '82222222-2222-4222-8222-222222222222', true);
+SELECT set_config('request.jwt.claim.role', 'authenticated', true);
+SELECT results_eq(
+ $$SELECT public.reply_workspace_comment(
+ current_setting('test.review_workspace')::uuid,
+ '80000000-0000-4000-8000-000000000003',
+ '80000000-0000-4000-8000-000000000004',
+ 'I will check it.'
+ )$$,
+ ARRAY['80000000-0000-4000-8000-000000000004'::uuid],
+ 'a teammate replies to the inline comment'
+);
+SELECT results_eq(
+ $$SELECT public.resolve_workspace_comment(
+ current_setting('test.review_workspace')::uuid,
+ '80000000-0000-4000-8000-000000000003', true
+ )$$,
+ ARRAY[true],
+ 'a teammate resolves the comment'
+);
+SELECT throws_ok(
+ $$SELECT public.create_workspace_comment(
+ current_setting('test.review_workspace')::uuid,
+ current_setting('test.review_document')::uuid,
+ '80000000-0000-4000-8000-000000000007',
+ jsonb_build_object('id', '80000000-0000-4000-8000-000000000007', 'from', jsonb_build_array(1), 'to', jsonb_build_array(2)),
+ 'Missing quote', ARRAY[]::uuid[]
+ )$$,
+ 'P0001', 'invalid comment request',
+ 'comments require a bounded detached quote'
+);
+SELECT results_eq(
+ $$SELECT count(*)::bigint FROM public.workspace_notifications$$,
+ ARRAY[1::bigint],
+ 'the mentioned teammate reads their notification'
+);
+
+RESET ROLE;
+SET LOCAL ROLE authenticated;
+SELECT set_config('request.jwt.claim.sub', '81111111-1111-4111-8111-111111111111', true);
+SELECT set_config('request.jwt.claim.role', 'authenticated', true);
+SELECT results_eq(
+ $$SELECT public.change_workspace_access(
+ current_setting('test.review_workspace')::uuid,
+ 'remove', '82222222-2222-4222-8222-222222222222', 0,
+ '80000000-0000-4000-8000-000000000008'
+ )$$,
+ ARRAY[1::bigint],
+ 'the owner removes the mentioned teammate'
+);
+RESET ROLE;
+SET LOCAL ROLE authenticated;
+SELECT set_config('request.jwt.claim.sub', '82222222-2222-4222-8222-222222222222', true);
+SELECT set_config('request.jwt.claim.role', 'authenticated', true);
+SELECT results_eq(
+ $$SELECT count(*)::bigint FROM public.workspace_notifications$$,
+ ARRAY[0::bigint],
+ 'a removed teammate cannot read retained notification metadata'
+);
+
+RESET ROLE;
+SET LOCAL ROLE authenticated;
+SELECT set_config('request.jwt.claim.sub', '81111111-1111-4111-8111-111111111111', true);
+SELECT set_config('request.jwt.claim.role', 'authenticated', true);
+SELECT results_eq(
+ $$SELECT public.change_workspace_state(
+ current_setting('test.review_workspace')::uuid,
+ 'archive', 1, '80000000-0000-4000-8000-000000000009'
+ )$$,
+ ARRAY[2::bigint],
+ 'the owner archives the review workspace'
+);
+SELECT throws_ok(
+ $$SELECT public.reply_workspace_comment(
+ current_setting('test.review_workspace')::uuid,
+ '80000000-0000-4000-8000-000000000003',
+ '80000000-0000-4000-8000-000000000010',
+ 'Late reply'
+ )$$,
+ 'P0001', 'workspace unavailable',
+ 'archived workspaces reject replies'
+);
+SELECT throws_ok(
+ $$SELECT public.resolve_workspace_comment(
+ current_setting('test.review_workspace')::uuid,
+ '80000000-0000-4000-8000-000000000003', false
+ )$$,
+ 'P0001', 'workspace unavailable',
+ 'archived workspaces reject resolution changes'
+);
+
+SET LOCAL ROLE authenticated;
+SELECT set_config('request.jwt.claim.sub', '83333333-3333-4333-8333-333333333333', true);
+SELECT set_config('request.jwt.claim.role', 'authenticated', true);
+SELECT results_eq(
+ $$SELECT count(*)::bigint FROM public.workspace_proposals$$,
+ ARRAY[0::bigint],
+ 'an outsider reads no proposals'
+);
+SELECT results_eq(
+ $$SELECT count(*)::bigint FROM public.workspace_comments$$,
+ ARRAY[0::bigint],
+ 'an outsider reads no comments'
+);
+SELECT throws_ok(
+ $$SELECT public.create_workspace_comment(
+ current_setting('test.review_workspace')::uuid,
+ current_setting('test.review_document')::uuid,
+ '80000000-0000-4000-8000-000000000005',
+ jsonb_build_object('id', '80000000-0000-4000-8000-000000000005', 'from', jsonb_build_array(1), 'to', jsonb_build_array(2), 'quote', 'claim'),
+ 'Forged comment', ARRAY[]::uuid[]
+ )$$,
+ 'P0001', 'workspace unavailable',
+ 'an outsider cannot create a comment'
+);
+SELECT throws_ok(
+ $$SELECT public.create_workspace_proposal(
+ current_setting('test.review_workspace')::uuid,
+ current_setting('test.review_document')::uuid,
+ '80000000-0000-4000-8000-000000000006', 0, 0,
+ jsonb_build_object('id', '80000000-0000-4000-8000-000000000006', 'authorId', '83333333-3333-4333-8333-333333333333', 'documentEpoch', 0, 'hunks', jsonb_build_array(jsonb_build_object('id', 'hunk')))
+ )$$,
+ 'P0001', 'workspace unavailable',
+ 'an outsider cannot create a proposal'
+);
+
+SELECT * FROM finish();
+ROLLBACK;