@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/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 provider this API key is linked to */
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
- return this.request<SubmitRequestResult>("POST", "/api/v1/help", {
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: opts.signPublicKey,
74
- encryptPublicKey: opts.encryptPublicKey,
75
- providerName: opts.providerName,
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: Message[] }> {
92
- return this.request<{ messages: Message[] }>(
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 { Provider } from "./types.js";
3
+ import type { Expert } from "./types.js";
4
4
 
5
- interface ProvidersFile {
6
- providers: Provider[];
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 providers.json with deduplication by API key and case-insensitive name.
11
+ * Reads/writes experts.json with deduplication by API key and case-insensitive name.
12
12
  */
13
- export class ProviderStore {
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(): Provider[] {
20
+ load(): Expert[] {
21
21
  if (!existsSync(this.filePath)) return [];
22
22
  try {
23
- const data = JSON.parse(readFileSync(this.filePath, "utf8")) as ProvidersFile;
24
- return Array.isArray(data.providers) ? data.providers : [];
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(providers: Provider[]): void {
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({ providers }, null, 2));
35
+ writeFileSync(this.filePath, JSON.stringify({ experts }, null, 2));
36
36
  }
37
37
 
38
38
  /**
39
- * Add or update a provider entry.
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<Provider, "addedAt" | "nameLower"> & { addedAt?: string }): Provider {
43
- const providers = this.load();
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 = providers.filter(
47
+ const filtered = experts.filter(
48
48
  (p) => p.apiKey !== entry.apiKey && p.nameLower !== nameLower
49
49
  );
50
50
 
51
- const newEntry: Provider = {
51
+ const newEntry: Expert = {
52
52
  name: entry.name,
53
53
  nameLower,
54
54
  apiKey: entry.apiKey,
55
- providerId: entry.providerId,
56
- providerName: entry.providerName,
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): Provider | undefined {
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): Provider | undefined {
72
+ findByKey(apiKey: string): Expert | undefined {
73
73
  return this.load().find((p) => p.apiKey === apiKey);
74
74
  }
75
75
 
76
- /** Returns the first provider (default when only one is registered) */
77
- getDefault(): Provider | undefined {
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 providers = this.load();
83
- const filtered = providers.filter((p) => p.apiKey !== apiKey);
84
- if (filtered.length === providers.length) return false;
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 { PollingWatcher } from "./poller.js";
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
- Provider,
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 { PollingWatcherOptions } from "./poller.js";
23
- export type { KeyPair } from "./crypto.js";
26
+ export type { KeyPair, KeyMaterial, EncryptedPayload } from "./crypto.js";