@wtfalch/foundry 0.16.3 → 0.16.5

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
@@ -237,6 +237,8 @@ export interface CoolifyPort {
237
237
  name?: string;
238
238
  description?: string;
239
239
  instant_deploy?: boolean;
240
+ /** Extra `docker run` options (`--entrypoint "..."` becomes the compose entrypoint). */
241
+ custom_docker_run_options?: string;
240
242
  /**
241
243
  * `host:container`, comma separated -- publishes host ports directly
242
244
  * (`@wtfalch/providers`' `CoolifyClient.createDockerImageApplication`
@@ -1377,6 +1379,14 @@ export declare function getDatabaseStarted(coolify: {
1377
1379
  internal_db_url?: string;
1378
1380
  status?: string;
1379
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
+ };
1380
1390
  export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer {
1381
1391
  private readonly ports;
1382
1392
  constructor(ports: FoundryStepExecutorPorts);
@@ -1603,6 +1613,14 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1603
1613
  * broker would drop.
1604
1614
  */
1605
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;
1606
1624
  /**
1607
1625
  * `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
1608
1626
  * only the first time (task brief), never regenerated or overwritten, so a
@@ -1996,6 +2014,30 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1996
2014
  * not the host values).
1997
2015
  */
1998
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;
1999
2041
  /**
2000
2042
  * `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with
2001
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);
@@ -46,6 +46,15 @@ export declare const PLATFORM_AUTH_KEY = "platform:auth";
46
46
  * that was never actually applied.
47
47
  */
48
48
  export declare const PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY = "auth_zitadel_applied_image_tag";
49
+ /**
50
+ * The image has `Entrypoint /app/zitadel` and no `Cmd`, so without this it
51
+ * runs zitadel with no subcommand, exits, and restarts forever. Coolify turns
52
+ * `--entrypoint` in `custom_docker_run_options` into the compose `entrypoint`
53
+ * (its PATCH takes this field; it refuses `dockerfile`).
54
+ */
55
+ export declare const AUTH_ZITADEL_RUN_OPTIONS = "--entrypoint \"/app/zitadel start-from-init --masterkeyFromEnv --tlsMode external\"";
56
+ /** The run options last applied to the ZITADEL app (value: the options string), so a changed value forces one redeploy. */
57
+ export declare const PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY = "zitadel_platform_auth_run_options_applied";
49
58
  /**
50
59
  * #251: the Synapse/MAS image tags an org's calls Matrix stack last
51
60
  * successful apply actually set live. `coolifyCalls` (`executor.ts`)
@@ -41,6 +41,15 @@ export const PLATFORM_AUTH_KEY = 'platform:auth';
41
41
  * that was never actually applied.
42
42
  */
43
43
  export const PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY = 'auth_zitadel_applied_image_tag';
44
+ /**
45
+ * The image has `Entrypoint /app/zitadel` and no `Cmd`, so without this it
46
+ * runs zitadel with no subcommand, exits, and restarts forever. Coolify turns
47
+ * `--entrypoint` in `custom_docker_run_options` into the compose `entrypoint`
48
+ * (its PATCH takes this field; it refuses `dockerfile`).
49
+ */
50
+ export const AUTH_ZITADEL_RUN_OPTIONS = '--entrypoint "/app/zitadel start-from-init --masterkeyFromEnv --tlsMode external"';
51
+ /** The run options last applied to the ZITADEL app (value: the options string), so a changed value forces one redeploy. */
52
+ export const PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY = 'zitadel_platform_auth_run_options_applied';
44
53
  /**
45
54
  * #251: the Synapse/MAS image tags an org's calls Matrix stack last
46
55
  * successful apply actually set live. `coolifyCalls` (`executor.ts`)
@@ -753,8 +762,15 @@ function planJobs(platform, byKey, claimed, observable) {
753
762
  claimed.add(key);
754
763
  if (!platform.jobs.enabled)
755
764
  return { step: null, blocked: null };
756
- if (live)
757
- return { step: null, blocked: null };
765
+ if (live) {
766
+ // The step also starts the scheduler's database (0.16.3), so a live app
767
+ // whose database the reader saw stopped plans it again. A status the
768
+ // reader could not read is absent, and plans nothing.
769
+ const databaseStatus = live.config.databaseStatus;
770
+ if (typeof databaseStatus !== 'string' || databaseStatus.startsWith('running')) {
771
+ return { step: null, blocked: null };
772
+ }
773
+ }
758
774
  // No Coolify reader means the host wired no Coolify at all for the
759
775
  // platform: the step's executor would throw every apply, and the scheduler
760
776
  // could never be seen live, so it would re-plan forever. A fixed text: it
@@ -1103,7 +1119,10 @@ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authD
1103
1119
  connectorApplied &&
1104
1120
  brokerApplied &&
1105
1121
  brokerDeployed &&
1106
- loginClientApplied) {
1122
+ loginClientApplied &&
1123
+ // The ZITADEL run options reach the container only through the auth
1124
+ // step's redeploy, so a platform recorded before them still plans it.
1125
+ recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS) {
1107
1126
  return { step: null, blocked: null };
1108
1127
  }
1109
1128
  // The admin key was minted but never reached the host. ZITADEL shows a
@@ -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`. */
@@ -770,7 +784,7 @@ export declare class ProvidersPlatformCloudflareReader implements PlatformCloudf
770
784
  export declare class ProvidersPlatformCoolifyReader implements PlatformCoolifyReader {
771
785
  private readonly client;
772
786
  private readonly platformStore;
773
- constructor(client: Pick<CoolifyPort, 'getApplication' | 'listDatabaseBackups'>, platformStore: Pick<PlatformStore, 'getServiceIds'>);
787
+ constructor(client: Pick<CoolifyPort, 'getApplication' | 'getDatabase' | 'listDatabaseBackups'>, platformStore: Pick<PlatformStore, 'getServiceIds'>);
774
788
  listResources(): Promise<LiveResource[]>;
775
789
  }
776
790
  export {};
@@ -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 { 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_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_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 &&
@@ -871,6 +875,7 @@ async function provisionPlatformAuth(context, deps) {
871
875
  docker_registry_image_name: AUTH_ZITADEL_IMAGE,
872
876
  docker_registry_image_tag: AUTH_ZITADEL_IMAGE_TAG,
873
877
  ports_exposes: AUTH_ZITADEL_PORT,
878
+ custom_docker_run_options: AUTH_ZITADEL_RUN_OPTIONS,
874
879
  name: PLATFORM_AUTH_ZITADEL_APP_NAME,
875
880
  description: `ZITADEL for the platform's own sign-in (#168, ${AUTH_ZITADEL_IMAGE}:${AUTH_ZITADEL_IMAGE_TAG})`,
876
881
  instant_deploy: false,
@@ -882,9 +887,14 @@ async function provisionPlatformAuth(context, deps) {
882
887
  // create must not leave the app unnamed for good, and the tunnel addresses
883
888
  // the app by this name. (The port cannot read the current name back, and
884
889
  // setting the same value again is harmless.)
890
+ // The run options go in the same patch, so an app made before they existed
891
+ // gets them too.
885
892
  await deps.coolify.updateApplication(zitadelAppId, {
886
893
  custom_internal_name: PLATFORM_AUTH_ZITADEL_INTERNAL_NAME,
894
+ custom_docker_run_options: AUTH_ZITADEL_RUN_OPTIONS,
887
895
  });
896
+ // The container only takes new run options at its next deploy.
897
+ const zitadelRunOptionsNew = recorded[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] !== AUTH_ZITADEL_RUN_OPTIONS;
888
898
  // #251: only on create, never unconditionally -- nothing here patches
889
899
  // `docker_registry_image_tag` on an app `findOrCreatePlatformApp` found
890
900
  // already running, so that is the only moment this tag is actually live.
@@ -1012,9 +1022,12 @@ async function provisionPlatformAuth(context, deps) {
1012
1022
  await deps.coolify.bulkUpsertEnvs(brokerAppId, brokerEnvs);
1013
1023
  // Deploy an app that was just created, is not running (a throw after the
1014
1024
  // create left it never deployed, or it stopped), or whose envs changed.
1015
- if (zitadelApp.justCreated || !zitadelApp.running || zitadelEnvsChanged) {
1025
+ if (zitadelApp.justCreated || !zitadelApp.running || zitadelEnvsChanged || zitadelRunOptionsNew) {
1016
1026
  await deps.coolify.deploy(zitadelAppId);
1017
1027
  await record(PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY, zitadelAppId);
1028
+ // Only now: the options are live once the deploy went out. A throw between
1029
+ // the PATCH and here leaves the marker unset, so the next run deploys.
1030
+ await record(PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, AUTH_ZITADEL_RUN_OPTIONS);
1018
1031
  }
1019
1032
  if (loginApp.justCreated || !loginApp.running || loginEnvsChanged) {
1020
1033
  await deps.coolify.deploy(loginAppId);
@@ -3219,9 +3232,13 @@ const PLATFORM_AI_WORKER_DEFAULTS = [
3219
3232
  * rotates it yet, so the platform loses its AI management key after 90 days.
3220
3233
  * - Deploys are decided from the recorded "pending" mark and the app's own
3221
3234
  * status, not from a deployment's age: the port cannot read one.
3222
- * - Provider keys (`KEYS_WORKER_URL`, `KEYS_CREDENTIAL`, `KEYS_VERIFY_KEYS`)
3223
- * and sign-in (`AUTH_*`) are not set: until an operator supplies them the
3224
- * host answers `503 not_configured` for provider work, as its README says.
3235
+ * - Sign-in (`AUTH_*`) is not set: until an operator supplies it the host
3236
+ * answers `503 not_configured` for sign-in, as its README says.
3237
+ * - `KEYS_WORKER_URL`, `KEYS_CREDENTIAL` and `KEYS_VERIFY_KEYS` come from
3238
+ * {@link PlatformAiDeps.keys} and are set on the host and the worker. They
3239
+ * gate more than provider work: ai builds the issuer behind
3240
+ * `POST /v1/platform/keys` only when all three are set, so without them
3241
+ * foundry's own management key can never be minted.
3225
3242
  */
3226
3243
  async function provisionPlatformAi(context, deps) {
3227
3244
  const { platform } = context;
@@ -3231,6 +3248,15 @@ async function provisionPlatformAi(context, deps) {
3231
3248
  const runtime = deps.runtime ?? {};
3232
3249
  const sleep = runtime.sleep ?? defaultSleep;
3233
3250
  const { coolify } = deps;
3251
+ const keys = aiKeys(deps);
3252
+ if (!keys) {
3253
+ 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");
3254
+ }
3255
+ const keysEnvs = [
3256
+ { key: 'KEYS_WORKER_URL', value: keys.workerUrl },
3257
+ { key: 'KEYS_CREDENTIAL', value: keys.credential },
3258
+ { key: 'KEYS_VERIFY_KEYS', value: keys.verifyKeys },
3259
+ ];
3234
3260
  const record = async (key, id) => {
3235
3261
  context.recordScopeId(key, id);
3236
3262
  await context.persist?.();
@@ -3315,10 +3341,12 @@ async function provisionPlatformAi(context, deps) {
3315
3341
  const runnerEnvs = [
3316
3342
  { key: 'DATABASE_URL', value: runtimeUrl },
3317
3343
  { key: 'APP_URL', value: `https://${hostname}` },
3344
+ ...keysEnvs,
3318
3345
  ...(await missingEnvs(coolify, runner.appId, PLATFORM_AI_RUNNER_DEFAULTS)),
3319
3346
  ];
3320
3347
  const workerEnvs = [
3321
3348
  { key: 'DATABASE_URL', value: runtimeUrl },
3349
+ ...keysEnvs,
3322
3350
  ...(await missingEnvs(coolify, worker.appId, PLATFORM_AI_WORKER_DEFAULTS)),
3323
3351
  ];
3324
3352
  // Whether anything the containers run on differs from what they last
@@ -4118,6 +4146,10 @@ async function provisionPlatformFiles(context, deps) {
4118
4146
  await record(PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
4119
4147
  return { resourceId: runnerApp.appId };
4120
4148
  }
4149
+ function aiKeys(deps) {
4150
+ const keys = deps?.keys;
4151
+ return keys?.workerUrl && keys.credential && keys.verifyKeys ? keys : null;
4152
+ }
4121
4153
  function filesKeys(deps) {
4122
4154
  const keys = deps?.keys;
4123
4155
  return keys?.workerUrl && keys.credential ? keys : null;
@@ -5029,11 +5061,27 @@ export class ProvidersPlatformCoolifyReader {
5029
5061
  if (recordedAppId) {
5030
5062
  try {
5031
5063
  const app = await this.client.getApplication(recordedAppId);
5064
+ // The scheduler's database status, so `planJobs` can see a stopped
5065
+ // one. Left out when it cannot be read: unknown is not "stopped".
5066
+ let databaseStatus;
5067
+ const databaseId = recorded[JOBS_DATABASE_SCOPE_KEY];
5068
+ if (databaseId) {
5069
+ try {
5070
+ databaseStatus = (await this.client.getDatabase(databaseId)).status;
5071
+ }
5072
+ catch {
5073
+ databaseStatus = undefined;
5074
+ }
5075
+ }
5032
5076
  resources.push({
5033
5077
  system: 'coolify',
5034
5078
  kind: 'jobs',
5035
5079
  key: JOBS_SCHEDULER_KEY,
5036
- config: { uuid: app.uuid, status: app.status },
5080
+ config: {
5081
+ uuid: app.uuid,
5082
+ status: app.status,
5083
+ ...(databaseStatus !== undefined && { databaseStatus }),
5084
+ },
5037
5085
  });
5038
5086
  }
5039
5087
  catch {
@@ -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.3",
3
+ "version": "0.16.5",
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",