voltkit-rtc-server 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,448 @@
1
+ """
2
+ The shapes of what the calls return, as the wire sends them: plain dicts and
3
+ lists with camelCase keys. These TypedDicts are for editors and type checkers;
4
+ nothing is converted, and a field a newer controller adds comes through as is.
5
+
6
+ Money is credit units (cu): 1 credit = 6,000,000 cu. Every cu amount is a
7
+ decimal string, never a number: a JSON number is a float, and a float rounds a
8
+ large balance without telling you. Do arithmetic with int(amount).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Literal, Optional, TypedDict
14
+
15
+ __all__ = [
16
+ "Allowance",
17
+ "BroadcastStream",
18
+ "BroadcastStreamWithPublish",
19
+ "BurnView",
20
+ "ChatInfo",
21
+ "ChatMessage",
22
+ "ChatToken",
23
+ "DailyUsage",
24
+ "LegacyFunds",
25
+ "Participant",
26
+ "PlaybackPolicy",
27
+ "PlaybackUrls",
28
+ "ProjectBalance",
29
+ "PublishGrant",
30
+ "Role",
31
+ "Room",
32
+ "RoomList",
33
+ "RoomToken",
34
+ "RoomView",
35
+ "RoomWithHostToken",
36
+ "RtcConversion",
37
+ "RtcMode",
38
+ "RtcRates",
39
+ "RtcSurcharge",
40
+ "RtcTierRate",
41
+ "RtcUnit",
42
+ "StreamAction",
43
+ "StreamEvent",
44
+ "StreamToken",
45
+ "Usage",
46
+ "UsageSummary",
47
+ "WalletFunds",
48
+ "WalletLowState",
49
+ "WalletScopeAmount",
50
+ ]
51
+
52
+ Role = Literal["host", "cohost", "moderator", "viewer"]
53
+ PlaybackPolicy = Literal["public", "token"]
54
+ StreamAction = Literal["play", "publish"]
55
+
56
+
57
+ # --- rooms --------------------------------------------------------------------
58
+
59
+
60
+ class _RoomRequired(TypedDict):
61
+ roomId: str
62
+ tenantId: str
63
+ originNode: str
64
+ state: str
65
+ hostIdentity: str
66
+ #: Unix milliseconds.
67
+ createdAt: int
68
+ nodes: list[str]
69
+
70
+
71
+ class Room(_RoomRequired, total=False):
72
+ participants: int
73
+ demo: bool
74
+ metadata: Any
75
+
76
+
77
+ class _RoomWithHostTokenRequired(_RoomRequired):
78
+ hostToken: str
79
+ wsUrl: str
80
+ #: Unix seconds.
81
+ expiresAt: int
82
+ #: True when the room already existed and was returned rather than created.
83
+ existed: bool
84
+
85
+
86
+ class RoomWithHostToken(_RoomWithHostTokenRequired, total=False):
87
+ participants: int
88
+ demo: bool
89
+ metadata: Any
90
+
91
+
92
+ class RoomToken(TypedDict):
93
+ token: str
94
+ wsUrl: str
95
+ role: Role
96
+ identity: str
97
+ roomId: str
98
+ #: Unix seconds.
99
+ expiresAt: int
100
+
101
+
102
+ class _ParticipantRequired(TypedDict):
103
+ identity: str
104
+ role: Role
105
+ nodeId: str
106
+ handRaised: bool
107
+ muted: bool
108
+
109
+
110
+ class Participant(_ParticipantRequired, total=False):
111
+ handMetadata: Any
112
+ kicked: bool
113
+ #: A moderator has switched this participant's camera off.
114
+ videoBlocked: bool
115
+ #: When they joined, in unix milliseconds.
116
+ joinedAt: int
117
+ metadata: Any
118
+
119
+
120
+ class RoomView(TypedDict, total=False):
121
+ room: Room
122
+ participants: list[Participant]
123
+ producers: list[dict[str, Any]]
124
+
125
+
126
+ class RoomList(TypedDict):
127
+ rooms: list[Room]
128
+
129
+
130
+ # --- chat ---------------------------------------------------------------------
131
+
132
+
133
+ class ChatToken(TypedDict):
134
+ token: str
135
+ #: Where a chat-only client connects. Ends in /ws/chat.
136
+ wsUrl: str
137
+ role: Role
138
+ identity: str
139
+ channel: str
140
+ expiresAt: int
141
+
142
+
143
+ class ChatInfo(TypedDict):
144
+ channel: str
145
+ members: int
146
+
147
+
148
+ class _ChatMessageRequired(TypedDict):
149
+ #: Monotonic within a channel: the oldest id you hold is a paging cursor.
150
+ id: str
151
+ channel: str
152
+ #: Who it is from; "system" for a backend message that named nobody.
153
+ from_: str # noqa: F841 - the wire key is "from"; see ChatMessage below.
154
+ type: str
155
+ #: Unix milliseconds.
156
+ ts: int
157
+
158
+
159
+ # "from" is a keyword, so the wire key cannot be a class attribute: this is the
160
+ # functional form, with the same fields as above plus the optional ones.
161
+ ChatMessage = TypedDict(
162
+ "ChatMessage",
163
+ {
164
+ "id": str,
165
+ "channel": str,
166
+ "from": str,
167
+ "to": str,
168
+ "type": str,
169
+ "text": str,
170
+ "metadata": Any,
171
+ "clientMsgId": str,
172
+ "ts": int,
173
+ },
174
+ total=False,
175
+ )
176
+
177
+
178
+ # --- broadcast ----------------------------------------------------------------
179
+
180
+
181
+ class PlaybackUrls(TypedDict, total=False):
182
+ llhls: str
183
+ webrtc: str
184
+ token: str
185
+ expiresAt: int
186
+
187
+
188
+ class PublishGrant(TypedDict):
189
+ #: Complete: hand it to the encoder as it is. It carries the token.
190
+ whipUrl: str
191
+ token: str
192
+ expiresAt: int
193
+
194
+
195
+ class _BroadcastStreamRequired(TypedDict):
196
+ streamId: str
197
+ tenantId: str
198
+ #: The name the media server knows the stream by. Random, so a playback
199
+ #: URL cannot be derived from a stream id.
200
+ streamKey: str
201
+ state: Literal["idle", "live"]
202
+ playback: PlaybackPolicy
203
+ createdAt: int
204
+ #: Playback sessions the media server serves itself; an audience behind a CDN is not counted.
205
+ viewers: int
206
+ peakViewers: int
207
+ playbackUrls: PlaybackUrls
208
+
209
+
210
+ class BroadcastStream(_BroadcastStreamRequired, total=False):
211
+ startedAt: int
212
+ endedAt: int
213
+ metadata: Any
214
+
215
+
216
+ class _BroadcastStreamWithPublishRequired(_BroadcastStreamRequired):
217
+ #: Only ever returned by create_stream.
218
+ publish: PublishGrant
219
+
220
+
221
+ class BroadcastStreamWithPublish(_BroadcastStreamWithPublishRequired, total=False):
222
+ startedAt: int
223
+ endedAt: int
224
+ metadata: Any
225
+
226
+
227
+ class _StreamTokenRequired(TypedDict):
228
+ streamId: str
229
+ action: StreamAction
230
+ token: str
231
+ expiresAt: int
232
+
233
+
234
+ class StreamToken(_StreamTokenRequired, total=False):
235
+ #: Present when action is "publish".
236
+ whipUrl: str
237
+ #: Present when action is "play".
238
+ llhls: str
239
+ webrtc: str
240
+
241
+
242
+ # --- usage --------------------------------------------------------------------
243
+
244
+
245
+ class _UsageRequired(TypedDict):
246
+ participantSeconds: int
247
+ publisherSeconds: int
248
+ roomSeconds: int
249
+ roomsCreated: int
250
+ peakParticipants: int
251
+ chatMessages: int
252
+ chatConnectionSeconds: int
253
+ broadcastSeconds: int
254
+ broadcastViewerSeconds: int
255
+ streamsStarted: int
256
+
257
+
258
+ class Usage(_UsageRequired, total=False):
259
+ #: Everything that counts against an allowance: room plus broadcast seconds.
260
+ billableSeconds: int
261
+ #: The part of billableSeconds a free allowance covered.
262
+ freeSeconds: int
263
+
264
+
265
+ class DailyUsage(Usage, total=False):
266
+ #: UTC day, YYYY-MM-DD.
267
+ day: str
268
+
269
+
270
+ class Allowance(TypedDict, total=False):
271
+ plan: Literal["metered", "unlimited"]
272
+ freeDailyMinutes: int
273
+ freeMonthlyMinutes: int
274
+ dailyCapMinutes: int
275
+ monthlyCapMinutes: int
276
+ usedTodaySeconds: int
277
+ usedMonthSeconds: int
278
+ freeUsedTodaySeconds: int
279
+ freeUsedMonthSeconds: int
280
+ freeRemainingSeconds: int
281
+ paidRemainingSeconds: int
282
+ remainingSeconds: int
283
+ #: Why the account cannot be used right now, if it cannot.
284
+ blocked: str
285
+
286
+
287
+ class _UsageSummaryRequired(TypedDict):
288
+ tenantId: str
289
+ plan: Literal["metered", "unlimited"]
290
+ status: Literal["active", "suspended"]
291
+ minutesLimit: int
292
+ usedMinutes: int
293
+ #: -1 on the unlimited plan.
294
+ minutesRemaining: int
295
+ today: Usage
296
+ month: Usage
297
+ lifetime: Usage
298
+
299
+
300
+ class UsageSummary(_UsageSummaryRequired, total=False):
301
+ sharedUsedMinutes: int
302
+ allowance: Allowance
303
+
304
+
305
+ # --- billing ------------------------------------------------------------------
306
+
307
+
308
+ class LegacyFunds(TypedDict):
309
+ #: A plan whose own minutes never run out; ownSeconds and totalSeconds are then -1.
310
+ unlimited: bool
311
+ freeSeconds: int
312
+ ownSeconds: int
313
+ sharedSeconds: int
314
+ totalSeconds: int
315
+
316
+
317
+ class WalletScopeAmount(TypedDict):
318
+ paidCu: str
319
+ promoCu: str
320
+
321
+
322
+ class WalletFunds(TypedDict):
323
+ #: In the order they are spent: project:<tenantId>, product:rtc, unallocated.
324
+ spendOrder: list[str]
325
+ scopes: dict[str, WalletScopeAmount]
326
+ paidCu: str
327
+ promoCu: str
328
+ #: paidCu + promoCu: everything this project may spend from the wallet now.
329
+ availableCu: str
330
+ #: availableCu in credits, at most four decimals, rounded down. For display only.
331
+ availableCredits: str
332
+
333
+
334
+ class BurnView(TypedDict):
335
+ liveParticipants: int
336
+ projectCuPerSecond: str
337
+ walletCuPerSecond: str
338
+
339
+
340
+ class WalletLowState(TypedDict):
341
+ active: bool
342
+ graceSeconds: int
343
+
344
+
345
+ class ProjectBalance(TypedDict):
346
+ tenantId: str
347
+ billingMode: Literal["legacy", "wallet"]
348
+ #: Whether a newcomer would be admitted now.
349
+ canJoin: bool
350
+ #: When canJoin is false, the code a join or a token mint is refused with.
351
+ blocked: Optional[str]
352
+ legacy: LegacyFunds
353
+ #: None unless the project is billed from its wallet.
354
+ wallet: Optional[WalletFunds]
355
+ burn: BurnView
356
+ #: How long the funds last at the current burn, in seconds; -1 when nothing limits it.
357
+ remainingSeconds: int
358
+ walletLow: WalletLowState
359
+ pricingVersion: Optional[int]
360
+ #: Unix milliseconds.
361
+ updatedAt: int
362
+
363
+
364
+ # --- rates --------------------------------------------------------------------
365
+
366
+
367
+ class RtcUnit(TypedDict):
368
+ price: str
369
+ rate: str
370
+ cuPerCredit: str
371
+
372
+
373
+ class RtcTierRate(TypedDict):
374
+ id: str
375
+ rank: int
376
+ label: str
377
+ media: str
378
+ #: Credits per 1,000 participant-minutes, e.g. "99.00".
379
+ credits: str
380
+ #: The same price as an integer: cu per participant-second, e.g. "9900".
381
+ cuPerParticipantSecond: str
382
+ base: str
383
+ surcharge: Optional[str]
384
+ available: bool
385
+
386
+
387
+ class RtcSurcharge(TypedDict):
388
+ tier: str
389
+ height: int
390
+ fps: int
391
+ credits: str
392
+
393
+
394
+ class RtcMode(TypedDict, total=False):
395
+ profileId: str
396
+ label: str
397
+ tier: str
398
+ tierLabel: str
399
+ enabled: bool
400
+ codecs: list[str]
401
+ receiveKbps: dict[str, int]
402
+ kind: Literal["audio", "video"]
403
+ channels: int
404
+ width: int
405
+ height: int
406
+ fps: int
407
+ layers: int
408
+
409
+
410
+ class RtcConversion(TypedDict):
411
+ creditsPerRupee: str
412
+ numerator: str
413
+ denominator: str
414
+ inrPerCredit: str
415
+ version: Optional[int]
416
+ effectiveAt: Optional[str]
417
+ source: str
418
+
419
+
420
+ class RtcRates(TypedDict):
421
+ version: int
422
+ kind: Literal["standard", "contract"]
423
+ effectiveAt: Optional[str]
424
+ unit: RtcUnit
425
+ tiers: list[RtcTierRate]
426
+ surcharges: list[RtcSurcharge]
427
+ idleTier: str
428
+ modes: list[RtcMode]
429
+ conversion: RtcConversion
430
+ notes: list[str]
431
+
432
+
433
+ # --- webhooks -----------------------------------------------------------------
434
+
435
+
436
+ class _StreamEventRequired(TypedDict):
437
+ id: str
438
+ type: str
439
+ tenantId: str
440
+ #: Unix milliseconds.
441
+ createdAt: int
442
+
443
+
444
+ class StreamEvent(_StreamEventRequired, total=False):
445
+ """One delivery. `data` varies by `type`: https://voltkit.in/docs/webhooks."""
446
+
447
+ roomId: str
448
+ data: dict[str, Any]
@@ -0,0 +1,184 @@
1
+ """
2
+ Verified webhooks: the part of an integration that fails silently.
3
+
4
+ The controller signs every delivery as ``X-Stream-Signature:
5
+ t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">``, with one ``v1``
6
+ per secret during a rotation's overlap. The timestamp is inside the signed
7
+ payload, so a captured request cannot be replayed later against a receiver
8
+ that checks it; signing the body alone would leave that door open.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import hmac
15
+ import json
16
+ import math
17
+ import re
18
+ import time
19
+ from collections.abc import Sequence
20
+ from typing import Optional, Union, cast
21
+
22
+ from .errors import StreamError
23
+ from .types import StreamEvent
24
+
25
+ __all__ = ["DEFAULT_TOLERANCE_SECONDS", "MAX_HEADER_LENGTH", "parse_webhook", "verify_webhook"]
26
+
27
+ #: The raw request body: bytes as received, or a str the framework decoded as UTF-8.
28
+ RawBody = Union[bytes, bytearray, memoryview, str]
29
+ #: The signature header as a framework hands it over: the string, a list of them, or nothing.
30
+ SignatureHeader = Union[str, Sequence[str], None]
31
+
32
+ #: How far out of date a timestamp may be, in seconds, when nothing else is said.
33
+ DEFAULT_TOLERANCE_SECONDS = 300
34
+ #: A header longer than this is refused before it is read.
35
+ MAX_HEADER_LENGTH = 4096
36
+
37
+ _TIMESTAMP = re.compile(r"[0-9]{1,15}")
38
+ _SIGNATURE = re.compile(r"[0-9a-f]{64}")
39
+
40
+
41
+ def verify_webhook(
42
+ raw_body: RawBody,
43
+ signature_header: SignatureHeader,
44
+ secrets: Union[str, Sequence[str]],
45
+ *,
46
+ tolerance_seconds: Optional[float] = DEFAULT_TOLERANCE_SECONDS,
47
+ now: Optional[float] = None,
48
+ ) -> bool:
49
+ """
50
+ Whether a delivery's signature verifies.
51
+
52
+ ``raw_body`` must be the bytes as received (Flask ``request.get_data()``,
53
+ FastAPI ``await request.body()``, Django ``request.body``). A parsed
54
+ object, or JSON that has been through ``json.loads`` and back, changes the
55
+ bytes and no signature could ever match, so that is a TypeError rather
56
+ than a False that looks like an attack. ``secrets`` may be a list: during a
57
+ rotation, give the old and the new secret and a delivery signed with
58
+ either verifies. ``tolerance_seconds`` (default 300) is how far from now,
59
+ in either direction, the timestamp may be; ``now`` replaces the clock, for
60
+ tests.
61
+ """
62
+ body = _raw_bytes(raw_body)
63
+ keys = _secret_list(secrets)
64
+ tolerance = _check_tolerance(tolerance_seconds)
65
+
66
+ parsed = _parse_header(signature_header)
67
+ if parsed is None:
68
+ return False
69
+ timestamp, signatures = parsed
70
+
71
+ moment = time.time() if now is None else float(now)
72
+ if abs(moment - int(timestamp)) > tolerance:
73
+ return False
74
+
75
+ # Every secret against every signature, each compared in constant time.
76
+ # Both lists are short (two at most in practice), and nothing returns
77
+ # early on a match.
78
+ ok = False
79
+ for key in keys:
80
+ expected = hmac.new(key.encode("utf-8"), timestamp.encode("ascii") + b"." + body, hashlib.sha256)
81
+ digest = expected.hexdigest()
82
+ for signature in signatures:
83
+ if hmac.compare_digest(digest, signature):
84
+ ok = True
85
+ return ok
86
+
87
+
88
+ def parse_webhook(
89
+ raw_body: RawBody,
90
+ signature_header: SignatureHeader,
91
+ secrets: Union[str, Sequence[str]],
92
+ *,
93
+ tolerance_seconds: Optional[float] = DEFAULT_TOLERANCE_SECONDS,
94
+ now: Optional[float] = None,
95
+ ) -> StreamEvent:
96
+ """
97
+ Verify and decode in one step. Raises ``StreamError("bad-signature")``
98
+ (status 401) rather than returning None, so a handler cannot forget to
99
+ check. The event is ``{"id", "type", "tenantId", "roomId"?, "createdAt",
100
+ "data"?}``; ``data`` varies by ``type``.
101
+ """
102
+ if not verify_webhook(raw_body, signature_header, secrets, tolerance_seconds=tolerance_seconds, now=now):
103
+ raise StreamError("bad-signature", 401, "webhook signature did not verify", False)
104
+ return cast(StreamEvent, json.loads(_raw_bytes(raw_body).decode("utf-8")))
105
+
106
+
107
+ def _raw_bytes(raw_body: object) -> bytes:
108
+ if isinstance(raw_body, str):
109
+ return raw_body.encode("utf-8")
110
+ if isinstance(raw_body, (bytes, bytearray, memoryview)):
111
+ return bytes(raw_body)
112
+ raise TypeError(
113
+ "verify_webhook needs the RAW request body (bytes or str), not a parsed object. "
114
+ "Flask: request.get_data(); FastAPI/Starlette: await request.body(); Django: request.body."
115
+ )
116
+
117
+
118
+ def _secret_list(secrets: object) -> list[str]:
119
+ given: list[object]
120
+ if isinstance(secrets, str):
121
+ given = [secrets]
122
+ elif isinstance(secrets, (list, tuple)):
123
+ given = list(secrets)
124
+ else:
125
+ given = [secrets]
126
+ kept = [secret for secret in given if isinstance(secret, str) and secret]
127
+ if not kept:
128
+ raise TypeError("verify_webhook needs the signing secret")
129
+ return kept
130
+
131
+
132
+ def _check_tolerance(tolerance: object) -> float:
133
+ """
134
+ ``tolerance_seconds`` as a number, or an error now. A NaN switched the age
135
+ check off once, since every comparison with NaN is false, so a captured
136
+ delivery verified forever; a negative refused every delivery.
137
+ """
138
+ if tolerance is None:
139
+ return float(DEFAULT_TOLERANCE_SECONDS)
140
+ if isinstance(tolerance, bool) or not isinstance(tolerance, (int, float)):
141
+ raise TypeError("tolerance_seconds must be a number of 0 or more, got %r" % (tolerance,))
142
+ value = float(tolerance)
143
+ if math.isnan(value) or value < 0:
144
+ raise ValueError("tolerance_seconds must be a number of 0 or more, got %r" % (tolerance,))
145
+ return value
146
+
147
+
148
+ def _parse_header(header: object) -> Optional[tuple[str, list[str]]]:
149
+ """
150
+ Split ``t=<ts>,v1=<hex>[,v1=<hex>...]`` into its timestamp and signatures.
151
+
152
+ Schemes other than ``v1`` are skipped, so a later scheme can be added
153
+ beside it without breaking this. Anything malformed, or with more than one
154
+ timestamp, is refused.
155
+ """
156
+ if header is None:
157
+ value = ""
158
+ elif isinstance(header, str):
159
+ value = header
160
+ elif isinstance(header, (list, tuple)) and all(isinstance(part, str) for part in header):
161
+ value = ",".join(header)
162
+ else:
163
+ return None
164
+ if len(value) > MAX_HEADER_LENGTH:
165
+ return None
166
+ timestamp: Optional[str] = None
167
+ signatures: list[str] = []
168
+ for part in value.split(","):
169
+ item = part.strip()
170
+ eq = item.find("=")
171
+ if eq <= 0:
172
+ return None
173
+ key, val = item[:eq], item[eq + 1 :]
174
+ if key == "t":
175
+ if timestamp is not None or _TIMESTAMP.fullmatch(val) is None:
176
+ return None
177
+ timestamp = val
178
+ elif key == "v1":
179
+ if _SIGNATURE.fullmatch(val) is None:
180
+ return None
181
+ signatures.append(val)
182
+ if timestamp is None or not signatures:
183
+ return None
184
+ return timestamp, signatures