@tokenoftrust/storefront-runner 2.0.1 → 2.0.3

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.
Files changed (120) hide show
  1. package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +19 -1
  2. package/apps/storefront/dev-plugins/tenant-validate-on-save.mjs +78 -0
  3. package/apps/storefront/src/middleware/index.ts +17 -1
  4. package/package.json +1 -1
  5. package/scripts/lib/tenant-dirs.mjs +80 -0
  6. package/scripts/tenant/lint-embeds.mjs +180 -0
  7. package/scripts/tenant/materialize-tenant-content.mjs +533 -0
  8. package/scripts/tenant/validate.mjs +837 -0
  9. package/apps/storefront/scripts/build-v84-clean-clone-standalone.mjs +0 -68
  10. package/apps/storefront/scripts/deny-v84-clean-clone-standalone-capabilities.mjs +0 -111
  11. package/apps/storefront/scripts/deny-v84-dogfood-capabilities.mjs +0 -2
  12. package/apps/storefront/scripts/deny-v84-lifecycle-capabilities.mjs +0 -247
  13. package/apps/storefront/scripts/dogfood-v84-nonportable-routes.ts +0 -73
  14. package/apps/storefront/scripts/fixed-v84-native-install-producer.ts +0 -79
  15. package/apps/storefront/scripts/generated/v84-clean-clone-replay.bundle.mjs +0 -10982
  16. package/apps/storefront/scripts/import-v84-through-ws2.ts +0 -38
  17. package/apps/storefront/scripts/initialize-v84-lifecycle-tooling-dependency.ts +0 -2
  18. package/apps/storefront/scripts/initialize-v84-lifecycle-tooling.ts +0 -4
  19. package/apps/storefront/scripts/install-v84-native-through-ws1.ts +0 -185
  20. package/apps/storefront/scripts/prove-clone-isolation.ts +0 -506
  21. package/apps/storefront/scripts/replay-v84-clean-clone-pass.ts +0 -148
  22. package/apps/storefront/scripts/replay-v84-clean-clone-standalone.ts +0 -35
  23. package/apps/storefront/scripts/replay-v84-clean-clone.ts +0 -214
  24. package/apps/storefront/scripts/run-fixed-v84-native-install-producer.mjs +0 -18
  25. package/apps/storefront/scripts/run-v84-clone-isolation-proof.mjs +0 -18
  26. package/apps/storefront/scripts/run-v84-clone-lifecycle-proof.mjs +0 -18
  27. package/apps/storefront/scripts/run-v84-import-through-ws2-proof.mjs +0 -17
  28. package/apps/storefront/scripts/run-v84-nonportable-route-dogfood.mjs +0 -15
  29. package/apps/storefront/scripts/sanitize-v84-proof-environment.mjs +0 -21
  30. package/apps/storefront/scripts/v84CleanCloneGraphPin.ts +0 -42
  31. package/apps/storefront/scripts/v84CleanCloneSourceGraph.ts +0 -145
  32. package/apps/storefront/scripts/validate-v84-clone-lifecycle.ts +0 -18
  33. package/apps/storefront/src/components/admin/AdminAIWorkflowsTab.astro +0 -531
  34. package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +0 -878
  35. package/apps/storefront/src/components/admin/AdminBlogTab.astro +0 -1435
  36. package/apps/storefront/src/components/admin/AdminCheckoutSettingsPanel.astro +0 -226
  37. package/apps/storefront/src/components/admin/AdminCustomersTab.astro +0 -593
  38. package/apps/storefront/src/components/admin/AdminFulfillmentTab.astro +0 -465
  39. package/apps/storefront/src/components/admin/AdminIntegrationsTab.astro +0 -105
  40. package/apps/storefront/src/components/admin/AdminProductsTab.astro +0 -901
  41. package/apps/storefront/src/components/admin/AdminPublishTab.astro +0 -2978
  42. package/apps/storefront/src/components/admin/AdminReportingTab.astro +0 -435
  43. package/apps/storefront/src/components/admin/AdminSettingsTab.astro +0 -782
  44. package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +0 -611
  45. package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +0 -1223
  46. package/apps/storefront/src/lib/dev/cockpitStore.ts +0 -57
  47. package/apps/storefront/src/lib/publish/adminPublishTab.ts +0 -4625
  48. package/apps/storefront/src/lib/publish/apex-readiness.ts +0 -360
  49. package/apps/storefront/src/lib/publish/buildStream.ts +0 -269
  50. package/apps/storefront/src/lib/publish/collapsePersistence.ts +0 -72
  51. package/apps/storefront/src/lib/publish/dispatchHealth.ts +0 -267
  52. package/apps/storefront/src/lib/publish/domainState.ts +0 -360
  53. package/apps/storefront/src/lib/publish/lifecycleState.ts +0 -369
  54. package/apps/storefront/src/lib/publish/liveReleaseSummary.ts +0 -29
  55. package/apps/storefront/src/lib/publish/mergeDoctorOnFailure.ts +0 -78
  56. package/apps/storefront/src/lib/publish/mergeHealthBanner.ts +0 -62
  57. package/apps/storefront/src/lib/publish/mergeRowFindings.ts +0 -124
  58. package/apps/storefront/src/lib/publish/mergeSupportEscalation.ts +0 -75
  59. package/apps/storefront/src/lib/publish/panelPolling.ts +0 -78
  60. package/apps/storefront/src/lib/publish/publish-guard.ts +0 -88
  61. package/apps/storefront/src/lib/publish/publishFormat.ts +0 -165
  62. package/apps/storefront/src/lib/publish/publishHero.ts +0 -177
  63. package/apps/storefront/src/lib/publish/reviewRelease/changeState.ts +0 -66
  64. package/apps/storefront/src/lib/publish/reviewRelease/copy.ts +0 -141
  65. package/apps/storefront/src/lib/publish/reviewRelease/index.ts +0 -41
  66. package/apps/storefront/src/lib/publish/reviewRelease/permissions.ts +0 -47
  67. package/apps/storefront/src/lib/publish/reviewRelease/releaseState.ts +0 -42
  68. package/apps/storefront/src/lib/publish/reviewRelease/types.ts +0 -59
  69. package/apps/storefront/src/lib/publish/reviewRelease/viewModel.ts +0 -440
  70. package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/actions.ts +0 -161
  71. package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/controller.ts +0 -325
  72. package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/render.ts +0 -241
  73. package/apps/storefront/src/lib/publish/shipWorkspace.ts +0 -439
  74. package/apps/storefront/src/lib/publish/sseRoute.ts +0 -119
  75. package/apps/storefront/src/lib/publish/stageBundleUpload.ts +0 -215
  76. package/apps/storefront/src/lib/publish/streamLogConsole.ts +0 -142
  77. package/apps/storefront/src/lib/publish/versionNumber.ts +0 -52
  78. package/apps/storefront/src/pages/admin/ops-timeline.astro +0 -420
  79. package/apps/storefront/src/pages/admin/review-release.astro +0 -265
  80. package/apps/storefront/src/pages/admin/select.astro +0 -96
  81. package/apps/storefront/src/pages/admin.astro +0 -568
  82. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +0 -86
  83. package/apps/storefront/src/pages/api/apps/admin/gtm-migration-install.ts +0 -174
  84. package/apps/storefront/src/pages/api/apps/admin/gtm-migration.ts +0 -96
  85. package/apps/storefront/src/pages/api/apps/admin/health.ts +0 -44
  86. package/apps/storefront/src/pages/api/apps/admin/install.ts +0 -125
  87. package/apps/storefront/src/pages/api/apps/admin/integration-lifecycle.ts +0 -118
  88. package/apps/storefront/src/pages/api/apps/admin/integrations.ts +0 -89
  89. package/apps/storefront/src/pages/api/apps/admin/list.ts +0 -26
  90. package/apps/storefront/src/pages/api/apps/admin/platform-adapters.ts +0 -85
  91. package/apps/storefront/src/pages/api/apps/admin/resume.ts +0 -79
  92. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +0 -80
  93. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +0 -98
  94. package/apps/storefront/src/pages/api/apps/admin/update.ts +0 -126
  95. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +0 -70
  96. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +0 -51
  97. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +0 -255
  98. package/apps/storefront/src/pages/api/apps/oauth/token.ts +0 -87
  99. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +0 -162
  100. package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +0 -39
  101. package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +0 -49
  102. package/apps/storefront/src/pages/api/apps/v1/health.ts +0 -32
  103. package/apps/storefront/src/pages/api/apps/v1/inventory.ts +0 -59
  104. package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +0 -48
  105. package/apps/storefront/src/pages/api/apps/v1/orders.ts +0 -73
  106. package/apps/storefront/src/pages/api/apps/v1/reports.ts +0 -21
  107. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +0 -74
  108. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +0 -47
  109. package/apps/storefront/src/pages/api/cache-purge.ts +0 -55
  110. package/apps/storefront/src/pages/api/compliance/tamper.ts +0 -88
  111. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +0 -154
  112. package/apps/storefront/src/pages/api/dashboard/vendor-search.ts +0 -120
  113. package/apps/storefront/src/pages/api/integrations/v1/[operation].ts +0 -108
  114. package/apps/storefront/src/pages/api/publish/stage-bundle.ts +0 -70
  115. package/apps/storefront/src/pages/api/test/dev-session.ts +0 -133
  116. package/apps/storefront/src/pages/cockpit.astro +0 -1548
  117. package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +0 -119
  118. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +0 -105
  119. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +0 -177
  120. package/apps/storefront/src/pages/dashboard/index.astro +0 -160
@@ -4,7 +4,9 @@ import { readdirSync, existsSync } from "node:fs";
4
4
  import { basename, dirname, join } from "node:path";
5
5
  import { markSave, reportServerUp } from "./dev-loop-state.mjs";
6
6
  import { isTransientWatchFile } from "../../../scripts/dev/transient-files.mjs";
7
- import { E2E_TENANT_DIR, tenantDirSegments } from "../../../scripts/lib/tenant-dirs.mjs";
7
+ import { E2E_TENANT_DIR, tenantDirSegments, tenantDirRelative } from "../../../scripts/lib/tenant-dirs.mjs";
8
+ import { validateSavedFile } from "./tenant-validate-on-save.mjs";
9
+ import { validateTenant } from "../../../scripts/tenant/validate.mjs";
8
10
  import { readFile } from "node:fs/promises";
9
11
 
10
12
  // Repo-root tenants/ dir (colocated tenant layout) + the generated static dest
@@ -15,6 +17,10 @@ const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
15
17
  const TENANTS_DIR = join(REPO_ROOT, "tenants").replace(/\\/g, "/");
16
18
  const PUBLIC_TENANTS_DEST = fileURLToPath(new URL("../public/tenants", import.meta.url));
17
19
 
20
+ /** Per-file finding signatures, so an unchanged warning is not reprinted on every
21
+ * keystroke-save. Lives for the dev server's lifetime. */
22
+ const savedFileFindings = new Map();
23
+
18
24
  /**
19
25
  * Load a TS module's exports for Node-side (plugin) use, reusing Vite's own
20
26
  * transform pipeline instead of a plain `import` (which can't parse `.ts`) or
@@ -265,6 +271,18 @@ export function tenantHotReload() {
265
271
  // matching reload is timestamped by dev-loop-monitor's ws/hot wrap.
266
272
  markSave(rest, "change");
267
273
 
274
+ // Validate the save — deliberately NOT awaited. The reload branches below
275
+ // dispatch exactly as they did before, and the findings arrive in the
276
+ // terminal a beat later. Putting this in the reload path would tax every
277
+ // save to report something that is almost always empty.
278
+ void validateSavedFile({
279
+ tenantDir: join(REPO_ROOT, tenantDirRelative(id)),
280
+ tenantId: id,
281
+ rest,
282
+ memo: savedFileFindings,
283
+ validate: validateTenant,
284
+ });
285
+
268
286
  // Content HTML: the change invalidates the SSR module graph, so the
269
287
  // Cloudflare Vite plugin reloads the worker and — once it is ready — emits
270
288
  // its own correctly-timed full-reload (which the injected Vite HMR client
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Validate a tenant file on save, OFF the reload path.
3
+ *
4
+ * The validator's warnings used to reach a developer only if they remembered to
5
+ * run `tot validate`, or at `tot submit` — both after the fact. The earliest
6
+ * moment is the save itself, and `tot dev` already watches every tenant file.
7
+ *
8
+ * The constraint is that the dev loop's whole value is save→HMR speed, so this
9
+ * must never sit in that path: `handleHotUpdate` fires this and does not await it.
10
+ * The reload is dispatched exactly as before and the findings land a beat later in
11
+ * the terminal. A whole-tenant pass measures ~13ms, so there is no need to scope
12
+ * the validator itself — only to keep it off the critical path.
13
+ *
14
+ * Errors are NOT reported here. An error already blocks `tot submit`, and the dev
15
+ * loop is where a page is half-written by definition — a fragment mid-edit
16
+ * legitimately fails checks it will pass a keystroke later. Warnings are the ones
17
+ * that otherwise ship silently, which is the gap this closes.
18
+ */
19
+
20
+ /** Findings worth interrupting a save for: warnings on the file just saved. */
21
+ export function findingsForSavedFile(findings, rest) {
22
+ const target = String(rest || "").replace(/\\/g, "/");
23
+ return (findings || []).filter(
24
+ (f) => f.level !== "error" && String(f.file || "").replace(/\\/g, "/").endsWith(target),
25
+ );
26
+ }
27
+
28
+ /** A stable identity for a file's finding set, so an unchanged set is not reprinted. */
29
+ export function findingsSignature(findings) {
30
+ return findings.map((f) => `${f.rule}:${f.message}`).sort().join("|");
31
+ }
32
+
33
+ /**
34
+ * Decide what to print for one save. Pure: the caller owns the memo and the
35
+ * console. Returns null when there is nothing new to say — an unchanged finding
36
+ * set on every keystroke-save is noise that trains people to ignore the channel.
37
+ *
38
+ * @param {{ findings: any[], rest: string, previousSignature: string | undefined }} input
39
+ * @returns {{ signature: string, lines: string[] } | null}
40
+ */
41
+ export function reportForSave({ findings, rest, previousSignature }) {
42
+ const mine = findingsForSavedFile(findings, rest);
43
+ const signature = findingsSignature(mine);
44
+ if (signature === (previousSignature ?? "")) return null;
45
+ // Newly clean: say so once, so a fixed warning visibly clears rather than just
46
+ // never being mentioned again.
47
+ if (!mine.length) return { signature, lines: [` ✓ ${rest} — validation warnings cleared`] };
48
+ return {
49
+ signature,
50
+ lines: [
51
+ ` ⚠ ${rest} — ${mine.length} validation warning(s)`,
52
+ ...mine.map((f) => ` ⚠ [${f.rule}] ${f.message}`),
53
+ ],
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Run the validator for the tenant owning the saved file and print anything new.
59
+ * Never throws and never returns a rejected promise: a validator fault must not
60
+ * break the dev loop, which is the thing the developer is actually using.
61
+ *
62
+ * @param {{ tenantDir: string, tenantId: string, rest: string,
63
+ * memo: Map<string, string>, validate: (dir: string, opts?: any) => any,
64
+ * log?: (line: string) => void }} input
65
+ */
66
+ export async function validateSavedFile({ tenantDir, tenantId, rest, memo, validate, log = console.error }) {
67
+ let findings;
68
+ try {
69
+ ({ findings } = validate(tenantDir, { tenantId }));
70
+ } catch {
71
+ return; // a validator fault is never worth breaking the loop over
72
+ }
73
+ const key = `${tenantId}/${rest}`;
74
+ const report = reportForSave({ findings, rest, previousSignature: memo.get(key) });
75
+ if (!report) return;
76
+ memo.set(key, report.signature);
77
+ for (const line of report.lines) log(line);
78
+ }
@@ -69,6 +69,7 @@ import {
69
69
  PUBLIC_HOME_TENANT_ID,
70
70
  } from "@/lib/auth/loginGate";
71
71
  import { resolveAdminLanding, isAdminSelectPath } from "@/lib/auth/adminLanding";
72
+ import { resolveAdminTenantMode } from "@/lib/adminTenantEnvelope";
72
73
  import { gatedHoldingResponse } from "@/lib/auth/gatePage";
73
74
  import { readSession, KvSessionStore, SESSION_COOKIE } from "@/lib/auth/session";
74
75
  import { isMaintenanceOn, maintenanceResponse } from "@/lib/maintenance";
@@ -258,7 +259,22 @@ export const onRequest = defineMiddleware(async (context, next) => {
258
259
  // an explicit vendor selection sees the staffRoles-scoped capability. The
259
260
  // ship gates (decideIsOwner/resolveShipPrincipal) read this capability,
260
261
  // so binding it here keeps owner-resolution server-side + tenant-correct.
261
- const hostCapability = sessionHostCapability(viewerRecord, tenant.appDomain, nowSeconds);
262
+ //
263
+ // On an admin path, "the host" a staff viewer is really acting on is the
264
+ // ?asTenant= impersonation target (mirrors admin.astro/AdminPublishTab.astro's
265
+ // own resolveAdminTenantMode call) — resolving capability against the routed
266
+ // host instead left a staff viewer who selected another tenant reading as
267
+ // "not an owner" of it even while holding a real grant, because their
268
+ // staffSelection is bound to the SELECTED tenant, never the host app.
269
+ const capabilityHost = isAdminPath(url.pathname)
270
+ ? resolveAdminTenantMode({
271
+ session: viewerRecord,
272
+ adminAppTenant: tenant.appDomain,
273
+ requestedTarget: url.searchParams.get("asTenant"),
274
+ nowSeconds,
275
+ }).envelope.targetTenant
276
+ : tenant.appDomain;
277
+ const hostCapability = sessionHostCapability(viewerRecord, capabilityHost, nowSeconds);
262
278
  locals.viewer = {
263
279
  email: viewerRecord.email,
264
280
  roles: hostCapability ? [hostCapability] : viewerRecord.roles,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
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",
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Where a tenant's colocated directory lives — the NODE-side copy.
3
+ *
4
+ * A tenant IS a directory. Real tenants sit directly under `tenants/<id>/`;
5
+ * PLATFORM TEST tenants (every id ending `.e2e.test`) sit one level deeper,
6
+ * under `tenants/e2e/<id>/`.
7
+ *
8
+ * `@tot/public-runtime` owns this contract for the Worker/TS side
9
+ * (`packages/public-runtime/src/tenant.ts`), but it is TypeScript source with no
10
+ * build output, so the `.mjs` build/dev/tenant scripts cannot import it. This is
11
+ * that same total mapping, byte-for-byte in behavior, and
12
+ * `packages/public-runtime/tests/tenant-dirs-parity.test.ts` fails the build if
13
+ * the two ever disagree on any id.
14
+ */
15
+
16
+ /** The directory segment platform test tenants are nested under. */
17
+ export const E2E_TENANT_DIR = "e2e";
18
+
19
+ /** True for a PLATFORM TEST tenant id — the `<scenario>.e2e.test` class. */
20
+ export function isE2ETenantId(tenantId) {
21
+ return /\.e2e\.test$/.test(tenantId);
22
+ }
23
+
24
+ /** Repo-relative, POSIX-separated directory for a tenant id. */
25
+ export function tenantDirRelative(tenantId) {
26
+ return isE2ETenantId(tenantId)
27
+ ? `tenants/${E2E_TENANT_DIR}/${tenantId}`
28
+ : `tenants/${tenantId}`;
29
+ }
30
+
31
+ /** The `/tenants/…/<id>/` substring identifying one tenant inside a path. */
32
+ export function tenantDirSegment(tenantId) {
33
+ return `/${tenantDirRelative(tenantId)}/`;
34
+ }
35
+
36
+ /** The tenant id a `…/tenants/[e2e/]<id>/…` path encodes, or null. */
37
+ export function tenantIdFromPath(path) {
38
+ return /\/tenants\/(?:e2e\/)?([^/]+)\//.exec(path)?.[1] ?? null;
39
+ }
40
+
41
+ /**
42
+ * Narrow a set of tenant ids to what a generalized listing should show —
43
+ * platform test tenants are dropped unless explicitly requested.
44
+ */
45
+ export function listedTenantIds(ids, opts = {}) {
46
+ return opts.includeTestTenants ? [...ids] : ids.filter((id) => !isE2ETenantId(id));
47
+ }
48
+
49
+ /**
50
+ * Path segments (relative to the repo root) for a tenant's own directory —
51
+ * ready for `join(repoRoot, ...tenantDirSegments(id))`.
52
+ */
53
+ export function tenantDirSegments(tenantId) {
54
+ return tenantDirRelative(tenantId).split("/");
55
+ }
56
+
57
+ /**
58
+ * Every tenant id with a directory under `tenants/`, real and test, from one
59
+ * `readdir`-shaped view of the tree. `readdirSync` is passed in so the walk
60
+ * stays testable without a filesystem.
61
+ *
62
+ * @param tenantsDir absolute path to the repo's `tenants/` directory
63
+ * @param readdir `(dir) => string[]` listing a directory's entry names
64
+ * @param isDir `(path) => boolean`
65
+ * @param join path join
66
+ */
67
+ export function listTenantDirIds(tenantsDir, readdir, isDir, join) {
68
+ const ids = [];
69
+ for (const entry of readdir(tenantsDir)) {
70
+ if (!isDir(join(tenantsDir, entry))) continue;
71
+ if (entry === E2E_TENANT_DIR) {
72
+ for (const nested of readdir(join(tenantsDir, entry))) {
73
+ if (isDir(join(tenantsDir, entry, nested))) ids.push(nested);
74
+ }
75
+ continue;
76
+ }
77
+ ids.push(entry);
78
+ }
79
+ return ids;
80
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Dev-loop lint for third-party embeds (shift-left for the CSP allowlist).
3
+ *
4
+ * A strict CSP silently blocks any external <script>/<iframe> a tenant page
5
+ * embeds unless the origin is allowlisted (see packages/public-runtime/src/csp.ts
6
+ * — EMBED_PROVIDERS + embedCspAdditions). A blocked embed produces no build
7
+ * error and no page error — just a blank spot — so historically the only way it
8
+ * surfaced was a human noticing and escalating to the platform team.
9
+ *
10
+ * This lint closes that gap at author/preview time: it scans a tenant's HTML for
11
+ * external embed origins and tells the developer, right where they're working:
12
+ * - KNOWN provider present → make sure the tenant opts the slug into `embeds`
13
+ * (if we know the opt-in, hard-fail when it's missing).
14
+ * - UNKNOWN external origin → it will be blocked; a platform catalog entry is
15
+ * needed (EMBED_PROVIDERS), not a raw-origin config edit.
16
+ *
17
+ * Source of truth for the catalog is EMBED_PROVIDERS in TS; KNOWN_EMBEDS below
18
+ * mirrors it for dependency-free `node` execution and a vitest parity test
19
+ * (lint-embeds.test.mjs) fails if the two drift.
20
+ *
21
+ * Usage:
22
+ * node scripts/tenant/lint-embeds.mjs --dir tenants/<id>/content [--declared pipedrive,calendly]
23
+ * node scripts/tenant/lint-embeds.mjs --dir <tenant-repo>/content
24
+ * Exit non-zero when a blocking finding exists (undeclared known provider given
25
+ * a known opt-in, or an unknown external origin) so `tot preview` / CI can gate.
26
+ */
27
+ import { readdirSync, readFileSync, statSync } from "node:fs";
28
+ import { join, extname } from "node:path";
29
+ import { EMBED_PROVIDERS } from "../../packages/public-runtime/src/embed-catalog.mjs";
30
+
31
+ /**
32
+ * The embed catalog, flattened for the lint from the shared source of truth
33
+ * (packages/public-runtime/src/embed-catalog.mjs — imported directly, no mirror).
34
+ * `markers` = substrings meaning the embed is on a page; `origins` = the union
35
+ * of the provider's exact CSP hosts.
36
+ */
37
+ export const KNOWN_EMBEDS = Object.entries(EMBED_PROVIDERS).map(([slug, p]) => ({
38
+ slug,
39
+ label: p.label,
40
+ markers: p.markers,
41
+ origins: [...new Set([...(p.scriptSrc ?? []), ...(p.connectSrc ?? []), ...(p.frameSrc ?? [])])],
42
+ }));
43
+
44
+ /** Hosts the platform always allows (self is implicit); embeds to these are fine. */
45
+ const ALWAYS_ALLOWED_HOSTS = new Set([
46
+ "static.cloudflareinsights.com",
47
+ "cloudflareinsights.com",
48
+ ]);
49
+
50
+ // Only <script src> and <iframe src> load executable/frameable content the CSP
51
+ // gates (script-src / frame-src). We deliberately DON'T scan generic URLs
52
+ // (canonical links, JSON-LD @context, <a href>, og:url) — those aren't embeds
53
+ // and would be noisy false positives. Provider-specific embeds that mount via a
54
+ // data-attribute (e.g. Pipedrive's data-pd-webforms) are caught by their catalog
55
+ // marker instead, so they're covered without a generic URL sweep.
56
+ const SRC_RE = /<(?:script|iframe)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi;
57
+
58
+ /** Origin (host) of an absolute URL, or null for relative/self URLs. */
59
+ function originOf(u) {
60
+ try {
61
+ return new URL(u).origin;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Classify one HTML document's embeds.
69
+ * @returns {{ knownSlugs: string[], externalOrigins: string[], unknownOrigins: string[] }}
70
+ */
71
+ export function scanHtmlForEmbeds(html) {
72
+ const knownSlugs = new Set();
73
+ for (const { slug, markers } of KNOWN_EMBEDS) {
74
+ if (markers.some((m) => html.includes(m))) knownSlugs.add(slug);
75
+ }
76
+ const external = new Set();
77
+ SRC_RE.lastIndex = 0;
78
+ let m;
79
+ while ((m = SRC_RE.exec(html))) {
80
+ const origin = originOf(m[1]);
81
+ if (!origin) continue;
82
+ const host = new URL(origin).hostname;
83
+ if (ALWAYS_ALLOWED_HOSTS.has(host)) continue;
84
+ external.add(origin);
85
+ }
86
+ // An external origin is "unknown" if no catalog provider claims it.
87
+ const catalogOrigins = new Set(KNOWN_EMBEDS.flatMap((p) => p.origins));
88
+ const unknown = [...external].filter((o) => !catalogOrigins.has(o));
89
+ return {
90
+ knownSlugs: [...knownSlugs],
91
+ externalOrigins: [...external],
92
+ unknownOrigins: unknown,
93
+ };
94
+ }
95
+
96
+ function walkHtml(dir) {
97
+ /** @type {string[]} */
98
+ const out = [];
99
+ for (const entry of readdirSync(dir)) {
100
+ const p = join(dir, entry);
101
+ const st = statSync(p);
102
+ if (st.isDirectory()) out.push(...walkHtml(p));
103
+ else if (extname(p) === ".html") out.push(p);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /**
109
+ * Lint every HTML file under `dir`.
110
+ * @param {{ dir: string, declared?: string[] | null }} opts
111
+ * `declared` = the tenant's opted-in slugs if known (platform CI); when null,
112
+ * findings for known providers are advisory (we can't prove the opt-in here).
113
+ * @returns {{ findings: Array<{level:'error'|'warn'|'info', file:string, message:string}>, blocking: boolean }}
114
+ */
115
+ export function lintTenantEmbeds({ dir, declared = null }) {
116
+ /** @type {Array<{level:'error'|'warn'|'info', file:string, message:string}>} */
117
+ const findings = [];
118
+ const declaredSet = declared ? new Set(declared) : null;
119
+ for (const file of walkHtml(dir)) {
120
+ const html = readFileSync(file, "utf8");
121
+ const { knownSlugs, unknownOrigins } = scanHtmlForEmbeds(html);
122
+ for (const slug of knownSlugs) {
123
+ const label = KNOWN_EMBEDS.find((p) => p.slug === slug)?.label ?? slug;
124
+ if (declaredSet && !declaredSet.has(slug)) {
125
+ findings.push({
126
+ level: "error",
127
+ file,
128
+ message: `embeds ${label} but the tenant has not opted "${slug}" into its embeds allowlist — the CSP will block it. Add "${slug}" to the tenant's embeds config.`,
129
+ });
130
+ } else if (!declaredSet) {
131
+ findings.push({
132
+ level: "info",
133
+ file,
134
+ message: `embeds ${label}: ensure "${slug}" is in the tenant's embeds allowlist, or the CSP will block it.`,
135
+ });
136
+ }
137
+ }
138
+ for (const origin of unknownOrigins) {
139
+ findings.push({
140
+ level: "error",
141
+ file,
142
+ message: `embeds ${origin}, which is not a known embed provider — the CSP will block it. A platform catalog entry (EMBED_PROVIDERS) is required, or remove the embed.`,
143
+ });
144
+ }
145
+ }
146
+ return { findings, blocking: findings.some((f) => f.level === "error") };
147
+ }
148
+
149
+ function parseArgs(argv) {
150
+ const args = { dir: null, declared: null };
151
+ for (let i = 0; i < argv.length; i++) {
152
+ if (argv[i] === "--dir") args.dir = argv[++i];
153
+ else if (argv[i] === "--declared") args.declared = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
154
+ }
155
+ return args;
156
+ }
157
+
158
+ function main() {
159
+ const { dir, declared } = parseArgs(process.argv.slice(2));
160
+ if (!dir) {
161
+ console.error("usage: node scripts/tenant/lint-embeds.mjs --dir <content-dir> [--declared slug,slug]");
162
+ process.exit(2);
163
+ }
164
+ const { findings, blocking } = lintTenantEmbeds({ dir, declared });
165
+ if (findings.length === 0) {
166
+ console.log("embed lint: no external embeds found (or all allowlisted).");
167
+ return;
168
+ }
169
+ for (const f of findings) {
170
+ const tag = f.level === "error" ? "BLOCKED" : f.level === "warn" ? "WARN" : "note";
171
+ console.log(`[embed-lint:${tag}] ${f.file}\n ${f.message}`);
172
+ }
173
+ if (blocking) {
174
+ console.error(`\nembed lint: ${findings.filter((f) => f.level === "error").length} embed(s) will be blocked by CSP — see above.`);
175
+ process.exit(1);
176
+ }
177
+ }
178
+
179
+ // Only run main when invoked directly, not when imported by the parity test.
180
+ if (import.meta.url === `file://${process.argv[1]}`) main();