@wtfalch/foundry 0.43.0 → 0.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/index.d.ts +3 -3
  2. package/dist/index.js +1 -1
  3. package/dist/migrations/0057_org_customers.sql +29 -0
  4. package/dist/org.d.ts +42 -0
  5. package/dist/org.js +52 -26
  6. package/dist/plan/boule.d.ts +43 -2
  7. package/dist/plan/boule.js +48 -1
  8. package/dist/plan/broker-entries.d.ts +28 -0
  9. package/dist/plan/broker-entries.js +46 -1
  10. package/dist/plan/diff.d.ts +14 -0
  11. package/dist/plan/diff.js +176 -18
  12. package/dist/plan/executor.d.ts +100 -2
  13. package/dist/plan/executor.js +273 -16
  14. package/dist/plan/hostnames.d.ts +7 -0
  15. package/dist/plan/hostnames.js +17 -2
  16. package/dist/plan/locked-hostnames.d.ts +11 -0
  17. package/dist/plan/locked-hostnames.js +13 -0
  18. package/dist/plan/mcp.d.ts +57 -0
  19. package/dist/plan/mcp.js +131 -0
  20. package/dist/plan/org-names.d.ts +2 -2
  21. package/dist/plan/org-names.js +16 -1
  22. package/dist/plan/plan-apply.d.ts +26 -0
  23. package/dist/plan/plan-apply.js +45 -5
  24. package/dist/plan/platform-providers.d.ts +1 -1
  25. package/dist/plan/platform-providers.js +2 -1
  26. package/dist/plan/proxy-labels.js +2 -0
  27. package/dist/plan/worker-app-database.d.ts +148 -0
  28. package/dist/plan/worker-app-database.js +424 -0
  29. package/dist/plan/worker-app.d.ts +320 -0
  30. package/dist/plan/worker-app.js +792 -0
  31. package/dist/plan/zitadel-machine-user.d.ts +13 -1
  32. package/dist/plan/zitadel-machine-user.js +80 -0
  33. package/dist/requested-changes.d.ts +12 -3
  34. package/dist/requested-changes.js +22 -0
  35. package/dist/store/postgres.js +12 -6
  36. package/dist/store/schema.d.ts +34 -0
  37. package/dist/store/schema.js +3 -0
  38. package/dist/worker-app-config.d.ts +151 -0
  39. package/dist/worker-app-config.js +438 -0
  40. package/package.json +1 -1
@@ -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 });
@@ -208,6 +208,10 @@ export interface StepExecutor {
208
208
  }>;
209
209
  /** Whether the executor can mint an own org's Lettermint key: a Lettermint port and a reader for the held ref. */
210
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;
211
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. */
212
216
  blueGreenServers?(): Promise<Array<{
213
217
  uuid: string;
@@ -477,6 +481,12 @@ export interface FoundryPlanApplyOptions {
477
481
  * token of its existing sign-in apps.
478
482
  */
479
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;
480
490
  /**
481
491
  * F5: true when the host's executor was built with a `broker` port.
482
492
  * Optional, and false without it: each entry's broker entry is then
@@ -489,6 +499,17 @@ export interface FoundryPlanApplyOptions {
489
499
  * reported blocked instead of planned.
490
500
  */
491
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;
492
513
  /**
493
514
  * True when the host's executor was built with `ownerInviteMail`. Optional,
494
515
  * and false without it: each owner's invite is then reported blocked
@@ -619,8 +640,13 @@ export declare class FoundryPlanApply implements PlanApply {
619
640
  private readonly loginHostBindings;
620
641
  private readonly loginHostDiscovery;
621
642
  private readonly zitadelIdTokenUserinfo;
643
+ private readonly zitadelMcp;
622
644
  private readonly broker;
623
645
  private readonly edgeWorkers;
646
+ private readonly workerApps;
647
+ private readonly workerAppKeysUrl;
648
+ private readonly workerAppDatabase;
649
+ private readonly workerAppRegistryAccountId;
624
650
  private readonly ownerInviteMail;
625
651
  private readonly hostFiles;
626
652
  private readonly hostAi;
@@ -16,7 +16,7 @@ import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
16
16
  import { BOULE_EDGE_ENTRY_KEY, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
17
17
  import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
18
18
  import { readLivePlatformResources, readLiveResources, } from './live.js';
19
- import { lockedHostnames } from './locked-hostnames.js';
19
+ import { hostingNames } from './locked-hostnames.js';
20
20
  import { agoraIssuerHostFor, loginHostServesAgora, readLoginHostDiscovery, } from './login-host.js';
21
21
  import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_BASE, OWN_MAIL_SMTP_PORT_MAX, OWN_MAIL_SMTP_PORT_SCOPE_KEY, PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, isOwnMailSmtpPort, mailRelayFingerprint, } from './mail-relay.js';
22
22
  import { findNameClashes, nameClashMessage } from './org-names.js';
@@ -28,6 +28,7 @@ import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY
28
28
  import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, } from './proxy-labels.js';
29
29
  import { runSubjectRelease } from './release-host.js';
30
30
  import { remoteDatabaseFactsFrom } from './remote-database.js';
31
+ import { liveWorkerApps, workerAppTraceScopeKeys } from './worker-app.js';
31
32
  import { liveWorkers, workerTraceScopeKeys } from './worker.js';
32
33
  /** #441: the lease scope that serializes every mutation of one company, and the one for the shared platform. */
33
34
  const companyScope = (orgId) => `company:${orgId}`;
@@ -134,6 +135,8 @@ const HOSTNAME_STEPS = new Set([
134
135
  // #318 (F8): the route is itself hostname-shaped, validated against the
135
136
  // org's own domain by `validateEntryWorker`.
136
137
  'cloudflare:worker',
138
+ // ADR 0002: its host is hostname-shaped and validated by `validateEntryWorkerApp`.
139
+ 'cloudflare:worker-app',
137
140
  ]);
138
141
  /**
139
142
  * The #19 implementation of {@link PlanApply}: computes a plan against live
@@ -158,8 +161,13 @@ export class FoundryPlanApply {
158
161
  loginHostBindings;
159
162
  loginHostDiscovery;
160
163
  zitadelIdTokenUserinfo;
164
+ zitadelMcp;
161
165
  broker;
162
166
  edgeWorkers;
167
+ workerApps;
168
+ workerAppKeysUrl;
169
+ workerAppDatabase;
170
+ workerAppRegistryAccountId;
163
171
  ownerInviteMail;
164
172
  hostFiles;
165
173
  hostAi;
@@ -187,8 +195,13 @@ export class FoundryPlanApply {
187
195
  this.loginHostBindings = options.loginHostBindings ?? false;
188
196
  this.loginHostDiscovery = options.loginHostDiscovery;
189
197
  this.zitadelIdTokenUserinfo = options.zitadelIdTokenUserinfo ?? false;
198
+ this.zitadelMcp = options.zitadelMcp ?? false;
190
199
  this.broker = options.broker ?? false;
191
200
  this.edgeWorkers = options.edgeWorkers ?? false;
201
+ this.workerApps = options.workerApps ?? false;
202
+ this.workerAppKeysUrl = options.workerAppKeysUrl;
203
+ this.workerAppDatabase = options.workerAppDatabase ?? false;
204
+ this.workerAppRegistryAccountId = options.workerAppRegistryAccountId;
192
205
  this.ownerInviteMail = options.ownerInviteMail ?? false;
193
206
  this.hostFiles = options.filesOrganisation ?? false;
194
207
  this.hostAi = options.aiOrganisation ?? false;
@@ -697,6 +710,12 @@ export class FoundryPlanApply {
697
710
  delete serviceIds[scopeKey];
698
711
  }
699
712
  }
713
+ if (target.system === 'cloudflare' && target.kind === 'worker-app') {
714
+ const entryKey = target.key.slice(id.length + 1);
715
+ for (const scopeKey of workerAppTraceScopeKeys(entryKey)) {
716
+ delete serviceIds[scopeKey];
717
+ }
718
+ }
700
719
  steps[current] = {
701
720
  step: descriptions[current],
702
721
  status: 'finished',
@@ -1200,10 +1219,15 @@ export class FoundryPlanApply {
1200
1219
  loginHostBindings: this.loginHostBindings,
1201
1220
  loginHostServesDiscovery,
1202
1221
  zitadelIdTokenUserinfo: this.zitadelIdTokenUserinfo,
1222
+ zitadelMcp: this.zitadelMcp,
1203
1223
  broker: ownsServices(org) && this.hostOwnServices && this.executor.ownBrokerPort
1204
1224
  ? await this.executor.ownBrokerPort(org, recorded)
1205
1225
  : this.broker,
1206
1226
  edgeWorkers: this.edgeWorkers,
1227
+ workerApps: this.workerApps,
1228
+ workerAppKeysUrl: this.workerAppKeysUrl,
1229
+ workerAppDatabase: this.workerAppDatabase,
1230
+ workerAppRegistryAccountId: this.workerAppRegistryAccountId,
1207
1231
  ownerInviteMail: this.ownerInviteMail,
1208
1232
  otherSlugs,
1209
1233
  now: () => this.now().getTime(),
@@ -1214,6 +1238,8 @@ export class FoundryPlanApply {
1214
1238
  oikosZoneReady: platformReadiness.oikosIngressApplied === true,
1215
1239
  ownMailKeyHeld: await this.ownMailKeyHeld(org),
1216
1240
  ownMailKeyMintable: this.executor.ownMailKeyMintable?.() ?? false,
1241
+ customersDirectory: this.executor.customersDirectoryAvailable?.() ?? false,
1242
+ bouleArchonCredential: this.executor.bouleArchonCredentialAvailable?.() ?? false,
1217
1243
  ...(blueGreenServers ? { blueGreenServers } : {}),
1218
1244
  ...(blueGreenServers && org.hosting.entries.some((e) => e.blueGreen && e.database)
1219
1245
  ? {
@@ -1309,10 +1335,7 @@ export class FoundryPlanApply {
1309
1335
  * which only the plan can read.
1310
1336
  */
1311
1337
  async invalidHosting(org) {
1312
- return validateHosting(org.hosting, {
1313
- domain: org.domain,
1314
- locked: lockedHostnames(org, await this.platformServiceHostnames()),
1315
- });
1338
+ return validateHosting(org.hosting, hostingNames(org, await this.platformServiceHostnames()));
1316
1339
  }
1317
1340
  async platformServiceHostnames() {
1318
1341
  const platform = await this.platformDeps?.store.get();
@@ -1781,6 +1804,8 @@ function destroyTargets(serviceIds, orgId) {
1781
1804
  'cloudflare:gate:',
1782
1805
  // #318: an entry's Worker and its route.
1783
1806
  'cloudflare:worker:',
1807
+ // ADR 0002: a Worker app's route, record, containers, keys and script.
1808
+ 'cloudflare:worker-app:',
1784
1809
  'coolify:database:',
1785
1810
  'coolify:app:',
1786
1811
  // An own files host's storage key: the token id is the step's recorded resource id.
@@ -1811,6 +1836,17 @@ function destroyTargets(serviceIds, orgId) {
1811
1836
  }
1812
1837
  continue;
1813
1838
  }
1839
+ if (prefix === 'cloudflare:worker-app:') {
1840
+ for (const [entryKey, scriptName] of liveWorkerApps(orgId, serviceIds)) {
1841
+ targets.push({
1842
+ system: 'cloudflare',
1843
+ kind: 'worker-app',
1844
+ key: `${orgId}:${entryKey}`,
1845
+ id: scriptName,
1846
+ });
1847
+ }
1848
+ continue;
1849
+ }
1814
1850
  // Blue/green: the colors, balancers, stage, database sidecars and extra connectors, by recorded id, before
1815
1851
  // the main database they connect to. Their keys carry no ':' (scope ids), so no prefix above finds them.
1816
1852
  if (prefix === 'coolify:database:')
@@ -1983,6 +2019,9 @@ const STEP_FIELDS = {
1983
2019
  'zitadel:owner': 'owners',
1984
2020
  'zitadel:customers': 'hosting.entries.uses.auth',
1985
2021
  'zitadel:id-token-userinfo': 'hosting.entries.uses.auth',
2022
+ 'zitadel:mcp-project': 'hosting.entries.uses.auth.mcp',
2023
+ 'zitadel:mcp-access': 'hosting.entries.uses.auth.mcp',
2024
+ 'zitadel:access-token-lifetime': 'hosting.entries.uses.auth.mcp',
1986
2025
  'coolify:broker-entry': 'hosting.entries.uses.auth',
1987
2026
  'coolify:mail': 'mail',
1988
2027
  'coolify:chat': 'chat.usesDomain',
@@ -2010,6 +2049,7 @@ const STEP_FIELDS = {
2010
2049
  'cloudflare:gate': 'hosting.entries.gates',
2011
2050
  'cloudflare:ratelimit': 'hosting.usesDomain',
2012
2051
  'cloudflare:worker': 'hosting.entries.worker',
2052
+ 'cloudflare:worker-app': 'hosting.entries.workerApp',
2013
2053
  };
2014
2054
  function stepField(action) {
2015
2055
  // An entry's ai key (F6a) is keyed `<orgId>:<entry>`; the org's own is `<orgId>`.
@@ -995,7 +995,7 @@ export interface PlatformArchonRuntime {
995
995
  export declare const PLATFORM_ARCHON_ACCESS_APP_NAME = "foundry:platform:archon";
996
996
  /**
997
997
  * Archon paths a machine calls and Archon authenticates itself (its `proxy.ts`:
998
- * `PUBLIC_EXACT` and `ORG_CHANGES_PATH_RE`). Each gets its own Access app with
998
+ * `PUBLIC_ROUTES` and the org route pattern in `src/lib/public-routes.ts`). Each gets its own Access app with
999
999
  * one bypass policy, because the hostname app would otherwise ask a machine for
1000
1000
  * an emailed code. `/api/uptime/targets` and `/api/uptime/results` are left out
1001
1001
  * on purpose: they pass the hostname app through the uptime service-token
@@ -3722,7 +3722,7 @@ const PLATFORM_ARCHON_TUNNEL_SCOPE_KEY = 'cloudflare_platform_archon_tunnel';
3722
3722
  const PLATFORM_ARCHON_ACCESS_SCOPE_KEY = 'cloudflare_platform_archon_access_app';
3723
3723
  /**
3724
3724
  * Archon paths a machine calls and Archon authenticates itself (its `proxy.ts`:
3725
- * `PUBLIC_EXACT` and `ORG_CHANGES_PATH_RE`). Each gets its own Access app with
3725
+ * `PUBLIC_ROUTES` and the org route pattern in `src/lib/public-routes.ts`). Each gets its own Access app with
3726
3726
  * one bypass policy, because the hostname app would otherwise ask a machine for
3727
3727
  * an emailed code. `/api/uptime/targets` and `/api/uptime/results` are left out
3728
3728
  * on purpose: they pass the hostname app through the uptime service-token
@@ -3736,6 +3736,7 @@ export const PLATFORM_ARCHON_PUBLIC_PATHS = [
3736
3736
  { name: 'usage-tick', path: '/api/usage/tick' },
3737
3737
  { name: 'reporting-collect', path: '/api/reporting/collect' },
3738
3738
  { name: 'org-changes', path: '/api/orgs/*/changes' },
3739
+ { name: 'org-settings', path: '/api/orgs/*/settings' },
3739
3740
  ];
3740
3741
  const PLATFORM_ARCHON_PUBLIC_POLICY_NAME = 'foundry: public path';
3741
3742
  const archonPublicAppName = (name) => `${PLATFORM_ARCHON_ACCESS_APP_NAME}:public:${name}`;
@@ -63,6 +63,8 @@ export function entryRoutes(org, recorded = {}) {
63
63
  const routes = [];
64
64
  let wildcard;
65
65
  for (const entry of org.hosting.entries) {
66
+ if (entry.workerApp)
67
+ continue; // Served by its Worker route, not the tunnel.
66
68
  // A blue/green entry is reached through its balancer, which each server's connector finds on its own
67
69
  // network, once the balancer exists on the primary. Until then it keeps the origin it had.
68
70
  const target = !entry.blueGreen
@@ -0,0 +1,148 @@
1
+ import type { DeclaredOrg, HostingEntry } from '../org.js';
2
+ import type { AccessPort } from './edge-access.js';
3
+ import type { DnsPort } from './edge-dns.js';
4
+ import type { TunnelPort } from './edge-tunnels.js';
5
+ import type { ZonePort } from './edge-zones.js';
6
+ import type { StepExecutionContext } from './plan-apply.js';
7
+ /**
8
+ * C2 (campaign 2026-10-15): a Worker app with `workerApp.database` reaches the org's Coolify
9
+ * Postgres through a Hyperdrive config. The database host is Docker-internal, so Hyperdrive
10
+ * connects over the org's Cloudflare tunnel (a `tcp://<db container>:5432` rule on an internal
11
+ * hostname) behind an Access service token, the route `plan/remote-database.ts` already uses
12
+ * between servers.
13
+ *
14
+ * Order on apply, inside the `cloudflare:worker-app` step and before the upload, so a failure at
15
+ * any point leaves a state the next apply repairs and the Worker is never deployed against a
16
+ * database it cannot use:
17
+ * Access app and service token -> tunnel rule and DNS -> `<db>_rt` password -> release
18
+ * `migrations.json` (its sha256 checked against `database.migrationsSha256`) applied with the owner credentials -> runtime role (UPDATE, DELETE and
19
+ * TRUNCATE revoked on `appendOnly`) -> Hyperdrive config with the runtime credentials.
20
+ * A failed migration throws, so the Worker is not uploaded.
21
+ */
22
+ /** Everything needed to open a connection to the org's database over the tunnel. */
23
+ export interface HyperdriveOrigin {
24
+ /** The tunnel hostname that fronts the database (Access protected). */
25
+ host: string;
26
+ database: string;
27
+ user: string;
28
+ password: string;
29
+ accessClientId: string;
30
+ accessClientSecret: string;
31
+ }
32
+ /** One migration of a release's `migrations.json`. */
33
+ export interface DatabaseMigration {
34
+ id: string;
35
+ sql: string;
36
+ }
37
+ /**
38
+ * The calls a Worker app database needs beyond Cloudflare's Access, tunnel and DNS. Edge's
39
+ * Hyperdrive SDK (`client.platform.hyperdrive`) and the SQL runner behind the tunnel
40
+ * (`@wtfalch/db`'s migrate and `ensureRuntimeRole`) are the host's to implement; foundry only
41
+ * calls this port. Every method is idempotent.
42
+ */
43
+ export interface WorkerAppDatabasePort {
44
+ /** Creates or updates the Hyperdrive config `name`; returns its id. */
45
+ upsertHyperdrive(input: {
46
+ name: string;
47
+ origin: HyperdriveOrigin;
48
+ }): Promise<{
49
+ id: string;
50
+ }>;
51
+ /** A no-op when the config is gone. */
52
+ deleteHyperdrive(name: string): Promise<void>;
53
+ /** The text of a release asset of a wtfalch repo, or `null` when the release has no such asset. */
54
+ fetchReleaseAsset(url: string): Promise<string | null>;
55
+ /** The migration ids the database's ledger holds, in the order applied. */
56
+ appliedMigrationIds(owner: HyperdriveOrigin): Promise<string[]>;
57
+ /** Runs `migration.sql` and records `migration.id` in the ledger in one transaction. */
58
+ applyMigration(owner: HyperdriveOrigin, migration: DatabaseMigration): Promise<void>;
59
+ /**
60
+ * Creates or updates the `runtime.user` role with `runtime.password`, grants it DML on the
61
+ * tables and revokes UPDATE, DELETE and TRUNCATE from it on each `appendOnly` table.
62
+ */
63
+ ensureRuntimeRole(input: {
64
+ owner: HyperdriveOrigin;
65
+ runtime: {
66
+ user: string;
67
+ password: string;
68
+ };
69
+ appendOnly: string[];
70
+ }): Promise<void>;
71
+ }
72
+ export declare const WORKER_APP_MIGRATION_TIMEOUT_MS = 300000;
73
+ export declare const MIGRATIONS_ASSET_NAME = "migrations.json";
74
+ /** The Hyperdrive config, `{name, id}` as JSON. No secret. */
75
+ export declare const workerAppHyperdriveScopeKey: (entryKey: string) => string;
76
+ /** The migration ids applied, a JSON list, for the plan and the operator. The database's ledger is the fact. */
77
+ export declare const workerAppMigrationsScopeKey: (entryKey: string) => string;
78
+ /** The applied route marker (`{hostname, database}`). */
79
+ export declare const workerAppDbRouteMarkerScopeKey: (entryKey: string) => string;
80
+ /** The Hyperdrive config name of an entry: the Worker's script name plus `-db`. */
81
+ export declare const hyperdriveName: (scriptName: string) => string;
82
+ /** The tunnel hostname of an entry's database route: `<8 hex of org id>-<key>-hd.<zone>`. */
83
+ export declare const hyperdriveHostname: (org: DeclaredOrg, entryKey: string, zone: string) => string;
84
+ /**
85
+ * Reads a release's `migrations.json`. `null` text is an absent file: no migrations. A file that is
86
+ * not a list of `{id, sql}` with unique ids is refused, naming no SQL.
87
+ */
88
+ export declare function parseMigrations(text: string | null): DatabaseMigration[];
89
+ /**
90
+ * A present `migrations.json` runs as the database owner, so its bytes must be the ones the
91
+ * declaration pinned. No file needs no digest; a file with no declared digest, or a different
92
+ * one, is refused before it is parsed. The message names neither the SQL nor the wanted digest.
93
+ */
94
+ export declare function verifyMigrationsDigest(text: string | null, declared: string | undefined): void;
95
+ /** The URL of `migrations.json` beside the module: the same release, another file. */
96
+ export declare const migrationsUrl: (artifactUrl: string) => string;
97
+ /**
98
+ * The migrations the database does not hold, in order. The applied ledger must be the first
99
+ * migrations of the file: a missing or reordered one is refused (migrations are append-only).
100
+ */
101
+ export declare function unappliedDatabaseMigrations(all: readonly DatabaseMigration[], applied: readonly string[]): DatabaseMigration[];
102
+ export interface WorkerAppDatabaseDeps {
103
+ port: WorkerAppDatabasePort;
104
+ tunnels: Pick<TunnelPort, 'putTunnelConfig'>;
105
+ access: Pick<AccessPort, 'listAccessApps' | 'createAccessApp' | 'listAccessPolicies' | 'createAccessPolicy' | 'findServiceTokenByName' | 'createServiceToken' | 'deleteAccessApp' | 'removeServiceToken'>;
106
+ dns: Pick<DnsPort, 'findDnsRecord' | 'createDnsRecord' | 'deleteDnsRecord'>;
107
+ zones: Pick<ZonePort, 'findZoneByApex'>;
108
+ /** The owner connection string of the org's database, as Coolify reports it. Registered for redaction by the host. */
109
+ ownerUrl(databaseId: string): Promise<string>;
110
+ /** Registers a value for redaction before it is written anywhere. */
111
+ secret(value: string): void;
112
+ heldKeys?: {
113
+ resolve(ref: string): Promise<string | null | undefined>;
114
+ put?(ref: string, value: string): Promise<void>;
115
+ };
116
+ /** Test hook: the timeout of a call, in ms. */
117
+ timeoutMs?: number;
118
+ /** Test hook: the timeout of one migration, in ms. */
119
+ migrationTimeoutMs?: number;
120
+ }
121
+ type Call = <T>(label: string, task: () => Promise<T>, ms?: number) => Promise<T>;
122
+ /**
123
+ * Makes the database true and returns the Hyperdrive config id for the `HYPERDRIVE` binding.
124
+ * Secrets (the owner and runtime passwords, the Access service token) go only to the port and the
125
+ * held-key store, never into an error, a recorded id or a return value. Each outside call is
126
+ * bounded by a timeout; the error names the call, never the cause.
127
+ */
128
+ export declare function applyWorkerAppDatabase(deps: WorkerAppDatabaseDeps, org: DeclaredOrg, entry: HostingEntry, scriptName: string, want: {
129
+ artifactUrl: string;
130
+ database: {
131
+ appendOnly: string[];
132
+ migrationsSha256?: string;
133
+ };
134
+ }, context: StepExecutionContext, zoneApex: string): Promise<{
135
+ hyperdriveId: string;
136
+ }>;
137
+ /** Deletes the entry's Hyperdrive config, found by the recorded name, and clears the record. */
138
+ export declare function deleteWorkerAppHyperdrive(port: Pick<WorkerAppDatabasePort, 'deleteHyperdrive'> | undefined, entryKey: string, recorded: Readonly<Record<string, string>>, context: Pick<StepExecutionContext, 'recordScopeId' | 'persistScopeIds'> | null, call: Call): Promise<void>;
139
+ /**
140
+ * Removes the Cloudflare state the database route made, in the order that never leaves a name
141
+ * pointing at nothing: the DNS record, the tunnel rule (the tunnel config is rewritten without
142
+ * it), the Access app, the service token (also one minted and never delivered), then the held
143
+ * token and runtime password and the recorded ids. Each step tolerates "already gone", and the
144
+ * ids are cleared last, so a failed run is repeated by the next one. With `context` null (org
145
+ * destroy) the ids are not cleared: destroy drops them with the org.
146
+ */
147
+ export declare function deleteWorkerAppDatabaseRoute(database: WorkerAppDatabaseDeps | undefined, heldKeys: Pick<NonNullable<WorkerAppDatabaseDeps['heldKeys']>, 'put'> | undefined, orgId: string, entryKey: string, recorded: Readonly<Record<string, string>>, context: Pick<StepExecutionContext, 'recordScopeId' | 'persistScopeIds'> | null, call: Call): Promise<void>;
148
+ export {};