criticalAction()
The high-assurance sibling of action(). Same author shape — one declaration produces a typed .call() and a route handler — but every request is verified against an HMAC envelope before the handler body runs. Envelope signing binds the request to its session, origin, action, body bytes, and a monotonic counter. Optional capability checks (requires) and tamper-evident audit logging complete the substrate.
Designed for the actions where being wrong matters: payments, role changes, deletions, anything compliance audits. See ADR 0055 for the threat model + standards mapping (OWASP ASVS 5.0, NIST SP 800-53 Rev 5, RFC 4303 IPsec ESP anti-replay, the Stanford / Google macaroons paper).
Signature
criticalAction<I, R>(def: {
path: string
input: (raw: unknown) => I
fn: (input: I, ctx: CriticalActionCtx) => Promise<R> | R
// Optional:
requires?: readonly PermDeclaration[] // capability gates (perm('op'))
sameOrigin?: boolean // default true for state-changing methods
maxBodyBytes?: number // default 1 MiB
maxAgeSec?: number // default 300 (replay window)
appCaveatVerifier?: (key, value, ctx) => boolean | Promise<boolean>
}): { call, handler, path, __isCriticalAction: true }
App boot — install the secret
// 1. Add a 32+ byte app secret. Every node in a multi-node deployment
// derives the same per-session HMAC key from it.
import { app } from '@place/component/server'
app({
pages: [...],
secret: process.env.PLACE_SECRET!, // 32+ bytes, e.g. base64url(crypto.randomBytes(32))
}).run()
// The framework throws at app-config time if any criticalAction() is
// registered without a SessionCap install. Critical actions REQUIRE
// an authenticated session — the envelope binds session.id.
The secret roots the daily per-session HMAC key derivation (HKDF-SHA256, info "place-action-session-v1") and the macaroon key derivation (info "place-macaroon-v1" — domain-separated so a leak of one key doesn't help with the other). Rotate the secret by deploying with a new value; sessions issued under the old value remain valid for one day, then fail.
Defining a critical action
import { criticalAction } from '@place/component/server'
import { shape } from '@place/component'
export const transferFunds = criticalAction({
path: 'POST /__a/transfer',
input: shape({ to: 'string', amountCents: 'number' }),
fn: async (input, { session }) => {
// session.userId is GUARANTEED non-null — the framework enforces
// SessionCap before the handler runs. Envelope already verified.
await ledger.transfer(session.userId, input.to, input.amountCents)
return { ok: true }
},
})
Registering
// Same registration shape as action() — spread .handler into serve().
import { serve } from '@place/component/server'
import { transferFunds, withdrawFunds } from './actions'
serve({
routes: {
...transferFunds.handler,
...withdrawFunds.handler,
'/': home,
},
})
Capability gates — perm() + requires:
Macaroon-based capability checks. Each perm('op.name') in requires is verified independently against the macaroon attached to the request (X-Place-Macaroon header, sent automatically by .call() when one is installed). A macaroon with no op= caveats permits everything; with op=admin.* it permits any admin.*; with op=admin.users.delete it permits only that exact op. Multiple op= caveats compose by intersection — order doesn't matter.
import { criticalAction, perm } from '@place/component/server'
export const deleteUser = criticalAction({
path: 'POST /__a/users/delete',
input: shape({ userId: 'string' }),
// The macaroon attached to the request MUST permit admin.users.delete.
// A macaroon scoped to admin.users.* OR admin.* OR * permits it;
// a macaroon scoped to admin.posts.* does not.
requires: [perm('admin.users.delete')],
fn: async ({ userId }) => {
await db.users.softDelete(userId)
return { ok: true }
},
})
// Multiple perms — ALL must be permitted by the request's macaroon.
export const moveDocument = criticalAction({
path: 'POST /__a/docs/move',
input: shape({ docId: 'string', folderId: 'string' }),
requires: [perm('docs.write'), perm('folders.read')],
fn: async (input) => { /* … */ return { ok: true } },
})
Auth flow — provision both keys
Apps mint per-session HMAC keys + macaroons during their auth handler. The framework deliberately doesn't auto-attach an auth endpoint — your login / signup / refresh flow is app-specific (OAuth, password, magic link, …) and the key delivery rides whichever response shape you already use.
// Server-side auth flow. After successful login, return BOTH the
// action key (for envelope signing) and a macaroon (for the
// capability check). Apps attenuate the macaroon to match the user's
// actual permissions before issuance.
import {
provisionActionKey,
provisionMacaroon,
} from '@place/component/server'
import { attenuate, serializeMacaroon } from '@place/security'
export const login = action({
path: 'POST /api/login',
input: shape({ email: 'string', password: 'string' }),
fn: async ({ email, password }, ctx) => {
const user = await authenticate(email, password)
const session = await createSession(user)
// Action key — for envelope signing on every criticalAction call.
const action = await provisionActionKey(session.id)
// Macaroon — the broad root authority. Apps attenuate to match
// the user's actual capability set.
const broad = await provisionMacaroon(session.id)
const scoped = await policy.attenuateForUser(broad.macaroon, user)
// ↑ your app's helper. For example:
// attenuate(m, `op=${user.role === 'admin' ? '*' : 'comments.*'}`)
// then attenuate(m, `app:tenant=${user.tenantId}`)
setCookieHeader(ctx.req, 'place_sid', session.id, { httpOnly: true, secure: true })
return {
action,
macaroon: { macaroon: serializeMacaroon(scoped), expiresAt: broad.expiresAt },
}
},
})
// Browser-side, right after login resolves.
// installActionKey imports the HMAC key as a non-extractable
// WebCrypto CryptoKey + persists to IndexedDB so reloads keep it.
// installMacaroon stores the serialised macaroon alongside.
import { installActionKey, installMacaroon } from '@place/component/client'
const onLoginSuccess = async () => {
const res = await login.call({ email, password })
await installActionKey(res.action)
await installMacaroon(res.macaroon)
// From this point onward every criticalAction().call() picks them
// up automatically — envelope is signed, macaroon header attached.
}
// On logout: drop both. Subsequent criticalAction calls 403.
import { clearActionKey, clearMacaroon } from '@place/component/client'
await Promise.all([clearActionKey(), clearMacaroon()])
installActionKey() imports the raw bytes as a WebCrypto CryptoKey with extractable: false. Once imported, JavaScript on the page cannot read the bytes back — only use them to sign. This bounds the impact of an XSS bug: an attacker who runs in the page context can still use the key (to sign for actions the user could perform anyway), but cannot exfiltrate it for offline use. The macaroon wire string IS readable from IndexedDB (it's a bearer token by design), so attenuate broadly server-side and use expires= caveats to bound its lifetime.Calling from the client
// Client-side call() is identical to action().call() — no extra
// boilerplate, no manual envelope or macaroon handling. The framework
// signs + sends + verifies + audits.
import { transferFunds } from './actions'
try {
const result = await transferFunds.call({ to: 'acct_123', amountCents: 5000 })
// ^? { ok: boolean }
} catch (e) {
if (e instanceof ActionError && e.status === 403) {
// 403 with body "Forbidden" — the server returns no detail on
// which check failed (no-info-leak oracle). The audit log
// captures the typed reason server-side.
}
}
Custom audit events — ctx.audit()
Every critical action invocation auto-appends one tamper-evident entry to the audit log (success: action + payload-hash + result-hash; failure: action#error+ payload-hash, no result-hash). ctx.audit(event, payload?) appends additional entries with whatever handler-emitted context the action wants to record. Entries are hash-chained — any retroactive modification breaks verify().
// ctx.audit() — append custom events to the tamper-evident audit log
// from inside a handler. The framework auto-appends ONE entry per
// invocation (on success or failure); audit() adds MORE.
export const escalate = criticalAction({
path: 'POST /__a/escalate',
input: shape({ caseId: 'string', tier: 'number' }),
requires: [perm('cases.escalate')],
fn: async (input, ctx) => {
const score = await fraudScore(input.caseId)
if (score > 0.9) {
await ctx.audit('fraud_score.high', { caseId: input.caseId, score })
}
if (input.tier >= 3) {
await ctx.audit('kyc.escalated', { caseId: input.caseId })
}
await escalateCase(input.caseId, input.tier)
return { ok: true }
},
})
// Verification:
import { useAuditLog } from '@place/security'
const log = useAuditLog()
const { ok, brokenAt } = await log.verify()
if (!ok) reportTampering(brokenAt)
app: caveats — tenant scoping etc.
// app: caveats — app-defined namespace for tenant scoping etc.
// The verifier is invoked once per app: caveat at verify time.
// Fail-closed: if a macaroon carries app: caveats but no verifier is
// registered, the request is rejected.
export const readRecord = criticalAction({
path: 'POST /__a/records/read',
input: shape({ recordId: 'string' }),
requires: [perm('records.read')],
appCaveatVerifier: (key, value, { op }) => {
if (key === 'tenant') {
// Match the macaroon's tenant claim against the request context.
return value === currentRequestTenant()
}
return false // unknown app: key → fail closed
},
fn: async ({ recordId }) => db.records.find(recordId),
})
// At provision time the app attenuates the macaroon with the tenant:
const scoped = await attenuate(
await attenuate(broad.macaroon, 'op=records.*'),
`app:tenant=${user.tenantId}`,
)
action() vs criticalAction()
// action() vs criticalAction() at a glance:
// action() criticalAction()
// CSRF auto-token HMAC envelope (binds body hash)
// Replay (none — token reuse) IPsec sliding window (per session)
// Body integrity (none) envelope binds sha256(body)
// Origin binding same-origin guard binds origin into envelope tag
// Action binding path = path binds action_id into envelope tag
// Session binding SessionCap (optional) binds session.id into envelope tag
// Capability gate ctx.session.can(...) perm('op') verified via macaroon
// Audit (app-defined) hash-chained log + ctx.audit()
// Cost per call ~200 µs (validate) ~205-215 µs (envelope + replay + verify)
//
// Use action() for most mutations. Reach for criticalAction() when:
// - The action moves money, escalates privilege, or modifies records
// that compliance cares about
// - You need a tamper-evident audit trail
// - You need capability-based authorization (per-tenant, per-scope)
// - You need replay protection across multi-tab / multi-device
// scenarios (action()'s CSRF token doesn't defend against replays
// of a valid token from the same browser)
What's enforced before fn runs
- Same-origin guard (cross-origin → 403).
- Content-Length pre-check against
maxBodyBytes(oversize → 413). SessionCap.tryUse()— no session → 403.X-Place-Envelopeheader present → else 403.- Read body bytes; size guard again (post-stream).
- Verify envelope: constant-time HMAC compare on tag, then check
action_id+origin+session_id+body_hash+iatwithinmaxAgeSec. Tries current day then previous day for clock-rollover tolerance. - Replay defense via
NonceStoreCap(IPsec ESP sliding window per session). - When
requiresis non-empty: deserializeX-Place-Macaroon; derive macaroon key; verify HMAC chain + every caveat; check each declaredperm()against the macaroon's effective op-authority. - JSON parse + prototype-pollution guard.
- Schema validate (
def.input) — failure → 400. - Run
fn; auto-append audit entry; return JSON.
Every failure returns 403 Forbidden with identical body bytes — the typed reason is logged server-side but not exposed on the wire (no-info-leak oracle).