@wtfalch/foundry 0.61.0 → 0.63.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 (97) hide show
  1. package/dist/bootstrap/declare.js +4 -1
  2. package/dist/bootstrap/versions.d.ts +2 -0
  3. package/dist/index.d.ts +9 -1
  4. package/dist/index.js +3 -0
  5. package/dist/migrations/0065_native_build_runs.sql +32 -0
  6. package/dist/migrations/0066_org_kosmos.sql +10 -0
  7. package/dist/migrations/0067_requested_changes_kosmos.sql +6 -0
  8. package/dist/migrations/0068_platform_registry.sql +17 -0
  9. package/dist/modules/sign-in.js +1 -1
  10. package/dist/native-build.d.ts +50 -0
  11. package/dist/native-build.js +72 -0
  12. package/dist/org.d.ts +22 -3
  13. package/dist/org.js +25 -2
  14. package/dist/plan/agora.d.ts +1 -1
  15. package/dist/plan/agora.js +4 -0
  16. package/dist/plan/archon-blue-green.d.ts +5 -0
  17. package/dist/plan/archon-blue-green.js +2 -0
  18. package/dist/plan/blue-green-apply.d.ts +5 -0
  19. package/dist/plan/blue-green-apply.js +197 -22
  20. package/dist/plan/blue-green.d.ts +80 -9
  21. package/dist/plan/blue-green.js +115 -14
  22. package/dist/plan/boule.d.ts +1 -1
  23. package/dist/plan/boule.js +2 -0
  24. package/dist/plan/broker-entries.d.ts +9 -0
  25. package/dist/plan/broker-entries.js +29 -0
  26. package/dist/plan/deploy-health.js +9 -2
  27. package/dist/plan/desktop-callback.js +4 -1
  28. package/dist/plan/diff.d.ts +5 -0
  29. package/dist/plan/diff.js +184 -63
  30. package/dist/plan/edge-access.d.ts +2 -3
  31. package/dist/plan/edge-access.js +8 -0
  32. package/dist/plan/executor.d.ts +111 -19
  33. package/dist/plan/executor.js +578 -72
  34. package/dist/plan/index.d.ts +2 -0
  35. package/dist/plan/index.js +1 -0
  36. package/dist/plan/jobs-scheduler-http.d.ts +2 -0
  37. package/dist/plan/jobs-scheduler-http.js +11 -0
  38. package/dist/plan/kosmos.d.ts +56 -0
  39. package/dist/plan/kosmos.js +84 -0
  40. package/dist/plan/locked-hostnames.js +5 -2
  41. package/dist/plan/org-names.d.ts +3 -3
  42. package/dist/plan/org-names.js +3 -1
  43. package/dist/plan/org-postgres-migrate.js +1 -0
  44. package/dist/plan/own-limits.d.ts +12 -0
  45. package/dist/plan/own-limits.js +2 -0
  46. package/dist/plan/plan-apply.d.ts +51 -1
  47. package/dist/plan/plan-apply.js +298 -25
  48. package/dist/plan/platform-diff.d.ts +6 -0
  49. package/dist/plan/platform-diff.js +74 -0
  50. package/dist/plan/platform-org.d.ts +2 -0
  51. package/dist/plan/platform-providers.d.ts +46 -1
  52. package/dist/plan/platform-providers.js +162 -1
  53. package/dist/plan/platform-registry-keys.d.ts +58 -0
  54. package/dist/plan/platform-registry-keys.js +69 -0
  55. package/dist/plan/platform-registry.d.ts +67 -0
  56. package/dist/plan/platform-registry.js +298 -0
  57. package/dist/plan/post-logout-uris.d.ts +1 -1
  58. package/dist/plan/post-logout-uris.js +5 -2
  59. package/dist/plan/providers-readers.js +20 -1
  60. package/dist/plan/release-host.d.ts +31 -2
  61. package/dist/plan/release-host.js +114 -6
  62. package/dist/plan/stage-copy-host.d.ts +104 -0
  63. package/dist/plan/stage-copy-host.js +259 -0
  64. package/dist/plan/stage-copy.js +44 -0
  65. package/dist/plan/stage-env.d.ts +9 -0
  66. package/dist/plan/stage-env.js +38 -0
  67. package/dist/plan/stage-guards.d.ts +57 -1
  68. package/dist/plan/stage-guards.js +106 -0
  69. package/dist/plan/stage-isolation.js +56 -0
  70. package/dist/plan/stage-services.d.ts +93 -0
  71. package/dist/plan/stage-services.js +153 -0
  72. package/dist/plan/stage-signin.d.ts +152 -0
  73. package/dist/plan/stage-signin.js +133 -0
  74. package/dist/plan/stage-sink.d.ts +2 -0
  75. package/dist/plan/stage-sink.js +6 -1
  76. package/dist/platform.d.ts +7 -1
  77. package/dist/platform.js +1 -0
  78. package/dist/release/decide.d.ts +12 -0
  79. package/dist/release/decide.js +37 -2
  80. package/dist/release/migration-compatibility.d.ts +66 -0
  81. package/dist/release/migration-compatibility.js +340 -0
  82. package/dist/release/run.d.ts +71 -1
  83. package/dist/release/run.js +92 -8
  84. package/dist/release/state.d.ts +14 -1
  85. package/dist/release/state.js +43 -0
  86. package/dist/release/types.d.ts +20 -0
  87. package/dist/release/types.js +2 -0
  88. package/dist/requested-changes.d.ts +11 -2
  89. package/dist/requested-changes.js +13 -1
  90. package/dist/slug.d.ts +1 -1
  91. package/dist/slug.js +11 -2
  92. package/dist/store/postgres.js +21 -0
  93. package/dist/store/schema.d.ts +87 -0
  94. package/dist/store/schema.js +10 -0
  95. package/package.json +3 -2
  96. package/versions.digests.json +1 -0
  97. package/versions.json +1 -0
@@ -1,12 +1,15 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { VERSIONS, pinnedRef } from '../bootstrap/versions.js';
3
- import { agoraHostnameFor } from './agora.js';
3
+ import { kosmosOn } from '../org.js';
4
+ import { ZITADEL_ORG_SCOPE_KEY, 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';
12
+ import { stageHostname } from './stage-guards.js';
10
13
  import { assertStageIsolated, envClass } from './stage-isolation.js';
11
14
  import { STAGE_SINK_VERSION } from './stage-sink.js';
12
15
  export const COLORS = ['blue', 'green'];
@@ -38,6 +41,8 @@ export const bgKey = {
38
41
  digest: (id, color) => `bg.${id}.digest.${color}`,
39
42
  weights: (id) => `bg.${id}.weights`,
40
43
  stage: (id) => `bg.${id}.stage`,
44
+ /** The last `deploy_stage`: `<ISO time>|ok|<commit>` or `<ISO time>|failed|<step>`. */
45
+ stageDeploy: (id) => `bg.${id}.stagedeploy`,
41
46
  window: (id) => `bg.${id}.window`,
42
47
  held: (id, digest) => `bg.${id}.held.${digest}`,
43
48
  /** The weights the load balancer on `server` was last written with. */
@@ -64,8 +69,29 @@ export const bgKey = {
64
69
  stageIsolation: (id) => `bg.${id}.stageisolation`,
65
70
  /** The id of the Access gate over the stage host: staff only, no bypass, no service-token rule. */
66
71
  stageGate: (id) => `bg.${id}.stagegate`,
72
+ /** The hash of the sorted email list last written to the stage gate (owners plus the people holding `releases:preview`). */
73
+ stageGateMembers: (id) => `bg.${id}.stagegatemembers`,
67
74
  /** `ok:<hash>` once every apply-time probe was rejected; `notrun:<names>` or `failed:<names>` otherwise. */
68
75
  stageProbes: (id) => `bg.${id}.stageprobes`,
76
+ /** Stage's own sign-in client: its ZITADEL app id, and its client id (public, not a secret). */
77
+ stageClient: (id) => `bg.${id}.stageclient`,
78
+ stageClientId: (id) => `bg.${id}.stageclientid`,
79
+ /** The held ref of stage's app key, recorded and persisted as soon as it is minted (no `:`: undelivered). */
80
+ stageSignInKey: (id) => `bg.${id}.stagesigninkey`,
81
+ /** `{name, organizationId}` of stage's broker entry, so org destroy can remove it. No key. */
82
+ stageBroker: (id) => `bg.${id}.stagebroker`,
83
+ /** `ok:<hash of the client, app and broker entry ids>` or `none`; the copy waits for it. */
84
+ stageSignIn: (id) => `bg.${id}.stagesignin`,
85
+ /** `none` (production's app uses no files, ai or jobs) or `ok:<hash>` once every service it uses is held by stage or listed absent. */
86
+ stageServices: (id) => `bg.${id}.stageservices`,
87
+ /** `ok:<hash of the ids>` or `absent:<service>:<reason>` for one service (`files`, `ai` or `jobs`). */
88
+ stageService: (id, service) => `bg.${id}.stage${service}`,
89
+ /** The id of stage's own organisation (files, ai) or project (jobs). An id, not a secret. */
90
+ stageServiceOrg: (id, service) => `bg.${id}.stage${service}org`,
91
+ /** The name stage's project or organisation was made under, when the service is deleted by name (jobs). */
92
+ stageServiceName: (id, service) => `bg.${id}.stage${service}name`,
93
+ /** `{ids, refs}` of stage's keys in that service: key ids and held refs, never a value. */
94
+ stageServiceKeys: (id, service) => `bg.${id}.stage${service}keys`,
69
95
  /** `applied` once the stage app holds the sink's mail env. The copy refuses without it. */
70
96
  stageSink: (id) => `bg.${id}.stagesink`,
71
97
  /** `<ISO time>|<dump execution id>|ok`, written by a finished copy of production into stage. */
@@ -165,7 +191,7 @@ export function reportingEnv(input) {
165
191
  { key: REPORTING_ENV.healthToken, value: input.healthToken },
166
192
  ];
167
193
  }
168
- export const BUILTIN_APPS = ['boule', 'agora'];
194
+ export const BUILTIN_APPS = ['boule', 'agora', 'kosmos'];
169
195
  /** What foundry builds each built-in app from; `executor.ts` creates the app from the same values. */
170
196
  export const BUILTIN_APP = {
171
197
  boule: {
@@ -185,7 +211,31 @@ export const BUILTIN_APP = {
185
211
  database: false,
186
212
  reporting: false,
187
213
  },
214
+ kosmos: {
215
+ repo: 'wtfalch/kosmos',
216
+ dockerfile: '/Dockerfile',
217
+ port: 3000,
218
+ healthPath: '/api/health',
219
+ database: true,
220
+ reporting: true,
221
+ },
188
222
  };
223
+ /** `builtinSubject`'s hostname for each built-in app, kept beside {@link BUILTIN_APP} rather than a third switch. */
224
+ function builtinHostnameFor(org, app) {
225
+ if (app === 'boule')
226
+ return bouleHostnameFor(org);
227
+ if (app === 'agora')
228
+ return agoraHostnameFor(org);
229
+ return kosmosHostnameFor(org);
230
+ }
231
+ /**
232
+ * Whether `org` declares blue/green for a built-in app: Boule and Agora opt into it through their own
233
+ * `blueGreen` field (they already run as a single copy otherwise); kosmos has no single-copy mode -- turning
234
+ * it on (`org.kosmos.enabled`) always runs it blue/green, docs/adr/0001 rule 1.
235
+ */
236
+ function builtinDeclaresBlueGreen(org, app) {
237
+ return app === 'kosmos' ? kosmosOn(org) : org[app]?.blueGreen !== undefined;
238
+ }
189
239
  /**
190
240
  * A hosting entry that declares `blueGreen`, as a subject; `null` when it does not or cannot run.
191
241
  * `recorded` names the blue app: an app that ran before blue/green keeps its own name (see {@link liveAppNameFor}).
@@ -213,13 +263,14 @@ export function entrySubject(org, entry, recorded) {
213
263
  };
214
264
  }
215
265
  /**
216
- * The company's Boule or Agora as a subject; `null` when it does not declare blue/green. The running app keeps
217
- * its name `<slug>-<app>` and is blue, like platform-archon. Both run on the primary server only.
266
+ * The company's Boule, Agora or kosmos as a subject; `null` when it does not declare blue/green (see
267
+ * {@link builtinDeclaresBlueGreen}). The running app keeps its name `<slug>-<app>` and is blue, like
268
+ * platform-archon. All three run on the primary server only.
218
269
  */
219
270
  export function builtinSubject(org, app, recorded = {}) {
220
- const config = org[app]?.blueGreen;
221
- if (!config)
271
+ if (!builtinDeclaresBlueGreen(org, app))
222
272
  return null;
273
+ const config = org[app]?.blueGreen ?? {};
223
274
  const spec = BUILTIN_APP[app];
224
275
  const base = `${org.slug}-${app}`;
225
276
  return {
@@ -233,7 +284,7 @@ export function builtinSubject(org, app, recorded = {}) {
233
284
  buildPack: 'dockerfile',
234
285
  healthPath: spec.healthPath,
235
286
  database: spec.database,
236
- hostnames: [app === 'boule' ? bouleHostnameFor(org) : agoraHostnameFor(org)],
287
+ hostnames: [builtinHostnameFor(org, app)],
237
288
  config,
238
289
  blueAppKey: `coolify:${app}:${org.id}`,
239
290
  dockerfileLocation: spec.dockerfile,
@@ -241,7 +292,7 @@ export function builtinSubject(org, app, recorded = {}) {
241
292
  builtin: app,
242
293
  };
243
294
  }
244
- /** Every blue/green subject of an org: the hosting entries that declare it, then Boule and Agora. */
295
+ /** Every blue/green subject of an org: the hosting entries that declare it, then Boule, Agora and kosmos. */
245
296
  export function blueGreenSubjects(org, recorded) {
246
297
  const subjects = [];
247
298
  for (const entry of org.hosting.entries) {
@@ -258,7 +309,7 @@ export function blueGreenSubjects(org, recorded) {
258
309
  }
259
310
  /** Whether the org declares blue/green for any of its apps. */
260
311
  export const declaresBlueGreen = (org) => org.hosting.entries.some((entry) => entry.blueGreen) ||
261
- BUILTIN_APPS.some((app) => org[app]?.blueGreen);
312
+ BUILTIN_APPS.some((app) => builtinDeclaresBlueGreen(org, app));
262
313
  /**
263
314
  * The Coolify name of the entry's app on the primary. A blue/green entry's app is its blue copy: the recorded
264
315
  * name; else the entry's own name when its app is already recorded (it ran before blue/green and is adopted as
@@ -410,12 +461,42 @@ export function structureHash(subject, input) {
410
461
  primary: input.primary,
411
462
  lb: VERSIONS.traefik,
412
463
  ...(input.primary ? { stageSink: STAGE_SINK_VERSION } : {}),
464
+ // Only a primary has a stage, and only a present guard counts: no guard hashes as before.
465
+ ...(input.primary && input.guards?.length ? { stageGuards: [...input.guards].sort() } : {}),
413
466
  }))
414
467
  .digest('hex')
415
468
  .slice(0, 16);
416
469
  }
417
- /** The kind of stage app a subject has: platform-archon, else a customer app. */
418
- export const stageKindOf = (subject) => subject.id === ARCHON_SUBJECT_ID ? 'archon' : 'customer';
470
+ /**
471
+ * The kind of stage app a subject has: Boule and Agora by `builtin` (a hosting entry that happens to be keyed
472
+ * `boule` is still a customer app), platform-archon by its id, else a customer app (kosmos included: it has no lists of its own).
473
+ */
474
+ export const stageKindOf = (subject) => subject.builtin === 'boule' || subject.builtin === 'agora'
475
+ ? subject.builtin
476
+ : subject.id === ARCHON_SUBJECT_ID
477
+ ? 'archon'
478
+ : 'customer';
479
+ /**
480
+ * The stage guards that can be made now, by name. The names go into the applied structure hash, so a guard
481
+ * that becomes possible after the first apply changes the hash and plans the structure step once more.
482
+ */
483
+ export function stageGuardInputs(facts) {
484
+ const out = [];
485
+ if (facts.probesPort)
486
+ out.push('probes');
487
+ if (facts.accessPort &&
488
+ facts.providerId &&
489
+ facts.domain &&
490
+ facts.claimValue &&
491
+ facts.emails > 0 &&
492
+ !facts.hostRuled) {
493
+ out.push('gate');
494
+ }
495
+ return out;
496
+ }
497
+ /** The guard names as the plan's `desired.guards` carries them, and back. */
498
+ export const formatGuards = (guards) => [...guards].sort().join(',');
499
+ export const parseGuards = (value) => typeof value === 'string' && value ? value.split(',') : [];
419
500
  /** The names stage can collide on. */
420
501
  export function stageResources(subject) {
421
502
  const database = `${subject.base}-stage-db`;
@@ -596,10 +677,22 @@ export function planBlueGreen(input) {
596
677
  key: `bg:${id}:stage`,
597
678
  });
598
679
  }
680
+ // The guards that can be made now go into the applied hash: one that becomes possible after the first
681
+ // apply (a provider, a probe port, an owner) changes the hash and plans the structure step once more.
682
+ const stageHost = org.domain ? stageHostname(org.domain, id) : null;
683
+ const guards = stageGuardInputs({
684
+ probesPort: input.stageGuardPorts?.probes === true,
685
+ accessPort: input.stageGuardPorts?.access === true,
686
+ providerId: input.accessProviderId,
687
+ domain: org.domain,
688
+ claimValue: recordedIds[ZITADEL_ORG_SCOPE_KEY],
689
+ emails: org.owners.length,
690
+ hostRuled: (org.hosting.access ?? []).some((rule) => rule.hostname === stageHost),
691
+ });
599
692
  for (const server of targets) {
600
693
  const isPrimary = server === primary;
601
694
  const key = `${org.id}:${id}:${server}`;
602
- const hash = structureHash(subject, { primary: isPrimary });
695
+ const hash = structureHash(subject, { primary: isPrimary, guards });
603
696
  if (!isPrimary && subject.database && !remoteDatabaseAppliedFor(recordedIds, id, server)) {
604
697
  unmanaged.push({
605
698
  description: `${org.slug}'s ${id} app has no blue/green copies on Coolify server ${server} yet: its database route to that server is not applied`,
@@ -624,6 +717,7 @@ export function planBlueGreen(input) {
624
717
  primary,
625
718
  weights: formatWeights(weights),
626
719
  ...(input.accessProviderId ? { accessProviderId: input.accessProviderId } : {}),
720
+ ...(isPrimary && guards.length > 0 ? { guards: formatGuards(guards) } : {}),
627
721
  },
628
722
  });
629
723
  }
@@ -722,7 +816,9 @@ export function planArchonBlueGreen(input) {
722
816
  if (!primary || !up.includes(primary)) {
723
817
  return blocked(`the primary Coolify server ${primary ?? '(none)'} is not usable and reachable`);
724
818
  }
725
- const done = recorded[bgKey.applied(subject.id, primary)] === structureHash(subject, { primary: true }) &&
819
+ const guards = input.guards ?? [];
820
+ const done = recorded[bgKey.applied(subject.id, primary)] ===
821
+ structureHash(subject, { primary: true, guards }) &&
726
822
  recorded[bgKey.lb(subject.id, primary)] === formatWeights(weights) &&
727
823
  recorded[ARCHON_BG_ORIGIN_SCOPE_KEY] === lbOrigin(subject);
728
824
  if (done)
@@ -734,7 +830,12 @@ export function planArchonBlueGreen(input) {
734
830
  phase: 'after',
735
831
  key: subject.id,
736
832
  description: `run platform-archon as blue and green behind a load balancer on Coolify server ${primary}, then send its tunnel to the balancer`,
737
- desired: { subject: subject.id, primary, weights: formatWeights(weights) },
833
+ desired: {
834
+ subject: subject.id,
835
+ primary,
836
+ weights: formatWeights(weights),
837
+ ...(guards.length > 0 ? { guards: formatGuards(guards) } : {}),
838
+ },
738
839
  },
739
840
  };
740
841
  }
@@ -154,7 +154,7 @@ export declare const registrarWanted: (org: Pick<DeclaredOrg, "customers" | "bou
154
154
  /** The broker's base as Boule calls it: `@wtfalch/auth`'s own `brokerUrl` default, `<issuer>/api`. */
155
155
  export declare const registrarUrl: (issuer: string) => string;
156
156
  /** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
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"];
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", "NATIVE_BUILD_KEY", "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"];
158
158
  /**
159
159
  * The env vars an org's Boule app should carry, and every input still missing
160
160
  * to build them. Callers that write must refuse on a non-empty `missing`: a
@@ -1,3 +1,4 @@
1
+ import { NATIVE_BUILD_KEY_ENV } from '../native-build.js';
1
2
  import { DEFAULT_NO_REPLY_LOCAL_PART, appsOn, ownerMailboxes, } from '../org.js';
2
3
  import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
3
4
  import { SIGN_IN_UPSTREAM_ENV, inAppHostApplied, signInUpstream, staleSignInUpstream, } from './sign-in.js';
@@ -176,6 +177,7 @@ export const BOULE_MANAGED_ENV_KEYS = [
176
177
  BOULE_PEER_PASSWORD_ENV,
177
178
  BOULE_BRIDGE_SECRET_ENV,
178
179
  BOULE_OWNER_SEED_SECRET_ENV,
180
+ NATIVE_BUILD_KEY_ENV,
179
181
  BOULE_ARCHON_ORG_ID_ENV,
180
182
  BOULE_ARCHON_CREDENTIAL_ENV,
181
183
  BOULE_CUSTOMERS_ORG_ENV,
@@ -178,3 +178,12 @@ export declare function writeBrokerEntry(broker: BrokerPort, input: {
178
178
  invalidForeign: string[];
179
179
  replaced: string | null;
180
180
  }>;
181
+ /**
182
+ * Takes one entry (and so its key) out of the broker's `AUTH_APPS`, matched by name and organisation, and
183
+ * deploys the broker when the list changed. Every other entry is copied back as it was. An entry that is
184
+ * already gone writes nothing. Errors name the entry, never a key.
185
+ */
186
+ export declare function removeBrokerEntry(broker: BrokerPort, input: {
187
+ name: string;
188
+ organizationId: string;
189
+ }): Promise<boolean>;
@@ -373,3 +373,32 @@ export async function writeBrokerEntry(broker, input) {
373
373
  replaced: replaced.length > 0 ? JSON.stringify(replaced) : null,
374
374
  };
375
375
  }
376
+ /**
377
+ * Takes one entry (and so its key) out of the broker's `AUTH_APPS`, matched by name and organisation, and
378
+ * deploys the broker when the list changed. Every other entry is copied back as it was. An entry that is
379
+ * already gone writes nothing. Errors name the entry, never a key.
380
+ */
381
+ export async function removeBrokerEntry(broker, input) {
382
+ const label = `broker entry '${input.name}'`;
383
+ const env = (await broker.listEnvs()).find((e) => e.key === 'AUTH_APPS' && !e.is_preview);
384
+ if (!env?.value)
385
+ return false;
386
+ let parsed;
387
+ try {
388
+ parsed = JSON.parse(env.value);
389
+ }
390
+ catch {
391
+ throw new Error(`${label}: the broker's AUTH_APPS is not JSON, so nothing was removed`);
392
+ }
393
+ if (!Array.isArray(parsed)) {
394
+ throw new Error(`${label}: the broker's AUTH_APPS is not a JSON array, so nothing was removed`);
395
+ }
396
+ const next = parsed.filter((entry) => !(isObject(entry) &&
397
+ entry.name === input.name &&
398
+ entry.organizationId === input.organizationId));
399
+ if (next.length === parsed.length)
400
+ return false;
401
+ await broker.setAuthApps(JSON.stringify(next));
402
+ await broker.deploy();
403
+ return true;
404
+ }
@@ -142,10 +142,17 @@ async function matchesHealth(fetchImpl, health) {
142
142
  const response = await fetchImpl(health.url, {
143
143
  signal: AbortSignal.timeout(HEALTH_REQUEST_TIMEOUT_MS),
144
144
  });
145
- if (!response.ok)
145
+ if (!response.ok) {
146
+ // #760: an unread body left undici's parser paused when the socket
147
+ // ended, throwing an uncaught assertion in the host. Every answer this
148
+ // function does not go on to consume gets cancelled here.
149
+ await response.body?.cancel();
146
150
  return false;
147
- if (health.kind === 'ready')
151
+ }
152
+ if (health.kind === 'ready') {
153
+ await response.body?.cancel();
148
154
  return true;
155
+ }
149
156
  const body = await response.json().catch(() => undefined);
150
157
  const value = body && typeof body === 'object'
151
158
  ? body[health.field ?? 'version']
@@ -1,6 +1,7 @@
1
1
  import { agoraHostnameFor } from './agora.js';
2
2
  import { bouleHostnameFor } from './boule.js';
3
3
  import { appHostname, cmsStudioHostnameFor, entryHostnames } from './hostnames.js';
4
+ import { kosmosHostnameFor } from './kosmos.js';
4
5
  import { workspaceAuthEntryOf, workspaceClientHosts } from './workspace-client.js';
5
6
  /**
6
7
  * #339: the desktop app signs in through the company's web app, so every
@@ -36,7 +37,9 @@ export function signInRedirectUris(org, appKey) {
36
37
  ? cmsStudioHostnameFor(org)
37
38
  : appKey === 'boule'
38
39
  ? bouleHostnameFor(org)
39
- : appHostname(org),
40
+ : appKey === 'kosmos'
41
+ ? kosmosHostnameFor(org)
42
+ : appHostname(org),
40
43
  ];
41
44
  return [
42
45
  ...hosts.map((host) => `https://${host}/auth/callback`),
@@ -150,6 +150,11 @@ export interface PlanOrgOptions {
150
150
  blueGreenServers?: readonly BlueGreenServer[];
151
151
  /** The company's stage mail sink is recorded and its live app is stopped or gone. */
152
152
  stageSinkDown?: boolean;
153
+ /** Which stage-guard ports the host has ({@link PlanBlueGreenInput.stageGuardPorts}). */
154
+ stageGuardPorts?: {
155
+ probes: boolean;
156
+ access: boolean;
157
+ };
153
158
  }
154
159
  export interface PlanDetail {
155
160
  orgId: OrgId;