@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/push.js CHANGED
@@ -68,7 +68,12 @@ var FoirRequestError = class extends Error {
68
68
  * failed on the first attempt for an hour.
69
69
  */
70
70
  get retryable() {
71
- if (this.status === void 0) return true;
71
+ if (this.status === void 0) {
72
+ if (this.graphQLErrors?.length || this.prunedSelections?.length) {
73
+ return this.code === "RATE_LIMITED" || this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
74
+ }
75
+ return true;
76
+ }
72
77
  if (this.status === 429 || this.code === "RATE_LIMITED") return true;
73
78
  if (this.status >= 500) return true;
74
79
  return this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
@@ -103,19 +108,50 @@ function readFoirRetryAfterMs(headers) {
103
108
  const seconds = Number(headers.get("Retry-After"));
104
109
  return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
105
110
  }
111
+ var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
112
+ "x-api-key",
113
+ "authorization",
114
+ "x-foir-token",
115
+ "x-foir-context"
116
+ ]);
117
+ function refuseCredentialHeaders(extra) {
118
+ if (!extra) return;
119
+ for (const name of Object.keys(extra)) {
120
+ if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
121
+ throw new FoirRequestError(
122
+ `[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.`
123
+ );
124
+ }
125
+ }
126
+ }
127
+ var FOIR_VISITOR_IP_HEADER = "X-Foir-Visitor-IP";
106
128
  function buildFoirHeaders(input) {
107
129
  const headers = {
108
- "Content-Type": "application/json",
109
- "x-api-key": input.apiKey
130
+ "Content-Type": "application/json"
110
131
  };
111
- if (input.draftChannel) {
112
- headers["X-Foir-Schema-Channel"] = "draft";
113
- }
114
- if (input.bearerToken) {
115
- headers["Authorization"] = `Bearer ${input.bearerToken}`;
116
- }
117
- if (input.orgId) {
118
- headers["X-Org-Id"] = input.orgId;
132
+ if (input.dispatchToken !== void 0) {
133
+ refuseCredentialHeaders(input.extra);
134
+ headers["X-Foir-Token"] = input.dispatchToken;
135
+ if (input.dispatchContext) {
136
+ headers["X-Foir-Context"] = input.dispatchContext;
137
+ }
138
+ } else {
139
+ headers["x-api-key"] = input.apiKey;
140
+ if (input.draftChannel) {
141
+ headers["X-Foir-Schema-Channel"] = "draft";
142
+ }
143
+ if (input.bearerToken) {
144
+ headers["Authorization"] = `Bearer ${input.bearerToken}`;
145
+ }
146
+ if (input.orgId) {
147
+ headers["X-Org-Id"] = input.orgId;
148
+ }
149
+ if (input.orgUrl) {
150
+ headers["X-Org-Url"] = input.orgUrl;
151
+ }
152
+ if (input.visitorAddress) {
153
+ headers[FOIR_VISITOR_IP_HEADER] = input.visitorAddress;
154
+ }
119
155
  }
120
156
  if (input.extra) {
121
157
  for (const [key, value] of Object.entries(input.extra)) {
@@ -131,6 +167,26 @@ function foirApiEndpoint(apiUrl, path) {
131
167
  }
132
168
  return base + path;
133
169
  }
170
+ var FoirAuthDowngradedError = class extends FoirRequestError {
171
+ downgrade;
172
+ constructor(downgrade) {
173
+ super(
174
+ `[foir] your customer token was ${downgrade.customerToken} (${downgrade.reason}), so the request was served as ${downgrade.servedAs}`,
175
+ { graphQLErrors: [{ message: "customer token rejected", extensions: { code: "AUTH_DOWNGRADED" } }] }
176
+ );
177
+ this.name = "FoirAuthDowngradedError";
178
+ this.downgrade = downgrade;
179
+ }
180
+ };
181
+ var FoirSessionExpiredError = class extends FoirRequestError {
182
+ constructor(options) {
183
+ super("[foir] the customer session has expired and could not be refreshed: sign in again", {
184
+ cause: options?.cause,
185
+ graphQLErrors: [{ message: "session expired", extensions: { code: "SESSION_EXPIRED" } }]
186
+ });
187
+ this.name = "FoirSessionExpiredError";
188
+ }
189
+ };
134
190
  var warnedDowngrade = null;
135
191
  function reportAuthDowngrade(envelope) {
136
192
  const downgrade = envelope.extensions?.auth;
@@ -159,12 +215,18 @@ function refusePrunedDocument(json, options) {
159
215
  `[foir] selections stripped from this query: ${selections.map(describe).join(", ")}`
160
216
  );
161
217
  }
218
+ function answeredAnything(data) {
219
+ return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
220
+ }
162
221
  function readFoirEnvelope(json, options) {
222
+ if (options?.rejectDowngrade && json.extensions?.auth) {
223
+ throw new FoirAuthDowngradedError(json.extensions.auth);
224
+ }
163
225
  reportAuthDowngrade(json);
164
226
  refusePrunedDocument(json, options);
165
227
  if (json.errors?.length) {
166
228
  const msg = json.errors.map((e) => e.message).join("; ");
167
- if (json.data && !options?.strict) {
229
+ if (answeredAnything(json.data) && !options?.strict) {
168
230
  console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
169
231
  } else {
170
232
  throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
@@ -232,12 +294,13 @@ async function foirRequest(env, document, variables, options) {
232
294
  apiKey,
233
295
  draftChannel: isFoirPreview(env),
234
296
  bearerToken: options?.bearerToken,
235
- orgId: options?.orgId
297
+ orgId: options?.orgId,
298
+ orgUrl: options?.orgUrl
236
299
  });
237
300
  const documentText = print2(document);
238
301
  let res;
239
302
  try {
240
- if (isCdnEligible(env, documentText, options?.bearerToken, options?.orgId)) {
303
+ if (isCdnEligible(env, documentText, options?.bearerToken, options?.orgId ?? options?.orgUrl)) {
241
304
  const hash = await documentHash(documentText);
242
305
  let shared = await fetch(buildCdnUrl(endpoint, apiKey, hash, variables), {
243
306
  method: "GET",
@@ -283,7 +346,7 @@ async function foirRequest(env, document, variables, options) {
283
346
  );
284
347
  }
285
348
  const json = await res.json();
286
- return readFoirEnvelope(json);
349
+ return readFoirEnvelope(json, { rejectDowngrade: options?.rejectDowngrade });
287
350
  }
288
351
  async function readErrorEnvelope(res) {
289
352
  let errors;
@@ -788,15 +851,54 @@ function operationName(scope, op) {
788
851
  function foirDataEndpoint(apiUrl, path) {
789
852
  return foirApiEndpoint(apiUrl, path);
790
853
  }
854
+ async function resolveVisitorAddress(config) {
855
+ const source = config.visitor;
856
+ const value = typeof source === "function" ? await source() : source;
857
+ const address = value?.trim();
858
+ return address ? address : void 0;
859
+ }
791
860
  async function resolveBearerToken(config) {
792
861
  const source = config.bearerToken;
793
862
  if (typeof source === "function") return await source() ?? void 0;
794
863
  return source ?? void 0;
795
864
  }
865
+ async function resolveDispatchToken(dispatch) {
866
+ const token = (await dispatch.token())?.trim();
867
+ if (!token) {
868
+ throw new FoirRequestError(
869
+ "[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`."
870
+ );
871
+ }
872
+ return token;
873
+ }
874
+ async function resolveFoirAuth(config) {
875
+ if (config.dispatch !== void 0) {
876
+ return {
877
+ headers: buildFoirHeaders({
878
+ dispatchToken: await resolveDispatchToken(config.dispatch),
879
+ dispatchContext: config.dispatch.context ?? void 0,
880
+ extra: config.headers
881
+ })
882
+ };
883
+ }
884
+ const bearerToken = await resolveBearerToken(config);
885
+ return {
886
+ headers: buildFoirHeaders({
887
+ apiKey: config.apiKey,
888
+ draftChannel: config.channel === "draft",
889
+ bearerToken,
890
+ orgId: config.orgId,
891
+ orgUrl: config.orgUrl,
892
+ visitorAddress: await resolveVisitorAddress(config),
893
+ extra: config.headers
894
+ }),
895
+ key: { apiKey: config.apiKey, bearerToken }
896
+ };
897
+ }
796
898
  function foirRoute(input) {
797
899
  if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
798
900
  if (input.bearerToken) return { lane: "origin", reason: "bearer" };
799
- if (input.orgId) return { lane: "origin", reason: "org" };
901
+ if (input.orgId || input.orgUrl) return { lane: "origin", reason: "org" };
800
902
  if (input.mutation) return { lane: "origin", reason: "operation" };
801
903
  if (input.writeToken) return { lane: "origin", reason: "floor" };
802
904
  if (input.channel === "draft") return { lane: "origin", reason: "channel" };
@@ -811,25 +913,45 @@ async function executeFoirDocument(config, plan) {
811
913
  }
812
914
  return data[plan.rootField];
813
915
  }
916
+ var renewing = /* @__PURE__ */ new WeakMap();
917
+ function renewBearerOnce(refresh) {
918
+ const running = renewing.get(refresh);
919
+ if (running) return running;
920
+ const next = refresh().finally(() => renewing.delete(refresh));
921
+ renewing.set(refresh, next);
922
+ return next;
923
+ }
814
924
  async function runFoirDocument(config, document, mutation, variables, options) {
925
+ try {
926
+ return await sendFoirDocument(config, document, mutation, variables, options);
927
+ } catch (err) {
928
+ const refresh = config.refreshBearer;
929
+ if (!(err instanceof FoirAuthDowngradedError) || !refresh || err.downgrade.reason === "wrong_project") {
930
+ throw err;
931
+ }
932
+ const token = err.downgrade.reason === "expired" ? await renewBearerOnce(refresh) : null;
933
+ if (!token) throw new FoirSessionExpiredError({ cause: err });
934
+ try {
935
+ return await sendFoirDocument({ ...config, bearerToken: token }, document, mutation, variables, options);
936
+ } catch (again) {
937
+ if (again instanceof FoirAuthDowngradedError) throw new FoirSessionExpiredError({ cause: again });
938
+ throw again;
939
+ }
940
+ }
941
+ }
942
+ async function sendFoirDocument(config, document, mutation, variables, options) {
815
943
  const doFetch = config.fetch ?? globalThis.fetch;
816
944
  const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
817
- const bearerToken = await resolveBearerToken(config);
818
- const headers = buildFoirHeaders({
819
- apiKey: config.apiKey,
820
- draftChannel: config.channel === "draft",
821
- bearerToken,
822
- orgId: config.orgId,
823
- extra: config.headers
824
- });
945
+ const { headers, key } = await resolveFoirAuth(config);
825
946
  const hash = await documentHash(document);
826
- const route = foirRoute({
827
- apiKey: config.apiKey,
828
- bearerToken,
947
+ const sharedKey = key !== void 0 && foirRoute({
948
+ apiKey: key.apiKey,
949
+ bearerToken: key.bearerToken,
829
950
  orgId: config.orgId,
951
+ orgUrl: config.orgUrl,
830
952
  mutation,
831
953
  channel: config.channel
832
- });
954
+ }).lane === "shared" ? key.apiKey : void 0;
833
955
  const post = (body) => doFetch(endpoint, {
834
956
  method: "POST",
835
957
  headers,
@@ -839,8 +961,8 @@ async function runFoirDocument(config, document, mutation, variables, options) {
839
961
  const extensions = persistedQueryExtension(hash);
840
962
  let res;
841
963
  try {
842
- if (route.lane === "shared") {
843
- res = await doFetch(buildCdnUrl(endpoint, config.apiKey, hash, variables), {
964
+ if (sharedKey !== void 0) {
965
+ res = await doFetch(buildCdnUrl(endpoint, sharedKey, hash, variables), {
844
966
  method: "GET",
845
967
  headers,
846
968
  ...config.requestInit
@@ -866,7 +988,7 @@ async function runFoirDocument(config, document, mutation, variables, options) {
866
988
  });
867
989
  }
868
990
  const json = await res.json();
869
- return readFoirEnvelope(json, options);
991
+ return readFoirEnvelope(json, { ...options, rejectDowngrade: Boolean(key?.bearerToken) });
870
992
  }
871
993
  async function readErrorBody(res) {
872
994
  try {
@@ -878,20 +1000,16 @@ async function readErrorBody(res) {
878
1000
  }
879
1001
 
880
1002
  // src/data/manifest.ts
881
- async function fetchFoirManifest(config) {
882
- const url = foirDataEndpoint(config.apiUrl, "/v1/manifest");
1003
+ async function fetchFoirManifest(config, options = {}) {
1004
+ const base = foirDataEndpoint(config.apiUrl, "/v1/manifest");
1005
+ const url = options.operation === void 0 ? base : `${base}?operation=${encodeURIComponent(options.operation)}`;
883
1006
  const doFetch = config.fetch ?? globalThis.fetch;
1007
+ const { headers } = await resolveFoirAuth(config);
884
1008
  let res;
885
1009
  try {
886
1010
  res = await doFetch(url, {
887
1011
  method: "GET",
888
- headers: buildFoirHeaders({
889
- apiKey: config.apiKey,
890
- draftChannel: config.channel === "draft",
891
- bearerToken: await resolveBearerToken(config),
892
- orgId: config.orgId,
893
- extra: config.headers
894
- }),
1012
+ headers,
895
1013
  ...config.requestInit
896
1014
  });
897
1015
  } catch (err) {
@@ -963,6 +1081,7 @@ function authClientConfig(env, options) {
963
1081
  channel: isFoirPreview(env) ? "draft" : "published",
964
1082
  bearerToken: options?.bearerToken,
965
1083
  orgId: options?.orgId,
1084
+ orgUrl: options?.orgUrl,
966
1085
  headers: options?.headers,
967
1086
  fetch: options?.fetch
968
1087
  };
@@ -1014,6 +1133,9 @@ async function customerRefreshToken(env, refreshToken) {
1014
1133
  async function saveSession(adapter, result) {
1015
1134
  await adapter.set(TOKEN_KEY, result.token);
1016
1135
  await adapter.set(REFRESH_KEY, result.refreshToken);
1136
+ if (!result.customerId) {
1137
+ return await getSessionCustomer(adapter) ?? { id: "", email: "" };
1138
+ }
1017
1139
  await adapter.set(CUSTOMER_ID_KEY, result.customerId);
1018
1140
  await adapter.set(EMAIL_KEY, result.email);
1019
1141
  return { id: result.customerId, email: result.email };
@@ -1027,6 +1149,12 @@ async function clearSession(adapter) {
1027
1149
  async function getSessionToken(adapter) {
1028
1150
  return await adapter.get(TOKEN_KEY) ?? null;
1029
1151
  }
1152
+ async function getSessionCustomer(adapter) {
1153
+ const id = await adapter.get(CUSTOMER_ID_KEY) ?? null;
1154
+ const email = await adapter.get(EMAIL_KEY) ?? null;
1155
+ if (!id) return null;
1156
+ return { id, email: email ?? "" };
1157
+ }
1030
1158
  async function refreshSession(adapter, env) {
1031
1159
  const refreshToken = await adapter.get(REFRESH_KEY) ?? null;
1032
1160
  if (!refreshToken) return null;
@@ -1046,21 +1174,55 @@ function isAuthExpiredError(err) {
1046
1174
  (e) => /authentication required/i.test(e.message)
1047
1175
  );
1048
1176
  }
1049
- async function authedRequest(adapter, env, document, variables) {
1177
+ var refreshing = /* @__PURE__ */ new WeakMap();
1178
+ function refreshSessionOnce(adapter, env) {
1179
+ const running = refreshing.get(adapter);
1180
+ if (running) return running;
1181
+ const next = refreshSession(adapter, env).finally(() => refreshing.delete(adapter));
1182
+ refreshing.set(adapter, next);
1183
+ return next;
1184
+ }
1185
+ function sessionLapse(err) {
1186
+ if (err instanceof FoirAuthDowngradedError) {
1187
+ switch (err.downgrade.reason) {
1188
+ case "expired":
1189
+ return "refresh";
1190
+ case "invalid":
1191
+ return "expired";
1192
+ default:
1193
+ return null;
1194
+ }
1195
+ }
1196
+ return isAuthExpiredError(err) ? "refresh" : null;
1197
+ }
1198
+ async function withSessionRefresh(adapter, env, run) {
1050
1199
  const token = await getSessionToken(adapter);
1051
1200
  try {
1052
- return await foirRequest(env, document, variables, {
1053
- bearerToken: token ?? void 0
1054
- });
1201
+ return await run(token ?? void 0);
1055
1202
  } catch (err) {
1056
- if (!isAuthExpiredError(err)) throw err;
1057
- const refreshed = await refreshSession(adapter, env);
1058
- if (!refreshed) throw err;
1059
- return foirRequest(env, document, variables, {
1060
- bearerToken: refreshed.token
1061
- });
1203
+ const lapse = sessionLapse(err);
1204
+ if (!lapse) throw err;
1205
+ if (lapse === "expired") throw new FoirSessionExpiredError({ cause: err });
1206
+ const refreshed = await refreshSessionOnce(adapter, env);
1207
+ if (!refreshed) {
1208
+ if (err instanceof FoirAuthDowngradedError) throw new FoirSessionExpiredError({ cause: err });
1209
+ throw err;
1210
+ }
1211
+ try {
1212
+ return await run(refreshed.token);
1213
+ } catch (again) {
1214
+ if (again instanceof FoirAuthDowngradedError) throw new FoirSessionExpiredError({ cause: again });
1215
+ throw again;
1216
+ }
1062
1217
  }
1063
1218
  }
1219
+ async function authedRequest(adapter, env, document, variables) {
1220
+ return withSessionRefresh(
1221
+ adapter,
1222
+ env,
1223
+ (bearerToken) => foirRequest(env, document, variables, { bearerToken, rejectDowngrade: true })
1224
+ );
1225
+ }
1064
1226
 
1065
1227
  // src/push/operations.ts
1066
1228
  import { parse } from "graphql";