@heysummon/consumer-sdk 0.1.1 → 0.2.6
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/LICENSE.md +87 -0
- package/README.md +225 -0
- package/dist/cli.d.ts +4 -4
- package/dist/cli.js +108 -381
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +20 -5
- package/dist/client.js +172 -8
- package/dist/client.js.map +1 -1
- package/dist/crypto.d.ts +40 -0
- package/dist/crypto.js +76 -0
- package/dist/crypto.js.map +1 -1
- package/dist/expert-store.d.ts +25 -0
- package/dist/{provider-store.js → expert-store.js} +15 -15
- package/dist/expert-store.js.map +1 -0
- package/dist/index.d.ts +4 -7
- package/dist/index.js +2 -4
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +44 -16
- package/package.json +7 -7
- package/src/cli.ts +117 -417
- package/src/client.ts +224 -9
- package/src/crypto.ts +145 -0
- package/src/{provider-store.ts → expert-store.ts} +24 -24
- package/src/index.ts +9 -6
- package/src/types.ts +48 -17
- package/dist/poller.d.ts +0 -28
- package/dist/poller.js +0 -57
- package/dist/poller.js.map +0 -1
- package/dist/provider-store.d.ts +0 -25
- package/dist/provider-store.js.map +0 -1
- package/dist/request-tracker.d.ts +0 -22
- package/dist/request-tracker.js +0 -66
- package/dist/request-tracker.js.map +0 -1
- package/src/poller.ts +0 -72
- package/src/request-tracker.ts +0 -72
package/src/client.ts
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
1
4
|
import type {
|
|
2
5
|
SubmitRequestOptions,
|
|
3
6
|
SubmitRequestResult,
|
|
4
7
|
PendingEvent,
|
|
5
8
|
Message,
|
|
9
|
+
DecryptedMessage,
|
|
10
|
+
MessagesResponse,
|
|
6
11
|
WhoamiResult,
|
|
7
12
|
HeySummonClientOptions,
|
|
8
13
|
RequestStatusResponse,
|
|
9
14
|
} from "./types.js";
|
|
15
|
+
import type { KeyMaterial } from "./crypto.js";
|
|
16
|
+
import {
|
|
17
|
+
generateKeyMaterial,
|
|
18
|
+
encryptWithKeys,
|
|
19
|
+
decryptWithKeys,
|
|
20
|
+
publicKeyFromHex,
|
|
21
|
+
} from "./crypto.js";
|
|
10
22
|
|
|
11
23
|
/** HTTP error with status code for callers to inspect */
|
|
12
24
|
export class HeySummonHttpError extends Error {
|
|
@@ -31,10 +43,14 @@ export class HeySummonHttpError extends Error {
|
|
|
31
43
|
export class HeySummonClient {
|
|
32
44
|
private readonly baseUrl: string;
|
|
33
45
|
private readonly apiKey: string;
|
|
46
|
+
private readonly e2e: boolean;
|
|
47
|
+
private readonly keyStore: Map<string, KeyMaterial> = new Map();
|
|
48
|
+
private readonly providerKeyCache: Map<string, { encPub: string; signPub: string }> = new Map();
|
|
34
49
|
|
|
35
50
|
constructor(opts: HeySummonClientOptions) {
|
|
36
51
|
this.baseUrl = opts.baseUrl.replace(/\/$/, ""); // trim trailing slash
|
|
37
52
|
this.apiKey = opts.apiKey;
|
|
53
|
+
this.e2e = opts.e2e !== false; // default true
|
|
38
54
|
}
|
|
39
55
|
|
|
40
56
|
private async request<T>(
|
|
@@ -59,22 +75,41 @@ export class HeySummonClient {
|
|
|
59
75
|
return res.json() as Promise<T>;
|
|
60
76
|
}
|
|
61
77
|
|
|
62
|
-
/** Identify which
|
|
78
|
+
/** Identify which expert this API key is linked to */
|
|
63
79
|
async whoami(): Promise<WhoamiResult> {
|
|
64
80
|
return this.request<WhoamiResult>("GET", "/api/v1/whoami");
|
|
65
81
|
}
|
|
66
82
|
|
|
67
|
-
/** Submit a help request */
|
|
83
|
+
/** Submit a help request (auto-generates E2E keys unless consumer provides their own or e2e is disabled) */
|
|
68
84
|
async submitRequest(opts: SubmitRequestOptions): Promise<SubmitRequestResult> {
|
|
69
|
-
|
|
85
|
+
let signPublicKey = opts.signPublicKey;
|
|
86
|
+
let encryptPublicKey = opts.encryptPublicKey;
|
|
87
|
+
|
|
88
|
+
const consumerProvidedKeys = !!(opts.signPublicKey && opts.encryptPublicKey);
|
|
89
|
+
const shouldAutoKeygen = this.e2e && !consumerProvidedKeys;
|
|
90
|
+
|
|
91
|
+
let keys: KeyMaterial | undefined;
|
|
92
|
+
if (shouldAutoKeygen) {
|
|
93
|
+
keys = generateKeyMaterial();
|
|
94
|
+
signPublicKey = keys.signPublicKey;
|
|
95
|
+
encryptPublicKey = keys.encryptPublicKey;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const result = await this.request<SubmitRequestResult>("POST", "/api/v1/help", {
|
|
70
99
|
apiKey: this.apiKey,
|
|
71
100
|
question: opts.question,
|
|
72
101
|
messages: opts.messages,
|
|
73
|
-
signPublicKey
|
|
74
|
-
encryptPublicKey
|
|
75
|
-
|
|
102
|
+
signPublicKey,
|
|
103
|
+
encryptPublicKey,
|
|
104
|
+
expertName: opts.expertName,
|
|
76
105
|
requiresApproval: opts.requiresApproval,
|
|
77
106
|
});
|
|
107
|
+
|
|
108
|
+
if (keys && result.requestId) {
|
|
109
|
+
this.keyStore.set(result.requestId, keys);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return result;
|
|
78
113
|
}
|
|
79
114
|
|
|
80
115
|
/** Poll for pending events (writes lastPollAt heartbeat on the server) */
|
|
@@ -87,12 +122,96 @@ export class HeySummonClient {
|
|
|
87
122
|
await this.request<unknown>("POST", `/api/v1/events/ack/${requestId}`, {});
|
|
88
123
|
}
|
|
89
124
|
|
|
90
|
-
/** Fetch the full message history for a request */
|
|
91
|
-
async getMessages(requestId: string): Promise<{ messages:
|
|
92
|
-
|
|
125
|
+
/** Fetch the full message history for a request (auto-decrypts when keys are available) */
|
|
126
|
+
async getMessages(requestId: string): Promise<{ messages: DecryptedMessage[] }> {
|
|
127
|
+
const res = await this.request<MessagesResponse>(
|
|
93
128
|
"GET",
|
|
94
129
|
`/api/v1/messages/${requestId}`
|
|
95
130
|
);
|
|
131
|
+
|
|
132
|
+
const keys = this.keyStore.get(requestId);
|
|
133
|
+
const hasExpertKeys = !!(res.expertEncryptPubKey && res.expertSignPubKey);
|
|
134
|
+
|
|
135
|
+
// Cache expert public keys for sendMessage() to avoid N+1 fetches
|
|
136
|
+
if (hasExpertKeys) {
|
|
137
|
+
this.providerKeyCache.set(requestId, {
|
|
138
|
+
encPub: res.expertEncryptPubKey!,
|
|
139
|
+
signPub: res.expertSignPubKey!,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const messages: DecryptedMessage[] = res.messages.map((msg) => {
|
|
144
|
+
// Plaintext messages: return as-is
|
|
145
|
+
if (msg.iv === "plaintext" || msg.plaintext) {
|
|
146
|
+
return {
|
|
147
|
+
id: msg.id,
|
|
148
|
+
from: msg.from,
|
|
149
|
+
messageId: msg.messageId,
|
|
150
|
+
createdAt: msg.createdAt,
|
|
151
|
+
plaintext: msg.plaintext ?? Buffer.from(msg.ciphertext, "base64").toString("utf8"),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// No keys available for decryption: return raw message
|
|
156
|
+
if (!keys || !hasExpertKeys) {
|
|
157
|
+
return {
|
|
158
|
+
id: msg.id,
|
|
159
|
+
from: msg.from,
|
|
160
|
+
ciphertext: msg.ciphertext,
|
|
161
|
+
iv: msg.iv,
|
|
162
|
+
authTag: msg.authTag,
|
|
163
|
+
signature: msg.signature,
|
|
164
|
+
messageId: msg.messageId,
|
|
165
|
+
createdAt: msg.createdAt,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Auto-decrypt with stored keys
|
|
170
|
+
try {
|
|
171
|
+
// Always use expert's key for DH shared secret — DH is symmetric:
|
|
172
|
+
// DH(consumer_priv, expert_pub) = DH(expert_priv, consumer_pub)
|
|
173
|
+
const senderEncPub = publicKeyFromHex(res.expertEncryptPubKey!, "x25519");
|
|
174
|
+
// Only vary the signing key for signature verification
|
|
175
|
+
const senderSignPub = msg.from === "expert"
|
|
176
|
+
? publicKeyFromHex(res.expertSignPubKey!, "ed25519")
|
|
177
|
+
: publicKeyFromHex(res.consumerSignPubKey!, "ed25519");
|
|
178
|
+
|
|
179
|
+
const plaintext = decryptWithKeys(
|
|
180
|
+
{
|
|
181
|
+
ciphertext: msg.ciphertext,
|
|
182
|
+
iv: msg.iv,
|
|
183
|
+
authTag: msg.authTag,
|
|
184
|
+
signature: msg.signature,
|
|
185
|
+
messageId: msg.messageId,
|
|
186
|
+
},
|
|
187
|
+
senderEncPub,
|
|
188
|
+
senderSignPub,
|
|
189
|
+
keys.encryptKeyPair.privateKey
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
id: msg.id,
|
|
194
|
+
from: msg.from,
|
|
195
|
+
messageId: msg.messageId,
|
|
196
|
+
createdAt: msg.createdAt,
|
|
197
|
+
plaintext,
|
|
198
|
+
};
|
|
199
|
+
} catch {
|
|
200
|
+
return {
|
|
201
|
+
id: msg.id,
|
|
202
|
+
from: msg.from,
|
|
203
|
+
ciphertext: msg.ciphertext,
|
|
204
|
+
iv: msg.iv,
|
|
205
|
+
authTag: msg.authTag,
|
|
206
|
+
signature: msg.signature,
|
|
207
|
+
messageId: msg.messageId,
|
|
208
|
+
createdAt: msg.createdAt,
|
|
209
|
+
decryptError: true,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
return { messages };
|
|
96
215
|
}
|
|
97
216
|
|
|
98
217
|
/** Get the current status of a help request */
|
|
@@ -114,4 +233,100 @@ export class HeySummonClient {
|
|
|
114
233
|
`/api/v1/requests/by-ref/${refCode}`
|
|
115
234
|
);
|
|
116
235
|
}
|
|
236
|
+
|
|
237
|
+
/** Report that the client's blocking poll timed out */
|
|
238
|
+
async reportTimeout(requestId: string): Promise<void> {
|
|
239
|
+
await this.request<unknown>("POST", `/api/v1/help/${requestId}/timeout`, {});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Send an encrypted message to the expert (falls back to plaintext if no keys) */
|
|
243
|
+
async sendMessage(
|
|
244
|
+
requestId: string,
|
|
245
|
+
text: string
|
|
246
|
+
): Promise<{ success: boolean; messageId: string; createdAt: string }> {
|
|
247
|
+
const keys = this.keyStore.get(requestId);
|
|
248
|
+
|
|
249
|
+
if (!keys) {
|
|
250
|
+
// No local keys: send as plaintext (content-safety checked server-side)
|
|
251
|
+
return this.request("POST", `/api/v1/message/${requestId}`, {
|
|
252
|
+
from: "consumer",
|
|
253
|
+
plaintext: text,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Use cached expert keys to avoid N+1 API calls; fetch only on cache miss
|
|
258
|
+
let cached = this.providerKeyCache.get(requestId);
|
|
259
|
+
if (!cached) {
|
|
260
|
+
const res = await this.request<MessagesResponse>(
|
|
261
|
+
"GET",
|
|
262
|
+
`/api/v1/messages/${requestId}`
|
|
263
|
+
);
|
|
264
|
+
if (res.expertEncryptPubKey && res.expertSignPubKey) {
|
|
265
|
+
cached = { encPub: res.expertEncryptPubKey, signPub: res.expertSignPubKey };
|
|
266
|
+
this.providerKeyCache.set(requestId, cached);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!cached) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
"Cannot send encrypted message: expert has not completed key exchange yet"
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const expertEncPub = publicKeyFromHex(cached.encPub, "x25519");
|
|
277
|
+
|
|
278
|
+
const payload = encryptWithKeys(
|
|
279
|
+
text,
|
|
280
|
+
expertEncPub,
|
|
281
|
+
keys.signKeyPair.privateKey,
|
|
282
|
+
keys.encryptKeyPair.privateKey
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
return this.request("POST", `/api/v1/message/${requestId}`, {
|
|
286
|
+
from: "consumer",
|
|
287
|
+
ciphertext: payload.ciphertext,
|
|
288
|
+
iv: payload.iv,
|
|
289
|
+
authTag: payload.authTag,
|
|
290
|
+
signature: payload.signature,
|
|
291
|
+
messageId: payload.messageId,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Remove keys from the store for a completed/closed request */
|
|
296
|
+
releaseKeys(requestId: string): void {
|
|
297
|
+
this.keyStore.delete(requestId);
|
|
298
|
+
this.providerKeyCache.delete(requestId);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Import persistent keys from PEM files into the key store for a given request */
|
|
302
|
+
importKeys(requestId: string, keyDir: string): void {
|
|
303
|
+
const signPubPem = fs.readFileSync(path.join(keyDir, "sign_public.pem"), "utf8");
|
|
304
|
+
const signPrivPem = fs.readFileSync(path.join(keyDir, "sign_private.pem"), "utf8");
|
|
305
|
+
const encPubPem = fs.readFileSync(path.join(keyDir, "encrypt_public.pem"), "utf8");
|
|
306
|
+
const encPrivPem = fs.readFileSync(path.join(keyDir, "encrypt_private.pem"), "utf8");
|
|
307
|
+
|
|
308
|
+
const signPublicKey = crypto
|
|
309
|
+
.createPublicKey(signPubPem)
|
|
310
|
+
.export({ type: "spki", format: "der" })
|
|
311
|
+
.toString("hex");
|
|
312
|
+
const encryptPublicKey = crypto
|
|
313
|
+
.createPublicKey(encPubPem)
|
|
314
|
+
.export({ type: "spki", format: "der" })
|
|
315
|
+
.toString("hex");
|
|
316
|
+
|
|
317
|
+
const keys: KeyMaterial = {
|
|
318
|
+
signPublicKey,
|
|
319
|
+
encryptPublicKey,
|
|
320
|
+
signKeyPair: {
|
|
321
|
+
publicKey: crypto.createPublicKey(signPubPem),
|
|
322
|
+
privateKey: crypto.createPrivateKey(signPrivPem),
|
|
323
|
+
},
|
|
324
|
+
encryptKeyPair: {
|
|
325
|
+
publicKey: crypto.createPublicKey(encPubPem),
|
|
326
|
+
privateKey: crypto.createPrivateKey(encPrivPem),
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
this.keyStore.set(requestId, keys);
|
|
331
|
+
}
|
|
117
332
|
}
|
package/src/crypto.ts
CHANGED
|
@@ -7,6 +7,21 @@ export interface KeyPair {
|
|
|
7
7
|
encryptPublicKey: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export interface EncryptedPayload {
|
|
11
|
+
ciphertext: string;
|
|
12
|
+
iv: string;
|
|
13
|
+
authTag: string;
|
|
14
|
+
signature: string;
|
|
15
|
+
messageId: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface KeyMaterial {
|
|
19
|
+
signPublicKey: string;
|
|
20
|
+
encryptPublicKey: string;
|
|
21
|
+
signKeyPair: { publicKey: crypto.KeyObject; privateKey: crypto.KeyObject };
|
|
22
|
+
encryptKeyPair: { publicKey: crypto.KeyObject; privateKey: crypto.KeyObject };
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
/**
|
|
11
26
|
* Generate ephemeral Ed25519 + X25519 key pairs in memory.
|
|
12
27
|
* Returns hex-encoded DER public keys for the HeySummon API.
|
|
@@ -203,3 +218,133 @@ export function decrypt(
|
|
|
203
218
|
|
|
204
219
|
return Buffer.concat([decipher.update(ciphertextBuf), decipher.final()]).toString("utf8");
|
|
205
220
|
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Generate full key material (Ed25519 + X25519) with retained private keys.
|
|
224
|
+
* Unlike generateEphemeralKeys(), private keys are kept as KeyObject instances
|
|
225
|
+
* for use with encryptWithKeys/decryptWithKeys.
|
|
226
|
+
*/
|
|
227
|
+
export function generateKeyMaterial(): KeyMaterial {
|
|
228
|
+
const signKeyPair = crypto.generateKeyPairSync("ed25519");
|
|
229
|
+
const encryptKeyPair = crypto.generateKeyPairSync("x25519");
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
signPublicKey: signKeyPair.publicKey
|
|
233
|
+
.export({ type: "spki", format: "der" })
|
|
234
|
+
.toString("hex"),
|
|
235
|
+
encryptPublicKey: encryptKeyPair.publicKey
|
|
236
|
+
.export({ type: "spki", format: "der" })
|
|
237
|
+
.toString("hex"),
|
|
238
|
+
signKeyPair,
|
|
239
|
+
encryptKeyPair,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Reconstruct a crypto.KeyObject from a hex-encoded DER public key.
|
|
245
|
+
*/
|
|
246
|
+
export function publicKeyFromHex(
|
|
247
|
+
hex: string,
|
|
248
|
+
type: "ed25519" | "x25519"
|
|
249
|
+
): crypto.KeyObject {
|
|
250
|
+
const derBuffer = Buffer.from(hex, "hex");
|
|
251
|
+
return crypto.createPublicKey({
|
|
252
|
+
key: derBuffer,
|
|
253
|
+
format: "der",
|
|
254
|
+
type: "spki",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Encrypt plaintext using KeyObject instances directly (no file I/O).
|
|
260
|
+
* Same X25519 DH + HKDF + AES-256-GCM + Ed25519 signing as encrypt().
|
|
261
|
+
*/
|
|
262
|
+
export function encryptWithKeys(
|
|
263
|
+
plaintext: string,
|
|
264
|
+
recipientEncryptPub: crypto.KeyObject,
|
|
265
|
+
ownSignPriv: crypto.KeyObject,
|
|
266
|
+
ownEncPriv: crypto.KeyObject,
|
|
267
|
+
messageId?: string
|
|
268
|
+
): EncryptedPayload {
|
|
269
|
+
const sharedSecret = crypto.diffieHellman({
|
|
270
|
+
privateKey: ownEncPriv,
|
|
271
|
+
publicKey: recipientEncryptPub,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const msgId = messageId || crypto.randomUUID();
|
|
275
|
+
|
|
276
|
+
const messageKey = crypto.hkdfSync(
|
|
277
|
+
"sha256",
|
|
278
|
+
sharedSecret,
|
|
279
|
+
msgId,
|
|
280
|
+
"heysummon-msg",
|
|
281
|
+
32
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const iv = crypto.randomBytes(12);
|
|
285
|
+
const cipher = crypto.createCipheriv(
|
|
286
|
+
"aes-256-gcm",
|
|
287
|
+
Buffer.from(messageKey),
|
|
288
|
+
iv
|
|
289
|
+
);
|
|
290
|
+
const encrypted = Buffer.concat([
|
|
291
|
+
cipher.update(plaintext, "utf8"),
|
|
292
|
+
cipher.final(),
|
|
293
|
+
]);
|
|
294
|
+
const authTag = cipher.getAuthTag();
|
|
295
|
+
|
|
296
|
+
const signature = crypto.sign(null, encrypted, ownSignPriv);
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
ciphertext: encrypted.toString("base64"),
|
|
300
|
+
iv: iv.toString("base64"),
|
|
301
|
+
authTag: authTag.toString("base64"),
|
|
302
|
+
signature: signature.toString("base64"),
|
|
303
|
+
messageId: msgId,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Decrypt an encrypted payload using KeyObject instances directly (no file I/O).
|
|
309
|
+
* Verifies the Ed25519 signature before decrypting.
|
|
310
|
+
*/
|
|
311
|
+
export function decryptWithKeys(
|
|
312
|
+
payload: EncryptedPayload,
|
|
313
|
+
senderEncryptPub: crypto.KeyObject,
|
|
314
|
+
senderSignPub: crypto.KeyObject,
|
|
315
|
+
ownEncPriv: crypto.KeyObject
|
|
316
|
+
): string {
|
|
317
|
+
const ciphertextBuf = Buffer.from(payload.ciphertext, "base64");
|
|
318
|
+
const valid = crypto.verify(
|
|
319
|
+
null,
|
|
320
|
+
ciphertextBuf,
|
|
321
|
+
senderSignPub,
|
|
322
|
+
Buffer.from(payload.signature, "base64")
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
if (!valid) {
|
|
326
|
+
throw new Error("Signature verification failed");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const sharedSecret = crypto.diffieHellman({
|
|
330
|
+
privateKey: ownEncPriv,
|
|
331
|
+
publicKey: senderEncryptPub,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const messageKey = crypto.hkdfSync(
|
|
335
|
+
"sha256",
|
|
336
|
+
sharedSecret,
|
|
337
|
+
payload.messageId,
|
|
338
|
+
"heysummon-msg",
|
|
339
|
+
32
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
const decipher = crypto.createDecipheriv(
|
|
343
|
+
"aes-256-gcm",
|
|
344
|
+
Buffer.from(messageKey),
|
|
345
|
+
Buffer.from(payload.iv, "base64")
|
|
346
|
+
);
|
|
347
|
+
decipher.setAuthTag(Buffer.from(payload.authTag, "base64"));
|
|
348
|
+
|
|
349
|
+
return Buffer.concat([decipher.update(ciphertextBuf), decipher.final()]).toString("utf8");
|
|
350
|
+
}
|
|
@@ -1,59 +1,59 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
2
2
|
import { dirname } from "path";
|
|
3
|
-
import type {
|
|
3
|
+
import type { Expert } from "./types.js";
|
|
4
4
|
|
|
5
|
-
interface
|
|
6
|
-
|
|
5
|
+
interface ExpertsFile {
|
|
6
|
+
experts: Expert[];
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Typed replacement for the inline `node -e` JSON manipulation in the bash scripts.
|
|
11
|
-
* Reads/writes
|
|
11
|
+
* Reads/writes experts.json with deduplication by API key and case-insensitive name.
|
|
12
12
|
*/
|
|
13
|
-
export class
|
|
13
|
+
export class ExpertStore {
|
|
14
14
|
private readonly filePath: string;
|
|
15
15
|
|
|
16
16
|
constructor(filePath: string) {
|
|
17
17
|
this.filePath = filePath;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
load():
|
|
20
|
+
load(): Expert[] {
|
|
21
21
|
if (!existsSync(this.filePath)) return [];
|
|
22
22
|
try {
|
|
23
|
-
const data = JSON.parse(readFileSync(this.filePath, "utf8")) as
|
|
24
|
-
return Array.isArray(data.
|
|
23
|
+
const data = JSON.parse(readFileSync(this.filePath, "utf8")) as ExpertsFile;
|
|
24
|
+
return Array.isArray(data.experts) ? data.experts : [];
|
|
25
25
|
} catch {
|
|
26
26
|
return [];
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
save(
|
|
30
|
+
save(experts: Expert[]): void {
|
|
31
31
|
const dir = dirname(this.filePath);
|
|
32
32
|
if (!existsSync(dir)) {
|
|
33
33
|
mkdirSync(dir, { recursive: true });
|
|
34
34
|
}
|
|
35
|
-
writeFileSync(this.filePath, JSON.stringify({
|
|
35
|
+
writeFileSync(this.filePath, JSON.stringify({ experts }, null, 2));
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
* Add or update
|
|
39
|
+
* Add or update an expert entry.
|
|
40
40
|
* Deduplicates by API key and case-insensitive name (same logic as the bash script).
|
|
41
41
|
*/
|
|
42
|
-
add(entry: Omit<
|
|
43
|
-
const
|
|
42
|
+
add(entry: Omit<Expert, "addedAt" | "nameLower"> & { addedAt?: string }): Expert {
|
|
43
|
+
const experts = this.load();
|
|
44
44
|
const nameLower = entry.name.toLowerCase();
|
|
45
45
|
|
|
46
46
|
// Remove existing entries with same key or name (case-insensitive)
|
|
47
|
-
const filtered =
|
|
47
|
+
const filtered = experts.filter(
|
|
48
48
|
(p) => p.apiKey !== entry.apiKey && p.nameLower !== nameLower
|
|
49
49
|
);
|
|
50
50
|
|
|
51
|
-
const newEntry:
|
|
51
|
+
const newEntry: Expert = {
|
|
52
52
|
name: entry.name,
|
|
53
53
|
nameLower,
|
|
54
54
|
apiKey: entry.apiKey,
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
expertId: entry.expertId,
|
|
56
|
+
expertName: entry.expertName,
|
|
57
57
|
addedAt: entry.addedAt ?? new Date().toISOString(),
|
|
58
58
|
};
|
|
59
59
|
|
|
@@ -63,25 +63,25 @@ export class ProviderStore {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/** Case-insensitive lookup by name or alias */
|
|
66
|
-
findByName(name: string):
|
|
66
|
+
findByName(name: string): Expert | undefined {
|
|
67
67
|
const lower = name.toLowerCase();
|
|
68
68
|
return this.load().find((p) => p.nameLower === lower || p.name.toLowerCase() === lower);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/** Look up by exact API key */
|
|
72
|
-
findByKey(apiKey: string):
|
|
72
|
+
findByKey(apiKey: string): Expert | undefined {
|
|
73
73
|
return this.load().find((p) => p.apiKey === apiKey);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
/** Returns the first
|
|
77
|
-
getDefault():
|
|
76
|
+
/** Returns the first expert (default when only one is registered) */
|
|
77
|
+
getDefault(): Expert | undefined {
|
|
78
78
|
return this.load()[0];
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
remove(apiKey: string): boolean {
|
|
82
|
-
const
|
|
83
|
-
const filtered =
|
|
84
|
-
if (filtered.length ===
|
|
82
|
+
const experts = this.load();
|
|
83
|
+
const filtered = experts.filter((p) => p.apiKey !== apiKey);
|
|
84
|
+
if (filtered.length === experts.length) return false;
|
|
85
85
|
this.save(filtered);
|
|
86
86
|
return true;
|
|
87
87
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
export { HeySummonClient, HeySummonHttpError } from "./client.js";
|
|
2
|
-
export {
|
|
3
|
-
export { ProviderStore } from "./provider-store.js";
|
|
4
|
-
export { RequestTracker } from "./request-tracker.js";
|
|
2
|
+
export { ExpertStore } from "./expert-store.js";
|
|
5
3
|
export {
|
|
6
4
|
generateEphemeralKeys,
|
|
7
5
|
generatePersistentKeys,
|
|
8
6
|
loadPublicKeys,
|
|
9
7
|
encrypt,
|
|
10
8
|
decrypt,
|
|
9
|
+
generateKeyMaterial,
|
|
10
|
+
publicKeyFromHex,
|
|
11
|
+
encryptWithKeys,
|
|
12
|
+
decryptWithKeys,
|
|
11
13
|
} from "./crypto.js";
|
|
12
14
|
export type {
|
|
13
|
-
|
|
15
|
+
Expert,
|
|
14
16
|
SubmitRequestOptions,
|
|
15
17
|
SubmitRequestResult,
|
|
16
18
|
PendingEvent,
|
|
17
19
|
Message,
|
|
20
|
+
DecryptedMessage,
|
|
21
|
+
MessagesResponse,
|
|
18
22
|
RequestStatusResponse,
|
|
19
23
|
WhoamiResult,
|
|
20
24
|
HeySummonClientOptions,
|
|
21
25
|
} from "./types.js";
|
|
22
|
-
export type {
|
|
23
|
-
export type { KeyPair } from "./crypto.js";
|
|
26
|
+
export type { KeyPair, KeyMaterial, EncryptedPayload } from "./crypto.js";
|