@wtfalch/foundry 0.16.4 → 0.16.6

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/org.js CHANGED
@@ -82,6 +82,10 @@ export function validateHosting(hosting, names) {
82
82
  if (app.name === 'agora') {
83
83
  return `issuerApps '${app.name}' is reserved for the always-on Agora client every org gets`;
84
84
  }
85
+ // #161: the same for the always-on Boule client.
86
+ if (app.name === 'boule') {
87
+ return `issuerApps '${app.name}' is reserved for the always-on Boule client every org gets`;
88
+ }
85
89
  }
86
90
  if (hosting.enabled && !appEntry(hosting)) {
87
91
  return `hosting.enabled requires an entry keyed '${APP_ENTRY_KEY}'`;
@@ -256,6 +260,7 @@ function validateEntryAuth(entry, hosting, domain) {
256
260
  return `${at} needs an exact hostname to sign in at`;
257
261
  }
258
262
  if (entry.key === 'agora' ||
263
+ entry.key === 'boule' ||
259
264
  entry.key === 'cms' ||
260
265
  hosting.issuerApps.some((app) => app.name === entry.key)) {
261
266
  return `${at} client name '${entry.key}' is already held by another ZITADEL client`;
@@ -0,0 +1,67 @@
1
+ import type { DeclaredOrg } from '../org.js';
2
+ import type { BrokerEntryInput, BrokerEntryWant } from './broker-entries.js';
3
+ /**
4
+ * #161 (Boule part): what both the planner (`diff.ts`) and the executor
5
+ * (`executor.ts`) need to agree on for an org's Boule -- the hostname, the
6
+ * recorded ids and the env vars those produce -- so the plan can compare the
7
+ * live app with exactly what the executor would write. The same split as
8
+ * `agora.ts`, for the same reason.
9
+ */
10
+ /**
11
+ * The fixed `boule` ZITADEL web client's `clientId`, recorded by
12
+ * `executor.ts`'s `zitadelApp` and read by `coolifyBoule` for
13
+ * `AUTH_CLIENT_ID`. Public, not a secret, so a flat scope id like
14
+ * `agora_client_id`.
15
+ */
16
+ export declare const BOULE_CLIENT_ID_SCOPE_KEY = "boule_client_id";
17
+ /**
18
+ * The Boule database's Coolify uuid. It has no `system:kind:key` slot of its
19
+ * own (the step's one auto-recorded `resourceId` is the app), so it is a flat
20
+ * scope id, like the cms and calls databases.
21
+ */
22
+ export declare const BOULE_DATABASE_SCOPE_KEY = "coolify_boule_database";
23
+ /**
24
+ * `boule.<domain>` when the org has a domain, `<slug>-boule.oikos.host`
25
+ * otherwise (the tier rule of boule#128 and foundry#147; `../slug.ts`'s
26
+ * `BOULE_SUFFIX` reserves the collision). A subdomain, not a path: an org's
27
+ * Boule is its own deploy, a separate origin and a separate application at the
28
+ * issuer.
29
+ */
30
+ export declare function bouleHostnameFor(org: DeclaredOrg): string;
31
+ /** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
32
+ export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR"];
33
+ /**
34
+ * The env vars an org's Boule app should carry, and every input still missing
35
+ * to build them. Callers that write must refuse on a non-empty `missing`: a
36
+ * Boule with a blank client id fails every sign-in. `issuer` is the host's
37
+ * issuer address (`AgoraHostValues.issuer`), the same one Agora signs in at.
38
+ *
39
+ * `AUTHZ_BOOTSTRAP_OPERATOR` is the declared first owner's ZITADEL user id
40
+ * (the subject Boule's `bootstrapOperator` compares with the signed-in user's
41
+ * id, never an email), present once `zitadel:owner` has recorded it. It is not
42
+ * `missing` while absent: Boule runs without an operator until then.
43
+ *
44
+ * Not here, and written by the executor instead: the database URLs
45
+ * (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
46
+ * (generated once, never rotated).
47
+ */
48
+ export declare function desiredBouleEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, issuer: string | null): {
49
+ env: Record<string, string>;
50
+ missing: string[];
51
+ };
52
+ /** The `AUTH_APPS` name of an org's Boule broker entry, and the scope key suffix it records under. */
53
+ export declare const BOULE_BROKER_ENTRY_KEY = "boule";
54
+ export declare function bouleBrokerEntryName(org: DeclaredOrg): string;
55
+ /**
56
+ * What the org's Boule broker entry must say (the same shape a hosting entry
57
+ * with `uses.auth` gets, `broker-entries.ts`'s `wantedBrokerEntry`): staff
58
+ * sign in, no sign-up, the one origin of the Boule hostname. Or the inputs
59
+ * still missing, each with the step that records it.
60
+ */
61
+ export declare function wantedBouleBrokerEntry(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): {
62
+ want: BrokerEntryWant;
63
+ } | {
64
+ missing: BrokerEntryInput[];
65
+ };
66
+ /** The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored. */
67
+ export declare function bouleEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];
@@ -0,0 +1,126 @@
1
+ import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
2
+ /**
3
+ * #161 (Boule part): what both the planner (`diff.ts`) and the executor
4
+ * (`executor.ts`) need to agree on for an org's Boule -- the hostname, the
5
+ * recorded ids and the env vars those produce -- so the plan can compare the
6
+ * live app with exactly what the executor would write. The same split as
7
+ * `agora.ts`, for the same reason.
8
+ */
9
+ /**
10
+ * The fixed `boule` ZITADEL web client's `clientId`, recorded by
11
+ * `executor.ts`'s `zitadelApp` and read by `coolifyBoule` for
12
+ * `AUTH_CLIENT_ID`. Public, not a secret, so a flat scope id like
13
+ * `agora_client_id`.
14
+ */
15
+ export const BOULE_CLIENT_ID_SCOPE_KEY = 'boule_client_id';
16
+ /**
17
+ * The Boule database's Coolify uuid. It has no `system:kind:key` slot of its
18
+ * own (the step's one auto-recorded `resourceId` is the app), so it is a flat
19
+ * scope id, like the cms and calls databases.
20
+ */
21
+ export const BOULE_DATABASE_SCOPE_KEY = 'coolify_boule_database';
22
+ /**
23
+ * `boule.<domain>` when the org has a domain, `<slug>-boule.oikos.host`
24
+ * otherwise (the tier rule of boule#128 and foundry#147; `../slug.ts`'s
25
+ * `BOULE_SUFFIX` reserves the collision). A subdomain, not a path: an org's
26
+ * Boule is its own deploy, a separate origin and a separate application at the
27
+ * issuer.
28
+ */
29
+ export function bouleHostnameFor(org) {
30
+ return org.domain ? `boule.${org.domain}` : `${org.slug}-boule.oikos.host`;
31
+ }
32
+ /** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
33
+ export const BOULE_MANAGED_ENV_KEYS = [
34
+ 'APP_URL',
35
+ 'AUTH_ISSUER',
36
+ 'AUTH_ORGANIZATION_ID',
37
+ 'AUTH_CLIENT_ID',
38
+ 'BOULE_ORG_NAME',
39
+ 'AUTHZ_BOOTSTRAP_OPERATOR',
40
+ ];
41
+ /**
42
+ * The env vars an org's Boule app should carry, and every input still missing
43
+ * to build them. Callers that write must refuse on a non-empty `missing`: a
44
+ * Boule with a blank client id fails every sign-in. `issuer` is the host's
45
+ * issuer address (`AgoraHostValues.issuer`), the same one Agora signs in at.
46
+ *
47
+ * `AUTHZ_BOOTSTRAP_OPERATOR` is the declared first owner's ZITADEL user id
48
+ * (the subject Boule's `bootstrapOperator` compares with the signed-in user's
49
+ * id, never an email), present once `zitadel:owner` has recorded it. It is not
50
+ * `missing` while absent: Boule runs without an operator until then.
51
+ *
52
+ * Not here, and written by the executor instead: the database URLs
53
+ * (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
54
+ * (generated once, never rotated).
55
+ */
56
+ export function desiredBouleEnv(org, recorded, issuer) {
57
+ const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
58
+ const clientId = recorded[BOULE_CLIENT_ID_SCOPE_KEY];
59
+ const missing = [];
60
+ if (!issuer)
61
+ missing.push('issuer');
62
+ if (!zitadelOrgId)
63
+ missing.push('zitadelOrgId');
64
+ if (!clientId)
65
+ missing.push('clientId');
66
+ const env = {
67
+ APP_URL: `https://${bouleHostnameFor(org)}`,
68
+ BOULE_ORG_NAME: org.displayName,
69
+ };
70
+ if (issuer)
71
+ env.AUTH_ISSUER = issuer;
72
+ if (zitadelOrgId)
73
+ env.AUTH_ORGANIZATION_ID = zitadelOrgId;
74
+ if (clientId)
75
+ env.AUTH_CLIENT_ID = clientId;
76
+ const owner = org.owners[0];
77
+ const ownerUserId = owner
78
+ ? recorded[`zitadel:owner:${org.id}:${owner.email.toLowerCase()}`]
79
+ : undefined;
80
+ if (ownerUserId)
81
+ env.AUTHZ_BOOTSTRAP_OPERATOR = ownerUserId;
82
+ return { env, missing };
83
+ }
84
+ /** The `AUTH_APPS` name of an org's Boule broker entry, and the scope key suffix it records under. */
85
+ export const BOULE_BROKER_ENTRY_KEY = 'boule';
86
+ export function bouleBrokerEntryName(org) {
87
+ return `${org.slug}-boule`;
88
+ }
89
+ /**
90
+ * What the org's Boule broker entry must say (the same shape a hosting entry
91
+ * with `uses.auth` gets, `broker-entries.ts`'s `wantedBrokerEntry`): staff
92
+ * sign in, no sign-up, the one origin of the Boule hostname. Or the inputs
93
+ * still missing, each with the step that records it.
94
+ */
95
+ export function wantedBouleBrokerEntry(org, recorded) {
96
+ const organizationId = recorded[ZITADEL_ORG_SCOPE_KEY];
97
+ const clientId = recorded[BOULE_CLIENT_ID_SCOPE_KEY];
98
+ const mailDomain = recorded.mail_domain;
99
+ const missing = [];
100
+ if (!organizationId) {
101
+ missing.push({ what: "the org's ZITADEL org id", producedBy: ['zitadel', 'issuer'] });
102
+ }
103
+ if (!clientId)
104
+ missing.push({ what: 'its sign-in client id', producedBy: ['zitadel', 'app'] });
105
+ if (!mailDomain)
106
+ missing.push({ what: 'the org mail domain', producedBy: ['coolify', 'mail'] });
107
+ if (!organizationId || !clientId || !mailDomain)
108
+ return { missing };
109
+ return {
110
+ want: {
111
+ name: bouleBrokerEntryName(org),
112
+ organizationId,
113
+ clientId,
114
+ origins: [`https://${bouleHostnameFor(org)}`],
115
+ from: `${org.mail.noReplyLocalPart || 'noreply'}@${mailDomain}`,
116
+ registration: false,
117
+ replaces: null,
118
+ },
119
+ };
120
+ }
121
+ /** The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored. */
122
+ export function bouleEnvDrift(desired, live) {
123
+ return Object.entries(desired)
124
+ .filter(([key, value]) => live[key] !== value)
125
+ .map(([key]) => key);
126
+ }
@@ -36,6 +36,8 @@ export interface PlanOrgOptions {
36
36
  loginHostBindings?: boolean;
37
37
  /** F5: the executor was built with a `broker` port. */
38
38
  broker?: boolean;
39
+ /** #161: the slugs of the other declared orgs, so a Boule whose hostname another org's slug would take is blocked. */
40
+ otherSlugs?: readonly string[];
39
41
  /** F6a, F6b: the clock `planEntryAiKeys` and `planFilesKeys` read for the renewal window. Default `Date.now`. */
40
42
  now?: () => number;
41
43
  }
package/dist/plan/diff.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
2
  import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
3
3
  import { CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
4
+ import { BOULE_BROKER_ENTRY_KEY, bouleEnvDrift, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
4
5
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
5
6
  import { planEntryAiKeys } from './entry-ai-key.js';
6
7
  import { planFilesKeys } from './entry-files-key.js';
@@ -235,6 +236,20 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
235
236
  const agora = planAgora(org, byKey, claimed, recordedIds, agoraHost);
236
237
  if (agora.step)
237
238
  plannedSteps.push(agora.step);
239
+ // #161 (Boule part): every org's own Boule -- always on, no toggle, the same
240
+ // shape as Agora and planned right after it. Its fixed ZITADEL client first,
241
+ // for the same reason: `coolifyBoule` reads that step's recorded client id.
242
+ const bouleClient = planBouleClient(org, byKey, claimed);
243
+ if (bouleClient)
244
+ plannedSteps.push(bouleClient);
245
+ const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? []);
246
+ if (boule.step)
247
+ plannedSteps.push(boule.step);
248
+ // Boule's own broker entry, after its app: the entry needs the AUTH_APP_KEY
249
+ // that step puts in the app's env.
250
+ const bouleBroker = planBouleBrokerEntry(org, recordedIds, plannedSteps, options.broker ?? false);
251
+ if (bouleBroker.step)
252
+ plannedSteps.push(bouleBroker.step);
238
253
  // #173: register the org's own app with the platform's jobs scheduler.
239
254
  // After Agora, so a failing registration can never stop Agora from
240
255
  // provisioning (`runSteps` stops at the first failing step).
@@ -253,6 +268,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
253
268
  // item list, so it rides along with the unmanaged items.
254
269
  if (agora.blocked)
255
270
  unmanaged.push({ description: agora.blocked, key: 'coolify:agora' });
271
+ if (boule.blocked)
272
+ unmanaged.push({ description: boule.blocked, key: 'coolify:boule' });
256
273
  // Same for an ai key minted but never delivered: planning a new mint would
257
274
  // orphan a second live key.
258
275
  if (aiKey.blocked)
@@ -307,6 +324,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
307
324
  // #243: an appEnv source nothing will record is reported, not silently skipped.
308
325
  unmanaged.push(...app.blocked);
309
326
  unmanaged.push(...brokerEntries.blocked);
327
+ unmanaged.push(...bouleBroker.blocked);
310
328
  return { orgId: org.id, plannedSteps, unmanaged, versionChanges };
311
329
  }
312
330
  /**
@@ -1434,6 +1452,114 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
1434
1452
  blocked: null,
1435
1453
  };
1436
1454
  }
1455
+ /**
1456
+ * #161 (Boule part): every org gets one fixed ZITADEL web client named
1457
+ * `boule`, so its Boule (https://github.com/wtfalch/boule) can sign anyone in.
1458
+ * The same shape as {@link planAgoraClient}: provision when missing, recreate
1459
+ * when the live `appType` differs, nothing otherwise.
1460
+ */
1461
+ function planBouleClient(org, byKey, claimed) {
1462
+ const key = resourceKey('zitadel', 'app', 'boule');
1463
+ const live = byKey.get(key);
1464
+ if (live)
1465
+ claimed.add(key);
1466
+ const desired = { appType: 'web' };
1467
+ if (!live) {
1468
+ return {
1469
+ action: { system: 'zitadel', kind: 'app', key: 'boule', desired },
1470
+ step: {
1471
+ description: `provision the web ZITADEL app 'boule' for ${org.slug}'s Boule sign-in`,
1472
+ destroys: false,
1473
+ migrates: false,
1474
+ },
1475
+ };
1476
+ }
1477
+ if (live.config.appType === 'web')
1478
+ return null;
1479
+ return {
1480
+ action: { system: 'zitadel', kind: 'app', key: 'boule', desired },
1481
+ step: {
1482
+ description: `recreate ZITADEL app 'boule' for ${org.slug} as web (was ${String(live.config.appType)})`,
1483
+ destroys: true,
1484
+ migrates: false,
1485
+ },
1486
+ };
1487
+ }
1488
+ /**
1489
+ * #161 (Boule part): the org's own Boule, a per-org singleton keyed by the org
1490
+ * id under its own `boule` kind, a separate Coolify application from the org's
1491
+ * hosted app and from Agora. Built from https://github.com/wtfalch/boule's
1492
+ * root Dockerfile (`executor.ts`'s `coolifyBoule`), with a database of its
1493
+ * own, never toggled by any declared field and never parked or deleted.
1494
+ *
1495
+ * Once live, a step is planned again whenever the live app's sign-in env lacks
1496
+ * a desired key or holds a different value (`boule.ts`'s `desiredBouleEnv`,
1497
+ * the function the executor writes from). The only host value is the issuer
1498
+ * address (the one Agora signs in at); without it the Boule is blocked, not
1499
+ * planned, so every other step still applies.
1500
+ */
1501
+ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs) {
1502
+ const key = resourceKey('coolify', 'boule', org.id);
1503
+ const live = byKey.get(key);
1504
+ if (live)
1505
+ claimed.add(key);
1506
+ // Never truncated: Postgres cuts an identifier at 63 bytes and Boule finds
1507
+ // its runtime role as `<current_database()>_rt`, so a cut name breaks the
1508
+ // match; a DNS label over 63 is refused by Coolify and Cloudflare.
1509
+ const tooLong = [
1510
+ [`${org.slug}-boule`, 'the Boule host label and app name'],
1511
+ [`${org.slug}_boule_db_rt`, 'the Boule database role'],
1512
+ ].filter(([name]) => name.length > 63);
1513
+ if (tooLong.length > 0) {
1514
+ return {
1515
+ step: null,
1516
+ blocked: `${org.slug}'s Boule is blocked, not planned: ${tooLong.map(([n, what]) => `${what} '${n}' is over 63 characters`).join(', ')}; shorten the slug`,
1517
+ };
1518
+ }
1519
+ if (otherSlugs.includes(`${org.slug}-boule`)) {
1520
+ return {
1521
+ step: null,
1522
+ blocked: `${org.slug}'s Boule is blocked, not planned: another declared org has the slug '${org.slug}-boule', which is this Boule's hostname`,
1523
+ };
1524
+ }
1525
+ if (!issuer) {
1526
+ return {
1527
+ step: null,
1528
+ blocked: `${org.slug}'s Boule is blocked, not planned: the host supplies no issuer`,
1529
+ };
1530
+ }
1531
+ const action = {
1532
+ system: 'coolify',
1533
+ kind: 'boule',
1534
+ key: org.id,
1535
+ desired: { issuer },
1536
+ };
1537
+ if (!live) {
1538
+ return {
1539
+ step: {
1540
+ action,
1541
+ step: { description: `provision ${org.slug}'s Boule`, destroys: false, migrates: false },
1542
+ },
1543
+ blocked: null,
1544
+ };
1545
+ }
1546
+ const liveEnv = (live.config.env ?? {});
1547
+ const { env } = desiredBouleEnv(org, recordedIds, issuer);
1548
+ const drift = bouleEnvDrift(env, liveEnv);
1549
+ if (drift.length === 0)
1550
+ return { step: null, blocked: null };
1551
+ return {
1552
+ step: {
1553
+ action,
1554
+ step: {
1555
+ description: `update ${org.slug}'s Boule config (${drift.join(', ')} missing or different)`,
1556
+ destroys: false,
1557
+ migrates: false,
1558
+ },
1559
+ },
1560
+ blocked: null,
1561
+ };
1562
+ }
1437
1563
  /**
1438
1564
  * The shared on/off shape behind {@link planApp}, {@link planDatabase} and
1439
1565
  * {@link planTunnel} (#71): a per-org singleton resource, keyed by the org
@@ -2094,6 +2220,70 @@ function planBrokerEntries(org, recordedIds, plannedSteps, brokerWired) {
2094
2220
  }
2095
2221
  return { steps, blocked };
2096
2222
  }
2223
+ /**
2224
+ * #161: the org's Boule broker entry (`<slug>-boule`), planned like a hosting
2225
+ * entry's ({@link planBrokerEntries}): blocked while the host has not wired
2226
+ * the broker port, or while an input is neither recorded nor produced by a
2227
+ * step in this plan; nothing once the recorded marker matches what it would
2228
+ * write. Silent while no Boule app exists or is planned (a blocked Boule
2229
+ * already says why).
2230
+ */
2231
+ function planBouleBrokerEntry(org, recordedIds, plannedSteps, brokerWired) {
2232
+ const at = `${org.slug}'s Boule broker entry`;
2233
+ const key = 'coolify:boule-broker-entry';
2234
+ const blocked = [];
2235
+ const warnings = recordedIds[brokerEntryWarningsScopeKey(BOULE_BROKER_ENTRY_KEY)];
2236
+ for (const name of warnings ? JSON.parse(warnings) : []) {
2237
+ blocked.push({
2238
+ description: `warning: the broker's AUTH_APPS entry '${name}' is invalid and the broker drops it; foundry leaves it as it is`,
2239
+ key: `${key}:invalid:${name}`,
2240
+ });
2241
+ }
2242
+ const hasApp = Boolean(recordedIds[`coolify:boule:${org.id}`]) ||
2243
+ plannedSteps.some((p) => p.action.system === 'coolify' && p.action.kind === 'boule');
2244
+ if (!hasApp)
2245
+ return { step: null, blocked };
2246
+ if (!brokerWired) {
2247
+ blocked.push({
2248
+ description: `${at} is blocked, not planned: the host has not wired the broker port`,
2249
+ key,
2250
+ });
2251
+ return { step: null, blocked };
2252
+ }
2253
+ const wanted = wantedBouleBrokerEntry(org, recordedIds);
2254
+ if ('want' in wanted) {
2255
+ if (recordedIds[brokerEntryAppliedScopeKey(BOULE_BROKER_ENTRY_KEY)] ===
2256
+ JSON.stringify(wanted.want)) {
2257
+ return { step: null, blocked };
2258
+ }
2259
+ }
2260
+ else {
2261
+ const unplanned = wanted.missing.filter(({ producedBy: [system, kind] }) => !plannedSteps.some((p) => p.action.system === system && p.action.kind === kind));
2262
+ if (unplanned.length > 0) {
2263
+ // With mail off nothing records a mail domain, so the Boule app is up
2264
+ // while nobody can sign in to it: say so rather than "not recorded yet".
2265
+ const noMail = org.mail.mode === 'off' && unplanned.some((m) => m.producedBy[1] === 'mail');
2266
+ blocked.push({
2267
+ description: noMail
2268
+ ? `${at} is blocked, not planned: ${org.slug}'s Boule is deployed but sign-in is down until the org's mail records a domain`
2269
+ : `${at} is blocked, not planned: ${unplanned.map((m) => m.what).join(', ')} not recorded yet`,
2270
+ key,
2271
+ });
2272
+ return { step: null, blocked };
2273
+ }
2274
+ }
2275
+ return {
2276
+ step: {
2277
+ action: { system: 'coolify', kind: 'boule-broker-entry', key: org.id, desired: {} },
2278
+ step: {
2279
+ description: `write ${at} beside the others`,
2280
+ destroys: false,
2281
+ migrates: false,
2282
+ },
2283
+ },
2284
+ blocked,
2285
+ };
2286
+ }
2097
2287
  /**
2098
2288
  * Plan gap A9: an org's customers sign in through a second ZITADEL org with
2099
2289
  * self-registration on, granted the org's app project. Planned once an entry
@@ -1379,6 +1379,14 @@ export declare function getDatabaseStarted(coolify: {
1379
1379
  internal_db_url?: string;
1380
1380
  status?: string;
1381
1381
  }>;
1382
+ /** Timing for the wait in {@link getDatabaseStarted}; a test swaps `sleep` and `now` for a fake clock. */
1383
+ export declare const databaseStartWait: {
1384
+ sleep: (ms: number) => Promise<void>;
1385
+ now: () => number;
1386
+ pollMs: number;
1387
+ timeoutMs: number;
1388
+ graceMs: number;
1389
+ };
1382
1390
  export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer {
1383
1391
  private readonly ports;
1384
1392
  constructor(ports: FoundryStepExecutorPorts);
@@ -1605,6 +1613,14 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1605
1613
  * broker would drop.
1606
1614
  */
1607
1615
  private coolifyBrokerEntry;
1616
+ /**
1617
+ * #161: the org's Boule broker entry, `<slug>-boule`, written the way a
1618
+ * hosting entry's is: with the `AUTH_APP_KEY` `coolifyBoule` put in the
1619
+ * Boule app's env.
1620
+ */
1621
+ private bouleBrokerEntry;
1622
+ /** The shared tail of both broker-entry steps: read the app's key, write the entry, record what was written. */
1623
+ private writeAppBrokerEntry;
1608
1624
  /**
1609
1625
  * `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
1610
1626
  * only the first time (task brief), never regenerated or overwritten, so a
@@ -1998,6 +2014,30 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1998
2014
  * not the host values).
1999
2015
  */
2000
2016
  private coolifyAgora;
2017
+ /**
2018
+ * `plan/diff.ts`'s `planBoule`: provisions the org's own Boule
2019
+ * (https://github.com/wtfalch/boule) as one more Coolify application, from
2020
+ * that repo's root Dockerfile ({@link BOULE_REPO}), with a database of its
2021
+ * own, and pushes its env. The same shape as {@link coolifyAgora}. There is
2022
+ * no park branch: Boule is always on.
2023
+ *
2024
+ * The sign-in env comes from `plan/boule.ts`'s `desiredBouleEnv`, the same
2025
+ * function the plan compares the live app against. The step never writes a
2026
+ * partial set: when the issuer (carried in the step), the org's ZITADEL org
2027
+ * id or the Boule client id is missing it throws naming each one, before it
2028
+ * creates anything.
2029
+ *
2030
+ * The database is `<slug>-boule-db`, split like the cms app's (#117, #239):
2031
+ * the superuser URL as `DATABASE_URL_OWNER` (Boule's boot script migrates
2032
+ * with it, then unsets it) and a `<dbName>_rt` role's URL as `DATABASE_URL`,
2033
+ * minted once and never rotated. `AUTH_COOKIE_SECRET` is generated once the
2034
+ * same way. A running app is restarted when its sign-in env or database URLs
2035
+ * changed, since a container reads its env only at start.
2036
+ *
2037
+ * `AUTH_APP_KEY` is minted once here; its broker entry is the next step
2038
+ * (`bouleBrokerEntry`).
2039
+ */
2040
+ private coolifyBoule;
2001
2041
  /**
2002
2042
  * `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with
2003
2043
  * the platform's jobs scheduler (`JobsSchedulerPort.registerProject`, the
@@ -3,6 +3,7 @@ import { URL } from 'node:url';
3
3
  import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
4
4
  import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, } from '../org.js';
5
5
  import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
6
+ import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
6
7
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
7
8
  import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
8
9
  import { CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, chatInternalNames, chatMasConfigYaml, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
@@ -226,6 +227,16 @@ const CMS_SITE_APP_DOCKERFILE = '/apps/site/Dockerfile';
226
227
  */
227
228
  const AGORA_REPO = 'wtfalch/agora';
228
229
  const AGORA_APP_DOCKERFILE = '/apps/agora/Dockerfile';
230
+ /**
231
+ * #161 (Boule part): the repo `coolifyBoule` builds the org's Boule from
232
+ * (https://github.com/wtfalch/boule), by the same convention as
233
+ * {@link AGORA_REPO}: Coolify's github-app build from the repo, never a
234
+ * registry image. Boule's own Dockerfile is at the repository root, serves on
235
+ * 3000 (`EXPOSE 3000`) and runs `scripts/migrate-and-start.sh`, which applies
236
+ * its migrations as the owner and then serves as the `<database>_rt` role.
237
+ */
238
+ const BOULE_REPO = 'wtfalch/boule';
239
+ const BOULE_APP_DOCKERFILE = '/Dockerfile';
229
240
  /**
230
241
  * Every app foundry deploys keeps its own logs across a redeploy, so a
231
242
  * database outage does not also erase the only other copy of the evidence
@@ -559,12 +570,35 @@ export function isRunningOrStarting(status) {
559
570
  * instead of `getDatabase`. Returns what `getDatabase` returned.
560
571
  */
561
572
  export async function getDatabaseStarted(coolify, dbId) {
562
- const db = await coolify.getDatabase(dbId);
563
- if (db.status !== undefined && !db.status.startsWith('running')) {
564
- await coolify.raw('POST', `/databases/${dbId}/start`);
573
+ let db = await coolify.getDatabase(dbId);
574
+ if (db.status === undefined || db.status.startsWith('running'))
575
+ return db;
576
+ await coolify.raw('POST', `/databases/${dbId}/start`);
577
+ // Coolify reports `running` before Postgres accepts connections, and the app
578
+ // deployed next (a migrate step) connects at once. Wait for `running*`, then
579
+ // a fixed grace. A database already running returned above and never waits.
580
+ const { sleep, now, pollMs, timeoutMs, graceMs } = databaseStartWait;
581
+ const deadline = now() + timeoutMs;
582
+ for (;;) {
583
+ await sleep(pollMs);
584
+ db = await coolify.getDatabase(dbId);
585
+ if (db.status?.startsWith('running'))
586
+ break;
587
+ if (now() >= deadline) {
588
+ throw new Error(`Database ${dbId} did not reach "running" within ${timeoutMs / 1000}s (last status: ${db.status ?? 'unknown'}). Retry the apply.`);
589
+ }
565
590
  }
591
+ await sleep(graceMs);
566
592
  return db;
567
593
  }
594
+ /** Timing for the wait in {@link getDatabaseStarted}; a test swaps `sleep` and `now` for a fake clock. */
595
+ export const databaseStartWait = {
596
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
597
+ now: () => Date.now(),
598
+ pollMs: 2000,
599
+ timeoutMs: 120_000,
600
+ graceMs: 5000,
601
+ };
568
602
  // ---------------------------------------------------------------------------
569
603
  // Executor
570
604
  // ---------------------------------------------------------------------------
@@ -632,6 +666,10 @@ export class FoundryStepExecutor {
632
666
  return this.cmsSite(action, org, context);
633
667
  case 'coolify:agora':
634
668
  return this.coolifyAgora(action, org, context);
669
+ case 'coolify:boule':
670
+ return this.coolifyBoule(action, org, context);
671
+ case 'coolify:boule-broker-entry':
672
+ return this.bouleBrokerEntry(org, context);
635
673
  case 'jobs:registration':
636
674
  return this.jobsRegistration(action, org, context);
637
675
  default:
@@ -1052,11 +1090,17 @@ export class FoundryStepExecutor {
1052
1090
  // `OIDC_AUTH_METHOD_TYPE_NONE`, no secret), which is what Agora's browser
1053
1091
  // SPA needs. Providers cannot create ZITADEL's `USER_AGENT` app kind.
1054
1092
  const isAgoraClient = action.key === 'agora';
1093
+ // #161: the fixed `boule` client signs people into the org's Boule at its
1094
+ // own hostname, on the standard `/auth/callback` path (Boule is built on
1095
+ // `@wtfalch/auth` like every other web app here).
1096
+ const isBouleClient = action.key === 'boule';
1055
1097
  const hostname = isCmsClient
1056
1098
  ? cmsHostnameFor(org)
1057
1099
  : isAgoraClient
1058
1100
  ? agoraHostnameFor(org)
1059
- : appHostname(org);
1101
+ : isBouleClient
1102
+ ? bouleHostnameFor(org)
1103
+ : appHostname(org);
1060
1104
  // Native apps sign in via device code, with no redirect of their own
1061
1105
  // (auth/scripts/provision.mjs:475-497, prior art cited in the decisions
1062
1106
  // file) -- empty arrays rather than a web app's callback/logout URLs.
@@ -1099,9 +1143,11 @@ export class FoundryStepExecutor {
1099
1143
  ? CMS_CLIENT_ID_SCOPE_KEY
1100
1144
  : isAgoraClient
1101
1145
  ? AGORA_CLIENT_ID_SCOPE_KEY
1102
- : authEntry
1103
- ? entryClientIdScopeKey(action.key)
1104
- : APP_CLIENT_ID_SCOPE_KEY, created.clientId);
1146
+ : isBouleClient
1147
+ ? BOULE_CLIENT_ID_SCOPE_KEY
1148
+ : authEntry
1149
+ ? entryClientIdScopeKey(action.key)
1150
+ : APP_CLIENT_ID_SCOPE_KEY, created.clientId);
1105
1151
  }
1106
1152
  return { resourceId: created.appId };
1107
1153
  }
@@ -1635,20 +1681,46 @@ export class FoundryStepExecutor {
1635
1681
  if (!appId) {
1636
1682
  throw new Error(`coolify:broker-entry for ${org.slug}: app ${action.key} is not recorded yet`);
1637
1683
  }
1684
+ await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, appId, context);
1685
+ return {};
1686
+ }
1687
+ /**
1688
+ * #161: the org's Boule broker entry, `<slug>-boule`, written the way a
1689
+ * hosting entry's is: with the `AUTH_APP_KEY` `coolifyBoule` put in the
1690
+ * Boule app's env.
1691
+ */
1692
+ async bouleBrokerEntry(org, context) {
1693
+ const broker = this.ports.broker;
1694
+ if (!broker) {
1695
+ throw new Error(`coolify:boule-broker-entry for ${org.slug}: the broker port is not wired`);
1696
+ }
1697
+ const recorded = context.recordedIds();
1698
+ const wanted = wantedBouleBrokerEntry(org, recorded);
1699
+ if ('missing' in wanted) {
1700
+ throw new Error(`coolify:boule-broker-entry for ${org.slug}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
1701
+ }
1702
+ const appId = recorded[`coolify:boule:${org.id}`];
1703
+ if (!appId) {
1704
+ throw new Error(`coolify:boule-broker-entry for ${org.slug}: the Boule app is not recorded yet`);
1705
+ }
1706
+ await this.writeAppBrokerEntry(org, broker, BOULE_BROKER_ENTRY_KEY, wanted.want, appId, context);
1707
+ return {};
1708
+ }
1709
+ /** The shared tail of both broker-entry steps: read the app's key, write the entry, record what was written. */
1710
+ async writeAppBrokerEntry(org, broker, entryKey, want, appId, context) {
1638
1711
  const appKey = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview)?.value;
1639
1712
  if (!appKey || appKey.length < 32) {
1640
1713
  throw new Error(`coolify:broker-entry for ${org.slug}: app ${appId} has no AUTH_APP_KEY yet`);
1641
1714
  }
1642
- const previousRaw = recorded[brokerEntryAppliedScopeKey(entry.key)];
1715
+ const previousRaw = context.recordedIds()[brokerEntryAppliedScopeKey(entryKey)];
1643
1716
  const result = await writeBrokerEntry(broker, {
1644
- want: wanted.want,
1717
+ want,
1645
1718
  appKey,
1646
1719
  previous: previousRaw ? JSON.parse(previousRaw) : null,
1647
1720
  });
1648
- context.recordScopeId(brokerEntryWarningsScopeKey(entry.key), JSON.stringify(result.invalidForeign));
1721
+ context.recordScopeId(brokerEntryWarningsScopeKey(entryKey), JSON.stringify(result.invalidForeign));
1649
1722
  // Last: a run that failed above plans the step again.
1650
- context.recordScopeId(brokerEntryAppliedScopeKey(entry.key), JSON.stringify(wanted.want));
1651
- return {};
1723
+ context.recordScopeId(brokerEntryAppliedScopeKey(entryKey), JSON.stringify(want));
1652
1724
  }
1653
1725
  /**
1654
1726
  * `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
@@ -2958,6 +3030,84 @@ export class FoundryStepExecutor {
2958
3030
  await this.ports.coolify.bulkUpsertEnvs(app.appId, Object.entries(env).map(([key, value]) => ({ key, value })));
2959
3031
  return { resourceId: app.appId };
2960
3032
  }
3033
+ // ---- boule (#161) --------------------------------------------------------
3034
+ /**
3035
+ * `plan/diff.ts`'s `planBoule`: provisions the org's own Boule
3036
+ * (https://github.com/wtfalch/boule) as one more Coolify application, from
3037
+ * that repo's root Dockerfile ({@link BOULE_REPO}), with a database of its
3038
+ * own, and pushes its env. The same shape as {@link coolifyAgora}. There is
3039
+ * no park branch: Boule is always on.
3040
+ *
3041
+ * The sign-in env comes from `plan/boule.ts`'s `desiredBouleEnv`, the same
3042
+ * function the plan compares the live app against. The step never writes a
3043
+ * partial set: when the issuer (carried in the step), the org's ZITADEL org
3044
+ * id or the Boule client id is missing it throws naming each one, before it
3045
+ * creates anything.
3046
+ *
3047
+ * The database is `<slug>-boule-db`, split like the cms app's (#117, #239):
3048
+ * the superuser URL as `DATABASE_URL_OWNER` (Boule's boot script migrates
3049
+ * with it, then unsets it) and a `<dbName>_rt` role's URL as `DATABASE_URL`,
3050
+ * minted once and never rotated. `AUTH_COOKIE_SECRET` is generated once the
3051
+ * same way. A running app is restarted when its sign-in env or database URLs
3052
+ * changed, since a container reads its env only at start.
3053
+ *
3054
+ * `AUTH_APP_KEY` is minted once here; its broker entry is the next step
3055
+ * (`bouleBrokerEntry`).
3056
+ */
3057
+ async coolifyBoule(action, org, context) {
3058
+ // The issuer the plan read, carried in the step: the env written is the
3059
+ // env the plan showed.
3060
+ const issuer = action.desired.issuer ?? null;
3061
+ const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer);
3062
+ if (missing.length > 0) {
3063
+ throw new Error(`Boule for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
3064
+ }
3065
+ const projectUuid = await this.ensureCoolifyProject(org, context);
3066
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
3067
+ const name = `${org.slug}-boule`;
3068
+ const dbName = `${org.slug}-boule-db`.replace(/-/g, '_');
3069
+ const dbId = await this.ensureCallsDatabase(context, projectUuid, environment, `${org.slug}-boule-db`, dbName, BOULE_DATABASE_SCOPE_KEY);
3070
+ context.recordScopeId(BOULE_DATABASE_SCOPE_KEY, dbId);
3071
+ const server = await this.resolveServer();
3072
+ const githubApp = await this.ports.coolify.requireGithubApp();
3073
+ const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
3074
+ project_uuid: projectUuid,
3075
+ server_uuid: server.uuid,
3076
+ environment_name: environment.name,
3077
+ environment_uuid: environment.uuid,
3078
+ github_app_uuid: githubApp.uuid,
3079
+ git_repository: BOULE_REPO,
3080
+ git_branch: 'main',
3081
+ build_pack: 'dockerfile',
3082
+ name,
3083
+ domains: coolifyDomains([bouleHostnameFor(org)]),
3084
+ dockerfile_location: BOULE_APP_DOCKERFILE,
3085
+ }));
3086
+ // Every app foundry deploys keeps its own logs (#75), reused as-is here.
3087
+ await this.ensureLogVolume(app.appId, org, app.existed);
3088
+ const liveEnv = new Map((await this.ports.coolify.listEnvs(app.appId))
3089
+ .filter((e) => !e.is_preview)
3090
+ .map((e) => [e.key, e.value]));
3091
+ const envChanged = bouleEnvDrift(env, Object.fromEntries(liveEnv)).length > 0;
3092
+ const envs = Object.entries(env).map(([key, value]) => ({ key, value }));
3093
+ const cookieSecret = await this.ensureAuthCookieSecret(app.appId);
3094
+ if (cookieSecret)
3095
+ envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
3096
+ // The key the sign-in service knows this app by: minted once, then it
3097
+ // lives in this env and in the broker entry `bouleBrokerEntry` writes.
3098
+ const appKey = await this.ensureAuthAppKey(app.appId);
3099
+ if (appKey)
3100
+ envs.push({ key: 'AUTH_APP_KEY', value: appKey });
3101
+ await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
3102
+ const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName);
3103
+ if (!dbPush.pushed) {
3104
+ throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
3105
+ }
3106
+ if (app.existed && (envChanged || cookieSecret !== null || appKey !== null || dbPush.changed)) {
3107
+ await this.ports.coolify.restart(app.appId);
3108
+ }
3109
+ return { resourceId: app.appId };
3110
+ }
2961
3111
  // ---- jobs (#173) ---------------------------------------------------------
2962
3112
  /**
2963
3113
  * `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with
@@ -28,8 +28,8 @@ export declare function coolifyDomains(names: readonly string[]): string;
28
28
  export declare function legacyAppHostname(org: DeclaredOrg): string;
29
29
  /**
30
30
  * The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
31
- * #164): a site's two names, every entry's names, an own-domain calls name and
32
- * Agora's, then the wildcard last. An org that does not host keeps the single
31
+ * #164): a site's two names, every entry's names, an own-domain calls name,
32
+ * Agora's and Boule's, then the wildcard last. An org that does not host keeps the single
33
33
  * name it always had.
34
34
  */
35
35
  export declare function tunnelIngressHostnames(org: DeclaredOrg): string[];
@@ -1,5 +1,6 @@
1
1
  import { APP_ENTRY_KEY } from '../org.js';
2
2
  import { agoraHostnameFor } from './agora.js';
3
+ import { bouleHostnameFor } from './boule.js';
3
4
  /**
4
5
  * The full names an entry is served at. With `hosting.usesDomain` off, or no
5
6
  * domain, `hostnames` is not read: the `app` entry is at `<slug>.oikos.host`
@@ -48,8 +49,8 @@ export function legacyAppHostname(org) {
48
49
  }
49
50
  /**
50
51
  * The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
51
- * #164): a site's two names, every entry's names, an own-domain calls name and
52
- * Agora's, then the wildcard last. An org that does not host keeps the single
52
+ * #164): a site's two names, every entry's names, an own-domain calls name,
53
+ * Agora's and Boule's, then the wildcard last. An org that does not host keeps the single
53
54
  * name it always had.
54
55
  */
55
56
  export function tunnelIngressHostnames(org) {
@@ -67,6 +68,8 @@ export function tunnelIngressHostnames(org) {
67
68
  // #164: Agora always moves onto `agora.<domain>` once `org.domain` is set.
68
69
  if (org.domain) {
69
70
  hostnames.push(agoraHostnameFor(org));
71
+ // #161: and Boule moves onto `boule.<domain>` the same way.
72
+ hostnames.push(bouleHostnameFor(org));
70
73
  }
71
74
  // The wildcard goes last: Cloudflare takes the first ingress rule that
72
75
  // matches, so every exact name above must come before `*.<domain>`.
@@ -1,7 +1,7 @@
1
1
  import type { DeclaredOrg } from '../org.js';
2
2
  /**
3
3
  * The names under the org's domain that foundry owns, so no hosting entry may
4
- * take one: Agora, the cms studio when the site is on, calls when it uses the
4
+ * take one: Agora, Boule, the cms studio when the site is on, calls when it uses the
5
5
  * domain, the sign-in host when the issuer does, and every platform service
6
6
  * hostname that falls under the domain. Empty for an org with no domain.
7
7
  */
@@ -1,8 +1,9 @@
1
1
  import { agoraHostnameFor } from './agora.js';
2
+ import { bouleHostnameFor } from './boule.js';
2
3
  import { loginHostnameFor } from './login-host.js';
3
4
  /**
4
5
  * The names under the org's domain that foundry owns, so no hosting entry may
5
- * take one: Agora, the cms studio when the site is on, calls when it uses the
6
+ * take one: Agora, Boule, the cms studio when the site is on, calls when it uses the
6
7
  * domain, the sign-in host when the issuer does, and every platform service
7
8
  * hostname that falls under the domain. Empty for an org with no domain.
8
9
  */
@@ -12,6 +13,7 @@ export function lockedHostnames(org, platformHostnames) {
12
13
  if (!domain)
13
14
  return locked;
14
15
  locked.add(agoraHostnameFor(org));
16
+ locked.add(bouleHostnameFor(org));
15
17
  if (org.site.enabled)
16
18
  locked.add(`cms.${domain}`);
17
19
  if (org.calls.enabled && org.calls.usesDomain)
@@ -371,9 +371,11 @@ export class FoundryPlanApply {
371
371
  this.orgPlatformReadiness(),
372
372
  this.orgAgoraHost(id),
373
373
  ]);
374
+ const otherSlugs = (await this.store.list()).filter((o) => o.id !== id).map((o) => o.slug);
374
375
  const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
375
376
  loginHostBindings: this.loginHostBindings,
376
377
  broker: this.broker,
378
+ otherSlugs,
377
379
  now: () => this.now().getTime(),
378
380
  });
379
381
  const invalid = await this.invalidHosting(org);
@@ -466,6 +466,13 @@ export declare const PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY = "ai_platform_migrat
466
466
  * that throws) is repaired by the next one.
467
467
  */
468
468
  export declare const PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY = "ai_platform_deploy_pending";
469
+ /**
470
+ * The Coolify deployment queued for the host (its `deployment_uuid`, or
471
+ * `untracked` when the deploy answered none), set when that deploy is queued
472
+ * and cleared once it finished. The mint waits for it, so a retry after a
473
+ * failed wait still waits instead of minting against the old container.
474
+ */
475
+ export declare const PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY = "ai_platform_runner_deployment";
469
476
  /**
470
477
  * #171: the resource key `planFiles` plans under and `FoundryPlatformStepExecutor`
471
478
  * dispatches on, the {@link PLATFORM_AUTH_KEY} shape for the files host.
@@ -523,6 +523,13 @@ export const PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY = 'ai_platform_migrate_blocke
523
523
  * that throws) is repaired by the next one.
524
524
  */
525
525
  export const PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY = 'ai_platform_deploy_pending';
526
+ /**
527
+ * The Coolify deployment queued for the host (its `deployment_uuid`, or
528
+ * `untracked` when the deploy answered none), set when that deploy is queued
529
+ * and cleared once it finished. The mint waits for it, so a retry after a
530
+ * failed wait still waits instead of minting against the old container.
531
+ */
532
+ export const PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY = 'ai_platform_runner_deployment';
526
533
  /**
527
534
  * #171: the resource key `planFiles` plans under and `FoundryPlatformStepExecutor`
528
535
  * dispatches on, the {@link PLATFORM_AUTH_KEY} shape for the files host.
@@ -513,6 +513,20 @@ export interface PlatformAiDeps {
513
513
  heldKeys?: {
514
514
  resolve(ref: string): Promise<string | null>;
515
515
  };
516
+ /**
517
+ * The `@wtfalch/keys` settings the AI host reads (`KEYS_WORKER_URL`,
518
+ * `KEYS_CREDENTIAL`, `KEYS_VERIFY_KEYS`: the worker's origin, this host's
519
+ * own worker-signed caller credential, and the worker's Ed25519 public
520
+ * key(s), comma-separated base64url, current first). ai's
521
+ * `POST /v1/platform/keys` refuses every call unless all three are set, so
522
+ * the step sets them on the host and the worker, and the executor reports
523
+ * the step blocked while this is unset. Never recorded or logged.
524
+ */
525
+ keys?: {
526
+ workerUrl: string;
527
+ credential: string;
528
+ verifyKeys: string;
529
+ };
516
530
  /** Injected for tests. Absent, {@link provisionPlatformAi} builds the real {@link createAiManagementKeyMinter} from the signing key it mints. */
517
531
  managementKeyMinter?: AiManagementKeyMinter;
518
532
  /** Injected for tests, e.g. against a fake ai host. Defaults to the global `fetch`. */
@@ -6,7 +6,7 @@ import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
6
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
7
7
  import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
8
8
  import { coolifyDomains } from './hostnames.js';
9
- import { AUTH_ZITADEL_RUN_OPTIONS, 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_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, 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, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
9
+ import { AUTH_ZITADEL_RUN_OPTIONS, 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_RUNNER_DEPLOYMENT_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_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, 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, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
10
10
  import { applyCallsSfuPlatformStep } from './platform-sfu.js';
11
11
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
12
12
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
@@ -184,6 +184,10 @@ export class FoundryPlatformStepExecutor {
184
184
  blocked.ai =
185
185
  "the platform's AI host is blocked, not planned: the host built the platform executor without its ai dependencies";
186
186
  }
187
+ else if (platform.ai.enabled && !aiKeys(this.ai)) {
188
+ blocked.ai =
189
+ "the platform's AI host is blocked, not planned: the host supplies no keys worker URL, credential and verify keys (KEYS_WORKER_URL, KEYS_CREDENTIAL, KEYS_VERIFY_KEYS), and without them ai's POST /v1/platform/keys refuses every call, so foundry's management key could never be minted";
190
+ }
187
191
  else if (platform.ai.enabled &&
188
192
  !this.ai?.managementKeyMinter &&
189
193
  !this.ai?.heldKeys &&
@@ -3143,16 +3147,18 @@ const PLATFORM_AI_WORKER_SCOPE_KEY = 'coolify_platform_ai_worker_app';
3143
3147
  * Written only when absent, never overwritten: an operator's own model list
3144
3148
  * or worker bounds survive every apply. Values are `.env.example`'s, and
3145
3149
  * `AI_MODELS=[]` is the README's "honest configuration for a host with no
3146
- * approved text model".
3150
+ * approved text model". The runner needs `AI_WORKER_TIMEOUT_MS` too: ai's
3151
+ * `runtimeHost()` (`apps/host/src/lib/authz/ai-host.ts`) requires it, and
3152
+ * every route that checks readiness fails without it.
3147
3153
  */
3148
3154
  const PLATFORM_AI_RUNNER_DEFAULTS = [
3149
3155
  { key: 'AI_MODELS', value: '[]' },
3150
3156
  { key: 'AI_FORGE_BINDINGS', value: '[]' },
3157
+ { key: 'AI_WORKER_TIMEOUT_MS', value: '60000' },
3151
3158
  ];
3152
3159
  const PLATFORM_AI_WORKER_DEFAULTS = [
3153
3160
  ...PLATFORM_AI_RUNNER_DEFAULTS,
3154
3161
  { key: 'AI_WORKER_BATCH', value: '10' },
3155
- { key: 'AI_WORKER_TIMEOUT_MS', value: '60000' },
3156
3162
  ];
3157
3163
  /**
3158
3164
  * #172: makes `coolify:app` keyed {@link PLATFORM_AI_KEY} true -- the ai host
@@ -3228,9 +3234,13 @@ const PLATFORM_AI_WORKER_DEFAULTS = [
3228
3234
  * rotates it yet, so the platform loses its AI management key after 90 days.
3229
3235
  * - Deploys are decided from the recorded "pending" mark and the app's own
3230
3236
  * status, not from a deployment's age: the port cannot read one.
3231
- * - Provider keys (`KEYS_WORKER_URL`, `KEYS_CREDENTIAL`, `KEYS_VERIFY_KEYS`)
3232
- * and sign-in (`AUTH_*`) are not set: until an operator supplies them the
3233
- * host answers `503 not_configured` for provider work, as its README says.
3237
+ * - Sign-in (`AUTH_*`) is not set: until an operator supplies it the host
3238
+ * answers `503 not_configured` for sign-in, as its README says.
3239
+ * - `KEYS_WORKER_URL`, `KEYS_CREDENTIAL` and `KEYS_VERIFY_KEYS` come from
3240
+ * {@link PlatformAiDeps.keys} and are set on the host and the worker. They
3241
+ * gate more than provider work: ai builds the issuer behind
3242
+ * `POST /v1/platform/keys` only when all three are set, so without them
3243
+ * foundry's own management key can never be minted.
3234
3244
  */
3235
3245
  async function provisionPlatformAi(context, deps) {
3236
3246
  const { platform } = context;
@@ -3240,6 +3250,15 @@ async function provisionPlatformAi(context, deps) {
3240
3250
  const runtime = deps.runtime ?? {};
3241
3251
  const sleep = runtime.sleep ?? defaultSleep;
3242
3252
  const { coolify } = deps;
3253
+ const keys = aiKeys(deps);
3254
+ if (!keys) {
3255
+ throw new Error("platform:ai needs the keys worker URL, credential and verify keys in its ai dependencies (KEYS_WORKER_URL, KEYS_CREDENTIAL, KEYS_VERIFY_KEYS); ai refuses to mint foundry's management key without them");
3256
+ }
3257
+ const keysEnvs = [
3258
+ { key: 'KEYS_WORKER_URL', value: keys.workerUrl },
3259
+ { key: 'KEYS_CREDENTIAL', value: keys.credential },
3260
+ { key: 'KEYS_VERIFY_KEYS', value: keys.verifyKeys },
3261
+ ];
3243
3262
  const record = async (key, id) => {
3244
3263
  context.recordScopeId(key, id);
3245
3264
  await context.persist?.();
@@ -3324,10 +3343,12 @@ async function provisionPlatformAi(context, deps) {
3324
3343
  const runnerEnvs = [
3325
3344
  { key: 'DATABASE_URL', value: runtimeUrl },
3326
3345
  { key: 'APP_URL', value: `https://${hostname}` },
3346
+ ...keysEnvs,
3327
3347
  ...(await missingEnvs(coolify, runner.appId, PLATFORM_AI_RUNNER_DEFAULTS)),
3328
3348
  ];
3329
3349
  const workerEnvs = [
3330
3350
  { key: 'DATABASE_URL', value: runtimeUrl },
3351
+ ...keysEnvs,
3331
3352
  ...(await missingEnvs(coolify, worker.appId, PLATFORM_AI_WORKER_DEFAULTS)),
3332
3353
  ];
3333
3354
  // Whether anything the containers run on differs from what they last
@@ -3383,8 +3404,12 @@ async function provisionPlatformAi(context, deps) {
3383
3404
  }
3384
3405
  await coolify.raw('POST', `/applications/${migrate.appId}/stop`);
3385
3406
  }
3386
- if (deployRunner)
3387
- await coolify.deploy(runner.appId);
3407
+ // `deploy` only queues a build that takes minutes, so the id of the host's
3408
+ // deployment is kept for the mint below to wait on.
3409
+ const runnerDeployment = deployRunner ? await coolify.deploy(runner.appId) : undefined;
3410
+ if (deployRunner) {
3411
+ await record(PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY, queuedDeploymentUuid(runnerDeployment) ?? 'untracked');
3412
+ }
3388
3413
  if (deployWorker)
3389
3414
  await coolify.deploy(worker.appId);
3390
3415
  await record(PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, '');
@@ -3403,6 +3428,25 @@ async function provisionPlatformAi(context, deps) {
3403
3428
  if (lost) {
3404
3429
  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`);
3405
3430
  }
3431
+ // The host answers 503 (or nothing) until the deployment that carries its
3432
+ // management settings has finished.
3433
+ const queued = context.recordedIds()[PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY];
3434
+ if (queued) {
3435
+ try {
3436
+ await waitForAiDeployment(coolify, runner.appId, queued, runtime, sleep);
3437
+ }
3438
+ catch (error) {
3439
+ // Only a build still running is worth waiting on again. A deployment
3440
+ // that failed, was cancelled or cannot be read is not: mark the host
3441
+ // pending so the next apply queues a new one instead of polling it.
3442
+ if (!(error instanceof AiDeploymentTimeoutError)) {
3443
+ await record(PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, '1');
3444
+ await record(PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY, '');
3445
+ }
3446
+ throw error;
3447
+ }
3448
+ await record(PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY, '');
3449
+ }
3406
3450
  // #201: a test injects its own minter; without one, build the real one
3407
3451
  // over ai's own POST /v1/platform/keys, signed with the key pinned above.
3408
3452
  const minter = deps.managementKeyMinter ??
@@ -3440,8 +3484,9 @@ async function provisionPlatformAi(context, deps) {
3440
3484
  if (error instanceof AiManagementKeyUntrustedError) {
3441
3485
  throw new Error(`platform:ai: ${error.message}`);
3442
3486
  }
3443
- // The cause is dropped in case it echoes a credential.
3444
- 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');
3487
+ // The cause is dropped in case it echoes a credential; only the HTTP
3488
+ // status, or a network error's name and code, say why.
3489
+ throw new Error(`platform:ai: the AI management key could not be minted (${describeMintFailure(error)}). Nothing was recorded; apply again`);
3445
3490
  }
3446
3491
  const { credentialId, organisationId, secret } = minted;
3447
3492
  await record(PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, credentialId);
@@ -3530,6 +3575,70 @@ class MigrationNotHealthyError extends Error {
3530
3575
  this.lastStatus = lastStatus;
3531
3576
  }
3532
3577
  }
3578
+ /** The `deployment_uuid` in the `{deployments:[...]}` / array answer of Coolify's deploy call, if any. */
3579
+ function queuedDeploymentUuid(result) {
3580
+ const rows = Array.isArray(result)
3581
+ ? result
3582
+ : result?.deployments;
3583
+ const uuid = Array.isArray(rows)
3584
+ ? rows[0]?.deployment_uuid
3585
+ : undefined;
3586
+ return typeof uuid === 'string' && uuid ? uuid : undefined;
3587
+ }
3588
+ /**
3589
+ * Waits until the host's queued deployment has `finished`; throws on
3590
+ * `failed`/`cancelled-by-user` or once `migrateTimeoutMs` has passed. With no
3591
+ * deployment id (`untracked`) the only signal left is the app's own status,
3592
+ * which an old container can already satisfy, so that is best effort.
3593
+ */
3594
+ /** The host's deployment was still running when the wait gave up. */
3595
+ class AiDeploymentTimeoutError extends Error {
3596
+ }
3597
+ async function waitForAiDeployment(coolify, appId, deploymentUuid, runtime, sleep) {
3598
+ const now = runtime.now ?? Date.now;
3599
+ const timeout = runtime.migrateTimeoutMs ?? 600_000;
3600
+ const interval = runtime.migrateIntervalMs ?? 5_000;
3601
+ const deadline = now() + timeout;
3602
+ let status;
3603
+ for (;;) {
3604
+ await sleep(interval);
3605
+ if (deploymentUuid === 'untracked') {
3606
+ status = (await coolify.getApplication(appId)).status;
3607
+ if (status?.startsWith('running:healthy'))
3608
+ return;
3609
+ }
3610
+ else {
3611
+ const found = (await coolify.raw('GET', `/deployments/${deploymentUuid}`));
3612
+ status = typeof found?.status === 'string' ? found.status : undefined;
3613
+ if (status === 'finished')
3614
+ return;
3615
+ if (status === 'failed' || status === 'cancelled-by-user') {
3616
+ throw new Error(`platform:ai: the host's deployment for ${appId} ended ${status}; the AI management key was not minted. Check its build log in Coolify, then apply again`);
3617
+ }
3618
+ }
3619
+ if (now() >= deadline) {
3620
+ throw new AiDeploymentTimeoutError(`platform:ai: the host's deployment for ${appId} did not finish within ${Math.round(timeout / 1000)}s (last status ${status ?? 'unknown'}); the AI management key was not minted. Apply again`);
3621
+ }
3622
+ }
3623
+ }
3624
+ /**
3625
+ * Why a mint failed, safe to show: the status ai answered (the minter's own
3626
+ * fixed message), or a network error's `name` and `cause.code`. Never a
3627
+ * `.message` or body, which could echo a credential.
3628
+ */
3629
+ function describeMintFailure(error) {
3630
+ const status = /^ai's platform\/keys route answered (\d{3})$/.exec(error instanceof Error ? error.message : '')?.[1];
3631
+ if (status)
3632
+ return `ai answered HTTP ${status}`;
3633
+ if (error instanceof Error) {
3634
+ const code = error.cause?.code;
3635
+ // A plain `Error` is the minter's own (a malformed body), not the network.
3636
+ if (typeof code === 'string' || error.name !== 'Error') {
3637
+ return `network error ${error.name}${typeof code === 'string' ? ` ${code}` : ''}`;
3638
+ }
3639
+ }
3640
+ return 'the minter failed; its error is not shown';
3641
+ }
3533
3642
  /**
3534
3643
  * Polls the app until Coolify reports it stopped (`exited`/`stopped`), so a
3535
3644
  * healthy status read afterwards belongs to a deployment queued after this.
@@ -4127,6 +4236,10 @@ async function provisionPlatformFiles(context, deps) {
4127
4236
  await record(PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
4128
4237
  return { resourceId: runnerApp.appId };
4129
4238
  }
4239
+ function aiKeys(deps) {
4240
+ const keys = deps?.keys;
4241
+ return keys?.workerUrl && keys.credential && keys.verifyKeys ? keys : null;
4242
+ }
4130
4243
  function filesKeys(deps) {
4131
4244
  const keys = deps?.keys;
4132
4245
  return keys?.workerUrl && keys.credential ? keys : null;
@@ -1,3 +1,4 @@
1
+ import { BOULE_MANAGED_ENV_KEYS } from './boule.js';
1
2
  /**
2
3
  * A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
3
4
  * — the one place foundry records a provider's own id for an org today,
@@ -191,6 +192,24 @@ async function withAgoraEnvConfig(client, resource, recorded) {
191
192
  }
192
193
  return { ...resource, config: { ...resource.config, env } };
193
194
  }
195
+ /**
196
+ * #161: the org's own recorded Boule app's live sign-in env vars, as
197
+ * `config.env`, so `diff.ts`'s `planBoule` can compare them with what
198
+ * `executor.ts` would write. Gated and failing exactly like
199
+ * {@link withAgoraEnvConfig}.
200
+ */
201
+ async function withBouleEnvConfig(client, resource, recorded) {
202
+ if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('boule:')) {
203
+ return resource;
204
+ }
205
+ const managed = BOULE_MANAGED_ENV_KEYS;
206
+ const env = {};
207
+ for (const e of await client.listEnvs(resource.key)) {
208
+ if (!e.is_preview && managed.includes(e.key))
209
+ env[e.key] = e.value;
210
+ }
211
+ return { ...resource, config: { ...resource.config, env } };
212
+ }
194
213
  /** `@wtfalch/providers`' own `LiveResource` (`resource.ts`) has no `system` field — foundry's adds it at this boundary. */
195
214
  function toFoundryResources(system, resources) {
196
215
  return resources.map((r) => ({ system, kind: r.kind, key: r.key, config: r.config }));
@@ -221,7 +240,8 @@ export class ProvidersCoolifyReader {
221
240
  const withEnvNames = await Promise.all(withLogConfig.map((r) => withEnvNamesConfig(this.client, r, recorded)));
222
241
  const withDbUrl = await Promise.all(withEnvNames.map((r) => withDatabaseUrlConfig(this.client, r, recorded)));
223
242
  const withAgoraConfig = await Promise.all(withDbUrl.map((r) => withAgoraEnvConfig(this.client, r, recorded)));
224
- return applyRecordedIds(withAgoraConfig, recorded);
243
+ const withBouleConfig = await Promise.all(withAgoraConfig.map((r) => withBouleEnvConfig(this.client, r, recorded)));
244
+ return applyRecordedIds(withBouleConfig, recorded);
225
245
  }
226
246
  }
227
247
  /**
package/dist/slug.js CHANGED
@@ -128,6 +128,13 @@ const CALLS_SUFFIX = /-calls$/;
128
128
  * taken, not just one that turns a feature on.
129
129
  */
130
130
  const AGORA_SUFFIX = /-agora$/;
131
+ /**
132
+ * `<something>-boule` — a level-1 org's own Boule hostname
133
+ * (`<slug>-boule.oikos.host`, foundry#161). Every org gets a Boule, so like
134
+ * {@link AGORA_SUFFIX} this collides with every org's own hostname, not only
135
+ * one that opts in.
136
+ */
137
+ const BOULE_SUFFIX = /-boule$/;
131
138
  /**
132
139
  * Validates a candidate org slug against every rule #28 decided. Returns
133
140
  * `null` when the slug is acceptable, or a {@link SlugRejection} naming
@@ -151,10 +158,11 @@ export function validateSlug(slug) {
151
158
  if (STAGE_SUFFIX.test(slug) ||
152
159
  PR_SUFFIX.test(slug) ||
153
160
  CALLS_SUFFIX.test(slug) ||
154
- AGORA_SUFFIX.test(slug)) {
161
+ AGORA_SUFFIX.test(slug) ||
162
+ BOULE_SUFFIX.test(slug)) {
155
163
  return {
156
164
  reason: 'environment-suffix',
157
- message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls' or '-agora') reserved for another org's own hostname (#28, #145, #164) — 'acme-calls' is only legal as acme's calls hostname, 'acme-agora' only as acme's Agora hostname, never as an org slug in its own right`,
165
+ message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls', '-agora' or '-boule') reserved for another org's own hostname (#28, #145, #164, #161) — 'acme-calls' is only legal as acme's calls hostname, 'acme-agora' only as acme's Agora hostname, 'acme-boule' only as acme's Boule hostname, never as an org slug in its own right`,
158
166
  };
159
167
  }
160
168
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.16.4",
3
+ "version": "0.16.6",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",