place

@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.

Charter: secure-by-default
Insecure choices require explicit opt-in syntax ({ 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.

ts
// 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.

ts
// <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.

ts
// 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)

ts
// 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)

ts
// 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)

ts
// 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

ts
// 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

ts
// 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", })

See also