@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.
- package/dist/bootstrap/bootstrap.d.ts +96 -0
- package/dist/bootstrap/bootstrap.js +285 -0
- package/dist/bootstrap/declare.d.ts +36 -0
- package/dist/bootstrap/declare.js +94 -0
- package/dist/bootstrap/drive.d.ts +40 -0
- package/dist/bootstrap/drive.js +51 -0
- package/dist/bootstrap/index.d.ts +3 -0
- package/dist/bootstrap/index.js +1 -0
- package/dist/calls-sfu-deploy-support.d.ts +9 -6
- package/dist/calls-sfu-deploy-support.js +9 -6
- package/dist/calls-sfu-keys.d.ts +21 -0
- package/dist/calls-sfu-keys.js +27 -0
- package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
- package/dist/calls-sfu-ssh-adapter.js +78 -0
- package/dist/calls-sfu-ssh-client.d.ts +49 -0
- package/dist/calls-sfu-ssh-client.js +82 -0
- package/dist/calls-sfu.d.ts +22 -4
- package/dist/calls-sfu.js +22 -4
- package/dist/index.d.ts +31 -6
- package/dist/index.js +16 -3
- package/dist/migrations/0014_calls_enabled.sql +7 -0
- package/dist/migrations/0015_domain_unique.sql +39 -0
- package/dist/migrations/0016_platform.sql +61 -0
- package/dist/migrations/0017_requested_changes.sql +42 -0
- package/dist/migrations/0018_platform_apply.sql +19 -0
- package/dist/org.d.ts +18 -2
- package/dist/org.js +56 -0
- package/dist/plan/agora.d.ts +77 -0
- package/dist/plan/agora.js +127 -0
- package/dist/plan/calls-matrix.d.ts +135 -0
- package/dist/plan/calls-matrix.js +275 -0
- package/dist/plan/cloudflared-connector.d.ts +70 -0
- package/dist/plan/cloudflared-connector.js +106 -0
- package/dist/plan/diff.d.ts +26 -5
- package/dist/plan/diff.js +720 -106
- package/dist/plan/executor.d.ts +731 -36
- package/dist/plan/executor.js +1180 -41
- package/dist/plan/index.d.ts +22 -5
- package/dist/plan/index.js +12 -2
- package/dist/plan/jobs-scheduler-http.d.ts +29 -0
- package/dist/plan/jobs-scheduler-http.js +50 -0
- package/dist/plan/live.d.ts +51 -2
- package/dist/plan/live.js +8 -0
- package/dist/plan/plan-apply.d.ts +192 -3
- package/dist/plan/plan-apply.js +290 -14
- package/dist/plan/platform-diff.d.ts +312 -0
- package/dist/plan/platform-diff.js +867 -0
- package/dist/plan/platform-org-ports.d.ts +50 -0
- package/dist/plan/platform-org-ports.js +70 -0
- package/dist/plan/platform-org.d.ts +120 -0
- package/dist/plan/platform-org.js +115 -0
- package/dist/plan/platform-providers.d.ts +501 -0
- package/dist/plan/platform-providers.js +3029 -0
- package/dist/plan/platform-sfu.d.ts +93 -0
- package/dist/plan/platform-sfu.js +100 -0
- package/dist/plan/providers-readers.js +22 -1
- package/dist/plan/zitadel-machine-user.d.ts +34 -0
- package/dist/plan/zitadel-machine-user.js +95 -0
- package/dist/plan.d.ts +6 -0
- package/dist/platform.d.ts +55 -0
- package/dist/platform.js +46 -0
- package/dist/requested-changes.d.ts +108 -0
- package/dist/requested-changes.js +95 -0
- package/dist/slug.js +21 -2
- package/dist/store/audit.js +4 -2
- package/dist/store/postgres.d.ts +40 -7
- package/dist/store/postgres.js +257 -29
- package/dist/store/schema.d.ts +700 -0
- package/dist/store/schema.js +58 -0
- package/dist/store.d.ts +56 -0
- 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).
|
|
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
|
@@ -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;
|