@tokenoftrust/storefront-runner 2.2.95 → 2.2.96
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/agency-kit/tools/lib/embed-catalog.mjs +99 -0
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +5 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/declared-tenant-config.d.mts +1 -1
- package/packages/public-runtime/src/declared-tenant-config.mjs +12 -6
- package/packages/public-runtime/src/embed-catalog.d.mts +2 -9
- package/packages/public-runtime/src/embed-catalog.mjs +5 -40
- package/scripts/tenant/validate.mjs +3 -2
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reviewed third-party embed catalog: the providers a store may opt into, and the exact
|
|
3
|
+
* origins each one needs. The one copy: the platform's CSP, its reconcile frame check, the
|
|
4
|
+
* static-publish CSP pipeline, the dev-loop lint and the kit's own tools all read it (the platform
|
|
5
|
+
* re-exports this module).
|
|
6
|
+
*
|
|
7
|
+
* Pure and dependency-free, so plain `node` tooling and the Worker both import it.
|
|
8
|
+
*
|
|
9
|
+
* A store opts in by slug: `embeds` in its `.tot/config.json` (or its `embeds.json`). Each entry:
|
|
10
|
+
* `markers` are substrings that appear in rendered HTML when the embed is on a page (so CSP origins
|
|
11
|
+
* are added only to pages that use it); `frameSrc`/`connectSrc`/`scriptSrc` are the EXACT origins
|
|
12
|
+
* the embed needs in the parent document, never wildcards. The embed renders in a cross-origin
|
|
13
|
+
* iframe governed by the provider's own CSP.
|
|
14
|
+
*
|
|
15
|
+
* @type {Readonly<Record<string, {
|
|
16
|
+
* label: string, markers: readonly string[], scriptSrc?: readonly string[],
|
|
17
|
+
* connectSrc?: readonly string[], frameSrc?: readonly string[] }>>}
|
|
18
|
+
*/
|
|
19
|
+
export const EMBED_PROVIDERS = {
|
|
20
|
+
pipedrive: {
|
|
21
|
+
label: "Pipedrive Web Forms",
|
|
22
|
+
// The embed div carries data-pd-webforms=… and the loader is
|
|
23
|
+
// webforms.pipedrive.com/f/loader; either marker means the form is present.
|
|
24
|
+
markers: ["webforms.pipedrive.com", "pipedriveWebForms"],
|
|
25
|
+
// The nonce-allowed loader fetches its form definition from this origin and
|
|
26
|
+
// mounts the form in a same-origin iframe from it. script-src as a
|
|
27
|
+
// belt-and-suspenders in case a future loader drops the parent-tag nonce.
|
|
28
|
+
scriptSrc: ["https://webforms.pipedrive.com"],
|
|
29
|
+
connectSrc: ["https://webforms.pipedrive.com"],
|
|
30
|
+
frameSrc: ["https://webforms.pipedrive.com"],
|
|
31
|
+
},
|
|
32
|
+
youtube: {
|
|
33
|
+
label: "YouTube video (privacy-enhanced)",
|
|
34
|
+
// Only the privacy-enhanced player: it sets no tracking cookies until the visitor plays the
|
|
35
|
+
// video. A www.youtube.com/embed/ iframe is rewritten to it (tools/video-embeds.mjs).
|
|
36
|
+
markers: ["www.youtube-nocookie.com/embed/"],
|
|
37
|
+
frameSrc: ["https://www.youtube-nocookie.com"],
|
|
38
|
+
},
|
|
39
|
+
vimeo: {
|
|
40
|
+
label: "Vimeo video",
|
|
41
|
+
markers: ["player.vimeo.com/video/"],
|
|
42
|
+
frameSrc: ["https://player.vimeo.com"],
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every frame origin the given opted-in slugs allow (unknown slugs allow nothing).
|
|
48
|
+
* @param {readonly string[] | null | undefined} slugs
|
|
49
|
+
* @returns {string[]}
|
|
50
|
+
*/
|
|
51
|
+
export function embedFrameOrigins(slugs) {
|
|
52
|
+
/** @type {Set<string>} */
|
|
53
|
+
const out = new Set();
|
|
54
|
+
for (const slug of slugs ?? []) {
|
|
55
|
+
for (const origin of EMBED_PROVIDERS[slug]?.frameSrc ?? []) out.add(origin);
|
|
56
|
+
}
|
|
57
|
+
return [...out];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The catalog slug whose `frameSrc` includes `origin`, or null.
|
|
62
|
+
* @param {string} origin
|
|
63
|
+
* @returns {string | null}
|
|
64
|
+
*/
|
|
65
|
+
export function embedSlugForFrameOrigin(origin) {
|
|
66
|
+
for (const [slug, provider] of Object.entries(EMBED_PROVIDERS)) {
|
|
67
|
+
if (provider.frameSrc?.includes(origin)) return slug;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A tag's `src`, in HTML or in HTML held in a JSON string (where its quotes are escaped).
|
|
73
|
+
const IFRAME_TAG_RE = /<iframe\b[^>]*>/gi;
|
|
74
|
+
const SRC_ATTR_RE = /\bsrc\s*=\s*\\?["']([^"'\\]+)/i;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Every `<iframe>` in a text (HTML, or JSON holding HTML), with the origin of its `src` (null when
|
|
78
|
+
* it has none or it is not an absolute URL).
|
|
79
|
+
*
|
|
80
|
+
* @param {string} text
|
|
81
|
+
* @returns {Array<{ tag: string, src: string | null, origin: string | null }>}
|
|
82
|
+
*/
|
|
83
|
+
export function findIframes(text) {
|
|
84
|
+
/** @type {Array<{ tag: string, src: string | null, origin: string | null }>} */
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const [tag] of String(text).matchAll(IFRAME_TAG_RE)) {
|
|
87
|
+
const src = SRC_ATTR_RE.exec(tag)?.[1] ?? null;
|
|
88
|
+
let origin = null;
|
|
89
|
+
if (src) {
|
|
90
|
+
try {
|
|
91
|
+
origin = new URL(src).origin;
|
|
92
|
+
} catch {
|
|
93
|
+
origin = null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
out.push({ tag, src, origin });
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.96",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
export const TOT_CONFIG_KEYS = [
|
|
13
13
|
"$schema", "tenant", "scope", "mappings", "hostPlatform", "sample", "seed",
|
|
14
|
-
"siteType", "compliance", "capabilities", "features",
|
|
14
|
+
"siteType", "compliance", "capabilities", "features", "embeds",
|
|
15
15
|
];
|
|
16
16
|
// Keys other tooling has written that the platform does NOT read, with the key it does.
|
|
17
17
|
const RENAMED_CONFIG_KEYS = { appDomain: "scope", tenantId: "tenant" };
|
|
@@ -74,5 +74,9 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
|
74
74
|
if (config.capabilities !== undefined && !isObject(config.capabilities)) {
|
|
75
75
|
out.push(mk(ERROR, "config-capabilities", file, "`capabilities` must be an object"));
|
|
76
76
|
}
|
|
77
|
+
if (config.embeds !== undefined
|
|
78
|
+
&& !(Array.isArray(config.embeds) && config.embeds.every((/** @type {unknown} */ s) => typeof s === "string"))) {
|
|
79
|
+
out.push(mk(ERROR, "config-embeds", file, '`embeds` must be an array of embed slugs, e.g. ["youtube"]'));
|
|
80
|
+
}
|
|
77
81
|
return out;
|
|
78
82
|
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"./hosted-image-media-types": "./src/hosted-image-media-types.ts",
|
|
19
19
|
"./durable-assets": "./src/durable-assets.ts",
|
|
20
20
|
"./catalog-files": "./src/catalog-files.ts",
|
|
21
|
+
"./embed-catalog": "./src/embed-catalog.mjs",
|
|
21
22
|
"./retention-holds": "./src/retention-holds.ts",
|
|
22
23
|
"./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
|
|
23
24
|
"./declared-tenant-config": "./src/declared-tenant-config.mjs",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Types for the dependency-free declared-tenant-config module (declared-tenant-config.mjs). */
|
|
2
2
|
import type { TenantConfig } from "./tenant.js";
|
|
3
3
|
|
|
4
|
-
export type DeclaredTenantConfigField = "siteType" | "compliance" | "capabilities" | "features";
|
|
4
|
+
export type DeclaredTenantConfigField = "siteType" | "compliance" | "capabilities" | "features" | "embeds";
|
|
5
5
|
export type DeclaredTenantConfig = Partial<Pick<TenantConfig, DeclaredTenantConfigField>>;
|
|
6
6
|
|
|
7
7
|
export const DECLARED_TENANT_CONFIG_FIELDS: readonly DeclaredTenantConfigField[];
|
|
@@ -23,6 +23,7 @@ export const DECLARED_TENANT_CONFIG_FIELDS = Object.freeze([
|
|
|
23
23
|
"compliance",
|
|
24
24
|
"capabilities",
|
|
25
25
|
"features",
|
|
26
|
+
"embeds",
|
|
26
27
|
]);
|
|
27
28
|
|
|
28
29
|
/**
|
|
@@ -86,11 +87,12 @@ export function declaredTenantConfig(config) {
|
|
|
86
87
|
/** @type {Record<string, unknown>} */
|
|
87
88
|
const out = {};
|
|
88
89
|
if (!isPlainObject(config)) return out;
|
|
89
|
-
const { siteType, compliance, capabilities, features } = /** @type {Record<string, unknown>} */ (config);
|
|
90
|
+
const { siteType, compliance, capabilities, features, embeds } = /** @type {Record<string, unknown>} */ (config);
|
|
90
91
|
if (typeof siteType === "string" && SITE_TYPES.has(siteType)) out.siteType = siteType;
|
|
91
92
|
if (isPlainObject(compliance)) out.compliance = compliance;
|
|
92
93
|
if (isPlainObject(capabilities)) out.capabilities = capabilities;
|
|
93
94
|
if (isPlainObject(features)) out.features = features;
|
|
95
|
+
if (Array.isArray(embeds) && embeds.every((slug) => typeof slug === "string")) out.embeds = embeds;
|
|
94
96
|
return out;
|
|
95
97
|
}
|
|
96
98
|
|
|
@@ -101,8 +103,9 @@ export function isEmptyDeclaredTenantConfig(declared) {
|
|
|
101
103
|
|
|
102
104
|
/**
|
|
103
105
|
* Overlay a declaration onto a resolved tenant. `siteType` is replaced; the object
|
|
104
|
-
* fields merge key by key,
|
|
105
|
-
*
|
|
106
|
+
* fields merge key by key, and `embeds` (catalog slugs) is unioned, so a declaration
|
|
107
|
+
* adds to — and can set individual keys of — what the platform record already
|
|
108
|
+
* carries, but never erases what it omits.
|
|
106
109
|
*
|
|
107
110
|
* @template T
|
|
108
111
|
* @param {T} tenant
|
|
@@ -115,9 +118,12 @@ export function applyDeclaredTenantConfig(tenant, declared) {
|
|
|
115
118
|
for (const field of DECLARED_TENANT_CONFIG_FIELDS) {
|
|
116
119
|
const value = declared[field];
|
|
117
120
|
if (value === undefined) continue;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
+
const current = out[field];
|
|
122
|
+
out[field] = isPlainObject(value) && isPlainObject(current)
|
|
123
|
+
? { ...current, ...value }
|
|
124
|
+
: Array.isArray(value) && Array.isArray(current)
|
|
125
|
+
? [...new Set([...current, ...value])]
|
|
126
|
+
: value;
|
|
121
127
|
}
|
|
122
128
|
return /** @type {T} */ (out);
|
|
123
129
|
}
|
|
@@ -1,9 +1,2 @@
|
|
|
1
|
-
/** Types for the
|
|
2
|
-
export
|
|
3
|
-
label: string;
|
|
4
|
-
markers: readonly string[];
|
|
5
|
-
scriptSrc?: readonly string[];
|
|
6
|
-
connectSrc?: readonly string[];
|
|
7
|
-
frameSrc?: readonly string[];
|
|
8
|
-
}
|
|
9
|
-
export const EMBED_PROVIDERS: Readonly<Record<string, EmbedProviderData>>;
|
|
1
|
+
/** Types for the reviewed embed catalog (agency-kit/tools/lib/embed-catalog.mjs), re-exported here. */
|
|
2
|
+
export * from "../../../agency-kit/tools/lib/embed-catalog.mjs";
|
|
@@ -1,40 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
* dev-loop lint (scripts/tenant/lint-embeds.mjs) runs under `node --test` /
|
|
7
|
-
* `node`. Keeping the catalog here, dependency-free, lets those share ONE copy
|
|
8
|
-
* instead of hand-mirroring it. (The published `tot` CLI validator keeps its own
|
|
9
|
-
* inline copy — it can't reach this package at runtime — but a parity test
|
|
10
|
-
* pins it to this module so it can't drift.)
|
|
11
|
-
*
|
|
12
|
-
* Each entry: `markers` are substrings that appear in rendered HTML when the
|
|
13
|
-
* embed is present on a page (so the CSP origins are added only to pages that
|
|
14
|
-
* actually use it); `frameSrc`/`connectSrc`/`scriptSrc` are the EXACT hosts the
|
|
15
|
-
* embed needs in the parent document — never wildcards. The embed itself renders
|
|
16
|
-
* in a cross-origin iframe governed by the provider's own CSP.
|
|
17
|
-
*
|
|
18
|
-
* @typedef {Object} EmbedProviderData
|
|
19
|
-
* @property {string} label
|
|
20
|
-
* @property {readonly string[]} markers
|
|
21
|
-
* @property {readonly string[]} [scriptSrc]
|
|
22
|
-
* @property {readonly string[]} [connectSrc]
|
|
23
|
-
* @property {readonly string[]} [frameSrc]
|
|
24
|
-
*
|
|
25
|
-
* @type {Readonly<Record<string, EmbedProviderData>>}
|
|
26
|
-
*/
|
|
27
|
-
export const EMBED_PROVIDERS = {
|
|
28
|
-
pipedrive: {
|
|
29
|
-
label: "Pipedrive Web Forms",
|
|
30
|
-
// The embed div carries data-pd-webforms=… and the loader is
|
|
31
|
-
// webforms.pipedrive.com/f/loader; either marker means the form is present.
|
|
32
|
-
markers: ["webforms.pipedrive.com", "pipedriveWebForms"],
|
|
33
|
-
// The nonce-allowed loader fetches its form definition from this origin and
|
|
34
|
-
// mounts the form in a same-origin iframe from it. script-src as a
|
|
35
|
-
// belt-and-suspenders in case a future loader drops the parent-tag nonce.
|
|
36
|
-
scriptSrc: ["https://webforms.pipedrive.com"],
|
|
37
|
-
connectSrc: ["https://webforms.pipedrive.com"],
|
|
38
|
-
frameSrc: ["https://webforms.pipedrive.com"],
|
|
39
|
-
},
|
|
40
|
-
};
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The reviewed embed catalog is the agency kit's pure module, so a store developer checks an embed
|
|
3
|
+
// with the kit alone and the platform enforces the same entries. This subpath is how the platform
|
|
4
|
+
// imports it.
|
|
5
|
+
export * from "../../../agency-kit/tools/lib/embed-catalog.mjs";
|
|
@@ -1241,10 +1241,11 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1241
1241
|
// spot a human has to notice. Advisory (info) when there's no embeds.json to
|
|
1242
1242
|
// compare against, since the opt-in may be the platform build-time default.
|
|
1243
1243
|
if (existsSync(contentDir)) {
|
|
1244
|
-
|
|
1244
|
+
// The opt-in is the `embeds` .tot/config.json declares, plus an embeds.json.
|
|
1245
|
+
let declaredEmbeds = Array.isArray(config?.embeds) ? config.embeds.filter((s) => typeof s === "string") : null;
|
|
1245
1246
|
if (existsSync(embedsPath)) {
|
|
1246
1247
|
const { value } = readJsonSafe(embedsPath);
|
|
1247
|
-
if (Array.isArray(value)) declaredEmbeds = value.filter((s) => typeof s === "string");
|
|
1248
|
+
if (Array.isArray(value)) declaredEmbeds = [...(declaredEmbeds ?? []), ...value.filter((s) => typeof s === "string")];
|
|
1248
1249
|
}
|
|
1249
1250
|
for (const f of lintTenantEmbeds({ dir: contentDir, declared: declaredEmbeds }).findings) {
|
|
1250
1251
|
findings.push(mk(f.level, "embed-csp", rel(f.file), f.message));
|