Manual Session

Session

elements man session Read as markdown

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

import { session, sql, AuthError } from "@elements/app";

/** @rpc */
export function login(email: string, password: string) {
  let user = sql<User>(`
    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<User>(`select * from users where id = ${session.getOrThrow("userId")}`).firstOrThrow("user not found");
}

In a template:

<header>
  <span e:if={session.isLoggedIn()}>{session.get("userName")}</span>
  <a e:else href="/login">log in</a>
</header>

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.

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

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.

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

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

// 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;
}
/** @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>
  <p e:if={session.isLoggedIn()}>welcome, {session.get("userName")}</p>
  <p e:else>please <a href="/login">log in</a></p>
</html>

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.