@wtfalch/foundry 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +111 -0
  2. package/dist/bootstrap/bootstrap.js +323 -0
  3. package/dist/bootstrap/declare.d.ts +36 -0
  4. package/dist/bootstrap/declare.js +99 -0
  5. package/dist/bootstrap/drive.d.ts +40 -0
  6. package/dist/bootstrap/drive.js +51 -0
  7. package/dist/bootstrap/index.d.ts +3 -0
  8. package/dist/bootstrap/index.js +1 -0
  9. package/dist/calls-sfu-deploy-support.d.ts +17 -6
  10. package/dist/calls-sfu-deploy-support.js +19 -7
  11. package/dist/calls-sfu-keys.d.ts +21 -0
  12. package/dist/calls-sfu-keys.js +27 -0
  13. package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
  14. package/dist/calls-sfu-ssh-adapter.js +78 -0
  15. package/dist/calls-sfu-ssh-client.d.ts +49 -0
  16. package/dist/calls-sfu-ssh-client.js +82 -0
  17. package/dist/calls-sfu.d.ts +36 -12
  18. package/dist/calls-sfu.js +41 -9
  19. package/dist/index.d.ts +38 -7
  20. package/dist/index.js +20 -4
  21. package/dist/migrations/0016_platform.sql +61 -0
  22. package/dist/migrations/0017_requested_changes.sql +42 -0
  23. package/dist/migrations/0018_platform_apply.sql +19 -0
  24. package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
  25. package/dist/migrations/0020_mail_apps.sql +7 -0
  26. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  27. package/dist/org.d.ts +81 -0
  28. package/dist/org.js +96 -0
  29. package/dist/plan/agora.d.ts +77 -0
  30. package/dist/plan/agora.js +127 -0
  31. package/dist/plan/cloudflared-connector.d.ts +70 -0
  32. package/dist/plan/cloudflared-connector.js +106 -0
  33. package/dist/plan/diff.d.ts +20 -3
  34. package/dist/plan/diff.js +713 -115
  35. package/dist/plan/executor.d.ts +680 -172
  36. package/dist/plan/executor.js +755 -189
  37. package/dist/plan/index.d.ts +28 -5
  38. package/dist/plan/index.js +15 -2
  39. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  40. package/dist/plan/jobs-scheduler-http.js +50 -0
  41. package/dist/plan/live.d.ts +51 -2
  42. package/dist/plan/live.js +8 -0
  43. package/dist/plan/mail-copy.d.ts +125 -0
  44. package/dist/plan/mail-copy.js +166 -0
  45. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  46. package/dist/plan/mail-identity-allowlist.js +11 -0
  47. package/dist/plan/mail-identity.d.ts +68 -0
  48. package/dist/plan/mail-identity.js +17 -0
  49. package/dist/plan/plan-apply.d.ts +197 -3
  50. package/dist/plan/plan-apply.js +278 -9
  51. package/dist/plan/platform-diff.d.ts +487 -0
  52. package/dist/plan/platform-diff.js +1220 -0
  53. package/dist/plan/platform-org-ports.d.ts +50 -0
  54. package/dist/plan/platform-org-ports.js +70 -0
  55. package/dist/plan/platform-org.d.ts +121 -0
  56. package/dist/plan/platform-org.js +115 -0
  57. package/dist/plan/platform-providers.d.ts +615 -0
  58. package/dist/plan/platform-providers.js +4410 -0
  59. package/dist/plan/platform-sfu.d.ts +128 -0
  60. package/dist/plan/platform-sfu.js +142 -0
  61. package/dist/plan/providers-readers.js +22 -1
  62. package/dist/plan/zitadel-machine-user.d.ts +83 -0
  63. package/dist/plan/zitadel-machine-user.js +238 -0
  64. package/dist/plan.d.ts +6 -0
  65. package/dist/platform.d.ts +55 -0
  66. package/dist/platform.js +46 -0
  67. package/dist/requested-changes.d.ts +118 -0
  68. package/dist/requested-changes.js +125 -0
  69. package/dist/slug.js +15 -2
  70. package/dist/store/audit.js +4 -2
  71. package/dist/store/postgres.d.ts +29 -1
  72. package/dist/store/postgres.js +203 -4
  73. package/dist/store/schema.d.ts +755 -0
  74. package/dist/store/schema.js +70 -0
  75. package/dist/store.d.ts +53 -0
  76. package/package.json +1 -1
@@ -1,10 +1,13 @@
1
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
2
+ * Pure decision logic for the calls SFU's SSH/SFTP deploy, split out so it is
3
+ * unit-tested by vitest against `src/**` -- before `pnpm build` produces
4
+ * `dist` -- the same way `calls-sfu.ts` is, rather than only by hand against
5
+ * a real VM. `calls-sfu-ssh-adapter.ts` applies every export here to a
6
+ * {@link CallsSfuSshSession} (`toCallsSfuHostPort`/`applyCallsSfuOverSession`)
7
+ * -- the one place both `scripts/calls-sfu-deploy.mjs` (#122) and the
8
+ * platform `sfu` step (`plan/platform-sfu.ts`, #170) use it. The real SSH
9
+ * session -- `ssh2`'s `Client`/`sftp`/`exec` wiring -- stays untested, same
10
+ * as `foundry-db-provision.mjs`/`foundry-db-migrate.mjs`: real I/O, no branch
8
11
  * logic worth a fake session over.
9
12
  *
10
13
  * Each export here answers one finding from PR #125's review (#122):
@@ -35,6 +38,14 @@ export declare function verifyHostKey(rawKey: Buffer, expectedFingerprint: strin
35
38
  * this script writes holds no secret. Returns the SFTP file mode to write
36
39
  * with, or `undefined` for anything else (the SFTP server's own default).
37
40
  * PR #125's review, finding 2; extended for #127's second secret file.
41
+ *
42
+ * A trailing `.tmp` is stripped before comparing: `calls-sfu.ts`'s
43
+ * `applyCallsSfu` writes `livekit.yaml` to a `.tmp` path first and renames
44
+ * it into place (#152 round 2 review, finding 3 -- a temp file plus an
45
+ * atomic rename, rather than overwriting the live config two concurrent
46
+ * applies could otherwise tear). The temp file holds the same secret the
47
+ * final path does, so it gets the same 0600 mode; `mv` preserves it across
48
+ * the rename.
38
49
  */
39
50
  export declare function fileWriteMode(path: string): number | undefined;
40
51
  /**
@@ -1,10 +1,13 @@
1
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
2
+ * Pure decision logic for the calls SFU's SSH/SFTP deploy, split out so it is
3
+ * unit-tested by vitest against `src/**` -- before `pnpm build` produces
4
+ * `dist` -- the same way `calls-sfu.ts` is, rather than only by hand against
5
+ * a real VM. `calls-sfu-ssh-adapter.ts` applies every export here to a
6
+ * {@link CallsSfuSshSession} (`toCallsSfuHostPort`/`applyCallsSfuOverSession`)
7
+ * -- the one place both `scripts/calls-sfu-deploy.mjs` (#122) and the
8
+ * platform `sfu` step (`plan/platform-sfu.ts`, #170) use it. The real SSH
9
+ * session -- `ssh2`'s `Client`/`sftp`/`exec` wiring -- stays untested, same
10
+ * as `foundry-db-provision.mjs`/`foundry-db-migrate.mjs`: real I/O, no branch
8
11
  * logic worth a fake session over.
9
12
  *
10
13
  * Each export here answers one finding from PR #125's review (#122):
@@ -50,9 +53,18 @@ export function verifyHostKey(rawKey, expectedFingerprint) {
50
53
  * this script writes holds no secret. Returns the SFTP file mode to write
51
54
  * with, or `undefined` for anything else (the SFTP server's own default).
52
55
  * PR #125's review, finding 2; extended for #127's second secret file.
56
+ *
57
+ * A trailing `.tmp` is stripped before comparing: `calls-sfu.ts`'s
58
+ * `applyCallsSfu` writes `livekit.yaml` to a `.tmp` path first and renames
59
+ * it into place (#152 round 2 review, finding 3 -- a temp file plus an
60
+ * atomic rename, rather than overwriting the live config two concurrent
61
+ * applies could otherwise tear). The temp file holds the same secret the
62
+ * final path does, so it gets the same 0600 mode; `mv` preserves it across
63
+ * the rename.
53
64
  */
54
65
  export function fileWriteMode(path) {
55
- return path === LIVEKIT_CONFIG_PATH || path === CLOUDFLARED_ENV_PATH ? 0o600 : undefined;
66
+ const target = path.endsWith('.tmp') ? path.slice(0, -'.tmp'.length) : path;
67
+ return target === LIVEKIT_CONFIG_PATH || target === CLOUDFLARED_ENV_PATH ? 0o600 : undefined;
56
68
  }
57
69
  // ---------------------------------------------------------------------------
58
70
  // 3. Preflight
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Mints the LiveKit API key/secret pair the shared calls SFU signs tokens
3
+ * with (#170). Before this, `scripts/calls-sfu-deploy.mjs` read
4
+ * `LIVEKIT_API_KEY`/`LIVEKIT_API_SECRET` from the environment -- a human
5
+ * minted the pair by hand and put it in the secrets store. The platform
6
+ * `sfu` step (`plan/platform-sfu.ts`) mints it instead, once, and delivers it
7
+ * to held keys through `CallsSfuKeysPort.deliverLivekitKeys`; every apply
8
+ * after that reads the same pair back through `resolveLivekitKeys` rather
9
+ * than minting a second one.
10
+ *
11
+ * The shape matches LiveKit's own self-hosting docs: an `API`-prefixed key
12
+ * id and a long random secret, both plain alphanumeric so they drop straight
13
+ * into `livekit.yaml`'s `keys:` map (`calls-sfu.ts`'s `livekitConfigYaml`)
14
+ * with no escaping to worry about.
15
+ */
16
+ export interface LivekitCredentials {
17
+ apiKey: string;
18
+ apiSecret: string;
19
+ }
20
+ /** A fresh LiveKit API key/secret pair, minted with `node:crypto`'s CSPRNG. */
21
+ export declare function mintLivekitCredentials(): LivekitCredentials;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Mints the LiveKit API key/secret pair the shared calls SFU signs tokens
3
+ * with (#170). Before this, `scripts/calls-sfu-deploy.mjs` read
4
+ * `LIVEKIT_API_KEY`/`LIVEKIT_API_SECRET` from the environment -- a human
5
+ * minted the pair by hand and put it in the secrets store. The platform
6
+ * `sfu` step (`plan/platform-sfu.ts`) mints it instead, once, and delivers it
7
+ * to held keys through `CallsSfuKeysPort.deliverLivekitKeys`; every apply
8
+ * after that reads the same pair back through `resolveLivekitKeys` rather
9
+ * than minting a second one.
10
+ *
11
+ * The shape matches LiveKit's own self-hosting docs: an `API`-prefixed key
12
+ * id and a long random secret, both plain alphanumeric so they drop straight
13
+ * into `livekit.yaml`'s `keys:` map (`calls-sfu.ts`'s `livekitConfigYaml`)
14
+ * with no escaping to worry about.
15
+ */
16
+ import { randomBytes } from 'node:crypto';
17
+ const ALPHANUMERIC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
18
+ function randomAlphanumeric(length) {
19
+ return Array.from(randomBytes(length), (byte) => ALPHANUMERIC[byte % ALPHANUMERIC.length]).join('');
20
+ }
21
+ /** A fresh LiveKit API key/secret pair, minted with `node:crypto`'s CSPRNG. */
22
+ export function mintLivekitCredentials() {
23
+ return {
24
+ apiKey: `API${randomAlphanumeric(12)}`,
25
+ apiSecret: randomAlphanumeric(40),
26
+ };
27
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Turns one open SSH session into the `CallsSfuHostPort` {@link applyCallsSfu}
3
+ * needs, applying `calls-sfu-deploy-support.ts`'s decision logic
4
+ * (`verifyHostKey`, `fileWriteMode`, the preflight check) exactly once so
5
+ * `scripts/calls-sfu-deploy.mjs` and the platform `sfu` step
6
+ * (`plan/platform-sfu.ts`, #170) share it rather than each re-deriving it.
7
+ *
8
+ * {@link CallsSfuSshSession} is deliberately narrow -- the host key an
9
+ * already-open session presented, a way to run a command, a way to write a
10
+ * file at a chosen mode -- so a test hands in a fake session and proves the
11
+ * host-key refusal and the 0600 file mode without ever opening a socket.
12
+ * `calls-sfu-ssh-client.ts`'s `connectCallsSfuSsh` is the one real
13
+ * implementation, over `ssh2`, and stays untested for the same reason
14
+ * `foundry-db-provision.mjs`/`foundry-db-migrate.mjs` do: real I/O, no
15
+ * branch logic worth a fake session over.
16
+ */
17
+ import { type CallsSfuConfig, type CallsSfuHostPort } from './calls-sfu.js';
18
+ export declare class UnknownCallsSfuHostKeyError extends Error {
19
+ constructor();
20
+ }
21
+ /**
22
+ * One already-open SSH session, narrow enough to fake in a test: the raw
23
+ * host key it presented during the handshake, a way to run one command, and
24
+ * a way to write a file at a chosen mode (`undefined` for the SFTP server's
25
+ * own default, same as {@link CallsSfuHostPort.writeFile} today).
26
+ */
27
+ export interface CallsSfuSshSession {
28
+ hostKey: Buffer;
29
+ exec(command: string): Promise<{
30
+ exitCode: number;
31
+ stdout: string;
32
+ stderr: string;
33
+ }>;
34
+ writeFile(path: string, contents: string, mode: number | undefined): Promise<void>;
35
+ }
36
+ /**
37
+ * Refuses with {@link UnknownCallsSfuHostKeyError} before adapting anything
38
+ * when `session.hostKey` does not match `expectedFingerprint` -- checked
39
+ * here, in code a fake session exercises directly, rather than only inside
40
+ * `ssh2`'s own `hostVerifier` (PR #125's review, finding 1). Otherwise
41
+ * returns a {@link CallsSfuHostPort} that writes each file at
42
+ * {@link fileWriteMode}'s mode (finding 2) and runs commands as-is.
43
+ */
44
+ export declare function toCallsSfuHostPort(session: CallsSfuSshSession, expectedFingerprint: string | undefined): CallsSfuHostPort;
45
+ /**
46
+ * The whole deploy, over one already-open session: refuse an unknown host
47
+ * key first (before any command runs), check `docker`/`ufw` are installed
48
+ * (PR #125's review, finding 3), make sure `/opt/livekit` exists, then hand
49
+ * off to {@link applyCallsSfu} -- the same order
50
+ * `scripts/calls-sfu-deploy.mjs` ran by hand, now shared with the platform
51
+ * `sfu` step. Every step here is safe to re-run, the same "safe to re-run"
52
+ * {@link applyCallsSfu} itself promises, so calling this twice with the same
53
+ * `session`/`config` changes nothing on the second call.
54
+ */
55
+ export declare function applyCallsSfuOverSession(session: CallsSfuSshSession, config: CallsSfuConfig, expectedFingerprint: string | undefined): Promise<void>;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Turns one open SSH session into the `CallsSfuHostPort` {@link applyCallsSfu}
3
+ * needs, applying `calls-sfu-deploy-support.ts`'s decision logic
4
+ * (`verifyHostKey`, `fileWriteMode`, the preflight check) exactly once so
5
+ * `scripts/calls-sfu-deploy.mjs` and the platform `sfu` step
6
+ * (`plan/platform-sfu.ts`, #170) share it rather than each re-deriving it.
7
+ *
8
+ * {@link CallsSfuSshSession} is deliberately narrow -- the host key an
9
+ * already-open session presented, a way to run a command, a way to write a
10
+ * file at a chosen mode -- so a test hands in a fake session and proves the
11
+ * host-key refusal and the 0600 file mode without ever opening a socket.
12
+ * `calls-sfu-ssh-client.ts`'s `connectCallsSfuSsh` is the one real
13
+ * implementation, over `ssh2`, and stays untested for the same reason
14
+ * `foundry-db-provision.mjs`/`foundry-db-migrate.mjs` do: real I/O, no
15
+ * branch logic worth a fake session over.
16
+ */
17
+ import { dirname } from 'node:path';
18
+ import { PREFLIGHT_COMMAND, fileWriteMode, preflightFailureMessage, verifyHostKey, } from './calls-sfu-deploy-support.js';
19
+ import { LIVEKIT_COMPOSE_PATH, LIVEKIT_CONFIG_PATH, applyCallsSfu, livekitConfigYaml, } from './calls-sfu.js';
20
+ export class UnknownCallsSfuHostKeyError extends Error {
21
+ constructor() {
22
+ super('The calls SFU host key did not match the fingerprint held for it; refusing to ' +
23
+ 'deploy. See calls-sfu-deploy-support.ts’s verifyHostKey (PR #125) -- the ' +
24
+ 'expected fingerprint comes from held keys (#170’s "Human steps").');
25
+ this.name = 'UnknownCallsSfuHostKeyError';
26
+ }
27
+ }
28
+ /**
29
+ * Refuses with {@link UnknownCallsSfuHostKeyError} before adapting anything
30
+ * when `session.hostKey` does not match `expectedFingerprint` -- checked
31
+ * here, in code a fake session exercises directly, rather than only inside
32
+ * `ssh2`'s own `hostVerifier` (PR #125's review, finding 1). Otherwise
33
+ * returns a {@link CallsSfuHostPort} that writes each file at
34
+ * {@link fileWriteMode}'s mode (finding 2) and runs commands as-is.
35
+ */
36
+ export function toCallsSfuHostPort(session, expectedFingerprint) {
37
+ if (!verifyHostKey(session.hostKey, expectedFingerprint)) {
38
+ throw new UnknownCallsSfuHostKeyError();
39
+ }
40
+ return {
41
+ writeFile: (path, contents) => session.writeFile(path, contents, fileWriteMode(path)),
42
+ exec: (command) => session.exec(command),
43
+ };
44
+ }
45
+ /**
46
+ * The whole deploy, over one already-open session: refuse an unknown host
47
+ * key first (before any command runs), check `docker`/`ufw` are installed
48
+ * (PR #125's review, finding 3), make sure `/opt/livekit` exists, then hand
49
+ * off to {@link applyCallsSfu} -- the same order
50
+ * `scripts/calls-sfu-deploy.mjs` ran by hand, now shared with the platform
51
+ * `sfu` step. Every step here is safe to re-run, the same "safe to re-run"
52
+ * {@link applyCallsSfu} itself promises, so calling this twice with the same
53
+ * `session`/`config` changes nothing on the second call.
54
+ */
55
+ export async function applyCallsSfuOverSession(session, config, expectedFingerprint) {
56
+ const host = toCallsSfuHostPort(session, expectedFingerprint);
57
+ const preflight = await host.exec(PREFLIGHT_COMMAND);
58
+ if (preflight.exitCode !== 0) {
59
+ throw new Error(preflightFailureMessage(preflight));
60
+ }
61
+ const mkdir = await host.exec(`mkdir -p ${dirname(LIVEKIT_COMPOSE_PATH)}`);
62
+ if (mkdir.exitCode !== 0) {
63
+ throw new Error(`mkdir -p failed: ${mkdir.stderr || mkdir.stdout}`);
64
+ }
65
+ // `docker compose up -d` leaves a running container alone when the compose
66
+ // file is unchanged, so LiveKit would keep the old keys in memory after a
67
+ // new `livekit.yaml` lands. Read the config before it is overwritten and
68
+ // restart LiveKit afterwards if the keys (or anything else in it) changed.
69
+ const previous = await host.exec(`cat ${LIVEKIT_CONFIG_PATH}`);
70
+ const configChanged = previous.exitCode === 0 && previous.stdout !== livekitConfigYaml(config);
71
+ await applyCallsSfu(host, config);
72
+ if (configChanged) {
73
+ const restart = await host.exec(`docker compose -f ${LIVEKIT_COMPOSE_PATH} restart livekit`);
74
+ if (restart.exitCode !== 0) {
75
+ throw new Error(`restarting livekit failed: ${restart.stderr || restart.stdout}`);
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The real {@link CallsSfuSshConnector} (`plan/platform-sfu.ts`), over `ssh2`:
3
+ * the same `connect()`/`exec`/SFTP wiring `scripts/calls-sfu-deploy.mjs` uses
4
+ * by hand. A host (archon) passes {@link connectCallsSfuSsh} as the `ssh` dep
5
+ * of `CallsSfuPlatformDeps`.
6
+ *
7
+ * The host key is verified inside `ssh2`'s own `hostVerifier`, which returns
8
+ * `false` on a mismatch or a missing fingerprint so the handshake is refused
9
+ * before any authentication happens and the private key is never offered to
10
+ * an unknown host. `applyCallsSfuOverSession` checks the captured key again.
11
+ *
12
+ * `ssh2` is loaded lazily and the `Client` class can be injected, so a test
13
+ * hands in a fake and never opens a socket.
14
+ */
15
+ import type { CallsSfuSshSession } from './calls-sfu-ssh-adapter.js';
16
+ interface SshStream {
17
+ on(event: string, listener: (...args: never[]) => void): SshStream;
18
+ stderr: {
19
+ on(event: string, listener: (...args: never[]) => void): unknown;
20
+ };
21
+ }
22
+ interface SshSftp {
23
+ writeFile(path: string, contents: string, options: {
24
+ mode: number;
25
+ } | undefined, cb: (err?: Error | null) => void): void;
26
+ chmod(path: string, mode: number, cb: (err?: Error | null) => void): void;
27
+ }
28
+ interface SshClient {
29
+ on(event: string, listener: (...args: never[]) => void): SshClient;
30
+ connect(options: Record<string, unknown>): void;
31
+ exec(command: string, cb: (err: Error | undefined, stream: SshStream) => void): void;
32
+ sftp(cb: (err: Error | undefined, sftp: SshSftp) => void): void;
33
+ end(): void;
34
+ }
35
+ export type SshClientConstructor = new () => SshClient;
36
+ export interface CallsSfuSshConnectOptions {
37
+ address: string;
38
+ sshUser: string;
39
+ sshPort: number;
40
+ sshPrivateKey: string;
41
+ /** `SHA256:...`. Without it every host key is refused. */
42
+ sshHostFingerprint: string | undefined;
43
+ }
44
+ export declare function connectCallsSfuSsh(options: CallsSfuSshConnectOptions, deps?: {
45
+ Client?: SshClientConstructor;
46
+ }): Promise<CallsSfuSshSession & {
47
+ close(): void;
48
+ }>;
49
+ export {};
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The real {@link CallsSfuSshConnector} (`plan/platform-sfu.ts`), over `ssh2`:
3
+ * the same `connect()`/`exec`/SFTP wiring `scripts/calls-sfu-deploy.mjs` uses
4
+ * by hand. A host (archon) passes {@link connectCallsSfuSsh} as the `ssh` dep
5
+ * of `CallsSfuPlatformDeps`.
6
+ *
7
+ * The host key is verified inside `ssh2`'s own `hostVerifier`, which returns
8
+ * `false` on a mismatch or a missing fingerprint so the handshake is refused
9
+ * before any authentication happens and the private key is never offered to
10
+ * an unknown host. `applyCallsSfuOverSession` checks the captured key again.
11
+ *
12
+ * `ssh2` is loaded lazily and the `Client` class can be injected, so a test
13
+ * hands in a fake and never opens a socket.
14
+ */
15
+ import { verifyHostKey } from './calls-sfu-deploy-support.js';
16
+ function sshExec(conn, command) {
17
+ return new Promise((resolve, reject) => {
18
+ conn.exec(command, (err, stream) => {
19
+ if (err)
20
+ return reject(err);
21
+ let stdout = '';
22
+ let stderr = '';
23
+ stream.on('close', (code) => resolve({ exitCode: code ?? 0, stdout, stderr }));
24
+ stream.on('data', (chunk) => {
25
+ stdout += chunk;
26
+ });
27
+ stream.stderr.on('data', (chunk) => {
28
+ stderr += chunk;
29
+ });
30
+ });
31
+ });
32
+ }
33
+ function sshWriteFile(conn, path, contents, mode) {
34
+ return new Promise((resolve, reject) => {
35
+ conn.sftp((err, sftp) => {
36
+ if (err)
37
+ return reject(err);
38
+ sftp.writeFile(path, contents, mode ? { mode } : undefined, (writeErr) => {
39
+ if (writeErr)
40
+ return reject(writeErr);
41
+ // `mode` on writeFile only applies when the file is created; chmod
42
+ // repairs a file that already existed with looser permissions.
43
+ if (!mode)
44
+ return resolve();
45
+ sftp.chmod(path, mode, (chmodErr) => (chmodErr ? reject(chmodErr) : resolve()));
46
+ });
47
+ });
48
+ });
49
+ }
50
+ export async function connectCallsSfuSsh(options, deps = {}) {
51
+ const Client = deps.Client ?? (await loadSsh2()).Client;
52
+ return new Promise((resolve, reject) => {
53
+ const conn = new Client();
54
+ let hostKey = Buffer.alloc(0);
55
+ conn.on('ready', () => resolve({
56
+ hostKey,
57
+ exec: (command) => sshExec(conn, command),
58
+ writeFile: (path, contents, mode) => sshWriteFile(conn, path, contents, mode),
59
+ close: () => conn.end(),
60
+ }));
61
+ conn.on('error', reject);
62
+ conn.connect({
63
+ host: options.address,
64
+ port: options.sshPort,
65
+ username: options.sshUser,
66
+ privateKey: options.sshPrivateKey,
67
+ // One argument: ssh2 treats the return value as the verdict.
68
+ hostVerifier: (key) => {
69
+ hostKey = key;
70
+ return verifyHostKey(key, options.sshHostFingerprint);
71
+ },
72
+ });
73
+ });
74
+ }
75
+ async function loadSsh2() {
76
+ const specifier = 'ssh2';
77
+ const mod = (await import(specifier));
78
+ const Client = mod.Client ?? mod.default?.Client;
79
+ if (!Client)
80
+ throw new Error('ssh2 did not export a Client');
81
+ return { Client };
82
+ }
@@ -17,16 +17,25 @@
17
17
  * specifically to keep Coolify's own firewall script (`ADR 0011`) untouched
18
18
  * (calls#15). {@link CallsSfuHostPort} is deliberately narrow -- write a
19
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.
20
+ * (`packages/foundry/scripts/calls-sfu-deploy.mjs`, and, as of #170, the
21
+ * platform's own `sfu:livekit` step, `plan/platform-sfu.ts`), never imported
22
+ * here, and every test in `calls-sfu.test.ts` runs against a fake.
22
23
  *
23
- * The address, the LiveKit API key and its secret are never hardcoded here
24
+ * The address and every LiveKit API key/secret pair are never hardcoded here
24
25
  * ({@link CallsSfuConfig}): PR 344's review on app-template's
25
26
  * `calls-instance` step found its first cut created three Coolify apps
26
27
  * before it ever checked the address was known.
27
28
  * {@link requireCallsSfuAddress} is that check, run first, before either
28
29
  * file is written.
29
30
  *
31
+ * #152: one shared instance, kept apart by *per-company* keys rather than
32
+ * one shared pair -- {@link CallsSfuConfig.livekitKeys} carries every org's
33
+ * own key/secret pair, and {@link livekitConfigYaml} writes all of them into
34
+ * `keys:`. Minting and delivering each org's pair is the platform `sfu`
35
+ * step's job (`plan/platform-sfu.ts`'s `applyCallsSfuPlatformStep`), not
36
+ * this module's -- the same "this package never invents a credential" rule
37
+ * the single-pair design already followed.
38
+ *
30
39
  * #127 adds a second compose service, `cloudflared`, so the SFU has a public
31
40
  * `wss://` address through its own named tunnel -- browsers on an https page
32
41
  * cannot open a plain `ws://` connection, and 7880 was open to the whole
@@ -63,16 +72,24 @@ export declare class MissingCloudflaredTunnelTokenError extends Error {
63
72
  export declare function requireCloudflaredTunnelToken(token: string | undefined | null): string;
64
73
  /**
65
74
  * 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".
75
+ * its own: the SFU's own address (calls#15), one LiveKit API key/secret pair
76
+ * per org with calls enabled (#152 -- a shared instance, kept apart by
77
+ * per-company keys, not a port range or a deployment each), and the token
78
+ * for the named tunnel `scripts/calls-sfu-ingress.mjs` creates. All of it
79
+ * comes from the secrets store, at the caller's own script -- #122's and
80
+ * #127's "Human steps" -- except the per-org pairs, which the platform `sfu`
81
+ * step mints itself (`plan/platform-sfu.ts`).
71
82
  */
72
83
  export interface CallsSfuConfig {
73
84
  address: string;
74
- livekitApiKey: string;
75
- livekitApiSecret: string;
85
+ /**
86
+ * API key id -> secret, one entry per org currently enabled for calls.
87
+ * {@link livekitConfigYaml} writes every entry under `keys:`, sorted by key
88
+ * id so the same set of orgs always serializes to the same bytes -- what
89
+ * `calls-sfu-ssh-adapter.ts`'s `applyCallsSfuOverSession` compares, byte
90
+ * for byte, to decide whether LiveKit needs a restart.
91
+ */
92
+ livekitKeys: Record<string, string>;
76
93
  cloudflaredTunnelToken: string;
77
94
  }
78
95
  /**
@@ -102,8 +119,8 @@ export declare const CLOUDFLARED_ENV_PATH = "/opt/livekit/cloudflared.env";
102
119
  * anywhere a supply-chain review has asked for a digest pin yet.
103
120
  */
104
121
  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";
122
+ /** Pinned to a release tag; equals `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). */
123
+ export declare const CLOUDFLARED_IMAGE_REF = "cloudflare/cloudflared:2026.9.3";
107
124
  /** LiveKit signalling -- reachable only through the tunnel as of #127 (see {@link CALLS_SFU_PRIVATE_CIDR}). */
108
125
  export declare const LIVEKIT_SIGNALLING_PORT = 7880;
109
126
  /** Media fallback for networks that block UDP -- reaches the SFU's own address directly. */
@@ -162,6 +179,13 @@ export declare function cloudflaredEnvFile(token: string): string;
162
179
  * has one public address of its own, bridged directly rather than sitting
163
180
  * behind NAT (calls#15's decision comment), so there is a known-good answer
164
181
  * and no reason to make LiveKit discover it via STUN.
182
+ *
183
+ * `keys:` carries one line per entry in {@link CallsSfuConfig.livekitKeys}
184
+ * (#152), sorted by key id -- never object insertion order, which
185
+ * JavaScript does not even guarantee is the order the platform step built
186
+ * the map in. An org dropped from the map (calls turned off) simply has no
187
+ * line here on the next apply; nothing here deletes a key, it only ever
188
+ * writes the set it was given.
165
189
  */
166
190
  export declare function livekitConfigYaml(config: CallsSfuConfig): string;
167
191
  /**
package/dist/calls-sfu.js CHANGED
@@ -17,16 +17,25 @@
17
17
  * specifically to keep Coolify's own firewall script (`ADR 0011`) untouched
18
18
  * (calls#15). {@link CallsSfuHostPort} is deliberately narrow -- write a
19
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.
20
+ * (`packages/foundry/scripts/calls-sfu-deploy.mjs`, and, as of #170, the
21
+ * platform's own `sfu:livekit` step, `plan/platform-sfu.ts`), never imported
22
+ * here, and every test in `calls-sfu.test.ts` runs against a fake.
22
23
  *
23
- * The address, the LiveKit API key and its secret are never hardcoded here
24
+ * The address and every LiveKit API key/secret pair are never hardcoded here
24
25
  * ({@link CallsSfuConfig}): PR 344's review on app-template's
25
26
  * `calls-instance` step found its first cut created three Coolify apps
26
27
  * before it ever checked the address was known.
27
28
  * {@link requireCallsSfuAddress} is that check, run first, before either
28
29
  * file is written.
29
30
  *
31
+ * #152: one shared instance, kept apart by *per-company* keys rather than
32
+ * one shared pair -- {@link CallsSfuConfig.livekitKeys} carries every org's
33
+ * own key/secret pair, and {@link livekitConfigYaml} writes all of them into
34
+ * `keys:`. Minting and delivering each org's pair is the platform `sfu`
35
+ * step's job (`plan/platform-sfu.ts`'s `applyCallsSfuPlatformStep`), not
36
+ * this module's -- the same "this package never invents a credential" rule
37
+ * the single-pair design already followed.
38
+ *
30
39
  * #127 adds a second compose service, `cloudflared`, so the SFU has a public
31
40
  * `wss://` address through its own named tunnel -- browsers on an https page
32
41
  * cannot open a plain `ws://` connection, and 7880 was open to the whole
@@ -99,8 +108,8 @@ export const CLOUDFLARED_ENV_PATH = '/opt/livekit/cloudflared.env';
99
108
  * anywhere a supply-chain review has asked for a digest pin yet.
100
109
  */
101
110
  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';
111
+ /** Pinned to a release tag; equals `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). */
112
+ export const CLOUDFLARED_IMAGE_REF = 'cloudflare/cloudflared:2026.9.3';
104
113
  /** LiveKit signalling -- reachable only through the tunnel as of #127 (see {@link CALLS_SFU_PRIVATE_CIDR}). */
105
114
  export const LIVEKIT_SIGNALLING_PORT = 7880;
106
115
  /** Media fallback for networks that block UDP -- reaches the SFU's own address directly. */
@@ -190,9 +199,20 @@ export function cloudflaredEnvFile(token) {
190
199
  * has one public address of its own, bridged directly rather than sitting
191
200
  * behind NAT (calls#15's decision comment), so there is a known-good answer
192
201
  * and no reason to make LiveKit discover it via STUN.
202
+ *
203
+ * `keys:` carries one line per entry in {@link CallsSfuConfig.livekitKeys}
204
+ * (#152), sorted by key id -- never object insertion order, which
205
+ * JavaScript does not even guarantee is the order the platform step built
206
+ * the map in. An org dropped from the map (calls turned off) simply has no
207
+ * line here on the next apply; nothing here deletes a key, it only ever
208
+ * writes the set it was given.
193
209
  */
194
210
  export function livekitConfigYaml(config) {
195
211
  const address = requireCallsSfuAddress(config.address);
212
+ const keys = Object.entries(config.livekitKeys).sort(([a], [b]) => a.localeCompare(b));
213
+ const keysYaml = keys.length === 0
214
+ ? 'keys: {}\n'
215
+ : `keys:\n${keys.map(([apiKey, apiSecret]) => ` ${apiKey}: ${apiSecret}`).join('\n')}\n`;
196
216
  return `port: ${LIVEKIT_SIGNALLING_PORT}
197
217
  rtc:
198
218
  tcp_port: ${LIVEKIT_MEDIA_TCP_PORT}
@@ -200,9 +220,7 @@ rtc:
200
220
  port_range_end: 60000
201
221
  use_external_ip: false
202
222
  node_ip: ${address}
203
- keys:
204
- ${config.livekitApiKey}: ${config.livekitApiSecret}
205
- `;
223
+ ${keysYaml}`;
206
224
  }
207
225
  async function run(host, command) {
208
226
  const result = await host.exec(command);
@@ -210,6 +228,20 @@ async function run(host, command) {
210
228
  throw new Error(`"${command}" exited ${result.exitCode}: ${result.stderr || result.stdout}`);
211
229
  }
212
230
  }
231
+ /**
232
+ * Writes `contents` to `${path}.tmp` and renames it onto `path` with `mv`,
233
+ * rather than writing `path` directly -- `mv` within the same directory is a
234
+ * single rename syscall, so a reader of `path` (this function's own
235
+ * `cat`-and-compare in `calls-sfu-ssh-adapter.ts`, or LiveKit's own read at
236
+ * container start) only ever sees the old complete file or the new complete
237
+ * one, never a partial write from one apply interleaved with another's
238
+ * (#152 round 2 review, finding 3).
239
+ */
240
+ async function writeFileAtomically(host, path, contents) {
241
+ const tmpPath = `${path}.tmp`;
242
+ await host.writeFile(tmpPath, contents);
243
+ await run(host, `mv -f ${tmpPath} ${path}`);
244
+ }
213
245
  /**
214
246
  * Writes all three files, brings LiveKit and `cloudflared` up, then narrows
215
247
  * the VM firewall to exactly what #122's and #127's "Decided" sections name
@@ -239,7 +271,7 @@ export async function applyCallsSfu(host, config) {
239
271
  requireCallsSfuAddress(config.address);
240
272
  requireCloudflaredTunnelToken(config.cloudflaredTunnelToken);
241
273
  await host.writeFile(LIVEKIT_COMPOSE_PATH, dockerComposeYaml());
242
- await host.writeFile(LIVEKIT_CONFIG_PATH, livekitConfigYaml(config));
274
+ await writeFileAtomically(host, LIVEKIT_CONFIG_PATH, livekitConfigYaml(config));
243
275
  await host.writeFile(CLOUDFLARED_ENV_PATH, cloudflaredEnvFile(config.cloudflaredTunnelToken));
244
276
  await run(host, `docker compose -f ${LIVEKIT_COMPOSE_PATH} up -d`);
245
277
  await run(host, 'ufw allow 22/tcp');