@elysra/tungsten-runtime 0.0.0-bootstrap.0 → 0.0.0-stage
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/README.md +3 -0
- package/package.json +6 -55
- package/LICENSE +0 -55
- package/dist/auth.d.ts +0 -50
- package/dist/auth.js +0 -264
- package/dist/classify.d.ts +0 -64
- package/dist/classify.js +0 -301
- package/dist/client.d.ts +0 -49
- package/dist/client.js +0 -2309
- package/dist/confirm.d.ts +0 -7
- package/dist/confirm.js +0 -32
- package/dist/define.d.ts +0 -13
- package/dist/define.js +0 -12
- package/dist/envelope.d.ts +0 -24
- package/dist/envelope.js +0 -152
- package/dist/errors.d.ts +0 -37
- package/dist/errors.js +0 -75
- package/dist/expr.d.ts +0 -24
- package/dist/expr.js +0 -198
- package/dist/idempotency.d.ts +0 -27
- package/dist/idempotency.js +0 -56
- package/dist/index.d.ts +0 -18
- package/dist/index.js +0 -19
- package/dist/node.d.ts +0 -17
- package/dist/node.js +0 -98
- package/dist/oauth.d.ts +0 -124
- package/dist/oauth.js +0 -367
- package/dist/serialize.d.ts +0 -55
- package/dist/serialize.js +0 -323
- package/dist/sse.d.ts +0 -42
- package/dist/sse.js +0 -166
- package/dist/streams.d.ts +0 -65
- package/dist/streams.js +0 -96
- package/dist/transport.d.ts +0 -88
- package/dist/transport.js +0 -245
- package/dist/types.d.ts +0 -667
- package/dist/types.js +0 -1
- package/dist/util.d.ts +0 -73
- package/dist/util.js +0 -317
- package/dist/version.d.ts +0 -3
- package/dist/version.js +0 -4
package/dist/client.js
DELETED
|
@@ -1,2309 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
/**
|
|
3
|
-
* `ClientCore`: the behaviour behind every generated SDK method
|
|
4
|
-
* (planning/06 "Transport pipeline"):
|
|
5
|
-
*
|
|
6
|
-
* ```
|
|
7
|
-
* call → preflight → auth → idempotency → send → classify → verify? → result
|
|
8
|
-
* ▲ │
|
|
9
|
-
* └── retries ◀──┘
|
|
10
|
-
* ```
|
|
11
|
-
*
|
|
12
|
-
* Every public method resolves; none throws. Failures are diagnostic
|
|
13
|
-
* envelopes (`{ok: false, error}`).
|
|
14
|
-
*/
|
|
15
|
-
import { isSafeMethod, resolveAuth, TokenError } from "./auth.js";
|
|
16
|
-
import { changeOf, classifyError, decodeBody, isMutation, matchResponse, outcomeUnknown, requestIdOf, verifyCallable, } from "./classify.js";
|
|
17
|
-
import { checkToken, CONFIRMATION_TTL_MS, issueToken } from "./confirm.js";
|
|
18
|
-
import { diagnostic, scrubDiagnostic, scrubText } from "./envelope.js";
|
|
19
|
-
import { OAuthFlow, OAuthSession } from "./oauth.js";
|
|
20
|
-
import { containsPlaceholder, describePredicate, evaluateDry, evaluateExpr, evaluatePredicate, placeholdersIn, resolveRef, } from "./expr.js";
|
|
21
|
-
import { checkKeyFormat, hasReplayProtection, keyFormatDescription, keyHeader, MemoryIdempotencyStore } from "./idempotency.js";
|
|
22
|
-
import { bodyValue, encodeBody, HeaderBag, SerializationError, serializeCookieParam, serializeHeaderParam, serializePathParam, serializeQueryParam, validHeaderName, validHeaderValue, } from "./serialize.js";
|
|
23
|
-
import { DEFAULT_MAX_EVENT_BYTES, SseParser } from "./sse.js";
|
|
24
|
-
import { EventStream } from "./streams.js";
|
|
25
|
-
import { attempt, nextLink, parseRetryAfter, StreamBody } from "./transport.js";
|
|
26
|
-
import { canonicalJson, describeError, envelopeValue, getPath, isRecord, looksSensitive, mergeAccept, REDACTED, redactPaths, redactSensitiveKeys, sha256Hex, sleep, splitPath, } from "./util.js";
|
|
27
|
-
import { RUNTIME_VERSION } from "./version.js";
|
|
28
|
-
const METHODS = new Set(["GET", "PUT", "POST", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE"]);
|
|
29
|
-
const SAFETIES = new Set(["read_only", "mutating", "destructive", "irreversible"]);
|
|
30
|
-
const POLICIES = new Set(["none", "auto", "caller_owned", "content_hash", "content_identity"]);
|
|
31
|
-
const RETRY_CATEGORIES = new Set(["RATE_LIMITED", "UPSTREAM_UNAVAILABLE", "TRANSPORT_FAILED", "OUTCOME_UNKNOWN"]);
|
|
32
|
-
const DEFAULT_RETRIES = { max: 3, baseMs: 200, maxMs: 5000, jitter: "full", honorRetryAfter: true };
|
|
33
|
-
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
34
|
-
const DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
35
|
-
const DEFAULT_VERIFY_BUDGET_MS = 30_000;
|
|
36
|
-
const DEFAULT_MACRO_BUDGET_MS = 30_000;
|
|
37
|
-
const MACRO_PAGE_LIMIT = 100;
|
|
38
|
-
/** Same-origin redirects followed for one read attempt. */
|
|
39
|
-
const MAX_REDIRECTS = 5;
|
|
40
|
-
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
41
|
-
/** Marks the call options of a macro step whose run was confirmed at the
|
|
42
|
-
* macro level. Module-private, so callers cannot set it. */
|
|
43
|
-
const MACRO_CONFIRMED = Symbol("tungsten.macroConfirmed");
|
|
44
|
-
/** A macro step's hook that spends the macro's confirmation token; called
|
|
45
|
-
* once a step's request passed pre-flight and is about to be sent, so a
|
|
46
|
-
* step that fails pre-flight leaves the token unspent. */
|
|
47
|
-
const MACRO_CLAIM = Symbol("tungsten.macroClaim");
|
|
48
|
-
const SAFETY_RANK = { read_only: 0, mutating: 1, destructive: 2, irreversible: 3 };
|
|
49
|
-
/** Stands in for a verification reference that did not resolve; equal to nothing. */
|
|
50
|
-
const UNRESOLVED = Symbol("tungsten.unresolved");
|
|
51
|
-
/** Reconnects of a dropped stream when `ClientOptions.maxReconnects` is unset. */
|
|
52
|
-
const DEFAULT_MAX_RECONNECTS = 3;
|
|
53
|
-
/** The wait before a reconnect when the server sent no `retry`, in milliseconds. */
|
|
54
|
-
const DEFAULT_RECONNECT_MS = 250;
|
|
55
|
-
/** Longest wait before a reconnect when `ClientOptions.reconnectMaxMs` is unset. */
|
|
56
|
-
const DEFAULT_RECONNECT_MAX_MS = 30000;
|
|
57
|
-
/** The failure of a 2xx answer to a stream request that is not an event stream. */
|
|
58
|
-
function notAStream(op, meta, afterEffect) {
|
|
59
|
-
return diagnostic(op.id, "UNEXPECTED_RESPONSE", {
|
|
60
|
-
http_status: meta.status,
|
|
61
|
-
request_id: meta.requestId,
|
|
62
|
-
failed_parameter: "response",
|
|
63
|
-
received_value: envelopeValue(meta.headers["content-type"] ?? null, false),
|
|
64
|
-
expected: "a text/event-stream body",
|
|
65
|
-
remediation: `The success response is not an event stream (Content-Type ${meta.headers["content-type"] ?? "missing"}).${afterEffect}`,
|
|
66
|
-
retryable: "never",
|
|
67
|
-
trace: { attempts: meta.attempts },
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
function fail(error) {
|
|
71
|
-
return { ok: false, error };
|
|
72
|
-
}
|
|
73
|
-
/** `value[key]` when it is a non-empty string, else the fallback; safe
|
|
74
|
-
* on hostile objects (throwing getters, proxies). */
|
|
75
|
-
function safeName(value, key, fallback) {
|
|
76
|
-
try {
|
|
77
|
-
const name = isRecord(value) ? value[key] : undefined;
|
|
78
|
-
return typeof name === "string" && name !== "" ? name : fallback;
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
return fallback;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
function safeId(op) {
|
|
85
|
-
return safeName(op, "id", "<unknown operation>");
|
|
86
|
-
}
|
|
87
|
-
/** A finite number in [min, max], or the fallback. */
|
|
88
|
-
function bounded(value, fallback, min = 0, max = Number.MAX_SAFE_INTEGER) {
|
|
89
|
-
return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
|
|
90
|
-
}
|
|
91
|
-
function isSchema(value) {
|
|
92
|
-
return isRecord(value) && typeof value.safeParse === "function";
|
|
93
|
-
}
|
|
94
|
-
/** Structural problems that would make the descriptor unusable, or null. */
|
|
95
|
-
function descriptorProblem(op) {
|
|
96
|
-
if (!isRecord(op))
|
|
97
|
-
return "the operation descriptor is not an object";
|
|
98
|
-
if (typeof op.id !== "string" || op.id === "")
|
|
99
|
-
return "`id` must be a non-empty string";
|
|
100
|
-
if (typeof op.method !== "string" || !METHODS.has(op.method))
|
|
101
|
-
return "`method` must be an HTTP method";
|
|
102
|
-
if (typeof op.path !== "string")
|
|
103
|
-
return "`path` must be a string";
|
|
104
|
-
if (!Array.isArray(op.params))
|
|
105
|
-
return "`params` must be an array";
|
|
106
|
-
for (const p of op.params) {
|
|
107
|
-
if (!isRecord(p) || typeof p.name !== "string" || typeof p.wire !== "string" || !["path", "query", "header", "cookie"].includes(p.in)) {
|
|
108
|
-
return "every parameter needs string `name` and `wire` and a valid `in`";
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
if (op.body !== null && op.body !== undefined) {
|
|
112
|
-
const body = op.body;
|
|
113
|
-
if (!isRecord(body) || !isRecord(body.shape))
|
|
114
|
-
return "`body.shape` must be an object";
|
|
115
|
-
const shape = body.shape;
|
|
116
|
-
if (shape.kind === "merged" ? !Array.isArray(shape.fields) : !(shape.kind === "arg" && typeof shape.arg === "string")) {
|
|
117
|
-
return "`body.shape` must be {kind: merged, fields} or {kind: arg, arg}";
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
if (!Array.isArray(op.responses))
|
|
121
|
-
return "`responses` must be an array";
|
|
122
|
-
if (!Array.isArray(op.security))
|
|
123
|
-
return "`security` must be an array of arrays";
|
|
124
|
-
if (op.rpc !== null && op.rpc !== undefined) {
|
|
125
|
-
const rpc = op.rpc;
|
|
126
|
-
if (!isRecord(rpc) || typeof rpc.field !== "string" || typeof rpc.paramsField !== "string")
|
|
127
|
-
return "`rpc` needs `field` and `paramsField`";
|
|
128
|
-
}
|
|
129
|
-
if (op.stream !== null && op.stream !== undefined) {
|
|
130
|
-
const stream = op.stream;
|
|
131
|
-
if (!isRecord(stream))
|
|
132
|
-
return "`stream` must be an object";
|
|
133
|
-
if (stream.done !== undefined && typeof stream.done !== "string")
|
|
134
|
-
return "`stream.done` must be a string";
|
|
135
|
-
if (stream.flag !== undefined && typeof stream.flag !== "string")
|
|
136
|
-
return "`stream.flag` must be a string";
|
|
137
|
-
if (stream.event !== undefined && !isSchema(stream.event))
|
|
138
|
-
return "`stream.event` must be a schema";
|
|
139
|
-
}
|
|
140
|
-
if (!isRecord(op.status) || (op.status.kind !== "implemented" && op.status.kind !== "gated"))
|
|
141
|
-
return "`status.kind` must be implemented or gated";
|
|
142
|
-
const agent = op.agent;
|
|
143
|
-
if (!isRecord(agent))
|
|
144
|
-
return "`agent` must be an object";
|
|
145
|
-
if (typeof agent.safety !== "string" || !SAFETIES.has(agent.safety))
|
|
146
|
-
return "`agent.safety` must be a safety tier";
|
|
147
|
-
if (!isRecord(agent.idempotency) || typeof agent.idempotency.policy !== "string" || !POLICIES.has(agent.idempotency.policy)) {
|
|
148
|
-
return "`agent.idempotency.policy` must be an idempotency policy";
|
|
149
|
-
}
|
|
150
|
-
if (!isRecord(agent.preview))
|
|
151
|
-
return "`agent.preview` must be an object";
|
|
152
|
-
return null;
|
|
153
|
-
}
|
|
154
|
-
/** Arg keys that are parameters supplied by the caller (not auth, key or origin). */
|
|
155
|
-
function argParams(op) {
|
|
156
|
-
return op.params.filter((p) => p.role !== "idempotency_key" && p.role !== "origin" && p.role !== "auth" && p.role !== "constant");
|
|
157
|
-
}
|
|
158
|
-
/** JSON path of an argument issue: `body.x` for body fields, else `args.x`. */
|
|
159
|
-
function argumentPath(op, path) {
|
|
160
|
-
const format = (segments) => segments.map((s) => (typeof s === "number" || /^\d+$/.test(String(s)) ? `[${s}]` : `.${String(s)}`)).join("");
|
|
161
|
-
const [head, ...rest] = path;
|
|
162
|
-
const body = op.body;
|
|
163
|
-
if (body && head !== undefined) {
|
|
164
|
-
if (body.shape.kind === "merged" && body.shape.fields.includes(String(head)))
|
|
165
|
-
return `body${format(path)}`;
|
|
166
|
-
if (body.shape.kind === "arg" && body.shape.arg === String(head))
|
|
167
|
-
return `body${format(rest)}`;
|
|
168
|
-
}
|
|
169
|
-
return path.length === 0 ? "args" : `args${format(path)}`;
|
|
170
|
-
}
|
|
171
|
-
/** The operation's sensitive argument paths (`AgentMeta.sensitiveRequestFields`). */
|
|
172
|
-
function sensitiveRequestPaths(op) {
|
|
173
|
-
const listed = isRecord(op.agent) ? op.agent.sensitiveRequestFields : undefined;
|
|
174
|
-
return Array.isArray(listed) ? listed.filter((p) => typeof p === "string" && p !== "") : [];
|
|
175
|
-
}
|
|
176
|
-
/** An args path without array indices, dotted (`users.0.pin` → `users.pin`). */
|
|
177
|
-
function dottedArgPath(path) {
|
|
178
|
-
return path.filter((s) => typeof s === "string" && !/^\d+$/.test(s)).join(".");
|
|
179
|
-
}
|
|
180
|
-
function sensitiveArg(op, path) {
|
|
181
|
-
const head = path[0];
|
|
182
|
-
if (op.params.some((p) => p.sensitive === true && p.name === head))
|
|
183
|
-
return true;
|
|
184
|
-
const dotted = dottedArgPath(path);
|
|
185
|
-
if (dotted !== "" && sensitiveRequestPaths(op).some((f) => dotted === f || dotted.startsWith(`${f}.`)))
|
|
186
|
-
return true;
|
|
187
|
-
return path.some((segment) => typeof segment === "string" && looksSensitive(segment));
|
|
188
|
-
}
|
|
189
|
-
/** `value` (the argument at `path`) with the sensitive fields below it redacted. */
|
|
190
|
-
function redactBelow(op, path, value) {
|
|
191
|
-
const dotted = dottedArgPath(path);
|
|
192
|
-
const prefix = dotted === "" ? "" : `${dotted}.`;
|
|
193
|
-
const below = sensitiveRequestPaths(op)
|
|
194
|
-
.filter((f) => f.startsWith(prefix))
|
|
195
|
-
.map((f) => f.slice(prefix.length));
|
|
196
|
-
return below.length > 0 ? redactPaths(value, below) : value;
|
|
197
|
-
}
|
|
198
|
-
/** Sensitive argument paths relative to the request body as sent (the
|
|
199
|
-
* rpc envelope's params member for rpc operations); `""` is the whole body. */
|
|
200
|
-
function sensitiveBodyPaths(op) {
|
|
201
|
-
const body = op.body;
|
|
202
|
-
const paths = sensitiveRequestPaths(op);
|
|
203
|
-
let relative;
|
|
204
|
-
if (body?.shape.kind === "merged") {
|
|
205
|
-
const fields = new Set(body.shape.fields);
|
|
206
|
-
relative = paths.filter((p) => fields.has(splitPath(p)[0] ?? ""));
|
|
207
|
-
}
|
|
208
|
-
else if (body?.shape.kind === "arg") {
|
|
209
|
-
const arg = body.shape.arg;
|
|
210
|
-
relative = paths.filter((p) => p === arg || p.startsWith(`${arg}.`)).map((p) => (p === arg ? "" : p.slice(arg.length + 1)));
|
|
211
|
-
}
|
|
212
|
-
else {
|
|
213
|
-
relative = op.rpc ? paths : [];
|
|
214
|
-
}
|
|
215
|
-
return op.rpc ? relative.map((p) => (p === "" ? op.rpc.paramsField : `${op.rpc.paramsField}.${p}`)) : relative;
|
|
216
|
-
}
|
|
217
|
-
/** Every string or number found at a dotted path, through arrays. */
|
|
218
|
-
function valuesAt(value, segments, out, depth = 0) {
|
|
219
|
-
if (depth > 64)
|
|
220
|
-
return;
|
|
221
|
-
if (Array.isArray(value)) {
|
|
222
|
-
for (const item of value)
|
|
223
|
-
valuesAt(item, segments, out, depth + 1);
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
if (segments.length === 0) {
|
|
227
|
-
if (typeof value === "string" || typeof value === "number")
|
|
228
|
-
out.push(String(value));
|
|
229
|
-
else if (isRecord(value))
|
|
230
|
-
for (const v of Object.values(value))
|
|
231
|
-
valuesAt(v, [], out, depth + 1);
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
if (!isRecord(value))
|
|
235
|
-
return;
|
|
236
|
-
const [head, ...rest] = segments;
|
|
237
|
-
valuesAt(value[head], rest, out, depth + 1);
|
|
238
|
-
}
|
|
239
|
-
/** A copy of the args without `undefined` members at any depth: an
|
|
240
|
-
* optional argument set to `undefined` (`{cursor: maybe}`) is absent, as
|
|
241
|
-
* TypeScript callers without `exactOptionalPropertyTypes` expect. Only
|
|
242
|
-
* plain objects and arrays are copied; dates, bytes and blobs are kept. */
|
|
243
|
-
function withoutUndefined(value, depth = 0) {
|
|
244
|
-
if (depth > 64)
|
|
245
|
-
return value;
|
|
246
|
-
if (Array.isArray(value))
|
|
247
|
-
return value.map((item) => withoutUndefined(item, depth + 1));
|
|
248
|
-
if (!isRecord(value))
|
|
249
|
-
return value;
|
|
250
|
-
const proto = Object.getPrototypeOf(value);
|
|
251
|
-
if (proto !== Object.prototype && proto !== null)
|
|
252
|
-
return value;
|
|
253
|
-
const out = {};
|
|
254
|
-
for (const [k, v] of Object.entries(value))
|
|
255
|
-
if (v !== undefined)
|
|
256
|
-
out[k] = withoutUndefined(v, depth + 1);
|
|
257
|
-
return out;
|
|
258
|
-
}
|
|
259
|
-
/** `args` plus each parameter's value under its wire name, so references
|
|
260
|
-
* written against the API (agent.yml `{device_id}`, `$args.endpoint_id`)
|
|
261
|
-
* find arguments whose generated name differs (`deviceId`). */
|
|
262
|
-
function withWireNames(op, args) {
|
|
263
|
-
const out = { ...args };
|
|
264
|
-
for (const p of Array.isArray(op.params) ? op.params : []) {
|
|
265
|
-
if (!isRecord(p) || typeof p.name !== "string" || typeof p.wire !== "string")
|
|
266
|
-
continue;
|
|
267
|
-
if (args[p.name] !== undefined && !Object.prototype.hasOwnProperty.call(out, p.wire))
|
|
268
|
-
out[p.wire] = args[p.name];
|
|
269
|
-
}
|
|
270
|
-
return out;
|
|
271
|
-
}
|
|
272
|
-
/** A path whose first segment is a parameter's wire name, rewritten to
|
|
273
|
-
* the parameter's generated name. */
|
|
274
|
-
function argPath(op, path) {
|
|
275
|
-
const [head, ...rest] = path;
|
|
276
|
-
const param = op.params.find((p) => p.wire === head && p.name !== head);
|
|
277
|
-
return param ? [param.name, ...rest] : path;
|
|
278
|
-
}
|
|
279
|
-
function interpolate(template, args, op) {
|
|
280
|
-
return template.replace(/\{([^{}]+)\}/g, (_m, field) => {
|
|
281
|
-
const path = argPath(op, field.trim().split("."));
|
|
282
|
-
const value = getPath(args, path);
|
|
283
|
-
if (value === undefined || value === null)
|
|
284
|
-
return "<unset>";
|
|
285
|
-
if (sensitiveArg(op, path))
|
|
286
|
-
return REDACTED;
|
|
287
|
-
const shown = envelopeValue(value, false);
|
|
288
|
-
return typeof shown === "string" ? shown : JSON.stringify(shown);
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
/** What middleware may see of an encoded body: the body itself, unless
|
|
292
|
-
* the preview rendering redacted something in it (sensitive arguments,
|
|
293
|
-
* credential-like keys); then that rendering, serialized like the body. */
|
|
294
|
-
function observableBody(encoding, encoded) {
|
|
295
|
-
if (encoded.body === null)
|
|
296
|
-
return null;
|
|
297
|
-
let changed;
|
|
298
|
-
try {
|
|
299
|
-
changed = canonicalJson(encoded.display) !== canonicalJson(encoded.raw);
|
|
300
|
-
}
|
|
301
|
-
catch {
|
|
302
|
-
changed = true;
|
|
303
|
-
}
|
|
304
|
-
if (!changed)
|
|
305
|
-
return encoded.body;
|
|
306
|
-
if (encoding === "form" && isRecord(encoded.display)) {
|
|
307
|
-
return new URLSearchParams(Object.entries(encoded.display).map(([k, v]) => [k, typeof v === "string" ? v : JSON.stringify(v)])).toString();
|
|
308
|
-
}
|
|
309
|
-
return typeof encoded.display === "string" ? encoded.display : JSON.stringify(encoded.display);
|
|
310
|
-
}
|
|
311
|
-
/** A preview's display URL with the placeholders of `args` shown as
|
|
312
|
-
* written (`/v1/webhooks/<from step created: endpoint_id>/enable`) instead
|
|
313
|
-
* of percent-encoded. Only renderings change; nothing with placeholders
|
|
314
|
-
* is ever sent. */
|
|
315
|
-
function unescapePlaceholders(url, args) {
|
|
316
|
-
let out = url;
|
|
317
|
-
for (const text of new Set(placeholdersIn(args))) {
|
|
318
|
-
// Parameters are encoded with encodeURIComponent (serialize.ts).
|
|
319
|
-
out = out.split(encodeURIComponent(text)).join(text);
|
|
320
|
-
}
|
|
321
|
-
return out;
|
|
322
|
-
}
|
|
323
|
-
/** ` with name "value", ...` for remediation text naming a call's
|
|
324
|
-
* arguments; "" when there are none. */
|
|
325
|
-
function withArguments(args) {
|
|
326
|
-
if (!isRecord(args))
|
|
327
|
-
return "";
|
|
328
|
-
const shown = Object.entries(args).map(([k, v]) => `${k} ${shortJson(v)}`);
|
|
329
|
-
return shown.length > 0 ? ` with ${shown.join(", ")}` : "";
|
|
330
|
-
}
|
|
331
|
-
/** ` (name "value", ...)`: the scalar, non-sensitive arguments a call was
|
|
332
|
-
* made with (at most four, by wire name), to recognize what it created. */
|
|
333
|
-
function sentArguments(op, args) {
|
|
334
|
-
const shown = [];
|
|
335
|
-
for (const [key, value] of Object.entries(args)) {
|
|
336
|
-
if (shown.length === 4)
|
|
337
|
-
break;
|
|
338
|
-
if (!(typeof value === "string" || typeof value === "number" || typeof value === "boolean"))
|
|
339
|
-
continue;
|
|
340
|
-
if (sensitiveArg(op, [key]))
|
|
341
|
-
continue;
|
|
342
|
-
const wire = op.params.find((p) => p.name === key)?.wire ?? key;
|
|
343
|
-
shown.push(`${wire} ${shortJson(value)}`);
|
|
344
|
-
}
|
|
345
|
-
return shown.length > 0 ? ` (${shown.join(", ")})` : "";
|
|
346
|
-
}
|
|
347
|
-
/** JSON for remediation text, cut to 80 characters. */
|
|
348
|
-
function shortJson(value) {
|
|
349
|
-
let text;
|
|
350
|
-
try {
|
|
351
|
-
text = JSON.stringify(value) ?? String(value);
|
|
352
|
-
}
|
|
353
|
-
catch {
|
|
354
|
-
text = String(value);
|
|
355
|
-
}
|
|
356
|
-
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
|
|
357
|
-
}
|
|
358
|
-
/** The args of a stream call: the stream's request flag set to `true`, in
|
|
359
|
-
* the body field of a merged body or inside an object body argument. */
|
|
360
|
-
function withStreamFlag(op, spec, args) {
|
|
361
|
-
const flag = spec.flag;
|
|
362
|
-
const body = op.body;
|
|
363
|
-
if (flag === undefined || !isRecord(args) || !body)
|
|
364
|
-
return args;
|
|
365
|
-
if (body.shape.kind === "merged")
|
|
366
|
-
return body.shape.fields.includes(flag) ? { ...args, [flag]: true } : args;
|
|
367
|
-
const inner = args[body.shape.arg];
|
|
368
|
-
return isRecord(inner) ? { ...args, [body.shape.arg]: { ...inner, [flag]: true } } : args;
|
|
369
|
-
}
|
|
370
|
-
function trimTrailingSlash(base) {
|
|
371
|
-
return base.replace(/\/+$/, "");
|
|
372
|
-
}
|
|
373
|
-
/**
|
|
374
|
-
* The runtime core used by generated clients. One instance per client:
|
|
375
|
-
* it owns the idempotency store, the confirmation-token key, the OAuth2
|
|
376
|
-
* token cache and the registry of operations resolvable by id.
|
|
377
|
-
*/
|
|
378
|
-
export class ClientCore {
|
|
379
|
-
api;
|
|
380
|
-
options;
|
|
381
|
-
#registry = new Map();
|
|
382
|
-
#store;
|
|
383
|
-
#confirmationKey;
|
|
384
|
-
#tokenCache = new Map();
|
|
385
|
-
#tokenInflight = new Map();
|
|
386
|
-
#oauth;
|
|
387
|
-
/** Confirmation tokens already used to send, with the replay protection
|
|
388
|
-
* of their first use, until they expire. */
|
|
389
|
-
#usedTokens = new Map();
|
|
390
|
-
constructor(api, options = {}) {
|
|
391
|
-
this.api = isRecord(api) ? api : {};
|
|
392
|
-
this.options = isRecord(options) ? options : {};
|
|
393
|
-
let store;
|
|
394
|
-
let key;
|
|
395
|
-
try {
|
|
396
|
-
const candidate = this.options.idempotencyStore;
|
|
397
|
-
if (isRecord(candidate) && typeof candidate.get === "function" && typeof candidate.put === "function")
|
|
398
|
-
store = candidate;
|
|
399
|
-
const configured = this.options.confirmationKey;
|
|
400
|
-
if (configured instanceof Uint8Array && configured.byteLength > 0)
|
|
401
|
-
key = configured;
|
|
402
|
-
if (Array.isArray(this.options.operations))
|
|
403
|
-
this.register(...this.options.operations);
|
|
404
|
-
}
|
|
405
|
-
catch {
|
|
406
|
-
// Unreadable options: keep the defaults; calls report the problem.
|
|
407
|
-
}
|
|
408
|
-
this.#oauth = new OAuthSession({
|
|
409
|
-
api: this.api,
|
|
410
|
-
options: this.options,
|
|
411
|
-
fetch: () => this.#fetch(),
|
|
412
|
-
now: () => this.#now(),
|
|
413
|
-
timeoutMs: () => bounded(this.options.timeoutMs, DEFAULT_TIMEOUT_MS, 1),
|
|
414
|
-
});
|
|
415
|
-
this.#store = store ?? new MemoryIdempotencyStore();
|
|
416
|
-
this.#confirmationKey = key ?? crypto.getRandomValues(new Uint8Array(32));
|
|
417
|
-
}
|
|
418
|
-
/** Make operations resolvable by id for verification hooks, endpoint
|
|
419
|
-
* previews and macro steps. Invalid entries are ignored. */
|
|
420
|
-
register(...ops) {
|
|
421
|
-
for (const op of ops) {
|
|
422
|
-
if (isRecord(op) && typeof op.id === "string" && op.id !== "" && !this.#registry.has(op.id))
|
|
423
|
-
this.#registry.set(op.id, op);
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
/** A registered operation by id. */
|
|
427
|
-
operation(id) {
|
|
428
|
-
return this.#registry.get(id);
|
|
429
|
-
}
|
|
430
|
-
/** Validate, authenticate, apply idempotency and confirmation rules,
|
|
431
|
-
* send with retries and classify the response. Never throws. */
|
|
432
|
-
async call(op, args, opts) {
|
|
433
|
-
try {
|
|
434
|
-
return await this.#call(op, args, this.#callOptions(opts), null);
|
|
435
|
-
}
|
|
436
|
-
catch (error) {
|
|
437
|
-
return fail(this.#internal(op, error, "building or sending the request"));
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
/** Run the operation's preview mode: local rendering (no network), a
|
|
441
|
-
* dry-run header, or a preview endpoint. Mutating operations get a
|
|
442
|
-
* confirmation token bound to these exact arguments for five minutes. */
|
|
443
|
-
async preview(op, args, opts) {
|
|
444
|
-
try {
|
|
445
|
-
return await this.#preview(op, args, this.#callOptions(opts));
|
|
446
|
-
}
|
|
447
|
-
catch (error) {
|
|
448
|
-
return fail(this.#internal(op, error, "rendering the preview"));
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
/** Iterate pages (cursor, offset, page number or `Link` header); stops
|
|
452
|
-
* after the last page or after yielding the first error. */
|
|
453
|
-
async *pages(op, args, opts) {
|
|
454
|
-
try {
|
|
455
|
-
yield* this.#pages(op, args, this.#callOptions(opts));
|
|
456
|
-
}
|
|
457
|
-
catch (error) {
|
|
458
|
-
yield fail(this.#internal(op, error, "paginating"));
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
/** Iterate the events of the operation's event stream: one item per
|
|
462
|
-
* server-sent event, decoded from JSON, then the end of the stream or a
|
|
463
|
-
* final failure item. Never throws. */
|
|
464
|
-
stream(op, args, opts) {
|
|
465
|
-
const callOptions = this.#callOptions(opts);
|
|
466
|
-
const controller = new AbortController();
|
|
467
|
-
const caller = callOptions.signal;
|
|
468
|
-
const forward = () => controller.abort();
|
|
469
|
-
if (caller?.aborted === true)
|
|
470
|
-
controller.abort();
|
|
471
|
-
else
|
|
472
|
-
caller?.addEventListener("abort", forward, { once: true });
|
|
473
|
-
const items = async function* (core) {
|
|
474
|
-
try {
|
|
475
|
-
yield* core.#stream(op, args, { ...callOptions, signal: controller.signal });
|
|
476
|
-
}
|
|
477
|
-
catch (error) {
|
|
478
|
-
yield fail(core.#internal(op, error, "streaming"));
|
|
479
|
-
}
|
|
480
|
-
finally {
|
|
481
|
-
caller?.removeEventListener("abort", forward);
|
|
482
|
-
}
|
|
483
|
-
};
|
|
484
|
-
return new EventStream(items(this), () => controller.abort());
|
|
485
|
-
}
|
|
486
|
-
/** Call a read operation until `until` holds on its body or the budget
|
|
487
|
-
* runs out (`timedOut: true` with the last result). */
|
|
488
|
-
async poll(op, args, until, intervalMs, budgetMs, opts) {
|
|
489
|
-
try {
|
|
490
|
-
return await this.#poll(op, args, until, intervalMs, budgetMs, this.#callOptions(opts));
|
|
491
|
-
}
|
|
492
|
-
catch (error) {
|
|
493
|
-
return fail(this.#internal(op, error, "polling"));
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
/** Run a compiled macro: steps in order, each step's success body bound
|
|
497
|
-
* to its `as` name; returns the evaluated output, or the first failing
|
|
498
|
-
* step's envelope (its remediation names the steps already completed). */
|
|
499
|
-
async runMacro(macro, input, opts) {
|
|
500
|
-
try {
|
|
501
|
-
return await this.#runMacro(macro, input, this.#callOptions(opts));
|
|
502
|
-
}
|
|
503
|
-
catch (error) {
|
|
504
|
-
return fail(this.#internal({ id: safeName(macro, "name", "<unknown macro>") }, error, "running the macro"));
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
/** Preview a macro: no request is sent. Validates and renders the first
|
|
508
|
-
* step, lists the effects of every step, and returns a confirmation token
|
|
509
|
-
* bound to the macro and this exact input unless the macro is read-only. */
|
|
510
|
-
async previewMacro(macro, input, opts) {
|
|
511
|
-
try {
|
|
512
|
-
return await this.#previewMacro(macro, input, this.#callOptions(opts));
|
|
513
|
-
}
|
|
514
|
-
catch (error) {
|
|
515
|
-
return fail(this.#internal({ id: safeName(macro, "name", "<unknown macro>") }, error, "previewing the macro"));
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
// ------------------------------------------------------------ internals
|
|
519
|
-
#callOptions(opts) {
|
|
520
|
-
return isRecord(opts) ? opts : {};
|
|
521
|
-
}
|
|
522
|
-
#internal(op, error, during) {
|
|
523
|
-
return diagnostic(safeId(op), "UNEXPECTED_RESPONSE", {
|
|
524
|
-
remediation: `The SDK failed while ${during} (${describeError(error)}). This is a bug in the generated SDK or the runtime; report it. If the call may have been sent, check its effect before repeating it.`,
|
|
525
|
-
retryable: "never",
|
|
526
|
-
});
|
|
527
|
-
}
|
|
528
|
-
#now() {
|
|
529
|
-
const now = this.options.now;
|
|
530
|
-
if (typeof now === "function") {
|
|
531
|
-
const value = now();
|
|
532
|
-
if (typeof value === "number" && Number.isFinite(value))
|
|
533
|
-
return value;
|
|
534
|
-
}
|
|
535
|
-
return Date.now();
|
|
536
|
-
}
|
|
537
|
-
#fetch() {
|
|
538
|
-
return typeof this.options.fetch === "function" ? this.options.fetch : globalThis.fetch.bind(globalThis);
|
|
539
|
-
}
|
|
540
|
-
/** The retry policy of one operation: the runtime defaults, then the
|
|
541
|
-
* API's defaults for the operation's tier, then `ClientOptions.retries`;
|
|
542
|
-
* each layer overrides the fields it sets. */
|
|
543
|
-
#retryOptions(op) {
|
|
544
|
-
const tiers = isRecord(this.api.retries) ? this.api.retries : {};
|
|
545
|
-
const tier = isMutation(op) ? tiers.mutating : tiers.readOnly;
|
|
546
|
-
const layers = [isRecord(tier) ? tier : {}, isRecord(this.options.retries) ? this.options.retries : {}];
|
|
547
|
-
const pick = (key, valid, fallback) => {
|
|
548
|
-
let value = fallback;
|
|
549
|
-
for (const layer of layers) {
|
|
550
|
-
const candidate = layer[key];
|
|
551
|
-
if (valid(candidate))
|
|
552
|
-
value = candidate;
|
|
553
|
-
}
|
|
554
|
-
return value;
|
|
555
|
-
};
|
|
556
|
-
const finite = (v) => typeof v === "number" && Number.isFinite(v);
|
|
557
|
-
const jitter = (v) => v === "none" || v === "equal" || v === "full";
|
|
558
|
-
const flag = (v) => typeof v === "boolean";
|
|
559
|
-
return {
|
|
560
|
-
max: Math.floor(bounded(pick("max", finite, DEFAULT_RETRIES.max), DEFAULT_RETRIES.max, 0, 100)),
|
|
561
|
-
baseMs: bounded(pick("baseMs", finite, DEFAULT_RETRIES.baseMs), DEFAULT_RETRIES.baseMs, 0),
|
|
562
|
-
maxMs: bounded(pick("maxMs", finite, DEFAULT_RETRIES.maxMs), DEFAULT_RETRIES.maxMs, 0),
|
|
563
|
-
jitter: pick("jitter", jitter, DEFAULT_RETRIES.jitter),
|
|
564
|
-
honorRetryAfter: pick("honorRetryAfter", flag, DEFAULT_RETRIES.honorRetryAfter),
|
|
565
|
-
};
|
|
566
|
-
}
|
|
567
|
-
#timeout(opts) {
|
|
568
|
-
return bounded(opts.timeoutMs, bounded(this.options.timeoutMs, DEFAULT_TIMEOUT_MS, 1), 1);
|
|
569
|
-
}
|
|
570
|
-
#emit(d) {
|
|
571
|
-
try {
|
|
572
|
-
this.options.onDiagnostic?.(d);
|
|
573
|
-
}
|
|
574
|
-
catch {
|
|
575
|
-
// A failing observer never changes the call.
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
#middleware() {
|
|
579
|
-
return Array.isArray(this.options.middleware) ? this.options.middleware.filter(isRecord) : [];
|
|
580
|
-
}
|
|
581
|
-
async #call(op, args, opts, urlOverride) {
|
|
582
|
-
const prepared = await this.#prepare(op, args, opts, "call", urlOverride);
|
|
583
|
-
if (!prepared.ok)
|
|
584
|
-
return prepared;
|
|
585
|
-
let result = await this.#send(prepared.value, opts);
|
|
586
|
-
if (!result.ok && result.error.http_status === 401 && prepared.value.oauth !== null) {
|
|
587
|
-
// An authorization-code token the server no longer accepts: refresh
|
|
588
|
-
// it and send once more. Never a second time.
|
|
589
|
-
result = await this.#retryRejected(prepared.value, result, opts);
|
|
590
|
-
}
|
|
591
|
-
if (result.ok && opts.verify === true && op.agent.verify) {
|
|
592
|
-
return { ...result, verification: await this.#verify(op, prepared.value.args, result.value, opts) };
|
|
593
|
-
}
|
|
594
|
-
return result;
|
|
595
|
-
}
|
|
596
|
-
/** The answer to a request whose stored OAuth2 token was rejected (401):
|
|
597
|
-
* the refreshed token is put on the same request, which is sent once more;
|
|
598
|
-
* `rejected` stands when nothing could be refreshed. */
|
|
599
|
-
async #retryRejected(prepared, rejected, opts) {
|
|
600
|
-
const scheme = prepared.oauth;
|
|
601
|
-
const current = prepared.headers.get("Authorization");
|
|
602
|
-
if (scheme === null || current === undefined || !current.startsWith("Bearer "))
|
|
603
|
-
return rejected;
|
|
604
|
-
const token = await this.#oauth.refreshAfterRejection(scheme, current.slice("Bearer ".length));
|
|
605
|
-
if (token === null)
|
|
606
|
-
return rejected;
|
|
607
|
-
prepared.headers.set("Authorization", `Bearer ${token}`, true);
|
|
608
|
-
prepared.secrets.add(token);
|
|
609
|
-
return this.#send({ ...prepared, oauth: null }, opts);
|
|
610
|
-
}
|
|
611
|
-
/** The OAuth2 authorization-code helpers of the scheme `scheme`: PKCE, the
|
|
612
|
-
* authorization URL, the code exchange and refresh. */
|
|
613
|
-
oauth(scheme) {
|
|
614
|
-
return new OAuthFlow(this.#oauth, scheme);
|
|
615
|
-
}
|
|
616
|
-
// ------------------------------------------------------------ preflight
|
|
617
|
-
#validation(op, path, value, expected, remediation) {
|
|
618
|
-
return fail(diagnostic(op.id, "VALIDATION_FAILED", {
|
|
619
|
-
failed_parameter: argumentPath(op, path),
|
|
620
|
-
received_value: envelopeValue(redactBelow(op, path, value), sensitiveArg(op, path)),
|
|
621
|
-
expected,
|
|
622
|
-
remediation,
|
|
623
|
-
}));
|
|
624
|
-
}
|
|
625
|
-
/** Pre-flight validation of the args. With `placeholders`, an issue at
|
|
626
|
-
* (or below) a value that is a dry evaluation's placeholder is not a
|
|
627
|
-
* failure: the value is only known once the earlier step has run. */
|
|
628
|
-
#validateArgs(op, args, placeholders) {
|
|
629
|
-
const params = argParams(op);
|
|
630
|
-
for (const p of params) {
|
|
631
|
-
if (p.required && (args[p.name] === undefined || args[p.name] === null)) {
|
|
632
|
-
return this.#validation(op, [p.name], args[p.name], `a value for the required ${p.in} parameter ${p.wire}`, `Pass ${p.name}; ${op.id} cannot be called without it.`);
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
const body = op.body;
|
|
636
|
-
if (body && body.required && body.shape.kind === "arg" && args[body.shape.arg] === undefined) {
|
|
637
|
-
return this.#validation(op, [body.shape.arg], undefined, "a request body", `Pass the request body as ${body.shape.arg}.`);
|
|
638
|
-
}
|
|
639
|
-
const request = op.request;
|
|
640
|
-
if (isSchema(request)) {
|
|
641
|
-
const parsed = request.safeParse(args);
|
|
642
|
-
if (!parsed.success) {
|
|
643
|
-
const issues = isRecord(parsed.error) && Array.isArray(parsed.error.issues) ? parsed.error.issues : [];
|
|
644
|
-
const pathOf = (issue) => isRecord(issue) && Array.isArray(issue.path) ? issue.path.filter((s) => typeof s === "string" || typeof s === "number") : [];
|
|
645
|
-
const pending = (path) => placeholders && path.some((_, i) => containsPlaceholder(getPath(args, path.slice(0, i + 1).map(String))));
|
|
646
|
-
const issue = placeholders ? issues.find((candidate) => !pending(pathOf(candidate))) : issues[0];
|
|
647
|
-
if (placeholders && issue === undefined)
|
|
648
|
-
return null;
|
|
649
|
-
const path = pathOf(issue);
|
|
650
|
-
const message = isRecord(issue) && typeof issue.message === "string" ? issue.message : "a valid value";
|
|
651
|
-
return this.#validation(op, path, getPath(args, path.map(String)), message, `Fix ${argumentPath(op, path)} (${message}) and call again.`);
|
|
652
|
-
}
|
|
653
|
-
return null;
|
|
654
|
-
}
|
|
655
|
-
// Without a schema, unknown keys are rejected so a misspelt argument is
|
|
656
|
-
// never silently dropped (rpc methods without a body take free-form args).
|
|
657
|
-
if (op.rpc && !body)
|
|
658
|
-
return null;
|
|
659
|
-
const allowed = new Set(params.map((p) => p.name));
|
|
660
|
-
if (body?.shape.kind === "merged")
|
|
661
|
-
for (const f of body.shape.fields)
|
|
662
|
-
allowed.add(f);
|
|
663
|
-
if (body?.shape.kind === "arg")
|
|
664
|
-
allowed.add(body.shape.arg);
|
|
665
|
-
for (const key of Object.keys(args)) {
|
|
666
|
-
if (!allowed.has(key) && args[key] !== undefined) {
|
|
667
|
-
const names = [...allowed].sort();
|
|
668
|
-
const list = names.length > 0 ? names.slice(0, 20).join(", ") : "no arguments";
|
|
669
|
-
return this.#validation(op, [key], args[key], `one of: ${list}`, `Remove ${key}; ${op.id} does not accept it.`);
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
return null;
|
|
673
|
-
}
|
|
674
|
-
#checkKey(op, opts, purpose) {
|
|
675
|
-
const policy = op.agent.idempotency;
|
|
676
|
-
const key = opts.idempotencyKey;
|
|
677
|
-
const note = policy.note ? ` ${policy.note}` : "";
|
|
678
|
-
if (key === undefined) {
|
|
679
|
-
if (purpose === "call" && policy.policy === "caller_owned" && policy.persistRequired) {
|
|
680
|
-
return fail(diagnostic(op.id, "VALIDATION_FAILED", {
|
|
681
|
-
failed_parameter: "idempotencyKey",
|
|
682
|
-
expected: keyFormatDescription(policy.format),
|
|
683
|
-
remediation: `Generate a random ${/uuid/i.test(policy.format ?? "") ? "UUIDv4" : "unique key"}, persist it with the intent of this ${op.id} call, and pass it as idempotencyKey. Reuse the exact same key on every retry; a new key can apply the effect twice.${note}`,
|
|
684
|
-
}));
|
|
685
|
-
}
|
|
686
|
-
return null;
|
|
687
|
-
}
|
|
688
|
-
if (policy.policy === "none" && !op.params.some((p) => p.role === "idempotency_key"))
|
|
689
|
-
return null;
|
|
690
|
-
if (policy.policy === "content_identity" || policy.policy === "content_hash")
|
|
691
|
-
return null;
|
|
692
|
-
const expected = typeof key === "string" ? checkKeyFormat(key, policy.policy === "caller_owned" ? policy.format : null) : keyFormatDescription(policy.format);
|
|
693
|
-
if (expected !== null) {
|
|
694
|
-
return fail(diagnostic(op.id, "VALIDATION_FAILED", {
|
|
695
|
-
failed_parameter: "idempotencyKey",
|
|
696
|
-
received_value: envelopeValue(key, false),
|
|
697
|
-
expected,
|
|
698
|
-
remediation: `Pass idempotencyKey as ${expected}. Generate it once, persist it with the intent of this call, and reuse it on every retry.${note}`,
|
|
699
|
-
}));
|
|
700
|
-
}
|
|
701
|
-
return null;
|
|
702
|
-
}
|
|
703
|
-
async #checkConfirmation(op, args, opts) {
|
|
704
|
-
if (opts[MACRO_CONFIRMED] === true)
|
|
705
|
-
return null;
|
|
706
|
-
return this.#confirmed(op.id, op.id, op.agent.safety, args, opts.confirm, "preview(...)", opts.allowConfirmTrue !== false);
|
|
707
|
-
}
|
|
708
|
-
/** The confirmation rule of planning/04 for one tier: `destructive`
|
|
709
|
-
* accepts `true` (unless `allowTrue` is false) or a token, `irreversible`
|
|
710
|
-
* only a token issued for `subject` (an operation id, or `macro:<name>`)
|
|
711
|
-
* and these exact args. */
|
|
712
|
-
async #confirmed(id, subject, safety, args, confirm, previewCall, allowTrue) {
|
|
713
|
-
if (safety !== "destructive" && safety !== "irreversible")
|
|
714
|
-
return null;
|
|
715
|
-
const how = `call ${previewCall} with the same arguments and pass its confirmation_token as confirm`;
|
|
716
|
-
const required = (remediation) => fail(diagnostic(id, "CONFIRMATION_REQUIRED", { failed_parameter: "confirm", expected: "a confirmation_token from preview()", remediation }));
|
|
717
|
-
if (confirm === true) {
|
|
718
|
-
if (safety === "destructive" && allowTrue)
|
|
719
|
-
return null;
|
|
720
|
-
return required(`${id} is ${safety}, so confirm: true is not accepted: ${how}.`);
|
|
721
|
-
}
|
|
722
|
-
if (typeof confirm !== "string" || confirm === "") {
|
|
723
|
-
return required(`${id} is ${safety}: ${how}${safety === "destructive" && allowTrue ? " (or pass confirm: true)" : ""}.`);
|
|
724
|
-
}
|
|
725
|
-
const check = await checkToken(this.#confirmationKey, confirm, subject, args, this.#now());
|
|
726
|
-
if (check === "valid")
|
|
727
|
-
return null;
|
|
728
|
-
if (check === "expired")
|
|
729
|
-
return required(`The confirmation token expired (tokens last ${CONFIRMATION_TTL_MS / 60000} minutes): ${how}.`);
|
|
730
|
-
return required(`The confirmation token was not issued by this client for ${id} with exactly these arguments: ${how}.`);
|
|
731
|
-
}
|
|
732
|
-
/** Spend a valid confirmation token on the call about to be sent. A
|
|
733
|
-
* token authorizes one intent: its first use binds it to that call's
|
|
734
|
-
* replay protection (`bind`: the idempotency key sent, or the identity
|
|
735
|
-
* of an identity body), and a later use is accepted only as a retry of
|
|
736
|
-
* the same intent under the same protection. A call without protection
|
|
737
|
-
* (no key) can use a token once. */
|
|
738
|
-
#claimToken(id, token, bind, previewCall) {
|
|
739
|
-
const now = this.#now();
|
|
740
|
-
for (const [used, entry] of this.#usedTokens)
|
|
741
|
-
if (entry.expiry <= now)
|
|
742
|
-
this.#usedTokens.delete(used);
|
|
743
|
-
const previous = this.#usedTokens.get(token);
|
|
744
|
-
if (previous) {
|
|
745
|
-
if (previous.bind !== null && previous.bind === bind)
|
|
746
|
-
return null;
|
|
747
|
-
const why = previous.bind === null
|
|
748
|
-
? "This call has no idempotency key, so a repeat can apply the effect twice"
|
|
749
|
-
: `It was used with ${bind === null ? "an idempotency key" : "another idempotency key"}; only a retry with that same key may reuse it`;
|
|
750
|
-
return fail(diagnostic(id, "CONFIRMATION_REQUIRED", {
|
|
751
|
-
failed_parameter: "confirm",
|
|
752
|
-
expected: "a confirmation_token from preview()",
|
|
753
|
-
remediation: `This confirmation token was already used for one ${id} call. ${why}. Check whether that call took effect; to send again, call ${previewCall} and confirm with its new token.`,
|
|
754
|
-
}));
|
|
755
|
-
}
|
|
756
|
-
const expiry = Number(/^tgc1\.(\d{1,16})\./.exec(token)?.[1] ?? now + CONFIRMATION_TTL_MS);
|
|
757
|
-
this.#usedTokens.set(token, { bind, expiry });
|
|
758
|
-
return null;
|
|
759
|
-
}
|
|
760
|
-
// ------------------------------------------------------------- building
|
|
761
|
-
async #prepare(op, rawArgs, opts, purpose, urlOverride) {
|
|
762
|
-
if (opts.maxResponseBytes !== undefined && (!Number.isSafeInteger(opts.maxResponseBytes) || opts.maxResponseBytes < 1)) {
|
|
763
|
-
return fail(diagnostic(safeId(op), "VALIDATION_FAILED", {
|
|
764
|
-
failed_parameter: "maxResponseBytes",
|
|
765
|
-
remediation: "maxResponseBytes must be a positive safe integer; nothing was sent.",
|
|
766
|
-
}));
|
|
767
|
-
}
|
|
768
|
-
const problem = descriptorProblem(op);
|
|
769
|
-
if (problem) {
|
|
770
|
-
return fail(diagnostic(safeId(op), "VALIDATION_FAILED", {
|
|
771
|
-
failed_parameter: "operation",
|
|
772
|
-
expected: "a valid operation descriptor",
|
|
773
|
-
remediation: `The operation descriptor is invalid: ${problem}. Regenerate the SDK or fix the descriptor; nothing was sent.`,
|
|
774
|
-
}));
|
|
775
|
-
}
|
|
776
|
-
this.register(op);
|
|
777
|
-
if (!isRecord(rawArgs)) {
|
|
778
|
-
return fail(diagnostic(op.id, "VALIDATION_FAILED", {
|
|
779
|
-
failed_parameter: "args",
|
|
780
|
-
received_value: envelopeValue(rawArgs, false),
|
|
781
|
-
expected: "an object of named arguments",
|
|
782
|
-
remediation: `Pass the arguments of ${op.id} as one object, e.g. {${argParams(op)
|
|
783
|
-
.slice(0, 3)
|
|
784
|
-
.map((p) => `${p.name}: ...`)
|
|
785
|
-
.join(", ")}}.`,
|
|
786
|
-
}));
|
|
787
|
-
}
|
|
788
|
-
try {
|
|
789
|
-
canonicalJson(rawArgs);
|
|
790
|
-
}
|
|
791
|
-
catch {
|
|
792
|
-
return this.#validation(op, [], "<cyclic value>", "a JSON-like value without cycles", "Pass arguments without circular references.");
|
|
793
|
-
}
|
|
794
|
-
const args = withoutUndefined(rawArgs);
|
|
795
|
-
const invalid = this.#validateArgs(op, args, purpose === "macro_preview") ?? this.#checkKey(op, opts, purpose);
|
|
796
|
-
if (invalid)
|
|
797
|
-
return invalid;
|
|
798
|
-
if (purpose === "call") {
|
|
799
|
-
const unconfirmed = await this.#checkConfirmation(op, args, opts);
|
|
800
|
-
if (unconfirmed)
|
|
801
|
-
return unconfirmed;
|
|
802
|
-
}
|
|
803
|
-
const method = op.method;
|
|
804
|
-
const auth = await resolveAuth(this.api, op, isRecord(this.options.auth) ? this.options.auth : {}, method, this.#tokenSource);
|
|
805
|
-
if (!auth.ok)
|
|
806
|
-
return fail(diagnostic(op.id, "AUTH_FAILED", { remediation: auth.remediation }));
|
|
807
|
-
const secrets = new Set(auth.secrets.filter((s) => s !== ""));
|
|
808
|
-
for (const p of op.params) {
|
|
809
|
-
const value = args[p.name];
|
|
810
|
-
if (p.sensitive === true && (typeof value === "string" || typeof value === "number"))
|
|
811
|
-
secrets.add(String(value));
|
|
812
|
-
}
|
|
813
|
-
for (const path of sensitiveRequestPaths(op)) {
|
|
814
|
-
const found = [];
|
|
815
|
-
valuesAt(args, splitPath(path), found);
|
|
816
|
-
for (const value of found)
|
|
817
|
-
secrets.add(value);
|
|
818
|
-
}
|
|
819
|
-
const url = this.#buildUrl(op, args, auth.plan, urlOverride);
|
|
820
|
-
if (!url.ok)
|
|
821
|
-
return url;
|
|
822
|
-
const headers = new HeaderBag();
|
|
823
|
-
this.#buildHeaders(op, args, opts, auth.plan, headers);
|
|
824
|
-
let encoded;
|
|
825
|
-
try {
|
|
826
|
-
const value = isSafeMethod(method) && method !== "OPTIONS" ? undefined : bodyValue(op, args, new Set(argParams(op).map((p) => p.name)));
|
|
827
|
-
const bodyPaths = sensitiveBodyPaths(op);
|
|
828
|
-
encoded = await encodeBody(op.body ?? null, value, (display) => redactSensitiveKeys(bodyPaths.includes("") ? REDACTED : redactPaths(display, bodyPaths.filter((p) => p !== ""))));
|
|
829
|
-
}
|
|
830
|
-
catch (error) {
|
|
831
|
-
if (error instanceof SerializationError) {
|
|
832
|
-
return this.#validation(op, error.parameter === "body" && op.body?.shape.kind === "arg" ? [op.body.shape.arg] : [], error.value, error.expected, `Pass ${error.parameter} as ${error.expected}.`);
|
|
833
|
-
}
|
|
834
|
-
throw error;
|
|
835
|
-
}
|
|
836
|
-
if (encoded.contentType)
|
|
837
|
-
headers.set("Content-Type", encoded.contentType);
|
|
838
|
-
const observedBody = observableBody(op.body?.encoding ?? "json", encoded);
|
|
839
|
-
const header = keyHeader(op);
|
|
840
|
-
const key = await this.#idempotencyKey(op, args, opts, purpose, encoded.hashMaterial);
|
|
841
|
-
if (!key.ok)
|
|
842
|
-
return key;
|
|
843
|
-
if (key.value !== null) {
|
|
844
|
-
headers.set(header, key.value, true);
|
|
845
|
-
secrets.add(key.value);
|
|
846
|
-
}
|
|
847
|
-
else if (purpose !== "call" && (op.agent.idempotency.policy === "auto" || op.agent.idempotency.policy === "caller_owned")) {
|
|
848
|
-
headers.set(header, "<set at call time>");
|
|
849
|
-
}
|
|
850
|
-
for (const h of auth.plan.headers)
|
|
851
|
-
headers.set(h.name, h.value, h.secret);
|
|
852
|
-
if (purpose === "server_preview" && op.agent.preview.mode === "header")
|
|
853
|
-
headers.set(op.agent.preview.header, op.agent.preview.value);
|
|
854
|
-
const record = headers.toRecord(false);
|
|
855
|
-
for (const [name, value] of Object.entries(record)) {
|
|
856
|
-
if (!validHeaderName(name) || !validHeaderValue(value)) {
|
|
857
|
-
const fromAuth = auth.plan.headers.some((h) => h.name.toLowerCase() === name.toLowerCase());
|
|
858
|
-
if (fromAuth) {
|
|
859
|
-
return fail(diagnostic(op.id, "AUTH_FAILED", { remediation: `The credential for header ${name} contains characters not allowed in an HTTP header; check ClientOptions.auth.` }));
|
|
860
|
-
}
|
|
861
|
-
const param = op.params.find((p) => p.wire.toLowerCase() === name.toLowerCase());
|
|
862
|
-
return this.#validation(op, param ? [param.name] : [], headers.isSecret(name) ? REDACTED : value, "a header value of visible ASCII characters (no line breaks)", `Header ${name} cannot carry this value; remove line breaks and non-Latin-1 characters.`);
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
const claimMacro = opts[MACRO_CLAIM];
|
|
866
|
-
if (purpose === "call" && typeof claimMacro === "function") {
|
|
867
|
-
const spent = claimMacro();
|
|
868
|
-
if (spent)
|
|
869
|
-
return spent;
|
|
870
|
-
}
|
|
871
|
-
else if (purpose === "call" && typeof opts.confirm === "string" && opts[MACRO_CONFIRMED] !== true) {
|
|
872
|
-
if (op.agent.safety === "destructive" || op.agent.safety === "irreversible") {
|
|
873
|
-
const policy = op.agent.idempotency.policy;
|
|
874
|
-
const bind = key.value ?? (policy === "content_identity" ? "content-identity" : null);
|
|
875
|
-
const spent = this.#claimToken(op.id, opts.confirm, bind, "preview(...)");
|
|
876
|
-
if (spent)
|
|
877
|
-
return spent;
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
return {
|
|
881
|
-
ok: true,
|
|
882
|
-
value: {
|
|
883
|
-
op,
|
|
884
|
-
args,
|
|
885
|
-
method,
|
|
886
|
-
url: url.value.url,
|
|
887
|
-
displayUrl: url.value.display,
|
|
888
|
-
headers,
|
|
889
|
-
body: encoded.body,
|
|
890
|
-
observedBody,
|
|
891
|
-
displayBody: encoded.display,
|
|
892
|
-
key: key.value,
|
|
893
|
-
keyHeader: header,
|
|
894
|
-
authQuery: auth.plan.query.map((q) => ({ name: q.name, value: q.value })),
|
|
895
|
-
hiddenQuery: [...auth.plan.query.map((q) => q.name), ...op.params.filter((x) => x.in === "query" && x.sensitive === true).map((x) => x.wire)],
|
|
896
|
-
secrets: new Set([...secrets, ...headers.secrets()]),
|
|
897
|
-
oauth: auth.oauth,
|
|
898
|
-
},
|
|
899
|
-
};
|
|
900
|
-
}
|
|
901
|
-
#buildUrl(op, args, plan, urlOverride) {
|
|
902
|
-
const servers = Array.isArray(this.api.servers) ? this.api.servers.filter((s) => typeof s === "string") : [];
|
|
903
|
-
const base = typeof this.options.baseUrl === "string" ? this.options.baseUrl : servers[0];
|
|
904
|
-
if (base === undefined || base === "") {
|
|
905
|
-
return fail(diagnostic(op.id, "TRANSPORT_FAILED", { retryable: "never", remediation: "No base URL is configured: set ClientOptions.baseUrl. Nothing was sent." }));
|
|
906
|
-
}
|
|
907
|
-
const enc = encodeURIComponent;
|
|
908
|
-
const params = argParams(op);
|
|
909
|
-
let url;
|
|
910
|
-
let display;
|
|
911
|
-
if (urlOverride !== null) {
|
|
912
|
-
url = urlOverride;
|
|
913
|
-
display = urlOverride;
|
|
914
|
-
}
|
|
915
|
-
else {
|
|
916
|
-
let missing = null;
|
|
917
|
-
let unknownPlaceholder = null;
|
|
918
|
-
const path = op.path.replace(/\{([^{}]+)\}/g, (match, wire) => {
|
|
919
|
-
const p = params.find((x) => x.in === "path" && x.wire === wire) ?? params.find((x) => x.in === "path" && x.name === wire);
|
|
920
|
-
if (!p) {
|
|
921
|
-
unknownPlaceholder = wire;
|
|
922
|
-
return match;
|
|
923
|
-
}
|
|
924
|
-
const value = args[p.name];
|
|
925
|
-
if (value === undefined || value === null) {
|
|
926
|
-
missing = p;
|
|
927
|
-
return match;
|
|
928
|
-
}
|
|
929
|
-
return serializePathParam(p, value);
|
|
930
|
-
});
|
|
931
|
-
if (unknownPlaceholder !== null) {
|
|
932
|
-
return fail(diagnostic(op.id, "VALIDATION_FAILED", { failed_parameter: "operation", expected: "a valid operation descriptor", remediation: `The path template has a placeholder {${String(unknownPlaceholder)}} with no path parameter; regenerate the SDK.` }));
|
|
933
|
-
}
|
|
934
|
-
if (missing !== null) {
|
|
935
|
-
const p = missing;
|
|
936
|
-
return this.#validation(op, [p.name], undefined, `a value for path parameter ${p.wire}`, `Pass ${p.name}.`);
|
|
937
|
-
}
|
|
938
|
-
// A path segment that is empty or a dot segment (`.`, `..`, also
|
|
939
|
-
// percent-encoded) would be removed or resolved by URL parsing, so
|
|
940
|
-
// the request would reach another resource (DELETE /items/.. is
|
|
941
|
-
// DELETE /): refuse it before anything is built or confirmed.
|
|
942
|
-
const templateSegments = op.path.split("/");
|
|
943
|
-
const builtSegments = path.split("/");
|
|
944
|
-
if (templateSegments.length === builtSegments.length) {
|
|
945
|
-
for (const [i, segment] of builtSegments.entries()) {
|
|
946
|
-
const template = templateSegments[i] ?? "";
|
|
947
|
-
if (!template.includes("{") || !(segment === "" || /^(?:\.|%2e){1,2}$/i.test(segment)))
|
|
948
|
-
continue;
|
|
949
|
-
const wire = /\{([^{}]+)\}/.exec(template)?.[1];
|
|
950
|
-
const p = params.find((x) => x.in === "path" && (x.wire === wire || x.name === wire));
|
|
951
|
-
return this.#validation(op, p ? [p.name] : [], p ? args[p.name] : segment, "a non-empty path segment other than . and ..", `Pass ${p ? p.name : "the path parameter"} as the identifier of one resource; "${segment}" would change the request path.`);
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
const parts = [];
|
|
955
|
-
const shown = [];
|
|
956
|
-
for (const p of params) {
|
|
957
|
-
if (p.in !== "query")
|
|
958
|
-
continue;
|
|
959
|
-
const serialized = serializeQueryParam(p, args[p.name]);
|
|
960
|
-
parts.push(...serialized);
|
|
961
|
-
shown.push(...(p.sensitive ? serialized.map((s) => `${s.split("=")[0]}=${REDACTED}`) : serialized));
|
|
962
|
-
}
|
|
963
|
-
for (const q of plan.query) {
|
|
964
|
-
parts.push(`${enc(q.name)}=${enc(q.value)}`);
|
|
965
|
-
shown.push(`${enc(q.name)}=${REDACTED}`);
|
|
966
|
-
}
|
|
967
|
-
const root = trimTrailingSlash(base);
|
|
968
|
-
const join = (list) => (list.length > 0 ? `${path.includes("?") ? "&" : "?"}${list.join("&")}` : "");
|
|
969
|
-
url = `${root}${path}${join(parts)}`;
|
|
970
|
-
display = `${root}${path}${join(shown)}`;
|
|
971
|
-
plan = { ...plan, query: [] };
|
|
972
|
-
}
|
|
973
|
-
if (urlOverride !== null && plan.query.length > 0) {
|
|
974
|
-
const target = new URL(url);
|
|
975
|
-
for (const q of plan.query)
|
|
976
|
-
if (!target.searchParams.has(q.name))
|
|
977
|
-
target.searchParams.set(q.name, q.value);
|
|
978
|
-
url = target.toString();
|
|
979
|
-
const shownTarget = new URL(display);
|
|
980
|
-
for (const q of plan.query)
|
|
981
|
-
shownTarget.searchParams.set(q.name, REDACTED);
|
|
982
|
-
display = shownTarget.toString();
|
|
983
|
-
}
|
|
984
|
-
try {
|
|
985
|
-
const parsed = new URL(url);
|
|
986
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
987
|
-
throw new TypeError("unsupported protocol");
|
|
988
|
-
if (parsed.username || parsed.password)
|
|
989
|
-
throw new TypeError("credentials in the URL");
|
|
990
|
-
}
|
|
991
|
-
catch {
|
|
992
|
-
return fail(diagnostic(op.id, "TRANSPORT_FAILED", {
|
|
993
|
-
retryable: "never",
|
|
994
|
-
remediation: "The base URL is not a valid absolute http(s) URL without credentials: fix ClientOptions.baseUrl. Nothing was sent.",
|
|
995
|
-
}));
|
|
996
|
-
}
|
|
997
|
-
return { ok: true, value: { url, display } };
|
|
998
|
-
}
|
|
999
|
-
#buildHeaders(op, args, opts, plan, headers) {
|
|
1000
|
-
const accept = [
|
|
1001
|
-
...new Set(op.responses
|
|
1002
|
-
.filter((r) => isRecord(r) && r.kind === "success" && typeof r.mediaType === "string")
|
|
1003
|
-
.map((r) => r.mediaType)),
|
|
1004
|
-
];
|
|
1005
|
-
if (accept.length > 0)
|
|
1006
|
-
headers.set("Accept", accept.join(", "));
|
|
1007
|
-
const apiName = typeof this.api.name === "string" ? this.api.name : "api";
|
|
1008
|
-
const apiVersion = typeof this.api.version === "string" ? this.api.version : "0";
|
|
1009
|
-
headers.set("X-Tungsten-Runtime", `tungsten-ts/${RUNTIME_VERSION} ${apiName}-sdk/${apiVersion}`);
|
|
1010
|
-
headers.set("X-Tungsten-Operation", op.id);
|
|
1011
|
-
if (globalThis.document === undefined) {
|
|
1012
|
-
const tungsten = typeof this.api.tungstenVersion === "string" ? this.api.tungstenVersion : RUNTIME_VERSION;
|
|
1013
|
-
headers.set("User-Agent", `${apiName}-sdk/${apiVersion} tungsten/${tungsten} (typescript)`);
|
|
1014
|
-
}
|
|
1015
|
-
for (const p of op.params) {
|
|
1016
|
-
if (p.role === "constant" && p.in === "header" && typeof p.constant === "string") {
|
|
1017
|
-
headers.set(p.wire, p.constant, p.sensitive === true);
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
for (const extra of [this.options.headers, opts.headers]) {
|
|
1021
|
-
if (!isRecord(extra))
|
|
1022
|
-
continue;
|
|
1023
|
-
for (const [name, value] of Object.entries(extra)) {
|
|
1024
|
-
if (typeof value === "string")
|
|
1025
|
-
headers.set(name, value, looksSensitive(name));
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
const cookies = [];
|
|
1029
|
-
const existingCookie = headers.get("Cookie");
|
|
1030
|
-
if (existingCookie !== undefined)
|
|
1031
|
-
cookies.push(existingCookie);
|
|
1032
|
-
for (const p of argParams(op)) {
|
|
1033
|
-
const value = args[p.name];
|
|
1034
|
-
if (value === undefined || value === null)
|
|
1035
|
-
continue;
|
|
1036
|
-
if (p.in === "header")
|
|
1037
|
-
headers.set(p.wire, serializeHeaderParam(p, value), p.sensitive === true);
|
|
1038
|
-
else if (p.in === "cookie")
|
|
1039
|
-
cookies.push(serializeCookieParam(p, value));
|
|
1040
|
-
}
|
|
1041
|
-
for (const c of plan.cookies)
|
|
1042
|
-
cookies.push(`${c.name}=${c.value}`);
|
|
1043
|
-
// Cookies are session material: the whole header is always redacted.
|
|
1044
|
-
if (cookies.length > 0)
|
|
1045
|
-
headers.set("Cookie", cookies.join("; "), true);
|
|
1046
|
-
}
|
|
1047
|
-
async #idempotencyKey(op, args, opts, purpose, hashMaterial) {
|
|
1048
|
-
const policy = op.agent.idempotency.policy;
|
|
1049
|
-
const supplied = typeof opts.idempotencyKey === "string" ? opts.idempotencyKey : null;
|
|
1050
|
-
switch (policy) {
|
|
1051
|
-
case "content_identity":
|
|
1052
|
-
return { ok: true, value: null };
|
|
1053
|
-
case "content_hash":
|
|
1054
|
-
return { ok: true, value: hashMaterial === null ? null : await sha256Hex(hashMaterial) };
|
|
1055
|
-
case "caller_owned":
|
|
1056
|
-
return { ok: true, value: supplied };
|
|
1057
|
-
case "none":
|
|
1058
|
-
return { ok: true, value: op.params.some((p) => p.role === "idempotency_key") ? supplied : null };
|
|
1059
|
-
case "auto": {
|
|
1060
|
-
if (supplied !== null)
|
|
1061
|
-
return { ok: true, value: supplied };
|
|
1062
|
-
if (purpose !== "call")
|
|
1063
|
-
return { ok: true, value: null };
|
|
1064
|
-
const logicalId = await sha256Hex(canonicalJson(args));
|
|
1065
|
-
try {
|
|
1066
|
-
const existing = await this.#store.get(op.id, logicalId);
|
|
1067
|
-
if (typeof existing === "string" && existing !== "")
|
|
1068
|
-
return { ok: true, value: existing };
|
|
1069
|
-
const key = crypto.randomUUID();
|
|
1070
|
-
await this.#store.put(op.id, logicalId, key);
|
|
1071
|
-
return { ok: true, value: key };
|
|
1072
|
-
}
|
|
1073
|
-
catch (error) {
|
|
1074
|
-
return fail(diagnostic(op.id, "TRANSPORT_FAILED", {
|
|
1075
|
-
retryable: "never",
|
|
1076
|
-
remediation: `The idempotency store failed (${describeError(error)}), so the call was not sent rather than risk a second key for the same intent. Fix or replace ClientOptions.idempotencyStore.`,
|
|
1077
|
-
}));
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
#tokenSource = async (scheme, config) => {
|
|
1083
|
-
if (config.flow === "authorizationCode")
|
|
1084
|
-
return this.#oauth.token(scheme.name);
|
|
1085
|
-
const cached = this.#tokenCache.get(scheme.name);
|
|
1086
|
-
if (cached && cached.expiresAt > Date.now() + 30_000)
|
|
1087
|
-
return cached.token;
|
|
1088
|
-
const inflight = this.#tokenInflight.get(scheme.name);
|
|
1089
|
-
if (inflight)
|
|
1090
|
-
return inflight;
|
|
1091
|
-
const request = this.#fetchToken(scheme.name, scheme.tokenUrl ?? "", scheme.scopes, config);
|
|
1092
|
-
this.#tokenInflight.set(scheme.name, request);
|
|
1093
|
-
try {
|
|
1094
|
-
return await request;
|
|
1095
|
-
}
|
|
1096
|
-
finally {
|
|
1097
|
-
this.#tokenInflight.delete(scheme.name);
|
|
1098
|
-
}
|
|
1099
|
-
};
|
|
1100
|
-
async #fetchToken(name, tokenUrl, scopes, config) {
|
|
1101
|
-
const form = new URLSearchParams({ grant_type: "client_credentials" });
|
|
1102
|
-
if (Array.isArray(scopes) && scopes.length > 0)
|
|
1103
|
-
form.set("scope", scopes.filter((s) => typeof s === "string").join(" "));
|
|
1104
|
-
const basic = btoa(`${encodeURIComponent(config.clientId ?? "")}:${encodeURIComponent(config.clientSecret ?? "")}`);
|
|
1105
|
-
const outcome = await attempt(this.#fetch(), {
|
|
1106
|
-
url: tokenUrl,
|
|
1107
|
-
method: "POST",
|
|
1108
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", Authorization: `Basic ${basic}` },
|
|
1109
|
-
body: form.toString(),
|
|
1110
|
-
redirect: "manual",
|
|
1111
|
-
timeoutMs: bounded(this.options.timeoutMs, DEFAULT_TIMEOUT_MS, 1),
|
|
1112
|
-
signal: undefined,
|
|
1113
|
-
});
|
|
1114
|
-
if (outcome.kind !== "response" || outcome.body === null) {
|
|
1115
|
-
throw new TokenError(`The OAuth2 token endpoint for ${name} could not be reached; check network access and the token URL.`);
|
|
1116
|
-
}
|
|
1117
|
-
if (outcome.status < 200 || outcome.status > 299) {
|
|
1118
|
-
throw new TokenError(`The OAuth2 token endpoint for ${name} answered HTTP ${outcome.status}; check clientId, clientSecret and scopes.`);
|
|
1119
|
-
}
|
|
1120
|
-
const decoded = decodeBody(outcome.body, outcome.headers, "application/json");
|
|
1121
|
-
const token = getPath(decoded.value, "access_token");
|
|
1122
|
-
if (typeof token !== "string" || token === "")
|
|
1123
|
-
throw new TokenError(`The OAuth2 token endpoint for ${name} returned no access_token.`);
|
|
1124
|
-
const expiresIn = getPath(decoded.value, "expires_in");
|
|
1125
|
-
const lifetime = typeof expiresIn === "number" && Number.isFinite(expiresIn) ? expiresIn * 1000 : 3_600_000;
|
|
1126
|
-
this.#tokenCache.set(name, { token, expiresAt: Date.now() + lifetime });
|
|
1127
|
-
return token;
|
|
1128
|
-
}
|
|
1129
|
-
// -------------------------------------------------------------- sending
|
|
1130
|
-
async #send(prepared, opts, stream = false, idleMs) {
|
|
1131
|
-
const { op } = prepared;
|
|
1132
|
-
const retries = this.#retryOptions(op);
|
|
1133
|
-
const mutation = isMutation(op);
|
|
1134
|
-
const protectedReplay = hasReplayProtection(op, prepared.key);
|
|
1135
|
-
const check = mutation && !protectedReplay ? this.#outcomeCheck(op, prepared.args) : null;
|
|
1136
|
-
const timeoutMs = this.#timeout(opts);
|
|
1137
|
-
let attempts = 0;
|
|
1138
|
-
for (;;) {
|
|
1139
|
-
attempts += 1;
|
|
1140
|
-
const ctx = {
|
|
1141
|
-
operation: op,
|
|
1142
|
-
attempt: attempts,
|
|
1143
|
-
method: prepared.method,
|
|
1144
|
-
url: prepared.displayUrl,
|
|
1145
|
-
headers: prepared.headers.toRecord(true),
|
|
1146
|
-
body: prepared.observedBody,
|
|
1147
|
-
};
|
|
1148
|
-
const headers = await this.#beforeRequest(ctx, prepared);
|
|
1149
|
-
// Redirects are never followed by fetch: it would forward custom
|
|
1150
|
-
// credential headers (API keys, CSRF headers) to another origin. A
|
|
1151
|
-
// read follows same-origin redirects here; a mutation follows none.
|
|
1152
|
-
let outcome = await attempt(this.#fetch(), {
|
|
1153
|
-
url: prepared.url,
|
|
1154
|
-
method: prepared.method,
|
|
1155
|
-
headers,
|
|
1156
|
-
body: prepared.body,
|
|
1157
|
-
redirect: "manual",
|
|
1158
|
-
timeoutMs,
|
|
1159
|
-
signal: opts.signal,
|
|
1160
|
-
stream,
|
|
1161
|
-
idleMs,
|
|
1162
|
-
maxResponseBytes: opts.maxResponseBytes,
|
|
1163
|
-
});
|
|
1164
|
-
let current = { url: prepared.url, method: prepared.method, body: prepared.body };
|
|
1165
|
-
for (let hops = 0; !mutation && hops < MAX_REDIRECTS && outcome.kind === "response" && REDIRECT_STATUSES.has(outcome.status); hops += 1) {
|
|
1166
|
-
const next = this.#redirectTarget(prepared, current.url, outcome.headers.location);
|
|
1167
|
-
if (next === null)
|
|
1168
|
-
break;
|
|
1169
|
-
const rewrite = outcome.status !== 307 && outcome.status !== 308 && current.method !== "GET" && current.method !== "HEAD";
|
|
1170
|
-
current = { url: next.url, method: rewrite ? "GET" : current.method, body: rewrite ? null : current.body };
|
|
1171
|
-
ctx.url = next.display;
|
|
1172
|
-
ctx.method = current.method;
|
|
1173
|
-
const hopHeaders = { ...headers };
|
|
1174
|
-
if (rewrite)
|
|
1175
|
-
for (const name of Object.keys(hopHeaders))
|
|
1176
|
-
if (name.toLowerCase() === "content-type")
|
|
1177
|
-
delete hopHeaders[name];
|
|
1178
|
-
outcome = await attempt(this.#fetch(), {
|
|
1179
|
-
url: current.url,
|
|
1180
|
-
method: current.method,
|
|
1181
|
-
headers: hopHeaders,
|
|
1182
|
-
body: current.body,
|
|
1183
|
-
redirect: "manual",
|
|
1184
|
-
timeoutMs,
|
|
1185
|
-
signal: opts.signal,
|
|
1186
|
-
stream,
|
|
1187
|
-
idleMs,
|
|
1188
|
-
maxResponseBytes: opts.maxResponseBytes,
|
|
1189
|
-
});
|
|
1190
|
-
}
|
|
1191
|
-
const callCtx = { api: this.api, op, key: prepared.key, keyHeader: prepared.keyHeader, attempts, check };
|
|
1192
|
-
const result = await this.#classify(callCtx, prepared, outcome, ctx, timeoutMs);
|
|
1193
|
-
if (result.ok)
|
|
1194
|
-
return result;
|
|
1195
|
-
const error = scrubDiagnostic(result.error, prepared.secrets);
|
|
1196
|
-
const done = () => (result.partial === undefined ? fail(error) : { ok: false, error, partial: result.partial });
|
|
1197
|
-
const cancelled = opts.signal?.aborted === true;
|
|
1198
|
-
if (cancelled || attempts > retries.max || !RETRY_CATEGORIES.has(error.category))
|
|
1199
|
-
return done();
|
|
1200
|
-
if (error.retryable === "never" || error.retryable === "after_remediation")
|
|
1201
|
-
return done();
|
|
1202
|
-
if (mutation && !protectedReplay)
|
|
1203
|
-
return done();
|
|
1204
|
-
let delay;
|
|
1205
|
-
if (retries.honorRetryAfter && error.retry_after_ms !== null) {
|
|
1206
|
-
if (error.retry_after_ms > retries.maxMs)
|
|
1207
|
-
return done();
|
|
1208
|
-
delay = error.retry_after_ms;
|
|
1209
|
-
}
|
|
1210
|
-
else {
|
|
1211
|
-
const ceiling = Math.min(retries.maxMs, retries.baseMs * 2 ** (attempts - 1));
|
|
1212
|
-
const random = typeof this.options.random === "function" ? bounded(this.options.random(), 0.5, 0, 1) : Math.random();
|
|
1213
|
-
delay = retries.jitter === "none" ? ceiling : retries.jitter === "equal" ? ceiling / 2 + (random * ceiling) / 2 : random * ceiling;
|
|
1214
|
-
}
|
|
1215
|
-
for (const m of this.#middleware()) {
|
|
1216
|
-
try {
|
|
1217
|
-
await m.onRetry?.(ctx, error);
|
|
1218
|
-
}
|
|
1219
|
-
catch {
|
|
1220
|
-
// Observers never change the call.
|
|
1221
|
-
}
|
|
1222
|
-
}
|
|
1223
|
-
if (!(await sleep(delay, opts.signal)))
|
|
1224
|
-
return done();
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
/** How to find out whether `op` (a mutation without replay protection)
|
|
1228
|
-
* took effect after its answer was lost: its verification hook when it
|
|
1229
|
-
* can be called without the lost response, else a registered read of the
|
|
1230
|
-
* same resource. Null when neither exists. */
|
|
1231
|
-
#outcomeCheck(op, args) {
|
|
1232
|
-
const sent = sentArguments(op, args);
|
|
1233
|
-
const hook = op.agent.verify;
|
|
1234
|
-
if (hook && typeof hook.operation === "string" && verifyCallable(hook)) {
|
|
1235
|
-
const scope = { args: withWireNames(op, args) };
|
|
1236
|
-
const call = `${hook.operation}${withArguments(evaluateExpr(hook.args ?? {}, scope))}`;
|
|
1237
|
-
// `expect` is what a successful call leaves behind; references to the
|
|
1238
|
-
// call's arguments are known, references to its response are not.
|
|
1239
|
-
const lost = [];
|
|
1240
|
-
const resolve = (node, depth = 0) => {
|
|
1241
|
-
if (depth > 32)
|
|
1242
|
-
return null;
|
|
1243
|
-
if (typeof node === "string" && node.startsWith("$response")) {
|
|
1244
|
-
lost.push(node.slice("$response".length).replace(/^\./, "") || "body");
|
|
1245
|
-
return `<${node.slice(1)}>`;
|
|
1246
|
-
}
|
|
1247
|
-
if (typeof node === "string" && node.startsWith("$"))
|
|
1248
|
-
return resolveRef(node, scope) ?? `<${node.slice(1)}>`;
|
|
1249
|
-
if (Array.isArray(node))
|
|
1250
|
-
return node.map((item) => resolve(item, depth + 1));
|
|
1251
|
-
if (isRecord(node))
|
|
1252
|
-
return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, resolve(v, depth + 1)]));
|
|
1253
|
-
return node;
|
|
1254
|
-
};
|
|
1255
|
-
const expect = isRecord(hook.expect) ? resolve(hook.expect) : {};
|
|
1256
|
-
const fields = Object.keys(expect);
|
|
1257
|
-
let shows;
|
|
1258
|
-
if (fields.length === 0)
|
|
1259
|
-
shows = `whether it shows ${changeOf(op)}`;
|
|
1260
|
-
else if (lost.length > 0) {
|
|
1261
|
-
shows = `whether ${fields.join(" and ")} holds what ${op.id} creates, matching the arguments you sent${sent} (its ${[...new Set(lost)].join(", ")} was in the lost response)`;
|
|
1262
|
-
}
|
|
1263
|
-
else
|
|
1264
|
-
shows = `whether ${describePredicate(expect)}`;
|
|
1265
|
-
return { call, shows };
|
|
1266
|
-
}
|
|
1267
|
-
const read = this.#resourceRead(op, args);
|
|
1268
|
-
return read ? { call: `${read.id}${withArguments(read.args)}`, shows: `whether it shows ${changeOf(op)}` } : null;
|
|
1269
|
-
}
|
|
1270
|
-
/** A registered read of the resource `op` changes: a `GET` whose path is
|
|
1271
|
-
* the longest prefix of `op`'s path (`/v1/items/{id}/cancel` →
|
|
1272
|
-
* `/v1/items/{id}`, then `/v1/items`), never the API root, whose required
|
|
1273
|
-
* parameters are all path parameters `op` was called with. Candidates on
|
|
1274
|
-
* one path are taken in id order. rpc methods have no resource paths. */
|
|
1275
|
-
#resourceRead(op, args) {
|
|
1276
|
-
if (op.rpc)
|
|
1277
|
-
return null;
|
|
1278
|
-
const known = new Map();
|
|
1279
|
-
for (const p of op.params)
|
|
1280
|
-
if (p.in === "path" && args[p.name] !== undefined && args[p.name] !== null)
|
|
1281
|
-
known.set(p.wire, args[p.name]);
|
|
1282
|
-
const segments = op.path.split("/");
|
|
1283
|
-
const root = (s) => s === "" || s === "api" || /^v\d+(?:\.\d+)*$/i.test(s);
|
|
1284
|
-
const reads = [...this.#registry.values()]
|
|
1285
|
-
.filter((r) => r !== op && r.method === "GET" && r.agent?.safety === "read_only" && !r.rpc && Array.isArray(r.params))
|
|
1286
|
-
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
1287
|
-
for (let end = segments.length; end > 0; end -= 1) {
|
|
1288
|
-
const prefix = segments.slice(0, end);
|
|
1289
|
-
if (prefix.every(root))
|
|
1290
|
-
break;
|
|
1291
|
-
const path = prefix.join("/");
|
|
1292
|
-
for (const read of reads) {
|
|
1293
|
-
if (read.path !== path)
|
|
1294
|
-
continue;
|
|
1295
|
-
const required = argParams(read).filter((p) => p.required);
|
|
1296
|
-
if (!required.every((p) => p.in === "path" && known.has(p.wire)))
|
|
1297
|
-
continue;
|
|
1298
|
-
const readArgs = {};
|
|
1299
|
-
for (const p of argParams(read))
|
|
1300
|
-
if (p.in === "path" && known.has(p.wire))
|
|
1301
|
-
readArgs[p.wire] = known.get(p.wire);
|
|
1302
|
-
return { id: read.id, args: readArgs };
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
return null;
|
|
1306
|
-
}
|
|
1307
|
-
/** The URL a read's redirect points to, when it stays on the request's
|
|
1308
|
-
* origin (scheme, host and port), with the auth query re-applied; null
|
|
1309
|
-
* for any other origin, which is reported instead of followed. */
|
|
1310
|
-
#redirectTarget(prepared, from, location) {
|
|
1311
|
-
if (typeof location !== "string" || location === "")
|
|
1312
|
-
return null;
|
|
1313
|
-
let target;
|
|
1314
|
-
let origin;
|
|
1315
|
-
try {
|
|
1316
|
-
origin = new URL(from).origin;
|
|
1317
|
-
target = new URL(location, from);
|
|
1318
|
-
}
|
|
1319
|
-
catch {
|
|
1320
|
-
return null;
|
|
1321
|
-
}
|
|
1322
|
-
if (target.origin !== origin || target.username !== "" || target.password !== "")
|
|
1323
|
-
return null;
|
|
1324
|
-
for (const q of prepared.authQuery)
|
|
1325
|
-
if (!target.searchParams.has(q.name))
|
|
1326
|
-
target.searchParams.set(q.name, q.value);
|
|
1327
|
-
const shown = new URL(target.toString());
|
|
1328
|
-
for (const name of prepared.hiddenQuery)
|
|
1329
|
-
if (shown.searchParams.has(name))
|
|
1330
|
-
shown.searchParams.set(name, REDACTED);
|
|
1331
|
-
return { url: target.toString(), display: shown.toString() };
|
|
1332
|
-
}
|
|
1333
|
-
/** Run `onRequest` with redacted headers; headers a middleware adds or
|
|
1334
|
-
* changes (other than redacted ones) are applied to the real request. */
|
|
1335
|
-
async #beforeRequest(ctx, prepared) {
|
|
1336
|
-
const real = prepared.headers.toRecord(false);
|
|
1337
|
-
const shown = { ...ctx.headers };
|
|
1338
|
-
for (const m of this.#middleware()) {
|
|
1339
|
-
try {
|
|
1340
|
-
await m.onRequest?.(ctx);
|
|
1341
|
-
}
|
|
1342
|
-
catch {
|
|
1343
|
-
// Observers never change the call.
|
|
1344
|
-
}
|
|
1345
|
-
}
|
|
1346
|
-
if (!isRecord(ctx.headers))
|
|
1347
|
-
return real;
|
|
1348
|
-
const lower = new Map(Object.keys(real).map((k) => [k.toLowerCase(), k]));
|
|
1349
|
-
for (const [name, value] of Object.entries(ctx.headers)) {
|
|
1350
|
-
if (typeof value !== "string" || value === REDACTED || shown[name] === value)
|
|
1351
|
-
continue;
|
|
1352
|
-
if (!validHeaderName(name) || !validHeaderValue(value))
|
|
1353
|
-
continue;
|
|
1354
|
-
const existing = lower.get(name.toLowerCase());
|
|
1355
|
-
if (existing !== undefined && prepared.headers.isSecret(existing))
|
|
1356
|
-
continue;
|
|
1357
|
-
if (existing !== undefined)
|
|
1358
|
-
delete real[existing];
|
|
1359
|
-
real[name] = value;
|
|
1360
|
-
}
|
|
1361
|
-
return real;
|
|
1362
|
-
}
|
|
1363
|
-
async #afterResponse(ctx, prepared, response, status, body, headers) {
|
|
1364
|
-
const middleware = this.#middleware().filter((m) => typeof m.onResponse === "function");
|
|
1365
|
-
if (middleware.length === 0)
|
|
1366
|
-
return;
|
|
1367
|
-
let copy;
|
|
1368
|
-
try {
|
|
1369
|
-
let payload = null;
|
|
1370
|
-
if (body !== null && body.byteLength > 0 && ![101, 204, 205, 304].includes(status)) {
|
|
1371
|
-
const decoded = decodeBody(body, headers, null);
|
|
1372
|
-
const fields = Array.isArray(prepared.op.agent.sensitiveResponseFields) ? prepared.op.agent.sensitiveResponseFields : [];
|
|
1373
|
-
if (decoded.json)
|
|
1374
|
-
payload = scrubText(JSON.stringify(redactPaths(decoded.value, fields)), prepared.secrets);
|
|
1375
|
-
else if (decoded.jsonl && Array.isArray(decoded.value))
|
|
1376
|
-
payload = scrubText(`${decoded.value.map((line) => JSON.stringify(redactPaths(line, fields))).join("\n")}\n`, prepared.secrets);
|
|
1377
|
-
else if (typeof decoded.value === "string")
|
|
1378
|
-
payload = scrubText(decoded.value, prepared.secrets);
|
|
1379
|
-
else
|
|
1380
|
-
payload = body.slice();
|
|
1381
|
-
}
|
|
1382
|
-
copy = new Response(payload, { status: status >= 200 && status <= 599 ? status : 200, statusText: response.statusText, headers });
|
|
1383
|
-
}
|
|
1384
|
-
catch {
|
|
1385
|
-
return;
|
|
1386
|
-
}
|
|
1387
|
-
for (const m of middleware) {
|
|
1388
|
-
try {
|
|
1389
|
-
await m.onResponse?.(ctx, copy.clone());
|
|
1390
|
-
}
|
|
1391
|
-
catch {
|
|
1392
|
-
// Observers never change the call.
|
|
1393
|
-
}
|
|
1394
|
-
}
|
|
1395
|
-
}
|
|
1396
|
-
async #classify(callCtx, prepared, outcome, ctx, timeoutMs) {
|
|
1397
|
-
const { op } = prepared;
|
|
1398
|
-
const mutation = isMutation(op);
|
|
1399
|
-
const attempts = callCtx.attempts;
|
|
1400
|
-
switch (outcome.kind) {
|
|
1401
|
-
case "not_sent":
|
|
1402
|
-
return fail(diagnostic(op.id, "TRANSPORT_FAILED", {
|
|
1403
|
-
remediation: `The request could not be delivered (${outcome.detail}), so the server did not receive it. Check baseUrl and network access, then call again.`,
|
|
1404
|
-
trace: { attempts },
|
|
1405
|
-
}));
|
|
1406
|
-
case "lost":
|
|
1407
|
-
return fail(mutation
|
|
1408
|
-
? outcomeUnknown(callCtx, `The connection failed before a response arrived (${outcome.detail}).`)
|
|
1409
|
-
: diagnostic(op.id, "TRANSPORT_FAILED", {
|
|
1410
|
-
remediation: `The connection failed before a response arrived (${outcome.detail}). This read has no side effects; call again.`,
|
|
1411
|
-
trace: { attempts },
|
|
1412
|
-
}));
|
|
1413
|
-
case "timeout":
|
|
1414
|
-
return fail(mutation
|
|
1415
|
-
? outcomeUnknown(callCtx, `No response arrived within ${timeoutMs} ms.`)
|
|
1416
|
-
: diagnostic(op.id, "UPSTREAM_UNAVAILABLE", {
|
|
1417
|
-
remediation: `No response arrived within ${timeoutMs} ms. This read has no side effects; call again later or with a larger timeoutMs.`,
|
|
1418
|
-
trace: { attempts },
|
|
1419
|
-
}));
|
|
1420
|
-
case "aborted":
|
|
1421
|
-
return fail(mutation && outcome.sent
|
|
1422
|
-
? outcomeUnknown(callCtx, "The caller's AbortSignal cancelled the call after it was sent.")
|
|
1423
|
-
: diagnostic(op.id, "TRANSPORT_FAILED", {
|
|
1424
|
-
retryable: "never",
|
|
1425
|
-
remediation: `The caller's AbortSignal cancelled the call${outcome.sent ? "" : " before it was sent"}.`,
|
|
1426
|
-
trace: { attempts },
|
|
1427
|
-
}));
|
|
1428
|
-
case "response":
|
|
1429
|
-
break;
|
|
1430
|
-
}
|
|
1431
|
-
const { status, headers } = outcome;
|
|
1432
|
-
await this.#afterResponse(ctx, prepared, outcome.response, status, outcome.body, headers);
|
|
1433
|
-
const requestId = requestIdOf(headers);
|
|
1434
|
-
const retryAfter = parseRetryAfter(headers["retry-after"], this.#now());
|
|
1435
|
-
const declared = matchResponse(op.responses, status);
|
|
1436
|
-
const success = (status >= 200 && status <= 299) || (declared?.kind === "success" && status >= 100 && status <= 399);
|
|
1437
|
-
const hint = op.agent.verify ? `Call ${op.agent.verify.operation} to read the current state.` : null;
|
|
1438
|
-
if (success && outcome.stream !== undefined) {
|
|
1439
|
-
return { ok: true, value: outcome.stream, meta: { status, headers, requestId, attempts } };
|
|
1440
|
-
}
|
|
1441
|
-
if (outcome.bodyFailure === "oversized") {
|
|
1442
|
-
return fail(diagnostic(op.id, "UNEXPECTED_RESPONSE", {
|
|
1443
|
-
code: "RESPONSE_TOO_LARGE", http_status: status, request_id: requestId,
|
|
1444
|
-
retryable: "never", next_action: hint, trace: { attempts },
|
|
1445
|
-
remediation: `Response exceeded maxResponseBytes and was abandoned. ${mutation ? "The call may have taken effect; inspect API state before retrying." : "The read is incomplete."}`,
|
|
1446
|
-
}));
|
|
1447
|
-
}
|
|
1448
|
-
if (success && outcome.body === null) {
|
|
1449
|
-
if (!mutation) {
|
|
1450
|
-
return fail(diagnostic(op.id, outcome.bodyFailure === "aborted" ? "TRANSPORT_FAILED" : "UPSTREAM_UNAVAILABLE", {
|
|
1451
|
-
http_status: status,
|
|
1452
|
-
request_id: requestId,
|
|
1453
|
-
retryable: outcome.bodyFailure === "aborted" ? "never" : "after_delay",
|
|
1454
|
-
remediation: `The response body was cut off (${outcome.bodyFailure ?? "broken"}). This read has no side effects; call again.`,
|
|
1455
|
-
trace: { attempts },
|
|
1456
|
-
}));
|
|
1457
|
-
}
|
|
1458
|
-
return fail(diagnostic(op.id, "UNEXPECTED_RESPONSE", {
|
|
1459
|
-
http_status: status,
|
|
1460
|
-
request_id: requestId,
|
|
1461
|
-
retryable: "never",
|
|
1462
|
-
remediation: `The server accepted ${op.id} (HTTP ${status}) but the response body was cut off (${outcome.bodyFailure ?? "broken"}). The call took effect; do not repeat it.`,
|
|
1463
|
-
next_action: hint,
|
|
1464
|
-
trace: { attempts },
|
|
1465
|
-
}));
|
|
1466
|
-
}
|
|
1467
|
-
const bytes = outcome.body ?? new Uint8Array(0);
|
|
1468
|
-
if (!success) {
|
|
1469
|
-
const isRedirect = status === 0 || (status >= 300 && status <= 399) || outcome.response.type === "opaqueredirect";
|
|
1470
|
-
if (isRedirect) {
|
|
1471
|
-
const to = headers.location ? ` to ${headers.location.slice(0, 200)}` : "";
|
|
1472
|
-
return fail(diagnostic(op.id, "UNEXPECTED_RESPONSE", {
|
|
1473
|
-
http_status: status || null,
|
|
1474
|
-
request_id: requestId,
|
|
1475
|
-
remediation: mutation
|
|
1476
|
-
? `The server answered with a redirect${to}. Redirects are never followed on mutations; check baseUrl (scheme, host, trailing slash). Whether the call took effect is unknown only if the server applied it before redirecting.`
|
|
1477
|
-
: `The server answered with a redirect${to} that leaves the API's origin (or too many redirects). It is not followed, so credentials never leave the API's host; check baseUrl.`,
|
|
1478
|
-
next_action: mutation ? hint : null,
|
|
1479
|
-
trace: { attempts },
|
|
1480
|
-
}));
|
|
1481
|
-
}
|
|
1482
|
-
const decoded = decodeBody(bytes, headers, declared?.mediaType ?? null);
|
|
1483
|
-
return fail(classifyError(callCtx, status, headers, decoded, retryAfter));
|
|
1484
|
-
}
|
|
1485
|
-
const decoded = decodeBody(bytes, headers, declared?.mediaType ?? null);
|
|
1486
|
-
const mode = this.options.validateResponses === "off" || this.options.validateResponses === "strict" ? this.options.validateResponses : "warn";
|
|
1487
|
-
const sensitive = Array.isArray(op.agent.sensitiveResponseFields) ? op.agent.sensitiveResponseFields : [];
|
|
1488
|
-
const afterEffect = mutation ? " The call took effect; do not repeat it." : "";
|
|
1489
|
-
let problem = null;
|
|
1490
|
-
if (decoded.invalidJson) {
|
|
1491
|
-
problem = diagnostic(op.id, "UNEXPECTED_RESPONSE", {
|
|
1492
|
-
http_status: status,
|
|
1493
|
-
request_id: requestId,
|
|
1494
|
-
failed_parameter: "response",
|
|
1495
|
-
expected: decoded.jsonl ? "a JSON Lines body" : "a JSON body",
|
|
1496
|
-
remediation: decoded.jsonl
|
|
1497
|
-
? `The success response announced JSON Lines but line ${decoded.badLine ?? 1} did not parse.${afterEffect}`
|
|
1498
|
-
: `The success response announced JSON but did not parse.${afterEffect}`,
|
|
1499
|
-
trace: { attempts },
|
|
1500
|
-
});
|
|
1501
|
-
}
|
|
1502
|
-
else if (mode !== "off" && isSchema(op.response) && !decoded.empty) {
|
|
1503
|
-
let parsed;
|
|
1504
|
-
try {
|
|
1505
|
-
parsed = op.response.safeParse(decoded.value);
|
|
1506
|
-
}
|
|
1507
|
-
catch (error) {
|
|
1508
|
-
parsed = { success: false, error: { issues: [{ path: [], message: `the response schema failed (${describeError(error)})` }] } };
|
|
1509
|
-
}
|
|
1510
|
-
if (!parsed.success) {
|
|
1511
|
-
const issues = isRecord(parsed.error) && Array.isArray(parsed.error.issues) ? parsed.error.issues : [];
|
|
1512
|
-
const issue = issues[0];
|
|
1513
|
-
const path = issue && Array.isArray(issue.path) ? issue.path.map(String) : [];
|
|
1514
|
-
const message = issue && typeof issue.message === "string" ? issue.message : "a valid response";
|
|
1515
|
-
const pathText = path.map((s) => (/^\d+$/.test(s) ? `[${s}]` : `.${s}`)).join("");
|
|
1516
|
-
const kept = mutation && mode === "strict" ? " The decoded body is in the result's partial; store any value shown only once from it before anything else." : "";
|
|
1517
|
-
problem = diagnostic(op.id, "UNEXPECTED_RESPONSE", {
|
|
1518
|
-
http_status: status,
|
|
1519
|
-
request_id: requestId,
|
|
1520
|
-
failed_parameter: `response${pathText}`,
|
|
1521
|
-
received_value: envelopeValue(getPath(redactPaths(decoded.value, sensitive), path), path.some(looksSensitive)),
|
|
1522
|
-
expected: message,
|
|
1523
|
-
remediation: `The success response does not match the API description at response${pathText} (${message}).${afterEffect}${kept}`,
|
|
1524
|
-
trace: { attempts },
|
|
1525
|
-
});
|
|
1526
|
-
}
|
|
1527
|
-
}
|
|
1528
|
-
if (problem) {
|
|
1529
|
-
const scrubbed = scrubDiagnostic(problem, prepared.secrets);
|
|
1530
|
-
// The effect of a mutation happened: its body is never dropped, since
|
|
1531
|
-
// it can hold the only copy of a value (a one-time signing secret).
|
|
1532
|
-
if (mode === "strict")
|
|
1533
|
-
return mutation && !decoded.invalidJson ? { ok: false, error: scrubbed, partial: decoded.value } : fail(scrubbed);
|
|
1534
|
-
this.#emit(scrubbed);
|
|
1535
|
-
}
|
|
1536
|
-
const meta = { status, headers, requestId, attempts };
|
|
1537
|
-
return { ok: true, value: decoded.value, meta };
|
|
1538
|
-
}
|
|
1539
|
-
// -------------------------------------------------------------- preview
|
|
1540
|
-
async #preview(op, args, opts) {
|
|
1541
|
-
const prepared = await this.#prepare(op, args, opts, "preview", null);
|
|
1542
|
-
if (!prepared.ok)
|
|
1543
|
-
return prepared;
|
|
1544
|
-
const p = prepared.value;
|
|
1545
|
-
const effects = [];
|
|
1546
|
-
const confirmation = op.agent.confirmation;
|
|
1547
|
-
if (confirmation && typeof confirmation.message === "string" && confirmation.message !== "")
|
|
1548
|
-
effects.push(interpolate(confirmation.message, args, op));
|
|
1549
|
-
if (typeof op.agent.remediationNote === "string" && op.agent.remediationNote !== "")
|
|
1550
|
-
effects.push(op.agent.remediationNote);
|
|
1551
|
-
if (confirmation && Array.isArray(confirmation.summaryFields) && confirmation.summaryFields.length > 0) {
|
|
1552
|
-
const fields = confirmation.summaryFields.filter((f) => typeof f === "string").map((f) => `${f}={${f}}`);
|
|
1553
|
-
effects.push(interpolate(`Arguments: ${fields.join(", ")}.`, args, op));
|
|
1554
|
-
}
|
|
1555
|
-
if (typeof op.summary === "string" && op.summary !== "")
|
|
1556
|
-
effects.push(op.summary);
|
|
1557
|
-
const readOnly = op.agent.safety === "read_only";
|
|
1558
|
-
const token = readOnly ? null : await issueToken(this.#confirmationKey, op.id, args, this.#now());
|
|
1559
|
-
const request = { method: p.method, url: p.displayUrl, headers: p.headers.toRecord(true), body: p.displayBody };
|
|
1560
|
-
const result = {
|
|
1561
|
-
operation: op.id,
|
|
1562
|
-
safety: op.agent.safety,
|
|
1563
|
-
request,
|
|
1564
|
-
effects,
|
|
1565
|
-
confirmation_token: token,
|
|
1566
|
-
expires_in_ms: token === null ? null : CONFIRMATION_TTL_MS,
|
|
1567
|
-
};
|
|
1568
|
-
const meta = { status: 0, headers: {}, requestId: null, attempts: 0 };
|
|
1569
|
-
const mode = op.agent.preview;
|
|
1570
|
-
if (mode.mode === "header") {
|
|
1571
|
-
const dry = await this.#prepare(op, args, opts, "server_preview", null);
|
|
1572
|
-
if (!dry.ok)
|
|
1573
|
-
return dry;
|
|
1574
|
-
const answer = await this.#send(dry.value, opts);
|
|
1575
|
-
if (!answer.ok)
|
|
1576
|
-
return answer;
|
|
1577
|
-
return { ok: true, value: { ...result, server_preview: redactPaths(answer.value, op.agent.sensitiveResponseFields ?? []) }, meta: answer.meta };
|
|
1578
|
-
}
|
|
1579
|
-
if (mode.mode === "endpoint") {
|
|
1580
|
-
const target = this.#registry.get(mode.operation);
|
|
1581
|
-
if (!target) {
|
|
1582
|
-
return fail(diagnostic(op.id, "VALIDATION_FAILED", {
|
|
1583
|
-
failed_parameter: "operation",
|
|
1584
|
-
expected: `the preview operation ${mode.operation} registered with the client`,
|
|
1585
|
-
remediation: `Register ${mode.operation} (ClientCore.register or ClientOptions.operations) so preview() can call it; nothing was sent.`,
|
|
1586
|
-
}));
|
|
1587
|
-
}
|
|
1588
|
-
const { confirm: _confirm, verify: _verify, ...rest } = opts;
|
|
1589
|
-
const answer = await this.#call(target, args, rest, null);
|
|
1590
|
-
if (!answer.ok)
|
|
1591
|
-
return answer;
|
|
1592
|
-
return { ok: true, value: { ...result, server_preview: answer.value }, meta: answer.meta };
|
|
1593
|
-
}
|
|
1594
|
-
return { ok: true, value: result, meta };
|
|
1595
|
-
}
|
|
1596
|
-
// ----------------------------------------------------- pages and polling
|
|
1597
|
-
#argName(op, key) {
|
|
1598
|
-
const params = Array.isArray(op.params) ? op.params : [];
|
|
1599
|
-
return (params.find((p) => p.name === key) ?? params.find((p) => p.wire === key))?.name ?? key;
|
|
1600
|
-
}
|
|
1601
|
-
async *#pages(op, args, opts) {
|
|
1602
|
-
const pagination = isRecord(op) && isRecord(op.pagination) ? op.pagination : null;
|
|
1603
|
-
let current = isRecord(args) ? { ...args } : args;
|
|
1604
|
-
let override = null;
|
|
1605
|
-
let previousCursor = undefined;
|
|
1606
|
-
for (;;) {
|
|
1607
|
-
const result = await this.#call(op, current, opts, override);
|
|
1608
|
-
if (!result.ok) {
|
|
1609
|
-
yield result;
|
|
1610
|
-
return;
|
|
1611
|
-
}
|
|
1612
|
-
const body = result.value;
|
|
1613
|
-
const itemsField = pagination ? pagination.itemsField : "";
|
|
1614
|
-
const found = itemsField ? getPath(body, itemsField) : body;
|
|
1615
|
-
const items = (Array.isArray(found) ? found : []);
|
|
1616
|
-
let next = null;
|
|
1617
|
-
if (pagination) {
|
|
1618
|
-
switch (pagination.style) {
|
|
1619
|
-
case "cursor": {
|
|
1620
|
-
let cursor = pagination.responseField ? getPath(body, pagination.responseField) : undefined;
|
|
1621
|
-
if ((cursor === undefined || cursor === null || cursor === "") && pagination.cursorItemField && items.length > 0) {
|
|
1622
|
-
cursor = getPath(items[items.length - 1], pagination.cursorItemField);
|
|
1623
|
-
}
|
|
1624
|
-
const more = pagination.hasMoreField ? getPath(body, pagination.hasMoreField) : undefined;
|
|
1625
|
-
next = more === false || cursor === undefined || cursor === null || cursor === "" || canonicalJson(cursor) === canonicalJson(previousCursor) ? null : cursor;
|
|
1626
|
-
if (next !== null) {
|
|
1627
|
-
previousCursor = next;
|
|
1628
|
-
current = { ...current, [this.#argName(op, pagination.requestParam)]: next };
|
|
1629
|
-
}
|
|
1630
|
-
break;
|
|
1631
|
-
}
|
|
1632
|
-
case "offset": {
|
|
1633
|
-
const offsetKey = this.#argName(op, pagination.offsetParam);
|
|
1634
|
-
const limit = current[this.#argName(op, pagination.limitParam)];
|
|
1635
|
-
const offset = bounded(current[offsetKey], 0, 0);
|
|
1636
|
-
const short = typeof limit === "number" && items.length < limit;
|
|
1637
|
-
next = items.length === 0 || short ? null : offset + items.length;
|
|
1638
|
-
if (next !== null)
|
|
1639
|
-
current = { ...current, [offsetKey]: next };
|
|
1640
|
-
break;
|
|
1641
|
-
}
|
|
1642
|
-
case "page": {
|
|
1643
|
-
const pageKey = this.#argName(op, pagination.pageParam);
|
|
1644
|
-
const size = current[this.#argName(op, pagination.sizeParam)];
|
|
1645
|
-
const page = bounded(current[pageKey], 1, 0);
|
|
1646
|
-
const short = typeof size === "number" && items.length < size;
|
|
1647
|
-
next = items.length === 0 || short ? null : page + 1;
|
|
1648
|
-
if (next !== null)
|
|
1649
|
-
current = { ...current, [pageKey]: next };
|
|
1650
|
-
break;
|
|
1651
|
-
}
|
|
1652
|
-
case "link_header": {
|
|
1653
|
-
const link = nextLink(result.meta.headers.link);
|
|
1654
|
-
if (link !== null) {
|
|
1655
|
-
const base = override ?? (typeof this.options.baseUrl === "string" ? this.options.baseUrl : (this.api.servers?.[0] ?? ""));
|
|
1656
|
-
let resolved;
|
|
1657
|
-
try {
|
|
1658
|
-
resolved = new URL(link, base);
|
|
1659
|
-
}
|
|
1660
|
-
catch {
|
|
1661
|
-
resolved = new URL("invalid:");
|
|
1662
|
-
}
|
|
1663
|
-
const origin = (() => {
|
|
1664
|
-
try {
|
|
1665
|
-
return new URL(base).origin;
|
|
1666
|
-
}
|
|
1667
|
-
catch {
|
|
1668
|
-
return null;
|
|
1669
|
-
}
|
|
1670
|
-
})();
|
|
1671
|
-
if (resolved.origin !== origin || origin === null) {
|
|
1672
|
-
yield { ok: true, value: { items, body, next: null }, meta: result.meta };
|
|
1673
|
-
yield fail(diagnostic(op.id, "UNEXPECTED_RESPONSE", {
|
|
1674
|
-
http_status: result.meta.status,
|
|
1675
|
-
request_id: result.meta.requestId,
|
|
1676
|
-
remediation: "The next-page Link header points to another origin; it is not followed so credentials never leave the API's host. Stop paginating here.",
|
|
1677
|
-
trace: { attempts: result.meta.attempts },
|
|
1678
|
-
}));
|
|
1679
|
-
return;
|
|
1680
|
-
}
|
|
1681
|
-
next = resolved.toString();
|
|
1682
|
-
override = next;
|
|
1683
|
-
}
|
|
1684
|
-
break;
|
|
1685
|
-
}
|
|
1686
|
-
}
|
|
1687
|
-
}
|
|
1688
|
-
yield { ok: true, value: { items, body, next }, meta: result.meta };
|
|
1689
|
-
if (next === null)
|
|
1690
|
-
return;
|
|
1691
|
-
}
|
|
1692
|
-
}
|
|
1693
|
-
/** The headers of a stream call: the call's headers, with any casing of
|
|
1694
|
-
* `Accept` (the call's, else the client's) merged into one `Accept` that
|
|
1695
|
-
* lists `text/event-stream` (see {@link mergeAccept}). */
|
|
1696
|
-
#streamHeaders(callHeaders) {
|
|
1697
|
-
const out = {};
|
|
1698
|
-
let accept;
|
|
1699
|
-
for (const [headers, own] of [[this.options.headers, false], [callHeaders, true]]) {
|
|
1700
|
-
if (!isRecord(headers))
|
|
1701
|
-
continue;
|
|
1702
|
-
for (const [name, value] of Object.entries(headers)) {
|
|
1703
|
-
if (typeof value !== "string")
|
|
1704
|
-
continue;
|
|
1705
|
-
if (name.toLowerCase() === "accept")
|
|
1706
|
-
accept = value;
|
|
1707
|
-
else if (own)
|
|
1708
|
-
out[name] = value;
|
|
1709
|
-
}
|
|
1710
|
-
}
|
|
1711
|
-
out.Accept = mergeAccept(accept ?? "");
|
|
1712
|
-
return out;
|
|
1713
|
-
}
|
|
1714
|
-
async *#stream(op, rawArgs, opts) {
|
|
1715
|
-
const spec = isRecord(op) && isRecord(op.stream) ? op.stream : null;
|
|
1716
|
-
if (spec === null) {
|
|
1717
|
-
yield fail(diagnostic(safeId(op), "VALIDATION_FAILED", {
|
|
1718
|
-
failed_parameter: "operation",
|
|
1719
|
-
expected: "an operation with an event stream",
|
|
1720
|
-
remediation: `${safeId(op)} has no event stream (its success response is not text/event-stream); use call() instead. Nothing was sent.`,
|
|
1721
|
-
}));
|
|
1722
|
-
return;
|
|
1723
|
-
}
|
|
1724
|
-
const streamOpts = { ...opts, headers: this.#streamHeaders(opts.headers) };
|
|
1725
|
-
const prepared = await this.#prepare(op, withStreamFlag(op, spec, rawArgs), streamOpts, "call", null);
|
|
1726
|
-
if (!prepared.ok) {
|
|
1727
|
-
yield fail(prepared.error);
|
|
1728
|
-
return;
|
|
1729
|
-
}
|
|
1730
|
-
const prep = prepared.value;
|
|
1731
|
-
const idleSetting = bounded(this.options.idleTimeoutMs, 0, 0);
|
|
1732
|
-
const idleMs = idleSetting > 0 ? idleSetting : undefined;
|
|
1733
|
-
const sent = await this.#send(prep, streamOpts, true, idleMs);
|
|
1734
|
-
if (!sent.ok) {
|
|
1735
|
-
yield fail(sent.error);
|
|
1736
|
-
return;
|
|
1737
|
-
}
|
|
1738
|
-
let { meta } = sent;
|
|
1739
|
-
const mutation = isMutation(op);
|
|
1740
|
-
const afterEffect = mutation ? " The call took effect; do not repeat it." : "";
|
|
1741
|
-
if (!(sent.value instanceof StreamBody)) {
|
|
1742
|
-
yield fail(notAStream(op, meta, afterEffect));
|
|
1743
|
-
return;
|
|
1744
|
-
}
|
|
1745
|
-
let body = sent.value;
|
|
1746
|
-
const timeoutMs = this.#timeout(streamOpts);
|
|
1747
|
-
const mode = this.options.validateResponses === "off" || this.options.validateResponses === "strict" ? this.options.validateResponses : "warn";
|
|
1748
|
-
const sensitive = Array.isArray(op.agent.sensitiveResponseFields) ? op.agent.sensitiveResponseFields : [];
|
|
1749
|
-
const maxEventBytes = Math.floor(bounded(this.options.maxEventBytes, DEFAULT_MAX_EVENT_BYTES, 1));
|
|
1750
|
-
const maxReconnects = Math.floor(bounded(this.options.maxReconnects, DEFAULT_MAX_RECONNECTS, 0, 1000));
|
|
1751
|
-
const reconnectCapMs = bounded(this.options.reconnectMaxMs, DEFAULT_RECONNECT_MAX_MS, 0);
|
|
1752
|
-
// Only a call that is safe to repeat is reconnected: a read, or a
|
|
1753
|
-
// mutation whose repeat is a replay (the reconnect reuses the request,
|
|
1754
|
-
// idempotency key included).
|
|
1755
|
-
const repeatable = maxReconnects > 0 && (!mutation || hasReplayProtection(op, prep.key));
|
|
1756
|
-
let parser = new SseParser(maxEventBytes);
|
|
1757
|
-
let decoder = new TextDecoder("utf-8");
|
|
1758
|
-
let count = 0;
|
|
1759
|
-
let reconnects = 0;
|
|
1760
|
-
let lastId = null;
|
|
1761
|
-
let lastRetry = null;
|
|
1762
|
-
// Ids delivered so far, to skip the replay of a server that ignores Last-Event-ID.
|
|
1763
|
-
const delivered = new Set();
|
|
1764
|
-
let replaying = false;
|
|
1765
|
-
const interrupted = (kind) => {
|
|
1766
|
-
const callCtx = {
|
|
1767
|
-
api: this.api,
|
|
1768
|
-
op,
|
|
1769
|
-
key: prep.key,
|
|
1770
|
-
keyHeader: prep.keyHeader,
|
|
1771
|
-
attempts: meta.attempts,
|
|
1772
|
-
check: mutation && !hasReplayProtection(op, prep.key) ? this.#outcomeCheck(op, prep.args) : null,
|
|
1773
|
-
};
|
|
1774
|
-
const after = `after ${count} ${count === 1 ? "event" : "events"}`;
|
|
1775
|
-
const trace = { attempts: meta.attempts };
|
|
1776
|
-
const common = { http_status: meta.status, request_id: meta.requestId, trace };
|
|
1777
|
-
const retried = reconnects > 0 ? ` (${reconnects} ${reconnects === 1 ? "reconnect" : "reconnects"} made)` : "";
|
|
1778
|
-
let error;
|
|
1779
|
-
if (kind === "timeout" || kind === "idle") {
|
|
1780
|
-
const code = kind === "idle" ? "STREAM_IDLE" : null;
|
|
1781
|
-
const silence = kind === "idle" ? `No bytes arrived within ${idleMs} ms ${after}${retried}` : `No event arrived within ${timeoutMs} ms ${after}`;
|
|
1782
|
-
error = mutation
|
|
1783
|
-
? outcomeUnknown(callCtx, `${silence}; the stream was abandoned.`, { ...common, code })
|
|
1784
|
-
: diagnostic(op.id, "UPSTREAM_UNAVAILABLE", {
|
|
1785
|
-
...common,
|
|
1786
|
-
code,
|
|
1787
|
-
remediation: `${silence}; the stream was abandoned. This read has no side effects; call again later or with a larger ${kind === "idle" ? "idleTimeoutMs" : "timeoutMs"}.`,
|
|
1788
|
-
});
|
|
1789
|
-
}
|
|
1790
|
-
else if (kind === "aborted") {
|
|
1791
|
-
error =
|
|
1792
|
-
mutation
|
|
1793
|
-
? outcomeUnknown(callCtx, `The caller's AbortSignal cancelled the stream ${after}.`, common)
|
|
1794
|
-
: diagnostic(op.id, "TRANSPORT_FAILED", { ...common, retryable: "never", remediation: `The caller's AbortSignal cancelled the stream ${after}.` });
|
|
1795
|
-
}
|
|
1796
|
-
else {
|
|
1797
|
-
error = mutation
|
|
1798
|
-
? outcomeUnknown(callCtx, `The event stream was cut off ${after}${retried}: the connection failed before it ended.`, common)
|
|
1799
|
-
: diagnostic(op.id, "TRANSPORT_FAILED", {
|
|
1800
|
-
...common,
|
|
1801
|
-
remediation: `The event stream was cut off ${after}${retried}: the connection failed before it ended. This read has no side effects; call again.`,
|
|
1802
|
-
});
|
|
1803
|
-
}
|
|
1804
|
-
return fail(scrubDiagnostic(error, prep.secrets));
|
|
1805
|
-
};
|
|
1806
|
-
const accept = (event) => {
|
|
1807
|
-
if (spec.done !== undefined && event.data === spec.done)
|
|
1808
|
-
return "done";
|
|
1809
|
-
const eventId = event.id ?? lastId;
|
|
1810
|
-
if (replaying) {
|
|
1811
|
-
if (event.id !== null && delivered.has(event.id))
|
|
1812
|
-
return "skip";
|
|
1813
|
-
replaying = false;
|
|
1814
|
-
}
|
|
1815
|
-
const index = count;
|
|
1816
|
-
const failure = (fields) => fail(scrubDiagnostic(diagnostic(op.id, "UNEXPECTED_RESPONSE", { http_status: meta.status, request_id: meta.requestId, retryable: "never", trace: { attempts: meta.attempts }, ...fields }), prep.secrets));
|
|
1817
|
-
let value;
|
|
1818
|
-
try {
|
|
1819
|
-
value = JSON.parse(event.data);
|
|
1820
|
-
}
|
|
1821
|
-
catch {
|
|
1822
|
-
return failure({
|
|
1823
|
-
failed_parameter: `events[${index}]`,
|
|
1824
|
-
received_value: envelopeValue(event.data, false),
|
|
1825
|
-
expected: "JSON in the data of every event",
|
|
1826
|
-
remediation: `Event ${index} of the stream (event "${event.event}") does not carry JSON in its data.${afterEffect} Events before it were delivered.`,
|
|
1827
|
-
});
|
|
1828
|
-
}
|
|
1829
|
-
if (mode !== "off" && isSchema(spec.event)) {
|
|
1830
|
-
let parsed;
|
|
1831
|
-
try {
|
|
1832
|
-
parsed = spec.event.safeParse(value);
|
|
1833
|
-
}
|
|
1834
|
-
catch (error) {
|
|
1835
|
-
parsed = { success: false, error: { issues: [{ path: [], message: `the event schema failed (${describeError(error)})` }] } };
|
|
1836
|
-
}
|
|
1837
|
-
if (!parsed.success) {
|
|
1838
|
-
const issues = isRecord(parsed.error) && Array.isArray(parsed.error.issues) ? parsed.error.issues : [];
|
|
1839
|
-
const issue = issues[0];
|
|
1840
|
-
const path = issue && Array.isArray(issue.path) ? issue.path.map(String) : [];
|
|
1841
|
-
const message = issue && typeof issue.message === "string" ? issue.message : "a valid event";
|
|
1842
|
-
const pathText = path.map((s) => (/^\d+$/.test(s) ? `[${s}]` : `.${s}`)).join("");
|
|
1843
|
-
const problem = failure({
|
|
1844
|
-
failed_parameter: `events[${index}]${pathText}`,
|
|
1845
|
-
received_value: envelopeValue(getPath(redactPaths(value, sensitive), path), path.some(looksSensitive)),
|
|
1846
|
-
expected: message,
|
|
1847
|
-
remediation: `Event ${index} of the stream (event "${event.event}") does not match the API description at events[${index}]${pathText} (${message}).${afterEffect}`,
|
|
1848
|
-
});
|
|
1849
|
-
if (mode === "strict")
|
|
1850
|
-
return problem;
|
|
1851
|
-
if (!problem.ok)
|
|
1852
|
-
this.#emit(problem.error);
|
|
1853
|
-
}
|
|
1854
|
-
}
|
|
1855
|
-
count += 1;
|
|
1856
|
-
if (event.id !== null)
|
|
1857
|
-
delivered.add(event.id);
|
|
1858
|
-
lastId = eventId;
|
|
1859
|
-
lastRetry = event.retry ?? lastRetry;
|
|
1860
|
-
return { ok: true, value: value, event: event.event, id: eventId, retry: lastRetry, meta: { ...meta, reconnects } };
|
|
1861
|
-
};
|
|
1862
|
-
const oversize = () => fail(scrubDiagnostic(diagnostic(op.id, "UNEXPECTED_RESPONSE", {
|
|
1863
|
-
http_status: meta.status,
|
|
1864
|
-
request_id: meta.requestId,
|
|
1865
|
-
retryable: "never",
|
|
1866
|
-
trace: { attempts: meta.attempts },
|
|
1867
|
-
failed_parameter: `events[${count}]`,
|
|
1868
|
-
expected: `an event of at most ${maxEventBytes} bytes`,
|
|
1869
|
-
remediation: `Event ${count} of the stream is larger than the limit of ${maxEventBytes} bytes (ClientOptions.maxEventBytes); the stream was abandoned.${afterEffect} Events before it were delivered. Raise maxEventBytes if the server sends events this large on purpose.`,
|
|
1870
|
-
}), prep.secrets));
|
|
1871
|
-
try {
|
|
1872
|
-
for (;;) {
|
|
1873
|
-
// Read one connection; `drop` says why it ended without finishing the stream.
|
|
1874
|
-
let drop = null;
|
|
1875
|
-
for (; drop === null;) {
|
|
1876
|
-
const read = await body.read();
|
|
1877
|
-
if (read.kind === "aborted") {
|
|
1878
|
-
yield interrupted("aborted");
|
|
1879
|
-
return;
|
|
1880
|
-
}
|
|
1881
|
-
if (read.kind === "timeout" && idleMs === undefined) {
|
|
1882
|
-
yield interrupted("timeout");
|
|
1883
|
-
return;
|
|
1884
|
-
}
|
|
1885
|
-
const events = [];
|
|
1886
|
-
if (read.kind === "chunk")
|
|
1887
|
-
events.push(...parser.push(decoder.decode(read.bytes, { stream: true })));
|
|
1888
|
-
else if (read.kind === "end")
|
|
1889
|
-
events.push(...parser.push(decoder.decode()), ...parser.end());
|
|
1890
|
-
for (const event of events) {
|
|
1891
|
-
const item = accept(event);
|
|
1892
|
-
if (item === "done")
|
|
1893
|
-
return;
|
|
1894
|
-
if (item === "skip")
|
|
1895
|
-
continue;
|
|
1896
|
-
yield item;
|
|
1897
|
-
if (!item.ok)
|
|
1898
|
-
return;
|
|
1899
|
-
}
|
|
1900
|
-
if (parser.exceeded) {
|
|
1901
|
-
yield oversize();
|
|
1902
|
-
return;
|
|
1903
|
-
}
|
|
1904
|
-
if (read.kind === "end") {
|
|
1905
|
-
// A stream that names its terminal event has not finished without it.
|
|
1906
|
-
if (spec.done === undefined)
|
|
1907
|
-
return;
|
|
1908
|
-
drop = "early_end";
|
|
1909
|
-
}
|
|
1910
|
-
else if (read.kind === "lost")
|
|
1911
|
-
drop = "lost";
|
|
1912
|
-
else if (read.kind === "timeout")
|
|
1913
|
-
drop = "idle";
|
|
1914
|
-
}
|
|
1915
|
-
const resumeId = lastId;
|
|
1916
|
-
const resumable = repeatable && reconnects < maxReconnects && (count === 0 || (resumeId !== null && validHeaderValue(resumeId)));
|
|
1917
|
-
if (!resumable) {
|
|
1918
|
-
// Without reconnects a clean end stays a clean end; the failures
|
|
1919
|
-
// are reported.
|
|
1920
|
-
if (drop === "early_end" && reconnects === 0)
|
|
1921
|
-
return;
|
|
1922
|
-
yield interrupted(drop === "idle" ? "idle" : "lost");
|
|
1923
|
-
return;
|
|
1924
|
-
}
|
|
1925
|
-
body.close();
|
|
1926
|
-
reconnects += 1;
|
|
1927
|
-
const server = lastRetry ?? DEFAULT_RECONNECT_MS;
|
|
1928
|
-
const ceiling = Math.min(reconnectCapMs, server);
|
|
1929
|
-
const random = typeof this.options.random === "function" ? bounded(this.options.random(), 0.5, 0, 1) : Math.random();
|
|
1930
|
-
if (!(await sleep(ceiling - random * 0.25 * ceiling, opts.signal))) {
|
|
1931
|
-
yield interrupted("aborted");
|
|
1932
|
-
return;
|
|
1933
|
-
}
|
|
1934
|
-
if (resumeId !== null)
|
|
1935
|
-
prep.headers.set("Last-Event-ID", resumeId);
|
|
1936
|
-
const again = await this.#send(prep, streamOpts, true, idleMs);
|
|
1937
|
-
if (!again.ok) {
|
|
1938
|
-
yield fail(again.error);
|
|
1939
|
-
return;
|
|
1940
|
-
}
|
|
1941
|
-
meta = again.meta;
|
|
1942
|
-
if (!(again.value instanceof StreamBody)) {
|
|
1943
|
-
yield fail(notAStream(op, meta, afterEffect));
|
|
1944
|
-
return;
|
|
1945
|
-
}
|
|
1946
|
-
body = again.value;
|
|
1947
|
-
parser = new SseParser(maxEventBytes);
|
|
1948
|
-
decoder = new TextDecoder("utf-8");
|
|
1949
|
-
replaying = true;
|
|
1950
|
-
}
|
|
1951
|
-
}
|
|
1952
|
-
finally {
|
|
1953
|
-
body.close();
|
|
1954
|
-
}
|
|
1955
|
-
}
|
|
1956
|
-
async #poll(op, args, until, intervalMs, budgetMs, opts) {
|
|
1957
|
-
const interval = bounded(intervalMs, DEFAULT_POLL_INTERVAL_MS, 0);
|
|
1958
|
-
const budget = bounded(budgetMs, 0, 0);
|
|
1959
|
-
const started = Date.now();
|
|
1960
|
-
for (;;) {
|
|
1961
|
-
const result = await this.#call(op, args, { ...opts, verify: false }, null);
|
|
1962
|
-
if (!result.ok)
|
|
1963
|
-
return result;
|
|
1964
|
-
if (evaluatePredicate(until, result.value))
|
|
1965
|
-
return { ...result, timedOut: false };
|
|
1966
|
-
if (Date.now() - started + interval > budget)
|
|
1967
|
-
return { ...result, timedOut: true };
|
|
1968
|
-
if (!(await sleep(interval, opts.signal)))
|
|
1969
|
-
return { ...result, timedOut: true };
|
|
1970
|
-
}
|
|
1971
|
-
}
|
|
1972
|
-
async #verify(op, args, value, opts) {
|
|
1973
|
-
const hook = op.agent.verify;
|
|
1974
|
-
const unchecked = { checked: false, passed: false, observed: null };
|
|
1975
|
-
if (!hook || typeof hook.operation !== "string")
|
|
1976
|
-
return unchecked;
|
|
1977
|
-
const target = this.#registry.get(hook.operation);
|
|
1978
|
-
if (!target)
|
|
1979
|
-
return unchecked;
|
|
1980
|
-
// The hook is written against the API: argument keys and `$args`
|
|
1981
|
-
// references use wire names, and predicates may reference the call.
|
|
1982
|
-
const scope = { response: value, args: withWireNames(op, args) };
|
|
1983
|
-
const evaluated = evaluateExpr(hook.args ?? {}, scope);
|
|
1984
|
-
if (!isRecord(evaluated))
|
|
1985
|
-
return unchecked;
|
|
1986
|
-
const mapped = {};
|
|
1987
|
-
for (const [key, arg] of Object.entries(evaluated))
|
|
1988
|
-
mapped[this.#argName(target, key)] = arg;
|
|
1989
|
-
// A reference that does not resolve never matches (it is not dropped,
|
|
1990
|
-
// which would make the predicate hold vacuously).
|
|
1991
|
-
const refs = (node, depth = 0) => {
|
|
1992
|
-
if (depth > 64)
|
|
1993
|
-
return UNRESOLVED;
|
|
1994
|
-
if (typeof node === "string" && node.startsWith("$"))
|
|
1995
|
-
return resolveRef(node, scope) ?? UNRESOLVED;
|
|
1996
|
-
if (Array.isArray(node))
|
|
1997
|
-
return node.map((item) => refs(item, depth + 1));
|
|
1998
|
-
if (isRecord(node))
|
|
1999
|
-
return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, refs(v, depth + 1)]));
|
|
2000
|
-
return node;
|
|
2001
|
-
};
|
|
2002
|
-
const resolve = (predicate) => isRecord(predicate) && Object.keys(predicate).length > 0 ? refs(predicate) : null;
|
|
2003
|
-
const terminal = resolve(hook.terminal);
|
|
2004
|
-
const expect = resolve(hook.expect) ?? {};
|
|
2005
|
-
const interval = bounded(hook.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS, 0);
|
|
2006
|
-
const budget = bounded(hook.pollBudgetMs, terminal ? DEFAULT_VERIFY_BUDGET_MS : 0, 0);
|
|
2007
|
-
const { confirm: _confirm, idempotencyKey: _key, ...rest } = opts;
|
|
2008
|
-
const result = await this.#poll(target, mapped, terminal ?? expect, interval, budget, { ...rest, verify: false });
|
|
2009
|
-
if (!result.ok)
|
|
2010
|
-
return { ...unchecked, error: result.error };
|
|
2011
|
-
const observed = redactPaths(result.value, Array.isArray(target.agent?.sensitiveResponseFields) ? target.agent.sensitiveResponseFields : []);
|
|
2012
|
-
const verification = { checked: true, passed: evaluatePredicate(expect, result.value), observed };
|
|
2013
|
-
if (budget > 0)
|
|
2014
|
-
verification.timedOut = result.timedOut === true;
|
|
2015
|
-
return verification;
|
|
2016
|
-
}
|
|
2017
|
-
// --------------------------------------------------------------- macros
|
|
2018
|
-
/** The macro's input with the `add` defaults applied, or a failure. */
|
|
2019
|
-
#macroInput(macro, input, name) {
|
|
2020
|
-
if (!isRecord(input)) {
|
|
2021
|
-
return fail(diagnostic(name, "VALIDATION_FAILED", { failed_parameter: "input", expected: "an object", remediation: `Pass the input of ${name} as one object.` }));
|
|
2022
|
-
}
|
|
2023
|
-
const effective = { ...input };
|
|
2024
|
-
const add = isRecord(macro.input) && isRecord(macro.input.add) ? macro.input.add : {};
|
|
2025
|
-
for (const [key, schema] of Object.entries(add)) {
|
|
2026
|
-
if (effective[key] === undefined && isRecord(schema) && schema.default !== undefined)
|
|
2027
|
-
effective[key] = schema.default;
|
|
2028
|
-
}
|
|
2029
|
-
return { ok: true, value: effective };
|
|
2030
|
-
}
|
|
2031
|
-
/** The macro's steps resolved against the registry, and its effective
|
|
2032
|
-
* tier: the strictest of the declared one and every step's. */
|
|
2033
|
-
#macroPlan(macro, name) {
|
|
2034
|
-
const invalid = (remediation) => fail(diagnostic(name, "VALIDATION_FAILED", { failed_parameter: "macro", expected: "a valid macro descriptor", remediation }));
|
|
2035
|
-
if (!isRecord(macro) || !Array.isArray(macro.steps))
|
|
2036
|
-
return invalid("The macro descriptor has no steps; regenerate the SDK.");
|
|
2037
|
-
const steps = [];
|
|
2038
|
-
let safety = typeof macro.safety === "string" && SAFETIES.has(macro.safety) ? macro.safety : "irreversible";
|
|
2039
|
-
for (const [index, step] of macro.steps.filter(isRecord).entries()) {
|
|
2040
|
-
const op = typeof step.operation === "string" ? this.#registry.get(step.operation) : undefined;
|
|
2041
|
-
if (!op)
|
|
2042
|
-
return invalid(`Step ${index + 1} of ${name} names ${String(step.operation)}, which is not registered with the client.`);
|
|
2043
|
-
const tier = op.agent?.safety;
|
|
2044
|
-
if (typeof tier === "string" && (SAFETY_RANK[tier] ?? 3) > (SAFETY_RANK[safety] ?? 3))
|
|
2045
|
-
safety = tier;
|
|
2046
|
-
steps.push({ step, op });
|
|
2047
|
-
}
|
|
2048
|
-
return { ok: true, value: { steps, safety } };
|
|
2049
|
-
}
|
|
2050
|
-
/** Step arguments evaluated against `scope` (a dry evaluation when
|
|
2051
|
-
* `pending` names results not produced yet); for the step whose args the
|
|
2052
|
-
* input extends, the fields the macro adds are removed. */
|
|
2053
|
-
#stepArgs(macro, step, op, scope, pending) {
|
|
2054
|
-
let args = pending ? evaluateDry(step.args ?? {}, scope, pending) : evaluateExpr(step.args ?? {}, scope);
|
|
2055
|
-
if (args === undefined || args === null)
|
|
2056
|
-
args = {};
|
|
2057
|
-
const extendsOp = isRecord(macro.input) && typeof macro.input.extends === "string" ? macro.input.extends : null;
|
|
2058
|
-
const added = isRecord(macro.input) && isRecord(macro.input.add) ? Object.keys(macro.input.add) : [];
|
|
2059
|
-
if (isRecord(args) && op.id === extendsOp && added.length > 0) {
|
|
2060
|
-
const trimmed = { ...args };
|
|
2061
|
-
for (const key of added)
|
|
2062
|
-
delete trimmed[key];
|
|
2063
|
-
args = trimmed;
|
|
2064
|
-
}
|
|
2065
|
-
return args;
|
|
2066
|
-
}
|
|
2067
|
-
async #previewMacro(macro, input, opts) {
|
|
2068
|
-
const name = safeName(macro, "name", "<unknown macro>");
|
|
2069
|
-
const plan = this.#macroPlan(macro, name);
|
|
2070
|
-
if (!plan.ok)
|
|
2071
|
-
return plan;
|
|
2072
|
-
const effective = this.#macroInput(macro, input, name);
|
|
2073
|
-
if (!effective.ok)
|
|
2074
|
-
return effective;
|
|
2075
|
-
const { steps, safety } = plan.value;
|
|
2076
|
-
if (steps.length === 0) {
|
|
2077
|
-
return fail(diagnostic(name, "VALIDATION_FAILED", { failed_parameter: "macro", expected: "a valid macro descriptor", remediation: `${name} has no steps; regenerate the SDK.` }));
|
|
2078
|
-
}
|
|
2079
|
-
const { confirm: _confirm, verify: _verify, ...rest } = opts;
|
|
2080
|
-
// A dry evaluation: the input is known, earlier steps' results are
|
|
2081
|
-
// placeholders (`<from step NAME: path>`).
|
|
2082
|
-
const scope = { input: effective.value };
|
|
2083
|
-
const pending = new Set();
|
|
2084
|
-
const previews = [];
|
|
2085
|
-
const effects = [];
|
|
2086
|
-
if (typeof macro.summary === "string" && macro.summary !== "")
|
|
2087
|
-
effects.push(macro.summary);
|
|
2088
|
-
let keyUsed = false;
|
|
2089
|
-
for (const [index, { step, op }] of steps.entries()) {
|
|
2090
|
-
const kind = step.kind === "poll" || step.kind === "paginate" ? step.kind : "call";
|
|
2091
|
-
const where = `step ${index + 1} of ${name}: ${op.id}`;
|
|
2092
|
-
const args = this.#stepArgs(macro, step, op, scope, pending);
|
|
2093
|
-
const stepOpts = { ...rest, verify: false };
|
|
2094
|
-
const usesKey = op.agent?.idempotency?.policy === "caller_owned" || op.agent?.idempotency?.policy === "auto";
|
|
2095
|
-
if (!usesKey || keyUsed)
|
|
2096
|
-
delete stepOpts.idempotencyKey;
|
|
2097
|
-
else if (opts.idempotencyKey !== undefined)
|
|
2098
|
-
keyUsed = true;
|
|
2099
|
-
let request = null;
|
|
2100
|
-
if (isRecord(args)) {
|
|
2101
|
-
const encoding = op.body?.encoding;
|
|
2102
|
-
// Bytes and multipart bodies cannot be encoded from a value not known yet.
|
|
2103
|
-
const unrenderable = (encoding === "bytes" || encoding === "multipart") && containsPlaceholder(args);
|
|
2104
|
-
if (!unrenderable) {
|
|
2105
|
-
const prepared = await this.#prepare(op, args, stepOpts, "macro_preview", null);
|
|
2106
|
-
if (!prepared.ok)
|
|
2107
|
-
return fail({ ...prepared.error, operation: name, remediation: `${prepared.error.remediation} (${where})` });
|
|
2108
|
-
const p = prepared.value;
|
|
2109
|
-
request = { method: p.method, url: unescapePlaceholders(p.displayUrl, args), headers: p.headers.toRecord(true), body: p.displayBody };
|
|
2110
|
-
}
|
|
2111
|
-
}
|
|
2112
|
-
else if (!containsPlaceholder(args)) {
|
|
2113
|
-
return fail(diagnostic(name, "VALIDATION_FAILED", { failed_parameter: "input", expected: "an object", remediation: `Step ${index + 1} of ${name} does not evaluate to an argument object.` }));
|
|
2114
|
-
}
|
|
2115
|
-
const own = [];
|
|
2116
|
-
const confirmation = op.agent.confirmation;
|
|
2117
|
-
if (confirmation && typeof confirmation.message === "string" && confirmation.message !== "") {
|
|
2118
|
-
own.push(interpolate(confirmation.message, isRecord(args) ? args : {}, op));
|
|
2119
|
-
}
|
|
2120
|
-
if (kind === "poll") {
|
|
2121
|
-
const until = describePredicate(step.until);
|
|
2122
|
-
const budget = bounded(evaluateExpr(step.budget_ms, scope), DEFAULT_MACRO_BUDGET_MS, 0);
|
|
2123
|
-
const interval = bounded(step.interval_ms, DEFAULT_POLL_INTERVAL_MS, 0);
|
|
2124
|
-
own.push(`Repeats ${op.id} every ${interval} ms${until ? ` until ${until}` : ""}, for at most ${budget} ms.`);
|
|
2125
|
-
}
|
|
2126
|
-
else if (kind === "paginate") {
|
|
2127
|
-
own.push(`Reads up to ${Math.floor(bounded(step.max_pages, MACRO_PAGE_LIMIT, 1, 10_000))} pages of ${op.id}.`);
|
|
2128
|
-
}
|
|
2129
|
-
if (typeof op.agent.remediationNote === "string" && op.agent.remediationNote !== "")
|
|
2130
|
-
own.push(op.agent.remediationNote);
|
|
2131
|
-
const as = typeof step.as === "string" && step.as !== "" ? step.as : null;
|
|
2132
|
-
previews.push({ step: index + 1, kind, operation: op.id, as, safety: op.agent.safety, request, effects: own });
|
|
2133
|
-
effects.push(`Step ${index + 1}: ${kind} ${op.id} (${op.agent.safety}).`, ...own);
|
|
2134
|
-
if (as !== null)
|
|
2135
|
-
pending.add(as);
|
|
2136
|
-
}
|
|
2137
|
-
if (macro.shownOnce === true)
|
|
2138
|
-
effects.push("The result contains values the API shows only once; store them immediately.");
|
|
2139
|
-
// Step 1 has no earlier results, so it is always rendered.
|
|
2140
|
-
const first = previews[0]?.request;
|
|
2141
|
-
if (!first)
|
|
2142
|
-
return fail(diagnostic(name, "VALIDATION_FAILED", { failed_parameter: "input", expected: "an object", remediation: `Step 1 of ${name} cannot be rendered.` }));
|
|
2143
|
-
const token = safety === "read_only" ? null : await issueToken(this.#confirmationKey, `macro:${name}`, effective.value, this.#now());
|
|
2144
|
-
const value = {
|
|
2145
|
-
operation: name,
|
|
2146
|
-
safety: safety,
|
|
2147
|
-
request: first,
|
|
2148
|
-
effects,
|
|
2149
|
-
confirmation_token: token,
|
|
2150
|
-
expires_in_ms: token === null ? null : CONFIRMATION_TTL_MS,
|
|
2151
|
-
steps: previews,
|
|
2152
|
-
};
|
|
2153
|
-
return { ok: true, value, meta: { status: 0, headers: {}, requestId: null, attempts: 0 } };
|
|
2154
|
-
}
|
|
2155
|
-
/** Whether running this step again (by rerunning its macro with the
|
|
2156
|
-
* same input and options) answers the first result instead of applying
|
|
2157
|
-
* the effect twice: an identity or content-hash body, a caller's key, or
|
|
2158
|
-
* an automatic key (the store maps identical arguments to one key). */
|
|
2159
|
-
#rerunProtected(op, opts) {
|
|
2160
|
-
const policy = op.agent?.idempotency?.policy;
|
|
2161
|
-
if (policy === "content_identity" || policy === "content_hash" || policy === "auto")
|
|
2162
|
-
return true;
|
|
2163
|
-
return typeof opts.idempotencyKey === "string" && (policy === "caller_owned" || op.params.some((p) => p.role === "idempotency_key"));
|
|
2164
|
-
}
|
|
2165
|
-
/** The replay protection a macro's confirmation token is bound to: the
|
|
2166
|
-
* run's idempotency key when a step sends it and every mutating step is
|
|
2167
|
-
* protected against a rerun; otherwise none, so the token is used once
|
|
2168
|
-
* (a key no step sends protects nothing). */
|
|
2169
|
-
#macroBind(ops, opts) {
|
|
2170
|
-
if (typeof opts.idempotencyKey !== "string")
|
|
2171
|
-
return null;
|
|
2172
|
-
let sent = false;
|
|
2173
|
-
for (const op of ops) {
|
|
2174
|
-
const usesKey = op.agent?.idempotency?.policy === "caller_owned" || op.agent?.idempotency?.policy === "auto";
|
|
2175
|
-
const stepOpts = { ...opts };
|
|
2176
|
-
if (!usesKey || sent)
|
|
2177
|
-
delete stepOpts.idempotencyKey;
|
|
2178
|
-
else
|
|
2179
|
-
sent = true;
|
|
2180
|
-
if (isMutation(op) && !this.#rerunProtected(op, stepOpts))
|
|
2181
|
-
return null;
|
|
2182
|
-
}
|
|
2183
|
-
return sent ? opts.idempotencyKey : null;
|
|
2184
|
-
}
|
|
2185
|
-
async #runMacro(macro, input, opts) {
|
|
2186
|
-
const name = safeName(macro, "name", "<unknown macro>");
|
|
2187
|
-
const plan = this.#macroPlan(macro, name);
|
|
2188
|
-
if (!plan.ok)
|
|
2189
|
-
return plan;
|
|
2190
|
-
const effective = this.#macroInput(macro, input, name);
|
|
2191
|
-
if (!effective.ok)
|
|
2192
|
-
return effective;
|
|
2193
|
-
const { steps, safety } = plan.value;
|
|
2194
|
-
const unconfirmed = await this.#confirmed(name, `macro:${name}`, safety, effective.value, opts.confirm, `the macro's preview(...)`, opts.allowConfirmTrue !== false);
|
|
2195
|
-
if (unconfirmed)
|
|
2196
|
-
return unconfirmed;
|
|
2197
|
-
// The token is spent by the first step that sends (as an operation's
|
|
2198
|
-
// token is): a step failing pre-flight, before anything was sent,
|
|
2199
|
-
// leaves it valid for the corrected run.
|
|
2200
|
-
const token = typeof opts.confirm === "string" ? opts.confirm : null;
|
|
2201
|
-
const bind = this.#macroBind(steps.map(({ op }) => op), opts);
|
|
2202
|
-
let claimed = false;
|
|
2203
|
-
const claim = () => {
|
|
2204
|
-
if (claimed || token === null)
|
|
2205
|
-
return null;
|
|
2206
|
-
claimed = true;
|
|
2207
|
-
return this.#claimToken(name, token, bind, "the macro's preview(...)");
|
|
2208
|
-
};
|
|
2209
|
-
const scope = { input: effective.value };
|
|
2210
|
-
const completed = [];
|
|
2211
|
-
/** Results of completed steps by `as` name: returned as `partial` when a later step fails. */
|
|
2212
|
-
const produced = {};
|
|
2213
|
-
/** Completed mutating steps that a rerun of the macro would apply again. */
|
|
2214
|
-
const unprotected = [];
|
|
2215
|
-
let keyUsed = false;
|
|
2216
|
-
let meta = { status: 0, headers: {}, requestId: null, attempts: 0 };
|
|
2217
|
-
for (const [index, { step, op }] of steps.entries()) {
|
|
2218
|
-
const args = this.#stepArgs(macro, step, op, scope);
|
|
2219
|
-
if (!isRecord(args)) {
|
|
2220
|
-
return fail(diagnostic(name, "VALIDATION_FAILED", {
|
|
2221
|
-
failed_parameter: "input",
|
|
2222
|
-
expected: "an object",
|
|
2223
|
-
remediation: `Step ${index + 1} of ${name} does not evaluate to an argument object.`,
|
|
2224
|
-
}));
|
|
2225
|
-
}
|
|
2226
|
-
const usesKey = op.agent?.idempotency?.policy === "caller_owned" || op.agent?.idempotency?.policy === "auto";
|
|
2227
|
-
const { confirm: _confirm, ...rest } = opts;
|
|
2228
|
-
const stepOpts = { ...rest, verify: false, [MACRO_CONFIRMED]: true, [MACRO_CLAIM]: claim };
|
|
2229
|
-
if (!usesKey || keyUsed)
|
|
2230
|
-
delete stepOpts.idempotencyKey;
|
|
2231
|
-
else if (opts.idempotencyKey !== undefined)
|
|
2232
|
-
keyUsed = true;
|
|
2233
|
-
let value;
|
|
2234
|
-
let failure = null;
|
|
2235
|
-
let failedPartial;
|
|
2236
|
-
if (step.kind === "poll") {
|
|
2237
|
-
const budget = evaluateExpr(step.budget_ms, scope);
|
|
2238
|
-
const result = await this.#poll(op, args, isRecord(step.until) ? step.until : {}, bounded(step.interval_ms, DEFAULT_POLL_INTERVAL_MS, 0), bounded(budget, DEFAULT_MACRO_BUDGET_MS, 0), stepOpts);
|
|
2239
|
-
if (result.ok) {
|
|
2240
|
-
value = result.timedOut === true ? null : result.value;
|
|
2241
|
-
meta = result.meta;
|
|
2242
|
-
}
|
|
2243
|
-
else
|
|
2244
|
-
failure = result.error;
|
|
2245
|
-
}
|
|
2246
|
-
else if (step.kind === "paginate") {
|
|
2247
|
-
const items = [];
|
|
2248
|
-
const limit = Math.floor(bounded(step.max_pages, MACRO_PAGE_LIMIT, 1, 10_000));
|
|
2249
|
-
let pages = 0;
|
|
2250
|
-
for await (const page of this.#pages(op, args, stepOpts)) {
|
|
2251
|
-
if (!page.ok) {
|
|
2252
|
-
failure = page.error;
|
|
2253
|
-
break;
|
|
2254
|
-
}
|
|
2255
|
-
items.push(...page.value.items);
|
|
2256
|
-
meta = page.meta;
|
|
2257
|
-
pages += 1;
|
|
2258
|
-
if (pages >= limit)
|
|
2259
|
-
break;
|
|
2260
|
-
}
|
|
2261
|
-
value = items;
|
|
2262
|
-
}
|
|
2263
|
-
else {
|
|
2264
|
-
const result = await this.#call(op, args, stepOpts, null);
|
|
2265
|
-
if (result.ok) {
|
|
2266
|
-
value = result.value;
|
|
2267
|
-
meta = result.meta;
|
|
2268
|
-
}
|
|
2269
|
-
else {
|
|
2270
|
-
failure = result.error;
|
|
2271
|
-
failedPartial = result.partial;
|
|
2272
|
-
}
|
|
2273
|
-
}
|
|
2274
|
-
if (failure) {
|
|
2275
|
-
if (failedPartial !== undefined && typeof step.as === "string" && step.as !== "")
|
|
2276
|
-
produced[step.as] = failedPartial;
|
|
2277
|
-
const done = completed.length > 0 ? `completed before it: ${completed.join(", ")}` : "no step completed before it";
|
|
2278
|
-
let remediation = `${failure.remediation} Macro ${name} stopped at step ${index + 1} of ${steps.length} (${op.id}); ${done}.`;
|
|
2279
|
-
let retryable = failure.retryable;
|
|
2280
|
-
let nextAction = failure.next_action;
|
|
2281
|
-
const kept = Object.keys(produced);
|
|
2282
|
-
if (kept.length > 0) {
|
|
2283
|
-
remediation += ` The result's partial holds the completed steps' results (${kept.join(", ")})${macro.shownOnce === true ? ", including values the API shows only once: store them now" : ""}.`;
|
|
2284
|
-
}
|
|
2285
|
-
if (unprotected.length > 0) {
|
|
2286
|
-
// Rerunning the macro would apply these steps again (a second
|
|
2287
|
-
// endpoint, a lost one-time secret): finish from here instead.
|
|
2288
|
-
remediation += ` Do not run ${name} again: ${unprotected.join(", ")} already took effect and has no replay protection.`;
|
|
2289
|
-
if (retryable === "after_delay" || retryable === "same_key_only")
|
|
2290
|
-
retryable = "after_remediation";
|
|
2291
|
-
const finish = `Finish ${name} without rerunning it: call ${op.id} yourself with the values from the result's partial.`;
|
|
2292
|
-
nextAction = nextAction === null ? finish : `${nextAction} ${finish}`;
|
|
2293
|
-
}
|
|
2294
|
-
// The envelope names what was called (the macro); the remediation
|
|
2295
|
-
// names the step that failed.
|
|
2296
|
-
const error = { ...failure, operation: name, remediation, retryable, next_action: nextAction };
|
|
2297
|
-
return kept.length > 0 ? { ok: false, error, partial: { ...produced } } : fail(error);
|
|
2298
|
-
}
|
|
2299
|
-
completed.push(op.id);
|
|
2300
|
-
if (typeof step.as === "string" && step.as !== "") {
|
|
2301
|
-
scope[step.as] = value;
|
|
2302
|
-
produced[step.as] = value;
|
|
2303
|
-
}
|
|
2304
|
-
if (isMutation(op) && !this.#rerunProtected(op, stepOpts))
|
|
2305
|
-
unprotected.push(op.id);
|
|
2306
|
-
}
|
|
2307
|
-
return { ok: true, value: evaluateExpr(macro.output, scope), meta };
|
|
2308
|
-
}
|
|
2309
|
-
}
|