@forumone/throughline-approvals
v0.9.9
Published
Conversational approval workflow server for Throughline. Provides the approval resolver the publishing server consumes plus MCP tools for requesting and responding to approvals.
Maintainers
Readme
@forumone/throughline-approvals
Conversational approval workflow server for Throughline. Provides the resolver the publishing server consumes plus MCP tools and email-action endpoints for requesting and responding to approvals.
What this package provides
- Approvals collection with target / request / decision / workflow-state fields and indexes for the common queries.
- Approval resolver that the publishing server's
approvalStepcalls to check for active granted approvals (auto-attached on the Payload instance via Symbol — no manual wiring needed). - HMAC-signed action tokens (
generateActionToken/verifyActionToken) for inline-action emails. Single-use enforcement via the per-recordconsumedTokensarray prevents replay. - HTTP action endpoint at
/api/approvals/actionthat handles email button clicks: verify token → confirmation page → record decision. - Five MCP tools, handed to the host's collector at
onInitand served by@payloadcms/plugin-mcpon one/api/mcp. PassmcpToolsor they reach nobody:
| Tool | Audit |
| ------------------------ | ------------------------------------------------------- |
| request_approval | approval.requested |
| respond_to_approval | approval.granted / .declined / .changes_requested |
| get_approval_status | none (read-only) |
| list_pending_approvals | none (read-only) |
| list_my_requests | none (read-only) |
Installation
pnpm add @forumone/throughline-approvalsPeers: payload@^3.89.0, inngest@^4.0.0. Required runtime peer: @forumone/throughline-core (audit log).
Usage
import { buildConfig } from 'payload'
import { auditPlugin, createInngestClient } from '@forumone/throughline-core'
import { approvalsPlugin } from '@forumone/throughline-approvals'
import { publishingPlugin } from '@forumone/throughline-publishing'
const inngest = createInngestClient({ id: 'my-site' })
export default buildConfig({
// collections, db, secret...
plugins: [
auditPlugin({ inngest }),
approvalsPlugin({
inngest,
groups: [
{ slug: 'editorial', name: 'Editorial review' },
{ slug: 'legal', name: 'Legal review' },
],
groupResolver: {
async resolveUsers(slugs) {
// Return users belonging to any of the listed groups.
return payload.find({
collection: 'users',
where: { groups: { in: slugs } },
})
},
},
tokenSecret: process.env.APPROVAL_TOKEN_SECRET,
}),
publishingPlugin({
inngest,
collections: [{ slug: 'pages' }],
// No `approvalResolver` is needed — approvalsPlugin attaches it
// automatically via Symbol. Pass one explicitly only if you need
// to override.
}),
],
})Sidebar group
The approvals collection sits in the admin sidebar's Throughline group by default. Pass admin: { group: 'Workflow' } to file it elsewhere, or admin: { group: false } to leave it ungrouped. Every Throughline plugin that declares a collection takes the same option — see the reference.
Environment
approvalsEnv declares what the plugin reads from the environment and refuses to start without — APPROVAL_TOKEN_SECRET, 32+ characters, used when tokenSecret is omitted. Pass it to assertEnvironment from @forumone/throughline-core at the top of payload.config.ts and a missing secret is reported together with every other missing variable, instead of as this plugin's own error at init. The init check still runs, driven by the same declaration.
import { assertEnvironment } from '@forumone/throughline-core'
import { approvalsEnv } from '@forumone/throughline-approvals'
assertEnvironment(approvalsEnv /*, every other plugin's list, and the site's own */)Wiring with the publishing server
The publishing server's approvalStep does not require an approvalResolver in its options. When approvalsPlugin is registered, it attaches the resolver to the Payload instance under Symbol.for('@forumone/throughline/approvals-resolver'), and publishing's approval step looks it up at publish time.
If you need a custom resolver (e.g. you store approvals in an external system), pass approvalResolver directly to publishingPlugin — it takes precedence over the symbol lookup.
Phase 1 semantics
First-decision-wins. Multi-party approvals (e.g. legal AND communications must both approve) are deferred to Phase 2. The Phase 1 model handles "any one approver from the configured groups," which covers the most common case.
Approvals are tied to content, not to a timestamp.
request_approvalstoresdocumentContentHash(document)intargetVersion, and publishing's approval step recomputes the same hash from the document it is about to publish. So an approval granted against one draft does not apply to a subsequent edit — but it does survive a save that changed nothing, and it comes back if an edit is reverted.This is what lets autosave and approvals both be on. The binding used to be
updatedAt, which moves on every save: an editor fixing a typo while an approver read the request invalidated the approval, and autosave did that every couple of seconds. See the note underdocumentContentHashin@forumone/throughline-corefor what counts as content — in short, everything exceptid,createdAt,updatedAt,_statusand the other storage bookkeeping, at every level of the document.Both sides must load the document the same way for the hashes to agree; both use
payload.findByID({ collection, id, draft: true }). A populated relationship and a bare relationship id are different values, so a caller hashing a document fetched at a different depth would match nothing.Action tokens are single-use, 14-day validity. Once an approver clicks an action link, the token is appended to the request's
consumedTokensarray; reusing it returns an error.Self-approval is blocked. The respond_to_approval tool refuses if the caller is the requester.
Group resolution is configurable. Clients define what "editorial" or "legal" means via the
groupResolver.resolveUserscallback. Core does not hardcode group membership logic.
Action endpoint
The plugin registers a GET endpoint at ${routePrefix}/action (default /api/approvals/action) that accepts a ?token= query param. Flow:
- Verify the HMAC signature and check the token hasn't expired.
- Load the approval; bail if it's already decided or the token has been consumed.
- First hit: render a confirmation page. Second hit (with
?confirm=true): record the decision, append the token toconsumedTokens, fireapproval/decided, write the audit record.
The confirmation page is intentionally minimal. Clients that want a branded action page can register their own endpoint that calls verifyActionToken and previewVerification directly — both are exported from the package entry point.
The _meta payload
request_approval and respond_to_approval accept the framework's _meta payload (via withMeta from @forumone/throughline-core). Audit records carry the prompt and reasoning fields for later "why was this approved?" queries.
Related packages
@forumone/throughline-core— required peer; provides the audit log and MCP handler this plugin builds on@forumone/throughline-publishing— peer plugin that consumes the resolver this plugin attaches@forumone/throughline-email(C11) — will subscribe toapproval/requestedandapproval/decidedto send notifications
