@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.
- package/dist/bootstrap/bootstrap.d.ts +1 -1
- package/dist/bootstrap/bootstrap.js +49 -11
- package/dist/bootstrap/declare.d.ts +12 -5
- package/dist/bootstrap/declare.js +33 -18
- package/dist/bootstrap/versions.d.ts +24 -0
- package/dist/bootstrap/versions.js +46 -0
- package/dist/calls-sfu.d.ts +10 -0
- package/dist/calls-sfu.js +44 -3
- package/dist/index.d.ts +21 -8
- package/dist/index.js +11 -4
- package/dist/migrations/0051_org_services.sql +13 -0
- package/dist/migrations/0052_platform_mail_relay.sql +8 -0
- package/dist/migrations/0053_heartbeats_provider.sql +9 -0
- package/dist/migrations/0053_org_version_pin.sql +6 -0
- package/dist/org.d.ts +76 -12
- package/dist/org.js +136 -20
- package/dist/plan/backup-gate.d.ts +93 -0
- package/dist/plan/backup-gate.js +261 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +20 -3
- package/dist/plan/build-record.d.ts +56 -0
- package/dist/plan/build-record.js +109 -0
- package/dist/plan/calls-matrix.d.ts +9 -0
- package/dist/plan/calls-matrix.js +18 -1
- package/dist/plan/cloudflared-connector.d.ts +7 -1
- package/dist/plan/cloudflared-connector.js +15 -2
- package/dist/plan/company-backups.d.ts +31 -0
- package/dist/plan/company-backups.js +101 -0
- package/dist/plan/deploy-health.d.ts +12 -2
- package/dist/plan/deploy-health.js +3 -1
- package/dist/plan/destinations.d.ts +73 -0
- package/dist/plan/destinations.js +217 -0
- package/dist/plan/diff.d.ts +46 -8
- package/dist/plan/diff.js +367 -126
- package/dist/plan/edge-access.d.ts +15 -0
- package/dist/plan/edge-access.js +21 -2
- package/dist/plan/executor.d.ts +276 -5
- package/dist/plan/executor.js +1111 -109
- package/dist/plan/health-check-settings.d.ts +28 -0
- package/dist/plan/health-check-settings.js +36 -0
- package/dist/plan/health-gaps.d.ts +33 -0
- package/dist/plan/health-gaps.js +89 -0
- package/dist/plan/health-targets.d.ts +1 -3
- package/dist/plan/health-targets.js +36 -36
- package/dist/plan/held-key-refs.d.ts +15 -0
- package/dist/plan/held-key-refs.js +15 -0
- package/dist/plan/hostnames.d.ts +10 -0
- package/dist/plan/hostnames.js +14 -0
- package/dist/plan/index.d.ts +19 -6
- package/dist/plan/index.js +10 -3
- package/dist/plan/live.d.ts +11 -2
- package/dist/plan/live.js +16 -2
- package/dist/plan/locked-hostnames.d.ts +7 -3
- package/dist/plan/locked-hostnames.js +25 -4
- package/dist/plan/login-host.d.ts +61 -12
- package/dist/plan/login-host.js +99 -11
- package/dist/plan/mail-relay.d.ts +139 -0
- package/dist/plan/mail-relay.js +400 -0
- package/dist/plan/network-move.d.ts +20 -0
- package/dist/plan/network-move.js +41 -0
- package/dist/plan/oikos-ingress.d.ts +111 -0
- package/dist/plan/oikos-ingress.js +231 -0
- package/dist/plan/org-names.d.ts +30 -0
- package/dist/plan/org-names.js +108 -0
- package/dist/plan/own-diff.d.ts +170 -0
- package/dist/plan/own-diff.js +440 -0
- package/dist/plan/own-dns.d.ts +6 -0
- package/dist/plan/own-dns.js +15 -0
- package/dist/plan/own-hostnames.d.ts +27 -0
- package/dist/plan/own-hostnames.js +24 -0
- package/dist/plan/own-limits.d.ts +213 -0
- package/dist/plan/own-limits.js +232 -0
- package/dist/plan/own-services.d.ts +193 -0
- package/dist/plan/own-services.js +169 -0
- package/dist/plan/own-sfu.d.ts +69 -0
- package/dist/plan/own-sfu.js +256 -0
- package/dist/plan/plan-apply.d.ts +121 -10
- package/dist/plan/plan-apply.js +357 -18
- package/dist/plan/platform-diff.d.ts +155 -11
- package/dist/plan/platform-diff.js +271 -66
- package/dist/plan/platform-org.d.ts +107 -4
- package/dist/plan/platform-org.js +175 -3
- package/dist/plan/platform-providers.d.ts +474 -81
- package/dist/plan/platform-providers.js +1407 -560
- package/dist/plan/proxy-labels.d.ts +57 -11
- package/dist/plan/proxy-labels.js +94 -14
- package/dist/plan/run-options.d.ts +19 -0
- package/dist/plan/run-options.js +46 -0
- package/dist/plan/sign-in-org.d.ts +1 -1
- package/dist/plan/sign-in-org.js +20 -2
- package/dist/plan/zitadel-login-policy.d.ts +1 -1
- package/dist/plan/zitadel-login-policy.js +77 -9
- package/dist/plan/zitadel-system-api.d.ts +22 -1
- package/dist/plan/zitadel-system-api.js +49 -8
- package/dist/platform.d.ts +40 -0
- package/dist/platform.js +16 -0
- package/dist/provision.d.ts +2 -0
- package/dist/provision.js +3 -1
- package/dist/slug.d.ts +3 -1
- package/dist/slug.js +26 -3
- package/dist/store/postgres.js +93 -2
- package/dist/store/schema.d.ts +87 -0
- package/dist/store/schema.js +9 -0
- package/package.json +3 -3
- package/versions.digests.json +11 -0
- 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
|
-
/**
|
|
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'
|
|
496
|
+
export type AppEnvSource = 'filesOrganisationId' | 'aiOrganisationId' | 'mailDomain' | 'sharedSecret';
|
|
490
497
|
/**
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
*
|
|
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`
|
|
530
|
-
*
|
|
531
|
-
*
|
|
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
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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`
|
|
656
|
-
*
|
|
657
|
-
*
|
|
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
|
-
|
|
670
|
-
|
|
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';
|