@foir/sdk 0.30.0 → 0.31.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.
@@ -83,28 +83,21 @@ export interface OperationContinuationBlock {
83
83
  }
84
84
  /**
85
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.
86
+ * ASYNC operation. Everything the endpoint needs to reply is here: where, the
87
+ * token, and the key this run signs with.
89
88
  */
90
89
  export interface OperationCallback {
91
90
  /**
92
- * The address to report to, e.g. https://api.foir.dev/graphql. Empty when the
91
+ * Where the run replies: its own reply route, e.g.
92
+ * https://api.foir.dev/v1/steps/<executionId>/reply. Empty when the
93
93
  * platform that dispatched has no public address of its own configured;
94
- * `createCallbackClient` then reports to the `apiUrl` it is given.
94
+ * `createCallbackClient` then replies at the `apiUrl` it is given.
95
95
  */
96
- gqlEndpoint: string;
96
+ endpoint: string;
97
97
  /** Bearer token for the Authorization header (Ed25519, single execution scope) */
98
98
  token: string;
99
99
  /** RFC3339 expiry — extensions can short-circuit work past this. */
100
100
  expiresAt: string;
101
- /** Per-op typed mutation names the extension should call back with. */
102
- mutations: {
103
- complete: string;
104
- fail: string;
105
- progress: string;
106
- cancel: string;
107
- };
108
101
  /**
109
102
  * The operation keys that led to this run, its own last. Present when the
110
103
  * run was caused by another operation's work.
package/dist/server.js CHANGED
@@ -730,8 +730,11 @@ function stepUpRequired(error) {
730
730
  if (!(error instanceof FoirRequestError)) return null;
731
731
  for (const item of error.graphQLErrors ?? []) {
732
732
  if (item.extensions?.code !== "STEP_UP_REQUIRED") continue;
733
- const asked = item.extensions.stepUp;
734
- if (asked === "second_factor" || asked === "sign_in_method") return asked;
733
+ const kind = item.extensions.stepUp;
734
+ if (kind === "proof" || kind === "second_factor" || kind === "sign_in_method") {
735
+ const url = item.extensions.stepUpUrl;
736
+ return { kind, url: typeof url === "string" && url !== "" ? url : null };
737
+ }
735
738
  }
736
739
  return null;
737
740
  }
@@ -952,131 +955,96 @@ async function withFoirRetry(fn, policy = {}) {
952
955
  }
953
956
  }
954
957
 
958
+ // src/runs/step-reply.ts
959
+ async function replyToStep(reply, body, opts = {}) {
960
+ if (!reply?.token || !reply.signingKey || !reply.endpoint) {
961
+ throw new Error("replyToStep: the step was handed no reply, so it cannot answer");
962
+ }
963
+ return sendReply(reply.endpoint, reply.token, reply.signingKey, body, opts);
964
+ }
965
+ async function sendReply(endpoint, token, signingKey, body, opts = {}) {
966
+ const fetchImpl = opts.fetch ?? globalThis.fetch;
967
+ if (!fetchImpl) throw new Error("replyToStep: no fetch available; pass opts.fetch on runtimes without a global fetch");
968
+ const payload = JSON.stringify(body);
969
+ const signature = await hmacSha256Hex(signingKey, payload);
970
+ return withFoirRetry(async () => {
971
+ let resp;
972
+ try {
973
+ resp = await fetchImpl(endpoint, {
974
+ method: "POST",
975
+ headers: {
976
+ "Content-Type": "application/json",
977
+ Authorization: `Bearer ${token}`,
978
+ "X-Foir-Signature": `sha256=${signature}`
979
+ },
980
+ body: payload,
981
+ ...opts.signal ? { signal: opts.signal } : {}
982
+ });
983
+ } catch (err) {
984
+ if (opts.signal?.aborted) throw err;
985
+ throw new FoirRequestError("the reply got no answer", { cause: err });
986
+ }
987
+ if (!resp.ok) {
988
+ throw new FoirRequestError(`the reply was refused with HTTP ${resp.status}: ${await resp.text()}`, { status: resp.status });
989
+ }
990
+ const out = await resp.json();
991
+ if (!out || typeof out.status !== "string") {
992
+ throw new FoirRequestError("the reply was answered with nothing", { status: resp.status });
993
+ }
994
+ return { status: out.status, cancelled: out.cancelled === true, applied: out.applied === true };
995
+ }, opts.retry);
996
+ }
997
+ async function hmacSha256Hex(secret, body) {
998
+ const enc = new TextEncoder();
999
+ const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
1000
+ const sig = await crypto.subtle.sign("HMAC", key, enc.encode(body));
1001
+ return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
1002
+ }
1003
+
955
1004
  // src/server/callback-client.ts
956
1005
  function createCallbackClient(callback, opts) {
957
1006
  const { executionId } = opts;
958
- const fetchImpl = opts.fetch ?? globalThis.fetch;
959
- if (!fetchImpl) {
960
- throw new Error(
961
- "createCallbackClient: no fetch available; pass opts.fetch on runtimes without a global fetch"
962
- );
963
- }
964
1007
  if (!executionId) throw new Error("createCallbackClient: executionId is required");
965
- if (!callback?.token || !callback?.mutations) {
1008
+ if (!callback?.token) {
966
1009
  throw new Error("createCallbackClient: callback block is missing required fields");
967
1010
  }
968
- if (!callback.gqlEndpoint && !opts.apiUrl) {
1011
+ if (!callback.endpoint && !opts.apiUrl) {
969
1012
  throw new Error(
970
- "createCallbackClient: nowhere to report to. The dispatch named no callback.gqlEndpoint and no apiUrl was passed."
1013
+ "createCallbackClient: nowhere to reply. The dispatch named no callback.endpoint and no apiUrl was passed."
971
1014
  );
972
1015
  }
973
- const endpoint = callback.gqlEndpoint || foirApiEndpoint(opts.apiUrl ?? "", "/graphql");
1016
+ const endpoint = callback.endpoint || foirApiEndpoint(opts.apiUrl ?? "", `/v1/steps/${encodeURIComponent(executionId)}/reply`);
974
1017
  const signingSecret = opts.signingSecret || callback.signingKey;
975
1018
  if (!signingSecret) {
976
1019
  throw new Error(
977
1020
  "createCallbackClient: nothing to sign with. The dispatch carried no callback.signingKey and no signingSecret was passed."
978
1021
  );
979
1022
  }
980
- const post = async (mutationName, fields, variables) => {
981
- const query = `mutation ${mutationName}Mutation($id: String!${fieldArgsForMutation(mutationName, variables)}) { ${mutationName}(id: $id${fieldArgsCallSite(mutationName, variables)}) { ${fields} } }`;
982
- const body = JSON.stringify({
983
- query,
984
- variables: { id: executionId, ...variables }
985
- });
986
- const signature = await hmacSha256Hex(signingSecret, body);
987
- return withFoirRetry(async () => {
988
- let resp;
989
- try {
990
- resp = await fetchImpl(endpoint, {
991
- method: "POST",
992
- headers: {
993
- "Content-Type": "application/json",
994
- Authorization: `Bearer ${callback.token}`,
995
- "X-Foir-Signature": `sha256=${signature}`
996
- },
997
- body,
998
- ...opts.signal ? { signal: opts.signal } : {}
999
- });
1000
- } catch (err) {
1001
- if (opts.signal?.aborted) throw err;
1002
- throw new FoirRequestError(`callback ${mutationName} got no answer`, { cause: err });
1003
- }
1004
- if (!resp.ok) {
1005
- throw new FoirRequestError(
1006
- `callback ${mutationName} failed with HTTP ${resp.status}: ${await resp.text()}`,
1007
- { status: resp.status }
1008
- );
1009
- }
1010
- const json = await resp.json();
1011
- if (json.errors?.length) {
1012
- throw new FoirRequestError(`callback ${mutationName} GraphQL error: ${json.errors[0].message}`, {
1013
- status: resp.status,
1014
- graphQLErrors: json.errors
1015
- });
1016
- }
1017
- const out = json.data?.[mutationName];
1018
- if (!out) {
1019
- throw new FoirRequestError(`callback ${mutationName} returned no data`, { status: resp.status });
1020
- }
1021
- return out;
1022
- }, opts.retry);
1023
- };
1023
+ const send = (body) => sendReply(endpoint, callback.token, signingSecret, body, {
1024
+ ...opts.fetch ? { fetch: opts.fetch } : {},
1025
+ ...opts.retry ? { retry: opts.retry } : {},
1026
+ ...opts.signal ? { signal: opts.signal } : {}
1027
+ });
1024
1028
  return {
1025
- complete: (result) => post(callback.mutations.complete, "status cancelled", { result }),
1026
- fail: (code, message, options = {}) => post(callback.mutations.fail, "status cancelled", {
1027
- code,
1028
- message,
1029
- retryable: options.retryable,
1030
- details: options.details
1029
+ complete: (result) => send({ result: result ?? null }),
1030
+ fail: (code, message, options = {}) => send({
1031
+ failure: {
1032
+ code,
1033
+ message,
1034
+ ...options.retryable === void 0 ? {} : { retryable: options.retryable },
1035
+ ...options.details === void 0 ? {} : { details: options.details }
1036
+ }
1031
1037
  }),
1032
- progress: (update) => post(callback.mutations.progress, "status cancelled applied", {
1033
- pct: update.pct,
1034
- message: update.message,
1035
- metadata: update.metadata
1038
+ progress: (update) => send({
1039
+ progress: {
1040
+ ...update.pct === void 0 ? {} : { percent: update.pct },
1041
+ ...update.message === void 0 ? {} : { message: update.message },
1042
+ ...update.metadata === void 0 ? {} : { metadata: update.metadata }
1043
+ }
1036
1044
  }),
1037
- cancel: () => post(callback.mutations.cancel, "status cancelled", {})
1045
+ cancel: () => send({ cancel: {} })
1038
1046
  };
1039
1047
  }
1040
- function fieldArgsForMutation(mutationName, vars) {
1041
- const parts = [];
1042
- for (const key of Object.keys(vars)) {
1043
- if (vars[key] === void 0) continue;
1044
- parts.push(`, $${key}: ${graphQLTypeFor(mutationName, key)}`);
1045
- }
1046
- return parts.join("");
1047
- }
1048
- function fieldArgsCallSite(_mutationName, vars) {
1049
- const parts = [];
1050
- for (const key of Object.keys(vars)) {
1051
- if (vars[key] === void 0) continue;
1052
- parts.push(`, ${key}: $${key}`);
1053
- }
1054
- return parts.join("");
1055
- }
1056
- function graphQLTypeFor(mutationName, key) {
1057
- if (key === "code" || key === "message") return "String!";
1058
- if (key === "pct") return "Int";
1059
- if (key === "retryable") return "Boolean";
1060
- if (key === "details" || key === "metadata") return "JSON";
1061
- if (key === "result") {
1062
- const base = mutationName.replace(/^complete/, "").replace(/Execution$/, "");
1063
- return `${base}ExecutionResultInput!`;
1064
- }
1065
- if (key === "message") return "String";
1066
- return "String";
1067
- }
1068
- async function hmacSha256Hex(secret, body) {
1069
- const enc = new TextEncoder();
1070
- const key = await crypto.subtle.importKey(
1071
- "raw",
1072
- enc.encode(secret),
1073
- { name: "HMAC", hash: "SHA-256" },
1074
- false,
1075
- ["sign"]
1076
- );
1077
- const sig = await crypto.subtle.sign("HMAC", key, enc.encode(body));
1078
- return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
1079
- }
1080
1048
 
1081
1049
  // src/transport/key-kind.ts
1082
1050
  var KEY_PREFIXES = [
@@ -1836,6 +1804,7 @@ export {
1836
1804
  recordsCreateModels,
1837
1805
  recordsReadModels,
1838
1806
  recordsWriteModels,
1807
+ replyToStep,
1839
1808
  requireScopedToken,
1840
1809
  stepUpRequired,
1841
1810
  verifyScopedToken,
@@ -62,7 +62,17 @@ export type FoirErrorCode = 'INVALID_ARGUMENT' | 'NOT_FOUND' | 'ALREADY_EXISTS'
62
62
  * flow) and retry; `sign_in_method`, sign in again with a method the
63
63
  * organization the request stands in accepts.
64
64
  */
65
- export type FoirStepUp = 'second_factor' | 'sign_in_method';
65
+ export type FoirStepUp = 'proof' | 'second_factor' | 'sign_in_method';
66
+ /** A step-up a refusal asks for, and the hosted page to prove it on. */
67
+ export interface FoirStepUpAsk {
68
+ kind: FoirStepUp;
69
+ /**
70
+ * Fóir's hosted step-up page, where the refusal names one: send the user
71
+ * there with `return_to` added to its query, and ask again when they come
72
+ * back. Its token is in the fragment.
73
+ */
74
+ url: string | null;
75
+ }
66
76
  export interface FoirErrorExtensions {
67
77
  /** Stable machine-readable code. Branch on this, never on `message`. */
68
78
  code?: FoirErrorCode | string;
@@ -72,6 +82,8 @@ export interface FoirErrorExtensions {
72
82
  retryAfter?: number;
73
83
  /** On `STEP_UP_REQUIRED`: what the signed-in user must do before retrying. */
74
84
  stepUp?: FoirStepUp;
85
+ /** On `STEP_UP_REQUIRED`, where the refusal names one: the hosted step-up page. */
86
+ stepUpUrl?: string;
75
87
  [key: string]: unknown;
76
88
  }
77
89
  export interface FoirGraphQLErrorItem {
@@ -126,11 +138,13 @@ export interface FoirRateLimitSnapshot {
126
138
  }
127
139
  /**
128
140
  * What an error asks of the signed-in user before the request can succeed, or
129
- * null: an organization's sign-in rules turned the session away, because it
130
- * requires a second factor the session has not proven (`second_factor`) or does
131
- * not accept how the user signed in (`sign_in_method`).
141
+ * null: a change to their factors needs a fresh proof (`proof`), or an
142
+ * organization's sign-in rules turned the session away, because it requires a
143
+ * second factor the session has not proven (`second_factor`) or does not
144
+ * accept how the user signed in (`sign_in_method`). `url` is the hosted page
145
+ * to prove it on, where the refusal names one.
132
146
  */
133
- export declare function stepUpRequired(error: unknown): FoirStepUp | null;
147
+ export declare function stepUpRequired(error: unknown): FoirStepUpAsk | null;
134
148
  export declare class FoirRequestError extends Error {
135
149
  /** HTTP status code, when the request failed at the HTTP layer. */
136
150
  readonly status?: number;
@@ -224,7 +238,7 @@ export interface FoirRequestOptions {
224
238
  /**
225
239
  * User bearer token. api-public still needs the `x-api-key` to resolve
226
240
  * the project; the bearer adds the user identity for self-scoped reads
227
- * (e.g. `myProfile`). Used by `@foir/sdk/auth`'s post-login profile fetch.
241
+ * (e.g. `myProfile`). Used by `@foir/sdk/auth`'s signed-in profile fetch.
228
242
  */
229
243
  bearerToken?: string;
230
244
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foir/sdk",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Official Foir SDK — editor iframe client + server-side platform helpers",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -186,8 +186,8 @@
186
186
  "ws": "^8.18.0",
187
187
  "y-websocket": "^3.0.0",
188
188
  "yjs": "^13.6.30",
189
- "@foir/proto-ts": "0.109.0",
190
- "@foir/access-vocab": "0.0.0"
189
+ "@foir/access-vocab": "0.0.0",
190
+ "@foir/proto-ts": "0.109.0"
191
191
  },
192
192
  "engines": {
193
193
  "node": ">=18.0.0"