@wtfalch/foundry 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +96 -0
  2. package/dist/bootstrap/bootstrap.js +285 -0
  3. package/dist/bootstrap/declare.d.ts +36 -0
  4. package/dist/bootstrap/declare.js +94 -0
  5. package/dist/bootstrap/drive.d.ts +40 -0
  6. package/dist/bootstrap/drive.js +51 -0
  7. package/dist/bootstrap/index.d.ts +3 -0
  8. package/dist/bootstrap/index.js +1 -0
  9. package/dist/calls-sfu-deploy-support.d.ts +9 -6
  10. package/dist/calls-sfu-deploy-support.js +9 -6
  11. package/dist/calls-sfu-keys.d.ts +21 -0
  12. package/dist/calls-sfu-keys.js +27 -0
  13. package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
  14. package/dist/calls-sfu-ssh-adapter.js +78 -0
  15. package/dist/calls-sfu-ssh-client.d.ts +49 -0
  16. package/dist/calls-sfu-ssh-client.js +82 -0
  17. package/dist/calls-sfu.d.ts +22 -4
  18. package/dist/calls-sfu.js +22 -4
  19. package/dist/index.d.ts +31 -6
  20. package/dist/index.js +16 -3
  21. package/dist/migrations/0014_calls_enabled.sql +7 -0
  22. package/dist/migrations/0015_domain_unique.sql +39 -0
  23. package/dist/migrations/0016_platform.sql +61 -0
  24. package/dist/migrations/0017_requested_changes.sql +42 -0
  25. package/dist/migrations/0018_platform_apply.sql +19 -0
  26. package/dist/org.d.ts +18 -2
  27. package/dist/org.js +56 -0
  28. package/dist/plan/agora.d.ts +77 -0
  29. package/dist/plan/agora.js +127 -0
  30. package/dist/plan/calls-matrix.d.ts +135 -0
  31. package/dist/plan/calls-matrix.js +275 -0
  32. package/dist/plan/cloudflared-connector.d.ts +70 -0
  33. package/dist/plan/cloudflared-connector.js +106 -0
  34. package/dist/plan/diff.d.ts +26 -5
  35. package/dist/plan/diff.js +720 -106
  36. package/dist/plan/executor.d.ts +731 -36
  37. package/dist/plan/executor.js +1180 -41
  38. package/dist/plan/index.d.ts +22 -5
  39. package/dist/plan/index.js +12 -2
  40. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  41. package/dist/plan/jobs-scheduler-http.js +50 -0
  42. package/dist/plan/live.d.ts +51 -2
  43. package/dist/plan/live.js +8 -0
  44. package/dist/plan/plan-apply.d.ts +192 -3
  45. package/dist/plan/plan-apply.js +290 -14
  46. package/dist/plan/platform-diff.d.ts +312 -0
  47. package/dist/plan/platform-diff.js +867 -0
  48. package/dist/plan/platform-org-ports.d.ts +50 -0
  49. package/dist/plan/platform-org-ports.js +70 -0
  50. package/dist/plan/platform-org.d.ts +120 -0
  51. package/dist/plan/platform-org.js +115 -0
  52. package/dist/plan/platform-providers.d.ts +501 -0
  53. package/dist/plan/platform-providers.js +3029 -0
  54. package/dist/plan/platform-sfu.d.ts +93 -0
  55. package/dist/plan/platform-sfu.js +100 -0
  56. package/dist/plan/providers-readers.js +22 -1
  57. package/dist/plan/zitadel-machine-user.d.ts +34 -0
  58. package/dist/plan/zitadel-machine-user.js +95 -0
  59. package/dist/plan.d.ts +6 -0
  60. package/dist/platform.d.ts +55 -0
  61. package/dist/platform.js +46 -0
  62. package/dist/requested-changes.d.ts +108 -0
  63. package/dist/requested-changes.js +95 -0
  64. package/dist/slug.js +21 -2
  65. package/dist/store/audit.js +4 -2
  66. package/dist/store/postgres.d.ts +40 -7
  67. package/dist/store/postgres.js +257 -29
  68. package/dist/store/schema.d.ts +700 -0
  69. package/dist/store/schema.js +58 -0
  70. package/dist/store.d.ts +56 -0
  71. package/package.json +2 -2
@@ -0,0 +1,19 @@
1
+ -- @wtfalch/foundry: what applying the declared platform (#167) needs to
2
+ -- remember, and how its audit rows are told apart from an org's.
3
+ --
4
+ -- `service_ids` is the platform's own applied state: the provider ids its
5
+ -- apply made or adopted, keyed `system:kind:key` like
6
+ -- `foundry_applied_state.service_ids`. Without it a Cloudflare zone created
7
+ -- by one apply is forgotten if the next plan's read lags behind the create.
8
+ -- It lives on the singleton row because there is one platform, and
9
+ -- `put`'s upsert never writes it, so re-declaring the platform keeps it.
10
+ --
11
+ -- `target` marks an audit row as the platform's. It is null for an org row.
12
+ -- A platform plan is stamped with the owner org's id, so `org_id` alone
13
+ -- cannot say which of the two a row is.
14
+
15
+ alter table foundry_declared_platform
16
+ add column if not exists service_ids jsonb not null default '{}'::jsonb;
17
+
18
+ alter table foundry_audit_events
19
+ add column if not exists target text check (target in ('platform'));
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
@@ -49,6 +49,56 @@ export function validateHosting(hosting) {
49
49
  if (!ISSUER_APP_KINDS.has(app.kind)) {
50
50
  return `issuerApps '${app.name}' has an unknown kind '${app.kind}'`;
51
51
  }
52
+ // The fixed 'agora' ZITADEL client (#164, `plan/diff.ts`'s
53
+ // `planAgoraClient`) already owns the `zitadel:app:agora` key, for
54
+ // every org -- Agora is always on, unlike the cms client's own 'cms'
55
+ // reservation (`validateSite` below), which only applies once
56
+ // `site.enabled` is true.
57
+ if (app.name === 'agora') {
58
+ return `issuerApps '${app.name}' is reserved for the always-on Agora client every org gets`;
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+ /**
64
+ * A DNS-label-per-segment format check for `DeclaredOrg.domain` (#131): every
65
+ * dot-separated segment follows the exact rule `../slug.ts`'s `DNS_LABEL`
66
+ * enforces for a slug (1-63 characters, lowercase `a-z`, `0-9` and hyphen, no
67
+ * leading or trailing hyphen) -- duplicated here rather than imported, the
68
+ * same "restated, not shared" choice `plan/executor.ts`'s `hostnameFor` makes
69
+ * for the hostname rule, since a domain is not a slug: a slug is always
70
+ * exactly one label, a domain is at least two (a bare label has no apex
71
+ * Cloudflare can host a zone for).
72
+ *
73
+ * Forced by the same routing collision `../slug.ts`'s own top comment names
74
+ * for the slug: two orgs on the same domain would be routed to the same
75
+ * Cloudflare zone and Coolify hostname with no refusal
76
+ * (`store/postgres.ts`'s `create`/`update` call this the same place they
77
+ * call `requireValidSlug`; `migrations/0015_domain_unique.sql` adds the
78
+ * matching unique index).
79
+ *
80
+ * Requiring lowercase outright, rather than lowercasing before comparing, is
81
+ * the same choice `../slug.ts`'s `DNS_LABEL` already makes for a slug --
82
+ * whether the unique index should instead normalize case is open (#131
83
+ * "Decide"), but this validator never lets two domains differing only by
84
+ * case both exist to be compared in the first place.
85
+ *
86
+ * `null` when `domain` is valid; otherwise the reason it is not, same shape
87
+ * as {@link validateBuyerIdentity}/{@link validateOwner}.
88
+ */
89
+ const DOMAIN_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
90
+ export function validateDomain(domain) {
91
+ if (domain.length > 253) {
92
+ return `domain '${domain}' exceeds 253 characters`;
93
+ }
94
+ const labels = domain.split('.');
95
+ if (labels.length < 2) {
96
+ return `domain '${domain}' must have at least two dot-separated labels`;
97
+ }
98
+ for (const label of labels) {
99
+ if (!DOMAIN_LABEL.test(label)) {
100
+ 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`;
101
+ }
52
102
  }
53
103
  return null;
54
104
  }
@@ -68,6 +118,9 @@ export function validateHosting(hosting) {
68
118
  * and the issuer, the site has no meaning anywhere but the org's own
69
119
  * domain, so turning it on always means the domain opt-in too.
70
120
  * - `site.enabled` and `hosting.enabled` cannot both be on.
121
+ * - `site.enabled` refuses an `issuerApps` entry named `cms`: the fixed cms
122
+ * client (#139, `plan/diff.ts`'s `planCmsClient`) already owns the
123
+ * `zitadel:app:cms` key, and the two would overwrite each other.
71
124
  */
72
125
  export function validateSite(org) {
73
126
  if (org.site.enabled && !org.domain) {
@@ -79,6 +132,9 @@ export function validateSite(org) {
79
132
  if (org.site.enabled && org.hosting.enabled) {
80
133
  return 'site.enabled and hosting.enabled cannot both be on';
81
134
  }
135
+ if (org.site.enabled && org.hosting.issuerApps.some((app) => app.name === 'cms')) {
136
+ return "site.enabled reserves the issuerApps name 'cms'";
137
+ }
82
138
  return null;
83
139
  }
84
140
  const ORG_NUMBER_PATTERN = /^\d{9}$/;
@@ -0,0 +1,77 @@
1
+ import type { DeclaredOrg, OrgId } from '../org.js';
2
+ /**
3
+ * #164: what both the planner (`diff.ts`) and the executor (`executor.ts`)
4
+ * need to agree on for an org's Agora -- the hostname, the values the host
5
+ * supplies, and the env vars those produce -- so the plan can compare the
6
+ * live app with exactly what the executor would write.
7
+ */
8
+ /**
9
+ * `AppliedState.serviceIds` scope keys `desiredAgoraEnv` reads:
10
+ * `zitadel_org` is `executor.ts`'s `ensureZitadelProject`/`zitadelIssuer`
11
+ * scope id, `agora_client_id` the fixed `agora` ZITADEL client's `clientId`
12
+ * (recorded by `executor.ts`'s `zitadelApp`).
13
+ */
14
+ export declare const ZITADEL_ORG_SCOPE_KEY = "zitadel_org";
15
+ export declare const AGORA_CLIENT_ID_SCOPE_KEY = "agora_client_id";
16
+ /**
17
+ * The accent every org's Agora wears until orgs choose their own: the house
18
+ * theme's `--accent` (`@wtfalch/house`, `HOUSE_THEMES['good-company']` in
19
+ * `design/packages/house/src/themes.ts`, the default theme
20
+ * `HOUSE_DEFAULT_THEME`). Agora's config parser requires `theme.accent` as a
21
+ * hex colour (`agora/apps/agora/src/agoras.ts`). Restated here rather than
22
+ * imported: `packages/foundry` carries no `@wtfalch/house` dependency.
23
+ */
24
+ export declare const AGORA_THEME_ACCENT = "#B23A0C";
25
+ /**
26
+ * Provider-side values only the host can supply, like every other provider
27
+ * value `FoundryStepExecutorPorts` carries. As of #177, `plan-apply.ts`'s
28
+ * `FoundryPlanApply` derives these itself from the applied platform
29
+ * (`platform-org.ts`'s `agoraHostValuesFromPlatform`) whenever a host wires
30
+ * one in and supplies no `AgoraHostPort` of its own -- an explicit `agora`
31
+ * port still overrides that, unchanged. `null` means the value is not
32
+ * available yet. The first three are required by Agora's config parser
33
+ * (`agoras.ts`: `issuer`, `mailProjectId`, `messagingProjectId`);
34
+ * `mailUpstream`/`filesUpstream` are the mail/files servers' internal
35
+ * addresses, optional -- unset, `AGORA_MAIL_UPSTREAM`/`AGORA_FILES_UPSTREAM`
36
+ * are not pushed and Agora answers 502 on those routes rather than serving
37
+ * anything wrong (`executor.ts`'s `coolifyAgora` doc comment).
38
+ */
39
+ export interface AgoraHostValues {
40
+ issuer: string | null;
41
+ mailProjectId: string | null;
42
+ messagingProjectId: string | null;
43
+ mailUpstream: string | null;
44
+ filesUpstream: string | null;
45
+ }
46
+ export interface AgoraHostPort {
47
+ valuesFor(orgId: OrgId): Promise<AgoraHostValues>;
48
+ }
49
+ /** No host values at all: what an absent port means. */
50
+ export declare const NO_AGORA_HOST_VALUES: AgoraHostValues;
51
+ /** What the plan reads from the host: its values, or why it could not get them. */
52
+ export type AgoraHostReading = AgoraHostValues | {
53
+ error: string;
54
+ };
55
+ /** Reads the port once for a plan. An absent port, or one that throws, is a blocked Agora, never a failed plan. */
56
+ export declare function readAgoraHost(port: AgoraHostPort | undefined, orgId: OrgId): Promise<AgoraHostReading>;
57
+ /** The host values Agora cannot be configured without, by name -- the ones `host` lacks. */
58
+ export declare function agoraHostMissing(host: AgoraHostValues): string[];
59
+ /**
60
+ * `agora.<domain>` when the org has a domain, `<slug>-agora.oikos.host`
61
+ * otherwise (the 2026-09-24 layout, foundry#14; `../slug.ts`'s
62
+ * `AGORA_SUFFIX` reserves the collision).
63
+ */
64
+ export declare function agoraHostnameFor(org: DeclaredOrg): string;
65
+ /**
66
+ * The env vars an org's Agora app should carry, and every input still
67
+ * missing to build them. `AGORA_CONFIG` is present only when `missing` is
68
+ * empty; callers that write must refuse on a non-empty `missing`.
69
+ * `AGORA_MAIL_UPSTREAM` is present only when mail is on and the host supplies
70
+ * an address.
71
+ */
72
+ export declare function desiredAgoraEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, host: AgoraHostValues): {
73
+ env: Record<string, string>;
74
+ missing: string[];
75
+ };
76
+ /** The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored. */
77
+ export declare function agoraEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];
@@ -0,0 +1,127 @@
1
+ /**
2
+ * #164: what both the planner (`diff.ts`) and the executor (`executor.ts`)
3
+ * need to agree on for an org's Agora -- the hostname, the values the host
4
+ * supplies, and the env vars those produce -- so the plan can compare the
5
+ * live app with exactly what the executor would write.
6
+ */
7
+ /**
8
+ * `AppliedState.serviceIds` scope keys `desiredAgoraEnv` reads:
9
+ * `zitadel_org` is `executor.ts`'s `ensureZitadelProject`/`zitadelIssuer`
10
+ * scope id, `agora_client_id` the fixed `agora` ZITADEL client's `clientId`
11
+ * (recorded by `executor.ts`'s `zitadelApp`).
12
+ */
13
+ export const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
14
+ export const AGORA_CLIENT_ID_SCOPE_KEY = 'agora_client_id';
15
+ /**
16
+ * The accent every org's Agora wears until orgs choose their own: the house
17
+ * theme's `--accent` (`@wtfalch/house`, `HOUSE_THEMES['good-company']` in
18
+ * `design/packages/house/src/themes.ts`, the default theme
19
+ * `HOUSE_DEFAULT_THEME`). Agora's config parser requires `theme.accent` as a
20
+ * hex colour (`agora/apps/agora/src/agoras.ts`). Restated here rather than
21
+ * imported: `packages/foundry` carries no `@wtfalch/house` dependency.
22
+ */
23
+ export const AGORA_THEME_ACCENT = '#B23A0C';
24
+ /** No host values at all: what an absent port means. */
25
+ export const NO_AGORA_HOST_VALUES = {
26
+ issuer: null,
27
+ mailProjectId: null,
28
+ messagingProjectId: null,
29
+ mailUpstream: null,
30
+ filesUpstream: null,
31
+ };
32
+ /** Reads the port once for a plan. An absent port, or one that throws, is a blocked Agora, never a failed plan. */
33
+ export async function readAgoraHost(port, orgId) {
34
+ if (!port)
35
+ return NO_AGORA_HOST_VALUES;
36
+ try {
37
+ return await port.valuesFor(orgId);
38
+ }
39
+ catch (error) {
40
+ return { error: error instanceof Error ? error.message : String(error) };
41
+ }
42
+ }
43
+ /** The host values Agora cannot be configured without, by name -- the ones `host` lacks. */
44
+ export function agoraHostMissing(host) {
45
+ const missing = [];
46
+ if (!host.issuer)
47
+ missing.push('issuer');
48
+ if (!host.mailProjectId)
49
+ missing.push('mailProjectId');
50
+ if (!host.messagingProjectId)
51
+ missing.push('messagingProjectId');
52
+ return missing;
53
+ }
54
+ /**
55
+ * `agora.<domain>` when the org has a domain, `<slug>-agora.oikos.host`
56
+ * otherwise (the 2026-09-24 layout, foundry#14; `../slug.ts`'s
57
+ * `AGORA_SUFFIX` reserves the collision).
58
+ */
59
+ export function agoraHostnameFor(org) {
60
+ return org.domain ? `agora.${org.domain}` : `${org.slug}-agora.oikos.host`;
61
+ }
62
+ /**
63
+ * The env vars an org's Agora app should carry, and every input still
64
+ * missing to build them. `AGORA_CONFIG` is present only when `missing` is
65
+ * empty; callers that write must refuse on a non-empty `missing`.
66
+ * `AGORA_MAIL_UPSTREAM` is present only when mail is on and the host supplies
67
+ * an address.
68
+ */
69
+ export function desiredAgoraEnv(org, recorded, host) {
70
+ const env = { AGORA_CANONICAL_HOST: agoraHostnameFor(org) };
71
+ const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
72
+ const clientId = recorded[AGORA_CLIENT_ID_SCOPE_KEY];
73
+ const missing = [];
74
+ if (!zitadelOrgId)
75
+ missing.push('zitadelOrgId');
76
+ if (!clientId)
77
+ missing.push('clientId');
78
+ missing.push(...agoraHostMissing(host));
79
+ if (missing.length === 0) {
80
+ env.AGORA_CONFIG = JSON.stringify({
81
+ name: org.displayName,
82
+ theme: { accent: AGORA_THEME_ACCENT },
83
+ issuer: host.issuer,
84
+ clientId,
85
+ organisationId: zitadelOrgId,
86
+ mailProjectId: host.mailProjectId,
87
+ messagingProjectId: host.messagingProjectId,
88
+ });
89
+ }
90
+ if (org.mail.mode !== 'off' && host.mailUpstream) {
91
+ env.AGORA_MAIL_UPSTREAM = host.mailUpstream;
92
+ }
93
+ // Files is always on for every org (#80), unlike mail -- no mode to gate
94
+ // this on, only whether the host supplies an address at all (#177).
95
+ if (host.filesUpstream) {
96
+ env.AGORA_FILES_UPSTREAM = host.filesUpstream;
97
+ }
98
+ return { env, missing };
99
+ }
100
+ function stable(value) {
101
+ if (Array.isArray(value))
102
+ return `[${value.map(stable).join(',')}]`;
103
+ if (value && typeof value === 'object') {
104
+ const entries = Object.entries(value).sort(([a], [b]) => a < b ? -1 : 1);
105
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stable(v)}`).join(',')}}`;
106
+ }
107
+ return JSON.stringify(value);
108
+ }
109
+ function sameEnvValue(key, desired, live) {
110
+ if (key !== 'AGORA_CONFIG')
111
+ return desired === live;
112
+ try {
113
+ return stable(JSON.parse(desired)) === stable(JSON.parse(live));
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
119
+ /** The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored. */
120
+ export function agoraEnvDrift(desired, live) {
121
+ return Object.entries(desired)
122
+ .filter(([key, value]) => {
123
+ const liveValue = live[key];
124
+ return liveValue === undefined || !sameEnvValue(key, value, liveValue);
125
+ })
126
+ .map(([key]) => key);
127
+ }
@@ -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;