@hwip/mcp-server 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +10 -0
  3. package/README.md +413 -2
  4. package/dist/agent-target.d.ts +45 -0
  5. package/dist/agent-target.js +81 -0
  6. package/dist/api-client.d.ts +162 -0
  7. package/dist/api-client.js +517 -0
  8. package/dist/ask-context-attach.d.ts +67 -0
  9. package/dist/ask-context-attach.js +147 -0
  10. package/dist/ask-seal.d.ts +118 -0
  11. package/dist/ask-seal.js +194 -0
  12. package/dist/command-seal.d.ts +49 -0
  13. package/dist/command-seal.js +90 -0
  14. package/dist/command-seq.d.ts +30 -0
  15. package/dist/command-seq.js +147 -0
  16. package/dist/config.d.ts +93 -0
  17. package/dist/config.js +561 -0
  18. package/dist/crypto.d.ts +234 -0
  19. package/dist/crypto.js +606 -0
  20. package/dist/error-format.d.ts +7 -0
  21. package/dist/error-format.js +77 -0
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.js +30 -0
  24. package/dist/key-changes.d.ts +50 -0
  25. package/dist/key-changes.js +163 -0
  26. package/dist/mime.d.ts +16 -0
  27. package/dist/mime.js +58 -0
  28. package/dist/peer-key.d.ts +36 -0
  29. package/dist/peer-key.js +35 -0
  30. package/dist/poll.d.ts +24 -0
  31. package/dist/poll.js +156 -0
  32. package/dist/push-audience.d.ts +35 -0
  33. package/dist/push-audience.js +64 -0
  34. package/dist/redirect.d.ts +8 -0
  35. package/dist/redirect.js +17 -0
  36. package/dist/remote-state.d.ts +38 -0
  37. package/dist/remote-state.js +81 -0
  38. package/dist/resources/channels.d.ts +4 -0
  39. package/dist/resources/channels.js +24 -0
  40. package/dist/resources/devices.d.ts +4 -0
  41. package/dist/resources/devices.js +24 -0
  42. package/dist/sanitize.d.ts +41 -0
  43. package/dist/sanitize.js +106 -0
  44. package/dist/self-register.d.ts +34 -0
  45. package/dist/self-register.js +51 -0
  46. package/dist/server.d.ts +4 -0
  47. package/dist/server.js +63 -0
  48. package/dist/tools/agent-local.d.ts +17 -0
  49. package/dist/tools/agent-local.js +139 -0
  50. package/dist/tools/agent-send.d.ts +6 -0
  51. package/dist/tools/agent-send.js +89 -0
  52. package/dist/tools/ask.d.ts +9 -0
  53. package/dist/tools/ask.js +275 -0
  54. package/dist/tools/broadcast.d.ts +5 -0
  55. package/dist/tools/broadcast.js +42 -0
  56. package/dist/tools/clipboard.d.ts +5 -0
  57. package/dist/tools/clipboard.js +47 -0
  58. package/dist/tools/dismiss.d.ts +5 -0
  59. package/dist/tools/dismiss.js +49 -0
  60. package/dist/tools/file.d.ts +5 -0
  61. package/dist/tools/file.js +169 -0
  62. package/dist/tools/list.d.ts +4 -0
  63. package/dist/tools/list.js +44 -0
  64. package/dist/tools/lookup.d.ts +10 -0
  65. package/dist/tools/lookup.js +45 -0
  66. package/dist/tools/notify.d.ts +5 -0
  67. package/dist/tools/notify.js +118 -0
  68. package/dist/tools/rename.d.ts +5 -0
  69. package/dist/tools/rename.js +45 -0
  70. package/dist/trust-pins.d.ts +70 -0
  71. package/dist/trust-pins.js +269 -0
  72. package/dist/types.d.ts +157 -0
  73. package/dist/types.js +2 -0
  74. package/dist/url-check.d.ts +11 -0
  75. package/dist/url-check.js +57 -0
  76. package/dist/ws-wait.d.ts +72 -0
  77. package/dist/ws-wait.js +200 -0
  78. package/package.json +70 -4
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Per-device encryption for the MCP server — self-contained ECDH P-256 +
3
+ * AES-256-GCM. Mirrors @hwip/crypto API but bundled inline (no external
4
+ * dependency). Uses Web Crypto API via node:crypto webcrypto — Node.js 18+
5
+ * (the `crypto` global only exists unflagged from Node 19, so we import it).
6
+ *
7
+ * How it works (ADR-0007):
8
+ *
9
+ * This host holds one ECDH keypair, in ~/.hwip/device-keys.json — the
10
+ * Machine Device's, shared with `hwip listener`. Either puts its public half
11
+ * on the device record when the list lacks it (`self-register.ts`). Whichever
12
+ * process starts first creates it; the
13
+ * private half never leaves the host — the server only ever sees public
14
+ * keys. A push is encrypted once
15
+ * with a random AES key, and that key is wrapped separately for each of the
16
+ * user's registered devices using ECDH(this host, that device).
17
+ *
18
+ * That makes it end-to-end in the standard sense: the backend stores
19
+ * ciphertext plus wrapped keys it cannot unwrap.
20
+ *
21
+ * What it still does not give you:
22
+ * • Forward secrecy — the ECDH secret for a given (sender, device) pair is
23
+ * static, so a compromise of either private key retroactively opens every
24
+ * push wrapped for that pair. The per-message AES key is random; its wrap
25
+ * key is not.
26
+ * • Authenticity beyond the key pairing — nothing signs `senderPublicKey`,
27
+ * so a server that swapped it could make a push undecryptable, though not
28
+ * readable.
29
+ *
30
+ * Superseded scheme: a single account-wide keypair whose private half the
31
+ * backend escrowed so it could sync to new devices. Key escrow was removed
32
+ * server-side, which left this client waiting for a private key
33
+ * the API stopped returning — encryption was silently off for months. Nothing
34
+ * here asks for that key any more.
35
+ */
36
+ /** One target device, as returned by `GET /devices`. */
37
+ export interface DeviceRecipient {
38
+ deviceId: string;
39
+ /** Base64 SPKI of that device's per-device public key. */
40
+ publicKey: string;
41
+ }
42
+ /** deviceId → JSON `{ encryptedKey, keyIv }`, the wire shape the clients parse. */
43
+ export type DeviceKeyMap = Record<string, string>;
44
+ /**
45
+ * Initialize crypto.
46
+ *
47
+ * This host generates its own keypair on first use and keeps it, whatever the
48
+ * account's plan: the keypair is this machine's identity, registered ungated so
49
+ * that any device can address it (ADR-0013 decision 3, amended 2026-09-16).
50
+ * The private key is created here and stays here. Every push is sealed (F16):
51
+ * there is no account setting and no host switch that turns it off.
52
+ *
53
+ * Asks the server nothing. Safe to call concurrently — deduplicates to a single init.
54
+ * Returns this host's public key, or '' when it has none.
55
+ */
56
+ export declare const initCrypto: (apiKey?: string) => Promise<string>;
57
+ /**
58
+ * Resolves once the start-up `initCrypto` has settled, however it went. index.ts connects the
59
+ * MCP transport without waiting for it, so the first send waits here instead. A start that
60
+ * failed (a key file that would not load) is tried once more for each send that finds no keys.
61
+ */
62
+ export declare const cryptoReady: () => Promise<void>;
63
+ /** One line for stderr saying what `initCrypto` found. */
64
+ export declare const cryptoStatusLine: () => string;
65
+ /**
66
+ * Keep only devices this host can actually encrypt for.
67
+ *
68
+ * A device without a public key has never run a build that registers one:
69
+ * wrapping for it produces a push it cannot open.
70
+ */
71
+ export declare const selectRecipients: (devices: {
72
+ deviceId: string;
73
+ publicKey?: string;
74
+ }[]) => DeviceRecipient[];
75
+ export declare const getKeyPair: () => CryptoKeyPair | null;
76
+ export declare const getPublicKey: () => string | null;
77
+ /**
78
+ * Drop everything, keypair included, so this host does no crypto at all.
79
+ *
80
+ * Only the keyless path in `initCrypto` uses this. Keys on disk are untouched; a restart re-adopts them.
81
+ */
82
+ export declare const disableCrypto: () => void;
83
+ /**
84
+ * Encrypt a push body for the given recipient devices.
85
+ *
86
+ * Returns the wire fields the API expects: `body` carries the ciphertext and
87
+ * IV, `deviceKeyMap` the per-device wrapped keys, `senderPublicKey` the half
88
+ * recipients need to derive the same secret back.
89
+ */
90
+ export declare const encryptPushBodyForDevices: (input: PushBodyInput, recipients: DeviceRecipient[]) => Promise<{
91
+ body: string;
92
+ deviceKeyMap: DeviceKeyMap;
93
+ senderPublicKey: string;
94
+ isEncrypted: true;
95
+ }>;
96
+ /**
97
+ * Encrypt file content for the given recipient devices.
98
+ */
99
+ export declare const encryptFileForDevices: (content: string | Buffer, recipients: DeviceRecipient[]) => Promise<{
100
+ ciphertext: Buffer;
101
+ iv: string;
102
+ deviceKeyMap: DeviceKeyMap;
103
+ }>;
104
+ /**
105
+ * The label a sealed REST command carries inside its body (M5 D8): which
106
+ * session on which machine it is for, and a number that only grows per
107
+ * sender. A sealed body without one is not a command. `from` is the id of the
108
+ * computer that sent it, when it names itself.
109
+ */
110
+ export interface SealedCommandLabel {
111
+ n: number;
112
+ session: string;
113
+ device: string;
114
+ from?: string;
115
+ }
116
+ /** One button of a sealed question: `id` and `style` also reach the server in the clear, `label` never does. */
117
+ export interface SealedAskAction {
118
+ id: string;
119
+ label: string;
120
+ style?: string;
121
+ }
122
+ /**
123
+ * The label a sealed question carries inside its body: the asker's own random
124
+ * `id` (the answer must echo it, so a relay cannot pass an old question's answer
125
+ * off as a new one's), the Machine Device that asks, and the wording the server
126
+ * must not read.
127
+ */
128
+ export interface SealedAskLabel {
129
+ id: string;
130
+ device: string;
131
+ actions?: SealedAskAction[];
132
+ placeholder?: string;
133
+ }
134
+ /**
135
+ * The summary a sealed question may carry in its body under `context` (F3): what the asking session has changed
136
+ * and how much of its screen the original holds. It is a new key of the same plaintext JSON — the envelope and
137
+ * the schema are unchanged, and a reader that does not know the key drops it with the rest of the keys it does
138
+ * not read. The original (the masked diff and screen) is never in the body: it is the question's sealed attachment.
139
+ */
140
+ export interface SealedAskContext {
141
+ diff: 'ok' | 'no_repo' | 'failed';
142
+ files: number;
143
+ added: number;
144
+ removed: number;
145
+ listed: {
146
+ path: string;
147
+ status: 'M' | 'A' | 'D' | '?';
148
+ added: number;
149
+ removed: number;
150
+ binary?: true;
151
+ }[];
152
+ moreFiles: number;
153
+ screenLines: number;
154
+ masked: number;
155
+ cut: {
156
+ diff: boolean;
157
+ screen: boolean;
158
+ };
159
+ }
160
+ /** The label a sealed answer carries: which question it answers and what was chosen. */
161
+ export interface SealedAnswerLabel {
162
+ ask: string;
163
+ actionId?: string;
164
+ exit?: true;
165
+ }
166
+ /** What `encryptPushBodyForDevices` seals. Keys left out are left out of the plaintext JSON. */
167
+ export interface PushBodyInput {
168
+ title?: string;
169
+ body?: string;
170
+ url?: string;
171
+ cmd?: SealedCommandLabel;
172
+ ask?: SealedAskLabel;
173
+ answer?: SealedAnswerLabel;
174
+ files?: SealedFileMeta[];
175
+ /**
176
+ * Named keys (the blocked prompt's answer) inside a sealed command. Only
177
+ * non-empty string arrays read back; the listener allowlists the names.
178
+ */
179
+ keys?: string[];
180
+ /** The blocked prompt id the keys answer, inside a sealed command. */
181
+ ifPrompt?: string;
182
+ context?: SealedAskContext;
183
+ }
184
+ /**
185
+ * One sealed attachment as the sealed body names it (M5): its IV — fresh per
186
+ * file, so an attachment from another message does not match — and what it
187
+ * really is. The list binds the attachments to the message, in order.
188
+ */
189
+ export interface SealedFileMeta {
190
+ iv: string;
191
+ name: string;
192
+ type: string;
193
+ size: number;
194
+ }
195
+ /** The `code` of the errors above that a shorter choice list fixes. */
196
+ export declare const ASK_CHOICES_CODE = "ask-choices-over-limits";
197
+ /** The `context` summary of a sealed question, or `undefined` when it is absent or not well formed (the question still opens without it). */
198
+ export declare const askContext: (value: unknown) => SealedAskContext | undefined;
199
+ /**
200
+ * Decrypt a push body sealed by `encryptPushBodyForDevices` (any client's
201
+ * twin of it) for this device: `body` is the JSON `{ ciphertext, iv }`
202
+ * envelope, the message key comes out of this device's `deviceKeyMap` slot.
203
+ * Returns the `{ title, body, url }` the sender put in, the `cmd` label of a
204
+ * sealed command, the `ask` / `answer` label of a sealed question / answer and
205
+ * the `files` list and the `context` summary — each only when well formed. `context` is read from any body;
206
+ * whether it belongs there (a question) is the caller's to decide.
207
+ */
208
+ export declare const decryptPushBodyForDevice: (body: string, entryJson: string, senderPublicKeyRaw: string) => Promise<{
209
+ title?: string;
210
+ body?: string;
211
+ url?: string;
212
+ cmd?: SealedCommandLabel;
213
+ ask?: SealedAskLabel;
214
+ answer?: SealedAnswerLabel;
215
+ files?: SealedFileMeta[];
216
+ keys?: string[];
217
+ ifPrompt?: string;
218
+ context?: SealedAskContext;
219
+ }>;
220
+ /**
221
+ * Whether two strings are the same device public key (twin of the cli's
222
+ * `samePublicKey`). A device key has exactly one spelling — standard padded
223
+ * Base64 of the 91-byte SPKI with an uncompressed point, what WebCrypto's
224
+ * `exportKey('spki')` and the app write — so two keys are the same exactly
225
+ * when their strings are. A string in any other spelling equals nothing,
226
+ * itself included.
227
+ */
228
+ export declare const samePublicKey: (a: string, b: string) => boolean;
229
+ /**
230
+ * The x coordinate of a device key's point, in hex (twin of the cli's `publicKeySender`): what the listener counts
231
+ * sealed command numbers by in the trust file's `commandKeys`. null for a string that is not a device key.
232
+ */
233
+ export declare const publicKeySender: (spkiBase64: string) => string | null;
234
+ //# sourceMappingURL=crypto.d.ts.map