@place-ts/security
Primitives the framework's security: 'standard' default builds on: signed tokens, double-submit CSRF, rate limiting, a capability-typed session slot, secure-by- default cookie helpers, strict CSP. Not an auth library — no OAuth dance, no JWT, no password hashing. The substrate every auth library needs, exposed as one package.
{ insecure: true }, 'unsafe-inline' literal, …) so the audit trail in source is obvious. See Concepts: Security for the full pipeline.SessionCap + requireSession
Capability-typed session. Apps populate it inside the request handler after their cookie lookup. Handlers that need an authenticated user call requireSession() which throws a typed SecurityError (401) when the slot is empty.
// Session capability — typed runtime slot for the authenticated user.
import { SessionCap, requireSession, type Session } from '@place-ts/security'
// Install at the request boundary (typically in a layout's load()):
SessionCap.provide(
{
id: sessionId,
userId: user.id,
issuedAt: Date.now(),
expiresAt: null,
// Optional RBAC predicate; populated from your policy engine.
can: (action) => policy.evaluate(user, action),
},
() => handler(req),
)
// Read inside any handler that requires auth — throws 401 if absent.
const session = requireSession()
const userId = session.userId
// Optional read — returns null if not installed.
const maybe = SessionCap.tryUse()
<Can do="..."> — RBAC gate (ADR 0044)
Renders its children only when the current session's .can(action) predicate returns strictly true. Fails closed by default. Synchronous predicate runs at render time inside a reactive function child, so the gate works pre-hydration in SSR — unauthorized content never appears in rendered HTML, never ships JS for the hidden island, and is invisible to view-source.
// <Can do="..."> — render-time RBAC gate (ADR 0044).
// Fails closed: no session, no .can, anything other than strict true.
// Synchronous predicate → SSR-safe, denied content never reaches HTML.
import { Can } from '@place-ts/security/can'
import { Button } from '@place-ts/design'
<Can do="post.delete">
<Button intent="destructive" onClick={remove}>Delete</Button>
</Can>
<Can do="admin.users.read" otherwise="Access denied">
<UserTable />
</Can>
The framework does not ship a policy DSL. Wire any policy engine (Cerbos, Permify, hand-rolled) into Session.can at install time. See Recipes: Authentication & RBAC for the full session flow.
fromStandard + isValidationFailure (ADR 0045)
Schema interop for action() inputs. Adapts any Standard Schema v1 validator (Zod 3.24+, Valibot 0.36+, ArkType, Effect Schema) into an ActionSchema<T>. On validation failure, throws ActionError(400, 'Validation failed', { fields }). Lives in @place-ts/component (it's part of the action surface); re-documented here for discoverability.
// Schema-agnostic validation for action() inputs (ADR 0045).
// Any Standard Schema v1 validator works: Zod 3.24+, Valibot 0.36+,
// ArkType, Effect Schema. Field-level errors land in
// ActionError.payload.fields, narrowed via isValidationFailure.
import { z } from 'zod'
import { action, fromStandard, isValidationFailure } from '@place-ts/component'
export const signup = action({
path: 'POST /api/signup',
input: fromStandard(z.object({
email: z.string().email(),
age: z.number().int().min(18),
})),
fn: async ({ email, age }) => ({ ok: true }),
})
// Client-side:
<Form action={signup} onError={(e) => {
if (e instanceof ActionError && isValidationFailure(e.payload)) {
emailErr.set(e.payload.fields.email ?? '')
}
}}>
{/* ... */}
</Form>
See Recipes: Forms & actions for the full form-field wiring pattern.
signedToken<T>(secret)
// HMAC-signed opaque payload. SHA-256, Web Crypto. Optional expiry.
import { signedToken } from '@place-ts/security'
const sessionToken = signedToken<{ userId: string }>(SECRET, {
expiresInSeconds: 60 * 60 * 24 * 7, // 7 days
})
const token = await sessionToken.sign({ userId: 'u123' })
const payload = await sessionToken.verify(token) // null on bad sig / expired
// Same primitive for any "trust this opaque string came from us
// unmodified" use case: session cookies, share links, magic-link tokens,
// signed download URLs.
csrfToken(secret)
// Double-submit CSRF — auto-wired into action() + <Form> when
// security: 'standard' is on (the default). The primitive is exposed
// for apps that need it outside that path.
import { csrfToken } from '@place-ts/security'
const csrf = csrfToken(SECRET)
const token = await csrf.issue(sessionId)
const ok = await csrf.verify(sessionId, submittedToken)
rateLimit(options)
// In-memory token bucket. For multi-instance deployments wrap with
// a shared backend (Redis, KV) behind the same interface.
import { rateLimit } from '@place-ts/security'
const checkLogin = rateLimit({ windowMs: 60_000, max: 5 })
// In a handler:
if (!checkLogin(req.headers.get('x-forwarded-for') ?? 'anon')) {
return new Response('Too many requests', { status: 429 })
}
Cookies — parseCookies, setCookieHeader, clearCookieHeader
// Secure-by-default cookie helpers.
import { parseCookies, setCookieHeader, clearCookieHeader } from '@place-ts/security'
// Parse incoming Cookie header.
const cookies = parseCookies(req.headers.get('cookie'))
const sessionCookie = cookies['place-session']
// Set: HttpOnly + Secure + SameSite=Lax baked in. Path=/ implicit.
const setCookie = setCookieHeader('place-session', token, {
maxAgeSeconds: 60 * 60 * 24 * 7,
})
return new Response(body, { headers: { 'set-cookie': setCookie } })
// Clear:
const clear = clearCookieHeader('place-session')
// Localhost dev: pass insecure: true to drop the Secure flag.
// **Never in production** — the explicit name keeps the choice visible.
CSP — CSP_DEFAULTS + cspHeader
// Strict CSP starter. The serve({ security: 'standard' }) path
// applies this header with a fresh per-request nonce. No 'unsafe-inline'
// anywhere; auto-hash injection covers inline-style attrs (ADR 0014).
import { CSP_DEFAULTS, cspHeader } from '@place-ts/security'
const header = cspHeader(CSP_DEFAULTS)
// Extend:
const custom = cspHeader({
...CSP_DEFAULTS,
'connect-src': "'self' https://api.example.com",
})
Envelope — signEnvelope + verifyEnvelope (ADR 0055)
HMAC envelope substrate. The canonical metadata blob binds an actionId, body hash, monotonic counter, issued-at timestamp, origin, session id, and key id — then signs it with a per-session HMAC key. Constant-time verify on the way in. Used by criticalAction() and exposed for custom transports (websocket frames, signed pub/sub messages).
// HMAC envelope — the substrate criticalAction() builds on.
// Signs a canonical metadata blob that binds the action, body hash,
// session, origin, counter, and iat. signEnvelope() returns a single
// wire string; verifyEnvelope() returns ok + typed reason on failure.
//
// Apps don't usually call these directly — criticalAction() does — but
// they're exposed for custom transports (websocket frames, signed
// pub/sub messages, etc.).
import { signEnvelope, verifyEnvelope, sha256Base64url } from '@place-ts/security'
const wire = await signEnvelope(perSessionKey, {
actionId: 'POST /__a/transfer',
bodyHash: await sha256Base64url(bodyBytes),
counter: nextCounter,
iat: Math.floor(Date.now() / 1000),
origin: 'https://app.example.com',
sessionId: session.id,
keyId: 'b20142',
})
const r = await verifyEnvelope(wire, {
key: perSessionKey,
body: bodyBytes,
expectedActionId: 'POST /__a/transfer',
expectedOrigin: 'https://app.example.com',
expectedSessionId: session.id,
maxAgeSec: 300,
})
if (!r.ok) reject(r.reason) // bad-tag | stale | replay | wrong-session | …
Macaroons — mintMacaroon + attenuate + verifyMacaroon (ADR 0055)
HMAC-chained bearer tokens with attenuating caveats — the Stanford / Google paper, in 300 lines. Apps mint a broad token at the auth boundary, narrow it to match the user's actual permissions, and pass the serialised wire string to the browser. Anyone holding the token can attenuate further (the chain extends) but cannot widen (that requires the root key). criticalAction({ requires }) uses these structurally; the primitive is exposed for capability-based authorization in custom paths.
// Macaroons — HMAC-chained bearer tokens with attenuating caveats.
// criticalAction({ requires }) uses them; the primitives are exposed
// for apps that want capability-based authorization elsewhere.
//
// Stanford / Google macaroons paper. Each caveat NARROWS the token's
// authority — a holder can attenuate without the root key (HMAC over
// existing tag), but cannot widen.
import {
mintMacaroon,
attenuate,
verifyMacaroon,
serializeMacaroon,
deserializeMacaroon,
} from '@place-ts/security'
// Mint at the auth boundary.
const root = await mintMacaroon(rootKey, session.id)
// Narrow to the user's actual permissions.
const scoped = await attenuate(root, 'op=comments.*')
const tenanted = await attenuate(scoped, 'app:tenant=acme')
const dated = await attenuate(tenanted, 'expires=2026-06-01T00:00:00Z')
// Send to the browser:
const wire = serializeMacaroon(dated) // header-safe single-line string
// Verify on receive:
const r = await verifyMacaroon(deserializeMacaroon(wire), rootKey, {
op: 'comments.create',
origin: 'https://app.example.com',
appVerifier: (key, value, ctx) => key === 'tenant' && value === requestTenant(),
})
if (!r.ok) reject(r.reason)
// reason ∈ 'bad-sig' | 'malformed' | 'unknown-caveat' | 'expired'
// | 'wrong-op' | 'wrong-origin' | 'app-denied'
// Caveat grammar (v0.1, fail-closed on anything else):
// expires=<ISO-8601 UTC>
// origin=<URL>
// op=<name> // exact
// op=<prefix>.* // prefix match
// op=* // wildcard
// app:<key>=<value> // requires appVerifier; rejected otherwise
//
// Multiple op= caveats compose by INTERSECTION (order-free).
<Can do="…"> is a render-time predicate — fast, sync, perfect for UI gating. Macaroons are tokens: they carry authority WITH the request, so a sub-system handling part of the work can trust an attenuated token without re-fetching the session. They compose. UI gating and request-time authorization are different jobs — this is the second one.Audit log — AuditLogCap + inMemoryAuditLog (ADR 0055)
Hash-chained tamper-evident log. Each entry binds the previous via prev_hash = sha256(canonical(entry_{i-1})); any retroactive modification breaks verify() and reports the broken index. criticalAction() auto-appends one entry per invocation; ctx.audit(event, payload?) appends handler-emitted events alongside. The in-memory adapter is a ring buffer (default 10k entries); apps with retention requirements plug in a durable adapter conforming to the AuditLog interface.
// Hash-chained tamper-evident audit log. criticalAction() auto-appends
// one entry per invocation (request body hash + result hash bound).
// Apps emit additional entries via ctx.audit() inside the handler.
// Any modification to an existing entry breaks the chain and is caught
// by verify().
import { AuditLogCap, inMemoryAuditLog, useAuditLog } from '@place-ts/security'
// At app boot:
AuditLogCap.install(inMemoryAuditLog({ maxEntries: 10_000 }))
// ↑ ring-buffered in-memory adapter. Replace with a durable adapter
// (postgres / S3 / object-store) that conforms to AuditLog.
// Inside any handler (criticalAction sets it up automatically; manual
// use is fine too):
const log = useAuditLog()
await log.append({
actor: session.userId,
action: 'admin.role.change',
payloadHash: await sha256Base64url(payloadBytes),
resultHash: '',
keyId: 'b20142',
})
// Verify the chain anywhere:
const { ok, brokenAt } = await log.verify()
if (!ok) reportTampering(brokenAt)