@akinon/projectzero 2.0.79-beta.1 → 2.0.79-rc.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.
@@ -1,115 +0,0 @@
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
- /**
9
- * Mints a signed, expiring preview entry token for the theme editor.
10
- *
11
- * The editor is a static SPA on another origin and must never hold the
12
- * storefront's PREVIEW_SECRET. Instead it proves it is an editor user by
13
- * forwarding its Omnitron session token; this handler verifies that token
14
- * against Omnitron (`OMNITRON_URL`) and answers with a token the editor puts
15
- * on the link as `?preview=<token>`. The link opens exactly one preview and
16
- * stops working after PREVIEW_LINK_TTL_SECONDS (default 24h).
17
- *
18
- * CORS is open on purpose: the request carries no cookies, only the caller's
19
- * Omnitron token, and the response is worthless without one.
20
- */
21
- const CORS_HEADERS = {
22
- 'Access-Control-Allow-Origin': '*',
23
- 'Access-Control-Allow-Methods': 'GET, OPTIONS',
24
- 'Access-Control-Allow-Headers': 'Authorization, Content-Type',
25
- 'Access-Control-Max-Age': '600',
26
- 'Cache-Control': 'no-store'
27
- };
28
-
29
- const json = (body: Record<string, unknown>, status: number) =>
30
- NextResponse.json(body, { status, headers: CORS_HEADERS });
31
-
32
- export async function OPTIONS() {
33
- return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
34
- }
35
-
36
- /** Bounded wait for Omnitron — an unreachable host must fail fast, not hang the editor. */
37
- const OMNITRON_TIMEOUT_MS = 8_000;
38
-
39
- /** True when Omnitron accepts the token as a signed-in user. */
40
- const isEditorUser = async (
41
- omnitronUrl: string,
42
- authorization: string
43
- ): Promise<boolean> => {
44
- try {
45
- const response = await fetch(
46
- `${omnitronUrl.replace(/\/+$/, '')}/api/v1/active_user/`,
47
- {
48
- headers: { Authorization: authorization, Accept: 'application/json' },
49
- cache: 'no-store',
50
- signal: AbortSignal.timeout(OMNITRON_TIMEOUT_MS)
51
- }
52
- );
53
- return response.status === 200;
54
- } catch {
55
- return false;
56
- }
57
- };
58
-
59
- export async function GET(request: NextRequest) {
60
- const secret = process.env.PREVIEW_SECRET;
61
- const omnitronUrl = process.env.OMNITRON_URL;
62
- if (!secret || !omnitronUrl) {
63
- return json(
64
- {
65
- error: 'preview-disabled',
66
- message:
67
- 'Preview links are disabled: PREVIEW_SECRET and OMNITRON_URL must both be configured on the storefront.'
68
- },
69
- 503
70
- );
71
- }
72
-
73
- const previewId =
74
- request.nextUrl.searchParams.get(PREVIEW_ID_QUERY_PARAM) ?? '';
75
- if (!isValidPreviewId(previewId)) {
76
- return json(
77
- { error: 'invalid-preview-id', message: 'A valid preview_id is required.' },
78
- 400
79
- );
80
- }
81
-
82
- const authorization = request.headers.get('authorization') ?? '';
83
- if (!/^Token \S+$/i.test(authorization)) {
84
- return json(
85
- { error: 'unauthorized', message: 'An Omnitron token is required.' },
86
- 401
87
- );
88
- }
89
- if (!(await isEditorUser(omnitronUrl, authorization))) {
90
- return json(
91
- {
92
- error: 'unauthorized',
93
- message: `Omnitron at ${omnitronUrl} did not accept the token (or did not answer).`
94
- },
95
- 401
96
- );
97
- }
98
-
99
- const ttl =
100
- Number(process.env.PREVIEW_LINK_TTL_SECONDS) ||
101
- DEFAULT_PREVIEW_LINK_TTL_SECONDS;
102
- const expiresAt = Math.floor(Date.now() / 1000) + ttl;
103
- const token = await signPreviewToken({ previewId, expiresAt }, secret);
104
-
105
- return json(
106
- {
107
- token,
108
- previewId,
109
- expiresAt: new Date(expiresAt * 1000).toISOString(),
110
- // Ready to append to any page url of this storefront.
111
- query: `preview=${encodeURIComponent(token)}`
112
- },
113
- 200
114
- );
115
- }
@@ -1,96 +0,0 @@
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 { redirect } from 'next/navigation';
5
- import { NextRequest, NextResponse } from 'next/server';
6
-
7
- /**
8
- * Enables/disables Next.js Draft Mode for widget previews, and records WHICH
9
- * named preview is being viewed.
10
- *
11
- * Visitors never call this directly — any page url carrying `?preview` is
12
- * redirected here by the middleware (see src/proxy.ts), and redirected back
13
- * to the same page once the cookies are set. With draft mode on, the widget
14
- * fetch layer serves `<slug>-preview-<id>` copies (falling back to live).
15
- *
16
- * The preview id rides in its own cookie rather than the url: pages keep
17
- * their clean addresses and links followed afterwards stay inside the same
18
- * preview. The cookie is folded into the pz route segment by the middleware
19
- * (see settings.js `pzSegments`) because the widget pages are force-static,
20
- * where Next.js blanks `cookies()` inside the render.
21
- *
22
- * Deny-by-default: entering preview ALWAYS requires `?preview=<token>` — a
23
- * signed, expiring token minted by /api/preview/link for an editor user (see
24
- * utils/preview-token.ts); PREVIEW_SECRET is the signing key and never
25
- * travels in a url. Environments without the env have preview disabled
26
- * entirely. Exiting never requires a token.
27
- */
28
- export async function GET(request: NextRequest) {
29
- const { searchParams } = request.nextUrl;
30
-
31
- // Only ever redirect within the site — the target comes from the query.
32
- const target = searchParams.get('redirect') ?? '/';
33
- let safeTarget =
34
- target.startsWith('/') && !target.startsWith('//') ? target : '/';
35
- // Never bounce back to a url that still carries ?preview — that would
36
- // re-enter the middleware redirect and loop.
37
- const targetUrl = new URL(safeTarget, request.nextUrl.origin);
38
- targetUrl.searchParams.delete('preview');
39
- targetUrl.searchParams.delete(PREVIEW_ID_QUERY_PARAM);
40
- safeTarget = `${targetUrl.pathname}${targetUrl.search}`;
41
- // URL normalization can reintroduce a protocol-relative target the first
42
- // guard already rejected (e.g. '/..//evil.com' normalizes to pathname
43
- // '//evil.com', which browsers resolve to https://evil.com) — re-check the
44
- // FINAL string, not just the raw input.
45
- if (!safeTarget.startsWith('/') || safeTarget.startsWith('//')) {
46
- safeTarget = '/';
47
- }
48
-
49
- const draft = await draftMode();
50
- const cookieStore = await cookies();
51
-
52
- if (searchParams.has('exit')) {
53
- draft.disable();
54
- cookieStore.delete(PREVIEW_ID_COOKIE);
55
- redirect(safeTarget);
56
- }
57
-
58
- const secret = process.env.PREVIEW_SECRET;
59
- if (!secret) {
60
- return new NextResponse(
61
- 'Preview is disabled: PREVIEW_SECRET is not configured.',
62
- { status: 401 }
63
- );
64
- }
65
-
66
- const verdict = await verifyPreviewToken(
67
- searchParams.get('token') ?? '',
68
- secret
69
- );
70
- if (verdict.ok !== true) {
71
- const { reason } = verdict;
72
- return new NextResponse(
73
- reason === 'expired'
74
- ? 'This preview link has expired — open the page from the editor again to get a fresh one.'
75
- : 'Preview link is invalid or missing. Preview pages are opened from the theme editor.',
76
- { status: 401 }
77
- );
78
- }
79
- const { previewId } = verdict.claims;
80
-
81
- draft.enable();
82
- // Same flags Next gives its own draft cookie (SameSite=None; Secure outside
83
- // development): the storefront may be embedded cross-site (editor,
84
- // Omnitron), and a Lax id cookie would be dropped there while the draft
85
- // cookie survives — draft mode on, no id, live content under a "Preview"
86
- // banner. `nextUrl.protocol` is not consulted: behind a TLS-terminating
87
- // proxy it reads http even in production.
88
- const isDevelopment = process.env.NODE_ENV === 'development';
89
- cookieStore.set(PREVIEW_ID_COOKIE, previewId, {
90
- path: '/',
91
- httpOnly: true,
92
- sameSite: isDevelopment ? 'lax' : 'none',
93
- secure: !isDevelopment
94
- });
95
- redirect(safeTarget);
96
- }
@@ -1,223 +0,0 @@
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
- });