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