Repository navigation
fix(plugin-approvals): a reminder's notification payload carries no one-tap action token - #24620
Conversation
…ction-link token The reminder fan-out no longer mints ADR-0043 action tokens into the payload it hands to messaging. That payload is stored, so a raw token in it was a usable link at rest. ApprovalNotificationPayload is now closed to links, and issueActionTokens states where a raw token may travel. Claude-Session: https://claude.ai/code/session_01CfRDy7SuTfUqaTu2AEH2Hi Co-authored-by: Claude <noreply@anthropic.com>
…ink in storage Claude-Session: https://claude.ai/code/session_01CfRDy7SuTfUqaTu2AEH2Hi Co-authored-by: Claude <noreply@anthropic.com>
… generic read Claude-Session: https://claude.ai/code/session_01CfRDy7SuTfUqaTu2AEH2Hi Co-authored-by: Claude <noreply@anthropic.com>
…in one assertion Claude-Session: https://claude.ai/code/session_01CfRDy7SuTfUqaTu2AEH2Hi Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CfRDy7SuTfUqaTu2AEH2Hi Co-authored-by: Claude <noreply@anthropic.com>
… ledger Claude-Session: https://claude.ai/code/session_01CfRDy7SuTfUqaTu2AEH2Hi Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CfRDy7SuTfUqaTu2AEH2Hi Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CfRDy7SuTfUqaTu2AEH2Hi Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ab866b8d769fa52dddafc1c6158a836095cb090c && git checkout ab866b8d769fa52dddafc1c6158a836095cb090c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5a871e4e41b110d5d4b0cdcc325a75723740bb31 63262a2561c62a6894be03e42b6a6edac3d10e9d && git checkout -B drift-repro 5a871e4e41b110d5d4b0cdcc325a75723740bb31 && git merge --no-ff 63262a2561c62a6894be03e42b6a6edac3d10e9d
node scripts/docs-audit/affected-docs.mjs --json 5a871e4e41b110d5d4b0cdcc325a75723740bb31
|
Fixes #24564
Clause-②: no — the change narrows what the platform persists; it is a runtime security-boundary change, so it sits on the human floor and its PR stays draft until the maintainer approves
What changes
remind()mints no action token and attaches no link. Each concrete pending approver still gets their ownapproval.remindernotification, with the same title, body, deep link (/system/approvals?request=ID) and dedup key. The payload handed tomessagingno longer carries anactionslist, andremind()no longer callsissueActionTokens.ApprovalNotificationPayloaddrops its optionalactionsmember, so a call site that hands a link tonotify()does not compile. Its doc says why: messaging keeps this payload, verbatim on thesys_notificationevent row and snapshotted onto eachsys_notification_deliveryrow, and ADR-0043's storage axis is that the database holds no usable link.issueActionTokensstates the caller's half of that guarantee. A raw token, or a URL built from one, is never written to a persisted row (a notification payload, an audit row, a log line). It travels only in a message that is sent and not kept.GETandPOST /api/v1/approvals/act), peek and redeem. A redeemed token still decides as the approver it binds.How the links were delivered before, and why the fix is at the producer
Read in this repository at the merge base
de43524:inboxchannel (MessagingService.emit). Delivery preferences only remove channels; they never add one.sys_inbox_message) keeps the title, the body and the action URL, neveractions. The console bell readssys_inbox_message.payload.actions, and noapproval.remindertemplate exists.So no recipient received the links through any channel, and the stored payload rows were the only copies. Carrying the links only on a synchronous send has no send to ride on, and minting them at delivery time would be new plumbing for a channel that renders no links. Neither is needed to make the contract hold, so the producer stops putting them in. Nothing a recipient sees changes.
Tokens stored before this fix
A reminder sent by an earlier build stored its links. Each such token is dead at its 72-hour expiry (
ACTION_TOKEN_TTL_MS), or earlier: once it is used, once its request leavespending, or once its slot is reassigned. No revocation migration ships. 18.0 is a fresh-install release, so no 18.0 deployment holds such rows, and on a development database they lapse within 72 hours. The rows themselves age out under the notification tables' retention, 90 days at most.Tests
Run at head
63262a2(the branch withorigin/mainat5a871e4merged in), unless a line says otherwise.reminder-action-link-at-rest.integration.test.ts. A realObjectQLoverSqlDriver(better-sqlite3, in-memory), the real plugin and the realsys_notificationobject. Live tokens are minted and held in memory, so every scan can fail.sys_notificationserves the reminder event and nothing redeemable in it.actor_idandacted_asare that approver,via_overrideis false) and refuses a replay.approval-service.test.ts(the remind pin asserts per-approver reminders with no link and no mint),approval-notification-body.integration.test.ts(the declared payload fields no longer includeactions),sys-approval-token-generic-door.integration.test.ts(mints withissueActionTokensinstead of reading a link off a reminder).pnpm --filter @objectstack/plugin-approvals test: 82 files, 1116 tests passed.pnpm --filter @objectstack/plugin-approvals typecheck: exit 0.remind()withscripts/ablation-replace.mjs(anchor hit once, file restored to the HEAD blob,git diff HEADempty) and the four test files above were run: 7 tests went red in three of them, namely the three at-rest pins (the positive control stayed green), the remind unit pin and three body-battery pins. The token-door file stayed green, as it mints directly. The at-rest pin's failure lists the action URL both in the emit input and in the storedsys_notificationrow.466a4fd(before theorigin/mainmerge, which touched neither package). After a reminder, the storedsys_notification,sys_notification_deliveryandsys_inbox_messagerows for it carry no action link: one reminder row in each table, zero hits in each.Gates
node scripts/pm/dispatch-gates.mjs --commands, 69 commands) run at63262a2, every one exit 0;dispatch-gates.mjs --ranreconciles 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. That includes thecheck:type-check-debtre-measure, thecheck:query-options-erasureratchet andcheck:test-source-alias.pnpm lintand the rest of the lint workflow), thetest:repovitest project, and the cross-package consumers of@objectstack/plugin-approvals, whose public surface is unchanged.Acceptance notes
remind()gives each concrete approver "their own approve/reject links", and its Non-goals say "the links ride the existing messaging payloads". After this PR no notification carries a link.docs/adr/**is a governed surface outside this card's file surface, so it is not edited here; an amendment status line is the maintainer's call.approvals.email-action-token-door(docs/qa/platform-checklist/areas/approvals.json) captures live tokens from the stored reminder payload. That capture no longer exists, and after this change no REST door mints a token, so the item needs a revision. It is outside this card's file surface.actionLinkUrland the plugin'spublicBaseUrloption keep their contract but have no first-party caller now. They stay, together withissueActionTokensand the action page.payload.actions. This session has no access to that code.remind()counts a slot that a per-group slate lists twice as two innotified, while messaging dedups the second emit. This is unchanged here and was seen on the live boot.packages/qa/dogfood), outside this card's file surface. The in-package pin covers the producer boundary and every stored row of a real engine.Generated by Claude Code