@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
@@ -0,0 +1,140 @@
1
+ import { companyGithubFor } from '../org.js';
2
+ import { withTimeout } from './call-timeout.js';
3
+ import { PLATFORM_GITHUB_APP_REFS } from './held-key-refs.js';
4
+ /** Recorded after the source is made: the installation id it is bound to, so a changed installation plans the step again. */
5
+ export const GITHUB_SOURCE_INSTALLATION_SCOPE_KEY = 'github_source_installation';
6
+ /** The Coolify private key every company source shares: the platform GitHub App's key, made once. */
7
+ export const GITHUB_SOURCE_KEY_NAME = 'platform-github-app';
8
+ /** Every Coolify call this module makes is cut off after this long. */
9
+ export const GITHUB_SOURCE_TIMEOUT_MS = 20_000;
10
+ const GITHUB_API_URL = 'https://api.github.com';
11
+ const GITHUB_HTML_URL = 'https://github.com';
12
+ /** The recorded resource id (`serviceIds` key) of a company's source: the step is keyed by the org id. */
13
+ export function githubSourceResourceKey(orgId) {
14
+ return `coolify:github-source:${orgId}`;
15
+ }
16
+ /** The Coolify name of a company's GitHub source. */
17
+ export function githubSourceName(slug) {
18
+ return `${slug}-github`;
19
+ }
20
+ /** True when an app of this company is built from the company's own account, so it needs the company's source. */
21
+ export function needsCompanyGithubSource(org) {
22
+ return (org.hosting.enabled &&
23
+ org.hosting.entries.some((e) => !e.workerApp && companyGithubFor(org.hosting, e.repo)));
24
+ }
25
+ /**
26
+ * `coolify:github-source` (keyed by the org id): the company's own Coolify GitHub App source, bound to its
27
+ * installation id. Blocked, not planned, while a key of the platform's GitHub App is not held. Planned again
28
+ * when the recorded source is missing or was made for another installation.
29
+ */
30
+ export function planGithubSource(org, recordedIds, keys) {
31
+ const github = org.hosting.github;
32
+ if (!github || !needsCompanyGithubSource(org))
33
+ return { step: null, blocked: [] };
34
+ const recorded = Boolean(recordedIds[githubSourceResourceKey(org.id)]) &&
35
+ recordedIds[GITHUB_SOURCE_INSTALLATION_SCOPE_KEY] === String(github.installationId);
36
+ if (recorded)
37
+ return { step: null, blocked: [] };
38
+ const blockedItem = (why) => ({
39
+ step: null,
40
+ blocked: [
41
+ {
42
+ description: `${org.slug}'s GitHub source is blocked, not planned: ${why}`,
43
+ key: 'github-source',
44
+ },
45
+ ],
46
+ });
47
+ if (!keys?.store) {
48
+ return blockedItem('the host gave no reader for the platform GitHub App keys');
49
+ }
50
+ if (keys.missing.length > 0) {
51
+ return blockedItem(`the platform GitHub App keys are not all held (${keys.missing.join(', ')})`);
52
+ }
53
+ return {
54
+ step: {
55
+ action: {
56
+ system: 'coolify',
57
+ kind: 'github-source',
58
+ key: org.id,
59
+ desired: { installationId: github.installationId, account: github.account },
60
+ },
61
+ step: {
62
+ description: `make ${org.slug}'s Coolify GitHub source for installation ${github.installationId} on ${github.account}`,
63
+ destroys: false,
64
+ migrates: false,
65
+ },
66
+ },
67
+ blocked: [],
68
+ };
69
+ }
70
+ const isRecord = (v) => typeof v === 'object' && v !== null;
71
+ /**
72
+ * The uuid of the company's Coolify GitHub source: found by name when it is bound to the company's installation,
73
+ * else made from the platform GitHub App's held keys (`PLATFORM_GITHUB_APP_REFS`) and the Coolify private key
74
+ * `platform-github-app` (found, else made). Every Coolify call has a timeout and a failure is fixed text: a
75
+ * Coolify error can echo the request body, which holds the App's secrets. A failure after the private key was
76
+ * made leaves that key (shared, found by name next run) and no source; the source is the last call.
77
+ */
78
+ export async function ensureGithubSource(deps) {
79
+ const { label, github } = deps;
80
+ const timeout = deps.timeoutMs ?? GITHUB_SOURCE_TIMEOUT_MS;
81
+ const held = {
82
+ appId: '',
83
+ clientId: '',
84
+ clientSecret: '',
85
+ webhookSecret: '',
86
+ privateKey: '',
87
+ };
88
+ for (const [name, ref] of Object.entries(PLATFORM_GITHUB_APP_REFS)) {
89
+ const value = await deps.heldKeys.resolve(ref);
90
+ if (!value)
91
+ throw new Error(`${label}: held key ${ref} is not held`);
92
+ deps.secret(value);
93
+ held[name] = value;
94
+ }
95
+ const appId = Number(held.appId);
96
+ if (!Number.isSafeInteger(appId) || appId <= 0) {
97
+ throw new Error(`${label}: held key ${PLATFORM_GITHUB_APP_REFS.appId} is not a positive integer`);
98
+ }
99
+ const name = githubSourceName(deps.slug);
100
+ const sources = await withTimeout(`${label}: listing Coolify GitHub sources`, timeout, () => deps.coolify.raw('GET', '/github-apps'));
101
+ if (!Array.isArray(sources))
102
+ throw new Error(`${label}: Coolify's GitHub source list was not a list`);
103
+ const existing = sources.find((s) => isRecord(s) && s.name === name);
104
+ if (isRecord(existing) && typeof existing.uuid === 'string') {
105
+ if (existing.installation_id === github.installationId)
106
+ return { uuid: existing.uuid };
107
+ throw new Error(`${label}: Coolify already has a GitHub source named ${name} for another installation; remove it in Coolify and apply again`);
108
+ }
109
+ const keysList = await withTimeout(`${label}: listing Coolify private keys`, timeout, () => deps.coolify.raw('GET', '/security/keys'));
110
+ if (!Array.isArray(keysList))
111
+ throw new Error(`${label}: Coolify's private key list was not a list`);
112
+ const foundKey = keysList.find((k) => isRecord(k) && k.name === GITHUB_SOURCE_KEY_NAME);
113
+ let keyUuid = isRecord(foundKey) && typeof foundKey.uuid === 'string' ? foundKey.uuid : null;
114
+ if (!keyUuid) {
115
+ const madeKey = await withTimeout(`${label}: making the Coolify private key`, timeout, () => deps.coolify.raw('POST', '/security/keys', {
116
+ name: GITHUB_SOURCE_KEY_NAME,
117
+ description: "The platform's GitHub App private key, shared by every company source",
118
+ private_key: held.privateKey,
119
+ }));
120
+ keyUuid = isRecord(madeKey) && typeof madeKey.uuid === 'string' ? madeKey.uuid : null;
121
+ if (!keyUuid)
122
+ throw new Error(`${label}: Coolify gave no uuid for the private key`);
123
+ }
124
+ const made = await withTimeout(`${label}: making the Coolify GitHub source`, timeout, () => deps.coolify.raw('POST', '/github-apps', {
125
+ name,
126
+ api_url: GITHUB_API_URL,
127
+ html_url: GITHUB_HTML_URL,
128
+ app_id: appId,
129
+ installation_id: github.installationId,
130
+ client_id: held.clientId,
131
+ client_secret: held.clientSecret,
132
+ webhook_secret: held.webhookSecret,
133
+ private_key_uuid: keyUuid,
134
+ is_system_wide: false,
135
+ }));
136
+ if (!isRecord(made) || typeof made.uuid !== 'string') {
137
+ throw new Error(`${label}: Coolify gave no uuid for the GitHub source`);
138
+ }
139
+ return { uuid: made.uuid };
140
+ }
@@ -13,3 +13,15 @@ export declare const PLATFORM_AI_MANAGEMENT_KEY_REF = "ai:platform:managementKey
13
13
  export declare const PROXY_LABEL_TOKEN_REF = "proxy:labels:token";
14
14
  /** The ref the management private key (PKCS#8 PEM) is delivered under. */
15
15
  export declare const FILES_MANAGEMENT_KEY_REF = "files:platform:managementKey";
16
+ /**
17
+ * The platform's GitHub App, held by the operator (Archon's GitHub App setup), one ref per value. A company's
18
+ * Coolify GitHub source (`coolify:github-source`) is made from these and the company's installation id. All five
19
+ * must be held before that step is planned; a ref is never a value.
20
+ */
21
+ export declare const PLATFORM_GITHUB_APP_REFS: {
22
+ readonly appId: "platform:github-app:app-id";
23
+ readonly clientId: "platform:github-app:client-id";
24
+ readonly clientSecret: "platform:github-app:client-secret";
25
+ readonly webhookSecret: "platform:github-app:webhook-secret";
26
+ readonly privateKey: "platform:github-app:private-key";
27
+ };
@@ -13,3 +13,15 @@ export const PLATFORM_AI_MANAGEMENT_KEY_REF = 'ai:platform:managementKey';
13
13
  export const PROXY_LABEL_TOKEN_REF = 'proxy:labels:token';
14
14
  /** The ref the management private key (PKCS#8 PEM) is delivered under. */
15
15
  export const FILES_MANAGEMENT_KEY_REF = 'files:platform:managementKey';
16
+ /**
17
+ * The platform's GitHub App, held by the operator (Archon's GitHub App setup), one ref per value. A company's
18
+ * Coolify GitHub source (`coolify:github-source`) is made from these and the company's installation id. All five
19
+ * must be held before that step is planned; a ref is never a value.
20
+ */
21
+ export const PLATFORM_GITHUB_APP_REFS = {
22
+ appId: 'platform:github-app:app-id',
23
+ clientId: 'platform:github-app:client-id',
24
+ clientSecret: 'platform:github-app:client-secret',
25
+ webhookSecret: 'platform:github-app:webhook-secret',
26
+ privateKey: 'platform:github-app:private-key',
27
+ };
@@ -6,6 +6,11 @@ import type { DeclaredOrg, HostingEntry } from '../org.js';
6
6
  * acted on elsewhere.
7
7
  */
8
8
  export declare function entryHostnames(org: DeclaredOrg, entry: HostingEntry): string[];
9
+ /**
10
+ * The host that actually serves an entry: its first live name, or, when it is not live (`live: false`, so it
11
+ * has none), its workspace host. `null` for an entry with neither. The ai webhook is registered here.
12
+ */
13
+ export declare function entryServingHost(org: DeclaredOrg, entry: HostingEntry): string | null;
9
14
  /**
10
15
  * The one exact name a Worker app is served at (ADR 0002): `<host>.<domain>` or
11
16
  * the apex on the org's own domain, else `<slug>-<key>.oikos.host` (one label
@@ -13,6 +18,10 @@ export declare function entryHostnames(org: DeclaredOrg, entry: HostingEntry): s
13
18
  * `<slug>.oikos.host` for the main entry.
14
19
  */
15
20
  export declare function workerAppHostname(org: DeclaredOrg, entry: HostingEntry): string;
21
+ /** An entry's private workspace address: `work-<slug>-<key>.companyincluded.app`, one label deep so the free certificate covers it. */
22
+ export declare function workspaceHost(slug: string, key: string): string;
23
+ /** The company's test inbox: the stage mail sink's web page, `inbox-<slug>.companyincluded.app`. */
24
+ export declare function workspaceInboxHost(slug: string): string;
16
25
  /**
17
26
  * The wildcard name (`*.<domain>`) when an entry declares `'*'`, else `null`.
18
27
  * Like `entryHostnames`, it is read only while hosting is on, `usesDomain` is
@@ -1,7 +1,9 @@
1
- import { isMainEntry, mainEntry } from '../org.js';
1
+ import { INBOX_HOST_PREFIX, WORKSPACE_HOST_PREFIX } from '../hosted-label.js';
2
+ import { APPS_DOMAIN, isMainEntry, mainEntry } from '../org.js';
2
3
  import { workerAppHostnameOf } from '../worker-app-config.js';
3
4
  import { agoraHostnameFor } from './agora.js';
4
5
  import { bouleHostnameFor } from './boule.js';
6
+ import { workspaceWorkerParent } from './workspace-worker.js';
5
7
  /**
6
8
  * The full names an entry is served at. With `hosting.usesDomain` off, or no
7
9
  * domain, `hostnames` is not read: the main entry is at `<slug>.oikos.host`
@@ -10,13 +12,27 @@ import { bouleHostnameFor } from './boule.js';
10
12
  */
11
13
  export function entryHostnames(org, entry) {
12
14
  const { domain } = org;
15
+ if (entry.live === false)
16
+ return [];
13
17
  if (entry.workerApp)
14
18
  return [workerAppHostname(org, entry)];
19
+ if (entry.hostedName !== undefined)
20
+ return [`${entry.hostedName}.${APPS_DOMAIN}`];
15
21
  if (!org.hosting.usesDomain || !domain) {
16
22
  return isMainEntry(entry) ? [`${org.slug}.oikos.host`] : [];
17
23
  }
18
24
  return entry.hostnames.flatMap((name) => name === '*' ? [] : name === '@' ? [domain] : [`${name}.${domain}`]);
19
25
  }
26
+ /**
27
+ * The host that actually serves an entry: its first live name, or, when it is not live (`live: false`, so it
28
+ * has none), its workspace host. `null` for an entry with neither. The ai webhook is registered here.
29
+ */
30
+ export function entryServingHost(org, entry) {
31
+ const live = entryHostnames(org, entry)[0];
32
+ if (live)
33
+ return live;
34
+ return entry.workspace !== undefined ? workspaceHost(org.slug, entry.key) : null;
35
+ }
20
36
  /**
21
37
  * The one exact name a Worker app is served at (ADR 0002): `<host>.<domain>` or
22
38
  * the apex on the org's own domain, else `<slug>-<key>.oikos.host` (one label
@@ -24,8 +40,19 @@ export function entryHostnames(org, entry) {
24
40
  * `<slug>.oikos.host` for the main entry.
25
41
  */
26
42
  export function workerAppHostname(org, entry) {
43
+ const parent = workspaceWorkerParent(entry);
44
+ if (parent !== null)
45
+ return workspaceHost(org.slug, parent);
27
46
  return workerAppHostnameOf({ slug: org.slug, domain: org.domain, usesDomain: org.hosting.usesDomain }, entry, entry.workerApp?.host ?? '@');
28
47
  }
48
+ /** An entry's private workspace address: `work-<slug>-<key>.companyincluded.app`, one label deep so the free certificate covers it. */
49
+ export function workspaceHost(slug, key) {
50
+ return `${WORKSPACE_HOST_PREFIX}${slug}-${key}.${APPS_DOMAIN}`;
51
+ }
52
+ /** The company's test inbox: the stage mail sink's web page, `inbox-<slug>.companyincluded.app`. */
53
+ export function workspaceInboxHost(slug) {
54
+ return `${INBOX_HOST_PREFIX}${slug}.${APPS_DOMAIN}`;
55
+ }
29
56
  /**
30
57
  * The wildcard name (`*.<domain>`) when an entry declares `'*'`, else `null`.
31
58
  * Like `entryHostnames`, it is read only while hosting is on, `usesDomain` is
@@ -1,3 +1,4 @@
1
+ export type { KeysRegistrarPort } from './keys-registrar.js';
1
2
  export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, } from './live.js';
2
3
  export { readLiveResources, readLivePlatformResources } from './live.js';
3
4
  export type { StepAction, PlannedStep, PlanDetail, OrgPostgresPlan } from './diff.js';
@@ -57,6 +58,11 @@ export type { BrokerPort } from './broker-entries.js';
57
58
  export { PlatformBrokerPort } from './broker-entries.js';
58
59
  export type { LoginHostBinding, LoginHostBindingsPort, LoginHostDiscoveryPort, } from './login-host.js';
59
60
  export { loginHostnameFor } from './login-host.js';
61
+ export { workspaceHost, workspaceInboxHost } from './hostnames.js';
62
+ export { companyPeople, workspaceGateKey, workspacePeople } from './workspace.js';
63
+ export { privatePageGateKey } from './private-pages.js';
64
+ export { entryMachinePaths } from './machine-paths.js';
65
+ export type { PrivatePageScope } from './private-pages.js';
60
66
  export { NO_DOMAIN_ZONE, ownHostnames } from './own-hostnames.js';
61
67
  export type { OwnHostnames } from './own-hostnames.js';
62
68
  export type { MailIdentityApp, MailIdentityAudienceCheck } from './mail-identity.js';
@@ -76,6 +82,8 @@ export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, Ed
76
82
  export { createEdgeWorkers } from './edge-workers.js';
77
83
  export type { EdgePlatformWorkers, EdgeWorkersOptions, PlatformWorkersPort, } from './edge-workers.js';
78
84
  export type { PlatformGithubReleaseHookDeps } from './github-release-hook.js';
85
+ export { createGithubDeployKeys } from './github-deploy-keys.js';
86
+ export type { DeployKeyTarget, GithubDeployKey, GithubDeployKeysPort, } from './github-deploy-keys.js';
79
87
  export type { PlatformUptimeAccessFactory, PlatformUptimeAccessPort, PlatformUptimeChatDeps, PlatformUptimeDeps, PlatformWorkersFactory, } from './uptime-worker.js';
80
88
  export { createEdgeRateLimits } from './edge-ratelimits.js';
81
89
  export type { EdgePlatformRateLimits, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, } from './edge-ratelimits.js';
@@ -86,6 +94,7 @@ export type { EdgePlatformTunnels, TunnelIngress, TunnelPort, TunnelSummary, } f
86
94
  export type { ZitadelListAppsRawApp } from './executor.js';
87
95
  export { adaptZitadelListApps } from './executor.js';
88
96
  export { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';
97
+ export { httpKeysRegistrar, keysRegistrarFromConfig, KEYS_PLATFORM_TOKEN_ENV, KEYS_SERVICE_URL_ENV, } from './keys-registrar-http.js';
89
98
  export type { OrgAppVersionRef, OrgAppVersionStatus, WtfalchPackageDrift, PlatformVersionStatus, ReadPlatformVersionStatusOptions, StatusCoolifyPort, StatusGithubPort, GhExec as StatusGhExec, } from './version-status.js';
90
99
  export { readPlatformVersionStatus, wtfalchVersionsFromPnpmLockfile, providersStatusCoolifyPort, ghCliStatusGithubPort, } from './version-status.js';
91
100
  export { COMPANY_DESTINATION_NAME_PREFIX, COMPANY_NETWORKS, DEFAULT_MAX_DESTINATIONS, DestinationCapacityError, PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, placed, placedOnPlatform, validateNetworkName, } from './destinations.js';
@@ -34,6 +34,10 @@ export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCop
34
34
  export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeLoginHostResolver, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
35
35
  export { PlatformBrokerPort } from './broker-entries.js';
36
36
  export { loginHostnameFor } from './login-host.js';
37
+ export { workspaceHost, workspaceInboxHost } from './hostnames.js';
38
+ export { companyPeople, workspaceGateKey, workspacePeople } from './workspace.js';
39
+ export { privatePageGateKey } from './private-pages.js';
40
+ export { entryMachinePaths } from './machine-paths.js';
37
41
  export { NO_DOMAIN_ZONE, ownHostnames } from './own-hostnames.js';
38
42
  export { checkMailIdentityAudience } from './mail-identity.js';
39
43
  export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
@@ -43,10 +47,12 @@ export { healthTargets, platformHealthTargets } from './health-targets.js';
43
47
  export { createEdgeDns, edgeDnsRecordOwners, EDGE_DNS_WRITE_CONCURRENCY } from './edge-dns.js';
44
48
  export { createEdgeAccess } from './edge-access.js';
45
49
  export { createEdgeWorkers } from './edge-workers.js';
50
+ export { createGithubDeployKeys } from './github-deploy-keys.js';
46
51
  export { createEdgeRateLimits } from './edge-ratelimits.js';
47
52
  export { createEdgeZones } from './edge-zones.js';
48
53
  export { createEdgeTunnels } from './edge-tunnels.js';
49
54
  export { adaptZitadelListApps } from './executor.js';
50
55
  export { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';
56
+ export { httpKeysRegistrar, keysRegistrarFromConfig, KEYS_PLATFORM_TOKEN_ENV, KEYS_SERVICE_URL_ENV, } from './keys-registrar-http.js';
51
57
  export { readPlatformVersionStatus, wtfalchVersionsFromPnpmLockfile, providersStatusCoolifyPort, ghCliStatusGithubPort, } from './version-status.js';
52
58
  export { COMPANY_DESTINATION_NAME_PREFIX, COMPANY_NETWORKS, DEFAULT_MAX_DESTINATIONS, DestinationCapacityError, PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, placed, placedOnPlatform, validateNetworkName, } from './destinations.js';
@@ -1,3 +1,5 @@
1
+ /** How long the scheduler may take to answer an admin call. */
2
+ const ADMIN_CALL_TIMEOUT_MS = 30_000;
1
3
  /**
2
4
  * #173: the real {@link JobsSchedulerPort}, a thin client over the jobs
3
5
  * scheduler's admin HTTP API (https://github.com/wtfalch/jobs README, "The
@@ -28,6 +30,7 @@ export class HttpJobsSchedulerClient {
28
30
  'content-type': 'application/json',
29
31
  },
30
32
  body: JSON.stringify(input),
33
+ signal: AbortSignal.timeout(ADMIN_CALL_TIMEOUT_MS),
31
34
  });
32
35
  const body = await response.json().catch(() => undefined);
33
36
  const fields = typeof body === 'object' && body !== null ? body : {};
@@ -0,0 +1,32 @@
1
+ import type { KeysRegistrarPort } from './keys-registrar.js';
2
+ /** The name of the host config that holds the keys service's platform bearer token. */
3
+ export declare const KEYS_PLATFORM_TOKEN_ENV = "KEYS_PLATFORM_TOKEN";
4
+ /** The name of the host config that holds the keys service's public address. */
5
+ export declare const KEYS_SERVICE_URL_ENV = "KEYS_SERVICE_URL";
6
+ /**
7
+ * F7: the real {@link KeysRegistrarPort}, over the keys service's platform route
8
+ * (`@wtfalch/keys` `platformRegistrarEndpoint`):
9
+ *
10
+ * - `POST <url>/v1/keys/platform/registrars` with `{ organisationId, name, expiresDays }`, 200 `{ id, secret }`.
11
+ * - `DELETE <url>/v1/keys/platform/registrars/<organisationId>/<id>`, any 200 is success (`revoked: false` is a key already gone).
12
+ * - `DELETE <url>/v1/keys/platform/registrars/<organisationId>`, 200 `{ revoked: number }`: every registrar key of the company.
13
+ *
14
+ * Both carry `Authorization: Bearer <platform token>`. An error carries the service's own plain `message` and
15
+ * the status, never the token, the secret or a response body that is not that message.
16
+ */
17
+ export declare function httpKeysRegistrar(options: {
18
+ url: string;
19
+ token: string;
20
+ fetch?: typeof fetch;
21
+ }): KeysRegistrarPort;
22
+ /**
23
+ * Builds the port from host config (`KEYS_SERVICE_URL`, `KEYS_PLATFORM_TOKEN`). When either is missing there is
24
+ * no port and `reason` names which, so the host can log why the plan reports Boule's registrar key blocked.
25
+ */
26
+ export declare function keysRegistrarFromConfig(config: Readonly<Record<string, string | undefined>>, fetchImpl?: typeof fetch): {
27
+ port: KeysRegistrarPort;
28
+ reason?: undefined;
29
+ } | {
30
+ port?: undefined;
31
+ reason: string;
32
+ };
@@ -0,0 +1,105 @@
1
+ /** The name of the host config that holds the keys service's platform bearer token. */
2
+ export const KEYS_PLATFORM_TOKEN_ENV = 'KEYS_PLATFORM_TOKEN';
3
+ /** The name of the host config that holds the keys service's public address. */
4
+ export const KEYS_SERVICE_URL_ENV = 'KEYS_SERVICE_URL';
5
+ /** The keys route accepts `[A-Za-z0-9_-]{1,128}` for the company id, the key name and the key id. */
6
+ const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
7
+ const CALL_TIMEOUT_MS = 30_000;
8
+ /**
9
+ * F7: the real {@link KeysRegistrarPort}, over the keys service's platform route
10
+ * (`@wtfalch/keys` `platformRegistrarEndpoint`):
11
+ *
12
+ * - `POST <url>/v1/keys/platform/registrars` with `{ organisationId, name, expiresDays }`, 200 `{ id, secret }`.
13
+ * - `DELETE <url>/v1/keys/platform/registrars/<organisationId>/<id>`, any 200 is success (`revoked: false` is a key already gone).
14
+ * - `DELETE <url>/v1/keys/platform/registrars/<organisationId>`, 200 `{ revoked: number }`: every registrar key of the company.
15
+ *
16
+ * Both carry `Authorization: Bearer <platform token>`. An error carries the service's own plain `message` and
17
+ * the status, never the token, the secret or a response body that is not that message.
18
+ */
19
+ export function httpKeysRegistrar(options) {
20
+ const url = options.url.replace(/\/+$/, '');
21
+ const fetchImpl = options.fetch ?? fetch;
22
+ const headers = { authorization: `Bearer ${options.token}` };
23
+ const refuse = async (what, response) => {
24
+ const body = await response.json().catch(() => undefined);
25
+ const message = typeof body === 'object' &&
26
+ body !== null &&
27
+ typeof body.message === 'string'
28
+ ? body.message
29
+ : 'the service gave no reason';
30
+ throw new Error(`the keys service refused to ${what} (status ${response.status}): ${message}`);
31
+ };
32
+ const call = async (what, path, init) => {
33
+ try {
34
+ return await fetchImpl(`${url}${path}`, {
35
+ ...init,
36
+ redirect: 'error',
37
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
38
+ });
39
+ }
40
+ catch {
41
+ throw new Error(`the keys service did not answer when asked to ${what}; check that it is running and try again`);
42
+ }
43
+ };
44
+ return {
45
+ url,
46
+ async mintCompanyKey({ organisationId, name, expiresDays }) {
47
+ if (!ID_PATTERN.test(organisationId)) {
48
+ throw new Error(`the registrar key was not requested: the company id "${organisationId}" must be 1 to 128 letters, digits, dashes or underscores`);
49
+ }
50
+ if (!ID_PATTERN.test(name)) {
51
+ throw new Error(`the registrar key was not requested: the key name "${name}" must be 1 to 128 letters, digits, dashes or underscores`);
52
+ }
53
+ const response = await call('mint the registrar key', '/v1/keys/platform/registrars', {
54
+ method: 'POST',
55
+ headers: { ...headers, 'content-type': 'application/json' },
56
+ body: JSON.stringify({ organisationId, name, expiresDays }),
57
+ });
58
+ if (response.status !== 200)
59
+ return refuse('mint the registrar key', response);
60
+ const body = await response.json().catch(() => undefined);
61
+ const fields = typeof body === 'object' && body !== null ? body : {};
62
+ if (typeof fields.id !== 'string' ||
63
+ fields.id.length === 0 ||
64
+ typeof fields.secret !== 'string' ||
65
+ fields.secret.length === 0) {
66
+ throw new Error('the keys service answered 200 to the registrar key request but sent no key id and secret');
67
+ }
68
+ return { id: fields.id, secret: fields.secret };
69
+ },
70
+ async revokeCompanyKey(organisationId, id) {
71
+ const response = await call('revoke the registrar key', `/v1/keys/platform/registrars/${encodeURIComponent(organisationId)}/${encodeURIComponent(id)}`, { method: 'DELETE', headers });
72
+ if (response.status !== 200)
73
+ await refuse('revoke the registrar key', response);
74
+ },
75
+ async revokeAllCompanyKeys(organisationId) {
76
+ if (!ID_PATTERN.test(organisationId)) {
77
+ throw new Error(`the registrar keys were not revoked: the company id "${organisationId}" must be 1 to 128 letters, digits, dashes or underscores`);
78
+ }
79
+ const response = await call('revoke the registrar keys', `/v1/keys/platform/registrars/${encodeURIComponent(organisationId)}`, { method: 'DELETE', headers });
80
+ if (response.status !== 200)
81
+ return refuse('revoke the registrar keys', response);
82
+ const body = await response.json().catch(() => undefined);
83
+ const revoked = typeof body === 'object' && body !== null
84
+ ? body.revoked
85
+ : undefined;
86
+ if (typeof revoked !== 'number' || !Number.isInteger(revoked) || revoked < 0) {
87
+ throw new Error('the keys service answered 200 to the revoke-all request but did not say how many keys it revoked');
88
+ }
89
+ return revoked;
90
+ },
91
+ };
92
+ }
93
+ /**
94
+ * Builds the port from host config (`KEYS_SERVICE_URL`, `KEYS_PLATFORM_TOKEN`). When either is missing there is
95
+ * no port and `reason` names which, so the host can log why the plan reports Boule's registrar key blocked.
96
+ */
97
+ export function keysRegistrarFromConfig(config, fetchImpl) {
98
+ const url = config[KEYS_SERVICE_URL_ENV];
99
+ const token = config[KEYS_PLATFORM_TOKEN_ENV];
100
+ if (!url)
101
+ return { reason: `no keys registrar port: ${KEYS_SERVICE_URL_ENV} is not set` };
102
+ if (!token)
103
+ return { reason: `no keys registrar port: ${KEYS_PLATFORM_TOKEN_ENV} is not set` };
104
+ return { port: httpKeysRegistrar({ url, token, ...(fetchImpl ? { fetch: fetchImpl } : {}) }) };
105
+ }
@@ -0,0 +1,67 @@
1
+ import { type DeclaredOrg } from '../org.js';
2
+ import type { DraftStep } from './diff.js';
3
+ /**
4
+ * F7 (#928): a company whose Boule runs apps (`org.boule.apps`) gets one registrar key at the signed-records
5
+ * (keys) service. Boule calls the service directly with it: `PUT` or `DELETE
6
+ * <KEYS_REGISTRAR_URL>/v1/keys/registrar/apps/<appName>` with `Authorization: Bearer <KEYS_REGISTRAR_KEY>`
7
+ * (`@wtfalch/keys/host` `registrarEndpoint`). The key is an ordinary credential of the keys issuer carrying the
8
+ * host's registrar grant; every app credential registered through it is its child, so it reaches only its own
9
+ * company's apps.
10
+ *
11
+ * Same shape as Boule's edge key (F3): one `coolify:keys-registrar` step mints the key through
12
+ * {@link KeysRegistrarPort}, records its id as undelivered and persists before anything is written, writes
13
+ * `KEYS_REGISTRAR_URL` and `KEYS_REGISTRAR_KEY` to Boule's env and restarts it. The key is held nowhere but
14
+ * Boule's env. A failed delivery is repaired by the next apply, which revokes the undelivered key and mints
15
+ * one. Mints once and never renews (the key lives {@link KEYS_REGISTRAR_KEY_DAYS} days; a row warns 30 days
16
+ * before). Turning the switch off revokes the key and takes both env rows away.
17
+ */
18
+ export declare const KEYS_REGISTRAR_URL_ENV = "KEYS_REGISTRAR_URL";
19
+ export declare const KEYS_REGISTRAR_KEY_ENV = "KEYS_REGISTRAR_KEY";
20
+ export declare const KEYS_REGISTRAR_KEY_DAYS = 365;
21
+ export declare const KEYS_REGISTRAR_WARN_DAYS = 30;
22
+ export declare const KEYS_REGISTRAR_DELIVERY_ATTEMPTS = 3;
23
+ /** The id of the live key (never the key). */
24
+ export declare const KEYS_REGISTRAR_SCOPE_KEY = "keys_registrar_id";
25
+ /** The id of a key minted and not yet delivered. */
26
+ export declare const KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY = "keys_registrar_undelivered";
27
+ /** ISO time of the mint, the only trace of the key's age. */
28
+ /**
29
+ * Set (and persisted) just before the mint call and cleared once the new key's id is recorded. Found set on a
30
+ * later run it means a mint whose answer was lost: the key may exist with no id on record, so every registrar
31
+ * key of the company is revoked before minting again.
32
+ */
33
+ export declare const KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY = "keys_registrar_mint_started";
34
+ export declare const KEYS_REGISTRAR_MINTED_AT_SCOPE_KEY = "keys_registrar_minted_at";
35
+ /** The service address last written to Boule. */
36
+ export declare const KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY = "keys_registrar_url_applied";
37
+ /** The keys service's per-company registrar, as the host wires it. */
38
+ export interface KeysRegistrarPort {
39
+ /** The public address of the service that serves the registrar route, delivered as `KEYS_REGISTRAR_URL`. */
40
+ readonly url: string;
41
+ /** Mints the company's registrar credential. The secret is shown once. */
42
+ mintCompanyKey(input: {
43
+ organisationId: string;
44
+ name: string;
45
+ expiresDays: number;
46
+ }): Promise<{
47
+ id: string;
48
+ secret: string;
49
+ }>;
50
+ /** Revokes it (and, by lineage, every app credential it registered). A key already gone is not an error. */
51
+ revokeCompanyKey(organisationId: string, id: string): Promise<void>;
52
+ /**
53
+ * Revokes every registrar key the company has, however many, and returns how many were revoked. For a key
54
+ * whose id was never recorded (a mint whose answer was lost), and for org destroy.
55
+ */
56
+ revokeAllCompanyKeys(organisationId: string): Promise<number>;
57
+ }
58
+ export declare const keysRegistrarName: (org: Pick<DeclaredOrg, "slug">) => string;
59
+ export declare function planKeysRegistrar(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], host: {
60
+ url?: string;
61
+ }, now?: number): {
62
+ steps: DraftStep[];
63
+ blocked: Array<{
64
+ description: string;
65
+ key: string;
66
+ }>;
67
+ };
@@ -0,0 +1,103 @@
1
+ import { appsOn } from '../org.js';
2
+ /**
3
+ * F7 (#928): a company whose Boule runs apps (`org.boule.apps`) gets one registrar key at the signed-records
4
+ * (keys) service. Boule calls the service directly with it: `PUT` or `DELETE
5
+ * <KEYS_REGISTRAR_URL>/v1/keys/registrar/apps/<appName>` with `Authorization: Bearer <KEYS_REGISTRAR_KEY>`
6
+ * (`@wtfalch/keys/host` `registrarEndpoint`). The key is an ordinary credential of the keys issuer carrying the
7
+ * host's registrar grant; every app credential registered through it is its child, so it reaches only its own
8
+ * company's apps.
9
+ *
10
+ * Same shape as Boule's edge key (F3): one `coolify:keys-registrar` step mints the key through
11
+ * {@link KeysRegistrarPort}, records its id as undelivered and persists before anything is written, writes
12
+ * `KEYS_REGISTRAR_URL` and `KEYS_REGISTRAR_KEY` to Boule's env and restarts it. The key is held nowhere but
13
+ * Boule's env. A failed delivery is repaired by the next apply, which revokes the undelivered key and mints
14
+ * one. Mints once and never renews (the key lives {@link KEYS_REGISTRAR_KEY_DAYS} days; a row warns 30 days
15
+ * before). Turning the switch off revokes the key and takes both env rows away.
16
+ */
17
+ export const KEYS_REGISTRAR_URL_ENV = 'KEYS_REGISTRAR_URL';
18
+ export const KEYS_REGISTRAR_KEY_ENV = 'KEYS_REGISTRAR_KEY';
19
+ export const KEYS_REGISTRAR_KEY_DAYS = 365;
20
+ export const KEYS_REGISTRAR_WARN_DAYS = 30;
21
+ export const KEYS_REGISTRAR_DELIVERY_ATTEMPTS = 3;
22
+ /** The id of the live key (never the key). */
23
+ export const KEYS_REGISTRAR_SCOPE_KEY = 'keys_registrar_id';
24
+ /** The id of a key minted and not yet delivered. */
25
+ export const KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY = 'keys_registrar_undelivered';
26
+ /** ISO time of the mint, the only trace of the key's age. */
27
+ /**
28
+ * Set (and persisted) just before the mint call and cleared once the new key's id is recorded. Found set on a
29
+ * later run it means a mint whose answer was lost: the key may exist with no id on record, so every registrar
30
+ * key of the company is revoked before minting again.
31
+ */
32
+ export const KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY = 'keys_registrar_mint_started';
33
+ export const KEYS_REGISTRAR_MINTED_AT_SCOPE_KEY = 'keys_registrar_minted_at';
34
+ /** The service address last written to Boule. */
35
+ export const KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY = 'keys_registrar_url_applied';
36
+ const DAY_MS = 24 * 60 * 60 * 1000;
37
+ export const keysRegistrarName = (org) => `${org.slug}-registrar`;
38
+ export function planKeysRegistrar(org, recordedIds, plannedSteps, host, now = Date.now()) {
39
+ const at = `${org.slug}'s signed-records registrar key`;
40
+ const key = `coolify:keys-registrar:${org.id}`;
41
+ const blocked = [];
42
+ const draft = (desired, description) => ({
43
+ action: { system: 'coolify', kind: 'keys-registrar', key: org.id, desired },
44
+ step: { description, destroys: desired.mode === 'retire', migrates: false },
45
+ });
46
+ const liveId = recordedIds[KEYS_REGISTRAR_SCOPE_KEY];
47
+ const leftover = recordedIds[KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY];
48
+ if (!appsOn(org)) {
49
+ if (!liveId && !leftover)
50
+ return { steps: [], blocked };
51
+ if (!host.url) {
52
+ blocked.push({
53
+ description: `${at} is not revoked, not planned: the host supplies no keys registrar port`,
54
+ key,
55
+ });
56
+ return { steps: [], blocked };
57
+ }
58
+ return {
59
+ steps: [
60
+ draft({ mode: 'retire' }, `revoke ${org.slug}'s signed-records registrar key and take it out of Boule's env (the company's Boule runs no apps)`),
61
+ ],
62
+ blocked,
63
+ };
64
+ }
65
+ if (!host.url) {
66
+ blocked.push({
67
+ description: `${at} is blocked, not planned: the host supplies no keys registrar port`,
68
+ key,
69
+ });
70
+ return { steps: [], blocked };
71
+ }
72
+ if (liveId) {
73
+ const mintedAt = Date.parse(recordedIds[KEYS_REGISTRAR_MINTED_AT_SCOPE_KEY] ?? '');
74
+ if (!Number.isNaN(mintedAt)) {
75
+ const left = Math.floor((mintedAt + KEYS_REGISTRAR_KEY_DAYS * DAY_MS - now) / DAY_MS);
76
+ if (left < KEYS_REGISTRAR_WARN_DAYS) {
77
+ blocked.push({
78
+ description: `warning: ${at} ${left < 0 ? 'has expired' : `expires in ${left} days`} and is never renewed: turn apps off and on again to make a new one`,
79
+ key,
80
+ });
81
+ }
82
+ }
83
+ if (recordedIds[KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY] !== host.url) {
84
+ return {
85
+ steps: [
86
+ draft({ mode: 'url', keysUrl: host.url }, `write ${org.slug}'s signed-records service address to Boule's env`),
87
+ ],
88
+ blocked,
89
+ };
90
+ }
91
+ return { steps: [], blocked };
92
+ }
93
+ const bouleRecorded = Boolean(recordedIds[`coolify:boule:${org.id}`]);
94
+ const boulePlanned = plannedSteps.some((p) => p.action.system === 'coolify' && p.action.kind === 'boule' && p.action.key === org.id);
95
+ if (!bouleRecorded && !boulePlanned)
96
+ return { steps: [], blocked };
97
+ return {
98
+ steps: [
99
+ draft({ mode: 'mint', name: keysRegistrarName(org), keysUrl: host.url }, `mint ${org.slug}'s signed-records registrar key, write KEYS_REGISTRAR_* to Boule's env, restart Boule`),
100
+ ],
101
+ blocked,
102
+ };
103
+ }
@@ -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>;