@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/react.js CHANGED
@@ -81,7 +81,12 @@ var FoirRequestError = class extends Error {
81
81
  * failed on the first attempt for an hour.
82
82
  */
83
83
  get retryable() {
84
- if (this.status === void 0) return true;
84
+ if (this.status === void 0) {
85
+ if (this.graphQLErrors?.length || this.prunedSelections?.length) {
86
+ return this.code === "RATE_LIMITED" || this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
87
+ }
88
+ return true;
89
+ }
85
90
  if (this.status === 429 || this.code === "RATE_LIMITED") return true;
86
91
  if (this.status >= 500) return true;
87
92
  return this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
@@ -95,19 +100,50 @@ var FoirRequestError = class extends Error {
95
100
  return typeof seconds === "number" && seconds > 0 ? seconds * 1e3 : void 0;
96
101
  }
97
102
  };
103
+ var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
104
+ "x-api-key",
105
+ "authorization",
106
+ "x-foir-token",
107
+ "x-foir-context"
108
+ ]);
109
+ function refuseCredentialHeaders(extra) {
110
+ if (!extra) return;
111
+ for (const name of Object.keys(extra)) {
112
+ if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
113
+ throw new FoirRequestError(
114
+ `[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.`
115
+ );
116
+ }
117
+ }
118
+ }
119
+ var FOIR_VISITOR_IP_HEADER = "X-Foir-Visitor-IP";
98
120
  function buildFoirHeaders(input) {
99
121
  const headers = {
100
- "Content-Type": "application/json",
101
- "x-api-key": input.apiKey
122
+ "Content-Type": "application/json"
102
123
  };
103
- if (input.draftChannel) {
104
- headers["X-Foir-Schema-Channel"] = "draft";
105
- }
106
- if (input.bearerToken) {
107
- headers["Authorization"] = `Bearer ${input.bearerToken}`;
108
- }
109
- if (input.orgId) {
110
- headers["X-Org-Id"] = input.orgId;
124
+ if (input.dispatchToken !== void 0) {
125
+ refuseCredentialHeaders(input.extra);
126
+ headers["X-Foir-Token"] = input.dispatchToken;
127
+ if (input.dispatchContext) {
128
+ headers["X-Foir-Context"] = input.dispatchContext;
129
+ }
130
+ } else {
131
+ headers["x-api-key"] = input.apiKey;
132
+ if (input.draftChannel) {
133
+ headers["X-Foir-Schema-Channel"] = "draft";
134
+ }
135
+ if (input.bearerToken) {
136
+ headers["Authorization"] = `Bearer ${input.bearerToken}`;
137
+ }
138
+ if (input.orgId) {
139
+ headers["X-Org-Id"] = input.orgId;
140
+ }
141
+ if (input.orgUrl) {
142
+ headers["X-Org-Url"] = input.orgUrl;
143
+ }
144
+ if (input.visitorAddress) {
145
+ headers[FOIR_VISITOR_IP_HEADER] = input.visitorAddress;
146
+ }
111
147
  }
112
148
  if (input.extra) {
113
149
  for (const [key, value] of Object.entries(input.extra)) {
@@ -123,6 +159,26 @@ function foirApiEndpoint(apiUrl, path) {
123
159
  }
124
160
  return base + path;
125
161
  }
162
+ var FoirAuthDowngradedError = class extends FoirRequestError {
163
+ downgrade;
164
+ constructor(downgrade) {
165
+ super(
166
+ `[foir] your customer token was ${downgrade.customerToken} (${downgrade.reason}), so the request was served as ${downgrade.servedAs}`,
167
+ { graphQLErrors: [{ message: "customer token rejected", extensions: { code: "AUTH_DOWNGRADED" } }] }
168
+ );
169
+ this.name = "FoirAuthDowngradedError";
170
+ this.downgrade = downgrade;
171
+ }
172
+ };
173
+ var FoirSessionExpiredError = class extends FoirRequestError {
174
+ constructor(options) {
175
+ super("[foir] the customer session has expired and could not be refreshed: sign in again", {
176
+ cause: options?.cause,
177
+ graphQLErrors: [{ message: "session expired", extensions: { code: "SESSION_EXPIRED" } }]
178
+ });
179
+ this.name = "FoirSessionExpiredError";
180
+ }
181
+ };
126
182
  var warnedDowngrade = null;
127
183
  function reportAuthDowngrade(envelope) {
128
184
  const downgrade = envelope.extensions?.auth;
@@ -151,12 +207,18 @@ function refusePrunedDocument(json, options) {
151
207
  `[foir] selections stripped from this query: ${selections.map(describe).join(", ")}`
152
208
  );
153
209
  }
210
+ function answeredAnything(data) {
211
+ return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
212
+ }
154
213
  function readFoirEnvelope(json, options) {
214
+ if (options?.rejectDowngrade && json.extensions?.auth) {
215
+ throw new FoirAuthDowngradedError(json.extensions.auth);
216
+ }
155
217
  reportAuthDowngrade(json);
156
218
  refusePrunedDocument(json, options);
157
219
  if (json.errors?.length) {
158
220
  const msg = json.errors.map((e) => e.message).join("; ");
159
- if (json.data && !options?.strict) {
221
+ if (answeredAnything(json.data) && !options?.strict) {
160
222
  console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
161
223
  } else {
162
224
  throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
@@ -670,15 +732,54 @@ function operationName(scope, op) {
670
732
  function foirDataEndpoint(apiUrl, path) {
671
733
  return foirApiEndpoint(apiUrl, path);
672
734
  }
735
+ async function resolveVisitorAddress(config) {
736
+ const source = config.visitor;
737
+ const value = typeof source === "function" ? await source() : source;
738
+ const address = value?.trim();
739
+ return address ? address : void 0;
740
+ }
673
741
  async function resolveBearerToken(config) {
674
742
  const source = config.bearerToken;
675
743
  if (typeof source === "function") return await source() ?? void 0;
676
744
  return source ?? void 0;
677
745
  }
746
+ async function resolveDispatchToken(dispatch) {
747
+ const token = (await dispatch.token())?.trim();
748
+ if (!token) {
749
+ throw new FoirRequestError(
750
+ "[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`."
751
+ );
752
+ }
753
+ return token;
754
+ }
755
+ async function resolveFoirAuth(config) {
756
+ if (config.dispatch !== void 0) {
757
+ return {
758
+ headers: buildFoirHeaders({
759
+ dispatchToken: await resolveDispatchToken(config.dispatch),
760
+ dispatchContext: config.dispatch.context ?? void 0,
761
+ extra: config.headers
762
+ })
763
+ };
764
+ }
765
+ const bearerToken = await resolveBearerToken(config);
766
+ return {
767
+ headers: buildFoirHeaders({
768
+ apiKey: config.apiKey,
769
+ draftChannel: config.channel === "draft",
770
+ bearerToken,
771
+ orgId: config.orgId,
772
+ orgUrl: config.orgUrl,
773
+ visitorAddress: await resolveVisitorAddress(config),
774
+ extra: config.headers
775
+ }),
776
+ key: { apiKey: config.apiKey, bearerToken }
777
+ };
778
+ }
678
779
  function foirRoute(input) {
679
780
  if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
680
781
  if (input.bearerToken) return { lane: "origin", reason: "bearer" };
681
- if (input.orgId) return { lane: "origin", reason: "org" };
782
+ if (input.orgId || input.orgUrl) return { lane: "origin", reason: "org" };
682
783
  if (input.mutation) return { lane: "origin", reason: "operation" };
683
784
  if (input.writeToken) return { lane: "origin", reason: "floor" };
684
785
  if (input.channel === "draft") return { lane: "origin", reason: "channel" };
@@ -693,25 +794,45 @@ async function executeFoirDocument(config, plan) {
693
794
  }
694
795
  return data[plan.rootField];
695
796
  }
797
+ var renewing = /* @__PURE__ */ new WeakMap();
798
+ function renewBearerOnce(refresh) {
799
+ const running = renewing.get(refresh);
800
+ if (running) return running;
801
+ const next = refresh().finally(() => renewing.delete(refresh));
802
+ renewing.set(refresh, next);
803
+ return next;
804
+ }
696
805
  async function runFoirDocument(config, document2, mutation, variables, options) {
806
+ try {
807
+ return await sendFoirDocument(config, document2, mutation, variables, options);
808
+ } catch (err) {
809
+ const refresh = config.refreshBearer;
810
+ if (!(err instanceof FoirAuthDowngradedError) || !refresh || err.downgrade.reason === "wrong_project") {
811
+ throw err;
812
+ }
813
+ const token = err.downgrade.reason === "expired" ? await renewBearerOnce(refresh) : null;
814
+ if (!token) throw new FoirSessionExpiredError({ cause: err });
815
+ try {
816
+ return await sendFoirDocument({ ...config, bearerToken: token }, document2, mutation, variables, options);
817
+ } catch (again) {
818
+ if (again instanceof FoirAuthDowngradedError) throw new FoirSessionExpiredError({ cause: again });
819
+ throw again;
820
+ }
821
+ }
822
+ }
823
+ async function sendFoirDocument(config, document2, mutation, variables, options) {
697
824
  const doFetch = config.fetch ?? globalThis.fetch;
698
825
  const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
699
- const bearerToken = await resolveBearerToken(config);
700
- const headers = buildFoirHeaders({
701
- apiKey: config.apiKey,
702
- draftChannel: config.channel === "draft",
703
- bearerToken,
704
- orgId: config.orgId,
705
- extra: config.headers
706
- });
826
+ const { headers, key } = await resolveFoirAuth(config);
707
827
  const hash = await documentHash(document2);
708
- const route = foirRoute({
709
- apiKey: config.apiKey,
710
- bearerToken,
828
+ const sharedKey = key !== void 0 && foirRoute({
829
+ apiKey: key.apiKey,
830
+ bearerToken: key.bearerToken,
711
831
  orgId: config.orgId,
832
+ orgUrl: config.orgUrl,
712
833
  mutation,
713
834
  channel: config.channel
714
- });
835
+ }).lane === "shared" ? key.apiKey : void 0;
715
836
  const post = (body) => doFetch(endpoint, {
716
837
  method: "POST",
717
838
  headers,
@@ -721,8 +842,8 @@ async function runFoirDocument(config, document2, mutation, variables, options)
721
842
  const extensions = persistedQueryExtension(hash);
722
843
  let res;
723
844
  try {
724
- if (route.lane === "shared") {
725
- res = await doFetch(buildCdnUrl(endpoint, config.apiKey, hash, variables), {
845
+ if (sharedKey !== void 0) {
846
+ res = await doFetch(buildCdnUrl(endpoint, sharedKey, hash, variables), {
726
847
  method: "GET",
727
848
  headers,
728
849
  ...config.requestInit
@@ -748,7 +869,7 @@ async function runFoirDocument(config, document2, mutation, variables, options)
748
869
  });
749
870
  }
750
871
  const json = await res.json();
751
- return readFoirEnvelope(json, options);
872
+ return readFoirEnvelope(json, { ...options, rejectDowngrade: Boolean(key?.bearerToken) });
752
873
  }
753
874
  async function readErrorBody(res) {
754
875
  try {
@@ -760,20 +881,16 @@ async function readErrorBody(res) {
760
881
  }
761
882
 
762
883
  // src/data/manifest.ts
763
- async function fetchFoirManifest(config) {
764
- const url = foirDataEndpoint(config.apiUrl, "/v1/manifest");
884
+ async function fetchFoirManifest(config, options = {}) {
885
+ const base = foirDataEndpoint(config.apiUrl, "/v1/manifest");
886
+ const url = options.operation === void 0 ? base : `${base}?operation=${encodeURIComponent(options.operation)}`;
765
887
  const doFetch = config.fetch ?? globalThis.fetch;
888
+ const { headers } = await resolveFoirAuth(config);
766
889
  let res;
767
890
  try {
768
891
  res = await doFetch(url, {
769
892
  method: "GET",
770
- headers: buildFoirHeaders({
771
- apiKey: config.apiKey,
772
- draftChannel: config.channel === "draft",
773
- bearerToken: await resolveBearerToken(config),
774
- orgId: config.orgId,
775
- extra: config.headers
776
- }),
893
+ headers,
777
894
  ...config.requestInit
778
895
  });
779
896
  } catch (err) {
@@ -845,6 +962,7 @@ function authClientConfig(env, options) {
845
962
  channel: isFoirPreview(env) ? "draft" : "published",
846
963
  bearerToken: options?.bearerToken,
847
964
  orgId: options?.orgId,
965
+ orgUrl: options?.orgUrl,
848
966
  headers: options?.headers,
849
967
  fetch: options?.fetch
850
968
  };
@@ -917,6 +1035,9 @@ async function getAuthProviders(env) {
917
1035
  async function saveSession(adapter, result) {
918
1036
  await adapter.set(TOKEN_KEY, result.token);
919
1037
  await adapter.set(REFRESH_KEY, result.refreshToken);
1038
+ if (!result.customerId) {
1039
+ return await getSessionCustomer(adapter) ?? { id: "", email: "" };
1040
+ }
920
1041
  await adapter.set(CUSTOMER_ID_KEY, result.customerId);
921
1042
  await adapter.set(EMAIL_KEY, result.email);
922
1043
  return { id: result.customerId, email: result.email };
@@ -17,14 +17,21 @@
17
17
  *
18
18
  * Each call carries:
19
19
  * - `Authorization: Bearer <callback.token>` — Ed25519 scoped token.
20
- * - `X-Foir-Signature: sha256=<hex>` — HMAC of the raw request body
21
- * keyed on the project's `operations.signing_secret`. The secret is
22
- * out-of-band (CLI publishes it to the extension on push); the SDK
23
- * only handles the HMAC computation.
20
+ * - `X-Foir-Signature: sha256=<hex>` — HMAC of the raw request body, keyed
21
+ * on the run's own key (`callback.signingKey`), which the platform makes
22
+ * for each run and sends with it. An endpoint that still holds the
23
+ * project's signing secret may pass it as `signingSecret` instead; the
24
+ * platform accepts either.
25
+ *
26
+ * A call that fails before the platform answers, or that the platform answers
27
+ * with a 5xx or a 429, is tried again: a report that is lost leaves the run to
28
+ * be closed by the platform's watchdog as timed out, and every one of these
29
+ * calls is safe to repeat.
24
30
  *
25
31
  * Both response body shape and content-type follow the GraphQL spec —
26
32
  * extensions importing this client receive typed result data.
27
33
  */
34
+ import { type FoirRetryPolicy } from '../transport/retry';
28
35
  import type { OperationCallback } from './operation-types.js';
29
36
  /** Shape of a successful response from any callback mutation. */
30
37
  export interface CallbackResult {
@@ -78,15 +85,29 @@ export interface CallbackClient<TResult = unknown> {
78
85
  * `callback`; pass them together so the SDK doesn't have to re-parse the
79
86
  * payload structure on every call.
80
87
  *
81
- * `signingSecret` lives on the extension (set as `FOIR_SIGNING_SECRET` or
82
- * equivalent env var). `foir push` writes it there from the project's
83
- * signing secret, so it's available before any callback runs.
88
+ * The signature is keyed on `callback.signingKey`, the run's own key, which
89
+ * the platform sends with every ASYNC dispatch. `signingSecret` is for an
90
+ * endpoint that holds the project's signing secret instead (`foir push`
91
+ * writes it to `FOIR_SIGNING_SECRET`); when it is passed it is the key used.
92
+ * One of the two must be there.
84
93
  *
85
94
  * `fetch` is overridable for testability and to support runtimes (CF
86
- * Workers, Node) where `fetch` may not be the global.
95
+ * Workers, Node) where `fetch` may not be the global. `retry` is how a call
96
+ * that got no answer, a 5xx or a 429 is tried again (three attempts unless
97
+ * told otherwise; `{ attempts: 1 }` turns it off).
98
+ *
99
+ * `apiUrl` is the Foir API origin the endpoint is configured with. The
100
+ * dispatch names the address to report to and that address is the one used; a
101
+ * platform with no public address of its own configured names none, and the
102
+ * run then reports to the data API at `apiUrl`.
87
103
  */
88
104
  export declare function createCallbackClient<TResult = unknown>(callback: OperationCallback, opts: {
89
105
  executionId: string;
90
- signingSecret: string;
106
+ signingSecret?: string;
107
+ /** The Foir API origin, used when the dispatch names no address to report to. */
108
+ apiUrl?: string;
91
109
  fetch?: typeof fetch;
110
+ retry?: FoirRetryPolicy;
111
+ /** Aborts a call in flight, and the wait before a retry is not started. */
112
+ signal?: AbortSignal;
92
113
  }): CallbackClient<TResult>;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * continueRun: answer one slice of an ASYNC run and ask to be called again.
3
+ *
4
+ * A run that takes longer than one call is cut into slices. Each dispatch does
5
+ * a bounded amount of work and then either answers `202 Accepted` and reports
6
+ * the outcome through the callback, as an ASYNC run always has, or answers a
7
+ * continuation: the cursor the next slice resumes from. The platform queues the
8
+ * next call, at once or after `delayMs`, and hands the cursor back as
9
+ * `continuation` on the dispatch body (`readDispatch` reads it). Each slice
10
+ * gets a fresh callback, so the one that finishes reports through its own.
11
+ *
12
+ * ```ts
13
+ * const payload = await readDispatch(request);
14
+ * const page = await syncOnePage(payload.continuation?.cursor);
15
+ * if (page.next) return continueRun({ cursor: page.next, progressPct: page.pct });
16
+ * await createCallbackClient(payload.callback!, { executionId, signingSecret }).complete(page.summary);
17
+ * return new Response(null, { status: 202 });
18
+ * ```
19
+ *
20
+ * Only an ASYNC run can continue: a caller waiting on a SYNC call cannot be
21
+ * answered by a later one, so the platform fails a SYNC call answered this
22
+ * way. A continuation is not a retry and does not spend the retry budget; a run
23
+ * takes at most 1000 slices, and one that asks for more is dead-lettered
24
+ * holding its last cursor, so a retry resumes from it.
25
+ *
26
+ * On the wire it is a 200 under {@link CONTINUATION_MEDIA_TYPE} with the
27
+ * continuation as the JSON body. The content type is what marks it, so a
28
+ * framework without fetch `Response` objects sends the same thing by setting
29
+ * that header and writing `JSON.stringify(continuation)`.
30
+ */
31
+ /** The content type a continuation is answered under. */
32
+ export declare const CONTINUATION_MEDIA_TYPE = "application/vnd.foir.continuation+json";
33
+ /** The longest cursor the platform keeps, in bytes. */
34
+ export declare const MAX_CONTINUATION_CURSOR_BYTES: number;
35
+ /** The longest wait a continuation may ask for, in milliseconds. */
36
+ export declare const MAX_CONTINUATION_DELAY_MS: number;
37
+ /** What a slice answers when the run is not finished. */
38
+ export interface Continuation {
39
+ /** Where the next slice resumes. Opaque to the platform, never empty. */
40
+ cursor: string;
41
+ /**
42
+ * How long to wait before the next slice, for a slice waiting on a third
43
+ * party, such as an export that finishes later. At most 15 minutes.
44
+ */
45
+ delayMs?: number;
46
+ /** Progress shown on the execution while it runs, 0 to 100. */
47
+ progressPct?: number;
48
+ /** A line shown beside the progress. */
49
+ progressMessage?: string;
50
+ }
51
+ /**
52
+ * Build the continuation answer. Throws, before anything is sent, on a
53
+ * continuation the platform would refuse, so the mistake surfaces in the
54
+ * endpoint rather than as a failed run.
55
+ */
56
+ export declare function continueRun(continuation: Continuation): Response;
@@ -1,13 +1,16 @@
1
1
  export { createServerClient, ServerClientError } from './server-client';
2
2
  export { verifyWebhookSignature } from './webhook-helpers';
3
3
  export { verifyScopedToken, parseContextHeader, parseSubject, recordsReadModels, recordsWriteModels, type ScopedTokenClaims, type ScopedTokenSubject, type VerifyScopedTokenOptions, } from './verify-scoped-token';
4
- export { requireScopedToken, ScopedTokenError, type RequireScopedTokenOptions, type ScopedTokenIngress, } from './require-scoped-token';
4
+ export { createScopedTokenVerifier, ScopedTokenUnavailableError, type ScopedTokenVerifier, type ScopedTokenVerifierOptions, type ScopedTokenVerifierStats, } from './verify-scoped-token';
5
+ export { requireScopedToken, ScopedTokenError, FOIR_TOKEN_HEADER, FOIR_CONTEXT_HEADER, type RequireScopedTokenOptions, type ScopedTokenIngress, } from './require-scoped-token';
6
+ export { readDispatch, OperationPayloadError, type BodyCarrier, } from './read-dispatch';
5
7
  export type { ServerClient, ServerClientOptions, GraphQLResponse, EditorPlacement, } from './types';
6
- export type { OperationPayload, OperationTrigger, OperationRecordContext, OperationContext, OperationResult, OperationCallback, } from './operation-types';
8
+ export type { OperationPayload, OperationTrigger, OperationRecordContext, OperationContext, OperationResult, OperationCallback, OperationContinuationBlock, } from './operation-types';
9
+ export { continueRun, CONTINUATION_MEDIA_TYPE, MAX_CONTINUATION_CURSOR_BYTES, MAX_CONTINUATION_DELAY_MS, type Continuation, } from './continue-run';
7
10
  export { createCallbackClient } from './callback-client';
8
11
  export type { CallbackClient, CallbackResult } from './callback-client';
9
12
  export { createEventsClient, FoirEventCursorExpiredError, isFoirEventCursorExpired, FOIR_EVENTS_PATH, FOIR_EVENTS_SCOPE, FOIR_EVENTS_RETENTION_DAYS, } from './events-client';
10
13
  export type { FoirEvent, FoirEventActor, FoirEventPage, FoirEventRetention, FoirEventsClient, FoirEventsClientConfig, FoirEventsListParams, FoirEventsStreamParams, FoirEventsSubscribeParams, } from './events-client';
11
14
  export type { FoirSubscription, FoirSubscriptionHandlers } from '../data/ws-transport';
12
- export { FoirRequestError, isFoirForbidden, isFoirNotFound, isFoirRateLimited, isFoirUnauthenticated, } from '../transport/foir-http';
15
+ export { FoirRequestError, FoirAuthDowngradedError, FoirSessionExpiredError, isFoirForbidden, isFoirNotFound, isFoirRateLimited, isFoirUnauthenticated, } from '../transport/foir-http';
13
16
  export type { FoirErrorCode, FoirErrorExtensions, FoirGraphQLErrorItem, FoirAuthDowngrade, } from '../transport/foir-http';
@@ -1,54 +1,98 @@
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;
70
+ /**
71
+ * Where this slice of a run resumes. Present when the previous slice answered
72
+ * with `continueRun`: the cursor it answered, and which slice this is. Slice
73
+ * 0 is a run a dead-letter retry resumes. Absent on a run's first call.
74
+ */
75
+ continuation?: OperationContinuationBlock;
76
+ }
77
+ /** The dispatch body's continuation block (see `continueRun`). */
78
+ export interface OperationContinuationBlock {
79
+ /** The cursor the previous slice answered, as it wrote it. */
80
+ cursor: string;
81
+ /** Which slice of the run this is. */
82
+ slice: number;
41
83
  }
42
84
  /**
43
- * Callback context the platform hands the extension when it dispatches an
44
- * async-callback operation. Everything the extension needs to call back is
45
- * here — endpoint, auth token, signing secret reference (the secret itself
46
- * lives on the extension, set out of band; the platform only sends the
47
- * token), and the per-op mutation names so the extension doesn't need to
48
- * derive them from the operation key.
85
+ * Callback context the platform hands the endpoint when it dispatches an
86
+ * ASYNC operation. Everything the endpoint needs to call back is here: the
87
+ * address, the token, the key this run signs with, and the per-operation
88
+ * mutation names, so the endpoint derives none of them from the operation key.
49
89
  */
50
90
  export interface OperationCallback {
51
- /** api-public GraphQL endpoint, e.g. https://api.foir.dev/graphql */
91
+ /**
92
+ * The address to report to, e.g. https://api.foir.dev/graphql. Empty when the
93
+ * platform that dispatched has no public address of its own configured;
94
+ * `createCallbackClient` then reports to the `apiUrl` it is given.
95
+ */
52
96
  gqlEndpoint: string;
53
97
  /** Bearer token for the Authorization header (Ed25519, single execution scope) */
54
98
  token: string;
@@ -61,53 +105,58 @@ export interface OperationCallback {
61
105
  progress: string;
62
106
  cancel: string;
63
107
  };
108
+ /**
109
+ * The operation keys that led to this run, its own last. Present when the
110
+ * run was caused by another operation's work.
111
+ */
112
+ causationChain?: string[];
113
+ /**
114
+ * The key this run signs its callbacks with, made by the platform for this
115
+ * run alone. `createCallbackClient` signs with it, so an endpoint holds no
116
+ * signing secret of its own. Treat it as a secret: never log or store it.
117
+ */
118
+ signingKey?: string;
64
119
  }
65
120
  /**
66
- * Trigger information describing how the operation was invoked.
121
+ * How a run was started. The four values are the platform's own; a caller that
122
+ * names none is recorded as `api`.
123
+ *
124
+ * - `ui`: a console surface that names itself as the trigger, such as an app's
125
+ * configure page.
126
+ * - `api`: an `execute<Op>` call, or any caller that named no trigger.
127
+ * - `lifecycle`: a hook on a platform event (a record published, an operation
128
+ * completed).
129
+ * - `schedule`: a cron schedule firing.
67
130
  */
68
131
  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;
132
+ type: 'ui' | 'api' | 'lifecycle' | 'schedule';
75
133
  }
76
134
  /**
77
- * Record context provided when operation is triggered by a record event.
135
+ * The record block a caller attached to a run, forwarded as the caller wrote
136
+ * it. The platform does not shape it, so neither does this type.
78
137
  */
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
- }
138
+ export type OperationRecordContext = Record<string, unknown>;
96
139
  /**
97
- * Platform context provided to all operations.
140
+ * The tenant, project and principal a run belongs to.
98
141
  */
99
142
  export interface OperationContext {
100
143
  /** Tenant ID */
101
144
  tenantId: string;
102
145
  /** Project ID */
103
146
  projectId: string;
104
- /** User ID (if user-triggered) */
105
- userId?: string;
106
- /** Customer ID (if customer-scoped operation) */
147
+ /**
148
+ * The principal that started the run. Always present, and the empty string
149
+ * when the run has none, as a hook's run does.
150
+ */
151
+ userId: string;
152
+ /**
153
+ * The customer the run acts for, when it acts for one: the signed-in
154
+ * customer who ran it, or the owner of the record whose event fired the
155
+ * hook. The dispatch token carries the same customer, so a client built from
156
+ * it acts for them too.
157
+ */
107
158
  customerId?: string;
108
- /** Locale code (if locale-specific) */
109
- locale?: string;
110
- /** Timestamp when operation was triggered */
159
+ /** When the platform built this body, RFC 3339 in UTC to the second. */
111
160
  timestamp: string;
112
161
  }
113
162
  /**