@wtfalch/foundry 0.34.0 → 0.35.1

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 (88) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +1 -1
  2. package/dist/bootstrap/bootstrap.js +49 -11
  3. package/dist/bootstrap/declare.d.ts +12 -5
  4. package/dist/bootstrap/declare.js +33 -18
  5. package/dist/bootstrap/versions.d.ts +20 -0
  6. package/dist/bootstrap/versions.js +46 -0
  7. package/dist/calls-sfu.js +3 -3
  8. package/dist/index.d.ts +9 -4
  9. package/dist/index.js +5 -2
  10. package/dist/migrations/0053_heartbeats_provider.sql +9 -0
  11. package/dist/migrations/0053_org_version_pin.sql +6 -0
  12. package/dist/org.d.ts +60 -14
  13. package/dist/org.js +118 -28
  14. package/dist/plan/backup-gate.d.ts +93 -0
  15. package/dist/plan/backup-gate.js +261 -0
  16. package/dist/plan/broker-entries.d.ts +9 -1
  17. package/dist/plan/broker-entries.js +20 -3
  18. package/dist/plan/build-record.d.ts +56 -0
  19. package/dist/plan/build-record.js +109 -0
  20. package/dist/plan/calls-matrix.js +3 -3
  21. package/dist/plan/cloudflared-connector.js +2 -2
  22. package/dist/plan/company-backups.d.ts +31 -0
  23. package/dist/plan/company-backups.js +101 -0
  24. package/dist/plan/deploy-health.d.ts +12 -2
  25. package/dist/plan/deploy-health.js +3 -1
  26. package/dist/plan/destinations.d.ts +73 -0
  27. package/dist/plan/destinations.js +217 -0
  28. package/dist/plan/diff.d.ts +17 -8
  29. package/dist/plan/diff.js +131 -131
  30. package/dist/plan/edge-access.d.ts +15 -0
  31. package/dist/plan/edge-access.js +21 -2
  32. package/dist/plan/executor.d.ts +166 -7
  33. package/dist/plan/executor.js +547 -106
  34. package/dist/plan/health-targets.d.ts +1 -3
  35. package/dist/plan/health-targets.js +36 -36
  36. package/dist/plan/held-key-refs.d.ts +5 -0
  37. package/dist/plan/held-key-refs.js +5 -0
  38. package/dist/plan/hostnames.d.ts +10 -0
  39. package/dist/plan/hostnames.js +14 -0
  40. package/dist/plan/index.d.ts +5 -0
  41. package/dist/plan/index.js +3 -0
  42. package/dist/plan/live.d.ts +6 -0
  43. package/dist/plan/live.js +3 -2
  44. package/dist/plan/locked-hostnames.d.ts +2 -1
  45. package/dist/plan/locked-hostnames.js +16 -3
  46. package/dist/plan/login-host.d.ts +9 -20
  47. package/dist/plan/login-host.js +10 -25
  48. package/dist/plan/mail-relay.d.ts +48 -29
  49. package/dist/plan/mail-relay.js +145 -68
  50. package/dist/plan/network-move.d.ts +20 -0
  51. package/dist/plan/network-move.js +41 -0
  52. package/dist/plan/oikos-ingress.d.ts +111 -0
  53. package/dist/plan/oikos-ingress.js +231 -0
  54. package/dist/plan/org-names.d.ts +8 -2
  55. package/dist/plan/org-names.js +57 -1
  56. package/dist/plan/own-diff.d.ts +9 -8
  57. package/dist/plan/own-diff.js +42 -34
  58. package/dist/plan/own-hostnames.d.ts +27 -0
  59. package/dist/plan/own-hostnames.js +24 -0
  60. package/dist/plan/own-limits.d.ts +198 -7
  61. package/dist/plan/own-limits.js +163 -32
  62. package/dist/plan/own-services.js +3 -1
  63. package/dist/plan/own-sfu.js +3 -2
  64. package/dist/plan/plan-apply.d.ts +47 -7
  65. package/dist/plan/plan-apply.js +184 -38
  66. package/dist/plan/platform-diff.d.ts +133 -17
  67. package/dist/plan/platform-diff.js +231 -68
  68. package/dist/plan/platform-org.d.ts +79 -10
  69. package/dist/plan/platform-org.js +93 -13
  70. package/dist/plan/platform-providers.d.ts +118 -73
  71. package/dist/plan/platform-providers.js +770 -231
  72. package/dist/plan/proxy-labels.d.ts +57 -11
  73. package/dist/plan/proxy-labels.js +94 -14
  74. package/dist/plan/run-options.d.ts +19 -0
  75. package/dist/plan/run-options.js +46 -0
  76. package/dist/plan/zitadel-login-policy.d.ts +1 -1
  77. package/dist/plan/zitadel-login-policy.js +76 -8
  78. package/dist/platform.d.ts +32 -0
  79. package/dist/platform.js +16 -0
  80. package/dist/provision.d.ts +2 -0
  81. package/dist/provision.js +3 -1
  82. package/dist/slug.d.ts +3 -1
  83. package/dist/slug.js +25 -3
  84. package/dist/store/postgres.js +43 -1
  85. package/dist/store/schema.d.ts +36 -0
  86. package/dist/store/schema.js +4 -0
  87. package/package.json +3 -3
  88. package/versions.digests.json +11 -0
package/dist/org.js CHANGED
@@ -1,4 +1,4 @@
1
- import { floatingTagBlocked } from './bootstrap/versions.js';
1
+ import { floatingRefBlocked, floatingTagBlocked } from './bootstrap/versions.js';
2
2
  /** {@link MailConfig.mailApps} when the org declares none of its own (#34, decided 2026-09-30). */
3
3
  export const DEFAULT_MAIL_APPS = ['agora', 'webmail'];
4
4
  /** {@link MailConfig.noReplyLocalPart}'s own default, restated wherever it is read rather than imported (this file's `LOG_DIR`-style convention, named in `plan/executor.ts`'s own doc comments). */
@@ -41,24 +41,18 @@ export function appEntry(hosting) {
41
41
  return hosting.entries.find((entry) => entry.key === APP_ENTRY_KEY);
42
42
  }
43
43
  /**
44
- * Two sources are secrets. `sharedSecret` is {@link EnvSharedSecret}.
45
- * `platformMailKey` is the platform's held Lettermint key, handed over as the
46
- * value of the declared env name (the platform's own apps call it
47
- * `LETTERMINT_API_TOKEN`). Any hosting entry of any customer org may name it
48
- * (William, 2026-10-04): mail is Lettermint, and the key is the platform's.
44
+ * `sharedSecret` is the one secret source: {@link EnvSharedSecret}. The
45
+ * platform's Lettermint key is not a source any more (2026-10-11): each
46
+ * company uses its own mail key.
49
47
  */
50
48
  export const APP_ENV_SOURCES = new Set([
51
49
  'filesOrganisationId',
52
50
  'aiOrganisationId',
53
51
  'mailDomain',
54
52
  'sharedSecret',
55
- 'platformMailKey',
56
53
  ]);
57
54
  /** The sources whose value is a secret: resolved at apply time, never recorded. */
58
- export const SECRET_APP_ENV_SOURCES = new Set([
59
- 'sharedSecret',
60
- 'platformMailKey',
61
- ]);
55
+ export const SECRET_APP_ENV_SOURCES = new Set(['sharedSecret']);
62
56
  /** The held-key ref of an org's shared secret. */
63
57
  export const sharedSecretRef = (orgId, name) => `foundry:shared-secret:${orgId}:${name}`;
64
58
  const ISSUER_APP_KINDS = new Set(['web', 'native', 'api']);
@@ -336,6 +330,11 @@ function validateHostingEntry(entry, enabled) {
336
330
  if (entry.healthPath !== null && !entry.healthPath.startsWith('/')) {
337
331
  return `${at} healthPath must start with '/', or be null`;
338
332
  }
333
+ if (entry.capabilities !== undefined &&
334
+ entry.capabilities !== 'restricted' &&
335
+ entry.capabilities !== 'standard') {
336
+ return `${at} capabilities must be 'restricted' or 'standard'`;
337
+ }
339
338
  if (entry.database && !enabled) {
340
339
  return `${at} database requires hosting.enabled`;
341
340
  }
@@ -347,6 +346,9 @@ function validateHostingEntry(entry, enabled) {
347
346
  return `${at} env '${name}' is set by foundry and cannot be declared`;
348
347
  }
349
348
  if (typeof value !== 'string') {
349
+ if (value?.from === 'platformMailKey') {
350
+ return `${at} env '${name}' has an unknown source 'platformMailKey': each company uses its own mail key`;
351
+ }
350
352
  if (!APP_ENV_SOURCES.has(value?.from)) {
351
353
  return `${at} env '${name}' has an unknown source '${value?.from}'`;
352
354
  }
@@ -387,6 +389,10 @@ function validateEntryAuth(entry, hosting, domain) {
387
389
  (typeof auth.replacesBrokerEntry !== 'string' || auth.replacesBrokerEntry === '')) {
388
390
  return `${at} replacesBrokerEntry must be the non-empty name of a broker entry`;
389
391
  }
392
+ if (typeof auth.replacesBrokerEntry === 'string' &&
393
+ isFoundryOwnedBrokerEntryName(auth.replacesBrokerEntry)) {
394
+ return `${at} replacesBrokerEntry '${auth.replacesBrokerEntry}' is a broker entry foundry writes itself`;
395
+ }
390
396
  if (!hasExactHostname(entry, hosting, domain)) {
391
397
  return `${at} needs an exact hostname to sign in at`;
392
398
  }
@@ -627,6 +633,9 @@ function urlHasPassword(value) {
627
633
  * as {@link validateBuyerIdentity}/{@link validateOwners}.
628
634
  */
629
635
  const DOMAIN_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
636
+ const SHARED_ZONE = 'oikos.host';
637
+ /** The mail relay accepts only a letters-only top-level label (`plan/mail-relay.ts`); one domain it rejects would block the relay step for every company. */
638
+ const RELAY_TLD = /^[a-z]{2,63}$/;
630
639
  export function validateDomain(domain) {
631
640
  if (domain.length > 253) {
632
641
  return `domain '${domain}' exceeds 253 characters`;
@@ -640,6 +649,45 @@ export function validateDomain(domain) {
640
649
  return `domain '${domain}' has an invalid label '${label}': must be 1-63 characters, lowercase a-z, 0-9 and hyphen, and may not start or end with a hyphen`;
641
650
  }
642
651
  }
652
+ if (!RELAY_TLD.test(labels[labels.length - 1] ?? '')) {
653
+ return `domain '${domain}' must end in a top-level label of 2 to 63 letters`;
654
+ }
655
+ // oikos.host is the shared zone every company without a domain runs on; no company owns it or a name under it.
656
+ if (domain === SHARED_ZONE || domain.endsWith(`.${SHARED_ZONE}`)) {
657
+ return `domain '${domain}' is on ${SHARED_ZONE}, the shared zone for companies without a domain; a company cannot own it`;
658
+ }
659
+ return null;
660
+ }
661
+ /**
662
+ * A broker entry name foundry writes itself: Archon's, and any company's Boule.
663
+ * A hosting entry may not declare that it replaces one.
664
+ */
665
+ export function isFoundryOwnedBrokerEntryName(name) {
666
+ return name === 'archon' || name.endsWith('-boule');
667
+ }
668
+ /** True when one name is the other or a subdomain of the other. */
669
+ function domainsOverlap(a, b) {
670
+ return a === b || a.endsWith(`.${b}`) || b.endsWith(`.${a}`);
671
+ }
672
+ /**
673
+ * `null` when `domain` may be claimed by company `orgId`, given the platform's
674
+ * apex and owner company and every other company's declared domain; otherwise
675
+ * the reason it may not. The platform's apex and anything under it belong to
676
+ * the owner company alone, and no company takes a parent or subdomain of
677
+ * another's, since Cloudflare would resolve both to one zone.
678
+ */
679
+ export function validateDomainClaim(domain, claim) {
680
+ const { platform } = claim;
681
+ if (platform && claim.orgId !== platform.ownerOrgId && domainsOverlap(domain, platform.apex)) {
682
+ return `domain '${domain}' overlaps the platform's own domain, which only the owner company may use`;
683
+ }
684
+ for (const other of claim.others) {
685
+ if (other.id === claim.orgId || other.domain === null)
686
+ continue;
687
+ if (domainsOverlap(domain, other.domain)) {
688
+ return `domain '${domain}' is a parent or subdomain of ${other.domain}, which another company declares`;
689
+ }
690
+ }
643
691
  return null;
644
692
  }
645
693
  /**
@@ -652,11 +700,9 @@ export function validateDomain(domain) {
652
700
  * and cms there (`site`), never both -- both would otherwise fight over the
653
701
  * same hostname (https://github.com/wtfalch/foundry/issues/99).
654
702
  *
655
- * - `site.enabled` requires `domain` to be set -- a site with nowhere to
656
- * live is not a site.
657
- * - `site.enabled` requires `site.usesDomain` -- unlike mail, chat, calls
658
- * and the issuer, the site has no meaning anywhere but the org's own
659
- * domain, so turning it on always means the domain opt-in too.
703
+ * - `site.enabled` with a `domain` requires `site.usesDomain`: the site then
704
+ * lives on the domain. Without a `domain` it lives on `<slug>.oikos.host`
705
+ * and `site.usesDomain` must be off.
660
706
  * - `site.enabled` and `hosting.enabled` cannot both be on.
661
707
  * - `site.enabled` refuses an `issuerApps` entry named `cms`: the fixed cms
662
708
  * client (#139, `plan/diff.ts`'s `planCmsClient`) already owns the
@@ -666,12 +712,14 @@ export function validateDomain(domain) {
666
712
  * to.
667
713
  */
668
714
  export function validateSite(org) {
669
- if (org.site.enabled && !org.domain) {
670
- return 'site.enabled requires domain';
671
- }
672
- if (org.site.enabled && !org.site.usesDomain) {
715
+ // A company without a domain has its site at `<slug>.oikos.host` and its editor
716
+ // at `<slug>-cms.oikos.host`; `site.usesDomain` is then off, and on with a domain.
717
+ if (org.site.enabled && org.domain && !org.site.usesDomain) {
673
718
  return 'site.enabled requires site.usesDomain';
674
719
  }
720
+ if (org.site.enabled && !org.domain && org.site.usesDomain) {
721
+ return 'site.usesDomain requires domain';
722
+ }
675
723
  if (org.site.enabled && org.hosting.enabled) {
676
724
  return 'site.enabled and hosting.enabled cannot both be on';
677
725
  }
@@ -691,6 +739,9 @@ export function validateSite(org) {
691
739
  * reason to pin at all.
692
740
  */
693
741
  export function validateCalls(org) {
742
+ // `usesDomain` serves the calls service at `calls.<domain>`; a company without a domain has none.
743
+ if (org.calls.usesDomain && org.domain === null)
744
+ return 'calls.usesDomain requires domain';
694
745
  const pin = org.calls.versionPin;
695
746
  if (!pin)
696
747
  return null;
@@ -706,8 +757,49 @@ export function validateCalls(org) {
706
757
  }
707
758
  return null;
708
759
  }
760
+ /** The parts of a company that can be held on their own version. */
761
+ export const VERSION_PIN_PARTS = ['auth', 'mail', 'files', 'jobs', 'sfu', 'calls'];
762
+ const VERSION_PIN_LABELS = {
763
+ auth: { label: 'auth', example: 'v1.4.0', gitRef: true },
764
+ mail: { label: 'Stalwart', example: 'v0.16.24', gitRef: false },
765
+ files: { label: 'files', example: 'v1.4.0', gitRef: true },
766
+ jobs: { label: 'jobs', example: 'v1.4.0', gitRef: true },
767
+ sfu: { label: 'LiveKit', example: 'v1.9.0', gitRef: false },
768
+ calls: { label: 'Synapse', example: 'v1.161.0', gitRef: false },
769
+ };
770
+ const VERSION_PIN_VALUE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/;
771
+ /**
772
+ * `versionPin` holds parts of a company on an older release while others move.
773
+ * Every value is a fixed ref or tag: a floating one would defeat the hold.
774
+ * Also run on a stored value when it is read, so a bad row is not trusted.
775
+ */
776
+ export function validateVersionPin(org) {
777
+ const pin = org.versionPin;
778
+ if (pin === undefined || pin === null)
779
+ return null;
780
+ if (typeof pin !== 'object' || Array.isArray(pin))
781
+ return 'versionPin must be an object';
782
+ for (const [part, value] of Object.entries(pin)) {
783
+ if (!VERSION_PIN_PARTS.includes(part)) {
784
+ return `versionPin.${part} is not a part; use auth, mail, files, jobs, sfu or calls`;
785
+ }
786
+ if (typeof value !== 'string' || value.trim() === '') {
787
+ return `versionPin.${part} must not be empty; remove the part to follow the platform`;
788
+ }
789
+ const meta = VERSION_PIN_LABELS[part];
790
+ const blocked = meta.gitRef
791
+ ? floatingRefBlocked(meta.label, value, meta.example)
792
+ : floatingTagBlocked(meta.label, value, meta.example);
793
+ if (blocked)
794
+ return `versionPin.${part} ${blocked}`;
795
+ if (!VERSION_PIN_VALUE.test(value) || value.includes('..')) {
796
+ return `versionPin.${part} must be a release name of letters, digits, ".", "_", "+" and "-" (at most 128 characters, no ".."); it goes to Coolify as a git ref or an image tag`;
797
+ }
798
+ }
799
+ return null;
800
+ }
709
801
  export const SFU_SLOT_MAX = 20;
710
- /** The only reader of `services.own`: an org without `services` runs the platform's shared services. */
802
+ /** The only reader of `services.own`. Every company runs its own services, the platform's owner company too; a company without a domain runs them on oikos.host. */
711
803
  export function ownsServices(org) {
712
804
  return org.services?.own === true;
713
805
  }
@@ -717,14 +809,12 @@ export function validateServices(org) {
717
809
  if (!ownsServices(org)) {
718
810
  return slot === null ? null : 'services.sfuSlot needs services.own';
719
811
  }
720
- if (org.domain === null) {
721
- return "services.own needs the org's own domain: the issuer is auth.<domain>";
722
- }
723
- if (!org.issuer.usesDomain) {
724
- return 'services.own needs issuer.usesDomain: the issuer is auth.<domain>';
812
+ const hasDomain = org.domain !== null;
813
+ if (org.issuer.usesDomain !== hasDomain) {
814
+ return 'services.own: issuer.usesDomain must be on exactly when the company has a domain';
725
815
  }
726
- if (org.mail.mode !== 'off' && !org.mail.usesDomain) {
727
- return "services.own needs mail.usesDomain: the company's mail server serves its own domain";
816
+ if (org.mail.mode !== 'off' && org.mail.usesDomain !== hasDomain) {
817
+ return 'services.own: mail.usesDomain must be on exactly when the company has a domain';
728
818
  }
729
819
  if (org.calls.enabled &&
730
820
  (slot === null || !Number.isInteger(slot) || slot < 1 || slot > SFU_SLOT_MAX)) {
@@ -0,0 +1,93 @@
1
+ import type { OrgId } from '../org.js';
2
+ import type { VersionChangeItem } from '../plan.js';
3
+ import { type CoolifyPort } from './executor.js';
4
+ /** A confirmed stateful upgrade needs a dump that finished within this many hours. */
5
+ export declare const BACKUP_MAX_AGE_HOURS = 26;
6
+ /** A move deletes the databases, so the dump it relies on is at most this old. */
7
+ export declare const MOVE_BACKUP_MAX_AGE_MINUTES = 60;
8
+ /**
9
+ * Thrown before any step runs when a confirmed upgrade of a stateful service
10
+ * has no fresh finished backup. The message names the service and nothing
11
+ * else: no database name, uuid or URL. There is no override.
12
+ */
13
+ export declare class UpgradeBackupMissingError extends Error {
14
+ readonly service: string;
15
+ /** True when this Coolify has no executions route, so only the schedule was checked. */
16
+ readonly scheduleOnly: boolean;
17
+ constructor(service: string,
18
+ /** True when this Coolify has no executions route, so only the schedule was checked. */
19
+ scheduleOnly?: boolean);
20
+ }
21
+ /**
22
+ * A network move is refused when the host has no backup gate, when a database of the project has no
23
+ * verified dump under {@link MOVE_BACKUP_MAX_AGE_MINUTES} minutes old, or when the project holds
24
+ * something no dump covers (the company's mail server, an app with a volume) or cannot be read. The
25
+ * message names no database, app, uuid or URL. There is no override: such a company is moved by hand.
26
+ */
27
+ export declare class NetworkMoveBackupMissingError extends Error {
28
+ readonly reason: 'no-gate' | 'no-backup' | 'by-hand';
29
+ constructor(reason: 'no-gate' | 'no-backup' | 'by-hand');
30
+ }
31
+ export interface BackupGatePorts {
32
+ /** The company's recorded ids (`AppliedState.serviceIds`): database uuids and `backup_<db>` schedule uuids. */
33
+ recordedIds: Readonly<Record<string, string>>;
34
+ coolify: Pick<CoolifyPort, 'listDatabaseBackups' | 'raw'> & Partial<Pick<CoolifyPort, 'listDatabaseBackupExecutions'>>;
35
+ /** The org's id, for the `app` database key. Unused when `scope` is given. */
36
+ orgId: OrgId;
37
+ /**
38
+ * The platform's databases instead of a company's: the recorded-id key of
39
+ * each database, and which of them an upgrade of a service migrates. The
40
+ * platform's backup steps record no schedule uuid, so the schedule is found
41
+ * by listing the database's schedules (enabled, saved to S3).
42
+ */
43
+ scope?: {
44
+ keys: Readonly<Record<string, string>>;
45
+ forService(service: string): readonly string[] | 'all';
46
+ };
47
+ now?: () => number;
48
+ }
49
+ /**
50
+ * Refuses a confirmed upgrade of a stateful service whose database has no
51
+ * finished backup in the last {@link BACKUP_MAX_AGE_HOURS} hours. Runs after
52
+ * `unconfirmedUpgrades` and before any step. A service whose database the
53
+ * company does not have passes (there is nothing to migrate); a database that
54
+ * exists with no recorded schedule, a disabled or non-S3 schedule, a failed or
55
+ * old dump, or an unreadable list all refuse. `mail` has no Postgres and is
56
+ * not covered here (plan note `backup-not-covered:mail`).
57
+ *
58
+ * Where this Coolify has no executions route, the fallback is that the
59
+ * schedule exists, is enabled and saves to S3; that proves a dump is
60
+ * scheduled, not that one ran, so it passes with no backup id.
61
+ *
62
+ * Returns the uuid of the newest fresh execution across the checked
63
+ * databases, for the caller to record as `backupId`, or `null`.
64
+ */
65
+ export declare function requireFreshBackupsForUpgrades(plan: {
66
+ versionChanges?: readonly VersionChangeItem[];
67
+ }, confirmed: readonly string[] | undefined, ports: BackupGatePorts): Promise<{
68
+ backupId: string | null;
69
+ }>;
70
+ export interface MoveGatePorts {
71
+ /** The company's Coolify project (`coolify_project`). */
72
+ projectUuid: string | undefined;
73
+ /** The company runs its own mail server: its volume has no dump. */
74
+ ownsMail: boolean;
75
+ coolify: BackupGatePorts['coolify'] & Partial<Pick<CoolifyPort, 'listResources' | 'listStorages'>>;
76
+ now?: () => number;
77
+ }
78
+ /**
79
+ * The gate for a move to the company's own network, which deletes every database of the project. It
80
+ * fails closed and is stricter than the upgrade gate: it reads the project itself, not foundry's records.
81
+ * It passes only when every database in the project has a successful dump, seen in Coolify's
82
+ * executions, from the last {@link MOVE_BACKUP_MAX_AGE_MINUTES} minutes, and the project holds nothing
83
+ * a dump does not cover. It refuses (`by-hand`) the company's own mail server and any app with a
84
+ * volume other than its log volume, and (`no-backup`) a database with no schedule or no execution, an
85
+ * unreadable list, and a Coolify with no executions route. A company with neither database nor volume passes.
86
+ */
87
+ export declare function requireFreshBackupsForMove(ports: MoveGatePorts): Promise<void>;
88
+ /**
89
+ * The platform databases an upgrade of `service` can migrate. Postgres is all three. archon, ai and
90
+ * integrations each migrate their own database when the new container starts. The owner company runs its own
91
+ * sign-in, files and jobs, so the platform has no database for them; mail, edge and hc have no Postgres.
92
+ */
93
+ export declare function platformDatabasesForService(service: string): readonly string[] | 'all';
@@ -0,0 +1,261 @@
1
+ import { backupScopeKey, companyDatabaseKeys, databasesForService } from './company-backups.js';
2
+ import { LOG_DIR } from './executor.js';
3
+ /** A confirmed stateful upgrade needs a dump that finished within this many hours. */
4
+ export const BACKUP_MAX_AGE_HOURS = 26;
5
+ /** A move deletes the databases, so the dump it relies on is at most this old. */
6
+ export const MOVE_BACKUP_MAX_AGE_MINUTES = 60;
7
+ /** How long one Coolify call of the gate may take. */
8
+ const GATE_CALL_TIMEOUT_MS = 30_000;
9
+ /**
10
+ * Thrown before any step runs when a confirmed upgrade of a stateful service
11
+ * has no fresh finished backup. The message names the service and nothing
12
+ * else: no database name, uuid or URL. There is no override.
13
+ */
14
+ export class UpgradeBackupMissingError extends Error {
15
+ service;
16
+ scheduleOnly;
17
+ constructor(service,
18
+ /** True when this Coolify has no executions route, so only the schedule was checked. */
19
+ scheduleOnly = false) {
20
+ const note = scheduleOnly
21
+ ? ' (this Coolify has no executions route, so only the schedule was checked, and it is missing, disabled or not saved to S3)'
22
+ : '';
23
+ super(`upgrade of ${service} refused: no finished backup of its database in the last ${BACKUP_MAX_AGE_HOURS} hours${note}`);
24
+ this.service = service;
25
+ this.scheduleOnly = scheduleOnly;
26
+ this.name = 'UpgradeBackupMissingError';
27
+ }
28
+ }
29
+ /**
30
+ * A network move is refused when the host has no backup gate, when a database of the project has no
31
+ * verified dump under {@link MOVE_BACKUP_MAX_AGE_MINUTES} minutes old, or when the project holds
32
+ * something no dump covers (the company's mail server, an app with a volume) or cannot be read. The
33
+ * message names no database, app, uuid or URL. There is no override: such a company is moved by hand.
34
+ */
35
+ export class NetworkMoveBackupMissingError extends Error {
36
+ reason;
37
+ constructor(reason) {
38
+ super(reason === 'no-gate'
39
+ ? 'network move refused: this host has no backup gate, so a fresh backup cannot be checked'
40
+ : reason === 'no-backup'
41
+ ? `network move refused: not every database in the company's project has a finished dump from the last ${MOVE_BACKUP_MAX_AGE_MINUTES} minutes, checked in Coolify; take one now, or move this company by hand with docs/runbooks/network-move.md`
42
+ : "network move refused: the company's project holds something no backup covers (its mail server or an app with a volume), or Coolify could not be asked; move this company by hand with docs/runbooks/network-move.md");
43
+ this.reason = reason;
44
+ this.name = 'NetworkMoveBackupMissingError';
45
+ }
46
+ }
47
+ async function withinTimeout(call) {
48
+ let timer;
49
+ try {
50
+ return await Promise.race([
51
+ call(),
52
+ new Promise((_, reject) => {
53
+ timer = setTimeout(() => reject(new Error('timeout')), GATE_CALL_TIMEOUT_MS);
54
+ }),
55
+ ]);
56
+ }
57
+ finally {
58
+ clearTimeout(timer);
59
+ }
60
+ }
61
+ /**
62
+ * Coolify 4.x answers `GET /databases/{uuid}/backups/{backup}/executions` with
63
+ * `{ executions: [{ uuid, filename, size, created_at, message, status }] }`,
64
+ * newest first; `status` is `success`, `failed` or `running`. Read from
65
+ * `routes/api.php` and `DatabasesController::list_backup_executions` on the
66
+ * v4.x branch, 2026-10-10; not run against a live instance. Anything else is
67
+ * a shape this gate cannot trust, so it throws.
68
+ */
69
+ function parseExecutions(body) {
70
+ const list = Array.isArray(body) ? body : body?.executions;
71
+ if (!Array.isArray(list))
72
+ throw new Error('executions list has an unexpected shape');
73
+ return list.map((row) => {
74
+ const r = row;
75
+ if (!r ||
76
+ typeof r.uuid !== 'string' ||
77
+ typeof r.status !== 'string' ||
78
+ typeof r.created_at !== 'string') {
79
+ throw new Error('executions list has an unexpected shape');
80
+ }
81
+ return { uuid: r.uuid, status: r.status, created_at: r.created_at };
82
+ });
83
+ }
84
+ /** The executions of one schedule, or `null` when this Coolify has no such route. */
85
+ async function readExecutions(coolify, databaseUuid, backupUuid) {
86
+ try {
87
+ const list = coolify.listDatabaseBackupExecutions?.bind(coolify);
88
+ if (list)
89
+ return parseExecutions(await withinTimeout(() => list(databaseUuid, backupUuid)));
90
+ return parseExecutions(await withinTimeout(() => coolify.raw('GET', `/databases/${databaseUuid}/backups/${backupUuid}/executions`)));
91
+ }
92
+ catch (e) {
93
+ if (isRouteMissing(e))
94
+ return null;
95
+ throw e;
96
+ }
97
+ }
98
+ function isRouteMissing(error) {
99
+ const status = error?.status;
100
+ if (typeof status === 'number')
101
+ return status === 404;
102
+ const message = error instanceof Error ? error.message : String(error);
103
+ return /^\S+ \S+ \S+ → 404:/.test(message);
104
+ }
105
+ /** The newest `success` execution that is not older than the window, or null. */
106
+ function freshSuccess(executions, now, maxAgeMs = BACKUP_MAX_AGE_HOURS * 3_600_000, skewMs = 3_600_000) {
107
+ const oldest = now - maxAgeMs;
108
+ let best = null;
109
+ for (const execution of executions) {
110
+ if (execution.status !== 'success')
111
+ continue;
112
+ const at = Date.parse(execution.created_at);
113
+ if (Number.isNaN(at) || at < oldest || at > now + skewMs)
114
+ continue;
115
+ if (!best || at > best.at)
116
+ best = { execution, at };
117
+ }
118
+ return best?.execution ?? null;
119
+ }
120
+ /**
121
+ * Refuses a confirmed upgrade of a stateful service whose database has no
122
+ * finished backup in the last {@link BACKUP_MAX_AGE_HOURS} hours. Runs after
123
+ * `unconfirmedUpgrades` and before any step. A service whose database the
124
+ * company does not have passes (there is nothing to migrate); a database that
125
+ * exists with no recorded schedule, a disabled or non-S3 schedule, a failed or
126
+ * old dump, or an unreadable list all refuse. `mail` has no Postgres and is
127
+ * not covered here (plan note `backup-not-covered:mail`).
128
+ *
129
+ * Where this Coolify has no executions route, the fallback is that the
130
+ * schedule exists, is enabled and saves to S3; that proves a dump is
131
+ * scheduled, not that one ran, so it passes with no backup id.
132
+ *
133
+ * Returns the uuid of the newest fresh execution across the checked
134
+ * databases, for the caller to record as `backupId`, or `null`.
135
+ */
136
+ export async function requireFreshBackupsForUpgrades(plan, confirmed, ports) {
137
+ const now = (ports.now ?? Date.now)();
138
+ const keys = ports.scope?.keys ?? companyDatabaseKeys({ id: ports.orgId });
139
+ const names = Object.keys(keys);
140
+ const confirmedSet = new Set(confirmed ?? []);
141
+ let backupId = null;
142
+ let newest = 0;
143
+ for (const change of plan.versionChanges ?? []) {
144
+ if (!change.needsConfirmation || !confirmedSet.has(change.service))
145
+ continue;
146
+ const wanted = (ports.scope?.forService ?? databasesForService)(change.service);
147
+ const dbs = wanted === 'all' ? names : wanted;
148
+ for (const db of dbs) {
149
+ const databaseUuid = ports.recordedIds[keys[db]];
150
+ if (!databaseUuid)
151
+ continue;
152
+ let backupUuid = ports.recordedIds[backupScopeKey(db)];
153
+ if (!backupUuid && ports.scope) {
154
+ const schedules = await withinTimeout(() => ports.coolify.listDatabaseBackups(databaseUuid)).catch(() => []);
155
+ backupUuid = schedules.find((x) => x.enabled && x.save_s3)?.uuid;
156
+ }
157
+ if (!backupUuid)
158
+ throw new UpgradeBackupMissingError(change.service);
159
+ let executions;
160
+ try {
161
+ executions = await readExecutions(ports.coolify, databaseUuid, backupUuid);
162
+ }
163
+ catch {
164
+ // An unreadable list is not a fresh backup. The cause stays out of the
165
+ // message: it can carry a response body.
166
+ throw new UpgradeBackupMissingError(change.service);
167
+ }
168
+ if (executions === null) {
169
+ const schedules = await withinTimeout(() => ports.coolify.listDatabaseBackups(databaseUuid)).catch(() => []);
170
+ const ok = schedules.some((s) => s.uuid === backupUuid && s.enabled && s.save_s3);
171
+ if (!ok)
172
+ throw new UpgradeBackupMissingError(change.service, true);
173
+ continue;
174
+ }
175
+ const fresh = freshSuccess(executions, now);
176
+ if (!fresh)
177
+ throw new UpgradeBackupMissingError(change.service);
178
+ const at = Date.parse(fresh.created_at);
179
+ if (at >= newest) {
180
+ newest = at;
181
+ backupId = fresh.uuid;
182
+ }
183
+ }
184
+ }
185
+ return { backupId };
186
+ }
187
+ /**
188
+ * The gate for a move to the company's own network, which deletes every database of the project. It
189
+ * fails closed and is stricter than the upgrade gate: it reads the project itself, not foundry's records.
190
+ * It passes only when every database in the project has a successful dump, seen in Coolify's
191
+ * executions, from the last {@link MOVE_BACKUP_MAX_AGE_MINUTES} minutes, and the project holds nothing
192
+ * a dump does not cover. It refuses (`by-hand`) the company's own mail server and any app with a
193
+ * volume other than its log volume, and (`no-backup`) a database with no schedule or no execution, an
194
+ * unreadable list, and a Coolify with no executions route. A company with neither database nor volume passes.
195
+ */
196
+ export async function requireFreshBackupsForMove(ports) {
197
+ const { coolify } = ports;
198
+ const listResources = coolify.listResources?.bind(coolify);
199
+ const listStorages = coolify.listStorages?.bind(coolify);
200
+ if (!ports.projectUuid || !listResources || !listStorages || ports.ownsMail) {
201
+ throw new NetworkMoveBackupMissingError('by-hand');
202
+ }
203
+ const projectUuid = ports.projectUuid;
204
+ const now = (ports.now ?? Date.now)();
205
+ let resources;
206
+ try {
207
+ resources = await withinTimeout(() => listResources(projectUuid));
208
+ }
209
+ catch {
210
+ throw new NetworkMoveBackupMissingError('by-hand');
211
+ }
212
+ for (const app of resources.filter((r) => r.kind === 'application')) {
213
+ if (typeof app.key !== 'string')
214
+ throw new NetworkMoveBackupMissingError('by-hand');
215
+ const appUuid = app.key;
216
+ let storages;
217
+ try {
218
+ storages = await withinTimeout(() => listStorages(appUuid));
219
+ }
220
+ catch {
221
+ throw new NetworkMoveBackupMissingError('by-hand');
222
+ }
223
+ if ((storages.persistent_storages ?? []).some((v) => v.mount_path !== LOG_DIR)) {
224
+ throw new NetworkMoveBackupMissingError('by-hand');
225
+ }
226
+ }
227
+ for (const database of resources.filter((r) => r.kind === 'database')) {
228
+ if (typeof database.key !== 'string')
229
+ throw new NetworkMoveBackupMissingError('no-backup');
230
+ const databaseUuid = database.key;
231
+ let fresh = false;
232
+ try {
233
+ const schedules = await withinTimeout(() => coolify.listDatabaseBackups(databaseUuid));
234
+ for (const schedule of schedules) {
235
+ // A missing executions route is a refusal: a schedule alone proves no dump ran.
236
+ const executions = await readExecutions(coolify, databaseUuid, schedule.uuid);
237
+ if (executions &&
238
+ freshSuccess(executions, now, MOVE_BACKUP_MAX_AGE_MINUTES * 60_000, 5 * 60_000)) {
239
+ fresh = true;
240
+ break;
241
+ }
242
+ }
243
+ }
244
+ catch {
245
+ // The cause can carry a response body.
246
+ throw new NetworkMoveBackupMissingError('no-backup');
247
+ }
248
+ if (!fresh)
249
+ throw new NetworkMoveBackupMissingError('no-backup');
250
+ }
251
+ }
252
+ /**
253
+ * The platform databases an upgrade of `service` can migrate. Postgres is all three. archon, ai and
254
+ * integrations each migrate their own database when the new container starts. The owner company runs its own
255
+ * sign-in, files and jobs, so the platform has no database for them; mail, edge and hc have no Postgres.
256
+ */
257
+ export function platformDatabasesForService(service) {
258
+ if (service === 'postgres')
259
+ return 'all';
260
+ return service === 'archon' || service === 'ai' || service === 'integrations' ? [service] : [];
261
+ }
@@ -1,4 +1,4 @@
1
- import type { DeclaredOrg, HostingEntry } from '../org.js';
1
+ import { type DeclaredOrg, type HostingEntry } from '../org.js';
2
2
  import type { DeclaredOrgStore, PlatformStore } from '../store.js';
3
3
  import type { CoolifyPort } from './executor.js';
4
4
  /**
@@ -64,6 +64,13 @@ export interface BrokerPort {
64
64
  /** The platform's `PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY` value, if any. */
65
65
  recordedDeploy(): Promise<string | undefined>;
66
66
  recordDeploy(value: string): Promise<void>;
67
+ /**
68
+ * The id of Archon's own sign-in organisation, when the platform has made it.
69
+ * No other entry may sign in that organisation, so an entry that does is
70
+ * refused before anything is written. Optional: a port that cannot read it
71
+ * (an older host) skips the check.
72
+ */
73
+ archonOrganisationId?(): Promise<string | undefined>;
67
74
  }
68
75
  /** {@link BrokerPort} over the platform's recorded ids and a Coolify client. */
69
76
  export declare class PlatformBrokerPort implements BrokerPort {
@@ -89,6 +96,7 @@ export declare class PlatformBrokerPort implements BrokerPort {
89
96
  setAuthApps(raw: string): Promise<void>;
90
97
  deploy(): Promise<void>;
91
98
  recordedDeploy(): Promise<string | undefined>;
99
+ archonOrganisationId(): Promise<string | undefined>;
92
100
  recordDeploy(value: string): Promise<void>;
93
101
  }
94
102
  /**