@wtfalch/foundry 0.60.0 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apps/cloudflare.d.ts +173 -0
- package/dist/apps/cloudflare.js +17 -0
- package/dist/apps/deploy.d.ts +116 -0
- package/dist/apps/deploy.js +791 -0
- package/dist/apps/github.d.ts +47 -0
- package/dist/apps/github.js +201 -0
- package/dist/apps/index.d.ts +9 -0
- package/dist/apps/index.js +9 -0
- package/dist/apps/migrations.d.ts +110 -0
- package/dist/apps/migrations.js +458 -0
- package/dist/audit/connector.d.ts +43 -0
- package/dist/audit/connector.js +22 -0
- package/dist/audit/evidence.d.ts +49 -0
- package/dist/audit/evidence.js +56 -0
- package/dist/audit/index.d.ts +9 -0
- package/dist/audit/index.js +9 -0
- package/dist/audit/migrations.d.ts +5 -0
- package/dist/audit/migrations.js +5 -0
- package/dist/audit/registry.d.ts +49 -0
- package/dist/audit/registry.js +44 -0
- package/dist/audit/seal.d.ts +49 -0
- package/dist/audit/seal.js +131 -0
- package/dist/audit/sealer-role.d.ts +33 -0
- package/dist/audit/sealer-role.js +56 -0
- package/dist/audit/signer.d.ts +40 -0
- package/dist/audit/signer.js +78 -0
- package/dist/audit/tick.d.ts +36 -0
- package/dist/audit/tick.js +87 -0
- package/dist/catalogue/copy-en.d.ts +1 -0
- package/dist/catalogue/copy-en.js +129 -0
- package/dist/catalogue/index.d.ts +9 -0
- package/dist/catalogue/index.js +21 -0
- package/dist/catalogue/integrations.d.ts +4 -0
- package/dist/catalogue/integrations.js +104 -0
- package/dist/catalogue/systems.d.ts +7 -0
- package/dist/catalogue/systems.js +31 -0
- package/dist/catalogue/types.d.ts +110 -0
- package/dist/catalogue/types.js +1 -0
- package/dist/entry-rules.d.ts +53 -0
- package/dist/entry-rules.js +71 -0
- package/dist/hosted-label.d.ts +13 -0
- package/dist/hosted-label.js +81 -0
- package/dist/index.d.ts +14 -5
- package/dist/index.js +7 -3
- package/dist/migrations/0062_hosting_github.sql +6 -0
- package/dist/migrations/0063_boule_apps.sql +7 -0
- package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
- package/dist/migrations/0065_native_build_runs.sql +32 -0
- package/dist/migrations/0066_org_kosmos.sql +10 -0
- package/dist/modules/ai.d.ts +3 -0
- package/dist/modules/ai.js +64 -0
- package/dist/modules/assistants.d.ts +3 -0
- package/dist/modules/assistants.js +72 -0
- package/dist/modules/audit-ledger.d.ts +29 -0
- package/dist/modules/audit-ledger.js +49 -0
- package/dist/modules/audit.d.ts +7 -0
- package/dist/modules/audit.js +44 -0
- package/dist/modules/database.d.ts +23 -0
- package/dist/modules/database.js +189 -0
- package/dist/modules/email.d.ts +3 -0
- package/dist/modules/email.js +18 -0
- package/dist/modules/entry-parts.d.ts +23 -0
- package/dist/modules/entry-parts.js +63 -0
- package/dist/modules/files.d.ts +3 -0
- package/dist/modules/files.js +64 -0
- package/dist/modules/index.d.ts +74 -0
- package/dist/modules/index.js +774 -0
- package/dist/modules/jobs.d.ts +3 -0
- package/dist/modules/jobs.js +23 -0
- package/dist/modules/organisations.d.ts +3 -0
- package/dist/modules/organisations.js +20 -0
- package/dist/modules/outputs.d.ts +9 -0
- package/dist/modules/outputs.js +38 -0
- package/dist/modules/sign-in.d.ts +3 -0
- package/dist/modules/sign-in.js +197 -0
- package/dist/modules/signed-records.d.ts +3 -0
- package/dist/modules/signed-records.js +32 -0
- package/dist/modules/types.d.ts +194 -0
- package/dist/modules/types.js +1 -0
- package/dist/modules/workspaces.d.ts +3 -0
- package/dist/modules/workspaces.js +75 -0
- package/dist/native-build.d.ts +70 -0
- package/dist/native-build.js +92 -0
- package/dist/org.d.ts +184 -24
- package/dist/org.js +444 -49
- package/dist/plan/agora.d.ts +1 -1
- package/dist/plan/agora.js +4 -0
- package/dist/plan/apps-db-route.d.ts +90 -0
- package/dist/plan/apps-db-route.js +168 -0
- package/dist/plan/apps-zones.d.ts +56 -0
- package/dist/plan/apps-zones.js +118 -0
- package/dist/plan/archon-blue-green.js +11 -1
- package/dist/plan/blue-green-apply.d.ts +37 -1
- package/dist/plan/blue-green-apply.js +55 -15
- package/dist/plan/blue-green.d.ts +60 -8
- package/dist/plan/blue-green.js +115 -10
- package/dist/plan/boule.d.ts +23 -1
- package/dist/plan/boule.js +35 -2
- package/dist/plan/broker-entries.d.ts +28 -3
- package/dist/plan/broker-entries.js +74 -3
- package/dist/plan/broker-registrar.d.ts +168 -0
- package/dist/plan/broker-registrar.js +440 -0
- package/dist/plan/broker-workspaces-database.d.ts +48 -0
- package/dist/plan/broker-workspaces-database.js +87 -0
- package/dist/plan/deploy-health.js +9 -2
- package/dist/plan/desktop-callback.js +15 -7
- package/dist/plan/diff.d.ts +8 -2
- package/dist/plan/diff.js +552 -104
- package/dist/plan/edge-access.d.ts +6 -0
- package/dist/plan/edge-access.js +1 -0
- package/dist/plan/edge-dns.d.ts +6 -0
- package/dist/plan/edge-dns.js +9 -7
- package/dist/plan/entry-ai-key.d.ts +1 -1
- package/dist/plan/entry-ai-key.js +2 -2
- package/dist/plan/entry-audit.d.ts +58 -0
- package/dist/plan/entry-audit.js +122 -0
- package/dist/plan/entry-customer-grants.d.ts +75 -0
- package/dist/plan/entry-customer-grants.js +154 -0
- package/dist/plan/entry-edge-key.d.ts +26 -1
- package/dist/plan/entry-edge-key.js +42 -3
- package/dist/plan/entry-jobs.d.ts +64 -0
- package/dist/plan/entry-jobs.js +92 -0
- package/dist/plan/entry-repo-key-apply.d.ts +55 -0
- package/dist/plan/entry-repo-key-apply.js +185 -0
- package/dist/plan/entry-repo-key.d.ts +92 -0
- package/dist/plan/entry-repo-key.js +235 -0
- package/dist/plan/entry-workspaces.d.ts +55 -0
- package/dist/plan/entry-workspaces.js +90 -0
- package/dist/plan/executor.d.ts +231 -19
- package/dist/plan/executor.js +1648 -129
- package/dist/plan/gates.d.ts +1 -1
- package/dist/plan/gates.js +62 -9
- package/dist/plan/github-deploy-keys.d.ts +78 -0
- package/dist/plan/github-deploy-keys.js +305 -0
- package/dist/plan/github-source.d.ts +53 -0
- package/dist/plan/github-source.js +140 -0
- package/dist/plan/held-key-refs.d.ts +12 -0
- package/dist/plan/held-key-refs.js +12 -0
- package/dist/plan/hostnames.d.ts +9 -0
- package/dist/plan/hostnames.js +28 -1
- package/dist/plan/index.d.ts +9 -0
- package/dist/plan/index.js +6 -0
- package/dist/plan/jobs-scheduler-http.js +3 -0
- package/dist/plan/keys-registrar-http.d.ts +32 -0
- package/dist/plan/keys-registrar-http.js +105 -0
- package/dist/plan/keys-registrar.d.ts +67 -0
- package/dist/plan/keys-registrar.js +103 -0
- package/dist/plan/kosmos.d.ts +56 -0
- package/dist/plan/kosmos.js +84 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +2 -2
- package/dist/plan/locked-hostnames.js +7 -4
- package/dist/plan/login-host.js +11 -5
- package/dist/plan/machine-paths.d.ts +36 -0
- package/dist/plan/machine-paths.js +137 -0
- package/dist/plan/mcp.d.ts +1 -1
- package/dist/plan/mcp.js +49 -17
- package/dist/plan/org-apps-admin.d.ts +108 -0
- package/dist/plan/org-apps-admin.js +434 -0
- package/dist/plan/org-names.d.ts +3 -3
- package/dist/plan/org-names.js +15 -2
- package/dist/plan/org-postgres-migrate.js +1 -0
- package/dist/plan/own-diff.d.ts +23 -0
- package/dist/plan/own-diff.js +53 -0
- package/dist/plan/own-hostnames.d.ts +2 -0
- package/dist/plan/own-hostnames.js +1 -0
- package/dist/plan/own-jobs-route.d.ts +47 -0
- package/dist/plan/own-jobs-route.js +91 -0
- package/dist/plan/own-limits.d.ts +6 -0
- package/dist/plan/own-limits.js +1 -0
- package/dist/plan/own-services.d.ts +4 -0
- package/dist/plan/own-services.js +4 -0
- package/dist/plan/plan-apply.d.ts +39 -0
- package/dist/plan/plan-apply.js +163 -9
- package/dist/plan/platform-diff.d.ts +2 -2
- package/dist/plan/platform-diff.js +7 -2
- package/dist/plan/platform-org.d.ts +30 -0
- package/dist/plan/platform-org.js +12 -1
- package/dist/plan/platform-providers.d.ts +2 -0
- package/dist/plan/platform-providers.js +13 -2
- package/dist/plan/post-logout-uris.d.ts +1 -1
- package/dist/plan/post-logout-uris.js +12 -3
- package/dist/plan/private-pages.d.ts +26 -0
- package/dist/plan/private-pages.js +39 -0
- package/dist/plan/providers-readers.js +20 -1
- package/dist/plan/proxy-labels.d.ts +12 -1
- package/dist/plan/proxy-labels.js +32 -2
- package/dist/plan/release-host.d.ts +6 -2
- package/dist/plan/release-host.js +36 -8
- package/dist/plan/stage-env.d.ts +19 -1
- package/dist/plan/stage-env.js +25 -1
- package/dist/plan/stage-guards.d.ts +20 -0
- package/dist/plan/stage-guards.js +24 -0
- package/dist/plan/stage-isolation.d.ts +24 -0
- package/dist/plan/stage-isolation.js +29 -1
- package/dist/plan/worker-app-database.d.ts +46 -42
- package/dist/plan/worker-app-database.js +150 -153
- package/dist/plan/worker-app.d.ts +5 -65
- package/dist/plan/worker-app.js +28 -105
- package/dist/plan/worker.d.ts +2 -2
- package/dist/plan/worker.js +2 -1
- package/dist/plan/workspace-client.d.ts +25 -0
- package/dist/plan/workspace-client.js +48 -0
- package/dist/plan/workspace-sign-in.d.ts +55 -0
- package/dist/plan/workspace-sign-in.js +273 -0
- package/dist/plan/workspace-worker.d.ts +4 -0
- package/dist/plan/workspace-worker.js +13 -0
- package/dist/plan/workspace.d.ts +101 -0
- package/dist/plan/workspace.js +305 -0
- package/dist/release/decide.d.ts +30 -1
- package/dist/release/decide.js +73 -11
- package/dist/release/health.d.ts +8 -3
- package/dist/release/health.js +58 -5
- package/dist/release/migration-compatibility.d.ts +66 -0
- package/dist/release/migration-compatibility.js +340 -0
- package/dist/release/run.d.ts +2 -5
- package/dist/release/run.js +4 -0
- package/dist/release/state.d.ts +3 -1
- package/dist/release/state.js +6 -0
- package/dist/release/types.d.ts +20 -0
- package/dist/requested-changes.d.ts +11 -2
- package/dist/requested-changes.js +13 -1
- package/dist/service-labels.d.ts +4 -0
- package/dist/service-labels.js +11 -0
- package/dist/slug.d.ts +2 -0
- package/dist/slug.js +20 -7
- package/dist/store/postgres.js +25 -2
- package/dist/store/schema.d.ts +73 -1
- package/dist/store/schema.js +10 -0
- package/dist/worker-app-config.d.ts +1 -11
- package/dist/worker-app-config.js +4 -17
- package/dist/worker-app-migrations.d.ts +105 -0
- package/dist/worker-app-migrations.js +166 -0
- package/dist/worker-app-upload.d.ts +103 -0
- package/dist/worker-app-upload.js +128 -0
- package/package.json +19 -2
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { DeclaredOrg } from '../org.js';
|
|
2
|
+
/**
|
|
3
|
+
* foundry#949: what both the planner (`diff.ts`) and the executor
|
|
4
|
+
* (`executor.ts`) need to agree on for an org's kosmos -- the hostname, the
|
|
5
|
+
* recorded ids and the env vars those produce -- so the plan can compare the
|
|
6
|
+
* live app with exactly what the executor would write. The same split
|
|
7
|
+
* `boule.ts` and `agora.ts` already follow, trimmed to what kosmos needs for
|
|
8
|
+
* its first release: a ZITADEL client, a database and sign-in env. Kosmos has
|
|
9
|
+
* no mail, no owner seeding and no Archon credential -- it is not Boule.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* The fixed `kosmos` ZITADEL web client's `clientId`, recorded by
|
|
13
|
+
* `executor.ts`'s `zitadelApp` and read by `coolifyKosmos` for
|
|
14
|
+
* `AUTH_CLIENT_ID`. Public, not a secret, so a flat scope id like
|
|
15
|
+
* `boule_client_id`.
|
|
16
|
+
*/
|
|
17
|
+
export declare const KOSMOS_CLIENT_ID_SCOPE_KEY = "kosmos_client_id";
|
|
18
|
+
/**
|
|
19
|
+
* The kosmos database's Coolify uuid. It has no `system:kind:key` slot of its
|
|
20
|
+
* own (the step's one auto-recorded `resourceId` is the app), so it is a flat
|
|
21
|
+
* scope id, like the Boule and cms databases.
|
|
22
|
+
*/
|
|
23
|
+
export declare const KOSMOS_DATABASE_SCOPE_KEY = "coolify_kosmos_database";
|
|
24
|
+
/**
|
|
25
|
+
* Recorded once `coolifyKosmos` has actually called `deploy` on this org's
|
|
26
|
+
* kosmos app -- the same marker {@link BOULE_DEPLOYED_SCOPE_KEY} (`boule.ts`)
|
|
27
|
+
* and {@link AGORA_DEPLOYED_SCOPE_KEY} (`agora.ts`) are, and for the same
|
|
28
|
+
* reason: Coolify defaults `instant_deploy` to false, so a freshly created
|
|
29
|
+
* app sat built but never started.
|
|
30
|
+
*/
|
|
31
|
+
export declare const KOSMOS_DEPLOYED_SCOPE_KEY = "coolify_kosmos_deployed";
|
|
32
|
+
/**
|
|
33
|
+
* `kosmos.<domain>` when the org has a domain, `<slug>-kosmos.oikos.host`
|
|
34
|
+
* otherwise -- the same tier rule `bouleHostnameFor`/`agoraHostnameFor`
|
|
35
|
+
* follow; `../slug.ts`'s `KOSMOS_SUFFIX` reserves the collision.
|
|
36
|
+
*/
|
|
37
|
+
export declare function kosmosHostnameFor(org: DeclaredOrg): string;
|
|
38
|
+
/** The env names `desiredKosmosEnv` can write, so the live reader knows which to read back. */
|
|
39
|
+
export declare const KOSMOS_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID"];
|
|
40
|
+
/**
|
|
41
|
+
* The env vars an org's kosmos app should carry, and every input still missing
|
|
42
|
+
* to build them. Callers that write must refuse on a non-empty `missing`: an
|
|
43
|
+
* kosmos with a blank client id fails every sign-in. `issuer` is the host's
|
|
44
|
+
* issuer address (`AgoraHostValues.issuer`), the same one Agora and Boule sign
|
|
45
|
+
* in at.
|
|
46
|
+
*
|
|
47
|
+
* Not here, and written by the executor instead: the database URLs
|
|
48
|
+
* (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
|
|
49
|
+
* (generated once, never rotated) -- the same split `desiredBouleEnv` keeps.
|
|
50
|
+
*/
|
|
51
|
+
export declare function desiredKosmosEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, issuer: string | null): {
|
|
52
|
+
env: Record<string, string>;
|
|
53
|
+
missing: string[];
|
|
54
|
+
};
|
|
55
|
+
/** The desired env keys the live app lacks or holds a different value for. */
|
|
56
|
+
export declare function kosmosEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
|
|
2
|
+
/**
|
|
3
|
+
* foundry#949: what both the planner (`diff.ts`) and the executor
|
|
4
|
+
* (`executor.ts`) need to agree on for an org's kosmos -- the hostname, the
|
|
5
|
+
* recorded ids and the env vars those produce -- so the plan can compare the
|
|
6
|
+
* live app with exactly what the executor would write. The same split
|
|
7
|
+
* `boule.ts` and `agora.ts` already follow, trimmed to what kosmos needs for
|
|
8
|
+
* its first release: a ZITADEL client, a database and sign-in env. Kosmos has
|
|
9
|
+
* no mail, no owner seeding and no Archon credential -- it is not Boule.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* The fixed `kosmos` ZITADEL web client's `clientId`, recorded by
|
|
13
|
+
* `executor.ts`'s `zitadelApp` and read by `coolifyKosmos` for
|
|
14
|
+
* `AUTH_CLIENT_ID`. Public, not a secret, so a flat scope id like
|
|
15
|
+
* `boule_client_id`.
|
|
16
|
+
*/
|
|
17
|
+
export const KOSMOS_CLIENT_ID_SCOPE_KEY = 'kosmos_client_id';
|
|
18
|
+
/**
|
|
19
|
+
* The kosmos database's Coolify uuid. It has no `system:kind:key` slot of its
|
|
20
|
+
* own (the step's one auto-recorded `resourceId` is the app), so it is a flat
|
|
21
|
+
* scope id, like the Boule and cms databases.
|
|
22
|
+
*/
|
|
23
|
+
export const KOSMOS_DATABASE_SCOPE_KEY = 'coolify_kosmos_database';
|
|
24
|
+
/**
|
|
25
|
+
* Recorded once `coolifyKosmos` has actually called `deploy` on this org's
|
|
26
|
+
* kosmos app -- the same marker {@link BOULE_DEPLOYED_SCOPE_KEY} (`boule.ts`)
|
|
27
|
+
* and {@link AGORA_DEPLOYED_SCOPE_KEY} (`agora.ts`) are, and for the same
|
|
28
|
+
* reason: Coolify defaults `instant_deploy` to false, so a freshly created
|
|
29
|
+
* app sat built but never started.
|
|
30
|
+
*/
|
|
31
|
+
export const KOSMOS_DEPLOYED_SCOPE_KEY = 'coolify_kosmos_deployed';
|
|
32
|
+
/**
|
|
33
|
+
* `kosmos.<domain>` when the org has a domain, `<slug>-kosmos.oikos.host`
|
|
34
|
+
* otherwise -- the same tier rule `bouleHostnameFor`/`agoraHostnameFor`
|
|
35
|
+
* follow; `../slug.ts`'s `KOSMOS_SUFFIX` reserves the collision.
|
|
36
|
+
*/
|
|
37
|
+
export function kosmosHostnameFor(org) {
|
|
38
|
+
return org.domain ? `kosmos.${org.domain}` : `${org.slug}-kosmos.oikos.host`;
|
|
39
|
+
}
|
|
40
|
+
/** The env names `desiredKosmosEnv` can write, so the live reader knows which to read back. */
|
|
41
|
+
export const KOSMOS_MANAGED_ENV_KEYS = [
|
|
42
|
+
'APP_URL',
|
|
43
|
+
'AUTH_ISSUER',
|
|
44
|
+
'AUTH_ORGANIZATION_ID',
|
|
45
|
+
'AUTH_CLIENT_ID',
|
|
46
|
+
];
|
|
47
|
+
/**
|
|
48
|
+
* The env vars an org's kosmos app should carry, and every input still missing
|
|
49
|
+
* to build them. Callers that write must refuse on a non-empty `missing`: an
|
|
50
|
+
* kosmos with a blank client id fails every sign-in. `issuer` is the host's
|
|
51
|
+
* issuer address (`AgoraHostValues.issuer`), the same one Agora and Boule sign
|
|
52
|
+
* in at.
|
|
53
|
+
*
|
|
54
|
+
* Not here, and written by the executor instead: the database URLs
|
|
55
|
+
* (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
|
|
56
|
+
* (generated once, never rotated) -- the same split `desiredBouleEnv` keeps.
|
|
57
|
+
*/
|
|
58
|
+
export function desiredKosmosEnv(org, recorded, issuer) {
|
|
59
|
+
const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
60
|
+
const clientId = recorded[KOSMOS_CLIENT_ID_SCOPE_KEY];
|
|
61
|
+
const missing = [];
|
|
62
|
+
if (!issuer)
|
|
63
|
+
missing.push('issuer');
|
|
64
|
+
if (!zitadelOrgId)
|
|
65
|
+
missing.push('zitadelOrgId');
|
|
66
|
+
if (!clientId)
|
|
67
|
+
missing.push('clientId');
|
|
68
|
+
const env = {
|
|
69
|
+
APP_URL: `https://${kosmosHostnameFor(org)}`,
|
|
70
|
+
};
|
|
71
|
+
if (issuer)
|
|
72
|
+
env.AUTH_ISSUER = issuer;
|
|
73
|
+
if (zitadelOrgId)
|
|
74
|
+
env.AUTH_ORGANIZATION_ID = zitadelOrgId;
|
|
75
|
+
if (clientId)
|
|
76
|
+
env.AUTH_CLIENT_ID = clientId;
|
|
77
|
+
return { env, missing };
|
|
78
|
+
}
|
|
79
|
+
/** The desired env keys the live app lacks or holds a different value for. */
|
|
80
|
+
export function kosmosEnvDrift(desired, live) {
|
|
81
|
+
return Object.entries(desired)
|
|
82
|
+
.filter(([key, value]) => live[key] !== value)
|
|
83
|
+
.map(([key]) => key);
|
|
84
|
+
}
|
package/dist/plan/live.d.ts
CHANGED
|
@@ -42,7 +42,7 @@ export interface LiveResource {
|
|
|
42
42
|
* see that function's own doc comment for why "always planned" broke
|
|
43
43
|
* `bootstrapPlatform`.
|
|
44
44
|
*/
|
|
45
|
-
system: 'zitadel' | 'coolify' | 'cloudflare' | 'files' | 'ai' | 'cms' | 'jobs' | 'sfu' | 'edge' | 'heartbeats' | 'github';
|
|
45
|
+
system: 'zitadel' | 'coolify' | 'cloudflare' | 'files' | 'ai' | 'cms' | 'jobs' | 'sfu' | 'edge' | 'heartbeats' | 'github' | 'audit';
|
|
46
46
|
kind: string;
|
|
47
47
|
key: string;
|
|
48
48
|
config: Record<string, unknown>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type DeclaredOrg } from '../org.js';
|
|
2
|
-
|
|
3
|
-
export
|
|
2
|
+
import { OWN_SERVICE_LABELS } from '../service-labels.js';
|
|
3
|
+
export { OWN_SERVICE_LABELS };
|
|
4
4
|
/**
|
|
5
5
|
* The names under the org's domain that foundry owns, so no hosting entry may
|
|
6
6
|
* take one: Agora, Boule, the cms studio when the site is on, calls when it uses the
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { ownsServices } from '../org.js';
|
|
1
|
+
import { kosmosOn, ownsServices } from '../org.js';
|
|
2
|
+
import { OWN_SERVICE_LABELS } from '../service-labels.js';
|
|
2
3
|
import { agoraHostnameFor } from './agora.js';
|
|
3
4
|
import { bouleHostnameFor } from './boule.js';
|
|
4
5
|
import { cmsStudioHostnameFor, siteHostnameFor } from './hostnames.js';
|
|
6
|
+
import { kosmosHostnameFor } from './kosmos.js';
|
|
5
7
|
import { loginHostnameFor } from './login-host.js';
|
|
6
8
|
import { ownHostnames } from './own-hostnames.js';
|
|
7
|
-
|
|
8
|
-
export const OWN_SERVICE_LABELS = ['auth', 'mail', 'files', 'sfu'];
|
|
9
|
+
export { OWN_SERVICE_LABELS };
|
|
9
10
|
/**
|
|
10
11
|
* The names under the org's domain that foundry owns, so no hosting entry may
|
|
11
12
|
* take one: Agora, Boule, the cms studio when the site is on, calls when it uses the
|
|
@@ -31,6 +32,8 @@ export function lockedHostnames(org, platformHostnames) {
|
|
|
31
32
|
}
|
|
32
33
|
locked.add(agoraHostnameFor(org));
|
|
33
34
|
locked.add(bouleHostnameFor(org));
|
|
35
|
+
if (kosmosOn(org))
|
|
36
|
+
locked.add(kosmosHostnameFor(org));
|
|
34
37
|
if (org.site.enabled)
|
|
35
38
|
locked.add(cmsStudioHostnameFor(org));
|
|
36
39
|
if (org.calls.enabled && org.calls.usesDomain)
|
|
@@ -58,6 +61,6 @@ export function hostingNames(org, platformHostnames) {
|
|
|
58
61
|
locked: lockedHostnames(org, platformHostnames),
|
|
59
62
|
slug: org.slug,
|
|
60
63
|
siteOwnsApex: org.site.enabled && org.site.usesDomain && org.domain !== null,
|
|
61
|
-
builtinBlueGreen: org.boule?.blueGreen !== undefined || org.agora?.blueGreen !== undefined,
|
|
64
|
+
builtinBlueGreen: org.boule?.blueGreen !== undefined || org.agora?.blueGreen !== undefined || kosmosOn(org),
|
|
62
65
|
};
|
|
63
66
|
}
|
package/dist/plan/login-host.js
CHANGED
|
@@ -2,6 +2,7 @@ import { ownsServices } from '../org.js';
|
|
|
2
2
|
import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, agoraHostnameFor, } from './agora.js';
|
|
3
3
|
import { BOULE_CLIENT_ID_SCOPE_KEY, bouleHostnameFor } from './boule.js';
|
|
4
4
|
import { SIGN_IN_IN_APP_SCOPE_KEY, inAppSignIn, inAppStatePairs, } from './sign-in.js';
|
|
5
|
+
import { testCustomersEntries, testCustomersOrgKey, workspaceClientKey, } from './workspace-client.js';
|
|
5
6
|
/**
|
|
6
7
|
* #267: what both the planner (`diff.ts`) and the executor (`executor.ts`)
|
|
7
8
|
* need to agree on for an org's own company login host -- the hostname and
|
|
@@ -168,7 +169,11 @@ export const LOGIN_BINDING_SCOPE_KEY = 'login_host_binding';
|
|
|
168
169
|
* is no longer declared is not here, so it stays bound as before.
|
|
169
170
|
*/
|
|
170
171
|
export function customersEntryKeys(org) {
|
|
171
|
-
return new Set(
|
|
172
|
+
return new Set([
|
|
173
|
+
...org.hosting.entries.filter((e) => e.uses?.auth?.users === 'customers').map((e) => e.key),
|
|
174
|
+
// A workspace's customers client signs in the test customers organisation, on the shared host as well.
|
|
175
|
+
...testCustomersEntries(org).map((e) => workspaceClientKey(e.key)),
|
|
176
|
+
]);
|
|
172
177
|
}
|
|
173
178
|
/**
|
|
174
179
|
* The org's recorded app client ids (hosted app, each entry's own bar a customers entry, cms, agora, boule),
|
|
@@ -201,13 +206,14 @@ export const LOGIN_CLIENT_ORGS_SCOPE_KEY = 'login_client_orgs';
|
|
|
201
206
|
*/
|
|
202
207
|
export function customersClientOrganizations(org, recorded) {
|
|
203
208
|
const customersOrgId = recorded[`zitadel:customers:${org.id}`];
|
|
204
|
-
|
|
205
|
-
return {};
|
|
209
|
+
const testOrgId = recorded[testCustomersOrgKey(org.id)];
|
|
206
210
|
const map = {};
|
|
211
|
+
const workspaceKeys = new Set(testCustomersEntries(org).map((e) => workspaceClientKey(e.key)));
|
|
207
212
|
for (const key of customersEntryKeys(org)) {
|
|
208
213
|
const clientId = recorded[ENTRY_CLIENT_ID_SCOPE_PREFIX + key];
|
|
209
|
-
|
|
210
|
-
|
|
214
|
+
const orgId = workspaceKeys.has(key) ? testOrgId : customersOrgId;
|
|
215
|
+
if (clientId && orgId)
|
|
216
|
+
map[clientId] = orgId;
|
|
211
217
|
}
|
|
212
218
|
return map;
|
|
213
219
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type DeclaredOrg, type HostingEntry } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
/**
|
|
4
|
+
* F11: the paths of a gated entry that a machine calls, not a person: the ai service posts to
|
|
5
|
+
* `uses.ai.webhookPath`, Boule calls `/api/customer-grants`. A gate (`gates.ts`) stops a machine, so each of
|
|
6
|
+
* these paths on each gated host gets its own path-scoped Access application (`<host><path>`) with a bypass
|
|
7
|
+
* policy, the opposite of a private page's path gate. Cloudflare applies the most specific application, so
|
|
8
|
+
* the bypass wins over the host's whole-host gate. The app itself checks the machine's credential.
|
|
9
|
+
*/
|
|
10
|
+
/** The path Boule's customer-grants routes live under (`uses.customerGrants`, F13). */
|
|
11
|
+
export declare const CUSTOMER_GRANTS_PATH = "/api/customer-grants";
|
|
12
|
+
/** The paths of `entry` that machines call, in a fixed order, without repeats. */
|
|
13
|
+
export declare function entryMachinePaths(entry: Pick<HostingEntry, 'uses'>): string[];
|
|
14
|
+
/** The step key stem of one machine bypass: a hash of host and path, so it fits a key and needs no escaping. */
|
|
15
|
+
export declare function machineBypassKey(host: string, path: string): string;
|
|
16
|
+
/** The recorded `host/path` a machine bypass was made for, by its step key stem, read for the delete's wording. */
|
|
17
|
+
export declare const machineBypassScopeKey: (stem: string) => string;
|
|
18
|
+
export interface MachineBypass {
|
|
19
|
+
entry: string;
|
|
20
|
+
host: string;
|
|
21
|
+
path: string;
|
|
22
|
+
}
|
|
23
|
+
/** Every machine bypass wanted, by step key stem, and a line for each case where an entry's machine path gets none. */
|
|
24
|
+
export declare function declaredMachineBypasses(org: DeclaredOrg, declared?: DeclaredOrg): {
|
|
25
|
+
bypasses: Map<string, MachineBypass>;
|
|
26
|
+
notes: string[];
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* One step per wanted machine bypass with no recorded Access application, and one deleting step per recorded
|
|
30
|
+
* bypass no longer wanted (a removed path, a removed gate, a removed entry). A delete is not `destroys`: it
|
|
31
|
+
* loses no data and only closes a path again, so it needs no typed confirmation.
|
|
32
|
+
*/
|
|
33
|
+
export declare function planMachineBypasses(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, declared?: DeclaredOrg): {
|
|
34
|
+
steps: DraftStep[];
|
|
35
|
+
notes: string[];
|
|
36
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isLiveEntry } from '../org.js';
|
|
3
|
+
import { resolveGateHostnames } from './gates.js';
|
|
4
|
+
import { entryHostnames, workspaceHost } from './hostnames.js';
|
|
5
|
+
import { appStepKey } from './platform-diff.js';
|
|
6
|
+
import { workspaceEntries } from './workspace.js';
|
|
7
|
+
/**
|
|
8
|
+
* F11: the paths of a gated entry that a machine calls, not a person: the ai service posts to
|
|
9
|
+
* `uses.ai.webhookPath`, Boule calls `/api/customer-grants`. A gate (`gates.ts`) stops a machine, so each of
|
|
10
|
+
* these paths on each gated host gets its own path-scoped Access application (`<host><path>`) with a bypass
|
|
11
|
+
* policy, the opposite of a private page's path gate. Cloudflare applies the most specific application, so
|
|
12
|
+
* the bypass wins over the host's whole-host gate. The app itself checks the machine's credential.
|
|
13
|
+
*/
|
|
14
|
+
/** The path Boule's customer-grants routes live under (`uses.customerGrants`, F13). */
|
|
15
|
+
export const CUSTOMER_GRANTS_PATH = '/api/customer-grants';
|
|
16
|
+
/** The paths of `entry` that machines call, in a fixed order, without repeats. */
|
|
17
|
+
export function entryMachinePaths(entry) {
|
|
18
|
+
const paths = [];
|
|
19
|
+
const webhook = entry.uses?.ai?.webhookPath;
|
|
20
|
+
if (webhook !== undefined)
|
|
21
|
+
paths.push(webhook);
|
|
22
|
+
if (entry.uses?.customerGrants)
|
|
23
|
+
paths.push(CUSTOMER_GRANTS_PATH);
|
|
24
|
+
return [...new Set(paths)];
|
|
25
|
+
}
|
|
26
|
+
/** The step key stem of one machine bypass: a hash of host and path, so it fits a key and needs no escaping. */
|
|
27
|
+
export function machineBypassKey(host, path) {
|
|
28
|
+
return `mp-${createHash('sha256').update(`${host}\0${path}`).digest('hex').slice(0, 16)}`;
|
|
29
|
+
}
|
|
30
|
+
/** What edge accepts as the path of a destination: segments of `a-z 0-9 . _ ~ -`, none empty, `.` or `..`, at most 200 characters. */
|
|
31
|
+
const EDGE_PATH = /^(\/[a-z0-9._~-]+)+$/;
|
|
32
|
+
const edgeAccepts = (path) => path.length <= 200 &&
|
|
33
|
+
EDGE_PATH.test(path) &&
|
|
34
|
+
!path.split('/').some((s) => s === '.' || s === '..');
|
|
35
|
+
/** The recorded `host/path` a machine bypass was made for, by its step key stem, read for the delete's wording. */
|
|
36
|
+
export const machineBypassScopeKey = (stem) => `machine_bypass_${stem}`;
|
|
37
|
+
/**
|
|
38
|
+
* The gated hosts an entry is served on: the exact names of its own gates, the entry's live names under a
|
|
39
|
+
* gated wildcard (`*.<domain>` cannot carry a path), and the entry's workspace host when `declared` gives it one.
|
|
40
|
+
*/
|
|
41
|
+
function gatedHosts(org, declared, entry) {
|
|
42
|
+
const hosts = new Set();
|
|
43
|
+
const live = org.hosting.entries.find((e) => e.key === entry.key);
|
|
44
|
+
if (live && org.hosting.enabled && org.domain) {
|
|
45
|
+
const names = resolveGateHostnames(org.domain, live.gates.flatMap((gate) => gate.hostnames));
|
|
46
|
+
for (const name of names) {
|
|
47
|
+
if (!name.startsWith('*.'))
|
|
48
|
+
hosts.add(name);
|
|
49
|
+
}
|
|
50
|
+
const wildcards = names.filter((name) => name.startsWith('*.')).map((name) => name.slice(1));
|
|
51
|
+
for (const host of entryHostnames(org, live)) {
|
|
52
|
+
if (wildcards.some((suffix) => host.endsWith(suffix)))
|
|
53
|
+
hosts.add(host);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (workspaceEntries(declared).some((e) => e.key === entry.key)) {
|
|
57
|
+
hosts.add(workspaceHost(declared.slug, entry.key));
|
|
58
|
+
}
|
|
59
|
+
return [...hosts].sort();
|
|
60
|
+
}
|
|
61
|
+
/** Every machine bypass wanted, by step key stem, and a line for each case where an entry's machine path gets none. */
|
|
62
|
+
export function declaredMachineBypasses(org, declared = org) {
|
|
63
|
+
const bypasses = new Map();
|
|
64
|
+
const notes = [];
|
|
65
|
+
if (!declared.hosting.enabled)
|
|
66
|
+
return { bypasses, notes };
|
|
67
|
+
for (const entry of declared.hosting.entries) {
|
|
68
|
+
const paths = entryMachinePaths(entry);
|
|
69
|
+
if (paths.length === 0)
|
|
70
|
+
continue;
|
|
71
|
+
const hosts = gatedHosts(org, declared, entry);
|
|
72
|
+
if (hosts.length === 0) {
|
|
73
|
+
notes.push(`${org.slug}'s ${entry.key} has machine paths (${paths.join(', ')}) but no gated host${isLiveEntry(entry) ? '' : ' and no workspace'}, so no Access bypass is planned for them`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
for (const path of paths) {
|
|
77
|
+
if (!edgeAccepts(path)) {
|
|
78
|
+
notes.push(`${org.slug}'s ${entry.key} machine path ${path} gets no Access bypass: edge accepts only lower-case a-z, 0-9, '.', '_', '~' and '-' in path segments`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
for (const host of hosts) {
|
|
82
|
+
bypasses.set(machineBypassKey(host, path), { entry: entry.key, host, path });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { bypasses, notes };
|
|
87
|
+
}
|
|
88
|
+
const accessRecordKey = (org, stem) => `cloudflare:access:${org.id}:${stem}`;
|
|
89
|
+
/**
|
|
90
|
+
* One step per wanted machine bypass with no recorded Access application, and one deleting step per recorded
|
|
91
|
+
* bypass no longer wanted (a removed path, a removed gate, a removed entry). A delete is not `destroys`: it
|
|
92
|
+
* loses no data and only closes a path again, so it needs no typed confirmation.
|
|
93
|
+
*/
|
|
94
|
+
export function planMachineBypasses(org, recordedIds, declared = org) {
|
|
95
|
+
const { bypasses, notes } = declaredMachineBypasses(org, declared);
|
|
96
|
+
const steps = [];
|
|
97
|
+
const prefix = accessRecordKey(org, 'mp-');
|
|
98
|
+
for (const [name, id] of Object.entries(recordedIds)) {
|
|
99
|
+
if (!id || !name.startsWith(prefix))
|
|
100
|
+
continue;
|
|
101
|
+
const stem = name.slice(accessRecordKey(org, '').length);
|
|
102
|
+
if (bypasses.has(stem))
|
|
103
|
+
continue;
|
|
104
|
+
const was = recordedIds[machineBypassScopeKey(stem)];
|
|
105
|
+
steps.push({
|
|
106
|
+
action: {
|
|
107
|
+
system: 'cloudflare',
|
|
108
|
+
kind: 'access',
|
|
109
|
+
key: appStepKey(org, stem),
|
|
110
|
+
desired: { machineBypass: 'delete' },
|
|
111
|
+
},
|
|
112
|
+
step: {
|
|
113
|
+
description: `remove the Access bypass ${was ? `on ${was} ` : ''}(${org.slug}'s ${stem}): the machine path is no longer declared`,
|
|
114
|
+
destroys: false,
|
|
115
|
+
migrates: false,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
for (const [stem, { entry, host, path }] of [...bypasses].sort(([a], [b]) => a.localeCompare(b))) {
|
|
120
|
+
if (recordedIds[accessRecordKey(org, stem)])
|
|
121
|
+
continue;
|
|
122
|
+
steps.push({
|
|
123
|
+
action: {
|
|
124
|
+
system: 'cloudflare',
|
|
125
|
+
kind: 'access',
|
|
126
|
+
key: appStepKey(org, stem),
|
|
127
|
+
desired: { machineBypass: 'apply', entry, domain: `${host}${path}` },
|
|
128
|
+
},
|
|
129
|
+
step: {
|
|
130
|
+
description: `let machine callers through Cloudflare Access on ${host}${path} (${org.slug}'s ${entry}): a bypass for the path only`,
|
|
131
|
+
destroys: false,
|
|
132
|
+
migrates: false,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return { steps, notes };
|
|
137
|
+
}
|
package/dist/plan/mcp.d.ts
CHANGED
|
@@ -30,7 +30,7 @@ type Blocked = Array<{
|
|
|
30
30
|
export declare const MCP_CALL_TIMEOUT_MS = 60000;
|
|
31
31
|
/** The want of the access step, recorded when it is done. */
|
|
32
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): {
|
|
33
|
+
export declare function planMcp(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], zitadelMcp: boolean, declaredOrg?: DeclaredOrg): {
|
|
34
34
|
steps: DraftStep[];
|
|
35
35
|
blocked: Blocked;
|
|
36
36
|
};
|
package/dist/plan/mcp.js
CHANGED
|
@@ -1,23 +1,44 @@
|
|
|
1
1
|
import { ACCESS_TOKEN_LIFETIME_APPLIED_SCOPE_KEY, MCP_ACCESS_TOKEN_LIFETIME_SECONDS, entryMcpAccessAppliedScopeKey, entryMcpProjectScopeKey, mcpCallbackUri, } from './broker-entries.js';
|
|
2
2
|
import { withCallTimeout } from './worker-app.js';
|
|
3
|
+
import { testCustomersOrgKey, workspaceAuthEntries, workspaceAuthEntryOf, workspaceClientKey, } from './workspace-client.js';
|
|
3
4
|
/** A ZITADEL call of the MCP steps that has not answered in this long fails the step; the next apply repeats it. */
|
|
4
5
|
export const MCP_CALL_TIMEOUT_MS = 60_000;
|
|
5
6
|
/** The want of the access step, recorded when it is done. */
|
|
6
7
|
export const mcpAccessWant = (org) => JSON.stringify({ accessTokenType: 'jwt', redirectUri: mcpCallbackUri(org) });
|
|
7
|
-
export function planMcp(org, recorded, plannedSteps, zitadelMcp) {
|
|
8
|
+
export function planMcp(org, recorded, plannedSteps, zitadelMcp, declaredOrg = org) {
|
|
8
9
|
const steps = [];
|
|
9
10
|
const blocked = [];
|
|
10
|
-
const
|
|
11
|
+
const live = org.hosting.enabled
|
|
11
12
|
? org.hosting.entries.filter((e) => Boolean(e.uses?.auth?.mcp))
|
|
12
13
|
: [];
|
|
13
|
-
|
|
14
|
+
const workspaces = workspaceAuthEntries(declaredOrg).filter((e) => e.uses?.auth?.mcp);
|
|
15
|
+
if (live.length === 0 && workspaces.length === 0)
|
|
14
16
|
return { steps, blocked };
|
|
15
17
|
const planned = (system, kind, key) => plannedSteps.some((p) => p.action.system === system &&
|
|
16
18
|
p.action.kind === kind &&
|
|
17
19
|
(key === undefined || p.action.key === key));
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
const targets = [
|
|
21
|
+
...live.map((entry) => ({
|
|
22
|
+
clientKey: entry.key,
|
|
23
|
+
at: `${org.slug}'s ${entry.key} MCP`,
|
|
24
|
+
customersOrg: {
|
|
25
|
+
recorded: `zitadel:customers:${org.id}`,
|
|
26
|
+
kind: 'customers',
|
|
27
|
+
what: 'customers',
|
|
28
|
+
},
|
|
29
|
+
})),
|
|
30
|
+
...workspaces.map((entry) => ({
|
|
31
|
+
clientKey: workspaceClientKey(entry.key),
|
|
32
|
+
at: `${org.slug}'s ${entry.key} workspace MCP`,
|
|
33
|
+
customersOrg: {
|
|
34
|
+
recorded: testCustomersOrgKey(org.id),
|
|
35
|
+
kind: 'customers-test',
|
|
36
|
+
what: 'test customers',
|
|
37
|
+
},
|
|
38
|
+
})),
|
|
39
|
+
];
|
|
40
|
+
for (const { clientKey, at, customersOrg } of targets) {
|
|
41
|
+
const stepKey = `${org.id}:${clientKey}`;
|
|
21
42
|
if (!zitadelMcp) {
|
|
22
43
|
blocked.push({
|
|
23
44
|
description: `${at} is blocked, not planned: the host's ZITADEL client cannot set JWT access tokens or the access-token lifetime`,
|
|
@@ -25,10 +46,10 @@ export function planMcp(org, recorded, plannedSteps, zitadelMcp) {
|
|
|
25
46
|
});
|
|
26
47
|
continue;
|
|
27
48
|
}
|
|
28
|
-
if (!recorded[entryMcpProjectScopeKey(
|
|
29
|
-
if (!recorded[
|
|
49
|
+
if (!recorded[entryMcpProjectScopeKey(clientKey)]) {
|
|
50
|
+
if (!recorded[customersOrg.recorded] && !planned('zitadel', customersOrg.kind)) {
|
|
30
51
|
blocked.push({
|
|
31
|
-
description: `${at} project is blocked, not planned: the
|
|
52
|
+
description: `${at} project is blocked, not planned: the ${customersOrg.what} organisation id is not recorded yet`,
|
|
32
53
|
key: `zitadel:mcp-project:${stepKey}`,
|
|
33
54
|
});
|
|
34
55
|
}
|
|
@@ -36,15 +57,15 @@ export function planMcp(org, recorded, plannedSteps, zitadelMcp) {
|
|
|
36
57
|
steps.push({
|
|
37
58
|
action: { system: 'zitadel', kind: 'mcp-project', key: stepKey, desired: {} },
|
|
38
59
|
step: {
|
|
39
|
-
description: `make the empty project that is ${org.slug}'s ${
|
|
60
|
+
description: `make the empty project that is ${org.slug}'s ${clientKey} MCP token audience`,
|
|
40
61
|
destroys: false,
|
|
41
62
|
migrates: false,
|
|
42
63
|
},
|
|
43
64
|
});
|
|
44
65
|
}
|
|
45
66
|
}
|
|
46
|
-
if (recorded[entryMcpAccessAppliedScopeKey(
|
|
47
|
-
if (!recorded[`zitadel:app:${
|
|
67
|
+
if (recorded[entryMcpAccessAppliedScopeKey(clientKey)] !== mcpAccessWant(org)) {
|
|
68
|
+
if (!recorded[`zitadel:app:${clientKey}`] && !planned('zitadel', 'app', clientKey)) {
|
|
48
69
|
blocked.push({
|
|
49
70
|
description: `${at} access is blocked, not planned: the sign-in client is not recorded yet`,
|
|
50
71
|
key: `zitadel:mcp-access:${stepKey}`,
|
|
@@ -54,7 +75,7 @@ export function planMcp(org, recorded, plannedSteps, zitadelMcp) {
|
|
|
54
75
|
steps.push({
|
|
55
76
|
action: { system: 'zitadel', kind: 'mcp-access', key: stepKey, desired: {} },
|
|
56
77
|
step: {
|
|
57
|
-
description: `${org.slug}'s ${
|
|
78
|
+
description: `${org.slug}'s ${clientKey} sign-in client issues JWT access tokens and accepts ${mcpCallbackUri(org)}`,
|
|
58
79
|
destroys: false,
|
|
59
80
|
migrates: false,
|
|
60
81
|
},
|
|
@@ -75,6 +96,17 @@ export function planMcp(org, recorded, plannedSteps, zitadelMcp) {
|
|
|
75
96
|
}
|
|
76
97
|
return { steps, blocked };
|
|
77
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* The entry a step key names: a live entry by its key, or a workspace by its client name `<key>-work`. A
|
|
101
|
+
* workspace's client name cannot be another entry's key (`client_name_taken`), so the two never meet.
|
|
102
|
+
*/
|
|
103
|
+
function mcpTarget(org, clientKey) {
|
|
104
|
+
const live = org.hosting.entries.find((e) => e.key === clientKey && e.uses?.auth?.mcp);
|
|
105
|
+
if (live)
|
|
106
|
+
return { workspace: false, entry: live };
|
|
107
|
+
const entry = workspaceAuthEntryOf(org, clientKey);
|
|
108
|
+
return entry?.uses?.auth?.mcp ? { workspace: true, entry } : undefined;
|
|
109
|
+
}
|
|
78
110
|
// ---- the apply -------------------------------------------------------------
|
|
79
111
|
/**
|
|
80
112
|
* The empty project in the customers organisation that is an entry's MCP token
|
|
@@ -85,10 +117,10 @@ export function planMcp(org, recorded, plannedSteps, zitadelMcp) {
|
|
|
85
117
|
export async function applyMcpProject(zitadel, org, action, context) {
|
|
86
118
|
const entryKey = action.key.slice(org.id.length + 1);
|
|
87
119
|
const at = `zitadel:mcp-project for ${org.slug} entry '${entryKey}'`;
|
|
88
|
-
|
|
120
|
+
const target = mcpTarget(org, entryKey);
|
|
121
|
+
if (!target)
|
|
89
122
|
throw new Error(`${at}: the entry no longer declares uses.auth.mcp`);
|
|
90
|
-
}
|
|
91
|
-
const customersOrgId = context.recordedIds()[`zitadel:customers:${org.id}`];
|
|
123
|
+
const customersOrgId = context.recordedIds()[target.workspace ? testCustomersOrgKey(org.id) : `zitadel:customers:${org.id}`];
|
|
92
124
|
if (!customersOrgId)
|
|
93
125
|
throw new Error(`${at}: the customers organisation is not recorded yet`);
|
|
94
126
|
const { project } = await withCallTimeout(MCP_CALL_TIMEOUT_MS, `${at}: find or create the project`, () => zitadel.findOrCreateProject(customersOrgId, `${org.slug}-${entryKey}-mcp`));
|
|
@@ -105,7 +137,7 @@ export async function applyMcpAccess(zitadel, org, action, context) {
|
|
|
105
137
|
const ensure = zitadel.ensureOidcAppMcpAccess?.bind(zitadel);
|
|
106
138
|
if (!ensure)
|
|
107
139
|
throw new Error(`${at}: the ZITADEL client cannot set JWT access tokens`);
|
|
108
|
-
if (!org
|
|
140
|
+
if (!mcpTarget(org, entryKey)) {
|
|
109
141
|
throw new Error(`${at}: the entry no longer declares uses.auth.mcp`);
|
|
110
142
|
}
|
|
111
143
|
const recorded = context.recordedIds();
|