@hakimedes/dsh-easyremote 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/dist/android.js +50 -0
  4. package/dist/android.js.map +1 -0
  5. package/dist/autostart.js +104 -0
  6. package/dist/autostart.js.map +1 -0
  7. package/dist/cli-views.js +32 -0
  8. package/dist/cli-views.js.map +1 -0
  9. package/dist/cli.js +584 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/cloudflared.js +54 -0
  12. package/dist/cloudflared.js.map +1 -0
  13. package/dist/command-router.js +12 -0
  14. package/dist/command-router.js.map +1 -0
  15. package/dist/connector-install.js +42 -0
  16. package/dist/connector-install.js.map +1 -0
  17. package/dist/controller.js +206 -0
  18. package/dist/controller.js.map +1 -0
  19. package/dist/doctor.js +46 -0
  20. package/dist/doctor.js.map +1 -0
  21. package/dist/domain.js +33 -0
  22. package/dist/domain.js.map +1 -0
  23. package/dist/download.js +59 -0
  24. package/dist/download.js.map +1 -0
  25. package/dist/index.js +16 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/install-state.js +48 -0
  28. package/dist/install-state.js.map +1 -0
  29. package/dist/local-runtime.js +115 -0
  30. package/dist/local-runtime.js.map +1 -0
  31. package/dist/maintenance.js +58 -0
  32. package/dist/maintenance.js.map +1 -0
  33. package/dist/named-tunnel.js +49 -0
  34. package/dist/named-tunnel.js.map +1 -0
  35. package/dist/runtime.js +110 -0
  36. package/dist/runtime.js.map +1 -0
  37. package/dist/setup-progress.js +23 -0
  38. package/dist/setup-progress.js.map +1 -0
  39. package/dist/supervisor.js +81 -0
  40. package/dist/supervisor.js.map +1 -0
  41. package/dist/wizard.js +251 -0
  42. package/dist/wizard.js.map +1 -0
  43. package/package.json +56 -0
  44. package/runtime/connector/README.md +52 -0
  45. package/runtime/connector/cordis.patch.yml +3 -0
  46. package/runtime/connector/dsh.plugin.json +16 -0
  47. package/runtime/connector/lib/client.js +262 -0
  48. package/runtime/connector/lib/command-cache.d.ts +7 -0
  49. package/runtime/connector/lib/command-cache.js +37 -0
  50. package/runtime/connector/lib/command-cache.js.map +1 -0
  51. package/runtime/connector/lib/connector-config.d.ts +20 -0
  52. package/runtime/connector/lib/connector-config.js +74 -0
  53. package/runtime/connector/lib/connector-config.js.map +1 -0
  54. package/runtime/connector/lib/dsh-api.d.ts +38 -0
  55. package/runtime/connector/lib/dsh-api.js +76 -0
  56. package/runtime/connector/lib/dsh-api.js.map +1 -0
  57. package/runtime/connector/lib/index.d.ts +4 -0
  58. package/runtime/connector/lib/index.js +913 -0
  59. package/runtime/connector/lib/index.js.map +1 -0
  60. package/runtime/connector/lib/protocol.d.ts +15 -0
  61. package/runtime/connector/lib/protocol.js +96 -0
  62. package/runtime/connector/lib/protocol.js.map +1 -0
  63. package/runtime/connector/package.json +44 -0
  64. package/runtime/hub/database.js +239 -0
  65. package/runtime/hub/database.js.map +1 -0
  66. package/runtime/hub/index.js +1804 -0
  67. package/runtime/hub/index.js.map +1 -0
  68. package/runtime/hub/schema.js +111 -0
  69. package/runtime/hub/schema.js.map +1 -0
@@ -0,0 +1,1804 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { readFileSync, mkdirSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import Fastify from 'fastify';
5
+ import websocket from '@fastify/websocket';
6
+ import { z } from 'zod';
7
+ import { SignJWT, jwtVerify } from 'jose';
8
+ import { v7 as uuidv7 } from 'uuid';
9
+ import { getOrCreateHubId, openDatabase } from './database.js';
10
+ const PORT = Number(process.env.PORT ?? '8787');
11
+ const HOST = process.env.HOST ?? '127.0.0.1';
12
+ const HUB_ENTRY = process.env.HUB_ENTRY ?? 'https://dsh.infomind.cc';
13
+ const HUB_ENTRY_FILE = process.env.HUB_ENTRY_FILE;
14
+ const HUB_VERSION = process.env.DSH_EASYREMOTE_VERSION ?? '0.2.0';
15
+ const ACCESS_TTL_SECONDS = 15 * 60;
16
+ const REFRESH_TTL_MS = 60 * 24 * 60 * 60 * 1000;
17
+ const COMMAND_TTL_MS = 30_000;
18
+ const HEARTBEAT_MS = 15_000;
19
+ const OFFLINE_MS = 45_000;
20
+ const HTTP_BODY_LIMIT = 1_000_000;
21
+ const WS_PAYLOAD_LIMIT = 512 * 1024;
22
+ const SESSION_EVENT_MAX = 2000;
23
+ const SESSION_EVENT_TTL_MS = 10 * 60 * 1000;
24
+ const COMMAND_RETENTION_MS = Number(process.env.COMMAND_RETENTION_MS ?? 7 * 24 * 60 * 60 * 1000);
25
+ const DB_PATH = process.env.DATABASE_PATH || './data/hub.sqlite';
26
+ const jwtSecretValue = process.env.JWT_SECRET || 'replace-me-secret-change-before-prod';
27
+ if (process.env.NODE_ENV === 'production' && (!process.env.JWT_SECRET || jwtSecretValue.length < 32)) {
28
+ throw new Error('JWT_SECRET must be explicitly set to at least 32 characters in production');
29
+ }
30
+ const JWT_SECRET = new TextEncoder().encode(jwtSecretValue);
31
+ const dbFile = resolve(DB_PATH);
32
+ mkdirSync(dirname(dbFile), { recursive: true });
33
+ const sqlite = openDatabase(dbFile);
34
+ const HUB_ID = getOrCreateHubId(sqlite);
35
+ function nowMs() {
36
+ return Date.now();
37
+ }
38
+ function objectValue(value) {
39
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
40
+ ? value
41
+ : null;
42
+ }
43
+ function normalizeModelSelection(value) {
44
+ const selection = objectValue(value);
45
+ if (!selection || typeof selection.provider !== 'string' || typeof selection.model !== 'string')
46
+ return null;
47
+ return {
48
+ provider: selection.provider,
49
+ model: selection.model,
50
+ ...(typeof selection.reasoningEffort === 'string'
51
+ ? { reasoningEffort: selection.reasoningEffort }
52
+ : {}),
53
+ };
54
+ }
55
+ function normalizeSessionModels(value) {
56
+ const result = objectValue(value);
57
+ const current = normalizeModelSelection(result?.current);
58
+ if (!result || !current)
59
+ throw { code: 'INTERNAL_ERROR', message: 'Node returned invalid model metadata' };
60
+ const groups = Array.isArray(result.groups) ? result.groups.flatMap((groupValue) => {
61
+ const group = objectValue(groupValue);
62
+ if (!group || typeof group.id !== 'string' || typeof group.name !== 'string' || !Array.isArray(group.models))
63
+ return [];
64
+ const models = group.models.flatMap((modelValue) => {
65
+ const model = objectValue(modelValue);
66
+ if (!model || typeof model.id !== 'string' || typeof model.name !== 'string')
67
+ return [];
68
+ const reasoningValue = objectValue(model.reasoning);
69
+ const efforts = Array.isArray(reasoningValue?.efforts)
70
+ ? reasoningValue.efforts.flatMap((effortValue) => {
71
+ const effort = objectValue(effortValue);
72
+ if (!effort || typeof effort.id !== 'string' || typeof effort.name !== 'string')
73
+ return [];
74
+ return [{
75
+ id: effort.id,
76
+ name: effort.name,
77
+ ...(typeof effort.description === 'string' ? { description: effort.description } : {}),
78
+ }];
79
+ })
80
+ : [];
81
+ return [{
82
+ id: model.id,
83
+ name: model.name,
84
+ ...(typeof model.description === 'string' ? { description: model.description } : {}),
85
+ ...(reasoningValue ? {
86
+ reasoning: {
87
+ efforts,
88
+ ...(typeof reasoningValue.defaultEffort === 'string'
89
+ ? { defaultEffort: reasoningValue.defaultEffort }
90
+ : {}),
91
+ },
92
+ } : {}),
93
+ }];
94
+ });
95
+ return [{ id: group.id, name: group.name, models }];
96
+ }) : [];
97
+ const failures = Array.isArray(result.failures) ? result.failures.flatMap((failureValue) => {
98
+ const failure = objectValue(failureValue);
99
+ if (!failure || typeof failure.id !== 'string' || typeof failure.name !== 'string' || typeof failure.message !== 'string')
100
+ return [];
101
+ return [{ id: failure.id, name: failure.name, message: failure.message }];
102
+ }) : [];
103
+ return { current, routable: result.routable === true, groups, failures };
104
+ }
105
+ function hashText(value) {
106
+ return createHash('sha256').update(value).digest('hex');
107
+ }
108
+ function randomHex(bytes) {
109
+ return randomBytes(bytes).toString('hex');
110
+ }
111
+ function sleep(ms) {
112
+ return new Promise((resolve) => setTimeout(resolve, ms));
113
+ }
114
+ function isUuidv7(value) {
115
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
116
+ }
117
+ function parseBearer(auth) {
118
+ if (!auth)
119
+ return null;
120
+ if (!auth.startsWith('Bearer '))
121
+ return null;
122
+ return auth.slice(7);
123
+ }
124
+ function parsePairToken(auth) {
125
+ if (!auth)
126
+ return null;
127
+ if (!auth.startsWith('Pair '))
128
+ return null;
129
+ return auth.slice(5);
130
+ }
131
+ function parseNodeAuth(auth) {
132
+ if (!auth)
133
+ return null;
134
+ if (!auth.startsWith('Node '))
135
+ return null;
136
+ const raw = auth.slice(5);
137
+ const idx = raw.indexOf('.');
138
+ if (idx <= 0)
139
+ return null;
140
+ return {
141
+ nodeId: raw.slice(0, idx),
142
+ nodeSecret: raw.slice(idx + 1),
143
+ };
144
+ }
145
+ function normalizePublicOrigin(value) {
146
+ const url = new URL(value.trim());
147
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
148
+ throw new Error('Hub public origin must use HTTP or HTTPS');
149
+ }
150
+ return url.origin;
151
+ }
152
+ function currentPublicOrigin() {
153
+ if (HUB_ENTRY_FILE) {
154
+ try {
155
+ const raw = readFileSync(HUB_ENTRY_FILE, 'utf8').trim();
156
+ if (raw) {
157
+ const fileValue = raw.startsWith('{')
158
+ ? JSON.parse(raw).publicOrigin
159
+ : raw;
160
+ if (typeof fileValue === 'string' && fileValue.trim()) {
161
+ return normalizePublicOrigin(fileValue);
162
+ }
163
+ }
164
+ }
165
+ catch (error) {
166
+ app.log.warn({ err: error, path: HUB_ENTRY_FILE }, 'Unable to read Hub entry file; using HUB_ENTRY');
167
+ }
168
+ }
169
+ return normalizePublicOrigin(HUB_ENTRY);
170
+ }
171
+ function pairingQrPayload(pairToken) {
172
+ const qr = new URL('dshremote://pair');
173
+ qr.searchParams.set('server', currentPublicOrigin());
174
+ qr.searchParams.set('token', pairToken);
175
+ qr.searchParams.set('hubId', HUB_ID);
176
+ return qr.toString();
177
+ }
178
+ const nodeConnections = new Map();
179
+ const mobileConnections = new Set();
180
+ const sessionSubscribers = new Map();
181
+ const sessionRings = new Map();
182
+ const sessionMeta = new Map();
183
+ function minimalPersistedCommandResult(action, frame) {
184
+ const base = {
185
+ v: 1,
186
+ kind: 'command.result',
187
+ commandId: frame.commandId,
188
+ ...(frame.requestId ? { requestId: frame.requestId } : {}),
189
+ ok: frame.ok,
190
+ };
191
+ if (!frame.ok) {
192
+ return {
193
+ ...base,
194
+ error: { code: String(frame.error?.code || 'INTERNAL_ERROR') },
195
+ };
196
+ }
197
+ const result = objectValue(frame.result);
198
+ if (action === 'session.create') {
199
+ const session = objectValue(result?.session);
200
+ const id = typeof session?.id === 'string'
201
+ ? session.id
202
+ : typeof session?.sessionId === 'string'
203
+ ? session.sessionId
204
+ : null;
205
+ if (!id)
206
+ return null;
207
+ return {
208
+ ...base,
209
+ result: {
210
+ session: {
211
+ id,
212
+ ...(typeof session?.title === 'string' ? { title: session.title } : {}),
213
+ ...(typeof session?.agentPreset === 'string' ? { agentPreset: session.agentPreset } : {}),
214
+ ...(typeof session?.lastSourceSeq === 'number' ? { lastSourceSeq: session.lastSourceSeq } : {}),
215
+ ...(typeof session?.createdAt === 'number' ? { createdAt: session.createdAt } : {}),
216
+ ...(typeof session?.updatedAt === 'number' ? { updatedAt: session.updatedAt } : {}),
217
+ },
218
+ },
219
+ };
220
+ }
221
+ if (action === 'session.selectModel') {
222
+ const selected = normalizeModelSelection(result?.selected);
223
+ return selected ? { ...base, result: { selected } } : null;
224
+ }
225
+ if (action === 'session.rename') {
226
+ const title = typeof result?.title === 'string' ? result.title : null;
227
+ if (!title)
228
+ return null;
229
+ return {
230
+ ...base,
231
+ result: {
232
+ title,
233
+ ...(typeof result?.seq === 'number' ? { seq: result.seq } : {}),
234
+ },
235
+ };
236
+ }
237
+ return null;
238
+ }
239
+ const pendingCommandTimeout = new Map();
240
+ const pendingCommandResults = new Map();
241
+ const rateBuckets = new Map();
242
+ function keySession(nodeId, sessionId) {
243
+ return `${nodeId}:${sessionId}`;
244
+ }
245
+ function sendJson(ws, payload) {
246
+ if (ws.readyState === 1) {
247
+ ws.send(JSON.stringify(payload));
248
+ }
249
+ }
250
+ async function issueAccessToken(userId, orgId, deviceId) {
251
+ return new SignJWT({
252
+ orgId,
253
+ deviceId,
254
+ type: 'access',
255
+ })
256
+ .setProtectedHeader({ alg: 'HS256' })
257
+ .setSubject(userId)
258
+ .setIssuedAt()
259
+ .setExpirationTime(`${ACCESS_TTL_SECONDS}s`)
260
+ .sign(JWT_SECRET);
261
+ }
262
+ async function verifyAccessToken(token) {
263
+ try {
264
+ const { payload } = await jwtVerify(token, JWT_SECRET);
265
+ if (payload.type !== 'access' || typeof payload.sub !== 'string')
266
+ return null;
267
+ if (typeof payload.orgId !== 'string' || typeof payload.deviceId !== 'string')
268
+ return null;
269
+ return {
270
+ userId: payload.sub,
271
+ orgId: payload.orgId,
272
+ deviceId: payload.deviceId,
273
+ };
274
+ }
275
+ catch {
276
+ return null;
277
+ }
278
+ }
279
+ function countUsers() {
280
+ const row = sqlite.prepare('SELECT COUNT(1) AS c FROM users').get();
281
+ return row.c;
282
+ }
283
+ function getUser(userId) {
284
+ return sqlite.prepare('SELECT * FROM users WHERE id = ?').get(userId);
285
+ }
286
+ function getUserByNode(nodeId) {
287
+ return sqlite.prepare('SELECT owner_user_id FROM nodes WHERE id = ?').get(nodeId);
288
+ }
289
+ function createDefaultOrgAndUser(displayName) {
290
+ const orgId = uuidv7();
291
+ const userId = uuidv7();
292
+ const now = nowMs();
293
+ const orgName = 'Default Organization';
294
+ sqlite.prepare('INSERT INTO organizations (id, name, created_at) VALUES (?, ?, ?)').run(orgId, orgName, now);
295
+ sqlite.prepare('INSERT INTO users (id, org_id, display_name, created_at) VALUES (?, ?, ?, ?)').run(userId, orgId, displayName, now);
296
+ return { orgId, userId, displayName };
297
+ }
298
+ function createDevice(userId, kind, name) {
299
+ const deviceId = uuidv7();
300
+ sqlite.prepare('INSERT INTO devices (id, user_id, kind, display_name, created_at) VALUES (?, ?, ?, ?, ?)').run(deviceId, userId, kind, name, nowMs());
301
+ return deviceId;
302
+ }
303
+ function createRefreshToken(userId, deviceId) {
304
+ const refreshId = uuidv7();
305
+ const token = randomHex(32);
306
+ const tokenHash = hashText(token);
307
+ const expiresAt = nowMs() + REFRESH_TTL_MS;
308
+ const createdAt = nowMs();
309
+ sqlite.prepare('INSERT INTO refresh_tokens (id, user_id, device_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(refreshId, userId, deviceId, tokenHash, expiresAt, createdAt);
310
+ return { refreshId, token, expiresAt, createdAt };
311
+ }
312
+ function writeAudit(event, actorUserId, nodeId, deviceId, details) {
313
+ sqlite.prepare('INSERT INTO audit_logs (id, event, actor_user_id, node_id, device_id, details, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
314
+ .run(uuidv7(), event, actorUserId, nodeId, deviceId, JSON.stringify(details), nowMs());
315
+ }
316
+ function ensureNodeMeta(nodeId) {
317
+ const row = sqlite.prepare('SELECT * FROM nodes WHERE id = ?').get(nodeId);
318
+ return row;
319
+ }
320
+ function getNodeFromAuthorization(authHeader) {
321
+ const auth = parseNodeAuth(authHeader);
322
+ if (!auth)
323
+ return null;
324
+ const node = ensureNodeMeta(auth.nodeId);
325
+ if (!node || node.revoked_at || node.credential_hash !== hashText(auth.nodeSecret))
326
+ return null;
327
+ return node;
328
+ }
329
+ function nodeOwnerId(nodeId) {
330
+ const row = sqlite.prepare('SELECT owner_user_id FROM nodes WHERE id = ?').get(nodeId);
331
+ return row?.owner_user_id;
332
+ }
333
+ function nodeIsOnline(nodeId) {
334
+ const socket = nodeConnections.get(nodeId);
335
+ return Boolean(socket && socket.ws.readyState === 1);
336
+ }
337
+ function publicNode(row) {
338
+ return {
339
+ id: row.id,
340
+ name: row.name,
341
+ platform: row.platform,
342
+ arch: row.arch,
343
+ pluginVersion: row.plugin_version,
344
+ dshVersion: row.dsh_version,
345
+ createdAt: row.created_at,
346
+ lastSeenAt: row.last_seen_at,
347
+ revokedAt: row.revoked_at,
348
+ online: nodeIsOnline(row.id),
349
+ };
350
+ }
351
+ function publicSession(row) {
352
+ return {
353
+ sessionId: row.session_id,
354
+ title: row.title,
355
+ lastEventSeq: row.last_event_seq,
356
+ updatedAt: row.updated_at,
357
+ createdAt: row.created_at,
358
+ };
359
+ }
360
+ function writeRingEvent(nodeId, sessionId, event) {
361
+ const k = keySession(nodeId, sessionId);
362
+ const bucket = sessionRings.get(k) ?? { events: [], lastSourceSeq: -1, updatedAt: nowMs() };
363
+ const next = {
364
+ ...event,
365
+ createdAt: nowMs(),
366
+ };
367
+ const expectedNext = bucket.lastSourceSeq + 1;
368
+ if (bucket.events.length === 0 || event.sourceSeq >= expectedNext) {
369
+ bucket.lastSourceSeq = event.sourceSeq;
370
+ }
371
+ bucket.events.push(next);
372
+ bucket.events = bucket.events
373
+ .filter((item) => nowMs() - item.createdAt <= SESSION_EVENT_TTL_MS)
374
+ .sort((a, b) => a.sourceSeq - b.sourceSeq);
375
+ while (bucket.events.length > SESSION_EVENT_MAX) {
376
+ bucket.events.shift();
377
+ }
378
+ if (bucket.events.length > 0) {
379
+ bucket.lastSourceSeq = Math.max(...bucket.events.map((item) => item.sourceSeq));
380
+ }
381
+ bucket.updatedAt = nowMs();
382
+ sessionRings.set(k, bucket);
383
+ sqlite.prepare('INSERT OR REPLACE INTO session_index (id, node_id, session_id, title, last_event_seq, updated_at, created_at) VALUES (?, ?, ?, COALESCE((SELECT title FROM session_index WHERE id = ?), ?), ?, ?, COALESCE((SELECT created_at FROM session_index WHERE id = ?), ?))')
384
+ .run(k, nodeId, sessionId, k, `Session ${sessionId}`, bucket.lastSourceSeq, nowMs(), k, nowMs());
385
+ if (event.event.type === 'session.title') {
386
+ const title = typeof event.event.data?.title === 'string' ? event.event.data.title.trim() : '';
387
+ if (title) {
388
+ sqlite.prepare('UPDATE session_index SET title = ?, updated_at = ? WHERE id = ?')
389
+ .run(title, nowMs(), k);
390
+ }
391
+ }
392
+ }
393
+ function getSessionReplay(nodeId, sessionId, afterSourceSeq) {
394
+ const ring = sessionRings.get(keySession(nodeId, sessionId));
395
+ if (!ring) {
396
+ return null;
397
+ }
398
+ if (ring.events.length === 0)
399
+ return [];
400
+ const minSeq = ring.events[0].sourceSeq;
401
+ const maxSeq = ring.events[ring.events.length - 1].sourceSeq;
402
+ if (afterSourceSeq < minSeq - 1 || afterSourceSeq > maxSeq) {
403
+ return null;
404
+ }
405
+ return ring.events.filter((evt) => evt.sourceSeq > afterSourceSeq);
406
+ }
407
+ function broadcastToSession(nodeId, sessionId, payload) {
408
+ const key = keySession(nodeId, sessionId);
409
+ const targets = sessionSubscribers.get(key);
410
+ if (!targets)
411
+ return;
412
+ for (const ws of targets) {
413
+ sendJson(ws, payload);
414
+ }
415
+ }
416
+ function approvalRequestFrame(nodeId, sessionId, approval) {
417
+ return {
418
+ v: 1,
419
+ kind: 'approval.request',
420
+ nodeId,
421
+ sessionId,
422
+ approval: {
423
+ approvalId: approval.approvalId,
424
+ title: approval.title || 'Approval',
425
+ summary: approval.summary || '',
426
+ toolCallId: approval.toolCallId,
427
+ nodeId,
428
+ sessionId,
429
+ ...(typeof approval.cwd === 'string' ? { cwd: approval.cwd } : {}),
430
+ ...(typeof approval.risk === 'string' ? { risk: approval.risk } : {}),
431
+ expiresAt: approval.expiresAt,
432
+ },
433
+ };
434
+ }
435
+ function cleanupOfflineNodes() {
436
+ const now = nowMs();
437
+ for (const [nodeId, state] of nodeConnections.entries()) {
438
+ if (now - state.lastSeenAt > OFFLINE_MS) {
439
+ state.ws.close(4001, 'NODE_OFFLINE');
440
+ nodeConnections.delete(nodeId);
441
+ sqlite.prepare('UPDATE nodes SET last_seen_at = ? WHERE id = ?').run(nowMs(), nodeId);
442
+ }
443
+ }
444
+ }
445
+ function cleanupStaleRates() {
446
+ const now = nowMs();
447
+ for (const [key, value] of rateBuckets.entries()) {
448
+ if (now - value.windowStart > 60_000) {
449
+ rateBuckets.delete(key);
450
+ }
451
+ }
452
+ }
453
+ function cleanupCommandMetadata() {
454
+ const cutoff = nowMs() - COMMAND_RETENTION_MS;
455
+ sqlite
456
+ .prepare("DELETE FROM commands WHERE created_at < ? AND status NOT IN ('pending', 'sent')")
457
+ .run(cutoff);
458
+ }
459
+ function getAuthUserFromRequest(authHeader) {
460
+ const token = parseBearer(authHeader);
461
+ if (!token)
462
+ return Promise.resolve(null);
463
+ return verifyAccessToken(token);
464
+ }
465
+ function getUserByRefreshToken(refreshToken) {
466
+ const tokenHash = hashText(refreshToken);
467
+ const row = sqlite
468
+ .prepare(`
469
+ SELECT rt.id, rt.user_id, rt.device_id, rt.expires_at, rt.revoked_at, rt.token_hash, u.org_id
470
+ FROM refresh_tokens rt
471
+ JOIN users u ON u.id = rt.user_id
472
+ WHERE rt.token_hash = ?
473
+ `)
474
+ .get(tokenHash);
475
+ return row;
476
+ }
477
+ function requireBody(request, schema) {
478
+ return schema.parse(request.body);
479
+ }
480
+ function sendError(reply, httpCode, errorCode, message, details) {
481
+ reply.code(httpCode).send({
482
+ code: errorCode,
483
+ message,
484
+ details,
485
+ });
486
+ }
487
+ const app = Fastify({
488
+ logger: {
489
+ level: 'info',
490
+ redact: {
491
+ paths: [
492
+ 'req.headers.authorization',
493
+ 'req.body.pairToken',
494
+ 'req.body.pollToken',
495
+ 'req.body.refreshToken',
496
+ 'req.body.nodeSecret',
497
+ 'req.body.nodeSecretHash',
498
+ ],
499
+ censor: '[REDACTED]',
500
+ },
501
+ },
502
+ bodyLimit: HTTP_BODY_LIMIT,
503
+ });
504
+ await app.register(websocket, {
505
+ options: {
506
+ maxPayload: WS_PAYLOAD_LIMIT,
507
+ },
508
+ });
509
+ app.addHook('onRequest', async (request, reply) => {
510
+ const key = request.ip || 'unknown';
511
+ const now = nowMs();
512
+ const bucket = rateBuckets.get(key) ?? { count: 0, windowStart: now };
513
+ if (now - bucket.windowStart > 60_000) {
514
+ bucket.count = 0;
515
+ bucket.windowStart = now;
516
+ }
517
+ bucket.count += 1;
518
+ rateBuckets.set(key, bucket);
519
+ if (bucket.count > 120) {
520
+ return sendError(reply, 429, 'RATE_LIMITED', 'Too many requests');
521
+ }
522
+ });
523
+ const httpError = {
524
+ UNAUTHORIZED: 401,
525
+ FORBIDDEN: 403,
526
+ INVALID_REQUEST: 400,
527
+ PAIR_TOKEN_INVALID: 400,
528
+ PAIR_TOKEN_EXPIRED: 410,
529
+ PAIR_TOKEN_ALREADY_USED: 409,
530
+ PAIRING_NOT_FOUND: 404,
531
+ NODE_NOT_FOUND: 404,
532
+ NODE_OFFLINE: 409,
533
+ NODE_REVOKED: 403,
534
+ SESSION_NOT_FOUND: 404,
535
+ APPROVAL_NOT_FOUND: 404,
536
+ APPROVAL_EXPIRED: 410,
537
+ APPROVAL_ALREADY_RESOLVED: 409,
538
+ CAPABILITY_UNAVAILABLE: 409,
539
+ AGENT_PRESET_NOT_FOUND: 400,
540
+ AGENT_PRESET_INVALID: 400,
541
+ AGENT_PRESET_LOCKED: 409,
542
+ MODEL_UNAVAILABLE: 409,
543
+ COMMAND_TIMEOUT: 408,
544
+ COMMAND_DUPLICATE: 200,
545
+ COMMAND_EXPIRED: 408,
546
+ PROTOCOL_UNSUPPORTED: 400,
547
+ INTERNAL_ERROR: 500,
548
+ };
549
+ const pairingBody = z.object({
550
+ nodeName: z.string().min(1),
551
+ platform: z.string().min(1),
552
+ arch: z.string().min(1),
553
+ pluginVersion: z.string().min(1),
554
+ dshVersion: z.string().min(1),
555
+ installId: z.string().min(1),
556
+ nodeSecretHash: z.string().length(64),
557
+ });
558
+ const requestIdSchema = z.string().refine(isUuidv7, { message: 'requestId must be UUIDv7' });
559
+ const claimBody = z.object({
560
+ pairToken: z.string().min(1),
561
+ ownerDisplayName: z.string().min(1).optional(),
562
+ deviceName: z.string().min(1).optional(),
563
+ });
564
+ const refreshBody = z.object({
565
+ refreshToken: z.string().min(1),
566
+ });
567
+ const createSessionBody = z.object({
568
+ requestId: requestIdSchema,
569
+ agentPreset: z.string().min(1).max(256).optional(),
570
+ });
571
+ const modelSelectionBody = z.object({
572
+ requestId: requestIdSchema,
573
+ provider: z.string().min(1).max(256),
574
+ model: z.string().min(1).max(512),
575
+ reasoningEffort: z.string().min(1).max(128).optional(),
576
+ });
577
+ const renameSessionBody = z.object({
578
+ requestId: requestIdSchema,
579
+ title: z.string().max(200).refine((title) => title.trim().length > 0, {
580
+ message: 'title must not be empty',
581
+ }),
582
+ });
583
+ const followupBody = z.object({
584
+ requestId: requestIdSchema,
585
+ content: z.string().min(1).max(131_072),
586
+ });
587
+ const steerBody = z.object({
588
+ requestId: requestIdSchema,
589
+ instruction: z.string().min(1).max(131_072),
590
+ });
591
+ const stopBody = z.object({
592
+ requestId: requestIdSchema,
593
+ reason: z.string().min(1).max(4096).optional(),
594
+ });
595
+ const approvalRespondBody = z.object({
596
+ requestId: requestIdSchema,
597
+ response: z.enum(['allow_once', 'deny']),
598
+ });
599
+ const wsSubscribeBody = z.object({
600
+ v: z.literal(1),
601
+ kind: z.literal('subscribe'),
602
+ requestId: requestIdSchema,
603
+ nodeId: z.string().min(1),
604
+ sessionId: z.string().min(1),
605
+ });
606
+ const wsSessionSyncBody = z.object({
607
+ v: z.literal(1),
608
+ kind: z.literal('session.sync'),
609
+ requestId: requestIdSchema,
610
+ nodeId: z.string().min(1),
611
+ sessionId: z.string().min(1),
612
+ afterSourceSeq: z.number().int().min(-1),
613
+ });
614
+ app.get('/healthz', async () => ({
615
+ ok: true,
616
+ server: 'dsh-hub',
617
+ version: 'v1',
618
+ }));
619
+ app.get('/readyz', async () => {
620
+ const row = sqlite.prepare('SELECT 1 as ok').get();
621
+ if (!row?.ok) {
622
+ throw new Error('DB not ready');
623
+ }
624
+ return { ok: true };
625
+ });
626
+ app.get('/v1/meta', async () => ({
627
+ hubId: HUB_ID,
628
+ version: HUB_VERSION,
629
+ publicOrigin: currentPublicOrigin(),
630
+ }));
631
+ app.post('/v1/node-pairings', async (request, reply) => {
632
+ const body = requireBody(request, pairingBody);
633
+ const id = uuidv7();
634
+ const pairToken = randomHex(32);
635
+ const pollToken = randomHex(32);
636
+ const now = nowMs();
637
+ const expiresAt = now + 5 * 60_000;
638
+ sqlite
639
+ .prepare(`INSERT INTO node_pairings (
640
+ id, pair_token_hash, poll_token_hash, install_id, node_name, platform, arch, plugin_version,
641
+ dsh_version, node_secret_hash, status, created_at, expires_at
642
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`)
643
+ .run(id, hashText(pairToken), hashText(pollToken), body.installId, body.nodeName, body.platform, body.arch, body.pluginVersion, body.dshVersion, body.nodeSecretHash, now, expiresAt);
644
+ writeAudit('pairing_created', null, null, null, { pairingId: id, nodeName: body.nodeName, platform: body.platform });
645
+ reply.code(200).send({
646
+ pairingId: id,
647
+ pairToken,
648
+ pollToken,
649
+ expiresAt,
650
+ qrPayload: pairingQrPayload(pairToken),
651
+ });
652
+ });
653
+ app.post('/v1/node-pairings/recover', async (request, reply) => {
654
+ const node = getNodeFromAuthorization(request.headers.authorization);
655
+ if (!node) {
656
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Valid Node authorization required');
657
+ }
658
+ const id = uuidv7();
659
+ const pairToken = randomHex(32);
660
+ const pollToken = randomHex(32);
661
+ const now = nowMs();
662
+ const expiresAt = now + 5 * 60_000;
663
+ sqlite
664
+ .prepare(`INSERT INTO node_pairings (
665
+ id, pair_token_hash, poll_token_hash, install_id, node_name, platform, arch, plugin_version,
666
+ dsh_version, node_secret_hash, status, created_at, expires_at, claimed_user_id, node_id
667
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)`)
668
+ .run(id, hashText(pairToken), hashText(pollToken), node.install_id, node.name, node.platform, node.arch, node.plugin_version, node.dsh_version, node.credential_hash, now, expiresAt, node.owner_user_id, node.id);
669
+ writeAudit('mobile_recovery_created', node.owner_user_id, node.id, null, { pairingId: id });
670
+ reply.send({
671
+ pairingId: id,
672
+ pairToken,
673
+ pollToken,
674
+ expiresAt,
675
+ qrPayload: pairingQrPayload(pairToken),
676
+ });
677
+ });
678
+ app.get('/v1/node-pairings/:pairingId', async (request, reply) => {
679
+ const pairingId = request.params.pairingId;
680
+ const pollToken = parsePairToken(request.headers.authorization);
681
+ if (!pollToken) {
682
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing Pair authorization');
683
+ }
684
+ const pollHash = hashText(pollToken);
685
+ const row = sqlite
686
+ .prepare('SELECT * FROM node_pairings WHERE id = ? AND poll_token_hash = ?')
687
+ .get(pairingId, pollHash);
688
+ if (!row) {
689
+ return sendError(reply, httpError.PAIRING_NOT_FOUND, 'PAIRING_NOT_FOUND', 'Pairing not found');
690
+ }
691
+ if (row.status === 'claimed') {
692
+ const owner = sqlite
693
+ .prepare('SELECT display_name FROM users WHERE id = ?')
694
+ .get(row.claimed_user_id);
695
+ reply.send({
696
+ status: 'claimed',
697
+ nodeId: row.node_id,
698
+ ownerDisplayName: owner?.display_name ?? 'Owner',
699
+ });
700
+ return;
701
+ }
702
+ if (row.status === 'pending') {
703
+ if (row.expires_at <= nowMs()) {
704
+ reply.send({
705
+ status: 'expired',
706
+ });
707
+ return;
708
+ }
709
+ reply.send({
710
+ status: 'pending',
711
+ });
712
+ return;
713
+ }
714
+ return sendError(reply, httpError.INVALID_REQUEST, 'INVALID_REQUEST', 'Unsupported pairing status');
715
+ });
716
+ app.post('/v1/node-pairings/claim', async (request, reply) => {
717
+ const body = requireBody(request, claimBody);
718
+ const authUser = await getAuthUserFromRequest(request.headers.authorization);
719
+ const currentUserCount = countUsers();
720
+ const row = sqlite
721
+ .prepare('SELECT * FROM node_pairings WHERE pair_token_hash = ?')
722
+ .get(hashText(body.pairToken));
723
+ if (!row) {
724
+ return sendError(reply, httpError.PAIRING_NOT_FOUND, 'PAIRING_NOT_FOUND', 'Pair token not found');
725
+ }
726
+ if (row.status !== 'pending') {
727
+ return sendError(reply, httpError.PAIR_TOKEN_ALREADY_USED, 'PAIR_TOKEN_ALREADY_USED', 'Pair token already used');
728
+ }
729
+ if (row.expires_at <= nowMs()) {
730
+ return sendError(reply, httpError.PAIR_TOKEN_EXPIRED, 'PAIR_TOKEN_EXPIRED', 'Pair token expired');
731
+ }
732
+ const isRecovery = Boolean(row.claimed_user_id && row.node_id);
733
+ if (!authUser && currentUserCount > 0 && !isRecovery) {
734
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Bearer required');
735
+ }
736
+ let userId;
737
+ let orgId;
738
+ if (isRecovery) {
739
+ const user = getUser(row.claimed_user_id);
740
+ const node = ensureNodeMeta(row.node_id);
741
+ if (!user || !node || node.revoked_at || node.owner_user_id !== row.claimed_user_id) {
742
+ return sendError(reply, httpError.FORBIDDEN, 'FORBIDDEN', 'Recovery pairing is no longer valid');
743
+ }
744
+ userId = user.id;
745
+ orgId = user.org_id;
746
+ }
747
+ else if (authUser) {
748
+ const user = getUser(authUser.userId);
749
+ if (!user) {
750
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Invalid bearer user');
751
+ }
752
+ userId = authUser.userId;
753
+ orgId = user.org_id;
754
+ }
755
+ else {
756
+ const ownerDisplayName = body.ownerDisplayName || 'Owner';
757
+ const created = createDefaultOrgAndUser(ownerDisplayName);
758
+ userId = created.userId;
759
+ orgId = created.orgId;
760
+ }
761
+ const deviceId = createDevice(userId, 'mobile', body.deviceName || 'Mobile Device');
762
+ const tokenInfo = createRefreshToken(userId, deviceId);
763
+ const accessToken = await issueAccessToken(userId, orgId, deviceId);
764
+ if (isRecovery) {
765
+ sqlite
766
+ .prepare('UPDATE node_pairings SET status = ?, claimed_at = ? WHERE id = ?')
767
+ .run('claimed', nowMs(), row.id);
768
+ writeAudit('mobile_recovery_claimed', userId, row.node_id, deviceId, { pairingId: row.id });
769
+ reply.send({
770
+ accessToken,
771
+ refreshToken: tokenInfo.token,
772
+ refreshExpiresAt: tokenInfo.expiresAt,
773
+ expiresIn: ACCESS_TTL_SECONDS,
774
+ nodeId: row.node_id,
775
+ tokenType: 'Bearer',
776
+ });
777
+ return;
778
+ }
779
+ const nodeId = uuidv7();
780
+ sqlite
781
+ .prepare(`INSERT INTO nodes (
782
+ id, org_id, owner_user_id, install_id, name, platform, arch, plugin_version,
783
+ dsh_version, credential_hash, created_at
784
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
785
+ .run(nodeId, orgId, userId, row.install_id, row.node_name, row.platform, row.arch, row.plugin_version, row.dsh_version, row.node_secret_hash, nowMs());
786
+ sqlite
787
+ .prepare('UPDATE node_pairings SET status = ?, claimed_user_id = ?, claimed_at = ?, node_id = ? WHERE id = ?')
788
+ .run('claimed', userId, nowMs(), nodeId, row.id);
789
+ sqlite
790
+ .prepare('INSERT INTO audit_logs (id, event, actor_user_id, node_id, device_id, details, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
791
+ .run(uuidv7(), 'pairing_claimed', userId, nodeId, deviceId, JSON.stringify({ pairingId: row.id }), nowMs());
792
+ writeAudit('pairing_claimed', userId, nodeId, deviceId, { pairingId: row.id });
793
+ reply.send({
794
+ accessToken,
795
+ refreshToken: tokenInfo.token,
796
+ refreshExpiresAt: tokenInfo.expiresAt,
797
+ expiresIn: ACCESS_TTL_SECONDS,
798
+ nodeId,
799
+ tokenType: 'Bearer',
800
+ });
801
+ });
802
+ app.post('/v1/auth/refresh', async (request, reply) => {
803
+ const body = requireBody(request, refreshBody);
804
+ const row = getUserByRefreshToken(body.refreshToken);
805
+ if (!row) {
806
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Invalid refresh token');
807
+ }
808
+ if (row.revoked_at) {
809
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Refresh token revoked');
810
+ }
811
+ if (row.expires_at <= nowMs()) {
812
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Refresh token expired');
813
+ }
814
+ sqlite
815
+ .prepare('UPDATE refresh_tokens SET revoked_at = ? WHERE id = ?')
816
+ .run(nowMs(), row.id);
817
+ const newToken = createRefreshToken(row.user_id, row.device_id);
818
+ const accessToken = await issueAccessToken(row.user_id, row.org_id, row.device_id);
819
+ writeAudit('token_refreshed', row.user_id, null, row.device_id, { refreshId: row.id });
820
+ reply.send({
821
+ accessToken,
822
+ refreshToken: newToken.token,
823
+ refreshExpiresAt: newToken.expiresAt,
824
+ expiresIn: ACCESS_TTL_SECONDS,
825
+ tokenType: 'Bearer',
826
+ });
827
+ });
828
+ app.post('/v1/auth/logout', async (request, reply) => {
829
+ const body = requireBody(request, refreshBody);
830
+ const tokenHash = hashText(body.refreshToken);
831
+ const row = sqlite.prepare('SELECT id, user_id, device_id FROM refresh_tokens WHERE token_hash = ?').get(tokenHash);
832
+ if (!row) {
833
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Invalid refresh token');
834
+ }
835
+ sqlite.prepare('UPDATE refresh_tokens SET revoked_at = ? WHERE id = ?').run(nowMs(), row.id);
836
+ writeAudit('refresh_revoked', row.user_id, null, row.device_id, { refreshId: row.id });
837
+ reply.send({ ok: true });
838
+ });
839
+ app.get('/v1/me', async (request, reply) => {
840
+ const authUser = await getAuthUserFromRequest(request.headers.authorization);
841
+ if (!authUser) {
842
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
843
+ }
844
+ const user = getUser(authUser.userId);
845
+ if (!user) {
846
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Unknown user');
847
+ }
848
+ reply.send({
849
+ user: {
850
+ id: user.id,
851
+ displayName: user.display_name,
852
+ orgId: user.org_id,
853
+ },
854
+ });
855
+ });
856
+ app.get('/v1/nodes', async (request, reply) => {
857
+ const authUser = await getAuthUserFromRequest(request.headers.authorization);
858
+ if (!authUser) {
859
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
860
+ }
861
+ const rows = sqlite
862
+ .prepare('SELECT * FROM nodes WHERE owner_user_id = ? ORDER BY created_at DESC')
863
+ .all(authUser.userId);
864
+ const items = rows.map(publicNode);
865
+ reply.send({ items, count: items.length });
866
+ });
867
+ app.get('/v1/nodes/:nodeId', async (request, reply) => {
868
+ const nodeId = request.params.nodeId;
869
+ const authUser = await getAuthUserFromRequest(request.headers.authorization);
870
+ if (!authUser) {
871
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
872
+ }
873
+ const node = ensureNodeMeta(nodeId);
874
+ if (!node) {
875
+ return sendError(reply, httpError.NODE_NOT_FOUND, 'NODE_NOT_FOUND', 'Node not found');
876
+ }
877
+ if (node.owner_user_id !== authUser.userId) {
878
+ return sendError(reply, httpError.FORBIDDEN, 'FORBIDDEN', 'Node belongs to another user');
879
+ }
880
+ reply.send(publicNode(node));
881
+ });
882
+ app.post('/v1/nodes/:nodeId/revoke', async (request, reply) => {
883
+ const nodeId = request.params.nodeId;
884
+ const authUser = await getAuthUserFromRequest(request.headers.authorization);
885
+ if (!authUser) {
886
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
887
+ }
888
+ const node = ensureNodeMeta(nodeId);
889
+ if (!node) {
890
+ return sendError(reply, httpError.NODE_NOT_FOUND, 'NODE_NOT_FOUND', 'Node not found');
891
+ }
892
+ if (node.owner_user_id !== authUser.userId) {
893
+ return sendError(reply, httpError.FORBIDDEN, 'FORBIDDEN', 'Node belongs to another user');
894
+ }
895
+ sqlite.prepare('UPDATE nodes SET revoked_at = ? WHERE id = ?').run(nowMs(), nodeId);
896
+ const conn = nodeConnections.get(nodeId);
897
+ if (conn) {
898
+ conn.ws.close(4403, 'revoked');
899
+ nodeConnections.delete(nodeId);
900
+ }
901
+ writeAudit('node_revoked', authUser.userId, nodeId, authUser.deviceId, {});
902
+ reply.send({ ok: true, nodeId });
903
+ });
904
+ app.get('/v1/nodes/:nodeId/sessions', async (request, reply) => {
905
+ const nodeId = request.params.nodeId;
906
+ const authUser = await getAuthUserFromRequest(request.headers.authorization);
907
+ if (!authUser) {
908
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
909
+ }
910
+ if (nodeOwnerId(nodeId) !== authUser.userId) {
911
+ return sendError(reply, httpError.FORBIDDEN, 'FORBIDDEN', 'Node belongs to another user');
912
+ }
913
+ try {
914
+ const dispatched = await dispatchCommand(authUser, nodeId, null, 'session.list', uuidv7(), {}, true);
915
+ const resultFrame = await dispatched.resultPromise;
916
+ if (!resultFrame || !resultFrame.ok) {
917
+ throw {
918
+ code: resultFrame?.error?.code || 'INTERNAL_ERROR',
919
+ message: resultFrame?.error?.message || 'Session list command failed',
920
+ };
921
+ }
922
+ const result = resultFrame.result;
923
+ const remoteSessions = Array.isArray(result?.sessions) ? result.sessions : [];
924
+ const upsert = sqlite.prepare(`
925
+ INSERT INTO session_index (
926
+ id, node_id, session_id, title, last_event_seq, updated_at, created_at
927
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
928
+ ON CONFLICT(node_id, session_id) DO UPDATE SET
929
+ title = excluded.title,
930
+ last_event_seq = CASE
931
+ WHEN excluded.last_event_seq > session_index.last_event_seq THEN excluded.last_event_seq
932
+ ELSE session_index.last_event_seq
933
+ END,
934
+ updated_at = excluded.updated_at
935
+ `);
936
+ const indexedAt = nowMs();
937
+ const sessions = [];
938
+ for (const item of remoteSessions) {
939
+ if (!item || typeof item !== 'object')
940
+ continue;
941
+ const session = item;
942
+ const sessionId = typeof session.id === 'string'
943
+ ? session.id
944
+ : typeof session.sessionId === 'string'
945
+ ? session.sessionId
946
+ : null;
947
+ if (!sessionId)
948
+ continue;
949
+ const title = typeof session.title === 'string' && session.title.trim()
950
+ ? session.title
951
+ : `Session ${sessionId}`;
952
+ const lastEventSeq = typeof session.lastSourceSeq === 'number'
953
+ ? session.lastSourceSeq
954
+ : typeof session.lastEventSeq === 'number'
955
+ ? session.lastEventSeq
956
+ : -1;
957
+ const updatedAt = typeof session.updatedAt === 'number' ? session.updatedAt : indexedAt;
958
+ const createdAt = typeof session.createdAt === 'number' ? session.createdAt : indexedAt;
959
+ const status = session.status === 'running' || session.status === 'idle' ? session.status : 'unknown';
960
+ const workspaceLabel = typeof session.cwdLabel === 'string'
961
+ ? session.cwdLabel
962
+ : typeof session.workspaceLabel === 'string'
963
+ ? session.workspaceLabel
964
+ : undefined;
965
+ const agentPreset = typeof session.agentPreset === 'string' && session.agentPreset
966
+ ? session.agentPreset
967
+ : undefined;
968
+ upsert.run(keySession(nodeId, sessionId), nodeId, sessionId, title, lastEventSeq, updatedAt, createdAt);
969
+ sessions.push({
970
+ sessionId,
971
+ title,
972
+ status,
973
+ lastEventSeq,
974
+ updatedAt,
975
+ createdAt,
976
+ ...(workspaceLabel ? { workspaceLabel } : {}),
977
+ ...(agentPreset ? { agentPreset } : {}),
978
+ });
979
+ }
980
+ sessions.sort((a, b) => b.updatedAt - a.updatedAt);
981
+ reply.send({ sessions, count: sessions.length });
982
+ }
983
+ catch (err) {
984
+ const statusCode = httpError[err?.code] ?? 500;
985
+ return sendError(reply, statusCode, err?.code || 'INTERNAL_ERROR', err?.message || 'Failed to list sessions');
986
+ }
987
+ });
988
+ async function dispatchCommand(authUser, nodeId, sessionId, action, requestId, payload, expectResult = false) {
989
+ const existing = sqlite
990
+ .prepare('SELECT * FROM commands WHERE request_id = ? AND user_id = ?')
991
+ .get(requestId, authUser.userId);
992
+ if (existing) {
993
+ const persistedResult = typeof existing.result_json === 'string'
994
+ ? JSON.parse(existing.result_json)
995
+ : undefined;
996
+ const inFlightResult = pendingCommandResults.get(existing.id)?.promise;
997
+ return {
998
+ commandId: existing.id,
999
+ status: existing.status,
1000
+ requestId,
1001
+ duplicates: true,
1002
+ resultPromise: expectResult
1003
+ ? persistedResult
1004
+ ? Promise.resolve(persistedResult)
1005
+ : inFlightResult
1006
+ : undefined,
1007
+ };
1008
+ }
1009
+ const node = ensureNodeMeta(nodeId);
1010
+ if (!node) {
1011
+ throw { code: 'NODE_NOT_FOUND' };
1012
+ }
1013
+ if (node.owner_user_id !== authUser.userId) {
1014
+ throw { code: 'FORBIDDEN' };
1015
+ }
1016
+ if (node.revoked_at) {
1017
+ throw { code: 'NODE_REVOKED' };
1018
+ }
1019
+ const conn = nodeConnections.get(nodeId);
1020
+ if (!conn) {
1021
+ throw { code: 'NODE_OFFLINE' };
1022
+ }
1023
+ if (!conn.capabilities.has(action)) {
1024
+ throw { code: 'CAPABILITY_UNAVAILABLE' };
1025
+ }
1026
+ const commandId = uuidv7();
1027
+ const now = nowMs();
1028
+ const expiresAt = now + COMMAND_TTL_MS;
1029
+ sqlite
1030
+ .prepare(`INSERT INTO commands (id, request_id, user_id, device_id, node_id, session_id, action, status, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`)
1031
+ .run(commandId, requestId, authUser.userId, authUser.deviceId, nodeId, sessionId, action, now, expiresAt);
1032
+ const commandFrame = {
1033
+ v: 1,
1034
+ kind: 'command',
1035
+ commandId,
1036
+ requestId,
1037
+ nodeId,
1038
+ sessionId,
1039
+ action,
1040
+ payload,
1041
+ issuedAt: now,
1042
+ expiresAt,
1043
+ };
1044
+ let resultPromise;
1045
+ if (expectResult) {
1046
+ let resolveResult;
1047
+ let rejectResult;
1048
+ resultPromise = new Promise((resolve, reject) => {
1049
+ resolveResult = resolve;
1050
+ rejectResult = reject;
1051
+ });
1052
+ const timeout = setTimeout(() => {
1053
+ pendingCommandResults.delete(commandId);
1054
+ sqlite
1055
+ .prepare('UPDATE commands SET status = ?, error_code = ? WHERE id = ?')
1056
+ .run('failed', 'COMMAND_TIMEOUT', commandId);
1057
+ rejectResult({ code: 'COMMAND_TIMEOUT', message: 'Command result timed out' });
1058
+ }, COMMAND_TTL_MS);
1059
+ pendingCommandResults.set(commandId, {
1060
+ resolve: resolveResult,
1061
+ reject: rejectResult,
1062
+ timeout,
1063
+ promise: resultPromise,
1064
+ });
1065
+ }
1066
+ sendJson(conn.ws, commandFrame);
1067
+ sqlite
1068
+ .prepare('UPDATE commands SET status = ? WHERE id = ?')
1069
+ .run('sent', commandId);
1070
+ const t = setTimeout(() => {
1071
+ const c = sqlite.prepare('SELECT status FROM commands WHERE id = ?').get(commandId);
1072
+ if (c && c.status !== 'acked') {
1073
+ sqlite
1074
+ .prepare('UPDATE commands SET status = ?, error_code = ? WHERE id = ?')
1075
+ .run('failed', 'COMMAND_TIMEOUT', commandId);
1076
+ }
1077
+ pendingCommandTimeout.delete(commandId);
1078
+ }, COMMAND_TTL_MS);
1079
+ pendingCommandTimeout.set(commandId, t);
1080
+ return {
1081
+ commandId,
1082
+ status: 'sent',
1083
+ requestId,
1084
+ duplicates: false,
1085
+ resultPromise,
1086
+ };
1087
+ }
1088
+ app.get('/v1/nodes/:nodeId/agent-presets', async (request, reply) => {
1089
+ const nodeId = request.params.nodeId;
1090
+ const authUser = await getAuthUserFromRequest(request.headers.authorization);
1091
+ if (!authUser) {
1092
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
1093
+ }
1094
+ if (nodeOwnerId(nodeId) !== authUser.userId) {
1095
+ return sendError(reply, httpError.FORBIDDEN, 'FORBIDDEN', 'Node belongs to another user');
1096
+ }
1097
+ try {
1098
+ const dispatched = await dispatchCommand(authUser, nodeId, null, 'agentPreset.list', uuidv7(), {}, true);
1099
+ const resultFrame = await dispatched.resultPromise;
1100
+ if (!resultFrame?.ok) {
1101
+ throw {
1102
+ code: resultFrame?.error?.code || 'INTERNAL_ERROR',
1103
+ message: resultFrame?.error?.message || 'Agent preset command failed',
1104
+ };
1105
+ }
1106
+ const result = resultFrame.result;
1107
+ const presets = Array.isArray(result?.presets)
1108
+ ? result.presets.flatMap((value) => {
1109
+ if (!value || typeof value !== 'object')
1110
+ return [];
1111
+ const preset = value;
1112
+ if (typeof preset.id !== 'string' || !preset.id)
1113
+ return [];
1114
+ const trust = preset.trust === 'user' ? 'user' : 'system';
1115
+ return [{
1116
+ id: preset.id,
1117
+ trust,
1118
+ isDefault: preset.isDefault === true,
1119
+ ...(typeof preset.name === 'string' ? { name: preset.name } : {}),
1120
+ ...(typeof preset.description === 'string' ? { description: preset.description } : {}),
1121
+ ...(typeof preset.broken === 'string' ? { broken: preset.broken } : {}),
1122
+ }];
1123
+ })
1124
+ : [];
1125
+ reply.send({ presets });
1126
+ }
1127
+ catch (err) {
1128
+ const statusCode = httpError[err.code] ?? 500;
1129
+ return sendError(reply, statusCode, err.code || 'INTERNAL_ERROR', err.message || 'Command failed');
1130
+ }
1131
+ });
1132
+ app.post('/v1/nodes/:nodeId/sessions', async (request, reply) => {
1133
+ const nodeId = request.params.nodeId;
1134
+ const authUser = await getAuthUserFromRequest(request.headers.authorization);
1135
+ if (!authUser) {
1136
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
1137
+ }
1138
+ const body = requireBody(request, createSessionBody);
1139
+ const requestId = body.requestId;
1140
+ try {
1141
+ const dispatched = await dispatchCommand(authUser, nodeId, null, 'session.create', requestId, {
1142
+ createdBy: 'mobile',
1143
+ ...(body.agentPreset ? { agentPreset: body.agentPreset } : {}),
1144
+ }, true);
1145
+ const resultFrame = await dispatched.resultPromise;
1146
+ if (!resultFrame?.ok) {
1147
+ throw {
1148
+ code: resultFrame?.error?.code || 'INTERNAL_ERROR',
1149
+ message: resultFrame?.error?.message || 'Session create command failed',
1150
+ };
1151
+ }
1152
+ const remoteSession = resultFrame.result?.session;
1153
+ const sessionId = typeof remoteSession?.id === 'string'
1154
+ ? remoteSession.id
1155
+ : typeof remoteSession?.sessionId === 'string'
1156
+ ? remoteSession.sessionId
1157
+ : null;
1158
+ if (!sessionId) {
1159
+ throw { code: 'INTERNAL_ERROR', message: 'Node did not return a session id' };
1160
+ }
1161
+ const title = typeof remoteSession?.title === 'string' && remoteSession.title.trim()
1162
+ ? remoteSession.title
1163
+ : 'New Session';
1164
+ const lastEventSeq = typeof remoteSession?.lastSourceSeq === 'number'
1165
+ ? remoteSession.lastSourceSeq
1166
+ : typeof remoteSession?.lastEventSeq === 'number'
1167
+ ? remoteSession.lastEventSeq
1168
+ : -1;
1169
+ const updatedAt = typeof remoteSession?.updatedAt === 'number' ? remoteSession.updatedAt : nowMs();
1170
+ const createdAt = typeof remoteSession?.createdAt === 'number' ? remoteSession.createdAt : updatedAt;
1171
+ const agentPreset = typeof remoteSession?.agentPreset === 'string' && remoteSession.agentPreset
1172
+ ? remoteSession.agentPreset
1173
+ : body.agentPreset;
1174
+ sqlite.prepare('INSERT OR IGNORE INTO session_index (id, node_id, session_id, title, last_event_seq, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(`${nodeId}:${sessionId}`, nodeId, sessionId, title, lastEventSeq, updatedAt, createdAt);
1175
+ sqlite
1176
+ .prepare('UPDATE nodes SET last_seen_at = ? WHERE id = ?')
1177
+ .run(nowMs(), nodeId);
1178
+ reply.code(202).send({
1179
+ commandId: dispatched.commandId,
1180
+ sessionId,
1181
+ requestId: dispatched.requestId,
1182
+ ...(agentPreset ? { agentPreset } : {}),
1183
+ });
1184
+ }
1185
+ catch (err) {
1186
+ const statusCode = httpError[err.code] ?? 500;
1187
+ return sendError(reply, statusCode, err.code || 'INTERNAL_ERROR', err.message || 'Command failed');
1188
+ }
1189
+ });
1190
+ app.get('/v1/nodes/:nodeId/sessions/:sessionId/snapshot', async (request, reply) => {
1191
+ const params = request.params;
1192
+ const authUser = await getAuthUserFromRequest(request.headers.authorization);
1193
+ if (!authUser) {
1194
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
1195
+ }
1196
+ const node = ensureNodeMeta(params.nodeId);
1197
+ if (!node || node.owner_user_id !== authUser.userId) {
1198
+ return sendError(reply, httpError.FORBIDDEN, 'FORBIDDEN', 'Node belongs to another user');
1199
+ }
1200
+ const conn = nodeConnections.get(params.nodeId);
1201
+ if (conn && conn.capabilities.has('session.snapshot')) {
1202
+ try {
1203
+ const dispatched = await dispatchCommand(authUser, params.nodeId, params.sessionId, 'session.snapshot', uuidv7(), {}, true);
1204
+ const resultFrame = await dispatched.resultPromise;
1205
+ if (resultFrame && resultFrame.ok) {
1206
+ const result = resultFrame.result;
1207
+ return reply.send({
1208
+ source: 'node',
1209
+ session: result?.session ?? {
1210
+ nodeId: params.nodeId,
1211
+ sessionId: params.sessionId,
1212
+ },
1213
+ events: result?.events ?? [],
1214
+ });
1215
+ }
1216
+ }
1217
+ catch {
1218
+ // Fall through to ring-buffer fallback.
1219
+ }
1220
+ }
1221
+ const row = sqlite
1222
+ .prepare('SELECT * FROM session_index WHERE node_id = ? AND session_id = ?')
1223
+ .get(params.nodeId, params.sessionId);
1224
+ if (!row) {
1225
+ return sendError(reply, httpError.SESSION_NOT_FOUND, 'SESSION_NOT_FOUND', 'Session not found');
1226
+ }
1227
+ const replay = getSessionReplay(params.nodeId, params.sessionId, -1) || [];
1228
+ reply.send({
1229
+ source: 'ring-buffer',
1230
+ session: {
1231
+ nodeId: params.nodeId,
1232
+ sessionId: params.sessionId,
1233
+ title: row.title,
1234
+ lastEventSeq: row.last_event_seq,
1235
+ },
1236
+ events: replay,
1237
+ });
1238
+ });
1239
+ app.get('/v1/nodes/:nodeId/sessions/:sessionId/models', async (request, reply) => {
1240
+ const params = request.params;
1241
+ const authUser = await getAuthUserFromRequest(request.headers.authorization);
1242
+ if (!authUser) {
1243
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
1244
+ }
1245
+ if (nodeOwnerId(params.nodeId) !== authUser.userId) {
1246
+ return sendError(reply, httpError.FORBIDDEN, 'FORBIDDEN', 'Node belongs to another user');
1247
+ }
1248
+ try {
1249
+ const dispatched = await dispatchCommand(authUser, params.nodeId, params.sessionId, 'session.models', uuidv7(), {}, true);
1250
+ const resultFrame = await dispatched.resultPromise;
1251
+ if (!resultFrame?.ok) {
1252
+ throw {
1253
+ code: resultFrame?.error?.code || 'INTERNAL_ERROR',
1254
+ message: resultFrame?.error?.message || 'Session model command failed',
1255
+ };
1256
+ }
1257
+ reply.send(normalizeSessionModels(resultFrame.result));
1258
+ }
1259
+ catch (err) {
1260
+ const statusCode = httpError[err.code] ?? 500;
1261
+ return sendError(reply, statusCode, err.code || 'INTERNAL_ERROR', err.message || 'Command failed');
1262
+ }
1263
+ });
1264
+ app.post('/v1/nodes/:nodeId/sessions/:sessionId/model-selection', async (request, reply) => {
1265
+ const params = request.params;
1266
+ const body = requireBody(request, modelSelectionBody);
1267
+ const authUser = await getAuthUserFromRequest(request.headers.authorization);
1268
+ if (!authUser) {
1269
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
1270
+ }
1271
+ try {
1272
+ const dispatched = await dispatchCommand(authUser, params.nodeId, params.sessionId, 'session.selectModel', body.requestId, {
1273
+ provider: body.provider,
1274
+ model: body.model,
1275
+ ...(body.reasoningEffort ? { reasoningEffort: body.reasoningEffort } : {}),
1276
+ }, true);
1277
+ const resultFrame = await dispatched.resultPromise;
1278
+ if (!resultFrame?.ok) {
1279
+ throw {
1280
+ code: resultFrame?.error?.code || 'INTERNAL_ERROR',
1281
+ message: resultFrame?.error?.message || 'Model selection command failed',
1282
+ };
1283
+ }
1284
+ const result = objectValue(resultFrame.result);
1285
+ const selected = normalizeModelSelection(result?.selected);
1286
+ if (!selected)
1287
+ throw { code: 'INTERNAL_ERROR', message: 'Node returned an invalid model selection' };
1288
+ reply.send({ selected });
1289
+ }
1290
+ catch (err) {
1291
+ const statusCode = httpError[err.code] ?? 500;
1292
+ return sendError(reply, statusCode, err.code || 'INTERNAL_ERROR', err.message || 'Command failed');
1293
+ }
1294
+ });
1295
+ app.patch('/v1/nodes/:nodeId/sessions/:sessionId', async (request, reply) => {
1296
+ const params = request.params;
1297
+ const body = requireBody(request, renameSessionBody);
1298
+ const authUser = await getAuthUserFromRequest(request.headers.authorization);
1299
+ if (!authUser) {
1300
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
1301
+ }
1302
+ try {
1303
+ const dispatched = await dispatchCommand(authUser, params.nodeId, params.sessionId, 'session.rename', body.requestId, { title: body.title }, true);
1304
+ const resultFrame = await dispatched.resultPromise;
1305
+ if (!resultFrame?.ok) {
1306
+ throw {
1307
+ code: resultFrame?.error?.code || 'INTERNAL_ERROR',
1308
+ message: resultFrame?.error?.message || 'Session rename command failed',
1309
+ };
1310
+ }
1311
+ const result = objectValue(resultFrame.result);
1312
+ const title = typeof result?.title === 'string' ? result.title.trim() : '';
1313
+ const seq = typeof result?.seq === 'number' ? result.seq : undefined;
1314
+ if (!title)
1315
+ throw { code: 'INTERNAL_ERROR', message: 'Node returned an invalid session title' };
1316
+ sqlite.prepare('UPDATE session_index SET title = ?, updated_at = ? WHERE node_id = ? AND session_id = ?')
1317
+ .run(title, nowMs(), params.nodeId, params.sessionId);
1318
+ reply.send({ title, ...(seq === undefined ? {} : { seq }) });
1319
+ }
1320
+ catch (err) {
1321
+ const statusCode = httpError[err.code] ?? 500;
1322
+ return sendError(reply, statusCode, err.code || 'INTERNAL_ERROR', err.message || 'Command failed');
1323
+ }
1324
+ });
1325
+ app.post('/v1/nodes/:nodeId/sessions/:sessionId/followup', async (request, reply) => {
1326
+ const params = request.params;
1327
+ const body = requireBody(request, followupBody);
1328
+ const authUser = await getAuthUserFromRequest(request.headers.authorization);
1329
+ if (!authUser) {
1330
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
1331
+ }
1332
+ try {
1333
+ const result = await dispatchCommand(authUser, params.nodeId, params.sessionId, 'session.followup', body.requestId, {
1334
+ content: body.content,
1335
+ });
1336
+ reply.code(202).send(result);
1337
+ }
1338
+ catch (err) {
1339
+ const statusCode = httpError[err.code] ?? 500;
1340
+ return sendError(reply, statusCode, err.code || 'INTERNAL_ERROR', err.message || 'Command failed');
1341
+ }
1342
+ });
1343
+ app.post('/v1/nodes/:nodeId/sessions/:sessionId/steer', async (request, reply) => {
1344
+ const params = request.params;
1345
+ const body = requireBody(request, steerBody);
1346
+ const authUser = await getAuthUserFromRequest(request.headers.authorization);
1347
+ if (!authUser) {
1348
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
1349
+ }
1350
+ try {
1351
+ const result = await dispatchCommand(authUser, params.nodeId, params.sessionId, 'session.steer', body.requestId, {
1352
+ instruction: body.instruction,
1353
+ });
1354
+ reply.code(202).send(result);
1355
+ }
1356
+ catch (err) {
1357
+ const statusCode = httpError[err.code] ?? 500;
1358
+ return sendError(reply, statusCode, err.code || 'INTERNAL_ERROR', err.message || 'Command failed');
1359
+ }
1360
+ });
1361
+ app.post('/v1/nodes/:nodeId/sessions/:sessionId/stop', async (request, reply) => {
1362
+ const params = request.params;
1363
+ const body = requireBody(request, stopBody);
1364
+ const authUser = await getAuthUserFromRequest(request.headers.authorization);
1365
+ if (!authUser) {
1366
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
1367
+ }
1368
+ try {
1369
+ const result = await dispatchCommand(authUser, params.nodeId, params.sessionId, 'session.stop', body.requestId, {
1370
+ reason: body.reason || 'user_stop',
1371
+ });
1372
+ reply.code(202).send(result);
1373
+ }
1374
+ catch (err) {
1375
+ const statusCode = httpError[err.code] ?? 500;
1376
+ return sendError(reply, statusCode, err.code || 'INTERNAL_ERROR', err.message || 'Command failed');
1377
+ }
1378
+ });
1379
+ app.post('/v1/approvals/:approvalId/respond', async (request, reply) => {
1380
+ const approvalId = request.params.approvalId;
1381
+ const body = requireBody(request, approvalRespondBody);
1382
+ const authUser = await getAuthUserFromRequest(request.headers.authorization);
1383
+ if (!authUser) {
1384
+ return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
1385
+ }
1386
+ const row = sqlite.prepare('SELECT * FROM approvals WHERE id = ?').get(approvalId);
1387
+ if (!row) {
1388
+ return sendError(reply, httpError.APPROVAL_NOT_FOUND, 'APPROVAL_NOT_FOUND', 'Approval not found');
1389
+ }
1390
+ if (row.user_id !== authUser.userId) {
1391
+ return sendError(reply, httpError.FORBIDDEN, 'FORBIDDEN', 'Approval belongs to another user');
1392
+ }
1393
+ const existingCommand = sqlite
1394
+ .prepare('SELECT * FROM commands WHERE request_id = ? AND user_id = ?')
1395
+ .get(body.requestId, authUser.userId);
1396
+ if (existingCommand) {
1397
+ if (existingCommand.action !== 'approval.respond'
1398
+ || existingCommand.node_id !== row.node_id
1399
+ || existingCommand.session_id !== row.session_id
1400
+ || row.response !== body.response) {
1401
+ return sendError(reply, httpError.INVALID_REQUEST, 'INVALID_REQUEST', 'requestId was already used for another write');
1402
+ }
1403
+ return reply.send({
1404
+ ok: true,
1405
+ approvalId,
1406
+ status: row.status,
1407
+ commandId: existingCommand.id,
1408
+ requestId: body.requestId,
1409
+ duplicate: true,
1410
+ });
1411
+ }
1412
+ if (row.status !== 'pending') {
1413
+ return sendError(reply, httpError.APPROVAL_ALREADY_RESOLVED, 'APPROVAL_ALREADY_RESOLVED', 'Approval already resolved');
1414
+ }
1415
+ if (row.expires_at <= nowMs()) {
1416
+ return sendError(reply, httpError.APPROVAL_EXPIRED, 'APPROVAL_EXPIRED', 'Approval expired');
1417
+ }
1418
+ let dispatched;
1419
+ try {
1420
+ dispatched = await dispatchCommand(authUser, row.node_id, row.session_id, 'approval.respond', body.requestId, { approvalId, response: body.response });
1421
+ }
1422
+ catch (err) {
1423
+ const statusCode = httpError[err.code] ?? 500;
1424
+ return sendError(reply, statusCode, err.code || 'INTERNAL_ERROR', err.message || 'Command failed');
1425
+ }
1426
+ const status = body.response === 'allow_once' ? 'approved' : 'denied';
1427
+ sqlite
1428
+ .prepare('UPDATE approvals SET status = ?, resolved_at = ?, response = ? WHERE id = ?')
1429
+ .run(status, nowMs(), body.response, approvalId);
1430
+ broadcastToSession(row.node_id, row.session_id, {
1431
+ v: 1,
1432
+ kind: 'approval.resolved',
1433
+ approvalId,
1434
+ status,
1435
+ });
1436
+ reply.send({
1437
+ ok: true,
1438
+ approvalId,
1439
+ status,
1440
+ commandId: dispatched.commandId,
1441
+ requestId: body.requestId,
1442
+ duplicate: false,
1443
+ });
1444
+ });
1445
+ app.get('/v1/node/connect', { websocket: true }, (connection, request) => {
1446
+ const socket = connection;
1447
+ const auth = parseNodeAuth(request.headers.authorization);
1448
+ if (!auth) {
1449
+ socket.close(1008, 'UNAUTHORIZED');
1450
+ return;
1451
+ }
1452
+ const nodeRow = ensureNodeMeta(auth.nodeId);
1453
+ if (!nodeRow) {
1454
+ socket.close(1008, 'NODE_NOT_FOUND');
1455
+ return;
1456
+ }
1457
+ const credentialHash = hashText(auth.nodeSecret);
1458
+ if (nodeRow.credential_hash !== credentialHash) {
1459
+ socket.close(1008, 'UNAUTHORIZED');
1460
+ return;
1461
+ }
1462
+ if (nodeRow.revoked_at) {
1463
+ socket.close(4403, 'NODE_REVOKED');
1464
+ return;
1465
+ }
1466
+ let acceptedNode = false;
1467
+ const now = nowMs();
1468
+ const state = {
1469
+ ws: socket,
1470
+ nodeId: auth.nodeId,
1471
+ userId: nodeRow.owner_user_id,
1472
+ capabilities: new Set(),
1473
+ protocolMin: 1,
1474
+ protocolMax: 1,
1475
+ lastSeenAt: now,
1476
+ };
1477
+ const closeExisting = nodeConnections.get(auth.nodeId);
1478
+ if (closeExisting) {
1479
+ closeExisting.ws.close(4009, 'NODE_REPLACED');
1480
+ }
1481
+ nodeConnections.set(auth.nodeId, state);
1482
+ socket.on('close', () => {
1483
+ const current = nodeConnections.get(auth.nodeId);
1484
+ if (current === state) {
1485
+ nodeConnections.delete(auth.nodeId);
1486
+ sqlite.prepare('UPDATE nodes SET last_seen_at = ? WHERE id = ?').run(nowMs(), auth.nodeId);
1487
+ }
1488
+ });
1489
+ socket.on('message', (raw) => {
1490
+ const text = raw.toString();
1491
+ if (!text)
1492
+ return;
1493
+ let payload;
1494
+ try {
1495
+ payload = JSON.parse(text);
1496
+ }
1497
+ catch {
1498
+ socket.close(1007, 'INVALID_JSON');
1499
+ return;
1500
+ }
1501
+ state.lastSeenAt = nowMs();
1502
+ if (payload?.v !== 1) {
1503
+ sendJson(socket, { v: 1, kind: 'error', code: 'PROTOCOL_UNSUPPORTED', message: 'unsupported protocol version' });
1504
+ return;
1505
+ }
1506
+ if (!acceptedNode) {
1507
+ if (payload?.v !== 1 ||
1508
+ payload?.kind !== 'node.hello' ||
1509
+ payload?.protocolMin > 1 ||
1510
+ payload?.protocolMax < 1 ||
1511
+ !payload?.node ||
1512
+ payload.node.id !== auth.nodeId) {
1513
+ socket.close(4400, 'PROTOCOL_UNSUPPORTED');
1514
+ return;
1515
+ }
1516
+ state.protocolMin = payload.protocolMin;
1517
+ state.protocolMax = payload.protocolMax;
1518
+ state.capabilities = new Set(Array.isArray(payload.capabilities) ? payload.capabilities : []);
1519
+ const persistedNode = ensureNodeMeta(auth.nodeId);
1520
+ const runtimeNode = payload.node;
1521
+ const runtimeValue = (key, fallback) => typeof runtimeNode[key] === 'string' && runtimeNode[key]
1522
+ ? runtimeNode[key]
1523
+ : fallback;
1524
+ sqlite
1525
+ .prepare('UPDATE nodes SET name = ?, platform = ?, arch = ?, plugin_version = ?, dsh_version = ?, last_seen_at = ? WHERE id = ?')
1526
+ .run(runtimeValue('name', String(persistedNode?.name || 'DSH Node')), runtimeValue('platform', String(persistedNode?.platform || 'unknown')), runtimeValue('arch', String(persistedNode?.arch || 'unknown')), runtimeValue('pluginVersion', String(persistedNode?.plugin_version || 'unknown')), runtimeValue('dshVersion', String(persistedNode?.dsh_version || 'unknown')), nowMs(), auth.nodeId);
1527
+ acceptedNode = true;
1528
+ sendJson(socket, {
1529
+ v: 1,
1530
+ kind: 'node.hello.ack',
1531
+ nodeId: auth.nodeId,
1532
+ });
1533
+ return;
1534
+ }
1535
+ if (payload.kind === 'node.heartbeat') {
1536
+ sqlite.prepare('UPDATE nodes SET last_seen_at = ? WHERE id = ?').run(nowMs(), auth.nodeId);
1537
+ sendJson(socket, { v: 1, kind: 'node.heartbeat.ack' });
1538
+ return;
1539
+ }
1540
+ if (payload.kind === 'session.event') {
1541
+ if (typeof payload.sourceSeq !== 'number' || !payload.sessionId || typeof payload.nodeId !== 'string') {
1542
+ sendJson(socket, { v: 1, kind: 'error', code: 'INVALID_REQUEST', message: 'invalid session event' });
1543
+ return;
1544
+ }
1545
+ if (payload.nodeId !== auth.nodeId) {
1546
+ sendJson(socket, { v: 1, kind: 'error', code: 'UNAUTHORIZED', message: 'nodeId mismatch' });
1547
+ return;
1548
+ }
1549
+ const evt = payload;
1550
+ writeRingEvent(evt.nodeId, evt.sessionId, {
1551
+ v: 1,
1552
+ kind: 'session.event',
1553
+ nodeId: evt.nodeId,
1554
+ sessionId: evt.sessionId,
1555
+ sourceSeq: evt.sourceSeq,
1556
+ event: evt.event,
1557
+ });
1558
+ broadcastToSession(evt.nodeId, evt.sessionId, {
1559
+ v: 1,
1560
+ kind: 'session.event',
1561
+ nodeId: evt.nodeId,
1562
+ sessionId: evt.sessionId,
1563
+ sourceSeq: evt.sourceSeq,
1564
+ event: evt.event,
1565
+ createdAt: nowMs(),
1566
+ });
1567
+ return;
1568
+ }
1569
+ if (payload.kind === 'command.ack') {
1570
+ const commandId = payload.commandId;
1571
+ const status = payload.status || 'acked';
1572
+ if (commandId) {
1573
+ const command = sqlite
1574
+ .prepare('SELECT node_id FROM commands WHERE id = ?')
1575
+ .get(commandId);
1576
+ if (!command || command.node_id !== auth.nodeId) {
1577
+ sendJson(socket, { v: 1, kind: 'error', code: 'UNAUTHORIZED', message: 'commandId does not belong to node' });
1578
+ return;
1579
+ }
1580
+ const errorCode = payload.errorCode ? String(payload.errorCode) : null;
1581
+ sqlite
1582
+ .prepare('UPDATE commands SET status = ?, acked_at = ?, error_code = ? WHERE id = ?')
1583
+ .run(status, nowMs(), errorCode, commandId);
1584
+ const timeout = pendingCommandTimeout.get(commandId);
1585
+ if (timeout) {
1586
+ clearTimeout(timeout);
1587
+ pendingCommandTimeout.delete(commandId);
1588
+ }
1589
+ }
1590
+ return;
1591
+ }
1592
+ if (payload.kind === 'command.result') {
1593
+ const frame = payload;
1594
+ if (!frame.commandId || typeof frame.ok !== 'boolean') {
1595
+ sendJson(socket, { v: 1, kind: 'error', code: 'INVALID_REQUEST', message: 'invalid command result' });
1596
+ return;
1597
+ }
1598
+ const command = sqlite
1599
+ .prepare('SELECT node_id, action FROM commands WHERE id = ?')
1600
+ .get(frame.commandId);
1601
+ if (!command || command.node_id !== auth.nodeId) {
1602
+ sendJson(socket, { v: 1, kind: 'error', code: 'UNAUTHORIZED', message: 'commandId does not belong to node' });
1603
+ return;
1604
+ }
1605
+ const errorCode = frame.ok ? null : String(frame.error?.code || 'INTERNAL_ERROR');
1606
+ const persistedResult = minimalPersistedCommandResult(command.action, frame);
1607
+ sqlite
1608
+ .prepare('UPDATE commands SET status = ?, acked_at = COALESCE(acked_at, ?), error_code = ?, result_json = ? WHERE id = ?')
1609
+ .run(frame.ok ? 'completed' : 'failed', nowMs(), errorCode, persistedResult ? JSON.stringify(persistedResult) : null, frame.commandId);
1610
+ const ackTimeout = pendingCommandTimeout.get(frame.commandId);
1611
+ if (ackTimeout) {
1612
+ clearTimeout(ackTimeout);
1613
+ pendingCommandTimeout.delete(frame.commandId);
1614
+ }
1615
+ const pending = pendingCommandResults.get(frame.commandId);
1616
+ if (pending) {
1617
+ clearTimeout(pending.timeout);
1618
+ pendingCommandResults.delete(frame.commandId);
1619
+ pending.resolve(frame);
1620
+ }
1621
+ return;
1622
+ }
1623
+ if (payload.kind === 'approval.request') {
1624
+ const approval = payload.approval;
1625
+ if (!approval?.approvalId ||
1626
+ !approval?.sessionId ||
1627
+ !approval?.toolCallId ||
1628
+ typeof approval.expiresAt !== 'number') {
1629
+ sendJson(socket, { v: 1, kind: 'error', code: 'INVALID_REQUEST', message: 'invalid approval request' });
1630
+ return;
1631
+ }
1632
+ if (approval.nodeId && approval.nodeId !== auth.nodeId) {
1633
+ return;
1634
+ }
1635
+ const status = sqlite
1636
+ .prepare('SELECT * FROM approvals WHERE id = ?')
1637
+ .get(approval.approvalId);
1638
+ if (!status) {
1639
+ sqlite
1640
+ .prepare(`INSERT INTO approvals (
1641
+ id, user_id, node_id, session_id, tool_call_id, status, expires_at, created_at, request_payload
1642
+ ) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?)`)
1643
+ .run(approval.approvalId, nodeRow.owner_user_id, auth.nodeId, approval.sessionId, approval.toolCallId, approval.expiresAt, nowMs(), JSON.stringify(approval));
1644
+ }
1645
+ if (!status || (status.status === 'pending' && status.expires_at > nowMs())) {
1646
+ broadcastToSession(auth.nodeId, approval.sessionId, approvalRequestFrame(auth.nodeId, approval.sessionId, approval));
1647
+ }
1648
+ return;
1649
+ }
1650
+ sendJson(socket, {
1651
+ v: 1,
1652
+ kind: 'error',
1653
+ code: 'INVALID_REQUEST',
1654
+ message: 'unknown frame',
1655
+ });
1656
+ });
1657
+ });
1658
+ app.get('/v1/realtime', { websocket: true }, async (connection, request) => {
1659
+ const socket = connection;
1660
+ const auth = await getAuthUserFromRequest(request.headers.authorization);
1661
+ if (!auth) {
1662
+ socket.close(1008, 'UNAUTHORIZED');
1663
+ return;
1664
+ }
1665
+ const state = {
1666
+ ws: socket,
1667
+ userId: auth.userId,
1668
+ deviceId: auth.deviceId,
1669
+ subscriptions: new Set(),
1670
+ };
1671
+ mobileConnections.add(state);
1672
+ socket.on('close', () => {
1673
+ mobileConnections.delete(state);
1674
+ for (const sub of state.subscriptions) {
1675
+ const targets = sessionSubscribers.get(sub);
1676
+ if (!targets)
1677
+ continue;
1678
+ targets.delete(socket);
1679
+ if (targets.size === 0) {
1680
+ sessionSubscribers.delete(sub);
1681
+ }
1682
+ }
1683
+ });
1684
+ const handleMobileMessage = (raw) => {
1685
+ const text = raw.toString();
1686
+ let payload;
1687
+ try {
1688
+ payload = JSON.parse(text);
1689
+ }
1690
+ catch {
1691
+ sendJson(socket, { v: 1, kind: 'error', code: 'INVALID_REQUEST', message: 'Invalid JSON' });
1692
+ return;
1693
+ }
1694
+ if (payload?.v !== 1) {
1695
+ sendJson(socket, { v: 1, kind: 'error', code: 'PROTOCOL_UNSUPPORTED', message: 'unsupported protocol version' });
1696
+ return;
1697
+ }
1698
+ if (payload.kind === 'subscribe') {
1699
+ const body = wsSubscribeBody.safeParse(payload);
1700
+ if (!body.success) {
1701
+ sendJson(socket, { v: 1, kind: 'error', code: 'INVALID_REQUEST', message: 'Invalid subscribe payload' });
1702
+ return;
1703
+ }
1704
+ const { nodeId, sessionId } = body.data;
1705
+ const node = ensureNodeMeta(nodeId);
1706
+ if (!node) {
1707
+ sendJson(socket, { v: 1, kind: 'error', code: 'NODE_NOT_FOUND', message: 'Node not found' });
1708
+ return;
1709
+ }
1710
+ if (node.owner_user_id !== auth.userId) {
1711
+ sendJson(socket, { v: 1, kind: 'error', code: 'FORBIDDEN', message: 'Node belongs to another user' });
1712
+ return;
1713
+ }
1714
+ const subKey = keySession(nodeId, sessionId);
1715
+ state.subscriptions.add(subKey);
1716
+ const targets = sessionSubscribers.get(subKey) ?? new Set();
1717
+ targets.add(socket);
1718
+ sessionSubscribers.set(subKey, targets);
1719
+ const replay = getSessionReplay(nodeId, sessionId, -1);
1720
+ sendJson(socket, {
1721
+ v: 1,
1722
+ kind: 'subscribe.ok',
1723
+ requestId: body.data.requestId,
1724
+ nodeId,
1725
+ sessionId,
1726
+ });
1727
+ const pendingApprovals = sqlite
1728
+ .prepare(`SELECT request_payload FROM approvals
1729
+ WHERE user_id = ? AND node_id = ? AND session_id = ? AND status = 'pending' AND expires_at > ?
1730
+ ORDER BY created_at ASC`)
1731
+ .all(auth.userId, nodeId, sessionId, nowMs());
1732
+ for (const row of pendingApprovals) {
1733
+ try {
1734
+ const approval = JSON.parse(row.request_payload);
1735
+ sendJson(socket, approvalRequestFrame(nodeId, sessionId, approval));
1736
+ }
1737
+ catch {
1738
+ // A malformed legacy row is ignored instead of breaking subscription.
1739
+ }
1740
+ }
1741
+ if (replay && replay.length > 0) {
1742
+ sendJson(socket, {
1743
+ v: 1,
1744
+ kind: 'session.sync',
1745
+ nodeId,
1746
+ sessionId,
1747
+ afterSourceSeq: replay[0].sourceSeq - 1,
1748
+ events: replay,
1749
+ });
1750
+ }
1751
+ return;
1752
+ }
1753
+ if (payload.kind === 'session.sync') {
1754
+ const body = wsSessionSyncBody.safeParse(payload);
1755
+ if (!body.success) {
1756
+ sendJson(socket, { v: 1, kind: 'error', code: 'INVALID_REQUEST', message: 'Invalid sync payload' });
1757
+ return;
1758
+ }
1759
+ const { nodeId, sessionId, afterSourceSeq } = body.data;
1760
+ const key = keySession(nodeId, sessionId);
1761
+ if (!state.subscriptions.has(key)) {
1762
+ sendJson(socket, { v: 1, kind: 'error', code: 'FORBIDDEN', message: 'Not subscribed' });
1763
+ return;
1764
+ }
1765
+ const replay = getSessionReplay(nodeId, sessionId, afterSourceSeq);
1766
+ if (replay === null) {
1767
+ sendJson(socket, {
1768
+ v: 1,
1769
+ kind: 'snapshot.required',
1770
+ nodeId,
1771
+ sessionId,
1772
+ message: 'Replay window missing',
1773
+ });
1774
+ }
1775
+ else {
1776
+ sendJson(socket, {
1777
+ v: 1,
1778
+ kind: 'session.sync',
1779
+ nodeId,
1780
+ sessionId,
1781
+ afterSourceSeq,
1782
+ events: replay,
1783
+ });
1784
+ }
1785
+ return;
1786
+ }
1787
+ sendJson(socket, { v: 1, kind: 'error', code: 'INVALID_REQUEST', message: 'Unsupported realtime frame' });
1788
+ };
1789
+ socket.on('message', handleMobileMessage);
1790
+ });
1791
+ app.setErrorHandler((error, request, reply) => {
1792
+ if (error instanceof z.ZodError) {
1793
+ return sendError(reply, 400, 'INVALID_REQUEST', 'Validation failed', { issues: error.issues });
1794
+ }
1795
+ app.log.error(error);
1796
+ reply.code(500).send({ code: 'INTERNAL_ERROR', message: 'Unexpected error' });
1797
+ });
1798
+ setInterval(cleanupOfflineNodes, 5_000);
1799
+ setInterval(cleanupStaleRates, 60_000);
1800
+ setInterval(cleanupCommandMetadata, 60 * 60_000);
1801
+ app.listen({ port: PORT, host: HOST }).then(() => {
1802
+ app.log.info(`dsh-hub listening on ${HOST}:${PORT}`);
1803
+ });
1804
+ //# sourceMappingURL=index.js.map