@wtfalch/foundry 0.7.1 → 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 (43) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +12 -8
  2. package/dist/bootstrap/bootstrap.js +4 -12
  3. package/dist/bootstrap/versions.d.ts +8 -0
  4. package/dist/bootstrap/versions.js +31 -0
  5. package/dist/calls-sfu-deploy-support.d.ts +1 -1
  6. package/dist/calls-sfu-deploy-support.js +1 -1
  7. package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
  8. package/dist/calls-sfu-ssh-adapter.js +1 -1
  9. package/dist/calls-sfu.d.ts +5 -4
  10. package/dist/calls-sfu.js +5 -4
  11. package/dist/index.d.ts +1 -2
  12. package/dist/index.js +4 -1
  13. package/dist/migrations/0024_audit_events.sql +1 -1
  14. package/dist/migrations/0025_audit_display.sql +1 -1
  15. package/dist/migrations/0026_audit_erase_email_guard.sql +1 -1
  16. package/dist/migrations/0027_audit_chain.sql +1 -1
  17. package/dist/migrations/0028_audit_rls.sql +2 -2
  18. package/dist/migrations-dir.d.ts +9 -0
  19. package/dist/migrations-dir.js +10 -0
  20. package/dist/plan/calls-matrix.d.ts +2 -0
  21. package/dist/plan/calls-matrix.js +2 -0
  22. package/dist/plan/diff.d.ts +3 -1
  23. package/dist/plan/diff.js +76 -14
  24. package/dist/plan/executor.d.ts +305 -16
  25. package/dist/plan/executor.js +606 -9
  26. package/dist/plan/login-host.d.ts +24 -0
  27. package/dist/plan/login-host.js +21 -0
  28. package/dist/plan/plan-apply.d.ts +2 -0
  29. package/dist/plan/plan-apply.js +20 -1
  30. package/dist/plan/platform-diff.d.ts +79 -1
  31. package/dist/plan/platform-diff.js +197 -1
  32. package/dist/plan/platform-providers.d.ts +36 -1
  33. package/dist/plan/platform-providers.js +153 -21
  34. package/dist/plan.d.ts +43 -0
  35. package/dist/plan.js +16 -1
  36. package/dist/store/audit.d.ts +13 -17
  37. package/dist/store/audit.js +14 -18
  38. package/dist/store/postgres.d.ts +14 -0
  39. package/dist/store/postgres.js +13 -8
  40. package/package.json +6 -3
  41. package/versions.json +1 -1
  42. package/dist/migrate.d.ts +0 -38
  43. package/dist/migrate.js +0 -47
@@ -1,10 +1,10 @@
1
1
  import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from 'node:crypto';
2
2
  import { isIPv4 } from 'node:net';
3
- import { VERSIONS } from '../bootstrap/versions.js';
3
+ import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
4
4
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
5
5
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
6
6
  import { ACCESS_POLICY_NAME, isNotFoundError, isRunningOrStarting } from './executor.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_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';
8
8
  import { applyCallsSfuPlatformStep } from './platform-sfu.js';
9
9
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
10
10
  function apexZoneKey(apex) {
@@ -93,6 +93,10 @@ const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
93
93
  * #175 adds `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, Archon itself
94
94
  * ({@link provisionPlatformArchon}), once a host passes
95
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}.
96
100
  */
97
101
  export class FoundryPlatformStepExecutor {
98
102
  cloudflare;
@@ -106,6 +110,7 @@ export class FoundryPlatformStepExecutor {
106
110
  files;
107
111
  ai;
108
112
  archon;
113
+ backup;
109
114
  constructor(cloudflare, declaredOrgStore, options = {}) {
110
115
  this.cloudflare = cloudflare;
111
116
  this.declaredOrgStore = declaredOrgStore;
@@ -118,6 +123,7 @@ export class FoundryPlatformStepExecutor {
118
123
  this.files = options.files;
119
124
  this.ai = options.ai;
120
125
  this.archon = options.archon;
126
+ this.backup = options.backup;
121
127
  }
122
128
  async blockedSteps(platform, recordedIds = {}) {
123
129
  const blocked = {};
@@ -131,8 +137,19 @@ export class FoundryPlatformStepExecutor {
131
137
  }
132
138
  else if (platform.auth.enabled && this.auth) {
133
139
  const { reason: authBlocked, otherInputsMissing } = await authBrokerBlockedReason(this.auth, recordedIds);
134
- if (authBlocked)
140
+ if (authBlocked) {
135
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
+ }
136
153
  // #216 round 5: see `PlanPlatformOptions.blocked.authOtherInputsMissing`.
137
154
  if (otherInputsMissing.length > 0)
138
155
  blocked.authOtherInputsMissing = otherInputsMissing;
@@ -198,6 +215,14 @@ export class FoundryPlatformStepExecutor {
198
215
  }
199
216
  }
200
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
+ }
201
226
  return blocked;
202
227
  }
203
228
  /**
@@ -271,6 +296,12 @@ export class FoundryPlatformStepExecutor {
271
296
  if (action.system === 'coolify' && action.kind === 'jobs') {
272
297
  return this.coolifyJobs(action, context);
273
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
+ }
274
305
  if (action.system === 'sfu' && action.kind === 'livekit') {
275
306
  return this.executeSfu();
276
307
  }
@@ -809,6 +840,15 @@ async function provisionPlatformAuth(context, deps) {
809
840
  await deps.coolify.updateApplication(zitadelAppId, {
810
841
  custom_internal_name: PLATFORM_AUTH_ZITADEL_INTERNAL_NAME,
811
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
+ }
812
852
  // ---- the zitadel-login app -----------------------------------------------
813
853
  const loginApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_LOGIN_APP_NAME, environment, async () => {
814
854
  const server = await resolvePlatformServer(deps.coolify);
@@ -1520,12 +1560,17 @@ async function provisionPlatformMail(context, deps) {
1520
1560
  await record(PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, imageTag);
1521
1561
  return { resourceId: appId };
1522
1562
  }
1523
- /** 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
+ */
1524
1569
  function mailImageTagBlocked(tag) {
1525
- if (tag.trim() === '' || tag.trim().toLowerCase() === 'latest') {
1526
- 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`;
1527
- }
1528
- 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;
1529
1574
  }
1530
1575
  /** Why the server's address cannot be the MX A record's content, or `null`. */
1531
1576
  function mailServerIpBlocked(ip) {
@@ -1933,7 +1978,7 @@ async function provisionPlatformArchon(context, deps) {
1933
1978
  // response was lost, or one made for another hostname: its client id
1934
1979
  // cannot be read back (`listApps` does not carry it), so it is deleted and
1935
1980
  // made again, as `zitadelApp` does.
1936
- 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 &&
1937
1982
  a.appType === 'web' &&
1938
1983
  a.state !== 'APP_STATE_REMOVED');
1939
1984
  if (orphan) {
@@ -3836,7 +3881,7 @@ async function provisionPlatformFiles(context, deps) {
3836
3881
  // An app by this name here has no recorded secret (no undelivered id, or
3837
3882
  // the throw above would have fired): a crash between the create and the
3838
3883
  // record. Nothing was delivered, so it is deleted and made again.
3839
- 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');
3840
3885
  for (const app of stale) {
3841
3886
  try {
3842
3887
  await zitadel.deleteApp(org.id, project.id, app.id);
@@ -4339,6 +4384,61 @@ async function waitForIntegrationsMigration(coolify, appId, runtime, sleep) {
4339
4384
  await sleep(interval);
4340
4385
  }
4341
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
+ }
4342
4442
  /**
4343
4443
  * The real {@link PlatformCloudflareReader}: the platform's apex zone as
4344
4444
  * `planApexZone` expects to see it, and nothing else. The zone's DNS records
@@ -4393,23 +4493,55 @@ export class ProvidersPlatformCoolifyReader {
4393
4493
  this.platformStore = platformStore;
4394
4494
  }
4395
4495
  async listResources() {
4396
- const recordedId = (await this.platformStore.getServiceIds())[JOBS_APP_KEY];
4397
- if (!recordedId)
4398
- return [];
4399
- try {
4400
- const app = await this.client.getApplication(recordedId);
4401
- return [
4402
- {
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({
4403
4503
  system: 'coolify',
4404
4504
  kind: 'jobs',
4405
4505
  key: JOBS_SCHEDULER_KEY,
4406
4506
  config: { uuid: app.uuid, status: app.status },
4407
- },
4408
- ];
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
+ }
4409
4514
  }
4410
- catch {
4411
- 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
+ }
4412
4543
  }
4544
+ return resources;
4413
4545
  }
4414
4546
  }
4415
4547
  /**
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';
@@ -21,23 +21,19 @@ import type { FoundryStoreDb } from './postgres.js';
21
21
  * backfill can still be written later, where discarding the old rows
22
22
  * outright could not be undone.
23
23
  *
24
- * `checkRuntimeRole` defaults to `false` here, the opposite of
25
- * `createLedger`'s own default (#232, needs a human call -- flagged
26
- * `ai:needs-human`, not decided here): that check requires the connection
27
- * to be a real, restricted `<database>_rt` role, the one 0001's migration
28
- * revokes UPDATE/DELETE/TRUNCATE from by name
29
- * (`current_database() || '_rt'`). foundry has no such role for its own
30
- * database -- `FOUNDRY_DATABASE_URL` is one connection, no owner/runtime
31
- * split the way archon's, manage's or lokessmie's own `DATABASE_URL_OWNER`/
32
- * `DATABASE_URL` pair is (see #239, the same split for a *hosted* app's
33
- * database -- this is foundry's *own*). Leaving the default on would make
34
- * every real call throw `UnsafeRuntimeRoleError`, since the revoke in
35
- * 0001_audit.sql never ran against whatever role `FOUNDRY_DATABASE_URL`
36
- * actually is; the append-only guard and the chain still hold at the
37
- * database level regardless (0001's triggers, 0004's hashing), only the
38
- * extra fail-closed check on the *connection* is off. Options, not a second
39
- * positional argument (`CLAUDE.md` rule 7): a caller -- today only a test
40
- * fixture -- can pass `true` once foundry's own database gets that split.
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).
41
37
  */
42
38
  export declare function createPostgresAuditLog(db: FoundryStoreDb, options?: {
43
39
  checkRuntimeRole?: boolean;
@@ -61,29 +61,25 @@ function boundPlan(plan) {
61
61
  * backfill can still be written later, where discarding the old rows
62
62
  * outright could not be undone.
63
63
  *
64
- * `checkRuntimeRole` defaults to `false` here, the opposite of
65
- * `createLedger`'s own default (#232, needs a human call -- flagged
66
- * `ai:needs-human`, not decided here): that check requires the connection
67
- * to be a real, restricted `<database>_rt` role, the one 0001's migration
68
- * revokes UPDATE/DELETE/TRUNCATE from by name
69
- * (`current_database() || '_rt'`). foundry has no such role for its own
70
- * database -- `FOUNDRY_DATABASE_URL` is one connection, no owner/runtime
71
- * split the way archon's, manage's or lokessmie's own `DATABASE_URL_OWNER`/
72
- * `DATABASE_URL` pair is (see #239, the same split for a *hosted* app's
73
- * database -- this is foundry's *own*). Leaving the default on would make
74
- * every real call throw `UnsafeRuntimeRoleError`, since the revoke in
75
- * 0001_audit.sql never ran against whatever role `FOUNDRY_DATABASE_URL`
76
- * actually is; the append-only guard and the chain still hold at the
77
- * database level regardless (0001's triggers, 0004's hashing), only the
78
- * extra fail-closed check on the *connection* is off. Options, not a second
79
- * positional argument (`CLAUDE.md` rule 7): a caller -- today only a test
80
- * fixture -- can pass `true` once foundry's own database gets that split.
64
+ * `checkRuntimeRole` defaults to `true`, `createLedger`'s own default. The
65
+ * connection must be a restricted role: not a superuser, no BYPASSRLS, and no
66
+ * UPDATE, DELETE or TRUNCATE on `audit_events`. A call through the owner
67
+ * connection throws `UnsafeRuntimeRoleError`. The check reads attributes of
68
+ * the connected role, not its name.
69
+ *
70
+ * The host provides that role. After it migrates with the owner URL, it calls
71
+ * `ensureRuntimeRole` from `@wtfalch/db/runtime-role` with
72
+ * `appendOnly: ['audit_events']` and the four audit functions in `grants`
73
+ * (README, "Foundry's own Postgres"), then connects the server as the runtime
74
+ * role. A test fixture without that split (PGlite's one user is a superuser)
75
+ * passes `checkRuntimeRole: false`. Options, not a second positional argument
76
+ * (`CLAUDE.md` rule 7).
81
77
  */
82
78
  export function createPostgresAuditLog(db, options = {}) {
83
79
  const ledger = createLedger({
84
80
  vocabulary: FOUNDRY_LEDGER_VOCABULARY,
85
81
  hashChain: true,
86
- checkRuntimeRole: options.checkRuntimeRole ?? false,
82
+ checkRuntimeRole: options.checkRuntimeRole,
87
83
  });
88
84
  const targetFor = (orgId, target) => ({
89
85
  type: target === 'platform' ? 'platform' : 'org',
@@ -7,6 +7,20 @@ import type { DeclaredOrgStore, PlatformStore, RequestedChangeStore } from '../s
7
7
  * connection itself.
8
8
  */
9
9
  export type FoundryStoreDb = PgDatabase<PgQueryResultHKT, any, any>;
10
+ /**
11
+ * The Postgres unique-violation constraint name, however the driver hands the
12
+ * error back -- directly (PGlite in this package's own tests) or wrapped in
13
+ * drizzle-orm's own `DrizzleQueryError`, whose `cause` carries the raw driver
14
+ * error (postgres-js in a host) -- or `undefined` when this is not a unique
15
+ * violation. Checked by Postgres error code, never by message text, since
16
+ * message text is not a contract. Same code-check pattern as
17
+ * `../runner/runner.ts`'s own `isUniqueViolation` -- kept as a local copy
18
+ * here rather than shared, since neither module depends on the other; this
19
+ * one additionally reads the constraint name (`constraint` under node-postgres and PGlite, `constraint_name` under postgres-js) so `create`/`update` can tell a slug
20
+ * collision from a domain collision (#131) rather than reporting both the
21
+ * same way.
22
+ */
23
+ export declare function uniqueViolationConstraint(error: unknown): string | undefined;
10
24
  /**
11
25
  * Thrown by `create` and `update` when the store refuses a write: an id
12
26
  * that already exists, an id that does not, a slug that fails
@@ -19,17 +19,22 @@ const NON_NEGATIVE_INTEGER = /^(0|[1-9]\d*)$/;
19
19
  * message text is not a contract. Same code-check pattern as
20
20
  * `../runner/runner.ts`'s own `isUniqueViolation` -- kept as a local copy
21
21
  * here rather than shared, since neither module depends on the other; this
22
- * one additionally reads `constraint` so `create`/`update` can tell a slug
22
+ * one additionally reads the constraint name (`constraint` under node-postgres and PGlite, `constraint_name` under postgres-js) so `create`/`update` can tell a slug
23
23
  * collision from a domain collision (#131) rather than reporting both the
24
24
  * same way.
25
25
  */
26
- function uniqueViolationConstraint(error) {
27
- const direct = error;
28
- if (direct?.code === UNIQUE_VIOLATION)
29
- return direct.constraint;
30
- const cause = error?.cause;
31
- if (cause?.code === UNIQUE_VIOLATION)
32
- return cause.constraint;
26
+ export function uniqueViolationConstraint(error) {
27
+ // Walk `cause`: drizzle-orm wraps the driver error, and how deep differs by version. Bounded so a cyclic `cause` ends.
28
+ let current = error;
29
+ for (let depth = 0; depth < 5 && current !== null && typeof current === 'object'; depth += 1) {
30
+ const e = current;
31
+ if (e.code === UNIQUE_VIOLATION) {
32
+ // node-postgres spells it `constraint`; postgres-js spells it `constraint_name`.
33
+ const name = e.constraint ?? e.constraint_name;
34
+ return typeof name === 'string' ? name : undefined;
35
+ }
36
+ current = e.cause;
37
+ }
33
38
  return undefined;
34
39
  }
35
40
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -15,6 +15,10 @@
15
15
  "types": "./dist/index.d.ts",
16
16
  "default": "./dist/index.js"
17
17
  },
18
+ "./migrations-dir": {
19
+ "types": "./dist/migrations-dir.d.ts",
20
+ "default": "./dist/migrations-dir.js"
21
+ },
18
22
  "./migrations/*.sql": "./dist/migrations/*.sql",
19
23
  "./package.json": "./package.json"
20
24
  },
@@ -30,7 +34,6 @@
30
34
  "typecheck": "tsc --noEmit",
31
35
  "test": "vitest run",
32
36
  "db:provision": "node scripts/foundry-db-provision.mjs",
33
- "db:migrate": "node scripts/foundry-db-migrate.mjs",
34
37
  "calls-sfu:deploy": "node scripts/calls-sfu-deploy.mjs"
35
38
  },
36
39
  "peerDependencies": {
@@ -47,8 +50,8 @@
47
50
  "devDependencies": {
48
51
  "@electric-sql/pglite": "^0.5.8",
49
52
  "@types/node": "^22",
53
+ "@wtfalch/db": "0.5.2",
50
54
  "drizzle-orm": "0.45.2",
51
- "postgres": "3.4.9",
52
55
  "typescript": "^5.9.0",
53
56
  "vitest": "^4.1.6"
54
57
  }
package/versions.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "zitadel": "v4.17.3",
3
3
  "synapse": "v1.161.0",
4
4
  "mas": "1.24.0",
5
- "livekit": "latest",
5
+ "livekit": "v1.13.7",
6
6
  "cloudflared": "2026.9.3",
7
7
  "stalwart": "v0.16.24"
8
8
  }
package/dist/migrate.d.ts DELETED
@@ -1,38 +0,0 @@
1
- /** This package's own migrations -- `../migrations` from `src/`, `./migrations` once built to `dist/` (`package.json`'s `build` script copies the `.sql` files there alongside `migrate.js`). */
2
- export declare const MIGRATIONS_DIR: string;
3
- /**
4
- * The one capability `migrate` needs from a driver: run SQL text (one
5
- * statement or several) and run a callback inside a transaction. Deliberately
6
- * not a drizzle `PgDatabase` or a concrete driver type -- drizzle's own
7
- * `.execute()` routes a multi-statement string through a single prepared
8
- * query on PGlite, which rejects it, so a migration file's raw text has to
9
- * reach the driver's own multi-statement path (`postgres`'s `.unsafe()`,
10
- * PGlite's `.exec()`) instead. Same "narrow port, no concrete client type"
11
- * shape `plan/executor.ts`'s `CoolifyPort` uses, for the same reason: a real
12
- * driver satisfies this structurally, with a few lines of adapter at the
13
- * call site (see `scripts/foundry-db-migrate.mjs` and `migrate.test.ts`).
14
- */
15
- export interface MigrateSql {
16
- /** Run SQL text and discard any result -- the tracking table's own DDL, and each migration file's contents. */
17
- exec(text: string): Promise<void>;
18
- /**
19
- * Run one literal SELECT and return its rows. Only ever called here with
20
- * fixed text this module builds itself (the tracking table's name, and a
21
- * migration filename already checked against `/^\d{4}_.+\.sql$/`), so
22
- * there is no external input to inject.
23
- */
24
- query<T = Record<string, unknown>>(text: string): Promise<T[]>;
25
- /** Run `fn` inside one transaction, handing it a `MigrateSql` scoped to it; rolled back if `fn` throws. */
26
- transaction<T>(fn: (tx: MigrateSql) => Promise<T>): Promise<T>;
27
- }
28
- export interface MigrationResult {
29
- /** Names of the migration files applied this run, in filename order -- empty when the database was already current. */
30
- applied: string[];
31
- }
32
- /**
33
- * Applies every migration file in `dir` (default: this package's own
34
- * `../migrations`) not yet recorded in `foundry_schema_migrations`, in
35
- * filename order, one transaction per file. Safe to call repeatedly: a file
36
- * already recorded is never re-read, let alone re-run.
37
- */
38
- export declare function migrate(sql: MigrateSql, dir?: string): Promise<MigrationResult>;
package/dist/migrate.js DELETED
@@ -1,47 +0,0 @@
1
- import { readFileSync, readdirSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- // #R2 (map gap: foundry's own Postgres, two commands): a tracked, idempotent
5
- // migration runner. `./test/db.ts` and `./runner/test/db.ts` already read
6
- // and sort this same directory to build one concatenated blob for a fresh
7
- // PGlite -- fine for a test fixture that always starts empty, wrong for a
8
- // real host that must apply only what a `foundry_schema_migrations` table
9
- // does not yet list. `migrationFiles` below is that same discovery, reused
10
- // rather than re-implemented, kept per-file instead of concatenated.
11
- const here = dirname(fileURLToPath(import.meta.url));
12
- /** This package's own migrations -- `../migrations` from `src/`, `./migrations` once built to `dist/` (`package.json`'s `build` script copies the `.sql` files there alongside `migrate.js`). */
13
- export const MIGRATIONS_DIR = join(here, 'migrations');
14
- const TRACKING_TABLE = 'foundry_schema_migrations';
15
- function migrationFiles(dir) {
16
- return readdirSync(dir)
17
- .filter((f) => /^\d{4}_.+\.sql$/.test(f))
18
- .sort()
19
- .map((name) => ({ name, path: join(dir, name) }));
20
- }
21
- /**
22
- * Applies every migration file in `dir` (default: this package's own
23
- * `../migrations`) not yet recorded in `foundry_schema_migrations`, in
24
- * filename order, one transaction per file. Safe to call repeatedly: a file
25
- * already recorded is never re-read, let alone re-run.
26
- */
27
- export async function migrate(sql, dir = MIGRATIONS_DIR) {
28
- await sql.exec(`create table if not exists ${TRACKING_TABLE} (name text primary key, applied_at timestamptz not null default now())`);
29
- const appliedRows = await sql.query(`select name from ${TRACKING_TABLE}`);
30
- const applied = new Set(appliedRows.map((row) => row.name));
31
- const pending = migrationFiles(dir).filter((file) => !applied.has(file.name));
32
- const done = [];
33
- for (const file of pending) {
34
- const text = readFileSync(file.path, 'utf8');
35
- await sql.transaction(async (tx) => {
36
- await tx.exec(text);
37
- // `file.name` is drawn from `migrationFiles`' own regex-filtered
38
- // `readdirSync`, never from outside this module -- see `MigrateSql`'s
39
- // doc comment. Quotes are still escaped, so an oddly-named future
40
- // migration file cannot break the statement.
41
- const escaped = file.name.replace(/'/g, "''");
42
- await tx.exec(`insert into ${TRACKING_TABLE} (name) values ('${escaped}')`);
43
- });
44
- done.push(file.name);
45
- }
46
- return { applied: done };
47
- }