my-durable 0.0.6 → 0.0.8

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 (45) hide show
  1. package/README.md +6 -0
  2. package/apps/dpi-backend/src/config.ts +10 -3
  3. package/apps/dpi-backend/src/daemon.ts +422 -0
  4. package/apps/dpi-backend/src/main.ts +148 -7
  5. package/apps/dpi-backend/src/pair-page.ts +111 -0
  6. package/apps/dpi-backend/src/pairing-files.ts +39 -9
  7. package/apps/dpi-backend/src/pairing.ts +368 -59
  8. package/apps/dpi-backend/src/routes.ts +14 -1
  9. package/apps/dpi-backend/src/store.ts +1 -0
  10. package/apps/dpi-backend/src/tunnel.ts +294 -55
  11. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Cg3vey95.css +1 -0
  12. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CoYMgU-2.js +77 -0
  13. package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
  14. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +17 -4
  15. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +29 -0
  16. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +107 -18
  17. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +1 -1
  18. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceHandoff.vue +132 -0
  19. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +2 -2
  20. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +36 -6
  21. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +107 -5
  22. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +3 -1
  23. package/apps/dpi-playground/apps/dpi-playground-hub/src/admin-auth.ts +19 -17
  24. package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +1 -0
  25. package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +4 -0
  26. package/apps/dpi-playground/apps/dpi-playground-hub/src/hub-identity.ts +98 -0
  27. package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +31 -3
  28. package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +172 -25
  29. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +24 -0
  30. package/apps/dpi-sdk/src/api.ts +2 -0
  31. package/apps/dpi-sdk/src/node/private-file.ts +140 -0
  32. package/apps/dpi-sdk/src/node/registry-migration.ts +9 -0
  33. package/apps/dpi-sdk/src/node/registry.ts +6 -0
  34. package/apps/dpi-sdk/src/protocol/pairing.ts +137 -16
  35. package/apps/dpi-sdk/src/protocol/tunnel.ts +162 -38
  36. package/apps/dpi-tui/src/app.ts +889 -187
  37. package/apps/dpi-tui/src/hub-feed.ts +71 -0
  38. package/apps/dpi-tui/src/hub-mux.ts +168 -0
  39. package/apps/dpi-tui/src/main.ts +5 -1
  40. package/apps/dpi-tui/src/theme.ts +14 -0
  41. package/apps/dpi-tui/src/views.ts +311 -0
  42. package/bin/dpi.mjs +8 -3
  43. package/package.json +2 -2
  44. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index--aoZ5Tj0.css +0 -1
  45. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Dq0CZYr7.js +0 -77
@@ -276,6 +276,7 @@ export class Store {
276
276
  startDirs: this.startDirs,
277
277
  models: this.modelList,
278
278
  defaultModel: this.defaultModel(),
279
+ provider: this.options.provider,
279
280
  storage: { dataDir: this.options.dataDir, catalogPath: this.catalogPath, appStatePath: this.sharedStatePath },
280
281
  codeDir: CODE_DIR,
281
282
  capabilities: ["events", "docs", "demo", "jobs"],
@@ -15,7 +15,7 @@
15
15
  * `token_unknown` and bare 401s stay non-terminal with an actionable re-pair
16
16
  * status instead of an invisible infinite retry.
17
17
  */
18
- import { createHash } from "node:crypto";
18
+ import { createHash, createPublicKey, randomBytes, verify as edVerify } from "node:crypto";
19
19
  import { readFileSync, rmSync } from "node:fs";
20
20
  import { Agent as HttpsAgent } from "node:https";
21
21
  import WebSocket from "ws";
@@ -37,7 +37,15 @@ import {
37
37
  WS_PACE_LOW_WATER_BYTES,
38
38
  } from "../../dpi-sdk/src/protocol/limits.js";
39
39
  import { Backoff } from "../../dpi-sdk/src/protocol/backoff.js";
40
- import { loadJson, pairingPath, keypairPath, writeAtomic, type PairingFile, type DeviceKeypair } from "./pairing-files.js";
40
+ import {
41
+ loadJson,
42
+ pairingPath,
43
+ keypairPath,
44
+ writeAtomic,
45
+ type PairingFile,
46
+ type DeviceKeypair,
47
+ } from "./pairing-files.js";
48
+ import { hubProofSigningPayload } from "../../dpi-sdk/src/protocol/pairing.js";
41
49
  import { ByteBudget, SseReader, chunkMessage } from "../../dpi-sdk/src/node/chunk.js";
42
50
  import { REST_BODY_MAX_BYTES as LOCAL_BODY_CAP } from "../../dpi-sdk/src/protocol/limits.js";
43
51
 
@@ -50,15 +58,24 @@ const SUBSCRIBE_PATH_OK =
50
58
  function isSubscribePathOk(sub: string): boolean {
51
59
  if (!SUBSCRIBE_PATH_OK.test(sub) || sub.includes("\\")) return false;
52
60
  const path = sub.split("?", 1)[0]!;
53
- return path.split("/").slice(1).every((segment) => {
54
- let decoded: string;
55
- try {
56
- decoded = decodeURIComponent(segment);
57
- } catch {
58
- return false;
59
- }
60
- return segment !== "" && decoded !== "." && decoded !== ".." && !decoded.includes("/") && !decoded.includes("\\");
61
- });
61
+ return path
62
+ .split("/")
63
+ .slice(1)
64
+ .every((segment) => {
65
+ let decoded: string;
66
+ try {
67
+ decoded = decodeURIComponent(segment);
68
+ } catch {
69
+ return false;
70
+ }
71
+ return (
72
+ segment !== "" &&
73
+ decoded !== "." &&
74
+ decoded !== ".." &&
75
+ !decoded.includes("/") &&
76
+ !decoded.includes("\\")
77
+ );
78
+ });
62
79
  }
63
80
 
64
81
  /** Bounded clamp for server-advised 429 delays (04 §6: never trust raw body). */
@@ -81,7 +98,10 @@ const BACKPRESSURE_CLOSE_GRACE_MS = 1_000;
81
98
  * cap is refused on the headers, an undeclared one is cut off as soon as the bytes read pass it, and in both
82
99
  * cases the loopback connection is abandoned.
83
100
  */
84
- async function readTextCapped(res: Response, cap: number): Promise<{ ok: true; text: string } | { ok: false }> {
101
+ async function readTextCapped(
102
+ res: Response,
103
+ cap: number,
104
+ ): Promise<{ ok: true; text: string } | { ok: false }> {
85
105
  const declared = Number(res.headers.get("content-length"));
86
106
  if (Number.isFinite(declared) && declared > cap) {
87
107
  await res.body?.cancel().catch(() => {});
@@ -111,7 +131,8 @@ export type ConnectorState =
111
131
  | "connected" // valid hello received; transport up
112
132
  | "reauth" // non-terminal auth trouble: token_unknown / bare 401 — re-pair is the actionable fix
113
133
  | "revoked" // terminal: hub rejected the CURRENT credential tuple; pairing file removed
114
- | "hub-mismatch"; // terminal: TLS-trusted endpoint answered with a different hubId
134
+ | "hub-mismatch" // terminal: TLS-trusted endpoint answered with a different hubId
135
+ | "hub-key-mismatch"; // terminal (pin): the endpoint cannot prove the pinned hub identity key
115
136
 
116
137
  export interface ConnectorStatus {
117
138
  state: ConnectorState;
@@ -141,17 +162,71 @@ type AttemptOutcome =
141
162
  | { kind: "error"; message: string }
142
163
  | { kind: "hello"; serverTime: number }
143
164
  | { kind: "hub-id-mismatch"; sawHubId: string }
165
+ | { kind: "hub-key-mismatch"; message: string }
144
166
  | { kind: "hello-timeout" }
145
167
  | { kind: "stopped" };
146
168
 
147
169
  interface Classified {
148
- kind: "retry" | "reauth" | "revoked-terminal" | "hub-mismatch" | "stopped";
170
+ kind: "retry" | "reauth" | "revoked-terminal" | "hub-mismatch" | "hub-key-mismatch" | "stopped";
149
171
  delayMs?: number;
150
172
  detail: string;
151
173
  }
152
174
 
153
175
  const err = (code: string, message: string) => Object.assign(new Error(message), { code });
154
176
 
177
+ /**
178
+ * Gap #6 — verify the hub identity proof inside a tunnel `hello` against the
179
+ * pinned fingerprint. Returns undefined (pinned + proven), "adopt" (legacy
180
+ * pairing with NO pin: the proof verifies and the fingerprint may be pinned —
181
+ * safe because this point is only reachable after the hub accepted the dpt_
182
+ * credential, which only the real hub issued), or a refusal string.
183
+ */
184
+ function verifyHubHelloProof(
185
+ file: PairingFile,
186
+ hello: Extract<TunnelFrame, { type: "hello" }>,
187
+ nonce: string,
188
+ ): undefined | "adopt" | "skip-legacy-hub" | string {
189
+ if (!hello.hubPublicKey || !hello.hubSignature) {
190
+ // An OLD hub (pre-v3) cannot prove anything. A pinned pairing must refuse;
191
+ // a legacy one just proceeds un-pinned until the hub is upgraded.
192
+ return file.hubKeyFingerprint
193
+ ? "the hub hello carried no identity proof (it may predate offer v3); update the hub or re-pair"
194
+ : "skip-legacy-hub";
195
+ }
196
+ let fingerprint: string;
197
+ let verified: boolean;
198
+ try {
199
+ const pub = createPublicKey(hello.hubPublicKey);
200
+ if (pub.asymmetricKeyType !== "ed25519") return "the hub identity key is not Ed25519";
201
+ const der = pub.export({ format: "der", type: "spki" }) as Buffer;
202
+ fingerprint = `sha256:${createHash("sha256").update(der).digest("hex")}`;
203
+ if (hello.hubKeyFingerprint !== undefined && hello.hubKeyFingerprint !== fingerprint)
204
+ return "the hub's declared fingerprint does not match the key it sent";
205
+ verified = edVerify(
206
+ null,
207
+ Buffer.from(
208
+ hubProofSigningPayload({
209
+ hubOrigin: file.hubOrigin,
210
+ hubId: file.hubId,
211
+ subject: `tunnel:${file.deviceId}:${file.pairingEpoch}`,
212
+ nonce,
213
+ }),
214
+ ),
215
+ pub,
216
+ Buffer.from(hello.hubSignature, "base64url"),
217
+ );
218
+ } catch {
219
+ return "the hub identity proof is malformed";
220
+ }
221
+ if (!verified) return "the hub signature does not verify under the key it sent";
222
+ if (file.hubKeyFingerprint) {
223
+ return file.hubKeyFingerprint === fingerprint
224
+ ? undefined
225
+ : `hub key fingerprint ${fingerprint.slice(0, 19)}… differs from the pinned ${file.hubKeyFingerprint.slice(0, 19)}…`;
226
+ }
227
+ return "adopt";
228
+ }
229
+
155
230
  export class DeviceConnector {
156
231
  private file: PairingFile | undefined;
157
232
  private keypair: DeviceKeypair | undefined;
@@ -264,7 +339,15 @@ export class DeviceConnector {
264
339
  }
265
340
  const pairing = loadJson<PairingFile>(pairingPath(this.home));
266
341
  const keypair = loadJson<DeviceKeypair>(keypairPath(this.home));
267
- if (!pairing?.tunnelToken || !pairing?.hubUrl || !pairing?.deviceUid || !pairing?.deviceId || !pairing?.pairingEpoch || !pairing?.credentialId || typeof pairing?.generation !== "number") {
342
+ if (
343
+ !pairing?.tunnelToken ||
344
+ !pairing?.hubUrl ||
345
+ !pairing?.deviceUid ||
346
+ !pairing?.deviceId ||
347
+ !pairing?.pairingEpoch ||
348
+ !pairing?.credentialId ||
349
+ typeof pairing?.generation !== "number"
350
+ ) {
268
351
  this.setState("file-error", "pairing.json missing or malformed; run pairing again");
269
352
  return;
270
353
  }
@@ -358,6 +441,11 @@ export class DeviceConnector {
358
441
  this.setState("hub-mismatch", verdict.detail);
359
442
  break;
360
443
  }
444
+ if (verdict.kind === "hub-key-mismatch") {
445
+ this.stopped = true;
446
+ this.setState("hub-key-mismatch", verdict.detail);
447
+ break;
448
+ }
361
449
  if (verdict.kind === "reauth") this.setState("reauth", verdict.detail);
362
450
  else this.setState("connecting", verdict.detail);
363
451
  // A verdict without its own delay (transport failure, 5xx, re-pair-needed 401) walks the shared curve.
@@ -431,16 +519,23 @@ export class DeviceConnector {
431
519
  }
432
520
  finish({ kind: "auth", status: res.statusCode ?? 0, body, raw });
433
521
  });
434
- res.on("error", () => finish({ kind: "auth", status: res.statusCode ?? 0, body: null, raw: "" }));
522
+ res.on("error", () =>
523
+ finish({ kind: "auth", status: res.statusCode ?? 0, body: null, raw: "" }),
524
+ );
435
525
  });
436
526
 
437
527
  ws.on("error", (e) => finish({ kind: "error", message: e.message }));
438
- ws.on("close", (code, reason) => finish({ kind: "closed", code, reason: Buffer.from(reason).toString("utf8") }));
528
+ ws.on("close", (code, reason) =>
529
+ finish({ kind: "closed", code, reason: Buffer.from(reason).toString("utf8") }),
530
+ );
439
531
 
440
532
  ws.on("open", () => {
441
533
  if (settled || this.wsGeneration !== generation) return;
442
534
  // Identity handshake with the SAVED tuple; retry counter resets only
443
- // after a schema-valid hello below.
535
+ // after a schema-valid hello below. Every connect challenges the hub to
536
+ // prove its identity key: a PINNED pairing must see it match; a legacy
537
+ // pairing (no pin yet) adopts it through this authenticated channel.
538
+ const proofNonce = randomBytes(32).toString("base64url");
444
539
  const hs: TunnelFrame = {
445
540
  type: "pair-handshake",
446
541
  v: 1,
@@ -452,6 +547,7 @@ export class DeviceConnector {
452
547
  apiVersion: 1,
453
548
  tunnelProto: 1,
454
549
  keyFingerprint: this.keypair?.fingerprint ?? "",
550
+ ...(proofNonce ? { proofNonce } : {}),
455
551
  };
456
552
  try {
457
553
  ws.send(JSON.stringify(hs));
@@ -469,11 +565,17 @@ export class DeviceConnector {
469
565
  try {
470
566
  msg = JSON.parse(data.toString("utf8"));
471
567
  } catch {
472
- finish({ kind: "error", message: helloDone ? "frame: malformed JSON" : "hello: malformed JSON" });
568
+ finish({
569
+ kind: "error",
570
+ message: helloDone ? "frame: malformed JSON" : "hello: malformed JSON",
571
+ });
473
572
  return;
474
573
  }
475
574
  if (!checkTunnelMessage(msg)) {
476
- finish({ kind: "error", message: helloDone ? "frame: schema violation" : "hello: schema violation" });
575
+ finish({
576
+ kind: "error",
577
+ message: helloDone ? "frame: schema violation" : "hello: schema violation",
578
+ });
477
579
  return;
478
580
  }
479
581
  const frame = msg as TunnelFrame;
@@ -486,6 +588,21 @@ export class DeviceConnector {
486
588
  finish({ kind: "hub-id-mismatch", sawHubId: frame.hubId });
487
589
  return;
488
590
  }
591
+ // Gap #6 pin enforcement: a pinned pairing demands a valid identity
592
+ // proof in the very hello; anything else is a WRONG HUB, not a glitch.
593
+ const pinProblem = verifyHubHelloProof(file, frame, proofNonce);
594
+ if (
595
+ pinProblem !== undefined &&
596
+ pinProblem !== "adopt" &&
597
+ pinProblem !== "skip-legacy-hub"
598
+ ) {
599
+ try {
600
+ ws.close(1011, "hub identity proof failed");
601
+ } catch {}
602
+ finish({ kind: "hub-key-mismatch", message: pinProblem });
603
+ return;
604
+ }
605
+ if (pinProblem === "adopt") this.adoptPin(file, frame.hubKeyFingerprint!);
489
606
  // Connected: heartbeat + idle safety net from here (04 §5).
490
607
  helloDone = true;
491
608
  clearTimeout(helloTimer);
@@ -537,16 +654,34 @@ export class DeviceConnector {
537
654
  void (async () => {
538
655
  const check = validateRestRequest({ method: frame.method, path: frame.path });
539
656
  if (!check.ok) {
540
- send({ type: "rest-response", reqId: frame.reqId, status: 404, body: { error: { code: "invalid", message: check.error } } });
657
+ send({
658
+ type: "rest-response",
659
+ reqId: frame.reqId,
660
+ status: 404,
661
+ body: { error: { code: "invalid", message: check.error } },
662
+ });
541
663
  return;
542
664
  }
543
- const bodyBytes = frame.body === undefined ? 0 : Buffer.byteLength(JSON.stringify(frame.body), "utf8");
665
+ const bodyBytes =
666
+ frame.body === undefined ? 0 : Buffer.byteLength(JSON.stringify(frame.body), "utf8");
544
667
  if (bodyBytes > LOCAL_BODY_CAP) {
545
- send({ type: "rest-response", reqId: frame.reqId, status: 413, body: { error: { code: "invalid", message: `Request body exceeds ${LOCAL_BODY_CAP} bytes` } } });
668
+ send({
669
+ type: "rest-response",
670
+ reqId: frame.reqId,
671
+ status: 413,
672
+ body: {
673
+ error: { code: "invalid", message: `Request body exceeds ${LOCAL_BODY_CAP} bytes` },
674
+ },
675
+ });
546
676
  return;
547
677
  }
548
678
  const tooLarge = (message: string) =>
549
- send({ type: "rest-response", reqId: frame.reqId, status: 502, body: { error: { code: "response_too_large", message } } });
679
+ send({
680
+ type: "rest-response",
681
+ reqId: frame.reqId,
682
+ status: 502,
683
+ body: { error: { code: "response_too_large", message } },
684
+ });
550
685
  const call = new AbortController();
551
686
  this.restCalls.add(call);
552
687
  try {
@@ -556,7 +691,9 @@ export class DeviceConnector {
556
691
  headers: {
557
692
  authorization: `Bearer ${api.token}`,
558
693
  "x-dpi-token": api.token,
559
- ...(hasBody && frame.body !== undefined ? { "content-type": "application/json" } : {}),
694
+ ...(hasBody && frame.body !== undefined
695
+ ? { "content-type": "application/json" }
696
+ : {}),
560
697
  ...(frame.idempotencyKey ? { "idempotency-key": frame.idempotencyKey } : {}),
561
698
  },
562
699
  body: hasBody && frame.body !== undefined ? JSON.stringify(frame.body) : undefined,
@@ -565,7 +702,17 @@ export class DeviceConnector {
565
702
  });
566
703
  if (res.status >= 300 && res.status < 400) {
567
704
  await res.body?.cancel().catch(() => {});
568
- send({ type: "rest-response", reqId: frame.reqId, status: 502, body: { error: { code: "offline", message: `Device API answered with a redirect (${res.status}); redirects are not followed` } } });
705
+ send({
706
+ type: "rest-response",
707
+ reqId: frame.reqId,
708
+ status: 502,
709
+ body: {
710
+ error: {
711
+ code: "offline",
712
+ message: `Device API answered with a redirect (${res.status}); redirects are not followed`,
713
+ },
714
+ },
715
+ });
569
716
  return;
570
717
  }
571
718
  const read = await readTextCapped(res, LOGICAL_MESSAGE_MAX_BYTES);
@@ -574,7 +721,13 @@ export class DeviceConnector {
574
721
  return;
575
722
  }
576
723
  // Small responses ride one frame; large ones chunk (hub reassembles).
577
- const envelope = { type: "rest-response", reqId: frame.reqId, status: res.status, contentType: res.headers.get("content-type") ?? undefined, body: read.text } as const;
724
+ const envelope = {
725
+ type: "rest-response",
726
+ reqId: frame.reqId,
727
+ status: res.status,
728
+ contentType: res.headers.get("content-type") ?? undefined,
729
+ body: read.text,
730
+ } as const;
578
731
  const serialized = JSON.stringify(envelope);
579
732
  if (Buffer.byteLength(serialized, "utf8") <= INLINE_FRAME_MAX_BYTES) {
580
733
  await sendPaced(serialized);
@@ -587,7 +740,12 @@ export class DeviceConnector {
587
740
  }
588
741
  for (const chunk of plan.chunks) if (!(await sendPaced(chunk))) return;
589
742
  } catch (e) {
590
- send({ type: "rest-response", reqId: frame.reqId, status: 502, body: { error: { code: "offline", message: (e as Error).message } } });
743
+ send({
744
+ type: "rest-response",
745
+ reqId: frame.reqId,
746
+ status: 502,
747
+ body: { error: { code: "offline", message: (e as Error).message } },
748
+ });
591
749
  } finally {
592
750
  this.restCalls.delete(call);
593
751
  }
@@ -609,12 +767,21 @@ export class DeviceConnector {
609
767
  const sse = new SseReader(this.ingress);
610
768
  // This reader failed, the tunnel did not: say so for this subscription only and stop reading.
611
769
  const fault = (message: string) => {
612
- send({ type: "tunnel-data", frame: { type: "fault", error: { code: "invalid", message } }, subscriptionId: frame.subscriptionId, sub: frame.sub });
770
+ send({
771
+ type: "tunnel-data",
772
+ frame: { type: "fault", error: { code: "invalid", message } },
773
+ subscriptionId: frame.subscriptionId,
774
+ sub: frame.sub,
775
+ });
613
776
  ctl.abort();
614
777
  };
615
778
  try {
616
779
  const res = await fetch(api.baseUrl + frame.sub, {
617
- headers: { authorization: `Bearer ${api.token}`, "x-dpi-token": api.token, accept: "text/event-stream" },
780
+ headers: {
781
+ authorization: `Bearer ${api.token}`,
782
+ "x-dpi-token": api.token,
783
+ accept: "text/event-stream",
784
+ },
618
785
  signal: ctl.signal,
619
786
  });
620
787
  if (!res.ok || !res.body) throw new Error(`${frame.sub} answered ${res.status}`);
@@ -632,13 +799,22 @@ export class DeviceConnector {
632
799
  } catch {
633
800
  continue;
634
801
  }
635
- const wire = JSON.stringify({ type: "tunnel-data", frame: parsed, subscriptionId: frame.subscriptionId, sub: frame.sub });
802
+ const wire = JSON.stringify({
803
+ type: "tunnel-data",
804
+ frame: parsed,
805
+ subscriptionId: frame.subscriptionId,
806
+ sub: frame.sub,
807
+ });
636
808
  if (Buffer.byteLength(wire, "utf8") <= INLINE_FRAME_MAX_BYTES) {
637
809
  if (!(await sendPaced(wire))) return;
638
810
  continue;
639
811
  }
640
812
  // One WS message ≤1 MiB: large frames (big trees) chunk, one paced frame at a time.
641
- const plan = chunkMessage(parsed, { kind: "tunnel-data", subscriptionId: frame.subscriptionId, sub: frame.sub });
813
+ const plan = chunkMessage(parsed, {
814
+ kind: "tunnel-data",
815
+ subscriptionId: frame.subscriptionId,
816
+ sub: frame.sub,
817
+ });
642
818
  if (!plan.ok) return fault(plan.error);
643
819
  for (const chunk of plan.chunks) if (!(await sendPaced(chunk))) return;
644
820
  }
@@ -648,7 +824,8 @@ export class DeviceConnector {
648
824
  } finally {
649
825
  sse.close();
650
826
  ctl.abort();
651
- if (this.readers.get(frame.subscriptionId) === ctl) this.readers.delete(frame.subscriptionId);
827
+ if (this.readers.get(frame.subscriptionId) === ctl)
828
+ this.readers.delete(frame.subscriptionId);
652
829
  }
653
830
  })();
654
831
  return;
@@ -761,7 +938,11 @@ export class DeviceConnector {
761
938
  lowest = ws.bufferedAmount;
762
939
  progressAt = this.now();
763
940
  } else if (this.now() - progressAt >= this.drainMs) {
764
- this.closeForBackpressure(ws, generation, `outbound queue did not drain for ${this.drainMs} ms`);
941
+ this.closeForBackpressure(
942
+ ws,
943
+ generation,
944
+ `outbound queue did not drain for ${this.drainMs} ms`,
945
+ );
765
946
  return false;
766
947
  }
767
948
  await new Promise<void>((resolve) => setTimeout(resolve, DRAIN_POLL_MS));
@@ -793,6 +974,22 @@ export class DeviceConnector {
793
974
  ws.once("close", () => clearTimeout(force));
794
975
  }
795
976
 
977
+ /**
978
+ * Authenticated upgrade (gap #6 migration): a legacy pairing adopted the
979
+ * hub's identity fingerprint through the just-verified, dpt_-authenticated
980
+ * tunnel. Best effort — a failed write just means the pin is adopted on a
981
+ * later connect.
982
+ */
983
+ private adoptPin(file: PairingFile, fingerprint: string): void {
984
+ const next: PairingFile = { ...file, hubKeyFingerprint: fingerprint, updatedAt: this.now() };
985
+ try {
986
+ writeAtomic(pairingPath(this.home), JSON.stringify(next, null, 2));
987
+ } catch {
988
+ return; // pin adopted next connect
989
+ }
990
+ if (this.file?.operationId === file.operationId) this.file = next;
991
+ }
992
+
796
993
  private startHeartbeat(ws: WebSocket, generation: number): void {
797
994
  this.pingTimer = setInterval(() => {
798
995
  if (this.wsGeneration !== generation || ws.readyState !== ws.OPEN) return;
@@ -811,15 +1008,18 @@ export class DeviceConnector {
811
1008
  /* close event will settle */
812
1009
  }
813
1010
  }, this.pingMs);
814
- this.idleTimer = setInterval(() => {
815
- if (this.now() - this.lastByte > this.idleMs) {
816
- try {
817
- ws.terminate();
818
- } catch {
819
- /* already gone */
1011
+ this.idleTimer = setInterval(
1012
+ () => {
1013
+ if (this.now() - this.lastByte > this.idleMs) {
1014
+ try {
1015
+ ws.terminate();
1016
+ } catch {
1017
+ /* already gone */
1018
+ }
820
1019
  }
821
- }
822
- }, Math.min(this.idleMs, Math.max(200, Math.floor(this.idleMs / 4))));
1020
+ },
1021
+ Math.min(this.idleMs, Math.max(200, Math.floor(this.idleMs / 4))),
1022
+ );
823
1023
  }
824
1024
 
825
1025
  private classify(
@@ -837,41 +1037,79 @@ export class DeviceConnector {
837
1037
  return { kind: "retry", detail: `closed ${outcome.code} ${outcome.reason}`.trim() };
838
1038
  }
839
1039
  if (outcome.kind === "error") return { kind: "retry", detail: outcome.message };
840
- if (outcome.kind === "hello-timeout") return { kind: "retry", detail: "no hello within deadline" };
1040
+ if (outcome.kind === "hello-timeout")
1041
+ return { kind: "retry", detail: "no hello within deadline" };
841
1042
  if (outcome.kind === "hub-id-mismatch")
842
- return { kind: "hub-mismatch", detail: `hub answered with a different hubId (${outcome.sawHubId}); check the pair URL — this is not the hub you paired with` };
1043
+ return {
1044
+ kind: "hub-mismatch",
1045
+ detail: `hub answered with a different hubId (${outcome.sawHubId}); check the pair URL — this is not the hub you paired with`,
1046
+ };
1047
+ if (outcome.kind === "hub-key-mismatch")
1048
+ return {
1049
+ kind: "hub-key-mismatch",
1050
+ detail: `${outcome.message}; REFUSING to continue — the pinned hub identity (hubKeyFingerprint) does not match. Re-pair with a fresh offer link from the hub you trust`,
1051
+ };
843
1052
  // auth-shaped outcomes
844
1053
  const body = outcome.body as { type?: string; code?: string; retryAfterMs?: number } | null;
845
1054
  const parsed = body?.type === "tunnel.auth-error" ? body : null;
846
1055
  if (outcome.status === 429 || parsed?.code === "rate_limited") {
847
1056
  const advised = Number(parsed?.retryAfterMs ?? 0);
848
- const delay = Number.isFinite(advised) && advised > 0
849
- ? Math.max(RETRY_AFTER_MIN_MS, Math.min(RETRY_AFTER_MAX_MS, advised))
850
- : RETRY_AFTER_MAX_MS;
1057
+ const delay =
1058
+ Number.isFinite(advised) && advised > 0
1059
+ ? Math.max(RETRY_AFTER_MIN_MS, Math.min(RETRY_AFTER_MAX_MS, advised))
1060
+ : RETRY_AFTER_MAX_MS;
851
1061
  return { kind: "retry", delayMs: delay, detail: "rate limited; honoring bounded retryAfter" };
852
1062
  }
853
1063
  if (outcome.status >= 300 && outcome.status < 400)
854
- return { kind: "retry", detail: `redirect (${outcome.status}) rejected: connector uses one exact origin` };
1064
+ return {
1065
+ kind: "retry",
1066
+ detail: `redirect (${outcome.status}) rejected: connector uses one exact origin`,
1067
+ };
855
1068
  if (parsed?.code === "revoked") {
856
1069
  // Terminal ONLY when this attempt's tuple still matches the CURRENT file.
857
1070
  const current = this.file;
858
- if (current && current.credentialId === attemptTuple.credentialId && current.pairingEpoch === attemptTuple.pairingEpoch) {
1071
+ if (
1072
+ current &&
1073
+ current.credentialId === attemptTuple.credentialId &&
1074
+ current.pairingEpoch === attemptTuple.pairingEpoch
1075
+ ) {
859
1076
  // F05 disk-truth: a re-pair may have replaced the file mid-flight.
860
1077
  const onDisk = loadJson<PairingFile>(pairingPath(this.home));
861
- if (onDisk && (onDisk.credentialId !== attemptTuple.credentialId || onDisk.pairingEpoch !== attemptTuple.pairingEpoch)) {
1078
+ if (
1079
+ onDisk &&
1080
+ (onDisk.credentialId !== attemptTuple.credentialId ||
1081
+ onDisk.pairingEpoch !== attemptTuple.pairingEpoch)
1082
+ ) {
862
1083
  this.file = onDisk; // adopt the newer pairing and keep going
863
1084
  this.backoff.reset();
864
- return { kind: "retry", delayMs: 0, detail: "revoked for an old credential; a newer pairing file is on disk" };
1085
+ return {
1086
+ kind: "retry",
1087
+ delayMs: 0,
1088
+ detail: "revoked for an old credential; a newer pairing file is on disk",
1089
+ };
865
1090
  }
866
1091
  this.removePairingFile();
867
- return { kind: "revoked-terminal", detail: "hub revoked this credential; pairing removed — re-pair to continue" };
1092
+ return {
1093
+ kind: "revoked-terminal",
1094
+ detail: "hub revoked this credential; pairing removed — re-pair to continue",
1095
+ };
868
1096
  }
869
- return { kind: "retry", delayMs: this.backoff.ms, detail: "late revoked response for a replaced credential; ignored" };
1097
+ return {
1098
+ kind: "retry",
1099
+ delayMs: this.backoff.ms,
1100
+ detail: "late revoked response for a replaced credential; ignored",
1101
+ };
870
1102
  }
871
1103
  if (parsed?.code === "token_unknown" || outcome.status === 401) {
872
1104
  // Non-terminal, actionable: the operator can re-pair any time; the
873
1105
  // connector keeps trying with backoff and never hides that fact.
874
- return { kind: "reauth", detail: parsed?.code === "token_unknown" ? "hub does not recognize this credential anymore; re-pair when convenient (still retrying)" : "bare 401 without a parsable auth-error body; still retrying" };
1106
+ return {
1107
+ kind: "reauth",
1108
+ detail:
1109
+ parsed?.code === "token_unknown"
1110
+ ? "hub does not recognize this credential anymore; re-pair when convenient (still retrying)"
1111
+ : "bare 401 without a parsable auth-error body; still retrying",
1112
+ };
875
1113
  }
876
1114
  return { kind: "retry", detail: `upgrade failed with HTTP ${outcome.status}` };
877
1115
  }
@@ -942,7 +1180,8 @@ export class DeviceConnector {
942
1180
  }
943
1181
 
944
1182
  /** sha256 hex of a token — pairing parity helper (used by tests/tools). */
945
- export const tokenHash = (token: string) => createHash("sha256").update(token, "utf8").digest("hex");
1183
+ export const tokenHash = (token: string) =>
1184
+ createHash("sha256").update(token, "utf8").digest("hex");
946
1185
 
947
1186
  export { err as connectorError };
948
1187
  export const readCaPem = (path: string | undefined): string | undefined => {