@broberg/lens 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -65,7 +65,13 @@ function createLensMintHandler(opts) {
65
65
  ttlMs,
66
66
  expiresAt
67
67
  };
68
- const minted = await opts.createSession(ctx);
68
+ let minted;
69
+ try {
70
+ minted = await opts.createSession(ctx);
71
+ } catch (err) {
72
+ console.error("[@broberg/lens] createSession threw while minting a lens session:", err);
73
+ return { status: 500, body: { error: "lens-session mint failed" } };
74
+ }
69
75
  const cookies = Array.isArray(minted) ? minted : [minted];
70
76
  const fallbackDomain = opts.cookieDomain ?? process.env.LENS_COOKIE_DOMAIN ?? req.host;
71
77
  const expiresSec = Math.floor(expiresAt / 1e3);
@@ -87,5 +93,5 @@ function createLensMintHandler(opts) {
87
93
  }
88
94
 
89
95
  export { createLensMintHandler };
90
- //# sourceMappingURL=chunk-4QJFODYF.js.map
91
- //# sourceMappingURL=chunk-4QJFODYF.js.map
96
+ //# sourceMappingURL=chunk-VYKHHZQA.js.map
97
+ //# sourceMappingURL=chunk-VYKHHZQA.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;AAiBA,IAAM,cAAA,GAAiB,KAAK,EAAA,GAAK,GAAA;AAEjC,IAAM,aAAa,EAAA,GAAK,GAAA;AACxB,IAAM,sBAAA,GAAyB,EAAA;AAG/B,IAAM,mBAAA,GAAsB,gBAAA;AAyF5B,SAAS,SAAA,CAAU,GAAW,CAAA,EAAoB;AAChD,EAAA,MAAM,KAAK,UAAA,CAAW,QAAQ,EAAE,MAAA,CAAO,CAAC,EAAE,MAAA,EAAO;AACjD,EAAA,MAAM,KAAK,UAAA,CAAW,QAAQ,EAAE,MAAA,CAAO,CAAC,EAAE,MAAA,EAAO;AACjD,EAAA,OAAO,eAAA,CAAgB,IAAI,EAAE,CAAA;AAC/B;AAEA,SAAS,YAAY,aAAA,EAA6C;AAChE,EAAA,IAAI,CAAC,eAAe,OAAO,IAAA;AAC3B,EAAA,MAAM,CAAA,GAAI,aAAA,CAAc,KAAA,CAAM,kBAAkB,CAAA;AAChD,EAAA,OAAO,CAAA,GAAI,CAAA,CAAE,CAAC,CAAA,CAAG,MAAK,GAAI,IAAA;AAC5B;AAEA,SAAS,SAAS,KAAA,EAAuB;AACvC,EAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,KAAK,GAAG,OAAO,cAAA;AACpC,EAAA,OAAO,KAAK,GAAA,CAAI,IAAA,CAAK,IAAI,KAAA,EAAO,UAAU,GAAG,cAAc,CAAA;AAC7D;AASO,SAAS,sBACd,IAAA,EACqD;AACrD,EAAA,MAAM,SAAA,GAAA,CAAa,IAAA,CAAK,SAAA,IAAa,EAAA,EAAI,IAAA,EAAK;AAC9C,EAAA,IAAI,CAAC,SAAA,EAAW;AACd,IAAA,MAAM,IAAI,KAAA;AAAA,MACR;AAAA,KACF;AAAA,EACF;AACA,EAAA,IAAI,SAAA,CAAU,WAAA,EAAY,KAAM,mBAAA,EAAqB;AACnD,IAAA,MAAM,IAAI,KAAA;AAAA,MACR,2BAA2B,mBAAmB,CAAA,+FAAA;AAAA,KAChD;AAAA,EACF;AAEA,EAAA,MAAM,YAAA,GAAe,KAAK,YAAA,IAAgB,sBAAA;AAC1C,EAAA,IAAI,WAAA,GAAc,KAAK,GAAA,EAAI;AAC3B,EAAA,IAAI,KAAA,GAAQ,CAAA;AACZ,EAAA,SAAS,UAAA,GAAsB;AAC7B,IAAA,IAAI,YAAA,IAAgB,GAAG,OAAO,IAAA;AAC9B,IAAA,MAAM,GAAA,GAAM,KAAK,GAAA,EAAI;AACrB,IAAA,IAAI,GAAA,GAAM,eAAe,GAAA,EAAQ;AAC/B,MAAA,WAAA,GAAc,GAAA;AACd,MAAA,KAAA,GAAQ,CAAA;AAAA,IACV;AACA,IAAA,KAAA,IAAS,CAAA;AACT,IAAA,OAAO,KAAA,IAAS,YAAA;AAAA,EAClB;AAEA,EAAA,OAAO,eAAe,OAAO,GAAA,EAAiD;AAE5E,IAAA,MAAM,MAAA,GAAS,IAAA,CAAK,MAAA,IAAU,OAAA,CAAQ,GAAA,CAAI,gBAAA;AAC1C,IAAA,IAAI,CAAC,MAAA,EAAQ;AACX,MAAA,OAAO,EAAE,MAAA,EAAQ,GAAA,EAAK,MAAM,EAAE,KAAA,EAAO,kDAAiD,EAAE;AAAA,IAC1F;AAEA,IAAA,MAAM,QAAA,GAAW,WAAA,CAAY,GAAA,CAAI,aAAa,CAAA;AAC9C,IAAA,IAAI,CAAC,QAAA,IAAY,CAAC,SAAA,CAAU,QAAA,EAAU,MAAM,CAAA,EAAG;AAC7C,MAAA,OAAO,EAAE,MAAA,EAAQ,GAAA,EAAK,MAAM,EAAE,KAAA,EAAO,gBAAe,EAAE;AAAA,IACxD;AAIA,IAAA,IAAI,CAAC,YAAW,EAAG;AACjB,MAAA,OAAO,EAAE,MAAA,EAAQ,GAAA,EAAK,MAAM,EAAE,KAAA,EAAO,gBAAe,EAAE;AAAA,IACxD;AAEA,IAAA,MAAM,KAAA,GAAQ,QAAA,CAAS,IAAA,CAAK,KAAA,IAAS,cAAc,CAAA;AACnD,IAAA,MAAM,SAAA,GAAY,IAAA,CAAK,GAAA,EAAI,GAAI,KAAA;AAC/B,IAAA,MAAM,GAAA,GAA0B;AAAA,MAC9B,SAAA;AAAA,MACA,MAAM,GAAA,CAAI,IAAA;AAAA,MACV,QAAQ,GAAA,CAAI,MAAA;AAAA,MACZ,KAAA;AAAA,MACA;AAAA,KACF;AAMA,IAAA,IAAI,MAAA;AACJ,IAAA,IAAI;AACF,MAAA,MAAA,GAAS,MAAM,IAAA,CAAK,aAAA,CAAc,GAAG,CAAA;AAAA,IACvC,SAAS,GAAA,EAAK;AACZ,MAAA,OAAA,CAAQ,KAAA,CAAM,qEAAqE,GAAG,CAAA;AACtF,MAAA,OAAO,EAAE,MAAA,EAAQ,GAAA,EAAK,MAAM,EAAE,KAAA,EAAO,4BAA2B,EAAE;AAAA,IACpE;AAEA,IAAA,MAAM,UAAU,KAAA,CAAM,OAAA,CAAQ,MAAM,CAAA,GAAI,MAAA,GAAS,CAAC,MAAM,CAAA;AACxD,IAAA,MAAM,iBAAiB,IAAA,CAAK,YAAA,IAAgB,OAAA,CAAQ,GAAA,CAAI,sBAAsB,GAAA,CAAI,IAAA;AAClF,IAAA,MAAM,UAAA,GAAa,IAAA,CAAK,KAAA,CAAM,SAAA,GAAY,GAAI,CAAA;AAE9C,IAAA,MAAM,YAAA,GAAiC;AAAA,MACrC,OAAA,EAAS,OAAA,CAAQ,GAAA,CAAI,CAAC,CAAA,MAAO;AAAA,QAC3B,MAAM,CAAA,CAAE,IAAA;AAAA,QACR,OAAO,CAAA,CAAE,KAAA;AAAA,QACT,MAAA,EAAQ,EAAE,MAAA,IAAU,cAAA;AAAA,QACpB,IAAA,EAAM,EAAE,IAAA,IAAQ,GAAA;AAAA,QAChB,QAAA,EAAU,EAAE,QAAA,IAAY,IAAA;AAAA,QACxB,MAAA,EAAQ,CAAA,CAAE,MAAA,IAAU,GAAA,CAAI,MAAA;AAAA,QACxB,QAAA,EAAU,EAAE,QAAA,IAAY,KAAA;AAAA,QACxB,OAAA,EAAS,EAAE,OAAA,IAAW;AAAA,OACxB,CAAE,CAAA;AAAA,MACF,SAAS;AAAC,KACZ;AAEA,IAAA,OAAO,EAAE,MAAA,EAAQ,GAAA,EAAK,IAAA,EAAM,YAAA,EAAa;AAAA,EAC3C,CAAA;AACF","file":"chunk-VYKHHZQA.js","sourcesContent":["// @broberg/lens — Lens-mint compliance core (F036).\n//\n// The fleet auth standard (cardmem F098.1, docs/LENS-MINT-ENDPOINT.md): the Lens\n// daemon POSTs to your app's `POST /api/lens-session` with a NARROW bearer; you\n// mint a SHORT-LIVED, READ-ONLY session for a DEDICATED lens principal (never\n// cb@webhouse.dk) and return it as a Playwright storageState. Lens injects those\n// cookies before capture, so it screenshots the REAL authed surface — incl. prod.\n//\n// This module is the UNIFORM + SECURE ~80% every app shares: ship-dark, a\n// constant-time bearer check, a never-cb principal guard, TTL clamp, basic\n// rate-limit, and the fixed storageState assembly. The app supplies only the\n// auth-specific 20% — a `createLensSession(ctx)` hook that mints + SIGNS its own\n// session cookie. Framework adapters live in `./next` and `./hono`.\n\nimport { createHash, timingSafeEqual } from \"node:crypto\";\n\n/** Default + max session TTL: 10 minutes — long enough for a capture run. */\nconst DEFAULT_TTL_MS = 10 * 60 * 1000;\n/** Floor so a misconfigured app can't mint an instantly-dead session. */\nconst MIN_TTL_MS = 60 * 1000;\nconst DEFAULT_MAX_PER_MINUTE = 30;\n\n/** The permanent human admin — the lens principal must NEVER be this. */\nconst FORBIDDEN_PRINCIPAL = \"cb@webhouse.dk\";\n\n/**\n * A single cookie the app's `createLensSession` hook returns. Only `name` +\n * `value` are required; the core fills the rest (`domain`, `path`, `secure`,\n * `expires`, …) from the request + options.\n */\nexport interface LensCookie {\n name: string;\n value: string;\n domain?: string;\n path?: string;\n httpOnly?: boolean;\n secure?: boolean;\n sameSite?: \"Lax\" | \"Strict\" | \"None\";\n /** unix SECONDS. Omit to let the core stamp it from the clamped TTL. */\n expires?: number;\n}\n\n/** What the app's `createLensSession` hook receives. */\nexport interface LensSessionContext {\n /** The dedicated read-only lens principal (e.g. \"lens@myapp.local\"). */\n principal: string;\n /** Request host — the default cookie domain. */\n host: string;\n /** Whether the request arrived over https — the default cookie `secure`. */\n secure: boolean;\n /** The clamped TTL, in ms. */\n ttlMs: number;\n /** When the session must expire (unix MS). Clamp your session row to this. */\n expiresAt: number;\n}\n\n/** The app-supplied session minter — the auth-specific 20%. */\nexport type CreateLensSession = (\n ctx: LensSessionContext,\n) => Promise<LensCookie | LensCookie[]> | LensCookie | LensCookie[];\n\nexport interface LensMintOptions {\n /**\n * The narrow bearer secret. Defaults to `process.env.LENS_MINT_SECRET`, read\n * per-request so the endpoint ships dark and flips on without a restart.\n */\n secret?: string;\n /** App-supplied session minter (mints + signs the principal's cookie). */\n createSession: CreateLensSession;\n /** The dedicated read-only lens identity. Required; never cb@webhouse.dk. */\n principal: string;\n /** Session TTL in ms. Default 600_000; clamped to [60_000, 600_000]. */\n ttlMs?: number;\n /**\n * Force a cookie domain (e.g. \".myapp.com\" for cross-subdomain capture). Else\n * `process.env.LENS_COOKIE_DOMAIN`, else the request host. NEVER derive it\n * from the bound socket address — on Fly/proxy hosts that is \"0.0.0.0\", and\n * the browser then never sends the cookie (silent false-green).\n */\n cookieDomain?: string;\n /** Basic per-handler fixed-window rate-limit. Default 30/min; 0 disables. */\n maxPerMinute?: number;\n}\n\n/** A normalized request — what the framework adapters extract + pass in. */\nexport interface LensMintRequest {\n authorization: string | null;\n host: string;\n secure: boolean;\n}\n\n/** The fixed Playwright storageState the Lens daemon consumes verbatim. */\nexport interface LensStorageState {\n cookies: Array<{\n name: string;\n value: string;\n domain: string;\n path: string;\n httpOnly: boolean;\n secure: boolean;\n sameSite: \"Lax\" | \"Strict\" | \"None\";\n expires: number;\n }>;\n origins: never[];\n}\n\nexport interface LensMintResponse {\n status: number;\n body: LensStorageState | { error: string };\n}\n\n/** Length-independent constant-time compare (hash → equal-length → compare). */\nfunction safeEqual(a: string, b: string): boolean {\n const ha = createHash(\"sha256\").update(a).digest();\n const hb = createHash(\"sha256\").update(b).digest();\n return timingSafeEqual(ha, hb);\n}\n\nfunction parseBearer(authorization: string | null): string | null {\n if (!authorization) return null;\n const m = authorization.match(/^Bearer\\s+(.+)$/i);\n return m ? m[1]!.trim() : null;\n}\n\nfunction clampTtl(ttlMs: number): number {\n if (!Number.isFinite(ttlMs)) return DEFAULT_TTL_MS;\n return Math.min(Math.max(ttlMs, MIN_TTL_MS), DEFAULT_TTL_MS);\n}\n\n/**\n * Build the normalized Lens-mint handler. Wrap it with `@broberg/lens/next` or\n * `@broberg/lens/hono`, or call the returned function directly with a\n * `{ authorization, host, secure }` request from any framework.\n *\n * Throws at construction if `principal` is missing/blank or is cb@webhouse.dk.\n */\nexport function createLensMintHandler(\n opts: LensMintOptions,\n): (req: LensMintRequest) => Promise<LensMintResponse> {\n const principal = (opts.principal ?? \"\").trim();\n if (!principal) {\n throw new Error(\n '@broberg/lens: `principal` is required — the dedicated read-only lens identity (e.g. \"lens@yourapp.local\").',\n );\n }\n if (principal.toLowerCase() === FORBIDDEN_PRINCIPAL) {\n throw new Error(\n `@broberg/lens: refusing ${FORBIDDEN_PRINCIPAL} as the lens principal — it must be a dedicated read-only identity, never the human admin.`,\n );\n }\n\n const maxPerMinute = opts.maxPerMinute ?? DEFAULT_MAX_PER_MINUTE;\n let windowStart = Date.now();\n let count = 0;\n function withinRate(): boolean {\n if (maxPerMinute <= 0) return true;\n const now = Date.now();\n if (now - windowStart >= 60_000) {\n windowStart = now;\n count = 0;\n }\n count += 1;\n return count <= maxPerMinute;\n }\n\n return async function handle(req: LensMintRequest): Promise<LensMintResponse> {\n // Ship-dark: inert until the secret is provisioned. Read per-request.\n const secret = opts.secret ?? process.env.LENS_MINT_SECRET;\n if (!secret) {\n return { status: 503, body: { error: \"lens-session disabled (LENS_MINT_SECRET unset)\" } };\n }\n\n const provided = parseBearer(req.authorization);\n if (!provided || !safeEqual(provided, secret)) {\n return { status: 401, body: { error: \"unauthorized\" } };\n }\n\n // Rate-limit only authenticated requests — the only holder of a valid bearer\n // is the daemon, so this caps the mint rate if the secret ever leaks.\n if (!withinRate()) {\n return { status: 429, body: { error: \"rate limited\" } };\n }\n\n const ttlMs = clampTtl(opts.ttlMs ?? DEFAULT_TTL_MS);\n const expiresAt = Date.now() + ttlMs;\n const ctx: LensSessionContext = {\n principal,\n host: req.host,\n secure: req.secure,\n ttlMs,\n expiresAt,\n };\n\n // The app-supplied minter can fail (DB down, signing error, …). Keep the\n // {status, body} contract intact: a 500 JSON instead of an uncaught throw\n // bubbling up as a framework 500 with a stack. Log server-side for debugging;\n // never leak the underlying error to the caller (the daemon).\n let minted: LensCookie | LensCookie[];\n try {\n minted = await opts.createSession(ctx);\n } catch (err) {\n console.error(\"[@broberg/lens] createSession threw while minting a lens session:\", err);\n return { status: 500, body: { error: \"lens-session mint failed\" } };\n }\n\n const cookies = Array.isArray(minted) ? minted : [minted];\n const fallbackDomain = opts.cookieDomain ?? process.env.LENS_COOKIE_DOMAIN ?? req.host;\n const expiresSec = Math.floor(expiresAt / 1000);\n\n const storageState: LensStorageState = {\n cookies: cookies.map((c) => ({\n name: c.name,\n value: c.value,\n domain: c.domain ?? fallbackDomain,\n path: c.path ?? \"/\",\n httpOnly: c.httpOnly ?? true,\n secure: c.secure ?? req.secure,\n sameSite: c.sameSite ?? \"Lax\",\n expires: c.expires ?? expiresSec,\n })),\n origins: [],\n };\n\n return { status: 200, body: storageState };\n };\n}\n"]}
package/dist/hono.cjs CHANGED
@@ -67,7 +67,13 @@ function createLensMintHandler(opts) {
67
67
  ttlMs,
68
68
  expiresAt
69
69
  };
70
- const minted = await opts.createSession(ctx);
70
+ let minted;
71
+ try {
72
+ minted = await opts.createSession(ctx);
73
+ } catch (err) {
74
+ console.error("[@broberg/lens] createSession threw while minting a lens session:", err);
75
+ return { status: 500, body: { error: "lens-session mint failed" } };
76
+ }
71
77
  const cookies = Array.isArray(minted) ? minted : [minted];
72
78
  const fallbackDomain = opts.cookieDomain ?? process.env.LENS_COOKIE_DOMAIN ?? req.host;
73
79
  const expiresSec = Math.floor(expiresAt / 1e3);
package/dist/hono.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/hono.ts"],"names":["createHash","timingSafeEqual"],"mappings":"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","file":"hono.cjs","sourcesContent":["// @broberg/lens — Lens-mint compliance core (F036).\n//\n// The fleet auth standard (cardmem F098.1, docs/LENS-MINT-ENDPOINT.md): the Lens\n// daemon POSTs to your app's `POST /api/lens-session` with a NARROW bearer; you\n// mint a SHORT-LIVED, READ-ONLY session for a DEDICATED lens principal (never\n// cb@webhouse.dk) and return it as a Playwright storageState. Lens injects those\n// cookies before capture, so it screenshots the REAL authed surface — incl. prod.\n//\n// This module is the UNIFORM + SECURE ~80% every app shares: ship-dark, a\n// constant-time bearer check, a never-cb principal guard, TTL clamp, basic\n// rate-limit, and the fixed storageState assembly. The app supplies only the\n// auth-specific 20% — a `createLensSession(ctx)` hook that mints + SIGNS its own\n// session cookie. Framework adapters live in `./next` and `./hono`.\n\nimport { createHash, timingSafeEqual } from \"node:crypto\";\n\n/** Default + max session TTL: 10 minutes — long enough for a capture run. */\nconst DEFAULT_TTL_MS = 10 * 60 * 1000;\n/** Floor so a misconfigured app can't mint an instantly-dead session. */\nconst MIN_TTL_MS = 60 * 1000;\nconst DEFAULT_MAX_PER_MINUTE = 30;\n\n/** The permanent human admin — the lens principal must NEVER be this. */\nconst FORBIDDEN_PRINCIPAL = \"cb@webhouse.dk\";\n\n/**\n * A single cookie the app's `createLensSession` hook returns. Only `name` +\n * `value` are required; the core fills the rest (`domain`, `path`, `secure`,\n * `expires`, …) from the request + options.\n */\nexport interface LensCookie {\n name: string;\n value: string;\n domain?: string;\n path?: string;\n httpOnly?: boolean;\n secure?: boolean;\n sameSite?: \"Lax\" | \"Strict\" | \"None\";\n /** unix SECONDS. Omit to let the core stamp it from the clamped TTL. */\n expires?: number;\n}\n\n/** What the app's `createLensSession` hook receives. */\nexport interface LensSessionContext {\n /** The dedicated read-only lens principal (e.g. \"lens@myapp.local\"). */\n principal: string;\n /** Request host — the default cookie domain. */\n host: string;\n /** Whether the request arrived over https — the default cookie `secure`. */\n secure: boolean;\n /** The clamped TTL, in ms. */\n ttlMs: number;\n /** When the session must expire (unix MS). Clamp your session row to this. */\n expiresAt: number;\n}\n\n/** The app-supplied session minter — the auth-specific 20%. */\nexport type CreateLensSession = (\n ctx: LensSessionContext,\n) => Promise<LensCookie | LensCookie[]> | LensCookie | LensCookie[];\n\nexport interface LensMintOptions {\n /**\n * The narrow bearer secret. Defaults to `process.env.LENS_MINT_SECRET`, read\n * per-request so the endpoint ships dark and flips on without a restart.\n */\n secret?: string;\n /** App-supplied session minter (mints + signs the principal's cookie). */\n createSession: CreateLensSession;\n /** The dedicated read-only lens identity. Required; never cb@webhouse.dk. */\n principal: string;\n /** Session TTL in ms. Default 600_000; clamped to [60_000, 600_000]. */\n ttlMs?: number;\n /**\n * Force a cookie domain (e.g. \".myapp.com\" for cross-subdomain capture). Else\n * `process.env.LENS_COOKIE_DOMAIN`, else the request host. NEVER derive it\n * from the bound socket address — on Fly/proxy hosts that is \"0.0.0.0\", and\n * the browser then never sends the cookie (silent false-green).\n */\n cookieDomain?: string;\n /** Basic per-handler fixed-window rate-limit. Default 30/min; 0 disables. */\n maxPerMinute?: number;\n}\n\n/** A normalized request — what the framework adapters extract + pass in. */\nexport interface LensMintRequest {\n authorization: string | null;\n host: string;\n secure: boolean;\n}\n\n/** The fixed Playwright storageState the Lens daemon consumes verbatim. */\nexport interface LensStorageState {\n cookies: Array<{\n name: string;\n value: string;\n domain: string;\n path: string;\n httpOnly: boolean;\n secure: boolean;\n sameSite: \"Lax\" | \"Strict\" | \"None\";\n expires: number;\n }>;\n origins: never[];\n}\n\nexport interface LensMintResponse {\n status: number;\n body: LensStorageState | { error: string };\n}\n\n/** Length-independent constant-time compare (hash → equal-length → compare). */\nfunction safeEqual(a: string, b: string): boolean {\n const ha = createHash(\"sha256\").update(a).digest();\n const hb = createHash(\"sha256\").update(b).digest();\n return timingSafeEqual(ha, hb);\n}\n\nfunction parseBearer(authorization: string | null): string | null {\n if (!authorization) return null;\n const m = authorization.match(/^Bearer\\s+(.+)$/i);\n return m ? m[1]!.trim() : null;\n}\n\nfunction clampTtl(ttlMs: number): number {\n if (!Number.isFinite(ttlMs)) return DEFAULT_TTL_MS;\n return Math.min(Math.max(ttlMs, MIN_TTL_MS), DEFAULT_TTL_MS);\n}\n\n/**\n * Build the normalized Lens-mint handler. Wrap it with `@broberg/lens/next` or\n * `@broberg/lens/hono`, or call the returned function directly with a\n * `{ authorization, host, secure }` request from any framework.\n *\n * Throws at construction if `principal` is missing/blank or is cb@webhouse.dk.\n */\nexport function createLensMintHandler(\n opts: LensMintOptions,\n): (req: LensMintRequest) => Promise<LensMintResponse> {\n const principal = (opts.principal ?? \"\").trim();\n if (!principal) {\n throw new Error(\n '@broberg/lens: `principal` is required — the dedicated read-only lens identity (e.g. \"lens@yourapp.local\").',\n );\n }\n if (principal.toLowerCase() === FORBIDDEN_PRINCIPAL) {\n throw new Error(\n `@broberg/lens: refusing ${FORBIDDEN_PRINCIPAL} as the lens principal — it must be a dedicated read-only identity, never the human admin.`,\n );\n }\n\n const maxPerMinute = opts.maxPerMinute ?? DEFAULT_MAX_PER_MINUTE;\n let windowStart = Date.now();\n let count = 0;\n function withinRate(): boolean {\n if (maxPerMinute <= 0) return true;\n const now = Date.now();\n if (now - windowStart >= 60_000) {\n windowStart = now;\n count = 0;\n }\n count += 1;\n return count <= maxPerMinute;\n }\n\n return async function handle(req: LensMintRequest): Promise<LensMintResponse> {\n // Ship-dark: inert until the secret is provisioned. Read per-request.\n const secret = opts.secret ?? process.env.LENS_MINT_SECRET;\n if (!secret) {\n return { status: 503, body: { error: \"lens-session disabled (LENS_MINT_SECRET unset)\" } };\n }\n\n const provided = parseBearer(req.authorization);\n if (!provided || !safeEqual(provided, secret)) {\n return { status: 401, body: { error: \"unauthorized\" } };\n }\n\n // Rate-limit only authenticated requests — the only holder of a valid bearer\n // is the daemon, so this caps the mint rate if the secret ever leaks.\n if (!withinRate()) {\n return { status: 429, body: { error: \"rate limited\" } };\n }\n\n const ttlMs = clampTtl(opts.ttlMs ?? DEFAULT_TTL_MS);\n const expiresAt = Date.now() + ttlMs;\n const ctx: LensSessionContext = {\n principal,\n host: req.host,\n secure: req.secure,\n ttlMs,\n expiresAt,\n };\n\n const minted = await opts.createSession(ctx);\n const cookies = Array.isArray(minted) ? minted : [minted];\n const fallbackDomain = opts.cookieDomain ?? process.env.LENS_COOKIE_DOMAIN ?? req.host;\n const expiresSec = Math.floor(expiresAt / 1000);\n\n const storageState: LensStorageState = {\n cookies: cookies.map((c) => ({\n name: c.name,\n value: c.value,\n domain: c.domain ?? fallbackDomain,\n path: c.path ?? \"/\",\n httpOnly: c.httpOnly ?? true,\n secure: c.secure ?? req.secure,\n sameSite: c.sameSite ?? \"Lax\",\n expires: c.expires ?? expiresSec,\n })),\n origins: [],\n };\n\n return { status: 200, body: storageState };\n };\n}\n","// @broberg/lens/hono — Stack B (Bun/Hono) adapter.\n//\n// import { Hono } from \"hono\";\n// import { lensSessionHandler } from \"@broberg/lens/hono\";\n// app.post(\"/api/lens-session\", lensSessionHandler({\n// principal: \"lens@myapp.local\",\n// async createSession({ principal, expiresAt }) {\n// const value = await signMySessionCookie(principal, expiresAt);\n// return { name: \"myapp_session\", value };\n// },\n// }));\n//\n// `hono` is an optional peer dep — `import type` is erased at build, so the\n// package never bundles Hono and only needs it for typecheck.\n\nimport type { Context } from \"hono\";\nimport { createLensMintHandler, type LensMintOptions } from \"./index\";\n\nexport function lensSessionHandler(\n opts: LensMintOptions,\n): (c: Context) => Promise<Response> {\n const handle = createLensMintHandler(opts);\n return async (c: Context): Promise<Response> => {\n const url = new URL(c.req.url);\n const host =\n c.req.header(\"x-forwarded-host\") ?? c.req.header(\"host\") ?? url.host;\n const proto = c.req.header(\"x-forwarded-proto\") ?? url.protocol.replace(\":\", \"\");\n const res = await handle({\n authorization: c.req.header(\"authorization\") ?? null,\n host,\n secure: proto === \"https\",\n });\n return c.json(res.body, res.status as 200 | 401 | 429 | 503);\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/hono.ts"],"names":["createHash","timingSafeEqual"],"mappings":";;;;;AAiBA,IAAM,cAAA,GAAiB,KAAK,EAAA,GAAK,GAAA;AAEjC,IAAM,aAAa,EAAA,GAAK,GAAA;AACxB,IAAM,sBAAA,GAAyB,EAAA;AAG/B,IAAM,mBAAA,GAAsB,gBAAA;AAyF5B,SAAS,SAAA,CAAU,GAAW,CAAA,EAAoB;AAChD,EAAA,MAAM,KAAKA,iBAAA,CAAW,QAAQ,EAAE,MAAA,CAAO,CAAC,EAAE,MAAA,EAAO;AACjD,EAAA,MAAM,KAAKA,iBAAA,CAAW,QAAQ,EAAE,MAAA,CAAO,CAAC,EAAE,MAAA,EAAO;AACjD,EAAA,OAAOC,sBAAA,CAAgB,IAAI,EAAE,CAAA;AAC/B;AAEA,SAAS,YAAY,aAAA,EAA6C;AAChE,EAAA,IAAI,CAAC,eAAe,OAAO,IAAA;AAC3B,EAAA,MAAM,CAAA,GAAI,aAAA,CAAc,KAAA,CAAM,kBAAkB,CAAA;AAChD,EAAA,OAAO,CAAA,GAAI,CAAA,CAAE,CAAC,CAAA,CAAG,MAAK,GAAI,IAAA;AAC5B;AAEA,SAAS,SAAS,KAAA,EAAuB;AACvC,EAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,KAAK,GAAG,OAAO,cAAA;AACpC,EAAA,OAAO,KAAK,GAAA,CAAI,IAAA,CAAK,IAAI,KAAA,EAAO,UAAU,GAAG,cAAc,CAAA;AAC7D;AASO,SAAS,sBACd,IAAA,EACqD;AACrD,EAAA,MAAM,SAAA,GAAA,CAAa,IAAA,CAAK,SAAA,IAAa,EAAA,EAAI,IAAA,EAAK;AAC9C,EAAA,IAAI,CAAC,SAAA,EAAW;AACd,IAAA,MAAM,IAAI,KAAA;AAAA,MACR;AAAA,KACF;AAAA,EACF;AACA,EAAA,IAAI,SAAA,CAAU,WAAA,EAAY,KAAM,mBAAA,EAAqB;AACnD,IAAA,MAAM,IAAI,KAAA;AAAA,MACR,2BAA2B,mBAAmB,CAAA,+FAAA;AAAA,KAChD;AAAA,EACF;AAEA,EAAA,MAAM,YAAA,GAAe,KAAK,YAAA,IAAgB,sBAAA;AAC1C,EAAA,IAAI,WAAA,GAAc,KAAK,GAAA,EAAI;AAC3B,EAAA,IAAI,KAAA,GAAQ,CAAA;AACZ,EAAA,SAAS,UAAA,GAAsB;AAC7B,IAAA,IAAI,YAAA,IAAgB,GAAG,OAAO,IAAA;AAC9B,IAAA,MAAM,GAAA,GAAM,KAAK,GAAA,EAAI;AACrB,IAAA,IAAI,GAAA,GAAM,eAAe,GAAA,EAAQ;AAC/B,MAAA,WAAA,GAAc,GAAA;AACd,MAAA,KAAA,GAAQ,CAAA;AAAA,IACV;AACA,IAAA,KAAA,IAAS,CAAA;AACT,IAAA,OAAO,KAAA,IAAS,YAAA;AAAA,EAClB;AAEA,EAAA,OAAO,eAAe,OAAO,GAAA,EAAiD;AAE5E,IAAA,MAAM,MAAA,GAAS,IAAA,CAAK,MAAA,IAAU,OAAA,CAAQ,GAAA,CAAI,gBAAA;AAC1C,IAAA,IAAI,CAAC,MAAA,EAAQ;AACX,MAAA,OAAO,EAAE,MAAA,EAAQ,GAAA,EAAK,MAAM,EAAE,KAAA,EAAO,kDAAiD,EAAE;AAAA,IAC1F;AAEA,IAAA,MAAM,QAAA,GAAW,WAAA,CAAY,GAAA,CAAI,aAAa,CAAA;AAC9C,IAAA,IAAI,CAAC,QAAA,IAAY,CAAC,SAAA,CAAU,QAAA,EAAU,MAAM,CAAA,EAAG;AAC7C,MAAA,OAAO,EAAE,MAAA,EAAQ,GAAA,EAAK,MAAM,EAAE,KAAA,EAAO,gBAAe,EAAE;AAAA,IACxD;AAIA,IAAA,IAAI,CAAC,YAAW,EAAG;AACjB,MAAA,OAAO,EAAE,MAAA,EAAQ,GAAA,EAAK,MAAM,EAAE,KAAA,EAAO,gBAAe,EAAE;AAAA,IACxD;AAEA,IAAA,MAAM,KAAA,GAAQ,QAAA,CAAS,IAAA,CAAK,KAAA,IAAS,cAAc,CAAA;AACnD,IAAA,MAAM,SAAA,GAAY,IAAA,CAAK,GAAA,EAAI,GAAI,KAAA;AAC/B,IAAA,MAAM,GAAA,GAA0B;AAAA,MAC9B,SAAA;AAAA,MACA,MAAM,GAAA,CAAI,IAAA;AAAA,MACV,QAAQ,GAAA,CAAI,MAAA;AAAA,MACZ,KAAA;AAAA,MACA;AAAA,KACF;AAMA,IAAA,IAAI,MAAA;AACJ,IAAA,IAAI;AACF,MAAA,MAAA,GAAS,MAAM,IAAA,CAAK,aAAA,CAAc,GAAG,CAAA;AAAA,IACvC,SAAS,GAAA,EAAK;AACZ,MAAA,OAAA,CAAQ,KAAA,CAAM,qEAAqE,GAAG,CAAA;AACtF,MAAA,OAAO,EAAE,MAAA,EAAQ,GAAA,EAAK,MAAM,EAAE,KAAA,EAAO,4BAA2B,EAAE;AAAA,IACpE;AAEA,IAAA,MAAM,UAAU,KAAA,CAAM,OAAA,CAAQ,MAAM,CAAA,GAAI,MAAA,GAAS,CAAC,MAAM,CAAA;AACxD,IAAA,MAAM,iBAAiB,IAAA,CAAK,YAAA,IAAgB,OAAA,CAAQ,GAAA,CAAI,sBAAsB,GAAA,CAAI,IAAA;AAClF,IAAA,MAAM,UAAA,GAAa,IAAA,CAAK,KAAA,CAAM,SAAA,GAAY,GAAI,CAAA;AAE9C,IAAA,MAAM,YAAA,GAAiC;AAAA,MACrC,OAAA,EAAS,OAAA,CAAQ,GAAA,CAAI,CAAC,CAAA,MAAO;AAAA,QAC3B,MAAM,CAAA,CAAE,IAAA;AAAA,QACR,OAAO,CAAA,CAAE,KAAA;AAAA,QACT,MAAA,EAAQ,EAAE,MAAA,IAAU,cAAA;AAAA,QACpB,IAAA,EAAM,EAAE,IAAA,IAAQ,GAAA;AAAA,QAChB,QAAA,EAAU,EAAE,QAAA,IAAY,IAAA;AAAA,QACxB,MAAA,EAAQ,CAAA,CAAE,MAAA,IAAU,GAAA,CAAI,MAAA;AAAA,QACxB,QAAA,EAAU,EAAE,QAAA,IAAY,KAAA;AAAA,QACxB,OAAA,EAAS,EAAE,OAAA,IAAW;AAAA,OACxB,CAAE,CAAA;AAAA,MACF,SAAS;AAAC,KACZ;AAEA,IAAA,OAAO,EAAE,MAAA,EAAQ,GAAA,EAAK,IAAA,EAAM,YAAA,EAAa;AAAA,EAC3C,CAAA;AACF;;;AC/MO,SAAS,mBACd,IAAA,EACmC;AACnC,EAAA,MAAM,MAAA,GAAS,sBAAsB,IAAI,CAAA;AACzC,EAAA,OAAO,OAAO,CAAA,KAAkC;AAC9C,IAAA,MAAM,GAAA,GAAM,IAAI,GAAA,CAAI,CAAA,CAAE,IAAI,GAAG,CAAA;AAC7B,IAAA,MAAM,IAAA,GACJ,CAAA,CAAE,GAAA,CAAI,MAAA,CAAO,kBAAkB,CAAA,IAAK,CAAA,CAAE,GAAA,CAAI,MAAA,CAAO,MAAM,CAAA,IAAK,GAAA,CAAI,IAAA;AAClE,IAAA,MAAM,KAAA,GAAQ,CAAA,CAAE,GAAA,CAAI,MAAA,CAAO,mBAAmB,KAAK,GAAA,CAAI,QAAA,CAAS,OAAA,CAAQ,GAAA,EAAK,EAAE,CAAA;AAC/E,IAAA,MAAM,GAAA,GAAM,MAAM,MAAA,CAAO;AAAA,MACvB,aAAA,EAAe,CAAA,CAAE,GAAA,CAAI,MAAA,CAAO,eAAe,CAAA,IAAK,IAAA;AAAA,MAChD,IAAA;AAAA,MACA,QAAQ,KAAA,KAAU;AAAA,KACnB,CAAA;AACD,IAAA,OAAO,CAAA,CAAE,IAAA,CAAK,GAAA,CAAI,IAAA,EAAM,IAAI,MAA+B,CAAA;AAAA,EAC7D,CAAA;AACF","file":"hono.cjs","sourcesContent":["// @broberg/lens — Lens-mint compliance core (F036).\n//\n// The fleet auth standard (cardmem F098.1, docs/LENS-MINT-ENDPOINT.md): the Lens\n// daemon POSTs to your app's `POST /api/lens-session` with a NARROW bearer; you\n// mint a SHORT-LIVED, READ-ONLY session for a DEDICATED lens principal (never\n// cb@webhouse.dk) and return it as a Playwright storageState. Lens injects those\n// cookies before capture, so it screenshots the REAL authed surface — incl. prod.\n//\n// This module is the UNIFORM + SECURE ~80% every app shares: ship-dark, a\n// constant-time bearer check, a never-cb principal guard, TTL clamp, basic\n// rate-limit, and the fixed storageState assembly. The app supplies only the\n// auth-specific 20% — a `createLensSession(ctx)` hook that mints + SIGNS its own\n// session cookie. Framework adapters live in `./next` and `./hono`.\n\nimport { createHash, timingSafeEqual } from \"node:crypto\";\n\n/** Default + max session TTL: 10 minutes — long enough for a capture run. */\nconst DEFAULT_TTL_MS = 10 * 60 * 1000;\n/** Floor so a misconfigured app can't mint an instantly-dead session. */\nconst MIN_TTL_MS = 60 * 1000;\nconst DEFAULT_MAX_PER_MINUTE = 30;\n\n/** The permanent human admin — the lens principal must NEVER be this. */\nconst FORBIDDEN_PRINCIPAL = \"cb@webhouse.dk\";\n\n/**\n * A single cookie the app's `createLensSession` hook returns. Only `name` +\n * `value` are required; the core fills the rest (`domain`, `path`, `secure`,\n * `expires`, …) from the request + options.\n */\nexport interface LensCookie {\n name: string;\n value: string;\n domain?: string;\n path?: string;\n httpOnly?: boolean;\n secure?: boolean;\n sameSite?: \"Lax\" | \"Strict\" | \"None\";\n /** unix SECONDS. Omit to let the core stamp it from the clamped TTL. */\n expires?: number;\n}\n\n/** What the app's `createLensSession` hook receives. */\nexport interface LensSessionContext {\n /** The dedicated read-only lens principal (e.g. \"lens@myapp.local\"). */\n principal: string;\n /** Request host — the default cookie domain. */\n host: string;\n /** Whether the request arrived over https — the default cookie `secure`. */\n secure: boolean;\n /** The clamped TTL, in ms. */\n ttlMs: number;\n /** When the session must expire (unix MS). Clamp your session row to this. */\n expiresAt: number;\n}\n\n/** The app-supplied session minter — the auth-specific 20%. */\nexport type CreateLensSession = (\n ctx: LensSessionContext,\n) => Promise<LensCookie | LensCookie[]> | LensCookie | LensCookie[];\n\nexport interface LensMintOptions {\n /**\n * The narrow bearer secret. Defaults to `process.env.LENS_MINT_SECRET`, read\n * per-request so the endpoint ships dark and flips on without a restart.\n */\n secret?: string;\n /** App-supplied session minter (mints + signs the principal's cookie). */\n createSession: CreateLensSession;\n /** The dedicated read-only lens identity. Required; never cb@webhouse.dk. */\n principal: string;\n /** Session TTL in ms. Default 600_000; clamped to [60_000, 600_000]. */\n ttlMs?: number;\n /**\n * Force a cookie domain (e.g. \".myapp.com\" for cross-subdomain capture). Else\n * `process.env.LENS_COOKIE_DOMAIN`, else the request host. NEVER derive it\n * from the bound socket address — on Fly/proxy hosts that is \"0.0.0.0\", and\n * the browser then never sends the cookie (silent false-green).\n */\n cookieDomain?: string;\n /** Basic per-handler fixed-window rate-limit. Default 30/min; 0 disables. */\n maxPerMinute?: number;\n}\n\n/** A normalized request — what the framework adapters extract + pass in. */\nexport interface LensMintRequest {\n authorization: string | null;\n host: string;\n secure: boolean;\n}\n\n/** The fixed Playwright storageState the Lens daemon consumes verbatim. */\nexport interface LensStorageState {\n cookies: Array<{\n name: string;\n value: string;\n domain: string;\n path: string;\n httpOnly: boolean;\n secure: boolean;\n sameSite: \"Lax\" | \"Strict\" | \"None\";\n expires: number;\n }>;\n origins: never[];\n}\n\nexport interface LensMintResponse {\n status: number;\n body: LensStorageState | { error: string };\n}\n\n/** Length-independent constant-time compare (hash → equal-length → compare). */\nfunction safeEqual(a: string, b: string): boolean {\n const ha = createHash(\"sha256\").update(a).digest();\n const hb = createHash(\"sha256\").update(b).digest();\n return timingSafeEqual(ha, hb);\n}\n\nfunction parseBearer(authorization: string | null): string | null {\n if (!authorization) return null;\n const m = authorization.match(/^Bearer\\s+(.+)$/i);\n return m ? m[1]!.trim() : null;\n}\n\nfunction clampTtl(ttlMs: number): number {\n if (!Number.isFinite(ttlMs)) return DEFAULT_TTL_MS;\n return Math.min(Math.max(ttlMs, MIN_TTL_MS), DEFAULT_TTL_MS);\n}\n\n/**\n * Build the normalized Lens-mint handler. Wrap it with `@broberg/lens/next` or\n * `@broberg/lens/hono`, or call the returned function directly with a\n * `{ authorization, host, secure }` request from any framework.\n *\n * Throws at construction if `principal` is missing/blank or is cb@webhouse.dk.\n */\nexport function createLensMintHandler(\n opts: LensMintOptions,\n): (req: LensMintRequest) => Promise<LensMintResponse> {\n const principal = (opts.principal ?? \"\").trim();\n if (!principal) {\n throw new Error(\n '@broberg/lens: `principal` is required — the dedicated read-only lens identity (e.g. \"lens@yourapp.local\").',\n );\n }\n if (principal.toLowerCase() === FORBIDDEN_PRINCIPAL) {\n throw new Error(\n `@broberg/lens: refusing ${FORBIDDEN_PRINCIPAL} as the lens principal — it must be a dedicated read-only identity, never the human admin.`,\n );\n }\n\n const maxPerMinute = opts.maxPerMinute ?? DEFAULT_MAX_PER_MINUTE;\n let windowStart = Date.now();\n let count = 0;\n function withinRate(): boolean {\n if (maxPerMinute <= 0) return true;\n const now = Date.now();\n if (now - windowStart >= 60_000) {\n windowStart = now;\n count = 0;\n }\n count += 1;\n return count <= maxPerMinute;\n }\n\n return async function handle(req: LensMintRequest): Promise<LensMintResponse> {\n // Ship-dark: inert until the secret is provisioned. Read per-request.\n const secret = opts.secret ?? process.env.LENS_MINT_SECRET;\n if (!secret) {\n return { status: 503, body: { error: \"lens-session disabled (LENS_MINT_SECRET unset)\" } };\n }\n\n const provided = parseBearer(req.authorization);\n if (!provided || !safeEqual(provided, secret)) {\n return { status: 401, body: { error: \"unauthorized\" } };\n }\n\n // Rate-limit only authenticated requests — the only holder of a valid bearer\n // is the daemon, so this caps the mint rate if the secret ever leaks.\n if (!withinRate()) {\n return { status: 429, body: { error: \"rate limited\" } };\n }\n\n const ttlMs = clampTtl(opts.ttlMs ?? DEFAULT_TTL_MS);\n const expiresAt = Date.now() + ttlMs;\n const ctx: LensSessionContext = {\n principal,\n host: req.host,\n secure: req.secure,\n ttlMs,\n expiresAt,\n };\n\n // The app-supplied minter can fail (DB down, signing error, …). Keep the\n // {status, body} contract intact: a 500 JSON instead of an uncaught throw\n // bubbling up as a framework 500 with a stack. Log server-side for debugging;\n // never leak the underlying error to the caller (the daemon).\n let minted: LensCookie | LensCookie[];\n try {\n minted = await opts.createSession(ctx);\n } catch (err) {\n console.error(\"[@broberg/lens] createSession threw while minting a lens session:\", err);\n return { status: 500, body: { error: \"lens-session mint failed\" } };\n }\n\n const cookies = Array.isArray(minted) ? minted : [minted];\n const fallbackDomain = opts.cookieDomain ?? process.env.LENS_COOKIE_DOMAIN ?? req.host;\n const expiresSec = Math.floor(expiresAt / 1000);\n\n const storageState: LensStorageState = {\n cookies: cookies.map((c) => ({\n name: c.name,\n value: c.value,\n domain: c.domain ?? fallbackDomain,\n path: c.path ?? \"/\",\n httpOnly: c.httpOnly ?? true,\n secure: c.secure ?? req.secure,\n sameSite: c.sameSite ?? \"Lax\",\n expires: c.expires ?? expiresSec,\n })),\n origins: [],\n };\n\n return { status: 200, body: storageState };\n };\n}\n","// @broberg/lens/hono — Stack B (Bun/Hono) adapter.\n//\n// import { Hono } from \"hono\";\n// import { lensSessionHandler } from \"@broberg/lens/hono\";\n// app.post(\"/api/lens-session\", lensSessionHandler({\n// principal: \"lens@myapp.local\",\n// async createSession({ principal, expiresAt }) {\n// const value = await signMySessionCookie(principal, expiresAt);\n// return { name: \"myapp_session\", value };\n// },\n// }));\n//\n// `hono` is an optional peer dep — `import type` is erased at build, so the\n// package never bundles Hono and only needs it for typecheck.\n\nimport type { Context } from \"hono\";\nimport { createLensMintHandler, type LensMintOptions } from \"./index\";\n\nexport function lensSessionHandler(\n opts: LensMintOptions,\n): (c: Context) => Promise<Response> {\n const handle = createLensMintHandler(opts);\n return async (c: Context): Promise<Response> => {\n const url = new URL(c.req.url);\n const host =\n c.req.header(\"x-forwarded-host\") ?? c.req.header(\"host\") ?? url.host;\n const proto = c.req.header(\"x-forwarded-proto\") ?? url.protocol.replace(\":\", \"\");\n const res = await handle({\n authorization: c.req.header(\"authorization\") ?? null,\n host,\n secure: proto === \"https\",\n });\n return c.json(res.body, res.status as 200 | 401 | 429 | 503);\n };\n}\n"]}
package/dist/hono.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createLensMintHandler } from './chunk-4QJFODYF.js';
1
+ import { createLensMintHandler } from './chunk-VYKHHZQA.js';
2
2
 
3
3
  // src/hono.ts
4
4
  function lensSessionHandler(opts) {
package/dist/index.cjs CHANGED
@@ -67,7 +67,13 @@ function createLensMintHandler(opts) {
67
67
  ttlMs,
68
68
  expiresAt
69
69
  };
70
- const minted = await opts.createSession(ctx);
70
+ let minted;
71
+ try {
72
+ minted = await opts.createSession(ctx);
73
+ } catch (err) {
74
+ console.error("[@broberg/lens] createSession threw while minting a lens session:", err);
75
+ return { status: 500, body: { error: "lens-session mint failed" } };
76
+ }
71
77
  const cookies = Array.isArray(minted) ? minted : [minted];
72
78
  const fallbackDomain = opts.cookieDomain ?? process.env.LENS_COOKIE_DOMAIN ?? req.host;
73
79
  const expiresSec = Math.floor(expiresAt / 1e3);
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":["createHash","timingSafeEqual"],"mappings":"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","file":"index.cjs","sourcesContent":["// @broberg/lens — Lens-mint compliance core (F036).\n//\n// The fleet auth standard (cardmem F098.1, docs/LENS-MINT-ENDPOINT.md): the Lens\n// daemon POSTs to your app's `POST /api/lens-session` with a NARROW bearer; you\n// mint a SHORT-LIVED, READ-ONLY session for a DEDICATED lens principal (never\n// cb@webhouse.dk) and return it as a Playwright storageState. Lens injects those\n// cookies before capture, so it screenshots the REAL authed surface — incl. prod.\n//\n// This module is the UNIFORM + SECURE ~80% every app shares: ship-dark, a\n// constant-time bearer check, a never-cb principal guard, TTL clamp, basic\n// rate-limit, and the fixed storageState assembly. The app supplies only the\n// auth-specific 20% — a `createLensSession(ctx)` hook that mints + SIGNS its own\n// session cookie. Framework adapters live in `./next` and `./hono`.\n\nimport { createHash, timingSafeEqual } from \"node:crypto\";\n\n/** Default + max session TTL: 10 minutes — long enough for a capture run. */\nconst DEFAULT_TTL_MS = 10 * 60 * 1000;\n/** Floor so a misconfigured app can't mint an instantly-dead session. */\nconst MIN_TTL_MS = 60 * 1000;\nconst DEFAULT_MAX_PER_MINUTE = 30;\n\n/** The permanent human admin — the lens principal must NEVER be this. */\nconst FORBIDDEN_PRINCIPAL = \"cb@webhouse.dk\";\n\n/**\n * A single cookie the app's `createLensSession` hook returns. Only `name` +\n * `value` are required; the core fills the rest (`domain`, `path`, `secure`,\n * `expires`, …) from the request + options.\n */\nexport interface LensCookie {\n name: string;\n value: string;\n domain?: string;\n path?: string;\n httpOnly?: boolean;\n secure?: boolean;\n sameSite?: \"Lax\" | \"Strict\" | \"None\";\n /** unix SECONDS. Omit to let the core stamp it from the clamped TTL. */\n expires?: number;\n}\n\n/** What the app's `createLensSession` hook receives. */\nexport interface LensSessionContext {\n /** The dedicated read-only lens principal (e.g. \"lens@myapp.local\"). */\n principal: string;\n /** Request host — the default cookie domain. */\n host: string;\n /** Whether the request arrived over https — the default cookie `secure`. */\n secure: boolean;\n /** The clamped TTL, in ms. */\n ttlMs: number;\n /** When the session must expire (unix MS). Clamp your session row to this. */\n expiresAt: number;\n}\n\n/** The app-supplied session minter — the auth-specific 20%. */\nexport type CreateLensSession = (\n ctx: LensSessionContext,\n) => Promise<LensCookie | LensCookie[]> | LensCookie | LensCookie[];\n\nexport interface LensMintOptions {\n /**\n * The narrow bearer secret. Defaults to `process.env.LENS_MINT_SECRET`, read\n * per-request so the endpoint ships dark and flips on without a restart.\n */\n secret?: string;\n /** App-supplied session minter (mints + signs the principal's cookie). */\n createSession: CreateLensSession;\n /** The dedicated read-only lens identity. Required; never cb@webhouse.dk. */\n principal: string;\n /** Session TTL in ms. Default 600_000; clamped to [60_000, 600_000]. */\n ttlMs?: number;\n /**\n * Force a cookie domain (e.g. \".myapp.com\" for cross-subdomain capture). Else\n * `process.env.LENS_COOKIE_DOMAIN`, else the request host. NEVER derive it\n * from the bound socket address — on Fly/proxy hosts that is \"0.0.0.0\", and\n * the browser then never sends the cookie (silent false-green).\n */\n cookieDomain?: string;\n /** Basic per-handler fixed-window rate-limit. Default 30/min; 0 disables. */\n maxPerMinute?: number;\n}\n\n/** A normalized request — what the framework adapters extract + pass in. */\nexport interface LensMintRequest {\n authorization: string | null;\n host: string;\n secure: boolean;\n}\n\n/** The fixed Playwright storageState the Lens daemon consumes verbatim. */\nexport interface LensStorageState {\n cookies: Array<{\n name: string;\n value: string;\n domain: string;\n path: string;\n httpOnly: boolean;\n secure: boolean;\n sameSite: \"Lax\" | \"Strict\" | \"None\";\n expires: number;\n }>;\n origins: never[];\n}\n\nexport interface LensMintResponse {\n status: number;\n body: LensStorageState | { error: string };\n}\n\n/** Length-independent constant-time compare (hash → equal-length → compare). */\nfunction safeEqual(a: string, b: string): boolean {\n const ha = createHash(\"sha256\").update(a).digest();\n const hb = createHash(\"sha256\").update(b).digest();\n return timingSafeEqual(ha, hb);\n}\n\nfunction parseBearer(authorization: string | null): string | null {\n if (!authorization) return null;\n const m = authorization.match(/^Bearer\\s+(.+)$/i);\n return m ? m[1]!.trim() : null;\n}\n\nfunction clampTtl(ttlMs: number): number {\n if (!Number.isFinite(ttlMs)) return DEFAULT_TTL_MS;\n return Math.min(Math.max(ttlMs, MIN_TTL_MS), DEFAULT_TTL_MS);\n}\n\n/**\n * Build the normalized Lens-mint handler. Wrap it with `@broberg/lens/next` or\n * `@broberg/lens/hono`, or call the returned function directly with a\n * `{ authorization, host, secure }` request from any framework.\n *\n * Throws at construction if `principal` is missing/blank or is cb@webhouse.dk.\n */\nexport function createLensMintHandler(\n opts: LensMintOptions,\n): (req: LensMintRequest) => Promise<LensMintResponse> {\n const principal = (opts.principal ?? \"\").trim();\n if (!principal) {\n throw new Error(\n '@broberg/lens: `principal` is required — the dedicated read-only lens identity (e.g. \"lens@yourapp.local\").',\n );\n }\n if (principal.toLowerCase() === FORBIDDEN_PRINCIPAL) {\n throw new Error(\n `@broberg/lens: refusing ${FORBIDDEN_PRINCIPAL} as the lens principal — it must be a dedicated read-only identity, never the human admin.`,\n );\n }\n\n const maxPerMinute = opts.maxPerMinute ?? DEFAULT_MAX_PER_MINUTE;\n let windowStart = Date.now();\n let count = 0;\n function withinRate(): boolean {\n if (maxPerMinute <= 0) return true;\n const now = Date.now();\n if (now - windowStart >= 60_000) {\n windowStart = now;\n count = 0;\n }\n count += 1;\n return count <= maxPerMinute;\n }\n\n return async function handle(req: LensMintRequest): Promise<LensMintResponse> {\n // Ship-dark: inert until the secret is provisioned. Read per-request.\n const secret = opts.secret ?? process.env.LENS_MINT_SECRET;\n if (!secret) {\n return { status: 503, body: { error: \"lens-session disabled (LENS_MINT_SECRET unset)\" } };\n }\n\n const provided = parseBearer(req.authorization);\n if (!provided || !safeEqual(provided, secret)) {\n return { status: 401, body: { error: \"unauthorized\" } };\n }\n\n // Rate-limit only authenticated requests — the only holder of a valid bearer\n // is the daemon, so this caps the mint rate if the secret ever leaks.\n if (!withinRate()) {\n return { status: 429, body: { error: \"rate limited\" } };\n }\n\n const ttlMs = clampTtl(opts.ttlMs ?? DEFAULT_TTL_MS);\n const expiresAt = Date.now() + ttlMs;\n const ctx: LensSessionContext = {\n principal,\n host: req.host,\n secure: req.secure,\n ttlMs,\n expiresAt,\n };\n\n const minted = await opts.createSession(ctx);\n const cookies = Array.isArray(minted) ? minted : [minted];\n const fallbackDomain = opts.cookieDomain ?? process.env.LENS_COOKIE_DOMAIN ?? req.host;\n const expiresSec = Math.floor(expiresAt / 1000);\n\n const storageState: LensStorageState = {\n cookies: cookies.map((c) => ({\n name: c.name,\n value: c.value,\n domain: c.domain ?? fallbackDomain,\n path: c.path ?? \"/\",\n httpOnly: c.httpOnly ?? true,\n secure: c.secure ?? req.secure,\n sameSite: c.sameSite ?? \"Lax\",\n expires: c.expires ?? expiresSec,\n })),\n origins: [],\n };\n\n return { status: 200, body: storageState };\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":["createHash","timingSafeEqual"],"mappings":"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","file":"index.cjs","sourcesContent":["// @broberg/lens — Lens-mint compliance core (F036).\n//\n// The fleet auth standard (cardmem F098.1, docs/LENS-MINT-ENDPOINT.md): the Lens\n// daemon POSTs to your app's `POST /api/lens-session` with a NARROW bearer; you\n// mint a SHORT-LIVED, READ-ONLY session for a DEDICATED lens principal (never\n// cb@webhouse.dk) and return it as a Playwright storageState. Lens injects those\n// cookies before capture, so it screenshots the REAL authed surface — incl. prod.\n//\n// This module is the UNIFORM + SECURE ~80% every app shares: ship-dark, a\n// constant-time bearer check, a never-cb principal guard, TTL clamp, basic\n// rate-limit, and the fixed storageState assembly. The app supplies only the\n// auth-specific 20% — a `createLensSession(ctx)` hook that mints + SIGNS its own\n// session cookie. Framework adapters live in `./next` and `./hono`.\n\nimport { createHash, timingSafeEqual } from \"node:crypto\";\n\n/** Default + max session TTL: 10 minutes — long enough for a capture run. */\nconst DEFAULT_TTL_MS = 10 * 60 * 1000;\n/** Floor so a misconfigured app can't mint an instantly-dead session. */\nconst MIN_TTL_MS = 60 * 1000;\nconst DEFAULT_MAX_PER_MINUTE = 30;\n\n/** The permanent human admin — the lens principal must NEVER be this. */\nconst FORBIDDEN_PRINCIPAL = \"cb@webhouse.dk\";\n\n/**\n * A single cookie the app's `createLensSession` hook returns. Only `name` +\n * `value` are required; the core fills the rest (`domain`, `path`, `secure`,\n * `expires`, …) from the request + options.\n */\nexport interface LensCookie {\n name: string;\n value: string;\n domain?: string;\n path?: string;\n httpOnly?: boolean;\n secure?: boolean;\n sameSite?: \"Lax\" | \"Strict\" | \"None\";\n /** unix SECONDS. Omit to let the core stamp it from the clamped TTL. */\n expires?: number;\n}\n\n/** What the app's `createLensSession` hook receives. */\nexport interface LensSessionContext {\n /** The dedicated read-only lens principal (e.g. \"lens@myapp.local\"). */\n principal: string;\n /** Request host — the default cookie domain. */\n host: string;\n /** Whether the request arrived over https — the default cookie `secure`. */\n secure: boolean;\n /** The clamped TTL, in ms. */\n ttlMs: number;\n /** When the session must expire (unix MS). Clamp your session row to this. */\n expiresAt: number;\n}\n\n/** The app-supplied session minter — the auth-specific 20%. */\nexport type CreateLensSession = (\n ctx: LensSessionContext,\n) => Promise<LensCookie | LensCookie[]> | LensCookie | LensCookie[];\n\nexport interface LensMintOptions {\n /**\n * The narrow bearer secret. Defaults to `process.env.LENS_MINT_SECRET`, read\n * per-request so the endpoint ships dark and flips on without a restart.\n */\n secret?: string;\n /** App-supplied session minter (mints + signs the principal's cookie). */\n createSession: CreateLensSession;\n /** The dedicated read-only lens identity. Required; never cb@webhouse.dk. */\n principal: string;\n /** Session TTL in ms. Default 600_000; clamped to [60_000, 600_000]. */\n ttlMs?: number;\n /**\n * Force a cookie domain (e.g. \".myapp.com\" for cross-subdomain capture). Else\n * `process.env.LENS_COOKIE_DOMAIN`, else the request host. NEVER derive it\n * from the bound socket address — on Fly/proxy hosts that is \"0.0.0.0\", and\n * the browser then never sends the cookie (silent false-green).\n */\n cookieDomain?: string;\n /** Basic per-handler fixed-window rate-limit. Default 30/min; 0 disables. */\n maxPerMinute?: number;\n}\n\n/** A normalized request — what the framework adapters extract + pass in. */\nexport interface LensMintRequest {\n authorization: string | null;\n host: string;\n secure: boolean;\n}\n\n/** The fixed Playwright storageState the Lens daemon consumes verbatim. */\nexport interface LensStorageState {\n cookies: Array<{\n name: string;\n value: string;\n domain: string;\n path: string;\n httpOnly: boolean;\n secure: boolean;\n sameSite: \"Lax\" | \"Strict\" | \"None\";\n expires: number;\n }>;\n origins: never[];\n}\n\nexport interface LensMintResponse {\n status: number;\n body: LensStorageState | { error: string };\n}\n\n/** Length-independent constant-time compare (hash → equal-length → compare). */\nfunction safeEqual(a: string, b: string): boolean {\n const ha = createHash(\"sha256\").update(a).digest();\n const hb = createHash(\"sha256\").update(b).digest();\n return timingSafeEqual(ha, hb);\n}\n\nfunction parseBearer(authorization: string | null): string | null {\n if (!authorization) return null;\n const m = authorization.match(/^Bearer\\s+(.+)$/i);\n return m ? m[1]!.trim() : null;\n}\n\nfunction clampTtl(ttlMs: number): number {\n if (!Number.isFinite(ttlMs)) return DEFAULT_TTL_MS;\n return Math.min(Math.max(ttlMs, MIN_TTL_MS), DEFAULT_TTL_MS);\n}\n\n/**\n * Build the normalized Lens-mint handler. Wrap it with `@broberg/lens/next` or\n * `@broberg/lens/hono`, or call the returned function directly with a\n * `{ authorization, host, secure }` request from any framework.\n *\n * Throws at construction if `principal` is missing/blank or is cb@webhouse.dk.\n */\nexport function createLensMintHandler(\n opts: LensMintOptions,\n): (req: LensMintRequest) => Promise<LensMintResponse> {\n const principal = (opts.principal ?? \"\").trim();\n if (!principal) {\n throw new Error(\n '@broberg/lens: `principal` is required — the dedicated read-only lens identity (e.g. \"lens@yourapp.local\").',\n );\n }\n if (principal.toLowerCase() === FORBIDDEN_PRINCIPAL) {\n throw new Error(\n `@broberg/lens: refusing ${FORBIDDEN_PRINCIPAL} as the lens principal — it must be a dedicated read-only identity, never the human admin.`,\n );\n }\n\n const maxPerMinute = opts.maxPerMinute ?? DEFAULT_MAX_PER_MINUTE;\n let windowStart = Date.now();\n let count = 0;\n function withinRate(): boolean {\n if (maxPerMinute <= 0) return true;\n const now = Date.now();\n if (now - windowStart >= 60_000) {\n windowStart = now;\n count = 0;\n }\n count += 1;\n return count <= maxPerMinute;\n }\n\n return async function handle(req: LensMintRequest): Promise<LensMintResponse> {\n // Ship-dark: inert until the secret is provisioned. Read per-request.\n const secret = opts.secret ?? process.env.LENS_MINT_SECRET;\n if (!secret) {\n return { status: 503, body: { error: \"lens-session disabled (LENS_MINT_SECRET unset)\" } };\n }\n\n const provided = parseBearer(req.authorization);\n if (!provided || !safeEqual(provided, secret)) {\n return { status: 401, body: { error: \"unauthorized\" } };\n }\n\n // Rate-limit only authenticated requests — the only holder of a valid bearer\n // is the daemon, so this caps the mint rate if the secret ever leaks.\n if (!withinRate()) {\n return { status: 429, body: { error: \"rate limited\" } };\n }\n\n const ttlMs = clampTtl(opts.ttlMs ?? DEFAULT_TTL_MS);\n const expiresAt = Date.now() + ttlMs;\n const ctx: LensSessionContext = {\n principal,\n host: req.host,\n secure: req.secure,\n ttlMs,\n expiresAt,\n };\n\n // The app-supplied minter can fail (DB down, signing error, …). Keep the\n // {status, body} contract intact: a 500 JSON instead of an uncaught throw\n // bubbling up as a framework 500 with a stack. Log server-side for debugging;\n // never leak the underlying error to the caller (the daemon).\n let minted: LensCookie | LensCookie[];\n try {\n minted = await opts.createSession(ctx);\n } catch (err) {\n console.error(\"[@broberg/lens] createSession threw while minting a lens session:\", err);\n return { status: 500, body: { error: \"lens-session mint failed\" } };\n }\n\n const cookies = Array.isArray(minted) ? minted : [minted];\n const fallbackDomain = opts.cookieDomain ?? process.env.LENS_COOKIE_DOMAIN ?? req.host;\n const expiresSec = Math.floor(expiresAt / 1000);\n\n const storageState: LensStorageState = {\n cookies: cookies.map((c) => ({\n name: c.name,\n value: c.value,\n domain: c.domain ?? fallbackDomain,\n path: c.path ?? \"/\",\n httpOnly: c.httpOnly ?? true,\n secure: c.secure ?? req.secure,\n sameSite: c.sameSite ?? \"Lax\",\n expires: c.expires ?? expiresSec,\n })),\n origins: [],\n };\n\n return { status: 200, body: storageState };\n };\n}\n"]}
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { createLensMintHandler } from './chunk-4QJFODYF.js';
1
+ export { createLensMintHandler } from './chunk-VYKHHZQA.js';
2
2
  //# sourceMappingURL=index.js.map
3
3
  //# sourceMappingURL=index.js.map
package/dist/next.cjs CHANGED
@@ -67,7 +67,13 @@ function createLensMintHandler(opts) {
67
67
  ttlMs,
68
68
  expiresAt
69
69
  };
70
- const minted = await opts.createSession(ctx);
70
+ let minted;
71
+ try {
72
+ minted = await opts.createSession(ctx);
73
+ } catch (err) {
74
+ console.error("[@broberg/lens] createSession threw while minting a lens session:", err);
75
+ return { status: 500, body: { error: "lens-session mint failed" } };
76
+ }
71
77
  const cookies = Array.isArray(minted) ? minted : [minted];
72
78
  const fallbackDomain = opts.cookieDomain ?? process.env.LENS_COOKIE_DOMAIN ?? req.host;
73
79
  const expiresSec = Math.floor(expiresAt / 1e3);
package/dist/next.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/next.ts"],"names":["createHash","timingSafeEqual"],"mappings":"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","file":"next.cjs","sourcesContent":["// @broberg/lens — Lens-mint compliance core (F036).\n//\n// The fleet auth standard (cardmem F098.1, docs/LENS-MINT-ENDPOINT.md): the Lens\n// daemon POSTs to your app's `POST /api/lens-session` with a NARROW bearer; you\n// mint a SHORT-LIVED, READ-ONLY session for a DEDICATED lens principal (never\n// cb@webhouse.dk) and return it as a Playwright storageState. Lens injects those\n// cookies before capture, so it screenshots the REAL authed surface — incl. prod.\n//\n// This module is the UNIFORM + SECURE ~80% every app shares: ship-dark, a\n// constant-time bearer check, a never-cb principal guard, TTL clamp, basic\n// rate-limit, and the fixed storageState assembly. The app supplies only the\n// auth-specific 20% — a `createLensSession(ctx)` hook that mints + SIGNS its own\n// session cookie. Framework adapters live in `./next` and `./hono`.\n\nimport { createHash, timingSafeEqual } from \"node:crypto\";\n\n/** Default + max session TTL: 10 minutes — long enough for a capture run. */\nconst DEFAULT_TTL_MS = 10 * 60 * 1000;\n/** Floor so a misconfigured app can't mint an instantly-dead session. */\nconst MIN_TTL_MS = 60 * 1000;\nconst DEFAULT_MAX_PER_MINUTE = 30;\n\n/** The permanent human admin — the lens principal must NEVER be this. */\nconst FORBIDDEN_PRINCIPAL = \"cb@webhouse.dk\";\n\n/**\n * A single cookie the app's `createLensSession` hook returns. Only `name` +\n * `value` are required; the core fills the rest (`domain`, `path`, `secure`,\n * `expires`, …) from the request + options.\n */\nexport interface LensCookie {\n name: string;\n value: string;\n domain?: string;\n path?: string;\n httpOnly?: boolean;\n secure?: boolean;\n sameSite?: \"Lax\" | \"Strict\" | \"None\";\n /** unix SECONDS. Omit to let the core stamp it from the clamped TTL. */\n expires?: number;\n}\n\n/** What the app's `createLensSession` hook receives. */\nexport interface LensSessionContext {\n /** The dedicated read-only lens principal (e.g. \"lens@myapp.local\"). */\n principal: string;\n /** Request host — the default cookie domain. */\n host: string;\n /** Whether the request arrived over https — the default cookie `secure`. */\n secure: boolean;\n /** The clamped TTL, in ms. */\n ttlMs: number;\n /** When the session must expire (unix MS). Clamp your session row to this. */\n expiresAt: number;\n}\n\n/** The app-supplied session minter — the auth-specific 20%. */\nexport type CreateLensSession = (\n ctx: LensSessionContext,\n) => Promise<LensCookie | LensCookie[]> | LensCookie | LensCookie[];\n\nexport interface LensMintOptions {\n /**\n * The narrow bearer secret. Defaults to `process.env.LENS_MINT_SECRET`, read\n * per-request so the endpoint ships dark and flips on without a restart.\n */\n secret?: string;\n /** App-supplied session minter (mints + signs the principal's cookie). */\n createSession: CreateLensSession;\n /** The dedicated read-only lens identity. Required; never cb@webhouse.dk. */\n principal: string;\n /** Session TTL in ms. Default 600_000; clamped to [60_000, 600_000]. */\n ttlMs?: number;\n /**\n * Force a cookie domain (e.g. \".myapp.com\" for cross-subdomain capture). Else\n * `process.env.LENS_COOKIE_DOMAIN`, else the request host. NEVER derive it\n * from the bound socket address — on Fly/proxy hosts that is \"0.0.0.0\", and\n * the browser then never sends the cookie (silent false-green).\n */\n cookieDomain?: string;\n /** Basic per-handler fixed-window rate-limit. Default 30/min; 0 disables. */\n maxPerMinute?: number;\n}\n\n/** A normalized request — what the framework adapters extract + pass in. */\nexport interface LensMintRequest {\n authorization: string | null;\n host: string;\n secure: boolean;\n}\n\n/** The fixed Playwright storageState the Lens daemon consumes verbatim. */\nexport interface LensStorageState {\n cookies: Array<{\n name: string;\n value: string;\n domain: string;\n path: string;\n httpOnly: boolean;\n secure: boolean;\n sameSite: \"Lax\" | \"Strict\" | \"None\";\n expires: number;\n }>;\n origins: never[];\n}\n\nexport interface LensMintResponse {\n status: number;\n body: LensStorageState | { error: string };\n}\n\n/** Length-independent constant-time compare (hash → equal-length → compare). */\nfunction safeEqual(a: string, b: string): boolean {\n const ha = createHash(\"sha256\").update(a).digest();\n const hb = createHash(\"sha256\").update(b).digest();\n return timingSafeEqual(ha, hb);\n}\n\nfunction parseBearer(authorization: string | null): string | null {\n if (!authorization) return null;\n const m = authorization.match(/^Bearer\\s+(.+)$/i);\n return m ? m[1]!.trim() : null;\n}\n\nfunction clampTtl(ttlMs: number): number {\n if (!Number.isFinite(ttlMs)) return DEFAULT_TTL_MS;\n return Math.min(Math.max(ttlMs, MIN_TTL_MS), DEFAULT_TTL_MS);\n}\n\n/**\n * Build the normalized Lens-mint handler. Wrap it with `@broberg/lens/next` or\n * `@broberg/lens/hono`, or call the returned function directly with a\n * `{ authorization, host, secure }` request from any framework.\n *\n * Throws at construction if `principal` is missing/blank or is cb@webhouse.dk.\n */\nexport function createLensMintHandler(\n opts: LensMintOptions,\n): (req: LensMintRequest) => Promise<LensMintResponse> {\n const principal = (opts.principal ?? \"\").trim();\n if (!principal) {\n throw new Error(\n '@broberg/lens: `principal` is required — the dedicated read-only lens identity (e.g. \"lens@yourapp.local\").',\n );\n }\n if (principal.toLowerCase() === FORBIDDEN_PRINCIPAL) {\n throw new Error(\n `@broberg/lens: refusing ${FORBIDDEN_PRINCIPAL} as the lens principal — it must be a dedicated read-only identity, never the human admin.`,\n );\n }\n\n const maxPerMinute = opts.maxPerMinute ?? DEFAULT_MAX_PER_MINUTE;\n let windowStart = Date.now();\n let count = 0;\n function withinRate(): boolean {\n if (maxPerMinute <= 0) return true;\n const now = Date.now();\n if (now - windowStart >= 60_000) {\n windowStart = now;\n count = 0;\n }\n count += 1;\n return count <= maxPerMinute;\n }\n\n return async function handle(req: LensMintRequest): Promise<LensMintResponse> {\n // Ship-dark: inert until the secret is provisioned. Read per-request.\n const secret = opts.secret ?? process.env.LENS_MINT_SECRET;\n if (!secret) {\n return { status: 503, body: { error: \"lens-session disabled (LENS_MINT_SECRET unset)\" } };\n }\n\n const provided = parseBearer(req.authorization);\n if (!provided || !safeEqual(provided, secret)) {\n return { status: 401, body: { error: \"unauthorized\" } };\n }\n\n // Rate-limit only authenticated requests — the only holder of a valid bearer\n // is the daemon, so this caps the mint rate if the secret ever leaks.\n if (!withinRate()) {\n return { status: 429, body: { error: \"rate limited\" } };\n }\n\n const ttlMs = clampTtl(opts.ttlMs ?? DEFAULT_TTL_MS);\n const expiresAt = Date.now() + ttlMs;\n const ctx: LensSessionContext = {\n principal,\n host: req.host,\n secure: req.secure,\n ttlMs,\n expiresAt,\n };\n\n const minted = await opts.createSession(ctx);\n const cookies = Array.isArray(minted) ? minted : [minted];\n const fallbackDomain = opts.cookieDomain ?? process.env.LENS_COOKIE_DOMAIN ?? req.host;\n const expiresSec = Math.floor(expiresAt / 1000);\n\n const storageState: LensStorageState = {\n cookies: cookies.map((c) => ({\n name: c.name,\n value: c.value,\n domain: c.domain ?? fallbackDomain,\n path: c.path ?? \"/\",\n httpOnly: c.httpOnly ?? true,\n secure: c.secure ?? req.secure,\n sameSite: c.sameSite ?? \"Lax\",\n expires: c.expires ?? expiresSec,\n })),\n origins: [],\n };\n\n return { status: 200, body: storageState };\n };\n}\n","// @broberg/lens/next — Next.js 16 route-handler adapter.\n//\n// Mount at `app/api/lens-session/route.ts`:\n//\n// import { createLensRoute } from \"@broberg/lens/next\";\n// export const { POST } = createLensRoute({\n// principal: \"lens@myapp.local\",\n// async createSession({ principal, expiresAt }) {\n// const value = await signMySessionCookie(principal, expiresAt);\n// return { name: \"myapp.session_token\", value };\n// },\n// });\n//\n// Uses Web `Request`/`Response` — no `next`/`react` import, so it runs on the\n// Node runtime route handlers serve from. (node:crypto in the core means this is\n// NOT for the Edge runtime — mint endpoints hit a DB anyway, so Node is right.)\n\nimport { createLensMintHandler, type LensMintOptions } from \"./index\";\n\nexport function createLensRoute(opts: LensMintOptions): {\n POST: (req: Request) => Promise<Response>;\n} {\n const handle = createLensMintHandler(opts);\n return {\n async POST(req: Request): Promise<Response> {\n const url = new URL(req.url);\n const host =\n req.headers.get(\"x-forwarded-host\") ?? req.headers.get(\"host\") ?? url.host;\n const proto =\n req.headers.get(\"x-forwarded-proto\") ?? url.protocol.replace(\":\", \"\");\n const res = await handle({\n authorization: req.headers.get(\"authorization\"),\n host,\n secure: proto === \"https\",\n });\n return Response.json(res.body, { status: res.status });\n },\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/next.ts"],"names":["createHash","timingSafeEqual"],"mappings":"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","file":"next.cjs","sourcesContent":["// @broberg/lens — Lens-mint compliance core (F036).\n//\n// The fleet auth standard (cardmem F098.1, docs/LENS-MINT-ENDPOINT.md): the Lens\n// daemon POSTs to your app's `POST /api/lens-session` with a NARROW bearer; you\n// mint a SHORT-LIVED, READ-ONLY session for a DEDICATED lens principal (never\n// cb@webhouse.dk) and return it as a Playwright storageState. Lens injects those\n// cookies before capture, so it screenshots the REAL authed surface — incl. prod.\n//\n// This module is the UNIFORM + SECURE ~80% every app shares: ship-dark, a\n// constant-time bearer check, a never-cb principal guard, TTL clamp, basic\n// rate-limit, and the fixed storageState assembly. The app supplies only the\n// auth-specific 20% — a `createLensSession(ctx)` hook that mints + SIGNS its own\n// session cookie. Framework adapters live in `./next` and `./hono`.\n\nimport { createHash, timingSafeEqual } from \"node:crypto\";\n\n/** Default + max session TTL: 10 minutes — long enough for a capture run. */\nconst DEFAULT_TTL_MS = 10 * 60 * 1000;\n/** Floor so a misconfigured app can't mint an instantly-dead session. */\nconst MIN_TTL_MS = 60 * 1000;\nconst DEFAULT_MAX_PER_MINUTE = 30;\n\n/** The permanent human admin — the lens principal must NEVER be this. */\nconst FORBIDDEN_PRINCIPAL = \"cb@webhouse.dk\";\n\n/**\n * A single cookie the app's `createLensSession` hook returns. Only `name` +\n * `value` are required; the core fills the rest (`domain`, `path`, `secure`,\n * `expires`, …) from the request + options.\n */\nexport interface LensCookie {\n name: string;\n value: string;\n domain?: string;\n path?: string;\n httpOnly?: boolean;\n secure?: boolean;\n sameSite?: \"Lax\" | \"Strict\" | \"None\";\n /** unix SECONDS. Omit to let the core stamp it from the clamped TTL. */\n expires?: number;\n}\n\n/** What the app's `createLensSession` hook receives. */\nexport interface LensSessionContext {\n /** The dedicated read-only lens principal (e.g. \"lens@myapp.local\"). */\n principal: string;\n /** Request host — the default cookie domain. */\n host: string;\n /** Whether the request arrived over https — the default cookie `secure`. */\n secure: boolean;\n /** The clamped TTL, in ms. */\n ttlMs: number;\n /** When the session must expire (unix MS). Clamp your session row to this. */\n expiresAt: number;\n}\n\n/** The app-supplied session minter — the auth-specific 20%. */\nexport type CreateLensSession = (\n ctx: LensSessionContext,\n) => Promise<LensCookie | LensCookie[]> | LensCookie | LensCookie[];\n\nexport interface LensMintOptions {\n /**\n * The narrow bearer secret. Defaults to `process.env.LENS_MINT_SECRET`, read\n * per-request so the endpoint ships dark and flips on without a restart.\n */\n secret?: string;\n /** App-supplied session minter (mints + signs the principal's cookie). */\n createSession: CreateLensSession;\n /** The dedicated read-only lens identity. Required; never cb@webhouse.dk. */\n principal: string;\n /** Session TTL in ms. Default 600_000; clamped to [60_000, 600_000]. */\n ttlMs?: number;\n /**\n * Force a cookie domain (e.g. \".myapp.com\" for cross-subdomain capture). Else\n * `process.env.LENS_COOKIE_DOMAIN`, else the request host. NEVER derive it\n * from the bound socket address — on Fly/proxy hosts that is \"0.0.0.0\", and\n * the browser then never sends the cookie (silent false-green).\n */\n cookieDomain?: string;\n /** Basic per-handler fixed-window rate-limit. Default 30/min; 0 disables. */\n maxPerMinute?: number;\n}\n\n/** A normalized request — what the framework adapters extract + pass in. */\nexport interface LensMintRequest {\n authorization: string | null;\n host: string;\n secure: boolean;\n}\n\n/** The fixed Playwright storageState the Lens daemon consumes verbatim. */\nexport interface LensStorageState {\n cookies: Array<{\n name: string;\n value: string;\n domain: string;\n path: string;\n httpOnly: boolean;\n secure: boolean;\n sameSite: \"Lax\" | \"Strict\" | \"None\";\n expires: number;\n }>;\n origins: never[];\n}\n\nexport interface LensMintResponse {\n status: number;\n body: LensStorageState | { error: string };\n}\n\n/** Length-independent constant-time compare (hash → equal-length → compare). */\nfunction safeEqual(a: string, b: string): boolean {\n const ha = createHash(\"sha256\").update(a).digest();\n const hb = createHash(\"sha256\").update(b).digest();\n return timingSafeEqual(ha, hb);\n}\n\nfunction parseBearer(authorization: string | null): string | null {\n if (!authorization) return null;\n const m = authorization.match(/^Bearer\\s+(.+)$/i);\n return m ? m[1]!.trim() : null;\n}\n\nfunction clampTtl(ttlMs: number): number {\n if (!Number.isFinite(ttlMs)) return DEFAULT_TTL_MS;\n return Math.min(Math.max(ttlMs, MIN_TTL_MS), DEFAULT_TTL_MS);\n}\n\n/**\n * Build the normalized Lens-mint handler. Wrap it with `@broberg/lens/next` or\n * `@broberg/lens/hono`, or call the returned function directly with a\n * `{ authorization, host, secure }` request from any framework.\n *\n * Throws at construction if `principal` is missing/blank or is cb@webhouse.dk.\n */\nexport function createLensMintHandler(\n opts: LensMintOptions,\n): (req: LensMintRequest) => Promise<LensMintResponse> {\n const principal = (opts.principal ?? \"\").trim();\n if (!principal) {\n throw new Error(\n '@broberg/lens: `principal` is required — the dedicated read-only lens identity (e.g. \"lens@yourapp.local\").',\n );\n }\n if (principal.toLowerCase() === FORBIDDEN_PRINCIPAL) {\n throw new Error(\n `@broberg/lens: refusing ${FORBIDDEN_PRINCIPAL} as the lens principal — it must be a dedicated read-only identity, never the human admin.`,\n );\n }\n\n const maxPerMinute = opts.maxPerMinute ?? DEFAULT_MAX_PER_MINUTE;\n let windowStart = Date.now();\n let count = 0;\n function withinRate(): boolean {\n if (maxPerMinute <= 0) return true;\n const now = Date.now();\n if (now - windowStart >= 60_000) {\n windowStart = now;\n count = 0;\n }\n count += 1;\n return count <= maxPerMinute;\n }\n\n return async function handle(req: LensMintRequest): Promise<LensMintResponse> {\n // Ship-dark: inert until the secret is provisioned. Read per-request.\n const secret = opts.secret ?? process.env.LENS_MINT_SECRET;\n if (!secret) {\n return { status: 503, body: { error: \"lens-session disabled (LENS_MINT_SECRET unset)\" } };\n }\n\n const provided = parseBearer(req.authorization);\n if (!provided || !safeEqual(provided, secret)) {\n return { status: 401, body: { error: \"unauthorized\" } };\n }\n\n // Rate-limit only authenticated requests — the only holder of a valid bearer\n // is the daemon, so this caps the mint rate if the secret ever leaks.\n if (!withinRate()) {\n return { status: 429, body: { error: \"rate limited\" } };\n }\n\n const ttlMs = clampTtl(opts.ttlMs ?? DEFAULT_TTL_MS);\n const expiresAt = Date.now() + ttlMs;\n const ctx: LensSessionContext = {\n principal,\n host: req.host,\n secure: req.secure,\n ttlMs,\n expiresAt,\n };\n\n // The app-supplied minter can fail (DB down, signing error, …). Keep the\n // {status, body} contract intact: a 500 JSON instead of an uncaught throw\n // bubbling up as a framework 500 with a stack. Log server-side for debugging;\n // never leak the underlying error to the caller (the daemon).\n let minted: LensCookie | LensCookie[];\n try {\n minted = await opts.createSession(ctx);\n } catch (err) {\n console.error(\"[@broberg/lens] createSession threw while minting a lens session:\", err);\n return { status: 500, body: { error: \"lens-session mint failed\" } };\n }\n\n const cookies = Array.isArray(minted) ? minted : [minted];\n const fallbackDomain = opts.cookieDomain ?? process.env.LENS_COOKIE_DOMAIN ?? req.host;\n const expiresSec = Math.floor(expiresAt / 1000);\n\n const storageState: LensStorageState = {\n cookies: cookies.map((c) => ({\n name: c.name,\n value: c.value,\n domain: c.domain ?? fallbackDomain,\n path: c.path ?? \"/\",\n httpOnly: c.httpOnly ?? true,\n secure: c.secure ?? req.secure,\n sameSite: c.sameSite ?? \"Lax\",\n expires: c.expires ?? expiresSec,\n })),\n origins: [],\n };\n\n return { status: 200, body: storageState };\n };\n}\n","// @broberg/lens/next — Next.js 16 route-handler adapter.\n//\n// Mount at `app/api/lens-session/route.ts`:\n//\n// import { createLensRoute } from \"@broberg/lens/next\";\n// export const { POST } = createLensRoute({\n// principal: \"lens@myapp.local\",\n// async createSession({ principal, expiresAt }) {\n// const value = await signMySessionCookie(principal, expiresAt);\n// return { name: \"myapp.session_token\", value };\n// },\n// });\n//\n// Uses Web `Request`/`Response` — no `next`/`react` import, so it runs on the\n// Node runtime route handlers serve from. (node:crypto in the core means this is\n// NOT for the Edge runtime — mint endpoints hit a DB anyway, so Node is right.)\n\nimport { createLensMintHandler, type LensMintOptions } from \"./index\";\n\nexport function createLensRoute(opts: LensMintOptions): {\n POST: (req: Request) => Promise<Response>;\n} {\n const handle = createLensMintHandler(opts);\n return {\n async POST(req: Request): Promise<Response> {\n const url = new URL(req.url);\n const host =\n req.headers.get(\"x-forwarded-host\") ?? req.headers.get(\"host\") ?? url.host;\n const proto =\n req.headers.get(\"x-forwarded-proto\") ?? url.protocol.replace(\":\", \"\");\n const res = await handle({\n authorization: req.headers.get(\"authorization\"),\n host,\n secure: proto === \"https\",\n });\n return Response.json(res.body, { status: res.status });\n },\n };\n}\n"]}
package/dist/next.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createLensMintHandler } from './chunk-4QJFODYF.js';
1
+ import { createLensMintHandler } from './chunk-VYKHHZQA.js';
2
2
 
3
3
  // src/next.ts
4
4
  function createLensRoute(opts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@broberg/lens",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Make any app Cardmem-Lens-compliant: a headless POST /api/lens-session mint endpoint (narrow Bearer → short-lived, read-only Playwright storageState) so Lens can log past the auth wall and screenshot the real authed surface, incl. prod. Framework-agnostic core + thin Next.js / Hono adapters. Implements the fleet F098.1 mint standard.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"chunk-4QJFODYF.js","sourcesContent":["// @broberg/lens — Lens-mint compliance core (F036).\n//\n// The fleet auth standard (cardmem F098.1, docs/LENS-MINT-ENDPOINT.md): the Lens\n// daemon POSTs to your app's `POST /api/lens-session` with a NARROW bearer; you\n// mint a SHORT-LIVED, READ-ONLY session for a DEDICATED lens principal (never\n// cb@webhouse.dk) and return it as a Playwright storageState. Lens injects those\n// cookies before capture, so it screenshots the REAL authed surface — incl. prod.\n//\n// This module is the UNIFORM + SECURE ~80% every app shares: ship-dark, a\n// constant-time bearer check, a never-cb principal guard, TTL clamp, basic\n// rate-limit, and the fixed storageState assembly. The app supplies only the\n// auth-specific 20% — a `createLensSession(ctx)` hook that mints + SIGNS its own\n// session cookie. Framework adapters live in `./next` and `./hono`.\n\nimport { createHash, timingSafeEqual } from \"node:crypto\";\n\n/** Default + max session TTL: 10 minutes — long enough for a capture run. */\nconst DEFAULT_TTL_MS = 10 * 60 * 1000;\n/** Floor so a misconfigured app can't mint an instantly-dead session. */\nconst MIN_TTL_MS = 60 * 1000;\nconst DEFAULT_MAX_PER_MINUTE = 30;\n\n/** The permanent human admin — the lens principal must NEVER be this. */\nconst FORBIDDEN_PRINCIPAL = \"cb@webhouse.dk\";\n\n/**\n * A single cookie the app's `createLensSession` hook returns. Only `name` +\n * `value` are required; the core fills the rest (`domain`, `path`, `secure`,\n * `expires`, …) from the request + options.\n */\nexport interface LensCookie {\n name: string;\n value: string;\n domain?: string;\n path?: string;\n httpOnly?: boolean;\n secure?: boolean;\n sameSite?: \"Lax\" | \"Strict\" | \"None\";\n /** unix SECONDS. Omit to let the core stamp it from the clamped TTL. */\n expires?: number;\n}\n\n/** What the app's `createLensSession` hook receives. */\nexport interface LensSessionContext {\n /** The dedicated read-only lens principal (e.g. \"lens@myapp.local\"). */\n principal: string;\n /** Request host — the default cookie domain. */\n host: string;\n /** Whether the request arrived over https — the default cookie `secure`. */\n secure: boolean;\n /** The clamped TTL, in ms. */\n ttlMs: number;\n /** When the session must expire (unix MS). Clamp your session row to this. */\n expiresAt: number;\n}\n\n/** The app-supplied session minter — the auth-specific 20%. */\nexport type CreateLensSession = (\n ctx: LensSessionContext,\n) => Promise<LensCookie | LensCookie[]> | LensCookie | LensCookie[];\n\nexport interface LensMintOptions {\n /**\n * The narrow bearer secret. Defaults to `process.env.LENS_MINT_SECRET`, read\n * per-request so the endpoint ships dark and flips on without a restart.\n */\n secret?: string;\n /** App-supplied session minter (mints + signs the principal's cookie). */\n createSession: CreateLensSession;\n /** The dedicated read-only lens identity. Required; never cb@webhouse.dk. */\n principal: string;\n /** Session TTL in ms. Default 600_000; clamped to [60_000, 600_000]. */\n ttlMs?: number;\n /**\n * Force a cookie domain (e.g. \".myapp.com\" for cross-subdomain capture). Else\n * `process.env.LENS_COOKIE_DOMAIN`, else the request host. NEVER derive it\n * from the bound socket address — on Fly/proxy hosts that is \"0.0.0.0\", and\n * the browser then never sends the cookie (silent false-green).\n */\n cookieDomain?: string;\n /** Basic per-handler fixed-window rate-limit. Default 30/min; 0 disables. */\n maxPerMinute?: number;\n}\n\n/** A normalized request — what the framework adapters extract + pass in. */\nexport interface LensMintRequest {\n authorization: string | null;\n host: string;\n secure: boolean;\n}\n\n/** The fixed Playwright storageState the Lens daemon consumes verbatim. */\nexport interface LensStorageState {\n cookies: Array<{\n name: string;\n value: string;\n domain: string;\n path: string;\n httpOnly: boolean;\n secure: boolean;\n sameSite: \"Lax\" | \"Strict\" | \"None\";\n expires: number;\n }>;\n origins: never[];\n}\n\nexport interface LensMintResponse {\n status: number;\n body: LensStorageState | { error: string };\n}\n\n/** Length-independent constant-time compare (hash → equal-length → compare). */\nfunction safeEqual(a: string, b: string): boolean {\n const ha = createHash(\"sha256\").update(a).digest();\n const hb = createHash(\"sha256\").update(b).digest();\n return timingSafeEqual(ha, hb);\n}\n\nfunction parseBearer(authorization: string | null): string | null {\n if (!authorization) return null;\n const m = authorization.match(/^Bearer\\s+(.+)$/i);\n return m ? m[1]!.trim() : null;\n}\n\nfunction clampTtl(ttlMs: number): number {\n if (!Number.isFinite(ttlMs)) return DEFAULT_TTL_MS;\n return Math.min(Math.max(ttlMs, MIN_TTL_MS), DEFAULT_TTL_MS);\n}\n\n/**\n * Build the normalized Lens-mint handler. Wrap it with `@broberg/lens/next` or\n * `@broberg/lens/hono`, or call the returned function directly with a\n * `{ authorization, host, secure }` request from any framework.\n *\n * Throws at construction if `principal` is missing/blank or is cb@webhouse.dk.\n */\nexport function createLensMintHandler(\n opts: LensMintOptions,\n): (req: LensMintRequest) => Promise<LensMintResponse> {\n const principal = (opts.principal ?? \"\").trim();\n if (!principal) {\n throw new Error(\n '@broberg/lens: `principal` is required — the dedicated read-only lens identity (e.g. \"lens@yourapp.local\").',\n );\n }\n if (principal.toLowerCase() === FORBIDDEN_PRINCIPAL) {\n throw new Error(\n `@broberg/lens: refusing ${FORBIDDEN_PRINCIPAL} as the lens principal — it must be a dedicated read-only identity, never the human admin.`,\n );\n }\n\n const maxPerMinute = opts.maxPerMinute ?? DEFAULT_MAX_PER_MINUTE;\n let windowStart = Date.now();\n let count = 0;\n function withinRate(): boolean {\n if (maxPerMinute <= 0) return true;\n const now = Date.now();\n if (now - windowStart >= 60_000) {\n windowStart = now;\n count = 0;\n }\n count += 1;\n return count <= maxPerMinute;\n }\n\n return async function handle(req: LensMintRequest): Promise<LensMintResponse> {\n // Ship-dark: inert until the secret is provisioned. Read per-request.\n const secret = opts.secret ?? process.env.LENS_MINT_SECRET;\n if (!secret) {\n return { status: 503, body: { error: \"lens-session disabled (LENS_MINT_SECRET unset)\" } };\n }\n\n const provided = parseBearer(req.authorization);\n if (!provided || !safeEqual(provided, secret)) {\n return { status: 401, body: { error: \"unauthorized\" } };\n }\n\n // Rate-limit only authenticated requests — the only holder of a valid bearer\n // is the daemon, so this caps the mint rate if the secret ever leaks.\n if (!withinRate()) {\n return { status: 429, body: { error: \"rate limited\" } };\n }\n\n const ttlMs = clampTtl(opts.ttlMs ?? DEFAULT_TTL_MS);\n const expiresAt = Date.now() + ttlMs;\n const ctx: LensSessionContext = {\n principal,\n host: req.host,\n secure: req.secure,\n ttlMs,\n expiresAt,\n };\n\n const minted = await opts.createSession(ctx);\n const cookies = Array.isArray(minted) ? minted : [minted];\n const fallbackDomain = opts.cookieDomain ?? process.env.LENS_COOKIE_DOMAIN ?? req.host;\n const expiresSec = Math.floor(expiresAt / 1000);\n\n const storageState: LensStorageState = {\n cookies: cookies.map((c) => ({\n name: c.name,\n value: c.value,\n domain: c.domain ?? fallbackDomain,\n path: c.path ?? \"/\",\n httpOnly: c.httpOnly ?? true,\n secure: c.secure ?? req.secure,\n sameSite: c.sameSite ?? \"Lax\",\n expires: c.expires ?? expiresSec,\n })),\n origins: [],\n };\n\n return { status: 200, body: storageState };\n };\n}\n"]}