@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
@@ -0,0 +1,533 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Materialize a Gitea-hosted tenant's content into the monorepo working tree.
4
+ *
5
+ * Real tenants live in per-tenant Gitea repos (git.tokenoftrust.com/storefront/
6
+ * <repo>) — the SOURCE OF TRUTH. The monorepo's `tenants/<id>/` dirs for those
7
+ * tenants are gitignored, disposable LOCAL/PER-TENANT build input (the same
8
+ * stance as apps/storefront/public/tenants/, see .gitignore). The shared
9
+ * Storefront Worker reads published tenant revisions from KV/R2 and does NOT run
10
+ * this first. Local tenant development and a named per-tenant static publish do.
11
+ * Committed-fixture tenants (home, sample-store.e2e.test) are NOT materialized
12
+ * — they're tracked in git and this script refuses to touch them.
13
+ *
14
+ * Which tenants are Gitea-classified lives in `tenants/.gitea-tenants.json`
15
+ * (the same manifest the CI reintroduction guard reads —
16
+ * scripts/tenant/check-no-tracked-real-tenants.mjs).
17
+ *
18
+ * What it does per tenant (idempotent; pinnable to a ref):
19
+ * 1. `git clone` the tenant repo to a temp dir and check out `--ref` (default:
20
+ * the repo's default branch). Full clone — tenant repos are small and a SHA
21
+ * ref can't be shallow-fetched portably.
22
+ * 2. Read the repo's own `.tot/config.json` mappings (workspace → monorepo
23
+ * artifact path, e.g. `content/` → `tenants/<id>/content/`); fall back to
24
+ * the canonical shape (theme.json, content/, public/) when absent.
25
+ * 3. Copy each mapped workspace path into the monorepo target, REPLACING the
26
+ * target tree (stale files from a previous materialize are removed). Every
27
+ * target must resolve under `tenants/<id>/` — anything else is refused.
28
+ * 4. Copy `.tot/config.json` itself (the CLI + validate flows read it) and
29
+ * stamp `tenants/<id>/.materialized.json` with { repo, ref, sha, at } so
30
+ * `--check` and humans can see what's populated.
31
+ *
32
+ * Modes:
33
+ * node scripts/tenant/materialize-tenant-content.mjs <tenant> [--ref <sha|branch>]
34
+ * node scripts/tenant/materialize-tenant-content.mjs <tenant> --remote <cloneUrl>
35
+ * # e.g. the single-use gitRemote minted by the MCP `tenant_checkout` tool
36
+ * node scripts/tenant/materialize-tenant-content.mjs --all # every manifest tenant
37
+ * node scripts/tenant/materialize-tenant-content.mjs --check # verify populated; no network
38
+ * node scripts/tenant/materialize-tenant-content.mjs --check-pinned [--expect-ref <sha>] [--allow-unpinned]
39
+ * # stricter deploy gate: verify populated AND materialized from Gitea at a
40
+ * # PINNED ref (.materialized.json present, pinned, on the expected ref); no network
41
+ *
42
+ * Auth (never logged; tokens are redacted from every error):
43
+ * TENANT_GIT_BASE clone base. Default https://git.tokenoftrust.com/storefront
44
+ * TENANT_GIT_USER / TENANT_GIT_TOKEN
45
+ * basic-auth pair injected into the clone URL (CI: a
46
+ * read-only Gitea token; local dev can instead rely on an
47
+ * ambient credential helper and set neither). For a HUMAN
48
+ * running this manually (e.g. handing an agent a scoped
49
+ * token instead of ambient creds), mint a short-lived
50
+ * per-repo read-only TENANT_GIT_TOKEN with
51
+ * scripts/tenant/mint-read-token.sh <tenant> — it never
52
+ * exposes the app's MCP client-assertion signing key to
53
+ * whoever consumes the output.
54
+ */
55
+
56
+ import {
57
+ cpSync,
58
+ existsSync,
59
+ mkdtempSync,
60
+ readFileSync,
61
+ rmSync,
62
+ writeFileSync,
63
+ } from "node:fs";
64
+ import { execFileSync } from "node:child_process";
65
+ import { tmpdir } from "node:os";
66
+ import { dirname, join, resolve, sep } from "node:path";
67
+ import { fileURLToPath, pathToFileURL } from "node:url";
68
+
69
+ const here = dirname(fileURLToPath(import.meta.url));
70
+ const repoRoot = resolve(here, "..", "..");
71
+
72
+ export const MANIFEST_PATH = "tenants/.gitea-tenants.json";
73
+ const TENANT_ID_RE = /^[a-z0-9][a-z0-9.-]{1,60}[a-z0-9]$/;
74
+ const DEFAULT_GIT_BASE = "https://git.tokenoftrust.com/storefront";
75
+
76
+ /** Load the Gitea-tenant manifest. Returns { tenants: { <id>: { repo } } }. */
77
+ export function loadManifest(root = repoRoot) {
78
+ const p = join(root, MANIFEST_PATH);
79
+ if (!existsSync(p)) {
80
+ throw new Error(`${MANIFEST_PATH} not found — no Gitea-classified tenants declared`);
81
+ }
82
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
83
+ if (!parsed || typeof parsed.tenants !== "object" || parsed.tenants === null) {
84
+ throw new Error(`${MANIFEST_PATH}: expected a "tenants" object`);
85
+ }
86
+ return parsed;
87
+ }
88
+
89
+ /** Redact any secret substrings (tokens) from a message before it can escape. */
90
+ export function redact(message, secrets) {
91
+ let out = String(message);
92
+ for (const s of secrets) if (s) out = out.split(s).join("***REDACTED***");
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * Build the authenticated clone URL. The token rides ONLY in the returned URL
98
+ * (passed to git, never printed); callers must route errors through `redact`.
99
+ */
100
+ export function buildCloneUrl({ base, repo, user, token }) {
101
+ const clean = base.replace(/\/+$/, "");
102
+ if (!token) return `${clean}/${repo}.git`;
103
+ const u = new URL(`${clean}/${repo}.git`);
104
+ u.username = user || "token";
105
+ u.password = token;
106
+ return u.toString();
107
+ }
108
+
109
+ /**
110
+ * Resolve a mapping's monorepo target and REFUSE anything that escapes
111
+ * `tenants/<id>/` — a hostile/typo'd `.tot/config.json` must not be able to
112
+ * write elsewhere in the tree (compare reconcile's manifest deny-by-default).
113
+ */
114
+ export function resolveTargetPath(root, tenant, repoPath) {
115
+ const target = resolve(root, repoPath);
116
+ const bound = resolve(root, "tenants", tenant) + sep;
117
+ if (!target.startsWith(bound) && target !== resolve(root, "tenants", tenant)) {
118
+ throw new Error(
119
+ `mapping target "${repoPath}" escapes tenants/${tenant}/ — refusing`,
120
+ );
121
+ }
122
+ return target;
123
+ }
124
+
125
+ /** The canonical mapping fallback (matches buildTotCheckoutConfig). */
126
+ function canonicalMappings(tenant) {
127
+ return [
128
+ { workspace: "theme.json", repo: `tenants/${tenant}/theme.json`, kind: "file" },
129
+ { workspace: "content/", repo: `tenants/${tenant}/content/`, kind: "tree" },
130
+ { workspace: "public/", repo: `tenants/${tenant}/public/`, kind: "tree" },
131
+ ];
132
+ }
133
+
134
+ /**
135
+ * Copy one mapping from the checked-out tenant repo into the monorepo tree.
136
+ * `tree` targets are replaced wholesale so a removed file doesn't linger from a
137
+ * previous materialize. Missing OPTIONAL workspace paths are skipped (a tenant
138
+ * without public/ is fine); the reconcile-side required-artifact rules (theme +
139
+ * home present) are enforced by validate/reconcile, not here.
140
+ */
141
+ export function copyMapping({ checkoutDir, root, tenant, mapping, log = (/** @type {string} */ _m) => {} }) {
142
+ const src = join(checkoutDir, mapping.workspace.replace(/\/+$/, ""));
143
+ const target = resolveTargetPath(root, tenant, mapping.repo.replace(/\/+$/, ""));
144
+ if (!existsSync(src)) {
145
+ log(` ~ ${mapping.workspace} absent in repo — skipped`);
146
+ return { copied: false, target };
147
+ }
148
+ if (mapping.kind === "tree" && existsSync(target)) {
149
+ rmSync(target, { recursive: true, force: true });
150
+ }
151
+ cpSync(src, target, { recursive: true });
152
+ log(` + ${mapping.workspace} → ${mapping.repo}`);
153
+ return { copied: true, target };
154
+ }
155
+
156
+ /**
157
+ * Materialize one tenant. `gitBase` may be a file:// URL (tests) or an
158
+ * authenticated https URL base. Returns { tenant, ref, sha, mappingsCopied }.
159
+ */
160
+ export function materializeTenant({
161
+ tenant,
162
+ repo = tenant,
163
+ ref = null,
164
+ root = repoRoot,
165
+ gitBase = DEFAULT_GIT_BASE,
166
+ /** Full clone URL override (e.g. a tenant_checkout-minted single-use remote). */
167
+ remote = null,
168
+ user = process.env.TENANT_GIT_USER,
169
+ token = process.env.TENANT_GIT_TOKEN,
170
+ log = (/** @type {string} */ _m) => {},
171
+ }) {
172
+ if (!TENANT_ID_RE.test(tenant)) {
173
+ throw new Error(`invalid tenant id "${tenant}"`);
174
+ }
175
+ const manifest = loadManifest(root);
176
+ if (!manifest.tenants[tenant]) {
177
+ throw new Error(
178
+ `"${tenant}" is not a Gitea-classified tenant (${MANIFEST_PATH}) — its ` +
179
+ `tenants/ dir is committed, not materialized. Refusing to overwrite it.`,
180
+ );
181
+ }
182
+ // A full remote URL may itself carry a credential — treat it as a secret too.
183
+ // (scp-style remotes like git@host:path aren't URL-parseable; they carry no
184
+ // inline password, so an unparseable remote contributes no secret.)
185
+ let remoteSecret = null;
186
+ if (remote) {
187
+ try {
188
+ remoteSecret = new URL(remote).password || null;
189
+ } catch {
190
+ remoteSecret = null;
191
+ }
192
+ }
193
+ const secrets = [token, remoteSecret].filter(Boolean);
194
+ const cloneUrl = remote ?? buildCloneUrl({ base: gitBase, repo, user, token });
195
+ const work = mkdtempSync(join(tmpdir(), `materialize-${tenant}-`));
196
+ const git = (args, cwd = work) =>
197
+ execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
198
+ .toString()
199
+ .trim();
200
+ try {
201
+ try {
202
+ git(["clone", "--quiet", cloneUrl, work], tmpdir());
203
+ } catch (e) {
204
+ throw new Error(
205
+ `clone of ${gitBase.startsWith("file:") ? repo : `${gitBase}/${repo}`} failed: ` +
206
+ redact(e.stderr?.toString() || e.message, secrets),
207
+ );
208
+ }
209
+ if (ref) {
210
+ try {
211
+ git(["checkout", "--quiet", ref]);
212
+ } catch {
213
+ // A SHA not on the default branch (e.g. a merged-then-advanced tip)
214
+ // may need an explicit fetch before checkout.
215
+ git(["fetch", "--quiet", "origin", ref]);
216
+ git(["checkout", "--quiet", "FETCH_HEAD"]);
217
+ }
218
+ } else {
219
+ // No --ref = the repo's DEFAULT branch, which for tenant repos is often
220
+ // days behind the promoted/preview content. Loud, unmissable, but not fatal —
221
+ // local iteration against the default branch is legitimate; publishes are
222
+ // separately guarded by ci-www-publish.sh's PUBLISH_REF requirement.
223
+ console.warn(
224
+ `[materialize-tenant-content] WARNING: no --ref given — materializing ${tenant} from the ` +
225
+ `repo's DEFAULT branch, which may be stale. Pin a commit (--ref <sha|branch>) for anything ` +
226
+ `feeding a build or publish.`,
227
+ );
228
+ }
229
+ const sha = git(["rev-parse", "HEAD"]);
230
+
231
+ // Mappings: the repo's own .tot/config.json wins; canonical shape otherwise.
232
+ let mappings = canonicalMappings(tenant);
233
+ const cfgPath = join(work, ".tot", "config.json");
234
+ if (existsSync(cfgPath)) {
235
+ const cfg = JSON.parse(readFileSync(cfgPath, "utf8"));
236
+ if (Array.isArray(cfg.mappings) && cfg.mappings.length > 0) {
237
+ mappings = cfg.mappings;
238
+ }
239
+ }
240
+
241
+ let mappingsCopied = 0;
242
+ for (const m of mappings) {
243
+ if (typeof m?.repo !== "string" || (m.kind !== "file" && m.kind !== "tree")) {
244
+ throw new Error(
245
+ `.tot/config.json: malformed mapping for "${m?.workspace ?? "?"}" ` +
246
+ `(need string repo + kind file|tree)`,
247
+ );
248
+ }
249
+ const { copied } = copyMapping({ checkoutDir: work, root, tenant, mapping: m, log });
250
+ if (copied) mappingsCopied++;
251
+ }
252
+
253
+ // The checkout config itself + the provenance stamp.
254
+ if (existsSync(cfgPath)) {
255
+ const cfgTarget = resolveTargetPath(root, tenant, `tenants/${tenant}/.tot/config.json`);
256
+ cpSync(cfgPath, cfgTarget);
257
+ }
258
+ // The assets-r2-versioned opt-in marker:
259
+ // copy-tenant-assets.mjs reads this from the MATERIALIZED tree, not the repo
260
+ // directly, so it must ride along here or the marker a tenant commits in
261
+ // Gitea would silently never take effect on a real build.
262
+ const versionedMarkerSrc = join(work, ".tot", "assets-versioned");
263
+ if (existsSync(versionedMarkerSrc)) {
264
+ const versionedMarkerTarget = resolveTargetPath(
265
+ root,
266
+ tenant,
267
+ `tenants/${tenant}/.tot/assets-versioned`,
268
+ );
269
+ cpSync(versionedMarkerSrc, versionedMarkerTarget);
270
+ log(` + .tot/assets-versioned → tenants/${tenant}/.tot/assets-versioned`);
271
+ }
272
+ const stamp = resolveTargetPath(root, tenant, `tenants/${tenant}/.materialized.json`);
273
+ writeFileSync(
274
+ stamp,
275
+ JSON.stringify(
276
+ { tenant, repo, ref: ref ?? "(default branch)", sha, at: new Date().toISOString() },
277
+ null,
278
+ 2,
279
+ ) + "\n",
280
+ );
281
+ log(` ✓ ${tenant} materialized @ ${sha.slice(0, 10)} (${mappingsCopied} mapping(s))`);
282
+ return { tenant, ref, sha, mappingsCopied };
283
+ } finally {
284
+ rmSync(work, { recursive: true, force: true });
285
+ }
286
+ }
287
+
288
+ /**
289
+ * `--check`: every manifest tenant dir must exist and carry content (at least a
290
+ * theme or content/ dir — the build globs would otherwise silently render an
291
+ * empty tenant). Returns { ok, missing: string[] }. No network.
292
+ */
293
+ export function checkMaterialized(root = repoRoot) {
294
+ const manifest = loadManifest(root);
295
+ const missing = [];
296
+ for (const tenant of Object.keys(manifest.tenants)) {
297
+ const dir = join(root, "tenants", tenant);
298
+ const populated =
299
+ existsSync(join(dir, "theme.json")) ||
300
+ existsSync(join(dir, "content")) ||
301
+ existsSync(join(dir, "content.json"));
302
+ if (!populated) missing.push(tenant);
303
+ }
304
+ return { ok: missing.length === 0, missing };
305
+ }
306
+
307
+ /**
308
+ * `--check-pinned`: the stricter DEPLOY gate. Beyond presence (checkMaterialized),
309
+ * every manifest tenant must carry a `.materialized.json` provenance stamp proving
310
+ * it was materialized from Gitea at a PINNED ref — mirroring ci-www-publish.sh's
311
+ * PUBLISH_REF contract so a Worker deploy can never bake a stale local-disk tree
312
+ * (local tenants/ is a disposable cache, never a source of truth). No network.
313
+ * Returns { ok, problems: [{ tenant, reason }] } where
314
+ * reason ∈ "missing" | "unpinned" | "off-ref":
315
+ * missing no stamp / unpopulated dir
316
+ * unpinned stamp exists but ref == "(default branch)" (materialized off the
317
+ * tenant repo's default branch, which may be stale). Tolerated only
318
+ * with allowUnpinned.
319
+ * off-ref expectRef given and the stamp's sha/ref doesn't match it.
320
+ *
321
+ * @param {string} [root]
322
+ * @param {object} [opts]
323
+ * @param {string|null} [opts.expectRef] assert the stamp is this exact commit (sha or ref;
324
+ * short-sha prefix matches, like git)
325
+ * @param {boolean} [opts.allowUnpinned] tolerate a default-branch (unpinned) materialize
326
+ * @param {string[]|null} [opts.onlyTenants] restrict the check to these tenant ids (a per-tenant
327
+ * STATIC publish only ships one tenant's routes, so it
328
+ * need only guard that tenant; null ⇒ every Gitea tenant
329
+ * for explicit operator diagnostics)
330
+ */
331
+ export function checkPinned(
332
+ root = repoRoot,
333
+ { expectRef = null, allowUnpinned = false, onlyTenants = null } = {},
334
+ ) {
335
+ const manifest = loadManifest(root);
336
+ const problems = [];
337
+ const scope = onlyTenants ? new Set(onlyTenants) : null;
338
+ for (const tenant of Object.keys(manifest.tenants)) {
339
+ if (scope && !scope.has(tenant)) continue;
340
+ const dir = join(root, "tenants", tenant);
341
+ const populated =
342
+ existsSync(join(dir, "theme.json")) ||
343
+ existsSync(join(dir, "content")) ||
344
+ existsSync(join(dir, "content.json"));
345
+ const stampPath = join(dir, ".materialized.json");
346
+ if (!populated) {
347
+ // Nothing on disk at all — bakes NOTHING (vs "missing": populated content
348
+ // of unknown provenance). A framework-only build may tolerate this; a
349
+ // deploy never should.
350
+ problems.push({ tenant, reason: "absent" });
351
+ continue;
352
+ }
353
+ if (!existsSync(stampPath)) {
354
+ problems.push({ tenant, reason: "missing" });
355
+ continue;
356
+ }
357
+ let stamp;
358
+ try {
359
+ stamp = JSON.parse(readFileSync(stampPath, "utf8"));
360
+ } catch {
361
+ problems.push({ tenant, reason: "missing" });
362
+ continue;
363
+ }
364
+ const ref = typeof stamp.ref === "string" ? stamp.ref : "";
365
+ const sha = typeof stamp.sha === "string" ? stamp.sha : "";
366
+ const pinned = ref !== "" && ref !== "(default branch)";
367
+ if (!pinned && !allowUnpinned) {
368
+ problems.push({ tenant, reason: "unpinned" });
369
+ continue;
370
+ }
371
+ if (expectRef) {
372
+ const shaMatch =
373
+ sha.length >= 7 && (sha.startsWith(expectRef) || expectRef.startsWith(sha));
374
+ const refMatch = ref !== "" && ref === expectRef;
375
+ if (!shaMatch && !refMatch) {
376
+ problems.push({ tenant, reason: "off-ref" });
377
+ }
378
+ }
379
+ }
380
+ return { ok: problems.length === 0, problems };
381
+ }
382
+
383
+ /** Human-readable refusal for a failed `checkPinned` (shared by the CLI + the
384
+ * build-time guard). `problems` is checkPinned().problems; `expectRef` is echoed
385
+ * into the off-ref explanation when set. */
386
+ export function describePinnedProblems(problems, /** @type {string|null} */ expectRef = null) {
387
+ const how = {
388
+ absent: "not materialized (no tenant content on disk at all)",
389
+ missing: "not materialized (populated but no tenants/<id>/.materialized.json)",
390
+ unpinned:
391
+ "materialized from the tenant repo's DEFAULT branch (no pinned ref) — may be stale",
392
+ "off-ref": `materialized at a different ref than expected (${expectRef})`,
393
+ };
394
+ return (
395
+ "REFUSING: tenant content is not materialized from Gitea at a pinned ref.\n" +
396
+ problems.map(({ tenant, reason }) => ` ✗ ${tenant}: ${how[reason]}`).join("\n") +
397
+ "\n\nLocal tenants/ is a disposable cache, never a source of truth. Re-materialize\n" +
398
+ "at the promoted commit:\n" +
399
+ " node scripts/tenant/materialize-tenant-content.mjs <tenant> --ref <sha>\n" +
400
+ "(--allow-unpinned deliberately tolerates a default-branch materialize)."
401
+ );
402
+ }
403
+
404
+ /**
405
+ * Throw if any Gitea-classified tenant is unmaterialized/unpinned/off-ref — the
406
+ * intrinsic build gate. Called from the storefront's `astro:build:start` hook so
407
+ * `astro build` refuses to bake empty/stale tenant content on its own, not only
408
+ * when routed through deploy.sh. No network.
409
+ *
410
+ * Skips silently when the manifest is absent (a pruned standalone runner tree
411
+ * declares no Gitea tenants — nothing to guard).
412
+ *
413
+ * @param {object} [opts]
414
+ * @param {string} [opts.root] monorepo root (default: this repo)
415
+ * @param {string|null} [opts.expectRef] assert every stamp is this exact commit
416
+ * @param {boolean} [opts.allowUnpinned] tolerate a default-branch materialize
417
+ * @param {string[]|null} [opts.onlyTenants] restrict to these tenant ids (per-tenant static
418
+ * publish); null ⇒ every Gitea tenant (operator check)
419
+ */
420
+ export function assertPinnedMaterialized({
421
+ root = repoRoot,
422
+ expectRef = null,
423
+ allowUnpinned = false,
424
+ onlyTenants = null,
425
+ } = {}) {
426
+ if (!existsSync(join(root, MANIFEST_PATH))) return;
427
+ const { ok, problems } = checkPinned(root, { expectRef, allowUnpinned, onlyTenants });
428
+ if (!ok) throw new Error(describePinnedProblems(problems, expectRef));
429
+ }
430
+
431
+ function parseArgs(argv) {
432
+ const a = {
433
+ tenant: null,
434
+ ref: null,
435
+ remote: null,
436
+ all: false,
437
+ check: false,
438
+ checkPinned: false,
439
+ expectRef: null,
440
+ allowUnpinned: false,
441
+ help: false,
442
+ };
443
+ for (let i = 0; i < argv.length; i++) {
444
+ const t = argv[i];
445
+ if (t === "--ref") a.ref = argv[++i];
446
+ else if (t === "--remote") a.remote = argv[++i];
447
+ else if (t === "--all") a.all = true;
448
+ else if (t === "--check") a.check = true;
449
+ else if (t === "--check-pinned") a.checkPinned = true;
450
+ else if (t === "--expect-ref") a.expectRef = argv[++i];
451
+ else if (t === "--allow-unpinned") a.allowUnpinned = true;
452
+ else if (t === "--help" || t === "-h") a.help = true;
453
+ else if (!t.startsWith("--") && !a.tenant) a.tenant = t;
454
+ else throw new Error(`unknown argument: ${t}`);
455
+ }
456
+ if (a.remote && a.all) throw new Error("--remote targets ONE tenant; not valid with --all");
457
+ return a;
458
+ }
459
+
460
+ const USAGE = `materialize-tenant-content — populate tenants/<id>/ from its Gitea repo
461
+
462
+ node scripts/tenant/materialize-tenant-content.mjs <tenant> [--ref <sha|branch>] [--remote <cloneUrl>]
463
+ node scripts/tenant/materialize-tenant-content.mjs --all
464
+ node scripts/tenant/materialize-tenant-content.mjs --check
465
+ node scripts/tenant/materialize-tenant-content.mjs --check-pinned [--expect-ref <sha>] [--allow-unpinned]
466
+
467
+ env: TENANT_GIT_BASE (default ${DEFAULT_GIT_BASE}), TENANT_GIT_USER, TENANT_GIT_TOKEN`;
468
+
469
+ function main() {
470
+ const args = parseArgs(process.argv.slice(2));
471
+ if (args.help) {
472
+ console.log(USAGE);
473
+ return;
474
+ }
475
+ const gitBase = process.env.TENANT_GIT_BASE || DEFAULT_GIT_BASE;
476
+ if (args.check) {
477
+ const { ok, missing } = checkMaterialized();
478
+ if (!ok) {
479
+ console.error(
480
+ `NOT MATERIALIZED: ${missing.join(", ")}\n` +
481
+ `Run: node scripts/tenant/materialize-tenant-content.mjs --all`,
482
+ );
483
+ process.exit(1);
484
+ }
485
+ console.log("All Gitea-classified tenants are materialized.");
486
+ return;
487
+ }
488
+ if (args.checkPinned) {
489
+ const { ok, problems } = checkPinned(repoRoot, {
490
+ expectRef: args.expectRef,
491
+ allowUnpinned: args.allowUnpinned,
492
+ });
493
+ if (!ok) {
494
+ console.error(describePinnedProblems(problems, args.expectRef));
495
+ process.exit(1);
496
+ }
497
+ console.log("All Gitea-classified tenants materialized from Gitea at a pinned ref.");
498
+ return;
499
+ }
500
+ const manifest = loadManifest();
501
+ const targets = args.all
502
+ ? Object.keys(manifest.tenants)
503
+ : args.tenant
504
+ ? [args.tenant]
505
+ : null;
506
+ if (!targets) {
507
+ console.error(USAGE);
508
+ process.exit(2);
509
+ }
510
+ for (const tenant of targets) {
511
+ const repo = manifest.tenants[tenant]?.repo ?? tenant;
512
+ console.log(`Materializing ${tenant} (repo ${repo})…`);
513
+ materializeTenant({
514
+ tenant,
515
+ repo,
516
+ ref: args.ref,
517
+ remote: args.remote,
518
+ gitBase,
519
+ log: (s) => console.log(s),
520
+ });
521
+ }
522
+ }
523
+
524
+ const invokedDirectly =
525
+ process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
526
+ if (invokedDirectly) {
527
+ try {
528
+ main();
529
+ } catch (e) {
530
+ console.error("ERROR:", redact(String(e?.message || e), [process.env.TENANT_GIT_TOKEN]));
531
+ process.exit(1);
532
+ }
533
+ }