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
// 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()
// 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
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 />,
})
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.
// <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.