@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
@@ -6,7 +6,7 @@
6
6
  * Consumers that bring their own GraphQL client (Apollo/urql/react-query) use
7
7
  * the generated `TypedDocumentNode`s directly and replicate those two headers.
8
8
  */
9
- export { foirRequest, isFoirPreview, absolutiseFoirMediaUrl, FOIR_CDN_BASE, FoirRequestError, } from './foir-request';
9
+ export { foirRequest, isFoirPreview, absolutiseFoirMediaUrl, FOIR_CDN_BASE, FoirRequestError, FoirAuthDowngradedError, FoirSessionExpiredError, } from './foir-request';
10
10
  export type { FoirEnv, FoirRequestOptions } from './foir-request';
11
11
  /**
12
12
  * Failure classification. Branch on these rather than on `err.message`: the
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
26
26
  import { type FoirEnv, type FoirRequestOptions } from '../transport/foir-http';
27
- export { FoirRequestError, isFoirPreview, isFoirRateLimited, isFoirUnauthenticated, isFoirForbidden, isFoirNotFound, readFoirRateLimit, type FoirEnv, type FoirErrorCode, type FoirErrorExtensions, type FoirGraphQLErrorItem, type FoirRateLimitSnapshot, type FoirRequestOptions, type FoirAuthDowngrade, } from '../transport/foir-http';
27
+ export { FoirRequestError, FoirAuthDowngradedError, FoirSessionExpiredError, isFoirPreview, isFoirRateLimited, isFoirUnauthenticated, isFoirForbidden, isFoirNotFound, readFoirRateLimit, type FoirEnv, type FoirErrorCode, type FoirErrorExtensions, type FoirGraphQLErrorItem, type FoirRateLimitSnapshot, type FoirRequestOptions, type FoirAuthDowngrade, } from '../transport/foir-http';
28
28
  export { withFoirRetry, foirRetryDelayMs, type FoirRetryPolicy, } from '../transport/retry';
29
29
  /** Default Foir CDN origin used to absolutise relative media URLs. */
30
30
  export declare const FOIR_CDN_BASE = "https://cdn.foir.dev/";
package/dist/next.js CHANGED
@@ -87,7 +87,12 @@ var FoirRequestError = class extends Error {
87
87
  * failed on the first attempt for an hour.
88
88
  */
89
89
  get retryable() {
90
- if (this.status === void 0) return true;
90
+ if (this.status === void 0) {
91
+ if (this.graphQLErrors?.length || this.prunedSelections?.length) {
92
+ return this.code === "RATE_LIMITED" || this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
93
+ }
94
+ return true;
95
+ }
91
96
  if (this.status === 429 || this.code === "RATE_LIMITED") return true;
92
97
  if (this.status >= 500) return true;
93
98
  return this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
@@ -117,6 +122,7 @@ function refuseCredentialHeaders(extra) {
117
122
  }
118
123
  }
119
124
  }
125
+ var FOIR_VISITOR_IP_HEADER = "X-Foir-Visitor-IP";
120
126
  function buildFoirHeaders(input) {
121
127
  const headers = {
122
128
  "Content-Type": "application/json"
@@ -141,6 +147,9 @@ function buildFoirHeaders(input) {
141
147
  if (input.orgUrl) {
142
148
  headers["X-Org-Url"] = input.orgUrl;
143
149
  }
150
+ if (input.visitorAddress) {
151
+ headers[FOIR_VISITOR_IP_HEADER] = input.visitorAddress;
152
+ }
144
153
  }
145
154
  if (input.extra) {
146
155
  for (const [key, value] of Object.entries(input.extra)) {
@@ -156,6 +165,26 @@ function foirApiEndpoint(apiUrl, path) {
156
165
  }
157
166
  return base + path;
158
167
  }
168
+ var FoirAuthDowngradedError = class extends FoirRequestError {
169
+ downgrade;
170
+ constructor(downgrade) {
171
+ super(
172
+ `[foir] your customer token was ${downgrade.customerToken} (${downgrade.reason}), so the request was served as ${downgrade.servedAs}`,
173
+ { graphQLErrors: [{ message: "customer token rejected", extensions: { code: "AUTH_DOWNGRADED" } }] }
174
+ );
175
+ this.name = "FoirAuthDowngradedError";
176
+ this.downgrade = downgrade;
177
+ }
178
+ };
179
+ var FoirSessionExpiredError = class extends FoirRequestError {
180
+ constructor(options) {
181
+ super("[foir] the customer session has expired and could not be refreshed: sign in again", {
182
+ cause: options?.cause,
183
+ graphQLErrors: [{ message: "session expired", extensions: { code: "SESSION_EXPIRED" } }]
184
+ });
185
+ this.name = "FoirSessionExpiredError";
186
+ }
187
+ };
159
188
  var warnedDowngrade = null;
160
189
  function reportAuthDowngrade(envelope) {
161
190
  const downgrade = envelope.extensions?.auth;
@@ -188,6 +217,9 @@ function answeredAnything(data) {
188
217
  return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
189
218
  }
190
219
  function readFoirEnvelope(json, options) {
220
+ if (options?.rejectDowngrade && json.extensions?.auth) {
221
+ throw new FoirAuthDowngradedError(json.extensions.auth);
222
+ }
191
223
  reportAuthDowngrade(json);
192
224
  refusePrunedDocument(json, options);
193
225
  if (json.errors?.length) {
@@ -706,6 +738,12 @@ function operationName(scope, op) {
706
738
  function foirDataEndpoint(apiUrl, path) {
707
739
  return foirApiEndpoint(apiUrl, path);
708
740
  }
741
+ async function resolveVisitorAddress(config) {
742
+ const source = config.visitor;
743
+ const value = typeof source === "function" ? await source() : source;
744
+ const address = value?.trim();
745
+ return address ? address : void 0;
746
+ }
709
747
  async function resolveBearerToken(config) {
710
748
  const source = config.bearerToken;
711
749
  if (typeof source === "function") return await source() ?? void 0;
@@ -738,6 +776,7 @@ async function resolveFoirAuth(config) {
738
776
  bearerToken,
739
777
  orgId: config.orgId,
740
778
  orgUrl: config.orgUrl,
779
+ visitorAddress: await resolveVisitorAddress(config),
741
780
  extra: config.headers
742
781
  }),
743
782
  key: { apiKey: config.apiKey, bearerToken }
@@ -761,7 +800,33 @@ async function executeFoirDocument(config, plan) {
761
800
  }
762
801
  return data[plan.rootField];
763
802
  }
803
+ var renewing = /* @__PURE__ */ new WeakMap();
804
+ function renewBearerOnce(refresh) {
805
+ const running = renewing.get(refresh);
806
+ if (running) return running;
807
+ const next = refresh().finally(() => renewing.delete(refresh));
808
+ renewing.set(refresh, next);
809
+ return next;
810
+ }
764
811
  async function runFoirDocument(config, document, mutation, variables, options) {
812
+ try {
813
+ return await sendFoirDocument(config, document, mutation, variables, options);
814
+ } catch (err) {
815
+ const refresh = config.refreshBearer;
816
+ if (!(err instanceof FoirAuthDowngradedError) || !refresh || err.downgrade.reason === "wrong_project") {
817
+ throw err;
818
+ }
819
+ const token = err.downgrade.reason === "expired" ? await renewBearerOnce(refresh) : null;
820
+ if (!token) throw new FoirSessionExpiredError({ cause: err });
821
+ try {
822
+ return await sendFoirDocument({ ...config, bearerToken: token }, document, mutation, variables, options);
823
+ } catch (again) {
824
+ if (again instanceof FoirAuthDowngradedError) throw new FoirSessionExpiredError({ cause: again });
825
+ throw again;
826
+ }
827
+ }
828
+ }
829
+ async function sendFoirDocument(config, document, mutation, variables, options) {
765
830
  const doFetch = config.fetch ?? globalThis.fetch;
766
831
  const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
767
832
  const { headers, key } = await resolveFoirAuth(config);
@@ -810,7 +875,7 @@ async function runFoirDocument(config, document, mutation, variables, options) {
810
875
  });
811
876
  }
812
877
  const json = await res.json();
813
- return readFoirEnvelope(json, options);
878
+ return readFoirEnvelope(json, { ...options, rejectDowngrade: Boolean(key?.bearerToken) });
814
879
  }
815
880
  async function readErrorBody(res) {
816
881
  try {
@@ -964,6 +1029,9 @@ async function customerProviderCallback(env, input) {
964
1029
  async function saveSession(adapter, result) {
965
1030
  await adapter.set(TOKEN_KEY, result.token);
966
1031
  await adapter.set(REFRESH_KEY, result.refreshToken);
1032
+ if (!result.customerId) {
1033
+ return await getSessionCustomer(adapter) ?? { id: "", email: "" };
1034
+ }
967
1035
  await adapter.set(CUSTOMER_ID_KEY, result.customerId);
968
1036
  await adapter.set(EMAIL_KEY, result.email);
969
1037
  return { id: result.customerId, email: result.email };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `@foir/sdk/placement`: your app's own admin console, shown inside Foir's.
3
+ *
4
+ * A full-page placement is loaded in a frame on the Foir console. No cookie
5
+ * reaches a cross-site frame, so the console hands the page a short-lived token
6
+ * instead, by message, to your placement's origin only. The token is the
7
+ * operator looking at the page, acting through your app at the level your app
8
+ * is installed at, carrying the scopes your placement declares that this
9
+ * operator holds. Your page runs no login of its own.
10
+ *
11
+ * ```ts
12
+ * import { connectPlacement, FOIR_CONSOLE_ORIGINS } from '@foir/sdk/placement';
13
+ *
14
+ * const session = await connectPlacement({
15
+ * apiUrl: 'https://api.foir.dev',
16
+ * consoleOrigins: FOIR_CONSOLE_ORIGINS.production,
17
+ * });
18
+ * if (session.can('records:write:preorder')) showEditButtons();
19
+ * const data = await session.request('{ preorders { items { id } } }');
20
+ * ```
21
+ *
22
+ * The browser shows the page blank, however well it is authenticated, unless
23
+ * the page allows the console as a frame ancestor. Hydrogen and many other
24
+ * frameworks send `frame-ancestors 'none'` by default. `placementFrameAncestors`
25
+ * builds the header value; the console and the install preview list the origins
26
+ * your project needs.
27
+ */
28
+ /** The messages the console and an embedded page exchange. */
29
+ export declare const PLACEMENT_MESSAGE: {
30
+ /** Page to console: the page is listening and wants a token. */
31
+ readonly ready: "foir:placement:ready";
32
+ /** Console to page: a token. */
33
+ readonly token: "foir:placement:token";
34
+ /** Page to console: the token is about to lapse; a fresh one, please. */
35
+ readonly refresh: "foir:placement:refresh";
36
+ /** Console to page: no token, and why. */
37
+ readonly refused: "foir:placement:refused";
38
+ /** Console to page: the operator signed out. */
39
+ readonly signedOut: "foir:placement:signed-out";
40
+ };
41
+ /** What the console hands an embedded page. */
42
+ export interface PlacementGrant {
43
+ /** The bearer for Foir's API. */
44
+ accessToken: string;
45
+ /** When it lapses, RFC 3339. The session asks for a fresh one before then. */
46
+ expiresAt: string;
47
+ /** The organisation the page acts at: your app's install. Empty for the project. */
48
+ orgId: string;
49
+ /** What the token carries. */
50
+ grantedScopes: string[];
51
+ /** What your placement declares that this operator does not hold, and why. */
52
+ withheld: {
53
+ scope: string;
54
+ reason: string;
55
+ }[];
56
+ }
57
+ /** A message the console sends an embedded page. */
58
+ export type PlacementHostMessage = {
59
+ type: typeof PLACEMENT_MESSAGE.token;
60
+ grant: PlacementGrant;
61
+ } | {
62
+ type: typeof PLACEMENT_MESSAGE.refused;
63
+ reason: string;
64
+ } | {
65
+ type: typeof PLACEMENT_MESSAGE.signedOut;
66
+ };
67
+ /** A message an embedded page sends the console. */
68
+ export type PlacementPageMessage = {
69
+ type: typeof PLACEMENT_MESSAGE.ready;
70
+ } | {
71
+ type: typeof PLACEMENT_MESSAGE.refresh;
72
+ };
73
+ /**
74
+ * The Foir consoles, per environment. A project that serves the console on its
75
+ * own verified admin domain adds that origin too: the install preview lists it.
76
+ */
77
+ export declare const FOIR_CONSOLE_ORIGINS: {
78
+ readonly production: readonly ["https://app.foir.io"];
79
+ readonly dev: readonly ["https://app-dev.foir.io"];
80
+ };
81
+ /**
82
+ * The `Content-Security-Policy` value that lets these consoles show your page:
83
+ * `frame-ancestors https://app.foir.io`. Set it on every response the
84
+ * placement's page serves.
85
+ */
86
+ export declare function placementFrameAncestors(origins: readonly string[]): string;
87
+ /** Why the console would not give the page a token. */
88
+ export declare class PlacementRefused extends Error {
89
+ constructor(reason: string);
90
+ }
91
+ export interface ConnectPlacementOptions {
92
+ /** Foir's API, e.g. `https://api.foir.dev`. */
93
+ apiUrl: string;
94
+ /**
95
+ * The consoles your page accepts a token from. A message from any other
96
+ * origin is ignored, so a page framed elsewhere never receives one.
97
+ */
98
+ consoleOrigins: readonly string[];
99
+ /** How long to wait for the first token. Default 10 seconds. */
100
+ timeoutMs?: number;
101
+ /** How long before expiry to ask for a fresh token. Default 60 seconds. */
102
+ refreshBeforeMs?: number;
103
+ /** The page's window. Default the global one; tests pass their own. */
104
+ window?: Window;
105
+ /** The fetch the session's requests use. Default the global one. */
106
+ fetch?: typeof fetch;
107
+ }
108
+ /** An embedded page's connection to the console that showed it. */
109
+ export interface PlacementSession {
110
+ /** The grant in force now. Replaced as the session refreshes. */
111
+ readonly grant: PlacementGrant;
112
+ /** Whether the token carries this scope, to hide or grey out what the operator cannot do. */
113
+ can(scope: string): boolean;
114
+ /** A GraphQL request to Foir's API as the operator, through your app. */
115
+ request<TResult = unknown>(document: string | {
116
+ toString(): string;
117
+ }, variables?: Record<string, unknown>): Promise<TResult>;
118
+ /** Called when the operator signs out; the session holds no token from then on. */
119
+ onSignedOut(listener: () => void): () => void;
120
+ /** Stop listening and refreshing. */
121
+ close(): void;
122
+ }
123
+ /**
124
+ * Connect an embedded page to the console showing it: tell the console the page
125
+ * is listening, wait for its token, and keep the token fresh while the page is
126
+ * open. Resolves with the session, or rejects with `PlacementRefused` when the
127
+ * console gives no token, or when none arrives in time.
128
+ */
129
+ export declare function connectPlacement(options: ConnectPlacementOptions): Promise<PlacementSession>;
@@ -0,0 +1,144 @@
1
+ // src/placement/index.ts
2
+ var PLACEMENT_MESSAGE = {
3
+ /** Page to console: the page is listening and wants a token. */
4
+ ready: "foir:placement:ready",
5
+ /** Console to page: a token. */
6
+ token: "foir:placement:token",
7
+ /** Page to console: the token is about to lapse; a fresh one, please. */
8
+ refresh: "foir:placement:refresh",
9
+ /** Console to page: no token, and why. */
10
+ refused: "foir:placement:refused",
11
+ /** Console to page: the operator signed out. */
12
+ signedOut: "foir:placement:signed-out"
13
+ };
14
+ var FOIR_CONSOLE_ORIGINS = {
15
+ production: ["https://app.foir.io"],
16
+ dev: ["https://app-dev.foir.io"]
17
+ };
18
+ function placementFrameAncestors(origins) {
19
+ if (origins.length === 0) {
20
+ throw new Error("[foir] placementFrameAncestors needs at least one console origin");
21
+ }
22
+ for (const o of origins) {
23
+ let u;
24
+ try {
25
+ u = new URL(o);
26
+ } catch {
27
+ throw new Error(`[foir] placementFrameAncestors: ${JSON.stringify(o)} is not an origin`);
28
+ }
29
+ if (u.protocol !== "https:" || u.origin !== o) {
30
+ throw new Error(`[foir] placementFrameAncestors: ${JSON.stringify(o)} must be an https origin, such as https://app.foir.io`);
31
+ }
32
+ }
33
+ return `frame-ancestors ${origins.join(" ")}`;
34
+ }
35
+ var PlacementRefused = class extends Error {
36
+ constructor(reason) {
37
+ super(reason);
38
+ this.name = "PlacementRefused";
39
+ }
40
+ };
41
+ function isHostMessage(data) {
42
+ if (typeof data !== "object" || data === null) return false;
43
+ const t = data.type;
44
+ return t === PLACEMENT_MESSAGE.token || t === PLACEMENT_MESSAGE.refused || t === PLACEMENT_MESSAGE.signedOut;
45
+ }
46
+ function connectPlacement(options) {
47
+ const win = options.window ?? window;
48
+ const doFetch = options.fetch ?? fetch;
49
+ const timeoutMs = options.timeoutMs ?? 1e4;
50
+ const refreshBeforeMs = options.refreshBeforeMs ?? 6e4;
51
+ const allowed = new Set(options.consoleOrigins);
52
+ const apiUrl = options.apiUrl.replace(/\/+$/, "");
53
+ if (win.parent === win) {
54
+ return Promise.reject(new PlacementRefused("this page is not inside the Foir console, so there is no console to give it a token"));
55
+ }
56
+ return new Promise((resolve, reject) => {
57
+ let grant = null;
58
+ let settled = false;
59
+ let refreshTimer;
60
+ const signedOut = /* @__PURE__ */ new Set();
61
+ const post = (message) => {
62
+ win.parent.postMessage(message, "*");
63
+ };
64
+ const scheduleRefresh = () => {
65
+ clearTimeout(refreshTimer);
66
+ if (!grant) return;
67
+ const due = Date.parse(grant.expiresAt) - Date.now() - refreshBeforeMs;
68
+ refreshTimer = setTimeout(() => post({ type: PLACEMENT_MESSAGE.refresh }), Math.max(0, due));
69
+ };
70
+ const close = () => {
71
+ clearTimeout(refreshTimer);
72
+ clearTimeout(timeout);
73
+ win.removeEventListener("message", onMessage);
74
+ };
75
+ const session = {
76
+ get grant() {
77
+ if (!grant) throw new PlacementRefused("the operator has signed out");
78
+ return grant;
79
+ },
80
+ can: (scope) => grant?.grantedScopes.includes(scope) ?? false,
81
+ async request(document, variables) {
82
+ if (!grant) throw new PlacementRefused("the operator has signed out");
83
+ const res = await doFetch(`${apiUrl}/graphql`, {
84
+ method: "POST",
85
+ headers: { "content-type": "application/json", authorization: `Bearer ${grant.accessToken}` },
86
+ body: JSON.stringify({ query: String(document), variables: variables ?? {} })
87
+ });
88
+ const body = await res.json().catch(() => ({}));
89
+ if (!res.ok || body.errors && body.errors.length > 0) {
90
+ const messages = body.errors?.map((e) => e.message).join("; ") || `HTTP ${res.status}`;
91
+ throw new Error(`[foir] placement request failed: ${messages}`);
92
+ }
93
+ return body.data;
94
+ },
95
+ onSignedOut(listener) {
96
+ signedOut.add(listener);
97
+ return () => signedOut.delete(listener);
98
+ },
99
+ close
100
+ };
101
+ function onMessage(event) {
102
+ if (event.source !== win.parent || !allowed.has(event.origin) || !isHostMessage(event.data)) return;
103
+ const msg = event.data;
104
+ switch (msg.type) {
105
+ case PLACEMENT_MESSAGE.token:
106
+ grant = msg.grant;
107
+ scheduleRefresh();
108
+ if (!settled) {
109
+ settled = true;
110
+ clearTimeout(timeout);
111
+ resolve(session);
112
+ }
113
+ return;
114
+ case PLACEMENT_MESSAGE.refused:
115
+ if (!settled) {
116
+ settled = true;
117
+ close();
118
+ reject(new PlacementRefused(msg.reason));
119
+ }
120
+ return;
121
+ case PLACEMENT_MESSAGE.signedOut:
122
+ grant = null;
123
+ close();
124
+ for (const l of signedOut) l();
125
+ return;
126
+ }
127
+ }
128
+ const timeout = setTimeout(() => {
129
+ if (settled) return;
130
+ settled = true;
131
+ close();
132
+ reject(new PlacementRefused(`no token arrived from the Foir console within ${timeoutMs} ms`));
133
+ }, timeoutMs);
134
+ win.addEventListener("message", onMessage);
135
+ post({ type: PLACEMENT_MESSAGE.ready });
136
+ });
137
+ }
138
+ export {
139
+ FOIR_CONSOLE_ORIGINS,
140
+ PLACEMENT_MESSAGE,
141
+ PlacementRefused,
142
+ connectPlacement,
143
+ placementFrameAncestors
144
+ };
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";
@@ -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 {
@@ -1068,6 +1133,9 @@ async function customerRefreshToken(env, refreshToken) {
1068
1133
  async function saveSession(adapter, result) {
1069
1134
  await adapter.set(TOKEN_KEY, result.token);
1070
1135
  await adapter.set(REFRESH_KEY, result.refreshToken);
1136
+ if (!result.customerId) {
1137
+ return await getSessionCustomer(adapter) ?? { id: "", email: "" };
1138
+ }
1071
1139
  await adapter.set(CUSTOMER_ID_KEY, result.customerId);
1072
1140
  await adapter.set(EMAIL_KEY, result.email);
1073
1141
  return { id: result.customerId, email: result.email };
@@ -1081,6 +1149,12 @@ async function clearSession(adapter) {
1081
1149
  async function getSessionToken(adapter) {
1082
1150
  return await adapter.get(TOKEN_KEY) ?? null;
1083
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
+ }
1084
1158
  async function refreshSession(adapter, env) {
1085
1159
  const refreshToken = await adapter.get(REFRESH_KEY) ?? null;
1086
1160
  if (!refreshToken) return null;
@@ -1100,21 +1174,55 @@ function isAuthExpiredError(err) {
1100
1174
  (e) => /authentication required/i.test(e.message)
1101
1175
  );
1102
1176
  }
1103
- 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) {
1104
1199
  const token = await getSessionToken(adapter);
1105
1200
  try {
1106
- return await foirRequest(env, document, variables, {
1107
- bearerToken: token ?? void 0
1108
- });
1201
+ return await run(token ?? void 0);
1109
1202
  } catch (err) {
1110
- if (!isAuthExpiredError(err)) throw err;
1111
- const refreshed = await refreshSession(adapter, env);
1112
- if (!refreshed) throw err;
1113
- return foirRequest(env, document, variables, {
1114
- bearerToken: refreshed.token
1115
- });
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
+ }
1116
1217
  }
1117
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
+ }
1118
1226
 
1119
1227
  // src/push/operations.ts
1120
1228
  import { parse } from "graphql";