@foir/sdk 0.2.0 → 0.4.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 +107 -1
- package/dist/auth.d.ts +78 -1
- package/dist/auth.js +136 -0
- package/dist/next.d.ts +178 -0
- package/dist/next.js +275 -0
- package/dist/react.d.ts +319 -0
- package/dist/react.js +812 -0
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -24,7 +24,9 @@ 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` |
|
|
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
|
+
| `@foir/sdk/next` | Next.js App Router BFF auth — `createFoirCookieAdapter`, `getFoirSession`, `foirSignIn*`, `completeFoirProviderLogin`, `createFoirMiddleware` | `graphql` |
|
|
28
30
|
| `@foir/sdk/server` | Server-side helpers for extension/config API backends — `createServerClient`, `verifyWebhookSignature`, `verifyScopedToken`, callback client | — |
|
|
29
31
|
| `@foir/sdk/media` | Progressive media — `ProgressiveMedia`, `BlurhashCanvas`, `useProgressiveLoad`, variant-tier adapters | `react` (`hls.js`, `blurhash` optional) |
|
|
30
32
|
| `@foir/sdk/content` | Content-rendering primitives — `createContentRenderer` block dispatch + Lexical `RichTextRenderer` | `react` |
|
|
@@ -72,6 +74,110 @@ const customer = await loginWithPassword(adapter, env, { email, password });
|
|
|
72
74
|
const { myProfile } = await authedRequest(adapter, env, MyProfileDocument);
|
|
73
75
|
```
|
|
74
76
|
|
|
77
|
+
### React
|
|
78
|
+
|
|
79
|
+
`@foir/sdk/react` builds the reactive layer on top of `@foir/sdk/auth`. Wrap
|
|
80
|
+
your app once, then read the session with hooks or drop in the components. The
|
|
81
|
+
default session store is `localStorage`; pass your own `adapter` for cookies.
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
'use client';
|
|
85
|
+
import { FoirProvider, SignIn, UserButton, useFoirSession } from '@foir/sdk/react';
|
|
86
|
+
|
|
87
|
+
const env = {
|
|
88
|
+
PLATFORM_API_URL: process.env.NEXT_PUBLIC_PLATFORM_API_URL,
|
|
89
|
+
PLATFORM_API_KEY: process.env.NEXT_PUBLIC_PLATFORM_API_KEY, // a publishable pk_ key
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export function App() {
|
|
93
|
+
return (
|
|
94
|
+
<FoirProvider env={env}>
|
|
95
|
+
<Header />
|
|
96
|
+
<SignIn afterSignInUrl="/dashboard" />
|
|
97
|
+
</FoirProvider>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function Header() {
|
|
102
|
+
const { isAuthenticated, isLoading } = useFoirSession();
|
|
103
|
+
if (isLoading) return null;
|
|
104
|
+
return isAuthenticated ? <UserButton afterSignOutUrl="/" /> : null;
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
For a custom form, use `useFoirAuth()` (`signIn`, `register`, `signInWithOtp`,
|
|
109
|
+
`requestOtp`, `signInWithProvider`, `signOut`, `getToken`) and `useFoirUser()`.
|
|
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
|
+
|
|
145
|
+
### Next.js (App Router, BFF)
|
|
146
|
+
|
|
147
|
+
`@foir/sdk/next` keeps the tokens in HttpOnly cookies server-side. Protect
|
|
148
|
+
routes with the middleware, read the session in a server component, and sign in
|
|
149
|
+
from a route handler.
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
// middleware.ts
|
|
153
|
+
import { createFoirMiddleware } from '@foir/sdk/next';
|
|
154
|
+
export const middleware = createFoirMiddleware({ signInUrl: '/sign-in' });
|
|
155
|
+
export const config = { matcher: ['/dashboard/:path*'] };
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// app/dashboard/page.tsx (Server Component)
|
|
160
|
+
import { cookies } from 'next/headers';
|
|
161
|
+
import { getFoirSession } from '@foir/sdk/next';
|
|
162
|
+
|
|
163
|
+
export default async function Dashboard() {
|
|
164
|
+
const customer = await getFoirSession(await cookies());
|
|
165
|
+
return <p>Signed in as {customer?.email}</p>;
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
// app/sign-in/route.ts (Route Handler)
|
|
171
|
+
import { cookies } from 'next/headers';
|
|
172
|
+
import { foirSignInWithPassword } from '@foir/sdk/next';
|
|
173
|
+
|
|
174
|
+
export async function POST(req: Request) {
|
|
175
|
+
const { email, password } = await req.json();
|
|
176
|
+
await foirSignInWithPassword({ env, store: await cookies(), email, password });
|
|
177
|
+
return Response.json({ ok: true });
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
75
181
|
### Codegen
|
|
76
182
|
|
|
77
183
|
```ts
|
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
|
-
|
|
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/next.d.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foir GraphQL client — a single typed fetch wrapper for SSR/edge loaders.
|
|
3
|
+
*
|
|
4
|
+
* No GraphQL client (Apollo/urql) required: the Foir public API is a plain
|
|
5
|
+
* `POST` with an `x-api-key` header, and the dominant consumers (Hydrogen on
|
|
6
|
+
* Oxygen, React Router loaders, TanStack Start on CF Workers) fetch in
|
|
7
|
+
* server-only loaders and render — so Apollo's `InMemoryCache`/`useQuery`
|
|
8
|
+
* reactivity is dead weight, and a shared cache on a stateless multi-tenant
|
|
9
|
+
* edge worker is a cross-tenant hazard. This helper carries only the
|
|
10
|
+
* Foir-specific transport concerns (auth header, preview schema channel,
|
|
11
|
+
* error shape). Consumers on a GraphQL client (Apollo / urql / react-query /
|
|
12
|
+
* graphql-request) use the generated `TypedDocumentNode`s directly with their
|
|
13
|
+
* own client and replicate the two Foir-specific headers below — the
|
|
14
|
+
* `x-api-key` and, in preview, `X-Foir-Schema-Channel: draft`.
|
|
15
|
+
*
|
|
16
|
+
* Deduplicates the hand-copied `lib/foir/client.ts` that shipped identically
|
|
17
|
+
* across the Foir storefronts.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface FoirEnv {
|
|
21
|
+
PLATFORM_API_URL?: string;
|
|
22
|
+
PLATFORM_API_KEY?: string;
|
|
23
|
+
PLATFORM_PREVIEW_API_KEY?: string;
|
|
24
|
+
FOIR_PREVIEW?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Foir customer auth — framework-agnostic flows, token lifecycle, and guards.
|
|
29
|
+
*
|
|
30
|
+
* Session I/O is injected via {@link SessionAdapter} (no Hydrogen/React-Router
|
|
31
|
+
* coupling). Pre-login flows authenticate with the project `x-api-key`;
|
|
32
|
+
* post-login reads add the customer bearer. The project-specific profile (e.g.
|
|
33
|
+
* `isAdmin`) is resolved by a consumer-supplied `resolveUser` — the SDK never
|
|
34
|
+
* hardcodes the per-project customer profile shape.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** The customer identity carried by an auth result. */
|
|
38
|
+
interface FoirCustomer {
|
|
39
|
+
id: string;
|
|
40
|
+
email: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Injected session store. Methods may be sync or async so consumers can plug
|
|
44
|
+
* in a cookie session (Hydrogen/Remix), a KV store, etc.
|
|
45
|
+
*/
|
|
46
|
+
interface SessionAdapter {
|
|
47
|
+
get(key: string): string | null | undefined | Promise<string | null | undefined>;
|
|
48
|
+
set(key: string, value: string): void | Promise<void>;
|
|
49
|
+
unset(key: string): void | Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The minimal cookie-store shape this adapter needs. It's satisfied by Next's
|
|
54
|
+
* `cookies()` (route handlers / server actions — `await cookies()` on Next 15),
|
|
55
|
+
* and by `NextResponse.cookies` in middleware. Kept structural on purpose so the
|
|
56
|
+
* SDK doesn't import (or pin a version of) `next/headers`.
|
|
57
|
+
*/
|
|
58
|
+
interface FoirCookieStore {
|
|
59
|
+
get(name: string): {
|
|
60
|
+
value: string;
|
|
61
|
+
} | undefined;
|
|
62
|
+
set(name: string, value: string, options?: Record<string, unknown>): void;
|
|
63
|
+
delete(name: string): void;
|
|
64
|
+
}
|
|
65
|
+
interface CookieAdapterOptions {
|
|
66
|
+
/** Cookie attributes for writes, merged over the secure defaults. */
|
|
67
|
+
cookieOptions?: Record<string, unknown>;
|
|
68
|
+
}
|
|
69
|
+
declare function foirCookieName(key: string): string;
|
|
70
|
+
/**
|
|
71
|
+
* A {@link SessionAdapter} backed by an HttpOnly cookie store — the seam for
|
|
72
|
+
* server-side (BFF) Foir sessions in Next.js. Pass the cookie store from a route
|
|
73
|
+
* handler / server action (`await cookies()`) for reads and writes; a Server
|
|
74
|
+
* Component's `cookies()` is read-only, so use it only for reads there.
|
|
75
|
+
*/
|
|
76
|
+
declare function createFoirCookieAdapter(store: FoirCookieStore, opts?: CookieAdapterOptions): SessionAdapter;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Server-side session helpers for the Next.js BFF pattern. Each takes the Next
|
|
80
|
+
* cookie store (`await cookies()` in a route handler / server action, or the
|
|
81
|
+
* read-only `cookies()` in a Server Component for reads).
|
|
82
|
+
*
|
|
83
|
+
* Note on "verification": the customer access token is opaque to your app and
|
|
84
|
+
* validated by the Foir API on use, so `getFoirSession` returns the identity
|
|
85
|
+
* stored in the cookies rather than locally verifying a JWT. A rejected/expired
|
|
86
|
+
* bearer surfaces when you call the API (and `@foir/sdk/auth`'s `authedRequest`
|
|
87
|
+
* transparently refreshes it).
|
|
88
|
+
*/
|
|
89
|
+
/** The signed-in customer from the session cookies, or null. */
|
|
90
|
+
declare function getFoirSession(store: FoirCookieStore): Promise<FoirCustomer | null>;
|
|
91
|
+
/** The current access token from the session cookies, or null. */
|
|
92
|
+
declare function getFoirToken(store: FoirCookieStore): Promise<string | null>;
|
|
93
|
+
/** Clear the session cookies (sign out). */
|
|
94
|
+
declare function foirSignOut(store: FoirCookieStore, opts?: CookieAdapterOptions): Promise<void>;
|
|
95
|
+
interface PasswordArgs {
|
|
96
|
+
env: FoirEnv;
|
|
97
|
+
store: FoirCookieStore;
|
|
98
|
+
email: string;
|
|
99
|
+
password: string;
|
|
100
|
+
cookieOptions?: CookieAdapterOptions['cookieOptions'];
|
|
101
|
+
}
|
|
102
|
+
/** Password sign-in in a route handler; persists the session to cookies. */
|
|
103
|
+
declare function foirSignInWithPassword({ env, store, email, password, cookieOptions, }: PasswordArgs): Promise<FoirCustomer>;
|
|
104
|
+
/** Register a customer in a route handler; persists the session to cookies. */
|
|
105
|
+
declare function foirRegisterWithPassword({ env, store, email, password, cookieOptions, }: PasswordArgs): Promise<FoirCustomer>;
|
|
106
|
+
/** Complete an OTP sign-in in a route handler; persists the session to cookies. */
|
|
107
|
+
declare function foirSignInWithOtp({ env, store, email, otp, cookieOptions, }: {
|
|
108
|
+
env: FoirEnv;
|
|
109
|
+
store: FoirCookieStore;
|
|
110
|
+
email: string;
|
|
111
|
+
otp: string;
|
|
112
|
+
cookieOptions?: CookieAdapterOptions['cookieOptions'];
|
|
113
|
+
}): Promise<FoirCustomer>;
|
|
114
|
+
/**
|
|
115
|
+
* Complete a provider (OAuth/OIDC) login in your callback route handler:
|
|
116
|
+
* exchange the `code` + `state` for tokens and persist them to the session
|
|
117
|
+
* cookies. Returns the customer.
|
|
118
|
+
*/
|
|
119
|
+
declare function completeFoirProviderLogin(opts: {
|
|
120
|
+
env: FoirEnv;
|
|
121
|
+
store: FoirCookieStore;
|
|
122
|
+
providerKey: string;
|
|
123
|
+
code?: string;
|
|
124
|
+
state?: string;
|
|
125
|
+
cookieOptions?: CookieAdapterOptions['cookieOptions'];
|
|
126
|
+
}): Promise<FoirCustomer>;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The slice of `NextRequest` the middleware reads. Typed structurally so the SDK
|
|
130
|
+
* doesn't need `next` installed to build — a real `NextRequest` satisfies it, so
|
|
131
|
+
* the returned function drops straight into `export const middleware`.
|
|
132
|
+
*/
|
|
133
|
+
interface FoirMiddlewareRequest {
|
|
134
|
+
cookies: {
|
|
135
|
+
get(name: string): {
|
|
136
|
+
value: string;
|
|
137
|
+
} | undefined;
|
|
138
|
+
};
|
|
139
|
+
nextUrl: {
|
|
140
|
+
pathname: string;
|
|
141
|
+
search: string;
|
|
142
|
+
};
|
|
143
|
+
url: string;
|
|
144
|
+
}
|
|
145
|
+
interface FoirMiddlewareConfig {
|
|
146
|
+
/** Where to send unauthenticated requests (path or absolute URL). */
|
|
147
|
+
signInUrl: string;
|
|
148
|
+
/**
|
|
149
|
+
* The session cookie whose presence means "signed in". Defaults to the
|
|
150
|
+
* access-token cookie the cookie adapter writes (`foir_token`).
|
|
151
|
+
*/
|
|
152
|
+
cookieName?: string;
|
|
153
|
+
/**
|
|
154
|
+
* Return true for requests that require auth. Omit to protect every request
|
|
155
|
+
* the middleware `matcher` passes through.
|
|
156
|
+
*/
|
|
157
|
+
isProtected?(request: FoirMiddlewareRequest): boolean;
|
|
158
|
+
/** Query param carrying the original path on the sign-in URL. Default `returnTo`. */
|
|
159
|
+
returnParam?: string;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Build a Next.js middleware that redirects unauthenticated requests to your
|
|
163
|
+
* sign-in URL. Edge-safe: it checks for the presence of the session cookie only
|
|
164
|
+
* — the bearer itself is validated server-side when you call the Foir API — so
|
|
165
|
+
* pair it with a real `getFoirSession` check in protected route handlers /
|
|
166
|
+
* server components. Returns a standard `Response` (redirect) or `undefined`
|
|
167
|
+
* (continue), both of which Next.js middleware accepts.
|
|
168
|
+
*
|
|
169
|
+
* ```ts
|
|
170
|
+
* // middleware.ts
|
|
171
|
+
* import { createFoirMiddleware } from '@foir/sdk/next';
|
|
172
|
+
* export const middleware = createFoirMiddleware({ signInUrl: '/sign-in' });
|
|
173
|
+
* export const config = { matcher: ['/dashboard/:path*'] };
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
declare function createFoirMiddleware(config: FoirMiddlewareConfig): (request: FoirMiddlewareRequest) => Response | undefined;
|
|
177
|
+
|
|
178
|
+
export { type CookieAdapterOptions, type FoirCookieStore, type FoirCustomer, type FoirEnv, type FoirMiddlewareConfig, type FoirMiddlewareRequest, type SessionAdapter, completeFoirProviderLogin, createFoirCookieAdapter, createFoirMiddleware, foirCookieName, foirRegisterWithPassword, foirSignInWithOtp, foirSignInWithPassword, foirSignOut, getFoirSession, getFoirToken };
|