@wtfalch/foundry 0.3.0 → 0.4.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.
@@ -118,6 +118,23 @@ export declare const LIVEKIT_MEDIA_UDP_RANGE = "50000:60000";
118
118
  * reachable from the public internet at all -- #127's "Decided" section.
119
119
  */
120
120
  export declare const CALLS_SFU_PRIVATE_CIDR = "10.10.10.0/24";
121
+ /**
122
+ * The SFU's own address on {@link CALLS_SFU_PRIVATE_CIDR}'s private network
123
+ * -- decided as a fixed value (foundry#145's "Decided", 2026-09-27) rather
124
+ * than threaded through as config: the estate has exactly one calls SFU (VM
125
+ * 105, this file's own top doc comment), and `.105` is that VM's own number.
126
+ * The calls service (foundry#145, part 2 of #128) reaches LiveKit's API here,
127
+ * over `vmbr1`, never through the tunnel.
128
+ */
129
+ export declare const CALLS_SFU_PRIVATE_URL = "http://10.10.10.105:7880";
130
+ /**
131
+ * The SFU's public `wss://`-capable address, reached through its own named
132
+ * tunnel (#127, `scripts/calls-sfu-ingress.mjs`) -- also decided as a fixed
133
+ * value (foundry#145's "Decided", 2026-09-27): `../slug.ts` reserves the
134
+ * `calls-sfu` slug for exactly this reason, so no org can ever take this
135
+ * hostname out from under it.
136
+ */
137
+ export declare const CALLS_SFU_PUBLIC_URL = "https://calls-sfu.oikos.host";
121
138
  /**
122
139
  * Exported for the test file. Reads `ufw status`'s own report rather than
123
140
  * assuming the exit code of a blind `ufw delete allow 7880/tcp` -- real
package/dist/calls-sfu.js CHANGED
@@ -115,6 +115,23 @@ export const LIVEKIT_MEDIA_UDP_RANGE = '50000:60000';
115
115
  * reachable from the public internet at all -- #127's "Decided" section.
116
116
  */
117
117
  export const CALLS_SFU_PRIVATE_CIDR = '10.10.10.0/24';
118
+ /**
119
+ * The SFU's own address on {@link CALLS_SFU_PRIVATE_CIDR}'s private network
120
+ * -- decided as a fixed value (foundry#145's "Decided", 2026-09-27) rather
121
+ * than threaded through as config: the estate has exactly one calls SFU (VM
122
+ * 105, this file's own top doc comment), and `.105` is that VM's own number.
123
+ * The calls service (foundry#145, part 2 of #128) reaches LiveKit's API here,
124
+ * over `vmbr1`, never through the tunnel.
125
+ */
126
+ export const CALLS_SFU_PRIVATE_URL = `http://10.10.10.105:${LIVEKIT_SIGNALLING_PORT}`;
127
+ /**
128
+ * The SFU's public `wss://`-capable address, reached through its own named
129
+ * tunnel (#127, `scripts/calls-sfu-ingress.mjs`) -- also decided as a fixed
130
+ * value (foundry#145's "Decided", 2026-09-27): `../slug.ts` reserves the
131
+ * `calls-sfu` slug for exactly this reason, so no org can ever take this
132
+ * hostname out from under it.
133
+ */
134
+ export const CALLS_SFU_PUBLIC_URL = 'https://calls-sfu.oikos.host';
118
135
  /**
119
136
  * Matches `ufw status`'s report of the exact wide-open rule PR 125 added
120
137
  * (`ufw allow 7880/tcp`) -- "Anywhere" in the From column -- and not the
package/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export type { FoundryStoreDb } from './store/postgres.js';
12
12
  export { declaredOrgs, foundryAppliedState, foundryAuditEvents, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, } from './store/schema.js';
13
13
  export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, StepAction, PlannedStep, PlanDetail, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, FoundryPlanApplyOptions, } from './plan/index.js';
14
14
  export { readLiveResources, computePlanDetail, toPlan, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, } from './plan/index.js';
15
- export type { CoolifyPort, CloudflarePort, ZitadelPort, FilesOrgLimits, AiOrgLimits, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, } from './plan/index.js';
15
+ export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, FilesOrgLimits, AiOrgLimits, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, } from './plan/index.js';
16
16
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './plan/index.js';
17
17
  export type { MigrateSql, MigrationResult } from './migrate.js';
18
18
  export { MIGRATIONS_DIR, migrate } from './migrate.js';
@@ -0,0 +1,7 @@
1
+ -- @wtfalch/foundry: turns on an org's own calls Matrix stack (#128). Mirrors
2
+ -- `src/store/schema.ts`'s `callsEnabled`. Defaults false, same as every other
3
+ -- per-org on/off switch (`site_enabled`, `hosting_enabled`) -- an existing
4
+ -- org has no calls stack until it declares one.
5
+
6
+ alter table foundry_declared_orgs
7
+ add column if not exists calls_enabled boolean not null default false;
@@ -0,0 +1,39 @@
1
+ -- @wtfalch/foundry: the declared org's domain is globally unique, one non-
2
+ -- null value per org, on top of #0001's `foundry_declared_orgs` table (#131).
3
+ -- Mirrors src/store/schema.ts's `domain` column, which has no index of its
4
+ -- own to express -- this file, not the schema, is the index's source of
5
+ -- truth, the same convention `0004_slug_unique.sql`'s header explains.
6
+ --
7
+ -- Partial (`where domain is not null`): a brand new org has no domain yet
8
+ -- (org.ts's own doc comment on `DeclaredOrg.domain`), so a plain unique
9
+ -- index would let only one such org exist at a time -- every other null
10
+ -- would collide with it.
11
+ --
12
+ -- Forced by the same routing collision `0004_slug_unique.sql` exists for: an
13
+ -- org's domain is the Cloudflare zone and Coolify hostname it is served on
14
+ -- once any service opts in (org.ts's `MailConfig`/`ChatConfig`/etc.
15
+ -- `usesDomain`, plan/executor.ts's `hostnameFor`), so two orgs sharing a
16
+ -- domain would fight over both from the moment the second is declared, with
17
+ -- no refusal today. src/store/postgres.ts's `create` and `update` already
18
+ -- call org.ts's `validateDomain` before this index is ever reached; the
19
+ -- index exists to catch the race the pre-check cannot -- two concurrent
20
+ -- writes for the same domain -- which postgres.ts maps back to the same
21
+ -- `DeclaredOrgError` a pre-check would have thrown, per #18's rule that a
22
+ -- caller gets a reason by name either way.
23
+ --
24
+ -- Format validity (DNS-label-per-segment) is org.ts's `validateDomain`'s
25
+ -- job, not a CHECK constraint's -- #0001's header already gives the
26
+ -- reasoning for keeping "combinations foundry knows are impossible" in the
27
+ -- code path that can explain itself, not in SQL that can't. Whether this
28
+ -- index (or `validateDomain`) should normalize case before comparing is
29
+ -- still open (#131 "Decide") -- `validateDomain` requires lowercase input
30
+ -- outright today, so this index never has to compare two differently-cased
31
+ -- values in the first place.
32
+ --
33
+ -- Human step before this ships (#131): audit existing declared orgs for a
34
+ -- pre-existing domain collision -- this index refuses to apply if one
35
+ -- already exists.
36
+
37
+ create unique index if not exists foundry_declared_orgs_domain_idx
38
+ on foundry_declared_orgs (domain)
39
+ where domain is not null;
package/dist/org.d.ts CHANGED
@@ -55,10 +55,22 @@ export interface ChatConfig {
55
55
  * Calls' per-service opt-in into the org's `domain` fact, in the same shape
56
56
  * as mail, chat and the issuer so that none of them are coupled to `domain`
57
57
  * being set (#7 amendment 1, carried into
58
- * https://github.com/wtfalch/foundry/issues/18's "Done when" list). Nothing
59
- * about calls' own provisioning is decided yet beyond this.
58
+ * https://github.com/wtfalch/foundry/issues/18's "Done when" list).
59
+ *
60
+ * `enabled` (#128, #145) turns on the org's own calls Matrix stack — a
61
+ * dedicated Postgres, Synapse and matrix-authentication-service, entirely
62
+ * separate from chat's (a call guest must never gain chat membership,
63
+ * guaranteed structurally by the two never sharing a homeserver) — and the
64
+ * calls service itself (`wtfalch/calls`'s `packages/service`), wired to that
65
+ * stack and to the one shared LiveKit SFU the whole estate uses
66
+ * (foundry#122). `usesDomain` decides the calls service's own public
67
+ * hostname (#145's "Decided", 2026-09-27): `false` (or no `domain`) serves it
68
+ * flat at `<slug>-calls.oikos.host`, riding the shared tunnel with no new
69
+ * mechanism; `true` serves it at `calls.<domain>` on its own per-org tunnel,
70
+ * the same opt-in shape `hosting.usesDomain` and `site.usesDomain` use.
60
71
  */
61
72
  export interface CallsConfig {
73
+ enabled: boolean;
62
74
  usesDomain: boolean;
63
75
  }
64
76
  /**
@@ -165,6 +177,7 @@ export interface HostingConfig {
165
177
  * list, and a `kind` that is one of `IssuerAppKind`'s three values.
166
178
  */
167
179
  export declare function validateHosting(hosting: HostingConfig): string | null;
180
+ export declare function validateDomain(domain: string): string | null;
168
181
  /**
169
182
  * `null` when `site` is valid; otherwise the reason it is not (#101).
170
183
  *
@@ -181,6 +194,9 @@ export declare function validateHosting(hosting: HostingConfig): string | null;
181
194
  * and the issuer, the site has no meaning anywhere but the org's own
182
195
  * domain, so turning it on always means the domain opt-in too.
183
196
  * - `site.enabled` and `hosting.enabled` cannot both be on.
197
+ * - `site.enabled` refuses an `issuerApps` entry named `cms`: the fixed cms
198
+ * client (#139, `plan/diff.ts`'s `planCmsClient`) already owns the
199
+ * `zitadel:app:cms` key, and the two would overwrite each other.
184
200
  */
185
201
  export declare function validateSite(org: DeclaredOrg): string | null;
186
202
  /**
package/dist/org.js CHANGED
@@ -52,6 +52,48 @@ export function validateHosting(hosting) {
52
52
  }
53
53
  return null;
54
54
  }
55
+ /**
56
+ * A DNS-label-per-segment format check for `DeclaredOrg.domain` (#131): every
57
+ * dot-separated segment follows the exact rule `../slug.ts`'s `DNS_LABEL`
58
+ * enforces for a slug (1-63 characters, lowercase `a-z`, `0-9` and hyphen, no
59
+ * leading or trailing hyphen) -- duplicated here rather than imported, the
60
+ * same "restated, not shared" choice `plan/executor.ts`'s `hostnameFor` makes
61
+ * for the hostname rule, since a domain is not a slug: a slug is always
62
+ * exactly one label, a domain is at least two (a bare label has no apex
63
+ * Cloudflare can host a zone for).
64
+ *
65
+ * Forced by the same routing collision `../slug.ts`'s own top comment names
66
+ * for the slug: two orgs on the same domain would be routed to the same
67
+ * Cloudflare zone and Coolify hostname with no refusal
68
+ * (`store/postgres.ts`'s `create`/`update` call this the same place they
69
+ * call `requireValidSlug`; `migrations/0015_domain_unique.sql` adds the
70
+ * matching unique index).
71
+ *
72
+ * Requiring lowercase outright, rather than lowercasing before comparing, is
73
+ * the same choice `../slug.ts`'s `DNS_LABEL` already makes for a slug --
74
+ * whether the unique index should instead normalize case is open (#131
75
+ * "Decide"), but this validator never lets two domains differing only by
76
+ * case both exist to be compared in the first place.
77
+ *
78
+ * `null` when `domain` is valid; otherwise the reason it is not, same shape
79
+ * as {@link validateBuyerIdentity}/{@link validateOwner}.
80
+ */
81
+ const DOMAIN_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
82
+ export function validateDomain(domain) {
83
+ if (domain.length > 253) {
84
+ return `domain '${domain}' exceeds 253 characters`;
85
+ }
86
+ const labels = domain.split('.');
87
+ if (labels.length < 2) {
88
+ return `domain '${domain}' must have at least two dot-separated labels`;
89
+ }
90
+ for (const label of labels) {
91
+ if (!DOMAIN_LABEL.test(label)) {
92
+ return `domain '${domain}' has an invalid label '${label}': must be 1-63 characters, lowercase a-z, 0-9 and hyphen, and may not start or end with a hyphen`;
93
+ }
94
+ }
95
+ return null;
96
+ }
55
97
  /**
56
98
  * `null` when `site` is valid; otherwise the reason it is not (#101).
57
99
  *
@@ -68,6 +110,9 @@ export function validateHosting(hosting) {
68
110
  * and the issuer, the site has no meaning anywhere but the org's own
69
111
  * domain, so turning it on always means the domain opt-in too.
70
112
  * - `site.enabled` and `hosting.enabled` cannot both be on.
113
+ * - `site.enabled` refuses an `issuerApps` entry named `cms`: the fixed cms
114
+ * client (#139, `plan/diff.ts`'s `planCmsClient`) already owns the
115
+ * `zitadel:app:cms` key, and the two would overwrite each other.
71
116
  */
72
117
  export function validateSite(org) {
73
118
  if (org.site.enabled && !org.domain) {
@@ -79,6 +124,9 @@ export function validateSite(org) {
79
124
  if (org.site.enabled && org.hosting.enabled) {
80
125
  return 'site.enabled and hosting.enabled cannot both be on';
81
126
  }
127
+ if (org.site.enabled && org.hosting.issuerApps.some((app) => app.name === 'cms')) {
128
+ return "site.enabled reserves the issuerApps name 'cms'";
129
+ }
82
130
  return null;
83
131
  }
84
132
  const ORG_NUMBER_PATTERN = /^\d{9}$/;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The calls Matrix stack (#128, part 1 of 2 -- see that issue's "If this
3
+ * passes about 800 changed lines, split it" note): pure config generation
4
+ * for the dedicated Synapse and matrix-authentication-service (MAS)
5
+ * `executor.ts`'s `coolifyCalls` deploys per org, adapted from
6
+ * `wtfalch/app-template#344`'s `bootstrap/steps/calls-instance.ts`.
7
+ *
8
+ * TWO COOLIFY DATABASES, NOT ONE SHARED POSTGRES
9
+ * ------------------------------------------------
10
+ * PR 344 ran one bare `postgres` container and created Synapse's and MAS's
11
+ * databases on it with hand-rolled SQL over a direct connection -- workable
12
+ * for a CLI run with network access to the box, but this executor never
13
+ * connects to a database it creates (`executor.ts`'s `pushDatabaseUrl`/
14
+ * `ensureCmsRuntimeUrl` only ever read `internal_db_url` back through
15
+ * Coolify's own API). So this stack instead asks Coolify for two ordinary
16
+ * managed Postgres databases, `coolifyCalls`'s own `CoolifyPort.
17
+ * createPostgresDatabase` calls -- the same primitive `coolifyDatabase`/
18
+ * `ensureCmsDatabase` already use for the org's own app and its cms site.
19
+ * That also means neither app needs a postgres superuser password minted
20
+ * here: Coolify mints and owns each database's own credential.
21
+ *
22
+ * NOT CHAT'S STACK, BY CONSTRUCTION
23
+ * ----------------------------------
24
+ * PR 344's review settled that a call guest must never gain chat membership.
25
+ * This is not a gate bolted onto shared infrastructure -- there is no shared
26
+ * infrastructure: this stack is an entirely separate homeserver from
27
+ * `archon/bootstrap/steps/chat-instance.ts`'s own Synapse+MAS, with its own
28
+ * databases, its own `server_name` ({@link callsServerName}) and its own MAS
29
+ * client. A guest account minted here cannot join a chat room because it does
30
+ * not exist on chat's homeserver at all.
31
+ *
32
+ * NO PUBLIC HOSTNAME OF ITS OWN
33
+ * -------------------------------
34
+ * `@wtfalch/calls`' own `SYNAPSE_URL`/`MAS_URL` env vars
35
+ * (`calls/packages/service/src/config.mjs`) are plain internal addresses --
36
+ * the calls service is the only thing that ever talks to either, over
37
+ * Coolify's own Docker network, proxying the Matrix client-server API on a
38
+ * guest's behalf (`calls/packages/service/src/matrix-gateway.mjs`). Neither
39
+ * app needs a Cloudflare-routed hostname, unlike chat's own Synapse (real
40
+ * Matrix clients, real federation surface). That is also why this module
41
+ * mints no tunnel or DNS anything: the only public hostname this stack ever
42
+ * needs is the calls *service's* own, which foundry#145 (part 2 of #128)
43
+ * gives it -- `<slug>-calls.oikos.host` on the shared tunnel for a level-1
44
+ * org, `calls.<domain>` on its own per-org tunnel for an own-domain one
45
+ * (`executor.ts`'s `callsHostnameFor`).
46
+ */
47
+ export declare const SYNAPSE_IMAGE = "ghcr.io/element-hq/synapse";
48
+ export declare const SYNAPSE_TAG = "v1.161.0";
49
+ export declare const MAS_IMAGE = "ghcr.io/element-hq/matrix-authentication-service:1.24.0";
50
+ /**
51
+ * Never resolved by DNS and never federates (`callsSynapseHomeserverYaml`'s
52
+ * `federation_domain_whitelist: []`) -- purely a logical identifier MAS and
53
+ * Synapse agree on, and the same value the calls service's own
54
+ * `MAS_HOMESERVER` (part 2) must carry. `<org.slug>` alone would collide with
55
+ * a real domain some org might later own; `.internal` never resolves and
56
+ * matches `@wtfalch/calls`' own bare-homeserver-domain regex
57
+ * (`^[A-Za-z0-9.:-]+$`).
58
+ */
59
+ export declare function callsServerName(orgSlug: string): string;
60
+ /** One `internal_db_url`, split into the discrete fields Synapse's and MAS's own YAML shapes each want. */
61
+ export interface CallsDbConnection {
62
+ host: string;
63
+ port: number;
64
+ user: string;
65
+ password: string;
66
+ database: string;
67
+ }
68
+ /**
69
+ * Coolify's own `internal_db_url` is `postgres://user:password@host[:port]/db`
70
+ * -- `URL` parses it exactly the way `executor.ts`'s `ensureCmsRuntimeUrl`
71
+ * already parses the same shape for the cms app's own database. Returns
72
+ * `null` on a malformed URL rather than throwing, the same defensive stance
73
+ * `ensureCmsRuntimeUrl` takes: Coolify's own superuser password is not
74
+ * something this package controls the alphabet of.
75
+ */
76
+ export declare function parseCallsDbUrl(internalDbUrl: string): CallsDbConnection | null;
77
+ /**
78
+ * Minted once per org and kept alive only as envs on the MAS app itself
79
+ * ({@link CALLS_SECRET_ENV_KEYS}, read back by `executor.ts`'s
80
+ * `coolifyCalls` before minting fresh ones) -- this package has no
81
+ * `ctx.outputs` state file the way `archon/bootstrap/steps/chat-instance.ts`
82
+ * does, so an env already on the app that needs them is this stack's only
83
+ * memory across a re-run.
84
+ */
85
+ export interface CallsMatrixSecrets {
86
+ masSynapseSharedSecret: string;
87
+ masClientId: string;
88
+ masClientSecret: string;
89
+ masEncryptionKey: string;
90
+ masRsaKeyPem: string;
91
+ masEcKeyPem: string;
92
+ }
93
+ export declare function mintCallsMatrixSecrets(): CallsMatrixSecrets;
94
+ /**
95
+ * A ULID-shaped (26-char Crockford base32) id -- MAS's `clients[].client_id`
96
+ * must be one (`docs/reference/configuration.md`). Not time-sortable (a
97
+ * static config value, not an event id), just shape-valid: 16 random bytes,
98
+ * Crockford-encoded. Identical to `chat-instance.ts`'s own helper of the same
99
+ * name -- restated, not imported, since neither `plan/` nor `archon`'s
100
+ * bootstrap tree exposes the other as a dependency.
101
+ */
102
+ export declare function generateUlid(bytes?: Buffer): string;
103
+ export interface CallsSynapseHomeserverInput {
104
+ orgSlug: string;
105
+ db: CallsDbConnection;
106
+ masHost: string;
107
+ masSharedSecret: string;
108
+ }
109
+ /**
110
+ * `homeserver.yaml` -- rate limits raised well past Synapse's defaults
111
+ * (`rc_*` below), matching `calls/proof/provider/scripts/guest-stack.mjs`'s
112
+ * own real-deployment values: every guest session this stack mints goes
113
+ * through MAS's admin API and Synapse's client API in a burst when a call
114
+ * starts, not at human typing speed.
115
+ */
116
+ export declare function callsSynapseHomeserverYaml(input: CallsSynapseHomeserverInput): string;
117
+ export interface CallsMasConfigInput {
118
+ orgSlug: string;
119
+ db: CallsDbConnection;
120
+ synapseHost: string;
121
+ sharedSecret: string;
122
+ clientId: string;
123
+ clientSecret: string;
124
+ encryptionKey: string;
125
+ rsaKeyPem: string;
126
+ ecKeyPem: string;
127
+ }
128
+ /**
129
+ * `config.yaml` -- password login and registration off (the calls service is
130
+ * the only way in, minting a guest a personal session through the admin API,
131
+ * `calls/proof/provider/scripts/guest-stack.mjs`'s `provisioner`); the one
132
+ * registered client is the calls service itself, granted `admin_clients` so
133
+ * its `client_credentials` grant can carry `urn:mas:admin`.
134
+ */
135
+ export declare function callsMasConfigYaml(input: CallsMasConfigInput): string;
@@ -0,0 +1,275 @@
1
+ import { generateKeyPairSync, randomBytes } from 'node:crypto';
2
+ /**
3
+ * The calls Matrix stack (#128, part 1 of 2 -- see that issue's "If this
4
+ * passes about 800 changed lines, split it" note): pure config generation
5
+ * for the dedicated Synapse and matrix-authentication-service (MAS)
6
+ * `executor.ts`'s `coolifyCalls` deploys per org, adapted from
7
+ * `wtfalch/app-template#344`'s `bootstrap/steps/calls-instance.ts`.
8
+ *
9
+ * TWO COOLIFY DATABASES, NOT ONE SHARED POSTGRES
10
+ * ------------------------------------------------
11
+ * PR 344 ran one bare `postgres` container and created Synapse's and MAS's
12
+ * databases on it with hand-rolled SQL over a direct connection -- workable
13
+ * for a CLI run with network access to the box, but this executor never
14
+ * connects to a database it creates (`executor.ts`'s `pushDatabaseUrl`/
15
+ * `ensureCmsRuntimeUrl` only ever read `internal_db_url` back through
16
+ * Coolify's own API). So this stack instead asks Coolify for two ordinary
17
+ * managed Postgres databases, `coolifyCalls`'s own `CoolifyPort.
18
+ * createPostgresDatabase` calls -- the same primitive `coolifyDatabase`/
19
+ * `ensureCmsDatabase` already use for the org's own app and its cms site.
20
+ * That also means neither app needs a postgres superuser password minted
21
+ * here: Coolify mints and owns each database's own credential.
22
+ *
23
+ * NOT CHAT'S STACK, BY CONSTRUCTION
24
+ * ----------------------------------
25
+ * PR 344's review settled that a call guest must never gain chat membership.
26
+ * This is not a gate bolted onto shared infrastructure -- there is no shared
27
+ * infrastructure: this stack is an entirely separate homeserver from
28
+ * `archon/bootstrap/steps/chat-instance.ts`'s own Synapse+MAS, with its own
29
+ * databases, its own `server_name` ({@link callsServerName}) and its own MAS
30
+ * client. A guest account minted here cannot join a chat room because it does
31
+ * not exist on chat's homeserver at all.
32
+ *
33
+ * NO PUBLIC HOSTNAME OF ITS OWN
34
+ * -------------------------------
35
+ * `@wtfalch/calls`' own `SYNAPSE_URL`/`MAS_URL` env vars
36
+ * (`calls/packages/service/src/config.mjs`) are plain internal addresses --
37
+ * the calls service is the only thing that ever talks to either, over
38
+ * Coolify's own Docker network, proxying the Matrix client-server API on a
39
+ * guest's behalf (`calls/packages/service/src/matrix-gateway.mjs`). Neither
40
+ * app needs a Cloudflare-routed hostname, unlike chat's own Synapse (real
41
+ * Matrix clients, real federation surface). That is also why this module
42
+ * mints no tunnel or DNS anything: the only public hostname this stack ever
43
+ * needs is the calls *service's* own, which foundry#145 (part 2 of #128)
44
+ * gives it -- `<slug>-calls.oikos.host` on the shared tunnel for a level-1
45
+ * org, `calls.<domain>` on its own per-org tunnel for an own-domain one
46
+ * (`executor.ts`'s `callsHostnameFor`).
47
+ */
48
+ export const SYNAPSE_IMAGE = 'ghcr.io/element-hq/synapse';
49
+ export const SYNAPSE_TAG = 'v1.161.0';
50
+ export const MAS_IMAGE = 'ghcr.io/element-hq/matrix-authentication-service:1.24.0';
51
+ /**
52
+ * Never resolved by DNS and never federates (`callsSynapseHomeserverYaml`'s
53
+ * `federation_domain_whitelist: []`) -- purely a logical identifier MAS and
54
+ * Synapse agree on, and the same value the calls service's own
55
+ * `MAS_HOMESERVER` (part 2) must carry. `<org.slug>` alone would collide with
56
+ * a real domain some org might later own; `.internal` never resolves and
57
+ * matches `@wtfalch/calls`' own bare-homeserver-domain regex
58
+ * (`^[A-Za-z0-9.:-]+$`).
59
+ */
60
+ export function callsServerName(orgSlug) {
61
+ return `calls-${orgSlug}.internal`;
62
+ }
63
+ /**
64
+ * Coolify's own `internal_db_url` is `postgres://user:password@host[:port]/db`
65
+ * -- `URL` parses it exactly the way `executor.ts`'s `ensureCmsRuntimeUrl`
66
+ * already parses the same shape for the cms app's own database. Returns
67
+ * `null` on a malformed URL rather than throwing, the same defensive stance
68
+ * `ensureCmsRuntimeUrl` takes: Coolify's own superuser password is not
69
+ * something this package controls the alphabet of.
70
+ */
71
+ export function parseCallsDbUrl(internalDbUrl) {
72
+ try {
73
+ const url = new URL(internalDbUrl);
74
+ const database = url.pathname.replace(/^\//, '');
75
+ if (!url.hostname || !url.username || !database)
76
+ return null;
77
+ return {
78
+ host: url.hostname,
79
+ port: url.port ? Number(url.port) : 5432,
80
+ user: decodeURIComponent(url.username),
81
+ password: decodeURIComponent(url.password),
82
+ database,
83
+ };
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ export function mintCallsMatrixSecrets() {
90
+ const rsa = generateKeyPairSync('rsa', {
91
+ modulusLength: 2048,
92
+ privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
93
+ publicKeyEncoding: { type: 'pkcs1', format: 'pem' },
94
+ });
95
+ const ec = generateKeyPairSync('ec', {
96
+ namedCurve: 'P-256',
97
+ privateKeyEncoding: { type: 'sec1', format: 'pem' },
98
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
99
+ });
100
+ return {
101
+ masSynapseSharedSecret: randomBytes(32).toString('hex'),
102
+ masClientId: generateUlid(),
103
+ masClientSecret: randomBytes(24).toString('base64url'),
104
+ masEncryptionKey: randomBytes(32).toString('hex'),
105
+ masRsaKeyPem: rsa.privateKey,
106
+ masEcKeyPem: ec.privateKey,
107
+ };
108
+ }
109
+ /**
110
+ * A ULID-shaped (26-char Crockford base32) id -- MAS's `clients[].client_id`
111
+ * must be one (`docs/reference/configuration.md`). Not time-sortable (a
112
+ * static config value, not an event id), just shape-valid: 16 random bytes,
113
+ * Crockford-encoded. Identical to `chat-instance.ts`'s own helper of the same
114
+ * name -- restated, not imported, since neither `plan/` nor `archon`'s
115
+ * bootstrap tree exposes the other as a dependency.
116
+ */
117
+ export function generateUlid(bytes = randomBytes(16)) {
118
+ const alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
119
+ let bits = 0n;
120
+ for (const b of bytes)
121
+ bits = (bits << 8n) | BigInt(b);
122
+ let out = '';
123
+ for (let i = 0; i < 26; i++) {
124
+ const shift = BigInt(130 - (i + 1) * 5);
125
+ const idx = shift >= 0n ? Number((bits >> shift) & 0x1fn) : Number((bits << -shift) & 0x1fn);
126
+ out += alphabet[idx];
127
+ }
128
+ return out;
129
+ }
130
+ /**
131
+ * `homeserver.yaml` -- rate limits raised well past Synapse's defaults
132
+ * (`rc_*` below), matching `calls/proof/provider/scripts/guest-stack.mjs`'s
133
+ * own real-deployment values: every guest session this stack mints goes
134
+ * through MAS's admin API and Synapse's client API in a burst when a call
135
+ * starts, not at human typing speed.
136
+ */
137
+ export function callsSynapseHomeserverYaml(input) {
138
+ const serverName = callsServerName(input.orgSlug);
139
+ const rateLimit = '{ per_second: 100, burst_count: 1000 }';
140
+ return [
141
+ '# Generated by @wtfalch/foundry (executor.ts, coolify:calls). Do not edit',
142
+ '# by hand; re-run apply instead -- it is idempotent and will not rotate',
143
+ '# secrets that already exist.',
144
+ '',
145
+ `server_name: "${serverName}"`,
146
+ 'pid_file: /data/homeserver.pid',
147
+ 'report_stats: false',
148
+ '',
149
+ 'listeners:',
150
+ ' - port: 8008',
151
+ ' type: http',
152
+ ' tls: false',
153
+ ' bind_addresses: ["0.0.0.0"]',
154
+ ' x_forwarded: true',
155
+ ' resources:',
156
+ ' - names: [client]',
157
+ ' compress: false',
158
+ '',
159
+ 'database:',
160
+ ' name: psycopg2',
161
+ ' args:',
162
+ ` user: ${input.db.user}`,
163
+ ` password: "${input.db.password}"`,
164
+ ` dbname: ${input.db.database}`,
165
+ ` host: ${input.db.host}`,
166
+ ` port: ${input.db.port}`,
167
+ ' cp_min: 5',
168
+ ' cp_max: 10',
169
+ '',
170
+ '# "Specifying an empty list ([]) here is the recommended way of disabling',
171
+ '# federation." (config_documentation.md, federation_domain_whitelist) --',
172
+ '# this homeserver never federates: it exists only to authenticate calls',
173
+ '# guests, never to talk to another Matrix server.',
174
+ 'federation_domain_whitelist: []',
175
+ '',
176
+ 'presence:',
177
+ ' enabled: false',
178
+ '',
179
+ 'media_store_path: /data/media_store',
180
+ 'signing_key_path: /data/signing.key',
181
+ '',
182
+ 'matrix_authentication_service:',
183
+ ' enabled: true',
184
+ ` endpoint: "http://${input.masHost}:8080/"`,
185
+ ` secret: "${input.masSharedSecret}"`,
186
+ '',
187
+ `rc_message: ${rateLimit}`,
188
+ `rc_room_creation: ${rateLimit}`,
189
+ 'rc_joins:',
190
+ ` local: ${rateLimit}`,
191
+ ` remote: ${rateLimit}`,
192
+ 'rc_invites:',
193
+ ` per_room: ${rateLimit}`,
194
+ ` per_user: ${rateLimit}`,
195
+ ` per_issuer: ${rateLimit}`,
196
+ '',
197
+ ].join('\n');
198
+ }
199
+ /**
200
+ * `config.yaml` -- password login and registration off (the calls service is
201
+ * the only way in, minting a guest a personal session through the admin API,
202
+ * `calls/proof/provider/scripts/guest-stack.mjs`'s `provisioner`); the one
203
+ * registered client is the calls service itself, granted `admin_clients` so
204
+ * its `client_credentials` grant can carry `urn:mas:admin`.
205
+ */
206
+ export function callsMasConfigYaml(input) {
207
+ const serverName = callsServerName(input.orgSlug);
208
+ const indentKey = (pem, spaces) => pem
209
+ .trimEnd()
210
+ .split('\n')
211
+ .map((line) => `${' '.repeat(spaces)}${line}`)
212
+ .join('\n');
213
+ return [
214
+ '# Generated by @wtfalch/foundry (executor.ts, coolify:calls). Do not edit by hand.',
215
+ '',
216
+ 'http:',
217
+ ' listeners:',
218
+ ' - name: web',
219
+ ' resources:',
220
+ ' - name: discovery',
221
+ ' - name: human',
222
+ ' - name: oauth',
223
+ ' - name: compat',
224
+ ' - name: graphql',
225
+ ' - name: assets',
226
+ ' - name: adminapi',
227
+ ' - name: health',
228
+ ' binds:',
229
+ ' - address: "[::]:8080"',
230
+ ' proxy_protocol: false',
231
+ ` public_base: "http://${serverName}/"`,
232
+ ' trusted_proxies:',
233
+ ' - 0.0.0.0/0',
234
+ '',
235
+ 'database:',
236
+ ` host: ${input.db.host}`,
237
+ ` port: ${input.db.port}`,
238
+ ` username: ${input.db.user}`,
239
+ ` password: "${input.db.password}"`,
240
+ ` database: ${input.db.database}`,
241
+ ' ssl_mode: disable',
242
+ '',
243
+ 'matrix:',
244
+ ' kind: synapse',
245
+ ` homeserver: ${serverName}`,
246
+ ` endpoint: "http://${input.synapseHost}:8008"`,
247
+ ` secret: "${input.sharedSecret}"`,
248
+ '',
249
+ '# The calls service is the only way in.',
250
+ 'passwords:',
251
+ ' enabled: false',
252
+ '',
253
+ 'account:',
254
+ ' password_registration_enabled: false',
255
+ '',
256
+ 'clients:',
257
+ ` - client_id: "${input.clientId}"`,
258
+ ' client_auth_method: client_secret_basic',
259
+ ` client_secret: "${input.clientSecret}"`,
260
+ '',
261
+ 'policy:',
262
+ ' data:',
263
+ ' admin_clients:',
264
+ ` - "${input.clientId}"`,
265
+ '',
266
+ 'secrets:',
267
+ ` encryption: ${input.encryptionKey}`,
268
+ ' keys:',
269
+ ' - key: |',
270
+ indentKey(input.rsaKeyPem, 8),
271
+ ' - key: |',
272
+ indentKey(input.ecKeyPem, 8),
273
+ '',
274
+ ].join('\n');
275
+ }
@@ -56,10 +56,14 @@ export declare function toPlan(detail: PlanDetail): Plan;
56
56
  * rest: the org's owner, invited by ZITADEL email, idempotent on the
57
57
  * *recorded* resource id (`recordedIds`, sourced from
58
58
  * `AppliedState.serviceIds`) rather than a live resource, since a human user
59
- * is never something `live` can contain. Everything else in `live` that none
60
- * of these twelve singletons and however many declared issuer apps claim is
61
- * unmanaged. The full field-by-field provisioning surface (SSO, guests,
62
- * calls, adopted/bespoke apps, the actual
59
+ * is never something `live` can contain. #128 adds one more singleton, off
60
+ * by default unlike chat's own: {@link planCalls}, the org's own calls
61
+ * Matrix stack (`org.ts`'s `CallsConfig.enabled`). #145 adds one more,
62
+ * planned right after it and gated on the same flag: {@link planCallsService},
63
+ * the calls service itself (`wtfalch/calls`'s `packages/service`). Everything
64
+ * else in `live` that none of these fifteen singletons and however many
65
+ * declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
66
+ * guests, calls on the org's own domain, adopted/bespoke apps, the actual
63
67
  * Zitadel/Coolify/Cloudflare/Files/AI/cms shapes) is #20's "provision
64
68
  * Company Factory end to end", which wires real readers and executors
65
69
  * against this seam.