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,356 @@
1
+ /** Bounded, in-memory Export jobs. Host owns authorization; uploader owns HTTP/cleanup. */
2
+ import { randomUUID } from 'node:crypto';
3
+ import { constants } from 'node:fs';
4
+ import { lstat, open } from 'node:fs/promises';
5
+ import { extname } from 'node:path';
6
+ import { mimeForMediaPath } from './host-media-fetch.js';
7
+
8
+ const MAX_BYTES = 2 * 1024 * 1024 * 1024;
9
+ const MESSAGES = Object.freeze({
10
+ BAD_REQUEST: 'invalid file export request',
11
+ FORBIDDEN: 'file export is not authorized',
12
+ NOT_FOUND: 'the requested file or transfer was not found',
13
+ OVERSIZE: 'the file exceeds the export limit',
14
+ NOT_AVAILABLE: 'file export is busy or unavailable',
15
+ OWNER_UNVERIFIED: 'the export account is no longer verified',
16
+ CANCELLED: 'file export was cancelled',
17
+ SOURCE_CHANGED: 'the export source changed',
18
+ SOURCE_READ_FAILED: 'the export source could not be read',
19
+ AUTH_FAILED: 'file export authentication failed',
20
+ PRESIGN_FAILED: 'file export staging failed',
21
+ PUT_FAILED: 'file export upload failed',
22
+ UPLOAD_FAILED: 'file export upload failed',
23
+ NETWORK: 'file export network request failed',
24
+ TIMEOUT: 'file export timed out',
25
+ STAGE_TIMEOUT: 'file export staging timed out',
26
+ IDLE_TIMEOUT: 'file export upload stalled',
27
+ NETWORK_ERROR: 'file export network request failed',
28
+ SIZE_MISMATCH: 'the export source changed',
29
+ INCOMPLETE_UPLOAD: 'file export upload was incomplete',
30
+ ABORTED: 'file export was cancelled',
31
+ INVALID_ARGUMENT: 'invalid file export request',
32
+ });
33
+ const STAGES = new Set(['validation', 'open', 'credential', 'presign', 'refresh', 'put', 'verify', 'cleanup', 'close', 'done']);
34
+ const UPLOAD_EVENTS = Object.freeze({
35
+ presign: 'presign', presign_start: 'presign', presign_done: 'presign', presign_error: 'error',
36
+ refresh: 'presign', put: 'put', put_start: 'put', put_done: 'put', put_error: 'error',
37
+ complete: 'put', error: 'error', timeout: 'error', source_changed: 'error',
38
+ cleanup_start: 'cleanup', cleanup_done: 'cleanup', cleanup_error: 'cleanup',
39
+ cleanup_failed: 'cleanup', cleanup_skipped_owner: 'cleanup',
40
+ });
41
+ const safeCode = (code, fallback = 'UPLOAD_FAILED') => typeof code === 'string' && Object.hasOwn(MESSAGES, code) ? code : fallback;
42
+ const failure = (code) => ({ ok: false, code, message: MESSAGES[code] });
43
+ const sourceError = () => Object.assign(new Error(MESSAGES.SOURCE_CHANGED), { code: 'SOURCE_CHANGED' });
44
+ const text = (value, max) => typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
45
+ const regular = (info) => typeof info?.isFile === 'function' && info.isFile();
46
+ const sameSnapshot = (actual, expected) => regular(actual)
47
+ && expected.dev !== undefined && expected.ino !== undefined
48
+ && actual.dev === expected.dev && actual.ino === expected.ino
49
+ && actual.size === expected.size && actual.mtimeMs === expected.mtimeMs;
50
+
51
+ /**
52
+ * Open only the authorized snapshot. O_NONBLOCK prevents a raced FIFO from hanging
53
+ * open on POSIX. Windows lacks O_NOFOLLOW: lstat + fd identity still prevent a
54
+ * substituted target from supplying bytes. No read occurs before fstat.
55
+ */
56
+ async function openSafeSource(real, info, { signal }) {
57
+ let fd;
58
+ try {
59
+ signal.throwIfAborted();
60
+ if (!sameSnapshot(await lstat(real), info)) throw sourceError();
61
+ fd = await open(real, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
62
+ signal.throwIfAborted();
63
+ if (!sameSnapshot(await fd.stat(), info)) throw sourceError();
64
+ let closing;
65
+ return {
66
+ size: info.size,
67
+ async read(buffer, position) {
68
+ signal.throwIfAborted();
69
+ if (!Buffer.isBuffer(buffer) || !Number.isSafeInteger(position) || position < 0 || position > info.size) throw sourceError();
70
+ const length = Math.min(buffer.length, info.size - position);
71
+ return (await fd.read(buffer, 0, length, position)).bytesRead;
72
+ },
73
+ async verify() {
74
+ signal.throwIfAborted();
75
+ if (!sameSnapshot(await fd.stat(), info)) throw sourceError();
76
+ },
77
+ close() { return closing ??= fd.close(); },
78
+ };
79
+ } catch (error) {
80
+ if (fd) await fd.close().catch(() => {});
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * resolveSource(workdir, relPath, {maxBytes, signal}) authorizes a real path + stat.
87
+ * openSource(real, info, {signal}) is a test seam returning size/read/verify/close.
88
+ * upload(source, {owner, signal, ext, contentType, onProgress, onEvent}) must settle
89
+ * only after final source verification and its own bounded cleanup; it must NOT
90
+ * close source (manager owns it). Success may carry an idempotent async discard()
91
+ * for bounded cleanup if cancellation/close failure prevents publishing that key.
92
+ * Owner is an opaque stable object, not credentials. Policy excludes connectivity.
93
+ * start returns one shared Promise per owner/src/RPC id; status is synchronous.
94
+ * Cancellation methods abort synchronously. invalidateOwner/stop also return
95
+ * settlement Promises; callers need not wait before rotating credentials/policy.
96
+ */
97
+ export function createExportJobs({
98
+ resolveSource, upload, captureOwner, isOwnerCurrent, isControllerAllowed,
99
+ now = Date.now, emit, identify, maxActive = 2, maxJobs = 64, ttlMs = 600000,
100
+ openSource = openSafeSource,
101
+ }) {
102
+ for (const fn of [resolveSource, upload, captureOwner, isOwnerCurrent, isControllerAllowed, now, openSource]) {
103
+ if (typeof fn !== 'function') throw new TypeError('invalid export dependency');
104
+ }
105
+ if (!Number.isSafeInteger(maxActive) || maxActive < 1 || maxActive > 2
106
+ || !Number.isSafeInteger(maxJobs) || maxJobs < 1 || maxJobs > 64
107
+ || !Number.isSafeInteger(ttlMs) || ttlMs < 0) throw new TypeError('invalid export limits');
108
+ const jobs = new Map();
109
+ let active = 0;
110
+ let stopped = false;
111
+ let invalidOwner;
112
+ const time = () => Number(now());
113
+ const allowed = (src) => { try { return isControllerAllowed(src) === true; } catch { return false; } };
114
+ const current = (owner) => { try { return owner !== invalidOwner && isOwnerCurrent(owner) === true; } catch { return false; } };
115
+ const ownerNow = () => { try { const owner = captureOwner(); return owner && typeof owner === 'object' ? owner : null; } catch { return null; } };
116
+
117
+ const tags = (facts) => {
118
+ try {
119
+ const value = identify?.(facts);
120
+ if (value?.then) { void Promise.resolve(value).catch(() => {}); return {}; }
121
+ return Object.fromEntries(['actorId', 'ownerId', 'sourceId']
122
+ .filter(key => typeof value?.[key] === 'string' && /^[a-f0-9]{64}$/.test(value[key]))
123
+ .map(key => [key, value[key]]));
124
+ } catch { return {}; }
125
+ };
126
+ // Never forward free-form producer fields, paths, owners, credentials or keys.
127
+ function event(job, name, stage, code) {
128
+ try {
129
+ if (!current(job.owner)) return;
130
+ job.tags ??= tags({ actor: job.src, owner: job.owner });
131
+ Promise.resolve(emit?.({ ...job.tags, transport: 'oss', event: name, transferId: job.id,
132
+ stage: STAGES.has(stage) ? stage : 'put', at: time(), size: job.size, uploaded: job.uploaded,
133
+ ...(code ? { code: safeCode(code) } : {}),
134
+ })).catch(() => {});
135
+ } catch { /* Diagnostics must never change transfer outcomes. */ }
136
+ }
137
+ function finishError(job, code) {
138
+ if (job.state !== 'uploading') return;
139
+ job.state = 'error';
140
+ job.code = safeCode(code);
141
+ job.terminalAt = time();
142
+ delete job.key;
143
+ event(job, 'error', job.stage, job.code);
144
+ // Terminalize before synchronous abort listeners can report a late success.
145
+ job.controller.abort();
146
+ if (!job.accepted) job.resolveStart(Object.freeze(failure(job.code)));
147
+ }
148
+ function revoke(job, code) {
149
+ job.revoked = true;
150
+ delete job.key;
151
+ finishError(job, code);
152
+ job.controller.abort();
153
+ }
154
+ function live(job) {
155
+ if (job.state !== 'uploading' || job.revoked) return false;
156
+ if (stopped) { revoke(job, 'CANCELLED'); return false; }
157
+ if (!current(job.owner)) { revoke(job, 'OWNER_UNVERIFIED'); return false; }
158
+ if (!allowed(job.src)) { revoke(job, 'FORBIDDEN'); return false; }
159
+ return true;
160
+ }
161
+ function prune() {
162
+ const at = time();
163
+ for (const [id, job] of jobs) {
164
+ // Terminal-but-cleaning jobs still own BOTH quotas, even beyond their TTL.
165
+ if (!job.active && job.state !== 'uploading' && at - job.terminalAt >= ttlMs) jobs.delete(id);
166
+ }
167
+ }
168
+ function pruneUnauthorized() {
169
+ for (const job of jobs.values()) {
170
+ if (!current(job.owner)) revoke(job, 'OWNER_UNVERIFIED');
171
+ else if (!allowed(job.src)) revoke(job, 'FORBIDDEN');
172
+ }
173
+ prune();
174
+ }
175
+
176
+ async function run(job) {
177
+ let source;
178
+ let result;
179
+ try {
180
+ if (!live(job)) return;
181
+ const resolved = await resolveSource(job.args.workdir, job.args.relPath, {
182
+ maxBytes: job.limit, signal: job.controller.signal,
183
+ });
184
+ if (!live(job)) return;
185
+ if (resolved?.ok !== true) { finishError(job, safeCode(resolved?.code, 'SOURCE_READ_FAILED')); return; }
186
+ const info = resolved.info;
187
+ if (!text(resolved.real, 32768) || !regular(info) || !Number.isSafeInteger(info.size) || info.size < 0
188
+ || !Number.isFinite(info.mtimeMs)) { finishError(job, 'BAD_REQUEST'); return; }
189
+ if (info.size > job.limit) { finishError(job, 'OVERSIZE'); return; }
190
+ job.tags = tags({ actor: job.src, owner: job.owner, real: resolved.real, info });
191
+ job.size = info.size;
192
+ job.stage = 'open';
193
+ event(job, 'opening', 'open');
194
+ if (!live(job)) return;
195
+ // Assign before checking cancellation: a late open is still our descriptor.
196
+ source = await openSource(resolved.real, info, { signal: job.controller.signal });
197
+ if (!live(job)) return;
198
+ if (source?.size !== info.size || typeof source?.read !== 'function'
199
+ || typeof source?.verify !== 'function' || typeof source?.close !== 'function') throw sourceError();
200
+ await source.verify();
201
+ if (!live(job)) return;
202
+ const checkedSource = {
203
+ size: source.size,
204
+ async read(buffer, position) {
205
+ try {
206
+ if (!live(job)) throw sourceError();
207
+ if (!Buffer.isBuffer(buffer) || !Number.isSafeInteger(position) || position < 0 || position > job.size) throw sourceError();
208
+ const bytes = await source.read(buffer, position);
209
+ if (!live(job)) throw sourceError();
210
+ if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > buffer.length
211
+ || position + bytes > job.size || (bytes === 0 && position < job.size)) throw sourceError();
212
+ return bytes;
213
+ } catch (error) {
214
+ finishError(job, safeCode(error?.code, 'SOURCE_READ_FAILED'));
215
+ throw Object.assign(new Error(MESSAGES[job.code] ?? MESSAGES.SOURCE_READ_FAILED), { code: job.code ?? 'SOURCE_READ_FAILED' });
216
+ }
217
+ },
218
+ async verify() {
219
+ try {
220
+ if (!live(job)) throw sourceError();
221
+ await source.verify();
222
+ if (!live(job)) throw sourceError();
223
+ } catch (error) {
224
+ finishError(job, safeCode(error?.code, 'SOURCE_CHANGED'));
225
+ throw Object.assign(new Error(MESSAGES[job.code] ?? MESSAGES.SOURCE_CHANGED), { code: job.code ?? 'SOURCE_CHANGED' });
226
+ }
227
+ },
228
+ // Only finally closes the real source, including badly behaved uploaders.
229
+ close: async () => {},
230
+ };
231
+ job.accepted = true;
232
+ job.stage = 'put';
233
+ job.resolveStart(Object.freeze({ ok: true, transferId: job.id, size: info.size, mtimeMs: info.mtimeMs }));
234
+ event(job, 'accepted', 'put');
235
+ if (!live(job)) return;
236
+ const extension = extname(resolved.real).slice(1);
237
+ result = await upload(checkedSource, {
238
+ owner: job.owner, signal: job.controller.signal,
239
+ ext: /^[a-zA-Z0-9]{1,32}$/.test(extension) ? extension : 'bin',
240
+ contentType: mimeForMediaPath(resolved.real),
241
+ onProgress(value) {
242
+ if (!live(job)) return;
243
+ const bytes = typeof value === 'number' ? value : value?.uploaded;
244
+ if (Number.isSafeInteger(bytes) && bytes >= 0 && bytes > job.uploaded) {
245
+ job.uploaded = Math.min(job.size, bytes);
246
+ event(job, 'progress', 'put');
247
+ }
248
+ },
249
+ onEvent(value) {
250
+ try {
251
+ const type = value?.type ?? value?.event;
252
+ if (typeof type === 'string' && Object.hasOwn(UPLOAD_EVENTS, type)) {
253
+ const stage = type.startsWith('cleanup') ? 'cleanup' : value.stage === 'validate' ? 'validation' : value.stage;
254
+ event(job, UPLOAD_EVENTS[type], stage, value.code);
255
+ }
256
+ } catch { /* Even malformed producer events are only diagnostics. */ }
257
+ },
258
+ });
259
+ if (!live(job)) return;
260
+ if (result?.ok !== true) { finishError(job, safeCode(result?.code)); return; }
261
+ if (!text(result.key, 4096) || result.size !== job.size
262
+ || typeof result.sha256 !== 'string' || !/^[a-fA-F0-9]{64}$/.test(result.sha256)) {
263
+ finishError(job, 'UPLOAD_FAILED'); return;
264
+ }
265
+ // Final verification belongs to upload so every verification failure can
266
+ // clean its unpublished object before returning. Do not verify again here.
267
+ } catch (error) {
268
+ finishError(job, safeCode(error?.code, job.accepted ? 'UPLOAD_FAILED' : 'SOURCE_READ_FAILED'));
269
+ } finally {
270
+ job.stage = 'close';
271
+ if (source) {
272
+ try { await source.close(); }
273
+ catch { finishError(job, 'SOURCE_READ_FAILED'); }
274
+ }
275
+ if (result?.ok === true && live(job)) {
276
+ job.state = 'done';
277
+ job.key = result.key;
278
+ job.uploaded = job.size;
279
+ job.terminalAt = time();
280
+ event(job, 'done', 'done');
281
+ } else if (result?.ok === true) {
282
+ // Upload may win the race with revoke, or fd.close may fail after PUT.
283
+ // Its captured-owner discard is idempotent/bounded; never borrow credentials.
284
+ job.stage = 'cleanup';
285
+ try { await result.discard?.(); }
286
+ catch { event(job, 'cleanup', 'cleanup', 'UPLOAD_FAILED'); }
287
+ }
288
+ job.active = false;
289
+ active--;
290
+ // Failed admission isn't retained or cached after its resource lifetime.
291
+ if (!job.accepted) jobs.delete(job.id);
292
+ }
293
+ }
294
+
295
+ function start(input, request) {
296
+ prune();
297
+ const validId = text(request?.id, 256) || (Number.isSafeInteger(request?.id) && request.id >= 0);
298
+ if (!text(request?.src, 256) || !validId || !text(input?.workdir, 4096) || !input.workdir.trim()
299
+ || !text(input?.relPath, 4096) || !input.relPath.trim()
300
+ || /^(?:[a-zA-Z]:|[/\\])/.test(input.relPath)
301
+ || (input.maxBytes !== undefined && (!Number.isSafeInteger(input.maxBytes) || input.maxBytes < 0))) {
302
+ return Promise.resolve(failure('BAD_REQUEST'));
303
+ }
304
+ if (stopped) return Promise.resolve(failure('NOT_AVAILABLE'));
305
+ const owner = ownerNow();
306
+ if (!owner || !current(owner)) return Promise.resolve(failure('OWNER_UNVERIFIED'));
307
+ if (!allowed(request.src)) return Promise.resolve(failure('FORBIDDEN'));
308
+ const args = { workdir: input.workdir, relPath: input.relPath, maxBytes: input.maxBytes };
309
+ for (const job of jobs.values()) {
310
+ if (job.owner !== owner || job.src !== request.src || job.rpcId !== request.id) continue;
311
+ if (job.revoked) return Promise.resolve(failure('FORBIDDEN'));
312
+ if (Object.keys(args).some((key) => args[key] !== job.args[key])) return Promise.resolve(failure('BAD_REQUEST'));
313
+ return job.startPromise;
314
+ }
315
+ if (active >= maxActive || jobs.size >= maxJobs) return Promise.resolve(failure('NOT_AVAILABLE'));
316
+ const job = {
317
+ id: 'exp_' + randomUUID(), owner, src: request.src, rpcId: request.id, args,
318
+ limit: Math.min(MAX_BYTES, input.maxBytes ?? MAX_BYTES), controller: new AbortController(),
319
+ state: 'uploading', stage: 'validation', size: 0, uploaded: 0, accepted: false, active: true, revoked: false,
320
+ };
321
+ job.startPromise = new Promise((resolve) => { job.resolveStart = resolve; });
322
+ // Reserve BOTH quotas and dedup before invoking any asynchronous dependency.
323
+ jobs.set(job.id, job);
324
+ active++;
325
+ job.settled = Promise.resolve().then(() => run(job));
326
+ event(job, 'start', 'validation');
327
+ return job.startPromise;
328
+ }
329
+ function status(input, request) {
330
+ pruneUnauthorized();
331
+ const job = text(input?.transferId, 128) ? jobs.get(input.transferId) : undefined;
332
+ if (!job || !job.accepted || job.revoked || !text(request?.src, 256) || job.src !== request.src
333
+ || ownerNow() !== job.owner || !current(job.owner) || !allowed(job.src)) return failure('NOT_FOUND');
334
+ return { ok: true, state: job.state, size: job.size, uploaded: job.uploaded,
335
+ ...(job.state === 'done' ? { key: job.key } : {}),
336
+ ...(job.state === 'error' ? { message: MESSAGES[job.code] } : {}),
337
+ };
338
+ }
339
+ function invalidateOwner() {
340
+ invalidOwner = ownerNow();
341
+ for (const job of jobs.values()) revoke(job, 'OWNER_UNVERIFIED');
342
+ return Promise.all([...jobs.values()].map((job) => job.settled)).then(() => {});
343
+ }
344
+ function stop() {
345
+ stopped = true;
346
+ for (const job of jobs.values()) revoke(job, 'CANCELLED');
347
+ return Promise.all([...jobs.values()].map((job) => job.settled)).then(() => {});
348
+ }
349
+ function getStats() {
350
+ prune();
351
+ const stats = { active, records: jobs.size, uploading: 0, done: 0, error: 0 };
352
+ for (const job of jobs.values()) stats[job.state]++;
353
+ return stats;
354
+ }
355
+ return { start, status, pruneUnauthorized, invalidateOwner, stop, getStats };
356
+ }
@@ -0,0 +1,62 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { refreshOwnedCredential } from './auth-session.js';
3
+
4
+ /** Verified socket identity and private credential leases. Neither is a relay generation. */
5
+ export function createExportOwner({ refresh = refreshOwnedCredential, ports = {}, now = Date.now, cooldownMs = 30_000, onSession = () => {}, onInvalidate = () => {} } = {}) {
6
+ const runtimeId = randomUUID();
7
+ const leases = new WeakMap();
8
+ let epoch = 0;
9
+ let owner = null;
10
+ let credential = null;
11
+ let revision = 0;
12
+ let pending = null;
13
+ let refreshedAt = -Infinity;
14
+ const current = (captured) => captured != null && captured === owner;
15
+ function invalidate() {
16
+ pending?.controller.abort();
17
+ epoch += 1;
18
+ owner = null;
19
+ credential = null;
20
+ revision += 1;
21
+ try { onInvalidate(); } catch { /* diagnostics/consumers must not retain authority */ }
22
+ }
23
+ function install(session) {
24
+ const lease = Object.freeze({});
25
+ const next = Object.freeze({ lease, session: Object.freeze({ ...session }) });
26
+ leases.set(lease, { owner, revision: ++revision });
27
+ credential = next;
28
+ return next;
29
+ }
30
+ function bind({ realm, userId, session }) {
31
+ if (typeof userId !== 'string' || !userId || typeof realm !== 'string' || !realm || !session) { invalidate(); return null; }
32
+ if (owner && (owner.realm !== realm || owner.userId !== userId)) invalidate();
33
+ if (!owner) owner = Object.freeze({ runtimeId, epoch: ++epoch, realm, userId });
34
+ install(session);
35
+ return owner;
36
+ }
37
+ function refreshCredential(captured) {
38
+ const record = captured?.lease && leases.get(captured.lease);
39
+ if (!record || !current(record.owner) || credential !== captured || record.revision !== revision) return Promise.resolve(null);
40
+ if (pending?.captured === captured) return pending.promise;
41
+ // Never join a refresh for another lease, nor launch overlapping token rotations.
42
+ if (pending || Number(now()) - refreshedAt < cooldownMs) return Promise.resolve(null);
43
+ refreshedAt = Number(now());
44
+ const isCurrent = () => current(record.owner) && credential === captured && revision === record.revision;
45
+ const controller = new AbortController();
46
+ const promise = (async () => {
47
+ try {
48
+ const result = await refresh(captured.session, { ...ports, isCurrent, signal: controller.signal });
49
+ if (!result?.ok || !isCurrent()) return null;
50
+ const next = install(result.session);
51
+ onSession(next.session);
52
+ return next;
53
+ } catch { return null; }
54
+ })();
55
+ const flight = { captured, promise, controller };
56
+ pending = flight;
57
+ void promise.finally(() => { if (pending === flight) pending = null; }).catch(() => {});
58
+ return promise;
59
+ }
60
+ return { bind, invalidate, capture: () => owner, isCurrent: current,
61
+ getCredential: (captured) => current(captured) ? credential : null, refreshCredential };
62
+ }