dsh-cindy-host 0.1.20

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 (82) hide show
  1. package/CHANGELOG.md +462 -0
  2. package/LICENSE +21 -0
  3. package/README.md +178 -0
  4. package/cordis.patch.yml +6 -0
  5. package/doc/architecture.md +34 -0
  6. package/doc/cindy-android-verify.md +362 -0
  7. package/doc/cindy-mobile-freeze-handoff.md +124 -0
  8. package/doc/cindy-phone-link.md +2902 -0
  9. package/doc/desktop-runtime-fix-20260930.md +69 -0
  10. package/doc/development-plan.md +19 -0
  11. package/doc/file-peer-apply-recovery-20260921.md +35 -0
  12. package/doc/file-peer-installed-hotfix-20260921.md +53 -0
  13. package/doc/file-peer-minimal-apply-20260921.md +78 -0
  14. package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
  15. package/doc/file-peer-phone-export-observation-20260921.md +75 -0
  16. package/doc/file-peer-product-contract-audit-20260921.md +106 -0
  17. package/doc/file-peer-root-cause-20260921.md +53 -0
  18. package/doc/file-transfer.md +50 -0
  19. package/doc/host-performance-20260930.md +91 -0
  20. package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
  21. package/doc/new-session-sync-fix-20260930.md +59 -0
  22. package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
  23. package/doc/projection-read-probe.md +77 -0
  24. package/doc/publishing.md +259 -0
  25. package/doc/releasing.md +149 -0
  26. package/doc/research.md +24 -0
  27. package/doc/session-discovery-2026-09-20.md +67 -0
  28. package/lib/client.js +763 -0
  29. package/package.json +84 -0
  30. package/src/auth-session.js +189 -0
  31. package/src/authorization-policy.js +59 -0
  32. package/src/cindy-channels.js +1707 -0
  33. package/src/cindy-login-flow.js +159 -0
  34. package/src/cindy-login.js +80 -0
  35. package/src/cindy-message-row.js +432 -0
  36. package/src/cindy-session-row.js +171 -0
  37. package/src/console-sink.js +6 -0
  38. package/src/contracts.js +26 -0
  39. package/src/credential-store.js +128 -0
  40. package/src/demo.js +9 -0
  41. package/src/dsh-active-sessions.js +59 -0
  42. package/src/dsh-host-source.js +62 -0
  43. package/src/dsh-message-fold.js +608 -0
  44. package/src/dsh-plugin.js +1932 -0
  45. package/src/dsh-session-source.js +529 -0
  46. package/src/file-peer-protocol.js +7 -0
  47. package/src/file-peer-rtc.js +41 -0
  48. package/src/fixture-source.js +13 -0
  49. package/src/host-approvals.js +329 -0
  50. package/src/host-attachments.js +249 -0
  51. package/src/host-authorization.js +51 -0
  52. package/src/host-demo.js +3 -0
  53. package/src/host-export-jobs.js +356 -0
  54. package/src/host-export-owner.js +62 -0
  55. package/src/host-file-browser.js +400 -0
  56. package/src/host-file-ice.js +63 -0
  57. package/src/host-file-peer.js +289 -0
  58. package/src/host-file-reference.js +16 -0
  59. package/src/host-files.js +148 -0
  60. package/src/host-frame-budget.js +310 -0
  61. package/src/host-goals.js +284 -0
  62. package/src/host-history-view-limits.js +22 -0
  63. package/src/host-history-view.js +417 -0
  64. package/src/host-input-queue.js +505 -0
  65. package/src/host-media-fetch.js +511 -0
  66. package/src/host-media.js +678 -0
  67. package/src/host-models.js +231 -0
  68. package/src/host-palette.js +111 -0
  69. package/src/host-push-channels.js +37 -0
  70. package/src/host-reconnect.js +54 -0
  71. package/src/host-routes.js +266 -0
  72. package/src/host-settings.js +41 -0
  73. package/src/host-status.js +216 -0
  74. package/src/host-transfer-diagnostics.js +50 -0
  75. package/src/host.js +2497 -0
  76. package/src/login.js +31 -0
  77. package/src/logout.js +3 -0
  78. package/src/projection-read-model-sink.js +6 -0
  79. package/src/projection.js +43 -0
  80. package/src/session-flags.js +160 -0
  81. package/src/session-publisher.js +123 -0
  82. package/src/session-read-model.js +6 -0
@@ -0,0 +1,400 @@
1
+ /**
2
+ * The aggregated remote file browser (`file-browser:remote-op`).
3
+ *
4
+ * The controller's file screen does not use the `fs:*` / `text-file:*` reads this
5
+ * Host already serves; it calls **one** channel and dispatches on `op`
6
+ * (`apps/desktop/src/main/file-browser/device-op.ts` on the reference host, whose
7
+ * shapes the controller documents as field-for-field). Two consequences shape
8
+ * this module:
9
+ *
10
+ * - every op addresses a path as `workdir` + `relPath`, not an absolute path, so
11
+ * the work directory is the security boundary and **every** join must be
12
+ * proved to stay inside it;
13
+ * - several ops answer with their own `ok` discriminant rather than as invoke
14
+ * failures, because the controller renders those reasons (`OVERSIZE`,
15
+ * `BINARY_FILE`, `READ_FAILED`, `THUMB_UNSUPPORTED`). An op this Host cannot
16
+ * serve at all answers `{ ok: false, message }`, which is exactly what the
17
+ * reference host returns for an op it does not know.
18
+ */
19
+
20
+ /** Longest file this Host will inline for `readFile`. */
21
+ export const READ_LIMIT_BYTES = 1024 * 1024;
22
+
23
+ /** Longest `listAllFiles` walk, in entries. */
24
+ export const LIST_ALL_LIMIT = 5000;
25
+
26
+ /**
27
+ * Search caps, matching the reference host's own collection bound
28
+ * (`SEARCH_COLLECT_MAX_MATCHES` in device-op.ts) so a controller sees the same
29
+ * ceiling from either host.
30
+ */
31
+ export const SEARCH_MATCH_LIMIT = 500;
32
+
33
+ /** Files one search will open before it stops, and the largest file it will read. */
34
+ export const SEARCH_FILE_LIMIT = 2000;
35
+ export const SEARCH_FILE_BYTES = 512 * 1024;
36
+
37
+ /**
38
+ * How far past the collection cap a search keeps counting.
39
+ *
40
+ * The controller renders `totalMatches`, so the count has to be the real one, not
41
+ * "how many I kept" — but a pathological file cannot be allowed to stall the
42
+ * relay, so counting stops at this multiple of the cap.
43
+ */
44
+ export const SEARCH_MATCH_SCAN_FACTOR = 10;
45
+
46
+ /**
47
+ * How many files a search reads at once, and how long it may take in total.
48
+ *
49
+ * Every read is a round trip through the filesystem service (resolve, stat, read),
50
+ * so a serial walk of a real repository measured **6.4 s** for 117 files — long
51
+ * enough that a phone gives up on the spinner. Reading a few at a time keeps the
52
+ * result order while cutting the wall clock, and the time budget is the honest
53
+ * bound the reference host also has (`SEARCH_COLLECT_TIMEOUT_MS`, 20 s there).
54
+ */
55
+ export const SEARCH_CONCURRENCY = 8;
56
+ export const SEARCH_TIME_BUDGET_MS = 8000;
57
+
58
+ /** Longest line kept for one match; the controller renders a single row per hit. */
59
+ export const SEARCH_LINE_CHARS = 400;
60
+
61
+ /**
62
+ * Directory names a search walks past.
63
+ *
64
+ * The reference host searches through ripgrep, which honours `.gitignore` — so it
65
+ * skips a project's `node_modules` and build output without being told. This Host
66
+ * has no gitignore engine, and walking those trees would turn a search of a large
67
+ * repository into a multi-second scan whose results the reference host would not
68
+ * have returned either. The skip is therefore an explicit, documented difference
69
+ * rather than a silent one.
70
+ */
71
+ const SEARCH_SKIP_DIRS = new Set(['.git', 'node_modules', '.sandbox', 'dist', 'build', '.next', 'target']);
72
+
73
+ /** Whether a workdir-relative path lies under a skipped directory. */
74
+ function isSkippedPath(relPath) {
75
+ return String(relPath).split(/[\\/]+/).some((segment) => SEARCH_SKIP_DIRS.has(segment));
76
+ }
77
+
78
+ /**
79
+ * Compile the controller's query.
80
+ *
81
+ * The reference host searches with ripgrep, so the query is a **regular
82
+ * expression**; a query that cannot compile is treated as a literal instead of
83
+ * failing the search, because a user searching for `foo(` means that text.
84
+ * @param query - the controller's query.
85
+ * @param caseSensitive - whether case matters.
86
+ * @returns a pattern with `test`.
87
+ */
88
+ export function compileSearchQuery(query, caseSensitive) {
89
+ try {
90
+ return new RegExp(query, caseSensitive === true ? '' : 'i');
91
+ } catch {
92
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
93
+ return new RegExp(escaped, caseSensitive === true ? '' : 'i');
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Join a work directory and a controller-supplied relative path, refusing to
99
+ * leave the work directory.
100
+ *
101
+ * `relPath` is attacker-adjacent input: it arrives over the relay and the whole
102
+ * point of the workdir guard is that a controller cannot read outside the
103
+ * session's directory. Traversal is therefore resolved **segment by segment** and
104
+ * a `..` that would climb above the root is refused outright rather than
105
+ * normalised away, so a caller cannot reach a path the guard never saw.
106
+ *
107
+ * @param workdir - the session's work directory.
108
+ * @param relPath - the controller's path, relative to it (`''` is the root).
109
+ * @returns the absolute path, or null when the path would escape.
110
+ */
111
+ export function resolveInside(workdir, relPath) {
112
+ if (typeof workdir !== 'string' || workdir.trim() === '') return null;
113
+ const base = workdir.replace(/[\\/]+$/, '');
114
+ const segments = [];
115
+ for (const part of String(relPath ?? '').split(/[\\/]+/)) {
116
+ if (part === '' || part === '.') continue;
117
+ if (part === '..') {
118
+ // Above the root is outside the work directory, whatever the filesystem
119
+ // would have resolved it to.
120
+ if (segments.length === 0) return null;
121
+ segments.pop();
122
+ continue;
123
+ }
124
+ segments.push(part);
125
+ }
126
+ const separator = base.includes('\\') ? '\\' : '/';
127
+ return segments.length === 0 ? base : `${base}${separator}${segments.join(separator)}`;
128
+ }
129
+
130
+ /** The workdir-relative form of one entry, in the controller's separator. */
131
+ function relOf(baseRel, name) {
132
+ return baseRel === '' ? name : `${baseRel}/${name}`;
133
+ }
134
+
135
+ /**
136
+ * A byte buffer looks binary when it carries a NUL in its first block.
137
+ *
138
+ * The reference host answers `BINARY_FILE` rather than sending bytes the
139
+ * controller would render as mojibake, and this is the same cheap test.
140
+ */
141
+ function looksBinary(text) {
142
+ return text.includes('\u0000');
143
+ }
144
+
145
+ /**
146
+ * Build the remote-op handler over one filesystem service.
147
+ * @param options - the filesystem service, plus injectable limits.
148
+ * @returns `(args) => Promise<unknown>`, one result per op.
149
+ */
150
+ export function createFileBrowser({ fileSystem, readLimitBytes = READ_LIMIT_BYTES, listAllLimit = LIST_ALL_LIMIT }) {
151
+ if (fileSystem === undefined || fileSystem === null) throw new Error('createFileBrowser requires a filesystem service');
152
+
153
+ /** Resolve a workdir-relative path, or null when it escapes or cannot resolve. */
154
+ async function open(workdir, relPath) {
155
+ const absolute = resolveInside(workdir, relPath);
156
+ if (absolute === null) return null;
157
+ try {
158
+ const target = await fileSystem.resolve(absolute);
159
+ return { absolute, target };
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * `listDir`, with the resolved handle kept.
167
+ *
168
+ * The handle is what saves a round trip: a file read needs `stat` + `read`, and
169
+ * re-resolving every path by hand adds a third filesystem call per file. The
170
+ * handle never leaves this module — `listDir` strips it, because it is a
171
+ * `FsTarget` and the controller is not owed a filesystem identity.
172
+ */
173
+ async function listDirRaw(workdir, relPath) {
174
+ const dirRel = String(relPath ?? '').replace(/^[\\/]+/, '');
175
+ const opened = await open(workdir, dirRel);
176
+ if (opened === null) return null;
177
+ let raw;
178
+ try {
179
+ raw = await fileSystem.listDir(opened.target);
180
+ } catch {
181
+ return null;
182
+ }
183
+ const entries = [];
184
+ for (const entry of Array.isArray(raw) ? raw : []) {
185
+ if (entry === null || typeof entry !== 'object') continue;
186
+ const name = String(entry.name ?? '');
187
+ if (name === '') continue;
188
+ const type = entry.type === 'directory' ? 'directory' : 'file';
189
+ // `FsDirEntry` carries the size itself, so no per-entry stat is needed.
190
+ let size = Number.isFinite(entry.size) ? entry.size : 0;
191
+ if (type === 'file' && !Number.isFinite(entry.size)) {
192
+ try {
193
+ const info = await fileSystem.stat(entry.target);
194
+ if (Number.isFinite(info?.size)) size = info.size;
195
+ } catch {
196
+ // An entry that cannot be sized is still an entry the user can open.
197
+ }
198
+ }
199
+ entries.push({
200
+ relPath: relOf(dirRel.replace(/^\/+/, ''), name),
201
+ type,
202
+ name,
203
+ size,
204
+ // See below: DSH's filesystem reports no modification time, so this is 0 —
205
+ // "unknown", which is also what the controller's normaliser defaults to.
206
+ mtimeMs: 0,
207
+ target: entry.target,
208
+ });
209
+ }
210
+ return entries;
211
+ }
212
+
213
+ /** `listDir` — a bare entries array, which is what the controller normalises. */
214
+ async function listDir(workdir, relPath) {
215
+ const entries = await listDirRaw(workdir, relPath);
216
+ if (entries === null) return null;
217
+ return entries.map(({ target, ...entry }) => entry);
218
+ }
219
+
220
+ /** `readFile` — text only, with the reference host's own refusal codes. */
221
+ async function readFile(workdir, relPath) {
222
+ const opened = await open(workdir, relPath);
223
+ if (opened === null) return { ok: false, code: 'READ_FAILED', message: 'path does not resolve inside the work directory' };
224
+ let info;
225
+ try {
226
+ info = await fileSystem.stat(opened.target);
227
+ } catch {
228
+ info = undefined;
229
+ }
230
+ const size = Number.isFinite(info?.size) ? info.size : 0;
231
+ // See `listDir`: the filesystem reports no modification time.
232
+ const mtimeMs = 0;
233
+ if (info?.type === 'directory') return { ok: false, code: 'READ_FAILED', message: 'path is a directory' };
234
+ if (size > readLimitBytes) {
235
+ return { ok: false, code: 'OVERSIZE', message: 'file is larger than this Host will inline', stat: { relPath, type: 'file', size, mtimeMs } };
236
+ }
237
+ let bytes;
238
+ try {
239
+ bytes = await fileSystem.readByteRange(opened.target, { offset: 0, length: readLimitBytes });
240
+ } catch (error) {
241
+ return { ok: false, code: 'READ_FAILED', message: String(error?.message ?? error) };
242
+ }
243
+ const text = Buffer.from(bytes).toString('utf8');
244
+ if (looksBinary(text)) return { ok: false, code: 'BINARY_FILE', message: 'file is not text' };
245
+ return {
246
+ ok: true,
247
+ data: { relPath, content: text, size, mtimeMs, ...(size > bytes.length ? { truncated: true } : {}) },
248
+ };
249
+ }
250
+
251
+ /** `listAllFiles` — a bounded walk, so a huge tree cannot stall the relay. */
252
+ async function listAllFiles(workdir, relPath) {
253
+ const startedAt = Date.now();
254
+ const files = [];
255
+ let truncated = false;
256
+ const rootRel = String(relPath ?? '').replace(/^[\\/]+/, '');
257
+ const queue = [rootRel];
258
+ while (queue.length > 0 && !truncated) {
259
+ const current = queue.shift();
260
+ const entries = await listDir(workdir, current);
261
+ if (entries === null) continue;
262
+ for (const entry of entries) {
263
+ if (files.length >= listAllLimit) {
264
+ truncated = true;
265
+ break;
266
+ }
267
+ if (entry.type === 'directory') queue.push(entry.relPath);
268
+ else files.push(entry.relPath);
269
+ }
270
+ }
271
+ return { files, truncated, elapsedMs: Date.now() - startedAt };
272
+ }
273
+
274
+ /**
275
+ * `searchCollect` — a content search over the work directory.
276
+ *
277
+ * The controller renders matches, a truncation flag, and the counts, and asks
278
+ * with `{ query, caseSensitive?, maxMatches? }`. This Host has no ripgrep, so it
279
+ * walks the tree itself with a bounded budget (matches, files opened, bytes per
280
+ * file) instead of pretending to mimic one exactly; what it cannot honour — a
281
+ * huge tree, a file it cannot read — is reported through `truncated` rather than
282
+ * silently narrowing the answer.
283
+ * @param workdir - the session's work directory.
284
+ * @param options - the query and the controller's optional bounds.
285
+ * @returns the collected matches and counts, in the controller's shape.
286
+ */
287
+ async function searchCollect(workdir, options = {}) {
288
+ const query = typeof options.query === 'string' ? options.query : '';
289
+ if (query === '') return { matches: [], truncated: false, totalMatches: 0, totalFiles: 0 };
290
+ const asked = Number.isFinite(options.maxMatches) && options.maxMatches > 0 ? Math.floor(options.maxMatches) : SEARCH_MATCH_LIMIT;
291
+ const limit = Math.min(asked, SEARCH_MATCH_LIMIT);
292
+ const pattern = compileSearchQuery(query, options.caseSensitive === true);
293
+
294
+ const matches = [];
295
+ let totalMatches = 0;
296
+ let totalFiles = 0;
297
+ let truncated = false;
298
+ const startedAt = Date.now();
299
+
300
+ /** Read and scan one already-listed file. */
301
+ async function scanEntry(entry) {
302
+ if (entry.type === 'directory') return;
303
+ if (Number.isFinite(entry.size) && entry.size > SEARCH_FILE_BYTES) {
304
+ truncated = true;
305
+ return;
306
+ }
307
+ let bytes;
308
+ try {
309
+ bytes = await fileSystem.readByteRange(entry.target, { offset: 0, length: SEARCH_FILE_BYTES });
310
+ } catch {
311
+ return;
312
+ }
313
+ const text = Buffer.from(bytes).toString('utf8');
314
+ if (looksBinary(text)) return;
315
+ totalFiles += 1;
316
+ const lines = text.split('\n');
317
+ for (let index = 0; index < lines.length; index += 1) {
318
+ if (!pattern.test(lines[index])) continue;
319
+ totalMatches += 1;
320
+ if (matches.length < limit) {
321
+ matches.push({ relPath: entry.relPath, lineNumber: index + 1, lineText: lines[index].slice(0, SEARCH_LINE_CHARS) });
322
+ } else {
323
+ truncated = true;
324
+ }
325
+ }
326
+ }
327
+
328
+ // The search walks the tree itself instead of listing every file first and then
329
+ // re-resolving each path: the full listing alone measured **6.1 s** on a real
330
+ // repository (it walks up to `LIST_ALL_LIMIT` entries, `node_modules` included),
331
+ // while a directory listing costs ~10 ms. Walking here means the skipped
332
+ // directories are never entered and each file costs one read instead of a
333
+ // resolve plus a read.
334
+ const queue = [''];
335
+ while (queue.length > 0) {
336
+ if (Date.now() - startedAt >= SEARCH_TIME_BUDGET_MS
337
+ || totalFiles >= SEARCH_FILE_LIMIT
338
+ || totalMatches >= limit * SEARCH_MATCH_SCAN_FACTOR) {
339
+ truncated = true;
340
+ break;
341
+ }
342
+ const current = queue.shift();
343
+ const entries = await listDirRaw(workdir, current);
344
+ if (entries === null) continue;
345
+ const wanted = [];
346
+ for (const entry of entries) {
347
+ if (isSkippedPath(entry.relPath)) continue;
348
+ if (entry.type === 'directory') queue.push(entry.relPath);
349
+ else wanted.push(entry);
350
+ }
351
+ for (let index = 0; index < wanted.length; index += SEARCH_CONCURRENCY) {
352
+ await Promise.all(wanted.slice(index, index + SEARCH_CONCURRENCY).map((entry) => scanEntry(entry)));
353
+ }
354
+ }
355
+
356
+ return { matches, truncated, totalMatches, totalFiles };
357
+ }
358
+
359
+ /**
360
+ * Serve one `file-browser:remote-op` call.
361
+ * @param args - `{ op, workdir, relPath? }`.
362
+ * @returns the op's result, never throwing for a path the user cannot reach.
363
+ */
364
+ return async function remoteOp(args) {
365
+ if (args === null || typeof args !== 'object' || typeof args.op !== 'string' || typeof args.workdir !== 'string' || args.workdir === '') {
366
+ return { ok: false, message: 'invalid remote-op args' };
367
+ }
368
+ const { op, workdir } = args;
369
+ const relPath = typeof args.relPath === 'string' ? args.relPath : '';
370
+ // Advertised first because it is the capability probe: no workdir, no fs
371
+ // access — an old host answers `unknown op: caps` and the controller reads
372
+ // that as a definite "no gzip".
373
+ if (op === 'caps') return { ok: true };
374
+ if (op === 'listDir') return listDir(workdir, relPath);
375
+ if (op === 'readFile') return readFile(workdir, relPath);
376
+ if (op === 'listAllFiles') return listAllFiles(workdir, relPath);
377
+ if (op === 'searchCollect') return searchCollect(workdir, args);
378
+ // Ops the controller asks for that this Host cannot serve *for a stated
379
+ // reason* answer in their own documented shape, so the controller degrades
380
+ // deliberately instead of showing a transport-looking failure.
381
+ //
382
+ // - `thumbnail` needs an image pipeline that produces the `image/webp` data
383
+ // URL the controller expects; `THUMB_UNSUPPORTED` is the reference host's
384
+ // own code for "this host cannot make one".
385
+ // - `exportFileStart` hands a file on this machine to the account's staging area
386
+ // so the *controller* can download it — the reverse of the attachment flow this
387
+ // Host does serve (there it fetches what the phone uploaded). Uploading on
388
+ // demand, with the progress polling `exportFileStatus` implies, is a transfer
389
+ // manager rather than a read, and is not served.
390
+ if (op === 'thumbnail') {
391
+ return { ok: false, code: 'THUMB_UNSUPPORTED', message: 'this Host generates no thumbnails' };
392
+ }
393
+ if (op === 'exportFileStart' || op === 'exportFileStatus') {
394
+ return { ok: false, message: 'file export needs the Cindy media pipeline, which this Host does not serve' };
395
+ }
396
+ // The reference host answers an unknown op this way, and so does this one:
397
+ // an op that is not served must not look like an empty result.
398
+ return { ok: false, message: `unknown op: ${op}` };
399
+ };
400
+ }
@@ -0,0 +1,63 @@
1
+ /** Cindy's authenticated, short-lived ICE configuration; no credential cache or logging. */
2
+ const FALLBACK = ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302'];
3
+ const ICE_URL = /^(stun|turn|turns):(?:[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?|\[[0-9a-fA-F:]+\]):([0-9]{1,5})(\?transport=(udp|tcp))?$/;
4
+
5
+ export function parseFileIceConfig(value, now = Date.now()) {
6
+ const fail = () => { throw new Error('INVALID_FILE_ICE_CONFIG'); };
7
+ if (!value || typeof value !== 'object' || !Array.isArray(value.iceServers) || value.iceServers.length > 4) return fail();
8
+ if (!value.iceServers.length && value.expiresAt === null) return [];
9
+ const expires = typeof value.expiresAt === 'string' ? Date.parse(value.expiresAt) : NaN;
10
+ if (!Number.isFinite(expires) || expires <= now + 120_000 || expires > now + 86_400_000) return fail();
11
+ return value.iceServers.map(server => {
12
+ if (!server || typeof server !== 'object' || !Array.isArray(server.urls) || !server.urls.length || server.urls.length > 4) return fail();
13
+ const urls = server.urls.map(url => {
14
+ if (typeof url !== 'string' || url.length > 512) return fail();
15
+ const match = ICE_URL.exec(url);
16
+ if (!match || Number(match[2]) < 1 || Number(match[2]) > 65535 || (match[1] === 'stun' && match[3]) || (match[1] === 'turns' && match[4] !== 'tcp')) return fail();
17
+ try {
18
+ const host = new URL('http://' + url.slice(url.indexOf(':') + 1).split('?')[0]).hostname;
19
+ if (!host.startsWith('[') && host.split('.').some(label => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label))) return fail();
20
+ } catch { return fail(); }
21
+ return url;
22
+ });
23
+ if (!urls.some(url => /^turns?:/.test(url)) || typeof server.username !== 'string' || !server.username.length || server.username.length > 256 || typeof server.credential !== 'string' || !server.credential.length || server.credential.length > 256) return fail();
24
+ return { urls, username: server.username, credential: server.credential };
25
+ });
26
+ }
27
+
28
+ export function createFileIceLoader({ apiBaseUrl, getSession, fetchImpl = fetch, onUnauthorized, timeoutMs = 3000, now = Date.now }) {
29
+ return async function loadIceServers() {
30
+ const abort = new AbortController();
31
+ let timer;
32
+ const fetchConfig = async () => {
33
+ for (let attempt = 0; attempt < 2; attempt++) {
34
+ abort.signal.throwIfAborted();
35
+ const session = getSession();
36
+ if (!session?.accessToken) throw new Error('NO_CREDENTIAL');
37
+ const response = await fetchImpl(`${apiBaseUrl}/ice-servers`, {
38
+ headers: { Authorization: `Bearer ${session.accessToken}`, accept: 'application/json' },
39
+ cache: 'no-store', signal: abort.signal,
40
+ });
41
+ abort.signal.throwIfAborted();
42
+ if (response.status === 401 && attempt === 0 && typeof onUnauthorized === 'function') {
43
+ if (await onUnauthorized()) continue;
44
+ }
45
+ if (!response.ok) throw new Error('ICE_CONFIG_UNAVAILABLE');
46
+ return parseFileIceConfig(await response.json(), now());
47
+ }
48
+ throw new Error('ICE_CONFIG_UNAVAILABLE');
49
+ };
50
+ try {
51
+ const servers = await Promise.race([fetchConfig(), new Promise((_, reject) => {
52
+ timer = setTimeout(() => { abort.abort(); reject(new Error('ICE_CONFIG_TIMEOUT')); }, timeoutMs);
53
+ })]);
54
+ if (servers.length) return servers;
55
+ } catch {
56
+ // Match Cindy's fallback. Never retain/log the response body, credentials or SDP.
57
+ } finally {
58
+ clearTimeout(timer);
59
+ abort.abort();
60
+ }
61
+ return FALLBACK.map(url => ({ urls: [url] }));
62
+ };
63
+ }