{
  "$schema": "https://hyperjs.ai/schema/registry-item.json",
  "name": "session",
  "version": "0.1.0",
  "description": "Signed-cookie session middleware for Hyper. Pluggable stores.",
  "readme": "# @hyper/session\n\nSigned-cookie session middleware for Hyper. Pluggable stores.\n\n## Install\n\nComponents are installed as source into your repo, not pulled from npm:\n\n```bash\nbunx hyper add session\n```\n\nWires the alias `@hyper/session` to `src/hyper/session/` (configurable in `hyper.config.json`). See [hyperjs.ai](https://hyperjs.ai) for the full registry.\n\n## Usage\n\n```ts\nimport { Hyper } from \"@hyper/core\"\nimport { csrfGuard, session } from \"@hyper/session\"\n\nexport default new Hyper()\n  .use(session({ secret: process.env.SESSION_SECRET! }))\n  .use(csrfGuard())\n  .get(\"/me\", ({ ctx }) => ({ session: ctx.session }))\n  .listen(3000)\n```\n\n## Docs\n\nSee the [main README](../../README.md) and [docs/](../../docs) for guides and integration recipes.\n\n## License\n\nMIT\n",
  "registryDeps": [
    "core"
  ],
  "peerDeps": {},
  "optionalPeerDeps": {},
  "files": [
    {
      "path": "session/csrf.ts",
      "contents": "/**\n * CSRF double-submit protection for cookie-authenticated routes.\n *\n * Strategy: on every response where a session is active, we issue a\n * non-HttpOnly `csrf` cookie containing a random token. For mutating\n * methods (POST/PUT/PATCH/DELETE), we verify that the client echoed\n * the token back in the `X-CSRF-Token` header (constant-time compare).\n *\n * Usage:\n *\n *   const sess = session({ secret: env.SESSION_SECRET })\n *   const guard = csrfGuard()\n *   route.post(\"/mutate\").use(sess).use(guard).handle(...)\n *\n * Or pre-bind the pair:\n *\n *   const { session: sess, csrf: guard } = sessionWithCsrf({ secret })\n *\n * Pure-bearer endpoints that deliberately ignore cookies can omit\n * `csrfGuard()` — it only acts when `ctx.session` is present.\n */\n\nimport { timingSafeEqual } from \"node:crypto\"\nimport { type Middleware, coerce, createError } from \"@hyper/core\"\n\nexport interface CsrfConfig {\n  readonly cookieName?: string\n  readonly headerName?: string\n  readonly sameSite?: \"Strict\" | \"Lax\" | \"None\"\n  readonly secure?: boolean\n  /** Methods that never require a CSRF check. Default: safe verbs. */\n  readonly exemptMethods?: readonly string[]\n}\n\nconst DEFAULT_EXEMPT = [\"GET\", \"HEAD\", \"OPTIONS\"] as const\n\nexport function csrfGuard(config: CsrfConfig = {}): Middleware {\n  const cookieName = config.cookieName ?? \"csrf\"\n  const headerName = (config.headerName ?? \"x-csrf-token\").toLowerCase()\n  const sameSite = config.sameSite ?? \"Lax\"\n  const secure = config.secure ?? true\n  const exempt = new Set((config.exemptMethods ?? DEFAULT_EXEMPT).map((m) => m.toUpperCase()))\n\n  const mw: Middleware = async ({ ctx, req, next }) => {\n    const sess = (ctx as { session?: { id?: string } }).session\n    // Only protect requests whose session was *loaded from* an incoming\n    // cookie. Freshly-minted sessions (first-time login) don't have a\n    // csrf cookie to echo yet — that would create a chicken-and-egg block.\n    const isEstablished = !!sess?.id\n    const method = req.method.toUpperCase()\n    const cookieToken = readCookie(req, cookieName)\n    const hasSession = !!sess\n\n    if (isEstablished && !exempt.has(method)) {\n      const headerToken = req.headers.get(headerName)\n      if (!cookieToken || !headerToken || !constantTimeEq(cookieToken, headerToken)) {\n        throw createError({\n          status: 403,\n          code: \"csrf_token_mismatch\",\n          message: \"Missing or invalid CSRF token.\",\n          why: \"Mutating request from a cookie-authenticated session without a matching CSRF token.\",\n          fix: `Include the '${headerName}' header with the value from the '${cookieName}' cookie. Non-browser clients should use bearer auth instead of cookies.`,\n        })\n      }\n    }\n\n    const out = await next()\n    const res = out instanceof Response ? out : coerce(out)\n\n    if (hasSession && !cookieToken) {\n      const tok = newToken()\n      res.headers.append(\n        \"set-cookie\",\n        `${cookieName}=${tok}; Path=/; SameSite=${sameSite}${secure ? \"; Secure\" : \"\"}`,\n      )\n    }\n    return res\n  }\n  ;(mw as unknown as { __hyperTag: string }).__hyperTag = \"@hyper/session:csrf\"\n  return mw\n}\n\nfunction readCookie(req: Request, name: string): string | null {\n  const header = req.headers.get(\"cookie\")\n  if (!header) return null\n  for (const part of header.split(/;\\s*/)) {\n    const [k, ...rest] = part.split(\"=\")\n    if (k === name) return rest.join(\"=\")\n  }\n  return null\n}\n\nfunction constantTimeEq(a: string, b: string): boolean {\n  const ab = new TextEncoder().encode(a)\n  const bb = new TextEncoder().encode(b)\n  if (ab.length !== bb.length) return false\n  return timingSafeEqual(Buffer.from(ab), Buffer.from(bb))\n}\n\nfunction newToken(): string {\n  const buf = new Uint8Array(24)\n  crypto.getRandomValues(buf)\n  let s = \"\"\n  for (let i = 0; i < buf.length; i++) s += String.fromCharCode(buf[i]!)\n  return btoa(s).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\")\n}\n",
      "sha256": "bf0abfe2a8e8ccf2f38371e5cb4d0a9986f738b27ef169c75d412aec6655f0ca"
    },
    {
      "path": "session/index.ts",
      "contents": "/**\n * @hyper/session — encrypted, signed-cookie session middleware.\n *\n * - Cookie stores a short opaque id; payload lives in the pluggable\n *   `SessionStore` (in-memory by default).\n * - Session id is HMAC-signed; mismatch = session discarded, no 500.\n * - Rolling expiration by default; consumer can opt into absolute.\n * - `ctx.session.set/get/destroy/regenerate` for handler use.\n */\n\nimport { timingSafeEqual } from \"node:crypto\"\nimport { type Middleware, coerce } from \"@hyper/core\"\n\nexport { csrfGuard, type CsrfConfig } from \"./csrf.ts\"\n\nexport interface SessionStore {\n  get(id: string): Promise<Record<string, unknown> | undefined>\n  set(id: string, data: Record<string, unknown>, ttlMs: number): Promise<void>\n  destroy(id: string): Promise<void>\n}\n\nexport function memorySessions(): SessionStore {\n  const m = new Map<string, { data: Record<string, unknown>; expires: number }>()\n  return {\n    async get(id) {\n      const v = m.get(id)\n      if (!v) return undefined\n      if (v.expires < Date.now()) {\n        m.delete(id)\n        return undefined\n      }\n      return v.data\n    },\n    async set(id, data, ttlMs) {\n      m.set(id, { data, expires: Date.now() + ttlMs })\n    },\n    async destroy(id) {\n      m.delete(id)\n    },\n  }\n}\n\nexport interface SessionConfig {\n  readonly secret: string\n  readonly store?: SessionStore\n  readonly cookieName?: string\n  /** ms. Default: 7 days. */\n  readonly ttlMs?: number\n  /** Renew cookie on every request. Default: true. */\n  readonly rolling?: boolean\n  /** Secure cookie flag. Default: true. */\n  readonly secure?: boolean\n  readonly sameSite?: \"Strict\" | \"Lax\" | \"None\"\n  /** Opt out of the 32-byte secret check. Off by default. */\n  readonly allowShortSecret?: boolean\n}\n\nexport const MIN_SESSION_SECRET_BYTES = 32\n\nexport function validateSessionSecret(\n  secret: string,\n  opts: { readonly allowShort?: boolean } = {},\n): void {\n  if (opts.allowShort) return\n  const bytes = new TextEncoder().encode(secret).byteLength\n  if (bytes < MIN_SESSION_SECRET_BYTES) {\n    throw new Error(\n      `@hyper/session: secret is ${bytes} bytes; minimum is ${MIN_SESSION_SECRET_BYTES}. Why: short HMAC secrets let an attacker forge session ids with modest compute. Fix: generate a 32+ byte secret (e.g., \\`openssl rand -base64 48\\`) or pass \\`allowShortSecret: true\\` to opt out.`,\n    )\n  }\n}\n\nexport interface SessionHandle {\n  readonly id: string\n  get<T = unknown>(key: string): T | undefined\n  set(key: string, value: unknown): void\n  destroy(): void\n  regenerate(): void\n  readonly dirty: boolean\n}\n\ndeclare module \"@hyper/core\" {\n  interface AppContext {\n    readonly session?: SessionHandle\n  }\n}\n\nconst WEEK = 7 * 24 * 60 * 60 * 1000\n\nexport function session(config: SessionConfig): Middleware {\n  validateSessionSecret(config.secret, { allowShort: config.allowShortSecret ?? false })\n  const store = config.store ?? memorySessions()\n  const name = config.cookieName ?? \"hyper.sid\"\n  const ttl = config.ttlMs ?? WEEK\n  const rolling = config.rolling ?? true\n  const secure = config.secure ?? true\n  const sameSite = config.sameSite ?? \"Lax\"\n\n  const mw: Middleware = async ({ ctx, req, next }) => {\n    const existingId = await readSignedCookie(req, name, config.secret)\n    let id = existingId ?? \"\"\n    let data: Record<string, unknown> = {}\n    if (id) {\n      data = (await store.get(id)) ?? {}\n    }\n    let dirty = false\n    let destroyed = false\n    let regenerated = false\n\n    const handle: SessionHandle = Object.freeze({\n      get id() {\n        return id\n      },\n      get<T>(k: string): T | undefined {\n        return data[k] as T | undefined\n      },\n      set(k, v) {\n        data[k] = v\n        dirty = true\n      },\n      destroy() {\n        destroyed = true\n        dirty = true\n      },\n      regenerate() {\n        regenerated = true\n        dirty = true\n      },\n      get dirty() {\n        return dirty\n      },\n    })\n    ;(ctx as { session?: SessionHandle }).session = handle\n\n    const out = await next()\n    const res = out instanceof Response ? out : coerce(out)\n    if (destroyed && id) {\n      await store.destroy(id)\n      res.headers.append(\n        \"set-cookie\",\n        `${name}=; Path=/; Max-Age=0; HttpOnly; SameSite=${sameSite}${secure ? \"; Secure\" : \"\"}`,\n      )\n      return res\n    }\n    if (regenerated || (!id && Object.keys(data).length > 0)) {\n      if (id) await store.destroy(id)\n      id = newId()\n    }\n    if (dirty || (rolling && id)) {\n      if (!id) id = newId()\n      await store.set(id, data, ttl)\n      const signed = await signCookie(id, config.secret)\n      res.headers.append(\n        \"set-cookie\",\n        `${name}=${signed}; Path=/; Max-Age=${Math.floor(ttl / 1000)}; HttpOnly; SameSite=${sameSite}${\n          secure ? \"; Secure\" : \"\"\n        }`,\n      )\n    }\n    return res\n  }\n  ;(mw as unknown as { __hyperTag: string }).__hyperTag = \"@hyper/session\"\n  return mw\n}\n\nasync function readSignedCookie(\n  req: Request,\n  name: string,\n  secret: string,\n): Promise<string | null> {\n  const header = req.headers.get(\"cookie\")\n  if (!header) return null\n  for (const part of header.split(/;\\s*/)) {\n    const [k, ...rest] = part.split(\"=\")\n    if (k === name && rest.length) {\n      const value = rest.join(\"=\")\n      const id = await verifyCookie(value, secret)\n      if (id) return id\n    }\n  }\n  return null\n}\n\nfunction newId(): string {\n  return crypto.randomUUID().replace(/-/g, \"\")\n}\n\nasync function signCookie(id: string, secret: string): Promise<string> {\n  const key = await importHmac(secret)\n  const sig = await crypto.subtle.sign(\"HMAC\", key, new TextEncoder().encode(id))\n  const b64 = b64url(new Uint8Array(sig))\n  return `${id}.${b64}`\n}\n\nasync function verifyCookie(value: string, secret: string): Promise<string | null> {\n  const dot = value.lastIndexOf(\".\")\n  if (dot <= 0) return null\n  const id = value.slice(0, dot)\n  const sigB64 = value.slice(dot + 1)\n  const key = await importHmac(secret)\n  const expected = new Uint8Array(\n    await crypto.subtle.sign(\"HMAC\", key, new TextEncoder().encode(id)),\n  )\n  const actual = fromB64url(sigB64)\n  if (expected.length !== actual.length) return null\n  if (!timingSafeEqual(Buffer.from(expected), Buffer.from(actual))) return null\n  return id\n}\n\nasync function importHmac(secret: string): Promise<CryptoKey> {\n  return crypto.subtle.importKey(\n    \"raw\",\n    new TextEncoder().encode(secret),\n    { name: \"HMAC\", hash: \"SHA-256\" },\n    false,\n    [\"sign\"],\n  )\n}\n\nfunction b64url(b: Uint8Array): string {\n  let s = \"\"\n  for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]!)\n  return btoa(s).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\")\n}\nfunction fromB64url(s: string): Uint8Array {\n  const pad = s.length % 4 === 0 ? 0 : 4 - (s.length % 4)\n  const b64 = (s + \"====\".slice(0, pad)).replace(/-/g, \"+\").replace(/_/g, \"/\")\n  const bin = atob(b64)\n  const out = new Uint8Array(bin.length)\n  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)\n  return out\n}\n",
      "sha256": "24334ad83d54e7cc091c50cc1e4edf65855a3ff66d68c245eb60dc4472da6ee4"
    },
    {
      "path": "session/sqlite.ts",
      "contents": "/**\n * bun:sqlite-backed SessionStore — persistent, single-node production.\n * For multi-node, use Redis.\n */\n\nimport { Database } from \"bun:sqlite\"\nimport type { SessionStore } from \"./index.ts\"\n\nexport interface SqliteSessionOptions {\n  readonly path?: string\n}\n\nexport function sqliteSessions(opts: SqliteSessionOptions = {}): SessionStore & {\n  readonly sweep: () => void\n  readonly close: () => void\n} {\n  const path = opts.path ?? \"./.hyper/sessions.sqlite\"\n  const db = new Database(path, { create: true })\n  db.exec(\"PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;\")\n  db.exec(`\n    CREATE TABLE IF NOT EXISTS hyper_sessions (\n      id TEXT PRIMARY KEY,\n      data TEXT NOT NULL,\n      expires INTEGER NOT NULL\n    );\n    CREATE INDEX IF NOT EXISTS hyper_sessions_expires ON hyper_sessions (expires);\n  `)\n  const stmtGet = db.prepare(\"SELECT data, expires FROM hyper_sessions WHERE id = ?\")\n  const stmtSet = db.prepare(\n    \"INSERT OR REPLACE INTO hyper_sessions (id, data, expires) VALUES (?, ?, ?)\",\n  )\n  const stmtDel = db.prepare(\"DELETE FROM hyper_sessions WHERE id = ?\")\n  const stmtSweep = db.prepare(\"DELETE FROM hyper_sessions WHERE expires < ?\")\n\n  return {\n    async get(id) {\n      const row = stmtGet.get(id) as { data: string; expires: number } | undefined\n      if (!row) return undefined\n      if (row.expires < Date.now()) {\n        stmtDel.run(id)\n        return undefined\n      }\n      return JSON.parse(row.data) as Record<string, unknown>\n    },\n    async set(id, data, ttlMs) {\n      stmtSet.run(id, JSON.stringify(data), Date.now() + ttlMs)\n    },\n    async destroy(id) {\n      stmtDel.run(id)\n    },\n    sweep: () => {\n      stmtSweep.run(Date.now())\n    },\n    close: () => db.close(),\n  }\n}\n",
      "sha256": "e721a9469d938493d7602ed827e7fe7622ae189d945b333f6d94cffd0421caef"
    }
  ],
  "subpaths": {}
}