@cmssy/core 11.5.0 → 11.6.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
@@ -764,21 +764,24 @@ var CmssyWebhookError = class extends Error {
764
764
  }
765
765
  };
766
766
  var DEFAULT_TOLERANCE_SECONDS = 300;
767
+ var MAX_SIGNATURES = 8;
767
768
  function parseSignatureHeader(header) {
768
769
  let timestamp = null;
769
- let signature = null;
770
+ const signatures = [];
770
771
  for (const part of header.split(",")) {
771
772
  const idx = part.indexOf("=");
772
773
  if (idx === -1) continue;
773
774
  const key = part.slice(0, idx).trim();
774
775
  const value = part.slice(idx + 1).trim();
775
776
  if (key === "t") timestamp = Number(value);
776
- else if (key === "v1") signature = value;
777
+ else if (key === "v1" && value && signatures.length < MAX_SIGNATURES) {
778
+ signatures.push(value);
779
+ }
777
780
  }
778
- if (timestamp === null || !Number.isFinite(timestamp) || !signature) {
781
+ if (timestamp === null || !Number.isFinite(timestamp) || !signatures.length) {
779
782
  throw new CmssyWebhookError("Malformed X-Cmssy-Signature header");
780
783
  }
781
- return { timestamp, signature };
784
+ return { timestamp, signatures };
782
785
  }
783
786
  function hexToBytes(hex) {
784
787
  if (hex.length % 2 !== 0 || /[^0-9a-fA-F]/.test(hex)) return null;
@@ -792,9 +795,10 @@ function bytesToHex(bytes) {
792
795
  return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
793
796
  }
794
797
  function timingSafeHexEqual(expectedHex, providedHex) {
798
+ if (expectedHex.length !== providedHex.length) return false;
795
799
  const expected = hexToBytes(expectedHex);
796
800
  const provided = hexToBytes(providedHex);
797
- if (!expected || !provided || expected.length !== provided.length) {
801
+ if (!expected || !provided) {
798
802
  return false;
799
803
  }
800
804
  let diff = 0;
@@ -824,17 +828,28 @@ async function verifyCmssyWebhook(options) {
824
828
  if (!signatureHeader) {
825
829
  throw new CmssyWebhookError("Missing X-Cmssy-Signature header");
826
830
  }
827
- if (!secret) {
831
+ const candidates = typeof secret === "string" ? [secret] : Array.isArray(secret) ? secret : [];
832
+ if (candidates.some((value) => typeof value !== "string")) {
833
+ throw new CmssyWebhookError("Webhook secret must be a string");
834
+ }
835
+ const secrets = candidates.filter(Boolean);
836
+ if (!secrets.length) {
828
837
  throw new CmssyWebhookError("Missing webhook secret");
829
838
  }
830
- const { timestamp, signature } = parseSignatureHeader(signatureHeader);
839
+ const { timestamp, signatures } = parseSignatureHeader(signatureHeader);
831
840
  const toleranceMs = (options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS) * 1e3;
832
841
  const now = options.now ?? Date.now();
833
842
  if (Math.abs(now - timestamp) > toleranceMs) {
834
843
  throw new CmssyWebhookError("Webhook timestamp outside tolerance");
835
844
  }
836
- const expected = await hmacSha256Hex(secret, `${timestamp}.${body}`);
837
- if (!timingSafeHexEqual(expected, signature)) {
845
+ let matched = false;
846
+ for (const candidate of secrets) {
847
+ const expected = await hmacSha256Hex(candidate, `${timestamp}.${body}`);
848
+ for (const signature of signatures) {
849
+ if (timingSafeHexEqual(expected, signature)) matched = true;
850
+ }
851
+ }
852
+ if (!matched) {
838
853
  throw new CmssyWebhookError("Webhook signature mismatch");
839
854
  }
840
855
  let parsed;
package/dist/index.js CHANGED
@@ -762,21 +762,24 @@ var CmssyWebhookError = class extends Error {
762
762
  }
763
763
  };
764
764
  var DEFAULT_TOLERANCE_SECONDS = 300;
765
+ var MAX_SIGNATURES = 8;
765
766
  function parseSignatureHeader(header) {
766
767
  let timestamp = null;
767
- let signature = null;
768
+ const signatures = [];
768
769
  for (const part of header.split(",")) {
769
770
  const idx = part.indexOf("=");
770
771
  if (idx === -1) continue;
771
772
  const key = part.slice(0, idx).trim();
772
773
  const value = part.slice(idx + 1).trim();
773
774
  if (key === "t") timestamp = Number(value);
774
- else if (key === "v1") signature = value;
775
+ else if (key === "v1" && value && signatures.length < MAX_SIGNATURES) {
776
+ signatures.push(value);
777
+ }
775
778
  }
776
- if (timestamp === null || !Number.isFinite(timestamp) || !signature) {
779
+ if (timestamp === null || !Number.isFinite(timestamp) || !signatures.length) {
777
780
  throw new CmssyWebhookError("Malformed X-Cmssy-Signature header");
778
781
  }
779
- return { timestamp, signature };
782
+ return { timestamp, signatures };
780
783
  }
781
784
  function hexToBytes(hex) {
782
785
  if (hex.length % 2 !== 0 || /[^0-9a-fA-F]/.test(hex)) return null;
@@ -790,9 +793,10 @@ function bytesToHex(bytes) {
790
793
  return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
791
794
  }
792
795
  function timingSafeHexEqual(expectedHex, providedHex) {
796
+ if (expectedHex.length !== providedHex.length) return false;
793
797
  const expected = hexToBytes(expectedHex);
794
798
  const provided = hexToBytes(providedHex);
795
- if (!expected || !provided || expected.length !== provided.length) {
799
+ if (!expected || !provided) {
796
800
  return false;
797
801
  }
798
802
  let diff = 0;
@@ -822,17 +826,28 @@ async function verifyCmssyWebhook(options) {
822
826
  if (!signatureHeader) {
823
827
  throw new CmssyWebhookError("Missing X-Cmssy-Signature header");
824
828
  }
825
- if (!secret) {
829
+ const candidates = typeof secret === "string" ? [secret] : Array.isArray(secret) ? secret : [];
830
+ if (candidates.some((value) => typeof value !== "string")) {
831
+ throw new CmssyWebhookError("Webhook secret must be a string");
832
+ }
833
+ const secrets = candidates.filter(Boolean);
834
+ if (!secrets.length) {
826
835
  throw new CmssyWebhookError("Missing webhook secret");
827
836
  }
828
- const { timestamp, signature } = parseSignatureHeader(signatureHeader);
837
+ const { timestamp, signatures } = parseSignatureHeader(signatureHeader);
829
838
  const toleranceMs = (options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS) * 1e3;
830
839
  const now = options.now ?? Date.now();
831
840
  if (Math.abs(now - timestamp) > toleranceMs) {
832
841
  throw new CmssyWebhookError("Webhook timestamp outside tolerance");
833
842
  }
834
- const expected = await hmacSha256Hex(secret, `${timestamp}.${body}`);
835
- if (!timingSafeHexEqual(expected, signature)) {
843
+ let matched = false;
844
+ for (const candidate of secrets) {
845
+ const expected = await hmacSha256Hex(candidate, `${timestamp}.${body}`);
846
+ for (const signature of signatures) {
847
+ if (timingSafeHexEqual(expected, signature)) matched = true;
848
+ }
849
+ }
850
+ if (!matched) {
836
851
  throw new CmssyWebhookError("Webhook signature mismatch");
837
852
  }
838
853
  let parsed;
package/dist/testing.cjs CHANGED
@@ -141,39 +141,6 @@ async function graphqlRequest(config, query, variables, options = {}, label = "r
141
141
  });
142
142
  }
143
143
 
144
- // src/data/queries.ts
145
- var SITE_CONFIG_QUERY = `query PublicSiteConfig($workspaceSlug: String!) {
146
- public {
147
- siteConfig(workspaceSlug: $workspaceSlug) {
148
- id
149
- workspaceId
150
- siteName
151
- defaultLanguage
152
- enabledLanguages
153
- enabledFeatures
154
- notFoundPageId
155
- previewUrl
156
- branding {
157
- brandName
158
- logoUrl
159
- logoDarkUrl
160
- faviconUrl
161
- ogImageUrl
162
- }
163
- }
164
- }
165
- }`;
166
-
167
- // src/data/site-locales.ts
168
- function localesFromSiteConfig(siteConfig) {
169
- const defaultLocale = siteConfig?.defaultLanguage || "en";
170
- const enabled = siteConfig?.enabledLanguages ?? [];
171
- return {
172
- defaultLocale,
173
- locales: enabled.length > 0 ? enabled : [defaultLocale]
174
- };
175
- }
176
-
177
144
  // src/preflight.ts
178
145
  var ALLOWED_FRAME_HOSTS = ["cmssy.io", "www.cmssy.io"];
179
146
  function frameAncestorSources(cspHeaderValue) {
@@ -269,29 +236,6 @@ async function workspaceHasLayoutBlocks(workspace, pageSlug) {
269
236
  return null;
270
237
  }
271
238
  }
272
- async function derivedLocalizedPath(workspace, path) {
273
- try {
274
- const data = await graphqlRequest(
275
- workspace,
276
- SITE_CONFIG_QUERY,
277
- { workspaceSlug: workspace.workspaceSlug },
278
- { public: true, retry: {} },
279
- "edit smoke: site locales"
280
- );
281
- const { defaultLocale, locales } = localesFromSiteConfig(
282
- data.public?.siteConfig ?? null
283
- );
284
- const locale = locales.find((candidate) => candidate !== defaultLocale);
285
- if (!locale) return { kind: "none" };
286
- const routed = withoutEditPrefix(path);
287
- const [first] = routed.split("/").filter(Boolean);
288
- if (first && locales.includes(first)) return { kind: "none" };
289
- const suffix = routed === "/" || routed === "" ? "" : routed.replace(/\/+$/, "");
290
- return { kind: "derived", locale, localizedPath: `/${locale}${suffix}` };
291
- } catch {
292
- return { kind: "unavailable" };
293
- }
294
- }
295
239
  async function checkCmssyEditMode(options) {
296
240
  const { baseUrl, secret, path = "/" } = options;
297
241
  const failures = [];
@@ -345,20 +289,13 @@ async function checkCmssyEditMode(options) {
345
289
  }
346
290
  }
347
291
  const query = `cmssyEdit=1&cmssySecret=${encodeURIComponent(secret)}`;
348
- const asked = options.localizedPath;
349
- const derived = !asked && options.workspace ? await derivedLocalizedPath(options.workspace, path) : { kind: "none" };
350
- if (derived.kind === "unavailable") {
351
- failures.push(
352
- `the delivery API would not say which languages ${options.workspace?.workspaceSlug} enables, so the preview's language went unchecked. That is the check three releases shipped a wrong-language preview past - a green run here does not mean it works.`
353
- );
354
- }
355
- const localizedPath = asked ?? (derived.kind === "derived" ? derived.localizedPath : void 0);
292
+ const { localizedPath } = options;
356
293
  if (localizedPath) {
357
- const locale = options.localizedLocale ?? (derived.kind === "derived" ? derived.locale : localeOf(localizedPath));
294
+ const locale = options.localizedLocale ?? localeOf(localizedPath);
358
295
  const localized = await html(url(`${localizedPath}?${query}`));
359
296
  if (localized.status !== 200) {
360
297
  failures.push(
361
- derived.kind === "derived" ? `edit ${localizedPath}: expected 200, got ${localized.status}. The workspace enables "${locale}", so the language went unchecked - pass localizedPath if this site spells the language some other way.` : `edit ${localizedPath}: expected 200, got ${localized.status}`
298
+ `edit ${localizedPath}: expected 200, got ${localized.status}`
362
299
  );
363
300
  } else {
364
301
  if (!EDITOR_MARKER.test(localized.body)) {
@@ -6,11 +6,13 @@ interface EditSmokeOptions {
6
6
  /** A published page to exercise. Defaults to "/". */
7
7
  path?: string;
8
8
  /**
9
- * The same page under a language prefix, e.g. "/no". Pass it on a site whose
10
- * URLs carry the language, and the check also proves the preview renders in
11
- * THAT language rather than the default one - by reading `<html lang>`, which
12
- * is a contract, unlike a word from the page's copy that an editor can change
13
- * at any time.
9
+ * The same page under a language prefix, e.g. "/no". The caller states it
10
+ * because only the caller knows how its site spells a language; asking a
11
+ * workspace would tie this check to content that can change or be deleted.
12
+ *
13
+ * Given it, the check also proves the preview renders in THAT language rather
14
+ * than the default one - by reading `<html lang>`, which is a contract,
15
+ * unlike a word from the page's copy that an editor can change at any time.
14
16
  */
15
17
  localizedPath?: string;
16
18
  /**
package/dist/testing.d.ts CHANGED
@@ -6,11 +6,13 @@ interface EditSmokeOptions {
6
6
  /** A published page to exercise. Defaults to "/". */
7
7
  path?: string;
8
8
  /**
9
- * The same page under a language prefix, e.g. "/no". Pass it on a site whose
10
- * URLs carry the language, and the check also proves the preview renders in
11
- * THAT language rather than the default one - by reading `<html lang>`, which
12
- * is a contract, unlike a word from the page's copy that an editor can change
13
- * at any time.
9
+ * The same page under a language prefix, e.g. "/no". The caller states it
10
+ * because only the caller knows how its site spells a language; asking a
11
+ * workspace would tie this check to content that can change or be deleted.
12
+ *
13
+ * Given it, the check also proves the preview renders in THAT language rather
14
+ * than the default one - by reading `<html lang>`, which is a contract,
15
+ * unlike a word from the page's copy that an editor can change at any time.
14
16
  */
15
17
  localizedPath?: string;
16
18
  /**
package/dist/testing.js CHANGED
@@ -139,39 +139,6 @@ async function graphqlRequest(config, query, variables, options = {}, label = "r
139
139
  });
140
140
  }
141
141
 
142
- // src/data/queries.ts
143
- var SITE_CONFIG_QUERY = `query PublicSiteConfig($workspaceSlug: String!) {
144
- public {
145
- siteConfig(workspaceSlug: $workspaceSlug) {
146
- id
147
- workspaceId
148
- siteName
149
- defaultLanguage
150
- enabledLanguages
151
- enabledFeatures
152
- notFoundPageId
153
- previewUrl
154
- branding {
155
- brandName
156
- logoUrl
157
- logoDarkUrl
158
- faviconUrl
159
- ogImageUrl
160
- }
161
- }
162
- }
163
- }`;
164
-
165
- // src/data/site-locales.ts
166
- function localesFromSiteConfig(siteConfig) {
167
- const defaultLocale = siteConfig?.defaultLanguage || "en";
168
- const enabled = siteConfig?.enabledLanguages ?? [];
169
- return {
170
- defaultLocale,
171
- locales: enabled.length > 0 ? enabled : [defaultLocale]
172
- };
173
- }
174
-
175
142
  // src/preflight.ts
176
143
  var ALLOWED_FRAME_HOSTS = ["cmssy.io", "www.cmssy.io"];
177
144
  function frameAncestorSources(cspHeaderValue) {
@@ -267,29 +234,6 @@ async function workspaceHasLayoutBlocks(workspace, pageSlug) {
267
234
  return null;
268
235
  }
269
236
  }
270
- async function derivedLocalizedPath(workspace, path) {
271
- try {
272
- const data = await graphqlRequest(
273
- workspace,
274
- SITE_CONFIG_QUERY,
275
- { workspaceSlug: workspace.workspaceSlug },
276
- { public: true, retry: {} },
277
- "edit smoke: site locales"
278
- );
279
- const { defaultLocale, locales } = localesFromSiteConfig(
280
- data.public?.siteConfig ?? null
281
- );
282
- const locale = locales.find((candidate) => candidate !== defaultLocale);
283
- if (!locale) return { kind: "none" };
284
- const routed = withoutEditPrefix(path);
285
- const [first] = routed.split("/").filter(Boolean);
286
- if (first && locales.includes(first)) return { kind: "none" };
287
- const suffix = routed === "/" || routed === "" ? "" : routed.replace(/\/+$/, "");
288
- return { kind: "derived", locale, localizedPath: `/${locale}${suffix}` };
289
- } catch {
290
- return { kind: "unavailable" };
291
- }
292
- }
293
237
  async function checkCmssyEditMode(options) {
294
238
  const { baseUrl, secret, path = "/" } = options;
295
239
  const failures = [];
@@ -343,20 +287,13 @@ async function checkCmssyEditMode(options) {
343
287
  }
344
288
  }
345
289
  const query = `cmssyEdit=1&cmssySecret=${encodeURIComponent(secret)}`;
346
- const asked = options.localizedPath;
347
- const derived = !asked && options.workspace ? await derivedLocalizedPath(options.workspace, path) : { kind: "none" };
348
- if (derived.kind === "unavailable") {
349
- failures.push(
350
- `the delivery API would not say which languages ${options.workspace?.workspaceSlug} enables, so the preview's language went unchecked. That is the check three releases shipped a wrong-language preview past - a green run here does not mean it works.`
351
- );
352
- }
353
- const localizedPath = asked ?? (derived.kind === "derived" ? derived.localizedPath : void 0);
290
+ const { localizedPath } = options;
354
291
  if (localizedPath) {
355
- const locale = options.localizedLocale ?? (derived.kind === "derived" ? derived.locale : localeOf(localizedPath));
292
+ const locale = options.localizedLocale ?? localeOf(localizedPath);
356
293
  const localized = await html(url(`${localizedPath}?${query}`));
357
294
  if (localized.status !== 200) {
358
295
  failures.push(
359
- derived.kind === "derived" ? `edit ${localizedPath}: expected 200, got ${localized.status}. The workspace enables "${locale}", so the language went unchecked - pass localizedPath if this site spells the language some other way.` : `edit ${localizedPath}: expected 200, got ${localized.status}`
296
+ `edit ${localizedPath}: expected 200, got ${localized.status}`
360
297
  );
361
298
  } else {
362
299
  if (!EDITOR_MARKER.test(localized.body)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmssy/core",
3
- "version": "11.5.0",
3
+ "version": "11.6.0",
4
4
  "description": "Framework-agnostic cmssy gateway: GraphQL client, block/field system, editor protocol. No React, no Next.",
5
5
  "keywords": [
6
6
  "cmssy",
@@ -62,7 +62,7 @@
62
62
  "vitest": "^2.1.0"
63
63
  },
64
64
  "dependencies": {
65
- "@cmssy/types": "0.32.0",
65
+ "@cmssy/types": "0.33.0",
66
66
  "jose": "^6.2.3"
67
67
  },
68
68
  "scripts": {