@broberg/lens
Make any app Cardmem-Lens-compliant in a few lines: expose the fleet-standard mint endpoint so Lens can log past the auth wall and screenshot the real authed surface — including in production — instead of a login page.
npm i @broberg/lens
What it is
Cardmem Lens verifies the surface users actually see, which is almost always behind a login. Lens can't hard-code every app's auth, so each app exposes one endpoint that mints a short-lived, read-only session on demand; Lens calls it just before capture, uses the session, and discards it.
The contract is identical in every repo — only how you mint the session differs.
So this package ships the uniform, security-sensitive ~80% as a headless core;
you supply the auth-specific 20% (a createLensSession hook that mints + signs
your own session cookie).
Implements the fleet F098.1 mint standard (cardmem
docs/LENS-MINT-ENDPOINT.md).componentsowns + publishes it; cardmem owns the spec.
The contract
POST /api/lens-session, header Authorization: Bearer <LENS_MINT_SECRET> →
200 with a Playwright storageState JSON the Lens daemon injects verbatim:
{ "cookies": [ { "name": "<session-cookie>", "value": "<signed>", "domain": "<host>",
"path": "/", "httpOnly": true, "secure": true, "sameSite": "Lax",
"expires": 1733430000 } ],
"origins": [] }
expires is unix seconds. The core fills every field except name/value.
Next.js 16 (Stack A)
app/api/lens-session/route.ts:
import { createLensRoute } from "@broberg/lens/next";
import { signLensCookie } from "@/lib/auth"; // your app's signing
export const { POST } = createLensRoute({
principal: "[email protected]", // dedicated, read-only — NEVER [email protected]
async createSession({ principal, expiresAt }) {
const value = await signLensCookie(principal, expiresAt);
return { name: "myapp.session_token", value };
},
});
Hono (Stack B)
import { Hono } from "hono";
import { lensSessionHandler } from "@broberg/lens/hono";
const app = new Hono();
app.post("/api/lens-session", lensSessionHandler({
principal: "[email protected]",
async createSession({ principal, expiresAt }) {
return { name: "myapp_session", value: await mintSession(principal, expiresAt) };
},
}));
Any other framework
Call the core handler directly with a normalized request:
import { createLensMintHandler } from "@broberg/lens";
const handle = createLensMintHandler({ principal, createSession });
const res = await handle({ authorization, host, secure }); // → { status, body }
Minting the session (the part you write)
⚠️ Issue a REAL session cookie your own auth accepts — the same one your SPA's auth-gate checks (
getSession()/ your JWT middleware). A synthetic token can authenticate API routes (socurllooks green) yet the SPA still bounces to login, and Lens captures a login wall. Mint via your framework's real session machinery.
- Better Auth: the cookie is signed (
<token>.<sig>). Create the session viaauth.$context → internalAdapter.createSession(userId, ctx), clamp its expiry toexpiresAt, then serialize the signed cookie. Cookie name =<prefix>.session_token. - Supabase: mint server-side with the service-role key for the dedicated lens
user; return the
sb-<ref>-auth-tokencookie@supabase/ssrreads. - Custom JWT (jose/HS256): sign a short-lived read-only JWT for the lens principal; return it as your session cookie.
What the core guarantees
- Ships dark:
503untilLENS_MINT_SECRETis set (read per-request — flip it on without a restart). 401+ constant-time bearer compare (crypto.timingSafeEqualover SHA-256 digests — length-independent, never throws).- Never cb@: constructing with
principal: "[email protected]"(or a blank principal) throws — the lens identity must be a dedicated read-only user. - TTL clamp to
[60s, 10min](default 10min);expiresAtis handed to your hook so you clamp your session row to the same TTL. - Basic rate-limit (default 30/min) →
429. - Cookie domain =
cookieDomain ?? LENS_COOKIE_DOMAIN ?? request host(port stripped, so alocalhost:3000dev build yields a validlocalhostdomain) — never the bound socket address (0.0.0.0on Fly/proxy hosts) and never a port-scoped value. On an https request that resolves to a localhost-family host with no explicit override, the coreconsole.warns loudly — behind a reverse proxy that's a silent false-green (setLENS_COOKIE_DOMAIN). Genuine http localhost dev stays silent, so a Docker dev-build sandbox just works.
Write sessions — only when asked for, explicitly (0.2.1, F036.6)
Some surfaces need Lens to ACT, not only look (helpdesk: Lens plays the receptionist and writes in live chat). cardmem's standard: the mint door gives a READ principal by default; a WRITE principal only on an explicit request, and every write mint is audited.
| request body | result |
|---|---|
none / empty / {} / {"mode":"read"} | read principal, exactly as 0.1.x |
{"mode":"write","writes":true} | write principal, IF the app enabled writes |
{"mode":"write"} · {"writes":true} · unknown mode · bad JSON | 400, nothing minted |
a write request, app has no writePrincipal | 403, nothing minted |
Both keys, on purpose: one field is one typo or one default away from write access.
createLensRoute({
principal: "[email protected]", // read
writePrincipal: "[email protected]", // write: a SEPARATE identity
async onWriteMint({ principal, host, expiresAt, at }) {
await audit.insert({ kind: "lens.write-mint", principal, host, expiresAt, at });
},
async createSession({ principal, mode, expiresAt }) {
// mint a write-capable role ONLY when mode === "write"
const value = await signMySessionCookie(principal, expiresAt, { role: mode === "write" ? "reception" : "readonly" });
return { name: "myapp_session", value };
},
});
- Writes are off until
writePrincipalis set. It must differ fromprincipal(the audit has to tell a read from a write), and it is never [email protected]. Setting it withoutonWriteMintthrows at startup. - The audit fails closed.
onWriteMintruns aftercreateSessionand BEFORE the cookie is returned. If it throws, the response is500and no cookie leaves; the minted row simply expires with the TTL (max 10 min). ctx.mode("read"|"write") is new onLensSessionContext. Code that only READS the context is unaffected; code that BUILDS one (a test fixture) must addmode.
Read-only is enforced by YOUR app
This package mints the session; it does not enforce read-only from inside the
endpoint. Add a server-side write-guard: if the authenticated principal is the
lens user, reject every mutating request (POST/PUT/PATCH/DELETE + write
RPC/tools) with 403. Give the lens principal enough read access to render the
target surfaces (often admin-level read). For PII surfaces, capture no_diff
smoke — never a stored pixel baseline.
Runs on the Node runtime (the core uses
node:crypto) — not Next's Edge runtime. Mint endpoints hit a DB to create a session anyway, so Node is correct.
testid-gaps — the F086 check in CI (0.3.0, F036.7)
Every interactive element must carry a data-testid (the fleet's F086 rule). The
Cardmem daemon checks it locally (POST /lens/testid-gaps); CI cannot reach that
daemon, so the same scanner ships here:
npx -p @broberg/lens testid-gaps src # or: testid-gaps src, with the package installed
| exit | means |
|---|---|
0 | files were scanned, no gaps — prints how many files it read |
1 | gaps — one file:line <tag> without data-testid … per gap |
2 | usage error, or zero source files scanned (a wrong path must not pass a gate) |
Files the scanner cannot see into while they carry handlers (hyperscript,
h('button', { onClick })) are named on stderr as not examined. They are not
gaps, and they are not clean either.
In code: import { inventoryInteractiveGaps, gapsInFile } from "@broberg/lens/testid-gaps" (Node only).
The rules are cardmem's, moved verbatim (cardmem manifest.ts @ cc3ad9e) with
their tests. What counts as interactive is decided there; a change goes through
cardmem first, so the daemon and CI never disagree.
API
interface LensCookie { name: string; value: string; domain?: string; path?: string;
httpOnly?: boolean; secure?: boolean; sameSite?: "Lax" | "Strict" | "None"; expires?: number; }
interface LensSessionContext { principal: string; mode: "read" | "write"; host: string; secure: boolean; ttlMs: number; expiresAt: number; }
type CreateLensSession = (ctx: LensSessionContext) => Promise<LensCookie | LensCookie[]> | LensCookie | LensCookie[];
interface LensMintOptions { secret?: string; createSession: CreateLensSession; principal: string;
ttlMs?: number; cookieDomain?: string; maxPerMinute?: number;
writePrincipal?: string; onWriteMint?: (e: { principal: string; host: string; expiresAt: number; at: number }) => void | Promise<void>; }
function createLensMintHandler(opts: LensMintOptions): (req: { authorization: string | null; host: string; secure: boolean; body?: string | null }) => Promise<{ status: number; body: unknown }>;
// @broberg/lens/next → createLensRoute(opts): { POST(req: Request): Promise<Response> }
// @broberg/lens/hono → lensSessionHandler(opts): (c: Context) => Promise<Response>
// @broberg/lens/next-auth → nextAuthLensSession(opts): (ctx: LensSessionContext) => Promise<LensCookie>
NextAuth apps — @broberg/lens/next-auth
A ready-made createSession hook for NextAuth (v5) apps, so you don't hand-roll
the JWT encode + the subtle cookie gotchas every NextAuth-behind-login repo hits:
import { createLensMintHandler } from "@broberg/lens";
import { nextAuthLensSession } from "@broberg/lens/next-auth";
const handler = createLensMintHandler({
principal: "[email protected]",
createSession: nextAuthLensSession({
authSecret: process.env.AUTH_SECRET!,
claims: { id: "lens-user-id", email: "[email protected]", name: "Lens" },
// `secure` defaults to NODE_ENV === "production" (NextAuth's useSecureCookies)
}),
});
next-auth is an optional peer dependency (imported lazily; pass a custom
encode to avoid it). The helper captures three battle-tested gotchas so you don't:
saltMUST equal the cookie name — NextAuthgetTokenderives the JWE key withsalt = cookie name; a mismatch silently decodes tonull.__Secure-prefix ⇒secure: true— secure mode drives the cookie name (__Secure-authjs.session-tokenvsauthjs.session-token), the salt, AND the secure flag, together.- Chromium rejects a secure cookie set by
{domain}alone — the Cardmem Lens daemon synthesizes an https source-URL so{domain, path}works fleet-wide; on a non-daemon env, set aurlcookie instead.
MIT · part of the @broberg/* shared-library family.