@hwip/cli 0.0.1 → 0.1.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.
Files changed (245) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +11 -0
  3. package/README.md +1983 -0
  4. package/dist/agent-list.d.ts +76 -0
  5. package/dist/agent-list.js +102 -0
  6. package/dist/agent-read.d.ts +105 -0
  7. package/dist/agent-read.js +309 -0
  8. package/dist/agent-rules-fetch.d.ts +51 -0
  9. package/dist/agent-rules-fetch.js +275 -0
  10. package/dist/agent-rules.default.d.ts +30 -0
  11. package/dist/agent-rules.default.js +305 -0
  12. package/dist/agent-session.d.ts +23 -0
  13. package/dist/agent-session.js +44 -0
  14. package/dist/agent-state.d.ts +84 -0
  15. package/dist/agent-state.js +188 -0
  16. package/dist/agent-target.d.ts +60 -0
  17. package/dist/agent-target.js +81 -0
  18. package/dist/agents.d.ts +31 -0
  19. package/dist/agents.js +75 -0
  20. package/dist/api.d.ts +28 -0
  21. package/dist/api.js +76 -0
  22. package/dist/args.d.ts +74 -0
  23. package/dist/args.js +254 -0
  24. package/dist/ask-context-attach.d.ts +73 -0
  25. package/dist/ask-context-attach.js +151 -0
  26. package/dist/ask-context-screen.d.ts +21 -0
  27. package/dist/ask-context-screen.js +49 -0
  28. package/dist/ask-context-switch.d.ts +31 -0
  29. package/dist/ask-context-switch.js +74 -0
  30. package/dist/ask-context.d.ts +84 -0
  31. package/dist/ask-context.js +172 -0
  32. package/dist/ask-pending.d.ts +47 -0
  33. package/dist/ask-pending.js +221 -0
  34. package/dist/ask-seal.d.ts +120 -0
  35. package/dist/ask-seal.js +179 -0
  36. package/dist/ask.d.ts +107 -0
  37. package/dist/ask.js +551 -0
  38. package/dist/blocked-prompt-seal.d.ts +39 -0
  39. package/dist/blocked-prompt-seal.js +92 -0
  40. package/dist/blocked-prompt.d.ts +62 -0
  41. package/dist/blocked-prompt.js +518 -0
  42. package/dist/channel.d.ts +67 -0
  43. package/dist/channel.js +136 -0
  44. package/dist/check-update.d.ts +28 -0
  45. package/dist/check-update.js +153 -0
  46. package/dist/cli.d.ts +3 -0
  47. package/dist/cli.js +813 -0
  48. package/dist/codex-config.d.ts +24 -0
  49. package/dist/codex-config.js +169 -0
  50. package/dist/codex-transcript.d.ts +59 -0
  51. package/dist/codex-transcript.js +373 -0
  52. package/dist/command-refusal.d.ts +56 -0
  53. package/dist/command-refusal.js +127 -0
  54. package/dist/command-scan.d.ts +46 -0
  55. package/dist/command-scan.js +154 -0
  56. package/dist/command-seal.d.ts +57 -0
  57. package/dist/command-seal.js +88 -0
  58. package/dist/command-seq.d.ts +30 -0
  59. package/dist/command-seq.js +150 -0
  60. package/dist/config.d.ts +156 -0
  61. package/dist/config.js +392 -0
  62. package/dist/core-sections.d.ts +18 -0
  63. package/dist/core-sections.js +28 -0
  64. package/dist/crypto.d.ts +278 -0
  65. package/dist/crypto.js +698 -0
  66. package/dist/desktop-notify.d.ts +33 -0
  67. package/dist/desktop-notify.js +49 -0
  68. package/dist/device-directory.d.ts +36 -0
  69. package/dist/device-directory.js +97 -0
  70. package/dist/device-presence.d.ts +17 -0
  71. package/dist/device-presence.js +62 -0
  72. package/dist/downloads.d.ts +29 -0
  73. package/dist/downloads.js +106 -0
  74. package/dist/errors.d.ts +12 -0
  75. package/dist/errors.js +28 -0
  76. package/dist/file-crypto.d.ts +94 -0
  77. package/dist/file-crypto.js +278 -0
  78. package/dist/file-view-budget.d.ts +15 -0
  79. package/dist/file-view-budget.js +34 -0
  80. package/dist/file-view-switch.d.ts +28 -0
  81. package/dist/file-view-switch.js +122 -0
  82. package/dist/file-view.d.ts +108 -0
  83. package/dist/file-view.js +327 -0
  84. package/dist/gate.d.ts +141 -0
  85. package/dist/gate.js +336 -0
  86. package/dist/gemini-transcript.d.ts +32 -0
  87. package/dist/gemini-transcript.js +189 -0
  88. package/dist/http-failure.d.ts +98 -0
  89. package/dist/http-failure.js +349 -0
  90. package/dist/hwip-core.generated.d.ts +7 -0
  91. package/dist/hwip-core.generated.js +14 -0
  92. package/dist/hwip-hook.d.ts +77 -0
  93. package/dist/hwip-hook.js +340 -0
  94. package/dist/index.d.ts +7 -0
  95. package/dist/index.js +14 -0
  96. package/dist/input-sequencer.d.ts +65 -0
  97. package/dist/input-sequencer.js +142 -0
  98. package/dist/insecure-url.d.ts +16 -0
  99. package/dist/insecure-url.js +76 -0
  100. package/dist/installer.d.ts +96 -0
  101. package/dist/installer.js +850 -0
  102. package/dist/inventory-offload.d.ts +49 -0
  103. package/dist/inventory-offload.js +209 -0
  104. package/dist/inventory-worker.d.ts +2 -0
  105. package/dist/inventory-worker.js +27 -0
  106. package/dist/jsonc-doc.d.ts +106 -0
  107. package/dist/jsonc-doc.js +354 -0
  108. package/dist/keep-awake.d.ts +32 -0
  109. package/dist/keep-awake.js +46 -0
  110. package/dist/key-changes.d.ts +48 -0
  111. package/dist/key-changes.js +140 -0
  112. package/dist/lan-auth.d.ts +140 -0
  113. package/dist/lan-auth.js +217 -0
  114. package/dist/lan-endpoint.d.ts +41 -0
  115. package/dist/lan-endpoint.js +115 -0
  116. package/dist/lan-receiver.d.ts +122 -0
  117. package/dist/lan-receiver.js +510 -0
  118. package/dist/lan-sender.d.ts +76 -0
  119. package/dist/lan-sender.js +184 -0
  120. package/dist/lan-transfer.d.ts +36 -0
  121. package/dist/lan-transfer.js +54 -0
  122. package/dist/listener-device-id.d.ts +20 -0
  123. package/dist/listener-device-id.js +133 -0
  124. package/dist/listener-log.d.ts +4 -0
  125. package/dist/listener-log.js +23 -0
  126. package/dist/listener-process.d.ts +200 -0
  127. package/dist/listener-process.js +470 -0
  128. package/dist/listener-service.d.ts +225 -0
  129. package/dist/listener-service.js +682 -0
  130. package/dist/listener-worktree-cleanup.d.ts +27 -0
  131. package/dist/listener-worktree-cleanup.js +62 -0
  132. package/dist/listener.d.ts +1325 -0
  133. package/dist/listener.js +5681 -0
  134. package/dist/login-address.d.ts +25 -0
  135. package/dist/login-address.js +97 -0
  136. package/dist/login-page.d.ts +29 -0
  137. package/dist/login-page.js +93 -0
  138. package/dist/login.d.ts +98 -0
  139. package/dist/login.js +388 -0
  140. package/dist/mcp-command.d.ts +45 -0
  141. package/dist/mcp-command.js +50 -0
  142. package/dist/mcp.d.ts +19 -0
  143. package/dist/mcp.js +29 -0
  144. package/dist/node-support.d.ts +19 -0
  145. package/dist/node-support.js +38 -0
  146. package/dist/notify-failure.d.ts +57 -0
  147. package/dist/notify-failure.js +150 -0
  148. package/dist/opencode-transcript.d.ts +139 -0
  149. package/dist/opencode-transcript.js +390 -0
  150. package/dist/optional-features.d.ts +6 -0
  151. package/dist/optional-features.js +19 -0
  152. package/dist/pane-fit.d.ts +40 -0
  153. package/dist/pane-fit.js +119 -0
  154. package/dist/peer-key.d.ts +33 -0
  155. package/dist/peer-key.js +34 -0
  156. package/dist/pi-transcript.d.ts +26 -0
  157. package/dist/pi-transcript.js +255 -0
  158. package/dist/presence.d.ts +14 -0
  159. package/dist/presence.js +124 -0
  160. package/dist/private-files.d.ts +51 -0
  161. package/dist/private-files.js +218 -0
  162. package/dist/project-switch.d.ts +19 -0
  163. package/dist/project-switch.js +60 -0
  164. package/dist/redact.d.ts +10 -0
  165. package/dist/redact.js +21 -0
  166. package/dist/remote-agents.d.ts +410 -0
  167. package/dist/remote-agents.js +1218 -0
  168. package/dist/remote-hook.d.ts +12 -0
  169. package/dist/remote-hook.js +196 -0
  170. package/dist/replace-file.d.ts +50 -0
  171. package/dist/replace-file.js +328 -0
  172. package/dist/schedule-command.d.ts +35 -0
  173. package/dist/schedule-command.js +260 -0
  174. package/dist/schedule-launch.d.ts +15 -0
  175. package/dist/schedule-launch.js +24 -0
  176. package/dist/schedule-ready.d.ts +44 -0
  177. package/dist/schedule-ready.js +71 -0
  178. package/dist/schedule-remote.d.ts +77 -0
  179. package/dist/schedule-remote.js +167 -0
  180. package/dist/schedule-runner.d.ts +89 -0
  181. package/dist/schedule-runner.js +452 -0
  182. package/dist/schedule.d.ts +149 -0
  183. package/dist/schedule.js +428 -0
  184. package/dist/secret-mask.d.ts +29 -0
  185. package/dist/secret-mask.js +245 -0
  186. package/dist/self-register.d.ts +45 -0
  187. package/dist/self-register.js +67 -0
  188. package/dist/send.d.ts +56 -0
  189. package/dist/send.js +104 -0
  190. package/dist/session-diff.d.ts +161 -0
  191. package/dist/session-diff.js +445 -0
  192. package/dist/session-registry.d.ts +143 -0
  193. package/dist/session-registry.js +216 -0
  194. package/dist/sessions-report.d.ts +59 -0
  195. package/dist/sessions-report.js +99 -0
  196. package/dist/shell-audit.d.ts +34 -0
  197. package/dist/shell-audit.js +47 -0
  198. package/dist/subagent-transcripts.d.ts +167 -0
  199. package/dist/subagent-transcripts.js +437 -0
  200. package/dist/subscriber-trust.d.ts +128 -0
  201. package/dist/subscriber-trust.js +397 -0
  202. package/dist/templates.d.ts +105 -0
  203. package/dist/templates.js +871 -0
  204. package/dist/tmux-install.d.ts +44 -0
  205. package/dist/tmux-install.js +65 -0
  206. package/dist/tmux-liveness.d.ts +45 -0
  207. package/dist/tmux-liveness.js +71 -0
  208. package/dist/tmux-pane.d.ts +55 -0
  209. package/dist/tmux-pane.js +126 -0
  210. package/dist/tmux-socket.d.ts +20 -0
  211. package/dist/tmux-socket.js +292 -0
  212. package/dist/todo-fold.d.ts +31 -0
  213. package/dist/todo-fold.js +102 -0
  214. package/dist/transcript-tail.d.ts +312 -0
  215. package/dist/transcript-tail.js +718 -0
  216. package/dist/trust-command.d.ts +49 -0
  217. package/dist/trust-command.js +200 -0
  218. package/dist/trust-lock.d.ts +26 -0
  219. package/dist/trust-lock.js +94 -0
  220. package/dist/trust-pins.d.ts +37 -0
  221. package/dist/trust-pins.js +82 -0
  222. package/dist/turn-recorder-wire.d.ts +70 -0
  223. package/dist/turn-recorder-wire.js +165 -0
  224. package/dist/turn-recorder.d.ts +190 -0
  225. package/dist/turn-recorder.js +695 -0
  226. package/dist/turn-ring.d.ts +93 -0
  227. package/dist/turn-ring.js +418 -0
  228. package/dist/turn-watch.d.ts +177 -0
  229. package/dist/turn-watch.js +630 -0
  230. package/dist/types.d.ts +63 -0
  231. package/dist/types.js +2 -0
  232. package/dist/uninstall.d.ts +55 -0
  233. package/dist/uninstall.js +507 -0
  234. package/dist/usage.d.ts +17 -0
  235. package/dist/usage.js +421 -0
  236. package/dist/verify.d.ts +47 -0
  237. package/dist/verify.js +396 -0
  238. package/dist/worktree.d.ts +144 -0
  239. package/dist/worktree.js +310 -0
  240. package/dist/wrapper.d.ts +254 -0
  241. package/dist/wrapper.js +704 -0
  242. package/dist/ws-url.d.ts +109 -0
  243. package/dist/ws-url.js +202 -0
  244. package/package.json +78 -10
  245. package/bin/hwip.js +0 -3
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Local transfer (ADR-0013) — sender authentication, shared by every
3
+ * implementation of the contract: `src/lan-auth.ts` in `@hwip/cli`, the iOS
4
+ * app's uploader (hwip `LanAuth.swift`), and any Kotlin one to come. They
5
+ * stay in step by `lan-auth.vectors.json`, not by reading each other.
6
+ *
7
+ * The device keys are ECDH-only (WebCrypto `deriveKey`), so there is no
8
+ * signature primitive. What both sides can compute offline is the ECDH
9
+ * secret, and from it two keys:
10
+ *
11
+ * macKey = HKDF-SHA256(ikm = ECDH(sender priv, receiver pub), salt = "", info = "hwip-lan-v1", 32)
12
+ * metaKey = HKDF-SHA256(same ikm, salt = "", info = "hwip-lan-v1-meta", 32)
13
+ * mac = HMAC-SHA256(macKey, method | path | senderDeviceId | transferId | timestamp | nonce | sha256(body))
14
+ * meta = base64( iv[12] || AES-256-GCM(metaKey, iv, json) ) — upload only
15
+ * receipt = HMAC-SHA256(macKey, "hwip-lan-receipt|" + mac) — on the 200 / 201
16
+ *
17
+ * The receiver derives the same keys from its own private key and the
18
+ * sender's registered public key (the device list it already fetches), so
19
+ * a valid MAC proves the sender holds the private half of a key the
20
+ * account registered — and binds the route, the body, the transfer and
21
+ * the moment. The MAC over `sha256(body)` can only be checked once the
22
+ * body has arrived; the header-level checks (shape, clock, nonce, known
23
+ * sender) run before a byte of it is read. The metadata is sealed because
24
+ * the transport is plain HTTP on a LAN: a sniffer sees a sender device id
25
+ * and a transfer id, never a file name or the account's device list.
26
+ *
27
+ * The receipt runs the other way. Without it any host that answers on the
28
+ * published address — an ARP spoof is enough — could return 201, and the
29
+ * sender would skip the relay for a file that never landed. Only the device
30
+ * holding the receiver's private key derives `macKey`, so a receipt over the
31
+ * request's own MAC proves this answer, for this request, came from it.
32
+ */
33
+ export declare const LAN_AUTH_INFO = "hwip-lan-v1";
34
+ export declare const LAN_META_INFO = "hwip-lan-v1-meta";
35
+ /** A request whose timestamp is further from now than this is refused. */
36
+ export declare const LAN_CLOCK_SKEW_MS: number;
37
+ export declare const LAN_NONCE_TTL_MS: number;
38
+ /** Nonces remembered per sender within the window — a legitimate device sends
39
+ * two per transfer, so this is hundreds of transfers in five minutes. A sender
40
+ * that exceeds it is refused until entries expire, never trimmed. */
41
+ export declare const LAN_NONCES_PER_SENDER = 1000;
42
+ /** The ping has no transfer; this stands in so the canonical string keeps its shape. */
43
+ export declare const LAN_PING_TRANSFER_ID = "ping";
44
+ export declare const LAN_PATHS: {
45
+ readonly ping: "/hwip/lan/v1/ping";
46
+ readonly upload: "/hwip/lan/v1/upload";
47
+ };
48
+ /** Header names, lower-case as Node hands them to a server. */
49
+ export declare const LAN_HEADERS: {
50
+ readonly sender: "x-hwip-lan-sender";
51
+ readonly transfer: "x-hwip-lan-transfer";
52
+ readonly timestamp: "x-hwip-lan-timestamp";
53
+ readonly nonce: "x-hwip-lan-nonce";
54
+ readonly mac: "x-hwip-lan-mac";
55
+ /** Sealed `{ fileName, fileType, fileSize, iv, deviceKeyMap, transferId }` — upload only. */
56
+ readonly meta: "x-hwip-lan-meta";
57
+ /** On the receiver's 200 / 201: `signLanReceipt(macKey, request mac)`. */
58
+ readonly receipt: "x-hwip-lan-receipt";
59
+ };
60
+ export interface LanAuthFields {
61
+ /** HTTP method, upper-case. */
62
+ method: string;
63
+ /** Request path, one of `LAN_PATHS`. */
64
+ path: string;
65
+ senderDeviceId: string;
66
+ transferId: string;
67
+ /** Unix time in milliseconds, the sender's clock. */
68
+ timestamp: number;
69
+ /** 16 random bytes, lower-case hex. */
70
+ nonce: string;
71
+ /** SHA-256 of the raw request body, lower-case hex. Empty body → hash of nothing. */
72
+ bodySha256: string;
73
+ }
74
+ /** A transfer id is a single safe path segment — it names a landing directory. */
75
+ export declare const isTransferId: (v: string) => boolean;
76
+ export declare const sha256Hex: (data: Buffer | string) => string;
77
+ export declare const EMPTY_BODY_SHA256: string;
78
+ /** Incremental body hash for a streamed request. */
79
+ export declare const createBodyHasher: () => {
80
+ update: (chunk: Buffer) => void;
81
+ digestHex: () => string;
82
+ };
83
+ export interface LanKeys {
84
+ macKey: Buffer;
85
+ metaKey: Buffer;
86
+ }
87
+ /** Both keys from the raw ECDH secret. */
88
+ export declare const deriveLanKeys: (sharedSecret: Buffer) => LanKeys;
89
+ export declare const canonicalString: (f: LanAuthFields) => string;
90
+ export declare const signLanRequest: (macKey: Buffer, f: LanAuthFields) => string;
91
+ /** Constant-time compare; a MAC of the wrong length is simply wrong. */
92
+ export declare const verifyLanMac: (macKey: Buffer, f: LanAuthFields, mac: string) => boolean;
93
+ export declare const newNonce: () => string;
94
+ /** The receiver's proof that it, and not whatever answered on the address, accepted `requestMac`. */
95
+ export declare const signLanReceipt: (macKey: Buffer, requestMac: string) => string;
96
+ /** Constant-time; a missing or malformed receipt is simply wrong. */
97
+ export declare const verifyLanReceipt: (macKey: Buffer, requestMac: string, receipt: string | null | undefined) => boolean;
98
+ /** Largest sealed header accepted (base64 characters). A 5-device key map is
99
+ * well under 2 KiB; the ceiling stays under Node's 16 KiB `maxHeaderSize`
100
+ * for all headers together, so it is this check that refuses, not the parser. */
101
+ export declare const LAN_META_MAX_BYTES: number;
102
+ /** `iv || ciphertext || tag`, base64 — what goes in `X-Hwip-Lan-Meta`. */
103
+ export declare const sealMeta: (metaKey: Buffer, meta: object, iv?: Buffer) => string;
104
+ /** Inverse of `sealMeta`. Null on any shape or authentication failure — never throws. */
105
+ export declare const openMeta: (metaKey: Buffer, sealed: string | string[] | undefined) => unknown;
106
+ export type LanAuthHeaderFields = Omit<LanAuthFields, 'bodySha256' | 'method' | 'path'> & {
107
+ mac: string;
108
+ };
109
+ /** What a sender puts on the request; the receiver parses it back with `parseLanAuthHeaders`. */
110
+ export declare const toLanAuthHeaders: (f: Omit<LanAuthFields, "bodySha256" | "method" | "path">, mac: string) => Record<string, string>;
111
+ /**
112
+ * Shape check only — null when any header is missing or malformed. Runs
113
+ * before the body, before any crypto, before any lookup: a probe that
114
+ * gets the shape wrong costs nothing and learns nothing.
115
+ */
116
+ export declare const parseLanAuthHeaders: (headers: Record<string, string | string[] | undefined>) => LanAuthHeaderFields | null;
117
+ export declare const isTimestampFresh: (timestamp: number, now?: number, skewMs?: number) => boolean;
118
+ export interface NonceRegistry {
119
+ /** True when `nonce` has been recorded for `sender` within the TTL. */
120
+ seen: (sender: string, nonce: string, now?: number) => boolean;
121
+ /** Record a verified nonce. False when it was already there (a concurrent
122
+ * replay won the race) or the sender's bucket is full — the caller
123
+ * refuses in both cases. */
124
+ commit: (sender: string, nonce: string, now?: number) => boolean;
125
+ size: () => number;
126
+ }
127
+ /**
128
+ * Replay guard. In memory on purpose (ADR-0013): a restart reopens a
129
+ * window of one clock-skew period, and the worst case is the same
130
+ * ciphertext saved once more as `name (2).ext`.
131
+ *
132
+ * Two properties an earlier shape lacked: nothing is recorded until the
133
+ * MAC has verified, so an unauthenticated peer cannot touch it; and it is
134
+ * partitioned per sender with a fixed quota that refuses rather than
135
+ * evicts, so no device — not even a hostile one on the account — can push
136
+ * another device's nonce out and reopen a replay. Expired entries are the
137
+ * only thing ever removed.
138
+ */
139
+ export declare const createNonceRegistry: (perSender?: number, ttlMs?: number) => NonceRegistry;
140
+ //# sourceMappingURL=lan-auth.d.ts.map
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createNonceRegistry = exports.isTimestampFresh = exports.parseLanAuthHeaders = exports.toLanAuthHeaders = exports.openMeta = exports.sealMeta = exports.LAN_META_MAX_BYTES = exports.verifyLanReceipt = exports.signLanReceipt = exports.newNonce = exports.verifyLanMac = exports.signLanRequest = exports.canonicalString = exports.deriveLanKeys = exports.createBodyHasher = exports.EMPTY_BODY_SHA256 = exports.sha256Hex = exports.isTransferId = exports.LAN_HEADERS = exports.LAN_PATHS = exports.LAN_PING_TRANSFER_ID = exports.LAN_NONCES_PER_SENDER = exports.LAN_NONCE_TTL_MS = exports.LAN_CLOCK_SKEW_MS = exports.LAN_META_INFO = exports.LAN_AUTH_INFO = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ /**
6
+ * Local transfer (ADR-0013) — sender authentication, shared by every
7
+ * implementation of the contract: `src/lan-auth.ts` in `@hwip/cli`, the iOS
8
+ * app's uploader (hwip `LanAuth.swift`), and any Kotlin one to come. They
9
+ * stay in step by `lan-auth.vectors.json`, not by reading each other.
10
+ *
11
+ * The device keys are ECDH-only (WebCrypto `deriveKey`), so there is no
12
+ * signature primitive. What both sides can compute offline is the ECDH
13
+ * secret, and from it two keys:
14
+ *
15
+ * macKey = HKDF-SHA256(ikm = ECDH(sender priv, receiver pub), salt = "", info = "hwip-lan-v1", 32)
16
+ * metaKey = HKDF-SHA256(same ikm, salt = "", info = "hwip-lan-v1-meta", 32)
17
+ * mac = HMAC-SHA256(macKey, method | path | senderDeviceId | transferId | timestamp | nonce | sha256(body))
18
+ * meta = base64( iv[12] || AES-256-GCM(metaKey, iv, json) ) — upload only
19
+ * receipt = HMAC-SHA256(macKey, "hwip-lan-receipt|" + mac) — on the 200 / 201
20
+ *
21
+ * The receiver derives the same keys from its own private key and the
22
+ * sender's registered public key (the device list it already fetches), so
23
+ * a valid MAC proves the sender holds the private half of a key the
24
+ * account registered — and binds the route, the body, the transfer and
25
+ * the moment. The MAC over `sha256(body)` can only be checked once the
26
+ * body has arrived; the header-level checks (shape, clock, nonce, known
27
+ * sender) run before a byte of it is read. The metadata is sealed because
28
+ * the transport is plain HTTP on a LAN: a sniffer sees a sender device id
29
+ * and a transfer id, never a file name or the account's device list.
30
+ *
31
+ * The receipt runs the other way. Without it any host that answers on the
32
+ * published address — an ARP spoof is enough — could return 201, and the
33
+ * sender would skip the relay for a file that never landed. Only the device
34
+ * holding the receiver's private key derives `macKey`, so a receipt over the
35
+ * request's own MAC proves this answer, for this request, came from it.
36
+ */
37
+ exports.LAN_AUTH_INFO = 'hwip-lan-v1';
38
+ exports.LAN_META_INFO = 'hwip-lan-v1-meta';
39
+ /** A request whose timestamp is further from now than this is refused. */
40
+ exports.LAN_CLOCK_SKEW_MS = 5 * 60_000;
41
+ exports.LAN_NONCE_TTL_MS = exports.LAN_CLOCK_SKEW_MS;
42
+ /** Nonces remembered per sender within the window — a legitimate device sends
43
+ * two per transfer, so this is hundreds of transfers in five minutes. A sender
44
+ * that exceeds it is refused until entries expire, never trimmed. */
45
+ exports.LAN_NONCES_PER_SENDER = 1000;
46
+ /** The ping has no transfer; this stands in so the canonical string keeps its shape. */
47
+ exports.LAN_PING_TRANSFER_ID = 'ping';
48
+ exports.LAN_PATHS = {
49
+ ping: '/hwip/lan/v1/ping',
50
+ upload: '/hwip/lan/v1/upload',
51
+ };
52
+ /** Header names, lower-case as Node hands them to a server. */
53
+ exports.LAN_HEADERS = {
54
+ sender: 'x-hwip-lan-sender',
55
+ transfer: 'x-hwip-lan-transfer',
56
+ timestamp: 'x-hwip-lan-timestamp',
57
+ nonce: 'x-hwip-lan-nonce',
58
+ mac: 'x-hwip-lan-mac',
59
+ /** Sealed `{ fileName, fileType, fileSize, iv, deviceKeyMap, transferId }` — upload only. */
60
+ meta: 'x-hwip-lan-meta',
61
+ /** On the receiver's 200 / 201: `signLanReceipt(macKey, request mac)`. */
62
+ receipt: 'x-hwip-lan-receipt',
63
+ };
64
+ const DEVICE_ID = /^[A-Za-z0-9_.:-]{1,128}$/;
65
+ const TRANSFER_ID = /^[A-Za-z0-9_-]{1,64}$/;
66
+ const HEX_32 = /^[0-9a-f]{32}$/;
67
+ const HEX_64 = /^[0-9a-f]{64}$/;
68
+ const TIMESTAMP = /^[0-9]{1,16}$/;
69
+ /** A transfer id is a single safe path segment — it names a landing directory. */
70
+ const isTransferId = (v) => TRANSFER_ID.test(v);
71
+ exports.isTransferId = isTransferId;
72
+ const sha256Hex = (data) => (0, node_crypto_1.createHash)('sha256').update(data).digest('hex');
73
+ exports.sha256Hex = sha256Hex;
74
+ exports.EMPTY_BODY_SHA256 = (0, exports.sha256Hex)(Buffer.alloc(0));
75
+ /** Incremental body hash for a streamed request. */
76
+ const createBodyHasher = () => {
77
+ const hash = (0, node_crypto_1.createHash)('sha256');
78
+ return { update: (chunk) => { hash.update(chunk); }, digestHex: () => hash.digest('hex') };
79
+ };
80
+ exports.createBodyHasher = createBodyHasher;
81
+ /** Both keys from the raw ECDH secret. */
82
+ const deriveLanKeys = (sharedSecret) => ({
83
+ macKey: Buffer.from((0, node_crypto_1.hkdfSync)('sha256', sharedSecret, Buffer.alloc(0), exports.LAN_AUTH_INFO, 32)),
84
+ metaKey: Buffer.from((0, node_crypto_1.hkdfSync)('sha256', sharedSecret, Buffer.alloc(0), exports.LAN_META_INFO, 32)),
85
+ });
86
+ exports.deriveLanKeys = deriveLanKeys;
87
+ const canonicalString = (f) => [f.method, f.path, f.senderDeviceId, f.transferId, String(f.timestamp), f.nonce, f.bodySha256].join('|');
88
+ exports.canonicalString = canonicalString;
89
+ const signLanRequest = (macKey, f) => (0, node_crypto_1.createHmac)('sha256', macKey).update((0, exports.canonicalString)(f)).digest('hex');
90
+ exports.signLanRequest = signLanRequest;
91
+ /** Constant-time compare; a MAC of the wrong length is simply wrong. */
92
+ const verifyLanMac = (macKey, f, mac) => {
93
+ if (!HEX_64.test(mac))
94
+ return false;
95
+ const expected = Buffer.from((0, exports.signLanRequest)(macKey, f), 'hex');
96
+ return (0, node_crypto_1.timingSafeEqual)(expected, Buffer.from(mac, 'hex'));
97
+ };
98
+ exports.verifyLanMac = verifyLanMac;
99
+ const newNonce = () => (0, node_crypto_1.randomBytes)(16).toString('hex');
100
+ exports.newNonce = newNonce;
101
+ const LAN_RECEIPT_PREFIX = 'hwip-lan-receipt|';
102
+ /** The receiver's proof that it, and not whatever answered on the address, accepted `requestMac`. */
103
+ const signLanReceipt = (macKey, requestMac) => (0, node_crypto_1.createHmac)('sha256', macKey).update(LAN_RECEIPT_PREFIX + requestMac).digest('hex');
104
+ exports.signLanReceipt = signLanReceipt;
105
+ /** Constant-time; a missing or malformed receipt is simply wrong. */
106
+ const verifyLanReceipt = (macKey, requestMac, receipt) => {
107
+ if (typeof receipt !== 'string' || !HEX_64.test(receipt))
108
+ return false;
109
+ return (0, node_crypto_1.timingSafeEqual)(Buffer.from((0, exports.signLanReceipt)(macKey, requestMac), 'hex'), Buffer.from(receipt, 'hex'));
110
+ };
111
+ exports.verifyLanReceipt = verifyLanReceipt;
112
+ // ─── Sealed metadata ───
113
+ const META_IV_BYTES = 12;
114
+ const META_TAG_BYTES = 16;
115
+ /** Largest sealed header accepted (base64 characters). A 5-device key map is
116
+ * well under 2 KiB; the ceiling stays under Node's 16 KiB `maxHeaderSize`
117
+ * for all headers together, so it is this check that refuses, not the parser. */
118
+ exports.LAN_META_MAX_BYTES = 8 * 1024;
119
+ /** `iv || ciphertext || tag`, base64 — what goes in `X-Hwip-Lan-Meta`. */
120
+ const sealMeta = (metaKey, meta, iv = (0, node_crypto_1.randomBytes)(META_IV_BYTES)) => {
121
+ const cipher = (0, node_crypto_1.createCipheriv)('aes-256-gcm', metaKey, iv);
122
+ const body = Buffer.concat([cipher.update(Buffer.from(JSON.stringify(meta), 'utf8')), cipher.final()]);
123
+ return Buffer.concat([iv, body, cipher.getAuthTag()]).toString('base64');
124
+ };
125
+ exports.sealMeta = sealMeta;
126
+ /** Inverse of `sealMeta`. Null on any shape or authentication failure — never throws. */
127
+ const openMeta = (metaKey, sealed) => {
128
+ if (typeof sealed !== 'string' || sealed.length === 0 || sealed.length > exports.LAN_META_MAX_BYTES)
129
+ return null;
130
+ const raw = Buffer.from(sealed, 'base64');
131
+ if (raw.length < META_IV_BYTES + META_TAG_BYTES)
132
+ return null;
133
+ try {
134
+ const decipher = (0, node_crypto_1.createDecipheriv)('aes-256-gcm', metaKey, raw.subarray(0, META_IV_BYTES));
135
+ decipher.setAuthTag(raw.subarray(raw.length - META_TAG_BYTES));
136
+ const json = Buffer.concat([decipher.update(raw.subarray(META_IV_BYTES, raw.length - META_TAG_BYTES)), decipher.final()]);
137
+ return JSON.parse(json.toString('utf8'));
138
+ }
139
+ catch {
140
+ return null;
141
+ }
142
+ };
143
+ exports.openMeta = openMeta;
144
+ /** What a sender puts on the request; the receiver parses it back with `parseLanAuthHeaders`. */
145
+ const toLanAuthHeaders = (f, mac) => ({
146
+ [exports.LAN_HEADERS.sender]: f.senderDeviceId,
147
+ [exports.LAN_HEADERS.transfer]: f.transferId,
148
+ [exports.LAN_HEADERS.timestamp]: String(f.timestamp),
149
+ [exports.LAN_HEADERS.nonce]: f.nonce,
150
+ [exports.LAN_HEADERS.mac]: mac,
151
+ });
152
+ exports.toLanAuthHeaders = toLanAuthHeaders;
153
+ const one = (v) => (Array.isArray(v) ? undefined : v);
154
+ /**
155
+ * Shape check only — null when any header is missing or malformed. Runs
156
+ * before the body, before any crypto, before any lookup: a probe that
157
+ * gets the shape wrong costs nothing and learns nothing.
158
+ */
159
+ const parseLanAuthHeaders = (headers) => {
160
+ const senderDeviceId = one(headers[exports.LAN_HEADERS.sender]);
161
+ const transferId = one(headers[exports.LAN_HEADERS.transfer]);
162
+ const timestamp = one(headers[exports.LAN_HEADERS.timestamp]);
163
+ const nonce = one(headers[exports.LAN_HEADERS.nonce]);
164
+ const mac = one(headers[exports.LAN_HEADERS.mac]);
165
+ if (senderDeviceId === undefined || !DEVICE_ID.test(senderDeviceId)
166
+ || transferId === undefined || !TRANSFER_ID.test(transferId)
167
+ || timestamp === undefined || !TIMESTAMP.test(timestamp)
168
+ || nonce === undefined || !HEX_32.test(nonce)
169
+ || mac === undefined || !HEX_64.test(mac))
170
+ return null;
171
+ return { senderDeviceId, transferId, timestamp: Number(timestamp), nonce, mac };
172
+ };
173
+ exports.parseLanAuthHeaders = parseLanAuthHeaders;
174
+ const isTimestampFresh = (timestamp, now = Date.now(), skewMs = exports.LAN_CLOCK_SKEW_MS) => Math.abs(now - timestamp) <= skewMs;
175
+ exports.isTimestampFresh = isTimestampFresh;
176
+ /**
177
+ * Replay guard. In memory on purpose (ADR-0013): a restart reopens a
178
+ * window of one clock-skew period, and the worst case is the same
179
+ * ciphertext saved once more as `name (2).ext`.
180
+ *
181
+ * Two properties an earlier shape lacked: nothing is recorded until the
182
+ * MAC has verified, so an unauthenticated peer cannot touch it; and it is
183
+ * partitioned per sender with a fixed quota that refuses rather than
184
+ * evicts, so no device — not even a hostile one on the account — can push
185
+ * another device's nonce out and reopen a replay. Expired entries are the
186
+ * only thing ever removed.
187
+ */
188
+ const createNonceRegistry = (perSender = exports.LAN_NONCES_PER_SENDER, ttlMs = exports.LAN_NONCE_TTL_MS) => {
189
+ const buckets = new Map(); // sender → nonce → recorded at (insertion-ordered)
190
+ const bucketFor = (sender, now) => {
191
+ const bucket = buckets.get(sender) ?? new Map();
192
+ for (const [nonce, at] of bucket) {
193
+ if (now - at <= ttlMs)
194
+ break; // the rest are newer
195
+ bucket.delete(nonce);
196
+ }
197
+ if (bucket.size === 0)
198
+ buckets.delete(sender);
199
+ else
200
+ buckets.set(sender, bucket);
201
+ return bucket;
202
+ };
203
+ return {
204
+ seen: (sender, nonce, now = Date.now()) => bucketFor(sender, now).has(nonce),
205
+ commit: (sender, nonce, now = Date.now()) => {
206
+ const bucket = bucketFor(sender, now);
207
+ if (bucket.has(nonce) || bucket.size >= perSender)
208
+ return false;
209
+ bucket.set(nonce, now);
210
+ buckets.set(sender, bucket);
211
+ return true;
212
+ },
213
+ size: () => { let n = 0; for (const b of buckets.values())
214
+ n += b.size; return n; },
215
+ };
216
+ };
217
+ exports.createNonceRegistry = createNonceRegistry;
@@ -0,0 +1,41 @@
1
+ import { type NetworkInterfaceInfo } from 'node:os';
2
+ export declare const isPrivateIpv4: (host: string) => boolean;
3
+ /**
4
+ * The private, non-internal IPv4 a phone on the same network is likeliest to
5
+ * reach, or null when the machine has none up. Taking the first one found
6
+ * published a Tailscale, docker or VM-bridge address whenever that interface
7
+ * sorted first, and every file then waited out the 1.5 s LAN probe before
8
+ * taking the relay. Wi-Fi / Ethernet beat unknown interfaces, which beat
9
+ * tunnels and bridges; within that, RFC 1918 beats link-local beats CGNAT.
10
+ * Ties keep the OS's order. Names only — no route lookup, so it stays a pure
11
+ * function of `networkInterfaces()`.
12
+ */
13
+ export declare const pickLanIpv4: (interfaces?: NodeJS.Dict<NetworkInterfaceInfo[]>) => string | null;
14
+ export interface LanPublisherDeps {
15
+ log: (msg: string) => void;
16
+ /** Send one frame on the live socket; false when there is none. The
17
+ * server's answer (`listener.presence.ack` / `.error`) arrives on the
18
+ * socket's message handler, not here. */
19
+ send: (msg: object) => boolean;
20
+ pickHost?: () => string | null;
21
+ }
22
+ export interface LanPublisher {
23
+ /**
24
+ * Publish `{host, port}` if it differs from what was last published (or
25
+ * always, with `force` — used on every WebSocket open, because the device
26
+ * record may have been recreated). True when a frame went out. Never
27
+ * throws: a socket that is not there leaves the relay path unaffected.
28
+ */
29
+ publish: (port: number, opts?: {
30
+ force?: boolean;
31
+ }) => boolean;
32
+ /** `lan: null` on the device record — shutdown, or the LAN went away.
33
+ * Best-effort: every reader gates on `isOnline`, so an endpoint left
34
+ * behind by a socket that dropped is already inert. */
35
+ clear: () => void;
36
+ /** Poll the address and republish on change. One watcher at a time. */
37
+ startWatch: (port: number, intervalMs?: number) => void;
38
+ stopWatch: () => void;
39
+ }
40
+ export declare const createLanPublisher: (deps: LanPublisherDeps) => LanPublisher;
41
+ //# sourceMappingURL=lan-endpoint.d.ts.map
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createLanPublisher = exports.pickLanIpv4 = exports.isPrivateIpv4 = void 0;
4
+ const node_os_1 = require("node:os");
5
+ const device_presence_js_1 = require("./device-presence.js");
6
+ /**
7
+ * Local transfer (ADR-0013) — the listener's side of the rendezvous.
8
+ *
9
+ * There is no mDNS. The listener writes `lan: { host, port }` on its own
10
+ * device record by sending `listener.presence` over the WebSocket it already
11
+ * holds; a sender on the same network reads it back from `GET /devices` and
12
+ * tries a direct upload before the S3 relay. The socket is the transport
13
+ * because it is what proves which device is speaking — the HTTP device-record
14
+ * route is JWT-only and the daemon holds an API key.
15
+ *
16
+ * Liveness is the device's `isOnline` (that same socket), so this module
17
+ * republishes on every reconnect and otherwise only when the address moves.
18
+ *
19
+ * The server refuses anything but a private IPv4, so `pickLanIpv4` applies
20
+ * the same ranges here: a VPN or public address never reaches the wire.
21
+ */
22
+ /** RFC 1918, CGNAT (100.64/10) and link-local. Same set as the server's validator. */
23
+ const PRIVATE_IPV4 = /^(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}|169\.254\.\d{1,3}\.\d{1,3})$/;
24
+ const isPrivateIpv4 = (host) => PRIVATE_IPV4.test(host) && host.split('.').every((octet) => Number(octet) <= 255);
25
+ exports.isPrivateIpv4 = isPrivateIpv4;
26
+ /** Wi-Fi and Ethernet: macOS `en*`, Linux `eth*` / `en*` / `wlan*` / `wl*`. */
27
+ const PHYSICAL_IFACE = /^(en|eth|wlan|wl)/;
28
+ /** Tunnels, VPNs, containers and VM bridges: a phone on the Wi-Fi cannot reach these. */
29
+ const VIRTUAL_IFACE = /^(utun|tun|tap|ppp|ipsec|wg|tailscale|zt|docker|br-|bridge|virbr|veth|vmnet|vboxnet|awdl|llw|anpi)/;
30
+ const ifaceRank = (name) => (VIRTUAL_IFACE.test(name) ? 2 : PHYSICAL_IFACE.test(name) ? 0 : 1);
31
+ /** RFC 1918 first; link-local next; CGNAT (Tailscale's 100.64/10) last. */
32
+ const addressRank = (address) => address.startsWith('169.254.') ? 1 : address.startsWith('100.') ? 2 : 0;
33
+ /**
34
+ * The private, non-internal IPv4 a phone on the same network is likeliest to
35
+ * reach, or null when the machine has none up. Taking the first one found
36
+ * published a Tailscale, docker or VM-bridge address whenever that interface
37
+ * sorted first, and every file then waited out the 1.5 s LAN probe before
38
+ * taking the relay. Wi-Fi / Ethernet beat unknown interfaces, which beat
39
+ * tunnels and bridges; within that, RFC 1918 beats link-local beats CGNAT.
40
+ * Ties keep the OS's order. Names only — no route lookup, so it stays a pure
41
+ * function of `networkInterfaces()`.
42
+ */
43
+ const pickLanIpv4 = (interfaces = (0, node_os_1.networkInterfaces)()) => {
44
+ let best = null;
45
+ for (const [name, entries] of Object.entries(interfaces)) {
46
+ for (const entry of entries ?? []) {
47
+ if (entry.family !== 'IPv4' || entry.internal || !(0, exports.isPrivateIpv4)(entry.address))
48
+ continue;
49
+ const rank = ifaceRank(name) * 10 + addressRank(entry.address);
50
+ if (best === null || rank < best.rank)
51
+ best = { address: entry.address, rank };
52
+ }
53
+ }
54
+ return best?.address ?? null;
55
+ };
56
+ exports.pickLanIpv4 = pickLanIpv4;
57
+ const DEFAULT_WATCH_INTERVAL_MS = 60_000;
58
+ const createLanPublisher = (deps) => {
59
+ const pickHost = deps.pickHost ?? (() => (0, exports.pickLanIpv4)());
60
+ let published = null;
61
+ let watchTimer = null;
62
+ /** One frame. Synchronous, so a watch tick cannot interleave with the
63
+ * forced publish from a socket open the way two in-flight writes could. */
64
+ const announce = (lan) => {
65
+ const sent = deps.send({ type: device_presence_js_1.LISTENER_PRESENCE, data: { lan } });
66
+ if (!sent)
67
+ deps.log('local transfer: endpoint not published — no connection right now, relay until the next open');
68
+ return sent;
69
+ };
70
+ const publish = (port, opts) => {
71
+ try {
72
+ const host = pickHost();
73
+ if (host === null) {
74
+ // The LAN went away (or never existed). Retract a stale endpoint
75
+ // so no sender is pointed at an address we no longer hold.
76
+ if (published !== null && announce(null))
77
+ published = null;
78
+ return false;
79
+ }
80
+ const unchanged = published !== null && published.host === host && published.port === port;
81
+ if (unchanged && !opts?.force)
82
+ return false;
83
+ const sent = announce({ host, port });
84
+ if (sent)
85
+ published = { host, port };
86
+ return sent;
87
+ }
88
+ catch (err) {
89
+ // Called from timers and socket events; a throw here (a host
90
+ // picker that fails) must not take the daemon down.
91
+ deps.log(`local transfer: publish skipped — ${err instanceof Error ? err.message : String(err)}`);
92
+ return false;
93
+ }
94
+ };
95
+ const clear = () => {
96
+ if (published === null)
97
+ return;
98
+ // Forgotten either way: the socket is closing, and what keeps a
99
+ // sender away from this port after that is `isOnline`, not this field.
100
+ announce(null);
101
+ published = null;
102
+ };
103
+ const stopWatch = () => {
104
+ if (watchTimer)
105
+ clearInterval(watchTimer);
106
+ watchTimer = null;
107
+ };
108
+ const startWatch = (port, intervalMs = DEFAULT_WATCH_INTERVAL_MS) => {
109
+ stopWatch();
110
+ watchTimer = setInterval(() => publish(port), intervalMs);
111
+ watchTimer.unref();
112
+ };
113
+ return { publish, clear, startWatch, stopWatch };
114
+ };
115
+ exports.createLanPublisher = createLanPublisher;
@@ -0,0 +1,122 @@
1
+ import { type NonceRegistry } from './lan-auth.js';
2
+ /**
3
+ * Local transfer (ADR-0013) — the listener's LAN-facing HTTP server.
4
+ *
5
+ * Binds an OS-assigned port on every interface; the port is what
6
+ * `lan-endpoint.ts` publishes. A remote address may hold only a few of the
7
+ * sockets (`LAN_MAX_CONNECTIONS_PER_IP`), so one address cannot starve the rest. Nothing here is reachable from the internet
8
+ * on purpose (the server only ever stores a private IPv4 for it), but a
9
+ * public Wi-Fi is a LAN too, so every route authenticates before it reads
10
+ * a body, and an unauthenticated peer learns nothing: every refusal it can
11
+ * reach is a 401 with an empty body — the same one, whatever the reason.
12
+ *
13
+ * Two routes:
14
+ * GET /hwip/lan/v1/ping — "are you the device I think, and on my LAN?"
15
+ * MAC over an empty body; answers `{deviceId}`.
16
+ * POST /hwip/lan/v1/upload — raw ciphertext body, sealed metadata in
17
+ * `X-Hwip-Lan-Meta`; decrypted as it streams
18
+ * into `<landingDir>/<transferId>/<fileName>`.
19
+ * The push record for it arrives over the
20
+ * WebSocket afterwards and moves the file to
21
+ * the Downloads folder (listener.ts).
22
+ *
23
+ * Order on an upload: header shape → clock → nonce not
24
+ * yet seen → sender on the account → keys → the sealed meta opens. That
25
+ * last step is the pre-body proof: the meta is AES-GCM under a key only
26
+ * the real holder of that device's private key can derive, so a peer that
27
+ * merely knows a registered device id gets no further than 401 either.
28
+ * Only then do the statuses that say anything about this machine appear
29
+ * (409 transfer known, 503 busy, 411/413 length, 400 not wrapped for us),
30
+ * all still before the body. The MAC binds sha256(body), so it is checked
31
+ * when the last byte is in; so is the GCM tag. Either failing removes what
32
+ * was written. The nonce is recorded only once the MAC has verified.
33
+ */
34
+ export interface LanReceiverOptions {
35
+ log: (msg: string) => void;
36
+ /** A server error after the bind. The socket is unusable from here;
37
+ * the caller retracts the endpoint and turns the feature off. */
38
+ onError?: (err: Error) => void;
39
+ deviceId: () => string;
40
+ /** Registered public key (Base64 SPKI) of a device on this account, or null. */
41
+ lookupSenderPublicKey: (deviceId: string) => Promise<string | null>;
42
+ /** ECDH secret between this device and `peerPublicKeyRaw` (crypto.ts `deriveLanSharedSecret`). */
43
+ deriveSharedSecret: (peerPublicKeyRaw: string) => Promise<Buffer>;
44
+ /** Open this device's `deviceKeyMap` slot (crypto.ts `unwrapDeviceKey`). */
45
+ unwrapFileKey: (entryJson: string, senderPublicKeyRaw: string) => Promise<Buffer>;
46
+ /** Where finished transfers wait for their push record: `~/.hwip/attachments/lan`. */
47
+ landingDir: () => string;
48
+ now?: () => number;
49
+ maxBodyBytes?: number;
50
+ /** Bytes free for an unprivileged writer on the volume of `dir`; `statfsSync` unless a test says. */
51
+ freeBytes?: (dir: string) => number;
52
+ maxInFlight?: number;
53
+ /** Open sockets, of any state, the receiver holds at once. */
54
+ maxConnections?: number;
55
+ /** Open sockets one remote address may hold at once. */
56
+ maxConnectionsPerIp?: number;
57
+ nonces?: NonceRegistry;
58
+ /** A body that sends no bytes for this long is dead. */
59
+ idleTimeoutMs?: number;
60
+ }
61
+ export interface LanReceiver {
62
+ port: number;
63
+ /** Close the socket. Idempotent; resolves once the port is released. */
64
+ stop: () => Promise<void>;
65
+ }
66
+ /**
67
+ * 16 GiB — a long phone video in 4K fits — and the tag each piece of a file
68
+ * that size sealed in chunks adds. No plan's limit applies here: the bytes
69
+ * never reach the relay, and only a device of this account gets past the MAC.
70
+ * What bounds it is the disk (`freeBytes`). A sender sends a file over the
71
+ * relay's ceiling here only to a listener that lists `LAN_LARGE_CAPABILITY`;
72
+ * one from before it takes 1 GiB.
73
+ */
74
+ export declare const LAN_MAX_FILE_BYTES: number;
75
+ /** What a listener tells senders when it takes `LAN_MAX_FILE_BYTES` over LAN (`capabilities` on its device record). */
76
+ export declare const LAN_LARGE_CAPABILITY = "lan-large-v1";
77
+ /** Room left on the landing volume after a file, so a transfer never fills the disk to its last byte. */
78
+ export declare const LAN_DISK_RESERVE_BYTES: number;
79
+ export declare const LAN_MAX_BODY_BYTES: number;
80
+ export declare const LAN_MAX_IN_FLIGHT = 2;
81
+ /**
82
+ * Beside a landed file, the iv it was sealed under (base64, as the meta gave
83
+ * it). The push record that claims the file comes from the relay; this is what
84
+ * the claim holds it against. A leading dot: no landed file can have this name
85
+ * (`safeFileName` strips them).
86
+ */
87
+ export declare const LAN_LANDED_IV = ".iv";
88
+ /** The sockets are the daemon's file descriptors, shared with the relay
89
+ * WebSocket: a LAN flood may exhaust local transfer (sends fall back to the
90
+ * relay), never the daemon. */
91
+ export declare const LAN_MAX_CONNECTIONS = 64;
92
+ /**
93
+ * Sockets one remote address may hold. The pool above is shared by the whole
94
+ * LAN, and a host that opens 64 sockets and sends nothing holds every slot for
95
+ * the 5 s `headersTimeout` — and reopens them — which locks every real sender
96
+ * out of the LAN path (they fall back to the relay, nothing worse). A real
97
+ * sender needs a ping and at most `LAN_MAX_IN_FLIGHT` uploads, so a handful per
98
+ * address is plenty, and it takes this many remote addresses to fill the pool —
99
+ * a host with several addresses on the subnet counts as several.
100
+ *
101
+ * Not "bind only the published address": the attacker is on the same LAN and
102
+ * reaches that address as well as any other, so a narrower bind removes nothing
103
+ * from this problem — it only hides the port from other interfaces (a VPN, a
104
+ * docker bridge). It also costs: the publisher re-picks the address every
105
+ * minute as the machine changes networks, and the port is what it publishes, so
106
+ * a socket bound to one address would have to be rebound, and republished under
107
+ * a new port, on every move.
108
+ */
109
+ export declare const LAN_MAX_CONNECTIONS_PER_IP = 8;
110
+ export declare const LAN_BODY_IDLE_MS = 60000;
111
+ /**
112
+ * Open-socket count per remote address. `admit` before the socket is used,
113
+ * `release` when it closes; an address with no sockets is forgotten, so the map
114
+ * is as large as the number of hosts connected right now and never larger.
115
+ */
116
+ export declare const createConnectionLimiter: (max: number) => {
117
+ admit: (address: string) => boolean;
118
+ release: (address: string) => void;
119
+ tracked: () => number;
120
+ };
121
+ export declare const startLanReceiver: (opts: LanReceiverOptions) => Promise<LanReceiver>;
122
+ //# sourceMappingURL=lan-receiver.d.ts.map