place

Authentication

Sessions live in a cookie, guards live in a layout's load(), and the framework flows the typed user into every page underneath. No middleware DSL, no global request/response pipeline — just composition.

Session store as a capability

ts
// src/auth.ts import { defineCapability } from '@place/capability' interface SessionStore { get(req: Request): Promise<Session | null> set(user: User): { value: string; cookie: string } clear(): { cookie: string } } export const SessionCap = defineCapability<SessionStore>('Session')

Express the contract once. Swap implementations (in-memory for tests, KV in prod) without touching consumers.

Guard via a layout's load()

ts
// Reusable layout that guards every page under it. import { layout } from '@place/component' import { SessionCap } from '../auth' export const requireAuth = layout({ load: async ({ req }) => { const session = await SessionCap.use().get(req) if (!session) throw redirect('/login?next=' + encodeURIComponent(req.url)) return { user: session.user } }, view: ({ children, user }) => ( <> <UserBadge name={user.name} /> {children} </> ), }) // Usage: page('/dashboard', { layout: [rootLayout, requireAuth], view: ({ user }) => <Dash user={user} />, })

A layout's load() can throw a redirect() to short-circuit the page's render. The user object flows into every nested page via loadData, typed end-to-end.

Login + set-cookie

ts
page('/login', { on: { submit: async ({ email, password }: Creds, { req }) => { const user = await verify(email, password) if (!user) return { ok: false, error: 'invalid' } const { cookie } = SessionCap.use().set(user) return new Response(null, { status: 302, headers: { location: '/dashboard', 'set-cookie': cookie }, }) }, }, view: () => <LoginForm />, })
Don't roll your own crypto
Use oslo, iron-session, or your platform's built-in session primitive for signing. place's contribution is the composition story — the crypto is yours.

RBAC: <Can>

Once a session is installed, gate UI on a per-action predicate. <Can> reads SessionCap.tryUse()?.can?.(action) at render time and fails closed — if there's no session, no .can, or the predicate doesn't return strictly true, the denied content is never emitted. Because the check is synchronous, the gate works pre-hydration; unauthorized content stays out of view-source, not just hidden via CSS.

ts
// <Can> — render-time RBAC gate (T16-E, ADR 0044). // Renders its children only when session.can(action) returns true. // Fails closed (no session, no .can predicate, anything other than // strict true → renders `otherwise` or nothing). Synchronous // predicate → works pre-hydration in SSR; denied content NEVER // appears in rendered HTML, NEVER ships JS for the hidden island. 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="You don't have access."> <UserTable /> </Can> // Populate Session.can at session-install time from whatever policy // engine you use — Cerbos, Permify, hand-rolled RBAC. The framework // stays out of the policy DSL business. import { SessionCap } from '@place/security' SessionCap.provide( { id, userId, issuedAt, expiresAt, can: (action) => policy.evaluate(userId, action), }, () => handler(req), )

<Can> lives in @place/security (data lives there, not in the design library). The framework doesn't ship a policy DSL — apps wire any authorization engine (Cerbos, Permify, hand-rolled) into Session.can at install time. See ADR 0044.

See also