@johnhenry/wsh 0.16.1 → 0.17.1

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/src/index.d.ts CHANGED
@@ -228,6 +228,16 @@ export const AUTH_METHOD: {
228
228
  /** Protocol version string. */
229
229
  export const PROTOCOL_VERSION: 'wsh-v1';
230
230
 
231
+ /**
232
+ * ServerHello feature advertising that McpResult echoes McpCall's call_id.
233
+ *
234
+ * Compare against `WshClient.hasFeature()` before setting `callId`:
235
+ * McpCallPayload is `deny_unknown_fields` server-side, so sending the field
236
+ * to a server predating it makes the server reject the call rather than
237
+ * ignore the field.
238
+ */
239
+ export const MCP_CALL_ID_FEATURE: 'mcp-call-id';
240
+
231
241
  /** A wsh protocol control message (all messages have a numeric `type`). */
232
242
  export interface WshMessage {
233
243
  type: number;
@@ -396,10 +406,14 @@ export function mcpTools(opts?: {
396
406
  export function mcpCall(opts?: {
397
407
  tool?: string;
398
408
  arguments?: Record<string, unknown>;
409
+ /** Correlates this call with its McpResult. Omitted from the message entirely when undefined. */
410
+ callId?: string;
399
411
  }): WshMessage;
400
412
 
401
413
  export function mcpResult(opts?: {
402
414
  result?: unknown;
415
+ /** Echoed verbatim from the McpCall this answers. */
416
+ callId?: string;
403
417
  }): WshMessage;
404
418
 
405
419
  // -- Message constructors (reverse) --
@@ -764,6 +778,14 @@ export function fileResult(opts?: {
764
778
  channelId?: number;
765
779
  success?: boolean;
766
780
  metadata?: Record<string, unknown>;
781
+ /** wsh #59: typed directory entries for a "list" op (see WshFileListEntry-shaped wire fields). */
782
+ entries?: Array<{
783
+ name: string;
784
+ size: number;
785
+ modified: number;
786
+ type: 'file' | 'directory' | 'symlink' | 'device' | 'pipe' | 'socket';
787
+ symlink_target?: string;
788
+ }>;
767
789
  errorMessage?: string;
768
790
  }): WshMessage;
769
791
 
@@ -775,6 +797,18 @@ export function fileChunk(opts?: {
775
797
  totalSize?: number;
776
798
  }): WshMessage;
777
799
 
800
+ /** wsh #59: install a public key into the remote's authorized_keys (see WshClient.addAuthorizedKey). */
801
+ export function authorizedKeyAdd(opts?: {
802
+ publicKey?: Uint8Array;
803
+ comment?: string;
804
+ }): WshMessage;
805
+
806
+ export function authorizedKeyResult(opts?: {
807
+ success?: boolean;
808
+ added?: boolean;
809
+ errorMessage?: string;
810
+ }): WshMessage;
811
+
778
812
  export function policyEval(opts?: {
779
813
  requestId?: string;
780
814
  action?: string;
@@ -817,7 +851,19 @@ export function isValidMessage(msg: unknown): boolean;
817
851
  // ============================================================================
818
852
 
819
853
  /**
820
- * Generate a new Ed25519 key pair.
854
+ * Whether this runtime's WebCrypto implements Ed25519, measured by
855
+ * generating a key rather than sniffed from a version string.
856
+ *
857
+ * There is no pure-JS fallback: one would need the private scalar as
858
+ * ordinary bytes, giving up the non-extractable `CryptoKey` property
859
+ * `WshKeyStore` is built around. Check this before committing to pubkey
860
+ * auth and fall back to password auth where it returns false.
861
+ */
862
+ export function isEd25519Supported(): Promise<boolean>;
863
+
864
+ /**
865
+ * Generate a new Ed25519 key pair. Throws with an actionable message
866
+ * where WebCrypto has no Ed25519 -- see `isEd25519Supported()`.
821
867
  * @param extractable - Whether private key can be exported (default false)
822
868
  */
823
869
  export function generateKeyPair(extractable?: boolean): Promise<CryptoKeyPair>;
@@ -1017,7 +1063,7 @@ export class WshTransport {
1017
1063
  readonly state: 'disconnected' | 'connecting' | 'connected' | 'closed';
1018
1064
 
1019
1065
  /** Connect to a wsh server. */
1020
- connect(url: string): Promise<void>;
1066
+ connect(url: string, options?: object): Promise<void>;
1021
1067
 
1022
1068
  /** Gracefully close the transport. */
1023
1069
  close(): Promise<void>;
@@ -1044,7 +1090,7 @@ export class WshTransport {
1044
1090
  protected _emitError(err: Error): void;
1045
1091
 
1046
1092
  /** @protected Must be overridden by subclasses. */
1047
- protected _doConnect(url: string): Promise<void>;
1093
+ protected _doConnect(url: string, options?: object): Promise<void>;
1048
1094
 
1049
1095
  /** @protected Must be overridden by subclasses. */
1050
1096
  protected _doClose(): Promise<void>;
@@ -1064,7 +1110,65 @@ export class WshTransport {
1064
1110
  * - Subsequent streams carry raw byte data (no framing).
1065
1111
  * - Server-initiated streams are surfaced via onStreamOpen.
1066
1112
  */
1067
- export class WebTransportTransport extends WshTransport {}
1113
+ export class WebTransportTransport extends WshTransport {
1114
+ /**
1115
+ * @param options Forwarded to the `WebTransport` constructor. Options
1116
+ * passed to `connect()` override these per call.
1117
+ */
1118
+ constructor(options?: WshWebTransportOptions);
1119
+
1120
+ connect(url: string, options?: WshWebTransportOptions): Promise<void>;
1121
+ }
1122
+
1123
+ /**
1124
+ * One entry of `serverCertificateHashes`. The digest may be given as raw
1125
+ * bytes, as hex (with or without `:` separators -- what
1126
+ * `openssl x509 -fingerprint -sha256` prints), or as base64/base64url.
1127
+ */
1128
+ export interface WshCertificateHash {
1129
+ /** Defaults to `'sha-256'`, the only algorithm the platform accepts today. */
1130
+ algorithm?: string;
1131
+ value: BufferSource | string;
1132
+ }
1133
+
1134
+ /**
1135
+ * Options forwarded to the `WebTransport` constructor.
1136
+ *
1137
+ * `serverCertificateHashes` is the interesting one: it pins a specific
1138
+ * self-signed certificate by digest, which is the only mechanism in the
1139
+ * web platform that lets page JavaScript reach a server whose certificate
1140
+ * no certificate authority signed. The platform's own constraints apply --
1141
+ * `https:` URL, HTTP/3 only, ECDSA P-256 key, at most 14 days validity,
1142
+ * no connection pooling.
1143
+ *
1144
+ * Every other key is forwarded verbatim, so platform options not listed
1145
+ * here still work.
1146
+ */
1147
+ export interface WshWebTransportOptions {
1148
+ serverCertificateHashes?: Array<WshCertificateHash | BufferSource | string>;
1149
+ allowPooling?: boolean;
1150
+ requireUnreliable?: boolean;
1151
+ congestionControl?: 'default' | 'throughput' | 'low-latency';
1152
+ anticipatedConcurrentIncomingUnidirectionalStreams?: number | null;
1153
+ anticipatedConcurrentIncomingBidirectionalStreams?: number | null;
1154
+ protocols?: string[];
1155
+ [key: string]: unknown;
1156
+ }
1157
+
1158
+ /**
1159
+ * Decode one `serverCertificateHashes` digest into bytes. Accepts a
1160
+ * `BufferSource`, hex (separators and an `... Fingerprint=` prefix are
1161
+ * stripped), or base64/base64url. Throws a `TypeError` on an
1162
+ * unrecognised shape and a `RangeError` on a digest of the wrong length
1163
+ * for the algorithm.
1164
+ */
1165
+ export function parseCertificateHash(value: BufferSource | string, algorithm?: string): Uint8Array;
1166
+
1167
+ /**
1168
+ * Normalise a caller-supplied options bag into a `WebTransportOptions`
1169
+ * dictionary, or `undefined` when there is nothing to pass.
1170
+ */
1171
+ export function normalizeWebTransportOptions(options?: WshWebTransportOptions): object | undefined;
1068
1172
 
1069
1173
  /**
1070
1174
  * Dispatch a fixed, already-available batch of items one at a time,
@@ -1307,8 +1411,21 @@ export class WshSession {
1307
1411
  /** Called when the remote process exits. */
1308
1412
  onExit: ((code: number) => void) | null;
1309
1413
 
1310
- /** Called when the session is fully closed. */
1311
- onClose: (() => void) | null;
1414
+ /**
1415
+ * Called when the session is fully closed.
1416
+ *
1417
+ * Receives the same value as `closeReason`: an Error for a torn-down
1418
+ * session, null for an ordinary close.
1419
+ */
1420
+ onClose: ((reason: Error | null) => void) | null;
1421
+
1422
+ /**
1423
+ * Why the session closed, or null for an ordinary close.
1424
+ *
1425
+ * A torn-down session and a clean exit are otherwise indistinguishable
1426
+ * from the outside.
1427
+ */
1428
+ readonly closeReason: Error | null;
1312
1429
 
1313
1430
  /** Called when the remote peer acknowledges speculative local echo. */
1314
1431
  onEchoAck: ((payload: Record<string, unknown>) => void) | null;
@@ -1427,6 +1544,12 @@ export interface WshConnectOptions {
1427
1544
  keyPair?: CryptoKeyPair;
1428
1545
  password?: string;
1429
1546
  transport?: 'wt' | 'ws';
1547
+ /**
1548
+ * Forwarded to the `WebTransport` constructor when the WebTransport
1549
+ * rung of the transport ladder is tried; ignored by the WebSocket rung,
1550
+ * which has no `serverCertificateHashes` equivalent.
1551
+ */
1552
+ webTransport?: WshWebTransportOptions;
1430
1553
  timeout?: number;
1431
1554
  }
1432
1555
 
@@ -1466,6 +1589,8 @@ export interface WshConnectReverseOptions {
1466
1589
  username: string;
1467
1590
  keyPair?: CryptoKeyPair;
1468
1591
  password?: string;
1592
+ /** Forwarded to the underlying `connect()` -- see `WshConnectOptions`. */
1593
+ webTransport?: WshWebTransportOptions;
1469
1594
  expose?: {
1470
1595
  shell?: boolean;
1471
1596
  exec?: boolean;
@@ -1544,6 +1669,28 @@ export interface WshE2EResult {
1544
1669
  hybrid: boolean;
1545
1670
  }
1546
1671
 
1672
+ /**
1673
+ * Raw `FileResult` response to a structured file-channel operation
1674
+ * (`WshClient.fileList`/`fileRemove`/etc.), as received off the wire --
1675
+ * wire field names (snake_case), not the camelCase shape the `fileResult()`
1676
+ * message constructor takes as input. `entries` is only populated for a
1677
+ * `"list"` op; `error_message` is only present on failure.
1678
+ */
1679
+ export interface WshFileOperationResult {
1680
+ type: number;
1681
+ channel_id: number;
1682
+ success: boolean;
1683
+ metadata: Record<string, unknown>;
1684
+ entries: Array<{
1685
+ name: string;
1686
+ size: number;
1687
+ modified: number;
1688
+ type: 'file' | 'directory' | 'symlink' | 'device' | 'pipe' | 'socket';
1689
+ symlink_target?: string;
1690
+ }>;
1691
+ error_message?: string;
1692
+ }
1693
+
1547
1694
  /**
1548
1695
  * WshClient -- manages a wsh connection, authentication, and multiple sessions.
1549
1696
  *
@@ -1659,6 +1806,36 @@ export class WshClient {
1659
1806
  */
1660
1807
  revokeSessionAccess(sessionId: string, principal: string, reason?: string): Promise<void>;
1661
1808
 
1809
+ /**
1810
+ * List a remote directory over the structured file channel (`FileOp`
1811
+ * op `"list"`). Prefer `WshFileTransfer.list()` for a typed
1812
+ * `WshFileListEntry[]` result (with `modified` as a `Date`) -- this is
1813
+ * the raw wire-shaped `FileResult` response `fileOperation()` waits on.
1814
+ */
1815
+ fileList(path: string, timeout?: number): Promise<WshFileOperationResult>;
1816
+
1817
+ /**
1818
+ * Remove a remote file or directory (`FileOp` op `"remove"`). Refused
1819
+ * today by every `wsh-server` release ("not yet implemented" -- see
1820
+ * README's file-op parity table); wired here so clients built against
1821
+ * this SDK don't need to wait on the server to type-check against it.
1822
+ */
1823
+ fileRemove(path: string, timeout?: number): Promise<WshFileOperationResult>;
1824
+
1825
+ /**
1826
+ * Install a raw 32-byte Ed25519 public key into the remote host's
1827
+ * `~/.wsh/authorized_keys` (wsh #59's `AuthorizedKeyAdd` protocol
1828
+ * message -- replaces `wsh copy-id`'s previous CLI-only shell-command
1829
+ * approach with something every implementation, including this one,
1830
+ * can call directly). Idempotent: installing an already-present key
1831
+ * resolves `added: false` rather than duplicating the line or erroring.
1832
+ */
1833
+ addAuthorizedKey(
1834
+ publicKeyRaw: Uint8Array,
1835
+ comment?: string,
1836
+ timeout?: number,
1837
+ ): Promise<{ added: boolean }>;
1838
+
1662
1839
  /**
1663
1840
  * Gracefully disconnect: close all sessions and the transport.
1664
1841
  */
@@ -1697,6 +1874,31 @@ export class WshClient {
1697
1874
  */
1698
1875
  sendRelayControl(msg: WshMessage): Promise<void>;
1699
1876
 
1877
+ /**
1878
+ * Send a raw control message on the authenticated connection.
1879
+ *
1880
+ * Prefer the typed methods. This is the hook `WshMcpBridge` and
1881
+ * `WshFileTransfer` compose their own messages through.
1882
+ */
1883
+ sendControl(msg: WshMessage): Promise<void>;
1884
+
1885
+ /**
1886
+ * Open a raw bidirectional stream on the underlying transport.
1887
+ */
1888
+ openStream(): Promise<WshStream>;
1889
+
1890
+ /**
1891
+ * Observe every inbound control message this client handles. Listeners
1892
+ * are called past the RelayForward trust gate, so a message from an
1893
+ * unaccepted peer never reaches one.
1894
+ */
1895
+ addControlListener(fn: (msg: WshMessage) => void): void;
1896
+
1897
+ /**
1898
+ * Stop observing inbound control messages.
1899
+ */
1900
+ removeControlListener(fn: (msg: WshMessage) => void): void;
1901
+
1700
1902
  /**
1701
1903
  * Initiate end-to-end encryption for a session: `'X25519'` (default,
1702
1904
  * classical ECDH) or `'X25519+ML-KEM-768'` (hybrid, with automatic
@@ -1863,6 +2065,48 @@ export class WshKeyStore {
1863
2065
  restore(passphrase: string): Promise<WshRestoreResult>;
1864
2066
  }
1865
2067
 
2068
+ // ============================================================================
2069
+ // known-hosts.mjs -- WshKnownHosts (wsh #59)
2070
+ // ============================================================================
2071
+
2072
+ /** Minimal storage interface `WshKnownHosts` needs -- `localStorage` already satisfies it. */
2073
+ export interface WshKnownHostsStorage {
2074
+ getItem(key: string): string | null;
2075
+ setItem(key: string, value: string): void;
2076
+ removeItem(key: string): void;
2077
+ }
2078
+
2079
+ /** Result of verifying a host's fingerprint against the stored record. */
2080
+ export interface WshHostVerifyResult {
2081
+ status: 'known' | 'unknown' | 'changed';
2082
+ /** Previously stored fingerprint, present only when status === 'changed'. */
2083
+ expected?: string;
2084
+ }
2085
+
2086
+ /**
2087
+ * WshKnownHosts -- trust-on-first-use (TOFU) host-identity store, mirroring
2088
+ * Rust's `KnownHosts`/`HostStatus` (`crates/wsh-client/src/known_hosts.rs`).
2089
+ * Pins `ServerHello.host_fingerprint` (spec/wsh-v1.yaml, wsh #59) -- no
2090
+ * `wsh-server` release populates that field yet, so this has nothing
2091
+ * authoritative to verify against for any real server today. See the
2092
+ * class doc comment in `known-hosts.mjs` for the full reasoning.
2093
+ */
2094
+ export class WshKnownHosts {
2095
+ constructor(opts?: { storage?: WshKnownHostsStorage; storageKey?: string });
2096
+
2097
+ /** Verify a host's fingerprint against the stored record. */
2098
+ verifyHost(host: string, fingerprint: string): WshHostVerifyResult;
2099
+
2100
+ /** Add or update a host's fingerprint. */
2101
+ addHost(host: string, fingerprint: string): void;
2102
+
2103
+ /** Remove a host entry. Returns true if an entry was removed. */
2104
+ removeHost(host: string): boolean;
2105
+
2106
+ /** List all known hosts and their fingerprints. */
2107
+ list(): Array<{ host: string; fingerprint: string }>;
2108
+ }
2109
+
1866
2110
  // ============================================================================
1867
2111
  // file-transfer.mjs -- WshFileTransfer
1868
2112
  // ============================================================================
@@ -1885,12 +2129,21 @@ export interface WshFileUploadResult {
1885
2129
  bytesTransferred: number;
1886
2130
  }
1887
2131
 
1888
- /** File listing entry. */
2132
+ /**
2133
+ * File listing entry (wsh #59: generated from spec/wsh-v1.yaml's
2134
+ * `FileEntry` nested type -- the same shape `crates/wsh-core`'s Rust
2135
+ * `FileEntry` struct is generated from, so a directory entry can no longer
2136
+ * describe a symlink as a plain file in one implementation and not the
2137
+ * other). `modified` is converted from the wire's Unix epoch seconds into
2138
+ * a `Date` for JS ergonomics. `symlinkTarget` is only present when
2139
+ * `type === 'symlink'`.
2140
+ */
1889
2141
  export interface WshFileListEntry {
1890
2142
  name: string;
1891
2143
  size: number;
1892
- modified: string;
2144
+ modified: Date;
1893
2145
  type: 'file' | 'directory' | 'symlink' | 'device' | 'pipe' | 'socket';
2146
+ symlinkTarget?: string;
1894
2147
  }
1895
2148
 
1896
2149
  /**
package/src/index.mjs CHANGED
@@ -35,6 +35,7 @@ export {
35
35
  nodeAnnounce, nodeRedirect,
36
36
  sessionGrant, sessionRevoke,
37
37
  fileOp, fileResult, fileChunk,
38
+ authorizedKeyAdd, authorizedKeyResult,
38
39
  policyEval, policyResult, policyUpdate,
39
40
  terminalConfig,
40
41
  msgName, isValidMessage,
@@ -42,6 +43,7 @@ export {
42
43
 
43
44
  // Auth + crypto
44
45
  export {
46
+ isEd25519Supported,
45
47
  generateKeyPair, exportPublicKeyRaw, exportPublicKeySSH,
46
48
  importPublicKeyRaw, exportPrivateKeyPKCS8, importPrivateKeyPKCS8,
47
49
  sign, verify, buildTranscript, signChallenge, verifyChallenge,
@@ -51,7 +53,10 @@ export {
51
53
  } from './auth.mjs';
52
54
 
53
55
  // Transport
54
- export { WshTransport, WebTransportTransport, dispatchSerially, SerialQueue } from './transport.mjs';
56
+ export {
57
+ WshTransport, WebTransportTransport, dispatchSerially, SerialQueue,
58
+ parseCertificateHash, normalizeWebTransportOptions,
59
+ } from './transport.mjs';
55
60
  export { WebSocketTransport, WS_FRAME_TYPE } from './transport-ws.mjs';
56
61
 
57
62
  // QMux (QUIC-v1 frames over an ordered byte stream, draft-ietf-quic-qmux-02)
@@ -68,11 +73,14 @@ export {
68
73
  // Session + Client
69
74
  export { WshSession } from './session.mjs';
70
75
  export { WshVirtualSessionBackend, normalizeSessionData } from './virtual-session.mjs';
71
- export { WshClient } from './client.mjs';
76
+ export { WshClient, MCP_CALL_ID_FEATURE } from './client.mjs';
72
77
 
73
78
  // Key storage
74
79
  export { WshKeyStore } from './keystore.mjs';
75
80
 
81
+ // Host identity (TOFU) -- wsh #59
82
+ export { WshKnownHosts } from './known-hosts.mjs';
83
+
76
84
  // File transfer
77
85
  export { WshFileTransfer } from './file-transfer.mjs';
78
86
 
@@ -0,0 +1,139 @@
1
+ /**
2
+ * WshKnownHosts — trust-on-first-use (TOFU) host-identity store for the
3
+ * browser SDK (wsh #59).
4
+ *
5
+ * ## Why this exists, and why it is not yet load-bearing
6
+ *
7
+ * wsh #59 asked for a decision on host identity: TOFU *policy* (when to
8
+ * prompt, how long to trust) is legitimately per-implementation, but the
9
+ * *host identity record* -- what a fingerprint covers, how it is computed,
10
+ * what counts as "the same host" -- must not be invented twice. This class
11
+ * mirrors Rust's `KnownHosts`/`HostStatus`
12
+ * (`crates/wsh-client/src/known_hosts.rs`) in shape and semantics --
13
+ * `verifyHost`/`addHost`/`removeHost`/`list`, keyed by a host label,
14
+ * storing one fingerprint per host -- so the browser gets the same
15
+ * mechanism rather than a second, differently-shaped answer arrived at
16
+ * later under pressure.
17
+ *
18
+ * What it pins is `ServerHello.host_fingerprint` (spec/wsh-v1.yaml, added
19
+ * by this same change): the server's own persistent Ed25519 host-identity
20
+ * key, SHA-256-fingerprinted the same way `fingerprint()` in `auth.mjs`
21
+ * already fingerprints user/peer keys. That field is new spec surface,
22
+ * not new server behavior -- no `wsh-server` release populates it yet.
23
+ * Minting and persisting a server host keypair is a separate,
24
+ * security-sensitive feature this change does not implement (see the
25
+ * wsh #59 PR description for the full reasoning). Until a server
26
+ * populates it, `verifyHost()` has nothing authoritative to check for any
27
+ * real deployment -- exactly the same degraded state Rust's own
28
+ * `verify_host_key` is in today when it falls back to `fingerprints[0]`
29
+ * (an authorized *client* key, not a host identity -- see that fallback's
30
+ * comment in `client.rs`). This store does not paper over that; a caller
31
+ * that gets `'unknown'` back from every real server today is seeing the
32
+ * honest state of host verification in this protocol version, not a bug
33
+ * in this class.
34
+ *
35
+ * The README's `serverCertificateHashes` pinning
36
+ * (see "Pinning a Self-Signed Certificate") is a different mechanism: it
37
+ * pins a certificate supplied *per connection*, decided by the caller in
38
+ * advance -- it is not a persisted record of what a host presented last
39
+ * time, so it does not already solve TOFU. `WshKnownHosts` is the
40
+ * missing persisted-record half.
41
+ *
42
+ * ## Storage
43
+ *
44
+ * Pluggable (defaults to `localStorage` when available, else an
45
+ * in-memory `Map` that does not survive a page reload) since "persist
46
+ * trust across sessions" needs a browser storage API this package has
47
+ * never assumed before. Pass `{ storage }` with a `getItem`/`setItem`/
48
+ * `removeItem` interface (the `Storage` interface `localStorage`
49
+ * already implements) to use IndexedDB-via-adapter, `sessionStorage`,
50
+ * or anything else.
51
+ */
52
+
53
+ const STORAGE_KEY = 'wsh:known-hosts:v1';
54
+
55
+ /** In-memory fallback storage, used when no persistent storage is available or injected. */
56
+ class MemoryStorage {
57
+ #map = new Map();
58
+ getItem(key) { return this.#map.has(key) ? this.#map.get(key) : null; }
59
+ setItem(key, value) { this.#map.set(key, value); }
60
+ removeItem(key) { this.#map.delete(key); }
61
+ }
62
+
63
+ function defaultStorage() {
64
+ if (typeof localStorage !== 'undefined' && localStorage) return localStorage;
65
+ return new MemoryStorage();
66
+ }
67
+
68
+ export class WshKnownHosts {
69
+ #storage;
70
+ #storageKey;
71
+
72
+ /**
73
+ * @param {object} [opts]
74
+ * @param {{getItem: function, setItem: function, removeItem: function}} [opts.storage]
75
+ * Storage backend (defaults to `localStorage` if present, else an
76
+ * in-memory `Map` scoped to this instance).
77
+ * @param {string} [opts.storageKey] - Override the storage key (useful
78
+ * for isolating multiple instances/tests under one storage backend).
79
+ */
80
+ constructor({ storage, storageKey = STORAGE_KEY } = {}) {
81
+ this.#storage = storage || defaultStorage();
82
+ this.#storageKey = storageKey;
83
+ }
84
+
85
+ /**
86
+ * Verify a host's fingerprint against the stored record.
87
+ *
88
+ * @param {string} host - Host label (e.g. "example.com:4422")
89
+ * @param {string} fingerprint - Hex SHA-256 fingerprint to check
90
+ * @returns {{ status: 'known' | 'unknown' | 'changed', expected?: string }}
91
+ */
92
+ verifyHost(host, fingerprint) {
93
+ const entries = this.#load();
94
+ if (!(host in entries)) {
95
+ return { status: 'unknown' };
96
+ }
97
+ if (entries[host] === fingerprint) {
98
+ return { status: 'known' };
99
+ }
100
+ return { status: 'changed', expected: entries[host] };
101
+ }
102
+
103
+ /** Add or update a host's fingerprint. */
104
+ addHost(host, fingerprint) {
105
+ const entries = this.#load();
106
+ entries[host] = fingerprint;
107
+ this.#save(entries);
108
+ }
109
+
110
+ /** Remove a host entry. Returns true if an entry was removed. */
111
+ removeHost(host) {
112
+ const entries = this.#load();
113
+ if (!(host in entries)) return false;
114
+ delete entries[host];
115
+ this.#save(entries);
116
+ return true;
117
+ }
118
+
119
+ /** List all known hosts and their fingerprints. */
120
+ list() {
121
+ const entries = this.#load();
122
+ return Object.entries(entries).map(([host, fingerprint]) => ({ host, fingerprint }));
123
+ }
124
+
125
+ #load() {
126
+ const raw = this.#storage.getItem(this.#storageKey);
127
+ if (!raw) return {};
128
+ try {
129
+ const parsed = JSON.parse(raw);
130
+ return parsed && typeof parsed === 'object' ? parsed : {};
131
+ } catch {
132
+ return {};
133
+ }
134
+ }
135
+
136
+ #save(entries) {
137
+ this.#storage.setItem(this.#storageKey, JSON.stringify(entries));
138
+ }
139
+ }