@foir/sdk 0.11.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 (38) hide show
  1. package/CHANGELOG.md +131 -2
  2. package/dist/app/client.d.ts +371 -0
  3. package/dist/app/contract/index.d.ts +23 -0
  4. package/dist/app/contract/proto/apps/v1/apps_pb.d.ts +1465 -0
  5. package/dist/app/contract/proto/apps/v1/discovery_pb.d.ts +678 -0
  6. package/dist/app/dispatch.d.ts +64 -0
  7. package/dist/app/errors.d.ts +50 -0
  8. package/dist/app/index.d.ts +40 -0
  9. package/dist/app/manifest.d.ts +56 -0
  10. package/dist/app/naming.d.ts +31 -0
  11. package/dist/app/record-key.d.ts +17 -0
  12. package/dist/app/ticket.d.ts +37 -0
  13. package/dist/app/vocabulary.generated.d.ts +20 -0
  14. package/dist/app-contract.js +124 -0
  15. package/dist/app.js +998 -0
  16. package/dist/auth/auth.d.ts +24 -6
  17. package/dist/auth/index.d.ts +2 -1
  18. package/dist/auth.js +139 -23
  19. package/dist/client.js +36 -2
  20. package/dist/data/index.d.ts +2 -2
  21. package/dist/data/transport.d.ts +34 -2
  22. package/dist/data.js +77 -2
  23. package/dist/graphql/client.d.ts +1 -1
  24. package/dist/graphql/foir-request.d.ts +1 -1
  25. package/dist/next.js +70 -2
  26. package/dist/placement/index.d.ts +129 -0
  27. package/dist/placement.js +144 -0
  28. package/dist/push.js +121 -13
  29. package/dist/react.js +70 -2
  30. package/dist/server/callback-client.d.ts +30 -9
  31. package/dist/server/continue-run.d.ts +56 -0
  32. package/dist/server/index.d.ts +5 -3
  33. package/dist/server/operation-types.d.ts +28 -7
  34. package/dist/server/require-scoped-token.d.ts +52 -7
  35. package/dist/server/verify-scoped-token.d.ts +109 -3
  36. package/dist/server.js +415 -127
  37. package/dist/transport/foir-http.d.ts +48 -0
  38. 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';
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";
@@ -119,6 +124,7 @@ function refuseCredentialHeaders(extra) {
119
124
  }
120
125
  }
121
126
  }
127
+ var FOIR_VISITOR_IP_HEADER = "X-Foir-Visitor-IP";
122
128
  function buildFoirHeaders(input) {
123
129
  const headers = {
124
130
  "Content-Type": "application/json"
@@ -143,6 +149,9 @@ function buildFoirHeaders(input) {
143
149
  if (input.orgUrl) {
144
150
  headers["X-Org-Url"] = input.orgUrl;
145
151
  }
152
+ if (input.visitorAddress) {
153
+ headers[FOIR_VISITOR_IP_HEADER] = input.visitorAddress;
154
+ }
146
155
  }
147
156
  if (input.extra) {
148
157
  for (const [key, value] of Object.entries(input.extra)) {
@@ -158,6 +167,26 @@ function foirApiEndpoint(apiUrl, path) {
158
167
  }
159
168
  return base + path;
160
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
+ };
161
190
  var warnedDowngrade = null;
162
191
  function reportAuthDowngrade(envelope) {
163
192
  const downgrade = envelope.extensions?.auth;
@@ -190,6 +219,9 @@ function answeredAnything(data) {
190
219
  return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
191
220
  }
192
221
  function readFoirEnvelope(json, options) {
222
+ if (options?.rejectDowngrade && json.extensions?.auth) {
223
+ throw new FoirAuthDowngradedError(json.extensions.auth);
224
+ }
193
225
  reportAuthDowngrade(json);
194
226
  refusePrunedDocument(json, options);
195
227
  if (json.errors?.length) {
@@ -314,7 +346,7 @@ async function foirRequest(env, document, variables, options) {
314
346
  );
315
347
  }
316
348
  const json = await res.json();
317
- return readFoirEnvelope(json);
349
+ return readFoirEnvelope(json, { rejectDowngrade: options?.rejectDowngrade });
318
350
  }
319
351
  async function readErrorEnvelope(res) {
320
352
  let errors;
@@ -819,6 +851,12 @@ function operationName(scope, op) {
819
851
  function foirDataEndpoint(apiUrl, path) {
820
852
  return foirApiEndpoint(apiUrl, path);
821
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
+ }
822
860
  async function resolveBearerToken(config) {
823
861
  const source = config.bearerToken;
824
862
  if (typeof source === "function") return await source() ?? void 0;
@@ -851,6 +889,7 @@ async function resolveFoirAuth(config) {
851
889
  bearerToken,
852
890
  orgId: config.orgId,
853
891
  orgUrl: config.orgUrl,
892
+ visitorAddress: await resolveVisitorAddress(config),
854
893
  extra: config.headers
855
894
  }),
856
895
  key: { apiKey: config.apiKey, bearerToken }
@@ -874,7 +913,33 @@ async function executeFoirDocument(config, plan) {
874
913
  }
875
914
  return data[plan.rootField];
876
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
+ }
877
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) {
878
943
  const doFetch = config.fetch ?? globalThis.fetch;
879
944
  const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
880
945
  const { headers, key } = await resolveFoirAuth(config);
@@ -923,7 +988,7 @@ async function runFoirDocument(config, document, mutation, variables, options) {
923
988
  });
924
989
  }
925
990
  const json = await res.json();
926
- return readFoirEnvelope(json, options);
991
+ return readFoirEnvelope(json, { ...options, rejectDowngrade: Boolean(key?.bearerToken) });
927
992
  }
928
993
  async function readErrorBody(res) {
929
994
  try {
@@ -1137,6 +1202,9 @@ async function registerOrganization(env, vars, options) {
1137
1202
  async function saveSession(adapter, result) {
1138
1203
  await adapter.set(TOKEN_KEY, result.token);
1139
1204
  await adapter.set(REFRESH_KEY, result.refreshToken);
1205
+ if (!result.customerId) {
1206
+ return await getSessionCustomer(adapter) ?? { id: "", email: "" };
1207
+ }
1140
1208
  await adapter.set(CUSTOMER_ID_KEY, result.customerId);
1141
1209
  await adapter.set(EMAIL_KEY, result.email);
1142
1210
  return { id: result.customerId, email: result.email };
@@ -1197,35 +1265,68 @@ function isAuthExpiredError(err) {
1197
1265
  (e) => /authentication required/i.test(e.message)
1198
1266
  );
1199
1267
  }
1200
- async function authedRequest(adapter, env, document, variables) {
1201
- const token = await getSessionToken(adapter);
1202
- try {
1203
- return await foirRequest(env, document, variables, {
1204
- bearerToken: token ?? void 0
1205
- });
1206
- } catch (err) {
1207
- if (!isAuthExpiredError(err)) throw err;
1208
- const refreshed = await refreshSession(adapter, env);
1209
- if (!refreshed) throw err;
1210
- return foirRequest(env, document, variables, {
1211
- bearerToken: refreshed.token
1212
- });
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
+ }
1213
1286
  }
1287
+ return isAuthExpiredError(err) ? "refresh" : null;
1214
1288
  }
1215
- async function authedOp(adapter, env, op, args) {
1289
+ async function withSessionRefresh(adapter, env, run) {
1216
1290
  const token = await getSessionToken(adapter);
1217
1291
  try {
1218
- return await authOp(env, op, args, { bearerToken: token ?? void 0 });
1292
+ return await run(token ?? void 0);
1219
1293
  } catch (err) {
1220
- if (!isAuthExpiredError(err)) throw err;
1221
- const refreshed = await refreshSession(adapter, env);
1222
- if (!refreshed) throw err;
1223
- 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
+ }
1224
1308
  }
1225
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
+ }
1226
1324
  async function requireAuth(adapter, options) {
1227
1325
  const token = await getSessionToken(adapter);
1228
- 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
+ }
1229
1330
  if (!token || !customer) {
1230
1331
  await options.onUnauthenticated();
1231
1332
  throw new Error("[foir] onUnauthenticated must not return");
@@ -1235,6 +1336,18 @@ async function requireAuth(adapter, options) {
1235
1336
  }
1236
1337
  return { id: customer.id, email: customer.email, isAdmin: false };
1237
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
+ }
1238
1351
  async function requireAdmin(adapter, options) {
1239
1352
  const user = await requireAuth(adapter, options);
1240
1353
  if (!user.isAdmin) {
@@ -1454,6 +1567,8 @@ function trimSlash(s) {
1454
1567
  }
1455
1568
  export {
1456
1569
  AUTH_OPS,
1570
+ FoirAuthDowngradedError,
1571
+ FoirSessionExpiredError,
1457
1572
  authedOp,
1458
1573
  authedRequest,
1459
1574
  clearSession,
@@ -1488,6 +1603,7 @@ export {
1488
1603
  myOrganizations,
1489
1604
  myProviderToken,
1490
1605
  refreshSession,
1606
+ refreshSessionOnce,
1491
1607
  registerOrganization,
1492
1608
  registerWithPassword,
1493
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";
@@ -131,6 +136,7 @@ function refuseCredentialHeaders(extra) {
131
136
  }
132
137
  }
133
138
  }
139
+ var FOIR_VISITOR_IP_HEADER = "X-Foir-Visitor-IP";
134
140
  function buildFoirHeaders(input) {
135
141
  const headers = {
136
142
  "Content-Type": "application/json"
@@ -155,6 +161,9 @@ function buildFoirHeaders(input) {
155
161
  if (input.orgUrl) {
156
162
  headers["X-Org-Url"] = input.orgUrl;
157
163
  }
164
+ if (input.visitorAddress) {
165
+ headers[FOIR_VISITOR_IP_HEADER] = input.visitorAddress;
166
+ }
158
167
  }
159
168
  if (input.extra) {
160
169
  for (const [key, value] of Object.entries(input.extra)) {
@@ -163,6 +172,26 @@ function buildFoirHeaders(input) {
163
172
  }
164
173
  return headers;
165
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
+ };
166
195
  var warnedDowngrade = null;
167
196
  function reportAuthDowngrade(envelope) {
168
197
  const downgrade = envelope.extensions?.auth;
@@ -195,6 +224,9 @@ function answeredAnything(data) {
195
224
  return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
196
225
  }
197
226
  function readFoirEnvelope(json, options) {
227
+ if (options?.rejectDowngrade && json.extensions?.auth) {
228
+ throw new FoirAuthDowngradedError(json.extensions.auth);
229
+ }
198
230
  reportAuthDowngrade(json);
199
231
  refusePrunedDocument(json, options);
200
232
  if (json.errors?.length) {
@@ -353,7 +385,7 @@ async function foirRequest(env, document, variables, options) {
353
385
  );
354
386
  }
355
387
  const json = await res.json();
356
- return readFoirEnvelope(json);
388
+ return readFoirEnvelope(json, { rejectDowngrade: options?.rejectDowngrade });
357
389
  }
358
390
  async function readErrorEnvelope(res) {
359
391
  let errors;
@@ -406,7 +438,9 @@ function createFoirContentClient(config) {
406
438
  }
407
439
  export {
408
440
  FOIR_CDN_BASE,
441
+ FoirAuthDowngradedError,
409
442
  FoirRequestError,
443
+ FoirSessionExpiredError,
410
444
  absolutiseFoirMediaUrl,
411
445
  createFoirContentClient,
412
446
  foirRequest,
@@ -81,5 +81,5 @@ export type { FoirConnection, FoirFieldsShape, FoirModelDefinition, FoirNamespac
81
81
  export type { FoirInclude, FoirModelOperation, FoirNamespaceOperation, FoirOmittableKey, FoirOpResult, FoirQuery, FoirRecordResult, FoirWriteResult, } from './selection';
82
82
  export { FoirDecimal, decimal } from './decimal';
83
83
  export type { FoirDecimalDivOptions, FoirDecimalInput, FoirDecimalRounding, } from './decimal';
84
- export { FoirRequestError } from '../transport/foir-http';
85
- export type { FoirEnv, FoirGraphQLErrorItem, FoirTokenProvider, FoirAuthDowngrade, } from '../transport/foir-http';
84
+ export { FoirRequestError, FoirAuthDowngradedError, FoirSessionExpiredError, FOIR_VISITOR_IP_HEADER, } from '../transport/foir-http';
85
+ export type { FoirEnv, FoirGraphQLErrorItem, FoirTokenProvider, FoirAuthDowngrade, FoirVisitorSource, } from '../transport/foir-http';
@@ -60,7 +60,7 @@
60
60
  * with more force here — this client is per-credential, so anything cached
61
61
  * across instances is cached across tenants.
62
62
  */
63
- import { FoirRequestError, type FoirTokenProvider } from '../transport/foir-http';
63
+ import { FoirRequestError, type FoirTokenProvider, type FoirVisitorSource } from '../transport/foir-http';
64
64
  import type { FoirDocumentPlan, FoirNamespaceSelections, FoirSelections, FoirSelectionUnions } from './document';
65
65
  import type { FoirSchemaDefinition } from './schema';
66
66
  import type { FoirSubjectRegistry } from './subjects';
@@ -211,6 +211,18 @@ export interface FoirKeyClientConfig<S extends FoirSchemaDefinition = FoirSchema
211
211
  * per-request; a bare string is used as-is.
212
212
  */
213
213
  bearerToken?: string | FoirTokenProvider;
214
+ /**
215
+ * Renew the customer bearer when an answer comes back served without it
216
+ * because it expired. Resolve the new token, or null when the session is
217
+ * over. It is called at most once per request, and one call is shared by the
218
+ * client's requests that find the bearer lapsed at the same time.
219
+ *
220
+ * With it, such a request is asked again with the new token, and fails with
221
+ * `FoirSessionExpiredError` when there is none. Without it, the request fails
222
+ * with `FoirAuthDowngradedError`. Either way it is never handed the signed-out
223
+ * answer as though it were the signed-in one.
224
+ */
225
+ refreshBearer?: () => Promise<string | null>;
214
226
  /**
215
227
  * The organization every request from this client acts in, sent as
216
228
  * `X-Org-Id`.
@@ -246,6 +258,24 @@ export interface FoirKeyClientConfig<S extends FoirSchemaDefinition = FoirSchema
246
258
  * appear.
247
259
  */
248
260
  orgId?: string;
261
+ /**
262
+ * The address of the visitor a server rendering pages is calling for, or a
263
+ * function resolving it for each request, sent as `X-Foir-Visitor-IP`. The
264
+ * public API's per-visitor limit then applies to that visitor rather than to
265
+ * your server, which otherwise counts as one visitor for every page it
266
+ * renders.
267
+ *
268
+ * Give it the address your host guarantees: the one its edge sets and a
269
+ * visitor cannot. A header a visitor can write lets them name a fresh address
270
+ * per request, which is exactly what the limit exists to stop. The docs list
271
+ * the value for each common host.
272
+ *
273
+ * A secret key only, and one holding `visitors:forward`: the API refuses the
274
+ * header, naming the permission, from any other. A client built from a public
275
+ * key is refused `visitor` here, because a browser already calls as its own
276
+ * visitor and a public key cannot hold the permission.
277
+ */
278
+ visitor?: FoirVisitorSource;
249
279
  /**
250
280
  * The organization every request from this client acts in, named by the
251
281
  * address of the page it is for, sent as `X-Org-Url`.
@@ -483,7 +513,7 @@ export interface FoirKeyClientConfig<S extends FoirSchemaDefinition = FoirSchema
483
513
  * (a customer bearer, an organization, a channel), and the subscription socket,
484
514
  * which only a key can open.
485
515
  */
486
- type FoirKeySideOption = 'apiKey' | 'dispatch' | 'channel' | 'bearerToken' | 'orgId' | 'orgUrl' | 'wsUrl' | 'webSocketImpl' | 'reconnect' | 'connectionParams';
516
+ type FoirKeySideOption = 'apiKey' | 'dispatch' | 'channel' | 'bearerToken' | 'refreshBearer' | 'orgId' | 'orgUrl' | 'visitor' | 'wsUrl' | 'webSocketImpl' | 'reconnect' | 'connectionParams';
487
517
  /**
488
518
  * A client that calls Foir AS an operation, from inside the endpoint the
489
519
  * operation dispatched to. See {@link FoirDispatchCredential}.
@@ -513,8 +543,10 @@ export interface FoirDispatchClientConfig<S extends FoirSchemaDefinition = FoirS
513
543
  apiKey?: never;
514
544
  channel?: never;
515
545
  bearerToken?: never;
546
+ refreshBearer?: never;
516
547
  orgId?: never;
517
548
  orgUrl?: never;
549
+ visitor?: never;
518
550
  wsUrl?: never;
519
551
  webSocketImpl?: never;
520
552
  reconnect?: never;
package/dist/data.js CHANGED
@@ -62,7 +62,12 @@ var FoirRequestError = class extends Error {
62
62
  * failed on the first attempt for an hour.
63
63
  */
64
64
  get retryable() {
65
- if (this.status === void 0) return true;
65
+ if (this.status === void 0) {
66
+ if (this.graphQLErrors?.length || this.prunedSelections?.length) {
67
+ return this.code === "RATE_LIMITED" || this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
68
+ }
69
+ return true;
70
+ }
66
71
  if (this.status === 429 || this.code === "RATE_LIMITED") return true;
67
72
  if (this.status >= 500) return true;
68
73
  return this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
@@ -92,6 +97,7 @@ function refuseCredentialHeaders(extra) {
92
97
  }
93
98
  }
94
99
  }
100
+ var FOIR_VISITOR_IP_HEADER = "X-Foir-Visitor-IP";
95
101
  function buildFoirHeaders(input) {
96
102
  const headers = {
97
103
  "Content-Type": "application/json"
@@ -116,6 +122,9 @@ function buildFoirHeaders(input) {
116
122
  if (input.orgUrl) {
117
123
  headers["X-Org-Url"] = input.orgUrl;
118
124
  }
125
+ if (input.visitorAddress) {
126
+ headers[FOIR_VISITOR_IP_HEADER] = input.visitorAddress;
127
+ }
119
128
  }
120
129
  if (input.extra) {
121
130
  for (const [key, value] of Object.entries(input.extra)) {
@@ -131,6 +140,26 @@ function foirApiEndpoint(apiUrl, path) {
131
140
  }
132
141
  return base + path;
133
142
  }
143
+ var FoirAuthDowngradedError = class extends FoirRequestError {
144
+ downgrade;
145
+ constructor(downgrade) {
146
+ super(
147
+ `[foir] your customer token was ${downgrade.customerToken} (${downgrade.reason}), so the request was served as ${downgrade.servedAs}`,
148
+ { graphQLErrors: [{ message: "customer token rejected", extensions: { code: "AUTH_DOWNGRADED" } }] }
149
+ );
150
+ this.name = "FoirAuthDowngradedError";
151
+ this.downgrade = downgrade;
152
+ }
153
+ };
154
+ var FoirSessionExpiredError = class extends FoirRequestError {
155
+ constructor(options) {
156
+ super("[foir] the customer session has expired and could not be refreshed: sign in again", {
157
+ cause: options?.cause,
158
+ graphQLErrors: [{ message: "session expired", extensions: { code: "SESSION_EXPIRED" } }]
159
+ });
160
+ this.name = "FoirSessionExpiredError";
161
+ }
162
+ };
134
163
  var warnedDowngrade = null;
135
164
  function reportAuthDowngrade(envelope) {
136
165
  const downgrade = envelope.extensions?.auth;
@@ -163,6 +192,9 @@ function answeredAnything(data) {
163
192
  return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
164
193
  }
165
194
  function readFoirEnvelope(json, options) {
195
+ if (options?.rejectDowngrade && json.extensions?.auth) {
196
+ throw new FoirAuthDowngradedError(json.extensions.auth);
197
+ }
166
198
  reportAuthDowngrade(json);
167
199
  refusePrunedDocument(json, options);
168
200
  if (json.errors?.length) {
@@ -224,6 +256,12 @@ var FoirSchemaDriftError = class extends FoirRequestError {
224
256
  function foirDataEndpoint(apiUrl, path) {
225
257
  return foirApiEndpoint(apiUrl, path);
226
258
  }
259
+ async function resolveVisitorAddress(config) {
260
+ const source = config.visitor;
261
+ const value = typeof source === "function" ? await source() : source;
262
+ const address = value?.trim();
263
+ return address ? address : void 0;
264
+ }
227
265
  async function resolveBearerToken(config) {
228
266
  const source = config.bearerToken;
229
267
  if (typeof source === "function") return await source() ?? void 0;
@@ -256,6 +294,7 @@ async function resolveFoirAuth(config) {
256
294
  bearerToken,
257
295
  orgId: config.orgId,
258
296
  orgUrl: config.orgUrl,
297
+ visitorAddress: await resolveVisitorAddress(config),
259
298
  extra: config.headers
260
299
  }),
261
300
  key: { apiKey: config.apiKey, bearerToken }
@@ -270,6 +309,11 @@ function assertFoirCredential(config) {
270
309
  "[foir] createFoirClient needs both apiUrl and apiKey (or, inside an operation endpoint, apiUrl and dispatch)"
271
310
  );
272
311
  }
312
+ if (loose.visitor !== void 0 && !loose.apiKey.startsWith("sk_")) {
313
+ throw new FoirRequestError(
314
+ "[foir] createFoirClient: `visitor` names the visitor a server calls for, and only a secret key holding visitors:forward may send it. A public key already calls as its visitor, and cannot hold the permission. Drop `visitor`, or build this client from your server's secret key."
315
+ );
316
+ }
273
317
  if (operation !== void 0) {
274
318
  throw new FoirRequestError(
275
319
  `[foir] createFoirClient: the module passed as \`generated\` was generated for operation "${operation}" (\`foir types --operation ${operation}\`), so its types are what that operation's dispatch token is served, and this client holds an api key, which is served its own surface. Generate the key's types with \`foir types\`, or build this client from the dispatch token inside the operation's endpoint.`
@@ -280,8 +324,10 @@ function assertFoirCredential(config) {
280
324
  const keySide = [
281
325
  "apiKey",
282
326
  "bearerToken",
327
+ "refreshBearer",
283
328
  "orgId",
284
329
  "orgUrl",
330
+ "visitor",
285
331
  "channel",
286
332
  "wsUrl",
287
333
  "webSocketImpl",
@@ -347,7 +393,33 @@ async function executeFoirDocument(config, plan) {
347
393
  async function executeFoirRawDocument(config, document, mutation, variables, options) {
348
394
  return await runFoirDocument(config, document, mutation, variables, options);
349
395
  }
396
+ var renewing = /* @__PURE__ */ new WeakMap();
397
+ function renewBearerOnce(refresh) {
398
+ const running = renewing.get(refresh);
399
+ if (running) return running;
400
+ const next = refresh().finally(() => renewing.delete(refresh));
401
+ renewing.set(refresh, next);
402
+ return next;
403
+ }
350
404
  async function runFoirDocument(config, document, mutation, variables, options) {
405
+ try {
406
+ return await sendFoirDocument(config, document, mutation, variables, options);
407
+ } catch (err) {
408
+ const refresh = config.refreshBearer;
409
+ if (!(err instanceof FoirAuthDowngradedError) || !refresh || err.downgrade.reason === "wrong_project") {
410
+ throw err;
411
+ }
412
+ const token = err.downgrade.reason === "expired" ? await renewBearerOnce(refresh) : null;
413
+ if (!token) throw new FoirSessionExpiredError({ cause: err });
414
+ try {
415
+ return await sendFoirDocument({ ...config, bearerToken: token }, document, mutation, variables, options);
416
+ } catch (again) {
417
+ if (again instanceof FoirAuthDowngradedError) throw new FoirSessionExpiredError({ cause: again });
418
+ throw again;
419
+ }
420
+ }
421
+ }
422
+ async function sendFoirDocument(config, document, mutation, variables, options) {
351
423
  const doFetch = config.fetch ?? globalThis.fetch;
352
424
  const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
353
425
  const { headers, key } = await resolveFoirAuth(config);
@@ -396,7 +468,7 @@ async function runFoirDocument(config, document, mutation, variables, options) {
396
468
  });
397
469
  }
398
470
  const json = await res.json();
399
- return readFoirEnvelope(json, options);
471
+ return readFoirEnvelope(json, { ...options, rejectDowngrade: Boolean(key?.bearerToken) });
400
472
  }
401
473
  async function readErrorBody(res) {
402
474
  try {
@@ -2190,9 +2262,12 @@ function decimal(value) {
2190
2262
  return new FoirDecimal(value);
2191
2263
  }
2192
2264
  export {
2265
+ FOIR_VISITOR_IP_HEADER,
2266
+ FoirAuthDowngradedError,
2193
2267
  FoirDecimal,
2194
2268
  FoirRequestError,
2195
2269
  FoirSchemaDriftError,
2270
+ FoirSessionExpiredError,
2196
2271
  FoirSocketTransport,
2197
2272
  createFoirClient,
2198
2273
  createFoirClientFromEnv,