@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
@@ -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
  }
@@ -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 created = await coolify.createPostgresDatabase({
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 '-boule') 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`,
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;
@@ -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
  /**
@@ -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";
@@ -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.33.1",
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.12.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
+ }
package/versions.json CHANGED
@@ -5,5 +5,7 @@
5
5
  "livekit": "v1.13.7",
6
6
  "cloudflared": "2026.9.3",
7
7
  "stalwart": "v0.16.24",
8
- "hc": "v4.4"
8
+ "hc": "v4.4",
9
+ "alpine": "3.22.1",
10
+ "busybox": "1.37.0-musl"
9
11
  }