@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.
@@ -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.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, 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));