@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +165 -2
  2. package/dist/app/client.d.ts +445 -0
  3. package/dist/app/contract/index.d.ts +23 -0
  4. package/dist/app/contract/proto/apps/v1/apps_pb.d.ts +1465 -0
  5. package/dist/app/contract/proto/apps/v1/discovery_pb.d.ts +678 -0
  6. package/dist/app/dispatch.d.ts +64 -0
  7. package/dist/app/errors.d.ts +50 -0
  8. package/dist/app/index.d.ts +40 -0
  9. package/dist/app/manifest.d.ts +56 -0
  10. package/dist/app/naming.d.ts +31 -0
  11. package/dist/app/record-key.d.ts +17 -0
  12. package/dist/app/ticket.d.ts +37 -0
  13. package/dist/app/vocabulary.generated.d.ts +20 -0
  14. package/dist/app-contract.js +124 -0
  15. package/dist/app.js +1106 -0
  16. package/dist/auth/auth.d.ts +24 -6
  17. package/dist/auth/index.d.ts +2 -1
  18. package/dist/auth.js +139 -23
  19. package/dist/client.js +36 -2
  20. package/dist/data/index.d.ts +2 -2
  21. package/dist/data/transport.d.ts +34 -2
  22. package/dist/data.js +77 -2
  23. package/dist/graphql/client.d.ts +1 -1
  24. package/dist/graphql/foir-request.d.ts +1 -1
  25. package/dist/next.js +70 -2
  26. package/dist/placement/index.d.ts +129 -0
  27. package/dist/placement.js +144 -0
  28. package/dist/push.js +121 -13
  29. package/dist/react.js +70 -2
  30. package/dist/server/callback-client.d.ts +30 -9
  31. package/dist/server/continue-run.d.ts +56 -0
  32. package/dist/server/index.d.ts +5 -3
  33. package/dist/server/operation-types.d.ts +31 -8
  34. package/dist/server/require-scoped-token.d.ts +52 -7
  35. package/dist/server/verify-scoped-token.d.ts +109 -3
  36. package/dist/server.js +415 -127
  37. package/dist/transport/foir-http.d.ts +48 -0
  38. 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 parts = sub.split("|");
121
- if (parts.length !== 3) {
122
- throw new Error(`malformed sub: expected 3 pipe-delimited segments, got ${parts.length}`);
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 [tenantId, projectId, appName] = parts;
125
- if (!tenantId || !projectId || !appName) {
126
- throw new Error("malformed sub: empty segment");
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
- const header = JSON.parse(
142
- new TextDecoder().decode(base64UrlDecode(rawHeader))
143
- );
144
- if (header.alg !== "EdDSA") throw new Error(`unexpected alg ${header.alg}`);
145
- if (!header.kid) throw new Error("missing kid");
146
- const keys = await loadJwks(jwksUrl);
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
- const claims = JSON.parse(
162
- new TextDecoder().decode(base64UrlDecode(rawPayload))
163
- );
164
- const now = Math.floor(Date.now() / 1e3);
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 TOKEN_HEADER = "X-Foir-Token";
189
- var CONTEXT_HEADER = "X-Foir-Context";
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
- constructor(message, status) {
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
- return payload;
326
- }
327
-
328
- // src/server/callback-client.ts
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 parts.join("");
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
- async function hmacSha256Hex(secret, body) {
416
- const enc = new TextEncoder();
417
- const key = await crypto.subtle.importKey(
418
- "raw",
419
- enc.encode(secret),
420
- { name: "HMAC", hash: "SHA-256" },
421
- false,
422
- ["sign"]
423
- );
424
- const sig = await crypto.subtle.sign("HMAC", key, enc.encode(body));
425
- return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
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) return true;
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,