@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.
- package/CHANGELOG.md +131 -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.js +139 -23
- package/dist/client.js +36 -2
- package/dist/data/index.d.ts +2 -2
- package/dist/data/transport.d.ts +34 -2
- package/dist/data.js +77 -2
- package/dist/graphql/client.d.ts +1 -1
- package/dist/graphql/foir-request.d.ts +1 -1
- package/dist/next.js +70 -2
- package/dist/placement/index.d.ts +129 -0
- package/dist/placement.js +144 -0
- package/dist/push.js +121 -13
- package/dist/react.js +70 -2
- package/dist/server/callback-client.d.ts +30 -9
- package/dist/server/continue-run.d.ts +56 -0
- package/dist/server/index.d.ts +5 -3
- package/dist/server/operation-types.d.ts +28 -7
- package/dist/server/require-scoped-token.d.ts +52 -7
- package/dist/server/verify-scoped-token.d.ts +109 -3
- package/dist/server.js +415 -127
- package/dist/transport/foir-http.d.ts +48 -0
- package/package.json +24 -6
package/dist/graphql/client.d.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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
|
-
|
|
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
|
|
1107
|
-
bearerToken: token ?? void 0
|
|
1108
|
-
});
|
|
1201
|
+
return await run(token ?? void 0);
|
|
1109
1202
|
} catch (err) {
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
if (
|
|
1113
|
-
|
|
1114
|
-
|
|
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";
|