@foir/sdk 0.9.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +719 -27
  2. package/README.md +121 -2
  3. package/dist/auth/auth.d.ts +72 -1
  4. package/dist/auth/hosted.d.ts +71 -4
  5. package/dist/auth/index.d.ts +3 -3
  6. package/dist/auth/operations.d.ts +84 -0
  7. package/dist/auth/ops.d.ts +18 -0
  8. package/dist/auth.js +209 -47
  9. package/dist/client.js +101 -13
  10. package/dist/codegen/block-registry.d.ts +1 -1
  11. package/dist/codegen/block-scaffold.d.ts +1 -1
  12. package/dist/codegen/index.d.ts +1 -1
  13. package/dist/codegen/item-registry.d.ts +1 -1
  14. package/dist/codegen/item-scaffold.d.ts +1 -1
  15. package/dist/codegen/preset.d.ts +3 -2
  16. package/dist/content/content-renderer.d.ts +4 -5
  17. package/dist/content/index.d.ts +1 -1
  18. package/dist/content/richtext.d.ts +1 -1
  19. package/dist/data/builtins.d.ts +81 -13
  20. package/dist/data/client.d.ts +92 -9
  21. package/dist/data/decimal.d.ts +212 -0
  22. package/dist/data/document.d.ts +14 -1
  23. package/dist/data/index.d.ts +22 -10
  24. package/dist/data/manifest.d.ts +40 -4
  25. package/dist/data/schema.d.ts +18 -11
  26. package/dist/data/selection.d.ts +15 -0
  27. package/dist/data/subscriptions.d.ts +36 -10
  28. package/dist/data/transport.d.ts +363 -24
  29. package/dist/data/ws-transport.d.ts +8 -2
  30. package/dist/data.js +591 -64
  31. package/dist/graphql/cdn-route.d.ts +1 -1
  32. package/dist/graphql/foir-request.d.ts +1 -1
  33. package/dist/index.js +0 -9
  34. package/dist/live/coalesce.d.ts +57 -0
  35. package/dist/live/drift.d.ts +58 -0
  36. package/dist/live/events.d.ts +13 -0
  37. package/dist/live/headers.d.ts +53 -0
  38. package/dist/live/index.d.ts +54 -0
  39. package/dist/live/keys.d.ts +72 -0
  40. package/dist/live/manager.d.ts +77 -0
  41. package/dist/live/react/index.d.ts +143 -0
  42. package/dist/live/reconcile.d.ts +82 -0
  43. package/dist/live/resync.d.ts +59 -0
  44. package/dist/live/store.d.ts +134 -0
  45. package/dist/live/tagIndex.d.ts +29 -0
  46. package/dist/live/tanstack.d.ts +127 -0
  47. package/dist/live/writetoken.d.ts +86 -0
  48. package/dist/live-react.js +395 -0
  49. package/dist/live.js +845 -0
  50. package/dist/next/cacheTags.d.ts +245 -0
  51. package/dist/next/index.d.ts +7 -0
  52. package/dist/next.js +260 -34
  53. package/dist/push.js +163 -36
  54. package/dist/react.js +157 -33
  55. package/dist/realtime/index.d.ts +4 -5
  56. package/dist/server/events-client.d.ts +54 -4
  57. package/dist/server/index.d.ts +4 -2
  58. package/dist/server/operation-types.d.ts +86 -58
  59. package/dist/server/read-dispatch.d.ts +53 -0
  60. package/dist/server/require-scoped-token.d.ts +20 -1
  61. package/dist/server.js +602 -10
  62. package/dist/transport/foir-http.d.ts +168 -15
  63. package/package.json +10 -2
package/dist/auth.js CHANGED
@@ -11,11 +11,30 @@ function isFoirPreview(env) {
11
11
  function resolveFoirApiKey(env) {
12
12
  return isFoirPreview(env) ? env.PLATFORM_PREVIEW_API_KEY ?? env.PLATFORM_API_KEY : env.PLATFORM_API_KEY;
13
13
  }
14
+ var MALFORMED_PRUNE_REASONS = /* @__PURE__ */ new Set([
15
+ "unknown_field",
16
+ "subselections_on_scalar",
17
+ "missing_subselections"
18
+ ]);
14
19
  var FoirRequestError = class extends Error {
15
20
  /** HTTP status code, when the request failed at the HTTP layer. */
16
21
  status;
17
22
  /** GraphQL errors, when the request failed with a no-data error envelope. */
18
23
  graphQLErrors;
24
+ /**
25
+ * The `data` the server did return, when a STRICT read refused a partial
26
+ * response. Present only on that path: without it, opting into strictness
27
+ * would trade a silent wrong answer for a thrown one that discards the answer,
28
+ * and a caller that wants to log what it nearly rendered could not.
29
+ */
30
+ partialData;
31
+ /**
32
+ * The selections the server stripped, when the refusal was a pruned document
33
+ * rather than a partial error. Same purpose as `partialData`: a caller that
34
+ * wants to log what it nearly rendered, or name the offending path in a
35
+ * developer-facing message, has it without re-parsing anything.
36
+ */
37
+ prunedSelections;
19
38
  cause;
20
39
  constructor(message, options) {
21
40
  super(message);
@@ -23,6 +42,8 @@ var FoirRequestError = class extends Error {
23
42
  this.cause = options?.cause;
24
43
  this.status = options?.status;
25
44
  this.graphQLErrors = options?.graphQLErrors;
45
+ this.partialData = options?.partialData;
46
+ this.prunedSelections = options?.prunedSelections;
26
47
  }
27
48
  /**
28
49
  * The code of the first error, if the response carried one.
@@ -82,16 +103,46 @@ function readFoirRetryAfterMs(headers) {
82
103
  const seconds = Number(headers.get("Retry-After"));
83
104
  return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
84
105
  }
106
+ var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
107
+ "x-api-key",
108
+ "authorization",
109
+ "x-foir-token",
110
+ "x-foir-context"
111
+ ]);
112
+ function refuseCredentialHeaders(extra) {
113
+ if (!extra) return;
114
+ for (const name of Object.keys(extra)) {
115
+ if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
116
+ throw new FoirRequestError(
117
+ `[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.`
118
+ );
119
+ }
120
+ }
121
+ }
85
122
  function buildFoirHeaders(input) {
86
123
  const headers = {
87
- "Content-Type": "application/json",
88
- "x-api-key": input.apiKey
124
+ "Content-Type": "application/json"
89
125
  };
90
- if (input.draftChannel) {
91
- headers["X-Foir-Schema-Channel"] = "draft";
92
- }
93
- if (input.bearerToken) {
94
- headers["Authorization"] = `Bearer ${input.bearerToken}`;
126
+ if (input.dispatchToken !== void 0) {
127
+ refuseCredentialHeaders(input.extra);
128
+ headers["X-Foir-Token"] = input.dispatchToken;
129
+ if (input.dispatchContext) {
130
+ headers["X-Foir-Context"] = input.dispatchContext;
131
+ }
132
+ } else {
133
+ headers["x-api-key"] = input.apiKey;
134
+ if (input.draftChannel) {
135
+ headers["X-Foir-Schema-Channel"] = "draft";
136
+ }
137
+ if (input.bearerToken) {
138
+ headers["Authorization"] = `Bearer ${input.bearerToken}`;
139
+ }
140
+ if (input.orgId) {
141
+ headers["X-Org-Id"] = input.orgId;
142
+ }
143
+ if (input.orgUrl) {
144
+ headers["X-Org-Url"] = input.orgUrl;
145
+ }
95
146
  }
96
147
  if (input.extra) {
97
148
  for (const [key, value] of Object.entries(input.extra)) {
@@ -107,14 +158,48 @@ function foirApiEndpoint(apiUrl, path) {
107
158
  }
108
159
  return base + path;
109
160
  }
110
- function readFoirEnvelope(json) {
161
+ var warnedDowngrade = null;
162
+ function reportAuthDowngrade(envelope) {
163
+ const downgrade = envelope.extensions?.auth;
164
+ if (!downgrade) {
165
+ warnedDowngrade = null;
166
+ return;
167
+ }
168
+ if (warnedDowngrade === downgrade.reason) return;
169
+ warnedDowngrade = downgrade.reason;
170
+ console.warn(
171
+ `[foir] your customer token was ${downgrade.customerToken} (${downgrade.reason}) \u2014 this request was served as ${downgrade.servedAs}, so auth-targeted content resolved to its anonymous variant. ` + (downgrade.reason === "expired" ? "Re-exchange the token and retry." : "Check which credential you are sending.")
172
+ );
173
+ }
174
+ function refusePrunedDocument(json, options) {
175
+ const selections = json.extensions?.pruned?.selections;
176
+ if (!selections?.length) return;
177
+ const describe = (s) => `${s.path}${s.type ? ` on ${s.type}` : ""} (${s.reason})`;
178
+ const malformed = selections.filter((s) => MALFORMED_PRUNE_REASONS.has(s.reason));
179
+ if (malformed.length && options?.strict !== false) {
180
+ throw new FoirRequestError(
181
+ `[foir] the server could not serve part of this query and returned the rest without it: ${malformed.map(describe).join(", ")}. Those selections do not exist on the schema this project is serving. Pass strict: false to get the partial answer instead of this error.`,
182
+ { prunedSelections: selections }
183
+ );
184
+ }
185
+ console.warn(
186
+ `[foir] selections stripped from this query: ${selections.map(describe).join(", ")}`
187
+ );
188
+ }
189
+ function answeredAnything(data) {
190
+ return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
191
+ }
192
+ function readFoirEnvelope(json, options) {
193
+ reportAuthDowngrade(json);
194
+ refusePrunedDocument(json, options);
111
195
  if (json.errors?.length) {
112
196
  const msg = json.errors.map((e) => e.message).join("; ");
113
- if (json.data) {
197
+ if (answeredAnything(json.data) && !options?.strict) {
114
198
  console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
115
199
  } else {
116
200
  throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
117
- graphQLErrors: json.errors
201
+ graphQLErrors: json.errors,
202
+ partialData: json.data ?? void 0
118
203
  });
119
204
  }
120
205
  }
@@ -155,8 +240,9 @@ async function hasFoirErrorCode(res, ...codes) {
155
240
  }
156
241
 
157
242
  // src/graphql/cdn-route.ts
158
- function isCdnEligible(env, documentText, bearerToken) {
243
+ function isCdnEligible(env, documentText, bearerToken, orgId) {
159
244
  if (bearerToken) return false;
245
+ if (orgId) return false;
160
246
  if (isFoirPreview(env)) return false;
161
247
  const apiKey = resolveFoirApiKey(env);
162
248
  if (!apiKey?.startsWith("pk_")) return false;
@@ -175,12 +261,14 @@ async function foirRequest(env, document, variables, options) {
175
261
  const headers = buildFoirHeaders({
176
262
  apiKey,
177
263
  draftChannel: isFoirPreview(env),
178
- bearerToken: options?.bearerToken
264
+ bearerToken: options?.bearerToken,
265
+ orgId: options?.orgId,
266
+ orgUrl: options?.orgUrl
179
267
  });
180
268
  const documentText = print2(document);
181
269
  let res;
182
270
  try {
183
- if (isCdnEligible(env, documentText, options?.bearerToken)) {
271
+ if (isCdnEligible(env, documentText, options?.bearerToken, options?.orgId ?? options?.orgUrl)) {
184
272
  const hash = await documentHash(documentText);
185
273
  let shared = await fetch(buildCdnUrl(endpoint, apiKey, hash, variables), {
186
274
  method: "GET",
@@ -736,10 +824,44 @@ async function resolveBearerToken(config) {
736
824
  if (typeof source === "function") return await source() ?? void 0;
737
825
  return source ?? void 0;
738
826
  }
827
+ async function resolveDispatchToken(dispatch) {
828
+ const token = (await dispatch.token())?.trim();
829
+ if (!token) {
830
+ throw new FoirRequestError(
831
+ "[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`."
832
+ );
833
+ }
834
+ return token;
835
+ }
836
+ async function resolveFoirAuth(config) {
837
+ if (config.dispatch !== void 0) {
838
+ return {
839
+ headers: buildFoirHeaders({
840
+ dispatchToken: await resolveDispatchToken(config.dispatch),
841
+ dispatchContext: config.dispatch.context ?? void 0,
842
+ extra: config.headers
843
+ })
844
+ };
845
+ }
846
+ const bearerToken = await resolveBearerToken(config);
847
+ return {
848
+ headers: buildFoirHeaders({
849
+ apiKey: config.apiKey,
850
+ draftChannel: config.channel === "draft",
851
+ bearerToken,
852
+ orgId: config.orgId,
853
+ orgUrl: config.orgUrl,
854
+ extra: config.headers
855
+ }),
856
+ key: { apiKey: config.apiKey, bearerToken }
857
+ };
858
+ }
739
859
  function foirRoute(input) {
740
860
  if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
741
861
  if (input.bearerToken) return { lane: "origin", reason: "bearer" };
862
+ if (input.orgId || input.orgUrl) return { lane: "origin", reason: "org" };
742
863
  if (input.mutation) return { lane: "origin", reason: "operation" };
864
+ if (input.writeToken) return { lane: "origin", reason: "floor" };
743
865
  if (input.channel === "draft") return { lane: "origin", reason: "channel" };
744
866
  return { lane: "shared" };
745
867
  }
@@ -752,23 +874,19 @@ async function executeFoirDocument(config, plan) {
752
874
  }
753
875
  return data[plan.rootField];
754
876
  }
755
- async function runFoirDocument(config, document, mutation, variables) {
877
+ async function runFoirDocument(config, document, mutation, variables, options) {
756
878
  const doFetch = config.fetch ?? globalThis.fetch;
757
879
  const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
758
- const bearerToken = await resolveBearerToken(config);
759
- const headers = buildFoirHeaders({
760
- apiKey: config.apiKey,
761
- draftChannel: config.channel === "draft",
762
- bearerToken,
763
- extra: config.headers
764
- });
880
+ const { headers, key } = await resolveFoirAuth(config);
765
881
  const hash = await documentHash(document);
766
- const route = foirRoute({
767
- apiKey: config.apiKey,
768
- bearerToken,
882
+ const sharedKey = key !== void 0 && foirRoute({
883
+ apiKey: key.apiKey,
884
+ bearerToken: key.bearerToken,
885
+ orgId: config.orgId,
886
+ orgUrl: config.orgUrl,
769
887
  mutation,
770
888
  channel: config.channel
771
- });
889
+ }).lane === "shared" ? key.apiKey : void 0;
772
890
  const post = (body) => doFetch(endpoint, {
773
891
  method: "POST",
774
892
  headers,
@@ -778,8 +896,8 @@ async function runFoirDocument(config, document, mutation, variables) {
778
896
  const extensions = persistedQueryExtension(hash);
779
897
  let res;
780
898
  try {
781
- if (route.lane === "shared") {
782
- res = await doFetch(buildCdnUrl(endpoint, config.apiKey, hash, variables), {
899
+ if (sharedKey !== void 0) {
900
+ res = await doFetch(buildCdnUrl(endpoint, sharedKey, hash, variables), {
783
901
  method: "GET",
784
902
  headers,
785
903
  ...config.requestInit
@@ -805,7 +923,7 @@ async function runFoirDocument(config, document, mutation, variables) {
805
923
  });
806
924
  }
807
925
  const json = await res.json();
808
- return readFoirEnvelope(json);
926
+ return readFoirEnvelope(json, options);
809
927
  }
810
928
  async function readErrorBody(res) {
811
929
  try {
@@ -817,19 +935,16 @@ async function readErrorBody(res) {
817
935
  }
818
936
 
819
937
  // src/data/manifest.ts
820
- async function fetchFoirManifest(config) {
821
- const url = foirDataEndpoint(config.apiUrl, "/v1/manifest");
938
+ async function fetchFoirManifest(config, options = {}) {
939
+ const base = foirDataEndpoint(config.apiUrl, "/v1/manifest");
940
+ const url = options.operation === void 0 ? base : `${base}?operation=${encodeURIComponent(options.operation)}`;
822
941
  const doFetch = config.fetch ?? globalThis.fetch;
942
+ const { headers } = await resolveFoirAuth(config);
823
943
  let res;
824
944
  try {
825
945
  res = await doFetch(url, {
826
946
  method: "GET",
827
- headers: buildFoirHeaders({
828
- apiKey: config.apiKey,
829
- draftChannel: config.channel === "draft",
830
- bearerToken: await resolveBearerToken(config),
831
- extra: config.headers
832
- }),
947
+ headers,
833
948
  ...config.requestInit
834
949
  });
835
950
  } catch (err) {
@@ -869,6 +984,16 @@ var AUTH_OPS = {
869
984
  customerUpdatePassword: "customerUpdatePassword",
870
985
  customerVerifyEmail: "customerVerifyEmail",
871
986
  deleteMyAccount: "deleteMyAccount",
987
+ // The operator's organization context. TWO ops and no third: one to read
988
+ // which organizations the caller may stand in, one to say which. There is
989
+ // deliberately no per-call org argument anywhere in this SDK — the claim the
990
+ // row layer reads is resolved from the caller's own membership row on every
991
+ // request, so an application that never names an org cannot name the wrong
992
+ // one.
993
+ myOrganizations: "myOrganizations",
994
+ selectMyOrganization: "selectMyOrganization",
995
+ // Founding one. Present in a manifest only when the project opted in.
996
+ registerOperatorOrganization: "registerOperatorOrganization",
872
997
  // External identity providers.
873
998
  customerLoginWithProvider: "customerLoginWithProvider",
874
999
  customerProviderCallback: "customerProviderCallback",
@@ -890,6 +1015,8 @@ function authClientConfig(env, options) {
890
1015
  apiKey,
891
1016
  channel: isFoirPreview(env) ? "draft" : "published",
892
1017
  bearerToken: options?.bearerToken,
1018
+ orgId: options?.orgId,
1019
+ orgUrl: options?.orgUrl,
893
1020
  headers: options?.headers,
894
1021
  fetch: options?.fetch
895
1022
  };
@@ -998,6 +1125,15 @@ async function customerLogout(env, options) {
998
1125
  async function deleteMyAccount(env, vars, options) {
999
1126
  return authOp(env, AUTH_OPS.deleteMyAccount, vars, options);
1000
1127
  }
1128
+ async function myOrganizations(env, vars, options) {
1129
+ return authOp(env, AUTH_OPS.myOrganizations, vars ?? {}, options);
1130
+ }
1131
+ async function selectOrganization(env, vars, options) {
1132
+ return authOp(env, AUTH_OPS.selectMyOrganization, vars, options);
1133
+ }
1134
+ async function registerOrganization(env, vars, options) {
1135
+ return authOp(env, AUTH_OPS.registerOperatorOrganization, vars, options);
1136
+ }
1001
1137
  async function saveSession(adapter, result) {
1002
1138
  await adapter.set(TOKEN_KEY, result.token);
1003
1139
  await adapter.set(REFRESH_KEY, result.refreshToken);
@@ -1135,25 +1271,48 @@ async function fetchHostedSession(request, config) {
1135
1271
  const match = cookie.match(
1136
1272
  new RegExp(`(?:^|;\\s*)${escapeRegExp(cookieName)}=([^;]+)`)
1137
1273
  );
1138
- if (!match) return null;
1274
+ if (!match) return { outcome: "signed-out", reason: "no-cookie" };
1275
+ let res;
1139
1276
  try {
1140
- const res = await fetch(`${config.loginOrigin}${sessionPath}`, {
1277
+ res = await fetch(`${config.loginOrigin}${sessionPath}`, {
1141
1278
  headers: {
1142
1279
  Cookie: `${cookieName}=${match[1]}`,
1143
1280
  "X-Foir-Session": "1"
1144
1281
  }
1145
1282
  });
1146
- if (!res.ok) return null;
1147
- const json = await res.json();
1148
- if (!json.access_token || !json.customer_id) return null;
1149
- return {
1150
- token: json.access_token,
1151
- customerId: json.customer_id,
1152
- email: json.email ?? ""
1153
- };
1283
+ } catch (cause) {
1284
+ return { outcome: "unavailable", reason: "network", cause };
1285
+ }
1286
+ if (!res.ok) {
1287
+ if (res.status === 401) {
1288
+ return { outcome: "signed-out", reason: "rejected" };
1289
+ }
1290
+ if (res.status === 403) {
1291
+ return { outcome: "unavailable", reason: "forbidden", httpStatus: res.status };
1292
+ }
1293
+ if (res.status === 429) {
1294
+ return { outcome: "unavailable", reason: "rate-limited", httpStatus: res.status };
1295
+ }
1296
+ return { outcome: "unavailable", reason: "server-error", httpStatus: res.status };
1297
+ }
1298
+ let json;
1299
+ try {
1300
+ json = await res.json();
1154
1301
  } catch {
1155
- return null;
1302
+ return { outcome: "unavailable", reason: "malformed", httpStatus: res.status };
1156
1303
  }
1304
+ if (!json.access_token || !json.customer_id) {
1305
+ return { outcome: "unavailable", reason: "malformed", httpStatus: res.status };
1306
+ }
1307
+ return {
1308
+ outcome: "active",
1309
+ session: {
1310
+ token: json.access_token,
1311
+ customerId: json.customer_id,
1312
+ email: json.email ?? "",
1313
+ expiresIn: typeof json.expires_in === "number" ? json.expires_in : 0
1314
+ }
1315
+ };
1157
1316
  }
1158
1317
  function escapeRegExp(s) {
1159
1318
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -1326,12 +1485,15 @@ export {
1326
1485
  hostedLogoutUrl,
1327
1486
  loginWithOTP,
1328
1487
  loginWithPassword,
1488
+ myOrganizations,
1329
1489
  myProviderToken,
1330
1490
  refreshSession,
1491
+ registerOrganization,
1331
1492
  registerWithPassword,
1332
1493
  requireAdmin,
1333
1494
  requireAuth,
1334
1495
  resetPasswordAndSignIn,
1335
1496
  saveSession,
1497
+ selectOrganization,
1336
1498
  signOut
1337
1499
  };
package/dist/client.js CHANGED
@@ -11,11 +11,30 @@ function isFoirPreview(env) {
11
11
  function resolveFoirApiKey(env) {
12
12
  return isFoirPreview(env) ? env.PLATFORM_PREVIEW_API_KEY ?? env.PLATFORM_API_KEY : env.PLATFORM_API_KEY;
13
13
  }
14
+ var MALFORMED_PRUNE_REASONS = /* @__PURE__ */ new Set([
15
+ "unknown_field",
16
+ "subselections_on_scalar",
17
+ "missing_subselections"
18
+ ]);
14
19
  var FoirRequestError = class extends Error {
15
20
  /** HTTP status code, when the request failed at the HTTP layer. */
16
21
  status;
17
22
  /** GraphQL errors, when the request failed with a no-data error envelope. */
18
23
  graphQLErrors;
24
+ /**
25
+ * The `data` the server did return, when a STRICT read refused a partial
26
+ * response. Present only on that path: without it, opting into strictness
27
+ * would trade a silent wrong answer for a thrown one that discards the answer,
28
+ * and a caller that wants to log what it nearly rendered could not.
29
+ */
30
+ partialData;
31
+ /**
32
+ * The selections the server stripped, when the refusal was a pruned document
33
+ * rather than a partial error. Same purpose as `partialData`: a caller that
34
+ * wants to log what it nearly rendered, or name the offending path in a
35
+ * developer-facing message, has it without re-parsing anything.
36
+ */
37
+ prunedSelections;
19
38
  cause;
20
39
  constructor(message, options) {
21
40
  super(message);
@@ -23,6 +42,8 @@ var FoirRequestError = class extends Error {
23
42
  this.cause = options?.cause;
24
43
  this.status = options?.status;
25
44
  this.graphQLErrors = options?.graphQLErrors;
45
+ this.partialData = options?.partialData;
46
+ this.prunedSelections = options?.prunedSelections;
26
47
  }
27
48
  /**
28
49
  * The code of the first error, if the response carried one.
@@ -94,16 +115,46 @@ function readFoirRetryAfterMs(headers) {
94
115
  const seconds = Number(headers.get("Retry-After"));
95
116
  return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
96
117
  }
118
+ var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
119
+ "x-api-key",
120
+ "authorization",
121
+ "x-foir-token",
122
+ "x-foir-context"
123
+ ]);
124
+ function refuseCredentialHeaders(extra) {
125
+ if (!extra) return;
126
+ for (const name of Object.keys(extra)) {
127
+ if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
128
+ throw new FoirRequestError(
129
+ `[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.`
130
+ );
131
+ }
132
+ }
133
+ }
97
134
  function buildFoirHeaders(input) {
98
135
  const headers = {
99
- "Content-Type": "application/json",
100
- "x-api-key": input.apiKey
136
+ "Content-Type": "application/json"
101
137
  };
102
- if (input.draftChannel) {
103
- headers["X-Foir-Schema-Channel"] = "draft";
104
- }
105
- if (input.bearerToken) {
106
- headers["Authorization"] = `Bearer ${input.bearerToken}`;
138
+ if (input.dispatchToken !== void 0) {
139
+ refuseCredentialHeaders(input.extra);
140
+ headers["X-Foir-Token"] = input.dispatchToken;
141
+ if (input.dispatchContext) {
142
+ headers["X-Foir-Context"] = input.dispatchContext;
143
+ }
144
+ } else {
145
+ headers["x-api-key"] = input.apiKey;
146
+ if (input.draftChannel) {
147
+ headers["X-Foir-Schema-Channel"] = "draft";
148
+ }
149
+ if (input.bearerToken) {
150
+ headers["Authorization"] = `Bearer ${input.bearerToken}`;
151
+ }
152
+ if (input.orgId) {
153
+ headers["X-Org-Id"] = input.orgId;
154
+ }
155
+ if (input.orgUrl) {
156
+ headers["X-Org-Url"] = input.orgUrl;
157
+ }
107
158
  }
108
159
  if (input.extra) {
109
160
  for (const [key, value] of Object.entries(input.extra)) {
@@ -112,14 +163,48 @@ function buildFoirHeaders(input) {
112
163
  }
113
164
  return headers;
114
165
  }
115
- function readFoirEnvelope(json) {
166
+ var warnedDowngrade = null;
167
+ function reportAuthDowngrade(envelope) {
168
+ const downgrade = envelope.extensions?.auth;
169
+ if (!downgrade) {
170
+ warnedDowngrade = null;
171
+ return;
172
+ }
173
+ if (warnedDowngrade === downgrade.reason) return;
174
+ warnedDowngrade = downgrade.reason;
175
+ console.warn(
176
+ `[foir] your customer token was ${downgrade.customerToken} (${downgrade.reason}) \u2014 this request was served as ${downgrade.servedAs}, so auth-targeted content resolved to its anonymous variant. ` + (downgrade.reason === "expired" ? "Re-exchange the token and retry." : "Check which credential you are sending.")
177
+ );
178
+ }
179
+ function refusePrunedDocument(json, options) {
180
+ const selections = json.extensions?.pruned?.selections;
181
+ if (!selections?.length) return;
182
+ const describe = (s) => `${s.path}${s.type ? ` on ${s.type}` : ""} (${s.reason})`;
183
+ const malformed = selections.filter((s) => MALFORMED_PRUNE_REASONS.has(s.reason));
184
+ if (malformed.length && options?.strict !== false) {
185
+ throw new FoirRequestError(
186
+ `[foir] the server could not serve part of this query and returned the rest without it: ${malformed.map(describe).join(", ")}. Those selections do not exist on the schema this project is serving. Pass strict: false to get the partial answer instead of this error.`,
187
+ { prunedSelections: selections }
188
+ );
189
+ }
190
+ console.warn(
191
+ `[foir] selections stripped from this query: ${selections.map(describe).join(", ")}`
192
+ );
193
+ }
194
+ function answeredAnything(data) {
195
+ return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
196
+ }
197
+ function readFoirEnvelope(json, options) {
198
+ reportAuthDowngrade(json);
199
+ refusePrunedDocument(json, options);
116
200
  if (json.errors?.length) {
117
201
  const msg = json.errors.map((e) => e.message).join("; ");
118
- if (json.data) {
202
+ if (answeredAnything(json.data) && !options?.strict) {
119
203
  console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
120
204
  } else {
121
205
  throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
122
- graphQLErrors: json.errors
206
+ graphQLErrors: json.errors,
207
+ partialData: json.data ?? void 0
123
208
  });
124
209
  }
125
210
  }
@@ -160,8 +245,9 @@ async function hasFoirErrorCode(res, ...codes) {
160
245
  }
161
246
 
162
247
  // src/graphql/cdn-route.ts
163
- function isCdnEligible(env, documentText, bearerToken) {
248
+ function isCdnEligible(env, documentText, bearerToken, orgId) {
164
249
  if (bearerToken) return false;
250
+ if (orgId) return false;
165
251
  if (isFoirPreview(env)) return false;
166
252
  const apiKey = resolveFoirApiKey(env);
167
253
  if (!apiKey?.startsWith("pk_")) return false;
@@ -214,12 +300,14 @@ async function foirRequest(env, document, variables, options) {
214
300
  const headers = buildFoirHeaders({
215
301
  apiKey,
216
302
  draftChannel: isFoirPreview(env),
217
- bearerToken: options?.bearerToken
303
+ bearerToken: options?.bearerToken,
304
+ orgId: options?.orgId,
305
+ orgUrl: options?.orgUrl
218
306
  });
219
307
  const documentText = print2(document);
220
308
  let res;
221
309
  try {
222
- if (isCdnEligible(env, documentText, options?.bearerToken)) {
310
+ if (isCdnEligible(env, documentText, options?.bearerToken, options?.orgId ?? options?.orgUrl)) {
223
311
  const hash = await documentHash(documentText);
224
312
  let shared = await fetch(buildCdnUrl(endpoint, apiKey, hash, variables), {
225
313
  method: "GET",
@@ -2,7 +2,7 @@
2
2
  * `@foir/sdk/codegen/block-registry` — DEPRECATED alias.
3
3
  *
4
4
  * `block` was one project's name for a flexible field, never a platform
5
- * concept (TTS-1391). The plugin moved to `@foir/sdk/codegen/item-registry`;
5
+ * concept. The plugin moved to `@foir/sdk/codegen/item-registry`;
6
6
  * this path re-exports it unchanged so an existing `codegen.ts` keeps working.
7
7
  * Repoint the `plugins:` entry when convenient — this will be removed in a
8
8
  * future major.
@@ -2,7 +2,7 @@
2
2
  * `@foir/sdk/codegen/block-scaffold` — DEPRECATED alias.
3
3
  *
4
4
  * `block` was one project's name for a flexible field, never a platform
5
- * concept (TTS-1391). The plugin moved to `@foir/sdk/codegen/item-scaffold`;
5
+ * concept. The plugin moved to `@foir/sdk/codegen/item-scaffold`;
6
6
  * this path re-exports it unchanged so an existing `codegen.ts` keeps working.
7
7
  * Repoint the `plugins:` entry when convenient — this will be removed in a
8
8
  * future major.
@@ -6,7 +6,7 @@
6
6
  * and item-scaffold plugins are wired by the preset and also available at
7
7
  * `@foir/sdk/codegen/item-registry` / `@foir/sdk/codegen/item-scaffold`.
8
8
  *
9
- * The pre-TTS-1391 paths `@foir/sdk/codegen/block-registry` and
9
+ * The pre-rename paths `@foir/sdk/codegen/block-registry` and
10
10
  * `/block-scaffold` still resolve, re-exporting those modules unchanged.
11
11
  */
12
12
  export { foirCodegen, foirScaffold } from './preset';
@@ -13,7 +13,7 @@
13
13
  * types from the codegen output (`./graphql`) and `ItemComponent` from
14
14
  * `@foir/sdk/content`, so it pairs directly with `createContentRenderer`.
15
15
  *
16
- * Reachable at the pre-TTS-1391 path `@foir/sdk/codegen/block-registry` too,
16
+ * Reachable at the pre-rename path `@foir/sdk/codegen/block-registry` too,
17
17
  * which re-exports this module unchanged.
18
18
  */
19
19
  import type { GraphQLSchema } from 'graphql';
@@ -10,7 +10,7 @@
10
10
  * in. Adding a new item type later surfaces as a compile error on
11
11
  * `itemRegistry` (via the regenerated `ItemRegistry` type), not here.
12
12
  *
13
- * Reachable at the pre-TTS-1391 path `@foir/sdk/codegen/block-scaffold` too,
13
+ * Reachable at the pre-rename path `@foir/sdk/codegen/block-scaffold` too,
14
14
  * which re-exports this module unchanged.
15
15
  */
16
16
  import type { GraphQLSchema } from 'graphql';
@@ -21,9 +21,10 @@ export interface FoirCodegenOptions {
21
21
  */
22
22
  apiKey?: string;
23
23
  /**
24
- * Preview (`sk_`) key with `drafts:read`. Reads the DRAFT schema channel so
24
+ * Preview key holding `drafts:read`. Reads the DRAFT schema channel so
25
25
  * freshly pushed (un-promoted) model changes are visible to codegen.
26
- * Strongly preferred — a `pk_` key only sees the published schema.
26
+ * Strongly preferred: a key without `drafts:read` only sees the published
27
+ * schema.
27
28
  */
28
29
  previewKey?: string;
29
30
  /** Output directory for the generated client types. Default `./app/gql/foir/`. */