@wtfalch/foundry 0.42.1 → 0.44.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 (73) hide show
  1. package/dist/bootstrap/versions.d.ts +2 -0
  2. package/dist/index.d.ts +8 -4
  3. package/dist/index.js +4 -2
  4. package/dist/migrations/0055_release_requests.sql +20 -0
  5. package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
  6. package/dist/migrations/0057_org_customers.sql +29 -0
  7. package/dist/org.d.ts +55 -0
  8. package/dist/org.js +90 -27
  9. package/dist/plan/archon-blue-green.d.ts +54 -0
  10. package/dist/plan/archon-blue-green.js +72 -0
  11. package/dist/plan/blue-green-apply.d.ts +82 -0
  12. package/dist/plan/blue-green-apply.js +360 -0
  13. package/dist/plan/blue-green.d.ts +223 -0
  14. package/dist/plan/blue-green.js +444 -0
  15. package/dist/plan/boule.d.ts +43 -2
  16. package/dist/plan/boule.js +48 -1
  17. package/dist/plan/broker-entries.d.ts +28 -0
  18. package/dist/plan/broker-entries.js +46 -1
  19. package/dist/plan/cloudflared-connector.d.ts +26 -0
  20. package/dist/plan/cloudflared-connector.js +37 -0
  21. package/dist/plan/diff.d.ts +20 -0
  22. package/dist/plan/diff.js +207 -20
  23. package/dist/plan/executor.d.ts +134 -2
  24. package/dist/plan/executor.js +517 -25
  25. package/dist/plan/hostnames.d.ts +7 -0
  26. package/dist/plan/hostnames.js +17 -2
  27. package/dist/plan/locked-hostnames.d.ts +11 -0
  28. package/dist/plan/locked-hostnames.js +13 -0
  29. package/dist/plan/mcp.d.ts +57 -0
  30. package/dist/plan/mcp.js +131 -0
  31. package/dist/plan/org-names.d.ts +2 -2
  32. package/dist/plan/org-names.js +16 -1
  33. package/dist/plan/plan-apply.d.ts +77 -0
  34. package/dist/plan/plan-apply.js +267 -5
  35. package/dist/plan/platform-diff.d.ts +9 -0
  36. package/dist/plan/platform-diff.js +24 -1
  37. package/dist/plan/platform-providers.d.ts +15 -1
  38. package/dist/plan/platform-providers.js +90 -5
  39. package/dist/plan/proxy-labels.d.ts +2 -2
  40. package/dist/plan/proxy-labels.js +14 -4
  41. package/dist/plan/release-host.d.ts +59 -0
  42. package/dist/plan/release-host.js +239 -0
  43. package/dist/plan/remote-database.d.ts +124 -0
  44. package/dist/plan/remote-database.js +373 -0
  45. package/dist/plan/worker-app-database.d.ts +148 -0
  46. package/dist/plan/worker-app-database.js +424 -0
  47. package/dist/plan/worker-app.d.ts +320 -0
  48. package/dist/plan/worker-app.js +792 -0
  49. package/dist/plan/zitadel-machine-user.d.ts +13 -1
  50. package/dist/plan/zitadel-machine-user.js +80 -0
  51. package/dist/platform.d.ts +11 -1
  52. package/dist/platform.js +4 -1
  53. package/dist/release/decide.d.ts +95 -0
  54. package/dist/release/decide.js +242 -0
  55. package/dist/release/health.d.ts +48 -0
  56. package/dist/release/health.js +61 -0
  57. package/dist/release/run.d.ts +40 -0
  58. package/dist/release/run.js +56 -0
  59. package/dist/release/state.d.ts +64 -0
  60. package/dist/release/state.js +126 -0
  61. package/dist/release/types.d.ts +54 -0
  62. package/dist/release/types.js +4 -0
  63. package/dist/requested-changes.d.ts +12 -3
  64. package/dist/requested-changes.js +22 -0
  65. package/dist/store/postgres.d.ts +8 -0
  66. package/dist/store/postgres.js +67 -8
  67. package/dist/store/schema.d.ts +188 -0
  68. package/dist/store/schema.js +15 -0
  69. package/dist/worker-app-config.d.ts +151 -0
  70. package/dist/worker-app-config.js +438 -0
  71. package/package.json +1 -1
  72. package/versions.digests.json +2 -1
  73. package/versions.json +2 -1
@@ -18,6 +18,8 @@ export interface PinnedVersions {
18
18
  busybox: string;
19
19
  /** The sign-in database's image (#746). Its major version is the data directory's format: a bump past 16 needs a dump and restore, not an image change. */
20
20
  postgres: string;
21
+ /** The load balancer of a blue/green app (`plan/blue-green.ts`): Traefik with a file provider. */
22
+ traefik: string;
21
23
  }
22
24
  export declare function loadVersions(): PinnedVersions;
23
25
  export declare const VERSIONS: PinnedVersions;
package/dist/index.d.ts CHANGED
@@ -1,11 +1,11 @@
1
- export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceSchema, ServicesConfig, SignInConfig, VersionPin, VersionPinPart, } from './org.js';
2
- export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateSignIn, validateVersionPin, validateSite, } from './org.js';
1
+ export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, WorkerAppConfig, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceSchema, ServicesConfig, SignInConfig, VersionPin, VersionPinPart, } from './org.js';
2
+ export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateCustomers, validateSignIn, validateVersionPin, validateSite, } from './org.js';
3
3
  export type { DeclaredPlatform, PlatformService, PlatformServiceKey, PlatformSfuService, HeartbeatsProvider, } from './platform.js';
4
4
  export { PLATFORM_SERVICE_KEYS, HEARTBEATS_PROVIDERS, defaultPlatformHostname, validatePlatform, } from './platform.js';
5
5
  import { type PinnedVersions } from './bootstrap/versions.js';
6
6
  export type { PinnedVersions };
7
7
  export declare const PINNED_VERSIONS: Readonly<PinnedVersions>;
8
- export type { RequestableField, CallsChange, StorageLimitChange, AiLimitChange, DomainChange, MailTierChange, RequestedChangeValue, ChangeClassification, RequestedChangeState, RequestedChange, } from './requested-changes.js';
8
+ export type { RequestableField, CallsChange, CustomersChange, StorageLimitChange, AiLimitChange, DomainChange, MailTierChange, RequestedChangeValue, ChangeClassification, RequestedChangeState, RequestedChange, } from './requested-changes.js';
9
9
  export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requested-changes.js';
10
10
  export type { SlugRejectionReason, SlugRejection } from './slug.js';
11
11
  export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
@@ -16,8 +16,12 @@ export { StaleLeaseError } from './store.js';
16
16
  export type { PlanApply, DestroyProgress } from './apply.js';
17
17
  export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
18
18
  export { createPostgresAuditLog } from './store/audit.js';
19
+ export type { Color, ReleaseAction, ReleaseSignal, ReleaseState, WindowState, } from './release/types.js';
20
+ export type { ReleaseStore } from './release/state.js';
21
+ export { readReleaseState, requestReleaseAction } from './release/state.js';
22
+ export { createPostgresReleaseStore } from './store/postgres.js';
19
23
  export type { FoundryStoreDb } from './store/postgres.js';
20
- export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, 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';
24
+ 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';
21
25
  export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, StepSystem, PlanDetail, OrgPostgresPlan, PlatformPlanDetail, MailRoute, AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, FoundryApplyOptions, UnconfirmedUpgrade, } from './plan/index.js';
22
26
  export { COMPANY_DESTINATION_NAME_PREFIX, COMPANY_NETWORKS, DEFAULT_MAX_DESTINATIONS, DestinationCapacityError, PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, placed, placedOnPlatform, validateNetworkName, } from './plan/index.js';
23
27
  export { AdoptRefusedError } from './plan/index.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateSignIn, validateVersionPin, validateSite, } from './org.js';
1
+ export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateCustomers, validateSignIn, validateVersionPin, validateSite, } from './org.js';
2
2
  export { PLATFORM_SERVICE_KEYS, HEARTBEATS_PROVIDERS, defaultPlatformHostname, validatePlatform, } from './platform.js';
3
3
  // archon#273: the pinned platform service versions the planner deploys, so a
4
4
  // host can show declared against running. Readonly: it is the planner's own
@@ -10,7 +10,9 @@ export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
10
10
  export { StaleLeaseError } from './store.js';
11
11
  export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
12
12
  export { createPostgresAuditLog } from './store/audit.js';
13
- export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
13
+ export { readReleaseState, requestReleaseAction } from './release/state.js';
14
+ export { createPostgresReleaseStore } from './store/postgres.js';
15
+ export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryReleaseRequests, foundryRequestedChanges, } from './store/schema.js';
14
16
  export { COMPANY_DESTINATION_NAME_PREFIX, COMPANY_NETWORKS, DEFAULT_MAX_DESTINATIONS, DestinationCapacityError, PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, placed, placedOnPlatform, validateNetworkName, } from './plan/index.js';
15
17
  export { AdoptRefusedError } from './plan/index.js';
16
18
  export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './plan/index.js';
@@ -0,0 +1,20 @@
1
+ -- @wtfalch/foundry: an operator's release action (promote, roll back, clear a
2
+ -- held digest, decide at the cap) waiting for the release runner. The runner
3
+ -- marks a row consumed in one statement, so each request is applied once.
4
+ -- Additive: a new table, no existing row touched. Copies 0036_applied_versions
5
+ -- (an appended table with no foreign key, because `org_id` may be `platform`).
6
+ -- Mirrors src/store/schema.ts's `foundryReleaseRequests`.
7
+
8
+ create table if not exists foundry_release_requests (
9
+ id bigserial primary key,
10
+ org_id text not null,
11
+ entry_key text not null,
12
+ action jsonb not null,
13
+ actor text not null,
14
+ created_at timestamptz not null default now(),
15
+ consumed_at timestamptz
16
+ );
17
+
18
+ create index if not exists foundry_release_requests_open_idx
19
+ on foundry_release_requests (org_id, entry_key, id)
20
+ where consumed_at is null;
@@ -0,0 +1,7 @@
1
+ -- @wtfalch/foundry: `archon_blue_green` is `platform.archon.blueGreen` as JSON ({ steps?, stepHoldMinutes? });
2
+ -- null (the default) keeps platform-archon on one app. Copies 0052_platform_mail_relay.sql: one
3
+ -- nullable column added with `if not exists`, so it is safe to run at container start on live rows.
4
+ -- Mirrors src/store/schema.ts's `foundryDeclaredPlatform` table addition.
5
+
6
+ alter table foundry_declared_platform
7
+ add column if not exists archon_blue_green jsonb;
@@ -0,0 +1,29 @@
1
+ -- @wtfalch/foundry: a company's customers switch (C3). `customers_enabled` gives the company a
2
+ -- `<slug> customers` sign-in organisation and a read-only directory credential for its Boule;
3
+ -- `customers_sign_up` lets people register themselves in it. Both default off.
4
+ -- Mirrors src/store/schema.ts's `declaredOrgs`.
5
+
6
+ alter table foundry_declared_orgs
7
+ add column if not exists customers_enabled boolean not null default false,
8
+ add column if not exists customers_sign_up boolean not null default false;
9
+
10
+ -- A company that already signs a hosting entry's users in as customers keeps working. Until now
11
+ -- foundry forced self-registration on for every customers organisation, so those companies carry
12
+ -- both switches on: the live state does not change. A company declared after this migration
13
+ -- starts with sign-up off.
14
+ update foundry_declared_orgs
15
+ set customers_enabled = true,
16
+ customers_sign_up = true
17
+ where jsonb_typeof(hosting_entries) = 'array'
18
+ and exists (
19
+ select 1
20
+ from jsonb_array_elements(hosting_entries) as entry
21
+ where entry -> 'uses' -> 'auth' ->> 'users' = 'customers'
22
+ );
23
+
24
+ -- Requested changes may now name `customers`.
25
+ alter table foundry_requested_changes
26
+ drop constraint if exists foundry_requested_changes_field_check;
27
+ alter table foundry_requested_changes
28
+ add constraint foundry_requested_changes_field_check
29
+ check (field in ('calls', 'customers', 'storageLimit', 'aiLimit', 'domain', 'mailTier'));
package/dist/org.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { type WorkerAppConfig } from './worker-app-config.js';
2
+ export type { WorkerAppConfig } from './worker-app-config.js';
1
3
  /**
2
4
  * Opaque, minted by foundry when an org is created. Keys the declared org,
3
5
  * the applied state, every estate-wide service's id map, and the chat
@@ -355,6 +357,23 @@ export interface HostingEntry {
355
357
  * is the org's own domain.
356
358
  */
357
359
  worker?: WorkerConfig;
360
+ /**
361
+ * A Cloudflare Worker with Durable Objects and Containers, hosted by foundry
362
+ * as an option any org can declare (ADR 0002). Exclusive with `worker` and a
363
+ * Coolify `repo`; `validateEntryWorkerApp` states the rest.
364
+ */
365
+ workerApp?: WorkerAppConfig;
366
+ /**
367
+ * Blue/green releases (docs/adr/0001-blue-green-releases.md). Presence turns it on: foundry runs the app as a
368
+ * blue and a green copy behind a load balancer on every server it manages, and the tunnel routes to the balancer.
369
+ */
370
+ blueGreen?: BlueGreenConfig;
371
+ }
372
+ /** The traffic steps (percent to the new color) and the hold at each step. Both default in `plan/blue-green.ts`. */
373
+ export interface BlueGreenConfig {
374
+ /** Ascending, each 1..100, ending at 100. */
375
+ steps?: number[];
376
+ stepHoldMinutes?: number;
358
377
  }
359
378
  /** One named volume on a hosting entry's app. */
360
379
  export interface EntryVolume {
@@ -430,7 +449,19 @@ export interface EntryAuth {
430
449
  users: 'staff' | 'customers';
431
450
  registration: 'open' | 'closed';
432
451
  replacesBrokerEntry?: string;
452
+ /**
453
+ * The entry is an OAuth resource server for MCP clients (auth ADR 0002): the
454
+ * broker is the authorization server, ZITADEL signs the person in. `path` is
455
+ * the MCP endpoint on the entry's host; the resource is
456
+ * `https://<the entry's host><path>`, derived and never declared. Needs
457
+ * `users: 'customers'` and `registration: 'open'`.
458
+ */
459
+ mcp?: {
460
+ path: string;
461
+ };
433
462
  }
463
+ /** The MCP endpoint path: absolute, plain characters, no dot segments, no query. */
464
+ export declare const MCP_PATH: RegExp;
434
465
  /**
435
466
  * AI for one entry: a key below the org's own ai key, the `AI_*` env and,
436
467
  * with `webhookPath`, the ai webhook. `offerings` is what the app may call,
@@ -551,7 +582,11 @@ export declare const heldEnvRef: (orgId: string, entryKey: string, name: string)
551
582
  export declare function validateHosting(hosting: HostingConfig, names?: {
552
583
  domain: string | null;
553
584
  locked: ReadonlySet<string>;
585
+ slug?: string;
586
+ siteOwnsApex?: boolean;
554
587
  }): string | null;
588
+ /** `blueGreen`: steps ascending, each a whole number 1..100, the last 100; a hold of 1..1440 minutes. */
589
+ export declare function validateBlueGreen(value: unknown, at: string): string | null;
555
590
  export declare function validateDomain(domain: string): string | null;
556
591
  /**
557
592
  * A broker entry name foundry writes itself: Archon's, and any company's Boule.
@@ -651,6 +686,24 @@ export interface SignInConfig {
651
686
  * own sign-in stack gets the second login app.
652
687
  */
653
688
  export declare function validateSignIn(org: Pick<DeclaredOrg, 'signIn' | 'domain' | 'issuer' | 'services'>): string | null;
689
+ /**
690
+ * A company's customers: people outside the company who sign in to its apps (C3, campaign
691
+ * 2026-10-15). `enabled` gives the company a `<slug> customers` ZITADEL organisation and a
692
+ * read-only directory credential for its Boule. `signUp` lets people register themselves in
693
+ * that organisation; it needs `enabled` and is off by default. Both are requestable (`customers`).
694
+ */
695
+ export interface CustomersConfig {
696
+ enabled: boolean;
697
+ signUp: boolean;
698
+ }
699
+ /**
700
+ * `null` when `customers` is valid: both switches are booleans, sign-up needs customers on, and
701
+ * an entry with `registration: 'open'` needs both, because open registration signs people up into
702
+ * the customers organisation.
703
+ */
704
+ export declare function validateCustomers(org: Pick<DeclaredOrg, 'customers'> & {
705
+ hosting: Pick<HostingConfig, 'entries'>;
706
+ }): string | null;
654
707
  /** The only reader of `services.own`. Every company runs its own services, the platform's owner company too; a company without a domain runs them on oikos.host. */
655
708
  export declare function ownsServices(org: Pick<DeclaredOrg, 'services'>): boolean;
656
709
  /** The pure part of the `services` rules; that no two orgs share an `sfuSlot` is the store's check. */
@@ -933,6 +986,8 @@ export interface DeclaredOrg {
933
986
  services?: ServicesConfig;
934
987
  /** Absent means `{ inApp: false }`. Optional so every existing literal and fixture compiles unchanged. */
935
988
  signIn?: SignInConfig;
989
+ /** Absent means `{ enabled: false, signUp: false }`. Optional so every existing literal and fixture compiles unchanged. */
990
+ customers?: CustomersConfig;
936
991
  /** Parts of this company held on their own version. Absent or null follows the platform. Ignored for the owner company. */
937
992
  versionPin?: VersionPin | null;
938
993
  sso: SsoConfig;
package/dist/org.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { floatingRefBlocked, floatingTagBlocked } from './bootstrap/versions.js';
2
+ import { entryHasDatabase, validateArtifact, validateEntryWorkerApp, } from './worker-app-config.js';
2
3
  /** {@link MailConfig.mailApps} when the org declares none of its own (#34, decided 2026-09-30). */
3
4
  export const DEFAULT_MAIL_APPS = ['agora', 'webmail'];
4
5
  /** {@link MailConfig.noReplyLocalPart}'s own default, restated wherever it is read rather than imported (this file's `LOG_DIR`-style convention, named in `plan/executor.ts`'s own doc comments). */
@@ -10,6 +11,8 @@ export const DEFAULT_GATE_LIMIT = 3;
10
11
  export const APP_ENTRY_KEY = 'app';
11
12
  /** The most volumes one entry may declare. */
12
13
  export const MAX_ENTRY_VOLUMES = 8;
14
+ /** The MCP endpoint path: absolute, plain characters, no dot segments, no query. */
15
+ export const MCP_PATH = /^\/[A-Za-z0-9_~-]+(\/[A-Za-z0-9_~-]+)*$|^\/$/;
13
16
  /** What an entry's ai key may hold: the most the org's own key holds. */
14
17
  export const AI_ENTRY_PERMISSIONS = ['ai.offerings:invoke', 'ai.runs:create'];
15
18
  /** The env names `uses.ai` writes; `env` may not name them. */
@@ -159,6 +162,9 @@ export function validateHosting(hosting, names) {
159
162
  const invalidWorker = validateEntryWorker(entry, hosting, names?.domain);
160
163
  if (invalidWorker)
161
164
  return invalidWorker;
165
+ const invalidWorkerApp = validateEntryWorkerApp(entry, hosting, names);
166
+ if (invalidWorkerApp)
167
+ return invalidWorkerApp;
162
168
  const invalidEdgeUse = validateEntryEdge(entry, hosting, names?.domain);
163
169
  if (invalidEdgeUse)
164
170
  return invalidEdgeUse;
@@ -183,7 +189,7 @@ export function validateHosting(hosting, names) {
183
189
  const invalidNames = validateEntryHostnames(hosting, names);
184
190
  if (invalidNames)
185
191
  return invalidNames;
186
- if (hosting.entries.filter((entry) => entry.database).length > 1) {
192
+ if (hosting.entries.filter(entryHasDatabase).length > 1) {
187
193
  return 'at most one hosting entry may set database: the one runtime database role cannot serve two apps';
188
194
  }
189
195
  const invalidGates = validateGates(hosting, names?.domain, names?.locked);
@@ -308,7 +314,10 @@ function validateEntryHostnames(hosting, names) {
308
314
  return `${at} hostnames need hosting.usesDomain: without it only the '${APP_ENTRY_KEY}' entry is served, at its own address`;
309
315
  }
310
316
  const reads = hosting.usesDomain && names?.domain;
311
- if (reads && entry.key === APP_ENTRY_KEY && !entry.hostnames.some((n) => n !== '*')) {
317
+ if (reads &&
318
+ entry.key === APP_ENTRY_KEY &&
319
+ !entry.workerApp &&
320
+ !entry.hostnames.some((n) => n !== '*')) {
312
321
  return `${at} needs a hostname ('@' or a label) while hosting.usesDomain is on: '*' alone names nothing, and the app would fall back to the bare domain`;
313
322
  }
314
323
  for (const name of entry.hostnames) {
@@ -351,6 +360,8 @@ const VOLUME_MOUNT_PATH_MAX = 255;
351
360
  const REPO = /^wtfalch\/[A-Za-z0-9._-]+$/;
352
361
  // The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
353
362
  const FOUNDRY_ENV_NAME = /^(APP_URL|LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
363
+ // Set only on the colors of a blueGreen entry; any other entry may declare them.
364
+ const BLUE_GREEN_ENV_NAME = /^REPORTING_(COLOR|RELEASE|HEALTH_TOKEN)$/;
354
365
  const SECRET_ENV_NAME = /_(KEY|SECRET|TOKEN|PASSWORD)$/;
355
366
  const HELD_SECRETS = "declare it as { from: 'heldSecret' }, not a plain value";
356
367
  /** The rules for one {@link HostingEntry}; `null` when it is valid. */
@@ -392,11 +403,19 @@ function validateHostingEntry(entry, enabled) {
392
403
  const invalidVolumes = validateEntryVolumes(entry, at);
393
404
  if (invalidVolumes)
394
405
  return invalidVolumes;
406
+ const invalidBlueGreen = validateBlueGreen(entry.blueGreen, at);
407
+ if (invalidBlueGreen)
408
+ return invalidBlueGreen;
409
+ if (entry.blueGreen !== undefined &&
410
+ entry.key !== APP_ENTRY_KEY &&
411
+ !entry.hostnames.some((name) => name !== '*')) {
412
+ return `${at} blueGreen needs a public hostname: the release check reaches each color through it`;
413
+ }
395
414
  for (const [name, value] of Object.entries(entry.env)) {
396
415
  if (!name) {
397
416
  return `${at} env needs a non-empty env var name`;
398
417
  }
399
- if (FOUNDRY_ENV_NAME.test(name)) {
418
+ if (FOUNDRY_ENV_NAME.test(name) || (entry.blueGreen && BLUE_GREEN_ENV_NAME.test(name))) {
400
419
  return `${at} env '${name}' is set by foundry and cannot be declared`;
401
420
  }
402
421
  if (typeof value !== 'string') {
@@ -424,6 +443,33 @@ function validateHostingEntry(entry, enabled) {
424
443
  }
425
444
  return null;
426
445
  }
446
+ /** `blueGreen`: steps ascending, each a whole number 1..100, the last 100; a hold of 1..1440 minutes. */
447
+ export function validateBlueGreen(value, at) {
448
+ if (value === undefined)
449
+ return null;
450
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
451
+ return `${at} blueGreen must be an object`;
452
+ }
453
+ const { steps, stepHoldMinutes } = value;
454
+ if (steps !== undefined) {
455
+ if (!Array.isArray(steps) || steps.length === 0 || steps.length > 20) {
456
+ return `${at} blueGreen steps must be a list of 1 to 20 percentages`;
457
+ }
458
+ if (!steps.every((n) => Number.isInteger(n) && n >= 1 && n <= 100)) {
459
+ return `${at} blueGreen steps must be whole numbers from 1 to 100`;
460
+ }
461
+ if (!steps.every((n, i) => i === 0 || n > steps[i - 1])) {
462
+ return `${at} blueGreen steps must be ascending`;
463
+ }
464
+ if (steps[steps.length - 1] !== 100)
465
+ return `${at} blueGreen steps must end at 100`;
466
+ }
467
+ if (stepHoldMinutes !== undefined &&
468
+ !(Number.isInteger(stepHoldMinutes) && stepHoldMinutes >= 1 && stepHoldMinutes <= 1440)) {
469
+ return `${at} blueGreen stepHoldMinutes must be a whole number from 1 to 1440`;
470
+ }
471
+ return null;
472
+ }
427
473
  /** `volumes`: a bounded list of named volumes at distinct absolute paths, none on the log volume's path or name,
428
474
  * on `/app` or under a system path. */
429
475
  function validateEntryVolumes(entry, at) {
@@ -501,6 +547,16 @@ function validateEntryAuth(entry, hosting, domain) {
501
547
  if (!hasExactHostname(entry, hosting, domain)) {
502
548
  return `${at} needs an exact hostname to sign in at`;
503
549
  }
550
+ if (auth.mcp !== undefined) {
551
+ if (auth.users !== 'customers' || auth.registration !== 'open') {
552
+ return `${at} mcp needs users 'customers' and registration 'open': an MCP client signs the customer in, and only customers register themselves`;
553
+ }
554
+ if (typeof auth.mcp?.path !== 'string' ||
555
+ !MCP_PATH.test(auth.mcp.path) ||
556
+ auth.mcp.path.length > 200) {
557
+ return `${at} mcp.path must be an absolute path of letters, digits, '_', '~' and '-' segments, such as '/mcp'`;
558
+ }
559
+ }
504
560
  if (entry.key === 'agora' ||
505
561
  entry.key === 'boule' ||
506
562
  entry.key === 'cms' ||
@@ -511,6 +567,8 @@ function validateEntryAuth(entry, hosting, domain) {
511
567
  }
512
568
  /** With no domain the hostnames are not read (`entryHostnames`). */
513
569
  function hasExactHostname(entry, hosting, domain) {
570
+ if (entry.workerApp)
571
+ return true; // Its host is workerApp.host, or <slug>[-<key>].oikos.host.
514
572
  return hosting.usesDomain && domain !== null
515
573
  ? entry.hostnames.some((name) => name !== '*')
516
574
  : entry.key === APP_ENTRY_KEY;
@@ -601,7 +659,6 @@ function workerRouteZone(route) {
601
659
  return host.startsWith('*.') ? host.slice(2) : host;
602
660
  }
603
661
  const WORKER_ROUTE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+\/.*$/;
604
- const WORKER_SHA256 = /^[0-9a-f]{64}$/;
605
662
  /**
606
663
  * Restated, not shared, from `plan/worker.ts`'s `WORKER_ORG_HASH_LENGTH`/
607
664
  * `WORKER_NAME_MAX_LENGTH` -- importing them here would import that
@@ -635,29 +692,9 @@ function validateEntryWorker(entry, hosting, domain) {
635
692
  if (entry.key.length > WORKER_NAME_MAX_LENGTH - WORKER_NAME_FIXED_LENGTH) {
636
693
  return `${at} entry key is too long: the deployed Worker's name (an 8-character org hash, the key, and "-edge") must fit Cloudflare's ${WORKER_NAME_MAX_LENGTH}-character limit`;
637
694
  }
638
- let artifactUrl;
639
- try {
640
- artifactUrl = new URL(worker.artifactUrl);
641
- }
642
- catch {
643
- return `${at} artifactUrl must be an absolute URL, not a bare path or relative file`;
644
- }
645
- if (artifactUrl.protocol !== 'https:') {
646
- return `${at} artifactUrl must be an https:// URL`;
647
- }
648
- if (artifactUrl.username || artifactUrl.password) {
649
- return `${at} artifactUrl must not carry userinfo (a signed or credentialed URL ends up stored verbatim in recorded scope ids)`;
650
- }
651
- if (artifactUrl.search) {
652
- return `${at} artifactUrl must not carry a query string (a signed or credentialed URL ends up stored verbatim in recorded scope ids)`;
653
- }
654
- // `URL.hash` is '' for a bare trailing `#`, so the raw string is checked.
655
- if (worker.artifactUrl.includes('#')) {
656
- return `${at} artifactUrl must not carry a fragment (refused for the same reason as a query string)`;
657
- }
658
- if (!WORKER_SHA256.test(worker.artifactSha256)) {
659
- return `${at} artifactSha256 must be 64 lowercase hex characters`;
660
- }
695
+ const invalidArtifact = validateArtifact(at, worker.artifactUrl, worker.artifactSha256, entry.repo);
696
+ if (invalidArtifact)
697
+ return invalidArtifact;
661
698
  if (!WORKER_ROUTE.test(worker.route)) {
662
699
  return `${at} route must be a hostname (an optional leading '*.' label, then at least two more labels) and a path, joined by '/'`;
663
700
  }
@@ -918,6 +955,32 @@ export function validateSignIn(org) {
918
955
  }
919
956
  return null;
920
957
  }
958
+ /**
959
+ * `null` when `customers` is valid: both switches are booleans, sign-up needs customers on, and
960
+ * an entry with `registration: 'open'` needs both, because open registration signs people up into
961
+ * the customers organisation.
962
+ */
963
+ export function validateCustomers(org) {
964
+ const customers = org.customers;
965
+ if (customers !== undefined) {
966
+ const value = customers;
967
+ if (typeof customers !== 'object' ||
968
+ customers === null ||
969
+ Array.isArray(customers) ||
970
+ typeof value?.enabled !== 'boolean' ||
971
+ typeof value?.signUp !== 'boolean' ||
972
+ Object.keys(customers).some((key) => key !== 'enabled' && key !== 'signUp')) {
973
+ return 'customers must be { enabled: boolean, signUp: boolean }';
974
+ }
975
+ if (value.signUp && !value.enabled)
976
+ return 'customers.signUp needs customers.enabled';
977
+ }
978
+ const open = org.hosting.entries.find((entry) => entry.uses?.auth?.registration === 'open');
979
+ if (open && !(org.customers?.enabled && org.customers.signUp)) {
980
+ return `hosting.entries '${open.key}' uses.auth registration 'open' needs customers.enabled and customers.signUp`;
981
+ }
982
+ return null;
983
+ }
921
984
  /** The only reader of `services.own`. Every company runs its own services, the platform's owner company too; a company without a domain runs them on oikos.host. */
922
985
  export function ownsServices(org) {
923
986
  return org.services?.own === true;
@@ -0,0 +1,54 @@
1
+ import { type BlueGreenApplyDeps } from './blue-green-apply.js';
2
+ import { type BlueGreenSubject } from './blue-green.js';
3
+ import type { DeployHealthRuntime } from './deploy-health.js';
4
+ import type { PlatformTunnelPort } from './platform-providers.js';
5
+ export interface ArchonBlueGreenRun {
6
+ subject: BlueGreenSubject;
7
+ /** The owner company's id: the health token's held ref is `sharedSecretRef(ownerOrgId, ...)` like an entry's. */
8
+ ownerOrgId: string;
9
+ desired: {
10
+ primary: string;
11
+ };
12
+ recorded(): Readonly<Record<string, string>>;
13
+ record(key: string, value: string): void;
14
+ persist(): Promise<void>;
15
+ coolify: BlueGreenApplyDeps['coolify'] & {
16
+ listServers(): Promise<Array<{
17
+ uuid: string;
18
+ is_usable: boolean;
19
+ is_reachable: boolean;
20
+ }>>;
21
+ findOrCreateEnvironment(projectUuid: string, name: string): Promise<{
22
+ environment: {
23
+ id: number;
24
+ name: string;
25
+ uuid: string;
26
+ };
27
+ }>;
28
+ };
29
+ heldKeys: {
30
+ resolve(ref: string): Promise<string | null>;
31
+ put(ref: string, value: string): Promise<void>;
32
+ };
33
+ /** Registers a value for redaction before it is written anywhere. */
34
+ secret: (value: string) => void;
35
+ /** Archon's Coolify project, its blue app and its tunnel, all recorded by the archon step. */
36
+ projectUuid: string;
37
+ blueAppId: string;
38
+ tunnelId: string;
39
+ hostname: string;
40
+ tunnels: Pick<PlatformTunnelPort, 'putTunnelConfig'>;
41
+ runtime: DeployHealthRuntime;
42
+ sleep: (ms: number) => Promise<void>;
43
+ logDir: string;
44
+ isRunning: (status: string | undefined) => boolean;
45
+ }
46
+ /**
47
+ * platform-archon on the blue/green path (`platform.archon.blueGreen`): the archon app keeps its name and is
48
+ * blue; green, the balancer and stage are new apps beside it on the primary server. The tunnel moves to the
49
+ * balancer last, only after every part of the structure is applied and running, so a first apply never has the
50
+ * tunnel point at a balancer that does not answer. A failure halfway leaves the apps already made, recorded by
51
+ * id; the structure is recorded as applied only after its last part, so the next apply repeats from there and
52
+ * the tunnel stays on the app until then.
53
+ */
54
+ export declare function applyArchonBlueGreen(run: ArchonBlueGreenRun): Promise<void>;
@@ -0,0 +1,72 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { sharedSecretRef } from '../org.js';
3
+ import { applyBlueGreenServer } from './blue-green-apply.js';
4
+ import { ARCHON_BG_ORIGIN_SCOPE_KEY, DEFAULT_WEIGHTS, PRIMARY_SERVER_SCOPE_KEY, bgKey, healthTokenName, lbOrigin, splitServers, } from './blue-green.js';
5
+ /**
6
+ * platform-archon on the blue/green path (`platform.archon.blueGreen`): the archon app keeps its name and is
7
+ * blue; green, the balancer and stage are new apps beside it on the primary server. The tunnel moves to the
8
+ * balancer last, only after every part of the structure is applied and running, so a first apply never has the
9
+ * tunnel point at a balancer that does not answer. A failure halfway leaves the apps already made, recorded by
10
+ * id; the structure is recorded as applied only after its last part, so the next apply repeats from there and
11
+ * the tunnel stays on the app until then.
12
+ */
13
+ export async function applyArchonBlueGreen(run) {
14
+ const { subject } = run;
15
+ const id = subject.id;
16
+ const label = `coolify:archon-bluegreen:${id}`;
17
+ const { up } = splitServers((await run.coolify.listServers()).map((s) => ({
18
+ uuid: s.uuid,
19
+ usable: s.is_usable,
20
+ reachable: s.is_reachable,
21
+ })));
22
+ const primary = run.recorded()[PRIMARY_SERVER_SCOPE_KEY] ?? up[0];
23
+ if (!primary || !up.includes(primary) || primary !== run.desired.primary) {
24
+ throw new Error(`${label}: the plan's primary server is not the usable recorded one`);
25
+ }
26
+ if (!run.recorded()[PRIMARY_SERVER_SCOPE_KEY]) {
27
+ run.record(PRIMARY_SERVER_SCOPE_KEY, primary);
28
+ await run.persist();
29
+ }
30
+ if (!run.recorded()[bgKey.weights(id)]) {
31
+ run.record(bgKey.weights(id), DEFAULT_WEIGHTS);
32
+ await run.persist();
33
+ }
34
+ const { environment } = await run.coolify.findOrCreateEnvironment(run.projectUuid, 'production');
35
+ const ref = sharedSecretRef(run.ownerOrgId, healthTokenName(id));
36
+ const deps = {
37
+ coolify: run.coolify,
38
+ recorded: run.recorded,
39
+ record: run.record,
40
+ persist: run.persist,
41
+ projectUuid: run.projectUuid,
42
+ environment,
43
+ healthToken: async () => {
44
+ const held = await run.heldKeys.resolve(ref);
45
+ if (held) {
46
+ run.secret(held);
47
+ return held;
48
+ }
49
+ const minted = randomBytes(32).toString('base64url');
50
+ run.secret(minted);
51
+ await run.heldKeys.put(ref, minted);
52
+ return minted;
53
+ },
54
+ secret: run.secret,
55
+ runtime: run.runtime,
56
+ sleep: run.sleep,
57
+ label,
58
+ primaryBlueAppId: run.blueAppId,
59
+ logDir: run.logDir,
60
+ isRunning: run.isRunning,
61
+ };
62
+ await applyBlueGreenServer(deps, subject, { uuid: primary, primaryUuid: primary });
63
+ // The structure is applied and every part ran: now, and only now, the tunnel moves.
64
+ const origin = lbOrigin(subject);
65
+ if (run.recorded()[ARCHON_BG_ORIGIN_SCOPE_KEY] !== origin) {
66
+ await run.tunnels.putTunnelConfig(run.tunnelId, {
67
+ ingress: [{ hostname: run.hostname, service: origin }, { service: 'http_status:404' }],
68
+ });
69
+ run.record(ARCHON_BG_ORIGIN_SCOPE_KEY, origin);
70
+ await run.persist();
71
+ }
72
+ }
@@ -0,0 +1,82 @@
1
+ import { type BlueGreenSubject } from './blue-green.js';
2
+ import { ensureConnectorsOnServers } from './cloudflared-connector.js';
3
+ import { type DeployHealthRuntime } from './deploy-health.js';
4
+ import type { TunnelPort } from './edge-tunnels.js';
5
+ import type { CoolifyPort } from './executor.js';
6
+ /** The Coolify calls this module makes. */
7
+ type Coolify = Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy' | 'deployAndWait' | 'listStorages' | 'createPersistentStorage' | 'createFileStorage' | 'updateFileStorage' | 'requireGithubApp' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw'> & {
8
+ listApplicationDeployments?: CoolifyPort['listApplicationDeployments'];
9
+ };
10
+ export interface BlueGreenApplyDeps {
11
+ coolify: Coolify;
12
+ /** The ids recorded so far, and how to record one. */
13
+ recorded: () => Readonly<Record<string, string>>;
14
+ record: (key: string, value: string) => void;
15
+ persist: () => Promise<void>;
16
+ projectUuid: string;
17
+ environment: {
18
+ id: number;
19
+ name: string;
20
+ uuid: string;
21
+ };
22
+ /** The entry's one health token: held once, handed to the env writes below and nowhere else. */
23
+ healthToken: () => Promise<string>;
24
+ /** Registers a value for redaction before it is written anywhere. */
25
+ secret: (value: string) => void;
26
+ runtime: DeployHealthRuntime;
27
+ sleep: (ms: number) => Promise<void>;
28
+ /** The step's name, for errors. */
29
+ label: string;
30
+ /** The app id of the blue copy `coolify:app` made on the primary. */
31
+ primaryBlueAppId: string;
32
+ logDir: string;
33
+ isRunning: (status: string | undefined) => boolean;
34
+ }
35
+ /** What writing a balancer's config needs; {@link BlueGreenApplyDeps} and the release runner both have it. */
36
+ export interface LbConfigDeps {
37
+ coolify: Pick<Coolify, 'listStorages' | 'createFileStorage' | 'updateFileStorage'>;
38
+ recorded: () => Readonly<Record<string, string>>;
39
+ record: (key: string, value: string) => void;
40
+ persist: () => Promise<void>;
41
+ label: string;
42
+ }
43
+ /** Replaces the host in a connection URL; `null` when it is not one. Never throws a value. */
44
+ export declare function withDatabaseHost(url: string, host: string): string | null;
45
+ /** `postgres://user:password@host/db` for a stage runtime role: the password is minted by the caller. */
46
+ export declare function runtimeDatabaseUrl(ownerUrl: string, role: string, password: string): string | null;
47
+ /**
48
+ * Applies one server's blue/green structure for `subject`: green (and blue, off the primary) beside the
49
+ * primary's blue, the balancer with the recorded weights, and on the primary the stage app. Every color
50
+ * gets the primary blue's env (the database host rewritten off the primary) and its three reporting
51
+ * values. A failure halfway leaves the apps already made, recorded by id; the next run finds them and
52
+ * does the rest. Nothing is recorded as applied until every part is done.
53
+ */
54
+ export declare function applyBlueGreenServer(deps: BlueGreenApplyDeps, subject: BlueGreenSubject, server: {
55
+ uuid: string;
56
+ primaryUuid: string;
57
+ }): Promise<void>;
58
+ /** Writes the balancer's config with the recorded weights and records them for this server. */
59
+ export declare function applyBlueGreenWeights(deps: LbConfigDeps, subject: BlueGreenSubject, server: {
60
+ uuid: string;
61
+ primaryUuid: string;
62
+ }): Promise<void>;
63
+ /** A connector for the org's tunnel on each given server; the primary's is the existing one. */
64
+ export declare function applyBlueGreenConnectors(input: {
65
+ coolify: Parameters<typeof ensureConnectorsOnServers>[0]['coolify'];
66
+ tunnels: Pick<TunnelPort, 'getTunnelToken'>;
67
+ tunnelId: string;
68
+ baseAppName: string;
69
+ baseScopeKey: string;
70
+ primaryUuid: string;
71
+ serverUuids: readonly string[];
72
+ projectUuid: string;
73
+ environment: {
74
+ id: number;
75
+ name: string;
76
+ uuid: string;
77
+ };
78
+ recorded: () => Readonly<Record<string, string>>;
79
+ record: (key: string, value: string) => void;
80
+ persist: () => Promise<void>;
81
+ }): Promise<void>;
82
+ export {};