# Session Session in Elements is unified across your entire app. Route handlers, rpc functions, and reactive templates all read the calling user's session through one API with one set of semantics. Session works the same way over HTTP as it does over WebSockets. Browser templates that read the session are reactive: a login flips the UI without a page reload. A session in Elements is login state. It exists only after `session.login()`. An anonymous visitor has no session row, no token, and no cookie. There is no anonymous session id. See "Identifying Anonymous Visitors" below for how to track visitors who haven't logged in. ## At a Glance ```ts import { session, sql, AuthError } from "@elements/app"; /** @rpc */ export function login(email: string, password: string) { let user = sql(` select * from users where email = ${email} and passwordHash = crypt(${password}, passwordHash) `).first(); if (!user) { throw new AuthError("invalid credentials"); } session.login({ userId: user.id, userName: user.name }); } /** @rpc */ export function logout() { session.logout(); } /** @rpc */ export function getMyProfile(): User { session.isLoggedInOrThrow(); return sql(`select * from users where id = ${session.getOrThrow("userId")}`).firstOrThrow("user not found"); } ``` In a template: ```html
{session.get("userName")} log in
``` The header re-renders the moment login state changes. ## Session Data The fields carried on a session (`userId`, `userName`, whatever your app needs) are declared once through module augmentation. This gives `session.get()`, `session.getOrThrow()`, and `session.login()` full type checking. ```ts // app/types/session.d.ts declare module "@elements/app" { interface SessionData { userId: string; userName: string; } } ``` `SessionData` is empty by default. Once you augment it: - `session.login()` requires a matching data object: `session.login({ userId, userName })`. - `session.get("userId")` returns `string | undefined`. - `session.getOrThrow("userId")` returns `string` (throws if missing). - An unknown key (`session.get("nope")`) is a compile error. The data is stored in the `data` jsonb column of the session's `elements.sessions` row. ## API Read methods (`isLoggedIn`, `get`, `getOrThrow`, `willExpireIn`) are reactive in the browser. If the session changes, every template that reads it re-renders. Mutation methods (`login`, `logout`, `renew`, `findActiveSessions`, `revoke`) are server-only. - `session.login(data)`: server-only. Creates a session row, mints an opaque token, and communicates it to the browser. `data` matches your augmented `SessionData`; omit it entirely when `SessionData` is empty. - `session.logout()`: server-only. Deletes the session row and clears the cookie. - `session.renew()`: server-only. Takes no arguments. Extends the current session's expiry to now plus the configured policy duration. No-op if not logged in. - `session.isLoggedIn()`: `boolean`. - `session.isLoggedInOrThrow()`: throws if not logged in; otherwise a no-op. - `session.get(key)`: a typed field from the session data, or `undefined`. - `session.getOrThrow(key)`: a typed field from the session data; throws if missing. - `session.willExpireIn(durationMs)`: `boolean`. True when the session expires within `durationMs` of now. - `session.expiresAt()`: `Date | null`. Null when there is no expiry. - `session.id()`: `string | null`. The session's non-secret id, safe to log or show in support tooling. Null when anonymous. - `session.findActiveSessions(key, value)`: server-only. Returns the session rows whose data field `key` equals `value`, for account features like "list my sessions across devices". - `session.revoke(token)`: server-only. Deletes a session by its token, forcing that device to log out. Take the token from a row returned by `findActiveSessions()`. ## Login and Logout ```ts session.login({ userId: user.id, userName: user.name }); session.logout(); ``` Expiry is a config policy (see Config below), not a per-login argument. `session.login()` and `session.logout()` are server-only. Call them from anywhere on the server: an `@rpc` body, a route handler, or any helper reachable from those. Calling them from browser-reachable code is a compile error pointing at the call site. ## Renew Extends the current session's expiration, anchored at now. ```ts session.renew(); ``` `renew()` takes no arguments: the amount of the extension is the configured `expires` policy. It is a no-op if not logged in. Use it after a significant action, or from your own activity heuristic when `autoRenew` is off (see Config). ## Sliding Window With `autoRenew` on (the default), every authenticated request extends the session's expiry automatically. Elements writes a fresh `Set-Cookie` and silently updates the server row (no WebSocket fanout). Every request keeps the session alive without any app code. Turn `autoRenew` off for strict-activity apps (banking-style "log out after N minutes of true inactivity"). With it off, Elements never extends the session; call `session.renew()` yourself from your own activity signal. ## Config ```jsoc // config.jsoc { session: { expires: '30d', autoRenew: true, }, } ``` - `expires`: expiration policy. Default `'30d'`. - `'close'`: a browser session cookie with no server-side expiry; dies when the browser closes. - A duration string (`'15m'`, `'7d'`, `'30d'`, `'1y'`): a sliding expiry. The server row's `expires_at` and the cookie `Max-Age` both track it. With `autoRenew` on, each active request advances it, so `'1y'` is the long "remember me". - `autoRenew`: automatic sliding-window renewal. Default `true`. When on, every authenticated request advances the expiry. Set `false` for strict-activity apps that renew manually. - `domain`: optional `Domain` attribute for the cookie. Set to a parent domain (`.example.com`) to share the session across subdomains. Leave unset in development. Browsers reject `Domain` for localhost and IP hosts. - `reaperIntervalSeconds`: how often the expired-session reaper runs. Default `30`. The session cookie is set with `Path=/` and `SameSite=Lax`. `Lax` rather than `Strict` so a user returning from an OAuth or payment-provider redirect still arrives authenticated. The cookie is intentionally **not** `HttpOnly` and **not** HMAC-signed: the runtime reads the session payload from the cookie in JavaScript at page load so the first render is correct without waiting on the WebSocket. Security does not rest on the cookie being unreadable. It rests on the opaque token being unguessable and validated server-side against the `elements.sessions` row on every request. ## What's Stored Sessions are stored server-side in the `elements.sessions` table. The cookie carries a small payload: - `token`: the opaque credential. It is the session's identifier in every code path; the row's primary-key `id` column is never surfaced. - `data`: your `SessionData` fields, for synchronous access on the next page load. - `expiresAt`: when the session expires. The token is what authenticates the request: it's looked up against the server row every time. State that doesn't belong on the cookie lives in your own tables, keyed by a data field such as `session.get("userId")`. ## Identity vs Credential The token is a *credential*, proof of an authenticated session, and is never exposed to your code. The session's own id is `session.id()`, the non-secret row primary key: safe to log or show in support tooling, but it identifies a *session*, not a user (a login starts a new session). For application identity, meaning who the user is, use a `SessionData` field you set at login and read back with `session.get("userId")`. Session is auth state, not an identity service. ## Identifying Anonymous Visitors Because a session exists only after login, an anonymous visitor has nothing to key on server-side: no row, no token, no cookie. This is deliberate: a server-generated anonymous id would force a `Set-Cookie` on every visitor, which forces `Vary: Cookie` and breaks edge caching. To track visitors before they log in (analytics, an abandoned draft, a wizard-in-progress), generate the id in the browser and persist it client-side, then pass it explicitly to the server: ```ts // browser: generate once, reuse across visits function anonId(): string { let id = localStorage.getItem("anonId"); if (!id) { id = crypto.randomUUID(); localStorage.setItem("anonId", id); } return id; } ``` ```ts /** @rpc */ export function saveDraft(anonId: string, text: string) { sql(` insert into drafts (anonId, text) values (${anonId}, ${text}) on conflict (anonId) do update set text = excluded.text `); } ``` The app owns the anonymous id; Elements stays out of it. On login, associate the stored `anonId` with the new `userId` if you want continuity across the boundary. ## Reactivity in Templates Read methods are reactive. Templates that read the session re-render automatically when the session changes. After `session.login()` lands via rpc, every UI element that depends on session state updates immediately. ```html

welcome, {session.get("userName")}

please log in

``` The `e:if` branch swaps the moment the session state flips. ## Browser Session Lifecycle Each page navigation creates a new request and a new WebSocket. The session arrives with the new request, read synchronously from the cookie payload. Session state is consistent across the WebSocket: a login via rpc is immediately visible to subsequent rpc calls and to live data on the same connection. Closing the tab or navigating away does not log the user out. Logout is an explicit `session.logout()` call. ## Related - `rpc`: the `@rpc` boundary, authorization patterns, error catalog. - `router`: route handlers have access to the same `session`.