Skip to content

feat: close the two remaining ADR 0023 / Art. 20 follow-ups - #128

Merged
Musiker15 merged 3 commits into
mainfrom
feat/backlog-followups
Jul 26, 2026
Merged

feat: close the two remaining ADR 0023 / Art. 20 follow-ups#128
Musiker15 merged 3 commits into
mainfrom
feat/backlog-followups

Conversation

@Musiker15

Copy link
Copy Markdown
Member

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. flushOutbox replays 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]/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.

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>/cards and 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/export returns the tree still encrypted. src/lib/export/account-export.ts walks 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.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. Co-members now ship as {userId, email, role}, which 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 dropped, so a partial export never reads as complete.

Security notes

⚠️ Crypto-relevant. account-export.ts performs key unsealing and unwrapping client-side; tests/unit/account-export.test.ts exercises 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-id endpoints 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.

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>
@Musiker15
Musiker15 merged commit 87ead73 into main Jul 26, 2026
9 checks passed
@Musiker15
Musiker15 deleted the feat/backlog-followups branch July 26, 2026 08:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant