From fe927d5796d9da8b17a9b223f7cc2c551d8887ab Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Sat, 25 Jul 2026 16:32:08 +0200 Subject: [PATCH 1/3] feat(offline): create a card from a template while offline Closes the first of the two follow-ups ADR 0023 left open. Applying a card template was online-only because it is a POST chain in which every step needed the id the previous response returned: card, then a checklist per template checklist, then an item per item, plus a PATCH for items the template ticks. Nothing could be queued, and offline the first fetch threw and the whole action aborted with no UI effect at all. The chain now mints every id on the client up front, which makes the steps independent and queueable. flushOutbox replays oldest-first, so a child never reaches the server before the row it hangs off. Server side, two create endpoints gain the optional `id` + idempotent-replay contract the card and comment endpoints already had: - POST /api/cards/[id]/checklists - POST /api/checklists/[id]/items Both authorize a replay against the board the *existing* row currently lives on, not the one named in the request, which is what stops a caller probing or hijacking a row that has since moved. Same charset/length lockdown on the id as the card route. One ordering hazard is handled explicitly: once any link of the chain has been queued, the rest is queued too, even if the browser still reports itself online. queuedFetch's per-card ordering guard cannot cover this case, because the card create posts to /api/columns//cards and so shares no ordering key with its children; without the guard a transient failure on the card POST would queue it while the checklist POST fired directly and 404'd. Also skips the automation trigger when the chain was queued, matching what createCard already does, since automation only runs on online clients. tests/unit/offline-template.test.ts covers queueing offline, replay order with the client ids intact, an idempotent 200 replay counting as synced, and a transient mid-chain failure keeping the tail queued. 326 tests, lint and typecheck green. Signed-off-by: Musiker15 --- .../[wsId]/boards/[boardId]/board-client.tsx | 141 ++++++++----- src/app/api/cards/[id]/checklists/route.ts | 15 ++ src/app/api/checklists/[id]/items/route.ts | 13 ++ src/lib/repositories/checklists.ts | 62 +++++- tests/unit/offline-template.test.ts | 190 ++++++++++++++++++ 5 files changed, 371 insertions(+), 50 deletions(-) create mode 100644 tests/unit/offline-template.test.ts diff --git a/src/app/(app)/workspaces/[wsId]/boards/[boardId]/board-client.tsx b/src/app/(app)/workspaces/[wsId]/boards/[boardId]/board-client.tsx index 97e2892..6d518c0 100644 --- a/src/app/(app)/workspaces/[wsId]/boards/[boardId]/board-client.tsx +++ b/src/app/(app)/workspaces/[wsId]/boards/[boardId]/board-client.tsx @@ -14,7 +14,7 @@ import { useKeyBundle } from "@/stores/key-bundle"; import { useBoardLive } from "@/hooks/use-board-live"; import { useBoardPresence } from "@/hooks/use-board-presence"; import { loadBoardSnapshot, saveBoardSnapshot } from "@/lib/idb/board-cache"; -import { queuedFetch } from "@/lib/offline/sync"; +import { queueOp, queuedFetch } from "@/lib/offline/sync"; import { downloadCsvExport, downloadExport, @@ -836,6 +836,15 @@ export function BoardClient({ const t = templates.find((x) => x.id === templateId); if (!t) return; + // Every id in the chain is minted up front (ADR 0023). That is what makes + // the whole template applicable offline: previously each POST had to wait + // for the previous response to learn the id it needed, so nothing could be + // queued. With the ids known in advance the ops are independent, and + // flushOutbox replays them oldest-first, so a child never lands before its + // parent. Every create endpoint in the chain upserts idempotently, so a + // partial replay is safe to repeat. + const newCardId = crypto.randomUUID(); + const cardMeta: CardMeta = { title: t.body.title }; if (t.body.description) cardMeta.description = t.body.description; const env = encryptToEnvelope({ @@ -843,89 +852,123 @@ export function BoardClient({ payload: cardMeta, bindTo: "card:new", }); - const res = await fetch(`/api/columns/${columnId}/cards`, { + const { queued, res } = await queuedFetch(currentUserId, { + boardId, + label: `Create card from template "${t.body.title}"`, method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enc_content: env.serialised }), + url: `/api/columns/${columnId}/cards`, + body: { id: newCardId, enc_content: env.serialised }, }); - if (!res.ok) return; - const createdCard = (await res.json()) as ApiCard; + if (!queued && !(res && res.ok)) return; - // Append optimistically with zero checklist items; we'll patch the - // progress after all the items have been posted. + const createdCard = !queued && res ? ((await res.json()) as ApiCard) : null; + + // Once any link of the chain is queued, every later one must be queued + // too, even if the browser still reports itself online. Otherwise a + // transient failure on the card POST would queue it while the checklist + // POST fired directly and 404'd, because the card does not exist server + // side yet. queuedFetch's per-card ordering guard cannot cover this: the + // card create posts to /api/columns//cards, which carries no card id + // in its path, so it shares no ordering key with its children. + let chainQueued = queued; + async function chainSend(op: { + label: string; + method: "POST" | "PATCH"; + url: string; + body: unknown; + }): Promise { + if (chainQueued) { + await queueOp(currentUserId, { boardId, ...op }); + return; + } + const r = await queuedFetch(currentUserId, { boardId, ...op }); + if (r.queued) chainQueued = true; + } + + // Count from the template itself rather than from successful responses: + // offline there are no responses, and the counts have to match what the + // queued ops will create. + let total = 0; + let done = 0; + for (const cl of t.body.checklists ?? []) { + for (const it of cl.items ?? []) { + total += 1; + if (it.done) done += 1; + } + } + + // Append optimistically. Offline we synthesize a tail position the same + // way createCard does, so ordering stays stable until the real row lands. + const tailPos = cards + .filter((c) => c.columnId === columnId) + .reduce((m, c) => Math.max(m, c.position), 0); setCards((prev) => [ ...prev, { - id: createdCard.id, - columnId: createdCard.columnId, + id: createdCard?.id ?? newCardId, + columnId: createdCard?.columnId ?? columnId, title: t.body.title, description: t.body.description, - position: createdCard.position, - dueAt: createdCard.dueAt, - startAt: createdCard.startAt, - archived: createdCard.archived, - labelIds: createdCard.labelIds, - assigneeIds: createdCard.assigneeIds, - milestoneId: createdCard.milestoneId, - parentId: createdCard.parentId, - checklistProgress: { total: 0, done: 0 }, + position: createdCard?.position ?? tailPos + 1000, + dueAt: createdCard?.dueAt ?? null, + startAt: createdCard?.startAt ?? null, + archived: createdCard?.archived ?? false, + labelIds: createdCard?.labelIds ?? [], + assigneeIds: createdCard?.assigneeIds ?? [], + milestoneId: createdCard?.milestoneId ?? null, + parentId: createdCard?.parentId ?? null, + checklistProgress: { total, done }, }, ]); - let total = 0; - let done = 0; for (const cl of t.body.checklists ?? []) { + const checklistId = crypto.randomUUID(); const titleEnv = encryptToEnvelope({ key: boardKey, payload: { title: cl.title }, bindTo: "checklist:new", }); - const clRes = await fetch(`/api/cards/${createdCard.id}/checklists`, { + await chainSend({ + label: `Add checklist "${cl.title}"`, method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enc_title: titleEnv.serialised }), + url: `/api/cards/${newCardId}/checklists`, + body: { id: checklistId, enc_title: titleEnv.serialised }, }); - if (!clRes.ok) continue; - const createdCl = (await clRes.json()) as { id: string }; for (const it of cl.items ?? []) { + const itemId = crypto.randomUUID(); const itEnv = encryptToEnvelope({ key: boardKey, payload: { content: it.content }, bindTo: "checklist-item:new", }); - const itemRes = await fetch(`/api/checklists/${createdCl.id}/items`, { + await chainSend({ + label: "Add checklist item", method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enc_content: itEnv.serialised }), + url: `/api/checklists/${checklistId}/items`, + body: { id: itemId, enc_content: itEnv.serialised }, }); - if (!itemRes.ok) continue; - total += 1; if (it.done) { - // Persist initial `done` state for items that were ticked - // in the template. - const item = (await itemRes.json()) as { id: string }; - done += 1; - await fetch(`/api/checklist-items/${item.id}`, { + // Persist the initial `done` state for items ticked in the template. + await chainSend({ + label: "Tick checklist item", method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ done: true }), + url: `/api/checklist-items/${itemId}`, + body: { done: true }, }); } } } - if (total > 0) { - setCards((prev) => - prev.map((c) => - c.id === createdCard.id ? { ...c, checklistProgress: { total, done } } : c, - ), - ); + + bumpActivity(); + // Automation runs on online clients only; skip the trigger emit offline. + if (!queued) { + emitAutomationTrigger({ + type: "card_created", + cardId: newCardId, + columnId, + }); } - emitAutomationTrigger({ - type: "card_created", - cardId: createdCard.id, - columnId: createdCard.columnId, - }); } async function deleteTemplate(templateId: string): Promise { diff --git a/src/app/api/cards/[id]/checklists/route.ts b/src/app/api/cards/[id]/checklists/route.ts index cc3f5f0..fbf0c69 100644 --- a/src/app/api/cards/[id]/checklists/route.ts +++ b/src/app/api/cards/[id]/checklists/route.ts @@ -13,6 +13,20 @@ interface RouteContext { } const createSchema = z.object({ + /** + * Optional client-generated checklist id for offline create (ADR 0023). + * Needed because creating a card from a template is a POST chain in which + * each step needs the previous step's id: minting the ids up front makes + * the whole chain queueable. A replay of the same id returns the existing + * checklist (re-authorized against its current card) instead of a + * duplicate. Same charset/length lockdown as the card route. + */ + id: z + .string() + .min(20) + .max(64) + .regex(/^[A-Za-z0-9_-]+$/) + .optional(), enc_title: z.string().min(8).max(8192), /** * Optional idempotency token for the automation `append_checklist` @@ -50,6 +64,7 @@ export async function POST(req: NextRequest, ctx: RouteContext) { const c = await Checklists.create({ cardId: id, userId, + ...(body.id ? { id: body.id } : {}), enc_title: body.enc_title, }); return NextResponse.json(c, { status: 201 }); diff --git a/src/app/api/checklists/[id]/items/route.ts b/src/app/api/checklists/[id]/items/route.ts index b1ed1e9..ceb4a4d 100644 --- a/src/app/api/checklists/[id]/items/route.ts +++ b/src/app/api/checklists/[id]/items/route.ts @@ -12,6 +12,18 @@ interface RouteContext { } const createSchema = z.object({ + /** + * Optional client-generated item id for offline create (ADR 0023) — see the + * checklist route for the rationale. A replay of the same id returns the + * existing item (re-authorized against its current board) rather than + * appending a duplicate. + */ + id: z + .string() + .min(20) + .max(64) + .regex(/^[A-Za-z0-9_-]+$/) + .optional(), enc_content: z.string().min(8).max(8192), }); @@ -24,6 +36,7 @@ export async function POST(req: NextRequest, ctx: RouteContext) { const item = await Checklists.addItem({ checklistId: id, userId, + ...(body.id ? { id: body.id } : {}), enc_content: body.enc_content, }); return NextResponse.json(item, { status: 201 }); diff --git a/src/lib/repositories/checklists.ts b/src/lib/repositories/checklists.ts index b34e1a7..2c7a046 100644 --- a/src/lib/repositories/checklists.ts +++ b/src/lib/repositories/checklists.ts @@ -91,8 +91,40 @@ export async function listForCard(opts: { export async function create(opts: { cardId: string; userId: string; + /** Optional client-generated id for offline create (ADR 0023). */ + id?: string; enc_title: string; }): Promise { + // Idempotent replay (ADR 0023): a client-minted id that already exists means + // the offline outbox is replaying this op. Re-authorize against the row's + // CURRENT card, then return it unchanged instead of creating a duplicate. + // Authorizing on the existing row (not the requested cardId) is what stops a + // caller from probing or hijacking a checklist that has since moved. + if (opts.id) { + const existing = await prisma.checklist.findUnique({ + where: { id: opts.id }, + select: { id: true, cardId: true, enc_title: true, position: true, items: true }, + }); + if (existing) { + const existingBoardId = await boardIdForCard(existing.cardId); + await requireBoardWrite({ boardId: existingBoardId, userId: opts.userId }); + return { + id: existing.id, + cardId: existing.cardId, + enc_title: existing.enc_title, + position: existing.position, + items: existing.items + .sort((a, b) => a.position - b.position) + .map((i) => ({ + id: i.id, + checklistId: i.checklistId, + enc_content: i.enc_content, + done: i.done, + position: i.position, + })), + }; + } + } const boardId = await boardIdForCard(opts.cardId); await requireBoardWrite({ boardId, userId: opts.userId }); const tail = await prisma.checklist.findFirst({ @@ -102,7 +134,12 @@ export async function create(opts: { }); const position = (tail?.position ?? 0) + 1; const created = await prisma.checklist.create({ - data: { cardId: opts.cardId, enc_title: opts.enc_title, position }, + data: { + ...(opts.id ? { id: opts.id } : {}), + cardId: opts.cardId, + enc_title: opts.enc_title, + position, + }, include: { items: true }, }); return { @@ -136,8 +173,30 @@ export async function remove(opts: { checklistId: string; userId: string }): Pro export async function addItem(opts: { checklistId: string; userId: string; + /** Optional client-generated id for offline create (ADR 0023). */ + id?: string; enc_content: string; }): Promise { + // Idempotent replay (ADR 0023) — same contract as `create` above: authorize + // against the board the existing item currently lives on, then return it + // unchanged rather than adding a duplicate. + if (opts.id) { + const existing = await prisma.checklistItem.findUnique({ + where: { id: opts.id }, + select: { + id: true, + checklistId: true, + enc_content: true, + done: true, + position: true, + }, + }); + if (existing) { + const existingBoardId = await boardIdForChecklist(existing.checklistId); + await requireBoardWrite({ boardId: existingBoardId, userId: opts.userId }); + return existing; + } + } const boardId = await boardIdForChecklist(opts.checklistId); await requireBoardWrite({ boardId, userId: opts.userId }); const tail = await prisma.checklistItem.findFirst({ @@ -148,6 +207,7 @@ export async function addItem(opts: { const position = (tail?.position ?? 0) + 1; const item = await prisma.checklistItem.create({ data: { + ...(opts.id ? { id: opts.id } : {}), checklistId: opts.checklistId, enc_content: opts.enc_content, position, diff --git a/tests/unit/offline-template.test.ts b/tests/unit/offline-template.test.ts new file mode 100644 index 0000000..bb43398 --- /dev/null +++ b/tests/unit/offline-template.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +/** + * Offline "create card from template" (ADR 0023 follow-up). + * + * Creating a card from a template is a POST chain: card, then a checklist per + * template checklist, then an item per checklist item, plus a PATCH for items + * the template ticks. It used to be online-only because every step needed the + * id the *previous* response returned, so nothing could be queued. + * + * Minting every id on the client up front removes that dependency. These tests + * pin the two properties the chain relies on: + * 1. offline, the whole chain queues, and replays in parent-before-child + * order with the client ids intact (a child must never reach the server + * before the row it hangs off), + * 2. a replay that hits an already-created row is idempotent, so a partially + * applied chain can safely be replayed in full. + * + * The IDB outbox is mocked with an in-memory store; `navigator.onLine` drives + * the offline branch in `queuedFetch`. + */ + +const store = new Map>(); + +vi.mock("@/lib/idb/outbox", () => ({ + addEntry: vi.fn(async (e: { id: string; userId: string }) => { + store.set(e.id, e); + }), + allEntries: vi.fn(async (u: string) => + [...store.values()] + .filter((e) => e.userId === u) + .sort( + (a, b) => + (a.createdAt as number) - (b.createdAt as number) || + (a.seq as number) - (b.seq as number), + ), + ), + deleteEntry: vi.fn(async (id: string) => { + store.delete(id); + }), + countEntries: vi.fn( + async (u: string) => [...store.values()].filter((e) => e.userId === u).length, + ), +})); + +import { queuedFetch, flushOutbox } from "@/lib/offline/sync"; + +beforeEach(() => { + store.clear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const CARD_ID = "tpl-card-uuid-000000001"; +const CL_ID = "tpl-checklist-uuid-00001"; +const ITEM_ID = "tpl-item-uuid-000000001"; + +/** + * The op sequence board-client emits for a template with one checklist that + * holds one ticked item. Ids are minted before any request goes out. + */ +const CHAIN = [ + { + boardId: "b1", + label: 'Create card from template "Bug report"', + method: "POST" as const, + url: "/api/columns/col1/cards", + body: { id: CARD_ID, enc_content: "v1.nonce.card" }, + }, + { + boardId: "b1", + label: 'Add checklist "Steps"', + method: "POST" as const, + url: `/api/cards/${CARD_ID}/checklists`, + body: { id: CL_ID, enc_title: "v1.nonce.cl" }, + }, + { + boardId: "b1", + label: "Add checklist item", + method: "POST" as const, + url: `/api/checklists/${CL_ID}/items`, + body: { id: ITEM_ID, enc_content: "v1.nonce.item" }, + }, + { + boardId: "b1", + label: "Tick checklist item", + method: "PATCH" as const, + url: `/api/checklist-items/${ITEM_ID}`, + body: { done: true }, + }, +]; + +describe("offline create-from-template", () => { + it("queues the whole chain offline instead of fetching", async () => { + vi.stubGlobal("navigator", { onLine: false }); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + for (const op of CHAIN) { + const { queued, res } = await queuedFetch("u1", op); + expect(queued).toBe(true); + expect(res).toBeNull(); + } + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(store.size).toBe(4); + }); + + it("replays parent before child, with the client ids intact", async () => { + vi.stubGlobal("navigator", { onLine: false }); + for (const op of CHAIN) await queuedFetch("u1", op); + + const calls: { url: string; method: string; body: unknown }[] = []; + const fakeFetch = vi.fn(async (url: string, init?: RequestInit) => { + calls.push({ + url, + method: init?.method ?? "GET", + body: init?.body ? JSON.parse(init.body as string) : undefined, + }); + return { status: 201 } as Response; + }); + + const summary = await flushOutbox("u1", { + fetchFn: fakeFetch as unknown as typeof fetch, + }); + + expect(summary.synced).toBe(4); + expect(summary.remaining).toBe(0); + + // Order is the invariant that makes the chain safe: the card must exist + // before its checklist, the checklist before its item, the item before the + // PATCH that ticks it. + expect(calls.map((c) => c.url)).toEqual([ + "/api/columns/col1/cards", + `/api/cards/${CARD_ID}/checklists`, + `/api/checklists/${CL_ID}/items`, + `/api/checklist-items/${ITEM_ID}`, + ]); + + // The ids the client minted are the ones that reach the server, which is + // what lets the later URLs reference rows that do not exist yet locally. + expect((calls[0]!.body as { id: string }).id).toBe(CARD_ID); + expect((calls[1]!.body as { id: string }).id).toBe(CL_ID); + expect((calls[2]!.body as { id: string }).id).toBe(ITEM_ID); + expect(calls[3]!.body).toEqual({ done: true }); + }); + + it("treats an idempotent replay (200 on an existing row) as synced", async () => { + vi.stubGlobal("navigator", { onLine: false }); + for (const op of CHAIN) await queuedFetch("u1", op); + + // A chain that was already applied server-side: every create returns the + // existing row with 200 rather than 201. Nothing may be re-queued or + // reported as a conflict. + const fakeFetch = vi.fn(async () => ({ status: 200 }) as Response); + const summary = await flushOutbox("u1", { + fetchFn: fakeFetch as unknown as typeof fetch, + }); + + expect(summary.synced).toBe(4); + expect(summary.dropped).toBe(0); + expect(summary.droppedLabels).toEqual([]); + expect(store.size).toBe(0); + }); + + it("stops mid-chain on a transient failure and keeps the tail queued", async () => { + vi.stubGlobal("navigator", { onLine: false }); + for (const op of CHAIN) await queuedFetch("u1", op); + + // The card lands, then the server hiccups. The remaining ops must stay + // queued in order rather than being dropped, otherwise the card would be + // left without the checklist the template promised. + let n = 0; + const fakeFetch = vi.fn(async () => { + n += 1; + return { status: n === 1 ? 201 : 503 } as Response; + }); + + const summary = await flushOutbox("u1", { + fetchFn: fakeFetch as unknown as typeof fetch, + }); + + expect(summary.synced).toBe(1); + expect(summary.dropped).toBe(0); + expect(summary.remaining).toBe(3); + // The flush stops at the first transient failure instead of racing ahead. + expect(fakeFetch).toHaveBeenCalledTimes(2); + }); +}); From 445f93c320a820dbc489a6bf09bdfe1230cc733e Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Sat, 25 Jul 2026 16:37:34 +0200 Subject: [PATCH 2/3] feat(export): account-wide bulk export of every board, decrypted Closes the second follow-up: portability (DSGVO Art. 20) existed per board only, and required opening each board to use it. There is now one action on the account page that exports every board in every workspace as readable JSON. This has to live on the client. The server holds only ciphertext and no keys, so GET /api/me/export returns the whole nested tree still encrypted; the new src/lib/export/account-export.ts walks the key hierarchy the user already holds (sealed WorkspaceKey -> wrapped BoardKey -> content) and decrypts locally. One request, then no further network I/O: the per-board exporter fetches comments, checklists and custom-field values per card, which across a whole account would be thousands of requests. Three gaps in the server dump had to be closed first, all of which also make the plain Art. 15 export more complete: - Card.parentId was not selected, so mind-map hierarchies were flattened. - CardTemplate was not selected at all, so the exported `templates` section was always empty. - Only the caller's own membership row was present, so assignees could not be remapped on re-import (the importer matches by email). Co-members are now included as {userId, email, role}. This is not new exposure: GET /api/workspaces/[wsId]/members already returns exactly that to any member. A board whose key cannot be unwrapped is recorded under `skipped` with a reason rather than being dropped, so a partial export never reads as complete. Same for a workspace whose key will not unseal. Undecryptable individual fields fall back to a placeholder, matching how the board view already refuses to let one bad row blank the whole board. The UI section is gated on an unlocked vault, warns before download that the file is no longer end-to-end encrypted (same wording as the per-board export), and shows decrypted-board progress. tests/unit/account-export.test.ts runs the real crypto rather than stubbing it, since the point of the feature is that the client can walk the hierarchy unaided: full-depth decrypt, members and parent links surviving, a foreign board landing in `skipped`, and progress reporting. 330 tests, typecheck, lint and a production build pass. Signed-off-by: Musiker15 --- src/app/(app)/account/account-client.tsx | 94 +++++ src/lib/export/account-export.ts | 440 +++++++++++++++++++++++ src/lib/export/board-export.ts | 3 +- src/lib/repositories/account.ts | 16 + tests/unit/account-export.test.ts | 268 ++++++++++++++ 5 files changed, 820 insertions(+), 1 deletion(-) create mode 100644 src/lib/export/account-export.ts create mode 100644 tests/unit/account-export.test.ts diff --git a/src/app/(app)/account/account-client.tsx b/src/app/(app)/account/account-client.tsx index 6b25b23..e227f0a 100644 --- a/src/app/(app)/account/account-client.tsx +++ b/src/app/(app)/account/account-client.tsx @@ -7,6 +7,11 @@ import { changePassword, startLogin } from "@/lib/auth/client-prelude"; import { HIBP_ENABLED, pwnedPasswordCount } from "@/lib/auth/pwned"; import * as deviceUnlock from "@/lib/idb/device-unlock"; import { useKeyBundle } from "@/stores/key-bundle"; +import { + buildAccountExport, + downloadAccountExport, + type RawAccountExport, +} from "@/lib/export/account-export"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; @@ -62,6 +67,7 @@ export function AccountClient({ + ); @@ -344,6 +350,94 @@ function DataSection() { ); } +/** + * DSGVO Art. 20 — decrypted, portable export of every board in every + * workspace. The sibling section above ships what the *server* holds, which is + * ciphertext; this one is the readable counterpart, and it can only exist on + * the client because the server has no keys. + */ +function BulkBoardExportSection() { + const userKeyPair = useKeyBundle((s) => s.userKeyPair); + const hydrated = useKeyBundle((s) => s.hydrated); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [progress, setProgress] = useState<{ done: number; total: number } | null>(null); + + const locked = hydrated && !userKeyPair; + + async function exportAllBoards() { + if (!userKeyPair) return; + const ok = window.confirm( + "This downloads a plaintext JSON of every board you can read: cards, " + + "comments, checklists and custom-field values across all workspaces. " + + "Keep the file safe, its contents are no longer end-to-end encrypted.\n\nContinue?", + ); + if (!ok) return; + setBusy(true); + setError(null); + setProgress(null); + try { + const res = await fetch("/api/me/export"); + if (!res.ok) { + setError("Could not fetch your data."); + return; + } + const raw = (await res.json()) as RawAccountExport; + const snapshot = await buildAccountExport({ + raw, + userKeyPair, + exportedAt: new Date().toISOString(), + onProgress: (done, total) => setProgress({ done, total }), + }); + downloadAccountExport(snapshot); + if (snapshot.skipped.length > 0) { + setError( + `${snapshot.skipped.length} board(s) could not be decrypted and are listed under "skipped" in the file.`, + ); + } + } catch { + setError("Could not build the export."); + } finally { + setBusy(false); + setProgress(null); + } + } + + return ( + +

+ Download every board from every workspace as one readable JSON file. Decryption happens in + this browser, so the server never sees the plaintext. +

+ {locked ? ( +

+ Your vault is locked. Open a board once to unlock it, then come back. +

+ ) : ( +
+ +
+ )} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} + /** DSGVO Art. 17 — crypto-shred + scheduled hard delete (#46). */ function DangerSection() { const router = useRouter(); diff --git a/src/lib/export/account-export.ts b/src/lib/export/account-export.ts new file mode 100644 index 0000000..c97ade2 --- /dev/null +++ b/src/lib/export/account-export.ts @@ -0,0 +1,440 @@ +"use client"; + +import { + decryptFromEnvelope, + deriveBoardKey, + fromBase64Url, + unsealWorkspaceKey, + unwrapBoardKey, + type BoardKey, + type UserKeyPair, + type WorkspaceKey, +} from "@/lib/crypto"; +import { downloadBlob, type ExportedBoardV1 } from "@/lib/export/board-export"; + +/** + * Account-wide bulk export (DSGVO Art. 20 portability, account level). + * + * The per-board export covers one board at a time and needs an open board to + * do it. This walks every workspace and every board the user belongs to and + * produces one decrypted file. + * + * Why it is built client-side: the server cannot do this. It holds only + * ciphertext and no keys, so `GET /api/me/export` returns the whole nested + * tree still encrypted. Decryption happens here, walking the key hierarchy + * the user already holds: + * + * userKeyPair --(sealed box)--> WorkspaceKey --(AEAD unwrap)--> BoardKey + * + * One request, then everything is decrypted locally. That is deliberate: the + * per-board exporter fetches comments/checklists/custom-field values per card, + * which across a whole account would be thousands of requests. + * + * Note the asymmetry that is easy to get wrong: a board's *name* is encrypted + * under the WorkspaceKey (boards live in the workspace namespace), while + * everything inside the board is under the BoardKey. + */ + +/** Envelope AAD tags are bound at creation time and never rebound, so every + * decrypt tries the `:new` tag first and falls back to the `:` form. + * Returning a placeholder rather than throwing keeps one unreadable row from + * failing the entire export. */ +function tryDecrypt(opts: { + key: BoardKey | WorkspaceKey; + envelope: string; + tags: string[]; +}): T | null { + for (const bindTo of opts.tags) { + try { + return decryptFromEnvelope({ key: opts.key, envelope: opts.envelope, bindTo }); + } catch { + // try the next binding + } + } + return null; +} + +const UNREADABLE = "(unable to decrypt)"; + +// --------------------------------------------------------------------------- +// Shape of `GET /api/me/export` (ciphertext in, nothing decrypted). +// --------------------------------------------------------------------------- + +interface RawItem { + id: string; + enc_content: string; + done: boolean; + position: number; +} +interface RawChecklist { + id: string; + enc_title: string; + position: number; + items: RawItem[]; +} +interface RawComment { + id: string; + authorId: string; + enc_content: string; + createdAt: string; +} +interface RawCard { + id: string; + columnId: string; + enc_content: string; + position: number; + parentId: string | null; + dueAt: string | null; + startAt: string | null; + archived: boolean; + milestoneId: string | null; + createdAt: string; + labels: { labelId: string }[]; + assignments: { userId: string }[]; + comments: RawComment[]; + checklists: RawChecklist[]; + customValues: { fieldId: string; enc_value: string }[]; +} +interface RawColumn { + id: string; + enc_name: string; + position: number; + isDone: boolean; + wipLimit: number | null; + cards: RawCard[]; +} +interface RawBoard { + id: string; + enc_name: string; + enc_metadata: string | null; + enc_boardKey: string | null; + position: number; + archived: boolean; + labels: { id: string; color: string; enc_name: string }[]; + customFields: { id: string; enc_definition: string; position: number }[]; + cardTemplates: { id: string; enc_template: string; position: number }[]; + columns: RawColumn[]; +} +interface RawMembership { + role: string; + encWorkspaceKey: string; + workspace: { + id: string; + enc_name: string; + members: { userId: string; role: string; user: { email: string } }[]; + boards: RawBoard[]; + }; +} +export interface RawAccountExport { + workspaces: RawMembership[]; +} + +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +/** One board, in the same shape the per-board export emits, plus the ids + * needed to tell boards apart once they all sit in one file. */ +export type ExportedAccountBoard = ExportedBoardV1 & { + boardId: string; + workspaceId: string; + archived: boolean; +}; + +export interface ExportedAccountV1 { + version: 1; + generator: "mskanban"; + exportedAt: string; + workspaces: Array<{ + id: string; + name: string; + role: string; + boards: ExportedAccountBoard[]; + }>; + /** Boards that could not be decrypted at all, so a partial export never + * looks complete. */ + skipped: Array<{ workspaceId: string; boardId: string; reason: string }>; +} + +interface CardMeta { + title: string; + description?: string; +} +interface CustomFieldDef { + name: string; + type: string; + options?: string[]; +} +interface TemplateBody { + title: string; + description?: string; + checklists?: Array<{ title: string; items?: Array<{ content: string; done: boolean }> }>; +} + +function buildBoard(opts: { + raw: RawBoard; + workspaceId: string; + boardKey: BoardKey; + workspaceKey: WorkspaceKey; + members: ExportedBoardV1["members"]; + exportedAt: string; +}): ExportedAccountBoard { + const { raw, boardKey, workspaceKey } = opts; + + // Board name/description live under the WORKSPACE key, not the BoardKey. + const meta = tryDecrypt<{ name: string; description?: string }>({ + key: workspaceKey, + envelope: raw.enc_name, + tags: ["board:new", `board:${raw.id}`], + }); + + const columns = raw.columns + .slice() + .sort((a, b) => a.position - b.position) + .map((c) => ({ + id: c.id, + name: + tryDecrypt<{ name: string }>({ + key: boardKey, + envelope: c.enc_name, + tags: ["column:new", `column:${c.id}`], + })?.name ?? UNREADABLE, + position: c.position, + wipLimit: c.wipLimit, + isDone: c.isDone, + })); + + const labels = raw.labels.map((l) => ({ + id: l.id, + name: + tryDecrypt<{ name: string }>({ + key: boardKey, + envelope: l.enc_name, + tags: ["label:new", `label:${l.id}`], + })?.name ?? "?", + color: l.color, + })); + + const customFields = raw.customFields + .slice() + .sort((a, b) => a.position - b.position) + .map((f) => { + const def = tryDecrypt({ + key: boardKey, + envelope: f.enc_definition, + tags: ["custom-field:new", `custom-field:${f.id}`], + }); + return { + id: f.id, + name: def?.name ?? UNREADABLE, + type: def?.type ?? "text", + ...(def?.options ? { options: def.options } : {}), + position: f.position, + }; + }); + + const templates = raw.cardTemplates + .slice() + .sort((a, b) => a.position - b.position) + .map((t) => + tryDecrypt({ + key: boardKey, + envelope: t.enc_template, + tags: ["card-template:new", `card-template:${t.id}`], + }), + ) + .filter((t): t is TemplateBody => t !== null) + .map((t) => ({ + title: t.title, + ...(t.description ? { description: t.description } : {}), + ...(t.checklists ? { checklists: t.checklists } : {}), + })); + + const cards: ExportedBoardV1["cards"] = []; + for (const col of raw.columns) { + for (const card of col.cards.slice().sort((a, b) => a.position - b.position)) { + const cardMeta = tryDecrypt({ + key: boardKey, + envelope: card.enc_content, + tags: ["card:new", `card:${card.id}`], + }); + cards.push({ + id: card.id, + columnId: card.columnId, + parentId: card.parentId, + title: cardMeta?.title ?? UNREADABLE, + ...(cardMeta?.description ? { description: cardMeta.description } : {}), + startAt: card.startAt, + dueAt: card.dueAt, + archived: card.archived, + position: card.position, + labelIds: card.labels.map((l) => l.labelId), + assigneeUserIds: card.assignments.map((a) => a.userId), + comments: card.comments.map((cm) => ({ + authorId: cm.authorId, + body: + tryDecrypt<{ body: string }>({ + key: boardKey, + envelope: cm.enc_content, + tags: ["comment:new", `comment:${cm.id}`], + })?.body ?? UNREADABLE, + createdAt: cm.createdAt, + })), + checklists: card.checklists + .slice() + .sort((a, b) => a.position - b.position) + .map((cl) => ({ + title: + tryDecrypt<{ title: string }>({ + key: boardKey, + envelope: cl.enc_title, + tags: ["checklist:new", `checklist:${cl.id}`], + })?.title ?? UNREADABLE, + items: cl.items + .slice() + .sort((a, b) => a.position - b.position) + .map((it) => ({ + content: + tryDecrypt<{ content: string }>({ + key: boardKey, + envelope: it.enc_content, + tags: ["checklist-item:new", `checklist-item:${it.id}`], + })?.content ?? UNREADABLE, + done: it.done, + })), + })), + customFieldValues: card.customValues.map((v) => ({ + fieldId: v.fieldId, + value: + tryDecrypt<{ value: unknown }>({ + key: boardKey, + envelope: v.enc_value, + tags: ["custom-field-value:new"], + })?.value ?? null, + })), + }); + } + } + + return { + version: 1, + generator: "mskanban", + exportedAt: opts.exportedAt, + board: { + name: meta?.name ?? UNREADABLE, + ...(meta?.description ? { description: meta.description } : {}), + }, + columns, + labels, + members: opts.members, + customFields, + cards, + templates, + boardId: raw.id, + workspaceId: opts.workspaceId, + archived: raw.archived, + }; +} + +/** + * Decrypt a raw `/api/me/export` payload into every board it contains. + * Pure apart from the crypto calls, so it is unit-testable without a server. + * `onProgress` reports boards finished / total for the UI. + */ +export async function buildAccountExport(opts: { + raw: RawAccountExport; + userKeyPair: UserKeyPair; + exportedAt: string; + onProgress?: (done: number, total: number) => void; +}): Promise { + const { raw, userKeyPair } = opts; + const total = raw.workspaces.reduce((n, m) => n + m.workspace.boards.length, 0); + let done = 0; + + const workspaces: ExportedAccountV1["workspaces"] = []; + const skipped: ExportedAccountV1["skipped"] = []; + + for (const membership of raw.workspaces) { + const ws = membership.workspace; + let wsKey: WorkspaceKey; + try { + wsKey = await unsealWorkspaceKey(fromBase64Url(membership.encWorkspaceKey), userKeyPair); + } catch { + // Without the workspace key nothing below it is readable. Record every + // board so the file states what is missing instead of omitting it. + for (const b of ws.boards) { + skipped.push({ workspaceId: ws.id, boardId: b.id, reason: "workspace key unavailable" }); + done += 1; + } + opts.onProgress?.(done, total); + continue; + } + + const members = ws.members.map((m) => ({ + userId: m.userId, + email: m.user.email, + role: m.role, + })); + + const boards: ExportedAccountBoard[] = []; + for (const rawBoard of ws.boards) { + let boardKey: BoardKey; + try { + // A null wrap means a legacy board whose key is derived, not wrapped + // (ADR 0015). + boardKey = rawBoard.enc_boardKey + ? await unwrapBoardKey(rawBoard.enc_boardKey, wsKey) + : await deriveBoardKey(wsKey, rawBoard.id); + } catch { + skipped.push({ + workspaceId: ws.id, + boardId: rawBoard.id, + reason: "board key could not be unwrapped", + }); + done += 1; + opts.onProgress?.(done, total); + continue; + } + boards.push( + buildBoard({ + raw: rawBoard, + workspaceId: ws.id, + boardKey, + workspaceKey: wsKey, + members, + exportedAt: opts.exportedAt, + }), + ); + done += 1; + opts.onProgress?.(done, total); + } + + workspaces.push({ + id: ws.id, + name: + tryDecrypt<{ name: string }>({ + key: wsKey, + envelope: ws.enc_name, + tags: ["workspace:new", `workspace:${ws.id}`], + })?.name ?? UNREADABLE, + role: membership.role, + boards, + }); + } + + return { + version: 1, + generator: "mskanban", + exportedAt: opts.exportedAt, + workspaces, + skipped, + }; +} + +export function downloadAccountExport(snapshot: ExportedAccountV1): void { + downloadBlob({ + content: JSON.stringify(snapshot, null, 2), + mime: "application/json", + filename: "mskanban-all-boards.json", + }); +} diff --git a/src/lib/export/board-export.ts b/src/lib/export/board-export.ts index 91cfddc..a3ac64e 100644 --- a/src/lib/export/board-export.ts +++ b/src/lib/export/board-export.ts @@ -669,7 +669,8 @@ function safeFilename(name: string): string { return name.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "board"; } -function downloadBlob(opts: { content: string; mime: string; filename: string }): void { +/** Shared by the account-wide bulk export (`account-export.ts`). */ +export function downloadBlob(opts: { content: string; mime: string; filename: string }): void { const blob = new Blob([opts.content], { type: opts.mime }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); diff --git a/src/lib/repositories/account.ts b/src/lib/repositories/account.ts index 405e689..6140175 100644 --- a/src/lib/repositories/account.ts +++ b/src/lib/repositories/account.ts @@ -54,6 +54,14 @@ export async function buildAccountExport(userId: string) { enc_name: true, enc_metadata: true, createdAt: true, + // Co-members, so an exported board can be re-imported with its + // assignees intact (the importer matches them by email). This is not + // new exposure: GET /api/workspaces/[wsId]/members already returns + // {userId, email, role} for every member to any member. Kept to + // those three fields for that reason. + members: { + select: { userId: true, role: true, user: { select: { email: true } } }, + }, boards: { select: { id: true, @@ -64,6 +72,10 @@ export async function buildAccountExport(userId: string) { archived: true, labels: { select: { id: true, color: true, enc_name: true } }, customFields: { select: { id: true, enc_definition: true, position: true } }, + // Card templates are board content the user authored, so an + // Art. 15/20 export has to carry them; the board export format + // has a `templates` section that would otherwise always be empty. + cardTemplates: { select: { id: true, enc_template: true, position: true } }, milestones: { select: { id: true, enc_name: true, startAt: true, endAt: true, archived: true }, }, @@ -80,6 +92,10 @@ export async function buildAccountExport(userId: string) { columnId: true, enc_content: true, position: true, + // Mind-map parent link. Server-visible metadata, and the + // board export format round-trips it, so leaving it out + // silently flattened the hierarchy on re-import. + parentId: true, dueAt: true, startAt: true, archived: true, diff --git a/tests/unit/account-export.test.ts b/tests/unit/account-export.test.ts new file mode 100644 index 0000000..427ae54 --- /dev/null +++ b/tests/unit/account-export.test.ts @@ -0,0 +1,268 @@ +// @vitest-environment node +import { beforeAll, describe, expect, it } from "vitest"; +import { + ready, + generateWorkspaceKey, + generateBoardKey, + generateUserKeyPair, + wrapBoardKey, + sealWorkspaceKeyFor, + encryptToEnvelope, + toBase64Url, +} from "@/lib/crypto"; +import { buildAccountExport, type RawAccountExport } from "@/lib/export/account-export"; + +/** + * Account-wide bulk export (DSGVO Art. 20). + * + * These tests run the REAL crypto rather than stubbing it, because the whole + * point of the feature is that the client can walk the key hierarchy on its + * own: sealed WorkspaceKey -> wrapped BoardKey -> content. Stubbing that away + * would leave the interesting part unproven. + * + * The fixture is built the same way the app builds it, so a mistake in the + * AAD bindings or in which key a field belongs to shows up as an undecryptable + * value instead of passing silently. + */ + +const EXPORTED_AT = "2026-07-25T00:00:00.000Z"; + +async function fixture() { + const user = await generateUserKeyPair(); + const wsKey = await generateWorkspaceKey(); + const boardKey = await generateBoardKey(); + const boardId = "board_1"; + + const raw: RawAccountExport = { + workspaces: [ + { + role: "OWNER", + encWorkspaceKey: toBase64Url(await sealWorkspaceKeyFor(wsKey, user.publicKey)), + workspace: { + id: "ws_1", + // Workspace + board names are sealed under the WORKSPACE key. + enc_name: encryptToEnvelope({ + key: wsKey, + payload: { name: "Acme" }, + bindTo: "workspace:new", + }).serialised, + members: [ + { userId: "u1", role: "OWNER", user: { email: "owner@example.com" } }, + { userId: "u2", role: "MEMBER", user: { email: "member@example.com" } }, + ], + boards: [ + { + id: boardId, + enc_name: encryptToEnvelope({ + key: wsKey, + payload: { name: "Roadmap" }, + bindTo: "board:new", + }).serialised, + enc_metadata: null, + enc_boardKey: await wrapBoardKey(boardKey, wsKey), + position: 0, + archived: false, + labels: [ + { + id: "lab_1", + color: "#ff0000", + enc_name: encryptToEnvelope({ + key: boardKey, + payload: { name: "urgent" }, + bindTo: "label:new", + }).serialised, + }, + ], + customFields: [], + cardTemplates: [ + { + id: "tpl_1", + position: 0, + enc_template: encryptToEnvelope({ + key: boardKey, + payload: { title: "Bug report" }, + bindTo: "card-template:new", + }).serialised, + }, + ], + columns: [ + { + id: "col_1", + position: 0, + isDone: false, + wipLimit: null, + enc_name: encryptToEnvelope({ + key: boardKey, + payload: { name: "Backlog" }, + bindTo: "column:new", + }).serialised, + cards: [ + { + id: "card_1", + columnId: "col_1", + position: 1000, + parentId: null, + dueAt: null, + startAt: null, + archived: false, + milestoneId: null, + createdAt: EXPORTED_AT, + enc_content: encryptToEnvelope({ + key: boardKey, + payload: { title: "Parent card" }, + bindTo: "card:new", + }).serialised, + labels: [{ labelId: "lab_1" }], + assignments: [{ userId: "u2" }], + comments: [ + { + id: "cm_1", + authorId: "u2", + createdAt: EXPORTED_AT, + enc_content: encryptToEnvelope({ + key: boardKey, + payload: { body: "looks good" }, + bindTo: "comment:new", + }).serialised, + }, + ], + checklists: [ + { + id: "cl_1", + position: 0, + enc_title: encryptToEnvelope({ + key: boardKey, + payload: { title: "Steps" }, + bindTo: "checklist:new", + }).serialised, + items: [ + { + id: "it_1", + position: 0, + done: true, + enc_content: encryptToEnvelope({ + key: boardKey, + payload: { content: "reproduce" }, + bindTo: "checklist-item:new", + }).serialised, + }, + ], + }, + ], + customValues: [], + }, + { + id: "card_2", + columnId: "col_1", + position: 2000, + // Mind-map link, which used to be dropped from the + // account export entirely. + parentId: "card_1", + dueAt: null, + startAt: null, + archived: false, + milestoneId: null, + createdAt: EXPORTED_AT, + enc_content: encryptToEnvelope({ + key: boardKey, + payload: { title: "Child card" }, + bindTo: "card:new", + }).serialised, + labels: [], + assignments: [], + comments: [], + checklists: [], + customValues: [], + }, + ], + }, + ], + }, + ], + }, + }, + ], + }; + return { raw, user, wsKey, boardKey }; +} + +describe("account-wide bulk export", () => { + beforeAll(async () => { + await ready(); + }); + + it("decrypts every level of the hierarchy from the user key alone", async () => { + const { raw, user } = await fixture(); + + const out = await buildAccountExport({ raw, userKeyPair: user, exportedAt: EXPORTED_AT }); + + expect(out.skipped).toEqual([]); + expect(out.workspaces).toHaveLength(1); + const ws = out.workspaces[0]!; + expect(ws.name).toBe("Acme"); + expect(ws.role).toBe("OWNER"); + + const board = ws.boards[0]!; + expect(board.board.name).toBe("Roadmap"); + expect(board.boardId).toBe("board_1"); + expect(board.workspaceId).toBe("ws_1"); + expect(board.columns[0]!.name).toBe("Backlog"); + expect(board.labels[0]!.name).toBe("urgent"); + expect(board.templates[0]!.title).toBe("Bug report"); + + const parent = board.cards.find((c) => c.id === "card_1")!; + expect(parent.title).toBe("Parent card"); + expect(parent.comments[0]!.body).toBe("looks good"); + expect(parent.checklists[0]!.title).toBe("Steps"); + expect(parent.checklists[0]!.items[0]).toEqual({ content: "reproduce", done: true }); + expect(parent.labelIds).toEqual(["lab_1"]); + expect(parent.assigneeUserIds).toEqual(["u2"]); + }); + + it("carries members and mind-map parent links so a re-import can rebuild them", async () => { + const { raw, user } = await fixture(); + const out = await buildAccountExport({ raw, userKeyPair: user, exportedAt: EXPORTED_AT }); + const board = out.workspaces[0]!.boards[0]!; + + // Assignees are matched by email on import, so the member list has to ship. + expect(board.members).toEqual([ + { userId: "u1", email: "owner@example.com", role: "OWNER" }, + { userId: "u2", email: "member@example.com", role: "MEMBER" }, + ]); + expect(board.cards.find((c) => c.id === "card_2")!.parentId).toBe("card_1"); + }); + + it("records a board it cannot decrypt instead of silently omitting it", async () => { + const { raw, user } = await fixture(); + // Simulate a board wrapped under a key this user does not hold. + const foreignWsKey = await generateWorkspaceKey(); + const foreignBoardKey = await generateBoardKey(); + raw.workspaces[0]!.workspace.boards[0]!.enc_boardKey = await wrapBoardKey( + foreignBoardKey, + foreignWsKey, + ); + + const out = await buildAccountExport({ raw, userKeyPair: user, exportedAt: EXPORTED_AT }); + + expect(out.workspaces[0]!.boards).toHaveLength(0); + expect(out.skipped).toEqual([ + { + workspaceId: "ws_1", + boardId: "board_1", + reason: "board key could not be unwrapped", + }, + ]); + }); + + it("reports progress per board", async () => { + const { raw, user } = await fixture(); + const seen: Array<[number, number]> = []; + await buildAccountExport({ + raw, + userKeyPair: user, + exportedAt: EXPORTED_AT, + onProgress: (done, total) => seen.push([done, total]), + }); + expect(seen).toEqual([[1, 1]]); + }); +}); From 6c7903f3e55525bea8bf00939abe777646b0c337 Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Sun, 26 Jul 2026 10:21:21 +0200 Subject: [PATCH 3/3] test(e2e): prove both follow-ups against a real stack Adds an end-to-end run for the two features, against a real database and the real crypto rather than fixtures. The unit tests cover the pipelines in isolation; this proves the pieces line up with what the server actually stores and what the key hierarchy actually holds. The spec registers a user, builds a board with a card that has a checklist, saves it as a template, then goes offline via context.setOffline and applies the template. That drives the genuine navigator.onLine branch, so the outbox is exercised for real. After reconnecting it asserts against the SERVER that two cards exist and each carries a checklist with its item, which can only happen if the queued child POSTs landed against the card id the client minted while offline. It then downloads the account-wide export and asserts on plaintext in the file: workspace name, board name, column, both card titles, the nested checklist item, a non-empty templates section and the member email. Plaintext can only appear there if sealed workspace key to wrapped board key to content all resolved in the browser. Two things the run taught us, both reflected in the spec: - Asserting via a page reload does not work. The reload drops the in-memory master key and lands on the unlock gate (ADR 0009), which says nothing about what was persisted. The checks query the API instead, and the account page is reached by client-side navigation so the key bundle survives. - Those queries run inside the page. The session cookie is SameSite=Strict, so Playwright's separate request context does not carry it and every call would 401 regardless of the data. Also gives the template-picker button an aria-label. It previously announced only the template title with no indication of what activating it does, and the name collided with the card's drag handle and the delete-template button. Full E2E suite green (15 tests, including the WCAG specs), 330 unit tests, typecheck and lint clean. Signed-off-by: Musiker15 --- .../[wsId]/boards/[boardId]/kanban-view.tsx | 1 + tests/e2e/export-and-offline-template.spec.ts | 175 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 tests/e2e/export-and-offline-template.spec.ts diff --git a/src/app/(app)/workspaces/[wsId]/boards/[boardId]/kanban-view.tsx b/src/app/(app)/workspaces/[wsId]/boards/[boardId]/kanban-view.tsx index bc8083f..a1ea2ed 100644 --- a/src/app/(app)/workspaces/[wsId]/boards/[boardId]/kanban-view.tsx +++ b/src/app/(app)/workspaces/[wsId]/boards/[boardId]/kanban-view.tsx @@ -303,6 +303,7 @@ export function KanbanView({ onCreateFromTemplate(col.id, t.id); setTemplatePickerFor(null); }} + aria-label={`Create card from template ${t.body.title}`} className="flex-1 truncate rounded px-2 py-1 text-left text-xs hover:bg-[color:var(--color-card)]" title={t.body.description ?? ""} > diff --git a/tests/e2e/export-and-offline-template.spec.ts b/tests/e2e/export-and-offline-template.spec.ts new file mode 100644 index 0000000..d5c9c53 --- /dev/null +++ b/tests/e2e/export-and-offline-template.spec.ts @@ -0,0 +1,175 @@ +import { test, expect } from "@playwright/test"; + +/** + * End-to-end coverage for the two backlog follow-ups: + * + * 1. Creating a card from a template while OFFLINE. The chain (card -> + * checklist -> item) used to need each server response to learn the next + * id, so it could not be queued. Playwright's `context.setOffline` drives + * the real `navigator.onLine` branch, so this exercises the outbox for + * real rather than a mocked store. + * + * 2. The account-wide bulk export. The unit tests prove the decrypt pipeline + * against a hand-built fixture; only an end-to-end run proves the pieces + * line up with what the server actually stores and what the key hierarchy + * actually holds. The assertion is deliberately on PLAINTEXT appearing in + * the downloaded file: that can only happen if sealed workspace key -> + * wrapped board key -> content all resolved. + * + * Registration runs two sequential Argon2id derivations under libsodium-WASM, + * which is slow on CI hardware, hence the generous timeout (same reasoning as + * kanban.spec.ts). + */ +test.setTimeout(240_000); + +test("offline template create, then account-wide decrypted export", async ({ page, context }) => { + page.on("pageerror", (err) => console.log(`[browser pageerror] ${err.message}`)); + page.on("console", (msg) => { + if (msg.type() === "error") console.log(`[browser error] ${msg.text()}`); + }); + + const email = `e2e-export-${Date.now()}@example.com`; + const password = "LongEnoughPassword!23"; + + // ---------------- Register ---------------- + await page.goto("/register"); + await page.getByLabel(/email/i).fill(email); + await page.getByLabel(/^password \(/i).fill(password); + await page.getByLabel(/confirm password/i).fill(password); + await page.getByRole("button", { name: /create account/i }).click(); + + await expect(page.getByRole("heading", { name: /write this down/i })).toBeVisible({ + timeout: 120_000, + }); + await page.getByLabel(/i have copied or printed/i).check(); + await page.getByRole("button", { name: /continue and create account/i }).click(); + await expect(page).toHaveURL(/\/workspaces$/, { timeout: 30_000 }); + + // ---------------- Workspace + board ---------------- + await page.getByPlaceholder("Name").fill("Export WS"); + await page.getByRole("button", { name: /^create$/i }).click(); + await page.getByRole("link", { name: /export ws/i }).click(); + + await page.getByPlaceholder(/new board name/i).fill("Export Board"); + await page.getByRole("button", { name: /add board/i }).click(); + await page.getByRole("link", { name: /export board/i }).click(); + + const backlog = page.getByRole("region", { name: /backlog/i }); + await expect(backlog).toBeVisible(); + + // ---------------- A card with a checklist ---------------- + await backlog.getByRole("button", { name: /add a card/i }).click(); + await backlog.getByPlaceholder(/card title/i).fill("Seed card"); + await backlog.getByRole("button", { name: /^add$/i }).click(); + await expect(backlog.getByText("Seed card")).toBeVisible(); + + await backlog.getByRole("button", { name: /open card/i }).first().click(); + const drawer = page.getByRole("dialog", { name: /card details/i }); + await expect(drawer).toBeVisible(); + + await drawer.getByRole("button", { name: /new checklist/i }).click(); + await drawer.getByPlaceholder("Checklist title").fill("Steps"); + await drawer.getByRole("button", { name: /^create$/i }).click(); + await expect(drawer.getByText("Steps")).toBeVisible(); + + await drawer.getByPlaceholder(/add an item/i).fill("Do the thing"); + await drawer.getByRole("button", { name: /^add$/i }).click(); + await expect(drawer.getByText("Do the thing")).toBeVisible(); + + // Save it as a template, so the template carries a checklist chain. That is + // what makes the offline case non-trivial: it is a multi-step POST chain. + await drawer.getByRole("button", { name: /save as template/i }).click(); + await drawer.getByRole("button", { name: /^close$/i }).click(); + await expect(drawer).toBeHidden(); + + // ---------------- Create from template, OFFLINE ---------------- + await context.setOffline(true); + + await backlog.getByRole("button", { name: /from template/i }).click(); + await backlog.getByRole("button", { name: /create card from template seed card/i }).click(); + + // The optimistic card must appear even though nothing reached the server. + // Before this change the whole action aborted silently while offline. + await expect(backlog.getByText("Seed card")).toHaveCount(2, { timeout: 15_000 }); + + // ---------------- Reconnect and let the outbox drain ---------------- + const boardId = page.url().split("/boards/")[1]!.split(/[/?#]/)[0]!; + + await context.setOffline(false); + await page.evaluate(() => window.dispatchEvent(new Event("online"))); + + // Assert against the SERVER rather than reloading the page. A reload would + // drop the in-memory master key and land on the unlock gate (ADR 0009), + // which says nothing about whether the queue replayed. Querying the API with + // the same session cookie shows exactly what was persisted. + // The queries run inside the page rather than through page.request: the + // session cookie is SameSite=Strict, which Playwright's separate request + // context does not carry, so it would see 401 regardless of the data. + await expect(async () => { + const state = await page.evaluate(async (bid) => { + const r = await fetch(`/api/boards/${bid}/cards`, { credentials: "include" }); + if (!r.ok) return { status: r.status, cardCount: -1, checklistCount: -1, itemCounts: [] }; + const { cards } = (await r.json()) as { cards: { id: string }[] }; + let checklistCount = 0; + const itemCounts: number[] = []; + for (const c of cards) { + const cr = await fetch(`/api/cards/${c.id}/checklists`, { credentials: "include" }); + const body = (await cr.json()) as { checklists: { items: unknown[] }[] }; + checklistCount += body.checklists.length; + for (const cl of body.checklists) itemCounts.push(cl.items.length); + } + return { status: r.status, cardCount: cards.length, checklistCount, itemCounts }; + }, boardId); + + expect(state.status).toBe(200); + expect(state.cardCount).toBe(2); + // Both the seeded card and the templated copy carry a checklist with its + // item. The copy's only exists if the child POSTs landed against the card + // id the client minted while offline. + expect(state.checklistCount).toBe(2); + expect(state.itemCounts).toEqual([1, 1]); + }).toPass({ timeout: 60_000 }); + + // ---------------- Account-wide decrypted export ---------------- + // Client-side navigation on purpose: a full load would discard the in-memory + // key bundle and the export section would (correctly) report a locked vault. + await page.getByRole("link", { name: email }).click(); + await expect(page.getByRole("heading", { name: /account/i }).first()).toBeVisible({ + timeout: 20_000, + }); + page.once("dialog", (d) => void d.accept()); + + const downloadPromise = page.waitForEvent("download", { timeout: 90_000 }); + await page.getByRole("button", { name: /download all boards/i }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe("mskanban-all-boards.json"); + + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const c of stream) chunks.push(c as Buffer); + const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); + + // Plaintext in the file is the whole point: it can only be there if the + // client walked sealed workspace key -> wrapped board key -> content. + expect(parsed.version).toBe(1); + expect(parsed.skipped).toEqual([]); + const ws = parsed.workspaces.find((w: { name: string }) => w.name === "Export WS"); + expect(ws, "workspace name decrypted").toBeTruthy(); + + const board = ws.boards.find((b: { board: { name: string } }) => b.board.name === "Export Board"); + expect(board, "board name decrypted under the workspace key").toBeTruthy(); + expect(board.columns.map((c: { name: string }) => c.name)).toContain("Backlog"); + + const titles = board.cards.map((c: { title: string }) => c.title); + expect(titles.filter((t: string) => t === "Seed card").length).toBe(2); + + // Nested content decrypts too, and the template section is populated (it was + // always empty before, because the server dump never selected templates). + const withChecklist = board.cards.find( + (c: { checklists: unknown[] }) => c.checklists.length > 0, + ); + expect(withChecklist.checklists[0].title).toBe("Steps"); + expect(withChecklist.checklists[0].items[0].content).toBe("Do the thing"); + expect(board.templates.length).toBeGreaterThan(0); + expect(board.members[0].email).toBe(email); +});