You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 65a8eeb
Browse filesBrowse the repository at this point in the historyBrowse files
fix(plugin-approvals): a reminder's notification payload carries no one-tap action token (#24620)
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 own `approval.reminder`
notification, with the same title, body, deep link
(`/system/approvals?request=ID`) and dedup key. The payload handed to
`messaging` no longer carries an `actions` list, and `remind()` no
longer calls `issueActionTokens`.
- **The approval notification payload type no longer accepts links.**
`ApprovalNotificationPayload` drops its optional `actions` member, so a
call site that hands a link to `notify()` does not compile. Its doc says
why: messaging keeps this payload, verbatim on the `sys_notification`
event row and snapshotted onto each `sys_notification_delivery` row, and
ADR-0043's storage axis is that the database holds no usable link.
- **`issueActionTokens` states 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.
- **Untouched:** the token store, the action page (`GET` and `POST
/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`:
- The reminder's emit names no channel, so messaging falls back to the
in-app `inbox` channel (`MessagingService.emit`). Delivery preferences
only remove channels; they never add one.
- The inbox materialization (`sys_inbox_message`) keeps the title, the
body and the action URL, never `actions`. The console bell reads
`sys_inbox_message`.
- No channel in this repository (inbox, email, SMS) reads
`payload.actions`, and no `approval.reminder` template 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 leaves `pending`, 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 with `origin/main` at `5a871e4` merged
in), unless a line says otherwise.
- **New: `reminder-action-link-at-rest.integration.test.ts`.** A real
`ObjectQL` over `SqlDriver` (better-sqlite3, in-memory), the real plugin
and the real `sys_notification` object. Live tokens are minted and held
in memory, so every scan can fail.
- No emit input, and no row of any table in the database, holds an
action-page URL or a string whose SHA-256 is a live token's digest.
- The data door's read of `sys_notification` serves the reminder event
and nothing redeemable in it.
- The reminder mints no token and reaches its approver with the deep
link.
- Positive control: the action page decides once as the bound approver
(`actor_id` and `acted_as` are that approver, `via_override` is false)
and refuses a replay.
- **Updated:** `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 include `actions`),
`sys-approval-token-generic-door.integration.test.ts` (mints with
`issueActionTokens` instead 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.
- **Ablation** (one-time, not committed). The old mint-and-attach was
restored in `remind()` with `scripts/ablation-replace.mjs` (anchor hit
once, file restored to the HEAD blob, `git diff HEAD` empty) 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 stored `sys_notification` row.
- **Live, on a fresh showcase boot of this branch at `466a4fd`** (before
the `origin/main` merge, which touched neither package). After a
reminder, the stored `sys_notification`, `sys_notification_delivery` and
`sys_inbox_message` rows for it carry no action link: one reminder row
in each table, zero hits in each.
## Gates
- Local: the derived set for this diff (`node
scripts/pm/dispatch-gates.mjs --commands`, 69 commands) run at
`63262a2`, every one exit 0; `dispatch-gates.mjs --ran` reconciles 69
derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. That includes the
`check:type-check-debt` re-measure, the `check:query-options-erasure`
ratchet and `check:test-source-alias`.
- Not run locally, left to CI: the repo-wide families (`pnpm lint` and
the rest of the lint workflow), the `test:repo` vitest project, and the
cross-package consumers of `@objectstack/plugin-approvals`, whose public
surface is unchanged.
- CI: in progress when this PR opened.
## Acceptance notes
- **ADR-0043 now says more than the code does in one place.** Its
Mechanics bullet says `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.
- **The platform checklist item `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.
- `actionLinkUrl` and the plugin's `publicBaseUrl` option keep their
contract but have no first-party caller now. They stay, together with
`issueActionTokens` and the action page.
- Not measured: whether any channel outside this repository reads
`payload.actions`. This session has no access to that code.
- `remind()` counts a slot that a per-group slate lists twice as two in
`notified`, while messaging dedups the second emit. This is unchanged
here and was seen on the live boot.
- The end-to-end pin through the real messaging service and the REST
data door belongs to the dogfood layer (`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](https://claude.ai/code/session_01CfRDy7SuTfUqaTu2AEH2Hi)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(plugin-approvals): a reminder's notification no longer carries one-tap approve / reject links, so no stored notification row holds a raw action token
6
+
7
+
Clause-②: no
8
+
9
+
-**What changed.**`remind()` still sends each concrete pending approver their own `approval.reminder` notification, with the same title, body and deep link to the request in the Approvals Inbox. It no longer mints ADR-0043 action tokens for that notification, and the payload it hands to `messaging` has no `actions` list. Messaging stores that payload, on the `sys_notification` event row and on each `sys_notification_delivery` row, so the links were kept in the database as usable links. ADR-0043's storage rule (`sys_approval_token` keeps SHA-256 digests only) exists so that the database holds none.
10
+
-**What a recipient sees.** Nothing changes. A reminder names no channel, so it is delivered to the in-app inbox, and the inbox row never carried the links.
11
+
-**Unchanged.** The action page (`GET` and `POST /api/v1/approvals/act`), `issueActionTokens`, `peekActionToken` and `redeemActionToken` behave exactly as before, and a redeemed token still decides as the approver it binds. The approval notification payload type no longer accepts links, and `issueActionTokens` now states that a raw token may travel only in a message that is sent and not kept.
12
+
-**Rows written before this fix.** A reminder sent by an earlier build stored its links. Each of those tokens stops working at its 72-hour expiry, or earlier: once it is used, once its request is decided or recalled, or once its slot is reassigned. The rows themselves age out under the notification tables' retention, 90 days at most.
0 commit comments