@broberg/lens-daemon 0.0.0-stage → 0.1.5
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 +14 -2
- package/package.json +52 -3
- package/src/artifact-backfill.ts +239 -0
- package/src/artifact-uploads.ts +397 -0
- package/src/auth/index.ts +301 -0
- package/src/browser.ts +440 -0
- package/src/build-marker.ts +197 -0
- package/src/capture-session.ts +873 -0
- package/src/capture.ts +348 -0
- package/src/critic-dom.ts +951 -0
- package/src/critic-vision.ts +205 -0
- package/src/critic.ts +73 -0
- package/src/daemon-routes.ts +333 -0
- package/src/durability.ts +18 -0
- package/src/ensure-chromium.ts +118 -0
- package/src/export.ts +256 -0
- package/src/fill.ts +149 -0
- package/src/flow-store.ts +207 -0
- package/src/flow.ts +2953 -0
- package/src/frames.ts +195 -0
- package/src/gate.ts +123 -0
- package/src/http.ts +175 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +741 -0
- package/src/mcp.ts +562 -0
- package/src/motion-probe.ts +653 -0
- package/src/password-in-url.ts +106 -0
- package/src/pdf-text.ts +514 -0
- package/src/pixelmatch.d.ts +21 -0
- package/src/preflight.ts +76 -0
- package/src/readiness.ts +282 -0
- package/src/record-run.ts +427 -0
- package/src/record.ts +387 -0
- package/src/sandbox.ts +22 -0
- package/src/schema.ts +300 -0
- package/src/scrape.ts +116 -0
- package/src/secret-fill.ts +80 -0
- package/src/seed-storage.ts +149 -0
- package/src/service.ts +1493 -0
- package/src/storage.ts +159 -0
- package/src/store.ts +267 -0
- package/src/swallowed-errors.ts +252 -0
- package/src/validate-vision.ts +97 -0
- package/src/verify.ts +411 -0
- package/src/video.ts +211 -0
|
@@ -0,0 +1,301 @@
|
|
|
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 { isAbsolute } from 'node:path';
|
|
23
|
+
import type { BrowserContext } from 'playwright';
|
|
24
|
+
|
|
25
|
+
export type AuthEnv = Record<string, string | undefined>;
|
|
26
|
+
|
|
27
|
+
export interface StorageStateAuth {
|
|
28
|
+
adapter: 'storageState';
|
|
29
|
+
/** Name of the env var holding either a path to a storageState.json or the
|
|
30
|
+
* JSON itself. Default LENS_STORAGE_STATE. */
|
|
31
|
+
stateEnv?: string;
|
|
32
|
+
/** F074.12 — direct path to a storageState.json on the daemon host. When set,
|
|
33
|
+
* used WITHOUT env indirection, so a credential can be injected without a
|
|
34
|
+
* daemon restart (env vars only reach NEW launchd spawns). A relative path is
|
|
35
|
+
* resolved against the repo root by loadManifest. The path is NOT a secret —
|
|
36
|
+
* the gitignored file it points at is — so this preserves "secret never in
|
|
37
|
+
* the call". Takes precedence over stateEnv. */
|
|
38
|
+
statePath?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CookieJwtAuth {
|
|
42
|
+
adapter: 'cookieJwt';
|
|
43
|
+
/** Cookie name to set (e.g. "projects.session_token"). */
|
|
44
|
+
cookieName: string;
|
|
45
|
+
/** Cookie domain (e.g. "localhost"). */
|
|
46
|
+
domain: string;
|
|
47
|
+
/** Cookie path. Default "/". */
|
|
48
|
+
path?: string;
|
|
49
|
+
/** Name of the env var holding the token value. Secret never in the call. */
|
|
50
|
+
tokenEnv: string;
|
|
51
|
+
httpOnly?: boolean;
|
|
52
|
+
secure?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface MintEndpointAuth {
|
|
56
|
+
adapter: 'mintEndpoint';
|
|
57
|
+
/** URL the daemon POSTs (with the secret) just before capture. The endpoint
|
|
58
|
+
* mints a SHORT-LIVED, READ-ONLY session and returns it as a Playwright
|
|
59
|
+
* storageState JSON ({cookies, origins}). */
|
|
60
|
+
url: string;
|
|
61
|
+
/** HTTP method. Default POST. */
|
|
62
|
+
method?: 'POST' | 'GET';
|
|
63
|
+
/** Env var holding the narrow "mint a lens session" secret (NOT an admin
|
|
64
|
+
* session). Sent as `Authorization: Bearer <secret>`. */
|
|
65
|
+
secretEnv?: string;
|
|
66
|
+
/** OR a gitignored file holding the secret (relative path → resolved against
|
|
67
|
+
* the repo root by loadManifest). Avoids the env-var restart friction.
|
|
68
|
+
* Takes precedence over secretEnv. */
|
|
69
|
+
secretPath?: string;
|
|
70
|
+
/** Optional extra non-secret headers. */
|
|
71
|
+
headers?: Record<string, string>;
|
|
72
|
+
/** Optional JSON body sent with the (POST) mint call — e.g. `{org, site}` so
|
|
73
|
+
* the endpoint can mint a session targeting a SPECIFIC site on-demand instead
|
|
74
|
+
* of a fixed LENS_ACTIVE_ORG/SITE env default (the cms F151 org/site pattern).
|
|
75
|
+
* Validated by the endpoint against its site registry; never carries a secret.
|
|
76
|
+
* Ignored for GET. */
|
|
77
|
+
body?: Record<string, unknown>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type AuthConfig = StorageStateAuth | CookieJwtAuth | MintEndpointAuth;
|
|
81
|
+
|
|
82
|
+
interface StorageStateShape {
|
|
83
|
+
cookies?: Array<Record<string, unknown>>;
|
|
84
|
+
origins?: Array<{ origin: string; localStorage?: Array<{ name: string; value: string }> }>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function applyStorageState(
|
|
88
|
+
context: BrowserContext,
|
|
89
|
+
cfg: StorageStateAuth,
|
|
90
|
+
env: AuthEnv,
|
|
91
|
+
): Promise<void> {
|
|
92
|
+
// F074.12 — statePath (a filesystem path, already absolutised by loadManifest)
|
|
93
|
+
// wins over the env-var indirection. Either way `raw` is a path or inline JSON.
|
|
94
|
+
let raw: string | undefined;
|
|
95
|
+
let sourceLabel: string;
|
|
96
|
+
if (cfg.statePath) {
|
|
97
|
+
raw = cfg.statePath;
|
|
98
|
+
sourceLabel = `statePath ${cfg.statePath}`;
|
|
99
|
+
} else {
|
|
100
|
+
const name = cfg.stateEnv ?? 'LENS_STORAGE_STATE';
|
|
101
|
+
raw = env[name];
|
|
102
|
+
sourceLabel = `env ${name}`;
|
|
103
|
+
if (!raw) throw new Error(`storageState auth: set auth.statePath or ${sourceLabel}`);
|
|
104
|
+
}
|
|
105
|
+
let json: StorageStateShape;
|
|
106
|
+
try {
|
|
107
|
+
json = raw.trim().startsWith('{')
|
|
108
|
+
? (JSON.parse(raw) as StorageStateShape)
|
|
109
|
+
: (JSON.parse(readFileSync(raw, 'utf-8')) as StorageStateShape);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
throw new Error(`storageState auth: could not load ${sourceLabel}: ${err instanceof Error ? err.message : err}`);
|
|
112
|
+
}
|
|
113
|
+
await applyStorageStateJson(context, json);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** F074.22 — Chromium's CDP setCookie silently rejects a `secure` / `__Secure-` /
|
|
117
|
+
* `__Host-` cookie set with only `domain` (no `url`): the source scheme is read
|
|
118
|
+
* as non-secure, so a Secure cookie is refused and never lands — the next
|
|
119
|
+
* navigation then redirects to login (a mystery 307). curl has no such
|
|
120
|
+
* source-scheme check, which is why the SAME cookie value authenticates via curl
|
|
121
|
+
* but not via Playwright. Synthesize an `https://<host><path>` url so the secure
|
|
122
|
+
* source is explicit and the cookie is accepted.
|
|
123
|
+
*
|
|
124
|
+
* F236.fix — but ONLY for cookies that are already host-scoped. A url makes the
|
|
125
|
+
* cookie HOST-ONLY (Playwright can't take url+domain together), so collapsing a
|
|
126
|
+
* DOMAIN-WIDE cookie (leading-dot domain, e.g. `.cardmem.com` — valid on the apex
|
|
127
|
+
* AND every subdomain) to `https://cardmem.com/` silently dropped it on every
|
|
128
|
+
* subdomain navigation (www.cardmem.com, app.trailmem.com) → login. The leading
|
|
129
|
+
* dot is the RFC-6265 / Playwright signal for a domain cookie: keep `domain`+`path`
|
|
130
|
+
* for those so they still reach subdomains. Host-specific secure cookies (no dot)
|
|
131
|
+
* and `__Host-` (which MUST be host-only per spec) keep the url synthesis. */
|
|
132
|
+
export function prepareCookies(
|
|
133
|
+
cookies: Array<Record<string, unknown>>,
|
|
134
|
+
): Array<Record<string, unknown>> {
|
|
135
|
+
return cookies.map((c) => {
|
|
136
|
+
const name = typeof c.name === 'string' ? c.name : '';
|
|
137
|
+
const isSecure = c.secure === true || name.startsWith('__Secure-') || name.startsWith('__Host-');
|
|
138
|
+
if (!isSecure || c.url || typeof c.domain !== 'string') return c;
|
|
139
|
+
const domain = c.domain as string;
|
|
140
|
+
// A leading-dot domain is domain-wide (apex + all subdomains). `__Host-` is the
|
|
141
|
+
// one exception: the prefix REQUIRES a host-only cookie, so it always takes the
|
|
142
|
+
// url path even with a dotted domain.
|
|
143
|
+
const domainWide = domain.startsWith('.') && !name.startsWith('__Host-');
|
|
144
|
+
if (domainWide) return c; // keep domain+path so subdomains still get the cookie
|
|
145
|
+
const host = domain.replace(/^\./, '');
|
|
146
|
+
const path = typeof c.path === 'string' ? c.path : '/';
|
|
147
|
+
const { domain: _domain, path: _path, ...rest } = c;
|
|
148
|
+
return { ...rest, url: `https://${host}${path}` };
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Apply a Playwright storageState shape (cookies + localStorage) to a context.
|
|
153
|
+
* Shared by the storageState adapter and the mintEndpoint adapter (which fetches
|
|
154
|
+
* the same shape just-in-time). */
|
|
155
|
+
async function applyStorageStateJson(context: BrowserContext, json: StorageStateShape): Promise<void> {
|
|
156
|
+
if (json.cookies?.length) {
|
|
157
|
+
// Secure/prefixed cookies with only a domain need a synthesized https url
|
|
158
|
+
// (see prepareCookies) — else Chromium silently drops them.
|
|
159
|
+
await context.addCookies(prepareCookies(json.cookies) as unknown as Parameters<BrowserContext['addCookies']>[0]);
|
|
160
|
+
// F074.22 — LENS_AUTH_DEBUG surfaces what Chromium actually RETAINED after
|
|
161
|
+
// apply (values never logged), so a failed authed capture is evidence, not a
|
|
162
|
+
// mystery. A cookie that was dropped simply won't appear here.
|
|
163
|
+
if (process.env.LENS_AUTH_DEBUG) {
|
|
164
|
+
const retained = await context.cookies();
|
|
165
|
+
console.error(
|
|
166
|
+
'[lens-auth] cookies retained after apply:',
|
|
167
|
+
JSON.stringify(retained.map((c) => ({ name: c.name, domain: c.domain, path: c.path, secure: c.secure, httpOnly: c.httpOnly, expires: c.expires }))),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const o of json.origins ?? []) {
|
|
172
|
+
const items = o.localStorage ?? [];
|
|
173
|
+
if (!items.length) continue;
|
|
174
|
+
await context.addInitScript(
|
|
175
|
+
(payload: { origin: string; items: Array<{ name: string; value: string }> }) => {
|
|
176
|
+
if (location.origin === payload.origin) {
|
|
177
|
+
for (const it of payload.items) localStorage.setItem(it.name, it.value);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
{ origin: o.origin, items },
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// F074.13 — mintEndpoint: the daemon POSTs to a protected endpoint on the target
|
|
186
|
+
// app just before capture, which mints a SHORT-LIVED, READ-ONLY session and
|
|
187
|
+
// returns it as a Playwright storageState JSON ({cookies, origins}). The daemon
|
|
188
|
+
// applies it to the fresh context and discards it with the context after the
|
|
189
|
+
// capture. No standing credential sits anywhere: scope is read-only, lifetime is
|
|
190
|
+
// minutes, exposure is nothing-on-disk + nothing-in-the-cc-transcript (daemon-
|
|
191
|
+
// minted), and injection needs no daemon restart. The only stored secret is a
|
|
192
|
+
// NARROW "mint a lens session" key (NOT an admin session), via secretEnv or a
|
|
193
|
+
// gitignored secretPath.
|
|
194
|
+
async function applyMintEndpoint(
|
|
195
|
+
context: BrowserContext,
|
|
196
|
+
cfg: MintEndpointAuth,
|
|
197
|
+
env: AuthEnv,
|
|
198
|
+
): Promise<void> {
|
|
199
|
+
// Resolve the narrow mint secret — secretPath (gitignored file, no restart)
|
|
200
|
+
// wins over secretEnv. The secret is NOT in the manifest/call, only its source.
|
|
201
|
+
let secret: string | undefined;
|
|
202
|
+
if (cfg.secretPath) {
|
|
203
|
+
// F009.22 — a relative path here resolves against the DAEMON's cwd, i.e.
|
|
204
|
+
// some other repo's secret, which would then be sent to the caller's url.
|
|
205
|
+
// Callers resolve against their project root first (resolveAuthPaths).
|
|
206
|
+
if (!isAbsolute(cfg.secretPath)) {
|
|
207
|
+
throw new Error(`mintEndpoint auth: secretPath must be absolute by the time it is read (got "${cfg.secretPath}") — register the project or pass an absolute path`);
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
secret = readFileSync(cfg.secretPath, 'utf-8').trim();
|
|
211
|
+
} catch (err) {
|
|
212
|
+
throw new Error(`mintEndpoint auth: could not read secretPath ${cfg.secretPath}: ${err instanceof Error ? err.message : err}`);
|
|
213
|
+
}
|
|
214
|
+
} else if (cfg.secretEnv) {
|
|
215
|
+
secret = env[cfg.secretEnv];
|
|
216
|
+
if (!secret) throw new Error(`mintEndpoint auth: env ${cfg.secretEnv} is not set`);
|
|
217
|
+
} else {
|
|
218
|
+
throw new Error('mintEndpoint auth: set auth.secretPath or auth.secretEnv');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Optional JSON body (POST only) — lets the endpoint mint a session for a
|
|
222
|
+
// SPECIFIC org/site rather than a fixed env default. GET can't carry a body.
|
|
223
|
+
const method = cfg.method ?? 'POST';
|
|
224
|
+
const sendBody = method !== 'GET' && cfg.body != null;
|
|
225
|
+
let res: Response;
|
|
226
|
+
try {
|
|
227
|
+
res = await fetch(cfg.url, {
|
|
228
|
+
method,
|
|
229
|
+
headers: {
|
|
230
|
+
Authorization: `Bearer ${secret}`,
|
|
231
|
+
Accept: 'application/json',
|
|
232
|
+
...(sendBody ? { 'Content-Type': 'application/json' } : {}),
|
|
233
|
+
...(cfg.headers ?? {}),
|
|
234
|
+
},
|
|
235
|
+
...(sendBody ? { body: JSON.stringify(cfg.body) } : {}),
|
|
236
|
+
});
|
|
237
|
+
} catch (err) {
|
|
238
|
+
throw new Error(`mintEndpoint auth: request to ${cfg.url} failed: ${err instanceof Error ? err.message : err}`);
|
|
239
|
+
}
|
|
240
|
+
if (!res.ok) {
|
|
241
|
+
throw new Error(`mintEndpoint auth: ${cfg.url} returned ${res.status} ${res.statusText}`);
|
|
242
|
+
}
|
|
243
|
+
let json: StorageStateShape;
|
|
244
|
+
try {
|
|
245
|
+
json = (await res.json()) as StorageStateShape;
|
|
246
|
+
} catch (err) {
|
|
247
|
+
throw new Error(`mintEndpoint auth: ${cfg.url} did not return JSON storageState: ${err instanceof Error ? err.message : err}`);
|
|
248
|
+
}
|
|
249
|
+
if (!json.cookies?.length && !json.origins?.length) {
|
|
250
|
+
throw new Error(`mintEndpoint auth: ${cfg.url} returned a storageState with no cookies/origins`);
|
|
251
|
+
}
|
|
252
|
+
await applyStorageStateJson(context, json);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function applyCookieJwt(
|
|
256
|
+
context: BrowserContext,
|
|
257
|
+
cfg: CookieJwtAuth,
|
|
258
|
+
env: AuthEnv,
|
|
259
|
+
): Promise<void> {
|
|
260
|
+
const token = env[cfg.tokenEnv];
|
|
261
|
+
if (!token) throw new Error(`cookieJwt auth: env ${cfg.tokenEnv} is not set`);
|
|
262
|
+
await context.addCookies([
|
|
263
|
+
{
|
|
264
|
+
name: cfg.cookieName,
|
|
265
|
+
value: token,
|
|
266
|
+
domain: cfg.domain,
|
|
267
|
+
path: cfg.path ?? '/',
|
|
268
|
+
httpOnly: cfg.httpOnly ?? true,
|
|
269
|
+
secure: cfg.secure ?? false,
|
|
270
|
+
sameSite: 'Lax',
|
|
271
|
+
},
|
|
272
|
+
]);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Run the configured auth adapter against a fresh context. */
|
|
276
|
+
export async function applyAuth(
|
|
277
|
+
context: BrowserContext,
|
|
278
|
+
cfg: AuthConfig,
|
|
279
|
+
env: AuthEnv = process.env,
|
|
280
|
+
): Promise<void> {
|
|
281
|
+
switch (cfg.adapter) {
|
|
282
|
+
case 'storageState':
|
|
283
|
+
return applyStorageState(context, cfg, env);
|
|
284
|
+
case 'cookieJwt':
|
|
285
|
+
return applyCookieJwt(context, cfg, env);
|
|
286
|
+
case 'mintEndpoint':
|
|
287
|
+
return applyMintEndpoint(context, cfg, env);
|
|
288
|
+
default:
|
|
289
|
+
throw new Error(`unknown auth adapter: ${(cfg as AuthConfig).adapter}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Resolve an AuthConfig to a `prepareContext` callback the capture/verify
|
|
294
|
+
* layer can pass straight through. Returns undefined when cfg is absent. */
|
|
295
|
+
export function authPrepare(
|
|
296
|
+
cfg: AuthConfig | undefined,
|
|
297
|
+
env: AuthEnv = process.env,
|
|
298
|
+
): ((context: BrowserContext) => Promise<void>) | undefined {
|
|
299
|
+
if (!cfg) return undefined;
|
|
300
|
+
return (context: BrowserContext) => applyAuth(context, cfg, env);
|
|
301
|
+
}
|