@broberg/lens-daemon 0.0.0-stage → 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.
@@ -0,0 +1,370 @@
1
+ // F276.1 — every Lens artifact reaches the cloud, so the owner can open it on a phone.
2
+ //
3
+ // THE DEFECT THIS CLOSES, in Christian's words (2026-08-25): "Alle Lens billeder bør da
4
+ // placeres i Cloud så jeg kan tilgå dem fra alle steder og maskiner eller kan jeg jo ikke
5
+ // se dem på min iPhone PWA." He was right, and measuring it found something larger — the
6
+ // whole Lens surface reads from 127.0.0.1, so on a phone it does not merely show broken
7
+ // images, it shows "daemon offline". Lens had never worked on his phone at all.
8
+ //
9
+ // A QUEUE, NOT AN INLINE UPLOAD. Three reasons, each of which has a failure attached:
10
+ // · a capture must not get slower, or people stop running them;
11
+ // · an upload failure must not fail the RUN — the local copy is still valid evidence,
12
+ // and losing a run because object storage hiccuped is the worse outcome;
13
+ // · the backfill of 6588 historic files (F276.4) is this same operation at scale, so it
14
+ // uses this machinery rather than a second implementation that drifts from it.
15
+ //
16
+ // WHAT IS DELIBERATELY NOT HERE: deletion. The local copy stays. It is the fast path on
17
+ // the Mac and the fallback when a cloud copy is missing, and removing a working path
18
+ // before its replacement is proven is the naked cutover the harness contract forbids.
19
+
20
+ import type { Database } from 'bun:sqlite';
21
+ import type { LensStorage } from './storage.ts';
22
+ import type { CloudClientLike } from './service.ts';
23
+
24
+ /**
25
+ * The queue's schema, owned by the module that reads and writes it.
26
+ *
27
+ * It lives here rather than inline in state.ts so a test can build a real queue table
28
+ * WITHOUT calling getDb — which is a module-level singleton that ignores its path
29
+ * argument after the first call. A second test file that touched it handed the next
30
+ * file someone else's database, and the assertion that failed named a value that test
31
+ * had never written. One definition, two callers, no copy to drift.
32
+ */
33
+ export const UPLOAD_QUEUE_DDL = `
34
+ CREATE TABLE IF NOT EXISTS lens_artifact_uploads (
35
+ artifact_key TEXT PRIMARY KEY,
36
+ project_slug TEXT NOT NULL,
37
+ status TEXT NOT NULL DEFAULT 'pending',
38
+ cloud_artifact_id TEXT,
39
+ attempts INTEGER NOT NULL DEFAULT 0,
40
+ last_error TEXT,
41
+ enqueued_at INTEGER NOT NULL,
42
+ uploaded_at INTEGER,
43
+ claimed_at INTEGER
44
+ );
45
+ CREATE INDEX IF NOT EXISTS lens_artifact_uploads_status_idx
46
+ ON lens_artifact_uploads (status, enqueued_at);
47
+ `;
48
+
49
+ /** Create the queue table if it is not there. Idempotent. */
50
+ export function ensureUploadQueue(db: Database): void {
51
+ db.exec(UPLOAD_QUEUE_DDL);
52
+ // Additive for daemons whose table predates F276.5.
53
+ try {
54
+ db.exec('ALTER TABLE lens_artifact_uploads ADD COLUMN claimed_at INTEGER');
55
+ } catch {
56
+ // column already exists
57
+ }
58
+ }
59
+
60
+ /** A claim older than this is treated as abandoned and returns to the queue. Long
61
+ * enough that a genuinely slow 7 MB upload is never stolen mid-flight; short enough
62
+ * that a daemon killed mid-batch does not strand its rows for a human to notice. */
63
+ export const CLAIM_TTL_MS = 10 * 60 * 1000;
64
+
65
+ export interface UploadRow {
66
+ artifact_key: string;
67
+ project_slug: string;
68
+ status: 'pending' | 'uploading' | 'done' | 'failed' | 'skipped';
69
+ cloud_artifact_id: string | null;
70
+ attempts: number;
71
+ last_error: string | null;
72
+ enqueued_at: number;
73
+ uploaded_at: number | null;
74
+ }
75
+
76
+ /** Give up after this many tries. A key that fails five times is broken in a way
77
+ * retrying will not fix (bytes gone, file corrupt), and an infinite retry would
78
+ * hide it behind a queue that never drains. */
79
+ export const MAX_ATTEMPTS = 5;
80
+
81
+ /**
82
+ * Queue an artifact for upload. Idempotent BY THE SCHEMA — artifact_key is the
83
+ * primary key, so enqueueing the same key twice does nothing.
84
+ *
85
+ * That is not a micro-optimisation: F276.4 re-runs the backfill over 6588 files and
86
+ * must not create 6588 duplicates on the second pass. Expressing it in the table
87
+ * rather than in a guard means no caller can forget it.
88
+ */
89
+ export function enqueueArtifact(db: Database, projectSlug: string, key: string | null): void {
90
+ if (!key) return;
91
+ db.prepare(
92
+ `INSERT OR IGNORE INTO lens_artifact_uploads (artifact_key, project_slug, status, enqueued_at)
93
+ VALUES (?, ?, 'pending', ?)`,
94
+ ).run(key, projectSlug, Date.now());
95
+ }
96
+
97
+ /** Queue several at once — the flow path has one per step. */
98
+ export function enqueueArtifacts(db: Database, projectSlug: string, keys: (string | null)[]): void {
99
+ for (const k of keys) enqueueArtifact(db, projectSlug, k);
100
+ }
101
+
102
+ export interface DrainResult {
103
+ /** Uploaded successfully in this pass. */
104
+ uploaded: number;
105
+ /** Failed this pass but will be retried. */
106
+ retryable: number;
107
+ /** Given up on (attempts exhausted, or the bytes are gone). */
108
+ abandoned: number;
109
+ /** Still waiting AFTER this pass — so a caller can tell "done" from "batch full". */
110
+ remaining: number;
111
+ /** Why nothing happened, when nothing happened. Stated rather than implied by a
112
+ * row of zeros, because "queue empty" and "not signed in" look identical in
113
+ * counts and mean completely different things. */
114
+ idle_reason: string | null;
115
+ }
116
+
117
+ /**
118
+ * Upload one batch. Returns counts AND, when nothing moved, the reason.
119
+ *
120
+ * The `idle_reason` field exists because of the failure this whole epic is about: a
121
+ * result of all zeros could mean the queue is empty, or that we are signed out, or
122
+ * that storage is unprovisioned. Three different situations, one indistinguishable
123
+ * report — so the reason is stated.
124
+ */
125
+ export async function drainUploads(
126
+ db: Database,
127
+ storage: LensStorage,
128
+ cloud: CloudClientLike | null,
129
+ opts: {
130
+ batch?: number;
131
+ projectId?: (slug: string) => Promise<string | null>;
132
+ /** Injectable clock, so the stale-claim path is testable without waiting 10 minutes. */
133
+ now?: () => number;
134
+ } = {},
135
+ ): Promise<DrainResult> {
136
+ const empty = (reason: string | null): DrainResult => ({
137
+ uploaded: 0,
138
+ retryable: 0,
139
+ abandoned: 0,
140
+ remaining: pendingCount(db),
141
+ idle_reason: reason,
142
+ });
143
+
144
+ if (!cloud) return empty('not signed in to cardmem — run `cardmem login`; nothing was uploaded');
145
+
146
+ const batch = Math.max(1, Math.min(opts.batch ?? 25, 200));
147
+ const now = opts.now?.() ?? Date.now();
148
+ releaseStaleClaims(db, now);
149
+ const rows = claimBatch(db, batch, now);
150
+
151
+ if (rows.length === 0) {
152
+ // "Nothing to do" and "another drain has everything claimed" both produce zero rows
153
+ // and mean opposite things — the first says stop, the second says come back. Say
154
+ // which one it is rather than letting a row of zeros stand for both.
155
+ const inFlight = db
156
+ .query("SELECT COUNT(*) AS n FROM lens_artifact_uploads WHERE status = 'uploading'")
157
+ .get() as { n: number } | undefined;
158
+ return empty(
159
+ Number(inFlight?.n ?? 0) > 0
160
+ ? `${inFlight!.n} artifact(s) are claimed by another drain in flight — nothing to take right now`
161
+ : null,
162
+ );
163
+ }
164
+
165
+ let uploaded = 0;
166
+ let retryable = 0;
167
+ let abandoned = 0;
168
+ const projectIds = new Map<string, string | null>();
169
+
170
+ for (const row of rows) {
171
+ try {
172
+ // Resolve the cloud project id once per slug per pass. Guessing one would put a
173
+ // project's screenshots into a different project's store.
174
+ if (!projectIds.has(row.project_slug)) {
175
+ projectIds.set(row.project_slug, await resolveProjectId(cloud, row.project_slug, opts.projectId));
176
+ }
177
+ const projectId = projectIds.get(row.project_slug);
178
+ if (!projectId) throw new Error(`project ${row.project_slug} is not known to cardmem`);
179
+
180
+ const bytes = await storage.get(row.artifact_key);
181
+ if (!bytes) {
182
+ // The row claims an artifact and the storage does not have it. Retrying cannot
183
+ // conjure bytes, so this is ABANDONED immediately rather than burning five
184
+ // attempts — and it is recorded as a distinct outcome, because a missing file
185
+ // is a finding, not a transient error.
186
+ markFailed(db, row.artifact_key, 'bytes are GONE from local storage', MAX_ATTEMPTS);
187
+ abandoned++;
188
+ continue;
189
+ }
190
+
191
+ const placed = await cloud.callTool<{ artifact_id: string }>('cardmem_place_artifact', {
192
+ project_id: projectId,
193
+ name: artifactName(row.artifact_key),
194
+ mime: row.artifact_key.endsWith('.webm') ? 'video/webm' : 'image/png',
195
+ base64: bytes.toString('base64'),
196
+ source: 'lens',
197
+ });
198
+
199
+ db.prepare(
200
+ `UPDATE lens_artifact_uploads
201
+ SET status = 'done', cloud_artifact_id = ?, uploaded_at = ?, last_error = NULL,
202
+ attempts = attempts + 1
203
+ WHERE artifact_key = ?`,
204
+ ).run(placed.artifact_id, Date.now(), row.artifact_key);
205
+ uploaded++;
206
+ } catch (err) {
207
+ const msg = err instanceof Error ? err.message : String(err);
208
+ const attempts = row.attempts + 1;
209
+ markFailed(db, row.artifact_key, msg, attempts);
210
+ if (attempts >= MAX_ATTEMPTS) abandoned++;
211
+ else retryable++;
212
+ }
213
+ }
214
+
215
+ return { uploaded, retryable, abandoned, remaining: pendingCount(db), idle_reason: null };
216
+ }
217
+
218
+ /**
219
+ * Take a batch of rows OUT of the pending pool in the same statement that reads them.
220
+ *
221
+ * THE BUG THIS CLOSES, measured 2026-08-25: the old code selected `status='pending'`,
222
+ * uploaded (hundreds of ms of network), and only then wrote `done`. For that whole
223
+ * window the row still read `pending` to anyone else — and two drains exist by design,
224
+ * the daemon's 30 s background tick and the HTTP route. On project xrt81-com that put
225
+ * 16 of 27 files in the cloud twice, ids ~65 ms apart, while the local queue showed a
226
+ * flawless 27. A count taken on the side that does the writing cannot see a duplicate
227
+ * it wrote twice.
228
+ *
229
+ * `artifact_key` being the PRIMARY KEY makes ENQUEUEING idempotent, which is true and
230
+ * is an acceptance criterion on F276.4. It says nothing about DRAINING. Idempotent
231
+ * insert and exactly-once processing are different properties.
232
+ */
233
+ function claimBatch(db: Database, batch: number, now: number): UploadRow[] {
234
+ return db
235
+ .prepare(
236
+ `UPDATE lens_artifact_uploads
237
+ SET status = 'uploading', claimed_at = ?
238
+ WHERE artifact_key IN (
239
+ SELECT artifact_key FROM lens_artifact_uploads
240
+ WHERE status = 'pending' AND attempts < ?
241
+ ORDER BY enqueued_at ASC LIMIT ?
242
+ )
243
+ RETURNING *`,
244
+ )
245
+ .all(now, MAX_ATTEMPTS, batch) as UploadRow[];
246
+ }
247
+
248
+ /**
249
+ * Return rows whose claim went stale to the queue.
250
+ *
251
+ * Without this, the cure for double-upload becomes a cause of never-upload: a daemon
252
+ * killed mid-batch would leave its rows in `uploading` forever, and they would be
253
+ * invisible to `pending` and to `failed` alike — the silent gap this epic is about,
254
+ * reintroduced by its own fix.
255
+ */
256
+ export function releaseStaleClaims(db: Database, now: number = Date.now()): number {
257
+ const r = db
258
+ .prepare(
259
+ `UPDATE lens_artifact_uploads
260
+ SET status = 'pending', claimed_at = NULL
261
+ WHERE status = 'uploading' AND (claimed_at IS NULL OR claimed_at < ?)`,
262
+ )
263
+ .run(now - CLAIM_TTL_MS);
264
+ return Number(r.changes ?? 0);
265
+ }
266
+
267
+ function markFailed(db: Database, key: string, error: string, attempts: number): void {
268
+ db.prepare(
269
+ `UPDATE lens_artifact_uploads
270
+ SET attempts = ?, last_error = ?, status = ?
271
+ WHERE artifact_key = ?`,
272
+ ).run(attempts, error.slice(0, 500), attempts >= MAX_ATTEMPTS ? 'failed' : 'pending', key);
273
+ }
274
+
275
+ /** Rows that are not finished — pending AND in flight elsewhere. A row another drain
276
+ * is currently uploading is still not done, and excluding it would make `remaining: 0`
277
+ * mean "nothing left" while an upload is still running. */
278
+ function pendingCount(db: Database): number {
279
+ const r = db
280
+ .query(
281
+ `SELECT COUNT(*) AS n FROM lens_artifact_uploads
282
+ WHERE (status = 'pending' AND attempts < ?) OR status = 'uploading'`,
283
+ )
284
+ .get(MAX_ATTEMPTS) as { n: number } | undefined;
285
+ return Number(r?.n ?? 0);
286
+ }
287
+
288
+ async function resolveProjectId(
289
+ cloud: CloudClientLike,
290
+ slug: string,
291
+ override?: (slug: string) => Promise<string | null>,
292
+ ): Promise<string | null> {
293
+ if (override) return override(slug);
294
+ const res = await cloud.callTool<{ projects?: Array<{ id: string; slug: string }> }>(
295
+ 'cardmem_list_projects',
296
+ {},
297
+ );
298
+ return res?.projects?.find((p) => p.slug === slug)?.id ?? null;
299
+ }
300
+
301
+ /** A readable filename from an artifact key, so the Artifacts surface is browsable
302
+ * rather than a wall of hashes. `cardmem/flows/<run>/step-3.png` → `lens-flows-<run>-step-3.png`. */
303
+ export function artifactName(key: string): string {
304
+ const cleaned = key.replace(/^[^/]+\//, '').replace(/\//g, '-');
305
+ return `lens-${cleaned}`;
306
+ }
307
+
308
+ /** What the queue holds, for the surface and for a human asking "is it done yet?". */
309
+ export function uploadStats(db: Database): {
310
+ pending: number;
311
+ done: number;
312
+ failed: number;
313
+ /** Claimed by a drain and in flight right now (F276.5). Its own bucket: in-flight and
314
+ * not-yet-started are different facts, and sharing one value of `status` between them
315
+ * is what let two drains upload the same file. */
316
+ uploading: number;
317
+ /** Held back on purpose (F276.4). Counted SEPARATELY from failed: one is a decision
318
+ * the owner made, the other is something broken, and a queue that shows them as one
319
+ * number cannot be used to tell whether the backfill is finished. */
320
+ skipped: number;
321
+ total: number;
322
+ /** pending + uploading + done + failed + skipped. If this ever differs from `total`,
323
+ * a row is in a state nothing counts — the silent gap this epic exists to close. */
324
+ accounted: number;
325
+ } {
326
+ const row = db
327
+ .query(
328
+ `SELECT
329
+ SUM(CASE WHEN status = 'pending' AND attempts < ? THEN 1 ELSE 0 END) AS pending,
330
+ SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) AS done,
331
+ SUM(CASE WHEN status = 'failed' OR (status = 'pending' AND attempts >= ?) THEN 1 ELSE 0 END) AS failed,
332
+ SUM(CASE WHEN status = 'uploading' THEN 1 ELSE 0 END) AS uploading,
333
+ SUM(CASE WHEN status = 'skipped' THEN 1 ELSE 0 END) AS skipped,
334
+ COUNT(*) AS total
335
+ FROM lens_artifact_uploads`,
336
+ )
337
+ .get(MAX_ATTEMPTS, MAX_ATTEMPTS) as Record<string, number> | undefined;
338
+ const pending = Number(row?.pending ?? 0);
339
+ const done = Number(row?.done ?? 0);
340
+ const failed = Number(row?.failed ?? 0);
341
+ const skipped = Number(row?.skipped ?? 0);
342
+ const uploading = Number(row?.uploading ?? 0);
343
+ return {
344
+ pending,
345
+ done,
346
+ failed,
347
+ uploading,
348
+ skipped,
349
+ total: Number(row?.total ?? 0),
350
+ accounted: pending + uploading + done + failed + skipped,
351
+ };
352
+ }
353
+
354
+ /** Map artifact keys → their cloud artifact id, for the read path. Only 'done' rows,
355
+ * so a queued-but-not-yet-uploaded key resolves to nothing rather than to a url that
356
+ * 404s — a link in the durable form pointing at nothing is worse than no link. */
357
+ export function cloudIdsFor(db: Database, keys: string[]): Map<string, string> {
358
+ const out = new Map<string, string>();
359
+ if (keys.length === 0) return out;
360
+ const placeholders = keys.map(() => '?').join(',');
361
+ const rows = db
362
+ .query(
363
+ `SELECT artifact_key, cloud_artifact_id FROM lens_artifact_uploads
364
+ WHERE status = 'done' AND cloud_artifact_id IS NOT NULL
365
+ AND artifact_key IN (${placeholders})`,
366
+ )
367
+ .all(...keys) as Array<{ artifact_key: string; cloud_artifact_id: string }>;
368
+ for (const r of rows) out.set(r.artifact_key, r.cloud_artifact_id);
369
+ return out;
370
+ }
@@ -0,0 +1,294 @@
1
+ // F074.4 — Lens auth as a pluggable pre-step.
2
+ //
3
+ // Capturing an authenticated surface must not assume one auth model. An auth
4
+ // adapter runs against a fresh browser context BEFORE the page loads, leaving
5
+ // it authenticated — exactly the `prepareContext` hook browser.ts/capture.ts
6
+ // already expose.
7
+ //
8
+ // SECURITY: the actual secret (a session token / a storageState file holding
9
+ // a session cookie) is NEVER passed in the capture call. The call names an
10
+ // adapter + non-secret params (cookie name, domain, which ENV VAR holds the
11
+ // secret); the daemon resolves the secret from its own env. So a project's
12
+ // MCP/HTTP request can request an authenticated capture without ever
13
+ // transmitting the credential.
14
+ //
15
+ // Two adapters ship first:
16
+ // - storageState : Playwright's storageState JSON (cookies + localStorage).
17
+ // Covers cardmem's Better Auth signed session cookie —
18
+ // reuses the artifact F037's auth.setup.ts already produces.
19
+ // - cookieJwt : a single named cookie holding a JWT. Covers cms.
20
+
21
+ import { readFileSync } from 'node:fs';
22
+ import type { BrowserContext } from 'playwright';
23
+
24
+ export type AuthEnv = Record<string, string | undefined>;
25
+
26
+ export interface StorageStateAuth {
27
+ adapter: 'storageState';
28
+ /** Name of the env var holding either a path to a storageState.json or the
29
+ * JSON itself. Default LENS_STORAGE_STATE. */
30
+ stateEnv?: string;
31
+ /** F074.12 — direct path to a storageState.json on the daemon host. When set,
32
+ * used WITHOUT env indirection, so a credential can be injected without a
33
+ * daemon restart (env vars only reach NEW launchd spawns). A relative path is
34
+ * resolved against the repo root by loadManifest. The path is NOT a secret —
35
+ * the gitignored file it points at is — so this preserves "secret never in
36
+ * the call". Takes precedence over stateEnv. */
37
+ statePath?: string;
38
+ }
39
+
40
+ export interface CookieJwtAuth {
41
+ adapter: 'cookieJwt';
42
+ /** Cookie name to set (e.g. "projects.session_token"). */
43
+ cookieName: string;
44
+ /** Cookie domain (e.g. "localhost"). */
45
+ domain: string;
46
+ /** Cookie path. Default "/". */
47
+ path?: string;
48
+ /** Name of the env var holding the token value. Secret never in the call. */
49
+ tokenEnv: string;
50
+ httpOnly?: boolean;
51
+ secure?: boolean;
52
+ }
53
+
54
+ export interface MintEndpointAuth {
55
+ adapter: 'mintEndpoint';
56
+ /** URL the daemon POSTs (with the secret) just before capture. The endpoint
57
+ * mints a SHORT-LIVED, READ-ONLY session and returns it as a Playwright
58
+ * storageState JSON ({cookies, origins}). */
59
+ url: string;
60
+ /** HTTP method. Default POST. */
61
+ method?: 'POST' | 'GET';
62
+ /** Env var holding the narrow "mint a lens session" secret (NOT an admin
63
+ * session). Sent as `Authorization: Bearer <secret>`. */
64
+ secretEnv?: string;
65
+ /** OR a gitignored file holding the secret (relative path → resolved against
66
+ * the repo root by loadManifest). Avoids the env-var restart friction.
67
+ * Takes precedence over secretEnv. */
68
+ secretPath?: string;
69
+ /** Optional extra non-secret headers. */
70
+ headers?: Record<string, string>;
71
+ /** Optional JSON body sent with the (POST) mint call — e.g. `{org, site}` so
72
+ * the endpoint can mint a session targeting a SPECIFIC site on-demand instead
73
+ * of a fixed LENS_ACTIVE_ORG/SITE env default (the cms F151 org/site pattern).
74
+ * Validated by the endpoint against its site registry; never carries a secret.
75
+ * Ignored for GET. */
76
+ body?: Record<string, unknown>;
77
+ }
78
+
79
+ export type AuthConfig = StorageStateAuth | CookieJwtAuth | MintEndpointAuth;
80
+
81
+ interface StorageStateShape {
82
+ cookies?: Array<Record<string, unknown>>;
83
+ origins?: Array<{ origin: string; localStorage?: Array<{ name: string; value: string }> }>;
84
+ }
85
+
86
+ async function applyStorageState(
87
+ context: BrowserContext,
88
+ cfg: StorageStateAuth,
89
+ env: AuthEnv,
90
+ ): Promise<void> {
91
+ // F074.12 — statePath (a filesystem path, already absolutised by loadManifest)
92
+ // wins over the env-var indirection. Either way `raw` is a path or inline JSON.
93
+ let raw: string | undefined;
94
+ let sourceLabel: string;
95
+ if (cfg.statePath) {
96
+ raw = cfg.statePath;
97
+ sourceLabel = `statePath ${cfg.statePath}`;
98
+ } else {
99
+ const name = cfg.stateEnv ?? 'LENS_STORAGE_STATE';
100
+ raw = env[name];
101
+ sourceLabel = `env ${name}`;
102
+ if (!raw) throw new Error(`storageState auth: set auth.statePath or ${sourceLabel}`);
103
+ }
104
+ let json: StorageStateShape;
105
+ try {
106
+ json = raw.trim().startsWith('{')
107
+ ? (JSON.parse(raw) as StorageStateShape)
108
+ : (JSON.parse(readFileSync(raw, 'utf-8')) as StorageStateShape);
109
+ } catch (err) {
110
+ throw new Error(`storageState auth: could not load ${sourceLabel}: ${err instanceof Error ? err.message : err}`);
111
+ }
112
+ await applyStorageStateJson(context, json);
113
+ }
114
+
115
+ /** F074.22 — Chromium's CDP setCookie silently rejects a `secure` / `__Secure-` /
116
+ * `__Host-` cookie set with only `domain` (no `url`): the source scheme is read
117
+ * as non-secure, so a Secure cookie is refused and never lands — the next
118
+ * navigation then redirects to login (a mystery 307). curl has no such
119
+ * source-scheme check, which is why the SAME cookie value authenticates via curl
120
+ * but not via Playwright. Synthesize an `https://<host><path>` url so the secure
121
+ * source is explicit and the cookie is accepted.
122
+ *
123
+ * F236.fix — but ONLY for cookies that are already host-scoped. A url makes the
124
+ * cookie HOST-ONLY (Playwright can't take url+domain together), so collapsing a
125
+ * DOMAIN-WIDE cookie (leading-dot domain, e.g. `.cardmem.com` — valid on the apex
126
+ * AND every subdomain) to `https://cardmem.com/` silently dropped it on every
127
+ * subdomain navigation (www.cardmem.com, app.trailmem.com) → login. The leading
128
+ * dot is the RFC-6265 / Playwright signal for a domain cookie: keep `domain`+`path`
129
+ * for those so they still reach subdomains. Host-specific secure cookies (no dot)
130
+ * and `__Host-` (which MUST be host-only per spec) keep the url synthesis. */
131
+ export function prepareCookies(
132
+ cookies: Array<Record<string, unknown>>,
133
+ ): Array<Record<string, unknown>> {
134
+ return cookies.map((c) => {
135
+ const name = typeof c.name === 'string' ? c.name : '';
136
+ const isSecure = c.secure === true || name.startsWith('__Secure-') || name.startsWith('__Host-');
137
+ if (!isSecure || c.url || typeof c.domain !== 'string') return c;
138
+ const domain = c.domain as string;
139
+ // A leading-dot domain is domain-wide (apex + all subdomains). `__Host-` is the
140
+ // one exception: the prefix REQUIRES a host-only cookie, so it always takes the
141
+ // url path even with a dotted domain.
142
+ const domainWide = domain.startsWith('.') && !name.startsWith('__Host-');
143
+ if (domainWide) return c; // keep domain+path so subdomains still get the cookie
144
+ const host = domain.replace(/^\./, '');
145
+ const path = typeof c.path === 'string' ? c.path : '/';
146
+ const { domain: _domain, path: _path, ...rest } = c;
147
+ return { ...rest, url: `https://${host}${path}` };
148
+ });
149
+ }
150
+
151
+ /** Apply a Playwright storageState shape (cookies + localStorage) to a context.
152
+ * Shared by the storageState adapter and the mintEndpoint adapter (which fetches
153
+ * the same shape just-in-time). */
154
+ async function applyStorageStateJson(context: BrowserContext, json: StorageStateShape): Promise<void> {
155
+ if (json.cookies?.length) {
156
+ // Secure/prefixed cookies with only a domain need a synthesized https url
157
+ // (see prepareCookies) — else Chromium silently drops them.
158
+ await context.addCookies(prepareCookies(json.cookies) as unknown as Parameters<BrowserContext['addCookies']>[0]);
159
+ // F074.22 — LENS_AUTH_DEBUG surfaces what Chromium actually RETAINED after
160
+ // apply (values never logged), so a failed authed capture is evidence, not a
161
+ // mystery. A cookie that was dropped simply won't appear here.
162
+ if (process.env.LENS_AUTH_DEBUG) {
163
+ const retained = await context.cookies();
164
+ console.error(
165
+ '[lens-auth] cookies retained after apply:',
166
+ JSON.stringify(retained.map((c) => ({ name: c.name, domain: c.domain, path: c.path, secure: c.secure, httpOnly: c.httpOnly, expires: c.expires }))),
167
+ );
168
+ }
169
+ }
170
+ for (const o of json.origins ?? []) {
171
+ const items = o.localStorage ?? [];
172
+ if (!items.length) continue;
173
+ await context.addInitScript(
174
+ (payload: { origin: string; items: Array<{ name: string; value: string }> }) => {
175
+ if (location.origin === payload.origin) {
176
+ for (const it of payload.items) localStorage.setItem(it.name, it.value);
177
+ }
178
+ },
179
+ { origin: o.origin, items },
180
+ );
181
+ }
182
+ }
183
+
184
+ // F074.13 — mintEndpoint: the daemon POSTs to a protected endpoint on the target
185
+ // app just before capture, which mints a SHORT-LIVED, READ-ONLY session and
186
+ // returns it as a Playwright storageState JSON ({cookies, origins}). The daemon
187
+ // applies it to the fresh context and discards it with the context after the
188
+ // capture. No standing credential sits anywhere: scope is read-only, lifetime is
189
+ // minutes, exposure is nothing-on-disk + nothing-in-the-cc-transcript (daemon-
190
+ // minted), and injection needs no daemon restart. The only stored secret is a
191
+ // NARROW "mint a lens session" key (NOT an admin session), via secretEnv or a
192
+ // gitignored secretPath.
193
+ async function applyMintEndpoint(
194
+ context: BrowserContext,
195
+ cfg: MintEndpointAuth,
196
+ env: AuthEnv,
197
+ ): Promise<void> {
198
+ // Resolve the narrow mint secret — secretPath (gitignored file, no restart)
199
+ // wins over secretEnv. The secret is NOT in the manifest/call, only its source.
200
+ let secret: string | undefined;
201
+ if (cfg.secretPath) {
202
+ try {
203
+ secret = readFileSync(cfg.secretPath, 'utf-8').trim();
204
+ } catch (err) {
205
+ throw new Error(`mintEndpoint auth: could not read secretPath ${cfg.secretPath}: ${err instanceof Error ? err.message : err}`);
206
+ }
207
+ } else if (cfg.secretEnv) {
208
+ secret = env[cfg.secretEnv];
209
+ if (!secret) throw new Error(`mintEndpoint auth: env ${cfg.secretEnv} is not set`);
210
+ } else {
211
+ throw new Error('mintEndpoint auth: set auth.secretPath or auth.secretEnv');
212
+ }
213
+
214
+ // Optional JSON body (POST only) — lets the endpoint mint a session for a
215
+ // SPECIFIC org/site rather than a fixed env default. GET can't carry a body.
216
+ const method = cfg.method ?? 'POST';
217
+ const sendBody = method !== 'GET' && cfg.body != null;
218
+ let res: Response;
219
+ try {
220
+ res = await fetch(cfg.url, {
221
+ method,
222
+ headers: {
223
+ Authorization: `Bearer ${secret}`,
224
+ Accept: 'application/json',
225
+ ...(sendBody ? { 'Content-Type': 'application/json' } : {}),
226
+ ...(cfg.headers ?? {}),
227
+ },
228
+ ...(sendBody ? { body: JSON.stringify(cfg.body) } : {}),
229
+ });
230
+ } catch (err) {
231
+ throw new Error(`mintEndpoint auth: request to ${cfg.url} failed: ${err instanceof Error ? err.message : err}`);
232
+ }
233
+ if (!res.ok) {
234
+ throw new Error(`mintEndpoint auth: ${cfg.url} returned ${res.status} ${res.statusText}`);
235
+ }
236
+ let json: StorageStateShape;
237
+ try {
238
+ json = (await res.json()) as StorageStateShape;
239
+ } catch (err) {
240
+ throw new Error(`mintEndpoint auth: ${cfg.url} did not return JSON storageState: ${err instanceof Error ? err.message : err}`);
241
+ }
242
+ if (!json.cookies?.length && !json.origins?.length) {
243
+ throw new Error(`mintEndpoint auth: ${cfg.url} returned a storageState with no cookies/origins`);
244
+ }
245
+ await applyStorageStateJson(context, json);
246
+ }
247
+
248
+ async function applyCookieJwt(
249
+ context: BrowserContext,
250
+ cfg: CookieJwtAuth,
251
+ env: AuthEnv,
252
+ ): Promise<void> {
253
+ const token = env[cfg.tokenEnv];
254
+ if (!token) throw new Error(`cookieJwt auth: env ${cfg.tokenEnv} is not set`);
255
+ await context.addCookies([
256
+ {
257
+ name: cfg.cookieName,
258
+ value: token,
259
+ domain: cfg.domain,
260
+ path: cfg.path ?? '/',
261
+ httpOnly: cfg.httpOnly ?? true,
262
+ secure: cfg.secure ?? false,
263
+ sameSite: 'Lax',
264
+ },
265
+ ]);
266
+ }
267
+
268
+ /** Run the configured auth adapter against a fresh context. */
269
+ export async function applyAuth(
270
+ context: BrowserContext,
271
+ cfg: AuthConfig,
272
+ env: AuthEnv = process.env,
273
+ ): Promise<void> {
274
+ switch (cfg.adapter) {
275
+ case 'storageState':
276
+ return applyStorageState(context, cfg, env);
277
+ case 'cookieJwt':
278
+ return applyCookieJwt(context, cfg, env);
279
+ case 'mintEndpoint':
280
+ return applyMintEndpoint(context, cfg, env);
281
+ default:
282
+ throw new Error(`unknown auth adapter: ${(cfg as AuthConfig).adapter}`);
283
+ }
284
+ }
285
+
286
+ /** Resolve an AuthConfig to a `prepareContext` callback the capture/verify
287
+ * layer can pass straight through. Returns undefined when cfg is absent. */
288
+ export function authPrepare(
289
+ cfg: AuthConfig | undefined,
290
+ env: AuthEnv = process.env,
291
+ ): ((context: BrowserContext) => Promise<void>) | undefined {
292
+ if (!cfg) return undefined;
293
+ return (context: BrowserContext) => applyAuth(context, cfg, env);
294
+ }