@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.
@@ -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.95",
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/, and
4
- * .tot/visual-parity.json, never a preview host or a delivery provider.
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
- function tenantFromConfig(tenantDir) {
64
+ /** The checkout's `.tot/config.json`, or null when absent or unreadable. */
65
+ function readTotConfig(tenantDir) {
63
66
  try {
64
- const value = JSON.parse(readFileSync(join(tenantDir, ".tot", "config.json"), "utf8"));
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
- const pagesDir = join(tenantDir, "content", "pages");
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 ?? tenantFromConfig(tenantDir), publicDir: join(tenantDir, "public"), routes, allowlist: Array.isArray(config.externalNavigationAllowlist) ? config.externalNavigationAllowlist : [], decodeAssets: [] };
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 = join(tenantDir, "content", name);
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 = join(tenantDir, "theme.json");
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, so a declaration adds to — and can set individual keys
105
- * of — what the platform record already carries, but never erases a key it omits.
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
- out[field] = isPlainObject(value) && isPlainObject(out[field])
119
- ? { ...out[field], ...value }
120
- : value;
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 dependency-free embed catalog data module (embed-catalog.mjs). */
2
- export interface EmbedProviderData {
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
- * Single source of truth for the third-party embed catalog (CSP allowlist).
3
- *
4
- * Plain JS ON PURPOSE: this data is consumed both by TypeScript (csp.ts, the
5
- * worker runtime) AND by plain-`node` tooling that can't import TS — the
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
- let declaredEmbeds = null;
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));