@wtfalch/foundry 0.5.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/bootstrap.d.ts +16 -1
- package/dist/bootstrap/bootstrap.js +40 -2
- package/dist/bootstrap/declare.d.ts +3 -1
- package/dist/bootstrap/declare.js +12 -3
- package/dist/bootstrap/versions.d.ts +17 -0
- package/dist/bootstrap/versions.js +12 -0
- package/dist/calls-sfu-deploy-support.d.ts +8 -0
- package/dist/calls-sfu-deploy-support.js +10 -1
- package/dist/calls-sfu.d.ts +36 -12
- package/dist/calls-sfu.js +42 -9
- package/dist/index.d.ts +10 -4
- package/dist/index.js +6 -3
- package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
- package/dist/migrations/0020_mail_apps.sql +7 -0
- package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
- package/dist/migrations/0022_chat_enabled.sql +6 -0
- package/dist/migrations/0023_hosting_app_env.sql +8 -0
- package/dist/migrations/0024_audit_events.sql +211 -0
- package/dist/migrations/0025_audit_display.sql +147 -0
- package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
- package/dist/migrations/0027_audit_chain.sql +170 -0
- package/dist/migrations/0028_audit_rls.sql +111 -0
- package/dist/org.d.ts +105 -0
- package/dist/org.js +101 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +23 -0
- package/dist/plan/calls-matrix.d.ts +2 -2
- package/dist/plan/calls-matrix.js +3 -2
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +5 -3
- package/dist/plan/diff.d.ts +15 -2
- package/dist/plan/diff.js +399 -51
- package/dist/plan/executor.d.ts +307 -61
- package/dist/plan/executor.js +489 -129
- package/dist/plan/index.d.ts +9 -1
- package/dist/plan/index.js +5 -1
- package/dist/plan/login-host.d.ts +64 -0
- package/dist/plan/login-host.js +52 -0
- package/dist/plan/mail-copy.d.ts +125 -0
- package/dist/plan/mail-copy.js +166 -0
- package/dist/plan/mail-identity-allowlist.d.ts +50 -0
- package/dist/plan/mail-identity-allowlist.js +11 -0
- package/dist/plan/mail-identity.d.ts +68 -0
- package/dist/plan/mail-identity.js +17 -0
- package/dist/plan/plan-apply.d.ts +26 -8
- package/dist/plan/plan-apply.js +10 -9
- package/dist/plan/platform-diff.d.ts +207 -10
- package/dist/plan/platform-diff.js +403 -15
- package/dist/plan/platform-org.d.ts +3 -2
- package/dist/plan/platform-providers.d.ts +139 -25
- package/dist/plan/platform-providers.js +1531 -95
- package/dist/plan/platform-sfu.d.ts +54 -19
- package/dist/plan/platform-sfu.js +64 -22
- package/dist/plan/providers-readers.js +44 -1
- package/dist/plan/zitadel-machine-user.d.ts +60 -11
- package/dist/plan/zitadel-machine-user.js +182 -39
- package/dist/requested-changes.d.ts +17 -7
- package/dist/requested-changes.js +37 -7
- package/dist/store/audit.d.ts +40 -4
- package/dist/store/audit.js +114 -29
- package/dist/store/postgres.js +35 -3
- package/dist/store/schema.d.ts +109 -1
- package/dist/store/schema.js +20 -0
- package/dist/store.d.ts +7 -0
- package/package.json +5 -3
- package/versions.json +8 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { generateKeyPairSync, randomBytes } from 'node:crypto';
|
|
1
|
+
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from 'node:crypto';
|
|
2
2
|
import { isIPv4 } from 'node:net';
|
|
3
|
+
import { VERSIONS } from '../bootstrap/versions.js';
|
|
3
4
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
4
5
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
5
6
|
import { ACCESS_POLICY_NAME, isNotFoundError, isRunningOrStarting } from './executor.js';
|
|
6
|
-
import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY,
|
|
7
|
+
import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
|
|
7
8
|
import { applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
8
9
|
/** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
|
|
9
10
|
function apexZoneKey(apex) {
|
|
@@ -67,6 +68,9 @@ const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
|
|
|
67
68
|
* optional: an existing host that constructs this executor for the zone step
|
|
68
69
|
* alone keeps compiling, and a plan that includes an `sfu:livekit` step
|
|
69
70
|
* without it configured refuses clearly rather than silently doing nothing.
|
|
71
|
+
* #152: the step mints one LiveKit key per org with calls enabled rather
|
|
72
|
+
* than one shared pair, so it also needs `declaredOrgStore.list()` -- the
|
|
73
|
+
* same store this class already takes for `findOrgIdForZone`.
|
|
70
74
|
*
|
|
71
75
|
* #168 adds `coolify:app` keyed {@link PLATFORM_AUTH_KEY}, the platform's own
|
|
72
76
|
* sign-in ({@link provisionPlatformAuth}), once a host passes
|
|
@@ -120,6 +124,18 @@ export class FoundryPlatformStepExecutor {
|
|
|
120
124
|
if (platform.auth.enabled && !this.auth) {
|
|
121
125
|
blocked.auth =
|
|
122
126
|
"the platform's sign-in is blocked, not planned: the host built the platform executor without its auth dependencies";
|
|
127
|
+
// #211 round 6: its own signal, distinct from `auth` -- `planAuth` must
|
|
128
|
+
// withhold the step for this case whatever the recorded broker state
|
|
129
|
+
// says, unlike "broker created, deploy still blocked" (below).
|
|
130
|
+
blocked.authExecutorMissing = true;
|
|
131
|
+
}
|
|
132
|
+
else if (platform.auth.enabled && this.auth) {
|
|
133
|
+
const { reason: authBlocked, otherInputsMissing } = await authBrokerBlockedReason(this.auth, recordedIds);
|
|
134
|
+
if (authBlocked)
|
|
135
|
+
blocked.auth = authBlocked;
|
|
136
|
+
// #216 round 5: see `PlanPlatformOptions.blocked.authOtherInputsMissing`.
|
|
137
|
+
if (otherInputsMissing.length > 0)
|
|
138
|
+
blocked.authOtherInputsMissing = otherInputsMissing;
|
|
123
139
|
}
|
|
124
140
|
if (platform.mail.enabled) {
|
|
125
141
|
const mailBlocked = await mailBlockedReason(platform, this.mail, recordedIds);
|
|
@@ -150,8 +166,14 @@ export class FoundryPlatformStepExecutor {
|
|
|
150
166
|
}
|
|
151
167
|
else if (platform.ai.enabled &&
|
|
152
168
|
!this.ai?.managementKeyMinter &&
|
|
169
|
+
!this.ai?.heldKeys &&
|
|
170
|
+
recordedIds[PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY] &&
|
|
153
171
|
!recordedIds[PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY]) {
|
|
154
|
-
|
|
172
|
+
// The signing pair was pinned on an earlier apply; minting the
|
|
173
|
+
// management key needs its private half read back, and no minter was
|
|
174
|
+
// injected to skip that (#201).
|
|
175
|
+
blocked.ai =
|
|
176
|
+
"the platform's AI host is blocked, not planned: its signing key is already pinned but the host built the platform executor without a held-key reader to read back its private half (PlatformAiDeps.heldKeys)";
|
|
155
177
|
}
|
|
156
178
|
if (platform.archon.enabled) {
|
|
157
179
|
if (!this.archon) {
|
|
@@ -164,20 +186,43 @@ export class FoundryPlatformStepExecutor {
|
|
|
164
186
|
}
|
|
165
187
|
else {
|
|
166
188
|
const inputs = await collectArchonServiceInputs(platform, this.archon, recordedIds);
|
|
167
|
-
if ('blocked' in inputs)
|
|
189
|
+
if ('blocked' in inputs) {
|
|
168
190
|
blocked.archon = inputs.blocked;
|
|
191
|
+
}
|
|
192
|
+
else if (!this.archon.masterKeys || !this.archon.heldKeys?.put) {
|
|
193
|
+
// #213: Archon's own onboarding reads these back as held keys
|
|
194
|
+
// before it can start; a host that cannot put them blocks Archon,
|
|
195
|
+
// the same rule a missing service ref follows above.
|
|
196
|
+
blocked.archon =
|
|
197
|
+
'Archon is blocked, not planned: the host built the platform executor without a way to put its master keys (masterKeys, and heldKeys with a put)';
|
|
198
|
+
}
|
|
169
199
|
}
|
|
170
200
|
}
|
|
171
201
|
return blocked;
|
|
172
202
|
}
|
|
173
|
-
/**
|
|
203
|
+
/**
|
|
204
|
+
* Whether Archon's live Access policy lets through anyone but the declared
|
|
205
|
+
* emails, or (#211 round 4) the broker it last registered with no longer
|
|
206
|
+
* carries its entry; or (#216 round 4) the platform's recorded
|
|
207
|
+
* login-client token is no longer the sole live PAT on its ZITADEL machine
|
|
208
|
+
* user.
|
|
209
|
+
*/
|
|
174
210
|
async driftedSteps(platform, recordedIds = {}) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
211
|
+
const result = {};
|
|
212
|
+
if (platform.archon.enabled &&
|
|
213
|
+
this.archon &&
|
|
214
|
+
recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]) {
|
|
215
|
+
const accessDrifted = await archonAccessDrifted(this.archon.cloudflare, platform.archon.hostname, this.archon.accessEmails);
|
|
216
|
+
const brokerDrifted = !accessDrifted && (await archonBrokerRegistrationDrifted(this.archon.coolify, recordedIds));
|
|
217
|
+
if (accessDrifted || brokerDrifted)
|
|
218
|
+
result.archon = true;
|
|
219
|
+
}
|
|
220
|
+
if (platform.auth.enabled && this.auth) {
|
|
221
|
+
const loginClientDrifted = await authLoginClientDrifted(platform, this.auth, recordedIds);
|
|
222
|
+
if (loginClientDrifted)
|
|
223
|
+
result.auth = true;
|
|
224
|
+
}
|
|
225
|
+
return result;
|
|
181
226
|
}
|
|
182
227
|
async execute(action, context) {
|
|
183
228
|
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_AUTH_KEY) {
|
|
@@ -244,11 +289,25 @@ export class FoundryPlatformStepExecutor {
|
|
|
244
289
|
const created = await this.cloudflare.createZone(domain);
|
|
245
290
|
return { resourceId: created.id };
|
|
246
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* #152: the platform `sfu` step owns the shared LiveKit config, so it
|
|
294
|
+
* lists every org with calls enabled itself -- `DeclaredOrgStore.list()`,
|
|
295
|
+
* filtered the same way an org's own `calls.enabled` flag gates its
|
|
296
|
+
* `coolify:calls-service` step (`executor.ts`'s `coolifyCallsService`) --
|
|
297
|
+
* rather than taking the list as an argument nothing else would supply.
|
|
298
|
+
* Sorted by slug so a re-run with the same set of enabled orgs is a no-op
|
|
299
|
+
* even before `applyCallsSfuPlatformStep`'s own sort (its doc comment).
|
|
300
|
+
*/
|
|
247
301
|
async executeSfu() {
|
|
248
302
|
if (!this.sfu) {
|
|
249
303
|
throw new Error('platform step sfu:livekit is not built yet (#170: pass `sfu` to FoundryPlatformStepExecutor)');
|
|
250
304
|
}
|
|
251
|
-
await
|
|
305
|
+
const orgs = await this.declaredOrgStore.list();
|
|
306
|
+
const orgSlugs = orgs
|
|
307
|
+
.filter((org) => org.calls.enabled)
|
|
308
|
+
.map((org) => org.slug)
|
|
309
|
+
.sort();
|
|
310
|
+
await applyCallsSfuPlatformStep(this.sfu, orgSlugs);
|
|
252
311
|
return undefined;
|
|
253
312
|
}
|
|
254
313
|
async coolifyJobs(action, context) {
|
|
@@ -367,6 +426,7 @@ const PLATFORM_AUTH_PROJECT = 'platform';
|
|
|
367
426
|
const PLATFORM_AUTH_DB_NAME = 'platform-auth-db';
|
|
368
427
|
const PLATFORM_AUTH_ZITADEL_APP_NAME = 'platform-auth-zitadel';
|
|
369
428
|
const PLATFORM_AUTH_LOGIN_APP_NAME = 'platform-auth-login';
|
|
429
|
+
const PLATFORM_AUTH_BROKER_APP_NAME = 'platform-auth-broker';
|
|
370
430
|
/**
|
|
371
431
|
* Fixed `custom_internal_name`s (`diff.ts`'s `coolifyCalls` sets the same
|
|
372
432
|
* kind of name on Synapse/MAS) so the Cloudflare tunnel below can address
|
|
@@ -376,15 +436,18 @@ const PLATFORM_AUTH_LOGIN_APP_NAME = 'platform-auth-login';
|
|
|
376
436
|
*/
|
|
377
437
|
const PLATFORM_AUTH_ZITADEL_INTERNAL_NAME = 'platform-auth-zitadel';
|
|
378
438
|
const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = 'platform-auth-login';
|
|
439
|
+
const PLATFORM_AUTH_BROKER_INTERNAL_NAME = 'platform-auth-broker';
|
|
379
440
|
const PLATFORM_AUTH_TUNNEL_NAME = 'platform-auth-tunnel';
|
|
380
441
|
/** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_AUTH_TUNNEL_NAME}. */
|
|
381
442
|
const PLATFORM_AUTH_CLOUDFLARED_APP_NAME = 'platform-auth-cloudflared';
|
|
382
|
-
/** `wtfalch/auth`'s `docker-compose.yml`: `image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION:-v4.17.3}`. */
|
|
443
|
+
/** `wtfalch/auth`'s `docker-compose.yml`: `image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION:-v4.17.3}`. Pinned in `versions.json` (#247). */
|
|
383
444
|
const AUTH_ZITADEL_IMAGE = 'ghcr.io/zitadel/zitadel';
|
|
384
|
-
const AUTH_ZITADEL_IMAGE_TAG =
|
|
445
|
+
const AUTH_ZITADEL_IMAGE_TAG = VERSIONS.zitadel;
|
|
385
446
|
/** `wtfalch/auth`'s own `login/Dockerfile` -- `docs/deploy.md`'s "One compose service, four containers... Two images are built from this repository: the proxy... and the login". */
|
|
386
447
|
const AUTH_REPO = 'wtfalch/auth';
|
|
387
448
|
const AUTH_LOGIN_DOCKERFILE = 'login/Dockerfile';
|
|
449
|
+
/** `wtfalch/auth`'s own `broker/Dockerfile` -- built from the repo root so the workspace resolves (#204). */
|
|
450
|
+
const AUTH_BROKER_DOCKERFILE = 'broker/Dockerfile';
|
|
388
451
|
/**
|
|
389
452
|
* `wtfalch/auth`'s login app answers on 3000 (its `Dockerfile`'s Next.js
|
|
390
453
|
* default); ZITADEL answers on 8080 -- both fixed by `docker-compose.yml`'s
|
|
@@ -392,6 +455,8 @@ const AUTH_LOGIN_DOCKERFILE = 'login/Dockerfile';
|
|
|
392
455
|
*/
|
|
393
456
|
const AUTH_ZITADEL_PORT = '8080';
|
|
394
457
|
const AUTH_LOGIN_PORT = '3000';
|
|
458
|
+
/** `broker/Dockerfile`'s own `ENV PORT=3000` / `EXPOSE 3000`. */
|
|
459
|
+
const AUTH_BROKER_PORT = '3000';
|
|
395
460
|
/**
|
|
396
461
|
* Every ZITADEL OIDC/management/console/asset path -- `deploy/proxy/Caddyfile`'s
|
|
397
462
|
* own explicit list, restated here as one regex since the tunnel routes by a
|
|
@@ -400,6 +465,14 @@ const AUTH_LOGIN_PORT = '3000';
|
|
|
400
465
|
* everything else, including the root).
|
|
401
466
|
*/
|
|
402
467
|
const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|system|auth|assets|ui/console|\\.well-known)(/|$)';
|
|
468
|
+
/**
|
|
469
|
+
* `@wtfalch/auth`'s own `brokerUrl` default, `<issuer>/api`
|
|
470
|
+
* (`packages/auth/src/config.ts`) -- `deploy/proxy/Caddyfile`'s own
|
|
471
|
+
* `handle /api/*`. Checked before {@link AUTH_ZITADEL_PATH_PATTERN}'s
|
|
472
|
+
* catch-all-else login rule below, so it must be its own ingress rule rather
|
|
473
|
+
* than folded into it.
|
|
474
|
+
*/
|
|
475
|
+
const AUTH_BROKER_PATH_PATTERN = '^/api(/|$)';
|
|
403
476
|
const PLATFORM_AUTH_PROJECT_SCOPE_KEY = 'coolify_project_platform_auth';
|
|
404
477
|
const PLATFORM_AUTH_DB_SCOPE_KEY = 'coolify_platform_auth_database';
|
|
405
478
|
const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = 'coolify_platform_auth_zitadel_app';
|
|
@@ -408,43 +481,252 @@ const PLATFORM_AUTH_TUNNEL_SCOPE_KEY = 'cloudflare_platform_auth_tunnel';
|
|
|
408
481
|
/** Recorded once the ZITADEL app has been deployed, so ZITADEL may have written to its database. */
|
|
409
482
|
const PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY = 'zitadel_platform_auth_deployed';
|
|
410
483
|
const PLATFORM_AUTH_ADMIN_USER_NAME = 'foundry-admin';
|
|
484
|
+
/** #216 (building on #209/#215): the narrower `IAM_LOGIN_CLIENT` machine user for the login app and the broker. */
|
|
485
|
+
const PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME = 'foundry-login-client';
|
|
411
486
|
const PLATFORM_AUTH_ORG_NAME = 'foundry';
|
|
487
|
+
/**
|
|
488
|
+
* Retries `action` up to `attempts` times, sleeping increasingly between
|
|
489
|
+
* tries -- the same shape the admin machine user's own delivery retry (just
|
|
490
|
+
* below) already uses inline, pulled out here because #216's login-client
|
|
491
|
+
* block needs it four times over (both apps' envs, both apps' deploys)
|
|
492
|
+
* rather than once. Rethrows the *last* failure unwrapped: callers that must
|
|
493
|
+
* never echo the underlying error (a Coolify validation error can include
|
|
494
|
+
* the rejected value -- here, the token itself, #216 follow-up review item
|
|
495
|
+
* 1) catch this function's own rejection and build a safe message instead of
|
|
496
|
+
* reading `.message` off what it throws.
|
|
497
|
+
*/
|
|
498
|
+
async function retryPlatformAuthCall(attempts, sleep, action) {
|
|
499
|
+
for (let attempt = 1;; attempt += 1) {
|
|
500
|
+
try {
|
|
501
|
+
return await action();
|
|
502
|
+
}
|
|
503
|
+
catch (e) {
|
|
504
|
+
if (attempt >= attempts)
|
|
505
|
+
throw e;
|
|
506
|
+
await sleep(1000 * attempt);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* #216 round 5: sha256 of the exact login-client token value -- never the
|
|
512
|
+
* token itself, only its hash, the same `hashAuthApps` rule for the same
|
|
513
|
+
* reason: a recorded scope id must never leak a secret.
|
|
514
|
+
*/
|
|
515
|
+
function hashLoginClientToken(token) {
|
|
516
|
+
return createHash('sha256').update(token).digest('hex');
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* #216 round 4/5: encodes the {@link PlatformAuthLoginClientRecord} a
|
|
520
|
+
* successful login-client delivery writes under
|
|
521
|
+
* {@link PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY}, replacing the plain
|
|
522
|
+
* user-id string round 2-3 wrote there. Paired with
|
|
523
|
+
* {@link parseLoginClientRecord} (`platform-diff.ts`), which `planAuth`
|
|
524
|
+
* reads back to answer whether the delivered PAT is still live, and with
|
|
525
|
+
* {@link authLoginClientDrifted} below, which (round 5) also reads `token`'s
|
|
526
|
+
* hash back to answer whether what was actually delivered is still live.
|
|
527
|
+
*/
|
|
528
|
+
function loginClientRecordValue(userId, patId, token) {
|
|
529
|
+
const record = {
|
|
530
|
+
userId,
|
|
531
|
+
patId,
|
|
532
|
+
tokenHash: hashLoginClientToken(token),
|
|
533
|
+
};
|
|
534
|
+
return JSON.stringify(record);
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* #216 round 4: whether the login-client PAT recorded for this platform
|
|
538
|
+
* (under {@link PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY}) is still, right
|
|
539
|
+
* now in ZITADEL, the one and only live PAT on that machine user. `false`
|
|
540
|
+
* when nothing is recorded yet -- that is "unapplied," which `planAuth`
|
|
541
|
+
* already plans for without this function's help, not drift.
|
|
542
|
+
*
|
|
543
|
+
* Every real mint revokes every other PAT on the user first
|
|
544
|
+
* (`zitadel-machine-user.ts`), so "exactly one live PAT, and it is the
|
|
545
|
+
* recorded one" is never a false negative: a concurrent apply's own mint
|
|
546
|
+
* landing at any point -- before this platform's last delivery, after it, or
|
|
547
|
+
* anywhere in between -- always leaves the recorded PAT either gone or
|
|
548
|
+
* joined by one it isn't. This is what lets `provisionPlatformAuth` stop
|
|
549
|
+
* trying to catch that interleaving from inside the run that delivers the
|
|
550
|
+
* token (round 2's check-after, round 3's check-before-every-write, each
|
|
551
|
+
* confirmed by review to still leave a window): whichever run's write
|
|
552
|
+
* actually won, the next plan's call here sees it and replans.
|
|
553
|
+
*
|
|
554
|
+
* #216 round 5: that ZITADEL-only check is itself not enough. Ordering that
|
|
555
|
+
* breaks it (confirmed by review): run A mints PAT a and is about to
|
|
556
|
+
* deliver; run B's own mint revokes a and mints PAT b first, delivers b to
|
|
557
|
+
* both apps, and records {b}; A's slower delivery of a then lands *last*,
|
|
558
|
+
* overwriting both apps' envs with the now-dead a, and A records {a} after
|
|
559
|
+
* B. Final state: ZITADEL's only live PAT is b, which is NOT what either
|
|
560
|
+
* record ends up naming last (a) -- so a check against ZITADEL's live PATs
|
|
561
|
+
* alone, compared only to whichever record happens to be the one read, can
|
|
562
|
+
* land on either side of this by accident. The fix is to also compare what
|
|
563
|
+
* is actually live in Coolify: `record.tokenHash` is a hash of the exact
|
|
564
|
+
* token this run itself delivered, and if what is live in Coolify right now
|
|
565
|
+
* does not hash to it, the record does not describe reality, whatever
|
|
566
|
+
* ZITADEL's PAT list says. Read fresh, every plan: a corrupted Coolify env
|
|
567
|
+
* is not something minting again fixes by itself, so if this is wrong, a
|
|
568
|
+
* later apply's own mint-and-redeliver is what actually repairs it, and the
|
|
569
|
+
* only way to know to run one is to keep checking.
|
|
570
|
+
*
|
|
571
|
+
* #216 round 5: reads the ZITADEL org id back from
|
|
572
|
+
* {@link PLATFORM_ZITADEL_ORG_SCOPE_KEY} rather than calling
|
|
573
|
+
* `zitadel.findOrCreateOrg` -- a plan must not create anything, and
|
|
574
|
+
* `findOrCreateOrg`'s name says it might. `provisionPlatformAuth` records
|
|
575
|
+
* that key itself now (mirroring `provisionPlatformArchon`), so it is always
|
|
576
|
+
* present by the time a login-client record exists to check. Any of the
|
|
577
|
+
* three ids this needs (`org`, the login app, the broker) missing is not a
|
|
578
|
+
* state this function can verify against, so it reads as drifted -- the
|
|
579
|
+
* next apply's own mint-and-deliver backfills all three.
|
|
580
|
+
*
|
|
581
|
+
* #216 round 5: wrapped in `try`/`catch` -- ZITADEL or Coolify being
|
|
582
|
+
* unreachable must read as "unknown, not drifted," the same rule
|
|
583
|
+
* `archonAccessDrifted`/`archonBrokerRegistrationDrifted` already follow and
|
|
584
|
+
* for the same reason (a plan must not fail outright over a transient read),
|
|
585
|
+
* but logged here: silently treating a real outage as healthy is a worse
|
|
586
|
+
* miss for a credential-liveness check than for Archon's own Access policy.
|
|
587
|
+
*/
|
|
588
|
+
async function authLoginClientDrifted(platform, auth, recordedIds) {
|
|
589
|
+
const record = parseLoginClientRecord(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
|
|
590
|
+
if (!record)
|
|
591
|
+
return false;
|
|
592
|
+
const orgId = recordedIds[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
|
|
593
|
+
const loginAppId = recordedIds[PLATFORM_AUTH_LOGIN_SCOPE_KEY];
|
|
594
|
+
const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
|
|
595
|
+
if (!orgId || !loginAppId || !brokerAppId)
|
|
596
|
+
return true;
|
|
597
|
+
try {
|
|
598
|
+
const zitadel = auth.zitadelForInstance?.(`https://${platform.auth.hostname}`) ?? auth.zitadel;
|
|
599
|
+
const livePats = await zitadel.listLoginClientPats(orgId, record.userId);
|
|
600
|
+
if (livePats.length !== 1 || livePats[0] !== record.patId)
|
|
601
|
+
return true;
|
|
602
|
+
const [loginHash, brokerHash] = await Promise.all([
|
|
603
|
+
liveLoginClientTokenHash(auth.coolify, loginAppId),
|
|
604
|
+
liveLoginClientTokenHash(auth.coolify, brokerAppId),
|
|
605
|
+
]);
|
|
606
|
+
// #216 (4th PR review): compare each side on its own. Either side can
|
|
607
|
+
// come back `undefined` -- hidden, with nothing to check it against, see
|
|
608
|
+
// `liveLoginClientTokenHash`'s own doc comment -- and that side alone
|
|
609
|
+
// reads as "unknown," the same as the outer `catch` below. But returning
|
|
610
|
+
// "not drifted" as soon as *either* side was unreadable (round 3's
|
|
611
|
+
// behavior) hid a stale token on the *other*, readable app whenever this
|
|
612
|
+
// app's own read happened to fail or come back hidden -- a readable and
|
|
613
|
+
// wrong hash must still drift even when its sibling is unknown.
|
|
614
|
+
for (const hash of [loginHash, brokerHash]) {
|
|
615
|
+
if (hash !== undefined && hash !== record.tokenHash)
|
|
616
|
+
return true;
|
|
617
|
+
}
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
catch (e) {
|
|
621
|
+
console.error(`platform:auth: could not check the login-client token's liveness for ZITADEL machine user ${record.userId}: ${e instanceof Error ? e.message : String(e)}`);
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* #216 round 5: `ZITADEL_SERVICE_USER_TOKEN`'s live value on one app, hashed
|
|
627
|
+
* -- see {@link authLoginClientDrifted}. `undefined` means "could not be
|
|
628
|
+
* read as a trustworthy value, treat as unknown" -- never a reason by itself
|
|
629
|
+
* to call the token missing.
|
|
630
|
+
*
|
|
631
|
+
* #216 (second PR review, round 2): Coolify's own `GET
|
|
632
|
+
* /applications/{uuid}/envs` response carries both `value` and `real_value`
|
|
633
|
+
* per row (its documented schema for `list-envs-by-application-uuid`), but
|
|
634
|
+
* `CoolifyPort.listEnvs`'s narrower return type (`executor.ts`, this
|
|
635
|
+
* package's own) names only `value` and `is_preview`. The real client
|
|
636
|
+
* (`@wtfalch/providers`' `CoolifyClient.listEnvs`, which does `await
|
|
637
|
+
* r.json()` and returns it as-is with no field-stripping, `coolify.ts:219-
|
|
638
|
+
* 253`) returns Coolify's full JSON row regardless of what this package's
|
|
639
|
+
* own narrower type declares, so `real_value` is present on the object at
|
|
640
|
+
* runtime to read even when absent from the declared type.
|
|
641
|
+
*
|
|
642
|
+
* #216 (4th PR review): "could not be read" has one real cause, confirmed
|
|
643
|
+
* against Coolify's own source rather than guessed -- there is no masked
|
|
644
|
+
* placeholder string anywhere in it. `routes/api.php` maps `GET
|
|
645
|
+
* /applications/{uuid}/envs` to `ApplicationsController::envs()`, which maps
|
|
646
|
+
* each row through `removeSensitiveData()`. That method calls
|
|
647
|
+
* `$env->makeVisible(['value', 'real_value'])` only when the request's
|
|
648
|
+
* `can_read_sensitive` attribute is true (set from the API token's
|
|
649
|
+
* `read:sensitive`/`root` ability), and separately calls
|
|
650
|
+
* `$env->makeHidden(['value', 'real_value'])` whenever the env var's own
|
|
651
|
+
* `is_shown_once` flag is set ("the saved value is hidden in the UI and API
|
|
652
|
+
* responses," per that flag's own API doc comment), regardless of ability.
|
|
653
|
+
* Laravel omits a `makeHidden` attribute from the JSON row entirely -- it is
|
|
654
|
+
* not present as a key, let alone populated with a placeholder string. So
|
|
655
|
+
* unreadable here never looks like `value: '••••••••'`; it looks like
|
|
656
|
+
* `found.value` and `found.real_value` both being `undefined`.
|
|
657
|
+
* `@wtfalch/providers`' own `CoolifyEnvVar` type documents a `real_value` of
|
|
658
|
+
* `null` for a genuinely empty variable (`coolify.ts:84`, "Empty variables
|
|
659
|
+
* come back as `\"\"` here and `null` in `real_value`"), which is a
|
|
660
|
+
* different, readable state -- a real, verified value (of the empty
|
|
661
|
+
* string), not a hidden one -- so only `undefined` on both counts as hidden.
|
|
662
|
+
*
|
|
663
|
+
* A wrong guess in the unsafe direction is costly here specifically: reading
|
|
664
|
+
* a hidden field as the empty string would never hash to what was recorded,
|
|
665
|
+
* so every single plan afterward would see "drift" and re-mint, revoking the
|
|
666
|
+
* one live PAT and redeploying both apps, taking sign-in down on every apply
|
|
667
|
+
* rather than the one apply a real drift deserves. So this reads as unknown
|
|
668
|
+
* (logged once, the same as `authLoginClientDrifted`'s own outer
|
|
669
|
+
* `try`/`catch` already treats a transient read failure) whenever the row
|
|
670
|
+
* exists but carries neither key.
|
|
671
|
+
*/
|
|
672
|
+
async function liveLoginClientTokenHash(coolify, appId) {
|
|
673
|
+
const envs = await coolify.listEnvs(appId);
|
|
674
|
+
const found = envs.find((e) => e.key === 'ZITADEL_SERVICE_USER_TOKEN' && !e.is_preview);
|
|
675
|
+
if (found && found.value === undefined && found.real_value === undefined) {
|
|
676
|
+
console.error(`platform:auth: ZITADEL_SERVICE_USER_TOKEN on Coolify app ${appId} came back with neither value nor real_value -- Coolify hides both unless the caller's token has read:sensitive/root ability, or the env var's own is_shown_once flag is set; treating its liveness as unknown rather than forcing a re-mint`);
|
|
677
|
+
return undefined;
|
|
678
|
+
}
|
|
679
|
+
const value = found?.real_value ?? found?.value;
|
|
680
|
+
return hashLoginClientToken(value ?? '');
|
|
681
|
+
}
|
|
412
682
|
/**
|
|
413
683
|
* #168: makes `coolify:app` keyed {@link PLATFORM_AUTH_KEY} true -- a Coolify
|
|
414
|
-
* Postgres, the ZITADEL app itself
|
|
415
|
-
* one Cloudflare tunnel and DNS record at
|
|
416
|
-
* the first time) foundry's own admin
|
|
417
|
-
* {@link PlatformAuthDeps.deliverSecret}. Env values
|
|
418
|
-
* are `wtfalch/auth`'s own `docker-compose.yml`
|
|
419
|
-
* `ZITADEL_DOMAIN`/`wtfalch.dev` replaced by this
|
|
420
|
-
* `apex`/`auth.hostname` wherever the compose named one --
|
|
421
|
-
* setting it leaves open (login v2 required, external port 443,
|
|
422
|
-
* terminated at Cloudflare, `PASSWORDCHANGEREQUIRED` false) is kept
|
|
423
|
-
* as that compose has it (#168's "Decide").
|
|
684
|
+
* Postgres, the ZITADEL app itself, the zitadel-login app and (#204) the
|
|
685
|
+
* sign-in broker, reached through one Cloudflare tunnel and DNS record at
|
|
686
|
+
* `platform.auth.hostname`, then (only the first time) foundry's own admin
|
|
687
|
+
* machine user, handed to {@link PlatformAuthDeps.deliverSecret}. Env values
|
|
688
|
+
* and instance settings are `wtfalch/auth`'s own `docker-compose.yml`
|
|
689
|
+
* (checked 2026-09-29), with `ZITADEL_DOMAIN`/`wtfalch.dev` replaced by this
|
|
690
|
+
* platform's own `apex`/`auth.hostname` wherever the compose named one --
|
|
691
|
+
* every other setting it leaves open (login v2 required, external port 443,
|
|
692
|
+
* TLS terminated at Cloudflare, `PASSWORDCHANGEREQUIRED` false) is kept
|
|
693
|
+
* exactly as that compose has it (#168's "Decide"). The estate's old, shared
|
|
694
|
+
* sign-in host carries nothing once this runs (#204, `WTFALCH-DEV-EXIT.md`
|
|
695
|
+
* row A4).
|
|
424
696
|
*
|
|
425
697
|
* Every part is safe to run again after a throw partway: each id is recorded
|
|
426
698
|
* (and persisted) as soon as its resource exists, each app is named and
|
|
427
699
|
* deployed on every run that needs it, and the admin key is delivered before
|
|
428
700
|
* the guard that skips the mint is recorded.
|
|
429
701
|
*
|
|
430
|
-
*
|
|
431
|
-
* ZITADEL's API
|
|
432
|
-
* one docker-compose stack; foundry instead gives each app a
|
|
433
|
-
* `custom_internal_name` and lets the Cloudflare tunnel itself
|
|
434
|
-
* between them (`AUTH_ZITADEL_PATH_PATTERN
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
* one directly by its
|
|
702
|
+
* Three apps, one public hostname, no separate `proxy` app: `wtfalch/auth`
|
|
703
|
+
* splits ZITADEL's API, its login UI and its broker with a built Caddy proxy
|
|
704
|
+
* container inside one docker-compose stack; foundry instead gives each app a
|
|
705
|
+
* fixed `custom_internal_name` and lets the Cloudflare tunnel itself
|
|
706
|
+
* path-route between them (`AUTH_ZITADEL_PATH_PATTERN`,
|
|
707
|
+
* `AUTH_BROKER_PATH_PATTERN`), skipping Coolify's own Traefik (which can only
|
|
708
|
+
* dispatch by `Host`, not by path) rather than provisioning a fourth Coolify
|
|
709
|
+
* resource to do the same job. No app is created with a Coolify `domains`
|
|
710
|
+
* binding for the same reason -- the tunnel reaches each one directly by its
|
|
711
|
+
* `custom_internal_name`, never through Traefik.
|
|
439
712
|
*
|
|
440
713
|
* Known gaps, left for review (#168's own "the PR stays open for William's
|
|
441
714
|
* review"):
|
|
442
|
-
* -
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
715
|
+
* - ~~Neither the login app nor the broker ever receives
|
|
716
|
+
* `ZITADEL_SERVICE_USER_TOKEN`.~~ Closed by #216 (building on #209/#215):
|
|
717
|
+
* this function's own login-client block below mints a dedicated
|
|
718
|
+
* `IAM_LOGIN_CLIENT` machine user (never the admin one, which is
|
|
719
|
+
* `IAM_OWNER`) and delivers its token to both apps, once the broker's
|
|
720
|
+
* other required inputs are already met.
|
|
721
|
+
* - The broker never receives `RESEND_API_TOKEN` either, so it will not start
|
|
722
|
+
* on `AUTH_APPS` alone. `wtfalch/auth#138` moves the broker's account mail
|
|
723
|
+
* onto held keys and `@wtfalch/mailer`; until that lands there is no held
|
|
724
|
+
* key to read here, so this stays a plain gap rather than a wired value.
|
|
725
|
+
* - `AUTH_APPS` starts unset: the broker has nothing to serve until a
|
|
726
|
+
* platform app registers with it ({@link provisionPlatformArchon} is the
|
|
727
|
+
* first), so it will not start (`parseApps` throws on an empty list) until
|
|
728
|
+
* then either. Safe to run again: the next apply that registers an app
|
|
729
|
+
* redeploys the broker with a non-empty list.
|
|
448
730
|
* - Whoever supplies `deps.zitadel` (or `zitadelForInstance`) must hold a
|
|
449
731
|
* bearer token for the new instance, which foundry cannot read from it.
|
|
450
732
|
*
|
|
@@ -549,6 +831,28 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
549
831
|
await deps.coolify.updateApplication(loginAppId, {
|
|
550
832
|
custom_internal_name: PLATFORM_AUTH_LOGIN_INTERNAL_NAME,
|
|
551
833
|
});
|
|
834
|
+
// ---- the broker app (#204) -------------------------------------------------
|
|
835
|
+
const brokerApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_BROKER_APP_NAME, environment, async () => {
|
|
836
|
+
const server = await resolvePlatformServer(deps.coolify);
|
|
837
|
+
const githubApp = await deps.coolify.requireGithubApp();
|
|
838
|
+
return deps.coolify.createGithubAppApplication({
|
|
839
|
+
project_uuid: projectUuid,
|
|
840
|
+
server_uuid: server.uuid,
|
|
841
|
+
environment_name: environment.name,
|
|
842
|
+
environment_uuid: environment.uuid,
|
|
843
|
+
github_app_uuid: githubApp.uuid,
|
|
844
|
+
git_repository: AUTH_REPO,
|
|
845
|
+
git_branch: gitRef,
|
|
846
|
+
build_pack: 'dockerfile',
|
|
847
|
+
name: PLATFORM_AUTH_BROKER_APP_NAME,
|
|
848
|
+
dockerfile_location: AUTH_BROKER_DOCKERFILE,
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
const brokerAppId = brokerApp.appId;
|
|
852
|
+
await record(PLATFORM_AUTH_BROKER_SCOPE_KEY, brokerAppId);
|
|
853
|
+
await deps.coolify.updateApplication(brokerAppId, {
|
|
854
|
+
custom_internal_name: PLATFORM_AUTH_BROKER_INTERNAL_NAME,
|
|
855
|
+
});
|
|
552
856
|
// ---- env -----------------------------------------------------------------
|
|
553
857
|
// Generated once, handed straight to `bulkUpsertEnvs` and nowhere else --
|
|
554
858
|
// never returned, recorded, logged or interpolated into a thrown error, the
|
|
@@ -588,13 +892,24 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
588
892
|
{ key: 'OTEL_NODE_RESOURCE_DETECTORS', value: 'none' },
|
|
589
893
|
{ key: 'API_CACHE_CONFIG', value: '{"defaultMinutes":2,"longMinutes":5}' },
|
|
590
894
|
];
|
|
895
|
+
// `AUTH_APPS`, `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` and `RESEND_API_TOKEN`
|
|
896
|
+
// are not pushed here -- this function's own doc comment, "Known gaps".
|
|
897
|
+
const brokerEnvs = [
|
|
898
|
+
{
|
|
899
|
+
key: 'ZITADEL_API_URL',
|
|
900
|
+
value: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
|
|
901
|
+
},
|
|
902
|
+
{ key: 'ZITADEL_DOMAIN', value: hostname },
|
|
903
|
+
];
|
|
591
904
|
// Compared before the write: an env change only reaches a running
|
|
592
905
|
// container on its next deploy (`diff.ts`'s `coolifyCalls` follows the
|
|
593
906
|
// identical rule).
|
|
594
907
|
const zitadelEnvsChanged = await envsDiffer(deps.coolify, zitadelAppId, zitadelEnvs);
|
|
595
908
|
const loginEnvsChanged = await envsDiffer(deps.coolify, loginAppId, loginEnvs);
|
|
909
|
+
const brokerEnvsChanged = await envsDiffer(deps.coolify, brokerAppId, brokerEnvs);
|
|
596
910
|
await deps.coolify.bulkUpsertEnvs(zitadelAppId, zitadelEnvs);
|
|
597
911
|
await deps.coolify.bulkUpsertEnvs(loginAppId, loginEnvs);
|
|
912
|
+
await deps.coolify.bulkUpsertEnvs(brokerAppId, brokerEnvs);
|
|
598
913
|
// Deploy an app that was just created, is not running (a throw after the
|
|
599
914
|
// create left it never deployed, or it stopped), or whose envs changed.
|
|
600
915
|
if (zitadelApp.justCreated || !zitadelApp.running || zitadelEnvsChanged) {
|
|
@@ -604,6 +919,31 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
604
919
|
if (loginApp.justCreated || !loginApp.running || loginEnvsChanged) {
|
|
605
920
|
await deps.coolify.deploy(loginAppId);
|
|
606
921
|
}
|
|
922
|
+
// #211: the broker's own `server.ts` refuses to start without
|
|
923
|
+
// `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` and `RESEND_API_TOKEN` -- deploying
|
|
924
|
+
// it without them only produces a container that crash-loops behind
|
|
925
|
+
// `/api`. The app is still created and recorded above either way, so #209
|
|
926
|
+
// and #210 (or a human, directly) have something to push the env onto;
|
|
927
|
+
// only the deploy waits.
|
|
928
|
+
// #211 round 5: `brokerDeployNeeded` is its own reason to deploy (not only
|
|
929
|
+
// alongside justCreated/running/envsChanged, none of which this function's
|
|
930
|
+
// own `brokerEnvs` write above ever touches `AUTH_APPS`): it is what
|
|
931
|
+
// catches a broker recreated under a new app id, or a crash between some
|
|
932
|
+
// other step writing `AUTH_APPS` and deploying, cases none of the other
|
|
933
|
+
// three conditions see. This function never writes `AUTH_APPS` itself
|
|
934
|
+
// (this function's own doc comment, "Known gaps"), so whatever is live now
|
|
935
|
+
// (`authAppsRaw`) is the value this deploy, if it runs, puts into effect.
|
|
936
|
+
const brokerMissingInputs = await missingBrokerInputs(deps.coolify, brokerAppId);
|
|
937
|
+
const authAppsRaw = await currentEnvValue(deps.coolify, brokerAppId, 'AUTH_APPS');
|
|
938
|
+
const recordedBrokerDeploy = parseBrokerDeployRecord(recorded[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]);
|
|
939
|
+
if (brokerMissingInputs.length === 0 &&
|
|
940
|
+
(brokerApp.justCreated ||
|
|
941
|
+
!brokerApp.running ||
|
|
942
|
+
brokerEnvsChanged ||
|
|
943
|
+
brokerDeployNeeded(brokerMissingInputs, brokerAppId, authAppsRaw, recordedBrokerDeploy))) {
|
|
944
|
+
await deps.coolify.deploy(brokerAppId);
|
|
945
|
+
await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, authAppsRaw));
|
|
946
|
+
}
|
|
607
947
|
// ---- tunnel and DNS ------------------------------------------------------
|
|
608
948
|
const existingTunnel = await deps.cloudflare.findTunnelByName(PLATFORM_AUTH_TUNNEL_NAME);
|
|
609
949
|
const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(PLATFORM_AUTH_TUNNEL_NAME));
|
|
@@ -615,6 +955,11 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
615
955
|
path: AUTH_ZITADEL_PATH_PATTERN,
|
|
616
956
|
service: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
|
|
617
957
|
},
|
|
958
|
+
{
|
|
959
|
+
hostname,
|
|
960
|
+
path: AUTH_BROKER_PATH_PATTERN,
|
|
961
|
+
service: `http://${PLATFORM_AUTH_BROKER_INTERNAL_NAME}:${AUTH_BROKER_PORT}`,
|
|
962
|
+
},
|
|
618
963
|
{ hostname, service: `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}` },
|
|
619
964
|
{ service: 'http_status:404' },
|
|
620
965
|
],
|
|
@@ -671,6 +1016,12 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
671
1016
|
await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep);
|
|
672
1017
|
const zitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
|
|
673
1018
|
const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
|
|
1019
|
+
// #216 round 5: recorded here too (mirroring `provisionPlatformArchon`),
|
|
1020
|
+
// so `authLoginClientDrifted` (below) never needs its own ZITADEL call
|
|
1021
|
+
// to resolve the org -- see that function's own doc comment.
|
|
1022
|
+
if (recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY] !== org.id) {
|
|
1023
|
+
await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, org.id);
|
|
1024
|
+
}
|
|
674
1025
|
const { userId, token } = await zitadel.mintAdminMachineUser(org.id, {
|
|
675
1026
|
userName: PLATFORM_AUTH_ADMIN_USER_NAME,
|
|
676
1027
|
name: 'Foundry (platform admin)',
|
|
@@ -698,6 +1049,132 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
698
1049
|
}
|
|
699
1050
|
await record(PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, userId);
|
|
700
1051
|
}
|
|
1052
|
+
// ---- foundry's own login-client machine user, once (#216, building on #209/#215) --
|
|
1053
|
+
// The login app and the broker both refuse to start without
|
|
1054
|
+
// `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` (this function's own doc comment,
|
|
1055
|
+
// "Known gaps") -- the admin token above must never go there (it is
|
|
1056
|
+
// `IAM_OWNER`), so this mints a second, narrower machine user
|
|
1057
|
+
// (`IAM_LOGIN_CLIENT` only) and delivers its token into both apps' own
|
|
1058
|
+
// envs, then redeploys both.
|
|
1059
|
+
//
|
|
1060
|
+
// Deferred, not blocked, while the broker's other required inputs
|
|
1061
|
+
// (`RESEND_API_TOKEN`, `AUTH_APPS`) are still missing (composing with
|
|
1062
|
+
// #211's own gating): minting and delivering here would only redeploy a
|
|
1063
|
+
// broker that still refuses to start on the other missing input, spending
|
|
1064
|
+
// a mint and two deploys on nothing this apply can fix. `blockedSteps`'s
|
|
1065
|
+
// own `missingBrokerInputs` read already names what is still missing; once
|
|
1066
|
+
// the other inputs land (#210, or Archon's own registration for
|
|
1067
|
+
// `AUTH_APPS`), a later apply's fresh read of `brokerMissingInputs` clears
|
|
1068
|
+
// this gate and this block runs.
|
|
1069
|
+
//
|
|
1070
|
+
// Unlike the admin key above, an undelivered login-client key never blocks
|
|
1071
|
+
// planning or throws a "delete this user by hand" error -- #216's own fix
|
|
1072
|
+
// for the bug PR #215 shipped with, which mirrored the admin pair's
|
|
1073
|
+
// forever-blocking behaviour here too. Revoking a stale login-client PAT
|
|
1074
|
+
// destroys nothing (unlike ZITADEL's own masterkey, nothing else was ever
|
|
1075
|
+
// encrypted with it), so `mintLoginClientMachineUser`
|
|
1076
|
+
// (`zitadel-machine-user.ts`) always lists and deletes every PAT the user
|
|
1077
|
+
// already holds before minting a fresh one. A crash between mint and
|
|
1078
|
+
// record, or every delivery retry failing, both leave this `if` still true
|
|
1079
|
+
// on the next run, which re-mints (revoking the stale PAT first) and
|
|
1080
|
+
// retries delivery, with no manual step.
|
|
1081
|
+
//
|
|
1082
|
+
// #216 round 2 and round 3 each tried to catch a concurrent apply's own
|
|
1083
|
+
// mint (idempotent revoke-then-create on the same `userName`) racing this
|
|
1084
|
+
// one -- round 2 checked liveness once, after every write; round 3 checked
|
|
1085
|
+
// again before every write, inside each retry. Review confirmed each still
|
|
1086
|
+
// left a window: a write that lands between the last check this run makes
|
|
1087
|
+
// and the very next network call is never caught by either. #216 round 4
|
|
1088
|
+
// drops both: this block no longer tries to detect the race from inside
|
|
1089
|
+
// the run that might lose it. It mints, delivers with ordinary retries,
|
|
1090
|
+
// and records whatever it actually delivered -- the user id *and* the
|
|
1091
|
+
// exact PAT id (`loginClientRecordValue`). `planAuth` (`platform-diff.ts`)
|
|
1092
|
+
// re-checks that recorded PAT against ZITADEL's live state on *every*
|
|
1093
|
+
// plan, not only this run's: whichever run's write actually won is caught
|
|
1094
|
+
// on the very next plan and replanned, however the writes interleaved.
|
|
1095
|
+
// See `authLoginClientDrifted`'s own doc comment for why that check can
|
|
1096
|
+
// never read a healthy delivery as drifted.
|
|
1097
|
+
//
|
|
1098
|
+
// Read fresh here, immediately before deciding whether to mint -- not
|
|
1099
|
+
// `recorded`, the snapshot this function took at the top, several hundred
|
|
1100
|
+
// lines above. A concurrent apply's own mint-deliver-record can land in
|
|
1101
|
+
// that gap; deciding off a stale snapshot would mint and redeliver on top
|
|
1102
|
+
// of a token another run just finished setting up.
|
|
1103
|
+
//
|
|
1104
|
+
// A record present is still not enough on its own: `planAuth` may have
|
|
1105
|
+
// replanned this step because `authLoginClientDrifted` (the same check,
|
|
1106
|
+
// reused here) found the recorded PAT no longer live -- this run has to
|
|
1107
|
+
// make the same call, not just trust that a record exists, or a drifted
|
|
1108
|
+
// platform would be planned again forever without ever actually healing.
|
|
1109
|
+
const loginClientOtherInputsMissing = brokerMissingInputs.filter((m) => m !== 'ZITADEL_SERVICE_USER_TOKEN');
|
|
1110
|
+
const freshRecordedIds = context.recordedIds();
|
|
1111
|
+
const freshLoginClientRecord = parseLoginClientRecord(freshRecordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
|
|
1112
|
+
const loginClientNeedsMint = !freshLoginClientRecord || (await authLoginClientDrifted(platform, deps, freshRecordedIds));
|
|
1113
|
+
if (loginClientNeedsMint && loginClientOtherInputsMissing.length === 0) {
|
|
1114
|
+
const loginClientZitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
|
|
1115
|
+
const { org: loginClientOrg } = await loginClientZitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
|
|
1116
|
+
// #216 round 5: backfills the org id for a platform whose admin block
|
|
1117
|
+
// (above) ran before this recording existed and has not run since --
|
|
1118
|
+
// see `authLoginClientDrifted`'s own doc comment.
|
|
1119
|
+
if (freshRecordedIds[PLATFORM_ZITADEL_ORG_SCOPE_KEY] !== loginClientOrg.id) {
|
|
1120
|
+
await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, loginClientOrg.id);
|
|
1121
|
+
}
|
|
1122
|
+
const { userId: loginClientUserId, token: loginClientToken, patId: loginClientPatId, revokedPriorPat, } = await loginClientZitadel.mintLoginClientMachineUser(loginClientOrg.id, {
|
|
1123
|
+
userName: PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME,
|
|
1124
|
+
name: 'Foundry (platform login client)',
|
|
1125
|
+
});
|
|
1126
|
+
// Recorded and persisted before the token leaves this function -- the
|
|
1127
|
+
// same "one mint" rule the admin block above follows. Never read back to
|
|
1128
|
+
// block planning; `planAuth` (`platform-diff.ts`) reads it only to
|
|
1129
|
+
// surface a non-blocking note, for the reason this block's own doc
|
|
1130
|
+
// comment above gives.
|
|
1131
|
+
await record(PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, loginClientUserId);
|
|
1132
|
+
const deliverAttempts = runtime.deliverAttempts ?? 3;
|
|
1133
|
+
try {
|
|
1134
|
+
await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.bulkUpsertEnvs(loginAppId, [
|
|
1135
|
+
{ key: 'ZITADEL_SERVICE_USER_TOKEN', value: loginClientToken },
|
|
1136
|
+
]));
|
|
1137
|
+
await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.bulkUpsertEnvs(brokerAppId, [
|
|
1138
|
+
{ key: 'ZITADEL_SERVICE_USER_TOKEN', value: loginClientToken },
|
|
1139
|
+
]));
|
|
1140
|
+
// Forced: an unforced deploy answers 200 "skipped" when one for the
|
|
1141
|
+
// same commit is already queued -- likely here, since this function's
|
|
1142
|
+
// own deploys above may just have queued one for either app. An
|
|
1143
|
+
// ignored "skipped" would let this function record delivery as
|
|
1144
|
+
// succeeded while the running containers never actually see the token
|
|
1145
|
+
// (#216 follow-up review, item 2).
|
|
1146
|
+
await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.deploy(loginAppId, true));
|
|
1147
|
+
await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.deploy(brokerAppId, true));
|
|
1148
|
+
}
|
|
1149
|
+
catch {
|
|
1150
|
+
// Never echo the underlying error: Coolify's validation error can
|
|
1151
|
+
// include the rejected env value -- the token itself -- in its
|
|
1152
|
+
// message (#216 follow-up review, item 1). Name the user id only,
|
|
1153
|
+
// never the token or the cause. Nothing records delivered, so the
|
|
1154
|
+
// next apply re-mints (revoking whatever is live) and re-delivers.
|
|
1155
|
+
//
|
|
1156
|
+
// #216 (second PR review, round 2; third PR review, nit): on a
|
|
1157
|
+
// re-mint, `mintLoginClientMachineUser` already revoked this user's
|
|
1158
|
+
// prior PAT before minting the one that just failed to deliver -- see
|
|
1159
|
+
// this file's own doc comment above and `zitadel-machine-user.ts`'s
|
|
1160
|
+
// revoke-before-mint sweep. So on that path the login app and broker
|
|
1161
|
+
// are not merely stale, they are down: neither has any live token
|
|
1162
|
+
// until a later apply mints and delivers a fresh one. `revokedPriorPat`
|
|
1163
|
+
// is false on a first-ever mint, where nothing was live to revoke --
|
|
1164
|
+
// saying "its prior token was already revoked" there would describe a
|
|
1165
|
+
// revoke that never happened, so the message only says so when it did.
|
|
1166
|
+
throw new Error(revokedPriorPat
|
|
1167
|
+
? `platform:auth: the login-client token of ZITADEL machine user ${loginClientUserId} could not be delivered after ${deliverAttempts} tries; its prior token was already revoked, so the login app and broker are down until a later apply mints and delivers a fresh one`
|
|
1168
|
+
: `platform:auth: the login-client token of ZITADEL machine user ${loginClientUserId} could not be delivered after ${deliverAttempts} tries; this is its first token, so the login app and broker still have none until a later apply mints and delivers one`);
|
|
1169
|
+
}
|
|
1170
|
+
// #216 round 4/5: records what was actually delivered -- the user id,
|
|
1171
|
+
// the exact PAT id, and (round 5) a hash of the exact token -- so the
|
|
1172
|
+
// next plan's `authLoginClientDrifted` check can tell a healthy delivery
|
|
1173
|
+
// from one a concurrent apply has since clobbered, including the
|
|
1174
|
+
// ordering round 4's own check alone could not catch. See this block's
|
|
1175
|
+
// own doc comment, and `authLoginClientDrifted`'s, for why.
|
|
1176
|
+
await record(PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, loginClientRecordValue(loginClientUserId, loginClientPatId, loginClientToken));
|
|
1177
|
+
}
|
|
701
1178
|
return { resourceId: zitadelAppId };
|
|
702
1179
|
}
|
|
703
1180
|
// ---------------------------------------------------------------------------
|
|
@@ -818,13 +1295,15 @@ export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
|
|
|
818
1295
|
* access-token `aud` check; whoever supplies
|
|
819
1296
|
* {@link PlatformMailDeps.oidcAudience} must have registered one by hand
|
|
820
1297
|
* until that is built.
|
|
821
|
-
* - **The org step
|
|
822
|
-
*
|
|
823
|
-
* not this one**, and the Lettermint team token it reads still comes from
|
|
824
|
-
* wherever the host already reads it, not held keys. Both need
|
|
1298
|
+
* - **The Lettermint team token the org app step reads still comes from
|
|
1299
|
+
* wherever the host already reads it, not held keys.** Needs
|
|
825
1300
|
* `StepExecutionContext` to carry the platform, which `plan-apply.ts`'s
|
|
826
1301
|
* own doc comment already scoped out of #167 as more than a small change
|
|
827
|
-
* -- a follow-up issue, not this one.
|
|
1302
|
+
* -- a follow-up issue, not this one. (#205 removed the org step
|
|
1303
|
+
* `coolify:mail`'s own fallback to the hand-built `wtfalch.dev`
|
|
1304
|
+
* instance -- `diff.ts`'s `planMail` already blocks the step until the
|
|
1305
|
+
* platform's own mail is applied, so the fallback only ever served a
|
|
1306
|
+
* plan recorded before that, and now fails loudly instead.)
|
|
828
1307
|
*/
|
|
829
1308
|
async function provisionPlatformMail(context, deps) {
|
|
830
1309
|
const { platform } = context;
|
|
@@ -1098,6 +1577,69 @@ async function mailBlockedReason(platform, mail, recordedIds) {
|
|
|
1098
1577
|
return "the platform's mail server is blocked, not planned: no usable Coolify server could be resolved for it";
|
|
1099
1578
|
}
|
|
1100
1579
|
}
|
|
1580
|
+
/**
|
|
1581
|
+
* #211: the broker cannot start without `ZITADEL_SERVICE_USER_TOKEN`/`_FILE`,
|
|
1582
|
+
* `RESEND_API_TOKEN` and `AUTH_APPS`. `provisionPlatformAuth` already skips
|
|
1583
|
+
* deploying the broker while any is missing ({@link missingBrokerInputs});
|
|
1584
|
+
* this is the same check surfaced through `blockedSteps`, so a plan says
|
|
1585
|
+
* why, by name, rather than the broker simply sitting undeployed with no
|
|
1586
|
+
* explanation. `null` while there is no broker app id recorded yet (nothing
|
|
1587
|
+
* to check) or every input is present.
|
|
1588
|
+
*
|
|
1589
|
+
* #211 round 3: wraps the live read the way {@link mailBlockedReason} wraps
|
|
1590
|
+
* its own Coolify call -- a transient `listEnvs` failure must report a
|
|
1591
|
+
* blocked reason naming the broker app id, never throw out of
|
|
1592
|
+
* `blockedSteps` and take the rest of the plan down with it.
|
|
1593
|
+
*
|
|
1594
|
+
* #211 round 5 addendum: when `AUTH_APPS` is the reason, names which problem
|
|
1595
|
+
* it actually is ({@link authAppsProblem}) instead of always saying "it has
|
|
1596
|
+
* no ... AUTH_APPS yet" -- that wording is only true when nothing has been
|
|
1597
|
+
* set; a present-but-invalid value is a different failure (a person needs to
|
|
1598
|
+
* go fix an entry, not merely wait for one of #209/#210/Archon's own
|
|
1599
|
+
* registration to supply one), and reporting it identically hid which one
|
|
1600
|
+
* was true.
|
|
1601
|
+
*/
|
|
1602
|
+
async function authBrokerBlockedReason(auth, recordedIds) {
|
|
1603
|
+
const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
|
|
1604
|
+
if (!brokerAppId)
|
|
1605
|
+
return { reason: null, otherInputsMissing: [] };
|
|
1606
|
+
let missing;
|
|
1607
|
+
let authAppsRaw;
|
|
1608
|
+
try {
|
|
1609
|
+
missing = await missingBrokerInputs(auth.coolify, brokerAppId);
|
|
1610
|
+
authAppsRaw = await currentEnvValue(auth.coolify, brokerAppId, 'AUTH_APPS');
|
|
1611
|
+
}
|
|
1612
|
+
catch {
|
|
1613
|
+
return {
|
|
1614
|
+
reason: `the platform's sign-in broker ${brokerAppId} is blocked, not deployed: its live env could not be read`,
|
|
1615
|
+
// #216 round 5: unreadable, not "known missing" -- `planAuth` must not
|
|
1616
|
+
// assume the login-client mint is blocked by anything in particular.
|
|
1617
|
+
otherInputsMissing: [],
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
// #216 round 5: what `provisionPlatformAuth`'s own `loginClientOtherInputsMissing`
|
|
1621
|
+
// gate is -- see `planAuth`'s own doc comment for why it needs this too.
|
|
1622
|
+
const otherInputsMissing = missing.filter((m) => m !== 'ZITADEL_SERVICE_USER_TOKEN');
|
|
1623
|
+
if (missing.length === 0)
|
|
1624
|
+
return { reason: null, otherInputsMissing };
|
|
1625
|
+
if (authAppsProblem(authAppsRaw, brokerAppId) === 'malformed') {
|
|
1626
|
+
const otherMissing = missing.filter((m) => m !== 'AUTH_APPS');
|
|
1627
|
+
const otherSuffix = otherMissing.length > 0
|
|
1628
|
+
? `, and it has no ${otherMissing.join(' and no ')} yet (#209, #210)`
|
|
1629
|
+
: '';
|
|
1630
|
+
return {
|
|
1631
|
+
reason: `the platform's sign-in broker ${brokerAppId} is blocked, not deployed: its AUTH_APPS is malformed${otherSuffix}`,
|
|
1632
|
+
otherInputsMissing,
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
// AUTH_APPS isn't gated on #209/#210 the way the two tokens are; only cite
|
|
1636
|
+
// those issues when one of them is actually among what's missing.
|
|
1637
|
+
const suffix = missing.some((m) => m !== 'AUTH_APPS') ? ' (#209, #210)' : '';
|
|
1638
|
+
return {
|
|
1639
|
+
reason: `the platform's sign-in broker is created but not deployed, blocked: it has no ${missing.join(' and no ')} yet${suffix}`,
|
|
1640
|
+
otherInputsMissing,
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1101
1643
|
/** Polls until Coolify reports the mail app stopped, so a healthy answer read afterwards is the new deployment's. */
|
|
1102
1644
|
async function waitForMailStopped(coolify, appId, runtime, sleep) {
|
|
1103
1645
|
const now = runtime.now ?? Date.now;
|
|
@@ -1222,15 +1764,25 @@ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_clie
|
|
|
1222
1764
|
* PKCE, no client secret: `createOidcApp` never returns one, see
|
|
1223
1765
|
* `executor.ts`'s `zitadelApp`), which is what Archon's `@wtfalch/auth`
|
|
1224
1766
|
* sign-in reads -- it takes `AUTH_CLIENT_ID` and no client secret. So there
|
|
1225
|
-
* is no one-time secret to hand to `deliverSecret` here. The
|
|
1226
|
-
* step does generate (cookie secret, runtime-role password)
|
|
1227
|
-
* the app's env and nowhere else: never returned, recorded,
|
|
1228
|
-
* an error.
|
|
1767
|
+
* is no one-time secret to hand to `deliverSecret` here. The three values this
|
|
1768
|
+
* step does generate (cookie secret, runtime-role password, `AUTH_APP_KEY`)
|
|
1769
|
+
* go straight into the app's env and nowhere else: never returned, recorded,
|
|
1770
|
+
* logged or put in an error.
|
|
1771
|
+
*
|
|
1772
|
+
* `AUTH_APP_KEY` (#204): minted once, the same `ensureGeneratedEnv` way as
|
|
1773
|
+
* the cookie secret, and registered with the broker by rewriting its
|
|
1774
|
+
* `AUTH_APPS` env to include Archon's entry -- `deliverSecret` is not the
|
|
1775
|
+
* right shape here, since the destination is another app's env this same
|
|
1776
|
+
* function already writes to, not a human or a secret store. Requires
|
|
1777
|
+
* {@link PLATFORM_AUTH_BROKER_SCOPE_KEY} to be recorded, which
|
|
1778
|
+
* `provisionPlatformAuth` guarantees by the time this step is reachable (its
|
|
1779
|
+
* own resourceId records only once the broker app exists too). The broker
|
|
1780
|
+
* will not actually be able to serve a request with this key until
|
|
1781
|
+
* `provisionPlatformAuth`'s own "Known gaps" (`ZITADEL_SERVICE_USER_TOKEN`,
|
|
1782
|
+
* `RESEND_API_TOKEN`) are closed; wiring the key here is still correct and
|
|
1783
|
+
* needed so the two apps agree the moment the broker can run.
|
|
1229
1784
|
*
|
|
1230
1785
|
* Known gaps, left for review:
|
|
1231
|
-
* - `AUTH_APP_KEY`: the `auth.wtfalch.dev` broker's per-organisation
|
|
1232
|
-
* registry key. The platform's new sign-in has no broker registration API,
|
|
1233
|
-
* the same gap `executor.ts`'s `pushAppEnv` names for an org's app.
|
|
1234
1786
|
* - `AUTHZ_BOOTSTRAP_OPERATOR`: the first operator's ZITADEL subject id. A
|
|
1235
1787
|
* person has to exist in the new instance first; not something foundry
|
|
1236
1788
|
* mints.
|
|
@@ -1280,6 +1832,13 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
1280
1832
|
const inputs = await collectArchonServiceInputs(platform, deps, recorded);
|
|
1281
1833
|
if ('blocked' in inputs)
|
|
1282
1834
|
throw new Error(`platform:archon: ${inputs.blocked}`);
|
|
1835
|
+
// #213: Archon's own onboarding reads these back as held keys before it
|
|
1836
|
+
// can start. Put before anything below touches Coolify, so a host that
|
|
1837
|
+
// cannot put them never gets a half-built app.
|
|
1838
|
+
if (!deps.masterKeys || !deps.heldKeys?.put) {
|
|
1839
|
+
throw new Error("platform:archon: the host built the platform executor without a way to put its master keys (masterKeys, and heldKeys with a put) -- Archon's onboarding reads them as held keys before it can start (#213)");
|
|
1840
|
+
}
|
|
1841
|
+
await putArchonMasterKeys(deps.heldKeys, deps.masterKeys);
|
|
1283
1842
|
// ---- Coolify project and database -----------------------------------------
|
|
1284
1843
|
let projectUuid = recorded[PLATFORM_ARCHON_PROJECT_SCOPE_KEY];
|
|
1285
1844
|
if (!projectUuid) {
|
|
@@ -1290,19 +1849,32 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
1290
1849
|
await record(PLATFORM_ARCHON_PROJECT_SCOPE_KEY, projectUuid);
|
|
1291
1850
|
}
|
|
1292
1851
|
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1852
|
+
// #265: a database already named `platform-archon-db` (the hand-deployed
|
|
1853
|
+
// one) is adopted, never built a second time. One of that name in another
|
|
1854
|
+
// environment is refused: adopting or duplicating it would split the data.
|
|
1293
1855
|
let dbId = recorded[PLATFORM_ARCHON_DB_SCOPE_KEY];
|
|
1294
1856
|
if (!dbId) {
|
|
1295
|
-
const
|
|
1296
|
-
const
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1857
|
+
const named = (await deps.coolify.listDatabases()).filter((d) => d.name === PLATFORM_ARCHON_DB_NAME);
|
|
1858
|
+
const found = named.find((d) => d.environment_id === environment.id);
|
|
1859
|
+
if (!found && named.length > 0) {
|
|
1860
|
+
throw new Error(`platform:archon: a database named ${PLATFORM_ARCHON_DB_NAME} exists outside the ${PLATFORM_ARCHON_PROJECT} project's production environment; refusing to adopt or duplicate it. Move or rename it in Coolify and apply again`);
|
|
1861
|
+
}
|
|
1862
|
+
if (found) {
|
|
1863
|
+
dbId = found.uuid;
|
|
1864
|
+
}
|
|
1865
|
+
else {
|
|
1866
|
+
const server = await resolvePlatformServer(deps.coolify);
|
|
1867
|
+
const created = await deps.coolify.createPostgresDatabase({
|
|
1868
|
+
project_uuid: projectUuid,
|
|
1869
|
+
server_uuid: server.uuid,
|
|
1870
|
+
environment_name: environment.name,
|
|
1871
|
+
environment_uuid: environment.uuid,
|
|
1872
|
+
name: PLATFORM_ARCHON_DB_NAME,
|
|
1873
|
+
postgres_db: ARCHON_POSTGRES_DB,
|
|
1874
|
+
postgres_user: ARCHON_POSTGRES_DB,
|
|
1875
|
+
});
|
|
1876
|
+
dbId = created.uuid;
|
|
1877
|
+
}
|
|
1306
1878
|
await record(PLATFORM_ARCHON_DB_SCOPE_KEY, dbId);
|
|
1307
1879
|
}
|
|
1308
1880
|
const db = await deps.coolify.getDatabase(dbId);
|
|
@@ -1311,6 +1883,12 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
1311
1883
|
}
|
|
1312
1884
|
// ---- the app ----------------------------------------------------------------
|
|
1313
1885
|
const app = await findOrCreatePlatformApp(deps.coolify, PLATFORM_ARCHON_APP_NAME, environment, async () => {
|
|
1886
|
+
// #265: the lookup above is scoped to this environment, so a same-named
|
|
1887
|
+
// app elsewhere is invisible to it. Making another one would give two
|
|
1888
|
+
// `platform-archon` apps; refuse instead.
|
|
1889
|
+
if (await deps.coolify.findApplicationByName(PLATFORM_ARCHON_APP_NAME)) {
|
|
1890
|
+
throw new Error(`platform:archon: an application named ${PLATFORM_ARCHON_APP_NAME} exists outside the ${PLATFORM_ARCHON_PROJECT} project's production environment; refusing to create a second one. Move or rename it in Coolify and apply again`);
|
|
1891
|
+
}
|
|
1314
1892
|
const server = await resolvePlatformServer(deps.coolify);
|
|
1315
1893
|
const githubApp = await deps.coolify.requireGithubApp();
|
|
1316
1894
|
return deps.coolify.createGithubAppApplication({
|
|
@@ -1386,18 +1964,130 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
1386
1964
|
{ key: 'AUTH_ISSUER', value: issuer },
|
|
1387
1965
|
{ key: 'AUTH_ORGANIZATION_ID', value: org.id },
|
|
1388
1966
|
{ key: 'AUTH_CLIENT_ID', value: clientId },
|
|
1389
|
-
{ key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
|
|
1390
|
-
{ key: 'REPORTING_SITE', value: 'archon' },
|
|
1391
1967
|
];
|
|
1968
|
+
// #265: an owner URL or reporting site already on the app is kept as it is.
|
|
1969
|
+
// The owner URL must name the adopted database's host, or the app's two
|
|
1970
|
+
// database URLs would point at different databases.
|
|
1971
|
+
const liveOwnerUrl = await currentEnvValue(deps.coolify, appId, 'DATABASE_URL_OWNER');
|
|
1972
|
+
if (liveOwnerUrl) {
|
|
1973
|
+
assertSameDatabaseHost('DATABASE_URL_OWNER', liveOwnerUrl, db.internal_db_url);
|
|
1974
|
+
}
|
|
1975
|
+
else {
|
|
1976
|
+
envs.push({ key: 'DATABASE_URL_OWNER', value: db.internal_db_url });
|
|
1977
|
+
}
|
|
1978
|
+
if (!(await currentEnvValue(deps.coolify, appId, 'REPORTING_SITE'))) {
|
|
1979
|
+
envs.push({ key: 'REPORTING_SITE', value: 'archon' });
|
|
1980
|
+
}
|
|
1392
1981
|
const cookieSecret = await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_COOKIE_SECRET', () => randomBytes(32).toString('base64'));
|
|
1393
1982
|
if (cookieSecret)
|
|
1394
1983
|
envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
|
|
1395
1984
|
const runtimeUrl = await ensureArchonRuntimeUrl(deps.coolify, appId, db.internal_db_url);
|
|
1396
1985
|
if (runtimeUrl)
|
|
1397
1986
|
envs.push({ key: 'DATABASE_URL', value: runtimeUrl });
|
|
1987
|
+
// `AUTH_APP_KEY` (#204): minted once like the cookie secret above, but read
|
|
1988
|
+
// back (never regenerated) rather than skipped when already set, since its
|
|
1989
|
+
// value is needed below to keep the broker's own registration in sync even
|
|
1990
|
+
// on a run that mints nothing new. `randomBytes(32)` (#211), the same
|
|
1991
|
+
// random half `wtfalch/auth/broker/src/apps.ts`'s own `mintKey` gives an
|
|
1992
|
+
// app key -- `parseApps` there rejects a key under 32 *characters*, and
|
|
1993
|
+
// `randomBytes(24)` base64url-encodes to exactly 32, landing right on that
|
|
1994
|
+
// edge with no margin for a different encoding or a trim.
|
|
1995
|
+
// #211 round 5: `minLength: 32` on this call only -- a live value shorter
|
|
1996
|
+
// than the broker itself will accept is exactly as unusable as one that is
|
|
1997
|
+
// empty, and `readBrokerApps`/`validateBrokerAppEntry` would refuse it the
|
|
1998
|
+
// moment this step tried to register it. See `ensureGeneratedEnv`'s own
|
|
1999
|
+
// doc comment for why no other call site gets this argument.
|
|
2000
|
+
const appKey = (await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_APP_KEY', () => randomBytes(32).toString('base64url'), 32)) ?? (await currentEnvValue(deps.coolify, appId, 'AUTH_APP_KEY'));
|
|
2001
|
+
if (!appKey) {
|
|
2002
|
+
throw new Error(`platform:archon: app ${appId} lost its AUTH_APP_KEY between two reads`);
|
|
2003
|
+
}
|
|
2004
|
+
envs.push({ key: 'AUTH_APP_KEY', value: appKey });
|
|
1398
2005
|
envs.push(...inputs.envs);
|
|
1399
2006
|
const changed = await envsDiffer(deps.coolify, appId, envs);
|
|
1400
2007
|
await deps.coolify.bulkUpsertEnvs(appId, envs);
|
|
2008
|
+
// ---- register with the broker (#204) ---------------------------------------
|
|
2009
|
+
// After Archon's own `AUTH_APP_KEY` is durably written above, so a throw
|
|
2010
|
+
// earlier in this function (a bad database URL, a Cloudflare failure) never
|
|
2011
|
+
// touches the broker. `provisionPlatformAuth`'s own resourceId records only
|
|
2012
|
+
// once the broker app exists too (it is created before that function
|
|
2013
|
+
// returns), and this step is unreachable until that resourceId is recorded
|
|
2014
|
+
// (the guard at the top of this function) -- so a missing id here means a
|
|
2015
|
+
// caller reached this step some other way.
|
|
2016
|
+
const brokerAppId = recorded[PLATFORM_AUTH_BROKER_SCOPE_KEY];
|
|
2017
|
+
if (!brokerAppId) {
|
|
2018
|
+
throw new Error('platform:archon: platform:auth has not recorded its broker app id yet; apply platform:auth again');
|
|
2019
|
+
}
|
|
2020
|
+
const brokerApps = await readBrokerApps(deps.coolify, brokerAppId);
|
|
2021
|
+
// #211 round 2: merged into the matching entry, not replacing it -- the
|
|
2022
|
+
// broker's own `App` (`broker/src/apps.ts`) carries fields this step never
|
|
2023
|
+
// sets (`registration`, `locale`, `productName`, `emailLink`), and a
|
|
2024
|
+
// previous `filter`-then-append dropped every one of them, plus every
|
|
2025
|
+
// *other* app this organisation may have registered directly through the
|
|
2026
|
+
// broker's own registration API. Matched on `organizationId` and `name`
|
|
2027
|
+
// together: Archon is one app of possibly several this organisation runs,
|
|
2028
|
+
// and only Archon's own entry is this step's to touch.
|
|
2029
|
+
// #211 round 3: every entry matching Archon's org and name, not just the
|
|
2030
|
+
// first -- a previous bug, or a hand-edited AUTH_APPS, can leave more than
|
|
2031
|
+
// one. The first is kept (and updated below); the rest are dropped, so no
|
|
2032
|
+
// duplicate carries a stale key a broker client could still present.
|
|
2033
|
+
const archonMatchIndices = brokerApps
|
|
2034
|
+
.map((a, i) => (a.organizationId === org.id && a.name === PLATFORM_ARCHON_ZITADEL_APP ? i : -1))
|
|
2035
|
+
.filter((i) => i >= 0);
|
|
2036
|
+
const archonEntryIndex = archonMatchIndices[0] ?? -1;
|
|
2037
|
+
const duplicateIndices = new Set(archonMatchIndices.slice(1));
|
|
2038
|
+
const archonEntry = {
|
|
2039
|
+
...(archonEntryIndex >= 0 ? brokerApps[archonEntryIndex] : {}),
|
|
2040
|
+
organizationId: org.id,
|
|
2041
|
+
key: appKey,
|
|
2042
|
+
name: PLATFORM_ARCHON_ZITADEL_APP,
|
|
2043
|
+
clientIds: [clientId],
|
|
2044
|
+
origins: [`https://${hostname}`],
|
|
2045
|
+
// #211: `wtfalch/auth`'s broker (`broker/src/apps.ts`'s `parseAppFields`)
|
|
2046
|
+
// falls back to `wtfalch <noreply@wtfalch.dev>` for an entry with no
|
|
2047
|
+
// `from`, and (`wtfalch/auth#179`) an entry with no `from` is dropped
|
|
2048
|
+
// entirely -- either way, unset here means Archon's mail comes from
|
|
2049
|
+
// the wrong platform or not at all.
|
|
2050
|
+
from: `Company Included <noreply@${platform.apex}>`,
|
|
2051
|
+
};
|
|
2052
|
+
const desiredBrokerApps = archonEntryIndex >= 0
|
|
2053
|
+
? brokerApps
|
|
2054
|
+
.map((a, i) => (i === archonEntryIndex ? archonEntry : a))
|
|
2055
|
+
.filter((_, i) => !duplicateIndices.has(i))
|
|
2056
|
+
: [...brokerApps, archonEntry];
|
|
2057
|
+
const desiredAuthApps = [{ key: 'AUTH_APPS', value: JSON.stringify(desiredBrokerApps) }];
|
|
2058
|
+
// Rebuilt and compared every run (not gated on a freshly minted key), so a
|
|
2059
|
+
// clientId or hostname change reaches the broker too, and so a previous
|
|
2060
|
+
// run that wrote Archon's own key but failed before reaching the broker is
|
|
2061
|
+
// repaired by this one.
|
|
2062
|
+
const authAppsChanged = await envsDiffer(deps.coolify, brokerAppId, desiredAuthApps);
|
|
2063
|
+
if (authAppsChanged) {
|
|
2064
|
+
await deps.coolify.bulkUpsertEnvs(brokerAppId, desiredAuthApps);
|
|
2065
|
+
}
|
|
2066
|
+
// #211 round 2: deploying the broker before its own `server.ts` can start
|
|
2067
|
+
// (missing `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` or `RESEND_API_TOKEN`, #209,
|
|
2068
|
+
// #210) only produces a container that crash-loops behind `/api` -- the
|
|
2069
|
+
// same rule `provisionPlatformAuth` follows for its own deploy of this
|
|
2070
|
+
// app. The env is still written either way, so the broker serves the new
|
|
2071
|
+
// registration the moment it is next deployed, by either step.
|
|
2072
|
+
// #211 round 5: the deploy decision is {@link brokerDeployNeeded}, the same
|
|
2073
|
+
// rule `provisionPlatformAuth` uses -- it is what replaces the old
|
|
2074
|
+
// `!brokerAlreadyDeployed` boolean, and with it catches both a broker
|
|
2075
|
+
// recreated under a new app id (this step's own last-registered-broker
|
|
2076
|
+
// check, `archonBrokerRegistrationDrifted`, is a different, already-correct
|
|
2077
|
+
// mechanism for *replanning*; this is what stops the *executor* from
|
|
2078
|
+
// reading a stale record as already deployed) and a crash between this
|
|
2079
|
+
// run's own `AUTH_APPS` write above and its deploy -- a rerun recomputes
|
|
2080
|
+
// the identical `desiredAuthApps` value, sees no recorded deploy naming
|
|
2081
|
+
// that hash, and deploys again. `desiredAuthApps[0].value` (not a re-read)
|
|
2082
|
+
// is the exact string now live on the app either way: written just above
|
|
2083
|
+
// when `authAppsChanged`, already matching when it was not.
|
|
2084
|
+
const brokerMissingInputs = await missingBrokerInputs(deps.coolify, brokerAppId);
|
|
2085
|
+
const recordedBrokerDeploy = parseBrokerDeployRecord(recorded[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]);
|
|
2086
|
+
const desiredAuthAppsRaw = desiredAuthApps[0]?.value;
|
|
2087
|
+
if (brokerDeployNeeded(brokerMissingInputs, brokerAppId, desiredAuthAppsRaw, recordedBrokerDeploy)) {
|
|
2088
|
+
await deps.coolify.deploy(brokerAppId);
|
|
2089
|
+
await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, desiredAuthAppsRaw));
|
|
2090
|
+
}
|
|
1401
2091
|
// ---- Cloudflare Access, before the hostname resolves ----------------------
|
|
1402
2092
|
let gateClosed = Boolean(recorded[PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY]);
|
|
1403
2093
|
const accessAppId = await ensureArchonAccess(deps.cloudflare, hostname, deps.accessEmails, async () => {
|
|
@@ -1477,8 +2167,280 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
1477
2167
|
// halfway plans again.
|
|
1478
2168
|
await record(PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
|
|
1479
2169
|
await record(PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
2170
|
+
// #211 round 4: the broker this run actually registered Archon's entry
|
|
2171
|
+
// with, so `planArchon` can tell a broker recreated under a new app id
|
|
2172
|
+
// (whose AUTH_APPS never carried this registration) from one still holding
|
|
2173
|
+
// it.
|
|
2174
|
+
await record(PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, brokerAppId);
|
|
1480
2175
|
return { resourceId: appId };
|
|
1481
2176
|
}
|
|
2177
|
+
// #211 round 6: mirrors `wtfalch/auth/broker/src/apps.ts`'s own `isValidFrom`
|
|
2178
|
+
// (and the `isSafeText`/control-character checks it shares with
|
|
2179
|
+
// `parseCommonAppFields`) exactly, character class for character class.
|
|
2180
|
+
// That module is `@wtfalch/auth-broker`, `private: true`, and exports
|
|
2181
|
+
// neither function, so there is nothing to import -- this has to stay a
|
|
2182
|
+
// faithful copy. Round 5's version only checked that an address sat inside
|
|
2183
|
+
// `<...>`, not the display name in front of it, so `bad<name> <a@b.co>` and
|
|
2184
|
+
// a name carrying a right-to-left override both passed this step's gate and
|
|
2185
|
+
// were then silently dropped by the broker's own parser at boot (`AUTH_APPS
|
|
2186
|
+
// has no usable entries` if it was the only one). Kept in lockstep by hand;
|
|
2187
|
+
// if the broker's rule ever changes, this one has to change with it.
|
|
2188
|
+
const FROM_MAX_LENGTH = 320;
|
|
2189
|
+
const LOCAL_PART = '[A-Za-z0-9._%+-]+';
|
|
2190
|
+
const DOMAIN_LABEL = '[A-Za-z0-9-]+';
|
|
2191
|
+
const SENDER_ADDRESS_RE = new RegExp(`^${LOCAL_PART}@${DOMAIN_LABEL}(?:\\.${DOMAIN_LABEL})*$`);
|
|
2192
|
+
const SENDER_NAME_CHAR = "[\\p{L}\\p{M}\\d .,'&()!\\u2019\\u2013-]";
|
|
2193
|
+
const SENDER_NAME_RE = new RegExp(`^${SENDER_NAME_CHAR}+$`, 'u');
|
|
2194
|
+
const NAMED_FROM_RE = /^(.*)<([^<>]+)>$/;
|
|
2195
|
+
// Every control character (`\p{Cc}`), every format character (`\p{Cf}`, how
|
|
2196
|
+
// an RTL override hides a reordering attack), both Unicode line/paragraph
|
|
2197
|
+
// separators, and the two blanks-in-disguise `trim()` cannot be trusted to
|
|
2198
|
+
// strip first (`\u00a0` NBSP, `\ufeff` BOM).
|
|
2199
|
+
const OTHER_UNSAFE_RE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u00a0\ufeff]/u;
|
|
2200
|
+
// #218 round 2: `\p{Cf}` also matches U+200C (ZWNJ) and U+200D (ZWJ), which
|
|
2201
|
+
// a real name can carry -- ZWNJ joins letters into one word in
|
|
2202
|
+
// Persian/Arabic script, ZWJ joins separate emoji into one glyph. Rejecting
|
|
2203
|
+
// every `\p{Cf}` character missed that the broker's own `hasUnsafeText`
|
|
2204
|
+
// exempts both, but only immediately between two letters or two emoji: a
|
|
2205
|
+
// loose one at either end, or beside anything else, is exactly the kind of
|
|
2206
|
+
// invisible character this check exists to catch, same as an RTL override.
|
|
2207
|
+
// Hand-copied from `wtfalch/auth/broker/src/apps.ts`'s own `hasUnsafeText`,
|
|
2208
|
+
// character class for character class, for the same reason the rest of
|
|
2209
|
+
// this block is a copy: that module is `@wtfalch/auth-broker`, `private:
|
|
2210
|
+
// true`.
|
|
2211
|
+
const JOINER_CHARS = new Set(['\u200c', '\u200d']);
|
|
2212
|
+
const LETTER_OR_EMOJI_RE = /^[\p{L}\p{Extended_Pictographic}]$/u;
|
|
2213
|
+
// A real joined sequence is rarely letter-immediately-next-to-joiner on
|
|
2214
|
+
// both sides. Devanagari's explicit half-form writes a virama (a combining
|
|
2215
|
+
// mark, `\p{M}`) before the ZWJ; a multi-part emoji chains a ZWJ onto a
|
|
2216
|
+
// variation selector (U+FE0F) or a skin-tone modifier
|
|
2217
|
+
// (`\p{Emoji_Modifier}`) rather than straight onto the base character. All
|
|
2218
|
+
// of these sit *before* the joiner, never after it, so only the "previous
|
|
2219
|
+
// character" side is widened.
|
|
2220
|
+
const JOINER_PREV_RE = /^(?:[\p{L}\p{M}\p{Extended_Pictographic}]|\u{FE0F}|\p{Emoji_Modifier})$/u;
|
|
2221
|
+
function hasUnsafeText(value) {
|
|
2222
|
+
const chars = Array.from(value);
|
|
2223
|
+
for (let i = 0; i < chars.length; i++) {
|
|
2224
|
+
const ch = chars[i]; // i < chars.length, so this index always exists
|
|
2225
|
+
if (JOINER_CHARS.has(ch)) {
|
|
2226
|
+
const prev = chars[i - 1];
|
|
2227
|
+
const next = chars[i + 1];
|
|
2228
|
+
if (!prev || !next || !JOINER_PREV_RE.test(prev) || !LETTER_OR_EMOJI_RE.test(next)) {
|
|
2229
|
+
return true;
|
|
2230
|
+
}
|
|
2231
|
+
continue;
|
|
2232
|
+
}
|
|
2233
|
+
if (OTHER_UNSAFE_RE.test(ch))
|
|
2234
|
+
return true;
|
|
2235
|
+
}
|
|
2236
|
+
return false;
|
|
2237
|
+
}
|
|
2238
|
+
function isSafeText(value, max) {
|
|
2239
|
+
return value.trim().length > 0 && value.length <= max && !hasUnsafeText(value);
|
|
2240
|
+
}
|
|
2241
|
+
function isValidSenderDisplayName(value) {
|
|
2242
|
+
return value.trim().length > 0 && SENDER_NAME_RE.test(value);
|
|
2243
|
+
}
|
|
2244
|
+
/**
|
|
2245
|
+
* A broker app entry's `from` -- not an RFC 5322 parser, just enough to
|
|
2246
|
+
* catch what the broker itself would refuse or silently drop. Accepts the
|
|
2247
|
+
* two shapes this package itself ever writes (`provisionPlatformArchon`'s
|
|
2248
|
+
* own `archonEntry.from`, `` `Company Included <noreply@${platform.apex}>` ``)
|
|
2249
|
+
* or reads back from a hand-edited `AUTH_APPS`: `Display Name <email@domain>`
|
|
2250
|
+
* (quoted or not) and a bare `email@domain` address.
|
|
2251
|
+
*/
|
|
2252
|
+
function isValidSenderFrom(from) {
|
|
2253
|
+
if (from.length === 0 || from.length > FROM_MAX_LENGTH)
|
|
2254
|
+
return false;
|
|
2255
|
+
if (SENDER_ADDRESS_RE.test(from))
|
|
2256
|
+
return true;
|
|
2257
|
+
const named = NAMED_FROM_RE.exec(from);
|
|
2258
|
+
if (!named)
|
|
2259
|
+
return false;
|
|
2260
|
+
const rawName = named[1] ?? '';
|
|
2261
|
+
const address = named[2] ?? '';
|
|
2262
|
+
if (!SENDER_ADDRESS_RE.test(address))
|
|
2263
|
+
return false;
|
|
2264
|
+
if (!isSafeText(rawName, FROM_MAX_LENGTH))
|
|
2265
|
+
return false;
|
|
2266
|
+
const name = rawName.trim();
|
|
2267
|
+
if (name.startsWith('"') && name.endsWith('"') && name.length >= 2) {
|
|
2268
|
+
return isValidSenderDisplayName(name.slice(1, -1));
|
|
2269
|
+
}
|
|
2270
|
+
return isValidSenderDisplayName(name);
|
|
2271
|
+
}
|
|
2272
|
+
/** #211 round 5: the two locales `@wtfalch/mailer`'s `Locale` type restricts a broker app entry's optional `locale` to. */
|
|
2273
|
+
const VALID_BROKER_APP_LOCALES = new Set(['en', 'nb']);
|
|
2274
|
+
/** #211 round 6: matches the broker's own `CLIENT_ID_MAX_LENGTH`. */
|
|
2275
|
+
const BROKER_CLIENT_ID_MAX_LENGTH = 200;
|
|
2276
|
+
/**
|
|
2277
|
+
* #218: matches the broker's own `PRODUCT_NAME_MAX_LENGTH`
|
|
2278
|
+
* (`broker/src/apps.ts`), which it uses as the cap for both `name` and
|
|
2279
|
+
* `productName` -- `parseCommonAppFields`'s own `isSafeText(app.name,
|
|
2280
|
+
* PRODUCT_NAME_MAX_LENGTH)` and `isValidProductName`'s
|
|
2281
|
+
* `isSafeText(value, PRODUCT_NAME_MAX_LENGTH)`.
|
|
2282
|
+
*/
|
|
2283
|
+
const BROKER_NAME_MAX_LENGTH = 100;
|
|
2284
|
+
/**
|
|
2285
|
+
* #211 round 5: whether `value` is already in the canonical origin form
|
|
2286
|
+
* `wtfalch/auth/broker/src/apps.ts`'s own `parseApps` requires for an entry's
|
|
2287
|
+
* `origins` -- http(s), and `new URL(value).origin === value` exactly, so no
|
|
2288
|
+
* path, query, trailing slash or differently-cased host survives. `new URL`
|
|
2289
|
+
* throws on an arbitrary string, so that failure is itself "not a URL".
|
|
2290
|
+
*/
|
|
2291
|
+
function isCanonicalOrigin(value) {
|
|
2292
|
+
try {
|
|
2293
|
+
const parsed = new URL(value);
|
|
2294
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.origin === value;
|
|
2295
|
+
}
|
|
2296
|
+
catch {
|
|
2297
|
+
return false;
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
/**
|
|
2301
|
+
* One `AUTH_APPS[index]` entry, checked the way
|
|
2302
|
+
* `wtfalch/auth/broker/src/apps.ts`'s own `parseApps`/`parseAppFields`
|
|
2303
|
+
* (https://github.com/wtfalch/auth/blob/main/broker/src/apps.ts) check it at
|
|
2304
|
+
* boot, in the same order: a `key` of at least 32 characters and a non-empty
|
|
2305
|
+
* `organizationId`; a `name` that passes the broker's own `isSafeText` rule
|
|
2306
|
+
* (#218); a `productName`, when present, that is a string and also passes it
|
|
2307
|
+
* (#218); a `from` that passes {@link isValidSenderFrom}; a `locale` of
|
|
2308
|
+
* `'en'` or `'nb'` when present; non-blank `clientIds`; and `origins` each in
|
|
2309
|
+
* {@link isCanonicalOrigin} form. #211 round 2: on a bad
|
|
2310
|
+
* entry this throws naming the broker app id and the entry's index, never
|
|
2311
|
+
* the value -- `key` may hold another organisation's secret, and even
|
|
2312
|
+
* `organizationId`/`name` are not this step's to print in an error only an
|
|
2313
|
+
* operator reads. `entry` may be anything at all (`null`, a string, an
|
|
2314
|
+
* array): every check below is a `typeof`/`Array.isArray` guard first, so a
|
|
2315
|
+
* malformed entry never reaches a property read on a non-object and throws a
|
|
2316
|
+
* `TypeError` instead of this named error.
|
|
2317
|
+
*/
|
|
2318
|
+
function validateBrokerAppEntry(entry, brokerAppId, index) {
|
|
2319
|
+
const label = `platform:archon: broker app ${brokerAppId}'s AUTH_APPS[${index}]`;
|
|
2320
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
2321
|
+
throw new Error(`${label} is not an object`);
|
|
2322
|
+
}
|
|
2323
|
+
const app = entry;
|
|
2324
|
+
if (typeof app.organizationId !== 'string' || !app.organizationId) {
|
|
2325
|
+
throw new Error(`${label} has no organizationId`);
|
|
2326
|
+
}
|
|
2327
|
+
if (typeof app.key !== 'string' || !app.key) {
|
|
2328
|
+
throw new Error(`${label} has no key`);
|
|
2329
|
+
}
|
|
2330
|
+
if (app.key.length < 32) {
|
|
2331
|
+
throw new Error(`${label} has too short a key`);
|
|
2332
|
+
}
|
|
2333
|
+
if (typeof app.name !== 'string' || !app.name) {
|
|
2334
|
+
throw new Error(`${label} has no name`);
|
|
2335
|
+
}
|
|
2336
|
+
// #218: the broker's own `parseCommonAppFields` runs `name` through
|
|
2337
|
+
// `isSafeText(app.name, PRODUCT_NAME_MAX_LENGTH)` too (`name` stands in for
|
|
2338
|
+
// `productName` in the mail subject whenever an entry declares no
|
|
2339
|
+
// `productName` of its own, so it needs the same safety check) -- this
|
|
2340
|
+
// step's own truthy check above missed a NUL, an RTL override, a line
|
|
2341
|
+
// separator, or a name over 100 characters, all of which this gate reads
|
|
2342
|
+
// back and keeps, and all of which the broker then throws on at boot.
|
|
2343
|
+
if (!isSafeText(app.name, BROKER_NAME_MAX_LENGTH)) {
|
|
2344
|
+
throw new Error(`${label} has an invalid name`);
|
|
2345
|
+
}
|
|
2346
|
+
if (app.productName !== undefined) {
|
|
2347
|
+
// #218: `productName` is optional on the broker's own `App`, but when
|
|
2348
|
+
// present it runs through `isValidProductName`
|
|
2349
|
+
// (`isSafeText(value, PRODUCT_NAME_MAX_LENGTH)`) exactly like `name`
|
|
2350
|
+
// above -- a non-string, a NUL, or one over 100 characters all throw at
|
|
2351
|
+
// the broker's boot the same way.
|
|
2352
|
+
if (typeof app.productName !== 'string') {
|
|
2353
|
+
throw new Error(`${label} has a non-string productName`);
|
|
2354
|
+
}
|
|
2355
|
+
if (!isSafeText(app.productName, BROKER_NAME_MAX_LENGTH)) {
|
|
2356
|
+
throw new Error(`${label} has an invalid productName`);
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
if (typeof app.from !== 'string' || !app.from) {
|
|
2360
|
+
throw new Error(`${label} has no from`);
|
|
2361
|
+
}
|
|
2362
|
+
if (!isValidSenderFrom(app.from)) {
|
|
2363
|
+
throw new Error(`${label} has an invalid from`);
|
|
2364
|
+
}
|
|
2365
|
+
if (app.locale !== undefined && !VALID_BROKER_APP_LOCALES.has(app.locale)) {
|
|
2366
|
+
throw new Error(`${label} has an invalid locale`);
|
|
2367
|
+
}
|
|
2368
|
+
if (!Array.isArray(app.clientIds) || app.clientIds.length === 0) {
|
|
2369
|
+
throw new Error(`${label} has no clientIds`);
|
|
2370
|
+
}
|
|
2371
|
+
if (app.clientIds.some((id) => typeof id !== 'string' || id.trim() === '')) {
|
|
2372
|
+
throw new Error(`${label} has a blank clientIds entry`);
|
|
2373
|
+
}
|
|
2374
|
+
// #211 round 6: `clientIds:["a\u0000b"]` passed this gate before -- a
|
|
2375
|
+
// control character is not blank, so the check above missed it, and the
|
|
2376
|
+
// broker's own `parseCommonAppFields` throws on it at boot
|
|
2377
|
+
// (`isSafeText(candidate, CLIENT_ID_MAX_LENGTH)`).
|
|
2378
|
+
if (app.clientIds.some((id) => typeof id === 'string' && !isSafeText(id, BROKER_CLIENT_ID_MAX_LENGTH))) {
|
|
2379
|
+
throw new Error(`${label} has an invalid clientIds entry`);
|
|
2380
|
+
}
|
|
2381
|
+
if (!Array.isArray(app.origins) || app.origins.length === 0) {
|
|
2382
|
+
throw new Error(`${label} has no origins`);
|
|
2383
|
+
}
|
|
2384
|
+
if (app.origins.some((origin) => typeof origin !== 'string' || !isCanonicalOrigin(origin))) {
|
|
2385
|
+
throw new Error(`${label} has an invalid origins entry`);
|
|
2386
|
+
}
|
|
2387
|
+
return app;
|
|
2388
|
+
}
|
|
2389
|
+
/**
|
|
2390
|
+
* The broker's current `AUTH_APPS` (#204), parsed back so an entry can be
|
|
2391
|
+
* merged without losing whichever other organisations -- or other apps of
|
|
2392
|
+
* this same organisation -- have registered. Empty only for a broker that
|
|
2393
|
+
* has never had one set. #211: an *unparseable* value (bad JSON, or JSON
|
|
2394
|
+
* that is not an array) is never treated as empty -- the write that follows
|
|
2395
|
+
* replaces the whole env, so silently reading it as `[]` would drop every
|
|
2396
|
+
* other organisation's entry the moment this run cannot parse what is
|
|
2397
|
+
* there. Throws instead, naming the broker app id (the app holding the
|
|
2398
|
+
* unreadable value), never the value itself -- it may hold another
|
|
2399
|
+
* organisation's key. #211 round 2: each entry is validated the same way
|
|
2400
|
+
* the broker itself validates `AUTH_APPS` at boot ({@link validateBrokerAppEntry}),
|
|
2401
|
+
* so a bad entry is caught here, by this step, rather than only discovered
|
|
2402
|
+
* once the broker refuses to start on it.
|
|
2403
|
+
*/
|
|
2404
|
+
async function readBrokerApps(coolify, brokerAppId) {
|
|
2405
|
+
const envs = await coolify.listEnvs(brokerAppId);
|
|
2406
|
+
const raw = envs.find((e) => e.key === 'AUTH_APPS' && !e.is_preview)?.value;
|
|
2407
|
+
if (!raw)
|
|
2408
|
+
return [];
|
|
2409
|
+
let parsed;
|
|
2410
|
+
try {
|
|
2411
|
+
parsed = JSON.parse(raw);
|
|
2412
|
+
}
|
|
2413
|
+
catch {
|
|
2414
|
+
throw new Error(`platform:archon: broker app ${brokerAppId} has an AUTH_APPS that is not JSON`);
|
|
2415
|
+
}
|
|
2416
|
+
if (!Array.isArray(parsed)) {
|
|
2417
|
+
throw new Error(`platform:archon: broker app ${brokerAppId} has an AUTH_APPS that is not a JSON array`);
|
|
2418
|
+
}
|
|
2419
|
+
return parsed.map((entry, index) => validateBrokerAppEntry(entry, brokerAppId, index));
|
|
2420
|
+
}
|
|
2421
|
+
/**
|
|
2422
|
+
* #213: puts {@link PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF},
|
|
2423
|
+
* {@link PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF} and
|
|
2424
|
+
* {@link PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF} as held keys, under
|
|
2425
|
+
* tenant `platform`, so Archon's own onboarding can read them back once its
|
|
2426
|
+
* app is live. Each ref is resolved first; `put` runs only when that comes
|
|
2427
|
+
* back empty, so an entry already held (put by an earlier apply, or by hand)
|
|
2428
|
+
* is left alone and never overwritten. Values never reach a log, a record or
|
|
2429
|
+
* an error: `heldKeys.put`'s own contract, mirroring `deliverSecret`'s.
|
|
2430
|
+
*/
|
|
2431
|
+
async function putArchonMasterKeys(heldKeys, masterKeys) {
|
|
2432
|
+
const entries = [
|
|
2433
|
+
[PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, masterKeys.coolifyUrl],
|
|
2434
|
+
[PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, masterKeys.coolifyToken],
|
|
2435
|
+
[PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, masterKeys.cloudflareParentToken],
|
|
2436
|
+
];
|
|
2437
|
+
for (const [ref, value] of entries) {
|
|
2438
|
+
const existing = await heldKeys.resolve(ref);
|
|
2439
|
+
if (existing)
|
|
2440
|
+
continue;
|
|
2441
|
+
await heldKeys.put?.(ref, value);
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
1482
2444
|
/**
|
|
1483
2445
|
* `executor.ts`'s `cloudflareAccess`, for the platform's Archon: reuses only
|
|
1484
2446
|
* an Access app named {@link PLATFORM_ARCHON_ACCESS_APP_NAME}, refuses to
|
|
@@ -1558,6 +2520,33 @@ async function archonAccessDrifted(cf, domain, emails) {
|
|
|
1558
2520
|
return false;
|
|
1559
2521
|
}
|
|
1560
2522
|
}
|
|
2523
|
+
/**
|
|
2524
|
+
* #211 round 4: whether the broker Archon last registered with (its
|
|
2525
|
+
* `AUTH_APPS`) still carries an entry for Archon's organisation.
|
|
2526
|
+
* `planArchon`'s own recorded-id check
|
|
2527
|
+
* ({@link PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY} against
|
|
2528
|
+
* {@link PLATFORM_AUTH_BROKER_SCOPE_KEY}) catches the broker itself being
|
|
2529
|
+
* recreated under a new app id; this catches the same broker losing the
|
|
2530
|
+
* entry some other way -- a hand edit of `AUTH_APPS`, a previous write that
|
|
2531
|
+
* silently failed. A failed read is not drift, the same rule
|
|
2532
|
+
* {@link archonAccessDrifted} follows: the next plan reads again. Missing
|
|
2533
|
+
* `recordedIds` (no broker recorded yet, or Archon's own ZITADEL org not yet
|
|
2534
|
+
* recorded) is not drift either -- `planArchon`'s other checks already cover
|
|
2535
|
+
* a step that has never applied.
|
|
2536
|
+
*/
|
|
2537
|
+
async function archonBrokerRegistrationDrifted(coolify, recordedIds) {
|
|
2538
|
+
const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
|
|
2539
|
+
const orgId = recordedIds[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
|
|
2540
|
+
if (!brokerAppId || !orgId)
|
|
2541
|
+
return false;
|
|
2542
|
+
try {
|
|
2543
|
+
const apps = await readBrokerApps(coolify, brokerAppId);
|
|
2544
|
+
return !apps.some((a) => a.organizationId === orgId && a.name === PLATFORM_ARCHON_ZITADEL_APP);
|
|
2545
|
+
}
|
|
2546
|
+
catch {
|
|
2547
|
+
return false;
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
1561
2550
|
/**
|
|
1562
2551
|
* What Archon reads from each enabled service, gathered before anything is
|
|
1563
2552
|
* built. Either everything is there and the env values come back, or the first
|
|
@@ -1633,10 +2622,34 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
|
|
|
1633
2622
|
* `base64url` fits `db-runtime-role.sh`'s password alphabet
|
|
1634
2623
|
* (`A-Za-z0-9_.~-`).
|
|
1635
2624
|
*/
|
|
2625
|
+
/**
|
|
2626
|
+
* #265: throws when `liveUrl` points at another host than the adopted
|
|
2627
|
+
* database. Names both hosts and nothing else: a URL carries a credential, and
|
|
2628
|
+
* Node's parse error echoes its whole input.
|
|
2629
|
+
*/
|
|
2630
|
+
function assertSameDatabaseHost(key, liveUrl, dbInternalUrl) {
|
|
2631
|
+
const hostOf = (url) => {
|
|
2632
|
+
try {
|
|
2633
|
+
return new URL(url).host;
|
|
2634
|
+
}
|
|
2635
|
+
catch {
|
|
2636
|
+
return null;
|
|
2637
|
+
}
|
|
2638
|
+
};
|
|
2639
|
+
const liveHost = hostOf(liveUrl);
|
|
2640
|
+
const dbHost = hostOf(dbInternalUrl);
|
|
2641
|
+
if (liveHost !== dbHost) {
|
|
2642
|
+
throw new Error(`platform:archon: ${key} on the app points at host ${liveHost ?? '(unparseable)'}, but the adopted database ${PLATFORM_ARCHON_DB_NAME} is at host ${dbHost ?? '(unparseable)'}; refusing to leave the app's database URLs on different databases`);
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
1636
2645
|
async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
|
|
1637
2646
|
const envs = await coolify.listEnvs(appId);
|
|
1638
|
-
|
|
2647
|
+
const live = envs.find((e) => e.key === 'DATABASE_URL' && !e.is_preview);
|
|
2648
|
+
if (live) {
|
|
2649
|
+
// #265: kept as it is, but it must name the adopted database's host.
|
|
2650
|
+
assertSameDatabaseHost('DATABASE_URL', live.value, dbInternalUrl);
|
|
1639
2651
|
return null;
|
|
2652
|
+
}
|
|
1640
2653
|
let host;
|
|
1641
2654
|
let pathname;
|
|
1642
2655
|
let search;
|
|
@@ -1651,22 +2664,213 @@ async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
|
|
|
1651
2664
|
const password = randomBytes(24).toString('base64url');
|
|
1652
2665
|
return `postgres://${ARCHON_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
|
|
1653
2666
|
}
|
|
2667
|
+
/**
|
|
2668
|
+
* Thrown by {@link createAiManagementKeyMinter} (never by a test's own
|
|
2669
|
+
* injected minter, unless it chooses to) when ai's `POST /v1/platform/keys`
|
|
2670
|
+
* answers 200: a live `archon` key already exists and no secret comes back.
|
|
2671
|
+
* See {@link PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY}.
|
|
2672
|
+
*/
|
|
2673
|
+
export class AiManagementKeyExistsError extends Error {
|
|
2674
|
+
credentialId;
|
|
2675
|
+
organisationId;
|
|
2676
|
+
constructor(credentialId, organisationId) {
|
|
2677
|
+
super(`ai already holds a management key named 'archon' (${credentialId}) with no secret to show`);
|
|
2678
|
+
this.credentialId = credentialId;
|
|
2679
|
+
this.organisationId = organisationId;
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
/**
|
|
2683
|
+
* Thrown by {@link createAiManagementKeyMinter} on a 409: a live `archon` key
|
|
2684
|
+
* already exists with different permissions or expiry than this request
|
|
2685
|
+
* asked for. ai never overwrites it, so this is never retried under another
|
|
2686
|
+
* name -- an operator revokes the live key in ai first.
|
|
2687
|
+
*/
|
|
2688
|
+
export class AiManagementKeyConflictError extends Error {
|
|
2689
|
+
constructor() {
|
|
2690
|
+
super("ai already holds a live 'archon' key minted with different permissions or expiry; revoke it in ai, then apply again");
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
/**
|
|
2694
|
+
* Thrown by {@link createAiManagementKeyMinter} on a 401 or 403: ai does not
|
|
2695
|
+
* trust foundry's signing key yet for this request. Most likely the runner
|
|
2696
|
+
* has not redeployed since {@link AI_SIGNING_KEY_REF}'s public half was
|
|
2697
|
+
* pinned as `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` -- a Coolify env
|
|
2698
|
+
* change reaches the container only on its next deploy.
|
|
2699
|
+
*/
|
|
2700
|
+
export class AiManagementKeyUntrustedError extends Error {
|
|
2701
|
+
status;
|
|
2702
|
+
constructor(status) {
|
|
2703
|
+
super(`ai's platform/keys route refused foundry's signing key (${status}); check MANAGEMENT_PUBLIC_KEYS/MANAGEMENT_ISSUER on the ai runner and that it has redeployed since they were pinned`);
|
|
2704
|
+
this.status = status;
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
/**
|
|
2708
|
+
* Both response bodies {@link createAiManagementKeyMinter} reads are parsed
|
|
2709
|
+
* rather than cast: a malformed body (an empty 201, a 200 with no
|
|
2710
|
+
* `credentialId`) must fail the mint outright rather than record an
|
|
2711
|
+
* `undefined` id or hand `deliverSecret` an `undefined` "secret". Neither
|
|
2712
|
+
* error message repeats the body, which could hold the very secret this
|
|
2713
|
+
* function must not leak.
|
|
2714
|
+
*/
|
|
2715
|
+
function parseAiKeyExistsBody(body) {
|
|
2716
|
+
const record = body;
|
|
2717
|
+
if (typeof record !== 'object' ||
|
|
2718
|
+
record === null ||
|
|
2719
|
+
typeof record.credentialId !== 'string' ||
|
|
2720
|
+
typeof record.organisationId !== 'string') {
|
|
2721
|
+
throw new Error("ai's platform/keys route answered 200 with a body missing credentialId/organisationId");
|
|
2722
|
+
}
|
|
2723
|
+
return { credentialId: record.credentialId, organisationId: record.organisationId };
|
|
2724
|
+
}
|
|
2725
|
+
function parseAiKeyMintedBody(body) {
|
|
2726
|
+
const record = body;
|
|
2727
|
+
if (typeof record !== 'object' ||
|
|
2728
|
+
record === null ||
|
|
2729
|
+
typeof record.credentialId !== 'string' ||
|
|
2730
|
+
typeof record.organisationId !== 'string' ||
|
|
2731
|
+
typeof record.secret !== 'string') {
|
|
2732
|
+
throw new Error("ai's platform/keys route answered 201 with a body missing credentialId/organisationId/secret");
|
|
2733
|
+
}
|
|
2734
|
+
return {
|
|
2735
|
+
credentialId: record.credentialId,
|
|
2736
|
+
organisationId: record.organisationId,
|
|
2737
|
+
secret: record.secret,
|
|
2738
|
+
};
|
|
2739
|
+
}
|
|
1654
2740
|
/** The ref the AI management key is delivered under; the host's `AiOrganisationPort` adapter reads it back. */
|
|
1655
2741
|
export const PLATFORM_AI_MANAGEMENT_KEY_REF = 'ai:platform:managementKey';
|
|
1656
2742
|
/**
|
|
1657
|
-
* What
|
|
1658
|
-
* (
|
|
1659
|
-
*
|
|
2743
|
+
* What Archon's management key may do in ai: exactly `ai`'s own
|
|
2744
|
+
* `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
|
|
2745
|
+
* configuration only (offerings, provisioning, offers, budgets, usage, cost)
|
|
2746
|
+
* plus deleting (archiving) an organisation, never connections, runs or
|
|
2747
|
+
* webhooks. ai's `/v1/platform/keys` route refuses any permission outside
|
|
2748
|
+
* this same list, so a request for more than this is a 403, not a wider key.
|
|
1660
2749
|
*/
|
|
1661
2750
|
export const PLATFORM_AI_MANAGEMENT_PERMISSIONS = [
|
|
2751
|
+
'ai.offerings:read',
|
|
2752
|
+
'ai.offerings:create',
|
|
2753
|
+
'ai.offerings:update',
|
|
2754
|
+
'ai.offerings:disable',
|
|
1662
2755
|
'ai.organisations:provision',
|
|
1663
2756
|
'ai.organisations:offer',
|
|
1664
2757
|
'ai.organisations:delete',
|
|
1665
2758
|
'ai.keys:issue',
|
|
2759
|
+
'ai.budgets:read',
|
|
1666
2760
|
'ai.budgets:update',
|
|
2761
|
+
'ai.usage:read',
|
|
2762
|
+
'ai.cost:read',
|
|
1667
2763
|
];
|
|
1668
|
-
/** The
|
|
1669
|
-
export const
|
|
2764
|
+
/** The ref the AI signing key's private half (PKCS#8 PEM) is delivered under -- the {@link INTEGRATIONS_MANAGEMENT_KEY_REF} pattern, for ai instead of integrations. */
|
|
2765
|
+
export const AI_SIGNING_KEY_REF = 'ai:platform:signingPrivateKey';
|
|
2766
|
+
/** `MANAGEMENT_ISSUER` on the ai runner: whoever signs with the delivered key signs as this issuer. */
|
|
2767
|
+
export const AI_MANAGEMENT_ISSUER = 'foundry';
|
|
2768
|
+
/** The key id the public half is pinned under in `MANAGEMENT_PUBLIC_KEYS` (ai's keyring allows `^[A-Za-z0-9_-]{1,100}$`). Fixed, since there is one key until rotation is built. */
|
|
2769
|
+
export const AI_MANAGEMENT_KEY_ID = 'foundry-1';
|
|
2770
|
+
/**
|
|
2771
|
+
* ai's fixed application/platform identifier
|
|
2772
|
+
* (`apps/host/src/lib/authz/policy-catalogue.ts`'s `PLATFORM_ID`), bound
|
|
2773
|
+
* into every signed assertion's `platform` claim. foundry does not depend on
|
|
2774
|
+
* that app, so the value is mirrored here rather than imported; a mismatch
|
|
2775
|
+
* makes ai's `verifyManagementRequest` refuse every request.
|
|
2776
|
+
*/
|
|
2777
|
+
const AI_MANAGEMENT_PLATFORM_ID = 'wtfalch';
|
|
2778
|
+
/** The header ai's `service-assertion.ts` (`MANAGEMENT_HEADER`) reads the signed assertion from. */
|
|
2779
|
+
const AI_MANAGEMENT_HEADER = 'x-wtfalch-management';
|
|
2780
|
+
/** `service-assertion.ts`'s own signed-message prefix; the signature covers exactly `PREFIX + message`. */
|
|
2781
|
+
const AI_MANAGEMENT_ASSERTION_PREFIX = 'wtfalch.service-request.v1\n';
|
|
2782
|
+
/** `service-assertion.ts`'s own token TTL, in seconds. */
|
|
2783
|
+
const AI_MANAGEMENT_ASSERTION_TTL_SECONDS = 30;
|
|
2784
|
+
/**
|
|
2785
|
+
* ai's signed-assertion `organisation` claim is required and validated as a
|
|
2786
|
+
* uuid, but unused for tenant selection on the platform/keys route
|
|
2787
|
+
* (`platform-keys-endpoint.ts`'s own comment: "callers may sign it as the
|
|
2788
|
+
* nil uuid"). There is no organisation to name before one exists.
|
|
2789
|
+
*/
|
|
2790
|
+
const AI_MANAGEMENT_NIL_ORGANISATION = '00000000-0000-0000-0000-000000000000';
|
|
2791
|
+
/** ai's platform/keys route, relative to its internal address. */
|
|
2792
|
+
const AI_PLATFORM_KEYS_PATH = '/v1/platform/keys';
|
|
2793
|
+
/**
|
|
2794
|
+
* Signs one request to ai's `POST /v1/platform/keys` the way ai's own
|
|
2795
|
+
* `service-assertion.ts` (`signManagementRequest`) does. foundry does not
|
|
2796
|
+
* depend on that app's code, so the wire format -- header, prefix, claim
|
|
2797
|
+
* shape, digest -- is reproduced here exactly, rather than imported, so
|
|
2798
|
+
* ai's `verifyManagementRequest` accepts it unmodified.
|
|
2799
|
+
*/
|
|
2800
|
+
function signAiManagementRequest(input) {
|
|
2801
|
+
const digest = createHash('sha256').update(input.body).digest('hex');
|
|
2802
|
+
const issued = Math.floor(input.now() / 1000);
|
|
2803
|
+
const claims = {
|
|
2804
|
+
version: 1,
|
|
2805
|
+
issuer: input.issuer,
|
|
2806
|
+
audience: input.audience,
|
|
2807
|
+
platform: AI_MANAGEMENT_PLATFORM_ID,
|
|
2808
|
+
subject: input.issuer,
|
|
2809
|
+
organisation: AI_MANAGEMENT_NIL_ORGANISATION,
|
|
2810
|
+
method: 'POST',
|
|
2811
|
+
target: input.target,
|
|
2812
|
+
contentType: input.contentType,
|
|
2813
|
+
digest,
|
|
2814
|
+
issued,
|
|
2815
|
+
expires: issued + AI_MANAGEMENT_ASSERTION_TTL_SECONDS,
|
|
2816
|
+
nonce: randomUUID(),
|
|
2817
|
+
};
|
|
2818
|
+
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
|
2819
|
+
const message = `${input.keyId}.${payload}`;
|
|
2820
|
+
const signature = sign(null, Buffer.from(AI_MANAGEMENT_ASSERTION_PREFIX + message), input.privateKey).toString('base64url');
|
|
2821
|
+
return `${message}.${signature}`;
|
|
2822
|
+
}
|
|
2823
|
+
/**
|
|
2824
|
+
* The real {@link AiManagementKeyMinter}: signs a management assertion with
|
|
2825
|
+
* `privateKeyPem` (the PKCS#8 PEM {@link provisionPlatformAi} delivers under
|
|
2826
|
+
* {@link AI_SIGNING_KEY_REF} and pins on the runner under `keyId`) and calls
|
|
2827
|
+
* ai's `POST /v1/platform/keys`. `audience` must be the exact origin ai's own
|
|
2828
|
+
* `APP_URL` resolves to (`https://<platform.ai.hostname>`), since ai checks
|
|
2829
|
+
* the claim against that env var, not against the internal address the
|
|
2830
|
+
* request itself is sent to.
|
|
2831
|
+
*/
|
|
2832
|
+
export function createAiManagementKeyMinter(options) {
|
|
2833
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
2834
|
+
const now = options.now ?? Date.now;
|
|
2835
|
+
const privateKey = createPrivateKey(options.privateKeyPem);
|
|
2836
|
+
return {
|
|
2837
|
+
async mintManagementKey({ internalUrl, name, expiresDays, permissions }) {
|
|
2838
|
+
const contentType = 'application/json';
|
|
2839
|
+
const body = JSON.stringify({ name, expiresDays, permissions });
|
|
2840
|
+
const token = signAiManagementRequest({
|
|
2841
|
+
target: AI_PLATFORM_KEYS_PATH,
|
|
2842
|
+
contentType,
|
|
2843
|
+
body,
|
|
2844
|
+
issuer: options.issuer,
|
|
2845
|
+
audience: options.audience,
|
|
2846
|
+
keyId: options.keyId,
|
|
2847
|
+
privateKey,
|
|
2848
|
+
now,
|
|
2849
|
+
});
|
|
2850
|
+
const response = await fetchImpl(`${internalUrl}${AI_PLATFORM_KEYS_PATH}`, {
|
|
2851
|
+
method: 'POST',
|
|
2852
|
+
headers: { 'content-type': contentType, [AI_MANAGEMENT_HEADER]: token },
|
|
2853
|
+
body,
|
|
2854
|
+
// A hung ai must not hold the apply open.
|
|
2855
|
+
signal: AbortSignal.timeout(30_000),
|
|
2856
|
+
});
|
|
2857
|
+
if (response.status === 200) {
|
|
2858
|
+
const existing = parseAiKeyExistsBody(await response.json());
|
|
2859
|
+
throw new AiManagementKeyExistsError(existing.credentialId, existing.organisationId);
|
|
2860
|
+
}
|
|
2861
|
+
if (response.status === 409) {
|
|
2862
|
+
throw new AiManagementKeyConflictError();
|
|
2863
|
+
}
|
|
2864
|
+
if (response.status === 401 || response.status === 403) {
|
|
2865
|
+
throw new AiManagementKeyUntrustedError(response.status);
|
|
2866
|
+
}
|
|
2867
|
+
if (response.status !== 201) {
|
|
2868
|
+
throw new Error(`ai's platform/keys route answered ${response.status}`);
|
|
2869
|
+
}
|
|
2870
|
+
return parseAiKeyMintedBody(await response.json());
|
|
2871
|
+
},
|
|
2872
|
+
};
|
|
2873
|
+
}
|
|
1670
2874
|
const PLATFORM_AI_PROJECT = 'platform';
|
|
1671
2875
|
const PLATFORM_AI_REPO = 'wtfalch/ai';
|
|
1672
2876
|
const PLATFORM_AI_DB_NAME = 'ai-db';
|
|
@@ -1761,22 +2965,22 @@ const PLATFORM_AI_WORKER_DEFAULTS = [
|
|
|
1761
2965
|
* put in an error. The step's resource id is returned only after that, so
|
|
1762
2966
|
* the planner keeps planning the step until the key is delivered.
|
|
1763
2967
|
*
|
|
2968
|
+
* #201 built the real minter: ai's `POST /v1/platform/keys`
|
|
2969
|
+
* (github.com/wtfalch/ai/issues/141) mints the `archon` key over HTTP, signed
|
|
2970
|
+
* with an Ed25519 pair this function mints and pins on the runner once
|
|
2971
|
+
* ({@link ensureAiSigningKey}), the same two-key discipline as the
|
|
2972
|
+
* management key itself. A test may still inject a different
|
|
2973
|
+
* {@link AiManagementKeyMinter} in {@link PlatformAiDeps.managementKeyMinter},
|
|
2974
|
+
* which skips the signing-key machinery entirely.
|
|
2975
|
+
*
|
|
1764
2976
|
* Known gaps, left for review (#172 stays open for review):
|
|
1765
|
-
* - No minter ships, so the step is blocked in the planner (nothing is
|
|
1766
|
-
* deployed) until a host supplies an {@link AiManagementKeyMinter}. It
|
|
1767
|
-
* cannot be built over ai's admin API: ai issues its first platform-tenant
|
|
1768
|
-
* credential only from `apps/host/scripts/authz/bootstrap-archon.ts`
|
|
1769
|
-
* (`keys:issue-archon`), a script that writes to the database directly and
|
|
1770
|
-
* needs `AUTHZ_BOOTSTRAP_OPERATOR` and the `KEYS_*` settings. Its HTTP
|
|
1771
|
-
* routes cannot bootstrap one: `POST /v1/organisations/{id}/keys` needs an
|
|
1772
|
-
* existing `ai.keys:issue` key and refuses the platform tenant, and a
|
|
1773
|
-
* signed management proof needs a human who already holds authority there.
|
|
1774
|
-
* `CoolifyPort` has no call that runs a command in a container.
|
|
1775
2977
|
* - Crash window: the credential id is recorded only after the minter
|
|
1776
2978
|
* returns, so a crash between the mint and that record loses a key nobody
|
|
1777
|
-
* knows the id of. ai
|
|
1778
|
-
*
|
|
1779
|
-
*
|
|
2979
|
+
* knows the id of. ai is idempotent by name, though, so the next apply's
|
|
2980
|
+
* mint call reaches the same live `archon` key rather than a second one --
|
|
2981
|
+
* it just cannot get the secret back, since ai shows it once. That is
|
|
2982
|
+
* reported as {@link PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY}, never
|
|
2983
|
+
* minted under another name.
|
|
1780
2984
|
* - Coolify's `dockerfile_target_build` and `is_auto_deploy_enabled` are set
|
|
1781
2985
|
* through the generic application PATCH; neither has been checked against
|
|
1782
2986
|
* a live instance from this package.
|
|
@@ -1890,12 +3094,19 @@ async function provisionPlatformAi(context, deps) {
|
|
|
1890
3094
|
// deployed with. Marked and persisted before the first write, and cleared
|
|
1891
3095
|
// only after the deploys below, so a run that fails halfway (the migrate
|
|
1892
3096
|
// wait throwing, for one) leaves the mark and the next run deploys again.
|
|
3097
|
+
// #201: pin foundry's signing key before the deploy decision below, so the
|
|
3098
|
+
// runner starts with MANAGEMENT_ISSUER/MANAGEMENT_PUBLIC_KEYS: Coolify env
|
|
3099
|
+
// changes reach a container only on a redeploy. Only the real minter needs
|
|
3100
|
+
// it; an injected one answers for its own authentication.
|
|
3101
|
+
const signingKeyPem = !recorded[PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY] && !deps.managementKeyMinter
|
|
3102
|
+
? await ensureAiSigningKey(context, deps, runner.appId, runtime, sleep, record)
|
|
3103
|
+
: undefined;
|
|
1893
3104
|
const migrateChanged = await envsDiffer(coolify, migrate.appId, migrateEnvs);
|
|
1894
3105
|
const runnerChanged = await envsDiffer(coolify, runner.appId, runnerEnvs);
|
|
1895
3106
|
const workerChanged = await envsDiffer(coolify, worker.appId, workerEnvs);
|
|
1896
3107
|
const declaredChanged = recorded[PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname ||
|
|
1897
3108
|
recorded[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] !== gitRef;
|
|
1898
|
-
const pending = Boolean(
|
|
3109
|
+
const pending = Boolean(context.recordedIds()[PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY]) ||
|
|
1899
3110
|
migrateChanged ||
|
|
1900
3111
|
runnerChanged ||
|
|
1901
3112
|
workerChanged ||
|
|
@@ -1945,28 +3156,50 @@ async function provisionPlatformAi(context, deps) {
|
|
|
1945
3156
|
await record(PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, gitRef);
|
|
1946
3157
|
const internalUrl = `http://${PLATFORM_AI_RUNNER.internalName}:${PLATFORM_AI_PORT}`;
|
|
1947
3158
|
await record(PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, internalUrl);
|
|
1948
|
-
// ----
|
|
3159
|
+
// ---- Archon's AI management key, once ------------------------------------
|
|
1949
3160
|
const now = context.recordedIds();
|
|
1950
3161
|
if (!now[PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY]) {
|
|
1951
3162
|
const lost = now[PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY];
|
|
1952
3163
|
if (lost) {
|
|
1953
3164
|
throw new Error(`platform:ai: AI management key ${lost} was minted but never delivered; revoke it and remove the recorded key ${PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
1954
3165
|
}
|
|
1955
|
-
|
|
1956
|
-
//
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
3166
|
+
// #201: a test injects its own minter; without one, build the real one
|
|
3167
|
+
// over ai's own POST /v1/platform/keys, signed with the key pinned above.
|
|
3168
|
+
const minter = deps.managementKeyMinter ??
|
|
3169
|
+
createAiManagementKeyMinter({
|
|
3170
|
+
privateKeyPem: signingKeyPem,
|
|
3171
|
+
keyId: AI_MANAGEMENT_KEY_ID,
|
|
3172
|
+
issuer: AI_MANAGEMENT_ISSUER,
|
|
3173
|
+
audience: `https://${hostname}`,
|
|
3174
|
+
fetchImpl: deps.fetchImpl,
|
|
3175
|
+
});
|
|
1960
3176
|
let minted;
|
|
1961
3177
|
try {
|
|
1962
3178
|
minted = await minter.mintManagementKey({
|
|
1963
3179
|
internalUrl,
|
|
1964
|
-
name: '
|
|
3180
|
+
name: 'archon',
|
|
1965
3181
|
expiresDays: 90,
|
|
1966
3182
|
permissions: PLATFORM_AI_MANAGEMENT_PERMISSIONS,
|
|
1967
3183
|
});
|
|
1968
3184
|
}
|
|
1969
|
-
catch {
|
|
3185
|
+
catch (error) {
|
|
3186
|
+
if (error instanceof AiManagementKeyExistsError) {
|
|
3187
|
+
// ai already holds an 'archon' key this record never learned about
|
|
3188
|
+
// (most likely the crash window below's own comment describes,
|
|
3189
|
+
// happening one mint earlier): its secret cannot be shown again, so
|
|
3190
|
+
// this is reported and never retried under another name.
|
|
3191
|
+
await record(PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, error.credentialId);
|
|
3192
|
+
throw new Error(`platform:ai: ai already holds a management key named 'archon' (${error.credentialId}) that foundry never recorded delivering; its secret cannot be shown again. Revoke it in ai and remove the recorded key ${PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY} to mint a new one`);
|
|
3193
|
+
}
|
|
3194
|
+
// Both messages below are fixed strings with no interpolated body, so
|
|
3195
|
+
// safe to surface directly -- unlike the generic case, neither can
|
|
3196
|
+
// ever echo a credential the minter read off the wire.
|
|
3197
|
+
if (error instanceof AiManagementKeyConflictError) {
|
|
3198
|
+
throw new Error(`platform:ai: ${error.message}`);
|
|
3199
|
+
}
|
|
3200
|
+
if (error instanceof AiManagementKeyUntrustedError) {
|
|
3201
|
+
throw new Error(`platform:ai: ${error.message}`);
|
|
3202
|
+
}
|
|
1970
3203
|
// The cause is dropped in case it echoes a credential.
|
|
1971
3204
|
throw new Error('platform:ai: the AI management key could not be minted (the minter failed; its error is not shown). Nothing was recorded; apply again');
|
|
1972
3205
|
}
|
|
@@ -1991,6 +3224,64 @@ async function provisionPlatformAi(context, deps) {
|
|
|
1991
3224
|
}
|
|
1992
3225
|
return { resourceId: runner.appId };
|
|
1993
3226
|
}
|
|
3227
|
+
/**
|
|
3228
|
+
* #201: ensures foundry's Ed25519 signing pair for ai's `POST
|
|
3229
|
+
* /v1/platform/keys` exists, and returns the private half (PKCS#8 PEM)
|
|
3230
|
+
* needed to sign the mint call that follows -- freshly minted this run, or
|
|
3231
|
+
* read back via `deps.heldKeys` when an earlier run already delivered and
|
|
3232
|
+
* pinned it but crashed before the management key itself was minted and
|
|
3233
|
+
* delivered. Follows `platform:integrations`'s own two-key discipline
|
|
3234
|
+
* exactly ({@link INTEGRATIONS_MANAGEMENT_KEY_REF}/`_ISSUER`/`_KEY_ID`): the
|
|
3235
|
+
* id is recorded and persisted before the private half is delivered, the
|
|
3236
|
+
* guard only once delivery and the pin on the runner both succeeded, so a
|
|
3237
|
+
* crash in between never mints a second pair.
|
|
3238
|
+
*/
|
|
3239
|
+
async function ensureAiSigningKey(context, deps, runnerAppId, runtime, sleep, record) {
|
|
3240
|
+
if (context.recordedIds()[PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY]) {
|
|
3241
|
+
if (!deps.heldKeys) {
|
|
3242
|
+
throw new Error('platform:ai: the AI management key cannot be minted: its signing key is already pinned but the host built the platform executor without a held-key reader to read back its private half (PlatformAiDeps.heldKeys)');
|
|
3243
|
+
}
|
|
3244
|
+
const held = await deps.heldKeys.resolve(AI_SIGNING_KEY_REF);
|
|
3245
|
+
if (!held) {
|
|
3246
|
+
throw new Error(`platform:ai: the AI management key cannot be minted: the signing key is pinned on the runner but its private half could not be read back (held key ${AI_SIGNING_KEY_REF})`);
|
|
3247
|
+
}
|
|
3248
|
+
return held;
|
|
3249
|
+
}
|
|
3250
|
+
if (context.recordedIds()[PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY]) {
|
|
3251
|
+
throw new Error(`platform:ai: signing key ${AI_MANAGEMENT_KEY_ID} was minted but not confirmed delivered and pinned; not minting another. Check the held key and the runner's MANAGEMENT_PUBLIC_KEYS, then remove the recorded key ${PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
3252
|
+
}
|
|
3253
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
|
|
3254
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
3255
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
3256
|
+
});
|
|
3257
|
+
await record(PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, AI_MANAGEMENT_KEY_ID);
|
|
3258
|
+
const attempts = runtime.deliverAttempts ?? 3;
|
|
3259
|
+
for (let attempt = 1;; attempt += 1) {
|
|
3260
|
+
try {
|
|
3261
|
+
await deps.deliverSecret(AI_SIGNING_KEY_REF, privateKey);
|
|
3262
|
+
break;
|
|
3263
|
+
}
|
|
3264
|
+
catch {
|
|
3265
|
+
// The cause is dropped in case it echoes the key.
|
|
3266
|
+
if (attempt >= attempts) {
|
|
3267
|
+
throw new Error(`platform:ai: signing key ${AI_MANAGEMENT_KEY_ID} was minted but could not be delivered after ${attempts} attempts`);
|
|
3268
|
+
}
|
|
3269
|
+
await sleep(1000 * attempt);
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
3272
|
+
// The runner reads these only after a redeploy: mark it pending first, so
|
|
3273
|
+
// a crash between pinning and deploying still redeploys on the next apply.
|
|
3274
|
+
await record(PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, '1');
|
|
3275
|
+
await deps.coolify.bulkUpsertEnvs(runnerAppId, [
|
|
3276
|
+
{ key: 'MANAGEMENT_ISSUER', value: AI_MANAGEMENT_ISSUER },
|
|
3277
|
+
{
|
|
3278
|
+
key: 'MANAGEMENT_PUBLIC_KEYS',
|
|
3279
|
+
value: JSON.stringify({ [AI_MANAGEMENT_KEY_ID]: publicKey }),
|
|
3280
|
+
},
|
|
3281
|
+
]);
|
|
3282
|
+
await record(PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, runnerAppId);
|
|
3283
|
+
return privateKey;
|
|
3284
|
+
}
|
|
1994
3285
|
/** The migrate job did not turn healthy in time; carries its last status, which holds no secret. */
|
|
1995
3286
|
class MigrationNotHealthyError extends Error {
|
|
1996
3287
|
lastStatus;
|
|
@@ -2126,6 +3417,123 @@ async function findOrCreatePlatformApp(coolify, name, environment, create) {
|
|
|
2126
3417
|
const created = await create();
|
|
2127
3418
|
return { appId: created.uuid, justCreated: true, running: false };
|
|
2128
3419
|
}
|
|
3420
|
+
/**
|
|
3421
|
+
* #211: names of the envs the platform's sign-in broker (`wtfalch/auth`'s
|
|
3422
|
+
* `broker/server.ts`) refuses to start without, that `provisionPlatformAuth`
|
|
3423
|
+
* itself has no way to supply yet -- `ZITADEL_SERVICE_USER_TOKEN`/`_FILE`
|
|
3424
|
+
* (#209 mints and pushes this once it lands) and `RESEND_API_TOKEN` (#210,
|
|
3425
|
+
* blocked on `wtfalch/auth#138`). Read from the broker's own live env, not a
|
|
3426
|
+
* recorded scope id: whichever of #209/#210 lands first (or a human, setting
|
|
3427
|
+
* either directly on the app) is enough to clear that one input, in either
|
|
3428
|
+
* order, with no coupling to this step's own execution.
|
|
3429
|
+
*
|
|
3430
|
+
* #211 round 3: `AUTH_APPS` is a third required input -- the broker's own
|
|
3431
|
+
* `parseApps` refuses to start with no `AUTH_APPS` set or an empty array
|
|
3432
|
+
* (`wtfalch/auth/broker/src/apps.ts`), and this check used to name only the
|
|
3433
|
+
* other two, so a deploy could go ahead with a broker that still would not
|
|
3434
|
+
* come up. Also treat `null`/`undefined` as missing, not only `''`: the
|
|
3435
|
+
* `CoolifyPort.listEnvs` doc comment already warns a live value does not
|
|
3436
|
+
* always match its declared `string` type.
|
|
3437
|
+
*/
|
|
3438
|
+
async function missingBrokerInputs(coolify, brokerAppId) {
|
|
3439
|
+
const envs = await coolify.listEnvs(brokerAppId);
|
|
3440
|
+
const value = (key) => envs.find((e) => e.key === key && !e.is_preview && e.value != null && e.value !== '')?.value;
|
|
3441
|
+
const has = (key) => value(key) !== undefined;
|
|
3442
|
+
const missing = [];
|
|
3443
|
+
if (!has('ZITADEL_SERVICE_USER_TOKEN') && !has('ZITADEL_SERVICE_USER_TOKEN_FILE')) {
|
|
3444
|
+
missing.push('ZITADEL_SERVICE_USER_TOKEN');
|
|
3445
|
+
}
|
|
3446
|
+
if (!has('RESEND_API_TOKEN'))
|
|
3447
|
+
missing.push('RESEND_API_TOKEN');
|
|
3448
|
+
if (authAppsProblem(value('AUTH_APPS'), brokerAppId) !== null)
|
|
3449
|
+
missing.push('AUTH_APPS');
|
|
3450
|
+
return missing;
|
|
3451
|
+
}
|
|
3452
|
+
/**
|
|
3453
|
+
* #211 round 5 addendum: why a live `AUTH_APPS` value would not pass the
|
|
3454
|
+
* broker's own `parseApps` at boot, distinguishing `'missing'` (nothing set,
|
|
3455
|
+
* or an empty array -- nobody has registered anything yet) from
|
|
3456
|
+
* `'malformed'` (something is set and it is wrong: bad JSON, not an array, or
|
|
3457
|
+
* an entry {@link validateBrokerAppEntry} itself refuses). `hasNonEmptyAuthApps`
|
|
3458
|
+
* (#211 round 4) collapsed both into one `false`, so `missingBrokerInputs`
|
|
3459
|
+
* (and, through it, `authBrokerBlockedReason`) always reported "it has no
|
|
3460
|
+
* ... AUTH_APPS yet" even when `AUTH_APPS` held garbage a person needs to go
|
|
3461
|
+
* fix, rather than an input nobody has supplied yet -- `authBrokerBlockedReason`
|
|
3462
|
+
* below is what turns this distinction into different wording. `null` when
|
|
3463
|
+
* the value is valid. A bad entry's own error is discarded here too: this
|
|
3464
|
+
* function only classifies the problem, and the named error still surfaces
|
|
3465
|
+
* wherever `readBrokerApps` is actually called.
|
|
3466
|
+
*/
|
|
3467
|
+
function authAppsProblem(raw, brokerAppId) {
|
|
3468
|
+
if (raw === undefined || raw === '')
|
|
3469
|
+
return 'missing';
|
|
3470
|
+
let parsed;
|
|
3471
|
+
try {
|
|
3472
|
+
parsed = JSON.parse(raw);
|
|
3473
|
+
}
|
|
3474
|
+
catch {
|
|
3475
|
+
return 'malformed';
|
|
3476
|
+
}
|
|
3477
|
+
if (!Array.isArray(parsed))
|
|
3478
|
+
return 'malformed';
|
|
3479
|
+
if (parsed.length === 0)
|
|
3480
|
+
return 'missing';
|
|
3481
|
+
try {
|
|
3482
|
+
parsed.forEach((entry, index) => validateBrokerAppEntry(entry, brokerAppId, index));
|
|
3483
|
+
}
|
|
3484
|
+
catch {
|
|
3485
|
+
return 'malformed';
|
|
3486
|
+
}
|
|
3487
|
+
return null;
|
|
3488
|
+
}
|
|
3489
|
+
/**
|
|
3490
|
+
* #211 round 5: sha256 of the exact `AUTH_APPS` string a {@link BrokerDeployRecord}
|
|
3491
|
+
* names -- never the value itself, only its hash, so a recorded scope id can
|
|
3492
|
+
* never leak another organisation's broker key. `undefined` (no `AUTH_APPS`
|
|
3493
|
+
* set) hashes the same as `''`, both meaning "nothing deployed yet".
|
|
3494
|
+
*/
|
|
3495
|
+
function hashAuthApps(raw) {
|
|
3496
|
+
return createHash('sha256')
|
|
3497
|
+
.update(raw ?? '')
|
|
3498
|
+
.digest('hex');
|
|
3499
|
+
}
|
|
3500
|
+
/**
|
|
3501
|
+
* #211 round 5: the one rule `provisionPlatformAuth` and
|
|
3502
|
+
* `provisionPlatformArchon` both call to decide whether the broker needs a
|
|
3503
|
+
* (re)deploy -- replacing the two ad hoc "was it ever ostensibly deployed"
|
|
3504
|
+
* booleans each function used to keep, which is what let a recreated broker,
|
|
3505
|
+
* and a crash between writing `AUTH_APPS` and deploying, both go unnoticed
|
|
3506
|
+
* (the round 5 issue text's own diagnosis: "they share one cause"). `false`
|
|
3507
|
+
* while any required input is still missing -- deploying a broker that
|
|
3508
|
+
* cannot start is pointless regardless of what changed. Otherwise `true`
|
|
3509
|
+
* when nothing has ever been recorded, when the broker that was deployed is
|
|
3510
|
+
* a different app (recreated under a new id), or when the `AUTH_APPS` that
|
|
3511
|
+
* was deployed is not the `AUTH_APPS` that is live/about-to-be-live now.
|
|
3512
|
+
* `authAppsRaw` is that exact string -- read live by `provisionPlatformAuth`,
|
|
3513
|
+
* which never writes `AUTH_APPS` itself, or supplied by
|
|
3514
|
+
* `provisionPlatformArchon` as the string it already knows it just wrote (or
|
|
3515
|
+
* left unchanged), so neither caller needs a second read to agree with
|
|
3516
|
+
* itself about what is now true of the app.
|
|
3517
|
+
*/
|
|
3518
|
+
function brokerDeployNeeded(missingInputs, brokerAppId, authAppsRaw, recordedDeploy) {
|
|
3519
|
+
if (missingInputs.length > 0)
|
|
3520
|
+
return false;
|
|
3521
|
+
if (recordedDeploy === null)
|
|
3522
|
+
return true;
|
|
3523
|
+
if (recordedDeploy.brokerAppId !== brokerAppId)
|
|
3524
|
+
return true;
|
|
3525
|
+
return recordedDeploy.authAppsHash !== hashAuthApps(authAppsRaw);
|
|
3526
|
+
}
|
|
3527
|
+
/**
|
|
3528
|
+
* #211 round 5: the {@link BrokerDeployRecord} JSON a successful deploy
|
|
3529
|
+
* writes under {@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY} (via each
|
|
3530
|
+
* caller's own `record()`), replacing the plain broker-id string round 2-4
|
|
3531
|
+
* wrote there. Paired with {@link brokerDeployNeeded}, which reads it back.
|
|
3532
|
+
*/
|
|
3533
|
+
function brokerDeployRecordValue(brokerAppId, authAppsRaw) {
|
|
3534
|
+
const record = { brokerAppId, authAppsHash: hashAuthApps(authAppsRaw) };
|
|
3535
|
+
return JSON.stringify(record);
|
|
3536
|
+
}
|
|
2129
3537
|
/** Whether any desired env is missing from the app, or set to another value. */
|
|
2130
3538
|
async function envsDiffer(coolify, appId, desired) {
|
|
2131
3539
|
const live = new Map((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
|
|
@@ -2134,15 +3542,43 @@ async function envsDiffer(coolify, appId, desired) {
|
|
|
2134
3542
|
/**
|
|
2135
3543
|
* `null` once the app already has this key set -- generated only the first
|
|
2136
3544
|
* time, never regenerated or overwritten, mirroring `executor.ts`'s
|
|
2137
|
-
* `ensureAuthCookieSecret` exactly.
|
|
3545
|
+
* `ensureAuthCookieSecret` exactly. #211 round 4: `''`, `null` and
|
|
3546
|
+
* `undefined` all count as not set, the same "missing" a live Coolify
|
|
3547
|
+
* response can carry despite its declared `string` type
|
|
3548
|
+
* (`CoolifyPort.listEnvs`'s own doc comment) -- an entry present with one of
|
|
3549
|
+
* those values used to read as "already has this key set" and return `null`,
|
|
3550
|
+
* so a caller reading the value back afterwards (`provisionPlatformArchon`'s
|
|
3551
|
+
* own `AUTH_APP_KEY`) threw "lost its ... between two reads" on every single
|
|
3552
|
+
* apply instead of regenerating it.
|
|
3553
|
+
*
|
|
3554
|
+
* #211 round 5: `minLength`, when given, also counts a *present, non-empty*
|
|
3555
|
+
* value shorter than it as not set -- the broker's own `parseApps`
|
|
3556
|
+
* (`wtfalch/auth/broker/src/apps.ts`) refuses a `key` under 32 characters,
|
|
3557
|
+
* and this package minted a 32-character `AUTH_APP_KEY` from the start
|
|
3558
|
+
* (`provisionPlatformArchon`'s own comment on `randomBytes(32)`), so this
|
|
3559
|
+
* only ever changes behaviour for a key that reached the app some other way
|
|
3560
|
+
* -- a hand-set value, or one carried over from before that guarantee held.
|
|
3561
|
+
* Optional, and unused by every other call site (`ensureMasterkey`,
|
|
3562
|
+
* `ZITADEL_ADMIN_PASSWORD`, `AUTH_COOKIE_SECRET`, this file's recovery admin
|
|
3563
|
+
* password): none of those have a broker-enforced minimum, so widening this
|
|
3564
|
+
* check to them would reject a value that has always been fine.
|
|
2138
3565
|
*/
|
|
2139
|
-
async function ensureGeneratedEnv(coolify, appId, key, generate) {
|
|
3566
|
+
async function ensureGeneratedEnv(coolify, appId, key, generate, minLength) {
|
|
2140
3567
|
const envs = await coolify.listEnvs(appId);
|
|
2141
|
-
const
|
|
3568
|
+
const entry = envs.find((e) => e.key === key && !e.is_preview);
|
|
3569
|
+
const already = entry !== undefined &&
|
|
3570
|
+
entry.value != null &&
|
|
3571
|
+
entry.value !== '' &&
|
|
3572
|
+
(minLength === undefined || entry.value.length >= minLength);
|
|
2142
3573
|
if (already)
|
|
2143
3574
|
return null;
|
|
2144
3575
|
return generate();
|
|
2145
3576
|
}
|
|
3577
|
+
/** The live value of `key` on `appId`, or `undefined` when unset -- `ensureGeneratedEnv`'s read half, for a caller that needs the value it already found. */
|
|
3578
|
+
async function currentEnvValue(coolify, appId, key) {
|
|
3579
|
+
const envs = await coolify.listEnvs(appId);
|
|
3580
|
+
return envs.find((e) => e.key === key && !e.is_preview)?.value;
|
|
3581
|
+
}
|
|
2146
3582
|
/**
|
|
2147
3583
|
* {@link ensureGeneratedEnv} for `ZITADEL_MASTERKEY`, which encrypts what
|
|
2148
3584
|
* ZITADEL keeps in its database. A new key over a database ZITADEL has run
|