@wtfalch/foundry 0.59.0 → 0.61.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 +10 -5
- package/dist/index.js +5 -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/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/org.d.ts +163 -22
- package/dist/org.js +419 -47
- 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 +41 -1
- package/dist/plan/blue-green.js +80 -2
- 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/desktop-callback.js +12 -7
- package/dist/plan/diff.d.ts +8 -2
- package/dist/plan/diff.js +373 -42
- package/dist/plan/edge-access.d.ts +6 -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 +191 -1
- package/dist/plan/executor.js +1505 -90
- 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/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +2 -2
- package/dist/plan/locked-hostnames.js +2 -2
- 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.js +12 -1
- 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-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 +7 -2
- package/dist/plan/post-logout-uris.js +7 -1
- package/dist/plan/private-pages.d.ts +26 -0
- package/dist/plan/private-pages.js +39 -0
- 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 +47 -40
- package/dist/plan/worker-app-database.js +150 -152
- package/dist/plan/worker-app.d.ts +6 -65
- package/dist/plan/worker-app.js +29 -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/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 +11 -0
- 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 +10 -6
- package/dist/store/postgres.js +12 -2
- package/dist/store/schema.d.ts +37 -1
- package/dist/store/schema.js +4 -0
- package/dist/worker-app-config.d.ts +6 -10
- package/dist/worker-app-config.js +22 -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 +18 -1
|
@@ -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,11 @@
|
|
|
1
1
|
import { 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';
|
|
5
6
|
import { loginHostnameFor } from './login-host.js';
|
|
6
7
|
import { ownHostnames } from './own-hostnames.js';
|
|
7
|
-
|
|
8
|
-
export const OWN_SERVICE_LABELS = ['auth', 'mail', 'files', 'sfu'];
|
|
8
|
+
export { OWN_SERVICE_LABELS };
|
|
9
9
|
/**
|
|
10
10
|
* The names under the org's domain that foundry owns, so no hosting entry may
|
|
11
11
|
* take one: Agora, Boule, the cms studio when the site is on, calls when it uses the
|
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();
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { type DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
import type { CoolifyPort } from './executor.js';
|
|
4
|
+
import { type JobRuntime } from './job-app.js';
|
|
5
|
+
import type { StepScope } from './own-services.js';
|
|
6
|
+
/** The role's name on the server: the org's slug plus `_apps_admin`. */
|
|
7
|
+
export declare const appsAdminRoleName: (org: Pick<DeclaredOrg, "slug">) => string;
|
|
8
|
+
/** The job app's name in Coolify. */
|
|
9
|
+
export declare const appsAdminJobName: (org: Pick<DeclaredOrg, "slug">) => string;
|
|
10
|
+
/** Recorded once the role is made, its password set, and the URL delivered. */
|
|
11
|
+
export declare const ORG_APPS_ADMIN_SCOPE_KEY = "org_apps_admin";
|
|
12
|
+
/**
|
|
13
|
+
* An opaque id, set once the password is minted and the job that sets it is about to run, until the URL is
|
|
14
|
+
* delivered. Never the password. A failure before that point leaves no id, so the next apply tries again.
|
|
15
|
+
*/
|
|
16
|
+
export declare const ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY = "org_apps_admin_undelivered";
|
|
17
|
+
/** The job app's id while it exists. */
|
|
18
|
+
export declare const ORG_APPS_ADMIN_JOB_APP_SCOPE_KEY = "org_apps_admin_job_app";
|
|
19
|
+
/** Where the host holds the role's connection URL, once delivered -- `deliverSecret`'s own naming convention. */
|
|
20
|
+
export declare const appsAdminUrlRef: (org: Pick<DeclaredOrg, "slug">) => string;
|
|
21
|
+
/** How many times the URL is handed to `deliverSecret`. */
|
|
22
|
+
export declare const ORG_APPS_ADMIN_DELIVERY_ATTEMPTS = 3;
|
|
23
|
+
export interface PlanOrgAppsAdminOptions {
|
|
24
|
+
/** The host can store a secret under a ref (`deliverSecret`). Default true. */
|
|
25
|
+
canHoldSecrets?: boolean;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Pure. Plans `coolify:org-apps-admin` once, after the company's Postgres server is recorded
|
|
29
|
+
* (platform-step rule 2: blocked, not failing, while it is not). A lost mint -- an undelivered id
|
|
30
|
+
* with no done guard -- is reported blocked and never minted again (rule 1); the operator clears the
|
|
31
|
+
* recorded id once the role is confirmed gone from the server.
|
|
32
|
+
*/
|
|
33
|
+
export declare function planOrgAppsAdminStep(org: Pick<DeclaredOrg, 'id' | 'slug'>, recordedIds: Readonly<Record<string, string>>, options?: PlanOrgAppsAdminOptions): {
|
|
34
|
+
step: DraftStep | null;
|
|
35
|
+
blocked: string | null;
|
|
36
|
+
};
|
|
37
|
+
export interface PlanAppsAdminOptions {
|
|
38
|
+
canHoldSecrets: boolean;
|
|
39
|
+
/** `coolify:org-postgres` is planned in this same plan, ahead of this step. */
|
|
40
|
+
postgresPlanned: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Pure. F4 (#925): the wiring of {@link planOrgAppsAdminStep} to `org.boule.apps`. Apps on: the mint
|
|
44
|
+
* step, once Boule (which receives the URL) and the Postgres server are recorded or planned. Apps off with
|
|
45
|
+
* the role recorded (or a mint never delivered): the retire step, which needs no secret and drops nothing.
|
|
46
|
+
*/
|
|
47
|
+
export declare function planAppsAdmin(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], options: PlanAppsAdminOptions): {
|
|
48
|
+
steps: DraftStep[];
|
|
49
|
+
blocked: Array<{
|
|
50
|
+
description: string;
|
|
51
|
+
key: string;
|
|
52
|
+
}>;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* The job's script: makes the role (idempotent, kept if it already exists), sets its password and grants
|
|
56
|
+
* it CONNECT on `postgres`. It revokes nothing from PUBLIC (see the file comment). Roles are made by
|
|
57
|
+
* `format('%I')` and the password by `format('%L')`, so no value is spliced into SQL by the shell. Setting
|
|
58
|
+
* the password again is harmless, so a mint that is repeated simply replaces it. It never prints a URL or a password: psql's output is discarded and the one
|
|
59
|
+
* error line names neither.
|
|
60
|
+
*/
|
|
61
|
+
export declare function orgAppsAdminScript(role: string, mode?: 'mint' | 'retire'): string[];
|
|
62
|
+
/** The job: the same pinned image as the other Postgres jobs, so `psql` matches the server's major version. */
|
|
63
|
+
export declare function orgAppsAdminDockerfile(role: string, mode?: 'mint' | 'retire'): string;
|
|
64
|
+
type AppsAdminCoolify = Pick<CoolifyPort, 'findOrCreateEnvironment' | 'findApplicationByName' | 'getApplication' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'deploy' | 'raw'>;
|
|
65
|
+
/** Everything the step takes from the executor. New ones go here, never as positional arguments. */
|
|
66
|
+
export interface OrgAppsAdminDeps {
|
|
67
|
+
coolify: AppsAdminCoolify;
|
|
68
|
+
ensureProject(): Promise<string>;
|
|
69
|
+
serverUuid(): Promise<string>;
|
|
70
|
+
/** Starts the company's Postgres server when it is not running and returns its live record. */
|
|
71
|
+
startDatabase(uuid: string): Promise<{
|
|
72
|
+
internal_db_url?: string;
|
|
73
|
+
status?: string;
|
|
74
|
+
}>;
|
|
75
|
+
deliverSecret(ref: string, secret: string): Promise<void>;
|
|
76
|
+
/** Registers a value for redaction. */
|
|
77
|
+
onSecret?(value: string): void;
|
|
78
|
+
/** Test seam: a password. Default: 24 random bytes, base64url. */
|
|
79
|
+
mintPassword?(): string;
|
|
80
|
+
/** Test seam: an opaque id. */
|
|
81
|
+
mintId?(): string;
|
|
82
|
+
runtime?: JobRuntime;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Makes the role true and delivers its connection URL. The password is minted in memory. The opaque id
|
|
86
|
+
* {@link ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY} is recorded and persisted only once the job that sets the
|
|
87
|
+
* password is about to be deployed ("password minted and role touched"); a failure before that leaves no
|
|
88
|
+
* id and the next apply simply retries, since setting a password again is harmless. The done guard is
|
|
89
|
+
* recorded only once the URL is delivered. A run that dies between the two finds the id and stops (the
|
|
90
|
+
* planner reports it blocked); retiring the role (apps off) clears it.
|
|
91
|
+
*/
|
|
92
|
+
export declare function provisionOrgAppsAdmin(scope: StepScope, deps: OrgAppsAdminDeps, org: DeclaredOrg): Promise<void>;
|
|
93
|
+
/** What the retire form takes from the executor: the mint form's deps, and the held-key blanking. */
|
|
94
|
+
export interface RetireOrgAppsAdminDeps extends Omit<OrgAppsAdminDeps, 'deliverSecret'> {
|
|
95
|
+
/** Blanks the held URL (the store has no delete: an empty value reads as not held). */
|
|
96
|
+
clearHeldUrl(ref: string): Promise<void>;
|
|
97
|
+
/** Takes `APPS_DATABASE_ADMIN_URL` out of Boule's env. A row already gone is not an error. */
|
|
98
|
+
removeBouleEnv(): Promise<void>;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Apps went off (F4, #925): the role stops logging in, the URL leaves Boule's env and the held store,
|
|
102
|
+
* and the recorded ids are cleared last so a failed run repeats. Never drops the role, a database or
|
|
103
|
+
* a role an app made (CLAUDE.md: never drop data); turning apps on again mints a new password for the
|
|
104
|
+
* same role. A recorded mint that was never delivered is retired the same way, which is also how an
|
|
105
|
+
* operator unblocks a lost mint. The job needs the server's admin URL and nothing else secret.
|
|
106
|
+
*/
|
|
107
|
+
export declare function retireOrgAppsAdmin(scope: StepScope, deps: RetireOrgAppsAdminDeps, org: DeclaredOrg): Promise<void>;
|
|
108
|
+
export {};
|