@wtfalch/foundry 0.29.0 → 0.31.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.
@@ -18,12 +18,14 @@ interface SshStream {
18
18
  stderr: {
19
19
  on(event: string, listener: (...args: never[]) => void): unknown;
20
20
  };
21
+ close(): void;
21
22
  }
22
23
  interface SshSftp {
23
24
  writeFile(path: string, contents: string, options: {
24
25
  mode: number;
25
26
  } | undefined, cb: (err?: Error | null) => void): void;
26
27
  chmod(path: string, mode: number, cb: (err?: Error | null) => void): void;
28
+ end(): void;
27
29
  }
28
30
  interface SshClient {
29
31
  on(event: string, listener: (...args: never[]) => void): SshClient;
@@ -41,6 +43,15 @@ export interface CallsSfuSshConnectOptions {
41
43
  /** `SHA256:...`. Without it every host key is refused. */
42
44
  sshHostFingerprint: string | undefined;
43
45
  }
46
+ /** `docker compose up -d` pulls images, so a command gets two minutes. */
47
+ export declare const SSH_COMMAND_TIMEOUT_MS = 120000;
48
+ /** One SFTP write (open, write, chmod) of a small config file. */
49
+ export declare const SSH_WRITE_TIMEOUT_MS = 30000;
50
+ /** Handshake limit; ssh2's default is 20 s, set here so it is explicit. */
51
+ export declare const SSH_READY_TIMEOUT_MS = 20000;
52
+ /** A dead link ends the session after 3 missed keepalives, 10 s apart. */
53
+ export declare const SSH_KEEPALIVE_INTERVAL_MS = 10000;
54
+ export declare const SSH_KEEPALIVE_COUNT_MAX = 3;
44
55
  export declare function connectCallsSfuSsh(options: CallsSfuSshConnectOptions, deps?: {
45
56
  Client?: SshClientConstructor;
46
57
  }): Promise<CallsSfuSshSession & {
@@ -13,36 +13,87 @@
13
13
  * hands in a fake and never opens a socket.
14
14
  */
15
15
  import { verifyHostKey } from './calls-sfu-deploy-support.js';
16
- function sshExec(conn, command) {
16
+ /** `docker compose up -d` pulls images, so a command gets two minutes. */
17
+ export const SSH_COMMAND_TIMEOUT_MS = 120_000;
18
+ /** One SFTP write (open, write, chmod) of a small config file. */
19
+ export const SSH_WRITE_TIMEOUT_MS = 30_000;
20
+ /** Handshake limit; ssh2's default is 20 s, set here so it is explicit. */
21
+ export const SSH_READY_TIMEOUT_MS = 20_000;
22
+ /** A dead link ends the session after 3 missed keepalives, 10 s apart. */
23
+ export const SSH_KEEPALIVE_INTERVAL_MS = 10_000;
24
+ export const SSH_KEEPALIVE_COUNT_MAX = 3;
25
+ /** `sshExec` keeps the last 64 KiB of each of stdout and stderr. */
26
+ const SSH_OUTPUT_MAX_CHARS = 64 * 1024;
27
+ // The timeout messages name the command or the file path and nothing else.
28
+ // Every caller passes a fixed command or a fixed path (`calls-sfu.ts`,
29
+ // `calls-sfu-ssh-adapter.ts`); the cloudflared token and the LiveKit keys
30
+ // only ever travel as file contents, which are never named here.
31
+ function sshExec(conn, command, timeoutMs = SSH_COMMAND_TIMEOUT_MS) {
17
32
  return new Promise((resolve, reject) => {
18
- conn.exec(command, (err, stream) => {
19
- if (err)
33
+ let stream;
34
+ let timedOut = false;
35
+ const timer = setTimeout(() => {
36
+ timedOut = true;
37
+ stream?.close();
38
+ reject(new Error(`ssh command timed out after ${timeoutMs / 1000}s: ${command}`));
39
+ }, timeoutMs);
40
+ conn.exec(command, (err, execStream) => {
41
+ if (timedOut)
42
+ return execStream?.close();
43
+ if (err) {
44
+ clearTimeout(timer);
20
45
  return reject(err);
46
+ }
47
+ stream = execStream;
21
48
  let stdout = '';
22
49
  let stderr = '';
23
- stream.on('close', (code) => resolve({ exitCode: code ?? 0, stdout, stderr }));
50
+ stream.on('close', (code) => {
51
+ clearTimeout(timer);
52
+ resolve({ exitCode: code ?? 0, stdout, stderr });
53
+ });
54
+ stream.on('error', (streamErr) => {
55
+ clearTimeout(timer);
56
+ reject(streamErr);
57
+ });
24
58
  stream.on('data', (chunk) => {
25
- stdout += chunk;
59
+ stdout = (stdout + chunk).slice(-SSH_OUTPUT_MAX_CHARS);
26
60
  });
27
61
  stream.stderr.on('data', (chunk) => {
28
- stderr += chunk;
62
+ stderr = (stderr + chunk).slice(-SSH_OUTPUT_MAX_CHARS);
29
63
  });
30
64
  });
31
65
  });
32
66
  }
33
- function sshWriteFile(conn, path, contents, mode) {
67
+ function sshWriteFile(conn, path, contents, mode, timeoutMs = SSH_WRITE_TIMEOUT_MS) {
34
68
  return new Promise((resolve, reject) => {
69
+ let session;
70
+ let timedOut = false;
71
+ const timer = setTimeout(() => {
72
+ timedOut = true;
73
+ session?.end();
74
+ reject(new Error(`sftp write timed out after ${timeoutMs / 1000}s: ${path}`));
75
+ }, timeoutMs);
76
+ const settle = (err) => {
77
+ clearTimeout(timer);
78
+ if (err)
79
+ reject(err);
80
+ else
81
+ resolve();
82
+ };
35
83
  conn.sftp((err, sftp) => {
84
+ if (timedOut)
85
+ return sftp?.end();
36
86
  if (err)
37
- return reject(err);
87
+ return settle(err);
88
+ session = sftp;
38
89
  sftp.writeFile(path, contents, mode ? { mode } : undefined, (writeErr) => {
39
90
  if (writeErr)
40
- return reject(writeErr);
91
+ return settle(writeErr);
41
92
  // `mode` on writeFile only applies when the file is created; chmod
42
93
  // repairs a file that already existed with looser permissions.
43
94
  if (!mode)
44
- return resolve();
45
- sftp.chmod(path, mode, (chmodErr) => (chmodErr ? reject(chmodErr) : resolve()));
95
+ return settle();
96
+ sftp.chmod(path, mode, settle);
46
97
  });
47
98
  });
48
99
  });
@@ -64,6 +115,9 @@ export async function connectCallsSfuSsh(options, deps = {}) {
64
115
  port: options.sshPort,
65
116
  username: options.sshUser,
66
117
  privateKey: options.sshPrivateKey,
118
+ readyTimeout: SSH_READY_TIMEOUT_MS,
119
+ keepaliveInterval: SSH_KEEPALIVE_INTERVAL_MS,
120
+ keepaliveCountMax: SSH_KEEPALIVE_COUNT_MAX,
67
121
  // One argument: ssh2 treats the return value as the verdict.
68
122
  hostVerifier: (key) => {
69
123
  hostKey = key;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EntryGate, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, } from './org.js';
2
- export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, appEntry, ownerMailboxes, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateSite, } from './org.js';
1
+ export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EntryGate, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceSchema, } from './org.js';
2
+ export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, appEntry, ownerMailboxes, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateSite, } from './org.js';
3
3
  export type { DeclaredPlatform, PlatformService, PlatformServiceKey, PlatformSfuService, } from './platform.js';
4
4
  export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
5
5
  import { type PinnedVersions } from './bootstrap/versions.js';
@@ -18,7 +18,7 @@ export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, cre
18
18
  export { createPostgresAuditLog } from './store/audit.js';
19
19
  export type { FoundryStoreDb } from './store/postgres.js';
20
20
  export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type AppliedVersionRow, type NewAppliedVersionRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
21
- export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, StepSystem, PlanDetail, PlatformPlanDetail, AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan/index.js';
21
+ export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, StepSystem, PlanDetail, OrgPostgresPlan, PlatformPlanDetail, AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan/index.js';
22
22
  export { AdoptRefusedError } from './plan/index.js';
23
23
  export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
24
24
  export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, DnsPort, EdgePlatformAccess, EdgePlatformDns, EdgePlatformRateLimits, EdgePlatformWorkers, EdgePlatformZones, EdgeZonesOptions, ZonePort, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, EdgeWorkersOptions, EdgePlatformTunnels, TunnelPort, } from './plan/index.js';
@@ -33,14 +33,14 @@ export { HttpJobsSchedulerClient } from './plan/index.js';
33
33
  export type { HealthTarget, HealthTargetDepth, HealthTargetExpect } from './plan/index.js';
34
34
  export { healthTargets, platformHealthTargets } from './plan/index.js';
35
35
  export type { ScopeResolver, RecordedIds, DnsRecordOwners } from './plan/index.js';
36
- export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
36
+ export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeLoginHostResolver, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
37
37
  export type { ZitadelListAppsRawApp } from './plan/index.js';
38
38
  export { adaptZitadelListApps } from './plan/index.js';
39
39
  export type { MailIdentityApp, MailIdentityAudienceCheck } from './plan/index.js';
40
40
  export { checkMailIdentityAudience } from './plan/index.js';
41
41
  export type { MailAllowlistApp } from './plan/index.js';
42
42
  export { checkMailAppsAllowlist } from './plan/index.js';
43
- export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelProjectRoles, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
43
+ export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelLoginPolicy, createZitadelProjectRoles, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
44
44
  export type { PlatformAuthDeps, PlatformAuthSmtp, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformUptimeAccessFactory, PlatformUptimeAccessPort, PlatformUptimeDeps, PlatformWorkersFactory, PlatformWorkersPort, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './plan/index.js';
45
45
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './plan/index.js';
46
46
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './plan/index.js';
@@ -60,6 +60,8 @@ export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCop
60
60
  export { migrationsDir as MIGRATIONS_DIR } from './migrations-dir.js';
61
61
  export type { BootstrapCompany, BootstrapExecutorDeps, BootstrapOwner, BootstrapPlatformDeps, BootstrapPlatformInput, BootstrapProgress, BootstrapResult, RootCredentials, } from './bootstrap/index.js';
62
62
  export { bootstrapPlatform } from './bootstrap/index.js';
63
+ export type { OrgAppVersionRef, OrgAppVersionStatus, WtfalchPackageDrift, PlatformVersionStatus, ReadPlatformVersionStatusOptions, StatusCoolifyPort, StatusGithubPort, StatusGhExec, } from './plan/index.js';
64
+ export { readPlatformVersionStatus, wtfalchVersionsFromPnpmLockfile, providersStatusCoolifyPort, ghCliStatusGithubPort, } from './plan/index.js';
63
65
  export type { RunnerDb } from './runner/db.js';
64
66
  export { AlreadyRunningError, RunNotFoundError, StepFailedError, StepMismatchError, } from './runner/errors.js';
65
67
  export { createJobRunner } from './runner/runner.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, appEntry, ownerMailboxes, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateSite, } from './org.js';
1
+ export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, appEntry, ownerMailboxes, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateSite, } from './org.js';
2
2
  export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
3
3
  // archon#273: the pinned platform service versions the planner deploys, so a
4
4
  // host can show declared against running. Readonly: it is the planner's own
@@ -19,11 +19,11 @@ export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
19
19
  export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
20
20
  export { HttpJobsSchedulerClient } from './plan/index.js';
21
21
  export { healthTargets, platformHealthTargets } from './plan/index.js';
22
- export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
22
+ export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeLoginHostResolver, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
23
23
  export { adaptZitadelListApps } from './plan/index.js';
24
24
  export { checkMailIdentityAudience } from './plan/index.js';
25
25
  export { checkMailAppsAllowlist } from './plan/index.js';
26
- export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelProjectRoles, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
26
+ export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelLoginPolicy, createZitadelProjectRoles, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
27
27
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './plan/index.js';
28
28
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.js';
29
29
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './plan/index.js';
@@ -36,5 +36,6 @@ export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCop
36
36
  // imports nothing else.
37
37
  export { migrationsDir as MIGRATIONS_DIR } from './migrations-dir.js';
38
38
  export { bootstrapPlatform } from './bootstrap/index.js';
39
+ export { readPlatformVersionStatus, wtfalchVersionsFromPnpmLockfile, providersStatusCoolifyPort, ghCliStatusGithubPort, } from './plan/index.js';
39
40
  export { AlreadyRunningError, RunNotFoundError, StepFailedError, StepMismatchError, } from './runner/errors.js';
40
41
  export { createJobRunner } from './runner/runner.js';
package/dist/org.d.ts CHANGED
@@ -843,3 +843,91 @@ export interface DeclaredOrg {
843
843
  */
844
844
  test: boolean;
845
845
  }
846
+ /**
847
+ * The one Postgres server an org's own service schemas and roles live on
848
+ * (#444) — the settled replacement for today's one-container-per-service
849
+ * model: `plan/executor.ts`'s `coolifyDatabase` mints Archon's/lokessmie's
850
+ * own database, `ensureCmsDatabase` mints CMS's, and `ensureCallsDatabase`
851
+ * mints Boule's and chat's and calls' own Synapse, matrix-authentication-
852
+ * service and service databases, each as its own Coolify Postgres resource
853
+ * today. Not a field on {@link DeclaredOrg}: an operator never picks a
854
+ * server, the same way nobody picks an invited owner's ZITADEL user id
855
+ * (`plan/diff.ts`'s `planOwners`, idempotent on a *recorded* resource id
856
+ * rather than a declared field) — there is exactly one per org, and this is
857
+ * the single flat key it is recorded under once `plan/executor.ts`
858
+ * provisions it (444b). 444a (this slice) only ever reads this key through
859
+ * `recordedIds`; nothing here writes it.
860
+ */
861
+ export declare const ORG_POSTGRES_SERVER_SCOPE_KEY = "org_postgres_server";
862
+ /**
863
+ * One service's own slice of the org's shared Postgres server (#444): a
864
+ * dedicated schema, an owner role that runs its migrations and owns every
865
+ * object inside it, and a restricted runtime role the service's own app
866
+ * connects as day to day and cannot create or drop anything with. The `_rt`
867
+ * suffix on {@link OrgServiceSchema.runtimeRole} is the one this estate
868
+ * already uses for exactly this role, everywhere a service splits an owner
869
+ * connection from a runtime one — every `package-template`-descended repo's
870
+ * own `CLAUDE.md` names it "the runtime role `<database>_rt`" — just scoped
871
+ * to a schema instead of a whole database, now that several schemas share
872
+ * one server. `scopeKey` is where `plan/diff.ts` and, later, 444b's
873
+ * `plan/executor.ts` record the schema's existence, the same derived-lookup
874
+ * shape {@link OwnerMailbox.scopeKey} already uses: nothing here is itself
875
+ * stored, {@link ORG_SERVICE_SCHEMAS} is rebuilt fresh every time it is read.
876
+ *
877
+ * Every field below is the **target** name 444b provisions under, not what
878
+ * is live today: today there is no schema at all (each service gets its own
879
+ * whole database), its "owner" is really whatever superuser Coolify's
880
+ * database-creation call happened to name, and a database's role is scoped
881
+ * to that whole database, never to a schema within it. The live runtime
882
+ * role names already in production do not all match {@link runtimeRole}'s
883
+ * `<schema>_rt` shape either — CMS's does (`cms_rt`), but chat's own service
884
+ * connects as `chat_rt`, Boule as `<slug>_boule_db_rt`, and calls' own
885
+ * service as `<dbName>_rt`. 444b must not rename a role a password was
886
+ * already minted for; it maps each of these live names onto the matching
887
+ * target below rather than provisioning a second, differently-named role
888
+ * beside it.
889
+ */
890
+ export interface OrgServiceSchema {
891
+ /** The service this schema belongs to, for a plan step's or an error's own wording — never part of an identifier. */
892
+ service: string;
893
+ /** The schema's target name on the org's one Postgres server. Unique by construction: {@link ORG_SERVICE_SCHEMAS} names each service's schema exactly once. */
894
+ schema: string;
895
+ /** The target role that owns {@link schema} and runs its migrations — this estate's `DATABASE_URL_OWNER` connection, scoped to one schema rather than a whole database. No live role matches this name yet (see this interface's own doc comment). */
896
+ ownerRole: string;
897
+ /** The target restricted role the service's own app connects as day to day — this estate's `DATABASE_URL` connection. Several services' live runtime roles already carry this exact name; others 444b must map onto it (see this interface's own doc comment). */
898
+ runtimeRole: string;
899
+ /** Where this schema's existence is recorded, once provisioned. */
900
+ scopeKey: string;
901
+ }
902
+ /**
903
+ * The full, fixed schema topology every org's shared Postgres server
904
+ * settles into (#444's acceptance: "the documented service schema/role
905
+ * descriptors") — not a per-org choice, so unlike {@link ownerMailboxes}
906
+ * this takes no `DeclaredOrg` at all. One entry per Postgres database
907
+ * `plan/executor.ts` mints today as its own container, confirmed by reading
908
+ * it rather than assumed from the issue or any triage comment: Boule's own
909
+ * (`coolifyBoule`'s `ensureCallsDatabase` call) and CMS's own
910
+ * (`ensureCmsDatabase`) are each one; chat's and calls' own Matrix stacks
911
+ * are each three — `coolifyChat`/`coolifyCalls` each mint a Synapse and a
912
+ * matrix-authentication-service database, and `coolifyChat`/
913
+ * `coolifyCallsService` each mint a third for the service app beside them
914
+ * — eight; plus the org's own app database, `coolifyDatabase`'s `<slug>-db`,
915
+ * for the one hosting entry that declares `database: true` (Archon's or
916
+ * lokessmie's own data) — nine in total.
917
+ *
918
+ * Boule's own entry is provisioned unconditionally — `validateHosting`
919
+ * already refuses an `issuerApps` entry named `boule` because "every org
920
+ * gets" one — so it, and this constant as a whole, name the *full* topology
921
+ * regardless of which feature switches are on, the same way `ORG_ENTRY_KEY`
922
+ * names `app` whether or not an org has one. Which of these schemas
923
+ * `plan/diff.ts`'s `planOrgPostgres` actually reports as missing on a given
924
+ * org is a narrower question than what this list contains — CMS's,
925
+ * chat's and calls' entries sit alongside their own feature's on/off switch
926
+ * ({@link SiteConfig.enabled}, {@link ChatConfig.enabled},
927
+ * {@link CallsConfig.enabled}) and the app entry beside whether any hosting
928
+ * entry declares `database: true`; `planOrgPostgres`'s own doc comment says
929
+ * which. A schema nothing needs yet simply is not reported missing, the
930
+ * same way an org with no app entry (foundry#447) has no app schema to
931
+ * create.
932
+ */
933
+ export declare const ORG_SERVICE_SCHEMAS: readonly OrgServiceSchema[];
package/dist/org.js CHANGED
@@ -930,3 +930,71 @@ export function validateMail(org, opts) {
930
930
  }
931
931
  return null;
932
932
  }
933
+ /**
934
+ * The one Postgres server an org's own service schemas and roles live on
935
+ * (#444) — the settled replacement for today's one-container-per-service
936
+ * model: `plan/executor.ts`'s `coolifyDatabase` mints Archon's/lokessmie's
937
+ * own database, `ensureCmsDatabase` mints CMS's, and `ensureCallsDatabase`
938
+ * mints Boule's and chat's and calls' own Synapse, matrix-authentication-
939
+ * service and service databases, each as its own Coolify Postgres resource
940
+ * today. Not a field on {@link DeclaredOrg}: an operator never picks a
941
+ * server, the same way nobody picks an invited owner's ZITADEL user id
942
+ * (`plan/diff.ts`'s `planOwners`, idempotent on a *recorded* resource id
943
+ * rather than a declared field) — there is exactly one per org, and this is
944
+ * the single flat key it is recorded under once `plan/executor.ts`
945
+ * provisions it (444b). 444a (this slice) only ever reads this key through
946
+ * `recordedIds`; nothing here writes it.
947
+ */
948
+ export const ORG_POSTGRES_SERVER_SCOPE_KEY = 'org_postgres_server';
949
+ /** Builds one {@link OrgServiceSchema}: its owner role, runtime role and scope key all follow from {@link schema} alone, so every caller gets the same shape. */
950
+ function orgServiceSchema(service, schema) {
951
+ return {
952
+ service,
953
+ schema,
954
+ ownerRole: `${schema}_owner`,
955
+ runtimeRole: `${schema}_rt`,
956
+ scopeKey: `org_postgres_schema.${schema}`,
957
+ };
958
+ }
959
+ /**
960
+ * The full, fixed schema topology every org's shared Postgres server
961
+ * settles into (#444's acceptance: "the documented service schema/role
962
+ * descriptors") — not a per-org choice, so unlike {@link ownerMailboxes}
963
+ * this takes no `DeclaredOrg` at all. One entry per Postgres database
964
+ * `plan/executor.ts` mints today as its own container, confirmed by reading
965
+ * it rather than assumed from the issue or any triage comment: Boule's own
966
+ * (`coolifyBoule`'s `ensureCallsDatabase` call) and CMS's own
967
+ * (`ensureCmsDatabase`) are each one; chat's and calls' own Matrix stacks
968
+ * are each three — `coolifyChat`/`coolifyCalls` each mint a Synapse and a
969
+ * matrix-authentication-service database, and `coolifyChat`/
970
+ * `coolifyCallsService` each mint a third for the service app beside them
971
+ * — eight; plus the org's own app database, `coolifyDatabase`'s `<slug>-db`,
972
+ * for the one hosting entry that declares `database: true` (Archon's or
973
+ * lokessmie's own data) — nine in total.
974
+ *
975
+ * Boule's own entry is provisioned unconditionally — `validateHosting`
976
+ * already refuses an `issuerApps` entry named `boule` because "every org
977
+ * gets" one — so it, and this constant as a whole, name the *full* topology
978
+ * regardless of which feature switches are on, the same way `ORG_ENTRY_KEY`
979
+ * names `app` whether or not an org has one. Which of these schemas
980
+ * `plan/diff.ts`'s `planOrgPostgres` actually reports as missing on a given
981
+ * org is a narrower question than what this list contains — CMS's,
982
+ * chat's and calls' entries sit alongside their own feature's on/off switch
983
+ * ({@link SiteConfig.enabled}, {@link ChatConfig.enabled},
984
+ * {@link CallsConfig.enabled}) and the app entry beside whether any hosting
985
+ * entry declares `database: true`; `planOrgPostgres`'s own doc comment says
986
+ * which. A schema nothing needs yet simply is not reported missing, the
987
+ * same way an org with no app entry (foundry#447) has no app schema to
988
+ * create.
989
+ */
990
+ export const ORG_SERVICE_SCHEMAS = [
991
+ orgServiceSchema('Boule', 'boule'),
992
+ orgServiceSchema('CMS', 'cms'),
993
+ orgServiceSchema("chat's Synapse homeserver", 'chat_synapse'),
994
+ orgServiceSchema("chat's matrix-authentication-service", 'chat_mas'),
995
+ orgServiceSchema("chat's own service", 'chat_service'),
996
+ orgServiceSchema("calls' Synapse homeserver", 'calls_synapse'),
997
+ orgServiceSchema("calls' matrix-authentication-service", 'calls_mas'),
998
+ orgServiceSchema("calls' own service", 'calls_service'),
999
+ orgServiceSchema("the org's own app", 'app'),
1000
+ ];
@@ -167,6 +167,19 @@ export declare const CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY = "zitadel_calls_
167
167
  */
168
168
  export declare const CHAT_INTROSPECTION_SCOPE_KEY = "zitadel_chat_introspection_app";
169
169
  export declare const CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY = "zitadel_chat_introspection_undelivered";
170
+ /**
171
+ * The one internal token per org that lets the calls service ask chat who is
172
+ * in a room: `CHAT_INTERNAL_TOKEN` on chat-service, `CALLS_CHAT_TOKEN` on the
173
+ * calls service (platform rule 1). The first holds the token's id once both
174
+ * apps carry it; the second is recorded and persisted before either does.
175
+ * The third is `<id> <chat address>`, recorded once both apps run with it. No
176
+ * `:` in any.
177
+ */
178
+ export declare const CHAT_CALLS_TOKEN_SCOPE_KEY = "coolify_chat_calls_token";
179
+ export declare const CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY = "coolify_chat_calls_token_undelivered";
180
+ export declare const CHAT_CALLS_APPLIED_SCOPE_KEY = "coolify_chat_calls_applied";
181
+ /** The value {@link CHAT_CALLS_APPLIED_SCOPE_KEY} holds once the token and address are live on both apps. */
182
+ export declare function chatCallsApplied(tokenId: string, chatUrl: string): string;
170
183
  /** The chat service's introspection API app name; one per org, exact match only. */
171
184
  export declare function chatIntrospectionAppName(org: DeclaredOrg): string;
172
185
  /** What the plan hands the calls service step: the two host values the token env needs. */
@@ -179,6 +179,21 @@ export const CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY = 'zitadel_calls_introspe
179
179
  */
180
180
  export const CHAT_INTROSPECTION_SCOPE_KEY = 'zitadel_chat_introspection_app';
181
181
  export const CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY = 'zitadel_chat_introspection_undelivered';
182
+ /**
183
+ * The one internal token per org that lets the calls service ask chat who is
184
+ * in a room: `CHAT_INTERNAL_TOKEN` on chat-service, `CALLS_CHAT_TOKEN` on the
185
+ * calls service (platform rule 1). The first holds the token's id once both
186
+ * apps carry it; the second is recorded and persisted before either does.
187
+ * The third is `<id> <chat address>`, recorded once both apps run with it. No
188
+ * `:` in any.
189
+ */
190
+ export const CHAT_CALLS_TOKEN_SCOPE_KEY = 'coolify_chat_calls_token';
191
+ export const CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY = 'coolify_chat_calls_token_undelivered';
192
+ export const CHAT_CALLS_APPLIED_SCOPE_KEY = 'coolify_chat_calls_applied';
193
+ /** The value {@link CHAT_CALLS_APPLIED_SCOPE_KEY} holds once the token and address are live on both apps. */
194
+ export function chatCallsApplied(tokenId, chatUrl) {
195
+ return `${tokenId} ${chatUrl}`;
196
+ }
182
197
  /** The chat service's introspection API app name; one per org, exact match only. */
183
198
  export function chatIntrospectionAppName(org) {
184
199
  return `chat-introspection-${org.slug}`;
@@ -47,6 +47,20 @@ export declare function chatUpstreams(orgSlug: string): {
47
47
  matrix: string;
48
48
  mas: string;
49
49
  };
50
+ /**
51
+ * Internal names for the org's re-keyed homeserver pair (#463, design note
52
+ * `docs/chat-rekey-design.md` Option 1): a second Synapse and MAS,
53
+ * provisioned beside the original pair under `chatInternalNames` so both
54
+ * ends of the copy are live at once. The chat service and its own database
55
+ * are never re-created — same app, repointed once the copy is done and the
56
+ * original pair is parked.
57
+ */
58
+ export declare function chatRekeyedInternalNames(orgSlug: string): {
59
+ synapse: string;
60
+ mas: string;
61
+ synapseDb: string;
62
+ masDb: string;
63
+ };
50
64
  /**
51
65
  * Never resolved by DNS and never federates; the identifier Synapse, MAS and
52
66
  * the chat service agree on. A level-1 org's chat lives on this name until a
@@ -54,13 +68,26 @@ export declare function chatUpstreams(orgSlug: string): {
54
68
  * not collide with a hostname some org owns.
55
69
  */
56
70
  export declare function chatServerName(orgSlug: string): string;
57
- /** The Matrix user the chat service acts as (`chat/apps/chat-service/src/workspace.ts`). */
58
- export declare function chatAdminUser(orgSlug: string): string;
71
+ /**
72
+ * The Matrix user the chat service acts as
73
+ * (`chat/apps/chat-service/src/workspace.ts`). `serverName` defaults to the
74
+ * org's level-1 `chatServerName`; a re-key (#463) passes the new one so the
75
+ * gate module's `admin_user` matches the homeserver it is loaded into.
76
+ */
77
+ export declare function chatAdminUser(orgSlug: string, serverName?: string): string;
78
+ /** A re-key's `server_name` is a lowercase DNS name with at least two labels; anything else would be written into both configs. */
79
+ export declare function assertChatServerName(serverName: string): void;
59
80
  export interface ChatSynapseHomeserverInput {
60
81
  orgSlug: string;
61
82
  db: CallsDbConnection;
62
83
  masHost: string;
63
84
  masSharedSecret: string;
85
+ /**
86
+ * #463: the `server_name` this homeserver answers to. Defaults to the
87
+ * org's level-1 `chatServerName` so every existing caller is unaffected;
88
+ * a re-key's new Synapse passes the org's own domain instead.
89
+ */
90
+ serverName?: string;
64
91
  }
65
92
  /** `homeserver.yaml`: federation off, presence on (stable Synapse features only -- no MSC3026 `busy`, foundry#41), MAS for auth, the chat gate module on. */
66
93
  export declare function chatSynapseHomeserverYaml(input: ChatSynapseHomeserverInput): string;
@@ -76,6 +103,8 @@ export interface ChatMasConfigInput {
76
103
  encryptionKey: string;
77
104
  rsaKeyPem: string;
78
105
  ecKeyPem: string;
106
+ /** #463: see {@link ChatSynapseHomeserverInput.serverName} -- the two must agree. */
107
+ serverName?: string;
79
108
  }
80
109
  /** MAS `config.yaml`: password login and registration off; the chat service is the one client, with admin. */
81
110
  export declare function chatMasConfigYaml(input: ChatMasConfigInput): string;
@@ -29,6 +29,22 @@ export function chatUpstreams(orgSlug) {
29
29
  mas: `http://${names.mas}:${CHAT_MAS_PORT}`,
30
30
  };
31
31
  }
32
+ /**
33
+ * Internal names for the org's re-keyed homeserver pair (#463, design note
34
+ * `docs/chat-rekey-design.md` Option 1): a second Synapse and MAS,
35
+ * provisioned beside the original pair under `chatInternalNames` so both
36
+ * ends of the copy are live at once. The chat service and its own database
37
+ * are never re-created — same app, repointed once the copy is done and the
38
+ * original pair is parked.
39
+ */
40
+ export function chatRekeyedInternalNames(orgSlug) {
41
+ return {
42
+ synapse: `${orgSlug}-chat-synapse-2`,
43
+ mas: `${orgSlug}-chat-mas-2`,
44
+ synapseDb: `${orgSlug}-chat-synapse-2-db`,
45
+ masDb: `${orgSlug}-chat-mas-2-db`,
46
+ };
47
+ }
32
48
  /**
33
49
  * Never resolved by DNS and never federates; the identifier Synapse, MAS and
34
50
  * the chat service agree on. A level-1 org's chat lives on this name until a
@@ -38,18 +54,33 @@ export function chatUpstreams(orgSlug) {
38
54
  export function chatServerName(orgSlug) {
39
55
  return `chat-${orgSlug}.internal`;
40
56
  }
41
- /** The Matrix user the chat service acts as (`chat/apps/chat-service/src/workspace.ts`). */
42
- export function chatAdminUser(orgSlug) {
43
- return `@chat-admin:${chatServerName(orgSlug)}`;
57
+ /**
58
+ * The Matrix user the chat service acts as
59
+ * (`chat/apps/chat-service/src/workspace.ts`). `serverName` defaults to the
60
+ * org's level-1 `chatServerName`; a re-key (#463) passes the new one so the
61
+ * gate module's `admin_user` matches the homeserver it is loaded into.
62
+ */
63
+ export function chatAdminUser(orgSlug, serverName = chatServerName(orgSlug)) {
64
+ return `@chat-admin:${serverName}`;
44
65
  }
45
66
  const q = (value) => JSON.stringify(value);
67
+ const SERVER_NAME_PATTERN = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
68
+ /** A re-key's `server_name` is a lowercase DNS name with at least two labels; anything else would be written into both configs. */
69
+ export function assertChatServerName(serverName) {
70
+ if (serverName.length > 253 || !SERVER_NAME_PATTERN.test(serverName)) {
71
+ throw new Error('chat server_name must be a lowercase domain name');
72
+ }
73
+ }
46
74
  /** `homeserver.yaml`: federation off, presence on (stable Synapse features only -- no MSC3026 `busy`, foundry#41), MAS for auth, the chat gate module on. */
47
75
  export function chatSynapseHomeserverYaml(input) {
76
+ const serverName = input.serverName ?? chatServerName(input.orgSlug);
77
+ if (input.serverName !== undefined)
78
+ assertChatServerName(serverName);
48
79
  return [
49
80
  '# Generated by @wtfalch/foundry (executor.ts, coolify:chat). Do not edit',
50
81
  '# by hand; re-run apply instead -- it will not rotate secrets that exist.',
51
82
  '',
52
- `server_name: ${q(chatServerName(input.orgSlug))}`,
83
+ `server_name: ${q(serverName)}`,
53
84
  'pid_file: /data/homeserver.pid',
54
85
  'report_stats: false',
55
86
  '',
@@ -106,13 +137,16 @@ export function chatSynapseHomeserverYaml(input) {
106
137
  'modules:',
107
138
  ' - module: wtfalch_chat_gate.ChatGate',
108
139
  ' config:',
109
- ` admin_user: ${q(chatAdminUser(input.orgSlug))}`,
140
+ ` admin_user: ${q(chatAdminUser(input.orgSlug, serverName))}`,
110
141
  ' allow_encrypted_channels: false',
111
142
  '',
112
143
  ].join('\n');
113
144
  }
114
145
  /** MAS `config.yaml`: password login and registration off; the chat service is the one client, with admin. */
115
146
  export function chatMasConfigYaml(input) {
147
+ const serverName = input.serverName ?? chatServerName(input.orgSlug);
148
+ if (input.serverName !== undefined)
149
+ assertChatServerName(serverName);
116
150
  const indentKey = (pem, spaces) => pem
117
151
  .trimEnd()
118
152
  .split('\n')
@@ -150,7 +184,7 @@ export function chatMasConfigYaml(input) {
150
184
  '',
151
185
  'matrix:',
152
186
  ' kind: synapse',
153
- ` homeserver: ${q(chatServerName(input.orgSlug))}`,
187
+ ` homeserver: ${q(serverName)}`,
154
188
  ` endpoint: ${q(`http://${input.synapseHost}:${CHAT_SYNAPSE_PORT}`)}`,
155
189
  ` secret: ${q(input.sharedSecret)}`,
156
190
  '',
@@ -1,4 +1,4 @@
1
- import type { DeclaredOrg, OrgId } from '../org.js';
1
+ import type { DeclaredOrg, OrgId, OrgServiceSchema } from '../org.js';
2
2
  import type { Plan, PlanStep, UnmanagedItem, VersionChangeItem } from '../plan.js';
3
3
  import { type AgoraHostReading } from './agora.js';
4
4
  import type { LiveResource } from './live.js';
@@ -64,6 +64,8 @@ export interface PlanDetail {
64
64
  unmanaged: UnmanagedItem[];
65
65
  /** #251: see `VersionChangeItem` (`../plan.ts`). Never folded into `plannedSteps`. */
66
66
  versionChanges: VersionChangeItem[];
67
+ /** #444a: see {@link OrgPostgresPlan}. Never folded into `plannedSteps`, for the same reason `versionChanges` above is not. */
68
+ orgPostgres: OrgPostgresPlan;
67
69
  }
68
70
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
69
71
  export declare function toPlan(detail: PlanDetail): Plan;
@@ -110,7 +112,12 @@ export declare function toPlan(detail: PlanDetail): Plan;
110
112
  * app-creating step below: {@link planLoginHost}, the org's own company
111
113
  * login host -- also idempotent on the *recorded* resource id, since
112
114
  * `live` has no notion of a ZITADEL trusted domain or a login binding
113
- * either. Everything
115
+ * either. #444a adds one more fact, in a different shape from every other
116
+ * addition here: {@link planOrgPostgres}, the org's one shared Postgres
117
+ * server plus whichever of `org.ts`'s `ORG_SERVICE_SCHEMAS` (nine today)
118
+ * this org actually runs and has not yet recorded -- idempotent on the
119
+ * *recorded* resource id like {@link planOwners}, but never folded into
120
+ * `plannedSteps`: its own {@link OrgPostgresPlan} field says why. Everything
114
121
  * else in `live` that none of these eighteen singletons and however many
115
122
  * declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
116
123
  * guests, calls on the org's own domain, adopted/bespoke apps, the actual
@@ -128,6 +135,42 @@ export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[]
128
135
  * its login host equal to the shared ZITADEL instance's own.
129
136
  */
130
137
  export declare function isPlatformOwnOrg(org: DeclaredOrg, platformApex: string | null): boolean;
138
+ /**
139
+ * What {@link planOrgPostgres} reports about the org's one shared Postgres
140
+ * server and its fixed schema topology (#444a, "manifest-and-plan-only"):
141
+ * missing only, nothing applied. Deliberately **not** folded into
142
+ * `plannedSteps` -- the same reason {@link VersionChangeItem} (`../plan.ts`)
143
+ * never is, restated in `computePlanDetail`'s own doc comment below, but
144
+ * sharper here: `plan-apply.ts`'s `runSteps` executes every
145
+ * `detail.plannedSteps` entry through `StepExecutor.execute`, which
146
+ * switches on `` `${system}:${kind}` `` and throws on anything it has no
147
+ * case for. `plan/executor.ts` is untouched this slice (444a's own
148
+ * acceptance line), so a `'coolify:postgres-server'`/`'coolify:postgres-
149
+ * schema'` entry in `plannedSteps` would not sit there harmlessly the way
150
+ * an unapplied field does -- the next real `apply()` would reach it and
151
+ * throw `FoundryStepExecutor has no dispatch for coolify:postgres-server`,
152
+ * for every org, immediately. This field is how 444a reports the same
153
+ * "missing" fact to a caller of `computePlanDetail` directly without that
154
+ * happening; wiring it into the public `Plan` (`toPlan` below) and into
155
+ * real `plannedSteps` entries is 444b's job, once `plan/executor.ts` has a
156
+ * dispatch for them.
157
+ */
158
+ export interface OrgPostgresPlan {
159
+ /** True until the org's one shared Postgres server is recorded under {@link ORG_POSTGRES_SERVER_SCOPE_KEY}. */
160
+ serverMissing: boolean;
161
+ /**
162
+ * Every `org.ts` {@link ORG_SERVICE_SCHEMAS} entry this org actually runs
163
+ * and has not yet recorded under its own `scopeKey`, in that constant's
164
+ * own order -- see {@link orgServiceSchemaEnabled} for which entries that
165
+ * is. Boule's is always included, the same way `ORG_SERVICE_SCHEMAS`
166
+ * itself always carries it; CMS's, chat's and calls' sit behind their own
167
+ * feature switch, and the app schema behind whether any hosting entry
168
+ * declares a database. A schema this org does not run yet is never
169
+ * reported missing, even though {@link ORG_SERVICE_SCHEMAS} still names
170
+ * it as part of the full topology.
171
+ */
172
+ missingSchemas: readonly OrgServiceSchema[];
173
+ }
131
174
  /** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
132
175
  export declare const AI_KEY_UNDELIVERED_SCOPE_KEY = "ai_key_undelivered";
133
176
  /** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */