@hearthlog/tooling 0.0.0-stage → 0.1.1

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 (86) hide show
  1. package/LICENSE +235 -0
  2. package/bin/hearthlog.mjs +61 -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 +195 -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 +168 -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 +141 -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/gitleaks.toml.tmpl +11 -0
  74. package/templates/module/release.yml.tmpl +53 -0
  75. package/templates/tenant/README.md.tmpl +13 -0
  76. package/templates/tenant/generated-package.json.tmpl +16 -0
  77. package/templates/tenant/github-workflows/deploy.yml.tmpl +111 -0
  78. package/templates/tenant/gitignore.tmpl +11 -0
  79. package/templates/tenant/package.json.tmpl +11 -0
  80. package/templates/tenant/patches/sqlocal@0.18.0.patch +18 -0
  81. package/templates/tenant/pnpm-workspace.yaml.tmpl +8 -0
  82. package/templates/tenant/tenants/__id__/tenant.json.tmpl +8 -0
  83. package/templates/tenant/tenants.json.tmpl +17 -0
  84. package/templates/tenant/tsconfig.json.tmpl +21 -0
  85. package/templates/tenant/vite.config.ts.tmpl +5 -0
  86. package/README.md +0 -3
@@ -0,0 +1,988 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Ensure one tenant (or every tenant) is registered and protected, without
4
+ * touching `.wrangler/tenants` as a required input.
5
+ *
6
+ * Phases, all idempotent:
7
+ * 1. R2 — ensure the selected environment's shared buckets exist.
8
+ * 2. Access — ensure a Zero Trust Access application for the site's
9
+ * workers.dev hostname, adopting an existing application,
10
+ * and reconcile its Allow policy to exactly the authorized
11
+ * emails (adding and removing as tenants.json changes). The
12
+ * retired calendar-feed Bypass application, if present, is
13
+ * deleted: feeds are served by their own Worker.
14
+ * A site with service integrations also gets a path-scoped
15
+ * application for /api/integrations/* with a Service Auth
16
+ * policy that admits exactly its integrations' service tokens.
17
+ * `--create-integration-token <id>` creates one token and
18
+ * prints its client id and secret once.
19
+ * 3. Ledger — ensure the site's Ledger application. A token is minted
20
+ * only when the application is first created, or when
21
+ * `--rotate-token` is explicitly requested. An existing
22
+ * application is left alone: its token already lives in the
23
+ * Worker (Worker secrets survive code deploys) and rotating
24
+ * would revoke the only principal and drop live WebSockets.
25
+ * 4. Secrets — push the tokens/secrets the Worker needs. Automatic on a
26
+ * bootstrap or rotation; `--set-secret` forces a refresh.
27
+ *
28
+ * A site on its own stack (tenants/<id>/deployment.json) is ensured only with
29
+ * `--site <id>`: its own buckets, and its Ledger application in its own Ledger,
30
+ * whose administrator token comes from LEDGER_ADMIN_TOKEN_<SITE> or
31
+ * .wrangler/tenants/<id>/.admin-token (written by
32
+ * `pnpm deploy:ledger -- --site <id> --bootstrap-secrets`).
33
+ *
34
+ * Environment:
35
+ * LEDGER_ADMIN_TOKEN administrator token of the target Ledger
36
+ * (falls back to .wrangler/tenants/.admin-token)
37
+ * LEDGER_BASE_URL optional URL override (default derived from
38
+ * the Ledger Worker's name)
39
+ * CLOUDFLARE_API_TOKEN Workers Scripts:Edit (for secret bulk/R2) and
40
+ * "Access: Apps and Policies -> Edit",
41
+ * "Access: Service Tokens -> Edit" and
42
+ * "Access: Organizations, Identity Providers, and Groups -> Edit"
43
+ * (plus
44
+ * "Account Settings -> Read" unless
45
+ * CLOUDFLARE_ACCOUNT_ID is set)
46
+ * CLOUDFLARE_ACCOUNT_ID optional when the token can list accounts
47
+ * INVOICE_RENDERER_TOKEN optional; pushed as the Worker's renderer credential
48
+ * INVOICE_EXTRACTION_API_KEY / INVOICE_EXTRACTION_SERVICE_ACCOUNT
49
+ *
50
+ * Usage:
51
+ * pnpm ensure:tenant -- --site st
52
+ * pnpm ensure:tenant -- --site st --set-secret
53
+ * pnpm ensure:tenant -- --site st --rotate-token
54
+ * pnpm ensure:tenant -- --site st --apply-policy # enforce the site's event policy on its existing Ledger app
55
+ * pnpm ensure:tenant -- --site alpha --create-integration-token iphone-steps
56
+ * pnpm ensure:tenant -- --dry-run
57
+ * pnpm provision:tenants # every site, same script
58
+ */
59
+
60
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
61
+ import { devNull } from "node:os";
62
+ import { dirname, join } from "node:path";
63
+
64
+ import { siteEventPolicy } from "./event-policy.js";
65
+ import {
66
+ defaultConcurrency,
67
+ mapWithConcurrency,
68
+ parseConcurrency,
69
+ } from "./async-utils.js";
70
+ import {
71
+ ACCESS_TOKEN_REQUIREMENT,
72
+ ALLOW_POLICY_NAME,
73
+ CloudflareAccessClient,
74
+ extractPolicyEmails,
75
+ planAccessApps,
76
+ planFeedBypassRemoval,
77
+ planIntegrationsApp,
78
+ planServiceAuthPolicy,
79
+ type AccessSiteInput,
80
+ type ExistingAccessApp,
81
+ type ExistingAccessPolicy,
82
+ } from "./cloudflare-access.js";
83
+ import { integrationTokenName } from "@hearthlog/contracts/integrations/kinds";
84
+ import {
85
+ runCommand,
86
+ runCommandResult,
87
+ runWrangler,
88
+ WRANGLER_BIN,
89
+ wranglerEnvironment,
90
+ } from "./deploy-runner.js";
91
+ import {
92
+ legacyTenantCredentialPath,
93
+ resolveLedgerAdminToken,
94
+ resolveLedgerBaseUrl,
95
+ tenantCredentialPath,
96
+ } from "./ledger-credential.js";
97
+ import { dedicatedLedgerBaseUrl, resolveDedicatedAdminToken } from "./dedicated-ledger.js";
98
+ import {
99
+ infrastructureFor,
100
+ loadTenantConfig,
101
+ REPOSITORY_ROOT,
102
+ sharedSites,
103
+ siteById,
104
+ TenantConfigError,
105
+ type InfrastructureDefinition,
106
+ type ResolvedSite,
107
+ } from "../tenants/tenant-config.js";
108
+ import {
109
+ generateTenantWranglerConfigs,
110
+ moduleBindings,
111
+ resolveFrontendWranglerTarget,
112
+ } from "../tenants/wrangler-config.js";
113
+
114
+
115
+ interface AdminAppSummary {
116
+ id: string;
117
+ name: string;
118
+ revokedAt: string | null;
119
+ }
120
+
121
+ interface AdminAppTokenResponse {
122
+ app: AdminAppSummary;
123
+ token: string;
124
+ }
125
+
126
+ interface StoredCredential {
127
+ siteId: string;
128
+ ledgerAppName: string;
129
+ appId: string;
130
+ token: string;
131
+ storedAt: string;
132
+ accessAppId?: string;
133
+ accessAud?: string;
134
+ }
135
+
136
+ interface Options {
137
+ site?: string;
138
+ dryRun: boolean;
139
+ rotateToken: boolean;
140
+ /** Set the site's event policy on an application that already exists (new applications always get it). */
141
+ applyPolicy: boolean;
142
+ /** Integration id whose Access service token should be created and printed once. */
143
+ createIntegrationToken?: string;
144
+ setSecret: boolean;
145
+ skipR2: boolean;
146
+ skipAccess: boolean;
147
+ skipLedger: boolean;
148
+ skipSecrets: boolean;
149
+ concurrency: number;
150
+ help: boolean;
151
+ }
152
+
153
+ export function parseOptions(argv: string[]): Options {
154
+ const options: Options = {
155
+ site: undefined,
156
+ dryRun: false,
157
+ rotateToken: false,
158
+ applyPolicy: false,
159
+ setSecret: false,
160
+ skipR2: false,
161
+ skipAccess: false,
162
+ skipLedger: false,
163
+ skipSecrets: false,
164
+ concurrency: defaultConcurrency(),
165
+ help: false,
166
+ };
167
+
168
+ const readValue = (name: string, index: number): string => {
169
+ const value = argv[index + 1]?.trim();
170
+ if (!value) throw new Error(`${name} requires a value`);
171
+ return value;
172
+ };
173
+
174
+ for (let index = 0; index < argv.length; index += 1) {
175
+ const argument = argv[index];
176
+ switch (argument) {
177
+ case "--":
178
+ break;
179
+ case "--site":
180
+ options.site = readValue("--site", index);
181
+ index += 1;
182
+ break;
183
+ case "--concurrency":
184
+ options.concurrency = parseConcurrency(readValue("--concurrency", index));
185
+ index += 1;
186
+ break;
187
+ case "--dry-run":
188
+ options.dryRun = true;
189
+ break;
190
+ case "--rotate-token":
191
+ options.rotateToken = true;
192
+ break;
193
+ case "--apply-policy":
194
+ options.applyPolicy = true;
195
+ break;
196
+ case "--create-integration-token":
197
+ options.createIntegrationToken = readValue("--create-integration-token", index);
198
+ index += 1;
199
+ break;
200
+ case "--set-secret":
201
+ options.setSecret = true;
202
+ break;
203
+ case "--skip-r2":
204
+ options.skipR2 = true;
205
+ break;
206
+ case "--skip-access":
207
+ options.skipAccess = true;
208
+ break;
209
+ case "--skip-ledger":
210
+ options.skipLedger = true;
211
+ break;
212
+ case "--skip-secrets":
213
+ options.skipSecrets = true;
214
+ break;
215
+ case "--help":
216
+ case "-h":
217
+ options.help = true;
218
+ break;
219
+ default:
220
+ throw new Error(`Unknown option: ${argument}`);
221
+ }
222
+ }
223
+
224
+ return options;
225
+ }
226
+
227
+ function usage(): string {
228
+ return `Ensure tenants are registered and protected (R2, Cloudflare Access, Ledger, secrets).
229
+
230
+ Usage:
231
+ pnpm ensure:tenant -- --site <id> [options]
232
+ pnpm provision:tenants
233
+
234
+ Options:
235
+ --site <id> Only this site (default: every site)
236
+ --set-secret Push Worker secrets even when nothing was minted
237
+ --rotate-token Mint a replacement Ledger token (invalidates the old one)
238
+ --create-integration-token <id>
239
+ Create the Access service token of one integration and
240
+ print its client id and secret once (needs --site)
241
+ --skip-r2 Skip the R2 bucket phase
242
+ --skip-access Skip the Cloudflare Access phase
243
+ --skip-ledger Skip the Ledger registration phase
244
+ --skip-secrets Skip the Worker secret phase
245
+ --concurrency <n> Maximum parallel API operations
246
+ --dry-run Print the plan without mutating anything
247
+ -h, --help Show this help
248
+
249
+ CI note: an existing Ledger application is never rotated automatically. The
250
+ Worker keeps the token minted at bootstrap; use --rotate-token to replace it.`;
251
+ }
252
+
253
+ interface LedgerContext {
254
+ baseUrl: string;
255
+ adminToken: string;
256
+ dryRun: boolean;
257
+ rotate: boolean;
258
+ api<T>(route: string, init?: { method?: string; body?: unknown }): Promise<T>;
259
+ }
260
+
261
+ interface TenantOutcome {
262
+ siteId: string;
263
+ appId: string;
264
+ tokenMinted: boolean;
265
+ accessAud?: string;
266
+ integrationsAud?: string;
267
+ secretsPushed: boolean;
268
+ }
269
+
270
+ /**
271
+ * Ensure one tenant. Never rotates an existing application unless explicitly
272
+ * asked, so repeat runs are safe to execute on every deploy.
273
+ */
274
+ export async function ensureTenant(
275
+ site: ResolvedSite,
276
+ infrastructure: InfrastructureDefinition,
277
+ options: Options,
278
+ ledger: LedgerContext | null,
279
+ accessClient: CloudflareAccessClient | null,
280
+ ): Promise<TenantOutcome> {
281
+ const hostname = site.hostname;
282
+ const outcome: TenantOutcome = {
283
+ siteId: site.id,
284
+ appId: "",
285
+ tokenMinted: false,
286
+ secretsPushed: false,
287
+ };
288
+
289
+ // Access first: its audience is one of the secrets we may push.
290
+ let accessAud: string | undefined;
291
+ let integrationsAud: string | undefined;
292
+ if (accessClient) {
293
+ const access = await ensureAccess(site, hostname, accessClient, options, infrastructure.accessSessionDuration);
294
+ accessAud = access.aud;
295
+ integrationsAud = access.integrationsAud;
296
+ outcome.accessAud = accessAud;
297
+ outcome.integrationsAud = integrationsAud;
298
+ }
299
+
300
+ // Ledger second: mint only on bootstrap (or an explicit rotation).
301
+ let ledgerToken: string | null = null;
302
+ let appId = "";
303
+ if (ledger) {
304
+ const result = await ensureLedgerApp(site, ledger, options);
305
+ ledgerToken = result.token;
306
+ appId = result.appId;
307
+ outcome.appId = appId;
308
+ outcome.tokenMinted = result.minted;
309
+ }
310
+
311
+ // Secrets last: automatic on bootstrap/rotation, forced with --set-secret.
312
+ if (ledger || accessClient) {
313
+ const shouldPush =
314
+ !options.skipSecrets && (options.setSecret || outcome.tokenMinted);
315
+ if (shouldPush) {
316
+ const secrets = await collectSecrets(
317
+ site,
318
+ infrastructure,
319
+ ledgerToken,
320
+ accessAud,
321
+ integrationsAud,
322
+ ledger?.adminToken,
323
+ );
324
+ if (Object.keys(secrets).length === 0) {
325
+ console.log(`Site ${site.id}: no secrets to push.`);
326
+ } else if (options.dryRun) {
327
+ console.log(
328
+ `[dry-run] Would push ${Object.keys(secrets).join(", ")} for site ${site.id}`,
329
+ );
330
+ } else {
331
+ const target = await resolveFrontendWranglerTarget(site.id);
332
+ await runWrangler(target.config, target.envKey, ["secret", "bulk"], {
333
+ input: `${JSON.stringify(secrets)}\n`,
334
+ });
335
+ outcome.secretsPushed = true;
336
+ console.log(`Site ${site.id}: pushed ${Object.keys(secrets).join(", ")}`);
337
+ }
338
+ }
339
+ }
340
+
341
+ if (!options.dryRun && outcome.tokenMinted && ledgerToken) {
342
+ await storeCredential({
343
+ siteId: site.id,
344
+ ledgerAppName: site.ledgerAppName,
345
+ appId,
346
+ token: ledgerToken,
347
+ storedAt: new Date().toISOString(),
348
+ ...(accessAud ? { accessAud } : {}),
349
+ });
350
+ }
351
+
352
+ return outcome;
353
+ }
354
+
355
+ async function ensureAccess(
356
+ site: ResolvedSite,
357
+ hostname: string,
358
+ client: CloudflareAccessClient,
359
+ options: Options,
360
+ sessionDuration?: string,
361
+ ): Promise<{ aud?: string; integrationsAud?: string }> {
362
+ const siteInput: AccessSiteInput = {
363
+ id: site.id,
364
+ title: site.title,
365
+ hostname,
366
+ authorizedEmails: site.authorizedEmails,
367
+ integrations: site.integrations.map((integration) => integration.id),
368
+ };
369
+ const existingApps: ExistingAccessApp[] = await client.listApps();
370
+
371
+ // Pass 1: adopt the existing application or create it.
372
+ const firstPass = planAccessApps([siteInput], existingApps, new Map());
373
+ const createAction = firstPass.find((action) => action.kind === "create-app");
374
+ let app = firstPass.find((action) => action.kind === "adopt-app");
375
+ if (createAction?.kind === "create-app") {
376
+ if (options.dryRun) {
377
+ console.log(
378
+ `[dry-run] Would create Access application "${createAction.name}" for ${createAction.hostname}` +
379
+ (sessionDuration ? ` with session duration ${sessionDuration}` : ''),
380
+ );
381
+ return {};
382
+ }
383
+ const created = await client.createApp(
384
+ createAction.name,
385
+ createAction.hostname,
386
+ sessionDuration,
387
+ );
388
+ existingApps.push(created);
389
+ app = {
390
+ kind: "adopt-app",
391
+ siteId: site.id,
392
+ appId: created.id,
393
+ aud: created.aud,
394
+ name: created.name,
395
+ };
396
+ console.log(
397
+ `Site ${site.id}: created Access application "${created.name}" for ${createAction.hostname}`,
398
+ );
399
+ }
400
+
401
+ if (!app || app.kind !== "adopt-app") {
402
+ return {};
403
+ }
404
+
405
+ if (sessionDuration) {
406
+ await client.reconcileSessionDuration(`apps/${app.appId}`, sessionDuration, options.dryRun);
407
+ }
408
+
409
+ // Pass 2: reconcile the Allow policy to exactly the authorized emails, so
410
+ // an email removed from tenants.json loses access on the next run.
411
+ const policies: ExistingAccessPolicy[] = await client.listPolicies(app.appId);
412
+ const policyAction = planAccessApps(
413
+ [siteInput],
414
+ existingApps,
415
+ new Map([[app.appId, policies]]),
416
+ ).find((action) => action.kind === "reconcile-policy");
417
+ if (policyAction?.kind === "reconcile-policy") {
418
+ const replaced = new Set(
419
+ policyAction.replacePolicyIds.flatMap((id) => {
420
+ const policy = policies.find((candidate) => candidate.id === id);
421
+ return policy ? extractPolicyEmails(policy.include) : [];
422
+ }),
423
+ );
424
+ const added = policyAction.emails.filter((email) => !replaced.has(email));
425
+ const removed = [...replaced].filter(
426
+ (email) => !policyAction.emails.includes(email),
427
+ );
428
+ const changes: string[] = [];
429
+ if (added.length > 0) changes.push(`allow ${added.join(", ")}`);
430
+ if (removed.length > 0) changes.push(`deny ${removed.join(", ")}`);
431
+ const summary =
432
+ changes.length > 0 ? changes.join("; ") : "consolidate policies";
433
+
434
+ if (options.dryRun) {
435
+ console.log(
436
+ `[dry-run] Would ${summary} in the Access Allow policy ` +
437
+ `(app ${policyAction.appId})`,
438
+ );
439
+ } else if (policyAction.replacePolicyIds.length === 0) {
440
+ await client.createAllowPolicy(policyAction.appId, policyAction.emails);
441
+ console.log(
442
+ `Site ${site.id}: created Access Allow policy for ${policyAction.emails.join(", ")}`,
443
+ );
444
+ } else {
445
+ const [keep, ...drop] = policyAction.replacePolicyIds;
446
+ if (keep !== undefined) {
447
+ const kept = policies.find((policy) => policy.id === keep);
448
+ await client.updateAllowPolicy(
449
+ policyAction.appId,
450
+ keep,
451
+ kept?.name ?? ALLOW_POLICY_NAME,
452
+ policyAction.emails,
453
+ kept?.reusable === true,
454
+ );
455
+ for (const policyId of drop) {
456
+ const dropped = policies.find((policy) => policy.id === policyId);
457
+ await client.deletePolicy(
458
+ policyAction.appId,
459
+ policyId,
460
+ dropped?.reusable === true,
461
+ );
462
+ }
463
+ console.log(
464
+ `Site ${site.id}: reconciled Access Allow policy (${summary})`,
465
+ );
466
+ }
467
+ }
468
+ }
469
+
470
+ if (sessionDuration) {
471
+ // Re-read after email reconciliation, which may replace or consolidate policies.
472
+ const currentPolicies = options.dryRun ? policies : await client.listPolicies(app.appId);
473
+ for (const policy of currentPolicies) {
474
+ if (policy.decision.toLowerCase() === 'allow') {
475
+ // Reusable policies are updated at the account scope, not the app scope.
476
+ await client.reconcileSessionDuration(
477
+ policy.reusable
478
+ ? `policies/${policy.id}`
479
+ : `apps/${app.appId}/policies/${policy.id}`,
480
+ sessionDuration,
481
+ options.dryRun,
482
+ );
483
+ }
484
+ }
485
+ }
486
+
487
+ // Pass 3: calendar feeds moved to their own Worker, so the path-scoped
488
+ // Bypass application that once exposed them on this host is retired.
489
+ for (const feedApp of planFeedBypassRemoval(siteInput, existingApps)) {
490
+ if (options.dryRun) {
491
+ console.log(`[dry-run] Would delete the retired Access application "${feedApp.name}" (${feedApp.domain})`);
492
+ continue;
493
+ }
494
+ await client.deleteApp(feedApp.id);
495
+ existingApps.splice(existingApps.indexOf(feedApp), 1);
496
+ console.log(`Site ${site.id}: deleted the retired Access application "${feedApp.name}" (${feedApp.domain})`);
497
+ }
498
+
499
+ // Pass 4: service integrations get their own path-scoped application.
500
+ const integrationsAud = await ensureIntegrationsAccess(site, siteInput, client, existingApps, options);
501
+
502
+ if (!options.dryRun) {
503
+ console.log(
504
+ `Site ${site.id}: Access application ${app.appId} (${app.name}) protects ${hostname}`,
505
+ );
506
+ }
507
+ return { aud: app.aud, ...(integrationsAud ? { integrationsAud } : {}) };
508
+ }
509
+
510
+ /**
511
+ * Ensure the integrations application and its Service Auth policy. The policy
512
+ * names exactly the service tokens of the site's integrations (by the name
513
+ * `<site>/<integration>`), so removing an integration from tenants.json
514
+ * removes its access on the next run. Returns the application's audience.
515
+ */
516
+ async function ensureIntegrationsAccess(
517
+ site: ResolvedSite,
518
+ siteInput: AccessSiteInput,
519
+ client: CloudflareAccessClient,
520
+ existingApps: ExistingAccessApp[],
521
+ options: Options,
522
+ ): Promise<string | undefined> {
523
+ const requested = options.createIntegrationToken;
524
+ if (requested !== undefined && !site.integrations.some((integration) => integration.id === requested)) {
525
+ throw new TenantConfigError(
526
+ `Site ${site.id} has no integration "${requested}" (add it to tenants.json first)`,
527
+ );
528
+ }
529
+ const action = planIntegrationsApp(siteInput, existingApps);
530
+ if (!action) return undefined;
531
+
532
+ let appId: string;
533
+ let aud: string;
534
+ if (action.kind === "adopt-integrations-app") {
535
+ appId = action.appId;
536
+ aud = action.aud;
537
+ } else {
538
+ if (options.dryRun) {
539
+ console.log(`[dry-run] Would create Access application "${action.name}" for ${action.domain}`);
540
+ return undefined;
541
+ }
542
+ // Service callers cannot log in: answer 401 instead of redirecting to a login page.
543
+ const created = await client.createApp(action.name, action.domain, undefined, {
544
+ service_auth_401_redirect: true,
545
+ });
546
+ existingApps.push(created);
547
+ appId = created.id;
548
+ aud = created.aud;
549
+ console.log(`Site ${site.id}: created Access application "${created.name}" for ${action.domain}`);
550
+ }
551
+
552
+ let tokens = await client.listServiceTokens();
553
+ for (const integration of site.integrations) {
554
+ const name = integrationTokenName(site.id, integration.id);
555
+ const existing = tokens.find((token) => token.name === name);
556
+ if (requested === integration.id) {
557
+ if (existing) {
558
+ throw new TenantConfigError(
559
+ `Service token "${name}" already exists. Delete it in Cloudflare Zero Trust ` +
560
+ "(Access, Service Auth) before creating a replacement.",
561
+ );
562
+ }
563
+ if (options.dryRun) {
564
+ console.log(`[dry-run] Would create the Access service token "${name}"`);
565
+ } else {
566
+ const created = await client.createServiceToken(name);
567
+ tokens = [...tokens, created];
568
+ printServiceToken(site.id, integration.id, created.clientId, created.clientSecret, created.expiresAt);
569
+ }
570
+ }
571
+ }
572
+
573
+ const wantedIds = site.integrations.flatMap((integration) =>
574
+ tokens.filter((token) => token.name === integrationTokenName(site.id, integration.id)).map((token) => token.id),
575
+ );
576
+ for (const integration of site.integrations) {
577
+ const token = tokens.find((candidate) => candidate.name === integrationTokenName(site.id, integration.id));
578
+ if (!token) {
579
+ console.log(
580
+ `Site ${site.id}: integration "${integration.id}" has no service token yet; ` +
581
+ `run with --create-integration-token ${integration.id}.`,
582
+ );
583
+ } else if (integration.clientId !== token.clientId) {
584
+ console.log(
585
+ `Site ${site.id}: set clientId "${token.clientId}" on integration "${integration.id}" in ` +
586
+ "tenants.json so the Worker accepts the token.",
587
+ );
588
+ }
589
+ }
590
+
591
+ const policies = await client.listPolicies(appId);
592
+ const plan = planServiceAuthPolicy(wantedIds, policies);
593
+ if (plan.kind === "none") return aud;
594
+ if (options.dryRun) {
595
+ console.log(`[dry-run] Would reconcile the Service Auth policy of app ${appId} to ${plan.tokenIds.length} token(s)`);
596
+ return aud;
597
+ }
598
+ if (plan.kind === "create") {
599
+ await client.createServiceAuthPolicy(appId, plan.tokenIds);
600
+ } else {
601
+ const kept = policies.find((policy) => policy.id === plan.policyId);
602
+ await client.updateServiceAuthPolicy(
603
+ appId,
604
+ plan.policyId,
605
+ plan.tokenIds,
606
+ kept?.reusable === true,
607
+ );
608
+ for (const policyId of plan.dropPolicyIds) {
609
+ const dropped = policies.find((policy) => policy.id === policyId);
610
+ await client.deletePolicy(appId, policyId, dropped?.reusable === true);
611
+ }
612
+ }
613
+ console.log(`Site ${site.id}: reconciled the Service Auth policy (${plan.tokenIds.length} token(s))`);
614
+ return aud;
615
+ }
616
+
617
+ function printServiceToken(
618
+ siteId: string,
619
+ integrationId: string,
620
+ clientId: string,
621
+ clientSecret: string,
622
+ expiresAt: string | null,
623
+ ): void {
624
+ console.log(
625
+ `\nCreated Access service token for ${siteId}/${integrationId}.\n` +
626
+ "The secret is shown once and cannot be read back. Put both values in the Shortcut\n" +
627
+ "as the CF-Access-Client-Id and CF-Access-Client-Secret headers; do not save them in the repository.\n" +
628
+ ` Client ID: ${clientId}\n` +
629
+ ` Client secret: ${clientSecret}\n` +
630
+ ` Expires: ${expiresAt ?? "unknown"}\n` +
631
+ `Then set "clientId": "${clientId}" on the integration in tenants.json.\n`,
632
+ );
633
+ }
634
+
635
+ async function ensureLedgerApp(
636
+ site: ResolvedSite,
637
+ ledger: LedgerContext,
638
+ options: Options,
639
+ ): Promise<{ token: string | null; minted: boolean; appId: string }> {
640
+ const existing = await ledger.api<{ apps: AdminAppSummary[] }>(
641
+ "/admin/v1/apps",
642
+ );
643
+ const registered = existing.apps.find(
644
+ (app) => app.name === site.ledgerAppName && app.revokedAt === null,
645
+ );
646
+
647
+ if (registered && options.applyPolicy) {
648
+ if (options.dryRun) {
649
+ console.log(`[dry-run] Would set the event policy of Ledger app ${registered.id} for site ${site.id}`);
650
+ } else {
651
+ await ledger.api(`/admin/v1/apps/${registered.id}/policy`, { method: "PUT", body: siteEventPolicy(site) });
652
+ console.log(`Site ${site.id}: set the event policy of Ledger app ${registered.id}`);
653
+ }
654
+ }
655
+
656
+ if (registered && !options.rotateToken) {
657
+ console.log(
658
+ `Site ${site.id}: Ledger app "${site.ledgerAppName}" already registered (${registered.id}); keeping its token.`,
659
+ );
660
+ return { token: null, minted: false, appId: registered.id };
661
+ }
662
+
663
+ if (options.dryRun) {
664
+ console.log(
665
+ registered
666
+ ? `[dry-run] Would rotate the Ledger token for site ${site.id}`
667
+ : `[dry-run] Would create Ledger app "${site.ledgerAppName}" for site ${site.id}`,
668
+ );
669
+ return { token: null, minted: false, appId: registered?.id ?? "" };
670
+ }
671
+
672
+ if (!registered) {
673
+ const created = await ledger.api<AdminAppTokenResponse>("/admin/v1/apps", {
674
+ method: "POST",
675
+ body: { name: site.ledgerAppName, policy: siteEventPolicy(site) },
676
+ });
677
+ console.log(
678
+ `Site ${site.id}: created Ledger app "${created.app.name}" (${created.app.id})`,
679
+ );
680
+ return { token: created.token, minted: true, appId: created.app.id };
681
+ }
682
+
683
+ const rotated = await ledger.api<AdminAppTokenResponse>(
684
+ `/admin/v1/apps/${registered.id}/rotate-token`,
685
+ { method: "POST" },
686
+ );
687
+ console.log(`Site ${site.id}: rotated Ledger token for app ${registered.id}`);
688
+ return { token: rotated.token, minted: true, appId: rotated.app.id };
689
+ }
690
+
691
+ /**
692
+ * The Worker secrets of one site. A site with the Ledger administrator proxy (`admin`) gets the administrator token of
693
+ * the Ledger its gateway is bound to: the shared Ledger's, or its own when it runs on its own stack. The operator's
694
+ * `LEDGER_ADMIN_TOKEN` environment variable is only the fallback when no Ledger was contacted (`--skip-ledger`).
695
+ */
696
+ export async function collectSecrets(
697
+ site: Pick<ResolvedSite, "id" | "admin" | "integrations">,
698
+ infrastructure: Pick<InfrastructureDefinition, "accessTeamDomain">,
699
+ ledgerToken: string | null,
700
+ accessAud: string | undefined,
701
+ integrationsAud: string | undefined,
702
+ ledgerAdminToken?: string,
703
+ ): Promise<Record<string, string>> {
704
+ const secrets: Record<string, string> = {
705
+ CF_ACCESS_TEAM_DOMAIN: infrastructure.accessTeamDomain,
706
+ };
707
+ if (ledgerToken) secrets.LEDGER_AUTH_TOKEN = ledgerToken;
708
+ if (accessAud) secrets.CF_ACCESS_APP_AUD = accessAud;
709
+ if (integrationsAud) secrets.CF_ACCESS_INTEGRATIONS_AUD = integrationsAud;
710
+
711
+ if (site.admin) {
712
+ const adminToken = ledgerAdminToken?.trim() || process.env.LEDGER_ADMIN_TOKEN?.trim();
713
+ if (adminToken) secrets.LEDGER_ADMIN_TOKEN = adminToken;
714
+ const adminAud = process.env.CF_ACCESS_ADMIN_AUD?.trim();
715
+ if (adminAud) secrets.CF_ACCESS_ADMIN_AUD = adminAud;
716
+ }
717
+
718
+ // The renderer's credential comes from the operator's environment; without one the renderer routes answer 503.
719
+ const rendererToken = process.env.INVOICE_RENDERER_TOKEN?.trim();
720
+ if (rendererToken) secrets.INVOICE_RENDERER_TOKEN = rendererToken;
721
+ Object.assign(secrets, extractionSecretsFromEnvironment());
722
+ return secrets;
723
+ }
724
+
725
+ /**
726
+ * Vertex AI credentials for AI invoice reading. Absent credentials are not a
727
+ * failure: the feature answers 503 and the UI hides itself.
728
+ */
729
+ function extractionSecretsFromEnvironment(): Record<string, string> {
730
+ const secrets: Record<string, string> = {};
731
+ const apiKey = process.env.INVOICE_EXTRACTION_API_KEY?.trim();
732
+ const serviceAccount = process.env.INVOICE_EXTRACTION_SERVICE_ACCOUNT?.trim();
733
+ if (apiKey) secrets.INVOICE_EXTRACTION_API_KEY = apiKey;
734
+ if (serviceAccount)
735
+ secrets.INVOICE_EXTRACTION_SERVICE_ACCOUNT = serviceAccount;
736
+ return secrets;
737
+ }
738
+
739
+ /**
740
+ * Ensure the shared R2 buckets exist. Creation is idempotent: the deploy
741
+ * matrix runs `ensure-tenant` once per site in parallel, so several processes
742
+ * may race to create the same shared bucket. Cloudflare answers the losers
743
+ * with code 10004 ("already exists, and you own it"), which is success here.
744
+ * The same code without ownership means another account holds the name, which
745
+ * must fail loudly.
746
+ */
747
+ /**
748
+ * The buckets the selected sites need: the core's calendar feed bucket, then
749
+ * those their modules declare.
750
+ */
751
+ export function requiredBuckets(
752
+ infrastructure: InfrastructureDefinition,
753
+ sites: readonly Pick<ResolvedSite, "features">[],
754
+ ): string[] {
755
+ const names = new Set([infrastructure.calendarFeedsBucket]);
756
+ for (const site of sites) {
757
+ for (const { bucket_name } of moduleBindings(site.features, infrastructure).r2) names.add(bucket_name);
758
+ }
759
+ return [...names];
760
+ }
761
+
762
+ async function ensureR2Buckets(
763
+ infrastructure: InfrastructureDefinition,
764
+ sites: readonly ResolvedSite[],
765
+ options: Options,
766
+ ): Promise<void> {
767
+ const bucketNames = requiredBuckets(infrastructure, sites);
768
+ if (options.dryRun) {
769
+ for (const name of bucketNames) {
770
+ console.log(`[dry-run] Would ensure R2 bucket "${name}"`);
771
+ }
772
+ return;
773
+ }
774
+ for (const name of bucketNames) {
775
+ const result = await runCommandResult(
776
+ WRANGLER_BIN,
777
+ ["r2", "bucket", "create", name, "--env-file", devNull],
778
+ { env: wranglerEnvironment() },
779
+ );
780
+ if (result.code === 0) {
781
+ console.log(`Created R2 bucket "${name}".`);
782
+ continue;
783
+ }
784
+ const output = `${result.stdout}\n${result.stderr}`;
785
+ if (isBucketAlreadyExistsError(output)) {
786
+ console.log(`R2 bucket "${name}" already exists.`);
787
+ continue;
788
+ }
789
+ throw new Error(
790
+ `Failed to create R2 bucket "${name}" (exit code ${result.code}):\n${output.trim()}`,
791
+ );
792
+ }
793
+ }
794
+
795
+ /**
796
+ * Cloudflare's raced-create answer: `[code: 10004]` plus the ownership
797
+ * phrase. Both parts matter: a same-named bucket owned by another account
798
+ * also reports 10004 but must not be treated as success.
799
+ */
800
+ export function isBucketAlreadyExistsError(output: string): boolean {
801
+ return (
802
+ /code:\s*10004\b/i.test(output) &&
803
+ /already exists/i.test(output) &&
804
+ /you own it/i.test(output)
805
+ );
806
+ }
807
+
808
+ async function readStoredCredential(
809
+ siteId: string,
810
+ ): Promise<StoredCredential | null> {
811
+ try {
812
+ return JSON.parse(
813
+ await readFile(tenantCredentialPath(siteId), "utf8"),
814
+ ) as StoredCredential;
815
+ } catch {
816
+ try {
817
+ return JSON.parse(
818
+ await readFile(legacyTenantCredentialPath(siteId), "utf8"),
819
+ ) as StoredCredential;
820
+ } catch {
821
+ return null;
822
+ }
823
+ }
824
+ }
825
+
826
+ async function storeCredential(credential: StoredCredential): Promise<void> {
827
+ const target = tenantCredentialPath(credential.siteId);
828
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
829
+ const previous = await readStoredCredential(credential.siteId);
830
+ const merged: StoredCredential = { ...previous, ...credential };
831
+ await writeFile(target, `${JSON.stringify(merged, null, 2)}\n`, {
832
+ mode: 0o600,
833
+ });
834
+ console.log(`Stored credential for ${credential.siteId}`);
835
+ }
836
+
837
+ function ledgerContext(
838
+ baseUrl: string,
839
+ adminToken: string,
840
+ options: Options,
841
+ ): LedgerContext {
842
+ return {
843
+ baseUrl,
844
+ adminToken,
845
+ dryRun: options.dryRun,
846
+ rotate: options.rotateToken,
847
+ async api<T>(
848
+ route: string,
849
+ init: { method?: string; body?: unknown } = {},
850
+ ): Promise<T> {
851
+ const response = await fetch(`${baseUrl}${route}`, {
852
+ method: init.method ?? "GET",
853
+ headers: {
854
+ Authorization: `Bearer ${adminToken}`,
855
+ ...(init.body === undefined ? {} : { "Content-Type": "application/json" }),
856
+ },
857
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
858
+ });
859
+ const payload: unknown = await response.json().catch(() => null);
860
+ if (!response.ok) {
861
+ throw new TenantConfigError(
862
+ `${init.method ?? "GET"} ${route} failed with ${response.status}: ` +
863
+ `${JSON.stringify(payload)}`,
864
+ );
865
+ }
866
+ return payload as T;
867
+ },
868
+ };
869
+ }
870
+
871
+ export async function ensureTenants(options: Options): Promise<void> {
872
+ if (options.rotateToken && !options.site) {
873
+ throw new Error("--rotate-token requires --site <id>");
874
+ }
875
+ if (options.createIntegrationToken && !options.site) {
876
+ throw new Error("--create-integration-token requires --site <id>");
877
+ }
878
+ if (options.createIntegrationToken && options.skipAccess) {
879
+ throw new Error("--create-integration-token cannot run with --skip-access");
880
+ }
881
+
882
+ const { infrastructure: sharedInfrastructure, sites } = await loadTenantConfig();
883
+ const selected = options.site ? [siteById(sites, options.site)] : sharedSites(sites);
884
+ if (selected.length === 0) {
885
+ throw new TenantConfigError("No sites are configured.");
886
+ }
887
+ if (!options.site) {
888
+ for (const site of sites.filter((candidate) => candidate.deployment)) {
889
+ console.log(`Skipping ${site.id}: it runs on its own stack; ensure it with --site ${site.id}.`);
890
+ }
891
+ }
892
+ // Only one site can be selected together with its own stack.
893
+ const ownStack = selected.length === 1 && selected[0]!.deployment ? selected[0]! : null;
894
+ const infrastructure = ownStack ? infrastructureFor(ownStack, sharedInfrastructure) : sharedInfrastructure;
895
+
896
+ await generateTenantWranglerConfigs();
897
+
898
+ if (!options.skipR2) {
899
+ await ensureR2Buckets(infrastructure, selected, options);
900
+ }
901
+
902
+ const cloudflareToken = process.env.CLOUDFLARE_API_TOKEN?.trim() ?? "";
903
+ let accessClient: CloudflareAccessClient | null = null;
904
+ if (!options.skipAccess) {
905
+ if (!cloudflareToken) {
906
+ throw new Error(
907
+ `Cloudflare Access cannot run without CLOUDFLARE_API_TOKEN (${ACCESS_TOKEN_REQUIREMENT}). ` +
908
+ "Pass --skip-access for an explicitly Access-free run.",
909
+ );
910
+ }
911
+ accessClient = new CloudflareAccessClient(
912
+ cloudflareToken,
913
+ process.env.CLOUDFLARE_ACCOUNT_ID?.trim() || null,
914
+ );
915
+ // The team-wide SSO lifetime is an account setting; the shared sites own it,
916
+ // so a run for a site on its own stack leaves it alone.
917
+ if (infrastructure.accessGlobalSessionDuration && !ownStack) {
918
+ await accessClient.reconcileSessionDuration(
919
+ 'organizations', infrastructure.accessGlobalSessionDuration, options.dryRun,
920
+ infrastructure.accessTeamDomain,
921
+ );
922
+ }
923
+ }
924
+
925
+ let ledger: LedgerContext | null = null;
926
+ if (!options.skipLedger && ownStack) {
927
+ const variable = ownStack.id.toUpperCase().replace(/-/g, "_");
928
+ const baseUrl = process.env[`LEDGER_BASE_URL_${variable}`]?.trim().replace(/\/$/, "")
929
+ || dedicatedLedgerBaseUrl(ownStack, sharedInfrastructure);
930
+ const adminToken = await resolveDedicatedAdminToken(ownStack.id);
931
+ if (!adminToken) {
932
+ throw new Error(
933
+ `No administrator token for the ${ownStack.id} Ledger: run ` +
934
+ `pnpm deploy:ledger -- --site ${ownStack.id} --bootstrap-secrets first, ` +
935
+ `set LEDGER_ADMIN_TOKEN_${variable}, or pass --skip-ledger.`,
936
+ );
937
+ }
938
+ ledger = ledgerContext(baseUrl, adminToken, options);
939
+ } else if (!options.skipLedger) {
940
+ const baseUrl = resolveLedgerBaseUrl(
941
+ process.env.LEDGER_BASE_URL,
942
+ sharedInfrastructure.ledgerWorker,
943
+ sharedInfrastructure.workersDevDomain,
944
+ );
945
+ const adminToken = await resolveLedgerAdminToken();
946
+ if (!adminToken) {
947
+ throw new Error(
948
+ "No Ledger admin token: set LEDGER_ADMIN_TOKEN or create " +
949
+ ".wrangler/tenants/.admin-token, or pass --skip-ledger.",
950
+ );
951
+ }
952
+ ledger = ledgerContext(baseUrl, adminToken, options);
953
+ }
954
+
955
+ const outcomes = await mapWithConcurrency(
956
+ selected,
957
+ options.concurrency,
958
+ (site) => ensureTenant(site, infrastructure, options, ledger, accessClient),
959
+ );
960
+
961
+ console.log("\nTenant ensure complete:");
962
+ for (const outcome of outcomes) {
963
+ const notes = [
964
+ outcome.appId ? `app ${outcome.appId}` : "no Ledger app",
965
+ outcome.tokenMinted ? "token minted" : "token kept",
966
+ outcome.accessAud ? "access ready" : "access skipped",
967
+ outcome.secretsPushed ? "secrets pushed" : "secrets kept",
968
+ ];
969
+ console.log(` ${outcome.siteId}: ${notes.join(", ")}`);
970
+ }
971
+ }
972
+
973
+ export async function main(argv = process.argv.slice(2)): Promise<void> {
974
+ const options = parseOptions(argv);
975
+ if (options.help) {
976
+ console.log(usage());
977
+ return;
978
+ }
979
+ await ensureTenants(options);
980
+ }
981
+
982
+ const invokedPath = process.argv[1];
983
+ if (invokedPath?.endsWith("ensure-tenant.ts")) {
984
+ main().catch((error: unknown) => {
985
+ console.error(`\n${error instanceof Error ? error.message : String(error)}`);
986
+ process.exitCode = 1;
987
+ });
988
+ }