@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,434 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { pinnedRef } from '../bootstrap/versions.js';
3
+ import { ORG_POSTGRES_SERVER_SCOPE_KEY, appsOn } from '../org.js';
4
+ import { JobFailure, waitForJob } from './job-app.js';
5
+ import { orgPostgresServerName } from './org-postgres.js';
6
+ import { boundedCall, withCallTimeout } from './signin-db.js';
7
+ /**
8
+ * estate#210: a narrow role on the company's one Postgres server (`org-postgres.ts`) that Boule uses
9
+ * to create a database per app copy itself -- `LOGIN CREATEDB CREATEROLE` only, never superuser,
10
+ * never able to touch another role's data or database (estate#215, tested on Postgres 16.15). Its
11
+ * connection URL is the one credential Boule is handed; it never sees the server's own admin URL.
12
+ *
13
+ * This step does NOT touch CONNECT on any database. `org-postgres-job.ts` deliberately leaves CONNECT open
14
+ * on PUBLIC (a template app creates its own runtime role on first boot and relies on it), so a server-wide
15
+ * `REVOKE CONNECT ... FROM PUBLIC` here would lock out every existing home. Isolation between app databases
16
+ * is done by Boule instead (contract with the Boule builder): on each `app_*` database it creates, Boule
17
+ * revokes CONNECT from PUBLIC and grants it to that database's own role only. The admin role holds CONNECT
18
+ * on `postgres` alone.
19
+ *
20
+ * Wired by F4 (#925): the step is planned only for a company whose Boule runs apps (`appsOn`,
21
+ * `org.boule.apps`), after the company's Postgres server and Boule are recorded or planned. The URL is
22
+ * held at {@link appsAdminUrlRef} and written to Boule's env as `APPS_DATABASE_ADMIN_URL`. Turning apps
23
+ * off plans the retire form ({@link retireOrgAppsAdmin}): the env row goes, the held URL is blanked, and
24
+ * the role is set NOLOGIN and its sessions ended. Nothing is dropped: the role, the `app_*` databases
25
+ * and their roles stay, so turning apps on again mints a new password for the same role.
26
+ */
27
+ const NAME = /^[a-z][a-z0-9_]{0,62}$/;
28
+ function checked(name) {
29
+ if (!NAME.test(name))
30
+ throw new Error(`Postgres name is not valid: ${name}`);
31
+ return name;
32
+ }
33
+ /** The role's name on the server: the org's slug plus `_apps_admin`. */
34
+ export const appsAdminRoleName = (org) => `${org.slug}_apps_admin`;
35
+ /** The job app's name in Coolify. */
36
+ export const appsAdminJobName = (org) => `${org.slug}-apps-admin-job`;
37
+ /** Recorded once the role is made, its password set, and the URL delivered. */
38
+ export const ORG_APPS_ADMIN_SCOPE_KEY = 'org_apps_admin';
39
+ /**
40
+ * An opaque id, set once the password is minted and the job that sets it is about to run, until the URL is
41
+ * delivered. Never the password. A failure before that point leaves no id, so the next apply tries again.
42
+ */
43
+ export const ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY = 'org_apps_admin_undelivered';
44
+ /** The job app's id while it exists. */
45
+ export const ORG_APPS_ADMIN_JOB_APP_SCOPE_KEY = 'org_apps_admin_job_app';
46
+ /** Where the host holds the role's connection URL, once delivered -- `deliverSecret`'s own naming convention. */
47
+ export const appsAdminUrlRef = (org) => `org-apps-admin:${org.slug}:adminUrl`;
48
+ /** How many times the URL is handed to `deliverSecret`. */
49
+ export const ORG_APPS_ADMIN_DELIVERY_ATTEMPTS = 3;
50
+ /**
51
+ * Pure. Plans `coolify:org-apps-admin` once, after the company's Postgres server is recorded
52
+ * (platform-step rule 2: blocked, not failing, while it is not). A lost mint -- an undelivered id
53
+ * with no done guard -- is reported blocked and never minted again (rule 1); the operator clears the
54
+ * recorded id once the role is confirmed gone from the server.
55
+ */
56
+ export function planOrgAppsAdminStep(org, recordedIds, options = {}) {
57
+ if (recordedIds[ORG_APPS_ADMIN_SCOPE_KEY])
58
+ return { step: null, blocked: null };
59
+ if (!recordedIds[ORG_POSTGRES_SERVER_SCOPE_KEY]) {
60
+ return {
61
+ step: null,
62
+ blocked: `${org.slug}'s apps admin role is blocked, not planned: its Postgres server is not provisioned yet`,
63
+ };
64
+ }
65
+ const lost = recordedIds[ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY];
66
+ if (lost) {
67
+ return {
68
+ step: null,
69
+ blocked: `${org.slug}'s apps admin role (note ${lost}): a run set a new password on the role but its connection URL was never saved. Nothing is lost. To start over, turn apps off and apply (that retires the role and clears this note), then turn apps on again`,
70
+ };
71
+ }
72
+ if (options.canHoldSecrets === false) {
73
+ return {
74
+ step: null,
75
+ blocked: `${org.slug}'s apps admin role is blocked, not planned: the host cannot hold secrets`,
76
+ };
77
+ }
78
+ return {
79
+ step: {
80
+ action: { system: 'coolify', kind: 'org-apps-admin', key: org.id, desired: {} },
81
+ step: {
82
+ description: `mint ${org.slug}'s database admin role (${appsAdminRoleName(org)}); it can create databases and roles but touches no existing database`,
83
+ destroys: false,
84
+ migrates: false,
85
+ },
86
+ },
87
+ blocked: null,
88
+ };
89
+ }
90
+ /**
91
+ * Pure. F4 (#925): the wiring of {@link planOrgAppsAdminStep} to `org.boule.apps`. Apps on: the mint
92
+ * step, once Boule (which receives the URL) and the Postgres server are recorded or planned. Apps off with
93
+ * the role recorded (or a mint never delivered): the retire step, which needs no secret and drops nothing.
94
+ */
95
+ export function planAppsAdmin(org, recordedIds, plannedSteps, options) {
96
+ const key = `coolify:org-apps-admin:${org.id}`;
97
+ const blocked = [];
98
+ if (!appsOn(org)) {
99
+ if (!recordedIds[ORG_APPS_ADMIN_SCOPE_KEY] &&
100
+ !recordedIds[ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY]) {
101
+ return { steps: [], blocked };
102
+ }
103
+ if (!recordedIds[ORG_POSTGRES_SERVER_SCOPE_KEY]) {
104
+ blocked.push({
105
+ description: `${org.slug}'s apps admin role is not retired, not planned: its Postgres server is not recorded`,
106
+ key,
107
+ });
108
+ return { steps: [], blocked };
109
+ }
110
+ return {
111
+ steps: [
112
+ {
113
+ action: {
114
+ system: 'coolify',
115
+ kind: 'org-apps-admin',
116
+ key: org.id,
117
+ desired: { mode: 'retire' },
118
+ },
119
+ step: {
120
+ description: `retire ${org.slug}'s database admin role (${appsAdminRoleName(org)}): no login, out of Boule's env; no database is dropped`,
121
+ destroys: true,
122
+ migrates: false,
123
+ },
124
+ },
125
+ ],
126
+ blocked,
127
+ };
128
+ }
129
+ const bouleReady = Boolean(recordedIds[`coolify:boule:${org.id}`]) ||
130
+ plannedSteps.some((p) => p.action.system === 'coolify' && p.action.kind === 'boule' && p.action.key === org.id);
131
+ if (!bouleReady && !recordedIds[ORG_APPS_ADMIN_SCOPE_KEY]) {
132
+ blocked.push({
133
+ description: `${org.slug}'s apps admin role is blocked, not planned: Boule is not recorded yet`,
134
+ key,
135
+ });
136
+ return { steps: [], blocked };
137
+ }
138
+ const seen = options.postgresPlanned && !recordedIds[ORG_POSTGRES_SERVER_SCOPE_KEY]
139
+ ? { ...recordedIds, [ORG_POSTGRES_SERVER_SCOPE_KEY]: 'planned' }
140
+ : recordedIds;
141
+ const planned = planOrgAppsAdminStep(org, seen, { canHoldSecrets: options.canHoldSecrets });
142
+ if (planned.blocked)
143
+ blocked.push({ description: planned.blocked, key });
144
+ return { steps: planned.step ? [planned.step] : [], blocked };
145
+ }
146
+ /**
147
+ * The job's script: makes the role (idempotent, kept if it already exists), sets its password and grants
148
+ * it CONNECT on `postgres`. It revokes nothing from PUBLIC (see the file comment). Roles are made by
149
+ * `format('%I')` and the password by `format('%L')`, so no value is spliced into SQL by the shell. Setting
150
+ * 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
151
+ * error line names neither.
152
+ */
153
+ export function orgAppsAdminScript(role, mode = 'mint') {
154
+ const owner = checked(role);
155
+ if (mode === 'retire')
156
+ return orgAppsAdminRetireScript(owner);
157
+ const JOB_DONE_FILE = '/tmp/done';
158
+ const JOB_FAILED_FILE = '/tmp/failed';
159
+ return [
160
+ '#!/bin/sh',
161
+ 'set -eu',
162
+ `trap '[ -f ${JOB_DONE_FILE} ] || touch ${JOB_FAILED_FILE}' EXIT`,
163
+ "trap 'exit 1' HUP INT TERM",
164
+ `[ ! -f ${JOB_FAILED_FILE} ] || exit 1`,
165
+ `rm -f ${JOB_DONE_FILE}`,
166
+ 'fail() { echo "org-apps-admin: $1 failed"; exit 1; }',
167
+ '[ -n "${PW:-}" ] || fail "role"',
168
+ 'psql "$ADMIN_DATABASE_URL" -X -q -v ON_ERROR_STOP=1 > /dev/null 2>&1 <<\'SQL\' || fail "role"',
169
+ `\\set role '${owner}'`,
170
+ '\\getenv pw PW',
171
+ "SELECT format('CREATE ROLE %I LOGIN CREATEDB CREATEROLE', :'role') WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'role') \\gexec",
172
+ "SELECT format('ALTER ROLE %I LOGIN CREATEDB CREATEROLE PASSWORD %L', :'role', :'pw') \\gexec",
173
+ "SELECT format('GRANT CONNECT ON DATABASE postgres TO %I', :'role') \\gexec",
174
+ 'SQL',
175
+ 'echo "org-apps-admin: role ready"',
176
+ `touch ${JOB_DONE_FILE}`,
177
+ 'exec sleep infinity',
178
+ ];
179
+ }
180
+ /**
181
+ * The retire script: the role stops being able to log in and its open sessions end. It drops nothing and
182
+ * grants nothing; CONNECT stays closed to PUBLIC. A role that is not there is left alone.
183
+ */
184
+ function orgAppsAdminRetireScript(owner) {
185
+ const JOB_DONE_FILE = '/tmp/done';
186
+ const JOB_FAILED_FILE = '/tmp/failed';
187
+ return [
188
+ '#!/bin/sh',
189
+ 'set -eu',
190
+ `trap '[ -f ${JOB_DONE_FILE} ] || touch ${JOB_FAILED_FILE}' EXIT`,
191
+ "trap 'exit 1' HUP INT TERM",
192
+ `[ ! -f ${JOB_FAILED_FILE} ] || exit 1`,
193
+ `rm -f ${JOB_DONE_FILE}`,
194
+ 'fail() { echo "org-apps-admin: $1 failed"; exit 1; }',
195
+ 'psql "$ADMIN_DATABASE_URL" -X -q -v ON_ERROR_STOP=1 > /dev/null 2>&1 <<\'SQL\' || fail "retire"',
196
+ `\\set role '${owner}'`,
197
+ "SELECT format('ALTER ROLE %I NOLOGIN', :'role') WHERE EXISTS (SELECT FROM pg_roles WHERE rolname = :'role') \\gexec",
198
+ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = :'role'",
199
+ 'SQL',
200
+ 'echo "org-apps-admin: role retired"',
201
+ `touch ${JOB_DONE_FILE}`,
202
+ 'exec sleep infinity',
203
+ ];
204
+ }
205
+ /** The job: the same pinned image as the other Postgres jobs, so `psql` matches the server's major version. */
206
+ export function orgAppsAdminDockerfile(role, mode = 'mint') {
207
+ const encoded = Buffer.from(`${orgAppsAdminScript(role, mode).join('\n')}\n`, 'utf8').toString('base64');
208
+ return [
209
+ `FROM postgres:${pinnedRef('postgres')}`,
210
+ `RUN echo '${encoded}' | base64 -d > /usr/local/bin/org-apps-admin && chmod 0755 /usr/local/bin/org-apps-admin`,
211
+ 'ENTRYPOINT ["/usr/local/bin/org-apps-admin"]',
212
+ `HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=1 CMD ["test", "-f", "/tmp/done"]`,
213
+ ].join('\n');
214
+ }
215
+ const mint = () => randomBytes(24).toString('base64url');
216
+ const mintOpaque = () => randomBytes(12).toString('hex');
217
+ /**
218
+ * Makes the role true and delivers its connection URL. The password is minted in memory. The opaque id
219
+ * {@link ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY} is recorded and persisted only once the job that sets the
220
+ * password is about to be deployed ("password minted and role touched"); a failure before that leaves no
221
+ * id and the next apply simply retries, since setting a password again is harmless. The done guard is
222
+ * recorded only once the URL is delivered. A run that dies between the two finds the id and stops (the
223
+ * planner reports it blocked); retiring the role (apps off) clears it.
224
+ */
225
+ export async function provisionOrgAppsAdmin(scope, deps, org) {
226
+ const label = `${org.slug}'s apps admin role`;
227
+ const coolify = withCallTimeout(deps.coolify);
228
+ const recorded = scope.recordedIds();
229
+ if (recorded[ORG_APPS_ADMIN_SCOPE_KEY])
230
+ return;
231
+ const serverId = recorded[ORG_POSTGRES_SERVER_SCOPE_KEY];
232
+ if (!serverId)
233
+ throw new Error(`${label}: the Postgres server is not recorded yet`);
234
+ if (recorded[ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY]) {
235
+ throw new Error(`${label}: an earlier run set a new password on the role but never saved its connection URL. Nothing is lost. To start over, turn apps off and apply (that retires the role and clears this note), then turn apps on again`);
236
+ }
237
+ const onSecret = (value) => deps.onSecret?.(value);
238
+ const server = await boundedCall(deps.startDatabase(serverId), 'Coolify startDatabase');
239
+ if (!server.internal_db_url || !URL.canParse(server.internal_db_url)) {
240
+ throw new Error(`${label}: the Postgres server has no usable internal URL`);
241
+ }
242
+ const adminUrl = server.internal_db_url;
243
+ onSecret(adminUrl);
244
+ const adminParsed = new URL(adminUrl);
245
+ const host = adminParsed.hostname;
246
+ const port = adminParsed.port || '5432';
247
+ if (!host)
248
+ throw new Error(`${label}: the Postgres server's internal URL has no host`);
249
+ const adminPassword = decodeURIComponent(adminParsed.password);
250
+ if (adminPassword)
251
+ onSecret(adminPassword);
252
+ const role = appsAdminRoleName(org);
253
+ const password = (deps.mintPassword ?? mint)();
254
+ onSecret(password);
255
+ const jobName = appsAdminJobName(org);
256
+ let jobAppId;
257
+ try {
258
+ const projectUuid = await deps.ensureProject();
259
+ const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
260
+ const leftover = await coolify.findApplicationByName(jobName, {
261
+ environmentIds: [environment.id],
262
+ });
263
+ if (leftover)
264
+ await coolify.raw('DELETE', `/applications/${leftover.uuid}`);
265
+ const created = await coolify.createDockerfileApplication({
266
+ project_uuid: projectUuid,
267
+ server_uuid: await deps.serverUuid(),
268
+ environment_name: environment.name,
269
+ environment_uuid: environment.uuid,
270
+ dockerfile: orgAppsAdminDockerfile(role),
271
+ name: jobName,
272
+ description: `${org.slug}'s apps admin role job`,
273
+ instant_deploy: false,
274
+ autogenerate_domain: false,
275
+ });
276
+ jobAppId = created.uuid;
277
+ scope.recordScopeId(ORG_APPS_ADMIN_JOB_APP_SCOPE_KEY, jobAppId);
278
+ await scope.persist();
279
+ await coolify.updateApplication(jobAppId, { health_check_enabled: false });
280
+ await coolify.bulkUpsertEnvs(jobAppId, [
281
+ { key: 'ADMIN_DATABASE_URL', value: adminUrl },
282
+ { key: 'PW', value: password },
283
+ ]);
284
+ // From here the job can set the password: the marker is recorded now, not earlier.
285
+ scope.recordScopeId(ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY, (deps.mintId ?? mintOpaque)());
286
+ await scope.persist();
287
+ await coolify.deploy(jobAppId, true);
288
+ await waitForJob(coolify, jobAppId, deps.runtime ?? {}, {
289
+ job: 'the apps admin job',
290
+ stopped: 'the apps admin job stopped before it finished',
291
+ });
292
+ }
293
+ catch (error) {
294
+ const why = error instanceof JobFailure ? error.message : 'a Coolify call failed or timed out';
295
+ let cleaned = '';
296
+ if (jobAppId) {
297
+ const id = jobAppId;
298
+ try {
299
+ await boundedCall(coolify.raw('POST', `/applications/${id}/stop`), 'stop the job');
300
+ }
301
+ catch {
302
+ cleaned += '; the job app could not be stopped, stop it in Coolify';
303
+ }
304
+ try {
305
+ await boundedCall(coolify.bulkUpsertEnvs(id, [
306
+ { key: 'ADMIN_DATABASE_URL', value: '' },
307
+ { key: 'PW', value: '' },
308
+ ]), 'clear the job env');
309
+ }
310
+ catch {
311
+ cleaned +=
312
+ '; its env could not be cleared, delete the app in Coolify (it holds the admin URL)';
313
+ }
314
+ }
315
+ throw new Error(`${label}: ${why}${jobAppId ? `; the job app ${jobAppId} is left stopped without secrets for inspection and the next apply replaces it${cleaned}` : ''}; apply again`);
316
+ }
317
+ try {
318
+ await coolify.raw('DELETE', `/applications/${jobAppId}`);
319
+ }
320
+ catch {
321
+ throw new Error(`${label}: the role was made, but its job app ${jobAppId} could not be deleted; delete it in Coolify (it holds the admin URL), then apply again`);
322
+ }
323
+ const url = `postgres://${encodeURIComponent(role)}:${encodeURIComponent(password)}@${host}:${port}/postgres`;
324
+ onSecret(url);
325
+ let delivered = false;
326
+ for (let attempt = 1; attempt <= ORG_APPS_ADMIN_DELIVERY_ATTEMPTS && !delivered; attempt++) {
327
+ try {
328
+ await boundedCall(deps.deliverSecret(appsAdminUrlRef(org), url), 'deliverSecret');
329
+ delivered = true;
330
+ }
331
+ catch {
332
+ // Retried; the cause may echo the secret and is dropped.
333
+ }
334
+ }
335
+ if (!delivered) {
336
+ throw new Error(`${label}: the role was made, but its connection URL could not be saved after ${ORG_APPS_ADMIN_DELIVERY_ATTEMPTS} attempts. Nothing is lost. Turn apps off and apply (that retires the role and clears the note), then turn apps on again`);
337
+ }
338
+ scope.recordScopeId(ORG_APPS_ADMIN_SCOPE_KEY, orgPostgresServerName(org));
339
+ scope.recordScopeId(ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY, '');
340
+ await scope.persist();
341
+ scope.recordScopeId(ORG_APPS_ADMIN_JOB_APP_SCOPE_KEY, '');
342
+ }
343
+ /**
344
+ * Apps went off (F4, #925): the role stops logging in, the URL leaves Boule's env and the held store,
345
+ * and the recorded ids are cleared last so a failed run repeats. Never drops the role, a database or
346
+ * a role an app made (CLAUDE.md: never drop data); turning apps on again mints a new password for the
347
+ * same role. A recorded mint that was never delivered is retired the same way, which is also how an
348
+ * operator unblocks a lost mint. The job needs the server's admin URL and nothing else secret.
349
+ */
350
+ export async function retireOrgAppsAdmin(scope, deps, org) {
351
+ const label = `${org.slug}'s apps admin role`;
352
+ const coolify = withCallTimeout(deps.coolify);
353
+ const recorded = scope.recordedIds();
354
+ const serverId = recorded[ORG_POSTGRES_SERVER_SCOPE_KEY];
355
+ const hadRole = Boolean(recorded[ORG_APPS_ADMIN_SCOPE_KEY] || recorded[ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY]);
356
+ if (hadRole) {
357
+ if (!serverId)
358
+ throw new Error(`${label}: the Postgres server is not recorded; cannot retire it`);
359
+ const onSecret = (value) => deps.onSecret?.(value);
360
+ const server = await boundedCall(deps.startDatabase(serverId), 'Coolify startDatabase');
361
+ if (!server.internal_db_url || !URL.canParse(server.internal_db_url)) {
362
+ throw new Error(`${label}: the Postgres server has no usable internal URL`);
363
+ }
364
+ onSecret(server.internal_db_url);
365
+ const password = decodeURIComponent(new URL(server.internal_db_url).password);
366
+ if (password)
367
+ onSecret(password);
368
+ const jobName = appsAdminJobName(org);
369
+ let jobAppId;
370
+ try {
371
+ const projectUuid = await deps.ensureProject();
372
+ const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
373
+ const leftover = await coolify.findApplicationByName(jobName, {
374
+ environmentIds: [environment.id],
375
+ });
376
+ if (leftover)
377
+ await coolify.raw('DELETE', `/applications/${leftover.uuid}`);
378
+ const created = await coolify.createDockerfileApplication({
379
+ project_uuid: projectUuid,
380
+ server_uuid: await deps.serverUuid(),
381
+ environment_name: environment.name,
382
+ environment_uuid: environment.uuid,
383
+ dockerfile: orgAppsAdminDockerfile(appsAdminRoleName(org), 'retire'),
384
+ name: jobName,
385
+ description: `${org.slug}'s apps admin role retire job`,
386
+ instant_deploy: false,
387
+ autogenerate_domain: false,
388
+ });
389
+ jobAppId = created.uuid;
390
+ scope.recordScopeId(ORG_APPS_ADMIN_JOB_APP_SCOPE_KEY, jobAppId);
391
+ await scope.persist();
392
+ await coolify.updateApplication(jobAppId, { health_check_enabled: false });
393
+ await coolify.bulkUpsertEnvs(jobAppId, [
394
+ { key: 'ADMIN_DATABASE_URL', value: server.internal_db_url },
395
+ ]);
396
+ await coolify.deploy(jobAppId, true);
397
+ await waitForJob(coolify, jobAppId, deps.runtime ?? {}, {
398
+ job: 'the apps admin retire job',
399
+ stopped: 'the apps admin retire job stopped before it finished',
400
+ });
401
+ }
402
+ catch (error) {
403
+ const why = error instanceof JobFailure ? error.message : 'a Coolify call failed or timed out';
404
+ let cleaned = '';
405
+ if (jobAppId) {
406
+ const id = jobAppId;
407
+ try {
408
+ await boundedCall(coolify.raw('POST', `/applications/${id}/stop`), 'stop the job');
409
+ await boundedCall(coolify.bulkUpsertEnvs(id, [{ key: 'ADMIN_DATABASE_URL', value: '' }]), 'clear the job env');
410
+ }
411
+ catch {
412
+ cleaned = '; delete the job app in Coolify (it holds the admin URL)';
413
+ }
414
+ }
415
+ throw new Error(`${label}: ${why}${cleaned}; apply again`);
416
+ }
417
+ try {
418
+ await coolify.raw('DELETE', `/applications/${jobAppId}`);
419
+ }
420
+ catch {
421
+ throw new Error(`${label}: the role was retired, but its job app ${jobAppId} could not be deleted; delete it in Coolify (it holds the admin URL), then apply again`);
422
+ }
423
+ }
424
+ await deps.removeBouleEnv();
425
+ await deps.clearHeldUrl(appsAdminUrlRef(org));
426
+ for (const key of [
427
+ ORG_APPS_ADMIN_SCOPE_KEY,
428
+ ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY,
429
+ ORG_APPS_ADMIN_JOB_APP_SCOPE_KEY,
430
+ ]) {
431
+ scope.recordScopeId(key, '');
432
+ }
433
+ await scope.persist();
434
+ }
@@ -1,7 +1,7 @@
1
1
  import { ownsServices } from '../org.js';
2
2
  import { entryHasDatabase } from '../worker-app-config.js';
3
3
  import { chatInternalNames, chatRekeyedInternalNames } from './chat-matrix.js';
4
- import { workerAppHostname } from './hostnames.js';
4
+ import { workerAppHostname, workspaceHost, workspaceInboxHost } from './hostnames.js';
5
5
  import { orgTarget, platformTarget } from './own-services.js';
6
6
  import { appNameFor } from './platform-diff.js';
7
7
  /**
@@ -61,6 +61,17 @@ export function orgDerivedNames(org) {
61
61
  if (org.hosting.entries.some(entryHasDatabase)) {
62
62
  names.push({ name: `${org.slug}-db`, source: 'hosting' });
63
63
  }
64
+ // A workspace's host and the test inbox's: one label under one zone, so it is compared across orgs like the names above.
65
+ if (org.hosting.enabled) {
66
+ for (const entry of org.hosting.entries) {
67
+ if (entry.workspace) {
68
+ names.push({ name: workspaceHost(org.slug, entry.key), source: 'hosting' });
69
+ }
70
+ }
71
+ if (org.hosting.entries.some((e) => e.workspace && !e.workerApp)) {
72
+ names.push({ name: workspaceInboxHost(org.slug), source: 'hosting' });
73
+ }
74
+ }
64
75
  return names;
65
76
  }
66
77
  /** Names the platform's own steps give apps, tunnels, connectors, databases and projects outside `platformTarget()`. */
@@ -24,6 +24,29 @@ export declare function planOwnJobs(options: PlanOwnJobsOptions): {
24
24
  step: DraftStep | null;
25
25
  blocked: string | null;
26
26
  };
27
+ /** `unmanaged` key of the one line `planOwnJobsRoute` reports. */
28
+ export declare const OWN_JOBS_ROUTE_BLOCKED_KEY = "own-jobs-route";
29
+ export interface PlanOwnJobsRouteOptions {
30
+ org: DeclaredOrg;
31
+ recordedIds: Readonly<Record<string, string>>;
32
+ readiness: OrgPlatformReadiness;
33
+ /** The executor was built with an `ownServices` port. */
34
+ ownServices: boolean;
35
+ /** The org's zone is live and active: none is planned and none is waiting. */
36
+ zoneActive: boolean;
37
+ /** The org's own scheduler step is planned in this same plan, so its address is recorded by the time the route runs. */
38
+ schedulerPlanned: boolean;
39
+ }
40
+ /**
41
+ * The public route of an own org's jobs scheduler: `jobs.<domain>`, serving only what an app calls. The
42
+ * owner company always publishes its scheduler, since every company on shared services registers there; any
43
+ * other own org publishes its own only while an entry declares `uses.jobs`. Planned again when the hostname
44
+ * moved. Every blocked line is fixed text: it is part of the stale-plan comparison.
45
+ */
46
+ export declare function planOwnJobsRoute(options: PlanOwnJobsRouteOptions): {
47
+ step: DraftStep | null;
48
+ blocked: string | null;
49
+ };
27
50
  /** `unmanaged` key of the one line `planOwnAuth` reports. */
28
51
  export declare const OWN_AUTH_BLOCKED_KEY = "own-auth";
29
52
  export { OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY };
@@ -8,6 +8,7 @@ import { OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, ownAuthApplied } from './login-hos
8
8
  import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_BASE, OWN_MAIL_SMTP_PORT_SCOPE_KEY, isOwnMailSmtpPort, } from './mail-relay.js';
9
9
  import { mailStoreIsLegacy, mailStoreSwitchDescription } from './mail-store.js';
10
10
  import { ownHostnames } from './own-hostnames.js';
11
+ import { JOBS_PUBLIC_URL_SCOPE_KEY, OWN_JOBS_ROUTE_TUNNEL_SCOPE_KEY } from './own-jobs-route.js';
11
12
  import { OWN_FILES_R2_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, orgTarget, } from './own-services.js';
12
13
  import { OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY, OWN_SFU_APPLIED_NODE_IP_SCOPE_KEY, OWN_SFU_APPLIED_SLOT_SCOPE_KEY, OWN_SFU_APPLIED_VERSION_SCOPE_KEY, OWN_SFU_DEPLOY_PENDING_SCOPE_KEY, OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY, SFU_INTERNAL_URL_SCOPE_KEY, SFU_PUBLIC_URL_SCOPE_KEY, } from './own-sfu.js';
13
14
  import { AUTH_TUNNEL_ROUTES_VERSION, AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_MCP_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, orgDeclaresMcp, platformMailMxHost, } from './platform-diff.js';
@@ -67,6 +68,58 @@ export function planOwnJobs(options) {
67
68
  },
68
69
  };
69
70
  }
71
+ /** `unmanaged` key of the one line `planOwnJobsRoute` reports. */
72
+ export const OWN_JOBS_ROUTE_BLOCKED_KEY = 'own-jobs-route';
73
+ /**
74
+ * The public route of an own org's jobs scheduler: `jobs.<domain>`, serving only what an app calls. The
75
+ * owner company always publishes its scheduler, since every company on shared services registers there; any
76
+ * other own org publishes its own only while an entry declares `uses.jobs`. Planned again when the hostname
77
+ * moved. Every blocked line is fixed text: it is part of the stale-plan comparison.
78
+ */
79
+ export function planOwnJobsRoute(options) {
80
+ const { org, recordedIds, readiness, ownServices } = options;
81
+ if (!ownsServices(org))
82
+ return { step: null, blocked: null };
83
+ const owner = readiness.platformOwnerOrgId === org.id;
84
+ if (!owner && !org.hosting.entries.some((e) => e.uses?.jobs)) {
85
+ return { step: null, blocked: null };
86
+ }
87
+ const hostname = ownHostnames(org).jobs;
88
+ const schedulerApplied = Boolean(recordedIds[`coolify:own-jobs:${org.id}`]) &&
89
+ Boolean(recordedIds[JOBS_INTERNAL_URL_SCOPE_KEY]);
90
+ if (!schedulerApplied && !options.schedulerPlanned)
91
+ return { step: null, blocked: null };
92
+ if (recordedIds[JOBS_PUBLIC_URL_SCOPE_KEY] === `https://${hostname}` &&
93
+ recordedIds[OWN_JOBS_ROUTE_TUNNEL_SCOPE_KEY]) {
94
+ return { step: null, blocked: null };
95
+ }
96
+ const waiting = (reason) => ({
97
+ step: null,
98
+ blocked: `${org.slug}'s own jobs scheduler route is blocked, not planned: ${reason}`,
99
+ });
100
+ if (!ownServices)
101
+ return waiting('the host supplies no ownServices port');
102
+ if (!options.zoneActive)
103
+ return waiting(`the zone for ${ownHostnames(org).zone} is not active yet`);
104
+ const ownEdge = readiness.ownEdgePorts;
105
+ if (readiness.dnsBlocked && !(ownEdge?.dns && ownEdge.tunnels)) {
106
+ return waiting(readiness.dnsBlocked);
107
+ }
108
+ if (!ownEdge?.tunnels && !readiness.edgeReady) {
109
+ return waiting('the host supplies no tunnel port and edge is not ready');
110
+ }
111
+ return {
112
+ blocked: null,
113
+ step: {
114
+ action: { system: 'coolify', kind: 'own-jobs-route', key: org.id, desired: { hostname } },
115
+ step: {
116
+ description: `publish ${org.slug}'s own jobs scheduler at ${hostname}: its tunnel and connector and a proxied CNAME`,
117
+ destroys: false,
118
+ migrates: false,
119
+ },
120
+ },
121
+ };
122
+ }
70
123
  const BROKER_ENTRY_APPLIED_PREFIX = brokerEntryAppliedScopeKey('');
71
124
  /** `unmanaged` key of the one line `planOwnAuth` reports. */
72
125
  export const OWN_AUTH_BLOCKED_KEY = 'own-auth';
@@ -13,6 +13,8 @@ export interface OwnHostnames {
13
13
  files: string;
14
14
  /** `sfu.<domain>`, or `<slug>-sfu.oikos.host`. */
15
15
  sfu: string;
16
+ /** `jobs.<domain>`, or `<slug>-jobs.oikos.host`. */
17
+ jobs: string;
16
18
  /** `https://<auth>`. */
17
19
  issuerUrl: string;
18
20
  /** The mail domain: `<domain>`, or `<slug>.oikos.host` (names are `name@<slug>.oikos.host`). */
@@ -18,6 +18,7 @@ export function ownHostnames(org) {
18
18
  mail: mailOnDomain ? `mail.${domain}` : `${slug}-mail.${NO_DOMAIN_ZONE}`,
19
19
  files: name('files'),
20
20
  sfu: name('sfu'),
21
+ jobs: name('jobs'),
21
22
  issuerUrl: `https://${auth}`,
22
23
  mailDomain: mailOnDomain && domain ? domain : `${slug}.${NO_DOMAIN_ZONE}`,
23
24
  };
@@ -0,0 +1,47 @@
1
+ import type { CoolifyPort } from './executor.js';
2
+ import type { ServiceNetwork, ServiceTarget, StepScope } from './own-services.js';
3
+ /**
4
+ * A company's jobs scheduler on a public hostname: a tunnel that dials the scheduler by its internal address,
5
+ * a connector app, and a proxied CNAME for `jobs.<domain>`. An entry runs on its own company's network and
6
+ * cannot dial the scheduler's container name, so `uses.jobs` gives it `https://<hostname>` once this is
7
+ * recorded. The owner company (hq) runs the scheduler every shared-services company registers on; a company
8
+ * that runs its own services publishes its own the same way. No Coolify domain and no Access app: the
9
+ * scheduler authenticates every caller itself (a project's sync key, the admin token), and a gate in front
10
+ * would stop the apps.
11
+ *
12
+ * One `coolify:own-jobs-route` step. It records {@link JOBS_PUBLIC_URL_SCOPE_KEY} last, so a run that fails
13
+ * halfway is planned again, finds the tunnel and connector by name, and no entry is given a dead URL.
14
+ */
15
+ export declare const JOBS_PUBLIC_URL_SCOPE_KEY = "jobs_public_url";
16
+ export declare const OWN_JOBS_ROUTE_TUNNEL_SCOPE_KEY = "cloudflare_own_jobs_tunnel";
17
+ export declare const OWN_JOBS_ROUTE_CONNECTOR_APP_SCOPE_KEY = "coolify_own_jobs_connector_app";
18
+ /**
19
+ * The only paths the public route serves: what a project's app calls (`/sync`, `/timers`, `/timers/<name>`,
20
+ * `/deliveries`). `/admin/*` is not on the internet; foundry reaches it on the Coolify network.
21
+ */
22
+ export declare const JOBS_ENTRY_PATH_PATTERN = "^/(sync$|timers(/|$)|deliveries$)";
23
+ export declare const JOBS_ROUTE_CALL_TIMEOUT_MS = 60000;
24
+ /** The tunnel's name and the connector app's, one pair per company. */
25
+ export declare const jobsRouteNames: (target: Pick<ServiceTarget, "id">) => {
26
+ tunnel: string;
27
+ connector: string;
28
+ };
29
+ export interface JobsRoutePorts {
30
+ coolify: Pick<CoolifyPort, 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy'>;
31
+ network: ServiceNetwork;
32
+ /** One outside call's limit in milliseconds; the connector step gets five times it. */
33
+ callTimeoutMs?: number;
34
+ }
35
+ /**
36
+ * Makes the route in this order: tunnel, its ingress, the connector app, the CNAME, then the recorded URL.
37
+ * Each is found by name or content first, so a second run makes nothing twice. A failure leaves what was made
38
+ * (found again on the next run) and no recorded URL. Errors carry the call's label only.
39
+ */
40
+ export declare function provisionJobsRoute(scope: StepScope, ports: JobsRoutePorts, target: ServiceTarget, input: {
41
+ hostname: string;
42
+ apex: string;
43
+ internalUrl: string;
44
+ projectUuid: string;
45
+ }): Promise<{
46
+ resourceId: string;
47
+ }>;