@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.
- package/dist/InteractiveBrowser-DUE_m12-.d.mts +161 -0
- package/dist/InteractiveBrowser-DupDtfw2.mjs +1169 -0
- package/dist/InteractiveBrowser-DupDtfw2.mjs.map +1 -0
- package/dist/InteractiveBrowser.d.mts +2 -144
- package/dist/InteractiveBrowser.mjs +1 -1089
- package/dist/ProtectedBrowser.d.mts +70 -14
- package/dist/ProtectedBrowser.mjs +599 -328
- package/dist/ProtectedBrowser.mjs.map +1 -1
- package/package.json +1 -1
- package/src/ProtectedBrowser.ts +7 -0
- package/src/protected-browser/binding.ts +151 -25
- package/src/protected-browser/host.ts +328 -0
- package/src/protected-browser/native.ts +17 -1
- package/src/protected-browser/policy.ts +640 -536
- package/dist/InteractiveBrowser.mjs.map +0 -1
- package/dist/browser-session-lifecycle-CmbQABHC.mjs +0 -84
- package/dist/browser-session-lifecycle-CmbQABHC.mjs.map +0 -1
- package/dist/browser-session-lifecycle-DJpqLXy_.d.mts +0 -21
|
@@ -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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(() => fail("provider")));
|
|
238
|
+
return cleanup;
|
|
239
|
+
}),
|
|
240
|
+
),
|
|
241
|
+
);
|
|
243
242
|
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
371
|
+
const reason =
|
|
372
|
+
error?._tag === "Fail" && Schema.is(ProtectedBrowserError)(error.error)
|
|
373
|
+
? error.error.reason
|
|
374
|
+
: "provider";
|
|
258
375
|
|
|
259
|
-
|
|
260
|
-
return yield* fail("denied");
|
|
376
|
+
const interrupt = cause.reasons.some((reason) => reason._tag === "Interrupt");
|
|
261
377
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
636
|
+
field.role !== current.role ||
|
|
637
|
+
kindFor(field.role) !== offer.kind ||
|
|
638
|
+
!sameTarget(offer.target, current.target)
|
|
437
639
|
)
|
|
438
|
-
return yield* fail("
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
458
|
-
|
|
645
|
+
if (submit.role !== "submit" || !sameTarget(offer.target, submit.target))
|
|
646
|
+
return yield* fail("denied");
|
|
647
|
+
}
|
|
648
|
+
});
|
|
459
649
|
|
|
460
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
543
|
-
|
|
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
|
-
|
|
674
|
+
const raw = yield* access
|
|
675
|
+
.resolve(authorization)
|
|
676
|
+
.pipe(Effect.mapError((error) => fail(error.reason)));
|
|
594
677
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
678
|
+
const material = yield* Schema.decodeEffect(
|
|
679
|
+
offer.kind === "login" ? LoginCredential : CardCredential,
|
|
680
|
+
)(raw).pipe(Effect.mapError(() => fail("resolver")));
|
|
598
681
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
);
|