feat: close the two remaining ADR 0023 / Art. 20 follow-ups - #128
Merged
Conversation
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/<id>/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 <info@musiker15.de>
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 <info@musiker15.de>
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 <info@musiker15.de>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the two items the earlier backlog left deliberately open.
1. Create a card from a template while offline
Applying a card template was a POST chain where each step needed the id the previous response returned, so nothing could be queued and offline the whole action aborted with no UI effect.
The chain now mints every id on the client up front (ADR 0023), which makes the steps independent and queueable.
flushOutboxreplays oldest-first, so a child never reaches the server before the row it hangs off.Two create endpoints gain the optional
id+ idempotent-replay contract the card and comment endpoints already had:POST /api/cards/[id]/checklistsPOST /api/checklists/[id]/itemsBoth 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.
One ordering hazard is handled explicitly: once any link of the chain is queued, the rest is queued too, even if the browser still reports itself online.
queuedFetch's per-card ordering guard cannot cover this, because the card create posts to/api/columns/<id>/cardsand shares no ordering key with its children. Without it, a transient failure on the card POST would queue it while the checklist POST fired directly and 404'd.2. Account-wide bulk export, decrypted
Portability existed per board only and required opening each board. There is now one action on the account page that exports every board in every workspace as readable JSON.
It has to live on the client: the server holds only ciphertext and no keys, so
GET /api/me/exportreturns the tree still encrypted.src/lib/export/account-export.tswalks the hierarchy the user already holds (sealed WorkspaceKey to wrapped BoardKey to content) and decrypts locally. One request, then no further network I/O, since the per-board exporter fetches per card and would otherwise mean thousands of requests across an account.Three gaps in the server dump had to be closed first, which also make the plain Art. 15 export more complete:
Card.parentIdwas not selected, so mind-map hierarchies were flattened.CardTemplatewas not selected at all, so the exportedtemplatessection was always empty.{userId, email, role}, which is not new exposure:GET /api/workspaces/[wsId]/membersalready returns exactly that to any member.A board whose key cannot be unwrapped is recorded under
skippedwith a reason rather than dropped, so a partial export never reads as complete.Security notes
account-export.tsperforms key unsealing and unwrapping client-side;tests/unit/account-export.test.tsexercises it with the real crypto rather than stubs, because the point of the feature is that the client can walk the hierarchy unaided.Threat impact: the two new optional-
idendpoints widen the input surface, mitigated by the same charset/length lockdown as the card route plus authorization against the existing row's current board. The export changes add co-member emails to a response that was already reachable via the members endpoint.Verification
typecheck, lint, 330 unit tests and a production build pass. The coverage gate (repaired earlier today) stays green.
Not covered: an end-to-end click-through of the export button needs a seeded DB and an unlocked vault, so the UI section itself is only verified as far as the route compiling and the auth guard redirecting. The decrypt pipeline underneath it has full real-crypto coverage.