@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
@@ -6,6 +6,13 @@ import type { DeclaredOrg, HostingEntry } from '../org.js';
6
6
  * acted on elsewhere.
7
7
  */
8
8
  export declare function entryHostnames(org: DeclaredOrg, entry: HostingEntry): string[];
9
+ /**
10
+ * The one exact name a Worker app is served at (ADR 0002): `<host>.<domain>` or
11
+ * the apex on the org's own domain, else `<slug>-<key>.oikos.host` (one label
12
+ * under `oikos.host`, so the free Universal certificate covers it), and
13
+ * `<slug>.oikos.host` for the entry keyed `app`.
14
+ */
15
+ export declare function workerAppHostname(org: DeclaredOrg, entry: HostingEntry): string;
9
16
  /**
10
17
  * The wildcard name (`*.<domain>`) when an entry declares `'*'`, else `null`.
11
18
  * Like `entryHostnames`, it is read only while hosting is on, `usesDomain` is
@@ -1,4 +1,5 @@
1
1
  import { APP_ENTRY_KEY } from '../org.js';
2
+ import { workerAppHostnameOf } from '../worker-app-config.js';
2
3
  import { agoraHostnameFor } from './agora.js';
3
4
  import { bouleHostnameFor } from './boule.js';
4
5
  /**
@@ -9,11 +10,22 @@ import { bouleHostnameFor } from './boule.js';
9
10
  */
10
11
  export function entryHostnames(org, entry) {
11
12
  const { domain } = org;
13
+ if (entry.workerApp)
14
+ return [workerAppHostname(org, entry)];
12
15
  if (!org.hosting.usesDomain || !domain) {
13
16
  return entry.key === APP_ENTRY_KEY ? [`${org.slug}.oikos.host`] : [];
14
17
  }
15
18
  return entry.hostnames.flatMap((name) => name === '*' ? [] : name === '@' ? [domain] : [`${name}.${domain}`]);
16
19
  }
20
+ /**
21
+ * The one exact name a Worker app is served at (ADR 0002): `<host>.<domain>` or
22
+ * the apex on the org's own domain, else `<slug>-<key>.oikos.host` (one label
23
+ * under `oikos.host`, so the free Universal certificate covers it), and
24
+ * `<slug>.oikos.host` for the entry keyed `app`.
25
+ */
26
+ export function workerAppHostname(org, entry) {
27
+ return workerAppHostnameOf({ slug: org.slug, domain: org.domain, usesDomain: org.hosting.usesDomain }, entry.key, entry.workerApp?.host ?? '@');
28
+ }
17
29
  /**
18
30
  * The wildcard name (`*.<domain>`) when an entry declares `'*'`, else `null`.
19
31
  * Like `entryHostnames`, it is read only while hosting is on, `usesDomain` is
@@ -98,8 +110,11 @@ export function filesShareHostname(org) {
98
110
  export function tunnelIngressHostnames(org, loginHost = null) {
99
111
  const hostnames = org.site.enabled && org.domain ? [org.domain, `cms.${org.domain}`] : [];
100
112
  if (org.hosting.enabled) {
101
- for (const entry of org.hosting.entries)
102
- hostnames.push(...entryHostnames(org, entry));
113
+ // A Worker app is reached through its own Worker route, never the tunnel.
114
+ for (const entry of org.hosting.entries) {
115
+ if (!entry.workerApp)
116
+ hostnames.push(...entryHostnames(org, entry));
117
+ }
103
118
  }
104
119
  else if (hostnames.length === 0) {
105
120
  hostnames.push(legacyAppHostname(org));
@@ -10,3 +10,14 @@ export declare const OWN_SERVICE_LABELS: readonly ["auth", "mail", "files", "sfu
10
10
  * oikos.host service names; any other org with no domain holds none.
11
11
  */
12
12
  export declare function lockedHostnames(org: DeclaredOrg, platformHostnames: readonly string[]): Set<string>;
13
+ /**
14
+ * What `validateHosting` knows of the org's names: its domain, the names
15
+ * foundry owns, its slug (for the domain-less `<slug>-<key>.oikos.host`) and
16
+ * whether its site is served at the apex.
17
+ */
18
+ export declare function hostingNames(org: DeclaredOrg, platformHostnames: readonly string[]): {
19
+ domain: string | null;
20
+ locked: Set<string>;
21
+ slug: string;
22
+ siteOwnsApex: boolean;
23
+ };
@@ -47,3 +47,16 @@ export function lockedHostnames(org, platformHostnames) {
47
47
  }
48
48
  return locked;
49
49
  }
50
+ /**
51
+ * What `validateHosting` knows of the org's names: its domain, the names
52
+ * foundry owns, its slug (for the domain-less `<slug>-<key>.oikos.host`) and
53
+ * whether its site is served at the apex.
54
+ */
55
+ export function hostingNames(org, platformHostnames) {
56
+ return {
57
+ domain: org.domain,
58
+ locked: lockedHostnames(org, platformHostnames),
59
+ slug: org.slug,
60
+ siteOwnsApex: org.site.enabled && org.site.usesDomain && org.domain !== null,
61
+ };
62
+ }
@@ -0,0 +1,57 @@
1
+ import type { DeclaredOrg } from '../org.js';
2
+ import type { DraftStep, StepAction } from './diff.js';
3
+ import type { ZitadelPort } from './executor.js';
4
+ import type { StepExecutionContext } from './plan-apply.js';
5
+ /**
6
+ * Auth ADR 0002, "What foundry declares and applies": the ZITADEL side of
7
+ * `uses.auth.mcp`. Three steps, all absent for an org that declares no `mcp`:
8
+ *
9
+ * - `zitadel:mcp-project` (per entry): an empty project in the customers
10
+ * organisation. It holds no app; it exists so its id can be the token's
11
+ * audience. The id is recorded once, and `entryMcp` (`broker-entries.ts`) is
12
+ * the single reader: the broker entry and the app's `MCP_AUDIENCE` both take
13
+ * it from there.
14
+ * - `zitadel:mcp-access` (per entry): the entry's existing sign-in app issues
15
+ * JWT access tokens and accepts `https://<login host>/mcp-oauth/callback`.
16
+ * - `zitadel:access-token-lifetime` (per org): the instance-wide lifetime goes
17
+ * from 12 hours to one. No app of ours keeps an access token, so no other
18
+ * app is affected (auth ADR 0002, 4). It is set once any org declares `mcp`
19
+ * and is not set back.
20
+ *
21
+ * Blocked, not failing (`CLAUDE.md` rule 2), while the executor's ZITADEL
22
+ * client lacks the calls, or an input is neither recorded nor made by a step in
23
+ * this plan.
24
+ */
25
+ type Blocked = Array<{
26
+ description: string;
27
+ key: string;
28
+ }>;
29
+ /** A ZITADEL call of the MCP steps that has not answered in this long fails the step; the next apply repeats it. */
30
+ export declare const MCP_CALL_TIMEOUT_MS = 60000;
31
+ /** The want of the access step, recorded when it is done. */
32
+ export declare const mcpAccessWant: (org: DeclaredOrg) => string;
33
+ export declare function planMcp(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], zitadelMcp: boolean): {
34
+ steps: DraftStep[];
35
+ blocked: Blocked;
36
+ };
37
+ /**
38
+ * The empty project in the customers organisation that is an entry's MCP token
39
+ * audience. Found by name first, so a create whose answer was lost is not made
40
+ * twice. Its id is recorded under {@link entryMcpProjectScopeKey}, the one
41
+ * source `entryMcp` reads.
42
+ */
43
+ export declare function applyMcpProject(zitadel: Pick<ZitadelPort, 'findOrCreateProject'>, org: DeclaredOrg, action: StepAction, context: StepExecutionContext): Promise<{
44
+ resourceId?: string;
45
+ }>;
46
+ /**
47
+ * The entry's sign-in app issues JWT access tokens and holds the broker's
48
+ * callback. Records the want last, so a failed call is planned again.
49
+ */
50
+ export declare function applyMcpAccess(zitadel: Pick<ZitadelPort, 'ensureOidcAppMcpAccess'>, org: DeclaredOrg, action: StepAction, context: StepExecutionContext): Promise<{
51
+ resourceId?: string;
52
+ }>;
53
+ /** The instance access-token lifetime, set to one hour. */
54
+ export declare function applyAccessTokenLifetime(zitadel: Pick<ZitadelPort, 'ensureAccessTokenLifetime'>, context: StepExecutionContext): Promise<{
55
+ resourceId?: string;
56
+ }>;
57
+ export {};
@@ -0,0 +1,131 @@
1
+ import { ACCESS_TOKEN_LIFETIME_APPLIED_SCOPE_KEY, MCP_ACCESS_TOKEN_LIFETIME_SECONDS, entryMcpAccessAppliedScopeKey, entryMcpProjectScopeKey, mcpCallbackUri, } from './broker-entries.js';
2
+ import { withCallTimeout } from './worker-app.js';
3
+ /** A ZITADEL call of the MCP steps that has not answered in this long fails the step; the next apply repeats it. */
4
+ export const MCP_CALL_TIMEOUT_MS = 60_000;
5
+ /** The want of the access step, recorded when it is done. */
6
+ export const mcpAccessWant = (org) => JSON.stringify({ accessTokenType: 'jwt', redirectUri: mcpCallbackUri(org) });
7
+ export function planMcp(org, recorded, plannedSteps, zitadelMcp) {
8
+ const steps = [];
9
+ const blocked = [];
10
+ const entries = org.hosting.enabled
11
+ ? org.hosting.entries.filter((e) => Boolean(e.uses?.auth?.mcp))
12
+ : [];
13
+ if (entries.length === 0)
14
+ return { steps, blocked };
15
+ const planned = (system, kind, key) => plannedSteps.some((p) => p.action.system === system &&
16
+ p.action.kind === kind &&
17
+ (key === undefined || p.action.key === key));
18
+ for (const entry of entries) {
19
+ const at = `${org.slug}'s ${entry.key} MCP`;
20
+ const stepKey = `${org.id}:${entry.key}`;
21
+ if (!zitadelMcp) {
22
+ blocked.push({
23
+ description: `${at} is blocked, not planned: the host's ZITADEL client cannot set JWT access tokens or the access-token lifetime`,
24
+ key: `zitadel:mcp:${stepKey}`,
25
+ });
26
+ continue;
27
+ }
28
+ if (!recorded[entryMcpProjectScopeKey(entry.key)]) {
29
+ if (!recorded[`zitadel:customers:${org.id}`] && !planned('zitadel', 'customers')) {
30
+ blocked.push({
31
+ description: `${at} project is blocked, not planned: the customers organisation id is not recorded yet`,
32
+ key: `zitadel:mcp-project:${stepKey}`,
33
+ });
34
+ }
35
+ else {
36
+ steps.push({
37
+ action: { system: 'zitadel', kind: 'mcp-project', key: stepKey, desired: {} },
38
+ step: {
39
+ description: `make the empty project that is ${org.slug}'s ${entry.key} MCP token audience`,
40
+ destroys: false,
41
+ migrates: false,
42
+ },
43
+ });
44
+ }
45
+ }
46
+ if (recorded[entryMcpAccessAppliedScopeKey(entry.key)] !== mcpAccessWant(org)) {
47
+ if (!recorded[`zitadel:app:${entry.key}`] && !planned('zitadel', 'app', entry.key)) {
48
+ blocked.push({
49
+ description: `${at} access is blocked, not planned: the sign-in client is not recorded yet`,
50
+ key: `zitadel:mcp-access:${stepKey}`,
51
+ });
52
+ }
53
+ else {
54
+ steps.push({
55
+ action: { system: 'zitadel', kind: 'mcp-access', key: stepKey, desired: {} },
56
+ step: {
57
+ description: `${org.slug}'s ${entry.key} sign-in client issues JWT access tokens and accepts ${mcpCallbackUri(org)}`,
58
+ destroys: false,
59
+ migrates: false,
60
+ },
61
+ });
62
+ }
63
+ }
64
+ }
65
+ if (zitadelMcp &&
66
+ recorded[ACCESS_TOKEN_LIFETIME_APPLIED_SCOPE_KEY] !== String(MCP_ACCESS_TOKEN_LIFETIME_SECONDS)) {
67
+ steps.push({
68
+ action: { system: 'zitadel', kind: 'access-token-lifetime', key: org.id, desired: {} },
69
+ step: {
70
+ description: `set the sign-in instance's access-token lifetime to ${MCP_ACCESS_TOKEN_LIFETIME_SECONDS / 3600} hour (MCP access tokens are short-lived; no app of ours keeps one)`,
71
+ destroys: false,
72
+ migrates: false,
73
+ },
74
+ });
75
+ }
76
+ return { steps, blocked };
77
+ }
78
+ // ---- the apply -------------------------------------------------------------
79
+ /**
80
+ * The empty project in the customers organisation that is an entry's MCP token
81
+ * audience. Found by name first, so a create whose answer was lost is not made
82
+ * twice. Its id is recorded under {@link entryMcpProjectScopeKey}, the one
83
+ * source `entryMcp` reads.
84
+ */
85
+ export async function applyMcpProject(zitadel, org, action, context) {
86
+ const entryKey = action.key.slice(org.id.length + 1);
87
+ const at = `zitadel:mcp-project for ${org.slug} entry '${entryKey}'`;
88
+ if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.auth?.mcp)) {
89
+ throw new Error(`${at}: the entry no longer declares uses.auth.mcp`);
90
+ }
91
+ const customersOrgId = context.recordedIds()[`zitadel:customers:${org.id}`];
92
+ if (!customersOrgId)
93
+ throw new Error(`${at}: the customers organisation is not recorded yet`);
94
+ const { project } = await withCallTimeout(MCP_CALL_TIMEOUT_MS, `${at}: find or create the project`, () => zitadel.findOrCreateProject(customersOrgId, `${org.slug}-${entryKey}-mcp`));
95
+ context.recordScopeId(entryMcpProjectScopeKey(entryKey), project.id);
96
+ return { resourceId: project.id };
97
+ }
98
+ /**
99
+ * The entry's sign-in app issues JWT access tokens and holds the broker's
100
+ * callback. Records the want last, so a failed call is planned again.
101
+ */
102
+ export async function applyMcpAccess(zitadel, org, action, context) {
103
+ const entryKey = action.key.slice(org.id.length + 1);
104
+ const at = `zitadel:mcp-access for ${org.slug} entry '${entryKey}'`;
105
+ const ensure = zitadel.ensureOidcAppMcpAccess?.bind(zitadel);
106
+ if (!ensure)
107
+ throw new Error(`${at}: the ZITADEL client cannot set JWT access tokens`);
108
+ if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.auth?.mcp)) {
109
+ throw new Error(`${at}: the entry no longer declares uses.auth.mcp`);
110
+ }
111
+ const recorded = context.recordedIds();
112
+ const zitadelOrgId = recorded.zitadel_org;
113
+ const projectId = recorded.zitadel_project;
114
+ const appId = recorded[`zitadel:app:${entryKey}`];
115
+ if (!zitadelOrgId || !projectId || !appId) {
116
+ throw new Error(`${at}: the sign-in client is not recorded yet`);
117
+ }
118
+ await withCallTimeout(MCP_CALL_TIMEOUT_MS, `${at}: update the app`, () => ensure(zitadelOrgId, projectId, appId, { redirectUris: [mcpCallbackUri(org)] }));
119
+ context.recordScopeId(entryMcpAccessAppliedScopeKey(entryKey), mcpAccessWant(org));
120
+ return {};
121
+ }
122
+ /** The instance access-token lifetime, set to one hour. */
123
+ export async function applyAccessTokenLifetime(zitadel, context) {
124
+ const ensure = zitadel.ensureAccessTokenLifetime?.bind(zitadel);
125
+ if (!ensure) {
126
+ throw new Error('zitadel:access-token-lifetime: the ZITADEL client cannot set the lifetime');
127
+ }
128
+ await withCallTimeout(MCP_CALL_TIMEOUT_MS, 'zitadel:access-token-lifetime: set the lifetime', () => ensure(MCP_ACCESS_TOKEN_LIFETIME_SECONDS));
129
+ context.recordScopeId(ACCESS_TOKEN_LIFETIME_APPLIED_SCOPE_KEY, String(MCP_ACCESS_TOKEN_LIFETIME_SECONDS));
130
+ return {};
131
+ }
@@ -12,7 +12,7 @@ export declare function companyServiceNames(org: Pick<DeclaredOrg, 'slug' | 'cha
12
12
  * foundry derives for an org: its own-service names when it owns services,
13
13
  * and its hosting-entry and database names.
14
14
  */
15
- export declare function orgDerivedNames(org: Pick<DeclaredOrg, 'slug' | 'services' | 'hosting' | 'chat' | 'calls' | 'site'>): {
15
+ export declare function orgDerivedNames(org: Pick<DeclaredOrg, 'slug' | 'domain' | 'services' | 'hosting' | 'chat' | 'calls' | 'site'>): {
16
16
  name: string;
17
17
  source: OrgNameSource;
18
18
  }[];
@@ -26,5 +26,5 @@ export interface NameClash {
26
26
  source: OrgNameSource;
27
27
  }
28
28
  /** Every name `org` derives that another org or the platform also derives. */
29
- export declare function findNameClashes(org: Pick<DeclaredOrg, 'slug' | 'services' | 'hosting' | 'chat' | 'calls' | 'site'>, others: readonly Pick<DeclaredOrg, 'slug' | 'services' | 'hosting' | 'chat' | 'calls' | 'site'>[]): NameClash[];
29
+ export declare function findNameClashes(org: Pick<DeclaredOrg, 'slug' | 'domain' | 'services' | 'hosting' | 'chat' | 'calls' | 'site'>, others: readonly Pick<DeclaredOrg, 'slug' | 'domain' | 'services' | 'hosting' | 'chat' | 'calls' | 'site'>[]): NameClash[];
30
30
  export declare function nameClashMessage(slug: string, clash: NameClash): string;
@@ -1,5 +1,7 @@
1
1
  import { ownsServices } from '../org.js';
2
+ import { entryHasDatabase } from '../worker-app-config.js';
2
3
  import { chatInternalNames, chatRekeyedInternalNames } from './chat-matrix.js';
4
+ import { workerAppHostname } from './hostnames.js';
3
5
  import { orgTarget, platformTarget } from './own-services.js';
4
6
  import { appNameFor } from './platform-diff.js';
5
7
  /**
@@ -38,7 +40,17 @@ export function orgDerivedNames(org) {
38
40
  for (const entry of org.hosting.entries) {
39
41
  names.push({ name: appNameFor(org, entry), source: 'hosting' });
40
42
  }
41
- if (org.hosting.entries.some((entry) => entry.database)) {
43
+ // A Worker app's public host. The domain-less `<slug>-<key>.oikos.host` of
44
+ // org `foo` key `x` is the `<slug>.oikos.host` of org `foo-x`, so hosts are
45
+ // compared across orgs like every other derived name.
46
+ if (org.hosting.enabled) {
47
+ for (const entry of org.hosting.entries) {
48
+ if (entry.workerApp) {
49
+ names.push({ name: workerAppHostname(org, entry), source: 'hosting' });
50
+ }
51
+ }
52
+ }
53
+ if (org.hosting.entries.some(entryHasDatabase)) {
42
54
  names.push({ name: `${org.slug}-db`, source: 'hosting' });
43
55
  }
44
56
  return names;
@@ -93,6 +105,9 @@ export function findNameClashes(org, others) {
93
105
  if (other.slug === org.slug)
94
106
  continue;
95
107
  const theirs = new Set(orgDerivedNames(other).map((n) => n.name));
108
+ // Org `foo-x` without a domain is served at `foo-x.oikos.host`.
109
+ if (!other.domain)
110
+ theirs.add(`${other.slug}.oikos.host`);
96
111
  for (const { name, source } of mine) {
97
112
  if (theirs.has(name))
98
113
  clashes.push({ name, other: other.slug, source });
@@ -2,18 +2,21 @@ import type { DestroyProgress, PlanApply } from '../apply.js';
2
2
  import type { DeclaredOrg, OrgId } from '../org.js';
3
3
  import type { Plan, PlanStep } from '../plan.js';
4
4
  import type { DeclaredPlatform } from '../platform.js';
5
+ import type { ReleaseStore } from '../release/state.js';
5
6
  import type { JobRunner } from '../runner/types.js';
6
7
  import type { AppliedState } from '../state.js';
7
8
  import { type AppliedVersionsStore, type DeclaredOrgStore, type PlatformStore } from '../store.js';
8
9
  import { type AdoptCandidate, type AdoptInput, type AdoptPlan } from './adopt.js';
9
10
  import { type AgoraHostPort } from './agora.js';
10
11
  import { type MoveGatePorts } from './backup-gate.js';
12
+ import { type BlueGreenServer, type BlueGreenSubject } from './blue-green.js';
11
13
  import { type UnconfirmedUpgrade } from './confirm-upgrade.js';
12
14
  import { type StepAction } from './diff.js';
13
15
  import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
14
16
  import { type LoginHostDiscoveryPort } from './login-host.js';
15
17
  import { type EdgePortName, type HeldKeyResolver, type PlatformOwnerInput } from './platform-org.js';
16
18
  import type { CallsSfuConnectionPort } from './platform-sfu.js';
19
+ import { type ReleaseContext, type ReleaseHost } from './release-host.js';
17
20
  /** Who did it, for `@wtfalch/audit`'s "who, when, the plan, the result" (#9, #19). */
18
21
  export interface AuditActor {
19
22
  readonly id: string;
@@ -205,6 +208,21 @@ export interface StepExecutor {
205
208
  }>;
206
209
  /** Whether the executor can mint an own org's Lettermint key: a Lettermint port and a reader for the held ref. */
207
210
  ownMailKeyMintable?(): boolean;
211
+ /** C3: whether the ZITADEL port can mint and revoke a customers directory credential. */
212
+ customersDirectoryAvailable?(): boolean;
213
+ /** C3: whether the host supplied `mintBouleArchonCredential`. */
214
+ bouleArchonCredentialAvailable?(): boolean;
215
+ /** Every Coolify server with whether it is usable and reachable; blue/green plans copies on each. Optional: an executor without it plans no blue/green. */
216
+ blueGreenServers?(): Promise<Array<{
217
+ uuid: string;
218
+ usable: boolean;
219
+ reachable: boolean;
220
+ }>>;
221
+ /**
222
+ * The release runner's outside calls for one blue/green entry (`plan/release-host.ts`); `null` while its blue
223
+ * copy is not recorded. Optional: an executor without it runs no release.
224
+ */
225
+ releaseHost?(org: DeclaredOrg, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
208
226
  /** Whether the executor's held keys can store the sign-in database's CA and certificate (#746). Absent: true. */
209
227
  ownSigninDbKeysPuttable?(): boolean;
210
228
  /** Whether the host supplies the backup bucket the sign-in database dumps into (#746). Absent: true. */
@@ -325,6 +343,16 @@ export interface PlatformStepExecutor {
325
343
  * one), or `null` when nothing changed.
326
344
  */
327
345
  refreshFilesShare?(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<Record<string, string> | null>;
346
+ /**
347
+ * The Coolify servers and the reason platform-archon's blue/green cannot run (the executor cannot hold the
348
+ * health token), for the plan. Optional: an executor without it plans no blue/green for archon.
349
+ */
350
+ archonBlueGreen?(): Promise<{
351
+ servers?: BlueGreenServer[];
352
+ blocked?: string;
353
+ }>;
354
+ /** The release runner's outside calls for platform-archon (`plan/release-host.ts`); `null` while its app is not recorded. */
355
+ releaseHost?(platform: DeclaredPlatform, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
328
356
  execute(action: StepAction, context: PlatformStepExecutionContext): Promise<{
329
357
  resourceId?: string;
330
358
  } | undefined>;
@@ -453,6 +481,12 @@ export interface FoundryPlanApplyOptions {
453
481
  * token of its existing sign-in apps.
454
482
  */
455
483
  zitadelIdTokenUserinfo?: boolean;
484
+ /**
485
+ * Auth ADR 0002: true when the host's ZITADEL client has `ensureOidcAppMcpAccess`
486
+ * and `ensureAccessTokenLifetime` (`@wtfalch/providers` 0.11.0). Without it every
487
+ * `uses.auth.mcp` is reported blocked.
488
+ */
489
+ zitadelMcp?: boolean;
456
490
  /**
457
491
  * F5: true when the host's executor was built with a `broker` port.
458
492
  * Optional, and false without it: each entry's broker entry is then
@@ -465,6 +499,17 @@ export interface FoundryPlanApplyOptions {
465
499
  * reported blocked instead of planned.
466
500
  */
467
501
  edgeWorkers?: boolean;
502
+ /**
503
+ * ADR 0002: true when the host's executor was built with a `WorkerAppPort`.
504
+ * Optional, and false without it: each `workerApp` entry is then reported blocked.
505
+ */
506
+ workerApps?: boolean;
507
+ /** ADR 0002: the keys custody Worker address, when the executor has a `KeysCustodyPort`. A `keysCustody` secret is blocked without it. */
508
+ workerAppKeysUrl?: string;
509
+ /** C2: true when the host's executor was built with a `WorkerAppDatabasePort`. */
510
+ workerAppDatabase?: boolean;
511
+ /** ADR 0002: the platform's Cloudflare account id. A container image outside its registry is blocked. */
512
+ workerAppRegistryAccountId?: string;
468
513
  /**
469
514
  * True when the host's executor was built with `ownerInviteMail`. Optional,
470
515
  * and false without it: each owner's invite is then reported blocked
@@ -510,6 +555,11 @@ export interface FoundryPlanApplyOptions {
510
555
  step(action: StepAction): boolean;
511
556
  blocked(key: string): boolean;
512
557
  };
558
+ /**
559
+ * The release requests (Archon's promote, roll back, clear held and decide-at-cap clicks), consumed once each by
560
+ * the release runner. Without it no release runs, in an apply or in {@link FoundryPlanApply.runReleaseTick}.
561
+ */
562
+ releaseStore?: ReleaseStore;
513
563
  /** Overridable for tests; defaults to `() => new Date()`. */
514
564
  now?: () => Date;
515
565
  /** #441: how long a mutation lease lasts before another caller may take it over. Defaults to 30 minutes. */
@@ -590,8 +640,13 @@ export declare class FoundryPlanApply implements PlanApply {
590
640
  private readonly loginHostBindings;
591
641
  private readonly loginHostDiscovery;
592
642
  private readonly zitadelIdTokenUserinfo;
643
+ private readonly zitadelMcp;
593
644
  private readonly broker;
594
645
  private readonly edgeWorkers;
646
+ private readonly workerApps;
647
+ private readonly workerAppKeysUrl;
648
+ private readonly workerAppDatabase;
649
+ private readonly workerAppRegistryAccountId;
595
650
  private readonly ownerInviteMail;
596
651
  private readonly hostFiles;
597
652
  private readonly hostAi;
@@ -605,6 +660,7 @@ export declare class FoundryPlanApply implements PlanApply {
605
660
  private readonly backupGate;
606
661
  private readonly platformDeps;
607
662
  private readonly runner;
663
+ private readonly releaseStore;
608
664
  constructor(options: FoundryPlanApplyOptions);
609
665
  plan(id: OrgId): Promise<Plan>;
610
666
  /**
@@ -741,6 +797,27 @@ export declare class FoundryPlanApply implements PlanApply {
741
797
  private refusePlatform;
742
798
  private refuseDestroy;
743
799
  private runSteps;
800
+ /**
801
+ * ADR 0001: one release run for each blue/green entry whose structure is applied, writing into `context`
802
+ * (the live ids of the run that holds the lease). A weight the runner changed reaches every server's balancer
803
+ * before this returns.
804
+ */
805
+ private runOrgReleases;
806
+ /** One release run for platform-archon, on the platform's live ids; see {@link runOrgReleases}. */
807
+ private runPlatformReleases;
808
+ private runPlatformReleaseTick;
809
+ /**
810
+ * The recurring release run (ADR 0001): for every declared org with a blue/green entry, and the platform's
811
+ * archon when it declares one, takes the open requests, reads the health signal and moves traffic. Foundry has
812
+ * no scheduler of its own, so the host calls this every 10 minutes. An org that cannot run (the actor may not
813
+ * apply it, an apply holds its lease, a provider call fails) is reported by id and does not stop the others.
814
+ * A refusal is audited; a run that does nothing writes no audit row.
815
+ */
816
+ runReleaseTick(): Promise<Array<{
817
+ target: string;
818
+ error?: string;
819
+ }>>;
820
+ private runOrgReleaseTick;
744
821
  /** An own org with calls on: the host's answer about its slot's ports. A failure or a slow answer is "not open". */
745
822
  private readSfuPortsOpen;
746
823
  private computeFreshPlan;