multistreaming: scenes/composition, installer build support, service updates

- multistreaming (new): RTMP ingest + multi-platform fan-out with pluggable providers (Twitch/YouTube/Kick/custom), zero-knowledge key vaults, Authelia OIDC auth, shared rooms with editor/streamer roles, single-use invites, per-account streaming grants, and scenes & composition (grid/PiP layouts, text/image overlays, per-output audio routing).
- installer: support Dockerfile build in metadata (not just image) and RSA key generation for the Authelia OIDC JWKS.
- authelia: add OIDC provider with portainer + multistreaming clients (public + PKCE).
- services: remove allprox; add nginx-proxy-manager and portainer; update lldap; regenerate catalog.
This commit is contained in:
Ezequiel C. 2026-09-02 21:18:25 +02:00
parent bb754cdd8c
commit 187379de4e
106 changed files with 21391 additions and 286 deletions

View file

@ -0,0 +1,151 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { Store } = require('../src/store');
const { Auth } = require('../src/auth');
const { Grants } = require('../src/grants');
function tmpdir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'ms-test-'));
}
function fresh() {
const store = new Store(tmpdir());
const auth = new Auth({ store, sessionSecret: 'test-secret' });
const grants = new Grants({ ttlMs: 1000 });
return { store, auth, grants };
}
test('register/login issues a session tied to the user', () => {
const { store, auth } = fresh();
const u = auth.register({ username: 'alice', password: 'pw' });
assert.ok(u);
assert.equal(auth.verifyPassword('pw', store.findUserByUsername('alice')), true);
assert.equal(auth.verifyPassword('nope', store.findUserByUsername('alice')), false);
const token = auth.issueToken(u.id);
const sess = auth.verifyToken(token);
assert.equal(sess.uid, u.id);
assert.ok(sess.sid);
assert.equal(auth.verifyToken('garbage.token'), null);
});
test('rooms: owner role, invite accept, editor/streamer roles', () => {
const { store, auth } = fresh();
const alice = auth.register({ username: 'alice', password: 'pw' });
const bob = auth.register({ username: 'bob', password: 'pw' });
const room = store.createRoom({ name: 'Collab', ownerId: alice.id });
assert.equal(store.membership(room.id, alice.id).role, 'owner');
const invite = store.createInvite({ roomId: room.id, role: 'editor' });
const m = store.acceptInvite(invite, bob.id);
assert.equal(m.role, 'editor');
// bob is now in the room; a second invite for streamer upgrades nothing (already member).
assert.equal(store.roomsFor(bob.id).length, 1);
assert.equal(store.findInviteByToken(invite.token), null); // consumed
});
test('accounts store only ciphertext; owners own them', () => {
const { store } = fresh();
const alice = store.createUser({ username: 'alice', passwordHash: 'x', passwordSalt: 'y' });
const bob = store.createUser({ username: 'bob', passwordHash: 'x', passwordSalt: 'y' });
const vault = store.createVault({ ownerId: alice.id, name: 'Main', salt: 's', serverWrapped: { iv: 'i', data: 'd' } });
const acct = store.createAccount({
ownerId: alice.id,
vaultId: vault.id,
provider: 'twitch',
name: 'Twitch main',
url: 'rtmp://live.twitch.tv/app',
secretCiphertext: { iv: 'a', data: 'b' },
});
assert.equal(store.findAccount(acct.id).secretCiphertext.iv, 'a');
assert.equal(store.findAccount(acct.id).vaultId, vault.id);
assert.equal(store.listAccountsFor(alice.id).length, 1);
assert.equal(store.listAccountsFor(bob.id).length, 0);
// Server-side state never has the plaintext key (it was never passed in).
const raw = JSON.stringify(store.state);
assert.ok(!raw.includes('sk_plaintext'));
});
test('grants: grant → get → revoke → expiry', async () => {
const { grants } = fresh();
grants.grant('acct-1', 'sk_abc', 'alice');
assert.equal(grants.get('acct-1'), 'sk_abc');
assert.deepEqual(grants.grantedAccountIds(), ['acct-1']);
grants.revoke('acct-1');
assert.equal(grants.get('acct-1'), null);
grants.grant('acct-2', 'sk_def', 'alice');
await new Promise((r) => setTimeout(r, 1100));
assert.equal(grants.get('acct-2'), null); // expired
assert.deepEqual(grants.grantedAccountIds(), []);
// revokeBy clears only that user's grants.
grants.grant('a', '1', 'alice');
grants.grant('b', '2', 'bob');
grants.revokeBy('alice');
assert.equal(grants.get('a'), null);
assert.equal(grants.get('b'), '2');
});
test('feed stream keys are unique and lookable', () => {
const { store } = fresh();
const u = store.createUser({ username: 'a', passwordHash: 'x', passwordSalt: 'y' });
const room = store.createRoom({ name: 'R', ownerId: u.id });
const f1 = store.createFeed({ roomId: room.id, ownerId: u.id, name: 'Cam A' });
const f2 = store.createFeed({ roomId: room.id, ownerId: u.id, name: 'Cam B' });
assert.notEqual(f1.streamKey, f2.streamKey);
assert.equal(store.findFeedByStreamKey(f1.streamKey).id, f1.id);
});
test('account ciphertext is only on the owner row (data-level isolation)', () => {
const { store } = fresh();
const alice = store.createUser({ username: 'a', passwordHash: 'x', passwordSalt: 'y' });
const bob = store.createUser({ username: 'b', passwordHash: 'x', passwordSalt: 'y' });
const vault = store.createVault({ ownerId: alice.id, name: 'V', salt: 's', serverWrapped: { iv: 'i', data: 'd' } });
store.createAccount({ ownerId: alice.id, vaultId: vault.id, provider: 'twitch', name: 'T', url: 'u', secretCiphertext: { iv: 'x', data: 'y' } });
// Only alice's account list has a row; bob's is empty, so there is no path
// to alice's ciphertext from bob's session.
assert.equal(store.listAccountsFor(alice.id).length, 1);
assert.equal(store.listAccountsFor(bob.id).length, 0);
// And the ciphertext is never stored as plaintext anywhere.
assert.ok(!JSON.stringify(store.state).includes('sk_secret'));
});
test('vaults: multiple per user, one default, removable', () => {
const { store } = fresh();
const alice = store.createUser({ username: 'a', passwordHash: 'x', passwordSalt: 'y' });
const v1 = store.createVault({ ownerId: alice.id, name: 'Personal', salt: 's1', serverWrapped: { iv: 'i', data: 'd' } });
const v2 = store.createVault({ ownerId: alice.id, name: 'Work', salt: 's2', serverWrapped: { iv: 'i', data: 'd' } });
assert.equal(store.listVaultsFor(alice.id).length, 2);
// First vault becomes the default automatically.
assert.equal(store.findUserById(alice.id).defaultVaultId, v1.id);
// Explicit default switch.
store.setDefaultVault(alice.id, v2.id);
assert.equal(store.findUserById(alice.id).defaultVaultId, v2.id);
// Removing a vault detaches its accounts (ciphertext becomes unusable).
const acct = store.createAccount({ ownerId: alice.id, vaultId: v1.id, provider: 'twitch', name: 'T', url: 'u', secretCiphertext: { iv: 'x', data: 'y' } });
store.removeVault(v1.id);
assert.equal(store.listVaultsFor(alice.id).length, 1);
assert.equal(store.findAccount(acct.id).vaultId, null);
// Can't set someone else's vault as default.
const mallory = store.createUser({ username: 'm', passwordHash: 'x', passwordSalt: 'y' });
assert.equal(store.setDefaultVault(mallory.id, v2.id), null);
});