teleportxr 1.0.87 → 1.0.89

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,100 @@
1
+ 'use strict';
2
+ // Per-client server-side state for avatar negotiation. Phase 2 of the
3
+ // implementation in plans/avatars_implementation.md: round-trip a policy,
4
+ // receive an offer, always reply with using_default. No validation, no
5
+ // download, no import. Behaviour is the same regardless of whether the
6
+ // client offered an avatar or not.
7
+ //
8
+ // One AvatarService is owned by each Client; messages are dispatched in
9
+ // from the signaling layer.
10
+
11
+ const avatars = require('../protocol/avatars.js');
12
+
13
+ function envelope(type, content) {
14
+ return JSON.stringify({ 'teleport-signal-type': type, content });
15
+ }
16
+
17
+ class AvatarService {
18
+ constructor(clientID, sigSend) {
19
+ this.clientID = clientID;
20
+ this.sigSend = sigSend;
21
+ this.currentPolicy = null;
22
+ this.lastOffer = null;
23
+ this.lastResult = null;
24
+ }
25
+
26
+ // Send (or re-send) the policy to the owning client. The client is
27
+ // expected to reply with an avatar-offer.
28
+ sendPolicy(policy) {
29
+ if (!policy)
30
+ return;
31
+ this.currentPolicy = policy;
32
+ const content = policy && typeof policy.toJSON === 'function'
33
+ ? policy.toJSON()
34
+ : avatars.parseAvatarPolicy(policy).toJSON();
35
+ console.log('avatar-policy → client ' + this.clientID + ' policy_id=' + content.policy_id);
36
+ this.sigSend(envelope(avatars.TELEPORT_SIGNAL_TYPE_AVATAR_POLICY, content));
37
+ }
38
+
39
+ // Handle an incoming avatar-offer. Phase 2 always replies using_default.
40
+ handleOffer(offerJson) {
41
+ const offer = avatars.parseAvatarOffer(offerJson);
42
+ this.lastOffer = offer;
43
+ console.log('avatar-offer ← client ' + this.clientID +
44
+ ' policy_id=' + offer.policy_id +
45
+ ' have_avatar=' + offer.have_avatar);
46
+
47
+ // If we have not sent a policy, or the offer references a different
48
+ // policy_id, reject so the client knows it is talking about something
49
+ // the server does not currently care about.
50
+ if (!this.currentPolicy ||
51
+ BigInt(offer.policy_id || 0n) !== BigInt(this.currentPolicy.policy_id))
52
+ {
53
+ this._reply({
54
+ policy_id: offer.policy_id || 0n,
55
+ status: 'rejected',
56
+ node_uid: 0n,
57
+ using_default: false,
58
+ delivery: 'import',
59
+ reasons: ['policy_unknown'],
60
+ });
61
+ return;
62
+ }
63
+
64
+ // Phase 2: regardless of what the client offered, the server uses its
65
+ // default avatar. node_uid is 0 because no real node has been imported.
66
+ this._reply({
67
+ policy_id: offer.policy_id,
68
+ status: 'using_default',
69
+ node_uid: 0n,
70
+ using_default: true,
71
+ delivery: 'import',
72
+ reasons: [],
73
+ });
74
+ }
75
+
76
+ // Handle a client-initiated revoke (rare in Phase 2; provided for
77
+ // symmetry with later phases).
78
+ handleRevoke(revokeJson) {
79
+ const policy_id = revokeJson && revokeJson.policy_id != null
80
+ ? BigInt(revokeJson.policy_id) : 0n;
81
+ console.log('avatar-revoke ← client ' + this.clientID + ' policy_id=' + policy_id);
82
+ // In Phase 2 a revoke from the client just drops cached state; the
83
+ // server keeps the same policy in force and a new offer is expected
84
+ // next.
85
+ this.lastOffer = null;
86
+ this.lastResult = null;
87
+ }
88
+
89
+ _reply(result) {
90
+ const content = avatars.encodeAvatarResult(result);
91
+ this.lastResult = content;
92
+ console.log('avatar-result → client ' + this.clientID +
93
+ ' status=' + content.status +
94
+ ' delivery=' + content.delivery +
95
+ (content.reasons.length ? ' reasons=' + JSON.stringify(content.reasons) : ''));
96
+ this.sigSend(envelope(avatars.TELEPORT_SIGNAL_TYPE_AVATAR_RESULT, content));
97
+ }
98
+ }
99
+
100
+ module.exports = { AvatarService };
package/client/client.js CHANGED
@@ -4,6 +4,7 @@ const core= require("../core/core.js");
4
4
  const command= require("../protocol/command.js");
5
5
  const message= require("../protocol/message.js");
6
6
  const gs= require("./geometry_service.js");
7
+ const avatar_service= require("./avatar_service.js");
7
8
  const node_encoder= require("../protocol/encoders/node_encoder.js");
8
9
  const resource_encoder= require("../protocol/encoders/resource_encoder.js");
9
10
  const WebRtcConnectionManager = require('../connections/webrtcconnectionmanager');
@@ -54,6 +55,9 @@ class Client {
54
55
  this.origin_uid=0;
55
56
  this.handshakeMessage=new message.HandshakeMessage();
56
57
  this.geometryService=new gs.GeometryService(cid);
58
+ // Per-client avatar negotiation state. The host application drives
59
+ // when (and whether) policy is sent via this service.
60
+ this.avatarService=new avatar_service.AvatarService(cid, sigSend);
57
61
  this.webRtcConnected=false;
58
62
  this.webRtcConnection=null;
59
63
  this.currentOriginState=new OriginState();
@@ -111,6 +111,10 @@ class ClientManager
111
111
  // then we tell the client manager to start this client.
112
112
  var c=this.GetOrCreateClient(clientID);
113
113
  signalingClient.receiveReliableBinaryMessage=c.receiveReliableBinaryMessage.bind(c);
114
+ // Route avatar-offer / avatar-revoke text frames to the per-client
115
+ // AvatarService. The signaling layer dispatches by message type.
116
+ signalingClient.handleAvatarOffer=c.avatarService.handleOffer.bind(c.avatarService);
117
+ signalingClient.handleAvatarRevoke=c.avatarService.handleRevoke.bind(c.avatarService);
114
118
  //c.SetScene(this.scene);
115
119
  c.Start();
116
120
  return c;
@@ -126,7 +126,7 @@ class GeometryService {
126
126
  // MAY not be in this set:
127
127
  this.streamedNodes.delete(uid);
128
128
  // TODO: now reduce the counts for all the dependent resources.
129
- console.log("Unstreaming node ", node_uid," for client ", this.clientID);
129
+ console.log("Unstreaming node ", uid," for client ", this.clientID);
130
130
  }
131
131
  StreamOrUnstream(resourceMap, uid, diff) {
132
132
  // exclude "undefined"
@@ -431,7 +431,6 @@ class WebRtcConnection extends EventEmitter
431
431
 
432
432
  this.videoDataChannel = this.createDataChannel("video",20);
433
433
  this.tagDataChannel = this.createDataChannel("video_tags",40);
434
- this.audioToClientDataChannel = this.createDataChannel("audio_server_to_client",60);
435
434
  this.geometryDataChannel = this.createDataChannel("geometry_unframed",80);
436
435
  this.reliableDataChannel = this.createDataChannel("reliable",100);
437
436
  this.unreliableDataChannel = this.createDataChannel("unreliable",120,false);
@@ -585,7 +584,6 @@ class WebRtcConnection extends EventEmitter
585
584
  {
586
585
  //.videoDataChannel = .("video",20);
587
586
  //.tagDataChannel = .("video_tags",40);
588
- //.audioToClientDataChannel = .("audio_server_to_client",60);
589
587
  //.geometryDataChannel =l("geometry_unframed",80);
590
588
  //.reliableDataChannel =l("reliable",100);
591
589
  //.unreliableDataChannelnel("unreliable",120,false);
package/package.json CHANGED
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "name": "teleportxr",
18
18
  "description": "Teleport Spatial Server on node.js",
19
- "version": "1.0.87",
19
+ "version": "1.0.89",
20
20
  "repository": {
21
21
  "type": "git",
22
22
  "url": "git+https://github.com/simul/teleport-nodejs.git"
@@ -32,7 +32,9 @@
32
32
  "./signaling": "./signaling.js",
33
33
  "./client/client": "./client/client.js",
34
34
  "./client/client_manager": "./client/client_manager.js",
35
+ "./client/avatar_service": "./client/avatar_service.js",
35
36
  "./connections/webrtcconnectionmanager": "./connections/webrtcconnectionmanager.js",
37
+ "./protocol/avatars": "./protocol/avatars.js",
36
38
  "./scene/scene": "./scene/scene.js",
37
39
  "./scene/node": "./scene/node.js",
38
40
  "./scene/resources": "./scene/resources.js"
@@ -0,0 +1,162 @@
1
+ 'use strict';
2
+ // JSON codecs for the avatar-negotiation signaling messages.
3
+ // Wire format mirrors Teleport/TeleportCore/Avatars.h and is documented
4
+ // in Teleport/docs/protocol/signaling.rst.
5
+ //
6
+ // Each codec is a tiny pair of plain JS helpers so callers can build the
7
+ // JSON object directly, or round-trip an incoming JSON value into a
8
+ // well-typed object. Unknown keys on incoming objects are preserved so
9
+ // future protocol fields survive a parse+emit cycle.
10
+
11
+ const TELEPORT_SIGNAL_TYPE_AVATAR_POLICY = 'avatar-policy';
12
+ const TELEPORT_SIGNAL_TYPE_AVATAR_OFFER = 'avatar-offer';
13
+ const TELEPORT_SIGNAL_TYPE_AVATAR_RESULT = 'avatar-result';
14
+ const TELEPORT_SIGNAL_TYPE_AVATAR_REVOKE = 'avatar-revoke';
15
+ const TELEPORT_SIGNAL_TYPE_PEER_AVATAR = 'peer-avatar';
16
+ const TELEPORT_SIGNAL_TYPE_PEER_AVATAR_FAILED = 'peer-avatar-failed';
17
+
18
+ // SignalingCapabilities ------------------------------------------------
19
+ // Free-form capability bag advertised on the `connect` envelope. Unknown
20
+ // keys are ignored on read and dropped on write — first-class flags only.
21
+
22
+ function decodeCapabilities(raw) {
23
+ const out = { avatar_relay: false };
24
+ if (raw && typeof raw === 'object' && typeof raw.avatar_relay === 'boolean') {
25
+ out.avatar_relay = raw.avatar_relay;
26
+ }
27
+ return out;
28
+ }
29
+
30
+ function encodeCapabilities(caps) {
31
+ return { avatar_relay: !!(caps && caps.avatar_relay) };
32
+ }
33
+
34
+ // AvatarPolicy ---------------------------------------------------------
35
+
36
+ class AvatarPolicy {
37
+ constructor(opts = {}) {
38
+ this.policy_id = BigInt(opts.policy_id || 0n);
39
+ this.requirement = opts.requirement || 'optional'; // required | optional | forbidden
40
+ this.default_available = !!opts.default_available;
41
+ this.requirements = opts.requirements || {};
42
+ this.proof = Object.assign({ required: false, accepted_schemes: [] }, opts.proof || {});
43
+ if (opts.fetch_timeout_ms != null) this.fetch_timeout_ms = opts.fetch_timeout_ms;
44
+ }
45
+ toJSON() {
46
+ const j = {
47
+ policy_id: Number(this.policy_id),
48
+ requirement: this.requirement,
49
+ default_available: this.default_available,
50
+ requirements: this.requirements,
51
+ proof: {
52
+ required: !!this.proof.required,
53
+ accepted_schemes: Array.isArray(this.proof.accepted_schemes) ? this.proof.accepted_schemes : []
54
+ }
55
+ };
56
+ if (this.fetch_timeout_ms != null) j.fetch_timeout_ms = this.fetch_timeout_ms;
57
+ return j;
58
+ }
59
+ }
60
+
61
+ function parseAvatarPolicy(j) {
62
+ const p = new AvatarPolicy();
63
+ if (!j || typeof j !== 'object') return p;
64
+ if (j.policy_id != null) p.policy_id = BigInt(j.policy_id);
65
+ if (j.requirement != null) p.requirement = String(j.requirement);
66
+ if (j.default_available != null) p.default_available = !!j.default_available;
67
+ if (j.requirements != null) p.requirements = j.requirements;
68
+ if (j.proof != null) p.proof = {
69
+ required: !!j.proof.required,
70
+ accepted_schemes: Array.isArray(j.proof.accepted_schemes) ? j.proof.accepted_schemes.slice() : []
71
+ };
72
+ if (j.fetch_timeout_ms != null) p.fetch_timeout_ms = j.fetch_timeout_ms;
73
+ return p;
74
+ }
75
+
76
+ // AvatarOffer ----------------------------------------------------------
77
+
78
+ function parseAvatarOffer(j) {
79
+ const o = { policy_id: 0n, have_avatar: false };
80
+ if (!j || typeof j !== 'object') return o;
81
+ if (j.policy_id != null) o.policy_id = BigInt(j.policy_id);
82
+ if (j.have_avatar != null) o.have_avatar = !!j.have_avatar;
83
+ if (j.url != null) o.url = String(j.url);
84
+ if (j.content_hash != null) o.content_hash = String(j.content_hash);
85
+ if (j.declared && typeof j.declared === 'object') {
86
+ o.declared = {
87
+ format: j.declared.format ? String(j.declared.format) : '',
88
+ };
89
+ if (j.declared.file_bytes != null) o.declared.file_bytes = Number(j.declared.file_bytes);
90
+ if (j.declared.triangles != null) o.declared.triangles = Number(j.declared.triangles);
91
+ }
92
+ if (j.proof && typeof j.proof === 'object') {
93
+ o.proof = {
94
+ scheme: j.proof.scheme ? String(j.proof.scheme) : '',
95
+ value: j.proof.value ? String(j.proof.value) : ''
96
+ };
97
+ }
98
+ if (j.allow_relay != null) o.allow_relay = !!j.allow_relay;
99
+ return o;
100
+ }
101
+
102
+ function encodeAvatarOffer(o) {
103
+ const j = { policy_id: Number(o.policy_id || 0n), have_avatar: !!o.have_avatar };
104
+ if (o.url != null) j.url = String(o.url);
105
+ if (o.content_hash != null) j.content_hash = String(o.content_hash);
106
+ if (o.declared) j.declared = Object.assign({}, o.declared);
107
+ if (o.proof) j.proof = Object.assign({}, o.proof);
108
+ if (o.allow_relay != null) j.allow_relay = !!o.allow_relay;
109
+ return j;
110
+ }
111
+
112
+ // AvatarResult / Revoke / Peer messages --------------------------------
113
+
114
+ function encodeAvatarResult(r) {
115
+ return {
116
+ policy_id: Number(r.policy_id || 0n),
117
+ status: r.status || 'rejected', // accepted | rejected | pending
118
+ node_uid: Number(r.node_uid || 0n),
119
+ using_default: !!r.using_default,
120
+ delivery: r.delivery || 'import', // import | relay
121
+ reasons: Array.isArray(r.reasons) ? r.reasons.slice() : []
122
+ };
123
+ }
124
+
125
+ function encodeAvatarRevoke(r) {
126
+ return { policy_id: Number(r.policy_id || 0n), reason: r.reason || '' };
127
+ }
128
+
129
+ function encodePeerAvatar(p) {
130
+ const j = {
131
+ peer_client_id: Number(p.peer_client_id || 0n),
132
+ peer_node_uid: Number(p.peer_node_uid || 0n),
133
+ revoked: !!p.revoked
134
+ };
135
+ if (p.url != null) j.url = String(p.url);
136
+ if (p.content_hash != null) j.content_hash = String(p.content_hash);
137
+ if (p.format != null) j.format = String(p.format);
138
+ if (p.proof) j.proof = Object.assign({}, p.proof);
139
+ return j;
140
+ }
141
+
142
+ function parsePeerAvatarFailed(j) {
143
+ const f = { peer_node_uid: 0n, reason: '' };
144
+ if (!j || typeof j !== 'object') return f;
145
+ if (j.peer_node_uid != null) f.peer_node_uid = BigInt(j.peer_node_uid);
146
+ if (j.reason != null) f.reason = String(j.reason);
147
+ return f;
148
+ }
149
+
150
+ module.exports = {
151
+ TELEPORT_SIGNAL_TYPE_AVATAR_POLICY,
152
+ TELEPORT_SIGNAL_TYPE_AVATAR_OFFER,
153
+ TELEPORT_SIGNAL_TYPE_AVATAR_RESULT,
154
+ TELEPORT_SIGNAL_TYPE_AVATAR_REVOKE,
155
+ TELEPORT_SIGNAL_TYPE_PEER_AVATAR,
156
+ TELEPORT_SIGNAL_TYPE_PEER_AVATAR_FAILED,
157
+ decodeCapabilities, encodeCapabilities,
158
+ AvatarPolicy, parseAvatarPolicy,
159
+ parseAvatarOffer, encodeAvatarOffer,
160
+ encodeAvatarResult, encodeAvatarRevoke,
161
+ encodePeerAvatar, parsePeerAvatarFailed,
162
+ };
package/signaling.js CHANGED
@@ -4,6 +4,7 @@ const getcurrentline = require("get-current-line").default;
4
4
  // Importing the required modules
5
5
  const WebSocketServer = require("ws");
6
6
  const core = require("./core/core.js");
7
+ const avatars = require("./protocol/avatars.js");
7
8
 
8
9
  class SignalingState {
9
10
  static START = new SignalingState("Start");
@@ -22,7 +23,7 @@ class SignalingState {
22
23
  }
23
24
  var serverID=BigInt(0n);
24
25
 
25
- class SignalingClient {
26
+ class SignalingClient {
26
27
  constructor(ip, ws, id) {
27
28
  this.ip = ip;
28
29
  this.ws = ws;
@@ -31,6 +32,15 @@ class SignalingClient {
31
32
  this.signalingState = SignalingState.START;
32
33
  this.clientID = id;
33
34
  this.receiveReliableBinaryMessage=null;
35
+ // Handlers wired by the per-client Client when it is created
36
+ // (see ClientManager.newClient). Avatar negotiation messages are
37
+ // JSON text frames so they cannot share the WebRTC binary path.
38
+ this.handleAvatarOffer=null;
39
+ this.handleAvatarRevoke=null;
40
+ // Session-level capabilities advertised by the client in its
41
+ // `connect` message. Defaults to all-false so that an older
42
+ // client which omits the field is treated conservatively.
43
+ this.capabilities = { avatar_relay: false };
34
44
  }
35
45
  ChangeSignalingState(newState) {
36
46
  console.log(
@@ -80,6 +90,11 @@ function processDisconnection(clientID,signalingClient){
80
90
  signalingClients.delete(clientID);
81
91
  }
82
92
  function processInitialRequest(clientID, signalingClient, content) {
93
+ // Free-form capability bag: a missing / malformed object leaves
94
+ // capabilities at their default (all-false) state.
95
+ if (content && typeof content === 'object' && content.capabilities) {
96
+ signalingClient.capabilities = avatars.decodeCapabilities(content.capabilities);
97
+ }
83
98
  var j_clientID = 0;
84
99
  if (content.hasOwnProperty("clientID")) {
85
100
  var j_clientID = content["clientID"];
@@ -162,6 +177,18 @@ function receiveWebSocketsMessage(clientID, signalingClient, txt) {
162
177
  {
163
178
  processDisconnection(clientID, signalingClient);
164
179
  }
180
+ else if (teleport_signal_type == avatars.TELEPORT_SIGNAL_TYPE_AVATAR_OFFER)
181
+ {
182
+ if (signalingClient.handleAvatarOffer)
183
+ signalingClient.handleAvatarOffer(message["content"]);
184
+ else
185
+ console.log("avatar-offer received for client " + clientID + " but no handler is wired.");
186
+ }
187
+ else if (teleport_signal_type == avatars.TELEPORT_SIGNAL_TYPE_AVATAR_REVOKE)
188
+ {
189
+ if (signalingClient.handleAvatarRevoke)
190
+ signalingClient.handleAvatarRevoke(message["content"]);
191
+ }
165
192
  else
166
193
  {
167
194
  var webRtcConnection = webRtcConnectionManager.getConnection(clientID);
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+ // Tests for the per-client AvatarService introduced in Phase 2 of the
3
+ // avatars implementation plan. The service:
4
+ // * serialises a policy and emits it as an avatar-policy text frame;
5
+ // * receives an avatar-offer and replies with avatar-result.
6
+ // Phase 2 always replies status=using_default for a matching policy and
7
+ // status=rejected,reasons=[policy_unknown] for anything else.
8
+
9
+ const test = require('node:test');
10
+ const assert = require('node:assert');
11
+
12
+ const avatars = require('../protocol/avatars.js');
13
+ const avatar_service = require('../client/avatar_service.js');
14
+
15
+ // Tiny helper that captures every signaling string the service emits.
16
+ function makeSink() {
17
+ const sent = [];
18
+ return { send: (s) => sent.push(JSON.parse(s)), sent };
19
+ }
20
+
21
+ test('sendPolicy emits an avatar-policy envelope with the policy content', () => {
22
+ const sink = makeSink();
23
+ const svc = new avatar_service.AvatarService(42n, sink.send);
24
+ const policy = new avatars.AvatarPolicy({
25
+ policy_id: 12345n,
26
+ requirement: 'optional',
27
+ default_available: true,
28
+ requirements: { formats: ['glb'], max_file_bytes: 8388608 },
29
+ });
30
+ svc.sendPolicy(policy);
31
+ assert.strictEqual(sink.sent.length, 1);
32
+ const msg = sink.sent[0];
33
+ assert.strictEqual(msg['teleport-signal-type'], 'avatar-policy');
34
+ assert.strictEqual(msg.content.policy_id, 12345);
35
+ assert.strictEqual(msg.content.requirement, 'optional');
36
+ assert.strictEqual(msg.content.default_available, true);
37
+ assert.deepStrictEqual(msg.content.requirements.formats, ['glb']);
38
+ });
39
+
40
+ test('handleOffer replies with using_default when policy_id matches', () => {
41
+ const sink = makeSink();
42
+ const svc = new avatar_service.AvatarService(42n, sink.send);
43
+ svc.sendPolicy(new avatars.AvatarPolicy({ policy_id: 7n, default_available: true }));
44
+ sink.sent.length = 0;
45
+ svc.handleOffer({ policy_id: 7, have_avatar: false });
46
+ assert.strictEqual(sink.sent.length, 1);
47
+ const msg = sink.sent[0];
48
+ assert.strictEqual(msg['teleport-signal-type'], 'avatar-result');
49
+ assert.strictEqual(msg.content.policy_id, 7);
50
+ assert.strictEqual(msg.content.status, 'using_default');
51
+ assert.strictEqual(msg.content.using_default, true);
52
+ assert.strictEqual(msg.content.delivery, 'import');
53
+ assert.strictEqual(msg.content.node_uid, 0);
54
+ assert.deepStrictEqual(msg.content.reasons, []);
55
+ });
56
+
57
+ test('handleOffer still replies using_default when client supplies an avatar', () => {
58
+ // Phase 2 ignores the offer contents entirely; this asserts that we
59
+ // don't accidentally accept a URL.
60
+ const sink = makeSink();
61
+ const svc = new avatar_service.AvatarService(42n, sink.send);
62
+ svc.sendPolicy(new avatars.AvatarPolicy({ policy_id: 9n, default_available: true }));
63
+ sink.sent.length = 0;
64
+ svc.handleOffer({
65
+ policy_id: 9,
66
+ have_avatar: true,
67
+ url: 'https://example.com/avatar.glb',
68
+ content_hash: 'sha256:abcd',
69
+ declared: { format: 'glb', file_bytes: 4096 },
70
+ });
71
+ assert.strictEqual(sink.sent[0].content.status, 'using_default');
72
+ assert.strictEqual(sink.sent[0].content.using_default, true);
73
+ });
74
+
75
+ test('handleOffer rejects with policy_unknown when policy_id does not match', () => {
76
+ const sink = makeSink();
77
+ const svc = new avatar_service.AvatarService(42n, sink.send);
78
+ svc.sendPolicy(new avatars.AvatarPolicy({ policy_id: 100n, default_available: true }));
79
+ sink.sent.length = 0;
80
+ svc.handleOffer({ policy_id: 999, have_avatar: false });
81
+ const msg = sink.sent[0];
82
+ assert.strictEqual(msg.content.status, 'rejected');
83
+ assert.deepStrictEqual(msg.content.reasons, ['policy_unknown']);
84
+ });
85
+
86
+ test('handleOffer rejects when no policy has been sent yet', () => {
87
+ const sink = makeSink();
88
+ const svc = new avatar_service.AvatarService(42n, sink.send);
89
+ svc.handleOffer({ policy_id: 1, have_avatar: false });
90
+ assert.strictEqual(sink.sent[0].content.status, 'rejected');
91
+ assert.deepStrictEqual(sink.sent[0].content.reasons, ['policy_unknown']);
92
+ });
93
+
94
+ test('handleRevoke clears cached offer state without emitting anything', () => {
95
+ const sink = makeSink();
96
+ const svc = new avatar_service.AvatarService(42n, sink.send);
97
+ svc.sendPolicy(new avatars.AvatarPolicy({ policy_id: 5n }));
98
+ svc.handleOffer({ policy_id: 5, have_avatar: false });
99
+ sink.sent.length = 0;
100
+ svc.handleRevoke({ policy_id: 5 });
101
+ assert.strictEqual(sink.sent.length, 0);
102
+ assert.strictEqual(svc.lastOffer, null);
103
+ assert.strictEqual(svc.lastResult, null);
104
+ });
105
+
106
+ test('signaling dispatch: avatar-offer routed to handleAvatarOffer', () => {
107
+ // Round-trip the dispatch path: build a SignalingClient stub, attach a
108
+ // handler, and feed it a JSON frame. Reaching into the module the same
109
+ // way test_avatars.js does because SignalingClient is not exported.
110
+ const fs = require('node:fs');
111
+ const path = require('node:path');
112
+ const Module = require('node:module');
113
+ const src = fs.readFileSync(path.join(__dirname, '..', 'signaling.js'), 'utf8');
114
+ const m = new Module(require.resolve('../signaling.js'));
115
+ m.filename = require.resolve('../signaling.js');
116
+ m.paths = Module._nodeModulePaths(m.filename);
117
+ m._compile(src + '\nmodule.exports._SignalingClient = SignalingClient;'
118
+ + '\nmodule.exports._receiveWebSocketsMessage = receiveWebSocketsMessage;\n', m.filename);
119
+ const SignalingClient = m.exports._SignalingClient;
120
+ const receive = m.exports._receiveWebSocketsMessage;
121
+
122
+ const sc = new SignalingClient('1.2.3.4', { send: () => {} }, 1n);
123
+ let receivedOffer = null;
124
+ sc.handleAvatarOffer = (o) => { receivedOffer = o; };
125
+ let receivedRevoke = null;
126
+ sc.handleAvatarRevoke = (r) => { receivedRevoke = r; };
127
+
128
+ receive(1n, sc, JSON.stringify({
129
+ 'teleport-signal-type': 'avatar-offer',
130
+ content: { policy_id: 1, have_avatar: false },
131
+ }));
132
+ assert.deepStrictEqual(receivedOffer, { policy_id: 1, have_avatar: false });
133
+
134
+ receive(1n, sc, JSON.stringify({
135
+ 'teleport-signal-type': 'avatar-revoke',
136
+ content: { policy_id: 1, reason: 'replaced' },
137
+ }));
138
+ assert.deepStrictEqual(receivedRevoke, { policy_id: 1, reason: 'replaced' });
139
+ });
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+ // Unit tests for the avatar-negotiation JSON codecs in protocol/avatars.js
3
+ // and the connect-time `capabilities` parsing in signaling.js.
4
+ // Mirrors the C++ test suite in Teleport/test/test_avatars.cpp so a
5
+ // regression on either side surfaces in matching test cases.
6
+
7
+ const test = require('node:test');
8
+ const assert = require('node:assert');
9
+
10
+ const avatars = require('../protocol/avatars.js');
11
+
12
+ test('decodeCapabilities returns all-false for missing / empty / non-object input', () => {
13
+ assert.deepStrictEqual(avatars.decodeCapabilities(undefined), { avatar_relay: false });
14
+ assert.deepStrictEqual(avatars.decodeCapabilities(null), { avatar_relay: false });
15
+ assert.deepStrictEqual(avatars.decodeCapabilities({}), { avatar_relay: false });
16
+ assert.deepStrictEqual(avatars.decodeCapabilities('nope'), { avatar_relay: false });
17
+ });
18
+
19
+ test('decodeCapabilities reads avatar_relay and ignores unknown future keys', () => {
20
+ const caps = avatars.decodeCapabilities({ avatar_relay: true, future_flag: 'whatever' });
21
+ assert.strictEqual(caps.avatar_relay, true);
22
+ assert.strictEqual('future_flag' in caps, false);
23
+ });
24
+
25
+ test('decodeCapabilities ignores non-boolean avatar_relay', () => {
26
+ const caps = avatars.decodeCapabilities({ avatar_relay: 'yes' });
27
+ assert.strictEqual(caps.avatar_relay, false);
28
+ });
29
+
30
+ test('encodeCapabilities drops unknown keys and coerces to boolean', () => {
31
+ assert.deepStrictEqual(avatars.encodeCapabilities({ avatar_relay: 1, junk: 'x' }), { avatar_relay: true });
32
+ assert.deepStrictEqual(avatars.encodeCapabilities({}), { avatar_relay: false });
33
+ });
34
+
35
+ test('AvatarPolicy: toJSON / parseAvatarPolicy round-trip', () => {
36
+ const policy = new avatars.AvatarPolicy({
37
+ policy_id: 12345n,
38
+ requirement: 'required',
39
+ default_available: true,
40
+ requirements: { formats: ['glb', 'vrm'], max_file_bytes: 8388608, max_triangles: 60000, skeleton: 'humanoid' },
41
+ proof: { required: true, accepted_schemes: ['jws-detached', 'well-known-url'] },
42
+ fetch_timeout_ms: 7500,
43
+ });
44
+ const wire = JSON.parse(JSON.stringify(policy));
45
+ const parsed = avatars.parseAvatarPolicy(wire);
46
+ assert.strictEqual(parsed.policy_id, 12345n);
47
+ assert.strictEqual(parsed.requirement, 'required');
48
+ assert.strictEqual(parsed.default_available, true);
49
+ assert.deepStrictEqual(parsed.requirements.formats, ['glb', 'vrm']);
50
+ assert.strictEqual(parsed.proof.required, true);
51
+ assert.deepStrictEqual(parsed.proof.accepted_schemes, ['jws-detached', 'well-known-url']);
52
+ assert.strictEqual(parsed.fetch_timeout_ms, 7500);
53
+ });
54
+
55
+ test('parseAvatarOffer handles the have_avatar=false short-form', () => {
56
+ const o = avatars.parseAvatarOffer({ policy_id: 7, have_avatar: false });
57
+ assert.strictEqual(o.policy_id, 7n);
58
+ assert.strictEqual(o.have_avatar, false);
59
+ assert.strictEqual(o.url, undefined);
60
+ assert.strictEqual(o.declared, undefined);
61
+ });
62
+
63
+ test('parseAvatarOffer + encodeAvatarOffer round-trip a full offer', () => {
64
+ const offer = {
65
+ policy_id: 42n,
66
+ have_avatar: true,
67
+ url: 'https://avatars.example.com/u/42.glb',
68
+ content_hash: 'sha256:abcd',
69
+ declared: { format: 'glb', file_bytes: 4096, triangles: 1200 },
70
+ proof: { scheme: 'jws-detached', value: 'eyJ...' },
71
+ allow_relay: false,
72
+ };
73
+ const wire = avatars.encodeAvatarOffer(offer);
74
+ const back = avatars.parseAvatarOffer(wire);
75
+ assert.strictEqual(back.policy_id, 42n);
76
+ assert.strictEqual(back.have_avatar, true);
77
+ assert.strictEqual(back.url, offer.url);
78
+ assert.strictEqual(back.content_hash, offer.content_hash);
79
+ assert.deepStrictEqual(back.declared, offer.declared);
80
+ assert.deepStrictEqual(back.proof, offer.proof);
81
+ assert.strictEqual(back.allow_relay, false);
82
+ });
83
+
84
+ test('encodeAvatarResult fills sensible defaults for missing fields', () => {
85
+ const r = avatars.encodeAvatarResult({ policy_id: 3n, status: 'accepted', node_uid: 999n, delivery: 'relay' });
86
+ assert.strictEqual(r.policy_id, 3);
87
+ assert.strictEqual(r.status, 'accepted');
88
+ assert.strictEqual(r.node_uid, 999);
89
+ assert.strictEqual(r.using_default, false);
90
+ assert.strictEqual(r.delivery, 'relay');
91
+ assert.deepStrictEqual(r.reasons, []);
92
+ });
93
+
94
+ test('encodeAvatarRevoke produces the expected envelope', () => {
95
+ assert.deepStrictEqual(
96
+ avatars.encodeAvatarRevoke({ policy_id: 17n, reason: 'licence_expired' }),
97
+ { policy_id: 17, reason: 'licence_expired' }
98
+ );
99
+ });
100
+
101
+ test('encodePeerAvatar carries url / hash / format / proof', () => {
102
+ const wire = avatars.encodePeerAvatar({
103
+ peer_client_id: 100n,
104
+ peer_node_uid: 200n,
105
+ url: 'https://example.com/a.glb',
106
+ content_hash: 'sha256:ff',
107
+ format: 'glb',
108
+ proof: { scheme: 'well-known-url', value: 'https://example.com/.well-known/avatar-binding' },
109
+ });
110
+ assert.strictEqual(wire.peer_client_id, 100);
111
+ assert.strictEqual(wire.peer_node_uid, 200);
112
+ assert.strictEqual(wire.url, 'https://example.com/a.glb');
113
+ assert.strictEqual(wire.content_hash, 'sha256:ff');
114
+ assert.strictEqual(wire.format, 'glb');
115
+ assert.strictEqual(wire.proof.scheme, 'well-known-url');
116
+ assert.strictEqual(wire.revoked, false);
117
+ });
118
+
119
+ test('parsePeerAvatarFailed round-trips peer_node_uid and reason', () => {
120
+ const f = avatars.parsePeerAvatarFailed({ peer_node_uid: '200', reason: '404' });
121
+ assert.strictEqual(f.peer_node_uid, 200n);
122
+ assert.strictEqual(f.reason, '404');
123
+ });
124
+
125
+ test('signaling.SignalingClient defaults capabilities to all-false', () => {
126
+ // Constructed without a websocket — fine for testing the field shape only.
127
+ const signaling = require('../signaling.js');
128
+ // SignalingClient isn't exported; reach into the module like other tests do.
129
+ const fs = require('node:fs');
130
+ const path = require('node:path');
131
+ const Module = require('node:module');
132
+ const src = fs.readFileSync(path.join(__dirname, '..', 'signaling.js'), 'utf8');
133
+ const m = new Module(require.resolve('../signaling.js'));
134
+ m.filename = require.resolve('../signaling.js');
135
+ m.paths = Module._nodeModulePaths(m.filename);
136
+ m._compile(src + '\nmodule.exports._SignalingClient = SignalingClient;\n', m.filename);
137
+ const SignalingClient = m.exports._SignalingClient;
138
+ const c = new SignalingClient('1.2.3.4', /* ws */ null, 1n);
139
+ assert.deepStrictEqual(c.capabilities, { avatar_relay: false });
140
+ });