@ultimat3/scraping 22.15.0 → 24.0.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 CHANGED
@@ -6,10 +6,8 @@ no ninth primitive, and no ordinal here to go stale when the next factory lands.
6
6
 
7
7
  ```ts
8
8
  import { t } from '@ultimat3/schema';
9
- import type { StorageDriver } from '@ultimat3/storage';
10
9
  import { scrape, storageSessionStore } from '@ultimat3/scraping';
11
-
12
- declare const disk: StorageDriver;
10
+ import { disk } from '@ultimat3/storage';
13
11
 
14
12
  const orderPage = t.object({
15
13
  rows: t.array(t.object({ id: t.string, total: t.number })),
@@ -29,7 +27,8 @@ export const dailyOrders = scrape({
29
27
  secrets: ['SHOP_PASSWORD'],
30
28
  expect: { minRows: 1, maxDrop: 0.5 },
31
29
  auth: {
32
- store: storageSessionStore(disk),
30
+ // A thunk: the app's disks exist once boot ran `defineStorage()`, after this module loaded.
31
+ store: storageSessionStore(() => disk('sessions')),
33
32
  login: async ({ page, secrets, prompt }) => {
34
33
  await page.goto('https://shop.example.com/login');
35
34
  await page.fill('#user', 'ops@example.com');
@@ -85,6 +84,168 @@ Both legs replay from **one** fixture directory (`fixtureBrowser(dir)`), so a hy
85
84
  login, session handoff, HTTP bulk fetch — is tested end to end. Both legs apply the same robots
86
85
  gate, offline included.
87
86
 
87
+ ## A session a service is built on
88
+
89
+ One connection, one run at a time, on its own exit, in a browser rented for that run — with a
90
+ person or an OTP source answering the site mid-run and a `usage` block on the result. Every piece
91
+ is a field on `scrape()` or on its driver; there is no second factory.
92
+
93
+ ```ts
94
+ import { t } from '@ultimat3/schema';
95
+ import type {
96
+ CdpLauncherLike,
97
+ CdpResolver,
98
+ PromptRequest,
99
+ ScrapeDriver,
100
+ ScrapeUsage,
101
+ } from '@ultimat3/scraping';
102
+ import {
103
+ answerPrompt,
104
+ eventPrompt,
105
+ fixtureBrowser,
106
+ remoteBrowser,
107
+ scrape,
108
+ storageSessionStore,
109
+ } from '@ultimat3/scraping';
110
+ import { disk } from '@ultimat3/storage';
111
+
112
+ declare const puppeteer: CdpLauncherLike;
113
+ /** Set where recordings stand in for the browser: `bun test`, `x dev`, the app's own gate. */
114
+ declare const fixtures: string | undefined;
115
+ /** The app's own provider client, and its own "a prompt is pending" row. Neither ships. */
116
+ declare const provider: {
117
+ create(request: {
118
+ tag: string | undefined;
119
+ proxy: string | undefined;
120
+ signal: AbortSignal | undefined;
121
+ }): Promise<{ id: string; connectUrl: string; priceCents: number }>;
122
+ end(id: string): Promise<void>;
123
+ };
124
+ declare function promptPending(request: PromptRequest): Promise<void>;
125
+ /** The app's own rows: the exit is a `.sealed()` column on the connection, read by id. */
126
+ declare function exitOf(connectionId: string): Promise<string | undefined>;
127
+ declare function recordRun(run: {
128
+ runId: string;
129
+ connectionId: string;
130
+ outcome: string;
131
+ code?: string | undefined;
132
+ usage?: ScrapeUsage | undefined;
133
+ }): Promise<void>;
134
+
135
+ // A browser rented per session. `release` runs exactly once, whatever the run did.
136
+ const rent: CdpResolver = async ({ runId, egress, signal }) => {
137
+ const rented = await provider.create({ tag: runId, proxy: egress, signal });
138
+ return {
139
+ cdpUrl: rented.connectUrl,
140
+ release: () => provider.end(rented.id),
141
+ cost: { minor: rented.priceCents, currency: 'USD' },
142
+ };
143
+ };
144
+
145
+ const browser: ScrapeDriver =
146
+ fixtures === undefined
147
+ ? remoteBrowser({ launcher: puppeteer, cdpUrl: rent })
148
+ : fixtureBrowser(fixtures);
149
+
150
+ const waitForAnswer = eventPrompt({ timeout: 300_000 });
151
+
152
+ export const syncAccounts = scrape({
153
+ name: 'accounts.sync',
154
+ // IDS only. A proxy URL carries its account, and the input is the queue row.
155
+ input: t.object({ connectionId: t.uuid, orgId: t.uuid }),
156
+ extract: t.object({ id: t.string }),
157
+ idempotencyKey: ({ connectionId }) => `accounts:${connectionId}`,
158
+ tenant: ({ orgId }) => orgId,
159
+ allowHosts: ['bank.example'],
160
+ driver: browser,
161
+ // The exit THIS run leaves through, looked up IN THE WORKER under the job's tenant. Both legs
162
+ // and the robots read dial it.
163
+ egress: ({ connectionId }) => exitOf(connectionId),
164
+ // One session per connection: a second run of the same one is `failed`, X_JOB_KEY_BUSY.
165
+ concurrency: { key: ({ connectionId }) => connectionId, limit: 1, whenBusy: 'fail' },
166
+ auth: {
167
+ // Declaring a store IS what persists the session — sealed under the app's master key.
168
+ store: storageSessionStore(() => disk('sessions')),
169
+ key: ({ connectionId }) => connectionId,
170
+ login: async ({ page, prompt }) => {
171
+ await page.goto('https://bank.example/login');
172
+ await page.fill('#otp', await prompt('sms code'));
173
+ },
174
+ },
175
+ // Say a prompt is pending, then wait for its answer on the job event bus — browser open.
176
+ prompt: async (request) => {
177
+ await promptPending(request);
178
+ return waitForAnswer(request);
179
+ },
180
+ async run({ page, http }) {
181
+ await page.goto('https://bank.example/accounts');
182
+ const body = await (await http.request('https://bank.example/api/accounts')).parse(
183
+ t.object({ rows: t.array(t.object({ id: t.string })) }),
184
+ );
185
+ return body.rows;
186
+ },
187
+ // How the run ENDED, told once: the report with its usage, or the code it failed with.
188
+ async onSettled(settled) {
189
+ if (settled.input === undefined) return;
190
+ const run = { runId: settled.runId, connectionId: settled.input.connectionId };
191
+ await recordRun(
192
+ settled.outcome === 'completed'
193
+ ? { ...run, outcome: settled.outcome, usage: settled.result.usage }
194
+ : { ...run, outcome: settled.outcome, code: settled.code, usage: settled.usage },
195
+ );
196
+ },
197
+ });
198
+
199
+ /** The body of the app's own policy-checked action: the event name is not an authorization. */
200
+ export const answer = (input: { runId: string; index: number; answer: string }) =>
201
+ answerPrompt(input);
202
+ ```
203
+
204
+ | Field | Rule |
205
+ |---|---|
206
+ | `egress` | `(input, ctx) => string \| undefined`, sync or async, called in the WORKER under the job's tenant — so the input carries an id and a row carries the exit. An exit whose password is also in the input rode the queue payload and is refused before the browser opens: `X_SCRAPE_EGRESS_IN_PAYLOAD`, terminal. Reaches the driver as `SessionInit.proxy` and wins over the driver's `proxy`. A URL with credentials never reaches the process arguments: a launched browser answers the proxy through `page.authenticate()`, and a launcher without it is `X_SCRAPE_EGRESS_UNSUPPORTED` |
207
+ | `remoteBrowser({ cdpUrl })` | a fixed URL, or a `CdpResolver`: called once per `open()` with `{ scrape, runId, egress, signal }`; `release()` runs once — in `close()` after the browser is quit, or at once when the attach fails. A `release()` that throws is logged `scrape.browser.release_failed` (`code`, `driver`, `origin` as scheme and host) through `SessionInit.logger`, which every driver now requires. A fixed URL handed a different exit is `X_SCRAPE_EGRESS_UNSUPPORTED` |
208
+ | `concurrency` | the job's own field, passed through: a number, or `{ key, limit, whenBusy }` |
209
+ | `auth.store` | `storageSessionStore(() => disk('sessions'), { prefix?, keySource? })` — a THUNK, read per call: a scrape is declared when its module loads and the app's disks exist after boot. Seals with core's `seal()`, purpose `SESSION_SEAL_PURPOSE`. Anything in the bucket that is not its own sealed record for that key is burned; no master key is `X_SEAL_KEY_MISSING` before the browser opens. `artifacts.storage` is the same thunk |
210
+ | `onSettled` | the job's own hook (`@ultimat3/jobs`), with what a scrape adds: `completed` hands over the `ScrapeReport`; `dead-lettered` / `dropped` / `refused` carry `code` and the last attempt's `usage` (`undefined` for `refused` — the body never ran). After the row is settled, under the job's tenant, **at most once** across a crash, and a hook that throws changes nothing |
211
+ | `run({ progress, finalAttempt })` | the job's own, passed through |
212
+ | `prompt` | `eventPrompt({ timeout, pollMs?, bus?, keySource?, env? })` polls the event bus in process — never `step.waitForEvent`, which would close the browser. An in-memory bus (`stored: false`) is refused outside development and test (`X_DRIVER_UNAVAILABLE`): the answer comes from another process. Event `promptEventName(runId, index)`; answered by `answerPrompt({ runId, index, answer })`, sealed for that one name. Timeout is `X_SCRAPE_PROMPT_UNANSWERED` |
213
+ | the bus | the **stored** one (`createPgEventBus`) wherever the worker and the answering process differ. Every `x` boot installs it |
214
+
215
+ `PromptRequest` carries the run's `input` and `runId` — what ties a prompt to the app's own row
216
+ for the run — and `index`, `clock`, `signal` and `keepAlive()` beside `label`, `scrape` and `url`:
217
+ what any handler that waits needs: wait on the run's clock, stop on the run's
218
+ signal, and call `keepAlive()` between polls so the wedge watchdog and a rented browser both keep
219
+ seeing activity. `auth.login` and `auth.validate` are handed `runId` beside `input`.
220
+
221
+ `ScrapeReport.usage` is `{ browserMs, navigations, httpRequests, bytesIn, promptsAnswered,
222
+ browserCost? }`. `bytesIn` is the HTTP leg's response bodies; `browserCost` is the resolver's
223
+ `cost`, a `Money` value parsed at the boundary, so a float is refused. The counts ride the
224
+ `scrape.ok` and `scrape.failed` log lines too — a failed run has no report and was billed anyway,
225
+ which is why `onSettled` hands a failed run its last attempt's `usage`. No quota reads them.
226
+
227
+ ### The clock, and a test's
228
+
229
+ A run waits on the PROCESS's clock — `scrapeClock()`, the system's — unless its definition pins
230
+ one with `clock:`. A test replaces it the way it replaces the queue driver:
231
+
232
+ ```ts
233
+ import { noWaitClock, resetScrapeClock, setScrapeClock } from '@ultimat3/scraping';
234
+
235
+ setScrapeClock(noWaitClock); // in a beforeEach
236
+ resetScrapeClock(); // in the afterEach
237
+ ```
238
+
239
+ | Clock | Sleeping | For |
240
+ |---|---|---|
241
+ | `systemScrapeClock` | waits | production |
242
+ | `noWaitClock` | one turn of the event loop, whatever was asked; `now()` and deadlines are real | a test whose run waits on something that really happens in the same process — an answer the test publishes |
243
+ | `testClock()` | advances virtual time and yields a microtask | a test of a timeout: thirty seconds pass in none |
244
+
245
+ A third-party driver takes part by reading three `SessionInit` fields: dial `proxy` or throw
246
+ `egressUnsupported()`, pass `usage` to `pageOverTarget` and its HTTP transport, and answer
247
+ `browserCost` on the session it returns.
248
+
88
249
  ## Reading elements, frames, and the network condition
89
250
 
90
251
  | Read | Answers |
@@ -152,8 +313,9 @@ bound threw away, so a count taken from one is never quietly a floor.
152
313
  All three are **redacted by value** on the way out, the same pass `page.html()` makes: a login
153
314
  endpoint fetched with the password in its query string, or a site that logs the credential it
154
315
  rejected, would otherwise put the value in `page.network()`/`page.console()` verbatim and from
155
- there into the stored failure artifact. The HTTP leg's `X_SCRAPE_HTTP_FAILED` cause is redacted
156
- too, at its throw site. Values shorter than `MIN_REDACTABLE_LENGTH` are not — a 3-character PIN is
316
+ there into the stored failure artifact — percent-encoded, form-encoded or HTML-escaped as much as
317
+ raw, since a password in a query string is never the raw string. The HTTP leg's
318
+ `X_SCRAPE_HTTP_FAILED` cause is redacted too, at its throw site. Values shorter than `MIN_REDACTABLE_LENGTH` are not — a 3-character PIN is
157
319
  a substring of ordinary prose — and pixels never can be, which is why a typed secret TAINTS the
158
320
  page and `screenshot()`/`pdf()` are refused outright.
159
321
 
@@ -199,14 +361,17 @@ on a machine with no browser — the case CI is.
199
361
  | `page.ts` / `page-over-target.ts` | the driver-blind vocabulary, implemented once |
200
362
  | `target.ts` / `driver.ts` | the two seams: what a driver answers, and what a session is |
201
363
  | `driver-cdp.ts` / `cdp-*.ts` | the real browser, over a structural CDP port |
202
- | `driver-fake.ts` / `driver-fixture.ts` / `html-*.ts` | the offline drivers, on Bun's `HTMLRewriter` |
364
+ | `driver-fake.ts` / `driver-recorded.ts` / `html-*.ts` | the offline drivers, on Bun's `HTMLRewriter` |
203
365
  | `http.ts` / `http-recorded.ts` | the second transport, live and replayed |
204
- | `auth.ts` / `session-state.ts` | acquire → persist → reuse → validate → burn. The session key encodes each part (`<sanitised>.<digest>`), so two account names that differ only outside `[a-zA-Z0-9._-]` are two sessions |
205
- | `secrets.ts` / `browser-record.ts` | what may leave this package, and how a browser's own string map is read |
366
+ | `auth.ts` / `session-state.ts` | acquire → persist → reuse → validate → burn. The session key encodes each part (`<sanitised>.<digest>`), so two account names that differ only outside `[a-zA-Z0-9._-]` are two sessions. A stored session is sealed. `burnSession(plan, seen)` takes `recordVersion(record)` of the record the run used, so a record another run saved since is not burned |
367
+ | `event-prompt.ts` | `eventPrompt()` and `answerPrompt()`: a prompt answered from another process, over the job event bus, with the browser open |
368
+ | `cdp-resolver.ts` | a browser rented per session: acquired once, released once |
369
+ | `usage.ts` | what one run used, counted where the work happens |
370
+ | `secrets.ts` / `url-secrets.ts` / `browser-record.ts` | what may leave this package — declared secrets, values learned mid-run (`conceal`), the credential-bearing parts of a URL — and how a browser's own string map is read |
206
371
  | `expect.ts` | the silent-green alarm |
207
372
  | `watchdog.ts` | the wedge and zombie discipline |
208
373
  | `capture-clip.ts` | the one framing rule — crop, whole page, or a refusal — checked before any driver sees it |
209
- | `errors.ts` / `error-throws.ts` | this package's `X_*` codes and their retry classification |
374
+ | `errors.ts` / `error-throws.ts` / `error-throws-session.ts` | this package's `X_*` codes and their retry classification |
210
375
 
211
376
  ## Extending it — there is no plugin API, and none is needed
212
377
 
@@ -247,6 +412,7 @@ export const myBrowser = (options: MyOptions): ScrapeDriver => ({
247
412
  secrets: init.secrets,
248
413
  robots: init.robots,
249
414
  signal: init.signal,
415
+ usage: init.usage,
250
416
  }),
251
417
  http: myHttp(target, init),
252
418
  close: () => target.close(),
@@ -260,10 +426,8 @@ export const myBrowser = (options: MyOptions): ScrapeDriver => ({
260
426
  ```ts
261
427
  // apps/web/shared/base/bank-scrape.ts — the app's convention, written once
262
428
  import type { JobHandle } from '@ultimat3/jobs';
263
- import type { StorageDriver } from '@ultimat3/storage';
264
429
  import { scrape, type ScrapeDefinition } from '@ultimat3/scraping';
265
-
266
- declare const disk: StorageDriver;
430
+ import { disk } from '@ultimat3/storage';
267
431
 
268
432
  export const bankScrape = <I, R>(over: ScrapeDefinition<I, R>): JobHandle<I> =>
269
433
  scrape<I, R>({
@@ -271,7 +435,7 @@ export const bankScrape = <I, R>(over: ScrapeDefinition<I, R>): JobHandle<I> =>
271
435
  rate: 0.5,
272
436
  block: ['image', 'media', 'font'],
273
437
  expect: { minRows: 1, maxDrop: 0.5 },
274
- artifacts: { storage: disk },
438
+ artifacts: { storage: () => disk('artifacts') },
275
439
  ...over,
276
440
  });
277
441
  ```
@@ -298,6 +462,15 @@ Every code carries a cause, a runnable `fix:` and a retry classification — see
298
462
  `X_SCRAPE_YIELD_COLLAPSED`, `X_SCRAPE_AUTH_FAILED`, `X_SCRAPE_BLOCKED` and `X_SCRAPE_PAGE_CRASHED`
299
463
  are the four worth knowing by heart.
300
464
 
465
+ `X_SCRAPE_EGRESS_IN_PAYLOAD` is the one that is about the DECLARATION rather than the site: the
466
+ exit's password was readable out of the run's own input, so it is in `x_jobs` in the clear.
467
+ Enqueue the id of the row that holds the exit, resolve it in `egress`, and rotate the credential.
468
+
469
+ `X_SCRAPE_CDP_ATTACH_FAILED` names the endpoint by **scheme and host only**, for a fixed `cdpUrl` as
470
+ for a resolved one, and cuts every credential-bearing part of the URL out of the launcher's own
471
+ message: a provider's connect URL is its access token, and this cause is written to the dead-letter
472
+ row.
473
+
301
474
  ### Error classes
302
475
 
303
476
  Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/scraping",
3
- "version": "22.15.0",
3
+ "version": "24.0.0",
4
4
  "description": "Browser automation as a job: scrape() returns a JobHandle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "!src/**/*-fixture.ts",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],
@@ -30,9 +31,9 @@
30
31
  "test": "bun test"
31
32
  },
32
33
  "dependencies": {
33
- "@ultimat3/core": "22.15.0",
34
- "@ultimat3/jobs": "22.15.0",
35
- "@ultimat3/schema": "22.15.0",
36
- "@ultimat3/storage": "22.15.0"
34
+ "@ultimat3/core": "24.0.0",
35
+ "@ultimat3/jobs": "24.0.0",
36
+ "@ultimat3/schema": "24.0.0",
37
+ "@ultimat3/storage": "24.0.0"
37
38
  }
38
39
  }
package/src/artifacts.ts CHANGED
@@ -21,7 +21,8 @@ export interface ArtifactWriter {
21
21
  }
22
22
 
23
23
  export interface ArtifactWriterInit {
24
- readonly storage: StorageDriver | undefined;
24
+ /** A thunk, read per save: the app's disk exists only after boot (`storageSessionStore`). */
25
+ readonly storage: (() => StorageDriver) | undefined;
25
26
  readonly scrape: string;
26
27
  readonly runId: string;
27
28
  /** Optional prefix, so an app can keep scrape artifacts out of its user-upload namespace. */
@@ -69,7 +70,7 @@ export function createArtifactWriter(init: ArtifactWriterInit): ArtifactWriter {
69
70
  contentType: contentType ?? contentTypeFor(name),
70
71
  };
71
72
  if (init.storage !== undefined) {
72
- await init.storage.put(ref.key, bytes, { contentType: ref.contentType });
73
+ await init.storage().put(ref.key, bytes, { contentType: ref.contentType });
73
74
  }
74
75
  saved.push(ref);
75
76
  return ref;
package/src/auth.ts CHANGED
@@ -23,23 +23,49 @@ import type { ScrapeClock } from './clock';
23
23
  import { authFailed, promptUnanswered, sessionExpired } from './error-throws';
24
24
  import type { ScrapePage } from './page';
25
25
  import type { ScrapeSecrets } from './secrets';
26
- import { type ScrapeSessionStore, type SessionState, sessionDigest } from './session-state';
26
+ import {
27
+ recordVersion,
28
+ type ScrapeSessionStore,
29
+ type SessionState,
30
+ sessionDigest,
31
+ } from './session-state';
27
32
 
28
- export interface PromptRequest {
33
+ export interface PromptRequest<I = unknown> {
34
+ /**
35
+ * The run's own input — what ties this prompt to the app's identity for the run (a connection
36
+ * id, an org). A handler typed on the definition reads it typed; a shared one reads `unknown`.
37
+ */
38
+ readonly input: I;
29
39
  /** What the site is asking for: `'sms code'`, `'authenticator code'`, `'security question 3'`. */
30
40
  readonly label: string;
31
41
  readonly scrape: string;
32
42
  readonly url: string;
43
+ /** The job run asking. With `index` it names this one prompt: `promptEventName(runId, index)`. */
44
+ readonly runId: string;
45
+ /** 1 for the first prompt of this attempt, 2 for the next. */
46
+ readonly index: number;
47
+ /** The run's clock. A handler that waits, waits on this — never on a timer of its own. */
48
+ readonly clock: ScrapeClock;
49
+ /** The run's cancellation. A handler that waits past it holds a browser nobody is using. */
50
+ readonly signal: AbortSignal | undefined;
51
+ /**
52
+ * One cheap round trip to the browser. A handler that waits calls it between polls: the site is
53
+ * waiting INSIDE this session, so the wedge watchdog must keep seeing activity and a rented
54
+ * browser must not idle out — and a browser that died is found now, not after the answer.
55
+ */
56
+ keepAlive(): Promise<void>;
33
57
  }
34
58
 
35
59
  /**
36
60
  * Where an out-of-band code comes from. A declared callback field on the definition — typed,
37
61
  * discoverable, one per concern — and never a global "register a plugin" call.
38
62
  */
39
- export type PromptHandler = (request: PromptRequest) => Promise<string> | string;
63
+ export type PromptHandler<I = unknown> = (request: PromptRequest<I>) => Promise<string> | string;
40
64
 
41
65
  export interface AuthContext<I> {
42
66
  readonly input: I;
67
+ /** The job run logging in — the id the run's events, steps and prompts are keyed by. */
68
+ readonly runId: string;
43
69
  readonly page: ScrapePage;
44
70
  /**
45
71
  * The declared secrets, boxed. A login body needs the credential it is about to type, and
@@ -78,15 +104,50 @@ export interface ScrapeAuth<I> {
78
104
  readonly maxAge?: number | undefined;
79
105
  }
80
106
 
81
- export function createPrompt(
82
- scrape: string,
83
- handler: PromptHandler | undefined,
84
- page: ScrapePage,
85
- ): (label: string) => Promise<string> {
107
+ export interface PromptInit<I = unknown> {
108
+ readonly scrape: string;
109
+ readonly handler: PromptHandler<I> | undefined;
110
+ readonly input: I;
111
+ readonly page: ScrapePage;
112
+ readonly runId: string;
113
+ readonly clock: ScrapeClock;
114
+ readonly signal?: AbortSignal | undefined;
115
+ /** The run's secret set. Every answer is concealed in it before the body sees the answer. */
116
+ readonly secrets?: ScrapeSecrets | undefined;
117
+ /** Called once per answered prompt — `ScrapeReport.usage.promptsAnswered`. */
118
+ readonly onAnswered?: (() => void) | undefined;
119
+ }
120
+
121
+ /**
122
+ * Matches no element on any page, which is the point: the read costs one round trip and carries
123
+ * nothing back. It goes through the page vocabulary, so it reports activity like any other verb.
124
+ */
125
+ const KEEPALIVE_SELECTOR = 'ultimate-keepalive';
126
+
127
+ export function createPrompt<I>(init: PromptInit<I>): (label: string) => Promise<string> {
128
+ let asked = 0;
86
129
  return async (label: string): Promise<string> => {
130
+ const { scrape, handler, page } = init;
87
131
  if (handler === undefined) throw promptUnanswered(scrape, label);
88
- const answer = await handler({ label, scrape, url: page.url() });
132
+ asked += 1;
133
+ const answer = await handler({
134
+ input: init.input,
135
+ label,
136
+ scrape,
137
+ url: page.url(),
138
+ runId: init.runId,
139
+ index: asked,
140
+ clock: init.clock,
141
+ signal: init.signal,
142
+ keepAlive: async (): Promise<void> => {
143
+ await page.count(KEEPALIVE_SELECTOR);
144
+ },
145
+ });
89
146
  if (typeof answer !== 'string' || answer === '') throw promptUnanswered(scrape, label);
147
+ // Before the body can type it: an OTP echoed into the page it was typed on is in `page.html()`,
148
+ // and the failure artifact is written from exactly that.
149
+ init.secrets?.conceal(answer);
150
+ init.onAnswered?.();
90
151
  return answer;
91
152
  };
92
153
  }
@@ -111,10 +172,30 @@ export interface AuthPlanInput<I> {
111
172
  export async function restorableSession<I>(
112
173
  plan: AuthPlanInput<I>,
113
174
  ): Promise<SessionState | undefined> {
175
+ return (await observeSession(plan)).restorable;
176
+ }
177
+
178
+ /** What `observeSession` answers: the session to restore, and the version of what was found. */
179
+ export interface ObservedSession {
180
+ readonly restorable: SessionState | undefined;
181
+ /**
182
+ * `recordVersion` of the record found — restorable or NOT. A `reuse: false` run, or one whose
183
+ * record is past `maxAge`, still saw that record: a refused login must tombstone over it, and a
184
+ * run that compared against "nothing" let the rejected credential through again.
185
+ */
186
+ readonly seen: string | undefined;
187
+ }
188
+
189
+ /** `restorableSession`, plus the version of the record found, for the run's later writes. */
190
+ export async function observeSession<I>(plan: AuthPlanInput<I>): Promise<ObservedSession> {
114
191
  const store = plan.auth?.store;
115
- if (store === undefined) return undefined;
192
+ if (store === undefined) return { restorable: undefined, seen: undefined };
116
193
  const found = await store.load(plan.key);
117
- if (found === undefined) return undefined;
194
+ if (found === undefined) return { restorable: undefined, seen: undefined };
195
+ return { restorable: restorableFrom(plan, found), seen: recordVersion(found) };
196
+ }
197
+
198
+ function restorableFrom<I>(plan: AuthPlanInput<I>, found: SessionState): SessionState | undefined {
118
199
  // The tombstone is read BEFORE `reuse` is honoured. `reuse: false` says "do not restore this
119
200
  // session"; it does not say "present the rejected credential again", and reading it first meant
120
201
  // a `reuse: false` scrape walked a refused password back to the login form on every requeue.
@@ -138,13 +219,42 @@ export async function restorableSession<I>(
138
219
  return found;
139
220
  }
140
221
 
141
- /** Written down so the next attempt cannot reach the login form with the same credentials. */
142
- export async function markRefused<I>(plan: AuthPlanInput<I>): Promise<void> {
222
+ /**
223
+ * True when the stored record is still the one this run last saw — `seen` is the `recordVersion`
224
+ * it found or persisted, `undefined` when it found none. The store has no compare-and-set (nor has
225
+ * `StorageDriver` a conditional write to build one from), so a
226
+ * burn or a tombstone is a blind write: two runs on one key, and the one that failed destroyed the
227
+ * session the other had just persisted. Compared before the write, which narrows the race to the
228
+ * gap between this read and that write rather than the whole run. A record moved on is left alone.
229
+ */
230
+ async function stillSeen<I>(
231
+ plan: AuthPlanInput<I>,
232
+ store: ScrapeSessionStore,
233
+ seen: string | undefined,
234
+ step: 'session.burn' | 'session.refuse',
235
+ ): Promise<boolean> {
236
+ const current = await store.load(plan.key);
237
+ if (current === undefined || recordVersion(current) === seen) return true;
238
+ plan.logger.warn('scrape.session.superseded', { step });
239
+ return false;
240
+ }
241
+
242
+ /**
243
+ * Written down so the next attempt cannot reach the login form with the same credentials — unless
244
+ * another run saved a session after this one read the record: that login SUCCEEDED, so this
245
+ * refusal is older news than the record it would overwrite.
246
+ */
247
+ export async function markRefused<I>(
248
+ plan: AuthPlanInput<I>,
249
+ seen: string | undefined,
250
+ ): Promise<void> {
143
251
  const store = plan.auth?.store;
144
252
  if (store === undefined) return;
253
+ if (!(await stillSeen(plan, store, seen, 'session.refuse'))) return;
145
254
  await store.save({
146
255
  key: plan.key,
147
256
  savedAt: plan.clock.now().toISOString(),
257
+ version: crypto.randomUUID(),
148
258
  refusedAt: plan.clock.now().toISOString(),
149
259
  cookies: [],
150
260
  headers: {},
@@ -154,15 +264,25 @@ export async function markRefused<I>(plan: AuthPlanInput<I>): Promise<void> {
154
264
  });
155
265
  }
156
266
 
157
- /** New identity, from scratch. One call, because a flagged profile is unusable and must go. */
158
- export async function burnSession<I>(plan: AuthPlanInput<I>): Promise<void> {
159
- if (plan.auth?.store === undefined) return;
160
- await plan.auth.store.burn(plan.key);
267
+ /**
268
+ * New identity, from scratch. One call, because a flagged profile is unusable and must go — the
269
+ * profile THIS run used: `seen` is that record's `recordVersion` (`observeSession`'s, or the one
270
+ * `persistSession` answered), and a record another run saved since is not burned.
271
+ */
272
+ export async function burnSession<I>(
273
+ plan: AuthPlanInput<I>,
274
+ seen: string | undefined,
275
+ ): Promise<void> {
276
+ const store = plan.auth?.store;
277
+ if (store === undefined) return;
278
+ if (!(await stillSeen(plan, store, seen, 'session.burn'))) return;
279
+ await store.burn(plan.key);
161
280
  plan.logger.warn('scrape.session.burned', { burned: true });
162
281
  }
163
282
 
164
283
  export interface EnsureAuthInput<I> extends AuthPlanInput<I> {
165
284
  readonly input: I;
285
+ readonly runId: string;
166
286
  readonly page: ScrapePage;
167
287
  readonly secrets: ScrapeSecrets;
168
288
  readonly restored: SessionState | undefined;
@@ -182,6 +302,7 @@ export async function ensureAuthenticated<I>(args: EnsureAuthInput<I>): Promise<
182
302
  if (auth === undefined) return false;
183
303
  const context: AuthContext<I> = {
184
304
  input: args.input,
305
+ runId: args.runId,
185
306
  page: args.page,
186
307
  secrets: args.secrets,
187
308
  prompt: args.prompt,
@@ -193,18 +314,31 @@ export async function ensureAuthenticated<I>(args: EnsureAuthInput<I>): Promise<
193
314
  return false;
194
315
  }
195
316
  args.logger.info('scrape.session.expired', { reused: false });
196
- await burnSession(args);
317
+ await burnSession(args, recordVersion(args.restored));
197
318
  }
198
319
  if (auth.login === undefined) throw sessionExpired(args.scrape, args.key);
199
320
  await auth.login(context);
200
321
  return true;
201
322
  }
202
323
 
203
- /** After a login: keep what it produced. A 2FA code somebody typed is worth exactly this. */
204
- export async function persistSession<I>(plan: AuthPlanInput<I>, page: ScrapePage): Promise<void> {
324
+ /**
325
+ * After a login: keep what it produced. A 2FA code somebody typed is worth exactly this. Answers
326
+ * the `version` it wrote — the record this run now owns, for a later burn to compare against.
327
+ */
328
+ export async function persistSession<I>(
329
+ plan: AuthPlanInput<I>,
330
+ page: ScrapePage,
331
+ ): Promise<string | undefined> {
205
332
  const store = plan.auth?.store;
206
- if (store === undefined) return;
333
+ if (store === undefined) return undefined;
207
334
  const snapshot = await page.session();
208
- await store.save({ ...snapshot, key: plan.key, savedAt: plan.clock.now().toISOString() });
335
+ const version = crypto.randomUUID();
336
+ await store.save({
337
+ ...snapshot,
338
+ key: plan.key,
339
+ savedAt: plan.clock.now().toISOString(),
340
+ version,
341
+ });
209
342
  plan.logger.info('scrape.session.saved', sessionDigest(snapshot));
343
+ return version;
210
344
  }
package/src/cdp-arm.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // three verbs `press`/`focus`/`accessibility` did not fit under it. The extraction is VERBATIM —
5
5
  // the request, console, `pageerror` and `error` handlers are the same code, one file over.
6
6
 
7
- import type { CdpPageLike, CdpRequestLike } from './cdp-port';
7
+ import type { CdpBrowserLike, CdpPageLike, CdpRequestLike } from './cdp-port';
8
8
  import type { ScrapeClock } from './clock';
9
9
  import type { InterceptRules } from './intercept';
10
10
  import { interceptVerdict, refusalEntry } from './intercept';
@@ -174,3 +174,67 @@ export async function arm(init: CdpArmInit, sinks: CdpSinks): Promise<void> {
174
174
  crashed.value = readStringFrom(payload, 'message') ?? 'renderer crashed';
175
175
  });
176
176
  }
177
+
178
+ /** A `Fetch.requestPaused` payload, read structurally — the fields the verdict needs, or nothing. */
179
+ interface PausedRequest {
180
+ readonly requestId: string;
181
+ readonly resourceType: string;
182
+ readonly url: string;
183
+ readonly method: string;
184
+ }
185
+
186
+ const asPaused = (payload: unknown): PausedRequest | undefined => {
187
+ if (typeof payload !== 'object' || payload === null) return undefined;
188
+ const event = payload as Record<string, unknown>;
189
+ const request = event['request'];
190
+ if (typeof request !== 'object' || request === null) return undefined;
191
+ const { url, method } = request as Record<string, unknown>;
192
+ const { requestId, resourceType } = event;
193
+ if (typeof requestId !== 'string' || typeof url !== 'string') return undefined;
194
+ return {
195
+ requestId,
196
+ url,
197
+ resourceType: typeof resourceType === 'string' ? resourceType : 'Other',
198
+ method: typeof method === 'string' ? method : 'GET',
199
+ };
200
+ };
201
+
202
+ /**
203
+ * `allowHosts` and `block` on EVERY target the browser opens. Page-level interception (`arm`)
204
+ * covers the one page this package created; a popup — `window.open`, a `target=_blank` link — is
205
+ * a new target it never sees, and on Chrome 150 a scraped page's `window.open` to an off-list host
206
+ * reached that host. Browser-level `Fetch` pauses those requests too, and the same
207
+ * `interceptVerdict` decides them. The page's own requests are decided at the page first, so an
208
+ * allowed one is continued here as well and a refused one never arrives: one ring entry each.
209
+ *
210
+ * Enabled BEFORE the first navigation; a browser that refuses `Fetch.enable` rejects here, and
211
+ * `driver-cdp.ts`'s `opened()` closes it — a session whose rule would hold on one tab is refused.
212
+ */
213
+ export async function armBrowser(
214
+ init: {
215
+ readonly browser: CdpBrowserLike;
216
+ readonly rules: InterceptRules;
217
+ readonly clock: ScrapeClock;
218
+ },
219
+ network: NetworkRing,
220
+ ): Promise<void> {
221
+ const session = await init.browser.target().createCDPSession();
222
+ session.on('Fetch.requestPaused', (payload) => {
223
+ const paused = asPaused(payload);
224
+ if (paused === undefined) return;
225
+ const type = asResourceType(paused.resourceType.toLowerCase());
226
+ const verdict = interceptVerdict(paused.url, type, init.rules);
227
+ const requestId = { requestId: paused.requestId };
228
+ // Caught, never floated, for `arm`'s reason: the target may be gone by the time this answers.
229
+ if (verdict === 'allow') {
230
+ session.send('Fetch.continueRequest', requestId).catch(() => undefined);
231
+ return;
232
+ }
233
+ const at = init.clock.now().getTime();
234
+ network.push(refusalEntry(paused.url, type, verdict, at, paused.method));
235
+ session
236
+ .send('Fetch.failRequest', { ...requestId, errorReason: 'BlockedByClient' })
237
+ .catch(() => undefined);
238
+ });
239
+ await session.send('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
240
+ }