@foir/sdk 0.3.0 → 0.5.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/README.md CHANGED
@@ -24,8 +24,8 @@ pnpm add yjs y-websocket # for @foir/sdk/realtime
24
24
  | --- | --- | --- |
25
25
  | `@foir/sdk` | Editor iframe client — `EditorProvider`, `useEditor`, `useAutoResize`, `createPlatformClient`, host↔editor message protocol | `react` |
26
26
  | `@foir/sdk/client` | Client-agnostic GraphQL transport for SSR/edge loaders — `foirRequest`, `isFoirPreview`, `absolutiseFoirMediaUrl` | `graphql` |
27
- | `@foir/sdk/auth` | Framework-agnostic customer auth — login/register/OTP/provider flows, token lifecycle (`SessionAdapter`), `authedRequest`, guards | `graphql` |
28
- | `@foir/sdk/react` | Drop-in React auth — `<FoirProvider>`, `useFoirSession`/`useFoirUser`/`useFoirAuth`, `<SignIn>`, `<OAuthButtons>`, `<UserButton>` | `react`, `graphql` |
27
+ | `@foir/sdk/auth` | Framework-agnostic customer auth — login/register/OTP/provider flows, token lifecycle (`SessionAdapter`), `createFoirSessionClient` (Foir-hosted cookie sessions), `authedRequest`, guards | `graphql` |
28
+ | `@foir/sdk/react` | Drop-in React auth — `<FoirProvider>` (token or `hostedSession` mode), `useFoirSession`/`useFoirUser`/`useFoirAuth`, `<SignIn>`, `<OAuthButtons>`, `<UserButton>` | `react`, `graphql` |
29
29
  | `@foir/sdk/next` | Next.js App Router BFF auth — `createFoirCookieAdapter`, `getFoirSession`, `foirSignIn*`, `completeFoirProviderLogin`, `createFoirMiddleware` | `graphql` |
30
30
  | `@foir/sdk/server` | Server-side helpers for extension/config API backends — `createServerClient`, `verifyWebhookSignature`, `verifyScopedToken`, callback client | — |
31
31
  | `@foir/sdk/media` | Progressive media — `ProgressiveMedia`, `BlurhashCanvas`, `useProgressiveLoad`, variant-tier adapters | `react` (`hls.js`, `blurhash` optional) |
@@ -108,6 +108,40 @@ function Header() {
108
108
  For a custom form, use `useFoirAuth()` (`signIn`, `register`, `signInWithOtp`,
109
109
  `requestOtp`, `signInWithProvider`, `signOut`, `getToken`) and `useFoirUser()`.
110
110
 
111
+ ### Backend-less SPA (Foir-hosted sessions)
112
+
113
+ By default `<FoirProvider>` stores tokens in `localStorage`. If your SPA has no
114
+ backend but you have a **verified custom login domain** on the same registrable
115
+ domain (`app.acme.com` + `login.acme.com`), prefer `hostedSession`: the durable
116
+ credential becomes an HttpOnly cookie Foir sets on your domain, and the 15-minute
117
+ access token lives only in memory. Nothing authentication-related is reachable
118
+ from JS, so XSS has nothing to exfiltrate — the BFF security posture without a
119
+ backend.
120
+
121
+ ```tsx
122
+ <FoirProvider
123
+ env={{ PLATFORM_API_URL: 'https://api.foir.dev/graphql', PLATFORM_API_KEY: 'pk_…' }}
124
+ hostedSession={{ loginOrigin: 'https://login.acme.com' }}
125
+ >
126
+ <App />
127
+ </FoirProvider>
128
+ ```
129
+
130
+ Sign-in is a redirect, not a form — `useFoirAuth().hostedSignInUrl()` gives you
131
+ the URL (the password/OTP actions throw in this mode). `getToken()` exchanges the
132
+ cookie and re-exchanges it silently before expiry; `signOut()` revokes the session
133
+ server-side. Everything else — `useFoirSession`, `useFoirUser`, `<UserButton>` —
134
+ behaves the same.
135
+
136
+ Framework-agnostic equivalent:
137
+
138
+ ```ts
139
+ import { createFoirSessionClient } from '@foir/sdk/auth';
140
+
141
+ const session = createFoirSessionClient({ loginOrigin: 'https://login.acme.com' });
142
+ const token = await session.getToken(); // null when signed out
143
+ ```
144
+
111
145
  ### Next.js (App Router, BFF)
112
146
 
113
147
  `@foir/sdk/next` keeps the tokens in HttpOnly cookies server-side. Protect
package/dist/auth.d.ts CHANGED
@@ -302,4 +302,81 @@ interface HostedSession {
302
302
  */
303
303
  declare function fetchHostedSession(request: Request, config: HostedLoginConfig): Promise<HostedSession | null>;
304
304
 
305
- export { type AuthProviderPublic, AuthProvidersDocument, type CustomerAuthResult, CustomerLoginDocument, CustomerLoginOTPDocument, CustomerLoginWithProviderDocument, CustomerProviderCallbackDocument, CustomerProviderVerifyOTPDocument, CustomerRefreshTokenDocument, CustomerRegisterDocument, CustomerRequestOTPDocument, type FoirAuthTokens, type FoirCustomer, type FoirUser, type HostedLoginConfig, type HostedSession, type ProviderCallbackInput, type ProviderLoginInput, type ProviderLoginResult, type ProviderOTPVerifyInput, type RequireAdminOptions, type RequireAuthOptions, type SessionAdapter, authedRequest, clearSession, customerLogin, customerLoginOTP, customerLoginWithProvider, customerProviderCallback, customerProviderVerifyOTP, customerRefreshToken, customerRegister, customerRequestOTP, fetchHostedSession, getAuthProviders, getSessionCustomer, getSessionToken, hostedLoginUrl, loginWithOTP, loginWithPassword, refreshSession, registerWithPassword, requireAdmin, requireAuth, saveSession };
305
+ /**
306
+ * Foir-hosted first-party sessions, from the browser — the model for a
307
+ * backend-less SPA (Vite, CRA, a static site) that wants no tokens in JS.
308
+ *
309
+ * The sibling `fetchHostedSession` in ./hosted does the same exchange from a
310
+ * SERVER: it reads the cookie off an incoming request and forwards it by hand.
311
+ * A browser cannot set a `Cookie` header, so that function is unusable from an
312
+ * SPA by construction. This one lets the browser send the cookie the only way it
313
+ * can — `credentials: 'include'` — and keeps the resulting bearer in memory.
314
+ *
315
+ * How the pieces divide:
316
+ *
317
+ * cookie — long-lived (30d), HttpOnly, first-party on your registrable
318
+ * domain, set by Foir when hosted login finalizes. JS cannot read
319
+ * it, so XSS cannot exfiltrate it.
320
+ * bearer — 15 minutes, held in a closure here. Never written to
321
+ * localStorage/sessionStorage/IndexedDB, never in a cookie of ours.
322
+ *
323
+ * There is no refresh token in this flow. Re-exchanging the cookie IS the
324
+ * refresh, which is why nothing here needs rotation or reuse-detection: the
325
+ * durable credential is an opaque server-side session handle, revocable at any
326
+ * time from the admin panel or `signOut()`.
327
+ *
328
+ * REQUIRES a verified custom login domain that shares a registrable domain with
329
+ * your app (app.acme.com + login.acme.com). That is what makes the cookie
330
+ * first-party and lets `SameSite=Lax` send it. The same endpoints exist on
331
+ * api.foir.dev but a browser cannot reach them there — the cookie would be
332
+ * cross-site, and that CORS surface deliberately refuses credentials.
333
+ */
334
+ interface FoirSessionConfig {
335
+ /**
336
+ * Your verified custom login domain, e.g. `https://login.acme.com`. Must share
337
+ * a registrable domain with the app calling it.
338
+ */
339
+ loginOrigin: string;
340
+ /** Session-exchange path. Default `/session/token`. */
341
+ sessionPath?: string;
342
+ /** Logout path. Default `/session/logout`. */
343
+ logoutPath?: string;
344
+ /** Seconds before expiry to re-exchange. Default 60. */
345
+ refreshSkewSeconds?: number;
346
+ /**
347
+ * Called when the session goes away — the cookie expired, was revoked, or
348
+ * `signOut()` ran. Use it to redirect to `signInUrl()`.
349
+ */
350
+ onSignOut?: () => void;
351
+ }
352
+ /** The identity behind the current session. */
353
+ interface FoirSessionUser {
354
+ customerId: string;
355
+ email: string;
356
+ }
357
+ interface FoirSessionClient {
358
+ /**
359
+ * The current access token, exchanging or re-exchanging the cookie as needed.
360
+ * Null when there is no session. Concurrent callers share one in-flight
361
+ * exchange. Call this before each API request — do not cache the result.
362
+ */
363
+ getToken(): Promise<string | null>;
364
+ /** The signed-in customer, or null. Exchanges if the session isn't loaded yet. */
365
+ getUser(): Promise<FoirSessionUser | null>;
366
+ /** Revoke the session server-side and clear the cookie. Idempotent. */
367
+ signOut(): Promise<void>;
368
+ /** Where to send the browser to sign in. */
369
+ signInUrl(returnTo?: string): string;
370
+ /** Observe session changes. Returns an unsubscribe function. */
371
+ subscribe(listener: (user: FoirSessionUser | null) => void): () => void;
372
+ /** Cancel the refresh timer. Call on unmount. */
373
+ destroy(): void;
374
+ }
375
+ /**
376
+ * Create a session client. Safe to call during SSR — it does nothing until a
377
+ * method is invoked, and `getToken()` on the server returns null rather than
378
+ * throwing (there is no cookie jar to draw on).
379
+ */
380
+ declare function createFoirSessionClient(config: FoirSessionConfig): FoirSessionClient;
381
+
382
+ export { type AuthProviderPublic, AuthProvidersDocument, type CustomerAuthResult, CustomerLoginDocument, CustomerLoginOTPDocument, CustomerLoginWithProviderDocument, CustomerProviderCallbackDocument, CustomerProviderVerifyOTPDocument, CustomerRefreshTokenDocument, CustomerRegisterDocument, CustomerRequestOTPDocument, type FoirAuthTokens, type FoirCustomer, type FoirSessionClient, type FoirSessionConfig, type FoirSessionUser, type FoirUser, type HostedLoginConfig, type HostedSession, type ProviderCallbackInput, type ProviderLoginInput, type ProviderLoginResult, type ProviderOTPVerifyInput, type RequireAdminOptions, type RequireAuthOptions, type SessionAdapter, authedRequest, clearSession, createFoirSessionClient, customerLogin, customerLoginOTP, customerLoginWithProvider, customerProviderCallback, customerProviderVerifyOTP, customerRefreshToken, customerRegister, customerRequestOTP, fetchHostedSession, getAuthProviders, getSessionCustomer, getSessionToken, hostedLoginUrl, loginWithOTP, loginWithPassword, refreshSession, registerWithPassword, requireAdmin, requireAuth, saveSession };
package/dist/auth.js CHANGED
@@ -274,6 +274,141 @@ async function fetchHostedSession(request, config) {
274
274
  function escapeRegExp(s) {
275
275
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
276
276
  }
277
+
278
+ // src/auth/session-client.ts
279
+ var DEFAULT_SESSION_PATH2 = "/session/token";
280
+ var DEFAULT_LOGOUT_PATH = "/session/logout";
281
+ var DEFAULT_REFRESH_SKEW_SECONDS = 60;
282
+ var MIN_REFRESH_DELAY_MS = 5e3;
283
+ var CSRF_HEADER = { "X-Foir-Session": "1" };
284
+ function createFoirSessionClient(config) {
285
+ const sessionUrl = `${trimSlash(config.loginOrigin)}${config.sessionPath ?? DEFAULT_SESSION_PATH2}`;
286
+ const logoutUrl = `${trimSlash(config.loginOrigin)}${config.logoutPath ?? DEFAULT_LOGOUT_PATH}`;
287
+ const skewMs = (config.refreshSkewSeconds ?? DEFAULT_REFRESH_SKEW_SECONDS) * 1e3;
288
+ let cached = null;
289
+ let inflight = null;
290
+ let timer = null;
291
+ let destroyed = false;
292
+ const listeners = /* @__PURE__ */ new Set();
293
+ function emit(user) {
294
+ for (const l of listeners) l(user);
295
+ }
296
+ function clearTimer() {
297
+ if (timer !== null) {
298
+ clearTimeout(timer);
299
+ timer = null;
300
+ }
301
+ }
302
+ function scheduleRefresh(expiresAt) {
303
+ clearTimer();
304
+ if (destroyed || typeof setTimeout === "undefined") return;
305
+ const delay = Math.max(expiresAt - Date.now() - skewMs, MIN_REFRESH_DELAY_MS);
306
+ timer = setTimeout(() => {
307
+ void exchange();
308
+ }, delay);
309
+ timer.unref?.();
310
+ }
311
+ function forget() {
312
+ const had = cached !== null;
313
+ cached = null;
314
+ clearTimer();
315
+ if (had) emit(null);
316
+ }
317
+ function exchange() {
318
+ if (inflight) return inflight;
319
+ const pending = (async () => {
320
+ let res;
321
+ try {
322
+ res = await fetch(sessionUrl, {
323
+ method: "GET",
324
+ // The whole point: send the first-party cookie cross-origin (but
325
+ // same-site) to the login domain. Without this the cookie is not sent.
326
+ credentials: "include",
327
+ headers: { ...CSRF_HEADER }
328
+ });
329
+ } catch {
330
+ return cached;
331
+ }
332
+ if (res.status === 401) {
333
+ forget();
334
+ config.onSignOut?.();
335
+ return null;
336
+ }
337
+ if (!res.ok) return cached;
338
+ let json;
339
+ try {
340
+ json = await res.json();
341
+ } catch {
342
+ return cached;
343
+ }
344
+ if (!json.access_token || !json.customer_id) return cached;
345
+ const expiresIn = typeof json.expires_in === "number" ? json.expires_in : 0;
346
+ const next = {
347
+ token: json.access_token,
348
+ expiresAt: Date.now() + expiresIn * 1e3,
349
+ user: { customerId: json.customer_id, email: json.email ?? "" }
350
+ };
351
+ const changed = cached?.user.customerId !== next.user.customerId;
352
+ cached = next;
353
+ scheduleRefresh(next.expiresAt);
354
+ if (changed) emit(next.user);
355
+ return next;
356
+ })();
357
+ inflight = pending;
358
+ void pending.finally(() => {
359
+ if (inflight === pending) inflight = null;
360
+ });
361
+ return pending;
362
+ }
363
+ function fresh() {
364
+ if (!cached) return null;
365
+ return Date.now() < cached.expiresAt - skewMs ? cached : null;
366
+ }
367
+ return {
368
+ async getToken() {
369
+ const hit = fresh();
370
+ if (hit) return hit.token;
371
+ const got = await exchange();
372
+ return got?.token ?? null;
373
+ },
374
+ async getUser() {
375
+ const hit = fresh();
376
+ if (hit) return hit.user;
377
+ const got = await exchange();
378
+ return got?.user ?? null;
379
+ },
380
+ async signOut() {
381
+ try {
382
+ await fetch(logoutUrl, {
383
+ method: "POST",
384
+ credentials: "include",
385
+ headers: { ...CSRF_HEADER }
386
+ });
387
+ } catch {
388
+ }
389
+ forget();
390
+ config.onSignOut?.();
391
+ },
392
+ signInUrl(returnTo) {
393
+ const dest = new URL(trimSlash(config.loginOrigin));
394
+ const target = returnTo ?? (typeof window !== "undefined" ? window.location.href : void 0);
395
+ if (target && target !== "/") dest.searchParams.set("returnTo", target);
396
+ return dest.toString();
397
+ },
398
+ subscribe(listener) {
399
+ listeners.add(listener);
400
+ return () => listeners.delete(listener);
401
+ },
402
+ destroy() {
403
+ destroyed = true;
404
+ clearTimer();
405
+ listeners.clear();
406
+ }
407
+ };
408
+ }
409
+ function trimSlash(s) {
410
+ return s.replace(/\/+$/, "");
411
+ }
277
412
  export {
278
413
  AuthProvidersDocument,
279
414
  CustomerLoginDocument,
@@ -286,6 +421,7 @@ export {
286
421
  CustomerRequestOTPDocument,
287
422
  authedRequest,
288
423
  clearSession,
424
+ createFoirSessionClient,
289
425
  customerLogin,
290
426
  customerLoginOTP,
291
427
  customerLoginWithProvider,
package/dist/client.d.ts CHANGED
@@ -80,4 +80,56 @@ interface FoirRequestOptions {
80
80
  }
81
81
  declare function foirRequest<TData, TVariables>(env: FoirEnv, document: TypedDocumentNode<TData, TVariables>, variables?: TVariables, options?: FoirRequestOptions): Promise<TData>;
82
82
 
83
- export { FOIR_CDN_BASE, type FoirEnv, FoirRequestError, type FoirRequestOptions, absolutiseFoirMediaUrl, foirRequest, isFoirPreview };
83
+ /**
84
+ * Session-bound content client — forwards the customer bearer on every read
85
+ * when one is available, so auth/segment-targeted variants and customer-scoped
86
+ * records resolve out of the box, with no per-query wiring to forget.
87
+ *
88
+ * Resolution keys off the request's principal (see the platform's two-gate
89
+ * model): the `x-api-key` alone resolves the *anonymous baseline*
90
+ * (`context.authStatus = ""`); adding the customer bearer resolves the request
91
+ * as that customer (`context.authStatus = "authenticated"`, `customer_id` set).
92
+ *
93
+ * Caching: an api-key-only read is identical for every anonymous visitor and is
94
+ * shared-cacheable; a bearer-bearing read resolves per-customer and is not.
95
+ * Because the bearer only exists once a customer signs in, anonymous traffic
96
+ * keeps hitting the shared-cacheable baseline — only signed-in reads become
97
+ * per-customer. Bind this client wherever content is personalized (for most
98
+ * apps, everywhere the customer may be signed in) and use plain `foirRequest`
99
+ * for reads that must stay anonymous regardless of session.
100
+ */
101
+
102
+ /**
103
+ * Supplies the current customer bearer. Sync or async so consumers can read a
104
+ * cookie session (Hydrogen/Remix), a KV store, an in-memory value, etc. Returns
105
+ * the token when signed in, or `null`/`undefined` when signed out.
106
+ */
107
+ type FoirTokenProvider = () => string | null | undefined | Promise<string | null | undefined>;
108
+ interface FoirContentClientConfig {
109
+ env: FoirEnv;
110
+ /**
111
+ * Resolves the current customer bearer before every request. When it yields a
112
+ * token the token is forwarded, so `authStatus`/segment targeting and
113
+ * customer-scoped records resolve as that customer; when it yields
114
+ * null/undefined the read hits the anonymous baseline. Omit for a client that
115
+ * is always anonymous (equivalent to calling `foirRequest` directly).
116
+ */
117
+ getToken?: FoirTokenProvider;
118
+ }
119
+ interface FoirContentClient {
120
+ /**
121
+ * Run a typed document, forwarding the bound customer bearer when available.
122
+ * An explicit `options.bearerToken` still wins, so a single call can override
123
+ * the bound token (or force anonymous by passing `undefined` — the bound
124
+ * token only fills in when `options.bearerToken` is not supplied).
125
+ */
126
+ request<TData, TVariables>(document: TypedDocumentNode<TData, TVariables>, variables?: TVariables, options?: FoirRequestOptions): Promise<TData>;
127
+ }
128
+ /**
129
+ * Create a {@link FoirContentClient} bound to an env + token source. Every
130
+ * `request` forwards the customer bearer when `getToken` yields one, upgrading
131
+ * that read from the anonymous baseline to the signed-in customer.
132
+ */
133
+ declare function createFoirContentClient(config: FoirContentClientConfig): FoirContentClient;
134
+
135
+ export { FOIR_CDN_BASE, type FoirContentClient, type FoirContentClientConfig, type FoirEnv, FoirRequestError, type FoirRequestOptions, type FoirTokenProvider, absolutiseFoirMediaUrl, createFoirContentClient, foirRequest, isFoirPreview };
package/dist/client.js CHANGED
@@ -72,10 +72,24 @@ async function foirRequest(env, document, variables, options) {
72
72
  }
73
73
  return json.data;
74
74
  }
75
+
76
+ // src/graphql/content-client.ts
77
+ function createFoirContentClient(config) {
78
+ return {
79
+ async request(document, variables, options) {
80
+ const bound = config.getToken ? await config.getToken() : null;
81
+ return foirRequest(config.env, document, variables, {
82
+ ...options,
83
+ bearerToken: options?.bearerToken ?? bound ?? void 0
84
+ });
85
+ }
86
+ };
87
+ }
75
88
  export {
76
89
  FOIR_CDN_BASE,
77
90
  FoirRequestError,
78
91
  absolutiseFoirMediaUrl,
92
+ createFoirContentClient,
79
93
  foirRequest,
80
94
  isFoirPreview
81
95
  };
package/dist/react.d.ts CHANGED
@@ -90,6 +90,54 @@ interface SessionAdapter {
90
90
  unset(key: string): void | Promise<void>;
91
91
  }
92
92
 
93
+ /**
94
+ * Foir-hosted first-party sessions, from the browser — the model for a
95
+ * backend-less SPA (Vite, CRA, a static site) that wants no tokens in JS.
96
+ *
97
+ * The sibling `fetchHostedSession` in ./hosted does the same exchange from a
98
+ * SERVER: it reads the cookie off an incoming request and forwards it by hand.
99
+ * A browser cannot set a `Cookie` header, so that function is unusable from an
100
+ * SPA by construction. This one lets the browser send the cookie the only way it
101
+ * can — `credentials: 'include'` — and keeps the resulting bearer in memory.
102
+ *
103
+ * How the pieces divide:
104
+ *
105
+ * cookie — long-lived (30d), HttpOnly, first-party on your registrable
106
+ * domain, set by Foir when hosted login finalizes. JS cannot read
107
+ * it, so XSS cannot exfiltrate it.
108
+ * bearer — 15 minutes, held in a closure here. Never written to
109
+ * localStorage/sessionStorage/IndexedDB, never in a cookie of ours.
110
+ *
111
+ * There is no refresh token in this flow. Re-exchanging the cookie IS the
112
+ * refresh, which is why nothing here needs rotation or reuse-detection: the
113
+ * durable credential is an opaque server-side session handle, revocable at any
114
+ * time from the admin panel or `signOut()`.
115
+ *
116
+ * REQUIRES a verified custom login domain that shares a registrable domain with
117
+ * your app (app.acme.com + login.acme.com). That is what makes the cookie
118
+ * first-party and lets `SameSite=Lax` send it. The same endpoints exist on
119
+ * api.foir.dev but a browser cannot reach them there — the cookie would be
120
+ * cross-site, and that CORS surface deliberately refuses credentials.
121
+ */
122
+ interface FoirSessionConfig {
123
+ /**
124
+ * Your verified custom login domain, e.g. `https://login.acme.com`. Must share
125
+ * a registrable domain with the app calling it.
126
+ */
127
+ loginOrigin: string;
128
+ /** Session-exchange path. Default `/session/token`. */
129
+ sessionPath?: string;
130
+ /** Logout path. Default `/session/logout`. */
131
+ logoutPath?: string;
132
+ /** Seconds before expiry to re-exchange. Default 60. */
133
+ refreshSkewSeconds?: number;
134
+ /**
135
+ * Called when the session goes away — the cookie expired, was revoked, or
136
+ * `signOut()` ran. Use it to redirect to `signInUrl()`.
137
+ */
138
+ onSignOut?: () => void;
139
+ }
140
+
93
141
  /** Where the session hydration stands. */
94
142
  type FoirStatus = 'loading' | 'authenticated' | 'unauthenticated';
95
143
  /** The full context value. Read it through the hooks below, not directly. */
@@ -126,20 +174,46 @@ interface FoirContextValue {
126
174
  getToken(): Promise<string | null>;
127
175
  /** The project's configured auth providers (for OAuth buttons). */
128
176
  getProviders(): Promise<AuthProviderPublic[]>;
177
+ /**
178
+ * In `hostedSession` mode, where to send the browser to sign in — sign-in is a
179
+ * redirect to your Foir login domain, not an in-page form. Null in token mode,
180
+ * where you call {@link FoirContextValue.signIn} instead.
181
+ */
182
+ hostedSignInUrl: ((returnTo?: string) => string) | null;
129
183
  }
130
184
  interface FoirProviderProps {
131
185
  /** API config: `{ PLATFORM_API_URL, PLATFORM_API_KEY }` (a publishable `pk_` key). */
132
186
  env: FoirEnv;
133
- /** Session store. Defaults to a browser localStorage adapter. */
187
+ /** Session store for token mode. Defaults to a browser localStorage adapter. */
134
188
  adapter?: SessionAdapter;
189
+ /**
190
+ * Opt into Foir-hosted first-party sessions: the durable credential is an
191
+ * HttpOnly cookie on your own domain and the access token lives only in
192
+ * memory, so nothing authentication-related is reachable from JS. This is the
193
+ * secure default for a backend-less SPA, and it REPLACES `adapter` — pass one
194
+ * or the other, not both.
195
+ *
196
+ * Requires a verified custom login domain sharing a registrable domain with
197
+ * your app (`app.acme.com` + `login.acme.com`). Sign-in becomes a redirect to
198
+ * {@link FoirContextValue.hostedSignInUrl}; the password/OTP/provider actions
199
+ * do not apply and will throw.
200
+ */
201
+ hostedSession?: Omit<FoirSessionConfig, 'onSignOut'>;
135
202
  children: ReactNode;
136
203
  }
137
204
  /**
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
205
+ * Provides Foir customer auth to a React tree. Hydrates the session on mount,
206
+ * then exposes the current customer and sign-in/out actions through
140
207
  * {@link useFoirSession}, {@link useFoirUser}, and {@link useFoirAuth}.
208
+ *
209
+ * Two session models:
210
+ *
211
+ * default tokens in a `SessionAdapter` (localStorage unless you pass
212
+ * your own). Simple, but a refresh token sits in JS.
213
+ * `hostedSession` Foir-hosted first-party cookie; the bearer never leaves
214
+ * memory. Needs a custom login domain. Prefer this.
141
215
  */
142
- declare function FoirProvider({ env, adapter, children }: FoirProviderProps): react_jsx_runtime.JSX.Element;
216
+ declare function FoirProvider({ env, adapter, hostedSession, children, }: FoirProviderProps): react_jsx_runtime.JSX.Element;
143
217
  /** The current session state. */
144
218
  interface FoirSession {
145
219
  status: FoirStatus;
@@ -174,6 +248,7 @@ declare function useFoirAuth(): {
174
248
  refresh: () => Promise<boolean>;
175
249
  getToken: () => Promise<string | null>;
176
250
  getProviders: () => Promise<AuthProviderPublic[]>;
251
+ hostedSignInUrl: ((returnTo?: string) => string) | null;
177
252
  };
178
253
 
179
254
  interface SignInProps {
package/dist/react.js CHANGED
@@ -202,6 +202,141 @@ async function refreshSession(adapter, env) {
202
202
  }
203
203
  }
204
204
 
205
+ // src/auth/session-client.ts
206
+ var DEFAULT_SESSION_PATH = "/session/token";
207
+ var DEFAULT_LOGOUT_PATH = "/session/logout";
208
+ var DEFAULT_REFRESH_SKEW_SECONDS = 60;
209
+ var MIN_REFRESH_DELAY_MS = 5e3;
210
+ var CSRF_HEADER = { "X-Foir-Session": "1" };
211
+ function createFoirSessionClient(config) {
212
+ const sessionUrl = `${trimSlash(config.loginOrigin)}${config.sessionPath ?? DEFAULT_SESSION_PATH}`;
213
+ const logoutUrl = `${trimSlash(config.loginOrigin)}${config.logoutPath ?? DEFAULT_LOGOUT_PATH}`;
214
+ const skewMs = (config.refreshSkewSeconds ?? DEFAULT_REFRESH_SKEW_SECONDS) * 1e3;
215
+ let cached = null;
216
+ let inflight = null;
217
+ let timer = null;
218
+ let destroyed = false;
219
+ const listeners = /* @__PURE__ */ new Set();
220
+ function emit(user) {
221
+ for (const l of listeners) l(user);
222
+ }
223
+ function clearTimer() {
224
+ if (timer !== null) {
225
+ clearTimeout(timer);
226
+ timer = null;
227
+ }
228
+ }
229
+ function scheduleRefresh(expiresAt) {
230
+ clearTimer();
231
+ if (destroyed || typeof setTimeout === "undefined") return;
232
+ const delay = Math.max(expiresAt - Date.now() - skewMs, MIN_REFRESH_DELAY_MS);
233
+ timer = setTimeout(() => {
234
+ void exchange();
235
+ }, delay);
236
+ timer.unref?.();
237
+ }
238
+ function forget() {
239
+ const had = cached !== null;
240
+ cached = null;
241
+ clearTimer();
242
+ if (had) emit(null);
243
+ }
244
+ function exchange() {
245
+ if (inflight) return inflight;
246
+ const pending = (async () => {
247
+ let res;
248
+ try {
249
+ res = await fetch(sessionUrl, {
250
+ method: "GET",
251
+ // The whole point: send the first-party cookie cross-origin (but
252
+ // same-site) to the login domain. Without this the cookie is not sent.
253
+ credentials: "include",
254
+ headers: { ...CSRF_HEADER }
255
+ });
256
+ } catch {
257
+ return cached;
258
+ }
259
+ if (res.status === 401) {
260
+ forget();
261
+ config.onSignOut?.();
262
+ return null;
263
+ }
264
+ if (!res.ok) return cached;
265
+ let json;
266
+ try {
267
+ json = await res.json();
268
+ } catch {
269
+ return cached;
270
+ }
271
+ if (!json.access_token || !json.customer_id) return cached;
272
+ const expiresIn = typeof json.expires_in === "number" ? json.expires_in : 0;
273
+ const next = {
274
+ token: json.access_token,
275
+ expiresAt: Date.now() + expiresIn * 1e3,
276
+ user: { customerId: json.customer_id, email: json.email ?? "" }
277
+ };
278
+ const changed = cached?.user.customerId !== next.user.customerId;
279
+ cached = next;
280
+ scheduleRefresh(next.expiresAt);
281
+ if (changed) emit(next.user);
282
+ return next;
283
+ })();
284
+ inflight = pending;
285
+ void pending.finally(() => {
286
+ if (inflight === pending) inflight = null;
287
+ });
288
+ return pending;
289
+ }
290
+ function fresh() {
291
+ if (!cached) return null;
292
+ return Date.now() < cached.expiresAt - skewMs ? cached : null;
293
+ }
294
+ return {
295
+ async getToken() {
296
+ const hit = fresh();
297
+ if (hit) return hit.token;
298
+ const got = await exchange();
299
+ return got?.token ?? null;
300
+ },
301
+ async getUser() {
302
+ const hit = fresh();
303
+ if (hit) return hit.user;
304
+ const got = await exchange();
305
+ return got?.user ?? null;
306
+ },
307
+ async signOut() {
308
+ try {
309
+ await fetch(logoutUrl, {
310
+ method: "POST",
311
+ credentials: "include",
312
+ headers: { ...CSRF_HEADER }
313
+ });
314
+ } catch {
315
+ }
316
+ forget();
317
+ config.onSignOut?.();
318
+ },
319
+ signInUrl(returnTo) {
320
+ const dest = new URL(trimSlash(config.loginOrigin));
321
+ const target = returnTo ?? (typeof window !== "undefined" ? window.location.href : void 0);
322
+ if (target && target !== "/") dest.searchParams.set("returnTo", target);
323
+ return dest.toString();
324
+ },
325
+ subscribe(listener) {
326
+ listeners.add(listener);
327
+ return () => listeners.delete(listener);
328
+ },
329
+ destroy() {
330
+ destroyed = true;
331
+ clearTimer();
332
+ listeners.clear();
333
+ }
334
+ };
335
+ }
336
+ function trimSlash(s) {
337
+ return s.replace(/\/+$/, "");
338
+ }
339
+
205
340
  // src/react/localStorageAdapter.ts
206
341
  function createLocalStorageAdapter() {
207
342
  const store = () => {
@@ -225,7 +360,17 @@ function createLocalStorageAdapter() {
225
360
  // src/react/FoirProvider.tsx
226
361
  import { jsx } from "react/jsx-runtime";
227
362
  var FoirContext = createContext(null);
228
- function FoirProvider({ env, adapter, children }) {
363
+ function FoirProvider({
364
+ env,
365
+ adapter,
366
+ hostedSession,
367
+ children
368
+ }) {
369
+ if (process.env.NODE_ENV !== "production" && adapter && hostedSession) {
370
+ console.warn(
371
+ "[foir] FoirProvider: `adapter` is ignored when `hostedSession` is set \u2014 the hosted session keeps the access token in memory and never persists it."
372
+ );
373
+ }
229
374
  const envRef = useRef(env);
230
375
  envRef.current = env;
231
376
  const adapterRef = useRef(adapter ?? createLocalStorageAdapter());
@@ -236,46 +381,86 @@ function FoirProvider({ env, adapter, children }) {
236
381
  setCustomer(c);
237
382
  setStatus(c ? "authenticated" : "unauthenticated");
238
383
  }, []);
384
+ const loginOrigin = hostedSession?.loginOrigin;
385
+ const sessionPath = hostedSession?.sessionPath;
386
+ const logoutPath = hostedSession?.logoutPath;
387
+ const refreshSkewSeconds = hostedSession?.refreshSkewSeconds;
388
+ const hosted = useMemo(() => {
389
+ if (!loginOrigin) return null;
390
+ return createFoirSessionClient({
391
+ loginOrigin,
392
+ sessionPath,
393
+ logoutPath,
394
+ refreshSkewSeconds,
395
+ onSignOut: () => apply(null)
396
+ });
397
+ }, [loginOrigin, sessionPath, logoutPath, refreshSkewSeconds, apply]);
239
398
  useEffect(() => {
240
399
  let cancelled = false;
241
400
  void (async () => {
401
+ if (hosted) {
402
+ const u = await hosted.getUser();
403
+ if (!cancelled) apply(u ? { id: u.customerId, email: u.email } : null);
404
+ return;
405
+ }
242
406
  const c = await getSessionCustomer(adapterRef.current);
243
407
  if (!cancelled) apply(c);
244
408
  })();
245
409
  return () => {
246
410
  cancelled = true;
247
411
  };
248
- }, [apply]);
412
+ }, [apply, hosted]);
413
+ useEffect(() => {
414
+ if (!hosted) return;
415
+ return () => hosted.destroy();
416
+ }, [hosted]);
417
+ const assertTokenMode = useCallback(
418
+ (action) => {
419
+ if (hosted) {
420
+ throw new Error(
421
+ `[foir] ${action}() is not available in hostedSession mode \u2014 the customer signs in at your Foir login domain. Redirect to hostedSignInUrl() instead.`
422
+ );
423
+ }
424
+ },
425
+ [hosted]
426
+ );
249
427
  const signIn = useCallback(
250
428
  async (vars) => {
429
+ assertTokenMode("signIn");
251
430
  const c = await loginWithPassword(adapterRef.current, envRef.current, vars);
252
431
  apply(c);
253
432
  return c;
254
433
  },
255
- [apply]
434
+ [apply, assertTokenMode]
256
435
  );
257
436
  const register = useCallback(
258
437
  async (vars) => {
438
+ assertTokenMode("register");
259
439
  const c = await registerWithPassword(adapterRef.current, envRef.current, vars);
260
440
  apply(c);
261
441
  return c;
262
442
  },
263
- [apply]
443
+ [apply, assertTokenMode]
264
444
  );
265
445
  const requestOtp = useCallback(
266
- (email) => customerRequestOTP(envRef.current, email),
267
- []
446
+ (email) => {
447
+ assertTokenMode("requestOtp");
448
+ return customerRequestOTP(envRef.current, email);
449
+ },
450
+ [assertTokenMode]
268
451
  );
269
452
  const signInWithOtp = useCallback(
270
453
  async (vars) => {
454
+ assertTokenMode("signInWithOtp");
271
455
  const c = await loginWithOTP(adapterRef.current, envRef.current, vars);
272
456
  apply(c);
273
457
  return c;
274
458
  },
275
- [apply]
459
+ [apply, assertTokenMode]
276
460
  );
277
461
  const signInWithProvider = useCallback(
278
462
  async (providerKey, opts) => {
463
+ assertTokenMode("signInWithProvider");
279
464
  const res = await customerLoginWithProvider(envRef.current, {
280
465
  providerKey,
281
466
  ...opts
@@ -284,31 +469,46 @@ function FoirProvider({ env, adapter, children }) {
284
469
  window.location.assign(res.redirectUrl);
285
470
  }
286
471
  },
287
- []
472
+ [assertTokenMode]
288
473
  );
289
474
  const completeProviderLogin = useCallback(
290
475
  async (input) => {
476
+ assertTokenMode("completeProviderLogin");
291
477
  const result = await customerProviderCallback(envRef.current, input);
292
478
  const c = await saveSession(adapterRef.current, result);
293
479
  apply(c);
294
480
  return c;
295
481
  },
296
- [apply]
482
+ [apply, assertTokenMode]
297
483
  );
298
484
  const signOut = useCallback(async () => {
485
+ if (hosted) {
486
+ await hosted.signOut();
487
+ return;
488
+ }
299
489
  await clearSession(adapterRef.current);
300
490
  apply(null);
301
- }, [apply]);
491
+ }, [apply, hosted]);
302
492
  const refresh = useCallback(async () => {
493
+ if (hosted) {
494
+ return await hosted.getToken() !== null;
495
+ }
303
496
  const tokens = await refreshSession(adapterRef.current, envRef.current);
304
497
  if (!tokens) {
305
498
  apply(null);
306
499
  return false;
307
500
  }
308
501
  return true;
309
- }, [apply]);
310
- const getToken = useCallback(() => getSessionToken(adapterRef.current), []);
502
+ }, [apply, hosted]);
503
+ const getToken = useCallback(
504
+ () => hosted ? hosted.getToken() : getSessionToken(adapterRef.current),
505
+ [hosted]
506
+ );
311
507
  const getProviders = useCallback(() => getAuthProviders(envRef.current), []);
508
+ const hostedSignInUrl = useMemo(
509
+ () => hosted ? (returnTo) => hosted.signInUrl(returnTo) : null,
510
+ [hosted]
511
+ );
312
512
  const value = useMemo(
313
513
  () => ({
314
514
  status,
@@ -324,7 +524,8 @@ function FoirProvider({ env, adapter, children }) {
324
524
  signOut,
325
525
  refresh,
326
526
  getToken,
327
- getProviders
527
+ getProviders,
528
+ hostedSignInUrl
328
529
  }),
329
530
  [
330
531
  status,
@@ -339,7 +540,8 @@ function FoirProvider({ env, adapter, children }) {
339
540
  signOut,
340
541
  refresh,
341
542
  getToken,
342
- getProviders
543
+ getProviders,
544
+ hostedSignInUrl
343
545
  ]
344
546
  );
345
547
  return /* @__PURE__ */ jsx(FoirContext.Provider, { value, children });
@@ -374,7 +576,8 @@ function useFoirAuth() {
374
576
  signOut,
375
577
  refresh,
376
578
  getToken,
377
- getProviders
579
+ getProviders,
580
+ hostedSignInUrl
378
581
  } = useFoirContext();
379
582
  return {
380
583
  signIn,
@@ -386,7 +589,8 @@ function useFoirAuth() {
386
589
  signOut,
387
590
  refresh,
388
591
  getToken,
389
- getProviders
592
+ getProviders,
593
+ hostedSignInUrl
390
594
  };
391
595
  }
392
596
 
package/dist/server.d.ts CHANGED
@@ -519,8 +519,8 @@ interface CallbackClient<TResult = unknown> {
519
519
  * payload structure on every call.
520
520
  *
521
521
  * `signingSecret` lives on the extension (set as `FOIR_SIGNING_SECRET` or
522
- * equivalent env var). The CLI publishes it during `foir push` so it's
523
- * available before any callback runs — see foir-cli writeConfigCredential.
522
+ * equivalent env var). `foir push` writes it there from the project's
523
+ * signing secret, so it's available before any callback runs.
524
524
  *
525
525
  * `fetch` is overridable for testability and to support runtimes (CF
526
526
  * Workers, Node) where `fetch` may not be the global.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foir/sdk",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Official Foir SDK — editor iframe client + server-side platform helpers",
5
5
  "type": "module",
6
6
  "publishConfig": {