@cmssy/next 2.7.0 → 4.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/dist/index.cjs CHANGED
@@ -6,8 +6,8 @@ var react = require('@cmssy/react');
6
6
  var client = require('@cmssy/react/client');
7
7
  var jose = require('jose');
8
8
  var jsxRuntime = require('react/jsx-runtime');
9
- var crypto$1 = require('crypto');
10
9
  var server = require('next/server');
10
+ var crypto$1 = require('crypto');
11
11
 
12
12
  // src/create-cmssy-page.tsx
13
13
  var CMSSY_SESSION_COOKIE = "cmssy_session";
@@ -204,11 +204,63 @@ function applyCmssyCsp(response, options) {
204
204
  response.headers.delete("X-Frame-Options");
205
205
  return response;
206
206
  }
207
- var EDIT_QUERY_PARAM = "cmssyEdit";
207
+
208
+ // src/secret-match.ts
209
+ var MAX_SECRET_LENGTH = 256;
210
+ async function cmssySecretsMatch(a, b) {
211
+ if (a.length > MAX_SECRET_LENGTH || b.length > MAX_SECRET_LENGTH) {
212
+ return false;
213
+ }
214
+ const encoder = new TextEncoder();
215
+ const [ha, hb] = await Promise.all([
216
+ crypto.subtle.digest("SHA-256", encoder.encode(a)),
217
+ crypto.subtle.digest("SHA-256", encoder.encode(b))
218
+ ]);
219
+ const va = new Uint8Array(ha);
220
+ const vb = new Uint8Array(hb);
221
+ let diff = 0;
222
+ for (let i = 0; i < va.length; i += 1) {
223
+ diff |= (va[i] ?? 0) ^ (vb[i] ?? 0);
224
+ }
225
+ return diff === 0;
226
+ }
227
+
228
+ // src/edit-mode.ts
229
+ var CMSSY_EDIT_HEADER = "x-cmssy-edit";
230
+ var CMSSY_EDIT_QUERY_PARAM = "cmssyEdit";
231
+ var CMSSY_SECRET_QUERY_PARAM = "cmssySecret";
232
+ async function isCmssyEditRequest(request2, config) {
233
+ if (request2.cookies.has("__prerender_bypass")) return true;
234
+ if (!request2.nextUrl.searchParams.getAll(CMSSY_EDIT_QUERY_PARAM).includes("1")) {
235
+ return false;
236
+ }
237
+ const provided = request2.nextUrl.searchParams.get(CMSSY_SECRET_QUERY_PARAM);
238
+ if (!provided || !config.draftSecret) return false;
239
+ return cmssySecretsMatch(provided, config.draftSecret);
240
+ }
241
+ async function isCmssyEditMode() {
242
+ const h = await headers.headers();
243
+ return h.get(CMSSY_EDIT_HEADER) === "1";
244
+ }
208
245
  function hasEditFlag(value) {
209
246
  return Array.isArray(value) ? value.includes("1") : value === "1";
210
247
  }
248
+ function firstValue(value) {
249
+ return Array.isArray(value) ? value[0] : value;
250
+ }
251
+ async function resolveEditorRequest(query, draftSecret) {
252
+ if (!hasEditFlag(query[CMSSY_EDIT_QUERY_PARAM])) return false;
253
+ const provided = firstValue(query[CMSSY_SECRET_QUERY_PARAM]);
254
+ if (!provided || !draftSecret) return false;
255
+ return cmssySecretsMatch(provided, draftSecret);
256
+ }
211
257
  function createCmssyPage(config, blocks, options) {
258
+ return buildCmssyPageRenderer(config, blocks, options, false);
259
+ }
260
+ function createCmssyEditPage(config, blocks, options) {
261
+ return buildCmssyPageRenderer(config, blocks, options, true);
262
+ }
263
+ function buildCmssyPageRenderer(config, blocks, options, editRoute) {
212
264
  if (!Array.isArray(blocks)) {
213
265
  throw new Error(
214
266
  "cmssy: createCmssyPage(config, blocks) requires a blocks array \u2014 pass your defineBlock(...) array"
@@ -228,10 +280,16 @@ function createCmssyPage(config, blocks, options) {
228
280
  }) {
229
281
  const path = fixedPath ?? (params ? (await params).path ?? void 0 : void 0);
230
282
  const { isEnabled } = await headers.draftMode();
231
- const query = searchParams ? await searchParams : {};
232
- const editMode = isEnabled || hasEditFlag(query[EDIT_QUERY_PARAM]);
283
+ let editorActive = false;
284
+ if (editRoute) {
285
+ const query = searchParams ? await searchParams : {};
286
+ editorActive = await resolveEditorRequest(query, config.draftSecret);
287
+ if (!editorActive) {
288
+ navigation.notFound();
289
+ }
290
+ }
291
+ const editMode = isEnabled || editorActive;
233
292
  const devAllowed = isDevelopment() && Boolean(config.devToken?.trim());
234
- const editorActive = editMode;
235
293
  let locale;
236
294
  let pagePath = path;
237
295
  let defaultLocale;
@@ -340,6 +398,23 @@ function resolveBridgeOrigin(editorOrigin) {
340
398
  }
341
399
  return origins.length === 1 ? origins[0] : origins;
342
400
  }
401
+ var CMSSY_EDIT_PATH_PREFIX = "/__cmssy/edit";
402
+ async function cmssyEditRewrite(request2, config) {
403
+ const { pathname, searchParams } = request2.nextUrl;
404
+ if (pathname.startsWith(CMSSY_EDIT_PATH_PREFIX)) return null;
405
+ if (!searchParams.getAll(CMSSY_EDIT_QUERY_PARAM).includes("1")) return null;
406
+ const provided = searchParams.get(CMSSY_SECRET_QUERY_PARAM);
407
+ if (!provided || !config.draftSecret) return null;
408
+ if (!await cmssySecretsMatch(provided, config.draftSecret)) return null;
409
+ const url = request2.nextUrl.clone();
410
+ url.pathname = `${CMSSY_EDIT_PATH_PREFIX}${pathname === "/" ? "" : pathname}`;
411
+ return server.NextResponse.rewrite(url);
412
+ }
413
+ function createCmssyEditMiddleware(config) {
414
+ return async function cmssyEditMiddleware(request2) {
415
+ return await cmssyEditRewrite(request2, config) ?? server.NextResponse.next();
416
+ };
417
+ }
343
418
  function DefaultNotFound() {
344
419
  return /* @__PURE__ */ jsxRuntime.jsxs(
345
420
  "main",
@@ -595,11 +670,6 @@ async function buildCmssyMetadata(config, path, options = {}) {
595
670
  };
596
671
  }
597
672
  var MIN_SECRET_LENGTH = 16;
598
- function secretsMatch(a, b) {
599
- const ha = crypto$1.createHash("sha256").update(a).digest();
600
- const hb = crypto$1.createHash("sha256").update(b).digest();
601
- return crypto$1.timingSafeEqual(ha, hb);
602
- }
603
673
  function safeRedirect(redirect2, fallback) {
604
674
  if (!redirect2 || !redirect2.startsWith("/")) return fallback;
605
675
  if (redirect2.startsWith("//") || redirect2.includes("\\")) return fallback;
@@ -620,15 +690,22 @@ function createDraftRoute(config) {
620
690
  );
621
691
  }
622
692
  return async function GET(request2) {
693
+ const url = new URL(request2.url);
694
+ if (url.searchParams.get("disable") === "1") {
695
+ const draft2 = await headers.draftMode();
696
+ draft2.disable();
697
+ navigation.redirect(
698
+ safeRedirect(url.searchParams.get("redirect"), fallbackRedirect)
699
+ );
700
+ }
623
701
  if (config.draftSecret.length < MIN_SECRET_LENGTH) {
624
702
  return new Response(
625
703
  `cmssy: draftSecret must be at least ${MIN_SECRET_LENGTH} characters`,
626
704
  { status: 500 }
627
705
  );
628
706
  }
629
- const url = new URL(request2.url);
630
707
  const secret = url.searchParams.get("secret");
631
- if (!secret || !secretsMatch(secret, config.draftSecret)) {
708
+ if (!secret || !await cmssySecretsMatch(secret, config.draftSecret)) {
632
709
  return new Response("Invalid draft secret", { status: 401 });
633
710
  }
634
711
  const location = safeRedirect(
@@ -640,14 +717,6 @@ function createDraftRoute(config) {
640
717
  navigation.redirect(location);
641
718
  };
642
719
  }
643
- var CMSSY_EDIT_HEADER = "x-cmssy-edit";
644
- function isCmssyEditRequest(request2) {
645
- return request2.cookies.has("__prerender_bypass") || request2.nextUrl.searchParams.getAll("cmssyEdit").includes("1");
646
- }
647
- async function isCmssyEditMode() {
648
- const h = await headers.headers();
649
- return h.get(CMSSY_EDIT_HEADER) === "1";
650
- }
651
720
  var CMSSY_LOCALE_HEADER = "x-cmssy-locale";
652
721
  async function localeForPathname(config, pathname) {
653
722
  const siteLocales = await react.resolveSiteLocales(config);
@@ -658,7 +727,12 @@ async function splitCmssyLocale(config, path) {
658
727
  const siteLocales = await react.resolveSiteLocales(config);
659
728
  return react.splitLocaleFromPath(path, siteLocales);
660
729
  }
661
- async function getCmssyLocale(config) {
730
+ async function getCmssyLocale(config, options) {
731
+ if (options?.path !== void 0) {
732
+ const siteLocales = await react.resolveSiteLocales(config);
733
+ const segments = Array.isArray(options.path) ? options.path.filter(Boolean) : options.path.split("/").filter(Boolean);
734
+ return react.splitLocaleFromPath(segments, siteLocales).locale;
735
+ }
662
736
  const { headers: headers3 } = await import('next/headers');
663
737
  const headerList = await headers3();
664
738
  const fromHeader = headerList.get(CMSSY_LOCALE_HEADER);
@@ -1918,7 +1992,10 @@ Object.defineProperty(exports, "resolveApiUrl", {
1918
1992
  });
1919
1993
  exports.CMSSY_CART_COOKIE = CMSSY_CART_COOKIE;
1920
1994
  exports.CMSSY_EDIT_HEADER = CMSSY_EDIT_HEADER;
1995
+ exports.CMSSY_EDIT_PATH_PREFIX = CMSSY_EDIT_PATH_PREFIX;
1996
+ exports.CMSSY_EDIT_QUERY_PARAM = CMSSY_EDIT_QUERY_PARAM;
1921
1997
  exports.CMSSY_LOCALE_HEADER = CMSSY_LOCALE_HEADER;
1998
+ exports.CMSSY_SECRET_QUERY_PARAM = CMSSY_SECRET_QUERY_PARAM;
1922
1999
  exports.CMSSY_SESSION_COOKIE = CMSSY_SESSION_COOKIE;
1923
2000
  exports.CmssyWebhookError = CmssyWebhookError;
1924
2001
  exports.DEFAULT_CMSSY_EDITOR_ORIGINS = DEFAULT_CMSSY_EDITOR_ORIGINS;
@@ -1927,9 +2004,12 @@ exports.applyCmssyCsp = applyCmssyCsp;
1927
2004
  exports.assertAuthConfig = assertAuthConfig;
1928
2005
  exports.buildCmssyMetadata = buildCmssyMetadata;
1929
2006
  exports.cmssyCspHeaders = cmssyCspHeaders;
2007
+ exports.cmssyEditRewrite = cmssyEditRewrite;
1930
2008
  exports.createCmssyAuthMiddleware = createCmssyAuthMiddleware;
1931
2009
  exports.createCmssyAuthRoute = createCmssyAuthRoute;
1932
2010
  exports.createCmssyCartRoute = createCmssyCartRoute;
2011
+ exports.createCmssyEditMiddleware = createCmssyEditMiddleware;
2012
+ exports.createCmssyEditPage = createCmssyEditPage;
1933
2013
  exports.createCmssyLocaleMiddleware = createCmssyLocaleMiddleware;
1934
2014
  exports.createCmssyNotFound = createCmssyNotFound;
1935
2015
  exports.createCmssyOrdersRoute = createCmssyOrdersRoute;
package/dist/index.d.cts CHANGED
@@ -5,8 +5,8 @@ export { DEFAULT_CMSSY_API_URL, FieldCondition, FieldConditionGroup, FieldCondit
5
5
  import { EditBridgeConfig } from '@cmssy/react/client';
6
6
  import { CmssyAuthConfig, CmssySessionPayload, SessionCookieOptions, CmssySessionUser, FetchProductOptions, CmssyProduct, FetchProductsOptions, CmssyProductPage, FetchOrderByTokenOptions, CmssyOrder, VerifyCmssyWebhookOptions, CmssyWebhookEvent } from '@cmssy/types';
7
7
  export { CmssyAuthConfig, CmssyProductPage, CmssySessionPayload, CmssySessionUser, CmssyStockState, CmssyWebhookEvent, CmssyWebhookOrder, FetchOrderByTokenOptions, FetchProductOptions, FetchProductsOptions, MyOrdersResult, SessionCookieOptions, VerifyCmssyWebhookOptions } from '@cmssy/types';
8
- import { MetadataRoute, Metadata } from 'next';
9
8
  import { NextRequest, NextResponse } from 'next/server';
9
+ import { MetadataRoute, Metadata } from 'next';
10
10
 
11
11
  declare const DEFAULT_CMSSY_EDITOR_ORIGINS: string[];
12
12
  declare function resolveEditorOrigin(editorOrigin: string | string[] | undefined): string | string[];
@@ -88,7 +88,51 @@ interface CatchAllProps {
88
88
  params?: Promise<CatchAllParams>;
89
89
  searchParams?: Promise<SearchParams>;
90
90
  }
91
+ /**
92
+ * Public catch-all page. Statically renderable: it never reads
93
+ * `searchParams` or `headers()` - awaiting either forces every route
94
+ * dynamic and kills ISR (CMS-952). Draft-mode preview (the authenticated
95
+ * /api/draft cookie) still works per-request via `draftMode()`, which is
96
+ * static-safe, and renders draft CONTENT without the editor (CMS-948).
97
+ * The editor-iframe flow (`?cmssyEdit=1&cmssySecret=...`) is served by
98
+ * the middleware rewrite (`cmssyEditRewrite`) onto the dynamic edit route
99
+ * mounted with `createCmssyEditPage`.
100
+ */
91
101
  declare function createCmssyPage(config: CmssyNextConfig, blocks: BlockDefinition[], options?: CreateCmssyPageOptions): ({ params, searchParams, }: CatchAllProps) => Promise<react_jsx_runtime.JSX.Element>;
102
+ /**
103
+ * Editor page for the middleware-rewritten edit route
104
+ * (`app/__cmssy/edit/[[...path]]/page.tsx`, `dynamic = "force-dynamic"`).
105
+ * Re-verifies the `cmssyEdit` + `cmssySecret` pair itself, so a direct hit
106
+ * on the route path (bypassing the middleware) cannot mount the editor.
107
+ */
108
+ declare function createCmssyEditPage(config: CmssyNextConfig, blocks: BlockDefinition[], options?: CreateCmssyPageOptions): ({ params, searchParams, }: CatchAllProps) => Promise<react_jsx_runtime.JSX.Element>;
109
+
110
+ /**
111
+ * Where the middleware rewrites editor-preview traffic. Mount the matching
112
+ * dynamic route in the consumer app:
113
+ *
114
+ * app/__cmssy/edit/[[...path]]/page.tsx
115
+ * export const dynamic = "force-dynamic";
116
+ * export default createCmssyEditPage(cmssy, blocks, { editor: Editor });
117
+ */
118
+ declare const CMSSY_EDIT_PATH_PREFIX = "/__cmssy/edit";
119
+ /**
120
+ * Rewrite VERIFIED editor requests (`cmssyEdit=1` + a `cmssySecret` matching
121
+ * the site's draft secret, CMS-948) onto the dedicated dynamic edit route, so
122
+ * the public catch-all can stay fully static. A static page never sees its
123
+ * query string - this rewrite is what makes the editor iframe work at all
124
+ * once ISR is on. Draft-mode preview (the authenticated /api/draft cookie) is
125
+ * NOT rewritten: it renders draft content on the public route via
126
+ * `draftMode()`, without the editor. Returns null for normal traffic so it
127
+ * composes with other middleware.
128
+ */
129
+ declare function cmssyEditRewrite(request: NextRequest, config: {
130
+ draftSecret: string;
131
+ }): Promise<NextResponse | null>;
132
+ /** Standalone middleware when the consumer has no other middleware to compose. */
133
+ declare function createCmssyEditMiddleware(config: {
134
+ draftSecret: string;
135
+ }): (request: NextRequest) => Promise<NextResponse>;
92
136
 
93
137
  interface CreateCmssyNotFoundOptions {
94
138
  /**
@@ -196,6 +240,8 @@ declare function cmssyCspHeaders(options: CmssyCspOptions): Record<string, strin
196
240
  declare function applyCmssyCsp<T extends MutableHeaders>(response: T, options: CmssyCspOptions): T;
197
241
 
198
242
  declare const CMSSY_EDIT_HEADER = "x-cmssy-edit";
243
+ declare const CMSSY_EDIT_QUERY_PARAM = "cmssyEdit";
244
+ declare const CMSSY_SECRET_QUERY_PARAM = "cmssySecret";
199
245
  interface EditRequestLike {
200
246
  cookies: {
201
247
  has: (name: string) => boolean;
@@ -203,10 +249,13 @@ interface EditRequestLike {
203
249
  nextUrl: {
204
250
  searchParams: {
205
251
  getAll: (name: string) => string[];
252
+ get: (name: string) => string | null;
206
253
  };
207
254
  };
208
255
  }
209
- declare function isCmssyEditRequest(request: EditRequestLike): boolean;
256
+ declare function isCmssyEditRequest(request: EditRequestLike, config: {
257
+ draftSecret: string;
258
+ }): Promise<boolean>;
210
259
  declare function isCmssyEditMode(): Promise<boolean>;
211
260
 
212
261
  declare const CMSSY_LOCALE_HEADER = "x-cmssy-locale";
@@ -215,7 +264,15 @@ declare function splitCmssyLocale(config: CmssyClientConfig, path: string[] | un
215
264
  locale: string;
216
265
  path: string[] | undefined;
217
266
  }>;
218
- declare function getCmssyLocale(config: CmssyClientConfig): Promise<string>;
267
+ /**
268
+ * Resolve the active locale. Pass `options.path` (route params) wherever
269
+ * possible - that path is static-safe. The no-argument form falls back to the
270
+ * `x-cmssy-locale` request header, which reads `headers()` and forces the
271
+ * calling route into dynamic rendering.
272
+ */
273
+ declare function getCmssyLocale(config: CmssyClientConfig, options?: {
274
+ path?: string | string[];
275
+ }): Promise<string>;
219
276
 
220
277
  /**
221
278
  * Resolves the active locale from a pathname's leading segment. Uses the
@@ -299,4 +356,4 @@ declare class CmssyWebhookError extends Error {
299
356
  */
300
357
  declare function verifyCmssyWebhook(options: VerifyCmssyWebhookOptions): CmssyWebhookEvent;
301
358
 
302
- export { type BuildCmssyMetadataOptions, CMSSY_CART_COOKIE, CMSSY_EDIT_HEADER, CMSSY_LOCALE_HEADER, CMSSY_SESSION_COOKIE, type CmssyAuthMiddleware, type CmssyAuthRouteHandlers, type CmssyCartRouteHandlers, type CmssyCspOptions, type CmssyDraftRouteConfig, type CmssyEditorProps, type CmssyEnvConfig, type CmssyNextConfig, type CmssyOrdersRouteHandlers, CmssyWebhookError, type CreateCmssyNotFoundOptions, type CreateCmssyPageOptions, type CreateCmssyRobotsOptions, type CreateCmssySitemapOptions, DEFAULT_CMSSY_EDITOR_ORIGINS, SESSION_MAX_AGE_SECONDS, applyCmssyCsp, assertAuthConfig, buildCmssyMetadata, cmssyCspHeaders, createCmssyAuthMiddleware, createCmssyAuthRoute, createCmssyCartRoute, createCmssyLocaleMiddleware, createCmssyNotFound, createCmssyOrdersRoute, createCmssyPage, createCmssyRobots, createCmssySitemap, createDraftRoute, defineCmssyConfig, fetchOrderByToken, fetchProduct, fetchProducts, getCmssyAccessToken, getCmssyLocale, getCmssyUser, isAccessExpired, isCmssyEditMode, isCmssyEditRequest, localeForPathname, openSession, resolveEditorOrigin, resolveLocaleFromPathname, sealSession, sessionCookieOptions, splitCmssyLocale, verifyCmssyWebhook };
359
+ export { type BuildCmssyMetadataOptions, CMSSY_CART_COOKIE, CMSSY_EDIT_HEADER, CMSSY_EDIT_PATH_PREFIX, CMSSY_EDIT_QUERY_PARAM, CMSSY_LOCALE_HEADER, CMSSY_SECRET_QUERY_PARAM, CMSSY_SESSION_COOKIE, type CmssyAuthMiddleware, type CmssyAuthRouteHandlers, type CmssyCartRouteHandlers, type CmssyCspOptions, type CmssyDraftRouteConfig, type CmssyEditorProps, type CmssyEnvConfig, type CmssyNextConfig, type CmssyOrdersRouteHandlers, CmssyWebhookError, type CreateCmssyNotFoundOptions, type CreateCmssyPageOptions, type CreateCmssyRobotsOptions, type CreateCmssySitemapOptions, DEFAULT_CMSSY_EDITOR_ORIGINS, SESSION_MAX_AGE_SECONDS, applyCmssyCsp, assertAuthConfig, buildCmssyMetadata, cmssyCspHeaders, cmssyEditRewrite, createCmssyAuthMiddleware, createCmssyAuthRoute, createCmssyCartRoute, createCmssyEditMiddleware, createCmssyEditPage, createCmssyLocaleMiddleware, createCmssyNotFound, createCmssyOrdersRoute, createCmssyPage, createCmssyRobots, createCmssySitemap, createDraftRoute, defineCmssyConfig, fetchOrderByToken, fetchProduct, fetchProducts, getCmssyAccessToken, getCmssyLocale, getCmssyUser, isAccessExpired, isCmssyEditMode, isCmssyEditRequest, localeForPathname, openSession, resolveEditorOrigin, resolveLocaleFromPathname, sealSession, sessionCookieOptions, splitCmssyLocale, verifyCmssyWebhook };
package/dist/index.d.ts CHANGED
@@ -5,8 +5,8 @@ export { DEFAULT_CMSSY_API_URL, FieldCondition, FieldConditionGroup, FieldCondit
5
5
  import { EditBridgeConfig } from '@cmssy/react/client';
6
6
  import { CmssyAuthConfig, CmssySessionPayload, SessionCookieOptions, CmssySessionUser, FetchProductOptions, CmssyProduct, FetchProductsOptions, CmssyProductPage, FetchOrderByTokenOptions, CmssyOrder, VerifyCmssyWebhookOptions, CmssyWebhookEvent } from '@cmssy/types';
7
7
  export { CmssyAuthConfig, CmssyProductPage, CmssySessionPayload, CmssySessionUser, CmssyStockState, CmssyWebhookEvent, CmssyWebhookOrder, FetchOrderByTokenOptions, FetchProductOptions, FetchProductsOptions, MyOrdersResult, SessionCookieOptions, VerifyCmssyWebhookOptions } from '@cmssy/types';
8
- import { MetadataRoute, Metadata } from 'next';
9
8
  import { NextRequest, NextResponse } from 'next/server';
9
+ import { MetadataRoute, Metadata } from 'next';
10
10
 
11
11
  declare const DEFAULT_CMSSY_EDITOR_ORIGINS: string[];
12
12
  declare function resolveEditorOrigin(editorOrigin: string | string[] | undefined): string | string[];
@@ -88,7 +88,51 @@ interface CatchAllProps {
88
88
  params?: Promise<CatchAllParams>;
89
89
  searchParams?: Promise<SearchParams>;
90
90
  }
91
+ /**
92
+ * Public catch-all page. Statically renderable: it never reads
93
+ * `searchParams` or `headers()` - awaiting either forces every route
94
+ * dynamic and kills ISR (CMS-952). Draft-mode preview (the authenticated
95
+ * /api/draft cookie) still works per-request via `draftMode()`, which is
96
+ * static-safe, and renders draft CONTENT without the editor (CMS-948).
97
+ * The editor-iframe flow (`?cmssyEdit=1&cmssySecret=...`) is served by
98
+ * the middleware rewrite (`cmssyEditRewrite`) onto the dynamic edit route
99
+ * mounted with `createCmssyEditPage`.
100
+ */
91
101
  declare function createCmssyPage(config: CmssyNextConfig, blocks: BlockDefinition[], options?: CreateCmssyPageOptions): ({ params, searchParams, }: CatchAllProps) => Promise<react_jsx_runtime.JSX.Element>;
102
+ /**
103
+ * Editor page for the middleware-rewritten edit route
104
+ * (`app/__cmssy/edit/[[...path]]/page.tsx`, `dynamic = "force-dynamic"`).
105
+ * Re-verifies the `cmssyEdit` + `cmssySecret` pair itself, so a direct hit
106
+ * on the route path (bypassing the middleware) cannot mount the editor.
107
+ */
108
+ declare function createCmssyEditPage(config: CmssyNextConfig, blocks: BlockDefinition[], options?: CreateCmssyPageOptions): ({ params, searchParams, }: CatchAllProps) => Promise<react_jsx_runtime.JSX.Element>;
109
+
110
+ /**
111
+ * Where the middleware rewrites editor-preview traffic. Mount the matching
112
+ * dynamic route in the consumer app:
113
+ *
114
+ * app/__cmssy/edit/[[...path]]/page.tsx
115
+ * export const dynamic = "force-dynamic";
116
+ * export default createCmssyEditPage(cmssy, blocks, { editor: Editor });
117
+ */
118
+ declare const CMSSY_EDIT_PATH_PREFIX = "/__cmssy/edit";
119
+ /**
120
+ * Rewrite VERIFIED editor requests (`cmssyEdit=1` + a `cmssySecret` matching
121
+ * the site's draft secret, CMS-948) onto the dedicated dynamic edit route, so
122
+ * the public catch-all can stay fully static. A static page never sees its
123
+ * query string - this rewrite is what makes the editor iframe work at all
124
+ * once ISR is on. Draft-mode preview (the authenticated /api/draft cookie) is
125
+ * NOT rewritten: it renders draft content on the public route via
126
+ * `draftMode()`, without the editor. Returns null for normal traffic so it
127
+ * composes with other middleware.
128
+ */
129
+ declare function cmssyEditRewrite(request: NextRequest, config: {
130
+ draftSecret: string;
131
+ }): Promise<NextResponse | null>;
132
+ /** Standalone middleware when the consumer has no other middleware to compose. */
133
+ declare function createCmssyEditMiddleware(config: {
134
+ draftSecret: string;
135
+ }): (request: NextRequest) => Promise<NextResponse>;
92
136
 
93
137
  interface CreateCmssyNotFoundOptions {
94
138
  /**
@@ -196,6 +240,8 @@ declare function cmssyCspHeaders(options: CmssyCspOptions): Record<string, strin
196
240
  declare function applyCmssyCsp<T extends MutableHeaders>(response: T, options: CmssyCspOptions): T;
197
241
 
198
242
  declare const CMSSY_EDIT_HEADER = "x-cmssy-edit";
243
+ declare const CMSSY_EDIT_QUERY_PARAM = "cmssyEdit";
244
+ declare const CMSSY_SECRET_QUERY_PARAM = "cmssySecret";
199
245
  interface EditRequestLike {
200
246
  cookies: {
201
247
  has: (name: string) => boolean;
@@ -203,10 +249,13 @@ interface EditRequestLike {
203
249
  nextUrl: {
204
250
  searchParams: {
205
251
  getAll: (name: string) => string[];
252
+ get: (name: string) => string | null;
206
253
  };
207
254
  };
208
255
  }
209
- declare function isCmssyEditRequest(request: EditRequestLike): boolean;
256
+ declare function isCmssyEditRequest(request: EditRequestLike, config: {
257
+ draftSecret: string;
258
+ }): Promise<boolean>;
210
259
  declare function isCmssyEditMode(): Promise<boolean>;
211
260
 
212
261
  declare const CMSSY_LOCALE_HEADER = "x-cmssy-locale";
@@ -215,7 +264,15 @@ declare function splitCmssyLocale(config: CmssyClientConfig, path: string[] | un
215
264
  locale: string;
216
265
  path: string[] | undefined;
217
266
  }>;
218
- declare function getCmssyLocale(config: CmssyClientConfig): Promise<string>;
267
+ /**
268
+ * Resolve the active locale. Pass `options.path` (route params) wherever
269
+ * possible - that path is static-safe. The no-argument form falls back to the
270
+ * `x-cmssy-locale` request header, which reads `headers()` and forces the
271
+ * calling route into dynamic rendering.
272
+ */
273
+ declare function getCmssyLocale(config: CmssyClientConfig, options?: {
274
+ path?: string | string[];
275
+ }): Promise<string>;
219
276
 
220
277
  /**
221
278
  * Resolves the active locale from a pathname's leading segment. Uses the
@@ -299,4 +356,4 @@ declare class CmssyWebhookError extends Error {
299
356
  */
300
357
  declare function verifyCmssyWebhook(options: VerifyCmssyWebhookOptions): CmssyWebhookEvent;
301
358
 
302
- export { type BuildCmssyMetadataOptions, CMSSY_CART_COOKIE, CMSSY_EDIT_HEADER, CMSSY_LOCALE_HEADER, CMSSY_SESSION_COOKIE, type CmssyAuthMiddleware, type CmssyAuthRouteHandlers, type CmssyCartRouteHandlers, type CmssyCspOptions, type CmssyDraftRouteConfig, type CmssyEditorProps, type CmssyEnvConfig, type CmssyNextConfig, type CmssyOrdersRouteHandlers, CmssyWebhookError, type CreateCmssyNotFoundOptions, type CreateCmssyPageOptions, type CreateCmssyRobotsOptions, type CreateCmssySitemapOptions, DEFAULT_CMSSY_EDITOR_ORIGINS, SESSION_MAX_AGE_SECONDS, applyCmssyCsp, assertAuthConfig, buildCmssyMetadata, cmssyCspHeaders, createCmssyAuthMiddleware, createCmssyAuthRoute, createCmssyCartRoute, createCmssyLocaleMiddleware, createCmssyNotFound, createCmssyOrdersRoute, createCmssyPage, createCmssyRobots, createCmssySitemap, createDraftRoute, defineCmssyConfig, fetchOrderByToken, fetchProduct, fetchProducts, getCmssyAccessToken, getCmssyLocale, getCmssyUser, isAccessExpired, isCmssyEditMode, isCmssyEditRequest, localeForPathname, openSession, resolveEditorOrigin, resolveLocaleFromPathname, sealSession, sessionCookieOptions, splitCmssyLocale, verifyCmssyWebhook };
359
+ export { type BuildCmssyMetadataOptions, CMSSY_CART_COOKIE, CMSSY_EDIT_HEADER, CMSSY_EDIT_PATH_PREFIX, CMSSY_EDIT_QUERY_PARAM, CMSSY_LOCALE_HEADER, CMSSY_SECRET_QUERY_PARAM, CMSSY_SESSION_COOKIE, type CmssyAuthMiddleware, type CmssyAuthRouteHandlers, type CmssyCartRouteHandlers, type CmssyCspOptions, type CmssyDraftRouteConfig, type CmssyEditorProps, type CmssyEnvConfig, type CmssyNextConfig, type CmssyOrdersRouteHandlers, CmssyWebhookError, type CreateCmssyNotFoundOptions, type CreateCmssyPageOptions, type CreateCmssyRobotsOptions, type CreateCmssySitemapOptions, DEFAULT_CMSSY_EDITOR_ORIGINS, SESSION_MAX_AGE_SECONDS, applyCmssyCsp, assertAuthConfig, buildCmssyMetadata, cmssyCspHeaders, cmssyEditRewrite, createCmssyAuthMiddleware, createCmssyAuthRoute, createCmssyCartRoute, createCmssyEditMiddleware, createCmssyEditPage, createCmssyLocaleMiddleware, createCmssyNotFound, createCmssyOrdersRoute, createCmssyPage, createCmssyRobots, createCmssySitemap, createDraftRoute, defineCmssyConfig, fetchOrderByToken, fetchProduct, fetchProducts, getCmssyAccessToken, getCmssyLocale, getCmssyUser, isAccessExpired, isCmssyEditMode, isCmssyEditRequest, localeForPathname, openSession, resolveEditorOrigin, resolveLocaleFromPathname, sealSession, sessionCookieOptions, splitCmssyLocale, verifyCmssyWebhook };
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
- import { draftMode, headers, cookies } from 'next/headers';
1
+ import { headers, draftMode, cookies } from 'next/headers';
2
2
  import { notFound, redirect } from 'next/navigation';
3
3
  import { createCmssyClient, resolveSiteLocales, splitLocaleFromPath, fetchPage, resolveForms, CmssyServerPage, fetchSiteConfig, fetchPageById, fetchPages, fetchPageMeta, normalizeSlug as normalizeSlug$1, resolveWorkspaceId, graphqlRequest, resolveApiUrl } from '@cmssy/react';
4
4
  export { DEFAULT_CMSSY_API_URL, evaluateFieldConditionGroup, resolveApiUrl } from '@cmssy/react';
5
5
  import { CmssyLocaleProvider } from '@cmssy/react/client';
6
6
  import { EncryptJWT, jwtDecrypt } from 'jose';
7
7
  import { jsx, jsxs } from 'react/jsx-runtime';
8
- import { createHmac, createHash, timingSafeEqual } from 'crypto';
9
8
  import { NextResponse } from 'next/server';
9
+ import { createHmac, timingSafeEqual } from 'crypto';
10
10
 
11
11
  // src/create-cmssy-page.tsx
12
12
  var CMSSY_SESSION_COOKIE = "cmssy_session";
@@ -203,11 +203,63 @@ function applyCmssyCsp(response, options) {
203
203
  response.headers.delete("X-Frame-Options");
204
204
  return response;
205
205
  }
206
- var EDIT_QUERY_PARAM = "cmssyEdit";
206
+
207
+ // src/secret-match.ts
208
+ var MAX_SECRET_LENGTH = 256;
209
+ async function cmssySecretsMatch(a, b) {
210
+ if (a.length > MAX_SECRET_LENGTH || b.length > MAX_SECRET_LENGTH) {
211
+ return false;
212
+ }
213
+ const encoder = new TextEncoder();
214
+ const [ha, hb] = await Promise.all([
215
+ crypto.subtle.digest("SHA-256", encoder.encode(a)),
216
+ crypto.subtle.digest("SHA-256", encoder.encode(b))
217
+ ]);
218
+ const va = new Uint8Array(ha);
219
+ const vb = new Uint8Array(hb);
220
+ let diff = 0;
221
+ for (let i = 0; i < va.length; i += 1) {
222
+ diff |= (va[i] ?? 0) ^ (vb[i] ?? 0);
223
+ }
224
+ return diff === 0;
225
+ }
226
+
227
+ // src/edit-mode.ts
228
+ var CMSSY_EDIT_HEADER = "x-cmssy-edit";
229
+ var CMSSY_EDIT_QUERY_PARAM = "cmssyEdit";
230
+ var CMSSY_SECRET_QUERY_PARAM = "cmssySecret";
231
+ async function isCmssyEditRequest(request2, config) {
232
+ if (request2.cookies.has("__prerender_bypass")) return true;
233
+ if (!request2.nextUrl.searchParams.getAll(CMSSY_EDIT_QUERY_PARAM).includes("1")) {
234
+ return false;
235
+ }
236
+ const provided = request2.nextUrl.searchParams.get(CMSSY_SECRET_QUERY_PARAM);
237
+ if (!provided || !config.draftSecret) return false;
238
+ return cmssySecretsMatch(provided, config.draftSecret);
239
+ }
240
+ async function isCmssyEditMode() {
241
+ const h = await headers();
242
+ return h.get(CMSSY_EDIT_HEADER) === "1";
243
+ }
207
244
  function hasEditFlag(value) {
208
245
  return Array.isArray(value) ? value.includes("1") : value === "1";
209
246
  }
247
+ function firstValue(value) {
248
+ return Array.isArray(value) ? value[0] : value;
249
+ }
250
+ async function resolveEditorRequest(query, draftSecret) {
251
+ if (!hasEditFlag(query[CMSSY_EDIT_QUERY_PARAM])) return false;
252
+ const provided = firstValue(query[CMSSY_SECRET_QUERY_PARAM]);
253
+ if (!provided || !draftSecret) return false;
254
+ return cmssySecretsMatch(provided, draftSecret);
255
+ }
210
256
  function createCmssyPage(config, blocks, options) {
257
+ return buildCmssyPageRenderer(config, blocks, options, false);
258
+ }
259
+ function createCmssyEditPage(config, blocks, options) {
260
+ return buildCmssyPageRenderer(config, blocks, options, true);
261
+ }
262
+ function buildCmssyPageRenderer(config, blocks, options, editRoute) {
211
263
  if (!Array.isArray(blocks)) {
212
264
  throw new Error(
213
265
  "cmssy: createCmssyPage(config, blocks) requires a blocks array \u2014 pass your defineBlock(...) array"
@@ -227,10 +279,16 @@ function createCmssyPage(config, blocks, options) {
227
279
  }) {
228
280
  const path = fixedPath ?? (params ? (await params).path ?? void 0 : void 0);
229
281
  const { isEnabled } = await draftMode();
230
- const query = searchParams ? await searchParams : {};
231
- const editMode = isEnabled || hasEditFlag(query[EDIT_QUERY_PARAM]);
282
+ let editorActive = false;
283
+ if (editRoute) {
284
+ const query = searchParams ? await searchParams : {};
285
+ editorActive = await resolveEditorRequest(query, config.draftSecret);
286
+ if (!editorActive) {
287
+ notFound();
288
+ }
289
+ }
290
+ const editMode = isEnabled || editorActive;
232
291
  const devAllowed = isDevelopment() && Boolean(config.devToken?.trim());
233
- const editorActive = editMode;
234
292
  let locale;
235
293
  let pagePath = path;
236
294
  let defaultLocale;
@@ -339,6 +397,23 @@ function resolveBridgeOrigin(editorOrigin) {
339
397
  }
340
398
  return origins.length === 1 ? origins[0] : origins;
341
399
  }
400
+ var CMSSY_EDIT_PATH_PREFIX = "/__cmssy/edit";
401
+ async function cmssyEditRewrite(request2, config) {
402
+ const { pathname, searchParams } = request2.nextUrl;
403
+ if (pathname.startsWith(CMSSY_EDIT_PATH_PREFIX)) return null;
404
+ if (!searchParams.getAll(CMSSY_EDIT_QUERY_PARAM).includes("1")) return null;
405
+ const provided = searchParams.get(CMSSY_SECRET_QUERY_PARAM);
406
+ if (!provided || !config.draftSecret) return null;
407
+ if (!await cmssySecretsMatch(provided, config.draftSecret)) return null;
408
+ const url = request2.nextUrl.clone();
409
+ url.pathname = `${CMSSY_EDIT_PATH_PREFIX}${pathname === "/" ? "" : pathname}`;
410
+ return NextResponse.rewrite(url);
411
+ }
412
+ function createCmssyEditMiddleware(config) {
413
+ return async function cmssyEditMiddleware(request2) {
414
+ return await cmssyEditRewrite(request2, config) ?? NextResponse.next();
415
+ };
416
+ }
342
417
  function DefaultNotFound() {
343
418
  return /* @__PURE__ */ jsxs(
344
419
  "main",
@@ -594,11 +669,6 @@ async function buildCmssyMetadata(config, path, options = {}) {
594
669
  };
595
670
  }
596
671
  var MIN_SECRET_LENGTH = 16;
597
- function secretsMatch(a, b) {
598
- const ha = createHash("sha256").update(a).digest();
599
- const hb = createHash("sha256").update(b).digest();
600
- return timingSafeEqual(ha, hb);
601
- }
602
672
  function safeRedirect(redirect2, fallback) {
603
673
  if (!redirect2 || !redirect2.startsWith("/")) return fallback;
604
674
  if (redirect2.startsWith("//") || redirect2.includes("\\")) return fallback;
@@ -619,15 +689,22 @@ function createDraftRoute(config) {
619
689
  );
620
690
  }
621
691
  return async function GET(request2) {
692
+ const url = new URL(request2.url);
693
+ if (url.searchParams.get("disable") === "1") {
694
+ const draft2 = await draftMode();
695
+ draft2.disable();
696
+ redirect(
697
+ safeRedirect(url.searchParams.get("redirect"), fallbackRedirect)
698
+ );
699
+ }
622
700
  if (config.draftSecret.length < MIN_SECRET_LENGTH) {
623
701
  return new Response(
624
702
  `cmssy: draftSecret must be at least ${MIN_SECRET_LENGTH} characters`,
625
703
  { status: 500 }
626
704
  );
627
705
  }
628
- const url = new URL(request2.url);
629
706
  const secret = url.searchParams.get("secret");
630
- if (!secret || !secretsMatch(secret, config.draftSecret)) {
707
+ if (!secret || !await cmssySecretsMatch(secret, config.draftSecret)) {
631
708
  return new Response("Invalid draft secret", { status: 401 });
632
709
  }
633
710
  const location = safeRedirect(
@@ -639,14 +716,6 @@ function createDraftRoute(config) {
639
716
  redirect(location);
640
717
  };
641
718
  }
642
- var CMSSY_EDIT_HEADER = "x-cmssy-edit";
643
- function isCmssyEditRequest(request2) {
644
- return request2.cookies.has("__prerender_bypass") || request2.nextUrl.searchParams.getAll("cmssyEdit").includes("1");
645
- }
646
- async function isCmssyEditMode() {
647
- const h = await headers();
648
- return h.get(CMSSY_EDIT_HEADER) === "1";
649
- }
650
719
  var CMSSY_LOCALE_HEADER = "x-cmssy-locale";
651
720
  async function localeForPathname(config, pathname) {
652
721
  const siteLocales = await resolveSiteLocales(config);
@@ -657,7 +726,12 @@ async function splitCmssyLocale(config, path) {
657
726
  const siteLocales = await resolveSiteLocales(config);
658
727
  return splitLocaleFromPath(path, siteLocales);
659
728
  }
660
- async function getCmssyLocale(config) {
729
+ async function getCmssyLocale(config, options) {
730
+ if (options?.path !== void 0) {
731
+ const siteLocales = await resolveSiteLocales(config);
732
+ const segments = Array.isArray(options.path) ? options.path.filter(Boolean) : options.path.split("/").filter(Boolean);
733
+ return splitLocaleFromPath(segments, siteLocales).locale;
734
+ }
661
735
  const { headers: headers3 } = await import('next/headers');
662
736
  const headerList = await headers3();
663
737
  const fromHeader = headerList.get(CMSSY_LOCALE_HEADER);
@@ -1903,4 +1977,4 @@ function verifyCmssyWebhook(options) {
1903
1977
  return parsed;
1904
1978
  }
1905
1979
 
1906
- export { CMSSY_CART_COOKIE, CMSSY_EDIT_HEADER, CMSSY_LOCALE_HEADER, CMSSY_SESSION_COOKIE, CmssyWebhookError, DEFAULT_CMSSY_EDITOR_ORIGINS, SESSION_MAX_AGE_SECONDS, applyCmssyCsp, assertAuthConfig, buildCmssyMetadata, cmssyCspHeaders, createCmssyAuthMiddleware, createCmssyAuthRoute, createCmssyCartRoute, createCmssyLocaleMiddleware, createCmssyNotFound, createCmssyOrdersRoute, createCmssyPage, createCmssyRobots, createCmssySitemap, createDraftRoute, defineCmssyConfig, fetchOrderByToken, fetchProduct, fetchProducts, getCmssyAccessToken, getCmssyLocale, getCmssyUser, isAccessExpired, isCmssyEditMode, isCmssyEditRequest, localeForPathname, openSession, resolveEditorOrigin, resolveLocaleFromPathname, sealSession, sessionCookieOptions, splitCmssyLocale, verifyCmssyWebhook };
1980
+ export { CMSSY_CART_COOKIE, CMSSY_EDIT_HEADER, CMSSY_EDIT_PATH_PREFIX, CMSSY_EDIT_QUERY_PARAM, CMSSY_LOCALE_HEADER, CMSSY_SECRET_QUERY_PARAM, CMSSY_SESSION_COOKIE, CmssyWebhookError, DEFAULT_CMSSY_EDITOR_ORIGINS, SESSION_MAX_AGE_SECONDS, applyCmssyCsp, assertAuthConfig, buildCmssyMetadata, cmssyCspHeaders, cmssyEditRewrite, createCmssyAuthMiddleware, createCmssyAuthRoute, createCmssyCartRoute, createCmssyEditMiddleware, createCmssyEditPage, createCmssyLocaleMiddleware, createCmssyNotFound, createCmssyOrdersRoute, createCmssyPage, createCmssyRobots, createCmssySitemap, createDraftRoute, defineCmssyConfig, fetchOrderByToken, fetchProduct, fetchProducts, getCmssyAccessToken, getCmssyLocale, getCmssyUser, isAccessExpired, isCmssyEditMode, isCmssyEditRequest, localeForPathname, openSession, resolveEditorOrigin, resolveLocaleFromPathname, sealSession, sessionCookieOptions, splitCmssyLocale, verifyCmssyWebhook };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmssy/next",
3
- "version": "2.7.0",
3
+ "version": "4.0.0",
4
4
  "description": "Next.js App Router bindings for cmssy headless sites (createCmssyPage + draft preview)",
5
5
  "keywords": [
6
6
  "cmssy",
@@ -41,7 +41,7 @@
41
41
  "dist"
42
42
  ],
43
43
  "peerDependencies": {
44
- "@cmssy/react": "^2.1.0",
44
+ "@cmssy/react": "^4.0.0",
45
45
  "next": ">=15",
46
46
  "react": "^18.2.0 || ^19.0.0",
47
47
  "react-dom": "^18.2.0 || ^19.0.0"
@@ -55,7 +55,7 @@
55
55
  "tsup": "^8.3.0",
56
56
  "typescript": "^5.6.0",
57
57
  "vitest": "^2.1.0",
58
- "@cmssy/react": "2.7.0"
58
+ "@cmssy/react": "4.0.0"
59
59
  },
60
60
  "dependencies": {
61
61
  "jose": "^6.2.3",