@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 +24 -9
- package/dist/index.js +24 -9
- package/dist/testing.cjs +3 -66
- package/dist/testing.d.cts +7 -5
- package/dist/testing.d.ts +7 -5
- package/dist/testing.js +3 -66
- package/package.json +2 -2
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
|
-
|
|
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"
|
|
777
|
+
else if (key === "v1" && value && signatures.length < MAX_SIGNATURES) {
|
|
778
|
+
signatures.push(value);
|
|
779
|
+
}
|
|
777
780
|
}
|
|
778
|
-
if (timestamp === null || !Number.isFinite(timestamp) || !
|
|
781
|
+
if (timestamp === null || !Number.isFinite(timestamp) || !signatures.length) {
|
|
779
782
|
throw new CmssyWebhookError("Malformed X-Cmssy-Signature header");
|
|
780
783
|
}
|
|
781
|
-
return { timestamp,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
837
|
-
|
|
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
|
-
|
|
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"
|
|
775
|
+
else if (key === "v1" && value && signatures.length < MAX_SIGNATURES) {
|
|
776
|
+
signatures.push(value);
|
|
777
|
+
}
|
|
775
778
|
}
|
|
776
|
-
if (timestamp === null || !Number.isFinite(timestamp) || !
|
|
779
|
+
if (timestamp === null || !Number.isFinite(timestamp) || !signatures.length) {
|
|
777
780
|
throw new CmssyWebhookError("Malformed X-Cmssy-Signature header");
|
|
778
781
|
}
|
|
779
|
-
return { timestamp,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
835
|
-
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
298
|
+
`edit ${localizedPath}: expected 200, got ${localized.status}`
|
|
362
299
|
);
|
|
363
300
|
} else {
|
|
364
301
|
if (!EDITOR_MARKER.test(localized.body)) {
|
package/dist/testing.d.cts
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".
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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".
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
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.
|
|
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.
|
|
65
|
+
"@cmssy/types": "0.33.0",
|
|
66
66
|
"jose": "^6.2.3"
|
|
67
67
|
},
|
|
68
68
|
"scripts": {
|