void 0.20.1 → 0.20.3

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 (109) hide show
  1. package/README.md +5 -1
  2. package/dist/{auth-W9WII-mN.mjs → auth-DPl6kck4.mjs} +46 -26
  3. package/dist/{auth-cmd-CAH62yDU.mjs → auth-cmd-CzwquNiP.mjs} +5 -4
  4. package/dist/auth-link-ElDTgF7j.mjs +28 -0
  5. package/dist/{build-cmd-CJvZvPQO.mjs → build-cmd-BpJe6boP.mjs} +3 -3
  6. package/dist/{cache-BlNeQjuP.mjs → cache-D98YTqeE.mjs} +3 -3
  7. package/dist/{cancel-deploy-CmlAZ9P6.mjs → cancel-deploy-CPbEQLMG.mjs} +3 -3
  8. package/dist/cf-access-DRsQRe6k.mjs +75 -0
  9. package/dist/cli/cli.mjs +309 -1958
  10. package/dist/cli/env-schema-probe.mjs +11 -2
  11. package/dist/client-BQBrZoCX.mjs +989 -0
  12. package/dist/{cloudflare-auth-B1QtTO1b.mjs → cloudflare-auth-6M5llVPC.mjs} +2 -2
  13. package/dist/{cloudflare-cmd-B6_OZx2V.mjs → cloudflare-cmd-4RPGN3KB.mjs} +2 -2
  14. package/dist/{cloudflare-connect-j5D4hhrG.mjs → cloudflare-connect-t1UU5svD.mjs} +2 -2
  15. package/dist/{cloudflare-operations-CPTpRW6d.mjs → cloudflare-operations-BzWnlC1_.mjs} +1 -1
  16. package/dist/{config-BQFq7QvD.mjs → config-uNGuFsI2.mjs} +1 -1
  17. package/dist/{connect-C04Wdy_h.mjs → connect-WCQZ_u3m.mjs} +6 -6
  18. package/dist/{create-project-ChGZ1DFd.mjs → create-project-D0oXA090.mjs} +7 -7
  19. package/dist/{db-D2d_mUsB.mjs → db-DJ-9qs3S.mjs} +47 -30
  20. package/dist/{delete-D8GigDk8.mjs → delete-BZ4-WaGm.mjs} +3 -3
  21. package/dist/{deploy-iXZ3F0N6.mjs → deploy-BAhhcg5q.mjs} +135 -117
  22. package/dist/{dev-inbox-DkgRWLkW.mjs → dev-inbox-P0u4tM8Y.mjs} +1 -1
  23. package/dist/{domain-B1VmoSr0.mjs → domain-BxAyhxXN.mjs} +4 -4
  24. package/dist/email-Bj7Cvdwp.mjs +795 -0
  25. package/dist/{env-D4Emu-M_.mjs → env-DBKmK4vc.mjs} +1 -0
  26. package/dist/{env-BcQzYgoG.mjs → env-DP_EErve.mjs} +5 -5
  27. package/dist/{env-validation-ENpMy6Ez.mjs → env-validation-CF6KvTRf.mjs} +3 -1
  28. package/dist/{gen-DI2YwdBM.mjs → gen-B_wPnVTK.mjs} +2 -2
  29. package/dist/{github-cmd-xItS5Zwf.mjs → github-cmd-C-z_xRrQ.mjs} +15 -21
  30. package/dist/{headers-D8QfRX9Y.mjs → headers-BAHwgHdW.mjs} +1 -1
  31. package/dist/help-CwOX-zmI.mjs +2216 -0
  32. package/dist/{inbound-afAcWeQ9.d.mts → inbound-CH5Mksyy.d.mts} +34 -48
  33. package/dist/{inbound-2d0zi2yS.mjs → inbound-aVHEUhKo.mjs} +130 -100
  34. package/dist/index.mjs +54 -17
  35. package/dist/{init-BD-9THgn.mjs → init-BGktCXgA.mjs} +11 -11
  36. package/dist/{link-RMdgjF1v.mjs → link-D2kbqhWb.mjs} +4 -4
  37. package/dist/{list-3F52R_yO.mjs → list-CvkK_G7k.mjs} +4 -4
  38. package/dist/{login-pV69H-ZO.mjs → login-WIjNc77c.mjs} +28 -10
  39. package/dist/{logs-DFHHD6wE.mjs → logs-dLUFCapG.mjs} +4 -4
  40. package/dist/{mime-BJD7d_qL.mjs → mime-D5Nmdzf7.mjs} +23 -9
  41. package/dist/{node-Dk3H2jmU.mjs → node-Ez5KW5rn.mjs} +2 -2
  42. package/dist/operator-auth-B3e08unv.mjs +52 -0
  43. package/dist/operator-client-LUZnlnYk.mjs +82 -0
  44. package/dist/{operator-cmd-DYWRbWUA.mjs → operator-cmd-CKJ7xIRs.mjs} +35 -55
  45. package/dist/{output-tFQLLj26.mjs → output-B0cfNSx5.mjs} +316 -2
  46. package/dist/pages/index.mjs +2 -2
  47. package/dist/platform-auth-config-CdVWRRJr.mjs +368 -0
  48. package/dist/platform-auth-protection-Drl0qhrn.mjs +219 -0
  49. package/dist/platform-auth-recovery-CmKEWpDo.mjs +310 -0
  50. package/dist/{platform-cmd-DxJ2FRwR.mjs → platform-cmd-5q_k56xS.mjs} +16 -6
  51. package/dist/{platform-domain-ChvbJkdy.mjs → platform-domain-4GiDlcqx.mjs} +4 -4
  52. package/dist/{platform-lifecycle-DN4MzJF_.mjs → platform-lifecycle-R9xAxvtG.mjs} +1716 -204
  53. package/dist/{platform-management-Db2PXw0B.mjs → platform-management-BfWsXHEW.mjs} +35 -7
  54. package/dist/{platform-recovery-C_YO-tIs.mjs → platform-recovery-CbK-I1FB.mjs} +6 -5
  55. package/dist/{prepare-CBetXvsN.mjs → prepare-CtDJjoOj.mjs} +2 -2
  56. package/dist/{prepare-BfJvFUtJ.mjs → prepare-blNRQvQl.mjs} +2 -2
  57. package/dist/prerender-render.d.mts +11 -0
  58. package/dist/prerender-render.mjs +111 -0
  59. package/dist/{project-cmd-Mo0V9yKS.mjs → project-cmd-CTdmnzvc.mjs} +32 -14
  60. package/dist/project-team-CGxsQe3_.mjs +132 -0
  61. package/dist/project-token-Cirx7uwZ.mjs +75 -0
  62. package/dist/{provision-Blnstcm2.mjs → provision-CSJOjjQk.mjs} +2 -0
  63. package/dist/{requests-BcKOVpRg.mjs → requests-4Nq59hOr.mjs} +3 -3
  64. package/dist/{rollback-Bx85-0xh.mjs → rollback-Dr7u0Ljx.mjs} +4 -4
  65. package/dist/runtime/ai.mjs +3 -2
  66. package/dist/runtime/email/testing.d.mts +1 -1
  67. package/dist/runtime/email/testing.mjs +3 -3
  68. package/dist/runtime/email-protocol.d.mts +15 -0
  69. package/dist/runtime/email-protocol.mjs +70 -0
  70. package/dist/runtime/email.d.mts +2 -2
  71. package/dist/runtime/email.mjs +189 -96
  72. package/dist/runtime/remote/index.mjs +54 -11
  73. package/dist/runtime/sandbox.d.mts +4 -56
  74. package/dist/runtime/sandbox.mjs +81 -220
  75. package/dist/{secret-ByhJ9AMl.mjs → secret-AcPi-FoA.mjs} +5 -5
  76. package/dist/{skills-Q46GZMO-.mjs → skills-C0RvGjeE.mjs} +1 -1
  77. package/dist/{subcommand-prompt-WfySCQ7S.mjs → subcommand-prompt-Bmyn5Rlc.mjs} +1 -1
  78. package/package.json +12 -7
  79. package/skills/void/SKILL.md +35 -4
  80. package/skills/void/docs/guide/deployment.md +16 -14
  81. package/skills/void/docs/guide/email.md +114 -120
  82. package/skills/void/docs/guide/platform/administration/access.md +163 -0
  83. package/skills/void/docs/guide/platform/administration/email.md +121 -0
  84. package/skills/void/docs/guide/platform/administration/operations.md +97 -0
  85. package/skills/void/docs/guide/platform/administration/projects.md +54 -0
  86. package/skills/void/docs/guide/platform/development/local.md +119 -0
  87. package/skills/void/docs/guide/platform/development/runtime.md +124 -0
  88. package/skills/void/docs/guide/platform/development/schema-ci.md +95 -0
  89. package/skills/void/docs/guide/platform/installation/ci.md +55 -0
  90. package/skills/void/docs/guide/platform/installation/credentials.md +80 -0
  91. package/skills/void/docs/guide/platform/installation/domains.md +68 -0
  92. package/skills/void/docs/guide/platform/installation/first-deployment.md +82 -0
  93. package/skills/void/docs/guide/platform/installation/maintenance.md +137 -0
  94. package/skills/void/docs/guide/platform/installation/prerequisites.md +86 -0
  95. package/skills/void/docs/guide/platform/installation/setup.md +169 -0
  96. package/skills/void/docs/guide/platform/installation/uninstall.md +54 -0
  97. package/skills/void/docs/guide/platform-administration.md +6 -202
  98. package/skills/void/docs/guide/platform-development.md +5 -254
  99. package/skills/void/docs/guide/project-collaboration.md +94 -0
  100. package/skills/void/docs/guide/sandboxes.md +9 -24
  101. package/skills/void/docs/guide/self-hosted-platform.md +13 -542
  102. package/skills/void/docs/reference/api.md +34 -34
  103. package/skills/void/docs/reference/cli.md +276 -31
  104. package/skills/void/docs/reference/config.md +1 -1
  105. package/skills/void/docs/reference/resource-inference.md +10 -10
  106. package/dist/cf-access-AJ1ehiFR.mjs +0 -42
  107. package/dist/cf-access-DsSsZUPr.mjs +0 -67
  108. package/dist/client-Clirrol3.mjs +0 -705
  109. package/dist/email-uKyQYUVY.mjs +0 -1016
@@ -1,111 +1,22 @@
1
1
  import { r as __toESM } from "./rolldown-runtime-rQ84J-ij.mjs";
2
2
  import { o as join } from "./dist-BrsS7cai.mjs";
3
- import { $ as resolveInstallationManifest, A as password, B as assertPlatformInstallationSlugAvailable, D as log, E as intro, F as isCancel, H as latestPlatformWorkerCutoverGeneration, I as settings, J as promoteInstallationSecrets, L as u$1, M as symbol, N as text, O as note, P as CANCEL_SYMBOL, U as listInstallationManifests, X as readPendingInstallationSecrets, c as import_picocolors$1, et as savePlatformConnection, h as S_BAR_END, i as createSpinner, j as select, k as outro, m as S_BAR, n as cliCommandName, nt as writeInstallationManifest, q as platformWorkerSecretUpdateIntentState, r as cliTitle, rt as writePendingInstallationSecrets, st as require_picocolors, v as S_PASSWORD_MASK, w as confirm, z as acquirePlatformLifecycleLock } from "./output-tFQLLj26.mjs";
4
- import { t as cfAccessHeaders } from "./cf-access-AJ1ehiFR.mjs";
5
- import { d as openBrowser } from "./auth-W9WII-mN.mjs";
3
+ import { $ as promoteInstallationSecrets, A as outro, D as log, E as intro, F as CANCEL_SYMBOL, G as assertPlatformInstallationSlugAvailable, I as isCancel, J as listInstallationManifests, L as settings, M as select, N as symbol, O as multiselect, P as text$1, Q as platformWorkerSecretUpdateIntentState, R as u$1, V as ensurePlatformAccessToken, W as acquirePlatformLifecycleLock, X as normalizePlatformApiUrl, at as savePlatformConnection, c as import_picocolors$1, ct as writePendingInstallationSecrets, et as readInstallationRecoverySecrets, ft as require_picocolors, h as S_BAR_END, i as createSpinner, it as resolveInstallationManifest, j as password, k as note, m as S_BAR, n as cliCommandName, q as latestPlatformWorkerCutoverGeneration, r as cliTitle, st as writeInstallationManifest, tt as readPendingInstallationSecrets, v as S_PASSWORD_MASK, w as confirm } from "./output-B0cfNSx5.mjs";
4
+ import { t as cfAccessHeaders } from "./cf-access-DRsQRe6k.mjs";
5
+ import { f as openBrowser } from "./auth-DPl6kck4.mjs";
6
6
  import { o as resolveWranglerArgv } from "./preset-lAy0B0BQ.mjs";
7
- import { l as runCloudflareAuthCommand, n as announceCloudflareLogin } from "./cloudflare-auth-B1QtTO1b.mjs";
8
- import { n as createDefaultRunWrangler, o as resolvePinnedApiBaseUrl } from "./provision-Blnstcm2.mjs";
7
+ import { l as runCloudflareAuthCommand, n as announceCloudflareLogin } from "./cloudflare-auth-6M5llVPC.mjs";
8
+ import { n as createDefaultRunWrangler, o as resolvePinnedApiBaseUrl } from "./provision-CSJOjjQk.mjs";
9
9
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
- import { createHash, randomBytes, randomUUID } from "node:crypto";
10
+ import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
11
11
  import { tmpdir } from "node:os";
12
12
  import { execFile } from "node:child_process";
13
- import { PLATFORM_DISABLED_KEY, PLATFORM_DISABLED_VALUE, PLATFORM_R2_OWNERSHIP_MARKER_KEY, PLATFORM_REMOTE_INSTALLATION_MANIFEST_KEY, PLATFORM_SANDBOX_DRAIN_KEY, createApplicationRoutes, createPlatformDeploymentManifest, createPlatformInstallationManifest, createPlatformResourceNames, createPlatformResourceOwnershipMarker, createPlatformWorkerConfigs, normalizeApplicationDomain, normalizeInstallationSlug, parsePlatformInstallationManifest, parsePlatformResourceOwnershipMarker, recordInstallationResource, recordInstallationStep, setInstallationDesiredState, setInstallationEndpoints, transitionInstallation } from "@void/platform";
13
+ import { PLATFORM_AUTH_SETUP_PROOF_PREFIX, PLATFORM_DISABLED_KEY, PLATFORM_DISABLED_VALUE, PLATFORM_LOGIN_KINDS, PLATFORM_R2_OWNERSHIP_MARKER_KEY, PLATFORM_REMOTE_INSTALLATION_MANIFEST_KEY, PLATFORM_SANDBOX_DRAIN_KEY, createApplicationRoutes, createPlatformDeploymentManifest, createPlatformInstallationManifest, createPlatformResourceNames, createPlatformResourceOwnershipMarker, createPlatformWorkerConfigs, normalizeApplicationDomain, normalizeInstallationSlug, parsePlatformAccessProtection, parsePlatformAdmissionPolicy, parsePlatformAuthenticationSetup, parsePlatformInstallationManifest, parsePlatformLoginConfiguration, parsePlatformResourceOwnershipMarker, recordInstallationResource, recordInstallationStep, setInstallationDesiredState, setInstallationEndpoints, transitionInstallation } from "@void/platform";
14
+ import { stripVTControlCharacters } from "node:util";
14
15
  import { encodeCloudflareVersionMessage, parseCloudflareVersionMessage, readCloudflareTriggerConfig, readCloudflareVersionMigrationTag, stabilizeCloudflareVersionTriggers } from "@void/deploy-cloudflare";
16
+ import { readFile } from "node:fs/promises";
15
17
  import { createServer, request } from "node:http";
16
18
  import { request as request$1 } from "node:https";
17
19
  import { platformDatabaseSchemaDigest, resolvePlatformRuntime } from "@void/platform/installer";
18
- //#region src/cli/platform-auth.ts
19
- function assertPlatformOperatorCredentialCanApplyPlan(credential, plan) {
20
- if (credential.source !== "oauth") return;
21
- const createsZone = plan.actions.some((action) => action.kind === "zone" && action.action === "create");
22
- const writesDns = plan.actions.some((action) => action.kind === "dns-record" && action.action !== "reuse");
23
- if (!createsZone && !writesDns) return;
24
- const permissions = [...createsZone ? ["Account → Zone → Edit"] : [], ...writesDns ? ["Zone → DNS → Edit"] : []];
25
- throw new Error(`platform: Cloudflare browser OAuth grants Zone Read and cannot apply the planned zone or DNS changes. Set CLOUDFLARE_API_TOKEN with ${permissions.join(" and ")}, scoped to the selected account and application zone, then rerun the command.`);
26
- }
27
- async function resolvePlatformCloudflareCredential(dependencies = {}) {
28
- const { credential } = await resolvePlatformCloudflareCredentialWithInput(async () => void 0, dependencies);
29
- return credential;
30
- }
31
- /** Check saved credentials during input, but never start browser login until it succeeds. */
32
- async function resolvePlatformCloudflareCredentialWithInput(readInput, dependencies = {}) {
33
- const controller = new AbortController();
34
- const runner = dependencies.runWrangler ?? ((args) => runCloudflareAuthCommand(process.cwd(), args, controller.signal));
35
- const runWrangler = (args, capture) => {
36
- controller.signal.throwIfAborted();
37
- return runner(args, capture);
38
- };
39
- const pending = readExistingCredential(dependencies, runWrangler).then((credential) => ({
40
- ok: true,
41
- credential
42
- }), (error) => ({
43
- ok: false,
44
- error
45
- }));
46
- const cancel = () => controller.abort();
47
- const assertNotCancelled = () => {
48
- if (controller.signal.aborted) throw new Error("platform: Cancelled.");
49
- };
50
- try {
51
- const input = await readInput();
52
- process.once("SIGINT", cancel);
53
- const result = await pending;
54
- assertNotCancelled();
55
- if (!result.ok) throw result.error;
56
- if (result.credential) return {
57
- input,
58
- credential: result.credential
59
- };
60
- announceCloudflareLogin();
61
- const login = await runWrangler(["login", "--use-keyring"], true);
62
- assertNotCancelled();
63
- if (login.status !== 0) throw new Error("platform: Cloudflare browser login did not complete.");
64
- const credential = await readWranglerOAuthCredential(runWrangler);
65
- assertNotCancelled();
66
- if (!credential) throw new Error("platform: Cloudflare sign-in completed without a usable OAuth credential.");
67
- return {
68
- input,
69
- credential
70
- };
71
- } finally {
72
- process.removeListener("SIGINT", cancel);
73
- controller.abort();
74
- }
75
- }
76
- async function readExistingCredential(dependencies, runWrangler) {
77
- const environmentToken = process.env.CLOUDFLARE_API_TOKEN?.trim() || process.env.CF_API_TOKEN?.trim() || "";
78
- if (environmentToken) return {
79
- source: "environment",
80
- token: environmentToken
81
- };
82
- if (!(dependencies.interactive ?? process.stdin.isTTY === true)) throw new Error("platform: Cloudflare authentication is required. Run `void cloudflare login` interactively, or set CLOUDFLARE_API_TOKEN in CI.");
83
- if ((await runWrangler([
84
- "auth",
85
- "keyring",
86
- "enable"
87
- ], true)).status !== 0) throw new Error("platform: Could not enable secure Cloudflare credential storage. Refusing to use a plaintext OAuth credential.");
88
- return await readWranglerOAuthCredential(runWrangler);
89
- }
90
- async function readWranglerOAuthCredential(runWrangler = (args) => runCloudflareAuthCommand(process.cwd(), args)) {
91
- const result = await runWrangler([
92
- "auth",
93
- "token",
94
- "--json"
95
- ], true);
96
- if (result.status !== 0) return null;
97
- try {
98
- const parsed = JSON.parse(result.stdout);
99
- if (parsed.type !== "oauth" || typeof parsed.token !== "string" || !parsed.token.trim()) return null;
100
- return {
101
- source: "oauth",
102
- token: parsed.token.trim()
103
- };
104
- } catch {
105
- return null;
106
- }
107
- }
108
- //#endregion
109
20
  //#region src/cli/platform-cloudflare.ts
110
21
  var PlatformCloudflareApiError = class extends Error {
111
22
  status;
@@ -191,7 +102,8 @@ var PlatformCloudflareClient = class PlatformCloudflareClient {
191
102
  ...init?.body ? { "Content-Type": "application/json" } : {},
192
103
  ...init?.headers
193
104
  },
194
- signal: init?.signal ?? AbortSignal.timeout(3e4)
105
+ signal: init?.signal ?? AbortSignal.timeout(3e4),
106
+ redirect: "manual"
195
107
  });
196
108
  let envelope;
197
109
  try {
@@ -546,6 +458,7 @@ var PlatformCloudflareClient = class PlatformCloudflareClient {
546
458
  method: "POST",
547
459
  body: JSON.stringify({
548
460
  name,
461
+ caching: { disabled: true },
549
462
  origin: {
550
463
  database: parsed.database,
551
464
  host: parsed.host,
@@ -565,6 +478,12 @@ var PlatformCloudflareClient = class PlatformCloudflareClient {
565
478
  async listWorkerNames(accountId) {
566
479
  return (await this.request(`/accounts/${encodeURIComponent(accountId)}/workers/scripts`)).map((entry) => requiredString(entry.id, "Worker name"));
567
480
  }
481
+ async disableHyperdriveCaching(accountId, id) {
482
+ await this.request(`/accounts/${encodeURIComponent(accountId)}/hyperdrive/configs/${encodeURIComponent(id)}`, {
483
+ method: "PATCH",
484
+ body: JSON.stringify({ caching: { disabled: true } })
485
+ });
486
+ }
568
487
  async putKvValue(accountId, namespaceId, key, value) {
569
488
  await this.request(`/accounts/${encodeURIComponent(accountId)}/storage/kv/namespaces/${encodeURIComponent(namespaceId)}/values/${encodeURIComponent(key)}`, {
570
489
  method: "PUT",
@@ -773,6 +692,7 @@ function parseHyperdriveConfig(value) {
773
692
  return {
774
693
  id: requiredString(value.id, "Hyperdrive id"),
775
694
  name: requiredString(value.name, "Hyperdrive name"),
695
+ cachingDisabled: !!value.caching && typeof value.caching === "object" && !Array.isArray(value.caching) && value.caching.disabled === true,
776
696
  origin: {
777
697
  database: requiredString(record.database, "Hyperdrive origin database"),
778
698
  host: requiredString(record.host, "Hyperdrive origin host").toLowerCase(),
@@ -783,10 +703,880 @@ function parseHyperdriveConfig(value) {
783
703
  };
784
704
  }
785
705
  //#endregion
706
+ //#region src/cli/platform-access-provision.ts
707
+ function object(value, name) {
708
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`platform: Invalid ${name} response from Cloudflare Access.`);
709
+ return value;
710
+ }
711
+ function text(value, name) {
712
+ if (typeof value !== "string" || !value || value.length > 8192) throw new Error(`platform: Cloudflare Access did not return ${name}.`);
713
+ return value;
714
+ }
715
+ function ids(value) {
716
+ if (!Array.isArray(value)) return [];
717
+ return value.map((entry) => typeof entry === "string" ? entry : text(object(entry, "policy").id, "policy ID"));
718
+ }
719
+ function sameSet(left, right) {
720
+ return left.length === right.length && [...left].sort().every((id, index) => id === [...right].sort()[index]);
721
+ }
722
+ function fingerprint(value) {
723
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
724
+ }
725
+ var PlatformAccessClient = class {
726
+ client;
727
+ accountId;
728
+ constructor(client, accountId) {
729
+ this.client = client;
730
+ this.accountId = accountId;
731
+ }
732
+ path(suffix) {
733
+ return `/accounts/${encodeURIComponent(this.accountId)}/access/${suffix}`;
734
+ }
735
+ get(suffix) {
736
+ return this.client.request(this.path(suffix));
737
+ }
738
+ create(suffix, body) {
739
+ return this.client.request(this.path(suffix), {
740
+ method: "POST",
741
+ body: JSON.stringify(body)
742
+ });
743
+ }
744
+ async list(suffix) {
745
+ const result = [];
746
+ for (let page = 1; page <= 100; page++) {
747
+ const value = await this.get(`${suffix}?page=${page}&per_page=100`);
748
+ if (!Array.isArray(value)) throw new Error("platform: Invalid Access inventory response.");
749
+ result.push(...value.map((entry) => object(entry, "inventory")));
750
+ if (value.length < 100) return result;
751
+ }
752
+ throw new Error("platform: Access inventory exceeded the supported pagination limit.");
753
+ }
754
+ };
755
+ function identityRule(rule) {
756
+ if (rule.email_domain) {
757
+ const domain = object(rule.email_domain, "email domain").domain;
758
+ return typeof domain === "string" && /^(?:[a-z0-9-]+\.)+[a-z]{2,63}$/i.test(domain);
759
+ }
760
+ if (rule.email) {
761
+ const email = object(rule.email, "email rule").email;
762
+ return typeof email === "string" && /^[^\s*@]+@[^\s*@]+\.[^\s*@]+$/.test(email);
763
+ }
764
+ return [
765
+ "email",
766
+ "email_domain",
767
+ "email_list",
768
+ "gsuite",
769
+ "github-organization",
770
+ "saml",
771
+ "okta",
772
+ "group"
773
+ ].some((key) => Object.hasOwn(rule, key) && !!rule[key] && typeof rule[key] === "object" && !Array.isArray(rule[key]) && Object.keys(rule[key]).length > 0);
774
+ }
775
+ /** Preserve corporate MFA/device requirements; reject broad policies rather than rewriting them. */
776
+ async function verifyCompanyAccessPolicies(client, policies) {
777
+ const groups = /* @__PURE__ */ new Set();
778
+ const checkedGroups = /* @__PURE__ */ new Set();
779
+ const checkRules = async (value, depth = 0) => {
780
+ if (value === void 0) return;
781
+ if (!Array.isArray(value) || value.length > 100 || depth > 8) throw new Error("platform: Unsupported Access policy rule structure.");
782
+ for (const entry of value) {
783
+ const rule = object(entry, "policy rule");
784
+ if (Object.hasOwn(rule, "everyone") || Object.hasOwn(rule, "any_valid_service_token")) throw new Error("platform: Company Access policies must not allow Everyone or any valid service token.");
785
+ if (rule.group) {
786
+ const id = text(object(rule.group, "Access group").id, "Access group ID");
787
+ if (groups.has(id)) throw new Error("platform: Access groups contain a recursive membership rule.");
788
+ if (checkedGroups.has(id)) continue;
789
+ if (checkedGroups.size >= 100) throw new Error("platform: Access group policy exceeds the inspection limit.");
790
+ groups.add(id);
791
+ const group = object(await client.get(`groups/${encodeURIComponent(id)}`), "Access group");
792
+ await checkRules(group.include, depth + 1);
793
+ await checkRules(group.require, depth + 1);
794
+ if (!Array.isArray(group.include) || !group.include.length) throw new Error("platform: Access groups must contain explicit identity rules.");
795
+ if (!(Array.isArray(group.require) && group.require.some((entry) => identityRule(object(entry, "group rule")))) && !group.include.every((entry) => identityRule(object(entry, "group rule")))) throw new Error("platform: Access groups must restrict company identities.");
796
+ groups.delete(id);
797
+ checkedGroups.add(id);
798
+ }
799
+ }
800
+ };
801
+ let allows = 0;
802
+ for (const policy of policies) {
803
+ if (![
804
+ "allow",
805
+ "deny",
806
+ "non_identity",
807
+ "bypass"
808
+ ].includes(String(policy.decision))) throw new Error("platform: Unsupported Access policy decision.");
809
+ if (policy.decision === "bypass") throw new Error("platform: A bypass policy cannot protect a Void platform.");
810
+ if (policy.decision !== "allow") continue;
811
+ allows++;
812
+ await checkRules(policy.include);
813
+ await checkRules(policy.require);
814
+ if (!Array.isArray(policy.include) || !policy.include.length || !(Array.isArray(policy.require) && policy.require.some((entry) => identityRule(object(entry, "policy rule")))) && !policy.include.every((entry) => identityRule(object(entry, "policy rule")))) throw new Error("platform: Select Access allow policies restricted to company identities.");
815
+ }
816
+ if (!allows) throw new Error("platform: Select at least one company Access allow policy.");
817
+ }
818
+ function platformAccessPublicDestinations(application) {
819
+ const destinations = Array.isArray(application.destinations) ? application.destinations : [];
820
+ return [...typeof application.domain === "string" ? [application.domain] : [], ...destinations.flatMap((value) => {
821
+ const entry = object(value, "Access destination");
822
+ return entry.type === "public" && typeof entry.uri === "string" ? [entry.uri] : [];
823
+ })];
824
+ }
825
+ function platformAccessOverlapsOrigin(destination, origin) {
826
+ const pattern = "^" + destination.split("/")[0].split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$";
827
+ return new RegExp(pattern, "i").test(new URL(origin).hostname);
828
+ }
829
+ async function inspectPlatformAccess(client, input) {
830
+ const authDomain = text(object(await client.get("organizations"), "Access organization").auth_domain, "Access team domain");
831
+ const teamDomain = authDomain.startsWith("https://") ? authDomain : `https://${authDomain}`;
832
+ parsePlatformAccessProtection({
833
+ teamDomain,
834
+ applications: [{
835
+ origin: "https://validation.example.com",
836
+ audience: "validation"
837
+ }]
838
+ });
839
+ const identityProviders = (await client.list("identity_providers")).map((provider) => ({
840
+ id: text(provider.id, "identity provider ID"),
841
+ name: text(provider.name ?? provider.id, "identity provider name")
842
+ }));
843
+ if (!input.identityProviderIds.length || !input.policyIds.length || input.identityProviderIds.length > 16 || input.policyIds.length > 16 || new Set(input.identityProviderIds).size !== input.identityProviderIds.length || new Set(input.policyIds).size !== input.policyIds.length) throw new Error("platform: Select explicit identity providers and company policies for Access.");
844
+ if (input.identityProviderIds.some((id) => !identityProviders.some((provider) => provider.id === id))) throw new Error("platform: A selected Access identity provider is unavailable in this account.");
845
+ const policies = await Promise.all(input.policyIds.map(async (id) => object(await client.get(`policies/${encodeURIComponent(id)}`), "Access policy")));
846
+ await verifyCompanyAccessPolicies(client, policies);
847
+ return {
848
+ teamDomain,
849
+ identityProviders,
850
+ policies
851
+ };
852
+ }
853
+ async function provisionPlatformAccess(client, input, previous, save) {
854
+ if (!input.login && !input.protection) return { resources: [] };
855
+ if (client.accountId !== input.accountId) throw new Error("platform: Access account selection does not match its management client.");
856
+ const inspected = await inspectPlatformAccess(client, input);
857
+ const origins = [...new Set([
858
+ input.apiUrl,
859
+ input.proxyUrl,
860
+ ...input.additionalOrigins ?? []
861
+ ].map((value) => {
862
+ const url = new URL(value);
863
+ if (url.origin !== value || url.protocol !== "https:") throw new Error("platform: Access requires exact HTTPS origins without credentials, paths, or query strings.");
864
+ return url.origin;
865
+ }))];
866
+ for (const origin of origins) if (!origin.startsWith("https://")) throw new Error("platform: Access protection requires HTTPS platform origins.");
867
+ const state = previous ? structuredClone(previous) : {
868
+ version: 1,
869
+ accountId: input.accountId,
870
+ installationId: input.installationId,
871
+ nonce: randomBytes(16).toString("hex"),
872
+ resources: {}
873
+ };
874
+ if (state.version !== 1 || state.accountId !== input.accountId || state.installationId !== input.installationId || !/^[a-f0-9]{32}$/.test(state.nonce)) throw new Error("platform: Access recovery data belongs to a different installation.");
875
+ save(state);
876
+ const policies = input.policyIds.map((id, index) => ({
877
+ id,
878
+ precedence: index + 1
879
+ }));
880
+ const inventory = await client.list("apps");
881
+ const tokenInventory = input.protection ? await client.list("service_tokens") : [];
882
+ if (input.protection?.applicationId && !input.serviceToken) throw new Error("platform: Connecting an existing protection application requires its admitted service token.");
883
+ if (input.serviceToken) {
884
+ const token = tokenInventory.find((token) => token.id === input.serviceToken.id);
885
+ if (!token || token.client_id !== input.serviceToken.clientId || token.enabled === false || typeof token.expires_at === "string" && Date.parse(token.expires_at) <= Date.now()) throw new Error("platform: The selected Access service token is unavailable, expired, or belongs to another client.");
886
+ const prior = state.resources["service-token"];
887
+ state.resources["service-token"] = {
888
+ id: input.serviceToken.id,
889
+ name: text(token.name, "service token name"),
890
+ fingerprint: fingerprint({ origins }),
891
+ pending: false,
892
+ adopted: prior?.id === input.serviceToken.id ? prior.adopted : true
893
+ };
894
+ save(state);
895
+ }
896
+ if (input.protection) for (const app of inventory) {
897
+ if (app.id === input.protection.applicationId || app.id === state.resources.protection?.id || app.name === state.resources.protection?.name) continue;
898
+ if (platformAccessPublicDestinations(app).some((destination) => origins.some((origin) => platformAccessOverlapsOrigin(destination, origin)) && (destination.includes("/") || origins.some((origin) => destination === new URL(origin).hostname)))) throw new Error("platform: An existing Access application overlaps a platform endpoint. Connect or reconcile it explicitly before provisioning protection.");
899
+ }
900
+ const output = { resources: [] };
901
+ const application = async (key, expected, existingId) => {
902
+ if (existingId) {
903
+ const found = object(await client.get(`apps/${encodeURIComponent(existingId)}`), "Access application");
904
+ if (found.id !== existingId) throw new Error("platform: Access returned a different application ID.");
905
+ state.resources[key] = {
906
+ id: existingId,
907
+ name: text(found.name, "application name"),
908
+ fingerprint: fingerprint(expected),
909
+ pending: true,
910
+ adopted: true
911
+ };
912
+ save(state);
913
+ return found;
914
+ }
915
+ const desiredHash = fingerprint(expected);
916
+ let checkpoint = state.resources[key];
917
+ if (checkpoint && checkpoint.fingerprint !== desiredHash) throw new Error("platform: Access setup changed after provisioning started. Connect the existing application explicitly rather than overwriting it.");
918
+ if (checkpoint?.id) {
919
+ const found = object(await client.get(`apps/${encodeURIComponent(checkpoint.id)}`), "Access application");
920
+ if (found.id !== checkpoint.id || found.name !== checkpoint.name) throw new Error("platform: The recorded Access application changed ownership identity.");
921
+ return found;
922
+ }
923
+ if (checkpoint?.pending) {
924
+ const matching = inventory.filter((app) => app.name === checkpoint.name);
925
+ if (matching.length !== 1) throw new Error("platform: An Access application creation has an uncertain outcome. Inspect Cloudflare and connect the existing application; no creation was retried.");
926
+ checkpoint.id = text(matching[0].id, "application ID");
927
+ save(state);
928
+ return object(await client.get(`apps/${encodeURIComponent(checkpoint.id)}`), "Access application");
929
+ }
930
+ const name = `void-${input.installationId}-${key}`.slice(0, 90) + "-" + state.nonce.slice(0, 16);
931
+ if (inventory.some((app) => app.name === name)) throw new Error("platform: An Access application with this installation identity already exists. Connect it explicitly; no duplicate was created.");
932
+ checkpoint = {
933
+ name,
934
+ fingerprint: desiredHash,
935
+ pending: true
936
+ };
937
+ state.resources[key] = checkpoint;
938
+ save(state);
939
+ try {
940
+ const created = object(await client.create("apps", {
941
+ ...expected,
942
+ name
943
+ }), "Access application");
944
+ checkpoint.id = text(created.id, "application ID");
945
+ if (created.saas_app) {
946
+ const oidc = object(created.saas_app, "OIDC application");
947
+ checkpoint.clientId = text(oidc.client_id, "OIDC client ID");
948
+ checkpoint.clientSecret = typeof oidc.client_secret === "string" ? oidc.client_secret : void 0;
949
+ }
950
+ save(state);
951
+ return object(await client.get(`apps/${encodeURIComponent(checkpoint.id)}`), "Access application");
952
+ } catch (error) {
953
+ if (error instanceof PlatformCloudflareApiError && error.status >= 400 && error.status < 500 && ![
954
+ 408,
955
+ 409,
956
+ 429
957
+ ].includes(error.status)) {
958
+ checkpoint.pending = false;
959
+ save(state);
960
+ }
961
+ throw error;
962
+ }
963
+ };
964
+ const completeApplication = (key, app) => {
965
+ const checkpoint = state.resources[key];
966
+ if (!checkpoint?.id || checkpoint.id !== app.id || checkpoint.name !== app.name) throw new Error("platform: The verified Access application changed ownership identity.");
967
+ checkpoint.pending = false;
968
+ save(state);
969
+ };
970
+ if (input.login) {
971
+ const key = `login-${input.login.id}`;
972
+ const app = await application(key, {
973
+ type: "saas",
974
+ allowed_idps: [...input.identityProviderIds],
975
+ auto_redirect_to_identity: input.identityProviderIds.length === 1,
976
+ policies,
977
+ session_duration: "12h",
978
+ saas_app: {
979
+ auth_type: "oidc",
980
+ redirect_uris: [`${input.apiUrl}/auth/callback`],
981
+ grant_types: ["authorization_code_with_pkce"],
982
+ allow_pkce_without_client_secret: false,
983
+ scopes: input.login.requiredGroups?.length ? [
984
+ "openid",
985
+ "email",
986
+ "profile",
987
+ "groups"
988
+ ] : [
989
+ "openid",
990
+ "email",
991
+ "profile"
992
+ ]
993
+ }
994
+ }, input.login.applicationId);
995
+ await verifyPlatformAccessApplication(client, input, app, "saas");
996
+ const oidc = object(app.saas_app, "OIDC configuration");
997
+ if (oidc.auth_type !== "oidc" || !sameSet(ids(oidc.redirect_uris), [`${input.apiUrl}/auth/callback`]) || !sameSet(ids(oidc.grant_types), ["authorization_code_with_pkce"]) || oidc.allow_pkce_without_client_secret === true) throw new Error("platform: Access OIDC redirect or flow settings do not match Void.");
998
+ const clientId = text(oidc.client_id, "OIDC client ID");
999
+ const clientSecret = input.login.clientSecret ?? state.resources[key]?.clientSecret;
1000
+ if (!clientSecret) throw new Error("platform: The Access application exists but its client secret was not recovered. Supply its secret explicitly; no duplicate application was created.");
1001
+ output.login = {
1002
+ configuration: parsePlatformLoginConfiguration({
1003
+ id: input.login.id,
1004
+ kind: "cloudflare-access",
1005
+ label: input.login.label,
1006
+ issuer: `${inspected.teamDomain}/cdn-cgi/access/sso/oidc/${clientId}`,
1007
+ clientId,
1008
+ requiredGroups: input.login.requiredGroups ?? []
1009
+ }),
1010
+ clientSecret
1011
+ };
1012
+ completeApplication(key, app);
1013
+ }
1014
+ if (input.protection) {
1015
+ let service = input.serviceToken;
1016
+ const tokenKey = "service-token";
1017
+ if (!service) {
1018
+ let saved = state.resources[tokenKey];
1019
+ if (!saved) {
1020
+ saved = {
1021
+ name: `void-${input.installationId}-access-probes-${state.nonce.slice(0, 16)}`,
1022
+ fingerprint: fingerprint({ origins }),
1023
+ pending: true
1024
+ };
1025
+ state.resources[tokenKey] = saved;
1026
+ save(state);
1027
+ if (tokenInventory.some((token) => token.name === saved.name)) throw new Error("platform: An Access service token for this installation already exists. Supply its credentials explicitly; no duplicate was created.");
1028
+ const created = object(await client.create("service_tokens", {
1029
+ name: saved.name,
1030
+ duration: "8760h"
1031
+ }), "Access service token");
1032
+ saved.id = text(created.id, "service token ID");
1033
+ saved.clientId = text(created.client_id, "service token client ID");
1034
+ saved.clientSecret = text(created.client_secret, "service token secret");
1035
+ saved.pending = false;
1036
+ save(state);
1037
+ }
1038
+ if (!saved.id || !saved.clientId || !saved.clientSecret) {
1039
+ const found = (await client.list("service_tokens")).filter((token) => token.name === saved.name);
1040
+ if (found.length === 1) {
1041
+ saved.id = text(found[0].id, "service token ID");
1042
+ save(state);
1043
+ }
1044
+ throw new Error("platform: Access service-token creation has an uncertain outcome or its secret was lost. Supply an existing admitted service token; no creation was retried.");
1045
+ }
1046
+ if (saved.fingerprint !== fingerprint({ origins })) throw new Error("platform: The saved Access service token belongs to different platform origins. Connect the intended service token explicitly.");
1047
+ service = {
1048
+ id: saved.id,
1049
+ clientId: saved.clientId,
1050
+ clientSecret: saved.clientSecret
1051
+ };
1052
+ }
1053
+ const app = await application("protection", {
1054
+ type: "self_hosted",
1055
+ domain: new URL(input.apiUrl).hostname,
1056
+ destinations: origins.map((origin) => ({
1057
+ type: "public",
1058
+ uri: new URL(origin).hostname
1059
+ })),
1060
+ allowed_idps: [...input.identityProviderIds],
1061
+ auto_redirect_to_identity: input.identityProviderIds.length === 1,
1062
+ session_duration: "12h",
1063
+ policies: [...policies, {
1064
+ name: "Void installer and CI",
1065
+ decision: "non_identity",
1066
+ precedence: 0,
1067
+ include: [{ service_token: { token_id: service.id } }]
1068
+ }]
1069
+ }, input.protection.applicationId);
1070
+ await verifyPlatformAccessApplication(client, input, app, "self_hosted", service.id);
1071
+ const covered = platformAccessPublicDestinations(app);
1072
+ if (origins.some((origin) => !covered.includes(new URL(origin).hostname))) throw new Error("platform: Access must protect both the API and proxy origins without path exclusions.");
1073
+ const audience = text(app.aud, "application audience");
1074
+ output.protection = parsePlatformAccessProtection({
1075
+ teamDomain: inspected.teamDomain,
1076
+ applications: origins.map((origin) => ({
1077
+ origin,
1078
+ audience
1079
+ }))
1080
+ });
1081
+ output.credentials = Object.fromEntries(origins.map((origin) => [origin, {
1082
+ CF_ACCESS_CLIENT_ID: service.clientId,
1083
+ CF_ACCESS_CLIENT_SECRET: service.clientSecret
1084
+ }]));
1085
+ completeApplication("protection", app);
1086
+ const savedService = state.resources[tokenKey];
1087
+ if (savedService?.id === service.id) {
1088
+ savedService.pending = false;
1089
+ save(state);
1090
+ }
1091
+ }
1092
+ output.resources = Object.entries(state.resources).flatMap(([key, value]) => value.id ? [{
1093
+ key,
1094
+ id: value.id,
1095
+ name: value.name,
1096
+ adopted: value.adopted === true
1097
+ }] : []);
1098
+ return output;
1099
+ }
1100
+ async function verifyPlatformAccessApplication(client, input, app, type, machineId) {
1101
+ if (app.type !== type || !sameSet(ids(app.allowed_idps), input.identityProviderIds)) throw new Error("platform: Access application identity-provider configuration does not match the reviewed plan.");
1102
+ const currentPolicies = await client.list(`apps/${encodeURIComponent(text(app.id, "application ID"))}/policies`);
1103
+ await verifyCompanyAccessPolicies(client, currentPolicies);
1104
+ if (!sameSet(currentPolicies.filter((policy) => policy.decision === "allow" || policy.decision === "deny").map((policy) => text(policy.id, "policy ID")), input.policyIds)) throw new Error("platform: Access application policies do not match the reviewed company policies.");
1105
+ for (const policy of currentPolicies.filter((policy) => policy.decision === "non_identity")) {
1106
+ const includes = Array.isArray(policy.include) ? policy.include : [];
1107
+ if (!machineId || includes.length !== 1 || object(includes[0], "service policy").service_token === void 0 || object(object(includes[0], "service policy").service_token, "service token rule").token_id !== machineId) throw new Error("platform: Access application contains an unexpected machine policy.");
1108
+ }
1109
+ if (machineId && !currentPolicies.some((policy) => policy.decision === "non_identity")) throw new Error("platform: The platform service token is not admitted by Access.");
1110
+ }
1111
+ //#endregion
1112
+ //#region src/cli/platform-access-setup.ts
1113
+ function platformAccessResourceInventory(encoded, installationId) {
1114
+ if (!encoded) return [];
1115
+ const state = JSON.parse(encoded);
1116
+ if (state.version !== 1 || state.installationId !== installationId || typeof state.accountId !== "string" || !state.resources) throw new Error("platform: Access resource recovery data belongs to another installation.");
1117
+ return Object.entries(state.resources).flatMap(([key, resource]) => resource.id ? [{
1118
+ key,
1119
+ kind: key.includes("service-token") ? "service-token" : "application",
1120
+ accountId: state.accountId,
1121
+ id: resource.id,
1122
+ name: resource.name,
1123
+ ownership: resource.adopted ? "adopted" : "created"
1124
+ }] : []);
1125
+ }
1126
+ function checkpointPlatformAccessResources(manifest, encoded, updatedAt) {
1127
+ const resources = platformAccessResourceInventory(encoded, manifest.id);
1128
+ const previous = manifest.authenticationResources ?? [];
1129
+ return resources.length ? {
1130
+ ...manifest,
1131
+ authenticationResources: [...previous, ...resources.filter((resource) => !previous.some((saved) => saved.accountId === resource.accountId && saved.kind === resource.kind && saved.id === resource.id))],
1132
+ updatedAt
1133
+ } : manifest;
1134
+ }
1135
+ function isPlatformAuthenticationPreparationKey(name) {
1136
+ return name.startsWith("authenticationConnection:") || [
1137
+ "accessManagementToken",
1138
+ "accessSetupSpecJson",
1139
+ "accessExistingServiceTokenJson",
1140
+ "accessExistingLoginSecret"
1141
+ ].includes(name);
1142
+ }
1143
+ /** Validate before planning; credential references are resolved only during provisioning. */
1144
+ function parsePlatformAccessSetupSpec(input) {
1145
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("platform: cloudflareAccess must contain an Access setup configuration.");
1146
+ const value = input;
1147
+ const allowed = /* @__PURE__ */ new Set([
1148
+ "mode",
1149
+ "accountId",
1150
+ "identityProviderIds",
1151
+ "policyIds",
1152
+ "loginApplicationId",
1153
+ "protectionApplicationId",
1154
+ "loginClientSecretEnv",
1155
+ "serviceToken"
1156
+ ]);
1157
+ if (Object.keys(value).some((key) => !allowed.has(key))) throw new Error("platform: Unknown Access setup field. Use environment references for credentials.");
1158
+ if (value.mode !== "create" && value.mode !== "existing") throw new Error("platform: Access setup mode must be create or existing.");
1159
+ for (const key of [
1160
+ "accountId",
1161
+ "loginApplicationId",
1162
+ "protectionApplicationId",
1163
+ "loginClientSecretEnv"
1164
+ ]) if (value[key] !== void 0 && (typeof value[key] !== "string" || !value[key] || String(value[key]).length > 256)) throw new Error(`platform: Invalid Access ${key}.`);
1165
+ for (const key of ["identityProviderIds", "policyIds"]) {
1166
+ const ids = value[key];
1167
+ if (ids !== void 0 && (!Array.isArray(ids) || !ids.length || ids.length > 16 || ids.some((id) => typeof id !== "string" || !id || id.length > 256) || new Set(ids).size !== ids.length)) throw new Error(`platform: Invalid Access ${key}.`);
1168
+ }
1169
+ if (value.loginClientSecretEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(value.loginClientSecretEnv))) throw new Error("platform: Invalid Access client secret environment reference.");
1170
+ if (value.serviceToken !== void 0) {
1171
+ const token = value.serviceToken;
1172
+ if (!token || typeof token !== "object" || Array.isArray(token) || Object.keys(token).some((key) => ![
1173
+ "id",
1174
+ "clientIdEnv",
1175
+ "clientSecretEnv"
1176
+ ].includes(key))) throw new Error("platform: Access service tokens require an ID and environment references.");
1177
+ const fields = token;
1178
+ if (typeof fields.id !== "string" || !fields.id || fields.id.length > 256 || ["clientIdEnv", "clientSecretEnv"].some((key) => typeof fields[key] !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(fields[key]))) throw new Error("platform: Invalid Access service token environment references.");
1179
+ }
1180
+ return value;
1181
+ }
1182
+ function answer$1(value) {
1183
+ if (isCancel(value) || typeof value === "symbol") throw new Error("platform: Cancelled.");
1184
+ return value;
1185
+ }
1186
+ function secretReference(name) {
1187
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || !process.env[name]) throw new Error("platform: Supply the referenced Access credential in the environment.");
1188
+ return process.env[name];
1189
+ }
1190
+ /** Only scoped credentials for the reviewed platform origins enter the process environment. */
1191
+ function activatePlatformAccessCredentials(encoded, origins) {
1192
+ if (!encoded) return;
1193
+ const entries = JSON.parse(encoded);
1194
+ if (!entries || typeof entries !== "object" || Array.isArray(entries)) throw new Error("platform: Invalid saved Access credentials.");
1195
+ const allowed = new Set(origins.map((origin) => new URL(origin).origin));
1196
+ for (const [origin, value] of Object.entries(entries)) {
1197
+ if (!allowed.has(origin) || !value || typeof value !== "object" || Array.isArray(value)) throw new Error("platform: Access credentials must be scoped to this platform’s API or proxy.");
1198
+ const headers = cfAccessHeaders({ VOID_ACCESS_CREDENTIALS: JSON.stringify({ [origin]: value }) }, origin);
1199
+ if (!Object.keys(headers).length) throw new Error("platform: Saved Access credentials are incomplete.");
1200
+ }
1201
+ const current = process.env.VOID_ACCESS_CREDENTIALS ? JSON.parse(process.env.VOID_ACCESS_CREDENTIALS) : {};
1202
+ if (!current || typeof current !== "object" || Array.isArray(current)) throw new Error("platform: VOID_ACCESS_CREDENTIALS must contain an origin-keyed object.");
1203
+ process.env.VOID_ACCESS_CREDENTIALS = JSON.stringify({
1204
+ ...current,
1205
+ ...entries
1206
+ });
1207
+ }
1208
+ async function preparePlatformAccessSetup(input) {
1209
+ if (!input.login && !input.protection) return;
1210
+ let spec = input.spec ?? (input.checkpoint.accessSetupSpecJson ? parsePlatformAccessSetupSpec(JSON.parse(input.checkpoint.accessSetupSpecJson)) : void 0);
1211
+ if (!spec) {
1212
+ if (!process.stdin.isTTY) {
1213
+ if (input.protection) throw new Error("platform: Protection requires cloudflareAccess create or existing application verification in --auth-config.");
1214
+ return;
1215
+ }
1216
+ const mode = answer$1(await select({
1217
+ message: "Cloudflare Access setup:",
1218
+ options: [
1219
+ {
1220
+ value: "create",
1221
+ label: "Create applications automatically in this Cloudflare account"
1222
+ },
1223
+ {
1224
+ value: "existing",
1225
+ label: "Connect existing applications (including another account)"
1226
+ },
1227
+ ...!input.protection ? [{
1228
+ value: "manual",
1229
+ label: "Connect an existing OIDC issuer without Cloudflare management access"
1230
+ }] : []
1231
+ ]
1232
+ }));
1233
+ if (mode === "manual") return;
1234
+ spec = { mode };
1235
+ if (mode === "existing") {
1236
+ spec.accountId = answer$1(await text$1({
1237
+ message: "Cloudflare identity account ID:",
1238
+ initialValue: input.plan.account.id
1239
+ }));
1240
+ if (input.login) spec.loginApplicationId = answer$1(await text$1({ message: "Existing Access OIDC application ID:" }));
1241
+ if (input.protection) spec.protectionApplicationId = answer$1(await text$1({ message: "Existing Access protection application ID:" }));
1242
+ }
1243
+ }
1244
+ spec = parsePlatformAccessSetupSpec(spec);
1245
+ input.save({ accessSetupSpecJson: JSON.stringify(spec) });
1246
+ const accountId = spec.accountId ?? input.plan.account.id;
1247
+ const credential = process.env.VOID_PLATFORM_ACCESS_SETUP_TOKEN || input.checkpoint.accessManagementToken;
1248
+ let management = credential ? new PlatformCloudflareClient(credential, { apiBase: input.client.apiBaseUrl }) : input.client;
1249
+ let client = new PlatformAccessClient(management, accountId);
1250
+ let providers;
1251
+ let policies;
1252
+ try {
1253
+ await client.get("organizations");
1254
+ providers = await client.list("identity_providers");
1255
+ policies = await client.list("policies");
1256
+ } catch (error) {
1257
+ if (!(error instanceof PlatformCloudflareApiError) || ![401, 403].includes(error.status) || !process.stdin.isTTY || credential) throw error;
1258
+ console.error(`Access setup for account ${accountId} needs Access applications/policies permissions and organization/identity-provider/group read access${input.protection ? ", plus service-token permissions" : ""}.`);
1259
+ const token = answer$1(await password({ message: "Access setup API token (kept out of the platform runtime):" }));
1260
+ input.save({ accessManagementToken: token });
1261
+ management = new PlatformCloudflareClient(token, { apiBase: input.client.apiBaseUrl });
1262
+ client = new PlatformAccessClient(management, accountId);
1263
+ await client.get("organizations");
1264
+ providers = await client.list("identity_providers");
1265
+ policies = await client.list("policies");
1266
+ }
1267
+ const identityProviderIds = spec.identityProviderIds ?? (process.stdin.isTTY ? answer$1(await multiselect({
1268
+ message: "Company identity providers:",
1269
+ required: true,
1270
+ options: providers.filter((provider) => typeof provider.id === "string").map((provider) => ({
1271
+ value: String(provider.id),
1272
+ label: stripVTControlCharacters(String(provider.name ?? provider.id))
1273
+ }))
1274
+ })) : []);
1275
+ const policyIds = spec.policyIds ?? (process.stdin.isTTY ? answer$1(await multiselect({
1276
+ message: "Existing company Access policies (MFA and device requirements are preserved):",
1277
+ required: true,
1278
+ options: policies.filter((policy) => typeof policy.id === "string" && ["allow", "deny"].includes(String(policy.decision))).map((policy) => ({
1279
+ value: String(policy.id),
1280
+ label: stripVTControlCharacters(String(policy.name ?? policy.id))
1281
+ }))
1282
+ })) : []);
1283
+ if (!identityProviderIds.length || !policyIds.length) throw new Error("platform: Access setup requires explicit identityProviderIds and policyIds.");
1284
+ spec = {
1285
+ ...spec,
1286
+ identityProviderIds,
1287
+ policyIds
1288
+ };
1289
+ input.save({ accessSetupSpecJson: JSON.stringify(spec) });
1290
+ if (spec.mode === "existing" && (input.login && !spec.loginApplicationId || input.protection && !spec.protectionApplicationId)) throw new Error("platform: Existing Access setup requires application IDs for each selected capability.");
1291
+ let serviceToken = spec.serviceToken ? {
1292
+ id: spec.serviceToken.id,
1293
+ clientId: secretReference(spec.serviceToken.clientIdEnv),
1294
+ clientSecret: secretReference(spec.serviceToken.clientSecretEnv)
1295
+ } : void 0;
1296
+ if (!serviceToken && spec.mode === "existing" && input.protection) {
1297
+ if (input.checkpoint.accessExistingServiceTokenJson) serviceToken = JSON.parse(input.checkpoint.accessExistingServiceTokenJson);
1298
+ else if (process.stdin.isTTY) {
1299
+ serviceToken = {
1300
+ id: answer$1(await text$1({ message: "Existing admitted Access service token ID:" })),
1301
+ clientId: answer$1(await password({ message: "Access service token client ID:" })),
1302
+ clientSecret: answer$1(await password({ message: "Access service token client secret:" }))
1303
+ };
1304
+ input.save({ accessExistingServiceTokenJson: JSON.stringify(serviceToken) });
1305
+ }
1306
+ }
1307
+ if (spec.mode === "existing" && input.protection && !serviceToken) throw new Error("platform: Supply the service token already admitted by the existing protection application.");
1308
+ const reviewed = {
1309
+ accountId,
1310
+ login: input.login?.id ?? null,
1311
+ protection: input.protection,
1312
+ identityProviderIds,
1313
+ policyIds,
1314
+ origins: input.protection ? [
1315
+ input.plan.apiUrl,
1316
+ input.plan.proxyUrl,
1317
+ ...input.additionalOrigins ?? []
1318
+ ] : [],
1319
+ mode: spec.mode
1320
+ };
1321
+ console.log(JSON.stringify({ accessPlan: reviewed }, null, 2));
1322
+ if (!input.yes) {
1323
+ if (!process.stdin.isTTY || !answer$1(await confirm({
1324
+ message: "Apply this Access configuration?",
1325
+ initialValue: false
1326
+ }))) throw new Error("platform: Access setup changes require confirmation or --yes.");
1327
+ }
1328
+ const previous = input.checkpoint.accessProvisioningJson ? JSON.parse(input.checkpoint.accessProvisioningJson) : {
1329
+ version: 1,
1330
+ accountId,
1331
+ installationId: input.plan.installationId,
1332
+ nonce: createHash("sha256").update(`void-access:${input.ownershipNonce}`).digest("hex").slice(0, 32),
1333
+ resources: {}
1334
+ };
1335
+ let loginClientSecret = spec.loginClientSecretEnv ? secretReference(spec.loginClientSecretEnv) : input.checkpoint.accessExistingLoginSecret;
1336
+ if (input.login && spec.mode === "existing" && !loginClientSecret && process.stdin.isTTY) {
1337
+ loginClientSecret = answer$1(await password({ message: "Existing Access OIDC client secret:" }));
1338
+ input.save({ accessExistingLoginSecret: loginClientSecret });
1339
+ }
1340
+ const result = await provisionPlatformAccess(client, {
1341
+ accountId,
1342
+ installationId: input.plan.installationId,
1343
+ apiUrl: input.plan.apiUrl,
1344
+ proxyUrl: input.plan.proxyUrl,
1345
+ additionalOrigins: input.additionalOrigins,
1346
+ identityProviderIds,
1347
+ policyIds,
1348
+ ...input.login ? { login: {
1349
+ ...input.login,
1350
+ applicationId: spec.loginApplicationId,
1351
+ ...loginClientSecret ? { clientSecret: loginClientSecret } : {}
1352
+ } } : {},
1353
+ ...input.protection ? { protection: { applicationId: spec.protectionApplicationId } } : {},
1354
+ serviceToken
1355
+ }, previous, (state) => input.save({ accessProvisioningJson: JSON.stringify(state) }));
1356
+ if (result.credentials) {
1357
+ const accessCredentialsJson = JSON.stringify(result.credentials);
1358
+ input.save({ accessCredentialsJson });
1359
+ activatePlatformAccessCredentials(accessCredentialsJson, [
1360
+ input.plan.apiUrl,
1361
+ input.plan.proxyUrl,
1362
+ ...input.additionalOrigins ?? []
1363
+ ]);
1364
+ }
1365
+ return result;
1366
+ }
1367
+ //#endregion
1368
+ //#region src/cli/platform-auth.ts
1369
+ function assertPlatformOperatorCredentialCanApplyPlan(credential, plan) {
1370
+ if (credential.source !== "oauth") return;
1371
+ const createsZone = plan.actions.some((action) => action.kind === "zone" && action.action === "create");
1372
+ const writesDns = plan.actions.some((action) => action.kind === "dns-record" && action.action !== "reuse");
1373
+ if (!createsZone && !writesDns) return;
1374
+ const permissions = [...createsZone ? ["Account → Zone → Edit"] : [], ...writesDns ? ["Zone → DNS → Edit"] : []];
1375
+ throw new Error(`platform: Cloudflare browser OAuth grants Zone Read and cannot apply the planned zone or DNS changes. Set CLOUDFLARE_API_TOKEN with ${permissions.join(" and ")}, scoped to the selected account and application zone, then rerun the command.`);
1376
+ }
1377
+ async function resolvePlatformCloudflareCredential(dependencies = {}) {
1378
+ const { credential } = await resolvePlatformCloudflareCredentialWithInput(async () => void 0, dependencies);
1379
+ return credential;
1380
+ }
1381
+ /** Check saved credentials during input, but never start browser login until it succeeds. */
1382
+ async function resolvePlatformCloudflareCredentialWithInput(readInput, dependencies = {}) {
1383
+ const controller = new AbortController();
1384
+ const runner = dependencies.runWrangler ?? ((args) => runCloudflareAuthCommand(process.cwd(), args, controller.signal));
1385
+ const runWrangler = (args, capture) => {
1386
+ controller.signal.throwIfAborted();
1387
+ return runner(args, capture);
1388
+ };
1389
+ const pending = readExistingCredential(dependencies, runWrangler).then((credential) => ({
1390
+ ok: true,
1391
+ credential
1392
+ }), (error) => ({
1393
+ ok: false,
1394
+ error
1395
+ }));
1396
+ const cancel = () => controller.abort();
1397
+ const assertNotCancelled = () => {
1398
+ if (controller.signal.aborted) throw new Error("platform: Cancelled.");
1399
+ };
1400
+ try {
1401
+ const input = await readInput();
1402
+ process.once("SIGINT", cancel);
1403
+ const result = await pending;
1404
+ assertNotCancelled();
1405
+ if (!result.ok) throw result.error;
1406
+ if (result.credential) return {
1407
+ input,
1408
+ credential: result.credential
1409
+ };
1410
+ announceCloudflareLogin();
1411
+ const login = await runWrangler(["login", "--use-keyring"], true);
1412
+ assertNotCancelled();
1413
+ if (login.status !== 0) throw new Error("platform: Cloudflare browser login did not complete.");
1414
+ const credential = await readWranglerOAuthCredential(runWrangler);
1415
+ assertNotCancelled();
1416
+ if (!credential) throw new Error("platform: Cloudflare sign-in completed without a usable OAuth credential.");
1417
+ return {
1418
+ input,
1419
+ credential
1420
+ };
1421
+ } finally {
1422
+ process.removeListener("SIGINT", cancel);
1423
+ controller.abort();
1424
+ }
1425
+ }
1426
+ async function readExistingCredential(dependencies, runWrangler) {
1427
+ const environmentToken = process.env.CLOUDFLARE_API_TOKEN?.trim() || process.env.CF_API_TOKEN?.trim() || "";
1428
+ if (environmentToken) return {
1429
+ source: "environment",
1430
+ token: environmentToken
1431
+ };
1432
+ if (!(dependencies.interactive ?? process.stdin.isTTY === true)) throw new Error("platform: Cloudflare authentication is required. Run `void cloudflare login` interactively, or set CLOUDFLARE_API_TOKEN in CI.");
1433
+ if ((await runWrangler([
1434
+ "auth",
1435
+ "keyring",
1436
+ "enable"
1437
+ ], true)).status !== 0) throw new Error("platform: Could not enable secure Cloudflare credential storage. Refusing to use a plaintext OAuth credential.");
1438
+ return await readWranglerOAuthCredential(runWrangler);
1439
+ }
1440
+ async function readWranglerOAuthCredential(runWrangler = (args) => runCloudflareAuthCommand(process.cwd(), args)) {
1441
+ const result = await runWrangler([
1442
+ "auth",
1443
+ "token",
1444
+ "--json"
1445
+ ], true);
1446
+ if (result.status !== 0) return null;
1447
+ try {
1448
+ const parsed = JSON.parse(result.stdout);
1449
+ if (parsed.type !== "oauth" || typeof parsed.token !== "string" || !parsed.token.trim()) return null;
1450
+ return {
1451
+ source: "oauth",
1452
+ token: parsed.token.trim()
1453
+ };
1454
+ } catch {
1455
+ return null;
1456
+ }
1457
+ }
1458
+ //#endregion
1459
+ //#region src/cli/platform-access-preflight.ts
1460
+ /** Recheck live protection without invoking any provisioning or configuration write. */
1461
+ async function verifyInstalledPlatformAccessProtection(input) {
1462
+ const protection = parsePlatformAccessProtection(input.protection);
1463
+ if (!protection) return;
1464
+ const encoded = input.checkpoint.accessProvisioningJson;
1465
+ const specEncoded = input.checkpoint.accessSetupSpecJson;
1466
+ if (!encoded || !specEncoded) throw new Error("platform: Restore the current encrypted Access setup checkpoint before maintaining this protected installation.");
1467
+ platformAccessResourceInventory(encoded, input.manifest.id);
1468
+ const state = JSON.parse(encoded);
1469
+ const spec = parsePlatformAccessSetupSpec(JSON.parse(specEncoded));
1470
+ if (state.accountId !== (spec.accountId ?? input.manifest.account.id) || state.nonce !== createHash("sha256").update(`void-access:${input.manifest.ownershipNonce}`).digest("hex").slice(0, 32)) throw new Error("platform: The saved Access setup does not match this installation and identity account.");
1471
+ const application = state.resources.protection;
1472
+ const service = state.resources["service-token"];
1473
+ if (!application?.id || application.pending || !service?.id || service.pending || spec.protectionApplicationId && spec.protectionApplicationId !== application.id) throw new Error("platform: Complete or restore the Access application and service-token checkpoint before maintenance.");
1474
+ if (!input.client.request || !input.client.listWorkerDomains || !input.client.listWorkerServiceRoutes) throw new Error("platform: Read-only Access and Worker origin verification is required before maintaining protection.");
1475
+ const token = process.env.VOID_PLATFORM_ACCESS_SETUP_TOKEN || input.checkpoint.accessManagementToken;
1476
+ const access = new PlatformAccessClient(token ? new PlatformCloudflareClient(token, { apiBase: input.client.apiBaseUrl }) : { request: input.client.request.bind(input.client) }, state.accountId);
1477
+ const policy = {
1478
+ identityProviderIds: spec.identityProviderIds ?? [],
1479
+ policyIds: spec.policyIds ?? []
1480
+ };
1481
+ if ((await inspectPlatformAccess(access, policy)).teamDomain !== protection.teamDomain) throw new Error("platform: The live Access team domain does not match the active platform protection.");
1482
+ const app = await access.get(`apps/${encodeURIComponent(application.id)}`);
1483
+ if (!app || app.id !== application.id || app.name !== application.name) throw new Error("platform: The saved Access protection application no longer matches its resource.");
1484
+ await verifyPlatformAccessApplication(access, policy, app, "self_hosted", service.id);
1485
+ const origins = protection.applications.map((entry) => entry.origin);
1486
+ if ([
1487
+ input.plan.apiUrl,
1488
+ input.plan.proxyUrl,
1489
+ ...input.manifest.endpoints.dashboardUrl ? [input.manifest.endpoints.dashboardUrl] : []
1490
+ ].map((value) => new URL(value).origin).some((origin) => !origins.includes(origin)) || protection.applications.some((entry) => entry.audience !== app.aud || !platformAccessPublicDestinations(app).includes(new URL(entry.origin).hostname))) throw new Error("platform: Access audiences and complete origin coverage must match the active platform protection. Restore the current Access checkpoint or repair its applications.");
1491
+ for (const other of await access.list("apps")) {
1492
+ if (other.id === app.id) continue;
1493
+ if (platformAccessPublicDestinations(other).some((destination) => origins.some((origin) => platformAccessOverlapsOrigin(destination, origin)) && (destination.includes("/") || origins.some((origin) => destination === new URL(origin).hostname)))) throw new Error("platform: Another Access application overlaps a protected platform origin. Reconcile it before maintenance.");
1494
+ }
1495
+ const liveToken = (await access.list("service_tokens")).find((entry) => entry.id === service.id);
1496
+ const expiry = liveToken?.expires_at;
1497
+ if (!liveToken || liveToken.enabled === false || typeof expiry === "string" && (!Number.isFinite(Date.parse(expiry)) || Date.parse(expiry) <= Date.now())) throw new Error("platform: The platform Access service token is unavailable or expired. Restore its admitted credentials before maintenance.");
1498
+ for (const origin of origins) {
1499
+ const headers = cfAccessHeaders(input.checkpoint.accessCredentialsJson ? { VOID_ACCESS_CREDENTIALS: input.checkpoint.accessCredentialsJson } : process.env, origin);
1500
+ if (headers["CF-Access-Client-Id"] !== liveToken.client_id || !headers["CF-Access-Client-Secret"]) throw new Error("platform: Restore the origin-scoped Access service credentials admitted by the active protection application.");
1501
+ }
1502
+ const existingWorkers = input.plan.actions.filter((action) => action.kind === "worker" && action.action === "update").map((action) => action.name);
1503
+ if (existingWorkers.length) {
1504
+ const { verifyProtectionWorkerOrigins } = await import("./platform-auth-protection-Drl0qhrn.mjs");
1505
+ await verifyProtectionWorkerOrigins({
1506
+ request: input.client.request.bind(input.client),
1507
+ listWorkerDomains: input.client.listWorkerDomains.bind(input.client),
1508
+ listWorkerServiceRoutes: input.client.listWorkerServiceRoutes.bind(input.client)
1509
+ }, input.manifest, origins, false, { workerNames: existingWorkers });
1510
+ }
1511
+ }
1512
+ //#endregion
1513
+ //#region src/cli/platform-auth-bootstrap.ts
1514
+ /** Checkpoint a replacement before submission so an interrupted response is safely replayable. */
1515
+ async function resumeInstalledPlatformAuthentication(input, initialize = initializeInstalledPlatformAuthentication) {
1516
+ const result = await initialize(input.apiUrl, input.installationId, input.setupToken, input.setupJson);
1517
+ if (!result.setupPending || !result.expiresAt || Date.parse(result.expiresAt) > Date.now()) return result;
1518
+ const authBootstrapCode = randomBytes(32).toString("hex");
1519
+ const authenticationSetupJson = JSON.stringify({
1520
+ ...parsePlatformAuthenticationSetup(JSON.parse(input.setupJson)),
1521
+ resetPending: true,
1522
+ bootstrapCredentialHash: createHash("sha256").update(authBootstrapCode).digest("hex")
1523
+ });
1524
+ input.checkpoint({
1525
+ authenticationSetupJson,
1526
+ authBootstrapCode
1527
+ });
1528
+ return initialize(input.apiUrl, input.installationId, input.setupToken, authenticationSetupJson);
1529
+ }
1530
+ async function initializeInstalledPlatformAuthentication(apiUrl, installationId, setupToken, setupJson, fetcher = fetch) {
1531
+ const origin = normalizePlatformApiUrl(apiUrl);
1532
+ const setup = parsePlatformAuthenticationSetup(JSON.parse(setupJson));
1533
+ if (setup.protection) ensurePlatformAccessToken(origin, false, true);
1534
+ const body = JSON.stringify(setup);
1535
+ const signature = createHmac("sha256", setupToken).update(`${PLATFORM_AUTH_SETUP_PROOF_PREFIX}\n${installationId}\n${body}`).digest("base64url");
1536
+ const response = await fetcher(`${origin}/internal/platform/auth-initialize`, {
1537
+ method: "POST",
1538
+ headers: {
1539
+ "Content-Type": "application/json",
1540
+ "X-Void-Auth-Setup": signature,
1541
+ ...cfAccessHeaders(process.env, origin)
1542
+ },
1543
+ body,
1544
+ redirect: "manual",
1545
+ signal: AbortSignal.timeout(3e4)
1546
+ });
1547
+ if (!response.ok) throw new Error(`platform: Authentication setup failed (HTTP ${response.status}). Its outcome may be unknown; preserve the encrypted setup checkpoint and resume installation.`);
1548
+ const result = await response.json();
1549
+ if (typeof result.setupPending !== "boolean" || result.setupPending && (typeof result.expiresAt !== "string" || !Number.isFinite(Date.parse(result.expiresAt)))) throw new Error("platform: The platform returned an invalid authentication setup response.");
1550
+ return {
1551
+ setupPending: result.setupPending,
1552
+ expiresAt: typeof result.expiresAt === "string" ? result.expiresAt : null
1553
+ };
1554
+ }
1555
+ //#endregion
1556
+ //#region src/cli/platform-auth-database.ts
1557
+ /** Authentication requires fresh reads; mutate only the installation-owned configuration. */
1558
+ async function verifyPlatformAuthenticationDatabase(client, accountId, resource, databaseUrl) {
1559
+ if (resource.kind !== "hyperdrive" || resource.key !== "hyperdrive" || resource.state !== "ready") throw new Error("platform: Authentication database verification requires the platform Hyperdrive resource.");
1560
+ const expected = hyperdriveOriginFromConnectionString(databaseUrl);
1561
+ const read = async () => {
1562
+ const current = await client.getHyperdriveConfig(accountId, resource.id);
1563
+ if (current.id !== resource.id || current.name !== resource.name || !hyperdriveOriginMatches(current.origin, expected)) throw new Error("platform: Hyperdrive does not match this installation’s pinned database. No authentication configuration was activated.");
1564
+ return current;
1565
+ };
1566
+ if ((await read()).cachingDisabled === true) return;
1567
+ if (resource.ownership !== "created") throw new Error("platform: The adopted Hyperdrive must have SQL result caching disabled by its owner before authentication can be configured.");
1568
+ await client.disableHyperdriveCaching(accountId, resource.id);
1569
+ if ((await read()).cachingDisabled !== true) throw new Error("platform: Could not verify uncached Hyperdrive reads. Authentication setup remains closed.");
1570
+ }
1571
+ //#endregion
786
1572
  //#region src/cli/platform-plan.ts
787
1573
  async function createPlatformInstallPlan(client, input, existing, options) {
788
1574
  const account = resolveAccount(await client.listAccounts(), input.accountId);
789
1575
  if (existing && existing.account.id !== account.id) throw new Error(`platform: Refusing to resume installation ${existing.id} in a different Cloudflare account.`);
1576
+ const providedEmailSharedZoneId = input.emailSharedZoneId?.trim();
1577
+ const providedEmailSenderDomain = input.emailSenderDomain?.trim();
1578
+ if (existing?.deployment.emailSharedZoneId && providedEmailSharedZoneId && existing.deployment.emailSharedZoneId !== providedEmailSharedZoneId) throw new Error("platform: The shared email zone is already recorded for this installation.");
1579
+ if (existing?.deployment.emailSenderDomain && providedEmailSenderDomain && existing.deployment.emailSenderDomain !== normalizeApplicationDomain(providedEmailSenderDomain)) throw new Error("platform: The shared email sender domain is already recorded for this installation.");
790
1580
  const installationSlug = normalizeInstallationSlug(input.installationSlug);
791
1581
  const installationId = existing?.id ?? `${installationSlug}-${createHash("sha256").update(JSON.stringify({
792
1582
  accountId: account.id,
@@ -802,7 +1592,24 @@ async function createPlatformInstallPlan(client, input, existing, options) {
802
1592
  } : void 0);
803
1593
  if (workersDev && workersDev.subdomain !== accountSubdomain) throw new Error("platform: The account workers.dev subdomain changed. Restore it before continuing.");
804
1594
  const testing = !!workersDev && !input.zoneName;
1595
+ const emailSharedZoneId = (existing?.deployment.emailSharedZoneId ?? providedEmailSharedZoneId)?.trim();
1596
+ const emailSenderDomain = existing?.deployment.emailSenderDomain ?? providedEmailSenderDomain;
1597
+ if (!!emailSharedZoneId !== !!emailSenderDomain) throw new Error("platform: Set VOID_EMAIL_SENDER_DOMAIN and VOID_EMAIL_SHARED_ZONE_ID together to enable email.");
1598
+ const normalizedEmailSenderDomain = emailSenderDomain ? normalizeApplicationDomain(emailSenderDomain) : void 0;
1599
+ const enableEmail = options?.includeEmailGateway !== false && !!emailSharedZoneId && !!normalizedEmailSenderDomain;
1600
+ const capabilities = {
1601
+ ...existing?.deployment.capabilities,
1602
+ ...options?.enableSandbox ? { sandbox: true } : {},
1603
+ ...input.authentication ? {
1604
+ githubOAuth: input.authentication.providers.includes("github"),
1605
+ googleOAuth: input.authentication.providers.includes("google")
1606
+ } : {},
1607
+ ...enableEmail ? { email: true } : {}
1608
+ };
1609
+ if (capabilities.email && options?.includeEmailGateway !== false && !enableEmail) throw new Error("platform: Email-enabled installations require VOID_EMAIL_SENDER_DOMAIN and VOID_EMAIL_SHARED_ZONE_ID before deploying this runtime.");
805
1610
  const deployment = createPlatformDeploymentManifest({
1611
+ authentication: input.authentication ?? existing?.deployment.authentication,
1612
+ ...Object.keys(capabilities).length > 0 ? { capabilities } : {},
806
1613
  application: {
807
1614
  baseDomain: testing ? `${accountSubdomain}.workers.dev` : input.applicationDomain,
808
1615
  dedicatedZone: input.dedicatedZone,
@@ -816,10 +1623,28 @@ async function createPlatformInstallPlan(client, input, existing, options) {
816
1623
  proxyHostname: null
817
1624
  },
818
1625
  displayName: input.displayName,
1626
+ ...emailSharedZoneId ? { emailSharedZoneId } : {},
1627
+ ...normalizedEmailSenderDomain ? { emailSenderDomain: normalizedEmailSenderDomain } : {},
819
1628
  installationSlug,
820
1629
  resources: existing?.deployment.resources ?? createPlatformResourceNames(installationSlug)
821
1630
  });
822
- if (existing && JSON.stringify(existing.deployment) !== JSON.stringify(deployment)) throw new Error(`platform: --resume inputs do not match installation ${existing.id}. Use its recorded configuration.`);
1631
+ const { emailSharedZoneId: configuredEmailZone, emailSenderDomain: configuredSenderDomain, ...deploymentWithoutEmailZone } = deployment;
1632
+ const { emailSharedZoneId: recordedEmailZone, emailSenderDomain: recordedSenderDomain, ...existingWithoutEmailZone } = existing?.deployment ?? deployment;
1633
+ const existingWithSandbox = existing && options?.enableSandbox ? {
1634
+ ...existingWithoutEmailZone,
1635
+ capabilities: {
1636
+ ...existingWithoutEmailZone.capabilities,
1637
+ sandbox: true
1638
+ }
1639
+ } : existingWithoutEmailZone;
1640
+ const comparableExistingWithoutEmailZone = existing && deployment.capabilities.email && !existing.deployment.capabilities.email ? {
1641
+ ...existingWithSandbox,
1642
+ capabilities: {
1643
+ ...existingWithSandbox.capabilities,
1644
+ email: true
1645
+ }
1646
+ } : existingWithSandbox;
1647
+ if (existing && (JSON.stringify(comparableExistingWithoutEmailZone) !== JSON.stringify(deploymentWithoutEmailZone) || recordedEmailZone !== void 0 && recordedEmailZone !== configuredEmailZone || recordedSenderDomain !== void 0 && recordedSenderDomain !== configuredSenderDomain)) throw new Error(`platform: --resume inputs do not match installation ${existing.id}. Use its recorded configuration.`);
823
1648
  const names = deployment.resources;
824
1649
  const wildcardHostname = `*.${deployment.application.baseDomain}`;
825
1650
  const zone = testing ? null : await client.findZone(account.id, deployment.application.zoneName);
@@ -871,6 +1696,7 @@ async function createPlatformInstallPlan(client, input, existing, options) {
871
1696
  for (const [key, name] of [
872
1697
  ["api-worker", names.apiWorker],
873
1698
  ["proxy-worker", names.proxyWorker],
1699
+ ["email-gateway-worker", names.emailGatewayWorker],
874
1700
  ["tail-worker", names.tailWorker],
875
1701
  ["dispatch-worker", names.dispatchWorker]
876
1702
  ]) if (workers.includes(name) && hasUnrecordedProvisionIntent(existing, key, "worker", name)) {
@@ -936,6 +1762,7 @@ async function createPlatformInstallPlan(client, input, existing, options) {
936
1762
  for (const [key, name] of [
937
1763
  ["api-worker", names.apiWorker],
938
1764
  ["proxy-worker", names.proxyWorker],
1765
+ ...options?.includeEmailGateway === false ? [] : [["email-gateway-worker", names.emailGatewayWorker]],
939
1766
  ["tail-worker", names.tailWorker],
940
1767
  ["dispatch-worker", names.dispatchWorker]
941
1768
  ]) addNamedPlan(actions, existing, key, "worker", name, workers.includes(name) ? [{
@@ -972,7 +1799,11 @@ async function revalidatePlatformInstallAction(client, original, manifest, actio
972
1799
  displayName: deployment.displayName,
973
1800
  installationSlug: deployment.installationSlug,
974
1801
  zoneName: deployment.application.zoneName
975
- }, manifest, options)).actions.find((entry) => entry.key === action.key);
1802
+ }, manifest, {
1803
+ ...options,
1804
+ includeEmailGateway: original.actions.some((entry) => entry.key === "email-gateway-worker"),
1805
+ enableSandbox: original.deployment.capabilities.sandbox
1806
+ })).actions.find((entry) => entry.key === action.key);
976
1807
  if (!current || current.kind !== action.kind || current.name !== action.name || current.action !== action.action) throw new Error(`platform: Cloudflare changed after the installation plan was approved for ${action.kind} ${JSON.stringify(action.name)}. Review a new plan before retrying.`);
977
1808
  if (action.kind === "worker" && action.action === "update") {
978
1809
  if (!matchesResourceMarker((await client.getWorkerPlainTextBindings(original.account.id, action.name)).PLATFORM_OWNERSHIP_MARKER, manifest, action.key, "worker", {
@@ -1171,6 +2002,7 @@ const RESOURCE_NAME_KEY_BY_ACTION = {
1171
2002
  "isr-cache-kv": "isrCacheKv",
1172
2003
  "legacy-assets-kv": "legacyAssetsKv",
1173
2004
  "proxy-worker": "proxyWorker",
2005
+ "email-gateway-worker": "emailGatewayWorker",
1174
2006
  "routing-kv": "routingKv",
1175
2007
  "tail-worker": "tailWorker",
1176
2008
  "usage-queue": "usageQueue"
@@ -1214,6 +2046,12 @@ function formatPlatformInstallPlan(plan) {
1214
2046
  plan.deployment.application.zoneName ? `${import_picocolors$1.default.bold("Applications:")} *.${plan.deployment.application.baseDomain}` : `${import_picocolors$1.default.bold("Applications:")} workers.dev (testing); add a domain with void platform domain set <domain>`,
1215
2047
  `${import_picocolors$1.default.bold("Control plane:")} ${plan.apiUrl}`,
1216
2048
  `${import_picocolors$1.default.bold("Resource proxy:")} ${plan.proxyUrl}`,
2049
+ `${import_picocolors$1.default.bold("Login methods:")} ${plan.deployment.authentication.providers.join(", ")}`,
2050
+ `${import_picocolors$1.default.bold("Access protection:")} ${plan.deployment.authentication.accessProtection ? "Enabled" : "Not enabled"}`,
2051
+ `${import_picocolors$1.default.bold("Login callback:")} ${plan.apiUrl}/auth/callback`,
2052
+ ...plan.deployment.authentication.providers.includes("cloudflare-access") ? ["Access login: create a dedicated OIDC application or connect an existing issuer."] : [],
2053
+ ...plan.deployment.authentication.accessProtection ? [`Access protection: verify an application covering ${plan.apiUrl} and ${plan.proxyUrl}, with a separate scoped service token for installation and CI.`] : [],
2054
+ `${import_picocolors$1.default.bold("Database:")} Verify uncached Hyperdrive reads for authentication`,
1217
2055
  ""
1218
2056
  ];
1219
2057
  for (const action of plan.actions) lines.push(`${action.action.padEnd(6)} ${action.kind.padEnd(19)} ${action.name}`);
@@ -1228,6 +2066,7 @@ const COMPLETE_WORKER_SECRET_UPDATE_NAMES = [
1228
2066
  "R2_ACCESS_KEY_ID",
1229
2067
  "R2_SECRET_ACCESS_KEY"
1230
2068
  ];
2069
+ const EMAIL_WORKER_SECRET_UPDATE_NAME = "EMAIL_SIGNING_SECRET";
1231
2070
  /**
1232
2071
  * Prevents a host without the encrypted values for an interrupted credential
1233
2072
  * update from carrying an opaque, possibly partial update into another cutover.
@@ -1236,7 +2075,7 @@ function assertPlatformWorkerSecretUpdateAuthority(input) {
1236
2075
  const unresolved = unresolvedWorkerSecretUpdate(input.manifest);
1237
2076
  if (!unresolved) return;
1238
2077
  const latestWorkerGeneration = readLatestWorkerGeneration(input.manifest);
1239
- if (unresolved.generation === latestWorkerGeneration && input.localIntent?.id === unresolved.id && (input.localIntent.generation === null || input.localIntent.generation === unresolved.generation) || hasCompleteWorkerSecretReplacement(input.explicitUpdates)) return;
2078
+ if (unresolved.generation === latestWorkerGeneration && input.localIntent?.id === unresolved.id && (input.localIntent.generation === null || input.localIntent.generation === unresolved.generation) || hasCompleteWorkerSecretReplacement(input.explicitUpdates, input.requiresEmailSigningSecret)) return;
1240
2079
  throw new Error("platform: An unresolved Worker credential update is owned by another encrypted recovery checkpoint. Resume on the host that owns that checkpoint, restore its encrypted pending recovery material, or provide all supported Worker credential values together as a complete replacement.");
1241
2080
  }
1242
2081
  function unresolvedWorkerSecretUpdate(manifest) {
@@ -1264,8 +2103,8 @@ function readLatestWorkerGeneration(manifest) {
1264
2103
  return match ? [Number(match[1])] : [];
1265
2104
  }));
1266
2105
  }
1267
- function hasCompleteWorkerSecretReplacement(updates) {
1268
- return COMPLETE_WORKER_SECRET_UPDATE_NAMES.every((name) => typeof updates[name] === "string" && updates[name] !== "");
2106
+ function hasCompleteWorkerSecretReplacement(updates, requiresEmailSigningSecret) {
2107
+ return COMPLETE_WORKER_SECRET_UPDATE_NAMES.every((name) => typeof updates[name] === "string" && updates[name] !== "") && (!requiresEmailSigningSecret || typeof updates[EMAIL_WORKER_SECRET_UPDATE_NAME] === "string" && updates[EMAIL_WORKER_SECRET_UPDATE_NAME] !== "");
1269
2108
  }
1270
2109
  //#endregion
1271
2110
  //#region src/cli/platform-traffic.ts
@@ -1339,11 +2178,29 @@ function recordPlatformWorkerTraffic(manifest, workerName, inventory, updatedAt)
1339
2178
  const REMOTE_INSTALLATION_MANIFEST_KEY = PLATFORM_REMOTE_INSTALLATION_MANIFEST_KEY;
1340
2179
  const PLATFORM_WORKER_SECRET_UPDATE_NAMES = [
1341
2180
  "CF_API_TOKEN",
2181
+ "EMAIL_SIGNING_SECRET",
1342
2182
  "GITHUB_CLIENT_ID",
1343
2183
  "GITHUB_CLIENT_SECRET",
1344
2184
  "R2_ACCESS_KEY_ID",
1345
2185
  "R2_SECRET_ACCESS_KEY"
1346
2186
  ];
2187
+ /** Preserve database-authoritative authentication choices while upgrading old manifests. */
2188
+ function mergePlatformLiveAuthenticationDeployment(deployment, live) {
2189
+ const providers = live.enabledProviders.length > 0 ? live.enabledProviders : deployment.authentication.providers;
2190
+ return createPlatformDeploymentManifest({
2191
+ ...deployment,
2192
+ authentication: {
2193
+ providers,
2194
+ ...live.sessionsRequired ? { configurable: true } : {},
2195
+ ...live.protectedByAccess ? { accessProtection: true } : {}
2196
+ },
2197
+ capabilities: {
2198
+ ...deployment.capabilities,
2199
+ githubOAuth: providers.includes("github"),
2200
+ googleOAuth: providers.includes("google")
2201
+ }
2202
+ });
2203
+ }
1347
2204
  var PlatformWorkerRecoveryIncompleteError = class extends Error {
1348
2205
  constructor(message, options) {
1349
2206
  super(message, options);
@@ -1364,9 +2221,28 @@ function createPlatformInstallationSecrets(input) {
1364
2221
  const existingInstallation = input.existingInstallation === true;
1365
2222
  const restoredIdentity = (explicit, name) => existingInstallation ? checkpoint[name] || explicit || "" : explicit || checkpoint[name] || "";
1366
2223
  const configured = (explicit, name) => explicit || checkpoint[name] || "";
2224
+ const authenticationSetupJson = configured(input.authenticationSetupJson ?? "", "authenticationSetupJson");
2225
+ const authSetupToken = configured(input.authSetupToken ?? "", "authSetupToken");
2226
+ const authBootstrapCode = configured(input.authBootstrapCode ?? "", "authBootstrapCode");
2227
+ const modernAuthentication = input.configurableAuthentication === true || !!authenticationSetupJson;
2228
+ if (modernAuthentication && !existingInstallation && (authSetupToken.length < 32 || !/^[a-f0-9]{64}$/.test(authBootstrapCode))) throw new Error("platform: Valid setup authority and an administrator setup code are required.");
2229
+ if (authenticationSetupJson) {
2230
+ const setup = parsePlatformAuthenticationSetup(JSON.parse(authenticationSetupJson));
2231
+ if (!existingInstallation && (authSetupToken.length < 32 || !/^[a-f0-9]{64}$/.test(authBootstrapCode) || createHash("sha256").update(authBootstrapCode).digest("hex") !== setup.bootstrapCredentialHash)) throw new Error("platform: Valid installation setup authority and a matching administrator setup code are required.");
2232
+ }
1367
2233
  const jwtSecret = restoredIdentity(input.jwtSecret || "", "jwtSecret");
1368
2234
  const resolvedJwtSecret = input.existingInstallation ? jwtSecret : required(jwtSecret, "VOID_PLATFORM_JWT_SECRET");
1369
2235
  if (!existingInstallation && Buffer.byteLength(resolvedJwtSecret, "utf8") < 32) throw new Error("platform: VOID_PLATFORM_JWT_SECRET must contain at least 32 bytes of secret material unique to this installation.");
2236
+ const emailEnabled = input.emailEnabled === true;
2237
+ const existingEmailEnabled = input.existingEmailEnabled === true;
2238
+ const emailSigningSecretInScope = emailEnabled || existingEmailEnabled;
2239
+ let emailSigningSecret = emailSigningSecretInScope ? restoredIdentity(input.emailSigningSecret || "", "emailSigningSecret") : checkpoint.emailSigningSecret || "";
2240
+ if (emailEnabled && !emailSigningSecret) {
2241
+ if (existingInstallation && existingEmailEnabled) throw new Error("platform: VOID_PLATFORM_EMAIL_SIGNING_SECRET is required to maintain an email-enabled installation without its encrypted recovery state.");
2242
+ emailSigningSecret = randomBytes(32).toString("base64");
2243
+ }
2244
+ if (emailSigningSecretInScope && emailSigningSecret && Buffer.byteLength(emailSigningSecret, "utf8") < 32) throw new Error("platform: VOID_PLATFORM_EMAIL_SIGNING_SECRET must contain at least 32 bytes of secret material unique to this installation.");
2245
+ if (emailSigningSecretInScope && emailSigningSecret && emailSigningSecret === resolvedJwtSecret) throw new Error("platform: VOID_PLATFORM_EMAIL_SIGNING_SECRET must be different from VOID_PLATFORM_JWT_SECRET.");
1370
2246
  const projectSecretKeyring = resolveProjectSecretKeyring({
1371
2247
  checkpoint,
1372
2248
  existingInstallation,
@@ -1376,22 +2252,34 @@ function createPlatformInstallationSecrets(input) {
1376
2252
  required
1377
2253
  });
1378
2254
  const pendingWorkerSecretUpdateIntent = parseWorkerSecretUpdateIntent(checkpoint.workerSecretUpdatesJson);
2255
+ const emailSigningSecretUpdateAuthorized = emailSigningSecretInScope && !!emailSigningSecret && (emailEnabled && (!existingEmailEnabled || !checkpoint.emailSigningSecret) || !!input.emailSigningSecret);
1379
2256
  const explicitWorkerSecretUpdates = withoutEmptySecrets({
1380
2257
  CF_API_TOKEN: input.cloudflareApiToken,
2258
+ ...emailSigningSecretUpdateAuthorized ? { EMAIL_SIGNING_SECRET: emailSigningSecret } : {},
1381
2259
  GITHUB_CLIENT_ID: input.githubClientId,
1382
2260
  GITHUB_CLIENT_SECRET: input.githubClientSecret,
1383
2261
  R2_ACCESS_KEY_ID: input.r2AccessKeyId,
1384
2262
  R2_SECRET_ACCESS_KEY: input.r2SecretAccessKey
1385
2263
  });
1386
2264
  return {
2265
+ authenticationPreparation: Object.fromEntries(Object.entries(checkpoint).filter(([name]) => isPlatformAuthenticationPreparationKey(name))),
2266
+ ...modernAuthentication ? {
2267
+ authenticationSetupJson,
2268
+ authSetupToken,
2269
+ authBootstrapCode
2270
+ } : {},
2271
+ ...checkpoint.accessProvisioningJson ? { accessProvisioningJson: checkpoint.accessProvisioningJson } : {},
2272
+ ...checkpoint.accessCredentialsJson ? { accessCredentialsJson: checkpoint.accessCredentialsJson } : {},
1387
2273
  workerSecretUpdates: input.updateExistingWorkers ? explicitWorkerSecretUpdates : {},
1388
2274
  pendingWorkerSecretUpdateIntent: input.updateExistingWorkers ? pendingWorkerSecretUpdateIntent : void 0,
1389
- bootstrapGitHubId: existingInstallation ? configured(input.bootstrapGitHubId, "bootstrapGitHubId") : required(configured(input.bootstrapGitHubId, "bootstrapGitHubId"), "resolved administrator GitHub account id"),
1390
- bootstrapGitHubLogin: existingInstallation ? configured(input.bootstrapGitHubLogin, "bootstrapGitHubLogin") : required(configured(input.bootstrapGitHubLogin, "bootstrapGitHubLogin"), "VOID_PLATFORM_ADMIN_GITHUB_LOGIN"),
2275
+ bootstrapGitHubId: existingInstallation || modernAuthentication ? configured(input.bootstrapGitHubId, "bootstrapGitHubId") : required(configured(input.bootstrapGitHubId, "bootstrapGitHubId"), "resolved administrator GitHub account id"),
2276
+ bootstrapGitHubLogin: existingInstallation || modernAuthentication ? configured(input.bootstrapGitHubLogin, "bootstrapGitHubLogin") : required(configured(input.bootstrapGitHubLogin, "bootstrapGitHubLogin"), "VOID_PLATFORM_ADMIN_GITHUB_LOGIN"),
1391
2277
  cloudflareApiToken: existingInstallation ? configured(input.cloudflareApiToken, "cloudflareApiToken") : required(configured(input.cloudflareApiToken, "cloudflareApiToken"), "VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN"),
1392
2278
  databaseUrl: required(input.databaseUrl || checkpoint.databaseUrl || "", "DATABASE_URL"),
1393
- githubClientId: existingInstallation ? configured(input.githubClientId, "githubClientId") : required(configured(input.githubClientId, "githubClientId"), "VOID_PLATFORM_GITHUB_CLIENT_ID"),
1394
- githubClientSecret: existingInstallation ? configured(input.githubClientSecret, "githubClientSecret") : required(configured(input.githubClientSecret, "githubClientSecret"), "VOID_PLATFORM_GITHUB_CLIENT_SECRET"),
2279
+ emailEnabled,
2280
+ emailSigningSecret,
2281
+ githubClientId: existingInstallation || modernAuthentication ? configured(input.githubClientId, "githubClientId") : required(configured(input.githubClientId, "githubClientId"), "VOID_PLATFORM_GITHUB_CLIENT_ID"),
2282
+ githubClientSecret: existingInstallation || modernAuthentication ? configured(input.githubClientSecret, "githubClientSecret") : required(configured(input.githubClientSecret, "githubClientSecret"), "VOID_PLATFORM_GITHUB_CLIENT_SECRET"),
1395
2283
  jwtSecret: resolvedJwtSecret,
1396
2284
  projectSecretActiveKeyVersion: projectSecretKeyring.activeVersion,
1397
2285
  projectSecretKey: projectSecretKeyring.v1Key,
@@ -1450,6 +2338,14 @@ function assertCanonicalProjectSecretKey(value, name, uniqueToInstallation = fal
1450
2338
  }
1451
2339
  function secretCheckpoint(secrets, workerSecretUpdateIntent) {
1452
2340
  return {
2341
+ ...Object.fromEntries(Object.entries(secrets.authenticationPreparation ?? {}).filter(([name]) => isPlatformAuthenticationPreparationKey(name))),
2342
+ ...secrets.authSetupToken ? {
2343
+ authenticationSetupJson: secrets.authenticationSetupJson ?? "",
2344
+ authSetupToken: secrets.authSetupToken ?? "",
2345
+ authBootstrapCode: secrets.authBootstrapCode ?? ""
2346
+ } : {},
2347
+ ...secrets.accessProvisioningJson ? { accessProvisioningJson: secrets.accessProvisioningJson } : {},
2348
+ ...secrets.accessCredentialsJson ? { accessCredentialsJson: secrets.accessCredentialsJson } : {},
1453
2349
  bootstrapGitHubId: secrets.bootstrapGitHubId,
1454
2350
  bootstrapGitHubLogin: secrets.bootstrapGitHubLogin,
1455
2351
  cloudflareApiToken: secrets.cloudflareApiToken,
@@ -1457,6 +2353,7 @@ function secretCheckpoint(secrets, workerSecretUpdateIntent) {
1457
2353
  githubClientId: secrets.githubClientId,
1458
2354
  githubClientSecret: secrets.githubClientSecret,
1459
2355
  jwtSecret: secrets.jwtSecret,
2356
+ emailSigningSecret: secrets.emailSigningSecret,
1460
2357
  projectSecretActiveKeyVersion: secrets.projectSecretActiveKeyVersion,
1461
2358
  projectSecretKey: secrets.projectSecretKey,
1462
2359
  projectSecretKeysJson: secrets.projectSecretKeysJson,
@@ -1499,6 +2396,22 @@ async function installPlatform(plan, secrets, existing, dependencies) {
1499
2396
  const resolvedRuntime = dependencies.runtime ?? resolvePlatformRuntime();
1500
2397
  const runtimeDirectory = resolvedRuntime.directory;
1501
2398
  const runtime = resolvedRuntime.manifest;
2399
+ const hasEmailGatewayArtifact = runtime.artifacts.emailGateway !== void 0;
2400
+ if (runtime.emailProtocolVersion !== 1 && plan.deployment.capabilities.email) throw new Error("platform: This runtime cannot serve enabled platform email without email protocol version 1.");
2401
+ if (runtime.emailProtocolVersion !== 1 && existing?.resources.some((resource) => resource.key === "email-gateway-worker" && resource.state === "ready")) throw new Error("platform: This runtime cannot replace an installed email gateway Worker. Use a runtime with email protocol version 1.");
2402
+ const workerKeys = hasEmailGatewayArtifact ? [
2403
+ "api",
2404
+ "proxy",
2405
+ "emailGateway",
2406
+ "tail",
2407
+ "dispatch"
2408
+ ] : [
2409
+ "api",
2410
+ "proxy",
2411
+ "tail",
2412
+ "dispatch"
2413
+ ];
2414
+ if (plan.deployment.authentication.configurable && runtime.authenticationProtocolVersion !== 1) throw new Error("platform: This runtime does not support configurable authentication. Upgrade Void or use a compatible runtime.");
1502
2415
  if (plan.deployment.application.workersDev && runtime.applicationRoutingVersion !== 1) throw new Error("platform: This runtime does not support workers.dev application routing. Use a current platform runtime.");
1503
2416
  verifyRuntimeArtifacts(runtimeDirectory, runtime);
1504
2417
  const runtimeProvenance = {
@@ -1512,11 +2425,14 @@ async function installPlatform(plan, secrets, existing, dependencies) {
1512
2425
  const hadPriorWorkers = existing !== null && (existing.resources.some((resource) => resource.kind === "worker" && resource.state === "ready") || plan.actions.some((action) => action.kind === "worker" && action.action !== "create"));
1513
2426
  const wasDisabled = existing?.status === "disabled";
1514
2427
  const preserveDisabled = existing?.desiredState === "disabled";
2428
+ const shouldBootstrapSharedEmail = !preserveDisabled && plan.deployment.capabilities.email && runtime.emailProtocolVersion === 1 && !!plan.deployment.emailSharedZoneId;
1515
2429
  const previousPublicEndpoints = stablePublicEndpointState(existing);
2430
+ const authenticationProtocolTransition = hadPriorWorkers && existing?.deployment.authentication.configurable !== true && runtime.authenticationProtocolVersion === 1;
1516
2431
  let workerCutoverGeneration = 0;
1517
2432
  let workerRollbacks = [];
1518
2433
  let workerDeploymentCommitted = false;
1519
2434
  let workerTrafficGuarded = false;
2435
+ let authenticationEnforcementActive = false;
1520
2436
  let manifest = existing;
1521
2437
  if (!manifest) {
1522
2438
  const createdAt = now();
@@ -1548,7 +2464,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
1548
2464
  manifest = setInstallationDesiredState(manifest, "disabled", now());
1549
2465
  writeInstallationManifest(manifest);
1550
2466
  }
1551
- assertRequiredPlatformSecrets(plan, manifest, secrets);
2467
+ assertRequiredPlatformSecrets(plan, manifest, secrets, !!dependencies.prepareAuthentication);
1552
2468
  const pendingSecrets = secretCheckpoint(secrets, workerSecretUpdateIntent);
1553
2469
  writePendingInstallationSecrets(manifest, {
1554
2470
  ...pendingSecrets,
@@ -1581,22 +2497,83 @@ async function installPlatform(plan, secrets, existing, dependencies) {
1581
2497
  assertPlatformWorkerSecretUpdateAuthority({
1582
2498
  explicitUpdates: explicitWorkerSecretUpdates,
1583
2499
  localIntent: secrets.pendingWorkerSecretUpdateIntent,
1584
- manifest
2500
+ manifest,
2501
+ requiresEmailSigningSecret: manifest.deployment.capabilities.email || plan.deployment.capabilities.email
1585
2502
  });
1586
2503
  } catch (error) {
1587
2504
  await releaseDatabaseLock();
1588
2505
  throw error;
1589
2506
  }
1590
2507
  if (workerSecretUpdateIntent && !hasExplicitWorkerSecretUpdates && platformWorkerSecretUpdateIntentState(manifest, workerSecretUpdateIntent) !== "active") workerSecretUpdateIntent = null;
1591
- const workerDeploymentSecrets = {
2508
+ let workerDeploymentSecrets = {
1592
2509
  ...secrets,
1593
2510
  workerSecretUpdates: workerSecretUpdateIntent?.updates ?? {}
1594
2511
  };
1595
2512
  try {
1596
2513
  assertPlatformRuntimeSafety(runtime, manifest.safetyProtocols, releaseDatabaseLock.requiredSafetyProtocols);
1597
2514
  if (existing) await dependencies.verifyExistingOwnership?.(manifest, authoritative !== null && authoritative !== void 0);
2515
+ const liveAuthentication = await releaseDatabaseLock.readAuthenticationState?.();
2516
+ if (liveAuthentication) {
2517
+ authenticationEnforcementActive = liveAuthentication.sessionsRequired;
2518
+ if (liveAuthentication.sessionsRequired && runtime.authenticationProtocolVersion !== 1) throw new Error("platform: This runtime cannot enforce the active authentication configuration. Use an authentication-compatible runtime.");
2519
+ const deployment = mergePlatformLiveAuthenticationDeployment(plan.deployment, liveAuthentication);
2520
+ plan = {
2521
+ ...plan,
2522
+ deployment
2523
+ };
2524
+ if (!authenticationProtocolTransition) manifest = {
2525
+ ...manifest,
2526
+ deployment
2527
+ };
2528
+ }
2529
+ if (!liveAuthentication && manifest.deployment.authentication.configurable && !plan.deployment.authentication.configurable) {
2530
+ const authentication = manifest.deployment.authentication;
2531
+ const providers = authentication.providers;
2532
+ plan = {
2533
+ ...plan,
2534
+ deployment: createPlatformDeploymentManifest({
2535
+ ...plan.deployment,
2536
+ authentication,
2537
+ capabilities: {
2538
+ ...plan.deployment.capabilities,
2539
+ githubOAuth: providers.includes("github"),
2540
+ googleOAuth: providers.includes("google")
2541
+ }
2542
+ })
2543
+ };
2544
+ }
2545
+ if (authenticationProtocolTransition && !plan.deployment.authentication.configurable) {
2546
+ const providers = plan.deployment.authentication.providers;
2547
+ const deployment = createPlatformDeploymentManifest({
2548
+ ...plan.deployment,
2549
+ authentication: {
2550
+ providers,
2551
+ configurable: true
2552
+ },
2553
+ capabilities: {
2554
+ ...plan.deployment.capabilities,
2555
+ githubOAuth: providers.includes("github"),
2556
+ googleOAuth: providers.includes("google")
2557
+ }
2558
+ });
2559
+ plan = {
2560
+ ...plan,
2561
+ deployment
2562
+ };
2563
+ }
2564
+ const activateAccessCredentials = () => {
2565
+ if (!secrets.accessCredentialsJson) return;
2566
+ const credentials = JSON.parse(secrets.accessCredentialsJson);
2567
+ if (!credentials || typeof credentials !== "object" || Array.isArray(credentials)) throw new Error("platform: Invalid saved Access credentials.");
2568
+ const origins = [plan.apiUrl, plan.proxyUrl];
2569
+ activatePlatformAccessCredentials(JSON.stringify(Object.fromEntries(Object.entries(credentials).filter(([origin]) => origins.includes(origin)))), origins);
2570
+ };
2571
+ activateAccessCredentials();
2572
+ manifest = checkpointAuthenticationResources(manifest, secrets, now());
2573
+ writeInstallationManifest(manifest);
2574
+ await releaseDatabaseLock.checkpoint?.(manifest);
1598
2575
  dependencies.onProgress?.("Checking and creating Cloudflare resources");
1599
- if (JSON.stringify(manifest.deployment) !== JSON.stringify(plan.deployment)) {
2576
+ if (!authenticationProtocolTransition && JSON.stringify(manifest.deployment) !== JSON.stringify(plan.deployment)) {
1600
2577
  manifest = {
1601
2578
  ...manifest,
1602
2579
  deployment: plan.deployment,
@@ -1610,6 +2587,61 @@ async function installPlatform(plan, secrets, existing, dependencies) {
1610
2587
  manifest = transitionInstallation(manifest, "provisioning", now());
1611
2588
  writeInstallationManifest(manifest);
1612
2589
  }
2590
+ if (dependencies.prepareAuthentication) {
2591
+ dependencies.onProgress?.("Preparing platform authentication");
2592
+ try {
2593
+ secrets = {
2594
+ ...secrets,
2595
+ ...await dependencies.prepareAuthentication(manifest, secrets)
2596
+ };
2597
+ } catch (error) {
2598
+ const latest = readPendingInstallationSecrets(manifest) ?? {};
2599
+ secrets = {
2600
+ ...secrets,
2601
+ authenticationPreparation: Object.fromEntries(Object.entries(latest).filter(([name]) => isPlatformAuthenticationPreparationKey(name))),
2602
+ authenticationSetupJson: latest.authenticationSetupJson || secrets.authenticationSetupJson,
2603
+ authSetupToken: latest.authSetupToken || secrets.authSetupToken,
2604
+ authBootstrapCode: latest.authBootstrapCode || secrets.authBootstrapCode,
2605
+ accessProvisioningJson: latest.accessProvisioningJson || secrets.accessProvisioningJson,
2606
+ accessCredentialsJson: latest.accessCredentialsJson || secrets.accessCredentialsJson
2607
+ };
2608
+ manifest = checkpointAuthenticationResources(manifest, secrets, now());
2609
+ writeInstallationManifest(manifest);
2610
+ await releaseDatabaseLock.checkpoint?.(manifest);
2611
+ throw error;
2612
+ }
2613
+ const latest = readPendingInstallationSecrets(manifest) ?? {};
2614
+ secrets = {
2615
+ ...secrets,
2616
+ authenticationPreparation: Object.fromEntries(Object.entries(latest).filter(([name]) => isPlatformAuthenticationPreparationKey(name)))
2617
+ };
2618
+ manifest = checkpointAuthenticationResources(manifest, secrets, now());
2619
+ writeInstallationManifest(manifest);
2620
+ await releaseDatabaseLock.checkpoint?.(manifest);
2621
+ activateAccessCredentials();
2622
+ assertRequiredPlatformSecrets(plan, manifest, secrets);
2623
+ if (secrets.authenticationSetupJson && createHash("sha256").update(secrets.authBootstrapCode ?? "").digest("hex") !== parsePlatformAuthenticationSetup(JSON.parse(secrets.authenticationSetupJson)).bootstrapCredentialHash) throw new Error("platform: Prepared authentication does not match its administrator setup code.");
2624
+ workerDeploymentSecrets = {
2625
+ ...secrets,
2626
+ workerSecretUpdates: workerSecretUpdateIntent?.updates ?? {}
2627
+ };
2628
+ writePendingInstallationSecrets(manifest, {
2629
+ ...secretCheckpoint(secrets, workerSecretUpdateIntent),
2630
+ databaseClaimState: "claimed"
2631
+ });
2632
+ }
2633
+ if (plan.deployment.authentication.accessProtection) {
2634
+ const protection = liveAuthentication ? liveAuthentication.protection : secrets.authenticationSetupJson ? parsePlatformAuthenticationSetup(JSON.parse(secrets.authenticationSetupJson)).protection : null;
2635
+ if (!protection) throw new Error("platform: The active Access protection configuration is unavailable. Restore its database and encrypted setup checkpoint before maintenance.");
2636
+ dependencies.onProgress?.("Verifying current Access policies and protected origins");
2637
+ await (dependencies.verifyAccessProtection ?? verifyInstalledPlatformAccessProtection)({
2638
+ client: dependencies.client,
2639
+ manifest,
2640
+ plan,
2641
+ protection,
2642
+ checkpoint: secretCheckpoint(secrets, workerSecretUpdateIntent)
2643
+ });
2644
+ }
1613
2645
  const routingAction = plan.actions.find((action) => action.key === "routing-kv");
1614
2646
  if (routingAction.action === "adopt") throw new Error("platform: The routing checkpoint cannot be adopted.");
1615
2647
  if (routingAction.action === "reuse") {
@@ -1692,6 +2724,10 @@ async function installPlatform(plan, secrets, existing, dependencies) {
1692
2724
  await writeR2OwnershipMarkerIfNeeded(dependencies.client, plan, manifest, action);
1693
2725
  await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
1694
2726
  }
2727
+ dependencies.onProgress?.("Verifying uncached authentication database reads");
2728
+ const authenticationDatabase = manifest.resources.find((resource) => resource.key === "hyperdrive");
2729
+ if (!authenticationDatabase) throw new Error("platform: The authentication database resource is missing.");
2730
+ await verifyPlatformAuthenticationDatabase(dependencies.client, plan.account.id, authenticationDatabase, secrets.databaseUrl);
1695
2731
  dependencies.onProgress?.("Checking the platform database schema");
1696
2732
  const rollbackDatabase = dependencies.databaseRollbackSource;
1697
2733
  const migrationStep = `database-schema:${runtime.database.schemaDigest}`;
@@ -1744,7 +2780,8 @@ async function installPlatform(plan, secrets, existing, dependencies) {
1744
2780
  const targetSandboxDrainProtocol = runtime.safetyProtocols?.sandboxDrain ?? 0;
1745
2781
  const prepareSandboxDrain = dependencies.prepareSandboxDrain ?? releaseDatabaseLock.prepareSandboxDrain;
1746
2782
  if (targetSandboxDrainProtocol >= 1 && !prepareSandboxDrain) throw new Error("platform: The PostgreSQL lifecycle lock cannot establish the sandbox-drain safety checkpoint.");
1747
- const sandboxDrainPreparation = targetSandboxDrainProtocol >= 1 ? await prepareSandboxDrain(targetSandboxDrainProtocol >= 2 ? 2 : 0) : null;
2783
+ const sandboxDrainPreparation = targetSandboxDrainProtocol >= 1 ? await prepareSandboxDrain(targetSandboxDrainProtocol >= 3 ? 3 : targetSandboxDrainProtocol >= 2 ? 2 : 0) : null;
2784
+ if (sandboxDrainPreparation && sandboxDrainPreparation.admissionProtocol < targetSandboxDrainProtocol) throw new Error("platform: The database did not arm the Sandbox admission protocol required by this runtime.");
1748
2785
  const sandboxDrainRoutingKvId = requireId(Object.fromEntries(manifest.resources.map((resource) => [resource.key, resource.id])), "routing-kv");
1749
2786
  const writeSandboxDrainMirror = async (preparation) => {
1750
2787
  await dependencies.client.putKvValue(plan.account.id, sandboxDrainRoutingKvId, PLATFORM_SANDBOX_DRAIN_KEY, preparation.mirrorValue);
@@ -1771,17 +2808,24 @@ async function installPlatform(plan, secrets, existing, dependencies) {
1771
2808
  manifest = recordInstallationStep(manifest, {
1772
2809
  completedAt: now(),
1773
2810
  key: bootstrapStep,
1774
- resultHash: hash(secrets.bootstrapGitHubId)
2811
+ resultHash: hash(secrets.authenticationSetupJson ?? secrets.bootstrapGitHubId)
1775
2812
  }, now());
1776
2813
  writeInstallationManifest(manifest);
1777
2814
  }
1778
2815
  const entrypoints = Object.fromEntries(Object.entries(runtime.artifacts).map(([key, artifact]) => [key, join(runtimeDirectory, artifact.path)]));
1779
- const interruptedCutover = interruptedWorkerCutover(manifest);
2816
+ let interruptedCutover = interruptedWorkerCutover(manifest);
2817
+ if (interruptedCutover && authenticationProtocolTransition && authenticationEnforcementActive) {
2818
+ manifest = recordWorkerCutoverResolution(manifest, interruptedCutover.generation, "committed", now());
2819
+ manifest = recordWorkerTrafficState(manifest, interruptedCutover.generation, false, now());
2820
+ writeInstallationManifest(manifest);
2821
+ await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
2822
+ interruptedCutover = null;
2823
+ }
1780
2824
  const recoveringUnsafeTraffic = (interruptedCutover?.unsafeTraffic.size ?? 0) > 0;
1781
- const createsWorker = plan.actions.some((action) => action.kind === "worker" && action.action === "create");
2825
+ const createsWorker = plan.actions.some((action) => action.kind === "worker" && action.action === "create" && (hasEmailGatewayArtifact || action.key !== "email-gateway-worker"));
1782
2826
  const recoveringCreatedWorker = interruptedCutover !== null && [...interruptedCutover.staged.values()].some((snapshot) => snapshot.previousVersionId === null);
1783
2827
  const requiresSandboxDrain = !preserveDisabled && targetSandboxDrainProtocol >= 1 && sandboxDrainState === "pending";
1784
- const requiresTrafficGuard = !previousPublicEndpoints || recoveringUnsafeTraffic || createsWorker || recoveringCreatedWorker || manifest.usageProtocolVersion !== targetUsageProtocolVersion || requiresSandboxDrain;
2828
+ const requiresTrafficGuard = !previousPublicEndpoints || recoveringUnsafeTraffic || createsWorker || recoveringCreatedWorker || manifest.usageProtocolVersion !== targetUsageProtocolVersion || authenticationProtocolTransition || shouldBootstrapSharedEmail || requiresSandboxDrain;
1785
2829
  if (requiresTrafficGuard) {
1786
2830
  await dependencies.client.putKvValue(plan.account.id, requireId(ids, "routing-kv"), PLATFORM_DISABLED_KEY, PLATFORM_DISABLED_VALUE);
1787
2831
  workerTrafficGuarded = true;
@@ -1797,6 +2841,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
1797
2841
  await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
1798
2842
  }
1799
2843
  const configInput = {
2844
+ authenticationDatabaseUncached: true,
1800
2845
  accountId: plan.account.id,
1801
2846
  apiUrl: plan.apiUrl,
1802
2847
  deployment: plan.deployment,
@@ -1805,6 +2850,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
1805
2850
  ownershipNonce: manifest.ownershipNonce,
1806
2851
  platformVersion: version,
1807
2852
  proxyUrl: plan.proxyUrl,
2853
+ emailGatewayUrl: `https://${plan.deployment.resources.emailGatewayWorker}.${plan.workersSubdomain}.workers.dev`,
1808
2854
  resources: {
1809
2855
  hyperdrive: requireId(ids, "hyperdrive"),
1810
2856
  isrCacheKv: requireId(ids, "isr-cache-kv"),
@@ -1831,7 +2877,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
1831
2877
  trafficState: previousPublicEndpoints ? "enabled" : "staging"
1832
2878
  });
1833
2879
  const managedRoutes = platformManagedWorkerTraffic(plan.deployment).routes;
1834
- let workerActions = plan.actions.filter((action) => action.kind === "worker");
2880
+ let workerActions = plan.actions.filter((action) => action.kind === "worker" && (hasEmailGatewayArtifact || action.key !== "email-gateway-worker"));
1835
2881
  if (interruptedCutover) {
1836
2882
  for (const key of [...interruptedCutover.staged.keys()].reverse()) {
1837
2883
  const snapshot = interruptedCutover.staged.get(key);
@@ -1866,7 +2912,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
1866
2912
  previousTriggers: mergePreservedTriggerRoutes(withoutPlatformCustomDomainTriggers(snapshot.previousTriggers), preservedRoutes),
1867
2913
  previousVersionId: snapshot.previousVersionId,
1868
2914
  preserveCustomDomains: interruptedCutover.unsafeTraffic.has(key),
1869
- inspectNewWorker: () => inspectNewPlatformWorker(dependencies.client, plan.account.id, manifest, `${key}-worker`, snapshot.workerName),
2915
+ inspectNewWorker: () => inspectNewPlatformWorker(dependencies.client, plan.account.id, manifest, key === "emailGateway" ? "email-gateway-worker" : `${key}-worker`, snapshot.workerName),
1870
2916
  removeCustomDomains: async () => {
1871
2917
  if (!dependencies.client.removeRoutes) throw new Error("platform: Cloudflare route removal is unavailable.");
1872
2918
  await dependencies.client.removeRoutes(plan.account.id, zoneId, trackedPlatformWorkerTraffic(manifest, /* @__PURE__ */ new Set([snapshot.workerName])));
@@ -1892,7 +2938,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
1892
2938
  await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
1893
2939
  if (retainedNewWorker) {
1894
2940
  if (!dependencies.replanWorkers) throw new Error("platform: Recovering retained Workers requires a fresh Cloudflare installation plan.");
1895
- workerActions = validateReplannedWorkerActions(plan, manifest, await dependencies.replanWorkers(manifest));
2941
+ workerActions = validateReplannedWorkerActions(plan, manifest, await dependencies.replanWorkers(manifest)).filter((action) => hasEmailGatewayArtifact || action.key !== "email-gateway-worker");
1896
2942
  }
1897
2943
  }
1898
2944
  workerCutoverGeneration = nextWorkerCutoverGeneration(manifest);
@@ -1906,14 +2952,9 @@ async function installPlatform(plan, secrets, existing, dependencies) {
1906
2952
  };
1907
2953
  writePendingInstallationSecrets(manifest, secretCheckpoint(secrets, workerSecretUpdateIntent));
1908
2954
  }
1909
- for (const key of [
1910
- "api",
1911
- "proxy",
1912
- "tail",
1913
- "dispatch"
1914
- ]) {
2955
+ for (const key of workerKeys) {
1915
2956
  dependencies.onProgress?.(`Deploying the ${key} service`);
1916
- const action = workerActions.find((entry) => entry.key === `${key}-worker`);
2957
+ const action = workerActions.find((entry) => entry.key === (key === "emailGateway" ? "email-gateway-worker" : `${key}-worker`));
1917
2958
  const workerSecrets = platformWorkerSecretPayload(key, workerDeploymentSecrets, plan.account.id, zoneId, action.action !== "create");
1918
2959
  manifest = recordProvisionIntent(manifest, action, now());
1919
2960
  writeInstallationManifest(manifest);
@@ -1982,6 +3023,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
1982
3023
  if (workerSecretUpdateIntent) manifest = recordWorkerSecretUpdateState(manifest, workerCutoverGeneration, "committed", workerSecretUpdateIntent.id, now());
1983
3024
  manifest = {
1984
3025
  ...manifest,
3026
+ deployment: plan.deployment,
1985
3027
  platformVersion: version,
1986
3028
  runtime: runtimeProvenance,
1987
3029
  updatedAt: now(),
@@ -2000,17 +3042,63 @@ async function installPlatform(plan, secrets, existing, dependencies) {
2000
3042
  zoneNameServers: zone?.nameServers ?? []
2001
3043
  };
2002
3044
  }
3045
+ let authenticationCutoverCommitted = false;
3046
+ if (authenticationProtocolTransition && requiresStagedCommit) {
3047
+ manifest = recordWorkerCutoverResolution(manifest, workerCutoverGeneration, "committed", now());
3048
+ manifest = recordWorkerTrafficState(manifest, workerCutoverGeneration, false, now());
3049
+ writeInstallationManifest(manifest);
3050
+ await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
3051
+ workerRollbacks = [];
3052
+ authenticationCutoverCommitted = true;
3053
+ }
3054
+ let authenticationStep = secrets.authenticationSetupJson ? "authentication-initialized:v1:" + hash(secrets.authenticationSetupJson) : "";
3055
+ let setupPending = false;
2003
3056
  manifest = transitionInstallation(manifest, "verifying", now());
2004
3057
  writeInstallationManifest(manifest);
3058
+ if (plan.deployment.authentication.configurable && authenticationStep) {
3059
+ if (!secrets.authenticationSetupJson || !secrets.authSetupToken) throw new Error("platform: Restore the encrypted authentication setup checkpoint before resuming.");
3060
+ dependencies.onProgress?.("Initializing platform authentication");
3061
+ if ((await resumeInstalledPlatformAuthentication({
3062
+ apiUrl: plan.apiUrl,
3063
+ installationId: manifest.id,
3064
+ setupToken: secrets.authSetupToken,
3065
+ setupJson: secrets.authenticationSetupJson,
3066
+ checkpoint: (replacement) => {
3067
+ secrets = {
3068
+ ...secrets,
3069
+ ...replacement
3070
+ };
3071
+ authenticationStep = "authentication-initialized:v1:" + hash(replacement.authenticationSetupJson);
3072
+ writePendingInstallationSecrets(manifest, secretCheckpoint(secrets, workerSecretUpdateIntent));
3073
+ }
3074
+ }, dependencies.initializeAuthentication ?? initializeInstalledPlatformAuthentication)).setupPending) {
3075
+ setupPending = true;
3076
+ dependencies.onProgress?.(`Administrator setup pending: ${plan.apiUrl}/setup`);
3077
+ }
3078
+ manifest = recordInstallationStep(manifest, {
3079
+ key: authenticationStep,
3080
+ completedAt: now(),
3081
+ resultHash: hash(secrets.authenticationSetupJson)
3082
+ }, now());
3083
+ writeInstallationManifest(manifest);
3084
+ await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
3085
+ }
2005
3086
  dependencies.onProgress?.("Verifying platform health");
3087
+ const emailGatewayUrl = hasEmailGatewayArtifact ? `https://${plan.deployment.resources.emailGatewayWorker}.${plan.workersSubdomain}.workers.dev` : void 0;
3088
+ if (plan.deployment.authentication.accessProtection) {
3089
+ ensurePlatformAccessToken(plan.apiUrl, false, true);
3090
+ ensurePlatformAccessToken(plan.proxyUrl, false, true);
3091
+ }
2006
3092
  const healthCheckContext = {
2007
3093
  accountName: plan.account.name,
2008
3094
  workers: [
2009
3095
  plan.deployment.resources.apiWorker,
2010
3096
  plan.deployment.resources.proxyWorker,
3097
+ ...hasEmailGatewayArtifact ? [plan.deployment.resources.emailGatewayWorker] : [],
2011
3098
  plan.deployment.resources.dispatchWorker
2012
3099
  ],
2013
- resumeCommand: dependencies.healthCheckResumeCommand
3100
+ resumeCommand: dependencies.healthCheckResumeCommand,
3101
+ ...emailGatewayUrl ? { emailGatewayUrl } : {}
2014
3102
  };
2015
3103
  await (dependencies.verifyHealth ?? verifyPlatformHealth)(plan.apiUrl, manifest.id, plan.proxyUrl, plan.deployment.application.baseDomain, version, targetUsageProtocolVersion, createPlatformHealthProbeToken(manifest.ownershipNonce), void 0, !plan.deployment.application.zoneName ? `https://${plan.deployment.resources.dispatchWorker}.${plan.workersSubdomain}.workers.dev` : void 0, healthCheckContext);
2016
3104
  if (requiresSandboxDrain) {
@@ -2039,11 +3127,13 @@ async function installPlatform(plan, secrets, existing, dependencies) {
2039
3127
  await writeSandboxDrainMirror(completedDrain);
2040
3128
  }
2041
3129
  if (requiresStagedCommit) {
2042
- manifest = recordWorkerCutoverResolution(manifest, workerCutoverGeneration, "committed", now());
2043
- manifest = recordWorkerTrafficState(manifest, workerCutoverGeneration, false, now());
2044
- writeInstallationManifest(manifest);
2045
- await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
2046
- workerRollbacks = [];
3130
+ if (!authenticationCutoverCommitted) {
3131
+ manifest = recordWorkerCutoverResolution(manifest, workerCutoverGeneration, "committed", now());
3132
+ manifest = recordWorkerTrafficState(manifest, workerCutoverGeneration, false, now());
3133
+ writeInstallationManifest(manifest);
3134
+ await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
3135
+ workerRollbacks = [];
3136
+ }
2047
3137
  workerCutoverGeneration = nextWorkerCutoverGeneration(manifest);
2048
3138
  if (workerSecretUpdateIntent) {
2049
3139
  manifest = recordWorkerSecretUpdateState(manifest, workerCutoverGeneration, "pending", workerSecretUpdateIntent.id, now());
@@ -2055,14 +3145,9 @@ async function installPlatform(plan, secrets, existing, dependencies) {
2055
3145
  };
2056
3146
  writePendingInstallationSecrets(manifest, secretCheckpoint(secrets, workerSecretUpdateIntent));
2057
3147
  }
2058
- for (const key of [
2059
- "api",
2060
- "proxy",
2061
- "tail",
2062
- "dispatch"
2063
- ]) {
3148
+ for (const key of workerKeys) {
2064
3149
  dependencies.onProgress?.(`Enabling the ${key} service`);
2065
- const action = workerActions.find((entry) => entry.key === `${key}-worker`);
3150
+ const action = workerActions.find((entry) => entry.key === (key === "emailGateway" ? "email-gateway-worker" : `${key}-worker`));
2066
3151
  const workerSecrets = platformWorkerSecretPayload(key, workerDeploymentSecrets, plan.account.id, zoneId, action.action !== "create");
2067
3152
  const preservedRoutes = dependencies.deployWorker ? [] : await inventoryUnmanagedPlatformWorkerTraffic(dependencies.client, plan, action.name);
2068
3153
  const workerConfig = mergePlatformWorkerTrafficConfig(enabledConfigs[key], preservedRoutes);
@@ -2116,9 +3201,20 @@ async function installPlatform(plan, secrets, existing, dependencies) {
2116
3201
  const verifyCurrentWorkerCutover = async () => {
2117
3202
  if (!requiresStagedCommit) return;
2118
3203
  if (dependencies.verifyWorkerCutover) await dependencies.verifyWorkerCutover(manifest, workerCutoverGeneration);
2119
- else if (!dependencies.deployWorker) await verifyPlatformWorkerCutoverVersions(manifest, workerCutoverGeneration, enabledConfigs, plan.account.id, runPinnedPlatformWrangler);
3204
+ else if (!dependencies.deployWorker) await verifyPlatformWorkerCutoverVersions(manifest, workerCutoverGeneration, enabledConfigs, plan.account.id, runPinnedPlatformWrangler, workerKeys);
2120
3205
  };
2121
3206
  await verifyCurrentWorkerCutover();
3207
+ let emailOperationId;
3208
+ if (shouldBootstrapSharedEmail) {
3209
+ workerDeploymentCommitted = true;
3210
+ manifest = recordWorkerCutoverResolution(manifest, workerCutoverGeneration, "committed", now());
3211
+ if (workerSecretUpdateIntent) manifest = recordWorkerSecretUpdateState(manifest, workerCutoverGeneration, "committed", workerSecretUpdateIntent.id, now());
3212
+ writeInstallationManifest(manifest);
3213
+ await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
3214
+ workerRollbacks = [];
3215
+ dependencies.onProgress?.("Preparing shared email routing");
3216
+ emailOperationId = await (dependencies.bootstrapEmail ?? bootstrapPlatformSharedEmail)(plan.apiUrl, createPlatformHealthProbeToken(manifest.ownershipNonce));
3217
+ }
2122
3218
  if (dependencies.client.deleteKvValue) await dependencies.client.deleteKvValue(plan.account.id, requireId(ids, "routing-kv"), PLATFORM_DISABLED_KEY);
2123
3219
  else if (wasDisabled || !previousPublicEndpoints) throw new Error("platform: Routing disablement removal is unavailable.");
2124
3220
  if (requiresStagedCommit) {
@@ -2132,6 +3228,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
2132
3228
  if (workerSecretUpdateIntent) manifest = recordWorkerSecretUpdateState(manifest, workerCutoverGeneration, "committed", workerSecretUpdateIntent.id, now());
2133
3229
  manifest = {
2134
3230
  ...manifest,
3231
+ deployment: plan.deployment,
2135
3232
  platformVersion: version,
2136
3233
  runtime: runtimeProvenance,
2137
3234
  safetyProtocols: runtime.safetyProtocols ?? { sandboxDrain: 0 },
@@ -2147,6 +3244,8 @@ async function installPlatform(plan, secrets, existing, dependencies) {
2147
3244
  saveInstalledPlatformConnection(plan, manifest);
2148
3245
  return {
2149
3246
  manifest,
3247
+ ...emailOperationId ? { emailOperationId } : {},
3248
+ setupPending,
2150
3249
  waitingForDns: false,
2151
3250
  waitingForSandboxDrain: false,
2152
3251
  zoneNameServers: zone?.nameServers ?? []
@@ -2159,7 +3258,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
2159
3258
  } catch (guardError) {
2160
3259
  failure = new Error(`platform: ${errorMessage(error)} The installation also failed to retain its routing safety gate: ${errorMessage(guardError)}`, { cause: error });
2161
3260
  }
2162
- if (!workerDeploymentCommitted && workerRollbacks.length > 0) {
3261
+ if (!workerDeploymentCommitted && workerRollbacks.length > 0 && !(authenticationEnforcementActive && authenticationProtocolTransition)) {
2163
3262
  const rollbackFailures = [];
2164
3263
  for (const rollback of workerRollbacks.reverse()) try {
2165
3264
  await rollback();
@@ -2173,7 +3272,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
2173
3272
  manifest = recordWorkerTrafficState(manifest, workerCutoverGeneration, previousPublicEndpoints, now());
2174
3273
  writeInstallationManifest(manifest);
2175
3274
  }
2176
- }
3275
+ } else if (authenticationEnforcementActive && authenticationProtocolTransition) workerRollbacks = [];
2177
3276
  if (manifest.status !== "failed" && manifest.status !== "uninstalled") try {
2178
3277
  manifest = transitionInstallation(manifest, "failed", now());
2179
3278
  writeInstallationManifest(manifest);
@@ -2184,6 +3283,9 @@ async function installPlatform(plan, secrets, existing, dependencies) {
2184
3283
  await releaseDatabaseLock();
2185
3284
  }
2186
3285
  }
3286
+ function checkpointAuthenticationResources(manifest, secrets, updatedAt) {
3287
+ return checkpointPlatformAccessResources(manifest, secrets.accessProvisioningJson, updatedAt);
3288
+ }
2187
3289
  async function writeR2OwnershipMarkerIfNeeded(client, plan, manifest, action) {
2188
3290
  if (action.kind !== "r2-bucket") return;
2189
3291
  const bucket = manifest.resources.find((resource) => resource.key === action.key && resource.state === "ready");
@@ -2424,8 +3526,10 @@ function apiWorkerSecrets(secrets, accountId, zoneId) {
2424
3526
  CF_ZONE_ID: zoneId,
2425
3527
  GITHUB_CLIENT_ID: secrets.githubClientId,
2426
3528
  GITHUB_CLIENT_SECRET: secrets.githubClientSecret,
3529
+ ...secrets.emailEnabled ? { EMAIL_SIGNING_SECRET: secrets.emailSigningSecret } : {},
2427
3530
  JWT_SECRET: secrets.jwtSecret,
2428
3531
  PLATFORM_BOOTSTRAP_GITHUB_ID: secrets.bootstrapGitHubId,
3532
+ PLATFORM_AUTH_SETUP_TOKEN: secrets.authSetupToken ?? "",
2429
3533
  ...secrets.projectSecretKeysJson && secrets.projectSecretActiveKeyVersion ? {
2430
3534
  PROJECT_SECRET_ACTIVE_KEY_VERSION: secrets.projectSecretActiveKeyVersion,
2431
3535
  PROJECT_SECRET_KEYS_JSON: secrets.projectSecretKeysJson
@@ -2436,15 +3540,23 @@ function apiWorkerSecrets(secrets, accountId, zoneId) {
2436
3540
  }
2437
3541
  /** Existing Workers inherit live secrets, including complete encryption keyrings. */
2438
3542
  function platformWorkerSecretPayload(worker, secrets, accountId, zoneId, existingWorker) {
2439
- if (worker !== "api" && worker !== "proxy") return null;
2440
- if (!existingWorker) return worker === "api" ? apiWorkerSecrets(secrets, accountId, zoneId) : proxyWorkerSecrets(secrets, accountId);
3543
+ if (worker !== "api" && worker !== "proxy" && worker !== "emailGateway") return null;
3544
+ if (!existingWorker) {
3545
+ if (worker === "api") return apiWorkerSecrets(secrets, accountId, zoneId);
3546
+ if (worker === "emailGateway") {
3547
+ const payload = withoutEmptySecrets({ EMAIL_SIGNING_SECRET: secrets.emailEnabled ? secrets.emailSigningSecret : "" });
3548
+ return Object.keys(payload).length > 0 ? payload : null;
3549
+ }
3550
+ return proxyWorkerSecrets(secrets, accountId);
3551
+ }
2441
3552
  const allowed = worker === "api" ? [
2442
3553
  "CF_API_TOKEN",
3554
+ "EMAIL_SIGNING_SECRET",
2443
3555
  "GITHUB_CLIENT_ID",
2444
3556
  "GITHUB_CLIENT_SECRET",
2445
3557
  "R2_ACCESS_KEY_ID",
2446
3558
  "R2_SECRET_ACCESS_KEY"
2447
- ] : ["CF_API_TOKEN"];
3559
+ ] : worker === "proxy" ? ["CF_API_TOKEN", "EMAIL_SIGNING_SECRET"] : ["EMAIL_SIGNING_SECRET"];
2448
3560
  const updates = Object.fromEntries(Object.entries(secrets.workerSecretUpdates ?? {}).filter(([name, value]) => allowed.includes(name) && typeof value === "string" && value !== ""));
2449
3561
  return Object.keys(updates).length > 0 ? updates : null;
2450
3562
  }
@@ -2469,26 +3581,34 @@ function proxyWorkerSecrets(secrets, accountId) {
2469
3581
  return withoutEmptySecrets({
2470
3582
  CF_ACCOUNT_ID: accountId,
2471
3583
  CF_API_TOKEN: secrets.cloudflareApiToken,
3584
+ ...secrets.emailEnabled ? { EMAIL_SIGNING_SECRET: secrets.emailSigningSecret } : {},
2472
3585
  JWT_SECRET: secrets.jwtSecret
2473
3586
  });
2474
3587
  }
2475
3588
  function withoutEmptySecrets(secrets) {
2476
3589
  return Object.fromEntries(Object.entries(secrets).filter(([, value]) => value !== ""));
2477
3590
  }
2478
- function assertRequiredPlatformSecrets(plan, manifest, secrets) {
3591
+ function assertRequiredPlatformSecrets(plan, manifest, secrets, authenticationWillBePrepared = false) {
2479
3592
  const missing = /* @__PURE__ */ new Set();
2480
3593
  const requireSecret = (value, name) => {
2481
3594
  if (!value) missing.add(name);
2482
3595
  };
2483
- if (!manifest.steps.some((step) => step.key === "bootstrap-access:v1")) {
3596
+ if (plan.deployment.authentication.configurable && !manifest.steps.some((step) => step.key.startsWith("authentication-initialized:v1:"))) {
3597
+ if (!authenticationWillBePrepared) requireSecret(secrets.authenticationSetupJson ?? "", "authentication setup configuration");
3598
+ requireSecret(secrets.authSetupToken ?? "", "installation authentication setup authority");
3599
+ requireSecret(secrets.authBootstrapCode ?? "", "administrator setup code");
3600
+ } else if (!plan.deployment.authentication.configurable && !manifest.steps.some((step) => step.key === "bootstrap-access:v1")) {
2484
3601
  requireSecret(secrets.bootstrapGitHubId, "VOID_PLATFORM_ADMIN_GITHUB_ID");
2485
3602
  requireSecret(secrets.bootstrapGitHubLogin, "VOID_PLATFORM_ADMIN_GITHUB_LOGIN");
2486
3603
  }
2487
3604
  if (plan.actions.some((action) => action.key === "api-worker" && action.kind === "worker" && action.action === "create")) {
2488
3605
  requireSecret(secrets.cloudflareApiToken, "VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN");
2489
- requireSecret(secrets.githubClientId, "VOID_PLATFORM_GITHUB_CLIENT_ID");
2490
- requireSecret(secrets.githubClientSecret, "VOID_PLATFORM_GITHUB_CLIENT_SECRET");
3606
+ if (!plan.deployment.authentication.configurable) {
3607
+ requireSecret(secrets.githubClientId, "VOID_PLATFORM_GITHUB_CLIENT_ID");
3608
+ requireSecret(secrets.githubClientSecret, "VOID_PLATFORM_GITHUB_CLIENT_SECRET");
3609
+ }
2491
3610
  requireSecret(secrets.jwtSecret, "VOID_PLATFORM_JWT_SECRET");
3611
+ if (plan.deployment.capabilities.email) requireSecret(secrets.emailSigningSecret, "VOID_PLATFORM_EMAIL_SIGNING_SECRET");
2492
3612
  requireSecret(secrets.projectSecretKeysJson, "VOID_PLATFORM_PROJECT_SECRET_KEYS_JSON (or VOID_PLATFORM_PROJECT_SECRET_KEY for v1)");
2493
3613
  requireSecret(secrets.projectSecretActiveKeyVersion, "VOID_PLATFORM_PROJECT_SECRET_ACTIVE_KEY_VERSION (or VOID_PLATFORM_PROJECT_SECRET_KEY for v1)");
2494
3614
  requireSecret(secrets.r2AccessKeyId, "VOID_PLATFORM_R2_ACCESS_KEY_ID");
@@ -2497,6 +3617,10 @@ function assertRequiredPlatformSecrets(plan, manifest, secrets) {
2497
3617
  if (plan.actions.some((action) => action.key === "proxy-worker" && action.kind === "worker" && action.action === "create")) {
2498
3618
  requireSecret(secrets.cloudflareApiToken, "VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN");
2499
3619
  requireSecret(secrets.jwtSecret, "VOID_PLATFORM_JWT_SECRET");
3620
+ if (plan.deployment.capabilities.email) requireSecret(secrets.emailSigningSecret, "VOID_PLATFORM_EMAIL_SIGNING_SECRET");
3621
+ }
3622
+ if (plan.actions.some((action) => action.key === "email-gateway-worker" && action.kind === "worker" && action.action === "create")) {
3623
+ if (plan.deployment.capabilities.email) requireSecret(secrets.emailSigningSecret, "VOID_PLATFORM_EMAIL_SIGNING_SECRET");
2500
3624
  }
2501
3625
  if (missing.size > 0) throw new Error(`platform: Creating a missing platform Worker requires: ${[...missing].sort().join(", ")}. Restore these values from your secret manager and retry.`);
2502
3626
  }
@@ -2681,6 +3805,17 @@ async function acquirePlatformDatabaseLifecycleLockConnection(client, installati
2681
3805
  await client.query("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [lockIdentity]);
2682
3806
  };
2683
3807
  release.authoritativeManifest = authoritativeManifest;
3808
+ release.readAuthenticationState = async () => {
3809
+ if (!(await client.query("SELECT to_regclass('public.platform_auth_settings') IS NOT NULL AS present")).rows[0]?.present) return null;
3810
+ const [result, enabledProviders] = await Promise.all([client.query("SELECT sessions_required, protection FROM platform_auth_settings WHERE singleton = true"), client.query("SELECT DISTINCT kind FROM platform_login_connections WHERE enabled = true ORDER BY kind")]);
3811
+ const settings = result.rows[0];
3812
+ return settings ? {
3813
+ enabledProviders: enabledProviders.rows.map((row) => row.kind),
3814
+ sessionsRequired: settings.sessions_required,
3815
+ protectedByAccess: settings.protection !== null,
3816
+ protection: parsePlatformAccessProtection(settings.protection)
3817
+ } : null;
3818
+ };
2684
3819
  release.requiredSafetyProtocols = await readRequiredPlatformSafetyProtocols(client);
2685
3820
  release.checkpoint = async (manifest) => {
2686
3821
  if (released) throw new Error("platform: Cannot checkpoint lifecycle state after releasing its lock.");
@@ -2711,14 +3846,14 @@ async function preparePlatformSandboxDrainConnection(client, requiredAdmissionPr
2711
3846
  AND table_name = 'sandbox_drain_checkpoints'
2712
3847
  AND column_name = 'admission_protocol'
2713
3848
  ) AS "present"`)).rows[0]?.present === true;
2714
- if (requiredAdmissionProtocol === 2 && !hasAdmissionColumn) throw new Error("platform: The runtime database does not include the sandbox deployment admission barrier.");
3849
+ if (requiredAdmissionProtocol >= 2 && !hasAdmissionColumn) throw new Error("platform: The runtime database does not include the sandbox deployment admission barrier.");
2715
3850
  const readCheckpoint = () => client.query(`SELECT ${hasAdmissionColumn ? "\"admission_protocol\"" : "0 AS \"admission_protocol\""}, "generation", "state", "phase", "cursor", "started_at", "updated_at", "completed_at"
2716
3851
  FROM "sandbox_drain_checkpoints"
2717
3852
  WHERE "singleton" = true`);
2718
3853
  const classify = (row) => {
2719
3854
  if (!row) return null;
2720
3855
  const cursor = row.cursor;
2721
- if (!row.generation || row.admission_protocol !== 0 && row.admission_protocol !== 2 || !row.started_at || !row.updated_at || row.state !== "draining" && row.state !== "complete" || row.phase !== "deployments" && row.phase !== "resources" || !cursor || cursor.pass !== "1" && cursor.pass !== "2" || row.state === "complete" && (!row.completed_at || row.phase !== "resources" || cursor.pass !== "2") || row.state === "draining" && row.completed_at !== null) throw new Error("platform: The authoritative sandbox-drain database checkpoint is invalid. Traffic remains disabled.");
3856
+ if (!row.generation || row.admission_protocol !== 0 && row.admission_protocol !== 2 && row.admission_protocol !== 3 || !row.started_at || !row.updated_at || row.state !== "draining" && row.state !== "complete" || row.phase !== "deployments" && row.phase !== "resources" || !cursor || cursor.pass !== "1" && cursor.pass !== "2" || row.state === "complete" && (!row.completed_at || row.phase !== "resources" || cursor.pass !== "2") || row.state === "draining" && row.completed_at !== null) throw new Error("platform: The authoritative sandbox-drain database checkpoint is invalid. Traffic remains disabled.");
2722
3857
  return {
2723
3858
  admissionProtocol: row.admission_protocol,
2724
3859
  generation: row.generation,
@@ -2735,7 +3870,23 @@ async function preparePlatformSandboxDrainConnection(client, requiredAdmissionPr
2735
3870
  };
2736
3871
  const existingState = classify((await readCheckpoint()).rows[0]);
2737
3872
  if (existingState && (requiredAdmissionProtocol === 0 || existingState.admissionProtocol >= requiredAdmissionProtocol)) return existingState;
2738
- if (requiredAdmissionProtocol === 2) {
3873
+ if (requiredAdmissionProtocol >= 2) {
3874
+ if (existingState && existingState.admissionProtocol >= 2) {
3875
+ await client.query("BEGIN");
3876
+ try {
3877
+ await client.query("LOCK TABLE \"deployments\" IN SHARE MODE");
3878
+ await client.query(`UPDATE "sandbox_drain_checkpoints"
3879
+ SET "admission_protocol" = GREATEST("admission_protocol", $1), "updated_at" = now()
3880
+ WHERE "singleton" = true`, [requiredAdmissionProtocol]);
3881
+ const advanced = classify((await readCheckpoint()).rows[0]);
3882
+ if (!advanced || advanced.admissionProtocol < requiredAdmissionProtocol) throw new Error("platform: The Sandbox controller admission barrier could not be armed.");
3883
+ await client.query("COMMIT");
3884
+ return advanced;
3885
+ } catch (error) {
3886
+ await client.query("ROLLBACK").catch(() => void 0);
3887
+ throw error;
3888
+ }
3889
+ }
2739
3890
  const generation = randomUUID();
2740
3891
  await client.query("BEGIN");
2741
3892
  try {
@@ -2768,8 +3919,10 @@ async function preparePlatformSandboxDrainConnection(client, requiredAdmissionPr
2768
3919
  WHERE "singleton" = true
2769
3920
  AND "admission_protocol" = 2
2770
3921
  AND "generation" = $1`, [generation]);
3922
+ if (requiredAdmissionProtocol >= 3) await client.query(`UPDATE "sandbox_drain_checkpoints" SET "admission_protocol" = $1
3923
+ WHERE "singleton" = true`, [requiredAdmissionProtocol]);
2771
3924
  const armed = classify((await readCheckpoint()).rows[0]);
2772
- if (!armed || armed.admissionProtocol !== 2) throw new Error("platform: The sandbox deployment admission barrier could not be armed.");
3925
+ if (!armed || armed.admissionProtocol < requiredAdmissionProtocol) throw new Error("platform: The sandbox deployment admission barrier could not be armed.");
2773
3926
  await client.query("COMMIT");
2774
3927
  return armed;
2775
3928
  } catch (error) {
@@ -2812,12 +3965,17 @@ async function readRequiredPlatformSafetyProtocols(client) {
2812
3965
  AND table_name = 'sandbox_drain_checkpoints'
2813
3966
  AND column_name = 'admission_protocol'
2814
3967
  ) AS "present"`)).rows[0]?.present === true) {
2815
- if ((await client.query(`SELECT EXISTS(
3968
+ const admission = await client.query(`SELECT EXISTS(
2816
3969
  SELECT 1
2817
3970
  FROM "sandbox_drain_checkpoints"
2818
3971
  WHERE "singleton" = true
2819
3972
  AND "admission_protocol" >= 2
2820
- ) AS "required"`)).rows[0]?.required === true) return { sandboxDrain: 2 };
3973
+ ) AS "required", (SELECT "admission_protocol" FROM "sandbox_drain_checkpoints"
3974
+ WHERE "singleton" = true) AS "protocol"`);
3975
+ if (admission.rows[0]?.required === true) {
3976
+ if (admission.rows[0].protocol != null && admission.rows[0].protocol > 3) throw new Error("platform: The database requires a newer Sandbox controller protocol.");
3977
+ return { sandboxDrain: admission.rows[0].protocol === 3 ? 3 : 2 };
3978
+ }
2821
3979
  }
2822
3980
  return { sandboxDrain: (await client.query(`SELECT EXISTS(
2823
3981
  SELECT 1
@@ -3582,7 +4740,7 @@ function interruptedWorkerCutover(manifest) {
3582
4740
  generations.set(generation, entry);
3583
4741
  continue;
3584
4742
  }
3585
- const unsafeTraffic = /^worker-cutover:(\d+):unsafe-traffic:(api|proxy|tail|dispatch)$/.exec(step.key);
4743
+ const unsafeTraffic = /^worker-cutover:(\d+):unsafe-traffic:(api|proxy|emailGateway|tail|dispatch)$/.exec(step.key);
3586
4744
  if (unsafeTraffic) {
3587
4745
  const generation = Number(unsafeTraffic[1]);
3588
4746
  const entry = generations.get(generation) ?? {
@@ -3594,7 +4752,7 @@ function interruptedWorkerCutover(manifest) {
3594
4752
  generations.set(generation, entry);
3595
4753
  continue;
3596
4754
  }
3597
- const staged = /^worker-cutover:(\d+):staged:(api|proxy|tail|dispatch)$/.exec(step.key);
4755
+ const staged = /^worker-cutover:(\d+):staged:(api|proxy|emailGateway|tail|dispatch)$/.exec(step.key);
3598
4756
  if (!staged) continue;
3599
4757
  const generation = Number(staged[1]);
3600
4758
  const snapshot = parseWorkerCutoverSnapshot(step.resultHash);
@@ -3614,17 +4772,18 @@ function interruptedWorkerCutover(manifest) {
3614
4772
  unsafeTraffic: unresolved[1].unsafeTraffic
3615
4773
  } : null;
3616
4774
  }
3617
- async function verifyPlatformWorkerCutoverVersions(manifest, generation, configs, accountId, runner = runPlatformWrangler) {
4775
+ async function verifyPlatformWorkerCutoverVersions(manifest, generation, configs, accountId, runner = runPlatformWrangler, workerKeys = [
4776
+ "api",
4777
+ "proxy",
4778
+ "emailGateway",
4779
+ "tail",
4780
+ "dispatch"
4781
+ ]) {
3618
4782
  const cutover = interruptedWorkerCutover(manifest);
3619
- if (cutover?.generation !== generation || cutover.staged.size !== 4) throw new Error(`platform: Worker cutover generation ${generation} is missing a complete immutable-version checkpoint.`);
4783
+ if (cutover?.generation !== generation || cutover.staged.size !== workerKeys.length) throw new Error(`platform: Worker cutover generation ${generation} is missing a complete immutable-version checkpoint.`);
3620
4784
  const directory = mkdtempSync(join(tmpdir(), "void-platform-cutover-verify-"));
3621
4785
  try {
3622
- for (const key of [
3623
- "api",
3624
- "proxy",
3625
- "tail",
3626
- "dispatch"
3627
- ]) {
4786
+ for (const key of workerKeys) {
3628
4787
  const snapshot = cutover.staged.get(key);
3629
4788
  if (!snapshot) throw new Error(`platform: Worker cutover generation ${generation} is missing its ${key} checkpoint.`);
3630
4789
  const config = configs[key];
@@ -3691,7 +4850,7 @@ var PlatformAccessBlockedError = class extends Error {
3691
4850
  "Void’s own authentication stays enabled. Keep account-wide Default-Deny on.",
3692
4851
  "",
3693
4852
  "For platforms that must remain Access-protected:",
3694
- "https://void.cloud/guide/self-hosted-platform#cloudflare-access"
4853
+ "https://void.cloud/guide/platform/installation/maintenance#cloudflare-access"
3695
4854
  ].join("\n"));
3696
4855
  }
3697
4856
  };
@@ -3713,11 +4872,12 @@ async function verifyPlatformHealth(apiUrl, installationId, proxyUrl, applicatio
3713
4872
  signal: AbortSignal.timeout(3e4)
3714
4873
  });
3715
4874
  const dispatchUrl = dispatchOrigin ?? `https://test.${applicationBaseDomain}`;
3716
- const [health, discovery, proxy, dispatch] = await Promise.all([
4875
+ const [health, discovery, proxy, dispatch, emailGateway] = await Promise.all([
3717
4876
  request(`${apiUrl}/health`),
3718
4877
  request(`${apiUrl}/.well-known/void-platform`),
3719
4878
  request(`${proxyUrl}/health`),
3720
- request(`${dispatchUrl}/health`)
4879
+ request(`${dispatchUrl}/health`),
4880
+ context?.emailGatewayUrl ? request(`${context.emailGatewayUrl}/health`) : void 0
3721
4881
  ]);
3722
4882
  if (!health.ok) throw await platformProbeFailure("Health check", apiUrl, health, context);
3723
4883
  if (!discovery.ok) throw await platformProbeFailure("Discovery check", apiUrl, discovery, context);
@@ -3727,6 +4887,11 @@ async function verifyPlatformHealth(apiUrl, installationId, proxyUrl, applicatio
3727
4887
  if ((body.usageProtocolVersion ?? 0) !== usageProtocolVersion) throw new Error("platform: Deployed API usage protocol does not match this runtime. Traffic remains disabled.");
3728
4888
  if (!proxy.ok) throw await platformProbeFailure("Proxy health check", proxyUrl, proxy, context);
3729
4889
  if (!dispatch.ok) throw await platformProbeFailure("Dispatch health check", dispatchUrl, dispatch, context);
4890
+ if (emailGateway) {
4891
+ if (!emailGateway.ok) throw await platformProbeFailure("Email gateway health check", context.emailGatewayUrl, emailGateway, context);
4892
+ const identity = await emailGateway.json();
4893
+ if (identity?.installationId !== installationId || identity.platformVersion !== platformVersion || identity.emailProtocolVersion !== 1) throw new Error("platform: Deployed email gateway identity, version, or protocol does not match this installation.");
4894
+ }
3730
4895
  }
3731
4896
  async function verifyPlatformSandboxDrain(apiUrl, healthProbeToken, fetcher = fetch) {
3732
4897
  const response = await fetcher(`${apiUrl}/internal/platform/sandbox-drain`, {
@@ -3734,7 +4899,8 @@ async function verifyPlatformSandboxDrain(apiUrl, healthProbeToken, fetcher = fe
3734
4899
  "X-Void-Platform-Probe": healthProbeToken,
3735
4900
  ...cfAccessHeaders(process.env, apiUrl)
3736
4901
  },
3737
- signal: AbortSignal.timeout(3e4)
4902
+ signal: AbortSignal.timeout(3e4),
4903
+ redirect: "manual"
3738
4904
  });
3739
4905
  if (!response.ok) throw new Error(`platform: Sandbox drain probe failed at ${apiUrl} (HTTP ${response.status}). Traffic remains disabled.`);
3740
4906
  let value;
@@ -3750,10 +4916,268 @@ async function verifyPlatformSandboxDrain(apiUrl, healthProbeToken, fetcher = fe
3750
4916
  if (status.version !== 1 || !validState || typeof status.complete !== "boolean" || !validPhase || status.generation !== null && (typeof status.generation !== "string" || status.generation.length === 0) || status.updatedAt !== null && (typeof status.updatedAt !== "string" || !Number.isFinite(Date.parse(status.updatedAt))) || status.state === "required" && (status.complete || status.generation !== null || status.updatedAt !== null || status.phase !== null) || status.state === "draining" && (status.complete || typeof status.generation !== "string" || typeof status.updatedAt !== "string" || status.phase === null) || status.state === "complete" && (!status.complete || typeof status.generation !== "string" || typeof status.updatedAt !== "string" || status.phase === null)) throw new Error("platform: Sandbox drain probe returned an invalid DB-backed protocol v1 document. Traffic remains disabled.");
3751
4917
  return status.state === "complete";
3752
4918
  }
4919
+ async function bootstrapPlatformSharedEmail(apiUrl, healthProbeToken, fetcher = fetch) {
4920
+ const response = await fetcher(`${apiUrl}/internal/platform/email-bootstrap`, {
4921
+ method: "POST",
4922
+ headers: {
4923
+ "X-Void-Platform-Probe": healthProbeToken,
4924
+ ...cfAccessHeaders(process.env, apiUrl)
4925
+ },
4926
+ signal: AbortSignal.timeout(3e4),
4927
+ redirect: "manual"
4928
+ });
4929
+ let value;
4930
+ try {
4931
+ value = await response.json();
4932
+ } catch {
4933
+ throw new Error(`platform: Shared email bootstrap failed at ${apiUrl} (HTTP ${response.status}). Traffic remains disabled.`);
4934
+ }
4935
+ const status = value && typeof value === "object" && !Array.isArray(value) ? value : null;
4936
+ if (!response.ok || status?.version !== 1 || status.state !== "ready" || typeof status.operationId !== "string" || status.operationId.length === 0) {
4937
+ const detail = status && typeof status.operationId === "string" ? ` Operation ${status.operationId}: ${typeof status.operationStatus === "string" ? status.operationStatus : "incomplete"}.` : status && typeof status.operationStatus === "string" ? ` Operation status: ${status.operationStatus}.` : "";
4938
+ throw new Error(`platform: Shared email bootstrap did not reach inbound readiness at ${apiUrl} (HTTP ${response.status}).${detail} Traffic remains disabled.`);
4939
+ }
4940
+ return status.operationId;
4941
+ }
3753
4942
  function createPlatformHealthProbeToken(ownershipNonce) {
3754
4943
  return createHash("sha256").update("void-platform-health-probe:v1\0").update(ownershipNonce).digest("base64url");
3755
4944
  }
3756
4945
  //#endregion
4946
+ //#region src/cli/platform-install-auth.ts
4947
+ function answer(value) {
4948
+ if (isCancel(value) || typeof value === "symbol") throw new Error("platform: Cancelled.");
4949
+ return value;
4950
+ }
4951
+ async function selectInstallationAuthentication(file, recorded) {
4952
+ if (file) {
4953
+ const input = JSON.parse(await readFile(file, "utf8"));
4954
+ if (!input || typeof input !== "object" || Array.isArray(input) || !("connections" in input) || !Array.isArray(input.connections) || !input.connections.length || input.connections.length > 32) throw new Error("platform: An authentication file must define between 1 and 32 connections.");
4955
+ const entries = input.connections;
4956
+ if (entries.some((entry) => !entry || typeof entry !== "object" || !entry.configuration || typeof entry.configuration !== "object" || Array.isArray(entry.configuration) || !PLATFORM_LOGIN_KINDS.includes(entry.configuration.kind) || entry.clientSecret !== void 0 || entry.configuration.clientSecret !== void 0)) throw new Error("platform: Each connection needs a supported configuration and a clientSecretEnv reference; plaintext secrets are not accepted in the file.");
4957
+ const selected = input;
4958
+ if (selected.cloudflareAccess !== void 0) selected.cloudflareAccess = parsePlatformAccessSetupSpec(selected.cloudflareAccess);
4959
+ const authentication = {
4960
+ providers: [...new Set(entries.map((entry) => entry.configuration.kind))],
4961
+ configurable: true,
4962
+ ...selected.protection ? { accessProtection: true } : {}
4963
+ };
4964
+ if (recorded && JSON.stringify(authentication) !== JSON.stringify(recorded)) throw new Error("platform: Resume with the recorded login methods and protection settings.");
4965
+ return {
4966
+ authentication,
4967
+ file: selected
4968
+ };
4969
+ }
4970
+ if (recorded) return { authentication: recorded };
4971
+ if (!process.stdin.isTTY) return { authentication: { providers: ["github"] } };
4972
+ const providers = answer(await multiselect({
4973
+ message: "Platform login methods:",
4974
+ initialValues: ["github"],
4975
+ required: true,
4976
+ options: [
4977
+ {
4978
+ value: "github",
4979
+ label: "GitHub"
4980
+ },
4981
+ {
4982
+ value: "google",
4983
+ label: "Google"
4984
+ },
4985
+ {
4986
+ value: "oidc",
4987
+ label: "Generic OIDC"
4988
+ },
4989
+ {
4990
+ value: "cloudflare-access",
4991
+ label: "Cloudflare Access login"
4992
+ }
4993
+ ]
4994
+ }));
4995
+ const protection = answer(await confirm({
4996
+ message: "Also protect the platform UI and APIs with Cloudflare Access?",
4997
+ initialValue: false
4998
+ }));
4999
+ return { authentication: {
5000
+ providers,
5001
+ ...protection || providers.length !== 1 || providers[0] !== "github" ? { configurable: true } : {},
5002
+ ...protection ? { accessProtection: true } : {}
5003
+ } };
5004
+ }
5005
+ async function collectInstallationAuthentication(selection, plan, checkpoint, save, access) {
5006
+ if (!selection.authentication.configurable) return;
5007
+ activatePlatformAccessCredentials(checkpoint.accessCredentialsJson, [plan.apiUrl, plan.proxyUrl]);
5008
+ if (checkpoint.authenticationSetupJson && !selection.file) {
5009
+ parsePlatformAuthenticationSetup(JSON.parse(checkpoint.authenticationSetupJson));
5010
+ return {
5011
+ authenticationSetupJson: checkpoint.authenticationSetupJson,
5012
+ authSetupToken: checkpoint.authSetupToken ?? "",
5013
+ authBootstrapCode: checkpoint.authBootstrapCode ?? ""
5014
+ };
5015
+ }
5016
+ const prompt = async (message, initialValue = "", masked = false) => {
5017
+ if (!process.stdin.isTTY) throw new Error(`platform: ${message} Supply it through --auth-config and environment references.`);
5018
+ return masked ? answer(await password({ message })) : answer(await text$1({
5019
+ message,
5020
+ initialValue
5021
+ }));
5022
+ };
5023
+ const entries = selection.file?.connections ?? selection.authentication.providers.map((kind) => ({
5024
+ configuration: {
5025
+ kind,
5026
+ id: kind === "oidc" ? "company" : kind === "cloudflare-access" ? "access" : kind,
5027
+ label: kind === "github" ? "GitHub" : kind === "google" ? "Google" : kind === "oidc" ? "Company SSO" : "Cloudflare Access"
5028
+ },
5029
+ clientSecretEnv: void 0
5030
+ }));
5031
+ const connections = [];
5032
+ const accessEntry = entries.find((entry) => entry.configuration.kind === "cloudflare-access");
5033
+ const prepared = access && (selection.authentication.accessProtection || accessEntry && (!selection.file || selection.file.cloudflareAccess)) ? await preparePlatformAccessSetup({
5034
+ plan,
5035
+ ownershipNonce: access.ownershipNonce,
5036
+ protection: !!selection.authentication.accessProtection,
5037
+ ...accessEntry ? { login: {
5038
+ id: String(accessEntry.configuration.id ?? "access"),
5039
+ label: String(accessEntry.configuration.label ?? "Cloudflare Access"),
5040
+ ...Array.isArray(accessEntry.configuration.requiredGroups) ? { requiredGroups: accessEntry.configuration.requiredGroups } : {}
5041
+ } } : {},
5042
+ spec: selection.file?.cloudflareAccess,
5043
+ client: access.client,
5044
+ checkpoint,
5045
+ save,
5046
+ yes: access.yes
5047
+ }) : void 0;
5048
+ console.log(`Login callback URL: ${plan.apiUrl}/auth/callback`);
5049
+ for (const entry of entries) {
5050
+ const config = { ...entry.configuration };
5051
+ const kind = config.kind;
5052
+ if (!config.id) config.id = kind === "oidc" ? "company" : kind === "cloudflare-access" ? "access" : kind;
5053
+ if (prepared?.login && prepared.login.configuration.id === config.id) {
5054
+ const connection = prepared.login;
5055
+ save({ [`authenticationConnection:${connection.configuration.id}`]: JSON.stringify(connection) });
5056
+ connections.push(connection);
5057
+ continue;
5058
+ }
5059
+ const partialKey = `authenticationConnection:${String(config.id)}`;
5060
+ const stored = checkpoint[partialKey] ? JSON.parse(checkpoint[partialKey]) : void 0;
5061
+ Object.assign(config, {
5062
+ ...stored?.configuration,
5063
+ ...config
5064
+ });
5065
+ if (!config.label) config.label = kind;
5066
+ if ((kind === "oidc" || kind === "cloudflare-access") && !config.issuer) config.issuer = await prompt(`${config.label}: issuer URL:`);
5067
+ if (!config.clientId) config.clientId = await prompt(`${config.label}: client ID:`);
5068
+ if (kind === "google" && config.allowedDomains === void 0 && !selection.file) config.allowedDomains = (await prompt("Allowed Google Workspace domains (comma-separated, or blank):")).split(",").map((value) => value.trim()).filter(Boolean);
5069
+ const parsed = parsePlatformLoginConfiguration(config);
5070
+ let secret;
5071
+ if (entry.clientSecretEnv) {
5072
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(entry.clientSecretEnv)) throw new Error("platform: Invalid clientSecretEnv reference.");
5073
+ secret = process.env[entry.clientSecretEnv];
5074
+ if (!secret) throw new Error(`platform: ${entry.clientSecretEnv} must contain the login client secret.`);
5075
+ } else if (stored?.clientSecret && stored.configuration?.clientId === parsed.clientId && stored.configuration?.issuer === parsed.issuer) secret = stored.clientSecret;
5076
+ else secret = await prompt(`${config.label}: client secret:`, "", true);
5077
+ const connection = {
5078
+ configuration: parsed,
5079
+ clientSecret: secret
5080
+ };
5081
+ save({ [partialKey]: JSON.stringify(connection) });
5082
+ connections.push(connection);
5083
+ }
5084
+ const administratorConnectionId = selection.file?.administratorConnectionId ?? (connections.length === 1 ? connections[0].configuration.id : process.stdin.isTTY ? answer(await select({
5085
+ message: "Login method for the first administrator:",
5086
+ options: connections.map((entry) => ({
5087
+ value: entry.configuration.id,
5088
+ label: entry.configuration.label
5089
+ }))
5090
+ })) : connections[0].configuration.id);
5091
+ let protection = prepared?.protection ?? selection.file?.protection ?? null;
5092
+ if (selection.authentication.accessProtection && !protection) {
5093
+ const teamDomain = await prompt("Access team origin (https://team.cloudflareaccess.com):");
5094
+ const apiAudience = await prompt(`Access application audience for ${plan.apiUrl}:`);
5095
+ const proxyAudience = await prompt(`Access application audience for ${plan.proxyUrl}:`, apiAudience);
5096
+ protection = {
5097
+ teamDomain,
5098
+ applications: [{
5099
+ origin: plan.apiUrl,
5100
+ audience: apiAudience
5101
+ }, {
5102
+ origin: plan.proxyUrl,
5103
+ audience: proxyAudience
5104
+ }]
5105
+ };
5106
+ }
5107
+ protection = parsePlatformAccessProtection(protection);
5108
+ let admissionInput = selection.file?.admission ?? (protection ? {
5109
+ mode: "company",
5110
+ access: true,
5111
+ connections: []
5112
+ } : { mode: "restricted" });
5113
+ if (!selection.file && !protection && process.stdin.isTTY) {
5114
+ const eligible = connections.filter((entry) => entry.configuration.kind !== "github" && (entry.configuration.kind !== "google" || entry.configuration.allowedDomains.length > 0));
5115
+ const mode = answer(await select({
5116
+ message: "Who can create an account?",
5117
+ initialValue: eligible.length ? "company" : "restricted",
5118
+ options: [
5119
+ {
5120
+ value: "restricted",
5121
+ label: "Invited or allowlisted users"
5122
+ },
5123
+ ...eligible.length ? [{
5124
+ value: "company",
5125
+ label: "Users approved by company login methods — automatic accounts"
5126
+ }] : [],
5127
+ {
5128
+ value: "public",
5129
+ label: "Anyone who completes an enabled login"
5130
+ }
5131
+ ]
5132
+ }));
5133
+ const ids = mode === "company" ? answer(await multiselect({
5134
+ message: "Company login methods that qualify new users:",
5135
+ required: true,
5136
+ initialValues: eligible.map((entry) => entry.configuration.id),
5137
+ options: eligible.map((entry) => ({
5138
+ value: entry.configuration.id,
5139
+ label: entry.configuration.label
5140
+ }))
5141
+ })) : [];
5142
+ admissionInput = mode === "company" ? {
5143
+ mode,
5144
+ connections: ids,
5145
+ access: false
5146
+ } : { mode };
5147
+ }
5148
+ const admission = parsePlatformAdmissionPolicy(admissionInput);
5149
+ let authBootstrapCode = checkpoint.authBootstrapCode || randomBytes(32).toString("hex");
5150
+ const authSetupToken = checkpoint.authSetupToken || randomBytes(32).toString("hex");
5151
+ let setup = parsePlatformAuthenticationSetup({
5152
+ connections,
5153
+ administratorConnectionId,
5154
+ admission,
5155
+ protection,
5156
+ bootstrapCredentialHash: createHash("sha256").update(authBootstrapCode).digest("hex")
5157
+ });
5158
+ if (selection.file && checkpoint.authenticationSetupJson) {
5159
+ const previous = parsePlatformAuthenticationSetup(JSON.parse(checkpoint.authenticationSetupJson));
5160
+ if (JSON.stringify({
5161
+ ...previous,
5162
+ resetPending: void 0
5163
+ }) !== JSON.stringify(setup)) {
5164
+ authBootstrapCode = randomBytes(32).toString("hex");
5165
+ setup = parsePlatformAuthenticationSetup({
5166
+ ...setup,
5167
+ resetPending: true,
5168
+ bootstrapCredentialHash: createHash("sha256").update(authBootstrapCode).digest("hex")
5169
+ });
5170
+ }
5171
+ }
5172
+ const result = {
5173
+ authenticationSetupJson: JSON.stringify(setup),
5174
+ authBootstrapCode,
5175
+ authSetupToken
5176
+ };
5177
+ save(result);
5178
+ return result;
5179
+ }
5180
+ //#endregion
3757
5181
  //#region src/cli/platform-setup-draft.ts
3758
5182
  const STARTED = "credential-setup:v1";
3759
5183
  const COMPLETED = "credential-setup-complete:v1";
@@ -3808,9 +5232,9 @@ function platformSetupLink(page, hyperlinks = supportsSetupLinks()) {
3808
5232
  return hyperlinks ? "\x1B]8;;" + page.url + "\x1B\\" + page.title + "\x1B]8;;\x1B\\" : page.title + ": " + page.url;
3809
5233
  }
3810
5234
  function platformSetupFieldHelp(key) {
3811
- const docs = (section, title) => platformSetupLink({
5235
+ const docs = (page, section, title) => platformSetupLink({
3812
5236
  title,
3813
- url: "https://void.cloud/guide/self-hosted-platform#" + section
5237
+ url: `https://void.cloud/guide/platform/installation/${page}#${section}`
3814
5238
  });
3815
5239
  switch (key) {
3816
5240
  case "databaseUrl": return {
@@ -3818,10 +5242,10 @@ function platformSetupFieldHelp(key) {
3818
5242
  description: "Stores platform users, projects, and deployments; use an empty hosted database, e.g. " + platformSetupLink({
3819
5243
  title: "Neon",
3820
5244
  url: "https://neon.com/"
3821
- }) + ". " + docs("platform-database", "Database setup")
5245
+ }) + ". " + docs("prerequisites", "platform-database", "Database setup")
3822
5246
  };
3823
- case "jwtSecret": return { description: "Signs platform login tokens. Run openssl rand -base64 32; save the key in your password manager. " + docs("signing-and-encryption-keys", "Key setup") };
3824
- case "projectSecretKey": return { description: "Encrypts app secrets. Run openssl rand -base64 32 separately; save this key for recovery. " + docs("signing-and-encryption-keys", "Key setup") };
5247
+ case "jwtSecret": return { description: "Signs platform login tokens. Run openssl rand -base64 32; save the key in your password manager. " + docs("credentials", "signing-and-encryption-keys", "Key setup") };
5248
+ case "projectSecretKey": return { description: "Encrypts app secrets. Run openssl rand -base64 32 separately; save this key for recovery. " + docs("credentials", "signing-and-encryption-keys", "Key setup") };
3825
5249
  default: return;
3826
5250
  }
3827
5251
  }
@@ -3885,7 +5309,29 @@ function platformSetupPages(plan) {
3885
5309
  ...plan.deployment.capabilities.customDomains ? [{
3886
5310
  key: "ssl_and_certificates",
3887
5311
  type: "edit"
3888
- }] : []
5312
+ }] : [],
5313
+ ...plan.deployment.capabilities.email ? [
5314
+ {
5315
+ key: "zone",
5316
+ type: "read"
5317
+ },
5318
+ {
5319
+ key: "zone_settings",
5320
+ type: "edit"
5321
+ },
5322
+ {
5323
+ key: "email_routing_address",
5324
+ type: "edit"
5325
+ },
5326
+ {
5327
+ key: "email_routing_rule",
5328
+ type: "edit"
5329
+ },
5330
+ {
5331
+ key: "email_sending",
5332
+ type: "edit"
5333
+ }
5334
+ ] : []
3889
5335
  ];
3890
5336
  return {
3891
5337
  runtime: {
@@ -3901,6 +5347,7 @@ function platformSetupPages(plan) {
3901
5347
  "Read: Account Settings, Account Analytics, Workers Tail.",
3902
5348
  ...plan.deployment.application.zoneName ? ["Zone " + plan.deployment.application.zoneName + ": Cache Purge: Purge."] : [],
3903
5349
  ...plan.deployment.capabilities.customDomains ? ["Custom project domains also need SSL and Certificates: Edit on that zone."] : [],
5350
+ ...plan.deployment.capabilities.email ? ["Email-enabled installations need Email Routing Addresses: Edit and Email Sending: Edit on the account, plus Zone: Read, Zone Settings: Edit and Email Routing Rules: Edit on the mail zones."] : [],
3904
5351
  "If the form differs, match the permissions above. Create the token, then paste it here."
3905
5352
  ].join("\n")
3906
5353
  },
@@ -3933,8 +5380,9 @@ function formatPlatformSetupPlan(plan) {
3933
5380
  platformSetupLink(pages.r2),
3934
5381
  pages.r2.instructions,
3935
5382
  "",
3936
- platformSetupLink(pages.github),
3937
- pages.github.instructions
5383
+ ...plan.deployment.authentication.providers.includes("github") ? [platformSetupLink(pages.github), pages.github.instructions] : [],
5384
+ ...plan.deployment.authentication.providers.filter((provider) => provider !== "github").map((provider) => `${provider === "google" ? "Google OAuth client" : provider === "oidc" ? "OIDC connection" : "Cloudflare Access OIDC application"}: client ID and secret; callback ${plan.apiUrl}/auth/callback.`),
5385
+ ...plan.deployment.authentication.accessProtection ? ["Cloudflare Access protection: configure API and proxy origins and admitted installer/CI credentials."] : []
3938
5386
  ].join("\n");
3939
5387
  }
3940
5388
  function openPlatformSetupPage(page, dependencies = {}) {
@@ -3992,14 +5440,14 @@ function resolvePlatformInstallDatabaseUrl(checkpoint, manifest = null) {
3992
5440
  }
3993
5441
  async function runPlatformLifecycleCommand(args) {
3994
5442
  if (args.subcommand === "domain") {
3995
- const { runPlatformDomainCommand } = await import("./platform-domain-ChvbJkdy.mjs");
5443
+ const { runPlatformDomainCommand } = await import("./platform-domain-4GiDlcqx.mjs");
3996
5444
  await runPlatformDomainCommand(args);
3997
5445
  return;
3998
5446
  }
3999
5447
  if (args.subcommand === "discover") {
4000
5448
  const client = new PlatformCloudflareClient((await resolvePlatformCloudflareCredential()).token);
4001
5449
  const accountId = await selectAccount(await readAccounts(client), args.account);
4002
- const { recoverPlatformInstallations } = await import("./platform-recovery-C_YO-tIs.mjs");
5450
+ const { recoverPlatformInstallations } = await import("./platform-recovery-CbK-I1FB.mjs");
4003
5451
  const recovered = await recoverPlatformInstallations(client, accountId, void 0, args.selector);
4004
5452
  if (recovered.length === 0) {
4005
5453
  log.info(`No recoverable Void platform installations were found in ${accountId}.`);
@@ -4009,7 +5457,7 @@ async function runPlatformLifecycleCommand(args) {
4009
5457
  return;
4010
5458
  }
4011
5459
  if (args.subcommand !== "install") {
4012
- const { runInstalledPlatformCommand } = await import("./platform-management-Db2PXw0B.mjs");
5460
+ const { runInstalledPlatformCommand } = await import("./platform-management-BfWsXHEW.mjs");
4013
5461
  await runInstalledPlatformCommand(args);
4014
5462
  return;
4015
5463
  }
@@ -4159,7 +5607,7 @@ async function runPlatformLifecycleCommand(args) {
4159
5607
  progress?.stop("Read the saved installation.");
4160
5608
  progress = void 0;
4161
5609
  if (authoritative) {
4162
- const { selectAuthoritativePlatformManifest } = await import("./platform-management-Db2PXw0B.mjs");
5610
+ const { selectAuthoritativePlatformManifest } = await import("./platform-management-BfWsXHEW.mjs");
4163
5611
  const selected = selectAuthoritativePlatformManifest(existing, authoritative);
4164
5612
  authoritativeProvisionIntents = true;
4165
5613
  if (!args.plan && JSON.stringify(selected) !== JSON.stringify(existing)) writeInstallationManifest(selected);
@@ -4167,19 +5615,25 @@ async function runPlatformLifecycleCommand(args) {
4167
5615
  if (selectedInstall && !isPlatformInstallInProgress(existing)) throw new Error(`platform: Installation ${existing.id} is no longer unfinished. Run \`void platform status ${existing.id}\` to inspect it.`);
4168
5616
  }
4169
5617
  }
5618
+ const authenticationSelection = await selectInstallationAuthentication(args.authConfig, existing?.deployment.authentication);
4170
5619
  const planInput = {
5620
+ authentication: authenticationSelection.authentication,
4171
5621
  accountId,
4172
5622
  applicationDomain,
4173
5623
  controlPlaneDomain,
4174
5624
  dedicatedZone,
4175
5625
  displayName,
5626
+ ...process.env.VOID_EMAIL_SHARED_ZONE_ID ? { emailSharedZoneId: process.env.VOID_EMAIL_SHARED_ZONE_ID } : {},
5627
+ ...process.env.VOID_EMAIL_SENDER_DOMAIN ? { emailSenderDomain: process.env.VOID_EMAIL_SENDER_DOMAIN } : {},
4176
5628
  installationSlug,
4177
5629
  zoneName,
4178
5630
  ...workersDev ? { workersDev: true } : {}
4179
5631
  };
4180
5632
  let plan = await createPlatformInstallPlan(client, planInput, existing, {
4181
5633
  authoritativeProvisionIntents,
4182
- allowUnverifiedAiGateway: true
5634
+ allowUnverifiedAiGateway: true,
5635
+ includeEmailGateway: runtime.manifest.artifacts.emailGateway !== void 0,
5636
+ enableSandbox: runtime.manifest.safetyProtocols.sandboxDrain >= 3
4183
5637
  });
4184
5638
  note(formatPlatformInstallPlan(plan), args.plan ? "Read-only installation plan" : "Installation plan");
4185
5639
  note(formatPlatformRuntime(runtime), "Platform runtime");
@@ -4258,22 +5712,44 @@ async function runPlatformLifecycleCommand(args) {
4258
5712
  await verifyRuntimeToken(runtimeCloudflareApiToken);
4259
5713
  }
4260
5714
  saveValues({ cloudflareApiToken: runtimeCloudflareApiToken });
4261
- const previousLogin = values.bootstrapGitHubLogin;
4262
- const bootstrapGitHubLogin = await collect("bootstrapGitHubLogin", "VOID_PLATFORM_ADMIN_GITHUB_LOGIN", "Initial administrator GitHub username:", false);
4263
- const bootstrapGitHubId = (previousLogin === bootstrapGitHubLogin ? values.bootstrapGitHubId : "") || process.env.VOID_PLATFORM_ADMIN_GITHUB_ID || await resolveBootstrapGitHubId(bootstrapGitHubLogin);
4264
- saveValues({ bootstrapGitHubId });
5715
+ const deferAuthentication = !!authenticationSelection.authentication.accessProtection || authenticationSelection.authentication.providers.includes("cloudflare-access");
5716
+ const saveAuthenticationValues = (updates) => {
5717
+ Object.assign(values, updates);
5718
+ const latest = readPendingInstallationSecrets(setupManifest) ?? {};
5719
+ writePendingInstallationSecrets(setupManifest, {
5720
+ ...latest,
5721
+ ...updates
5722
+ });
5723
+ };
5724
+ const authenticationSecrets = deferAuthentication ? {
5725
+ authSetupToken: values.authSetupToken || randomBytes(32).toString("hex"),
5726
+ authBootstrapCode: values.authBootstrapCode || randomBytes(32).toString("hex")
5727
+ } : await collectInstallationAuthentication(authenticationSelection, plan, values, saveAuthenticationValues);
5728
+ if (authenticationSecrets) saveAuthenticationValues(authenticationSecrets);
5729
+ let bootstrapGitHubLogin = "";
5730
+ let bootstrapGitHubId = "";
5731
+ let githubClientId = "";
5732
+ let githubClientSecret = "";
5733
+ if (!authenticationSecrets) {
5734
+ const previousLogin = values.bootstrapGitHubLogin;
5735
+ bootstrapGitHubLogin = await collect("bootstrapGitHubLogin", "VOID_PLATFORM_ADMIN_GITHUB_LOGIN", "Initial administrator GitHub username:", false);
5736
+ bootstrapGitHubId = (previousLogin === bootstrapGitHubLogin ? values.bootstrapGitHubId : "") || process.env.VOID_PLATFORM_ADMIN_GITHUB_ID || await resolveBootstrapGitHubId(bootstrapGitHubLogin);
5737
+ saveValues({ bootstrapGitHubId });
5738
+ }
4265
5739
  const installationDatabaseUrl = databaseUrl || await collect("databaseUrl", "VOID_PLATFORM_DATABASE_URL", "PostgreSQL DATABASE_URL:");
4266
5740
  saveValues({ databaseUrl: installationDatabaseUrl });
4267
- if (!configured("githubClientId", "VOID_PLATFORM_GITHUB_CLIENT_ID") || !configured("githubClientSecret", "VOID_PLATFORM_GITHUB_CLIENT_SECRET")) openPlatformSetupPage(configured("githubClientId", "VOID_PLATFORM_GITHUB_CLIENT_ID") ? {
4268
- ...pages.github,
4269
- url: "https://github.com/settings/developers"
4270
- } : pages.github);
4271
- const githubClientId = await collect("githubClientId", "VOID_PLATFORM_GITHUB_CLIENT_ID", "GitHub OAuth client ID:", false, false);
4272
- const githubClientSecret = await collect("githubClientSecret", "VOID_PLATFORM_GITHUB_CLIENT_SECRET", "GitHub OAuth client secret:", true, false);
4273
- saveValues({
4274
- githubClientId,
4275
- githubClientSecret
4276
- });
5741
+ if (!authenticationSecrets) {
5742
+ if (!configured("githubClientId", "VOID_PLATFORM_GITHUB_CLIENT_ID") || !configured("githubClientSecret", "VOID_PLATFORM_GITHUB_CLIENT_SECRET")) openPlatformSetupPage(configured("githubClientId", "VOID_PLATFORM_GITHUB_CLIENT_ID") ? {
5743
+ ...pages.github,
5744
+ url: "https://github.com/settings/developers"
5745
+ } : pages.github);
5746
+ githubClientId = await collect("githubClientId", "VOID_PLATFORM_GITHUB_CLIENT_ID", "GitHub OAuth client ID:", false, false);
5747
+ githubClientSecret = await collect("githubClientSecret", "VOID_PLATFORM_GITHUB_CLIENT_SECRET", "GitHub OAuth client secret:", true, false);
5748
+ saveValues({
5749
+ githubClientId,
5750
+ githubClientSecret
5751
+ });
5752
+ }
4277
5753
  if (!configured("r2AccessKeyId", "VOID_PLATFORM_R2_ACCESS_KEY_ID") || !configured("r2SecretAccessKey", "VOID_PLATFORM_R2_SECRET_ACCESS_KEY")) openPlatformSetupPage(pages.r2);
4278
5754
  const r2AccessKeyId = await collect("r2AccessKeyId", "VOID_PLATFORM_R2_ACCESS_KEY_ID", "Access Key ID:", false, false);
4279
5755
  const r2SecretAccessKey = await collect("r2SecretAccessKey", "VOID_PLATFORM_R2_SECRET_ACCESS_KEY", "Secret Access Key:", true, false);
@@ -4283,11 +5759,16 @@ async function runPlatformLifecycleCommand(args) {
4283
5759
  });
4284
5760
  const hasFullProjectSecretKeyring = Boolean(checkpoint?.projectSecretKeysJson || checkpoint?.projectSecretActiveKeyVersion || process.env.VOID_PLATFORM_PROJECT_SECRET_KEYS_JSON || process.env.VOID_PLATFORM_PROJECT_SECRET_ACTIVE_KEY_VERSION);
4285
5761
  const secrets = createPlatformInstallationSecrets({
5762
+ configurableAuthentication: !!authenticationSelection.authentication.configurable,
5763
+ ...authenticationSecrets,
4286
5764
  bootstrapGitHubId,
4287
5765
  bootstrapGitHubLogin,
4288
5766
  cloudflareApiToken: runtimeCloudflareApiToken,
4289
5767
  checkpoint: values,
4290
5768
  databaseUrl: installationDatabaseUrl,
5769
+ emailEnabled: plan.deployment.capabilities.email,
5770
+ emailSigningSecret: process.env.VOID_PLATFORM_EMAIL_SIGNING_SECRET || "",
5771
+ existingEmailEnabled: existing.deployment.capabilities.email,
4291
5772
  githubClientId,
4292
5773
  githubClientSecret,
4293
5774
  r2AccessKeyId,
@@ -4303,7 +5784,10 @@ async function runPlatformLifecycleCommand(args) {
4303
5784
  });
4304
5785
  progress = startProgress("Installing the platform");
4305
5786
  if (credentialSetup) {
4306
- saveValues({ projectSecretKey: secrets.projectSecretKey });
5787
+ saveValues({
5788
+ projectSecretKey: secrets.projectSecretKey,
5789
+ ...secrets.emailEnabled ? { emailSigningSecret: secrets.emailSigningSecret } : {}
5790
+ });
4307
5791
  existing = completePlatformSetupDraft(existing);
4308
5792
  }
4309
5793
  const result = await installPlatform(plan, secrets, existing, {
@@ -4312,13 +5796,30 @@ async function runPlatformLifecycleCommand(args) {
4312
5796
  runtime,
4313
5797
  healthCheckResumeCommand: `${cliCommandName()} platform install --resume --name ${existing.id}`,
4314
5798
  onProgress: (message) => progress?.message(message),
5799
+ ...deferAuthentication ? { prepareAuthentication: async (manifest) => {
5800
+ progress?.stop("Preparing Cloudflare Access configuration.");
5801
+ progress = void 0;
5802
+ const result = await collectInstallationAuthentication(authenticationSelection, plan, values, saveAuthenticationValues, {
5803
+ client,
5804
+ ownershipNonce: manifest.ownershipNonce,
5805
+ yes: args.yes
5806
+ });
5807
+ progress = startProgress("Continuing platform installation");
5808
+ return {
5809
+ ...result,
5810
+ accessProvisioningJson: values.accessProvisioningJson,
5811
+ accessCredentialsJson: values.accessCredentialsJson
5812
+ };
5813
+ } } : {},
4315
5814
  replanWorkers: async (manifest) => (await createPlatformInstallPlan(client, planInput, manifest, {
4316
5815
  authoritativeProvisionIntents: true,
4317
- repairMissing: true
5816
+ repairMissing: true,
5817
+ includeEmailGateway: runtime.manifest.artifacts.emailGateway !== void 0,
5818
+ enableSandbox: runtime.manifest.safetyProtocols.sandboxDrain >= 3
4318
5819
  })).actions.filter((action) => action.kind === "worker"),
4319
5820
  revalidateBeforeWorkerDeploy: (manifest, action) => revalidatePlatformInstallAction(client, plan, manifest, action),
4320
5821
  verifyExistingOwnership: async (manifest, databaseCheckpointExists) => {
4321
- const { verifyPlatformInstallationOwnership } = await import("./platform-management-Db2PXw0B.mjs");
5822
+ const { verifyPlatformInstallationOwnership } = await import("./platform-management-BfWsXHEW.mjs");
4322
5823
  const retainedReplacementKeys = new Set(plan.actions.filter((action) => {
4323
5824
  const previous = manifest.resources.find((resource) => resource.key === action.key);
4324
5825
  return action.action === "create" && previous && previous.name !== action.name;
@@ -4344,6 +5845,13 @@ async function runPlatformLifecycleCommand(args) {
4344
5845
  }
4345
5846
  log.success(`${result.manifest.deployment.displayName} is installed at ${new URL("/admin", plan.apiUrl).href}.`);
4346
5847
  log.info(`API URL: ${plan.apiUrl}`);
5848
+ if (result.emailOperationId) log.info(`Shared email routing ready (operation ${result.emailOperationId}).`);
5849
+ const completedAuthCode = readInstallationRecoverySecrets(result.manifest)?.authBootstrapCode;
5850
+ if (result.setupPending && completedAuthCode) {
5851
+ log.info(`First administrator setup: ${plan.apiUrl}/setup`);
5852
+ console.log(`One-time administrator setup code: ${completedAuthCode}`);
5853
+ log.info("Confirm your identity in the browser, then test and enable any additional login methods in Settings.");
5854
+ }
4347
5855
  if (!plan.deployment.application.zoneName) log.info("Apps will use workers.dev addresses. When your domain is ready: void platform domain set <domain>");
4348
5856
  outro("Run `void auth login`, then deploy an app with `void deploy --platform void`.");
4349
5857
  } catch (error) {
@@ -4374,7 +5882,11 @@ function formatPlatformRuntime(runtime) {
4374
5882
  async function verifyPlatformAiGatewayPlan(client, input, existing, preview, authoritativeProvisionIntents) {
4375
5883
  let verified;
4376
5884
  try {
4377
- verified = await createPlatformInstallPlan(client, input, existing, { authoritativeProvisionIntents });
5885
+ verified = await createPlatformInstallPlan(client, input, existing, {
5886
+ authoritativeProvisionIntents,
5887
+ includeEmailGateway: preview.actions.some((entry) => entry.key === "email-gateway-worker"),
5888
+ enableSandbox: preview.deployment.capabilities.sandbox
5889
+ });
4378
5890
  } catch (error) {
4379
5891
  if (error instanceof PlatformCloudflareApiError && (error.status === 401 || error.status === 403) && error.requestPath?.includes("/ai-gateway/")) throw new Error("platform: The runtime token could not verify AI Gateway access. Include Account → AI Gateway → Edit for this account, then retry. No platform resources were created.", { cause: error });
4380
5892
  throw error;
@@ -4422,7 +5934,7 @@ async function promptText(message, value, optional = false, validate) {
4422
5934
  if (optional) return "";
4423
5935
  throw new Error(`platform: ${message.replace(/:$/, "")} is required in non-interactive mode.`);
4424
5936
  }
4425
- const input = await text({
5937
+ const input = await text$1({
4426
5938
  message,
4427
5939
  ...typeof value === "string" ? { initialValue: value } : value,
4428
5940
  validate: (input) => input ? validate?.(input) : optional ? void 0 : "A value is required."
@@ -4439,7 +5951,7 @@ async function promptSecret(message, masked = true, validate, placeholder) {
4439
5951
  const value = masked ? placeholder ? await passwordWithPlaceholder({
4440
5952
  ...options,
4441
5953
  placeholder
4442
- }) : await password(options) : await text({
5954
+ }) : await password(options) : await text$1({
4443
5955
  ...options,
4444
5956
  placeholder
4445
5957
  });
@@ -4447,4 +5959,4 @@ async function promptSecret(message, masked = true, validate, placeholder) {
4447
5959
  return value;
4448
5960
  }
4449
5961
  //#endregion
4450
- export { resolvePlatformCloudflareCredential as S, hyperdriveOriginConfigHash as _, installPlatform as a, assertNoninteractivePlatformMutationAcknowledged, verifyPlatformRuntimeTokenPermissions as b, resolveBootstrapGitHubId as c, createPlatformInstallPlan as d, formatPlatformInstallPlan as f, PlatformCloudflareClient as g, revalidatePlatformInstallAction as h, createPlatformInstallationSecrets as i, recordPlatformWorkerTraffic as l, platformProvisionIntentHash as m, acquirePlatformDatabaseLifecycleLock as n, loadPlatformRuntime as o, platformAdoptionIntentHash as p, createPlatformHealthProbeToken as r, resolvePlatformInstallDatabaseUrl, runPlatformLifecycleCommand, recordResource as s, REMOTE_INSTALLATION_MANIFEST_KEY as t, trackedPlatformWorkerTraffic as u, hyperdriveOriginFromConnectionString as v, assertPlatformOperatorCredentialCanApplyPlan as x, hyperdriveOriginMatches as y };
5962
+ export { PlatformCloudflareClient as C, verifyPlatformRuntimeTokenPermissions as D, hyperdriveOriginMatches as E, preparePlatformAccessSetup as S, hyperdriveOriginFromConnectionString as T, revalidatePlatformInstallAction as _, createPlatformInstallationSecrets as a, assertNoninteractivePlatformMutationAcknowledged, checkpointPlatformAccessResources as b, platformDatabaseClientUrl as c, recordPlatformWorkerTraffic as d, trackedPlatformWorkerTraffic as f, platformProvisionIntentHash as g, platformAdoptionIntentHash as h, createPlatformHealthProbeToken as i, recordResource as l, formatPlatformInstallPlan as m, acquirePlatformDatabaseLifecycleLock as n, installPlatform as o, createPlatformInstallPlan as p, acquirePlatformDatabaseLifecycleLockConnection as r, resolvePlatformInstallDatabaseUrl, runPlatformLifecycleCommand, loadPlatformRuntime as s, REMOTE_INSTALLATION_MANIFEST_KEY as t, resolveBootstrapGitHubId as u, assertPlatformOperatorCredentialCanApplyPlan as v, hyperdriveOriginConfigHash as w, parsePlatformAccessSetupSpec as x, resolvePlatformCloudflareCredential as y };