@akinon/next 1.126.20-v1-rc.17 → 1.126.20

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 CHANGED
@@ -1,12 +1,16 @@
1
1
  # @akinon/next
2
2
 
3
- ## 1.126.20-v1-rc.17
3
+ ## 1.126.20
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - d4809417: ZERO-4399: add card rewards to pz-masterpass-rest
8
- - a34bb1b: ZERO-5168: Post the attribute-based shipping selection to the page name Commerce reports
9
- - 182a639b: ZERO-4483: Verify v1-rc maintenance pipeline (no-op patch to confirm v1-rc dist-tag flow)
7
+ - 55b89cb: ZERO-5206: Port the theme editor's app skeleton (chrome and page slots) to the v1 line
8
+
9
+ The `[pz]` layout now wraps the brand header and footer in `ChromeSlot` (the brand markup stays as the fallback) and mounts `ThemeModals`, so a designer can compose the header, the footer and modals from the theme editor. Basket, checkout (top/body/bottom outlets and the variants registry), auth, forgot and reset password, order success and the account area (menu, page body and the ten subpages) get the same slot layouts as 2.x, so the editor takes these pages over without any mounting in the brand. Account routes moved into route groups (`(dashboard)`, `orders/(list)`, `orders/[id]/(detail)`, `auth/(login)`); URLs are unchanged.
10
+
11
+ Inside the editor's cross-site preview iframe the storefront's cookies are now usable: when embedding is enabled (`NEXT_PUBLIC_THEME_EDITOR_ORIGINS` holds a valid origin, or outside production) the next-auth session, CSRF and callback cookies, the Django `csrftoken`, the commerce session cookies set at sign-in and the Set-Cookie headers relayed by the BFF and middlewares are sent as `SameSite=None; Secure`, and the BFF checks the request origin on every state-changing call. A storefront without the editor keeps the Lax defaults. The CSP `frame-ancestors` also lists the editor origins (`*.akinon.net`, `localhost:5174`).
12
+
13
+ The app depends on `@akinon/pz-theme` 2.0.143; `@akinon/pz-theme` and `@akinon/theme-contract` are transpiled and protected from plugin installs, and the Tailwind content globs include pz-theme's sources.
10
14
 
11
15
  ## 1.126.19
12
16
 
@@ -0,0 +1,76 @@
1
+ import {
2
+ isEditorEmbedEnabled,
3
+ makeSetCookieEmbedAware
4
+ } from '../utils/editor-embed';
5
+ import { getCsrfCookieFlags } from '../utils/csrf';
6
+
7
+ const env = process.env as Record<string, string | undefined>;
8
+ const original = {
9
+ NODE_ENV: env.NODE_ENV,
10
+ NEXT_PUBLIC_THEME_EDITOR_ORIGINS: env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS
11
+ };
12
+
13
+ const setEnv = (nodeEnv: string, origins?: string) => {
14
+ env.NODE_ENV = nodeEnv;
15
+ if (origins === undefined) delete env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS;
16
+ else env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS = origins;
17
+ };
18
+
19
+ afterEach(() => {
20
+ setEnv(original.NODE_ENV, original.NEXT_PUBLIC_THEME_EDITOR_ORIGINS);
21
+ });
22
+
23
+ describe('isEditorEmbedEnabled', () => {
24
+ it('is always on outside production', () => {
25
+ setEnv('development');
26
+ expect(isEditorEmbedEnabled()).toBe(true);
27
+ });
28
+
29
+ it('is off in production without editor origins', () => {
30
+ setEnv('production');
31
+ expect(isEditorEmbedEnabled()).toBe(false);
32
+ });
33
+
34
+ it('is on in production when an editor origin is declared', () => {
35
+ setEnv('production', 'https://editor.example.com, http://localhost:5174');
36
+ expect(isEditorEmbedEnabled()).toBe(true);
37
+ });
38
+
39
+ it('ignores entries that are not origins', () => {
40
+ setEnv('production', ' , not-a-url ,');
41
+ expect(isEditorEmbedEnabled()).toBe(false);
42
+ });
43
+ });
44
+
45
+ describe('makeSetCookieEmbedAware', () => {
46
+ it('passes the header through when embedding is off', () => {
47
+ setEnv('production');
48
+ const header = 'osessionid=abc; Path=/; HttpOnly; SameSite=Lax';
49
+ expect(makeSetCookieEmbedAware(header)).toBe(header);
50
+ });
51
+
52
+ it('replaces SameSite and adds Secure once when embedding is on', () => {
53
+ setEnv('production', 'https://editor.example.com');
54
+ expect(
55
+ makeSetCookieEmbedAware('osessionid=abc; Path=/; Secure; SameSite=Lax')
56
+ ).toBe('osessionid=abc; Path=/; Secure; SameSite=None');
57
+ });
58
+ });
59
+
60
+ describe('getCsrfCookieFlags', () => {
61
+ it('keeps the Lax default on a storefront without the editor', () => {
62
+ setEnv('production');
63
+ expect(getCsrfCookieFlags()).toMatchObject({
64
+ secure: true,
65
+ sameSite: 'lax'
66
+ });
67
+ });
68
+
69
+ it('switches to SameSite=None; Secure in embed mode', () => {
70
+ setEnv('production', 'https://editor.example.com');
71
+ expect(getCsrfCookieFlags()).toMatchObject({
72
+ secure: true,
73
+ sameSite: 'none'
74
+ });
75
+ });
76
+ });
package/api/auth.ts CHANGED
@@ -10,6 +10,7 @@ import { AuthError } from '../types';
10
10
  import getRootHostname from '../utils/get-root-hostname';
11
11
  import { LocaleUrlStrategy } from '../localization';
12
12
  import { getCsrfCookieFlags } from '../utils/csrf';
13
+ import { isEditorEmbedEnabled } from '../utils/editor-embed';
13
14
 
14
15
  function getRequestRootHostname(headers: NextApiRequest['headers']) {
15
16
  if (Settings.localization.localeUrlStrategy !== LocaleUrlStrategy.Subdomain) {
@@ -24,11 +25,7 @@ function getRequestRootHostname(headers: NextApiRequest['headers']) {
24
25
 
25
26
  function buildCsrfCookieString(value: string, rootHostname: string | null) {
26
27
  const flags = getCsrfCookieFlags();
27
- const parts = [
28
- `csrftoken=${value}`,
29
- 'Path=/',
30
- `SameSite=${flags.sameSite}`
31
- ];
28
+ const parts = [`csrftoken=${value}`, 'Path=/', `SameSite=${flags.sameSite}`];
32
29
 
33
30
  if (flags.httpOnly) {
34
31
  parts.push('HttpOnly');
@@ -82,11 +79,48 @@ type CustomNextAuthOptions = (
82
79
  res: NextApiResponse
83
80
  ) => Partial<NextAuthOptions>;
84
81
 
82
+ /**
83
+ * Cookie attributes that let the shopper sign in while the storefront runs
84
+ * inside the theme editor's preview iframe. next-auth defaults to
85
+ * SameSite=Lax, and in a cross-site iframe (editor origin vs storefront
86
+ * origin) the browser then drops the CSRF cookie, so every credentials POST
87
+ * fails before it reaches the provider. SameSite=None requires Secure;
88
+ * browsers accept Secure cookies from plain-http localhost, so local
89
+ * development is covered too. Scoped to editor-enabled deployments (and
90
+ * dev) — a storefront without the editor keeps the stricter Lax default.
91
+ *
92
+ * next-auth 4 takes an override only with its cookie name, so the names
93
+ * stay next-auth's own defaults: the middleware's `getToken()` still finds
94
+ * the session under the name it expects.
95
+ */
96
+ const getEmbedAwareCookies = (): NextAuthOptions['cookies'] => {
97
+ if (!isEditorEmbedEnabled()) return undefined;
98
+
99
+ const secureNames = process.env.NEXTAUTH_URL?.startsWith('https://') ?? false;
100
+ const prefix = secureNames ? '__Secure-' : '';
101
+ const options = {
102
+ sameSite: 'none' as const,
103
+ secure: true,
104
+ path: '/',
105
+ httpOnly: true
106
+ };
107
+
108
+ return {
109
+ sessionToken: { name: `${prefix}next-auth.session-token`, options },
110
+ callbackUrl: { name: `${prefix}next-auth.callback-url`, options },
111
+ csrfToken: {
112
+ name: `${secureNames ? '__Host-' : ''}next-auth.csrf-token`,
113
+ options
114
+ }
115
+ };
116
+ };
117
+
85
118
  const defaultNextAuthOptions = (
86
119
  req: NextApiRequest,
87
120
  res: NextApiResponse
88
121
  ): NextAuthOptions => {
89
122
  return {
123
+ cookies: getEmbedAwareCookies(),
90
124
  providers: [
91
125
  CredentialProvider({
92
126
  id: 'oauth',
@@ -212,7 +246,14 @@ const defaultNextAuthOptions = (
212
246
  ? getRootHostname(hostname)
213
247
  : null;
214
248
  const domainOption = rootHostname ? `Domain=${rootHostname};` : '';
215
- const cookieOptions = `Path=/; HttpOnly; Secure; Max-Age=${maxAge}; ${domainOption}`;
249
+ // Without SameSite the browser defaults to Lax and drops the
250
+ // commerce session on every request from the theme editor's
251
+ // cross-site preview iframe — the shopper signs in and the next
252
+ // authenticated call 401s straight back to the login.
253
+ const sameSiteOption = isEditorEmbedEnabled()
254
+ ? ' SameSite=None;'
255
+ : '';
256
+ const cookieOptions = `Path=/; HttpOnly; Secure; Max-Age=${maxAge}; ${domainOption}${sameSiteOption}`;
216
257
 
217
258
  const setCookies = [
218
259
  `osessionid=${sessionId}; ${cookieOptions}`,
package/api/client.ts CHANGED
@@ -8,6 +8,7 @@ import { cookies } from 'next/headers';
8
8
  import getRootHostname from '../utils/get-root-hostname';
9
9
  import { LocaleUrlStrategy } from '../localization';
10
10
  import { getCsrfCookieFlags, isCsrfHttpOnly } from '../utils/csrf';
11
+ import { isEditorEmbedEnabled } from '../utils/editor-embed';
11
12
 
12
13
  interface RouteParams {
13
14
  params: {
@@ -160,7 +161,16 @@ async function proxyRequest(...args) {
160
161
  // browser can no longer mirror it into the `x-csrftoken` header, so the
161
162
  // proxy validates the request origin and injects the header server-side
162
163
  // from the cookie that the browser sent with this request.
163
- if (isCsrfHttpOnly() && STATE_CHANGING_METHODS.includes(req.method)) {
164
+ //
165
+ // The origin check also runs whenever editor embedding is enabled: embed
166
+ // mode relaxes session cookies to SameSite=None, so the browser attaches
167
+ // them to cross-site requests and the Lax barrier this check replaces is
168
+ // gone. `isOriginAllowed` keeps accepting origin-less requests, so
169
+ // non-browser clients are unaffected.
170
+ if (
171
+ (isCsrfHttpOnly() || isEditorEmbedEnabled()) &&
172
+ STATE_CHANGING_METHODS.includes(req.method)
173
+ ) {
164
174
  if (!isOriginAllowed(req)) {
165
175
  logger.warn('Client Proxy Request - Blocked cross-origin request', {
166
176
  url: req.url,
@@ -172,9 +182,11 @@ async function proxyRequest(...args) {
172
182
  );
173
183
  }
174
184
 
175
- const csrfToken = nextCookies.get('csrftoken')?.value;
176
- if (csrfToken) {
177
- fetchOptions.headers['x-csrftoken'] = csrfToken;
185
+ if (isCsrfHttpOnly()) {
186
+ const csrfToken = nextCookies.get('csrftoken')?.value;
187
+ if (csrfToken) {
188
+ fetchOptions.headers['x-csrftoken'] = csrfToken;
189
+ }
178
190
  }
179
191
  }
180
192
 
@@ -292,6 +304,16 @@ async function proxyRequest(...args) {
292
304
  cookie.sameSite = flags.sameSite;
293
305
  }
294
306
  }
307
+ // Commerce sends its session cookies without SameSite; forwarded
308
+ // as-is the browser defaults them to Lax and drops them inside
309
+ // the theme editor's cross-site preview iframe, killing the
310
+ // session right after login. Editor-enabled deployments relax
311
+ // every forwarded cookie the same way the auth cookies already
312
+ // are.
313
+ if (isEditorEmbedEnabled()) {
314
+ cookie.sameSite = 'none';
315
+ cookie.secure = true;
316
+ }
295
317
  return formatCookieString(cookie);
296
318
  })
297
319
  .join(', ');
@@ -7,6 +7,7 @@ import {
7
7
  import Settings from 'settings';
8
8
  import { getUrlPathWithLocale } from '../utils/localization';
9
9
  import logger from '../utils/log';
10
+ import { makeSetCookieEmbedAware } from '../utils/editor-embed';
10
11
 
11
12
  const LOGIN_URL_REGEX = /^\/(\w+)\/login\/?$/;
12
13
  const CALLBACK_URL_REGEX = /^\/(\w+)\/login\/callback\/?$/;
@@ -58,7 +59,9 @@ function fetchCommerce(
58
59
 
59
60
  function forwardCookies(from: Response, to: NextResponse): void {
60
61
  from.headers.getSetCookie().forEach((cookie) => {
61
- to.headers.append('set-cookie', cookie);
62
+ // Relayed verbatim these would default to Lax and be rejected inside
63
+ // the editor's cross-site preview iframe.
64
+ to.headers.append('set-cookie', makeSetCookieEmbedAware(cookie));
62
65
  });
63
66
  }
64
67
 
@@ -172,7 +175,10 @@ const withOauthLogin =
172
175
  async (req: NextRequest, event: NextFetchEvent) => {
173
176
  const { pathname, search } = req.nextUrl;
174
177
 
175
- if (!pathname.includes('/login') && !pathname.startsWith('/baskets/basket')) {
178
+ if (
179
+ !pathname.includes('/login') &&
180
+ !pathname.startsWith('/baskets/basket')
181
+ ) {
176
182
  return middleware(req, event);
177
183
  }
178
184
 
@@ -5,6 +5,7 @@ import logger from '../utils/log';
5
5
  import { urlLocaleMatcherRegex } from '../utils';
6
6
  import { getUrlPathWithLocale } from '../utils/localization';
7
7
  import { shouldIgnoreRedirect } from '../utils/redirect-ignore';
8
+ import { makeSetCookieEmbedAware } from '../utils/editor-embed';
8
9
  import { ROUTES } from 'routes';
9
10
 
10
11
  // This middleware is used to handle url redirections set in Omnitron
@@ -76,7 +77,12 @@ const withUrlRedirection =
76
77
 
77
78
  if (setCookies && setCookies.length > 0) {
78
79
  for (const cookie of setCookies) {
79
- response.headers.append('Set-Cookie', cookie);
80
+ // Relayed verbatim these would default to Lax and be rejected
81
+ // inside the editor's cross-site preview iframe.
82
+ response.headers.append(
83
+ 'Set-Cookie',
84
+ makeSetCookieEmbedAware(cookie)
85
+ );
80
86
  }
81
87
  }
82
88
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@akinon/next",
3
3
  "description": "Core package for Project Zero Next",
4
- "version": "1.126.20-v1-rc.17",
4
+ "version": "1.126.20",
5
5
  "private": false,
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -35,7 +35,7 @@
35
35
  "set-cookie-parser": "2.6.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@akinon/eslint-plugin-projectzero": "1.126.20-v1-rc.17",
38
+ "@akinon/eslint-plugin-projectzero": "1.126.20",
39
39
  "@babel/core": "7.26.10",
40
40
  "@babel/preset-env": "7.26.9",
41
41
  "@babel/preset-typescript": "7.27.0",
package/utils/csrf.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import settings from 'settings';
2
2
 
3
+ import { isEditorEmbedEnabled } from './editor-embed';
4
+
3
5
  /**
4
6
  * Whether the Django `csrftoken` cookie should be hardened with the
5
7
  * `HttpOnly` flag.
@@ -16,7 +18,7 @@ export function isCsrfHttpOnly(): boolean {
16
18
  export interface CsrfCookieFlags {
17
19
  httpOnly: boolean;
18
20
  secure: boolean;
19
- sameSite: 'lax';
21
+ sameSite: 'lax' | 'none';
20
22
  }
21
23
 
22
24
  /**
@@ -26,9 +28,21 @@ export interface CsrfCookieFlags {
26
28
  * `SameSite=Lax` is the Next.js-layer CSRF defense: the browser will not
27
29
  * attach the cookie on cross-site state-changing requests, so the proxy's
28
30
  * server-side header injection cannot be abused from a third-party origin.
31
+ *
32
+ * Inside the theme editor's preview iframe the storefront itself is the
33
+ * cross-site party, and a Lax cookie is never sent — the shopper signs in
34
+ * and the next state-changing call fails. Editor-enabled deployments
35
+ * therefore switch to `None` + `Secure`. What replaces the Lax barrier
36
+ * there: the BFF's origin check runs for every state-changing request in
37
+ * embed mode (see api/client.ts), and the commerce backend's `x-csrftoken`
38
+ * double-submit stays on either way.
29
39
  */
30
40
  export function getCsrfCookieFlags(): CsrfCookieFlags {
31
41
  const httpOnly = isCsrfHttpOnly();
42
+
43
+ if (isEditorEmbedEnabled()) {
44
+ return { httpOnly, secure: true, sameSite: 'none' };
45
+ }
32
46
  return {
33
47
  httpOnly,
34
48
  secure: process.env.NODE_ENV === 'production',
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Whether this storefront can run inside the theme editor's preview iframe.
3
+ * Editor-enabled deployments declare the editor origins; local development
4
+ * is always embeddable.
5
+ *
6
+ * Cookie attributes hang off this: the preview iframe is cross-site from
7
+ * the editor's (or Omnitron's) top-level page, and a browser only sends
8
+ * `SameSite=None; Secure` cookies there — every session-bearing cookie has
9
+ * to switch when embedding is on, or the shopper signs in and the very next
10
+ * commerce call comes back 401.
11
+ */
12
+ const hasValidEditorOrigin = (raw: string | undefined): boolean => {
13
+ if (!raw) return false;
14
+
15
+ // Embed mode relaxes the whole storefront's cookie posture, so a stray
16
+ // character or leftover whitespace in the env var must not flip it — only
17
+ // entries that parse as real origins count.
18
+ return raw
19
+ .split(',')
20
+ .map((entry) => entry.trim())
21
+ .filter(Boolean)
22
+ .some((entry) => {
23
+ try {
24
+ return Boolean(new URL(entry).origin);
25
+ } catch {
26
+ return false;
27
+ }
28
+ });
29
+ };
30
+
31
+ export const isEditorEmbedEnabled = (): boolean =>
32
+ hasValidEditorOrigin(process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS) ||
33
+ process.env.NODE_ENV !== 'production';
34
+
35
+ /**
36
+ * Rewrites a raw Set-Cookie header so it survives the editor's cross-site
37
+ * preview iframe: `SameSite=None` requires `Secure`, and any prior SameSite
38
+ * value must be replaced rather than doubled. Outside embed mode the header
39
+ * passes through untouched. Use wherever commerce Set-Cookies are relayed
40
+ * verbatim (middlewares) — the BFF proxy applies the same rewrite itself.
41
+ */
42
+ export const makeSetCookieEmbedAware = (setCookie: string): string => {
43
+ if (!isEditorEmbedEnabled()) return setCookie;
44
+
45
+ const withoutAttrs = setCookie
46
+ .replace(/;\s*samesite=[^;]*/gi, '')
47
+ .replace(/;\s*secure(?=;|\s*$)/gi, '');
48
+
49
+ return `${withoutAttrs}; Secure; SameSite=None`;
50
+ };
package/with-pz-config.js CHANGED
@@ -58,7 +58,7 @@ const defaultConfig = {
58
58
  {
59
59
  key: 'Content-Security-Policy',
60
60
  value:
61
- "frame-ancestors 'self' https://*.akifast.com akifast.com https://*.akinoncloud.com akinoncloud.com https://*.akinoncloudcdn.com akinoncloudcdn.com"
61
+ "frame-ancestors 'self' https://*.akifast.com akifast.com https://*.akinoncloud.com akinoncloud.com https://*.akinoncloudcdn.com akinoncloudcdn.com https://*.akinon.net *.akinon.net http://localhost:5174 localhost:5174"
62
62
  }
63
63
  ]
64
64
  }