@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.
- package/dist/calls-sfu-deploy-support.d.ts +57 -0
- package/dist/calls-sfu-deploy-support.js +82 -0
- package/dist/calls-sfu.d.ts +175 -0
- package/dist/calls-sfu.js +239 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.js +6 -4
- package/dist/migrate.d.ts +38 -0
- package/dist/migrate.js +47 -0
- package/dist/migrations/0012_audit.sql +21 -0
- package/dist/migrations/0013_access_emails.sql +6 -0
- package/dist/org.d.ts +23 -0
- package/dist/org.js +37 -0
- package/dist/plan/diff.js +136 -5
- package/dist/plan/executor.d.ts +363 -14
- package/dist/plan/executor.js +759 -24
- package/dist/plan/index.d.ts +2 -2
- package/dist/plan/index.js +2 -2
- package/dist/plan/plan-apply.js +17 -2
- package/dist/plan/providers-readers.d.ts +54 -1
- package/dist/plan/providers-readers.js +153 -1
- package/dist/provision.d.ts +56 -0
- package/dist/provision.js +69 -0
- package/dist/slug.d.ts +12 -18
- package/dist/slug.js +66 -0
- package/dist/store/audit.d.ts +8 -0
- package/dist/store/audit.js +43 -0
- package/dist/store/postgres.js +20 -1
- package/dist/store/schema.d.ts +188 -0
- package/dist/store/schema.js +14 -1
- package/dist/store.d.ts +2 -0
- package/package.json +9 -3
|
@@ -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,
|
|
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 {
|
|
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,
|
|
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>;
|