Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/approvals-actionable-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@objectstack/plugin-approvals": minor
"@objectstack/spec": patch
---

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.
77 changes: 77 additions & 0 deletions docs/adr/0043-actionable-approval-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# ADR-0043: Actionable approval links — single-use tokens with a session-less confirm page

**Status**: Proposed (2026-06-12)
**Deciders**: ObjectStack Protocol Architects
**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)
**Closes**: [#1743](https://github.com/objectstack-ai/framework/issues/1743)
**Consumers**: `@objectstack/plugin-approvals` (token store + redemption + pages), messaging templates, future email channel wiring

---

## TL;DR

Approvers should act from an email/IM message without signing in — the
biggest lever on approval latency. The button behind that experience is a
URL whose bearer has **no session**, so the token in it must carry the
entire authorization, deliberately weakened on every axis:

| axis | decision | failure it prevents |
|---|---|---|
| scope | one token = one request + one action + one approver | leaked token ≠ account takeover |
| storage | only the **SHA-256 hash** is stored (`sys_approval_token`) | a DB leak yields no usable links |
| single-use | `consumed_at` set transactionally before deciding | forwarded email replayed |
| TTL | 72 h default | months-old mail approving today's request |
| identity | token binds `approver_id`; the decision is audited as that approver | anonymous decisions |
| invalidation | redemption re-checks the request is still pending **and** the approver still holds the slot | stale links after reassign / recall / decision |
| scanner-proof | **GET never executes** — it renders a confirm page whose button POSTs | mail-gateway link prefetchers approving requests |

The last row is the classic production incident: enterprise mail security
(Outlook SafeLinks et al.) pre-fetches every link in a message. Any
GET-executes design gets requests approved by robots.

## Mechanics

- **`sys_approval_token`** (new object — table creation only, no
migrations): `token_hash`, `request_id`, `action`
(`approve`/`reject`), `approver_id`, `expires_at`, `consumed_at`.
- **Issue** (`issueActionTokens`): 256-bit random raw tokens, returned
once, hashes stored. Wired into `remind()` — each pending approver with
a concrete identity (not `role:*` literals) gets their **own**
notification carrying their own approve/reject links. (Open-time
notification remains the flow author's `notify` node; templates there
can adopt the same links later.)
- **Confirm page** (`GET /api/v1/approvals/act?token=…`): session-less
minimal HTML rendered by the plugin on the host Hono app — request
summary (flow label, record title, action) + a POST form. Invalid /
expired / consumed tokens render an explanatory page with a Console
deep link; the GET **never** mutates.
- **Redeem** (`POST /api/v1/approvals/act`): hash lookup → not consumed →
not expired → request still `pending` → `approver_id` still in
`pending_approvers` → mark consumed → `decide()` **as that approver**
(system context carries the bound identity). Every check failure maps
to a distinct, non-enumerable result page.
- **URLs**: relative by default (works inside Console/IM webviews);
deployments set `publicBaseUrl` (plugin option) for absolute links in
outbound email.

## Non-goals (v1)

- Comment capture on the confirm page (a decision comment field is a
fast follow; the page ships decision-only).
- Email channel *delivery* configuration — the links ride the existing
messaging payloads; SMTP setup is deployment concern.
- Rate limiting beyond single-use + TTL (the token space is 2^256;
brute force is not the threat — leaked links are, and those die on
first use / decision / reassign / expiry).

## Consequences

- The remind nudge becomes genuinely actionable — one tap from the
notification to a decision, with the audit trail showing the human
approver (never a system actor).
- A deliberate, narrow bypass lane around session auth exists; its
entire surface is this ADR's table, and every property is enforced in
`redeemActionToken` with tests per row.
- Stale-link UX is explicit: recalled/decided/reassigned requests answer
with "this link is no longer valid" + a Console deep link, not an
error code.
102 changes: 102 additions & 0 deletions packages/plugins/plugin-approvals/src/action-link-pages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Session-less HTML for the actionable-link confirm/result pages (ADR-0043).
*
* Deliberately tiny and dependency-free: these pages are reached from an
* email or IM message by a bearer with no session, so they must not assume
* the Console bundle, auth state, or client-side i18n. Static bilingual
* (EN / 中文) copy keeps them readable for the demo audience without a
* locale negotiation step.
*
* The GET page NEVER mutates — the decision happens only on the POST form
* submit (mail-gateway link prefetchers must not approve requests).
*/

import type { ApprovalRequestRow, ApprovalActionKind } from '@objectstack/spec/contracts';

function esc(s: unknown): string {
return String(s ?? '')
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
.replaceAll('"', '&quot;').replaceAll("'", '&#39;');
}

function shell(title: string, body: string): string {
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>${esc(title)}</title>
<style>
body{font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;
background:#f6f7f9;color:#1a202c;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center}
.card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;max-width:440px;width:calc(100% - 32px);
padding:28px 32px;box-shadow:0 1px 3px rgba(0,0,0,.06)}
h1{font-size:18px;margin:0 0 4px}
.sub{color:#64748b;font-size:13px;margin:0 0 20px}
.row{display:flex;justify-content:space-between;gap:12px;padding:7px 0;border-bottom:1px solid #f1f5f9;font-size:14px}
.row b{font-weight:600;text-align:right}
.k{color:#64748b}
.actions{margin-top:22px;display:flex;gap:10px}
button{flex:1;padding:10px 16px;border-radius:8px;border:1px solid transparent;font-size:15px;font-weight:600;cursor:pointer}
.approve{background:#059669;color:#fff}
.reject{background:#fff;color:#dc2626;border-color:#fca5a5}
.badge{display:inline-block;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;margin-bottom:14px}
.ok{background:#ecfdf5;color:#047857}.warn{background:#fffbeb;color:#b45309}.err{background:#fef2f2;color:#b91c1c}
a{color:#2563eb;text-decoration:none}
.foot{margin-top:18px;font-size:12px;color:#94a3b8}
</style></head><body><div class="card">${body}</div></body></html>`;
}

function summaryRows(req: ApprovalRequestRow): string {
const rows: Array<[string, string]> = [
['Process · 流程', req.process_label || req.process_name],
['Step · 步骤', req.step_label || req.current_step || '—'],
['Record · 记录', req.record_title || req.record_id],
['Object · 对象', req.object_label || req.object_name],
['Requester · 申请人', req.submitter_name || req.submitter_id || '—'],
];
return rows.map(([k, v]) => `<div class="row"><span class="k">${esc(k)}</span><b>${esc(v)}</b></div>`).join('');
}

/** GET page: summary + a POST form. Rendering only — no mutation. */
export function renderConfirmPage(input: {
request: ApprovalRequestRow;
action: Extract<ApprovalActionKind, 'approve' | 'reject'>;
approverId: string;
token: string;
actPath: string;
}): string {
const approving = input.action === 'approve';
const verb = approving ? 'Approve · 通过' : 'Reject · 拒绝';
return shell(`${verb} — Approval`, `
<h1>${approving ? '✅ Approve this request?' : '⛔ Reject this request?'}</h1>
<p class="sub">${approving ? '确认通过该审批请求?' : '确认拒绝该审批请求?'}
Acting as · 操作身份:<b>${esc(input.approverId)}</b></p>
${summaryRows(input.request)}
<form method="post" action="${esc(input.actPath)}" class="actions">
<input type="hidden" name="token" value="${esc(input.token)}">
<button type="submit" class="${approving ? 'approve' : 'reject'}">${verb}</button>
</form>
<p class="foot">This link is single-use and expires automatically. · 此链接一次有效,过期自动失效。</p>`);
}

const RESULT_COPY: Record<string, { cls: string; title: string; body: string }> = {
approved: { cls: 'ok', title: '✅ Approved · 已通过', body: 'The decision was recorded. · 审批结果已记录。' },
rejected: { cls: 'ok', title: '⛔ Rejected · 已拒绝', body: 'The decision was recorded. · 审批结果已记录。' },
invalid: { cls: 'err', title: 'Invalid link · 链接无效', body: 'This link is not recognized. · 无法识别该链接。' },
expired: { cls: 'warn', title: 'Link expired · 链接已过期', body: 'Ask the requester to send a new reminder. · 请让申请人重新发送催办。' },
consumed: { cls: 'warn', title: 'Already used · 链接已使用', body: 'This link was already used once. · 该链接已被使用过。' },
not_pending: { cls: 'warn', title: 'Already decided · 请求已处理', body: 'This request is no longer pending. · 该请求已不在待审批状态。' },
not_approver: { cls: 'warn', title: 'No longer your approval · 已不在你名下', body: 'This approval was handed to someone else. · 该审批已转由他人处理。' },
};

/** Terminal page for every redemption outcome (and stale GETs). */
export function renderResultPage(kind: keyof typeof RESULT_COPY, request?: ApprovalRequestRow): string {
const copy = RESULT_COPY[kind] ?? RESULT_COPY.invalid;
return shell(copy.title, `
<span class="badge ${copy.cls}">${esc(copy.title)}</span>
${request ? summaryRows(request) : ''}
<p>${esc(copy.body)}</p>
<p class="foot"><a href="/system/approvals">Open the Approvals Inbox · 打开审批中心</a></p>`);
}
74 changes: 73 additions & 1 deletion packages/plugins/plugin-approvals/src/approval-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,9 @@ describe('ApprovalService (node era)', () => {
const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX);
const out = await svc.remind(req.id, { actorId: 'u1' }, CTX); // u1 = submitter (CTX.userId)
expect(out.notified).toBe(2);
expect(emitted[0]).toMatchObject({ topic: 'approval.reminder', audience: ['u9', 'u2'] });
// ADR-0043: per-approver fan-out so each reminder carries personal links.
const reminders = emitted.filter(e => e.topic === 'approval.reminder');
expect(reminders.map(r => r.audience)).toEqual([['u9'], ['u2']]);
const actions = await svc.listActions(req.id, SYS);
expect(actions.at(-1)?.action).toBe('remind');
// The fake clock steps 1s per call — well inside the 4h cool-down.
Expand Down Expand Up @@ -482,6 +484,76 @@ describe('ApprovalService (node era)', () => {
expect(actions.filter(a => a.action === 'comment')).toHaveLength(2);
});

// ── actionable links (ADR-0043) ─────────────────────────────────

it('issueActionTokens: stores hashes only and binds approver + action', async () => {
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
const tokens = await svc.issueActionTokens(req.id, 'u9');
expect(tokens.approve).not.toBe(tokens.reject);
const rows = engine._tables['sys_approval_token'];
expect(rows).toHaveLength(2);
expect(rows.every(r => r.token_hash.length === 64)).toBe(true); // sha256 hex, never the raw token
expect(rows.every(r => !JSON.stringify(r).includes(tokens.approve))).toBe(true);
await expect(svc.issueActionTokens(req.id, 'stranger')).rejects.toThrow(/FORBIDDEN/);
});

it('redeem: approves as the bound approver and burns the token (single-use)', async () => {
const resumed: any[] = [];
svc.attachAutomation({ async resume(runId, signal) { resumed.push({ runId, signal }); } });
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
const { approve } = await svc.issueActionTokens(req.id, 'u9');
const out = await svc.redeemActionToken(approve);
expect(out).toMatchObject({ ok: true, action: 'approve', approverId: 'u9' });
expect((out as any).request.status).toBe('approved');
expect(resumed[0]?.signal?.branchLabel).toBe('approve');
const acts = await svc.listActions(req.id, SYS);
expect(acts.at(-1)).toMatchObject({ action: 'approve', actor_id: 'u9', comment: 'Via action link' });
// replay
expect(await svc.redeemActionToken(approve)).toMatchObject({ ok: false, reason: 'consumed' });
});

it('peek: validates without consuming (GET never mutates)', async () => {
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
const { reject } = await svc.issueActionTokens(req.id, 'u9');
expect(await svc.peekActionToken(reject)).toMatchObject({ ok: true, action: 'reject' });
expect(await svc.peekActionToken(reject)).toMatchObject({ ok: true }); // still live
const fresh = await svc.getRequest(req.id, SYS);
expect(fresh?.status).toBe('pending');
});

it('redeem: dead tokens — invalid, expired, decided request, reassigned slot', async () => {
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
expect(await svc.redeemActionToken('garbage')).toMatchObject({ ok: false, reason: 'invalid' });

const short = await svc.issueActionTokens(req.id, 'u9', { ttlMs: 1 });
// fake clock advances 1s per call — far beyond a 1ms TTL
expect(await svc.redeemActionToken(short.approve)).toMatchObject({ ok: false, reason: 'expired' });

const live = await svc.issueActionTokens(req.id, 'u9');
await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, CTX);
expect(await svc.redeemActionToken(live.approve)).toMatchObject({ ok: false, reason: 'not_approver' });

const forU7 = await svc.issueActionTokens(req.id, 'u7');
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u7' }, SYS);
expect(await svc.redeemActionToken(forU7.reject)).toMatchObject({ ok: false, reason: 'not_pending' });
});

it('remind: each concrete approver gets their own action links', async () => {
const emitted: any[] = [];
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
const req = await svc.openNodeRequest(openInput(['u9', 'ada@example.com']), CTX);
await svc.remind(req.id, { actorId: 'u1' }, CTX);
const reminders = emitted.filter(e => e.topic === 'approval.reminder');
expect(reminders).toHaveLength(2);
for (const r of reminders) {
expect(r.audience).toHaveLength(1);
expect(r.payload.actions).toHaveLength(2);
expect(r.payload.actions[0].url).toContain('/api/v1/approvals/act?token=');
}
const urls = reminders.flatMap(r => r.payload.actions.map((a: any) => a.url));
expect(new Set(urls).size).toBe(4); // every link is personal + per-action
});

// ── pagination + search pushdown (#1745) ────────────────────────

async function openMany(n: number) {
Expand Down
Loading
Loading