@effect-agent/platform-cloudflare 0.1.0-beta.8 → 0.1.0-beta.80

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.
Files changed (100) hide show
  1. package/dist/Alarm.d.mts +183 -0
  2. package/dist/Alarm.mjs +454 -0
  3. package/dist/Alarm.mjs.map +1 -0
  4. package/dist/BrowserRestCapture.d.mts +35 -0
  5. package/dist/BrowserRestCapture.mjs +238 -0
  6. package/dist/BrowserRestCapture.mjs.map +1 -0
  7. package/dist/BrowserRestCrawl.d.mts +17 -0
  8. package/dist/BrowserRestCrawl.mjs +244 -0
  9. package/dist/BrowserRestCrawl.mjs.map +1 -0
  10. package/dist/CloudflareAiGateway.d.mts +64 -0
  11. package/dist/CloudflareAiGateway.mjs +70 -0
  12. package/dist/CloudflareAiGateway.mjs.map +1 -0
  13. package/dist/CloudflareBindings.d.mts +112 -0
  14. package/dist/CloudflareBindings.mjs +101 -0
  15. package/dist/CloudflareBindings.mjs.map +1 -0
  16. package/dist/CloudflareBrowser-D4YqaTAl.mjs +495 -0
  17. package/dist/CloudflareBrowser-D4YqaTAl.mjs.map +1 -0
  18. package/dist/CloudflareBrowser-DJq53niJ.d.mts +80 -0
  19. package/dist/CloudflareBrowser.d.mts +2 -0
  20. package/dist/CloudflareBrowser.mjs +2 -0
  21. package/dist/CloudflareCodeMode.d.mts +44 -0
  22. package/dist/CloudflareCodeMode.mjs +616 -0
  23. package/dist/CloudflareCodeMode.mjs.map +1 -0
  24. package/dist/CloudflareConfig-f3CqTel1.d.mts +101 -0
  25. package/dist/CloudflareConfig.d.mts +2 -0
  26. package/dist/CloudflareConfig.mjs +122 -0
  27. package/dist/CloudflareConfig.mjs.map +1 -0
  28. package/dist/CloudflareMemory.d.mts +142 -0
  29. package/dist/CloudflareMemory.mjs +195 -0
  30. package/dist/CloudflareMemory.mjs.map +1 -0
  31. package/dist/CloudflareScheduling.d.mts +52 -0
  32. package/dist/CloudflareScheduling.mjs +389 -0
  33. package/dist/CloudflareScheduling.mjs.map +1 -0
  34. package/dist/CloudflareSubscriptions.d.mts +87 -0
  35. package/dist/CloudflareSubscriptions.mjs +524 -0
  36. package/dist/CloudflareSubscriptions.mjs.map +1 -0
  37. package/dist/CloudflareThreadClient.d.mts +1286 -0
  38. package/dist/CloudflareThreadClient.mjs +386 -0
  39. package/dist/CloudflareThreadClient.mjs.map +1 -0
  40. package/dist/InteractiveBrowser.d.mts +144 -0
  41. package/dist/InteractiveBrowser.mjs +1090 -0
  42. package/dist/InteractiveBrowser.mjs.map +1 -0
  43. package/dist/ProtectedBrowser.d.mts +65 -0
  44. package/dist/ProtectedBrowser.mjs +859 -0
  45. package/dist/ProtectedBrowser.mjs.map +1 -0
  46. package/dist/ThreadObject-3uDpKfB3.d.mts +816 -0
  47. package/dist/ThreadObject-DGkXv41C.mjs +836 -0
  48. package/dist/ThreadObject-DGkXv41C.mjs.map +1 -0
  49. package/dist/ThreadObject.d.mts +2 -0
  50. package/dist/ThreadObject.mjs +3 -0
  51. package/dist/WakeScheduler.d.mts +23 -0
  52. package/dist/WakeScheduler.mjs +60 -0
  53. package/dist/WakeScheduler.mjs.map +1 -0
  54. package/dist/boundary-BguazVkh.mjs +42 -0
  55. package/dist/boundary-BguazVkh.mjs.map +1 -0
  56. package/dist/browser-session-lifecycle-CUbVfEgP.mjs +84 -0
  57. package/dist/browser-session-lifecycle-CUbVfEgP.mjs.map +1 -0
  58. package/dist/browser-session-lifecycle-DJpqLXy_.d.mts +21 -0
  59. package/dist/index.d.mts +13 -1547
  60. package/dist/index.mjs +13 -1636
  61. package/dist/prepared-admission-DSi55W-7.mjs +73 -0
  62. package/dist/prepared-admission-DSi55W-7.mjs.map +1 -0
  63. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  64. package/package.json +1 -53
  65. package/src/Alarm.ts +953 -0
  66. package/src/BrowserRestCapture.ts +477 -0
  67. package/src/BrowserRestCrawl.ts +540 -0
  68. package/src/CloudflareAiGateway.ts +170 -0
  69. package/src/CloudflareBindings.ts +180 -0
  70. package/src/CloudflareBrowser.ts +15 -0
  71. package/src/{code-mode-executor.ts → CloudflareCodeMode.ts} +372 -204
  72. package/src/{config.ts → CloudflareConfig.ts} +9 -8
  73. package/src/CloudflareMemory.ts +406 -0
  74. package/src/CloudflareScheduling.ts +733 -0
  75. package/src/CloudflareSubscriptions.ts +1045 -0
  76. package/src/CloudflareThreadClient.ts +807 -0
  77. package/src/InteractiveBrowser.ts +2357 -0
  78. package/src/ProtectedBrowser.ts +2 -0
  79. package/src/ThreadObject.ts +1179 -0
  80. package/src/{wake-scheduler.ts → WakeScheduler.ts} +23 -23
  81. package/src/index.ts +12 -23
  82. package/src/internal/boundary.ts +55 -0
  83. package/src/internal/browser-quick-action.ts +857 -0
  84. package/src/internal/browser-session-lifecycle.ts +160 -0
  85. package/src/internal/layers.ts +751 -0
  86. package/src/internal/message-delivery.ts +148 -0
  87. package/src/internal/prepared-admission.ts +116 -0
  88. package/src/internal/progress-wait.ts +121 -0
  89. package/src/internal/transport.ts +42 -0
  90. package/src/protected-browser/binding.ts +187 -0
  91. package/src/protected-browser/inspect-frame.ts +125 -0
  92. package/src/protected-browser/native.ts +429 -0
  93. package/src/protected-browser/policy.ts +719 -0
  94. package/dist/index.mjs.map +0 -1
  95. package/src/alarm.ts +0 -334
  96. package/src/bindings.ts +0 -145
  97. package/src/client.ts +0 -646
  98. package/src/conversation-object.ts +0 -768
  99. package/src/layers.ts +0 -376
  100. package/src/transport.ts +0 -38
@@ -0,0 +1,719 @@
1
+ import { InteractiveBrowserPolicy } from "@effect-agent/sandbox/InteractiveBrowser";
2
+ /// <reference types="@cloudflare/workers-types" />
3
+ import {
4
+ BrowserCredentialAccess,
5
+ type BrowserCredentialMaterial,
6
+ CardCredential,
7
+ CredentialOffer,
8
+ CredentialOfferMetadata,
9
+ CredentialOrigin,
10
+ CredentialObservationDecision,
11
+ type CredentialTarget,
12
+ CredentialUseResult,
13
+ ListCredentialOffers,
14
+ LoginCredential,
15
+ ProtectedBrowser,
16
+ ProtectedBrowserClick,
17
+ ProtectedBrowserError,
18
+ ProtectedBrowserFill,
19
+ ProtectedBrowserNavigate,
20
+ ProtectedBrowserObservation,
21
+ ProtectedBrowserControl,
22
+ UseCredential,
23
+ type CredentialFieldRole,
24
+ type CredentialKind,
25
+ type CredentialUseAuthorization,
26
+ type ProtectedBrowserHandle,
27
+ type ProtectedBrowserAction,
28
+ type ProtectedCleanup,
29
+ type ProtectedObservationState,
30
+ } from "@effect-agent/sandbox/ProtectedBrowser";
31
+ import {
32
+ Clock,
33
+ Context,
34
+ Crypto,
35
+ Effect,
36
+ Layer,
37
+ Option,
38
+ Redacted,
39
+ Schema,
40
+ Semaphore,
41
+ type Scope,
42
+ } from "effect";
43
+
44
+ export class ProtectedTransportError extends Schema.TaggedError<ProtectedTransportError>()(
45
+ "ProtectedTransportError",
46
+ { reason: Schema.Literals(["stale-reference", "needs-attention", "unsupported", "provider"]) },
47
+ ) {}
48
+
49
+ /** Per-write evidence authority, provided by the policy immediately around a transport fill. */
50
+ export class ProtectedBrowserDispatch extends Context.Service<
51
+ ProtectedBrowserDispatch,
52
+ { readonly mark: Effect.Effect<void> }
53
+ >()("@effect-agent/platform-cloudflare/ProtectedBrowserDispatch") {}
54
+
55
+ /** Decoded adapter boundary. SDK exceptions and page diagnostics never cross this port. */
56
+ export interface ProtectedBrowserTransport {
57
+ /** Select observation/target origins; undefined restores all network-permitted HTTPS frames. */
58
+ readonly restrictObservation: (
59
+ origins: ReadonlyArray<typeof CredentialOrigin.Type> | undefined,
60
+ ) => Effect.Effect<void, ProtectedTransportError>;
61
+ readonly context: Effect.Effect<typeof ProtectedPageContext.Type, ProtectedTransportError>;
62
+ readonly discover: Effect.Effect<typeof ProtectedDiscovery.Type, ProtectedTransportError>;
63
+ readonly target: (ref: string) => Effect.Effect<ProtectedBrowserControl, ProtectedTransportError>;
64
+ readonly navigate: (url: string) => Effect.Effect<void, ProtectedTransportError>;
65
+ readonly click: (ref: string) => Effect.Effect<void, ProtectedTransportError>;
66
+ readonly fill: (
67
+ ref: string,
68
+ role: typeof CredentialFieldRole.Type | "text" | "select",
69
+ value: Redacted.Redacted<string>,
70
+ ) => Effect.Effect<void, ProtectedTransportError, ProtectedBrowserDispatch>;
71
+ /** Invalidates local references synchronously before bounded exact-session cleanup. */
72
+ readonly invalidate: () => void;
73
+ readonly close: Effect.Effect<typeof ProtectedCleanup.Type>;
74
+ }
75
+
76
+ /** Adapter-private injection seam for deterministic tests, not a model capability. */
77
+ export class BrowserRunProtectedTransport extends Context.Service<
78
+ BrowserRunProtectedTransport,
79
+ {
80
+ readonly open: (
81
+ policy: InteractiveBrowserPolicy,
82
+ ) => Effect.Effect<ProtectedBrowserTransport, ProtectedBrowserError, Scope.Scope>;
83
+ }
84
+ >()("@effect-agent/platform-cloudflare/BrowserRunProtectedTransport") {}
85
+
86
+ export const ProtectedPageContext = Schema.Struct({
87
+ document: Schema.String.check(Schema.isUUID()),
88
+ topOrigin: CredentialOrigin,
89
+ frameOrigins: Schema.Array(CredentialOrigin).check(Schema.isMaxLength(16)),
90
+ });
91
+
92
+ export const ProtectedDiscovery = Schema.Struct({
93
+ ...ProtectedPageContext.fields,
94
+ text: Schema.String.check(Schema.isMaxLength(64 * 1024)),
95
+ controls: Schema.Array(ProtectedBrowserControl).check(Schema.isMaxLength(64)),
96
+ truncated: Schema.Boolean,
97
+ });
98
+
99
+ const sameTarget = (a: CredentialTarget, b: CredentialTarget) =>
100
+ a.topOrigin === b.topOrigin &&
101
+ a.frameOrigin === b.frameOrigin &&
102
+ a.recipientOrigin === b.recipientOrigin &&
103
+ a.document === b.document &&
104
+ a.frame === b.frame &&
105
+ a.form === b.form;
106
+
107
+ const kindFor = (role: string): typeof CredentialKind.Type | undefined =>
108
+ role === "username" || role === "password"
109
+ ? "login"
110
+ : role.startsWith("card-")
111
+ ? "card"
112
+ : undefined;
113
+
114
+ const secretFor = (material: BrowserCredentialMaterial, role: typeof CredentialFieldRole.Type) => {
115
+ if (material._tag === "LoginCredential") {
116
+ return role === "username"
117
+ ? material.username
118
+ : role === "password"
119
+ ? material.password
120
+ : undefined;
121
+ }
122
+ switch (role) {
123
+ case "card-name":
124
+ return material.name;
125
+ case "card-number":
126
+ return material.number;
127
+ case "card-expiry":
128
+ return material.expiry;
129
+ case "card-expiry-month":
130
+ return material.expiryMonth;
131
+ case "card-expiry-year":
132
+ return material.expiryYear;
133
+ case "card-security-code":
134
+ return material.securityCode;
135
+ case "username":
136
+ case "password":
137
+ return undefined;
138
+ }
139
+ };
140
+
141
+ /**
142
+ * Fresh private passes only. Account administrators and Browser Rendering token holders are
143
+ * trusted operators. No viewer, handoff, raw JavaScript, screenshot or plaintext credential API exists.
144
+ * Hosts explicitly authorize post-exposure observations for recipients they trust not to echo.
145
+ */
146
+ export const browserRunProtectedLayer = () =>
147
+ Layer.effect(ProtectedBrowser)(
148
+ Effect.gen(function* () {
149
+ const transport = yield* BrowserRunProtectedTransport;
150
+ const crypto = yield* Crypto.Crypto;
151
+
152
+ const open = Effect.fn("ProtectedBrowser.open")(function* (
153
+ input: InteractiveBrowserPolicy,
154
+ ): Effect.fn.Return<
155
+ ProtectedBrowserHandle,
156
+ ProtectedBrowserError,
157
+ Scope.Scope | BrowserCredentialAccess
158
+ > {
159
+ const initialError = (reason: ProtectedBrowserError["reason"]) =>
160
+ new ProtectedBrowserError({
161
+ reason,
162
+ dispatch: "not-dispatched",
163
+ milestone: "none",
164
+ observation: "closed",
165
+ cleanup: "not-requested",
166
+ });
167
+
168
+ const decodedPolicy = yield* Schema.decodeUnknownEffect(InteractiveBrowserPolicy)(
169
+ input,
170
+ ).pipe(Effect.mapError(() => initialError("denied")));
171
+
172
+ if (decodedPolicy.network._tag === "PublicWeb") return yield* initialError("unsupported");
173
+
174
+ const policy = InteractiveBrowserPolicy.make({
175
+ ...decodedPolicy,
176
+ network:
177
+ decodedPolicy.network._tag === "ExactHosts"
178
+ ? { _tag: "ExactHosts", allowedHosts: [...decodedPolicy.network.allowedHosts] }
179
+ : { _tag: "Unrestricted" },
180
+ });
181
+
182
+ // Capture the host access service for THIS execution, not the application singleton.
183
+ const access = yield* BrowserCredentialAccess;
184
+ const started = yield* Clock.currentTimeMillis;
185
+ const driver = yield* transport.open(policy);
186
+ const lock = yield* Semaphore.make(1);
187
+ let observation: typeof ProtectedObservationState.Type = "before-exposure";
188
+ let cleanup: typeof ProtectedCleanup.Type = "not-requested";
189
+ let actions = 0;
190
+ let dispatch: ProtectedBrowserError["dispatch"] = "not-dispatched";
191
+ let milestone: ProtectedBrowserError["milestone"] = "none";
192
+ const exposures: Array<CredentialTarget> = [];
193
+
194
+ const offers = new Map<
195
+ string,
196
+ {
197
+ caller: Redacted.Redacted<string>;
198
+ key: Redacted.Redacted<string>;
199
+ target: CredentialTarget;
200
+ targetRef: string;
201
+ kind: typeof CredentialKind.Type;
202
+ expires: number;
203
+ }
204
+ >();
205
+
206
+ const fail = (reason: ProtectedBrowserError["reason"]) =>
207
+ new ProtectedBrowserError({ reason, dispatch, milestone, observation, cleanup });
208
+
209
+ const close = yield* Effect.cached(
210
+ Effect.uninterruptible(
211
+ Effect.gen(function* () {
212
+ observation = "closed";
213
+ offers.clear();
214
+ driver.invalidate();
215
+ cleanup = yield* driver.close.pipe(
216
+ Effect.interruptible,
217
+ Effect.timeoutOrElse({
218
+ duration: "10 seconds",
219
+ orElse: () => Effect.succeed("unconfirmed" as const),
220
+ }),
221
+ Effect.catchCause(() => Effect.succeed("unconfirmed" as const)),
222
+ );
223
+
224
+ return cleanup;
225
+ }),
226
+ ),
227
+ );
228
+
229
+ yield* Effect.addFinalizer(() =>
230
+ close.pipe(
231
+ Effect.flatMap((result) =>
232
+ result === "unconfirmed"
233
+ ? Effect.logWarning("Protected browser exact-session closure unconfirmed")
234
+ : Effect.void,
235
+ ),
236
+ ),
237
+ );
238
+
239
+ const remote = <A, R>(effect: Effect.Effect<A, ProtectedTransportError, R>) =>
240
+ effect.pipe(Effect.mapError((error) => fail(error.reason)));
241
+
242
+ const decode = <A>(schema: Schema.Codec<A>, value: unknown) =>
243
+ Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(() => fail("provider")));
244
+
245
+ const caller = access.caller.pipe(Effect.mapError((error) => fail(error.reason)));
246
+ const pageContext = remote(driver.context);
247
+
248
+ const permitObservation = Effect.gen(function* () {
249
+ const context = yield* pageContext;
250
+ let origins: ReadonlyArray<typeof CredentialOrigin.Type> | undefined;
251
+
252
+ if (exposures.length > 0) {
253
+ observation = "protected";
254
+ const principal = yield* caller;
255
+
256
+ const rawDecision = yield* access
257
+ .observation({ ...context, caller: principal, exposures: [...exposures] })
258
+ .pipe(Effect.mapError((error) => fail(error.reason)));
259
+
260
+ if (Redacted.value(yield* caller) !== Redacted.value(principal))
261
+ return yield* fail("denied");
262
+
263
+ const decision = yield* Schema.decodeUnknownEffect(CredentialObservationDecision)(
264
+ rawDecision,
265
+ ).pipe(Effect.mapError(() => fail("observation-blocked")));
266
+
267
+ if (decision === "deny") return yield* fail("observation-blocked");
268
+ if (typeof decision !== "string") {
269
+ origins = [...decision.origins];
270
+ if (!origins.includes(context.topOrigin)) return yield* fail("observation-blocked");
271
+ }
272
+ observation = "approved-after-exposure";
273
+ }
274
+ yield* remote(driver.restrictObservation(origins));
275
+
276
+ return {
277
+ ...context,
278
+ frameOrigins: context.frameOrigins.filter(
279
+ (origin) => origins === undefined || origins.includes(origin),
280
+ ),
281
+ };
282
+ });
283
+
284
+ const target = (ref: string) => remote(driver.target(ref));
285
+
286
+ const authorizeAction = Effect.fn("ProtectedBrowser.authorizeAction")(function* (
287
+ action: ProtectedBrowserAction,
288
+ ) {
289
+ if (access.authorizeAction === undefined) {
290
+ if (action._tag === "Submit") return yield* fail("unsupported");
291
+
292
+ return;
293
+ }
294
+ const principal = yield* caller;
295
+
296
+ yield* access
297
+ .authorizeAction({ caller: principal, action, exposures: [...exposures] })
298
+ .pipe(Effect.mapError((error) => fail(error.reason)));
299
+ if (Redacted.value(yield* caller) !== Redacted.value(principal))
300
+ return yield* fail("denied");
301
+ if (action._tag !== "Navigate") {
302
+ const current = yield* target(action.ref);
303
+
304
+ if (
305
+ !sameTarget(current.target, action.target) ||
306
+ current.role !== (action._tag === "Submit" ? "submit" : action.role) ||
307
+ (action._tag === "Click" && action.role === "link" && current.url !== action.url)
308
+ )
309
+ return yield* fail("stale-reference");
310
+ }
311
+ }, Effect.withTracerEnabled(false));
312
+
313
+ const bounded = <A>(result: A) => {
314
+ const bytes = new TextEncoder().encode(JSON.stringify(result)).byteLength;
315
+
316
+ return bytes <= policy.maxReturnedBytes
317
+ ? Effect.succeed(result)
318
+ : Effect.fail(fail("limit"));
319
+ };
320
+
321
+ const run = <A>(effect: Effect.Effect<A, ProtectedBrowserError>) =>
322
+ lock
323
+ .withPermitsIfAvailable(1)(
324
+ Effect.gen(function* () {
325
+ dispatch = "not-dispatched";
326
+ milestone = "none";
327
+ if (observation === "closed") return yield* fail("closed");
328
+ if (++actions > policy.maxActions) return yield* fail("limit");
329
+
330
+ const remaining =
331
+ policy.maxElapsedMillis - ((yield* Clock.currentTimeMillis) - started);
332
+
333
+ if (remaining <= 0) {
334
+ yield* close;
335
+
336
+ return yield* fail("timeout");
337
+ }
338
+
339
+ return yield* effect.pipe(
340
+ Effect.timeoutOrElse({
341
+ duration: remaining,
342
+ orElse: () => Effect.fail(fail("timeout")),
343
+ }),
344
+ Effect.catchCause((cause) =>
345
+ Effect.gen(function* () {
346
+ // No foreign exception or defect is retained. Interrupted operations still finalize.
347
+ const error = cause.reasons.find(
348
+ (reason) =>
349
+ reason._tag === "Fail" && Schema.is(ProtectedBrowserError)(reason.error),
350
+ );
351
+
352
+ const reason =
353
+ error?._tag === "Fail" && Schema.is(ProtectedBrowserError)(error.error)
354
+ ? error.error.reason
355
+ : "provider";
356
+
357
+ const interrupt = cause.reasons.some((reason) => reason._tag === "Interrupt");
358
+
359
+ if (
360
+ dispatch !== "not-dispatched" ||
361
+ interrupt ||
362
+ reason === "provider" ||
363
+ reason === "timeout"
364
+ )
365
+ yield* close;
366
+ if (interrupt) return yield* Effect.interrupt;
367
+
368
+ return yield* fail(
369
+ dispatch === "possibly-dispatched" && reason === "provider"
370
+ ? "outcome-unknown"
371
+ : reason,
372
+ );
373
+ }),
374
+ ),
375
+ Effect.onInterrupt(() => close),
376
+ Effect.withTracerEnabled(false),
377
+ );
378
+ }),
379
+ )
380
+ .pipe(
381
+ Effect.flatMap((result) =>
382
+ Option.isSome(result)
383
+ ? Effect.succeed(result.value)
384
+ : Effect.fail(
385
+ new ProtectedBrowserError({
386
+ reason: "busy",
387
+ dispatch: "not-dispatched",
388
+ milestone: "none",
389
+ observation,
390
+ cleanup,
391
+ }),
392
+ ),
393
+ ),
394
+ );
395
+
396
+ return {
397
+ close,
398
+ navigate: (request) =>
399
+ run(
400
+ Effect.gen(function* () {
401
+ const decoded = yield* Schema.decodeUnknownEffect(ProtectedBrowserNavigate)(
402
+ request,
403
+ ).pipe(Effect.mapError(() => fail("denied")));
404
+
405
+ let url: URL;
406
+
407
+ try {
408
+ url = new URL(decoded.url);
409
+ } catch {
410
+ return yield* fail("denied");
411
+ }
412
+ if (
413
+ url.protocol !== "https:" ||
414
+ url.username ||
415
+ url.password ||
416
+ (policy.network._tag === "ExactHosts" &&
417
+ !policy.network.allowedHosts.includes(url.host))
418
+ )
419
+ return yield* fail("denied");
420
+ if (exposures.length > 0) yield* permitObservation;
421
+ yield* authorizeAction({ _tag: "Navigate", url: decoded.url });
422
+ offers.clear();
423
+ dispatch = "possibly-dispatched";
424
+ yield* remote(driver.navigate(decoded.url));
425
+ dispatch = "dispatched";
426
+ yield* permitObservation;
427
+ }),
428
+ ),
429
+ observe: run(
430
+ Effect.gen(function* () {
431
+ const before = yield* permitObservation;
432
+ const result = yield* remote(driver.discover);
433
+ const after = yield* permitObservation;
434
+
435
+ if (
436
+ before.document !== after.document ||
437
+ result.document !== after.document ||
438
+ result.topOrigin !== after.topOrigin
439
+ )
440
+ return yield* fail("stale-reference");
441
+ if (result.frameOrigins.some((origin) => !after.frameOrigins.includes(origin)))
442
+ return yield* fail("observation-blocked");
443
+
444
+ return yield* bounded(
445
+ ProtectedBrowserObservation.make({
446
+ ...result,
447
+ observation: exposures.length > 0 ? "approved-after-exposure" : "before-exposure",
448
+ }),
449
+ );
450
+ }),
451
+ ),
452
+ click: (request) =>
453
+ run(
454
+ Effect.gen(function* () {
455
+ const decoded = yield* Schema.decodeUnknownEffect(ProtectedBrowserClick)(
456
+ request,
457
+ ).pipe(Effect.mapError(() => fail("denied")));
458
+
459
+ yield* permitObservation;
460
+ const control = yield* target(decoded.ref);
461
+
462
+ if (
463
+ control.role !== "link" &&
464
+ control.role !== "button" &&
465
+ control.role !== "radio" &&
466
+ control.role !== "checkbox" &&
467
+ control.role !== "submit"
468
+ )
469
+ return yield* fail("unsupported");
470
+ let action: ProtectedBrowserAction;
471
+
472
+ if (control.role === "link") {
473
+ if (control.url === undefined) return yield* fail("unsupported");
474
+ action = {
475
+ _tag: "Click",
476
+ ref: decoded.ref,
477
+ target: control.target,
478
+ role: "link",
479
+ url: control.url,
480
+ };
481
+ } else if (control.role === "submit") {
482
+ action = { _tag: "Submit", ref: decoded.ref, target: control.target };
483
+ } else {
484
+ action = {
485
+ _tag: "Click",
486
+ ref: decoded.ref,
487
+ target: control.target,
488
+ role: control.role,
489
+ };
490
+ }
491
+ yield* authorizeAction(action);
492
+ dispatch = "possibly-dispatched";
493
+ yield* remote(driver.click(decoded.ref)).pipe(
494
+ Effect.catch((error) => {
495
+ if (error.reason === "needs-attention") dispatch = "not-dispatched";
496
+
497
+ return Effect.fail(error);
498
+ }),
499
+ );
500
+ dispatch = "dispatched";
501
+ if (control.role === "submit") milestone = "submission-dispatched";
502
+ yield* permitObservation;
503
+ }),
504
+ ),
505
+ fill: (request) =>
506
+ run(
507
+ Effect.gen(function* () {
508
+ const decoded = yield* Schema.decodeUnknownEffect(ProtectedBrowserFill)(
509
+ request,
510
+ ).pipe(Effect.mapError(() => fail("denied")));
511
+
512
+ yield* permitObservation;
513
+ const control = yield* target(decoded.ref);
514
+
515
+ if (control.role !== "text" && control.role !== "select")
516
+ return yield* fail("unsupported");
517
+ yield* authorizeAction({
518
+ _tag: "Fill",
519
+ ref: decoded.ref,
520
+ target: control.target,
521
+ role: control.role,
522
+ });
523
+ yield* remote(
524
+ driver.fill(decoded.ref, control.role, Redacted.make(decoded.value)),
525
+ ).pipe(
526
+ Effect.provideService(ProtectedBrowserDispatch, {
527
+ mark: Effect.sync(() => {
528
+ dispatch = "possibly-dispatched";
529
+ }),
530
+ }),
531
+ );
532
+ dispatch = "dispatched";
533
+ milestone = "filled";
534
+ yield* permitObservation;
535
+ }),
536
+ ),
537
+ listCredentialOffers: (request) =>
538
+ run(
539
+ Effect.gen(function* () {
540
+ const decoded = yield* Schema.decodeUnknownEffect(ListCredentialOffers)(
541
+ request,
542
+ ).pipe(Effect.mapError(() => fail("denied")));
543
+
544
+ yield* permitObservation;
545
+ const control = yield* target(decoded.target);
546
+
547
+ if (kindFor(control.role) !== decoded.kind) return yield* fail("unsupported");
548
+ const principal = yield* caller;
549
+
550
+ const candidates = yield* access
551
+ .list({ caller: principal, kind: decoded.kind, target: control.target })
552
+ .pipe(Effect.mapError((error) => fail(error.reason)));
553
+
554
+ const now = yield* Clock.currentTimeMillis;
555
+
556
+ for (const [ref, offer] of offers) if (offer.expires <= now) offers.delete(ref);
557
+ if (candidates.length > 16 || offers.size + candidates.length > 64)
558
+ return yield* fail("limit");
559
+ if (!sameTarget(control.target, (yield* target(decoded.target)).target))
560
+ return yield* fail("stale-reference");
561
+ const expires = now + 60_000;
562
+ const result: Array<CredentialOffer> = [];
563
+ const pending: typeof offers = new Map();
564
+
565
+ for (const candidate of candidates) {
566
+ const metadata = yield* decode(CredentialOfferMetadata, candidate.metadata);
567
+
568
+ const ref = yield* crypto.randomUUIDv4.pipe(
569
+ Effect.mapError(() => fail("provider")),
570
+ );
571
+
572
+ pending.set(ref, {
573
+ caller: principal,
574
+ key: candidate.key,
575
+ target: control.target,
576
+ targetRef: decoded.target,
577
+ kind: decoded.kind,
578
+ expires,
579
+ });
580
+ result.push(CredentialOffer.make({ ref, kind: decoded.kind, metadata }));
581
+ }
582
+ yield* bounded(result);
583
+ for (const [ref, offer] of pending) offers.set(ref, offer);
584
+
585
+ return result;
586
+ }),
587
+ ),
588
+ useCredential: (request) =>
589
+ run(
590
+ Effect.gen(function* () {
591
+ const decoded = yield* Schema.decodeUnknownEffect(UseCredential)(request).pipe(
592
+ Effect.mapError(() => fail("denied")),
593
+ );
594
+
595
+ const offer = offers.get(decoded.offer);
596
+
597
+ if (offer === undefined || offer.expires <= (yield* Clock.currentTimeMillis))
598
+ return yield* fail("stale-reference");
599
+ const principal = yield* caller;
600
+
601
+ if (Redacted.value(principal) !== Redacted.value(offer.caller))
602
+ return yield* fail("denied");
603
+ if (
604
+ new Set(decoded.fields.map((field) => field.ref)).size !==
605
+ decoded.fields.length ||
606
+ new Set(decoded.fields.map((field) => field.role)).size !== decoded.fields.length
607
+ )
608
+ return yield* fail("denied");
609
+
610
+ const validate = Effect.gen(function* () {
611
+ if (!sameTarget(offer.target, (yield* target(offer.targetRef)).target))
612
+ return yield* fail("stale-reference");
613
+ for (const field of decoded.fields) {
614
+ const current = yield* target(field.ref);
615
+
616
+ if (
617
+ field.role !== current.role ||
618
+ kindFor(field.role) !== offer.kind ||
619
+ !sameTarget(offer.target, current.target)
620
+ )
621
+ return yield* fail("denied");
622
+ }
623
+ if (decoded.submit !== undefined) {
624
+ const submit = yield* target(decoded.submit);
625
+
626
+ if (submit.role !== "submit" || !sameTarget(offer.target, submit.target))
627
+ return yield* fail("denied");
628
+ }
629
+ });
630
+
631
+ yield* validate;
632
+
633
+ const authorization: CredentialUseAuthorization = {
634
+ caller: principal,
635
+ key: offer.key,
636
+ kind: offer.kind,
637
+ target: offer.target,
638
+ roles: decoded.fields.map((field) => field.role),
639
+ submit: decoded.submit !== undefined,
640
+ };
641
+
642
+ const authorize = Effect.gen(function* () {
643
+ if (Redacted.value(yield* caller) !== Redacted.value(principal))
644
+ return yield* fail("denied");
645
+ yield* access
646
+ .authorize(authorization)
647
+ .pipe(Effect.mapError((error) => fail(error.reason)));
648
+ if (Redacted.value(yield* caller) !== Redacted.value(principal))
649
+ return yield* fail("denied");
650
+ });
651
+
652
+ yield* authorize;
653
+ yield* validate;
654
+
655
+ const raw = yield* access
656
+ .resolve(authorization)
657
+ .pipe(Effect.mapError((error) => fail(error.reason)));
658
+
659
+ const material = yield* Schema.decodeUnknownEffect(
660
+ offer.kind === "login" ? LoginCredential : CardCredential,
661
+ )(raw).pipe(Effect.mapError(() => fail("resolver")));
662
+
663
+ for (const field of decoded.fields)
664
+ if (secretFor(material, field.role) === undefined)
665
+ return yield* fail("missing-credential");
666
+ offers.delete(decoded.offer);
667
+ for (const field of decoded.fields) {
668
+ yield* validate;
669
+ yield* authorize;
670
+ const value = secretFor(material, field.role);
671
+
672
+ if (value === undefined) return yield* fail("missing-credential");
673
+ observation = "protected";
674
+ yield* remote(driver.fill(field.ref, field.role, value)).pipe(
675
+ Effect.provideService(ProtectedBrowserDispatch, {
676
+ mark: Effect.sync(() => {
677
+ dispatch = "possibly-dispatched";
678
+ if (!exposures.some((target) => sameTarget(target, offer.target)))
679
+ exposures.push(offer.target);
680
+ }),
681
+ }),
682
+ );
683
+ dispatch = "dispatched";
684
+ milestone = "partial-fill";
685
+ }
686
+ milestone = "filled";
687
+ if (decoded.submit !== undefined) {
688
+ yield* validate;
689
+ yield* authorize;
690
+ const ref = decoded.submit;
691
+
692
+ dispatch = "possibly-dispatched";
693
+ yield* remote(driver.click(ref)).pipe(
694
+ Effect.catch((error) => {
695
+ if (error.reason === "needs-attention") dispatch = "dispatched";
696
+
697
+ return Effect.fail(error);
698
+ }),
699
+ );
700
+ dispatch = "dispatched";
701
+ milestone = "submission-dispatched";
702
+ }
703
+ yield* permitObservation;
704
+
705
+ return CredentialUseResult.make({
706
+ dispatch,
707
+ milestone,
708
+ observation,
709
+ cleanup,
710
+ authentication: "unverified",
711
+ });
712
+ }),
713
+ ),
714
+ };
715
+ }, Effect.withTracerEnabled(false));
716
+
717
+ return { open };
718
+ }),
719
+ );