@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,395 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Copy one tenant into a new tenant on its own stack, with new identities.
4
+ *
5
+ * The source is only read. Both tenants keep working and diverge afterwards;
6
+ * nothing is merged back. See docs/tenant-copy.md for the full runbook.
7
+ *
8
+ * events Export the source app through the Ledger administrator API (the
9
+ * source app's own token is neither needed nor rotated), restore it
10
+ * into the target Ledger as a new app named after the target site,
11
+ * with new log IDs, and verify. Event IDs, timestamps, authors,
12
+ * action grouping, fork lineage, and hidden logs are kept. Writes
13
+ * `mapping.json` (IDs only, no tokens).
14
+ * files Deploy a short-lived copy Worker bound to the source and target
15
+ * buckets, copy invoice files, Gallery files, and the source app's
16
+ * Gallery publications with app and log IDs rewritten from the
17
+ * mapping, compare every object, then delete the Worker.
18
+ *
19
+ * Usage:
20
+ * pnpm tenant:copy -- events --from alpha --to jst [--allow-skipped-forks]
21
+ * pnpm tenant:copy -- files --from alpha --to jst --run <directory>
22
+ *
23
+ * Environment: the administrator tokens of both Ledgers (LEDGER_ADMIN_TOKEN or
24
+ * .wrangler/tenants/.admin-token for the shared Ledger, LEDGER_ADMIN_TOKEN_<SITE>
25
+ * or .wrangler/tenants/<id>/.admin-token for a site's own Ledger), optional
26
+ * LEDGER_BASE_URL / LEDGER_BASE_URL_<SITE> overrides, and a wrangler login for
27
+ * `files`.
28
+ */
29
+
30
+ import { spawn } from "node:child_process";
31
+ import { randomBytes } from "node:crypto";
32
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
33
+ import { join, relative, resolve } from "node:path";
34
+ import { fileURLToPath } from "node:url";
35
+
36
+ import { dedicatedLedgerBaseUrl, resolveDedicatedAdminToken } from "./dedicated-ledger.js";
37
+ import { runWrangler } from "./deploy-runner.js";
38
+ import { resolveLedgerAdminToken, resolveLedgerBaseUrl } from "./ledger-credential.js";
39
+ import {
40
+ infrastructureFor,
41
+ loadTenantConfig,
42
+ REPOSITORY_ROOT,
43
+ siteById,
44
+ type InfrastructureDefinition,
45
+ type ResolvedSite,
46
+ } from "../tenants/tenant-config.js";
47
+ import { planFileCopies, validateMapping, type CopyJob, type TenantCopyMapping } from "../tenants/tenant-copy-plan.js";
48
+ import { WRANGLER_BASE } from "../tenants/wrangler-base.js";
49
+ import { ledgerScript } from "./ledger-scripts.js";
50
+
51
+ const COPY_ROOT = join(REPOSITORY_ROOT, ".wrangler", "tenant-copies");
52
+ const COPY_WORKER_CONFIG = "wrangler.tenant-copy.jsonc";
53
+
54
+ interface Options {
55
+ command: "events" | "files" | "help";
56
+ from: string;
57
+ to: string;
58
+ run?: string;
59
+ allowSkippedForks: boolean;
60
+ }
61
+
62
+ interface LedgerAccess {
63
+ baseUrl: string;
64
+ adminToken: string;
65
+ }
66
+
67
+ interface AppSummary {
68
+ id: string;
69
+ name: string;
70
+ revokedAt: string | null;
71
+ }
72
+
73
+ interface LogSummary {
74
+ id: string;
75
+ headVersion: number;
76
+ sourceLogId: string | null;
77
+ sourceVersion: number | null;
78
+ hiddenAt: string | null;
79
+ status: string;
80
+ }
81
+
82
+ interface OwnedPage { owned: string[]; cursor: string | null }
83
+ interface CopyPage { copied: number; skipped: number; bytes: number; cursor: string | null }
84
+ interface ComparePage { checked: number; problems: string[]; cursor: string | null }
85
+
86
+ export interface CopyRecord {
87
+ source: { site: string; appId: string };
88
+ target: { site: string; appId: string };
89
+ copiedAt: string;
90
+ manifestSha256: string;
91
+ logs: Array<{ sourceLogId: string; targetLogId: string; headVersion: number; hidden: boolean }>;
92
+ skippedLogIds: string[];
93
+ }
94
+
95
+ export function parseOptions(argv: string[]): Options {
96
+ const args = argv.filter((argument) => argument !== "--");
97
+ const command = args[0];
98
+ const options: Options = {
99
+ command: command === "events" || command === "files" ? command : "help",
100
+ from: "",
101
+ to: "",
102
+ allowSkippedForks: false,
103
+ };
104
+ for (let index = 1; index < args.length; index += 1) {
105
+ const argument = args[index];
106
+ const value = () => {
107
+ const next = args[index + 1]?.trim();
108
+ if (!next) throw new Error(`${argument} requires a value`);
109
+ index += 1;
110
+ return next;
111
+ };
112
+ switch (argument) {
113
+ case "--from": options.from = value(); break;
114
+ case "--to": options.to = value(); break;
115
+ case "--run": options.run = value(); break;
116
+ case "--allow-skipped-forks": options.allowSkippedForks = true; break;
117
+ case "--help": case "-h": options.command = "help"; break;
118
+ default: throw new Error(`Unknown option: ${argument}`);
119
+ }
120
+ }
121
+ if (options.command !== "help" && (!options.from || !options.to)) throw new Error("--from and --to are required");
122
+ if (options.command === "files" && !options.run) throw new Error("files needs --run <directory> from the events step");
123
+ return options;
124
+ }
125
+
126
+ async function ledgerAccess(site: ResolvedSite, shared: InfrastructureDefinition): Promise<LedgerAccess> {
127
+ if (site.deployment) {
128
+ const variable = site.id.toUpperCase().replace(/-/g, "_");
129
+ const adminToken = await resolveDedicatedAdminToken(site.id);
130
+ if (!adminToken) throw new Error(`No administrator token for the ${site.id} Ledger`);
131
+ return {
132
+ baseUrl: process.env[`LEDGER_BASE_URL_${variable}`]?.trim().replace(/\/$/, "") || dedicatedLedgerBaseUrl(site, shared),
133
+ adminToken,
134
+ };
135
+ }
136
+ const adminToken = await resolveLedgerAdminToken();
137
+ if (!adminToken) throw new Error("No administrator token for the shared Ledger (LEDGER_ADMIN_TOKEN)");
138
+ return { baseUrl: resolveLedgerBaseUrl(process.env.LEDGER_BASE_URL, shared.ledgerWorker, shared.workersDevDomain), adminToken };
139
+ }
140
+
141
+ async function adminApi<T>(ledger: LedgerAccess, route: string): Promise<T> {
142
+ const response = await fetch(`${ledger.baseUrl}${route}`, { headers: { Authorization: `Bearer ${ledger.adminToken}` } });
143
+ if (!response.ok) throw new Error(`GET ${route} failed with ${response.status}`);
144
+ return await response.json() as T;
145
+ }
146
+
147
+ async function activeApp(ledger: LedgerAccess, name: string): Promise<AppSummary | null> {
148
+ const { apps } = await adminApi<{ apps: AppSummary[] }>(ledger, "/admin/v1/apps");
149
+ const matches = apps.filter((app) => app.name === name && app.revokedAt === null);
150
+ if (matches.length > 1) throw new Error(`Ledger has ${matches.length} active apps named "${name}"`);
151
+ return matches[0] ?? null;
152
+ }
153
+
154
+ /** Run the logical backup script; stdout is its JSON result, never shown. */
155
+ function logicalBackup(args: string[], ledger: LedgerAccess): Promise<unknown> {
156
+ return new Promise((resolvePromise, reject) => {
157
+ const backup = ledgerScript("logical-backup", args);
158
+ const child = spawn(backup.command, backup.args, {
159
+ cwd: REPOSITORY_ROOT,
160
+ env: { ...process.env, LEDGER_BASE_URL: ledger.baseUrl, LEDGER_ADMIN_TOKEN: ledger.adminToken },
161
+ stdio: ["ignore", "pipe", "inherit"],
162
+ });
163
+ let output = "";
164
+ child.stdout.on("data", (chunk: Buffer) => { output += chunk.toString("utf8"); });
165
+ child.on("error", reject);
166
+ child.on("close", (code) => {
167
+ if (code !== 0) return reject(new Error(`logical backup ${args[0]} failed with exit code ${code}`));
168
+ try {
169
+ resolvePromise(JSON.parse(output));
170
+ } catch {
171
+ reject(new Error(`logical backup ${args[0]} printed no JSON result`));
172
+ }
173
+ });
174
+ });
175
+ }
176
+
177
+ async function copyEvents(options: Options): Promise<void> {
178
+ const { infrastructure, sites } = await loadTenantConfig();
179
+ const source = siteById(sites, options.from);
180
+ const target = siteById(sites, options.to);
181
+ if (!target.deployment) throw new Error(`Target ${target.id} must run on its own stack (tenants/${target.id}/deployment.json)`);
182
+ const sourceLedger = await ledgerAccess(source, infrastructure);
183
+ const targetLedger = await ledgerAccess(target, infrastructure);
184
+ if (sourceLedger.baseUrl === targetLedger.baseUrl) throw new Error("Source and target use the same Ledger");
185
+
186
+ const sourceApp = await activeApp(sourceLedger, source.ledgerAppName);
187
+ if (!sourceApp) throw new Error(`The source Ledger has no active app named "${source.ledgerAppName}"`);
188
+ const existing = await activeApp(targetLedger, target.ledgerAppName);
189
+ if (existing) {
190
+ throw new Error(
191
+ `The ${target.id} Ledger already has an app named "${target.ledgerAppName}" (${existing.id}). ` +
192
+ "A copy only goes into a new app; to redo it, revoke that app first (see docs/tenant-copy.md).",
193
+ );
194
+ }
195
+
196
+ const run = join(COPY_ROOT, `${target.id}-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}`);
197
+ await mkdir(run, { recursive: true, mode: 0o700 });
198
+ console.log(`Exporting ${source.id} (${sourceApp.id}) through the administrator API...`);
199
+ const exported = await logicalBackup(["export", "--app", sourceApp.id, "--output", join(run, "export")], sourceLedger) as {
200
+ manifestSha256: string;
201
+ skippedLogIds: string[];
202
+ };
203
+ if (exported.skippedLogIds.length > 0 && !options.allowSkippedForks) {
204
+ throw new Error(
205
+ `Forks still being prepared were skipped: ${exported.skippedLogIds.join(", ")}. ` +
206
+ "Let them finish and run again, or pass --allow-skipped-forks to copy without them.",
207
+ );
208
+ }
209
+
210
+ console.log(`Restoring into the ${target.id} Ledger as "${target.ledgerAppName}"...`);
211
+ const restored = await logicalBackup(["restore", "--input", join(run, "export"), "--name", target.ledgerAppName], targetLedger) as {
212
+ app: { id: string };
213
+ manifestSha256: string;
214
+ logs: Array<{ sourceLogId: string; targetLogId: string }>;
215
+ };
216
+ if (restored.manifestSha256 !== exported.manifestSha256) throw new Error("Restore read a different export");
217
+
218
+ const record = await verifyEvents(sourceLedger, targetLedger, sourceApp.id, restored.app.id, restored.logs);
219
+ const result: CopyRecord = {
220
+ source: { site: source.id, appId: sourceApp.id },
221
+ target: { site: target.id, appId: restored.app.id },
222
+ copiedAt: new Date().toISOString(),
223
+ manifestSha256: exported.manifestSha256,
224
+ logs: record,
225
+ skippedLogIds: exported.skippedLogIds,
226
+ };
227
+ await writeFile(join(run, "mapping.json"), `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600 });
228
+ console.log(
229
+ `\nCopied ${record.length} log(s) into app ${restored.app.id}. Mapping: ${relative(REPOSITORY_ROOT, run)}/mapping.json\n` +
230
+ `Next: pnpm tenant:copy -- files --from ${source.id} --to ${target.id} --run ${relative(REPOSITORY_ROOT, run)}\n` +
231
+ `Then give the ${target.id} gateway a token: pnpm ensure:tenant -- --site ${target.id} --rotate-token`,
232
+ );
233
+ }
234
+
235
+ /** Heads, lineage, and hidden state match across the mapping; restore already compared every event page. */
236
+ async function verifyEvents(
237
+ sourceLedger: LedgerAccess,
238
+ targetLedger: LedgerAccess,
239
+ sourceAppId: string,
240
+ targetAppId: string,
241
+ pairs: ReadonlyArray<{ sourceLogId: string; targetLogId: string }>,
242
+ ): Promise<CopyRecord["logs"]> {
243
+ const sourceLogs = new Map((await adminApi<{ logs: LogSummary[] }>(sourceLedger, `/admin/v1/apps/${sourceAppId}/logs`)).logs.map((log) => [log.id, log]));
244
+ const targetLogs = new Map((await adminApi<{ logs: LogSummary[] }>(targetLedger, `/admin/v1/apps/${targetAppId}/logs`)).logs.map((log) => [log.id, log]));
245
+ const mapped = new Map(pairs.map((pair) => [pair.sourceLogId, pair.targetLogId]));
246
+ if (targetLogs.size !== pairs.length) throw new Error(`Target app has ${targetLogs.size} logs, expected ${pairs.length}`);
247
+ return pairs.map(({ sourceLogId, targetLogId }) => {
248
+ const from = sourceLogs.get(sourceLogId);
249
+ const to = targetLogs.get(targetLogId);
250
+ if (!from || !to) throw new Error(`Log ${sourceLogId} is missing after the copy`);
251
+ // The source may have moved on since the export; the copy holds the exported head.
252
+ if (to.headVersion > from.headVersion) throw new Error(`Log ${targetLogId} is ahead of its source`);
253
+ const expectedParent = from.sourceLogId ? mapped.get(from.sourceLogId) ?? null : null;
254
+ if ((to.sourceLogId ?? null) !== expectedParent || (to.sourceVersion ?? null) !== (from.sourceVersion ?? null)) {
255
+ throw new Error(`Fork lineage of ${targetLogId} does not match ${sourceLogId}`);
256
+ }
257
+ if (Boolean(to.hiddenAt) !== Boolean(from.hiddenAt)) throw new Error(`Hidden state of ${targetLogId} does not match`);
258
+ return { sourceLogId, targetLogId, headVersion: to.headVersion, hidden: Boolean(to.hiddenAt) };
259
+ });
260
+ }
261
+
262
+ export function buildCopyWorkerConfig(
263
+ name: string,
264
+ source: InfrastructureDefinition,
265
+ target: InfrastructureDefinition,
266
+ compatibilityDate: string,
267
+ ): Record<string, unknown> {
268
+ return {
269
+ name,
270
+ main: "tenant-copy/index.ts",
271
+ compatibility_date: compatibilityDate,
272
+ workers_dev: true,
273
+ preview_urls: false,
274
+ r2_buckets: [
275
+ { binding: "SOURCE_INVOICE_PDFS", bucket_name: source.invoicePdfsBucket },
276
+ { binding: "TARGET_INVOICE_PDFS", bucket_name: target.invoicePdfsBucket },
277
+ { binding: "SOURCE_GALLERY_FILES", bucket_name: source.galleryFilesBucket },
278
+ { binding: "TARGET_GALLERY_FILES", bucket_name: target.galleryFilesBucket },
279
+ { binding: "SOURCE_GALLERY_PUBLIC", bucket_name: source.galleryPublicBucket },
280
+ { binding: "TARGET_GALLERY_PUBLIC", bucket_name: target.galleryPublicBucket },
281
+ ],
282
+ };
283
+ }
284
+
285
+ async function copyFiles(options: Options): Promise<void> {
286
+ const { infrastructure, sites } = await loadTenantConfig();
287
+ const source = siteById(sites, options.from);
288
+ const target = siteById(sites, options.to);
289
+ const run = resolve(REPOSITORY_ROOT, options.run!);
290
+ const record = JSON.parse(await readFile(join(run, "mapping.json"), "utf8")) as CopyRecord;
291
+ if (record.source.site !== source.id || record.target.site !== target.id) throw new Error("The mapping belongs to another copy");
292
+ const mapping: TenantCopyMapping = {
293
+ sourceAppId: record.source.appId,
294
+ targetAppId: record.target.appId,
295
+ logs: Object.fromEntries(record.logs.map((log) => [log.sourceLogId, log.targetLogId])),
296
+ };
297
+ validateMapping(mapping);
298
+
299
+ const name = `${target.id}-tenant-copy`;
300
+ const config = buildCopyWorkerConfig(name, infrastructureFor(source, infrastructure), infrastructureFor(target, infrastructure), WRANGLER_BASE.compatibility_date);
301
+ await writeFile(join(REPOSITORY_ROOT, COPY_WORKER_CONFIG), `${JSON.stringify(config, null, 2)}\n`, "utf8");
302
+ const token = randomBytes(32).toString("base64url");
303
+ const url = `https://${name}.${infrastructureFor(target, infrastructure).workersDevDomain}`;
304
+
305
+ console.log(`Deploying the short-lived copy Worker ${name}...`);
306
+ await runWrangler(COPY_WORKER_CONFIG, null, ["deploy"]);
307
+ try {
308
+ await runWrangler(COPY_WORKER_CONFIG, null, ["secret", "bulk"], { input: `${JSON.stringify({ COPY_TOKEN: token })}\n` });
309
+ const report = await copyFilesThrough(workerClient(url, token), mapping);
310
+ await writeFile(join(run, "files-report.json"), `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
311
+ const total = report.jobs.reduce((sum, job) => sum + job.checked, 0);
312
+ console.log(`\nCopied and compared ${total} object(s), ${report.publications} publication(s). Report: ${relative(REPOSITORY_ROOT, run)}/files-report.json`);
313
+ } finally {
314
+ console.log(`Deleting the copy Worker ${name}...`);
315
+ await runWrangler(COPY_WORKER_CONFIG, null, ["delete", "--force"]);
316
+ }
317
+ }
318
+
319
+ export type CopyCall = <T>(path: string, body: unknown) => Promise<T>;
320
+
321
+ export interface FilesReport {
322
+ copiedAt: string;
323
+ publications: number;
324
+ jobs: Array<CopyJob & { copied: number; skipped: number; bytes: number; checked: number }>;
325
+ }
326
+
327
+ /** Copy, then compare, every planned prefix through a running copy Worker. */
328
+ export async function copyFilesThrough(call: CopyCall, mapping: TenantCopyMapping): Promise<FilesReport> {
329
+ const owned: string[] = [];
330
+ for (let cursor: string | null | undefined = undefined; cursor !== null;) {
331
+ const page: OwnedPage = await call<OwnedPage>("/publications", { appId: mapping.sourceAppId, cursor });
332
+ owned.push(...page.owned);
333
+ cursor = page.cursor;
334
+ }
335
+ const report: FilesReport = { copiedAt: new Date().toISOString(), publications: owned.length, jobs: [] };
336
+ for (const job of planFileCopies(mapping, owned)) {
337
+ const body = { ...job, ...(job.rewriteOwner ? { mapping } : {}) };
338
+ let copied = 0, skipped = 0, bytes = 0, checked = 0;
339
+ for (let cursor: string | null | undefined = undefined; cursor !== null;) {
340
+ const page: CopyPage = await call<CopyPage>("/copy", { ...body, cursor });
341
+ copied += page.copied; skipped += page.skipped; bytes += page.bytes; cursor = page.cursor;
342
+ }
343
+ for (let cursor: string | null | undefined = undefined; cursor !== null;) {
344
+ const page: ComparePage = await call<ComparePage>("/compare", { ...body, cursor });
345
+ if (page.problems.length > 0) throw new Error(`Copied objects differ under ${job.toPrefix}: ${page.problems.slice(0, 5).join(", ")}`);
346
+ checked += page.checked; cursor = page.cursor;
347
+ }
348
+ report.jobs.push({ ...job, copied, skipped, bytes, checked });
349
+ if (checked > 0) console.log(`${job.pair} ${job.fromPrefix} -> ${job.toPrefix}: ${checked} object(s), ${copied} copied`);
350
+ }
351
+ return report;
352
+ }
353
+
354
+ export function workerClient(url: string, token: string): CopyCall {
355
+ return async function call<T>(path: string, body: unknown): Promise<T> {
356
+ // A new workers.dev name can take a few seconds to answer.
357
+ for (let attempt = 0; ; attempt += 1) {
358
+ const response = await fetch(`${url}${path}`, {
359
+ method: "POST",
360
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
361
+ body: JSON.stringify(body),
362
+ });
363
+ if (response.ok) return await response.json() as T;
364
+ if (attempt < 10 && (response.status === 404 || response.status >= 520)) {
365
+ await new Promise((wait) => setTimeout(wait, 3000));
366
+ continue;
367
+ }
368
+ const detail = await response.text();
369
+ throw new Error(`Copy Worker ${path} failed with ${response.status}: ${detail.slice(0, 300)}`);
370
+ }
371
+ };
372
+ }
373
+
374
+ function usage(): string {
375
+ return `Copy a tenant into a new tenant on its own stack (new identities; the source is only read).
376
+
377
+ Usage:
378
+ pnpm tenant:copy -- events --from <site> --to <site> [--allow-skipped-forks]
379
+ pnpm tenant:copy -- files --from <site> --to <site> --run <directory>`;
380
+ }
381
+
382
+ export async function main(argv = process.argv.slice(2)): Promise<void> {
383
+ const options = parseOptions(argv);
384
+ process.chdir(REPOSITORY_ROOT);
385
+ if (options.command === "events") await copyEvents(options);
386
+ else if (options.command === "files") await copyFiles(options);
387
+ else console.log(usage());
388
+ }
389
+
390
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
391
+ main().catch((error: unknown) => {
392
+ console.error(`\n${error instanceof Error ? error.message : String(error)}`);
393
+ process.exitCode = 1;
394
+ });
395
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The Ledger Worker of a site on its own stack (`tenants/<id>/deployment.json`).
3
+ *
4
+ * It runs the same Ledger code with the same `LedgerApp` / `AppRegistry`
5
+ * classes as the shared Ledger, under its own Worker name, so its Durable
6
+ * Object namespaces hold only that site's data. Its administrator token lives
7
+ * in `.wrangler/tenants/<id>/.admin-token` (gitignored, mode 0600) because
8
+ * Worker secrets cannot be read back.
9
+ */
10
+
11
+ import { randomBytes } from "node:crypto";
12
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
13
+ import { dirname, join } from "node:path";
14
+
15
+ import {
16
+ infrastructureFor,
17
+ REPOSITORY_ROOT,
18
+ TenantConfigError,
19
+ type InfrastructureDefinition,
20
+ type ResolvedSite,
21
+ } from "../tenants/tenant-config.js";
22
+
23
+ export function dedicatedAdminTokenPath(siteId: string): string {
24
+ return join(REPOSITORY_ROOT, ".wrangler", "tenants", siteId, ".admin-token");
25
+ }
26
+
27
+ export function requireDedicated(site: ResolvedSite): void {
28
+ if (!site.deployment) {
29
+ throw new TenantConfigError(
30
+ `Site "${site.id}" uses the shared Ledger; add tenants/${site.id}/deployment.json to give it its own stack`,
31
+ );
32
+ }
33
+ }
34
+
35
+ export function dedicatedLedgerBaseUrl(site: ResolvedSite, shared: InfrastructureDefinition): string {
36
+ requireDedicated(site);
37
+ const own = infrastructureFor(site, shared);
38
+ return `https://${own.ledgerWorker}.${own.workersDevDomain}`;
39
+ }
40
+
41
+ /** `LEDGER_ADMIN_TOKEN_<SITE>` (for example LEDGER_ADMIN_TOKEN_JST), else the local file. */
42
+ export async function resolveDedicatedAdminToken(siteId: string): Promise<string> {
43
+ const variable = `LEDGER_ADMIN_TOKEN_${siteId.toUpperCase().replace(/-/g, "_")}`;
44
+ const exported = process.env[variable]?.trim();
45
+ if (exported) return exported;
46
+ try {
47
+ return (await readFile(dedicatedAdminTokenPath(siteId), "utf8")).trim();
48
+ } catch {
49
+ return "";
50
+ }
51
+ }
52
+
53
+ export async function hasStoredAdminToken(siteId: string): Promise<boolean> {
54
+ try {
55
+ await access(dedicatedAdminTokenPath(siteId));
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Fresh secrets for a new Ledger: the administrator token and the key that
64
+ * signs app tokens. Generated here and never printed.
65
+ */
66
+ export function newLedgerSecrets(): { ADMIN_TOKEN: string; TOKEN_SIGNING_KEY_CURRENT: string } {
67
+ return {
68
+ ADMIN_TOKEN: randomBytes(32).toString("base64url"),
69
+ TOKEN_SIGNING_KEY_CURRENT: randomBytes(48).toString("base64url"),
70
+ };
71
+ }
72
+
73
+ export async function storeAdminToken(siteId: string, token: string): Promise<void> {
74
+ const path = dedicatedAdminTokenPath(siteId);
75
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
76
+ await writeFile(path, `${token}\n`, { mode: 0o600, flag: "wx" });
77
+ }
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Validate and deploy the shared Ledger backend Worker.
4
+ *
5
+ *
6
+ * Frontends bind to it through the `LEDGER` service binding, so this runs
7
+ * before any tenant deploy. Durable Object class names, bindings, and the
8
+ * SQLite-backed DO identities are deployment and data contracts — this only
9
+ * pushes code, never state.
10
+ *
11
+ * With `--site <id>`, deploys that site's own Ledger instead (a site with
12
+ * `tenants/<id>/deployment.json`). The first deploy of a new Ledger passes
13
+ * `--bootstrap-secrets`: it generates the administrator token and the token
14
+ * signing key, pushes them as Worker secrets, and keeps the administrator
15
+ * token in `.wrangler/tenants/<id>/.admin-token`. Neither value is printed.
16
+ *
17
+ * Usage:
18
+ * pnpm deploy:ledger
19
+ * pnpm deploy:ledger -- --dry-run
20
+ * pnpm deploy:ledger -- --site jst --bootstrap-secrets
21
+ * pnpm deploy:ledger -- --site jst
22
+ */
23
+
24
+ import { relative } from "node:path";
25
+
26
+ import { runWrangler } from "./deploy-runner.js";
27
+ import {
28
+ hasStoredAdminToken,
29
+ newLedgerSecrets,
30
+ storeAdminToken,
31
+ dedicatedAdminTokenPath,
32
+ requireDedicated,
33
+ } from "./dedicated-ledger.js";
34
+ import { generateHostConfigs, hostWorkerConfigPath } from "../tenants/host-configs.js";
35
+ import { loadTenantConfig, REPOSITORY_ROOT, siteById } from "../tenants/tenant-config.js";
36
+
37
+
38
+ interface Options {
39
+ site?: string;
40
+ bootstrapSecrets: boolean;
41
+ dryRun: boolean;
42
+ help: boolean;
43
+ }
44
+
45
+ function usage(): string {
46
+ return `Validate and deploy the Ledger backend Worker.
47
+
48
+ Usage:
49
+ pnpm deploy:ledger [options]
50
+
51
+ Options:
52
+ --site <id> Deploy the own Ledger of a site on its own stack
53
+ --bootstrap-secrets First deploy of a site's own Ledger: generate and
54
+ push its administrator token and signing key
55
+ --dry-run Bundle the Worker without uploading
56
+ -h, --help Show this help`;
57
+ }
58
+
59
+ export function parseOptions(argv: string[]): Options {
60
+ const options: Options = {
61
+ bootstrapSecrets: false,
62
+ dryRun: false,
63
+ help: false,
64
+ };
65
+ for (let index = 0; index < argv.length; index += 1) {
66
+ const argument = argv[index];
67
+ switch (argument) {
68
+ case "--":
69
+ break;
70
+ case "--site": {
71
+ const site = argv[index + 1]?.trim();
72
+ if (!site) throw new Error("--site requires a site id");
73
+ options.site = site;
74
+ index += 1;
75
+ break;
76
+ }
77
+ case "--bootstrap-secrets":
78
+ options.bootstrapSecrets = true;
79
+ break;
80
+ case "--dry-run":
81
+ options.dryRun = true;
82
+ break;
83
+ case "--help":
84
+ case "-h":
85
+ options.help = true;
86
+ break;
87
+ default:
88
+ throw new Error(`Unknown option: ${argument}`);
89
+ }
90
+ }
91
+ return options;
92
+ }
93
+
94
+ export async function deployLedger(options: Options): Promise<void> {
95
+ if (options.site) {
96
+ await deployDedicatedLedger(options.site, options);
97
+ return;
98
+ }
99
+ if (options.bootstrapSecrets) throw new Error("--bootstrap-secrets requires --site <id>");
100
+ process.chdir(REPOSITORY_ROOT);
101
+ await generateHostConfigs("shared");
102
+ await runWrangler(relative(REPOSITORY_ROOT, hostWorkerConfigPath("shared", "ledger")), null, [
103
+ "deploy",
104
+ ...(options.dryRun ? ["--dry-run"] : []),
105
+ ]);
106
+ console.log(
107
+ options.dryRun
108
+ ? "\nLedger dry run complete. Nothing was uploaded."
109
+ : "\nDeployed the Ledger backend.",
110
+ );
111
+ }
112
+
113
+ async function deployDedicatedLedger(siteId: string, options: Options): Promise<void> {
114
+ process.chdir(REPOSITORY_ROOT);
115
+ const { infrastructure, sites } = await loadTenantConfig();
116
+ const site = siteById(sites, siteId);
117
+ requireDedicated(site);
118
+ await generateHostConfigs(site.id);
119
+ const config = relative(REPOSITORY_ROOT, hostWorkerConfigPath(site.id, "ledger"));
120
+ if (options.bootstrapSecrets && await hasStoredAdminToken(site.id)) {
121
+ throw new Error(
122
+ `${dedicatedAdminTokenPath(site.id)} already exists, so this Ledger was bootstrapped. ` +
123
+ "Rotating its signing key would invalidate every app token; deploy without --bootstrap-secrets.",
124
+ );
125
+ }
126
+ await runWrangler(config, null, ["deploy", ...(options.dryRun ? ["--dry-run"] : [])]);
127
+ if (options.dryRun) {
128
+ console.log(`\nLedger dry run complete for ${site.id}. Nothing was uploaded.`);
129
+ return;
130
+ }
131
+ if (options.bootstrapSecrets) {
132
+ const secrets = newLedgerSecrets();
133
+ // Keep the administrator token before pushing: a pushed token nobody holds is lost.
134
+ await storeAdminToken(site.id, secrets.ADMIN_TOKEN);
135
+ try {
136
+ await runWrangler(config, null, ["secret", "bulk"], { input: `${JSON.stringify(secrets)}\n` });
137
+ } catch (error) {
138
+ throw new Error(
139
+ `Pushing the Ledger secrets failed (${error instanceof Error ? error.message : String(error)}). ` +
140
+ `Delete ${dedicatedAdminTokenPath(site.id)} and run again with --bootstrap-secrets.`,
141
+ );
142
+ }
143
+ console.log(`Pushed ADMIN_TOKEN and TOKEN_SIGNING_KEY_CURRENT; the administrator token is in ${dedicatedAdminTokenPath(site.id)}.`);
144
+ }
145
+ console.log(`\nDeployed the ${site.id} Ledger.`);
146
+ }
147
+
148
+ export async function main(argv = process.argv.slice(2)): Promise<void> {
149
+ const options = parseOptions(argv);
150
+ if (options.help) {
151
+ console.log(usage());
152
+ return;
153
+ }
154
+ await deployLedger(options);
155
+ }
156
+
157
+ if (process.argv[1]?.endsWith("deploy-ledger.ts")) {
158
+ main().catch((error: unknown) => {
159
+ console.error(`\n${error instanceof Error ? error.message : String(error)}`);
160
+ process.exitCode = 1;
161
+ });
162
+ }