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,114 @@
1
+ """
2
+ Server SDK for VoltKit RTC: rooms, tokens, chat, broadcast streams, usage,
3
+ billing and verified webhooks. Standard library only.
4
+
5
+ from voltkit_rtc.server import StreamClient
6
+
7
+ rtc = StreamClient(os.environ["VOLTKIT_RTC_URL"], os.environ["VOLTKIT_RTC_SECRET"])
8
+ room = rtc.ensure_room("show-42", host_identity="user-7")
9
+ token = rtc.create_token("show-42", identity="user-99", role="viewer")
10
+
11
+ The API secret is tenant-wide, so it stays on your server, and the SDK
12
+ attaches it only to requests for ``base_url``, never across a redirect.
13
+ """
14
+
15
+ from ._version import __version__
16
+ from .client import DEFAULT_RETRIES, DEFAULT_TIMEOUT, MAX_TIMEOUT, RATES_PATH, USER_AGENT, StreamClient
17
+ from .errors import ERROR_CODES, REST_ERROR_CODES, ErrorInfo, StreamError, error_info, is_terminal
18
+ from .transport import HttpClientTransport, Transport, TransportError, TransportRequest, TransportResponse
19
+ from .types import (
20
+ Allowance,
21
+ BroadcastStream,
22
+ BroadcastStreamWithPublish,
23
+ BurnView,
24
+ ChatInfo,
25
+ ChatMessage,
26
+ ChatToken,
27
+ DailyUsage,
28
+ LegacyFunds,
29
+ Participant,
30
+ PlaybackPolicy,
31
+ PlaybackUrls,
32
+ ProjectBalance,
33
+ PublishGrant,
34
+ Role,
35
+ Room,
36
+ RoomList,
37
+ RoomToken,
38
+ RoomView,
39
+ RoomWithHostToken,
40
+ RtcConversion,
41
+ RtcMode,
42
+ RtcRates,
43
+ RtcSurcharge,
44
+ RtcTierRate,
45
+ RtcUnit,
46
+ StreamAction,
47
+ StreamEvent,
48
+ StreamToken,
49
+ Usage,
50
+ UsageSummary,
51
+ WalletFunds,
52
+ WalletLowState,
53
+ WalletScopeAmount,
54
+ )
55
+ from .webhooks import DEFAULT_TOLERANCE_SECONDS, MAX_HEADER_LENGTH, parse_webhook, verify_webhook
56
+
57
+ __all__ = [
58
+ "__version__",
59
+ "StreamClient",
60
+ "StreamError",
61
+ "ErrorInfo",
62
+ "ERROR_CODES",
63
+ "REST_ERROR_CODES",
64
+ "error_info",
65
+ "is_terminal",
66
+ "verify_webhook",
67
+ "parse_webhook",
68
+ "DEFAULT_TOLERANCE_SECONDS",
69
+ "MAX_HEADER_LENGTH",
70
+ "RATES_PATH",
71
+ "USER_AGENT",
72
+ "DEFAULT_TIMEOUT",
73
+ "DEFAULT_RETRIES",
74
+ "MAX_TIMEOUT",
75
+ "Transport",
76
+ "TransportRequest",
77
+ "TransportResponse",
78
+ "TransportError",
79
+ "HttpClientTransport",
80
+ "Allowance",
81
+ "BroadcastStream",
82
+ "BroadcastStreamWithPublish",
83
+ "BurnView",
84
+ "ChatInfo",
85
+ "ChatMessage",
86
+ "ChatToken",
87
+ "DailyUsage",
88
+ "LegacyFunds",
89
+ "Participant",
90
+ "PlaybackPolicy",
91
+ "PlaybackUrls",
92
+ "ProjectBalance",
93
+ "PublishGrant",
94
+ "Role",
95
+ "Room",
96
+ "RoomList",
97
+ "RoomToken",
98
+ "RoomView",
99
+ "RoomWithHostToken",
100
+ "RtcConversion",
101
+ "RtcMode",
102
+ "RtcRates",
103
+ "RtcSurcharge",
104
+ "RtcTierRate",
105
+ "RtcUnit",
106
+ "StreamAction",
107
+ "StreamEvent",
108
+ "StreamToken",
109
+ "Usage",
110
+ "UsageSummary",
111
+ "WalletFunds",
112
+ "WalletLowState",
113
+ "WalletScopeAmount",
114
+ ]
@@ -0,0 +1,333 @@
1
+ """
2
+ What keeps the API key out of anything a caller might log: the checks a key
3
+ passes when the client is made, and the redaction of the key, however it is
4
+ encoded, and of URL credentials from someone else's words (the server's reply,
5
+ a redirect's target, the transport's own error). The SDK's own words never
6
+ hold the key, so only quoted text comes through here.
7
+
8
+ The forms looked for and the limits are the Node SDK's (server-sdk/src/index.ts,
9
+ keyRedactorOf and keyPatternOf); docs/server-sdk-vectors.json pins them.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ import threading
17
+ from collections.abc import Mapping, Sequence
18
+ from typing import Callable, Optional, Union
19
+
20
+ __all__ = [
21
+ "KEY_NOT_CHECKED",
22
+ "LONGEST_KEY_LOOKED_FOR",
23
+ "MARKER",
24
+ "KeyRedactor",
25
+ "check_api_key",
26
+ "hide_url_credentials",
27
+ "key_pattern_of",
28
+ ]
29
+
30
+ #: What replaces the key, and the credentials of a URL.
31
+ MARKER = "[redacted]"
32
+ #: What replaces words that could not be checked for the key.
33
+ KEY_NOT_CHECKED = "[not shown: it could not be checked for the API key]"
34
+ #: The longest key redaction looks for: the pattern grows with the key, and a
35
+ #: key this long is already not one the controller issued.
36
+ LONGEST_KEY_LOOKED_FOR = 256
37
+
38
+ #: Spaces, tabs and line breaks at either end of a header value: fetch drops
39
+ #: them before sending; this SDK trims them itself and sends the net key.
40
+ HEADER_WHITESPACE = " \t\r\n"
41
+
42
+ # JavaScript's \s, so that words and URL credentials are read the way the Node
43
+ # SDK reads them (Python's \s differs at the edges).
44
+ _JS_SPACE = "\t\n\x0b\x0c\r    - 

   "
45
+ _WORD = re.compile("[^%s]+" % _JS_SPACE)
46
+ _ANY_SPACE = re.compile("[%s]" % _JS_SPACE)
47
+ # A character no HTTP header can carry: a line break, a NUL, another control
48
+ # character, DEL, or anything above U+00FF.
49
+ _HEADER_UNSAFE = re.compile("[^\t\x20-\x7e\x80-\xff]")
50
+ # A backslash (JSON doubles it, and a run of them made the pattern backtrack
51
+ # exponentially) or a Latin-1 letter (sent as one byte that servers echo as
52
+ # U+FFFD, a ?, or mojibake, which no pattern can list). No VoltKit key holds
53
+ # either: the controller issues sk_ and hex digits.
54
+ _NOT_A_VOLTKIT_KEY = re.compile("[\\\\\x80-\xff]")
55
+
56
+
57
+ def check_api_key(api_key: object) -> str:
58
+ """The key as it will be sent, or a TypeError that never quotes it."""
59
+ if api_key is None or api_key == "":
60
+ raise TypeError("api_key is required")
61
+ if not isinstance(api_key, str):
62
+ raise TypeError("api_key must be a str, got %s" % type(api_key).__name__)
63
+ sent = api_key.strip(HEADER_WHITESPACE)
64
+ # Only whitespace (a bare line break from a file or an environment
65
+ # variable): every call would send an empty X-API-Key and fail with 401,
66
+ # far from the mistake.
67
+ if sent == "":
68
+ raise TypeError("api_key is required: the one given is only whitespace")
69
+ if _HEADER_UNSAFE.search(sent):
70
+ raise TypeError(
71
+ "api_key holds a character no HTTP header can carry: a line break, a NUL, another control "
72
+ "character, or one above U+00FF. Check how the key is loaded (it is not shown here)"
73
+ )
74
+ if _NOT_A_VOLTKIT_KEY.search(sent):
75
+ raise TypeError(
76
+ "api_key holds a character no VoltKit key has: a backslash, or one outside ASCII such as an "
77
+ "accented letter. Check how the key is loaded (it is not shown here)"
78
+ )
79
+ return sent
80
+
81
+
82
+ # --- URL credentials -----------------------------------------------------------
83
+
84
+ # A URL's credentials: a run of scheme characters, `://`, then the user info up
85
+ # to the last @ before whitespace, `/`, `?` or `#` (a password holding an @ is
86
+ # taken out whole). The lookbehind starts a match only at the first character
87
+ # of such a run: tried from every character of a long run of letters, as the
88
+ # Node SDK once did, a large page took seconds (N044-2). A run with no letter
89
+ # in it is not a scheme and is left alone, so the matches are the Node SDK's.
90
+ _URL_CREDENTIALS = re.compile("(?<![A-Za-z0-9+.\\-])([A-Za-z0-9+.\\-]+://)[^%s/?#]*@" % _JS_SPACE)
91
+ _A_LETTER = re.compile("[A-Za-z]")
92
+
93
+
94
+ def _hide(match: re.Match[str]) -> str:
95
+ run = match.group(1)
96
+ if _A_LETTER.search(run) is None:
97
+ return match.group(0)
98
+ return run + MARKER + "@"
99
+
100
+
101
+ def hide_url_credentials(text: str) -> str:
102
+ """`text` with every `scheme://user:pass@` shown as `scheme://[redacted]@`."""
103
+ return _URL_CREDENTIALS.sub(_hide, text)
104
+
105
+
106
+ # --- the key, in every encoding a reply can hold -----------------------------
107
+
108
+ #: One character of an encoded form: the character, and its pattern (a hex
109
+ #: digit, or the x of &#x, in either case).
110
+ Piece = tuple[str, str]
111
+
112
+
113
+ def _pieces_of(text: str) -> list[Piece]:
114
+ return [(ch, re.escape(ch)) for ch in text]
115
+
116
+
117
+ def _hex_pieces_of(code: int, width: int) -> list[Piece]:
118
+ return [
119
+ (digit, "[%s%s]" % (digit, digit.lower()) if digit in "ABCDEF" else digit)
120
+ for digit in format(code, "X").rjust(width, "0")
121
+ ]
122
+
123
+
124
+ def _percent_of(code: int) -> list[Piece]:
125
+ return _pieces_of("%") + _hex_pieces_of(code, 2)
126
+
127
+
128
+ def _hex_reference_of(code: int) -> list[Piece]:
129
+ return _pieces_of("&#") + [("x", "[Xx]")] + _hex_pieces_of(code, 1) + _pieces_of(";")
130
+
131
+
132
+ def _decimal_reference_of(code: int, width: int = 1) -> list[Piece]:
133
+ return _pieces_of("&#%s;" % str(code).rjust(width, "0"))
134
+
135
+
136
+ #: The HTML5 named references of the characters a key may hold, and of the
137
+ #: backslash, which escapes hold.
138
+ _HTML_NAMES: Mapping[str, Sequence[str]] = {
139
+ "\t": ("Tab",),
140
+ "!": ("excl",),
141
+ '"': ("quot", "QUOT"),
142
+ "#": ("num",),
143
+ "$": ("dollar",),
144
+ "%": ("percnt",),
145
+ "&": ("amp", "AMP"),
146
+ "'": ("apos",),
147
+ "(": ("lpar",),
148
+ ")": ("rpar",),
149
+ "*": ("ast", "midast"),
150
+ "+": ("plus",),
151
+ ",": ("comma",),
152
+ ".": ("period",),
153
+ "/": ("sol",),
154
+ ":": ("colon",),
155
+ ";": ("semi",),
156
+ "<": ("lt", "LT"),
157
+ "=": ("equals",),
158
+ ">": ("gt", "GT"),
159
+ "?": ("quest",),
160
+ "@": ("commat",),
161
+ "[": ("lsqb", "lbrack"),
162
+ "\\": ("bsol",),
163
+ "]": ("rsqb", "rbrack"),
164
+ "^": ("Hat",),
165
+ "_": ("lowbar", "UnderBar"),
166
+ "`": ("grave", "DiacriticalGrave"),
167
+ "{": ("lcub", "lbrace"),
168
+ "|": ("verbar", "vert", "VerticalLine"),
169
+ "}": ("rcub", "rbrace"),
170
+ }
171
+
172
+ #: The characters one-layer escapes are made of, other than letters and digits:
173
+ #: all a second escaper changes.
174
+ _ESCAPE_CHARACTERS = '%\\"&#;/+'
175
+
176
+ Escaper = Mapping[str, list[Piece]]
177
+
178
+
179
+ def _escaping(chars: str, encode: Callable[[int], list[Piece]]) -> dict[str, list[Piece]]:
180
+ return {ch: encode(ord(ch)) for ch in chars}
181
+
182
+
183
+ # URL: encodeURIComponent, URLSearchParams and Python's quote(safe='') encode
184
+ # each of these; Python's quote() and encodeURI keep /, which matters only for
185
+ # PHP's \/ (%5C/).
186
+ _URL_ENCODED = _escaping(_ESCAPE_CHARACTERS, _percent_of)
187
+ _URL_ENCODED_BUT_SLASH = _escaping('%\\"&#;+', _percent_of)
188
+ # HTML: PHP's htmlentities with ENT_HTML5 names each character, OWASP's
189
+ # encoders write &#x..; for each, others &#..;.
190
+ _HTML_NAMED = _escaping(_ESCAPE_CHARACTERS, lambda code: _pieces_of("&%s;" % _HTML_NAMES[chr(code)][0]))
191
+ _HTML_HEX = _escaping(_ESCAPE_CHARACTERS, _hex_reference_of)
192
+ _HTML_DECIMAL = _escaping(_ESCAPE_CHARACTERS, _decimal_reference_of)
193
+
194
+ _NO_ESCAPER: dict[str, list[Piece]] = {}
195
+
196
+ #: The standard escapers, each as what it makes of the escape characters: a
197
+ #: second layer is one run over a first.
198
+ _ESCAPERS: Sequence[Escaper] = (
199
+ _URL_ENCODED,
200
+ _URL_ENCODED_BUT_SLASH,
201
+ # JSON: JSON.stringify and json.dumps write \ as \\ and " as \"; Go's
202
+ # json.Marshal writes & as & too, PHP's json_encode / as \/.
203
+ {"\\": _pieces_of("\\\\"), '"': _pieces_of('\\"'), "&": _pieces_of("\\u0026")},
204
+ {"\\": _pieces_of("\\\\"), '"': _pieces_of('\\"'), "/": _pieces_of("\\/")},
205
+ # HTML: html.escape, htmlspecialchars and lodash write &quot;, Go &#34;.
206
+ {"&": _pieces_of("&amp;"), '"': _pieces_of("&quot;")},
207
+ {"&": _pieces_of("&amp;"), '"': _pieces_of("&#34;")},
208
+ _HTML_NAMED,
209
+ _HTML_HEX,
210
+ _HTML_DECIMAL,
211
+ )
212
+
213
+
214
+ def _one_layer_forms_of(ch: str) -> list[list[Piece]]:
215
+ """
216
+ Every form one escaper gives `ch`: itself; its byte as %XX in either hex
217
+ case; + for a space; its JSON escapes (\\", \\t, PHP's \\/, \\uXXXX); and its
218
+ HTML references, named, decimal (PHP's &#039; included) and hex.
219
+ """
220
+ code = ord(ch)
221
+ forms = [
222
+ _pieces_of(ch),
223
+ _percent_of(code),
224
+ _pieces_of("\\u") + _hex_pieces_of(code, 4),
225
+ _decimal_reference_of(code),
226
+ _decimal_reference_of(code, 3),
227
+ _hex_reference_of(code),
228
+ ]
229
+ forms.extend(_pieces_of("&%s;" % name) for name in _HTML_NAMES.get(ch, ()))
230
+ if ch == " ":
231
+ forms.append(_pieces_of("+"))
232
+ escaped = json.dumps(ch)[1:-1]
233
+ if escaped != ch:
234
+ forms.append(_pieces_of(escaped))
235
+ if ch == "/":
236
+ forms.append(_pieces_of("\\/"))
237
+ return forms
238
+
239
+
240
+ # Each character's alternatives, built once per process (there are at most 96)
241
+ # and shared by every client.
242
+ _ALTERNATIVES: dict[str, str] = {}
243
+
244
+
245
+ def _alternatives_of(ch: str) -> str:
246
+ cached = _ALTERNATIVES.get(ch)
247
+ if cached is not None:
248
+ return cached
249
+ # An insertion-ordered set: the order of the alternatives is the Node
250
+ # SDK's, so both engines, which take the first alternative that leads to a
251
+ # match, replace the same text.
252
+ patterns: dict[str, None] = {}
253
+ for form in _one_layer_forms_of(ch):
254
+ for escaper in (_NO_ESCAPER, *_ESCAPERS):
255
+ patterns["".join(piece[1] for original in form for piece in escaper.get(original[0], [original]))] = None
256
+ alternatives = "(?:%s)" % "|".join(patterns)
257
+ _ALTERNATIVES[ch] = alternatives
258
+ return alternatives
259
+
260
+
261
+ def key_pattern_of(key: str) -> re.Pattern[str]:
262
+ """
263
+ The key in any encoding someone else's words can hold, as one pattern: each
264
+ character in each of its one-layer forms, and each of those run through
265
+ every standard escaper once more (%252F, \\\\\\/, \\&quot;), since a reply is
266
+ often escaped twice. Each alternative is a fixed sequence for one character
267
+ and there is no quantifier, so a match covers the whole key in order and
268
+ unrelated text is untouched. That holds because check_api_key has run: no
269
+ character of the key is a backslash or above U+007F.
270
+ """
271
+ return re.compile("".join(_alternatives_of(ch) for ch in key))
272
+
273
+
274
+ _Pattern = Union[re.Pattern[str], None, bool]
275
+
276
+
277
+ class KeyRedactor:
278
+ """
279
+ Replaces `key`, in any form key_pattern_of knows, by [redacted].
280
+
281
+ The pattern is built for the first text, not with the client: most clients
282
+ never see an error, and building it is what redaction costs. It fails
283
+ closed: for a key too long to look for, or a pattern the engine cannot
284
+ compile, each word (a run of non-whitespace) at least as long as the key
285
+ is replaced by KEY_NOT_CHECKED and shorter words are kept, since every
286
+ form of the key is at least as long as the key and, when the key holds no
287
+ whitespace, lies within one word. A key holding whitespace (a tab) could be
288
+ split across words: then the whole text goes, unless it is shorter than
289
+ the key. A pattern that failed is not tried again.
290
+ """
291
+
292
+ __slots__ = ("_key", "_lock", "_pattern")
293
+
294
+ def __init__(self, key: str) -> None:
295
+ self._key = key
296
+ self._lock = threading.Lock()
297
+ # None: not built yet; False: not looked for (too long, or the build failed).
298
+ self._pattern: _Pattern = False if len(key) > LONGEST_KEY_LOOKED_FOR else None
299
+
300
+ def __repr__(self) -> str:
301
+ return "<secrets>"
302
+
303
+ def __call__(self, text: str) -> str:
304
+ pattern = self._pattern
305
+ if pattern is None:
306
+ # Built once, whichever thread reports the first error.
307
+ with self._lock:
308
+ if self._pattern is None:
309
+ try:
310
+ self._pattern = key_pattern_of(self._key)
311
+ except Exception:
312
+ self._pattern = False
313
+ pattern = self._pattern
314
+ if pattern is not False and pattern is not None and pattern is not True:
315
+ try:
316
+ return pattern.sub(MARKER, text)
317
+ except Exception:
318
+ self._pattern = False
319
+ if _ANY_SPACE.search(self._key):
320
+ return text if len(text) < len(self._key) else KEY_NOT_CHECKED
321
+ return _WORD.sub(self._word, text)
322
+
323
+ def _word(self, match: re.Match[str]) -> str:
324
+ word = match.group(0)
325
+ return word if len(word) < len(self._key) else KEY_NOT_CHECKED
326
+
327
+ @property
328
+ def built(self) -> Optional[bool]:
329
+ """None before the first text; True once the pattern exists; False when it is not looked for."""
330
+ pattern = self._pattern
331
+ if pattern is None:
332
+ return None
333
+ return pattern is not False
@@ -0,0 +1,3 @@
1
+ """The package version, on its own so every module can read it without importing the package."""
2
+
3
+ __version__ = "0.1.0"