@foir/sdk 0.10.0 → 0.12.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 (52) hide show
  1. package/CHANGELOG.md +353 -2
  2. package/README.md +10 -2
  3. package/dist/app/client.d.ts +371 -0
  4. package/dist/app/contract/index.d.ts +23 -0
  5. package/dist/app/contract/proto/apps/v1/apps_pb.d.ts +1465 -0
  6. package/dist/app/contract/proto/apps/v1/discovery_pb.d.ts +678 -0
  7. package/dist/app/dispatch.d.ts +64 -0
  8. package/dist/app/errors.d.ts +50 -0
  9. package/dist/app/index.d.ts +40 -0
  10. package/dist/app/manifest.d.ts +56 -0
  11. package/dist/app/naming.d.ts +31 -0
  12. package/dist/app/record-key.d.ts +17 -0
  13. package/dist/app/ticket.d.ts +37 -0
  14. package/dist/app/vocabulary.generated.d.ts +20 -0
  15. package/dist/app-contract.js +124 -0
  16. package/dist/app.js +998 -0
  17. package/dist/auth/auth.d.ts +24 -6
  18. package/dist/auth/index.d.ts +2 -1
  19. package/dist/auth/ops.d.ts +8 -0
  20. package/dist/auth.js +230 -60
  21. package/dist/client.js +80 -15
  22. package/dist/codegen/preset.d.ts +3 -2
  23. package/dist/data/builtins.d.ts +17 -1
  24. package/dist/data/client.d.ts +10 -8
  25. package/dist/data/document.d.ts +13 -0
  26. package/dist/data/index.d.ts +15 -5
  27. package/dist/data/manifest.d.ts +31 -3
  28. package/dist/data/schema.d.ts +16 -9
  29. package/dist/data/selection.d.ts +15 -0
  30. package/dist/data/transport.d.ts +277 -23
  31. package/dist/data/ws-transport.d.ts +8 -2
  32. package/dist/data.js +313 -72
  33. package/dist/graphql/client.d.ts +1 -1
  34. package/dist/graphql/foir-request.d.ts +1 -1
  35. package/dist/index.js +0 -9
  36. package/dist/live/drift.d.ts +9 -10
  37. package/dist/live.js +1 -1
  38. package/dist/next.js +158 -37
  39. package/dist/placement/index.d.ts +129 -0
  40. package/dist/placement.js +144 -0
  41. package/dist/push.js +212 -50
  42. package/dist/react.js +158 -37
  43. package/dist/server/callback-client.d.ts +30 -9
  44. package/dist/server/continue-run.d.ts +56 -0
  45. package/dist/server/index.d.ts +6 -3
  46. package/dist/server/operation-types.d.ts +114 -65
  47. package/dist/server/read-dispatch.d.ts +53 -0
  48. package/dist/server/require-scoped-token.d.ts +71 -7
  49. package/dist/server/verify-scoped-token.d.ts +109 -3
  50. package/dist/server.js +566 -138
  51. package/dist/transport/foir-http.d.ts +103 -5
  52. 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
@@ -238,111 +391,126 @@ async function requireScopedToken(request, options) {
238
391
  403
239
392
  );
240
393
  }
394
+ const rawContext = request.headers.get(CONTEXT_HEADER);
395
+ const level = claims.ctx?.[LEVEL_CLAIM];
241
396
  return {
242
397
  claims,
243
398
  subject,
244
- context: parseContextHeader(request.headers.get(CONTEXT_HEADER))
399
+ context: parseContextHeader(rawContext),
400
+ // The token handed over is the one verified above, not a fresh read of the
401
+ // header, so the client presents exactly what this guard accepted.
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
245
407
  };
246
408
  }
247
409
 
248
- // src/server/callback-client.ts
249
- function createCallbackClient(callback, opts) {
250
- const { executionId, signingSecret } = opts;
251
- const fetchImpl = opts.fetch ?? globalThis.fetch;
252
- if (!fetchImpl) {
253
- throw new Error(
254
- "createCallbackClient: no fetch available; pass opts.fetch on runtimes without a global fetch"
410
+ // src/server/read-dispatch.ts
411
+ var OperationPayloadError = class extends Error {
412
+ status;
413
+ constructor(message) {
414
+ super(message);
415
+ this.name = "OperationPayloadError";
416
+ this.status = 400;
417
+ }
418
+ };
419
+ var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
420
+ function refuse(message) {
421
+ throw new OperationPayloadError(`[foir] not an operation dispatch body: ${message}`);
422
+ }
423
+ function requireString(from, key, where, allowEmpty = false) {
424
+ const value = from[key];
425
+ if (typeof value !== "string" || !allowEmpty && value === "") {
426
+ refuse(`${where}.${key} is ${describe(value)}, expected a${allowEmpty ? "" : " non-empty"} string`);
427
+ }
428
+ return value;
429
+ }
430
+ function describe(value) {
431
+ if (value === void 0) return "missing";
432
+ if (value === null) return "null";
433
+ if (Array.isArray(value)) return "an array";
434
+ return `a ${typeof value}`;
435
+ }
436
+ async function readDispatch(request) {
437
+ let text;
438
+ try {
439
+ text = await request.text();
440
+ } catch (err) {
441
+ throw new OperationPayloadError(
442
+ `[foir] could not read the dispatch body (has something already consumed it?): ${err instanceof Error ? err.message : String(err)}`
255
443
  );
256
444
  }
257
- if (!executionId) throw new Error("createCallbackClient: executionId is required");
258
- if (!signingSecret) throw new Error("createCallbackClient: signingSecret is required");
259
- if (!callback?.token || !callback?.gqlEndpoint || !callback?.mutations) {
260
- throw new Error("createCallbackClient: callback block is missing required fields");
445
+ let body;
446
+ try {
447
+ body = JSON.parse(text);
448
+ } catch {
449
+ refuse("the body is not JSON");
261
450
  }
262
- const post = async (mutationName, fields, variables) => {
263
- const query = `mutation ${mutationName}Mutation($id: String!${fieldArgsForMutation(mutationName, variables)}) { ${mutationName}(id: $id${fieldArgsCallSite(mutationName, variables)}) { ${fields} } }`;
264
- const body = JSON.stringify({
265
- query,
266
- variables: { id: executionId, ...variables }
267
- });
268
- const signature = await hmacSha256Hex(signingSecret, body);
269
- const resp = await fetchImpl(callback.gqlEndpoint, {
270
- method: "POST",
271
- headers: {
272
- "Content-Type": "application/json",
273
- Authorization: `Bearer ${callback.token}`,
274
- "X-Foir-Signature": `sha256=${signature}`
275
- },
276
- body
277
- });
278
- if (!resp.ok) {
279
- throw new Error(`callback ${mutationName} failed with HTTP ${resp.status}: ${await resp.text()}`);
280
- }
281
- const json = await resp.json();
282
- if (json.errors?.length) {
283
- throw new Error(`callback ${mutationName} GraphQL error: ${json.errors[0].message}`);
451
+ if (!isObject(body)) refuse(`the body is ${describe(body)}, expected a JSON object`);
452
+ if (typeof body.executionId !== "string" || typeof body.operationKey !== "string") {
453
+ refuse(
454
+ "it has no executionId and operationKey. The platform sends the run, not the input: the caller's input is under `input`"
455
+ );
456
+ }
457
+ requireString(body, "executionId", "body");
458
+ requireString(body, "operationKey", "body");
459
+ const trigger = body.trigger;
460
+ if (!isObject(trigger)) refuse(`body.trigger is ${describe(trigger)}, expected an object`);
461
+ requireString(trigger, "type", "body.trigger");
462
+ const context = body.context;
463
+ if (!isObject(context)) refuse(`body.context is ${describe(context)}, expected an object`);
464
+ requireString(context, "tenantId", "body.context");
465
+ requireString(context, "projectId", "body.context");
466
+ requireString(context, "userId", "body.context", true);
467
+ requireString(context, "timestamp", "body.context");
468
+ if (context.customerId !== void 0) requireString(context, "customerId", "body.context");
469
+ const input = body.input;
470
+ if (input !== null && !isObject(input)) {
471
+ refuse(`body.input is ${describe(input)}, expected an object`);
472
+ }
473
+ const payload = { ...body, input: input ?? {} };
474
+ for (const block of ["content", "record", "triggerContext", "callback", "continuation"]) {
475
+ const value = payload[block];
476
+ if (value === void 0) continue;
477
+ if (value === null) {
478
+ delete payload[block];
479
+ continue;
284
480
  }
285
- const out = json.data?.[mutationName];
286
- if (!out) {
287
- throw new Error(`callback ${mutationName} returned no data`);
481
+ if (!isObject(value)) refuse(`body.${block} is ${describe(value)}, expected an object`);
482
+ }
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`);
288
487
  }
289
- return out;
290
- };
291
- return {
292
- complete: (result) => post(callback.mutations.complete, "status cancelled", { result }),
293
- fail: (code, message, options = {}) => post(callback.mutations.fail, "status cancelled", {
294
- code,
295
- message,
296
- retryable: options.retryable,
297
- details: options.details
298
- }),
299
- progress: (update) => post(callback.mutations.progress, "status cancelled applied", {
300
- pct: update.pct,
301
- message: update.message,
302
- metadata: update.metadata
303
- }),
304
- cancel: () => post(callback.mutations.cancel, "status cancelled", {})
305
- };
306
- }
307
- function fieldArgsForMutation(mutationName, vars) {
308
- const parts = [];
309
- for (const key of Object.keys(vars)) {
310
- if (vars[key] === void 0) continue;
311
- parts.push(`, $${key}: ${graphQLTypeFor(mutationName, key)}`);
312
488
  }
313
- return parts.join("");
489
+ return payload;
314
490
  }
315
- function fieldArgsCallSite(_mutationName, vars) {
316
- const parts = [];
317
- for (const key of Object.keys(vars)) {
318
- if (vars[key] === void 0) continue;
319
- parts.push(`, ${key}: $${key}`);
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");
320
500
  }
321
- return parts.join("");
322
- }
323
- function graphQLTypeFor(mutationName, key) {
324
- if (key === "code" || key === "message") return "String!";
325
- if (key === "pct") return "Int";
326
- if (key === "retryable") return "Boolean";
327
- if (key === "details" || key === "metadata") return "JSON";
328
- if (key === "result") {
329
- const base = mutationName.replace(/^complete/, "").replace(/Execution$/, "");
330
- return `${base}ExecutionResultInput!`;
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`);
331
503
  }
332
- if (key === "message") return "String";
333
- return "String";
334
- }
335
- async function hmacSha256Hex(secret, body) {
336
- const enc = new TextEncoder();
337
- const key = await crypto.subtle.importKey(
338
- "raw",
339
- enc.encode(secret),
340
- { name: "HMAC", hash: "SHA-256" },
341
- false,
342
- ["sign"]
343
- );
344
- const sig = await crypto.subtle.sign("HMAC", key, enc.encode(body));
345
- return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
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
+ });
346
514
  }
347
515
 
348
516
  // src/transport/foir-http.ts
@@ -398,7 +566,12 @@ var FoirRequestError = class extends Error {
398
566
  * failed on the first attempt for an hour.
399
567
  */
400
568
  get retryable() {
401
- 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
+ }
402
575
  if (this.status === 429 || this.code === "RATE_LIMITED") return true;
403
576
  if (this.status >= 500) return true;
404
577
  return this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
@@ -428,19 +601,50 @@ function readFoirRetryAfterMs(headers) {
428
601
  const seconds = Number(headers.get("Retry-After"));
429
602
  return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
430
603
  }
604
+ var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
605
+ "x-api-key",
606
+ "authorization",
607
+ "x-foir-token",
608
+ "x-foir-context"
609
+ ]);
610
+ function refuseCredentialHeaders(extra) {
611
+ if (!extra) return;
612
+ for (const name of Object.keys(extra)) {
613
+ if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
614
+ throw new FoirRequestError(
615
+ `[foir] a client built from a dispatch token cannot send "${name}" in \`headers\`. The dispatch token is the credential, and an api key or a bearer beside it would make the request two actors at once. Build a separate client for the other credential.`
616
+ );
617
+ }
618
+ }
619
+ }
620
+ var FOIR_VISITOR_IP_HEADER = "X-Foir-Visitor-IP";
431
621
  function buildFoirHeaders(input) {
432
622
  const headers = {
433
- "Content-Type": "application/json",
434
- "x-api-key": input.apiKey
623
+ "Content-Type": "application/json"
435
624
  };
436
- if (input.draftChannel) {
437
- headers["X-Foir-Schema-Channel"] = "draft";
438
- }
439
- if (input.bearerToken) {
440
- headers["Authorization"] = `Bearer ${input.bearerToken}`;
441
- }
442
- if (input.orgId) {
443
- headers["X-Org-Id"] = input.orgId;
625
+ if (input.dispatchToken !== void 0) {
626
+ refuseCredentialHeaders(input.extra);
627
+ headers["X-Foir-Token"] = input.dispatchToken;
628
+ if (input.dispatchContext) {
629
+ headers["X-Foir-Context"] = input.dispatchContext;
630
+ }
631
+ } else {
632
+ headers["x-api-key"] = input.apiKey;
633
+ if (input.draftChannel) {
634
+ headers["X-Foir-Schema-Channel"] = "draft";
635
+ }
636
+ if (input.bearerToken) {
637
+ headers["Authorization"] = `Bearer ${input.bearerToken}`;
638
+ }
639
+ if (input.orgId) {
640
+ headers["X-Org-Id"] = input.orgId;
641
+ }
642
+ if (input.orgUrl) {
643
+ headers["X-Org-Url"] = input.orgUrl;
644
+ }
645
+ if (input.visitorAddress) {
646
+ headers[FOIR_VISITOR_IP_HEADER] = input.visitorAddress;
647
+ }
444
648
  }
445
649
  if (input.extra) {
446
650
  for (const [key, value] of Object.entries(input.extra)) {
@@ -456,13 +660,226 @@ function foirApiEndpoint(apiUrl, path) {
456
660
  }
457
661
  return base + path;
458
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
+ }
459
837
 
460
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
+ }
461
845
  async function resolveBearerToken(config) {
462
846
  const source = config.bearerToken;
463
847
  if (typeof source === "function") return await source() ?? void 0;
464
848
  return source ?? void 0;
465
849
  }
850
+ async function resolveDispatchToken(dispatch) {
851
+ const token = (await dispatch.token())?.trim();
852
+ if (!token) {
853
+ throw new FoirRequestError(
854
+ "[foir] the dispatch token provider returned no token. A client built from a dispatch calls Foir as that operation and holds no other credential. Build it from the token the endpoint was handed: `(await requireScopedToken(request)).dispatch`."
855
+ );
856
+ }
857
+ return token;
858
+ }
859
+ async function resolveFoirAuth(config) {
860
+ if (config.dispatch !== void 0) {
861
+ return {
862
+ headers: buildFoirHeaders({
863
+ dispatchToken: await resolveDispatchToken(config.dispatch),
864
+ dispatchContext: config.dispatch.context ?? void 0,
865
+ extra: config.headers
866
+ })
867
+ };
868
+ }
869
+ const bearerToken = await resolveBearerToken(config);
870
+ return {
871
+ headers: buildFoirHeaders({
872
+ apiKey: config.apiKey,
873
+ draftChannel: config.channel === "draft",
874
+ bearerToken,
875
+ orgId: config.orgId,
876
+ orgUrl: config.orgUrl,
877
+ visitorAddress: await resolveVisitorAddress(config),
878
+ extra: config.headers
879
+ }),
880
+ key: { apiKey: config.apiKey, bearerToken }
881
+ };
882
+ }
466
883
 
467
884
  // src/data/ws-transport.ts
468
885
  var GQL = {
@@ -500,6 +917,11 @@ var FoirSocketTransport = class {
500
917
  keepAlive = null;
501
918
  retryTimer = null;
502
919
  constructor(config) {
920
+ if (config.dispatch !== void 0) {
921
+ throw new FoirRequestError(
922
+ "[foir] a client built from a dispatch token cannot subscribe. The subscription socket authenticates an api key, an admin token or a customer bearer, and has no path for a dispatch token. Queries and mutations made as the operation go to /graphql and work as they are."
923
+ );
924
+ }
503
925
  this.config = config;
504
926
  this.policy = { ...DEFAULT_RECONNECT, ...config.reconnect ?? {} };
505
927
  }
@@ -571,13 +993,7 @@ var FoirSocketTransport = class {
571
993
  async sendConnectionInit(socket) {
572
994
  this.connecting = false;
573
995
  try {
574
- const headers = buildFoirHeaders({
575
- apiKey: this.config.apiKey,
576
- draftChannel: this.config.channel === "draft",
577
- bearerToken: await resolveBearerToken(this.config),
578
- orgId: this.config.orgId,
579
- extra: this.config.headers
580
- });
996
+ const { headers } = await resolveFoirAuth(this.config);
581
997
  const { "Content-Type": _contentType, ...params } = headers;
582
998
  const extra = await this.config.connectionParams?.() ?? {};
583
999
  this.send(socket, {
@@ -684,7 +1100,7 @@ var FoirSocketTransport = class {
684
1100
  return;
685
1101
  }
686
1102
  case GQL.error: {
687
- const error = new FoirRequestError(`[foir] subscription refused: ${describe(message.payload)}`, {
1103
+ const error = new FoirRequestError(`[foir] subscription refused: ${describe2(message.payload)}`, {
688
1104
  graphQLErrors: graphQLErrorItems(message.payload)
689
1105
  });
690
1106
  if (!message.id) {
@@ -805,17 +1221,17 @@ function graphQLErrorItems(payload) {
805
1221
  const { message, path, extensions } = entry;
806
1222
  if (extensions === void 0 && path === void 0) continue;
807
1223
  items.push({
808
- message: typeof message === "string" ? message : describe(entry),
1224
+ message: typeof message === "string" ? message : describe2(entry),
809
1225
  ...Array.isArray(path) ? { path } : {},
810
1226
  ...typeof extensions === "object" && extensions !== null ? { extensions } : {}
811
1227
  });
812
1228
  }
813
1229
  return items.length > 0 ? items : void 0;
814
1230
  }
815
- function describe(payload) {
1231
+ function describe2(payload) {
816
1232
  if (payload == null) return "no reason given";
817
1233
  if (typeof payload === "string") return payload;
818
- if (Array.isArray(payload)) return payload.map(describe).join("; ");
1234
+ if (Array.isArray(payload)) return payload.map(describe2).join("; ");
819
1235
  if (typeof payload === "object") {
820
1236
  const message = payload.message;
821
1237
  if (typeof message === "string") return message;
@@ -1084,15 +1500,26 @@ function asEventsSocketError(error, requestedCursor) {
1084
1500
  return error;
1085
1501
  }
1086
1502
  export {
1503
+ CONTINUATION_MEDIA_TYPE,
1504
+ FOIR_CONTEXT_HEADER,
1087
1505
  FOIR_EVENTS_PATH,
1088
1506
  FOIR_EVENTS_RETENTION_DAYS,
1089
1507
  FOIR_EVENTS_SCOPE,
1508
+ FOIR_TOKEN_HEADER,
1509
+ FoirAuthDowngradedError,
1090
1510
  FoirEventCursorExpiredError,
1091
1511
  FoirRequestError,
1512
+ FoirSessionExpiredError,
1513
+ MAX_CONTINUATION_CURSOR_BYTES,
1514
+ MAX_CONTINUATION_DELAY_MS,
1515
+ OperationPayloadError,
1092
1516
  ScopedTokenError,
1517
+ ScopedTokenUnavailableError,
1093
1518
  ServerClientError,
1519
+ continueRun,
1094
1520
  createCallbackClient,
1095
1521
  createEventsClient,
1522
+ createScopedTokenVerifier,
1096
1523
  createServerClient,
1097
1524
  isFoirEventCursorExpired,
1098
1525
  isFoirForbidden,
@@ -1101,6 +1528,7 @@ export {
1101
1528
  isFoirUnauthenticated,
1102
1529
  parseContextHeader,
1103
1530
  parseSubject,
1531
+ readDispatch,
1104
1532
  recordsReadModels,
1105
1533
  recordsWriteModels,
1106
1534
  requireScopedToken,