@wtfalch/foundry 0.33.1 → 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 (106) 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 +24 -0
  6. package/dist/bootstrap/versions.js +46 -0
  7. package/dist/calls-sfu.d.ts +10 -0
  8. package/dist/calls-sfu.js +44 -3
  9. package/dist/index.d.ts +21 -8
  10. package/dist/index.js +11 -4
  11. package/dist/migrations/0051_org_services.sql +13 -0
  12. package/dist/migrations/0052_platform_mail_relay.sql +8 -0
  13. package/dist/migrations/0053_heartbeats_provider.sql +9 -0
  14. package/dist/migrations/0053_org_version_pin.sql +6 -0
  15. package/dist/org.d.ts +76 -12
  16. package/dist/org.js +136 -20
  17. package/dist/plan/backup-gate.d.ts +93 -0
  18. package/dist/plan/backup-gate.js +261 -0
  19. package/dist/plan/broker-entries.d.ts +9 -1
  20. package/dist/plan/broker-entries.js +20 -3
  21. package/dist/plan/build-record.d.ts +56 -0
  22. package/dist/plan/build-record.js +109 -0
  23. package/dist/plan/calls-matrix.d.ts +9 -0
  24. package/dist/plan/calls-matrix.js +18 -1
  25. package/dist/plan/cloudflared-connector.d.ts +7 -1
  26. package/dist/plan/cloudflared-connector.js +15 -2
  27. package/dist/plan/company-backups.d.ts +31 -0
  28. package/dist/plan/company-backups.js +101 -0
  29. package/dist/plan/deploy-health.d.ts +12 -2
  30. package/dist/plan/deploy-health.js +3 -1
  31. package/dist/plan/destinations.d.ts +73 -0
  32. package/dist/plan/destinations.js +217 -0
  33. package/dist/plan/diff.d.ts +46 -8
  34. package/dist/plan/diff.js +367 -126
  35. package/dist/plan/edge-access.d.ts +15 -0
  36. package/dist/plan/edge-access.js +21 -2
  37. package/dist/plan/executor.d.ts +276 -5
  38. package/dist/plan/executor.js +1111 -109
  39. package/dist/plan/health-check-settings.d.ts +28 -0
  40. package/dist/plan/health-check-settings.js +36 -0
  41. package/dist/plan/health-gaps.d.ts +33 -0
  42. package/dist/plan/health-gaps.js +89 -0
  43. package/dist/plan/health-targets.d.ts +1 -3
  44. package/dist/plan/health-targets.js +36 -36
  45. package/dist/plan/held-key-refs.d.ts +15 -0
  46. package/dist/plan/held-key-refs.js +15 -0
  47. package/dist/plan/hostnames.d.ts +10 -0
  48. package/dist/plan/hostnames.js +14 -0
  49. package/dist/plan/index.d.ts +19 -6
  50. package/dist/plan/index.js +10 -3
  51. package/dist/plan/live.d.ts +11 -2
  52. package/dist/plan/live.js +16 -2
  53. package/dist/plan/locked-hostnames.d.ts +7 -3
  54. package/dist/plan/locked-hostnames.js +25 -4
  55. package/dist/plan/login-host.d.ts +61 -12
  56. package/dist/plan/login-host.js +99 -11
  57. package/dist/plan/mail-relay.d.ts +139 -0
  58. package/dist/plan/mail-relay.js +400 -0
  59. package/dist/plan/network-move.d.ts +20 -0
  60. package/dist/plan/network-move.js +41 -0
  61. package/dist/plan/oikos-ingress.d.ts +111 -0
  62. package/dist/plan/oikos-ingress.js +231 -0
  63. package/dist/plan/org-names.d.ts +30 -0
  64. package/dist/plan/org-names.js +108 -0
  65. package/dist/plan/own-diff.d.ts +170 -0
  66. package/dist/plan/own-diff.js +440 -0
  67. package/dist/plan/own-dns.d.ts +6 -0
  68. package/dist/plan/own-dns.js +15 -0
  69. package/dist/plan/own-hostnames.d.ts +27 -0
  70. package/dist/plan/own-hostnames.js +24 -0
  71. package/dist/plan/own-limits.d.ts +213 -0
  72. package/dist/plan/own-limits.js +232 -0
  73. package/dist/plan/own-services.d.ts +193 -0
  74. package/dist/plan/own-services.js +169 -0
  75. package/dist/plan/own-sfu.d.ts +69 -0
  76. package/dist/plan/own-sfu.js +256 -0
  77. package/dist/plan/plan-apply.d.ts +121 -10
  78. package/dist/plan/plan-apply.js +357 -18
  79. package/dist/plan/platform-diff.d.ts +155 -11
  80. package/dist/plan/platform-diff.js +271 -66
  81. package/dist/plan/platform-org.d.ts +107 -4
  82. package/dist/plan/platform-org.js +175 -3
  83. package/dist/plan/platform-providers.d.ts +474 -81
  84. package/dist/plan/platform-providers.js +1407 -560
  85. package/dist/plan/proxy-labels.d.ts +57 -11
  86. package/dist/plan/proxy-labels.js +94 -14
  87. package/dist/plan/run-options.d.ts +19 -0
  88. package/dist/plan/run-options.js +46 -0
  89. package/dist/plan/sign-in-org.d.ts +1 -1
  90. package/dist/plan/sign-in-org.js +20 -2
  91. package/dist/plan/zitadel-login-policy.d.ts +1 -1
  92. package/dist/plan/zitadel-login-policy.js +77 -9
  93. package/dist/plan/zitadel-system-api.d.ts +22 -1
  94. package/dist/plan/zitadel-system-api.js +49 -8
  95. package/dist/platform.d.ts +40 -0
  96. package/dist/platform.js +16 -0
  97. package/dist/provision.d.ts +2 -0
  98. package/dist/provision.js +3 -1
  99. package/dist/slug.d.ts +3 -1
  100. package/dist/slug.js +26 -3
  101. package/dist/store/postgres.js +93 -2
  102. package/dist/store/schema.d.ts +87 -0
  103. package/dist/store/schema.js +9 -0
  104. package/package.json +3 -3
  105. package/versions.digests.json +11 -0
  106. package/versions.json +3 -1
@@ -0,0 +1,8 @@
1
+ -- @wtfalch/foundry: `mail_relay` true puts one shared relay container on host
2
+ -- port 25, which routes each message by recipient domain to a company's own
3
+ -- mail server or to the platform's; false (the default) keeps the platform
4
+ -- Stalwart bound to port 25 directly. Mirrors src/store/schema.ts's
5
+ -- `foundryDeclaredPlatform` table addition.
6
+
7
+ alter table foundry_declared_platform
8
+ add column if not exists mail_relay boolean not null default false;
@@ -0,0 +1,9 @@
1
+ -- @wtfalch/foundry: foundry#544 -- which Healthchecks instance manages the
2
+ -- platform's heartbeat checks, decoupled from whether foundry also deploys
3
+ -- its own self-hosted server (`hc_enabled`, foundry#540). 'self-hosted' is
4
+ -- the default and today's only behaviour; 'hosted' needs no self-hosted
5
+ -- server applied at all. Mirrors `src/store/schema.ts`'s
6
+ -- `foundryDeclaredPlatform` table addition.
7
+
8
+ alter table foundry_declared_platform
9
+ add column if not exists heartbeats_provider text not null default 'self-hosted';
@@ -0,0 +1,6 @@
1
+ -- @wtfalch/foundry: a company's version hold. `version_pin` is a json object of
2
+ -- part to git ref or image tag (auth, mail, files, jobs, sfu, calls); null
3
+ -- follows the platform. Additive. Mirrors src/store/schema.ts's `declaredOrgs`.
4
+
5
+ alter table foundry_declared_orgs
6
+ add column if not exists version_pin jsonb;
package/dist/org.d.ts CHANGED
@@ -316,7 +316,14 @@ export interface HostingEntry {
316
316
  hostnames: string[];
317
317
  /** A Postgres database beside the app. At most one entry per org. */
318
318
  database: boolean;
319
- /** Deploy on every push to `branch`. Default: `true`. */
319
+ /**
320
+ * The Linux capabilities of the entry's app container. Absent or `'restricted'`: all dropped, then only the
321
+ * few a web server or node entrypoint needs added back. `'standard'`: Docker's defaults minus raw sockets,
322
+ * device nodes, file capabilities, chroot and audit writes. A fallback for an image that will not start
323
+ * restricted.
324
+ */
325
+ capabilities?: 'restricted' | 'standard';
326
+ /** Deploy on every push to `branch`. A host should declare `false`; foundry creates every other app with auto-deploy off. */
320
327
  autoDeploy: boolean;
321
328
  /**
322
329
  * Cloudflare Access gates on this entry's own `hostnames`. Empty means no
@@ -486,13 +493,11 @@ export declare function appEntry(hosting: HostingConfig): HostingEntry | undefin
486
493
  * sends from. Each is read from `StepExecutionContext.recordedIds()` in
487
494
  * `executor.ts`'s `pushAppEnv`, never minted again there.
488
495
  */
489
- export type AppEnvSource = 'filesOrganisationId' | 'aiOrganisationId' | 'mailDomain' | 'sharedSecret' | 'platformMailKey';
496
+ export type AppEnvSource = 'filesOrganisationId' | 'aiOrganisationId' | 'mailDomain' | 'sharedSecret';
490
497
  /**
491
- * Two sources are secrets. `sharedSecret` is {@link EnvSharedSecret}.
492
- * `platformMailKey` is the platform's held Lettermint key, handed over as the
493
- * value of the declared env name (the platform's own apps call it
494
- * `LETTERMINT_API_TOKEN`). Any hosting entry of any customer org may name it
495
- * (William, 2026-10-04): mail is Lettermint, and the key is the platform's.
498
+ * `sharedSecret` is the one secret source: {@link EnvSharedSecret}. The
499
+ * platform's Lettermint key is not a source any more (2026-10-11): each
500
+ * company uses its own mail key.
496
501
  */
497
502
  export declare const APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
498
503
  /** The sources whose value is a secret: resolved at apply time, never recorded. */
@@ -516,6 +521,29 @@ export declare function validateHosting(hosting: HostingConfig, names?: {
516
521
  locked: ReadonlySet<string>;
517
522
  }): string | null;
518
523
  export declare function validateDomain(domain: string): string | null;
524
+ /**
525
+ * A broker entry name foundry writes itself: Archon's, and any company's Boule.
526
+ * A hosting entry may not declare that it replaces one.
527
+ */
528
+ export declare function isFoundryOwnedBrokerEntryName(name: string): boolean;
529
+ /**
530
+ * `null` when `domain` may be claimed by company `orgId`, given the platform's
531
+ * apex and owner company and every other company's declared domain; otherwise
532
+ * the reason it may not. The platform's apex and anything under it belong to
533
+ * the owner company alone, and no company takes a parent or subdomain of
534
+ * another's, since Cloudflare would resolve both to one zone.
535
+ */
536
+ export declare function validateDomainClaim(domain: string, claim: {
537
+ orgId: string;
538
+ platform: {
539
+ apex: string;
540
+ ownerOrgId: string;
541
+ } | null;
542
+ others: readonly {
543
+ id: string;
544
+ domain: string | null;
545
+ }[];
546
+ }): string | null;
519
547
  /**
520
548
  * `null` when `site` is valid; otherwise the reason it is not (#101).
521
549
  *
@@ -526,11 +554,9 @@ export declare function validateDomain(domain: string): string | null;
526
554
  * and cms there (`site`), never both -- both would otherwise fight over the
527
555
  * same hostname (https://github.com/wtfalch/foundry/issues/99).
528
556
  *
529
- * - `site.enabled` requires `domain` to be set -- a site with nowhere to
530
- * live is not a site.
531
- * - `site.enabled` requires `site.usesDomain` -- unlike mail, chat, calls
532
- * and the issuer, the site has no meaning anywhere but the org's own
533
- * domain, so turning it on always means the domain opt-in too.
557
+ * - `site.enabled` with a `domain` requires `site.usesDomain`: the site then
558
+ * lives on the domain. Without a `domain` it lives on `<slug>.oikos.host`
559
+ * and `site.usesDomain` must be off.
534
560
  * - `site.enabled` and `hosting.enabled` cannot both be on.
535
561
  * - `site.enabled` refuses an `issuerApps` entry named `cms`: the fixed cms
536
562
  * client (#139, `plan/diff.ts`'s `planCmsClient`) already owns the
@@ -548,6 +574,40 @@ export declare function validateSite(org: DeclaredOrg): string | null;
548
574
  * reason to pin at all.
549
575
  */
550
576
  export declare function validateCalls(org: DeclaredOrg): string | null;
577
+ /** The parts of a company that can be held on their own version. */
578
+ export declare const VERSION_PIN_PARTS: readonly ["auth", "mail", "files", "jobs", "sfu", "calls"];
579
+ export type VersionPinPart = (typeof VERSION_PIN_PARTS)[number];
580
+ /**
581
+ * A part held at a git ref (auth, files, jobs) or an image tag (mail, sfu, calls). Absent part: the platform's value.
582
+ * `calls` is the Synapse tag of the calls stack. It is used when the stack is created; a running stack is not moved
583
+ * (nothing patches the image tag of an existing app, see the Synapse create in `plan/executor.ts`).
584
+ */
585
+ export type VersionPin = Partial<Record<VersionPinPart, string>>;
586
+ /**
587
+ * `versionPin` holds parts of a company on an older release while others move.
588
+ * Every value is a fixed ref or tag: a floating one would defeat the hold.
589
+ * Also run on a stored value when it is read, so a bad row is not trusted.
590
+ */
591
+ export declare function validateVersionPin(org: Pick<DeclaredOrg, 'versionPin'>): string | null;
592
+ /**
593
+ * Every company runs its own sign-in, mail, files, jobs and calls SFU in its own containers, the platform's owner company too.
594
+ * A company without a domain runs them on oikos.host.
595
+ */
596
+ export interface ServicesConfig {
597
+ /** true: own copies of the services. false (default): the platform's shared ones. */
598
+ own: boolean;
599
+ /**
600
+ * Which slice of the server's SFU ports this company's media server takes.
601
+ * Required when `own` and `calls.enabled`. 1 to {@link SFU_SLOT_MAX}.
602
+ * Slice n holds UDP (50000 + (n-1)*500) .. (50000 + n*500 - 1) and TCP 7881 + n.
603
+ */
604
+ sfuSlot?: number | null;
605
+ }
606
+ export declare const SFU_SLOT_MAX = 20;
607
+ /** 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. */
608
+ export declare function ownsServices(org: Pick<DeclaredOrg, 'services'>): boolean;
609
+ /** The pure part of the `services` rules; that no two orgs share an `sfuSlot` is the store's check. */
610
+ export declare function validateServices(org: DeclaredOrg): string | null;
551
611
  /**
552
612
  * Per-org SSO. Decided to exist in #14 ("Also in scope") and its resolved
553
613
  * research #3 ("per-org SSO code exists unused"); nothing about its
@@ -822,6 +882,10 @@ export interface DeclaredOrg {
822
882
  site: SiteConfig;
823
883
  /** `enabled: false`, `issuerApps: []` by default -- a brand new org has no hosted app (#71). */
824
884
  hosting: HostingConfig;
885
+ /** Absent means `{ own: false }`. Optional so every existing literal and test fixture compiles unchanged, as `calls.versionPin` and `MailConfig.mailApps` do. */
886
+ services?: ServicesConfig;
887
+ /** Parts of this company held on their own version. Absent or null follows the platform. Ignored for the owner company. */
888
+ versionPin?: VersionPin | null;
825
889
  sso: SsoConfig;
826
890
  guests: GuestsConfig;
827
891
  solo: SoloConfig;
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,6 +757,71 @@ 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
+ }
801
+ export const SFU_SLOT_MAX = 20;
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. */
803
+ export function ownsServices(org) {
804
+ return org.services?.own === true;
805
+ }
806
+ /** The pure part of the `services` rules; that no two orgs share an `sfuSlot` is the store's check. */
807
+ export function validateServices(org) {
808
+ const slot = org.services?.sfuSlot ?? null;
809
+ if (!ownsServices(org)) {
810
+ return slot === null ? null : 'services.sfuSlot needs services.own';
811
+ }
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';
815
+ }
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';
818
+ }
819
+ if (org.calls.enabled &&
820
+ (slot === null || !Number.isInteger(slot) || slot < 1 || slot > SFU_SLOT_MAX)) {
821
+ return `services.sfuSlot must be an integer from 1 to ${SFU_SLOT_MAX} when calls are enabled`;
822
+ }
823
+ return null;
824
+ }
709
825
  const ORG_NUMBER_PATTERN = /^\d{9}$/;
710
826
  /**
711
827
  * `null` when `buyer` is valid; otherwise the reason it is not, for a caller
@@ -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';