@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
@@ -213,7 +213,15 @@ export declare function registerOrganization(env: FoirEnv, vars: {
213
213
  name: string;
214
214
  parentId?: string;
215
215
  }, options?: AuthOpOptions): Promise<MyOrganization>;
216
- /** Persist an auth result to the session; returns the customer identity. */
216
+ /**
217
+ * Persist an auth result to the session; returns the customer identity.
218
+ *
219
+ * A result that names no customer keeps the identity already stored. A refresh
220
+ * rotates the tokens of a session that already knows who it belongs to, and a
221
+ * platform that answers a refresh without the customer must not blank that:
222
+ * {@link requireAuth} reads the stored identity, so a valid token beside an
223
+ * empty identity reads as signed out.
224
+ */
217
225
  export declare function saveSession(adapter: SessionAdapter, result: CustomerAuthResult): Promise<FoirCustomer>;
218
226
  /** Clear all Foir auth keys from the session. */
219
227
  export declare function clearSession(adapter: SessionAdapter): Promise<void>;
@@ -262,13 +270,16 @@ export declare function signOut(adapter: SessionAdapter, env: FoirEnv): Promise<
262
270
  * or the refresh fails (the session is cleared in that case).
263
271
  */
264
272
  export declare function refreshSession(adapter: SessionAdapter, env: FoirEnv): Promise<FoirAuthTokens | null>;
273
+ /** {@link refreshSession}, shared by every request of the session that asks while one is running. */
274
+ export declare function refreshSessionOnce(adapter: SessionAdapter, env: FoirEnv): Promise<FoirAuthTokens | null>;
265
275
  /**
266
276
  * Run a bearer-authed GraphQL operation with refresh-with-retry: uses the
267
- * stored access token, and on a 401-shaped failure (rejected/expired bearer)
268
- * attempts a single token refresh and retries once. Any other error — network
269
- * blips, server errors, validation — propagates without a retry so transient
270
- * mutation failures can't be duplicated. Throws the original error if there's
271
- * no refresh path.
277
+ * stored access token, and when the session lapsed (a 401-shaped failure, or an
278
+ * answer served without the token) refreshes it once and retries once. Any
279
+ * other error — network blips, server errors, validation — propagates without a
280
+ * retry so transient mutation failures can't be duplicated. A 401 with no
281
+ * refresh path throws the original error; a lapsed session that cannot be
282
+ * renewed throws {@link FoirSessionExpiredError}.
272
283
  */
273
284
  export declare function authedRequest<TData, TVariables>(adapter: SessionAdapter, env: FoirEnv, document: TypedDocumentNode<TData, TVariables>, variables?: TVariables): Promise<TData>;
274
285
  /**
@@ -299,6 +310,13 @@ export interface RequireAuthOptions {
299
310
  * (e.g. `throw redirect(loginUrl)`). Must not return.
300
311
  */
301
312
  onUnauthenticated: () => never | Promise<never>;
313
+ /**
314
+ * The environment to ask when a session holds a token but no identity. With
315
+ * it, the identity is read once from `currentUser` and saved, rather than a
316
+ * session whose token verifies being turned away. Without it, such a session
317
+ * is treated as signed out.
318
+ */
319
+ env?: FoirEnv;
302
320
  }
303
321
  /**
304
322
  * Require a signed-in customer. Resolves the {@link FoirUser} via
@@ -10,8 +10,9 @@
10
10
  * the caller's own manifest advertises, through the same op transport
11
11
  * `@foir/sdk/data` uses, so `graphql` is not on this module's path at all.
12
12
  */
13
- export { customerLogin, customerRegister, customerRequestOTP, customerLoginOTP, customerRefreshToken, customerLoginWithProvider, customerProviderCallback, customerProviderVerifyOTP, getAuthProviders, getAuthConfig, getDefaultAuthProvider, customerSignupContext, customerJoinWaitlist, customerRequestPasswordReset, customerResetPassword, customerUpdatePassword, customerVerifyEmail, currentUser, myProviderToken, customerLogout, deleteMyAccount, myOrganizations, selectOrganization, registerOrganization, saveSession, clearSession, getSessionToken, getSessionCustomer, loginWithPassword, registerWithPassword, loginWithOTP, resetPasswordAndSignIn, refreshSession, signOut, authedRequest, authedOp, requireAuth, requireAdmin, } from './auth';
13
+ export { customerLogin, customerRegister, customerRequestOTP, customerLoginOTP, customerRefreshToken, customerLoginWithProvider, customerProviderCallback, customerProviderVerifyOTP, getAuthProviders, getAuthConfig, getDefaultAuthProvider, customerSignupContext, customerJoinWaitlist, customerRequestPasswordReset, customerResetPassword, customerUpdatePassword, customerVerifyEmail, currentUser, myProviderToken, customerLogout, deleteMyAccount, myOrganizations, selectOrganization, registerOrganization, saveSession, clearSession, getSessionToken, getSessionCustomer, loginWithPassword, registerWithPassword, loginWithOTP, resetPasswordAndSignIn, refreshSession, refreshSessionOnce, signOut, authedRequest, authedOp, requireAuth, requireAdmin, } from './auth';
14
14
  export type { FoirAuthTokens, FoirCustomer, FoirUser, SessionAdapter, RequireAuthOptions, RequireAdminOptions, } from './auth';
15
+ export { FoirAuthDowngradedError, FoirSessionExpiredError } from '../graphql/foir-request';
15
16
  export { AUTH_OPS } from './ops';
16
17
  export type { AuthOpName, AuthOpOptions } from './ops';
17
18
  export { hostedLoginUrl, hostedLogoutUrl, fetchHostedSession } from './hosted';
@@ -81,6 +81,14 @@ export interface AuthOpOptions {
81
81
  * already confined to an organization may only name its own.
82
82
  */
83
83
  orgId?: string;
84
+ /**
85
+ * The organization the call acts in, named by the address of the page it is
86
+ * for, sent as `X-Org-Url`: same contract as `FoirClientConfig.orgUrl`. A
87
+ * storefront on a path per organization signs its customers in on those
88
+ * pages, and names the organization the way every other request from them
89
+ * does.
90
+ */
91
+ orgUrl?: string;
84
92
  /**
85
93
  * `fetch` implementation. Defaults to `globalThis.fetch`. Injectable for
86
94
  * tests and for runtimes that hand you a bound `fetch` (Hydrogen/Oxygen).
package/dist/auth.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
  };
@@ -1083,6 +1202,9 @@ async function registerOrganization(env, vars, options) {
1083
1202
  async function saveSession(adapter, result) {
1084
1203
  await adapter.set(TOKEN_KEY, result.token);
1085
1204
  await adapter.set(REFRESH_KEY, result.refreshToken);
1205
+ if (!result.customerId) {
1206
+ return await getSessionCustomer(adapter) ?? { id: "", email: "" };
1207
+ }
1086
1208
  await adapter.set(CUSTOMER_ID_KEY, result.customerId);
1087
1209
  await adapter.set(EMAIL_KEY, result.email);
1088
1210
  return { id: result.customerId, email: result.email };
@@ -1143,35 +1265,68 @@ function isAuthExpiredError(err) {
1143
1265
  (e) => /authentication required/i.test(e.message)
1144
1266
  );
1145
1267
  }
1146
- async function authedRequest(adapter, env, document, variables) {
1147
- const token = await getSessionToken(adapter);
1148
- try {
1149
- return await foirRequest(env, document, variables, {
1150
- bearerToken: token ?? void 0
1151
- });
1152
- } catch (err) {
1153
- if (!isAuthExpiredError(err)) throw err;
1154
- const refreshed = await refreshSession(adapter, env);
1155
- if (!refreshed) throw err;
1156
- return foirRequest(env, document, variables, {
1157
- bearerToken: refreshed.token
1158
- });
1268
+ var refreshing = /* @__PURE__ */ new WeakMap();
1269
+ function refreshSessionOnce(adapter, env) {
1270
+ const running = refreshing.get(adapter);
1271
+ if (running) return running;
1272
+ const next = refreshSession(adapter, env).finally(() => refreshing.delete(adapter));
1273
+ refreshing.set(adapter, next);
1274
+ return next;
1275
+ }
1276
+ function sessionLapse(err) {
1277
+ if (err instanceof FoirAuthDowngradedError) {
1278
+ switch (err.downgrade.reason) {
1279
+ case "expired":
1280
+ return "refresh";
1281
+ case "invalid":
1282
+ return "expired";
1283
+ default:
1284
+ return null;
1285
+ }
1159
1286
  }
1287
+ return isAuthExpiredError(err) ? "refresh" : null;
1160
1288
  }
1161
- async function authedOp(adapter, env, op, args) {
1289
+ async function withSessionRefresh(adapter, env, run) {
1162
1290
  const token = await getSessionToken(adapter);
1163
1291
  try {
1164
- return await authOp(env, op, args, { bearerToken: token ?? void 0 });
1292
+ return await run(token ?? void 0);
1165
1293
  } catch (err) {
1166
- if (!isAuthExpiredError(err)) throw err;
1167
- const refreshed = await refreshSession(adapter, env);
1168
- if (!refreshed) throw err;
1169
- return authOp(env, op, args, { bearerToken: refreshed.token });
1294
+ const lapse = sessionLapse(err);
1295
+ if (!lapse) throw err;
1296
+ if (lapse === "expired") throw new FoirSessionExpiredError({ cause: err });
1297
+ const refreshed = await refreshSessionOnce(adapter, env);
1298
+ if (!refreshed) {
1299
+ if (err instanceof FoirAuthDowngradedError) throw new FoirSessionExpiredError({ cause: err });
1300
+ throw err;
1301
+ }
1302
+ try {
1303
+ return await run(refreshed.token);
1304
+ } catch (again) {
1305
+ if (again instanceof FoirAuthDowngradedError) throw new FoirSessionExpiredError({ cause: again });
1306
+ throw again;
1307
+ }
1170
1308
  }
1171
1309
  }
1310
+ async function authedRequest(adapter, env, document, variables) {
1311
+ return withSessionRefresh(
1312
+ adapter,
1313
+ env,
1314
+ (bearerToken) => foirRequest(env, document, variables, { bearerToken, rejectDowngrade: true })
1315
+ );
1316
+ }
1317
+ async function authedOp(adapter, env, op, args) {
1318
+ return withSessionRefresh(
1319
+ adapter,
1320
+ env,
1321
+ (bearerToken) => authOp(env, op, args, { bearerToken })
1322
+ );
1323
+ }
1172
1324
  async function requireAuth(adapter, options) {
1173
1325
  const token = await getSessionToken(adapter);
1174
- const customer = await getSessionCustomer(adapter);
1326
+ let customer = await getSessionCustomer(adapter);
1327
+ if (token && !customer && options.env) {
1328
+ customer = await recoverSessionCustomer(adapter, options.env, token);
1329
+ }
1175
1330
  if (!token || !customer) {
1176
1331
  await options.onUnauthenticated();
1177
1332
  throw new Error("[foir] onUnauthenticated must not return");
@@ -1181,6 +1336,18 @@ async function requireAuth(adapter, options) {
1181
1336
  }
1182
1337
  return { id: customer.id, email: customer.email, isAdmin: false };
1183
1338
  }
1339
+ async function recoverSessionCustomer(adapter, env, token) {
1340
+ let me;
1341
+ try {
1342
+ me = await currentUser(env, { bearerToken: token });
1343
+ } catch {
1344
+ return null;
1345
+ }
1346
+ if (!me?.id) return null;
1347
+ await adapter.set(CUSTOMER_ID_KEY, me.id);
1348
+ await adapter.set(EMAIL_KEY, me.email);
1349
+ return { id: me.id, email: me.email };
1350
+ }
1184
1351
  async function requireAdmin(adapter, options) {
1185
1352
  const user = await requireAuth(adapter, options);
1186
1353
  if (!user.isAdmin) {
@@ -1400,6 +1567,8 @@ function trimSlash(s) {
1400
1567
  }
1401
1568
  export {
1402
1569
  AUTH_OPS,
1570
+ FoirAuthDowngradedError,
1571
+ FoirSessionExpiredError,
1403
1572
  authedOp,
1404
1573
  authedRequest,
1405
1574
  clearSession,
@@ -1434,6 +1603,7 @@ export {
1434
1603
  myOrganizations,
1435
1604
  myProviderToken,
1436
1605
  refreshSession,
1606
+ refreshSessionOnce,
1437
1607
  registerOrganization,
1438
1608
  registerWithPassword,
1439
1609
  requireAdmin,
package/dist/client.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";
@@ -115,19 +120,50 @@ function readFoirRetryAfterMs(headers) {
115
120
  const seconds = Number(headers.get("Retry-After"));
116
121
  return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
117
122
  }
123
+ var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
124
+ "x-api-key",
125
+ "authorization",
126
+ "x-foir-token",
127
+ "x-foir-context"
128
+ ]);
129
+ function refuseCredentialHeaders(extra) {
130
+ if (!extra) return;
131
+ for (const name of Object.keys(extra)) {
132
+ if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
133
+ throw new FoirRequestError(
134
+ `[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.`
135
+ );
136
+ }
137
+ }
138
+ }
139
+ var FOIR_VISITOR_IP_HEADER = "X-Foir-Visitor-IP";
118
140
  function buildFoirHeaders(input) {
119
141
  const headers = {
120
- "Content-Type": "application/json",
121
- "x-api-key": input.apiKey
142
+ "Content-Type": "application/json"
122
143
  };
123
- if (input.draftChannel) {
124
- headers["X-Foir-Schema-Channel"] = "draft";
125
- }
126
- if (input.bearerToken) {
127
- headers["Authorization"] = `Bearer ${input.bearerToken}`;
128
- }
129
- if (input.orgId) {
130
- headers["X-Org-Id"] = input.orgId;
144
+ if (input.dispatchToken !== void 0) {
145
+ refuseCredentialHeaders(input.extra);
146
+ headers["X-Foir-Token"] = input.dispatchToken;
147
+ if (input.dispatchContext) {
148
+ headers["X-Foir-Context"] = input.dispatchContext;
149
+ }
150
+ } else {
151
+ headers["x-api-key"] = input.apiKey;
152
+ if (input.draftChannel) {
153
+ headers["X-Foir-Schema-Channel"] = "draft";
154
+ }
155
+ if (input.bearerToken) {
156
+ headers["Authorization"] = `Bearer ${input.bearerToken}`;
157
+ }
158
+ if (input.orgId) {
159
+ headers["X-Org-Id"] = input.orgId;
160
+ }
161
+ if (input.orgUrl) {
162
+ headers["X-Org-Url"] = input.orgUrl;
163
+ }
164
+ if (input.visitorAddress) {
165
+ headers[FOIR_VISITOR_IP_HEADER] = input.visitorAddress;
166
+ }
131
167
  }
132
168
  if (input.extra) {
133
169
  for (const [key, value] of Object.entries(input.extra)) {
@@ -136,6 +172,26 @@ function buildFoirHeaders(input) {
136
172
  }
137
173
  return headers;
138
174
  }
175
+ var FoirAuthDowngradedError = class extends FoirRequestError {
176
+ downgrade;
177
+ constructor(downgrade) {
178
+ super(
179
+ `[foir] your customer token was ${downgrade.customerToken} (${downgrade.reason}), so the request was served as ${downgrade.servedAs}`,
180
+ { graphQLErrors: [{ message: "customer token rejected", extensions: { code: "AUTH_DOWNGRADED" } }] }
181
+ );
182
+ this.name = "FoirAuthDowngradedError";
183
+ this.downgrade = downgrade;
184
+ }
185
+ };
186
+ var FoirSessionExpiredError = class extends FoirRequestError {
187
+ constructor(options) {
188
+ super("[foir] the customer session has expired and could not be refreshed: sign in again", {
189
+ cause: options?.cause,
190
+ graphQLErrors: [{ message: "session expired", extensions: { code: "SESSION_EXPIRED" } }]
191
+ });
192
+ this.name = "FoirSessionExpiredError";
193
+ }
194
+ };
139
195
  var warnedDowngrade = null;
140
196
  function reportAuthDowngrade(envelope) {
141
197
  const downgrade = envelope.extensions?.auth;
@@ -164,12 +220,18 @@ function refusePrunedDocument(json, options) {
164
220
  `[foir] selections stripped from this query: ${selections.map(describe).join(", ")}`
165
221
  );
166
222
  }
223
+ function answeredAnything(data) {
224
+ return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
225
+ }
167
226
  function readFoirEnvelope(json, options) {
227
+ if (options?.rejectDowngrade && json.extensions?.auth) {
228
+ throw new FoirAuthDowngradedError(json.extensions.auth);
229
+ }
168
230
  reportAuthDowngrade(json);
169
231
  refusePrunedDocument(json, options);
170
232
  if (json.errors?.length) {
171
233
  const msg = json.errors.map((e) => e.message).join("; ");
172
- if (json.data && !options?.strict) {
234
+ if (answeredAnything(json.data) && !options?.strict) {
173
235
  console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
174
236
  } else {
175
237
  throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
@@ -271,12 +333,13 @@ async function foirRequest(env, document, variables, options) {
271
333
  apiKey,
272
334
  draftChannel: isFoirPreview(env),
273
335
  bearerToken: options?.bearerToken,
274
- orgId: options?.orgId
336
+ orgId: options?.orgId,
337
+ orgUrl: options?.orgUrl
275
338
  });
276
339
  const documentText = print2(document);
277
340
  let res;
278
341
  try {
279
- if (isCdnEligible(env, documentText, options?.bearerToken, options?.orgId)) {
342
+ if (isCdnEligible(env, documentText, options?.bearerToken, options?.orgId ?? options?.orgUrl)) {
280
343
  const hash = await documentHash(documentText);
281
344
  let shared = await fetch(buildCdnUrl(endpoint, apiKey, hash, variables), {
282
345
  method: "GET",
@@ -322,7 +385,7 @@ async function foirRequest(env, document, variables, options) {
322
385
  );
323
386
  }
324
387
  const json = await res.json();
325
- return readFoirEnvelope(json);
388
+ return readFoirEnvelope(json, { rejectDowngrade: options?.rejectDowngrade });
326
389
  }
327
390
  async function readErrorEnvelope(res) {
328
391
  let errors;
@@ -375,7 +438,9 @@ function createFoirContentClient(config) {
375
438
  }
376
439
  export {
377
440
  FOIR_CDN_BASE,
441
+ FoirAuthDowngradedError,
378
442
  FoirRequestError,
443
+ FoirSessionExpiredError,
379
444
  absolutiseFoirMediaUrl,
380
445
  createFoirContentClient,
381
446
  foirRequest,