@foir/sdk 0.1.0 → 0.3.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/dist/push.js ADDED
@@ -0,0 +1,259 @@
1
+ // src/graphql/foir-request.ts
2
+ import { print } from "graphql";
3
+ function isFoirPreview(env) {
4
+ return env.FOIR_PREVIEW === "true";
5
+ }
6
+ var FoirRequestError = class extends Error {
7
+ /** HTTP status code, when the request failed at the HTTP layer. */
8
+ status;
9
+ /** GraphQL errors, when the request failed with a no-data error envelope. */
10
+ graphQLErrors;
11
+ cause;
12
+ constructor(message, options) {
13
+ super(message);
14
+ this.name = "FoirRequestError";
15
+ this.cause = options?.cause;
16
+ this.status = options?.status;
17
+ this.graphQLErrors = options?.graphQLErrors;
18
+ }
19
+ };
20
+ async function foirRequest(env, document, variables, options) {
21
+ const endpoint = env.PLATFORM_API_URL;
22
+ const apiKey = isFoirPreview(env) ? env.PLATFORM_PREVIEW_API_KEY ?? env.PLATFORM_API_KEY : env.PLATFORM_API_KEY;
23
+ if (!endpoint || !apiKey) {
24
+ throw new FoirRequestError(
25
+ "[foir] PLATFORM_API_URL or PLATFORM_API_KEY is not set \u2014 add them to .env"
26
+ );
27
+ }
28
+ const headers = {
29
+ "Content-Type": "application/json",
30
+ "x-api-key": apiKey
31
+ };
32
+ if (isFoirPreview(env)) {
33
+ headers["X-Foir-Schema-Channel"] = "draft";
34
+ }
35
+ if (options?.bearerToken) {
36
+ headers["Authorization"] = `Bearer ${options.bearerToken}`;
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 });
47
+ }
48
+ if (!res.ok) {
49
+ throw new FoirRequestError(`[foir] HTTP ${res.status} ${res.statusText}`, {
50
+ status: res.status
51
+ });
52
+ }
53
+ const json = await res.json();
54
+ if (json.errors?.length) {
55
+ const msg = json.errors.map((e) => e.message).join("; ");
56
+ if (json.data) {
57
+ console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
58
+ } else {
59
+ throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
60
+ graphQLErrors: json.errors
61
+ });
62
+ }
63
+ }
64
+ if (!json.data) {
65
+ throw new FoirRequestError("[foir] empty GraphQL response");
66
+ }
67
+ return json.data;
68
+ }
69
+
70
+ // src/auth/operations.ts
71
+ import { parse } from "graphql";
72
+ var AUTH_RESULT_FIELDS = "token refreshToken customerId email";
73
+ var CustomerLoginDocument = parse(
74
+ `mutation CustomerLogin($email: String!, $password: String!) {
75
+ customerLogin(email: $email, password: $password) { ${AUTH_RESULT_FIELDS} }
76
+ }`
77
+ );
78
+ var CustomerRegisterDocument = parse(
79
+ `mutation CustomerRegister($email: String!, $password: String!) {
80
+ customerRegister(email: $email, password: $password) { ${AUTH_RESULT_FIELDS} }
81
+ }`
82
+ );
83
+ var CustomerRefreshTokenDocument = parse(
84
+ `mutation CustomerRefreshToken($refreshToken: String!) {
85
+ customerRefreshToken(refreshToken: $refreshToken) { ${AUTH_RESULT_FIELDS} }
86
+ }`
87
+ );
88
+ var CustomerRequestOTPDocument = parse(
89
+ `mutation CustomerRequestOTP($email: String!) {
90
+ customerRequestOTP(email: $email)
91
+ }`
92
+ );
93
+ var CustomerLoginOTPDocument = parse(
94
+ `mutation CustomerLoginOTP($email: String!, $otp: String!) {
95
+ customerLoginOTP(email: $email, otp: $otp) { ${AUTH_RESULT_FIELDS} }
96
+ }`
97
+ );
98
+ var CustomerLoginWithProviderDocument = parse(
99
+ `mutation CustomerLoginWithProvider($input: ProviderLoginInput!) {
100
+ customerLoginWithProvider(input: $input) { redirectUrl token }
101
+ }`
102
+ );
103
+ var CustomerProviderCallbackDocument = parse(
104
+ `mutation CustomerProviderCallback($input: ProviderCallbackInput!) {
105
+ customerProviderCallback(input: $input) { ${AUTH_RESULT_FIELDS} }
106
+ }`
107
+ );
108
+ var CustomerProviderVerifyOTPDocument = parse(
109
+ `mutation CustomerProviderVerifyOTP($input: ProviderOTPVerifyInput!) {
110
+ customerProviderVerifyOTP(input: $input) { ${AUTH_RESULT_FIELDS} }
111
+ }`
112
+ );
113
+ var AuthProvidersDocument = parse(
114
+ `query AuthProviders {
115
+ authProviders { key name type isDefault }
116
+ }`
117
+ );
118
+
119
+ // src/auth/auth.ts
120
+ var TOKEN_KEY = "foir:token";
121
+ var REFRESH_KEY = "foir:refreshToken";
122
+ var CUSTOMER_ID_KEY = "foir:customerId";
123
+ var EMAIL_KEY = "foir:email";
124
+ async function customerRefreshToken(env, refreshToken) {
125
+ return (await foirRequest(env, CustomerRefreshTokenDocument, { refreshToken })).customerRefreshToken;
126
+ }
127
+ async function saveSession(adapter, result) {
128
+ await adapter.set(TOKEN_KEY, result.token);
129
+ await adapter.set(REFRESH_KEY, result.refreshToken);
130
+ await adapter.set(CUSTOMER_ID_KEY, result.customerId);
131
+ await adapter.set(EMAIL_KEY, result.email);
132
+ return { id: result.customerId, email: result.email };
133
+ }
134
+ async function clearSession(adapter) {
135
+ await adapter.unset(TOKEN_KEY);
136
+ await adapter.unset(REFRESH_KEY);
137
+ await adapter.unset(CUSTOMER_ID_KEY);
138
+ await adapter.unset(EMAIL_KEY);
139
+ }
140
+ async function getSessionToken(adapter) {
141
+ return await adapter.get(TOKEN_KEY) ?? null;
142
+ }
143
+ async function refreshSession(adapter, env) {
144
+ const refreshToken = await adapter.get(REFRESH_KEY) ?? null;
145
+ if (!refreshToken) return null;
146
+ try {
147
+ const result = await customerRefreshToken(env, refreshToken);
148
+ await saveSession(adapter, result);
149
+ return { token: result.token, refreshToken: result.refreshToken };
150
+ } catch {
151
+ await clearSession(adapter);
152
+ return null;
153
+ }
154
+ }
155
+ function isAuthExpiredError(err) {
156
+ if (!(err instanceof FoirRequestError)) return false;
157
+ if (err.status === 401) return true;
158
+ return (err.graphQLErrors ?? []).some(
159
+ (e) => /authentication required/i.test(e.message)
160
+ );
161
+ }
162
+ async function authedRequest(adapter, env, document, variables) {
163
+ const token = await getSessionToken(adapter);
164
+ try {
165
+ return await foirRequest(env, document, variables, {
166
+ bearerToken: token ?? void 0
167
+ });
168
+ } catch (err) {
169
+ if (!isAuthExpiredError(err)) throw err;
170
+ const refreshed = await refreshSession(adapter, env);
171
+ if (!refreshed) throw err;
172
+ return foirRequest(env, document, variables, {
173
+ bearerToken: refreshed.token
174
+ });
175
+ }
176
+ }
177
+
178
+ // src/push/operations.ts
179
+ import { parse as parse2 } from "graphql";
180
+ var WebPushConfigDocument = parse2(
181
+ `query WebPushConfig {
182
+ webPushConfig { vapidPublicKey }
183
+ }`
184
+ );
185
+ var RegisterDeviceTokenDocument = parse2(
186
+ `mutation RegisterDeviceToken($input: RegisterDeviceTokenInput!) {
187
+ registerDeviceToken(input: $input)
188
+ }`
189
+ );
190
+ var UnregisterDeviceTokenDocument = parse2(
191
+ `mutation UnregisterDeviceToken($token: String!) {
192
+ unregisterDeviceToken(token: $token)
193
+ }`
194
+ );
195
+
196
+ // src/push/web-push.ts
197
+ function urlBase64ToUint8Array(base64) {
198
+ const padding = "=".repeat((4 - base64.length % 4) % 4);
199
+ const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
200
+ const raw = atob(normalized);
201
+ const out = new Uint8Array(new ArrayBuffer(raw.length));
202
+ for (let i = 0; i < raw.length; i += 1) out[i] = raw.charCodeAt(i);
203
+ return out;
204
+ }
205
+ function isWebPushSupported() {
206
+ return typeof window !== "undefined" && typeof navigator !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
207
+ }
208
+ async function enableWebPush(opts) {
209
+ if (!isWebPushSupported()) return { ok: false, reason: "unsupported" };
210
+ const permission = await Notification.requestPermission();
211
+ if (permission !== "granted") return { ok: false, reason: "denied" };
212
+ const registration = await navigator.serviceWorker.register(
213
+ opts.swPath ?? "/foir-sw.js",
214
+ { scope: opts.scope ?? "/" }
215
+ );
216
+ await navigator.serviceWorker.ready;
217
+ const config = await authedRequest(
218
+ opts.adapter,
219
+ opts.env,
220
+ WebPushConfigDocument
221
+ );
222
+ const vapidPublicKey = config.webPushConfig?.vapidPublicKey;
223
+ if (!vapidPublicKey) return { ok: false, reason: "no-config" };
224
+ const existing = await registration.pushManager.getSubscription();
225
+ const subscription = existing ?? await registration.pushManager.subscribe({
226
+ userVisibleOnly: true,
227
+ applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
228
+ });
229
+ await authedRequest(opts.adapter, opts.env, RegisterDeviceTokenDocument, {
230
+ input: {
231
+ token: JSON.stringify(subscription),
232
+ platform: "WEB",
233
+ deviceName: opts.deviceName
234
+ }
235
+ });
236
+ return { ok: true, subscription };
237
+ }
238
+ async function disableWebPush(opts) {
239
+ if (!isWebPushSupported()) return false;
240
+ const registration = await navigator.serviceWorker.getRegistration(
241
+ opts.scope ?? "/"
242
+ );
243
+ const subscription = await registration?.pushManager.getSubscription();
244
+ if (!subscription) return false;
245
+ const token = JSON.stringify(subscription);
246
+ await subscription.unsubscribe();
247
+ await authedRequest(opts.adapter, opts.env, UnregisterDeviceTokenDocument, {
248
+ token
249
+ });
250
+ return true;
251
+ }
252
+ export {
253
+ RegisterDeviceTokenDocument,
254
+ UnregisterDeviceTokenDocument,
255
+ WebPushConfigDocument,
256
+ disableWebPush,
257
+ enableWebPush,
258
+ isWebPushSupported
259
+ };
@@ -0,0 +1,244 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ReactNode } from 'react';
3
+
4
+ /**
5
+ * Foir GraphQL client — a single typed fetch wrapper for SSR/edge loaders.
6
+ *
7
+ * No GraphQL client (Apollo/urql) required: the Foir public API is a plain
8
+ * `POST` with an `x-api-key` header, and the dominant consumers (Hydrogen on
9
+ * Oxygen, React Router loaders, TanStack Start on CF Workers) fetch in
10
+ * server-only loaders and render — so Apollo's `InMemoryCache`/`useQuery`
11
+ * reactivity is dead weight, and a shared cache on a stateless multi-tenant
12
+ * edge worker is a cross-tenant hazard. This helper carries only the
13
+ * Foir-specific transport concerns (auth header, preview schema channel,
14
+ * error shape). Consumers on a GraphQL client (Apollo / urql / react-query /
15
+ * graphql-request) use the generated `TypedDocumentNode`s directly with their
16
+ * own client and replicate the two Foir-specific headers below — the
17
+ * `x-api-key` and, in preview, `X-Foir-Schema-Channel: draft`.
18
+ *
19
+ * Deduplicates the hand-copied `lib/foir/client.ts` that shipped identically
20
+ * across the Foir storefronts.
21
+ */
22
+
23
+ interface FoirEnv {
24
+ PLATFORM_API_URL?: string;
25
+ PLATFORM_API_KEY?: string;
26
+ PLATFORM_PREVIEW_API_KEY?: string;
27
+ FOIR_PREVIEW?: string;
28
+ }
29
+
30
+ /**
31
+ * Foir customer-auth GraphQL operations + result types.
32
+ *
33
+ * These are PLATFORM-STABLE primitives — `customerLogin`/`customerRegister`/
34
+ * `customerRefreshToken`/OTP/provider login are fixed-shape operations served
35
+ * by api-public for every project (gated per-project by the password/OTP/
36
+ * provider flags, but identical in shape when present). Unlike per-project
37
+ * model types, they don't vary by configured models, so the SDK owns the
38
+ * documents directly rather than relying on the consumer's codegen.
39
+ *
40
+ * The project-specific customer PROFILE (e.g. `myProfile.isAdmin`) is NOT here
41
+ * — that shape varies per project, so it stays consumer-injected (see
42
+ * `requireAuth`'s `resolveUser`).
43
+ */
44
+
45
+ /** A configured auth provider, as exposed to unauthenticated callers. */
46
+ interface AuthProviderPublic {
47
+ key: string;
48
+ name: string;
49
+ /** OAUTH2 | TOKEN_SSO | SAML | OIDC | EXTERNAL */
50
+ type: string;
51
+ isDefault: boolean | null;
52
+ }
53
+ interface ProviderLoginInput {
54
+ providerKey: string;
55
+ email?: string;
56
+ returnTo?: string;
57
+ /** Override the provider's default redirect_uri (must be allow-listed). */
58
+ redirectUri?: string;
59
+ }
60
+ interface ProviderCallbackInput {
61
+ providerKey: string;
62
+ code?: string;
63
+ state?: string;
64
+ email?: string;
65
+ idToken?: string;
66
+ }
67
+
68
+ /**
69
+ * Foir customer auth — framework-agnostic flows, token lifecycle, and guards.
70
+ *
71
+ * Session I/O is injected via {@link SessionAdapter} (no Hydrogen/React-Router
72
+ * coupling). Pre-login flows authenticate with the project `x-api-key`;
73
+ * post-login reads add the customer bearer. The project-specific profile (e.g.
74
+ * `isAdmin`) is resolved by a consumer-supplied `resolveUser` — the SDK never
75
+ * hardcodes the per-project customer profile shape.
76
+ */
77
+
78
+ /** The customer identity carried by an auth result. */
79
+ interface FoirCustomer {
80
+ id: string;
81
+ email: string;
82
+ }
83
+ /**
84
+ * Injected session store. Methods may be sync or async so consumers can plug
85
+ * in a cookie session (Hydrogen/Remix), a KV store, etc.
86
+ */
87
+ interface SessionAdapter {
88
+ get(key: string): string | null | undefined | Promise<string | null | undefined>;
89
+ set(key: string, value: string): void | Promise<void>;
90
+ unset(key: string): void | Promise<void>;
91
+ }
92
+
93
+ /** Where the session hydration stands. */
94
+ type FoirStatus = 'loading' | 'authenticated' | 'unauthenticated';
95
+ /** The full context value. Read it through the hooks below, not directly. */
96
+ interface FoirContextValue {
97
+ status: FoirStatus;
98
+ customer: FoirCustomer | null;
99
+ /** The API config; exposed so advanced consumers can call `@foir/sdk/auth` directly. */
100
+ env: FoirEnv;
101
+ /** The session store backing this provider. */
102
+ adapter: SessionAdapter;
103
+ signIn(vars: {
104
+ email: string;
105
+ password: string;
106
+ }): Promise<FoirCustomer>;
107
+ register(vars: {
108
+ email: string;
109
+ password: string;
110
+ }): Promise<FoirCustomer>;
111
+ requestOtp(email: string): Promise<boolean>;
112
+ signInWithOtp(vars: {
113
+ email: string;
114
+ otp: string;
115
+ }): Promise<FoirCustomer>;
116
+ /** Start a provider (OAuth/OIDC) login. Redirect providers navigate away. */
117
+ signInWithProvider(providerKey: string, opts?: Omit<ProviderLoginInput, 'providerKey'>): Promise<void>;
118
+ /** Complete a provider login from your callback route (code + state). */
119
+ completeProviderLogin(input: Omit<ProviderCallbackInput, 'providerKey'> & {
120
+ providerKey: string;
121
+ }): Promise<FoirCustomer>;
122
+ signOut(): Promise<void>;
123
+ /** Rotate the access token; returns false (and signs out) if it can't. */
124
+ refresh(): Promise<boolean>;
125
+ /** The current access token, or null when signed out. */
126
+ getToken(): Promise<string | null>;
127
+ /** The project's configured auth providers (for OAuth buttons). */
128
+ getProviders(): Promise<AuthProviderPublic[]>;
129
+ }
130
+ interface FoirProviderProps {
131
+ /** API config: `{ PLATFORM_API_URL, PLATFORM_API_KEY }` (a publishable `pk_` key). */
132
+ env: FoirEnv;
133
+ /** Session store. Defaults to a browser localStorage adapter. */
134
+ adapter?: SessionAdapter;
135
+ children: ReactNode;
136
+ }
137
+ /**
138
+ * Provides Foir customer auth to a React tree. Hydrates the stored session on
139
+ * mount, then exposes the current customer and sign-in/out actions through
140
+ * {@link useFoirSession}, {@link useFoirUser}, and {@link useFoirAuth}.
141
+ */
142
+ declare function FoirProvider({ env, adapter, children }: FoirProviderProps): react_jsx_runtime.JSX.Element;
143
+ /** The current session state. */
144
+ interface FoirSession {
145
+ status: FoirStatus;
146
+ customer: FoirCustomer | null;
147
+ isLoading: boolean;
148
+ isAuthenticated: boolean;
149
+ }
150
+ /** Read the current session status and customer. */
151
+ declare function useFoirSession(): FoirSession;
152
+ /** The signed-in customer (`{ id, email }`), or null. */
153
+ declare function useFoirUser(): FoirCustomer | null;
154
+ /** The sign-in / sign-out actions. */
155
+ declare function useFoirAuth(): {
156
+ signIn: (vars: {
157
+ email: string;
158
+ password: string;
159
+ }) => Promise<FoirCustomer>;
160
+ register: (vars: {
161
+ email: string;
162
+ password: string;
163
+ }) => Promise<FoirCustomer>;
164
+ requestOtp: (email: string) => Promise<boolean>;
165
+ signInWithOtp: (vars: {
166
+ email: string;
167
+ otp: string;
168
+ }) => Promise<FoirCustomer>;
169
+ signInWithProvider: (providerKey: string, opts?: Omit<ProviderLoginInput, "providerKey">) => Promise<void>;
170
+ completeProviderLogin: (input: Omit<ProviderCallbackInput, "providerKey"> & {
171
+ providerKey: string;
172
+ }) => Promise<FoirCustomer>;
173
+ signOut: () => Promise<void>;
174
+ refresh: () => Promise<boolean>;
175
+ getToken: () => Promise<string | null>;
176
+ getProviders: () => Promise<AuthProviderPublic[]>;
177
+ };
178
+
179
+ interface SignInProps {
180
+ /** `password` (default) or `otp` (email one-time code). Ignored when `hostedUrl` is set. */
181
+ method?: 'password' | 'otp';
182
+ /** Render a single button that redirects to a hosted login page instead of an embedded form. */
183
+ hostedUrl?: string;
184
+ /** Show OAuth provider buttons below the form. Default true. */
185
+ oauth?: boolean;
186
+ /** Navigate here after a successful sign-in. */
187
+ afterSignInUrl?: string;
188
+ /** Called with the customer on a successful sign-in (before any redirect). */
189
+ onSignIn?(customer: FoirCustomer): void;
190
+ className?: string;
191
+ }
192
+ /**
193
+ * A drop-in customer sign-in. Renders an email/password (or OTP) form and,
194
+ * unless disabled, OAuth provider buttons. Set `hostedUrl` to instead render a
195
+ * button that redirects to a hosted login page. All state is handled through
196
+ * {@link FoirProvider}; on success it calls `onSignIn` and optionally navigates
197
+ * to `afterSignInUrl`.
198
+ */
199
+ declare function SignIn({ method, hostedUrl, oauth, afterSignInUrl, onSignIn, className, }: SignInProps): react_jsx_runtime.JSX.Element;
200
+
201
+ interface OAuthButtonsProps {
202
+ /** Passed to the provider login (where the callback returns the user). */
203
+ returnTo?: string;
204
+ /** Override the provider's default redirect URI (must be allow-listed). */
205
+ redirectUri?: string;
206
+ /** Filter which providers render (defaults to every redirect-capable one). */
207
+ filter?(provider: AuthProviderPublic): boolean;
208
+ /** Render your own button. Receives the provider and a click handler. */
209
+ children?(provider: AuthProviderPublic, signIn: () => void): ReactNode;
210
+ className?: string;
211
+ onError?(error: unknown): void;
212
+ }
213
+ /**
214
+ * Renders a sign-in button per configured OAuth/OIDC/SAML provider. Clicking a
215
+ * button starts the provider login and (for redirect flows) navigates away.
216
+ * Pass a `children` render prop to fully control the button markup.
217
+ */
218
+ declare function OAuthButtons({ returnTo, redirectUri, filter, children, className, onError, }: OAuthButtonsProps): react_jsx_runtime.JSX.Element | null;
219
+
220
+ interface UserButtonProps {
221
+ /** Called after a successful sign-out. */
222
+ afterSignOut?(): void;
223
+ /** Navigate here after sign-out. */
224
+ afterSignOutUrl?: string;
225
+ className?: string;
226
+ }
227
+ /**
228
+ * A minimal signed-in affordance: the customer's email plus a sign-out button.
229
+ * Renders nothing when signed out. Style it via `className` (the default is an
230
+ * unopinionated inline layout).
231
+ */
232
+ declare function UserButton({ afterSignOut, afterSignOutUrl, className }: UserButtonProps): react_jsx_runtime.JSX.Element | null;
233
+
234
+ /**
235
+ * A {@link SessionAdapter} backed by the browser's `localStorage`.
236
+ *
237
+ * SSR-safe: with no `window` (server render) reads return `null` and writes are
238
+ * no-ops, so {@link FoirProvider} starts in `loading` and hydrates the real
239
+ * session on the client after mount. This is the default adapter when you don't
240
+ * pass one to `FoirProvider`.
241
+ */
242
+ declare function createLocalStorageAdapter(): SessionAdapter;
243
+
244
+ export { type AuthProviderPublic, type FoirContextValue, type FoirCustomer, type FoirEnv, FoirProvider, type FoirProviderProps, type FoirSession, type FoirStatus, OAuthButtons, type OAuthButtonsProps, type SessionAdapter, SignIn, type SignInProps, UserButton, type UserButtonProps, createLocalStorageAdapter, useFoirAuth, useFoirSession, useFoirUser };