@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.
- package/dist/Alarm.d.mts +183 -0
- package/dist/Alarm.mjs +454 -0
- package/dist/Alarm.mjs.map +1 -0
- package/dist/BrowserRestCapture.d.mts +35 -0
- package/dist/BrowserRestCapture.mjs +238 -0
- package/dist/BrowserRestCapture.mjs.map +1 -0
- package/dist/BrowserRestCrawl.d.mts +17 -0
- package/dist/BrowserRestCrawl.mjs +244 -0
- package/dist/BrowserRestCrawl.mjs.map +1 -0
- package/dist/CloudflareAiGateway.d.mts +64 -0
- package/dist/CloudflareAiGateway.mjs +70 -0
- package/dist/CloudflareAiGateway.mjs.map +1 -0
- package/dist/CloudflareBindings.d.mts +112 -0
- package/dist/CloudflareBindings.mjs +101 -0
- package/dist/CloudflareBindings.mjs.map +1 -0
- package/dist/CloudflareBrowser-D4YqaTAl.mjs +495 -0
- package/dist/CloudflareBrowser-D4YqaTAl.mjs.map +1 -0
- package/dist/CloudflareBrowser-DJq53niJ.d.mts +80 -0
- package/dist/CloudflareBrowser.d.mts +2 -0
- package/dist/CloudflareBrowser.mjs +2 -0
- package/dist/CloudflareCodeMode.d.mts +44 -0
- package/dist/CloudflareCodeMode.mjs +616 -0
- package/dist/CloudflareCodeMode.mjs.map +1 -0
- package/dist/CloudflareConfig-f3CqTel1.d.mts +101 -0
- package/dist/CloudflareConfig.d.mts +2 -0
- package/dist/CloudflareConfig.mjs +122 -0
- package/dist/CloudflareConfig.mjs.map +1 -0
- package/dist/CloudflareMemory.d.mts +142 -0
- package/dist/CloudflareMemory.mjs +195 -0
- package/dist/CloudflareMemory.mjs.map +1 -0
- package/dist/CloudflareScheduling.d.mts +52 -0
- package/dist/CloudflareScheduling.mjs +389 -0
- package/dist/CloudflareScheduling.mjs.map +1 -0
- package/dist/CloudflareSubscriptions.d.mts +87 -0
- package/dist/CloudflareSubscriptions.mjs +524 -0
- package/dist/CloudflareSubscriptions.mjs.map +1 -0
- package/dist/CloudflareThreadClient.d.mts +1286 -0
- package/dist/CloudflareThreadClient.mjs +386 -0
- package/dist/CloudflareThreadClient.mjs.map +1 -0
- package/dist/InteractiveBrowser.d.mts +144 -0
- package/dist/InteractiveBrowser.mjs +1090 -0
- package/dist/InteractiveBrowser.mjs.map +1 -0
- package/dist/ProtectedBrowser.d.mts +65 -0
- package/dist/ProtectedBrowser.mjs +859 -0
- package/dist/ProtectedBrowser.mjs.map +1 -0
- package/dist/ThreadObject-3uDpKfB3.d.mts +816 -0
- package/dist/ThreadObject-DGkXv41C.mjs +836 -0
- package/dist/ThreadObject-DGkXv41C.mjs.map +1 -0
- package/dist/ThreadObject.d.mts +2 -0
- package/dist/ThreadObject.mjs +3 -0
- package/dist/WakeScheduler.d.mts +23 -0
- package/dist/WakeScheduler.mjs +60 -0
- package/dist/WakeScheduler.mjs.map +1 -0
- package/dist/boundary-BguazVkh.mjs +42 -0
- package/dist/boundary-BguazVkh.mjs.map +1 -0
- package/dist/browser-session-lifecycle-CUbVfEgP.mjs +84 -0
- package/dist/browser-session-lifecycle-CUbVfEgP.mjs.map +1 -0
- package/dist/browser-session-lifecycle-DJpqLXy_.d.mts +21 -0
- package/dist/index.d.mts +13 -1547
- package/dist/index.mjs +13 -1636
- package/dist/prepared-admission-DSi55W-7.mjs +73 -0
- package/dist/prepared-admission-DSi55W-7.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/package.json +1 -53
- package/src/Alarm.ts +953 -0
- package/src/BrowserRestCapture.ts +477 -0
- package/src/BrowserRestCrawl.ts +540 -0
- package/src/CloudflareAiGateway.ts +170 -0
- package/src/CloudflareBindings.ts +180 -0
- package/src/CloudflareBrowser.ts +15 -0
- package/src/{code-mode-executor.ts → CloudflareCodeMode.ts} +372 -204
- package/src/{config.ts → CloudflareConfig.ts} +9 -8
- package/src/CloudflareMemory.ts +406 -0
- package/src/CloudflareScheduling.ts +733 -0
- package/src/CloudflareSubscriptions.ts +1045 -0
- package/src/CloudflareThreadClient.ts +807 -0
- package/src/InteractiveBrowser.ts +2357 -0
- package/src/ProtectedBrowser.ts +2 -0
- package/src/ThreadObject.ts +1179 -0
- package/src/{wake-scheduler.ts → WakeScheduler.ts} +23 -23
- package/src/index.ts +12 -23
- package/src/internal/boundary.ts +55 -0
- package/src/internal/browser-quick-action.ts +857 -0
- package/src/internal/browser-session-lifecycle.ts +160 -0
- package/src/internal/layers.ts +751 -0
- package/src/internal/message-delivery.ts +148 -0
- package/src/internal/prepared-admission.ts +116 -0
- package/src/internal/progress-wait.ts +121 -0
- package/src/internal/transport.ts +42 -0
- package/src/protected-browser/binding.ts +187 -0
- package/src/protected-browser/inspect-frame.ts +125 -0
- package/src/protected-browser/native.ts +429 -0
- package/src/protected-browser/policy.ts +719 -0
- package/dist/index.mjs.map +0 -1
- package/src/alarm.ts +0 -334
- package/src/bindings.ts +0 -145
- package/src/client.ts +0 -646
- package/src/conversation-object.ts +0 -768
- package/src/layers.ts +0 -376
- 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
|
+
);
|