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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .superset/db-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ for url in "$mailer_url/api/v1/messages" "$app_origin" "http://127.0.0.1:18090/_
done
curl --fail --silent --output /dev/null "$url"
done
for function_name in workspace-invites ai ai-stream demo-code-login admin announcements; do
for function_name in workspace-invites ai ai-stream demo-code-login admin announcements account; do
function_url="$api_url/functions/v1/$function_name"
for _ in $(seq 1 200); do
if curl --fail --silent --output /dev/null --request OPTIONS --header "Origin: $app_origin" "$function_url"; then break; fi
Expand Down
1 change: 1 addition & 0 deletions .superset/workspace-test.env
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ DEMO_CODE_PEPPER=local-demo-code-pepper-for-browser-gate
SPARRING_INTEGRITY_SECRET=local-sparring-integrity-secret-for-browser-gate
STRIPE_SECRET_KEY=sk_test_local-full-stack-unused
STRIPE_WEBHOOK_SECRET=whsec_local-full-stack-test
STRIPE_API_URL=http://debatelab-fake-openrouter:8787
13 changes: 13 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,19 @@ The browser uses user-scoped Supabase access. Privileged Edge Function operation

Do not infer current schema from old README examples or store query names. Replay migrations and run pgTAP tests for database proof.

## Account export and deletion

Settings offers three controls. "Download my data" calls the `account` Edge Function, which returns `export_account_data`, which holds the auth profile, every row whose ownership column (`user_id`, `owner_id`, `author_id`, `created_by`, or `recipient_id`) references the user, chat messages, published announcements addressed to the user, and announcements they wrote. Columns that only mention the user, such as `actor_id` or `resolved_by`, never select a row, and workspace rows are exported only while the user is still a member, as their read policies require. It leaves out credential and integrity columns and admin catalog work (promo codes, curated sources, global prompts, demo codes). Admin grants appear as grant type and dates only, because RLS hides their notes and author from the user. "Delete All My Data" calls `clear_current_user_data` and keeps the account. "Delete account" asks the user to type their email (or DELETE for an account without one), then calls the `account` function.

Deletion runs in a fixed order, and a failure at any step leaves the account in place:

1. Stop Stripe billing for every customer in `subscriptions`, in `stripe_customers`, or found by a Stripe search on `metadata.user_id`. The function expires open Checkout sessions, then cancels live subscriptions immediately, without a refund. If Stripe cannot be reached, nothing is deleted. `create-checkout` records the customer in `stripe_customers` before it opens a session, because Stripe search can lag a new customer by up to a minute. A Checkout session opened in another tab during the few seconds deletion takes is not stopped.
2. Remove Storage objects whose owner is the user through the Storage API, because Storage forbids deleting objects with SQL. `source_files.storage_key` is user-writable, so it never selects an object for deletion.
3. Call `prepare_account_deletion`, which deletes the workspaces the user owns and the user's own sources, threads, messages, and results in other workspaces. Those foreign keys stay `RESTRICT` so that a raw delete of an auth user cannot remove shared work by accident.
4. Delete the auth user with the Auth admin API. Every other foreign key into `auth.users` cascades or sets null, and the `on_auth_user_deleted` trigger clears the user from announcements, which have no foreign key.

Admin-authored rows such as promo codes, curated sources, announcements, and grants to other users survive with the author cleared. Stripe keeps its own invoices and payments for tax and accounting. A new foreign key to `auth.users` must cascade, set null, or be cleared by `prepare_account_deletion`. `supabase/tests/account_deletion_test.sql` fails if its fixture has no row in that table or if any row still mentions the deleted user.

## Authentication and authorization

Supabase Auth owns user sessions. Protected routes provide a client navigation boundary, but server authorization is decisive.
Expand Down
5 changes: 3 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ The stack replays migrations and runs pgTAP tests. The browser portion creates i

### Full-stack feature journeys

The same gate serves every Edge Function and runs `e2e/full-stack/` on desktop and mobile Chromium. Only the paid model provider is replaced: [`e2e/full-stack/fake-openrouter.mjs`](../e2e/full-stack/fake-openrouter.mjs) runs as a `node:22-alpine` container on the Supabase Docker network, and the functions reach it through `OPENROUTER_API_URL`. Real code handles everything else: Auth, demo-code sign-in, RLS, quota, source loading, prompt building, output validation, streaming, and persistence.
The same gate serves every Edge Function and runs `e2e/full-stack/` on desktop and mobile Chromium. Only the paid model provider is replaced: [`e2e/full-stack/fake-openrouter.mjs`](../e2e/full-stack/fake-openrouter.mjs) runs as a `node:22-alpine` container on the Supabase Docker network, and the functions reach it through `OPENROUTER_API_URL`. It also answers the Stripe customer search that account deletion runs, which the `account` function reaches through `STRIPE_API_URL`. Real code handles everything else: Auth, demo-code sign-in, RLS, quota, source loading, prompt building, output validation, streaming, and persistence.

Each test signs in through the Login page with a fresh demo code and deletes that user afterwards. The specs cover:

Expand All @@ -183,12 +183,13 @@ Each test signs in through the Login page with a fresh demo code and deletes tha
- Current Debate context: every request carries the debate's motion, instructions, and sources; Review drops a source and pins a saved case for exactly one request; the pin is stored; Crossfire leaves instructions out; and one-off work sends nothing;
- a judged Lincoln-Douglas sparring round and a Crossfire debrief after a Jev signal attaches to an answer;
- Library activation and deletion, output-language sync across devices, flowchart saves, and Delete All My Data (it must leave another account's rows intact);
- Download my data (only the caller's rows, even when the request names another user) and Delete account (no row in any `public` or `private` table mentions the user afterwards, another account is untouched, and a call without a session or with another user's confirmation deletes nothing);
- a promo code redeemed in Settings that restores AI access, and signed Stripe webhooks that grant and revoke Pro while a forged or replayed one changes nothing;
- admin announcements seen by another user, denied admin access, and a public share link.

Tests assert the rendered result, what the Edge Function sent upstream (read back from the fake provider), and the resulting database rows. They also fail on any uncaught page error or horizontal page overflow. The fake provider rejects prompts it does not recognize, so a prompt change must update it.

These journeys do not prove live provider behavior, web research (the local stack has no search key), real Jev judgement (the fake answers every Jev question with a fixed confident choice), Stripe Checkout and the billing portal (they call the Stripe API; the local key is a placeholder), the `checkout.session.completed` and `invoice.payment_succeeded` webhooks (they fetch the subscription from Stripe), or Google export.
These journeys do not prove live provider behavior, web research (the local stack has no search key), real Jev judgement (the fake answers every Jev question with a fixed confident choice), Stripe Checkout, the billing portal, and subscription cancellation during account deletion (they call the Stripe API; the local key is a placeholder, and the Deno tests cover the cancellation logic), the `checkout.session.completed` and `invoice.payment_succeeded` webhooks (they fetch the subscription from Stripe), or Google export.

### Live provider journeys

Expand Down
143 changes: 143 additions & 0 deletions e2e/full-stack/account-deletion.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { readFile } from "node:fs/promises";
import pg from "pg";
import type { Page } from "@playwright/test";
import { admin, createDemoUser, deleteDemoUser, expect, fake, signIn, stackReady, startDebate, test } from "./stack";

test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)");

const accountUrl = `${process.env.LOCAL_SUPABASE_URL}/functions/v1/account`;

async function addSource(page: Page, title: string, content: string) {
await page.goto("/app/library");
await page.getByRole("button", { name: "Add Source" }).click();
await page.getByRole("textbox", { name: "Source title..." }).fill(title);
await page.getByRole("textbox", { name: "Paste source content..." }).fill(content);
await page.getByRole("button", { name: "Save to Library" }).click();
await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked();
}

/** The signed-in browser session's access token, for calling the function directly. */
async function accessToken(page: Page) {
return page.evaluate(() => {
const key = Object.keys(localStorage).find((candidate) => candidate.startsWith("debate-auth-"));
return key ? (JSON.parse(localStorage.getItem(key)!) as { access_token: string }).access_token : "";
});
}

function callAccount(token: string | null, body: unknown) {
return fetch(accountUrl, {
method: "POST",
headers: {
"content-type": "application/json",
apikey: process.env.SUPABASE_ANON_KEY ?? "",
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
});
}

/** Every public and private table holding a row whose text contains the id. */
async function tablesMentioning(id: string) {
const client = new pg.Client({ connectionString: process.env.COLLABORATION_DATABASE_URL });
await client.connect();
try {
const { rows: tables } = await client.query<{ name: string }>(
"SELECT format('%I.%I', schemaname, tablename) AS name FROM pg_tables WHERE schemaname IN ('public', 'private')",
);
const found: string[] = [];
for (const { name } of tables) {
const { rows } = await client.query(`SELECT 1 FROM ${name} AS t WHERE t::text LIKE $1 LIMIT 1`, [`%${id}%`]);
if (rows.length > 0) found.push(name);
}
return found;
} finally {
await client.end();
}
}

async function seedBystander(page: Page, other: Awaited<ReturnType<typeof createDemoUser>>) {
const context = await page.context().browser()!.newContext();
const bystander = await context.newPage();
await bystander.addInitScript(() => localStorage.setItem("debate-trainer-onboarding", JSON.stringify({ state: { hasCompletedOnboarding: true, isTourRunning: false }, version: 0 })));
await signIn(bystander, other);
await startDebate(bystander, "Bystander motion");
await addSource(bystander, "Bystander study", "BYSTANDER-PRIVATE evidence");
await context.close();
}

test("Download my data saves the account's own data as JSON", async ({ signedIn: page, user }) => {
const other = await createDemoUser("export-bystander");
try {
await seedBystander(page, other);
await startDebate(page, "Exported motion");
await addSource(page, "Exported study", "EXPORTED-PRIVATE evidence");

await page.goto("/app/settings");
const download = page.waitForEvent("download");
await page.getByRole("button", { name: "Download my data" }).click();
const file = await download;
expect(file.suggestedFilename()).toMatch(/^debatelab-data-\d{4}-\d{2}-\d{2}\.json$/);
const text = await readFile((await file.path())!, "utf8");
const exported = JSON.parse(text);

expect(exported.format).toBe("debatelab-account-export");
expect(exported.account).toMatchObject({ id: await user.userId(), email: user.email });
expect(exported.data.debates.map((row: { motion: string }) => row.motion)).toEqual(["Exported motion"]);
expect(exported.data.sources.map((row: { title: string }) => row.title)).toEqual(["Exported study"]);
expect(text).not.toContain("BYSTANDER-PRIVATE");

// Naming another account in the request still returns only the caller's data.
const response = await callAccount(await accessToken(page), { action: "export", userId: await other.userId() });
expect(response.status).toBe(200);
const direct = await response.json();
expect(direct.account.id).toBe(await user.userId());
expect(JSON.stringify(direct)).not.toContain("BYSTANDER-PRIVATE");
} finally {
await deleteDemoUser(other);
}
});

test("Delete account removes every row that references the user and nobody else's", async ({ signedIn: page, user }) => {
const other = await createDemoUser("delete-bystander");
try {
await seedBystander(page, other);
await startDebate(page, "Deleted motion");
await addSource(page, "Deleted study", "Removed with the account.");
const userId = await user.userId();
const otherId = await other.userId();
expect(await tablesMentioning(userId)).not.toEqual([]);

// Without a session, or with another account's confirmation, nothing is deleted.
expect((await callAccount(null, { action: "delete", confirmation: user.email })).status).toBe(401);
expect((await callAccount(null, { action: "export" })).status).toBe(401);
const token = await accessToken(page);
const forged = await callAccount(token, { action: "delete", confirmation: other.email, userId: otherId });
expect(forged.status).toBe(400);
expect((await admin.auth.admin.getUserById(userId)).data.user?.id).toBe(userId);
expect((await admin.auth.admin.getUserById(otherId)).data.user?.id).toBe(otherId);

await page.goto("/app/settings");
await page.getByRole("button", { name: "Delete account" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("button", { name: "Delete account" })).toBeDisabled();
await dialog.getByLabel("Type your email to confirm").fill(user.email);
await dialog.getByRole("button", { name: "Delete account" }).click();
await expect(page.getByText("Account deleted").first()).toBeVisible({ timeout: 20_000 });
await expect(page).toHaveURL(/\/$/);

expect((await admin.auth.admin.getUserById(userId)).data.user).toBeNull();
expect(await tablesMentioning(userId)).toEqual([]);
// Billing was checked in Stripe, not only in the database, before anything was deleted.
const searches = (await fake.requests()).filter((request) => request.feature === "stripe-customer-search");
expect(searches.map((request) => request.user)).toEqual([`metadata['user_id']:'${userId}'`]);

expect((await admin.auth.admin.getUserById(otherId)).data.user?.id).toBe(otherId);
const { data: theirs } = await admin.from("sources").select("title").eq("user_id", otherId);
expect(theirs?.map((row) => row.title)).toEqual(["Bystander study"]);

// The deleted session no longer works.
expect((await callAccount(token, { action: "export" })).status).toBe(401);
} finally {
await deleteDemoUser(other);
}
});
6 changes: 6 additions & 0 deletions e2e/full-stack/fake-openrouter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,12 @@ createServer(async (req, res) => {
requests.push({ feature: "jev", model: body.model, status: 200 });
return json(res, 200, { model: body.model, answers: jevAnswers(body.questions ?? {}), usage: { input_tokens: 40, output_tokens: 8, cost: 0.00001 } });
}
if (url.pathname === "/v1/customers/search" && req.method === "GET") {
// Account deletion searches Stripe (through STRIPE_API_URL) for the user's
// customers. Test accounts have none.
requests.push({ feature: "stripe-customer-search", user: url.searchParams.get("query") ?? "" });
return json(res, 200, { object: "search_result", data: [], has_more: false, next_page: null, url: "/v1/customers/search" });
}
if (url.pathname !== "/api/v1/chat/completions" || req.method !== "POST") {
return json(res, 404, { error: { message: `Unexpected ${req.method} ${url.pathname}` } });
}
Expand Down
32 changes: 32 additions & 0 deletions src/lib/account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { supabase } from '@/lib/supabase/client';
import { getEdgeFunctionErrorMessage } from '@/lib/supabase/edge-function-error';
import { saveBlob } from '@/lib/export-utils';

async function invokeAccount(body: Record<string, unknown>, fallback: string): Promise<unknown> {
const { data: sessionData } = await supabase.auth.getSession();
const accessToken = sessionData.session?.access_token;
if (!accessToken) throw new Error('Please sign in first');

const { data, error } = await supabase.functions.invoke('account', {
headers: { Authorization: `Bearer ${accessToken}` },
body,
});
if (error) throw new Error(await getEdgeFunctionErrorMessage(error) || fallback);
return data;
}

/** Downloads everything the signed-in account owns as one JSON file. */
export async function downloadAccountData(): Promise<void> {
const data = await invokeAccount({ action: 'export' }, 'Could not export your data');
const date = new Date().toISOString().slice(0, 10);
saveBlob(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }), `debatelab-data-${date}.json`);
}

/**
* Permanently deletes the signed-in account. `confirmation` is the account
* email, or DELETE for an account without one. Any Stripe subscription is
* canceled first.
*/
export async function deleteAccount(confirmation: string): Promise<void> {
await invokeAccount({ action: 'delete', confirmation }, 'Could not delete your account');
}
5 changes: 3 additions & 2 deletions src/lib/announcements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export interface AdminAnnouncement extends UserAnnouncement {
targetType: AnnouncementTargetType;
targetUserIds: string[] | null;
deactivatedAt: string | null;
createdBy: string;
createdBy: string | null;
}

export interface CreateAnnouncementInput {
Expand All @@ -41,7 +41,8 @@ const AdminAnnouncementRowSchema = AnnouncementRowSchema.extend({
target_type: z.enum(['all', 'users']),
target_user_ids: z.array(z.uuid()).nullable(),
deactivated_at: z.iso.datetime({ offset: true }).nullable(),
created_by: z.uuid(),
// Null once the author's account is deleted.
created_by: z.uuid().nullable(),
});

const ActiveAnnouncementsResponseSchema = z.object({
Expand Down
2 changes: 1 addition & 1 deletion src/lib/export-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
} from './export-sparring-labels';

/** Triggers a browser download from a Blob. */
function saveBlob(blob: Blob, filename: string): void {
export function saveBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
Expand Down
Loading
Loading