@tokenoftrust/storefront-runner 2.2.95 → 2.2.97
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/cli/src/tot-repo-mappings.mjs +153 -0
- package/packages/cli/src/visual-parity.mjs +15 -9
- 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.97",
|
|
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
|
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.tot/config.json` mapping resolution — the ONE reading of a store repo's
|
|
3
|
+
* declared layout, shared by every consumer: the hosted reconcile (preview,
|
|
4
|
+
* candidate, aggregate, publication), `tot dev`'s checkout graft, `tot validate`,
|
|
5
|
+
* the platform's materialize / asset-repair scripts, and this kit's
|
|
6
|
+
* `repair-tenant-config.mjs`. The platform imports this file from the kit.
|
|
7
|
+
*
|
|
8
|
+
* A mapping is `{ workspace, repo, kind }`:
|
|
9
|
+
* - `workspace` — where the files physically live in the store repo. It is the
|
|
10
|
+
* only source of truth for location; nothing assumes a `tenants/<id>/` layout
|
|
11
|
+
* inside the store's own repo.
|
|
12
|
+
* - `repo` — the storefront artifact target, `tenants/<segment>/<rel>`. Only
|
|
13
|
+
* `<rel>` (the tenant-relative path: `theme.json`, `content/`, `public/`, …)
|
|
14
|
+
* is read from it. The tenant namespace always comes from the CANONICAL
|
|
15
|
+
* tenant id — the store's registered id hosted, the config's `tenant` locally
|
|
16
|
+
* — never from `<segment>`, so a config whose segment disagrees with its
|
|
17
|
+
* tenant id resolves identically on every surface.
|
|
18
|
+
*
|
|
19
|
+
* Pure and dependency-free so kit tools, platform `.mjs` scripts and TypeScript
|
|
20
|
+
* consumers (types in the adjacent `.d.mts`) all import it. The published CLI
|
|
21
|
+
* installs without the kit, so it ships a byte-identical copy that its own test
|
|
22
|
+
* suite holds equal to this file.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Platform test tenants nest one level deeper: `tenants/e2e/<id>.e2e.test/`. */
|
|
26
|
+
const TARGET_RE = /^tenants\/(?:e2e\/([^/]+\.e2e\.test)|([^/]+))(?:\/(.*))?$/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Split a declared mapping target into the tenant segment it names and the
|
|
30
|
+
* tenant-relative path it addresses. Null when the target is not under
|
|
31
|
+
* `tenants/<segment>/`, or when its relative part is not a clean path (empty,
|
|
32
|
+
* `.` or `..` segments) — such a target could address outside the tenant.
|
|
33
|
+
* A trailing `/` (tree target) is preserved on `rel`; the tenant root is `""`.
|
|
34
|
+
* @param {unknown} repo
|
|
35
|
+
* @returns {{ segment: string, rel: string } | null}
|
|
36
|
+
*/
|
|
37
|
+
export function parseMappingTarget(repo) {
|
|
38
|
+
if (typeof repo !== "string") return null;
|
|
39
|
+
const m = TARGET_RE.exec(repo);
|
|
40
|
+
if (!m) return null;
|
|
41
|
+
const segment = m[1] ?? m[2];
|
|
42
|
+
const rel = m[3] ?? "";
|
|
43
|
+
const parts = rel.replace(/\/$/, "").split("/");
|
|
44
|
+
if (rel && parts.some((p) => !p || p === "." || p === "..")) return null;
|
|
45
|
+
return { segment, rel };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The canonical artifact root for a tenant: `tenants/<tenantId>/`. */
|
|
49
|
+
export function tenantArtifactRoot(tenantId) {
|
|
50
|
+
return `tenants/${tenantId}/`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function asDir(path) {
|
|
54
|
+
return path === "" || path.endsWith("/") ? path : `${path}/`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mappingsOf(config) {
|
|
58
|
+
return Array.isArray(config?.mappings) ? config.mappings : [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Map a tenant-repo path to its tenant-relative path (e.g. `content/home.json`).
|
|
63
|
+
* First match wins; a `file` mapping matches exactly, a `tree` mapping by prefix.
|
|
64
|
+
* Null when no mapping covers the path or the covering mapping's target is not
|
|
65
|
+
* a valid tenant target.
|
|
66
|
+
* @param {{ mappings?: unknown }} config
|
|
67
|
+
* @param {string} workspacePath
|
|
68
|
+
* @returns {string | null}
|
|
69
|
+
*/
|
|
70
|
+
export function mapWorkspaceToTenantRelative(config, workspacePath) {
|
|
71
|
+
for (const m of mappingsOf(config)) {
|
|
72
|
+
if (!m || typeof m !== "object" || typeof m.workspace !== "string") continue;
|
|
73
|
+
if (m.kind === "file") {
|
|
74
|
+
if (workspacePath !== m.workspace) continue;
|
|
75
|
+
const target = parseMappingTarget(m.repo);
|
|
76
|
+
return target ? target.rel : null;
|
|
77
|
+
}
|
|
78
|
+
if (m.kind === "tree") {
|
|
79
|
+
const ws = asDir(m.workspace);
|
|
80
|
+
if (!workspacePath.startsWith(ws)) continue;
|
|
81
|
+
const target = parseMappingTarget(m.repo);
|
|
82
|
+
return target ? asDir(target.rel) + workspacePath.slice(ws.length) : null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Map a tenant-repo path to its storefront artifact path under the CANONICAL
|
|
90
|
+
* tenant root (`tenants/<tenantId>/<rel>`). Null when unmapped.
|
|
91
|
+
* @param {{ mappings?: unknown }} config
|
|
92
|
+
* @param {string} tenantId
|
|
93
|
+
* @param {string} workspacePath
|
|
94
|
+
* @returns {string | null}
|
|
95
|
+
*/
|
|
96
|
+
export function mapWorkspaceToArtifact(config, tenantId, workspacePath) {
|
|
97
|
+
const rel = mapWorkspaceToTenantRelative(config, workspacePath);
|
|
98
|
+
return rel === null ? null : tenantArtifactRoot(tenantId) + rel;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The inverse: where a tenant-relative path (`theme.json`, `content`, `public`,
|
|
103
|
+
* `content/home.json`) lives in the tenant repo, per the declared mappings.
|
|
104
|
+
* Unmapped paths resolve to themselves — the flat checkout layout — so a
|
|
105
|
+
* consumer still finds files the mappings do not cover (and the config-coverage
|
|
106
|
+
* check reports the gap).
|
|
107
|
+
* @param {{ mappings?: unknown }} config
|
|
108
|
+
* @param {string} relPath tenant-relative, no leading `/`
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function workspacePathForTenantRelative(config, relPath) {
|
|
112
|
+
const bare = relPath.replace(/\/$/, "");
|
|
113
|
+
for (const m of mappingsOf(config)) {
|
|
114
|
+
if (!m || typeof m !== "object" || typeof m.workspace !== "string") continue;
|
|
115
|
+
const target = parseMappingTarget(m.repo);
|
|
116
|
+
if (!target) continue;
|
|
117
|
+
const targetRel = target.rel.replace(/\/$/, "");
|
|
118
|
+
const ws = m.workspace.replace(/\/$/, "");
|
|
119
|
+
if (m.kind === "file") {
|
|
120
|
+
if (targetRel === bare) return ws;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (m.kind !== "tree") continue;
|
|
124
|
+
if (targetRel === bare) return ws;
|
|
125
|
+
const prefix = asDir(targetRel);
|
|
126
|
+
if (bare.startsWith(prefix)) {
|
|
127
|
+
const rest = bare.slice(prefix.length);
|
|
128
|
+
return ws ? `${ws}/${rest}` : rest;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return bare;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Mappings whose declared target names a tenant segment other than the
|
|
136
|
+
* canonical tenant id. They still resolve correctly (the segment is never
|
|
137
|
+
* read); this exists so validators can tell the author the config disagrees
|
|
138
|
+
* with itself.
|
|
139
|
+
* @param {{ mappings?: unknown }} config
|
|
140
|
+
* @param {string} tenantId
|
|
141
|
+
* @returns {{ workspace: string, repo: string, segment: string }[]}
|
|
142
|
+
*/
|
|
143
|
+
export function foreignTenantSegments(config, tenantId) {
|
|
144
|
+
const out = [];
|
|
145
|
+
for (const m of mappingsOf(config)) {
|
|
146
|
+
if (!m || typeof m !== "object") continue;
|
|
147
|
+
const target = parseMappingTarget(m.repo);
|
|
148
|
+
if (target && target.segment !== tenantId) {
|
|
149
|
+
out.push({ workspace: String(m.workspace ?? ""), repo: m.repo, segment: target.segment });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Read-only closure checks for the optional visual-parity migration module.
|
|
3
|
-
* The tenant checkout remains portable: this reads only content/, public
|
|
4
|
-
* .tot/visual-parity.json, never a preview host or a delivery
|
|
3
|
+
* The tenant checkout remains portable: this reads only content/, public/ and theme.json (where
|
|
4
|
+
* `.tot/config.json` maps them) and .tot/visual-parity.json, never a preview host or a delivery
|
|
5
|
+
* provider.
|
|
5
6
|
*/
|
|
6
7
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
8
|
import { spawnSync } from "node:child_process";
|
|
8
9
|
import { extname, join, relative } from "node:path";
|
|
10
|
+
import { workspacePathForTenantRelative } from "./tot-repo-mappings.mjs";
|
|
9
11
|
|
|
10
12
|
const ERROR = "error";
|
|
11
13
|
// Public routes implemented by the renderer. Authentication and cart URLs are
|
|
@@ -59,10 +61,10 @@ function parseConfig(tenantDir) {
|
|
|
59
61
|
}
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
/** The checkout's `.tot/config.json`, or null when absent or unreadable. */
|
|
65
|
+
function readTotConfig(tenantDir) {
|
|
63
66
|
try {
|
|
64
|
-
|
|
65
|
-
return typeof value?.tenant === "string" ? value.tenant : null;
|
|
67
|
+
return JSON.parse(readFileSync(join(tenantDir, ".tot", "config.json"), "utf8"));
|
|
66
68
|
} catch { return null; }
|
|
67
69
|
}
|
|
68
70
|
|
|
@@ -278,7 +280,11 @@ export function validateVisualParityClosure(tenantDir, options = {}) {
|
|
|
278
280
|
const findings = [];
|
|
279
281
|
const { value: config, findings: configFindings } = parseConfig(tenantDir);
|
|
280
282
|
findings.push(...configFindings);
|
|
281
|
-
|
|
283
|
+
// Files are found where the checkout's `.tot/config.json` mappings declare them, as `tot validate`
|
|
284
|
+
// and the hosted reconcile find them.
|
|
285
|
+
const totConfig = readTotConfig(tenantDir);
|
|
286
|
+
const located = (rel) => join(tenantDir, workspacePathForTenantRelative(totConfig, rel));
|
|
287
|
+
const pagesDir = located("content/pages");
|
|
282
288
|
const files = walk(pagesDir).filter((file) => file.endsWith(".json")).sort();
|
|
283
289
|
if (files.length === 0) findings.push(finding("parity-pages-missing", "content/pages/", "no page JSON files define the visual-parity route scope", "add the paired authored page records under content/pages/ before closing parity"));
|
|
284
290
|
const pages = [];
|
|
@@ -297,17 +303,17 @@ export function validateVisualParityClosure(tenantDir, options = {}) {
|
|
|
297
303
|
const routes = new Set(["/"]);
|
|
298
304
|
for (const page of pages) routes.add(normalizeRoute(page.value.slug));
|
|
299
305
|
/** @type {{findings:ParityFinding[], tenantId:string|null, publicDir:string, routes:Set<string>, allowlist:string[], decodeAssets:DecodeAsset[]}} */
|
|
300
|
-
const context = { findings, tenantId: tenantId ??
|
|
306
|
+
const context = { findings, tenantId: tenantId ?? (typeof totConfig?.tenant === "string" ? totConfig.tenant : null), publicDir: located("public"), routes, allowlist: Array.isArray(config.externalNavigationAllowlist) ? config.externalNavigationAllowlist : [], decodeAssets: [] };
|
|
301
307
|
if (!context.tenantId) findings.push(finding("parity-tenant-id", ".tot/config.json", "tenant id is required to verify /tenants/<tenant>/ assets", "set tenant in .tot/config.json or pass a resolved tenant id"));
|
|
302
308
|
for (const page of pages) inspectValue(page.value, page.file, [], context);
|
|
303
309
|
for (const name of ["home.json", "chrome.json"]) {
|
|
304
|
-
const path =
|
|
310
|
+
const path = located(`content/${name}`);
|
|
305
311
|
if (existsSync(path)) {
|
|
306
312
|
const value = readStructured(path, `content/${name}`, findings);
|
|
307
313
|
if (value != null) inspectValue(value, `content/${name}`, [], context);
|
|
308
314
|
}
|
|
309
315
|
}
|
|
310
|
-
const themePath =
|
|
316
|
+
const themePath = located("theme.json");
|
|
311
317
|
if (existsSync(themePath)) {
|
|
312
318
|
const value = readStructured(themePath, "theme.json", findings);
|
|
313
319
|
if (value != null) inspectValue(value, "theme.json", [], context);
|
|
@@ -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));
|