@effect-agent/platform-cloudflare 0.1.0-beta.87 → 0.1.0-beta.89

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.
@@ -25,6 +25,7 @@ import {
25
25
  LoginCredential,
26
26
  ProtectedBrowser,
27
27
  ProtectedBrowserClick,
28
+ ProtectedBrowserCheckpoint,
28
29
  ProtectedBrowserError,
29
30
  ProtectedBrowserFill,
30
31
  ProtectedBrowserNavigate,
@@ -69,6 +70,10 @@ export interface ProtectedBrowserTransport {
69
70
  ) => Effect.Effect<void, ProtectedTransportError, ProtectedBrowserDispatch>;
70
71
  /** Invalidates local references synchronously before bounded exact-session cleanup. */
71
72
  readonly invalidate: () => void;
73
+ /** Forget all discovered controls on a controller transition. */
74
+ readonly resetReferences: () => void;
75
+ /** Host attachment release, available only for transferable provider sessions. */
76
+ readonly detach?: Effect.Effect<void, ProtectedBrowserError>;
72
77
  readonly close: Effect.Effect<typeof ProtectedCleanup.Type>;
73
78
  }
74
79
 
@@ -137,581 +142,680 @@ const secretFor = (material: BrowserCredentialMaterial, role: typeof CredentialF
137
142
  }
138
143
  };
139
144
 
140
- /**
141
- * Fresh private passes only. Account administrators and Browser Rendering token holders are
142
- * trusted operators. No viewer, handoff, raw JavaScript, screenshot or plaintext credential API exists.
143
- * Hosts explicitly authorize post-exposure observations for recipients they trust not to echo.
144
- */
145
- export const browserRunProtectedLayer = () =>
146
- Layer.effect(ProtectedBrowser)(
147
- Effect.gen(function* () {
148
- const transport = yield* BrowserRunProtectedTransport;
149
- const crypto = yield* Crypto.Crypto;
145
+ /** Shared protected policy implementation for ephemeral and host-owned passes. */
146
+ export const makeProtectedBrowserPolicy = Effect.fn("ProtectedBrowser.open")(function* (
147
+ input: InteractiveBrowserPolicy,
148
+ checkpoint?: ProtectedBrowserCheckpoint,
149
+ ) {
150
+ const initialError = (reason: ProtectedBrowserError["reason"]) =>
151
+ new ProtectedBrowserError({
152
+ reason,
153
+ dispatch: "not-dispatched",
154
+ milestone: "none",
155
+ observation: "closed",
156
+ cleanup: "not-requested",
157
+ });
158
+
159
+ const decodedPolicy = yield* Schema.decodeEffect(InteractiveBrowserPolicy)(input).pipe(
160
+ Effect.mapError(() => initialError("denied")),
161
+ );
150
162
 
151
- const open = Effect.fn("ProtectedBrowser.open")(function* (
152
- input: InteractiveBrowserPolicy,
153
- ): Effect.fn.Return<
154
- ProtectedBrowserHandle,
155
- ProtectedBrowserError,
156
- Scope.Scope | BrowserCredentialAccess
157
- > {
158
- const initialError = (reason: ProtectedBrowserError["reason"]) =>
159
- new ProtectedBrowserError({
160
- reason,
161
- dispatch: "not-dispatched",
162
- milestone: "none",
163
- observation: "closed",
164
- cleanup: "not-requested",
165
- });
163
+ if (decodedPolicy.network._tag === "PublicWeb") return yield* initialError("unsupported");
166
164
 
167
- const decodedPolicy = yield* Schema.decodeEffect(InteractiveBrowserPolicy)(input).pipe(
168
- Effect.mapError(() => initialError("denied")),
169
- );
165
+ const policy = InteractiveBrowserPolicy.make({
166
+ ...decodedPolicy,
167
+ network:
168
+ decodedPolicy.network._tag === "ExactHosts"
169
+ ? { _tag: "ExactHosts", allowedHosts: [...decodedPolicy.network.allowedHosts] }
170
+ : { _tag: "Unrestricted" },
171
+ });
170
172
 
171
- if (decodedPolicy.network._tag === "PublicWeb") return yield* initialError("unsupported");
172
-
173
- const policy = InteractiveBrowserPolicy.make({
174
- ...decodedPolicy,
175
- network:
176
- decodedPolicy.network._tag === "ExactHosts"
177
- ? { _tag: "ExactHosts", allowedHosts: [...decodedPolicy.network.allowedHosts] }
178
- : { _tag: "Unrestricted" },
179
- });
180
-
181
- // Capture the host access service for THIS execution, not the application singleton.
182
- const access = yield* BrowserCredentialAccess;
183
- const started = yield* Clock.currentTimeMillis;
184
- const driver = yield* transport.open(policy);
185
- const lock = yield* Semaphore.make(1);
186
- let observation: typeof ProtectedObservationState.Type = "before-exposure";
187
- let cleanup: typeof ProtectedCleanup.Type = "not-requested";
188
- let actions = 0;
189
- let dispatch: ProtectedBrowserError["dispatch"] = "not-dispatched";
190
- let milestone: ProtectedBrowserError["milestone"] = "none";
191
- const exposures: Array<CredentialTarget> = [];
192
-
193
- const offers = new Map<
194
- string,
195
- {
196
- caller: Redacted.Redacted<string>;
197
- key: Redacted.Redacted<string>;
198
- target: CredentialTarget;
199
- targetRef: string;
200
- kind: typeof CredentialKind.Type;
201
- expires: number;
202
- }
203
- >();
204
-
205
- const fail = (reason: ProtectedBrowserError["reason"]) =>
206
- new ProtectedBrowserError({ reason, dispatch, milestone, observation, cleanup });
207
-
208
- const close = yield* Effect.cached(
209
- Effect.uninterruptible(
210
- Effect.gen(function* () {
211
- observation = "closed";
212
- offers.clear();
213
- driver.invalidate();
214
- cleanup = yield* driver.close.pipe(
215
- Effect.interruptible,
216
- Effect.timeoutOrElse({
217
- duration: "10 seconds",
218
- orElse: () => Effect.succeed("unconfirmed" as const),
219
- }),
220
- Effect.catchCause(() => Effect.succeed("unconfirmed" as const)),
221
- );
173
+ // Capture the host access service for THIS execution, not the application singleton.
174
+ const access = yield* BrowserCredentialAccess;
222
175
 
223
- return cleanup;
224
- }),
225
- ),
176
+ const restored =
177
+ checkpoint === undefined
178
+ ? undefined
179
+ : yield* Schema.decodeEffect(ProtectedBrowserCheckpoint)(checkpoint).pipe(
180
+ Effect.mapError(() => initialError("denied")),
226
181
  );
227
182
 
228
- yield* Effect.addFinalizer(() =>
229
- close.pipe(
230
- Effect.flatMap((result) =>
231
- result === "unconfirmed"
232
- ? Effect.logWarning("Protected browser exact-session closure unconfirmed")
233
- : Effect.void,
234
- ),
235
- ),
183
+ const now = yield* Clock.currentTimeMillis;
184
+
185
+ if (
186
+ restored !== undefined &&
187
+ (restored.startedAt > now ||
188
+ restored.actions > policy.maxActions ||
189
+ now - restored.startedAt >= policy.maxElapsedMillis)
190
+ )
191
+ return yield* initialError("timeout");
192
+ const started = restored?.startedAt ?? now;
193
+ const driver = yield* (yield* BrowserRunProtectedTransport).open(policy);
194
+ const crypto = yield* Crypto.Crypto;
195
+ let suspended = restored !== undefined;
196
+ let detached = false;
197
+ let needsObservation = restored !== undefined;
198
+ let humanExposure = restored?.humanExposure ?? false;
199
+ let humanOrigins = [...(restored?.humanOrigins ?? [])];
200
+ const lock = yield* Semaphore.make(1);
201
+ let observation: typeof ProtectedObservationState.Type = "before-exposure";
202
+ let cleanup: typeof ProtectedCleanup.Type = "not-requested";
203
+ let actions = restored?.actions ?? 0;
204
+ let dispatch: ProtectedBrowserError["dispatch"] = restored?.dispatch ?? "not-dispatched";
205
+ let milestone: ProtectedBrowserError["milestone"] = restored?.milestone ?? "none";
206
+ const exposures: Array<CredentialTarget> = [...(restored?.exposures ?? [])];
207
+
208
+ const offers = new Map<
209
+ string,
210
+ {
211
+ caller: Redacted.Redacted<string>;
212
+ key: Redacted.Redacted<string>;
213
+ target: CredentialTarget;
214
+ targetRef: string;
215
+ kind: typeof CredentialKind.Type;
216
+ expires: number;
217
+ }
218
+ >();
219
+
220
+ const fail = (reason: ProtectedBrowserError["reason"]) =>
221
+ new ProtectedBrowserError({ reason, dispatch, milestone, observation, cleanup });
222
+
223
+ const close = yield* Effect.cached(
224
+ Effect.uninterruptible(
225
+ Effect.gen(function* () {
226
+ observation = "closed";
227
+ offers.clear();
228
+ driver.invalidate();
229
+ cleanup = yield* driver.close.pipe(
230
+ Effect.interruptible,
231
+ Effect.timeoutOrElse({
232
+ duration: "10 seconds",
233
+ orElse: () => Effect.succeed("unconfirmed" as const),
234
+ }),
235
+ Effect.catchCause(() => Effect.succeed("unconfirmed" as const)),
236
236
  );
237
237
 
238
- const remote = <A, R>(effect: Effect.Effect<A, ProtectedTransportError, R>) =>
239
- effect.pipe(Effect.mapError((error) => fail(error.reason)));
240
-
241
- const decode = <A>(schema: Schema.Codec<A>, value: unknown) =>
242
- Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(() => fail("provider")));
238
+ return cleanup;
239
+ }),
240
+ ),
241
+ );
243
242
 
244
- const caller = access.caller.pipe(Effect.mapError((error) => fail(error.reason)));
245
- const pageContext = remote(driver.context);
243
+ yield* Effect.addFinalizer(() =>
244
+ detached
245
+ ? Effect.void
246
+ : close.pipe(
247
+ Effect.flatMap((result) =>
248
+ result === "unconfirmed"
249
+ ? Effect.logWarning("Protected browser exact-session closure unconfirmed")
250
+ : Effect.void,
251
+ ),
252
+ ),
253
+ );
246
254
 
247
- const permitObservation = Effect.gen(function* () {
248
- const context = yield* pageContext;
249
- let origins: ReadonlyArray<typeof CredentialOrigin.Type> | undefined;
255
+ const remote = <A, R>(effect: Effect.Effect<A, ProtectedTransportError, R>) =>
256
+ effect.pipe(Effect.mapError((error) => fail(error.reason)));
257
+
258
+ const decode = <A>(schema: Schema.Codec<A>, value: unknown) =>
259
+ Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(() => fail("provider")));
260
+
261
+ const caller = access.caller.pipe(Effect.mapError((error) => fail(error.reason)));
262
+ const pageContext = remote(driver.context);
263
+
264
+ const permitObservation = Effect.gen(function* () {
265
+ const context = yield* pageContext;
266
+ let origins: ReadonlyArray<typeof CredentialOrigin.Type> | undefined;
267
+
268
+ if (exposures.length > 0 || humanExposure) {
269
+ observation = "protected";
270
+ const principal = yield* caller;
271
+
272
+ const rawDecision = yield* access
273
+ .observation({
274
+ ...context,
275
+ caller: principal,
276
+ exposures: [...exposures],
277
+ humanExposure,
278
+ humanOrigins,
279
+ })
280
+ .pipe(Effect.mapError((error) => fail(error.reason)));
281
+
282
+ if (Redacted.value(yield* caller) !== Redacted.value(principal)) return yield* fail("denied");
283
+
284
+ const decision = yield* Schema.decodeEffect(CredentialObservationDecision)(rawDecision).pipe(
285
+ Effect.mapError(() => fail("observation-blocked")),
286
+ );
287
+
288
+ if (decision === "deny") return yield* fail("observation-blocked");
289
+ if (typeof decision !== "string") {
290
+ origins = [...decision.origins];
291
+ if (!origins.includes(context.topOrigin)) return yield* fail("observation-blocked");
292
+ }
293
+ observation = "approved-after-exposure";
294
+ }
295
+ yield* remote(driver.restrictObservation(origins));
296
+
297
+ return {
298
+ ...context,
299
+ frameOrigins: context.frameOrigins.filter(
300
+ (origin) => origins === undefined || origins.includes(origin),
301
+ ),
302
+ };
303
+ });
304
+
305
+ const target = (ref: string) => remote(driver.target(ref));
306
+
307
+ const authorizeAction = Effect.fn("ProtectedBrowser.authorizeAction")(function* (
308
+ action: ProtectedBrowserAction,
309
+ ) {
310
+ if (access.authorizeAction === undefined) {
311
+ if (action._tag === "Submit") return yield* fail("unsupported");
312
+
313
+ return;
314
+ }
315
+ const principal = yield* caller;
316
+
317
+ yield* access
318
+ .authorizeAction({ caller: principal, action, exposures: [...exposures] })
319
+ .pipe(Effect.mapError((error) => fail(error.reason)));
320
+ if (Redacted.value(yield* caller) !== Redacted.value(principal)) return yield* fail("denied");
321
+ if (action._tag !== "Navigate") {
322
+ const current = yield* target(action.ref);
323
+
324
+ if (
325
+ !sameTarget(current.target, action.target) ||
326
+ current.role !== (action._tag === "Submit" ? "submit" : action.role) ||
327
+ (action._tag === "Click" && action.role === "link" && current.url !== action.url)
328
+ )
329
+ return yield* fail("stale-reference");
330
+ }
331
+ }, Effect.withTracerEnabled(false));
332
+
333
+ const bounded = <A>(result: A) => {
334
+ const bytes = new TextEncoder().encode(JSON.stringify(result)).byteLength;
335
+
336
+ return bytes <= policy.maxReturnedBytes ? Effect.succeed(result) : Effect.fail(fail("limit"));
337
+ };
338
+
339
+ const run = <A>(effect: Effect.Effect<A, ProtectedBrowserError>, observing = false) =>
340
+ lock
341
+ .withPermitsIfAvailable(1)(
342
+ Effect.gen(function* () {
343
+ if (suspended || detached) return yield* fail("busy");
344
+ if (needsObservation && !observing) return yield* fail("stale-reference");
345
+ dispatch = "not-dispatched";
346
+ milestone = "none";
347
+ if (observation === "closed") return yield* fail("closed");
348
+ if (++actions > policy.maxActions) return yield* fail("limit");
349
+
350
+ const remaining = policy.maxElapsedMillis - ((yield* Clock.currentTimeMillis) - started);
351
+
352
+ if (remaining <= 0) {
353
+ yield* close;
354
+
355
+ return yield* fail("timeout");
356
+ }
250
357
 
251
- if (exposures.length > 0) {
252
- observation = "protected";
253
- const principal = yield* caller;
358
+ return yield* effect.pipe(
359
+ Effect.timeoutOrElse({
360
+ duration: remaining,
361
+ orElse: () => Effect.fail(fail("timeout")),
362
+ }),
363
+ Effect.catchCause((cause) =>
364
+ Effect.gen(function* () {
365
+ // No foreign exception or defect is retained. Interrupted operations still finalize.
366
+ const error = cause.reasons.find(
367
+ (reason) =>
368
+ reason._tag === "Fail" && Schema.is(ProtectedBrowserError)(reason.error),
369
+ );
254
370
 
255
- const rawDecision = yield* access
256
- .observation({ ...context, caller: principal, exposures: [...exposures] })
257
- .pipe(Effect.mapError((error) => fail(error.reason)));
371
+ const reason =
372
+ error?._tag === "Fail" && Schema.is(ProtectedBrowserError)(error.error)
373
+ ? error.error.reason
374
+ : "provider";
258
375
 
259
- if (Redacted.value(yield* caller) !== Redacted.value(principal))
260
- return yield* fail("denied");
376
+ const interrupt = cause.reasons.some((reason) => reason._tag === "Interrupt");
261
377
 
262
- const decision = yield* Schema.decodeEffect(CredentialObservationDecision)(
263
- rawDecision,
264
- ).pipe(Effect.mapError(() => fail("observation-blocked")));
378
+ if (
379
+ dispatch !== "not-dispatched" ||
380
+ interrupt ||
381
+ reason === "provider" ||
382
+ reason === "timeout"
383
+ )
384
+ yield* close;
385
+ if (interrupt) return yield* Effect.interrupt;
265
386
 
266
- if (decision === "deny") return yield* fail("observation-blocked");
267
- if (typeof decision !== "string") {
268
- origins = [...decision.origins];
269
- if (!origins.includes(context.topOrigin)) return yield* fail("observation-blocked");
270
- }
271
- observation = "approved-after-exposure";
387
+ return yield* fail(
388
+ dispatch === "possibly-dispatched" && reason === "provider"
389
+ ? "outcome-unknown"
390
+ : reason,
391
+ );
392
+ }),
393
+ ),
394
+ Effect.onInterrupt(() => close),
395
+ Effect.withTracerEnabled(false),
396
+ );
397
+ }),
398
+ )
399
+ .pipe(
400
+ Effect.flatMap(
401
+ Effect.fromOption(
402
+ () =>
403
+ new ProtectedBrowserError({
404
+ reason: "busy",
405
+ dispatch: "not-dispatched",
406
+ milestone: "none",
407
+ observation,
408
+ cleanup,
409
+ }),
410
+ ),
411
+ ),
412
+ );
413
+
414
+ const handle: ProtectedBrowserHandle = {
415
+ close: Effect.suspend(() => (detached ? Effect.succeed("not-requested" as const) : close)),
416
+ navigate: (request) =>
417
+ run(
418
+ Effect.gen(function* () {
419
+ const decoded = yield* Schema.decodeEffect(ProtectedBrowserNavigate)(request).pipe(
420
+ Effect.mapError(() => fail("denied")),
421
+ );
422
+
423
+ let url: URL;
424
+
425
+ try {
426
+ url = new URL(decoded.url);
427
+ } catch {
428
+ return yield* fail("denied");
272
429
  }
273
- yield* remote(driver.restrictObservation(origins));
430
+ if (
431
+ url.protocol !== "https:" ||
432
+ url.username ||
433
+ url.password ||
434
+ (policy.network._tag === "ExactHosts" &&
435
+ !policy.network.allowedHosts.includes(url.host))
436
+ )
437
+ return yield* fail("denied");
438
+ if (exposures.length > 0 || humanExposure) yield* permitObservation;
439
+ yield* authorizeAction({ _tag: "Navigate", url: decoded.url });
440
+ offers.clear();
441
+ dispatch = "possibly-dispatched";
442
+ yield* remote(driver.navigate(decoded.url));
443
+ dispatch = "dispatched";
444
+ yield* permitObservation;
445
+ }),
446
+ ),
447
+ observe: run(
448
+ Effect.gen(function* () {
449
+ const before = yield* permitObservation;
450
+ const result = yield* remote(driver.discover);
451
+ const after = yield* permitObservation;
452
+
453
+ if (
454
+ before.document !== after.document ||
455
+ result.document !== after.document ||
456
+ result.topOrigin !== after.topOrigin
457
+ )
458
+ return yield* fail("stale-reference");
459
+ if (result.frameOrigins.some((origin) => !after.frameOrigins.includes(origin)))
460
+ return yield* fail("observation-blocked");
461
+
462
+ const observation = yield* bounded(
463
+ ProtectedBrowserObservation.make({
464
+ ...result,
465
+ observation:
466
+ exposures.length > 0 || humanExposure ? "approved-after-exposure" : "before-exposure",
467
+ }),
468
+ );
274
469
 
275
- return {
276
- ...context,
277
- frameOrigins: context.frameOrigins.filter(
278
- (origin) => origins === undefined || origins.includes(origin),
279
- ),
280
- };
281
- });
470
+ needsObservation = false;
471
+
472
+ return observation;
473
+ }),
474
+ true,
475
+ ),
476
+ click: (request) =>
477
+ run(
478
+ Effect.gen(function* () {
479
+ const decoded = yield* Schema.decodeEffect(ProtectedBrowserClick)(request).pipe(
480
+ Effect.mapError(() => fail("denied")),
481
+ );
482
+
483
+ yield* permitObservation;
484
+ const control = yield* target(decoded.ref);
485
+
486
+ if (
487
+ control.role !== "link" &&
488
+ control.role !== "button" &&
489
+ control.role !== "radio" &&
490
+ control.role !== "checkbox" &&
491
+ control.role !== "submit"
492
+ )
493
+ return yield* fail("unsupported");
494
+ let action: ProtectedBrowserAction;
495
+
496
+ if (control.role === "link") {
497
+ if (control.url === undefined) return yield* fail("unsupported");
498
+ action = {
499
+ _tag: "Click",
500
+ ref: decoded.ref,
501
+ target: control.target,
502
+ role: "link",
503
+ url: control.url,
504
+ };
505
+ } else if (control.role === "submit") {
506
+ action = { _tag: "Submit", ref: decoded.ref, target: control.target };
507
+ } else {
508
+ action = {
509
+ _tag: "Click",
510
+ ref: decoded.ref,
511
+ target: control.target,
512
+ role: control.role,
513
+ };
514
+ }
515
+ yield* authorizeAction(action);
516
+ dispatch = "possibly-dispatched";
517
+ yield* remote(driver.click(decoded.ref)).pipe(
518
+ Effect.catch((error) => {
519
+ if (error.reason === "needs-attention") dispatch = "not-dispatched";
282
520
 
283
- const target = (ref: string) => remote(driver.target(ref));
521
+ return Effect.fail(error);
522
+ }),
523
+ );
524
+ dispatch = "dispatched";
525
+ if (control.role === "submit") milestone = "submission-dispatched";
526
+ yield* permitObservation;
527
+ }),
528
+ ),
529
+ fill: (request) =>
530
+ run(
531
+ Effect.gen(function* () {
532
+ const decoded = yield* Schema.decodeEffect(ProtectedBrowserFill)(request).pipe(
533
+ Effect.mapError(() => fail("denied")),
534
+ );
535
+
536
+ yield* permitObservation;
537
+ const control = yield* target(decoded.ref);
538
+
539
+ if (control.role !== "text" && control.role !== "select")
540
+ return yield* fail("unsupported");
541
+ yield* authorizeAction({
542
+ _tag: "Fill",
543
+ ref: decoded.ref,
544
+ target: control.target,
545
+ role: control.role,
546
+ });
547
+ yield* remote(driver.fill(decoded.ref, control.role, Redacted.make(decoded.value))).pipe(
548
+ Effect.provideService(ProtectedBrowserDispatch, {
549
+ mark: Effect.sync(() => {
550
+ dispatch = "possibly-dispatched";
551
+ }),
552
+ }),
553
+ );
554
+ dispatch = "dispatched";
555
+ milestone = "filled";
556
+ yield* permitObservation;
557
+ }),
558
+ ),
559
+ listCredentialOffers: (request) =>
560
+ run(
561
+ Effect.gen(function* () {
562
+ const decoded = yield* Schema.decodeEffect(ListCredentialOffers)(request).pipe(
563
+ Effect.mapError(() => fail("denied")),
564
+ );
565
+
566
+ yield* permitObservation;
567
+ const control = yield* target(decoded.target);
568
+
569
+ if (kindFor(control.role) !== decoded.kind) return yield* fail("unsupported");
570
+ const principal = yield* caller;
284
571
 
285
- const authorizeAction = Effect.fn("ProtectedBrowser.authorizeAction")(function* (
286
- action: ProtectedBrowserAction,
287
- ) {
288
- if (access.authorizeAction === undefined) {
289
- if (action._tag === "Submit") return yield* fail("unsupported");
572
+ const candidates = yield* access
573
+ .list({ caller: principal, kind: decoded.kind, target: control.target })
574
+ .pipe(Effect.mapError((error) => fail(error.reason)));
290
575
 
291
- return;
576
+ const now = yield* Clock.currentTimeMillis;
577
+
578
+ for (const [ref, offer] of offers) if (offer.expires <= now) offers.delete(ref);
579
+ if (candidates.length > 16 || offers.size + candidates.length > 64)
580
+ return yield* fail("limit");
581
+ if (!sameTarget(control.target, (yield* target(decoded.target)).target))
582
+ return yield* fail("stale-reference");
583
+ const expires = now + 60_000;
584
+ const result: Array<CredentialOffer> = [];
585
+ const pending: typeof offers = new Map();
586
+
587
+ for (const candidate of candidates) {
588
+ const metadata = yield* decode(CredentialOfferMetadata, candidate.metadata);
589
+
590
+ const ref = yield* crypto.randomUUIDv4.pipe(Effect.mapError(() => fail("provider")));
591
+
592
+ pending.set(ref, {
593
+ caller: principal,
594
+ key: candidate.key,
595
+ target: control.target,
596
+ targetRef: decoded.target,
597
+ kind: decoded.kind,
598
+ expires,
599
+ });
600
+ result.push(CredentialOffer.make({ ref, kind: decoded.kind, metadata }));
292
601
  }
602
+ yield* bounded(result);
603
+ for (const [ref, offer] of pending) offers.set(ref, offer);
604
+
605
+ return result;
606
+ }),
607
+ ),
608
+ useCredential: (request) =>
609
+ run(
610
+ Effect.gen(function* () {
611
+ const decoded = yield* Schema.decodeEffect(UseCredential)(request).pipe(
612
+ Effect.mapError(() => fail("denied")),
613
+ );
614
+
615
+ const offer = offers.get(decoded.offer);
616
+
617
+ if (offer === undefined || offer.expires <= (yield* Clock.currentTimeMillis))
618
+ return yield* fail("stale-reference");
293
619
  const principal = yield* caller;
294
620
 
295
- yield* access
296
- .authorizeAction({ caller: principal, action, exposures: [...exposures] })
297
- .pipe(Effect.mapError((error) => fail(error.reason)));
298
- if (Redacted.value(yield* caller) !== Redacted.value(principal))
621
+ if (Redacted.value(principal) !== Redacted.value(offer.caller))
622
+ return yield* fail("denied");
623
+ if (
624
+ new Set(decoded.fields.map((field) => field.ref)).size !== decoded.fields.length ||
625
+ new Set(decoded.fields.map((field) => field.role)).size !== decoded.fields.length
626
+ )
299
627
  return yield* fail("denied");
300
- if (action._tag !== "Navigate") {
301
- const current = yield* target(action.ref);
302
-
303
- if (
304
- !sameTarget(current.target, action.target) ||
305
- current.role !== (action._tag === "Submit" ? "submit" : action.role) ||
306
- (action._tag === "Click" && action.role === "link" && current.url !== action.url)
307
- )
308
- return yield* fail("stale-reference");
309
- }
310
- }, Effect.withTracerEnabled(false));
311
-
312
- const bounded = <A>(result: A) => {
313
- const bytes = new TextEncoder().encode(JSON.stringify(result)).byteLength;
314
-
315
- return bytes <= policy.maxReturnedBytes
316
- ? Effect.succeed(result)
317
- : Effect.fail(fail("limit"));
318
- };
319
-
320
- const run = <A>(effect: Effect.Effect<A, ProtectedBrowserError>) =>
321
- lock
322
- .withPermitsIfAvailable(1)(
323
- Effect.gen(function* () {
324
- dispatch = "not-dispatched";
325
- milestone = "none";
326
- if (observation === "closed") return yield* fail("closed");
327
- if (++actions > policy.maxActions) return yield* fail("limit");
328
-
329
- const remaining =
330
- policy.maxElapsedMillis - ((yield* Clock.currentTimeMillis) - started);
331
-
332
- if (remaining <= 0) {
333
- yield* close;
334
-
335
- return yield* fail("timeout");
336
- }
337
-
338
- return yield* effect.pipe(
339
- Effect.timeoutOrElse({
340
- duration: remaining,
341
- orElse: () => Effect.fail(fail("timeout")),
342
- }),
343
- Effect.catchCause((cause) =>
344
- Effect.gen(function* () {
345
- // No foreign exception or defect is retained. Interrupted operations still finalize.
346
- const error = cause.reasons.find(
347
- (reason) =>
348
- reason._tag === "Fail" && Schema.is(ProtectedBrowserError)(reason.error),
349
- );
350
-
351
- const reason =
352
- error?._tag === "Fail" && Schema.is(ProtectedBrowserError)(error.error)
353
- ? error.error.reason
354
- : "provider";
355
-
356
- const interrupt = cause.reasons.some((reason) => reason._tag === "Interrupt");
357
-
358
- if (
359
- dispatch !== "not-dispatched" ||
360
- interrupt ||
361
- reason === "provider" ||
362
- reason === "timeout"
363
- )
364
- yield* close;
365
- if (interrupt) return yield* Effect.interrupt;
366
-
367
- return yield* fail(
368
- dispatch === "possibly-dispatched" && reason === "provider"
369
- ? "outcome-unknown"
370
- : reason,
371
- );
372
- }),
373
- ),
374
- Effect.onInterrupt(() => close),
375
- Effect.withTracerEnabled(false),
376
- );
377
- }),
378
- )
379
- .pipe(
380
- Effect.flatMap(
381
- Effect.fromOption(
382
- () =>
383
- new ProtectedBrowserError({
384
- reason: "busy",
385
- dispatch: "not-dispatched",
386
- milestone: "none",
387
- observation,
388
- cleanup,
389
- }),
390
- ),
391
- ),
392
- );
393
-
394
- return {
395
- close,
396
- navigate: (request) =>
397
- run(
398
- Effect.gen(function* () {
399
- const decoded = yield* Schema.decodeEffect(ProtectedBrowserNavigate)(request).pipe(
400
- Effect.mapError(() => fail("denied")),
401
- );
402
-
403
- let url: URL;
404
628
 
405
- try {
406
- url = new URL(decoded.url);
407
- } catch {
408
- return yield* fail("denied");
409
- }
410
- if (
411
- url.protocol !== "https:" ||
412
- url.username ||
413
- url.password ||
414
- (policy.network._tag === "ExactHosts" &&
415
- !policy.network.allowedHosts.includes(url.host))
416
- )
417
- return yield* fail("denied");
418
- if (exposures.length > 0) yield* permitObservation;
419
- yield* authorizeAction({ _tag: "Navigate", url: decoded.url });
420
- offers.clear();
421
- dispatch = "possibly-dispatched";
422
- yield* remote(driver.navigate(decoded.url));
423
- dispatch = "dispatched";
424
- yield* permitObservation;
425
- }),
426
- ),
427
- observe: run(
428
- Effect.gen(function* () {
429
- const before = yield* permitObservation;
430
- const result = yield* remote(driver.discover);
431
- const after = yield* permitObservation;
629
+ const validate = Effect.gen(function* () {
630
+ if (!sameTarget(offer.target, (yield* target(offer.targetRef)).target))
631
+ return yield* fail("stale-reference");
632
+ for (const field of decoded.fields) {
633
+ const current = yield* target(field.ref);
432
634
 
433
635
  if (
434
- before.document !== after.document ||
435
- result.document !== after.document ||
436
- result.topOrigin !== after.topOrigin
636
+ field.role !== current.role ||
637
+ kindFor(field.role) !== offer.kind ||
638
+ !sameTarget(offer.target, current.target)
437
639
  )
438
- return yield* fail("stale-reference");
439
- if (result.frameOrigins.some((origin) => !after.frameOrigins.includes(origin)))
440
- return yield* fail("observation-blocked");
441
-
442
- return yield* bounded(
443
- ProtectedBrowserObservation.make({
444
- ...result,
445
- observation: exposures.length > 0 ? "approved-after-exposure" : "before-exposure",
446
- }),
447
- );
448
- }),
449
- ),
450
- click: (request) =>
451
- run(
452
- Effect.gen(function* () {
453
- const decoded = yield* Schema.decodeEffect(ProtectedBrowserClick)(request).pipe(
454
- Effect.mapError(() => fail("denied")),
455
- );
640
+ return yield* fail("denied");
641
+ }
642
+ if (decoded.submit !== undefined) {
643
+ const submit = yield* target(decoded.submit);
456
644
 
457
- yield* permitObservation;
458
- const control = yield* target(decoded.ref);
645
+ if (submit.role !== "submit" || !sameTarget(offer.target, submit.target))
646
+ return yield* fail("denied");
647
+ }
648
+ });
459
649
 
460
- if (
461
- control.role !== "link" &&
462
- control.role !== "button" &&
463
- control.role !== "radio" &&
464
- control.role !== "checkbox" &&
465
- control.role !== "submit"
466
- )
467
- return yield* fail("unsupported");
468
- let action: ProtectedBrowserAction;
469
-
470
- if (control.role === "link") {
471
- if (control.url === undefined) return yield* fail("unsupported");
472
- action = {
473
- _tag: "Click",
474
- ref: decoded.ref,
475
- target: control.target,
476
- role: "link",
477
- url: control.url,
478
- };
479
- } else if (control.role === "submit") {
480
- action = { _tag: "Submit", ref: decoded.ref, target: control.target };
481
- } else {
482
- action = {
483
- _tag: "Click",
484
- ref: decoded.ref,
485
- target: control.target,
486
- role: control.role,
487
- };
488
- }
489
- yield* authorizeAction(action);
490
- dispatch = "possibly-dispatched";
491
- yield* remote(driver.click(decoded.ref)).pipe(
492
- Effect.catch((error) => {
493
- if (error.reason === "needs-attention") dispatch = "not-dispatched";
650
+ yield* validate;
494
651
 
495
- return Effect.fail(error);
496
- }),
497
- );
498
- dispatch = "dispatched";
499
- if (control.role === "submit") milestone = "submission-dispatched";
500
- yield* permitObservation;
501
- }),
502
- ),
503
- fill: (request) =>
504
- run(
505
- Effect.gen(function* () {
506
- const decoded = yield* Schema.decodeEffect(ProtectedBrowserFill)(request).pipe(
507
- Effect.mapError(() => fail("denied")),
508
- );
652
+ const authorization: CredentialUseAuthorization = {
653
+ caller: principal,
654
+ key: offer.key,
655
+ kind: offer.kind,
656
+ target: offer.target,
657
+ roles: decoded.fields.map((field) => field.role),
658
+ submit: decoded.submit !== undefined,
659
+ };
509
660
 
510
- yield* permitObservation;
511
- const control = yield* target(decoded.ref);
512
-
513
- if (control.role !== "text" && control.role !== "select")
514
- return yield* fail("unsupported");
515
- yield* authorizeAction({
516
- _tag: "Fill",
517
- ref: decoded.ref,
518
- target: control.target,
519
- role: control.role,
520
- });
521
- yield* remote(
522
- driver.fill(decoded.ref, control.role, Redacted.make(decoded.value)),
523
- ).pipe(
524
- Effect.provideService(ProtectedBrowserDispatch, {
525
- mark: Effect.sync(() => {
526
- dispatch = "possibly-dispatched";
527
- }),
528
- }),
529
- );
530
- dispatch = "dispatched";
531
- milestone = "filled";
532
- yield* permitObservation;
533
- }),
534
- ),
535
- listCredentialOffers: (request) =>
536
- run(
537
- Effect.gen(function* () {
538
- const decoded = yield* Schema.decodeEffect(ListCredentialOffers)(request).pipe(
539
- Effect.mapError(() => fail("denied")),
540
- );
661
+ const authorize = Effect.gen(function* () {
662
+ if (Redacted.value(yield* caller) !== Redacted.value(principal))
663
+ return yield* fail("denied");
664
+ yield* access
665
+ .authorize(authorization)
666
+ .pipe(Effect.mapError((error) => fail(error.reason)));
667
+ if (Redacted.value(yield* caller) !== Redacted.value(principal))
668
+ return yield* fail("denied");
669
+ });
541
670
 
542
- yield* permitObservation;
543
- const control = yield* target(decoded.target);
544
-
545
- if (kindFor(control.role) !== decoded.kind) return yield* fail("unsupported");
546
- const principal = yield* caller;
547
-
548
- const candidates = yield* access
549
- .list({ caller: principal, kind: decoded.kind, target: control.target })
550
- .pipe(Effect.mapError((error) => fail(error.reason)));
551
-
552
- const now = yield* Clock.currentTimeMillis;
553
-
554
- for (const [ref, offer] of offers) if (offer.expires <= now) offers.delete(ref);
555
- if (candidates.length > 16 || offers.size + candidates.length > 64)
556
- return yield* fail("limit");
557
- if (!sameTarget(control.target, (yield* target(decoded.target)).target))
558
- return yield* fail("stale-reference");
559
- const expires = now + 60_000;
560
- const result: Array<CredentialOffer> = [];
561
- const pending: typeof offers = new Map();
562
-
563
- for (const candidate of candidates) {
564
- const metadata = yield* decode(CredentialOfferMetadata, candidate.metadata);
565
-
566
- const ref = yield* crypto.randomUUIDv4.pipe(
567
- Effect.mapError(() => fail("provider")),
568
- );
569
-
570
- pending.set(ref, {
571
- caller: principal,
572
- key: candidate.key,
573
- target: control.target,
574
- targetRef: decoded.target,
575
- kind: decoded.kind,
576
- expires,
577
- });
578
- result.push(CredentialOffer.make({ ref, kind: decoded.kind, metadata }));
579
- }
580
- yield* bounded(result);
581
- for (const [ref, offer] of pending) offers.set(ref, offer);
582
-
583
- return result;
584
- }),
585
- ),
586
- useCredential: (request) =>
587
- run(
588
- Effect.gen(function* () {
589
- const decoded = yield* Schema.decodeEffect(UseCredential)(request).pipe(
590
- Effect.mapError(() => fail("denied")),
591
- );
671
+ yield* authorize;
672
+ yield* validate;
592
673
 
593
- const offer = offers.get(decoded.offer);
674
+ const raw = yield* access
675
+ .resolve(authorization)
676
+ .pipe(Effect.mapError((error) => fail(error.reason)));
594
677
 
595
- if (offer === undefined || offer.expires <= (yield* Clock.currentTimeMillis))
596
- return yield* fail("stale-reference");
597
- const principal = yield* caller;
678
+ const material = yield* Schema.decodeEffect(
679
+ offer.kind === "login" ? LoginCredential : CardCredential,
680
+ )(raw).pipe(Effect.mapError(() => fail("resolver")));
598
681
 
599
- if (Redacted.value(principal) !== Redacted.value(offer.caller))
600
- return yield* fail("denied");
601
- if (
602
- new Set(decoded.fields.map((field) => field.ref)).size !==
603
- decoded.fields.length ||
604
- new Set(decoded.fields.map((field) => field.role)).size !== decoded.fields.length
605
- )
606
- return yield* fail("denied");
607
-
608
- const validate = Effect.gen(function* () {
609
- if (!sameTarget(offer.target, (yield* target(offer.targetRef)).target))
610
- return yield* fail("stale-reference");
611
- for (const field of decoded.fields) {
612
- const current = yield* target(field.ref);
613
-
614
- if (
615
- field.role !== current.role ||
616
- kindFor(field.role) !== offer.kind ||
617
- !sameTarget(offer.target, current.target)
618
- )
619
- return yield* fail("denied");
620
- }
621
- if (decoded.submit !== undefined) {
622
- const submit = yield* target(decoded.submit);
623
-
624
- if (submit.role !== "submit" || !sameTarget(offer.target, submit.target))
625
- return yield* fail("denied");
626
- }
627
- });
628
-
629
- yield* validate;
630
-
631
- const authorization: CredentialUseAuthorization = {
632
- caller: principal,
633
- key: offer.key,
634
- kind: offer.kind,
635
- target: offer.target,
636
- roles: decoded.fields.map((field) => field.role),
637
- submit: decoded.submit !== undefined,
638
- };
639
-
640
- const authorize = Effect.gen(function* () {
641
- if (Redacted.value(yield* caller) !== Redacted.value(principal))
642
- return yield* fail("denied");
643
- yield* access
644
- .authorize(authorization)
645
- .pipe(Effect.mapError((error) => fail(error.reason)));
646
- if (Redacted.value(yield* caller) !== Redacted.value(principal))
647
- return yield* fail("denied");
648
- });
649
-
650
- yield* authorize;
651
- yield* validate;
652
-
653
- const raw = yield* access
654
- .resolve(authorization)
655
- .pipe(Effect.mapError((error) => fail(error.reason)));
656
-
657
- const material = yield* Schema.decodeEffect(
658
- offer.kind === "login" ? LoginCredential : CardCredential,
659
- )(raw).pipe(Effect.mapError(() => fail("resolver")));
660
-
661
- for (const field of decoded.fields)
662
- if (secretFor(material, field.role) === undefined)
663
- return yield* fail("missing-credential");
664
- offers.delete(decoded.offer);
665
- for (const field of decoded.fields) {
666
- yield* validate;
667
- yield* authorize;
668
- const value = secretFor(material, field.role);
669
-
670
- if (value === undefined) return yield* fail("missing-credential");
671
- observation = "protected";
672
- yield* remote(driver.fill(field.ref, field.role, value)).pipe(
673
- Effect.provideService(ProtectedBrowserDispatch, {
674
- mark: Effect.sync(() => {
675
- dispatch = "possibly-dispatched";
676
- if (!exposures.some((target) => sameTarget(target, offer.target)))
677
- exposures.push(offer.target);
678
- }),
679
- }),
680
- );
681
- dispatch = "dispatched";
682
- milestone = "partial-fill";
683
- }
684
- milestone = "filled";
685
- if (decoded.submit !== undefined) {
686
- yield* validate;
687
- yield* authorize;
688
- const ref = decoded.submit;
682
+ for (const field of decoded.fields)
683
+ if (secretFor(material, field.role) === undefined)
684
+ return yield* fail("missing-credential");
685
+ offers.delete(decoded.offer);
686
+ for (const field of decoded.fields) {
687
+ yield* validate;
688
+ yield* authorize;
689
+ const value = secretFor(material, field.role);
689
690
 
691
+ if (value === undefined) return yield* fail("missing-credential");
692
+ observation = "protected";
693
+ yield* remote(driver.fill(field.ref, field.role, value)).pipe(
694
+ Effect.provideService(ProtectedBrowserDispatch, {
695
+ mark: Effect.sync(() => {
690
696
  dispatch = "possibly-dispatched";
691
- yield* remote(driver.click(ref)).pipe(
692
- Effect.catch((error) => {
693
- if (error.reason === "needs-attention") dispatch = "dispatched";
694
-
695
- return Effect.fail(error);
696
- }),
697
- );
698
- dispatch = "dispatched";
699
- milestone = "submission-dispatched";
700
- }
701
- yield* permitObservation;
702
-
703
- return CredentialUseResult.make({
704
- dispatch,
705
- milestone,
706
- observation,
707
- cleanup,
708
- authentication: "unverified",
709
- });
697
+ if (!exposures.some((target) => sameTarget(target, offer.target)))
698
+ exposures.push(offer.target);
699
+ }),
710
700
  }),
711
- ),
712
- };
713
- }, Effect.withTracerEnabled(false));
701
+ );
702
+ dispatch = "dispatched";
703
+ milestone = "partial-fill";
704
+ }
705
+ milestone = "filled";
706
+ if (decoded.submit !== undefined) {
707
+ yield* validate;
708
+ yield* authorize;
709
+ const ref = decoded.submit;
710
+
711
+ dispatch = "possibly-dispatched";
712
+ yield* remote(driver.click(ref)).pipe(
713
+ Effect.catch((error) => {
714
+ if (error.reason === "needs-attention") dispatch = "dispatched";
715
+
716
+ return Effect.fail(error);
717
+ }),
718
+ );
719
+ dispatch = "dispatched";
720
+ milestone = "submission-dispatched";
721
+ }
722
+ yield* permitObservation;
723
+
724
+ return CredentialUseResult.make({
725
+ dispatch,
726
+ milestone,
727
+ observation,
728
+ cleanup,
729
+ authentication: "unverified",
730
+ });
731
+ }),
732
+ ),
733
+ };
734
+
735
+ const locked = <A>(effect: Effect.Effect<A, ProtectedBrowserError>) =>
736
+ lock
737
+ .withPermitsIfAvailable(1)(effect)
738
+ .pipe(Effect.flatMap(Effect.fromOption(() => fail("busy"))));
739
+
740
+ return {
741
+ handle,
742
+ suspend: (human = false) =>
743
+ locked(
744
+ Effect.gen(function* () {
745
+ if (detached || observation === "closed") return yield* fail("closed");
746
+ if (dispatch === "possibly-dispatched") return yield* fail("outcome-unknown");
747
+ if ((yield* Clock.currentTimeMillis) - started >= policy.maxElapsedMillis)
748
+ return yield* fail("timeout");
749
+ suspended = true;
750
+ if (human) {
751
+ const context = yield* pageContext;
752
+
753
+ const origins = yield* Schema.decodeEffect(
754
+ ProtectedBrowserCheckpoint.fields.humanOrigins,
755
+ )([...new Set([...humanOrigins, context.topOrigin, ...context.frameOrigins])]).pipe(
756
+ Effect.catch(() => fail("needs-attention")),
757
+ );
758
+
759
+ humanExposure = true;
760
+ humanOrigins = [...origins];
761
+ observation = "protected";
762
+ }
763
+ driver.resetReferences();
764
+ offers.clear();
765
+
766
+ return ProtectedBrowserCheckpoint.make({
767
+ policy,
768
+ startedAt: started,
769
+ actions,
770
+ exposures: [...exposures],
771
+ humanExposure,
772
+ humanOrigins,
773
+ dispatch,
774
+ milestone,
775
+ });
776
+ }),
777
+ ),
778
+ returnControl: locked(
779
+ Effect.gen(function* () {
780
+ if (detached || observation === "closed") return yield* fail("closed");
781
+ if (!suspended) return yield* fail("denied");
782
+ yield* permitObservation;
783
+ offers.clear();
784
+ driver.resetReferences();
785
+ needsObservation = true;
786
+ suspended = false;
787
+ }),
788
+ ),
789
+ detach: locked(
790
+ Effect.gen(function* () {
791
+ if (!suspended || observation === "closed") return yield* fail("denied");
792
+ if (driver.detach === undefined) return yield* fail("unsupported");
793
+ yield* driver.detach.pipe(
794
+ Effect.onError(() => close),
795
+ Effect.onInterrupt(() => close),
796
+ );
797
+ detached = true;
798
+ offers.clear();
799
+ driver.invalidate();
800
+ }),
801
+ ),
802
+ };
803
+ }, Effect.withTracerEnabled(false));
804
+
805
+ /** Fresh private passes; scoped release confirms exact-session termination. */
806
+ export const browserRunProtectedLayer = () =>
807
+ Layer.effect(ProtectedBrowser)(
808
+ Effect.gen(function* () {
809
+ const transport = yield* BrowserRunProtectedTransport;
810
+ const crypto = yield* Crypto.Crypto;
714
811
 
715
- return { open };
812
+ return {
813
+ open: (policy: InteractiveBrowserPolicy) =>
814
+ makeProtectedBrowserPolicy(policy).pipe(
815
+ Effect.map((session) => session.handle),
816
+ Effect.provideService(BrowserRunProtectedTransport, transport),
817
+ Effect.provideService(Crypto.Crypto, crypto),
818
+ ),
819
+ };
716
820
  }),
717
821
  );