@wtfalch/foundry 0.33.1 → 0.34.0

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 (65) hide show
  1. package/dist/bootstrap/versions.d.ts +4 -0
  2. package/dist/calls-sfu.d.ts +10 -0
  3. package/dist/calls-sfu.js +41 -0
  4. package/dist/index.d.ts +14 -6
  5. package/dist/index.js +7 -3
  6. package/dist/migrations/0051_org_services.sql +13 -0
  7. package/dist/migrations/0052_platform_mail_relay.sql +8 -0
  8. package/dist/org.d.ts +18 -0
  9. package/dist/org.js +26 -0
  10. package/dist/plan/calls-matrix.d.ts +9 -0
  11. package/dist/plan/calls-matrix.js +17 -0
  12. package/dist/plan/cloudflared-connector.d.ts +7 -1
  13. package/dist/plan/cloudflared-connector.js +14 -1
  14. package/dist/plan/diff.d.ts +29 -0
  15. package/dist/plan/diff.js +259 -18
  16. package/dist/plan/executor.d.ts +112 -0
  17. package/dist/plan/executor.js +594 -33
  18. package/dist/plan/health-check-settings.d.ts +28 -0
  19. package/dist/plan/health-check-settings.js +36 -0
  20. package/dist/plan/health-gaps.d.ts +33 -0
  21. package/dist/plan/health-gaps.js +89 -0
  22. package/dist/plan/held-key-refs.d.ts +10 -0
  23. package/dist/plan/held-key-refs.js +10 -0
  24. package/dist/plan/index.d.ts +14 -6
  25. package/dist/plan/index.js +7 -3
  26. package/dist/plan/live.d.ts +5 -2
  27. package/dist/plan/live.js +15 -2
  28. package/dist/plan/locked-hostnames.d.ts +5 -2
  29. package/dist/plan/locked-hostnames.js +9 -1
  30. package/dist/plan/login-host.d.ts +62 -2
  31. package/dist/plan/login-host.js +104 -1
  32. package/dist/plan/mail-relay.d.ts +120 -0
  33. package/dist/plan/mail-relay.js +323 -0
  34. package/dist/plan/org-names.d.ts +24 -0
  35. package/dist/plan/org-names.js +52 -0
  36. package/dist/plan/own-diff.d.ts +169 -0
  37. package/dist/plan/own-diff.js +432 -0
  38. package/dist/plan/own-dns.d.ts +6 -0
  39. package/dist/plan/own-dns.js +15 -0
  40. package/dist/plan/own-limits.d.ts +22 -0
  41. package/dist/plan/own-limits.js +101 -0
  42. package/dist/plan/own-services.d.ts +193 -0
  43. package/dist/plan/own-services.js +167 -0
  44. package/dist/plan/own-sfu.d.ts +69 -0
  45. package/dist/plan/own-sfu.js +255 -0
  46. package/dist/plan/plan-apply.d.ts +78 -7
  47. package/dist/plan/plan-apply.js +205 -12
  48. package/dist/plan/platform-diff.d.ts +28 -0
  49. package/dist/plan/platform-diff.js +45 -3
  50. package/dist/plan/platform-org.d.ts +34 -0
  51. package/dist/plan/platform-org.js +94 -2
  52. package/dist/plan/platform-providers.d.ts +356 -8
  53. package/dist/plan/platform-providers.js +661 -353
  54. package/dist/plan/sign-in-org.d.ts +1 -1
  55. package/dist/plan/sign-in-org.js +20 -2
  56. package/dist/plan/zitadel-login-policy.js +1 -1
  57. package/dist/plan/zitadel-system-api.d.ts +22 -1
  58. package/dist/plan/zitadel-system-api.js +49 -8
  59. package/dist/platform.d.ts +8 -0
  60. package/dist/slug.js +1 -0
  61. package/dist/store/postgres.js +51 -2
  62. package/dist/store/schema.d.ts +51 -0
  63. package/dist/store/schema.js +5 -0
  64. package/package.json +1 -1
  65. package/versions.json +3 -1
@@ -23,7 +23,7 @@ export declare function signInOrgDomain(org: {
23
23
  * idempotent. A failure after the add leaves the domain on the org but not
24
24
  * primary, and nothing is recorded, so the next run repeats both.
25
25
  */
26
- export declare function registerSignInDomain(zitadel: Pick<ZitadelPort, 'addOrgDomain' | 'setPrimaryOrgDomain'>, orgId: string, domain: string): Promise<void>;
26
+ export declare function registerSignInDomain(zitadel: Pick<ZitadelPort, 'addOrgDomain' | 'setPrimaryOrgDomain'>, orgId: string, domain: string, sleep?: (ms: number) => Promise<void>): Promise<void>;
27
27
  /**
28
28
  * {@link registerSignInDomain}, except that a refusal to make the domain
29
29
  * primary (the sign-in server's domain policy wants proof) is recorded under
@@ -21,6 +21,12 @@ export function signInDomainBlockedText(orgName, domain) {
21
21
  function isPrimaryDomainRefused(error) {
22
22
  return error instanceof Error && /is not verified/.test(error.message);
23
23
  }
24
+ /** True for the providers' "domain is not on org" answer of `setPrimaryOrgDomain`. */
25
+ function isDomainNotListed(error) {
26
+ return error instanceof Error && /is not on org/.test(error.message);
27
+ }
28
+ const DOMAIN_LISTED_TRIES = 10;
29
+ const DOMAIN_LISTED_WAIT_MS = 500;
24
30
  /** The domain an org's owners sign in on: its own, or `<slug>.oikos.host` when it has none. */
25
31
  export function signInOrgDomain(org) {
26
32
  return (org.domain ?? `${org.slug}.oikos.host`).toLowerCase();
@@ -30,9 +36,21 @@ export function signInOrgDomain(org) {
30
36
  * idempotent. A failure after the add leaves the domain on the org but not
31
37
  * primary, and nothing is recorded, so the next run repeats both.
32
38
  */
33
- export async function registerSignInDomain(zitadel, orgId, domain) {
39
+ export async function registerSignInDomain(zitadel, orgId, domain, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
34
40
  await zitadel.addOrgDomain(orgId, domain);
35
- await zitadel.setPrimaryOrgDomain(orgId, domain);
41
+ // The sign-in server lists a domain a moment after it accepts the add, and
42
+ // `setPrimaryOrgDomain` reads that list first: ask again, for five seconds.
43
+ for (let attempt = 1;; attempt += 1) {
44
+ try {
45
+ await zitadel.setPrimaryOrgDomain(orgId, domain);
46
+ return;
47
+ }
48
+ catch (error) {
49
+ if (attempt >= DOMAIN_LISTED_TRIES || !isDomainNotListed(error))
50
+ throw error;
51
+ await sleep(DOMAIN_LISTED_WAIT_MS);
52
+ }
53
+ }
36
54
  }
37
55
  /**
38
56
  * {@link registerSignInDomain}, except that a refusal to make the domain
@@ -74,7 +74,7 @@ export function createZitadelLoginPolicy(options) {
74
74
  const data = await call('GET', orgId, undefined);
75
75
  const policy = data.policy ?? {};
76
76
  const isDefault = data.isDefault === true || policy.isDefault === true;
77
- if (!isDefault && policy.allowRegister === false) {
77
+ if (!isDefault && policy.allowRegister !== true) {
78
78
  return { changed: false };
79
79
  }
80
80
  const settings = {};
@@ -36,13 +36,32 @@ export declare class CreateInstanceError extends Error {
36
36
  constructor(kind: 'rejected' | 'exists' | 'uncertain', message: string);
37
37
  }
38
38
  /** What a person does when an instance exists without a delivered bearer. Nothing is deleted automatically. */
39
- export declare function instanceWithoutBearerMessage(hostname: string, detail: string): string;
39
+ export declare function instanceWithoutBearerMessage(hostname: string, detail: string, label?: string): string;
40
40
  export interface CreateInstanceRuntime {
41
41
  fetch?: typeof fetch;
42
42
  now: () => number;
43
43
  /** Fetch timeout for the one create call (default 30s). */
44
44
  timeoutMs?: number;
45
45
  }
46
+ /**
47
+ * Waits until ZITADEL's system API answers, before the one create call. `/debug/healthz` and `/debug/ready`
48
+ * answer as soon as the HTTP server listens, but the gateway behind the system API reaches its gRPC server a
49
+ * moment later and answers 503 until then (seen: `POST /instances/_create` 503 about 100 ms after "server is
50
+ * listening"). A 503 on the create leaves the instance marked as possibly created, so the wait asks first with a
51
+ * read that changes nothing: `POST /system/v1/instances/_search`. Any answer other than a failed request or a
52
+ * 5xx counts (a 401 is the key being refused, which the create reports itself). Times out with an error that
53
+ * names the last answer.
54
+ */
55
+ export declare function waitForSystemApi(input: {
56
+ customDomain: string;
57
+ privateKey: string;
58
+ label?: string;
59
+ }, runtime: CreateInstanceRuntime & {
60
+ sleep: (ms: number) => Promise<void>;
61
+ timeoutMs?: number;
62
+ intervalMs?: number;
63
+ requestTimeoutMs?: number;
64
+ }): Promise<void>;
46
65
  /**
47
66
  * Creates the platform's ZITADEL instance on `customDomain` with one machine
48
67
  * owner and returns its id and the owner's PAT. Sends exactly one request,
@@ -54,6 +73,8 @@ export declare function createZitadelInstance(input: {
54
73
  privateKey: string;
55
74
  instanceName: string;
56
75
  firstOrgName: string;
76
+ /** Names the target in error texts (default `platform:auth`). */
77
+ label?: string;
57
78
  owner: {
58
79
  userName: string;
59
80
  name: string;
@@ -61,8 +61,48 @@ export class CreateInstanceError extends Error {
61
61
  }
62
62
  }
63
63
  /** What a person does when an instance exists without a delivered bearer. Nothing is deleted automatically. */
64
- export function instanceWithoutBearerMessage(hostname, detail) {
65
- return `platform:auth: a ZITADEL instance may already exist for ${hostname} without a delivered bearer (${detail}). To recover, delete the instance with the system API (DELETE /system/v1/instances/{id}) or redeploy the ZITADEL database, remove the recorded key zitadel_platform_instance_undelivered, then apply again`;
64
+ export function instanceWithoutBearerMessage(hostname, detail, label = 'platform:auth') {
65
+ return `${label}: a ZITADEL instance may already exist for ${hostname} without a delivered bearer (${detail}). To recover, delete the instance with the system API (DELETE /system/v1/instances/{id}) or redeploy the ZITADEL database, remove the recorded key zitadel_platform_instance_undelivered, then apply again`;
66
+ }
67
+ /**
68
+ * Waits until ZITADEL's system API answers, before the one create call. `/debug/healthz` and `/debug/ready`
69
+ * answer as soon as the HTTP server listens, but the gateway behind the system API reaches its gRPC server a
70
+ * moment later and answers 503 until then (seen: `POST /instances/_create` 503 about 100 ms after "server is
71
+ * listening"). A 503 on the create leaves the instance marked as possibly created, so the wait asks first with a
72
+ * read that changes nothing: `POST /system/v1/instances/_search`. Any answer other than a failed request or a
73
+ * 5xx counts (a 401 is the key being refused, which the create reports itself). Times out with an error that
74
+ * names the last answer.
75
+ */
76
+ export async function waitForSystemApi(input, runtime) {
77
+ const fetchImpl = runtime.fetch ?? globalThis.fetch;
78
+ const url = `https://${input.customDomain}`;
79
+ const timeout = runtime.timeoutMs ?? 300_000;
80
+ const interval = runtime.intervalMs ?? 2_000;
81
+ const deadline = runtime.now() + timeout;
82
+ let last = 'no answer';
83
+ for (;;) {
84
+ try {
85
+ const response = await fetchImpl(`${url}/system/v1/instances/_search`, {
86
+ method: 'POST',
87
+ signal: AbortSignal.timeout(runtime.requestTimeoutMs ?? 10_000),
88
+ headers: {
89
+ authorization: `Bearer ${signSystemApiJwt(input.privateKey, url, runtime.now())}`,
90
+ 'content-type': 'application/json',
91
+ },
92
+ body: '{}',
93
+ });
94
+ if (response.status < 500)
95
+ return;
96
+ last = `HTTP ${response.status}`;
97
+ }
98
+ catch {
99
+ last = 'the request failed';
100
+ }
101
+ if (runtime.now() >= deadline) {
102
+ throw new Error(`${input.label ?? 'platform:auth'}: ZITADEL's system API did not answer at ${url} within ${Math.round(timeout / 1000)}s (${last}); nothing was created, retry this apply`);
103
+ }
104
+ await runtime.sleep(interval);
105
+ }
66
106
  }
67
107
  /**
68
108
  * Creates the platform's ZITADEL instance on `customDomain` with one machine
@@ -73,6 +113,7 @@ export function instanceWithoutBearerMessage(hostname, detail) {
73
113
  export async function createZitadelInstance(input, runtime) {
74
114
  const fetchImpl = runtime.fetch ?? globalThis.fetch;
75
115
  const url = `https://${input.customDomain}`;
116
+ const label = input.label ?? 'platform:auth';
76
117
  let response;
77
118
  try {
78
119
  response = await fetchImpl(`${url}/system/v1/instances/_create`, {
@@ -97,19 +138,19 @@ export async function createZitadelInstance(input, runtime) {
97
138
  });
98
139
  }
99
140
  catch {
100
- throw new CreateInstanceError('uncertain', instanceWithoutBearerMessage(input.customDomain, 'the create request timed out or failed'));
141
+ throw new CreateInstanceError('uncertain', instanceWithoutBearerMessage(input.customDomain, 'the create request timed out or failed', label));
101
142
  }
102
143
  if (response.status === 401) {
103
- throw new CreateInstanceError('rejected', 'platform:auth: system API key rejected (HTTP 401); nothing was created');
144
+ throw new CreateInstanceError('rejected', `${label}: system API key rejected (HTTP 401); nothing was created`);
104
145
  }
105
146
  if (response.status === 409) {
106
- throw new CreateInstanceError('exists', instanceWithoutBearerMessage(input.customDomain, 'ZITADEL answered 409, already exists'));
147
+ throw new CreateInstanceError('exists', instanceWithoutBearerMessage(input.customDomain, 'ZITADEL answered 409, already exists', label));
107
148
  }
108
149
  if (!response.ok) {
109
150
  // A 4xx is a refusal before anything was created; a 5xx may not be.
110
151
  throw new CreateInstanceError(response.status < 500 ? 'rejected' : 'uncertain', response.status < 500
111
- ? `platform:auth: ZITADEL refused to create the instance (HTTP ${response.status}); nothing was created`
112
- : instanceWithoutBearerMessage(input.customDomain, `ZITADEL answered HTTP ${response.status}`));
152
+ ? `${label}: ZITADEL refused to create the instance (HTTP ${response.status}); nothing was created`
153
+ : instanceWithoutBearerMessage(input.customDomain, `ZITADEL answered HTTP ${response.status}`, label));
113
154
  }
114
155
  let body = {};
115
156
  try {
@@ -119,7 +160,7 @@ export async function createZitadelInstance(input, runtime) {
119
160
  // Handled below.
120
161
  }
121
162
  if (!body.instanceId || !body.pat) {
122
- throw new CreateInstanceError('uncertain', instanceWithoutBearerMessage(input.customDomain, 'the answer had no instance id or token'));
163
+ throw new CreateInstanceError('uncertain', instanceWithoutBearerMessage(input.customDomain, 'the answer had no instance id or token', label));
123
164
  }
124
165
  return { instanceId: body.instanceId, pat: body.pat };
125
166
  }
@@ -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
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',
@@ -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, validateHosting, validateMail, validateOwners, validateServices, validateSite, } 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,15 @@ 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
+ : {}),
138
149
  site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
139
150
  hosting: {
140
151
  enabled: row.hostingEnabled,
@@ -177,6 +188,8 @@ function orgToRow(org) {
177
188
  callsVersionPinSynapse: org.calls.versionPin?.synapse ?? null,
178
189
  callsVersionPinMas: org.calls.versionPin?.mas ?? null,
179
190
  issuerUsesDomain: org.issuer.usesDomain,
191
+ servicesOwn: org.services?.own ?? false,
192
+ servicesSfuSlot: org.services?.sfuSlot ?? null,
180
193
  siteEnabled: org.site.enabled,
181
194
  siteUsesDomain: org.site.usesDomain,
182
195
  hostingEnabled: org.hosting.enabled,
@@ -345,6 +358,30 @@ export function createPostgresDeclaredOrgStore(db) {
345
358
  if (reason)
346
359
  throw new DeclaredOrgError(`calls invalid: ${reason} (#259)`);
347
360
  }
361
+ // Own-service rules, on `create` and on the merged result of `update`; no
362
+ // two orgs share an `sfuSlot` (the unique index catches the race this misses).
363
+ async function requireValidServices(org) {
364
+ const reason = validateServices(org);
365
+ if (reason)
366
+ throw new DeclaredOrgError(`services invalid: ${reason}`);
367
+ const slot = org.services?.sfuSlot ?? null;
368
+ if (slot === null)
369
+ return;
370
+ const [taken] = await db
371
+ .select({ id: declaredOrgs.id })
372
+ .from(declaredOrgs)
373
+ .where(and(eq(declaredOrgs.servicesSfuSlot, slot), ne(declaredOrgs.id, org.id)))
374
+ .limit(1);
375
+ if (taken)
376
+ throw new DeclaredOrgError(`services invalid: sfuSlot ${slot} is already taken by another org`);
377
+ }
378
+ // No name foundry derives for this org may be one another org or the platform derives.
379
+ async function requireNoNameClash(org) {
380
+ const rows = await db.select().from(declaredOrgs).where(ne(declaredOrgs.id, org.id));
381
+ const [clash] = findNameClashes(org, rows.map(rowToOrg));
382
+ if (clash)
383
+ throw new DeclaredOrgError(nameClashMessage(org.slug, clash));
384
+ }
348
385
  // `as`: `update` is overloaded in the interface (#351), which one object-literal method cannot express.
349
386
  return {
350
387
  async create(org) {
@@ -357,6 +394,8 @@ export function createPostgresDeclaredOrgStore(db) {
357
394
  requireValidOwner(org);
358
395
  requireValidMail(org);
359
396
  requireValidCalls(org);
397
+ await requireValidServices(org);
398
+ await requireNoNameClash(org);
360
399
  try {
361
400
  await db.insert(declaredOrgs).values(orgToRow(org));
362
401
  }
@@ -371,6 +410,9 @@ export function createPostgresDeclaredOrgStore(db) {
371
410
  if (constraint === DOMAIN_UNIQUE_INDEX) {
372
411
  throw new DeclaredOrgError(`domain '${org.domain}' is already taken by another org (#131)`);
373
412
  }
413
+ if (constraint === SFU_SLOT_UNIQUE_INDEX) {
414
+ throw new DeclaredOrgError(`services invalid: sfuSlot ${org.services?.sfuSlot} is already taken by another org`);
415
+ }
374
416
  if (constraint === SLUG_UNIQUE_INDEX || constraint !== undefined) {
375
417
  throw new DeclaredOrgError(`slug '${org.slug}' is already taken by another org`);
376
418
  }
@@ -461,6 +503,8 @@ export function createPostgresDeclaredOrgStore(db) {
461
503
  const wasTier2 = current.domain !== null && current.mail.mode === 'received';
462
504
  requireValidMail(next, { allowExistingTier2: wasTier2 });
463
505
  requireValidCalls(next);
506
+ await requireValidServices(next);
507
+ await requireNoNameClash(next);
464
508
  try {
465
509
  // #351: with `expectedVersion` the version check is part of this one
466
510
  // statement, so an edit that lands after the read above makes it match
@@ -489,6 +533,9 @@ export function createPostgresDeclaredOrgStore(db) {
489
533
  if (constraint === DOMAIN_UNIQUE_INDEX) {
490
534
  throw new DeclaredOrgError(`domain '${next.domain}' is already taken by another org (#131)`);
491
535
  }
536
+ if (constraint === SFU_SLOT_UNIQUE_INDEX) {
537
+ throw new DeclaredOrgError(`services invalid: sfuSlot ${next.services?.sfuSlot} is already taken by another org`);
538
+ }
492
539
  if (constraint === SLUG_UNIQUE_INDEX || constraint !== undefined) {
493
540
  throw new DeclaredOrgError(`slug '${next.slug}' is already taken by another org`);
494
541
  }
@@ -605,6 +652,7 @@ function rowToPlatform(row) {
605
652
  hostname: row.mailHostname,
606
653
  gitRef: row.mailGitRef,
607
654
  ...(row.mailLettermintProjectId ? { lettermintProjectId: row.mailLettermintProjectId } : {}),
655
+ ...(row.mailRelay ? { relay: true } : {}),
608
656
  },
609
657
  sfu: {
610
658
  enabled: row.sfuEnabled,
@@ -656,6 +704,7 @@ function platformToRow(platform) {
656
704
  mailHostname: platform.mail.hostname,
657
705
  mailGitRef: platform.mail.gitRef,
658
706
  mailLettermintProjectId: platform.mail.lettermintProjectId ?? null,
707
+ mailRelay: platform.mail.relay === true,
659
708
  sfuEnabled: platform.sfu.enabled,
660
709
  sfuHostname: platform.sfu.hostname,
661
710
  sfuGitRef: platform.sfu.gitRef,
@@ -321,6 +321,40 @@ 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
+ }, {}, {}>;
324
358
  siteEnabled: import("drizzle-orm/pg-core").PgColumn<{
325
359
  name: "site_enabled";
326
360
  tableName: "foundry_declared_orgs";
@@ -1592,6 +1626,23 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
1592
1626
  identity: undefined;
1593
1627
  generated: undefined;
1594
1628
  }, {}, {}>;
1629
+ mailRelay: import("drizzle-orm/pg-core").PgColumn<{
1630
+ name: "mail_relay";
1631
+ tableName: "foundry_declared_platform";
1632
+ dataType: "boolean";
1633
+ columnType: "PgBoolean";
1634
+ data: boolean;
1635
+ driverParam: boolean;
1636
+ notNull: true;
1637
+ hasDefault: true;
1638
+ isPrimaryKey: false;
1639
+ isAutoincrement: false;
1640
+ hasRuntimeDefault: false;
1641
+ enumValues: undefined;
1642
+ baseColumn: never;
1643
+ identity: undefined;
1644
+ generated: undefined;
1645
+ }, {}, {}>;
1595
1646
  sfuEnabled: import("drizzle-orm/pg-core").PgColumn<{
1596
1647
  name: "sfu_enabled";
1597
1648
  tableName: "foundry_declared_platform";
@@ -35,6 +35,9 @@ 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'),
38
41
  // SiteConfig (org.ts, T10). A brand new org has no cms site.
39
42
  siteEnabled: boolean('site_enabled').notNull().default(false),
40
43
  siteUsesDomain: boolean('site_uses_domain').notNull().default(false),
@@ -162,6 +165,8 @@ export const foundryDeclaredPlatform = pgTable('foundry_declared_platform', {
162
165
  mailGitRef: text('mail_git_ref').notNull().default(''),
163
166
  /** #491, `migrations/0044_platform_mail_lettermint_project.sql`. Declared, never discovered. */
164
167
  mailLettermintProjectId: text('mail_lettermint_project_id'),
168
+ /** `migrations/0052_platform_mail_relay.sql`. */
169
+ mailRelay: boolean('mail_relay').notNull().default(false),
165
170
  sfuEnabled: boolean('sfu_enabled').notNull().default(false),
166
171
  sfuHostname: text('sfu_hostname').notNull(),
167
172
  sfuGitRef: text('sfu_git_ref').notNull().default(''),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.33.1",
3
+ "version": "0.34.0",
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",
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
  }