void 0.20.0 → 0.20.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/{auth-W9WII-mN.mjs → auth-DPl6kck4.mjs} +46 -26
- package/dist/{auth-cmd-CYVhSNFy.mjs → auth-cmd-gniL2fNt.mjs} +5 -4
- package/dist/auth-link-NZdjCmSc.mjs +28 -0
- package/dist/{build-cmd-CkVD1uOh.mjs → build-cmd-sI18tX_O.mjs} +3 -3
- package/dist/{cache-QcUfR-ff.mjs → cache-IHn5MwBC.mjs} +3 -3
- package/dist/{cancel-deploy-DsvWKFTe.mjs → cancel-deploy-C5qTOdLi.mjs} +3 -3
- package/dist/cf-access-DRsQRe6k.mjs +75 -0
- package/dist/cli/cli.mjs +364 -1883
- package/dist/cli/env-schema-probe.mjs +11 -2
- package/dist/{client-CyCHWSO_.mjs → client-dHfSJvAN.mjs} +336 -58
- package/dist/{cloudflare-auth-DRkGe7-s.mjs → cloudflare-auth-6M5llVPC.mjs} +53 -6
- package/dist/{cloudflare-cmd-D3ME2GAe.mjs → cloudflare-cmd-4RPGN3KB.mjs} +2 -2
- package/dist/{cloudflare-connect-BivMefMA.mjs → cloudflare-connect-t1UU5svD.mjs} +2 -2
- package/dist/{cloudflare-operations-CPTpRW6d.mjs → cloudflare-operations-BzWnlC1_.mjs} +1 -1
- package/dist/{config-BQFq7QvD.mjs → config-uNGuFsI2.mjs} +1 -1
- package/dist/{connect-D9Yr-T5f.mjs → connect-Bfk31O_8.mjs} +6 -6
- package/dist/{create-project-DMV-csEm.mjs → create-project-Bk9Z0-Jg.mjs} +6 -5
- package/dist/{db-BxoUWL0F.mjs → db-BkRoptAt.mjs} +75 -48
- package/dist/{delete-iJOqxBz1.mjs → delete-DouASY9P.mjs} +3 -3
- package/dist/{deploy-CcDoeBIf.mjs → deploy-DTaWUS1S.mjs} +137 -128
- package/dist/{dev-inbox-DkgRWLkW.mjs → dev-inbox-P0u4tM8Y.mjs} +1 -1
- package/dist/{domain-gu_iaHmn.mjs → domain-1RhhOVrC.mjs} +4 -4
- package/dist/email-C-lGh51B.mjs +795 -0
- package/dist/{env-D4Emu-M_.mjs → env-DBKmK4vc.mjs} +1 -0
- package/dist/{env-DmU2To0C.mjs → env-DJHsPE7Z.mjs} +5 -5
- package/dist/{env-validation-ENpMy6Ez.mjs → env-validation-CF6KvTRf.mjs} +3 -1
- package/dist/{gen-BKw6qHIg.mjs → gen-B_wPnVTK.mjs} +3 -3
- package/dist/generate-RTK8_kK1.mjs +47 -0
- package/dist/{github-cmd-DJS5Ab-H.mjs → github-cmd-PW7ZnWTp.mjs} +3 -3
- package/dist/{headers-D8QfRX9Y.mjs → headers-BAHwgHdW.mjs} +1 -1
- package/dist/help-CwOX-zmI.mjs +2216 -0
- package/dist/{inbound-BJ70in1n.d.mts → inbound-CH5Mksyy.d.mts} +37 -42
- package/dist/{inbound-CNKxb3FY.mjs → inbound-aVHEUhKo.mjs} +143 -101
- package/dist/index.mjs +69 -30
- package/dist/{init-Dx5cgKqK.mjs → init-BWZ7q5Z4.mjs} +11 -11
- package/dist/{link-D_rm5sRH.mjs → link-Rmvu2Wl_.mjs} +4 -4
- package/dist/{list-M8XShti7.mjs → list-DEE2S6mY.mjs} +4 -4
- package/dist/{local-d1-BE8KBbMy.mjs → local-d1-D2I6Ox5F.mjs} +1 -1
- package/dist/{login-C8-UuLnp.mjs → login-Uvferzmm.mjs} +28 -10
- package/dist/{logs-DwFU7dMW.mjs → logs-27FenuiC.mjs} +4 -4
- package/dist/{mime-BJD7d_qL.mjs → mime-D5Nmdzf7.mjs} +23 -9
- package/dist/{node-BkaXcpAc.mjs → node-Ez5KW5rn.mjs} +3 -3
- package/dist/operator-auth-B3e08unv.mjs +52 -0
- package/dist/operator-client-LUZnlnYk.mjs +82 -0
- package/dist/{operator-cmd-DYWRbWUA.mjs → operator-cmd-CjOTmAYE.mjs} +35 -55
- package/dist/{output-tFQLLj26.mjs → output-B0cfNSx5.mjs} +316 -2
- package/dist/pages/index.mjs +2 -2
- package/dist/platform-auth-config-DrbQXXiW.mjs +368 -0
- package/dist/platform-auth-protection-Bhtvp0B_.mjs +219 -0
- package/dist/platform-auth-recovery-CeOKGVeJ.mjs +310 -0
- package/dist/{platform-cmd-BEOVv1PN.mjs → platform-cmd-BFhieCdV.mjs} +16 -6
- package/dist/{platform-domain-BszUfiS8.mjs → platform-domain-C74PULqV.mjs} +4 -4
- package/dist/{platform-lifecycle-CjSr6xf0.mjs → platform-lifecycle-BwAIgz-t.mjs} +1667 -191
- package/dist/{platform-management-W30iCaTL.mjs → platform-management-COogu_Se.mjs} +33 -7
- package/dist/{platform-recovery-pHHv4ZSg.mjs → platform-recovery-ewqLefp1.mjs} +6 -5
- package/dist/{prepare-CBetXvsN.mjs → prepare-CtDJjoOj.mjs} +2 -2
- package/dist/{prepare-BfJvFUtJ.mjs → prepare-blNRQvQl.mjs} +2 -2
- package/dist/prerender-render.d.mts +11 -0
- package/dist/prerender-render.mjs +111 -0
- package/dist/{project-cmd-DCpk1cDt.mjs → project-cmd-DmZK9Hxf.mjs} +32 -14
- package/dist/project-team-D8jOJMUJ.mjs +130 -0
- package/dist/project-token-DA34bf-C.mjs +75 -0
- package/dist/{provision-Blnstcm2.mjs → provision-CSJOjjQk.mjs} +2 -0
- package/dist/{requests-Dn8Vheh1.mjs → requests-CUExwGQQ.mjs} +3 -3
- package/dist/{rollback-CtlPXEBi.mjs → rollback-CDNGU1gr.mjs} +4 -4
- package/dist/{runner-mysql-CgRFl3s6.mjs → runner-mysql-7BPUNGmL.mjs} +1 -1
- package/dist/{runner-pg-DCkWPsWS.mjs → runner-pg-BkEza-dX.mjs} +1 -1
- package/dist/runtime/ai.mjs +3 -2
- package/dist/runtime/email/testing.d.mts +1 -1
- package/dist/runtime/email/testing.mjs +3 -3
- package/dist/runtime/email-protocol.d.mts +15 -0
- package/dist/runtime/email-protocol.mjs +70 -0
- package/dist/runtime/email.d.mts +2 -2
- package/dist/runtime/email.mjs +189 -96
- package/dist/runtime/remote/index.mjs +5 -3
- package/dist/{secret-BqTxGqki.mjs → secret-Bzzi2e9E.mjs} +5 -5
- package/dist/{skills-Q46GZMO-.mjs → skills-C0RvGjeE.mjs} +1 -1
- package/dist/sqlite-validation-BzKMWnO4.mjs +25 -0
- package/dist/{subcommand-prompt-WfySCQ7S.mjs → subcommand-prompt-Bmyn5Rlc.mjs} +1 -1
- package/dist/{validate-Bihr8WBi.mjs → validate-tBBN_dXH.mjs} +1 -0
- package/package.json +12 -7
- package/skills/void/SKILL.md +35 -4
- package/skills/void/docs/guide/deployment.md +2 -0
- package/skills/void/docs/guide/email.md +121 -98
- package/skills/void/docs/guide/platform-administration.md +214 -2
- package/skills/void/docs/guide/platform-development.md +93 -2
- package/skills/void/docs/guide/project-collaboration.md +94 -0
- package/skills/void/docs/guide/self-hosted-platform.md +184 -30
- package/skills/void/docs/reference/cli.md +294 -15
- package/dist/cf-access-AJ1ehiFR.mjs +0 -42
- package/dist/cf-access-DsSsZUPr.mjs +0 -67
- package/dist/email-r6DHJyAB.mjs +0 -262
|
@@ -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
|
|
4
|
-
import { t as cfAccessHeaders } from "./cf-access-
|
|
5
|
-
import {
|
|
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 {
|
|
8
|
-
import { n as createDefaultRunWrangler, o as resolvePinnedApiBaseUrl } from "./provision-
|
|
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-Bhtvp0B_.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,23 @@ 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
|
+
...input.authentication ? {
|
|
1603
|
+
githubOAuth: input.authentication.providers.includes("github"),
|
|
1604
|
+
googleOAuth: input.authentication.providers.includes("google")
|
|
1605
|
+
} : {},
|
|
1606
|
+
...enableEmail ? { email: true } : {}
|
|
1607
|
+
};
|
|
1608
|
+
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
1609
|
const deployment = createPlatformDeploymentManifest({
|
|
1610
|
+
authentication: input.authentication ?? existing?.deployment.authentication,
|
|
1611
|
+
...Object.keys(capabilities).length > 0 ? { capabilities } : {},
|
|
806
1612
|
application: {
|
|
807
1613
|
baseDomain: testing ? `${accountSubdomain}.workers.dev` : input.applicationDomain,
|
|
808
1614
|
dedicatedZone: input.dedicatedZone,
|
|
@@ -816,10 +1622,21 @@ async function createPlatformInstallPlan(client, input, existing, options) {
|
|
|
816
1622
|
proxyHostname: null
|
|
817
1623
|
},
|
|
818
1624
|
displayName: input.displayName,
|
|
1625
|
+
...emailSharedZoneId ? { emailSharedZoneId } : {},
|
|
1626
|
+
...normalizedEmailSenderDomain ? { emailSenderDomain: normalizedEmailSenderDomain } : {},
|
|
819
1627
|
installationSlug,
|
|
820
1628
|
resources: existing?.deployment.resources ?? createPlatformResourceNames(installationSlug)
|
|
821
1629
|
});
|
|
822
|
-
|
|
1630
|
+
const { emailSharedZoneId: configuredEmailZone, emailSenderDomain: configuredSenderDomain, ...deploymentWithoutEmailZone } = deployment;
|
|
1631
|
+
const { emailSharedZoneId: recordedEmailZone, emailSenderDomain: recordedSenderDomain, ...existingWithoutEmailZone } = existing?.deployment ?? deployment;
|
|
1632
|
+
const comparableExistingWithoutEmailZone = existing && deployment.capabilities.email && !existing.deployment.capabilities.email ? {
|
|
1633
|
+
...existingWithoutEmailZone,
|
|
1634
|
+
capabilities: {
|
|
1635
|
+
...existingWithoutEmailZone.capabilities,
|
|
1636
|
+
email: true
|
|
1637
|
+
}
|
|
1638
|
+
} : existingWithoutEmailZone;
|
|
1639
|
+
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
1640
|
const names = deployment.resources;
|
|
824
1641
|
const wildcardHostname = `*.${deployment.application.baseDomain}`;
|
|
825
1642
|
const zone = testing ? null : await client.findZone(account.id, deployment.application.zoneName);
|
|
@@ -871,6 +1688,7 @@ async function createPlatformInstallPlan(client, input, existing, options) {
|
|
|
871
1688
|
for (const [key, name] of [
|
|
872
1689
|
["api-worker", names.apiWorker],
|
|
873
1690
|
["proxy-worker", names.proxyWorker],
|
|
1691
|
+
["email-gateway-worker", names.emailGatewayWorker],
|
|
874
1692
|
["tail-worker", names.tailWorker],
|
|
875
1693
|
["dispatch-worker", names.dispatchWorker]
|
|
876
1694
|
]) if (workers.includes(name) && hasUnrecordedProvisionIntent(existing, key, "worker", name)) {
|
|
@@ -936,6 +1754,7 @@ async function createPlatformInstallPlan(client, input, existing, options) {
|
|
|
936
1754
|
for (const [key, name] of [
|
|
937
1755
|
["api-worker", names.apiWorker],
|
|
938
1756
|
["proxy-worker", names.proxyWorker],
|
|
1757
|
+
...options?.includeEmailGateway === false ? [] : [["email-gateway-worker", names.emailGatewayWorker]],
|
|
939
1758
|
["tail-worker", names.tailWorker],
|
|
940
1759
|
["dispatch-worker", names.dispatchWorker]
|
|
941
1760
|
]) addNamedPlan(actions, existing, key, "worker", name, workers.includes(name) ? [{
|
|
@@ -972,7 +1791,10 @@ async function revalidatePlatformInstallAction(client, original, manifest, actio
|
|
|
972
1791
|
displayName: deployment.displayName,
|
|
973
1792
|
installationSlug: deployment.installationSlug,
|
|
974
1793
|
zoneName: deployment.application.zoneName
|
|
975
|
-
}, manifest,
|
|
1794
|
+
}, manifest, {
|
|
1795
|
+
...options,
|
|
1796
|
+
includeEmailGateway: original.actions.some((entry) => entry.key === "email-gateway-worker")
|
|
1797
|
+
})).actions.find((entry) => entry.key === action.key);
|
|
976
1798
|
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
1799
|
if (action.kind === "worker" && action.action === "update") {
|
|
978
1800
|
if (!matchesResourceMarker((await client.getWorkerPlainTextBindings(original.account.id, action.name)).PLATFORM_OWNERSHIP_MARKER, manifest, action.key, "worker", {
|
|
@@ -1171,6 +1993,7 @@ const RESOURCE_NAME_KEY_BY_ACTION = {
|
|
|
1171
1993
|
"isr-cache-kv": "isrCacheKv",
|
|
1172
1994
|
"legacy-assets-kv": "legacyAssetsKv",
|
|
1173
1995
|
"proxy-worker": "proxyWorker",
|
|
1996
|
+
"email-gateway-worker": "emailGatewayWorker",
|
|
1174
1997
|
"routing-kv": "routingKv",
|
|
1175
1998
|
"tail-worker": "tailWorker",
|
|
1176
1999
|
"usage-queue": "usageQueue"
|
|
@@ -1214,6 +2037,12 @@ function formatPlatformInstallPlan(plan) {
|
|
|
1214
2037
|
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
2038
|
`${import_picocolors$1.default.bold("Control plane:")} ${plan.apiUrl}`,
|
|
1216
2039
|
`${import_picocolors$1.default.bold("Resource proxy:")} ${plan.proxyUrl}`,
|
|
2040
|
+
`${import_picocolors$1.default.bold("Login methods:")} ${plan.deployment.authentication.providers.join(", ")}`,
|
|
2041
|
+
`${import_picocolors$1.default.bold("Access protection:")} ${plan.deployment.authentication.accessProtection ? "Enabled" : "Not enabled"}`,
|
|
2042
|
+
`${import_picocolors$1.default.bold("Login callback:")} ${plan.apiUrl}/auth/callback`,
|
|
2043
|
+
...plan.deployment.authentication.providers.includes("cloudflare-access") ? ["Access login: create a dedicated OIDC application or connect an existing issuer."] : [],
|
|
2044
|
+
...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.`] : [],
|
|
2045
|
+
`${import_picocolors$1.default.bold("Database:")} Verify uncached Hyperdrive reads for authentication`,
|
|
1217
2046
|
""
|
|
1218
2047
|
];
|
|
1219
2048
|
for (const action of plan.actions) lines.push(`${action.action.padEnd(6)} ${action.kind.padEnd(19)} ${action.name}`);
|
|
@@ -1228,6 +2057,7 @@ const COMPLETE_WORKER_SECRET_UPDATE_NAMES = [
|
|
|
1228
2057
|
"R2_ACCESS_KEY_ID",
|
|
1229
2058
|
"R2_SECRET_ACCESS_KEY"
|
|
1230
2059
|
];
|
|
2060
|
+
const EMAIL_WORKER_SECRET_UPDATE_NAME = "EMAIL_SIGNING_SECRET";
|
|
1231
2061
|
/**
|
|
1232
2062
|
* Prevents a host without the encrypted values for an interrupted credential
|
|
1233
2063
|
* update from carrying an opaque, possibly partial update into another cutover.
|
|
@@ -1236,7 +2066,7 @@ function assertPlatformWorkerSecretUpdateAuthority(input) {
|
|
|
1236
2066
|
const unresolved = unresolvedWorkerSecretUpdate(input.manifest);
|
|
1237
2067
|
if (!unresolved) return;
|
|
1238
2068
|
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;
|
|
2069
|
+
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
2070
|
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
2071
|
}
|
|
1242
2072
|
function unresolvedWorkerSecretUpdate(manifest) {
|
|
@@ -1264,8 +2094,8 @@ function readLatestWorkerGeneration(manifest) {
|
|
|
1264
2094
|
return match ? [Number(match[1])] : [];
|
|
1265
2095
|
}));
|
|
1266
2096
|
}
|
|
1267
|
-
function hasCompleteWorkerSecretReplacement(updates) {
|
|
1268
|
-
return COMPLETE_WORKER_SECRET_UPDATE_NAMES.every((name) => typeof updates[name] === "string" && updates[name] !== "");
|
|
2097
|
+
function hasCompleteWorkerSecretReplacement(updates, requiresEmailSigningSecret) {
|
|
2098
|
+
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
2099
|
}
|
|
1270
2100
|
//#endregion
|
|
1271
2101
|
//#region src/cli/platform-traffic.ts
|
|
@@ -1339,11 +2169,29 @@ function recordPlatformWorkerTraffic(manifest, workerName, inventory, updatedAt)
|
|
|
1339
2169
|
const REMOTE_INSTALLATION_MANIFEST_KEY = PLATFORM_REMOTE_INSTALLATION_MANIFEST_KEY;
|
|
1340
2170
|
const PLATFORM_WORKER_SECRET_UPDATE_NAMES = [
|
|
1341
2171
|
"CF_API_TOKEN",
|
|
2172
|
+
"EMAIL_SIGNING_SECRET",
|
|
1342
2173
|
"GITHUB_CLIENT_ID",
|
|
1343
2174
|
"GITHUB_CLIENT_SECRET",
|
|
1344
2175
|
"R2_ACCESS_KEY_ID",
|
|
1345
2176
|
"R2_SECRET_ACCESS_KEY"
|
|
1346
2177
|
];
|
|
2178
|
+
/** Preserve database-authoritative authentication choices while upgrading old manifests. */
|
|
2179
|
+
function mergePlatformLiveAuthenticationDeployment(deployment, live) {
|
|
2180
|
+
const providers = live.enabledProviders.length > 0 ? live.enabledProviders : deployment.authentication.providers;
|
|
2181
|
+
return createPlatformDeploymentManifest({
|
|
2182
|
+
...deployment,
|
|
2183
|
+
authentication: {
|
|
2184
|
+
providers,
|
|
2185
|
+
...live.sessionsRequired ? { configurable: true } : {},
|
|
2186
|
+
...live.protectedByAccess ? { accessProtection: true } : {}
|
|
2187
|
+
},
|
|
2188
|
+
capabilities: {
|
|
2189
|
+
...deployment.capabilities,
|
|
2190
|
+
githubOAuth: providers.includes("github"),
|
|
2191
|
+
googleOAuth: providers.includes("google")
|
|
2192
|
+
}
|
|
2193
|
+
});
|
|
2194
|
+
}
|
|
1347
2195
|
var PlatformWorkerRecoveryIncompleteError = class extends Error {
|
|
1348
2196
|
constructor(message, options) {
|
|
1349
2197
|
super(message, options);
|
|
@@ -1364,9 +2212,28 @@ function createPlatformInstallationSecrets(input) {
|
|
|
1364
2212
|
const existingInstallation = input.existingInstallation === true;
|
|
1365
2213
|
const restoredIdentity = (explicit, name) => existingInstallation ? checkpoint[name] || explicit || "" : explicit || checkpoint[name] || "";
|
|
1366
2214
|
const configured = (explicit, name) => explicit || checkpoint[name] || "";
|
|
2215
|
+
const authenticationSetupJson = configured(input.authenticationSetupJson ?? "", "authenticationSetupJson");
|
|
2216
|
+
const authSetupToken = configured(input.authSetupToken ?? "", "authSetupToken");
|
|
2217
|
+
const authBootstrapCode = configured(input.authBootstrapCode ?? "", "authBootstrapCode");
|
|
2218
|
+
const modernAuthentication = input.configurableAuthentication === true || !!authenticationSetupJson;
|
|
2219
|
+
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.");
|
|
2220
|
+
if (authenticationSetupJson) {
|
|
2221
|
+
const setup = parsePlatformAuthenticationSetup(JSON.parse(authenticationSetupJson));
|
|
2222
|
+
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.");
|
|
2223
|
+
}
|
|
1367
2224
|
const jwtSecret = restoredIdentity(input.jwtSecret || "", "jwtSecret");
|
|
1368
2225
|
const resolvedJwtSecret = input.existingInstallation ? jwtSecret : required(jwtSecret, "VOID_PLATFORM_JWT_SECRET");
|
|
1369
2226
|
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.");
|
|
2227
|
+
const emailEnabled = input.emailEnabled === true;
|
|
2228
|
+
const existingEmailEnabled = input.existingEmailEnabled === true;
|
|
2229
|
+
const emailSigningSecretInScope = emailEnabled || existingEmailEnabled;
|
|
2230
|
+
let emailSigningSecret = emailSigningSecretInScope ? restoredIdentity(input.emailSigningSecret || "", "emailSigningSecret") : checkpoint.emailSigningSecret || "";
|
|
2231
|
+
if (emailEnabled && !emailSigningSecret) {
|
|
2232
|
+
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.");
|
|
2233
|
+
emailSigningSecret = randomBytes(32).toString("base64");
|
|
2234
|
+
}
|
|
2235
|
+
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.");
|
|
2236
|
+
if (emailSigningSecretInScope && emailSigningSecret && emailSigningSecret === resolvedJwtSecret) throw new Error("platform: VOID_PLATFORM_EMAIL_SIGNING_SECRET must be different from VOID_PLATFORM_JWT_SECRET.");
|
|
1370
2237
|
const projectSecretKeyring = resolveProjectSecretKeyring({
|
|
1371
2238
|
checkpoint,
|
|
1372
2239
|
existingInstallation,
|
|
@@ -1376,22 +2243,34 @@ function createPlatformInstallationSecrets(input) {
|
|
|
1376
2243
|
required
|
|
1377
2244
|
});
|
|
1378
2245
|
const pendingWorkerSecretUpdateIntent = parseWorkerSecretUpdateIntent(checkpoint.workerSecretUpdatesJson);
|
|
2246
|
+
const emailSigningSecretUpdateAuthorized = emailSigningSecretInScope && !!emailSigningSecret && (emailEnabled && (!existingEmailEnabled || !checkpoint.emailSigningSecret) || !!input.emailSigningSecret);
|
|
1379
2247
|
const explicitWorkerSecretUpdates = withoutEmptySecrets({
|
|
1380
2248
|
CF_API_TOKEN: input.cloudflareApiToken,
|
|
2249
|
+
...emailSigningSecretUpdateAuthorized ? { EMAIL_SIGNING_SECRET: emailSigningSecret } : {},
|
|
1381
2250
|
GITHUB_CLIENT_ID: input.githubClientId,
|
|
1382
2251
|
GITHUB_CLIENT_SECRET: input.githubClientSecret,
|
|
1383
2252
|
R2_ACCESS_KEY_ID: input.r2AccessKeyId,
|
|
1384
2253
|
R2_SECRET_ACCESS_KEY: input.r2SecretAccessKey
|
|
1385
2254
|
});
|
|
1386
2255
|
return {
|
|
2256
|
+
authenticationPreparation: Object.fromEntries(Object.entries(checkpoint).filter(([name]) => isPlatformAuthenticationPreparationKey(name))),
|
|
2257
|
+
...modernAuthentication ? {
|
|
2258
|
+
authenticationSetupJson,
|
|
2259
|
+
authSetupToken,
|
|
2260
|
+
authBootstrapCode
|
|
2261
|
+
} : {},
|
|
2262
|
+
...checkpoint.accessProvisioningJson ? { accessProvisioningJson: checkpoint.accessProvisioningJson } : {},
|
|
2263
|
+
...checkpoint.accessCredentialsJson ? { accessCredentialsJson: checkpoint.accessCredentialsJson } : {},
|
|
1387
2264
|
workerSecretUpdates: input.updateExistingWorkers ? explicitWorkerSecretUpdates : {},
|
|
1388
2265
|
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"),
|
|
2266
|
+
bootstrapGitHubId: existingInstallation || modernAuthentication ? configured(input.bootstrapGitHubId, "bootstrapGitHubId") : required(configured(input.bootstrapGitHubId, "bootstrapGitHubId"), "resolved administrator GitHub account id"),
|
|
2267
|
+
bootstrapGitHubLogin: existingInstallation || modernAuthentication ? configured(input.bootstrapGitHubLogin, "bootstrapGitHubLogin") : required(configured(input.bootstrapGitHubLogin, "bootstrapGitHubLogin"), "VOID_PLATFORM_ADMIN_GITHUB_LOGIN"),
|
|
1391
2268
|
cloudflareApiToken: existingInstallation ? configured(input.cloudflareApiToken, "cloudflareApiToken") : required(configured(input.cloudflareApiToken, "cloudflareApiToken"), "VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN"),
|
|
1392
2269
|
databaseUrl: required(input.databaseUrl || checkpoint.databaseUrl || "", "DATABASE_URL"),
|
|
1393
|
-
|
|
1394
|
-
|
|
2270
|
+
emailEnabled,
|
|
2271
|
+
emailSigningSecret,
|
|
2272
|
+
githubClientId: existingInstallation || modernAuthentication ? configured(input.githubClientId, "githubClientId") : required(configured(input.githubClientId, "githubClientId"), "VOID_PLATFORM_GITHUB_CLIENT_ID"),
|
|
2273
|
+
githubClientSecret: existingInstallation || modernAuthentication ? configured(input.githubClientSecret, "githubClientSecret") : required(configured(input.githubClientSecret, "githubClientSecret"), "VOID_PLATFORM_GITHUB_CLIENT_SECRET"),
|
|
1395
2274
|
jwtSecret: resolvedJwtSecret,
|
|
1396
2275
|
projectSecretActiveKeyVersion: projectSecretKeyring.activeVersion,
|
|
1397
2276
|
projectSecretKey: projectSecretKeyring.v1Key,
|
|
@@ -1450,6 +2329,14 @@ function assertCanonicalProjectSecretKey(value, name, uniqueToInstallation = fal
|
|
|
1450
2329
|
}
|
|
1451
2330
|
function secretCheckpoint(secrets, workerSecretUpdateIntent) {
|
|
1452
2331
|
return {
|
|
2332
|
+
...Object.fromEntries(Object.entries(secrets.authenticationPreparation ?? {}).filter(([name]) => isPlatformAuthenticationPreparationKey(name))),
|
|
2333
|
+
...secrets.authSetupToken ? {
|
|
2334
|
+
authenticationSetupJson: secrets.authenticationSetupJson ?? "",
|
|
2335
|
+
authSetupToken: secrets.authSetupToken ?? "",
|
|
2336
|
+
authBootstrapCode: secrets.authBootstrapCode ?? ""
|
|
2337
|
+
} : {},
|
|
2338
|
+
...secrets.accessProvisioningJson ? { accessProvisioningJson: secrets.accessProvisioningJson } : {},
|
|
2339
|
+
...secrets.accessCredentialsJson ? { accessCredentialsJson: secrets.accessCredentialsJson } : {},
|
|
1453
2340
|
bootstrapGitHubId: secrets.bootstrapGitHubId,
|
|
1454
2341
|
bootstrapGitHubLogin: secrets.bootstrapGitHubLogin,
|
|
1455
2342
|
cloudflareApiToken: secrets.cloudflareApiToken,
|
|
@@ -1457,6 +2344,7 @@ function secretCheckpoint(secrets, workerSecretUpdateIntent) {
|
|
|
1457
2344
|
githubClientId: secrets.githubClientId,
|
|
1458
2345
|
githubClientSecret: secrets.githubClientSecret,
|
|
1459
2346
|
jwtSecret: secrets.jwtSecret,
|
|
2347
|
+
emailSigningSecret: secrets.emailSigningSecret,
|
|
1460
2348
|
projectSecretActiveKeyVersion: secrets.projectSecretActiveKeyVersion,
|
|
1461
2349
|
projectSecretKey: secrets.projectSecretKey,
|
|
1462
2350
|
projectSecretKeysJson: secrets.projectSecretKeysJson,
|
|
@@ -1499,6 +2387,22 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
1499
2387
|
const resolvedRuntime = dependencies.runtime ?? resolvePlatformRuntime();
|
|
1500
2388
|
const runtimeDirectory = resolvedRuntime.directory;
|
|
1501
2389
|
const runtime = resolvedRuntime.manifest;
|
|
2390
|
+
const hasEmailGatewayArtifact = runtime.artifacts.emailGateway !== void 0;
|
|
2391
|
+
if (runtime.emailProtocolVersion !== 1 && plan.deployment.capabilities.email) throw new Error("platform: This runtime cannot serve enabled platform email without email protocol version 1.");
|
|
2392
|
+
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.");
|
|
2393
|
+
const workerKeys = hasEmailGatewayArtifact ? [
|
|
2394
|
+
"api",
|
|
2395
|
+
"proxy",
|
|
2396
|
+
"emailGateway",
|
|
2397
|
+
"tail",
|
|
2398
|
+
"dispatch"
|
|
2399
|
+
] : [
|
|
2400
|
+
"api",
|
|
2401
|
+
"proxy",
|
|
2402
|
+
"tail",
|
|
2403
|
+
"dispatch"
|
|
2404
|
+
];
|
|
2405
|
+
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
2406
|
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
2407
|
verifyRuntimeArtifacts(runtimeDirectory, runtime);
|
|
1504
2408
|
const runtimeProvenance = {
|
|
@@ -1512,11 +2416,14 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
1512
2416
|
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
2417
|
const wasDisabled = existing?.status === "disabled";
|
|
1514
2418
|
const preserveDisabled = existing?.desiredState === "disabled";
|
|
2419
|
+
const shouldBootstrapSharedEmail = !preserveDisabled && plan.deployment.capabilities.email && runtime.emailProtocolVersion === 1 && !!plan.deployment.emailSharedZoneId;
|
|
1515
2420
|
const previousPublicEndpoints = stablePublicEndpointState(existing);
|
|
2421
|
+
const authenticationProtocolTransition = hadPriorWorkers && existing?.deployment.authentication.configurable !== true && runtime.authenticationProtocolVersion === 1;
|
|
1516
2422
|
let workerCutoverGeneration = 0;
|
|
1517
2423
|
let workerRollbacks = [];
|
|
1518
2424
|
let workerDeploymentCommitted = false;
|
|
1519
2425
|
let workerTrafficGuarded = false;
|
|
2426
|
+
let authenticationEnforcementActive = false;
|
|
1520
2427
|
let manifest = existing;
|
|
1521
2428
|
if (!manifest) {
|
|
1522
2429
|
const createdAt = now();
|
|
@@ -1548,7 +2455,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
1548
2455
|
manifest = setInstallationDesiredState(manifest, "disabled", now());
|
|
1549
2456
|
writeInstallationManifest(manifest);
|
|
1550
2457
|
}
|
|
1551
|
-
assertRequiredPlatformSecrets(plan, manifest, secrets);
|
|
2458
|
+
assertRequiredPlatformSecrets(plan, manifest, secrets, !!dependencies.prepareAuthentication);
|
|
1552
2459
|
const pendingSecrets = secretCheckpoint(secrets, workerSecretUpdateIntent);
|
|
1553
2460
|
writePendingInstallationSecrets(manifest, {
|
|
1554
2461
|
...pendingSecrets,
|
|
@@ -1581,22 +2488,83 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
1581
2488
|
assertPlatformWorkerSecretUpdateAuthority({
|
|
1582
2489
|
explicitUpdates: explicitWorkerSecretUpdates,
|
|
1583
2490
|
localIntent: secrets.pendingWorkerSecretUpdateIntent,
|
|
1584
|
-
manifest
|
|
2491
|
+
manifest,
|
|
2492
|
+
requiresEmailSigningSecret: manifest.deployment.capabilities.email || plan.deployment.capabilities.email
|
|
1585
2493
|
});
|
|
1586
2494
|
} catch (error) {
|
|
1587
2495
|
await releaseDatabaseLock();
|
|
1588
2496
|
throw error;
|
|
1589
2497
|
}
|
|
1590
2498
|
if (workerSecretUpdateIntent && !hasExplicitWorkerSecretUpdates && platformWorkerSecretUpdateIntentState(manifest, workerSecretUpdateIntent) !== "active") workerSecretUpdateIntent = null;
|
|
1591
|
-
|
|
2499
|
+
let workerDeploymentSecrets = {
|
|
1592
2500
|
...secrets,
|
|
1593
2501
|
workerSecretUpdates: workerSecretUpdateIntent?.updates ?? {}
|
|
1594
2502
|
};
|
|
1595
2503
|
try {
|
|
1596
2504
|
assertPlatformRuntimeSafety(runtime, manifest.safetyProtocols, releaseDatabaseLock.requiredSafetyProtocols);
|
|
1597
2505
|
if (existing) await dependencies.verifyExistingOwnership?.(manifest, authoritative !== null && authoritative !== void 0);
|
|
2506
|
+
const liveAuthentication = await releaseDatabaseLock.readAuthenticationState?.();
|
|
2507
|
+
if (liveAuthentication) {
|
|
2508
|
+
authenticationEnforcementActive = liveAuthentication.sessionsRequired;
|
|
2509
|
+
if (liveAuthentication.sessionsRequired && runtime.authenticationProtocolVersion !== 1) throw new Error("platform: This runtime cannot enforce the active authentication configuration. Use an authentication-compatible runtime.");
|
|
2510
|
+
const deployment = mergePlatformLiveAuthenticationDeployment(plan.deployment, liveAuthentication);
|
|
2511
|
+
plan = {
|
|
2512
|
+
...plan,
|
|
2513
|
+
deployment
|
|
2514
|
+
};
|
|
2515
|
+
if (!authenticationProtocolTransition) manifest = {
|
|
2516
|
+
...manifest,
|
|
2517
|
+
deployment
|
|
2518
|
+
};
|
|
2519
|
+
}
|
|
2520
|
+
if (!liveAuthentication && manifest.deployment.authentication.configurable && !plan.deployment.authentication.configurable) {
|
|
2521
|
+
const authentication = manifest.deployment.authentication;
|
|
2522
|
+
const providers = authentication.providers;
|
|
2523
|
+
plan = {
|
|
2524
|
+
...plan,
|
|
2525
|
+
deployment: createPlatformDeploymentManifest({
|
|
2526
|
+
...plan.deployment,
|
|
2527
|
+
authentication,
|
|
2528
|
+
capabilities: {
|
|
2529
|
+
...plan.deployment.capabilities,
|
|
2530
|
+
githubOAuth: providers.includes("github"),
|
|
2531
|
+
googleOAuth: providers.includes("google")
|
|
2532
|
+
}
|
|
2533
|
+
})
|
|
2534
|
+
};
|
|
2535
|
+
}
|
|
2536
|
+
if (authenticationProtocolTransition && !plan.deployment.authentication.configurable) {
|
|
2537
|
+
const providers = plan.deployment.authentication.providers;
|
|
2538
|
+
const deployment = createPlatformDeploymentManifest({
|
|
2539
|
+
...plan.deployment,
|
|
2540
|
+
authentication: {
|
|
2541
|
+
providers,
|
|
2542
|
+
configurable: true
|
|
2543
|
+
},
|
|
2544
|
+
capabilities: {
|
|
2545
|
+
...plan.deployment.capabilities,
|
|
2546
|
+
githubOAuth: providers.includes("github"),
|
|
2547
|
+
googleOAuth: providers.includes("google")
|
|
2548
|
+
}
|
|
2549
|
+
});
|
|
2550
|
+
plan = {
|
|
2551
|
+
...plan,
|
|
2552
|
+
deployment
|
|
2553
|
+
};
|
|
2554
|
+
}
|
|
2555
|
+
const activateAccessCredentials = () => {
|
|
2556
|
+
if (!secrets.accessCredentialsJson) return;
|
|
2557
|
+
const credentials = JSON.parse(secrets.accessCredentialsJson);
|
|
2558
|
+
if (!credentials || typeof credentials !== "object" || Array.isArray(credentials)) throw new Error("platform: Invalid saved Access credentials.");
|
|
2559
|
+
const origins = [plan.apiUrl, plan.proxyUrl];
|
|
2560
|
+
activatePlatformAccessCredentials(JSON.stringify(Object.fromEntries(Object.entries(credentials).filter(([origin]) => origins.includes(origin)))), origins);
|
|
2561
|
+
};
|
|
2562
|
+
activateAccessCredentials();
|
|
2563
|
+
manifest = checkpointAuthenticationResources(manifest, secrets, now());
|
|
2564
|
+
writeInstallationManifest(manifest);
|
|
2565
|
+
await releaseDatabaseLock.checkpoint?.(manifest);
|
|
1598
2566
|
dependencies.onProgress?.("Checking and creating Cloudflare resources");
|
|
1599
|
-
if (JSON.stringify(manifest.deployment) !== JSON.stringify(plan.deployment)) {
|
|
2567
|
+
if (!authenticationProtocolTransition && JSON.stringify(manifest.deployment) !== JSON.stringify(plan.deployment)) {
|
|
1600
2568
|
manifest = {
|
|
1601
2569
|
...manifest,
|
|
1602
2570
|
deployment: plan.deployment,
|
|
@@ -1610,6 +2578,61 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
1610
2578
|
manifest = transitionInstallation(manifest, "provisioning", now());
|
|
1611
2579
|
writeInstallationManifest(manifest);
|
|
1612
2580
|
}
|
|
2581
|
+
if (dependencies.prepareAuthentication) {
|
|
2582
|
+
dependencies.onProgress?.("Preparing platform authentication");
|
|
2583
|
+
try {
|
|
2584
|
+
secrets = {
|
|
2585
|
+
...secrets,
|
|
2586
|
+
...await dependencies.prepareAuthentication(manifest, secrets)
|
|
2587
|
+
};
|
|
2588
|
+
} catch (error) {
|
|
2589
|
+
const latest = readPendingInstallationSecrets(manifest) ?? {};
|
|
2590
|
+
secrets = {
|
|
2591
|
+
...secrets,
|
|
2592
|
+
authenticationPreparation: Object.fromEntries(Object.entries(latest).filter(([name]) => isPlatformAuthenticationPreparationKey(name))),
|
|
2593
|
+
authenticationSetupJson: latest.authenticationSetupJson || secrets.authenticationSetupJson,
|
|
2594
|
+
authSetupToken: latest.authSetupToken || secrets.authSetupToken,
|
|
2595
|
+
authBootstrapCode: latest.authBootstrapCode || secrets.authBootstrapCode,
|
|
2596
|
+
accessProvisioningJson: latest.accessProvisioningJson || secrets.accessProvisioningJson,
|
|
2597
|
+
accessCredentialsJson: latest.accessCredentialsJson || secrets.accessCredentialsJson
|
|
2598
|
+
};
|
|
2599
|
+
manifest = checkpointAuthenticationResources(manifest, secrets, now());
|
|
2600
|
+
writeInstallationManifest(manifest);
|
|
2601
|
+
await releaseDatabaseLock.checkpoint?.(manifest);
|
|
2602
|
+
throw error;
|
|
2603
|
+
}
|
|
2604
|
+
const latest = readPendingInstallationSecrets(manifest) ?? {};
|
|
2605
|
+
secrets = {
|
|
2606
|
+
...secrets,
|
|
2607
|
+
authenticationPreparation: Object.fromEntries(Object.entries(latest).filter(([name]) => isPlatformAuthenticationPreparationKey(name)))
|
|
2608
|
+
};
|
|
2609
|
+
manifest = checkpointAuthenticationResources(manifest, secrets, now());
|
|
2610
|
+
writeInstallationManifest(manifest);
|
|
2611
|
+
await releaseDatabaseLock.checkpoint?.(manifest);
|
|
2612
|
+
activateAccessCredentials();
|
|
2613
|
+
assertRequiredPlatformSecrets(plan, manifest, secrets);
|
|
2614
|
+
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.");
|
|
2615
|
+
workerDeploymentSecrets = {
|
|
2616
|
+
...secrets,
|
|
2617
|
+
workerSecretUpdates: workerSecretUpdateIntent?.updates ?? {}
|
|
2618
|
+
};
|
|
2619
|
+
writePendingInstallationSecrets(manifest, {
|
|
2620
|
+
...secretCheckpoint(secrets, workerSecretUpdateIntent),
|
|
2621
|
+
databaseClaimState: "claimed"
|
|
2622
|
+
});
|
|
2623
|
+
}
|
|
2624
|
+
if (plan.deployment.authentication.accessProtection) {
|
|
2625
|
+
const protection = liveAuthentication ? liveAuthentication.protection : secrets.authenticationSetupJson ? parsePlatformAuthenticationSetup(JSON.parse(secrets.authenticationSetupJson)).protection : null;
|
|
2626
|
+
if (!protection) throw new Error("platform: The active Access protection configuration is unavailable. Restore its database and encrypted setup checkpoint before maintenance.");
|
|
2627
|
+
dependencies.onProgress?.("Verifying current Access policies and protected origins");
|
|
2628
|
+
await (dependencies.verifyAccessProtection ?? verifyInstalledPlatformAccessProtection)({
|
|
2629
|
+
client: dependencies.client,
|
|
2630
|
+
manifest,
|
|
2631
|
+
plan,
|
|
2632
|
+
protection,
|
|
2633
|
+
checkpoint: secretCheckpoint(secrets, workerSecretUpdateIntent)
|
|
2634
|
+
});
|
|
2635
|
+
}
|
|
1613
2636
|
const routingAction = plan.actions.find((action) => action.key === "routing-kv");
|
|
1614
2637
|
if (routingAction.action === "adopt") throw new Error("platform: The routing checkpoint cannot be adopted.");
|
|
1615
2638
|
if (routingAction.action === "reuse") {
|
|
@@ -1692,6 +2715,10 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
1692
2715
|
await writeR2OwnershipMarkerIfNeeded(dependencies.client, plan, manifest, action);
|
|
1693
2716
|
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1694
2717
|
}
|
|
2718
|
+
dependencies.onProgress?.("Verifying uncached authentication database reads");
|
|
2719
|
+
const authenticationDatabase = manifest.resources.find((resource) => resource.key === "hyperdrive");
|
|
2720
|
+
if (!authenticationDatabase) throw new Error("platform: The authentication database resource is missing.");
|
|
2721
|
+
await verifyPlatformAuthenticationDatabase(dependencies.client, plan.account.id, authenticationDatabase, secrets.databaseUrl);
|
|
1695
2722
|
dependencies.onProgress?.("Checking the platform database schema");
|
|
1696
2723
|
const rollbackDatabase = dependencies.databaseRollbackSource;
|
|
1697
2724
|
const migrationStep = `database-schema:${runtime.database.schemaDigest}`;
|
|
@@ -1771,17 +2798,24 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
1771
2798
|
manifest = recordInstallationStep(manifest, {
|
|
1772
2799
|
completedAt: now(),
|
|
1773
2800
|
key: bootstrapStep,
|
|
1774
|
-
resultHash: hash(secrets.bootstrapGitHubId)
|
|
2801
|
+
resultHash: hash(secrets.authenticationSetupJson ?? secrets.bootstrapGitHubId)
|
|
1775
2802
|
}, now());
|
|
1776
2803
|
writeInstallationManifest(manifest);
|
|
1777
2804
|
}
|
|
1778
2805
|
const entrypoints = Object.fromEntries(Object.entries(runtime.artifacts).map(([key, artifact]) => [key, join(runtimeDirectory, artifact.path)]));
|
|
1779
|
-
|
|
2806
|
+
let interruptedCutover = interruptedWorkerCutover(manifest);
|
|
2807
|
+
if (interruptedCutover && authenticationProtocolTransition && authenticationEnforcementActive) {
|
|
2808
|
+
manifest = recordWorkerCutoverResolution(manifest, interruptedCutover.generation, "committed", now());
|
|
2809
|
+
manifest = recordWorkerTrafficState(manifest, interruptedCutover.generation, false, now());
|
|
2810
|
+
writeInstallationManifest(manifest);
|
|
2811
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
2812
|
+
interruptedCutover = null;
|
|
2813
|
+
}
|
|
1780
2814
|
const recoveringUnsafeTraffic = (interruptedCutover?.unsafeTraffic.size ?? 0) > 0;
|
|
1781
|
-
const createsWorker = plan.actions.some((action) => action.kind === "worker" && action.action === "create");
|
|
2815
|
+
const createsWorker = plan.actions.some((action) => action.kind === "worker" && action.action === "create" && (hasEmailGatewayArtifact || action.key !== "email-gateway-worker"));
|
|
1782
2816
|
const recoveringCreatedWorker = interruptedCutover !== null && [...interruptedCutover.staged.values()].some((snapshot) => snapshot.previousVersionId === null);
|
|
1783
2817
|
const requiresSandboxDrain = !preserveDisabled && targetSandboxDrainProtocol >= 1 && sandboxDrainState === "pending";
|
|
1784
|
-
const requiresTrafficGuard = !previousPublicEndpoints || recoveringUnsafeTraffic || createsWorker || recoveringCreatedWorker || manifest.usageProtocolVersion !== targetUsageProtocolVersion || requiresSandboxDrain;
|
|
2818
|
+
const requiresTrafficGuard = !previousPublicEndpoints || recoveringUnsafeTraffic || createsWorker || recoveringCreatedWorker || manifest.usageProtocolVersion !== targetUsageProtocolVersion || authenticationProtocolTransition || shouldBootstrapSharedEmail || requiresSandboxDrain;
|
|
1785
2819
|
if (requiresTrafficGuard) {
|
|
1786
2820
|
await dependencies.client.putKvValue(plan.account.id, requireId(ids, "routing-kv"), PLATFORM_DISABLED_KEY, PLATFORM_DISABLED_VALUE);
|
|
1787
2821
|
workerTrafficGuarded = true;
|
|
@@ -1797,6 +2831,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
1797
2831
|
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1798
2832
|
}
|
|
1799
2833
|
const configInput = {
|
|
2834
|
+
authenticationDatabaseUncached: true,
|
|
1800
2835
|
accountId: plan.account.id,
|
|
1801
2836
|
apiUrl: plan.apiUrl,
|
|
1802
2837
|
deployment: plan.deployment,
|
|
@@ -1805,6 +2840,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
1805
2840
|
ownershipNonce: manifest.ownershipNonce,
|
|
1806
2841
|
platformVersion: version,
|
|
1807
2842
|
proxyUrl: plan.proxyUrl,
|
|
2843
|
+
emailGatewayUrl: `https://${plan.deployment.resources.emailGatewayWorker}.${plan.workersSubdomain}.workers.dev`,
|
|
1808
2844
|
resources: {
|
|
1809
2845
|
hyperdrive: requireId(ids, "hyperdrive"),
|
|
1810
2846
|
isrCacheKv: requireId(ids, "isr-cache-kv"),
|
|
@@ -1831,7 +2867,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
1831
2867
|
trafficState: previousPublicEndpoints ? "enabled" : "staging"
|
|
1832
2868
|
});
|
|
1833
2869
|
const managedRoutes = platformManagedWorkerTraffic(plan.deployment).routes;
|
|
1834
|
-
let workerActions = plan.actions.filter((action) => action.kind === "worker");
|
|
2870
|
+
let workerActions = plan.actions.filter((action) => action.kind === "worker" && (hasEmailGatewayArtifact || action.key !== "email-gateway-worker"));
|
|
1835
2871
|
if (interruptedCutover) {
|
|
1836
2872
|
for (const key of [...interruptedCutover.staged.keys()].reverse()) {
|
|
1837
2873
|
const snapshot = interruptedCutover.staged.get(key);
|
|
@@ -1866,7 +2902,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
1866
2902
|
previousTriggers: mergePreservedTriggerRoutes(withoutPlatformCustomDomainTriggers(snapshot.previousTriggers), preservedRoutes),
|
|
1867
2903
|
previousVersionId: snapshot.previousVersionId,
|
|
1868
2904
|
preserveCustomDomains: interruptedCutover.unsafeTraffic.has(key),
|
|
1869
|
-
inspectNewWorker: () => inspectNewPlatformWorker(dependencies.client, plan.account.id, manifest, `${key}-worker`, snapshot.workerName),
|
|
2905
|
+
inspectNewWorker: () => inspectNewPlatformWorker(dependencies.client, plan.account.id, manifest, key === "emailGateway" ? "email-gateway-worker" : `${key}-worker`, snapshot.workerName),
|
|
1870
2906
|
removeCustomDomains: async () => {
|
|
1871
2907
|
if (!dependencies.client.removeRoutes) throw new Error("platform: Cloudflare route removal is unavailable.");
|
|
1872
2908
|
await dependencies.client.removeRoutes(plan.account.id, zoneId, trackedPlatformWorkerTraffic(manifest, /* @__PURE__ */ new Set([snapshot.workerName])));
|
|
@@ -1892,7 +2928,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
1892
2928
|
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1893
2929
|
if (retainedNewWorker) {
|
|
1894
2930
|
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));
|
|
2931
|
+
workerActions = validateReplannedWorkerActions(plan, manifest, await dependencies.replanWorkers(manifest)).filter((action) => hasEmailGatewayArtifact || action.key !== "email-gateway-worker");
|
|
1896
2932
|
}
|
|
1897
2933
|
}
|
|
1898
2934
|
workerCutoverGeneration = nextWorkerCutoverGeneration(manifest);
|
|
@@ -1906,14 +2942,9 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
1906
2942
|
};
|
|
1907
2943
|
writePendingInstallationSecrets(manifest, secretCheckpoint(secrets, workerSecretUpdateIntent));
|
|
1908
2944
|
}
|
|
1909
|
-
for (const key of
|
|
1910
|
-
"api",
|
|
1911
|
-
"proxy",
|
|
1912
|
-
"tail",
|
|
1913
|
-
"dispatch"
|
|
1914
|
-
]) {
|
|
2945
|
+
for (const key of workerKeys) {
|
|
1915
2946
|
dependencies.onProgress?.(`Deploying the ${key} service`);
|
|
1916
|
-
const action = workerActions.find((entry) => entry.key === `${key}-worker`);
|
|
2947
|
+
const action = workerActions.find((entry) => entry.key === (key === "emailGateway" ? "email-gateway-worker" : `${key}-worker`));
|
|
1917
2948
|
const workerSecrets = platformWorkerSecretPayload(key, workerDeploymentSecrets, plan.account.id, zoneId, action.action !== "create");
|
|
1918
2949
|
manifest = recordProvisionIntent(manifest, action, now());
|
|
1919
2950
|
writeInstallationManifest(manifest);
|
|
@@ -1982,6 +3013,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
1982
3013
|
if (workerSecretUpdateIntent) manifest = recordWorkerSecretUpdateState(manifest, workerCutoverGeneration, "committed", workerSecretUpdateIntent.id, now());
|
|
1983
3014
|
manifest = {
|
|
1984
3015
|
...manifest,
|
|
3016
|
+
deployment: plan.deployment,
|
|
1985
3017
|
platformVersion: version,
|
|
1986
3018
|
runtime: runtimeProvenance,
|
|
1987
3019
|
updatedAt: now(),
|
|
@@ -2000,17 +3032,63 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
2000
3032
|
zoneNameServers: zone?.nameServers ?? []
|
|
2001
3033
|
};
|
|
2002
3034
|
}
|
|
3035
|
+
let authenticationCutoverCommitted = false;
|
|
3036
|
+
if (authenticationProtocolTransition && requiresStagedCommit) {
|
|
3037
|
+
manifest = recordWorkerCutoverResolution(manifest, workerCutoverGeneration, "committed", now());
|
|
3038
|
+
manifest = recordWorkerTrafficState(manifest, workerCutoverGeneration, false, now());
|
|
3039
|
+
writeInstallationManifest(manifest);
|
|
3040
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
3041
|
+
workerRollbacks = [];
|
|
3042
|
+
authenticationCutoverCommitted = true;
|
|
3043
|
+
}
|
|
3044
|
+
let authenticationStep = secrets.authenticationSetupJson ? "authentication-initialized:v1:" + hash(secrets.authenticationSetupJson) : "";
|
|
3045
|
+
let setupPending = false;
|
|
2003
3046
|
manifest = transitionInstallation(manifest, "verifying", now());
|
|
2004
3047
|
writeInstallationManifest(manifest);
|
|
3048
|
+
if (plan.deployment.authentication.configurable && authenticationStep) {
|
|
3049
|
+
if (!secrets.authenticationSetupJson || !secrets.authSetupToken) throw new Error("platform: Restore the encrypted authentication setup checkpoint before resuming.");
|
|
3050
|
+
dependencies.onProgress?.("Initializing platform authentication");
|
|
3051
|
+
if ((await resumeInstalledPlatformAuthentication({
|
|
3052
|
+
apiUrl: plan.apiUrl,
|
|
3053
|
+
installationId: manifest.id,
|
|
3054
|
+
setupToken: secrets.authSetupToken,
|
|
3055
|
+
setupJson: secrets.authenticationSetupJson,
|
|
3056
|
+
checkpoint: (replacement) => {
|
|
3057
|
+
secrets = {
|
|
3058
|
+
...secrets,
|
|
3059
|
+
...replacement
|
|
3060
|
+
};
|
|
3061
|
+
authenticationStep = "authentication-initialized:v1:" + hash(replacement.authenticationSetupJson);
|
|
3062
|
+
writePendingInstallationSecrets(manifest, secretCheckpoint(secrets, workerSecretUpdateIntent));
|
|
3063
|
+
}
|
|
3064
|
+
}, dependencies.initializeAuthentication ?? initializeInstalledPlatformAuthentication)).setupPending) {
|
|
3065
|
+
setupPending = true;
|
|
3066
|
+
dependencies.onProgress?.(`Administrator setup pending: ${plan.apiUrl}/setup`);
|
|
3067
|
+
}
|
|
3068
|
+
manifest = recordInstallationStep(manifest, {
|
|
3069
|
+
key: authenticationStep,
|
|
3070
|
+
completedAt: now(),
|
|
3071
|
+
resultHash: hash(secrets.authenticationSetupJson)
|
|
3072
|
+
}, now());
|
|
3073
|
+
writeInstallationManifest(manifest);
|
|
3074
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
3075
|
+
}
|
|
2005
3076
|
dependencies.onProgress?.("Verifying platform health");
|
|
3077
|
+
const emailGatewayUrl = hasEmailGatewayArtifact ? `https://${plan.deployment.resources.emailGatewayWorker}.${plan.workersSubdomain}.workers.dev` : void 0;
|
|
3078
|
+
if (plan.deployment.authentication.accessProtection) {
|
|
3079
|
+
ensurePlatformAccessToken(plan.apiUrl, false, true);
|
|
3080
|
+
ensurePlatformAccessToken(plan.proxyUrl, false, true);
|
|
3081
|
+
}
|
|
2006
3082
|
const healthCheckContext = {
|
|
2007
3083
|
accountName: plan.account.name,
|
|
2008
3084
|
workers: [
|
|
2009
3085
|
plan.deployment.resources.apiWorker,
|
|
2010
3086
|
plan.deployment.resources.proxyWorker,
|
|
3087
|
+
...hasEmailGatewayArtifact ? [plan.deployment.resources.emailGatewayWorker] : [],
|
|
2011
3088
|
plan.deployment.resources.dispatchWorker
|
|
2012
3089
|
],
|
|
2013
|
-
resumeCommand: dependencies.healthCheckResumeCommand
|
|
3090
|
+
resumeCommand: dependencies.healthCheckResumeCommand,
|
|
3091
|
+
...emailGatewayUrl ? { emailGatewayUrl } : {}
|
|
2014
3092
|
};
|
|
2015
3093
|
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
3094
|
if (requiresSandboxDrain) {
|
|
@@ -2039,11 +3117,13 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
2039
3117
|
await writeSandboxDrainMirror(completedDrain);
|
|
2040
3118
|
}
|
|
2041
3119
|
if (requiresStagedCommit) {
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
3120
|
+
if (!authenticationCutoverCommitted) {
|
|
3121
|
+
manifest = recordWorkerCutoverResolution(manifest, workerCutoverGeneration, "committed", now());
|
|
3122
|
+
manifest = recordWorkerTrafficState(manifest, workerCutoverGeneration, false, now());
|
|
3123
|
+
writeInstallationManifest(manifest);
|
|
3124
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
3125
|
+
workerRollbacks = [];
|
|
3126
|
+
}
|
|
2047
3127
|
workerCutoverGeneration = nextWorkerCutoverGeneration(manifest);
|
|
2048
3128
|
if (workerSecretUpdateIntent) {
|
|
2049
3129
|
manifest = recordWorkerSecretUpdateState(manifest, workerCutoverGeneration, "pending", workerSecretUpdateIntent.id, now());
|
|
@@ -2055,14 +3135,9 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
2055
3135
|
};
|
|
2056
3136
|
writePendingInstallationSecrets(manifest, secretCheckpoint(secrets, workerSecretUpdateIntent));
|
|
2057
3137
|
}
|
|
2058
|
-
for (const key of
|
|
2059
|
-
"api",
|
|
2060
|
-
"proxy",
|
|
2061
|
-
"tail",
|
|
2062
|
-
"dispatch"
|
|
2063
|
-
]) {
|
|
3138
|
+
for (const key of workerKeys) {
|
|
2064
3139
|
dependencies.onProgress?.(`Enabling the ${key} service`);
|
|
2065
|
-
const action = workerActions.find((entry) => entry.key === `${key}-worker`);
|
|
3140
|
+
const action = workerActions.find((entry) => entry.key === (key === "emailGateway" ? "email-gateway-worker" : `${key}-worker`));
|
|
2066
3141
|
const workerSecrets = platformWorkerSecretPayload(key, workerDeploymentSecrets, plan.account.id, zoneId, action.action !== "create");
|
|
2067
3142
|
const preservedRoutes = dependencies.deployWorker ? [] : await inventoryUnmanagedPlatformWorkerTraffic(dependencies.client, plan, action.name);
|
|
2068
3143
|
const workerConfig = mergePlatformWorkerTrafficConfig(enabledConfigs[key], preservedRoutes);
|
|
@@ -2116,9 +3191,20 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
2116
3191
|
const verifyCurrentWorkerCutover = async () => {
|
|
2117
3192
|
if (!requiresStagedCommit) return;
|
|
2118
3193
|
if (dependencies.verifyWorkerCutover) await dependencies.verifyWorkerCutover(manifest, workerCutoverGeneration);
|
|
2119
|
-
else if (!dependencies.deployWorker) await verifyPlatformWorkerCutoverVersions(manifest, workerCutoverGeneration, enabledConfigs, plan.account.id, runPinnedPlatformWrangler);
|
|
3194
|
+
else if (!dependencies.deployWorker) await verifyPlatformWorkerCutoverVersions(manifest, workerCutoverGeneration, enabledConfigs, plan.account.id, runPinnedPlatformWrangler, workerKeys);
|
|
2120
3195
|
};
|
|
2121
3196
|
await verifyCurrentWorkerCutover();
|
|
3197
|
+
let emailOperationId;
|
|
3198
|
+
if (shouldBootstrapSharedEmail) {
|
|
3199
|
+
workerDeploymentCommitted = true;
|
|
3200
|
+
manifest = recordWorkerCutoverResolution(manifest, workerCutoverGeneration, "committed", now());
|
|
3201
|
+
if (workerSecretUpdateIntent) manifest = recordWorkerSecretUpdateState(manifest, workerCutoverGeneration, "committed", workerSecretUpdateIntent.id, now());
|
|
3202
|
+
writeInstallationManifest(manifest);
|
|
3203
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
3204
|
+
workerRollbacks = [];
|
|
3205
|
+
dependencies.onProgress?.("Preparing shared email routing");
|
|
3206
|
+
emailOperationId = await (dependencies.bootstrapEmail ?? bootstrapPlatformSharedEmail)(plan.apiUrl, createPlatformHealthProbeToken(manifest.ownershipNonce));
|
|
3207
|
+
}
|
|
2122
3208
|
if (dependencies.client.deleteKvValue) await dependencies.client.deleteKvValue(plan.account.id, requireId(ids, "routing-kv"), PLATFORM_DISABLED_KEY);
|
|
2123
3209
|
else if (wasDisabled || !previousPublicEndpoints) throw new Error("platform: Routing disablement removal is unavailable.");
|
|
2124
3210
|
if (requiresStagedCommit) {
|
|
@@ -2132,6 +3218,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
2132
3218
|
if (workerSecretUpdateIntent) manifest = recordWorkerSecretUpdateState(manifest, workerCutoverGeneration, "committed", workerSecretUpdateIntent.id, now());
|
|
2133
3219
|
manifest = {
|
|
2134
3220
|
...manifest,
|
|
3221
|
+
deployment: plan.deployment,
|
|
2135
3222
|
platformVersion: version,
|
|
2136
3223
|
runtime: runtimeProvenance,
|
|
2137
3224
|
safetyProtocols: runtime.safetyProtocols ?? { sandboxDrain: 0 },
|
|
@@ -2147,6 +3234,8 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
2147
3234
|
saveInstalledPlatformConnection(plan, manifest);
|
|
2148
3235
|
return {
|
|
2149
3236
|
manifest,
|
|
3237
|
+
...emailOperationId ? { emailOperationId } : {},
|
|
3238
|
+
setupPending,
|
|
2150
3239
|
waitingForDns: false,
|
|
2151
3240
|
waitingForSandboxDrain: false,
|
|
2152
3241
|
zoneNameServers: zone?.nameServers ?? []
|
|
@@ -2159,7 +3248,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
2159
3248
|
} catch (guardError) {
|
|
2160
3249
|
failure = new Error(`platform: ${errorMessage(error)} The installation also failed to retain its routing safety gate: ${errorMessage(guardError)}`, { cause: error });
|
|
2161
3250
|
}
|
|
2162
|
-
if (!workerDeploymentCommitted && workerRollbacks.length > 0) {
|
|
3251
|
+
if (!workerDeploymentCommitted && workerRollbacks.length > 0 && !(authenticationEnforcementActive && authenticationProtocolTransition)) {
|
|
2163
3252
|
const rollbackFailures = [];
|
|
2164
3253
|
for (const rollback of workerRollbacks.reverse()) try {
|
|
2165
3254
|
await rollback();
|
|
@@ -2173,7 +3262,7 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
2173
3262
|
manifest = recordWorkerTrafficState(manifest, workerCutoverGeneration, previousPublicEndpoints, now());
|
|
2174
3263
|
writeInstallationManifest(manifest);
|
|
2175
3264
|
}
|
|
2176
|
-
}
|
|
3265
|
+
} else if (authenticationEnforcementActive && authenticationProtocolTransition) workerRollbacks = [];
|
|
2177
3266
|
if (manifest.status !== "failed" && manifest.status !== "uninstalled") try {
|
|
2178
3267
|
manifest = transitionInstallation(manifest, "failed", now());
|
|
2179
3268
|
writeInstallationManifest(manifest);
|
|
@@ -2184,6 +3273,9 @@ async function installPlatform(plan, secrets, existing, dependencies) {
|
|
|
2184
3273
|
await releaseDatabaseLock();
|
|
2185
3274
|
}
|
|
2186
3275
|
}
|
|
3276
|
+
function checkpointAuthenticationResources(manifest, secrets, updatedAt) {
|
|
3277
|
+
return checkpointPlatformAccessResources(manifest, secrets.accessProvisioningJson, updatedAt);
|
|
3278
|
+
}
|
|
2187
3279
|
async function writeR2OwnershipMarkerIfNeeded(client, plan, manifest, action) {
|
|
2188
3280
|
if (action.kind !== "r2-bucket") return;
|
|
2189
3281
|
const bucket = manifest.resources.find((resource) => resource.key === action.key && resource.state === "ready");
|
|
@@ -2424,8 +3516,10 @@ function apiWorkerSecrets(secrets, accountId, zoneId) {
|
|
|
2424
3516
|
CF_ZONE_ID: zoneId,
|
|
2425
3517
|
GITHUB_CLIENT_ID: secrets.githubClientId,
|
|
2426
3518
|
GITHUB_CLIENT_SECRET: secrets.githubClientSecret,
|
|
3519
|
+
...secrets.emailEnabled ? { EMAIL_SIGNING_SECRET: secrets.emailSigningSecret } : {},
|
|
2427
3520
|
JWT_SECRET: secrets.jwtSecret,
|
|
2428
3521
|
PLATFORM_BOOTSTRAP_GITHUB_ID: secrets.bootstrapGitHubId,
|
|
3522
|
+
PLATFORM_AUTH_SETUP_TOKEN: secrets.authSetupToken ?? "",
|
|
2429
3523
|
...secrets.projectSecretKeysJson && secrets.projectSecretActiveKeyVersion ? {
|
|
2430
3524
|
PROJECT_SECRET_ACTIVE_KEY_VERSION: secrets.projectSecretActiveKeyVersion,
|
|
2431
3525
|
PROJECT_SECRET_KEYS_JSON: secrets.projectSecretKeysJson
|
|
@@ -2436,15 +3530,23 @@ function apiWorkerSecrets(secrets, accountId, zoneId) {
|
|
|
2436
3530
|
}
|
|
2437
3531
|
/** Existing Workers inherit live secrets, including complete encryption keyrings. */
|
|
2438
3532
|
function platformWorkerSecretPayload(worker, secrets, accountId, zoneId, existingWorker) {
|
|
2439
|
-
if (worker !== "api" && worker !== "proxy") return null;
|
|
2440
|
-
if (!existingWorker)
|
|
3533
|
+
if (worker !== "api" && worker !== "proxy" && worker !== "emailGateway") return null;
|
|
3534
|
+
if (!existingWorker) {
|
|
3535
|
+
if (worker === "api") return apiWorkerSecrets(secrets, accountId, zoneId);
|
|
3536
|
+
if (worker === "emailGateway") {
|
|
3537
|
+
const payload = withoutEmptySecrets({ EMAIL_SIGNING_SECRET: secrets.emailEnabled ? secrets.emailSigningSecret : "" });
|
|
3538
|
+
return Object.keys(payload).length > 0 ? payload : null;
|
|
3539
|
+
}
|
|
3540
|
+
return proxyWorkerSecrets(secrets, accountId);
|
|
3541
|
+
}
|
|
2441
3542
|
const allowed = worker === "api" ? [
|
|
2442
3543
|
"CF_API_TOKEN",
|
|
3544
|
+
"EMAIL_SIGNING_SECRET",
|
|
2443
3545
|
"GITHUB_CLIENT_ID",
|
|
2444
3546
|
"GITHUB_CLIENT_SECRET",
|
|
2445
3547
|
"R2_ACCESS_KEY_ID",
|
|
2446
3548
|
"R2_SECRET_ACCESS_KEY"
|
|
2447
|
-
] : ["CF_API_TOKEN"];
|
|
3549
|
+
] : worker === "proxy" ? ["CF_API_TOKEN", "EMAIL_SIGNING_SECRET"] : ["EMAIL_SIGNING_SECRET"];
|
|
2448
3550
|
const updates = Object.fromEntries(Object.entries(secrets.workerSecretUpdates ?? {}).filter(([name, value]) => allowed.includes(name) && typeof value === "string" && value !== ""));
|
|
2449
3551
|
return Object.keys(updates).length > 0 ? updates : null;
|
|
2450
3552
|
}
|
|
@@ -2469,26 +3571,34 @@ function proxyWorkerSecrets(secrets, accountId) {
|
|
|
2469
3571
|
return withoutEmptySecrets({
|
|
2470
3572
|
CF_ACCOUNT_ID: accountId,
|
|
2471
3573
|
CF_API_TOKEN: secrets.cloudflareApiToken,
|
|
3574
|
+
...secrets.emailEnabled ? { EMAIL_SIGNING_SECRET: secrets.emailSigningSecret } : {},
|
|
2472
3575
|
JWT_SECRET: secrets.jwtSecret
|
|
2473
3576
|
});
|
|
2474
3577
|
}
|
|
2475
3578
|
function withoutEmptySecrets(secrets) {
|
|
2476
3579
|
return Object.fromEntries(Object.entries(secrets).filter(([, value]) => value !== ""));
|
|
2477
3580
|
}
|
|
2478
|
-
function assertRequiredPlatformSecrets(plan, manifest, secrets) {
|
|
3581
|
+
function assertRequiredPlatformSecrets(plan, manifest, secrets, authenticationWillBePrepared = false) {
|
|
2479
3582
|
const missing = /* @__PURE__ */ new Set();
|
|
2480
3583
|
const requireSecret = (value, name) => {
|
|
2481
3584
|
if (!value) missing.add(name);
|
|
2482
3585
|
};
|
|
2483
|
-
if (!manifest.steps.some((step) => step.key
|
|
3586
|
+
if (plan.deployment.authentication.configurable && !manifest.steps.some((step) => step.key.startsWith("authentication-initialized:v1:"))) {
|
|
3587
|
+
if (!authenticationWillBePrepared) requireSecret(secrets.authenticationSetupJson ?? "", "authentication setup configuration");
|
|
3588
|
+
requireSecret(secrets.authSetupToken ?? "", "installation authentication setup authority");
|
|
3589
|
+
requireSecret(secrets.authBootstrapCode ?? "", "administrator setup code");
|
|
3590
|
+
} else if (!plan.deployment.authentication.configurable && !manifest.steps.some((step) => step.key === "bootstrap-access:v1")) {
|
|
2484
3591
|
requireSecret(secrets.bootstrapGitHubId, "VOID_PLATFORM_ADMIN_GITHUB_ID");
|
|
2485
3592
|
requireSecret(secrets.bootstrapGitHubLogin, "VOID_PLATFORM_ADMIN_GITHUB_LOGIN");
|
|
2486
3593
|
}
|
|
2487
3594
|
if (plan.actions.some((action) => action.key === "api-worker" && action.kind === "worker" && action.action === "create")) {
|
|
2488
3595
|
requireSecret(secrets.cloudflareApiToken, "VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN");
|
|
2489
|
-
|
|
2490
|
-
|
|
3596
|
+
if (!plan.deployment.authentication.configurable) {
|
|
3597
|
+
requireSecret(secrets.githubClientId, "VOID_PLATFORM_GITHUB_CLIENT_ID");
|
|
3598
|
+
requireSecret(secrets.githubClientSecret, "VOID_PLATFORM_GITHUB_CLIENT_SECRET");
|
|
3599
|
+
}
|
|
2491
3600
|
requireSecret(secrets.jwtSecret, "VOID_PLATFORM_JWT_SECRET");
|
|
3601
|
+
if (plan.deployment.capabilities.email) requireSecret(secrets.emailSigningSecret, "VOID_PLATFORM_EMAIL_SIGNING_SECRET");
|
|
2492
3602
|
requireSecret(secrets.projectSecretKeysJson, "VOID_PLATFORM_PROJECT_SECRET_KEYS_JSON (or VOID_PLATFORM_PROJECT_SECRET_KEY for v1)");
|
|
2493
3603
|
requireSecret(secrets.projectSecretActiveKeyVersion, "VOID_PLATFORM_PROJECT_SECRET_ACTIVE_KEY_VERSION (or VOID_PLATFORM_PROJECT_SECRET_KEY for v1)");
|
|
2494
3604
|
requireSecret(secrets.r2AccessKeyId, "VOID_PLATFORM_R2_ACCESS_KEY_ID");
|
|
@@ -2497,6 +3607,10 @@ function assertRequiredPlatformSecrets(plan, manifest, secrets) {
|
|
|
2497
3607
|
if (plan.actions.some((action) => action.key === "proxy-worker" && action.kind === "worker" && action.action === "create")) {
|
|
2498
3608
|
requireSecret(secrets.cloudflareApiToken, "VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN");
|
|
2499
3609
|
requireSecret(secrets.jwtSecret, "VOID_PLATFORM_JWT_SECRET");
|
|
3610
|
+
if (plan.deployment.capabilities.email) requireSecret(secrets.emailSigningSecret, "VOID_PLATFORM_EMAIL_SIGNING_SECRET");
|
|
3611
|
+
}
|
|
3612
|
+
if (plan.actions.some((action) => action.key === "email-gateway-worker" && action.kind === "worker" && action.action === "create")) {
|
|
3613
|
+
if (plan.deployment.capabilities.email) requireSecret(secrets.emailSigningSecret, "VOID_PLATFORM_EMAIL_SIGNING_SECRET");
|
|
2500
3614
|
}
|
|
2501
3615
|
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
3616
|
}
|
|
@@ -2681,6 +3795,17 @@ async function acquirePlatformDatabaseLifecycleLockConnection(client, installati
|
|
|
2681
3795
|
await client.query("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [lockIdentity]);
|
|
2682
3796
|
};
|
|
2683
3797
|
release.authoritativeManifest = authoritativeManifest;
|
|
3798
|
+
release.readAuthenticationState = async () => {
|
|
3799
|
+
if (!(await client.query("SELECT to_regclass('public.platform_auth_settings') IS NOT NULL AS present")).rows[0]?.present) return null;
|
|
3800
|
+
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")]);
|
|
3801
|
+
const settings = result.rows[0];
|
|
3802
|
+
return settings ? {
|
|
3803
|
+
enabledProviders: enabledProviders.rows.map((row) => row.kind),
|
|
3804
|
+
sessionsRequired: settings.sessions_required,
|
|
3805
|
+
protectedByAccess: settings.protection !== null,
|
|
3806
|
+
protection: parsePlatformAccessProtection(settings.protection)
|
|
3807
|
+
} : null;
|
|
3808
|
+
};
|
|
2684
3809
|
release.requiredSafetyProtocols = await readRequiredPlatformSafetyProtocols(client);
|
|
2685
3810
|
release.checkpoint = async (manifest) => {
|
|
2686
3811
|
if (released) throw new Error("platform: Cannot checkpoint lifecycle state after releasing its lock.");
|
|
@@ -3582,7 +4707,7 @@ function interruptedWorkerCutover(manifest) {
|
|
|
3582
4707
|
generations.set(generation, entry);
|
|
3583
4708
|
continue;
|
|
3584
4709
|
}
|
|
3585
|
-
const unsafeTraffic = /^worker-cutover:(\d+):unsafe-traffic:(api|proxy|tail|dispatch)$/.exec(step.key);
|
|
4710
|
+
const unsafeTraffic = /^worker-cutover:(\d+):unsafe-traffic:(api|proxy|emailGateway|tail|dispatch)$/.exec(step.key);
|
|
3586
4711
|
if (unsafeTraffic) {
|
|
3587
4712
|
const generation = Number(unsafeTraffic[1]);
|
|
3588
4713
|
const entry = generations.get(generation) ?? {
|
|
@@ -3594,7 +4719,7 @@ function interruptedWorkerCutover(manifest) {
|
|
|
3594
4719
|
generations.set(generation, entry);
|
|
3595
4720
|
continue;
|
|
3596
4721
|
}
|
|
3597
|
-
const staged = /^worker-cutover:(\d+):staged:(api|proxy|tail|dispatch)$/.exec(step.key);
|
|
4722
|
+
const staged = /^worker-cutover:(\d+):staged:(api|proxy|emailGateway|tail|dispatch)$/.exec(step.key);
|
|
3598
4723
|
if (!staged) continue;
|
|
3599
4724
|
const generation = Number(staged[1]);
|
|
3600
4725
|
const snapshot = parseWorkerCutoverSnapshot(step.resultHash);
|
|
@@ -3614,17 +4739,18 @@ function interruptedWorkerCutover(manifest) {
|
|
|
3614
4739
|
unsafeTraffic: unresolved[1].unsafeTraffic
|
|
3615
4740
|
} : null;
|
|
3616
4741
|
}
|
|
3617
|
-
async function verifyPlatformWorkerCutoverVersions(manifest, generation, configs, accountId, runner = runPlatformWrangler
|
|
4742
|
+
async function verifyPlatformWorkerCutoverVersions(manifest, generation, configs, accountId, runner = runPlatformWrangler, workerKeys = [
|
|
4743
|
+
"api",
|
|
4744
|
+
"proxy",
|
|
4745
|
+
"emailGateway",
|
|
4746
|
+
"tail",
|
|
4747
|
+
"dispatch"
|
|
4748
|
+
]) {
|
|
3618
4749
|
const cutover = interruptedWorkerCutover(manifest);
|
|
3619
|
-
if (cutover?.generation !== generation || cutover.staged.size !==
|
|
4750
|
+
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
4751
|
const directory = mkdtempSync(join(tmpdir(), "void-platform-cutover-verify-"));
|
|
3621
4752
|
try {
|
|
3622
|
-
for (const key of
|
|
3623
|
-
"api",
|
|
3624
|
-
"proxy",
|
|
3625
|
-
"tail",
|
|
3626
|
-
"dispatch"
|
|
3627
|
-
]) {
|
|
4753
|
+
for (const key of workerKeys) {
|
|
3628
4754
|
const snapshot = cutover.staged.get(key);
|
|
3629
4755
|
if (!snapshot) throw new Error(`platform: Worker cutover generation ${generation} is missing its ${key} checkpoint.`);
|
|
3630
4756
|
const config = configs[key];
|
|
@@ -3713,11 +4839,12 @@ async function verifyPlatformHealth(apiUrl, installationId, proxyUrl, applicatio
|
|
|
3713
4839
|
signal: AbortSignal.timeout(3e4)
|
|
3714
4840
|
});
|
|
3715
4841
|
const dispatchUrl = dispatchOrigin ?? `https://test.${applicationBaseDomain}`;
|
|
3716
|
-
const [health, discovery, proxy, dispatch] = await Promise.all([
|
|
4842
|
+
const [health, discovery, proxy, dispatch, emailGateway] = await Promise.all([
|
|
3717
4843
|
request(`${apiUrl}/health`),
|
|
3718
4844
|
request(`${apiUrl}/.well-known/void-platform`),
|
|
3719
4845
|
request(`${proxyUrl}/health`),
|
|
3720
|
-
request(`${dispatchUrl}/health`)
|
|
4846
|
+
request(`${dispatchUrl}/health`),
|
|
4847
|
+
context?.emailGatewayUrl ? request(`${context.emailGatewayUrl}/health`) : void 0
|
|
3721
4848
|
]);
|
|
3722
4849
|
if (!health.ok) throw await platformProbeFailure("Health check", apiUrl, health, context);
|
|
3723
4850
|
if (!discovery.ok) throw await platformProbeFailure("Discovery check", apiUrl, discovery, context);
|
|
@@ -3727,6 +4854,11 @@ async function verifyPlatformHealth(apiUrl, installationId, proxyUrl, applicatio
|
|
|
3727
4854
|
if ((body.usageProtocolVersion ?? 0) !== usageProtocolVersion) throw new Error("platform: Deployed API usage protocol does not match this runtime. Traffic remains disabled.");
|
|
3728
4855
|
if (!proxy.ok) throw await platformProbeFailure("Proxy health check", proxyUrl, proxy, context);
|
|
3729
4856
|
if (!dispatch.ok) throw await platformProbeFailure("Dispatch health check", dispatchUrl, dispatch, context);
|
|
4857
|
+
if (emailGateway) {
|
|
4858
|
+
if (!emailGateway.ok) throw await platformProbeFailure("Email gateway health check", context.emailGatewayUrl, emailGateway, context);
|
|
4859
|
+
const identity = await emailGateway.json();
|
|
4860
|
+
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.");
|
|
4861
|
+
}
|
|
3730
4862
|
}
|
|
3731
4863
|
async function verifyPlatformSandboxDrain(apiUrl, healthProbeToken, fetcher = fetch) {
|
|
3732
4864
|
const response = await fetcher(`${apiUrl}/internal/platform/sandbox-drain`, {
|
|
@@ -3734,7 +4866,8 @@ async function verifyPlatformSandboxDrain(apiUrl, healthProbeToken, fetcher = fe
|
|
|
3734
4866
|
"X-Void-Platform-Probe": healthProbeToken,
|
|
3735
4867
|
...cfAccessHeaders(process.env, apiUrl)
|
|
3736
4868
|
},
|
|
3737
|
-
signal: AbortSignal.timeout(3e4)
|
|
4869
|
+
signal: AbortSignal.timeout(3e4),
|
|
4870
|
+
redirect: "manual"
|
|
3738
4871
|
});
|
|
3739
4872
|
if (!response.ok) throw new Error(`platform: Sandbox drain probe failed at ${apiUrl} (HTTP ${response.status}). Traffic remains disabled.`);
|
|
3740
4873
|
let value;
|
|
@@ -3750,10 +4883,268 @@ async function verifyPlatformSandboxDrain(apiUrl, healthProbeToken, fetcher = fe
|
|
|
3750
4883
|
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
4884
|
return status.state === "complete";
|
|
3752
4885
|
}
|
|
4886
|
+
async function bootstrapPlatformSharedEmail(apiUrl, healthProbeToken, fetcher = fetch) {
|
|
4887
|
+
const response = await fetcher(`${apiUrl}/internal/platform/email-bootstrap`, {
|
|
4888
|
+
method: "POST",
|
|
4889
|
+
headers: {
|
|
4890
|
+
"X-Void-Platform-Probe": healthProbeToken,
|
|
4891
|
+
...cfAccessHeaders(process.env, apiUrl)
|
|
4892
|
+
},
|
|
4893
|
+
signal: AbortSignal.timeout(3e4),
|
|
4894
|
+
redirect: "manual"
|
|
4895
|
+
});
|
|
4896
|
+
let value;
|
|
4897
|
+
try {
|
|
4898
|
+
value = await response.json();
|
|
4899
|
+
} catch {
|
|
4900
|
+
throw new Error(`platform: Shared email bootstrap failed at ${apiUrl} (HTTP ${response.status}). Traffic remains disabled.`);
|
|
4901
|
+
}
|
|
4902
|
+
const status = value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
4903
|
+
if (!response.ok || status?.version !== 1 || status.state !== "ready" || typeof status.operationId !== "string" || status.operationId.length === 0) {
|
|
4904
|
+
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}.` : "";
|
|
4905
|
+
throw new Error(`platform: Shared email bootstrap did not reach inbound readiness at ${apiUrl} (HTTP ${response.status}).${detail} Traffic remains disabled.`);
|
|
4906
|
+
}
|
|
4907
|
+
return status.operationId;
|
|
4908
|
+
}
|
|
3753
4909
|
function createPlatformHealthProbeToken(ownershipNonce) {
|
|
3754
4910
|
return createHash("sha256").update("void-platform-health-probe:v1\0").update(ownershipNonce).digest("base64url");
|
|
3755
4911
|
}
|
|
3756
4912
|
//#endregion
|
|
4913
|
+
//#region src/cli/platform-install-auth.ts
|
|
4914
|
+
function answer(value) {
|
|
4915
|
+
if (isCancel(value) || typeof value === "symbol") throw new Error("platform: Cancelled.");
|
|
4916
|
+
return value;
|
|
4917
|
+
}
|
|
4918
|
+
async function selectInstallationAuthentication(file, recorded) {
|
|
4919
|
+
if (file) {
|
|
4920
|
+
const input = JSON.parse(await readFile(file, "utf8"));
|
|
4921
|
+
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.");
|
|
4922
|
+
const entries = input.connections;
|
|
4923
|
+
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.");
|
|
4924
|
+
const selected = input;
|
|
4925
|
+
if (selected.cloudflareAccess !== void 0) selected.cloudflareAccess = parsePlatformAccessSetupSpec(selected.cloudflareAccess);
|
|
4926
|
+
const authentication = {
|
|
4927
|
+
providers: [...new Set(entries.map((entry) => entry.configuration.kind))],
|
|
4928
|
+
configurable: true,
|
|
4929
|
+
...selected.protection ? { accessProtection: true } : {}
|
|
4930
|
+
};
|
|
4931
|
+
if (recorded && JSON.stringify(authentication) !== JSON.stringify(recorded)) throw new Error("platform: Resume with the recorded login methods and protection settings.");
|
|
4932
|
+
return {
|
|
4933
|
+
authentication,
|
|
4934
|
+
file: selected
|
|
4935
|
+
};
|
|
4936
|
+
}
|
|
4937
|
+
if (recorded) return { authentication: recorded };
|
|
4938
|
+
if (!process.stdin.isTTY) return { authentication: { providers: ["github"] } };
|
|
4939
|
+
const providers = answer(await multiselect({
|
|
4940
|
+
message: "Platform login methods:",
|
|
4941
|
+
initialValues: ["github"],
|
|
4942
|
+
required: true,
|
|
4943
|
+
options: [
|
|
4944
|
+
{
|
|
4945
|
+
value: "github",
|
|
4946
|
+
label: "GitHub"
|
|
4947
|
+
},
|
|
4948
|
+
{
|
|
4949
|
+
value: "google",
|
|
4950
|
+
label: "Google"
|
|
4951
|
+
},
|
|
4952
|
+
{
|
|
4953
|
+
value: "oidc",
|
|
4954
|
+
label: "Generic OIDC"
|
|
4955
|
+
},
|
|
4956
|
+
{
|
|
4957
|
+
value: "cloudflare-access",
|
|
4958
|
+
label: "Cloudflare Access login"
|
|
4959
|
+
}
|
|
4960
|
+
]
|
|
4961
|
+
}));
|
|
4962
|
+
const protection = answer(await confirm({
|
|
4963
|
+
message: "Also protect the platform UI and APIs with Cloudflare Access?",
|
|
4964
|
+
initialValue: false
|
|
4965
|
+
}));
|
|
4966
|
+
return { authentication: {
|
|
4967
|
+
providers,
|
|
4968
|
+
...protection || providers.length !== 1 || providers[0] !== "github" ? { configurable: true } : {},
|
|
4969
|
+
...protection ? { accessProtection: true } : {}
|
|
4970
|
+
} };
|
|
4971
|
+
}
|
|
4972
|
+
async function collectInstallationAuthentication(selection, plan, checkpoint, save, access) {
|
|
4973
|
+
if (!selection.authentication.configurable) return;
|
|
4974
|
+
activatePlatformAccessCredentials(checkpoint.accessCredentialsJson, [plan.apiUrl, plan.proxyUrl]);
|
|
4975
|
+
if (checkpoint.authenticationSetupJson && !selection.file) {
|
|
4976
|
+
parsePlatformAuthenticationSetup(JSON.parse(checkpoint.authenticationSetupJson));
|
|
4977
|
+
return {
|
|
4978
|
+
authenticationSetupJson: checkpoint.authenticationSetupJson,
|
|
4979
|
+
authSetupToken: checkpoint.authSetupToken ?? "",
|
|
4980
|
+
authBootstrapCode: checkpoint.authBootstrapCode ?? ""
|
|
4981
|
+
};
|
|
4982
|
+
}
|
|
4983
|
+
const prompt = async (message, initialValue = "", masked = false) => {
|
|
4984
|
+
if (!process.stdin.isTTY) throw new Error(`platform: ${message} Supply it through --auth-config and environment references.`);
|
|
4985
|
+
return masked ? answer(await password({ message })) : answer(await text$1({
|
|
4986
|
+
message,
|
|
4987
|
+
initialValue
|
|
4988
|
+
}));
|
|
4989
|
+
};
|
|
4990
|
+
const entries = selection.file?.connections ?? selection.authentication.providers.map((kind) => ({
|
|
4991
|
+
configuration: {
|
|
4992
|
+
kind,
|
|
4993
|
+
id: kind === "oidc" ? "company" : kind === "cloudflare-access" ? "access" : kind,
|
|
4994
|
+
label: kind === "github" ? "GitHub" : kind === "google" ? "Google" : kind === "oidc" ? "Company SSO" : "Cloudflare Access"
|
|
4995
|
+
},
|
|
4996
|
+
clientSecretEnv: void 0
|
|
4997
|
+
}));
|
|
4998
|
+
const connections = [];
|
|
4999
|
+
const accessEntry = entries.find((entry) => entry.configuration.kind === "cloudflare-access");
|
|
5000
|
+
const prepared = access && (selection.authentication.accessProtection || accessEntry && (!selection.file || selection.file.cloudflareAccess)) ? await preparePlatformAccessSetup({
|
|
5001
|
+
plan,
|
|
5002
|
+
ownershipNonce: access.ownershipNonce,
|
|
5003
|
+
protection: !!selection.authentication.accessProtection,
|
|
5004
|
+
...accessEntry ? { login: {
|
|
5005
|
+
id: String(accessEntry.configuration.id ?? "access"),
|
|
5006
|
+
label: String(accessEntry.configuration.label ?? "Cloudflare Access"),
|
|
5007
|
+
...Array.isArray(accessEntry.configuration.requiredGroups) ? { requiredGroups: accessEntry.configuration.requiredGroups } : {}
|
|
5008
|
+
} } : {},
|
|
5009
|
+
spec: selection.file?.cloudflareAccess,
|
|
5010
|
+
client: access.client,
|
|
5011
|
+
checkpoint,
|
|
5012
|
+
save,
|
|
5013
|
+
yes: access.yes
|
|
5014
|
+
}) : void 0;
|
|
5015
|
+
console.log(`Login callback URL: ${plan.apiUrl}/auth/callback`);
|
|
5016
|
+
for (const entry of entries) {
|
|
5017
|
+
const config = { ...entry.configuration };
|
|
5018
|
+
const kind = config.kind;
|
|
5019
|
+
if (!config.id) config.id = kind === "oidc" ? "company" : kind === "cloudflare-access" ? "access" : kind;
|
|
5020
|
+
if (prepared?.login && prepared.login.configuration.id === config.id) {
|
|
5021
|
+
const connection = prepared.login;
|
|
5022
|
+
save({ [`authenticationConnection:${connection.configuration.id}`]: JSON.stringify(connection) });
|
|
5023
|
+
connections.push(connection);
|
|
5024
|
+
continue;
|
|
5025
|
+
}
|
|
5026
|
+
const partialKey = `authenticationConnection:${String(config.id)}`;
|
|
5027
|
+
const stored = checkpoint[partialKey] ? JSON.parse(checkpoint[partialKey]) : void 0;
|
|
5028
|
+
Object.assign(config, {
|
|
5029
|
+
...stored?.configuration,
|
|
5030
|
+
...config
|
|
5031
|
+
});
|
|
5032
|
+
if (!config.label) config.label = kind;
|
|
5033
|
+
if ((kind === "oidc" || kind === "cloudflare-access") && !config.issuer) config.issuer = await prompt(`${config.label}: issuer URL:`);
|
|
5034
|
+
if (!config.clientId) config.clientId = await prompt(`${config.label}: client ID:`);
|
|
5035
|
+
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);
|
|
5036
|
+
const parsed = parsePlatformLoginConfiguration(config);
|
|
5037
|
+
let secret;
|
|
5038
|
+
if (entry.clientSecretEnv) {
|
|
5039
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(entry.clientSecretEnv)) throw new Error("platform: Invalid clientSecretEnv reference.");
|
|
5040
|
+
secret = process.env[entry.clientSecretEnv];
|
|
5041
|
+
if (!secret) throw new Error(`platform: ${entry.clientSecretEnv} must contain the login client secret.`);
|
|
5042
|
+
} else if (stored?.clientSecret && stored.configuration?.clientId === parsed.clientId && stored.configuration?.issuer === parsed.issuer) secret = stored.clientSecret;
|
|
5043
|
+
else secret = await prompt(`${config.label}: client secret:`, "", true);
|
|
5044
|
+
const connection = {
|
|
5045
|
+
configuration: parsed,
|
|
5046
|
+
clientSecret: secret
|
|
5047
|
+
};
|
|
5048
|
+
save({ [partialKey]: JSON.stringify(connection) });
|
|
5049
|
+
connections.push(connection);
|
|
5050
|
+
}
|
|
5051
|
+
const administratorConnectionId = selection.file?.administratorConnectionId ?? (connections.length === 1 ? connections[0].configuration.id : process.stdin.isTTY ? answer(await select({
|
|
5052
|
+
message: "Login method for the first administrator:",
|
|
5053
|
+
options: connections.map((entry) => ({
|
|
5054
|
+
value: entry.configuration.id,
|
|
5055
|
+
label: entry.configuration.label
|
|
5056
|
+
}))
|
|
5057
|
+
})) : connections[0].configuration.id);
|
|
5058
|
+
let protection = prepared?.protection ?? selection.file?.protection ?? null;
|
|
5059
|
+
if (selection.authentication.accessProtection && !protection) {
|
|
5060
|
+
const teamDomain = await prompt("Access team origin (https://team.cloudflareaccess.com):");
|
|
5061
|
+
const apiAudience = await prompt(`Access application audience for ${plan.apiUrl}:`);
|
|
5062
|
+
const proxyAudience = await prompt(`Access application audience for ${plan.proxyUrl}:`, apiAudience);
|
|
5063
|
+
protection = {
|
|
5064
|
+
teamDomain,
|
|
5065
|
+
applications: [{
|
|
5066
|
+
origin: plan.apiUrl,
|
|
5067
|
+
audience: apiAudience
|
|
5068
|
+
}, {
|
|
5069
|
+
origin: plan.proxyUrl,
|
|
5070
|
+
audience: proxyAudience
|
|
5071
|
+
}]
|
|
5072
|
+
};
|
|
5073
|
+
}
|
|
5074
|
+
protection = parsePlatformAccessProtection(protection);
|
|
5075
|
+
let admissionInput = selection.file?.admission ?? (protection ? {
|
|
5076
|
+
mode: "company",
|
|
5077
|
+
access: true,
|
|
5078
|
+
connections: []
|
|
5079
|
+
} : { mode: "restricted" });
|
|
5080
|
+
if (!selection.file && !protection && process.stdin.isTTY) {
|
|
5081
|
+
const eligible = connections.filter((entry) => entry.configuration.kind !== "github" && (entry.configuration.kind !== "google" || entry.configuration.allowedDomains.length > 0));
|
|
5082
|
+
const mode = answer(await select({
|
|
5083
|
+
message: "Who can create an account?",
|
|
5084
|
+
initialValue: eligible.length ? "company" : "restricted",
|
|
5085
|
+
options: [
|
|
5086
|
+
{
|
|
5087
|
+
value: "restricted",
|
|
5088
|
+
label: "Invited or allowlisted users"
|
|
5089
|
+
},
|
|
5090
|
+
...eligible.length ? [{
|
|
5091
|
+
value: "company",
|
|
5092
|
+
label: "Users approved by company login methods — automatic accounts"
|
|
5093
|
+
}] : [],
|
|
5094
|
+
{
|
|
5095
|
+
value: "public",
|
|
5096
|
+
label: "Anyone who completes an enabled login"
|
|
5097
|
+
}
|
|
5098
|
+
]
|
|
5099
|
+
}));
|
|
5100
|
+
const ids = mode === "company" ? answer(await multiselect({
|
|
5101
|
+
message: "Company login methods that qualify new users:",
|
|
5102
|
+
required: true,
|
|
5103
|
+
initialValues: eligible.map((entry) => entry.configuration.id),
|
|
5104
|
+
options: eligible.map((entry) => ({
|
|
5105
|
+
value: entry.configuration.id,
|
|
5106
|
+
label: entry.configuration.label
|
|
5107
|
+
}))
|
|
5108
|
+
})) : [];
|
|
5109
|
+
admissionInput = mode === "company" ? {
|
|
5110
|
+
mode,
|
|
5111
|
+
connections: ids,
|
|
5112
|
+
access: false
|
|
5113
|
+
} : { mode };
|
|
5114
|
+
}
|
|
5115
|
+
const admission = parsePlatformAdmissionPolicy(admissionInput);
|
|
5116
|
+
let authBootstrapCode = checkpoint.authBootstrapCode || randomBytes(32).toString("hex");
|
|
5117
|
+
const authSetupToken = checkpoint.authSetupToken || randomBytes(32).toString("hex");
|
|
5118
|
+
let setup = parsePlatformAuthenticationSetup({
|
|
5119
|
+
connections,
|
|
5120
|
+
administratorConnectionId,
|
|
5121
|
+
admission,
|
|
5122
|
+
protection,
|
|
5123
|
+
bootstrapCredentialHash: createHash("sha256").update(authBootstrapCode).digest("hex")
|
|
5124
|
+
});
|
|
5125
|
+
if (selection.file && checkpoint.authenticationSetupJson) {
|
|
5126
|
+
const previous = parsePlatformAuthenticationSetup(JSON.parse(checkpoint.authenticationSetupJson));
|
|
5127
|
+
if (JSON.stringify({
|
|
5128
|
+
...previous,
|
|
5129
|
+
resetPending: void 0
|
|
5130
|
+
}) !== JSON.stringify(setup)) {
|
|
5131
|
+
authBootstrapCode = randomBytes(32).toString("hex");
|
|
5132
|
+
setup = parsePlatformAuthenticationSetup({
|
|
5133
|
+
...setup,
|
|
5134
|
+
resetPending: true,
|
|
5135
|
+
bootstrapCredentialHash: createHash("sha256").update(authBootstrapCode).digest("hex")
|
|
5136
|
+
});
|
|
5137
|
+
}
|
|
5138
|
+
}
|
|
5139
|
+
const result = {
|
|
5140
|
+
authenticationSetupJson: JSON.stringify(setup),
|
|
5141
|
+
authBootstrapCode,
|
|
5142
|
+
authSetupToken
|
|
5143
|
+
};
|
|
5144
|
+
save(result);
|
|
5145
|
+
return result;
|
|
5146
|
+
}
|
|
5147
|
+
//#endregion
|
|
3757
5148
|
//#region src/cli/platform-setup-draft.ts
|
|
3758
5149
|
const STARTED = "credential-setup:v1";
|
|
3759
5150
|
const COMPLETED = "credential-setup-complete:v1";
|
|
@@ -3885,7 +5276,29 @@ function platformSetupPages(plan) {
|
|
|
3885
5276
|
...plan.deployment.capabilities.customDomains ? [{
|
|
3886
5277
|
key: "ssl_and_certificates",
|
|
3887
5278
|
type: "edit"
|
|
3888
|
-
}] : []
|
|
5279
|
+
}] : [],
|
|
5280
|
+
...plan.deployment.capabilities.email ? [
|
|
5281
|
+
{
|
|
5282
|
+
key: "zone",
|
|
5283
|
+
type: "read"
|
|
5284
|
+
},
|
|
5285
|
+
{
|
|
5286
|
+
key: "zone_settings",
|
|
5287
|
+
type: "edit"
|
|
5288
|
+
},
|
|
5289
|
+
{
|
|
5290
|
+
key: "email_routing_address",
|
|
5291
|
+
type: "edit"
|
|
5292
|
+
},
|
|
5293
|
+
{
|
|
5294
|
+
key: "email_routing_rule",
|
|
5295
|
+
type: "edit"
|
|
5296
|
+
},
|
|
5297
|
+
{
|
|
5298
|
+
key: "email_sending",
|
|
5299
|
+
type: "edit"
|
|
5300
|
+
}
|
|
5301
|
+
] : []
|
|
3889
5302
|
];
|
|
3890
5303
|
return {
|
|
3891
5304
|
runtime: {
|
|
@@ -3901,6 +5314,7 @@ function platformSetupPages(plan) {
|
|
|
3901
5314
|
"Read: Account Settings, Account Analytics, Workers Tail.",
|
|
3902
5315
|
...plan.deployment.application.zoneName ? ["Zone " + plan.deployment.application.zoneName + ": Cache Purge: Purge."] : [],
|
|
3903
5316
|
...plan.deployment.capabilities.customDomains ? ["Custom project domains also need SSL and Certificates: Edit on that zone."] : [],
|
|
5317
|
+
...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
5318
|
"If the form differs, match the permissions above. Create the token, then paste it here."
|
|
3905
5319
|
].join("\n")
|
|
3906
5320
|
},
|
|
@@ -3933,8 +5347,9 @@ function formatPlatformSetupPlan(plan) {
|
|
|
3933
5347
|
platformSetupLink(pages.r2),
|
|
3934
5348
|
pages.r2.instructions,
|
|
3935
5349
|
"",
|
|
3936
|
-
platformSetupLink(pages.github),
|
|
3937
|
-
|
|
5350
|
+
...plan.deployment.authentication.providers.includes("github") ? [platformSetupLink(pages.github), pages.github.instructions] : [],
|
|
5351
|
+
...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.`),
|
|
5352
|
+
...plan.deployment.authentication.accessProtection ? ["Cloudflare Access protection: configure API and proxy origins and admitted installer/CI credentials."] : []
|
|
3938
5353
|
].join("\n");
|
|
3939
5354
|
}
|
|
3940
5355
|
function openPlatformSetupPage(page, dependencies = {}) {
|
|
@@ -3992,14 +5407,14 @@ function resolvePlatformInstallDatabaseUrl(checkpoint, manifest = null) {
|
|
|
3992
5407
|
}
|
|
3993
5408
|
async function runPlatformLifecycleCommand(args) {
|
|
3994
5409
|
if (args.subcommand === "domain") {
|
|
3995
|
-
const { runPlatformDomainCommand } = await import("./platform-domain-
|
|
5410
|
+
const { runPlatformDomainCommand } = await import("./platform-domain-C74PULqV.mjs");
|
|
3996
5411
|
await runPlatformDomainCommand(args);
|
|
3997
5412
|
return;
|
|
3998
5413
|
}
|
|
3999
5414
|
if (args.subcommand === "discover") {
|
|
4000
5415
|
const client = new PlatformCloudflareClient((await resolvePlatformCloudflareCredential()).token);
|
|
4001
5416
|
const accountId = await selectAccount(await readAccounts(client), args.account);
|
|
4002
|
-
const { recoverPlatformInstallations } = await import("./platform-recovery-
|
|
5417
|
+
const { recoverPlatformInstallations } = await import("./platform-recovery-ewqLefp1.mjs");
|
|
4003
5418
|
const recovered = await recoverPlatformInstallations(client, accountId, void 0, args.selector);
|
|
4004
5419
|
if (recovered.length === 0) {
|
|
4005
5420
|
log.info(`No recoverable Void platform installations were found in ${accountId}.`);
|
|
@@ -4009,7 +5424,7 @@ async function runPlatformLifecycleCommand(args) {
|
|
|
4009
5424
|
return;
|
|
4010
5425
|
}
|
|
4011
5426
|
if (args.subcommand !== "install") {
|
|
4012
|
-
const { runInstalledPlatformCommand } = await import("./platform-management-
|
|
5427
|
+
const { runInstalledPlatformCommand } = await import("./platform-management-COogu_Se.mjs");
|
|
4013
5428
|
await runInstalledPlatformCommand(args);
|
|
4014
5429
|
return;
|
|
4015
5430
|
}
|
|
@@ -4159,7 +5574,7 @@ async function runPlatformLifecycleCommand(args) {
|
|
|
4159
5574
|
progress?.stop("Read the saved installation.");
|
|
4160
5575
|
progress = void 0;
|
|
4161
5576
|
if (authoritative) {
|
|
4162
|
-
const { selectAuthoritativePlatformManifest } = await import("./platform-management-
|
|
5577
|
+
const { selectAuthoritativePlatformManifest } = await import("./platform-management-COogu_Se.mjs");
|
|
4163
5578
|
const selected = selectAuthoritativePlatformManifest(existing, authoritative);
|
|
4164
5579
|
authoritativeProvisionIntents = true;
|
|
4165
5580
|
if (!args.plan && JSON.stringify(selected) !== JSON.stringify(existing)) writeInstallationManifest(selected);
|
|
@@ -4167,19 +5582,24 @@ async function runPlatformLifecycleCommand(args) {
|
|
|
4167
5582
|
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
5583
|
}
|
|
4169
5584
|
}
|
|
5585
|
+
const authenticationSelection = await selectInstallationAuthentication(args.authConfig, existing?.deployment.authentication);
|
|
4170
5586
|
const planInput = {
|
|
5587
|
+
authentication: authenticationSelection.authentication,
|
|
4171
5588
|
accountId,
|
|
4172
5589
|
applicationDomain,
|
|
4173
5590
|
controlPlaneDomain,
|
|
4174
5591
|
dedicatedZone,
|
|
4175
5592
|
displayName,
|
|
5593
|
+
...process.env.VOID_EMAIL_SHARED_ZONE_ID ? { emailSharedZoneId: process.env.VOID_EMAIL_SHARED_ZONE_ID } : {},
|
|
5594
|
+
...process.env.VOID_EMAIL_SENDER_DOMAIN ? { emailSenderDomain: process.env.VOID_EMAIL_SENDER_DOMAIN } : {},
|
|
4176
5595
|
installationSlug,
|
|
4177
5596
|
zoneName,
|
|
4178
5597
|
...workersDev ? { workersDev: true } : {}
|
|
4179
5598
|
};
|
|
4180
5599
|
let plan = await createPlatformInstallPlan(client, planInput, existing, {
|
|
4181
5600
|
authoritativeProvisionIntents,
|
|
4182
|
-
allowUnverifiedAiGateway: true
|
|
5601
|
+
allowUnverifiedAiGateway: true,
|
|
5602
|
+
includeEmailGateway: runtime.manifest.artifacts.emailGateway !== void 0
|
|
4183
5603
|
});
|
|
4184
5604
|
note(formatPlatformInstallPlan(plan), args.plan ? "Read-only installation plan" : "Installation plan");
|
|
4185
5605
|
note(formatPlatformRuntime(runtime), "Platform runtime");
|
|
@@ -4258,22 +5678,44 @@ async function runPlatformLifecycleCommand(args) {
|
|
|
4258
5678
|
await verifyRuntimeToken(runtimeCloudflareApiToken);
|
|
4259
5679
|
}
|
|
4260
5680
|
saveValues({ cloudflareApiToken: runtimeCloudflareApiToken });
|
|
4261
|
-
const
|
|
4262
|
-
const
|
|
4263
|
-
|
|
4264
|
-
|
|
5681
|
+
const deferAuthentication = !!authenticationSelection.authentication.accessProtection || authenticationSelection.authentication.providers.includes("cloudflare-access");
|
|
5682
|
+
const saveAuthenticationValues = (updates) => {
|
|
5683
|
+
Object.assign(values, updates);
|
|
5684
|
+
const latest = readPendingInstallationSecrets(setupManifest) ?? {};
|
|
5685
|
+
writePendingInstallationSecrets(setupManifest, {
|
|
5686
|
+
...latest,
|
|
5687
|
+
...updates
|
|
5688
|
+
});
|
|
5689
|
+
};
|
|
5690
|
+
const authenticationSecrets = deferAuthentication ? {
|
|
5691
|
+
authSetupToken: values.authSetupToken || randomBytes(32).toString("hex"),
|
|
5692
|
+
authBootstrapCode: values.authBootstrapCode || randomBytes(32).toString("hex")
|
|
5693
|
+
} : await collectInstallationAuthentication(authenticationSelection, plan, values, saveAuthenticationValues);
|
|
5694
|
+
if (authenticationSecrets) saveAuthenticationValues(authenticationSecrets);
|
|
5695
|
+
let bootstrapGitHubLogin = "";
|
|
5696
|
+
let bootstrapGitHubId = "";
|
|
5697
|
+
let githubClientId = "";
|
|
5698
|
+
let githubClientSecret = "";
|
|
5699
|
+
if (!authenticationSecrets) {
|
|
5700
|
+
const previousLogin = values.bootstrapGitHubLogin;
|
|
5701
|
+
bootstrapGitHubLogin = await collect("bootstrapGitHubLogin", "VOID_PLATFORM_ADMIN_GITHUB_LOGIN", "Initial administrator GitHub username:", false);
|
|
5702
|
+
bootstrapGitHubId = (previousLogin === bootstrapGitHubLogin ? values.bootstrapGitHubId : "") || process.env.VOID_PLATFORM_ADMIN_GITHUB_ID || await resolveBootstrapGitHubId(bootstrapGitHubLogin);
|
|
5703
|
+
saveValues({ bootstrapGitHubId });
|
|
5704
|
+
}
|
|
4265
5705
|
const installationDatabaseUrl = databaseUrl || await collect("databaseUrl", "VOID_PLATFORM_DATABASE_URL", "PostgreSQL DATABASE_URL:");
|
|
4266
5706
|
saveValues({ databaseUrl: installationDatabaseUrl });
|
|
4267
|
-
if (!
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
5707
|
+
if (!authenticationSecrets) {
|
|
5708
|
+
if (!configured("githubClientId", "VOID_PLATFORM_GITHUB_CLIENT_ID") || !configured("githubClientSecret", "VOID_PLATFORM_GITHUB_CLIENT_SECRET")) openPlatformSetupPage(configured("githubClientId", "VOID_PLATFORM_GITHUB_CLIENT_ID") ? {
|
|
5709
|
+
...pages.github,
|
|
5710
|
+
url: "https://github.com/settings/developers"
|
|
5711
|
+
} : pages.github);
|
|
5712
|
+
githubClientId = await collect("githubClientId", "VOID_PLATFORM_GITHUB_CLIENT_ID", "GitHub OAuth client ID:", false, false);
|
|
5713
|
+
githubClientSecret = await collect("githubClientSecret", "VOID_PLATFORM_GITHUB_CLIENT_SECRET", "GitHub OAuth client secret:", true, false);
|
|
5714
|
+
saveValues({
|
|
5715
|
+
githubClientId,
|
|
5716
|
+
githubClientSecret
|
|
5717
|
+
});
|
|
5718
|
+
}
|
|
4277
5719
|
if (!configured("r2AccessKeyId", "VOID_PLATFORM_R2_ACCESS_KEY_ID") || !configured("r2SecretAccessKey", "VOID_PLATFORM_R2_SECRET_ACCESS_KEY")) openPlatformSetupPage(pages.r2);
|
|
4278
5720
|
const r2AccessKeyId = await collect("r2AccessKeyId", "VOID_PLATFORM_R2_ACCESS_KEY_ID", "Access Key ID:", false, false);
|
|
4279
5721
|
const r2SecretAccessKey = await collect("r2SecretAccessKey", "VOID_PLATFORM_R2_SECRET_ACCESS_KEY", "Secret Access Key:", true, false);
|
|
@@ -4283,11 +5725,16 @@ async function runPlatformLifecycleCommand(args) {
|
|
|
4283
5725
|
});
|
|
4284
5726
|
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
5727
|
const secrets = createPlatformInstallationSecrets({
|
|
5728
|
+
configurableAuthentication: !!authenticationSelection.authentication.configurable,
|
|
5729
|
+
...authenticationSecrets,
|
|
4286
5730
|
bootstrapGitHubId,
|
|
4287
5731
|
bootstrapGitHubLogin,
|
|
4288
5732
|
cloudflareApiToken: runtimeCloudflareApiToken,
|
|
4289
5733
|
checkpoint: values,
|
|
4290
5734
|
databaseUrl: installationDatabaseUrl,
|
|
5735
|
+
emailEnabled: plan.deployment.capabilities.email,
|
|
5736
|
+
emailSigningSecret: process.env.VOID_PLATFORM_EMAIL_SIGNING_SECRET || "",
|
|
5737
|
+
existingEmailEnabled: existing.deployment.capabilities.email,
|
|
4291
5738
|
githubClientId,
|
|
4292
5739
|
githubClientSecret,
|
|
4293
5740
|
r2AccessKeyId,
|
|
@@ -4303,7 +5750,10 @@ async function runPlatformLifecycleCommand(args) {
|
|
|
4303
5750
|
});
|
|
4304
5751
|
progress = startProgress("Installing the platform");
|
|
4305
5752
|
if (credentialSetup) {
|
|
4306
|
-
saveValues({
|
|
5753
|
+
saveValues({
|
|
5754
|
+
projectSecretKey: secrets.projectSecretKey,
|
|
5755
|
+
...secrets.emailEnabled ? { emailSigningSecret: secrets.emailSigningSecret } : {}
|
|
5756
|
+
});
|
|
4307
5757
|
existing = completePlatformSetupDraft(existing);
|
|
4308
5758
|
}
|
|
4309
5759
|
const result = await installPlatform(plan, secrets, existing, {
|
|
@@ -4312,13 +5762,29 @@ async function runPlatformLifecycleCommand(args) {
|
|
|
4312
5762
|
runtime,
|
|
4313
5763
|
healthCheckResumeCommand: `${cliCommandName()} platform install --resume --name ${existing.id}`,
|
|
4314
5764
|
onProgress: (message) => progress?.message(message),
|
|
5765
|
+
...deferAuthentication ? { prepareAuthentication: async (manifest) => {
|
|
5766
|
+
progress?.stop("Preparing Cloudflare Access configuration.");
|
|
5767
|
+
progress = void 0;
|
|
5768
|
+
const result = await collectInstallationAuthentication(authenticationSelection, plan, values, saveAuthenticationValues, {
|
|
5769
|
+
client,
|
|
5770
|
+
ownershipNonce: manifest.ownershipNonce,
|
|
5771
|
+
yes: args.yes
|
|
5772
|
+
});
|
|
5773
|
+
progress = startProgress("Continuing platform installation");
|
|
5774
|
+
return {
|
|
5775
|
+
...result,
|
|
5776
|
+
accessProvisioningJson: values.accessProvisioningJson,
|
|
5777
|
+
accessCredentialsJson: values.accessCredentialsJson
|
|
5778
|
+
};
|
|
5779
|
+
} } : {},
|
|
4315
5780
|
replanWorkers: async (manifest) => (await createPlatformInstallPlan(client, planInput, manifest, {
|
|
4316
5781
|
authoritativeProvisionIntents: true,
|
|
4317
|
-
repairMissing: true
|
|
5782
|
+
repairMissing: true,
|
|
5783
|
+
includeEmailGateway: runtime.manifest.artifacts.emailGateway !== void 0
|
|
4318
5784
|
})).actions.filter((action) => action.kind === "worker"),
|
|
4319
5785
|
revalidateBeforeWorkerDeploy: (manifest, action) => revalidatePlatformInstallAction(client, plan, manifest, action),
|
|
4320
5786
|
verifyExistingOwnership: async (manifest, databaseCheckpointExists) => {
|
|
4321
|
-
const { verifyPlatformInstallationOwnership } = await import("./platform-management-
|
|
5787
|
+
const { verifyPlatformInstallationOwnership } = await import("./platform-management-COogu_Se.mjs");
|
|
4322
5788
|
const retainedReplacementKeys = new Set(plan.actions.filter((action) => {
|
|
4323
5789
|
const previous = manifest.resources.find((resource) => resource.key === action.key);
|
|
4324
5790
|
return action.action === "create" && previous && previous.name !== action.name;
|
|
@@ -4344,6 +5810,13 @@ async function runPlatformLifecycleCommand(args) {
|
|
|
4344
5810
|
}
|
|
4345
5811
|
log.success(`${result.manifest.deployment.displayName} is installed at ${new URL("/admin", plan.apiUrl).href}.`);
|
|
4346
5812
|
log.info(`API URL: ${plan.apiUrl}`);
|
|
5813
|
+
if (result.emailOperationId) log.info(`Shared email routing ready (operation ${result.emailOperationId}).`);
|
|
5814
|
+
const completedAuthCode = readInstallationRecoverySecrets(result.manifest)?.authBootstrapCode;
|
|
5815
|
+
if (result.setupPending && completedAuthCode) {
|
|
5816
|
+
log.info(`First administrator setup: ${plan.apiUrl}/setup`);
|
|
5817
|
+
console.log(`One-time administrator setup code: ${completedAuthCode}`);
|
|
5818
|
+
log.info("Confirm your identity in the browser, then test and enable any additional login methods in Settings.");
|
|
5819
|
+
}
|
|
4347
5820
|
if (!plan.deployment.application.zoneName) log.info("Apps will use workers.dev addresses. When your domain is ready: void platform domain set <domain>");
|
|
4348
5821
|
outro("Run `void auth login`, then deploy an app with `void deploy --platform void`.");
|
|
4349
5822
|
} catch (error) {
|
|
@@ -4374,7 +5847,10 @@ function formatPlatformRuntime(runtime) {
|
|
|
4374
5847
|
async function verifyPlatformAiGatewayPlan(client, input, existing, preview, authoritativeProvisionIntents) {
|
|
4375
5848
|
let verified;
|
|
4376
5849
|
try {
|
|
4377
|
-
verified = await createPlatformInstallPlan(client, input, existing, {
|
|
5850
|
+
verified = await createPlatformInstallPlan(client, input, existing, {
|
|
5851
|
+
authoritativeProvisionIntents,
|
|
5852
|
+
includeEmailGateway: preview.actions.some((entry) => entry.key === "email-gateway-worker")
|
|
5853
|
+
});
|
|
4378
5854
|
} catch (error) {
|
|
4379
5855
|
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
5856
|
throw error;
|
|
@@ -4422,7 +5898,7 @@ async function promptText(message, value, optional = false, validate) {
|
|
|
4422
5898
|
if (optional) return "";
|
|
4423
5899
|
throw new Error(`platform: ${message.replace(/:$/, "")} is required in non-interactive mode.`);
|
|
4424
5900
|
}
|
|
4425
|
-
const input = await text({
|
|
5901
|
+
const input = await text$1({
|
|
4426
5902
|
message,
|
|
4427
5903
|
...typeof value === "string" ? { initialValue: value } : value,
|
|
4428
5904
|
validate: (input) => input ? validate?.(input) : optional ? void 0 : "A value is required."
|
|
@@ -4439,7 +5915,7 @@ async function promptSecret(message, masked = true, validate, placeholder) {
|
|
|
4439
5915
|
const value = masked ? placeholder ? await passwordWithPlaceholder({
|
|
4440
5916
|
...options,
|
|
4441
5917
|
placeholder
|
|
4442
|
-
}) : await password(options) : await text({
|
|
5918
|
+
}) : await password(options) : await text$1({
|
|
4443
5919
|
...options,
|
|
4444
5920
|
placeholder
|
|
4445
5921
|
});
|
|
@@ -4447,4 +5923,4 @@ async function promptSecret(message, masked = true, validate, placeholder) {
|
|
|
4447
5923
|
return value;
|
|
4448
5924
|
}
|
|
4449
5925
|
//#endregion
|
|
4450
|
-
export {
|
|
5926
|
+
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 };
|