@wtfalch/foundry 0.60.0 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/dist/apps/cloudflare.d.ts +173 -0
  2. package/dist/apps/cloudflare.js +17 -0
  3. package/dist/apps/deploy.d.ts +116 -0
  4. package/dist/apps/deploy.js +791 -0
  5. package/dist/apps/github.d.ts +47 -0
  6. package/dist/apps/github.js +201 -0
  7. package/dist/apps/index.d.ts +9 -0
  8. package/dist/apps/index.js +9 -0
  9. package/dist/apps/migrations.d.ts +110 -0
  10. package/dist/apps/migrations.js +458 -0
  11. package/dist/audit/connector.d.ts +43 -0
  12. package/dist/audit/connector.js +22 -0
  13. package/dist/audit/evidence.d.ts +49 -0
  14. package/dist/audit/evidence.js +56 -0
  15. package/dist/audit/index.d.ts +9 -0
  16. package/dist/audit/index.js +9 -0
  17. package/dist/audit/migrations.d.ts +5 -0
  18. package/dist/audit/migrations.js +5 -0
  19. package/dist/audit/registry.d.ts +49 -0
  20. package/dist/audit/registry.js +44 -0
  21. package/dist/audit/seal.d.ts +49 -0
  22. package/dist/audit/seal.js +131 -0
  23. package/dist/audit/sealer-role.d.ts +33 -0
  24. package/dist/audit/sealer-role.js +56 -0
  25. package/dist/audit/signer.d.ts +40 -0
  26. package/dist/audit/signer.js +78 -0
  27. package/dist/audit/tick.d.ts +36 -0
  28. package/dist/audit/tick.js +87 -0
  29. package/dist/catalogue/copy-en.d.ts +1 -0
  30. package/dist/catalogue/copy-en.js +129 -0
  31. package/dist/catalogue/index.d.ts +9 -0
  32. package/dist/catalogue/index.js +21 -0
  33. package/dist/catalogue/integrations.d.ts +4 -0
  34. package/dist/catalogue/integrations.js +104 -0
  35. package/dist/catalogue/systems.d.ts +7 -0
  36. package/dist/catalogue/systems.js +31 -0
  37. package/dist/catalogue/types.d.ts +110 -0
  38. package/dist/catalogue/types.js +1 -0
  39. package/dist/entry-rules.d.ts +53 -0
  40. package/dist/entry-rules.js +71 -0
  41. package/dist/hosted-label.d.ts +13 -0
  42. package/dist/hosted-label.js +81 -0
  43. package/dist/index.d.ts +14 -5
  44. package/dist/index.js +7 -3
  45. package/dist/migrations/0062_hosting_github.sql +6 -0
  46. package/dist/migrations/0063_boule_apps.sql +7 -0
  47. package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
  48. package/dist/migrations/0065_native_build_runs.sql +32 -0
  49. package/dist/migrations/0066_org_kosmos.sql +10 -0
  50. package/dist/modules/ai.d.ts +3 -0
  51. package/dist/modules/ai.js +64 -0
  52. package/dist/modules/assistants.d.ts +3 -0
  53. package/dist/modules/assistants.js +72 -0
  54. package/dist/modules/audit-ledger.d.ts +29 -0
  55. package/dist/modules/audit-ledger.js +49 -0
  56. package/dist/modules/audit.d.ts +7 -0
  57. package/dist/modules/audit.js +44 -0
  58. package/dist/modules/database.d.ts +23 -0
  59. package/dist/modules/database.js +189 -0
  60. package/dist/modules/email.d.ts +3 -0
  61. package/dist/modules/email.js +18 -0
  62. package/dist/modules/entry-parts.d.ts +23 -0
  63. package/dist/modules/entry-parts.js +63 -0
  64. package/dist/modules/files.d.ts +3 -0
  65. package/dist/modules/files.js +64 -0
  66. package/dist/modules/index.d.ts +74 -0
  67. package/dist/modules/index.js +774 -0
  68. package/dist/modules/jobs.d.ts +3 -0
  69. package/dist/modules/jobs.js +23 -0
  70. package/dist/modules/organisations.d.ts +3 -0
  71. package/dist/modules/organisations.js +20 -0
  72. package/dist/modules/outputs.d.ts +9 -0
  73. package/dist/modules/outputs.js +38 -0
  74. package/dist/modules/sign-in.d.ts +3 -0
  75. package/dist/modules/sign-in.js +197 -0
  76. package/dist/modules/signed-records.d.ts +3 -0
  77. package/dist/modules/signed-records.js +32 -0
  78. package/dist/modules/types.d.ts +194 -0
  79. package/dist/modules/types.js +1 -0
  80. package/dist/modules/workspaces.d.ts +3 -0
  81. package/dist/modules/workspaces.js +75 -0
  82. package/dist/native-build.d.ts +70 -0
  83. package/dist/native-build.js +92 -0
  84. package/dist/org.d.ts +184 -24
  85. package/dist/org.js +444 -49
  86. package/dist/plan/agora.d.ts +1 -1
  87. package/dist/plan/agora.js +4 -0
  88. package/dist/plan/apps-db-route.d.ts +90 -0
  89. package/dist/plan/apps-db-route.js +168 -0
  90. package/dist/plan/apps-zones.d.ts +56 -0
  91. package/dist/plan/apps-zones.js +118 -0
  92. package/dist/plan/archon-blue-green.js +11 -1
  93. package/dist/plan/blue-green-apply.d.ts +37 -1
  94. package/dist/plan/blue-green-apply.js +55 -15
  95. package/dist/plan/blue-green.d.ts +60 -8
  96. package/dist/plan/blue-green.js +115 -10
  97. package/dist/plan/boule.d.ts +23 -1
  98. package/dist/plan/boule.js +35 -2
  99. package/dist/plan/broker-entries.d.ts +28 -3
  100. package/dist/plan/broker-entries.js +74 -3
  101. package/dist/plan/broker-registrar.d.ts +168 -0
  102. package/dist/plan/broker-registrar.js +440 -0
  103. package/dist/plan/broker-workspaces-database.d.ts +48 -0
  104. package/dist/plan/broker-workspaces-database.js +87 -0
  105. package/dist/plan/deploy-health.js +9 -2
  106. package/dist/plan/desktop-callback.js +15 -7
  107. package/dist/plan/diff.d.ts +8 -2
  108. package/dist/plan/diff.js +552 -104
  109. package/dist/plan/edge-access.d.ts +6 -0
  110. package/dist/plan/edge-access.js +1 -0
  111. package/dist/plan/edge-dns.d.ts +6 -0
  112. package/dist/plan/edge-dns.js +9 -7
  113. package/dist/plan/entry-ai-key.d.ts +1 -1
  114. package/dist/plan/entry-ai-key.js +2 -2
  115. package/dist/plan/entry-audit.d.ts +58 -0
  116. package/dist/plan/entry-audit.js +122 -0
  117. package/dist/plan/entry-customer-grants.d.ts +75 -0
  118. package/dist/plan/entry-customer-grants.js +154 -0
  119. package/dist/plan/entry-edge-key.d.ts +26 -1
  120. package/dist/plan/entry-edge-key.js +42 -3
  121. package/dist/plan/entry-jobs.d.ts +64 -0
  122. package/dist/plan/entry-jobs.js +92 -0
  123. package/dist/plan/entry-repo-key-apply.d.ts +55 -0
  124. package/dist/plan/entry-repo-key-apply.js +185 -0
  125. package/dist/plan/entry-repo-key.d.ts +92 -0
  126. package/dist/plan/entry-repo-key.js +235 -0
  127. package/dist/plan/entry-workspaces.d.ts +55 -0
  128. package/dist/plan/entry-workspaces.js +90 -0
  129. package/dist/plan/executor.d.ts +231 -19
  130. package/dist/plan/executor.js +1648 -129
  131. package/dist/plan/gates.d.ts +1 -1
  132. package/dist/plan/gates.js +62 -9
  133. package/dist/plan/github-deploy-keys.d.ts +78 -0
  134. package/dist/plan/github-deploy-keys.js +305 -0
  135. package/dist/plan/github-source.d.ts +53 -0
  136. package/dist/plan/github-source.js +140 -0
  137. package/dist/plan/held-key-refs.d.ts +12 -0
  138. package/dist/plan/held-key-refs.js +12 -0
  139. package/dist/plan/hostnames.d.ts +9 -0
  140. package/dist/plan/hostnames.js +28 -1
  141. package/dist/plan/index.d.ts +9 -0
  142. package/dist/plan/index.js +6 -0
  143. package/dist/plan/jobs-scheduler-http.js +3 -0
  144. package/dist/plan/keys-registrar-http.d.ts +32 -0
  145. package/dist/plan/keys-registrar-http.js +105 -0
  146. package/dist/plan/keys-registrar.d.ts +67 -0
  147. package/dist/plan/keys-registrar.js +103 -0
  148. package/dist/plan/kosmos.d.ts +56 -0
  149. package/dist/plan/kosmos.js +84 -0
  150. package/dist/plan/live.d.ts +1 -1
  151. package/dist/plan/locked-hostnames.d.ts +2 -2
  152. package/dist/plan/locked-hostnames.js +7 -4
  153. package/dist/plan/login-host.js +11 -5
  154. package/dist/plan/machine-paths.d.ts +36 -0
  155. package/dist/plan/machine-paths.js +137 -0
  156. package/dist/plan/mcp.d.ts +1 -1
  157. package/dist/plan/mcp.js +49 -17
  158. package/dist/plan/org-apps-admin.d.ts +108 -0
  159. package/dist/plan/org-apps-admin.js +434 -0
  160. package/dist/plan/org-names.d.ts +3 -3
  161. package/dist/plan/org-names.js +15 -2
  162. package/dist/plan/org-postgres-migrate.js +1 -0
  163. package/dist/plan/own-diff.d.ts +23 -0
  164. package/dist/plan/own-diff.js +53 -0
  165. package/dist/plan/own-hostnames.d.ts +2 -0
  166. package/dist/plan/own-hostnames.js +1 -0
  167. package/dist/plan/own-jobs-route.d.ts +47 -0
  168. package/dist/plan/own-jobs-route.js +91 -0
  169. package/dist/plan/own-limits.d.ts +6 -0
  170. package/dist/plan/own-limits.js +1 -0
  171. package/dist/plan/own-services.d.ts +4 -0
  172. package/dist/plan/own-services.js +4 -0
  173. package/dist/plan/plan-apply.d.ts +39 -0
  174. package/dist/plan/plan-apply.js +163 -9
  175. package/dist/plan/platform-diff.d.ts +2 -2
  176. package/dist/plan/platform-diff.js +7 -2
  177. package/dist/plan/platform-org.d.ts +30 -0
  178. package/dist/plan/platform-org.js +12 -1
  179. package/dist/plan/platform-providers.d.ts +2 -0
  180. package/dist/plan/platform-providers.js +13 -2
  181. package/dist/plan/post-logout-uris.d.ts +1 -1
  182. package/dist/plan/post-logout-uris.js +12 -3
  183. package/dist/plan/private-pages.d.ts +26 -0
  184. package/dist/plan/private-pages.js +39 -0
  185. package/dist/plan/providers-readers.js +20 -1
  186. package/dist/plan/proxy-labels.d.ts +12 -1
  187. package/dist/plan/proxy-labels.js +32 -2
  188. package/dist/plan/release-host.d.ts +6 -2
  189. package/dist/plan/release-host.js +36 -8
  190. package/dist/plan/stage-env.d.ts +19 -1
  191. package/dist/plan/stage-env.js +25 -1
  192. package/dist/plan/stage-guards.d.ts +20 -0
  193. package/dist/plan/stage-guards.js +24 -0
  194. package/dist/plan/stage-isolation.d.ts +24 -0
  195. package/dist/plan/stage-isolation.js +29 -1
  196. package/dist/plan/worker-app-database.d.ts +46 -42
  197. package/dist/plan/worker-app-database.js +150 -153
  198. package/dist/plan/worker-app.d.ts +5 -65
  199. package/dist/plan/worker-app.js +28 -105
  200. package/dist/plan/worker.d.ts +2 -2
  201. package/dist/plan/worker.js +2 -1
  202. package/dist/plan/workspace-client.d.ts +25 -0
  203. package/dist/plan/workspace-client.js +48 -0
  204. package/dist/plan/workspace-sign-in.d.ts +55 -0
  205. package/dist/plan/workspace-sign-in.js +273 -0
  206. package/dist/plan/workspace-worker.d.ts +4 -0
  207. package/dist/plan/workspace-worker.js +13 -0
  208. package/dist/plan/workspace.d.ts +101 -0
  209. package/dist/plan/workspace.js +305 -0
  210. package/dist/release/decide.d.ts +30 -1
  211. package/dist/release/decide.js +73 -11
  212. package/dist/release/health.d.ts +8 -3
  213. package/dist/release/health.js +58 -5
  214. package/dist/release/migration-compatibility.d.ts +66 -0
  215. package/dist/release/migration-compatibility.js +340 -0
  216. package/dist/release/run.d.ts +2 -5
  217. package/dist/release/run.js +4 -0
  218. package/dist/release/state.d.ts +3 -1
  219. package/dist/release/state.js +6 -0
  220. package/dist/release/types.d.ts +20 -0
  221. package/dist/requested-changes.d.ts +11 -2
  222. package/dist/requested-changes.js +13 -1
  223. package/dist/service-labels.d.ts +4 -0
  224. package/dist/service-labels.js +11 -0
  225. package/dist/slug.d.ts +2 -0
  226. package/dist/slug.js +20 -7
  227. package/dist/store/postgres.js +25 -2
  228. package/dist/store/schema.d.ts +73 -1
  229. package/dist/store/schema.js +10 -0
  230. package/dist/worker-app-config.d.ts +1 -11
  231. package/dist/worker-app-config.js +4 -17
  232. package/dist/worker-app-migrations.d.ts +105 -0
  233. package/dist/worker-app-migrations.js +166 -0
  234. package/dist/worker-app-upload.d.ts +103 -0
  235. package/dist/worker-app-upload.js +128 -0
  236. package/package.json +19 -2
@@ -7,10 +7,11 @@ import { waitForNewDeployment } from './deploy-health.js';
7
7
  import { commandHealthCheckSettings, wgetProbe } from './health-check-settings.js';
8
8
  import { ENTRY_CONTAINER_LABELS } from './proxy-labels.js';
9
9
  import { remoteDatabaseAddress, remoteDatabaseHost } from './remote-database.js';
10
- import { buildStageEnv } from './stage-env.js';
10
+ import { STAGE_SIGN_IN_NAMES, buildStageEnv } from './stage-env.js';
11
11
  import { buildStageProbes, runStageProbes, stageProbesRecord, } from './stage-guards.js';
12
12
  import { assertStageIsolated, envClass, stageIsolationHash, } from './stage-isolation.js';
13
13
  import { STAGE_SINK_APPLIED_SCOPE_KEY, STAGE_SINK_APP_SCOPE_KEY, STAGE_SINK_HTTP_PORT, STAGE_SINK_IMAGE_TAG, STAGE_SINK_REPOSITORY, stageMailEnv, stageSinkAppName, } from './stage-sink.js';
14
+ import { readWorkspaceCommit, wsKey } from './workspace.js';
14
15
  /** How long one Coolify call of the sink may take. */
15
16
  const SINK_CALL_TIMEOUT_MS = 30_000;
16
17
  /** Env names whose value is a credential: registered before they are copied. */
@@ -97,6 +98,30 @@ export async function applyBlueGreenServer(deps, subject, server) {
97
98
  deps.record(bgKey.applied(id, server.uuid), structureHash(subject, { primary: isPrimary }));
98
99
  await deps.persist();
99
100
  }
101
+ /**
102
+ * Applies a server workspace on the primary: the stage mail sink, then the stage app with its own empty
103
+ * database, the stage guards and the gate, exactly as a blue/green stage, and no colors. The commit the stage
104
+ * deployed is recorded as `ws.<id>.commit` for the live app to pin to. A failure halfway leaves the sink, app
105
+ * and database already made, recorded by id; the next run finds them and does the rest. Stage fails closed for
106
+ * itself: a refused guard records `bg.<id>.stagestate` and writes nothing to the stage app.
107
+ */
108
+ export async function applyWorkspaceServer(deps, subject, serverUuid) {
109
+ const sinkReady = await ensureStageSink(deps, subject, serverUuid);
110
+ await ensureStage(deps, subject, serverUuid);
111
+ if (!sinkReady) {
112
+ deps.record(bgKey.stageState(subject.id), 'blocked:sink');
113
+ await deps.persist();
114
+ }
115
+ const stageId = deps.recorded()[bgKey.app(subject.id, serverUuid, 'stage')];
116
+ if (stageId && deps.recorded()[bgKey.stageState(subject.id)] === 'ok') {
117
+ const commit = (await readWorkspaceCommit(deps.coolify, stageId)) ??
118
+ deps.recorded()[bgKey.stage(subject.id)];
119
+ if (commit) {
120
+ deps.record(wsKey.commit(subject.id), commit);
121
+ await deps.persist();
122
+ }
123
+ }
124
+ }
100
125
  /** Writes the balancer's config with the recorded weights and records them for this server. */
101
126
  export async function applyBlueGreenWeights(deps, subject, server) {
102
127
  const weights = parseWeights(deps.recorded()[bgKey.weights(subject.id)]);
@@ -109,15 +134,14 @@ export async function applyBlueGreenWeights(deps, subject, server) {
109
134
  }
110
135
  async function writeLbConfig(deps, subject, server, lbId, weights) {
111
136
  const onPrimary = server.isPrimary;
112
- let pinKey = deps.recorded()[bgKey.pinKey(subject.id)];
113
- if (!pinKey) {
114
- pinKey = randomBytes(32).toString('hex');
115
- deps.record(bgKey.pinKey(subject.id), pinKey);
116
- await deps.persist();
117
- }
137
+ const pinKey = await deps.pinKey();
138
+ // The idle slot is the one `bg.<id>.live` does not name; the preview router follows it on every weight write.
139
+ const idle = deps.recorded()[bgKey.live(subject.id)] === 'green' ? 'blue' : 'green';
118
140
  const content = renderLbConfig({
119
141
  weights,
120
142
  pinKey,
143
+ idle,
144
+ reporting: subject.reporting,
121
145
  backends: {
122
146
  blue: [colorBackend(subject, 'blue', onPrimary)],
123
147
  green: [colorBackend(subject, 'green', onPrimary)],
@@ -211,14 +235,16 @@ async function ensureGitApp(deps, subject, input) {
211
235
  await deps.persist();
212
236
  return found.uuid;
213
237
  }
214
- const githubApp = await coolify.requireGithubApp();
238
+ const githubAppUuid = deps.githubAppUuid
239
+ ? await deps.githubAppUuid(subject.repo)
240
+ : (await coolify.requireGithubApp()).uuid;
215
241
  const created = await coolify.createGithubAppApplication({
216
242
  is_preview_deployments_enabled: false,
217
243
  project_uuid: deps.projectUuid,
218
244
  server_uuid: input.server,
219
245
  environment_name: deps.environment.name,
220
246
  environment_uuid: deps.environment.uuid,
221
- github_app_uuid: githubApp.uuid,
247
+ github_app_uuid: githubAppUuid,
222
248
  git_repository: subject.repo,
223
249
  git_branch: input.branch,
224
250
  build_pack: subject.buildPack,
@@ -319,7 +345,7 @@ async function ensureLoadBalancer(deps, subject, serverUuid, name, isPrimary) {
319
345
  * the sink deploys; a failure clears it, logs fixed text and the error name, and returns false. Production's
320
346
  * structure goes on (stage then holds no mail env and the copy stays refused).
321
347
  */
322
- async function ensureStageSink(deps, subject, serverUuid) {
348
+ export async function ensureStageSink(deps, subject, serverUuid) {
323
349
  const org = deps.stageSink;
324
350
  if (!org)
325
351
  return true;
@@ -377,7 +403,7 @@ async function ensureStageSink(deps, subject, serverUuid) {
377
403
  return true;
378
404
  }
379
405
  /** The stage app and its own empty database, on the primary. Its env comes from the allowlist, never from a copy of the live app's. */
380
- async function ensureStage(deps, subject, serverUuid) {
406
+ export async function ensureStage(deps, subject, serverUuid) {
381
407
  const { coolify } = deps;
382
408
  const name = stageAppName(subject.base);
383
409
  const appId = await stageAppId(deps, subject, name, serverUuid);
@@ -419,12 +445,12 @@ async function ensureStage(deps, subject, serverUuid) {
419
445
  }
420
446
  }
421
447
  const kind = stageKindOf(subject);
422
- const prodEnv = await coolify.listEnvs(deps.primaryBlueAppId);
448
+ const prodEnv = deps.primaryBlueAppId ? await coolify.listEnvs(deps.primaryBlueAppId) : [];
423
449
  const greenId = deps.recorded()[bgKey.app(subject.id, serverUuid, 'green')];
424
450
  const greenEnv = greenId ? await coolify.listEnvs(greenId) : [];
425
451
  const { env } = buildStageEnv({
426
452
  kind,
427
- appUrl: stageAppUrl(subject),
453
+ appUrl: deps.stageAppUrl ?? stageAppUrl(subject),
428
454
  logDir: deps.logDir,
429
455
  database,
430
456
  prod: prodEnv,
@@ -434,8 +460,14 @@ async function ensureStage(deps, subject, serverUuid) {
434
460
  deps.secret(value);
435
461
  return value;
436
462
  },
463
+ ...(deps.signIn ? { signIn: deps.signIn } : {}),
437
464
  });
438
465
  const stageEnv = [...env];
466
+ const signInEnv = deps.signIn ? env.filter((e) => STAGE_SIGN_IN_NAMES.includes(e.key)) : [];
467
+ // A secret kept from an earlier apply was not minted by this run, so it is registered here.
468
+ for (const e of signInEnv)
469
+ if (envClass(e.key) === 'secret')
470
+ deps.secret(e.value);
439
471
  if (subject.reporting && deps.actionsKey) {
440
472
  // Stage never shares a production secret: its key is its own, minted once and kept.
441
473
  const have = (await coolify.listEnvs(appId)).find((e) => e.key === ACTIONS_KEY_ENV && !e.is_preview);
@@ -447,7 +479,7 @@ async function ensureStage(deps, subject, serverUuid) {
447
479
  const sinkApplied = deps.stageSink && deps.recorded()[STAGE_SINK_APPLIED_SCOPE_KEY];
448
480
  const mail = deps.stageSink && sinkApplied ? stageMailEnv(deps.stageSink) : [];
449
481
  const stageHas = new Map((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
450
- const mailChanged = mail.some((e) => stageHas.get(e.key) !== e.value);
482
+ const mailChanged = [...mail, ...signInEnv].some((e) => stageHas.get(e.key) !== e.value);
451
483
  stageEnv.push(...mail);
452
484
  const stageSurface = {
453
485
  env: stageEnv.map((e) => ({ name: e.key, class: envClass(e.key), value: e.value })),
@@ -464,6 +496,9 @@ async function ensureStage(deps, subject, serverUuid) {
464
496
  },
465
497
  stage: stageSurface,
466
498
  sinkMail: Object.fromEntries(mail.map((e) => [e.key, e.value])),
499
+ ...(deps.signIn
500
+ ? { signIn: { customers: deps.signIn.customers, live: deps.signIn.live } }
501
+ : {}),
467
502
  });
468
503
  if (violations.length > 0) {
469
504
  // Stage fails closed for itself: nothing is written to it, production's structure goes on.
@@ -483,7 +518,12 @@ async function ensureStage(deps, subject, serverUuid) {
483
518
  probeResult = await runStageProbes(buildStageProbes({
484
519
  port: deps.stageGuards?.probes,
485
520
  stage: stageValues,
486
- database: subject.database,
521
+ // With no live app there is no production database to try the stage role against.
522
+ database: subject.database && deps.primaryBlueAppId !== null,
523
+ ...(deps.signIn?.live.customersOrgId
524
+ ? { liveCustomersOrgId: deps.signIn.live.customersOrgId }
525
+ : {}),
526
+ ...(deps.signIn ? { signInRedirectUri: deps.signIn.redirectUri } : {}),
487
527
  prod: new Map(prodEnv
488
528
  .filter((e) => !e.is_preview && e.value !== undefined)
489
529
  .map((e) => [e.key, e.value])),
@@ -1,4 +1,4 @@
1
- import type { BlueGreenConfig, DeclaredOrg, HostingEntry } from '../org.js';
1
+ import { type BlueGreenConfig, type DeclaredOrg, type HostingEntry } from '../org.js';
2
2
  import { type StageKind } from './stage-isolation.js';
3
3
  /**
4
4
  * Blue/green releases (docs/adr/0001-blue-green-releases.md). A color is a property any app can have
@@ -46,7 +46,10 @@ export declare const bgKey: {
46
46
  stageState: (id: string) => string;
47
47
  /** The Coolify name of the blue app on the primary: the adopted single app's name, or `<base>-blue`. */
48
48
  blueName: (id: string) => string;
49
- /** The pin key of the balancer's header routers. Not a credential: it only pins a color. */
49
+ /**
50
+ * The held ref of the balancer's pin key (a credential: it lets a caller pick a color). A value that is 64 hex
51
+ * characters is the older recorded key, moved to the held ref on the next read and then overwritten by the ref.
52
+ */
50
53
  pinKey: (id: string) => string;
51
54
  /** The hash of the stage env name set and resource set that passed the isolation check. */
52
55
  stageIsolation: (id: string) => string;
@@ -91,6 +94,34 @@ export declare function mintActionsKey(input: {
91
94
  /** Registers the value for redaction before it is written anywhere. */
92
95
  secret: (value: string) => void;
93
96
  }): Promise<string>;
97
+ /** The held-key name of a subject's balancer pin key: one mint per subject. */
98
+ export declare const pinKeyName: (id: string) => string;
99
+ /** The held store the pin key lives in; `put` is absent on a host that cannot mint. */
100
+ export interface PinHeldKeys {
101
+ resolve(ref: string): Promise<string | null | undefined>;
102
+ put?(ref: string, value: string): Promise<void>;
103
+ }
104
+ interface PinKeyInput {
105
+ id: string;
106
+ ref: string;
107
+ recorded: () => Readonly<Record<string, string>>;
108
+ record: (key: string, value: string) => void;
109
+ persist: () => Promise<void>;
110
+ heldKeys: PinHeldKeys;
111
+ /** Registers the value for redaction before it is written anywhere. */
112
+ secret: (value: string) => void;
113
+ }
114
+ /**
115
+ * The balancer's pin key, held (CLAUDE.md, Platform steps, rules 1 and 6). Order: the held key; else the older
116
+ * recorded key (64 hex), put in the held store and replaced by the ref; else one mint, put first, then the ref is
117
+ * recorded and persisted. A crash between the put and the record reuses the held key. A recorded ref whose key is
118
+ * no longer held stops: it never mints again. Errors name the ref only.
119
+ */
120
+ export declare function mintPinKey(input: PinKeyInput & {
121
+ generate: () => string;
122
+ }): Promise<string>;
123
+ /** Reads the pin key without minting: the held key, else the older recorded key, else null. */
124
+ export declare function readPinKey(input: Omit<PinKeyInput, 'record' | 'persist'>): Promise<string | null>;
94
125
  /** The held-key name of an entry's health token (`sharedSecretRef(org.id, name)`): one mint per entry. */
95
126
  export declare const healthTokenName: (id: string) => string;
96
127
  /** What a color's container gets for the reporting health read. The release is left out until a digest is known. */
@@ -133,11 +164,14 @@ export interface BlueGreenSubject {
133
164
  * (address check, crash loop) and moves up by time.
134
165
  */
135
166
  reporting: boolean;
136
- /** Set for Boule and Agora, which are not hosting entries. */
167
+ /** Set for Boule, Agora and kosmos, which are not hosting entries. */
137
168
  builtin?: BuiltinApp;
138
169
  }
139
- /** The apps foundry runs for every company, which can declare blue/green (`org.boule`, `org.agora`). */
140
- export type BuiltinApp = 'boule' | 'agora';
170
+ /**
171
+ * The apps foundry runs for a company that can declare blue/green: Boule and Agora, every company's own, plus
172
+ * kosmos once the company turns it on (`org.boule`, `org.agora`, `org.kosmos`; docs/adr/0001, rule 1).
173
+ */
174
+ export type BuiltinApp = 'boule' | 'agora' | 'kosmos';
141
175
  export declare const BUILTIN_APPS: readonly BuiltinApp[];
142
176
  /** What foundry builds each built-in app from; `executor.ts` creates the app from the same values. */
143
177
  export declare const BUILTIN_APP: {
@@ -157,6 +191,14 @@ export declare const BUILTIN_APP: {
157
191
  readonly database: false;
158
192
  readonly reporting: false;
159
193
  };
194
+ readonly kosmos: {
195
+ readonly repo: "wtfalch/kosmos";
196
+ readonly dockerfile: "/Dockerfile";
197
+ readonly port: 3000;
198
+ readonly healthPath: "/api/health";
199
+ readonly database: true;
200
+ readonly reporting: true;
201
+ };
160
202
  };
161
203
  /**
162
204
  * A hosting entry that declares `blueGreen`, as a subject; `null` when it does not or cannot run.
@@ -164,11 +206,12 @@ export declare const BUILTIN_APP: {
164
206
  */
165
207
  export declare function entrySubject(org: DeclaredOrg, entry: HostingEntry, recorded?: Readonly<Record<string, string>>): BlueGreenSubject | null;
166
208
  /**
167
- * The company's Boule or Agora as a subject; `null` when it does not declare blue/green. The running app keeps
168
- * its name `<slug>-<app>` and is blue, like platform-archon. Both run on the primary server only.
209
+ * The company's Boule, Agora or kosmos as a subject; `null` when it does not declare blue/green (see
210
+ * {@link builtinDeclaresBlueGreen}). The running app keeps its name `<slug>-<app>` and is blue, like
211
+ * platform-archon. All three run on the primary server only.
169
212
  */
170
213
  export declare function builtinSubject(org: DeclaredOrg, app: BuiltinApp, recorded?: Readonly<Record<string, string>>): BlueGreenSubject | null;
171
- /** Every blue/green subject of an org: the hosting entries that declare it, then Boule and Agora. */
214
+ /** Every blue/green subject of an org: the hosting entries that declare it, then Boule, Agora and kosmos. */
172
215
  export declare function blueGreenSubjects(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): BlueGreenSubject[];
173
216
  /** Whether the org declares blue/green for any of its apps. */
174
217
  export declare const declaresBlueGreen: (org: DeclaredOrg) => boolean;
@@ -200,6 +243,10 @@ export declare const LB_COOKIE_NAME = "bg_color";
200
243
  export declare const LB_COLOR_HEADER = "X-Foundry-Color";
201
244
  /** A pinned request also carries this header with the entry's pin key, so a stranger cannot pin a color. */
202
245
  export declare const LB_PIN_HEADER = "X-Foundry-Pin";
246
+ /** The cookie that sends a signed-in person with `releases:preview` to the idle slot. */
247
+ export declare const LB_PREVIEW_COOKIE = "bg_preview";
248
+ /** The path the balancer asks the live slot whether a request may see the idle slot. */
249
+ export declare const LB_PREVIEW_CHECK_PATH = "/api/foundry/preview-check";
203
250
  export declare const LB_CONFIG_PATH = "/etc/traefik/dynamic/lb.yml";
204
251
  /**
205
252
  * The balancer's inline Dockerfile: a pinned Traefik with a file provider watching one directory.
@@ -218,6 +265,10 @@ export declare function renderLbConfig(input: {
218
265
  weights: Weights;
219
266
  backends: Record<Color, string[]>;
220
267
  pinKey: string;
268
+ /** The slot that is not live: the `preview` router sends a cookie holder here. */
269
+ idle: Color;
270
+ /** True when the subject has a server that can answer the permission check; a static subject gets no router. */
271
+ reporting: boolean;
221
272
  }): string;
222
273
  /**
223
274
  * The URL of one color's container on its own server's network. On the primary, blue is the app that was
@@ -324,3 +375,4 @@ export declare function planArchonBlueGreen(input: {
324
375
  step: BlueGreenPlanStep | null;
325
376
  unmanaged: UnmanagedLine[];
326
377
  };
378
+ export {};
@@ -1,9 +1,11 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { VERSIONS, pinnedRef } from '../bootstrap/versions.js';
3
+ import { kosmosOn } from '../org.js';
3
4
  import { agoraHostnameFor } from './agora.js';
4
5
  import { bouleHostnameFor } from './boule.js';
5
6
  import { serverConnectorScopeKey } from './cloudflared-connector.js';
6
7
  import { entryHostnames, tunnelIngressHostnames, wildcardHostname } from './hostnames.js';
8
+ import { kosmosHostnameFor } from './kosmos.js';
7
9
  import { appNameFor, appStepKey } from './platform-diff.js';
8
10
  import { remoteDatabaseAppliedFor } from './remote-database.js';
9
11
  import { buildStageEnv } from './stage-env.js';
@@ -55,7 +57,10 @@ export const bgKey = {
55
57
  stageState: (id) => `bg.${id}.stagestate`,
56
58
  /** The Coolify name of the blue app on the primary: the adopted single app's name, or `<base>-blue`. */
57
59
  blueName: (id) => `bg.${id}.bluename`,
58
- /** The pin key of the balancer's header routers. Not a credential: it only pins a color. */
60
+ /**
61
+ * The held ref of the balancer's pin key (a credential: it lets a caller pick a color). A value that is 64 hex
62
+ * characters is the older recorded key, moved to the held ref on the next read and then overwritten by the ref.
63
+ */
59
64
  pinKey: (id) => `bg.${id}.pinkey`,
60
65
  /** The hash of the stage env name set and resource set that passed the isolation check. */
61
66
  stageIsolation: (id) => `bg.${id}.stageisolation`,
@@ -102,6 +107,56 @@ export async function mintActionsKey(input) {
102
107
  await input.persist();
103
108
  return key;
104
109
  }
110
+ /** The held-key name of a subject's balancer pin key: one mint per subject. */
111
+ export const pinKeyName = (id) => `bg-pin-${id}`;
112
+ /**
113
+ * The balancer's pin key, held (CLAUDE.md, Platform steps, rules 1 and 6). Order: the held key; else the older
114
+ * recorded key (64 hex), put in the held store and replaced by the ref; else one mint, put first, then the ref is
115
+ * recorded and persisted. A crash between the put and the record reuses the held key. A recorded ref whose key is
116
+ * no longer held stops: it never mints again. Errors name the ref only.
117
+ */
118
+ export async function mintPinKey(input) {
119
+ const held = await input.heldKeys.resolve(input.ref);
120
+ const recordedValue = input.recorded()[bgKey.pinKey(input.id)];
121
+ if (held) {
122
+ input.secret(held);
123
+ if (recordedValue !== input.ref) {
124
+ input.record(bgKey.pinKey(input.id), input.ref);
125
+ await input.persist();
126
+ }
127
+ return held;
128
+ }
129
+ const put = input.heldKeys.put?.bind(input.heldKeys);
130
+ if (!put)
131
+ throw new Error(`pin key ${input.ref} cannot be held: the host has no held store to write`);
132
+ if (recordedValue && PIN_KEY.test(recordedValue)) {
133
+ input.secret(recordedValue);
134
+ await put(input.ref, recordedValue);
135
+ input.record(bgKey.pinKey(input.id), input.ref);
136
+ await input.persist();
137
+ return recordedValue;
138
+ }
139
+ if (recordedValue) {
140
+ throw new Error(`pin key ${input.ref} was minted but is no longer held; restore the held value`);
141
+ }
142
+ const key = input.generate();
143
+ input.secret(key);
144
+ await put(input.ref, key);
145
+ input.record(bgKey.pinKey(input.id), input.ref);
146
+ await input.persist();
147
+ return key;
148
+ }
149
+ /** Reads the pin key without minting: the held key, else the older recorded key, else null. */
150
+ export async function readPinKey(input) {
151
+ const held = await input.heldKeys.resolve(input.ref);
152
+ const value = held ||
153
+ (PIN_KEY.test(input.recorded()[bgKey.pinKey(input.id)] ?? '')
154
+ ? input.recorded()[bgKey.pinKey(input.id)]
155
+ : null);
156
+ if (value)
157
+ input.secret(value);
158
+ return value ?? null;
159
+ }
105
160
  /** The held-key name of an entry's health token (`sharedSecretRef(org.id, name)`): one mint per entry. */
106
161
  export const healthTokenName = (id) => `reporting-health-${id}`;
107
162
  /** What a color's container gets for the reporting health read. The release is left out until a digest is known. */
@@ -112,7 +167,7 @@ export function reportingEnv(input) {
112
167
  { key: REPORTING_ENV.healthToken, value: input.healthToken },
113
168
  ];
114
169
  }
115
- export const BUILTIN_APPS = ['boule', 'agora'];
170
+ export const BUILTIN_APPS = ['boule', 'agora', 'kosmos'];
116
171
  /** What foundry builds each built-in app from; `executor.ts` creates the app from the same values. */
117
172
  export const BUILTIN_APP = {
118
173
  boule: {
@@ -132,7 +187,31 @@ export const BUILTIN_APP = {
132
187
  database: false,
133
188
  reporting: false,
134
189
  },
190
+ kosmos: {
191
+ repo: 'wtfalch/kosmos',
192
+ dockerfile: '/Dockerfile',
193
+ port: 3000,
194
+ healthPath: '/api/health',
195
+ database: true,
196
+ reporting: true,
197
+ },
135
198
  };
199
+ /** `builtinSubject`'s hostname for each built-in app, kept beside {@link BUILTIN_APP} rather than a third switch. */
200
+ function builtinHostnameFor(org, app) {
201
+ if (app === 'boule')
202
+ return bouleHostnameFor(org);
203
+ if (app === 'agora')
204
+ return agoraHostnameFor(org);
205
+ return kosmosHostnameFor(org);
206
+ }
207
+ /**
208
+ * Whether `org` declares blue/green for a built-in app: Boule and Agora opt into it through their own
209
+ * `blueGreen` field (they already run as a single copy otherwise); kosmos has no single-copy mode -- turning
210
+ * it on (`org.kosmos.enabled`) always runs it blue/green, docs/adr/0001 rule 1.
211
+ */
212
+ function builtinDeclaresBlueGreen(org, app) {
213
+ return app === 'kosmos' ? kosmosOn(org) : org[app]?.blueGreen !== undefined;
214
+ }
136
215
  /**
137
216
  * A hosting entry that declares `blueGreen`, as a subject; `null` when it does not or cannot run.
138
217
  * `recorded` names the blue app: an app that ran before blue/green keeps its own name (see {@link liveAppNameFor}).
@@ -160,13 +239,14 @@ export function entrySubject(org, entry, recorded) {
160
239
  };
161
240
  }
162
241
  /**
163
- * The company's Boule or Agora as a subject; `null` when it does not declare blue/green. The running app keeps
164
- * its name `<slug>-<app>` and is blue, like platform-archon. Both run on the primary server only.
242
+ * The company's Boule, Agora or kosmos as a subject; `null` when it does not declare blue/green (see
243
+ * {@link builtinDeclaresBlueGreen}). The running app keeps its name `<slug>-<app>` and is blue, like
244
+ * platform-archon. All three run on the primary server only.
165
245
  */
166
246
  export function builtinSubject(org, app, recorded = {}) {
167
- const config = org[app]?.blueGreen;
168
- if (!config)
247
+ if (!builtinDeclaresBlueGreen(org, app))
169
248
  return null;
249
+ const config = org[app]?.blueGreen ?? {};
170
250
  const spec = BUILTIN_APP[app];
171
251
  const base = `${org.slug}-${app}`;
172
252
  return {
@@ -180,7 +260,7 @@ export function builtinSubject(org, app, recorded = {}) {
180
260
  buildPack: 'dockerfile',
181
261
  healthPath: spec.healthPath,
182
262
  database: spec.database,
183
- hostnames: [app === 'boule' ? bouleHostnameFor(org) : agoraHostnameFor(org)],
263
+ hostnames: [builtinHostnameFor(org, app)],
184
264
  config,
185
265
  blueAppKey: `coolify:${app}:${org.id}`,
186
266
  dockerfileLocation: spec.dockerfile,
@@ -188,7 +268,7 @@ export function builtinSubject(org, app, recorded = {}) {
188
268
  builtin: app,
189
269
  };
190
270
  }
191
- /** Every blue/green subject of an org: the hosting entries that declare it, then Boule and Agora. */
271
+ /** Every blue/green subject of an org: the hosting entries that declare it, then Boule, Agora and kosmos. */
192
272
  export function blueGreenSubjects(org, recorded) {
193
273
  const subjects = [];
194
274
  for (const entry of org.hosting.entries) {
@@ -205,7 +285,7 @@ export function blueGreenSubjects(org, recorded) {
205
285
  }
206
286
  /** Whether the org declares blue/green for any of its apps. */
207
287
  export const declaresBlueGreen = (org) => org.hosting.entries.some((entry) => entry.blueGreen) ||
208
- BUILTIN_APPS.some((app) => org[app]?.blueGreen);
288
+ BUILTIN_APPS.some((app) => builtinDeclaresBlueGreen(org, app));
209
289
  /**
210
290
  * The Coolify name of the entry's app on the primary. A blue/green entry's app is its blue copy: the recorded
211
291
  * name; else the entry's own name when its app is already recorded (it ran before blue/green and is adopted as
@@ -246,6 +326,11 @@ export const LB_COLOR_HEADER = 'X-Foundry-Color';
246
326
  /** A pinned request also carries this header with the entry's pin key, so a stranger cannot pin a color. */
247
327
  export const LB_PIN_HEADER = 'X-Foundry-Pin';
248
328
  const PIN_KEY = /^[0-9a-f]{64}$/;
329
+ /** The cookie that sends a signed-in person with `releases:preview` to the idle slot. */
330
+ export const LB_PREVIEW_COOKIE = 'bg_preview';
331
+ /** The path the balancer asks the live slot whether a request may see the idle slot. */
332
+ export const LB_PREVIEW_CHECK_PATH = '/api/foundry/preview-check';
333
+ const LB_PREVIEW_PRIORITY = 9000;
249
334
  /** Above the default priority of `app` (its rule length), so a pinned request never reaches the weighted service. */
250
335
  const LB_PIN_PRIORITY = 10000;
251
336
  export const LB_CONFIG_PATH = '/etc/traefik/dynamic/lb.yml';
@@ -272,7 +357,8 @@ export const LB_IMAGE_REF = `traefik:${VERSIONS.traefik}`;
272
357
  * line, so a reader sees both numbers. `backends` are the URLs of each color's containers, one per server.
273
358
  */
274
359
  export function renderLbConfig(input) {
275
- const { weights, backends, pinKey } = input;
360
+ const { weights, backends, pinKey, idle } = input;
361
+ const live = COLORS.find((color) => color !== idle);
276
362
  if (!PIN_KEY.test(pinKey))
277
363
  throw new Error('the load balancer pin key is not 64 hex characters');
278
364
  const listed = COLORS.filter((color) => weights[color] > 0);
@@ -287,10 +373,29 @@ export function renderLbConfig(input) {
287
373
  ` entryPoints: [${LB_ENTRYPOINT}]`,
288
374
  ` service: ${color}`,
289
375
  ]),
376
+ ...(input.reporting
377
+ ? [
378
+ ' preview:',
379
+ ` rule: HeaderRegexp(\`Cookie\`, \`(^|;\\s*)${LB_PREVIEW_COOKIE}=1\`)`,
380
+ ` priority: ${LB_PREVIEW_PRIORITY}`,
381
+ ` entryPoints: [${LB_ENTRYPOINT}]`,
382
+ ` service: ${idle}`,
383
+ ' middlewares: [preview-check]',
384
+ ]
385
+ : []),
290
386
  ' app:',
291
387
  ' rule: PathPrefix(`/`)',
292
388
  ` entryPoints: [${LB_ENTRYPOINT}]`,
293
389
  ' service: weighted',
390
+ ...(input.reporting
391
+ ? [
392
+ ' middlewares:',
393
+ ' preview-check:',
394
+ ' forwardAuth:',
395
+ ` address: ${backends[live][0]}${LB_PREVIEW_CHECK_PATH}`,
396
+ ' trustForwardHeader: false',
397
+ ]
398
+ : []),
294
399
  ' services:',
295
400
  ' weighted:',
296
401
  ' weighted:',
@@ -91,6 +91,12 @@ export declare const BOULE_ARCHON_CREDENTIAL_ENV = "ARCHON_CREDENTIAL";
91
91
  /** The company's customers organisation in the sign-in service, and the read-only credential that lists its people (C3). */
92
92
  export declare const BOULE_CUSTOMERS_ORG_ENV = "CUSTOMERS_ORGANISATION_ID";
93
93
  export declare const BOULE_CUSTOMERS_CREDENTIAL_ENV = "CUSTOMERS_DIRECTORY_CREDENTIAL";
94
+ /**
95
+ * F5 (Q44): the company's test customers organisation, beside {@link BOULE_CUSTOMERS_ORG_ENV}, so a workspace copy
96
+ * of an app registers its sign-in against it. Written once the organisation is recorded (a server workspace with
97
+ * customers makes it); the plan plans Boule again then, as it does for the customers organisation.
98
+ */
99
+ export declare const BOULE_TEST_CUSTOMERS_ORG_ENV = "TEST_CUSTOMERS_ORGANISATION_ID";
94
100
  /**
95
101
  * When the Archon credential was last minted and held (ISO date-time). No secret. Archon's mint rotates
96
102
  * on every call and the credential lives 365 days, so foundry mints only when none was pushed or the
@@ -130,9 +136,25 @@ export interface BouleCredentialWants {
130
136
  customersDirectory?: boolean;
131
137
  /** The recorded Archon credential is missing or 335 days old: the step mints it again. */
132
138
  archonRenewDue?: boolean;
139
+ /** F13: Boule is given `CUSTOMER_GRANTS` (an entry declares `uses.customerGrants`; needs `customersDirectory`). */
140
+ customerGrants?: boolean;
141
+ /** T3: Boule is given its company's registrar key at the sign-in broker (`plan/broker-registrar.ts`). */
142
+ registrar?: boolean;
143
+ /** T3b: customers are off but the broker still holds the record, so Boule keeps its registrar env (plan only). */
144
+ registrarKept?: boolean;
133
145
  }
146
+ /** T3: the broker's base and the company's registrar key, in Boule's env (`plan/broker-registrar.ts`). */
147
+ export declare const AUTH_REGISTRAR_URL_ENV = "AUTH_REGISTRAR_URL";
148
+ export declare const AUTH_REGISTRAR_KEY_ENV = "AUTH_REGISTRAR_KEY";
149
+ /**
150
+ * A company gets a registrar while it has customers (the people its own apps sign in) or its Boule runs apps
151
+ * (F5: any company that installs an app with sign-in, `org.boule.apps`).
152
+ */
153
+ export declare const registrarWanted: (org: Pick<DeclaredOrg, "customers" | "boule">) => boolean;
154
+ /** The broker's base as Boule calls it: `@wtfalch/auth`'s own `brokerUrl` default, `<issuer>/api`. */
155
+ export declare const registrarUrl: (issuer: string) => string;
134
156
  /** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
135
- export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "AUTHZ_FIRST_OWNER", "ARCHON_URL", "MAIL_FROM", "LETTERMINT_API_TOKEN", "AUTHZ_PEER_PASSWORD_OPERATOR", "ARCHON_BRIDGE_SECRET", "OWNER_SEED_SECRET", "ARCHON_ORG_ID", "ARCHON_CREDENTIAL", "CUSTOMERS_ORGANISATION_ID", "CUSTOMERS_DIRECTORY_CREDENTIAL", "SIGNIN_UPSTREAM"];
157
+ export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "AUTHZ_FIRST_OWNER", "ARCHON_URL", "MAIL_FROM", "LETTERMINT_API_TOKEN", "AUTHZ_PEER_PASSWORD_OPERATOR", "ARCHON_BRIDGE_SECRET", "OWNER_SEED_SECRET", "ARCHON_ORG_ID", "ARCHON_CREDENTIAL", "CUSTOMERS_ORGANISATION_ID", "CUSTOMERS_DIRECTORY_CREDENTIAL", "TEST_CUSTOMERS_ORGANISATION_ID", "CUSTOMER_GRANTS", "SIGNIN_UPSTREAM", "AUTH_REGISTRAR_URL", "AUTH_REGISTRAR_KEY"];
136
158
  /**
137
159
  * The env vars an org's Boule app should carry, and every input still missing
138
160
  * to build them. Callers that write must refuse on a non-empty `missing`: a
@@ -1,6 +1,7 @@
1
- import { DEFAULT_NO_REPLY_LOCAL_PART, ownerMailboxes, } from '../org.js';
1
+ import { DEFAULT_NO_REPLY_LOCAL_PART, appsOn, ownerMailboxes, } from '../org.js';
2
2
  import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
3
3
  import { SIGN_IN_UPSTREAM_ENV, inAppHostApplied, signInUpstream, staleSignInUpstream, } from './sign-in.js';
4
+ import { testCustomersOrgKey } from './workspace-client.js';
4
5
  /**
5
6
  * #161 (Boule part): what both the planner (`diff.ts`) and the executor
6
7
  * (`executor.ts`) need to agree on for an org's Boule -- the hostname, the
@@ -102,6 +103,12 @@ export const BOULE_ARCHON_CREDENTIAL_ENV = 'ARCHON_CREDENTIAL';
102
103
  /** The company's customers organisation in the sign-in service, and the read-only credential that lists its people (C3). */
103
104
  export const BOULE_CUSTOMERS_ORG_ENV = 'CUSTOMERS_ORGANISATION_ID';
104
105
  export const BOULE_CUSTOMERS_CREDENTIAL_ENV = 'CUSTOMERS_DIRECTORY_CREDENTIAL';
106
+ /**
107
+ * F5 (Q44): the company's test customers organisation, beside {@link BOULE_CUSTOMERS_ORG_ENV}, so a workspace copy
108
+ * of an app registers its sign-in against it. Written once the organisation is recorded (a server workspace with
109
+ * customers makes it); the plan plans Boule again then, as it does for the customers organisation.
110
+ */
111
+ export const BOULE_TEST_CUSTOMERS_ORG_ENV = 'TEST_CUSTOMERS_ORGANISATION_ID';
105
112
  /**
106
113
  * When the Archon credential was last minted and held (ISO date-time). No secret. Archon's mint rotates
107
114
  * on every call and the credential lives 365 days, so foundry mints only when none was pushed or the
@@ -144,6 +151,16 @@ export const bouleArchonCredentialRef = (orgId) => `foundry:boule-archon:${orgId
144
151
  export const customersDirectoryRef = (orgId) => `foundry:customers-directory:${orgId}`;
145
152
  /** The customers organisation id `zitadel:customers` recorded. */
146
153
  export const customersOrgIdOf = (org, recorded) => recorded[`zitadel:customers:${org.id}`] || undefined;
154
+ /** T3: the broker's base and the company's registrar key, in Boule's env (`plan/broker-registrar.ts`). */
155
+ export const AUTH_REGISTRAR_URL_ENV = 'AUTH_REGISTRAR_URL';
156
+ export const AUTH_REGISTRAR_KEY_ENV = 'AUTH_REGISTRAR_KEY';
157
+ /**
158
+ * A company gets a registrar while it has customers (the people its own apps sign in) or its Boule runs apps
159
+ * (F5: any company that installs an app with sign-in, `org.boule.apps`).
160
+ */
161
+ export const registrarWanted = (org) => org.customers?.enabled === true || appsOn(org);
162
+ /** The broker's base as Boule calls it: `@wtfalch/auth`'s own `brokerUrl` default, `<issuer>/api`. */
163
+ export const registrarUrl = (issuer) => `${issuer.replace(/\/+$/, '')}/api`;
147
164
  /** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
148
165
  export const BOULE_MANAGED_ENV_KEYS = [
149
166
  'APP_URL',
@@ -163,7 +180,11 @@ export const BOULE_MANAGED_ENV_KEYS = [
163
180
  BOULE_ARCHON_CREDENTIAL_ENV,
164
181
  BOULE_CUSTOMERS_ORG_ENV,
165
182
  BOULE_CUSTOMERS_CREDENTIAL_ENV,
183
+ BOULE_TEST_CUSTOMERS_ORG_ENV,
184
+ 'CUSTOMER_GRANTS',
166
185
  SIGN_IN_UPSTREAM_ENV,
186
+ AUTH_REGISTRAR_URL_ENV,
187
+ AUTH_REGISTRAR_KEY_ENV,
167
188
  ];
168
189
  /**
169
190
  * The env vars an org's Boule app should carry, and every input still missing
@@ -218,6 +239,14 @@ export function desiredBouleEnv(org, recorded, issuer, archonUrl, mailWanted = f
218
239
  const customersOrgId = wants.customersDirectory ? customersOrgIdOf(org, recorded) : undefined;
219
240
  if (customersOrgId)
220
241
  env[BOULE_CUSTOMERS_ORG_ENV] = customersOrgId;
242
+ const testCustomersOrgId = wants.customersDirectory
243
+ ? recorded[testCustomersOrgKey(org.id)]
244
+ : undefined;
245
+ if (testCustomersOrgId)
246
+ env[BOULE_TEST_CUSTOMERS_ORG_ENV] = testCustomersOrgId;
247
+ // The key itself is a secret the executor writes; the plan compares it by presence (`bouleRegistrarDrift`).
248
+ if (wants.registrar && issuer)
249
+ env[AUTH_REGISTRAR_URL_ENV] = registrarUrl(issuer);
221
250
  const owner = org.owners[0];
222
251
  const ownerUserId = owner
223
252
  ? recorded[`zitadel:owner:${org.id}:${owner.email.toLowerCase()}`]
@@ -355,5 +384,9 @@ export function planBouleOwners(org, recorded, liveBouleEnv) {
355
384
  export function staleCustomersEnv(org, live) {
356
385
  if (org.customers?.enabled)
357
386
  return [];
358
- return [BOULE_CUSTOMERS_ORG_ENV, BOULE_CUSTOMERS_CREDENTIAL_ENV].filter((key) => live[key] !== undefined);
387
+ return [
388
+ BOULE_CUSTOMERS_ORG_ENV,
389
+ BOULE_TEST_CUSTOMERS_ORG_ENV,
390
+ BOULE_CUSTOMERS_CREDENTIAL_ENV,
391
+ ].filter((key) => live[key] !== undefined);
359
392
  }