@foir/sdk 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +353 -2
  2. package/README.md +10 -2
  3. package/dist/app/client.d.ts +371 -0
  4. package/dist/app/contract/index.d.ts +23 -0
  5. package/dist/app/contract/proto/apps/v1/apps_pb.d.ts +1465 -0
  6. package/dist/app/contract/proto/apps/v1/discovery_pb.d.ts +678 -0
  7. package/dist/app/dispatch.d.ts +64 -0
  8. package/dist/app/errors.d.ts +50 -0
  9. package/dist/app/index.d.ts +40 -0
  10. package/dist/app/manifest.d.ts +56 -0
  11. package/dist/app/naming.d.ts +31 -0
  12. package/dist/app/record-key.d.ts +17 -0
  13. package/dist/app/ticket.d.ts +37 -0
  14. package/dist/app/vocabulary.generated.d.ts +20 -0
  15. package/dist/app-contract.js +124 -0
  16. package/dist/app.js +998 -0
  17. package/dist/auth/auth.d.ts +24 -6
  18. package/dist/auth/index.d.ts +2 -1
  19. package/dist/auth/ops.d.ts +8 -0
  20. package/dist/auth.js +230 -60
  21. package/dist/client.js +80 -15
  22. package/dist/codegen/preset.d.ts +3 -2
  23. package/dist/data/builtins.d.ts +17 -1
  24. package/dist/data/client.d.ts +10 -8
  25. package/dist/data/document.d.ts +13 -0
  26. package/dist/data/index.d.ts +15 -5
  27. package/dist/data/manifest.d.ts +31 -3
  28. package/dist/data/schema.d.ts +16 -9
  29. package/dist/data/selection.d.ts +15 -0
  30. package/dist/data/transport.d.ts +277 -23
  31. package/dist/data/ws-transport.d.ts +8 -2
  32. package/dist/data.js +313 -72
  33. package/dist/graphql/client.d.ts +1 -1
  34. package/dist/graphql/foir-request.d.ts +1 -1
  35. package/dist/index.js +0 -9
  36. package/dist/live/drift.d.ts +9 -10
  37. package/dist/live.js +1 -1
  38. package/dist/next.js +158 -37
  39. package/dist/placement/index.d.ts +129 -0
  40. package/dist/placement.js +144 -0
  41. package/dist/push.js +212 -50
  42. package/dist/react.js +158 -37
  43. package/dist/server/callback-client.d.ts +30 -9
  44. package/dist/server/continue-run.d.ts +56 -0
  45. package/dist/server/index.d.ts +6 -3
  46. package/dist/server/operation-types.d.ts +114 -65
  47. package/dist/server/read-dispatch.d.ts +53 -0
  48. package/dist/server/require-scoped-token.d.ts +71 -7
  49. package/dist/server/verify-scoped-token.d.ts +109 -3
  50. package/dist/server.js +566 -138
  51. package/dist/transport/foir-http.d.ts +103 -5
  52. package/package.json +24 -6
package/dist/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";
@@ -101,19 +106,50 @@ var FoirRequestError = class extends Error {
101
106
  return typeof seconds === "number" && seconds > 0 ? seconds * 1e3 : void 0;
102
107
  }
103
108
  };
109
+ var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
110
+ "x-api-key",
111
+ "authorization",
112
+ "x-foir-token",
113
+ "x-foir-context"
114
+ ]);
115
+ function refuseCredentialHeaders(extra) {
116
+ if (!extra) return;
117
+ for (const name of Object.keys(extra)) {
118
+ if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
119
+ throw new FoirRequestError(
120
+ `[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.`
121
+ );
122
+ }
123
+ }
124
+ }
125
+ var FOIR_VISITOR_IP_HEADER = "X-Foir-Visitor-IP";
104
126
  function buildFoirHeaders(input) {
105
127
  const headers = {
106
- "Content-Type": "application/json",
107
- "x-api-key": input.apiKey
128
+ "Content-Type": "application/json"
108
129
  };
109
- if (input.draftChannel) {
110
- headers["X-Foir-Schema-Channel"] = "draft";
111
- }
112
- if (input.bearerToken) {
113
- headers["Authorization"] = `Bearer ${input.bearerToken}`;
114
- }
115
- if (input.orgId) {
116
- headers["X-Org-Id"] = input.orgId;
130
+ if (input.dispatchToken !== void 0) {
131
+ refuseCredentialHeaders(input.extra);
132
+ headers["X-Foir-Token"] = input.dispatchToken;
133
+ if (input.dispatchContext) {
134
+ headers["X-Foir-Context"] = input.dispatchContext;
135
+ }
136
+ } else {
137
+ headers["x-api-key"] = input.apiKey;
138
+ if (input.draftChannel) {
139
+ headers["X-Foir-Schema-Channel"] = "draft";
140
+ }
141
+ if (input.bearerToken) {
142
+ headers["Authorization"] = `Bearer ${input.bearerToken}`;
143
+ }
144
+ if (input.orgId) {
145
+ headers["X-Org-Id"] = input.orgId;
146
+ }
147
+ if (input.orgUrl) {
148
+ headers["X-Org-Url"] = input.orgUrl;
149
+ }
150
+ if (input.visitorAddress) {
151
+ headers[FOIR_VISITOR_IP_HEADER] = input.visitorAddress;
152
+ }
117
153
  }
118
154
  if (input.extra) {
119
155
  for (const [key, value] of Object.entries(input.extra)) {
@@ -129,6 +165,26 @@ function foirApiEndpoint(apiUrl, path) {
129
165
  }
130
166
  return base + path;
131
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
+ };
132
188
  var warnedDowngrade = null;
133
189
  function reportAuthDowngrade(envelope) {
134
190
  const downgrade = envelope.extensions?.auth;
@@ -157,12 +213,18 @@ function refusePrunedDocument(json, options) {
157
213
  `[foir] selections stripped from this query: ${selections.map(describe).join(", ")}`
158
214
  );
159
215
  }
216
+ function answeredAnything(data) {
217
+ return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
218
+ }
160
219
  function readFoirEnvelope(json, options) {
220
+ if (options?.rejectDowngrade && json.extensions?.auth) {
221
+ throw new FoirAuthDowngradedError(json.extensions.auth);
222
+ }
161
223
  reportAuthDowngrade(json);
162
224
  refusePrunedDocument(json, options);
163
225
  if (json.errors?.length) {
164
226
  const msg = json.errors.map((e) => e.message).join("; ");
165
- if (json.data && !options?.strict) {
227
+ if (answeredAnything(json.data) && !options?.strict) {
166
228
  console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
167
229
  } else {
168
230
  throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
@@ -676,15 +738,54 @@ function operationName(scope, op) {
676
738
  function foirDataEndpoint(apiUrl, path) {
677
739
  return foirApiEndpoint(apiUrl, path);
678
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
+ }
679
747
  async function resolveBearerToken(config) {
680
748
  const source = config.bearerToken;
681
749
  if (typeof source === "function") return await source() ?? void 0;
682
750
  return source ?? void 0;
683
751
  }
752
+ async function resolveDispatchToken(dispatch) {
753
+ const token = (await dispatch.token())?.trim();
754
+ if (!token) {
755
+ throw new FoirRequestError(
756
+ "[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`."
757
+ );
758
+ }
759
+ return token;
760
+ }
761
+ async function resolveFoirAuth(config) {
762
+ if (config.dispatch !== void 0) {
763
+ return {
764
+ headers: buildFoirHeaders({
765
+ dispatchToken: await resolveDispatchToken(config.dispatch),
766
+ dispatchContext: config.dispatch.context ?? void 0,
767
+ extra: config.headers
768
+ })
769
+ };
770
+ }
771
+ const bearerToken = await resolveBearerToken(config);
772
+ return {
773
+ headers: buildFoirHeaders({
774
+ apiKey: config.apiKey,
775
+ draftChannel: config.channel === "draft",
776
+ bearerToken,
777
+ orgId: config.orgId,
778
+ orgUrl: config.orgUrl,
779
+ visitorAddress: await resolveVisitorAddress(config),
780
+ extra: config.headers
781
+ }),
782
+ key: { apiKey: config.apiKey, bearerToken }
783
+ };
784
+ }
684
785
  function foirRoute(input) {
685
786
  if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
686
787
  if (input.bearerToken) return { lane: "origin", reason: "bearer" };
687
- if (input.orgId) return { lane: "origin", reason: "org" };
788
+ if (input.orgId || input.orgUrl) return { lane: "origin", reason: "org" };
688
789
  if (input.mutation) return { lane: "origin", reason: "operation" };
689
790
  if (input.writeToken) return { lane: "origin", reason: "floor" };
690
791
  if (input.channel === "draft") return { lane: "origin", reason: "channel" };
@@ -699,25 +800,45 @@ async function executeFoirDocument(config, plan) {
699
800
  }
700
801
  return data[plan.rootField];
701
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
+ }
702
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) {
703
830
  const doFetch = config.fetch ?? globalThis.fetch;
704
831
  const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
705
- const bearerToken = await resolveBearerToken(config);
706
- const headers = buildFoirHeaders({
707
- apiKey: config.apiKey,
708
- draftChannel: config.channel === "draft",
709
- bearerToken,
710
- orgId: config.orgId,
711
- extra: config.headers
712
- });
832
+ const { headers, key } = await resolveFoirAuth(config);
713
833
  const hash = await documentHash(document);
714
- const route = foirRoute({
715
- apiKey: config.apiKey,
716
- bearerToken,
834
+ const sharedKey = key !== void 0 && foirRoute({
835
+ apiKey: key.apiKey,
836
+ bearerToken: key.bearerToken,
717
837
  orgId: config.orgId,
838
+ orgUrl: config.orgUrl,
718
839
  mutation,
719
840
  channel: config.channel
720
- });
841
+ }).lane === "shared" ? key.apiKey : void 0;
721
842
  const post = (body) => doFetch(endpoint, {
722
843
  method: "POST",
723
844
  headers,
@@ -727,8 +848,8 @@ async function runFoirDocument(config, document, mutation, variables, options) {
727
848
  const extensions = persistedQueryExtension(hash);
728
849
  let res;
729
850
  try {
730
- if (route.lane === "shared") {
731
- res = await doFetch(buildCdnUrl(endpoint, config.apiKey, hash, variables), {
851
+ if (sharedKey !== void 0) {
852
+ res = await doFetch(buildCdnUrl(endpoint, sharedKey, hash, variables), {
732
853
  method: "GET",
733
854
  headers,
734
855
  ...config.requestInit
@@ -754,7 +875,7 @@ async function runFoirDocument(config, document, mutation, variables, options) {
754
875
  });
755
876
  }
756
877
  const json = await res.json();
757
- return readFoirEnvelope(json, options);
878
+ return readFoirEnvelope(json, { ...options, rejectDowngrade: Boolean(key?.bearerToken) });
758
879
  }
759
880
  async function readErrorBody(res) {
760
881
  try {
@@ -766,20 +887,16 @@ async function readErrorBody(res) {
766
887
  }
767
888
 
768
889
  // src/data/manifest.ts
769
- async function fetchFoirManifest(config) {
770
- const url = foirDataEndpoint(config.apiUrl, "/v1/manifest");
890
+ async function fetchFoirManifest(config, options = {}) {
891
+ const base = foirDataEndpoint(config.apiUrl, "/v1/manifest");
892
+ const url = options.operation === void 0 ? base : `${base}?operation=${encodeURIComponent(options.operation)}`;
771
893
  const doFetch = config.fetch ?? globalThis.fetch;
894
+ const { headers } = await resolveFoirAuth(config);
772
895
  let res;
773
896
  try {
774
897
  res = await doFetch(url, {
775
898
  method: "GET",
776
- headers: buildFoirHeaders({
777
- apiKey: config.apiKey,
778
- draftChannel: config.channel === "draft",
779
- bearerToken: await resolveBearerToken(config),
780
- orgId: config.orgId,
781
- extra: config.headers
782
- }),
899
+ headers,
783
900
  ...config.requestInit
784
901
  });
785
902
  } catch (err) {
@@ -851,6 +968,7 @@ function authClientConfig(env, options) {
851
968
  channel: isFoirPreview(env) ? "draft" : "published",
852
969
  bearerToken: options?.bearerToken,
853
970
  orgId: options?.orgId,
971
+ orgUrl: options?.orgUrl,
854
972
  headers: options?.headers,
855
973
  fetch: options?.fetch
856
974
  };
@@ -911,6 +1029,9 @@ async function customerProviderCallback(env, input) {
911
1029
  async function saveSession(adapter, result) {
912
1030
  await adapter.set(TOKEN_KEY, result.token);
913
1031
  await adapter.set(REFRESH_KEY, result.refreshToken);
1032
+ if (!result.customerId) {
1033
+ return await getSessionCustomer(adapter) ?? { id: "", email: "" };
1034
+ }
914
1035
  await adapter.set(CUSTOMER_ID_KEY, result.customerId);
915
1036
  await adapter.set(EMAIL_KEY, result.email);
916
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
+ };