@wtfalch/foundry 0.6.0 → 0.8.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 (58) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +12 -8
  2. package/dist/bootstrap/bootstrap.js +4 -12
  3. package/dist/bootstrap/declare.d.ts +3 -1
  4. package/dist/bootstrap/declare.js +6 -2
  5. package/dist/bootstrap/versions.d.ts +25 -0
  6. package/dist/bootstrap/versions.js +43 -0
  7. package/dist/calls-sfu-deploy-support.d.ts +1 -1
  8. package/dist/calls-sfu-deploy-support.js +1 -1
  9. package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
  10. package/dist/calls-sfu-ssh-adapter.js +1 -1
  11. package/dist/calls-sfu.d.ts +8 -6
  12. package/dist/calls-sfu.js +9 -6
  13. package/dist/index.d.ts +3 -4
  14. package/dist/index.js +5 -2
  15. package/dist/migrations/0022_chat_enabled.sql +6 -0
  16. package/dist/migrations/0023_hosting_app_env.sql +8 -0
  17. package/dist/migrations/0024_audit_events.sql +211 -0
  18. package/dist/migrations/0025_audit_display.sql +147 -0
  19. package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
  20. package/dist/migrations/0027_audit_chain.sql +170 -0
  21. package/dist/migrations/0028_audit_rls.sql +111 -0
  22. package/dist/migrations-dir.d.ts +9 -0
  23. package/dist/migrations-dir.js +10 -0
  24. package/dist/org.d.ts +24 -0
  25. package/dist/org.js +13 -0
  26. package/dist/plan/agora.d.ts +21 -0
  27. package/dist/plan/agora.js +23 -0
  28. package/dist/plan/calls-matrix.d.ts +4 -2
  29. package/dist/plan/calls-matrix.js +5 -2
  30. package/dist/plan/cloudflared-connector.d.ts +3 -2
  31. package/dist/plan/cloudflared-connector.js +5 -3
  32. package/dist/plan/diff.d.ts +18 -3
  33. package/dist/plan/diff.js +369 -42
  34. package/dist/plan/executor.d.ts +378 -44
  35. package/dist/plan/executor.js +816 -57
  36. package/dist/plan/index.d.ts +2 -0
  37. package/dist/plan/index.js +1 -0
  38. package/dist/plan/login-host.d.ts +88 -0
  39. package/dist/plan/login-host.js +73 -0
  40. package/dist/plan/plan-apply.d.ts +23 -8
  41. package/dist/plan/plan-apply.js +30 -10
  42. package/dist/plan/platform-diff.d.ts +102 -2
  43. package/dist/plan/platform-diff.js +232 -1
  44. package/dist/plan/platform-providers.d.ts +37 -2
  45. package/dist/plan/platform-providers.js +223 -36
  46. package/dist/plan/providers-readers.js +44 -1
  47. package/dist/plan.d.ts +43 -0
  48. package/dist/plan.js +16 -1
  49. package/dist/store/audit.d.ts +36 -4
  50. package/dist/store/audit.js +110 -29
  51. package/dist/store/postgres.d.ts +14 -0
  52. package/dist/store/postgres.js +17 -9
  53. package/dist/store/schema.d.ts +37 -1
  54. package/dist/store/schema.js +7 -0
  55. package/package.json +10 -5
  56. package/versions.json +8 -0
  57. package/dist/migrate.d.ts +0 -38
  58. package/dist/migrate.js +0 -47
@@ -1,9 +1,10 @@
1
1
  import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from 'node:crypto';
2
2
  import { isIPv4 } from 'node:net';
3
+ import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
3
4
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
4
5
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
5
6
  import { ACCESS_POLICY_NAME, isNotFoundError, isRunningOrStarting } from './executor.js';
6
- import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_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_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_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_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
7
+ import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, 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_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_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_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
7
8
  import { applyCallsSfuPlatformStep } from './platform-sfu.js';
8
9
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
9
10
  function apexZoneKey(apex) {
@@ -92,6 +93,10 @@ const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
92
93
  * #175 adds `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, Archon itself
93
94
  * ({@link provisionPlatformArchon}), once a host passes
94
95
  * {@link PlatformArchonDeps}.
96
+ *
97
+ * #257 adds `coolify:backup:<service>`, a nightly R2 backup of each
98
+ * platform database ({@link provisionPlatformBackup}), once a host passes
99
+ * {@link PlatformBackupDeps}.
95
100
  */
96
101
  export class FoundryPlatformStepExecutor {
97
102
  cloudflare;
@@ -105,6 +110,7 @@ export class FoundryPlatformStepExecutor {
105
110
  files;
106
111
  ai;
107
112
  archon;
113
+ backup;
108
114
  constructor(cloudflare, declaredOrgStore, options = {}) {
109
115
  this.cloudflare = cloudflare;
110
116
  this.declaredOrgStore = declaredOrgStore;
@@ -117,6 +123,7 @@ export class FoundryPlatformStepExecutor {
117
123
  this.files = options.files;
118
124
  this.ai = options.ai;
119
125
  this.archon = options.archon;
126
+ this.backup = options.backup;
120
127
  }
121
128
  async blockedSteps(platform, recordedIds = {}) {
122
129
  const blocked = {};
@@ -130,8 +137,19 @@ export class FoundryPlatformStepExecutor {
130
137
  }
131
138
  else if (platform.auth.enabled && this.auth) {
132
139
  const { reason: authBlocked, otherInputsMissing } = await authBrokerBlockedReason(this.auth, recordedIds);
133
- if (authBlocked)
140
+ if (authBlocked) {
134
141
  blocked.auth = authBlocked;
142
+ }
143
+ else {
144
+ // #248: the same "block, not throw" rule mail's own tag check
145
+ // follows. `VERSIONS.zitadel` is a real pin today, so this never
146
+ // fires in practice -- it is here so a future bump that lands a
147
+ // floating tag is caught the same way mail's would be.
148
+ const zitadelTagBlocked = floatingTagBlocked('ZITADEL', VERSIONS.zitadel, 'v4.17.3');
149
+ if (zitadelTagBlocked) {
150
+ blocked.auth = `the platform's sign-in is blocked, not planned: ${zitadelTagBlocked}`;
151
+ }
152
+ }
135
153
  // #216 round 5: see `PlanPlatformOptions.blocked.authOtherInputsMissing`.
136
154
  if (otherInputsMissing.length > 0)
137
155
  blocked.authOtherInputsMissing = otherInputsMissing;
@@ -197,6 +215,14 @@ export class FoundryPlatformStepExecutor {
197
215
  }
198
216
  }
199
217
  }
218
+ // Only once a database actually exists to back up -- the same
219
+ // "nothing to report yet" rule `planBackups` (`platform-diff.ts`)
220
+ // itself follows for "no database", so an otherwise-unrelated platform
221
+ // with no backup deps wired carries no standing blocked note.
222
+ if (!this.backup &&
223
+ PLATFORM_BACKUP_SERVICE_KEYS.some((service) => Boolean(recordedIds[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]]))) {
224
+ blocked.backupExecutorMissing = true;
225
+ }
200
226
  return blocked;
201
227
  }
202
228
  /**
@@ -270,6 +296,12 @@ export class FoundryPlatformStepExecutor {
270
296
  if (action.system === 'coolify' && action.kind === 'jobs') {
271
297
  return this.coolifyJobs(action, context);
272
298
  }
299
+ if (action.system === 'coolify' && action.kind === 'backup') {
300
+ if (!this.backup) {
301
+ throw new Error(`platform:backup:${action.key} needs FoundryPlatformStepExecutor to be constructed with its backup dependencies (#257)`);
302
+ }
303
+ return provisionPlatformBackup(action, context, this.backup);
304
+ }
273
305
  if (action.system === 'sfu' && action.kind === 'livekit') {
274
306
  return this.executeSfu();
275
307
  }
@@ -439,9 +471,9 @@ const PLATFORM_AUTH_BROKER_INTERNAL_NAME = 'platform-auth-broker';
439
471
  const PLATFORM_AUTH_TUNNEL_NAME = 'platform-auth-tunnel';
440
472
  /** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_AUTH_TUNNEL_NAME}. */
441
473
  const PLATFORM_AUTH_CLOUDFLARED_APP_NAME = 'platform-auth-cloudflared';
442
- /** `wtfalch/auth`'s `docker-compose.yml`: `image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION:-v4.17.3}`. */
474
+ /** `wtfalch/auth`'s `docker-compose.yml`: `image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION:-v4.17.3}`. Pinned in `versions.json` (#247). */
443
475
  const AUTH_ZITADEL_IMAGE = 'ghcr.io/zitadel/zitadel';
444
- const AUTH_ZITADEL_IMAGE_TAG = 'v4.17.3';
476
+ const AUTH_ZITADEL_IMAGE_TAG = VERSIONS.zitadel;
445
477
  /** `wtfalch/auth`'s own `login/Dockerfile` -- `docs/deploy.md`'s "One compose service, four containers... Two images are built from this repository: the proxy... and the login". */
446
478
  const AUTH_REPO = 'wtfalch/auth';
447
479
  const AUTH_LOGIN_DOCKERFILE = 'login/Dockerfile';
@@ -808,6 +840,15 @@ async function provisionPlatformAuth(context, deps) {
808
840
  await deps.coolify.updateApplication(zitadelAppId, {
809
841
  custom_internal_name: PLATFORM_AUTH_ZITADEL_INTERNAL_NAME,
810
842
  });
843
+ // #251: only on create, never unconditionally -- nothing here patches
844
+ // `docker_registry_image_tag` on an app `findOrCreatePlatformApp` found
845
+ // already running, so that is the only moment this tag is actually live.
846
+ // Recording it every run, the way the app id above is, would make
847
+ // `versionNotAppliedLine` compare a bumped `VERSIONS.zitadel` against a
848
+ // value that was never applied to the running app.
849
+ if (zitadelApp.justCreated) {
850
+ await record(PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, AUTH_ZITADEL_IMAGE_TAG);
851
+ }
811
852
  // ---- the zitadel-login app -----------------------------------------------
812
853
  const loginApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_LOGIN_APP_NAME, environment, async () => {
813
854
  const server = await resolvePlatformServer(deps.coolify);
@@ -1519,12 +1560,17 @@ async function provisionPlatformMail(context, deps) {
1519
1560
  await record(PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, imageTag);
1520
1561
  return { resourceId: appId };
1521
1562
  }
1522
- /** Why the Stalwart image tag cannot be used, or `null`. A floating tag cannot be re-pulled by comparing tags. */
1563
+ /**
1564
+ * Why the Stalwart image tag cannot be used, or `null`. #248: a thin wrapper
1565
+ * over the estate-wide {@link floatingTagBlocked} -- the mail step's own
1566
+ * message, naming `gitRef` since that is the declared field this tag
1567
+ * actually comes from.
1568
+ */
1523
1569
  function mailImageTagBlocked(tag) {
1524
- if (tag.trim() === '' || tag.trim().toLowerCase() === 'latest') {
1525
- return `the platform's mail server is blocked, not planned: the Stalwart image tag "${tag}" floats; declare a pinned release (for example v0.16.24) as the mail gitRef`;
1526
- }
1527
- return null;
1570
+ const reason = floatingTagBlocked('Stalwart', tag, 'v0.16.24');
1571
+ return reason
1572
+ ? `the platform's mail server is blocked, not planned: ${reason} as the mail gitRef`
1573
+ : null;
1528
1574
  }
1529
1575
  /** Why the server's address cannot be the MX A record's content, or `null`. */
1530
1576
  function mailServerIpBlocked(ip) {
@@ -1848,19 +1894,32 @@ async function provisionPlatformArchon(context, deps) {
1848
1894
  await record(PLATFORM_ARCHON_PROJECT_SCOPE_KEY, projectUuid);
1849
1895
  }
1850
1896
  const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
1897
+ // #265: a database already named `platform-archon-db` (the hand-deployed
1898
+ // one) is adopted, never built a second time. One of that name in another
1899
+ // environment is refused: adopting or duplicating it would split the data.
1851
1900
  let dbId = recorded[PLATFORM_ARCHON_DB_SCOPE_KEY];
1852
1901
  if (!dbId) {
1853
- const server = await resolvePlatformServer(deps.coolify);
1854
- const created = await deps.coolify.createPostgresDatabase({
1855
- project_uuid: projectUuid,
1856
- server_uuid: server.uuid,
1857
- environment_name: environment.name,
1858
- environment_uuid: environment.uuid,
1859
- name: PLATFORM_ARCHON_DB_NAME,
1860
- postgres_db: ARCHON_POSTGRES_DB,
1861
- postgres_user: ARCHON_POSTGRES_DB,
1862
- });
1863
- dbId = created.uuid;
1902
+ const named = (await deps.coolify.listDatabases()).filter((d) => d.name === PLATFORM_ARCHON_DB_NAME);
1903
+ const found = named.find((d) => d.environment_id === environment.id);
1904
+ if (!found && named.length > 0) {
1905
+ throw new Error(`platform:archon: a database named ${PLATFORM_ARCHON_DB_NAME} exists outside the ${PLATFORM_ARCHON_PROJECT} project's production environment; refusing to adopt or duplicate it. Move or rename it in Coolify and apply again`);
1906
+ }
1907
+ if (found) {
1908
+ dbId = found.uuid;
1909
+ }
1910
+ else {
1911
+ const server = await resolvePlatformServer(deps.coolify);
1912
+ const created = await deps.coolify.createPostgresDatabase({
1913
+ project_uuid: projectUuid,
1914
+ server_uuid: server.uuid,
1915
+ environment_name: environment.name,
1916
+ environment_uuid: environment.uuid,
1917
+ name: PLATFORM_ARCHON_DB_NAME,
1918
+ postgres_db: ARCHON_POSTGRES_DB,
1919
+ postgres_user: ARCHON_POSTGRES_DB,
1920
+ });
1921
+ dbId = created.uuid;
1922
+ }
1864
1923
  await record(PLATFORM_ARCHON_DB_SCOPE_KEY, dbId);
1865
1924
  }
1866
1925
  const db = await deps.coolify.getDatabase(dbId);
@@ -1869,6 +1928,12 @@ async function provisionPlatformArchon(context, deps) {
1869
1928
  }
1870
1929
  // ---- the app ----------------------------------------------------------------
1871
1930
  const app = await findOrCreatePlatformApp(deps.coolify, PLATFORM_ARCHON_APP_NAME, environment, async () => {
1931
+ // #265: the lookup above is scoped to this environment, so a same-named
1932
+ // app elsewhere is invisible to it. Making another one would give two
1933
+ // `platform-archon` apps; refuse instead.
1934
+ if (await deps.coolify.findApplicationByName(PLATFORM_ARCHON_APP_NAME)) {
1935
+ throw new Error(`platform:archon: an application named ${PLATFORM_ARCHON_APP_NAME} exists outside the ${PLATFORM_ARCHON_PROJECT} project's production environment; refusing to create a second one. Move or rename it in Coolify and apply again`);
1936
+ }
1872
1937
  const server = await resolvePlatformServer(deps.coolify);
1873
1938
  const githubApp = await deps.coolify.requireGithubApp();
1874
1939
  return deps.coolify.createGithubAppApplication({
@@ -1913,7 +1978,7 @@ async function provisionPlatformArchon(context, deps) {
1913
1978
  // response was lost, or one made for another hostname: its client id
1914
1979
  // cannot be read back (`listApps` does not carry it), so it is deleted and
1915
1980
  // made again, as `zitadelApp` does.
1916
- const orphan = (await zitadel.listApps(org.id, project.id)).find((a) => a.name === PLATFORM_ARCHON_ZITADEL_APP &&
1981
+ const orphan = (await zitadel.listApps(org.id, project.id)).apps.find((a) => a.name === PLATFORM_ARCHON_ZITADEL_APP &&
1917
1982
  a.appType === 'web' &&
1918
1983
  a.state !== 'APP_STATE_REMOVED');
1919
1984
  if (orphan) {
@@ -1944,9 +2009,20 @@ async function provisionPlatformArchon(context, deps) {
1944
2009
  { key: 'AUTH_ISSUER', value: issuer },
1945
2010
  { key: 'AUTH_ORGANIZATION_ID', value: org.id },
1946
2011
  { key: 'AUTH_CLIENT_ID', value: clientId },
1947
- { key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
1948
- { key: 'REPORTING_SITE', value: 'archon' },
1949
2012
  ];
2013
+ // #265: an owner URL or reporting site already on the app is kept as it is.
2014
+ // The owner URL must name the adopted database's host, or the app's two
2015
+ // database URLs would point at different databases.
2016
+ const liveOwnerUrl = await currentEnvValue(deps.coolify, appId, 'DATABASE_URL_OWNER');
2017
+ if (liveOwnerUrl) {
2018
+ assertSameDatabaseHost('DATABASE_URL_OWNER', liveOwnerUrl, db.internal_db_url);
2019
+ }
2020
+ else {
2021
+ envs.push({ key: 'DATABASE_URL_OWNER', value: db.internal_db_url });
2022
+ }
2023
+ if (!(await currentEnvValue(deps.coolify, appId, 'REPORTING_SITE'))) {
2024
+ envs.push({ key: 'REPORTING_SITE', value: 'archon' });
2025
+ }
1950
2026
  const cookieSecret = await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_COOKIE_SECRET', () => randomBytes(32).toString('base64'));
1951
2027
  if (cookieSecret)
1952
2028
  envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
@@ -2591,10 +2667,34 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
2591
2667
  * `base64url` fits `db-runtime-role.sh`'s password alphabet
2592
2668
  * (`A-Za-z0-9_.~-`).
2593
2669
  */
2670
+ /**
2671
+ * #265: throws when `liveUrl` points at another host than the adopted
2672
+ * database. Names both hosts and nothing else: a URL carries a credential, and
2673
+ * Node's parse error echoes its whole input.
2674
+ */
2675
+ function assertSameDatabaseHost(key, liveUrl, dbInternalUrl) {
2676
+ const hostOf = (url) => {
2677
+ try {
2678
+ return new URL(url).host;
2679
+ }
2680
+ catch {
2681
+ return null;
2682
+ }
2683
+ };
2684
+ const liveHost = hostOf(liveUrl);
2685
+ const dbHost = hostOf(dbInternalUrl);
2686
+ if (liveHost !== dbHost) {
2687
+ throw new Error(`platform:archon: ${key} on the app points at host ${liveHost ?? '(unparseable)'}, but the adopted database ${PLATFORM_ARCHON_DB_NAME} is at host ${dbHost ?? '(unparseable)'}; refusing to leave the app's database URLs on different databases`);
2688
+ }
2689
+ }
2594
2690
  async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
2595
2691
  const envs = await coolify.listEnvs(appId);
2596
- if (envs.some((e) => e.key === 'DATABASE_URL' && !e.is_preview))
2692
+ const live = envs.find((e) => e.key === 'DATABASE_URL' && !e.is_preview);
2693
+ if (live) {
2694
+ // #265: kept as it is, but it must name the adopted database's host.
2695
+ assertSameDatabaseHost('DATABASE_URL', live.value, dbInternalUrl);
2597
2696
  return null;
2697
+ }
2598
2698
  let host;
2599
2699
  let pathname;
2600
2700
  let search;
@@ -3781,7 +3881,7 @@ async function provisionPlatformFiles(context, deps) {
3781
3881
  // An app by this name here has no recorded secret (no undelivered id, or
3782
3882
  // the throw above would have fired): a crash between the create and the
3783
3883
  // record. Nothing was delivered, so it is deleted and made again.
3784
- const stale = (await zitadel.listApps(org.id, project.id)).filter((app) => app.name === FILES_INTROSPECTION_APP_NAME && app.state !== 'APP_STATE_REMOVED');
3884
+ const stale = (await zitadel.listApps(org.id, project.id)).apps.filter((app) => app.name === FILES_INTROSPECTION_APP_NAME && app.state !== 'APP_STATE_REMOVED');
3785
3885
  for (const app of stale) {
3786
3886
  try {
3787
3887
  await zitadel.deleteApp(org.id, project.id, app.id);
@@ -4284,6 +4384,61 @@ async function waitForIntegrationsMigration(coolify, appId, runtime, sleep) {
4284
4384
  await sleep(interval);
4285
4385
  }
4286
4386
  }
4387
+ /** The name every platform database's backup is registered against -- one shared R2 bucket, one storage entry. */
4388
+ const PLATFORM_BACKUP_S3_STORAGE_NAME = 'platform-backups';
4389
+ /** The uuid `provisionPlatformBackup` records once {@link PLATFORM_BACKUP_S3_STORAGE_NAME} is registered on Coolify, so a later apply never re-registers it. */
4390
+ const PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY = 'coolify_platform_backup_s3_storage';
4391
+ /** Coolify's own name for "once a day" -- `createDatabaseBackup`'s `frequency`. */
4392
+ const PLATFORM_BACKUP_DEFAULT_FREQUENCY = 'daily';
4393
+ /**
4394
+ * #257: schedules a nightly R2 backup of one platform database --
4395
+ * `planBackups` (`platform-diff.ts`) plans this step once per service, each
4396
+ * carrying that service's own database id as `action.desired.databaseId`
4397
+ * (recorded by that service's own `coolify:app` step; never minted here).
4398
+ * The R2 bucket is registered as a Coolify S3 storage once, by name, and
4399
+ * its uuid recorded under {@link PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY} so
4400
+ * every service's own backup step -- and a rerun of this one -- reuses it
4401
+ * rather than registering a second storage entry under the same name.
4402
+ * `createDatabaseBackup` itself is the idempotency boundary for the
4403
+ * schedule: an existing enabled, `save_s3` schedule is left alone rather
4404
+ * than replaced, since Coolify has no "upsert" for this call and a second
4405
+ * schedule would dump the same database twice a night.
4406
+ */
4407
+ async function provisionPlatformBackup(action, context, deps) {
4408
+ const { coolify, r2 } = deps;
4409
+ const databaseId = action.desired.databaseId;
4410
+ const recorded = context.recordedIds();
4411
+ let storageUuid = recorded[PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY];
4412
+ if (!storageUuid) {
4413
+ const found = (await coolify.listS3Storages()).find((storage) => storage.name === PLATFORM_BACKUP_S3_STORAGE_NAME);
4414
+ if (found) {
4415
+ storageUuid = found.uuid;
4416
+ }
4417
+ else {
4418
+ const created = await coolify.createS3Storage({
4419
+ name: PLATFORM_BACKUP_S3_STORAGE_NAME,
4420
+ endpoint: r2.endpoint,
4421
+ bucket: r2.bucket,
4422
+ region: r2.region ?? 'auto',
4423
+ key: r2.accessKeyId,
4424
+ secret: r2.secretAccessKey,
4425
+ is_usable: true,
4426
+ });
4427
+ storageUuid = created.uuid;
4428
+ }
4429
+ context.recordScopeId(PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY, storageUuid);
4430
+ }
4431
+ const existing = (await coolify.listDatabaseBackups(databaseId)).find((backup) => backup.enabled && backup.save_s3);
4432
+ if (!existing) {
4433
+ await coolify.createDatabaseBackup(databaseId, {
4434
+ frequency: deps.frequency ?? PLATFORM_BACKUP_DEFAULT_FREQUENCY,
4435
+ enabled: true,
4436
+ save_s3: true,
4437
+ s3_storage_uuid: storageUuid,
4438
+ });
4439
+ }
4440
+ return undefined;
4441
+ }
4287
4442
  /**
4288
4443
  * The real {@link PlatformCloudflareReader}: the platform's apex zone as
4289
4444
  * `planApexZone` expects to see it, and nothing else. The zone's DNS records
@@ -4338,23 +4493,55 @@ export class ProvidersPlatformCoolifyReader {
4338
4493
  this.platformStore = platformStore;
4339
4494
  }
4340
4495
  async listResources() {
4341
- const recordedId = (await this.platformStore.getServiceIds())[JOBS_APP_KEY];
4342
- if (!recordedId)
4343
- return [];
4344
- try {
4345
- const app = await this.client.getApplication(recordedId);
4346
- return [
4347
- {
4496
+ const recorded = await this.platformStore.getServiceIds();
4497
+ const resources = [];
4498
+ const recordedAppId = recorded[JOBS_APP_KEY];
4499
+ if (recordedAppId) {
4500
+ try {
4501
+ const app = await this.client.getApplication(recordedAppId);
4502
+ resources.push({
4348
4503
  system: 'coolify',
4349
4504
  kind: 'jobs',
4350
4505
  key: JOBS_SCHEDULER_KEY,
4351
4506
  config: { uuid: app.uuid, status: app.status },
4352
- },
4353
- ];
4507
+ });
4508
+ }
4509
+ catch {
4510
+ // A recorded id Coolify no longer has reads as not-live (see this
4511
+ // class's own doc comment); the scheduler, not every other resource
4512
+ // this reader checks.
4513
+ }
4354
4514
  }
4355
- catch {
4356
- return [];
4515
+ // #257: one backup-schedule resource per service whose database is
4516
+ // recorded, found the only way a schedule can be: listed off that
4517
+ // database itself, since Coolify names no schedule of its own.
4518
+ for (const service of PLATFORM_BACKUP_SERVICE_KEYS) {
4519
+ const databaseId = recorded[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]];
4520
+ if (!databaseId)
4521
+ continue;
4522
+ try {
4523
+ const backups = await this.client.listDatabaseBackups(databaseId);
4524
+ const scheduled = backups.find((backup) => backup.enabled && backup.save_s3);
4525
+ if (scheduled) {
4526
+ resources.push({
4527
+ system: 'coolify',
4528
+ kind: 'backup',
4529
+ key: service,
4530
+ config: { uuid: scheduled.uuid, frequency: scheduled.frequency },
4531
+ });
4532
+ }
4533
+ }
4534
+ catch {
4535
+ // A Coolify error (or a recorded database id Coolify no longer has)
4536
+ // reads as not-live, the same as the jobs app above -- `databaseId`
4537
+ // is still recorded, so `planBackups` plans the step again rather
4538
+ // than reporting it blocked; `provisionPlatformBackup`'s own
4539
+ // `listDatabaseBackups` check (run again there) is what keeps a
4540
+ // transient failure here from scheduling a second backup once the
4541
+ // first one did succeed.
4542
+ }
4357
4543
  }
4544
+ return resources;
4358
4545
  }
4359
4546
  }
4360
4547
  /**
@@ -130,6 +130,47 @@ async function withLogVolumeConfig(client, resource, recorded) {
130
130
  return resource;
131
131
  }
132
132
  }
133
+ /**
134
+ * #239: whether a recorded database has an internal connection URL, as
135
+ * `config.hasInternalUrl` (a boolean, never the URL), so `planApp` can report
136
+ * a database it cannot push from as blocked. A failed read leaves it absent.
137
+ */
138
+ async function withDatabaseUrlConfig(client, resource, recorded) {
139
+ if (resource.kind !== 'database' || !recorded[resource.key]?.startsWith('database:')) {
140
+ return resource;
141
+ }
142
+ try {
143
+ const db = await client.getDatabase(resource.key);
144
+ return {
145
+ ...resource,
146
+ config: { ...resource.config, hasInternalUrl: Boolean(db.internal_db_url) },
147
+ };
148
+ }
149
+ catch {
150
+ return resource;
151
+ }
152
+ }
153
+ /**
154
+ * #239: the live env var NAMES (never values) of the org's own recorded app,
155
+ * as `config.envNames`, so `diff.ts`'s `planApp` can notice an app that
156
+ * lacks `DATABASE_URL_OWNER`. Failure is swallowed like
157
+ * {@link withLogVolumeConfig}: `envNames` stays absent and the planner reads
158
+ * that as nothing checked.
159
+ */
160
+ async function withEnvNamesConfig(client, resource, recorded) {
161
+ if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('app:')) {
162
+ return resource;
163
+ }
164
+ try {
165
+ const envNames = (await client.listEnvs(resource.key))
166
+ .filter((e) => !e.is_preview)
167
+ .map((e) => e.key);
168
+ return { ...resource, config: { ...resource.config, envNames } };
169
+ }
170
+ catch {
171
+ return resource;
172
+ }
173
+ }
133
174
  /**
134
175
  * #164: the org's own recorded Agora app's live `AGORA_*` env vars, as
135
176
  * `config.env`, so `diff.ts`'s `planAgora` can compare them with what
@@ -177,7 +218,9 @@ export class ProvidersCoolifyReader {
177
218
  this.resolveRecordedIds(orgId),
178
219
  ]);
179
220
  const withLogConfig = await Promise.all(toFoundryResources('coolify', resources).map((r) => withLogVolumeConfig(this.client, r, recorded)));
180
- const withAgoraConfig = await Promise.all(withLogConfig.map((r) => withAgoraEnvConfig(this.client, r, recorded)));
221
+ const withEnvNames = await Promise.all(withLogConfig.map((r) => withEnvNamesConfig(this.client, r, recorded)));
222
+ const withDbUrl = await Promise.all(withEnvNames.map((r) => withDatabaseUrlConfig(this.client, r, recorded)));
223
+ const withAgoraConfig = await Promise.all(withDbUrl.map((r) => withAgoraEnvConfig(this.client, r, recorded)));
181
224
  return applyRecordedIds(withAgoraConfig, recorded);
182
225
  }
183
226
  }
package/dist/plan.d.ts CHANGED
@@ -53,4 +53,47 @@ export interface Plan {
53
53
  orgId: OrgId;
54
54
  steps: PlanStep[];
55
55
  unmanaged: UnmanagedItem[];
56
+ /**
57
+ * #251: see {@link VersionChangeItem}. Optional so a `Plan` built before
58
+ * this field existed, or a test fixture that predates it, still satisfies
59
+ * this type with none.
60
+ */
61
+ versionChanges?: VersionChangeItem[];
62
+ }
63
+ /**
64
+ * The action kind a {@link VersionChangeItem} carries -- a distinct, typed
65
+ * value so Archon can key off it to render and later gate these lines
66
+ * (wtfalch/archon#133), and so it is never mistaken for a `StepAction.kind`
67
+ * (`plan/diff.ts`) any `StepExecutor` dispatches on.
68
+ *
69
+ * #251 round 3: an earlier attempt planned these as ordinary `PlanStep`s in
70
+ * `Plan.steps`. That made a plan holding only version-change lines for
71
+ * zitadel/synapse/mas (whose own step never re-applies a running app's
72
+ * image tag -- see {@link VersionChangeItem}) never read as empty, which is
73
+ * the condition `bootstrap/drive.ts` loops on, and `apply` tried to run a
74
+ * step for those services that does nothing. Version-change lines are
75
+ * carried on their own `Plan.versionChanges` field instead, never on
76
+ * `steps`, so they are display-only everywhere a `Plan` is read.
77
+ */
78
+ export declare const VERSION_CHANGE_KIND = "version-change";
79
+ /**
80
+ * One informational line reporting a service whose version has drifted --
81
+ * either from what the next apply would actually put live (mail, archon,
82
+ * ai, files, integrations: each already redeploys when its own recorded
83
+ * applied ref differs from the declared one), or, for a service whose own
84
+ * step never patches a running app's image tag once created (zitadel,
85
+ * synapse, mas), from what is actually live. #251: the data to show this
86
+ * already existed per-service with no line anywhere saying a foundry
87
+ * release bumping a pinned version, or an operator changing a declared
88
+ * `gitRef`, had changed what the next apply would do -- or, for the second
89
+ * group, that it would do nothing at all.
90
+ *
91
+ * Never a {@link PlanStep}: it creates or changes nothing live, and is never
92
+ * passed to a `StepExecutor`.
93
+ */
94
+ export interface VersionChangeItem {
95
+ kind: typeof VERSION_CHANGE_KIND;
96
+ /** The service this line is about, e.g. 'mail', 'archon', 'zitadel'. */
97
+ service: string;
98
+ description: string;
56
99
  }
package/dist/plan.js CHANGED
@@ -1 +1,16 @@
1
- export {};
1
+ /**
2
+ * The action kind a {@link VersionChangeItem} carries -- a distinct, typed
3
+ * value so Archon can key off it to render and later gate these lines
4
+ * (wtfalch/archon#133), and so it is never mistaken for a `StepAction.kind`
5
+ * (`plan/diff.ts`) any `StepExecutor` dispatches on.
6
+ *
7
+ * #251 round 3: an earlier attempt planned these as ordinary `PlanStep`s in
8
+ * `Plan.steps`. That made a plan holding only version-change lines for
9
+ * zitadel/synapse/mas (whose own step never re-applies a running app's
10
+ * image tag -- see {@link VersionChangeItem}) never read as empty, which is
11
+ * the condition `bootstrap/drive.ts` loops on, and `apply` tried to run a
12
+ * step for those services that does nothing. Version-change lines are
13
+ * carried on their own `Plan.versionChanges` field instead, never on
14
+ * `steps`, so they are display-only everywhere a `Plan` is read.
15
+ */
16
+ export const VERSION_CHANGE_KIND = 'version-change';
@@ -1,8 +1,40 @@
1
1
  import type { AuditLog } from '../plan/plan-apply.js';
2
2
  import type { FoundryStoreDb } from './postgres.js';
3
3
  /**
4
- * foundry's own {@link AuditLog}, one row per plan, apply or destroy in
5
- * `foundry_audit_events` (#85). The CLI uses it before any archon exists; an
6
- * attached archon reads the same table. It only inserts.
4
+ * foundry's own {@link AuditLog}, backed by `@wtfalch/audit`'s ledger
5
+ * (#232) instead of the hand-rolled `foundry_audit_events` table
6
+ * (`migrations/0012_audit.sql`) this replaces. Every row: `tenantId: null`
7
+ * (foundry has no tenant of its own -- the org a plan is *for* is the
8
+ * `target`, not a tenant), `hashChain` on so a changed or deleted row is
9
+ * detectable (`verifyChain`), through migrations 0024-0028
10
+ * (`src/migrations/`, copied from `@wtfalch/audit` 0.6.0's own
11
+ * `migrations/0001-0005`, the same files its own `audit-migrations` CLI
12
+ * would copy into a host that has a `drizzle/` journal -- foundry does not,
13
+ * so they are hand-placed at the next free numbers instead).
14
+ *
15
+ * The `AuditLog` interface this satisfies is unchanged (`plan-apply.ts`),
16
+ * so `bootstrap.ts` and `redactingAudit` need no change either.
17
+ *
18
+ * Decide (#232, open): whether `foundry_audit_events`' existing rows are
19
+ * copied into the ledger or left as a closed, separate trail. Left as-is
20
+ * here -- the simpler default, and the one that forecloses nothing: a
21
+ * backfill can still be written later, where discarding the old rows
22
+ * outright could not be undone.
23
+ *
24
+ * `checkRuntimeRole` defaults to `true`, `createLedger`'s own default. The
25
+ * connection must be a restricted role: not a superuser, no BYPASSRLS, and no
26
+ * UPDATE, DELETE or TRUNCATE on `audit_events`. A call through the owner
27
+ * connection throws `UnsafeRuntimeRoleError`. The check reads attributes of
28
+ * the connected role, not its name.
29
+ *
30
+ * The host provides that role. After it migrates with the owner URL, it calls
31
+ * `ensureRuntimeRole` from `@wtfalch/db/runtime-role` with
32
+ * `appendOnly: ['audit_events']` and the four audit functions in `grants`
33
+ * (README, "Foundry's own Postgres"), then connects the server as the runtime
34
+ * role. A test fixture without that split (PGlite's one user is a superuser)
35
+ * passes `checkRuntimeRole: false`. Options, not a second positional argument
36
+ * (`CLAUDE.md` rule 7).
7
37
  */
8
- export declare function createPostgresAuditLog(db: FoundryStoreDb): AuditLog;
38
+ export declare function createPostgresAuditLog(db: FoundryStoreDb, options?: {
39
+ checkRuntimeRole?: boolean;
40
+ }): AuditLog;