@boluo-ai/relay 0.1.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.
package/dist/cli.js ADDED
@@ -0,0 +1,2035 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/ids.ts
4
+ import { randomUUID } from "crypto";
5
+ function mintId(prefix) {
6
+ return prefix + randomUUID().replace(/-/g, "").slice(0, 16);
7
+ }
8
+ function mintDeviceId() {
9
+ return mintId("dev_");
10
+ }
11
+ function mintUserId() {
12
+ return mintId("usr_");
13
+ }
14
+
15
+ // src/cli.ts
16
+ import { existsSync, statSync, readFileSync } from "fs";
17
+ import { extname, join, normalize, resolve as resolvePath } from "path";
18
+ import { fileURLToPath } from "url";
19
+ import { Command } from "commander";
20
+ import { serve } from "@hono/node-server";
21
+
22
+ // src/auth-apikey.ts
23
+ import { Buffer } from "buffer";
24
+ import { timingSafeEqual } from "crypto";
25
+
26
+ // src/auth-deps.ts
27
+ var RELAY_PROTOCOL_VERSION = 1;
28
+ function authError(code, message) {
29
+ return { type: "auth_error", code, message };
30
+ }
31
+
32
+ // src/auth-apikey.ts
33
+ function safeEqual(a, b) {
34
+ if (a.length !== b.length) return false;
35
+ const ab = Buffer.from(a);
36
+ const bb = Buffer.from(b);
37
+ return timingSafeEqual(ab, bb);
38
+ }
39
+ function createApiKeyProvider(opts) {
40
+ const apiKey = opts.apiKey;
41
+ return {
42
+ authenticate(msg, deps) {
43
+ if (apiKey === "") {
44
+ return {
45
+ ok: false,
46
+ result: authError("apikey_disabled", "apikey auth not configured")
47
+ };
48
+ }
49
+ if (!msg.device) {
50
+ return {
51
+ ok: false,
52
+ result: authError("missing_device", "apikey auth requires device payload")
53
+ };
54
+ }
55
+ const key = msg.auth.key ?? "";
56
+ if (!safeEqual(key, apiKey)) {
57
+ return {
58
+ ok: false,
59
+ result: authError("unauthorized", "invalid api key")
60
+ };
61
+ }
62
+ const user = deps.upsertUserFromProvider({
63
+ provider: "apikey",
64
+ login: "apikey-user",
65
+ email: ""
66
+ });
67
+ const deviceId = deps.newDeviceId();
68
+ deps.upsertDevice({
69
+ device_id: deviceId,
70
+ user_id: user.user_id,
71
+ device: msg.device
72
+ });
73
+ return {
74
+ ok: true,
75
+ result: {
76
+ type: "auth_ok",
77
+ deviceId,
78
+ userId: user.user_id,
79
+ user: {
80
+ id: user.user_id,
81
+ login: user.login,
82
+ email: user.email,
83
+ provider: user.provider
84
+ },
85
+ version: RELAY_PROTOCOL_VERSION
86
+ }
87
+ };
88
+ }
89
+ };
90
+ }
91
+
92
+ // src/challenge.ts
93
+ import { Buffer as Buffer2 } from "buffer";
94
+ import { randomBytes, randomUUID as randomUUID2, verify as cryptoVerify, constants } from "crypto";
95
+ var DEFAULT_NONCE_BYTES = 32;
96
+ var DEFAULT_PENDING_TTL_MS = 6e4;
97
+ function spkiB64ToPem(b64) {
98
+ const chunks = [];
99
+ for (let i = 0; i < b64.length; i += 64) {
100
+ chunks.push(b64.slice(i, i + 64));
101
+ }
102
+ return `-----BEGIN PUBLIC KEY-----
103
+ ${chunks.join("\n")}
104
+ -----END PUBLIC KEY-----`;
105
+ }
106
+ function createChallengeProvider(opts = {}) {
107
+ const nonceBytes = opts.nonceBytes ?? DEFAULT_NONCE_BYTES;
108
+ const ttl = opts.pendingTtlMs ?? DEFAULT_PENDING_TTL_MS;
109
+ const pending = /* @__PURE__ */ new Map();
110
+ return {
111
+ start(msg, deps) {
112
+ const deviceId = msg.auth.deviceId;
113
+ if (!deviceId) {
114
+ return { error: authError("invalid_request", "deviceId required") };
115
+ }
116
+ const row = deps.getDeviceById(deviceId);
117
+ if (!row) {
118
+ return { error: authError("unknown_device", "device not registered") };
119
+ }
120
+ const nonceB64 = randomBytes(nonceBytes).toString("base64");
121
+ const pendingKey = randomUUID2();
122
+ pending.set(pendingKey, {
123
+ nonceB64,
124
+ deviceId,
125
+ userId: row.user_id,
126
+ publicKey: row.public_key,
127
+ expiresAt: deps.now() + ttl
128
+ });
129
+ return { nonceB64, pendingKey, deviceId, userId: row.user_id };
130
+ },
131
+ verify(pendingKey, response, deps) {
132
+ const entry = pending.get(pendingKey);
133
+ if (!entry) {
134
+ return {
135
+ ok: false,
136
+ result: authError("unknown_challenge", "no pending challenge for key")
137
+ };
138
+ }
139
+ pending.delete(pendingKey);
140
+ if (entry.expiresAt <= deps.now()) {
141
+ return {
142
+ ok: false,
143
+ result: authError("challenge_expired", "challenge nonce expired")
144
+ };
145
+ }
146
+ let valid = false;
147
+ try {
148
+ valid = cryptoVerify(
149
+ "RSA-SHA256",
150
+ Buffer2.from(entry.nonceB64, "base64"),
151
+ {
152
+ key: spkiB64ToPem(entry.publicKey),
153
+ format: "pem",
154
+ padding: constants.RSA_PKCS1_PADDING
155
+ },
156
+ Buffer2.from(response.signature, "base64")
157
+ );
158
+ } catch {
159
+ valid = false;
160
+ }
161
+ if (!valid) {
162
+ return {
163
+ ok: false,
164
+ result: authError("bad_signature", "challenge signature invalid")
165
+ };
166
+ }
167
+ const user = deps.upsertUserFromProvider({
168
+ provider: "challenge",
169
+ login: entry.userId,
170
+ email: ""
171
+ });
172
+ return {
173
+ ok: true,
174
+ result: {
175
+ type: "auth_ok",
176
+ deviceId: entry.deviceId,
177
+ userId: entry.userId,
178
+ user: {
179
+ id: entry.userId,
180
+ login: user.login,
181
+ email: user.email,
182
+ provider: user.provider
183
+ },
184
+ version: RELAY_PROTOCOL_VERSION
185
+ }
186
+ };
187
+ }
188
+ };
189
+ }
190
+
191
+ // src/auth-github.ts
192
+ function createGithubProvider(opts = {}) {
193
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
194
+ const oauthEnabled = () => !!(opts.clientId && opts.clientId !== "" && opts.clientSecret && opts.clientSecret !== "");
195
+ async function fetchGithubUser(token) {
196
+ const res = await fetchImpl("https://api.github.com/user", {
197
+ headers: {
198
+ Authorization: `Bearer ${token}`,
199
+ Accept: "application/vnd.github+json",
200
+ "User-Agent": "boluo-relay"
201
+ }
202
+ });
203
+ if (!res.ok) {
204
+ const body = await safeText(res);
205
+ return { ok: false, status: res.status, body };
206
+ }
207
+ const user = await res.json();
208
+ return { ok: true, user };
209
+ }
210
+ function finalize(user, msg, deps) {
211
+ if (!msg.device) {
212
+ return {
213
+ ok: false,
214
+ result: authError("missing_device", "github auth requires device payload")
215
+ };
216
+ }
217
+ const login = (user.login ?? "").trim();
218
+ if (login === "") {
219
+ return {
220
+ ok: false,
221
+ result: authError("github_error", "github user payload missing login")
222
+ };
223
+ }
224
+ const stored = deps.upsertUserFromProvider({
225
+ provider: "github",
226
+ login,
227
+ email: user.email ?? ""
228
+ });
229
+ const deviceId = deps.newDeviceId();
230
+ deps.upsertDevice({
231
+ device_id: deviceId,
232
+ user_id: stored.user_id,
233
+ device: msg.device
234
+ });
235
+ return {
236
+ ok: true,
237
+ result: {
238
+ type: "auth_ok",
239
+ deviceId,
240
+ userId: stored.user_id,
241
+ user: {
242
+ id: stored.user_id,
243
+ login: stored.login,
244
+ email: stored.email,
245
+ provider: stored.provider
246
+ },
247
+ version: RELAY_PROTOCOL_VERSION
248
+ }
249
+ };
250
+ }
251
+ return {
252
+ oauthEnabled,
253
+ async authenticateOAuth(msg, deps) {
254
+ if (!oauthEnabled()) {
255
+ return {
256
+ ok: false,
257
+ result: authError("oauth_disabled", "github_oauth not configured")
258
+ };
259
+ }
260
+ if (!msg.device) {
261
+ return {
262
+ ok: false,
263
+ result: authError("missing_device", "github auth requires device payload")
264
+ };
265
+ }
266
+ try {
267
+ const tokenRes = await fetchImpl("https://github.com/login/oauth/access_token", {
268
+ method: "POST",
269
+ headers: {
270
+ "Content-Type": "application/json",
271
+ Accept: "application/json",
272
+ "User-Agent": "boluo-relay"
273
+ },
274
+ body: JSON.stringify({
275
+ client_id: opts.clientId,
276
+ client_secret: opts.clientSecret,
277
+ code: msg.auth.code,
278
+ state: msg.auth.state
279
+ })
280
+ });
281
+ if (!tokenRes.ok) {
282
+ return {
283
+ ok: false,
284
+ result: authError(
285
+ "github_error",
286
+ `oauth token exchange failed (status ${tokenRes.status})`
287
+ )
288
+ };
289
+ }
290
+ const tokenJson = await tokenRes.json();
291
+ if (!tokenJson.access_token) {
292
+ return {
293
+ ok: false,
294
+ result: authError(
295
+ "github_error",
296
+ tokenJson.error_description ?? tokenJson.error ?? "missing access_token"
297
+ )
298
+ };
299
+ }
300
+ const u = await fetchGithubUser(tokenJson.access_token);
301
+ if (!u.ok) {
302
+ return {
303
+ ok: false,
304
+ result: u.status === 401 ? authError("unauthorized", "github token rejected") : authError("github_error", `github /user ${u.status}: ${u.body}`)
305
+ };
306
+ }
307
+ return finalize(u.user, msg, deps);
308
+ } catch (err) {
309
+ return {
310
+ ok: false,
311
+ result: authError("github_error", `github oauth error: ${stringifyErr(err)}`)
312
+ };
313
+ }
314
+ },
315
+ async authenticatePAT(msg, deps) {
316
+ if (!msg.device) {
317
+ return {
318
+ ok: false,
319
+ result: authError("missing_device", "github auth requires device payload")
320
+ };
321
+ }
322
+ try {
323
+ const u = await fetchGithubUser(msg.auth.token);
324
+ if (!u.ok) {
325
+ return {
326
+ ok: false,
327
+ result: u.status === 401 ? authError("unauthorized", "github token rejected") : authError("github_error", `github /user ${u.status}: ${u.body}`)
328
+ };
329
+ }
330
+ return finalize(u.user, msg, deps);
331
+ } catch (err) {
332
+ return {
333
+ ok: false,
334
+ result: authError("github_error", `github pat error: ${stringifyErr(err)}`)
335
+ };
336
+ }
337
+ }
338
+ };
339
+ }
340
+ async function safeText(res) {
341
+ try {
342
+ return await res.text();
343
+ } catch {
344
+ return "";
345
+ }
346
+ }
347
+ function stringifyErr(err) {
348
+ if (err instanceof Error) return err.message;
349
+ return String(err);
350
+ }
351
+
352
+ // src/open-auth.ts
353
+ import { Buffer as Buffer3 } from "buffer";
354
+ import { timingSafeEqual as timingSafeEqual2 } from "crypto";
355
+ function safeEqual2(a, b) {
356
+ if (a.length !== b.length) return false;
357
+ return timingSafeEqual2(Buffer3.from(a), Buffer3.from(b));
358
+ }
359
+ function createOpenProvider(opts = {}) {
360
+ const sharedKey = opts.sharedKey ?? "";
361
+ return {
362
+ authenticate(msg, deps) {
363
+ if (!msg.device) {
364
+ return {
365
+ ok: false,
366
+ result: authError("missing_device", "open auth requires device payload")
367
+ };
368
+ }
369
+ if (sharedKey !== "") {
370
+ const provided = msg.auth.sharedKey ?? "";
371
+ if (!safeEqual2(provided, sharedKey)) {
372
+ return {
373
+ ok: false,
374
+ result: authError("unauthorized", "shared key mismatch")
375
+ };
376
+ }
377
+ }
378
+ const user = deps.upsertUserFromProvider({
379
+ provider: "open",
380
+ login: "open-user",
381
+ email: ""
382
+ });
383
+ const deviceId = deps.newDeviceId();
384
+ deps.upsertDevice({
385
+ device_id: deviceId,
386
+ user_id: user.user_id,
387
+ device: msg.device
388
+ });
389
+ return {
390
+ ok: true,
391
+ result: {
392
+ type: "auth_ok",
393
+ deviceId,
394
+ userId: user.user_id,
395
+ user: {
396
+ id: user.user_id,
397
+ login: user.login,
398
+ email: user.email,
399
+ provider: user.provider
400
+ },
401
+ version: RELAY_PROTOCOL_VERSION
402
+ }
403
+ };
404
+ }
405
+ };
406
+ }
407
+
408
+ // src/pairing.ts
409
+ import { randomBytes as randomBytes2 } from "crypto";
410
+ var DEFAULT_TTL_MS = 5 * 60 * 1e3;
411
+ var PairingTokenStore = class {
412
+ tokens = /* @__PURE__ */ new Map();
413
+ clock;
414
+ constructor(opts = {}) {
415
+ this.clock = opts.now ?? Date.now;
416
+ }
417
+ issue(userId, opts = {}) {
418
+ const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
419
+ const token = randomBytes2(24).toString("base64url");
420
+ const expiresAt = this.clock() + ttl;
421
+ this.tokens.set(token, {
422
+ userId,
423
+ expiresAt,
424
+ label: opts.label,
425
+ consumed: false
426
+ });
427
+ return { token, expiresAt };
428
+ }
429
+ consume(token) {
430
+ const entry = this.tokens.get(token);
431
+ if (!entry) return null;
432
+ if (entry.consumed) return null;
433
+ if (entry.expiresAt <= this.clock()) {
434
+ this.tokens.delete(token);
435
+ return null;
436
+ }
437
+ entry.consumed = true;
438
+ return { userId: entry.userId };
439
+ }
440
+ purgeExpired(now) {
441
+ let purged = 0;
442
+ for (const [token, entry] of this.tokens.entries()) {
443
+ if (entry.expiresAt <= now) {
444
+ this.tokens.delete(token);
445
+ purged += 1;
446
+ }
447
+ }
448
+ return purged;
449
+ }
450
+ /** Test helper. */
451
+ size() {
452
+ return this.tokens.size;
453
+ }
454
+ };
455
+ function createPairingProvider(store) {
456
+ return {
457
+ authenticate(msg, deps) {
458
+ if (!msg.device) {
459
+ return {
460
+ ok: false,
461
+ result: authError("missing_device", "pairing auth requires device payload")
462
+ };
463
+ }
464
+ const claim = store.consume(msg.auth.token ?? "");
465
+ if (!claim) {
466
+ return {
467
+ ok: false,
468
+ result: authError("invalid_token", "pairing token invalid or expired")
469
+ };
470
+ }
471
+ const deviceId = deps.newDeviceId();
472
+ deps.upsertDevice({
473
+ device_id: deviceId,
474
+ user_id: claim.userId,
475
+ device: msg.device
476
+ });
477
+ const user = deps.upsertUserFromProvider({
478
+ provider: "pairing",
479
+ login: claim.userId,
480
+ email: ""
481
+ });
482
+ return {
483
+ ok: true,
484
+ result: {
485
+ type: "auth_ok",
486
+ deviceId,
487
+ userId: claim.userId,
488
+ user: {
489
+ id: claim.userId,
490
+ login: user.login,
491
+ email: user.email,
492
+ provider: user.provider
493
+ },
494
+ version: RELAY_PROTOCOL_VERSION
495
+ }
496
+ };
497
+ }
498
+ };
499
+ }
500
+
501
+ // src/auth.ts
502
+ function createAuthRegistry(opts) {
503
+ const enabled = new Set(opts.enabledMethods);
504
+ const apiKeyProvider = createApiKeyProvider({
505
+ apiKey: opts.apiKey ?? ""
506
+ });
507
+ const openProvider = createOpenProvider({
508
+ sharedKey: opts.openSharedKey
509
+ });
510
+ const pairingStore = new PairingTokenStore();
511
+ const pairingProvider = createPairingProvider(pairingStore);
512
+ const githubProvider = createGithubProvider({
513
+ clientId: opts.githubClientId,
514
+ clientSecret: opts.githubClientSecret,
515
+ fetchImpl: opts.fetchImpl
516
+ });
517
+ const challengeProvider = createChallengeProvider();
518
+ function methodDisabled(method) {
519
+ return {
520
+ ok: false,
521
+ result: authError("method_disabled", `auth method '${method}' not enabled on this relay`)
522
+ };
523
+ }
524
+ return {
525
+ info() {
526
+ const methods = Array.from(enabled);
527
+ const response = {
528
+ type: "auth_info_response",
529
+ methods
530
+ };
531
+ if (enabled.has("github_oauth") && opts.githubClientId) {
532
+ response.githubClientId = opts.githubClientId;
533
+ }
534
+ return response;
535
+ },
536
+ async dispatch(msg, deps) {
537
+ const method = msg.auth.method;
538
+ if (!enabled.has(method)) {
539
+ return methodDisabled(method);
540
+ }
541
+ switch (method) {
542
+ case "apikey":
543
+ return apiKeyProvider.authenticate(msg, deps);
544
+ case "open":
545
+ return openProvider.authenticate(msg, deps);
546
+ case "pairing":
547
+ return pairingProvider.authenticate(
548
+ msg,
549
+ deps
550
+ );
551
+ case "github_oauth":
552
+ return githubProvider.authenticateOAuth(
553
+ msg,
554
+ deps
555
+ );
556
+ case "github_token":
557
+ return githubProvider.authenticatePAT(
558
+ msg,
559
+ deps
560
+ );
561
+ case "challenge":
562
+ return {
563
+ ok: false,
564
+ result: authError(
565
+ "use_two_step_challenge",
566
+ "use startChallenge/verifyChallenge two-step API"
567
+ )
568
+ };
569
+ default: {
570
+ const _exhaustive = method;
571
+ void _exhaustive;
572
+ return {
573
+ ok: false,
574
+ result: authError("unknown_method", `unknown auth method`)
575
+ };
576
+ }
577
+ }
578
+ },
579
+ startChallenge(msg, deps) {
580
+ if (!enabled.has("challenge")) {
581
+ return { error: authError("method_disabled", "challenge auth not enabled on this relay") };
582
+ }
583
+ const r = challengeProvider.start(msg, deps);
584
+ if ("error" in r) return { error: r.error };
585
+ return r;
586
+ },
587
+ verifyChallenge(pendingKey, response, deps) {
588
+ if (!enabled.has("challenge")) {
589
+ return {
590
+ ok: false,
591
+ result: authError("method_disabled", "challenge auth not enabled on this relay")
592
+ };
593
+ }
594
+ return challengeProvider.verify(pendingKey, response, deps);
595
+ },
596
+ pairing: pairingStore
597
+ };
598
+ }
599
+
600
+ // src/http.ts
601
+ import { Hono } from "hono";
602
+ function createHttpApp(deps) {
603
+ const app = new Hono();
604
+ app.get("/health", (c) => {
605
+ const m = deps.metrics();
606
+ return c.json({
607
+ status: "ok",
608
+ uptime: process.uptime(),
609
+ devicesOnline: m.devicesOnline,
610
+ users: m.users
611
+ });
612
+ });
613
+ app.get("/oauth/github/callback", (c) => {
614
+ const code = c.req.query("code") ?? "";
615
+ const state = c.req.query("state") ?? "";
616
+ const error = c.req.query("error") ?? "";
617
+ deps.logger.info("github oauth callback", { hasCode: code !== "", error });
618
+ const payload = JSON.stringify({ source: "boluo-oauth", code, state, error });
619
+ const html = `<!doctype html>
620
+ <html><head><meta charset="utf-8"><title>Boluo OAuth</title></head>
621
+ <body>
622
+ <script>
623
+ (function () {
624
+ var payload = ${payload};
625
+ try {
626
+ if (typeof BroadcastChannel === 'function') {
627
+ var bc = new BroadcastChannel('boluo-oauth');
628
+ bc.postMessage(payload);
629
+ bc.close();
630
+ }
631
+ } catch (e) {}
632
+ try {
633
+ if (window.opener) {
634
+ window.opener.postMessage(payload, '*');
635
+ }
636
+ } catch (e) {}
637
+ document.body.textContent = 'You can close this window.';
638
+ setTimeout(function () { try { window.close(); } catch (e) {} }, 100);
639
+ })();
640
+ </script>
641
+ <p>You can close this window.</p>
642
+ </body></html>`;
643
+ return c.html(html);
644
+ });
645
+ return app;
646
+ }
647
+
648
+ // src/logger.ts
649
+ var LEVEL_ORDER = {
650
+ debug: 10,
651
+ info: 20,
652
+ warn: 30,
653
+ error: 40
654
+ };
655
+ function createLogger(opts = {}) {
656
+ const threshold = LEVEL_ORDER[opts.level ?? "info"];
657
+ function emit(level, sink, msg, fields) {
658
+ if (LEVEL_ORDER[level] < threshold) return;
659
+ if (fields && Object.keys(fields).length > 0) {
660
+ sink(`[${level}] ${msg}`, fields);
661
+ } else {
662
+ sink(`[${level}] ${msg}`);
663
+ }
664
+ }
665
+ return {
666
+ debug: (msg, fields) => emit("debug", console.debug.bind(console), msg, fields),
667
+ info: (msg, fields) => emit("info", console.log.bind(console), msg, fields),
668
+ warn: (msg, fields) => emit("warn", console.warn.bind(console), msg, fields),
669
+ error: (msg, fields) => emit("error", console.error.bind(console), msg, fields)
670
+ };
671
+ }
672
+ var NOOP_LOGGER = {
673
+ debug: () => {
674
+ },
675
+ info: () => {
676
+ },
677
+ warn: () => {
678
+ },
679
+ error: () => {
680
+ }
681
+ };
682
+
683
+ // src/server.ts
684
+ import { WebSocket, WebSocketServer } from "ws";
685
+ var DEFAULT_PING_INTERVAL_MS = 3e4;
686
+ var DEFAULT_STALE_THRESHOLD_MS = 6e4;
687
+ var DEFAULT_STALE_CHECK_INTERVAL_MS = 1e4;
688
+ var DEFAULT_MAX_PAYLOAD = 10 * 1024 * 1024;
689
+ function createRelayServer(opts) {
690
+ const log = opts.logger ?? NOOP_LOGGER;
691
+ const pingIntervalMs = opts.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
692
+ const staleThresholdMs = opts.staleThresholdMs ?? DEFAULT_STALE_THRESHOLD_MS;
693
+ const staleCheckIntervalMs = opts.staleCheckIntervalMs ?? DEFAULT_STALE_CHECK_INTERVAL_MS;
694
+ const maxPayload = opts.maxPayload ?? DEFAULT_MAX_PAYLOAD;
695
+ const { storage, auth } = opts;
696
+ const wss = new WebSocketServer({ noServer: true, maxPayload });
697
+ const byConn = /* @__PURE__ */ new Map();
698
+ const byDeviceId = /* @__PURE__ */ new Map();
699
+ const byUserId = /* @__PURE__ */ new Map();
700
+ let pingTimer = null;
701
+ let staleTimer = null;
702
+ let closed = false;
703
+ function makeDeps() {
704
+ return {
705
+ upsertUserFromProvider({ provider, login, email }) {
706
+ const existing = storage.getUserByLogin(login);
707
+ if (existing && existing.provider === provider) {
708
+ storage.touchUser(existing.user_id, Date.now());
709
+ return {
710
+ user_id: existing.user_id,
711
+ login: existing.login,
712
+ email: existing.email,
713
+ provider: existing.provider
714
+ };
715
+ }
716
+ const row = storage.upsertUser({
717
+ user_id: existing?.user_id ?? newUserId(),
718
+ login,
719
+ provider,
720
+ email
721
+ });
722
+ return {
723
+ user_id: row.user_id,
724
+ login: row.login,
725
+ email: row.email,
726
+ provider: row.provider
727
+ };
728
+ },
729
+ upsertDevice({ device_id, user_id, device }) {
730
+ storage.upsertDevice({
731
+ device_id,
732
+ user_id,
733
+ name: device.name,
734
+ role: device.role,
735
+ kind: device.kind,
736
+ public_key: device.publicKey,
737
+ encryption_key: device.encryptionKey
738
+ });
739
+ },
740
+ getDeviceById(device_id) {
741
+ const d = storage.getDevice(device_id);
742
+ return d ? {
743
+ user_id: d.user_id,
744
+ public_key: d.public_key,
745
+ encryption_key: d.encryption_key
746
+ } : null;
747
+ },
748
+ newDeviceId,
749
+ newUserId,
750
+ now: () => Date.now()
751
+ };
752
+ }
753
+ function sendFrame(ws, frame) {
754
+ if (ws.readyState !== WebSocket.OPEN) return;
755
+ try {
756
+ ws.send(JSON.stringify(frame));
757
+ } catch (err) {
758
+ log.warn("send failed", { error: String(err) });
759
+ }
760
+ }
761
+ function sendServerError(ws, code, message, ref) {
762
+ const frame = { type: "server_error", code, message };
763
+ if (ref !== void 0) frame.ref = ref;
764
+ sendFrame(ws, frame);
765
+ }
766
+ function summarizeDevice(row, online) {
767
+ return {
768
+ deviceId: row.device_id,
769
+ userId: row.user_id,
770
+ name: row.name,
771
+ role: row.role,
772
+ kind: row.kind,
773
+ online,
774
+ lastSeen: row.last_seen,
775
+ encryptionKey: row.encryption_key
776
+ };
777
+ }
778
+ function userPeers(userId, exceptDeviceId) {
779
+ const ids = byUserId.get(userId);
780
+ if (!ids) return [];
781
+ const out = [];
782
+ for (const id of ids) {
783
+ if (id === exceptDeviceId) continue;
784
+ const c = byDeviceId.get(id);
785
+ if (c && c.ws.readyState === WebSocket.OPEN) out.push(c);
786
+ }
787
+ return out;
788
+ }
789
+ function registerConn(state) {
790
+ if (!state.deviceId || !state.userId) return;
791
+ byDeviceId.set(state.deviceId, state);
792
+ let set = byUserId.get(state.userId);
793
+ if (!set) {
794
+ set = /* @__PURE__ */ new Set();
795
+ byUserId.set(state.userId, set);
796
+ }
797
+ set.add(state.deviceId);
798
+ }
799
+ function unregisterConn(state) {
800
+ if (!state.deviceId) return;
801
+ const cur = byDeviceId.get(state.deviceId);
802
+ if (cur === state) byDeviceId.delete(state.deviceId);
803
+ if (state.userId) {
804
+ const set = byUserId.get(state.userId);
805
+ if (set && cur === state) {
806
+ set.delete(state.deviceId);
807
+ if (set.size === 0) byUserId.delete(state.userId);
808
+ }
809
+ }
810
+ }
811
+ function broadcastDeviceLifecycle(userId, exceptDeviceId, frame) {
812
+ for (const peer of userPeers(userId, exceptDeviceId)) {
813
+ sendFrame(peer.ws, frame);
814
+ }
815
+ }
816
+ function drainPending(state) {
817
+ if (!state.deviceId) return;
818
+ storage.expirePending(Date.now());
819
+ const rows = storage.drainPending(state.deviceId);
820
+ for (const row of rows) {
821
+ try {
822
+ const envelope = JSON.parse(row.envelope);
823
+ sendFrame(state.ws, envelope);
824
+ } catch (err) {
825
+ log.warn("dropping malformed pending envelope", {
826
+ deviceId: state.deviceId,
827
+ error: String(err)
828
+ });
829
+ }
830
+ }
831
+ }
832
+ function announceJoined(state) {
833
+ if (!state.userId || !state.deviceId) return;
834
+ const row = storage.getDevice(state.deviceId);
835
+ if (!row) return;
836
+ const frame = {
837
+ type: "device_joined",
838
+ device: summarizeDevice(row, true)
839
+ };
840
+ broadcastDeviceLifecycle(state.userId, state.deviceId, frame);
841
+ }
842
+ function sendDeviceListSnapshot(state) {
843
+ if (!state.userId || !state.deviceId) return;
844
+ const rows = storage.listDevicesByUser(state.userId);
845
+ const onlineIds = byUserId.get(state.userId) ?? /* @__PURE__ */ new Set();
846
+ const devices = rows.map(
847
+ (r) => summarizeDevice(
848
+ r,
849
+ r.device_id === state.deviceId || onlineIds.has(r.device_id)
850
+ )
851
+ );
852
+ sendFrame(state.ws, { type: "device_list", devices });
853
+ }
854
+ function announceLeft(state) {
855
+ if (!state.userId || !state.deviceId) return;
856
+ storage.touchDevice(state.deviceId, Date.now());
857
+ const frame = {
858
+ type: "device_left",
859
+ deviceId: state.deviceId
860
+ };
861
+ broadcastDeviceLifecycle(state.userId, state.deviceId, frame);
862
+ }
863
+ async function handleAuthMessage(state, msg) {
864
+ const deps = makeDeps();
865
+ if (msg.auth.method === "challenge") {
866
+ const r = auth.startChallenge(msg, deps);
867
+ if ("error" in r) {
868
+ sendAuthError(state, r.error);
869
+ return;
870
+ }
871
+ state.pendingChallenge = r;
872
+ const frame = { type: "auth_challenge", nonce: r.nonceB64 };
873
+ sendFrame(state.ws, frame);
874
+ return;
875
+ }
876
+ const outcome = await auth.dispatch(msg, deps);
877
+ if (!outcome.ok) {
878
+ sendAuthError(state, outcome.result);
879
+ return;
880
+ }
881
+ await completeAuth(state, outcome.result);
882
+ }
883
+ async function handleAuthResponse(state, msg) {
884
+ const pending = state.pendingChallenge;
885
+ state.pendingChallenge = void 0;
886
+ if (!pending) {
887
+ sendAuthError(state, {
888
+ type: "auth_error",
889
+ code: "no_pending_challenge",
890
+ message: "no pending challenge"
891
+ });
892
+ return;
893
+ }
894
+ const outcome = auth.verifyChallenge(pending.pendingKey, msg, makeDeps());
895
+ if (!outcome.ok) {
896
+ sendAuthError(state, outcome.result);
897
+ return;
898
+ }
899
+ await completeAuth(state, outcome.result);
900
+ }
901
+ function sendAuthError(state, err) {
902
+ sendFrame(state.ws, err);
903
+ try {
904
+ state.ws.close(1008, err.code);
905
+ } catch {
906
+ }
907
+ }
908
+ async function completeAuth(state, ok) {
909
+ state.deviceId = ok.deviceId;
910
+ state.userId = ok.userId;
911
+ state.lastSeen = Date.now();
912
+ registerConn(state);
913
+ storage.touchDevice(ok.deviceId, state.lastSeen);
914
+ sendFrame(state.ws, ok);
915
+ sendDeviceListSnapshot(state);
916
+ drainPending(state);
917
+ announceJoined(state);
918
+ }
919
+ function handlePostAuthFrame(state, frame) {
920
+ state.lastSeen = Date.now();
921
+ state.isAlive = true;
922
+ const t = frame.type;
923
+ switch (t) {
924
+ case "ping": {
925
+ const p = frame;
926
+ const pong = { type: "pong" };
927
+ if (p.id !== void 0) pong.id = p.id;
928
+ sendFrame(state.ws, pong);
929
+ return;
930
+ }
931
+ case "pong": {
932
+ return;
933
+ }
934
+ case "create_pairing_token":
935
+ case "request_pairing_token": {
936
+ if (!state.userId) {
937
+ sendServerError(state.ws, "not_authenticated", "auth required");
938
+ return;
939
+ }
940
+ const labeled = frame;
941
+ const label = labeled.type === "create_pairing_token" ? labeled.label : void 0;
942
+ const issued = auth.pairing.issue(state.userId, {
943
+ ttlMs: 5 * 6e4,
944
+ label
945
+ });
946
+ const out = {
947
+ type: "pairing_token_created",
948
+ token: issued.token,
949
+ expiresAt: issued.expiresAt
950
+ };
951
+ sendFrame(state.ws, out);
952
+ return;
953
+ }
954
+ case "update_preferences": {
955
+ if (!state.userId || !state.deviceId) {
956
+ sendServerError(state.ws, "not_authenticated", "auth required");
957
+ return;
958
+ }
959
+ const u = frame;
960
+ if (!u.preferences || typeof u.preferences !== "object") {
961
+ sendServerError(state.ws, "invalid_request", "preferences required");
962
+ return;
963
+ }
964
+ storage.setUserPreferences(state.userId, u.preferences);
965
+ const out = {
966
+ type: "preferences_updated",
967
+ preferences: u.preferences
968
+ };
969
+ for (const peer of userPeers(state.userId, state.deviceId)) {
970
+ sendFrame(peer.ws, out);
971
+ }
972
+ return;
973
+ }
974
+ case "remove_device": {
975
+ if (!state.userId || !state.deviceId) {
976
+ sendServerError(state.ws, "not_authenticated", "auth required");
977
+ return;
978
+ }
979
+ const rd = frame;
980
+ if (typeof rd.deviceId !== "string" || rd.deviceId === "") {
981
+ sendServerError(state.ws, "invalid_request", "missing deviceId");
982
+ return;
983
+ }
984
+ if (rd.deviceId === state.deviceId) {
985
+ sendServerError(state.ws, "invalid_request", "cannot remove self");
986
+ return;
987
+ }
988
+ const target = storage.getDevice(rd.deviceId);
989
+ if (!target) {
990
+ sendServerError(state.ws, "unknown_device", "target not found");
991
+ return;
992
+ }
993
+ if (target.user_id !== state.userId) {
994
+ sendServerError(state.ws, "forbidden", "cross-user");
995
+ return;
996
+ }
997
+ const live = byDeviceId.get(rd.deviceId);
998
+ if (live) {
999
+ unregisterConn(live);
1000
+ try {
1001
+ live.ws.close(1e3, "device-removed");
1002
+ } catch {
1003
+ }
1004
+ }
1005
+ if (!storage.removeDevice(rd.deviceId)) {
1006
+ sendServerError(state.ws, "unknown_device", "target not found");
1007
+ return;
1008
+ }
1009
+ const out = {
1010
+ type: "device_removed",
1011
+ deviceId: rd.deviceId
1012
+ };
1013
+ for (const peer of userPeers(state.userId)) {
1014
+ sendFrame(peer.ws, out);
1015
+ }
1016
+ return;
1017
+ }
1018
+ case "unicast":
1019
+ handleUnicast(state, frame);
1020
+ return;
1021
+ case "broadcast":
1022
+ handleBroadcast(state, frame);
1023
+ return;
1024
+ case "auth":
1025
+ case "auth_info":
1026
+ case "auth_response":
1027
+ sendServerError(state.ws, "already_authenticated", "already authed");
1028
+ return;
1029
+ default:
1030
+ sendServerError(state.ws, "unknown_frame", `unknown type: ${String(t)}`);
1031
+ return;
1032
+ }
1033
+ }
1034
+ function handleUnicast(state, env) {
1035
+ if (!state.userId || !state.deviceId) {
1036
+ sendServerError(state.ws, "not_authenticated", "auth required", env.ref);
1037
+ return;
1038
+ }
1039
+ if (typeof env.to !== "string" || env.to === "") {
1040
+ sendServerError(state.ws, "invalid_request", "missing 'to'", env.ref);
1041
+ return;
1042
+ }
1043
+ const target = storage.getDevice(env.to);
1044
+ if (!target) {
1045
+ sendServerError(state.ws, "unknown_device", "target not found", env.ref);
1046
+ return;
1047
+ }
1048
+ if (target.user_id !== state.userId) {
1049
+ sendServerError(state.ws, "forbidden", "cross-user", env.ref);
1050
+ return;
1051
+ }
1052
+ const live = byDeviceId.get(env.to);
1053
+ if (live && live.ws.readyState === WebSocket.OPEN) {
1054
+ sendFrame(live.ws, env);
1055
+ return;
1056
+ }
1057
+ try {
1058
+ storage.queuePending({
1059
+ target_device_id: env.to,
1060
+ user_id: state.userId,
1061
+ envelope: JSON.stringify(env),
1062
+ now: Date.now()
1063
+ });
1064
+ } catch (err) {
1065
+ log.warn("queuePending failed", { error: String(err) });
1066
+ sendServerError(state.ws, "queue_failed", "could not queue", env.ref);
1067
+ }
1068
+ }
1069
+ function handleBroadcast(state, env) {
1070
+ if (!state.userId || !state.deviceId) {
1071
+ sendServerError(state.ws, "not_authenticated", "auth required");
1072
+ return;
1073
+ }
1074
+ for (const peer of userPeers(state.userId, state.deviceId)) {
1075
+ sendFrame(peer.ws, env);
1076
+ }
1077
+ }
1078
+ function onConnection(ws) {
1079
+ const state = {
1080
+ ws,
1081
+ isAlive: true,
1082
+ lastSeen: Date.now()
1083
+ };
1084
+ byConn.set(ws, state);
1085
+ ws.on("pong", () => {
1086
+ state.isAlive = true;
1087
+ state.lastSeen = Date.now();
1088
+ });
1089
+ ws.on("message", (data) => {
1090
+ onMessage(state, data).catch((err) => {
1091
+ log.error("message handler crashed", { error: String(err) });
1092
+ try {
1093
+ ws.close(1011, "internal-error");
1094
+ } catch {
1095
+ }
1096
+ });
1097
+ });
1098
+ ws.on("close", () => onClose(state));
1099
+ ws.on("error", (err) => {
1100
+ log.debug("ws error", { error: String(err) });
1101
+ });
1102
+ }
1103
+ async function onMessage(state, data) {
1104
+ state.isAlive = true;
1105
+ state.lastSeen = Date.now();
1106
+ let frame;
1107
+ try {
1108
+ frame = JSON.parse(data.toString());
1109
+ } catch {
1110
+ sendServerError(state.ws, "invalid_json", "could not parse frame");
1111
+ try {
1112
+ state.ws.close(1003, "invalid-json");
1113
+ } catch {
1114
+ }
1115
+ return;
1116
+ }
1117
+ if (!state.deviceId) {
1118
+ if (frame.type === "auth_info") {
1119
+ const info = auth.info();
1120
+ sendFrame(state.ws, info);
1121
+ return;
1122
+ }
1123
+ if (frame.type === "auth") {
1124
+ await handleAuthMessage(state, frame);
1125
+ return;
1126
+ }
1127
+ if (frame.type === "auth_response") {
1128
+ await handleAuthResponse(state, frame);
1129
+ return;
1130
+ }
1131
+ sendAuthError(state, {
1132
+ type: "auth_error",
1133
+ code: "not_authenticated",
1134
+ message: "auth required"
1135
+ });
1136
+ return;
1137
+ }
1138
+ handlePostAuthFrame(state, frame);
1139
+ }
1140
+ function onClose(state) {
1141
+ byConn.delete(state.ws);
1142
+ const wasRegistered = state.deviceId ? byDeviceId.get(state.deviceId) === state : false;
1143
+ if (wasRegistered) announceLeft(state);
1144
+ unregisterConn(state);
1145
+ }
1146
+ function startTimers() {
1147
+ pingTimer = setInterval(() => {
1148
+ for (const state of byConn.values()) {
1149
+ if (!state.isAlive) {
1150
+ try {
1151
+ state.ws.terminate();
1152
+ } catch {
1153
+ }
1154
+ continue;
1155
+ }
1156
+ state.isAlive = false;
1157
+ try {
1158
+ state.ws.ping();
1159
+ } catch {
1160
+ }
1161
+ }
1162
+ }, pingIntervalMs);
1163
+ if (pingTimer && typeof pingTimer.unref === "function") {
1164
+ pingTimer.unref();
1165
+ }
1166
+ staleTimer = setInterval(() => {
1167
+ const now = Date.now();
1168
+ for (const state of byConn.values()) {
1169
+ if (now - state.lastSeen > staleThresholdMs) {
1170
+ try {
1171
+ state.ws.terminate();
1172
+ } catch {
1173
+ }
1174
+ }
1175
+ }
1176
+ }, staleCheckIntervalMs);
1177
+ if (staleTimer && typeof staleTimer.unref === "function") {
1178
+ staleTimer.unref();
1179
+ }
1180
+ }
1181
+ function stopTimers() {
1182
+ if (pingTimer) clearInterval(pingTimer);
1183
+ if (staleTimer) clearInterval(staleTimer);
1184
+ pingTimer = null;
1185
+ staleTimer = null;
1186
+ }
1187
+ startTimers();
1188
+ return {
1189
+ attach(httpServer) {
1190
+ httpServer.on("upgrade", (req, socket, head) => {
1191
+ const url = req.url ?? "";
1192
+ if (!url.startsWith("/ws")) {
1193
+ socket.destroy();
1194
+ return;
1195
+ }
1196
+ wss.handleUpgrade(req, socket, head, (ws) => {
1197
+ wss.emit("connection", ws, req);
1198
+ });
1199
+ });
1200
+ wss.on("connection", (ws) => onConnection(ws));
1201
+ },
1202
+ metrics() {
1203
+ return { devicesOnline: byDeviceId.size, users: byUserId.size };
1204
+ },
1205
+ async close() {
1206
+ if (closed) return;
1207
+ closed = true;
1208
+ stopTimers();
1209
+ for (const state of byConn.values()) {
1210
+ try {
1211
+ state.ws.close(1001, "shutdown");
1212
+ } catch {
1213
+ }
1214
+ }
1215
+ byConn.clear();
1216
+ byDeviceId.clear();
1217
+ byUserId.clear();
1218
+ await new Promise((resolve) => {
1219
+ wss.close(() => resolve());
1220
+ });
1221
+ }
1222
+ };
1223
+ }
1224
+ function newDeviceId() {
1225
+ return mintDeviceId();
1226
+ }
1227
+ function newUserId() {
1228
+ return mintUserId();
1229
+ }
1230
+
1231
+ // src/storage.ts
1232
+ import Database from "better-sqlite3";
1233
+
1234
+ // ../shared/src/relay/devices.ts
1235
+ var DEVICE_ROLES = ["server", "app"];
1236
+ var DEVICE_KINDS = [
1237
+ "desktop",
1238
+ "server",
1239
+ "vm",
1240
+ "web",
1241
+ "ios",
1242
+ "android"
1243
+ ];
1244
+ function isDeviceRole(v) {
1245
+ return typeof v === "string" && DEVICE_ROLES.includes(v);
1246
+ }
1247
+ function isDeviceKind(v) {
1248
+ return typeof v === "string" && DEVICE_KINDS.includes(v);
1249
+ }
1250
+
1251
+ // ../shared/src/relay/auth.ts
1252
+ var AUTH_METHODS = [
1253
+ "github_oauth",
1254
+ "github_token",
1255
+ "pairing",
1256
+ "challenge",
1257
+ "apikey",
1258
+ "open"
1259
+ ];
1260
+ function isAuthMethod(v) {
1261
+ return typeof v === "string" && AUTH_METHODS.includes(v);
1262
+ }
1263
+
1264
+ // ../shared/src/relay/inner-message.ts
1265
+ var PRODUCER_TYPE_LIST = [
1266
+ "user_message",
1267
+ "agent_message",
1268
+ "agent_message_delta",
1269
+ "tool_start",
1270
+ "tool_complete",
1271
+ "permission_request",
1272
+ "permission_resolved",
1273
+ "question_request",
1274
+ "question_resolved",
1275
+ "session_created",
1276
+ "session_ended",
1277
+ "session_deleted",
1278
+ "session_mode_set",
1279
+ "session_model_set",
1280
+ "session_pinned",
1281
+ "session_read",
1282
+ "session_title_updated",
1283
+ "session_list",
1284
+ "session_replay_batch",
1285
+ "session_state_snapshot",
1286
+ "idle",
1287
+ "active",
1288
+ "session_usage_update",
1289
+ "error",
1290
+ "device_greeting",
1291
+ "workspace_list_update",
1292
+ "workspace_created",
1293
+ "workspace_updated",
1294
+ "workspace_deleted",
1295
+ "workspace_check_result",
1296
+ "directory_listing",
1297
+ "claude_sessions_listing",
1298
+ "task_list_update",
1299
+ "task_created",
1300
+ "task_updated",
1301
+ "task_deleted",
1302
+ "agent_availability_update",
1303
+ "available_commands_update",
1304
+ "current_mode_update"
1305
+ ];
1306
+ var CONSUMER_TYPE_LIST = [
1307
+ "send_input",
1308
+ "approve",
1309
+ "deny",
1310
+ "always_allow",
1311
+ "answer",
1312
+ "kill_session",
1313
+ "abort_session",
1314
+ "delete_session",
1315
+ "set_session_mode",
1316
+ "set_session_model",
1317
+ "rename_session",
1318
+ "pin_session",
1319
+ "mark_read",
1320
+ "mark_unread",
1321
+ "request_session_replay",
1322
+ "create_workspace",
1323
+ "check_workspace_path",
1324
+ "list_directory",
1325
+ "list_claude_sessions",
1326
+ "update_workspace",
1327
+ "delete_workspace",
1328
+ "request_workspace_list",
1329
+ "create_task",
1330
+ "update_task",
1331
+ "delete_task",
1332
+ "request_task_list",
1333
+ "reconnect_task_session",
1334
+ "request_agent_recheck"
1335
+ ];
1336
+ var PRODUCER_TYPES = new Set(PRODUCER_TYPE_LIST);
1337
+ var CONSUMER_TYPES = new Set(CONSUMER_TYPE_LIST);
1338
+
1339
+ // src/storage.ts
1340
+ var DEFAULT_PENDING_TTL_MS2 = 30 * 24 * 60 * 60 * 1e3;
1341
+ var DEFAULT_PENDING_CAP = 200;
1342
+ var SCHEMA_VERSION = 1;
1343
+ var MIGRATIONS = [
1344
+ // v1 — initial relay schema.
1345
+ (db) => {
1346
+ db.exec(`
1347
+ CREATE TABLE IF NOT EXISTS users (
1348
+ user_id TEXT PRIMARY KEY,
1349
+ login TEXT NOT NULL DEFAULT '',
1350
+ provider TEXT NOT NULL DEFAULT '',
1351
+ email TEXT NOT NULL DEFAULT '',
1352
+ preferences TEXT NOT NULL DEFAULT '{}',
1353
+ created_at INTEGER NOT NULL DEFAULT 0,
1354
+ last_seen INTEGER NOT NULL DEFAULT 0
1355
+ );
1356
+
1357
+ CREATE TABLE IF NOT EXISTS devices (
1358
+ device_id TEXT PRIMARY KEY,
1359
+ user_id TEXT NOT NULL DEFAULT '',
1360
+ name TEXT NOT NULL DEFAULT '',
1361
+ role TEXT NOT NULL DEFAULT '',
1362
+ kind TEXT NOT NULL DEFAULT '',
1363
+ public_key TEXT NOT NULL DEFAULT '',
1364
+ encryption_key TEXT NOT NULL DEFAULT '',
1365
+ last_seen INTEGER NOT NULL DEFAULT 0,
1366
+ created_at INTEGER NOT NULL DEFAULT 0
1367
+ );
1368
+ CREATE INDEX IF NOT EXISTS idx_devices_user ON devices(user_id, last_seen);
1369
+
1370
+ CREATE TABLE IF NOT EXISTS pending_messages (
1371
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1372
+ target_device_id TEXT NOT NULL DEFAULT '',
1373
+ user_id TEXT NOT NULL DEFAULT '',
1374
+ envelope TEXT NOT NULL DEFAULT '',
1375
+ created_at INTEGER NOT NULL DEFAULT 0,
1376
+ expires_at INTEGER NOT NULL DEFAULT 0
1377
+ );
1378
+ CREATE INDEX IF NOT EXISTS idx_pending_target
1379
+ ON pending_messages(target_device_id, created_at);
1380
+ `);
1381
+ }
1382
+ ];
1383
+ function applyMigrations(db) {
1384
+ const row = db.pragma("user_version", { simple: true });
1385
+ let current = typeof row === "number" ? row : 0;
1386
+ for (let v = current; v < MIGRATIONS.length; v++) {
1387
+ const migrate = MIGRATIONS[v];
1388
+ const tx = db.transaction(() => {
1389
+ migrate(db);
1390
+ db.pragma(`user_version = ${v + 1}`);
1391
+ });
1392
+ tx();
1393
+ current = v + 1;
1394
+ }
1395
+ if (current !== SCHEMA_VERSION) {
1396
+ throw new Error(
1397
+ `relay storage schema version drift: applied=${current} expected=${SCHEMA_VERSION}`
1398
+ );
1399
+ }
1400
+ }
1401
+ function openRelayStorage(opts) {
1402
+ const db = new Database(opts.file);
1403
+ db.pragma("journal_mode = WAL");
1404
+ applyMigrations(db);
1405
+ const pendingTtlMs = opts.pendingTtlMs ?? DEFAULT_PENDING_TTL_MS2;
1406
+ const pendingCap = opts.pendingCap ?? DEFAULT_PENDING_CAP;
1407
+ const stUserGet = db.prepare(
1408
+ "SELECT * FROM users WHERE user_id = ?"
1409
+ );
1410
+ const stUserByLogin = db.prepare(
1411
+ "SELECT * FROM users WHERE login = ?"
1412
+ );
1413
+ const stUserInsert = db.prepare(
1414
+ `INSERT INTO users (user_id, login, provider, email, preferences, created_at, last_seen)
1415
+ VALUES (?, ?, ?, ?, '{}', ?, ?)`
1416
+ );
1417
+ const stUserUpdate = db.prepare(
1418
+ `UPDATE users SET login = ?, provider = ?, email = ?, last_seen = ? WHERE user_id = ?`
1419
+ );
1420
+ const stUserPrefs = db.prepare(
1421
+ "UPDATE users SET preferences = ? WHERE user_id = ?"
1422
+ );
1423
+ const stUserTouch = db.prepare(
1424
+ "UPDATE users SET last_seen = ? WHERE user_id = ?"
1425
+ );
1426
+ const stDeviceGet = db.prepare(
1427
+ "SELECT * FROM devices WHERE device_id = ?"
1428
+ );
1429
+ const stDeviceListByUser = db.prepare(
1430
+ "SELECT * FROM devices WHERE user_id = ? ORDER BY last_seen DESC, created_at DESC"
1431
+ );
1432
+ const stDeviceInsert = db.prepare(
1433
+ `INSERT INTO devices
1434
+ (device_id, user_id, name, role, kind, public_key, encryption_key, last_seen, created_at)
1435
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
1436
+ );
1437
+ const stDeviceUpdate = db.prepare(
1438
+ `UPDATE devices
1439
+ SET name = ?, role = ?, kind = ?, public_key = ?, encryption_key = ?, last_seen = ?
1440
+ WHERE device_id = ?`
1441
+ );
1442
+ const stDeviceTouch = db.prepare(
1443
+ "UPDATE devices SET last_seen = ? WHERE device_id = ?"
1444
+ );
1445
+ const stDeviceDelete = db.prepare("DELETE FROM devices WHERE device_id = ?");
1446
+ const stPendingInsert = db.prepare(
1447
+ `INSERT INTO pending_messages
1448
+ (target_device_id, user_id, envelope, created_at, expires_at)
1449
+ VALUES (?, ?, ?, ?, ?)`
1450
+ );
1451
+ const stPendingGet = db.prepare(
1452
+ "SELECT * FROM pending_messages WHERE id = ?"
1453
+ );
1454
+ const stPendingForDevice = db.prepare(
1455
+ "SELECT * FROM pending_messages WHERE target_device_id = ? ORDER BY created_at ASC, id ASC"
1456
+ );
1457
+ const stPendingDeleteForDevice = db.prepare(
1458
+ "DELETE FROM pending_messages WHERE target_device_id = ?"
1459
+ );
1460
+ const stPendingExpire = db.prepare(
1461
+ "DELETE FROM pending_messages WHERE expires_at > 0 AND expires_at <= ?"
1462
+ );
1463
+ const stPendingCount = db.prepare(
1464
+ "SELECT COUNT(*) AS c FROM pending_messages WHERE target_device_id = ?"
1465
+ );
1466
+ const stPendingOldest = db.prepare(
1467
+ `SELECT id FROM pending_messages
1468
+ WHERE target_device_id = ?
1469
+ ORDER BY created_at ASC, id ASC
1470
+ LIMIT ?`
1471
+ );
1472
+ const stPendingDeleteById = db.prepare(
1473
+ "DELETE FROM pending_messages WHERE id = ?"
1474
+ );
1475
+ function enforceFifoCap(target_device_id) {
1476
+ const row = stPendingCount.get(target_device_id);
1477
+ const count = row?.c ?? 0;
1478
+ if (count <= pendingCap) return 0;
1479
+ const over = count - pendingCap;
1480
+ const victims = stPendingOldest.all(target_device_id, over);
1481
+ let deleted = 0;
1482
+ const tx = db.transaction(() => {
1483
+ for (const v of victims) {
1484
+ const r = stPendingDeleteById.run(v.id);
1485
+ deleted += r.changes;
1486
+ }
1487
+ });
1488
+ tx();
1489
+ return deleted;
1490
+ }
1491
+ return {
1492
+ // users
1493
+ upsertUser({ user_id, login, provider, email }) {
1494
+ const now = Date.now();
1495
+ const existing = stUserGet.get(user_id);
1496
+ if (existing) {
1497
+ stUserUpdate.run(login, provider, email, now, user_id);
1498
+ } else {
1499
+ stUserInsert.run(user_id, login, provider, email, now, now);
1500
+ }
1501
+ const row = stUserGet.get(user_id);
1502
+ if (!row) throw new Error(`upsertUser failed for ${user_id}`);
1503
+ return row;
1504
+ },
1505
+ getUser: (user_id) => stUserGet.get(user_id) ?? null,
1506
+ getUserByLogin: (login) => stUserByLogin.get(login) ?? null,
1507
+ setUserPreferences: (user_id, prefs) => {
1508
+ stUserPrefs.run(JSON.stringify(prefs), user_id);
1509
+ },
1510
+ touchUser: (user_id, now) => {
1511
+ stUserTouch.run(now, user_id);
1512
+ },
1513
+ // devices
1514
+ upsertDevice({
1515
+ device_id,
1516
+ user_id,
1517
+ name,
1518
+ role,
1519
+ kind,
1520
+ public_key,
1521
+ encryption_key
1522
+ }) {
1523
+ if (!isDeviceRole(role)) {
1524
+ throw new Error(`invalid device role: ${String(role)}`);
1525
+ }
1526
+ if (!isDeviceKind(kind)) {
1527
+ throw new Error(`invalid device kind: ${String(kind)}`);
1528
+ }
1529
+ const now = Date.now();
1530
+ const existing = stDeviceGet.get(device_id);
1531
+ if (existing) {
1532
+ stDeviceUpdate.run(
1533
+ name,
1534
+ role,
1535
+ kind,
1536
+ public_key,
1537
+ encryption_key,
1538
+ now,
1539
+ device_id
1540
+ );
1541
+ } else {
1542
+ stDeviceInsert.run(
1543
+ device_id,
1544
+ user_id,
1545
+ name,
1546
+ role,
1547
+ kind,
1548
+ public_key,
1549
+ encryption_key,
1550
+ now,
1551
+ now
1552
+ );
1553
+ }
1554
+ const row = stDeviceGet.get(device_id);
1555
+ if (!row) throw new Error(`upsertDevice failed for ${device_id}`);
1556
+ return row;
1557
+ },
1558
+ getDevice: (device_id) => stDeviceGet.get(device_id) ?? null,
1559
+ listDevicesByUser: (user_id) => stDeviceListByUser.all(user_id),
1560
+ touchDevice: (device_id, now) => {
1561
+ stDeviceTouch.run(now, device_id);
1562
+ },
1563
+ removeDevice: (device_id) => stDeviceDelete.run(device_id).changes > 0,
1564
+ // pending
1565
+ queuePending({ target_device_id, user_id, envelope, now, ttlMs }) {
1566
+ const ttl = ttlMs ?? pendingTtlMs;
1567
+ const expires_at = now + ttl;
1568
+ const info = stPendingInsert.run(
1569
+ target_device_id,
1570
+ user_id,
1571
+ envelope,
1572
+ now,
1573
+ expires_at
1574
+ );
1575
+ enforceFifoCap(target_device_id);
1576
+ const id = Number(info.lastInsertRowid);
1577
+ const row = stPendingGet.get(id);
1578
+ if (!row) {
1579
+ return {
1580
+ id,
1581
+ target_device_id,
1582
+ user_id,
1583
+ envelope,
1584
+ created_at: now,
1585
+ expires_at
1586
+ };
1587
+ }
1588
+ return row;
1589
+ },
1590
+ drainPending(target_device_id) {
1591
+ const tx = db.transaction((tid) => {
1592
+ const rows = stPendingForDevice.all(tid);
1593
+ stPendingDeleteForDevice.run(tid);
1594
+ return rows;
1595
+ });
1596
+ return tx(target_device_id);
1597
+ },
1598
+ expirePending: (now) => stPendingExpire.run(now).changes,
1599
+ enforceFifoCap,
1600
+ countPending: (target_device_id) => stPendingCount.get(target_device_id)?.c ?? 0,
1601
+ close: () => db.close()
1602
+ };
1603
+ }
1604
+
1605
+ // src/config.ts
1606
+ var DEFAULTS = {
1607
+ port: 7900,
1608
+ host: "0.0.0.0",
1609
+ authMethods: ["pairing", "challenge"],
1610
+ logLevel: "info"
1611
+ };
1612
+ var DEFAULT_DB_FILE = "./boluo-relay.db";
1613
+ var VALID_LOG_LEVELS = /* @__PURE__ */ new Set([
1614
+ "debug",
1615
+ "info",
1616
+ "warn",
1617
+ "error"
1618
+ ]);
1619
+ function parseAuthList(raw, onWarn) {
1620
+ const out = [];
1621
+ const seen = /* @__PURE__ */ new Set();
1622
+ for (const tok of raw.split(",")) {
1623
+ const t = tok.trim().toLowerCase();
1624
+ if (t === "") continue;
1625
+ if (seen.has(t)) continue;
1626
+ seen.add(t);
1627
+ if (isAuthMethod(t)) {
1628
+ out.push(t);
1629
+ } else {
1630
+ onWarn(
1631
+ `unknown auth method '${t}' (valid: ${AUTH_METHODS.join(",")}) \u2014 ignored`
1632
+ );
1633
+ }
1634
+ }
1635
+ return out;
1636
+ }
1637
+ function parsePort(raw, onWarn) {
1638
+ if (raw === void 0 || raw === "") return void 0;
1639
+ const n = Number(raw);
1640
+ if (!Number.isFinite(n) || n < 0 || n > 65535 || !Number.isInteger(n)) {
1641
+ onWarn(`invalid port '${raw}' \u2014 ignored`);
1642
+ return void 0;
1643
+ }
1644
+ return n;
1645
+ }
1646
+ function parseLogLevel(raw, onWarn) {
1647
+ if (raw === void 0 || raw === "") return void 0;
1648
+ const v = raw.toLowerCase();
1649
+ if (VALID_LOG_LEVELS.has(v)) {
1650
+ return v;
1651
+ }
1652
+ onWarn(`invalid log level '${raw}' \u2014 ignored`);
1653
+ return void 0;
1654
+ }
1655
+ function resolveConfig(input = {}) {
1656
+ const flags = input.flags ?? {};
1657
+ const env = input.env ?? {};
1658
+ const onWarn = input.onWarn ?? (() => {
1659
+ });
1660
+ const port = flags.port ?? parsePort(env.BOLUO_RELAY_PORT, onWarn) ?? DEFAULTS.port;
1661
+ const host = flags.host ?? env.BOLUO_RELAY_HOST ?? DEFAULTS.host;
1662
+ const dbFile = flags.db ?? env.BOLUO_RELAY_DB ?? DEFAULT_DB_FILE;
1663
+ const apiKey = env.BOLUO_API_KEY ?? "";
1664
+ const userExplicitAuth = flags.auth !== void 0 && flags.auth !== "";
1665
+ let authMethods;
1666
+ if (userExplicitAuth) {
1667
+ authMethods = parseAuthList(flags.auth, onWarn);
1668
+ if (authMethods.length === 0) {
1669
+ onWarn("no valid auth methods after --auth parsing; falling back to defaults");
1670
+ authMethods = [...DEFAULTS.authMethods];
1671
+ }
1672
+ } else if (env.BOLUO_RELAY_AUTH !== void 0 && env.BOLUO_RELAY_AUTH !== "") {
1673
+ authMethods = parseAuthList(env.BOLUO_RELAY_AUTH, onWarn);
1674
+ if (authMethods.length === 0) {
1675
+ onWarn("no valid auth methods after BOLUO_RELAY_AUTH parsing; falling back to defaults");
1676
+ authMethods = [...DEFAULTS.authMethods];
1677
+ }
1678
+ if (apiKey !== "" && !authMethods.includes("apikey")) {
1679
+ authMethods.push("apikey");
1680
+ }
1681
+ } else {
1682
+ authMethods = [...DEFAULTS.authMethods];
1683
+ if (apiKey !== "" && !authMethods.includes("apikey")) {
1684
+ authMethods.push("apikey");
1685
+ }
1686
+ }
1687
+ if (!authMethods.includes("challenge")) {
1688
+ onWarn(
1689
+ "auth method 'challenge' is disabled \u2014 clients will not be able to reauth on an existing deviceId"
1690
+ );
1691
+ }
1692
+ const githubClientId = flags.githubClientId ?? env.GITHUB_CLIENT_ID ?? "";
1693
+ const githubClientSecret = flags.githubClientSecret ?? env.GITHUB_CLIENT_SECRET ?? "";
1694
+ const openSharedKey = flags.openSharedKey ?? env.BOLUO_OPEN_SHARED_KEY ?? "";
1695
+ const staticDir = flags.serveWeb ?? env.BOLUO_RELAY_STATIC_DIR ?? "";
1696
+ const logLevel = parseLogLevel(flags.logLevel, onWarn) ?? parseLogLevel(env.BOLUO_LOG_LEVEL, onWarn) ?? DEFAULTS.logLevel;
1697
+ return {
1698
+ port,
1699
+ host,
1700
+ dbFile,
1701
+ authMethods,
1702
+ apiKey,
1703
+ githubClientId,
1704
+ githubClientSecret,
1705
+ openSharedKey,
1706
+ staticDir,
1707
+ logLevel
1708
+ };
1709
+ }
1710
+ function redactConfig(cfg) {
1711
+ return {
1712
+ ...cfg,
1713
+ apiKey: cfg.apiKey ? "<set>" : "",
1714
+ githubClientSecret: cfg.githubClientSecret ? "<set>" : "",
1715
+ openSharedKey: cfg.openSharedKey ? "<set>" : ""
1716
+ };
1717
+ }
1718
+
1719
+ // src/cli.ts
1720
+ async function startRelay(cfg, deps = {}) {
1721
+ const logger = deps.logger ?? createLogger({ level: cfg.logLevel });
1722
+ const stdout = deps.stdout ?? ((m) => console.log(m));
1723
+ const storage = openRelayStorage({ file: cfg.dbFile });
1724
+ const auth = createAuthRegistry({
1725
+ enabledMethods: cfg.authMethods,
1726
+ apiKey: cfg.apiKey,
1727
+ githubClientId: cfg.githubClientId,
1728
+ githubClientSecret: cfg.githubClientSecret,
1729
+ openSharedKey: cfg.openSharedKey
1730
+ });
1731
+ const relay = createRelayServer({ storage, auth, logger });
1732
+ const app = createHttpApp({
1733
+ metrics: () => relay.metrics(),
1734
+ logger
1735
+ });
1736
+ if (cfg.staticDir !== "") {
1737
+ mountStatic(app, cfg.staticDir, logger);
1738
+ }
1739
+ const { server, port } = await new Promise((resolveListen, rejectListen) => {
1740
+ let settled = false;
1741
+ let raw;
1742
+ try {
1743
+ const s = serve(
1744
+ { fetch: app.fetch, port: cfg.port, hostname: cfg.host },
1745
+ (info) => {
1746
+ if (settled) return;
1747
+ settled = true;
1748
+ resolveListen({
1749
+ server: raw,
1750
+ port: info.port
1751
+ });
1752
+ }
1753
+ );
1754
+ raw = s;
1755
+ } catch (err) {
1756
+ if (!settled) {
1757
+ settled = true;
1758
+ rejectListen(err instanceof Error ? err : new Error(String(err)));
1759
+ }
1760
+ }
1761
+ });
1762
+ relay.attach(server);
1763
+ stdout(
1764
+ `boluo-relay listening on http://${cfg.host}:${port} (ws://${cfg.host}:${port}/ws, health: /health)`
1765
+ );
1766
+ let closing = false;
1767
+ const close = async () => {
1768
+ if (closing) return;
1769
+ closing = true;
1770
+ try {
1771
+ await relay.close();
1772
+ } catch {
1773
+ }
1774
+ if (typeof server.closeAllConnections === "function") {
1775
+ try {
1776
+ server.closeAllConnections();
1777
+ } catch {
1778
+ }
1779
+ }
1780
+ await new Promise((res) => {
1781
+ server.close(() => res());
1782
+ });
1783
+ try {
1784
+ storage.close();
1785
+ } catch {
1786
+ }
1787
+ };
1788
+ return { config: cfg, storage, auth, relay, http: app, server, port, close };
1789
+ }
1790
+ function mountStatic(app, dir, logger) {
1791
+ const root = resolvePath(dir);
1792
+ if (!existsSync(root) || !statSync(root).isDirectory()) {
1793
+ logger.warn("static dir does not exist; skipping", { dir: root });
1794
+ return;
1795
+ }
1796
+ app.use("*", async (c, next) => {
1797
+ await next();
1798
+ if (c.res.status !== 404) return;
1799
+ const url = new URL(c.req.url);
1800
+ let pathname = decodeURIComponent(url.pathname);
1801
+ if (pathname === "/" || pathname === "") pathname = "/index.html";
1802
+ const candidate = normalize(join(root, pathname));
1803
+ if (!candidate.startsWith(root)) return;
1804
+ if (!existsSync(candidate)) return;
1805
+ const stat = statSync(candidate);
1806
+ if (stat.isDirectory()) {
1807
+ const indexFile = join(candidate, "index.html");
1808
+ if (!existsSync(indexFile)) return;
1809
+ const body2 = readFileSync(indexFile);
1810
+ c.res = new Response(body2, {
1811
+ status: 200,
1812
+ headers: { "content-type": "text/html; charset=utf-8" }
1813
+ });
1814
+ return;
1815
+ }
1816
+ const body = readFileSync(candidate);
1817
+ c.res = new Response(body, {
1818
+ status: 200,
1819
+ headers: { "content-type": contentTypeFor(candidate) }
1820
+ });
1821
+ });
1822
+ }
1823
+ function contentTypeFor(file) {
1824
+ switch (extname(file).toLowerCase()) {
1825
+ case ".html":
1826
+ return "text/html; charset=utf-8";
1827
+ case ".js":
1828
+ case ".mjs":
1829
+ return "application/javascript; charset=utf-8";
1830
+ case ".css":
1831
+ return "text/css; charset=utf-8";
1832
+ case ".json":
1833
+ return "application/json; charset=utf-8";
1834
+ case ".svg":
1835
+ return "image/svg+xml";
1836
+ case ".png":
1837
+ return "image/png";
1838
+ case ".jpg":
1839
+ case ".jpeg":
1840
+ return "image/jpeg";
1841
+ case ".webp":
1842
+ return "image/webp";
1843
+ case ".ico":
1844
+ return "image/x-icon";
1845
+ case ".txt":
1846
+ return "text/plain; charset=utf-8";
1847
+ default:
1848
+ return "application/octet-stream";
1849
+ }
1850
+ }
1851
+ function newUserId2() {
1852
+ return mintUserId();
1853
+ }
1854
+ function defaultBundledWebDir() {
1855
+ try {
1856
+ const dir = fileURLToPath(new URL("../web-dist/", import.meta.url));
1857
+ if (existsSync(dir) && statSync(dir).isDirectory()) return dir;
1858
+ } catch {
1859
+ }
1860
+ return "";
1861
+ }
1862
+ async function runCli(opts) {
1863
+ const env = opts.env ?? {};
1864
+ const stdout = opts.stdout ?? ((m) => console.log(m));
1865
+ const stderr = opts.stderr ?? ((m) => console.error(m));
1866
+ const startImpl = opts.startImpl ?? startRelay;
1867
+ let exitCode = 0;
1868
+ let handle;
1869
+ const program = new Command();
1870
+ program.name("boluo-relay").description("Boluo encrypted relay server").exitOverride();
1871
+ program.configureOutput({
1872
+ writeOut: (s) => stdout(s.replace(/\n$/, "")),
1873
+ writeErr: (s) => stderr(s.replace(/\n$/, ""))
1874
+ });
1875
+ const startCmd = program.command("start", { isDefault: true }).description("Start the relay server (default)").option("--port <n>", "TCP port", (v) => Number(v)).option("--host <addr>", "bind address").option("--db <path>", "SQLite db file (':memory:' for tests)").option(
1876
+ "--auth <list>",
1877
+ "comma-separated auth methods (e.g. pairing,challenge,apikey)"
1878
+ ).option("--github-client-id <id>", "GitHub OAuth client id").option("--github-client-secret <secret>", "GitHub OAuth client secret").option("--open-shared-key <key>", "shared key for 'open' auth method").option("--serve-web <dir>", "serve static files from this directory").option("--log-level <level>", "debug|info|warn|error").action(async (flagsRaw) => {
1879
+ const flags = {
1880
+ port: typeof flagsRaw.port === "number" ? flagsRaw.port : void 0,
1881
+ host: typeof flagsRaw.host === "string" ? flagsRaw.host : void 0,
1882
+ db: typeof flagsRaw.db === "string" ? flagsRaw.db : void 0,
1883
+ auth: typeof flagsRaw.auth === "string" ? flagsRaw.auth : void 0,
1884
+ githubClientId: typeof flagsRaw.githubClientId === "string" ? flagsRaw.githubClientId : void 0,
1885
+ githubClientSecret: typeof flagsRaw.githubClientSecret === "string" ? flagsRaw.githubClientSecret : void 0,
1886
+ openSharedKey: typeof flagsRaw.openSharedKey === "string" ? flagsRaw.openSharedKey : void 0,
1887
+ serveWeb: typeof flagsRaw.serveWeb === "string" ? flagsRaw.serveWeb : void 0,
1888
+ logLevel: typeof flagsRaw.logLevel === "string" ? flagsRaw.logLevel : void 0
1889
+ };
1890
+ const cfg = resolveConfig({
1891
+ flags,
1892
+ env,
1893
+ onWarn: (m) => stderr(`warn: ${m}`)
1894
+ });
1895
+ const effective = cfg.staticDir === "" ? { ...cfg, staticDir: defaultBundledWebDir() } : cfg;
1896
+ handle = await startImpl(effective, { stdout });
1897
+ if (!opts.exitAfterStart) {
1898
+ await installShutdownHandlers(handle, stdout);
1899
+ }
1900
+ });
1901
+ const admin = program.command("admin").description("Admin subcommands");
1902
+ admin.command("create-user").description("Create a user record").requiredOption("--login <name>", "login (unique handle)").option("--provider <s>", "auth provider (e.g. github, local)", "local").option("--email <s>", "email address", "").action((flagsRaw) => {
1903
+ const cfg = resolveConfig({ env, onWarn: (m) => stderr(`warn: ${m}`) });
1904
+ const storage = openRelayStorage({ file: cfg.dbFile });
1905
+ try {
1906
+ const existing = storage.getUserByLogin(flagsRaw.login);
1907
+ const userId = existing?.user_id ?? newUserId2();
1908
+ const row = storage.upsertUser({
1909
+ user_id: userId,
1910
+ login: flagsRaw.login,
1911
+ provider: flagsRaw.provider,
1912
+ email: flagsRaw.email
1913
+ });
1914
+ stdout(`user_id=${row.user_id} login=${row.login} provider=${row.provider}`);
1915
+ } finally {
1916
+ storage.close();
1917
+ }
1918
+ });
1919
+ admin.command("issue-pairing").description(
1920
+ "Issue a pairing token (NOT supported standalone \u2014 tokens live in relay process memory; use a connected client's create_pairing_token control frame)"
1921
+ ).requiredOption("--user <login>", "user login").action(() => {
1922
+ stderr(
1923
+ "issue-pairing is not supported from the standalone CLI: pairing tokens live in the running relay's memory. Use a connected client to send `create_pairing_token`."
1924
+ );
1925
+ exitCode = 2;
1926
+ });
1927
+ admin.command("list-devices").description("List devices for a user (or all users)").option("--user <login>", "filter by user login").action((flagsRaw) => {
1928
+ const cfg = resolveConfig({ env, onWarn: (m) => stderr(`warn: ${m}`) });
1929
+ const storage = openRelayStorage({ file: cfg.dbFile });
1930
+ try {
1931
+ if (flagsRaw.user) {
1932
+ const u = storage.getUserByLogin(flagsRaw.user);
1933
+ if (!u) {
1934
+ stdout(`no user with login=${flagsRaw.user}`);
1935
+ exitCode = 1;
1936
+ return;
1937
+ }
1938
+ const rows = storage.listDevicesByUser(u.user_id);
1939
+ stdout(`devices for ${u.login} (${u.user_id}): ${rows.length}`);
1940
+ for (const r of rows) {
1941
+ stdout(
1942
+ ` ${r.device_id} role=${r.role} kind=${r.kind} name=${r.name} last_seen=${r.last_seen}`
1943
+ );
1944
+ }
1945
+ } else {
1946
+ stdout("no --user provided; listing devices per user not supported");
1947
+ exitCode = 1;
1948
+ }
1949
+ } finally {
1950
+ storage.close();
1951
+ }
1952
+ });
1953
+ admin.command("remove-device").description("Remove a device by id").requiredOption("--device-id <id>", "device id").action((flagsRaw) => {
1954
+ const cfg = resolveConfig({ env, onWarn: (m) => stderr(`warn: ${m}`) });
1955
+ const storage = openRelayStorage({ file: cfg.dbFile });
1956
+ try {
1957
+ const removed = storage.removeDevice(flagsRaw.deviceId);
1958
+ if (removed) {
1959
+ stdout(`removed device ${flagsRaw.deviceId}`);
1960
+ } else {
1961
+ stdout(`no rows removed for device_id=${flagsRaw.deviceId}`);
1962
+ exitCode = 1;
1963
+ }
1964
+ } finally {
1965
+ storage.close();
1966
+ }
1967
+ });
1968
+ admin.command("show-config").description("Print resolved config (secrets redacted)").action(() => {
1969
+ const cfg = resolveConfig({ env, onWarn: (m) => stderr(`warn: ${m}`) });
1970
+ stdout(JSON.stringify(redactConfig(cfg), null, 2));
1971
+ });
1972
+ try {
1973
+ await program.parseAsync(opts.argv, { from: "user" });
1974
+ } catch (err) {
1975
+ const errObj = err;
1976
+ if (errObj && typeof errObj.exitCode === "number") {
1977
+ if (errObj.code === "commander.helpDisplayed" || errObj.code === "commander.version") {
1978
+ return { exitCode: 0 };
1979
+ }
1980
+ if (errObj.code === "commander.help") {
1981
+ return { exitCode: 0 };
1982
+ }
1983
+ if (errObj.message) stderr(errObj.message);
1984
+ return { exitCode: errObj.exitCode };
1985
+ }
1986
+ stderr(`error: ${err instanceof Error ? err.message : String(err)}`);
1987
+ return { exitCode: 1 };
1988
+ }
1989
+ if (opts.exitAfterStart && handle !== void 0) {
1990
+ return { exitCode, handle };
1991
+ }
1992
+ return { exitCode };
1993
+ }
1994
+ async function installShutdownHandlers(handle, stdout) {
1995
+ const onSignal = async (sig) => {
1996
+ stdout(`received ${sig}, shutting down\u2026`);
1997
+ try {
1998
+ await handle.close();
1999
+ } finally {
2000
+ process.exit(0);
2001
+ }
2002
+ };
2003
+ process.once("SIGINT", () => {
2004
+ void onSignal("SIGINT");
2005
+ });
2006
+ process.once("SIGTERM", () => {
2007
+ void onSignal("SIGTERM");
2008
+ });
2009
+ await new Promise(() => {
2010
+ });
2011
+ }
2012
+ var isDirect = (() => {
2013
+ try {
2014
+ const entry = process.argv[1] ?? "";
2015
+ const url = new URL(`file://${entry}`).href;
2016
+ return import.meta.url === url;
2017
+ } catch {
2018
+ return false;
2019
+ }
2020
+ })();
2021
+ if (isDirect) {
2022
+ runCli({ argv: process.argv.slice(2), env: process.env }).then(
2023
+ (r) => {
2024
+ if (r.exitCode !== 0) process.exit(r.exitCode);
2025
+ },
2026
+ (err) => {
2027
+ console.error("fatal:", err);
2028
+ process.exit(1);
2029
+ }
2030
+ );
2031
+ }
2032
+ export {
2033
+ runCli,
2034
+ startRelay
2035
+ };