@tribe-nest/media-protocol 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/build/_fixtures/joinTokenVectors.json +103 -0
  2. package/build/claims.d.ts +327 -0
  3. package/build/claims.d.ts.map +1 -0
  4. package/build/claims.js +91 -0
  5. package/build/claims.js.map +1 -0
  6. package/build/codecs.d.ts +102 -0
  7. package/build/codecs.d.ts.map +1 -0
  8. package/build/codecs.js +138 -0
  9. package/build/codecs.js.map +1 -0
  10. package/build/errorSchema.d.ts +8 -0
  11. package/build/errorSchema.d.ts.map +1 -0
  12. package/build/errorSchema.js +12 -0
  13. package/build/errorSchema.js.map +1 -0
  14. package/build/errors.d.ts +18 -0
  15. package/build/errors.d.ts.map +1 -0
  16. package/build/errors.js +47 -0
  17. package/build/errors.js.map +1 -0
  18. package/build/frames.d.ts +1291 -0
  19. package/build/frames.d.ts.map +1 -0
  20. package/build/frames.js +222 -0
  21. package/build/frames.js.map +1 -0
  22. package/build/grants.d.ts +307 -0
  23. package/build/grants.d.ts.map +1 -0
  24. package/build/grants.js +228 -0
  25. package/build/grants.js.map +1 -0
  26. package/build/index.d.ts +24 -0
  27. package/build/index.d.ts.map +1 -0
  28. package/build/index.js +40 -0
  29. package/build/index.js.map +1 -0
  30. package/build/mp-build/_fixtures/joinTokenVectors.json +103 -0
  31. package/build/mp-build/claims.d.ts +299 -0
  32. package/build/mp-build/claims.d.ts.map +1 -0
  33. package/build/mp-build/claims.js +60 -0
  34. package/build/mp-build/claims.js.map +1 -0
  35. package/build/mp-build/errorSchema.d.ts +8 -0
  36. package/build/mp-build/errorSchema.d.ts.map +1 -0
  37. package/build/mp-build/errorSchema.js +12 -0
  38. package/build/mp-build/errorSchema.js.map +1 -0
  39. package/build/mp-build/errors.d.ts +18 -0
  40. package/build/mp-build/errors.d.ts.map +1 -0
  41. package/build/mp-build/errors.js +47 -0
  42. package/build/mp-build/errors.js.map +1 -0
  43. package/build/mp-build/frames.d.ts +786 -0
  44. package/build/mp-build/frames.d.ts.map +1 -0
  45. package/build/mp-build/frames.js +153 -0
  46. package/build/mp-build/frames.js.map +1 -0
  47. package/build/mp-build/grants.d.ts +245 -0
  48. package/build/mp-build/grants.d.ts.map +1 -0
  49. package/build/mp-build/grants.js +135 -0
  50. package/build/mp-build/grants.js.map +1 -0
  51. package/build/mp-build/index.d.ts +23 -0
  52. package/build/mp-build/index.d.ts.map +1 -0
  53. package/build/mp-build/index.js +39 -0
  54. package/build/mp-build/index.js.map +1 -0
  55. package/build/mp-build/token.d.ts +66 -0
  56. package/build/mp-build/token.d.ts.map +1 -0
  57. package/build/mp-build/token.js +208 -0
  58. package/build/mp-build/token.js.map +1 -0
  59. package/build/mp-build/version.d.ts +9 -0
  60. package/build/mp-build/version.d.ts.map +1 -0
  61. package/build/mp-build/version.js +12 -0
  62. package/build/mp-build/version.js.map +1 -0
  63. package/build/token.d.ts +66 -0
  64. package/build/token.d.ts.map +1 -0
  65. package/build/token.js +208 -0
  66. package/build/token.js.map +1 -0
  67. package/build/version.d.ts +9 -0
  68. package/build/version.d.ts.map +1 -0
  69. package/build/version.js +12 -0
  70. package/build/version.js.map +1 -0
  71. package/package.json +53 -0
  72. package/src/_fixtures/joinTokenVectors.json +103 -0
  73. package/src/_tests/codecs.spec.ts +66 -0
  74. package/src/_tests/publishGrants.spec.ts +109 -0
  75. package/src/_tests/token.spec.ts +272 -0
  76. package/src/claims.ts +116 -0
  77. package/src/codecs.ts +165 -0
  78. package/src/errorSchema.ts +9 -0
  79. package/src/errors.ts +44 -0
  80. package/src/frames.ts +234 -0
  81. package/src/grants.ts +248 -0
  82. package/src/index.ts +23 -0
  83. package/src/token.ts +237 -0
  84. package/src/version.ts +8 -0
package/src/grants.ts ADDED
@@ -0,0 +1,248 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * What a participant is allowed to do, and - the part that matters most - who
5
+ * they may receive.
6
+ *
7
+ * ## Why the schema is `.strict()`
8
+ *
9
+ * This is the single line that stops the information barrier failing open.
10
+ *
11
+ * Ten phase plans were written in parallel and two of them disagreed on a name:
12
+ * one minted `grants.subscribeOnly`, the other read `grants.subscribe`. The
13
+ * reader would have found nothing, applied its documented default ("an absent
14
+ * rule means everyone"), and two Mandanten in one room would have received each
15
+ * other with nothing anywhere erroring. That is a §203 incident whose start
16
+ * date is unrecoverable from logs.
17
+ *
18
+ * `.strict()` turns that class of mistake from silent permissiveness into a
19
+ * loud verification failure: a token carrying an unknown grant key is rejected
20
+ * with `bad_claims` rather than partially understood. Any future rename fails
21
+ * the same way, at mint time, in a test.
22
+ */
23
+
24
+ /**
25
+ * Who a participant may receive, or be received by.
26
+ *
27
+ * A boolean cannot express an information barrier. "A and B are in one room and
28
+ * must not receive each other, while C may receive both" is the actual
29
+ * requirement for a Kanzlei, and it is a per-identity rule rather than a
30
+ * per-room one.
31
+ */
32
+ export const subscribeRuleSchema = z.discriminatedUnion("mode", [
33
+ z.object({ mode: z.literal("all") }).strict(),
34
+ z.object({ mode: z.literal("none") }).strict(),
35
+ z.object({ mode: z.literal("allow"), identities: z.array(z.string()).max(500) }).strict(),
36
+ z.object({ mode: z.literal("deny"), identities: z.array(z.string()).max(500) }).strict(),
37
+ ]);
38
+
39
+ export type SubscribeRule = z.infer<typeof subscribeRuleSchema>;
40
+
41
+ export const participantKindSchema = z.enum(["human", "agent", "egress", "sip", "ingress"]);
42
+ export type ParticipantKind = z.infer<typeof participantKindSchema>;
43
+
44
+ /**
45
+ * Transport authority is NOT media authority, which is why this is its own
46
+ * claim rather than something inferred from `kind`.
47
+ *
48
+ * A browser holding a perfectly valid join token must not be able to ask for a
49
+ * `PlainTransport` pointed at an address of its choosing. That is an RTP
50
+ * reflector and an unauthenticated publish path into whatever room the token
51
+ * names. Three phases independently needed to refuse it and none could, because
52
+ * nothing carried the fact.
53
+ */
54
+ export const transportKindSchema = z.enum(["webrtc", "plain", "pipe"]);
55
+ export type TransportKind = z.infer<typeof transportKindSchema>;
56
+
57
+ /**
58
+ * The two vocabularies, and the ONE place they are reconciled.
59
+ *
60
+ * A grant is written in kinds - "audio", "video", "screen" - because that is
61
+ * what a person minting a token thinks in: may this seat talk, may it be seen,
62
+ * may it present. A publisher labels its track with a SOURCE, because that is
63
+ * what it knows and what the other side renders: "microphone", "camera",
64
+ * "screen", "rtmp". The two lists overlap on one word and disagree on the rest.
65
+ *
66
+ * That disagreement shipped as a defect. The node compared the declared source
67
+ * against `publishKinds` directly, so a booking token minted with the full
68
+ * `["audio", "video", "screen"]` refused every microphone and every camera the
69
+ * browser SDK offered it - "microphone" is not in the list - and the only thing
70
+ * a coaching call could publish was a screen share. It refused the RTMP ingress
71
+ * for the same reason, whose source is "rtmp".
72
+ *
73
+ * So the translation lives here, next to the schema that creates the need for
74
+ * it, and both the node and the UI read it. An unrecognised source falls back to
75
+ * the TRACK's kind, which is a fact about the media rather than a claim: that is
76
+ * what lets a new source ("rtmp") work without a protocol change while a token
77
+ * granting audio only still cannot send video by inventing a label for it. The
78
+ * one thing a client can never do is send a screen share on an audio-or-video
79
+ * grant, because "screen" is only ever reachable by declaring it.
80
+ *
81
+ * ## A declaration is only honoured when the track could carry it
82
+ *
83
+ * The label is the client's claim; the track's kind is a fact mediasoup already
84
+ * knows. Translating the claim UNCONDITIONALLY made the claim outrank the fact,
85
+ * which is the bypass restated: `{ source: "microphone", kind: "video" }`
86
+ * resolved to "audio", so a seat minted `publishKinds: ["audio"]` published a
87
+ * camera and the node created a video producer with the real kind. The two
88
+ * audio-only grants in the product are a SIP gateway and an assistant runtime,
89
+ * and `publishKinds` is precisely the claim that has to survive the holder being
90
+ * compromised.
91
+ *
92
+ * So a source is translated only when it can actually carry the track in hand
93
+ * (a microphone carries audio, a camera carries video, a screen share carries
94
+ * either, because a shared window may bring system audio with it). A declaration
95
+ * the track contradicts is not a translation, it is a relabelling, and it is
96
+ * discarded in favour of the fact.
97
+ */
98
+ export const publishKindSchema = z.enum(["audio", "video", "screen"]);
99
+ export type PublishKind = z.infer<typeof publishKindSchema>;
100
+
101
+ /** The sources the browser SDK publishes with. Others are legal on the wire. */
102
+ export const publishSourceSchema = z.enum(["microphone", "camera", "screen"]);
103
+ export type PublishSource = z.infer<typeof publishSourceSchema>;
104
+
105
+ const SOURCE_KINDS: Record<PublishSource, PublishKind> = {
106
+ microphone: "audio",
107
+ camera: "video",
108
+ screen: "screen",
109
+ };
110
+
111
+ /**
112
+ * What each source is physically able to carry.
113
+ *
114
+ * A screen share holds both, because sharing a window may bring its system
115
+ * audio; a microphone and a camera hold exactly one each. Anything outside this
116
+ * is a label that disagrees with the media, and the media wins.
117
+ */
118
+ const SOURCE_TRACK_KINDS: Record<PublishSource, readonly ("audio" | "video")[]> = {
119
+ microphone: ["audio"],
120
+ camera: ["video"],
121
+ screen: ["audio", "video"],
122
+ };
123
+
124
+ /** Which grant a publish needs, from what it declares and what it carries. */
125
+ export function requiredPublishKind(source: string | undefined, trackKind: "audio" | "video"): PublishKind {
126
+ const declared = source ? SOURCE_KINDS[source as PublishSource] : undefined;
127
+ if (!declared) return trackKind;
128
+ // The declaration is honoured only where the track bears it out. A camera on
129
+ // an audio track, or a microphone on a video one, is a relabelling, and
130
+ // falling back to the track's own kind is what stops it buying a grant the
131
+ // token was never given.
132
+ if (!SOURCE_TRACK_KINDS[source as PublishSource].includes(trackKind)) return trackKind;
133
+ return declared;
134
+ }
135
+
136
+ /**
137
+ * May this token publish this? The node's decision, and the UI's hint, from one
138
+ * function so the button and the enforcement cannot disagree.
139
+ */
140
+ export function mayPublish(
141
+ grants: MediaGrants | undefined,
142
+ input: { source: string | undefined; trackKind: "audio" | "video" },
143
+ ): boolean {
144
+ if (!grants?.canPublish) return false;
145
+ if (!grants.publishKinds) return true;
146
+ return grants.publishKinds.includes(requiredPublishKind(input.source, input.trackKind));
147
+ }
148
+
149
+ export const mediaGrantsSchema = z
150
+ .object({
151
+ canPublish: z.boolean(),
152
+ canSubscribe: z.boolean(),
153
+ canPublishData: z.boolean(),
154
+ /** Who I may receive. Omitted means `{ mode: "all" }`. */
155
+ subscribe: subscribeRuleSchema.optional(),
156
+ /**
157
+ * Who may receive ME. Enforced on the PRODUCER side from the owner's stored
158
+ * grants, so a barrier holds even if the other party's token is generous.
159
+ */
160
+ visibleTo: subscribeRuleSchema.optional(),
161
+ /**
162
+ * Which kinds of media this token may send, in the vocabulary of
163
+ * `publishKindSchema`. A publisher labels its track with a SOURCE
164
+ * ("microphone", "camera", "screen", "rtmp", ...) and those are not the same
165
+ * words: `requiredPublishKind` below is the one translation between them.
166
+ */
167
+ publishKinds: z.array(publishKindSchema).optional(),
168
+ /**
169
+ * Bypasses the top-N active-speaker refusal. Mintable ONLY for a non-human
170
+ * kind: a recorder needs every track, a human asking for 1,000 does not.
171
+ */
172
+ subscribeExempt: z.boolean().optional(),
173
+ /** Hard ceiling on concurrent consumers, for a non-human participant. */
174
+ maxSubscriptions: z.number().int().positive().max(5000).optional(),
175
+ })
176
+ .strict();
177
+
178
+ export type MediaGrants = z.infer<typeof mediaGrantsSchema>;
179
+
180
+ /** An omitted rule means everyone. Stated once so both sides agree. */
181
+ const DEFAULT_RULE: SubscribeRule = { mode: "all" };
182
+
183
+ function ruleAllows(rule: SubscribeRule | undefined, identity: string): boolean {
184
+ const r = rule ?? DEFAULT_RULE;
185
+ switch (r.mode) {
186
+ case "all":
187
+ return true;
188
+ case "none":
189
+ return false;
190
+ case "allow":
191
+ return r.identities.includes(identity);
192
+ case "deny":
193
+ return !r.identities.includes(identity);
194
+ }
195
+ }
196
+
197
+ /**
198
+ * May `consumer` receive `producer`? Two-sided, and deny wins.
199
+ *
200
+ * One-sided is not enough. If only the consumer's rule were consulted, a
201
+ * participant whose own token was minted generously could receive someone who
202
+ * had been explicitly walled off from them. So the producer's `visibleTo` is
203
+ * checked from the OWNER's stored grants, not from anything the consumer
204
+ * presents.
205
+ *
206
+ * **An unreadable owner is `false`.** If the producer's grants cannot be found,
207
+ * this refuses rather than falling through to the permissive default. A missing
208
+ * record is exactly the state a race or a partial failure produces, and in a
209
+ * room with an information barrier the safe reading of "I do not know" is "no".
210
+ * Note this differs deliberately from an ABSENT RULE on a record that WAS
211
+ * found, which means everyone.
212
+ */
213
+ export function maySubscribe(input: {
214
+ consumerIdentity: string;
215
+ consumerGrants: MediaGrants;
216
+ producerIdentity: string;
217
+ /** `undefined` when the owner's record could not be read. */
218
+ ownerGrants: MediaGrants | undefined;
219
+ }): boolean {
220
+ if (!input.consumerGrants.canSubscribe) return false;
221
+ // Nobody subscribes to themselves; asking is a client bug, not a denial.
222
+ if (input.consumerIdentity === input.producerIdentity) return false;
223
+ if (!input.ownerGrants) return false;
224
+
225
+ return (
226
+ ruleAllows(input.consumerGrants.subscribe, input.producerIdentity) &&
227
+ ruleAllows(input.ownerGrants.visibleTo, input.consumerIdentity)
228
+ );
229
+ }
230
+
231
+ /**
232
+ * `subscribeExempt` and `maxSubscriptions` are meaningless for a human and
233
+ * dangerous if mintable for one: exemption is what lets a recorder take every
234
+ * track, and a human with it can pull a thousand streams through a node.
235
+ * Checked at MINT time so a bad token never exists.
236
+ */
237
+ export function grantsAreValidForKind(grants: MediaGrants, kind: ParticipantKind): true | string {
238
+ if (kind === "human" && grants.subscribeExempt) {
239
+ return "subscribeExempt may not be minted for a human participant";
240
+ }
241
+ if (!grants.canSubscribe && (grants.subscribe || grants.subscribeExempt || grants.maxSubscriptions)) {
242
+ return "subscribe rules are meaningless when canSubscribe is false";
243
+ }
244
+ if (!grants.canPublish && grants.publishKinds?.length) {
245
+ return "publishKinds is meaningless when canPublish is false";
246
+ }
247
+ return true;
248
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The contract between the backend, the media plane and every client.
3
+ *
4
+ * The root export is ISOMORPHIC: types, zod schemas, wire frames and the pure
5
+ * `maySubscribe` decision. It pulls in no `node:crypto`, no mediasoup, no DOM,
6
+ * so a browser SDK can import it.
7
+ *
8
+ * Signing and verifying live at `@tribe-nest/media-protocol/token`, which is
9
+ * Node-only. Keeping them apart is what stops importing a frame type from
10
+ * dragging crypto into a browser bundle.
11
+ *
12
+ * This package exists because `apps/backend` and `apps/media-server` may not
13
+ * import each other (there is a test), and six independently written phase
14
+ * plans each invented a different wire envelope for want of somewhere neutral
15
+ * to put one.
16
+ */
17
+ export * from "./version";
18
+ export * from "./errors";
19
+ export * from "./errorSchema";
20
+ export * from "./grants";
21
+ export * from "./claims";
22
+ export * from "./frames";
23
+ export * from "./codecs";
package/src/token.ts ADDED
@@ -0,0 +1,237 @@
1
+ import * as crypto from "crypto";
2
+ import {
3
+ mediaControlClaimsSchema,
4
+ mediaJoinClaimsSchema,
5
+ type JoinTokenRejection,
6
+ type MediaControlClaims,
7
+ type MediaJoinClaims,
8
+ type VerifyResult,
9
+ } from "./claims";
10
+ import { grantsAreValidForKind } from "./grants";
11
+
12
+ /**
13
+ * Ed25519-signed join and control tokens.
14
+ *
15
+ * ## Why asymmetric, when LiveKit uses a shared secret
16
+ *
17
+ * LiveKit signs with an API key + secret, which is HMAC: every party that can
18
+ * verify can also mint. Media nodes are public-facing, accept connections from
19
+ * anyone, and there are many of them. Under a shared secret, compromising ONE
20
+ * node yields the ability to mint a token for any room in the system -
21
+ * including a Mandantengespräch.
22
+ *
23
+ * So the backend holds a PRIVATE key and signs; nodes hold only PUBLIC keys and
24
+ * verify. A compromised node can verify and nothing else. The cost is a keypair
25
+ * instead of a string.
26
+ *
27
+ * ## Why this file is Node-only, and the package root is not
28
+ *
29
+ * Signing and verifying need `node:crypto`. The client SDK imports the wire
30
+ * frames and grant TYPES and never verifies anything - it only carries a token
31
+ * it was handed - so those live at the package root and stay isomorphic. This
32
+ * module is a separate entry point (`@tribe-nest/media-protocol/token`) so
33
+ * importing a frame type cannot drag `crypto` into a browser bundle.
34
+ *
35
+ * ## Why verification is PURE
36
+ *
37
+ * No I/O, no clock unless you pass one, no Redis. Replay defence needs state
38
+ * and therefore lives in the media server (`auth/replayGuard`), composed
39
+ * around this. Keeping the crypto and claim checks free of state is what lets
40
+ * the golden vectors pin exactly one code path on both sides of the wire.
41
+ */
42
+
43
+ export type PublicKeyring = Record<string, string>;
44
+ export type PrivateKeyring = Record<string, string>;
45
+
46
+ const ALG = "EdDSA";
47
+ const b64u = (b: Buffer) => b.toString("base64url");
48
+ const fromB64u = (s: string) => Buffer.from(s, "base64url");
49
+
50
+ /** Default ceiling on `exp - iat`. A join ticket, not a session. */
51
+ export const MAX_TOKEN_TTL_SECONDS = 15 * 60;
52
+
53
+ function encodeSegment(value: unknown): string {
54
+ return b64u(Buffer.from(JSON.stringify(value), "utf8"));
55
+ }
56
+
57
+ function signCompact(privateKeyPem: string, kid: string, payload: unknown): string {
58
+ const header = encodeSegment({ alg: ALG, typ: "JWT", kid });
59
+ const body = encodeSegment(payload);
60
+ const signingInput = `${header}.${body}`;
61
+ // For Ed25519 the digest algorithm is null: the curve prescribes it.
62
+ const signature = crypto.sign(null, Buffer.from(signingInput, "utf8"), privateKeyPem);
63
+ return `${signingInput}.${b64u(signature)}`;
64
+ }
65
+
66
+ export function signJoinToken(privateKeyPem: string, kid: string, claims: MediaJoinClaims): string {
67
+ const parsed = mediaJoinClaimsSchema.parse(claims);
68
+
69
+ // Mint-time refusals. A token that should never exist is better stopped here
70
+ // than relied upon to be refused by every future verifier.
71
+ const grantCheck = grantsAreValidForKind(parsed.grants, parsed.kind);
72
+ if (grantCheck !== true) throw new Error(`refusing to mint: ${grantCheck}`);
73
+ if (parsed.exp <= parsed.iat) throw new Error("refusing to mint: exp is not after iat");
74
+ if (parsed.exp - parsed.iat > MAX_TOKEN_TTL_SECONDS) {
75
+ throw new Error(`refusing to mint: ttl exceeds ${MAX_TOKEN_TTL_SECONDS}s`);
76
+ }
77
+ if (parsed.transport !== "webrtc" && parsed.kind === "human") {
78
+ throw new Error("refusing to mint: a human participant may only use a webrtc transport");
79
+ }
80
+
81
+ return signCompact(privateKeyPem, kid, parsed);
82
+ }
83
+
84
+ export function signControlToken(privateKeyPem: string, kid: string, claims: MediaControlClaims): string {
85
+ const parsed = mediaControlClaimsSchema.parse(claims);
86
+ if (parsed.exp <= parsed.iat) throw new Error("refusing to mint: exp is not after iat");
87
+ return signCompact(privateKeyPem, kid, parsed);
88
+ }
89
+
90
+ type VerifyArgs = {
91
+ token: string;
92
+ keys: PublicKeyring;
93
+ issuer: string;
94
+ maxTtlSeconds?: number;
95
+ /** Seconds since epoch. Injected so vectors are deterministic. */
96
+ nowSeconds?: number;
97
+ skewSeconds?: number;
98
+ /** When given, the token's room must match. */
99
+ expectedRoom?: string;
100
+ };
101
+
102
+ function verifyCompact(
103
+ args: VerifyArgs,
104
+ audience: "media" | "control",
105
+ ): { ok: true; payload: unknown; kid: string } | { ok: false; reason: JoinTokenRejection } {
106
+ const parts = args.token.split(".");
107
+ if (parts.length !== 3) return { ok: false, reason: "malformed" };
108
+ const [headerSeg, bodySeg, sigSeg] = parts as [string, string, string];
109
+
110
+ let header: { alg?: unknown; kid?: unknown };
111
+ try {
112
+ header = JSON.parse(fromB64u(headerSeg).toString("utf8"));
113
+ } catch {
114
+ return { ok: false, reason: "malformed" };
115
+ }
116
+
117
+ // Only ever EdDSA. Accepting whatever the token names is the classic JWT
118
+ // algorithm-confusion hole, and `none` is the worst case of it.
119
+ if (header.alg !== ALG) return { ok: false, reason: "malformed" };
120
+ if (typeof header.kid !== "string" || !header.kid) return { ok: false, reason: "malformed" };
121
+
122
+ const publicKeyPem = args.keys[header.kid];
123
+ if (!publicKeyPem) return { ok: false, reason: "unknown_kid" };
124
+
125
+ // Signature BEFORE claims: never parse a payload that has not been shown to
126
+ // come from us.
127
+ let signatureValid = false;
128
+ try {
129
+ signatureValid = crypto.verify(
130
+ null,
131
+ Buffer.from(`${headerSeg}.${bodySeg}`, "utf8"),
132
+ publicKeyPem,
133
+ fromB64u(sigSeg),
134
+ );
135
+ } catch {
136
+ return { ok: false, reason: "bad_signature" };
137
+ }
138
+ if (!signatureValid) return { ok: false, reason: "bad_signature" };
139
+
140
+ let payload: unknown;
141
+ try {
142
+ payload = JSON.parse(fromB64u(bodySeg).toString("utf8"));
143
+ } catch {
144
+ return { ok: false, reason: "malformed" };
145
+ }
146
+
147
+ const asRecord = payload as Record<string, unknown>;
148
+ if (asRecord?.aud !== audience) return { ok: false, reason: "wrong_audience" };
149
+ if (asRecord?.iss !== args.issuer) return { ok: false, reason: "wrong_issuer" };
150
+
151
+ return { ok: true, payload, kid: header.kid };
152
+ }
153
+
154
+ function checkTiming(
155
+ claims: { iat: number; exp: number; nbf?: number },
156
+ args: VerifyArgs,
157
+ ): JoinTokenRejection | null {
158
+ const now = args.nowSeconds ?? Math.floor(Date.now() / 1000);
159
+ const skew = args.skewSeconds ?? 30;
160
+ const maxTtl = args.maxTtlSeconds ?? MAX_TOKEN_TTL_SECONDS;
161
+
162
+ if (claims.exp + skew <= now) return "expired";
163
+ if (claims.nbf !== undefined && claims.nbf - skew > now) return "not_yet_valid";
164
+ // A long-lived token is a stolen credential with a long life, so the ceiling
165
+ // is enforced at VERIFY too - not only at mint, which a compromised minter
166
+ // would simply skip.
167
+ if (claims.exp - claims.iat > maxTtl) return "ttl_too_long";
168
+ return null;
169
+ }
170
+
171
+ export function verifyJoinToken(args: VerifyArgs): VerifyResult<MediaJoinClaims> {
172
+ const base = verifyCompact(args, "media");
173
+ if (!base.ok) return base;
174
+
175
+ const parsed = mediaJoinClaimsSchema.safeParse(base.payload);
176
+ // `.strict()` fires here. A token carrying `subscribeOnly` instead of
177
+ // `subscribe` is rejected outright rather than read as "no rule, so
178
+ // everyone" - which is how an information barrier fails open.
179
+ if (!parsed.success) return { ok: false, reason: "bad_claims" };
180
+
181
+ const timing = checkTiming(parsed.data, args);
182
+ if (timing) return { ok: false, reason: timing };
183
+
184
+ if (args.expectedRoom !== undefined && parsed.data.room !== args.expectedRoom) {
185
+ return { ok: false, reason: "wrong_room" };
186
+ }
187
+ if (grantsAreValidForKind(parsed.data.grants, parsed.data.kind) !== true) {
188
+ return { ok: false, reason: "bad_claims" };
189
+ }
190
+
191
+ return { ok: true, claims: parsed.data, kid: base.kid };
192
+ }
193
+
194
+ export function verifyControlToken(args: VerifyArgs): VerifyResult<MediaControlClaims> {
195
+ const base = verifyCompact(args, "control");
196
+ if (!base.ok) return base;
197
+
198
+ const parsed = mediaControlClaimsSchema.safeParse(base.payload);
199
+ if (!parsed.success) return { ok: false, reason: "bad_claims" };
200
+
201
+ const timing = checkTiming(parsed.data, args);
202
+ if (timing) return { ok: false, reason: timing };
203
+
204
+ return { ok: true, claims: parsed.data, kid: base.kid };
205
+ }
206
+
207
+ /**
208
+ * Boot guard for a media node.
209
+ *
210
+ * A node must hold verification keys ONLY. If a private key reaches a node's
211
+ * keyring - a copy-paste, a shared secrets bundle - that node can mint, and the
212
+ * whole reason for the asymmetric split is gone silently. This makes it a
213
+ * refusal to start rather than a latent capability.
214
+ */
215
+ export function assertPublicKeyringIsPublicOnly(keys: PublicKeyring): void {
216
+ for (const [kid, pem] of Object.entries(keys)) {
217
+ if (/PRIVATE KEY/.test(pem)) {
218
+ throw new Error(
219
+ `media token keyring "${kid}" contains a PRIVATE key. Nodes verify and must never be able to mint.`,
220
+ );
221
+ }
222
+ try {
223
+ crypto.createPublicKey(pem);
224
+ } catch {
225
+ throw new Error(`media token keyring "${kid}" is not a readable public key`);
226
+ }
227
+ }
228
+ }
229
+
230
+ /** Convenience for tooling and tests; not used at runtime. */
231
+ export function generateKeypair(): { publicKey: string; privateKey: string } {
232
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
233
+ return {
234
+ publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(),
235
+ privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
236
+ };
237
+ }
package/src/version.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Bumped when a frame shape changes in a way an older peer cannot parse.
3
+ *
4
+ * The client sends it on connect and a node refuses a mismatch outright, rather
5
+ * than negotiating: a half-understood signalling session fails later, somewhere
6
+ * else, and looks like a media bug.
7
+ */
8
+ export const MEDIA_PROTOCOL_VERSION = 1 as const;