@doswiftly/storefront-sdk 17.0.0 → 18.1.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 (86) hide show
  1. package/CHANGELOG.md +976 -0
  2. package/README.md +47 -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 +133 -13
  50. package/dist/react/hooks/use-cart-manager.d.ts.map +1 -1
  51. package/dist/react/hooks/use-cart-manager.js +220 -16
  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 +5 -1
  63. package/dist/react/index.d.ts.map +1 -1
  64. package/dist/react/index.js +3 -0
  65. package/dist/react/providers/cart-manager-provider.d.ts +50 -0
  66. package/dist/react/providers/cart-manager-provider.d.ts.map +1 -0
  67. package/dist/react/providers/cart-manager-provider.js +59 -0
  68. package/dist/react/providers/storefront-client-provider.d.ts +10 -1
  69. package/dist/react/providers/storefront-client-provider.d.ts.map +1 -1
  70. package/dist/react/providers/storefront-client-provider.js +38 -3
  71. package/dist/react/providers/storefront-provider.d.ts +51 -3
  72. package/dist/react/providers/storefront-provider.d.ts.map +1 -1
  73. package/dist/react/providers/storefront-provider.js +22 -5
  74. package/dist/react/server/create-storefront-auth-route.d.ts +63 -0
  75. package/dist/react/server/create-storefront-auth-route.d.ts.map +1 -0
  76. package/dist/react/server/create-storefront-auth-route.js +239 -0
  77. package/dist/react/server/get-initial-auth.d.ts +57 -0
  78. package/dist/react/server/get-initial-auth.d.ts.map +1 -0
  79. package/dist/react/server/get-initial-auth.js +55 -0
  80. package/dist/react/server/index.d.ts +3 -0
  81. package/dist/react/server/index.d.ts.map +1 -1
  82. package/dist/react/server/index.js +6 -0
  83. package/dist/react/stores/auth.store.d.ts +46 -2
  84. package/dist/react/stores/auth.store.d.ts.map +1 -1
  85. package/dist/react/stores/auth.store.js +19 -7
  86. package/package.json +4 -2
@@ -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: {
@@ -1 +1 @@
1
- {"version":3,"file":"auth.store.d.ts","sourceRoot":"","sources":["../../../src/react/stores/auth.store.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,SAAS;IAExB,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,eAAe,EAAE,OAAO,CAAC;IACzB,SAAS,EAAE,OAAO,CAAC;IAGnB,OAAO,EAAE,CAAC,QAAQ,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/D,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,cAAc,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,IAAI,CAAC;IACzD,UAAU,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;CAC1C;AAED,eAAO,MAAM,eAAe,GAAI,gCAA8B;;;;;sBAkEtC,YAAY,GAAG,IAAI;6BACZ,OAAO;;;;;;;;sBADd,YAAY,GAAG,IAAI;6BACZ,OAAO;;;CAUnC,CAAC"}
1
+ {"version":3,"file":"auth.store.d.ts","sourceRoot":"","sources":["../../../src/react/stores/auth.store.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,iBAAiB,CAAC;AAE/C,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,SAAS;IAExB,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B;;;;;OAKG;IACH,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,eAAe,EAAE,OAAO,CAAC;IACzB,SAAS,EAAE,OAAO,CAAC;IAGnB,OAAO,EAAE,CAAC,QAAQ,EAAE,YAAY,GAAG,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IACjG,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB;;;;OAIG;IACH,YAAY,EAAE,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IACjD,cAAc,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,IAAI,CAAC;IACzD,UAAU,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;CAC1C;AAED,MAAM,WAAW,sBAAsB;IACrC;;;;OAIG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC;;;;;;;;OAQG;IACH,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED,eAAO,MAAM,eAAe,GAAI,UAAU,sBAAsB;;;;;sBAyExC,YAAY,GAAG,IAAI;6BACZ,OAAO;;;;;;;;sBADd,YAAY,GAAG,IAAI;6BACZ,OAAO;;;CAUnC,CAAC"}
@@ -9,21 +9,31 @@
9
9
  */
10
10
  import { createStore } from 'zustand/vanilla';
11
11
  import { persist } from 'zustand/middleware';
12
- export const createAuthStore = (initialIsAuthenticated = false) => createStore()(persist((set) => ({
12
+ /**
13
+ * localStorage key for the persisted auth slice (customer + isAuthenticated only —
14
+ * accessToken is excluded by `partialize`, XSS hardening). Exported so tests and
15
+ * future migrations can reference a single source of truth.
16
+ */
17
+ export const AUTH_STORAGE_KEY = 'auth-storage';
18
+ export const createAuthStore = (options) => createStore()(persist((set) => ({
13
19
  customer: null,
14
- accessToken: null,
15
- isAuthenticated: initialIsAuthenticated,
20
+ accessToken: options?.initialAccessToken ?? null,
21
+ expiresAt: options?.initialExpiresAt ?? null,
22
+ isAuthenticated: options?.initialIsAuthenticated ?? !!options?.initialAccessToken,
16
23
  isLoading: false,
17
- setAuth: (customer, accessToken) => set({
24
+ setAuth: (customer, accessToken, expiresAt) => set({
18
25
  customer,
19
26
  accessToken,
27
+ expiresAt: expiresAt ?? null,
20
28
  isAuthenticated: true,
21
29
  }),
22
30
  clearAuth: () => set({
23
31
  customer: null,
24
32
  accessToken: null,
33
+ expiresAt: null,
25
34
  isAuthenticated: false,
26
35
  }),
36
+ setExpiresAt: (expiresAt) => set({ expiresAt }),
27
37
  updateCustomer: (updates) => set((state) => ({
28
38
  customer: state.customer
29
39
  ? { ...state.customer, ...updates }
@@ -31,7 +41,7 @@ export const createAuthStore = (initialIsAuthenticated = false) => createStore()
31
41
  })),
32
42
  setLoading: (isLoading) => set({ isLoading }),
33
43
  }), {
34
- name: 'auth-storage',
44
+ name: AUTH_STORAGE_KEY,
35
45
  version: 3, // v3 (Iteracja 2 — XSS fix): accessToken DROP'owany z localStorage
36
46
  // persistence. Token żyje tylko w-memory + httpOnly cookie (browser auto-sent).
37
47
  // Non-browser klienci (mobile native, server-to-server) ustawiają token explicit
@@ -53,8 +63,10 @@ export const createAuthStore = (initialIsAuthenticated = false) => createStore()
53
63
  return {
54
64
  ...currentState,
55
65
  customer: persisted.customer ?? currentState.customer,
56
- // accessToken NIE persistowany initialize zawsze na null (in-memory only)
57
- // Server cookie is the authority never let stale localStorage override it
66
+ // accessToken NIE persistowany w localStorage (Inv-5 XSS hardening) spread
67
+ // `...currentState` propaguje wartość z factory: `null` (default) lub seed
68
+ // z `options.initialAccessToken` gdy konsumer podał token server-side.
69
+ // Server cookie is the authority — never let stale localStorage override it.
58
70
  isAuthenticated: currentState.isAuthenticated,
59
71
  };
60
72
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doswiftly/storefront-sdk",
3
- "version": "17.0.0",
3
+ "version": "18.1.0",
4
4
  "description": "Storefront runtime SDK for DoSwiftly Commerce — layered transport, middleware pipeline, React providers, Zustand stores, cache strategies. 0 runtime dependencies in core.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -84,6 +84,8 @@
84
84
  "test:contract": "vitest run src/__tests__/contract/",
85
85
  "test:coverage": "vitest run --coverage",
86
86
  "doctor": "node scripts/doctor.cjs",
87
- "validate:cart": "node scripts/validate-cart-operations.cjs --strict"
87
+ "validate:cart": "node scripts/validate-cart-operations.cjs --strict",
88
+ "yalc:push": "pnpm build && yalc publish --push",
89
+ "yalc:watch": "node scripts/yalc-watcher.cjs"
88
90
  }
89
91
  }