@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
package/dist/platform.d.ts
CHANGED
|
@@ -31,6 +31,14 @@ export interface PlatformSfuService extends PlatformService {
|
|
|
31
31
|
*/
|
|
32
32
|
export interface PlatformMailService extends PlatformService {
|
|
33
33
|
lettermintProjectId?: string | null;
|
|
34
|
+
/**
|
|
35
|
+
* true: one shared relay container owns host port 25 and routes each
|
|
36
|
+
* message by recipient domain to a company's own mail server or to the
|
|
37
|
+
* platform's; the platform Stalwart then stops publishing port 25.
|
|
38
|
+
* false or unset (the default): the platform Stalwart binds host port 25
|
|
39
|
+
* directly, as it always has.
|
|
40
|
+
*/
|
|
41
|
+
relay?: boolean;
|
|
34
42
|
}
|
|
35
43
|
/**
|
|
36
44
|
* The uptime Worker (foundry#542): the outside prober Archon publishes as a
|
|
@@ -113,13 +121,44 @@ export interface DeclaredPlatform {
|
|
|
113
121
|
coolify: PlatformService;
|
|
114
122
|
/** foundry#542: the outside prober Worker; see {@link PlatformUptimeWorker}. */
|
|
115
123
|
uptime: PlatformUptimeWorker;
|
|
124
|
+
/**
|
|
125
|
+
* foundry#544: which Healthchecks instance manages the heartbeat checks
|
|
126
|
+
* for every piece of scheduled work foundry sets up (the uptime Worker,
|
|
127
|
+
* each platform database backup), decoupled from whether foundry also
|
|
128
|
+
* deploys the self-hosted server ({@link hc} above). `'self-hosted'`
|
|
129
|
+
* (the default, and today's only behaviour before #544) still requires
|
|
130
|
+
* `hc.enabled` and reads the project API key under
|
|
131
|
+
* `PLATFORM_HC_API_KEY_REF`. `'hosted'` needs no self-hosted server at
|
|
132
|
+
* all -- not `hc.enabled`, not `hc` applied -- and reads its own API key
|
|
133
|
+
* under `PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF` instead; the host's own
|
|
134
|
+
* `HeartbeatsPort` implementation is what actually points at the outside
|
|
135
|
+
* service (healthchecks.io or otherwise) or the self-hosted instance --
|
|
136
|
+
* this field only says which one foundry expects behind it. Changing it
|
|
137
|
+
* re-plans `heartbeats:reconcile`, which creates every desired check
|
|
138
|
+
* fresh on the newly-chosen provider and delivers its ping URLs; a check
|
|
139
|
+
* left behind on the provider no longer chosen is not deleted (foundry
|
|
140
|
+
* never had it in view the moment the switch is declared) -- see
|
|
141
|
+
* issue #544's own "Human steps". Which to use at all, and whether the
|
|
142
|
+
* self-hosted server stays as a standing option, are both still open
|
|
143
|
+
* "Decide" items on issue #544's own thread, not settled by this field.
|
|
144
|
+
*/
|
|
145
|
+
heartbeatsProvider: HeartbeatsProvider;
|
|
116
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* foundry#544: see {@link DeclaredPlatform.heartbeatsProvider}'s own doc
|
|
149
|
+
* comment.
|
|
150
|
+
*/
|
|
151
|
+
export type HeartbeatsProvider = 'self-hosted' | 'hosted';
|
|
152
|
+
/** The only values {@link DeclaredPlatform.heartbeatsProvider} may hold. */
|
|
153
|
+
export declare const HEARTBEATS_PROVIDERS: ReadonlySet<HeartbeatsProvider>;
|
|
117
154
|
/** `<service>.<apex>` -- the default `hostname` for a platform service, before an operator overrides it. */
|
|
118
155
|
export declare function defaultPlatformHostname(service: PlatformServiceKey, apex: string): string;
|
|
119
156
|
/**
|
|
120
157
|
* `null` when `platform` is valid; otherwise the reason it is not, same
|
|
121
158
|
* shape as `org.ts`'s `validateBuyerIdentity`.
|
|
122
159
|
*
|
|
160
|
+
* - `auth`, `mail`, `files`, `jobs` and `sfu` are not `enabled`: the owner company
|
|
161
|
+
* runs them as its own services.
|
|
123
162
|
* - `apex` is a valid domain (`org.ts`'s `validateDomain`).
|
|
124
163
|
* - `serverIpv4`, when declared, is a dotted-quad IPv4 address.
|
|
125
164
|
* - Every service's `hostname` is the apex itself or a subdomain of it -- a
|
|
@@ -129,4 +168,5 @@ export declare function defaultPlatformHostname(service: PlatformServiceKey, ape
|
|
|
129
168
|
* - Every service's `gitRef` is non-empty once that service is `enabled` --
|
|
130
169
|
* an enabled service with nothing to deploy is not yet declared.
|
|
131
170
|
*/
|
|
171
|
+
export declare const OWNER_RUNS_OWN_SERVICES_REASON = "auth, mail, files, jobs and sfu cannot be enabled on the platform: the owner company runs these as its own services";
|
|
132
172
|
export declare function validatePlatform(platform: DeclaredPlatform): string | null;
|
package/dist/platform.js
CHANGED
|
@@ -17,6 +17,11 @@ export const PLATFORM_SERVICE_KEYS = [
|
|
|
17
17
|
'hc',
|
|
18
18
|
'coolify',
|
|
19
19
|
];
|
|
20
|
+
/** The only values {@link DeclaredPlatform.heartbeatsProvider} may hold. */
|
|
21
|
+
export const HEARTBEATS_PROVIDERS = new Set([
|
|
22
|
+
'self-hosted',
|
|
23
|
+
'hosted',
|
|
24
|
+
]);
|
|
20
25
|
/** `<service>.<apex>` -- the default `hostname` for a platform service, before an operator overrides it. */
|
|
21
26
|
export function defaultPlatformHostname(service, apex) {
|
|
22
27
|
return `${service}.${apex}`;
|
|
@@ -28,6 +33,8 @@ function serviceOf(platform, key) {
|
|
|
28
33
|
* `null` when `platform` is valid; otherwise the reason it is not, same
|
|
29
34
|
* shape as `org.ts`'s `validateBuyerIdentity`.
|
|
30
35
|
*
|
|
36
|
+
* - `auth`, `mail`, `files`, `jobs` and `sfu` are not `enabled`: the owner company
|
|
37
|
+
* runs them as its own services.
|
|
31
38
|
* - `apex` is a valid domain (`org.ts`'s `validateDomain`).
|
|
32
39
|
* - `serverIpv4`, when declared, is a dotted-quad IPv4 address.
|
|
33
40
|
* - Every service's `hostname` is the apex itself or a subdomain of it -- a
|
|
@@ -37,7 +44,13 @@ function serviceOf(platform, key) {
|
|
|
37
44
|
* - Every service's `gitRef` is non-empty once that service is `enabled` --
|
|
38
45
|
* an enabled service with nothing to deploy is not yet declared.
|
|
39
46
|
*/
|
|
47
|
+
export const OWNER_RUNS_OWN_SERVICES_REASON = 'auth, mail, files, jobs and sfu cannot be enabled on the platform: the owner company runs these as its own services';
|
|
48
|
+
/** The platform services the owner company runs as its own, which the platform keeps off. */
|
|
49
|
+
const OWNER_RUN_SERVICE_KEYS = ['auth', 'mail', 'files', 'jobs', 'sfu'];
|
|
40
50
|
export function validatePlatform(platform) {
|
|
51
|
+
if (OWNER_RUN_SERVICE_KEYS.some((key) => platform[key].enabled)) {
|
|
52
|
+
return OWNER_RUNS_OWN_SERVICES_REASON;
|
|
53
|
+
}
|
|
41
54
|
const apexReason = validateDomain(platform.apex);
|
|
42
55
|
if (apexReason)
|
|
43
56
|
return `apex invalid: ${apexReason}`;
|
|
@@ -75,5 +88,8 @@ export function validatePlatform(platform) {
|
|
|
75
88
|
if (platform.mail.lettermintProjectId != null && !platform.mail.lettermintProjectId.trim()) {
|
|
76
89
|
return 'mail.lettermintProjectId must not be empty when set';
|
|
77
90
|
}
|
|
91
|
+
if (!HEARTBEATS_PROVIDERS.has(platform.heartbeatsProvider)) {
|
|
92
|
+
return `heartbeatsProvider '${platform.heartbeatsProvider}' must be one of ${[...HEARTBEATS_PROVIDERS].join(', ')}`;
|
|
93
|
+
}
|
|
78
94
|
return null;
|
|
79
95
|
}
|
package/dist/provision.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export declare const DATABASE_NAME = "foundry";
|
|
|
10
10
|
* still satisfied with no adapter.
|
|
11
11
|
*/
|
|
12
12
|
export interface ProvisionCoolifyPort extends Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'listDatabases' | 'createPostgresDatabase'> {
|
|
13
|
+
/** With `raw`, the database is created on the platform destination (`plan/destinations.ts`). */
|
|
14
|
+
raw?: CoolifyPort['raw'];
|
|
13
15
|
listEnvironments(projectUuid: string): Promise<Array<{
|
|
14
16
|
id: number;
|
|
15
17
|
uuid: string;
|
package/dist/provision.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { placedOnPlatform } from './plan/destinations.js';
|
|
1
2
|
// #R2 (map gap: foundry's own Postgres, two commands): find-or-create the
|
|
2
3
|
// Postgres database `scripts/foundry-db-provision.mjs` migrates into.
|
|
3
4
|
// Project, environment and database names are fixed here rather than taken
|
|
@@ -56,7 +57,8 @@ export async function applyProvision(coolify, plan) {
|
|
|
56
57
|
// `createPostgresDatabase` also returns `internal_db_url` -- a live
|
|
57
58
|
// credential -- which is deliberately never read off this result: only
|
|
58
59
|
// `uuid` (and the `name` we already know) leaves this function.
|
|
59
|
-
const
|
|
60
|
+
const creator = placedOnPlatform(coolify);
|
|
61
|
+
const created = await creator.createPostgresDatabase({
|
|
60
62
|
project_uuid: project.uuid,
|
|
61
63
|
server_uuid: server.uuid,
|
|
62
64
|
environment_name: environment.name,
|
package/dist/slug.d.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/** The flattened set {@link validateSlug} checks a candidate slug against. */
|
|
2
2
|
export declare const RESERVED_SLUGS: ReadonlySet<string>;
|
|
3
|
+
/** Every platform app, tunnel, project and database name starts `platform-`, so no company slug may. */
|
|
4
|
+
export declare const RESERVED_SLUG_PREFIX = "platform-";
|
|
3
5
|
/**
|
|
4
6
|
* Why {@link validateSlug} refused a candidate slug, by name so a caller —
|
|
5
7
|
* `store/postgres.ts`'s `DeclaredOrgError`, eventually the operator page —
|
|
6
8
|
* can report it without parsing `message` text.
|
|
7
9
|
*/
|
|
8
|
-
export type SlugRejectionReason = 'invalid-label' | 'reserved' | 'environment-suffix';
|
|
10
|
+
export type SlugRejectionReason = 'invalid-label' | 'reserved' | 'environment-suffix' | 'reserved-prefix';
|
|
9
11
|
export interface SlugRejection {
|
|
10
12
|
reason: SlugRejectionReason;
|
|
11
13
|
message: string;
|
package/dist/slug.js
CHANGED
|
@@ -42,6 +42,7 @@ const RESERVED_ESTATE_SERVICES = [
|
|
|
42
42
|
'manage',
|
|
43
43
|
'boule',
|
|
44
44
|
'operator',
|
|
45
|
+
'platform',
|
|
45
46
|
'mailbox',
|
|
46
47
|
'oikos',
|
|
47
48
|
'cms',
|
|
@@ -104,6 +105,8 @@ export const RESERVED_SLUGS = new Set([
|
|
|
104
105
|
...RESERVED_INFRASTRUCTURE_AND_MAIL,
|
|
105
106
|
...RESERVED_AUTH_AND_ENVIRONMENTS,
|
|
106
107
|
]);
|
|
108
|
+
/** Every platform app, tunnel, project and database name starts `platform-`, so no company slug may. */
|
|
109
|
+
export const RESERVED_SLUG_PREFIX = 'platform-';
|
|
107
110
|
/**
|
|
108
111
|
* A DNS label: 1-63 characters, lowercase `a-z`, `0-9` and hyphen, no
|
|
109
112
|
* leading or trailing hyphen. Consecutive hyphens are legal DNS (RFC 1035
|
|
@@ -135,6 +138,19 @@ const AGORA_SUFFIX = /-agora$/;
|
|
|
135
138
|
* one that opts in.
|
|
136
139
|
*/
|
|
137
140
|
const BOULE_SUFFIX = /-boule$/;
|
|
141
|
+
/** `<something>-auth|-mail|-files|-sfu|-cms` — an own company's service hostnames on oikos.host (`plan/own-hostnames.ts`). */
|
|
142
|
+
const OWN_SERVICE_SUFFIX = /-(auth|mail|files|sfu|cms)$/;
|
|
143
|
+
/**
|
|
144
|
+
* `names` itself, or `names-<anything>` — foundry names the company tunnel
|
|
145
|
+
* `${slug}-tunnel` (`plan/executor.ts`), so a slug of `names` or starting
|
|
146
|
+
* `names-` produces a tunnel name starting `names-`. Edge refuses to create
|
|
147
|
+
* a platform tunnel whose name starts with `names-` and reads a live
|
|
148
|
+
* `names-*` tunnel as its own, not a company's (foundry#535; `edge`'s
|
|
149
|
+
* `packages/service/src/platform-tunnels.ts`). `namesake` is not a near
|
|
150
|
+
* miss: its tunnel name is `namesake-tunnel`, which does not start with
|
|
151
|
+
* `names-`.
|
|
152
|
+
*/
|
|
153
|
+
const NAMES_PREFIX = /^names(-|$)/;
|
|
138
154
|
/**
|
|
139
155
|
* Validates a candidate org slug against every rule #28 decided. Returns
|
|
140
156
|
* `null` when the slug is acceptable, or a {@link SlugRejection} naming
|
|
@@ -149,20 +165,27 @@ export function validateSlug(slug) {
|
|
|
149
165
|
message: `slug '${slug}' is not a valid DNS label: must be 1-63 characters, lowercase a-z, 0-9 and hyphen, and may not start or end with a hyphen`,
|
|
150
166
|
};
|
|
151
167
|
}
|
|
152
|
-
if (RESERVED_SLUGS.has(slug)) {
|
|
168
|
+
if (RESERVED_SLUGS.has(slug) || slug.startsWith(RESERVED_SLUG_PREFIX)) {
|
|
153
169
|
return {
|
|
154
170
|
reason: 'reserved',
|
|
155
171
|
message: `slug '${slug}' is reserved by the estate (#28) and cannot be taken by an org`,
|
|
156
172
|
};
|
|
157
173
|
}
|
|
174
|
+
if (NAMES_PREFIX.test(slug)) {
|
|
175
|
+
return {
|
|
176
|
+
reason: 'reserved-prefix',
|
|
177
|
+
message: `slug '${slug}' is 'names' or starts with 'names-', a prefix edge reserves for its own platform tunnels and refuses to create a company tunnel under (#535)`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
158
180
|
if (STAGE_SUFFIX.test(slug) ||
|
|
159
181
|
PR_SUFFIX.test(slug) ||
|
|
160
182
|
CALLS_SUFFIX.test(slug) ||
|
|
161
183
|
AGORA_SUFFIX.test(slug) ||
|
|
162
|
-
BOULE_SUFFIX.test(slug)
|
|
184
|
+
BOULE_SUFFIX.test(slug) ||
|
|
185
|
+
OWN_SERVICE_SUFFIX.test(slug)) {
|
|
163
186
|
return {
|
|
164
187
|
reason: 'environment-suffix',
|
|
165
|
-
message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls', '-agora' or '-
|
|
188
|
+
message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls', '-agora', '-boule', '-auth', '-mail', '-files', '-sfu' or '-cms') reserved for another org's own hostname (#28, #145, #164, #161) — 'acme-calls' is only legal as acme's calls hostname, 'acme-agora' only as acme's Agora hostname, 'acme-boule' only as acme's Boule hostname, never as an org slug in its own right`,
|
|
166
189
|
};
|
|
167
190
|
}
|
|
168
191
|
return null;
|
package/dist/store/postgres.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { and, asc, desc, eq, sql } from 'drizzle-orm';
|
|
2
|
-
import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBuyerIdentity, validateCalls, validateDomain, validateHosting, validateMail, validateOwners, validateSite, } from '../org.js';
|
|
1
|
+
import { and, asc, desc, eq, ne, sql } from 'drizzle-orm';
|
|
2
|
+
import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBuyerIdentity, validateCalls, validateDomain, validateDomainClaim, validateHosting, validateMail, validateOwners, validateServices, validateSite, validateVersionPin, } from '../org.js';
|
|
3
3
|
import { lockedHostnames } from '../plan/locked-hostnames.js';
|
|
4
|
+
import { findNameClashes, nameClashMessage } from '../plan/org-names.js';
|
|
4
5
|
import { validatePlatform } from '../platform.js';
|
|
5
6
|
import { RequestedChangeError, classifyChange } from '../requested-changes.js';
|
|
6
7
|
import { validateSlug } from '../slug.js';
|
|
@@ -29,6 +30,7 @@ async function writeFenced(db, fence, write) {
|
|
|
29
30
|
/** `migrations/0004_slug_unique.sql` / `migrations/0015_domain_unique.sql`'s index names, read back off a caught unique-violation to tell which one fired. */
|
|
30
31
|
const SLUG_UNIQUE_INDEX = 'foundry_declared_orgs_slug_idx';
|
|
31
32
|
const DOMAIN_UNIQUE_INDEX = 'foundry_declared_orgs_domain_idx';
|
|
33
|
+
const SFU_SLOT_UNIQUE_INDEX = 'foundry_declared_orgs_sfu_slot_idx';
|
|
32
34
|
/** #27: a bigint-safe decimal string of a non-negative integer -- no sign, no decimal point, no leading zeros (so "010" never diffs against a live "10"). */
|
|
33
35
|
const NON_NEGATIVE_INTEGER = /^(0|[1-9]\d*)$/;
|
|
34
36
|
/**
|
|
@@ -135,6 +137,17 @@ function rowToOrg(row) {
|
|
|
135
137
|
: {}),
|
|
136
138
|
},
|
|
137
139
|
issuer: { usesDomain: row.issuerUsesDomain },
|
|
140
|
+
// Omitted while shared, as `calls.versionPin` is.
|
|
141
|
+
...(row.servicesOwn || row.servicesSfuSlot !== null
|
|
142
|
+
? {
|
|
143
|
+
services: {
|
|
144
|
+
own: row.servicesOwn,
|
|
145
|
+
...(row.servicesSfuSlot !== null ? { sfuSlot: row.servicesSfuSlot } : {}),
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
: {}),
|
|
149
|
+
// Omitted while unset. Validated on read: a stored value is not trusted.
|
|
150
|
+
...(row.versionPin ? { versionPin: readVersionPin(row) } : {}),
|
|
138
151
|
site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
|
|
139
152
|
hosting: {
|
|
140
153
|
enabled: row.hostingEnabled,
|
|
@@ -158,6 +171,14 @@ function rowToOrg(row) {
|
|
|
158
171
|
test: row.isTest,
|
|
159
172
|
};
|
|
160
173
|
}
|
|
174
|
+
function readVersionPin(row) {
|
|
175
|
+
const pin = row.versionPin;
|
|
176
|
+
const reason = validateVersionPin({ versionPin: pin });
|
|
177
|
+
if (reason) {
|
|
178
|
+
throw new DeclaredOrgError(`stored org ${row.slug} has an invalid versionPin: ${reason}`);
|
|
179
|
+
}
|
|
180
|
+
return pin;
|
|
181
|
+
}
|
|
161
182
|
function orgToRow(org) {
|
|
162
183
|
return {
|
|
163
184
|
id: org.id,
|
|
@@ -177,6 +198,9 @@ function orgToRow(org) {
|
|
|
177
198
|
callsVersionPinSynapse: org.calls.versionPin?.synapse ?? null,
|
|
178
199
|
callsVersionPinMas: org.calls.versionPin?.mas ?? null,
|
|
179
200
|
issuerUsesDomain: org.issuer.usesDomain,
|
|
201
|
+
servicesOwn: org.services?.own ?? false,
|
|
202
|
+
servicesSfuSlot: org.services?.sfuSlot ?? null,
|
|
203
|
+
versionPin: org.versionPin && Object.keys(org.versionPin).length > 0 ? org.versionPin : null,
|
|
180
204
|
siteEnabled: org.site.enabled,
|
|
181
205
|
siteUsesDomain: org.site.usesDomain,
|
|
182
206
|
hostingEnabled: org.hosting.enabled,
|
|
@@ -275,6 +299,29 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
275
299
|
if (reason)
|
|
276
300
|
throw new DeclaredOrgError(`domain invalid: ${reason} (#131)`);
|
|
277
301
|
}
|
|
302
|
+
// The platform's apex and every other company's domain are read here, so a claim is checked against what is declared now.
|
|
303
|
+
async function requireDomainClaim(org) {
|
|
304
|
+
if (org.domain === null)
|
|
305
|
+
return;
|
|
306
|
+
const [platformRow] = await db
|
|
307
|
+
.select({
|
|
308
|
+
apex: foundryDeclaredPlatform.apex,
|
|
309
|
+
ownerOrgId: foundryDeclaredPlatform.ownerOrgId,
|
|
310
|
+
})
|
|
311
|
+
.from(foundryDeclaredPlatform)
|
|
312
|
+
.limit(1);
|
|
313
|
+
const others = await db
|
|
314
|
+
.select({ id: declaredOrgs.id, domain: declaredOrgs.domain })
|
|
315
|
+
.from(declaredOrgs)
|
|
316
|
+
.where(ne(declaredOrgs.id, org.id));
|
|
317
|
+
const reason = validateDomainClaim(org.domain, {
|
|
318
|
+
orgId: org.id,
|
|
319
|
+
platform: platformRow ?? null,
|
|
320
|
+
others,
|
|
321
|
+
});
|
|
322
|
+
if (reason)
|
|
323
|
+
throw new DeclaredOrgError(`domain invalid: ${reason}`);
|
|
324
|
+
}
|
|
278
325
|
function requireValidLimit(fieldName, value) {
|
|
279
326
|
if (value !== null && !NON_NEGATIVE_INTEGER.test(value)) {
|
|
280
327
|
throw new DeclaredOrgError(`${fieldName} '${value}' must be a bigint-safe decimal string of a non-negative integer, or null for uncapped (#27)`);
|
|
@@ -344,6 +391,33 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
344
391
|
const reason = validateCalls(org);
|
|
345
392
|
if (reason)
|
|
346
393
|
throw new DeclaredOrgError(`calls invalid: ${reason} (#259)`);
|
|
394
|
+
const pinReason = validateVersionPin(org);
|
|
395
|
+
if (pinReason)
|
|
396
|
+
throw new DeclaredOrgError(`versionPin invalid: ${pinReason}`);
|
|
397
|
+
}
|
|
398
|
+
// Own-service rules, on `create` and on the merged result of `update`; no
|
|
399
|
+
// two orgs share an `sfuSlot` (the unique index catches the race this misses).
|
|
400
|
+
async function requireValidServices(org) {
|
|
401
|
+
const reason = validateServices(org);
|
|
402
|
+
if (reason)
|
|
403
|
+
throw new DeclaredOrgError(`services invalid: ${reason}`);
|
|
404
|
+
const slot = org.services?.sfuSlot ?? null;
|
|
405
|
+
if (slot === null)
|
|
406
|
+
return;
|
|
407
|
+
const [taken] = await db
|
|
408
|
+
.select({ id: declaredOrgs.id })
|
|
409
|
+
.from(declaredOrgs)
|
|
410
|
+
.where(and(eq(declaredOrgs.servicesSfuSlot, slot), ne(declaredOrgs.id, org.id)))
|
|
411
|
+
.limit(1);
|
|
412
|
+
if (taken)
|
|
413
|
+
throw new DeclaredOrgError(`services invalid: sfuSlot ${slot} is already taken by another org`);
|
|
414
|
+
}
|
|
415
|
+
// No name foundry derives for this org may be one another org or the platform derives.
|
|
416
|
+
async function requireNoNameClash(org) {
|
|
417
|
+
const rows = await db.select().from(declaredOrgs).where(ne(declaredOrgs.id, org.id));
|
|
418
|
+
const [clash] = findNameClashes(org, rows.map(rowToOrg));
|
|
419
|
+
if (clash)
|
|
420
|
+
throw new DeclaredOrgError(nameClashMessage(org.slug, clash));
|
|
347
421
|
}
|
|
348
422
|
// `as`: `update` is overloaded in the interface (#351), which one object-literal method cannot express.
|
|
349
423
|
return {
|
|
@@ -357,6 +431,9 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
357
431
|
requireValidOwner(org);
|
|
358
432
|
requireValidMail(org);
|
|
359
433
|
requireValidCalls(org);
|
|
434
|
+
await requireValidServices(org);
|
|
435
|
+
await requireNoNameClash(org);
|
|
436
|
+
await requireDomainClaim(org);
|
|
360
437
|
try {
|
|
361
438
|
await db.insert(declaredOrgs).values(orgToRow(org));
|
|
362
439
|
}
|
|
@@ -371,6 +448,9 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
371
448
|
if (constraint === DOMAIN_UNIQUE_INDEX) {
|
|
372
449
|
throw new DeclaredOrgError(`domain '${org.domain}' is already taken by another org (#131)`);
|
|
373
450
|
}
|
|
451
|
+
if (constraint === SFU_SLOT_UNIQUE_INDEX) {
|
|
452
|
+
throw new DeclaredOrgError(`services invalid: sfuSlot ${org.services?.sfuSlot} is already taken by another org`);
|
|
453
|
+
}
|
|
374
454
|
if (constraint === SLUG_UNIQUE_INDEX || constraint !== undefined) {
|
|
375
455
|
throw new DeclaredOrgError(`slug '${org.slug}' is already taken by another org`);
|
|
376
456
|
}
|
|
@@ -461,6 +541,10 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
461
541
|
const wasTier2 = current.domain !== null && current.mail.mode === 'received';
|
|
462
542
|
requireValidMail(next, { allowExistingTier2: wasTier2 });
|
|
463
543
|
requireValidCalls(next);
|
|
544
|
+
await requireValidServices(next);
|
|
545
|
+
await requireNoNameClash(next);
|
|
546
|
+
if (next.domain !== current.domain)
|
|
547
|
+
await requireDomainClaim(next);
|
|
464
548
|
try {
|
|
465
549
|
// #351: with `expectedVersion` the version check is part of this one
|
|
466
550
|
// statement, so an edit that lands after the read above makes it match
|
|
@@ -489,6 +573,9 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
489
573
|
if (constraint === DOMAIN_UNIQUE_INDEX) {
|
|
490
574
|
throw new DeclaredOrgError(`domain '${next.domain}' is already taken by another org (#131)`);
|
|
491
575
|
}
|
|
576
|
+
if (constraint === SFU_SLOT_UNIQUE_INDEX) {
|
|
577
|
+
throw new DeclaredOrgError(`services invalid: sfuSlot ${next.services?.sfuSlot} is already taken by another org`);
|
|
578
|
+
}
|
|
492
579
|
if (constraint === SLUG_UNIQUE_INDEX || constraint !== undefined) {
|
|
493
580
|
throw new DeclaredOrgError(`slug '${next.slug}' is already taken by another org`);
|
|
494
581
|
}
|
|
@@ -605,6 +692,7 @@ function rowToPlatform(row) {
|
|
|
605
692
|
hostname: row.mailHostname,
|
|
606
693
|
gitRef: row.mailGitRef,
|
|
607
694
|
...(row.mailLettermintProjectId ? { lettermintProjectId: row.mailLettermintProjectId } : {}),
|
|
695
|
+
...(row.mailRelay ? { relay: true } : {}),
|
|
608
696
|
},
|
|
609
697
|
sfu: {
|
|
610
698
|
enabled: row.sfuEnabled,
|
|
@@ -641,6 +729,7 @@ function rowToPlatform(row) {
|
|
|
641
729
|
gitRef: row.coolifyGitRef,
|
|
642
730
|
},
|
|
643
731
|
uptime: { enabled: row.uptimeEnabled, tag: row.uptimeTag, sha256: row.uptimeSha256 },
|
|
732
|
+
heartbeatsProvider: row.heartbeatsProvider,
|
|
644
733
|
};
|
|
645
734
|
}
|
|
646
735
|
function platformToRow(platform) {
|
|
@@ -656,6 +745,7 @@ function platformToRow(platform) {
|
|
|
656
745
|
mailHostname: platform.mail.hostname,
|
|
657
746
|
mailGitRef: platform.mail.gitRef,
|
|
658
747
|
mailLettermintProjectId: platform.mail.lettermintProjectId ?? null,
|
|
748
|
+
mailRelay: platform.mail.relay === true,
|
|
659
749
|
sfuEnabled: platform.sfu.enabled,
|
|
660
750
|
sfuHostname: platform.sfu.hostname,
|
|
661
751
|
sfuGitRef: platform.sfu.gitRef,
|
|
@@ -687,6 +777,7 @@ function platformToRow(platform) {
|
|
|
687
777
|
uptimeEnabled: platform.uptime.enabled,
|
|
688
778
|
uptimeTag: platform.uptime.tag,
|
|
689
779
|
uptimeSha256: platform.uptime.sha256,
|
|
780
|
+
heartbeatsProvider: platform.heartbeatsProvider,
|
|
690
781
|
};
|
|
691
782
|
}
|
|
692
783
|
/**
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -321,6 +321,59 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
|
|
|
321
321
|
identity: undefined;
|
|
322
322
|
generated: undefined;
|
|
323
323
|
}, {}, {}>;
|
|
324
|
+
servicesOwn: import("drizzle-orm/pg-core").PgColumn<{
|
|
325
|
+
name: "services_own";
|
|
326
|
+
tableName: "foundry_declared_orgs";
|
|
327
|
+
dataType: "boolean";
|
|
328
|
+
columnType: "PgBoolean";
|
|
329
|
+
data: boolean;
|
|
330
|
+
driverParam: boolean;
|
|
331
|
+
notNull: true;
|
|
332
|
+
hasDefault: true;
|
|
333
|
+
isPrimaryKey: false;
|
|
334
|
+
isAutoincrement: false;
|
|
335
|
+
hasRuntimeDefault: false;
|
|
336
|
+
enumValues: undefined;
|
|
337
|
+
baseColumn: never;
|
|
338
|
+
identity: undefined;
|
|
339
|
+
generated: undefined;
|
|
340
|
+
}, {}, {}>;
|
|
341
|
+
servicesSfuSlot: import("drizzle-orm/pg-core").PgColumn<{
|
|
342
|
+
name: "services_sfu_slot";
|
|
343
|
+
tableName: "foundry_declared_orgs";
|
|
344
|
+
dataType: "number";
|
|
345
|
+
columnType: "PgInteger";
|
|
346
|
+
data: number;
|
|
347
|
+
driverParam: string | number;
|
|
348
|
+
notNull: false;
|
|
349
|
+
hasDefault: false;
|
|
350
|
+
isPrimaryKey: false;
|
|
351
|
+
isAutoincrement: false;
|
|
352
|
+
hasRuntimeDefault: false;
|
|
353
|
+
enumValues: undefined;
|
|
354
|
+
baseColumn: never;
|
|
355
|
+
identity: undefined;
|
|
356
|
+
generated: undefined;
|
|
357
|
+
}, {}, {}>;
|
|
358
|
+
versionPin: import("drizzle-orm/pg-core").PgColumn<{
|
|
359
|
+
name: "version_pin";
|
|
360
|
+
tableName: "foundry_declared_orgs";
|
|
361
|
+
dataType: "json";
|
|
362
|
+
columnType: "PgJsonb";
|
|
363
|
+
data: Record<string, string>;
|
|
364
|
+
driverParam: unknown;
|
|
365
|
+
notNull: false;
|
|
366
|
+
hasDefault: false;
|
|
367
|
+
isPrimaryKey: false;
|
|
368
|
+
isAutoincrement: false;
|
|
369
|
+
hasRuntimeDefault: false;
|
|
370
|
+
enumValues: undefined;
|
|
371
|
+
baseColumn: never;
|
|
372
|
+
identity: undefined;
|
|
373
|
+
generated: undefined;
|
|
374
|
+
}, {}, {
|
|
375
|
+
$type: Record<string, string>;
|
|
376
|
+
}>;
|
|
324
377
|
siteEnabled: import("drizzle-orm/pg-core").PgColumn<{
|
|
325
378
|
name: "site_enabled";
|
|
326
379
|
tableName: "foundry_declared_orgs";
|
|
@@ -1592,6 +1645,23 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
|
|
|
1592
1645
|
identity: undefined;
|
|
1593
1646
|
generated: undefined;
|
|
1594
1647
|
}, {}, {}>;
|
|
1648
|
+
mailRelay: import("drizzle-orm/pg-core").PgColumn<{
|
|
1649
|
+
name: "mail_relay";
|
|
1650
|
+
tableName: "foundry_declared_platform";
|
|
1651
|
+
dataType: "boolean";
|
|
1652
|
+
columnType: "PgBoolean";
|
|
1653
|
+
data: boolean;
|
|
1654
|
+
driverParam: boolean;
|
|
1655
|
+
notNull: true;
|
|
1656
|
+
hasDefault: true;
|
|
1657
|
+
isPrimaryKey: false;
|
|
1658
|
+
isAutoincrement: false;
|
|
1659
|
+
hasRuntimeDefault: false;
|
|
1660
|
+
enumValues: undefined;
|
|
1661
|
+
baseColumn: never;
|
|
1662
|
+
identity: undefined;
|
|
1663
|
+
generated: undefined;
|
|
1664
|
+
}, {}, {}>;
|
|
1595
1665
|
sfuEnabled: import("drizzle-orm/pg-core").PgColumn<{
|
|
1596
1666
|
name: "sfu_enabled";
|
|
1597
1667
|
tableName: "foundry_declared_platform";
|
|
@@ -2119,6 +2189,23 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
|
|
|
2119
2189
|
identity: undefined;
|
|
2120
2190
|
generated: undefined;
|
|
2121
2191
|
}, {}, {}>;
|
|
2192
|
+
heartbeatsProvider: import("drizzle-orm/pg-core").PgColumn<{
|
|
2193
|
+
name: "heartbeats_provider";
|
|
2194
|
+
tableName: "foundry_declared_platform";
|
|
2195
|
+
dataType: "string";
|
|
2196
|
+
columnType: "PgText";
|
|
2197
|
+
data: string;
|
|
2198
|
+
driverParam: string;
|
|
2199
|
+
notNull: true;
|
|
2200
|
+
hasDefault: true;
|
|
2201
|
+
isPrimaryKey: false;
|
|
2202
|
+
isAutoincrement: false;
|
|
2203
|
+
hasRuntimeDefault: false;
|
|
2204
|
+
enumValues: [string, ...string[]];
|
|
2205
|
+
baseColumn: never;
|
|
2206
|
+
identity: undefined;
|
|
2207
|
+
generated: undefined;
|
|
2208
|
+
}, {}, {}>;
|
|
2122
2209
|
serviceIds: import("drizzle-orm/pg-core").PgColumn<{
|
|
2123
2210
|
name: "service_ids";
|
|
2124
2211
|
tableName: "foundry_declared_platform";
|
package/dist/store/schema.js
CHANGED
|
@@ -35,6 +35,11 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
|
|
|
35
35
|
callsVersionPinSynapse: text('calls_version_pin_synapse'),
|
|
36
36
|
callsVersionPinMas: text('calls_version_pin_mas'),
|
|
37
37
|
issuerUsesDomain: boolean('issuer_uses_domain').notNull().default(false),
|
|
38
|
+
// ServicesConfig (org.ts): false and null are the platform's shared services.
|
|
39
|
+
servicesOwn: boolean('services_own').notNull().default(false),
|
|
40
|
+
servicesSfuSlot: integer('services_sfu_slot'),
|
|
41
|
+
// VersionPin (org.ts): null follows the platform. `migrations/0053_org_version_pin.sql`.
|
|
42
|
+
versionPin: jsonb('version_pin').$type(),
|
|
38
43
|
// SiteConfig (org.ts, T10). A brand new org has no cms site.
|
|
39
44
|
siteEnabled: boolean('site_enabled').notNull().default(false),
|
|
40
45
|
siteUsesDomain: boolean('site_uses_domain').notNull().default(false),
|
|
@@ -162,6 +167,8 @@ export const foundryDeclaredPlatform = pgTable('foundry_declared_platform', {
|
|
|
162
167
|
mailGitRef: text('mail_git_ref').notNull().default(''),
|
|
163
168
|
/** #491, `migrations/0044_platform_mail_lettermint_project.sql`. Declared, never discovered. */
|
|
164
169
|
mailLettermintProjectId: text('mail_lettermint_project_id'),
|
|
170
|
+
/** `migrations/0052_platform_mail_relay.sql`. */
|
|
171
|
+
mailRelay: boolean('mail_relay').notNull().default(false),
|
|
165
172
|
sfuEnabled: boolean('sfu_enabled').notNull().default(false),
|
|
166
173
|
sfuHostname: text('sfu_hostname').notNull(),
|
|
167
174
|
sfuGitRef: text('sfu_git_ref').notNull().default(''),
|
|
@@ -198,6 +205,8 @@ export const foundryDeclaredPlatform = pgTable('foundry_declared_platform', {
|
|
|
198
205
|
uptimeEnabled: boolean('uptime_enabled').notNull().default(true),
|
|
199
206
|
uptimeTag: text('uptime_tag').notNull().default(''),
|
|
200
207
|
uptimeSha256: text('uptime_sha256').notNull().default(''),
|
|
208
|
+
/** foundry#544, `migrations/0053_heartbeats_provider.sql`: which Healthchecks instance manages the heartbeat checks, decoupled from `hcEnabled` above. */
|
|
209
|
+
heartbeatsProvider: text('heartbeats_provider').notNull().default('self-hosted'),
|
|
201
210
|
/** `migrations/0018_platform_apply.sql`: the platform's applied state, `system:kind:key` to provider id. */
|
|
202
211
|
serviceIds: jsonb('service_ids').$type().notNull().default({}),
|
|
203
212
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/foundry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.1",
|
|
4
4
|
"description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"type": "module",
|
|
12
|
-
"files": ["dist", "versions.json"],
|
|
12
|
+
"files": ["dist", "versions.json", "versions.digests.json"],
|
|
13
13
|
"exports": {
|
|
14
14
|
".": {
|
|
15
15
|
"types": "./dist/index.d.ts",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@wtfalch/audit": "0.6.0",
|
|
46
46
|
"@wtfalch/auth": "0.16.0",
|
|
47
|
-
"@wtfalch/edge": "0.
|
|
47
|
+
"@wtfalch/edge": "0.13.0",
|
|
48
48
|
"@wtfalch/email": "^0.20.0",
|
|
49
49
|
"@wtfalch/providers": "0.7.5",
|
|
50
50
|
"ssh2": "1.17.0",
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"zitadel": "sha256:2ec2a42551862ca59dc752c321c7041358dea8b33b63ea5e021ec499ad5e2d9f",
|
|
3
|
+
"synapse": "sha256:6b95dd129e35e97e8032cb759e5db4a516a5f7ef6c98d34d716fea1ae239b62a",
|
|
4
|
+
"mas": "sha256:52c18ffcc940220a3b6aa5985b7e09d24ac27f5ed10a4d660c312e48f73ff105",
|
|
5
|
+
"livekit": "sha256:6fd3b7088874c4d119160dd688798dfec852bc014786d392caad15f6f63912a3",
|
|
6
|
+
"cloudflared": "sha256:072c067d25ccbe61d46e18f0d0723255f2bb5304f7317caa95b27031520ff92c",
|
|
7
|
+
"stalwart": "sha256:ec011be228596e37e65f41aab17deed573859614430472f7eeb42178c50d87b7",
|
|
8
|
+
"hc": "sha256:aa08a61b0dcfd83f1332eab195d5cec1b595ae076eb8388cadf8a3d597114f9b",
|
|
9
|
+
"alpine": "sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1",
|
|
10
|
+
"busybox": "sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092"
|
|
11
|
+
}
|