@doswiftly/storefront-sdk 17.0.0 → 18.0.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.
Files changed (83) hide show
  1. package/CHANGELOG.md +919 -0
  2. package/README.md +16 -4
  3. package/dist/core/auth/auth-client.d.ts +39 -3
  4. package/dist/core/auth/auth-client.d.ts.map +1 -1
  5. package/dist/core/auth/auth-client.js +51 -3
  6. package/dist/core/auth/cookie-config.d.ts +52 -3
  7. package/dist/core/auth/cookie-config.d.ts.map +1 -1
  8. package/dist/core/auth/cookie-config.js +60 -6
  9. package/dist/core/auth/handlers.d.ts +46 -0
  10. package/dist/core/auth/handlers.d.ts.map +1 -1
  11. package/dist/core/auth/handlers.js +9 -2
  12. package/dist/core/auth/session-events.d.ts +38 -0
  13. package/dist/core/auth/session-events.d.ts.map +1 -0
  14. package/dist/core/auth/session-events.js +35 -0
  15. package/dist/core/cart/cart-recovery.d.ts +23 -0
  16. package/dist/core/cart/cart-recovery.d.ts.map +1 -1
  17. package/dist/core/cart/cart-recovery.js +20 -3
  18. package/dist/core/cart/types.d.ts +2 -1
  19. package/dist/core/cart/types.d.ts.map +1 -1
  20. package/dist/core/cart/types.js +7 -1
  21. package/dist/core/client/create-client.d.ts.map +1 -1
  22. package/dist/core/client/create-client.js +7 -3
  23. package/dist/core/client/execute.d.ts +29 -3
  24. package/dist/core/client/execute.d.ts.map +1 -1
  25. package/dist/core/client/execute.js +174 -3
  26. package/dist/core/client/types.d.ts +50 -2
  27. package/dist/core/client/types.d.ts.map +1 -1
  28. package/dist/core/errors.d.ts +6 -0
  29. package/dist/core/errors.d.ts.map +1 -1
  30. package/dist/core/errors.js +6 -0
  31. package/dist/core/generated/operation-types.d.ts +838 -221
  32. package/dist/core/generated/operation-types.d.ts.map +1 -1
  33. package/dist/core/generated/operation-types.js +560 -1
  34. package/dist/core/index.d.ts +6 -3
  35. package/dist/core/index.d.ts.map +1 -1
  36. package/dist/core/index.js +12 -2
  37. package/dist/core/middleware/session-retry.d.ts +47 -0
  38. package/dist/core/middleware/session-retry.d.ts.map +1 -0
  39. package/dist/core/middleware/session-retry.js +71 -0
  40. package/dist/core/operations/auth.d.ts.map +1 -1
  41. package/dist/core/operations/auth.js +1 -0
  42. package/dist/core/operations/cart.d.ts.map +1 -1
  43. package/dist/core/operations/cart.js +15 -11
  44. package/dist/react/components/PaymentInstrumentSection.d.ts.map +1 -1
  45. package/dist/react/components/PaymentInstrumentSection.js +4 -4
  46. package/dist/react/components/PaymentInstrumentTile.d.ts +7 -7
  47. package/dist/react/components/PaymentInstrumentTile.d.ts.map +1 -1
  48. package/dist/react/components/PaymentInstrumentTile.js +4 -3
  49. package/dist/react/hooks/use-cart-manager.d.ts +104 -13
  50. package/dist/react/hooks/use-cart-manager.d.ts.map +1 -1
  51. package/dist/react/hooks/use-cart-manager.js +144 -12
  52. package/dist/react/hooks/use-login.d.ts.map +1 -1
  53. package/dist/react/hooks/use-login.js +3 -3
  54. package/dist/react/hooks/use-refresh-token.d.ts.map +1 -1
  55. package/dist/react/hooks/use-refresh-token.js +6 -4
  56. package/dist/react/hooks/use-session-expired.d.ts +16 -0
  57. package/dist/react/hooks/use-session-expired.d.ts.map +1 -0
  58. package/dist/react/hooks/use-session-expired.js +26 -0
  59. package/dist/react/hooks/use-session-refresh.d.ts +32 -0
  60. package/dist/react/hooks/use-session-refresh.d.ts.map +1 -0
  61. package/dist/react/hooks/use-session-refresh.js +147 -0
  62. package/dist/react/index.d.ts +3 -0
  63. package/dist/react/index.d.ts.map +1 -1
  64. package/dist/react/index.js +2 -0
  65. package/dist/react/providers/storefront-client-provider.d.ts +10 -1
  66. package/dist/react/providers/storefront-client-provider.d.ts.map +1 -1
  67. package/dist/react/providers/storefront-client-provider.js +38 -3
  68. package/dist/react/providers/storefront-provider.d.ts +51 -3
  69. package/dist/react/providers/storefront-provider.d.ts.map +1 -1
  70. package/dist/react/providers/storefront-provider.js +22 -5
  71. package/dist/react/server/create-storefront-auth-route.d.ts +63 -0
  72. package/dist/react/server/create-storefront-auth-route.d.ts.map +1 -0
  73. package/dist/react/server/create-storefront-auth-route.js +239 -0
  74. package/dist/react/server/get-initial-auth.d.ts +57 -0
  75. package/dist/react/server/get-initial-auth.d.ts.map +1 -0
  76. package/dist/react/server/get-initial-auth.js +55 -0
  77. package/dist/react/server/index.d.ts +3 -0
  78. package/dist/react/server/index.d.ts.map +1 -1
  79. package/dist/react/server/index.js +6 -0
  80. package/dist/react/stores/auth.store.d.ts +46 -2
  81. package/dist/react/stores/auth.store.d.ts.map +1 -1
  82. package/dist/react/stores/auth.store.js +19 -7
  83. package/package.json +4 -2
@@ -10,14 +10,18 @@
10
10
  * @example
11
11
  * ```tsx
12
12
  * // app/layout.tsx
13
- * import { StorefrontProvider } from '@doswiftly/storefront-sdk/react';
13
+ * import { cookies } from 'next/headers';
14
+ * import { StorefrontProvider, AUTH_COOKIE_NAME } from '@doswiftly/storefront-sdk';
14
15
  *
15
16
  * export default async function RootLayout({ children }) {
16
- * const shopData = await fetchShop();
17
+ * const [shopData, cookieStore] = await Promise.all([fetchShop(), cookies()]);
18
+ * const initialAccessToken = cookieStore.get(AUTH_COOKIE_NAME)?.value ?? null;
19
+ *
17
20
  * return (
18
21
  * <StorefrontProvider
19
22
  * config={{ apiUrl: '...', shopSlug: '...' }}
20
23
  * shopData={shopData}
24
+ * initialAccessToken={initialAccessToken}
21
25
  * >
22
26
  * {children}
23
27
  * </StorefrontProvider>
@@ -35,14 +39,58 @@ export interface StorefrontProviderProps extends StorefrontClientProviderProps {
35
39
  * eliminating the flash of "Sign In" while Zustand persist rehydrates from localStorage.
36
40
  *
37
41
  * Read from cookies() in a Server Component (layout.tsx) and pass here.
42
+ *
43
+ * Defaults to `!!initialAccessToken` when omitted (raw token implies authenticated).
44
+ * Pass `false` explicitly to override that default in edge cases (opt-out flow,
45
+ * recovery banner that holds a token without claiming auth state).
38
46
  */
39
47
  initialIsAuthenticated?: boolean;
48
+ /**
49
+ * Server-side token seed. See {@link CreateAuthStoreOptions.initialAccessToken}
50
+ * for full semantics and security guarantees (token kept in memory, never
51
+ * persisted). Wire up from your Server Component with `cookies()` + `AUTH_COOKIE_NAME`.
52
+ *
53
+ * @example
54
+ * ```tsx
55
+ * // app/layout.tsx
56
+ * import { cookies } from 'next/headers';
57
+ * import { AUTH_COOKIE_NAME } from '@doswiftly/storefront-sdk';
58
+ *
59
+ * const cookieStore = await cookies();
60
+ * const initialAccessToken = cookieStore.get(AUTH_COOKIE_NAME)?.value ?? null;
61
+ *
62
+ * <StorefrontProvider initialAccessToken={initialAccessToken} ...>
63
+ * ```
64
+ */
65
+ initialAccessToken?: string | null;
40
66
  /**
41
67
  * Server-side language hint — pass the URL locale from next-intl params.
42
68
  * Eliminates flash of wrong language on first render by initializing the
43
69
  * language store with the correct value from the server.
44
70
  */
45
71
  initialLanguage?: string;
72
+ /**
73
+ * Proactive session refresh. Defaults to ON in the browser and OFF on the
74
+ * server — you do NOT need to pass it. When active, the SDK renews the access
75
+ * token shortly before it expires so an active buyer is never logged out
76
+ * mid-session, and fires a global `session-expired` signal (subscribe via
77
+ * `useSessionExpired`) when the session can no longer be kept alive. Pass
78
+ * `autoRefresh={false}` to opt out and drive refreshing yourself.
79
+ */
80
+ autoRefresh?: boolean;
81
+ /**
82
+ * Base path of the SDK-BFF auth route handlers (default `/api/auth`). The
83
+ * proactive scheduler and the reactive-401 renewal post to `${authBasePath}/refresh`
84
+ * (same-origin), which rotates the refresh cookie server-side. Override only for
85
+ * non-standard mounts — it must match where `createStorefrontAuthRoute` is mounted.
86
+ */
87
+ authBasePath?: string;
88
+ /**
89
+ * Server-side session-expiry seed (ISO 8601) — typically the readable
90
+ * `session-expiry` cookie value read in a Server Component. Lets the refresh
91
+ * scheduler arm on the first render (cold start) without a whoami round-trip.
92
+ */
93
+ initialExpiresAt?: string | null;
46
94
  }
47
- export declare function StorefrontProvider({ children, config, middleware, shopData, initialIsAuthenticated, initialLanguage, }: StorefrontProviderProps): import("react/jsx-runtime").JSX.Element;
95
+ export declare function StorefrontProvider({ children, config, middleware, shopData, initialIsAuthenticated, initialAccessToken, initialExpiresAt, initialLanguage, autoRefresh, authBasePath, }: StorefrontProviderProps): import("react/jsx-runtime").JSX.Element;
48
96
  //# sourceMappingURL=storefront-provider.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"storefront-provider.d.ts","sourceRoot":"","sources":["../../../src/react/providers/storefront-provider.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AASH,OAAO,EAA4B,KAAK,6BAA6B,EAAE,MAAM,8BAA8B,CAAC;AAC5G,OAAO,EAAoB,KAAK,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAMnF,MAAM,WAAW,uBAAwB,SAAQ,6BAA6B;IAC5E,QAAQ,EAAE,qBAAqB,CAAC,UAAU,CAAC,CAAC;IAC5C;;;;;;OAMG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,wBAAgB,kBAAkB,CAAC,EACjC,QAAQ,EACR,MAAM,EACN,UAAU,EACV,QAAQ,EACR,sBAAsB,EACtB,eAAe,GAChB,EAAE,uBAAuB,2CA+BzB"}
1
+ {"version":3,"file":"storefront-provider.d.ts","sourceRoot":"","sources":["../../../src/react/providers/storefront-provider.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AASH,OAAO,EAA4B,KAAK,6BAA6B,EAAE,MAAM,8BAA8B,CAAC;AAC5G,OAAO,EAAoB,KAAK,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AASnF,MAAM,WAAW,uBAAwB,SAAQ,6BAA6B;IAC5E,QAAQ,EAAE,qBAAqB,CAAC,UAAU,CAAC,CAAC;IAC5C;;;;;;;;;;OAUG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;;;;;;;OAgBG;IACH,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED,wBAAgB,kBAAkB,CAAC,EACjC,QAAQ,EACR,MAAM,EACN,UAAU,EACV,QAAQ,EACR,sBAAsB,EACtB,kBAAkB,EAClB,gBAAgB,EAChB,eAAe,EACf,WAAW,EACX,YAAY,GACb,EAAE,uBAAuB,2CAwCzB"}
@@ -10,14 +10,18 @@
10
10
  * @example
11
11
  * ```tsx
12
12
  * // app/layout.tsx
13
- * import { StorefrontProvider } from '@doswiftly/storefront-sdk/react';
13
+ * import { cookies } from 'next/headers';
14
+ * import { StorefrontProvider, AUTH_COOKIE_NAME } from '@doswiftly/storefront-sdk';
14
15
  *
15
16
  * export default async function RootLayout({ children }) {
16
- * const shopData = await fetchShop();
17
+ * const [shopData, cookieStore] = await Promise.all([fetchShop(), cookies()]);
18
+ * const initialAccessToken = cookieStore.get(AUTH_COOKIE_NAME)?.value ?? null;
19
+ *
17
20
  * return (
18
21
  * <StorefrontProvider
19
22
  * config={{ apiUrl: '...', shopSlug: '...' }}
20
23
  * shopData={shopData}
24
+ * initialAccessToken={initialAccessToken}
21
25
  * >
22
26
  * {children}
23
27
  * </StorefrontProvider>
@@ -38,10 +42,23 @@ import { LanguageProvider } from './language-provider';
38
42
  import { createBotProtectionManager } from '../../core/bot-protection/create-manager';
39
43
  import { BotProtectionContext } from '../bot-protection/bot-protection-context';
40
44
  import { BotProtectionWidget } from '../bot-protection/bot-protection-widget';
41
- export function StorefrontProvider({ children, config, middleware, shopData, initialIsAuthenticated, initialLanguage, }) {
42
- const authStoreRef = useRef(createAuthStore(initialIsAuthenticated));
45
+ import { createSessionExpiredEmitter } from '../../core/auth/session-events';
46
+ import { SessionExpiredContext } from '../hooks/use-session-expired';
47
+ import { useSessionRefresh } from '../hooks/use-session-refresh';
48
+ export function StorefrontProvider({ children, config, middleware, shopData, initialIsAuthenticated, initialAccessToken, initialExpiresAt, initialLanguage, autoRefresh, authBasePath, }) {
49
+ const authStoreRef = useRef(createAuthStore({ initialIsAuthenticated, initialAccessToken, initialExpiresAt }));
43
50
  const currencyStoreRef = useRef(createCurrencyStore());
44
51
  const languageStoreRef = useRef(createLanguageStore(initialLanguage));
52
+ const sessionExpiredRef = useRef(createSessionExpiredEmitter());
45
53
  const botProtectionRef = useRef(shopData.botProtection ? createBotProtectionManager(shopData.botProtection) : null);
46
- return (_jsx(AuthStoreContext.Provider, { value: authStoreRef.current, children: _jsx(CurrencyStoreContext.Provider, { value: currencyStoreRef.current, children: _jsx(LanguageStoreContext.Provider, { value: languageStoreRef.current, children: _jsx(StorefrontClientProvider, { config: config, middleware: middleware, botProtection: botProtectionRef.current, botProtectionOperations: shopData.botProtection?.protectedOperations, children: _jsx(BotProtectionContext.Provider, { value: { manager: botProtectionRef.current }, children: _jsx(CurrencyProvider, { shopData: shopData, children: _jsxs(LanguageProvider, { shopData: shopData, children: [_jsx(BotProtectionWidget, { manager: botProtectionRef.current }), children] }) }) }) }) }) }) }));
54
+ return (_jsx(AuthStoreContext.Provider, { value: authStoreRef.current, children: _jsx(CurrencyStoreContext.Provider, { value: currencyStoreRef.current, children: _jsx(LanguageStoreContext.Provider, { value: languageStoreRef.current, children: _jsx(StorefrontClientProvider, { config: config, middleware: middleware, botProtection: botProtectionRef.current, botProtectionOperations: shopData.botProtection?.protectedOperations, sessionExpiredEmitter: sessionExpiredRef.current, authBasePath: authBasePath, children: _jsx(BotProtectionContext.Provider, { value: { manager: botProtectionRef.current }, children: _jsx(CurrencyProvider, { shopData: shopData, children: _jsxs(LanguageProvider, { shopData: shopData, children: [_jsx(BotProtectionWidget, { manager: botProtectionRef.current }), _jsx(SessionRefreshRunner, { autoRefresh: autoRefresh, emitter: sessionExpiredRef.current }), _jsx(SessionExpiredContext.Provider, { value: sessionExpiredRef.current, children: children })] }) }) }) }) }) }) }));
55
+ }
56
+ /**
57
+ * Internal — drives the proactive refresh scheduler from inside the provider
58
+ * tree (so it can read the client + auth store via context). Renders nothing.
59
+ * The scheduler itself is a browser-only no-op on the server.
60
+ */
61
+ function SessionRefreshRunner({ autoRefresh, emitter, }) {
62
+ useSessionRefresh({ enabled: autoRefresh, sessionExpiredEmitter: emitter });
63
+ return null;
47
64
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * createStorefrontAuthRoute — SDK-BFF auth route handlers.
3
+ *
4
+ * Generates the four same-origin auth route handlers that live on the storefront
5
+ * domain (`/api/auth/[action]`): `login`, `refresh`, `logout` (POST) and `whoami`
6
+ * (GET). Each handler calls the backend `/storefront/auth/*` namespace
7
+ * server-to-server with `X-Shop-Slug` as a routing hint, owns the first-party
8
+ * httpOnly cookies on the storefront domain, and returns ONLY
9
+ * `{ accessToken, expiresAt[, customer] }` to JavaScript — the refresh token is
10
+ * read server-side and never reaches the browser's JS.
11
+ *
12
+ * This is the universal auth transport: the route handlers live on the
13
+ * storefront's own domain, so first-party cookies work identically on a platform
14
+ * subdomain, a custom domain, and off-platform hosting (e.g. Vercel) —
15
+ * independent of any reverse proxy in front. The backend never emits a
16
+ * `Set-Cookie` to a browser (tokens travel in the server-to-server response
17
+ * body); the BFF sets the cookies here.
18
+ *
19
+ * 0 runtime dependencies — pure Web API (`Request`/`Response`/`fetch`). No React,
20
+ * no `next/*`. Mount it in a Next.js (or any framework) route:
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * // app/api/auth/[action]/route.ts
25
+ * import { createStorefrontAuthRoute, trustedForwardedHostValidator } from '@doswiftly/storefront-sdk/react/server';
26
+ *
27
+ * export const { GET, POST } = createStorefrontAuthRoute({
28
+ * apiUrl: process.env.NEXT_PUBLIC_API_URL!,
29
+ * shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
30
+ * isTrustedOrigin: trustedForwardedHostValidator, // when behind a reverse proxy that rewrites Host
31
+ * });
32
+ * ```
33
+ */
34
+ import { type OriginValidator } from '../../core/auth/handlers';
35
+ export interface StorefrontAuthRouteOptions {
36
+ /** Backend base URL (e.g. `https://api.doswiftly.pl`). The server-to-server namespace is `${apiUrl}/storefront/auth/*`. */
37
+ apiUrl: string;
38
+ /** Shop slug forwarded as the `X-Shop-Slug` routing hint (selects the tenant; never binds the rotation family). */
39
+ shopSlug: string;
40
+ /**
41
+ * CSRF defense-in-depth predicate. When the storefront runs behind a reverse
42
+ * proxy that rewrites/strips `Host` (Cloudflare Workers, Vercel, NGINX),
43
+ * pass `trustedForwardedHostValidator`. This is NOT the primary control — the
44
+ * backend's protocol controls (rotation + reuse-detection + rate-limit +
45
+ * possession-proof) are. See {@link OriginValidator}.
46
+ */
47
+ isTrustedOrigin?: OriginValidator | null;
48
+ /**
49
+ * Base path the route is mounted at (default `/api/auth`). The refresh cookie's
50
+ * `Path` is set to it so the cookie reaches both the refresh and logout routes
51
+ * (siblings under this base) but not GraphQL data traffic, which lives on a
52
+ * different path.
53
+ */
54
+ authBasePath?: string;
55
+ /** Custom fetch (tests, non-standard runtimes). Defaults to `globalThis.fetch`. */
56
+ fetch?: typeof globalThis.fetch;
57
+ }
58
+ export interface StorefrontAuthRouteHandlers {
59
+ GET: (request: Request) => Promise<Response>;
60
+ POST: (request: Request) => Promise<Response>;
61
+ }
62
+ export declare function createStorefrontAuthRoute(options: StorefrontAuthRouteOptions): StorefrontAuthRouteHandlers;
63
+ //# sourceMappingURL=create-storefront-auth-route.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-storefront-auth-route.d.ts","sourceRoot":"","sources":["../../../src/react/server/create-storefront-auth-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,EAGL,KAAK,eAAe,EACrB,MAAM,0BAA0B,CAAC;AAOlC,MAAM,WAAW,0BAA0B;IACzC,2HAA2H;IAC3H,MAAM,EAAE,MAAM,CAAC;IACf,mHAAmH;IACnH,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;IACzC;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mFAAmF;IACnF,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CACjC;AAED,MAAM,WAAW,2BAA2B;IAC1C,GAAG,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC7C,IAAI,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC/C;AAgDD,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,0BAA0B,GAClC,2BAA2B,CA+L7B"}
@@ -0,0 +1,239 @@
1
+ /**
2
+ * createStorefrontAuthRoute — SDK-BFF auth route handlers.
3
+ *
4
+ * Generates the four same-origin auth route handlers that live on the storefront
5
+ * domain (`/api/auth/[action]`): `login`, `refresh`, `logout` (POST) and `whoami`
6
+ * (GET). Each handler calls the backend `/storefront/auth/*` namespace
7
+ * server-to-server with `X-Shop-Slug` as a routing hint, owns the first-party
8
+ * httpOnly cookies on the storefront domain, and returns ONLY
9
+ * `{ accessToken, expiresAt[, customer] }` to JavaScript — the refresh token is
10
+ * read server-side and never reaches the browser's JS.
11
+ *
12
+ * This is the universal auth transport: the route handlers live on the
13
+ * storefront's own domain, so first-party cookies work identically on a platform
14
+ * subdomain, a custom domain, and off-platform hosting (e.g. Vercel) —
15
+ * independent of any reverse proxy in front. The backend never emits a
16
+ * `Set-Cookie` to a browser (tokens travel in the server-to-server response
17
+ * body); the BFF sets the cookies here.
18
+ *
19
+ * 0 runtime dependencies — pure Web API (`Request`/`Response`/`fetch`). No React,
20
+ * no `next/*`. Mount it in a Next.js (or any framework) route:
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * // app/api/auth/[action]/route.ts
25
+ * import { createStorefrontAuthRoute, trustedForwardedHostValidator } from '@doswiftly/storefront-sdk/react/server';
26
+ *
27
+ * export const { GET, POST } = createStorefrontAuthRoute({
28
+ * apiUrl: process.env.NEXT_PUBLIC_API_URL!,
29
+ * shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
30
+ * isTrustedOrigin: trustedForwardedHostValidator, // when behind a reverse proxy that rewrites Host
31
+ * });
32
+ * ```
33
+ */
34
+ import { validateOrigin, serializeCookie, } from '../../core/auth/handlers';
35
+ import { AUTH_COOKIE_DEFAULTS, REFRESH_COOKIE_DEFAULTS, SESSION_EXPIRY_COOKIE_DEFAULTS, } from '../../core/auth/cookie-config';
36
+ const DEFAULT_AUTH_BASE_PATH = '/api/auth';
37
+ function jsonResponse(body, status = 200, extraHeaders) {
38
+ const headers = extraHeaders ?? new Headers();
39
+ headers.set('content-type', 'application/json');
40
+ return new Response(JSON.stringify(body), { status, headers });
41
+ }
42
+ /** Last non-empty path segment — the `[action]` of `/api/auth/{action}`. */
43
+ function resolveAction(request) {
44
+ const segments = new URL(request.url).pathname.split('/').filter(Boolean);
45
+ return segments[segments.length - 1] ?? '';
46
+ }
47
+ /** Read a single cookie value server-side (decoded). Cookie names are fixed constants. */
48
+ function readCookie(request, name) {
49
+ const header = request.headers.get('cookie') ?? '';
50
+ const match = header.match(new RegExp(`(?:^|; )${name}=([^;]+)`));
51
+ return match ? decodeURIComponent(match[1]) : null;
52
+ }
53
+ /** Access-token cookie lifetime fallback (seconds) when `expiresAt` can't be parsed (rare backend error). */
54
+ const ACCESS_FALLBACK_MAX_AGE_SECONDS = 60 * 30; // 30 minutes — a short, access-shaped fallback
55
+ /** Cookie `Max-Age` (seconds) until an absolute ISO expiry; clamped at 0; falls back when unparseable. */
56
+ function maxAgeFromExpiry(expiresAt) {
57
+ const ms = Date.parse(expiresAt);
58
+ if (Number.isNaN(ms))
59
+ return ACCESS_FALLBACK_MAX_AGE_SECONDS;
60
+ return Math.max(0, Math.floor((ms - Date.now()) / 1000));
61
+ }
62
+ export function createStorefrontAuthRoute(options) {
63
+ const apiBase = options.apiUrl.replace(/\/$/, '');
64
+ const shopSlug = options.shopSlug;
65
+ const isTrustedOrigin = options.isTrustedOrigin ?? null;
66
+ const fetchImpl = options.fetch ?? globalThis.fetch;
67
+ const authBasePath = (options.authBasePath ?? DEFAULT_AUTH_BASE_PATH).replace(/\/$/, '');
68
+ const backendUrl = (action) => `${apiBase}/storefront/auth/${action}`;
69
+ const backendHeaders = (extra) => ({
70
+ 'Content-Type': 'application/json',
71
+ Accept: 'application/json',
72
+ 'X-Shop-Slug': shopSlug,
73
+ ...extra,
74
+ });
75
+ // ---- first-party cookie specs (host-only) ----
76
+ // The refresh cookie is scoped to the route's auth base (NOT `${authBasePath}/refresh`)
77
+ // so the sibling logout route also receives it and can revoke the family — a
78
+ // narrower path is not sent to a sibling endpoint.
79
+ const ACCESS_COOKIE = { ...AUTH_COOKIE_DEFAULTS };
80
+ const REFRESH_COOKIE = { ...REFRESH_COOKIE_DEFAULTS, path: authBasePath };
81
+ const EXPIRY_COOKIE = { ...SESSION_EXPIRY_COOKIE_DEFAULTS };
82
+ const buildCookie = (spec, value, maxAge) => serializeCookie(spec.name, value, {
83
+ maxAge,
84
+ path: spec.path,
85
+ sameSite: spec.sameSite,
86
+ secure: spec.secure,
87
+ httpOnly: spec.httpOnly,
88
+ });
89
+ // Expire all three cookies, each at the SAME path it was set with (a mismatched
90
+ // path would not delete the cookie).
91
+ const clearSetCookies = () => [ACCESS_COOKIE, REFRESH_COOKIE, EXPIRY_COOKIE].map((spec) => buildCookie(spec, '', 0));
92
+ /**
93
+ * Set the first-party cookies from a token pair and return the body the browser
94
+ * receives — access token + expiry (+ customer), NEVER the refresh token. The
95
+ * refresh cookie is only (re)written when the backend issued a new token: a
96
+ * `null` refresh means a grace-window hit or a guest, so the current cookie is
97
+ * kept untouched.
98
+ */
99
+ function tokensResponse(data) {
100
+ const headers = new Headers();
101
+ headers.append('Set-Cookie', buildCookie(ACCESS_COOKIE, data.access, maxAgeFromExpiry(data.expiresAt)));
102
+ // A null refresh means a grace-window hit or a guest — keep the current refresh cookie.
103
+ if (data.refresh)
104
+ headers.append('Set-Cookie', buildCookie(REFRESH_COOKIE, data.refresh, REFRESH_COOKIE.maxAge));
105
+ // The expiry hint outlives the access token (its own 30-day lifetime) so a
106
+ // returning visitor's cold start can recover even after the access token lapsed.
107
+ headers.append('Set-Cookie', buildCookie(EXPIRY_COOKIE, data.expiresAt, EXPIRY_COOKIE.maxAge));
108
+ const jsBody = { accessToken: data.access, expiresAt: data.expiresAt };
109
+ if (data.customer != null)
110
+ jsBody.customer = data.customer;
111
+ return jsonResponse(jsBody, 200, headers);
112
+ }
113
+ /**
114
+ * Forward a backend error to the browser without setting cookies. The backend
115
+ * body is already localized and free of platform internals, so it is passed
116
+ * through verbatim — the SDK reacts to the status (401 → SESSION_EXPIRED).
117
+ */
118
+ async function passthroughError(backendRes) {
119
+ const text = await backendRes.text();
120
+ return new Response(text, {
121
+ status: backendRes.status,
122
+ headers: { 'content-type': backendRes.headers.get('content-type') ?? 'application/json' },
123
+ });
124
+ }
125
+ // ---- action handlers ----
126
+ async function handleLogin(request) {
127
+ const originError = await validateOrigin(request, { isTrustedOrigin });
128
+ if (originError)
129
+ return originError;
130
+ let body;
131
+ try {
132
+ body = await request.json();
133
+ }
134
+ catch {
135
+ return jsonResponse({ error: 'Invalid JSON body' }, 400);
136
+ }
137
+ const backendRes = await fetchImpl(backendUrl('login'), {
138
+ method: 'POST',
139
+ headers: backendHeaders(),
140
+ body: JSON.stringify({ email: body.email, password: body.password }),
141
+ });
142
+ if (!backendRes.ok)
143
+ return passthroughError(backendRes);
144
+ const data = (await backendRes.json());
145
+ return tokensResponse(data);
146
+ }
147
+ async function handleRefresh(request) {
148
+ const originError = await validateOrigin(request, { isTrustedOrigin });
149
+ if (originError)
150
+ return originError;
151
+ const refreshToken = readCookie(request, REFRESH_COOKIE_DEFAULTS.name);
152
+ if (!refreshToken) {
153
+ // No first-party refresh cookie → nothing to rotate. Fail-closed (401) so the
154
+ // SDK surfaces SESSION_EXPIRED without a wasted server-to-server round-trip.
155
+ return jsonResponse({ error: 'No refresh session' }, 401);
156
+ }
157
+ const backendRes = await fetchImpl(backendUrl('refresh'), {
158
+ method: 'POST',
159
+ headers: backendHeaders(),
160
+ body: JSON.stringify({ refresh: refreshToken }),
161
+ });
162
+ if (!backendRes.ok)
163
+ return passthroughError(backendRes);
164
+ const data = (await backendRes.json());
165
+ return tokensResponse(data);
166
+ }
167
+ async function handleLogout(request) {
168
+ const originError = await validateOrigin(request, { isTrustedOrigin });
169
+ if (originError)
170
+ return originError;
171
+ const refreshToken = readCookie(request, REFRESH_COOKIE_DEFAULTS.name);
172
+ if (refreshToken) {
173
+ // Best-effort server-to-server revoke (possession-proof + family revoke happen backend-side).
174
+ // A failure must not block clearing the local cookies.
175
+ try {
176
+ await fetchImpl(backendUrl('logout'), {
177
+ method: 'POST',
178
+ headers: backendHeaders(),
179
+ body: JSON.stringify({ refresh: refreshToken }),
180
+ });
181
+ }
182
+ catch {
183
+ // swallow — cookies are cleared regardless
184
+ }
185
+ }
186
+ const headers = new Headers();
187
+ for (const cookie of clearSetCookies())
188
+ headers.append('Set-Cookie', cookie);
189
+ return jsonResponse({ success: true }, 200, headers);
190
+ }
191
+ async function handleWhoami(request) {
192
+ // GET is CSRF-safe; browsers omit Origin for same-origin GET — allow missing.
193
+ const originError = await validateOrigin(request, { allowMissingOrigin: true, isTrustedOrigin });
194
+ if (originError)
195
+ return originError;
196
+ const accessToken = readCookie(request, AUTH_COOKIE_DEFAULTS.name);
197
+ const anon = { isAuthenticated: false, customer: null, expiresAt: null };
198
+ if (!accessToken)
199
+ return jsonResponse(anon);
200
+ let backendRes;
201
+ try {
202
+ backendRes = await fetchImpl(backendUrl('whoami'), {
203
+ method: 'GET',
204
+ headers: backendHeaders({ Authorization: `Bearer ${accessToken}` }),
205
+ });
206
+ }
207
+ catch {
208
+ return jsonResponse(anon);
209
+ }
210
+ if (!backendRes.ok)
211
+ return jsonResponse(anon);
212
+ const data = (await backendRes.json());
213
+ return jsonResponse({
214
+ isAuthenticated: !!data.isAuthenticated,
215
+ customer: data.customer ?? null,
216
+ expiresAt: data.expiresAt ?? null,
217
+ });
218
+ }
219
+ const POST = async (request) => {
220
+ const action = resolveAction(request);
221
+ switch (action) {
222
+ case 'login':
223
+ return handleLogin(request);
224
+ case 'refresh':
225
+ return handleRefresh(request);
226
+ case 'logout':
227
+ return handleLogout(request);
228
+ default:
229
+ return jsonResponse({ error: `Unknown auth action: ${action}` }, 404);
230
+ }
231
+ };
232
+ const GET = async (request) => {
233
+ const action = resolveAction(request);
234
+ if (action === 'whoami')
235
+ return handleWhoami(request);
236
+ return jsonResponse({ error: `Unknown auth action: ${action}` }, 404);
237
+ };
238
+ return { GET, POST };
239
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * getInitialAuth — server-only initial auth seed from the first-party cookies.
3
+ *
4
+ * Reads the cookies the SDK-BFF route handlers (`createStorefrontAuthRoute`) set
5
+ * on the storefront domain and maps them to the values `StorefrontProvider`
6
+ * expects, so the very first render already knows the buyer is signed in
7
+ * (no flash of signed-out UI) and the proactive scheduler can arm immediately on
8
+ * a cold start — both without a whoami round-trip.
9
+ *
10
+ * The access token is seeded into the in-memory auth store only — it is NEVER
11
+ * persisted to storage (the store's `partialize` excludes it, XSS hardening). The
12
+ * customer profile is hydrated client-side afterwards (e.g. via the same-origin
13
+ * whoami route), so it is intentionally not part of this cookie-only cold-start seed.
14
+ *
15
+ * Server-only — dynamically imports `next/headers`, so importing this module's
16
+ * sibling (`getStorefrontClient`) in a non-Next runtime stays unaffected.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * // app/layout.tsx (Server Component)
21
+ * import { getInitialAuth } from '@doswiftly/storefront-sdk/react/server';
22
+ * import { StorefrontProvider } from '@doswiftly/storefront-sdk/react';
23
+ *
24
+ * export default async function RootLayout({ children }) {
25
+ * const { isAuthenticated, accessToken, expiresAt } = await getInitialAuth();
26
+ * return (
27
+ * <StorefrontProvider
28
+ * config={{ apiUrl: '...', shopSlug: '...' }}
29
+ * shopData={shopData}
30
+ * initialIsAuthenticated={isAuthenticated}
31
+ * initialAccessToken={accessToken}
32
+ * initialExpiresAt={expiresAt}
33
+ * >
34
+ * {children}
35
+ * </StorefrontProvider>
36
+ * );
37
+ * }
38
+ * ```
39
+ */
40
+ /**
41
+ * Cold-start auth seed read from the first-party cookies. Maps 1:1 to the
42
+ * `StorefrontProvider` props `initialIsAuthenticated` / `initialAccessToken` /
43
+ * `initialExpiresAt` (and to `CreateAuthStoreOptions`).
44
+ */
45
+ export interface InitialAuth {
46
+ /**
47
+ * `true` when an access token OR the longer-lived session-expiry hint is present
48
+ * — drives the no-flash signed-in render even after the short access token lapsed.
49
+ */
50
+ isAuthenticated: boolean;
51
+ /** Raw access token from the httpOnly cookie — seeded into memory only, never persisted to storage. */
52
+ accessToken: string | null;
53
+ /** Absolute session expiry (ISO 8601) from the readable cookie, or null when unknown. */
54
+ expiresAt: string | null;
55
+ }
56
+ export declare function getInitialAuth(): Promise<InitialAuth>;
57
+ //# sourceMappingURL=get-initial-auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"get-initial-auth.d.ts","sourceRoot":"","sources":["../../../src/react/server/get-initial-auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAIH;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B;;;OAGG;IACH,eAAe,EAAE,OAAO,CAAC;IACzB,uGAAuG;IACvG,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,yFAAyF;IACzF,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,wBAAsB,cAAc,IAAI,OAAO,CAAC,WAAW,CAAC,CAc3D"}
@@ -0,0 +1,55 @@
1
+ /**
2
+ * getInitialAuth — server-only initial auth seed from the first-party cookies.
3
+ *
4
+ * Reads the cookies the SDK-BFF route handlers (`createStorefrontAuthRoute`) set
5
+ * on the storefront domain and maps them to the values `StorefrontProvider`
6
+ * expects, so the very first render already knows the buyer is signed in
7
+ * (no flash of signed-out UI) and the proactive scheduler can arm immediately on
8
+ * a cold start — both without a whoami round-trip.
9
+ *
10
+ * The access token is seeded into the in-memory auth store only — it is NEVER
11
+ * persisted to storage (the store's `partialize` excludes it, XSS hardening). The
12
+ * customer profile is hydrated client-side afterwards (e.g. via the same-origin
13
+ * whoami route), so it is intentionally not part of this cookie-only cold-start seed.
14
+ *
15
+ * Server-only — dynamically imports `next/headers`, so importing this module's
16
+ * sibling (`getStorefrontClient`) in a non-Next runtime stays unaffected.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * // app/layout.tsx (Server Component)
21
+ * import { getInitialAuth } from '@doswiftly/storefront-sdk/react/server';
22
+ * import { StorefrontProvider } from '@doswiftly/storefront-sdk/react';
23
+ *
24
+ * export default async function RootLayout({ children }) {
25
+ * const { isAuthenticated, accessToken, expiresAt } = await getInitialAuth();
26
+ * return (
27
+ * <StorefrontProvider
28
+ * config={{ apiUrl: '...', shopSlug: '...' }}
29
+ * shopData={shopData}
30
+ * initialIsAuthenticated={isAuthenticated}
31
+ * initialAccessToken={accessToken}
32
+ * initialExpiresAt={expiresAt}
33
+ * >
34
+ * {children}
35
+ * </StorefrontProvider>
36
+ * );
37
+ * }
38
+ * ```
39
+ */
40
+ import { AUTH_COOKIE_NAME, SESSION_EXPIRY_COOKIE_NAME } from '../../core/auth/cookie-config';
41
+ export async function getInitialAuth() {
42
+ const { cookies } = await import('next/headers');
43
+ const store = await cookies();
44
+ const accessToken = store.get(AUTH_COOKIE_NAME)?.value ?? null;
45
+ const expiresAt = store.get(SESSION_EXPIRY_COOKIE_NAME)?.value ?? null;
46
+ return {
47
+ // Authenticated when EITHER cookie is present: a returning visitor whose short
48
+ // access token already lapsed still carries the longer-lived session-expiry
49
+ // hint, so the app renders signed-in and the scheduler recovers the session (a
50
+ // past expiry triggers an immediate refresh) instead of flashing signed-out.
51
+ isAuthenticated: !!accessToken || !!expiresAt,
52
+ accessToken,
53
+ expiresAt,
54
+ };
55
+ }
@@ -1,2 +1,5 @@
1
1
  export { getStorefrontClient, type ServerClientOptions } from './get-storefront-client';
2
+ export { createStorefrontAuthRoute, type StorefrontAuthRouteOptions, type StorefrontAuthRouteHandlers, } from './create-storefront-auth-route';
3
+ export { getInitialAuth, type InitialAuth } from './get-initial-auth';
4
+ export { trustedForwardedHostValidator, originAllowlistValidator, type OriginValidator, type OriginValidatorContext, } from '../../core/auth/handlers';
2
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/server/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,KAAK,mBAAmB,EAAE,MAAM,yBAAyB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/server/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,KAAK,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AAGxF,OAAO,EACL,yBAAyB,EACzB,KAAK,0BAA0B,EAC/B,KAAK,2BAA2B,GACjC,MAAM,gCAAgC,CAAC;AAGxC,OAAO,EAAE,cAAc,EAAE,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAGtE,OAAO,EACL,6BAA6B,EAC7B,wBAAwB,EACxB,KAAK,eAAe,EACpB,KAAK,sBAAsB,GAC5B,MAAM,0BAA0B,CAAC"}
@@ -1 +1,7 @@
1
1
  export { getStorefrontClient } from './get-storefront-client';
2
+ // SDK-BFF auth route handlers — mount at /api/auth/[action]/route.ts.
3
+ export { createStorefrontAuthRoute, } from './create-storefront-auth-route';
4
+ // Server-only cold-start auth seed from the first-party cookies.
5
+ export { getInitialAuth } from './get-initial-auth';
6
+ // Origin validators — wire CSRF defense-in-depth on the route handlers from a single import.
7
+ export { trustedForwardedHostValidator, originAllowlistValidator, } from '../../core/auth/handlers';
@@ -7,6 +7,12 @@
7
7
  *
8
8
  * httpOnly cookie is managed separately (for SSR/middleware).
9
9
  */
10
+ /**
11
+ * localStorage key for the persisted auth slice (customer + isAuthenticated only —
12
+ * accessToken is excluded by `partialize`, XSS hardening). Exported so tests and
13
+ * future migrations can reference a single source of truth.
14
+ */
15
+ export declare const AUTH_STORAGE_KEY = "auth-storage";
10
16
  export interface CustomerInfo {
11
17
  id: string;
12
18
  email: string;
@@ -17,14 +23,52 @@ export interface CustomerInfo {
17
23
  export interface AuthStore {
18
24
  customer: CustomerInfo | null;
19
25
  accessToken: string | null;
26
+ /**
27
+ * Absolute session expiry (ISO 8601) from the latest authoritative response
28
+ * (login / refresh / whoami), or null when unknown. Runtime-only — NEVER
29
+ * persisted to localStorage (the readable `session-expiry` cookie is the
30
+ * cold-start hint; this value is re-synced from the backend on each response).
31
+ */
32
+ expiresAt: string | null;
20
33
  isAuthenticated: boolean;
21
34
  isLoading: boolean;
22
- setAuth: (customer: CustomerInfo, accessToken: string) => void;
35
+ setAuth: (customer: CustomerInfo | null, accessToken: string, expiresAt?: string | null) => void;
23
36
  clearAuth: () => void;
37
+ /**
38
+ * Sync just the session expiry from an authoritative backend response
39
+ * (whoami / refresh) without touching the token or customer — used when the
40
+ * locally-known expiry diverges from the backend's view.
41
+ */
42
+ setExpiresAt: (expiresAt: string | null) => void;
24
43
  updateCustomer: (updates: Partial<CustomerInfo>) => void;
25
44
  setLoading: (isLoading: boolean) => void;
26
45
  }
27
- export declare const createAuthStore: (initialIsAuthenticated?: boolean) => Omit<import("zustand").StoreApi<AuthStore>, "setState" | "persist"> & {
46
+ export interface CreateAuthStoreOptions {
47
+ /**
48
+ * Server-side auth hint — set to `true` when httpOnly auth cookie exists.
49
+ * Eliminates flash of "Sign In" while Zustand persist rehydrates from localStorage.
50
+ * Defaults to `!!initialAccessToken` when omitted (raw token implies authenticated).
51
+ */
52
+ initialIsAuthenticated?: boolean;
53
+ /**
54
+ * Server-side token seed — raw JWT from httpOnly cookie value, env var (dev seed),
55
+ * SSO redirect parameter, or magic link callback. Injected into `accessToken` so
56
+ * `authMiddleware` adds `Authorization: Bearer ...` from the very first request
57
+ * (no `/api/auth/whoami` round-trip required).
58
+ *
59
+ * Security: token lives only in memory (RAM) — never written to localStorage
60
+ * (XSS hardening, v3 persist `partialize` excludes `accessToken`).
61
+ */
62
+ initialAccessToken?: string | null;
63
+ /**
64
+ * Server-side session-expiry seed (ISO 8601) — typically the readable
65
+ * `session-expiry` cookie value read in a Server Component. Lets the proactive
66
+ * refresh scheduler arm on the very first render (cold start) without waiting
67
+ * for a whoami round-trip. Runtime-only, never persisted.
68
+ */
69
+ initialExpiresAt?: string | null;
70
+ }
71
+ export declare const createAuthStore: (options?: CreateAuthStoreOptions) => Omit<import("zustand").StoreApi<AuthStore>, "setState" | "persist"> & {
28
72
  setState(partial: AuthStore | Partial<AuthStore> | ((state: AuthStore) => AuthStore | Partial<AuthStore>), replace?: false | undefined): unknown;
29
73
  setState(state: AuthStore | ((state: AuthStore) => AuthStore), replace: true): unknown;
30
74
  persist: {