@volter/twin-livekit 0.1.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/LICENSE +202 -0
- package/README.md +106 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +23 -0
- package/dist/src/egress-service-cli.d.ts +2 -0
- package/dist/src/egress-service-cli.js +101 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +53 -0
- package/dist/src/livekit-budget.d.ts +57 -0
- package/dist/src/livekit-budget.js +131 -0
- package/dist/src/livekit-capabilities.d.ts +3 -0
- package/dist/src/livekit-capabilities.js +728 -0
- package/dist/src/livekit-conformance.d.ts +8 -0
- package/dist/src/livekit-conformance.js +13 -0
- package/dist/src/livekit-connector.d.ts +130 -0
- package/dist/src/livekit-connector.js +382 -0
- package/dist/src/livekit-data.d.ts +86 -0
- package/dist/src/livekit-data.js +246 -0
- package/dist/src/livekit-perform-harness.d.ts +5 -0
- package/dist/src/livekit-perform-harness.js +17 -0
- package/dist/src/livekit-server.d.ts +23 -0
- package/dist/src/livekit-server.js +45 -0
- package/dist/src/livekit-service-cli.d.ts +2 -0
- package/dist/src/livekit-service-cli.js +88 -0
- package/dist/src/livekit-token.d.ts +68 -0
- package/dist/src/livekit-token.js +76 -0
- package/dist/src/livekit-twin.d.ts +4 -0
- package/dist/src/livekit-twin.js +1161 -0
- package/dist/src/livekit-types.d.ts +203 -0
- package/dist/src/livekit-types.js +1 -0
- package/dist/src/livekit-webhook.d.ts +26 -0
- package/dist/src/livekit-webhook.js +79 -0
- package/dist/src/redis-service-cli.d.ts +2 -0
- package/dist/src/redis-service-cli.js +35 -0
- package/dist/test-fixtures/livekit-openapi-operations.SOURCE.md +88 -0
- package/dist/test-fixtures/livekit-openapi-operations.json +442 -0
- package/dist/test-fixtures/protobufs/cloud_replay.proto +82 -0
- package/dist/test-fixtures/protobufs/livekit_agent.proto +185 -0
- package/dist/test-fixtures/protobufs/livekit_agent_dispatch.proto +103 -0
- package/dist/test-fixtures/protobufs/livekit_agent_simulation.proto +422 -0
- package/dist/test-fixtures/protobufs/livekit_agent_worker.proto +29 -0
- package/dist/test-fixtures/protobufs/livekit_agentdb.proto +226 -0
- package/dist/test-fixtures/protobufs/livekit_analytics.proto +312 -0
- package/dist/test-fixtures/protobufs/livekit_cloud_agent.proto +376 -0
- package/dist/test-fixtures/protobufs/livekit_connector.proto +41 -0
- package/dist/test-fixtures/protobufs/livekit_connector_twilio.proto +65 -0
- package/dist/test-fixtures/protobufs/livekit_connector_whatsapp.proto +171 -0
- package/dist/test-fixtures/protobufs/livekit_egress.proto +641 -0
- package/dist/test-fixtures/protobufs/livekit_ingress.proto +222 -0
- package/dist/test-fixtures/protobufs/livekit_internal.proto +228 -0
- package/dist/test-fixtures/protobufs/livekit_metrics.proto +103 -0
- package/dist/test-fixtures/protobufs/livekit_models.proto +992 -0
- package/dist/test-fixtures/protobufs/livekit_phone_number.proto +151 -0
- package/dist/test-fixtures/protobufs/livekit_room.proto +312 -0
- package/dist/test-fixtures/protobufs/livekit_rtc.proto +673 -0
- package/dist/test-fixtures/protobufs/livekit_sip.proto +1000 -0
- package/dist/test-fixtures/protobufs/livekit_token_source.proto +49 -0
- package/dist/test-fixtures/protobufs/livekit_webhook.proto +62 -0
- package/package.json +58 -0
- package/src/cli.ts +22 -0
- package/src/egress-service-cli.ts +111 -0
- package/src/index.ts +103 -0
- package/src/livekit-budget.ts +158 -0
- package/src/livekit-capabilities.ts +789 -0
- package/src/livekit-conformance.ts +15 -0
- package/src/livekit-connector.ts +406 -0
- package/src/livekit-data.ts +278 -0
- package/src/livekit-perform-harness.ts +17 -0
- package/src/livekit-server.ts +67 -0
- package/src/livekit-service-cli.ts +93 -0
- package/src/livekit-token.ts +133 -0
- package/src/livekit-twin.ts +1162 -0
- package/src/livekit-types.ts +211 -0
- package/src/livekit-webhook.ts +91 -0
- package/src/redis-service-cli.ts +39 -0
- package/test-fixtures/livekit-openapi-operations.SOURCE.md +88 -0
- package/test-fixtures/livekit-openapi-operations.json +442 -0
- package/test-fixtures/protobufs/cloud_replay.proto +82 -0
- package/test-fixtures/protobufs/livekit_agent.proto +185 -0
- package/test-fixtures/protobufs/livekit_agent_dispatch.proto +103 -0
- package/test-fixtures/protobufs/livekit_agent_simulation.proto +422 -0
- package/test-fixtures/protobufs/livekit_agent_worker.proto +29 -0
- package/test-fixtures/protobufs/livekit_agentdb.proto +226 -0
- package/test-fixtures/protobufs/livekit_analytics.proto +312 -0
- package/test-fixtures/protobufs/livekit_cloud_agent.proto +376 -0
- package/test-fixtures/protobufs/livekit_connector.proto +41 -0
- package/test-fixtures/protobufs/livekit_connector_twilio.proto +65 -0
- package/test-fixtures/protobufs/livekit_connector_whatsapp.proto +171 -0
- package/test-fixtures/protobufs/livekit_egress.proto +641 -0
- package/test-fixtures/protobufs/livekit_ingress.proto +222 -0
- package/test-fixtures/protobufs/livekit_internal.proto +228 -0
- package/test-fixtures/protobufs/livekit_metrics.proto +103 -0
- package/test-fixtures/protobufs/livekit_models.proto +992 -0
- package/test-fixtures/protobufs/livekit_phone_number.proto +151 -0
- package/test-fixtures/protobufs/livekit_room.proto +312 -0
- package/test-fixtures/protobufs/livekit_rtc.proto +673 -0
- package/test-fixtures/protobufs/livekit_sip.proto +1000 -0
- package/test-fixtures/protobufs/livekit_token_source.proto +49 -0
- package/test-fixtures/protobufs/livekit_webhook.proto +62 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { applyTwinWrite, pendingActions, projectResources } from '@volter/world-core';
|
|
5
|
+
import { checkCapabilities, verifyBoundary } from '@volter/world-tooling';
|
|
6
|
+
import { EGRESS_STATUS, INGRESS_INPUT, INGRESS_STATUS, TRACK_SOURCE } from "./livekit-data.js";
|
|
7
|
+
import { pullLiveKitAgentDispatch, pullLiveKitSIP, pullLiveKitSnapshot, } from "./livekit-connector.js";
|
|
8
|
+
import { performPending } from "./livekit-perform-harness.js";
|
|
9
|
+
import { handleLiveKitTwinRequest } from "./livekit-twin.js";
|
|
10
|
+
import { mintAccessToken, verifyAccessToken } from "./livekit-token.js";
|
|
11
|
+
import { buildWebhookBody, signWebhookHeader, verifyWebhook } from "./livekit-webhook.js";
|
|
12
|
+
const API_KEY = 'devkey';
|
|
13
|
+
const API_SECRET = 'secretsecretsecretsecretsecretsecret';
|
|
14
|
+
async function withRoot(fn) {
|
|
15
|
+
const root = mkdtempSync(join(tmpdir(), 'livekit-cap-'));
|
|
16
|
+
const h = (step) => handleLiveKitTwinRequest({
|
|
17
|
+
method: 'POST',
|
|
18
|
+
path: `/twirp/livekit.${step.service ?? 'RoomService'}/${step.method ?? 'ListRooms'}`,
|
|
19
|
+
body: step.b === undefined ? '{}' : JSON.stringify(step.b),
|
|
20
|
+
root,
|
|
21
|
+
readOnly: step.readOnly,
|
|
22
|
+
occurredAt: '1970-01-01T00:00:00.000Z',
|
|
23
|
+
...(step.authToken !== undefined ? { authToken: step.authToken } : {}),
|
|
24
|
+
...(step.apiKeys !== undefined ? { apiKeys: step.apiKeys } : {}),
|
|
25
|
+
...(step.nowSeconds !== undefined ? { nowSeconds: step.nowSeconds } : {}),
|
|
26
|
+
...(step.maxActiveEgress !== undefined ? { maxActiveEgress: step.maxActiveEgress } : {}),
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
return await verifyBoundary('livekit.withRoot', () => fn(h, root));
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
rmSync(root, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const done = (id, area, title, dimension, tier, verify) => ({ id, area, title, dimension, tier, expected: 'done', verify });
|
|
36
|
+
const todo = (id, area, title, dimension, tier) => ({ id, area, title, dimension, tier, expected: 'todo' });
|
|
37
|
+
export const LIVEKIT_CAPABILITIES = [
|
|
38
|
+
todo('livekit.webhooks.delivery', 'webhooks', 'Outbound webhook delivery: POST the signed event bodies the twin already builds to the configured webhookUrl, with the documented retry schedule', 'api', 'common'),
|
|
39
|
+
done('livekit.rooms.create', 'rooms', 'RoomService CreateRoom creates a room with SDK-shaped fields', 'api', 'core', () => withRoot(async (h) => {
|
|
40
|
+
const r = await h({ method: 'CreateRoom', b: { name: 'cap-room', emptyTimeout: 600, maxParticipants: 10 } });
|
|
41
|
+
return r.status === 200 && r.body.name === 'cap-room' && r.body.emptyTimeout === 600 && r.body.maxParticipants === 10;
|
|
42
|
+
})),
|
|
43
|
+
done('livekit.rooms.create_validation', 'rooms', 'CreateRoom rejects missing room name', 'api', 'core', () => withRoot(async (h) => {
|
|
44
|
+
const r = await h({ method: 'CreateRoom', b: {} });
|
|
45
|
+
return r.status === 400 && r.body.code === 'invalid_argument';
|
|
46
|
+
})),
|
|
47
|
+
done('livekit.rooms.list_all', 'rooms', 'ListRooms returns all local active rooms', 'api', 'core', () => withRoot(async (h) => {
|
|
48
|
+
await h({ method: 'CreateRoom', b: { name: 'one' } });
|
|
49
|
+
await h({ method: 'CreateRoom', b: { name: 'two' } });
|
|
50
|
+
const r = await h({ method: 'ListRooms', b: {} });
|
|
51
|
+
return r.status === 200 && r.body.rooms?.map((room) => room.name).sort().join(',') === 'one,two';
|
|
52
|
+
})),
|
|
53
|
+
done('livekit.rooms.list_filter', 'rooms', 'ListRooms filters by room names', 'api', 'core', () => withRoot(async (h) => {
|
|
54
|
+
await h({ method: 'CreateRoom', b: { name: 'one' } });
|
|
55
|
+
await h({ method: 'CreateRoom', b: { name: 'two' } });
|
|
56
|
+
const r = await h({ method: 'ListRooms', b: { names: ['two'] } });
|
|
57
|
+
return r.status === 200 && r.body.rooms?.length === 1 && r.body.rooms[0].name === 'two';
|
|
58
|
+
})),
|
|
59
|
+
done('livekit.rooms.update_metadata', 'rooms', 'UpdateRoomMetadata persists metadata', 'api', 'core', () => withRoot(async (h) => {
|
|
60
|
+
await h({ method: 'CreateRoom', b: { name: 'meta-room' } });
|
|
61
|
+
const updated = await h({ method: 'UpdateRoomMetadata', b: { room: 'meta-room', metadata: '{"egressActive":true}' } });
|
|
62
|
+
const listed = await h({ method: 'ListRooms', b: { names: ['meta-room'] } });
|
|
63
|
+
return updated.status === 200 && updated.body.metadata === '{"egressActive":true}' && listed.body.rooms?.[0]?.metadata === '{"egressActive":true}';
|
|
64
|
+
})),
|
|
65
|
+
done('livekit.rooms.update_missing', 'rooms', 'UpdateRoomMetadata returns not_found for unknown room', 'api', 'common', () => withRoot(async (h) => {
|
|
66
|
+
const r = await h({ method: 'UpdateRoomMetadata', b: { room: 'missing', metadata: '{}' } });
|
|
67
|
+
return r.status === 404 && r.body.code === 'not_found';
|
|
68
|
+
})),
|
|
69
|
+
done('livekit.rooms.delete', 'rooms', 'DeleteRoom removes room from ListRooms', 'api', 'core', () => withRoot(async (h) => {
|
|
70
|
+
await h({ method: 'CreateRoom', b: { name: 'delete-me' } });
|
|
71
|
+
const deleted = await h({ method: 'DeleteRoom', b: { room: 'delete-me' } });
|
|
72
|
+
const listed = await h({ method: 'ListRooms', b: { names: ['delete-me'] } });
|
|
73
|
+
return deleted.status === 200 && listed.body.rooms?.length === 0;
|
|
74
|
+
})),
|
|
75
|
+
done('livekit.rooms.recreate_after_delete', 'rooms', 'CreateRoom can recreate a deleted room name', 'api', 'core', () => withRoot(async (h) => {
|
|
76
|
+
await h({ method: 'CreateRoom', b: { name: 'recreate-me' } });
|
|
77
|
+
await h({ method: 'DeleteRoom', b: { room: 'recreate-me' } });
|
|
78
|
+
await h({ method: 'CreateRoom', b: { name: 'recreate-me' } });
|
|
79
|
+
const listed = await h({ method: 'ListRooms', b: { names: ['recreate-me'] } });
|
|
80
|
+
return listed.body.rooms?.length === 1 && listed.body.rooms[0].name === 'recreate-me';
|
|
81
|
+
})),
|
|
82
|
+
done('livekit.readonly.rejects_room_write', 'honesty', 'readOnly rejects room writes', 'api', 'common', () => withRoot(async (h) => {
|
|
83
|
+
const r = await h({ method: 'CreateRoom', b: { name: 'ro' }, readOnly: true });
|
|
84
|
+
return r.status === 405 && r.body.code === 'permission_denied';
|
|
85
|
+
})),
|
|
86
|
+
done('livekit.participants.list', 'participants', 'ListParticipants returns locally seeded participants', 'api', 'core', () => withRoot(async (h) => {
|
|
87
|
+
await h({ method: 'CreateRoom', b: { name: 'p-room' } });
|
|
88
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'p-room', identity: 'tester-1' } });
|
|
89
|
+
const r = await h({ method: 'ListParticipants', b: { room: 'p-room' } });
|
|
90
|
+
return r.status === 200 && r.body.participants?.[0]?.identity === 'tester-1';
|
|
91
|
+
})),
|
|
92
|
+
done('livekit.participants.counts_room', 'participants', 'Room numParticipants reflects locally seeded participants', 'api', 'core', () => withRoot(async (h) => {
|
|
93
|
+
await h({ method: 'CreateRoom', b: { name: 'count-room' } });
|
|
94
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'count-room', identity: 'tester-1' } });
|
|
95
|
+
const r = await h({ method: 'ListRooms', b: { names: ['count-room'] } });
|
|
96
|
+
return r.body.rooms?.[0]?.numParticipants === 1;
|
|
97
|
+
})),
|
|
98
|
+
done('livekit.participants.get', 'participants', 'GetParticipant returns one locally seeded participant by identity', 'api', 'common', () => withRoot(async (h) => {
|
|
99
|
+
await h({ method: 'CreateRoom', b: { name: 'get-room' } });
|
|
100
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'get-room', identity: 'tester-1', name: 'Tester One' } });
|
|
101
|
+
const r = await h({ method: 'GetParticipant', b: { room: 'get-room', identity: 'tester-1' } });
|
|
102
|
+
return r.status === 200 && r.body.name === 'Tester One';
|
|
103
|
+
})),
|
|
104
|
+
done('livekit.participants.screen_share', 'participants', 'Locally seeded participant tracks expose SCREEN_SHARE source and muted state', 'api', 'core', () => withRoot(async (h) => {
|
|
105
|
+
await h({ method: 'CreateRoom', b: { name: 'share-room' } });
|
|
106
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'share-room', identity: 'tester-1', tracks: [{ source: 'SCREEN_SHARE', muted: false }] } });
|
|
107
|
+
const r = await h({ method: 'ListParticipants', b: { room: 'share-room' } });
|
|
108
|
+
const track = r.body.participants?.[0]?.tracks?.[0];
|
|
109
|
+
return r.status === 200 && track?.source === TRACK_SOURCE.SCREEN_SHARE && track?.muted === false;
|
|
110
|
+
})),
|
|
111
|
+
done('livekit.participants.mute_track', 'participants', 'MutePublishedTrack toggles a published track', 'api', 'common', () => withRoot(async (h) => {
|
|
112
|
+
await h({ method: 'CreateRoom', b: { name: 'mute-room' } });
|
|
113
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'mute-room', identity: 'tester-1', tracks: [{ sid: 'TR_screen', source: 'SCREEN_SHARE', muted: false }] } });
|
|
114
|
+
const muted = await h({ method: 'MutePublishedTrack', b: { room: 'mute-room', identity: 'tester-1', trackSid: 'TR_screen', muted: true } });
|
|
115
|
+
const got = await h({ method: 'GetParticipant', b: { room: 'mute-room', identity: 'tester-1' } });
|
|
116
|
+
return muted.status === 200 && muted.body.track?.muted === true && got.body.tracks?.[0]?.muted === true;
|
|
117
|
+
})),
|
|
118
|
+
done('livekit.participants.remove', 'participants', 'RemoveParticipant hides participant from ListParticipants', 'api', 'common', () => withRoot(async (h) => {
|
|
119
|
+
await h({ method: 'CreateRoom', b: { name: 'remove-room' } });
|
|
120
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'remove-room', identity: 'tester-1' } });
|
|
121
|
+
const removed = await h({ method: 'RemoveParticipant', b: { room: 'remove-room', identity: 'tester-1' } });
|
|
122
|
+
const listed = await h({ method: 'ListParticipants', b: { room: 'remove-room' } });
|
|
123
|
+
return removed.status === 200 && listed.body.participants?.length === 0;
|
|
124
|
+
})),
|
|
125
|
+
done('livekit.egress.start_room_composite', 'egress', 'StartRoomCompositeEgress records an active egress', 'api', 'core', () => withRoot(async (h) => {
|
|
126
|
+
await h({ method: 'CreateRoom', b: { name: 'egress-room' } });
|
|
127
|
+
const r = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'egress-room', fileOutputs: [{ filepath: 'recordings/job/eg.mp4' }], videoOnly: true } });
|
|
128
|
+
const second = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'egress-room', fileOutputs: [{ filepath: 'recordings/job/eg.mp4' }], videoOnly: true } });
|
|
129
|
+
return r.status === 200 && typeof r.body.egressId === 'string' && r.body.status === EGRESS_STATUS.ACTIVE && r.body.roomName === 'egress-room'
|
|
130
|
+
&& second.body.egressId !== r.body.egressId;
|
|
131
|
+
})),
|
|
132
|
+
done('livekit.egress.list_by_id', 'egress', 'ListEgress filters by egressId', 'api', 'core', () => withRoot(async (h) => {
|
|
133
|
+
await h({ method: 'CreateRoom', b: { name: 'egress-list' } });
|
|
134
|
+
const started = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'egress-list' } });
|
|
135
|
+
const egressId = started.body.egressId;
|
|
136
|
+
// guard against a sabotaged/empty handler: the started egressId must actually be a real id,
|
|
137
|
+
// not compare undefined-to-undefined.
|
|
138
|
+
if (started.status !== 200 || typeof egressId !== 'string' || egressId.length === 0)
|
|
139
|
+
return false;
|
|
140
|
+
const listed = await h({ service: 'Egress', method: 'ListEgress', b: { egressId } });
|
|
141
|
+
const items = listed.body.items;
|
|
142
|
+
return listed.status === 200 && Array.isArray(items) && items.length === 1 && items[0].egressId === egressId;
|
|
143
|
+
})),
|
|
144
|
+
done('livekit.egress.stop_complete', 'egress', 'StopEgress transitions egress to COMPLETE', 'api', 'core', () => withRoot(async (h) => {
|
|
145
|
+
await h({ method: 'CreateRoom', b: { name: 'stop-room' } });
|
|
146
|
+
const started = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'stop-room' } });
|
|
147
|
+
const stopped = await h({ service: 'Egress', method: 'StopEgress', b: { egressId: started.body.egressId } });
|
|
148
|
+
return stopped.status === 200 && stopped.body.status === EGRESS_STATUS.COMPLETE && typeof stopped.body.endedAt === 'string';
|
|
149
|
+
})),
|
|
150
|
+
done('livekit.egress.active_filter', 'egress', 'ListEgress active filter excludes completed egresses', 'api', 'common', () => withRoot(async (h) => {
|
|
151
|
+
await h({ method: 'CreateRoom', b: { name: 'active-room' } });
|
|
152
|
+
const started = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'active-room' } });
|
|
153
|
+
await h({ service: 'Egress', method: 'StopEgress', b: { egressId: started.body.egressId } });
|
|
154
|
+
const listed = await h({ service: 'Egress', method: 'ListEgress', b: { active: true } });
|
|
155
|
+
return listed.status === 200 && listed.body.items?.length === 0;
|
|
156
|
+
})),
|
|
157
|
+
done('livekit.egress.update_layout', 'egress', 'UpdateLayout persists egress layout', 'api', 'common', () => withRoot(async (h) => {
|
|
158
|
+
await h({ method: 'CreateRoom', b: { name: 'layout-room' } });
|
|
159
|
+
const started = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'layout-room' } });
|
|
160
|
+
const updated = await h({ service: 'Egress', method: 'UpdateLayout', b: { egressId: started.body.egressId, layout: 'speaker' } });
|
|
161
|
+
return updated.status === 200 && updated.body.roomComposite?.layout === 'speaker';
|
|
162
|
+
})),
|
|
163
|
+
done('livekit.unmodeled.404', 'honesty', 'Unmodeled Twirp methods fail with not_found', 'api', 'core', () => withRoot(async (h) => {
|
|
164
|
+
const r = await h({ method: 'TeleportRoom', b: {} });
|
|
165
|
+
return r.status === 404 && r.body.code === 'not_found';
|
|
166
|
+
})),
|
|
167
|
+
done('livekit.connector.pull', 'connector', 'Connector pulls rooms, participants, and egress through injected client', 'connector', 'common', async () => {
|
|
168
|
+
const root = mkdtempSync(join(tmpdir(), 'livekit-connector-cap-'));
|
|
169
|
+
try {
|
|
170
|
+
await pullLiveKitSnapshot(async (req) => {
|
|
171
|
+
if (req.service === 'RoomService' && req.method === 'ListRooms')
|
|
172
|
+
return { status: 200, data: { rooms: [{ name: 'remote-room', sid: 'RM_remote', metadata: '', emptyTimeout: 300 }] } };
|
|
173
|
+
if (req.service === 'RoomService' && req.method === 'ListParticipants')
|
|
174
|
+
return { status: 200, data: { participants: [{ identity: 'remote-user', tracks: [] }] } };
|
|
175
|
+
return { status: 200, data: { items: [{ egressId: 'EG_remote', roomName: 'remote-room', status: EGRESS_STATUS.ACTIVE }] } };
|
|
176
|
+
}, ['remote-room'], { root });
|
|
177
|
+
await pullLiveKitSnapshot(async (req) => {
|
|
178
|
+
if (req.service === 'RoomService' && req.method === 'ListRooms')
|
|
179
|
+
return { status: 200, data: { rooms: [{ name: 'remote-room', sid: 'RM_remote', metadata: '', emptyTimeout: 300 }] } };
|
|
180
|
+
if (req.service === 'RoomService' && req.method === 'ListParticipants')
|
|
181
|
+
return { status: 200, data: { participants: [{ identity: 'remote-user', tracks: [] }] } };
|
|
182
|
+
return { status: 200, data: { items: [{ egressId: 'EG_remote', roomName: 'remote-room', status: EGRESS_STATUS.ACTIVE }] } };
|
|
183
|
+
}, ['remote-room'], { root });
|
|
184
|
+
const rows = projectResources('livekit', root);
|
|
185
|
+
return rows.some((r) => r.type === 'room' && r.name === 'remote-room')
|
|
186
|
+
&& rows.some((r) => r.type === 'participant' && r.identity === 'remote-user')
|
|
187
|
+
&& rows.filter((r) => r.type === 'egress' && r.egressId === 'EG_remote').length === 1;
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
rmSync(root, { recursive: true, force: true });
|
|
191
|
+
}
|
|
192
|
+
}),
|
|
193
|
+
done('livekit.connector.push', 'connector', 'Connector confirms pending room metadata pushes idempotently', 'connector', 'common', async () => {
|
|
194
|
+
const root = mkdtempSync(join(tmpdir(), 'livekit-connector-push-cap-'));
|
|
195
|
+
try {
|
|
196
|
+
await applyTwinWrite('livekit', {
|
|
197
|
+
operation: 'room.update_metadata',
|
|
198
|
+
subjectType: 'room',
|
|
199
|
+
subjectId: 'room_push',
|
|
200
|
+
fields: { id: 'room_push', name: 'push-room', metadata: '{"ok":true}' },
|
|
201
|
+
occurredAt: '1970-01-01T00:00:00.000Z',
|
|
202
|
+
actor: { kind: 'system' },
|
|
203
|
+
}, root);
|
|
204
|
+
const pushed = await performPending(async () => ({ status: 200, data: { name: 'push-room', metadata: '{"ok":true}' } }), { root });
|
|
205
|
+
const again = await performPending(async () => ({ status: 200, data: {} }), { root });
|
|
206
|
+
return pushed === 1 && again === 0 && pendingActions('livekit', root).length === 0;
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
rmSync(root, { recursive: true, force: true });
|
|
210
|
+
}
|
|
211
|
+
}),
|
|
212
|
+
done('livekit.rooms.forward_participant', 'rooms', 'ForwardParticipant mirrors a participant into a destination room (source retained, mirror cannot publish)', 'api', 'niche', () => withRoot(async (h) => {
|
|
213
|
+
await h({ method: 'CreateRoom', b: { name: 'fwd-src' } });
|
|
214
|
+
await h({ method: 'CreateRoom', b: { name: 'fwd-dst' } });
|
|
215
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'fwd-src', identity: 'caller', tracks: [{ sid: 'TR_a', source: 'CAMERA' }] } });
|
|
216
|
+
const fwd = await h({ method: 'ForwardParticipant', b: { room: 'fwd-src', identity: 'caller', destinationRoom: 'fwd-dst' } });
|
|
217
|
+
// original retained in source, mirror appears in destination
|
|
218
|
+
const src = await h({ method: 'GetParticipant', b: { room: 'fwd-src', identity: 'caller' } });
|
|
219
|
+
const dst = await h({ method: 'GetParticipant', b: { room: 'fwd-dst', identity: 'caller' } });
|
|
220
|
+
const noDst = await h({ method: 'ForwardParticipant', b: { room: 'fwd-src', identity: 'caller' } });
|
|
221
|
+
const ghost = await h({ method: 'ForwardParticipant', b: { room: 'fwd-src', identity: 'nobody', destinationRoom: 'fwd-dst' } });
|
|
222
|
+
const dup = await h({ method: 'ForwardParticipant', b: { room: 'fwd-src', identity: 'caller', destinationRoom: 'fwd-dst' } });
|
|
223
|
+
return fwd.status === 200 && src.status === 200 && dst.status === 200
|
|
224
|
+
&& dst.body.isPublisher === false && dst.body.permission?.canPublish === false
|
|
225
|
+
&& noDst.status === 400 && ghost.status === 404 && dup.status === 409;
|
|
226
|
+
})),
|
|
227
|
+
done('livekit.rooms.move_participant', 'rooms', 'MoveParticipant relocates a participant (left source, present in destination)', 'api', 'niche', () => withRoot(async (h) => {
|
|
228
|
+
await h({ method: 'CreateRoom', b: { name: 'mv-src' } });
|
|
229
|
+
await h({ method: 'CreateRoom', b: { name: 'mv-dst' } });
|
|
230
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'mv-src', identity: 'mover', name: 'Mover' } });
|
|
231
|
+
const mv = await h({ method: 'MoveParticipant', b: { room: 'mv-src', identity: 'mover', destinationRoom: 'mv-dst' } });
|
|
232
|
+
const srcList = await h({ method: 'ListParticipants', b: { room: 'mv-src' } });
|
|
233
|
+
const dst = await h({ method: 'GetParticipant', b: { room: 'mv-dst', identity: 'mover' } });
|
|
234
|
+
const noDst = await h({ method: 'MoveParticipant', b: { room: 'mv-src', identity: 'mover' } });
|
|
235
|
+
const ghost = await h({ method: 'MoveParticipant', b: { room: 'mv-src', identity: 'gone', destinationRoom: 'mv-dst' } });
|
|
236
|
+
return mv.status === 200 && srcList.body.participants?.length === 0
|
|
237
|
+
&& dst.status === 200 && dst.body.name === 'Mover'
|
|
238
|
+
&& noDst.status === 400 && ghost.status === 404;
|
|
239
|
+
})),
|
|
240
|
+
done('livekit.rooms.update_subscriptions', 'rooms', 'UpdateSubscriptions adds/removes participant track subscriptions', 'api', 'common', () => withRoot(async (h) => {
|
|
241
|
+
await h({ method: 'CreateRoom', b: { name: 'sub-room' } });
|
|
242
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'sub-room', identity: 'sub-1' } });
|
|
243
|
+
const sub = await h({ method: 'UpdateSubscriptions', b: { room: 'sub-room', identity: 'sub-1', trackSids: ['TR_a', 'TR_b'], subscribe: true } });
|
|
244
|
+
const afterSub = await h({ method: 'GetParticipant', b: { room: 'sub-room', identity: 'sub-1' } });
|
|
245
|
+
const subbed = (afterSub.body.subscribedTracks ?? []).slice().sort().join(',');
|
|
246
|
+
const unsub = await h({ method: 'UpdateSubscriptions', b: { room: 'sub-room', identity: 'sub-1', trackSids: ['TR_a'], subscribe: false } });
|
|
247
|
+
const afterUnsub = await h({ method: 'GetParticipant', b: { room: 'sub-room', identity: 'sub-1' } });
|
|
248
|
+
const missing = await h({ method: 'UpdateSubscriptions', b: { room: 'sub-room', identity: 'ghost', trackSids: ['TR_a'], subscribe: true } });
|
|
249
|
+
return sub.status === 200 && subbed === 'TR_a,TR_b' && unsub.status === 200
|
|
250
|
+
&& (afterUnsub.body.subscribedTracks ?? []).join(',') === 'TR_b' && missing.status === 404;
|
|
251
|
+
})),
|
|
252
|
+
done('livekit.rooms.send_data', 'rooms', 'SendData validates room+payload and acks; unknown room/empty payload fail', 'api', 'common', () => withRoot(async (h) => {
|
|
253
|
+
await h({ method: 'CreateRoom', b: { name: 'data-room' } });
|
|
254
|
+
const ok = await h({ method: 'SendData', b: { room: 'data-room', data: 'aGVsbG8=', kind: 0, topic: 'chat' } });
|
|
255
|
+
const noRoom = await h({ method: 'SendData', b: { room: 'missing', data: 'aGVsbG8=' } });
|
|
256
|
+
const noData = await h({ method: 'SendData', b: { room: 'data-room' } });
|
|
257
|
+
return ok.status === 200 && noRoom.status === 404 && noRoom.body.code === 'not_found'
|
|
258
|
+
&& noData.status === 400 && noData.body.code === 'invalid_argument';
|
|
259
|
+
})),
|
|
260
|
+
done('livekit.rooms.permission_update', 'participants', 'UpdateParticipant atomically replaces permission grants', 'api', 'common', () => withRoot(async (h) => {
|
|
261
|
+
await h({ method: 'CreateRoom', b: { name: 'perm-room' } });
|
|
262
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'perm-room', identity: 'perm-1' } });
|
|
263
|
+
const updated = await h({ method: 'UpdateParticipant', b: { room: 'perm-room', identity: 'perm-1', permission: { canSubscribe: true, canPublish: false, canPublishData: false, hidden: true } } });
|
|
264
|
+
const perm = updated.body.permission;
|
|
265
|
+
return updated.status === 200 && perm?.canSubscribe === true && perm?.canPublish === false && perm?.hidden === true && updated.body.isPublisher === false;
|
|
266
|
+
})),
|
|
267
|
+
done('livekit.rooms.subscription_permissions', 'participants', 'Subscription set is tracked per participant across toggles', 'api', 'common', () => withRoot(async (h) => {
|
|
268
|
+
await h({ method: 'CreateRoom', b: { name: 'subperm-room' } });
|
|
269
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'subperm-room', identity: 'sp-1' } });
|
|
270
|
+
await h({ method: 'UpdateSubscriptions', b: { room: 'subperm-room', identity: 'sp-1', trackSids: ['TR_x'], subscribe: true } });
|
|
271
|
+
await h({ method: 'UpdateSubscriptions', b: { room: 'subperm-room', identity: 'sp-1', trackSids: ['TR_x'], subscribe: true } });
|
|
272
|
+
const got = await h({ method: 'GetParticipant', b: { room: 'subperm-room', identity: 'sp-1' } });
|
|
273
|
+
// idempotent: re-subscribing the same sid does not duplicate it
|
|
274
|
+
return (got.body.subscribedTracks ?? []).filter((s) => s === 'TR_x').length === 1;
|
|
275
|
+
})),
|
|
276
|
+
done('livekit.egress.web', 'egress', 'StartWebEgress records a web-source egress with no backing room', 'api', 'common', () => withRoot(async (h) => {
|
|
277
|
+
const started = await h({ service: 'Egress', method: 'StartWebEgress', b: { url: 'https://example.com/live', streamOutputs: [{ urls: ['rtmp://a/b'] }] } });
|
|
278
|
+
const noUrl = await h({ service: 'Egress', method: 'StartWebEgress', b: {} });
|
|
279
|
+
return started.status === 200 && started.body.web?.url === 'https://example.com/live'
|
|
280
|
+
&& started.body.status === EGRESS_STATUS.ACTIVE && noUrl.status === 400;
|
|
281
|
+
})),
|
|
282
|
+
done('livekit.egress.participant', 'egress', 'StartParticipantEgress requires identity and tags request case', 'api', 'common', () => withRoot(async (h) => {
|
|
283
|
+
await h({ method: 'CreateRoom', b: { name: 'pe-room' } });
|
|
284
|
+
const started = await h({ service: 'Egress', method: 'StartParticipantEgress', b: { roomName: 'pe-room', identity: 'pe-1' } });
|
|
285
|
+
const noId = await h({ service: 'Egress', method: 'StartParticipantEgress', b: { roomName: 'pe-room' } });
|
|
286
|
+
return started.status === 200 && started.body.participant?.identity === 'pe-1' && noId.status === 400;
|
|
287
|
+
})),
|
|
288
|
+
done('livekit.egress.track_composite', 'egress', 'StartTrackCompositeEgress requires a track id and tags request case', 'api', 'common', () => withRoot(async (h) => {
|
|
289
|
+
await h({ method: 'CreateRoom', b: { name: 'tc-room' } });
|
|
290
|
+
const started = await h({ service: 'Egress', method: 'StartTrackCompositeEgress', b: { roomName: 'tc-room', videoTrackId: 'TR_v', audioTrackId: 'TR_a' } });
|
|
291
|
+
const noTrack = await h({ service: 'Egress', method: 'StartTrackCompositeEgress', b: { roomName: 'tc-room' } });
|
|
292
|
+
return started.status === 200 && started.body.trackComposite?.videoTrackId === 'TR_v' && noTrack.status === 400;
|
|
293
|
+
})),
|
|
294
|
+
done('livekit.egress.track', 'egress', 'StartTrackEgress requires a track id and tags request case', 'api', 'common', () => withRoot(async (h) => {
|
|
295
|
+
await h({ method: 'CreateRoom', b: { name: 'te-room' } });
|
|
296
|
+
const started = await h({ service: 'Egress', method: 'StartTrackEgress', b: { roomName: 'te-room', trackId: 'TR_only' } });
|
|
297
|
+
const noTrack = await h({ service: 'Egress', method: 'StartTrackEgress', b: { roomName: 'te-room' } });
|
|
298
|
+
return started.status === 200 && started.body.track?.trackId === 'TR_only' && noTrack.status === 400;
|
|
299
|
+
})),
|
|
300
|
+
done('livekit.egress.update_stream', 'egress', 'UpdateStream adds and removes output URLs on an active egress', 'api', 'common', () => withRoot(async (h) => {
|
|
301
|
+
await h({ method: 'CreateRoom', b: { name: 'us-room' } });
|
|
302
|
+
const started = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'us-room', streamOutputs: [{ urls: ['rtmp://a/1'] }] } });
|
|
303
|
+
const id = started.body.egressId;
|
|
304
|
+
const added = await h({ service: 'Egress', method: 'UpdateStream', b: { egressId: id, addOutputUrls: ['rtmp://b/2'] } });
|
|
305
|
+
const urls1 = (added.body.streamResults ?? []).map((s) => s.url).sort().join(',');
|
|
306
|
+
const removed = await h({ service: 'Egress', method: 'UpdateStream', b: { egressId: id, removeOutputUrls: ['rtmp://a/1'] } });
|
|
307
|
+
const urls2 = (removed.body.streamResults ?? []).map((s) => s.url).join(',');
|
|
308
|
+
await h({ service: 'Egress', method: 'StopEgress', b: { egressId: id } });
|
|
309
|
+
const afterStop = await h({ service: 'Egress', method: 'UpdateStream', b: { egressId: id, addOutputUrls: ['rtmp://c/3'] } });
|
|
310
|
+
return added.status === 200 && urls1 === 'rtmp://a/1,rtmp://b/2' && urls2 === 'rtmp://b/2' && afterStop.status === 412;
|
|
311
|
+
})),
|
|
312
|
+
done('livekit.egress.failure_states', 'egress', 'Egress can transition to FAILED/ABORTED/LIMIT_REACHED terminal states', 'api', 'common', () => withRoot(async (h) => {
|
|
313
|
+
await h({ method: 'CreateRoom', b: { name: 'fail-room' } });
|
|
314
|
+
const started = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'fail-room' } });
|
|
315
|
+
const id = started.body.egressId;
|
|
316
|
+
const failed = await h({ service: 'Egress', method: 'FailEgress', b: { egressId: id, status: EGRESS_STATUS.FAILED, error: 'compositor crashed', errorCode: 2 } });
|
|
317
|
+
const listActive = await h({ service: 'Egress', method: 'ListEgress', b: { active: true } });
|
|
318
|
+
const cannotStop = await h({ service: 'Egress', method: 'StopEgress', b: { egressId: id } });
|
|
319
|
+
const badState = await h({ service: 'Egress', method: 'FailEgress', b: { egressId: id, status: EGRESS_STATUS.COMPLETE } });
|
|
320
|
+
return failed.status === 200 && failed.body.status === EGRESS_STATUS.FAILED
|
|
321
|
+
&& failed.body.error === 'compositor crashed' && listActive.body.items?.length === 0
|
|
322
|
+
&& cannotStop.status === 412 && badState.status === 400;
|
|
323
|
+
})),
|
|
324
|
+
done('livekit.egress.segment_outputs', 'egress', 'Segment outputs are persisted on the egress request', 'api', 'common', () => withRoot(async (h) => {
|
|
325
|
+
await h({ method: 'CreateRoom', b: { name: 'seg-room' } });
|
|
326
|
+
const started = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'seg-room', segmentOutputs: [{ filenamePrefix: 'seg/out', playlistName: 'index.m3u8', segmentDuration: 6 }] } });
|
|
327
|
+
const listed = await h({ service: 'Egress', method: 'ListEgress', b: { egressId: started.body.egressId } });
|
|
328
|
+
const seg = listed.body.items?.[0]?.roomComposite?.segmentOutputs?.[0];
|
|
329
|
+
return started.status === 200 && seg?.playlistName === 'index.m3u8' && seg?.segmentDuration === 6;
|
|
330
|
+
})),
|
|
331
|
+
done('livekit.egress.image_outputs', 'egress', 'Image (thumbnail) outputs are persisted on the egress request and round-trip via ListEgress', 'api', 'niche', () => withRoot(async (h) => {
|
|
332
|
+
await h({ method: 'CreateRoom', b: { name: 'img-room' } });
|
|
333
|
+
const started = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'img-room', imageOutputs: [{ captureInterval: 5, width: 1280, height: 720, filenamePrefix: 'thumb/img', imagePrefix: 'thumb' }] } });
|
|
334
|
+
const listed = await h({ service: 'Egress', method: 'ListEgress', b: { egressId: started.body.egressId } });
|
|
335
|
+
const img = listed.body.items?.[0]?.roomComposite?.imageOutputs?.[0];
|
|
336
|
+
// imageResults is always present (empty offline — no rendered snapshot bytes).
|
|
337
|
+
return started.status === 200 && img?.captureInterval === 5 && img?.filenamePrefix === 'thumb/img'
|
|
338
|
+
&& Array.isArray(listed.body.items?.[0]?.imageResults);
|
|
339
|
+
})),
|
|
340
|
+
done('livekit.egress.webhook_config', 'egress', 'Per-egress webhook config (webhookUrl) is persisted on the request and round-trips via ListEgress', 'api', 'niche', () => withRoot(async (h) => {
|
|
341
|
+
await h({ method: 'CreateRoom', b: { name: 'wh-room' } });
|
|
342
|
+
const started = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'wh-room', webhookUrl: 'https://hooks.example.com/egress' } });
|
|
343
|
+
const listed = await h({ service: 'Egress', method: 'ListEgress', b: { egressId: started.body.egressId } });
|
|
344
|
+
return started.status === 200 && listed.body.items?.[0]?.roomComposite?.webhookUrl === 'https://hooks.example.com/egress';
|
|
345
|
+
})),
|
|
346
|
+
done('livekit.ingress.create', 'ingress', 'CreateIngress mints stream key/url and requires participant identity', 'api', 'common', () => withRoot(async (h) => {
|
|
347
|
+
const created = await h({ service: 'Ingress', method: 'CreateIngress', b: { inputType: 'RTMP_INPUT', roomName: 'live', participantIdentity: 'streamer', name: 'My Ingress' } });
|
|
348
|
+
const noId = await h({ service: 'Ingress', method: 'CreateIngress', b: { inputType: 'RTMP_INPUT', roomName: 'live' } });
|
|
349
|
+
const noUrl = await h({ service: 'Ingress', method: 'CreateIngress', b: { inputType: 'URL_INPUT', roomName: 'live', participantIdentity: 'streamer' } });
|
|
350
|
+
const b = created.body;
|
|
351
|
+
return created.status === 200 && typeof b.ingressId === 'string' && b.ingressId.startsWith('IN_')
|
|
352
|
+
&& b.streamKey && b.url.startsWith('rtmp://') && b.inputType === INGRESS_INPUT.RTMP_INPUT
|
|
353
|
+
&& b.state?.status === INGRESS_STATUS.ENDPOINT_INACTIVE && noId.status === 400 && noUrl.status === 400;
|
|
354
|
+
})),
|
|
355
|
+
done('livekit.ingress.update', 'ingress', 'UpdateIngress mutates fields and toggles transcoding', 'api', 'common', () => withRoot(async (h) => {
|
|
356
|
+
const created = await h({ service: 'Ingress', method: 'CreateIngress', b: { inputType: 'RTMP_INPUT', roomName: 'r1', participantIdentity: 'p1' } });
|
|
357
|
+
const id = created.body.ingressId;
|
|
358
|
+
const updated = await h({ service: 'Ingress', method: 'UpdateIngress', b: { ingressId: id, name: 'renamed', roomName: 'r2', enableTranscoding: false } });
|
|
359
|
+
const missing = await h({ service: 'Ingress', method: 'UpdateIngress', b: { ingressId: 'IN_nope', name: 'x' } });
|
|
360
|
+
const b = updated.body;
|
|
361
|
+
return updated.status === 200 && b.name === 'renamed' && b.roomName === 'r2' && b.enableTranscoding === false && b.bypassTranscoding === true && missing.status === 404;
|
|
362
|
+
})),
|
|
363
|
+
done('livekit.ingress.list', 'ingress', 'ListIngress filters by room and ingress id, excludes deleted', 'api', 'common', () => withRoot(async (h) => {
|
|
364
|
+
await h({ service: 'Ingress', method: 'CreateIngress', b: { inputType: 'RTMP_INPUT', roomName: 'a', participantIdentity: 'p1' } });
|
|
365
|
+
const c2 = await h({ service: 'Ingress', method: 'CreateIngress', b: { inputType: 'WHIP_INPUT', roomName: 'b', participantIdentity: 'p2' } });
|
|
366
|
+
const all = await h({ service: 'Ingress', method: 'ListIngress', b: {} });
|
|
367
|
+
const byRoom = await h({ service: 'Ingress', method: 'ListIngress', b: { roomName: 'b' } });
|
|
368
|
+
const byId = await h({ service: 'Ingress', method: 'ListIngress', b: { ingressId: c2.body.ingressId } });
|
|
369
|
+
return all.body.items?.length === 2 && byRoom.body.items?.length === 1
|
|
370
|
+
&& byRoom.body.items[0].roomName === 'b' && byId.body.items?.length === 1;
|
|
371
|
+
})),
|
|
372
|
+
done('livekit.ingress.delete', 'ingress', 'DeleteIngress removes ingress from ListIngress and completes its state', 'api', 'common', () => withRoot(async (h) => {
|
|
373
|
+
const created = await h({ service: 'Ingress', method: 'CreateIngress', b: { inputType: 'RTMP_INPUT', roomName: 'a', participantIdentity: 'p1' } });
|
|
374
|
+
const id = created.body.ingressId;
|
|
375
|
+
const deleted = await h({ service: 'Ingress', method: 'DeleteIngress', b: { ingressId: id } });
|
|
376
|
+
const listed = await h({ service: 'Ingress', method: 'ListIngress', b: {} });
|
|
377
|
+
const missing = await h({ service: 'Ingress', method: 'DeleteIngress', b: { ingressId: id } });
|
|
378
|
+
return deleted.status === 200 && deleted.body.state?.status === INGRESS_STATUS.ENDPOINT_COMPLETE
|
|
379
|
+
&& listed.body.items?.length === 0 && missing.status === 404;
|
|
380
|
+
})),
|
|
381
|
+
done('livekit.ingress.rtmp_whip', 'ingress', 'RTMP defaults to transcoding+streamKey; WHIP defaults to bypass', 'api', 'common', () => withRoot(async (h) => {
|
|
382
|
+
const rtmp = await h({ service: 'Ingress', method: 'CreateIngress', b: { inputType: 'RTMP_INPUT', roomName: 'r', participantIdentity: 'p1' } });
|
|
383
|
+
const whip = await h({ service: 'Ingress', method: 'CreateIngress', b: { inputType: 'WHIP_INPUT', roomName: 'r', participantIdentity: 'p2' } });
|
|
384
|
+
const r = rtmp.body;
|
|
385
|
+
const w = whip.body;
|
|
386
|
+
return r.inputType === INGRESS_INPUT.RTMP_INPUT && r.enableTranscoding === true && r.url.startsWith('rtmp://') && r.streamKey
|
|
387
|
+
&& w.inputType === INGRESS_INPUT.WHIP_INPUT && w.enableTranscoding === false && w.bypassTranscoding === true && w.url.startsWith('https://');
|
|
388
|
+
})),
|
|
389
|
+
done('livekit.sip.inbound_trunks', 'sip', 'CreateSIPInboundTrunk + list/delete; create requires a number, list filters by id/number', 'api', 'niche', () => withRoot(async (h) => {
|
|
390
|
+
const created = await h({ service: 'SIP', method: 'CreateSIPInboundTrunk', b: { trunk: { name: 'main', numbers: ['+15551230000'], allowedNumbers: ['+1*'], authUsername: 'u', authPassword: 'p' } } });
|
|
391
|
+
const id = created.body.sipTrunkId;
|
|
392
|
+
const noNumber = await h({ service: 'SIP', method: 'CreateSIPInboundTrunk', b: { trunk: { name: 'bad', numbers: [] } } });
|
|
393
|
+
const listed = await h({ service: 'SIP', method: 'ListSIPInboundTrunk', b: {} });
|
|
394
|
+
const byNumber = await h({ service: 'SIP', method: 'ListSIPInboundTrunk', b: { numbers: ['+15551230000'] } });
|
|
395
|
+
const byMissNum = await h({ service: 'SIP', method: 'ListSIPInboundTrunk', b: { numbers: ['+19999999999'] } });
|
|
396
|
+
const deleted = await h({ service: 'SIP', method: 'DeleteSIPTrunk', b: { sipTrunkId: id } });
|
|
397
|
+
const afterDelete = await h({ service: 'SIP', method: 'ListSIPInboundTrunk', b: {} });
|
|
398
|
+
const deleteMissing = await h({ service: 'SIP', method: 'DeleteSIPTrunk', b: { sipTrunkId: id } });
|
|
399
|
+
return created.status === 200 && typeof id === 'string' && id.startsWith('ST_')
|
|
400
|
+
&& created.body.numbers?.[0] === '+15551230000' && created.body.authUsername === 'u'
|
|
401
|
+
&& noNumber.status === 400 && listed.body.items?.length === 1
|
|
402
|
+
&& byNumber.body.items?.length === 1 && byMissNum.body.items?.length === 0
|
|
403
|
+
&& deleted.status === 200 && afterDelete.body.items?.length === 0 && deleteMissing.status === 404;
|
|
404
|
+
})),
|
|
405
|
+
done('livekit.sip.outbound_trunks', 'sip', 'CreateSIPOutboundTrunk + list/delete; create requires an address, transport normalizes', 'api', 'niche', () => withRoot(async (h) => {
|
|
406
|
+
const created = await h({ service: 'SIP', method: 'CreateSIPOutboundTrunk', b: { trunk: { name: 'pstn', address: 'sip.provider.com', numbers: ['+15557654321'], transport: 'SIP_TRANSPORT_TCP', destinationCountry: 'US' } } });
|
|
407
|
+
const id = created.body.sipTrunkId;
|
|
408
|
+
const noAddr = await h({ service: 'SIP', method: 'CreateSIPOutboundTrunk', b: { trunk: { name: 'bad' } } });
|
|
409
|
+
const listed = await h({ service: 'SIP', method: 'ListSIPOutboundTrunk', b: {} });
|
|
410
|
+
const byId = await h({ service: 'SIP', method: 'ListSIPOutboundTrunk', b: { trunkIds: [id] } });
|
|
411
|
+
const deleted = await h({ service: 'SIP', method: 'DeleteSIPTrunk', b: { sipTrunkId: id } });
|
|
412
|
+
const afterDelete = await h({ service: 'SIP', method: 'ListSIPOutboundTrunk', b: {} });
|
|
413
|
+
return created.status === 200 && created.body.address === 'sip.provider.com'
|
|
414
|
+
&& created.body.transport === 'SIP_TRANSPORT_TCP' && created.body.destinationCountry === 'US'
|
|
415
|
+
&& noAddr.status === 400 && listed.body.items?.length === 1 && byId.body.items?.length === 1
|
|
416
|
+
&& deleted.status === 200 && afterDelete.body.items?.length === 0;
|
|
417
|
+
})),
|
|
418
|
+
done('livekit.sip.dispatch_rules', 'sip', 'CreateSIPDispatchRule (direct + individual oneof) + list/delete; direct requires a roomName', 'api', 'niche', () => withRoot(async (h) => {
|
|
419
|
+
const direct = await h({ service: 'SIP', method: 'CreateSIPDispatchRule', b: { rule: { dispatchRuleDirect: { roomName: 'support', pin: '1234' } }, trunkIds: ['ST_x'], name: 'd1' } });
|
|
420
|
+
const individual = await h({ service: 'SIP', method: 'CreateSIPDispatchRule', b: { rule: { dispatchRuleIndividual: { roomPrefix: 'call-' } } } });
|
|
421
|
+
const noRoom = await h({ service: 'SIP', method: 'CreateSIPDispatchRule', b: { rule: { dispatchRuleDirect: { pin: '1' } } } });
|
|
422
|
+
const noRule = await h({ service: 'SIP', method: 'CreateSIPDispatchRule', b: {} });
|
|
423
|
+
const listed = await h({ service: 'SIP', method: 'ListSIPDispatchRule', b: {} });
|
|
424
|
+
const byTrunk = await h({ service: 'SIP', method: 'ListSIPDispatchRule', b: { trunkIds: ['ST_x'] } });
|
|
425
|
+
const deleted = await h({ service: 'SIP', method: 'DeleteSIPDispatchRule', b: { sipDispatchRuleId: direct.body.sipDispatchRuleId } });
|
|
426
|
+
const afterDelete = await h({ service: 'SIP', method: 'ListSIPDispatchRule', b: {} });
|
|
427
|
+
const deleteMissing = await h({ service: 'SIP', method: 'DeleteSIPDispatchRule', b: { sipDispatchRuleId: 'SDR_nope' } });
|
|
428
|
+
return direct.status === 200 && direct.body.rule?.dispatchRuleDirect?.roomName === 'support'
|
|
429
|
+
&& individual.status === 200 && individual.body.rule?.dispatchRuleIndividual?.roomPrefix === 'call-'
|
|
430
|
+
&& noRoom.status === 400 && noRule.status === 400 && listed.body.items?.length === 2
|
|
431
|
+
// byTrunk matches the rule scoped to ST_x AND the wildcard (no-trunk) individual rule
|
|
432
|
+
&& byTrunk.body.items?.length === 2
|
|
433
|
+
&& deleted.status === 200 && afterDelete.body.items?.length === 1 && deleteMissing.status === 404;
|
|
434
|
+
})),
|
|
435
|
+
done('livekit.sip.participants', 'sip', 'CreateSIPParticipant dials over a known outbound trunk (adds a room participant) + TransferSIPParticipant', 'api', 'niche', () => withRoot(async (h) => {
|
|
436
|
+
const trunk = await h({ service: 'SIP', method: 'CreateSIPOutboundTrunk', b: { trunk: { name: 't', address: 'sip.p.com', numbers: ['+15550000000'] } } });
|
|
437
|
+
const trunkId = trunk.body.sipTrunkId;
|
|
438
|
+
const dialed = await h({ service: 'SIP', method: 'CreateSIPParticipant', b: { sipTrunkId: trunkId, sipCallTo: '+15551112222', roomName: 'room-call', participantIdentity: 'caller-1', sipNumber: '+15550000000' } });
|
|
439
|
+
const noRoom = await h({ service: 'SIP', method: 'CreateSIPParticipant', b: { sipTrunkId: trunkId, sipCallTo: '+15551112222' } });
|
|
440
|
+
const noNumber = await h({ service: 'SIP', method: 'CreateSIPParticipant', b: { sipTrunkId: trunkId, roomName: 'room-call' } });
|
|
441
|
+
const badTrunk = await h({ service: 'SIP', method: 'CreateSIPParticipant', b: { sipTrunkId: 'ST_missing', sipCallTo: '+1', roomName: 'room-call' } });
|
|
442
|
+
// the dialed caller appears as a room participant
|
|
443
|
+
const inRoom = await h({ method: 'GetParticipant', b: { room: 'room-call', identity: 'caller-1' } });
|
|
444
|
+
const transferred = await h({ service: 'SIP', method: 'TransferSIPParticipant', b: { roomName: 'room-call', participantIdentity: 'caller-1', transferTo: 'tel:+15559998888' } });
|
|
445
|
+
const afterTransfer = await h({ method: 'GetParticipant', b: { room: 'room-call', identity: 'caller-1' } });
|
|
446
|
+
const transferGhost = await h({ service: 'SIP', method: 'TransferSIPParticipant', b: { roomName: 'room-call', participantIdentity: 'ghost', transferTo: 'tel:+1' } });
|
|
447
|
+
return dialed.status === 200 && typeof dialed.body.sipCallId === 'string' && dialed.body.sipCallId.startsWith('SCL_')
|
|
448
|
+
&& dialed.body.participantIdentity === 'caller-1'
|
|
449
|
+
&& noRoom.status === 400 && noNumber.status === 400 && badTrunk.status === 404
|
|
450
|
+
&& inRoom.status === 200 && inRoom.body.kind === 3
|
|
451
|
+
&& transferred.status === 200 && afterTransfer.body.attributes?.['sip.transferTo'] === 'tel:+15559998888'
|
|
452
|
+
&& transferGhost.status === 404;
|
|
453
|
+
})),
|
|
454
|
+
done('livekit.sip.update_trunk', 'sip', 'UpdateSIPInboundTrunk/UpdateSIPOutboundTrunk — replace overwrites whole Info; update field-merges scalars (optional) + lists (ListUpdate set/add/remove/clear)', 'api', 'niche', () => withRoot(async (h) => {
|
|
455
|
+
// --- inbound trunk -------------------------------------------------
|
|
456
|
+
const inCreated = await h({ service: 'SIP', method: 'CreateSIPInboundTrunk', b: { trunk: { name: 'main', numbers: ['+15551230000'], allowedNumbers: ['+1*'], metadata: 'orig', authUsername: 'u', authPassword: 'p' } } });
|
|
457
|
+
const inId = inCreated.body.sipTrunkId;
|
|
458
|
+
// field-merge: rename + ListUpdate add a number + set allowedNumbers; leave authUsername/password untouched
|
|
459
|
+
const inMerged = await h({ service: 'SIP', method: 'UpdateSIPInboundTrunk', b: { sipTrunkId: inId, update: { name: 'renamed', numbers: { add: ['+15559990000'] }, allowedNumbers: { set: ['+44*'] } } } });
|
|
460
|
+
// full-replace: a complete Info wholly replaces (authUsername must be cleared since absent)
|
|
461
|
+
const inReplaced = await h({ service: 'SIP', method: 'UpdateSIPInboundTrunk', b: { sipTrunkId: inId, replace: { name: 'fresh', numbers: ['+10000000000'] } } });
|
|
462
|
+
const inAfter = await h({ service: 'SIP', method: 'ListSIPInboundTrunk', b: { trunkIds: [inId] } });
|
|
463
|
+
const inRow = inAfter.body.items?.[0];
|
|
464
|
+
const inMissing = await h({ service: 'SIP', method: 'UpdateSIPInboundTrunk', b: { sipTrunkId: 'ST_nope', update: { name: 'x' } } });
|
|
465
|
+
// --- outbound trunk ------------------------------------------------
|
|
466
|
+
const outCreated = await h({ service: 'SIP', method: 'CreateSIPOutboundTrunk', b: { trunk: { name: 'pstn', address: 'a.com', numbers: ['+15557654321'], destinationCountry: 'US' } } });
|
|
467
|
+
const outId = outCreated.body.sipTrunkId;
|
|
468
|
+
const outMerged = await h({ service: 'SIP', method: 'UpdateSIPOutboundTrunk', b: { sipTrunkId: outId, update: { address: 'b.com', numbers: { remove: ['+15557654321'], add: ['+15550001111'] } } } });
|
|
469
|
+
const outMissing = await h({ service: 'SIP', method: 'UpdateSIPOutboundTrunk', b: { sipTrunkId: 'ST_nope', replace: { address: 'x' } } });
|
|
470
|
+
return inCreated.status === 200
|
|
471
|
+
// field-merge asserted VALUES: renamed, numbers appended, allowedNumbers set-replaced, untouched scalar preserved
|
|
472
|
+
&& inMerged.status === 200 && inMerged.body.name === 'renamed'
|
|
473
|
+
&& Array.isArray(inMerged.body.numbers) && inMerged.body.numbers.length === 2
|
|
474
|
+
&& inMerged.body.numbers.includes('+15559990000')
|
|
475
|
+
&& JSON.stringify(inMerged.body.allowedNumbers) === JSON.stringify(['+44*'])
|
|
476
|
+
&& inMerged.body.authUsername === 'u' && inMerged.body.metadata === 'orig'
|
|
477
|
+
// full-replace asserted: name fresh, numbers replaced, absent authUsername cleared
|
|
478
|
+
&& inReplaced.status === 200 && inReplaced.body.name === 'fresh'
|
|
479
|
+
&& JSON.stringify(inReplaced.body.numbers) === JSON.stringify(['+10000000000'])
|
|
480
|
+
&& inReplaced.body.authUsername === '' && inReplaced.body.metadata === ''
|
|
481
|
+
// state folded from kernel log (read-back via List)
|
|
482
|
+
&& inRow?.name === 'fresh' && JSON.stringify(inRow?.numbers) === JSON.stringify(['+10000000000'])
|
|
483
|
+
// negative path: unknown id => Twirp not_found
|
|
484
|
+
&& inMissing.status === 404 && inMissing.body.code === 'not_found'
|
|
485
|
+
// outbound field-merge: address overwritten, list remove+add applied
|
|
486
|
+
&& outMerged.status === 200 && outMerged.body.address === 'b.com'
|
|
487
|
+
&& JSON.stringify(outMerged.body.numbers) === JSON.stringify(['+15550001111'])
|
|
488
|
+
&& outMerged.body.destinationCountry === 'US'
|
|
489
|
+
&& outMissing.status === 404;
|
|
490
|
+
})),
|
|
491
|
+
done('livekit.sip.update_dispatch_rule', 'sip', 'UpdateSIPDispatchRule — replace overwrites whole Info; update field-merges name/metadata/attributes + trunkIds (ListUpdate) + the rule oneof', 'api', 'niche', () => withRoot(async (h) => {
|
|
492
|
+
const created = await h({ service: 'SIP', method: 'CreateSIPDispatchRule', b: { rule: { dispatchRuleDirect: { roomName: 'support', pin: '1234' } }, trunkIds: ['ST_a'], name: 'd1', metadata: 'm' } });
|
|
493
|
+
const id = created.body.sipDispatchRuleId;
|
|
494
|
+
// field-merge: rename, ListUpdate add a trunk, swap the rule oneof to individual; metadata untouched
|
|
495
|
+
const merged = await h({ service: 'SIP', method: 'UpdateSIPDispatchRule', b: { sipDispatchRuleId: id, update: { name: 'd1-renamed', trunkIds: { add: ['ST_b'] }, rule: { dispatchRuleIndividual: { roomPrefix: 'call-' } } } } });
|
|
496
|
+
// full-replace: complete Info wholly replaces (metadata cleared, trunkIds reset)
|
|
497
|
+
const replaced = await h({ service: 'SIP', method: 'UpdateSIPDispatchRule', b: { sipDispatchRuleId: id, replace: { rule: { dispatchRuleDirect: { roomName: 'sales' } }, name: 'fresh' } } });
|
|
498
|
+
const after = await h({ service: 'SIP', method: 'ListSIPDispatchRule', b: { dispatchRuleIds: [id] } });
|
|
499
|
+
const row = after.body.items?.[0];
|
|
500
|
+
const badRule = await h({ service: 'SIP', method: 'UpdateSIPDispatchRule', b: { sipDispatchRuleId: id, replace: { name: 'x' } } });
|
|
501
|
+
const missing = await h({ service: 'SIP', method: 'UpdateSIPDispatchRule', b: { sipDispatchRuleId: 'SDR_nope', update: { name: 'x' } } });
|
|
502
|
+
return created.status === 200
|
|
503
|
+
&& merged.status === 200 && merged.body.name === 'd1-renamed' && merged.body.metadata === 'm'
|
|
504
|
+
&& JSON.stringify(merged.body.trunkIds) === JSON.stringify(['ST_a', 'ST_b'])
|
|
505
|
+
&& merged.body.rule?.dispatchRuleIndividual?.roomPrefix === 'call-'
|
|
506
|
+
&& !merged.body.rule?.dispatchRuleDirect
|
|
507
|
+
// full-replace asserted VALUES: rule back to direct/sales, metadata cleared, trunkIds reset
|
|
508
|
+
&& replaced.status === 200 && replaced.body.rule?.dispatchRuleDirect?.roomName === 'sales'
|
|
509
|
+
&& replaced.body.name === 'fresh' && replaced.body.metadata === ''
|
|
510
|
+
&& JSON.stringify(replaced.body.trunkIds) === JSON.stringify([])
|
|
511
|
+
// state folded from kernel log
|
|
512
|
+
&& row?.name === 'fresh' && row?.rule?.dispatchRuleDirect?.roomName === 'sales'
|
|
513
|
+
// negative paths: replace without a rule => invalid_argument; unknown id => not_found
|
|
514
|
+
&& badRule.status === 400 && missing.status === 404 && missing.body.code === 'not_found';
|
|
515
|
+
})),
|
|
516
|
+
// Honestly-still-undone SIP surfaces (real Twirp methods, not yet modeled):
|
|
517
|
+
// the singular get-by-id reads. Unmodeled ops fail like the vendor (not_found
|
|
518
|
+
// Twirp error) — never faked.
|
|
519
|
+
todo('livekit.sip.get_trunk', 'sip', 'GetSIPInboundTrunk/GetSIPOutboundTrunk — fetch a single trunk by id', 'api', 'niche'),
|
|
520
|
+
done('livekit.agents.dispatch_create', 'agents', 'CreateDispatch records an explicit agent dispatch; room+agentName required', 'api', 'niche', () => withRoot(async (h) => {
|
|
521
|
+
await h({ method: 'CreateRoom', b: { name: 'agent-room' } });
|
|
522
|
+
const created = await h({ service: 'AgentDispatchService', method: 'CreateDispatch', b: { room: 'agent-room', agentName: 'transcriber', metadata: '{"lang":"en"}' } });
|
|
523
|
+
const noRoom = await h({ service: 'AgentDispatchService', method: 'CreateDispatch', b: { agentName: 'x' } });
|
|
524
|
+
const noAgent = await h({ service: 'AgentDispatchService', method: 'CreateDispatch', b: { room: 'agent-room' } });
|
|
525
|
+
const b = created.body;
|
|
526
|
+
return created.status === 200 && typeof b.id === 'string' && b.id.startsWith('AD_')
|
|
527
|
+
&& b.agentName === 'transcriber' && b.room === 'agent-room' && b.metadata === '{"lang":"en"}'
|
|
528
|
+
&& noRoom.status === 400 && noAgent.status === 400;
|
|
529
|
+
})),
|
|
530
|
+
done('livekit.agents.dispatch_list', 'agents', 'ListDispatch filters by room and dispatch id, excludes deleted', 'api', 'niche', () => withRoot(async (h) => {
|
|
531
|
+
const a = await h({ service: 'AgentDispatchService', method: 'CreateDispatch', b: { room: 'r1', agentName: 'a1' } });
|
|
532
|
+
await h({ service: 'AgentDispatchService', method: 'CreateDispatch', b: { room: 'r2', agentName: 'a2' } });
|
|
533
|
+
const all = await h({ service: 'AgentDispatchService', method: 'ListDispatch', b: {} });
|
|
534
|
+
const byRoom = await h({ service: 'AgentDispatchService', method: 'ListDispatch', b: { room: 'r1' } });
|
|
535
|
+
const byId = await h({ service: 'AgentDispatchService', method: 'ListDispatch', b: { room: 'r1', dispatchId: a.body.id } });
|
|
536
|
+
return all.body.agentDispatches?.length === 2 && byRoom.body.agentDispatches?.length === 1
|
|
537
|
+
&& byRoom.body.agentDispatches[0].room === 'r1' && byId.body.agentDispatches?.length === 1;
|
|
538
|
+
})),
|
|
539
|
+
done('livekit.agents.dispatch_delete', 'agents', 'DeleteDispatch removes a dispatch from ListDispatch; deleting twice is not_found', 'api', 'niche', () => withRoot(async (h) => {
|
|
540
|
+
const created = await h({ service: 'AgentDispatchService', method: 'CreateDispatch', b: { room: 'del-room', agentName: 'a' } });
|
|
541
|
+
const id = created.body.id;
|
|
542
|
+
const deleted = await h({ service: 'AgentDispatchService', method: 'DeleteDispatch', b: { dispatchId: id, room: 'del-room' } });
|
|
543
|
+
const listed = await h({ service: 'AgentDispatchService', method: 'ListDispatch', b: { room: 'del-room' } });
|
|
544
|
+
const again = await h({ service: 'AgentDispatchService', method: 'DeleteDispatch', b: { dispatchId: id, room: 'del-room' } });
|
|
545
|
+
return deleted.status === 200 && listed.body.agentDispatches?.length === 0 && again.status === 404;
|
|
546
|
+
})),
|
|
547
|
+
done('livekit.tokens.access_grants', 'tokens', 'AccessToken mint+verify with full video-grant parity (round-trips and rejects forgery)', 'api', 'core', async () => {
|
|
548
|
+
// Mint a join token with rich video grants, verify it round-trips with the same grants.
|
|
549
|
+
const video = { roomJoin: true, room: 'r1', canPublish: true, canSubscribe: false, canPublishData: true, hidden: true, roomRecord: false };
|
|
550
|
+
const jwt = mintAccessToken({ apiKey: API_KEY, apiSecret: API_SECRET, identity: 'alice', name: 'Alice', nowSeconds: 1000, ttlSeconds: 3600, video, metadata: '{"x":1}' });
|
|
551
|
+
const ok = verifyAccessToken(jwt, (k) => (k === API_KEY ? API_SECRET : undefined), { nowSeconds: 1000 });
|
|
552
|
+
if (!ok.ok)
|
|
553
|
+
return false;
|
|
554
|
+
const g = ok.grants;
|
|
555
|
+
if (g.sub !== 'alice' || g.name !== 'Alice' || g.metadata !== '{"x":1}')
|
|
556
|
+
return false;
|
|
557
|
+
if (g.video?.roomJoin !== true || g.video?.room !== 'r1' || g.video?.canPublish !== true || g.video?.canSubscribe !== false || g.video?.hidden !== true)
|
|
558
|
+
return false;
|
|
559
|
+
// Forged signature must fail.
|
|
560
|
+
const forged = verifyAccessToken(jwt.slice(0, -3) + 'AAA', (k) => (k === API_KEY ? API_SECRET : undefined), { nowSeconds: 1000 });
|
|
561
|
+
// Wrong secret must fail.
|
|
562
|
+
const wrongSecret = verifyAccessToken(jwt, () => 'x'.repeat(36), { nowSeconds: 1000 });
|
|
563
|
+
// Expired must fail.
|
|
564
|
+
const expired = verifyAccessToken(jwt, (k) => (k === API_KEY ? API_SECRET : undefined), { nowSeconds: 1000 + 4000 });
|
|
565
|
+
// Unknown key must fail.
|
|
566
|
+
const unknown = verifyAccessToken(jwt, () => undefined, { nowSeconds: 1000 });
|
|
567
|
+
return forged.ok === false && wrongSecret.ok === false && expired.ok === false && expired.reason === 'expired' && unknown.ok === false && unknown.reason === 'unknown_key';
|
|
568
|
+
}),
|
|
569
|
+
done('livekit.tokens.sip_grants', 'tokens', 'SIP grants are carried and verified in access tokens', 'api', 'niche', async () => {
|
|
570
|
+
const jwt = mintAccessToken({ apiKey: API_KEY, apiSecret: API_SECRET, identity: 'caller', nowSeconds: 0, ttlSeconds: 600, sip: { admin: true, call: true } });
|
|
571
|
+
const ok = verifyAccessToken(jwt, (k) => (k === API_KEY ? API_SECRET : undefined), { nowSeconds: 0 });
|
|
572
|
+
return ok.ok && ok.grants.sip?.admin === true && ok.grants.sip?.call === true;
|
|
573
|
+
}),
|
|
574
|
+
done('livekit.webhooks.receiver_verify', 'webhooks', 'Webhook signing+verification (HMAC header + body sha256) round-trips and rejects tampering', 'api', 'common', async () => {
|
|
575
|
+
const body = buildWebhookBody({ event: 'room_started', id: 'EV_1', createdAtSeconds: 100, room: { name: 'r1', sid: 'RM_1' } });
|
|
576
|
+
const header = signWebhookHeader(body, API_KEY, API_SECRET, 100);
|
|
577
|
+
const ok = verifyWebhook(body, header, API_SECRET, { nowSeconds: 100 });
|
|
578
|
+
const tamperedBody = verifyWebhook(body + ' ', header, API_SECRET, { nowSeconds: 100 });
|
|
579
|
+
const badSig = verifyWebhook(body, header.slice(0, -3) + 'AAA', API_SECRET, { nowSeconds: 100 });
|
|
580
|
+
const noHeader = verifyWebhook(body, undefined, API_SECRET, { nowSeconds: 100 });
|
|
581
|
+
return ok.ok && ok.event.event === 'room_started' && tamperedBody.ok === false && tamperedBody.reason === 'hash_mismatch'
|
|
582
|
+
&& badSig.ok === false && noHeader.ok === false && noHeader.reason === 'missing_header';
|
|
583
|
+
}),
|
|
584
|
+
done('livekit.webhooks.room_events', 'webhooks', 'room_started/room_finished/participant_joined webhook bodies carry the right shape', 'api', 'common', async () => {
|
|
585
|
+
for (const event of ['room_started', 'room_finished', 'participant_joined', 'participant_left']) {
|
|
586
|
+
const body = buildWebhookBody({ event, createdAtSeconds: 5, room: { name: 'r', sid: 'RM_r' }, participant: { identity: 'u' } });
|
|
587
|
+
const v = verifyWebhook(body, signWebhookHeader(body, API_KEY, API_SECRET, 5), API_SECRET, { nowSeconds: 5 });
|
|
588
|
+
if (!v.ok || v.event.event !== event || v.event.room?.name !== 'r')
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
return true;
|
|
592
|
+
}),
|
|
593
|
+
done('livekit.webhooks.egress_events', 'webhooks', 'egress_started/updated/ended webhook bodies carry egressInfo and verify', 'api', 'common', async () => {
|
|
594
|
+
for (const event of ['egress_started', 'egress_updated', 'egress_ended']) {
|
|
595
|
+
const body = buildWebhookBody({ event, createdAtSeconds: 9, egressInfo: { egressId: 'EG_1', roomName: 'r', status: 1 } });
|
|
596
|
+
const v = verifyWebhook(body, signWebhookHeader(body, API_KEY, API_SECRET, 9), API_SECRET, { nowSeconds: 9 });
|
|
597
|
+
if (!v.ok || v.event.event !== event || v.event.egressInfo?.egressId !== 'EG_1')
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
return true;
|
|
601
|
+
}),
|
|
602
|
+
done('livekit.errors.auth_required', 'errors', 'Missing/invalid/expired JWT returns unauthenticated when auth is enforced', 'api', 'common', () => withRoot(async (h) => {
|
|
603
|
+
const apiKeys = { [API_KEY]: API_SECRET };
|
|
604
|
+
const noToken = await h({ method: 'ListRooms', b: {}, apiKeys, nowSeconds: 1000 });
|
|
605
|
+
const garbage = await h({ method: 'ListRooms', b: {}, apiKeys, authToken: 'not.a.jwt', nowSeconds: 1000 });
|
|
606
|
+
const wrongKeyToken = mintAccessToken({ apiKey: 'other', apiSecret: API_SECRET, nowSeconds: 1000, ttlSeconds: 600, video: { roomList: true } });
|
|
607
|
+
const unknownKey = await h({ method: 'ListRooms', b: {}, apiKeys, authToken: wrongKeyToken, nowSeconds: 1000 });
|
|
608
|
+
const expiredToken = mintAccessToken({ apiKey: API_KEY, apiSecret: API_SECRET, nowSeconds: 1000, ttlSeconds: 10, video: { roomList: true } });
|
|
609
|
+
const expired = await h({ method: 'ListRooms', b: {}, apiKeys, authToken: expiredToken, nowSeconds: 5000 });
|
|
610
|
+
// a valid token still works (auth layer is not blanket-deny)
|
|
611
|
+
const goodToken = mintAccessToken({ apiKey: API_KEY, apiSecret: API_SECRET, nowSeconds: 1000, ttlSeconds: 600, video: { roomList: true } });
|
|
612
|
+
const good = await h({ method: 'ListRooms', b: {}, apiKeys, authToken: goodToken, nowSeconds: 1000 });
|
|
613
|
+
return noToken.status === 401 && noToken.body.code === 'unauthenticated'
|
|
614
|
+
&& garbage.status === 401 && unknownKey.status === 401 && expired.status === 401 && good.status === 200;
|
|
615
|
+
})),
|
|
616
|
+
done('livekit.errors.permission_denied', 'errors', 'Valid token lacking the required grant returns permission_denied', 'api', 'common', () => withRoot(async (h) => {
|
|
617
|
+
const apiKeys = { [API_KEY]: API_SECRET };
|
|
618
|
+
// token has roomList but not roomCreate
|
|
619
|
+
const listOnly = mintAccessToken({ apiKey: API_KEY, apiSecret: API_SECRET, nowSeconds: 0, ttlSeconds: 600, video: { roomList: true } });
|
|
620
|
+
const create = await h({ method: 'CreateRoom', b: { name: 'x' }, apiKeys, authToken: listOnly, nowSeconds: 0 });
|
|
621
|
+
const list = await h({ method: 'ListRooms', b: {}, apiKeys, authToken: listOnly, nowSeconds: 0 });
|
|
622
|
+
// recorder-scoped token cannot create rooms but can start egress
|
|
623
|
+
const recorder = mintAccessToken({ apiKey: API_KEY, apiSecret: API_SECRET, nowSeconds: 0, ttlSeconds: 600, video: { roomCreate: true, roomRecord: true } });
|
|
624
|
+
await h({ method: 'CreateRoom', b: { name: 'rec' }, apiKeys, authToken: recorder, nowSeconds: 0 });
|
|
625
|
+
const egress = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'rec' }, apiKeys, authToken: recorder, nowSeconds: 0 });
|
|
626
|
+
const egressDenied = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'rec' }, apiKeys, authToken: listOnly, nowSeconds: 0 });
|
|
627
|
+
return create.status === 403 && create.body.code === 'permission_denied' && list.status === 200
|
|
628
|
+
&& egress.status === 200 && egressDenied.status === 403;
|
|
629
|
+
})),
|
|
630
|
+
done('livekit.errors.quota_capacity', 'errors', 'Egress start beyond the active-egress quota returns resource_exhausted', 'api', 'common', () => withRoot(async (h) => {
|
|
631
|
+
await h({ method: 'CreateRoom', b: { name: 'q-room' } });
|
|
632
|
+
const first = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'q-room' }, maxActiveEgress: 1 });
|
|
633
|
+
const second = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'q-room' }, maxActiveEgress: 1 });
|
|
634
|
+
// stopping one frees capacity again
|
|
635
|
+
await h({ service: 'Egress', method: 'StopEgress', b: { egressId: first.body.egressId } });
|
|
636
|
+
const third = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'q-room' }, maxActiveEgress: 1 });
|
|
637
|
+
return first.status === 200 && second.status === 429 && second.body.code === 'resource_exhausted' && third.status === 200;
|
|
638
|
+
})),
|
|
639
|
+
done('livekit.connector.push_egress_stop', 'connector', 'Connector pushes pending egress stop actions idempotently', 'connector', 'common', async () => {
|
|
640
|
+
const root = mkdtempSync(join(tmpdir(), 'livekit-egress-stop-cap-'));
|
|
641
|
+
try {
|
|
642
|
+
await applyTwinWrite('livekit', {
|
|
643
|
+
operation: 'egress.stop',
|
|
644
|
+
subjectType: 'egress',
|
|
645
|
+
subjectId: 'egress_EG_push',
|
|
646
|
+
fields: { id: 'egress_EG_push', egressId: 'EG_push' },
|
|
647
|
+
occurredAt: '1970-01-01T00:00:00.000Z',
|
|
648
|
+
actor: { kind: 'system' },
|
|
649
|
+
}, root);
|
|
650
|
+
let calls = 0;
|
|
651
|
+
const pushed = await performPending(async (req) => {
|
|
652
|
+
calls++;
|
|
653
|
+
if (req.service !== 'Egress' || req.method !== 'StopEgress')
|
|
654
|
+
return { status: 400, data: {} };
|
|
655
|
+
return { status: 200, data: { egressId: 'EG_push', status: EGRESS_STATUS.COMPLETE } };
|
|
656
|
+
}, { root });
|
|
657
|
+
const again = await performPending(async () => ({ status: 200, data: {} }), { root });
|
|
658
|
+
return pushed === 1 && calls === 1 && again === 0 && pendingActions('livekit', root).length === 0;
|
|
659
|
+
}
|
|
660
|
+
finally {
|
|
661
|
+
rmSync(root, { recursive: true, force: true });
|
|
662
|
+
}
|
|
663
|
+
}),
|
|
664
|
+
done('livekit.connector.ingress_pull', 'connector', 'Connector pulls ingress resources through the injected client', 'connector', 'common', async () => {
|
|
665
|
+
const root = mkdtempSync(join(tmpdir(), 'livekit-ingress-pull-cap-'));
|
|
666
|
+
try {
|
|
667
|
+
const exec = async (req) => {
|
|
668
|
+
if (req.service === 'Ingress' && req.method === 'ListIngress') {
|
|
669
|
+
return { status: 200, data: { items: [{ ingressId: 'IN_remote', name: 'live', roomName: 'r', inputType: INGRESS_INPUT.RTMP_INPUT, participantIdentity: 'p' }] } };
|
|
670
|
+
}
|
|
671
|
+
if (req.service === 'RoomService' && req.method === 'ListRooms')
|
|
672
|
+
return { status: 200, data: { rooms: [] } };
|
|
673
|
+
return { status: 200, data: { items: [] } };
|
|
674
|
+
};
|
|
675
|
+
await pullLiveKitSnapshot(exec, [], { root });
|
|
676
|
+
await pullLiveKitSnapshot(exec, [], { root });
|
|
677
|
+
const rows = projectResources('livekit', root);
|
|
678
|
+
return rows.filter((r) => r.type === 'ingress' && r.ingressId === 'IN_remote').length === 1;
|
|
679
|
+
}
|
|
680
|
+
finally {
|
|
681
|
+
rmSync(root, { recursive: true, force: true });
|
|
682
|
+
}
|
|
683
|
+
}),
|
|
684
|
+
done('livekit.connector.sip_pull', 'connector', 'Connector pulls SIP inbound/outbound trunks + dispatch rules through the injected client (idempotent)', 'connector', 'niche', async () => {
|
|
685
|
+
const root = mkdtempSync(join(tmpdir(), 'livekit-sip-pull-cap-'));
|
|
686
|
+
try {
|
|
687
|
+
const exec = async (req) => {
|
|
688
|
+
if (req.service === 'SIP' && req.method === 'ListSIPInboundTrunk')
|
|
689
|
+
return { status: 200, data: { items: [{ sipTrunkId: 'ST_in', name: 'in', numbers: ['+1'] }] } };
|
|
690
|
+
if (req.service === 'SIP' && req.method === 'ListSIPOutboundTrunk')
|
|
691
|
+
return { status: 200, data: { items: [{ sipTrunkId: 'ST_out', name: 'out', address: 'sip.x' }] } };
|
|
692
|
+
if (req.service === 'SIP' && req.method === 'ListSIPDispatchRule')
|
|
693
|
+
return { status: 200, data: { items: [{ sipDispatchRuleId: 'SDR_1', rule: { case: 'dispatchRuleDirect', value: { roomName: 'r' } } }] } };
|
|
694
|
+
return { status: 200, data: { items: [] } };
|
|
695
|
+
};
|
|
696
|
+
await pullLiveKitSIP(exec, { root });
|
|
697
|
+
await pullLiveKitSIP(exec, { root });
|
|
698
|
+
const rows = projectResources('livekit', root);
|
|
699
|
+
return rows.filter((r) => r.type === 'sip_inbound_trunk' && r.sipTrunkId === 'ST_in').length === 1
|
|
700
|
+
&& rows.filter((r) => r.type === 'sip_outbound_trunk' && r.sipTrunkId === 'ST_out').length === 1
|
|
701
|
+
&& rows.filter((r) => r.type === 'sip_dispatch_rule' && r.sipDispatchRuleId === 'SDR_1').length === 1;
|
|
702
|
+
}
|
|
703
|
+
finally {
|
|
704
|
+
rmSync(root, { recursive: true, force: true });
|
|
705
|
+
}
|
|
706
|
+
}),
|
|
707
|
+
done('livekit.connector.agent_pull', 'connector', 'Connector pulls agent dispatch resources per-room through the injected client (idempotent)', 'connector', 'niche', async () => {
|
|
708
|
+
const root = mkdtempSync(join(tmpdir(), 'livekit-agent-pull-cap-'));
|
|
709
|
+
try {
|
|
710
|
+
const exec = async (req) => {
|
|
711
|
+
if (req.service === 'AgentDispatchService' && req.method === 'ListDispatch') {
|
|
712
|
+
return { status: 200, data: { agentDispatches: [{ id: 'AD_remote', agentName: 'a', room: 'remote-room', metadata: '', state: { createdAt: '5' } }] } };
|
|
713
|
+
}
|
|
714
|
+
return { status: 200, data: { agentDispatches: [] } };
|
|
715
|
+
};
|
|
716
|
+
await pullLiveKitAgentDispatch(exec, ['remote-room'], { root });
|
|
717
|
+
await pullLiveKitAgentDispatch(exec, ['remote-room'], { root });
|
|
718
|
+
const rows = projectResources('livekit', root);
|
|
719
|
+
return rows.filter((r) => r.type === 'agent_dispatch' && r.dispatchId === 'AD_remote').length === 1;
|
|
720
|
+
}
|
|
721
|
+
finally {
|
|
722
|
+
rmSync(root, { recursive: true, force: true });
|
|
723
|
+
}
|
|
724
|
+
}),
|
|
725
|
+
];
|
|
726
|
+
export async function livekitCapabilities() {
|
|
727
|
+
return checkCapabilities('livekit', LIVEKIT_CAPABILITIES);
|
|
728
|
+
}
|