@wtfalch/foundry 0.46.0 → 0.47.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.
package/dist/plan/diff.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
- import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, heldEnvRef, ownerMailboxRecorded, ownerMailboxes, ownerNameProblem, ownsServices, } from '../org.js';
2
+ import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, heldEnvRef, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownerNameProblem, ownsServices, } from '../org.js';
3
3
  import { entryHasDatabase } from '../worker-app-config.js';
4
4
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
5
5
  import { planBlueGreen } from './blue-green.js';
@@ -3161,7 +3161,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
3161
3161
  // right now, and gating this on `mailHost` let a platform-apex org with an
3162
3162
  // already-recorded domain take the unprotected path the moment platform
3163
3163
  // mail went back to off.
3164
- if (entryKey === APP_ENTRY_KEY) {
3164
+ if (entryKey === mainEntry(org.hosting)?.key) {
3165
3165
  const mailDomain = recordedIds[MAIL_DOMAIN_SCOPE_KEY];
3166
3166
  const platformApex = readiness.platformApex;
3167
3167
  const platformLettermintProjectId = readiness.platformLettermintProjectId;
@@ -3473,6 +3473,7 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
3473
3473
  .map((id) => id.slice(`coolify:app:${org.id}:`.length));
3474
3474
  const declared = new Set([APP_ENTRY_KEY, ...org.hosting.entries.map((e) => e.key)]);
3475
3475
  const keys = new Set([...declared, ...recordedKeys]);
3476
+ const mainKey = mainEntry(org.hosting)?.key;
3476
3477
  for (const key of keys) {
3477
3478
  if (!declared.has(key)) {
3478
3479
  // The reader gives a parked app no `enabled` field, so only a running
@@ -3508,14 +3509,14 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
3508
3509
  app.step.action.desired = { ...app.step.action.desired, authIssuer };
3509
3510
  }
3510
3511
  // #491: only the `app` entry's own step runs `ensureAppLettermintMail`
3511
- // (`executor.ts`'s own `entry.key === APP_ENTRY_KEY` gate). Unlike
3512
+ // (`executor.ts`'s own `isMainEntry(entry)` gate). Unlike
3512
3513
  // `secretSources`/`authIssuer` above, these two are set unconditionally
3513
3514
  // -- `null` when no platform is declared, never omitted -- because
3514
3515
  // `executor.ts` treats the key being *absent* as a stale plan predating
3515
3516
  // this field and refuses to run (review round 2, finding 3): an omitted
3516
3517
  // key here would read identically to a stale plan and silently fall
3517
3518
  // back to the unprotected pre-#491 path instead.
3518
- if (key === APP_ENTRY_KEY && app.step && app.step.action.desired.enabled !== false) {
3519
+ if (key === mainKey && app.step && app.step.action.desired.enabled !== false) {
3519
3520
  app.step.action.desired = {
3520
3521
  ...app.step.action.desired,
3521
3522
  platformApex: readiness.platformApex,
@@ -3527,7 +3528,7 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
3527
3528
  // edge: blocked, not planned, while edge is not applied.
3528
3529
  const appReason = edgeNeeded(readiness, ['zones', 'dns']);
3529
3530
  if (appReason &&
3530
- key === APP_ENTRY_KEY &&
3531
+ key === mainKey &&
3531
3532
  app.step &&
3532
3533
  app.step.action.desired.enabled !== false &&
3533
3534
  recordedIds[MAIL_DOMAIN_SCOPE_KEY]) {
@@ -4,7 +4,7 @@ import { URL } from 'node:url';
4
4
  import { coolifyImageFor } from '../bootstrap/versions.js';
5
5
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
6
  import { resolveCallsSfuUrls } from '../calls-sfu.js';
7
- import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, appEntry, filesCollectionEnvName, heldEnvRef, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
7
+ import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, filesCollectionEnvName, heldEnvRef, isMainEntry, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
8
8
  import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
9
9
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, DESKTOP_AUTH_PORT, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, desktopAuthInternalName, entryClientIdScopeKey, } from './agora.js';
10
10
  import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, } from './blue-green-apply.js';
@@ -211,7 +211,7 @@ export function callsHostnameFor(org) {
211
211
  */
212
212
  export function healthUrls(org) {
213
213
  // One URL per entry that has a public name; an internal entry has none.
214
- if (org.hosting.enabled && appEntry(org.hosting)) {
214
+ if (org.hosting.enabled && org.hosting.entries.length > 0) {
215
215
  return org.hosting.entries.flatMap((entry) => {
216
216
  const [hostname] = entryHostnames(org, entry);
217
217
  if (!hostname)
@@ -3298,7 +3298,7 @@ export class FoundryStepExecutor {
3298
3298
  // actually resolved a mail domain for this org.
3299
3299
  // One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
3300
3300
  // second entry sharing the name would delete the first's live token.
3301
- if (entry.key === APP_ENTRY_KEY) {
3301
+ if (isMainEntry(entry)) {
3302
3302
  await this.ensureAppLettermintMail(appId, org, context,
3303
3303
  // Deployed or started above on every path, a created app included
3304
3304
  // (#566), so it has read its env before the mail env is written.
@@ -3718,10 +3718,8 @@ export class FoundryStepExecutor {
3718
3718
  async pushAppEnv(appId, org, entry, context, authIssuer, secretSources) {
3719
3719
  const recorded = context.recordedIds();
3720
3720
  const envs = [
3721
- // Only the `app` entry has a public name; the others are internal.
3722
- ...(entry.key === APP_ENTRY_KEY
3723
- ? [{ key: 'APP_URL', value: `https://${appHostname(org)}` }]
3724
- : []),
3721
+ // Only the main entry has a public name; the others are internal.
3722
+ ...(isMainEntry(entry) ? [{ key: 'APP_URL', value: `https://${appHostname(org)}` }] : []),
3725
3723
  // #75: read back by the package-side pino sink (not this repo's job,
3726
3724
  // #75 "What belongs where") once `ensureLogVolume` has mounted the
3727
3725
  // matching volume.
@@ -4203,7 +4201,9 @@ export class FoundryStepExecutor {
4203
4201
  const { org, context, mailDomain, self, mailFrom } = args;
4204
4202
  const appName = `${org.slug}-app`;
4205
4203
  const recorded = context.recordedIds();
4206
- const appId = self.role === 'app' ? self.id : recorded[`coolify:app:${appStepKey(org)}`];
4204
+ const appId = self.role === 'app'
4205
+ ? self.id
4206
+ : recorded[`coolify:app:${appStepKey(org, mainEntry(org.hosting)?.key)}`];
4207
4207
  const bouleId = self.role === 'boule' ? self.id : recorded[`coolify:boule:${org.id}`];
4208
4208
  const peerListEnvs = (id) => !id || id === self.id
4209
4209
  ? Promise.resolve(null)
@@ -1,4 +1,3 @@
1
- import { appEntry } from '../org.js';
2
1
  import { agoraHostnameFor } from './agora.js';
3
2
  import { bouleHostnameFor } from './boule.js';
4
3
  import { callsHostnameFor, cmsHostnameFor } from './executor.js';
@@ -187,7 +186,7 @@ function hostingEntryTarget(org, entry) {
187
186
  * differently-written version of the same choice.
188
187
  */
189
188
  function appTargets(org) {
190
- if (org.hosting.enabled && appEntry(org.hosting)) {
189
+ if (org.hosting.enabled && org.hosting.entries.length > 0) {
191
190
  return org.hosting.entries.map((entry) => hostingEntryTarget(org, entry));
192
191
  }
193
192
  if (org.site.enabled) {
@@ -1,7 +1,7 @@
1
1
  import type { DeclaredOrg, HostingEntry } from '../org.js';
2
2
  /**
3
3
  * The full names an entry is served at. With `hosting.usesDomain` off, or no
4
- * domain, `hostnames` is not read: the `app` entry is at `<slug>.oikos.host`
4
+ * domain, `hostnames` is not read: the main entry is at `<slug>.oikos.host`
5
5
  * and every other entry is internal. `'*'` names nothing here; the wildcard is
6
6
  * acted on elsewhere.
7
7
  */
@@ -10,7 +10,7 @@ export declare function entryHostnames(org: DeclaredOrg, entry: HostingEntry): s
10
10
  * The one exact name a Worker app is served at (ADR 0002): `<host>.<domain>` or
11
11
  * the apex on the org's own domain, else `<slug>-<key>.oikos.host` (one label
12
12
  * under `oikos.host`, so the free Universal certificate covers it), and
13
- * `<slug>.oikos.host` for the entry keyed `app`.
13
+ * `<slug>.oikos.host` for the main entry.
14
14
  */
15
15
  export declare function workerAppHostname(org: DeclaredOrg, entry: HostingEntry): string;
16
16
  /**
@@ -1,10 +1,10 @@
1
- import { APP_ENTRY_KEY } from '../org.js';
1
+ import { isMainEntry, mainEntry } from '../org.js';
2
2
  import { workerAppHostnameOf } from '../worker-app-config.js';
3
3
  import { agoraHostnameFor } from './agora.js';
4
4
  import { bouleHostnameFor } from './boule.js';
5
5
  /**
6
6
  * The full names an entry is served at. With `hosting.usesDomain` off, or no
7
- * domain, `hostnames` is not read: the `app` entry is at `<slug>.oikos.host`
7
+ * domain, `hostnames` is not read: the main entry is at `<slug>.oikos.host`
8
8
  * and every other entry is internal. `'*'` names nothing here; the wildcard is
9
9
  * acted on elsewhere.
10
10
  */
@@ -13,7 +13,7 @@ export function entryHostnames(org, entry) {
13
13
  if (entry.workerApp)
14
14
  return [workerAppHostname(org, entry)];
15
15
  if (!org.hosting.usesDomain || !domain) {
16
- return entry.key === APP_ENTRY_KEY ? [`${org.slug}.oikos.host`] : [];
16
+ return isMainEntry(entry) ? [`${org.slug}.oikos.host`] : [];
17
17
  }
18
18
  return entry.hostnames.flatMap((name) => name === '*' ? [] : name === '@' ? [domain] : [`${name}.${domain}`]);
19
19
  }
@@ -21,10 +21,10 @@ export function entryHostnames(org, entry) {
21
21
  * The one exact name a Worker app is served at (ADR 0002): `<host>.<domain>` or
22
22
  * the apex on the org's own domain, else `<slug>-<key>.oikos.host` (one label
23
23
  * under `oikos.host`, so the free Universal certificate covers it), and
24
- * `<slug>.oikos.host` for the entry keyed `app`.
24
+ * `<slug>.oikos.host` for the main entry.
25
25
  */
26
26
  export function workerAppHostname(org, entry) {
27
- return workerAppHostnameOf({ slug: org.slug, domain: org.domain, usesDomain: org.hosting.usesDomain }, entry.key, entry.workerApp?.host ?? '@');
27
+ return workerAppHostnameOf({ slug: org.slug, domain: org.domain, usesDomain: org.hosting.usesDomain }, entry, entry.workerApp?.host ?? '@');
28
28
  }
29
29
  /**
30
30
  * The wildcard name (`*.<domain>`) when an entry declares `'*'`, else `null`.
@@ -43,7 +43,7 @@ export function wildcardHostname(org) {
43
43
  * and the jobs registration read it.
44
44
  */
45
45
  export function appHostname(org) {
46
- const entry = org.hosting.entries.find((e) => e.key === APP_ENTRY_KEY);
46
+ const entry = mainEntry(org.hosting);
47
47
  const first = entry ? entryHostnames(org, entry)[0] : undefined;
48
48
  return first ?? legacyAppHostname(org);
49
49
  }
@@ -24,7 +24,7 @@ export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCO
24
24
  export type { OrgPostgresMigrationConfirmation } from './org-postgres-migrate.js';
25
25
  export { OrgPostgresMigrationBackupMissingError } from './backup-gate.js';
26
26
  export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, FoundryApplyOptions, } from './plan-apply.js';
27
- export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
27
+ export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, stepEntryKey, } from './plan-apply.js';
28
28
  export type { UnconfirmedUpgrade } from './confirm-upgrade.js';
29
29
  export { MAIL_STORE_SWITCH_SERVICE } from './mail-store.js';
30
30
  export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './backup-gate.js';
@@ -11,7 +11,7 @@ export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.
11
11
  export { AdoptRefusedError } from './adopt.js';
12
12
  export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationNotConfirmedError, } from './org-postgres-migrate.js';
13
13
  export { OrgPostgresMigrationBackupMissingError } from './backup-gate.js';
14
- export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
14
+ export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, stepEntryKey, } from './plan-apply.js';
15
15
  export { MAIL_STORE_SWITCH_SERVICE } from './mail-store.js';
16
16
  export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './backup-gate.js';
17
17
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, provisionFilesHost, provisionJobsHost, provisionMailServer, } from './platform-providers.js';
@@ -1,5 +1,5 @@
1
1
  import type { DestroyProgress, PlanApply } from '../apply.js';
2
- import type { DeclaredOrg, OrgId } from '../org.js';
2
+ import type { DeclaredOrg, HostingEntry, OrgId } from '../org.js';
3
3
  import type { Plan, PlanStep } from '../plan.js';
4
4
  import type { DeclaredPlatform } from '../platform.js';
5
5
  import type { ReleaseStore } from '../release/state.js';
@@ -443,6 +443,18 @@ export declare class PlatformConfirmationRequiredError extends Error {
443
443
  export interface FoundryApplyOptions {
444
444
  /** Service names (`VersionChangeItem.service`), matched exactly, whose version change the operator confirmed. */
445
445
  confirmUpgrade?: readonly string[];
446
+ /**
447
+ * A company's own confirmation for one of its apps, in place of the org's name. `apply` passes a
448
+ * destroying or migrating step only when {@link stepEntryKey} says it belongs to `entryKey` and
449
+ * `typed` equals `entryKey` exactly. Every other such step still needs the org's name, as does
450
+ * the whole plan when `typed` differs. It never passes a platform step.
451
+ */
452
+ entryConfirmation?: {
453
+ entryKey: string;
454
+ typed: string;
455
+ /** The removed entry's gate keys, read by the caller from the company before the change. */
456
+ gates?: readonly string[];
457
+ };
446
458
  }
447
459
  export interface FoundryPlanApplyOptions {
448
460
  store: DeclaredOrgStore;
@@ -947,3 +959,20 @@ export declare class FoundryPlanApply implements PlanApply {
947
959
  * than a second copy that could drift from it.
948
960
  */
949
961
  export declare function planNeedsConfirmation(steps: readonly PlanStep[]): boolean;
962
+ /**
963
+ * The key of the hosting entry a step belongs to, or `null` for a step of the org or the platform.
964
+ * An entry's own steps are keyed `<orgId>:<entry key>` (its Coolify app, Worker, Worker app, and
965
+ * ai, edge and files keys); with `orgId` the prefix must be exactly that id. With `entries` (the
966
+ * declared ones) two more are known: the ZITADEL sign-in client named like an entry that declares
967
+ * `uses.auth`, and a gate named in an entry's `gates`. The org database, the org-wide files key,
968
+ * the customers organisation and the issuer belong to no entry. (`apply` lets an entry's
969
+ * confirmation pass the park of the org database once no declared entry uses a database.)
970
+ */
971
+ export declare function stepEntryKey(step: {
972
+ action: Pick<StepAction, 'system' | 'kind' | 'key'>;
973
+ }, orgId?: string, entries?: readonly Pick<HostingEntry, 'key' | 'uses' | 'gates'>[]): string | null;
974
+ /** Whether `confirmation` passes every destroying or migrating step of `steps`: all belong to its entry, and it typed the key. */
975
+ export declare function entryConfirms(confirmation: FoundryApplyOptions['entryConfirmation'], steps: readonly {
976
+ step: PlanStep;
977
+ action: StepAction;
978
+ }[], orgId: string, entries: readonly HostingEntry[]): boolean;
@@ -5,6 +5,7 @@ import { ORG_SERVICE_SCHEMAS, heldEnvRef, ownsServices, validateHosting } from '
5
5
  import { PLATFORM_SERVICE_KEYS } from '../platform.js';
6
6
  import { AlreadyRunningError, StepFailedError } from '../runner/errors.js';
7
7
  import { StaleLeaseError, } from '../store.js';
8
+ import { entryHasDatabase } from '../worker-app-config.js';
8
9
  import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
9
10
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
10
11
  import { NetworkMoveBackupMissingError, OrgPostgresMigrationBackupMissingError, SigninDbMoveBackupMissingError, UpgradeBackupMissingError, platformDatabasesForService, requireFreshBackupForOrgPostgresMigration, requireFreshBackupForSigninMove, requireFreshBackupsForMove, requireFreshBackupsForUpgrades, } from './backup-gate.js';
@@ -577,7 +578,8 @@ export class FoundryPlanApply {
577
578
  }
578
579
  }
579
580
  if (planNeedsConfirmation(plan.steps) &&
580
- confirmedOrgName.trim() !== org.displayName.trim()) {
581
+ confirmedOrgName.trim() !== org.displayName.trim() &&
582
+ !entryConfirms(options.entryConfirmation, detail.plannedSteps, org.id, org.hosting.entries)) {
581
583
  await this.refuse(plan, "destructive or migrating step without the org's name typed correctly");
582
584
  throw new ConfirmationRequiredError(orgId);
583
585
  }
@@ -1859,6 +1861,73 @@ export class FoundryPlanApply {
1859
1861
  export function planNeedsConfirmation(steps) {
1860
1862
  return steps.some((step) => step.destroys || step.migrates);
1861
1863
  }
1864
+ /**
1865
+ * The key of the hosting entry a step belongs to, or `null` for a step of the org or the platform.
1866
+ * An entry's own steps are keyed `<orgId>:<entry key>` (its Coolify app, Worker, Worker app, and
1867
+ * ai, edge and files keys); with `orgId` the prefix must be exactly that id. With `entries` (the
1868
+ * declared ones) two more are known: the ZITADEL sign-in client named like an entry that declares
1869
+ * `uses.auth`, and a gate named in an entry's `gates`. The org database, the org-wide files key,
1870
+ * the customers organisation and the issuer belong to no entry. (`apply` lets an entry's
1871
+ * confirmation pass the park of the org database once no declared entry uses a database.)
1872
+ */
1873
+ export function stepEntryKey(step, orgId, entries = []) {
1874
+ const { system, kind, key } = step.action;
1875
+ if (system === 'zitadel' && kind === 'app') {
1876
+ return entries.find((e) => e.key === key && e.uses?.auth)?.key ?? null;
1877
+ }
1878
+ const entryOwned = (system === 'coolify' && kind === 'app') ||
1879
+ (system === 'cloudflare' && (kind === 'worker' || kind === 'worker-app' || kind === 'gate')) ||
1880
+ (kind === 'key' && (system === 'ai' || system === 'edge' || system === 'files'));
1881
+ if (!entryOwned)
1882
+ return null;
1883
+ const colon = key.indexOf(':');
1884
+ if (colon < 1)
1885
+ return null;
1886
+ const prefix = key.slice(0, colon);
1887
+ if (orgId === undefined ? prefix === 'platform' : prefix !== orgId)
1888
+ return null;
1889
+ const rest = key.slice(colon + 1);
1890
+ if (rest === '' || rest.includes(':'))
1891
+ return null;
1892
+ if (kind === 'gate')
1893
+ return entries.find((e) => e.gates?.some((g) => g.key === rest))?.key ?? null;
1894
+ return rest;
1895
+ }
1896
+ /**
1897
+ * The step that parks the org's one database. It belongs to the org, not to an entry, but the
1898
+ * entry that removes the last use of it may confirm it (see `entryConfirms`).
1899
+ */
1900
+ function isOrgDatabasePark(step, orgId) {
1901
+ const { system, kind, key } = step.action;
1902
+ return system === 'coolify' && kind === 'database' && key === orgId && step.step.destroys;
1903
+ }
1904
+ /**
1905
+ * The delete of one of the org's gates that no entry declares any more. The step names only the
1906
+ * gate, so the caller names the removed entry's gates (`entryConfirmation.gates`) and only those pass.
1907
+ */
1908
+ function isUndeclaredGateDelete(step, orgId) {
1909
+ const { system, kind, key, desired } = step.action;
1910
+ return (system === 'cloudflare' &&
1911
+ kind === 'gate' &&
1912
+ key.startsWith(`${orgId}:`) &&
1913
+ desired?.mode === 'delete');
1914
+ }
1915
+ /** Whether `confirmation` passes every destroying or migrating step of `steps`: all belong to its entry, and it typed the key. */
1916
+ export function entryConfirms(confirmation, steps, orgId, entries) {
1917
+ if (!confirmation ||
1918
+ confirmation.entryKey === '' ||
1919
+ confirmation.typed !== confirmation.entryKey) {
1920
+ return false;
1921
+ }
1922
+ return steps
1923
+ .filter(({ step }) => step.destroys || step.migrates)
1924
+ .every((planned) => stepEntryKey(planned, orgId, entries) === confirmation.entryKey ||
1925
+ (isOrgDatabasePark(planned, orgId) && !entries.some(entryHasDatabase)) ||
1926
+ (isUndeclaredGateDelete(planned, orgId) &&
1927
+ !planned.step.migrates &&
1928
+ (confirmation.gates ?? []).includes(planned.action.key.slice(orgId.length + 1)) &&
1929
+ !entries.some((e) => e.key === confirmation.entryKey)));
1930
+ }
1862
1931
  /** `AppliedState.serviceIds`' two flat ZITADEL scope keys (`executor.ts`'s `ensureZitadelProject`/`zitadelIssuer`) -- no `:`, unlike a resource key. */
1863
1932
  const ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_project';
1864
1933
  const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
@@ -3892,6 +3892,9 @@ export const PLATFORM_ARCHON_PUBLIC_PATHS = [
3892
3892
  { name: 'reporting-collect', path: '/api/reporting/collect' },
3893
3893
  { name: 'org-changes', path: '/api/orgs/*/changes' },
3894
3894
  { name: 'org-settings', path: '/api/orgs/*/settings' },
3895
+ { name: 'org-apps', path: '/api/orgs/*/apps' },
3896
+ { name: 'org-requests', path: '/api/orgs/*/requests' },
3897
+ { name: 'github-releases', path: '/api/github/releases' },
3895
3898
  ];
3896
3899
  const PLATFORM_ARCHON_PUBLIC_POLICY_NAME = 'foundry: public path';
3897
3900
  const archonPublicAppName = (name) => `${PLATFORM_ARCHON_ACCESS_APP_NAME}:public:${name}`;
@@ -280,12 +280,23 @@ async function withBouleEnvConfig(client, resource, recorded, liveKeys) {
280
280
  // Boule must hold too. In memory only, compared by `planBoule` and never
281
281
  // written to a step, description or log.
282
282
  const orgId = recorded[resource.key]?.slice('boule:'.length);
283
- const appUuid = Object.entries(recorded).find(([, value]) => value === `app:${orgId}:${APP_ENTRY_KEY}`)?.[0];
283
+ // The token sits on the org's main app, whatever its key; the reader does not know the
284
+ // declaration, so it tries the `app` entry first and then each other recorded app.
285
+ const appPrefix = `app:${orgId}:`;
286
+ const appUuids = Object.entries(recorded)
287
+ .filter(([, value]) => value.startsWith(appPrefix))
288
+ .sort(([, a], [, b]) => Number(b === `${appPrefix}${APP_ENTRY_KEY}`) - Number(a === `${appPrefix}${APP_ENTRY_KEY}`))
289
+ .map(([uuid]) => uuid);
284
290
  // A recorded app that is not in the live listing is gone: no app token, and
285
291
  // no read of its envs. A listed app whose read fails fails like any read.
286
- const appToken = appUuid && liveKeys.has(appUuid)
287
- ? (await client.listEnvs(appUuid)).find((e) => !e.is_preview && e.key === BOULE_MAIL_TOKEN_ENV)?.value
288
- : undefined;
292
+ let appToken;
293
+ for (const appUuid of appUuids) {
294
+ if (!liveKeys.has(appUuid))
295
+ continue;
296
+ appToken = (await client.listEnvs(appUuid)).find((e) => !e.is_preview && e.key === BOULE_MAIL_TOKEN_ENV)?.value;
297
+ if (appToken)
298
+ break;
299
+ }
289
300
  return {
290
301
  ...resource,
291
302
  config: { ...resource.config, env, ...(appToken ? { appMailToken: appToken } : {}) },
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
- import { WORKER_APP_HYPERDRIVE_BINDING, WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHostId, } from '../worker-app-config.js';
2
+ import { WORKER_APP_HYPERDRIVE_BINDING, WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHasBuild, workerAppHostId, } from '../worker-app-config.js';
3
3
  import { entryAuthEnv } from './broker-entries.js';
4
4
  import { workerAppHostname } from './hostnames.js';
5
5
  import { appStepKey } from './platform-diff.js';
@@ -315,6 +315,8 @@ export function planWorkerApps(org, recordedIds, input) {
315
315
  description: `${at} is blocked, not planned: ${why}`,
316
316
  key,
317
317
  });
318
+ if (!workerAppHasBuild(app))
319
+ continue;
318
320
  if (!input.available) {
319
321
  block('the host supplies no WorkerAppPort');
320
322
  continue;
@@ -0,0 +1,7 @@
1
+ import { type DeclaredOrg, type OrgRefusal } from './org.js';
2
+ /**
3
+ * The first refusal of the whole-company validation a store write runs for
4
+ * hosting (every entry, its sign-in included) and customers, or `null`. Locked
5
+ * names are the org's own; the platform's service names are not known here.
6
+ */
7
+ export declare function refuseOrg(org: DeclaredOrg): OrgRefusal | null;
@@ -0,0 +1,10 @@
1
+ import { refuseCustomers, refuseHosting } from './org.js';
2
+ import { hostingNames } from './plan/locked-hostnames.js';
3
+ /**
4
+ * The first refusal of the whole-company validation a store write runs for
5
+ * hosting (every entry, its sign-in included) and customers, or `null`. Locked
6
+ * names are the org's own; the platform's service names are not known here.
7
+ */
8
+ export function refuseOrg(org) {
9
+ return refuseHosting(org.hosting, hostingNames(org, [])) ?? refuseCustomers(org);
10
+ }
@@ -1,4 +1,5 @@
1
- import { type DeclaredOrg, type MailMode, type OrgId } from './org.js';
1
+ import type { BuildJson } from './build-json.js';
2
+ import { type CustomersConfig, type DeclaredOrg, type HostingEntry, type MailMode, type OrgId } from './org.js';
2
3
  import type { PlanDetail } from './plan/diff.js';
3
4
  /**
4
5
  * The whitelist of `DeclaredOrg` fields a company's own Boule can request a
@@ -15,7 +16,7 @@ import type { PlanDetail } from './plan/diff.js';
15
16
  * lowering or leaving a limit unchanged -- raising either one needs
16
17
  * approval too, until a billing field exists to say what the raise costs.
17
18
  */
18
- export type RequestableField = 'calls' | 'customers' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier';
19
+ export type RequestableField = 'calls' | 'customers' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier' | 'app' | 'build';
19
20
  export declare const REQUESTABLE_FIELDS: readonly RequestableField[];
20
21
  /** Turn the org's own calls Matrix stack (`org.ts`'s `CallsConfig.enabled`) on or off. */
21
22
  export interface CallsChange {
@@ -52,12 +53,34 @@ export interface MailTierChange {
52
53
  field: 'mailTier';
53
54
  mode: MailMode;
54
55
  }
56
+ /**
57
+ * Put or remove one hosting entry of the company's own app (`org.ts`'s `HostingEntry`).
58
+ * `before` and `after` are the entry as the requester saw it and as it should be (null when absent).
59
+ * `customers` is the switches after the change when the request changes them, else null.
60
+ * Archon validates the whole company; {@link classifyChange} does not look at the org for this kind.
61
+ */
62
+ export interface AppChange {
63
+ field: 'app';
64
+ key: string;
65
+ op: 'put' | 'remove';
66
+ before: HostingEntry | null;
67
+ after: HostingEntry | null;
68
+ customers: CustomersConfig | null;
69
+ }
70
+ /** Deploy one release of the company's app from its `build.json` (`build-json.ts`). */
71
+ export interface BuildChange {
72
+ field: 'build';
73
+ key: string;
74
+ repo: string;
75
+ release: string;
76
+ build: BuildJson;
77
+ }
55
78
  /**
56
79
  * One company-requested change to its own declared org, keyed by
57
80
  * {@link RequestableField}. `classifyChange` and the store both switch on
58
81
  * `.field`.
59
82
  */
60
- export type RequestedChangeValue = CallsChange | CustomersChange | StorageLimitChange | AiLimitChange | DomainChange | MailTierChange;
83
+ export type RequestedChangeValue = CallsChange | CustomersChange | StorageLimitChange | AiLimitChange | DomainChange | MailTierChange | AppChange | BuildChange;
61
84
  /**
62
85
  * `auto`: nothing further is required -- archon may carry the request
63
86
  * straight into a plan and apply it. `approval`: a human must approve the
@@ -72,7 +95,21 @@ export type ChangeClassification = 'auto' | 'approval';
72
95
  * onward. Foundry only stores and classifies (#178 "Why"); nothing in this
73
96
  * package transitions a state past its initial value.
74
97
  */
75
- export type RequestedChangeState = 'auto' | 'needs_approval' | 'approved' | 'rejected' | 'applied';
98
+ export type RequestedChangeState = 'auto' | 'needs_approval' | 'approved' | 'rejected' | 'applied' | 'applying' | 'failed';
99
+ /** Why an `app` or `build` request is `failed`. */
100
+ export type FailureCode = 'replaced' | 'refused' | 'busy' | 'timed_out' | 'apply_error';
101
+ /** Who asked: a person in Boule, or a release build. `requestedBy` keeps the raw principal id for audit. */
102
+ export type RequestedBy = {
103
+ via: 'boule';
104
+ person: {
105
+ id: string;
106
+ name: string;
107
+ };
108
+ } | {
109
+ via: 'build';
110
+ repo: string;
111
+ release: string;
112
+ };
76
113
  /**
77
114
  * One company-requested change to its own declared org, as stored --
78
115
  * `migrations/0017_requested_changes.sql`'s `foundry_requested_changes`
@@ -85,6 +122,15 @@ export interface RequestedChange {
85
122
  requestedBy: string;
86
123
  createdAt: string;
87
124
  state: RequestedChangeState;
125
+ /** Null for rows made before `app` and `build` requests. */
126
+ requester: RequestedBy | null;
127
+ idempotencyKey: string | null;
128
+ reason: {
129
+ code: FailureCode;
130
+ message: string;
131
+ replacedBy?: number;
132
+ } | null;
133
+ updatedAt: string;
88
134
  }
89
135
  /**
90
136
  * Thrown by {@link classifyChange} (and by the requested-change store's
@@ -1,4 +1,4 @@
1
- import { validateCustomers } from './org.js';
1
+ import { validateCustomers, } from './org.js';
2
2
  export const REQUESTABLE_FIELDS = [
3
3
  'calls',
4
4
  'customers',
@@ -6,6 +6,8 @@ export const REQUESTABLE_FIELDS = [
6
6
  'aiLimit',
7
7
  'domain',
8
8
  'mailTier',
9
+ 'app',
10
+ 'build',
9
11
  ];
10
12
  /**
11
13
  * Thrown by {@link classifyChange} (and by the requested-change store's
@@ -42,6 +44,9 @@ function applyRequestedChange(org, change) {
42
44
  return { ...org, domain: change.domain };
43
45
  case 'mailTier':
44
46
  return { ...org, mail: { ...org.mail, mode: change.mode } };
47
+ case 'app':
48
+ case 'build':
49
+ return org;
45
50
  }
46
51
  }
47
52
  /**
@@ -115,6 +120,9 @@ export function classifyChange(org, change, plan) {
115
120
  if (!REQUESTABLE_FIELDS.includes(change.field)) {
116
121
  throw new RequestedChangeError(`'${change.field}' is not a requestable field`);
117
122
  }
123
+ // Archon validates the whole company for these; nothing waits for approval.
124
+ if (change.field === 'app' || change.field === 'build')
125
+ return 'auto';
118
126
  if (change.field === 'customers') {
119
127
  validateCustomersChange(change);
120
128
  // The org after the change must still pass the rule apply checks, for example open registration
@@ -1,4 +1,5 @@
1
1
  import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
2
+ import type { OrgRefusal, OrgRefusalCode } from '../org.js';
2
3
  import { type ReleaseStore } from '../release/state.js';
3
4
  import { type AppliedVersionsStore, type DeclaredOrgStore, type PlatformStore, type RequestedChangeStore } from '../store.js';
4
5
  /**
@@ -38,7 +39,10 @@ export declare function uniqueViolationConstraint(error: unknown): string | unde
38
39
  * is that throw.
39
40
  */
40
41
  export declare class DeclaredOrgError extends Error {
41
- constructor(message: string);
42
+ /** Set when a hosting or customers validator refused the write: which rule, and where in the org. */
43
+ readonly code?: OrgRefusalCode;
44
+ readonly path?: string;
45
+ constructor(message: string, refusal?: Pick<OrgRefusal, 'code' | 'path'>);
42
46
  }
43
47
  /**
44
48
  * `DeclaredOrgStore` (`../store.ts`) on Postgres, for #18: create, read and