teleportxr 1.0.88 → 1.0.90

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,164 @@
1
+ 'use strict';
2
+ // Per-client server-side state for avatar negotiation. Phase 3 of the
3
+ // implementation in plans/avatars_implementation.md: hand an offered
4
+ // URL to an IAvatarValidator and surface its verdict. With no
5
+ // validator wired the service falls back to the Phase-2 behaviour
6
+ // (always reply using_default), so existing deployments keep working.
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
+ // Threshold above which a 'pending' frame is sent so the client can
14
+ // show progress (plan §4.1 / §9). Picked to be just under one second
15
+ // so the UX never feels stuck.
16
+ const PENDING_DELAY_MS = 750;
17
+
18
+ function envelope(type, content) {
19
+ return JSON.stringify({ 'teleport-signal-type': type, content });
20
+ }
21
+
22
+ class AvatarService {
23
+ constructor(clientID, sigSend, opts) {
24
+ this.clientID = clientID;
25
+ this.sigSend = sigSend;
26
+ this.currentPolicy = null;
27
+ this.lastOffer = null;
28
+ this.lastResult = null;
29
+ // Optional IAvatarValidator. When null the service keeps its
30
+ // Phase-2 behaviour: any offer is answered with using_default.
31
+ this.validator = (opts && opts.validator) || null;
32
+ }
33
+
34
+ // Send (or re-send) the policy to the owning client. The client is
35
+ // expected to reply with an avatar-offer.
36
+ sendPolicy(policy) {
37
+ if (!policy)
38
+ return;
39
+ this.currentPolicy = policy;
40
+ const content = policy && typeof policy.toJSON === 'function'
41
+ ? policy.toJSON()
42
+ : avatars.parseAvatarPolicy(policy).toJSON();
43
+ console.log('avatar-policy → client ' + this.clientID + ' policy_id=' + content.policy_id);
44
+ this.sigSend(envelope(avatars.TELEPORT_SIGNAL_TYPE_AVATAR_POLICY, content));
45
+ }
46
+
47
+ // Handle an incoming avatar-offer. With no validator wired the
48
+ // service replies using_default exactly as Phase 2 did; with a
49
+ // validator the offered URL is fetched, hashed and measured, and
50
+ // the verdict is reported back.
51
+ async handleOffer(offerJson) {
52
+ const offer = avatars.parseAvatarOffer(offerJson);
53
+ this.lastOffer = offer;
54
+ console.log('avatar-offer ← client ' + this.clientID +
55
+ ' policy_id=' + offer.policy_id +
56
+ ' have_avatar=' + offer.have_avatar);
57
+
58
+ if (!this.currentPolicy ||
59
+ BigInt(offer.policy_id || 0n) !== BigInt(this.currentPolicy.policy_id))
60
+ {
61
+ this._reply({
62
+ policy_id: offer.policy_id || 0n,
63
+ status: 'rejected',
64
+ node_uid: 0n,
65
+ using_default: false,
66
+ delivery: 'import',
67
+ reasons: ['policy_unknown'],
68
+ });
69
+ return;
70
+ }
71
+
72
+ // Without a validator, or without an offered URL, fall straight
73
+ // back to the default avatar (this is also the Phase-2 path).
74
+ if (!this.validator || !offer.have_avatar || !offer.url) {
75
+ this._reply({
76
+ policy_id: offer.policy_id,
77
+ status: 'using_default',
78
+ node_uid: 0n,
79
+ using_default: true,
80
+ delivery: 'import',
81
+ reasons: [],
82
+ });
83
+ return;
84
+ }
85
+
86
+ // Long-running validation gets a 'pending' status so the client
87
+ // can show progress instead of appearing to hang (plan §4.1).
88
+ let pendingSent = false;
89
+ const pendingTimer = setTimeout(() => {
90
+ pendingSent = true;
91
+ this._reply({
92
+ policy_id: offer.policy_id,
93
+ status: 'pending',
94
+ node_uid: 0n,
95
+ using_default: false,
96
+ delivery: 'import',
97
+ reasons: [],
98
+ }, /*record*/ false);
99
+ }, PENDING_DELAY_MS);
100
+
101
+ let result;
102
+ try {
103
+ result = await this.validator.validate(offer, this.currentPolicy.requirements || {});
104
+ } catch (err) {
105
+ result = { ok: false, reasons: ['validator_error'], bytes: 0, contentHash: '', format: '' };
106
+ }
107
+ clearTimeout(pendingTimer);
108
+
109
+ if (result.ok) {
110
+ this._reply({
111
+ policy_id: offer.policy_id,
112
+ status: 'accepted',
113
+ node_uid: 0n,
114
+ using_default: false,
115
+ delivery: 'import',
116
+ reasons: [],
117
+ });
118
+ } else if (this.currentPolicy.default_available) {
119
+ this._reply({
120
+ policy_id: offer.policy_id,
121
+ status: 'using_default',
122
+ node_uid: 0n,
123
+ using_default: true,
124
+ delivery: 'import',
125
+ reasons: result.reasons || [],
126
+ });
127
+ } else {
128
+ this._reply({
129
+ policy_id: offer.policy_id,
130
+ status: 'rejected',
131
+ node_uid: 0n,
132
+ using_default: false,
133
+ delivery: 'import',
134
+ reasons: result.reasons || ['validation_failed'],
135
+ });
136
+ }
137
+ void pendingSent;
138
+ }
139
+
140
+ // Handle a client-initiated revoke (rare in Phase 2; provided for
141
+ // symmetry with later phases).
142
+ handleRevoke(revokeJson) {
143
+ const policy_id = revokeJson && revokeJson.policy_id != null
144
+ ? BigInt(revokeJson.policy_id) : 0n;
145
+ console.log('avatar-revoke ← client ' + this.clientID + ' policy_id=' + policy_id);
146
+ // In Phase 2 a revoke from the client just drops cached state; the
147
+ // server keeps the same policy in force and a new offer is expected
148
+ // next.
149
+ this.lastOffer = null;
150
+ this.lastResult = null;
151
+ }
152
+
153
+ _reply(result, record = true) {
154
+ const content = avatars.encodeAvatarResult(result);
155
+ if (record) this.lastResult = content;
156
+ console.log('avatar-result → client ' + this.clientID +
157
+ ' status=' + content.status +
158
+ ' delivery=' + content.delivery +
159
+ (content.reasons.length ? ' reasons=' + JSON.stringify(content.reasons) : ''));
160
+ this.sigSend(envelope(avatars.TELEPORT_SIGNAL_TYPE_AVATAR_RESULT, content));
161
+ }
162
+ }
163
+
164
+ module.exports = { AvatarService };
@@ -0,0 +1,332 @@
1
+ 'use strict';
2
+ // Phase 3 of the avatar implementation plan: server-side validation of
3
+ // avatar URLs supplied in avatar-offer. Goal is real download, real
4
+ // measurement, real rejection reasons. Bytes never leave this process
5
+ // — Phase 3 still has no peer visibility.
6
+ //
7
+ // IAvatarValidator is the pluggable interface; DefaultAvatarValidator is
8
+ // a node-only implementation that uses node:https for full control over
9
+ // the transport (so SSRF and byte/time limits can be enforced before any
10
+ // peer protocol code sees the bytes). Tests substitute a fake fetcher
11
+ // via the constructor so they don't have to hit the network.
12
+
13
+ const crypto = require('node:crypto');
14
+ const dns = require('node:dns');
15
+ const https = require('node:https');
16
+ const http = require('node:http');
17
+ const net = require('node:net');
18
+ const { URL } = require('node:url');
19
+
20
+ // SSRF blocklist ------------------------------------------------------
21
+ // Mirrors plans/avatars_plan.md §7 (SSRF row) and §5 V3. Refuses
22
+ // loopback, link-local, private, broadcast, metadata-service and the
23
+ // "this network" range. IPv6: refuses loopback, link-local, ULA, and
24
+ // IPv4-mapped addresses (which would re-enter the v4 blocklist).
25
+ function isBlockedIp(ip)
26
+ {
27
+ if (typeof ip !== 'string' || !ip.length)
28
+ return true;
29
+ const family = net.isIP(ip);
30
+ if (family === 4)
31
+ {
32
+ const o = ip.split('.').map(Number);
33
+ if (o.length !== 4 || o.some(b => !Number.isFinite(b) || b < 0 || b > 255))
34
+ return true;
35
+ if (o[0] === 0) return true; // "this" net
36
+ if (o[0] === 10) return true; // RFC1918
37
+ if (o[0] === 127) return true; // loopback
38
+ if (o[0] === 169 && o[1] === 254) return true; // link-local + AWS metadata
39
+ if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return true; // RFC1918
40
+ if (o[0] === 192 && o[1] === 168) return true; // RFC1918
41
+ if (o[0] === 192 && o[1] === 0 && o[2] === 0) return true; // IETF protocol
42
+ if (o[0] === 198 && (o[1] === 18 || o[1] === 19)) return true; // benchmarking
43
+ if (o[0] >= 224) return true; // multicast + reserved
44
+ return false;
45
+ }
46
+ if (family === 6)
47
+ {
48
+ const lo = ip.toLowerCase();
49
+ if (lo === '::1' || lo === '::') return true;
50
+ if (lo.startsWith('fe80:') || lo.startsWith('fe80::')) return true; // link-local
51
+ if (lo.startsWith('fc') || lo.startsWith('fd')) return true; // ULA fc00::/7
52
+ if (lo.startsWith('ff')) return true; // multicast
53
+ if (lo.startsWith('::ffff:')) return true; // v4-mapped
54
+ return false;
55
+ }
56
+ return true;
57
+ }
58
+
59
+ // Resolve a hostname to a single IP and check it against the blocklist.
60
+ // Returns the resolved address on success; throws with a code that maps
61
+ // to one of the avatar-result reason codes on failure.
62
+ function resolveAndCheck(hostname)
63
+ {
64
+ return new Promise((resolve, reject) =>
65
+ {
66
+ // If the hostname is already a literal IP, validate directly.
67
+ if (net.isIP(hostname))
68
+ {
69
+ if (isBlockedIp(hostname))
70
+ return reject(Object.assign(new Error('ssrf_blocked'), { code: 'ssrf_blocked' }));
71
+ return resolve(hostname);
72
+ }
73
+ dns.lookup(hostname, { verbatim: true }, (err, address) =>
74
+ {
75
+ if (err)
76
+ return reject(Object.assign(new Error('dns_failed'), { code: 'download_failed' }));
77
+ if (isBlockedIp(address))
78
+ return reject(Object.assign(new Error('ssrf_blocked'), { code: 'ssrf_blocked' }));
79
+ resolve(address);
80
+ });
81
+ });
82
+ }
83
+
84
+ // Default fetcher --------------------------------------------------
85
+ // Streams the response body, hashing as it goes and aborting the
86
+ // connection as soon as `maxBytes` would be exceeded so that a hostile
87
+ // origin cannot trickle gigabytes. Honours `maxRedirects` and re-runs
88
+ // the SSRF check at every hop. Returns { ok, status, body, sha256,
89
+ // finalUrl, reason } where `body` is a Buffer trimmed to maxBytes.
90
+ function defaultFetcher(opts)
91
+ {
92
+ const { url, maxBytes, timeoutMs, maxRedirects, allowedSchemes } = opts;
93
+ // Tests can inject a permissive resolver so the loopback-bound
94
+ // scratch server they spin up isn't rejected by the SSRF guard.
95
+ const resolver = opts.resolver || resolveAndCheck;
96
+ return new Promise((resolve) =>
97
+ {
98
+ const visited = new Set();
99
+ const start = Date.now();
100
+
101
+ const attempt = (currentUrl, hopsLeft) =>
102
+ {
103
+ let u;
104
+ try { u = new URL(currentUrl); }
105
+ catch (e) { return resolve({ ok: false, reason: 'invalid_url' }); }
106
+
107
+ if (!allowedSchemes.includes(u.protocol))
108
+ return resolve({ ok: false, reason: 'scheme_not_allowed' });
109
+ if (visited.has(u.href))
110
+ return resolve({ ok: false, reason: 'redirect_loop' });
111
+ visited.add(u.href);
112
+
113
+ resolver(u.hostname).then((ip) =>
114
+ {
115
+ const lib = u.protocol === 'http:' ? http : https;
116
+ const port = u.port ? Number(u.port) : (u.protocol === 'http:' ? 80 : 443);
117
+ const remaining = Math.max(1, timeoutMs - (Date.now() - start));
118
+ const req = lib.request({
119
+ host: ip,
120
+ port: port,
121
+ path: u.pathname + u.search,
122
+ method: 'GET',
123
+ servername: u.hostname, // SNI/SAN check still uses real host
124
+ headers: { host: u.hostname, 'user-agent': 'teleportxr-avatar-validator/1' },
125
+ timeout: remaining,
126
+ }, (res) =>
127
+ {
128
+ // Follow redirects with the same SSRF check at every hop.
129
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location)
130
+ {
131
+ res.resume();
132
+ if (hopsLeft <= 0)
133
+ return resolve({ ok: false, reason: 'too_many_redirects' });
134
+ const next = new URL(res.headers.location, u).href;
135
+ return attempt(next, hopsLeft - 1);
136
+ }
137
+ if (res.statusCode !== 200)
138
+ {
139
+ res.resume();
140
+ return resolve({ ok: false, reason: 'http_' + res.statusCode });
141
+ }
142
+ const hash = crypto.createHash('sha256');
143
+ const chunks = [];
144
+ let total = 0;
145
+ let aborted = false;
146
+ res.on('data', (chunk) =>
147
+ {
148
+ if (aborted) return;
149
+ total += chunk.length;
150
+ if (total > maxBytes)
151
+ {
152
+ aborted = true;
153
+ req.destroy();
154
+ return resolve({ ok: false, reason: 'file_too_large' });
155
+ }
156
+ hash.update(chunk);
157
+ chunks.push(chunk);
158
+ });
159
+ res.on('end', () =>
160
+ {
161
+ if (aborted) return;
162
+ resolve({
163
+ ok: true,
164
+ status: 200,
165
+ body: Buffer.concat(chunks, total),
166
+ sha256: hash.digest('hex'),
167
+ finalUrl: u.href,
168
+ });
169
+ });
170
+ res.on('error', () =>
171
+ {
172
+ if (!aborted) resolve({ ok: false, reason: 'download_failed' });
173
+ });
174
+ });
175
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, reason: 'fetch_timeout' }); });
176
+ req.on('error', () => resolve({ ok: false, reason: 'download_failed' }));
177
+ req.end();
178
+ }).catch((err) =>
179
+ {
180
+ resolve({ ok: false, reason: err.code || 'download_failed' });
181
+ });
182
+ };
183
+
184
+ attempt(url, maxRedirects);
185
+ });
186
+ }
187
+
188
+ // LRU cache --------------------------------------------------------
189
+ // Bounded by both entry count and total bytes. Maps content_hash → a
190
+ // frozen validation result so two clients submitting the same URL do
191
+ // not cause two fetches (plan §5 V5, §9). Map preserves insertion order
192
+ // so we can evict the oldest by simply re-inserting on access.
193
+ class LruCache
194
+ {
195
+ constructor(maxEntries, maxBytes)
196
+ {
197
+ this.maxEntries = Math.max(1, maxEntries | 0);
198
+ this.maxBytes = Math.max(0, maxBytes | 0);
199
+ this.map = new Map();
200
+ this.bytes = 0;
201
+ }
202
+ get(key)
203
+ {
204
+ if (!this.map.has(key)) return undefined;
205
+ const v = this.map.get(key);
206
+ this.map.delete(key); this.map.set(key, v);
207
+ return v;
208
+ }
209
+ set(key, value, bytes)
210
+ {
211
+ if (this.map.has(key))
212
+ {
213
+ this.bytes -= this.map.get(key)._bytes || 0;
214
+ this.map.delete(key);
215
+ }
216
+ const stored = Object.assign({}, value, { _bytes: bytes });
217
+ this.map.set(key, stored);
218
+ this.bytes += bytes;
219
+ while (this.map.size > this.maxEntries || this.bytes > this.maxBytes)
220
+ {
221
+ const first = this.map.keys().next().value;
222
+ if (first === undefined) break;
223
+ this.bytes -= this.map.get(first)._bytes || 0;
224
+ this.map.delete(first);
225
+ }
226
+ }
227
+ get size() { return this.map.size; }
228
+ }
229
+
230
+ // Sniff the leading bytes to confirm the declared format. Phase 3
231
+ // supports GLB (binary glTF) by magic + glTF (JSON) by leading brace.
232
+ // Anything else is passed through as ok=true; the host-supplied
233
+ // validator can do stricter checking. Returns the canonical short name.
234
+ function sniffFormat(buf)
235
+ {
236
+ if (!buf || buf.length < 4) return '';
237
+ if (buf[0] === 0x67 && buf[1] === 0x6C && buf[2] === 0x54 && buf[3] === 0x46)
238
+ return 'glb';
239
+ // Skip BOM, whitespace.
240
+ let i = 0;
241
+ if (buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) i = 3;
242
+ while (i < buf.length && (buf[i] === 0x20 || buf[i] === 0x09 || buf[i] === 0x0A || buf[i] === 0x0D)) i++;
243
+ if (i < buf.length && buf[i] === 0x7B) return 'gltf';
244
+ return '';
245
+ }
246
+
247
+ // IAvatarValidator -------------------------------------------------
248
+ // Reference interface; subclass and override `validate`. The default
249
+ // implementation below is sufficient for the Phase 3 exit criteria;
250
+ // host applications that want a stricter pipeline (e.g. real glTF
251
+ // parse, triangle count, proof check) supply their own.
252
+ class IAvatarValidator
253
+ {
254
+ async validate(/* offer, requirements */)
255
+ {
256
+ throw new Error('IAvatarValidator.validate is abstract');
257
+ }
258
+ }
259
+
260
+ class DefaultAvatarValidator extends IAvatarValidator
261
+ {
262
+ constructor(opts = {})
263
+ {
264
+ super();
265
+ this.allowedSchemes = opts.allowedSchemes || ['https:'];
266
+ this.maxRedirects = opts.maxRedirects ?? 3;
267
+ this.defaultMaxBytes = opts.defaultMaxBytes || 10 * 1024 * 1024;
268
+ this.defaultTimeoutMs = opts.defaultTimeoutMs || 15000;
269
+ this.fetcher = opts.fetcher || defaultFetcher;
270
+ this.cache = opts.cache || new LruCache(opts.cacheEntries || 256, opts.cacheBytes || 256 * 1024 * 1024);
271
+ }
272
+
273
+ async validate(offer, requirements)
274
+ {
275
+ const reasons = [];
276
+ const req = requirements || {};
277
+ const formats = Array.isArray(req.formats) ? req.formats.map(s => String(s).toLowerCase()) : [];
278
+ const maxBytes = Number.isFinite(req.max_file_bytes) ? Math.min(req.max_file_bytes, this.defaultMaxBytes) : this.defaultMaxBytes;
279
+ const timeoutMs = Number.isFinite(req.fetch_timeout_ms) ? req.fetch_timeout_ms : this.defaultTimeoutMs;
280
+
281
+ // Cheap pre-flight checks before spending a TCP connection.
282
+ if (!offer || !offer.url || typeof offer.url !== 'string')
283
+ return { ok: false, reasons: ['no_url'], bytes: 0, contentHash: '', format: '' };
284
+ const declared = offer.declared || {};
285
+ if (formats.length && declared.format && !formats.includes(String(declared.format).toLowerCase()))
286
+ return { ok: false, reasons: ['format_not_allowed'], bytes: 0, contentHash: '', format: declared.format };
287
+ if (declared.file_bytes && declared.file_bytes > maxBytes)
288
+ return { ok: false, reasons: ['file_too_large'], bytes: declared.file_bytes, contentHash: '', format: declared.format || '' };
289
+
290
+ // Cache hit on declared content_hash short-circuits the fetch.
291
+ if (offer.content_hash && this.cache.get(offer.content_hash))
292
+ {
293
+ const cached = this.cache.get(offer.content_hash);
294
+ return { ok: cached.ok, reasons: cached.reasons.slice(), bytes: cached.bytes, contentHash: offer.content_hash, format: cached.format, fromCache: true };
295
+ }
296
+
297
+ const fetched = await this.fetcher({
298
+ url: offer.url,
299
+ maxBytes: maxBytes,
300
+ timeoutMs: timeoutMs,
301
+ maxRedirects: this.maxRedirects,
302
+ allowedSchemes: this.allowedSchemes,
303
+ });
304
+ if (!fetched.ok)
305
+ return { ok: false, reasons: [fetched.reason || 'download_failed'], bytes: 0, contentHash: '', format: '' };
306
+
307
+ const bytes = fetched.body.length;
308
+ const contentHash = 'sha256:' + fetched.sha256;
309
+ const sniffed = sniffFormat(fetched.body);
310
+ const format = sniffed || (declared.format || '').toLowerCase();
311
+
312
+ if (offer.content_hash && offer.content_hash !== contentHash)
313
+ reasons.push('hash_mismatch');
314
+ if (formats.length && format && !formats.includes(format))
315
+ reasons.push('format_not_allowed');
316
+ if (bytes > maxBytes)
317
+ reasons.push('file_too_large');
318
+
319
+ const ok = reasons.length === 0;
320
+ const result = { ok, reasons: reasons.slice(), bytes, contentHash, format };
321
+ this.cache.set(contentHash, result, bytes);
322
+ return result;
323
+ }
324
+ }
325
+
326
+ module.exports.isBlockedIp = isBlockedIp;
327
+ module.exports.resolveAndCheck = resolveAndCheck;
328
+ module.exports.defaultFetcher = defaultFetcher;
329
+ module.exports.LruCache = LruCache;
330
+ module.exports.sniffFormat = sniffFormat;
331
+ module.exports.IAvatarValidator = IAvatarValidator;
332
+ module.exports.DefaultAvatarValidator = DefaultAvatarValidator;
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;
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.88",
19
+ "version": "1.0.90",
20
20
  "repository": {
21
21
  "type": "git",
22
22
  "url": "git+https://github.com/simul/teleport-nodejs.git"
@@ -32,7 +32,10 @@
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",
36
+ "./client/avatar_validator": "./client/avatar_validator.js",
35
37
  "./connections/webrtcconnectionmanager": "./connections/webrtcconnectionmanager.js",
38
+ "./protocol/avatars": "./protocol/avatars.js",
36
39
  "./scene/scene": "./scene/scene.js",
37
40
  "./scene/node": "./scene/node.js",
38
41
  "./scene/resources": "./scene/resources.js"