Skip to content

Commit 3b2e729

Browse files
authored
Merge pull request #1765 from objectstack-ai/feat-approvals-actionable-links
feat(approvals): actionable approval links — single-use tokens + session-less confirm page (ADR-0043)
2 parents 11d9ece + 3219191 commit 3b2e729

8 files changed

Lines changed: 559 additions & 15 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@objectstack/plugin-approvals": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
ADR-0043 actionable approval links (#1743). `remind()` now fans out per approver: every concrete identity gets its own single-use approve/reject links in the notification payload. Tokens are 256-bit, stored as SHA-256 hashes only (`sys_approval_token`), scoped to one request + action + approver, 72h TTL, consumed-before-decide (replay burns), and re-validated at redemption against the live request (decided/recalled/reassigned ⇒ dead link). The plugin mounts a session-less bilingual confirm page at `GET /api/v1/approvals/act` (renders only — mail-gateway prefetch safe) and redeems exclusively on the `POST`, auditing the decision as the bound approver.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# ADR-0043: Actionable approval links — single-use tokens with a session-less confirm page
2+
3+
**Status**: Proposed (2026-06-12)
4+
**Deciders**: ObjectStack Protocol Architects
5+
**Builds on**: [ADR-0042](./0042-approval-sla-escalation.md) (reserved system actors, audit-first discipline), thread interactions (#1740), [ADR-0012/0030](./0030-notification-platform-convergence.md) (messaging + outbox)
6+
**Closes**: [#1743](https://github.com/objectstack-ai/framework/issues/1743)
7+
**Consumers**: `@objectstack/plugin-approvals` (token store + redemption + pages), messaging templates, future email channel wiring
8+
9+
---
10+
11+
## TL;DR
12+
13+
Approvers should act from an email/IM message without signing in — the
14+
biggest lever on approval latency. The button behind that experience is a
15+
URL whose bearer has **no session**, so the token in it must carry the
16+
entire authorization, deliberately weakened on every axis:
17+
18+
| axis | decision | failure it prevents |
19+
|---|---|---|
20+
| scope | one token = one request + one action + one approver | leaked token ≠ account takeover |
21+
| storage | only the **SHA-256 hash** is stored (`sys_approval_token`) | a DB leak yields no usable links |
22+
| single-use | `consumed_at` set transactionally before deciding | forwarded email replayed |
23+
| TTL | 72 h default | months-old mail approving today's request |
24+
| identity | token binds `approver_id`; the decision is audited as that approver | anonymous decisions |
25+
| invalidation | redemption re-checks the request is still pending **and** the approver still holds the slot | stale links after reassign / recall / decision |
26+
| scanner-proof | **GET never executes** — it renders a confirm page whose button POSTs | mail-gateway link prefetchers approving requests |
27+
28+
The last row is the classic production incident: enterprise mail security
29+
(Outlook SafeLinks et al.) pre-fetches every link in a message. Any
30+
GET-executes design gets requests approved by robots.
31+
32+
## Mechanics
33+
34+
- **`sys_approval_token`** (new object — table creation only, no
35+
migrations): `token_hash`, `request_id`, `action`
36+
(`approve`/`reject`), `approver_id`, `expires_at`, `consumed_at`.
37+
- **Issue** (`issueActionTokens`): 256-bit random raw tokens, returned
38+
once, hashes stored. Wired into `remind()` — each pending approver with
39+
a concrete identity (not `role:*` literals) gets their **own**
40+
notification carrying their own approve/reject links. (Open-time
41+
notification remains the flow author's `notify` node; templates there
42+
can adopt the same links later.)
43+
- **Confirm page** (`GET /api/v1/approvals/act?token=…`): session-less
44+
minimal HTML rendered by the plugin on the host Hono app — request
45+
summary (flow label, record title, action) + a POST form. Invalid /
46+
expired / consumed tokens render an explanatory page with a Console
47+
deep link; the GET **never** mutates.
48+
- **Redeem** (`POST /api/v1/approvals/act`): hash lookup → not consumed →
49+
not expired → request still `pending``approver_id` still in
50+
`pending_approvers` → mark consumed → `decide()` **as that approver**
51+
(system context carries the bound identity). Every check failure maps
52+
to a distinct, non-enumerable result page.
53+
- **URLs**: relative by default (works inside Console/IM webviews);
54+
deployments set `publicBaseUrl` (plugin option) for absolute links in
55+
outbound email.
56+
57+
## Non-goals (v1)
58+
59+
- Comment capture on the confirm page (a decision comment field is a
60+
fast follow; the page ships decision-only).
61+
- Email channel *delivery* configuration — the links ride the existing
62+
messaging payloads; SMTP setup is deployment concern.
63+
- Rate limiting beyond single-use + TTL (the token space is 2^256;
64+
brute force is not the threat — leaked links are, and those die on
65+
first use / decision / reassign / expiry).
66+
67+
## Consequences
68+
69+
- The remind nudge becomes genuinely actionable — one tap from the
70+
notification to a decision, with the audit trail showing the human
71+
approver (never a system actor).
72+
- A deliberate, narrow bypass lane around session auth exists; its
73+
entire surface is this ADR's table, and every property is enforced in
74+
`redeemActionToken` with tests per row.
75+
- Stale-link UX is explicit: recalled/decided/reassigned requests answer
76+
with "this link is no longer valid" + a Console deep link, not an
77+
error code.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Session-less HTML for the actionable-link confirm/result pages (ADR-0043).
5+
*
6+
* Deliberately tiny and dependency-free: these pages are reached from an
7+
* email or IM message by a bearer with no session, so they must not assume
8+
* the Console bundle, auth state, or client-side i18n. Static bilingual
9+
* (EN / 中文) copy keeps them readable for the demo audience without a
10+
* locale negotiation step.
11+
*
12+
* The GET page NEVER mutates — the decision happens only on the POST form
13+
* submit (mail-gateway link prefetchers must not approve requests).
14+
*/
15+
16+
import type { ApprovalRequestRow, ApprovalActionKind } from '@objectstack/spec/contracts';
17+
18+
function esc(s: unknown): string {
19+
return String(s ?? '')
20+
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
21+
.replaceAll('"', '&quot;').replaceAll("'", '&#39;');
22+
}
23+
24+
function shell(title: string, body: string): string {
25+
return `<!doctype html>
26+
<html lang="en"><head><meta charset="utf-8">
27+
<meta name="viewport" content="width=device-width, initial-scale=1">
28+
<meta name="robots" content="noindex">
29+
<title>${esc(title)}</title>
30+
<style>
31+
body{font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;
32+
background:#f6f7f9;color:#1a202c;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center}
33+
.card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;max-width:440px;width:calc(100% - 32px);
34+
padding:28px 32px;box-shadow:0 1px 3px rgba(0,0,0,.06)}
35+
h1{font-size:18px;margin:0 0 4px}
36+
.sub{color:#64748b;font-size:13px;margin:0 0 20px}
37+
.row{display:flex;justify-content:space-between;gap:12px;padding:7px 0;border-bottom:1px solid #f1f5f9;font-size:14px}
38+
.row b{font-weight:600;text-align:right}
39+
.k{color:#64748b}
40+
.actions{margin-top:22px;display:flex;gap:10px}
41+
button{flex:1;padding:10px 16px;border-radius:8px;border:1px solid transparent;font-size:15px;font-weight:600;cursor:pointer}
42+
.approve{background:#059669;color:#fff}
43+
.reject{background:#fff;color:#dc2626;border-color:#fca5a5}
44+
.badge{display:inline-block;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;margin-bottom:14px}
45+
.ok{background:#ecfdf5;color:#047857}.warn{background:#fffbeb;color:#b45309}.err{background:#fef2f2;color:#b91c1c}
46+
a{color:#2563eb;text-decoration:none}
47+
.foot{margin-top:18px;font-size:12px;color:#94a3b8}
48+
</style></head><body><div class="card">${body}</div></body></html>`;
49+
}
50+
51+
function summaryRows(req: ApprovalRequestRow): string {
52+
const rows: Array<[string, string]> = [
53+
['Process · 流程', req.process_label || req.process_name],
54+
['Step · 步骤', req.step_label || req.current_step || '—'],
55+
['Record · 记录', req.record_title || req.record_id],
56+
['Object · 对象', req.object_label || req.object_name],
57+
['Requester · 申请人', req.submitter_name || req.submitter_id || '—'],
58+
];
59+
return rows.map(([k, v]) => `<div class="row"><span class="k">${esc(k)}</span><b>${esc(v)}</b></div>`).join('');
60+
}
61+
62+
/** GET page: summary + a POST form. Rendering only — no mutation. */
63+
export function renderConfirmPage(input: {
64+
request: ApprovalRequestRow;
65+
action: Extract<ApprovalActionKind, 'approve' | 'reject'>;
66+
approverId: string;
67+
token: string;
68+
actPath: string;
69+
}): string {
70+
const approving = input.action === 'approve';
71+
const verb = approving ? 'Approve · 通过' : 'Reject · 拒绝';
72+
return shell(`${verb} — Approval`, `
73+
<h1>${approving ? '✅ Approve this request?' : '⛔ Reject this request?'}</h1>
74+
<p class="sub">${approving ? '确认通过该审批请求?' : '确认拒绝该审批请求?'}
75+
Acting as · 操作身份:<b>${esc(input.approverId)}</b></p>
76+
${summaryRows(input.request)}
77+
<form method="post" action="${esc(input.actPath)}" class="actions">
78+
<input type="hidden" name="token" value="${esc(input.token)}">
79+
<button type="submit" class="${approving ? 'approve' : 'reject'}">${verb}</button>
80+
</form>
81+
<p class="foot">This link is single-use and expires automatically. · 此链接一次有效,过期自动失效。</p>`);
82+
}
83+
84+
const RESULT_COPY: Record<string, { cls: string; title: string; body: string }> = {
85+
approved: { cls: 'ok', title: '✅ Approved · 已通过', body: 'The decision was recorded. · 审批结果已记录。' },
86+
rejected: { cls: 'ok', title: '⛔ Rejected · 已拒绝', body: 'The decision was recorded. · 审批结果已记录。' },
87+
invalid: { cls: 'err', title: 'Invalid link · 链接无效', body: 'This link is not recognized. · 无法识别该链接。' },
88+
expired: { cls: 'warn', title: 'Link expired · 链接已过期', body: 'Ask the requester to send a new reminder. · 请让申请人重新发送催办。' },
89+
consumed: { cls: 'warn', title: 'Already used · 链接已使用', body: 'This link was already used once. · 该链接已被使用过。' },
90+
not_pending: { cls: 'warn', title: 'Already decided · 请求已处理', body: 'This request is no longer pending. · 该请求已不在待审批状态。' },
91+
not_approver: { cls: 'warn', title: 'No longer your approval · 已不在你名下', body: 'This approval was handed to someone else. · 该审批已转由他人处理。' },
92+
};
93+
94+
/** Terminal page for every redemption outcome (and stale GETs). */
95+
export function renderResultPage(kind: keyof typeof RESULT_COPY, request?: ApprovalRequestRow): string {
96+
const copy = RESULT_COPY[kind] ?? RESULT_COPY.invalid;
97+
return shell(copy.title, `
98+
<span class="badge ${copy.cls}">${esc(copy.title)}</span>
99+
${request ? summaryRows(request) : ''}
100+
<p>${esc(copy.body)}</p>
101+
<p class="foot"><a href="/system/approvals">Open the Approvals Inbox · 打开审批中心</a></p>`);
102+
}

packages/plugins/plugin-approvals/src/approval-service.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,9 @@ describe('ApprovalService (node era)', () => {
447447
const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX);
448448
const out = await svc.remind(req.id, { actorId: 'u1' }, CTX); // u1 = submitter (CTX.userId)
449449
expect(out.notified).toBe(2);
450-
expect(emitted[0]).toMatchObject({ topic: 'approval.reminder', audience: ['u9', 'u2'] });
450+
// ADR-0043: per-approver fan-out so each reminder carries personal links.
451+
const reminders = emitted.filter(e => e.topic === 'approval.reminder');
452+
expect(reminders.map(r => r.audience)).toEqual([['u9'], ['u2']]);
451453
const actions = await svc.listActions(req.id, SYS);
452454
expect(actions.at(-1)?.action).toBe('remind');
453455
// The fake clock steps 1s per call — well inside the 4h cool-down.
@@ -482,6 +484,76 @@ describe('ApprovalService (node era)', () => {
482484
expect(actions.filter(a => a.action === 'comment')).toHaveLength(2);
483485
});
484486

487+
// ── actionable links (ADR-0043) ─────────────────────────────────
488+
489+
it('issueActionTokens: stores hashes only and binds approver + action', async () => {
490+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
491+
const tokens = await svc.issueActionTokens(req.id, 'u9');
492+
expect(tokens.approve).not.toBe(tokens.reject);
493+
const rows = engine._tables['sys_approval_token'];
494+
expect(rows).toHaveLength(2);
495+
expect(rows.every(r => r.token_hash.length === 64)).toBe(true); // sha256 hex, never the raw token
496+
expect(rows.every(r => !JSON.stringify(r).includes(tokens.approve))).toBe(true);
497+
await expect(svc.issueActionTokens(req.id, 'stranger')).rejects.toThrow(/FORBIDDEN/);
498+
});
499+
500+
it('redeem: approves as the bound approver and burns the token (single-use)', async () => {
501+
const resumed: any[] = [];
502+
svc.attachAutomation({ async resume(runId, signal) { resumed.push({ runId, signal }); } });
503+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
504+
const { approve } = await svc.issueActionTokens(req.id, 'u9');
505+
const out = await svc.redeemActionToken(approve);
506+
expect(out).toMatchObject({ ok: true, action: 'approve', approverId: 'u9' });
507+
expect((out as any).request.status).toBe('approved');
508+
expect(resumed[0]?.signal?.branchLabel).toBe('approve');
509+
const acts = await svc.listActions(req.id, SYS);
510+
expect(acts.at(-1)).toMatchObject({ action: 'approve', actor_id: 'u9', comment: 'Via action link' });
511+
// replay
512+
expect(await svc.redeemActionToken(approve)).toMatchObject({ ok: false, reason: 'consumed' });
513+
});
514+
515+
it('peek: validates without consuming (GET never mutates)', async () => {
516+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
517+
const { reject } = await svc.issueActionTokens(req.id, 'u9');
518+
expect(await svc.peekActionToken(reject)).toMatchObject({ ok: true, action: 'reject' });
519+
expect(await svc.peekActionToken(reject)).toMatchObject({ ok: true }); // still live
520+
const fresh = await svc.getRequest(req.id, SYS);
521+
expect(fresh?.status).toBe('pending');
522+
});
523+
524+
it('redeem: dead tokens — invalid, expired, decided request, reassigned slot', async () => {
525+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
526+
expect(await svc.redeemActionToken('garbage')).toMatchObject({ ok: false, reason: 'invalid' });
527+
528+
const short = await svc.issueActionTokens(req.id, 'u9', { ttlMs: 1 });
529+
// fake clock advances 1s per call — far beyond a 1ms TTL
530+
expect(await svc.redeemActionToken(short.approve)).toMatchObject({ ok: false, reason: 'expired' });
531+
532+
const live = await svc.issueActionTokens(req.id, 'u9');
533+
await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, CTX);
534+
expect(await svc.redeemActionToken(live.approve)).toMatchObject({ ok: false, reason: 'not_approver' });
535+
536+
const forU7 = await svc.issueActionTokens(req.id, 'u7');
537+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u7' }, SYS);
538+
expect(await svc.redeemActionToken(forU7.reject)).toMatchObject({ ok: false, reason: 'not_pending' });
539+
});
540+
541+
it('remind: each concrete approver gets their own action links', async () => {
542+
const emitted: any[] = [];
543+
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
544+
const req = await svc.openNodeRequest(openInput(['u9', 'ada@example.com']), CTX);
545+
await svc.remind(req.id, { actorId: 'u1' }, CTX);
546+
const reminders = emitted.filter(e => e.topic === 'approval.reminder');
547+
expect(reminders).toHaveLength(2);
548+
for (const r of reminders) {
549+
expect(r.audience).toHaveLength(1);
550+
expect(r.payload.actions).toHaveLength(2);
551+
expect(r.payload.actions[0].url).toContain('/api/v1/approvals/act?token=');
552+
}
553+
const urls = reminders.flatMap(r => r.payload.actions.map((a: any) => a.url));
554+
expect(new Set(urls).size).toBe(4); // every link is personal + per-action
555+
});
556+
485557
// ── pagination + search pushdown (#1745) ────────────────────────
486558

487559
async function openMany(n: number) {

0 commit comments

Comments
 (0)