@yolk-sdk/emulators 0.1.0-canary.96
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/LICENSE +21 -0
- package/README.md +2135 -0
- package/dist/anthropic.d.mts +269 -0
- package/dist/anthropic.d.mts.map +1 -0
- package/dist/anthropic.mjs +177 -0
- package/dist/anthropic.mjs.map +1 -0
- package/dist/chat-completions.d.mts +288 -0
- package/dist/chat-completions.d.mts.map +1 -0
- package/dist/chat-completions.mjs +451 -0
- package/dist/chat-completions.mjs.map +1 -0
- package/dist/codex.d.mts +265 -0
- package/dist/codex.d.mts.map +1 -0
- package/dist/codex.mjs +199 -0
- package/dist/codex.mjs.map +1 -0
- package/dist/dropbox/api.d.mts +63 -0
- package/dist/dropbox/api.d.mts.map +1 -0
- package/dist/dropbox/api.mjs +565 -0
- package/dist/dropbox/api.mjs.map +1 -0
- package/dist/dropbox/state.d.mts +136 -0
- package/dist/dropbox/state.d.mts.map +1 -0
- package/dist/dropbox/state.mjs +209 -0
- package/dist/dropbox/state.mjs.map +1 -0
- package/dist/dropbox.d.mts +79 -0
- package/dist/dropbox.d.mts.map +1 -0
- package/dist/dropbox.mjs +124 -0
- package/dist/dropbox.mjs.map +1 -0
- package/dist/email-fixtures.d.mts +36 -0
- package/dist/email-fixtures.d.mts.map +1 -0
- package/dist/email-fixtures.mjs +1080 -0
- package/dist/email-fixtures.mjs.map +1 -0
- package/dist/email.d.mts +160 -0
- package/dist/email.d.mts.map +1 -0
- package/dist/email.mjs +608 -0
- package/dist/email.mjs.map +1 -0
- package/dist/emulator-compose.d.mts +40 -0
- package/dist/emulator-compose.d.mts.map +1 -0
- package/dist/emulator-compose.mjs +91 -0
- package/dist/emulator-compose.mjs.map +1 -0
- package/dist/emulator-http.d.mts +53 -0
- package/dist/emulator-http.d.mts.map +1 -0
- package/dist/emulator-http.mjs +117 -0
- package/dist/emulator-http.mjs.map +1 -0
- package/dist/emulator-kernel.d.mts +176 -0
- package/dist/emulator-kernel.d.mts.map +1 -0
- package/dist/emulator-kernel.mjs +413 -0
- package/dist/emulator-kernel.mjs.map +1 -0
- package/dist/fixture-route.d.mts +129 -0
- package/dist/fixture-route.d.mts.map +1 -0
- package/dist/fixture-route.mjs +257 -0
- package/dist/fixture-route.mjs.map +1 -0
- package/dist/fortnox/api.d.mts +92 -0
- package/dist/fortnox/api.d.mts.map +1 -0
- package/dist/fortnox/api.mjs +750 -0
- package/dist/fortnox/api.mjs.map +1 -0
- package/dist/fortnox/state.d.mts +533 -0
- package/dist/fortnox/state.d.mts.map +1 -0
- package/dist/fortnox/state.mjs +612 -0
- package/dist/fortnox/state.mjs.map +1 -0
- package/dist/fortnox.d.mts +128 -0
- package/dist/fortnox.d.mts.map +1 -0
- package/dist/fortnox.mjs +403 -0
- package/dist/fortnox.mjs.map +1 -0
- package/dist/gateway-evaluate-recordings.d.mts +12 -0
- package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
- package/dist/gateway-evaluate-recordings.mjs +128 -0
- package/dist/gateway-evaluate-recordings.mjs.map +1 -0
- package/dist/gateway.d.mts +272 -0
- package/dist/gateway.d.mts.map +1 -0
- package/dist/gateway.mjs +400 -0
- package/dist/gateway.mjs.map +1 -0
- package/dist/github/api.d.mts +63 -0
- package/dist/github/api.d.mts.map +1 -0
- package/dist/github/api.mjs +527 -0
- package/dist/github/api.mjs.map +1 -0
- package/dist/github/state.d.mts +272 -0
- package/dist/github/state.d.mts.map +1 -0
- package/dist/github/state.mjs +303 -0
- package/dist/github/state.mjs.map +1 -0
- package/dist/github.d.mts +77 -0
- package/dist/github.d.mts.map +1 -0
- package/dist/github.mjs +180 -0
- package/dist/github.mjs.map +1 -0
- package/dist/google/calendar.d.mts +10 -0
- package/dist/google/calendar.d.mts.map +1 -0
- package/dist/google/calendar.mjs +252 -0
- package/dist/google/calendar.mjs.map +1 -0
- package/dist/google/drive.d.mts +8 -0
- package/dist/google/drive.d.mts.map +1 -0
- package/dist/google/drive.mjs +252 -0
- package/dist/google/drive.mjs.map +1 -0
- package/dist/google/gmail.d.mts +10 -0
- package/dist/google/gmail.d.mts.map +1 -0
- package/dist/google/gmail.mjs +591 -0
- package/dist/google/gmail.mjs.map +1 -0
- package/dist/google/shared.d.mts +107 -0
- package/dist/google/shared.d.mts.map +1 -0
- package/dist/google/shared.mjs +146 -0
- package/dist/google/shared.mjs.map +1 -0
- package/dist/google/state.d.mts +385 -0
- package/dist/google/state.d.mts.map +1 -0
- package/dist/google/state.mjs +496 -0
- package/dist/google/state.mjs.map +1 -0
- package/dist/google.d.mts +75 -0
- package/dist/google.d.mts.map +1 -0
- package/dist/google.mjs +210 -0
- package/dist/google.mjs.map +1 -0
- package/dist/linkedin-search/api.d.mts +45 -0
- package/dist/linkedin-search/api.d.mts.map +1 -0
- package/dist/linkedin-search/api.mjs +181 -0
- package/dist/linkedin-search/api.mjs.map +1 -0
- package/dist/linkedin-search/state.d.mts +123 -0
- package/dist/linkedin-search/state.d.mts.map +1 -0
- package/dist/linkedin-search/state.mjs +261 -0
- package/dist/linkedin-search/state.mjs.map +1 -0
- package/dist/linkedin-search.d.mts +69 -0
- package/dist/linkedin-search.d.mts.map +1 -0
- package/dist/linkedin-search.mjs +158 -0
- package/dist/linkedin-search.mjs.map +1 -0
- package/dist/mcp/api.d.mts +44 -0
- package/dist/mcp/api.d.mts.map +1 -0
- package/dist/mcp/api.mjs +557 -0
- package/dist/mcp/api.mjs.map +1 -0
- package/dist/mcp/recordings.d.mts +40 -0
- package/dist/mcp/recordings.d.mts.map +1 -0
- package/dist/mcp/recordings.mjs +2390 -0
- package/dist/mcp/recordings.mjs.map +1 -0
- package/dist/mcp/state.d.mts +71 -0
- package/dist/mcp/state.d.mts.map +1 -0
- package/dist/mcp/state.mjs +83 -0
- package/dist/mcp/state.mjs.map +1 -0
- package/dist/mcp.d.mts +84 -0
- package/dist/mcp.d.mts.map +1 -0
- package/dist/mcp.mjs +241 -0
- package/dist/mcp.mjs.map +1 -0
- package/dist/messages.d.mts +183 -0
- package/dist/messages.d.mts.map +1 -0
- package/dist/messages.mjs +532 -0
- package/dist/messages.mjs.map +1 -0
- package/dist/microsoft/api.d.mts +47 -0
- package/dist/microsoft/api.d.mts.map +1 -0
- package/dist/microsoft/api.mjs +178 -0
- package/dist/microsoft/api.mjs.map +1 -0
- package/dist/microsoft/calendar.d.mts +30 -0
- package/dist/microsoft/calendar.d.mts.map +1 -0
- package/dist/microsoft/calendar.mjs +271 -0
- package/dist/microsoft/calendar.mjs.map +1 -0
- package/dist/microsoft/drive.d.mts +36 -0
- package/dist/microsoft/drive.d.mts.map +1 -0
- package/dist/microsoft/drive.mjs +298 -0
- package/dist/microsoft/drive.mjs.map +1 -0
- package/dist/microsoft/graph.d.mts +198 -0
- package/dist/microsoft/graph.d.mts.map +1 -0
- package/dist/microsoft/graph.mjs +264 -0
- package/dist/microsoft/graph.mjs.map +1 -0
- package/dist/microsoft/mail.d.mts +48 -0
- package/dist/microsoft/mail.d.mts.map +1 -0
- package/dist/microsoft/mail.mjs +488 -0
- package/dist/microsoft/mail.mjs.map +1 -0
- package/dist/microsoft/state.d.mts +480 -0
- package/dist/microsoft/state.d.mts.map +1 -0
- package/dist/microsoft/state.mjs +625 -0
- package/dist/microsoft/state.mjs.map +1 -0
- package/dist/microsoft.d.mts +168 -0
- package/dist/microsoft.d.mts.map +1 -0
- package/dist/microsoft.mjs +483 -0
- package/dist/microsoft.mjs.map +1 -0
- package/dist/node.d.mts +47 -0
- package/dist/node.d.mts.map +1 -0
- package/dist/node.mjs +178 -0
- package/dist/node.mjs.map +1 -0
- package/dist/notion/api.d.mts +51 -0
- package/dist/notion/api.d.mts.map +1 -0
- package/dist/notion/api.mjs +507 -0
- package/dist/notion/api.mjs.map +1 -0
- package/dist/notion/state.d.mts +329 -0
- package/dist/notion/state.d.mts.map +1 -0
- package/dist/notion/state.mjs +432 -0
- package/dist/notion/state.mjs.map +1 -0
- package/dist/notion.d.mts +72 -0
- package/dist/notion.d.mts.map +1 -0
- package/dist/notion.mjs +127 -0
- package/dist/notion.mjs.map +1 -0
- package/dist/openai.d.mts +165 -0
- package/dist/openai.d.mts.map +1 -0
- package/dist/openai.mjs +141 -0
- package/dist/openai.mjs.map +1 -0
- package/dist/opencode-recordings.d.mts +7 -0
- package/dist/opencode-recordings.d.mts.map +1 -0
- package/dist/opencode-recordings.mjs +222 -0
- package/dist/opencode-recordings.mjs.map +1 -0
- package/dist/opencode.d.mts +70 -0
- package/dist/opencode.d.mts.map +1 -0
- package/dist/opencode.mjs +217 -0
- package/dist/opencode.mjs.map +1 -0
- package/dist/r2-fixtures.d.mts +36 -0
- package/dist/r2-fixtures.d.mts.map +1 -0
- package/dist/r2-fixtures.mjs +245 -0
- package/dist/r2-fixtures.mjs.map +1 -0
- package/dist/r2-guard.d.mts +43 -0
- package/dist/r2-guard.d.mts.map +1 -0
- package/dist/r2-guard.mjs +215 -0
- package/dist/r2-guard.mjs.map +1 -0
- package/dist/r2.d.mts +166 -0
- package/dist/r2.d.mts.map +1 -0
- package/dist/r2.mjs +517 -0
- package/dist/r2.mjs.map +1 -0
- package/dist/responses.d.mts +231 -0
- package/dist/responses.d.mts.map +1 -0
- package/dist/responses.mjs +556 -0
- package/dist/responses.mjs.map +1 -0
- package/dist/route-evidence.d.mts +45 -0
- package/dist/route-evidence.d.mts.map +1 -0
- package/dist/route-evidence.mjs +56 -0
- package/dist/route-evidence.mjs.map +1 -0
- package/dist/router.d.mts +93 -0
- package/dist/router.d.mts.map +1 -0
- package/dist/router.mjs +259 -0
- package/dist/router.mjs.map +1 -0
- package/dist/stateful-core.d.mts +17 -0
- package/dist/stateful-core.d.mts.map +1 -0
- package/dist/stateful-core.mjs +41 -0
- package/dist/stateful-core.mjs.map +1 -0
- package/dist/stateful-emulator.d.mts +565 -0
- package/dist/stateful-emulator.d.mts.map +1 -0
- package/dist/stateful-emulator.mjs +1228 -0
- package/dist/stateful-emulator.mjs.map +1 -0
- package/dist/stateful-secrets.d.mts +84 -0
- package/dist/stateful-secrets.d.mts.map +1 -0
- package/dist/stateful-secrets.mjs +216 -0
- package/dist/stateful-secrets.mjs.map +1 -0
- package/dist/subscription-usage-recordings.d.mts +9 -0
- package/dist/subscription-usage-recordings.d.mts.map +1 -0
- package/dist/subscription-usage-recordings.mjs +53 -0
- package/dist/subscription-usage-recordings.mjs.map +1 -0
- package/dist/subscription-usage.d.mts +53 -0
- package/dist/subscription-usage.d.mts.map +1 -0
- package/dist/subscription-usage.mjs +21 -0
- package/dist/subscription-usage.mjs.map +1 -0
- package/dist/telegram/api.d.mts +34 -0
- package/dist/telegram/api.d.mts.map +1 -0
- package/dist/telegram/api.mjs +257 -0
- package/dist/telegram/api.mjs.map +1 -0
- package/dist/telegram/state.d.mts +128 -0
- package/dist/telegram/state.d.mts.map +1 -0
- package/dist/telegram/state.mjs +154 -0
- package/dist/telegram/state.mjs.map +1 -0
- package/dist/telegram.d.mts +62 -0
- package/dist/telegram.d.mts.map +1 -0
- package/dist/telegram.mjs +127 -0
- package/dist/telegram.mjs.map +1 -0
- package/dist/todoist/api.d.mts +55 -0
- package/dist/todoist/api.d.mts.map +1 -0
- package/dist/todoist/api.mjs +415 -0
- package/dist/todoist/api.mjs.map +1 -0
- package/dist/todoist/state.d.mts +275 -0
- package/dist/todoist/state.d.mts.map +1 -0
- package/dist/todoist/state.mjs +345 -0
- package/dist/todoist/state.mjs.map +1 -0
- package/dist/todoist.d.mts +68 -0
- package/dist/todoist.d.mts.map +1 -0
- package/dist/todoist.mjs +181 -0
- package/dist/todoist.mjs.map +1 -0
- package/dist/xai.d.mts +267 -0
- package/dist/xai.d.mts.map +1 -0
- package/dist/xai.mjs +252 -0
- package/dist/xai.mjs.map +1 -0
- package/package.json +157 -0
- package/src/anthropic.ts +289 -0
- package/src/chat-completions.ts +924 -0
- package/src/codex.ts +296 -0
- package/src/dropbox/api.ts +1084 -0
- package/src/dropbox/state.ts +364 -0
- package/src/dropbox.ts +203 -0
- package/src/email-fixtures.ts +1297 -0
- package/src/email.ts +1108 -0
- package/src/emulator-compose.ts +147 -0
- package/src/emulator-http.ts +184 -0
- package/src/emulator-kernel.ts +844 -0
- package/src/fixture-route.ts +561 -0
- package/src/fortnox/api.ts +1352 -0
- package/src/fortnox/state.ts +798 -0
- package/src/fortnox.ts +801 -0
- package/src/gateway-evaluate-recordings.ts +153 -0
- package/src/gateway.ts +530 -0
- package/src/github/api.ts +986 -0
- package/src/github/state.ts +439 -0
- package/src/github.ts +271 -0
- package/src/google/calendar.ts +486 -0
- package/src/google/drive.ts +471 -0
- package/src/google/gmail.ts +1011 -0
- package/src/google/shared.ts +321 -0
- package/src/google/state.ts +686 -0
- package/src/google.ts +298 -0
- package/src/linkedin-search/api.ts +363 -0
- package/src/linkedin-search/state.ts +373 -0
- package/src/linkedin-search.ts +241 -0
- package/src/mcp/api.ts +995 -0
- package/src/mcp/recordings.ts +2665 -0
- package/src/mcp/state.ts +125 -0
- package/src/mcp.ts +319 -0
- package/src/messages.ts +844 -0
- package/src/microsoft/api.ts +426 -0
- package/src/microsoft/calendar.ts +491 -0
- package/src/microsoft/drive.ts +541 -0
- package/src/microsoft/graph.ts +550 -0
- package/src/microsoft/mail.ts +833 -0
- package/src/microsoft/state.ts +822 -0
- package/src/microsoft.ts +982 -0
- package/src/node.ts +293 -0
- package/src/notion/api.ts +976 -0
- package/src/notion/state.ts +512 -0
- package/src/notion.ts +210 -0
- package/src/openai.ts +198 -0
- package/src/opencode-recordings.ts +257 -0
- package/src/opencode.ts +271 -0
- package/src/r2-fixtures.ts +295 -0
- package/src/r2-guard.ts +323 -0
- package/src/r2.ts +890 -0
- package/src/responses.ts +901 -0
- package/src/route-evidence.ts +90 -0
- package/src/router.ts +490 -0
- package/src/stateful-core.ts +70 -0
- package/src/stateful-emulator.ts +2571 -0
- package/src/stateful-secrets.ts +299 -0
- package/src/subscription-usage-recordings.ts +77 -0
- package/src/subscription-usage.ts +68 -0
- package/src/telegram/api.ts +437 -0
- package/src/telegram/state.ts +229 -0
- package/src/telegram.ts +227 -0
- package/src/todoist/api.ts +736 -0
- package/src/todoist/state.ts +456 -0
- package/src/todoist.ts +316 -0
- package/src/xai.ts +341 -0
|
@@ -0,0 +1,1228 @@
|
|
|
1
|
+
import { EmulatorHeaderRecord, answeredOutsideCore, emulatorJobHeader, handlerFailedResponse, isCredentialHeaderName, isCredentialQueryKey, redactCredentialFields, redactCredentialQuery, redactedCredentialValue } from "./emulator-http.mjs";
|
|
2
|
+
import { emulatorEvidenceHeader, emulatorRouteKey } from "./route-evidence.mjs";
|
|
3
|
+
import { isRecognisableBearerValue, jsonRepeatsSecret, repeatsSecret, scrubSecrets, textRepeatsSecret, unrecognisedLedgerPath, unrecognisedMethod } from "./stateful-secrets.mjs";
|
|
4
|
+
import { Data, Predicate, Result } from "effect";
|
|
5
|
+
import * as Schema from "effect/Schema";
|
|
6
|
+
//#region src/stateful-emulator.ts
|
|
7
|
+
/**
|
|
8
|
+
* Shared wrapper of the fixture-only stateful connector emulators (internal; used by `/dropbox`,
|
|
9
|
+
* `/notion`, `/todoist`, `/telegram`, `/github`, `/google`, `/linkedin-search`, and `/mcp`, not by
|
|
10
|
+
* the earlier `/fortnox` and `/microsoft` emulators, which keep their own).
|
|
11
|
+
*
|
|
12
|
+
* The emulator state lives in an `@emulators/core` custom runtime that the Node-only subpath
|
|
13
|
+
* creates and hands in (`src/stateful-core.ts`; this module imports no Node builtin and never
|
|
14
|
+
* imports the core); the request ledger, status faults, credential handling, and the
|
|
15
|
+
* `/_emulate/*` control plane live here, because the core reserves `/_emulate`.
|
|
16
|
+
*
|
|
17
|
+
* The owner rule: response behaviour comes only from the committed conformance fixtures. Every
|
|
18
|
+
* request is answered by its route or with one ledgered 400 not-emulated
|
|
19
|
+
* (`{ error: { type: 'not_emulated', message } }`, `notEmulated` in the ledger). Precedence per
|
|
20
|
+
* request:
|
|
21
|
+
*
|
|
22
|
+
* 1. route match on the raw path (path parameters decoded once; unknown routes and methods, and
|
|
23
|
+
* invalid percent-encoding, are not emulated), and the origin the route is recorded on;
|
|
24
|
+
* 2. a non-empty `Authorization: Bearer` credential (never compared against anything, stored,
|
|
25
|
+
* forwarded, or ledgered) and the emulator's header rules;
|
|
26
|
+
* 3. the body the route takes (none, JSON with an `application/json` media type, or raw bytes);
|
|
27
|
+
* 4. the route's request-shape check, which reads no state;
|
|
28
|
+
* 5. in the core runtime, the route's plan: a pure, state-reading eligibility check that refuses
|
|
29
|
+
* what the state cannot answer the way a fixture does, and otherwise returns a commit;
|
|
30
|
+
* 6. the first matching status fault, decided only for an eligible request (nothing is written);
|
|
31
|
+
* 7. the commit, the only step that writes.
|
|
32
|
+
*
|
|
33
|
+
* A request that is not emulated (steps 1 to 5) never uses up a fault. The plan, the fault
|
|
34
|
+
* decision, and the commit run synchronously together, so no other request interleaves. A route
|
|
35
|
+
* that throws answers an evidence-tagged 500 emulator error
|
|
36
|
+
* (`responseError` in the ledger); a closed emulator answers 503. No recovery answer reads the
|
|
37
|
+
* injectable clock, so a failing clock cannot change it. Every response of a matched route
|
|
38
|
+
* carries `x-emulator-evidence: unverified` when the route is unverified. Ledgered bodies and
|
|
39
|
+
* query parameters have credential-named keys redacted.
|
|
40
|
+
*
|
|
41
|
+
* Fail-closed mode (opt-in, `failClosed`; used by `/github`, `/google`, and `/linkedin-search`):
|
|
42
|
+
* every route parameter has a raw pattern (matched in full), and a request is recognised only when
|
|
43
|
+
* its raw path is exactly an emulated route shape under that route's method and any `Authorization`
|
|
44
|
+
* header is exactly `Bearer <at least 8 non-space characters>` (a recognisable bearer, below).
|
|
45
|
+
* Every other request is ledgered and answered with constant text only (`/<unrecognised>`, a
|
|
46
|
+
* standard method or `<other>`, an empty query, no body, a constant reason). A recognised bearer
|
|
47
|
+
* must match the RFC 6750 `b64token` syntax exactly (`^[A-Za-z0-9\-._~+/]+=*$`, at least 8
|
|
48
|
+
* characters), start with a character in `[G-Zg-z\-._~+/]` other than `n`, `r`, `t`, `u`, and hold
|
|
49
|
+
* at least one character outside the JSON-number alphabet `[0-9.eE+-]` (every GitHub and Google
|
|
50
|
+
* token form does: `ghp_…`, `github_pat_…`, `gho_…`, `ya29.…`). So no number's text can contain it;
|
|
51
|
+
* it holds no escape introducer (`%`, `\`, `"`), so no escape starts inside it; and its first
|
|
52
|
+
* character is no hex digit and no JSON escape letter, so no stray `%`, `\`, or partial escape to
|
|
53
|
+
* its left can complete with it, and its characters always decode in place. An `Authorization`
|
|
54
|
+
* header with any other value is unrecognisable. A recognised request that repeats the bearer value
|
|
55
|
+
* in its raw path, any path segment, the raw query or any query key or value, any recorded header,
|
|
56
|
+
* or its body is refused and ledgered with constant text only: a standard method, the path
|
|
57
|
+
* `/<unrecognised>`, its route template, an empty query, no headers or body, and a constant reason
|
|
58
|
+
* (`the query repeats the credential`, for example). Each part is checked through the closure of
|
|
59
|
+
* two total, lexical transforms that cannot fail: a tolerant percent-decode (every `%XX` below
|
|
60
|
+
* `%80` becomes its ASCII character; any other `%` sequence is left as it is) and a tolerant
|
|
61
|
+
* JSON-unescape (in any text, whether or not it parses as JSON, `\uXXXX` below `\u0080` and `\"`,
|
|
62
|
+
* `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t` become their characters). Starting from each part's raw
|
|
63
|
+
* text, either transform is applied to every text of the previous step, deduplicated, until no new
|
|
64
|
+
* text appears (a fixpoint), and every text is checked for the bearer as a substring; both
|
|
65
|
+
* transforms never lengthen a text and shorten it whenever they change it. So any depth of
|
|
66
|
+
* percent-encoding or JSON escaping, in any order, is seen through in every part: the raw path and
|
|
67
|
+
* each raw path segment, the raw query and each query key and value (already decoded once by
|
|
68
|
+
* `URLSearchParams`), each recorded header, and the raw body. The work is capped at 64 rounds, 1024
|
|
69
|
+
* distinct texts, or 8 Mi characters read by the transforms, whichever comes first; a part whose
|
|
70
|
+
* closure hits a cap before its fixpoint counts as repeating the credential and is refused with the
|
|
71
|
+
* same constant entry (uncertainty refuses, it never admits; so any part over 4 Mi characters is
|
|
72
|
+
* always refused). Any other recognised request has the bearer value scrubbed from its ledgered
|
|
73
|
+
* fields and every not-emulated reason (plan-time reasons included); its recorded query is keyed by
|
|
74
|
+
* recorded key; a key recorded more than once lists its values in order (as a JSON array); and
|
|
75
|
+
* recorded headers and query keys and values that start like JSON (`{`, `[`, `"`) are recorded
|
|
76
|
+
* parsed with credential-named keys redacted at any depth, or as `<redacted>` when they do not
|
|
77
|
+
* parse, whatever the header's declared format. Empty query components (a bare `?`, a stray `&`)
|
|
78
|
+
* are refused. Routes check their own query and body keys with `exactQuery` and `exactBodyKeys`,
|
|
79
|
+
* whose reasons never echo a request's own key (`exactQuery`'s opt-in `rawNames`, used by
|
|
80
|
+
* `/linkedin-search`, also refuses a parameter name in any but its plain form, comparing the raw
|
|
81
|
+
* names the wrapper hands routes as `rawQuery`). A template parameter written `{name+}` spans one
|
|
82
|
+
* or more path segments (each decoded once, none may decode to a `/`). The credential helpers live
|
|
83
|
+
* in `src/stateful-secrets.ts`. A route may also name decoded views of its raw body
|
|
84
|
+
* (`decodedViews`, opt-in; `/google` gives the base64url-decoded MIME of a Gmail draft's
|
|
85
|
+
* `message.raw`, which the provider's own wire format wraps): in fail-closed mode each view goes
|
|
86
|
+
* through the same fixpoint check as the raw body, before anything is recorded, a fault is decided,
|
|
87
|
+
* or anything is committed, and a hit is the same constant credential-repeat entry. A view may
|
|
88
|
+
* throw to refuse a body it cannot check completely: that request is ledgered as the same constant
|
|
89
|
+
* entry, as a repeat when the `DecodedViewRefusal` it threw carries cleanly decoded text holding
|
|
90
|
+
* the bearer, else with the refusal's reason when the route declares it in `viewRefusalReasons` (a
|
|
91
|
+
* constant the route owns, scrubbed defensively), else with
|
|
92
|
+
* `the request body cannot be checked for the credential`. A route without `decodedViews` is
|
|
93
|
+
* checked exactly as before. An emulator may also opt in to a per-origin bearer digest
|
|
94
|
+
* (`bearerDigest`, fail-closed mode only; `/linkedin-search` uses it): routes then see a one-way
|
|
95
|
+
* digest of the bearer for the origin the request arrived on (`EmulatedRequest.bearerDigest`),
|
|
96
|
+
* never the bearer, so a seed can mark a key as rejected on one origin by its digest, and the
|
|
97
|
+
* bearer still never reaches the state, the ledger, or `/_emulate/*`. A digest that throws or
|
|
98
|
+
* repeats the bearer answers the 500 emulator error (`responseError`). Without it, routes see no
|
|
99
|
+
* digest, as before.
|
|
100
|
+
*
|
|
101
|
+
* More opt-ins serve `/mcp`, whose JSON-RPC methods share one HTTP endpoint; a route or emulator
|
|
102
|
+
* that does not take them behaves exactly as before. A route may answer several manifest rows
|
|
103
|
+
* (`variants`, for example `RPC <origin><path>#<method>`): its admission names the row, which
|
|
104
|
+
* becomes the request's ledger route, its coverage row, and what a fault's `match.route` compares
|
|
105
|
+
* (a request refused before admission keeps the route template). A fault's `match.route` must name
|
|
106
|
+
* a manifest row (a variant, or the template of a route without variants); any other value is
|
|
107
|
+
* rejected when the fault is added, and `match.method` is always the HTTP method. A plan may return
|
|
108
|
+
* a `StreamedCommit`: the answer it prepares, and the `commit` that writes. Faults are decided
|
|
109
|
+
* against the prepared answer, and a faulted request never runs `commit`; an emulator built with
|
|
110
|
+
* `makeChunkedStatefulEmulator` also takes `truncate-after-chunks` faults, which send the prepared
|
|
111
|
+
* answer cut short and write nothing (one that cannot take effect answers 500 and is not used up).
|
|
112
|
+
* In fail-closed mode: `constantRefusals` ledgers every refusal with constant text only
|
|
113
|
+
* (`/<unrecognised>`, no query, headers, or body), so request text reaches the ledger only once a
|
|
114
|
+
* route admitted the request; `guardAllHeaders` checks every request header name and value but
|
|
115
|
+
* `Authorization` for a credential repeat, not only the recorded ones; and `guardOutput` checks a
|
|
116
|
+
* streamed commit's prepared answer (every header and chunk) and its `persisted` texts (minted ids,
|
|
117
|
+
* for example) for the bearer before any fault is decided or anything is committed, refusing a hit
|
|
118
|
+
* with the constant credential-repeat entry, so no answer or stored value repeats the bearer, while
|
|
119
|
+
* routes still see only its digest. `uniqueJsonKeys` refuses a JSON body in which an object repeats
|
|
120
|
+
* a key (after unescaping). Routes also see every request header name
|
|
121
|
+
* (`EmulatedRequest.headerNames`), never a credential value.
|
|
122
|
+
*
|
|
123
|
+
* Resolved mode (opt-in, `resolveRequest`; not with `failClosed`; used by `/todoist` and
|
|
124
|
+
* `/telegram`) serves an emulator whose credential the wrapper cannot find by itself (a Telegram
|
|
125
|
+
* bot token in a path segment) or that has its own credential rules (a Todoist bearer of any 8
|
|
126
|
+
* non-space characters): the emulator resolves every request itself, failing closed
|
|
127
|
+
* (`StatefulResolution`): an unrecognised request is ledgered with constant text only (the method
|
|
128
|
+
* as sent when standard, else `<other>`), a request it refuses before its body is read keeps its
|
|
129
|
+
* scrubbed fields, and a recognised one names its route, credential-free parameters, ledger path,
|
|
130
|
+
* guarded path, and the credential values to guard. Those values are scrubbed from everything the
|
|
131
|
+
* ledger keeps or a refusal answers (plan-time reasons included), and a query, guarded path, or
|
|
132
|
+
* body that repeats one (raw or percent-decoded once, and in any parsed JSON key, string, or
|
|
133
|
+
* number) is refused with a constant reason before anything else is checked. The ledger records
|
|
134
|
+
* the resolution's path and the query as sent (credential-named keys redacted), faults'
|
|
135
|
+
* `match.path` compares that path, and routes and the core see it too (routes see no header
|
|
136
|
+
* names and read no header but `content-type`), so the credential never reaches a route or the
|
|
137
|
+
* core. The request body is read once, after the query and path checks (so a query or path refusal
|
|
138
|
+
* leaves it unread, and any later refusal finds it read); a body that is already consumed or
|
|
139
|
+
* locked is refused as unreadable; a refusal reason that cannot be percent-encoded (an unpaired
|
|
140
|
+
* surrogate) is a handler failure (500). Fault answers and refusals are decided in the core with
|
|
141
|
+
* the commit, but returned by the wrapper itself, so a reset or a close before they are read never
|
|
142
|
+
* cancels them (only a commit's answer is the core's own); a core that answers without running
|
|
143
|
+
* the dispatch (closed meanwhile) gives the 500 handler failure (`responseError`
|
|
144
|
+
* `the route handler answered no eligibility verdict`). Resolved mode records no request
|
|
145
|
+
* header (`recordHeaders` must be empty). More opt-ins, off by default: a `json-or-empty` route
|
|
146
|
+
* body (JSON of any media type, or none), `makeHeaderlessStatefulEmulator` (ledger entries without
|
|
147
|
+
* a `headers` field), and `errorTexts` (the texts of the recovery answers).
|
|
148
|
+
*
|
|
149
|
+
* @experimental
|
|
150
|
+
*/
|
|
151
|
+
/** A request the emulator does not emulate, with the reason (answered 400 not-emulated). */
|
|
152
|
+
var NotEmulated = class extends Data.TaggedClass("NotEmulated") {};
|
|
153
|
+
/**
|
|
154
|
+
* What a route's decoded view throws to refuse a body with a reason of its own (fail-closed mode;
|
|
155
|
+
* see `StatefulRouteBinding.decodedViews`). `reason` must be one of the route's declared
|
|
156
|
+
* `viewRefusalReasons` (a constant the route owns, never derived from the request); `decoded` holds
|
|
157
|
+
* any text the view did decode cleanly, which is still checked for the bearer first.
|
|
158
|
+
*/
|
|
159
|
+
var DecodedViewRefusal = class extends Data.TaggedError("DecodedViewRefusal") {};
|
|
160
|
+
const notEmulated = (reason) => new NotEmulated({ reason });
|
|
161
|
+
const isNotEmulated = (value) => value instanceof NotEmulated;
|
|
162
|
+
const jsonResponse = (status, body, headers = {}) => {
|
|
163
|
+
const responseHeaders = new Headers(headers);
|
|
164
|
+
if (!responseHeaders.has("content-type")) responseHeaders.set("content-type", "application/json");
|
|
165
|
+
return new Response(JSON.stringify(body), {
|
|
166
|
+
status,
|
|
167
|
+
headers: responseHeaders
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
/** The one answer for everything no fixture records. */
|
|
171
|
+
const notEmulatedResponse = (reason) => jsonResponse(400, { error: {
|
|
172
|
+
type: "not_emulated",
|
|
173
|
+
message: `Not emulated: ${reason}`
|
|
174
|
+
} });
|
|
175
|
+
const emulatorError = (status, message, headers = {}) => jsonResponse(status, { error: {
|
|
176
|
+
message,
|
|
177
|
+
type: "emulator_error"
|
|
178
|
+
} }, headers);
|
|
179
|
+
const FaultStatus = Schema.Int.check(Schema.isBetween({
|
|
180
|
+
minimum: 400,
|
|
181
|
+
maximum: 599
|
|
182
|
+
}));
|
|
183
|
+
const FaultCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));
|
|
184
|
+
const ChunkCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
|
|
185
|
+
/**
|
|
186
|
+
* Optional fault filter; an omitted field matches every request. `method` is the HTTP method
|
|
187
|
+
* (never a manifest row's `RPC`). `path` is the raw request path (in resolved mode, the
|
|
188
|
+
* resolution's ledger path); ending in `*`, a prefix. `route` is a manifest row path, compared
|
|
189
|
+
* with the request's ledger route: the template of its matched route, or the manifest variant its
|
|
190
|
+
* route admitted it as (see `StatefulRouteBinding.variants`).
|
|
191
|
+
* A `route` that names no manifest row of the emulator could never match, so adding the fault
|
|
192
|
+
* rejects it (a route with variants is matched by its variant rows, never its template).
|
|
193
|
+
*/
|
|
194
|
+
const StatefulFaultMatch = Schema.Struct({
|
|
195
|
+
method: Schema.optionalKey(Schema.String),
|
|
196
|
+
path: Schema.optionalKey(Schema.String),
|
|
197
|
+
route: Schema.optionalKey(Schema.String)
|
|
198
|
+
});
|
|
199
|
+
/**
|
|
200
|
+
* A status fault: answer matching requests with this status (400-599), headers, and body before
|
|
201
|
+
* the route runs, so nothing is written. The body defaults to an emulator-fault body
|
|
202
|
+
* (`{ error: { type: 'emulator_fault', message } }`), never a guessed provider envelope. `count`
|
|
203
|
+
* limits how many requests it answers (omitted: all). `match.path` is the raw request path.
|
|
204
|
+
* Invalid header names or values, `location`, and framing headers are rejected.
|
|
205
|
+
*/
|
|
206
|
+
const StatefulFault = Schema.Struct({
|
|
207
|
+
kind: Schema.Literal("status"),
|
|
208
|
+
status: FaultStatus,
|
|
209
|
+
headers: Schema.optionalKey(EmulatorHeaderRecord),
|
|
210
|
+
body: Schema.optionalKey(Schema.Json),
|
|
211
|
+
match: Schema.optionalKey(StatefulFaultMatch),
|
|
212
|
+
count: Schema.optionalKey(FaultCount)
|
|
213
|
+
});
|
|
214
|
+
/**
|
|
215
|
+
* Opt-in (`makeChunkedStatefulEmulator`): send the first `chunks` body chunks of the answer a
|
|
216
|
+
* streamed commit prepared (`StreamedCommit`), then close the body cleanly (a truncated answer).
|
|
217
|
+
* It is decided where a status fault is, after the plan, and a truncated request writes nothing:
|
|
218
|
+
* its `commit` never runs (no state, counter, or runtime change). One that cannot take effect (the
|
|
219
|
+
* answer is not streamed, or has no more than `chunks` chunks) answers the 500 emulator error and
|
|
220
|
+
* is not used up, never a silent no-op.
|
|
221
|
+
*/
|
|
222
|
+
const StatefulTruncateFault = Schema.Struct({
|
|
223
|
+
kind: Schema.Literal("truncate-after-chunks"),
|
|
224
|
+
chunks: ChunkCount,
|
|
225
|
+
match: Schema.optionalKey(StatefulFaultMatch),
|
|
226
|
+
count: Schema.optionalKey(FaultCount)
|
|
227
|
+
});
|
|
228
|
+
/** A status fault, or (`makeChunkedStatefulEmulator`) a truncation fault. */
|
|
229
|
+
const StatefulStreamFault = Schema.Union([StatefulFault, StatefulTruncateFault]);
|
|
230
|
+
/** A streamed commit: the prepared `answer`, and the write (none by default) it commits. */
|
|
231
|
+
const streamedCommit = (answer, commit = () => void 0, persisted = []) => ({
|
|
232
|
+
answer,
|
|
233
|
+
commit,
|
|
234
|
+
persisted
|
|
235
|
+
});
|
|
236
|
+
/**
|
|
237
|
+
* A route of the table: its evidence (and recorded origin), the body it takes, its request-shape
|
|
238
|
+
* check `admit` (no state), and `plan`, which only ever sees an admitted input: it reads the state
|
|
239
|
+
* without writing and answers not emulated or the commit that writes.
|
|
240
|
+
*/
|
|
241
|
+
const statefulRoute = (evidence, body, admit, plan) => ({
|
|
242
|
+
...evidence,
|
|
243
|
+
body,
|
|
244
|
+
admit: (request, env) => {
|
|
245
|
+
const input = admit(request, env);
|
|
246
|
+
return isNotEmulated(input) ? input : { plan: (state, context) => plan(state, input, context) };
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
/** Route evidence without the handler parts. */
|
|
250
|
+
const routeEvidence = ({ body: _body, admit: _admit, origin: _origin, params: _params, decodedViews: _decodedViews, viewRefusalReasons: _viewRefusalReasons, variants: _variants, ...evidence }) => evidence;
|
|
251
|
+
/** The manifest rows of a route: its variants, or the route itself (`routeEvidence`). */
|
|
252
|
+
const routeManifest = (route) => route.variants ?? [routeEvidence(route)];
|
|
253
|
+
/** A percent-decoded path segment, or `undefined` for invalid percent-encoding. */
|
|
254
|
+
const decodeSegment = (segment) => {
|
|
255
|
+
try {
|
|
256
|
+
return decodeURIComponent(segment);
|
|
257
|
+
} catch {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
const templatePattern = (template) => new RegExp(`^${template.split(/(\{[A-Za-z]+\+?\})/).map((part) => /^\{[A-Za-z]+\}$/.test(part) ? "([^/]+)" : /^\{[A-Za-z]+\+\}$/.test(part) ? "(.+)" : part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("")}$`);
|
|
262
|
+
const templateNames = (template) => [...template.matchAll(/\{([A-Za-z]+)\+?\}/g)].map((match) => match[1] ?? "");
|
|
263
|
+
/**
|
|
264
|
+
* A multi-segment parameter decoded segment by segment (each once), or `undefined` for an empty
|
|
265
|
+
* segment, invalid percent-encoding, or a segment that decodes to a `/`.
|
|
266
|
+
*/
|
|
267
|
+
const decodeSegments = (raw) => {
|
|
268
|
+
const segments = raw.split("/").map((segment) => {
|
|
269
|
+
const decoded = segment === "" ? void 0 : decodeSegment(segment);
|
|
270
|
+
return decoded === void 0 || decoded.includes("/") ? void 0 : decoded;
|
|
271
|
+
});
|
|
272
|
+
return segments.some((segment) => segment === void 0) ? void 0 : segments.join("/");
|
|
273
|
+
};
|
|
274
|
+
/**
|
|
275
|
+
* `pattern` required to match a whole raw parameter: `^(?:source)$` with its flags, so alternation
|
|
276
|
+
* and lazy quantifiers are tried against the whole value, never against a prefix of it.
|
|
277
|
+
*/
|
|
278
|
+
const wholePattern = (pattern) => new RegExp(`^(?:${pattern.source})$`, pattern.flags);
|
|
279
|
+
/**
|
|
280
|
+
* A matcher over a route table: the route answering `method` + raw `path`, with its parameters
|
|
281
|
+
* decoded once, or `undefined` (also for a parameter that is not valid percent-encoding). A raw
|
|
282
|
+
* parameter pattern must match the whole raw parameter; a pattern with the `g` or `y` flag (whose
|
|
283
|
+
* matches depend on earlier ones) is refused when the matcher is built.
|
|
284
|
+
*/
|
|
285
|
+
const routeMatcher = (routes) => {
|
|
286
|
+
const keys = routes.map((route) => emulatorRouteKey(route.method, route.path));
|
|
287
|
+
const duplicate = keys.find((key, index) => keys.indexOf(key) !== index);
|
|
288
|
+
if (duplicate !== void 0) throw new Error(`duplicate emulator route ${duplicate}`);
|
|
289
|
+
for (const route of routes) for (const [name, pattern] of Object.entries(route.params ?? {})) if (pattern.global || pattern.sticky) {
|
|
290
|
+
const where = `${route.method} ${route.path} parameter ${name}`;
|
|
291
|
+
throw new Error(`raw patterns take no g or y flag (${where})`);
|
|
292
|
+
}
|
|
293
|
+
const compiled = routes.map((route) => ({
|
|
294
|
+
route,
|
|
295
|
+
pattern: templatePattern(route.path),
|
|
296
|
+
names: templateNames(route.path),
|
|
297
|
+
whole: new Map(Object.entries(route.params ?? {}).map(([name, raw]) => [name, wholePattern(raw)])),
|
|
298
|
+
multi: new Set([...route.path.matchAll(/\{([A-Za-z]+)\+\}/g)].map((match) => match[1] ?? ""))
|
|
299
|
+
}));
|
|
300
|
+
return (method, path) => {
|
|
301
|
+
for (const candidate of compiled) {
|
|
302
|
+
if (candidate.route.method !== method.toUpperCase()) continue;
|
|
303
|
+
const match = candidate.pattern.exec(path);
|
|
304
|
+
if (match === null) continue;
|
|
305
|
+
const raws = candidate.names.map((name, index) => [name, match[index + 1] ?? ""]);
|
|
306
|
+
if (raws.some(([name, raw]) => {
|
|
307
|
+
const pattern = candidate.whole.get(name);
|
|
308
|
+
return pattern !== void 0 && !pattern.test(raw);
|
|
309
|
+
})) continue;
|
|
310
|
+
const params = {};
|
|
311
|
+
for (const [name, raw] of raws) {
|
|
312
|
+
const value = candidate.multi.has(name) ? decodeSegments(raw) : decodeSegment(raw);
|
|
313
|
+
if (value === void 0) return void 0;
|
|
314
|
+
params[name] = value;
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
route: candidate.route,
|
|
318
|
+
params
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
};
|
|
323
|
+
/** The template parameters of a route that have no raw pattern (fail-closed emulators need one). */
|
|
324
|
+
const unpatternedParams = (route) => templateNames(route.path).filter((name) => !route.params?.[name]);
|
|
325
|
+
const decodeJsonText = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Json));
|
|
326
|
+
/** Parsed JSON, or `undefined` for invalid JSON. */
|
|
327
|
+
const parseJsonText = (text) => {
|
|
328
|
+
const result = decodeJsonText(text);
|
|
329
|
+
return Result.isSuccess(result) ? result.success : void 0;
|
|
330
|
+
};
|
|
331
|
+
const isJsonObject = (value) => value !== void 0 && value !== null && Predicate.isObject(value) && !Array.isArray(value);
|
|
332
|
+
/**
|
|
333
|
+
* True when any object of a valid JSON text repeats a key, compared after JSON unescaping (so
|
|
334
|
+
* `"id"` and `"\u0069d"` are one key). `JSON.parse` would keep the last value silently.
|
|
335
|
+
*/
|
|
336
|
+
const repeatsJsonKey = (text) => {
|
|
337
|
+
const open = [];
|
|
338
|
+
let index = 0;
|
|
339
|
+
while (index < text.length) {
|
|
340
|
+
const character = text[index];
|
|
341
|
+
if (character === "\"") {
|
|
342
|
+
let end = index + 1;
|
|
343
|
+
while (end < text.length && text[end] !== "\"") end += text[end] === "\\" ? 2 : 1;
|
|
344
|
+
const top = open.at(-1);
|
|
345
|
+
if (top?.keys !== void 0 && top.expectKey) {
|
|
346
|
+
const key = parseJsonText(text.slice(index, end + 1));
|
|
347
|
+
if (!Predicate.isString(key) || top.keys.has(key)) return true;
|
|
348
|
+
top.keys.add(key);
|
|
349
|
+
top.expectKey = false;
|
|
350
|
+
}
|
|
351
|
+
index = end + 1;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (character === "{") open.push({
|
|
355
|
+
keys: /* @__PURE__ */ new Set(),
|
|
356
|
+
expectKey: true
|
|
357
|
+
});
|
|
358
|
+
else if (character === "[") open.push({
|
|
359
|
+
keys: void 0,
|
|
360
|
+
expectKey: false
|
|
361
|
+
});
|
|
362
|
+
else if (character === "}" || character === "]") open.pop();
|
|
363
|
+
else if (character === ",") {
|
|
364
|
+
const top = open.at(-1);
|
|
365
|
+
if (top?.keys !== void 0) top.expectKey = true;
|
|
366
|
+
}
|
|
367
|
+
index += 1;
|
|
368
|
+
}
|
|
369
|
+
return false;
|
|
370
|
+
};
|
|
371
|
+
/** The media type of a `content-type` value (lower-case, without parameters). */
|
|
372
|
+
const mediaType = (value) => (value ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
373
|
+
/**
|
|
374
|
+
* A JSON object with exactly the `required` keys plus any of the `optional` ones, or not
|
|
375
|
+
* emulated (naming the first missing or unknown key).
|
|
376
|
+
*/
|
|
377
|
+
const exactObject = (value, label, required, optional = []) => {
|
|
378
|
+
if (!isJsonObject(value)) return notEmulated(`${label} must be a JSON object`);
|
|
379
|
+
const unknown = Object.keys(value).find((key) => !required.includes(key) && !optional.includes(key));
|
|
380
|
+
if (unknown !== void 0) return notEmulated(`${label} key '${unknown}' is not emulated`);
|
|
381
|
+
const missing = required.find((key) => !(key in value));
|
|
382
|
+
return missing === void 0 ? value : notEmulated(`${label} without '${missing}' is not emulated`);
|
|
383
|
+
};
|
|
384
|
+
/**
|
|
385
|
+
* Constant-text shape check (for fail-closed emulators): a JSON object with exactly the `required`
|
|
386
|
+
* keys plus any of the `optional` ones, or not emulated. Unlike `exactObject`, a reason never
|
|
387
|
+
* echoes a request's own key; it names only `label` and a missing key from the route's own list.
|
|
388
|
+
*/
|
|
389
|
+
const exactBodyKeys = (value, label, required, optional = []) => {
|
|
390
|
+
if (!isJsonObject(value)) return notEmulated(`${label} must be a JSON object`);
|
|
391
|
+
const keys = Object.keys(value);
|
|
392
|
+
if (keys.some((key) => !required.includes(key) && !optional.includes(key))) return notEmulated(`${label} has a key this route does not take`);
|
|
393
|
+
const missing = required.find((key) => !keys.includes(key));
|
|
394
|
+
return missing === void 0 ? value : notEmulated(`${label} without '${missing}' is not emulated`);
|
|
395
|
+
};
|
|
396
|
+
/** The raw parameter names of a raw query, in order (`[]` for an empty query). */
|
|
397
|
+
const rawQueryNames = (raw) => raw === "" ? [] : raw.split("&").map((component) => component.split("=", 1)[0] ?? "");
|
|
398
|
+
/**
|
|
399
|
+
* Constant-text query check (for fail-closed emulators): exactly the `required` query keys plus
|
|
400
|
+
* any of the `optional` ones, each once, as a record of their values; or not emulated. A reason
|
|
401
|
+
* never echoes a request's own key; it names only a missing key from the route's own list. With
|
|
402
|
+
* `rawNames`, every raw parameter name must also be the plain name it decodes to.
|
|
403
|
+
*/
|
|
404
|
+
const exactQuery = (request, required, optional = [], options = {}) => {
|
|
405
|
+
const keys = [...request.query.keys()];
|
|
406
|
+
if (keys.length !== new Set(keys).size) return notEmulated("repeated query parameters are not emulated");
|
|
407
|
+
if (keys.some((key) => !required.includes(key) && !optional.includes(key))) return notEmulated("a query parameter this route does not take is not emulated");
|
|
408
|
+
if (options.rawNames === true) {
|
|
409
|
+
const raw = rawQueryNames(request.rawQuery ?? "");
|
|
410
|
+
if (raw.length !== keys.length || raw.some((name, index) => name !== keys[index])) return notEmulated("a query parameter name in any but its plain form is not emulated");
|
|
411
|
+
}
|
|
412
|
+
const missing = required.find((key) => !keys.includes(key));
|
|
413
|
+
return missing === void 0 ? Object.fromEntries(request.query) : notEmulated(`requests without query parameter ${missing} are not emulated on this route`);
|
|
414
|
+
};
|
|
415
|
+
/** An integer in `[minimum, maximum]`, or not emulated. */
|
|
416
|
+
const integerIn = (value, label, minimum, maximum) => Predicate.isNumber(value) && Number.isSafeInteger(value) && value >= minimum && value <= maximum ? value : notEmulated(`${label} must be an integer from ${minimum} to ${maximum}`);
|
|
417
|
+
const defaultErrorTexts = {
|
|
418
|
+
failed: "the emulator could not build the response",
|
|
419
|
+
closed: "the emulator is closed",
|
|
420
|
+
unhandled: "the emulator failed to handle the request"
|
|
421
|
+
};
|
|
422
|
+
const strict = { onExcessProperty: "error" };
|
|
423
|
+
const decodeFault = Schema.decodeUnknownResult(StatefulFault, strict);
|
|
424
|
+
const decodeFaultList = Schema.decodeUnknownResult(Schema.Union([StatefulFault, Schema.Struct({ faults: Schema.Array(StatefulFault) })]), strict);
|
|
425
|
+
const decodeStreamFault = Schema.decodeUnknownResult(StatefulStreamFault, strict);
|
|
426
|
+
const decodeStreamFaultList = Schema.decodeUnknownResult(Schema.Union([StatefulStreamFault, Schema.Struct({ faults: Schema.Array(StatefulStreamFault) })]), strict);
|
|
427
|
+
const issueMessage = (issue) => new Schema.SchemaError(issue).message;
|
|
428
|
+
const bearerPattern = /^bearer\s+\S+/i;
|
|
429
|
+
/**
|
|
430
|
+
* Fail-closed mode: exactly `Bearer `, one space, and one token of at least 8 non-space characters
|
|
431
|
+
* (no other scheme spelling, no extra words, so combined duplicate headers never match). The token
|
|
432
|
+
* must also match the RFC 6750 `b64token` syntax, start with a character that completes no escape
|
|
433
|
+
* (not a hex digit, not `n`, `r`, `t`, `u`), and hold a character outside the JSON-number alphabet
|
|
434
|
+
* (`isRecognisableBearerValue`, which says why). The value is guarded, never compared against
|
|
435
|
+
* anything, stored, or ledgered.
|
|
436
|
+
*/
|
|
437
|
+
const recognisableBearerPattern = /^Bearer ([^\s]{8,})$/;
|
|
438
|
+
/** The fail-closed bearer of an `Authorization` header, or `undefined` when unrecognisable. */
|
|
439
|
+
const recognisableBearer = (authorization) => {
|
|
440
|
+
const bearer = recognisableBearerPattern.exec(authorization ?? "")?.[1];
|
|
441
|
+
return bearer !== void 0 && isRecognisableBearerValue(bearer) ? bearer : void 0;
|
|
442
|
+
};
|
|
443
|
+
/** The raw query of a request URL (after `?`, before `#`), or `undefined` when it has none. */
|
|
444
|
+
const rawQuery = (requestUrl) => {
|
|
445
|
+
const withoutFragment = requestUrl.split("#", 1)[0] ?? "";
|
|
446
|
+
const start = withoutFragment.indexOf("?");
|
|
447
|
+
return start === -1 ? void 0 : withoutFragment.slice(start + 1);
|
|
448
|
+
};
|
|
449
|
+
/** True for a bare `?` or an empty `&`-separated component (`URLSearchParams` drops both). */
|
|
450
|
+
const hasEmptyQueryComponent = (requestUrl) => {
|
|
451
|
+
const query = rawQuery(requestUrl);
|
|
452
|
+
return query !== void 0 && query.split("&").some((component) => component === "");
|
|
453
|
+
};
|
|
454
|
+
/**
|
|
455
|
+
* Fail-closed mode: a query key or value or a header value that starts like a JSON object, array,
|
|
456
|
+
* or string (`{`, `[`, `"`) is recorded parsed with credential-named keys redacted at any depth, or
|
|
457
|
+
* whole as `<redacted>` when it does not parse; any other text (numbers included) is recorded
|
|
458
|
+
* unchanged. A request whose text repeats a guarded secret never gets here: it is ledgered with
|
|
459
|
+
* constant text only.
|
|
460
|
+
*/
|
|
461
|
+
const recordedJsonLooking = (text) => {
|
|
462
|
+
const trimmed = text.trimStart();
|
|
463
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[") && !trimmed.startsWith("\"")) return text;
|
|
464
|
+
const parsed = parseJsonText(text);
|
|
465
|
+
return parsed === void 0 ? redactedCredentialValue : JSON.stringify(redactCredentialFields(parsed));
|
|
466
|
+
};
|
|
467
|
+
/**
|
|
468
|
+
* Fail-closed mode: the recorded query, built from every original pair (never one value per key)
|
|
469
|
+
* and keyed by recorded key: credential-named keys have their values redacted, keys and values are
|
|
470
|
+
* recorded as `recordedJsonLooking` does, and a key recorded more than once (two original keys may
|
|
471
|
+
* record alike, for example two unparseable JSON-looking keys as `<redacted>`) lists its values in
|
|
472
|
+
* order, as a JSON array.
|
|
473
|
+
*/
|
|
474
|
+
const recordedQueryPairs = (query, scrub) => {
|
|
475
|
+
const values = /* @__PURE__ */ new Map();
|
|
476
|
+
for (const [key, value] of query) {
|
|
477
|
+
const recordedKey = scrub(recordedJsonLooking(key));
|
|
478
|
+
const recordedValue = scrub(isCredentialQueryKey(key) ? redactedCredentialValue : recordedJsonLooking(value));
|
|
479
|
+
values.set(recordedKey, [...values.get(recordedKey) ?? [], recordedValue]);
|
|
480
|
+
}
|
|
481
|
+
return Object.fromEntries([...values].map(([key, list]) => [key, list.length === 1 ? list[0] ?? "" : JSON.stringify(list)]));
|
|
482
|
+
};
|
|
483
|
+
/** The constant reason of a body a route's decoded view refuses to check (the view threw). */
|
|
484
|
+
const uncheckableBodyReason = "the request body cannot be checked for the credential";
|
|
485
|
+
const missingBearerReason = "requests without Authorization: Bearer <token of at least 8 characters> are not emulated";
|
|
486
|
+
const pathMatches = (pattern, path) => pattern.endsWith("*") ? path.startsWith(pattern.slice(0, -1)) : pattern === path;
|
|
487
|
+
const faultMatches = (fault, method, path, route) => (fault.match?.method === void 0 || fault.match.method.toUpperCase() === method) && (fault.match?.path === void 0 || pathMatches(fault.match.path, path)) && (fault.match?.route === void 0 || fault.match.route === route);
|
|
488
|
+
const faultBody = (status) => ({ error: {
|
|
489
|
+
type: "emulator_fault",
|
|
490
|
+
message: `Emulator fault: status ${status}.`
|
|
491
|
+
} });
|
|
492
|
+
const readText = (request) => request.text().then((text) => text, () => void 0);
|
|
493
|
+
const readBytes = (request) => request.arrayBuffer().then((buffer) => new Uint8Array(buffer), () => void 0);
|
|
494
|
+
const isControlPath = (path) => path === "/_emulate" || path.startsWith("/_emulate/");
|
|
495
|
+
const withEvidence = (response, evidence) => {
|
|
496
|
+
const headers = new Headers(response.headers);
|
|
497
|
+
if (evidence === "unverified") headers.set(emulatorEvidenceHeader, "unverified");
|
|
498
|
+
return new Response(response.body, {
|
|
499
|
+
status: response.status,
|
|
500
|
+
statusText: response.statusText,
|
|
501
|
+
headers
|
|
502
|
+
});
|
|
503
|
+
};
|
|
504
|
+
/**
|
|
505
|
+
* The recorded query: credential-named keys redacted, and any value that parses as a JSON object or
|
|
506
|
+
* array (Dropbox's browser-style `arg` parameter, for example) recorded with its credential-named
|
|
507
|
+
* keys redacted at any depth, and a JSON-looking value that does not parse recorded as `<redacted>`,
|
|
508
|
+
* as a recorded JSON header is.
|
|
509
|
+
*/
|
|
510
|
+
const recordedQuery = (query) => Object.fromEntries(Object.entries(redactCredentialQuery(query)).map(([key, value]) => {
|
|
511
|
+
if (!value.trimStart().startsWith("{") && !value.trimStart().startsWith("[")) return [key, value];
|
|
512
|
+
const parsed = parseJsonText(value);
|
|
513
|
+
return [key, parsed === void 0 ? redactedCredentialValue : JSON.stringify(redactCredentialFields(parsed))];
|
|
514
|
+
}));
|
|
515
|
+
/** A recorded header value: JSON with credential-named keys redacted, or the plain value. */
|
|
516
|
+
const recordedHeaderValue = (header, value) => {
|
|
517
|
+
if (!header.json) return value;
|
|
518
|
+
const parsed = parseJsonText(value);
|
|
519
|
+
return parsed === void 0 ? redactedCredentialValue : JSON.stringify(redactCredentialFields(parsed));
|
|
520
|
+
};
|
|
521
|
+
/** `constantRefusals`: a refused request's entry keeps only constant text. */
|
|
522
|
+
const blankEntry = (entry) => {
|
|
523
|
+
entry.method = unrecognisedMethod(entry.method.toUpperCase());
|
|
524
|
+
entry.path = unrecognisedLedgerPath;
|
|
525
|
+
entry.query = {};
|
|
526
|
+
entry.headers = {};
|
|
527
|
+
delete entry.body;
|
|
528
|
+
delete entry.bodyBytes;
|
|
529
|
+
};
|
|
530
|
+
const snapshotEntry = (entry) => ({
|
|
531
|
+
...entry,
|
|
532
|
+
query: { ...entry.query },
|
|
533
|
+
headers: { ...entry.headers }
|
|
534
|
+
});
|
|
535
|
+
/** Whether an entry records no truncation fault (only a status fault, if any). */
|
|
536
|
+
const hasStatusFaultOnly = (entry) => entry.fault !== "truncate-after-chunks";
|
|
537
|
+
/** `makeHeaderlessStatefulEmulator`: the entry without its (always empty) `headers` field. */
|
|
538
|
+
const snapshotHeaderlessEntry = ({ headers: _headers, bodyBytes: _bodyBytes, ...entry }) => {
|
|
539
|
+
const copy = {
|
|
540
|
+
...entry,
|
|
541
|
+
query: { ...entry.query }
|
|
542
|
+
};
|
|
543
|
+
if (hasStatusFaultOnly(copy)) return copy;
|
|
544
|
+
throw new Error("a headerless stateful emulator records status faults only");
|
|
545
|
+
};
|
|
546
|
+
/**
|
|
547
|
+
* Resolved mode: the constant reason when the raw query or the resolution's `guardedPath` repeats a
|
|
548
|
+
* guarded secret (`repeatsSecret`: raw, and percent-decoded once), or `undefined`. Checked before
|
|
549
|
+
* the body is read, so such a refusal leaves the request body unread.
|
|
550
|
+
*/
|
|
551
|
+
const partsCredentialRepeat = (url, guardedPath, secrets) => {
|
|
552
|
+
if (repeatsSecret(url.search, secrets)) return "the query repeats the credential";
|
|
553
|
+
return repeatsSecret(guardedPath, secrets) ? "the request path repeats the credential" : void 0;
|
|
554
|
+
};
|
|
555
|
+
/**
|
|
556
|
+
* Resolved mode: the constant reason when the body text repeats a guarded secret, or `undefined`:
|
|
557
|
+
* `repeatsSecret` on the raw text, and for a body that parses as JSON also `jsonRepeatsSecret`
|
|
558
|
+
* (every key, string, and number) and as it would be recorded. A body that cannot be read or parsed
|
|
559
|
+
* is left to the route's body check.
|
|
560
|
+
*/
|
|
561
|
+
const bodyCredentialRepeat = (text, secrets) => {
|
|
562
|
+
if (text === void 0 || text === "") return void 0;
|
|
563
|
+
const repeat = "the request body repeats the credential";
|
|
564
|
+
if (repeatsSecret(text, secrets)) return repeat;
|
|
565
|
+
const json = parseJsonText(text);
|
|
566
|
+
return json !== void 0 && (jsonRepeatsSecret(json, secrets) || repeatsSecret(JSON.stringify(json), secrets)) ? repeat : void 0;
|
|
567
|
+
};
|
|
568
|
+
/** The body as `Request.text()` reads it: UTF-8, a leading byte order mark dropped. */
|
|
569
|
+
const bodyText = (bytes) => new TextDecoder().decode(bytes);
|
|
570
|
+
const snapshotFault = (state) => ({ ...state });
|
|
571
|
+
const isStreamedCommit = (planned) => !Predicate.isFunction(planned);
|
|
572
|
+
/** The constant reason of a prepared output that would repeat the bearer (`guardOutput`). */
|
|
573
|
+
const outputRepeatReason = "the answer would repeat the credential";
|
|
574
|
+
/**
|
|
575
|
+
* `guardOutput`: whether a streamed commit's answer (every header name and value, every chunk, and
|
|
576
|
+
* the whole body) or any text it would persist repeats a guarded secret, through the closure.
|
|
577
|
+
*/
|
|
578
|
+
const outputRepeatsSecret = (planned, secrets) => {
|
|
579
|
+
const { answer } = planned;
|
|
580
|
+
return [
|
|
581
|
+
...Object.entries(answer.headers).flat(),
|
|
582
|
+
...answer.chunks,
|
|
583
|
+
answer.chunks.join(""),
|
|
584
|
+
...planned.persisted
|
|
585
|
+
].some((text) => textRepeatsSecret(text, secrets));
|
|
586
|
+
};
|
|
587
|
+
/**
|
|
588
|
+
* A streamed answer's body, strictly pull-driven (one chunk per pull), closed cleanly after
|
|
589
|
+
* `after` chunks when a truncation fault applies; no body at all for an answer without chunks.
|
|
590
|
+
*/
|
|
591
|
+
const streamedResponse = (answer, after) => {
|
|
592
|
+
if (answer.chunks.length === 0) return new Response(null, {
|
|
593
|
+
status: answer.status,
|
|
594
|
+
headers: answer.headers
|
|
595
|
+
});
|
|
596
|
+
const encoder = new TextEncoder();
|
|
597
|
+
const sent = after === void 0 ? answer.chunks : answer.chunks.slice(0, after);
|
|
598
|
+
let next = 0;
|
|
599
|
+
const body = new ReadableStream({ pull: (controller) => {
|
|
600
|
+
const chunk = sent[next];
|
|
601
|
+
if (chunk === void 0) {
|
|
602
|
+
controller.close();
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
next += 1;
|
|
606
|
+
controller.enqueue(encoder.encode(chunk));
|
|
607
|
+
} }, { highWaterMark: 0 });
|
|
608
|
+
return new Response(body, {
|
|
609
|
+
status: answer.status,
|
|
610
|
+
headers: answer.headers
|
|
611
|
+
});
|
|
612
|
+
};
|
|
613
|
+
/**
|
|
614
|
+
* Build the wrapper over a core runtime (`makeStatefulEmulator`, with truncation faults
|
|
615
|
+
* `makeChunkedStatefulEmulator`, or without ledgered headers `makeHeaderlessStatefulEmulator`).
|
|
616
|
+
* `createCore` receives the dispatch the core must run for every request (its only route) and
|
|
617
|
+
* returns the runtime adapter; `snapshotLedgerEntry` copies a ledger entry for readers.
|
|
618
|
+
*/
|
|
619
|
+
const buildStatefulEmulator = async (config, createCore, chunkFaults, snapshotLedgerEntry) => {
|
|
620
|
+
const credentialHeader = config.recordHeaders.find((header) => isCredentialHeaderName(header.name));
|
|
621
|
+
if (credentialHeader !== void 0) throw new Error(`the ledger never records the credential header ${credentialHeader.name}`);
|
|
622
|
+
if (config.resolveRequest !== void 0 && config.failClosed !== void 0) throw new Error("resolveRequest (resolved mode) and failClosed exclude each other");
|
|
623
|
+
if (config.resolveRequest !== void 0 && config.recordHeaders.length > 0) throw new Error("resolveRequest (resolved mode) records no request header (recordHeaders)");
|
|
624
|
+
const resolveRequest = config.resolveRequest;
|
|
625
|
+
const errorTexts = config.errorTexts ?? defaultErrorTexts;
|
|
626
|
+
if (config.bearerDigest !== void 0 && config.failClosed === void 0) throw new Error("bearerDigest needs fail-closed mode (failClosed)");
|
|
627
|
+
for (const option of [
|
|
628
|
+
"constantRefusals",
|
|
629
|
+
"guardAllHeaders",
|
|
630
|
+
"guardOutput"
|
|
631
|
+
]) if (config[option] === true && config.failClosed === void 0) throw new Error(`${option} needs fail-closed mode (failClosed)`);
|
|
632
|
+
const constantRefusals = config.constantRefusals === true;
|
|
633
|
+
const templates = new Set(config.routes.map((route) => route.path));
|
|
634
|
+
const variantRows = /* @__PURE__ */ new Set();
|
|
635
|
+
for (const route of config.routes) {
|
|
636
|
+
if (route.variants === void 0) continue;
|
|
637
|
+
if (route.variants.length === 0) throw new Error(`route ${route.method} ${route.path} has an empty variants list`);
|
|
638
|
+
for (const variant of route.variants) {
|
|
639
|
+
const where = `variant ${variant.method} ${variant.path} of ${route.method} ${route.path}`;
|
|
640
|
+
if (variant.evidence !== route.evidence) throw new Error(`${where} must carry its route's evidence`);
|
|
641
|
+
if (templates.has(variant.path) || variantRows.has(variant.path)) throw new Error(`${where} repeats a route template or another variant path`);
|
|
642
|
+
variantRows.add(variant.path);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (config.failClosed !== void 0) for (const route of config.routes) {
|
|
646
|
+
const missing = unpatternedParams(route);
|
|
647
|
+
if (missing.length > 0) {
|
|
648
|
+
const where = `fail-closed route ${route.method} ${route.path}`;
|
|
649
|
+
throw new Error(`${where} needs raw patterns for ${missing.join(", ")}`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const match = routeMatcher(config.routes);
|
|
653
|
+
const manifest = config.routes.flatMap(routeManifest);
|
|
654
|
+
const manifestPaths = new Set(manifest.map((row) => row.path));
|
|
655
|
+
const jobs = /* @__PURE__ */ new Map();
|
|
656
|
+
let nextJobId = 1;
|
|
657
|
+
const dispatch = (state, request) => {
|
|
658
|
+
const job = jobs.get(Number(request.headers.get("x-emulator-job-id") ?? "NaN"));
|
|
659
|
+
if (job === void 0) return handlerFailedResponse();
|
|
660
|
+
job.dispatched = true;
|
|
661
|
+
try {
|
|
662
|
+
const planned = job.admission.plan(state, {
|
|
663
|
+
env: config.env,
|
|
664
|
+
seq: job.seq
|
|
665
|
+
});
|
|
666
|
+
if (isNotEmulated(planned)) {
|
|
667
|
+
if (resolveRequest !== void 0 && Result.isFailure(Result.try(() => encodeURIComponent(planned.reason)))) return handlerFailedResponse();
|
|
668
|
+
const reason = scrubSecrets(planned.reason, job.secrets);
|
|
669
|
+
job.notEmulated = reason;
|
|
670
|
+
return resolveRequest === void 0 ? notEmulatedResponse(reason) : answeredOutsideCore();
|
|
671
|
+
}
|
|
672
|
+
const streamed = isStreamedCommit(planned);
|
|
673
|
+
if (config.guardOutput === true) {
|
|
674
|
+
if (!streamed) return handlerFailedResponse();
|
|
675
|
+
if (outputRepeatsSecret(planned, job.secrets)) {
|
|
676
|
+
job.notEmulated = outputRepeatReason;
|
|
677
|
+
job.credentialRepeat = true;
|
|
678
|
+
return notEmulatedResponse(outputRepeatReason);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
const decision = job.decideFault(streamed ? planned.answer.chunks.length : void 0);
|
|
682
|
+
if (decision.kind === "answer") {
|
|
683
|
+
if (resolveRequest === void 0) return decision.response;
|
|
684
|
+
job.faultAnswer = decision.response;
|
|
685
|
+
return answeredOutsideCore();
|
|
686
|
+
}
|
|
687
|
+
if (!streamed) return planned();
|
|
688
|
+
if (decision.kind === "truncate") return streamedResponse(planned.answer, decision.after);
|
|
689
|
+
planned.commit();
|
|
690
|
+
return streamedResponse(planned.answer, void 0);
|
|
691
|
+
} catch {
|
|
692
|
+
return handlerFailedResponse();
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
const core = await createCore(dispatch);
|
|
696
|
+
let baseline = config.initial;
|
|
697
|
+
let entries = [];
|
|
698
|
+
let faultStates = [];
|
|
699
|
+
let nextSeq = 1;
|
|
700
|
+
let nextFaultId = 1;
|
|
701
|
+
let closed = false;
|
|
702
|
+
const clearLedger = () => {
|
|
703
|
+
entries = [];
|
|
704
|
+
nextSeq = 1;
|
|
705
|
+
};
|
|
706
|
+
/** Why a fault's `match.route` could never match (it names no manifest row), or `undefined`. */
|
|
707
|
+
const matchRouteProblem = (fault) => {
|
|
708
|
+
const route = fault.match?.route;
|
|
709
|
+
return route === void 0 || manifestPaths.has(route) ? void 0 : "match.route must name a manifest row of this emulator";
|
|
710
|
+
};
|
|
711
|
+
const addFault = (input) => {
|
|
712
|
+
const decoded = chunkFaults ? decodeStreamFault(input) : decodeFault(input);
|
|
713
|
+
if (Result.isFailure(decoded)) return issueMessage(decoded.failure.issue);
|
|
714
|
+
const problem = matchRouteProblem(decoded.success);
|
|
715
|
+
if (problem !== void 0) return problem;
|
|
716
|
+
const state = {
|
|
717
|
+
id: nextFaultId++,
|
|
718
|
+
fault: decoded.success,
|
|
719
|
+
remaining: decoded.success.count,
|
|
720
|
+
applied: 0
|
|
721
|
+
};
|
|
722
|
+
faultStates.push(state);
|
|
723
|
+
return snapshotFault(state);
|
|
724
|
+
};
|
|
725
|
+
const takeFault = (method, path, route) => faultStates.find((state) => (state.remaining === void 0 || state.remaining > 0) && faultMatches(state.fault, method, path, route));
|
|
726
|
+
const spend = (state) => {
|
|
727
|
+
state.applied += 1;
|
|
728
|
+
if (state.remaining !== void 0) state.remaining -= 1;
|
|
729
|
+
};
|
|
730
|
+
/** Build the fault's response first: a response that cannot be built must not consume it. */
|
|
731
|
+
const applyFault = (state, fault) => {
|
|
732
|
+
const headers = new Headers(fault.headers ?? {});
|
|
733
|
+
const body = fault.body ?? faultBody(fault.status);
|
|
734
|
+
if (!headers.has("content-type")) headers.set("content-type", Predicate.isString(body) ? "text/plain" : "application/json");
|
|
735
|
+
const response = new Response(Predicate.isString(body) ? body : JSON.stringify(body), {
|
|
736
|
+
status: fault.status,
|
|
737
|
+
headers
|
|
738
|
+
});
|
|
739
|
+
spend(state);
|
|
740
|
+
return response;
|
|
741
|
+
};
|
|
742
|
+
const reset = async () => {
|
|
743
|
+
clearLedger();
|
|
744
|
+
faultStates = [];
|
|
745
|
+
config.clearRuntime();
|
|
746
|
+
await core.restore(baseline);
|
|
747
|
+
};
|
|
748
|
+
const reseed = async (input) => {
|
|
749
|
+
const next = config.buildSeed(input);
|
|
750
|
+
if (Predicate.isString(next)) return next;
|
|
751
|
+
await core.restore(next);
|
|
752
|
+
config.clearRuntime();
|
|
753
|
+
baseline = next;
|
|
754
|
+
return next;
|
|
755
|
+
};
|
|
756
|
+
const coverage = () => ({
|
|
757
|
+
routes: manifest.map((route) => ({
|
|
758
|
+
...route,
|
|
759
|
+
requests: entries.filter((entry) => entry.route === route.path && (variantRows.has(route.path) || entry.method.toUpperCase() === route.method)).length
|
|
760
|
+
})),
|
|
761
|
+
unknownRouteRequests: entries.filter((entry) => entry.evidence === "unknown-route").length,
|
|
762
|
+
notEmulatedRequests: entries.filter((entry) => entry.notEmulated !== void 0).length
|
|
763
|
+
});
|
|
764
|
+
/** The ledgered 400; the reason is scrubbed of the request's guarded secrets first. */
|
|
765
|
+
const refuse = (entry, reason, secrets = []) => {
|
|
766
|
+
const safe = scrubSecrets(reason, secrets);
|
|
767
|
+
entry.notEmulated = safe;
|
|
768
|
+
return notEmulatedResponse(safe);
|
|
769
|
+
};
|
|
770
|
+
/**
|
|
771
|
+
* Fail-closed mode: the constant reason when any part of a recognised request repeats a guarded
|
|
772
|
+
* secret, or `undefined`. Checked, each through the fixpoint closure of `textRepeatsSecret` (a
|
|
773
|
+
* capped closure counts as a repeat): the raw path and every path segment, the raw query and
|
|
774
|
+
* every decoded query key and value, every recorded header, and the body.
|
|
775
|
+
*/
|
|
776
|
+
const credentialRepeat = (request, url, body, secrets, binding) => {
|
|
777
|
+
if (textRepeatsSecret(url.pathname, secrets) || url.pathname.split("/").some((segment) => textRepeatsSecret(segment, secrets))) return "the request path repeats the credential";
|
|
778
|
+
if (textRepeatsSecret(url.search, secrets) || [...url.searchParams].some(([key, value]) => textRepeatsSecret(key, secrets) || textRepeatsSecret(value, secrets))) return "the query repeats the credential";
|
|
779
|
+
if (config.recordHeaders.some((recorded) => textRepeatsSecret(request.headers.get(recorded.name) ?? "", secrets))) return "a recorded request header repeats the credential";
|
|
780
|
+
if (config.guardAllHeaders === true) {
|
|
781
|
+
const headers = [];
|
|
782
|
+
request.headers.forEach((value, name) => {
|
|
783
|
+
if (name !== "authorization") headers.push(name, value);
|
|
784
|
+
});
|
|
785
|
+
if (headers.some((text) => textRepeatsSecret(text, secrets))) return "a request header repeats the credential";
|
|
786
|
+
}
|
|
787
|
+
if (body === void 0) return void 0;
|
|
788
|
+
const repeat = "the request body repeats the credential";
|
|
789
|
+
if (textRepeatsSecret(body, secrets)) return repeat;
|
|
790
|
+
const { decodedViews, viewRefusalReasons = [] } = binding;
|
|
791
|
+
const views = Result.try(() => decodedViews === void 0 ? [] : decodedViews(body));
|
|
792
|
+
if (Result.isFailure(views)) {
|
|
793
|
+
const refusal = views.failure;
|
|
794
|
+
if (!(refusal instanceof DecodedViewRefusal)) return uncheckableBodyReason;
|
|
795
|
+
if (refusal.decoded.some((view) => textRepeatsSecret(view, secrets))) return repeat;
|
|
796
|
+
return viewRefusalReasons.includes(refusal.reason) ? scrubSecrets(refusal.reason, secrets) : uncheckableBodyReason;
|
|
797
|
+
}
|
|
798
|
+
return views.success.some((view) => textRepeatsSecret(view, secrets)) ? repeat : void 0;
|
|
799
|
+
};
|
|
800
|
+
/** Everything after the route match: credential, headers, body, shape, then the core. */
|
|
801
|
+
const routed = async (request, url, entry, matched, secrets, arrivedOn, recorded, guardedPath) => {
|
|
802
|
+
const header = (name) => isCredentialHeaderName(name) || guardedPath !== void 0 && name.toLowerCase() !== "content-type" ? void 0 : request.headers.get(name) ?? void 0;
|
|
803
|
+
const refused = (reason) => refuse(entry, reason, secrets);
|
|
804
|
+
let resolvedBody;
|
|
805
|
+
if (guardedPath !== void 0) {
|
|
806
|
+
const partsRepeat = partsCredentialRepeat(url, guardedPath, secrets);
|
|
807
|
+
if (partsRepeat !== void 0) return refused(partsRepeat);
|
|
808
|
+
resolvedBody = { bytes: await readBytes(request) };
|
|
809
|
+
const bodyRepeat = bodyCredentialRepeat(resolvedBody.bytes === void 0 ? void 0 : bodyText(resolvedBody.bytes), secrets);
|
|
810
|
+
if (bodyRepeat !== void 0) return refused(bodyRepeat);
|
|
811
|
+
} else if (config.failClosed === void 0) {
|
|
812
|
+
if (!bearerPattern.test(request.headers.get("authorization") ?? "")) return refused("a non-empty Authorization: Bearer credential is required");
|
|
813
|
+
} else {
|
|
814
|
+
if (secrets.length === 0) return refused(missingBearerReason);
|
|
815
|
+
if (hasEmptyQueryComponent(request.url)) return refused("empty query components (a bare ? or a stray &) are not emulated");
|
|
816
|
+
}
|
|
817
|
+
const problem = config.requestProblem?.(header);
|
|
818
|
+
if (problem !== void 0) return refused(problem);
|
|
819
|
+
const resolvedText = () => resolvedBody?.bytes === void 0 ? void 0 : bodyText(resolvedBody.bytes);
|
|
820
|
+
let json;
|
|
821
|
+
let bytes;
|
|
822
|
+
switch (matched.route.body) {
|
|
823
|
+
case "none": {
|
|
824
|
+
const text = resolvedBody === void 0 ? await readText(request) : resolvedText();
|
|
825
|
+
if (text === void 0 || text !== "") return refused("this route takes no request body");
|
|
826
|
+
break;
|
|
827
|
+
}
|
|
828
|
+
case "json": {
|
|
829
|
+
const text = resolvedBody === void 0 ? await readText(request) : resolvedText();
|
|
830
|
+
if (mediaType(header("content-type")) !== "application/json") return refused("this route takes a content-type: application/json body");
|
|
831
|
+
const parsed = text === void 0 ? void 0 : parseJsonText(text);
|
|
832
|
+
if (parsed === void 0) return refused("the request body is not valid JSON");
|
|
833
|
+
if (config.uniqueJsonKeys === true && text !== void 0 && repeatsJsonKey(text)) return refused("a JSON body with a repeated key is not emulated");
|
|
834
|
+
recorded.body = redactCredentialFields(parsed);
|
|
835
|
+
if (!constantRefusals) entry.body = recorded.body;
|
|
836
|
+
json = parsed;
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
839
|
+
case "json-or-empty": {
|
|
840
|
+
const text = resolvedBody === void 0 ? await readText(request) : resolvedText();
|
|
841
|
+
if (text === void 0) return refused("the request body is unreadable");
|
|
842
|
+
if (text === "") break;
|
|
843
|
+
const parsed = parseJsonText(text);
|
|
844
|
+
if (parsed === void 0) return refused("the request body is not JSON");
|
|
845
|
+
if (config.uniqueJsonKeys === true && repeatsJsonKey(text)) return refused("a JSON body with a repeated key is not emulated");
|
|
846
|
+
recorded.body = redactCredentialFields(parsed);
|
|
847
|
+
if (!constantRefusals) entry.body = recorded.body;
|
|
848
|
+
json = parsed;
|
|
849
|
+
break;
|
|
850
|
+
}
|
|
851
|
+
case "bytes":
|
|
852
|
+
bytes = resolvedBody === void 0 ? await readBytes(request) : resolvedBody.bytes;
|
|
853
|
+
if (bytes === void 0) return refused("the request body is unreadable");
|
|
854
|
+
recorded.bodyBytes = bytes.byteLength;
|
|
855
|
+
if (!constantRefusals) entry.bodyBytes = recorded.bodyBytes;
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
let bearerDigest;
|
|
859
|
+
const [bearer] = secrets;
|
|
860
|
+
if (config.bearerDigest !== void 0 && bearer !== void 0) {
|
|
861
|
+
const digest = Result.try(() => config.bearerDigest?.(bearer, arrivedOn));
|
|
862
|
+
if (Result.isFailure(digest) || !Predicate.isString(digest.success) || textRepeatsSecret(digest.success, secrets)) {
|
|
863
|
+
entry.responseError = "the bearer digest failed";
|
|
864
|
+
return emulatorError(500, errorTexts.failed);
|
|
865
|
+
}
|
|
866
|
+
bearerDigest = digest.success;
|
|
867
|
+
}
|
|
868
|
+
const admitted = matched.route.admit({
|
|
869
|
+
method: request.method.toUpperCase(),
|
|
870
|
+
path: guardedPath === void 0 ? url.pathname : entry.path,
|
|
871
|
+
params: matched.params,
|
|
872
|
+
query: url.searchParams,
|
|
873
|
+
rawQuery: rawQuery(request.url) ?? "",
|
|
874
|
+
header,
|
|
875
|
+
headerNames: guardedPath === void 0 ? [...request.headers.keys()] : [],
|
|
876
|
+
json,
|
|
877
|
+
bytes,
|
|
878
|
+
bearerDigest
|
|
879
|
+
}, config.env);
|
|
880
|
+
if (isNotEmulated(admitted)) return refused(admitted.reason);
|
|
881
|
+
const variants = matched.route.variants;
|
|
882
|
+
const variant = admitted.variant;
|
|
883
|
+
if (variants === void 0 ? variant !== void 0 : !variants.some((row) => row.path === variant)) {
|
|
884
|
+
entry.responseError = "the route admitted the request as no row of its manifest";
|
|
885
|
+
return emulatorError(500, errorTexts.failed);
|
|
886
|
+
}
|
|
887
|
+
if (variant !== void 0) entry.route = variant;
|
|
888
|
+
if (constantRefusals) {
|
|
889
|
+
entry.method = recorded.method;
|
|
890
|
+
entry.path = recorded.path;
|
|
891
|
+
entry.query = recorded.query;
|
|
892
|
+
entry.headers = recorded.headers;
|
|
893
|
+
if (recorded.body !== void 0) entry.body = recorded.body;
|
|
894
|
+
if (recorded.bodyBytes !== void 0) entry.bodyBytes = recorded.bodyBytes;
|
|
895
|
+
}
|
|
896
|
+
const method = request.method.toUpperCase();
|
|
897
|
+
const jobId = nextJobId++;
|
|
898
|
+
const requestPath = guardedPath === void 0 ? url.pathname : entry.path;
|
|
899
|
+
const job = {
|
|
900
|
+
admission: admitted,
|
|
901
|
+
seq: entry.seq,
|
|
902
|
+
secrets,
|
|
903
|
+
decideFault: (chunks) => {
|
|
904
|
+
const state = takeFault(method, requestPath, entry.route);
|
|
905
|
+
if (state === void 0) return { kind: "none" };
|
|
906
|
+
const fault = state.fault;
|
|
907
|
+
if (fault.kind === "status") {
|
|
908
|
+
const response = applyFault(state, fault);
|
|
909
|
+
entry.fault = "status";
|
|
910
|
+
return {
|
|
911
|
+
kind: "answer",
|
|
912
|
+
response
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
if (chunks === void 0 || fault.chunks >= chunks) {
|
|
916
|
+
entry.responseError = `emulator fault cannot apply: truncate-after-chunks after ${fault.chunks} chunk(s) needs a streamed answer of more chunks`;
|
|
917
|
+
return {
|
|
918
|
+
kind: "answer",
|
|
919
|
+
response: emulatorError(500, "emulator fault cannot apply")
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
spend(state);
|
|
923
|
+
entry.fault = "truncate-after-chunks";
|
|
924
|
+
return {
|
|
925
|
+
kind: "truncate",
|
|
926
|
+
after: fault.chunks
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
jobs.set(jobId, job);
|
|
931
|
+
try {
|
|
932
|
+
const response = await core.fetch(new Request(new URL(requestPath, core.baseUrl), {
|
|
933
|
+
method: "POST",
|
|
934
|
+
headers: { [emulatorJobHeader]: String(jobId) }
|
|
935
|
+
}));
|
|
936
|
+
if (response.headers.has("x-emulator-handler-failed")) {
|
|
937
|
+
entry.responseError = "the route handler failed";
|
|
938
|
+
return emulatorError(500, errorTexts.failed);
|
|
939
|
+
}
|
|
940
|
+
if (guardedPath !== void 0 && job.dispatched !== true) {
|
|
941
|
+
entry.responseError = "the route handler answered no eligibility verdict";
|
|
942
|
+
return emulatorError(500, errorTexts.failed);
|
|
943
|
+
}
|
|
944
|
+
if (job.notEmulated !== void 0) {
|
|
945
|
+
entry.notEmulated = job.notEmulated;
|
|
946
|
+
if (constantRefusals || job.credentialRepeat === true) blankEntry(entry);
|
|
947
|
+
if (job.credentialRepeat === true) entry.route = matched.route.path;
|
|
948
|
+
if (guardedPath !== void 0) return notEmulatedResponse(job.notEmulated);
|
|
949
|
+
}
|
|
950
|
+
return job.faultAnswer ?? response;
|
|
951
|
+
} finally {
|
|
952
|
+
jobs.delete(jobId);
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
/**
|
|
956
|
+
* Fail closed: an unrecognised request is ledgered and answered with constant text only. `method`
|
|
957
|
+
* is the request method upper-cased (fail-closed mode) or as sent (resolved mode).
|
|
958
|
+
*/
|
|
959
|
+
const unrecognised = (method, reason) => {
|
|
960
|
+
entries.push({
|
|
961
|
+
seq: nextSeq++,
|
|
962
|
+
method: unrecognisedMethod(method),
|
|
963
|
+
path: unrecognisedLedgerPath,
|
|
964
|
+
query: {},
|
|
965
|
+
headers: {},
|
|
966
|
+
status: 400,
|
|
967
|
+
evidence: "unknown-route",
|
|
968
|
+
notEmulated: reason
|
|
969
|
+
});
|
|
970
|
+
return notEmulatedResponse(reason);
|
|
971
|
+
};
|
|
972
|
+
const emulatedApi = async (request, url, arrivedOn) => {
|
|
973
|
+
const resolution = resolveRequest?.(request, url);
|
|
974
|
+
if (resolution?.kind === "unrecognised") return unrecognised(request.method, resolution.reason);
|
|
975
|
+
const matched = resolution === void 0 ? match(request.method, url.pathname) : {
|
|
976
|
+
route: resolution.route,
|
|
977
|
+
params: resolution.kind === "route" ? resolution.params : {}
|
|
978
|
+
};
|
|
979
|
+
const failClosed = config.failClosed;
|
|
980
|
+
let secrets = resolution?.secrets ?? [];
|
|
981
|
+
if (failClosed !== void 0) {
|
|
982
|
+
const authorization = request.headers.get("authorization");
|
|
983
|
+
const bearer = recognisableBearer(authorization);
|
|
984
|
+
if (authorization !== null && bearer === void 0) return unrecognised(request.method.toUpperCase(), failClosed.unrecognisedAuthorization);
|
|
985
|
+
if (matched === void 0) return unrecognised(request.method.toUpperCase(), failClosed.unrecognised);
|
|
986
|
+
secrets = bearer === void 0 ? [] : [bearer];
|
|
987
|
+
if (secrets.length > 0) {
|
|
988
|
+
const reason = credentialRepeat(request, url, request.body === null ? void 0 : await readText(request.clone()), secrets, matched.route);
|
|
989
|
+
if (reason !== void 0) {
|
|
990
|
+
entries.push({
|
|
991
|
+
seq: nextSeq++,
|
|
992
|
+
method: unrecognisedMethod(request.method.toUpperCase()),
|
|
993
|
+
path: unrecognisedLedgerPath,
|
|
994
|
+
route: matched.route.path,
|
|
995
|
+
query: {},
|
|
996
|
+
headers: {},
|
|
997
|
+
status: 400,
|
|
998
|
+
evidence: matched.route.evidence,
|
|
999
|
+
notEmulated: reason
|
|
1000
|
+
});
|
|
1001
|
+
return withEvidence(notEmulatedResponse(reason), matched.route.evidence);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
const scrub = (text) => scrubSecrets(text, secrets);
|
|
1006
|
+
const query = failClosed === void 0 ? Object.fromEntries(Object.entries(resolution === void 0 ? recordedQuery(url.searchParams) : redactCredentialQuery(url.searchParams)).map(([key, value]) => [scrub(key), scrub(value)])) : recordedQueryPairs(url.searchParams, scrub);
|
|
1007
|
+
const recorded = {
|
|
1008
|
+
method: scrub(request.method),
|
|
1009
|
+
path: scrub(resolution?.ledgerPath ?? url.pathname),
|
|
1010
|
+
query,
|
|
1011
|
+
headers: {}
|
|
1012
|
+
};
|
|
1013
|
+
for (const header of config.recordHeaders) {
|
|
1014
|
+
const value = request.headers.get(header.name);
|
|
1015
|
+
if (value === null) continue;
|
|
1016
|
+
const text = recordedHeaderValue(header, value);
|
|
1017
|
+
recorded.headers[header.name] = scrub(failClosed === void 0 ? text : recordedJsonLooking(text));
|
|
1018
|
+
}
|
|
1019
|
+
const evidence = matched?.route.evidence ?? "unknown-route";
|
|
1020
|
+
const entry = constantRefusals ? {
|
|
1021
|
+
seq: nextSeq++,
|
|
1022
|
+
method: unrecognisedMethod(request.method.toUpperCase()),
|
|
1023
|
+
path: unrecognisedLedgerPath,
|
|
1024
|
+
query: {},
|
|
1025
|
+
headers: {},
|
|
1026
|
+
status: 0,
|
|
1027
|
+
evidence
|
|
1028
|
+
} : resolution === void 0 ? {
|
|
1029
|
+
seq: nextSeq++,
|
|
1030
|
+
method: recorded.method,
|
|
1031
|
+
path: recorded.path,
|
|
1032
|
+
query: recorded.query,
|
|
1033
|
+
headers: { ...recorded.headers },
|
|
1034
|
+
status: 0,
|
|
1035
|
+
evidence
|
|
1036
|
+
} : {
|
|
1037
|
+
seq: nextSeq++,
|
|
1038
|
+
method: recorded.method,
|
|
1039
|
+
path: recorded.path,
|
|
1040
|
+
route: resolution.route.path,
|
|
1041
|
+
query: recorded.query,
|
|
1042
|
+
headers: { ...recorded.headers },
|
|
1043
|
+
status: 0,
|
|
1044
|
+
evidence
|
|
1045
|
+
};
|
|
1046
|
+
entries.push(entry);
|
|
1047
|
+
if (matched === void 0) {
|
|
1048
|
+
entry.status = 400;
|
|
1049
|
+
return refuse(entry, "no emulated route for this method and path");
|
|
1050
|
+
}
|
|
1051
|
+
entry.route = matched.route.path;
|
|
1052
|
+
if (matched.route.origin !== void 0 && matched.route.origin !== arrivedOn) {
|
|
1053
|
+
entry.status = 400;
|
|
1054
|
+
return withEvidence(refuse(entry, `this route is recorded on ${matched.route.origin} only`, secrets), matched.route.evidence);
|
|
1055
|
+
}
|
|
1056
|
+
if (resolution?.kind === "refused") {
|
|
1057
|
+
const tagged = withEvidence(refuse(entry, resolution.reason, secrets), resolution.route.evidence);
|
|
1058
|
+
entry.status = tagged.status;
|
|
1059
|
+
return tagged;
|
|
1060
|
+
}
|
|
1061
|
+
const guardedPath = resolution?.guardedPath;
|
|
1062
|
+
const tagged = withEvidence(await routed(request, url, entry, matched, secrets, arrivedOn, recorded, guardedPath).catch(() => {
|
|
1063
|
+
entry.responseError = "the emulator could not build or produce the response";
|
|
1064
|
+
return emulatorError(500, errorTexts.failed);
|
|
1065
|
+
}), matched.route.evidence);
|
|
1066
|
+
entry.status = tagged.status;
|
|
1067
|
+
return tagged;
|
|
1068
|
+
};
|
|
1069
|
+
const controlPlane = async (request, path) => {
|
|
1070
|
+
const method = request.method;
|
|
1071
|
+
const allow = (methods) => emulatorError(405, "method not allowed", { allow: methods });
|
|
1072
|
+
const jsonBody = async () => {
|
|
1073
|
+
const text = await readText(request);
|
|
1074
|
+
return text === void 0 ? void 0 : parseJsonText(text);
|
|
1075
|
+
};
|
|
1076
|
+
switch (path) {
|
|
1077
|
+
case "/_emulate/ledger":
|
|
1078
|
+
if (method === "GET") return jsonResponse(200, { entries: entries.map(snapshotLedgerEntry) });
|
|
1079
|
+
if (method === "DELETE") {
|
|
1080
|
+
const cleared = entries.length;
|
|
1081
|
+
clearLedger();
|
|
1082
|
+
return jsonResponse(200, { cleared });
|
|
1083
|
+
}
|
|
1084
|
+
return allow("GET, DELETE");
|
|
1085
|
+
case "/_emulate/faults": {
|
|
1086
|
+
if (method === "GET") return jsonResponse(200, { faults: faultStates.map(snapshotFault) });
|
|
1087
|
+
if (method === "DELETE") {
|
|
1088
|
+
const cleared = faultStates.length;
|
|
1089
|
+
faultStates = [];
|
|
1090
|
+
return jsonResponse(200, { cleared });
|
|
1091
|
+
}
|
|
1092
|
+
if (method !== "POST") return allow("GET, POST, DELETE");
|
|
1093
|
+
const input = await jsonBody();
|
|
1094
|
+
const decoded = chunkFaults ? decodeStreamFaultList(input) : decodeFaultList(input);
|
|
1095
|
+
if (Result.isFailure(decoded)) return emulatorError(400, `invalid fault: ${issueMessage(decoded.failure.issue)}`);
|
|
1096
|
+
const faults = "faults" in decoded.success ? decoded.success.faults : [decoded.success];
|
|
1097
|
+
const problem = faults.map(matchRouteProblem).find(Predicate.isString);
|
|
1098
|
+
if (problem !== void 0) return emulatorError(400, `invalid fault: ${problem}`);
|
|
1099
|
+
return jsonResponse(201, { faults: faults.map((fault) => addFault(fault)) });
|
|
1100
|
+
}
|
|
1101
|
+
case "/_emulate/reset":
|
|
1102
|
+
if (method !== "POST") return allow("POST");
|
|
1103
|
+
await reset();
|
|
1104
|
+
return jsonResponse(200, { reset: true });
|
|
1105
|
+
case "/_emulate/state":
|
|
1106
|
+
if (method !== "GET") return allow("GET");
|
|
1107
|
+
return jsonResponse(200, {
|
|
1108
|
+
state: core.snapshot(),
|
|
1109
|
+
...config.runtimeState(),
|
|
1110
|
+
faults: faultStates.map(snapshotFault),
|
|
1111
|
+
ledgerEntries: entries.length
|
|
1112
|
+
});
|
|
1113
|
+
case "/_emulate/seed": {
|
|
1114
|
+
if (method !== "POST") return allow("POST");
|
|
1115
|
+
const next = await reseed(await jsonBody() ?? null);
|
|
1116
|
+
if (Predicate.isString(next)) return emulatorError(400, `invalid seed: ${next}`);
|
|
1117
|
+
return jsonResponse(200, {
|
|
1118
|
+
seeded: true,
|
|
1119
|
+
...config.seedSummary(next)
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
case "/_emulate/coverage":
|
|
1123
|
+
if (method !== "GET") return allow("GET");
|
|
1124
|
+
return jsonResponse(200, coverage());
|
|
1125
|
+
default: return emulatorError(404, "unknown control-plane route");
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
/**
|
|
1129
|
+
* Last resort when handling itself fails (for example an unparseable request URL): a 500
|
|
1130
|
+
* emulator error, tagged with the matched route's evidence when there is one (in resolved mode,
|
|
1131
|
+
* whose own resolution may be what failed, never).
|
|
1132
|
+
*/
|
|
1133
|
+
const lastResort = (request) => {
|
|
1134
|
+
const path = URL.canParse(request.url) ? new URL(request.url).pathname : void 0;
|
|
1135
|
+
const failed = emulatorError(500, errorTexts.unhandled);
|
|
1136
|
+
if (path === void 0 || isControlPath(path) || resolveRequest !== void 0) return failed;
|
|
1137
|
+
const matched = match(request.method, path);
|
|
1138
|
+
return matched === void 0 ? failed : withEvidence(failed, matched.route.evidence);
|
|
1139
|
+
};
|
|
1140
|
+
const handle = async (request, origin) => {
|
|
1141
|
+
if (closed) return emulatorError(503, errorTexts.closed);
|
|
1142
|
+
const url = new URL(request.url);
|
|
1143
|
+
return isControlPath(url.pathname) ? controlPlane(request, url.pathname) : emulatedApi(request, url, origin ?? url.origin);
|
|
1144
|
+
};
|
|
1145
|
+
const fetchOn = (origin) => (request) => handle(request, origin).catch(() => lastResort(request));
|
|
1146
|
+
return {
|
|
1147
|
+
fetch: fetchOn(void 0),
|
|
1148
|
+
fetchOn: (origin) => fetchOn(origin),
|
|
1149
|
+
ledger: {
|
|
1150
|
+
entries: () => entries.map(snapshotLedgerEntry),
|
|
1151
|
+
clear: clearLedger
|
|
1152
|
+
},
|
|
1153
|
+
faults: {
|
|
1154
|
+
add: (fault) => {
|
|
1155
|
+
const added = addFault(fault);
|
|
1156
|
+
if (Predicate.isString(added)) throw config.inputInvalid("fault", added);
|
|
1157
|
+
return added;
|
|
1158
|
+
},
|
|
1159
|
+
list: () => faultStates.map(snapshotFault),
|
|
1160
|
+
clear: () => {
|
|
1161
|
+
faultStates = [];
|
|
1162
|
+
}
|
|
1163
|
+
},
|
|
1164
|
+
reset,
|
|
1165
|
+
seed: async (input) => {
|
|
1166
|
+
const next = await reseed(input);
|
|
1167
|
+
if (Predicate.isString(next)) throw config.inputInvalid("seed", next);
|
|
1168
|
+
},
|
|
1169
|
+
snapshot: () => core.snapshot(),
|
|
1170
|
+
coverage,
|
|
1171
|
+
close: () => {
|
|
1172
|
+
closed = true;
|
|
1173
|
+
return core.close();
|
|
1174
|
+
}
|
|
1175
|
+
};
|
|
1176
|
+
};
|
|
1177
|
+
/** The fault states of an emulator without truncation faults (its decoder takes status faults). */
|
|
1178
|
+
const statusFaultStates = (states) => states.flatMap((state) => {
|
|
1179
|
+
const fault = state.fault;
|
|
1180
|
+
return fault.kind === "status" ? [{
|
|
1181
|
+
...state,
|
|
1182
|
+
fault
|
|
1183
|
+
}] : [];
|
|
1184
|
+
});
|
|
1185
|
+
/** The API of an emulator without truncation faults: its fault methods take status faults only. */
|
|
1186
|
+
const withStatusFaults = (api, inputInvalid) => ({
|
|
1187
|
+
...api,
|
|
1188
|
+
faults: {
|
|
1189
|
+
add: (fault) => {
|
|
1190
|
+
const [added] = statusFaultStates([api.faults.add(fault)]);
|
|
1191
|
+
if (added === void 0) throw inputInvalid("fault", "a status fault is required");
|
|
1192
|
+
return added;
|
|
1193
|
+
},
|
|
1194
|
+
list: () => statusFaultStates(api.faults.list()),
|
|
1195
|
+
clear: api.faults.clear
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
1198
|
+
/**
|
|
1199
|
+
* Build the wrapper over a core runtime. `createCore` receives the dispatch the core must run for
|
|
1200
|
+
* every request (its only route) and returns the runtime adapter. Faults are status faults only.
|
|
1201
|
+
*/
|
|
1202
|
+
const makeStatefulEmulator = async (config, createCore) => withStatusFaults(await buildStatefulEmulator(config, createCore, false, snapshotEntry), config.inputInvalid);
|
|
1203
|
+
/**
|
|
1204
|
+
* `makeStatefulEmulator` for an emulator that records no request header (opt-in): it takes no
|
|
1205
|
+
* `recordHeaders`, and its ledger entries carry no `headers` field
|
|
1206
|
+
* (`StatefulHeaderlessLedgerEntry`). Faults are status faults only.
|
|
1207
|
+
*/
|
|
1208
|
+
const makeHeaderlessStatefulEmulator = async (config, createCore) => withStatusFaults(await buildStatefulEmulator({
|
|
1209
|
+
...config,
|
|
1210
|
+
recordHeaders: []
|
|
1211
|
+
}, createCore, false, snapshotHeaderlessEntry), config.inputInvalid);
|
|
1212
|
+
/**
|
|
1213
|
+
* `makeStatefulEmulator` that also takes `truncate-after-chunks` faults
|
|
1214
|
+
* (`StatefulTruncateFault`), which cut a streamed answer (`StreamedCommit`). Opt-in: the other
|
|
1215
|
+
* emulators keep status faults only.
|
|
1216
|
+
*/
|
|
1217
|
+
const makeChunkedStatefulEmulator = (config, createCore) => buildStatefulEmulator(config, createCore, true, snapshotEntry);
|
|
1218
|
+
/** Throws `inputInvalid('option', ...)` unless every drill knob is a known boolean (or absent). */
|
|
1219
|
+
const checkBooleanDrills = (drills, knobs, inputInvalid) => {
|
|
1220
|
+
for (const [key, value] of Object.entries(drills ?? {})) {
|
|
1221
|
+
if (!knobs.includes(key)) throw inputInvalid("option", `unknown drill knob ${key}`);
|
|
1222
|
+
if (value !== void 0 && !Predicate.isBoolean(value)) throw inputInvalid("option", `drills.${key} must be a boolean`);
|
|
1223
|
+
}
|
|
1224
|
+
};
|
|
1225
|
+
//#endregion
|
|
1226
|
+
export { DecodedViewRefusal, NotEmulated, StatefulFault, StatefulFaultMatch, StatefulStreamFault, StatefulTruncateFault, checkBooleanDrills, decodeSegment, exactBodyKeys, exactObject, exactQuery, integerIn, isJsonObject, isNotEmulated, jsonResponse, makeChunkedStatefulEmulator, makeHeaderlessStatefulEmulator, makeStatefulEmulator, mediaType, notEmulated, notEmulatedResponse, parseJsonText, repeatsJsonKey, routeEvidence, routeManifest, routeMatcher, statefulRoute, streamedCommit, unpatternedParams };
|
|
1227
|
+
|
|
1228
|
+
//# sourceMappingURL=stateful-emulator.mjs.map
|