@broberg/lens 0.1.0
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.
- package/README.md +141 -0
- package/dist/chunk-4QJFODYF.js +91 -0
- package/dist/chunk-4QJFODYF.js.map +1 -0
- package/dist/hono.cjs +109 -0
- package/dist/hono.cjs.map +1 -0
- package/dist/hono.d.cts +6 -0
- package/dist/hono.d.ts +6 -0
- package/dist/hono.js +21 -0
- package/dist/hono.js.map +1 -0
- package/dist/index.cjs +93 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +89 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/next.cjs +111 -0
- package/dist/next.cjs.map +1 -0
- package/dist/next.d.cts +7 -0
- package/dist/next.d.ts +7 -0
- package/dist/next.js +23 -0
- package/dist/next.js.map +1 -0
- package/package.json +64 -0
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# @broberg/lens
|
|
2
|
+
|
|
3
|
+
Make any app **Cardmem-Lens-compliant** in a few lines: expose the fleet-standard
|
|
4
|
+
**mint endpoint** so Lens can log *past the auth wall* and screenshot the **real
|
|
5
|
+
authed surface** — including in production — instead of a login page.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i @broberg/lens
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## What it is
|
|
12
|
+
|
|
13
|
+
Cardmem **Lens** verifies the surface users actually see, which is almost always
|
|
14
|
+
**behind a login**. Lens can't hard-code every app's auth, so each app exposes one
|
|
15
|
+
endpoint that mints a **short-lived, read-only** session on demand; Lens calls it
|
|
16
|
+
just before capture, uses the session, and discards it.
|
|
17
|
+
|
|
18
|
+
The contract is identical in every repo — only *how you mint the session* differs.
|
|
19
|
+
So this package ships the uniform, security-sensitive **~80%** as a headless core;
|
|
20
|
+
you supply the auth-specific **20%** (a `createLensSession` hook that mints + signs
|
|
21
|
+
your own session cookie).
|
|
22
|
+
|
|
23
|
+
> Implements the fleet F098.1 mint standard (cardmem `docs/LENS-MINT-ENDPOINT.md`).
|
|
24
|
+
> `components` owns + publishes it; cardmem owns the spec.
|
|
25
|
+
|
|
26
|
+
## The contract
|
|
27
|
+
|
|
28
|
+
`POST /api/lens-session`, header `Authorization: Bearer <LENS_MINT_SECRET>` →
|
|
29
|
+
**200** with a Playwright **storageState** JSON the Lens daemon injects verbatim:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{ "cookies": [ { "name": "<session-cookie>", "value": "<signed>", "domain": "<host>",
|
|
33
|
+
"path": "/", "httpOnly": true, "secure": true, "sameSite": "Lax",
|
|
34
|
+
"expires": 1733430000 } ],
|
|
35
|
+
"origins": [] }
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`expires` is **unix seconds**. The core fills every field except `name`/`value`.
|
|
39
|
+
|
|
40
|
+
## Next.js 16 (Stack A)
|
|
41
|
+
|
|
42
|
+
`app/api/lens-session/route.ts`:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { createLensRoute } from "@broberg/lens/next";
|
|
46
|
+
import { signLensCookie } from "@/lib/auth"; // your app's signing
|
|
47
|
+
|
|
48
|
+
export const { POST } = createLensRoute({
|
|
49
|
+
principal: "lens@myapp.local", // dedicated, read-only — NEVER cb@webhouse.dk
|
|
50
|
+
async createSession({ principal, expiresAt }) {
|
|
51
|
+
const value = await signLensCookie(principal, expiresAt);
|
|
52
|
+
return { name: "myapp.session_token", value };
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Hono (Stack B)
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { Hono } from "hono";
|
|
61
|
+
import { lensSessionHandler } from "@broberg/lens/hono";
|
|
62
|
+
|
|
63
|
+
const app = new Hono();
|
|
64
|
+
app.post("/api/lens-session", lensSessionHandler({
|
|
65
|
+
principal: "lens@myapp.local",
|
|
66
|
+
async createSession({ principal, expiresAt }) {
|
|
67
|
+
return { name: "myapp_session", value: await mintSession(principal, expiresAt) };
|
|
68
|
+
},
|
|
69
|
+
}));
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Any other framework
|
|
73
|
+
|
|
74
|
+
Call the core handler directly with a normalized request:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { createLensMintHandler } from "@broberg/lens";
|
|
78
|
+
|
|
79
|
+
const handle = createLensMintHandler({ principal, createSession });
|
|
80
|
+
const res = await handle({ authorization, host, secure }); // → { status, body }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Minting the session (the part you write)
|
|
84
|
+
|
|
85
|
+
> ⚠️ **Issue a REAL session cookie your own auth accepts** — the same one your
|
|
86
|
+
> SPA's auth-gate checks (`getSession()` / your JWT middleware). A synthetic token
|
|
87
|
+
> can authenticate API routes (so `curl` looks green) yet the SPA still bounces to
|
|
88
|
+
> login, and Lens captures a login wall. Mint via your framework's real session
|
|
89
|
+
> machinery.
|
|
90
|
+
|
|
91
|
+
- **Better Auth:** the cookie is signed (`<token>.<sig>`). Create the session via
|
|
92
|
+
`auth.$context → internalAdapter.createSession(userId, ctx)`, clamp its expiry to
|
|
93
|
+
`expiresAt`, then serialize the signed cookie. Cookie name = `<prefix>.session_token`.
|
|
94
|
+
- **Supabase:** mint server-side with the service-role key for the dedicated lens
|
|
95
|
+
user; return the `sb-<ref>-auth-token` cookie `@supabase/ssr` reads.
|
|
96
|
+
- **Custom JWT (jose/HS256):** sign a short-lived read-only JWT for the lens
|
|
97
|
+
principal; return it as your session cookie.
|
|
98
|
+
|
|
99
|
+
## What the core guarantees
|
|
100
|
+
|
|
101
|
+
- **Ships dark:** `503` until `LENS_MINT_SECRET` is set (read per-request — flip it
|
|
102
|
+
on without a restart).
|
|
103
|
+
- **`401` + constant-time** bearer compare (`crypto.timingSafeEqual` over SHA-256
|
|
104
|
+
digests — length-independent, never throws).
|
|
105
|
+
- **Never cb@:** constructing with `principal: "cb@webhouse.dk"` (or a blank
|
|
106
|
+
principal) **throws** — the lens identity must be a dedicated read-only user.
|
|
107
|
+
- **TTL clamp** to `[60s, 10min]` (default 10min); `expiresAt` is handed to your
|
|
108
|
+
hook so you clamp your session row to the same TTL.
|
|
109
|
+
- **Basic rate-limit** (default 30/min) → `429`.
|
|
110
|
+
- **Cookie domain** = `cookieDomain ?? LENS_COOKIE_DOMAIN ?? request host` — never
|
|
111
|
+
the bound socket address (which is `0.0.0.0` on Fly/proxy hosts → the browser
|
|
112
|
+
never sends the cookie → a silent false-green).
|
|
113
|
+
|
|
114
|
+
## Read-only is enforced by YOUR app
|
|
115
|
+
|
|
116
|
+
This package mints the session; it does **not** enforce read-only from inside the
|
|
117
|
+
endpoint. Add a server-side **write-guard**: if the authenticated principal is the
|
|
118
|
+
lens user, reject every mutating request (`POST`/`PUT`/`PATCH`/`DELETE` + write
|
|
119
|
+
RPC/tools) with `403`. Give the lens principal enough *read* access to render the
|
|
120
|
+
target surfaces (often admin-level read). For PII surfaces, capture `no_diff`
|
|
121
|
+
smoke — never a stored pixel baseline.
|
|
122
|
+
|
|
123
|
+
> Runs on the **Node runtime** (the core uses `node:crypto`) — not Next's Edge
|
|
124
|
+
> runtime. Mint endpoints hit a DB to create a session anyway, so Node is correct.
|
|
125
|
+
|
|
126
|
+
## API
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
interface LensCookie { name: string; value: string; domain?: string; path?: string;
|
|
130
|
+
httpOnly?: boolean; secure?: boolean; sameSite?: "Lax" | "Strict" | "None"; expires?: number; }
|
|
131
|
+
interface LensSessionContext { principal: string; host: string; secure: boolean; ttlMs: number; expiresAt: number; }
|
|
132
|
+
type CreateLensSession = (ctx: LensSessionContext) => Promise<LensCookie | LensCookie[]> | LensCookie | LensCookie[];
|
|
133
|
+
interface LensMintOptions { secret?: string; createSession: CreateLensSession; principal: string;
|
|
134
|
+
ttlMs?: number; cookieDomain?: string; maxPerMinute?: number; }
|
|
135
|
+
|
|
136
|
+
function createLensMintHandler(opts: LensMintOptions): (req: { authorization: string | null; host: string; secure: boolean }) => Promise<{ status: number; body: unknown }>;
|
|
137
|
+
// @broberg/lens/next → createLensRoute(opts): { POST(req: Request): Promise<Response> }
|
|
138
|
+
// @broberg/lens/hono → lensSessionHandler(opts): (c: Context) => Promise<Response>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
MIT · part of the [`@broberg/*`](https://github.com/broberg-ai/components) shared-library family.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'crypto';
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
var DEFAULT_TTL_MS = 10 * 60 * 1e3;
|
|
5
|
+
var MIN_TTL_MS = 60 * 1e3;
|
|
6
|
+
var DEFAULT_MAX_PER_MINUTE = 30;
|
|
7
|
+
var FORBIDDEN_PRINCIPAL = "cb@webhouse.dk";
|
|
8
|
+
function safeEqual(a, b) {
|
|
9
|
+
const ha = createHash("sha256").update(a).digest();
|
|
10
|
+
const hb = createHash("sha256").update(b).digest();
|
|
11
|
+
return timingSafeEqual(ha, hb);
|
|
12
|
+
}
|
|
13
|
+
function parseBearer(authorization) {
|
|
14
|
+
if (!authorization) return null;
|
|
15
|
+
const m = authorization.match(/^Bearer\s+(.+)$/i);
|
|
16
|
+
return m ? m[1].trim() : null;
|
|
17
|
+
}
|
|
18
|
+
function clampTtl(ttlMs) {
|
|
19
|
+
if (!Number.isFinite(ttlMs)) return DEFAULT_TTL_MS;
|
|
20
|
+
return Math.min(Math.max(ttlMs, MIN_TTL_MS), DEFAULT_TTL_MS);
|
|
21
|
+
}
|
|
22
|
+
function createLensMintHandler(opts) {
|
|
23
|
+
const principal = (opts.principal ?? "").trim();
|
|
24
|
+
if (!principal) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'@broberg/lens: `principal` is required \u2014 the dedicated read-only lens identity (e.g. "lens@yourapp.local").'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
if (principal.toLowerCase() === FORBIDDEN_PRINCIPAL) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`@broberg/lens: refusing ${FORBIDDEN_PRINCIPAL} as the lens principal \u2014 it must be a dedicated read-only identity, never the human admin.`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const maxPerMinute = opts.maxPerMinute ?? DEFAULT_MAX_PER_MINUTE;
|
|
35
|
+
let windowStart = Date.now();
|
|
36
|
+
let count = 0;
|
|
37
|
+
function withinRate() {
|
|
38
|
+
if (maxPerMinute <= 0) return true;
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
if (now - windowStart >= 6e4) {
|
|
41
|
+
windowStart = now;
|
|
42
|
+
count = 0;
|
|
43
|
+
}
|
|
44
|
+
count += 1;
|
|
45
|
+
return count <= maxPerMinute;
|
|
46
|
+
}
|
|
47
|
+
return async function handle(req) {
|
|
48
|
+
const secret = opts.secret ?? process.env.LENS_MINT_SECRET;
|
|
49
|
+
if (!secret) {
|
|
50
|
+
return { status: 503, body: { error: "lens-session disabled (LENS_MINT_SECRET unset)" } };
|
|
51
|
+
}
|
|
52
|
+
const provided = parseBearer(req.authorization);
|
|
53
|
+
if (!provided || !safeEqual(provided, secret)) {
|
|
54
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
55
|
+
}
|
|
56
|
+
if (!withinRate()) {
|
|
57
|
+
return { status: 429, body: { error: "rate limited" } };
|
|
58
|
+
}
|
|
59
|
+
const ttlMs = clampTtl(opts.ttlMs ?? DEFAULT_TTL_MS);
|
|
60
|
+
const expiresAt = Date.now() + ttlMs;
|
|
61
|
+
const ctx = {
|
|
62
|
+
principal,
|
|
63
|
+
host: req.host,
|
|
64
|
+
secure: req.secure,
|
|
65
|
+
ttlMs,
|
|
66
|
+
expiresAt
|
|
67
|
+
};
|
|
68
|
+
const minted = await opts.createSession(ctx);
|
|
69
|
+
const cookies = Array.isArray(minted) ? minted : [minted];
|
|
70
|
+
const fallbackDomain = opts.cookieDomain ?? process.env.LENS_COOKIE_DOMAIN ?? req.host;
|
|
71
|
+
const expiresSec = Math.floor(expiresAt / 1e3);
|
|
72
|
+
const storageState = {
|
|
73
|
+
cookies: cookies.map((c) => ({
|
|
74
|
+
name: c.name,
|
|
75
|
+
value: c.value,
|
|
76
|
+
domain: c.domain ?? fallbackDomain,
|
|
77
|
+
path: c.path ?? "/",
|
|
78
|
+
httpOnly: c.httpOnly ?? true,
|
|
79
|
+
secure: c.secure ?? req.secure,
|
|
80
|
+
sameSite: c.sameSite ?? "Lax",
|
|
81
|
+
expires: c.expires ?? expiresSec
|
|
82
|
+
})),
|
|
83
|
+
origins: []
|
|
84
|
+
};
|
|
85
|
+
return { status: 200, body: storageState };
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export { createLensMintHandler };
|
|
90
|
+
//# sourceMappingURL=chunk-4QJFODYF.js.map
|
|
91
|
+
//# sourceMappingURL=chunk-4QJFODYF.js.map
|
|
@@ -0,0 +1 @@
|
|
|
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"]}
|
package/dist/hono.cjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
// src/index.ts
|
|
6
|
+
var DEFAULT_TTL_MS = 10 * 60 * 1e3;
|
|
7
|
+
var MIN_TTL_MS = 60 * 1e3;
|
|
8
|
+
var DEFAULT_MAX_PER_MINUTE = 30;
|
|
9
|
+
var FORBIDDEN_PRINCIPAL = "cb@webhouse.dk";
|
|
10
|
+
function safeEqual(a, b) {
|
|
11
|
+
const ha = crypto.createHash("sha256").update(a).digest();
|
|
12
|
+
const hb = crypto.createHash("sha256").update(b).digest();
|
|
13
|
+
return crypto.timingSafeEqual(ha, hb);
|
|
14
|
+
}
|
|
15
|
+
function parseBearer(authorization) {
|
|
16
|
+
if (!authorization) return null;
|
|
17
|
+
const m = authorization.match(/^Bearer\s+(.+)$/i);
|
|
18
|
+
return m ? m[1].trim() : null;
|
|
19
|
+
}
|
|
20
|
+
function clampTtl(ttlMs) {
|
|
21
|
+
if (!Number.isFinite(ttlMs)) return DEFAULT_TTL_MS;
|
|
22
|
+
return Math.min(Math.max(ttlMs, MIN_TTL_MS), DEFAULT_TTL_MS);
|
|
23
|
+
}
|
|
24
|
+
function createLensMintHandler(opts) {
|
|
25
|
+
const principal = (opts.principal ?? "").trim();
|
|
26
|
+
if (!principal) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
'@broberg/lens: `principal` is required \u2014 the dedicated read-only lens identity (e.g. "lens@yourapp.local").'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
if (principal.toLowerCase() === FORBIDDEN_PRINCIPAL) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`@broberg/lens: refusing ${FORBIDDEN_PRINCIPAL} as the lens principal \u2014 it must be a dedicated read-only identity, never the human admin.`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
const maxPerMinute = opts.maxPerMinute ?? DEFAULT_MAX_PER_MINUTE;
|
|
37
|
+
let windowStart = Date.now();
|
|
38
|
+
let count = 0;
|
|
39
|
+
function withinRate() {
|
|
40
|
+
if (maxPerMinute <= 0) return true;
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
if (now - windowStart >= 6e4) {
|
|
43
|
+
windowStart = now;
|
|
44
|
+
count = 0;
|
|
45
|
+
}
|
|
46
|
+
count += 1;
|
|
47
|
+
return count <= maxPerMinute;
|
|
48
|
+
}
|
|
49
|
+
return async function handle(req) {
|
|
50
|
+
const secret = opts.secret ?? process.env.LENS_MINT_SECRET;
|
|
51
|
+
if (!secret) {
|
|
52
|
+
return { status: 503, body: { error: "lens-session disabled (LENS_MINT_SECRET unset)" } };
|
|
53
|
+
}
|
|
54
|
+
const provided = parseBearer(req.authorization);
|
|
55
|
+
if (!provided || !safeEqual(provided, secret)) {
|
|
56
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
57
|
+
}
|
|
58
|
+
if (!withinRate()) {
|
|
59
|
+
return { status: 429, body: { error: "rate limited" } };
|
|
60
|
+
}
|
|
61
|
+
const ttlMs = clampTtl(opts.ttlMs ?? DEFAULT_TTL_MS);
|
|
62
|
+
const expiresAt = Date.now() + ttlMs;
|
|
63
|
+
const ctx = {
|
|
64
|
+
principal,
|
|
65
|
+
host: req.host,
|
|
66
|
+
secure: req.secure,
|
|
67
|
+
ttlMs,
|
|
68
|
+
expiresAt
|
|
69
|
+
};
|
|
70
|
+
const minted = await opts.createSession(ctx);
|
|
71
|
+
const cookies = Array.isArray(minted) ? minted : [minted];
|
|
72
|
+
const fallbackDomain = opts.cookieDomain ?? process.env.LENS_COOKIE_DOMAIN ?? req.host;
|
|
73
|
+
const expiresSec = Math.floor(expiresAt / 1e3);
|
|
74
|
+
const storageState = {
|
|
75
|
+
cookies: cookies.map((c) => ({
|
|
76
|
+
name: c.name,
|
|
77
|
+
value: c.value,
|
|
78
|
+
domain: c.domain ?? fallbackDomain,
|
|
79
|
+
path: c.path ?? "/",
|
|
80
|
+
httpOnly: c.httpOnly ?? true,
|
|
81
|
+
secure: c.secure ?? req.secure,
|
|
82
|
+
sameSite: c.sameSite ?? "Lax",
|
|
83
|
+
expires: c.expires ?? expiresSec
|
|
84
|
+
})),
|
|
85
|
+
origins: []
|
|
86
|
+
};
|
|
87
|
+
return { status: 200, body: storageState };
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/hono.ts
|
|
92
|
+
function lensSessionHandler(opts) {
|
|
93
|
+
const handle = createLensMintHandler(opts);
|
|
94
|
+
return async (c) => {
|
|
95
|
+
const url = new URL(c.req.url);
|
|
96
|
+
const host = c.req.header("x-forwarded-host") ?? c.req.header("host") ?? url.host;
|
|
97
|
+
const proto = c.req.header("x-forwarded-proto") ?? url.protocol.replace(":", "");
|
|
98
|
+
const res = await handle({
|
|
99
|
+
authorization: c.req.header("authorization") ?? null,
|
|
100
|
+
host,
|
|
101
|
+
secure: proto === "https"
|
|
102
|
+
});
|
|
103
|
+
return c.json(res.body, res.status);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
exports.lensSessionHandler = lensSessionHandler;
|
|
108
|
+
//# sourceMappingURL=hono.cjs.map
|
|
109
|
+
//# sourceMappingURL=hono.cjs.map
|
|
@@ -0,0 +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"]}
|
package/dist/hono.d.cts
ADDED
package/dist/hono.d.ts
ADDED
package/dist/hono.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createLensMintHandler } from './chunk-4QJFODYF.js';
|
|
2
|
+
|
|
3
|
+
// src/hono.ts
|
|
4
|
+
function lensSessionHandler(opts) {
|
|
5
|
+
const handle = createLensMintHandler(opts);
|
|
6
|
+
return async (c) => {
|
|
7
|
+
const url = new URL(c.req.url);
|
|
8
|
+
const host = c.req.header("x-forwarded-host") ?? c.req.header("host") ?? url.host;
|
|
9
|
+
const proto = c.req.header("x-forwarded-proto") ?? url.protocol.replace(":", "");
|
|
10
|
+
const res = await handle({
|
|
11
|
+
authorization: c.req.header("authorization") ?? null,
|
|
12
|
+
host,
|
|
13
|
+
secure: proto === "https"
|
|
14
|
+
});
|
|
15
|
+
return c.json(res.body, res.status);
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { lensSessionHandler };
|
|
20
|
+
//# sourceMappingURL=hono.js.map
|
|
21
|
+
//# sourceMappingURL=hono.js.map
|
package/dist/hono.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/hono.ts"],"names":[],"mappings":";;;AAkBO,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.js","sourcesContent":["// @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/index.cjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
// src/index.ts
|
|
6
|
+
var DEFAULT_TTL_MS = 10 * 60 * 1e3;
|
|
7
|
+
var MIN_TTL_MS = 60 * 1e3;
|
|
8
|
+
var DEFAULT_MAX_PER_MINUTE = 30;
|
|
9
|
+
var FORBIDDEN_PRINCIPAL = "cb@webhouse.dk";
|
|
10
|
+
function safeEqual(a, b) {
|
|
11
|
+
const ha = crypto.createHash("sha256").update(a).digest();
|
|
12
|
+
const hb = crypto.createHash("sha256").update(b).digest();
|
|
13
|
+
return crypto.timingSafeEqual(ha, hb);
|
|
14
|
+
}
|
|
15
|
+
function parseBearer(authorization) {
|
|
16
|
+
if (!authorization) return null;
|
|
17
|
+
const m = authorization.match(/^Bearer\s+(.+)$/i);
|
|
18
|
+
return m ? m[1].trim() : null;
|
|
19
|
+
}
|
|
20
|
+
function clampTtl(ttlMs) {
|
|
21
|
+
if (!Number.isFinite(ttlMs)) return DEFAULT_TTL_MS;
|
|
22
|
+
return Math.min(Math.max(ttlMs, MIN_TTL_MS), DEFAULT_TTL_MS);
|
|
23
|
+
}
|
|
24
|
+
function createLensMintHandler(opts) {
|
|
25
|
+
const principal = (opts.principal ?? "").trim();
|
|
26
|
+
if (!principal) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
'@broberg/lens: `principal` is required \u2014 the dedicated read-only lens identity (e.g. "lens@yourapp.local").'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
if (principal.toLowerCase() === FORBIDDEN_PRINCIPAL) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`@broberg/lens: refusing ${FORBIDDEN_PRINCIPAL} as the lens principal \u2014 it must be a dedicated read-only identity, never the human admin.`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
const maxPerMinute = opts.maxPerMinute ?? DEFAULT_MAX_PER_MINUTE;
|
|
37
|
+
let windowStart = Date.now();
|
|
38
|
+
let count = 0;
|
|
39
|
+
function withinRate() {
|
|
40
|
+
if (maxPerMinute <= 0) return true;
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
if (now - windowStart >= 6e4) {
|
|
43
|
+
windowStart = now;
|
|
44
|
+
count = 0;
|
|
45
|
+
}
|
|
46
|
+
count += 1;
|
|
47
|
+
return count <= maxPerMinute;
|
|
48
|
+
}
|
|
49
|
+
return async function handle(req) {
|
|
50
|
+
const secret = opts.secret ?? process.env.LENS_MINT_SECRET;
|
|
51
|
+
if (!secret) {
|
|
52
|
+
return { status: 503, body: { error: "lens-session disabled (LENS_MINT_SECRET unset)" } };
|
|
53
|
+
}
|
|
54
|
+
const provided = parseBearer(req.authorization);
|
|
55
|
+
if (!provided || !safeEqual(provided, secret)) {
|
|
56
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
57
|
+
}
|
|
58
|
+
if (!withinRate()) {
|
|
59
|
+
return { status: 429, body: { error: "rate limited" } };
|
|
60
|
+
}
|
|
61
|
+
const ttlMs = clampTtl(opts.ttlMs ?? DEFAULT_TTL_MS);
|
|
62
|
+
const expiresAt = Date.now() + ttlMs;
|
|
63
|
+
const ctx = {
|
|
64
|
+
principal,
|
|
65
|
+
host: req.host,
|
|
66
|
+
secure: req.secure,
|
|
67
|
+
ttlMs,
|
|
68
|
+
expiresAt
|
|
69
|
+
};
|
|
70
|
+
const minted = await opts.createSession(ctx);
|
|
71
|
+
const cookies = Array.isArray(minted) ? minted : [minted];
|
|
72
|
+
const fallbackDomain = opts.cookieDomain ?? process.env.LENS_COOKIE_DOMAIN ?? req.host;
|
|
73
|
+
const expiresSec = Math.floor(expiresAt / 1e3);
|
|
74
|
+
const storageState = {
|
|
75
|
+
cookies: cookies.map((c) => ({
|
|
76
|
+
name: c.name,
|
|
77
|
+
value: c.value,
|
|
78
|
+
domain: c.domain ?? fallbackDomain,
|
|
79
|
+
path: c.path ?? "/",
|
|
80
|
+
httpOnly: c.httpOnly ?? true,
|
|
81
|
+
secure: c.secure ?? req.secure,
|
|
82
|
+
sameSite: c.sameSite ?? "Lax",
|
|
83
|
+
expires: c.expires ?? expiresSec
|
|
84
|
+
})),
|
|
85
|
+
origins: []
|
|
86
|
+
};
|
|
87
|
+
return { status: 200, body: storageState };
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
exports.createLensMintHandler = createLensMintHandler;
|
|
92
|
+
//# sourceMappingURL=index.cjs.map
|
|
93
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +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"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single cookie the app's `createLensSession` hook returns. Only `name` +
|
|
3
|
+
* `value` are required; the core fills the rest (`domain`, `path`, `secure`,
|
|
4
|
+
* `expires`, …) from the request + options.
|
|
5
|
+
*/
|
|
6
|
+
interface LensCookie {
|
|
7
|
+
name: string;
|
|
8
|
+
value: string;
|
|
9
|
+
domain?: string;
|
|
10
|
+
path?: string;
|
|
11
|
+
httpOnly?: boolean;
|
|
12
|
+
secure?: boolean;
|
|
13
|
+
sameSite?: "Lax" | "Strict" | "None";
|
|
14
|
+
/** unix SECONDS. Omit to let the core stamp it from the clamped TTL. */
|
|
15
|
+
expires?: number;
|
|
16
|
+
}
|
|
17
|
+
/** What the app's `createLensSession` hook receives. */
|
|
18
|
+
interface LensSessionContext {
|
|
19
|
+
/** The dedicated read-only lens principal (e.g. "lens@myapp.local"). */
|
|
20
|
+
principal: string;
|
|
21
|
+
/** Request host — the default cookie domain. */
|
|
22
|
+
host: string;
|
|
23
|
+
/** Whether the request arrived over https — the default cookie `secure`. */
|
|
24
|
+
secure: boolean;
|
|
25
|
+
/** The clamped TTL, in ms. */
|
|
26
|
+
ttlMs: number;
|
|
27
|
+
/** When the session must expire (unix MS). Clamp your session row to this. */
|
|
28
|
+
expiresAt: number;
|
|
29
|
+
}
|
|
30
|
+
/** The app-supplied session minter — the auth-specific 20%. */
|
|
31
|
+
type CreateLensSession = (ctx: LensSessionContext) => Promise<LensCookie | LensCookie[]> | LensCookie | LensCookie[];
|
|
32
|
+
interface LensMintOptions {
|
|
33
|
+
/**
|
|
34
|
+
* The narrow bearer secret. Defaults to `process.env.LENS_MINT_SECRET`, read
|
|
35
|
+
* per-request so the endpoint ships dark and flips on without a restart.
|
|
36
|
+
*/
|
|
37
|
+
secret?: string;
|
|
38
|
+
/** App-supplied session minter (mints + signs the principal's cookie). */
|
|
39
|
+
createSession: CreateLensSession;
|
|
40
|
+
/** The dedicated read-only lens identity. Required; never cb@webhouse.dk. */
|
|
41
|
+
principal: string;
|
|
42
|
+
/** Session TTL in ms. Default 600_000; clamped to [60_000, 600_000]. */
|
|
43
|
+
ttlMs?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Force a cookie domain (e.g. ".myapp.com" for cross-subdomain capture). Else
|
|
46
|
+
* `process.env.LENS_COOKIE_DOMAIN`, else the request host. NEVER derive it
|
|
47
|
+
* from the bound socket address — on Fly/proxy hosts that is "0.0.0.0", and
|
|
48
|
+
* the browser then never sends the cookie (silent false-green).
|
|
49
|
+
*/
|
|
50
|
+
cookieDomain?: string;
|
|
51
|
+
/** Basic per-handler fixed-window rate-limit. Default 30/min; 0 disables. */
|
|
52
|
+
maxPerMinute?: number;
|
|
53
|
+
}
|
|
54
|
+
/** A normalized request — what the framework adapters extract + pass in. */
|
|
55
|
+
interface LensMintRequest {
|
|
56
|
+
authorization: string | null;
|
|
57
|
+
host: string;
|
|
58
|
+
secure: boolean;
|
|
59
|
+
}
|
|
60
|
+
/** The fixed Playwright storageState the Lens daemon consumes verbatim. */
|
|
61
|
+
interface LensStorageState {
|
|
62
|
+
cookies: Array<{
|
|
63
|
+
name: string;
|
|
64
|
+
value: string;
|
|
65
|
+
domain: string;
|
|
66
|
+
path: string;
|
|
67
|
+
httpOnly: boolean;
|
|
68
|
+
secure: boolean;
|
|
69
|
+
sameSite: "Lax" | "Strict" | "None";
|
|
70
|
+
expires: number;
|
|
71
|
+
}>;
|
|
72
|
+
origins: never[];
|
|
73
|
+
}
|
|
74
|
+
interface LensMintResponse {
|
|
75
|
+
status: number;
|
|
76
|
+
body: LensStorageState | {
|
|
77
|
+
error: string;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Build the normalized Lens-mint handler. Wrap it with `@broberg/lens/next` or
|
|
82
|
+
* `@broberg/lens/hono`, or call the returned function directly with a
|
|
83
|
+
* `{ authorization, host, secure }` request from any framework.
|
|
84
|
+
*
|
|
85
|
+
* Throws at construction if `principal` is missing/blank or is cb@webhouse.dk.
|
|
86
|
+
*/
|
|
87
|
+
declare function createLensMintHandler(opts: LensMintOptions): (req: LensMintRequest) => Promise<LensMintResponse>;
|
|
88
|
+
|
|
89
|
+
export { type CreateLensSession, type LensCookie, type LensMintOptions, type LensMintRequest, type LensMintResponse, type LensSessionContext, type LensStorageState, createLensMintHandler };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single cookie the app's `createLensSession` hook returns. Only `name` +
|
|
3
|
+
* `value` are required; the core fills the rest (`domain`, `path`, `secure`,
|
|
4
|
+
* `expires`, …) from the request + options.
|
|
5
|
+
*/
|
|
6
|
+
interface LensCookie {
|
|
7
|
+
name: string;
|
|
8
|
+
value: string;
|
|
9
|
+
domain?: string;
|
|
10
|
+
path?: string;
|
|
11
|
+
httpOnly?: boolean;
|
|
12
|
+
secure?: boolean;
|
|
13
|
+
sameSite?: "Lax" | "Strict" | "None";
|
|
14
|
+
/** unix SECONDS. Omit to let the core stamp it from the clamped TTL. */
|
|
15
|
+
expires?: number;
|
|
16
|
+
}
|
|
17
|
+
/** What the app's `createLensSession` hook receives. */
|
|
18
|
+
interface LensSessionContext {
|
|
19
|
+
/** The dedicated read-only lens principal (e.g. "lens@myapp.local"). */
|
|
20
|
+
principal: string;
|
|
21
|
+
/** Request host — the default cookie domain. */
|
|
22
|
+
host: string;
|
|
23
|
+
/** Whether the request arrived over https — the default cookie `secure`. */
|
|
24
|
+
secure: boolean;
|
|
25
|
+
/** The clamped TTL, in ms. */
|
|
26
|
+
ttlMs: number;
|
|
27
|
+
/** When the session must expire (unix MS). Clamp your session row to this. */
|
|
28
|
+
expiresAt: number;
|
|
29
|
+
}
|
|
30
|
+
/** The app-supplied session minter — the auth-specific 20%. */
|
|
31
|
+
type CreateLensSession = (ctx: LensSessionContext) => Promise<LensCookie | LensCookie[]> | LensCookie | LensCookie[];
|
|
32
|
+
interface LensMintOptions {
|
|
33
|
+
/**
|
|
34
|
+
* The narrow bearer secret. Defaults to `process.env.LENS_MINT_SECRET`, read
|
|
35
|
+
* per-request so the endpoint ships dark and flips on without a restart.
|
|
36
|
+
*/
|
|
37
|
+
secret?: string;
|
|
38
|
+
/** App-supplied session minter (mints + signs the principal's cookie). */
|
|
39
|
+
createSession: CreateLensSession;
|
|
40
|
+
/** The dedicated read-only lens identity. Required; never cb@webhouse.dk. */
|
|
41
|
+
principal: string;
|
|
42
|
+
/** Session TTL in ms. Default 600_000; clamped to [60_000, 600_000]. */
|
|
43
|
+
ttlMs?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Force a cookie domain (e.g. ".myapp.com" for cross-subdomain capture). Else
|
|
46
|
+
* `process.env.LENS_COOKIE_DOMAIN`, else the request host. NEVER derive it
|
|
47
|
+
* from the bound socket address — on Fly/proxy hosts that is "0.0.0.0", and
|
|
48
|
+
* the browser then never sends the cookie (silent false-green).
|
|
49
|
+
*/
|
|
50
|
+
cookieDomain?: string;
|
|
51
|
+
/** Basic per-handler fixed-window rate-limit. Default 30/min; 0 disables. */
|
|
52
|
+
maxPerMinute?: number;
|
|
53
|
+
}
|
|
54
|
+
/** A normalized request — what the framework adapters extract + pass in. */
|
|
55
|
+
interface LensMintRequest {
|
|
56
|
+
authorization: string | null;
|
|
57
|
+
host: string;
|
|
58
|
+
secure: boolean;
|
|
59
|
+
}
|
|
60
|
+
/** The fixed Playwright storageState the Lens daemon consumes verbatim. */
|
|
61
|
+
interface LensStorageState {
|
|
62
|
+
cookies: Array<{
|
|
63
|
+
name: string;
|
|
64
|
+
value: string;
|
|
65
|
+
domain: string;
|
|
66
|
+
path: string;
|
|
67
|
+
httpOnly: boolean;
|
|
68
|
+
secure: boolean;
|
|
69
|
+
sameSite: "Lax" | "Strict" | "None";
|
|
70
|
+
expires: number;
|
|
71
|
+
}>;
|
|
72
|
+
origins: never[];
|
|
73
|
+
}
|
|
74
|
+
interface LensMintResponse {
|
|
75
|
+
status: number;
|
|
76
|
+
body: LensStorageState | {
|
|
77
|
+
error: string;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Build the normalized Lens-mint handler. Wrap it with `@broberg/lens/next` or
|
|
82
|
+
* `@broberg/lens/hono`, or call the returned function directly with a
|
|
83
|
+
* `{ authorization, host, secure }` request from any framework.
|
|
84
|
+
*
|
|
85
|
+
* Throws at construction if `principal` is missing/blank or is cb@webhouse.dk.
|
|
86
|
+
*/
|
|
87
|
+
declare function createLensMintHandler(opts: LensMintOptions): (req: LensMintRequest) => Promise<LensMintResponse>;
|
|
88
|
+
|
|
89
|
+
export { type CreateLensSession, type LensCookie, type LensMintOptions, type LensMintRequest, type LensMintResponse, type LensSessionContext, type LensStorageState, createLensMintHandler };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
|
package/dist/next.cjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
// src/index.ts
|
|
6
|
+
var DEFAULT_TTL_MS = 10 * 60 * 1e3;
|
|
7
|
+
var MIN_TTL_MS = 60 * 1e3;
|
|
8
|
+
var DEFAULT_MAX_PER_MINUTE = 30;
|
|
9
|
+
var FORBIDDEN_PRINCIPAL = "cb@webhouse.dk";
|
|
10
|
+
function safeEqual(a, b) {
|
|
11
|
+
const ha = crypto.createHash("sha256").update(a).digest();
|
|
12
|
+
const hb = crypto.createHash("sha256").update(b).digest();
|
|
13
|
+
return crypto.timingSafeEqual(ha, hb);
|
|
14
|
+
}
|
|
15
|
+
function parseBearer(authorization) {
|
|
16
|
+
if (!authorization) return null;
|
|
17
|
+
const m = authorization.match(/^Bearer\s+(.+)$/i);
|
|
18
|
+
return m ? m[1].trim() : null;
|
|
19
|
+
}
|
|
20
|
+
function clampTtl(ttlMs) {
|
|
21
|
+
if (!Number.isFinite(ttlMs)) return DEFAULT_TTL_MS;
|
|
22
|
+
return Math.min(Math.max(ttlMs, MIN_TTL_MS), DEFAULT_TTL_MS);
|
|
23
|
+
}
|
|
24
|
+
function createLensMintHandler(opts) {
|
|
25
|
+
const principal = (opts.principal ?? "").trim();
|
|
26
|
+
if (!principal) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
'@broberg/lens: `principal` is required \u2014 the dedicated read-only lens identity (e.g. "lens@yourapp.local").'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
if (principal.toLowerCase() === FORBIDDEN_PRINCIPAL) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`@broberg/lens: refusing ${FORBIDDEN_PRINCIPAL} as the lens principal \u2014 it must be a dedicated read-only identity, never the human admin.`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
const maxPerMinute = opts.maxPerMinute ?? DEFAULT_MAX_PER_MINUTE;
|
|
37
|
+
let windowStart = Date.now();
|
|
38
|
+
let count = 0;
|
|
39
|
+
function withinRate() {
|
|
40
|
+
if (maxPerMinute <= 0) return true;
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
if (now - windowStart >= 6e4) {
|
|
43
|
+
windowStart = now;
|
|
44
|
+
count = 0;
|
|
45
|
+
}
|
|
46
|
+
count += 1;
|
|
47
|
+
return count <= maxPerMinute;
|
|
48
|
+
}
|
|
49
|
+
return async function handle(req) {
|
|
50
|
+
const secret = opts.secret ?? process.env.LENS_MINT_SECRET;
|
|
51
|
+
if (!secret) {
|
|
52
|
+
return { status: 503, body: { error: "lens-session disabled (LENS_MINT_SECRET unset)" } };
|
|
53
|
+
}
|
|
54
|
+
const provided = parseBearer(req.authorization);
|
|
55
|
+
if (!provided || !safeEqual(provided, secret)) {
|
|
56
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
57
|
+
}
|
|
58
|
+
if (!withinRate()) {
|
|
59
|
+
return { status: 429, body: { error: "rate limited" } };
|
|
60
|
+
}
|
|
61
|
+
const ttlMs = clampTtl(opts.ttlMs ?? DEFAULT_TTL_MS);
|
|
62
|
+
const expiresAt = Date.now() + ttlMs;
|
|
63
|
+
const ctx = {
|
|
64
|
+
principal,
|
|
65
|
+
host: req.host,
|
|
66
|
+
secure: req.secure,
|
|
67
|
+
ttlMs,
|
|
68
|
+
expiresAt
|
|
69
|
+
};
|
|
70
|
+
const minted = await opts.createSession(ctx);
|
|
71
|
+
const cookies = Array.isArray(minted) ? minted : [minted];
|
|
72
|
+
const fallbackDomain = opts.cookieDomain ?? process.env.LENS_COOKIE_DOMAIN ?? req.host;
|
|
73
|
+
const expiresSec = Math.floor(expiresAt / 1e3);
|
|
74
|
+
const storageState = {
|
|
75
|
+
cookies: cookies.map((c) => ({
|
|
76
|
+
name: c.name,
|
|
77
|
+
value: c.value,
|
|
78
|
+
domain: c.domain ?? fallbackDomain,
|
|
79
|
+
path: c.path ?? "/",
|
|
80
|
+
httpOnly: c.httpOnly ?? true,
|
|
81
|
+
secure: c.secure ?? req.secure,
|
|
82
|
+
sameSite: c.sameSite ?? "Lax",
|
|
83
|
+
expires: c.expires ?? expiresSec
|
|
84
|
+
})),
|
|
85
|
+
origins: []
|
|
86
|
+
};
|
|
87
|
+
return { status: 200, body: storageState };
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/next.ts
|
|
92
|
+
function createLensRoute(opts) {
|
|
93
|
+
const handle = createLensMintHandler(opts);
|
|
94
|
+
return {
|
|
95
|
+
async POST(req) {
|
|
96
|
+
const url = new URL(req.url);
|
|
97
|
+
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? url.host;
|
|
98
|
+
const proto = req.headers.get("x-forwarded-proto") ?? url.protocol.replace(":", "");
|
|
99
|
+
const res = await handle({
|
|
100
|
+
authorization: req.headers.get("authorization"),
|
|
101
|
+
host,
|
|
102
|
+
secure: proto === "https"
|
|
103
|
+
});
|
|
104
|
+
return Response.json(res.body, { status: res.status });
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
exports.createLensRoute = createLensRoute;
|
|
110
|
+
//# sourceMappingURL=next.cjs.map
|
|
111
|
+
//# sourceMappingURL=next.cjs.map
|
|
@@ -0,0 +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"]}
|
package/dist/next.d.cts
ADDED
package/dist/next.d.ts
ADDED
package/dist/next.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createLensMintHandler } from './chunk-4QJFODYF.js';
|
|
2
|
+
|
|
3
|
+
// src/next.ts
|
|
4
|
+
function createLensRoute(opts) {
|
|
5
|
+
const handle = createLensMintHandler(opts);
|
|
6
|
+
return {
|
|
7
|
+
async POST(req) {
|
|
8
|
+
const url = new URL(req.url);
|
|
9
|
+
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? url.host;
|
|
10
|
+
const proto = req.headers.get("x-forwarded-proto") ?? url.protocol.replace(":", "");
|
|
11
|
+
const res = await handle({
|
|
12
|
+
authorization: req.headers.get("authorization"),
|
|
13
|
+
host,
|
|
14
|
+
secure: proto === "https"
|
|
15
|
+
});
|
|
16
|
+
return Response.json(res.body, { status: res.status });
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { createLensRoute };
|
|
22
|
+
//# sourceMappingURL=next.js.map
|
|
23
|
+
//# sourceMappingURL=next.js.map
|
package/dist/next.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/next.ts"],"names":[],"mappings":";;;AAmBO,SAAS,gBAAgB,IAAA,EAE9B;AACA,EAAA,MAAM,MAAA,GAAS,sBAAsB,IAAI,CAAA;AACzC,EAAA,OAAO;AAAA,IACL,MAAM,KAAK,GAAA,EAAiC;AAC1C,MAAA,MAAM,GAAA,GAAM,IAAI,GAAA,CAAI,GAAA,CAAI,GAAG,CAAA;AAC3B,MAAA,MAAM,IAAA,GACJ,GAAA,CAAI,OAAA,CAAQ,GAAA,CAAI,kBAAkB,CAAA,IAAK,GAAA,CAAI,OAAA,CAAQ,GAAA,CAAI,MAAM,CAAA,IAAK,GAAA,CAAI,IAAA;AACxE,MAAA,MAAM,KAAA,GACJ,GAAA,CAAI,OAAA,CAAQ,GAAA,CAAI,mBAAmB,KAAK,GAAA,CAAI,QAAA,CAAS,OAAA,CAAQ,GAAA,EAAK,EAAE,CAAA;AACtE,MAAA,MAAM,GAAA,GAAM,MAAM,MAAA,CAAO;AAAA,QACvB,aAAA,EAAe,GAAA,CAAI,OAAA,CAAQ,GAAA,CAAI,eAAe,CAAA;AAAA,QAC9C,IAAA;AAAA,QACA,QAAQ,KAAA,KAAU;AAAA,OACnB,CAAA;AACD,MAAA,OAAO,QAAA,CAAS,KAAK,GAAA,CAAI,IAAA,EAAM,EAAE,MAAA,EAAQ,GAAA,CAAI,QAAQ,CAAA;AAAA,IACvD;AAAA,GACF;AACF","file":"next.js","sourcesContent":["// @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/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@broberg/lens",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"files": ["dist", "README.md"],
|
|
9
|
+
"main": "./dist/index.cjs",
|
|
10
|
+
"module": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"require": "./dist/index.cjs"
|
|
17
|
+
},
|
|
18
|
+
"./next": {
|
|
19
|
+
"types": "./dist/next.d.ts",
|
|
20
|
+
"import": "./dist/next.js",
|
|
21
|
+
"require": "./dist/next.cjs"
|
|
22
|
+
},
|
|
23
|
+
"./hono": {
|
|
24
|
+
"types": "./dist/hono.d.ts",
|
|
25
|
+
"import": "./dist/hono.js",
|
|
26
|
+
"require": "./dist/hono.cjs"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsup",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"typecheck": "tsc --noEmit"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"hono": ">=4"
|
|
36
|
+
},
|
|
37
|
+
"peerDependenciesMeta": {
|
|
38
|
+
"hono": { "optional": true }
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^22.7.0",
|
|
42
|
+
"hono": "^4.6.0",
|
|
43
|
+
"tsup": "^8.3.0",
|
|
44
|
+
"typescript": "^5.6.0",
|
|
45
|
+
"vitest": "^2.1.0"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"lens",
|
|
49
|
+
"cardmem",
|
|
50
|
+
"visual-regression",
|
|
51
|
+
"playwright",
|
|
52
|
+
"storagestate",
|
|
53
|
+
"auth",
|
|
54
|
+
"session",
|
|
55
|
+
"screenshot",
|
|
56
|
+
"broberg"
|
|
57
|
+
],
|
|
58
|
+
"repository": {
|
|
59
|
+
"type": "git",
|
|
60
|
+
"url": "https://github.com/broberg-ai/components",
|
|
61
|
+
"directory": "packages/lens"
|
|
62
|
+
},
|
|
63
|
+
"publishConfig": { "access": "public" }
|
|
64
|
+
}
|