@wtfalch/foundry 0.2.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.
@@ -0,0 +1,38 @@
1
+ /** This package's own migrations -- `../migrations` from `src/`, `./migrations` once built to `dist/` (`package.json`'s `build` script copies the `.sql` files there alongside `migrate.js`). */
2
+ export declare const MIGRATIONS_DIR: string;
3
+ /**
4
+ * The one capability `migrate` needs from a driver: run SQL text (one
5
+ * statement or several) and run a callback inside a transaction. Deliberately
6
+ * not a drizzle `PgDatabase` or a concrete driver type -- drizzle's own
7
+ * `.execute()` routes a multi-statement string through a single prepared
8
+ * query on PGlite, which rejects it, so a migration file's raw text has to
9
+ * reach the driver's own multi-statement path (`postgres`'s `.unsafe()`,
10
+ * PGlite's `.exec()`) instead. Same "narrow port, no concrete client type"
11
+ * shape `plan/executor.ts`'s `CoolifyPort` uses, for the same reason: a real
12
+ * driver satisfies this structurally, with a few lines of adapter at the
13
+ * call site (see `scripts/foundry-db-migrate.mjs` and `migrate.test.ts`).
14
+ */
15
+ export interface MigrateSql {
16
+ /** Run SQL text and discard any result -- the tracking table's own DDL, and each migration file's contents. */
17
+ exec(text: string): Promise<void>;
18
+ /**
19
+ * Run one literal SELECT and return its rows. Only ever called here with
20
+ * fixed text this module builds itself (the tracking table's name, and a
21
+ * migration filename already checked against `/^\d{4}_.+\.sql$/`), so
22
+ * there is no external input to inject.
23
+ */
24
+ query<T = Record<string, unknown>>(text: string): Promise<T[]>;
25
+ /** Run `fn` inside one transaction, handing it a `MigrateSql` scoped to it; rolled back if `fn` throws. */
26
+ transaction<T>(fn: (tx: MigrateSql) => Promise<T>): Promise<T>;
27
+ }
28
+ export interface MigrationResult {
29
+ /** Names of the migration files applied this run, in filename order -- empty when the database was already current. */
30
+ applied: string[];
31
+ }
32
+ /**
33
+ * Applies every migration file in `dir` (default: this package's own
34
+ * `../migrations`) not yet recorded in `foundry_schema_migrations`, in
35
+ * filename order, one transaction per file. Safe to call repeatedly: a file
36
+ * already recorded is never re-read, let alone re-run.
37
+ */
38
+ export declare function migrate(sql: MigrateSql, dir?: string): Promise<MigrationResult>;
@@ -0,0 +1,47 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ // #R2 (map gap: foundry's own Postgres, two commands): a tracked, idempotent
5
+ // migration runner. `./test/db.ts` and `./runner/test/db.ts` already read
6
+ // and sort this same directory to build one concatenated blob for a fresh
7
+ // PGlite -- fine for a test fixture that always starts empty, wrong for a
8
+ // real host that must apply only what a `foundry_schema_migrations` table
9
+ // does not yet list. `migrationFiles` below is that same discovery, reused
10
+ // rather than re-implemented, kept per-file instead of concatenated.
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ /** This package's own migrations -- `../migrations` from `src/`, `./migrations` once built to `dist/` (`package.json`'s `build` script copies the `.sql` files there alongside `migrate.js`). */
13
+ export const MIGRATIONS_DIR = join(here, 'migrations');
14
+ const TRACKING_TABLE = 'foundry_schema_migrations';
15
+ function migrationFiles(dir) {
16
+ return readdirSync(dir)
17
+ .filter((f) => /^\d{4}_.+\.sql$/.test(f))
18
+ .sort()
19
+ .map((name) => ({ name, path: join(dir, name) }));
20
+ }
21
+ /**
22
+ * Applies every migration file in `dir` (default: this package's own
23
+ * `../migrations`) not yet recorded in `foundry_schema_migrations`, in
24
+ * filename order, one transaction per file. Safe to call repeatedly: a file
25
+ * already recorded is never re-read, let alone re-run.
26
+ */
27
+ export async function migrate(sql, dir = MIGRATIONS_DIR) {
28
+ await sql.exec(`create table if not exists ${TRACKING_TABLE} (name text primary key, applied_at timestamptz not null default now())`);
29
+ const appliedRows = await sql.query(`select name from ${TRACKING_TABLE}`);
30
+ const applied = new Set(appliedRows.map((row) => row.name));
31
+ const pending = migrationFiles(dir).filter((file) => !applied.has(file.name));
32
+ const done = [];
33
+ for (const file of pending) {
34
+ const text = readFileSync(file.path, 'utf8');
35
+ await sql.transaction(async (tx) => {
36
+ await tx.exec(text);
37
+ // `file.name` is drawn from `migrationFiles`' own regex-filtered
38
+ // `readdirSync`, never from outside this module -- see `MigrateSql`'s
39
+ // doc comment. Quotes are still escaped, so an oddly-named future
40
+ // migration file cannot break the statement.
41
+ const escaped = file.name.replace(/'/g, "''");
42
+ await tx.exec(`insert into ${TRACKING_TABLE} (name) values ('${escaped}')`);
43
+ });
44
+ done.push(file.name);
45
+ }
46
+ return { applied: done };
47
+ }
@@ -0,0 +1,21 @@
1
+ -- @wtfalch/foundry: who planned, applied or destroyed which org, and how it
2
+ -- went. Mirrors `src/store/schema.ts`'s `foundryAuditEvents`.
3
+ --
4
+ -- Decided on #85: before any archon exists, the foundry CLI records here. An
5
+ -- attached archon reads this table later. Rows are only ever inserted.
6
+ -- `org_id` has no foreign key, so a destroyed org's trail survives it.
7
+
8
+ create table if not exists foundry_audit_events (
9
+ id bigserial primary key,
10
+ at timestamptz not null,
11
+ kind text not null check (kind in ('plan', 'apply', 'destroy')),
12
+ actor_id text not null,
13
+ actor_display text not null,
14
+ org_id text not null,
15
+ outcome text check (outcome in ('applied', 'refused', 'failed', 'destroyed')),
16
+ reason text,
17
+ plan jsonb
18
+ );
19
+
20
+ create index if not exists foundry_audit_events_org_at
21
+ on foundry_audit_events (org_id, at);
@@ -0,0 +1,6 @@
1
+ -- @wtfalch/foundry: emails Cloudflare Access lets through to the org's app
2
+ -- (#84). Mirrors `src/store/schema.ts`'s `hostingAccessEmails`. An empty list
3
+ -- means no Access gate, so every existing org keeps its current behaviour.
4
+
5
+ alter table foundry_declared_orgs
6
+ add column if not exists hosting_access_emails jsonb not null default '[]'::jsonb;
@@ -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
  /**
@@ -147,6 +159,11 @@ export interface HostingConfig {
147
159
  buildPack: HostingBuildPack;
148
160
  /** The org's declared ZITADEL OIDC applications (#71). */
149
161
  issuerApps: IssuerApp[];
162
+ /**
163
+ * Emails Cloudflare Access lets through to the app's hostname (#84). Empty
164
+ * means no Access gate. Requires `enabled`.
165
+ */
166
+ accessEmails: string[];
150
167
  }
151
168
  /**
152
169
  * `null` when `hosting` is valid; otherwise the reason it is not (#71), same
@@ -160,6 +177,28 @@ export interface HostingConfig {
160
177
  * list, and a `kind` that is one of `IssuerAppKind`'s three values.
161
178
  */
162
179
  export declare function validateHosting(hosting: HostingConfig): string | null;
180
+ export declare function validateDomain(domain: string): string | null;
181
+ /**
182
+ * `null` when `site` is valid; otherwise the reason it is not (#101).
183
+ *
184
+ * Decided 2026-09-25
185
+ * (https://github.com/wtfalch/cms/blob/main/.plans/2026-09-25-org-sites.md):
186
+ * a cms site is how an org puts content on its own domain, and an org
187
+ * either runs its own app on its domain (`hosting`) or we host its website
188
+ * and cms there (`site`), never both -- both would otherwise fight over the
189
+ * same hostname (https://github.com/wtfalch/foundry/issues/99).
190
+ *
191
+ * - `site.enabled` requires `domain` to be set -- a site with nowhere to
192
+ * live is not a site.
193
+ * - `site.enabled` requires `site.usesDomain` -- unlike mail, chat, calls
194
+ * and the issuer, the site has no meaning anywhere but the org's own
195
+ * domain, so turning it on always means the domain opt-in too.
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.
200
+ */
201
+ export declare function validateSite(org: DeclaredOrg): string | null;
163
202
  /**
164
203
  * Per-org SSO. Decided to exist in #14 ("Also in scope") and its resolved
165
204
  * research #3 ("per-org SSO code exists unused"); nothing about its
package/dist/org.js CHANGED
@@ -29,6 +29,14 @@ export function validateHosting(hosting) {
29
29
  if (!HOSTING_BUILD_PACKS.has(hosting.buildPack)) {
30
30
  return `hosting.buildPack has an unknown value '${hosting.buildPack}'`;
31
31
  }
32
+ if (hosting.accessEmails.length > 0 && !hosting.enabled) {
33
+ return 'hosting.accessEmails requires hosting.enabled';
34
+ }
35
+ for (const email of hosting.accessEmails) {
36
+ if (!/^[^@\s]+@[^@\s]+$/.test(email)) {
37
+ return `hosting.accessEmails has an invalid email '${email}'`;
38
+ }
39
+ }
32
40
  const seenNames = new Set();
33
41
  for (const app of hosting.issuerApps) {
34
42
  if (!app.name) {
@@ -44,6 +52,83 @@ export function validateHosting(hosting) {
44
52
  }
45
53
  return null;
46
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
+ }
97
+ /**
98
+ * `null` when `site` is valid; otherwise the reason it is not (#101).
99
+ *
100
+ * Decided 2026-09-25
101
+ * (https://github.com/wtfalch/cms/blob/main/.plans/2026-09-25-org-sites.md):
102
+ * a cms site is how an org puts content on its own domain, and an org
103
+ * either runs its own app on its domain (`hosting`) or we host its website
104
+ * and cms there (`site`), never both -- both would otherwise fight over the
105
+ * same hostname (https://github.com/wtfalch/foundry/issues/99).
106
+ *
107
+ * - `site.enabled` requires `domain` to be set -- a site with nowhere to
108
+ * live is not a site.
109
+ * - `site.enabled` requires `site.usesDomain` -- unlike mail, chat, calls
110
+ * and the issuer, the site has no meaning anywhere but the org's own
111
+ * domain, so turning it on always means the domain opt-in too.
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.
116
+ */
117
+ export function validateSite(org) {
118
+ if (org.site.enabled && !org.domain) {
119
+ return 'site.enabled requires domain';
120
+ }
121
+ if (org.site.enabled && !org.site.usesDomain) {
122
+ return 'site.enabled requires site.usesDomain';
123
+ }
124
+ if (org.site.enabled && org.hosting.enabled) {
125
+ return 'site.enabled and hosting.enabled cannot both be on';
126
+ }
127
+ if (org.site.enabled && org.hosting.issuerApps.some((app) => app.name === 'cms')) {
128
+ return "site.enabled reserves the issuerApps name 'cms'";
129
+ }
130
+ return null;
131
+ }
47
132
  const ORG_NUMBER_PATTERN = /^\d{9}$/;
48
133
  /**
49
134
  * `null` when `buyer` is valid; otherwise the reason it is not, for a caller
@@ -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;