@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/push.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
  };
package/dist/react.js CHANGED
@@ -24,11 +24,30 @@ function isFoirPreview(env) {
24
24
  function resolveFoirApiKey(env) {
25
25
  return isFoirPreview(env) ? env.PLATFORM_PREVIEW_API_KEY ?? env.PLATFORM_API_KEY : env.PLATFORM_API_KEY;
26
26
  }
27
+ var MALFORMED_PRUNE_REASONS = /* @__PURE__ */ new Set([
28
+ "unknown_field",
29
+ "subselections_on_scalar",
30
+ "missing_subselections"
31
+ ]);
27
32
  var FoirRequestError = class extends Error {
28
33
  /** HTTP status code, when the request failed at the HTTP layer. */
29
34
  status;
30
35
  /** GraphQL errors, when the request failed with a no-data error envelope. */
31
36
  graphQLErrors;
37
+ /**
38
+ * The `data` the server did return, when a STRICT read refused a partial
39
+ * response. Present only on that path: without it, opting into strictness
40
+ * would trade a silent wrong answer for a thrown one that discards the answer,
41
+ * and a caller that wants to log what it nearly rendered could not.
42
+ */
43
+ partialData;
44
+ /**
45
+ * The selections the server stripped, when the refusal was a pruned document
46
+ * rather than a partial error. Same purpose as `partialData`: a caller that
47
+ * wants to log what it nearly rendered, or name the offending path in a
48
+ * developer-facing message, has it without re-parsing anything.
49
+ */
50
+ prunedSelections;
32
51
  cause;
33
52
  constructor(message, options) {
34
53
  super(message);
@@ -36,6 +55,8 @@ var FoirRequestError = class extends Error {
36
55
  this.cause = options?.cause;
37
56
  this.status = options?.status;
38
57
  this.graphQLErrors = options?.graphQLErrors;
58
+ this.partialData = options?.partialData;
59
+ this.prunedSelections = options?.prunedSelections;
39
60
  }
40
61
  /**
41
62
  * The code of the first error, if the response carried one.
@@ -74,16 +95,46 @@ var FoirRequestError = class extends Error {
74
95
  return typeof seconds === "number" && seconds > 0 ? seconds * 1e3 : void 0;
75
96
  }
76
97
  };
98
+ var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
99
+ "x-api-key",
100
+ "authorization",
101
+ "x-foir-token",
102
+ "x-foir-context"
103
+ ]);
104
+ function refuseCredentialHeaders(extra) {
105
+ if (!extra) return;
106
+ for (const name of Object.keys(extra)) {
107
+ if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
108
+ throw new FoirRequestError(
109
+ `[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.`
110
+ );
111
+ }
112
+ }
113
+ }
77
114
  function buildFoirHeaders(input) {
78
115
  const headers = {
79
- "Content-Type": "application/json",
80
- "x-api-key": input.apiKey
116
+ "Content-Type": "application/json"
81
117
  };
82
- if (input.draftChannel) {
83
- headers["X-Foir-Schema-Channel"] = "draft";
84
- }
85
- if (input.bearerToken) {
86
- headers["Authorization"] = `Bearer ${input.bearerToken}`;
118
+ if (input.dispatchToken !== void 0) {
119
+ refuseCredentialHeaders(input.extra);
120
+ headers["X-Foir-Token"] = input.dispatchToken;
121
+ if (input.dispatchContext) {
122
+ headers["X-Foir-Context"] = input.dispatchContext;
123
+ }
124
+ } else {
125
+ headers["x-api-key"] = input.apiKey;
126
+ if (input.draftChannel) {
127
+ headers["X-Foir-Schema-Channel"] = "draft";
128
+ }
129
+ if (input.bearerToken) {
130
+ headers["Authorization"] = `Bearer ${input.bearerToken}`;
131
+ }
132
+ if (input.orgId) {
133
+ headers["X-Org-Id"] = input.orgId;
134
+ }
135
+ if (input.orgUrl) {
136
+ headers["X-Org-Url"] = input.orgUrl;
137
+ }
87
138
  }
88
139
  if (input.extra) {
89
140
  for (const [key, value] of Object.entries(input.extra)) {
@@ -99,14 +150,48 @@ function foirApiEndpoint(apiUrl, path) {
99
150
  }
100
151
  return base + path;
101
152
  }
102
- function readFoirEnvelope(json) {
153
+ var warnedDowngrade = null;
154
+ function reportAuthDowngrade(envelope) {
155
+ const downgrade = envelope.extensions?.auth;
156
+ if (!downgrade) {
157
+ warnedDowngrade = null;
158
+ return;
159
+ }
160
+ if (warnedDowngrade === downgrade.reason) return;
161
+ warnedDowngrade = downgrade.reason;
162
+ console.warn(
163
+ `[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.")
164
+ );
165
+ }
166
+ function refusePrunedDocument(json, options) {
167
+ const selections = json.extensions?.pruned?.selections;
168
+ if (!selections?.length) return;
169
+ const describe = (s) => `${s.path}${s.type ? ` on ${s.type}` : ""} (${s.reason})`;
170
+ const malformed = selections.filter((s) => MALFORMED_PRUNE_REASONS.has(s.reason));
171
+ if (malformed.length && options?.strict !== false) {
172
+ throw new FoirRequestError(
173
+ `[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.`,
174
+ { prunedSelections: selections }
175
+ );
176
+ }
177
+ console.warn(
178
+ `[foir] selections stripped from this query: ${selections.map(describe).join(", ")}`
179
+ );
180
+ }
181
+ function answeredAnything(data) {
182
+ return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
183
+ }
184
+ function readFoirEnvelope(json, options) {
185
+ reportAuthDowngrade(json);
186
+ refusePrunedDocument(json, options);
103
187
  if (json.errors?.length) {
104
188
  const msg = json.errors.map((e) => e.message).join("; ");
105
- if (json.data) {
189
+ if (answeredAnything(json.data) && !options?.strict) {
106
190
  console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
107
191
  } else {
108
192
  throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
109
- graphQLErrors: json.errors
193
+ graphQLErrors: json.errors,
194
+ partialData: json.data ?? void 0
110
195
  });
111
196
  }
112
197
  }
@@ -620,10 +705,44 @@ async function resolveBearerToken(config) {
620
705
  if (typeof source === "function") return await source() ?? void 0;
621
706
  return source ?? void 0;
622
707
  }
708
+ async function resolveDispatchToken(dispatch) {
709
+ const token = (await dispatch.token())?.trim();
710
+ if (!token) {
711
+ throw new FoirRequestError(
712
+ "[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`."
713
+ );
714
+ }
715
+ return token;
716
+ }
717
+ async function resolveFoirAuth(config) {
718
+ if (config.dispatch !== void 0) {
719
+ return {
720
+ headers: buildFoirHeaders({
721
+ dispatchToken: await resolveDispatchToken(config.dispatch),
722
+ dispatchContext: config.dispatch.context ?? void 0,
723
+ extra: config.headers
724
+ })
725
+ };
726
+ }
727
+ const bearerToken = await resolveBearerToken(config);
728
+ return {
729
+ headers: buildFoirHeaders({
730
+ apiKey: config.apiKey,
731
+ draftChannel: config.channel === "draft",
732
+ bearerToken,
733
+ orgId: config.orgId,
734
+ orgUrl: config.orgUrl,
735
+ extra: config.headers
736
+ }),
737
+ key: { apiKey: config.apiKey, bearerToken }
738
+ };
739
+ }
623
740
  function foirRoute(input) {
624
741
  if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
625
742
  if (input.bearerToken) return { lane: "origin", reason: "bearer" };
743
+ if (input.orgId || input.orgUrl) return { lane: "origin", reason: "org" };
626
744
  if (input.mutation) return { lane: "origin", reason: "operation" };
745
+ if (input.writeToken) return { lane: "origin", reason: "floor" };
627
746
  if (input.channel === "draft") return { lane: "origin", reason: "channel" };
628
747
  return { lane: "shared" };
629
748
  }
@@ -636,23 +755,19 @@ async function executeFoirDocument(config, plan) {
636
755
  }
637
756
  return data[plan.rootField];
638
757
  }
639
- async function runFoirDocument(config, document2, mutation, variables) {
758
+ async function runFoirDocument(config, document2, mutation, variables, options) {
640
759
  const doFetch = config.fetch ?? globalThis.fetch;
641
760
  const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
642
- const bearerToken = await resolveBearerToken(config);
643
- const headers = buildFoirHeaders({
644
- apiKey: config.apiKey,
645
- draftChannel: config.channel === "draft",
646
- bearerToken,
647
- extra: config.headers
648
- });
761
+ const { headers, key } = await resolveFoirAuth(config);
649
762
  const hash = await documentHash(document2);
650
- const route = foirRoute({
651
- apiKey: config.apiKey,
652
- bearerToken,
763
+ const sharedKey = key !== void 0 && foirRoute({
764
+ apiKey: key.apiKey,
765
+ bearerToken: key.bearerToken,
766
+ orgId: config.orgId,
767
+ orgUrl: config.orgUrl,
653
768
  mutation,
654
769
  channel: config.channel
655
- });
770
+ }).lane === "shared" ? key.apiKey : void 0;
656
771
  const post = (body) => doFetch(endpoint, {
657
772
  method: "POST",
658
773
  headers,
@@ -662,8 +777,8 @@ async function runFoirDocument(config, document2, mutation, variables) {
662
777
  const extensions = persistedQueryExtension(hash);
663
778
  let res;
664
779
  try {
665
- if (route.lane === "shared") {
666
- res = await doFetch(buildCdnUrl(endpoint, config.apiKey, hash, variables), {
780
+ if (sharedKey !== void 0) {
781
+ res = await doFetch(buildCdnUrl(endpoint, sharedKey, hash, variables), {
667
782
  method: "GET",
668
783
  headers,
669
784
  ...config.requestInit
@@ -689,7 +804,7 @@ async function runFoirDocument(config, document2, mutation, variables) {
689
804
  });
690
805
  }
691
806
  const json = await res.json();
692
- return readFoirEnvelope(json);
807
+ return readFoirEnvelope(json, options);
693
808
  }
694
809
  async function readErrorBody(res) {
695
810
  try {
@@ -701,19 +816,16 @@ async function readErrorBody(res) {
701
816
  }
702
817
 
703
818
  // src/data/manifest.ts
704
- async function fetchFoirManifest(config) {
705
- const url = foirDataEndpoint(config.apiUrl, "/v1/manifest");
819
+ async function fetchFoirManifest(config, options = {}) {
820
+ const base = foirDataEndpoint(config.apiUrl, "/v1/manifest");
821
+ const url = options.operation === void 0 ? base : `${base}?operation=${encodeURIComponent(options.operation)}`;
706
822
  const doFetch = config.fetch ?? globalThis.fetch;
823
+ const { headers } = await resolveFoirAuth(config);
707
824
  let res;
708
825
  try {
709
826
  res = await doFetch(url, {
710
827
  method: "GET",
711
- headers: buildFoirHeaders({
712
- apiKey: config.apiKey,
713
- draftChannel: config.channel === "draft",
714
- bearerToken: await resolveBearerToken(config),
715
- extra: config.headers
716
- }),
828
+ headers,
717
829
  ...config.requestInit
718
830
  });
719
831
  } catch (err) {
@@ -753,6 +865,16 @@ var AUTH_OPS = {
753
865
  customerUpdatePassword: "customerUpdatePassword",
754
866
  customerVerifyEmail: "customerVerifyEmail",
755
867
  deleteMyAccount: "deleteMyAccount",
868
+ // The operator's organization context. TWO ops and no third: one to read
869
+ // which organizations the caller may stand in, one to say which. There is
870
+ // deliberately no per-call org argument anywhere in this SDK — the claim the
871
+ // row layer reads is resolved from the caller's own membership row on every
872
+ // request, so an application that never names an org cannot name the wrong
873
+ // one.
874
+ myOrganizations: "myOrganizations",
875
+ selectMyOrganization: "selectMyOrganization",
876
+ // Founding one. Present in a manifest only when the project opted in.
877
+ registerOperatorOrganization: "registerOperatorOrganization",
756
878
  // External identity providers.
757
879
  customerLoginWithProvider: "customerLoginWithProvider",
758
880
  customerProviderCallback: "customerProviderCallback",
@@ -774,6 +896,8 @@ function authClientConfig(env, options) {
774
896
  apiKey,
775
897
  channel: isFoirPreview(env) ? "draft" : "published",
776
898
  bearerToken: options?.bearerToken,
899
+ orgId: options?.orgId,
900
+ orgUrl: options?.orgUrl,
777
901
  headers: options?.headers,
778
902
  fetch: options?.fetch
779
903
  };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @foir/sdk/realtime — optional thin web helper over y-websocket (EID-103).
2
+ * @foir/sdk/realtime — optional thin web helper over y-websocket.
3
3
  *
4
4
  * The apollo-link analogue for Foir realtime collaboration: sugar over the
5
5
  * off-the-shelf `y-websocket` provider that adds exactly the wire-protocol
@@ -17,7 +17,7 @@
17
17
  * `yjs` + `y-websocket` are OPTIONAL peer deps gated to this subpath, so a
18
18
  * consumer importing only other `@foir/sdk/*` exports pulls no Yjs.
19
19
  *
20
- * Wire-protocol contract: `docs/realtime-collab-contract.md` (EID-117).
20
+ * Wire-protocol contract: `docs/realtime-collab-contract.md`.
21
21
  */
22
22
  import type * as Y from 'yjs';
23
23
  import { WebsocketProvider } from 'y-websocket';
@@ -36,13 +36,12 @@ export interface RoomAddress {
36
36
  * with a 400).
37
37
  *
38
38
  * That segment is a KEY space because the platform compares it against the
39
- * reserved token `live` to select an EPHEMERAL persistence policy (EID-385):
39
+ * reserved token `live` to select an EPHEMERAL persistence policy:
40
40
  * live co-edit only — no `editing_state`, no autosave, doc destroyed on last
41
41
  * leave. A cuid2 row id can never equal `live`, so sending an id puts that
42
42
  * policy out of reach by construction. Worse, variants are themselves rows in
43
43
  * `records`, so a variant id still passes the join gate: the connection
44
- * succeeds and edits a DIFFERENT record, with nothing reported on either side
45
- * (TTS-953).
44
+ * succeeds and edits a DIFFERENT record, with nothing reported on either side.
46
45
  *
47
46
  * The address to send is the one the API advertises — the `collab` field on
48
47
  * every public record type returns `{ enabled, wsUrl, roomName }`, where