@hearthlog/tooling 0.0.0-stage → 0.1.0

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 (85) hide show
  1. package/LICENSE +235 -0
  2. package/bin/hearthlog.mjs +52 -0
  3. package/package.json +64 -3
  4. package/src/apply.ts +73 -0
  5. package/src/check/check-layers.ts +118 -0
  6. package/src/check/check-modules.ts +198 -0
  7. package/src/check/check-sdk-exports.ts +79 -0
  8. package/src/check/check-tenant-config.ts +208 -0
  9. package/src/cli/hearthlog.ts +212 -0
  10. package/src/cli/list-sites.ts +15 -0
  11. package/src/compose/compose-modules.ts +490 -0
  12. package/src/compose/module-scaffold.ts +194 -0
  13. package/src/deploy/artifact-compare.ts +93 -0
  14. package/src/deploy/artifact.ts +127 -0
  15. package/src/deploy/async-utils.ts +71 -0
  16. package/src/deploy/backup-tenants.ts +139 -0
  17. package/src/deploy/cloudflare-access.ts +789 -0
  18. package/src/deploy/copy-tenant.ts +395 -0
  19. package/src/deploy/dedicated-ledger.ts +77 -0
  20. package/src/deploy/deploy-ledger.ts +162 -0
  21. package/src/deploy/deploy-runner.ts +240 -0
  22. package/src/deploy/deploy-tenant.ts +170 -0
  23. package/src/deploy/deployment-security.ts +141 -0
  24. package/src/deploy/ensure-tenant.ts +988 -0
  25. package/src/deploy/event-policy.ts +23 -0
  26. package/src/deploy/frontend-dry-run.ts +18 -0
  27. package/src/deploy/ledger-credential.ts +48 -0
  28. package/src/deploy/ledger-scripts.ts +13 -0
  29. package/src/deploy/split-tenant.ts +295 -0
  30. package/src/dev/local-development.ts +288 -0
  31. package/src/dev/provision-local.ts +13 -0
  32. package/src/generate/build-generated.ts +166 -0
  33. package/src/generate/generate-brand-icons.ts +128 -0
  34. package/src/generate/generate-entities.ts +627 -0
  35. package/src/generate/generate-tenant.ts +161 -0
  36. package/src/host/core-routes.ts +39 -0
  37. package/src/host/write-host.ts +119 -0
  38. package/src/index.ts +8 -0
  39. package/src/init/check-module.ts +143 -0
  40. package/src/init/export-tenant.ts +124 -0
  41. package/src/init/init.ts +167 -0
  42. package/src/move.ts +189 -0
  43. package/src/plan.ts +160 -0
  44. package/src/public-workers/build-gallery-public.ts +114 -0
  45. package/src/public-workers/calendar-public-config.ts +27 -0
  46. package/src/public-workers/check-calendar-public-route.ts +41 -0
  47. package/src/public-workers/check-gallery-public-route.ts +43 -0
  48. package/src/public-workers/gallery-public-config.ts +22 -0
  49. package/src/receipt.ts +74 -0
  50. package/src/root.ts +16 -0
  51. package/src/security/audit-push.ts +136 -0
  52. package/src/security/forbidden-identities.ts +107 -0
  53. package/src/security/forbidden-init.ts +88 -0
  54. package/src/tenants/host-configs.ts +96 -0
  55. package/src/tenants/ledger-config.ts +57 -0
  56. package/src/tenants/lock-file.ts +45 -0
  57. package/src/tenants/module-lock.ts +58 -0
  58. package/src/tenants/tenant-config.ts +887 -0
  59. package/src/tenants/tenant-copy-plan.ts +119 -0
  60. package/src/tenants/tenant-schema.ts +171 -0
  61. package/src/tenants/tenant-wrangler.ts +37 -0
  62. package/src/tenants/wrangler-base.ts +50 -0
  63. package/src/tenants/wrangler-config.ts +266 -0
  64. package/src/vite/config.ts +310 -0
  65. package/src/vite/dev-plugins.ts +338 -0
  66. package/src/vite/vite-plugin-entity-gen.ts +70 -0
  67. package/src/vite/vite-plugin-tenant-manifest.ts +61 -0
  68. package/src/vite/vite-plugin-worker-assets.ts +42 -0
  69. package/templates/host/app.d.ts.txt +31 -0
  70. package/templates/host/microlighter.d.ts.txt +11 -0
  71. package/templates/module/ci.yml.tmpl +44 -0
  72. package/templates/module/gitignore.tmpl +7 -0
  73. package/templates/module/release.yml.tmpl +53 -0
  74. package/templates/tenant/README.md.tmpl +13 -0
  75. package/templates/tenant/generated-package.json.tmpl +16 -0
  76. package/templates/tenant/github-workflows/deploy.yml.tmpl +110 -0
  77. package/templates/tenant/gitignore.tmpl +11 -0
  78. package/templates/tenant/package.json.tmpl +11 -0
  79. package/templates/tenant/patches/sqlocal@0.18.0.patch +18 -0
  80. package/templates/tenant/pnpm-workspace.yaml.tmpl +8 -0
  81. package/templates/tenant/tenants/__id__/tenant.json.tmpl +8 -0
  82. package/templates/tenant/tenants.json.tmpl +17 -0
  83. package/templates/tenant/tsconfig.json.tmpl +21 -0
  84. package/templates/tenant/vite.config.ts.tmpl +5 -0
  85. package/README.md +0 -3
@@ -0,0 +1,119 @@
1
+ /**
2
+ * What a tenant copy moves between buckets, as pure data.
3
+ *
4
+ * A copy gives the new tenant new app and log IDs, and private object keys
5
+ * embed them: invoice files are `<prefix>/<appId>/<logId>/…` and Gallery
6
+ * files `v1/<appId>/…`. Gallery publications are keyed by their own ID, but
7
+ * their `owner.json` names the app and log. The generated-invoice cache
8
+ * (`v1/<appId>/<logId>/invoices/…`) is not copied: it rebuilds on demand.
9
+ * Calendar feeds are not copied: the new tenant creates its own.
10
+ *
11
+ * A **move** (the split of a shared tenant onto its own stack) keeps the app
12
+ * and log IDs, so every key stays exactly as it is: `identity: true` copies
13
+ * each prefix to the same name in the destination bucket, per app rather than
14
+ * per log, and publication owner records are already correct and left alone.
15
+ */
16
+
17
+ export interface TenantCopyMapping {
18
+ sourceAppId: string;
19
+ targetAppId: string;
20
+ /** Source log ID to target log ID, for every copied log. Empty for an identity move. */
21
+ logs: Readonly<Record<string, string>>;
22
+ /** The destination keeps the app and log IDs, so keys are copied unchanged. */
23
+ identity?: true;
24
+ }
25
+
26
+ /** The mapping of a move that keeps every identity. */
27
+ export function identityMapping(appId: string): TenantCopyMapping {
28
+ return { sourceAppId: appId, targetAppId: appId, logs: {}, identity: true };
29
+ }
30
+
31
+ export type BucketPair = "invoice-pdfs" | "gallery-files" | "gallery-public";
32
+
33
+ export interface CopyJob {
34
+ pair: BucketPair;
35
+ fromPrefix: string;
36
+ toPrefix: string;
37
+ /** Rewrite `owner.json` objects under this prefix to the target app and log. */
38
+ rewriteOwner?: boolean;
39
+ }
40
+
41
+ const INVOICE_FILE_PREFIXES = ["received/v1", "received-isdoc/v1", "issued/v1", "issued-isdoc/v1"] as const;
42
+
43
+ const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
44
+
45
+ function requireId(value: string, label: string): void {
46
+ if (!ID.test(value)) throw new Error(`${label} is not a UUID: ${value}`);
47
+ }
48
+
49
+ export function validateMapping(mapping: TenantCopyMapping): void {
50
+ requireId(mapping.sourceAppId, "source app");
51
+ requireId(mapping.targetAppId, "target app");
52
+ if (mapping.identity) {
53
+ if (mapping.sourceAppId !== mapping.targetAppId) throw new Error("an identity move keeps the app: source and target must be the same id");
54
+ if (Object.keys(mapping.logs).length) throw new Error("an identity move names no logs: every log keeps its id");
55
+ return;
56
+ }
57
+ if (mapping.sourceAppId === mapping.targetAppId) throw new Error("source and target apps are the same");
58
+ for (const [source, target] of Object.entries(mapping.logs)) {
59
+ requireId(source, "source log");
60
+ requireId(target, "target log");
61
+ }
62
+ }
63
+
64
+ /** Every prefix to copy, given the publications the source app owns. */
65
+ export function planFileCopies(mapping: TenantCopyMapping, ownedPublications: readonly string[]): CopyJob[] {
66
+ validateMapping(mapping);
67
+ const jobs: CopyJob[] = [];
68
+ if (mapping.identity) {
69
+ for (const prefix of INVOICE_FILE_PREFIXES) {
70
+ const key = `${prefix}/${mapping.sourceAppId}/`;
71
+ jobs.push({ pair: "invoice-pdfs", fromPrefix: key, toPrefix: key });
72
+ }
73
+ jobs.push({ pair: "gallery-files", fromPrefix: `v1/${mapping.sourceAppId}/`, toPrefix: `v1/${mapping.sourceAppId}/` });
74
+ for (const publication of ownedPublications) {
75
+ requireId(publication, "publication");
76
+ const control = `v1/publications/${publication}/`;
77
+ jobs.push({ pair: "gallery-files", fromPrefix: control, toPrefix: control });
78
+ jobs.push({ pair: "gallery-public", fromPrefix: `v1/galleries/${publication}/`, toPrefix: `v1/galleries/${publication}/` });
79
+ }
80
+ return jobs;
81
+ }
82
+ for (const prefix of INVOICE_FILE_PREFIXES) {
83
+ for (const [sourceLog, targetLog] of Object.entries(mapping.logs)) {
84
+ jobs.push({
85
+ pair: "invoice-pdfs",
86
+ fromPrefix: `${prefix}/${mapping.sourceAppId}/${sourceLog}/`,
87
+ toPrefix: `${prefix}/${mapping.targetAppId}/${targetLog}/`,
88
+ });
89
+ }
90
+ }
91
+ jobs.push({ pair: "gallery-files", fromPrefix: `v1/${mapping.sourceAppId}/`, toPrefix: `v1/${mapping.targetAppId}/` });
92
+ for (const publication of ownedPublications) {
93
+ requireId(publication, "publication");
94
+ jobs.push({
95
+ pair: "gallery-files",
96
+ fromPrefix: `v1/publications/${publication}/`,
97
+ toPrefix: `v1/publications/${publication}/`,
98
+ rewriteOwner: true,
99
+ });
100
+ jobs.push({ pair: "gallery-public", fromPrefix: `v1/galleries/${publication}/`, toPrefix: `v1/galleries/${publication}/` });
101
+ }
102
+ return jobs;
103
+ }
104
+
105
+ export function isOwnerRecord(key: string): boolean {
106
+ return /^v1\/publications\/[^/]+\/owner\.json$/.test(key);
107
+ }
108
+
109
+ /** The owner record for the target tenant; throws for a log the copy left out. */
110
+ export function rewriteOwner(record: unknown, mapping: TenantCopyMapping): { appId: string; logId: string } {
111
+ const owner = record as { appId?: unknown; logId?: unknown } | null;
112
+ if (!owner || typeof owner.appId !== "string" || typeof owner.logId !== "string") {
113
+ throw new Error("publication owner record is malformed");
114
+ }
115
+ if (owner.appId !== mapping.sourceAppId) throw new Error("publication belongs to another app");
116
+ const logId = mapping.logs[owner.logId];
117
+ if (!logId) throw new Error(`publication belongs to log ${owner.logId}, which was not copied`);
118
+ return { appId: mapping.targetAppId, logId };
119
+ }
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * JSON Schema for tenants.json, so editors validate and complete it.
4
+ *
5
+ * Feature ids, AI capabilities and integration kinds come from the code, so
6
+ * the schema is generated rather than hand-written. `tenant-config.ts` stays
7
+ * the authoritative validator; the schema only mirrors its shape for IDEs.
8
+ *
9
+ * Usage: pnpm generate:tenant-schema [--check]
10
+ */
11
+
12
+ import { readFile, writeFile } from "node:fs/promises";
13
+ import { join, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ import { FEATURES } from "@hearthlog/contracts/features/registry";
17
+ import { AI_CAPABILITIES } from "@hearthlog/contracts/ai/capabilities";
18
+ import { INTEGRATION_ID_PATTERN, INTEGRATION_KINDS } from "@hearthlog/contracts/integrations/kinds";
19
+ import { DEVELOPER_PATH, REPOSITORY_ROOT } from "./tenant-config.js";
20
+
21
+ export const TENANT_SCHEMA_FILE = join(REPOSITORY_ROOT, "tenants.schema.json");
22
+
23
+ const nonEmptyString = { type: "string", minLength: 1 };
24
+ const httpsOrigin = { type: "string", pattern: "^https://[^/?#@]+/?$" };
25
+ const hours = { type: "string", pattern: "^[1-9][0-9]*h$", description: "Whole hours between 1h and 720h." };
26
+
27
+ export function buildTenantSchema(): Record<string, unknown> {
28
+ const featureIds = FEATURES.map((feature) => feature.id);
29
+ const homePaths = [...new Set(["/", DEVELOPER_PATH, ...FEATURES.flatMap((feature) => feature.page ? [feature.page.path] : [])])];
30
+ const shared = {
31
+ features: {
32
+ type: "array",
33
+ items: { enum: featureIds },
34
+ minItems: 1,
35
+ uniqueItems: true,
36
+ },
37
+ home: { enum: homePaths, description: "Start path; must belong to an enabled feature." },
38
+ title: nonEmptyString,
39
+ developerNavVisible: { type: "boolean" },
40
+ entitiesNavVisible: { type: "boolean" },
41
+ };
42
+ return {
43
+ $schema: "http://json-schema.org/draft-07/schema#",
44
+ title: "Hearthlog tenants",
45
+ description: "Generated by packages/tooling/src/tenants/tenant-schema.ts; do not edit by hand.",
46
+ type: "object",
47
+ additionalProperties: false,
48
+ required: ["infrastructure"],
49
+ properties: {
50
+ $schema: { type: "string" },
51
+ infrastructure: {
52
+ type: "object",
53
+ additionalProperties: false,
54
+ required: [
55
+ "ledgerWorker",
56
+ "invoicePdfsBucket",
57
+ "calendarFeedsBucket",
58
+ "calendarPublicWorker",
59
+ "calendarPublicOrigin",
60
+ "galleryFilesBucket",
61
+ "galleryPublicBucket",
62
+ "galleryPublicWorker",
63
+ "galleryPublicOrigin",
64
+ "workersDevDomain",
65
+ "ledgerAdminEmail",
66
+ "accessTeamDomain",
67
+ ],
68
+ properties: {
69
+ ledgerWorker: nonEmptyString,
70
+ invoicePdfsBucket: nonEmptyString,
71
+ calendarFeedsBucket: nonEmptyString,
72
+ calendarPublicWorker: nonEmptyString,
73
+ calendarPublicOrigin: httpsOrigin,
74
+ galleryFilesBucket: nonEmptyString,
75
+ galleryPublicBucket: nonEmptyString,
76
+ galleryPublicWorker: nonEmptyString,
77
+ galleryPublicOrigin: httpsOrigin,
78
+ workersDevDomain: { type: "string", pattern: "^[a-z0-9][a-z0-9-]*\\.workers\\.dev$", description: "The account's workers.dev subdomain." },
79
+ invoiceRendererOrigin: httpsOrigin,
80
+ kalApiOrigin: httpsOrigin,
81
+ ledgerAdminEmail: { type: "string", format: "email" },
82
+ accessTeamDomain: httpsOrigin,
83
+ accessSessionDuration: hours,
84
+ accessGlobalSessionDuration: hours,
85
+ accountId: { type: "string", pattern: "^[0-9a-f]{32}$", description: "The Cloudflare account that holds the shared stack." },
86
+ gallerySweeper: { type: "string", description: "The shared-stack site that sweeps the public Gallery bucket; the first by id when unset." },
87
+ },
88
+ },
89
+ defaults: {
90
+ type: "object",
91
+ additionalProperties: false,
92
+ properties: shared,
93
+ },
94
+ sites: {
95
+ type: "array",
96
+ items: {
97
+ type: "object",
98
+ additionalProperties: false,
99
+ required: ["id", "authorizedEmails"],
100
+ properties: {
101
+ id: {
102
+ type: "string",
103
+ pattern: "^[a-z0-9][a-z0-9-]*$",
104
+ description: "Becomes the wrangler env, the Worker name and the workers.dev hostname.",
105
+ },
106
+ ledgerAppName: nonEmptyString,
107
+ admin: { type: "boolean" },
108
+ authorizedEmails: {
109
+ type: "array",
110
+ items: { type: "string", format: "email" },
111
+ minItems: 1,
112
+ uniqueItems: true,
113
+ },
114
+ ...shared,
115
+ ai: {
116
+ type: "array",
117
+ items: { enum: [...AI_CAPABILITIES] },
118
+ uniqueItems: true,
119
+ },
120
+ integrations: {
121
+ type: "array",
122
+ items: {
123
+ type: "object",
124
+ additionalProperties: false,
125
+ required: ["id", "kind", "logId"],
126
+ properties: {
127
+ id: { type: "string", pattern: INTEGRATION_ID_PATTERN.source },
128
+ kind: { enum: [...INTEGRATION_KINDS] },
129
+ logId: { type: "string", format: "uuid" },
130
+ clientId: { type: "string", description: "Access service-token client id; inactive until set." },
131
+ },
132
+ },
133
+ },
134
+ dynamicEntities: { type: "boolean" },
135
+ theme: {
136
+ type: "object",
137
+ additionalProperties: false,
138
+ properties: {
139
+ accent: { type: "string", pattern: "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" },
140
+ },
141
+ },
142
+ hostname: { type: "string", format: "uri" },
143
+ },
144
+ },
145
+ },
146
+ },
147
+ };
148
+ }
149
+
150
+ export function renderTenantSchema(): string {
151
+ return `${JSON.stringify(buildTenantSchema(), null, 2)}\n`;
152
+ }
153
+
154
+ /** Whether tenants.schema.json matches the code it is generated from. */
155
+ export async function isTenantSchemaCurrent(): Promise<boolean> {
156
+ const current = await readFile(TENANT_SCHEMA_FILE, "utf8").catch(() => "");
157
+ return current === renderTenantSchema();
158
+ }
159
+
160
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
161
+ if (process.argv.includes("--check")) {
162
+ if (!(await isTenantSchemaCurrent())) {
163
+ console.error("tenants.schema.json is stale; run pnpm generate:tenant-schema");
164
+ process.exit(1);
165
+ }
166
+ console.log("tenants.schema.json is current.");
167
+ } else {
168
+ await writeFile(TENANT_SCHEMA_FILE, renderTenantSchema());
169
+ console.log("Generated tenants.schema.json");
170
+ }
171
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Shared wrangler invocation for one tenant's frontend Worker.
3
+ *
4
+ * Every site's wrangler config is generated from tenants.json on demand (`.generated/sites/<id>/wrangler.jsonc`).
5
+ */
6
+
7
+ import { spawnSync } from "node:child_process";
8
+
9
+ import { loadTenantConfig, REPOSITORY_ROOT, TenantConfigError } from "./tenant-config.js";
10
+ import {
11
+ generateTenantWranglerConfigs,
12
+ resolveFrontendWranglerTarget,
13
+ } from "./wrangler-config.js";
14
+
15
+ export async function runTenantWrangler(
16
+ siteId: string,
17
+ args: string[],
18
+ ): Promise<void> {
19
+ await generateTenantWranglerConfigs(siteId);
20
+ const { sites } = await loadTenantConfig();
21
+ if (!sites.some((site) => site.id === siteId)) {
22
+ throw new TenantConfigError(
23
+ `Unknown site id "${siteId}" (known: ${sites.map((site) => site.id).join(", ")})`,
24
+ );
25
+ }
26
+
27
+ const target = await resolveFrontendWranglerTarget(siteId);
28
+ const tail = target.envKey === null ? [] : ["--env", target.envKey];
29
+ const result = spawnSync(
30
+ "pnpm",
31
+ ["exec", "wrangler", ...args, "--config", target.config, ...tail],
32
+ { cwd: REPOSITORY_ROOT, stdio: "inherit" },
33
+ );
34
+ if (result.status !== 0) {
35
+ process.exit(result.status ?? 1);
36
+ }
37
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * What every site's frontend Worker config shares. `wrangler-config.ts` adds the site: its identity, variables, and
3
+ * the bindings its modules declare. The compatibility date is the Workers runtime the gateway code is tested against,
4
+ * so a tenant moves it by upgrading `@hearthlog/tooling`.
5
+ */
6
+
7
+ export interface WranglerBase {
8
+ compatibility_date: string;
9
+ compatibility_flags: string[];
10
+ assets: {
11
+ binding: string;
12
+ not_found_handling: string;
13
+ /** The core's API paths; a site's modules add theirs (see `siteWorkerFirst`). `/api/ledger` stays first. */
14
+ run_worker_first: string[];
15
+ };
16
+ observability: { enabled: boolean };
17
+ placement: { mode: string };
18
+ images: { binding: string };
19
+ ai: { binding: string };
20
+ /** Service ingest is limited per Access client id; the namespace id only has to be unique in the account. */
21
+ ratelimits: Array<{ name: string; namespace_id: string; simple: { limit: number; period: number } }>;
22
+ /** The daily sweep of the public Gallery copies, for the site that owns the bucket. */
23
+ triggers: { crons: string[] };
24
+ }
25
+
26
+ export const WRANGLER_BASE: WranglerBase = {
27
+ compatibility_date: "2026-07-21",
28
+ compatibility_flags: ["assets_navigation_has_no_effect"],
29
+ assets: {
30
+ binding: "ASSETS",
31
+ not_found_handling: "none",
32
+ run_worker_first: [
33
+ "/api/ledger",
34
+ "/api/ledger/*",
35
+ "/api/session",
36
+ "/api/ai/v1",
37
+ "/api/ai/v1/*",
38
+ "/api/calendar-feeds/v1",
39
+ "/api/calendar-feeds/v1/*",
40
+ "/api/integrations/v1",
41
+ "/api/integrations/v1/*",
42
+ ],
43
+ },
44
+ observability: { enabled: true },
45
+ placement: { mode: "smart" },
46
+ images: { binding: "IMAGES" },
47
+ ai: { binding: "AI" },
48
+ ratelimits: [{ name: "INTEGRATIONS_LIMITER", namespace_id: "21001", simple: { limit: 30, period: 60 } }],
49
+ triggers: { crons: ["23 3 * * *"] },
50
+ };
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Tenant wrangler-config generation.
3
+ *
4
+ * A site's directory (`tenants/<id>/`) and the shared infrastructure are the source of truth for deployed sites. For
5
+ * every site this module writes a standalone frontend Worker config into the site's generated folder,
6
+ * `.generated/sites/<id>/wrangler.jsonc`, from the shared base in `wrangler-base.ts`
7
+ * and the site's modules. The files are gitignored and deterministic, and are regenerated by `hearthlog build`,
8
+ * the deploy tools, and `pnpm check:tenants`. Paths in them are relative to that folder, as wrangler resolves them.
9
+ */
10
+
11
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
12
+ import { dirname, join, relative } from "node:path";
13
+
14
+ import type { ModuleManifest } from "@hearthlog/contracts";
15
+
16
+ import { WRANGLER_BASE } from "./wrangler-base.js";
17
+ import { activeIntegrations } from "@hearthlog/contracts/integrations/kinds";
18
+ import { gatewayEntryPath, loadModules, modulesForFeatures } from "../compose/compose-modules.js";
19
+ import {
20
+ gallerySweeperId,
21
+ infrastructureFor,
22
+ loadTenantConfig,
23
+ REPOSITORY_ROOT,
24
+ type InfrastructureDefinition,
25
+ type ResolvedSite,
26
+ type SiteStack,
27
+ } from "./tenant-config.js";
28
+
29
+ /** Strip line and block comments from wrangler's JSONC without touching strings. */
30
+ export function stripJsonComments(jsonc: string): string {
31
+ let result = "";
32
+ let index = 0;
33
+ let inString = false;
34
+ while (index < jsonc.length) {
35
+ const char = jsonc[index];
36
+ const next = jsonc[index + 1];
37
+ if (inString) {
38
+ result += char;
39
+ if (char === "\\") {
40
+ result += next ?? "";
41
+ index += 2;
42
+ continue;
43
+ }
44
+ if (char === '"') inString = false;
45
+ index += 1;
46
+ continue;
47
+ }
48
+ if (char === '"') {
49
+ inString = true;
50
+ result += char;
51
+ index += 1;
52
+ continue;
53
+ }
54
+ if (char === "/" && next === "/") {
55
+ while (index < jsonc.length && jsonc[index] !== "\n") index += 1;
56
+ continue;
57
+ }
58
+ if (char === "/" && next === "*") {
59
+ index += 2;
60
+ while (
61
+ index < jsonc.length &&
62
+ !(jsonc[index] === "*" && jsonc[index + 1] === "/")
63
+ ) {
64
+ index += 1;
65
+ }
66
+ index += 2;
67
+ continue;
68
+ }
69
+ result += char;
70
+ index += 1;
71
+ }
72
+ return result;
73
+ }
74
+
75
+ /** The folder a site's generated frontend Worker files are in. */
76
+ export function siteConfigDirectory(siteId: string): string {
77
+ return dirname(gatewayEntryPath(siteId));
78
+ }
79
+
80
+ /** Where a site's frontend Worker config is written. */
81
+ export function wranglerConfigPath(siteId: string): string {
82
+ return join(siteConfigDirectory(siteId), "wrangler.jsonc");
83
+ }
84
+
85
+ /** Where `vite build` writes a site's assets, from the repository root; the default site builds into `build`. */
86
+ export function siteBuildDirectory(siteId: string): string {
87
+ return siteId === "st" ? "build" : `build/${siteId}`;
88
+ }
89
+
90
+ /** The server declarations of the modules a site enables, in module order. */
91
+ export function serverDeclarations(features: readonly string[]): Array<NonNullable<ModuleManifest["server"]>> {
92
+ return modulesForFeatures(loadModules(), features).flatMap(({ manifest }) => manifest.server ? [manifest.server] : []);
93
+ }
94
+
95
+ /** `run_worker_first` entries for API prefixes: the prefix and everything beneath it. */
96
+ export function workerFirstEntries(prefixes: readonly string[]): string[] {
97
+ // Wrangler rejects a rule another makes redundant, so a prefix beneath a listed one is left out
98
+ // (`/api/invoice-generation/logs` is served by the `/api/invoice-generation/*` rule).
99
+ const outer = prefixes.filter((prefix) => !prefixes.some((other) => other !== prefix && prefix.startsWith(`${other}/`)));
100
+ return outer.flatMap((prefix) => [prefix, `${prefix}/*`]);
101
+ }
102
+
103
+ /** The core's `run_worker_first` entries, then those of the site's modules. */
104
+ export function siteWorkerFirst(features: readonly string[]): string[] {
105
+ return [...WRANGLER_BASE.assets.run_worker_first, ...workerFirstEntries(serverDeclarations(features).flatMap((server) => server.routes ?? []))];
106
+ }
107
+
108
+ /**
109
+ * The bindings and variables a site's modules need, from their manifests.
110
+ * `resource` names a key of the tenant's infrastructure.
111
+ */
112
+ export function moduleBindings(
113
+ features: readonly string[],
114
+ infrastructure: InfrastructureDefinition,
115
+ ): { r2: Array<{ binding: string; bucket_name: string }>; images: boolean; vars: Record<string, string> } {
116
+ const keyed = infrastructure as unknown as Record<string, string | undefined>;
117
+ const lookup = (resource: string): string => {
118
+ const value = keyed[resource];
119
+ if (!value) throw new Error(`The infrastructure has no "${resource}"`);
120
+ return value;
121
+ };
122
+ const optional = (resource: string): string | undefined => keyed[resource];
123
+ const result = { r2: [] as Array<{ binding: string; bucket_name: string }>, images: false, vars: {} as Record<string, string> };
124
+ for (const server of serverDeclarations(features)) {
125
+ for (const binding of server.bindings ?? []) {
126
+ if (binding.type === "r2") result.r2.push({ binding: binding.binding, bucket_name: lookup(binding.resource) });
127
+ else result.images = true;
128
+ }
129
+ for (const variable of server.vars ?? []) {
130
+ const value = variable.optional ? optional(variable.resource) : lookup(variable.resource);
131
+ if (value) result.vars[variable.name] = value;
132
+ }
133
+ }
134
+ return result;
135
+ }
136
+
137
+ /**
138
+ * Derive the standalone wrangler config for one site. Everything not tenant-specific (compatibility, assets
139
+ * behavior, observability, smart placement) comes from the shared base; the identity, vars, and bindings come from
140
+ * tenants.json and the site's modules. Pass the site's own infrastructure (`infrastructureFor`): a site on its own
141
+ * stack binds only its own Ledger and buckets. `sweeps` marks the site that sweeps the public Gallery bucket it owns.
142
+ */
143
+ export function buildTenantWranglerConfig(
144
+ site: ResolvedSite,
145
+ infrastructure: InfrastructureDefinition,
146
+ options: { sweeps?: boolean } = {},
147
+ ): Record<string, unknown> {
148
+ const modules = moduleBindings(site.features, infrastructure);
149
+ const scheduled = serverDeclarations(site.features).some((server) => server.scheduled);
150
+ const base = WRANGLER_BASE;
151
+ const directory = siteConfigDirectory(site.id);
152
+ return {
153
+ name: site.id,
154
+ ...(site.deployment?.accountId ? { account_id: site.deployment.accountId } : {}),
155
+ main: `./${relative(directory, gatewayEntryPath(site.id))}`,
156
+ compatibility_date: base.compatibility_date,
157
+ compatibility_flags: base.compatibility_flags,
158
+ // Sites live on their Access-protected workers.dev hostname only:
159
+ // version preview hostnames are never enabled.
160
+ workers_dev: true,
161
+ preview_urls: false,
162
+ assets: {
163
+ directory: relative(directory, join(REPOSITORY_ROOT, siteBuildDirectory(site.id))),
164
+ binding: base.assets.binding,
165
+ not_found_handling: base.assets.not_found_handling,
166
+ run_worker_first: siteWorkerFirst(site.features),
167
+ },
168
+ observability: base.observability,
169
+ placement: base.placement,
170
+ vars: {
171
+ LEDGER_ADMIN_EMAIL: infrastructure.ledgerAdminEmail,
172
+ STOR_AUTHORIZED_EMAILS: site.authorizedEmails.join(","),
173
+ STOR_ADMIN_ENABLED: String(site.admin),
174
+ ...modules.vars,
175
+ AI_CAPABILITIES: site.ai.join(","),
176
+ INTEGRATIONS: integrationsVariable(site),
177
+ ...siteIdentityVariables(site, infrastructure),
178
+ },
179
+ services: [
180
+ {
181
+ binding: "LEDGER",
182
+ service: infrastructure.ledgerWorker,
183
+ },
184
+ ],
185
+ // Each site's gateway entry bundles only the server code of the modules it enables.
186
+ ...(modules.images ? { images: base.images } : {}),
187
+ ratelimits: base.ratelimits,
188
+ ...(options.sweeps && scheduled ? { triggers: base.triggers } : {}),
189
+ ai: base.ai,
190
+ r2_buckets: [
191
+ {
192
+ binding: "CALENDAR_FEEDS",
193
+ bucket_name: infrastructure.calendarFeedsBucket,
194
+ },
195
+ ...modules.r2,
196
+ ],
197
+ };
198
+ }
199
+
200
+ /**
201
+ * The site that sweeps the shared stack's public Gallery bucket: the configured one (`gallerySweeper`), else the first
202
+ * site without a stack of its own (a site with its own stack sweeps its own bucket). Two sweepers on one bucket would race.
203
+ */
204
+ export function sweepsSharedStack(
205
+ stacks: readonly SiteStack[],
206
+ infrastructure: Pick<InfrastructureDefinition, "gallerySweeper">,
207
+ site: Pick<ResolvedSite, "id" | "deployment">,
208
+ ): boolean {
209
+ return Boolean(site.deployment) || gallerySweeperId(stacks, infrastructure) === site.id;
210
+ }
211
+
212
+ /**
213
+ * Who the site is and where its calendar feeds are served: the feed UIDs carry
214
+ * the site's own hostname, and the drawer returns URLs on the feed Worker.
215
+ */
216
+ export function siteIdentityVariables(
217
+ site: ResolvedSite,
218
+ infrastructure: Pick<InfrastructureDefinition, "calendarPublicOrigin">,
219
+ ): Record<string, string> {
220
+ return {
221
+ SITE_ID: site.id,
222
+ SITE_ORIGIN: site.hostname,
223
+ CALENDAR_FEED_ORIGIN: infrastructure.calendarPublicOrigin,
224
+ };
225
+ }
226
+
227
+ /** The non-secret integration list a site's Worker accepts, as the INTEGRATIONS variable. */
228
+ export function integrationsVariable(site: ResolvedSite): string {
229
+ return JSON.stringify(activeIntegrations(site.integrations));
230
+ }
231
+
232
+ /**
233
+ * Generate the standalone wrangler config of a site (all of them without an id). A build passes its own site, whose modules are the only ones in its catalog. Returns the written paths.
234
+ */
235
+ export async function generateTenantWranglerConfigs(siteId?: string): Promise<string[]> {
236
+ const { infrastructure, sites, stacks } = await loadTenantConfig(undefined, { only: siteId });
237
+ const written: string[] = [];
238
+ for (const site of sites) {
239
+ const path = wranglerConfigPath(site.id);
240
+ const config = buildTenantWranglerConfig(site, infrastructureFor(site, infrastructure), { sweeps: sweepsSharedStack(stacks, infrastructure, site) });
241
+ await mkdir(dirname(path), { recursive: true });
242
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
243
+ written.push(path);
244
+ }
245
+ return written;
246
+ }
247
+
248
+ /** Where a site's wrangler config lives (relative to the repository root, where the tools run wrangler). */
249
+ export interface WranglerTarget {
250
+ config: string;
251
+ /** --env value; null means the config is standalone (no --env). */
252
+ envKey: string | null;
253
+ }
254
+
255
+ export function resolveFrontendWranglerTarget(siteId: string): WranglerTarget {
256
+ return { config: relative(REPOSITORY_ROOT, wranglerConfigPath(siteId)), envKey: null };
257
+ }
258
+
259
+ export async function ensureTenantWranglerConfigs(): Promise<void> {
260
+ const written = await generateTenantWranglerConfigs();
261
+ if (written.length > 0 && process.env.STOR_VERBOSE) {
262
+ for (const path of written) {
263
+ console.log(`Generated ${path.replace(`${REPOSITORY_ROOT}/`, "")}`);
264
+ }
265
+ }
266
+ }