@foir/sdk 0.11.0 → 0.13.0
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/CHANGELOG.md +165 -2
- package/dist/app/client.d.ts +445 -0
- package/dist/app/contract/index.d.ts +23 -0
- package/dist/app/contract/proto/apps/v1/apps_pb.d.ts +1465 -0
- package/dist/app/contract/proto/apps/v1/discovery_pb.d.ts +678 -0
- package/dist/app/dispatch.d.ts +64 -0
- package/dist/app/errors.d.ts +50 -0
- package/dist/app/index.d.ts +40 -0
- package/dist/app/manifest.d.ts +56 -0
- package/dist/app/naming.d.ts +31 -0
- package/dist/app/record-key.d.ts +17 -0
- package/dist/app/ticket.d.ts +37 -0
- package/dist/app/vocabulary.generated.d.ts +20 -0
- package/dist/app-contract.js +124 -0
- package/dist/app.js +1106 -0
- package/dist/auth/auth.d.ts +24 -6
- package/dist/auth/index.d.ts +2 -1
- package/dist/auth.js +139 -23
- package/dist/client.js +36 -2
- package/dist/data/index.d.ts +2 -2
- package/dist/data/transport.d.ts +34 -2
- package/dist/data.js +77 -2
- package/dist/graphql/client.d.ts +1 -1
- package/dist/graphql/foir-request.d.ts +1 -1
- package/dist/next.js +70 -2
- package/dist/placement/index.d.ts +129 -0
- package/dist/placement.js +144 -0
- package/dist/push.js +121 -13
- package/dist/react.js +70 -2
- package/dist/server/callback-client.d.ts +30 -9
- package/dist/server/continue-run.d.ts +56 -0
- package/dist/server/index.d.ts +5 -3
- package/dist/server/operation-types.d.ts +31 -8
- package/dist/server/require-scoped-token.d.ts +52 -7
- package/dist/server/verify-scoped-token.d.ts +109 -3
- package/dist/server.js +415 -127
- package/dist/transport/foir-http.d.ts +48 -0
- package/package.json +24 -6
package/dist/server.js
CHANGED
|
@@ -117,15 +117,17 @@ async function loadJwks(jwksUrl) {
|
|
|
117
117
|
return keys;
|
|
118
118
|
}
|
|
119
119
|
function parseSubject(sub) {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
const first = sub.indexOf("|");
|
|
121
|
+
const second = first === -1 ? -1 : sub.indexOf("|", first + 1);
|
|
122
|
+
if (first === -1 || second === -1) {
|
|
123
|
+
throw new Error("malformed sub: expected <tenant>|<project>|<app>");
|
|
123
124
|
}
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
const tenantId = sub.slice(0, first);
|
|
126
|
+
const projectId = sub.slice(first + 1, second);
|
|
127
|
+
if (!tenantId || !projectId) {
|
|
128
|
+
throw new Error("malformed sub: empty tenant or project");
|
|
127
129
|
}
|
|
128
|
-
return { tenantId, projectId, appName };
|
|
130
|
+
return { tenantId, projectId, appName: sub.slice(second + 1) };
|
|
129
131
|
}
|
|
130
132
|
function recordsReadModels(claims) {
|
|
131
133
|
return claims.rrm ?? [];
|
|
@@ -134,16 +136,25 @@ function recordsWriteModels(claims) {
|
|
|
134
136
|
return claims.rwm ?? [];
|
|
135
137
|
}
|
|
136
138
|
async function verifyScopedToken(token, jwksUrl, options) {
|
|
139
|
+
return verifyWithKeys(token, () => loadJwks(jwksUrl), void 0, options);
|
|
140
|
+
}
|
|
141
|
+
async function verifyWithKeys(token, loadKeys, refetchForUnknownKid, options) {
|
|
137
142
|
const expectedIssuer = options?.expectedIssuer ?? "foir-platform";
|
|
138
143
|
const parts = token.split(".");
|
|
139
144
|
if (parts.length !== 3) throw new Error("malformed token");
|
|
140
145
|
const [rawHeader, rawPayload, rawSig] = parts;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
146
|
+
let header;
|
|
147
|
+
try {
|
|
148
|
+
header = JSON.parse(new TextDecoder().decode(base64UrlDecode(rawHeader)));
|
|
149
|
+
} catch {
|
|
150
|
+
throw new Error("unreadable token header");
|
|
151
|
+
}
|
|
152
|
+
if (header.alg !== "EdDSA") throw new Error(`unexpected alg ${String(header.alg)}`);
|
|
153
|
+
if (typeof header.kid !== "string" || !header.kid) throw new Error("missing kid");
|
|
154
|
+
let keys = await loadKeys();
|
|
155
|
+
if (!keys.has(header.kid) && refetchForUnknownKid) {
|
|
156
|
+
keys = await refetchForUnknownKid() ?? keys;
|
|
157
|
+
}
|
|
147
158
|
const key = keys.get(header.kid);
|
|
148
159
|
if (!key) throw new Error(`unknown kid ${header.kid}`);
|
|
149
160
|
const signingInput = new TextEncoder().encode(`${rawHeader}.${rawPayload}`);
|
|
@@ -158,10 +169,13 @@ async function verifyScopedToken(token, jwksUrl, options) {
|
|
|
158
169
|
)
|
|
159
170
|
);
|
|
160
171
|
if (!ok) throw new Error("signature invalid");
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
172
|
+
let claims;
|
|
173
|
+
try {
|
|
174
|
+
claims = JSON.parse(new TextDecoder().decode(base64UrlDecode(rawPayload)));
|
|
175
|
+
} catch {
|
|
176
|
+
throw new Error("unreadable token payload");
|
|
177
|
+
}
|
|
178
|
+
const now = Math.floor((options?.now?.() ?? Date.now()) / 1e3);
|
|
165
179
|
if (claims.iss !== expectedIssuer) throw new Error(`bad iss ${claims.iss}`);
|
|
166
180
|
if (typeof claims.exp !== "number" || claims.exp < now) {
|
|
167
181
|
throw new Error("token expired");
|
|
@@ -183,35 +197,174 @@ function parseContextHeader(raw) {
|
|
|
183
197
|
}
|
|
184
198
|
return out;
|
|
185
199
|
}
|
|
200
|
+
var ScopedTokenUnavailableError = class extends Error {
|
|
201
|
+
constructor(message, options) {
|
|
202
|
+
super(message);
|
|
203
|
+
this.name = "ScopedTokenUnavailableError";
|
|
204
|
+
if (options?.cause !== void 0) this.cause = options.cause;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
var VERIFIER_TTL_MS = 60 * 60 * 1e3;
|
|
208
|
+
var VERIFIER_TIMEOUT_MS = 5e3;
|
|
209
|
+
var KID_MISS_COOLDOWN_MS = 6e4;
|
|
210
|
+
var FAILURE_COOLDOWN_MS = 6e4;
|
|
211
|
+
function createScopedTokenVerifier(options) {
|
|
212
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
213
|
+
const now = options.now ?? Date.now;
|
|
214
|
+
const timeoutMs = options.timeoutMs ?? VERIFIER_TIMEOUT_MS;
|
|
215
|
+
const ttlMs = options.ttlMs ?? VERIFIER_TTL_MS;
|
|
216
|
+
let cache;
|
|
217
|
+
let inFlight;
|
|
218
|
+
let lastKidMissRefetch = 0;
|
|
219
|
+
let nextAttemptAt = 0;
|
|
220
|
+
let staleServes = 0;
|
|
221
|
+
let refetchFailures = 0;
|
|
222
|
+
let notModified = 0;
|
|
223
|
+
async function fetchKeys(held) {
|
|
224
|
+
try {
|
|
225
|
+
const headers = { accept: "application/json" };
|
|
226
|
+
if (held?.etag) headers["if-none-match"] = held.etag;
|
|
227
|
+
const res = await doFetch(options.jwksUrl, {
|
|
228
|
+
...options.requestInit,
|
|
229
|
+
headers,
|
|
230
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
231
|
+
});
|
|
232
|
+
if (res.status === 304 && held) {
|
|
233
|
+
notModified += 1;
|
|
234
|
+
nextAttemptAt = 0;
|
|
235
|
+
cache = { ...held, fetchedAt: now() };
|
|
236
|
+
return cache;
|
|
237
|
+
}
|
|
238
|
+
if (!res.ok) throw new Error(`jwks fetch ${res.status}`);
|
|
239
|
+
const doc = await res.json();
|
|
240
|
+
const keys2 = /* @__PURE__ */ new Map();
|
|
241
|
+
for (const jwk of doc.keys ?? []) {
|
|
242
|
+
if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.kid || !jwk.x) continue;
|
|
243
|
+
const raw = base64UrlDecode(jwk.x);
|
|
244
|
+
keys2.set(
|
|
245
|
+
jwk.kid,
|
|
246
|
+
await crypto.subtle.importKey(
|
|
247
|
+
"raw",
|
|
248
|
+
raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength),
|
|
249
|
+
{ name: "Ed25519" },
|
|
250
|
+
false,
|
|
251
|
+
["verify"]
|
|
252
|
+
)
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (keys2.size === 0) throw new Error("jwks carried no Ed25519 keys");
|
|
256
|
+
nextAttemptAt = 0;
|
|
257
|
+
cache = { keys: keys2, fetchedAt: now(), etag: res.headers.get("etag") ?? void 0 };
|
|
258
|
+
return cache;
|
|
259
|
+
} catch (error) {
|
|
260
|
+
refetchFailures += 1;
|
|
261
|
+
nextAttemptAt = now() + FAILURE_COOLDOWN_MS;
|
|
262
|
+
if (held) {
|
|
263
|
+
staleServes += 1;
|
|
264
|
+
try {
|
|
265
|
+
options.onStale?.({ error, ageMs: now() - held.fetchedAt, staleServes });
|
|
266
|
+
} catch {
|
|
267
|
+
}
|
|
268
|
+
return held;
|
|
269
|
+
}
|
|
270
|
+
throw new ScopedTokenUnavailableError(
|
|
271
|
+
`the JWKS could not be read and no keys are held: ${error instanceof Error ? error.message : String(error)}`,
|
|
272
|
+
{ cause: error }
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function keys(force) {
|
|
277
|
+
const held = cache;
|
|
278
|
+
const at = now();
|
|
279
|
+
if (!force && held && at - held.fetchedAt < ttlMs) return Promise.resolve(held);
|
|
280
|
+
if (at < nextAttemptAt && !inFlight) {
|
|
281
|
+
if (held) {
|
|
282
|
+
staleServes += 1;
|
|
283
|
+
return Promise.resolve(held);
|
|
284
|
+
}
|
|
285
|
+
return Promise.reject(
|
|
286
|
+
new ScopedTokenUnavailableError(
|
|
287
|
+
`the JWKS could not be read and no keys are held; the next attempt is in ${Math.ceil((nextAttemptAt - at) / 1e3)}s`
|
|
288
|
+
)
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
inFlight ??= fetchKeys(held).finally(() => {
|
|
292
|
+
inFlight = void 0;
|
|
293
|
+
});
|
|
294
|
+
return inFlight;
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
verify(token) {
|
|
298
|
+
return verifyWithKeys(
|
|
299
|
+
token,
|
|
300
|
+
async () => (await keys(false)).keys,
|
|
301
|
+
async () => {
|
|
302
|
+
const at = now();
|
|
303
|
+
if (at - lastKidMissRefetch < KID_MISS_COOLDOWN_MS) return null;
|
|
304
|
+
lastKidMissRefetch = at;
|
|
305
|
+
return (await keys(true)).keys;
|
|
306
|
+
},
|
|
307
|
+
{ expectedIssuer: options.expectedIssuer, now }
|
|
308
|
+
);
|
|
309
|
+
},
|
|
310
|
+
async warm() {
|
|
311
|
+
try {
|
|
312
|
+
await keys(false);
|
|
313
|
+
} catch {
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
stats() {
|
|
317
|
+
return {
|
|
318
|
+
keyCount: cache?.keys.size ?? 0,
|
|
319
|
+
ageMs: cache ? now() - cache.fetchedAt : null,
|
|
320
|
+
staleServes,
|
|
321
|
+
refetchFailures,
|
|
322
|
+
notModified
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
}
|
|
186
327
|
|
|
187
328
|
// src/server/require-scoped-token.ts
|
|
188
|
-
var
|
|
189
|
-
var
|
|
329
|
+
var FOIR_TOKEN_HEADER = "X-Foir-Token";
|
|
330
|
+
var FOIR_CONTEXT_HEADER = "X-Foir-Context";
|
|
331
|
+
var TOKEN_HEADER = FOIR_TOKEN_HEADER;
|
|
332
|
+
var CONTEXT_HEADER = FOIR_CONTEXT_HEADER;
|
|
190
333
|
var DEFAULT_JWKS_URL = "https://api.foir.dev/.well-known/jwks.json";
|
|
191
334
|
var ScopedTokenError = class extends Error {
|
|
192
335
|
status;
|
|
193
|
-
|
|
336
|
+
/**
|
|
337
|
+
* Why, for a route that answers the cases differently: `missing` (the
|
|
338
|
+
* request carried no token at all, so it did not come from the platform),
|
|
339
|
+
* `invalid` (a token that does not verify), `forbidden` (a valid token for
|
|
340
|
+
* another tenant or project) and `unavailable` (the token could not be
|
|
341
|
+
* checked).
|
|
342
|
+
*/
|
|
343
|
+
reason;
|
|
344
|
+
constructor(message, status, reason) {
|
|
194
345
|
super(message);
|
|
195
346
|
this.name = "ScopedTokenError";
|
|
196
347
|
this.status = status;
|
|
348
|
+
this.reason = reason ?? (status === 403 ? "forbidden" : status === 503 ? "unavailable" : "invalid");
|
|
197
349
|
}
|
|
198
350
|
};
|
|
351
|
+
var LEVEL_CLAIM = "org_id";
|
|
199
352
|
async function requireScopedToken(request, options) {
|
|
200
353
|
const token = request.headers.get(TOKEN_HEADER);
|
|
201
354
|
if (!token) {
|
|
202
355
|
throw new ScopedTokenError(
|
|
203
356
|
`missing ${TOKEN_HEADER} header \u2014 this route only accepts platform-dispatched requests`,
|
|
204
|
-
401
|
|
357
|
+
401,
|
|
358
|
+
"missing"
|
|
205
359
|
);
|
|
206
360
|
}
|
|
207
361
|
let claims;
|
|
208
362
|
try {
|
|
209
|
-
claims = await verifyScopedToken(
|
|
210
|
-
token,
|
|
211
|
-
options?.jwksUrl ?? DEFAULT_JWKS_URL,
|
|
212
|
-
options
|
|
213
|
-
);
|
|
363
|
+
claims = options?.verifier ? await options.verifier.verify(token) : await verifyScopedToken(token, options?.jwksUrl ?? DEFAULT_JWKS_URL, options);
|
|
214
364
|
} catch (err) {
|
|
365
|
+
if (err instanceof ScopedTokenUnavailableError) {
|
|
366
|
+
throw new ScopedTokenError(`the scoped token could not be checked: ${err.message}`, 503);
|
|
367
|
+
}
|
|
215
368
|
throw new ScopedTokenError(
|
|
216
369
|
`invalid scoped token: ${err instanceof Error ? err.message : String(err)}`,
|
|
217
370
|
401
|
|
@@ -239,13 +392,18 @@ async function requireScopedToken(request, options) {
|
|
|
239
392
|
);
|
|
240
393
|
}
|
|
241
394
|
const rawContext = request.headers.get(CONTEXT_HEADER);
|
|
395
|
+
const level = claims.ctx?.[LEVEL_CLAIM];
|
|
242
396
|
return {
|
|
243
397
|
claims,
|
|
244
398
|
subject,
|
|
245
399
|
context: parseContextHeader(rawContext),
|
|
246
400
|
// The token handed over is the one verified above, not a fresh read of the
|
|
247
401
|
// header, so the client presents exactly what this guard accepted.
|
|
248
|
-
dispatch: { token: () => token, context: rawContext }
|
|
402
|
+
dispatch: { token: () => token, context: rawContext },
|
|
403
|
+
orgId: typeof level === "string" ? level : null,
|
|
404
|
+
...typeof claims.cid === "string" && claims.cid !== "" ? { customerId: claims.cid } : {},
|
|
405
|
+
expiresAt: new Date(claims.exp * 1e3),
|
|
406
|
+
tokenId: claims.jti
|
|
249
407
|
};
|
|
250
408
|
}
|
|
251
409
|
|
|
@@ -313,7 +471,7 @@ async function readDispatch(request) {
|
|
|
313
471
|
refuse(`body.input is ${describe(input)}, expected an object`);
|
|
314
472
|
}
|
|
315
473
|
const payload = { ...body, input: input ?? {} };
|
|
316
|
-
for (const block of ["content", "record", "triggerContext", "callback"]) {
|
|
474
|
+
for (const block of ["content", "record", "triggerContext", "callback", "continuation"]) {
|
|
317
475
|
const value = payload[block];
|
|
318
476
|
if (value === void 0) continue;
|
|
319
477
|
if (value === null) {
|
|
@@ -322,107 +480,37 @@ async function readDispatch(request) {
|
|
|
322
480
|
}
|
|
323
481
|
if (!isObject(value)) refuse(`body.${block} is ${describe(value)}, expected an object`);
|
|
324
482
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
function createCallbackClient(callback, opts) {
|
|
330
|
-
const { executionId, signingSecret } = opts;
|
|
331
|
-
const fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
332
|
-
if (!fetchImpl) {
|
|
333
|
-
throw new Error(
|
|
334
|
-
"createCallbackClient: no fetch available; pass opts.fetch on runtimes without a global fetch"
|
|
335
|
-
);
|
|
336
|
-
}
|
|
337
|
-
if (!executionId) throw new Error("createCallbackClient: executionId is required");
|
|
338
|
-
if (!signingSecret) throw new Error("createCallbackClient: signingSecret is required");
|
|
339
|
-
if (!callback?.token || !callback?.gqlEndpoint || !callback?.mutations) {
|
|
340
|
-
throw new Error("createCallbackClient: callback block is missing required fields");
|
|
341
|
-
}
|
|
342
|
-
const post = async (mutationName, fields, variables) => {
|
|
343
|
-
const query = `mutation ${mutationName}Mutation($id: String!${fieldArgsForMutation(mutationName, variables)}) { ${mutationName}(id: $id${fieldArgsCallSite(mutationName, variables)}) { ${fields} } }`;
|
|
344
|
-
const body = JSON.stringify({
|
|
345
|
-
query,
|
|
346
|
-
variables: { id: executionId, ...variables }
|
|
347
|
-
});
|
|
348
|
-
const signature = await hmacSha256Hex(signingSecret, body);
|
|
349
|
-
const resp = await fetchImpl(callback.gqlEndpoint, {
|
|
350
|
-
method: "POST",
|
|
351
|
-
headers: {
|
|
352
|
-
"Content-Type": "application/json",
|
|
353
|
-
Authorization: `Bearer ${callback.token}`,
|
|
354
|
-
"X-Foir-Signature": `sha256=${signature}`
|
|
355
|
-
},
|
|
356
|
-
body
|
|
357
|
-
});
|
|
358
|
-
if (!resp.ok) {
|
|
359
|
-
throw new Error(`callback ${mutationName} failed with HTTP ${resp.status}: ${await resp.text()}`);
|
|
360
|
-
}
|
|
361
|
-
const json = await resp.json();
|
|
362
|
-
if (json.errors?.length) {
|
|
363
|
-
throw new Error(`callback ${mutationName} GraphQL error: ${json.errors[0].message}`);
|
|
483
|
+
if (isObject(payload.continuation)) {
|
|
484
|
+
requireString(payload.continuation, "cursor", "body.continuation");
|
|
485
|
+
if (typeof payload.continuation.slice !== "number") {
|
|
486
|
+
refuse(`body.continuation.slice is ${describe(payload.continuation.slice)}, expected a number`);
|
|
364
487
|
}
|
|
365
|
-
const out = json.data?.[mutationName];
|
|
366
|
-
if (!out) {
|
|
367
|
-
throw new Error(`callback ${mutationName} returned no data`);
|
|
368
|
-
}
|
|
369
|
-
return out;
|
|
370
|
-
};
|
|
371
|
-
return {
|
|
372
|
-
complete: (result) => post(callback.mutations.complete, "status cancelled", { result }),
|
|
373
|
-
fail: (code, message, options = {}) => post(callback.mutations.fail, "status cancelled", {
|
|
374
|
-
code,
|
|
375
|
-
message,
|
|
376
|
-
retryable: options.retryable,
|
|
377
|
-
details: options.details
|
|
378
|
-
}),
|
|
379
|
-
progress: (update) => post(callback.mutations.progress, "status cancelled applied", {
|
|
380
|
-
pct: update.pct,
|
|
381
|
-
message: update.message,
|
|
382
|
-
metadata: update.metadata
|
|
383
|
-
}),
|
|
384
|
-
cancel: () => post(callback.mutations.cancel, "status cancelled", {})
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
function fieldArgsForMutation(mutationName, vars) {
|
|
388
|
-
const parts = [];
|
|
389
|
-
for (const key of Object.keys(vars)) {
|
|
390
|
-
if (vars[key] === void 0) continue;
|
|
391
|
-
parts.push(`, $${key}: ${graphQLTypeFor(mutationName, key)}`);
|
|
392
488
|
}
|
|
393
|
-
return
|
|
394
|
-
}
|
|
395
|
-
function fieldArgsCallSite(_mutationName, vars) {
|
|
396
|
-
const parts = [];
|
|
397
|
-
for (const key of Object.keys(vars)) {
|
|
398
|
-
if (vars[key] === void 0) continue;
|
|
399
|
-
parts.push(`, ${key}: $${key}`);
|
|
400
|
-
}
|
|
401
|
-
return parts.join("");
|
|
402
|
-
}
|
|
403
|
-
function graphQLTypeFor(mutationName, key) {
|
|
404
|
-
if (key === "code" || key === "message") return "String!";
|
|
405
|
-
if (key === "pct") return "Int";
|
|
406
|
-
if (key === "retryable") return "Boolean";
|
|
407
|
-
if (key === "details" || key === "metadata") return "JSON";
|
|
408
|
-
if (key === "result") {
|
|
409
|
-
const base = mutationName.replace(/^complete/, "").replace(/Execution$/, "");
|
|
410
|
-
return `${base}ExecutionResultInput!`;
|
|
411
|
-
}
|
|
412
|
-
if (key === "message") return "String";
|
|
413
|
-
return "String";
|
|
489
|
+
return payload;
|
|
414
490
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
491
|
+
|
|
492
|
+
// src/server/continue-run.ts
|
|
493
|
+
var CONTINUATION_MEDIA_TYPE = "application/vnd.foir.continuation+json";
|
|
494
|
+
var MAX_CONTINUATION_CURSOR_BYTES = 16 * 1024;
|
|
495
|
+
var MAX_CONTINUATION_DELAY_MS = 15 * 60 * 1e3;
|
|
496
|
+
function continueRun(continuation) {
|
|
497
|
+
const { cursor, delayMs, progressPct } = continuation;
|
|
498
|
+
if (typeof cursor !== "string" || cursor === "") {
|
|
499
|
+
throw new TypeError("continueRun: a continuation carries the cursor the next slice resumes from");
|
|
500
|
+
}
|
|
501
|
+
if (new TextEncoder().encode(cursor).length > MAX_CONTINUATION_CURSOR_BYTES) {
|
|
502
|
+
throw new RangeError(`continueRun: a cursor is at most ${MAX_CONTINUATION_CURSOR_BYTES} bytes`);
|
|
503
|
+
}
|
|
504
|
+
if (delayMs !== void 0 && !(Number.isInteger(delayMs) && delayMs >= 0 && delayMs <= MAX_CONTINUATION_DELAY_MS)) {
|
|
505
|
+
throw new RangeError(`continueRun: delayMs is a whole number of milliseconds from 0 to ${MAX_CONTINUATION_DELAY_MS}`);
|
|
506
|
+
}
|
|
507
|
+
if (progressPct !== void 0 && !(Number.isInteger(progressPct) && progressPct >= 0 && progressPct <= 100)) {
|
|
508
|
+
throw new RangeError("continueRun: progressPct is a whole percentage from 0 to 100");
|
|
509
|
+
}
|
|
510
|
+
return new Response(JSON.stringify(continuation), {
|
|
511
|
+
status: 200,
|
|
512
|
+
headers: { "Content-Type": CONTINUATION_MEDIA_TYPE }
|
|
513
|
+
});
|
|
426
514
|
}
|
|
427
515
|
|
|
428
516
|
// src/transport/foir-http.ts
|
|
@@ -478,7 +566,12 @@ var FoirRequestError = class extends Error {
|
|
|
478
566
|
* failed on the first attempt for an hour.
|
|
479
567
|
*/
|
|
480
568
|
get retryable() {
|
|
481
|
-
if (this.status === void 0)
|
|
569
|
+
if (this.status === void 0) {
|
|
570
|
+
if (this.graphQLErrors?.length || this.prunedSelections?.length) {
|
|
571
|
+
return this.code === "RATE_LIMITED" || this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
|
|
572
|
+
}
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
482
575
|
if (this.status === 429 || this.code === "RATE_LIMITED") return true;
|
|
483
576
|
if (this.status >= 500) return true;
|
|
484
577
|
return this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
|
|
@@ -524,6 +617,7 @@ function refuseCredentialHeaders(extra) {
|
|
|
524
617
|
}
|
|
525
618
|
}
|
|
526
619
|
}
|
|
620
|
+
var FOIR_VISITOR_IP_HEADER = "X-Foir-Visitor-IP";
|
|
527
621
|
function buildFoirHeaders(input) {
|
|
528
622
|
const headers = {
|
|
529
623
|
"Content-Type": "application/json"
|
|
@@ -548,6 +642,9 @@ function buildFoirHeaders(input) {
|
|
|
548
642
|
if (input.orgUrl) {
|
|
549
643
|
headers["X-Org-Url"] = input.orgUrl;
|
|
550
644
|
}
|
|
645
|
+
if (input.visitorAddress) {
|
|
646
|
+
headers[FOIR_VISITOR_IP_HEADER] = input.visitorAddress;
|
|
647
|
+
}
|
|
551
648
|
}
|
|
552
649
|
if (input.extra) {
|
|
553
650
|
for (const [key, value] of Object.entries(input.extra)) {
|
|
@@ -563,8 +660,188 @@ function foirApiEndpoint(apiUrl, path) {
|
|
|
563
660
|
}
|
|
564
661
|
return base + path;
|
|
565
662
|
}
|
|
663
|
+
var FoirAuthDowngradedError = class extends FoirRequestError {
|
|
664
|
+
downgrade;
|
|
665
|
+
constructor(downgrade) {
|
|
666
|
+
super(
|
|
667
|
+
`[foir] your customer token was ${downgrade.customerToken} (${downgrade.reason}), so the request was served as ${downgrade.servedAs}`,
|
|
668
|
+
{ graphQLErrors: [{ message: "customer token rejected", extensions: { code: "AUTH_DOWNGRADED" } }] }
|
|
669
|
+
);
|
|
670
|
+
this.name = "FoirAuthDowngradedError";
|
|
671
|
+
this.downgrade = downgrade;
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
var FoirSessionExpiredError = class extends FoirRequestError {
|
|
675
|
+
constructor(options) {
|
|
676
|
+
super("[foir] the customer session has expired and could not be refreshed: sign in again", {
|
|
677
|
+
cause: options?.cause,
|
|
678
|
+
graphQLErrors: [{ message: "session expired", extensions: { code: "SESSION_EXPIRED" } }]
|
|
679
|
+
});
|
|
680
|
+
this.name = "FoirSessionExpiredError";
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
// src/transport/retry.ts
|
|
685
|
+
var DEFAULT_ATTEMPTS = 3;
|
|
686
|
+
var DEFAULT_BASE_DELAY_MS = 200;
|
|
687
|
+
var DEFAULT_MAX_DELAY_MS = 2e4;
|
|
688
|
+
var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
689
|
+
function foirRetryDelayMs(error, attempt, policy = {}) {
|
|
690
|
+
if (!(error instanceof FoirRequestError) || !error.retryable) return null;
|
|
691
|
+
const max = policy.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
692
|
+
const stated = error.retryAfterMs;
|
|
693
|
+
if (stated !== void 0) return Math.min(stated, max);
|
|
694
|
+
const base = policy.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
|
|
695
|
+
return Math.min(base * 2 ** (attempt - 1), max);
|
|
696
|
+
}
|
|
697
|
+
async function withFoirRetry(fn, policy = {}) {
|
|
698
|
+
const attempts = Math.max(1, policy.attempts ?? DEFAULT_ATTEMPTS);
|
|
699
|
+
const sleep = policy.sleep ?? defaultSleep;
|
|
700
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
701
|
+
try {
|
|
702
|
+
return await fn();
|
|
703
|
+
} catch (error) {
|
|
704
|
+
const delayMs = attempt >= attempts ? null : foirRetryDelayMs(error, attempt, policy);
|
|
705
|
+
if (delayMs === null) throw error;
|
|
706
|
+
policy.onRetry?.({ attempt, delayMs, error });
|
|
707
|
+
await sleep(delayMs);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// src/server/callback-client.ts
|
|
713
|
+
function createCallbackClient(callback, opts) {
|
|
714
|
+
const { executionId } = opts;
|
|
715
|
+
const fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
716
|
+
if (!fetchImpl) {
|
|
717
|
+
throw new Error(
|
|
718
|
+
"createCallbackClient: no fetch available; pass opts.fetch on runtimes without a global fetch"
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
if (!executionId) throw new Error("createCallbackClient: executionId is required");
|
|
722
|
+
if (!callback?.token || !callback?.mutations) {
|
|
723
|
+
throw new Error("createCallbackClient: callback block is missing required fields");
|
|
724
|
+
}
|
|
725
|
+
if (!callback.gqlEndpoint && !opts.apiUrl) {
|
|
726
|
+
throw new Error(
|
|
727
|
+
"createCallbackClient: nowhere to report to. The dispatch named no callback.gqlEndpoint and no apiUrl was passed."
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
const endpoint = callback.gqlEndpoint || foirApiEndpoint(opts.apiUrl ?? "", "/graphql");
|
|
731
|
+
const signingSecret = opts.signingSecret || callback.signingKey;
|
|
732
|
+
if (!signingSecret) {
|
|
733
|
+
throw new Error(
|
|
734
|
+
"createCallbackClient: nothing to sign with. The dispatch carried no callback.signingKey and no signingSecret was passed."
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
const post = async (mutationName, fields, variables) => {
|
|
738
|
+
const query = `mutation ${mutationName}Mutation($id: String!${fieldArgsForMutation(mutationName, variables)}) { ${mutationName}(id: $id${fieldArgsCallSite(mutationName, variables)}) { ${fields} } }`;
|
|
739
|
+
const body = JSON.stringify({
|
|
740
|
+
query,
|
|
741
|
+
variables: { id: executionId, ...variables }
|
|
742
|
+
});
|
|
743
|
+
const signature = await hmacSha256Hex(signingSecret, body);
|
|
744
|
+
return withFoirRetry(async () => {
|
|
745
|
+
let resp;
|
|
746
|
+
try {
|
|
747
|
+
resp = await fetchImpl(endpoint, {
|
|
748
|
+
method: "POST",
|
|
749
|
+
headers: {
|
|
750
|
+
"Content-Type": "application/json",
|
|
751
|
+
Authorization: `Bearer ${callback.token}`,
|
|
752
|
+
"X-Foir-Signature": `sha256=${signature}`
|
|
753
|
+
},
|
|
754
|
+
body,
|
|
755
|
+
...opts.signal ? { signal: opts.signal } : {}
|
|
756
|
+
});
|
|
757
|
+
} catch (err) {
|
|
758
|
+
if (opts.signal?.aborted) throw err;
|
|
759
|
+
throw new FoirRequestError(`callback ${mutationName} got no answer`, { cause: err });
|
|
760
|
+
}
|
|
761
|
+
if (!resp.ok) {
|
|
762
|
+
throw new FoirRequestError(
|
|
763
|
+
`callback ${mutationName} failed with HTTP ${resp.status}: ${await resp.text()}`,
|
|
764
|
+
{ status: resp.status }
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
const json = await resp.json();
|
|
768
|
+
if (json.errors?.length) {
|
|
769
|
+
throw new FoirRequestError(`callback ${mutationName} GraphQL error: ${json.errors[0].message}`, {
|
|
770
|
+
status: resp.status,
|
|
771
|
+
graphQLErrors: json.errors
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
const out = json.data?.[mutationName];
|
|
775
|
+
if (!out) {
|
|
776
|
+
throw new FoirRequestError(`callback ${mutationName} returned no data`, { status: resp.status });
|
|
777
|
+
}
|
|
778
|
+
return out;
|
|
779
|
+
}, opts.retry);
|
|
780
|
+
};
|
|
781
|
+
return {
|
|
782
|
+
complete: (result) => post(callback.mutations.complete, "status cancelled", { result }),
|
|
783
|
+
fail: (code, message, options = {}) => post(callback.mutations.fail, "status cancelled", {
|
|
784
|
+
code,
|
|
785
|
+
message,
|
|
786
|
+
retryable: options.retryable,
|
|
787
|
+
details: options.details
|
|
788
|
+
}),
|
|
789
|
+
progress: (update) => post(callback.mutations.progress, "status cancelled applied", {
|
|
790
|
+
pct: update.pct,
|
|
791
|
+
message: update.message,
|
|
792
|
+
metadata: update.metadata
|
|
793
|
+
}),
|
|
794
|
+
cancel: () => post(callback.mutations.cancel, "status cancelled", {})
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
function fieldArgsForMutation(mutationName, vars) {
|
|
798
|
+
const parts = [];
|
|
799
|
+
for (const key of Object.keys(vars)) {
|
|
800
|
+
if (vars[key] === void 0) continue;
|
|
801
|
+
parts.push(`, $${key}: ${graphQLTypeFor(mutationName, key)}`);
|
|
802
|
+
}
|
|
803
|
+
return parts.join("");
|
|
804
|
+
}
|
|
805
|
+
function fieldArgsCallSite(_mutationName, vars) {
|
|
806
|
+
const parts = [];
|
|
807
|
+
for (const key of Object.keys(vars)) {
|
|
808
|
+
if (vars[key] === void 0) continue;
|
|
809
|
+
parts.push(`, ${key}: $${key}`);
|
|
810
|
+
}
|
|
811
|
+
return parts.join("");
|
|
812
|
+
}
|
|
813
|
+
function graphQLTypeFor(mutationName, key) {
|
|
814
|
+
if (key === "code" || key === "message") return "String!";
|
|
815
|
+
if (key === "pct") return "Int";
|
|
816
|
+
if (key === "retryable") return "Boolean";
|
|
817
|
+
if (key === "details" || key === "metadata") return "JSON";
|
|
818
|
+
if (key === "result") {
|
|
819
|
+
const base = mutationName.replace(/^complete/, "").replace(/Execution$/, "");
|
|
820
|
+
return `${base}ExecutionResultInput!`;
|
|
821
|
+
}
|
|
822
|
+
if (key === "message") return "String";
|
|
823
|
+
return "String";
|
|
824
|
+
}
|
|
825
|
+
async function hmacSha256Hex(secret, body) {
|
|
826
|
+
const enc = new TextEncoder();
|
|
827
|
+
const key = await crypto.subtle.importKey(
|
|
828
|
+
"raw",
|
|
829
|
+
enc.encode(secret),
|
|
830
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
831
|
+
false,
|
|
832
|
+
["sign"]
|
|
833
|
+
);
|
|
834
|
+
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(body));
|
|
835
|
+
return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
836
|
+
}
|
|
566
837
|
|
|
567
838
|
// src/data/transport.ts
|
|
839
|
+
async function resolveVisitorAddress(config) {
|
|
840
|
+
const source = config.visitor;
|
|
841
|
+
const value = typeof source === "function" ? await source() : source;
|
|
842
|
+
const address = value?.trim();
|
|
843
|
+
return address ? address : void 0;
|
|
844
|
+
}
|
|
568
845
|
async function resolveBearerToken(config) {
|
|
569
846
|
const source = config.bearerToken;
|
|
570
847
|
if (typeof source === "function") return await source() ?? void 0;
|
|
@@ -597,6 +874,7 @@ async function resolveFoirAuth(config) {
|
|
|
597
874
|
bearerToken,
|
|
598
875
|
orgId: config.orgId,
|
|
599
876
|
orgUrl: config.orgUrl,
|
|
877
|
+
visitorAddress: await resolveVisitorAddress(config),
|
|
600
878
|
extra: config.headers
|
|
601
879
|
}),
|
|
602
880
|
key: { apiKey: config.apiKey, bearerToken }
|
|
@@ -1222,16 +1500,26 @@ function asEventsSocketError(error, requestedCursor) {
|
|
|
1222
1500
|
return error;
|
|
1223
1501
|
}
|
|
1224
1502
|
export {
|
|
1503
|
+
CONTINUATION_MEDIA_TYPE,
|
|
1504
|
+
FOIR_CONTEXT_HEADER,
|
|
1225
1505
|
FOIR_EVENTS_PATH,
|
|
1226
1506
|
FOIR_EVENTS_RETENTION_DAYS,
|
|
1227
1507
|
FOIR_EVENTS_SCOPE,
|
|
1508
|
+
FOIR_TOKEN_HEADER,
|
|
1509
|
+
FoirAuthDowngradedError,
|
|
1228
1510
|
FoirEventCursorExpiredError,
|
|
1229
1511
|
FoirRequestError,
|
|
1512
|
+
FoirSessionExpiredError,
|
|
1513
|
+
MAX_CONTINUATION_CURSOR_BYTES,
|
|
1514
|
+
MAX_CONTINUATION_DELAY_MS,
|
|
1230
1515
|
OperationPayloadError,
|
|
1231
1516
|
ScopedTokenError,
|
|
1517
|
+
ScopedTokenUnavailableError,
|
|
1232
1518
|
ServerClientError,
|
|
1519
|
+
continueRun,
|
|
1233
1520
|
createCallbackClient,
|
|
1234
1521
|
createEventsClient,
|
|
1522
|
+
createScopedTokenVerifier,
|
|
1235
1523
|
createServerClient,
|
|
1236
1524
|
isFoirEventCursorExpired,
|
|
1237
1525
|
isFoirForbidden,
|