@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.
- package/dist/calls-sfu.d.ts +17 -0
- package/dist/calls-sfu.js +17 -0
- package/dist/index.d.ts +1 -1
- package/dist/migrations/0014_calls_enabled.sql +7 -0
- package/dist/migrations/0015_domain_unique.sql +39 -0
- package/dist/org.d.ts +18 -2
- package/dist/org.js +48 -0
- package/dist/plan/calls-matrix.d.ts +135 -0
- package/dist/plan/calls-matrix.js +275 -0
- package/dist/plan/diff.d.ts +8 -4
- package/dist/plan/diff.js +105 -6
- package/dist/plan/executor.d.ts +390 -2
- package/dist/plan/executor.js +799 -28
- package/dist/plan/index.d.ts +1 -1
- package/dist/plan/plan-apply.js +12 -5
- package/dist/slug.js +8 -2
- package/dist/store/postgres.d.ts +11 -6
- package/dist/store/postgres.js +85 -27
- package/dist/store/schema.d.ts +17 -0
- package/dist/store/schema.js +1 -0
- package/dist/store.d.ts +10 -0
- package/package.json +2 -2
package/dist/calls-sfu.d.ts
CHANGED
|
@@ -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).
|
|
59
|
-
*
|
|
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
|
+
}
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -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.
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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.
|