@foir/sdk 0.10.0 → 0.11.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.
@@ -1,41 +1,70 @@
1
1
  /**
2
2
  * Type definitions for platform operations.
3
3
  *
4
- * These types match the platform's operation execution payload structure.
4
+ * `OperationPayload` is the body the platform POSTs to an operation endpoint,
5
+ * field for field. It is written against the worker that builds it
6
+ * (`buildOperationPayload` in the platform's operation processor), not against
7
+ * what an endpoint might like to receive: every field here is one the platform
8
+ * sends, present exactly when the platform sends it. A field the platform does
9
+ * not send is not here, because a type that promises one is how an endpoint
10
+ * comes to read `undefined` and refuse every call.
11
+ *
12
+ * Read it with `readDispatch(request)`, which parses and checks the body into
13
+ * this type.
5
14
  */
6
15
  /**
7
- * Operation payload sent by the platform to extension endpoints.
16
+ * The body the platform sends an operation endpoint.
17
+ *
18
+ * The same shape however the run was started (a person in the console, an
19
+ * `execute<Op>` call, a lifecycle hook, a schedule) and whichever way it is
20
+ * delivered (to the registered endpoint, or down `foir operations dev` to one
21
+ * on your machine).
8
22
  *
9
- * All operations receive this standardized payload structure regardless
10
- * of how they're triggered (UI, API, lifecycle hook, schedule).
23
+ * `TInput` types `input`. It is an assertion, not a check: the platform
24
+ * validates `input` against the operation's input schema before dispatching,
25
+ * and this parameter lets the endpoint say what that schema is.
11
26
  */
12
- export interface OperationPayload {
13
- /** Execution id — present so the extension can include it in callbacks. */
14
- executionId?: string;
15
- /** Unique key identifying this operation (e.g., 'tilly-process-note') */
27
+ export interface OperationPayload<TInput extends object = Record<string, unknown>> {
28
+ /** The run. Always present; quote it in a callback and in your own logs. */
29
+ executionId: string;
30
+ /** The key of the operation being run, e.g. `send_quote`. */
16
31
  operationKey: string;
17
- /** How this operation was triggered */
32
+ /** How the run was started. */
18
33
  trigger: OperationTrigger;
19
- /** Input data provided by the caller */
20
- input: Record<string, unknown>;
21
- /** Content/field value (for field-triggered operations) */
22
- content: unknown;
23
- /** Record context (if operation was triggered by a record event) */
24
- record: OperationRecordContext | null;
25
- /** Platform context (tenant, project, user, locale) */
34
+ /**
35
+ * What the caller passed as the operation's input. Always an object:
36
+ * `readDispatch` reads a run started with no input (a schedule that declares
37
+ * none, which the platform serialises as `null`) as `{}`.
38
+ */
39
+ input: TInput;
40
+ /** The tenant, project and principal the run belongs to. */
26
41
  context: OperationContext;
27
- /** Extension credentials (auto-injected by platform) */
28
- credentials?: Record<string, unknown>;
29
- /** Extension config (auto-injected by platform) */
30
- config?: Record<string, unknown>;
31
42
  /**
32
- * Async-callback dispatch metadata. Present iff the operation was
33
- * dispatched in `async-callback` mode. Contains the scoped token, the
34
- * api-public GraphQL endpoint, and the per-op typed mutation names the
35
- * extension should call back to.
43
+ * The content block the caller attached when it started the run, forwarded
44
+ * as the caller wrote it. Absent when there was none, which is every run
45
+ * started by a hook, a schedule or `execute<Op>`.
46
+ */
47
+ content?: Record<string, unknown>;
48
+ /**
49
+ * The record block the caller attached when it started the run, forwarded
50
+ * as the caller wrote it. Absent when there was none. For a record event,
51
+ * the record's id arrives in `input` (for example `input.recordId`), not
52
+ * here: read the record back as the operation when you need its content.
53
+ */
54
+ record?: OperationRecordContext;
55
+ /**
56
+ * Caller-supplied context about where the run was started from, forwarded as
57
+ * written. Absent when there was none.
58
+ */
59
+ triggerContext?: Record<string, unknown>;
60
+ /**
61
+ * Where and how to report back. Present only when the run was dispatched in
62
+ * ASYNC mode: the endpoint answers `202 Accepted` and reports the outcome
63
+ * later through this block (see `createCallbackClient`).
36
64
  *
37
- * Absent for SYNC operations — the extension returns its result in the
38
- * HTTP response body and there's nothing to call back to.
65
+ * Absent in the blocking (SYNC) mode. A blocking run has no callback path:
66
+ * the endpoint's response body is the result. Branch on this field being
67
+ * present to tell the two modes apart.
39
68
  */
40
69
  callback?: OperationCallback;
41
70
  }
@@ -61,53 +90,52 @@ export interface OperationCallback {
61
90
  progress: string;
62
91
  cancel: string;
63
92
  };
93
+ /**
94
+ * The operation keys that led to this run, its own last. Present when the
95
+ * run was caused by another operation's work.
96
+ */
97
+ causationChain?: string[];
64
98
  }
65
99
  /**
66
- * Trigger information describing how the operation was invoked.
100
+ * How a run was started. The four values are the platform's own; a caller that
101
+ * names none is recorded as `api`.
102
+ *
103
+ * - `ui`: a console surface that names itself as the trigger, such as an app's
104
+ * configure page.
105
+ * - `api`: an `execute<Op>` call, or any caller that named no trigger.
106
+ * - `lifecycle`: a hook on a platform event (a record published, an operation
107
+ * completed).
108
+ * - `schedule`: a cron schedule firing.
67
109
  */
68
110
  export interface OperationTrigger {
69
- /** Trigger type */
70
- type: 'ui' | 'api' | 'lifecycle' | 'field';
71
- /** Field key (for field-triggered operations) */
72
- fieldKey?: string;
73
- /** Field type (for field-triggered operations) */
74
- fieldType?: string;
111
+ type: 'ui' | 'api' | 'lifecycle' | 'schedule';
75
112
  }
76
113
  /**
77
- * Record context provided when operation is triggered by a record event.
114
+ * The record block a caller attached to a run, forwarded as the caller wrote
115
+ * it. The platform does not shape it, so neither does this type.
78
116
  */
79
- export interface OperationRecordContext {
80
- /** Record ID */
81
- id: string;
82
- /** Model key (e.g., 'tilly_note') */
83
- modelKey: string;
84
- /** Version ID (if versioned record) */
85
- versionId?: string;
86
- /** Record content/data */
87
- data: Record<string, unknown>;
88
- /** Record metadata */
89
- metadata: {
90
- /** Natural key (slug, handle, etc.) */
91
- naturalKey?: string;
92
- /** Additional metadata fields */
93
- [key: string]: unknown;
94
- };
95
- }
117
+ export type OperationRecordContext = Record<string, unknown>;
96
118
  /**
97
- * Platform context provided to all operations.
119
+ * The tenant, project and principal a run belongs to.
98
120
  */
99
121
  export interface OperationContext {
100
122
  /** Tenant ID */
101
123
  tenantId: string;
102
124
  /** Project ID */
103
125
  projectId: string;
104
- /** User ID (if user-triggered) */
105
- userId?: string;
106
- /** Customer ID (if customer-scoped operation) */
126
+ /**
127
+ * The principal that started the run. Always present, and the empty string
128
+ * when the run has none, as a hook's run does.
129
+ */
130
+ userId: string;
131
+ /**
132
+ * The customer the run acts for, when it acts for one: the signed-in
133
+ * customer who ran it, or the owner of the record whose event fired the
134
+ * hook. The dispatch token carries the same customer, so a client built from
135
+ * it acts for them too.
136
+ */
107
137
  customerId?: string;
108
- /** Locale code (if locale-specific) */
109
- locale?: string;
110
- /** Timestamp when operation was triggered */
138
+ /** When the platform built this body, RFC 3339 in UTC to the second. */
111
139
  timestamp: string;
112
140
  }
113
141
  /**
@@ -0,0 +1,53 @@
1
+ /**
2
+ * readDispatch: the body of an operation dispatch, parsed and checked.
3
+ *
4
+ * An operation endpoint is handed two things. The headers carry the dispatch
5
+ * token, which `requireScopedToken` verifies. The body carries the run: its id,
6
+ * the operation's key, how it was started, the context it runs in, and the
7
+ * caller's input under `input`. Reading the body as though it WERE the input is
8
+ * the mistake this exists to prevent, because it fails quietly: every field the
9
+ * endpoint looks for is absent, and the endpoint refuses every call on a
10
+ * missing field it was in fact sent.
11
+ *
12
+ * ```ts
13
+ * const { dispatch } = await requireScopedToken(request, { expectedProjectId });
14
+ * const { input, context } = await readDispatch<{ email: string }>(request);
15
+ * const foir = createFoirClient({ apiUrl, dispatch, generated });
16
+ * ```
17
+ *
18
+ * The shape is the platform's `buildOperationPayload`, field for field; see
19
+ * `OperationPayload`. A body that is not that shape is refused with
20
+ * {@link OperationPayloadError}, naming what is wrong, rather than handed on
21
+ * half-read.
22
+ *
23
+ * What it does not do is drop fields. A key the platform adds later is kept on
24
+ * the returned object, untyped, because a reader that re-built the body from
25
+ * the fields it knows would delete whatever it did not, which is the exact way
26
+ * blocks of this payload have gone missing on their way to an endpoint before.
27
+ */
28
+ import type { OperationPayload } from './operation-types';
29
+ /**
30
+ * The dispatch body was not an operation payload. `status` is the HTTP status
31
+ * the route should answer with: 400, since the request is malformed rather than
32
+ * unauthorised (the token has already been verified by the time the body is
33
+ * read).
34
+ */
35
+ export declare class OperationPayloadError extends Error {
36
+ readonly status: 400;
37
+ constructor(message: string);
38
+ }
39
+ /** The minimal request shape needed: satisfied by any fetch-API Request. */
40
+ export interface BodyCarrier {
41
+ text(): Promise<string>;
42
+ }
43
+ /**
44
+ * Read the dispatch body off an operation request.
45
+ *
46
+ * `TInput` types `input`, as an assertion: the platform has already checked the
47
+ * input against the operation's input schema, and this is where the endpoint
48
+ * says what that schema is.
49
+ *
50
+ * Reads the body, so call it once per request and after nothing else has
51
+ * consumed it.
52
+ */
53
+ export declare function readDispatch<TInput extends object = Record<string, unknown>>(request: BodyCarrier): Promise<OperationPayload<TInput>>;
@@ -16,6 +16,17 @@
16
16
  * });
17
17
  * ```
18
18
  *
19
+ * The verified token is also the endpoint's credential for calling Foir back.
20
+ * `dispatch` on the result is the token as a data-client credential, so the
21
+ * endpoint acts as the operation, with the capabilities the operation
22
+ * declared, and holds no key of its own. `generated` is the module
23
+ * `foir types --operation <key>` wrote for the operation:
24
+ *
25
+ * ```ts
26
+ * const { dispatch } = await requireScopedToken(request, { expectedProjectId });
27
+ * const foir = createFoirClient({ apiUrl: env.PLATFORM_API_URL, dispatch, generated });
28
+ * ```
29
+ *
19
30
  * Throws {@link ScopedTokenError} with `status` 401 (missing/invalid token)
20
31
  * or 403 (valid token for the wrong tenant/project) — map it to a response
21
32
  * in one place:
@@ -30,6 +41,7 @@
30
41
  * ```
31
42
  */
32
43
  import type { ScopedTokenClaims, ScopedTokenSubject, VerifyScopedTokenOptions } from './verify-scoped-token';
44
+ import type { FoirDispatchCredential } from '../data/transport';
33
45
  /**
34
46
  * Verification failure with the HTTP status the route should answer with:
35
47
  * 401 for a missing or invalid token, 403 for a valid token bound to the
@@ -66,6 +78,13 @@ export interface ScopedTokenIngress {
66
78
  subject: ScopedTokenSubject;
67
79
  /** Parsed `X-Foir-Context` correlation metadata (empty when absent). */
68
80
  context: Record<string, string>;
81
+ /**
82
+ * The token this call verified, as the credential for a data client that
83
+ * calls Foir AS the operation:
84
+ * `createFoirClient({ apiUrl, dispatch, generated })`. It sends the token as
85
+ * `X-Foir-Token` with the dispatch's `X-Foir-Context`, and never an api key.
86
+ */
87
+ dispatch: FoirDispatchCredential;
69
88
  }
70
89
  /**
71
90
  * Verify the platform-minted scoped token on an inbound dispatch request.
package/dist/server.js CHANGED
@@ -238,13 +238,93 @@ async function requireScopedToken(request, options) {
238
238
  403
239
239
  );
240
240
  }
241
+ const rawContext = request.headers.get(CONTEXT_HEADER);
241
242
  return {
242
243
  claims,
243
244
  subject,
244
- context: parseContextHeader(request.headers.get(CONTEXT_HEADER))
245
+ context: parseContextHeader(rawContext),
246
+ // The token handed over is the one verified above, not a fresh read of the
247
+ // header, so the client presents exactly what this guard accepted.
248
+ dispatch: { token: () => token, context: rawContext }
245
249
  };
246
250
  }
247
251
 
252
+ // src/server/read-dispatch.ts
253
+ var OperationPayloadError = class extends Error {
254
+ status;
255
+ constructor(message) {
256
+ super(message);
257
+ this.name = "OperationPayloadError";
258
+ this.status = 400;
259
+ }
260
+ };
261
+ var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
262
+ function refuse(message) {
263
+ throw new OperationPayloadError(`[foir] not an operation dispatch body: ${message}`);
264
+ }
265
+ function requireString(from, key, where, allowEmpty = false) {
266
+ const value = from[key];
267
+ if (typeof value !== "string" || !allowEmpty && value === "") {
268
+ refuse(`${where}.${key} is ${describe(value)}, expected a${allowEmpty ? "" : " non-empty"} string`);
269
+ }
270
+ return value;
271
+ }
272
+ function describe(value) {
273
+ if (value === void 0) return "missing";
274
+ if (value === null) return "null";
275
+ if (Array.isArray(value)) return "an array";
276
+ return `a ${typeof value}`;
277
+ }
278
+ async function readDispatch(request) {
279
+ let text;
280
+ try {
281
+ text = await request.text();
282
+ } catch (err) {
283
+ throw new OperationPayloadError(
284
+ `[foir] could not read the dispatch body (has something already consumed it?): ${err instanceof Error ? err.message : String(err)}`
285
+ );
286
+ }
287
+ let body;
288
+ try {
289
+ body = JSON.parse(text);
290
+ } catch {
291
+ refuse("the body is not JSON");
292
+ }
293
+ if (!isObject(body)) refuse(`the body is ${describe(body)}, expected a JSON object`);
294
+ if (typeof body.executionId !== "string" || typeof body.operationKey !== "string") {
295
+ refuse(
296
+ "it has no executionId and operationKey. The platform sends the run, not the input: the caller's input is under `input`"
297
+ );
298
+ }
299
+ requireString(body, "executionId", "body");
300
+ requireString(body, "operationKey", "body");
301
+ const trigger = body.trigger;
302
+ if (!isObject(trigger)) refuse(`body.trigger is ${describe(trigger)}, expected an object`);
303
+ requireString(trigger, "type", "body.trigger");
304
+ const context = body.context;
305
+ if (!isObject(context)) refuse(`body.context is ${describe(context)}, expected an object`);
306
+ requireString(context, "tenantId", "body.context");
307
+ requireString(context, "projectId", "body.context");
308
+ requireString(context, "userId", "body.context", true);
309
+ requireString(context, "timestamp", "body.context");
310
+ if (context.customerId !== void 0) requireString(context, "customerId", "body.context");
311
+ const input = body.input;
312
+ if (input !== null && !isObject(input)) {
313
+ refuse(`body.input is ${describe(input)}, expected an object`);
314
+ }
315
+ const payload = { ...body, input: input ?? {} };
316
+ for (const block of ["content", "record", "triggerContext", "callback"]) {
317
+ const value = payload[block];
318
+ if (value === void 0) continue;
319
+ if (value === null) {
320
+ delete payload[block];
321
+ continue;
322
+ }
323
+ if (!isObject(value)) refuse(`body.${block} is ${describe(value)}, expected an object`);
324
+ }
325
+ return payload;
326
+ }
327
+
248
328
  // src/server/callback-client.ts
249
329
  function createCallbackClient(callback, opts) {
250
330
  const { executionId, signingSecret } = opts;
@@ -428,19 +508,46 @@ function readFoirRetryAfterMs(headers) {
428
508
  const seconds = Number(headers.get("Retry-After"));
429
509
  return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
430
510
  }
511
+ var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
512
+ "x-api-key",
513
+ "authorization",
514
+ "x-foir-token",
515
+ "x-foir-context"
516
+ ]);
517
+ function refuseCredentialHeaders(extra) {
518
+ if (!extra) return;
519
+ for (const name of Object.keys(extra)) {
520
+ if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
521
+ throw new FoirRequestError(
522
+ `[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.`
523
+ );
524
+ }
525
+ }
526
+ }
431
527
  function buildFoirHeaders(input) {
432
528
  const headers = {
433
- "Content-Type": "application/json",
434
- "x-api-key": input.apiKey
529
+ "Content-Type": "application/json"
435
530
  };
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;
531
+ if (input.dispatchToken !== void 0) {
532
+ refuseCredentialHeaders(input.extra);
533
+ headers["X-Foir-Token"] = input.dispatchToken;
534
+ if (input.dispatchContext) {
535
+ headers["X-Foir-Context"] = input.dispatchContext;
536
+ }
537
+ } else {
538
+ headers["x-api-key"] = input.apiKey;
539
+ if (input.draftChannel) {
540
+ headers["X-Foir-Schema-Channel"] = "draft";
541
+ }
542
+ if (input.bearerToken) {
543
+ headers["Authorization"] = `Bearer ${input.bearerToken}`;
544
+ }
545
+ if (input.orgId) {
546
+ headers["X-Org-Id"] = input.orgId;
547
+ }
548
+ if (input.orgUrl) {
549
+ headers["X-Org-Url"] = input.orgUrl;
550
+ }
444
551
  }
445
552
  if (input.extra) {
446
553
  for (const [key, value] of Object.entries(input.extra)) {
@@ -463,6 +570,38 @@ async function resolveBearerToken(config) {
463
570
  if (typeof source === "function") return await source() ?? void 0;
464
571
  return source ?? void 0;
465
572
  }
573
+ async function resolveDispatchToken(dispatch) {
574
+ const token = (await dispatch.token())?.trim();
575
+ if (!token) {
576
+ throw new FoirRequestError(
577
+ "[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`."
578
+ );
579
+ }
580
+ return token;
581
+ }
582
+ async function resolveFoirAuth(config) {
583
+ if (config.dispatch !== void 0) {
584
+ return {
585
+ headers: buildFoirHeaders({
586
+ dispatchToken: await resolveDispatchToken(config.dispatch),
587
+ dispatchContext: config.dispatch.context ?? void 0,
588
+ extra: config.headers
589
+ })
590
+ };
591
+ }
592
+ const bearerToken = await resolveBearerToken(config);
593
+ return {
594
+ headers: buildFoirHeaders({
595
+ apiKey: config.apiKey,
596
+ draftChannel: config.channel === "draft",
597
+ bearerToken,
598
+ orgId: config.orgId,
599
+ orgUrl: config.orgUrl,
600
+ extra: config.headers
601
+ }),
602
+ key: { apiKey: config.apiKey, bearerToken }
603
+ };
604
+ }
466
605
 
467
606
  // src/data/ws-transport.ts
468
607
  var GQL = {
@@ -500,6 +639,11 @@ var FoirSocketTransport = class {
500
639
  keepAlive = null;
501
640
  retryTimer = null;
502
641
  constructor(config) {
642
+ if (config.dispatch !== void 0) {
643
+ throw new FoirRequestError(
644
+ "[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."
645
+ );
646
+ }
503
647
  this.config = config;
504
648
  this.policy = { ...DEFAULT_RECONNECT, ...config.reconnect ?? {} };
505
649
  }
@@ -571,13 +715,7 @@ var FoirSocketTransport = class {
571
715
  async sendConnectionInit(socket) {
572
716
  this.connecting = false;
573
717
  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
- });
718
+ const { headers } = await resolveFoirAuth(this.config);
581
719
  const { "Content-Type": _contentType, ...params } = headers;
582
720
  const extra = await this.config.connectionParams?.() ?? {};
583
721
  this.send(socket, {
@@ -684,7 +822,7 @@ var FoirSocketTransport = class {
684
822
  return;
685
823
  }
686
824
  case GQL.error: {
687
- const error = new FoirRequestError(`[foir] subscription refused: ${describe(message.payload)}`, {
825
+ const error = new FoirRequestError(`[foir] subscription refused: ${describe2(message.payload)}`, {
688
826
  graphQLErrors: graphQLErrorItems(message.payload)
689
827
  });
690
828
  if (!message.id) {
@@ -805,17 +943,17 @@ function graphQLErrorItems(payload) {
805
943
  const { message, path, extensions } = entry;
806
944
  if (extensions === void 0 && path === void 0) continue;
807
945
  items.push({
808
- message: typeof message === "string" ? message : describe(entry),
946
+ message: typeof message === "string" ? message : describe2(entry),
809
947
  ...Array.isArray(path) ? { path } : {},
810
948
  ...typeof extensions === "object" && extensions !== null ? { extensions } : {}
811
949
  });
812
950
  }
813
951
  return items.length > 0 ? items : void 0;
814
952
  }
815
- function describe(payload) {
953
+ function describe2(payload) {
816
954
  if (payload == null) return "no reason given";
817
955
  if (typeof payload === "string") return payload;
818
- if (Array.isArray(payload)) return payload.map(describe).join("; ");
956
+ if (Array.isArray(payload)) return payload.map(describe2).join("; ");
819
957
  if (typeof payload === "object") {
820
958
  const message = payload.message;
821
959
  if (typeof message === "string") return message;
@@ -1089,6 +1227,7 @@ export {
1089
1227
  FOIR_EVENTS_SCOPE,
1090
1228
  FoirEventCursorExpiredError,
1091
1229
  FoirRequestError,
1230
+ OperationPayloadError,
1092
1231
  ScopedTokenError,
1093
1232
  ServerClientError,
1094
1233
  createCallbackClient,
@@ -1101,6 +1240,7 @@ export {
1101
1240
  isFoirUnauthenticated,
1102
1241
  parseContextHeader,
1103
1242
  parseSubject,
1243
+ readDispatch,
1104
1244
  recordsReadModels,
1105
1245
  recordsWriteModels,
1106
1246
  requireScopedToken,
@@ -30,16 +30,17 @@ export interface FoirEnv {
30
30
  * Returns true when `FOIR_PREVIEW=true` is set in the environment.
31
31
  *
32
32
  * Preview is selected at the environment level, not per-request: load a
33
- * preview env file that sets `FOIR_PREVIEW=true` + a `sk_…`
34
- * `PLATFORM_PREVIEW_API_KEY` with `drafts:read` instead of the default env.
33
+ * preview env file that sets `FOIR_PREVIEW=true` + the preview environment's
34
+ * own `pk_…` key holding `drafts:read` as `PLATFORM_PREVIEW_API_KEY`, instead
35
+ * of the default env.
35
36
  * The site-wide mode keeps draft semantics consistent — every loader sees
36
37
  * the same preview/published view of the world. Intended for non-production
37
38
  * surfaces only.
38
39
  */
39
40
  export declare function isFoirPreview(env: FoirEnv): boolean;
40
41
  /**
41
- * The api key a request should carry for this env: the `sk_` preview key when
42
- * `FOIR_PREVIEW=true` (it is the one with `drafts:read`), the `pk_` key
42
+ * The api key a request should carry for this env: the preview key when
43
+ * `FOIR_PREVIEW=true` (it is the one with `drafts:read`), the production key
43
44
  * otherwise. Returns `undefined` when neither is configured, so the caller can
44
45
  * raise the "add them to .env" error with its own wording.
45
46
  */
@@ -230,6 +231,12 @@ export interface FoirRequestOptions {
230
231
  * covers what the request is about.
231
232
  */
232
233
  orgId?: string;
234
+ /**
235
+ * The organization this request acts in, named by the address of the page it
236
+ * is for, sent as `X-Org-Url`. See `FoirClientConfig.orgUrl` for the full
237
+ * contract. With `orgId` as well, the two must name the same organization.
238
+ */
239
+ orgUrl?: string;
233
240
  /**
234
241
  * Called with the `X-RateLimit-*` headers of every response, successful or
235
242
  * not, and with `undefined` when a response carried none.
@@ -252,7 +259,8 @@ export interface FoirRequestOptions {
252
259
  * they would end up accepting subtly different things.
253
260
  */
254
261
  export type FoirTokenProvider = () => string | null | undefined | Promise<string | null | undefined>;
255
- export interface FoirHeaderInput {
262
+ /** A request made with an api key, optionally for a signed-in customer. */
263
+ export interface FoirKeyHeaderInput {
256
264
  apiKey: string;
257
265
  /**
258
266
  * Opt into the draft *schema* channel (`X-Foir-Schema-Channel: draft`) so
@@ -270,14 +278,56 @@ export interface FoirHeaderInput {
270
278
  * spells the header in `extra` still wins, because `extra` is applied last.
271
279
  */
272
280
  orgId?: string;
281
+ /**
282
+ * The organization the request acts in, named by a page URL (`X-Org-Url`).
283
+ * Set from `FoirClientConfig.orgUrl` / `FoirRequestOptions.orgUrl`.
284
+ */
285
+ orgUrl?: string;
286
+ dispatchToken?: never;
287
+ dispatchContext?: never;
273
288
  /** Caller-supplied extras, applied last so a caller can override. */
274
289
  extra?: Readonly<Record<string, string>>;
275
290
  }
291
+ /**
292
+ * A request made AS an operation, with the dispatch token its endpoint was
293
+ * handed.
294
+ *
295
+ * The token is the whole credential. api-public takes the scoped path when
296
+ * `X-Foir-Token` is present and never reads `x-api-key` on it, and the
297
+ * organization, the customer and the schema channel all come from the token's
298
+ * own claims, so none of the key-side inputs has anything to say here.
299
+ */
300
+ export interface FoirDispatchHeaderInput {
301
+ /** Sent as `X-Foir-Token`. */
302
+ dispatchToken: string;
303
+ /** The dispatch's `X-Foir-Context`, forwarded as it arrived. */
304
+ dispatchContext?: string;
305
+ apiKey?: never;
306
+ draftChannel?: never;
307
+ bearerToken?: never;
308
+ orgId?: never;
309
+ orgUrl?: never;
310
+ /**
311
+ * Caller-supplied extras, applied last. They may not name a credential
312
+ * header: see {@link refuseCredentialHeaders}.
313
+ */
314
+ extra?: Readonly<Record<string, string>>;
315
+ }
316
+ export type FoirHeaderInput = FoirKeyHeaderInput | FoirDispatchHeaderInput;
317
+ /**
318
+ * Refuse extra headers that would add a second credential to a request made as
319
+ * an operation. Throws naming the header; returns nothing when there is none.
320
+ */
321
+ export declare function refuseCredentialHeaders(extra: Readonly<Record<string, string>> | undefined): void;
276
322
  /**
277
323
  * The exact header set every Foir data-plane request carries. Single
278
324
  * definition on purpose: `/graphql` and `/cdn/graphql` authenticate identically,
279
325
  * and a header that exists on one surface but not the other is a per-surface
280
326
  * authorization difference nobody would notice until it mattered.
327
+ *
328
+ * Two credentials, and a request carries exactly one: an api key (with its
329
+ * optional customer bearer, organization and channel), or an operation's
330
+ * dispatch token. A dispatch request never writes `x-api-key`.
281
331
  */
282
332
  export declare function buildFoirHeaders(input: FoirHeaderInput): Record<string, string>;
283
333
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foir/sdk",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Official Foir SDK — editor iframe client + server-side platform helpers",
5
5
  "type": "module",
6
6
  "publishConfig": {