@doswiftly/storefront-sdk 16.1.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.
- package/CHANGELOG.md +1255 -0
- package/README.md +16 -4
- package/dist/core/auth/auth-client.d.ts +39 -3
- package/dist/core/auth/auth-client.d.ts.map +1 -1
- package/dist/core/auth/auth-client.js +51 -3
- package/dist/core/auth/cookie-config.d.ts +52 -3
- package/dist/core/auth/cookie-config.d.ts.map +1 -1
- package/dist/core/auth/cookie-config.js +60 -6
- package/dist/core/auth/handlers.d.ts +46 -0
- package/dist/core/auth/handlers.d.ts.map +1 -1
- package/dist/core/auth/handlers.js +9 -2
- package/dist/core/auth/session-events.d.ts +38 -0
- package/dist/core/auth/session-events.d.ts.map +1 -0
- package/dist/core/auth/session-events.js +35 -0
- package/dist/core/cart/cart-client.d.ts +10 -1
- package/dist/core/cart/cart-client.d.ts.map +1 -1
- package/dist/core/cart/cart-client.js +17 -1
- package/dist/core/cart/cart-recovery.d.ts +23 -0
- package/dist/core/cart/cart-recovery.d.ts.map +1 -1
- package/dist/core/cart/cart-recovery.js +20 -3
- package/dist/core/cart/types.d.ts +2 -1
- package/dist/core/cart/types.d.ts.map +1 -1
- package/dist/core/cart/types.js +7 -1
- package/dist/core/client/create-client.d.ts.map +1 -1
- package/dist/core/client/create-client.js +7 -3
- package/dist/core/client/execute.d.ts +29 -3
- package/dist/core/client/execute.d.ts.map +1 -1
- package/dist/core/client/execute.js +174 -3
- package/dist/core/client/types.d.ts +50 -2
- package/dist/core/client/types.d.ts.map +1 -1
- package/dist/core/errors.d.ts +6 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +6 -0
- package/dist/core/generated/operation-types.d.ts +937 -182
- package/dist/core/generated/operation-types.d.ts.map +1 -1
- package/dist/core/generated/operation-types.js +560 -1
- package/dist/core/index.d.ts +6 -3
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +12 -2
- package/dist/core/middleware/session-retry.d.ts +47 -0
- package/dist/core/middleware/session-retry.d.ts.map +1 -0
- package/dist/core/middleware/session-retry.js +71 -0
- package/dist/core/operations/auth.d.ts.map +1 -1
- package/dist/core/operations/auth.js +1 -0
- package/dist/core/operations/cart.d.ts +7 -0
- package/dist/core/operations/cart.d.ts.map +1 -1
- package/dist/core/operations/cart.js +54 -3
- package/dist/react/components/PaymentInstrumentSection.d.ts +56 -0
- package/dist/react/components/PaymentInstrumentSection.d.ts.map +1 -0
- package/dist/react/components/PaymentInstrumentSection.js +89 -0
- package/dist/react/components/PaymentInstrumentTile.d.ts +56 -0
- package/dist/react/components/PaymentInstrumentTile.d.ts.map +1 -0
- package/dist/react/components/PaymentInstrumentTile.js +41 -0
- package/dist/react/components/index.d.ts +2 -0
- package/dist/react/components/index.d.ts.map +1 -1
- package/dist/react/components/index.js +2 -0
- package/dist/react/helpers/browser-data.d.ts +89 -0
- package/dist/react/helpers/browser-data.d.ts.map +1 -0
- package/dist/react/helpers/browser-data.js +84 -0
- package/dist/react/hooks/use-cart-manager.d.ts +104 -13
- package/dist/react/hooks/use-cart-manager.d.ts.map +1 -1
- package/dist/react/hooks/use-cart-manager.js +144 -12
- package/dist/react/hooks/use-login.d.ts.map +1 -1
- package/dist/react/hooks/use-login.js +3 -3
- package/dist/react/hooks/use-refresh-token.d.ts.map +1 -1
- package/dist/react/hooks/use-refresh-token.js +6 -4
- package/dist/react/hooks/use-session-expired.d.ts +16 -0
- package/dist/react/hooks/use-session-expired.d.ts.map +1 -0
- package/dist/react/hooks/use-session-expired.js +26 -0
- package/dist/react/hooks/use-session-refresh.d.ts +32 -0
- package/dist/react/hooks/use-session-refresh.d.ts.map +1 -0
- package/dist/react/hooks/use-session-refresh.js +147 -0
- package/dist/react/index.d.ts +5 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +5 -1
- package/dist/react/providers/storefront-client-provider.d.ts +10 -1
- package/dist/react/providers/storefront-client-provider.d.ts.map +1 -1
- package/dist/react/providers/storefront-client-provider.js +38 -3
- package/dist/react/providers/storefront-provider.d.ts +51 -3
- package/dist/react/providers/storefront-provider.d.ts.map +1 -1
- package/dist/react/providers/storefront-provider.js +22 -5
- package/dist/react/server/create-storefront-auth-route.d.ts +63 -0
- package/dist/react/server/create-storefront-auth-route.d.ts.map +1 -0
- package/dist/react/server/create-storefront-auth-route.js +239 -0
- package/dist/react/server/get-initial-auth.d.ts +57 -0
- package/dist/react/server/get-initial-auth.d.ts.map +1 -0
- package/dist/react/server/get-initial-auth.js +55 -0
- package/dist/react/server/index.d.ts +3 -0
- package/dist/react/server/index.d.ts.map +1 -1
- package/dist/react/server/index.js +6 -0
- package/dist/react/stores/auth.store.d.ts +46 -2
- package/dist/react/stores/auth.store.d.ts.map +1 -1
- package/dist/react/stores/auth.store.js +19 -7
- 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
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
57
|
-
//
|
|
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": "
|
|
3
|
+
"version": "18.0.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
|
}
|