@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.
- package/CHANGELOG.md +353 -2
- package/README.md +10 -2
- package/dist/app/client.d.ts +371 -0
- package/dist/app/contract/index.d.ts +23 -0
- package/dist/app/contract/proto/apps/v1/apps_pb.d.ts +1465 -0
- package/dist/app/contract/proto/apps/v1/discovery_pb.d.ts +678 -0
- package/dist/app/dispatch.d.ts +64 -0
- package/dist/app/errors.d.ts +50 -0
- package/dist/app/index.d.ts +40 -0
- package/dist/app/manifest.d.ts +56 -0
- package/dist/app/naming.d.ts +31 -0
- package/dist/app/record-key.d.ts +17 -0
- package/dist/app/ticket.d.ts +37 -0
- package/dist/app/vocabulary.generated.d.ts +20 -0
- package/dist/app-contract.js +124 -0
- package/dist/app.js +998 -0
- package/dist/auth/auth.d.ts +24 -6
- package/dist/auth/index.d.ts +2 -1
- package/dist/auth/ops.d.ts +8 -0
- package/dist/auth.js +230 -60
- package/dist/client.js +80 -15
- package/dist/codegen/preset.d.ts +3 -2
- package/dist/data/builtins.d.ts +17 -1
- package/dist/data/client.d.ts +10 -8
- package/dist/data/document.d.ts +13 -0
- package/dist/data/index.d.ts +15 -5
- package/dist/data/manifest.d.ts +31 -3
- package/dist/data/schema.d.ts +16 -9
- package/dist/data/selection.d.ts +15 -0
- package/dist/data/transport.d.ts +277 -23
- package/dist/data/ws-transport.d.ts +8 -2
- package/dist/data.js +313 -72
- package/dist/graphql/client.d.ts +1 -1
- package/dist/graphql/foir-request.d.ts +1 -1
- package/dist/index.js +0 -9
- package/dist/live/drift.d.ts +9 -10
- package/dist/live.js +1 -1
- package/dist/next.js +158 -37
- package/dist/placement/index.d.ts +129 -0
- package/dist/placement.js +144 -0
- package/dist/push.js +212 -50
- package/dist/react.js +158 -37
- package/dist/server/callback-client.d.ts +30 -9
- package/dist/server/continue-run.d.ts +56 -0
- package/dist/server/index.d.ts +6 -3
- package/dist/server/operation-types.d.ts +114 -65
- package/dist/server/read-dispatch.d.ts +53 -0
- package/dist/server/require-scoped-token.d.ts +71 -7
- package/dist/server/verify-scoped-token.d.ts +109 -3
- package/dist/server.js +566 -138
- package/dist/transport/foir-http.d.ts +103 -5
- 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)
|
|
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.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
headers["
|
|
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
|
|
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
|
|
715
|
-
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 (
|
|
731
|
-
res = await doFetch(buildCdnUrl(endpoint,
|
|
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
|
|
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
|
|
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
|
+
};
|