@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.
- package/LICENSE +235 -0
- package/bin/hearthlog.mjs +61 -0
- package/package.json +64 -3
- package/src/apply.ts +73 -0
- package/src/check/check-layers.ts +118 -0
- package/src/check/check-modules.ts +198 -0
- package/src/check/check-sdk-exports.ts +79 -0
- package/src/check/check-tenant-config.ts +208 -0
- package/src/cli/hearthlog.ts +212 -0
- package/src/cli/list-sites.ts +15 -0
- package/src/compose/compose-modules.ts +490 -0
- package/src/compose/module-scaffold.ts +195 -0
- package/src/deploy/artifact-compare.ts +93 -0
- package/src/deploy/artifact.ts +127 -0
- package/src/deploy/async-utils.ts +71 -0
- package/src/deploy/backup-tenants.ts +139 -0
- package/src/deploy/cloudflare-access.ts +789 -0
- package/src/deploy/copy-tenant.ts +395 -0
- package/src/deploy/dedicated-ledger.ts +77 -0
- package/src/deploy/deploy-ledger.ts +162 -0
- package/src/deploy/deploy-runner.ts +240 -0
- package/src/deploy/deploy-tenant.ts +170 -0
- package/src/deploy/deployment-security.ts +141 -0
- package/src/deploy/ensure-tenant.ts +988 -0
- package/src/deploy/event-policy.ts +23 -0
- package/src/deploy/frontend-dry-run.ts +18 -0
- package/src/deploy/ledger-credential.ts +48 -0
- package/src/deploy/ledger-scripts.ts +13 -0
- package/src/deploy/split-tenant.ts +295 -0
- package/src/dev/local-development.ts +288 -0
- package/src/dev/provision-local.ts +13 -0
- package/src/generate/build-generated.ts +166 -0
- package/src/generate/generate-brand-icons.ts +128 -0
- package/src/generate/generate-entities.ts +627 -0
- package/src/generate/generate-tenant.ts +161 -0
- package/src/host/core-routes.ts +39 -0
- package/src/host/write-host.ts +119 -0
- package/src/index.ts +8 -0
- package/src/init/check-module.ts +143 -0
- package/src/init/export-tenant.ts +124 -0
- package/src/init/init.ts +168 -0
- package/src/move.ts +189 -0
- package/src/plan.ts +160 -0
- package/src/public-workers/build-gallery-public.ts +114 -0
- package/src/public-workers/calendar-public-config.ts +27 -0
- package/src/public-workers/check-calendar-public-route.ts +41 -0
- package/src/public-workers/check-gallery-public-route.ts +43 -0
- package/src/public-workers/gallery-public-config.ts +22 -0
- package/src/receipt.ts +74 -0
- package/src/root.ts +16 -0
- package/src/security/audit-push.ts +141 -0
- package/src/security/forbidden-identities.ts +107 -0
- package/src/security/forbidden-init.ts +88 -0
- package/src/tenants/host-configs.ts +96 -0
- package/src/tenants/ledger-config.ts +57 -0
- package/src/tenants/lock-file.ts +45 -0
- package/src/tenants/module-lock.ts +58 -0
- package/src/tenants/tenant-config.ts +887 -0
- package/src/tenants/tenant-copy-plan.ts +119 -0
- package/src/tenants/tenant-schema.ts +171 -0
- package/src/tenants/tenant-wrangler.ts +37 -0
- package/src/tenants/wrangler-base.ts +50 -0
- package/src/tenants/wrangler-config.ts +266 -0
- package/src/vite/config.ts +310 -0
- package/src/vite/dev-plugins.ts +338 -0
- package/src/vite/vite-plugin-entity-gen.ts +70 -0
- package/src/vite/vite-plugin-tenant-manifest.ts +61 -0
- package/src/vite/vite-plugin-worker-assets.ts +42 -0
- package/templates/host/app.d.ts.txt +31 -0
- package/templates/host/microlighter.d.ts.txt +11 -0
- package/templates/module/ci.yml.tmpl +44 -0
- package/templates/module/gitignore.tmpl +7 -0
- package/templates/module/gitleaks.toml.tmpl +11 -0
- package/templates/module/release.yml.tmpl +53 -0
- package/templates/tenant/README.md.tmpl +13 -0
- package/templates/tenant/generated-package.json.tmpl +16 -0
- package/templates/tenant/github-workflows/deploy.yml.tmpl +111 -0
- package/templates/tenant/gitignore.tmpl +11 -0
- package/templates/tenant/package.json.tmpl +11 -0
- package/templates/tenant/patches/sqlocal@0.18.0.patch +18 -0
- package/templates/tenant/pnpm-workspace.yaml.tmpl +8 -0
- package/templates/tenant/tenants/__id__/tenant.json.tmpl +8 -0
- package/templates/tenant/tenants.json.tmpl +17 -0
- package/templates/tenant/tsconfig.json.tmpl +21 -0
- package/templates/tenant/vite.config.ts.tmpl +5 -0
- 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
|
+
}
|