@wtfalch/foundry 0.61.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 (40) hide show
  1. package/dist/index.d.ts +5 -1
  2. package/dist/index.js +2 -0
  3. package/dist/migrations/0065_native_build_runs.sql +32 -0
  4. package/dist/migrations/0066_org_kosmos.sql +10 -0
  5. package/dist/modules/sign-in.js +1 -1
  6. package/dist/native-build.d.ts +70 -0
  7. package/dist/native-build.js +92 -0
  8. package/dist/org.d.ts +22 -3
  9. package/dist/org.js +25 -2
  10. package/dist/plan/agora.d.ts +1 -1
  11. package/dist/plan/agora.js +4 -0
  12. package/dist/plan/blue-green.d.ts +19 -7
  13. package/dist/plan/blue-green.js +35 -8
  14. package/dist/plan/deploy-health.js +9 -2
  15. package/dist/plan/desktop-callback.js +4 -1
  16. package/dist/plan/diff.js +180 -63
  17. package/dist/plan/edge-access.js +1 -0
  18. package/dist/plan/executor.d.ts +40 -18
  19. package/dist/plan/executor.js +153 -49
  20. package/dist/plan/kosmos.d.ts +56 -0
  21. package/dist/plan/kosmos.js +84 -0
  22. package/dist/plan/locked-hostnames.js +5 -2
  23. package/dist/plan/org-names.d.ts +3 -3
  24. package/dist/plan/org-names.js +3 -1
  25. package/dist/plan/org-postgres-migrate.js +1 -0
  26. package/dist/plan/own-limits.d.ts +6 -0
  27. package/dist/plan/own-limits.js +1 -0
  28. package/dist/plan/platform-providers.js +6 -0
  29. package/dist/plan/post-logout-uris.d.ts +1 -1
  30. package/dist/plan/post-logout-uris.js +5 -2
  31. package/dist/plan/providers-readers.js +20 -1
  32. package/dist/release/migration-compatibility.d.ts +66 -0
  33. package/dist/release/migration-compatibility.js +340 -0
  34. package/dist/requested-changes.js +2 -1
  35. package/dist/slug.d.ts +1 -1
  36. package/dist/slug.js +11 -2
  37. package/dist/store/postgres.js +13 -0
  38. package/dist/store/schema.d.ts +36 -0
  39. package/dist/store/schema.js +6 -0
  40. package/package.json +2 -2
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, HostingGithub, BlueGreenConfig, OrgBuiltinApp, BouleApp, OrgRefusal, OrgRefusalCode, WorkerAppConfig, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryAuthRoom, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceDatabase, OrgServiceSchema, EntryWorkspace, ServicesConfig, SignInConfig, VersionPin, VersionPinPart, } from './org.js';
1
+ export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, HostingGithub, BlueGreenConfig, OrgBuiltinApp, BouleApp, KosmosApp, OrgRefusal, OrgRefusalCode, WorkerAppConfig, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryAuthRoom, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceDatabase, OrgServiceSchema, EntryWorkspace, ServicesConfig, SignInConfig, VersionPin, VersionPinPart, } from './org.js';
2
2
  export { APP_ENTRY_KEY, APPS_DOMAIN, DEFAULT_GATE_LIMIT, MAX_WORKSPACE_TESTERS, isLiveEntry, normalizeTesters, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, orgServiceDatabase, orgServiceDatabases, SFU_SLOT_MAX, VERSION_PIN_PARTS, isMainEntry, mainEntry, companyGithubFor, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, normalizeHostingRepos, validateMail, validateOwners, validateServices, validateCustomers, validateSignIn, validateVersionPin, validateSite, } from './org.js';
3
3
  export { refuseOrg } from './refuse-org.js';
4
4
  export { workerAppHasBuild } from './worker-app-config.js';
@@ -15,6 +15,8 @@ export type { SlugRejectionReason, SlugRejection } from './slug.js';
15
15
  export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
16
16
  export type { Checkpoint, LastApplied, AppliedState, DestroyRecord, DestroyStepRecord, } from './state.js';
17
17
  export type { PlanStep, UnmanagedItem, PlanNote, Plan, VersionChangeItem } from './plan.js';
18
+ export type { NativeBuildDispatchDeps, NativeBuildKeyEnvPort, } from './native-build.js';
19
+ export { NATIVE_BUILD_REPO, NATIVE_BUILD_WORKFLOW_FILE, NATIVE_BUILD_WORKFLOW_REF, NATIVE_BUILD_DISPATCH_TIMEOUT_MS, NATIVE_BUILD_KEY_ENV, NATIVE_BUILD_KEY_BYTES, NativeBuildDispatchError, dispatchNativeBuild, mintNativeBuildKey, } from './native-build.js';
18
20
  export type { AppliedVersion, AppliedVersionsStore, DeclaredOrgStore, HeldLease, MutationLease, PlatformStore, RequestedChangeStore, StaleUpdate, UpdateOptions, } from './store.js';
19
21
  export { StaleLeaseError } from './store.js';
20
22
  export type { PlanApply, DestroyProgress } from './apply.js';
@@ -23,6 +25,8 @@ export { createPostgresAuditLog } from './store/audit.js';
23
25
  export type { Color, ReleaseAction, ReleaseSignal, ReleaseState, WindowState, } from './release/types.js';
24
26
  export type { ReleaseStore } from './release/state.js';
25
27
  export { readReleaseState, requestReleaseAction } from './release/state.js';
28
+ export type { DeclaredClassRemoval, MigrationCompatibilityInput, MigrationCompatibilityProblem, MigrationCompatibilityResult, } from './release/migration-compatibility.js';
29
+ export { checkMigrationCompatibility } from './release/migration-compatibility.js';
26
30
  export { createPostgresReleaseStore } from './store/postgres.js';
27
31
  export type { FoundryStoreDb } from './store/postgres.js';
28
32
  export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryReleaseRequests, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type AppliedVersionRow, type NewAppliedVersionRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
package/dist/index.js CHANGED
@@ -10,10 +10,12 @@ export const PINNED_VERSIONS = VERSIONS;
10
10
  export { buildJsonSchema, parseBuildJson, BuildJsonError } from './build-json.js';
11
11
  export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requested-changes.js';
12
12
  export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
13
+ export { NATIVE_BUILD_REPO, NATIVE_BUILD_WORKFLOW_FILE, NATIVE_BUILD_WORKFLOW_REF, NATIVE_BUILD_DISPATCH_TIMEOUT_MS, NATIVE_BUILD_KEY_ENV, NATIVE_BUILD_KEY_BYTES, NativeBuildDispatchError, dispatchNativeBuild, mintNativeBuildKey, } from './native-build.js';
13
14
  export { StaleLeaseError } from './store.js';
14
15
  export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
15
16
  export { createPostgresAuditLog } from './store/audit.js';
16
17
  export { readReleaseState, requestReleaseAction } from './release/state.js';
18
+ export { checkMigrationCompatibility } from './release/migration-compatibility.js';
17
19
  export { createPostgresReleaseStore } from './store/postgres.js';
18
20
  export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryReleaseRequests, foundryRequestedChanges, } from './store/schema.js';
19
21
  export { COMPANY_DESTINATION_NAME_PREFIX, COMPANY_NETWORKS, DEFAULT_MAX_DESTINATIONS, DestinationCapacityError, PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, placed, placedOnPlatform, validateNetworkName, } from './plan/index.js';
@@ -0,0 +1,32 @@
1
+ -- @wtfalch/foundry: a company's native app settings and its native build runs (foundry#948, estate
2
+ -- `.plans/2026-10-22-app-shells.md`). Additive, like 0060_hosting_repos.sql and
3
+ -- 0055_release_requests.sql: a column with a default plus a new table, so a row written before this
4
+ -- reads back `[]` and an older reader ignores both. Safe on live rows when the container starts.
5
+ --
6
+ -- foundry#951: Boule owns the native settings, build runs and store keys now (estate#262) -- the
7
+ -- column and the table below are unused by this package's code from here on, but foundry#951 also
8
+ -- establishes that a migration only adds in a release where idle and live share a database, so
9
+ -- neither is dropped here. A later release drops both.
10
+
11
+ alter table foundry_declared_orgs
12
+ add column if not exists hosting_native_apps jsonb not null default '[]'::jsonb;
13
+
14
+ create table if not exists foundry_native_build_runs (
15
+ id bigserial primary key,
16
+ org_id text not null,
17
+ app text not null,
18
+ state text not null default 'queued',
19
+ log text,
20
+ created_at timestamptz not null default now(),
21
+ updated_at timestamptz not null default now()
22
+ );
23
+
24
+ alter table foundry_native_build_runs
25
+ drop constraint if exists foundry_native_build_runs_state_check;
26
+ alter table foundry_native_build_runs
27
+ add constraint foundry_native_build_runs_state_check
28
+ check (state in ('queued', 'building', 'uploaded', 'failed'));
29
+
30
+ -- Listing one app's runs, and (while foundry still checked it, before foundry#951) the `uploaded` look-up.
31
+ create index if not exists foundry_native_build_runs_app_idx
32
+ on foundry_native_build_runs (org_id, app, id desc);
@@ -0,0 +1,10 @@
1
+ -- @wtfalch/foundry: `kosmos_enabled` is `org.kosmos.enabled` (foundry#949): the company's own kosmos is set up
2
+ -- only when it asks. `kosmos_blue_green` is `org.kosmos.blueGreen` ({ steps?, stepHoldMinutes? }, as the other
3
+ -- two built-ins' own columns hold it) -- kept even though kosmos always runs blue/green once enabled, so tuning
4
+ -- its steps needs no new column later. Both default to off. Additive like 0059_org_builtin_blue_green.sql and
5
+ -- 0063_boule_apps.sql: `if not exists`, safe to run at container start on live rows.
6
+ -- Mirrors src/store/schema.ts's `declaredOrgs`.
7
+
8
+ alter table foundry_declared_orgs
9
+ add column if not exists kosmos_enabled boolean not null default false,
10
+ add column if not exists kosmos_blue_green jsonb;
@@ -55,7 +55,7 @@ export const signIn = {
55
55
  label: 'Who can make an account',
56
56
  kind: 'choice',
57
57
  required: true,
58
- default: 'open',
58
+ default: 'closed',
59
59
  choices: [
60
60
  {
61
61
  value: 'open',
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `wtfalch/shell`'s `build-app.yml`: the one GitHub Actions workflow that
3
+ * builds and signs a company's native app shell (estate
4
+ * `.plans/2026-10-22-app-shells.md`). Boule now owns the `NativeAppConfig`,
5
+ * the build runs and the company's store keys (estate#262, foundry#951);
6
+ * foundry keeps only two things, both in this file: dispatching the
7
+ * workflow, and the key Boule presents when it asks the host (Archon) to
8
+ * dispatch it.
9
+ *
10
+ * Neither function here is wired into `plan/diff.ts` or `plan/executor.ts`:
11
+ * `dispatchNativeBuild` runs inside Archon's own `/api/native/builds`
12
+ * route, not foundry's apply loop, and `mintNativeBuildKey` is left for the
13
+ * host to call from wherever it writes Boule's env (`executor.ts`'s
14
+ * `coolifyBoule`, the same way it already calls `mintIfAbsent` for
15
+ * `BOULE_OWNER_SEED_SECRET_ENV`) once that wiring has its own review --
16
+ * adding it here would touch every one of this package's Boule-convergence
17
+ * fixtures for one new env var, which is a larger change than this issue
18
+ * asks for.
19
+ */
20
+ export declare const NATIVE_BUILD_REPO = "wtfalch/shell";
21
+ export declare const NATIVE_BUILD_WORKFLOW_FILE = "build-app.yml";
22
+ export declare const NATIVE_BUILD_WORKFLOW_REF = "main";
23
+ /** Every GitHub call this module makes is cut off after this long. */
24
+ export declare const NATIVE_BUILD_DISPATCH_TIMEOUT_MS = 20000;
25
+ /** A `build-app.yml` dispatch that failed. The text is fixed: it never holds the token or a response body. */
26
+ export declare class NativeBuildDispatchError extends Error {
27
+ readonly status: number | null;
28
+ constructor(status: number | null);
29
+ }
30
+ export interface NativeBuildDispatchDeps {
31
+ /** A GitHub token that may dispatch workflows in {@link NATIVE_BUILD_REPO}. */
32
+ token: string;
33
+ /** Global fetch by default; tests give their own. */
34
+ fetchImpl?: typeof fetch;
35
+ timeoutMs?: number;
36
+ }
37
+ /**
38
+ * Dispatches `build-app.yml` with exactly `{ runId, origin }` as its inputs:
39
+ * `runId` is the run the caller (Archon, behind its own `/api/native/builds`
40
+ * route) already made, `origin` is the company's Boule address the job
41
+ * reports its status back to and trades its OIDC token for keys at. Foundry
42
+ * keeps no run store of its own any more -- Boule does -- so this is the one
43
+ * thing left for foundry to do with a run: start it.
44
+ */
45
+ export declare function dispatchNativeBuild(deps: NativeBuildDispatchDeps, run: {
46
+ runId: string;
47
+ origin: string;
48
+ }): Promise<void>;
49
+ /** The env name Boule presents this key under, to Archon's native-build route. */
50
+ export declare const NATIVE_BUILD_KEY_ENV = "NATIVE_BUILD_KEY";
51
+ export declare const NATIVE_BUILD_KEY_BYTES = 32;
52
+ /** The one thing {@link mintNativeBuildKey} needs from Coolify: a live app's env, to check whether it already has a key. */
53
+ export interface NativeBuildKeyEnvPort {
54
+ listEnvs(appId: string): Promise<ReadonlyArray<{
55
+ key: string;
56
+ value: string;
57
+ is_preview: boolean;
58
+ }>>;
59
+ }
60
+ /**
61
+ * Mints the per-company key Boule presents to Archon's native-build route
62
+ * (estate#262), the same way every other per-company key in this codebase is
63
+ * minted (estate#207): a random value, written to Boule's env once and never
64
+ * rotated, kept unchanged on every later apply once it is there. Returns
65
+ * `null` when Boule's env already holds one -- the caller then writes
66
+ * nothing, so the live value stands. `onSecret`, when given, is told the
67
+ * minted value so the caller can keep it out of its own logs and error
68
+ * messages.
69
+ */
70
+ export declare function mintNativeBuildKey(coolify: NativeBuildKeyEnvPort, appId: string, onSecret?: (value: string) => void): Promise<string | null>;
@@ -0,0 +1,92 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ /**
3
+ * `wtfalch/shell`'s `build-app.yml`: the one GitHub Actions workflow that
4
+ * builds and signs a company's native app shell (estate
5
+ * `.plans/2026-10-22-app-shells.md`). Boule now owns the `NativeAppConfig`,
6
+ * the build runs and the company's store keys (estate#262, foundry#951);
7
+ * foundry keeps only two things, both in this file: dispatching the
8
+ * workflow, and the key Boule presents when it asks the host (Archon) to
9
+ * dispatch it.
10
+ *
11
+ * Neither function here is wired into `plan/diff.ts` or `plan/executor.ts`:
12
+ * `dispatchNativeBuild` runs inside Archon's own `/api/native/builds`
13
+ * route, not foundry's apply loop, and `mintNativeBuildKey` is left for the
14
+ * host to call from wherever it writes Boule's env (`executor.ts`'s
15
+ * `coolifyBoule`, the same way it already calls `mintIfAbsent` for
16
+ * `BOULE_OWNER_SEED_SECRET_ENV`) once that wiring has its own review --
17
+ * adding it here would touch every one of this package's Boule-convergence
18
+ * fixtures for one new env var, which is a larger change than this issue
19
+ * asks for.
20
+ */
21
+ export const NATIVE_BUILD_REPO = 'wtfalch/shell';
22
+ export const NATIVE_BUILD_WORKFLOW_FILE = 'build-app.yml';
23
+ export const NATIVE_BUILD_WORKFLOW_REF = 'main';
24
+ /** Every GitHub call this module makes is cut off after this long. */
25
+ export const NATIVE_BUILD_DISPATCH_TIMEOUT_MS = 20_000;
26
+ /** A `build-app.yml` dispatch that failed. The text is fixed: it never holds the token or a response body. */
27
+ export class NativeBuildDispatchError extends Error {
28
+ status;
29
+ constructor(status) {
30
+ super(status === null
31
+ ? `dispatching ${NATIVE_BUILD_WORKFLOW_FILE} did not answer`
32
+ : `dispatching ${NATIVE_BUILD_WORKFLOW_FILE} failed with status ${status}`);
33
+ this.status = status;
34
+ this.name = 'NativeBuildDispatchError';
35
+ }
36
+ }
37
+ /**
38
+ * Dispatches `build-app.yml` with exactly `{ runId, origin }` as its inputs:
39
+ * `runId` is the run the caller (Archon, behind its own `/api/native/builds`
40
+ * route) already made, `origin` is the company's Boule address the job
41
+ * reports its status back to and trades its OIDC token for keys at. Foundry
42
+ * keeps no run store of its own any more -- Boule does -- so this is the one
43
+ * thing left for foundry to do with a run: start it.
44
+ */
45
+ export async function dispatchNativeBuild(deps, run) {
46
+ const doFetch = deps.fetchImpl ?? fetch;
47
+ const timeoutMs = deps.timeoutMs ?? NATIVE_BUILD_DISPATCH_TIMEOUT_MS;
48
+ let response;
49
+ try {
50
+ response = await doFetch(`https://api.github.com/repos/${NATIVE_BUILD_REPO}/actions/workflows/${NATIVE_BUILD_WORKFLOW_FILE}/dispatches`, {
51
+ method: 'POST',
52
+ headers: {
53
+ Authorization: `Bearer ${deps.token}`,
54
+ Accept: 'application/vnd.github+json',
55
+ 'X-GitHub-Api-Version': '2022-11-28',
56
+ 'Content-Type': 'application/json',
57
+ },
58
+ body: JSON.stringify({
59
+ ref: NATIVE_BUILD_WORKFLOW_REF,
60
+ inputs: { runId: run.runId, origin: run.origin },
61
+ }),
62
+ signal: AbortSignal.timeout(timeoutMs),
63
+ });
64
+ }
65
+ catch (error) {
66
+ console.error(`native build: dispatch did not answer (${error instanceof Error ? error.name : 'error'})`);
67
+ throw new NativeBuildDispatchError(null);
68
+ }
69
+ if (!response.ok)
70
+ throw new NativeBuildDispatchError(response.status);
71
+ }
72
+ /** The env name Boule presents this key under, to Archon's native-build route. */
73
+ export const NATIVE_BUILD_KEY_ENV = 'NATIVE_BUILD_KEY';
74
+ export const NATIVE_BUILD_KEY_BYTES = 32;
75
+ /**
76
+ * Mints the per-company key Boule presents to Archon's native-build route
77
+ * (estate#262), the same way every other per-company key in this codebase is
78
+ * minted (estate#207): a random value, written to Boule's env once and never
79
+ * rotated, kept unchanged on every later apply once it is there. Returns
80
+ * `null` when Boule's env already holds one -- the caller then writes
81
+ * nothing, so the live value stands. `onSecret`, when given, is told the
82
+ * minted value so the caller can keep it out of its own logs and error
83
+ * messages.
84
+ */
85
+ export async function mintNativeBuildKey(coolify, appId, onSecret) {
86
+ const envs = await coolify.listEnvs(appId);
87
+ if (envs.some((e) => e.key === NATIVE_BUILD_KEY_ENV && !e.is_preview))
88
+ return null;
89
+ const value = randomBytes(NATIVE_BUILD_KEY_BYTES).toString('base64url');
90
+ onSecret?.(value);
91
+ return value;
92
+ }
package/dist/org.d.ts CHANGED
@@ -301,7 +301,7 @@ export type AccessRule = {
301
301
  /** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
302
302
  export declare const APP_ENTRY_KEY = "app";
303
303
  /** Why a validator refused. `code` is stable and never reworded; `message` is for people. */
304
- export type OrgRefusalCode = 'hosting_tunnel_needs_hosting' | 'issuer_app_name_missing' | 'issuer_app_name_not_unique' | 'issuer_app_kind_unknown' | 'issuer_app_name_reserved_agora' | 'issuer_app_name_reserved_boule' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'ai_webhook_path_not_unique' | 'worker_route_not_unique' | 'database_not_unique' | 'access_hostname_not_unique' | 'access_hostname_invalid' | 'access_needs_hosting' | 'access_kind_unknown' | 'access_service_token_name_missing' | 'access_hostname_locked' | 'access_hostname_not_served' | 'gate_limit_invalid' | 'hosting_repo_invalid' | 'hosting_github_invalid' | 'gate_needs_domain' | 'gate_key_invalid' | 'gate_key_not_unique' | 'gate_needs_hostname' | 'gate_hostname_not_entry_hostname' | 'gate_hostname_held_twice' | 'gate_hostname_locked' | 'gate_limit_exceeded' | 'wildcard_needs_domain' | 'wildcard_held_twice' | 'hostnames_need_domain' | 'hostname_needed' | 'hostname_invalid' | 'hostname_not_unique' | 'hostname_locked' | 'hosted_name_invalid' | 'hosted_name_not_unique' | 'hosted_name_worker_app' | 'entry_workspace_invalid' | 'entry_workspace_unsupported' | 'entry_live_needs_workspace' | 'entry_live_commit_invalid' | 'gate_key_taken_by_workspace' | 'gate_key_taken_by_private_page' | 'private_page_invalid' | 'private_page_path_invalid' | 'private_page_duplicate' | 'private_page_limit' | 'private_page_needs_hosting' | 'private_page_roles_invalid' | 'private_page_people_invalid' | 'entry_key_invalid' | 'entry_key_reserved' | 'entry_repo_invalid' | 'entry_folder_invalid' | 'entry_branch_empty' | 'entry_build_pack_unknown' | 'entry_build_pack_unavailable' | 'entry_port_invalid' | 'entry_health_path_invalid' | 'entry_capabilities_invalid' | 'entry_database_needs_hosting' | 'blue_green_needs_hostname' | 'env_name_empty' | 'env_name_reserved' | 'env_source_platform_mail_key' | 'env_source_unknown' | 'env_shared_secret_name_invalid' | 'env_held_secret_name_mismatch' | 'env_held_secret_name_invalid' | 'env_looks_like_secret' | 'builtin_app_invalid' | 'blue_green_not_object' | 'blue_green_steps_length' | 'blue_green_steps_range' | 'blue_green_steps_order' | 'blue_green_steps_end' | 'blue_green_hold_invalid' | 'blue_green_advance_invalid' | 'blue_green_min_requests_invalid' | 'blue_green_error_margin_invalid' | 'blue_green_staff_first_invalid' | 'volumes_not_list' | 'volumes_too_many' | 'volume_name_invalid' | 'volume_name_logs' | 'volume_name_not_unique' | 'volume_mount_path_invalid' | 'volume_mount_path_too_long' | 'volume_mount_path_log_dir' | 'volume_mount_path_forbidden' | 'volume_mount_path_not_unique' | 'auth_needs_hosting' | 'auth_users_invalid' | 'auth_registration_invalid' | 'registration_open_needs_customers' | 'auth_replaces_broker_entry_invalid' | 'auth_replaces_broker_entry_owned' | 'auth_needs_exact_hostname' | 'mcp_needs_open_customers' | 'mcp_path_invalid' | 'mcp_onboarding_path_invalid' | 'auth_sign_in_invalid' | 'email_code_needs_customers' | 'auth_room_invalid' | 'auth_room_needs_customers' | 'auth_room_not_unique' | 'client_name_taken' | 'workspaces_needs_customers' | 'workspaces_needs_server' | 'workspaces_roles_invalid' | 'workspaces_role_reserved' | 'workspaces_invite_path_invalid' | 'workspaces_env_clash' | 'ai_needs_hosting' | 'ai_offerings_invalid' | 'ai_permissions_invalid' | 'ai_webhook_path_invalid' | 'ai_webhook_needs_exact_hostname' | 'ai_env_clash' | 'files_needs_hosting' | 'files_collections_invalid' | 'files_permissions_invalid' | 'files_share_host_invalid' | 'files_share_host_needs_domain' | 'files_env_clash' | 'worker_needs_hosting' | 'worker_needs_repo' | 'worker_needs_own_repo' | 'worker_key_too_long' | 'worker_artifact_invalid' | 'worker_route_invalid' | 'worker_route_off_domain' | 'worker_rate_limit_name_missing' | 'worker_rate_limit_namespace_missing' | 'worker_rate_limit_invalid' | 'worker_rate_limit_period_invalid' | 'worker_app_invalid' | 'edge_not_true' | 'edge_needs_hosting' | 'edge_needs_domain' | 'edge_env_clash' | 'mail_not_true' | 'mail_needs_hosting' | 'mail_env_clash' | 'jobs_not_true' | 'jobs_needs_hosting' | 'jobs_env_clash' | 'customer_grants_not_true' | 'customer_grants_needs_customers' | 'customer_grants_env_clash' | 'repo_use_invalid' | 'repo_use_needs_repo' | 'repo_use_env_clash' | 'audit_not_true' | 'audit_needs_hosting' | 'audit_needs_worker_database' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
304
+ export type OrgRefusalCode = 'hosting_tunnel_needs_hosting' | 'issuer_app_name_missing' | 'issuer_app_name_not_unique' | 'issuer_app_kind_unknown' | 'issuer_app_name_reserved_agora' | 'issuer_app_name_reserved_boule' | 'issuer_app_name_reserved_kosmos' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'ai_webhook_path_not_unique' | 'worker_route_not_unique' | 'database_not_unique' | 'access_hostname_not_unique' | 'access_hostname_invalid' | 'access_needs_hosting' | 'access_kind_unknown' | 'access_service_token_name_missing' | 'access_hostname_locked' | 'access_hostname_not_served' | 'gate_limit_invalid' | 'hosting_repo_invalid' | 'hosting_github_invalid' | 'gate_needs_domain' | 'gate_key_invalid' | 'gate_key_not_unique' | 'gate_needs_hostname' | 'gate_hostname_not_entry_hostname' | 'gate_hostname_held_twice' | 'gate_hostname_locked' | 'gate_limit_exceeded' | 'wildcard_needs_domain' | 'wildcard_held_twice' | 'hostnames_need_domain' | 'hostname_needed' | 'hostname_invalid' | 'hostname_not_unique' | 'hostname_locked' | 'hosted_name_invalid' | 'hosted_name_not_unique' | 'hosted_name_worker_app' | 'entry_workspace_invalid' | 'entry_workspace_unsupported' | 'entry_live_needs_workspace' | 'entry_live_commit_invalid' | 'gate_key_taken_by_workspace' | 'gate_key_taken_by_private_page' | 'private_page_invalid' | 'private_page_path_invalid' | 'private_page_duplicate' | 'private_page_limit' | 'private_page_needs_hosting' | 'private_page_roles_invalid' | 'private_page_people_invalid' | 'entry_key_invalid' | 'entry_key_reserved' | 'entry_repo_invalid' | 'entry_folder_invalid' | 'entry_branch_empty' | 'entry_build_pack_unknown' | 'entry_build_pack_unavailable' | 'entry_port_invalid' | 'entry_health_path_invalid' | 'entry_capabilities_invalid' | 'entry_database_needs_hosting' | 'blue_green_needs_hostname' | 'env_name_empty' | 'env_name_reserved' | 'env_source_platform_mail_key' | 'env_source_unknown' | 'env_shared_secret_name_invalid' | 'env_held_secret_name_mismatch' | 'env_held_secret_name_invalid' | 'env_looks_like_secret' | 'builtin_app_invalid' | 'blue_green_not_object' | 'blue_green_steps_length' | 'blue_green_steps_range' | 'blue_green_steps_order' | 'blue_green_steps_end' | 'blue_green_hold_invalid' | 'blue_green_advance_invalid' | 'blue_green_min_requests_invalid' | 'blue_green_error_margin_invalid' | 'blue_green_staff_first_invalid' | 'volumes_not_list' | 'volumes_too_many' | 'volume_name_invalid' | 'volume_name_logs' | 'volume_name_not_unique' | 'volume_mount_path_invalid' | 'volume_mount_path_too_long' | 'volume_mount_path_log_dir' | 'volume_mount_path_forbidden' | 'volume_mount_path_not_unique' | 'auth_needs_hosting' | 'auth_users_invalid' | 'auth_registration_invalid' | 'registration_open_needs_customers' | 'auth_replaces_broker_entry_invalid' | 'auth_replaces_broker_entry_owned' | 'auth_needs_exact_hostname' | 'mcp_needs_open_customers' | 'mcp_path_invalid' | 'mcp_onboarding_path_invalid' | 'auth_sign_in_invalid' | 'email_code_needs_customers' | 'auth_room_invalid' | 'auth_room_needs_customers' | 'auth_room_not_unique' | 'client_name_taken' | 'workspaces_needs_customers' | 'workspaces_needs_server' | 'workspaces_roles_invalid' | 'workspaces_role_reserved' | 'workspaces_invite_path_invalid' | 'workspaces_env_clash' | 'ai_needs_hosting' | 'ai_offerings_invalid' | 'ai_permissions_invalid' | 'ai_webhook_path_invalid' | 'ai_webhook_needs_exact_hostname' | 'ai_env_clash' | 'files_needs_hosting' | 'files_collections_invalid' | 'files_permissions_invalid' | 'files_share_host_invalid' | 'files_share_host_needs_domain' | 'files_env_clash' | 'worker_needs_hosting' | 'worker_needs_repo' | 'worker_needs_own_repo' | 'worker_key_too_long' | 'worker_artifact_invalid' | 'worker_route_invalid' | 'worker_route_off_domain' | 'worker_rate_limit_name_missing' | 'worker_rate_limit_namespace_missing' | 'worker_rate_limit_invalid' | 'worker_rate_limit_period_invalid' | 'worker_app_invalid' | 'edge_not_true' | 'edge_needs_hosting' | 'edge_needs_domain' | 'edge_env_clash' | 'mail_not_true' | 'mail_needs_hosting' | 'mail_env_clash' | 'jobs_not_true' | 'jobs_needs_hosting' | 'jobs_env_clash' | 'customer_grants_not_true' | 'customer_grants_needs_customers' | 'customer_grants_env_clash' | 'repo_use_invalid' | 'repo_use_needs_repo' | 'repo_use_env_clash' | 'audit_not_true' | 'audit_needs_hosting' | 'audit_needs_worker_database' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
305
305
  /**
306
306
  * One refusal: a stable `code`, the dotted `path` into {@link DeclaredOrg} it is about
307
307
  * (`hosting.entries.terminal.uses.auth.registration`) and the sentence for people.
@@ -484,6 +484,18 @@ export interface BouleApp extends OrgBuiltinApp {
484
484
  }
485
485
  /** True while the company's Boule runs apps (`org.boule.apps`). */
486
486
  export declare const appsOn: (org: Pick<DeclaredOrg, "boule">) => boolean;
487
+ /**
488
+ * The company's kosmos: its own creative workspace (canvas, libraries, photos, pages) that drives the
489
+ * frontend of its apps. Unlike Boule and Agora, which every company runs, kosmos is set up only when the
490
+ * company asks (William, 2026-10-22) -- absent, or `enabled` absent or `false`, plans nothing for it.
491
+ * `enabled: true` plans its ZITADEL client, its database and its live and idle copies; turning it back off
492
+ * parks the app but keeps the database, the same rule every other switched-off service follows.
493
+ */
494
+ export interface KosmosApp extends OrgBuiltinApp {
495
+ enabled?: boolean;
496
+ }
497
+ /** True while the company's kosmos is turned on (`org.kosmos.enabled`). */
498
+ export declare const kosmosOn: (org: Pick<DeclaredOrg, "kosmos">) => boolean;
487
499
  /** The traffic steps (percent to the new color) and the hold at each step. Both default in `plan/blue-green.ts`. */
488
500
  export interface BlueGreenConfig {
489
501
  /** Ascending, each 1..100, ending at 100. */
@@ -798,8 +810,13 @@ export declare function refuseHosting(hosting: HostingConfig, names?: HostingNam
798
810
  export declare function companyGithubFor(hosting: Pick<HostingConfig, 'github'>, repo: string | null): HostingGithub | undefined;
799
811
  /** `blueGreen`: steps ascending, each a whole number 1..100, the last 100; a hold of 1..1440 minutes. */
800
812
  export declare function validateBlueGreen(value: unknown, at: string): string | null;
801
- /** {@link refuseBlueGreen} for the `blueGreen` of the company's Boule and Agora; the same codes. */
802
- export declare function refuseBuiltinBlueGreen(org: Pick<DeclaredOrg, 'boule' | 'agora'>): OrgRefusal | null;
813
+ /**
814
+ * {@link refuseBlueGreen} for the `blueGreen` of the company's Boule, Agora and kosmos; the same codes.
815
+ * Also refuses an `issuerApps` entry named `kosmos` while kosmos is on -- the same reservation
816
+ * {@link refuseHosting}'s own loop already gives Agora and Boule, but kosmos is opt-in, so it is checked here
817
+ * instead, the same way {@link validateSite} reserves `cms` only while `site.enabled` is true.
818
+ */
819
+ export declare function refuseBuiltinBlueGreen(org: Pick<DeclaredOrg, 'boule' | 'agora' | 'kosmos' | 'hosting'>): OrgRefusal | null;
803
820
  /** The env names `uses.repo` writes; `env` may not name them. */
804
821
  export declare const REPO_ENTRY_ENV_NAMES: readonly ["GIT_REPO_URL", "GIT_SSH_KEY"];
805
822
  /** The env name `uses.customerGrants` writes; `env` may not name it. */
@@ -1226,6 +1243,8 @@ export interface DeclaredOrg {
1226
1243
  boule?: BouleApp;
1227
1244
  /** The company's Agora, a static app: its release moves on hard fails and time only. Absent means no blue/green. */
1228
1245
  agora?: OrgBuiltinApp;
1246
+ /** The company's kosmos. Absent or `enabled: false` means it is not set up (2026-10-22). */
1247
+ kosmos?: KosmosApp;
1229
1248
  /** Absent means `{ enabled: false, signUp: false }`. Optional so every existing literal and fixture compiles unchanged. */
1230
1249
  customers?: CustomersConfig;
1231
1250
  /** Parts of this company held on their own version. Absent or null follows the platform. Ignored for the owner company. */
package/dist/org.js CHANGED
@@ -33,6 +33,8 @@ export function normalizeTesters(testers) {
33
33
  }
34
34
  /** True while the company's Boule runs apps (`org.boule.apps`). */
35
35
  export const appsOn = (org) => org.boule?.apps === true;
36
+ /** True while the company's kosmos is turned on (`org.kosmos.enabled`). */
37
+ export const kosmosOn = (org) => org.kosmos?.enabled === true;
36
38
  /** The most volumes one entry may declare. */
37
39
  export const MAX_ENTRY_VOLUMES = 8;
38
40
  /** The env names `uses.auth.workspaces` writes; `env` may not name them. */
@@ -763,9 +765,14 @@ function refuseBlueGreen(value, at, path) {
763
765
  }
764
766
  return null;
765
767
  }
766
- /** {@link refuseBlueGreen} for the `blueGreen` of the company's Boule and Agora; the same codes. */
768
+ /**
769
+ * {@link refuseBlueGreen} for the `blueGreen` of the company's Boule, Agora and kosmos; the same codes.
770
+ * Also refuses an `issuerApps` entry named `kosmos` while kosmos is on -- the same reservation
771
+ * {@link refuseHosting}'s own loop already gives Agora and Boule, but kosmos is opt-in, so it is checked here
772
+ * instead, the same way {@link validateSite} reserves `cms` only while `site.enabled` is true.
773
+ */
767
774
  export function refuseBuiltinBlueGreen(org) {
768
- for (const app of ['boule', 'agora']) {
775
+ for (const app of ['boule', 'agora', 'kosmos']) {
769
776
  const value = org[app];
770
777
  if (value === undefined)
771
778
  continue;
@@ -781,6 +788,15 @@ export function refuseBuiltinBlueGreen(org) {
781
788
  ? 'boule.apps must be true or false'
782
789
  : `${app} has no apps switch: only the company's Boule runs apps`);
783
790
  }
791
+ const enabled = value.enabled;
792
+ if (enabled !== undefined && (app !== 'kosmos' || typeof enabled !== 'boolean')) {
793
+ return refusal('builtin_app_invalid', `${app}.enabled`, app === 'kosmos'
794
+ ? 'kosmos.enabled must be true or false'
795
+ : `${app} has no enabled switch: it is always on`);
796
+ }
797
+ }
798
+ if (kosmosOn(org) && org.hosting.issuerApps.some((app) => app.name === 'kosmos')) {
799
+ return refusal('issuer_app_name_reserved_kosmos', 'hosting.issuerApps.kosmos.name', "kosmos.enabled reserves the issuerApps name 'kosmos'");
784
800
  }
785
801
  return null;
786
802
  }
@@ -899,6 +915,7 @@ function validateEntryAuth(entry, hosting, domain) {
899
915
  if (entry.key === 'agora' ||
900
916
  entry.key === 'boule' ||
901
917
  entry.key === 'cms' ||
918
+ entry.key === 'kosmos' ||
902
919
  hosting.issuerApps.some((app) => app.name === entry.key)) {
903
920
  return refusal('client_name_taken', `hosting.entries.${entry.key}.key`, `${at} client name '${entry.key}' is already held by another ZITADEL client`);
904
921
  }
@@ -1881,6 +1898,8 @@ function orgServiceSchema(service, schema, legacyKey, split = true) {
1881
1898
  export const ORG_SERVICE_SCHEMAS = [
1882
1899
  orgServiceSchema('Boule', 'boule', () => 'coolify_boule_database'),
1883
1900
  orgServiceSchema('CMS', 'cms', () => 'coolify_cms_database'),
1901
+ // kosmos, like CMS, sits behind its own feature switch (`KosmosApp.enabled`); see `orgServiceSchemaEnabled`.
1902
+ orgServiceSchema('kosmos', 'kosmos', () => 'coolify_kosmos_database'),
1884
1903
  orgServiceSchema("chat's Synapse homeserver", 'chat_synapse', () => 'coolify_chat_synapse_database', false),
1885
1904
  orgServiceSchema("chat's matrix-authentication-service", 'chat_mas', () => 'coolify_chat_mas_database', false),
1886
1905
  orgServiceSchema("chat's own service", 'chat_service', () => 'coolify_chat_service_database'),
@@ -1914,6 +1933,10 @@ export function orgServiceDatabase(org, home) {
1914
1933
  database = underscored(`${org.slug}-cms-db`);
1915
1934
  runtimeRole = 'cms_rt';
1916
1935
  break;
1936
+ case 'kosmos':
1937
+ database = underscored(`${org.slug}-kosmos-db`);
1938
+ runtimeRole = 'kosmos_rt';
1939
+ break;
1917
1940
  case 'chat_service':
1918
1941
  database = 'chat';
1919
1942
  break;
@@ -1,4 +1,4 @@
1
- import type { DeclaredOrg, OrgId } from '../org.js';
1
+ import { type DeclaredOrg, type OrgId } from '../org.js';
2
2
  /**
3
3
  * #164: what both the planner (`diff.ts`) and the executor (`executor.ts`)
4
4
  * need to agree on for an org's Agora -- the hostname, the values the host
@@ -1,5 +1,7 @@
1
+ import { kosmosOn } from '../org.js';
1
2
  import { chatUpstreams } from './chat-matrix.js';
2
3
  import { FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY } from './core-files.js';
4
+ import { kosmosHostnameFor } from './kosmos.js';
3
5
  import { SIGN_IN_UPSTREAM_ENV, inAppHostApplied, signInUpstream, staleSignInUpstream, } from './sign-in.js';
4
6
  /**
5
7
  * #164: what both the planner (`diff.ts`) and the executor (`executor.ts`)
@@ -307,6 +309,8 @@ export function desiredAgoraEnv(org, recorded, rawHost) {
307
309
  chat: { enabled: org.chat.enabled },
308
310
  // Agora draws call buttons only when this is true.
309
311
  calls: { enabled: org.calls.enabled },
312
+ // foundry#949: kosmos is opt-in, so Agora is told both whether it is on and where it lives.
313
+ kosmos: { enabled: kosmosOn(org), url: `https://${kosmosHostnameFor(org)}` },
310
314
  // In-app sign-in: Agora reads this at runtime (one image serves every company). Set with `SIGNIN_UPSTREAM`
311
315
  // only, so Agora never shows the in-app screen before the login can serve it.
312
316
  ...(inAppHostApplied(org, recorded, agoraHostnameFor(org)) ? { signInInApp: true } : {}),
@@ -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
@@ -164,11 +164,14 @@ export interface BlueGreenSubject {
164
164
  * (address check, crash loop) and moves up by time.
165
165
  */
166
166
  reporting: boolean;
167
- /** Set for Boule and Agora, which are not hosting entries. */
167
+ /** Set for Boule, Agora and kosmos, which are not hosting entries. */
168
168
  builtin?: BuiltinApp;
169
169
  }
170
- /** The apps foundry runs for every company, which can declare blue/green (`org.boule`, `org.agora`). */
171
- 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';
172
175
  export declare const BUILTIN_APPS: readonly BuiltinApp[];
173
176
  /** What foundry builds each built-in app from; `executor.ts` creates the app from the same values. */
174
177
  export declare const BUILTIN_APP: {
@@ -188,6 +191,14 @@ export declare const BUILTIN_APP: {
188
191
  readonly database: false;
189
192
  readonly reporting: false;
190
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
+ };
191
202
  };
192
203
  /**
193
204
  * A hosting entry that declares `blueGreen`, as a subject; `null` when it does not or cannot run.
@@ -195,11 +206,12 @@ export declare const BUILTIN_APP: {
195
206
  */
196
207
  export declare function entrySubject(org: DeclaredOrg, entry: HostingEntry, recorded?: Readonly<Record<string, string>>): BlueGreenSubject | null;
197
208
  /**
198
- * The company's Boule or Agora as a subject; `null` when it does not declare blue/green. The running app keeps
199
- * 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.
200
212
  */
201
213
  export declare function builtinSubject(org: DeclaredOrg, app: BuiltinApp, recorded?: Readonly<Record<string, string>>): BlueGreenSubject | null;
202
- /** 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. */
203
215
  export declare function blueGreenSubjects(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): BlueGreenSubject[];
204
216
  /** Whether the org declares blue/green for any of its apps. */
205
217
  export declare const declaresBlueGreen: (org: DeclaredOrg) => boolean;
@@ -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';
@@ -165,7 +167,7 @@ export function reportingEnv(input) {
165
167
  { key: REPORTING_ENV.healthToken, value: input.healthToken },
166
168
  ];
167
169
  }
168
- export const BUILTIN_APPS = ['boule', 'agora'];
170
+ export const BUILTIN_APPS = ['boule', 'agora', 'kosmos'];
169
171
  /** What foundry builds each built-in app from; `executor.ts` creates the app from the same values. */
170
172
  export const BUILTIN_APP = {
171
173
  boule: {
@@ -185,7 +187,31 @@ export const BUILTIN_APP = {
185
187
  database: false,
186
188
  reporting: false,
187
189
  },
190
+ kosmos: {
191
+ repo: 'wtfalch/kosmos',
192
+ dockerfile: '/Dockerfile',
193
+ port: 3000,
194
+ healthPath: '/api/health',
195
+ database: true,
196
+ reporting: true,
197
+ },
188
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
+ }
189
215
  /**
190
216
  * A hosting entry that declares `blueGreen`, as a subject; `null` when it does not or cannot run.
191
217
  * `recorded` names the blue app: an app that ran before blue/green keeps its own name (see {@link liveAppNameFor}).
@@ -213,13 +239,14 @@ export function entrySubject(org, entry, recorded) {
213
239
  };
214
240
  }
215
241
  /**
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.
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.
218
245
  */
219
246
  export function builtinSubject(org, app, recorded = {}) {
220
- const config = org[app]?.blueGreen;
221
- if (!config)
247
+ if (!builtinDeclaresBlueGreen(org, app))
222
248
  return null;
249
+ const config = org[app]?.blueGreen ?? {};
223
250
  const spec = BUILTIN_APP[app];
224
251
  const base = `${org.slug}-${app}`;
225
252
  return {
@@ -233,7 +260,7 @@ export function builtinSubject(org, app, recorded = {}) {
233
260
  buildPack: 'dockerfile',
234
261
  healthPath: spec.healthPath,
235
262
  database: spec.database,
236
- hostnames: [app === 'boule' ? bouleHostnameFor(org) : agoraHostnameFor(org)],
263
+ hostnames: [builtinHostnameFor(org, app)],
237
264
  config,
238
265
  blueAppKey: `coolify:${app}:${org.id}`,
239
266
  dockerfileLocation: spec.dockerfile,
@@ -241,7 +268,7 @@ export function builtinSubject(org, app, recorded = {}) {
241
268
  builtin: app,
242
269
  };
243
270
  }
244
- /** 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. */
245
272
  export function blueGreenSubjects(org, recorded) {
246
273
  const subjects = [];
247
274
  for (const entry of org.hosting.entries) {
@@ -258,7 +285,7 @@ export function blueGreenSubjects(org, recorded) {
258
285
  }
259
286
  /** Whether the org declares blue/green for any of its apps. */
260
287
  export const declaresBlueGreen = (org) => org.hosting.entries.some((entry) => entry.blueGreen) ||
261
- BUILTIN_APPS.some((app) => org[app]?.blueGreen);
288
+ BUILTIN_APPS.some((app) => builtinDeclaresBlueGreen(org, app));
262
289
  /**
263
290
  * The Coolify name of the entry's app on the primary. A blue/green entry's app is its blue copy: the recorded
264
291
  * name; else the entry's own name when its app is already recorded (it ran before blue/green and is adopted as
@@ -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`),