@akinon/projectzero 1.126.15-v1-rc.12 → 1.126.15

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,10 +1,6 @@
1
1
  # @akinon/projectzero
2
2
 
3
- ## 1.126.15-v1-rc.12
4
-
5
- ### Patch Changes
6
-
7
- - a34bb1b: ZERO-5168: Post the attribute-based shipping selection to the page name Commerce reports
3
+ ## 1.126.15
8
4
 
9
5
  ## 1.126.14
10
6
 
@@ -16,3 +16,13 @@ NEXT_PUBLIC_VIRTUAL_TRY_ON_API_URL=https://d2a26507c7094f359aba349b96a96881.lb.a
16
16
  # CACHE_KEY_PREFIX=
17
17
 
18
18
  # BF_CACHE=false
19
+
20
+ # Widget preview (theme editor named previews).
21
+ # PREVIEW_SECRET signs the expiring preview links the editor requests from
22
+ # /api/preview/link; it never travels in a url. Unset = preview disabled.
23
+ # MAIN_APP_URL is the Omnitron the editor is signed into — the link endpoint
24
+ # verifies the editor's session token there before minting a link.
25
+ # PREVIEW_LINK_TTL_SECONDS: how long a link stays valid (default 86400 = 24h).
26
+ # PREVIEW_SECRET=
27
+ # MAIN_APP_URL=https://<omnitron-host>
28
+ # PREVIEW_LINK_TTL_SECONDS=86400
@@ -1,40 +1,42 @@
1
1
  # projectzeronext
2
2
 
3
- ## 1.126.15-v1-rc.12
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [560e209]
8
- - Updated dependencies [d480941]
9
- - Updated dependencies [a34bb1b]
10
- - Updated dependencies [560e209]
11
- - Updated dependencies [182a639b]
12
- - @akinon/pz-tamara-extension@1.126.15-v1-rc.12
13
- - @akinon/pz-masterpass-rest@1.126.15-v1-rc.12
14
- - @akinon/next@1.126.15-v1-rc.12
15
- - @akinon/pz-virtual-try-on@1.126.15-v1-rc.12
16
- - @akinon/pz-akifast@1.126.15-v1-rc.12
17
- - @akinon/pz-apple-pay@1.126.15-v1-rc.12
18
- - @akinon/pz-b2b@1.126.15-v1-rc.12
19
- - @akinon/pz-basket-gift-pack@1.126.15-v1-rc.12
20
- - @akinon/pz-bkm@1.126.15-v1-rc.12
21
- - @akinon/pz-checkout-gift-pack@1.126.15-v1-rc.12
22
- - @akinon/pz-click-collect@1.126.15-v1-rc.12
23
- - @akinon/pz-credit-payment@1.126.15-v1-rc.12
24
- - @akinon/pz-cybersource-uc@1.126.15-v1-rc.12
25
- - @akinon/pz-flow-payment@1.126.15-v1-rc.12
26
- - @akinon/pz-google-pay@1.126.15-v1-rc.12
27
- - @akinon/pz-gpay@1.126.15-v1-rc.12
28
- - @akinon/pz-haso@1.126.15-v1-rc.12
29
- - @akinon/pz-hepsipay@1.126.15-v1-rc.12
30
- - @akinon/pz-masterpass@1.126.15-v1-rc.12
31
- - @akinon/pz-multi-basket@1.126.15-v1-rc.12
32
- - @akinon/pz-one-click-checkout@1.126.15-v1-rc.12
33
- - @akinon/pz-otp@1.126.15-v1-rc.12
34
- - @akinon/pz-pay-on-delivery@1.126.15-v1-rc.12
35
- - @akinon/pz-saved-card@1.126.15-v1-rc.12
36
- - @akinon/pz-similar-products@1.126.15-v1-rc.12
37
- - @akinon/pz-tabby-extension@1.126.15-v1-rc.12
3
+ ## 1.126.15
4
+
5
+ ### Patch Changes
6
+
7
+ - 4f2f9b8: ZERO-5149: Bring the theme editor's widget preview to the v1 line
8
+
9
+ Storefronts on @akinon/next 1.x can now open the theme editor's named previews the way 2.x storefronts do. The editor asks `/api/preview/link` for a signed, expiring link, and `?preview=<token>` enters Next.js draft mode through `/api/preview`. While draft mode is on, `getWidgetData` tries `<slug>-preview-<id>` first, the theme-previews registry retires deleted previews, and `getPreviewSnapshot` hands @akinon/pz-theme (2.0.79 or later) the placeholder snapshot it renders from. Preview content is only rendered when Next 14 itself treats the render as a draft, so it can never reach the shared ISR cache. Normal traffic renders live as before.
10
+
11
+ A storefront needs the `[pz]` route layout (`usePzSegment: true`). Legacy `[commerce]/[locale]/[currency]` projects migrate first with `npx projectzero codemod --codemod=migrate-segments`, otherwise previews render live. On top of that it needs `PREVIEW_SECRET` and `MAIN_APP_URL` (optionally `PREVIEW_LINK_TTL_SECONDS`), the `/api/preview` and `/api/preview/link` route handlers, the `?preview` entry in `src/middleware.ts` and the `preview` segment in `settings.js` `pzSegments`, all as in projectzeronext. The new segment changes every page's pz value once, so ISR entries are rebuilt after the upgrade.
12
+
13
+ - Updated dependencies [4f2f9b8]
14
+ - @akinon/next@1.126.15
15
+ - @akinon/pz-virtual-try-on@1.126.15
16
+ - @akinon/pz-akifast@1.126.15
17
+ - @akinon/pz-apple-pay@1.126.15
18
+ - @akinon/pz-b2b@1.126.15
19
+ - @akinon/pz-basket-gift-pack@1.126.15
20
+ - @akinon/pz-bkm@1.126.15
21
+ - @akinon/pz-checkout-gift-pack@1.126.15
22
+ - @akinon/pz-click-collect@1.126.15
23
+ - @akinon/pz-credit-payment@1.126.15
24
+ - @akinon/pz-cybersource-uc@1.126.15
25
+ - @akinon/pz-flow-payment@1.126.15
26
+ - @akinon/pz-google-pay@1.126.15
27
+ - @akinon/pz-gpay@1.126.15
28
+ - @akinon/pz-haso@1.126.15
29
+ - @akinon/pz-hepsipay@1.126.15
30
+ - @akinon/pz-masterpass@1.126.15
31
+ - @akinon/pz-masterpass-rest@1.126.15
32
+ - @akinon/pz-multi-basket@1.126.15
33
+ - @akinon/pz-one-click-checkout@1.126.15
34
+ - @akinon/pz-otp@1.126.15
35
+ - @akinon/pz-pay-on-delivery@1.126.15
36
+ - @akinon/pz-saved-card@1.126.15
37
+ - @akinon/pz-similar-products@1.126.15
38
+ - @akinon/pz-tabby-extension@1.126.15
39
+ - @akinon/pz-tamara-extension@1.126.15
38
40
 
39
41
  ## 1.126.14
40
42
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectzeronext",
3
- "version": "1.126.15-v1-rc.12",
3
+ "version": "1.126.15",
4
4
  "private": true,
5
5
  "license": "MIT",
6
6
  "scripts": {
@@ -24,32 +24,32 @@
24
24
  "test:middleware": "jest middleware-matcher.test.ts --bail"
25
25
  },
26
26
  "dependencies": {
27
- "@akinon/next": "1.126.15-v1-rc.12",
28
- "@akinon/pz-akifast": "1.126.15-v1-rc.12",
29
- "@akinon/pz-apple-pay": "1.126.15-v1-rc.12",
30
- "@akinon/pz-b2b": "1.126.15-v1-rc.12",
31
- "@akinon/pz-basket-gift-pack": "1.126.15-v1-rc.12",
32
- "@akinon/pz-bkm": "1.126.15-v1-rc.12",
33
- "@akinon/pz-checkout-gift-pack": "1.126.15-v1-rc.12",
34
- "@akinon/pz-click-collect": "1.126.15-v1-rc.12",
35
- "@akinon/pz-credit-payment": "1.126.15-v1-rc.12",
36
- "@akinon/pz-cybersource-uc": "1.126.15-v1-rc.12",
37
- "@akinon/pz-flow-payment": "1.126.15-v1-rc.12",
38
- "@akinon/pz-google-pay": "1.126.15-v1-rc.12",
39
- "@akinon/pz-gpay": "1.126.15-v1-rc.12",
40
- "@akinon/pz-haso": "1.126.15-v1-rc.12",
41
- "@akinon/pz-hepsipay": "1.126.15-v1-rc.12",
42
- "@akinon/pz-masterpass": "1.126.15-v1-rc.12",
43
- "@akinon/pz-masterpass-rest": "1.126.15-v1-rc.12",
44
- "@akinon/pz-multi-basket": "1.126.15-v1-rc.12",
45
- "@akinon/pz-one-click-checkout": "1.126.15-v1-rc.12",
46
- "@akinon/pz-otp": "1.126.15-v1-rc.12",
47
- "@akinon/pz-pay-on-delivery": "1.126.15-v1-rc.12",
48
- "@akinon/pz-saved-card": "1.126.15-v1-rc.12",
49
- "@akinon/pz-similar-products": "1.126.15-v1-rc.12",
50
- "@akinon/pz-tabby-extension": "1.126.15-v1-rc.12",
51
- "@akinon/pz-tamara-extension": "1.126.15-v1-rc.12",
52
- "@akinon/pz-virtual-try-on": "1.126.15-v1-rc.12",
27
+ "@akinon/next": "1.126.15",
28
+ "@akinon/pz-akifast": "1.126.15",
29
+ "@akinon/pz-apple-pay": "1.126.15",
30
+ "@akinon/pz-b2b": "1.126.15",
31
+ "@akinon/pz-basket-gift-pack": "1.126.15",
32
+ "@akinon/pz-bkm": "1.126.15",
33
+ "@akinon/pz-checkout-gift-pack": "1.126.15",
34
+ "@akinon/pz-click-collect": "1.126.15",
35
+ "@akinon/pz-credit-payment": "1.126.15",
36
+ "@akinon/pz-cybersource-uc": "1.126.15",
37
+ "@akinon/pz-flow-payment": "1.126.15",
38
+ "@akinon/pz-google-pay": "1.126.15",
39
+ "@akinon/pz-gpay": "1.126.15",
40
+ "@akinon/pz-haso": "1.126.15",
41
+ "@akinon/pz-hepsipay": "1.126.15",
42
+ "@akinon/pz-masterpass": "1.126.15",
43
+ "@akinon/pz-masterpass-rest": "1.126.15",
44
+ "@akinon/pz-multi-basket": "1.126.15",
45
+ "@akinon/pz-one-click-checkout": "1.126.15",
46
+ "@akinon/pz-otp": "1.126.15",
47
+ "@akinon/pz-pay-on-delivery": "1.126.15",
48
+ "@akinon/pz-saved-card": "1.126.15",
49
+ "@akinon/pz-similar-products": "1.126.15",
50
+ "@akinon/pz-tabby-extension": "1.126.15",
51
+ "@akinon/pz-tamara-extension": "1.126.15",
52
+ "@akinon/pz-virtual-try-on": "1.126.15",
53
53
  "@hookform/resolvers": "2.9.0",
54
54
  "@next/third-parties": "14.1.0",
55
55
  "@react-google-maps/api": "2.17.1",
@@ -72,7 +72,7 @@
72
72
  "yup": "0.32.11"
73
73
  },
74
74
  "devDependencies": {
75
- "@akinon/eslint-plugin-projectzero": "1.126.15-v1-rc.12",
75
+ "@akinon/eslint-plugin-projectzero": "1.126.15",
76
76
  "@semantic-release/changelog": "6.0.2",
77
77
  "@semantic-release/exec": "6.0.3",
78
78
  "@semantic-release/git": "10.0.1",
@@ -12,6 +12,9 @@ import PzRoot from '@akinon/next/components/pz-root';
12
12
  import MobileAppToggler from '@akinon/next/components/mobile-app-toggler';
13
13
  import pwaTags from '@theme/components/pwa-tags';
14
14
  import type { Viewport } from 'next';
15
+ import { PREVIEW_PZ_SEGMENT } from '@akinon/next/utils/preview';
16
+ import { isDraftRender } from '@akinon/next/utils/preview-render';
17
+ import { parsePzParams } from '@akinon/next/utils/pz-segments';
15
18
  import settings from '@theme/settings';
16
19
 
17
20
  export const viewport: Viewport = {
@@ -42,11 +45,34 @@ export async function generateMetadata() {
42
45
  return result;
43
46
  }
44
47
 
45
- async function RootLayout({ locale, translations, children }: RootLayoutProps) {
48
+ async function RootLayout({
49
+ locale,
50
+ translations,
51
+ params,
52
+ children
53
+ }: RootLayoutProps) {
54
+ // Widget preview indicator — reading draftMode does NOT opt the page into
55
+ // dynamic rendering; it is only true behind the /api/preview bypass cookie,
56
+ // and never in a render Next 14 is about to cache (utils/preview-render.ts).
57
+ const isPreview = isDraftRender();
58
+ // Which named preview is being viewed, so the banner says so — several can
59
+ // exist side by side and they look identical otherwise.
60
+ const previewId = parsePzParams(params, settings)[PREVIEW_PZ_SEGMENT] ?? '';
61
+
46
62
  return (
47
63
  <html lang={locale.isoCode} {...(locale.rtl ? { dir: 'rtl' } : {})}>
48
64
  <head />
49
65
  <body className="overflow-x-hidden">
66
+ {isPreview && (
67
+ <div className="fixed bottom-3 left-1/2 -translate-x-1/2 z-[9999] flex items-center gap-2 rounded bg-black/80 px-3 py-1.5 text-xs text-white">
68
+ <span>{previewId ? `Preview: ${previewId}` : 'Preview'}</span>
69
+ {/* Relative query-only href: exits preview and returns to the
70
+ SAME page via the middleware ?preview=exit interception. */}
71
+ <a href="?preview=exit" className="underline">
72
+ Exit
73
+ </a>
74
+ </div>
75
+ )}
50
76
  <PzRoot translations={translations} locale={locale.value}>
51
77
  <ClientRoot>
52
78
  <div className="overflow-x-hidden">
@@ -0,0 +1,119 @@
1
+ import { isValidPreviewId, PREVIEW_ID_QUERY_PARAM } from '@akinon/next/utils/preview';
2
+ import {
3
+ DEFAULT_PREVIEW_LINK_TTL_SECONDS,
4
+ signPreviewToken
5
+ } from '@akinon/next/utils/preview-token';
6
+ import { NextRequest, NextResponse } from 'next/server';
7
+
8
+ // Next 14 caches GET route handlers by default; every call must mint a fresh
9
+ // token.
10
+ export const dynamic = 'force-dynamic';
11
+
12
+ /**
13
+ * Mints a signed, expiring preview entry token for the theme editor.
14
+ *
15
+ * The editor is a static SPA on another origin and must never hold the
16
+ * storefront's PREVIEW_SECRET. Instead it proves it is an editor user by
17
+ * forwarding its Omnitron session token; this handler verifies that token
18
+ * against Omnitron (`MAIN_APP_URL`) and answers with a token the editor puts
19
+ * on the link as `?preview=<token>`. The link opens exactly one preview and
20
+ * stops working after PREVIEW_LINK_TTL_SECONDS (default 24h).
21
+ *
22
+ * CORS is open on purpose: the request carries no cookies, only the caller's
23
+ * Omnitron token, and the response is worthless without one.
24
+ */
25
+ const CORS_HEADERS = {
26
+ 'Access-Control-Allow-Origin': '*',
27
+ 'Access-Control-Allow-Methods': 'GET, OPTIONS',
28
+ 'Access-Control-Allow-Headers': 'Authorization, Content-Type',
29
+ 'Access-Control-Max-Age': '600',
30
+ 'Cache-Control': 'no-store'
31
+ };
32
+
33
+ const json = (body: Record<string, unknown>, status: number) =>
34
+ NextResponse.json(body, { status, headers: CORS_HEADERS });
35
+
36
+ export async function OPTIONS() {
37
+ return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
38
+ }
39
+
40
+ /** Bounded wait for Omnitron — an unreachable host must fail fast, not hang the editor. */
41
+ const OMNITRON_TIMEOUT_MS = 8_000;
42
+
43
+ /** True when Omnitron accepts the token as a signed-in user. */
44
+ const isEditorUser = async (
45
+ omnitronUrl: string,
46
+ authorization: string
47
+ ): Promise<boolean> => {
48
+ try {
49
+ const response = await fetch(
50
+ `${omnitronUrl.replace(/\/+$/, '')}/api/v1/active_user/`,
51
+ {
52
+ headers: { Authorization: authorization, Accept: 'application/json' },
53
+ cache: 'no-store',
54
+ signal: AbortSignal.timeout(OMNITRON_TIMEOUT_MS)
55
+ }
56
+ );
57
+ return response.status === 200;
58
+ } catch {
59
+ return false;
60
+ }
61
+ };
62
+
63
+ export async function GET(request: NextRequest) {
64
+ const secret = process.env.PREVIEW_SECRET;
65
+ const omnitronUrl = process.env.MAIN_APP_URL;
66
+ if (!secret || !omnitronUrl) {
67
+ return json(
68
+ {
69
+ error: 'preview-disabled',
70
+ message:
71
+ 'Preview links are disabled: PREVIEW_SECRET and MAIN_APP_URL must both be configured on the storefront.'
72
+ },
73
+ 503
74
+ );
75
+ }
76
+
77
+ const previewId =
78
+ request.nextUrl.searchParams.get(PREVIEW_ID_QUERY_PARAM) ?? '';
79
+ if (!isValidPreviewId(previewId)) {
80
+ return json(
81
+ { error: 'invalid-preview-id', message: 'A valid preview_id is required.' },
82
+ 400
83
+ );
84
+ }
85
+
86
+ const authorization = request.headers.get('authorization') ?? '';
87
+ if (!/^Token \S+$/i.test(authorization)) {
88
+ return json(
89
+ { error: 'unauthorized', message: 'An Omnitron token is required.' },
90
+ 401
91
+ );
92
+ }
93
+ if (!(await isEditorUser(omnitronUrl, authorization))) {
94
+ return json(
95
+ {
96
+ error: 'unauthorized',
97
+ message: `Omnitron at ${omnitronUrl} did not accept the token (or did not answer).`
98
+ },
99
+ 401
100
+ );
101
+ }
102
+
103
+ const ttl =
104
+ Number(process.env.PREVIEW_LINK_TTL_SECONDS) ||
105
+ DEFAULT_PREVIEW_LINK_TTL_SECONDS;
106
+ const expiresAt = Math.floor(Date.now() / 1000) + ttl;
107
+ const token = await signPreviewToken({ previewId, expiresAt }, secret);
108
+
109
+ return json(
110
+ {
111
+ token,
112
+ previewId,
113
+ expiresAt: new Date(expiresAt * 1000).toISOString(),
114
+ // Ready to append to any page url of this storefront.
115
+ query: `preview=${encodeURIComponent(token)}`
116
+ },
117
+ 200
118
+ );
119
+ }
@@ -0,0 +1,107 @@
1
+ import { PREVIEW_ID_COOKIE, PREVIEW_ID_QUERY_PARAM } from '@akinon/next/utils/preview';
2
+ import { verifyPreviewToken } from '@akinon/next/utils/preview-token';
3
+ import { cookies, draftMode } from 'next/headers';
4
+ import { NextRequest, NextResponse } from 'next/server';
5
+
6
+ // Next 14 caches GET route handlers by default; entering and leaving preview
7
+ // must run on every request.
8
+ export const dynamic = 'force-dynamic';
9
+
10
+ // Next 14's redirect() cannot carry a ';' in its url (it splits its own
11
+ // digest on ';' and the route answers 500), so the 307 is built by hand.
12
+ // Next still appends the cookies the handler set through draftMode() and
13
+ // cookies().
14
+ const redirectTo = (location: string) =>
15
+ new NextResponse(null, { status: 307, headers: { location } });
16
+
17
+ /**
18
+ * Enables/disables Next.js Draft Mode for widget previews, and records WHICH
19
+ * named preview is being viewed.
20
+ *
21
+ * Visitors never call this directly — any page url carrying `?preview` is
22
+ * redirected here by the middleware (see src/middleware.ts), and redirected
23
+ * back to the same page once the cookies are set. With draft mode on, the
24
+ * widget fetch layer serves `<slug>-preview-<id>` copies (falling back to
25
+ * live).
26
+ *
27
+ * The preview id rides in its own cookie rather than the url: pages keep
28
+ * their clean addresses and links followed afterwards stay inside the same
29
+ * preview. The cookie is folded into the pz route segment by the middleware
30
+ * (see settings.js `pzSegments`) because the widget pages are force-static,
31
+ * where Next.js blanks `cookies()` inside the render.
32
+ *
33
+ * Deny-by-default: entering preview ALWAYS requires `?preview=<token>` — a
34
+ * signed, expiring token minted by /api/preview/link for an editor user (see
35
+ * utils/preview-token.ts); PREVIEW_SECRET is the signing key and never
36
+ * travels in a url. Environments without the env have preview disabled
37
+ * entirely. Exiting never requires a token.
38
+ */
39
+ export async function GET(request: NextRequest) {
40
+ const { searchParams } = request.nextUrl;
41
+
42
+ // Only ever redirect within the site — the target comes from the query.
43
+ const target = searchParams.get('redirect') ?? '/';
44
+ let safeTarget =
45
+ target.startsWith('/') && !target.startsWith('//') ? target : '/';
46
+ // Never bounce back to a url that still carries ?preview — that would
47
+ // re-enter the middleware redirect and loop.
48
+ const targetUrl = new URL(safeTarget, request.nextUrl.origin);
49
+ targetUrl.searchParams.delete('preview');
50
+ targetUrl.searchParams.delete(PREVIEW_ID_QUERY_PARAM);
51
+ safeTarget = `${targetUrl.pathname}${targetUrl.search}`;
52
+ // URL normalization can reintroduce a protocol-relative target the first
53
+ // guard already rejected (e.g. '/..//evil.com' normalizes to pathname
54
+ // '//evil.com', which browsers resolve to https://evil.com) — re-check the
55
+ // FINAL string, not just the raw input.
56
+ if (!safeTarget.startsWith('/') || safeTarget.startsWith('//')) {
57
+ safeTarget = '/';
58
+ }
59
+
60
+ const draft = draftMode();
61
+ const cookieStore = cookies();
62
+
63
+ if (searchParams.has('exit')) {
64
+ draft.disable();
65
+ cookieStore.delete(PREVIEW_ID_COOKIE);
66
+ return redirectTo(safeTarget);
67
+ }
68
+
69
+ const secret = process.env.PREVIEW_SECRET;
70
+ if (!secret) {
71
+ return new NextResponse(
72
+ 'Preview is disabled: PREVIEW_SECRET is not configured.',
73
+ { status: 401 }
74
+ );
75
+ }
76
+
77
+ const verdict = await verifyPreviewToken(
78
+ searchParams.get('token') ?? '',
79
+ secret
80
+ );
81
+ if (verdict.ok !== true) {
82
+ const { reason } = verdict;
83
+ return new NextResponse(
84
+ reason === 'expired'
85
+ ? 'This preview link has expired — open the page from the editor again to get a fresh one.'
86
+ : 'Preview link is invalid or missing. Preview pages are opened from the theme editor.',
87
+ { status: 401 }
88
+ );
89
+ }
90
+ const { previewId } = verdict.claims;
91
+
92
+ draft.enable();
93
+ // Same flags Next gives its own draft cookie (SameSite=None; Secure outside
94
+ // development): the storefront may be embedded cross-site (editor,
95
+ // Omnitron), and a Lax id cookie would be dropped there while the draft
96
+ // cookie survives — draft mode on, no id, live content under a "Preview"
97
+ // banner. `nextUrl.protocol` is not consulted: behind a TLS-terminating
98
+ // proxy it reads http even in production.
99
+ const isDevelopment = process.env.NODE_ENV === 'development';
100
+ cookieStore.set(PREVIEW_ID_COOKIE, previewId, {
101
+ path: '/',
102
+ httpOnly: true,
103
+ sameSite: isDevelopment ? 'lax' : 'none',
104
+ secure: !isDevelopment
105
+ });
106
+ return redirectTo(safeTarget);
107
+ }
@@ -1,4 +1,5 @@
1
- import { withPzDefault } from '@akinon/next/middlewares';
1
+ import { PzNextRequest, withPzDefault } from '@akinon/next/middlewares';
2
+ import { PREVIEW_ID_QUERY_PARAM } from '@akinon/next/utils/preview';
2
3
  import { NextMiddleware, NextResponse } from 'next/server';
3
4
 
4
5
  /**
@@ -32,4 +33,47 @@ const middleware: NextMiddleware = () =>
32
33
  return NextResponse.next();
33
34
  };
34
35
 
35
- export default withPzDefault(middleware);
36
+ const pzMiddleware = withPzDefault(middleware);
37
+
38
+ const PREVIEW_EXIT_VALUES = new Set(['exit', 'off', '0', 'false']);
39
+
40
+ /**
41
+ * Widget preview entry: any page url carrying `?preview` is redirected to the
42
+ * draft-mode route handler (/api/preview), which sets/clears the bypass and
43
+ * preview-id cookies and returns to the same page without the params. Runs
44
+ * BEFORE withPzDefault so the redirect is not subject to its response
45
+ * rewriting (pz-override-response).
46
+ *
47
+ * The `?preview` value is a signed token naming WHICH preview to view (see
48
+ * utils/preview-token.ts); it travels only as far as the route handler, which
49
+ * stores the preview id for the rest of the session. A stray `preview_id`
50
+ * from an old-style link is dropped from the return url.
51
+ */
52
+ const previewEntryMiddleware: NextMiddleware = (req, event) => {
53
+ const url = req.nextUrl;
54
+
55
+ if (url.searchParams.has('preview')) {
56
+ const value = url.searchParams.get('preview') ?? '';
57
+ const target = url.clone();
58
+ target.searchParams.delete('preview');
59
+ target.searchParams.delete(PREVIEW_ID_QUERY_PARAM);
60
+
61
+ const previewApi = url.clone();
62
+ previewApi.pathname = '/api/preview';
63
+ previewApi.search = '';
64
+ previewApi.searchParams.set(
65
+ 'redirect',
66
+ `${target.pathname}${target.search}`
67
+ );
68
+ if (PREVIEW_EXIT_VALUES.has(value.toLowerCase())) {
69
+ previewApi.searchParams.set('exit', '1');
70
+ } else {
71
+ if (value) previewApi.searchParams.set('token', value);
72
+ }
73
+ return NextResponse.redirect(previewApi);
74
+ }
75
+
76
+ return pzMiddleware(req as PzNextRequest, event);
77
+ };
78
+
79
+ export default previewEntryMiddleware;
@@ -1,4 +1,8 @@
1
1
  const { LocaleUrlStrategy } = require('@akinon/next/localization');
2
+ const {
3
+ PREVIEW_PZ_SEGMENT,
4
+ resolvePreviewSegment
5
+ } = require('@akinon/next/utils/preview');
2
6
  const { ROUTES } = require('@theme/routes');
3
7
 
4
8
  const commerceUrl = encodeURI(process.env.SERVICE_BACKEND_URL ?? 'default');
@@ -64,5 +68,18 @@ module.exports = {
64
68
  },
65
69
  customNotFoundEnabled: false,
66
70
  usePzSegment: true,
71
+ pzSegments: {
72
+ segments: [
73
+ {
74
+ // Carries WHICH named preview is being viewed into the render. The
75
+ // widget pages are force-static, where cookies() is blanked, so the
76
+ // middleware folds the preview cookie into the route instead.
77
+ // Empty for ordinary visitors — nothing renders from preview unless
78
+ // draft mode is on as well.
79
+ name: PREVIEW_PZ_SEGMENT,
80
+ resolve: (context) => resolvePreviewSegment(context.req)
81
+ }
82
+ ]
83
+ },
67
84
  commerceRedirectionIgnoreList: ['/users/reset']
68
85
  };
@@ -0,0 +1,223 @@
1
+ import {
2
+ isPreviewCopySlug,
3
+ isPreviewSlug,
4
+ isValidPreviewId,
5
+ previewSlug,
6
+ resolvePreviewSegment
7
+ } from '@akinon/next/utils/preview';
8
+ import {
9
+ decodePzValue,
10
+ encodePzValue,
11
+ getPzSegmentsConfig
12
+ } from '@akinon/next/utils/pz-segments';
13
+ import {
14
+ signPreviewToken,
15
+ verifyPreviewToken
16
+ } from '@akinon/next/utils/preview-token';
17
+ import { webcrypto } from 'node:crypto';
18
+ import { TextEncoder as NodeTextEncoder } from 'node:util';
19
+
20
+ // jsdom ships neither Web Crypto nor TextEncoder; the token code needs both.
21
+ if (!globalThis.crypto?.subtle) {
22
+ Object.defineProperty(globalThis, 'crypto', { value: webcrypto });
23
+ }
24
+ if (typeof globalThis.TextEncoder === 'undefined') {
25
+ Object.defineProperty(globalThis, 'TextEncoder', { value: NodeTextEncoder });
26
+ }
27
+
28
+ /**
29
+ * The half of the preview contract this app owns: which named preview a
30
+ * request belongs to, and the widget slug that preview's copies live under.
31
+ * The theme editor builds the same slugs — if these change, previews stop
32
+ * resolving and every page silently falls back to live.
33
+ */
34
+
35
+ const req = (cookie?: string, draft = true) => ({
36
+ cookies: {
37
+ get: (name: string) => {
38
+ if (name === '__prerender_bypass') {
39
+ return draft ? { value: 'bypass' } : undefined;
40
+ }
41
+ return name === 'pz-preview-id' && cookie !== undefined
42
+ ? { value: cookie }
43
+ : undefined;
44
+ }
45
+ }
46
+ });
47
+
48
+ describe('preview id validation', () => {
49
+ it('accepts the ids the editor produces', () => {
50
+ expect(isValidPreviewId('staging')).toBe(true);
51
+ expect(isValidPreviewId('black-friday')).toBe(true);
52
+ expect(isValidPreviewId('kis-kampanyasi-2026')).toBe(true);
53
+ expect(isValidPreviewId('a'.repeat(32))).toBe(true);
54
+ });
55
+
56
+ it('rejects anything that could escape the slug', () => {
57
+ // Ids arrive from a cookie, so this is a trust boundary.
58
+ expect(isValidPreviewId('')).toBe(false);
59
+ expect(isValidPreviewId('-leading')).toBe(false);
60
+ expect(isValidPreviewId('Upper')).toBe(false);
61
+ expect(isValidPreviewId('has space')).toBe(false);
62
+ expect(isValidPreviewId('../etc')).toBe(false);
63
+ expect(isValidPreviewId('a'.repeat(33))).toBe(false);
64
+ expect(isValidPreviewId(undefined)).toBe(false);
65
+ // `--` is the pz segment separator; the editor never produces it.
66
+ expect(isValidPreviewId('a--b')).toBe(false);
67
+ expect(isValidPreviewId('trailing-')).toBe(false);
68
+ });
69
+ });
70
+
71
+ describe('preview slugs', () => {
72
+ it('suffixes the live slug with the preview id', () => {
73
+ expect(previewSlug('homepage-body-new', 'staging')).toBe(
74
+ 'homepage-body-new-preview-staging'
75
+ );
76
+ expect(previewSlug('homepage-body-new', 'black-friday')).toBe(
77
+ 'homepage-body-new-preview-black-friday'
78
+ );
79
+ });
80
+
81
+ it('recognises its own output so previews are never nested', () => {
82
+ expect(isPreviewSlug('homepage-body-new-preview-staging')).toBe(true);
83
+ expect(isPreviewSlug('homepage-body-new')).toBe(false);
84
+ // The old single-preview suffix carries no id and is not one of ours.
85
+ expect(isPreviewSlug('homepage-body-new-preview')).toBe(false);
86
+ });
87
+
88
+ it('only treats a slug as a copy when it belongs to THIS preview', () => {
89
+ expect(isPreviewCopySlug('header-preview-staging', 'staging')).toBe(true);
90
+ // Another preview's copy is not ours to skip.
91
+ expect(isPreviewCopySlug('header-preview-winter', 'staging')).toBe(false);
92
+ // A live custom page whose name merely looks like a copy still gets its
93
+ // overlay — the shape-only check used to skip it and render live.
94
+ expect(isPreviewCopySlug('page-summer-preview-2026', 'staging')).toBe(
95
+ false
96
+ );
97
+ expect(isPreviewCopySlug('header', '')).toBe(false);
98
+ });
99
+ });
100
+
101
+ describe('the preview id inside the pz segment', () => {
102
+ const settings = {
103
+ pzSegments: { segments: [{ name: 'preview' }] }
104
+ };
105
+ const config = getPzSegmentsConfig(settings);
106
+ const url = encodeURIComponent('https://shop.test/collections/summer--sale');
107
+
108
+ it('round-trips a url containing the separator with a preview id after it', () => {
109
+ const encoded = encodePzValue(
110
+ { locale: 'en', currency: 'usd', url, preview: 'staging' },
111
+ config
112
+ );
113
+ expect(decodePzValue(encoded, config)).toEqual({
114
+ locale: 'en',
115
+ currency: 'usd',
116
+ url,
117
+ preview: 'staging'
118
+ });
119
+ });
120
+
121
+ it('round-trips such a url when no preview is active', () => {
122
+ const encoded = encodePzValue(
123
+ { locale: 'en', currency: 'usd', url, preview: '' },
124
+ config
125
+ );
126
+ expect(decodePzValue(encoded, config)).toEqual({
127
+ locale: 'en',
128
+ currency: 'usd',
129
+ url,
130
+ preview: ''
131
+ });
132
+ });
133
+
134
+ it('still reads a plain segment positionally', () => {
135
+ expect(decodePzValue('tr--try--%2F--kis', config)).toEqual({
136
+ locale: 'tr',
137
+ currency: 'try',
138
+ url: '%2F',
139
+ preview: 'kis'
140
+ });
141
+ });
142
+ });
143
+
144
+ describe('resolvePreviewSegment', () => {
145
+ it('passes a valid cookie through to the pz segment', () => {
146
+ expect(resolvePreviewSegment(req('staging'))).toBe('staging');
147
+ });
148
+
149
+ it('yields no preview for a missing or tampered cookie', () => {
150
+ expect(resolvePreviewSegment(req())).toBe('');
151
+ expect(resolvePreviewSegment(req(''))).toBe('');
152
+ expect(resolvePreviewSegment(req('../../evil'))).toBe('');
153
+ expect(resolvePreviewSegment(req('Staging'))).toBe('');
154
+ });
155
+
156
+ it('ignores the id cookie unless draft mode is on', () => {
157
+ // An anonymous visitor forging pz-preview-id must not get a private
158
+ // cache key per request — without the draft cookie the id is inert.
159
+ expect(resolvePreviewSegment(req('staging', false))).toBe('');
160
+ });
161
+ });
162
+
163
+ describe('signed preview links', () => {
164
+ const secret = 'k3y';
165
+ const now = 1_800_000_000_000; // ms
166
+ const inAnHour = Math.floor(now / 1000) + 3600;
167
+
168
+ it('round-trips the preview id and expiry it was minted with', async () => {
169
+ const token = await signPreviewToken(
170
+ { previewId: 'kis-kampanyasi', expiresAt: inAnHour },
171
+ secret
172
+ );
173
+ expect(token.startsWith('v1.kis-kampanyasi.')).toBe(true);
174
+ expect(await verifyPreviewToken(token, secret, now)).toEqual({
175
+ ok: true,
176
+ claims: { previewId: 'kis-kampanyasi', expiresAt: inAnHour }
177
+ });
178
+ });
179
+
180
+ it('dies at its expiry', async () => {
181
+ const token = await signPreviewToken(
182
+ { previewId: 'staging', expiresAt: inAnHour },
183
+ secret
184
+ );
185
+ expect(await verifyPreviewToken(token, secret, inAnHour * 1000)).toEqual({
186
+ ok: false,
187
+ reason: 'expired'
188
+ });
189
+ });
190
+
191
+ it('rejects tampering with the id, the expiry or the key', async () => {
192
+ const token = await signPreviewToken(
193
+ { previewId: 'staging', expiresAt: inAnHour },
194
+ secret
195
+ );
196
+ const [v, , exp, sig] = token.split('.');
197
+ expect(
198
+ (await verifyPreviewToken(`${v}.winter.${exp}.${sig}`, secret, now)).ok
199
+ ).toBe(false);
200
+ expect(
201
+ (await verifyPreviewToken(`${v}.staging.${exp + 1}.${sig}`, secret, now))
202
+ .ok
203
+ ).toBe(false);
204
+ expect((await verifyPreviewToken(token, 'other-key', now)).ok).toBe(false);
205
+ });
206
+
207
+ it('treats anything else as malformed — including the old raw secret', async () => {
208
+ expect(await verifyPreviewToken('pz-preview-local', secret, now)).toEqual({
209
+ ok: false,
210
+ reason: 'malformed'
211
+ });
212
+ expect((await verifyPreviewToken('', secret, now)).ok).toBe(false);
213
+ expect(
214
+ (await verifyPreviewToken('v1.Bad Id.123.sig', secret, now)).ok
215
+ ).toBe(false);
216
+ });
217
+
218
+ it('refuses to mint for an invalid id', async () => {
219
+ await expect(
220
+ signPreviewToken({ previewId: '../x', expiresAt: inAnHour }, secret)
221
+ ).rejects.toThrow();
222
+ });
223
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akinon/projectzero",
3
- "version": "1.126.15-v1-rc.12",
3
+ "version": "1.126.15",
4
4
  "private": false,
5
5
  "description": "CLI tool to manage your Project Zero Next project",
6
6
  "bin": {