@wtfalch/foundry 0.2.0 → 0.3.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,57 @@
1
+ /**
2
+ * Pure decision logic for `scripts/calls-sfu-deploy.mjs`'s SSH/SFTP adapter,
3
+ * split out so it is unit-tested by vitest against `src/**` -- before
4
+ * `pnpm build` produces `dist` -- the same way `calls-sfu.ts` is, rather
5
+ * than only by hand against a real VM. The adapter itself (`ssh2`'s
6
+ * `Client`/`sftp`/`exec` wiring) stays untested, same as
7
+ * `foundry-db-provision.mjs`/`foundry-db-migrate.mjs`: real I/O, no branch
8
+ * logic worth a fake session over.
9
+ *
10
+ * Each export here answers one finding from PR #125's review (#122):
11
+ * 1. No host-key check -- {@link hostKeyFingerprint} / {@link verifyHostKey}.
12
+ * 2. `livekit.yaml` landing world-readable -- {@link fileWriteMode}.
13
+ * 3. No preflight for `docker`/`ufw` -- {@link PREFLIGHT_COMMAND} / {@link preflightFailureMessage}.
14
+ * 4. Incomplete secret redaction -- {@link redactSecrets}.
15
+ */
16
+ /**
17
+ * The `ssh-keygen -lf`-style fingerprint of a host key: `SHA256:` plus the
18
+ * unpadded base64 SHA-256 digest of the key's raw bytes. Matches what an
19
+ * operator reads from `ssh-keyscan -t ed25519 <host> | ssh-keygen -lf -`, so
20
+ * `CALLS_SFU_SSH_HOST_FINGERPRINT` can be pasted from that command's output
21
+ * verbatim.
22
+ */
23
+ export declare function hostKeyFingerprint(rawKey: Buffer): string;
24
+ /**
25
+ * `ssh2`'s `hostVerifier(key, verify)` contract: `true` accepts the session,
26
+ * `false` refuses it. VM 105's SSH port is open to the internet, so with no
27
+ * expected fingerprint configured the session is refused rather than
28
+ * silently trusting whatever key is presented -- PR #125's review, finding 1.
29
+ */
30
+ export declare function verifyHostKey(rawKey: Buffer, expectedFingerprint: string | undefined): boolean;
31
+ /**
32
+ * `livekit.yaml` holds the LiveKit API key and secret in plain text
33
+ * (`calls-sfu.ts`'s `livekitConfigYaml`), and `cloudflared.env` holds the
34
+ * tunnel token the same way (`cloudflaredEnvFile`, #127) -- every other file
35
+ * this script writes holds no secret. Returns the SFTP file mode to write
36
+ * with, or `undefined` for anything else (the SFTP server's own default).
37
+ * PR #125's review, finding 2; extended for #127's second secret file.
38
+ */
39
+ export declare function fileWriteMode(path: string): number | undefined;
40
+ /**
41
+ * Checked before either file is written -- a Debian cloud image ships
42
+ * neither `docker` nor `ufw`, and bringing LiveKit up with
43
+ * `network_mode: host` before `ufw` exists would leave every port open with
44
+ * no firewall left to narrow it. PR #125's review, finding 3.
45
+ */
46
+ export declare const PREFLIGHT_COMMAND = "command -v docker && command -v ufw";
47
+ export declare function preflightFailureMessage(result: {
48
+ stdout: string;
49
+ stderr: string;
50
+ }): string;
51
+ /**
52
+ * Scrubs every secret this script ever handles out of a string -- an error
53
+ * message or stack trace, most often. PR #125's review, finding 4: the
54
+ * original left `livekitApiKey` out of this list, so a thrown error could
55
+ * still print it.
56
+ */
57
+ export declare function redactSecrets(text: string, secrets: Array<string | undefined>): string;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Pure decision logic for `scripts/calls-sfu-deploy.mjs`'s SSH/SFTP adapter,
3
+ * split out so it is unit-tested by vitest against `src/**` -- before
4
+ * `pnpm build` produces `dist` -- the same way `calls-sfu.ts` is, rather
5
+ * than only by hand against a real VM. The adapter itself (`ssh2`'s
6
+ * `Client`/`sftp`/`exec` wiring) stays untested, same as
7
+ * `foundry-db-provision.mjs`/`foundry-db-migrate.mjs`: real I/O, no branch
8
+ * logic worth a fake session over.
9
+ *
10
+ * Each export here answers one finding from PR #125's review (#122):
11
+ * 1. No host-key check -- {@link hostKeyFingerprint} / {@link verifyHostKey}.
12
+ * 2. `livekit.yaml` landing world-readable -- {@link fileWriteMode}.
13
+ * 3. No preflight for `docker`/`ufw` -- {@link PREFLIGHT_COMMAND} / {@link preflightFailureMessage}.
14
+ * 4. Incomplete secret redaction -- {@link redactSecrets}.
15
+ */
16
+ import { createHash } from 'node:crypto';
17
+ import { CLOUDFLARED_ENV_PATH, LIVEKIT_CONFIG_PATH } from './calls-sfu.js';
18
+ // ---------------------------------------------------------------------------
19
+ // 1. Host-key verification
20
+ // ---------------------------------------------------------------------------
21
+ /**
22
+ * The `ssh-keygen -lf`-style fingerprint of a host key: `SHA256:` plus the
23
+ * unpadded base64 SHA-256 digest of the key's raw bytes. Matches what an
24
+ * operator reads from `ssh-keyscan -t ed25519 <host> | ssh-keygen -lf -`, so
25
+ * `CALLS_SFU_SSH_HOST_FINGERPRINT` can be pasted from that command's output
26
+ * verbatim.
27
+ */
28
+ export function hostKeyFingerprint(rawKey) {
29
+ const digest = createHash('sha256').update(rawKey).digest('base64').replace(/=+$/, '');
30
+ return `SHA256:${digest}`;
31
+ }
32
+ /**
33
+ * `ssh2`'s `hostVerifier(key, verify)` contract: `true` accepts the session,
34
+ * `false` refuses it. VM 105's SSH port is open to the internet, so with no
35
+ * expected fingerprint configured the session is refused rather than
36
+ * silently trusting whatever key is presented -- PR #125's review, finding 1.
37
+ */
38
+ export function verifyHostKey(rawKey, expectedFingerprint) {
39
+ if (!expectedFingerprint)
40
+ return false;
41
+ return hostKeyFingerprint(rawKey) === expectedFingerprint;
42
+ }
43
+ // ---------------------------------------------------------------------------
44
+ // 2. File permissions
45
+ // ---------------------------------------------------------------------------
46
+ /**
47
+ * `livekit.yaml` holds the LiveKit API key and secret in plain text
48
+ * (`calls-sfu.ts`'s `livekitConfigYaml`), and `cloudflared.env` holds the
49
+ * tunnel token the same way (`cloudflaredEnvFile`, #127) -- every other file
50
+ * this script writes holds no secret. Returns the SFTP file mode to write
51
+ * with, or `undefined` for anything else (the SFTP server's own default).
52
+ * PR #125's review, finding 2; extended for #127's second secret file.
53
+ */
54
+ export function fileWriteMode(path) {
55
+ return path === LIVEKIT_CONFIG_PATH || path === CLOUDFLARED_ENV_PATH ? 0o600 : undefined;
56
+ }
57
+ // ---------------------------------------------------------------------------
58
+ // 3. Preflight
59
+ // ---------------------------------------------------------------------------
60
+ /**
61
+ * Checked before either file is written -- a Debian cloud image ships
62
+ * neither `docker` nor `ufw`, and bringing LiveKit up with
63
+ * `network_mode: host` before `ufw` exists would leave every port open with
64
+ * no firewall left to narrow it. PR #125's review, finding 3.
65
+ */
66
+ export const PREFLIGHT_COMMAND = 'command -v docker && command -v ufw';
67
+ export function preflightFailureMessage(result) {
68
+ const detail = (result.stderr || result.stdout || '').trim();
69
+ return `install docker and ufw first (\`${PREFLIGHT_COMMAND}\` failed${detail ? `: ${detail}` : ''})`;
70
+ }
71
+ // ---------------------------------------------------------------------------
72
+ // 4. Redaction
73
+ // ---------------------------------------------------------------------------
74
+ /**
75
+ * Scrubs every secret this script ever handles out of a string -- an error
76
+ * message or stack trace, most often. PR #125's review, finding 4: the
77
+ * original left `livekitApiKey` out of this list, so a thrown error could
78
+ * still print it.
79
+ */
80
+ export function redactSecrets(text, secrets) {
81
+ return secrets.reduce((acc, secret) => (secret ? acc.split(secret).join('<redacted>') : acc), text);
82
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Deploys LiveKit onto the estate's one calls SFU VM (VM 105, `82.39.206.243`
3
+ * as of 2026-09-27, calls#15). One instance for every org: calls are
4
+ * end-to-end encrypted, so LiveKit only ever forwards ciphertext and orgs are
5
+ * kept apart by room names and signed tokens, not by a VM or a port range
6
+ * each -- #122's "Decided" section, 2026-09-27. That is also why this is its
7
+ * own module rather than a case in `plan/executor.ts`'s per-`DeclaredOrg`
8
+ * dispatch, or an addition to `provision.ts` (foundry's *other* estate-wide
9
+ * singleton, its own Postgres): #122 asked for this to stay small, one
10
+ * module and one test file, rather than repeat app-template's
11
+ * `calls-instance` step, which grew to 1147 untested lines before anyone
12
+ * noticed (app-template#338, foundry#42).
13
+ *
14
+ * How: plain Docker Compose on the VM, applied over SSH -- not a Coolify
15
+ * server. LiveKit needs host networking and a wide UDP port range; Coolify
16
+ * would add a second host to manage for no gain, and the SFU got its own VM
17
+ * specifically to keep Coolify's own firewall script (`ADR 0011`) untouched
18
+ * (calls#15). {@link CallsSfuHostPort} is deliberately narrow -- write a
19
+ * file, run a command -- so a real implementation is a thin SSH/SFTP client
20
+ * (`packages/foundry/scripts/calls-sfu-deploy.mjs`), never imported here,
21
+ * and every test in `calls-sfu.test.ts` runs against a fake.
22
+ *
23
+ * The address, the LiveKit API key and its secret are never hardcoded here
24
+ * ({@link CallsSfuConfig}): PR 344's review on app-template's
25
+ * `calls-instance` step found its first cut created three Coolify apps
26
+ * before it ever checked the address was known.
27
+ * {@link requireCallsSfuAddress} is that check, run first, before either
28
+ * file is written.
29
+ *
30
+ * #127 adds a second compose service, `cloudflared`, so the SFU has a public
31
+ * `wss://` address through its own named tunnel -- browsers on an https page
32
+ * cannot open a plain `ws://` connection, and 7880 was open to the whole
33
+ * internet with nothing in front of it. `cloudflared` only ever dials out
34
+ * (`scripts/calls-sfu-ingress.mjs`, beside `scripts/shared-ingress.mjs`, owns
35
+ * the tunnel/DNS side of this -- the estate's Cloudflare resources, not
36
+ * anything on the VM), so 7880 no longer needs to be reachable from the
37
+ * internet at all: {@link applyCallsSfu} narrows it to the host's own private
38
+ * network on `vmbr1` ({@link CALLS_SFU_PRIVATE_CIDR}), where the calls
39
+ * service reaches LiveKit's API directly (#127's "Decided" section). 7881
40
+ * and the UDP media range stay open to everyone -- media isn't part of this.
41
+ * The tunnel token reaches the VM the same way the LiveKit secret does: a
42
+ * file written at 0600 ({@link requireCloudflaredTunnelToken}, mirrored in
43
+ * `calls-sfu-deploy-support.ts`'s `fileWriteMode`), never logged, scrubbed
44
+ * from any error `calls-sfu-deploy.mjs` prints.
45
+ */
46
+ export declare class MissingCallsSfuAddressError extends Error {
47
+ constructor();
48
+ }
49
+ /**
50
+ * Refuses on a missing or blank address; returns the trimmed address
51
+ * otherwise. Called first in {@link applyCallsSfu}, ahead of either file
52
+ * write, so a re-run with no address configured touches nothing.
53
+ */
54
+ export declare function requireCallsSfuAddress(address: string | undefined | null): string;
55
+ export declare class MissingCloudflaredTunnelTokenError extends Error {
56
+ constructor();
57
+ }
58
+ /**
59
+ * Refuses on a missing or blank token; returns the trimmed token otherwise.
60
+ * Called first in {@link applyCallsSfu}, alongside
61
+ * {@link requireCallsSfuAddress}, ahead of any file write.
62
+ */
63
+ export declare function requireCloudflaredTunnelToken(token: string | undefined | null): string;
64
+ /**
65
+ * Everything {@link applyCallsSfu} needs and this package never invents on
66
+ * its own: the SFU's own address (calls#15), the LiveKit API key/secret pair
67
+ * every client and the service sign tokens with, and the token for the
68
+ * named tunnel `scripts/calls-sfu-ingress.mjs` creates. All four come from
69
+ * the secrets store, at the caller's own script -- #122's and #127's "Human
70
+ * steps".
71
+ */
72
+ export interface CallsSfuConfig {
73
+ address: string;
74
+ livekitApiKey: string;
75
+ livekitApiSecret: string;
76
+ cloudflaredTunnelToken: string;
77
+ }
78
+ /**
79
+ * The one thing a real deploy needs beyond {@link CallsSfuConfig}: a way to
80
+ * put a file on the VM and run a command on it. Shaped to fit an SSH/SFTP
81
+ * session directly (`writeFile` -> SFTP, `exec` -> a single SSH channel), so
82
+ * `calls-sfu-deploy.mjs`'s real implementation is a thin adapter, not a
83
+ * reimplementation.
84
+ */
85
+ export interface CallsSfuHostPort {
86
+ /** Writes a file's full contents at an absolute path, overwriting whatever was there. */
87
+ writeFile(path: string, contents: string): Promise<void>;
88
+ /** Runs one command; never throws on a non-zero exit -- {@link applyCallsSfu} checks it. */
89
+ exec(command: string): Promise<{
90
+ exitCode: number;
91
+ stdout: string;
92
+ stderr: string;
93
+ }>;
94
+ }
95
+ export declare const LIVEKIT_COMPOSE_PATH = "/opt/livekit/docker-compose.yml";
96
+ export declare const LIVEKIT_CONFIG_PATH = "/opt/livekit/livekit.yaml";
97
+ /** Holds `TUNNEL_TOKEN=...` -- the one secret the `cloudflared` compose service reads (`env_file:`, below). */
98
+ export declare const CLOUDFLARED_ENV_PATH = "/opt/livekit/cloudflared.env";
99
+ /**
100
+ * `:latest`, matching this codebase's one other pinned-by-tag-only image
101
+ * (`scripts/shared-ingress.mjs`'s `CLOUDFLARED_IMAGE_REF`) -- neither runs
102
+ * anywhere a supply-chain review has asked for a digest pin yet.
103
+ */
104
+ export declare const LIVEKIT_IMAGE_REF = "livekit/livekit-server:latest";
105
+ /** Same tag policy as {@link LIVEKIT_IMAGE_REF}; matches `scripts/shared-ingress.mjs`'s own `CLOUDFLARED_IMAGE_REF` constant (duplicated, not imported -- this package and that top-level script sit in separate places in the tree). */
106
+ export declare const CLOUDFLARED_IMAGE_REF = "cloudflare/cloudflared:latest";
107
+ /** LiveKit signalling -- reachable only through the tunnel as of #127 (see {@link CALLS_SFU_PRIVATE_CIDR}). */
108
+ export declare const LIVEKIT_SIGNALLING_PORT = 7880;
109
+ /** Media fallback for networks that block UDP -- reaches the SFU's own address directly. */
110
+ export declare const LIVEKIT_MEDIA_TCP_PORT = 7881;
111
+ /** RTP media. Matches #122's "Decided" section exactly; not the wider range `docs/research/2026-09-19-meetings-research.md` lists for embedded TURN, which #122 does not turn on. */
112
+ export declare const LIVEKIT_MEDIA_UDP_RANGE = "50000:60000";
113
+ /**
114
+ * The host's private network on `vmbr1`, where the calls service reaches
115
+ * LiveKit's API directly (#127's "Human steps": VM 105 gets a second NIC on
116
+ * this network). `cloudflared` only ever dials out to Cloudflare, so once it
117
+ * carries signalling, {@link LIVEKIT_SIGNALLING_PORT} no longer needs to be
118
+ * reachable from the public internet at all -- #127's "Decided" section.
119
+ */
120
+ export declare const CALLS_SFU_PRIVATE_CIDR = "10.10.10.0/24";
121
+ /**
122
+ * Exported for the test file. Reads `ufw status`'s own report rather than
123
+ * assuming the exit code of a blind `ufw delete allow 7880/tcp` -- real
124
+ * ufw's behaviour when the rule is already gone differs by version, and a
125
+ * status check works the same on every one of them.
126
+ */
127
+ export declare function hasUnscopedSignallingRule(status: string): boolean;
128
+ /**
129
+ * `network_mode: host`: LiveKit's own self-hosting guide recommends this
130
+ * over mapping ten thousand individual UDP ports, and the SFU VM has no
131
+ * other service to collide with (calls#15 -- it exists so this workload gets
132
+ * a host to itself). `cloudflared` shares the same host network so it can
133
+ * reach LiveKit at `localhost:7880` -- #127's "How": its one ingress rule
134
+ * sends the SFU hostname there.
135
+ */
136
+ export declare function dockerComposeYaml(): string;
137
+ /**
138
+ * The `cloudflared` service's own secret file ({@link CLOUDFLARED_ENV_PATH}),
139
+ * mirroring {@link livekitConfigYaml}: refuses before writing anything when
140
+ * the token is missing, same guard as the address.
141
+ */
142
+ export declare function cloudflaredEnvFile(token: string): string;
143
+ /**
144
+ * `use_external_ip: false` with an explicit `node_ip`, not `true`: the VM
145
+ * has one public address of its own, bridged directly rather than sitting
146
+ * behind NAT (calls#15's decision comment), so there is a known-good answer
147
+ * and no reason to make LiveKit discover it via STUN.
148
+ */
149
+ export declare function livekitConfigYaml(config: CallsSfuConfig): string;
150
+ /**
151
+ * Writes all three files, brings LiveKit and `cloudflared` up, then narrows
152
+ * the VM firewall to exactly what #122's and #127's "Decided" sections name
153
+ * -- in that order, and with every `allow` rule added before `default deny
154
+ * incoming` takes effect, so a run never has a window where the SSH session
155
+ * it is running over would be refused. Every step is idempotent (`docker
156
+ * compose up -d`, `ufw allow` and `ufw --force enable` all no-op safely on a
157
+ * repeat), so a retry after a partial failure is always safe -- #122's own
158
+ * "safe to re-run".
159
+ *
160
+ * {@link LIVEKIT_SIGNALLING_PORT} is narrowed to
161
+ * {@link CALLS_SFU_PRIVATE_CIDR} rather than opened to everyone, as of #127:
162
+ * `cloudflared` only ever dials out, so signalling reaches the SFU over the
163
+ * tunnel, and the one thing left that still needs 7880 directly is the calls
164
+ * service, from the host's own private network. `ufw` only ever adds rules,
165
+ * so a VM already carrying PR 125's wide-open `ufw allow 7880/tcp` keeps it
166
+ * reachable from the whole internet beside the new scoped one unless
167
+ * something removes it first -- {@link hasUnscopedSignallingRule} reads `ufw
168
+ * status` and this function deletes that rule before adding the scoped
169
+ * replacement, and leaves a VM that never had it untouched.
170
+ *
171
+ * Stops at the first failing step: a `docker compose up -d` that fails
172
+ * leaves the firewall untouched rather than locking a broken deploy behind
173
+ * a narrowed one.
174
+ */
175
+ export declare function applyCallsSfu(host: CallsSfuHostPort, config: CallsSfuConfig): Promise<void>;
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Deploys LiveKit onto the estate's one calls SFU VM (VM 105, `82.39.206.243`
3
+ * as of 2026-09-27, calls#15). One instance for every org: calls are
4
+ * end-to-end encrypted, so LiveKit only ever forwards ciphertext and orgs are
5
+ * kept apart by room names and signed tokens, not by a VM or a port range
6
+ * each -- #122's "Decided" section, 2026-09-27. That is also why this is its
7
+ * own module rather than a case in `plan/executor.ts`'s per-`DeclaredOrg`
8
+ * dispatch, or an addition to `provision.ts` (foundry's *other* estate-wide
9
+ * singleton, its own Postgres): #122 asked for this to stay small, one
10
+ * module and one test file, rather than repeat app-template's
11
+ * `calls-instance` step, which grew to 1147 untested lines before anyone
12
+ * noticed (app-template#338, foundry#42).
13
+ *
14
+ * How: plain Docker Compose on the VM, applied over SSH -- not a Coolify
15
+ * server. LiveKit needs host networking and a wide UDP port range; Coolify
16
+ * would add a second host to manage for no gain, and the SFU got its own VM
17
+ * specifically to keep Coolify's own firewall script (`ADR 0011`) untouched
18
+ * (calls#15). {@link CallsSfuHostPort} is deliberately narrow -- write a
19
+ * file, run a command -- so a real implementation is a thin SSH/SFTP client
20
+ * (`packages/foundry/scripts/calls-sfu-deploy.mjs`), never imported here,
21
+ * and every test in `calls-sfu.test.ts` runs against a fake.
22
+ *
23
+ * The address, the LiveKit API key and its secret are never hardcoded here
24
+ * ({@link CallsSfuConfig}): PR 344's review on app-template's
25
+ * `calls-instance` step found its first cut created three Coolify apps
26
+ * before it ever checked the address was known.
27
+ * {@link requireCallsSfuAddress} is that check, run first, before either
28
+ * file is written.
29
+ *
30
+ * #127 adds a second compose service, `cloudflared`, so the SFU has a public
31
+ * `wss://` address through its own named tunnel -- browsers on an https page
32
+ * cannot open a plain `ws://` connection, and 7880 was open to the whole
33
+ * internet with nothing in front of it. `cloudflared` only ever dials out
34
+ * (`scripts/calls-sfu-ingress.mjs`, beside `scripts/shared-ingress.mjs`, owns
35
+ * the tunnel/DNS side of this -- the estate's Cloudflare resources, not
36
+ * anything on the VM), so 7880 no longer needs to be reachable from the
37
+ * internet at all: {@link applyCallsSfu} narrows it to the host's own private
38
+ * network on `vmbr1` ({@link CALLS_SFU_PRIVATE_CIDR}), where the calls
39
+ * service reaches LiveKit's API directly (#127's "Decided" section). 7881
40
+ * and the UDP media range stay open to everyone -- media isn't part of this.
41
+ * The tunnel token reaches the VM the same way the LiveKit secret does: a
42
+ * file written at 0600 ({@link requireCloudflaredTunnelToken}, mirrored in
43
+ * `calls-sfu-deploy-support.ts`'s `fileWriteMode`), never logged, scrubbed
44
+ * from any error `calls-sfu-deploy.mjs` prints.
45
+ */
46
+ // ---------------------------------------------------------------------------
47
+ // The guard
48
+ // ---------------------------------------------------------------------------
49
+ export class MissingCallsSfuAddressError extends Error {
50
+ constructor() {
51
+ super('The calls SFU address is required and was not supplied. See ' +
52
+ 'https://github.com/wtfalch/calls/issues/15 for where it comes from, ' +
53
+ 'and https://github.com/wtfalch/foundry/issues/122 for why nothing ' +
54
+ 'here may create anything before it is known.');
55
+ this.name = 'MissingCallsSfuAddressError';
56
+ }
57
+ }
58
+ /**
59
+ * Refuses on a missing or blank address; returns the trimmed address
60
+ * otherwise. Called first in {@link applyCallsSfu}, ahead of either file
61
+ * write, so a re-run with no address configured touches nothing.
62
+ */
63
+ export function requireCallsSfuAddress(address) {
64
+ const trimmed = address?.trim();
65
+ if (!trimmed)
66
+ throw new MissingCallsSfuAddressError();
67
+ return trimmed;
68
+ }
69
+ export class MissingCloudflaredTunnelTokenError extends Error {
70
+ constructor() {
71
+ super('The cloudflared tunnel token is required and was not supplied. Run ' +
72
+ 'scripts/calls-sfu-ingress.mjs --apply to create the named tunnel ' +
73
+ '`calls-sfu`, then put its token in the secrets store -- see ' +
74
+ 'https://github.com/wtfalch/foundry/issues/127.');
75
+ this.name = 'MissingCloudflaredTunnelTokenError';
76
+ }
77
+ }
78
+ /**
79
+ * Refuses on a missing or blank token; returns the trimmed token otherwise.
80
+ * Called first in {@link applyCallsSfu}, alongside
81
+ * {@link requireCallsSfuAddress}, ahead of any file write.
82
+ */
83
+ export function requireCloudflaredTunnelToken(token) {
84
+ const trimmed = token?.trim();
85
+ if (!trimmed)
86
+ throw new MissingCloudflaredTunnelTokenError();
87
+ return trimmed;
88
+ }
89
+ // ---------------------------------------------------------------------------
90
+ // What gets written and run
91
+ // ---------------------------------------------------------------------------
92
+ export const LIVEKIT_COMPOSE_PATH = '/opt/livekit/docker-compose.yml';
93
+ export const LIVEKIT_CONFIG_PATH = '/opt/livekit/livekit.yaml';
94
+ /** Holds `TUNNEL_TOKEN=...` -- the one secret the `cloudflared` compose service reads (`env_file:`, below). */
95
+ export const CLOUDFLARED_ENV_PATH = '/opt/livekit/cloudflared.env';
96
+ /**
97
+ * `:latest`, matching this codebase's one other pinned-by-tag-only image
98
+ * (`scripts/shared-ingress.mjs`'s `CLOUDFLARED_IMAGE_REF`) -- neither runs
99
+ * anywhere a supply-chain review has asked for a digest pin yet.
100
+ */
101
+ export const LIVEKIT_IMAGE_REF = 'livekit/livekit-server:latest';
102
+ /** Same tag policy as {@link LIVEKIT_IMAGE_REF}; matches `scripts/shared-ingress.mjs`'s own `CLOUDFLARED_IMAGE_REF` constant (duplicated, not imported -- this package and that top-level script sit in separate places in the tree). */
103
+ export const CLOUDFLARED_IMAGE_REF = 'cloudflare/cloudflared:latest';
104
+ /** LiveKit signalling -- reachable only through the tunnel as of #127 (see {@link CALLS_SFU_PRIVATE_CIDR}). */
105
+ export const LIVEKIT_SIGNALLING_PORT = 7880;
106
+ /** Media fallback for networks that block UDP -- reaches the SFU's own address directly. */
107
+ export const LIVEKIT_MEDIA_TCP_PORT = 7881;
108
+ /** RTP media. Matches #122's "Decided" section exactly; not the wider range `docs/research/2026-09-19-meetings-research.md` lists for embedded TURN, which #122 does not turn on. */
109
+ export const LIVEKIT_MEDIA_UDP_RANGE = '50000:60000';
110
+ /**
111
+ * The host's private network on `vmbr1`, where the calls service reaches
112
+ * LiveKit's API directly (#127's "Human steps": VM 105 gets a second NIC on
113
+ * this network). `cloudflared` only ever dials out to Cloudflare, so once it
114
+ * carries signalling, {@link LIVEKIT_SIGNALLING_PORT} no longer needs to be
115
+ * reachable from the public internet at all -- #127's "Decided" section.
116
+ */
117
+ export const CALLS_SFU_PRIVATE_CIDR = '10.10.10.0/24';
118
+ /**
119
+ * Matches `ufw status`'s report of the exact wide-open rule PR 125 added
120
+ * (`ufw allow 7880/tcp`) -- "Anywhere" in the From column -- and not the
121
+ * scoped one #127 replaces it with, whose From column names
122
+ * {@link CALLS_SFU_PRIVATE_CIDR} instead. Multiline so it matches the plain,
123
+ * non-`(v6)` row regardless of what comes before or after it in `ufw
124
+ * status`'s table.
125
+ */
126
+ const UNSCOPED_SIGNALLING_RULE_PATTERN = new RegExp(`^${LIVEKIT_SIGNALLING_PORT}/tcp\\s+ALLOW\\s+Anywhere\\b`, 'm');
127
+ /**
128
+ * Exported for the test file. Reads `ufw status`'s own report rather than
129
+ * assuming the exit code of a blind `ufw delete allow 7880/tcp` -- real
130
+ * ufw's behaviour when the rule is already gone differs by version, and a
131
+ * status check works the same on every one of them.
132
+ */
133
+ export function hasUnscopedSignallingRule(status) {
134
+ return UNSCOPED_SIGNALLING_RULE_PATTERN.test(status);
135
+ }
136
+ /**
137
+ * `network_mode: host`: LiveKit's own self-hosting guide recommends this
138
+ * over mapping ten thousand individual UDP ports, and the SFU VM has no
139
+ * other service to collide with (calls#15 -- it exists so this workload gets
140
+ * a host to itself). `cloudflared` shares the same host network so it can
141
+ * reach LiveKit at `localhost:7880` -- #127's "How": its one ingress rule
142
+ * sends the SFU hostname there.
143
+ */
144
+ export function dockerComposeYaml() {
145
+ return `services:
146
+ livekit:
147
+ image: ${LIVEKIT_IMAGE_REF}
148
+ restart: unless-stopped
149
+ network_mode: host
150
+ volumes:
151
+ - ${LIVEKIT_CONFIG_PATH}:/etc/livekit.yaml:ro
152
+ command: --config /etc/livekit.yaml
153
+ cloudflared:
154
+ image: ${CLOUDFLARED_IMAGE_REF}
155
+ restart: unless-stopped
156
+ network_mode: host
157
+ env_file:
158
+ - ${CLOUDFLARED_ENV_PATH}
159
+ command: tunnel --no-autoupdate run
160
+ `;
161
+ }
162
+ /**
163
+ * The `cloudflared` service's own secret file ({@link CLOUDFLARED_ENV_PATH}),
164
+ * mirroring {@link livekitConfigYaml}: refuses before writing anything when
165
+ * the token is missing, same guard as the address.
166
+ */
167
+ export function cloudflaredEnvFile(token) {
168
+ const trimmed = requireCloudflaredTunnelToken(token);
169
+ return `TUNNEL_TOKEN=${trimmed}\n`;
170
+ }
171
+ /**
172
+ * `use_external_ip: false` with an explicit `node_ip`, not `true`: the VM
173
+ * has one public address of its own, bridged directly rather than sitting
174
+ * behind NAT (calls#15's decision comment), so there is a known-good answer
175
+ * and no reason to make LiveKit discover it via STUN.
176
+ */
177
+ export function livekitConfigYaml(config) {
178
+ const address = requireCallsSfuAddress(config.address);
179
+ return `port: ${LIVEKIT_SIGNALLING_PORT}
180
+ rtc:
181
+ tcp_port: ${LIVEKIT_MEDIA_TCP_PORT}
182
+ port_range_start: 50000
183
+ port_range_end: 60000
184
+ use_external_ip: false
185
+ node_ip: ${address}
186
+ keys:
187
+ ${config.livekitApiKey}: ${config.livekitApiSecret}
188
+ `;
189
+ }
190
+ async function run(host, command) {
191
+ const result = await host.exec(command);
192
+ if (result.exitCode !== 0) {
193
+ throw new Error(`"${command}" exited ${result.exitCode}: ${result.stderr || result.stdout}`);
194
+ }
195
+ }
196
+ /**
197
+ * Writes all three files, brings LiveKit and `cloudflared` up, then narrows
198
+ * the VM firewall to exactly what #122's and #127's "Decided" sections name
199
+ * -- in that order, and with every `allow` rule added before `default deny
200
+ * incoming` takes effect, so a run never has a window where the SSH session
201
+ * it is running over would be refused. Every step is idempotent (`docker
202
+ * compose up -d`, `ufw allow` and `ufw --force enable` all no-op safely on a
203
+ * repeat), so a retry after a partial failure is always safe -- #122's own
204
+ * "safe to re-run".
205
+ *
206
+ * {@link LIVEKIT_SIGNALLING_PORT} is narrowed to
207
+ * {@link CALLS_SFU_PRIVATE_CIDR} rather than opened to everyone, as of #127:
208
+ * `cloudflared` only ever dials out, so signalling reaches the SFU over the
209
+ * tunnel, and the one thing left that still needs 7880 directly is the calls
210
+ * service, from the host's own private network. `ufw` only ever adds rules,
211
+ * so a VM already carrying PR 125's wide-open `ufw allow 7880/tcp` keeps it
212
+ * reachable from the whole internet beside the new scoped one unless
213
+ * something removes it first -- {@link hasUnscopedSignallingRule} reads `ufw
214
+ * status` and this function deletes that rule before adding the scoped
215
+ * replacement, and leaves a VM that never had it untouched.
216
+ *
217
+ * Stops at the first failing step: a `docker compose up -d` that fails
218
+ * leaves the firewall untouched rather than locking a broken deploy behind
219
+ * a narrowed one.
220
+ */
221
+ export async function applyCallsSfu(host, config) {
222
+ requireCallsSfuAddress(config.address);
223
+ requireCloudflaredTunnelToken(config.cloudflaredTunnelToken);
224
+ await host.writeFile(LIVEKIT_COMPOSE_PATH, dockerComposeYaml());
225
+ await host.writeFile(LIVEKIT_CONFIG_PATH, livekitConfigYaml(config));
226
+ await host.writeFile(CLOUDFLARED_ENV_PATH, cloudflaredEnvFile(config.cloudflaredTunnelToken));
227
+ await run(host, `docker compose -f ${LIVEKIT_COMPOSE_PATH} up -d`);
228
+ await run(host, 'ufw allow 22/tcp');
229
+ const status = await host.exec('ufw status');
230
+ if (status.exitCode === 0 && hasUnscopedSignallingRule(status.stdout)) {
231
+ await run(host, `ufw delete allow ${LIVEKIT_SIGNALLING_PORT}/tcp`);
232
+ }
233
+ await run(host, `ufw allow from ${CALLS_SFU_PRIVATE_CIDR} to any port ${LIVEKIT_SIGNALLING_PORT} proto tcp`);
234
+ await run(host, `ufw allow ${LIVEKIT_MEDIA_TCP_PORT}/tcp`);
235
+ await run(host, `ufw allow ${LIVEKIT_MEDIA_UDP_RANGE}/udp`);
236
+ await run(host, 'ufw default deny incoming');
237
+ await run(host, 'ufw default allow outgoing');
238
+ await run(host, 'ufw --force enable');
239
+ }
package/dist/index.d.ts CHANGED
@@ -1,18 +1,21 @@
1
1
  export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, DeclaredOrg, } from './org.js';
2
- export { validateBuyerIdentity, validateHosting, validateOwner } from './org.js';
2
+ export { validateBuyerIdentity, validateHosting, validateOwner, validateSite } from './org.js';
3
3
  export type { SlugRejectionReason, SlugRejection } from './slug.js';
4
- export { validateSlug, RESERVED_SLUGS } from './slug.js';
4
+ export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
5
5
  export type { Checkpoint, LastApplied, AppliedState } from './state.js';
6
6
  export type { PlanStep, UnmanagedItem, Plan } from './plan.js';
7
7
  export type { DeclaredOrgStore } from './store.js';
8
8
  export type { PlanApply } from './apply.js';
9
9
  export { createPostgresDeclaredOrgStore, DeclaredOrgError } from './store/postgres.js';
10
+ export { createPostgresAuditLog } from './store/audit.js';
10
11
  export type { FoundryStoreDb } from './store/postgres.js';
11
- export { declaredOrgs, foundryAppliedState, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, } from './store/schema.js';
12
+ export { declaredOrgs, foundryAppliedState, foundryAuditEvents, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, } from './store/schema.js';
12
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';
13
14
  export { readLiveResources, computePlanDetail, toPlan, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, } from './plan/index.js';
14
15
  export type { CoolifyPort, CloudflarePort, ZitadelPort, FilesOrgLimits, AiOrgLimits, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, } from './plan/index.js';
15
- export { FoundryStepExecutor, NotBuiltError, MissingBespokeAppRepoError } from './plan/index.js';
16
+ export { FoundryStepExecutor, NotBuiltError, healthUrls } from './plan/index.js';
17
+ export type { MigrateSql, MigrationResult } from './migrate.js';
18
+ export { MIGRATIONS_DIR, migrate } from './migrate.js';
16
19
  export type { RunnerDb } from './runner/db.js';
17
20
  export { AlreadyRunningError, RunNotFoundError, StepFailedError, StepMismatchError, } from './runner/errors.js';
18
21
  export { createJobRunner } from './runner/runner.js';
package/dist/index.js CHANGED
@@ -1,8 +1,10 @@
1
- export { validateBuyerIdentity, validateHosting, validateOwner } from './org.js';
2
- export { validateSlug, RESERVED_SLUGS } from './slug.js';
1
+ export { validateBuyerIdentity, validateHosting, validateOwner, validateSite } from './org.js';
2
+ export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
3
3
  export { createPostgresDeclaredOrgStore, DeclaredOrgError } from './store/postgres.js';
4
- export { declaredOrgs, foundryAppliedState, } from './store/schema.js';
4
+ export { createPostgresAuditLog } from './store/audit.js';
5
+ export { declaredOrgs, foundryAppliedState, foundryAuditEvents, } from './store/schema.js';
5
6
  export { readLiveResources, computePlanDetail, toPlan, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, } from './plan/index.js';
6
- export { FoundryStepExecutor, NotBuiltError, MissingBespokeAppRepoError } from './plan/index.js';
7
+ export { FoundryStepExecutor, NotBuiltError, healthUrls } from './plan/index.js';
8
+ export { MIGRATIONS_DIR, migrate } from './migrate.js';
7
9
  export { AlreadyRunningError, RunNotFoundError, StepFailedError, StepMismatchError, } from './runner/errors.js';
8
10
  export { createJobRunner } from './runner/runner.js';
@@ -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>;