@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.
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/migrations/0057_org_customers.sql +29 -0
- package/dist/org.d.ts +42 -0
- package/dist/org.js +52 -26
- package/dist/plan/boule.d.ts +43 -2
- package/dist/plan/boule.js +48 -1
- package/dist/plan/broker-entries.d.ts +28 -0
- package/dist/plan/broker-entries.js +46 -1
- package/dist/plan/diff.d.ts +14 -0
- package/dist/plan/diff.js +176 -18
- package/dist/plan/executor.d.ts +100 -2
- package/dist/plan/executor.js +273 -16
- package/dist/plan/hostnames.d.ts +7 -0
- package/dist/plan/hostnames.js +17 -2
- package/dist/plan/locked-hostnames.d.ts +11 -0
- package/dist/plan/locked-hostnames.js +13 -0
- package/dist/plan/mcp.d.ts +57 -0
- package/dist/plan/mcp.js +131 -0
- package/dist/plan/org-names.d.ts +2 -2
- package/dist/plan/org-names.js +16 -1
- package/dist/plan/plan-apply.d.ts +26 -0
- package/dist/plan/plan-apply.js +45 -5
- package/dist/plan/platform-providers.d.ts +1 -1
- package/dist/plan/platform-providers.js +2 -1
- package/dist/plan/proxy-labels.js +2 -0
- package/dist/plan/worker-app-database.d.ts +148 -0
- package/dist/plan/worker-app-database.js +424 -0
- package/dist/plan/worker-app.d.ts +320 -0
- package/dist/plan/worker-app.js +792 -0
- package/dist/plan/zitadel-machine-user.d.ts +13 -1
- package/dist/plan/zitadel-machine-user.js +80 -0
- package/dist/requested-changes.d.ts +12 -3
- package/dist/requested-changes.js +22 -0
- package/dist/store/postgres.js +12 -6
- package/dist/store/schema.d.ts +34 -0
- package/dist/store/schema.js +3 -0
- package/dist/worker-app-config.d.ts +151 -0
- package/dist/worker-app-config.js +438 -0
- package/package.json +1 -1
package/dist/plan/mcp.js
ADDED
|
@@ -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
|
+
}
|
package/dist/plan/org-names.d.ts
CHANGED
|
@@ -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;
|
package/dist/plan/org-names.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -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 {
|
|
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
|
-
* `
|
|
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
|
-
* `
|
|
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 {};
|