@wtfalch/foundry 0.60.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.
Files changed (218) hide show
  1. package/dist/apps/cloudflare.d.ts +173 -0
  2. package/dist/apps/cloudflare.js +17 -0
  3. package/dist/apps/deploy.d.ts +116 -0
  4. package/dist/apps/deploy.js +791 -0
  5. package/dist/apps/github.d.ts +47 -0
  6. package/dist/apps/github.js +201 -0
  7. package/dist/apps/index.d.ts +9 -0
  8. package/dist/apps/index.js +9 -0
  9. package/dist/apps/migrations.d.ts +110 -0
  10. package/dist/apps/migrations.js +458 -0
  11. package/dist/audit/connector.d.ts +43 -0
  12. package/dist/audit/connector.js +22 -0
  13. package/dist/audit/evidence.d.ts +49 -0
  14. package/dist/audit/evidence.js +56 -0
  15. package/dist/audit/index.d.ts +9 -0
  16. package/dist/audit/index.js +9 -0
  17. package/dist/audit/migrations.d.ts +5 -0
  18. package/dist/audit/migrations.js +5 -0
  19. package/dist/audit/registry.d.ts +49 -0
  20. package/dist/audit/registry.js +44 -0
  21. package/dist/audit/seal.d.ts +49 -0
  22. package/dist/audit/seal.js +131 -0
  23. package/dist/audit/sealer-role.d.ts +33 -0
  24. package/dist/audit/sealer-role.js +56 -0
  25. package/dist/audit/signer.d.ts +40 -0
  26. package/dist/audit/signer.js +78 -0
  27. package/dist/audit/tick.d.ts +36 -0
  28. package/dist/audit/tick.js +87 -0
  29. package/dist/catalogue/copy-en.d.ts +1 -0
  30. package/dist/catalogue/copy-en.js +129 -0
  31. package/dist/catalogue/index.d.ts +9 -0
  32. package/dist/catalogue/index.js +21 -0
  33. package/dist/catalogue/integrations.d.ts +4 -0
  34. package/dist/catalogue/integrations.js +104 -0
  35. package/dist/catalogue/systems.d.ts +7 -0
  36. package/dist/catalogue/systems.js +31 -0
  37. package/dist/catalogue/types.d.ts +110 -0
  38. package/dist/catalogue/types.js +1 -0
  39. package/dist/entry-rules.d.ts +53 -0
  40. package/dist/entry-rules.js +71 -0
  41. package/dist/hosted-label.d.ts +13 -0
  42. package/dist/hosted-label.js +81 -0
  43. package/dist/index.d.ts +10 -5
  44. package/dist/index.js +5 -3
  45. package/dist/migrations/0062_hosting_github.sql +6 -0
  46. package/dist/migrations/0063_boule_apps.sql +7 -0
  47. package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
  48. package/dist/modules/ai.d.ts +3 -0
  49. package/dist/modules/ai.js +64 -0
  50. package/dist/modules/assistants.d.ts +3 -0
  51. package/dist/modules/assistants.js +72 -0
  52. package/dist/modules/audit-ledger.d.ts +29 -0
  53. package/dist/modules/audit-ledger.js +49 -0
  54. package/dist/modules/audit.d.ts +7 -0
  55. package/dist/modules/audit.js +44 -0
  56. package/dist/modules/database.d.ts +23 -0
  57. package/dist/modules/database.js +189 -0
  58. package/dist/modules/email.d.ts +3 -0
  59. package/dist/modules/email.js +18 -0
  60. package/dist/modules/entry-parts.d.ts +23 -0
  61. package/dist/modules/entry-parts.js +63 -0
  62. package/dist/modules/files.d.ts +3 -0
  63. package/dist/modules/files.js +64 -0
  64. package/dist/modules/index.d.ts +74 -0
  65. package/dist/modules/index.js +774 -0
  66. package/dist/modules/jobs.d.ts +3 -0
  67. package/dist/modules/jobs.js +23 -0
  68. package/dist/modules/organisations.d.ts +3 -0
  69. package/dist/modules/organisations.js +20 -0
  70. package/dist/modules/outputs.d.ts +9 -0
  71. package/dist/modules/outputs.js +38 -0
  72. package/dist/modules/sign-in.d.ts +3 -0
  73. package/dist/modules/sign-in.js +197 -0
  74. package/dist/modules/signed-records.d.ts +3 -0
  75. package/dist/modules/signed-records.js +32 -0
  76. package/dist/modules/types.d.ts +194 -0
  77. package/dist/modules/types.js +1 -0
  78. package/dist/modules/workspaces.d.ts +3 -0
  79. package/dist/modules/workspaces.js +75 -0
  80. package/dist/org.d.ts +163 -22
  81. package/dist/org.js +419 -47
  82. package/dist/plan/apps-db-route.d.ts +90 -0
  83. package/dist/plan/apps-db-route.js +168 -0
  84. package/dist/plan/apps-zones.d.ts +56 -0
  85. package/dist/plan/apps-zones.js +118 -0
  86. package/dist/plan/archon-blue-green.js +11 -1
  87. package/dist/plan/blue-green-apply.d.ts +37 -1
  88. package/dist/plan/blue-green-apply.js +55 -15
  89. package/dist/plan/blue-green.d.ts +41 -1
  90. package/dist/plan/blue-green.js +80 -2
  91. package/dist/plan/boule.d.ts +23 -1
  92. package/dist/plan/boule.js +35 -2
  93. package/dist/plan/broker-entries.d.ts +28 -3
  94. package/dist/plan/broker-entries.js +74 -3
  95. package/dist/plan/broker-registrar.d.ts +168 -0
  96. package/dist/plan/broker-registrar.js +440 -0
  97. package/dist/plan/broker-workspaces-database.d.ts +48 -0
  98. package/dist/plan/broker-workspaces-database.js +87 -0
  99. package/dist/plan/desktop-callback.js +12 -7
  100. package/dist/plan/diff.d.ts +8 -2
  101. package/dist/plan/diff.js +373 -42
  102. package/dist/plan/edge-access.d.ts +6 -0
  103. package/dist/plan/edge-dns.d.ts +6 -0
  104. package/dist/plan/edge-dns.js +9 -7
  105. package/dist/plan/entry-ai-key.d.ts +1 -1
  106. package/dist/plan/entry-ai-key.js +2 -2
  107. package/dist/plan/entry-audit.d.ts +58 -0
  108. package/dist/plan/entry-audit.js +122 -0
  109. package/dist/plan/entry-customer-grants.d.ts +75 -0
  110. package/dist/plan/entry-customer-grants.js +154 -0
  111. package/dist/plan/entry-edge-key.d.ts +26 -1
  112. package/dist/plan/entry-edge-key.js +42 -3
  113. package/dist/plan/entry-jobs.d.ts +64 -0
  114. package/dist/plan/entry-jobs.js +92 -0
  115. package/dist/plan/entry-repo-key-apply.d.ts +55 -0
  116. package/dist/plan/entry-repo-key-apply.js +185 -0
  117. package/dist/plan/entry-repo-key.d.ts +92 -0
  118. package/dist/plan/entry-repo-key.js +235 -0
  119. package/dist/plan/entry-workspaces.d.ts +55 -0
  120. package/dist/plan/entry-workspaces.js +90 -0
  121. package/dist/plan/executor.d.ts +191 -1
  122. package/dist/plan/executor.js +1505 -90
  123. package/dist/plan/gates.d.ts +1 -1
  124. package/dist/plan/gates.js +62 -9
  125. package/dist/plan/github-deploy-keys.d.ts +78 -0
  126. package/dist/plan/github-deploy-keys.js +305 -0
  127. package/dist/plan/github-source.d.ts +53 -0
  128. package/dist/plan/github-source.js +140 -0
  129. package/dist/plan/held-key-refs.d.ts +12 -0
  130. package/dist/plan/held-key-refs.js +12 -0
  131. package/dist/plan/hostnames.d.ts +9 -0
  132. package/dist/plan/hostnames.js +28 -1
  133. package/dist/plan/index.d.ts +9 -0
  134. package/dist/plan/index.js +6 -0
  135. package/dist/plan/jobs-scheduler-http.js +3 -0
  136. package/dist/plan/keys-registrar-http.d.ts +32 -0
  137. package/dist/plan/keys-registrar-http.js +105 -0
  138. package/dist/plan/keys-registrar.d.ts +67 -0
  139. package/dist/plan/keys-registrar.js +103 -0
  140. package/dist/plan/live.d.ts +1 -1
  141. package/dist/plan/locked-hostnames.d.ts +2 -2
  142. package/dist/plan/locked-hostnames.js +2 -2
  143. package/dist/plan/login-host.js +11 -5
  144. package/dist/plan/machine-paths.d.ts +36 -0
  145. package/dist/plan/machine-paths.js +137 -0
  146. package/dist/plan/mcp.d.ts +1 -1
  147. package/dist/plan/mcp.js +49 -17
  148. package/dist/plan/org-apps-admin.d.ts +108 -0
  149. package/dist/plan/org-apps-admin.js +434 -0
  150. package/dist/plan/org-names.js +12 -1
  151. package/dist/plan/own-diff.d.ts +23 -0
  152. package/dist/plan/own-diff.js +53 -0
  153. package/dist/plan/own-hostnames.d.ts +2 -0
  154. package/dist/plan/own-hostnames.js +1 -0
  155. package/dist/plan/own-jobs-route.d.ts +47 -0
  156. package/dist/plan/own-jobs-route.js +91 -0
  157. package/dist/plan/own-services.d.ts +4 -0
  158. package/dist/plan/own-services.js +4 -0
  159. package/dist/plan/plan-apply.d.ts +39 -0
  160. package/dist/plan/plan-apply.js +163 -9
  161. package/dist/plan/platform-diff.d.ts +2 -2
  162. package/dist/plan/platform-diff.js +7 -2
  163. package/dist/plan/platform-org.d.ts +30 -0
  164. package/dist/plan/platform-org.js +12 -1
  165. package/dist/plan/platform-providers.d.ts +2 -0
  166. package/dist/plan/platform-providers.js +7 -2
  167. package/dist/plan/post-logout-uris.js +7 -1
  168. package/dist/plan/private-pages.d.ts +26 -0
  169. package/dist/plan/private-pages.js +39 -0
  170. package/dist/plan/proxy-labels.d.ts +12 -1
  171. package/dist/plan/proxy-labels.js +32 -2
  172. package/dist/plan/release-host.d.ts +6 -2
  173. package/dist/plan/release-host.js +36 -8
  174. package/dist/plan/stage-env.d.ts +19 -1
  175. package/dist/plan/stage-env.js +25 -1
  176. package/dist/plan/stage-guards.d.ts +20 -0
  177. package/dist/plan/stage-guards.js +24 -0
  178. package/dist/plan/stage-isolation.d.ts +24 -0
  179. package/dist/plan/stage-isolation.js +29 -1
  180. package/dist/plan/worker-app-database.d.ts +46 -42
  181. package/dist/plan/worker-app-database.js +150 -153
  182. package/dist/plan/worker-app.d.ts +5 -65
  183. package/dist/plan/worker-app.js +28 -105
  184. package/dist/plan/worker.d.ts +2 -2
  185. package/dist/plan/worker.js +2 -1
  186. package/dist/plan/workspace-client.d.ts +25 -0
  187. package/dist/plan/workspace-client.js +48 -0
  188. package/dist/plan/workspace-sign-in.d.ts +55 -0
  189. package/dist/plan/workspace-sign-in.js +273 -0
  190. package/dist/plan/workspace-worker.d.ts +4 -0
  191. package/dist/plan/workspace-worker.js +13 -0
  192. package/dist/plan/workspace.d.ts +101 -0
  193. package/dist/plan/workspace.js +305 -0
  194. package/dist/release/decide.d.ts +30 -1
  195. package/dist/release/decide.js +73 -11
  196. package/dist/release/health.d.ts +8 -3
  197. package/dist/release/health.js +58 -5
  198. package/dist/release/run.d.ts +2 -5
  199. package/dist/release/run.js +4 -0
  200. package/dist/release/state.d.ts +3 -1
  201. package/dist/release/state.js +6 -0
  202. package/dist/release/types.d.ts +20 -0
  203. package/dist/requested-changes.d.ts +11 -2
  204. package/dist/requested-changes.js +11 -0
  205. package/dist/service-labels.d.ts +4 -0
  206. package/dist/service-labels.js +11 -0
  207. package/dist/slug.d.ts +2 -0
  208. package/dist/slug.js +10 -6
  209. package/dist/store/postgres.js +12 -2
  210. package/dist/store/schema.d.ts +37 -1
  211. package/dist/store/schema.js +4 -0
  212. package/dist/worker-app-config.d.ts +1 -11
  213. package/dist/worker-app-config.js +4 -17
  214. package/dist/worker-app-migrations.d.ts +105 -0
  215. package/dist/worker-app-migrations.js +166 -0
  216. package/dist/worker-app-upload.d.ts +103 -0
  217. package/dist/worker-app-upload.js +128 -0
  218. package/package.json +18 -1
@@ -1,6 +1,6 @@
1
1
  import { type DeclaredOrg } from '../org.js';
2
- /** The labels an own org's services answer on, so no hosting entry may take them. */
3
- export declare const OWN_SERVICE_LABELS: readonly ["auth", "mail", "files", "sfu"];
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
- /** The labels an own org's services answer on, so no hosting entry may take them. */
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
@@ -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(org.hosting.entries.filter((e) => e.uses?.auth?.users === 'customers').map((e) => e.key));
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
- if (!customersOrgId)
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
- if (clientId)
210
- map[clientId] = customersOrgId;
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
+ }
@@ -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 entries = org.hosting.enabled
11
+ const live = org.hosting.enabled
11
12
  ? org.hosting.entries.filter((e) => Boolean(e.uses?.auth?.mcp))
12
13
  : [];
13
- if (entries.length === 0)
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
- for (const entry of entries) {
19
- const at = `${org.slug}'s ${entry.key} MCP`;
20
- const stepKey = `${org.id}:${entry.key}`;
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(entry.key)]) {
29
- if (!recorded[`zitadel:customers:${org.id}`] && !planned('zitadel', 'customers')) {
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 customers organisation id is not recorded yet`,
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 ${entry.key} MCP token audience`,
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(entry.key)] !== mcpAccessWant(org)) {
47
- if (!recorded[`zitadel:app:${entry.key}`] && !planned('zitadel', 'app', entry.key)) {
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 ${entry.key} sign-in client issues JWT access tokens and accepts ${mcpCallbackUri(org)}`,
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
- if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.auth?.mcp)) {
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.hosting.entries.some((e) => e.key === entryKey && e.uses?.auth?.mcp)) {
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 {};