@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/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@tribe-nest/media-protocol",
3
+ "version": "1.0.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "The contract between the backend, the media plane and every client: wire frames, join-token claims, grants, and the pure sign/verify pair. Zero mediasoup, zero DOM, zero React - apps/backend and apps/media-server may not import each other, so the types have nowhere else to live.",
8
+ "main": "./build/index.js",
9
+ "types": "./build/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./build/index.d.ts",
13
+ "module": "./src/index.ts",
14
+ "default": "./build/index.js"
15
+ },
16
+ "./token": {
17
+ "types": "./build/token.d.ts",
18
+ "module": "./src/token.ts",
19
+ "default": "./build/token.js"
20
+ },
21
+ "./fixtures": "./src/_fixtures/joinTokenVectors.json"
22
+ },
23
+ "typesVersions": {
24
+ "*": {
25
+ "token": [
26
+ "./build/token.d.ts"
27
+ ]
28
+ }
29
+ },
30
+ "files": [
31
+ "build",
32
+ "src"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsc",
36
+ "dev": "tsc --watch --preserveWatchOutput",
37
+ "check-types": "tsc --noEmit",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest",
40
+ "gen:vectors": "tsx scripts/generateVectors.ts",
41
+ "prepack": "npm run build"
42
+ },
43
+ "dependencies": {
44
+ "zod": "^3.25.76"
45
+ },
46
+ "devDependencies": {
47
+ "@repo/typescript-config": "*",
48
+ "tsx": "^4.19.2",
49
+ "typescript": "5.8.2",
50
+ "vitest": "^3.2.4"
51
+ },
52
+ "license": "ISC"
53
+ }
@@ -0,0 +1,103 @@
1
+ {
2
+ "_comment": "GENERATED by scripts/generateVectors.ts - do not hand-edit. One fixture read by BOTH the backend minter spec and the media-server verifier spec, so the two cannot drift. The keys are test keys and are committed on purpose.",
3
+ "nowSeconds": 1760000000,
4
+ "issuer": "legal-de",
5
+ "kid": "test-v1",
6
+ "keys": {
7
+ "test-v1": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEADDA6KSONGhJqPWozH6py1Gu6M53ZpupI9cLMN/RZtws=\n-----END PUBLIC KEY-----\n"
8
+ },
9
+ "privateKeyForMinterTests": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIO8H96J+s7hghw0TR4cDCVjjaBwMciXpPte0hx7WSFf3\n-----END PRIVATE KEY-----\n",
10
+ "vectors": [
11
+ {
12
+ "name": "valid_human",
13
+ "why": "the ordinary case: a human joining over webrtc",
14
+ "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6InRlc3QtdjEifQ.eyJ2IjoxLCJpc3MiOiJsZWdhbC1kZSIsImF1ZCI6Im1lZGlhIiwicm9vbSI6InJvb20tMSIsImlkZW50aXR5IjoiYWxpY2UiLCJraW5kIjoiaHVtYW4iLCJ0cmFuc3BvcnQiOiJ3ZWJydGMiLCJncmFudHMiOnsiY2FuUHVibGlzaCI6dHJ1ZSwiY2FuU3Vic2NyaWJlIjp0cnVlLCJjYW5QdWJsaXNoRGF0YSI6dHJ1ZX0sImp0aSI6Imp0aS0wMDAwMDAwMSIsImlhdCI6MTc2MDAwMDAwMCwiZXhwIjoxNzYwMDAwMzAwfQ.cHwZ_nFkDQoHVtB6-mA2Rg-KcuVTralgw1cS908OQh7_8JUOKAP9l6kFiKpmfHFMEz6aBt7yXeIRKnBvTfJlBw",
15
+ "expect": "ok"
16
+ },
17
+ {
18
+ "name": "valid_agent_exempt",
19
+ "why": "a non-human may be exempt from the active-set ceiling; a human may not",
20
+ "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6InRlc3QtdjEifQ.eyJ2IjoxLCJpc3MiOiJsZWdhbC1kZSIsImF1ZCI6Im1lZGlhIiwicm9vbSI6InJvb20tMSIsImlkZW50aXR5IjoidHJhbnNjcmliZXIiLCJraW5kIjoiYWdlbnQiLCJ0cmFuc3BvcnQiOiJwbGFpbiIsImdyYW50cyI6eyJjYW5QdWJsaXNoIjpmYWxzZSwiY2FuU3Vic2NyaWJlIjp0cnVlLCJjYW5QdWJsaXNoRGF0YSI6ZmFsc2UsInN1YnNjcmliZUV4ZW1wdCI6dHJ1ZX0sImp0aSI6Imp0aS0wMDAwMDAwMSIsImlhdCI6MTc2MDAwMDAwMCwiZXhwIjoxNzYwMDAwMzAwfQ.Mc3KKrczwe0j6YYpe-HXBiYn5C8CeOhYaE2HI64tANDzhucO0BxAgTg9fTdJAGKW8W9RymwV6j83HRWZr854AQ",
21
+ "expect": "ok"
22
+ },
23
+ {
24
+ "name": "valid_barrier",
25
+ "why": "an information barrier: alice may receive only carol, and only carol may receive alice",
26
+ "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6InRlc3QtdjEifQ.eyJ2IjoxLCJpc3MiOiJsZWdhbC1kZSIsImF1ZCI6Im1lZGlhIiwicm9vbSI6InJvb20tMSIsImlkZW50aXR5IjoiYWxpY2UiLCJraW5kIjoiaHVtYW4iLCJ0cmFuc3BvcnQiOiJ3ZWJydGMiLCJncmFudHMiOnsiY2FuUHVibGlzaCI6dHJ1ZSwiY2FuU3Vic2NyaWJlIjp0cnVlLCJjYW5QdWJsaXNoRGF0YSI6dHJ1ZSwic3Vic2NyaWJlIjp7Im1vZGUiOiJhbGxvdyIsImlkZW50aXRpZXMiOlsiY2Fyb2wiXX0sInZpc2libGVUbyI6eyJtb2RlIjoiYWxsb3ciLCJpZGVudGl0aWVzIjpbImNhcm9sIl19fSwianRpIjoianRpLTAwMDAwMDAxIiwiaWF0IjoxNzYwMDAwMDAwLCJleHAiOjE3NjAwMDAzMDB9.1jx23AACBL-tvtUFSUGQAjjBCuEs6pv7MHSeqAfy7HY-ki65Rf-YadxsPk5Nzg-OrOopu08J6m_Wdm5YA3TMDw",
27
+ "expect": "ok"
28
+ },
29
+ {
30
+ "name": "unknown_grant_key",
31
+ "why": "THE fail-open case. One phase minted `subscribeOnly` while another read `subscribe`; a lenient schema would have read 'no rule' as 'everyone' and put two Mandanten in one room.",
32
+ "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6InRlc3QtdjEifQ.eyJ2IjoxLCJpc3MiOiJsZWdhbC1kZSIsImF1ZCI6Im1lZGlhIiwicm9vbSI6InJvb20tMSIsImlkZW50aXR5IjoiYWxpY2UiLCJraW5kIjoiaHVtYW4iLCJ0cmFuc3BvcnQiOiJ3ZWJydGMiLCJncmFudHMiOnsiY2FuUHVibGlzaCI6dHJ1ZSwiY2FuU3Vic2NyaWJlIjp0cnVlLCJjYW5QdWJsaXNoRGF0YSI6dHJ1ZSwic3Vic2NyaWJlT25seSI6WyJjYXJvbCJdfSwianRpIjoianRpLTAwMDAwMDAxIiwiaWF0IjoxNzYwMDAwMDAwLCJleHAiOjE3NjAwMDAzMDB9.uda5k_gP-zizkivazZkx1QoSj-54DgnTzCMoTBZ-Pzfyn1jZdw49IvPbAD-88yOfn7ukVYCOko0DRHkxmfu8Aw",
33
+ "expect": "bad_claims"
34
+ },
35
+ {
36
+ "name": "human_with_subscribe_exempt",
37
+ "why": "exemption bypasses the active-set ceiling; a human holding it can pull every track in a 1,000-person room",
38
+ "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6InRlc3QtdjEifQ.eyJ2IjoxLCJpc3MiOiJsZWdhbC1kZSIsImF1ZCI6Im1lZGlhIiwicm9vbSI6InJvb20tMSIsImlkZW50aXR5IjoiYWxpY2UiLCJraW5kIjoiaHVtYW4iLCJ0cmFuc3BvcnQiOiJ3ZWJydGMiLCJncmFudHMiOnsiY2FuUHVibGlzaCI6dHJ1ZSwiY2FuU3Vic2NyaWJlIjp0cnVlLCJjYW5QdWJsaXNoRGF0YSI6dHJ1ZSwic3Vic2NyaWJlRXhlbXB0Ijp0cnVlfSwianRpIjoianRpLTAwMDAwMDAxIiwiaWF0IjoxNzYwMDAwMDAwLCJleHAiOjE3NjAwMDAzMDB9.o34OTK5D0qRD5enDABkljr_aFerqzoZYLR_uqgTKwO1DsszjDkIG8gUxvjucYZ9apvwWIq3tuwAPVRSAotqYCA",
39
+ "expect": "bad_claims"
40
+ },
41
+ {
42
+ "name": "expired",
43
+ "why": "a join ticket outlives its window",
44
+ "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6InRlc3QtdjEifQ.eyJ2IjoxLCJpc3MiOiJsZWdhbC1kZSIsImF1ZCI6Im1lZGlhIiwicm9vbSI6InJvb20tMSIsImlkZW50aXR5IjoiYWxpY2UiLCJraW5kIjoiaHVtYW4iLCJ0cmFuc3BvcnQiOiJ3ZWJydGMiLCJncmFudHMiOnsiY2FuUHVibGlzaCI6dHJ1ZSwiY2FuU3Vic2NyaWJlIjp0cnVlLCJjYW5QdWJsaXNoRGF0YSI6dHJ1ZX0sImp0aSI6Imp0aS0wMDAwMDAwMSIsImlhdCI6MTc1OTk5OTQwMCwiZXhwIjoxNzU5OTk5NzAwfQ.hTM23tP-4IP044XhjXNSmzpXMWzi1GC7LrV5vLN_H1SnnI0ZFgLmg8VTW34cPbZy6yIs8mXRZT8uNJ6xsCAQDQ",
45
+ "expect": "expired"
46
+ },
47
+ {
48
+ "name": "not_yet_valid",
49
+ "why": "nbf in the future",
50
+ "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6InRlc3QtdjEifQ.eyJ2IjoxLCJpc3MiOiJsZWdhbC1kZSIsImF1ZCI6Im1lZGlhIiwicm9vbSI6InJvb20tMSIsImlkZW50aXR5IjoiYWxpY2UiLCJraW5kIjoiaHVtYW4iLCJ0cmFuc3BvcnQiOiJ3ZWJydGMiLCJncmFudHMiOnsiY2FuUHVibGlzaCI6dHJ1ZSwiY2FuU3Vic2NyaWJlIjp0cnVlLCJjYW5QdWJsaXNoRGF0YSI6dHJ1ZX0sImp0aSI6Imp0aS0wMDAwMDAwMSIsImlhdCI6MTc2MDAwMDAwMCwibmJmIjoxNzYwMDAwNjAwLCJleHAiOjE3NjAwMDA5MDB9.xpFjHBt5XYyG6pP9_3j5C-h0FS5Y7byfo_M8qkkGn33iK9i4qO3L_SzFxYebTEoX-jmlTu8Y8MCPlhLzb3MFCw",
51
+ "expect": "not_yet_valid"
52
+ },
53
+ {
54
+ "name": "ttl_too_long",
55
+ "why": "enforced at VERIFY as well as mint, because a compromised minter would simply skip the mint check",
56
+ "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6InRlc3QtdjEifQ.eyJ2IjoxLCJpc3MiOiJsZWdhbC1kZSIsImF1ZCI6Im1lZGlhIiwicm9vbSI6InJvb20tMSIsImlkZW50aXR5IjoiYWxpY2UiLCJraW5kIjoiaHVtYW4iLCJ0cmFuc3BvcnQiOiJ3ZWJydGMiLCJncmFudHMiOnsiY2FuUHVibGlzaCI6dHJ1ZSwiY2FuU3Vic2NyaWJlIjp0cnVlLCJjYW5QdWJsaXNoRGF0YSI6dHJ1ZX0sImp0aSI6Imp0aS0wMDAwMDAwMSIsImlhdCI6MTc2MDAwMDAwMCwiZXhwIjoxNzYwMDg2NDAwfQ.UCA0foITm9CL3z1ZslwGf4Dvmqe0xkOpYhN9l13-DKRPjje6pGez4jcvhKDGwPuy8f7lovvUfoRwCWG4UGYWCg",
57
+ "expect": "ttl_too_long"
58
+ },
59
+ {
60
+ "name": "wrong_issuer",
61
+ "why": "a token minted for the main instance must be worthless on the legal one",
62
+ "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6InRlc3QtdjEifQ.eyJ2IjoxLCJpc3MiOiJtYWluIiwiYXVkIjoibWVkaWEiLCJyb29tIjoicm9vbS0xIiwiaWRlbnRpdHkiOiJhbGljZSIsImtpbmQiOiJodW1hbiIsInRyYW5zcG9ydCI6IndlYnJ0YyIsImdyYW50cyI6eyJjYW5QdWJsaXNoIjp0cnVlLCJjYW5TdWJzY3JpYmUiOnRydWUsImNhblB1Ymxpc2hEYXRhIjp0cnVlfSwianRpIjoianRpLTAwMDAwMDAxIiwiaWF0IjoxNzYwMDAwMDAwLCJleHAiOjE3NjAwMDAzMDB9.Z33LRIVYrY7KlvUUsQCZ52x7HBxPKWBuJSKySb6d0Ncmenu8CpFSxC2JRm_s7NPbuMwBymgQjKUiFmTeUm_FCA",
63
+ "expect": "wrong_issuer"
64
+ },
65
+ {
66
+ "name": "wrong_audience_control_as_join",
67
+ "why": "a control token replayed as a join",
68
+ "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6InRlc3QtdjEifQ.eyJ2IjoxLCJpc3MiOiJsZWdhbC1kZSIsImF1ZCI6ImNvbnRyb2wiLCJqdGkiOiJqdGktY29udHJvbC0xIiwiaWF0IjoxNzYwMDAwMDAwLCJleHAiOjE3NjAwMDAzMDB9.q9LAV0vxFy95CqICQ9jl5dm7ll_iD8HSDADK0cQu85wJxVx1roQL-RZggjcrxhShH2uzfKufTW9211pF56caDA",
69
+ "expect": "wrong_audience"
70
+ },
71
+ {
72
+ "name": "unknown_kid",
73
+ "why": "signed by a key this node does not carry",
74
+ "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6InJvdGF0ZWQtYXdheSJ9.eyJ2IjoxLCJpc3MiOiJsZWdhbC1kZSIsImF1ZCI6Im1lZGlhIiwicm9vbSI6InJvb20tMSIsImlkZW50aXR5IjoiYWxpY2UiLCJraW5kIjoiaHVtYW4iLCJ0cmFuc3BvcnQiOiJ3ZWJydGMiLCJncmFudHMiOnsiY2FuUHVibGlzaCI6dHJ1ZSwiY2FuU3Vic2NyaWJlIjp0cnVlLCJjYW5QdWJsaXNoRGF0YSI6dHJ1ZX0sImp0aSI6Imp0aS0wMDAwMDAwMSIsImlhdCI6MTc2MDAwMDAwMCwiZXhwIjoxNzYwMDAwMzAwfQ.W_BZBx_p9W245Xw89H3Dy_MEYf5JfNb8KpSeLok4ZjYUc_EWx8wxvghDrWjaidXp6yxOK3Su_r_b_QHOis7tCA",
75
+ "expect": "unknown_kid"
76
+ },
77
+ {
78
+ "name": "bad_signature",
79
+ "why": "correct kid, wrong key - a forgery attempt rather than a rotation",
80
+ "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6InRlc3QtdjEifQ.eyJ2IjoxLCJpc3MiOiJsZWdhbC1kZSIsImF1ZCI6Im1lZGlhIiwicm9vbSI6InJvb20tMSIsImlkZW50aXR5IjoiYWxpY2UiLCJraW5kIjoiaHVtYW4iLCJ0cmFuc3BvcnQiOiJ3ZWJydGMiLCJncmFudHMiOnsiY2FuUHVibGlzaCI6dHJ1ZSwiY2FuU3Vic2NyaWJlIjp0cnVlLCJjYW5QdWJsaXNoRGF0YSI6dHJ1ZX0sImp0aSI6Imp0aS0wMDAwMDAwMSIsImlhdCI6MTc2MDAwMDAwMCwiZXhwIjoxNzYwMDAwMzAwfQ.Chdv_Jl7v6HTE5u1TpP6f7j2mqFPocPKfBSdwgbL_lNIN708mHHA5oGeR8AOjPakn16WLTW6jODEUnbj_KTLAA",
81
+ "expect": "bad_signature"
82
+ },
83
+ {
84
+ "name": "alg_none",
85
+ "why": "classic JWT algorithm confusion. Only EdDSA is ever accepted.",
86
+ "token": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIiwia2lkIjoidGVzdC12MSJ9.eyJ2IjoxLCJpc3MiOiJsZWdhbC1kZSIsImF1ZCI6Im1lZGlhIiwicm9vbSI6InJvb20tMSIsImlkZW50aXR5IjoiYWxpY2UiLCJraW5kIjoiaHVtYW4iLCJ0cmFuc3BvcnQiOiJ3ZWJydGMiLCJncmFudHMiOnsiY2FuUHVibGlzaCI6dHJ1ZSwiY2FuU3Vic2NyaWJlIjp0cnVlLCJjYW5QdWJsaXNoRGF0YSI6dHJ1ZX0sImp0aSI6Imp0aS0wMDAwMDAwMSIsImlhdCI6MTc2MDAwMDAwMCwiZXhwIjoxNzYwMDAwMzAwfQ.",
87
+ "expect": "malformed"
88
+ },
89
+ {
90
+ "name": "wrong_room",
91
+ "why": "a valid token for a different room",
92
+ "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6InRlc3QtdjEifQ.eyJ2IjoxLCJpc3MiOiJsZWdhbC1kZSIsImF1ZCI6Im1lZGlhIiwicm9vbSI6InJvb20tMiIsImlkZW50aXR5IjoiYWxpY2UiLCJraW5kIjoiaHVtYW4iLCJ0cmFuc3BvcnQiOiJ3ZWJydGMiLCJncmFudHMiOnsiY2FuUHVibGlzaCI6dHJ1ZSwiY2FuU3Vic2NyaWJlIjp0cnVlLCJjYW5QdWJsaXNoRGF0YSI6dHJ1ZX0sImp0aSI6Imp0aS0wMDAwMDAwMSIsImlhdCI6MTc2MDAwMDAwMCwiZXhwIjoxNzYwMDAwMzAwfQ.EWEv-Np5ECbj4TZXt3KTgcPUHEoDM2G4Wx-M0L25pTkE0onIRMm5vmbVBypULUtIWtKxOxsdDNBezcl8Nqy8Bg",
93
+ "expect": "wrong_room",
94
+ "room": "room-1"
95
+ },
96
+ {
97
+ "name": "malformed",
98
+ "why": "not three segments",
99
+ "token": "not.a.token.at.all",
100
+ "expect": "malformed"
101
+ }
102
+ ]
103
+ }
@@ -0,0 +1,66 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { MEDIA_CODECS, audioOnlyCapabilities } from "../codecs";
6
+
7
+ /**
8
+ * The list every Router in the fleet is created from, and every server-side
9
+ * participant derives its capabilities from.
10
+ *
11
+ * It lives here because the node is not the only party that has to agree: a
12
+ * room agent, the SIP gateway and the RTMP ingress each declare RTP
13
+ * capabilities on `consume`, each runs in its own app, and none may import
14
+ * `apps/media-server`. Their only alternative was a second hand-written copy,
15
+ * and a second copy is the drift the pinning exists to prevent.
16
+ */
17
+ describe("MEDIA_CODECS", () => {
18
+ it("pins exactly the codecs the fleet negotiates", () => {
19
+ expect(MEDIA_CODECS.map((codec) => codec.mimeType)).toEqual(["audio/opus", "video/VP8", "video/H264"]);
20
+ });
21
+
22
+ it("keeps the RTCP feedback a keyframe request needs a wire representation", () => {
23
+ // Drop `pli` and every active-speaker switch shows macroblocks until the
24
+ // encoder happens to send an IDR on its own schedule.
25
+ for (const codec of MEDIA_CODECS.filter((c) => c.kind === "video")) {
26
+ expect(codec.rtcpFeedback?.some((fb) => fb.type === "nack" && fb.parameter === "pli")).toBe(true);
27
+ }
28
+ });
29
+
30
+ it("stays free of mediasoup, so a browser can import a frame type", () => {
31
+ // The structural types are declared here rather than imported for exactly
32
+ // this reason. `apps/media-server` re-exports these values through
33
+ // mediasoup-typed bindings, so a divergence fails ITS compile.
34
+ const source = readFileSync(join(__dirname, "..", "codecs.ts"), "utf8");
35
+ expect(source).not.toMatch(/from ["']mediasoup/);
36
+ });
37
+ });
38
+
39
+ describe("audioOnlyCapabilities", () => {
40
+ it("keeps the audio codecs and drops the video ones", () => {
41
+ const derived = audioOnlyCapabilities();
42
+ expect(derived.codecs.map((codec) => codec.mimeType)).toEqual(["audio/opus"]);
43
+ expect(derived.codecs.every((codec) => codec.kind === "audio")).toBe(true);
44
+ expect(derived.headerExtensions).toEqual([]);
45
+ });
46
+
47
+ it("carries the codec parameters through rather than restating them", () => {
48
+ // `useinbandfec` buys intelligibility across the packet loss a consultation
49
+ // actually hits. A participant that negotiated without it is the one that
50
+ // degrades first, and nothing anywhere would say why.
51
+ const opus = MEDIA_CODECS.find((codec) => codec.mimeType === "audio/opus");
52
+ expect(audioOnlyCapabilities().codecs[0]?.parameters).toEqual(opus?.parameters);
53
+ });
54
+
55
+ it("assigns a payload type in the dynamic range when the fleet pins none", () => {
56
+ const derived = audioOnlyCapabilities([{ kind: "audio", mimeType: "audio/opus", clockRate: 48000 }]);
57
+ expect(derived.codecs[0]?.preferredPayloadType).toBe(100);
58
+ });
59
+
60
+ it("respects a payload type the fleet does pin", () => {
61
+ const derived = audioOnlyCapabilities([
62
+ { kind: "audio", mimeType: "audio/opus", clockRate: 48000, preferredPayloadType: 111 },
63
+ ]);
64
+ expect(derived.codecs[0]?.preferredPayloadType).toBe(111);
65
+ });
66
+ });
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { mayPublish, requiredPublishKind, type MediaGrants } from "../grants";
4
+
5
+ /**
6
+ * The two vocabularies, pinned against each other.
7
+ *
8
+ * A grant lists KINDS ("audio", "video", "screen"); a publisher declares a
9
+ * SOURCE ("microphone", "camera", "screen", "rtmp"). Comparing the two lists
10
+ * directly is what shipped: a booking token minted with every kind refused
11
+ * every microphone and every camera, because neither word appears in a grant,
12
+ * and let a screen share through because "screen" is the one word both lists
13
+ * contain. Every assertion below is one half of that mistake.
14
+ */
15
+
16
+ const open: MediaGrants = { canPublish: true, canSubscribe: true, canPublishData: false };
17
+
18
+ describe("requiredPublishKind", () => {
19
+ it("maps the sources the browser SDK actually sends", () => {
20
+ // These three are exactly what `room.publish(track, source)` puts in
21
+ // `appData`. Nothing else in the SDK can produce a source.
22
+ expect(requiredPublishKind("microphone", "audio")).toBe("audio");
23
+ expect(requiredPublishKind("camera", "video")).toBe("video");
24
+ expect(requiredPublishKind("screen", "video")).toBe("screen");
25
+ });
26
+
27
+ it("falls back to the track's kind for a source it does not know", () => {
28
+ // The RTMP relay publishes `{ source: "rtmp" }` on both an audio and a
29
+ // video track, and its token is minted `["audio", "video"]`. A fallback to
30
+ // the KIND is what lets a new source exist without a protocol change.
31
+ expect(requiredPublishKind("rtmp", "audio")).toBe("audio");
32
+ expect(requiredPublishKind("rtmp", "video")).toBe("video");
33
+ expect(requiredPublishKind(undefined, "audio")).toBe("audio");
34
+ });
35
+
36
+ it("discards a source the track contradicts, rather than letting the label decide", () => {
37
+ // The bypass this closes: the label outranking the fact. A "microphone"
38
+ // carrying a VIDEO track used to resolve to "audio", so an audio-only seat
39
+ // published a camera by renaming it. mediasoup knows the kind; the client
40
+ // only asserts the source, and an assertion the media contradicts is worth
41
+ // nothing.
42
+ expect(requiredPublishKind("microphone", "video")).toBe("video");
43
+ expect(requiredPublishKind("camera", "audio")).toBe("audio");
44
+
45
+ // A screen share is the one source that legitimately carries either, because
46
+ // a shared window may bring its system audio along.
47
+ expect(requiredPublishKind("screen", "audio")).toBe("screen");
48
+ expect(requiredPublishKind("screen", "video")).toBe("screen");
49
+ });
50
+
51
+ it("never resolves an undeclared source to `screen`", () => {
52
+ // The fallback must not become a way round the one grant that cannot be
53
+ // inferred from the media: a screen share is only ever reachable by saying
54
+ // so, which is what keeps `["audio", "video"]` meaningful.
55
+ for (const source of [undefined, "rtmp", "camera", "microphone", "anything"]) {
56
+ expect(requiredPublishKind(source, "video")).not.toBe("screen");
57
+ }
58
+ });
59
+ });
60
+
61
+ describe("mayPublish", () => {
62
+ it("lets the grants a booking room mints carry a microphone and a camera", () => {
63
+ // `BOOKING_GRANTS` and the consultation grants both mint exactly this.
64
+ const booking: MediaGrants = { ...open, publishKinds: ["audio", "video", "screen"] };
65
+
66
+ expect(mayPublish(booking, { source: "microphone", trackKind: "audio" })).toBe(true);
67
+ expect(mayPublish(booking, { source: "camera", trackKind: "video" })).toBe(true);
68
+ expect(mayPublish(booking, { source: "screen", trackKind: "video" })).toBe(true);
69
+ });
70
+
71
+ it("still refuses a screen share on a token that was granted audio and video", () => {
72
+ const ingress: MediaGrants = { ...open, publishKinds: ["audio", "video"] };
73
+
74
+ expect(mayPublish(ingress, { source: "rtmp", trackKind: "audio" })).toBe(true);
75
+ expect(mayPublish(ingress, { source: "rtmp", trackKind: "video" })).toBe(true);
76
+ expect(mayPublish(ingress, { source: "screen", trackKind: "video" })).toBe(false);
77
+ });
78
+
79
+ it("refuses a camera on an audio-only seat", () => {
80
+ const listenAndTalk: MediaGrants = { ...open, publishKinds: ["audio"] };
81
+
82
+ expect(mayPublish(listenAndTalk, { source: "microphone", trackKind: "audio" })).toBe(true);
83
+ expect(mayPublish(listenAndTalk, { source: "camera", trackKind: "video" })).toBe(false);
84
+ expect(mayPublish(listenAndTalk, { source: "screen", trackKind: "video" })).toBe(false);
85
+ // And not by dropping the label either.
86
+ expect(mayPublish(listenAndTalk, { source: undefined, trackKind: "video" })).toBe(false);
87
+ });
88
+
89
+ it("refuses a camera on an audio-only seat that calls itself a microphone", () => {
90
+ // The two audio-only grants the product mints are a SIP gateway
91
+ // (`SIP_GRANTS`) and an assistant runtime (`ASSISTANT_GRANTS`). Both are
92
+ // processes on somebody else's box, so `publishKinds` is the claim that has
93
+ // to hold once the holder is compromised - and a compromised holder writes
94
+ // its own `appData`.
95
+ const listenAndTalk: MediaGrants = { ...open, publishKinds: ["audio"] };
96
+
97
+ expect(mayPublish(listenAndTalk, { source: "microphone", trackKind: "video" })).toBe(false);
98
+ // The mirror of it: a video-only seat must not send audio by calling it a
99
+ // camera.
100
+ const seeOnly: MediaGrants = { ...open, publishKinds: ["video"] };
101
+ expect(mayPublish(seeOnly, { source: "camera", trackKind: "audio" })).toBe(false);
102
+ });
103
+
104
+ it("reads an absent `publishKinds` as every kind, and `canPublish: false` as none", () => {
105
+ expect(mayPublish(open, { source: "camera", trackKind: "video" })).toBe(true);
106
+ expect(mayPublish({ ...open, canPublish: false }, { source: "microphone", trackKind: "audio" })).toBe(false);
107
+ expect(mayPublish(undefined, { source: "microphone", trackKind: "audio" })).toBe(false);
108
+ });
109
+ });
@@ -0,0 +1,272 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import vectors from "../_fixtures/joinTokenVectors.json";
4
+ import type { MediaGrants } from "../grants";
5
+ import { maySubscribe } from "../grants";
6
+ import {
7
+ MAX_TOKEN_TTL_SECONDS,
8
+ assertPublicKeyringIsPublicOnly,
9
+ generateKeypair,
10
+ signJoinToken,
11
+ verifyJoinToken,
12
+ } from "../token";
13
+ import { MEDIA_PARTICIPANT_NAME_MAX_LENGTH, clampParticipantName, type MediaJoinClaims } from "../claims";
14
+
15
+ /**
16
+ * The golden vectors are the point of this file.
17
+ *
18
+ * The same JSON is read by the backend's minter spec and the media server's
19
+ * verifier spec, so a rename on one side fails on the other rather than
20
+ * producing tokens that verify in unit tests and not in staging. That drift is
21
+ * exactly what the phase reconciliation found: six independently written plans,
22
+ * seven different claim sets.
23
+ */
24
+ describe("golden vectors", () => {
25
+ const args = { keys: vectors.keys as Record<string, string>, issuer: vectors.issuer, nowSeconds: vectors.nowSeconds };
26
+
27
+ it("covers every rejection reason the verifier can return", () => {
28
+ // A vector set that only tests the happy path is decoration.
29
+ const covered = new Set(vectors.vectors.map((v) => v.expect));
30
+ for (const reason of [
31
+ "ok",
32
+ "malformed",
33
+ "unknown_kid",
34
+ "bad_signature",
35
+ "expired",
36
+ "not_yet_valid",
37
+ "ttl_too_long",
38
+ "wrong_audience",
39
+ "wrong_issuer",
40
+ "wrong_room",
41
+ "bad_claims",
42
+ ]) {
43
+ expect(covered.has(reason), `no vector produces ${reason}`).toBe(true);
44
+ }
45
+ });
46
+
47
+ for (const v of vectors.vectors) {
48
+ it(`${v.name}: ${v.expect}`, () => {
49
+ const result = verifyJoinToken({
50
+ token: v.token,
51
+ ...args,
52
+ ...(v.room ? { expectedRoom: v.room } : {}),
53
+ });
54
+ if (v.expect === "ok") {
55
+ expect(result.ok, `${v.name} should verify: ${v.why}`).toBe(true);
56
+ } else {
57
+ expect(result.ok).toBe(false);
58
+ expect(result.ok === false && result.reason, v.why).toBe(v.expect);
59
+ }
60
+ });
61
+ }
62
+ });
63
+
64
+ /**
65
+ * The fail-open case, stated on its own because it is the one with a §203
66
+ * consequence rather than a broken-feature consequence.
67
+ */
68
+ describe("an unknown grant key is REFUSED, never partially understood", () => {
69
+ const { publicKey, privateKey } = generateKeypair();
70
+ const keys = { k1: publicKey };
71
+ const now = 1_800_000_000;
72
+
73
+ const claims = (grants: unknown): MediaJoinClaims =>
74
+ ({
75
+ v: 1,
76
+ iss: "legal-de",
77
+ aud: "media",
78
+ room: "r",
79
+ identity: "alice",
80
+ kind: "human",
81
+ transport: "webrtc",
82
+ grants,
83
+ jti: "jti-aaaaaaaa",
84
+ iat: now,
85
+ exp: now + 300,
86
+ }) as MediaJoinClaims;
87
+
88
+ it("rejects a misspelled subscribe rule rather than reading it as 'no rule, so everyone'", () => {
89
+ // Hand-signed: the minter would refuse this, which is the point - the
90
+ // verifier must not be the only thing standing in the way, and must not
91
+ // wave it through either.
92
+ const raw = (payload: unknown) => {
93
+ const b = (x: Buffer) => x.toString("base64url");
94
+ const h = b(Buffer.from(JSON.stringify({ alg: "EdDSA", typ: "JWT", kid: "k1" })));
95
+ const p = b(Buffer.from(JSON.stringify(payload)));
96
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
97
+ const crypto = require("crypto") as typeof import("crypto");
98
+ return `${h}.${p}.${b(crypto.sign(null, Buffer.from(`${h}.${p}`), privateKey))}`;
99
+ };
100
+
101
+ const token = raw(claims({ canPublish: true, canSubscribe: true, canPublishData: true, subscribeOnly: ["carol"] }));
102
+ const result = verifyJoinToken({ token, keys, issuer: "legal-de", nowSeconds: now });
103
+
104
+ expect(result.ok).toBe(false);
105
+ expect(result.ok === false && result.reason).toBe("bad_claims");
106
+ });
107
+ });
108
+
109
+ describe("minting refuses tokens that should not exist", () => {
110
+ const { privateKey } = generateKeypair();
111
+ const now = 1_800_000_000;
112
+ const base = {
113
+ v: 1 as const,
114
+ iss: "legal-de",
115
+ aud: "media" as const,
116
+ room: "r",
117
+ identity: "alice",
118
+ jti: "jti-aaaaaaaa",
119
+ iat: now,
120
+ };
121
+
122
+ it("refuses a ttl beyond the ceiling", () => {
123
+ expect(() =>
124
+ signJoinToken(privateKey, "k1", {
125
+ ...base,
126
+ kind: "human",
127
+ transport: "webrtc",
128
+ grants: { canPublish: true, canSubscribe: true, canPublishData: true },
129
+ exp: now + MAX_TOKEN_TTL_SECONDS + 1,
130
+ }),
131
+ ).toThrow(/ttl exceeds/);
132
+ });
133
+
134
+ it("refuses subscribeExempt for a human", () => {
135
+ expect(() =>
136
+ signJoinToken(privateKey, "k1", {
137
+ ...base,
138
+ kind: "human",
139
+ transport: "webrtc",
140
+ grants: { canPublish: true, canSubscribe: true, canPublishData: true, subscribeExempt: true },
141
+ exp: now + 300,
142
+ }),
143
+ ).toThrow(/subscribeExempt/);
144
+ });
145
+
146
+ /**
147
+ * The refusal that has to be prevented UPSTREAM rather than handled.
148
+ *
149
+ * A minting surface builds `name` out of stored text it cannot bound (a
150
+ * buyer's name off a booking row, a seat's display name off a roster), and
151
+ * this parse throws rather than truncating - which on the bookings surface
152
+ * meant a permanent 500 on every join attempt, after the room rows had
153
+ * already been committed. `clampParticipantName` is the fix, and it lives in
154
+ * this package so there is exactly one thing to call.
155
+ */
156
+ it("refuses a name longer than the claim allows, and accepts a clamped one", () => {
157
+ const tooLong = "N".repeat(MEDIA_PARTICIPANT_NAME_MAX_LENGTH + 1);
158
+ const mint = (name: string) =>
159
+ signJoinToken(privateKey, "k1", {
160
+ ...base,
161
+ name,
162
+ kind: "human",
163
+ transport: "webrtc",
164
+ grants: { canPublish: true, canSubscribe: true, canPublishData: true },
165
+ exp: now + 300,
166
+ });
167
+
168
+ expect(() => mint(tooLong)).toThrow();
169
+ expect(() => mint(clampParticipantName(tooLong))).not.toThrow();
170
+ });
171
+
172
+ it("clamps to exactly the bound and leaves a short name alone", () => {
173
+ expect(clampParticipantName("N".repeat(400))).toHaveLength(MEDIA_PARTICIPANT_NAME_MAX_LENGTH);
174
+ expect(clampParticipantName("N".repeat(MEDIA_PARTICIPANT_NAME_MAX_LENGTH))).toHaveLength(
175
+ MEDIA_PARTICIPANT_NAME_MAX_LENGTH,
176
+ );
177
+ expect(clampParticipantName("Bought Session")).toBe("Bought Session");
178
+ });
179
+
180
+ /** A browser asking for a PlainTransport pointed anywhere is an RTP
181
+ * reflector plus an unauthenticated publish path into the room. */
182
+ it("refuses a non-webrtc transport for a human", () => {
183
+ expect(() =>
184
+ signJoinToken(privateKey, "k1", {
185
+ ...base,
186
+ kind: "human",
187
+ transport: "plain",
188
+ grants: { canPublish: true, canSubscribe: true, canPublishData: true },
189
+ exp: now + 300,
190
+ }),
191
+ ).toThrow(/webrtc/);
192
+ });
193
+ });
194
+
195
+ describe("a node's keyring must be verify-only", () => {
196
+ it("refuses to boot when a PRIVATE key is in the node's keyring", () => {
197
+ const { privateKey } = generateKeypair();
198
+ expect(() => assertPublicKeyringIsPublicOnly({ k1: privateKey })).toThrow(/PRIVATE key/);
199
+ });
200
+
201
+ it("refuses an unreadable key", () => {
202
+ expect(() => assertPublicKeyringIsPublicOnly({ k1: "not a key" })).toThrow(/readable public key/);
203
+ });
204
+
205
+ it("accepts a public keyring", () => {
206
+ const { publicKey } = generateKeypair();
207
+ expect(() => assertPublicKeyringIsPublicOnly({ k1: publicKey })).not.toThrow();
208
+ });
209
+ });
210
+
211
+ /**
212
+ * The information barrier. A boolean cannot express "A and B are in one room
213
+ * and must not receive each other, while C may receive both", which is the
214
+ * actual requirement for a Kanzlei.
215
+ */
216
+ describe("maySubscribe", () => {
217
+ const open: MediaGrants = { canPublish: true, canSubscribe: true, canPublishData: false };
218
+ const walled = (from: string[]): MediaGrants => ({ ...open, subscribe: { mode: "deny", identities: from } });
219
+ const hidden = (from: string[]): MediaGrants => ({ ...open, visibleTo: { mode: "deny", identities: from } });
220
+
221
+ const may = (c: string, cg: MediaGrants, p: string, pg: MediaGrants | undefined) =>
222
+ maySubscribe({ consumerIdentity: c, consumerGrants: cg, producerIdentity: p, ownerGrants: pg });
223
+
224
+ it("allows an ordinary pair", () => {
225
+ expect(may("alice", open, "bob", open)).toBe(true);
226
+ });
227
+
228
+ it("holds a two-way barrier between A and B while C sees both", () => {
229
+ // The case a boolean cannot express.
230
+ const alice = walled(["bob"]);
231
+ const bob = walled(["alice"]);
232
+ expect(may("alice", alice, "bob", bob)).toBe(false);
233
+ expect(may("bob", bob, "alice", alice)).toBe(false);
234
+ expect(may("carol", open, "alice", alice)).toBe(true);
235
+ expect(may("carol", open, "bob", bob)).toBe(true);
236
+ });
237
+
238
+ /** One-sided is not enough: a generously minted consumer must not be able to
239
+ * receive someone who was explicitly walled off from them. */
240
+ it("refuses on the PRODUCER's rule even when the consumer's is open", () => {
241
+ expect(may("alice", open, "bob", hidden(["alice"]))).toBe(false);
242
+ });
243
+
244
+ it("treats an unreadable owner as NO, not as the permissive default", () => {
245
+ // "I do not know" and "everyone" must never be the same answer in a room
246
+ // with a barrier. Note this differs from an ABSENT rule on a record that
247
+ // was found, which does mean everyone.
248
+ expect(may("alice", open, "bob", undefined)).toBe(false);
249
+ });
250
+
251
+ it("treats an absent rule on a found record as everyone", () => {
252
+ expect(may("alice", open, "bob", open)).toBe(true);
253
+ });
254
+
255
+ it("refuses when the consumer cannot subscribe at all", () => {
256
+ expect(may("alice", { ...open, canSubscribe: false }, "bob", open)).toBe(false);
257
+ });
258
+
259
+ it("refuses self-subscription", () => {
260
+ expect(may("alice", open, "alice", open)).toBe(false);
261
+ });
262
+
263
+ it("honours an allow list in both directions", () => {
264
+ const onlyCarol: MediaGrants = { ...open, subscribe: { mode: "allow", identities: ["carol"] } };
265
+ expect(may("alice", onlyCarol, "carol", open)).toBe(true);
266
+ expect(may("alice", onlyCarol, "bob", open)).toBe(false);
267
+ });
268
+
269
+ it("refuses everything under mode none", () => {
270
+ expect(may("alice", { ...open, subscribe: { mode: "none" } }, "bob", open)).toBe(false);
271
+ });
272
+ });