@foir/sdk 0.5.0 → 0.7.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 +325 -0
- package/README.md +63 -0
- package/dist/auth/auth.d.ts +140 -0
- package/dist/auth/hosted.d.ts +40 -0
- package/dist/auth/index.d.ts +15 -0
- package/dist/auth/operations.d.ts +123 -0
- package/dist/auth/session-client.d.ts +76 -0
- package/dist/auth-session.js +137 -0
- package/dist/auth.js +238 -26
- package/dist/client/platform-client.d.ts +29 -0
- package/dist/client.js +281 -37
- package/dist/codegen/block-registry.d.ts +4 -8
- package/dist/codegen/block-scaffold.d.ts +4 -8
- package/dist/codegen/index.d.ts +8 -62
- package/dist/codegen/preset.d.ts +60 -0
- package/dist/codegen/shared.d.ts +25 -0
- package/dist/components/EditorProvider.d.ts +36 -0
- package/dist/content/content-renderer.d.ts +47 -0
- package/dist/content/index.d.ts +12 -0
- package/dist/content/richtext.d.ts +70 -0
- package/dist/data/builtins.d.ts +296 -0
- package/dist/data/client.d.ts +94 -0
- package/dist/data/index.d.ts +54 -0
- package/dist/data/manifest.d.ts +161 -0
- package/dist/data/schema.d.ts +243 -0
- package/dist/data/selection.d.ts +330 -0
- package/dist/data/transport.d.ts +107 -0
- package/dist/data.js +309 -0
- package/dist/graphql/cdn-route.d.ts +42 -0
- package/dist/graphql/client.d.ts +30 -0
- package/dist/graphql/content-client.d.ts +66 -0
- package/dist/graphql/foir-request.d.ts +47 -0
- package/dist/hooks/useAutoResize.d.ts +22 -0
- package/dist/hooks/useEditor.d.ts +16 -0
- package/dist/index.d.ts +10 -394
- package/dist/media/BlurhashCanvas.d.ts +10 -0
- package/dist/media/ProgressiveMedia.d.ts +56 -0
- package/dist/media/index.d.ts +14 -0
- package/dist/media/media.d.ts +139 -0
- package/dist/media/useProgressiveLoad.d.ts +23 -0
- package/dist/media.js +5 -0
- package/dist/next/cookieAdapter.d.ts +26 -0
- package/dist/next/index.d.ts +15 -0
- package/dist/next/middleware.d.ts +49 -0
- package/dist/next/session.d.ts +53 -0
- package/dist/next.js +230 -26
- package/dist/protocol/constants.d.ts +6 -0
- package/dist/protocol/messages.d.ts +155 -0
- package/dist/protocol/validation.d.ts +8 -0
- package/dist/push/index.d.ts +14 -0
- package/dist/push/operations.d.ts +22 -0
- package/dist/push/web-push.d.ts +39 -0
- package/dist/push.js +230 -26
- package/dist/react/FoirProvider.d.ts +116 -0
- package/dist/react/OAuthButtons.d.ts +20 -0
- package/dist/react/SignIn.d.ts +22 -0
- package/dist/react/UserButton.d.ts +13 -0
- package/dist/react/index.d.ts +19 -0
- package/dist/react/localStorageAdapter.d.ts +10 -0
- package/dist/react.js +230 -26
- package/dist/{realtime.d.ts → realtime/index.d.ts} +35 -16
- package/dist/server/callback-client.d.ts +92 -0
- package/dist/server/index.d.ts +8 -0
- package/dist/server/operation-types.d.ts +134 -0
- package/dist/server/require-scoped-token.d.ts +76 -0
- package/dist/server/server-client.d.ts +34 -0
- package/dist/server/types.d.ts +54 -0
- package/dist/server/verify-scoped-token.d.ts +100 -0
- package/dist/server/webhook-helpers.d.ts +36 -0
- package/dist/transport/foir-http.d.ts +217 -0
- package/dist/transport/retry.d.ts +61 -0
- package/dist/types/index.d.ts +124 -0
- package/package.json +23 -13
- package/dist/auth.d.ts +0 -382
- package/dist/client.d.ts +0 -135
- package/dist/content.d.ts +0 -96
- package/dist/media.d.ts +0 -234
- package/dist/next.d.ts +0 -178
- package/dist/push.d.ts +0 -109
- package/dist/react.d.ts +0 -319
- package/dist/server.d.ts +0 -534
- package/dist/shared-DkWlEd2Z.d.ts +0 -18
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foir customer-auth GraphQL operations + result types.
|
|
3
|
+
*
|
|
4
|
+
* These are PLATFORM-STABLE primitives — `customerLogin`/`customerRegister`/
|
|
5
|
+
* `customerRefreshToken`/OTP/provider login are fixed-shape operations served
|
|
6
|
+
* by api-public for every project (gated per-project by the password/OTP/
|
|
7
|
+
* provider flags, but identical in shape when present). Unlike per-project
|
|
8
|
+
* model types, they don't vary by configured models, so the SDK owns the
|
|
9
|
+
* documents directly rather than relying on the consumer's codegen.
|
|
10
|
+
*
|
|
11
|
+
* The project-specific customer PROFILE (e.g. `myProfile.isAdmin`) is NOT here
|
|
12
|
+
* — that shape varies per project, so it stays consumer-injected (see
|
|
13
|
+
* `requireAuth`'s `resolveUser`).
|
|
14
|
+
*/
|
|
15
|
+
import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
|
|
16
|
+
/** The common auth result every login/register/refresh mutation returns. */
|
|
17
|
+
export interface CustomerAuthResult {
|
|
18
|
+
token: string;
|
|
19
|
+
refreshToken: string;
|
|
20
|
+
customerId: string;
|
|
21
|
+
email: string;
|
|
22
|
+
}
|
|
23
|
+
/** Result of initiating a provider login — a redirect URL or an inline token. */
|
|
24
|
+
export interface ProviderLoginResult {
|
|
25
|
+
/** OAuth/OIDC authorization URL to redirect the user to (redirect flows). */
|
|
26
|
+
redirectUrl: string | null;
|
|
27
|
+
/** Inline access token (non-redirect provider flows). */
|
|
28
|
+
token: string | null;
|
|
29
|
+
}
|
|
30
|
+
/** A configured auth provider, as exposed to unauthenticated callers. */
|
|
31
|
+
export interface AuthProviderPublic {
|
|
32
|
+
key: string;
|
|
33
|
+
name: string;
|
|
34
|
+
/** OAUTH2 | TOKEN_SSO | SAML | OIDC | EXTERNAL */
|
|
35
|
+
type: string;
|
|
36
|
+
isDefault: boolean | null;
|
|
37
|
+
}
|
|
38
|
+
export interface ProviderLoginInput {
|
|
39
|
+
providerKey: string;
|
|
40
|
+
email?: string;
|
|
41
|
+
returnTo?: string;
|
|
42
|
+
/** Override the provider's default redirect_uri (must be allow-listed). */
|
|
43
|
+
redirectUri?: string;
|
|
44
|
+
}
|
|
45
|
+
export interface ProviderCallbackInput {
|
|
46
|
+
providerKey: string;
|
|
47
|
+
code?: string;
|
|
48
|
+
state?: string;
|
|
49
|
+
email?: string;
|
|
50
|
+
idToken?: string;
|
|
51
|
+
}
|
|
52
|
+
export interface ProviderOTPVerifyInput {
|
|
53
|
+
providerKey: string;
|
|
54
|
+
email: string;
|
|
55
|
+
otp: string;
|
|
56
|
+
}
|
|
57
|
+
/** The project's customer signup mode. */
|
|
58
|
+
export type SignupMode = "PUBLIC" | "RESTRICTED" | "WAITLIST" | "CLOSED";
|
|
59
|
+
/** The signup surface a self-hosted frontend reads to render the right UI. */
|
|
60
|
+
export interface SignupContext {
|
|
61
|
+
signupMode: SignupMode;
|
|
62
|
+
}
|
|
63
|
+
/** The queued (or existing pending) entry a waitlist join returns. */
|
|
64
|
+
export interface WaitlistJoinResult {
|
|
65
|
+
id: string;
|
|
66
|
+
email: string;
|
|
67
|
+
/** Waitlist lifecycle status; PENDING on a fresh join. */
|
|
68
|
+
status: string;
|
|
69
|
+
}
|
|
70
|
+
export declare const CustomerLoginDocument: TypedDocumentNode<{
|
|
71
|
+
customerLogin: CustomerAuthResult;
|
|
72
|
+
}, {
|
|
73
|
+
email: string;
|
|
74
|
+
password: string;
|
|
75
|
+
}>;
|
|
76
|
+
export declare const CustomerRegisterDocument: TypedDocumentNode<{
|
|
77
|
+
customerRegister: CustomerAuthResult;
|
|
78
|
+
}, {
|
|
79
|
+
email: string;
|
|
80
|
+
password: string;
|
|
81
|
+
}>;
|
|
82
|
+
export declare const CustomerRefreshTokenDocument: TypedDocumentNode<{
|
|
83
|
+
customerRefreshToken: CustomerAuthResult;
|
|
84
|
+
}, {
|
|
85
|
+
refreshToken: string;
|
|
86
|
+
}>;
|
|
87
|
+
export declare const CustomerRequestOTPDocument: TypedDocumentNode<{
|
|
88
|
+
customerRequestOTP: boolean;
|
|
89
|
+
}, {
|
|
90
|
+
email: string;
|
|
91
|
+
}>;
|
|
92
|
+
export declare const CustomerLoginOTPDocument: TypedDocumentNode<{
|
|
93
|
+
customerLoginOTP: CustomerAuthResult;
|
|
94
|
+
}, {
|
|
95
|
+
email: string;
|
|
96
|
+
otp: string;
|
|
97
|
+
}>;
|
|
98
|
+
export declare const CustomerLoginWithProviderDocument: TypedDocumentNode<{
|
|
99
|
+
customerLoginWithProvider: ProviderLoginResult;
|
|
100
|
+
}, {
|
|
101
|
+
input: ProviderLoginInput;
|
|
102
|
+
}>;
|
|
103
|
+
export declare const CustomerProviderCallbackDocument: TypedDocumentNode<{
|
|
104
|
+
customerProviderCallback: CustomerAuthResult;
|
|
105
|
+
}, {
|
|
106
|
+
input: ProviderCallbackInput;
|
|
107
|
+
}>;
|
|
108
|
+
export declare const CustomerProviderVerifyOTPDocument: TypedDocumentNode<{
|
|
109
|
+
customerProviderVerifyOTP: CustomerAuthResult;
|
|
110
|
+
}, {
|
|
111
|
+
input: ProviderOTPVerifyInput;
|
|
112
|
+
}>;
|
|
113
|
+
export declare const AuthProvidersDocument: TypedDocumentNode<{
|
|
114
|
+
authProviders: AuthProviderPublic[];
|
|
115
|
+
}, Record<string, never>>;
|
|
116
|
+
export declare const CustomerJoinWaitlistDocument: TypedDocumentNode<{
|
|
117
|
+
customerJoinWaitlist: WaitlistJoinResult;
|
|
118
|
+
}, {
|
|
119
|
+
email: string;
|
|
120
|
+
}>;
|
|
121
|
+
export declare const CustomerSignupContextDocument: TypedDocumentNode<{
|
|
122
|
+
customerSignupContext: SignupContext;
|
|
123
|
+
}, Record<string, never>>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foir-hosted first-party sessions, from the browser — the model for a
|
|
3
|
+
* backend-less SPA (Vite, CRA, a static site) that wants no tokens in JS.
|
|
4
|
+
*
|
|
5
|
+
* The sibling `fetchHostedSession` in ./hosted does the same exchange from a
|
|
6
|
+
* SERVER: it reads the cookie off an incoming request and forwards it by hand.
|
|
7
|
+
* A browser cannot set a `Cookie` header, so that function is unusable from an
|
|
8
|
+
* SPA by construction. This one lets the browser send the cookie the only way it
|
|
9
|
+
* can — `credentials: 'include'` — and keeps the resulting bearer in memory.
|
|
10
|
+
*
|
|
11
|
+
* How the pieces divide:
|
|
12
|
+
*
|
|
13
|
+
* cookie — long-lived (30d), HttpOnly, first-party on your registrable
|
|
14
|
+
* domain, set by Foir when hosted login finalizes. JS cannot read
|
|
15
|
+
* it, so XSS cannot exfiltrate it.
|
|
16
|
+
* bearer — 15 minutes, held in a closure here. Never written to
|
|
17
|
+
* localStorage/sessionStorage/IndexedDB, never in a cookie of ours.
|
|
18
|
+
*
|
|
19
|
+
* There is no refresh token in this flow. Re-exchanging the cookie IS the
|
|
20
|
+
* refresh, which is why nothing here needs rotation or reuse-detection: the
|
|
21
|
+
* durable credential is an opaque server-side session handle, revocable at any
|
|
22
|
+
* time from the admin panel or `signOut()`.
|
|
23
|
+
*
|
|
24
|
+
* REQUIRES a verified custom login domain that shares a registrable domain with
|
|
25
|
+
* your app (app.acme.com + login.acme.com). That is what makes the cookie
|
|
26
|
+
* first-party and lets `SameSite=Lax` send it. The same endpoints exist on
|
|
27
|
+
* api.foir.dev but a browser cannot reach them there — the cookie would be
|
|
28
|
+
* cross-site, and that CORS surface deliberately refuses credentials.
|
|
29
|
+
*/
|
|
30
|
+
export interface FoirSessionConfig {
|
|
31
|
+
/**
|
|
32
|
+
* Your verified custom login domain, e.g. `https://login.acme.com`. Must share
|
|
33
|
+
* a registrable domain with the app calling it.
|
|
34
|
+
*/
|
|
35
|
+
loginOrigin: string;
|
|
36
|
+
/** Session-exchange path. Default `/session/token`. */
|
|
37
|
+
sessionPath?: string;
|
|
38
|
+
/** Logout path. Default `/session/logout`. */
|
|
39
|
+
logoutPath?: string;
|
|
40
|
+
/** Seconds before expiry to re-exchange. Default 60. */
|
|
41
|
+
refreshSkewSeconds?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Called when the session goes away — the cookie expired, was revoked, or
|
|
44
|
+
* `signOut()` ran. Use it to redirect to `signInUrl()`.
|
|
45
|
+
*/
|
|
46
|
+
onSignOut?: () => void;
|
|
47
|
+
}
|
|
48
|
+
/** The identity behind the current session. */
|
|
49
|
+
export interface FoirSessionUser {
|
|
50
|
+
customerId: string;
|
|
51
|
+
email: string;
|
|
52
|
+
}
|
|
53
|
+
export interface FoirSessionClient {
|
|
54
|
+
/**
|
|
55
|
+
* The current access token, exchanging or re-exchanging the cookie as needed.
|
|
56
|
+
* Null when there is no session. Concurrent callers share one in-flight
|
|
57
|
+
* exchange. Call this before each API request — do not cache the result.
|
|
58
|
+
*/
|
|
59
|
+
getToken(): Promise<string | null>;
|
|
60
|
+
/** The signed-in customer, or null. Exchanges if the session isn't loaded yet. */
|
|
61
|
+
getUser(): Promise<FoirSessionUser | null>;
|
|
62
|
+
/** Revoke the session server-side and clear the cookie. Idempotent. */
|
|
63
|
+
signOut(): Promise<void>;
|
|
64
|
+
/** Where to send the browser to sign in. */
|
|
65
|
+
signInUrl(returnTo?: string): string;
|
|
66
|
+
/** Observe session changes. Returns an unsubscribe function. */
|
|
67
|
+
subscribe(listener: (user: FoirSessionUser | null) => void): () => void;
|
|
68
|
+
/** Cancel the refresh timer. Call on unmount. */
|
|
69
|
+
destroy(): void;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Create a session client. Safe to call during SSR — it does nothing until a
|
|
73
|
+
* method is invoked, and `getToken()` on the server returns null rather than
|
|
74
|
+
* throwing (there is no cookie jar to draw on).
|
|
75
|
+
*/
|
|
76
|
+
export declare function createFoirSessionClient(config: FoirSessionConfig): FoirSessionClient;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// src/auth/session-client.ts
|
|
2
|
+
var DEFAULT_SESSION_PATH = "/session/token";
|
|
3
|
+
var DEFAULT_LOGOUT_PATH = "/session/logout";
|
|
4
|
+
var DEFAULT_REFRESH_SKEW_SECONDS = 60;
|
|
5
|
+
var MIN_REFRESH_DELAY_MS = 5e3;
|
|
6
|
+
var CSRF_HEADER = { "X-Foir-Session": "1" };
|
|
7
|
+
function createFoirSessionClient(config) {
|
|
8
|
+
const sessionUrl = `${trimSlash(config.loginOrigin)}${config.sessionPath ?? DEFAULT_SESSION_PATH}`;
|
|
9
|
+
const logoutUrl = `${trimSlash(config.loginOrigin)}${config.logoutPath ?? DEFAULT_LOGOUT_PATH}`;
|
|
10
|
+
const skewMs = (config.refreshSkewSeconds ?? DEFAULT_REFRESH_SKEW_SECONDS) * 1e3;
|
|
11
|
+
let cached = null;
|
|
12
|
+
let inflight = null;
|
|
13
|
+
let timer = null;
|
|
14
|
+
let destroyed = false;
|
|
15
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
16
|
+
function emit(user) {
|
|
17
|
+
for (const l of listeners) l(user);
|
|
18
|
+
}
|
|
19
|
+
function clearTimer() {
|
|
20
|
+
if (timer !== null) {
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
timer = null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function scheduleRefresh(expiresAt) {
|
|
26
|
+
clearTimer();
|
|
27
|
+
if (destroyed || typeof setTimeout === "undefined") return;
|
|
28
|
+
const delay = Math.max(expiresAt - Date.now() - skewMs, MIN_REFRESH_DELAY_MS);
|
|
29
|
+
timer = setTimeout(() => {
|
|
30
|
+
void exchange();
|
|
31
|
+
}, delay);
|
|
32
|
+
timer.unref?.();
|
|
33
|
+
}
|
|
34
|
+
function forget() {
|
|
35
|
+
const had = cached !== null;
|
|
36
|
+
cached = null;
|
|
37
|
+
clearTimer();
|
|
38
|
+
if (had) emit(null);
|
|
39
|
+
}
|
|
40
|
+
function exchange() {
|
|
41
|
+
if (inflight) return inflight;
|
|
42
|
+
const pending = (async () => {
|
|
43
|
+
let res;
|
|
44
|
+
try {
|
|
45
|
+
res = await fetch(sessionUrl, {
|
|
46
|
+
method: "GET",
|
|
47
|
+
// The whole point: send the first-party cookie cross-origin (but
|
|
48
|
+
// same-site) to the login domain. Without this the cookie is not sent.
|
|
49
|
+
credentials: "include",
|
|
50
|
+
headers: { ...CSRF_HEADER }
|
|
51
|
+
});
|
|
52
|
+
} catch {
|
|
53
|
+
return cached;
|
|
54
|
+
}
|
|
55
|
+
if (res.status === 401) {
|
|
56
|
+
forget();
|
|
57
|
+
config.onSignOut?.();
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (!res.ok) return cached;
|
|
61
|
+
let json;
|
|
62
|
+
try {
|
|
63
|
+
json = await res.json();
|
|
64
|
+
} catch {
|
|
65
|
+
return cached;
|
|
66
|
+
}
|
|
67
|
+
if (!json.access_token || !json.customer_id) return cached;
|
|
68
|
+
const expiresIn = typeof json.expires_in === "number" ? json.expires_in : 0;
|
|
69
|
+
const next = {
|
|
70
|
+
token: json.access_token,
|
|
71
|
+
expiresAt: Date.now() + expiresIn * 1e3,
|
|
72
|
+
user: { customerId: json.customer_id, email: json.email ?? "" }
|
|
73
|
+
};
|
|
74
|
+
const changed = cached?.user.customerId !== next.user.customerId;
|
|
75
|
+
cached = next;
|
|
76
|
+
scheduleRefresh(next.expiresAt);
|
|
77
|
+
if (changed) emit(next.user);
|
|
78
|
+
return next;
|
|
79
|
+
})();
|
|
80
|
+
inflight = pending;
|
|
81
|
+
void pending.finally(() => {
|
|
82
|
+
if (inflight === pending) inflight = null;
|
|
83
|
+
});
|
|
84
|
+
return pending;
|
|
85
|
+
}
|
|
86
|
+
function fresh() {
|
|
87
|
+
if (!cached) return null;
|
|
88
|
+
return Date.now() < cached.expiresAt - skewMs ? cached : null;
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
async getToken() {
|
|
92
|
+
const hit = fresh();
|
|
93
|
+
if (hit) return hit.token;
|
|
94
|
+
const got = await exchange();
|
|
95
|
+
return got?.token ?? null;
|
|
96
|
+
},
|
|
97
|
+
async getUser() {
|
|
98
|
+
const hit = fresh();
|
|
99
|
+
if (hit) return hit.user;
|
|
100
|
+
const got = await exchange();
|
|
101
|
+
return got?.user ?? null;
|
|
102
|
+
},
|
|
103
|
+
async signOut() {
|
|
104
|
+
try {
|
|
105
|
+
await fetch(logoutUrl, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
credentials: "include",
|
|
108
|
+
headers: { ...CSRF_HEADER }
|
|
109
|
+
});
|
|
110
|
+
} catch {
|
|
111
|
+
}
|
|
112
|
+
forget();
|
|
113
|
+
config.onSignOut?.();
|
|
114
|
+
},
|
|
115
|
+
signInUrl(returnTo) {
|
|
116
|
+
const dest = new URL(trimSlash(config.loginOrigin));
|
|
117
|
+
const target = returnTo ?? (typeof window !== "undefined" ? window.location.href : void 0);
|
|
118
|
+
if (target && target !== "/") dest.searchParams.set("returnTo", target);
|
|
119
|
+
return dest.toString();
|
|
120
|
+
},
|
|
121
|
+
subscribe(listener) {
|
|
122
|
+
listeners.add(listener);
|
|
123
|
+
return () => listeners.delete(listener);
|
|
124
|
+
},
|
|
125
|
+
destroy() {
|
|
126
|
+
destroyed = true;
|
|
127
|
+
clearTimer();
|
|
128
|
+
listeners.clear();
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function trimSlash(s) {
|
|
133
|
+
return s.replace(/\/+$/, "");
|
|
134
|
+
}
|
|
135
|
+
export {
|
|
136
|
+
createFoirSessionClient
|
|
137
|
+
};
|
package/dist/auth.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
// src/graphql/foir-request.ts
|
|
2
|
+
import { print as print2 } from "graphql";
|
|
3
|
+
|
|
4
|
+
// src/graphql/cdn-route.ts
|
|
2
5
|
import { print } from "graphql";
|
|
6
|
+
|
|
7
|
+
// src/transport/foir-http.ts
|
|
3
8
|
function isFoirPreview(env) {
|
|
4
9
|
return env.FOIR_PREVIEW === "true";
|
|
5
10
|
}
|
|
11
|
+
function resolveFoirApiKey(env) {
|
|
12
|
+
return isFoirPreview(env) ? env.PLATFORM_PREVIEW_API_KEY ?? env.PLATFORM_API_KEY : env.PLATFORM_API_KEY;
|
|
13
|
+
}
|
|
6
14
|
var FoirRequestError = class extends Error {
|
|
7
15
|
/** HTTP status code, when the request failed at the HTTP layer. */
|
|
8
16
|
status;
|
|
@@ -16,41 +24,83 @@ var FoirRequestError = class extends Error {
|
|
|
16
24
|
this.status = options?.status;
|
|
17
25
|
this.graphQLErrors = options?.graphQLErrors;
|
|
18
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* The code of the first error, if the response carried one.
|
|
29
|
+
*
|
|
30
|
+
* A getter rather than a stored field so it stays correct for an error
|
|
31
|
+
* constructed anywhere, including by a caller in a test.
|
|
32
|
+
*/
|
|
33
|
+
get code() {
|
|
34
|
+
return this.graphQLErrors?.[0]?.extensions?.code;
|
|
35
|
+
}
|
|
36
|
+
/** The correlation id, if the response carried one. */
|
|
37
|
+
get requestId() {
|
|
38
|
+
return this.graphQLErrors?.[0]?.extensions?.requestId;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Whether retrying this request could succeed.
|
|
42
|
+
*
|
|
43
|
+
* The 429 is the one that has to be in here and is easy to leave out: it sits
|
|
44
|
+
* among the 4xx statuses that are otherwise permanent, so a policy written as
|
|
45
|
+
* "retry 5xx, never retry 4xx" excludes exactly the failure that clears on its
|
|
46
|
+
* own. A storefront wrote that policy, its key saturated, and every read
|
|
47
|
+
* failed on the first attempt for an hour.
|
|
48
|
+
*/
|
|
49
|
+
get retryable() {
|
|
50
|
+
if (this.status === void 0) return true;
|
|
51
|
+
if (this.status === 429 || this.code === "RATE_LIMITED") return true;
|
|
52
|
+
if (this.status >= 500) return true;
|
|
53
|
+
return this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* How long to wait before retrying, in milliseconds, when the server said.
|
|
57
|
+
* Undefined when it did not, which means the caller picks its own backoff.
|
|
58
|
+
*/
|
|
59
|
+
get retryAfterMs() {
|
|
60
|
+
const seconds = this.graphQLErrors?.[0]?.extensions?.retryAfter;
|
|
61
|
+
return typeof seconds === "number" && seconds > 0 ? seconds * 1e3 : void 0;
|
|
62
|
+
}
|
|
19
63
|
};
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
64
|
+
function readFoirRateLimit(headers) {
|
|
65
|
+
const raw = {
|
|
66
|
+
limit: headers.get("X-RateLimit-Limit"),
|
|
67
|
+
remaining: headers.get("X-RateLimit-Remaining"),
|
|
68
|
+
resetAt: headers.get("X-RateLimit-Reset")
|
|
69
|
+
};
|
|
70
|
+
if (raw.limit === null || raw.remaining === null || raw.resetAt === null) {
|
|
71
|
+
return void 0;
|
|
27
72
|
}
|
|
73
|
+
const limit = Number(raw.limit);
|
|
74
|
+
const remaining = Number(raw.remaining);
|
|
75
|
+
const resetAt = Number(raw.resetAt);
|
|
76
|
+
if (!Number.isFinite(limit) || !Number.isFinite(remaining) || !Number.isFinite(resetAt)) {
|
|
77
|
+
return void 0;
|
|
78
|
+
}
|
|
79
|
+
return { limit, remaining, resetAt };
|
|
80
|
+
}
|
|
81
|
+
function readFoirRetryAfterMs(headers) {
|
|
82
|
+
const seconds = Number(headers.get("Retry-After"));
|
|
83
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
|
|
84
|
+
}
|
|
85
|
+
function buildFoirHeaders(input) {
|
|
28
86
|
const headers = {
|
|
29
87
|
"Content-Type": "application/json",
|
|
30
|
-
"x-api-key": apiKey
|
|
88
|
+
"x-api-key": input.apiKey
|
|
31
89
|
};
|
|
32
|
-
if (
|
|
90
|
+
if (input.draftChannel) {
|
|
33
91
|
headers["X-Foir-Schema-Channel"] = "draft";
|
|
34
92
|
}
|
|
35
|
-
if (
|
|
36
|
-
headers["Authorization"] = `Bearer ${
|
|
37
|
-
}
|
|
38
|
-
let res;
|
|
39
|
-
try {
|
|
40
|
-
res = await fetch(endpoint, {
|
|
41
|
-
method: "POST",
|
|
42
|
-
headers,
|
|
43
|
-
body: JSON.stringify({ query: print(document), variables })
|
|
44
|
-
});
|
|
45
|
-
} catch (err) {
|
|
46
|
-
throw new FoirRequestError("[foir] network error", { cause: err });
|
|
93
|
+
if (input.bearerToken) {
|
|
94
|
+
headers["Authorization"] = `Bearer ${input.bearerToken}`;
|
|
47
95
|
}
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
96
|
+
if (input.extra) {
|
|
97
|
+
for (const [key, value] of Object.entries(input.extra)) {
|
|
98
|
+
headers[key] = value;
|
|
99
|
+
}
|
|
52
100
|
}
|
|
53
|
-
|
|
101
|
+
return headers;
|
|
102
|
+
}
|
|
103
|
+
function readFoirEnvelope(json) {
|
|
54
104
|
if (json.errors?.length) {
|
|
55
105
|
const msg = json.errors.map((e) => e.message).join("; ");
|
|
56
106
|
if (json.data) {
|
|
@@ -67,6 +117,150 @@ async function foirRequest(env, document, variables, options) {
|
|
|
67
117
|
return json.data;
|
|
68
118
|
}
|
|
69
119
|
|
|
120
|
+
// src/graphql/cdn-route.ts
|
|
121
|
+
var CDN_PATH = "/cdn/graphql";
|
|
122
|
+
function isCdnEligible(env, documentText, bearerToken) {
|
|
123
|
+
if (bearerToken) return false;
|
|
124
|
+
if (isFoirPreview(env)) return false;
|
|
125
|
+
const apiKey = resolveFoirApiKey(env);
|
|
126
|
+
if (!apiKey?.startsWith("pk_")) return false;
|
|
127
|
+
return !/\bmutation\b/.test(documentText);
|
|
128
|
+
}
|
|
129
|
+
function buildCdnUrl(endpoint, apiKey, hash, variables) {
|
|
130
|
+
const url = new URL(endpoint);
|
|
131
|
+
url.pathname = url.pathname.replace(/\/graphql\/?$/, "") + CDN_PATH;
|
|
132
|
+
url.searchParams.set("key", apiKey);
|
|
133
|
+
url.searchParams.set("extensions", JSON.stringify(persistedQueryExtension(hash)));
|
|
134
|
+
if (variables && Object.keys(variables).length > 0) {
|
|
135
|
+
url.searchParams.set("variables", JSON.stringify(variables));
|
|
136
|
+
}
|
|
137
|
+
return url.toString();
|
|
138
|
+
}
|
|
139
|
+
function persistedQueryExtension(hash) {
|
|
140
|
+
return { persistedQuery: { version: 1, sha256Hash: hash } };
|
|
141
|
+
}
|
|
142
|
+
async function documentHash(documentText) {
|
|
143
|
+
const digest = await crypto.subtle.digest(
|
|
144
|
+
"SHA-256",
|
|
145
|
+
new TextEncoder().encode(documentText)
|
|
146
|
+
);
|
|
147
|
+
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
148
|
+
}
|
|
149
|
+
var PERSISTED_QUERY_NOT_FOUND = "PERSISTED_QUERY_NOT_FOUND";
|
|
150
|
+
var NOT_SHAREABLE = "NOT_SHAREABLE";
|
|
151
|
+
|
|
152
|
+
// src/graphql/foir-request.ts
|
|
153
|
+
async function foirRequest(env, document, variables, options) {
|
|
154
|
+
const endpoint = env.PLATFORM_API_URL;
|
|
155
|
+
const apiKey = resolveFoirApiKey(env);
|
|
156
|
+
if (!endpoint || !apiKey) {
|
|
157
|
+
throw new FoirRequestError(
|
|
158
|
+
"[foir] PLATFORM_API_URL or PLATFORM_API_KEY is not set \u2014 add them to .env"
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const headers = buildFoirHeaders({
|
|
162
|
+
apiKey,
|
|
163
|
+
draftChannel: isFoirPreview(env),
|
|
164
|
+
bearerToken: options?.bearerToken
|
|
165
|
+
});
|
|
166
|
+
const documentText = print2(document);
|
|
167
|
+
let res;
|
|
168
|
+
try {
|
|
169
|
+
if (isCdnEligible(env, documentText, options?.bearerToken)) {
|
|
170
|
+
const hash = await documentHash(documentText);
|
|
171
|
+
let shared = await fetch(buildCdnUrl(endpoint, apiKey, hash, variables), {
|
|
172
|
+
method: "GET",
|
|
173
|
+
headers
|
|
174
|
+
});
|
|
175
|
+
if (await isPersistedQueryMiss(shared)) {
|
|
176
|
+
shared = await fetch(buildCdnUrl(endpoint, apiKey, hash, variables), {
|
|
177
|
+
method: "POST",
|
|
178
|
+
headers,
|
|
179
|
+
body: JSON.stringify({
|
|
180
|
+
query: documentText,
|
|
181
|
+
variables,
|
|
182
|
+
extensions: persistedQueryExtension(hash)
|
|
183
|
+
})
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (await isSharedRefusal(shared)) {
|
|
187
|
+
res = await fetch(endpoint, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers,
|
|
190
|
+
body: JSON.stringify({ query: documentText, variables })
|
|
191
|
+
});
|
|
192
|
+
} else {
|
|
193
|
+
res = shared;
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
res = await fetch(endpoint, {
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers,
|
|
199
|
+
body: JSON.stringify({ query: documentText, variables })
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
} catch (err) {
|
|
203
|
+
throw new FoirRequestError("[foir] network error", { cause: err });
|
|
204
|
+
}
|
|
205
|
+
options?.onRateLimit?.(readFoirRateLimit(res.headers));
|
|
206
|
+
if (!res.ok) {
|
|
207
|
+
const errors = await readErrorEnvelope(res);
|
|
208
|
+
const first = errors?.[0]?.message;
|
|
209
|
+
throw new FoirRequestError(
|
|
210
|
+
first ? `[foir] HTTP ${res.status} ${res.statusText}: ${first}` : `[foir] HTTP ${res.status} ${res.statusText}`,
|
|
211
|
+
{ status: res.status, graphQLErrors: errors }
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const json = await res.json();
|
|
215
|
+
return readFoirEnvelope(json);
|
|
216
|
+
}
|
|
217
|
+
async function readErrorEnvelope(res) {
|
|
218
|
+
let errors;
|
|
219
|
+
try {
|
|
220
|
+
const body = await res.json();
|
|
221
|
+
errors = body.errors;
|
|
222
|
+
} catch {
|
|
223
|
+
errors = void 0;
|
|
224
|
+
}
|
|
225
|
+
const headerRetryMs = readFoirRetryAfterMs(res.headers);
|
|
226
|
+
if (headerRetryMs === void 0) return errors;
|
|
227
|
+
const seconds = Math.round(headerRetryMs / 1e3);
|
|
228
|
+
if (!errors?.length) {
|
|
229
|
+
return [
|
|
230
|
+
{
|
|
231
|
+
message: res.statusText || `HTTP ${res.status}`,
|
|
232
|
+
extensions: { code: "RATE_LIMITED", retryAfter: seconds }
|
|
233
|
+
}
|
|
234
|
+
];
|
|
235
|
+
}
|
|
236
|
+
if (errors[0].extensions?.retryAfter === void 0) {
|
|
237
|
+
errors[0] = {
|
|
238
|
+
...errors[0],
|
|
239
|
+
extensions: { ...errors[0].extensions, retryAfter: seconds }
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return errors;
|
|
243
|
+
}
|
|
244
|
+
async function isSharedRefusal(res) {
|
|
245
|
+
if (res.ok) return false;
|
|
246
|
+
try {
|
|
247
|
+
const body = await res.clone().json();
|
|
248
|
+
return body.errors?.some((e) => e.extensions?.code === NOT_SHAREABLE) ?? false;
|
|
249
|
+
} catch {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function isPersistedQueryMiss(res) {
|
|
254
|
+
try {
|
|
255
|
+
const body = await res.clone().json();
|
|
256
|
+
return body.errors?.some(
|
|
257
|
+
(e) => e.extensions?.code === PERSISTED_QUERY_NOT_FOUND
|
|
258
|
+
) ?? false;
|
|
259
|
+
} catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
70
264
|
// src/auth/operations.ts
|
|
71
265
|
import { parse } from "graphql";
|
|
72
266
|
var AUTH_RESULT_FIELDS = "token refreshToken customerId email";
|
|
@@ -115,6 +309,16 @@ var AuthProvidersDocument = parse(
|
|
|
115
309
|
authProviders { key name type isDefault }
|
|
116
310
|
}`
|
|
117
311
|
);
|
|
312
|
+
var CustomerJoinWaitlistDocument = parse(
|
|
313
|
+
`mutation CustomerJoinWaitlist($email: String!) {
|
|
314
|
+
customerJoinWaitlist(email: $email) { id email status }
|
|
315
|
+
}`
|
|
316
|
+
);
|
|
317
|
+
var CustomerSignupContextDocument = parse(
|
|
318
|
+
`query CustomerSignupContext {
|
|
319
|
+
customerSignupContext { signupMode }
|
|
320
|
+
}`
|
|
321
|
+
);
|
|
118
322
|
|
|
119
323
|
// src/auth/auth.ts
|
|
120
324
|
var TOKEN_KEY = "foir:token";
|
|
@@ -148,6 +352,12 @@ async function customerProviderVerifyOTP(env, input) {
|
|
|
148
352
|
async function getAuthProviders(env) {
|
|
149
353
|
return (await foirRequest(env, AuthProvidersDocument, {})).authProviders;
|
|
150
354
|
}
|
|
355
|
+
async function customerSignupContext(env) {
|
|
356
|
+
return (await foirRequest(env, CustomerSignupContextDocument, {})).customerSignupContext;
|
|
357
|
+
}
|
|
358
|
+
async function customerJoinWaitlist(env, email) {
|
|
359
|
+
return (await foirRequest(env, CustomerJoinWaitlistDocument, { email })).customerJoinWaitlist;
|
|
360
|
+
}
|
|
151
361
|
async function saveSession(adapter, result) {
|
|
152
362
|
await adapter.set(TOKEN_KEY, result.token);
|
|
153
363
|
await adapter.set(REFRESH_KEY, result.refreshToken);
|
|
@@ -422,6 +632,7 @@ export {
|
|
|
422
632
|
authedRequest,
|
|
423
633
|
clearSession,
|
|
424
634
|
createFoirSessionClient,
|
|
635
|
+
customerJoinWaitlist,
|
|
425
636
|
customerLogin,
|
|
426
637
|
customerLoginOTP,
|
|
427
638
|
customerLoginWithProvider,
|
|
@@ -430,6 +641,7 @@ export {
|
|
|
430
641
|
customerRefreshToken,
|
|
431
642
|
customerRegister,
|
|
432
643
|
customerRequestOTP,
|
|
644
|
+
customerSignupContext,
|
|
433
645
|
fetchHostedSession,
|
|
434
646
|
getAuthProviders,
|
|
435
647
|
getSessionCustomer,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch-based GraphQL client for the EIDE platform API.
|
|
3
|
+
*
|
|
4
|
+
* Zero dependencies — uses the native Fetch API.
|
|
5
|
+
*/
|
|
6
|
+
import type { PlatformClient, PlatformClientOptions } from '../types';
|
|
7
|
+
/** Error thrown when a platform API request fails. */
|
|
8
|
+
export declare class PlatformClientError extends Error {
|
|
9
|
+
status?: number | undefined;
|
|
10
|
+
response?: unknown | undefined;
|
|
11
|
+
constructor(message: string, status?: number | undefined, response?: unknown | undefined);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Create a PlatformClient instance.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* const client = createPlatformClient({
|
|
19
|
+
* apiUrl: 'https://api.example.com/graphql',
|
|
20
|
+
* apiKey: 'sk_test_...',
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* const { data } = await client.query(
|
|
24
|
+
* `query GetProduct($id: ID!) { product(id: $id) { title } }`,
|
|
25
|
+
* { id: '123' }
|
|
26
|
+
* );
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export declare function createPlatformClient(options: PlatformClientOptions): PlatformClient;
|