SessionHandle
A request's session, in context.session, to read, create, rotate, revoke and verify before an irreversible effect.
import { SessionHandle } from "@meekojs/meeko/server";SessionHandle is a request's session. Do not construct it manually: SessionManager.load creates it for each request and it reaches API and RPC handlers as context.session, pages' <script server> (usePage().context.session), server route middleware (session) and Socket channels. current is the active session if the request carries a valid one; create, rotate and revoke change it and prepare the response cookie.
Data has the MeekoSession type when declared by the application (since 1.2), or unknown.
Usage#
// server/api/auth/login.post.ts
import { verifyPassword } from "@meekojs/meeko/password";
import { loginInput, okResult } from "#shared/schemas/auth";
export default defineApiRoute({
body: loginInput,
response: okResult,
origin: "required",
rateLimit: { limit: 5, window: "5m" },
handler: async ({ body, context }) => {
const user = await findUser(body.email);
const valid = await verifyPassword(body.password, user?.passwordHash ?? DUMMY_HASH);
if (!user || !valid) throw new ApiError("INVALID_CREDENTIALS", 401);
// Replaces the anonymous session, if any, with a new ID and CSRF token.
await context.session.create({ userId: user.id, role: user.role });
return { ok: true as const };
},
});Type#
class SessionHandle<T = SessionData> {
constructor(manager: SessionManager<T>, current?: SessionRecord<T>);
get cookieHeader(): string | undefined;
get signal(): AbortSignal;
create(data: T, options?: SessionCreateOptions): Promise<SessionRecord<T>>;
rotate(): Promise<SessionRecord<T>>;
revoke(): Promise<void>;
verify(): Promise<void>;
}interface SessionRecord<T = unknown> {
id: string
familyId?: string
csrfToken: string
data: T
createdAt: number
expiresAt: number
ttlMs?: number
}interface SessionCreateOptions {
readonly ttlMs?: number
}interface MeekoSession {}Parameters#
Applications do not construct it. Its members:
| Member | Type | Description |
|---|---|---|
current |
SessionRecord<T> | undefined |
The active session: id, familyId, csrfToken, data, createdAt, expiresAt and, if set, ttlMs. undefined without a session. |
create(data, options?) |
Promise<SessionRecord<T>> |
Creates a session with a random ID, family and CSRF token. If one already exists, replaces it atomically while preserving its family. options.ttlMs (since 1.2) sets its lifetime. |
rotate() |
Promise<SessionRecord<T>> |
Changes the ID and CSRF token while preserving data, family and lifetime. Expiration is counted from rotation. |
revoke() |
Promise<void> |
Revokes the entire family, including in other requests and instances, and clears the cookie. The calling request is not aborted. |
verify() |
Promise<void> |
Rereads the store and throws SessionRevokedError if the session was revoked, replaced by rotation or expired. Does nothing without a session. |
signal |
AbortSignal |
Aborted with SessionRevokedError when another request or instance revokes the family. context.signal includes it. |
cookieHeader |
string | undefined |
The pending Set-Cookie header after creation, rotation or revocation. |
Return value#
create and rotate return the new SessionRecord, which becomes current; the response carries its cookie and the new token in x-meeko-csrf-token. revoke sets current to undefined and the cookie to Max-Age=0.
Examples#
Log out#
// server/api/auth/logout.post.ts
import { defineApiRoute } from "@meekojs/meeko/server";
export default defineApiRoute({
auth: "required",
status: 204,
handler: async ({ context }) => { await context.session.revoke(); },
});auth: "required" and the CSRF token protect the route; other tabs and open sockets belonging to that family close.
Different lifetimes per role#
const ttlMs = user.role === "staff" ? 12 * 60 * 60 * 1000 : 30 * 24 * 60 * 60 * 1000;
await context.session.create({ userId: user.id, role: user.role }, { ttlMs });The lifetime sets the cookie's Max-Age and the store's expiresAt, and rotate() preserves it.
Rotate after elevating privileges#
// server/rpc/account/confirm-password.ts
export default defineRpc({
input: passwordInput,
output: okOutput,
auth: "required",
handler: async ({ input, context }) => {
await checkPassword(context.session.current!.data.userId, input.password);
await context.session.rotate(); // new ID and token: a previously stolen ID becomes invalid
return { ok: true };
},
});Verify before charging#
handler: async ({ body, context }) => {
await context.session.verify(); // 401 SESSION_REVOKED si ya no vale
await payments.charge(body.orderId, { signal: context.signal });
return { ok: true };
},Cross-instance revocation arrives asynchronously; verify() rereads the store immediately before the effect.
A page that requires a session#
<!-- app/pages/dashboard.meeko -->
<script server lang="ts">
const { context } = usePage();
const session = context.session.current;
if (!session) redirect("/login");
export const userId = session.data.userId;
</script>
<template><h1>Hello, {userId}</h1></template>Common mistakes#
Logging in from a script without Origin#
curl -X POST https://app.example.com/api/auth/login -H 'content-type: application/json' -d '{"email":"…","password":"…"}'Since 1.2, create() in a POST, PUT, PATCH or DELETE requires an allowed Origin: it returns 403 INVALID_ORIGIN without touching the store. In development, the first rejection writes the warning [meeko] session.create() rechazó una petición sin un Origin permitido (403 INVALID_ORIGIN)….
curl -X POST https://app.example.com/api/auth/login -H 'origin: https://app.example.com' -H 'content-type: application/json' -d '{"email":"…","password":"…"}'origin: "none" on the route disables the check, but leaves login exposed to login CSRF.
Creating the session after a stream's first byte#
// render: "stream": the deferred value resolves after the document has started sending
load: async ({ context }) => ({
user: defer(context.session.create({ userId }).then(() => userId)),
}),Headers have already been sent: create, rotate and revoke reject with 500 SESSION_LOCKED: the session cookie was already sent with the response headers; create, rotate or revoke the session in the loader, before any deferred work.
// In the loader or critical part, before the first byte; better still, in an API.
await context.session.create({ userId });Rotating without a session#
await context.session.rotate(); // in an anonymous requestThrows Error: No active session to rotate, which the response converts to 500 INTERNAL_ERROR. If the session expired or was revoked between reading and rotation, the error is Session has expired or been revoked.
if (context.session.current) await context.session.rotate();An invalid lifetime#
await context.session.create(data, { ttlMs: 0 });Rejects with RangeError: Invalid session TTL without touching the store.
await context.session.create(data, { ttlMs: 60 * 60 * 1000 });Notes#
- Server. Exists only on the server. The browser never receives the ID (
HttpOnlycookie) and stores no authentication secrets. - Login CSRF.
create()protects login itself against another website attempting to place its session in the victim's browser;GET,HEADandOPTIONSare not checked, because OAuth callbacks and magic links arrive as navigation withoutOrigin: those flows validate their ownstateor single-use token. - Atomic rotation and revocation. A logout from an earlier handle invalidates the active ID even if another request has already rotated it, because
revokeFamilydeletes the family's active session. - Cancellation. Pass
context.signaltofetchand queries: it aborts if the client leaves or the session is revoked. Aborting is cooperative. - Streaming. With
render: "stream", change the session before the first byte or in an API route. - Types. Data is not validated at runtime: if you change the shape of
MeekoSession, stored sessions retain the previous shape until they expire.