@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.
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 +47 -40
  181. package/dist/plan/worker-app-database.js +150 -152
  182. package/dist/plan/worker-app.d.ts +6 -65
  183. package/dist/plan/worker-app.js +29 -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 +6 -10
  213. package/dist/worker-app-config.js +22 -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,64 @@
1
+ import type { DeclaredOrg, HostingEntry } from '../org.js';
2
+ import type { DraftStep } from './diff.js';
3
+ import type { OrgPlatformReadiness } from './platform-org.js';
4
+ /**
5
+ * `uses.jobs`: the entry is a project of its own in a jobs scheduler: the owner company's (hq) for a company
6
+ * on shared services, the company's own for one that runs its own services. One `jobs:project` step
7
+ * per entry, keyed `<orgId>:<entry>`:
8
+ *
9
+ * 1. registers `<slug>-<entry>` with the scheduler (`POST /admin/projects`), which shows the signing secret
10
+ * and the sync key once;
11
+ * 2. holds both under their refs before anything else is written: the id is recorded as undelivered first,
12
+ * so a failure after the project exists never registers a second one;
13
+ * 3. writes `JOBS_SCHEDULER_URL` (the scheduler's public origin), `JOBS_SIGNING_SECRET` and `JOBS_SYNC_KEY`
14
+ * to the entry's env and restarts the app.
15
+ *
16
+ * The secret and the key reach the held keys and the entry's env and nothing else: not a plan, a log, an
17
+ * error or a recorded id. A workspace (stage) gets no project: its copy of the app is built without
18
+ * `JOBS_*` (`stage-env.ts`), so a stage never schedules or receives a live job.
19
+ *
20
+ * Nothing removes the project when the entry drops `uses.jobs`: the admin API can deregister, but the
21
+ * scheduler port has no such call yet, so the project and its held credentials stay until a person
22
+ * deregisters it.
23
+ */
24
+ type Blocked = Array<{
25
+ description: string;
26
+ key: string;
27
+ }>;
28
+ /** The project is registered, its credentials are not held yet: the id, never a credential. */
29
+ export declare const entryJobsUndeliveredScopeKey: (entryKey: string) => string;
30
+ /** What the entry's env was last written with (name, address, scheduler origin); a change replans the step. */
31
+ export declare const entryJobsAppliedScopeKey: (entryKey: string) => string;
32
+ /** The held-key refs of an entry's project: its signing secret and its sync key. */
33
+ export declare const entryJobsRefs: (orgId: string, entryKey: string) => {
34
+ signingSecret: string;
35
+ syncKey: string;
36
+ };
37
+ /** The scheduler's name for the entry's project. */
38
+ export declare const entryJobsProjectName: (org: Pick<DeclaredOrg, "slug">, entryKey: string) => string;
39
+ /**
40
+ * What the project is registered with and the env is written with, or `null` while the entry has no public
41
+ * address for the scheduler to deliver to (a workspace-only entry has none).
42
+ */
43
+ export declare function entryJobsWant(org: DeclaredOrg, entry: HostingEntry, schedulerUrl: string): {
44
+ name: string;
45
+ baseUrl: string;
46
+ schedulerUrl: string;
47
+ } | null;
48
+ /** The value `jobs_project_applied.<entry>` holds for a want. */
49
+ export declare const entryJobsWantMarker: (want: {
50
+ name: string;
51
+ baseUrl: string;
52
+ schedulerUrl: string;
53
+ }) => string;
54
+ /**
55
+ * One step per `uses.jobs` entry whose project is not registered and written yet, or whose address changed.
56
+ * Blocked, not planned (rule 2), while the scheduler's public route is not recorded, no scheduler is usable
57
+ * (`schedulerUsable`), the host cannot hold secrets, the entry's app is not recorded, the entry has no public
58
+ * address, or a previous registration's credentials were never held.
59
+ */
60
+ export declare function planEntryJobs(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], readiness: OrgPlatformReadiness, schedulerUsable: boolean): {
61
+ steps: DraftStep[];
62
+ blocked: Blocked;
63
+ };
64
+ export {};
@@ -0,0 +1,92 @@
1
+ import { entryHostnames } from './hostnames.js';
2
+ import { appStepKey } from './platform-diff.js';
3
+ /** The project is registered, its credentials are not held yet: the id, never a credential. */
4
+ export const entryJobsUndeliveredScopeKey = (entryKey) => `jobs_project_undelivered.${entryKey}`;
5
+ /** What the entry's env was last written with (name, address, scheduler origin); a change replans the step. */
6
+ export const entryJobsAppliedScopeKey = (entryKey) => `jobs_project_applied.${entryKey}`;
7
+ /** The held-key refs of an entry's project: its signing secret and its sync key. */
8
+ export const entryJobsRefs = (orgId, entryKey) => ({
9
+ signingSecret: `jobs:project:${orgId}:${entryKey}:signingSecret`,
10
+ syncKey: `jobs:project:${orgId}:${entryKey}:syncKey`,
11
+ });
12
+ /** The scheduler's name for the entry's project. */
13
+ export const entryJobsProjectName = (org, entryKey) => `${org.slug}-${entryKey}`;
14
+ /**
15
+ * What the project is registered with and the env is written with, or `null` while the entry has no public
16
+ * address for the scheduler to deliver to (a workspace-only entry has none).
17
+ */
18
+ export function entryJobsWant(org, entry, schedulerUrl) {
19
+ const host = entryHostnames(org, entry)[0];
20
+ if (!host)
21
+ return null;
22
+ return {
23
+ name: entryJobsProjectName(org, entry.key),
24
+ baseUrl: `https://${host}`,
25
+ schedulerUrl,
26
+ };
27
+ }
28
+ /** The value `jobs_project_applied.<entry>` holds for a want. */
29
+ export const entryJobsWantMarker = (want) => JSON.stringify([want.name, want.baseUrl, want.schedulerUrl]);
30
+ /**
31
+ * One step per `uses.jobs` entry whose project is not registered and written yet, or whose address changed.
32
+ * Blocked, not planned (rule 2), while the scheduler's public route is not recorded, no scheduler is usable
33
+ * (`schedulerUsable`), the host cannot hold secrets, the entry's app is not recorded, the entry has no public
34
+ * address, or a previous registration's credentials were never held.
35
+ */
36
+ export function planEntryJobs(org, recordedIds, plannedSteps, readiness, schedulerUsable) {
37
+ const steps = [];
38
+ const blocked = [];
39
+ if (!org.hosting.enabled)
40
+ return { steps, blocked };
41
+ const appPlanned = (stepKey) => plannedSteps.some((p) => p.action.system === 'coolify' && p.action.kind === 'app' && p.action.key === stepKey);
42
+ for (const entry of org.hosting.entries) {
43
+ if (!entry.uses?.jobs)
44
+ continue;
45
+ const stepKey = appStepKey(org, entry.key);
46
+ const key = `jobs:project:${stepKey}`;
47
+ const at = `${org.slug}'s ${entry.key} jobs`;
48
+ const block = (why) => blocked.push({ description: `${at} are blocked: ${why}`, key });
49
+ const undelivered = recordedIds[entryJobsUndeliveredScopeKey(entry.key)];
50
+ if (undelivered) {
51
+ block(`project ${undelivered} was registered but its credentials were never held, and the scheduler shows them once; deregister it (DELETE /admin/projects/${entryJobsProjectName(org, entry.key)}) and remove the recorded key ${entryJobsUndeliveredScopeKey(entry.key)}`);
52
+ continue;
53
+ }
54
+ if (!readiness.jobsPublicUrl) {
55
+ block("the jobs scheduler's public address is not recorded yet; the scheduler owner's jobs route must be applied first");
56
+ continue;
57
+ }
58
+ if (!schedulerUsable) {
59
+ block('there is no usable jobs scheduler (it is not applied, its admin token is not held, or the host supplies none)');
60
+ continue;
61
+ }
62
+ if (readiness.canHoldSecrets !== true) {
63
+ block("the host's held keys cannot hold the project's credentials");
64
+ continue;
65
+ }
66
+ if (!recordedIds[`coolify:app:${stepKey}`] && !appPlanned(stepKey)) {
67
+ block("the entry's app is not recorded yet");
68
+ continue;
69
+ }
70
+ const want = entryJobsWant(org, entry, readiness.jobsPublicUrl);
71
+ if (!want) {
72
+ block('the entry has no public address for the scheduler to deliver to');
73
+ continue;
74
+ }
75
+ if (recordedIds[entryJobsAppliedScopeKey(entry.key)] === entryJobsWantMarker(want))
76
+ continue;
77
+ steps.push({
78
+ action: {
79
+ system: 'jobs',
80
+ kind: 'project',
81
+ key: stepKey,
82
+ desired: { ...want },
83
+ },
84
+ step: {
85
+ description: `register ${org.slug}'s ${entry.key} as project ${want.name} in the jobs scheduler, hold its credentials, write JOBS_* env, restart the app`,
86
+ destroys: false,
87
+ migrates: false,
88
+ },
89
+ });
90
+ }
91
+ return { steps, blocked };
92
+ }
@@ -0,0 +1,55 @@
1
+ import { type GithubDeployKeysPort } from './github-deploy-keys.js';
2
+ /** Every call this module makes (GitHub, held keys, Coolify) is cut off after this long. */
3
+ export declare const REPO_KEY_CALL_TIMEOUT_MS = 30000;
4
+ export interface RepoKeyApplyDeps {
5
+ github: GithubDeployKeysPort;
6
+ heldKeys: {
7
+ resolve(ref: string): Promise<string | null | undefined>;
8
+ put(ref: string, value: string): Promise<void>;
9
+ };
10
+ coolify: {
11
+ bulkUpsertEnvs(appId: string, envs: Array<{
12
+ key: string;
13
+ value: string;
14
+ literal?: boolean;
15
+ multiline?: boolean;
16
+ }>): Promise<unknown>;
17
+ listEnvs(appId: string): Promise<Array<{
18
+ key: string;
19
+ uuid?: string;
20
+ is_preview?: boolean | undefined;
21
+ }>>;
22
+ deleteEnv(appId: string, envUuid: string): Promise<unknown>;
23
+ restart(appId: string): Promise<unknown>;
24
+ };
25
+ org: {
26
+ id: string;
27
+ slug: string;
28
+ };
29
+ entryKey: string;
30
+ /** The entry's Coolify app, when it is recorded. */
31
+ appId: string | undefined;
32
+ recorded: () => Readonly<Record<string, string>>;
33
+ record: (key: string, value: string) => void;
34
+ persist: () => Promise<void>;
35
+ /** Registers a value for redaction before anything else can see it. */
36
+ secret: (value: string) => void;
37
+ /** The installation of the company's GitHub App that owns `repo`, or undefined for a `wtfalch` repo. */
38
+ installationFor?: (repo: string) => number | undefined;
39
+ timeoutMs?: number;
40
+ }
41
+ /**
42
+ * Makes, holds and delivers the entry's deploy key (see `entry-repo-key.ts`). Errors are plain lines naming the
43
+ * repo, the key id or the ref: never a credential, a response body or a cause.
44
+ */
45
+ export declare function ensureRepoKey(deps: RepoKeyApplyDeps, want: {
46
+ repo: string;
47
+ access: 'read' | 'write';
48
+ installationId?: number;
49
+ }): Promise<void>;
50
+ /**
51
+ * Removes the entry's key: every key of its title on the recorded repo (the recorded id first), both env names, the
52
+ * held private half, then both records. Each part is safe to repeat, so a run that died halfway is finished by the
53
+ * next. The records go last: while one remains the plan plans this again.
54
+ */
55
+ export declare function removeRepoKey(deps: RepoKeyApplyDeps, repo: string): Promise<void>;
@@ -0,0 +1,185 @@
1
+ import { GIT_REPO_URL_ENV, GIT_SSH_KEY_ENV, parseRecordedRepoKey, repoGitUrl, repoKeyAppliedMarker, repoKeyAppliedScopeKey, repoKeyRef, repoKeyScopeKey, repoKeyTitle, } from './entry-repo-key.js';
2
+ import { DeployKeysBlocked, deployKeysBlockedLine, generateRepoKeyPair, publicKeyOfPrivate, sameSshKey, } from './github-deploy-keys.js';
3
+ /** Every call this module makes (GitHub, held keys, Coolify) is cut off after this long. */
4
+ export const REPO_KEY_CALL_TIMEOUT_MS = 30_000;
5
+ const isNotFound = (error) => error?.status === 404;
6
+ async function timed(work, ms) {
7
+ let timer;
8
+ try {
9
+ return await Promise.race([
10
+ work(),
11
+ new Promise((_, reject) => {
12
+ timer = setTimeout(() => reject(new Error('timeout')), ms);
13
+ }),
14
+ ]);
15
+ }
16
+ finally {
17
+ clearTimeout(timer);
18
+ }
19
+ }
20
+ /** The text of a private key's body, so a log that holds only the middle of a key is scrubbed as well. */
21
+ const secretsOf = (privateKey) => {
22
+ const body = privateKey
23
+ .replace(/-----(BEGIN|END) OPENSSH PRIVATE KEY-----/g, '')
24
+ .replace(/\s+/g, '');
25
+ return body ? [privateKey, privateKey.trim(), body] : [privateKey];
26
+ };
27
+ /**
28
+ * Makes, holds and delivers the entry's deploy key (see `entry-repo-key.ts`). Errors are plain lines naming the
29
+ * repo, the key id or the ref: never a credential, a response body or a cause.
30
+ */
31
+ export async function ensureRepoKey(deps, want) {
32
+ const at = `github:deploy-key for ${deps.org.slug} entry '${deps.entryKey}'`;
33
+ const ms = deps.timeoutMs ?? REPO_KEY_CALL_TIMEOUT_MS;
34
+ const target = {
35
+ repo: want.repo,
36
+ ...(want.installationId !== undefined ? { installationId: want.installationId } : {}),
37
+ };
38
+ const ref = repoKeyRef(deps.org.id, deps.entryKey);
39
+ const title = repoKeyTitle(deps.org, deps.entryKey);
40
+ const readOnly = want.access === 'read';
41
+ const github = (what, run, on = target) => callGithub(at, what, on, ms, run);
42
+ if (!deps.appId)
43
+ throw new Error(`${at}: the entry's app is not recorded yet`);
44
+ let held;
45
+ try {
46
+ held = await timed(() => deps.heldKeys.resolve(ref), ms);
47
+ }
48
+ catch {
49
+ throw new Error(`${at}: the held key ${ref} could not be read`);
50
+ }
51
+ if (held)
52
+ for (const s of secretsOf(held))
53
+ deps.secret(s);
54
+ const heldPub = held ? publicKeyOfPrivate(held) : null;
55
+ const keys = await github('listing deploy keys', () => deps.github.list(target));
56
+ const mine = keys.filter((k) => k.title === title);
57
+ let current = heldPub
58
+ ? mine.find((k) => sameSshKey(k.key, heldPub) && k.readOnly === readOnly)
59
+ : undefined;
60
+ let privateKey = held ?? '';
61
+ let publicKey = heldPub ?? '';
62
+ const previous = parseRecordedRepoKey(deps.recorded()[repoKeyScopeKey(deps.entryKey)]);
63
+ // A key on another repo stays recorded until it is deleted, so a run that dies in between still knows it.
64
+ const recordNow = previous === null || previous.repo === want.repo;
65
+ const recordKey = async (id) => {
66
+ deps.record(repoKeyScopeKey(deps.entryKey), JSON.stringify({ repo: want.repo, id, access: want.access, pub: publicKey }));
67
+ await deps.persist();
68
+ };
69
+ if (!current) {
70
+ const pair = generateRepoKeyPair();
71
+ for (const s of secretsOf(pair.privateKey))
72
+ deps.secret(s);
73
+ // The key is on GitHub before its private half is held. A failure between the two leaves a key nothing can use,
74
+ // which the next run finds by its title and deletes.
75
+ current = await github('making the deploy key', () => deps.github.create(target, { title, key: pair.publicKey, readOnly }));
76
+ try {
77
+ await timed(() => deps.heldKeys.put(ref, pair.privateKey), ms);
78
+ }
79
+ catch {
80
+ throw new Error(`${at}: deploy key ${current.id} was made on ${want.repo} but its private half could not be held at ${ref}; the next apply deletes that key and makes another`);
81
+ }
82
+ privateKey = pair.privateKey;
83
+ publicKey = pair.publicKey;
84
+ if (recordNow)
85
+ await recordKey(current.id);
86
+ }
87
+ else if (recordNow &&
88
+ (previous === null || previous.id !== current.id || previous.access !== want.access)) {
89
+ await recordKey(current.id);
90
+ }
91
+ try {
92
+ await timed(() => deps.coolify.bulkUpsertEnvs(deps.appId, [
93
+ { key: GIT_REPO_URL_ENV, value: repoGitUrl(want.repo), literal: true },
94
+ { key: GIT_SSH_KEY_ENV, value: privateKey, literal: true, multiline: true },
95
+ ]), ms);
96
+ await timed(() => deps.coolify.restart(deps.appId), ms);
97
+ }
98
+ catch {
99
+ // The error is dropped in case Coolify echoes the request body.
100
+ throw new Error(`${at}: deploy key ${current.id} is made and held but ${GIT_REPO_URL_ENV} and ${GIT_SSH_KEY_ENV} could not be written to the app's env; the next apply writes them again`);
101
+ }
102
+ // New first, old after: every other key of the title (an older access, a key a failed run made) goes now, and
103
+ // the recorded key of another repo.
104
+ for (const k of mine.filter((x) => x.id !== current?.id)) {
105
+ await github(`deleting deploy key ${k.id}`, () => deps.github.remove(target, k.id));
106
+ }
107
+ if (previous && previous.repo !== want.repo) {
108
+ const old = targetOf(deps, previous.repo);
109
+ await github(`deleting deploy key ${previous.id}`, () => deps.github.remove(old, previous.id), old);
110
+ }
111
+ if (!recordNow)
112
+ await recordKey(current.id);
113
+ deps.record(repoKeyAppliedScopeKey(deps.entryKey), repoKeyAppliedMarker(want.repo, want.access, current.id));
114
+ await deps.persist();
115
+ }
116
+ /**
117
+ * Removes the entry's key: every key of its title on the recorded repo (the recorded id first), both env names, the
118
+ * held private half, then both records. Each part is safe to repeat, so a run that died halfway is finished by the
119
+ * next. The records go last: while one remains the plan plans this again.
120
+ */
121
+ export async function removeRepoKey(deps, repo) {
122
+ const at = `github:deploy-key for ${deps.org.slug} entry '${deps.entryKey}'`;
123
+ const ms = deps.timeoutMs ?? REPO_KEY_CALL_TIMEOUT_MS;
124
+ const recordedKey = parseRecordedRepoKey(deps.recorded()[repoKeyScopeKey(deps.entryKey)]);
125
+ const where = recordedKey?.repo ?? repo;
126
+ const target = targetOf(deps, where);
127
+ const title = repoKeyTitle(deps.org, deps.entryKey);
128
+ const ref = repoKeyRef(deps.org.id, deps.entryKey);
129
+ if (recordedKey) {
130
+ await callGithub(at, `deleting deploy key ${recordedKey.id}`, target, ms, () => deps.github.remove(target, recordedKey.id));
131
+ }
132
+ const left = await callGithub(at, 'listing deploy keys', target, ms, () => deps.github.list(target));
133
+ for (const k of left.filter((x) => x.title === title)) {
134
+ await callGithub(at, `deleting deploy key ${k.id}`, target, ms, () => deps.github.remove(target, k.id));
135
+ }
136
+ if (deps.appId) {
137
+ let removedEnv = false;
138
+ try {
139
+ for (const row of await timed(() => deps.coolify.listEnvs(deps.appId), ms)) {
140
+ if ((row.key !== GIT_REPO_URL_ENV && row.key !== GIT_SSH_KEY_ENV) ||
141
+ row.is_preview ||
142
+ !row.uuid) {
143
+ continue;
144
+ }
145
+ try {
146
+ await timed(() => deps.coolify.deleteEnv(deps.appId, row.uuid), ms);
147
+ removedEnv = true;
148
+ }
149
+ catch (error) {
150
+ if (!isNotFound(error))
151
+ throw error;
152
+ }
153
+ }
154
+ if (removedEnv)
155
+ await timed(() => deps.coolify.restart(deps.appId), ms);
156
+ }
157
+ catch {
158
+ throw new Error(`${at}: the deploy key is deleted but ${GIT_REPO_URL_ENV} and ${GIT_SSH_KEY_ENV} could not be dropped from the app's env; the next apply drops them`);
159
+ }
160
+ }
161
+ try {
162
+ await timed(() => deps.heldKeys.put(ref, ''), ms);
163
+ }
164
+ catch {
165
+ throw new Error(`${at}: the held key ${ref} could not be forgotten; the next apply forgets it`);
166
+ }
167
+ deps.record(repoKeyScopeKey(deps.entryKey), '');
168
+ deps.record(repoKeyAppliedScopeKey(deps.entryKey), '');
169
+ await deps.persist();
170
+ }
171
+ function targetOf(deps, repo) {
172
+ const installationId = deps.installationFor?.(repo);
173
+ return installationId === undefined ? { repo } : { repo, installationId };
174
+ }
175
+ async function callGithub(at, what, target, ms, run) {
176
+ try {
177
+ return await timed(run, ms);
178
+ }
179
+ catch (error) {
180
+ if (error instanceof DeployKeysBlocked) {
181
+ throw new Error(`${at}: ${deployKeysBlockedLine(target, error.reason)}`);
182
+ }
183
+ throw new Error(`${at}: GitHub did not answer or refused while ${what} on ${target.repo}`);
184
+ }
185
+ }
@@ -0,0 +1,92 @@
1
+ import type { DeclaredOrg, HostingEntry } from '../org.js';
2
+ import type { DraftStep } from './diff.js';
3
+ import { type DeployKeyTarget, type GithubDeployKey } from './github-deploy-keys.js';
4
+ import type { OrgPlatformReadiness } from './platform-org.js';
5
+ /**
6
+ * `uses.repo` (F12): the entry's app gets credentials for its own `entry.repo`. One `github:deploy-key` step per
7
+ * entry, keyed `<orgId>:<entry>`:
8
+ *
9
+ * 1. makes an ed25519 keypair and registers the public half on GitHub as a deploy key titled
10
+ * `foundry <org slug> <entry key>`, read-only unless `access` is `write`;
11
+ * 2. holds the private half at {@link repoKeyRef} and records the key's GitHub id (`repo_key.<entry>`);
12
+ * 3. writes `GIT_REPO_URL` (`git@github.com:<owner>/<name>.git`) and `GIT_SSH_KEY` to the entry's env, restarts
13
+ * the app, records `repo_key_applied.<entry>` last;
14
+ * 4. deletes every other key of that title (an older key after a change of access, or a key a failed run made).
15
+ *
16
+ * Halfway failures, and what the next apply does: a key made on GitHub whose private half was not held is found by
17
+ * its title and deleted, and another is made; a key held and on GitHub but not in the env is written; an env written
18
+ * and an old key not yet deleted is finished by step 4. No step leaves a key that nothing uses once an apply has
19
+ * run to its end. Removing the entry or `uses.repo` plans the same step in removal form: delete the recorded key and
20
+ * every key of the title, drop both env names, forget the held value, clear both markers.
21
+ *
22
+ * The private key reaches the held keys and the app's env and nothing else: not a plan, a log, a note, an error or a
23
+ * recorded id. A workspace copy (stage) is given none.
24
+ *
25
+ * Blocked, not failing (a plan line each): GitHub cannot be asked (no client wired), the credential is not held or
26
+ * lacks "Administration: Read and write" (the line names it and says it is William's step), no held key store that
27
+ * can put, the app is not recorded yet.
28
+ */
29
+ export declare const GIT_REPO_URL_ENV = "GIT_REPO_URL";
30
+ export declare const GIT_SSH_KEY_ENV = "GIT_SSH_KEY";
31
+ /** What GitHub made for the entry: `{repo, id, access, pub}` as JSON. No secret. Cleared when the key is removed. */
32
+ export declare const repoKeyScopeKey: (entryKey: string) => string;
33
+ export declare const REPO_KEY_PREFIX = "repo_key.";
34
+ /** What the env was last written with (repo, access, key id); a change replans the step. */
35
+ export declare const repoKeyAppliedScopeKey: (entryKey: string) => string;
36
+ /** The held-key ref of an entry's private key. */
37
+ export declare const repoKeyRef: (orgId: string, entryKey: string) => string;
38
+ /** GitHub's title for the entry's key. */
39
+ export declare const repoKeyTitle: (org: Pick<DeclaredOrg, "slug">, entryKey: string) => string;
40
+ /** `git@github.com:<owner>/<name>.git`. */
41
+ export declare const repoGitUrl: (repo: string) => string;
42
+ export interface RecordedRepoKey {
43
+ repo: string;
44
+ id: number;
45
+ access: 'read' | 'write';
46
+ /** The public key line: not a secret. */
47
+ pub: string;
48
+ }
49
+ export declare function parseRecordedRepoKey(value: string | undefined): RecordedRepoKey | null;
50
+ export declare const repoKeyAppliedMarker: (repo: string, access: string, id: number) => string;
51
+ /** The repo and, under the company's installation, its id. */
52
+ export declare function repoKeyTarget(org: Pick<DeclaredOrg, 'hosting'>, repo: string): DeployKeyTarget;
53
+ /**
54
+ * The entries that declare `uses.repo` with a repo, in declared order. An entry that is not live (`live: false`)
55
+ * has no app, so it counts as none: a key it had is removed, and made again when it goes live.
56
+ */
57
+ export declare const repoKeyEntries: (org: Pick<DeclaredOrg, "hosting">) => HostingEntry[];
58
+ /** Entry keys that were given a key and no longer declare `uses.repo` (or are gone, or hosting is off). */
59
+ export declare function repoKeysToRemove(org: Pick<DeclaredOrg, 'hosting'>, recorded: Readonly<Record<string, string>>): string[];
60
+ /** What the plan learnt from GitHub about one repo, or why it could not ask. Lines only, never a credential. */
61
+ export type RepoKeyProbe = {
62
+ ok: true;
63
+ keys: readonly GithubDeployKey[];
64
+ } | {
65
+ ok: false;
66
+ line: string;
67
+ };
68
+ /** Asks GitHub for the repo's deploy keys; a refusal or silence becomes a plain line. Never throws. */
69
+ export declare function probeRepoKeys(port: {
70
+ list(target: DeployKeyTarget): Promise<GithubDeployKey[]>;
71
+ }, target: DeployKeyTarget): Promise<RepoKeyProbe>;
72
+ type Blocked = Array<{
73
+ description: string;
74
+ key: string;
75
+ }>;
76
+ /**
77
+ * One step per `uses.repo` entry whose key is not made, held and written yet (or is gone from GitHub, or has the
78
+ * wrong access), and one removal step per recorded key whose entry no longer declares it. Every other path says why
79
+ * in `blocked` (reported in `unmanaged`).
80
+ */
81
+ export declare function planEntryRepoKeys(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], readiness: OrgPlatformReadiness): {
82
+ steps: DraftStep[];
83
+ blocked: Blocked;
84
+ };
85
+ /** The entries whose repo GitHub should be asked about now: those that declare the flag and those with a key to remove. */
86
+ export declare function repoKeyProbeTargets(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): Array<{
87
+ entryKey: string;
88
+ target: DeployKeyTarget;
89
+ }>;
90
+ /** True when an entry of the org needs anything from GitHub: a key to make, check or remove. */
91
+ export declare const repoKeysInPlay: (org: DeclaredOrg, recorded: Readonly<Record<string, string>>) => boolean;
92
+ export {};