@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,240 @@
1
+ /**
2
+ * Shared deployment primitives for the CI-native pipelines.
3
+ *
4
+ * This replaces the former compensating-transaction machinery: it only runs
5
+ * commands, invokes Wrangler, loads the deploy units, and builds one tenant
6
+ * bundle. Rollback is delegated to Cloudflare's native per-Worker version
7
+ * history (`wrangler deployments` / `wrangler rollback`).
8
+ *
9
+ * Wrangler authentication:
10
+ * - CI (`CI`/`GITHUB_ACTIONS`) uses CLOUDFLARE_API_TOKEN for deploys and
11
+ * secret pushes, exactly like the old single-Worker `deploy.yml`.
12
+ * - Local runs keep using the Wrangler OAuth session unless
13
+ * `STOR_WRANGLER_API_TOKEN=1` is set, because the local Access-only API
14
+ * token cannot push Worker secrets.
15
+ */
16
+
17
+ import { spawn } from "node:child_process";
18
+ import { rm } from "node:fs/promises";
19
+ import { devNull } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ import { REPOSITORY_ROOT } from "../tenants/tenant-config.js";
23
+ import { resolveFrontendWranglerTarget, siteBuildDirectory } from "../tenants/wrangler-config.js";
24
+
25
+
26
+ export interface DeployUnit {
27
+ /** Journal key: "backend" or the site id. */
28
+ key: string;
29
+ label: string;
30
+ config: string;
31
+ /** --env value; null means the config is standalone (no --env). */
32
+ envKey: string | null;
33
+ kind: "backend" | "frontend";
34
+ }
35
+
36
+ export interface RunCommandOptions {
37
+ captureStdout?: boolean;
38
+ env?: NodeJS.ProcessEnv;
39
+ /** Written to the child's stdin and closed; used by `wrangler secret bulk`. */
40
+ input?: string;
41
+ }
42
+
43
+ export interface CommandResult {
44
+ code: number;
45
+ stdout: string;
46
+ stderr: string;
47
+ }
48
+
49
+ function quote(value: string): string {
50
+ return /^[A-Za-z0-9_./:@%=-]+$/.test(value) ? value : JSON.stringify(value);
51
+ }
52
+
53
+ export function formatCommand(command: string, args: string[]): string {
54
+ return [command, ...args].map(quote).join(" ");
55
+ }
56
+
57
+ /** Run a command, inheriting stdio unless stdout capture is requested. */
58
+ export async function runCommand(
59
+ command: string,
60
+ args: string[],
61
+ options: RunCommandOptions = {},
62
+ ): Promise<string> {
63
+ const rendered = formatCommand(command, args);
64
+ console.log(`\n$ ${rendered}`);
65
+
66
+ return await new Promise<string>((resolvePromise, rejectPromise) => {
67
+ const child = spawn(command, args, {
68
+ cwd: REPOSITORY_ROOT,
69
+ env: options.env ?? process.env,
70
+ stdio: [
71
+ options.input === undefined ? "inherit" : "pipe",
72
+ options.captureStdout ? "pipe" : "inherit",
73
+ "inherit",
74
+ ],
75
+ });
76
+ let stdout = "";
77
+ if (options.captureStdout && child.stdout) child.stdout.setEncoding("utf8");
78
+ child.stdout?.on("data", (chunk: string) => {
79
+ stdout += chunk;
80
+ });
81
+ if (options.input !== undefined) child.stdin?.end(options.input);
82
+ child.once("error", rejectPromise);
83
+ child.once("exit", (code, signal) => {
84
+ if (code === 0) {
85
+ resolvePromise(stdout);
86
+ return;
87
+ }
88
+ const reason = signal
89
+ ? `signal ${signal}`
90
+ : `exit code ${code ?? "unknown"}`;
91
+ rejectPromise(new Error(`Command failed with ${reason}: ${rendered}`));
92
+ });
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Run a command without rejecting on a non-zero exit, capturing both streams.
98
+ * Used for idempotent provisioning calls that must distinguish "already
99
+ * exists" from a real failure.
100
+ */
101
+ export async function runCommandResult(
102
+ command: string,
103
+ args: string[],
104
+ options: RunCommandOptions = {},
105
+ ): Promise<CommandResult> {
106
+ const rendered = formatCommand(command, args);
107
+ console.log(`\n$ ${rendered}`);
108
+
109
+ return await new Promise<CommandResult>((resolvePromise, rejectPromise) => {
110
+ const child = spawn(command, args, {
111
+ cwd: REPOSITORY_ROOT,
112
+ env: options.env ?? process.env,
113
+ stdio: [
114
+ options.input === undefined ? "inherit" : "pipe",
115
+ "pipe",
116
+ "pipe",
117
+ ],
118
+ });
119
+ let stdout = "";
120
+ let stderr = "";
121
+ child.stdout?.setEncoding("utf8");
122
+ child.stderr?.setEncoding("utf8");
123
+ child.stdout?.on("data", (chunk: string) => {
124
+ stdout += chunk;
125
+ });
126
+ child.stderr?.on("data", (chunk: string) => {
127
+ stderr += chunk;
128
+ });
129
+ if (options.input !== undefined) child.stdin?.end(options.input);
130
+ child.once("error", rejectPromise);
131
+ child.once("exit", (code, signal) => {
132
+ if (signal) {
133
+ rejectPromise(
134
+ new Error(`Command failed with signal ${signal}: ${rendered}`),
135
+ );
136
+ return;
137
+ }
138
+ resolvePromise({ code: code ?? -1, stdout, stderr });
139
+ });
140
+ });
141
+ }
142
+
143
+ export function isCi(): boolean {
144
+ return Boolean(
145
+ process.env.CI || process.env.GITHUB_ACTIONS || process.env.STOR_CI,
146
+ );
147
+ }
148
+
149
+ /** Environment for Wrangler invocations. See the module header. */
150
+ export function wranglerEnvironment(
151
+ overrides: NodeJS.ProcessEnv = {},
152
+ ): NodeJS.ProcessEnv {
153
+ const environment: NodeJS.ProcessEnv = { ...process.env, ...overrides };
154
+ if (!isCi() && process.env.STOR_WRANGLER_API_TOKEN !== "1") {
155
+ delete environment.CLOUDFLARE_API_TOKEN;
156
+ delete environment.CLOUDFLARE_API_KEY;
157
+ delete environment.CLOUDFLARE_API_EMAIL;
158
+ }
159
+ return environment;
160
+ }
161
+
162
+ /**
163
+ * The project's own wrangler binary. Calling it directly (not through
164
+ * `pnpm exec`) keeps `--env-file` away from pnpm's standalone executable,
165
+ * which would read the flag as its own Node option and fail.
166
+ */
167
+ export const WRANGLER_BIN = join(REPOSITORY_ROOT, "node_modules", ".bin", "wrangler");
168
+
169
+ export function wranglerArgs(
170
+ config: string,
171
+ envKey: string | null,
172
+ args: string[],
173
+ ): string[] {
174
+ const tail = envKey === null ? [] : ["--env", envKey];
175
+ return [
176
+ ...args,
177
+ "--config",
178
+ config,
179
+ ...tail,
180
+ "--env-file",
181
+ devNull,
182
+ ];
183
+ }
184
+
185
+ export async function runWrangler(
186
+ config: string,
187
+ envKey: string | null,
188
+ args: string[],
189
+ options: RunCommandOptions = {},
190
+ ): Promise<string> {
191
+ return await runCommand(WRANGLER_BIN, wranglerArgs(config, envKey, args), {
192
+ ...options,
193
+ env: wranglerEnvironment(options.env),
194
+ });
195
+ }
196
+
197
+ /** The wrangler target for one site, resolving generated tenant configs. */
198
+ export async function frontendUnit(siteId: string): Promise<DeployUnit> {
199
+ const target = resolveFrontendWranglerTarget(siteId);
200
+ return {
201
+ key: siteId,
202
+ label: `frontend ${siteId}`,
203
+ config: target.config,
204
+ envKey: target.envKey,
205
+ kind: "frontend",
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Build one tenant into its Wrangler `assets.directory`.
211
+ *
212
+ * `pnpm build` writes the site's catalog, tenant module, and app shell to
213
+ * `.generated/` first, so the Worker bundles that site's modules; call
214
+ * `restoreGenerated()` when the site's Wrangler commands are done. `kit.outDir` is left at the default
215
+ * `.svelte-kit`. The old parallel transaction deploy also pointed SvelteKit's
216
+ * outDir at an isolated directory; that made SvelteKit and `vite-plugin-pwa`
217
+ * race over `output/client/service-worker.js` on a clean CI checkout, so it is
218
+ * gone. One tenant builds per CI job / local invocation, so sharing the
219
+ * default `.svelte-kit` is safe.
220
+ */
221
+ export async function buildTenant(siteId: string): Promise<void> {
222
+ const output = join(REPOSITORY_ROOT, siteBuildDirectory(siteId));
223
+ await rm(output, { recursive: true, force: true });
224
+ await runCommand("pnpm", ["build"], {
225
+ env: {
226
+ ...process.env,
227
+ VITE_STOR_TENANT: siteId,
228
+ },
229
+ });
230
+ }
231
+
232
+ /**
233
+ * Build the default site's generated files again. A tenant build leaves `.generated/` with that site's
234
+ * (smaller) catalog, and the tools that read every site's configuration need the default site's, which
235
+ * covers them all.
236
+ */
237
+ export async function restoreGenerated(): Promise<void> {
238
+ const { VITE_STOR_TENANT: _site, ...environment } = process.env;
239
+ await runCommand("pnpm", ["build:generated", "--quiet"], { env: environment });
240
+ }
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Build and deploy one tenant's frontend Worker.
4
+ *
5
+ * This is the CI-native replacement for the per-tenant leg of the old
6
+ * compensating transaction. It validates the bundle with a Wrangler dry-run,
7
+ * then deploys. Roll back with Cloudflare's native per-Worker versions:
8
+ *
9
+ * pnpm exec wrangler deployments list --config .generated/sites/<site>/wrangler.jsonc
10
+ * pnpm exec wrangler rollback <deployment-id> --config .generated/sites/<site>/wrangler.jsonc
11
+ *
12
+ * Usage:
13
+ * pnpm deploy:tenant -- --site st
14
+ * pnpm deploy:tenant -- --site st --dry-run
15
+ * pnpm deploy:tenant -- --site st --skip-build
16
+ *
17
+ * A site on its own stack (tenants/<id>/deployment.json) also deploys its own
18
+ * public Gallery and calendar feed Workers after the frontend, because the
19
+ * feed Worker binds to the frontend's renderer.
20
+ */
21
+
22
+ import { existsSync } from "node:fs";
23
+ import { join, resolve } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ import { relative } from "node:path";
27
+
28
+ import { buildTenant, frontendUnit, restoreGenerated, runWrangler } from "./deploy-runner.js";
29
+ import { buildGalleryPublic } from "../public-workers/build-gallery-public.js";
30
+ import { generateHostConfigs, hostWorkerConfigPath, hostWorkerDirectory } from "../tenants/host-configs.js";
31
+ import {
32
+ loadTenantConfig,
33
+ REPOSITORY_ROOT,
34
+ siteById,
35
+ type ResolvedSite,
36
+ } from "../tenants/tenant-config.js";
37
+ import { generateTenantWranglerConfigs } from "../tenants/wrangler-config.js";
38
+
39
+ interface Options {
40
+ site: string;
41
+ dryRun: boolean;
42
+ skipBuild: boolean;
43
+ help: boolean;
44
+ }
45
+
46
+ function usage(): string {
47
+ return `Build and deploy one tenant frontend Worker.
48
+
49
+ Usage:
50
+ pnpm deploy:tenant -- --site <id> [options]
51
+
52
+ Options:
53
+ --site <id> Site id from tenants.json (required)
54
+ --dry-run Build and bundle without uploading
55
+ --skip-build Deploy the existing build/<site> output
56
+ -h, --help Show this help`;
57
+ }
58
+
59
+ export function parseOptions(argv: string[]): Options {
60
+ const options: Options = {
61
+ site: "",
62
+ dryRun: false,
63
+ skipBuild: false,
64
+ help: false,
65
+ };
66
+ for (let index = 0; index < argv.length; index += 1) {
67
+ const argument = argv[index];
68
+ switch (argument) {
69
+ case "--":
70
+ break;
71
+ case "--site": {
72
+ const site = argv[index + 1]?.trim();
73
+ if (!site) throw new Error("--site requires a site id");
74
+ options.site = site;
75
+ index += 1;
76
+ break;
77
+ }
78
+ case "--dry-run":
79
+ options.dryRun = true;
80
+ break;
81
+ case "--skip-build":
82
+ options.skipBuild = true;
83
+ break;
84
+ case "--help":
85
+ case "-h":
86
+ options.help = true;
87
+ break;
88
+ default:
89
+ throw new Error(`Unknown option: ${argument}`);
90
+ }
91
+ }
92
+ return options;
93
+ }
94
+
95
+ export async function deploySite(options: Options): Promise<void> {
96
+ if (!options.site) throw new Error("--site requires a site id");
97
+ const { sites } = await loadTenantConfig();
98
+ const site = siteById(sites, options.site);
99
+
100
+ // Generated tenant wrangler configs are derived from tenants.json.
101
+ await generateTenantWranglerConfigs(site.id);
102
+
103
+ // The build leaves the site's own catalog in `.generated/`, which its Worker bundles; the commands
104
+ // that read every site's configuration run after it is restored.
105
+ const unit = await frontendUnit(site.id);
106
+ try {
107
+ if (!options.skipBuild) {
108
+ await buildTenant(site.id);
109
+ }
110
+ console.log(`\nValidating ${unit.label}...`);
111
+ await runWrangler(unit.config, unit.envKey, ["deploy", "--dry-run"]);
112
+ if (!options.dryRun) {
113
+ console.log(`\nDeploying ${unit.label}...`);
114
+ await runWrangler(unit.config, unit.envKey, ["deploy"]);
115
+ console.log(`Deployed ${site.id}.`);
116
+ }
117
+ } finally {
118
+ if (!options.skipBuild) await restoreGenerated();
119
+ }
120
+
121
+ if (options.dryRun) {
122
+ await deployOwnPublicWorkers(site, true, options.skipBuild);
123
+ console.log(`\nDry run complete for ${site.id}. Nothing was uploaded.`);
124
+ return;
125
+ }
126
+ await deployOwnPublicWorkers(site, false, options.skipBuild);
127
+ }
128
+
129
+ /** The public Workers of a site on its own stack; shared sites use the shared ones. */
130
+ async function deployOwnPublicWorkers(site: ResolvedSite, dryRun: boolean, skipBuild: boolean): Promise<void> {
131
+ if (!site.deployment) return;
132
+ const args = ["deploy", ...(dryRun ? ["--dry-run"] : [])];
133
+ if (site.features.includes("gallery")) {
134
+ if (skipBuild) {
135
+ // A release built elsewhere carries its Worker bundle; only the configs are written here.
136
+ await generateHostConfigs(site.id);
137
+ if (!existsSync(join(hostWorkerDirectory(site.id, "gallery-public"), "dist", "worker", "index.js"))) {
138
+ throw new Error(`--skip-build needs the built public Gallery Worker: run hearthlog gallery-public:build --site ${site.id} first`);
139
+ }
140
+ } else {
141
+ await buildGalleryPublic(site.id);
142
+ }
143
+ const config = relative(REPOSITORY_ROOT, hostWorkerConfigPath(site.id, "gallery-public"));
144
+ console.log(`\n${dryRun ? "Validating" : "Deploying"} the ${site.id} public Gallery Worker...`);
145
+ await runWrangler(config, null, args);
146
+ }
147
+ // Calendar is on for every site, so every site on its own stack has a feed Worker.
148
+ await generateHostConfigs(site.id);
149
+ const calendarConfig = relative(REPOSITORY_ROOT, hostWorkerConfigPath(site.id, "calendar-public"));
150
+ console.log(`\n${dryRun ? "Validating" : "Deploying"} the ${site.id} calendar feed Worker...`);
151
+ await runWrangler(calendarConfig, null, args);
152
+ }
153
+
154
+ export async function main(argv = process.argv.slice(2)): Promise<void> {
155
+ const options = parseOptions(argv);
156
+ if (options.help) {
157
+ console.log(usage());
158
+ return;
159
+ }
160
+ process.chdir(REPOSITORY_ROOT);
161
+ await deploySite(options);
162
+ }
163
+
164
+ const invokedPath = process.argv[1] ? resolve(process.argv[1]) : "";
165
+ if (invokedPath === fileURLToPath(import.meta.url)) {
166
+ main().catch((error: unknown) => {
167
+ console.error(`\n${error instanceof Error ? error.message : String(error)}`);
168
+ process.exitCode = 1;
169
+ });
170
+ }
@@ -0,0 +1,141 @@
1
+ import { defaultConcurrency, mapWithConcurrency } from './async-utils.js';
2
+ import {
3
+ ACCESS_TOKEN_REQUIREMENT,
4
+ CloudflareAccessClient,
5
+ planAccessApps,
6
+ type ExistingAccessApp,
7
+ type ExistingAccessPolicy,
8
+ } from './cloudflare-access.js';
9
+ import { loadTenantConfig, type ResolvedSite } from '../tenants/tenant-config.js';
10
+
11
+ export interface DeploymentAccessSite {
12
+ id: string;
13
+ title: string;
14
+ hostname: string;
15
+ authorizedEmails: readonly string[];
16
+ }
17
+
18
+ export interface DeploymentAccessIssue {
19
+ siteId: string;
20
+ hostname: string;
21
+ kind: 'missing-app' | 'missing-policy';
22
+ detail: string;
23
+ }
24
+
25
+ function deploymentSites(sites: readonly ResolvedSite[]): DeploymentAccessSite[] {
26
+ return sites.map((site) => ({
27
+ id: site.id,
28
+ title: site.title,
29
+ hostname: site.hostname,
30
+ authorizedEmails: site.authorizedEmails,
31
+ }));
32
+ }
33
+
34
+ /**
35
+ * Turn the existing Access planner's actions into deployment-blocking issues.
36
+ * A deployment is safe only when every target has an exact host-wide Access
37
+ * application and every configured email is covered by an Allow policy.
38
+ */
39
+ export function planDeploymentAccess(
40
+ sites: readonly DeploymentAccessSite[],
41
+ existingApps: readonly ExistingAccessApp[],
42
+ policiesByAppId: ReadonlyMap<string, readonly ExistingAccessPolicy[]>,
43
+ ): DeploymentAccessIssue[] {
44
+ const actions = planAccessApps(sites, existingApps, policiesByAppId);
45
+ const issues: DeploymentAccessIssue[] = [];
46
+
47
+ for (const site of sites) {
48
+ const siteActions = actions.filter((action) => action.siteId === site.id);
49
+ const missingApp = siteActions.find((action) => action.kind === 'create-app');
50
+ if (missingApp?.kind === 'create-app') {
51
+ issues.push({
52
+ siteId: site.id,
53
+ hostname: site.hostname,
54
+ kind: 'missing-app',
55
+ detail: `no exact host-wide Access application protects ${site.hostname}`,
56
+ });
57
+ continue;
58
+ }
59
+
60
+ const policyDrift = siteActions.find(
61
+ (action) =>
62
+ action.kind === 'reconcile-policy' && action.missingEmails.length > 0,
63
+ );
64
+ if (policyDrift?.kind === 'reconcile-policy') {
65
+ issues.push({
66
+ siteId: site.id,
67
+ hostname: site.hostname,
68
+ kind: 'missing-policy',
69
+ detail: `Access Allow policy is missing ${policyDrift.missingEmails.join(', ')}`,
70
+ });
71
+ continue;
72
+ }
73
+
74
+ if (!siteActions.some((action) => action.kind === 'adopt-app')) {
75
+ issues.push({
76
+ siteId: site.id,
77
+ hostname: site.hostname,
78
+ kind: 'missing-app',
79
+ detail: `could not resolve an Access application for ${site.hostname}`,
80
+ });
81
+ }
82
+ }
83
+
84
+ return issues;
85
+ }
86
+
87
+ /**
88
+ * Verify the Access perimeter before a real Worker deployment. This is
89
+ * intentionally read-only: creation and policy reconciliation stay in the
90
+ * explicit tenant provisioning phase used by release:all.
91
+ */
92
+ export async function assertDeploymentAccess(
93
+ options: {
94
+ concurrency?: number;
95
+ token?: string;
96
+ accountId?: string | null;
97
+ } = {},
98
+ ): Promise<void> {
99
+ const token = options.token?.trim() ?? process.env.CLOUDFLARE_API_TOKEN?.trim() ?? '';
100
+ if (!token) {
101
+ throw new Error(
102
+ `Refusing the deployment: Cloudflare Access protection ` +
103
+ `cannot be verified because CLOUDFLARE_API_TOKEN is missing. ` +
104
+ `The token needs the ${ACCESS_TOKEN_REQUIREMENT}.`,
105
+ );
106
+ }
107
+
108
+ const { sites } = await loadTenantConfig();
109
+ const targets = deploymentSites(sites);
110
+ if (targets.length === 0) {
111
+ throw new Error(`Refusing the deployment: no frontend sites are configured`);
112
+ }
113
+
114
+ const client = new CloudflareAccessClient(
115
+ token,
116
+ options.accountId === undefined
117
+ ? process.env.CLOUDFLARE_ACCOUNT_ID?.trim() || null
118
+ : options.accountId,
119
+ );
120
+ const existingApps = await client.listApps();
121
+ const initialActions = planAccessApps(targets, existingApps, new Map());
122
+ const matchedApps = initialActions
123
+ .filter((action) => action.kind === 'adopt-app')
124
+ .map((action) => action.appId);
125
+ const policiesByAppId = new Map<string, ExistingAccessPolicy[]>();
126
+ const policyResults = await mapWithConcurrency(
127
+ [...new Set(matchedApps)],
128
+ options.concurrency ?? defaultConcurrency(),
129
+ async (appId) => [appId, await client.listPolicies(appId)] as const,
130
+ );
131
+ for (const [appId, policies] of policyResults) policiesByAppId.set(appId, policies);
132
+
133
+ const issues = planDeploymentAccess(targets, existingApps, policiesByAppId);
134
+ if (issues.length > 0) {
135
+ throw new Error([
136
+ `Refusing the deployment: Cloudflare Access is incomplete.`,
137
+ ...issues.map((issue) => `- ${issue.siteId} (${issue.hostname}): ${issue.detail}`),
138
+ 'Run `pnpm ensure:tenant -- --set-secret` to reconcile it before deploying.',
139
+ ].join('\n'));
140
+ }
141
+ }