@place/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/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/security'
import { Button } from '@place/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/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/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/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/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/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/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/security'
const header = cspHeader(CSP_DEFAULTS)
// Extend:
const custom = cspHeader({
...CSP_DEFAULTS,
'connect-src': "'self' https://api.example.com",
})