@xdevplatform/chat-xdk 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -5,8 +5,14 @@
5
5
  * The Rust WASM `Chat` is a pure crypto engine that never touches
6
6
  * globalThis or module-level singletons.
7
7
  *
8
- * Each `ChatWithJuicebox` instance holds its **own** Juicebox client, so
9
- * multiple users / configs can coexist safely in the same runtime.
8
+ * Each `ChatWithJuicebox` instance holds its own Juicebox client, but the
9
+ * juicebox-sdk WASM resolves its auth-token callback through a single
10
+ * process-global (`JuiceboxGetAuthToken`). Every Juicebox operation re-arms
11
+ * that global with its own instance's token getter just before running, so
12
+ * *sequential* use of multiple instances in one runtime is correct.
13
+ * *Concurrent* Juicebox operations across instances in one process are not
14
+ * supported: the last-armed instance's token getter services all in-flight
15
+ * calls.
10
16
  */
11
17
 
12
18
  function isNodeRuntime() {
@@ -46,11 +52,13 @@ function toPinBytes(pin) {
46
52
  * descending run of digits. Never applied on unlock.
47
53
  */
48
54
  function validatePinStrength(pinBytes) {
55
+ // Error messages match the native core's weak-PIN errors byte-for-byte so
56
+ // callers can match on them across bindings.
49
57
  if (pinBytes.length < MIN_PIN_LENGTH) {
50
- throw new Error(`Weak PIN: must be at least ${MIN_PIN_LENGTH} characters`);
58
+ throw new Error(`PIN must be at least ${MIN_PIN_LENGTH} characters`);
51
59
  }
52
60
  if (pinBytes.every((b) => b === pinBytes[0])) {
53
- throw new Error('Weak PIN: must not be a single repeated character');
61
+ throw new Error('PIN must not be a single repeated character');
54
62
  }
55
63
  const allDigits = pinBytes.every((b) => b >= 0x30 && b <= 0x39);
56
64
  let ascending = true;
@@ -60,7 +68,7 @@ function validatePinStrength(pinBytes) {
60
68
  if (pinBytes[i] !== pinBytes[i - 1] - 1) descending = false;
61
69
  }
62
70
  if (allDigits && (ascending || descending)) {
63
- throw new Error('Weak PIN: must not be a sequential run of digits');
71
+ throw new Error('PIN must not be a sequential run of digits');
64
72
  }
65
73
  }
66
74
 
@@ -90,9 +98,15 @@ async function initWasmModule() {
90
98
  let wasmModule;
91
99
  let init;
92
100
  if (isNodeRuntime()) {
93
- const { createRequire } = await import("module");
94
- const require = createRequire(import.meta.url);
95
- wasmModule = require("./pkg/chat_xdk_wasm.js");
101
+ // Node 18 ships WebCrypto but does not expose it on the global scope,
102
+ // and the wasm module's random-byte source requires globalThis.crypto.
103
+ if (typeof globalThis.crypto === "undefined") {
104
+ const { webcrypto } = await import("crypto");
105
+ globalThis.crypto = webcrypto;
106
+ }
107
+ // Dynamic import, not require(): the wasm glue is ESM, and require(esm)
108
+ // only exists on Node >= 20.19 while this package supports Node 18+.
109
+ wasmModule = await import("./pkg/chat_xdk_wasm.js");
96
110
  init =
97
111
  wasmModule?.default ||
98
112
  wasmModule?.init ||
@@ -141,6 +155,104 @@ async function loadJuiceboxSdk() {
141
155
  return await import("juicebox-sdk");
142
156
  }
143
157
 
158
+ /**
159
+ * Resolve the PIN guess budget for Juicebox registration, matching the
160
+ * native bindings: a `max_guess_count` that is a non-negative integer
161
+ * (including 0) applies as-is; a fractional, negative, or non-numeric value
162
+ * falls back to the shape default — 20 for the `sdk_config` and
163
+ * `key_store_token_map_json` shapes, 5 otherwise. An explicit
164
+ * `maxGuessCount` option overrides the config under
165
+ * the same integer rule. Exported for tests; `createChat` calls it
166
+ * internally.
167
+ *
168
+ * @param {Object} configValue - Parsed Juicebox config JSON from the X API
169
+ * @param {number} [maxGuessCount] - Explicit override from createChat options
170
+ * @returns {number} The number of PIN guesses to register with
171
+ */
172
+ export function resolveMaxGuessCount(configValue, maxGuessCount) {
173
+ if (Number.isSafeInteger(maxGuessCount) && maxGuessCount >= 0) {
174
+ return maxGuessCount;
175
+ }
176
+ const fromConfig = configValue?.max_guess_count;
177
+ if (Number.isSafeInteger(fromConfig) && fromConfig >= 0) {
178
+ return fromConfig;
179
+ }
180
+ return typeof configValue?.sdk_config === "string" ||
181
+ typeof configValue?.key_store_token_map_json === "string"
182
+ ? 20
183
+ : 5;
184
+ }
185
+
186
+ /**
187
+ * Derive the value to hand the Juicebox `Configuration` constructor from a
188
+ * parsed X API config. The `sdk_config` wrapper shape is unwrapped to its
189
+ * embedded SDK config JSON string (which the Juicebox constructor accepts
190
+ * directly); the X API `juicebox_config` object shape is unwrapped to its
191
+ * `key_store_token_map_json` string, which is used **verbatim** because it
192
+ * carries each realm's `public_key` and the server's register/recover
193
+ * thresholds — the realms require both; a raw realms config passes through
194
+ * unchanged; a bare `token_map` shape (an array of
195
+ * `{ key, value: { address } }` entries, no `key_store_token_map_json`) is
196
+ * converted to a realms config with majority recover threshold, the same
197
+ * derivation the native bindings apply. A non-array `token_map` is rejected
198
+ * here, with the same message the core parser uses, rather than handed to
199
+ * the Juicebox constructor to fail obscurely. Realm auth tokens are not
200
+ * taken from the config in this wrapper — they come from the `getAuthToken`
201
+ * callback.
202
+ */
203
+ export function juiceboxClientConfig(configValue) {
204
+ if (typeof configValue?.sdk_config === "string") {
205
+ return configValue.sdk_config;
206
+ }
207
+ if (configValue?.key_store_token_map_json !== undefined) {
208
+ if (typeof configValue.key_store_token_map_json !== "string") {
209
+ throw new Error("key_store_token_map_json must be a string");
210
+ }
211
+ // A malformed embedded config is an error rather than a fall-through to
212
+ // the lossy token_map derivation, which would silently drop the realm
213
+ // public keys and produce configs that can never reach the recover
214
+ // threshold. It must parse to a JSON object — merely valid JSON like
215
+ // "42" or "[]" would be handed to the Juicebox constructor to fail
216
+ // obscurely at setup/unlock time.
217
+ let parsed;
218
+ try {
219
+ parsed = JSON.parse(configValue.key_store_token_map_json);
220
+ } catch (e) {
221
+ throw new Error(`Invalid key_store_token_map_json: ${e.message}`);
222
+ }
223
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
224
+ throw new Error("Invalid key_store_token_map_json: not a JSON object");
225
+ }
226
+ return configValue.key_store_token_map_json;
227
+ }
228
+ if (
229
+ configValue?.token_map !== undefined &&
230
+ configValue?.realms === undefined &&
231
+ !Array.isArray(configValue.token_map)
232
+ ) {
233
+ throw new Error("Missing token_map or sdk_config");
234
+ }
235
+ if (Array.isArray(configValue?.token_map) && configValue?.realms === undefined) {
236
+ const realms = configValue.token_map.map((entry) => {
237
+ const id = entry?.key;
238
+ const address = entry?.value?.address;
239
+ if (typeof id !== "string" || typeof address !== "string") {
240
+ throw new Error(
241
+ "Invalid token_map entry: expected { key, value: { address } }",
242
+ );
243
+ }
244
+ return { id, address };
245
+ });
246
+ return {
247
+ realms,
248
+ register_threshold: realms.length,
249
+ recover_threshold: Math.floor(realms.length / 2) + 1,
250
+ pin_hashing_mode: "Standard2019",
251
+ };
252
+ }
253
+ return configValue;
254
+ }
255
+
144
256
  // ChatWithJuicebox — JS wrapper that owns both a WasmChat and a Juicebox client
145
257
 
146
258
  /**
@@ -153,18 +265,36 @@ export class ChatWithJuicebox {
153
265
  #inner;
154
266
  #juiceboxClient;
155
267
  #numGuesses;
268
+ // The raw `maxGuessCount` createChat option (undefined when not provided),
269
+ // kept so updateConfig re-resolves the budget under the same override
270
+ // semantics as construction: an explicit option keeps winning.
271
+ #maxGuessCountOverride;
156
272
  #JuiceboxConfiguration;
157
273
  #JuiceboxClient;
274
+ // Points the process-global `JuiceboxGetAuthToken` at this instance's token
275
+ // getter. Called before every Juicebox network operation so sequential use
276
+ // of multiple instances presents the right tokens (last-armed wins).
277
+ #armAuthTokenHook;
158
278
 
159
- constructor(wasmChat, juiceboxClient, numGuesses, JuiceboxConfiguration, JuiceboxClientCtor) {
279
+ constructor(
280
+ wasmChat,
281
+ juiceboxClient,
282
+ numGuesses,
283
+ JuiceboxConfiguration,
284
+ JuiceboxClientCtor,
285
+ armAuthTokenHook,
286
+ maxGuessCountOverride,
287
+ ) {
160
288
  this.#inner = wasmChat;
161
289
  this.#juiceboxClient = juiceboxClient;
162
290
  this.#numGuesses = numGuesses;
291
+ this.#maxGuessCountOverride = maxGuessCountOverride;
163
292
  this.#JuiceboxConfiguration = JuiceboxConfiguration;
164
293
  this.#JuiceboxClient = JuiceboxClientCtor;
294
+ this.#armAuthTokenHook = armAuthTokenHook ?? (() => {});
165
295
  }
166
296
 
167
- // Juicebox lifecycle (JS-only no globalThis, no singletons)
297
+ // Juicebox lifecycle (handled in this JS layer)
168
298
 
169
299
  /**
170
300
  * Register existing keys with Juicebox. Call generateKeypairs() first.
@@ -178,6 +308,7 @@ export class ChatWithJuicebox {
178
308
  validatePinStrength(pinBytes);
179
309
  const secretBytes = this.#inner.exportKeys();
180
310
  try {
311
+ this.#armAuthTokenHook();
181
312
  await this.#juiceboxClient.register(
182
313
  pinBytes,
183
314
  secretBytes,
@@ -202,6 +333,7 @@ export class ChatWithJuicebox {
202
333
  const ownsPin = typeof pin === 'string';
203
334
  let secretBytes;
204
335
  try {
336
+ this.#armAuthTokenHook();
205
337
  secretBytes = await this.#juiceboxClient.recover(pinBytes, new Uint8Array(0));
206
338
  } catch (err) {
207
339
  throw new Error(`Juicebox recovery failed: ${formatJuiceboxError(err, RECOVER_REASON_BY_VALUE)}`);
@@ -217,6 +349,7 @@ export class ChatWithJuicebox {
217
349
 
218
350
  /** Delete keys from Juicebox. Warning: Irreversible. */
219
351
  async delete() {
352
+ this.#armAuthTokenHook();
220
353
  await this.#juiceboxClient.delete();
221
354
  this.#inner.lock();
222
355
  }
@@ -229,6 +362,7 @@ export class ChatWithJuicebox {
229
362
  await this.unlock(oldPin);
230
363
  const secretBytes = this.#inner.exportKeys();
231
364
  try {
365
+ this.#armAuthTokenHook();
232
366
  await this.#juiceboxClient.register(
233
367
  newPinBytes,
234
368
  secretBytes,
@@ -243,11 +377,16 @@ export class ChatWithJuicebox {
243
377
  }
244
378
  }
245
379
 
246
- /** Update Juicebox config (e.g. to refresh auth tokens). Re-creates the client. */
380
+ /**
381
+ * Update Juicebox config (e.g. to refresh auth tokens). Re-creates the
382
+ * client and re-resolves the PIN guess budget from the new config; an
383
+ * explicit createChat `maxGuessCount` override keeps winning.
384
+ */
247
385
  updateConfig(juiceboxConfig) {
248
386
  const configValue = JSON.parse(juiceboxConfig);
249
- const config = new this.#JuiceboxConfiguration(configValue);
387
+ const config = new this.#JuiceboxConfiguration(juiceboxClientConfig(configValue));
250
388
  this.#juiceboxClient = new this.#JuiceboxClient(config, []);
389
+ this.#numGuesses = resolveMaxGuessCount(configValue, this.#maxGuessCountOverride);
251
390
  }
252
391
 
253
392
  // Forwarded crypto methods — delegate to inner WasmChat
@@ -261,35 +400,42 @@ export class ChatWithJuicebox {
261
400
  hasIdentityKey() { return this.#inner.hasIdentityKey(); }
262
401
  lock() { return this.#inner.lock(); }
263
402
  decryptConversationKey(b64) { return this.#inner.decryptConversationKey(b64); }
264
- generateConversationKey() { return this.#inner.generateConversationKey(); }
265
403
  extractConversationKeys(events) { return this.#inner.extractConversationKeys(events); }
266
404
  decryptEvent(eventB64, conversationKeys, signingKeys) {
267
- return this.#inner.decryptEvent(eventB64, conversationKeys, signingKeys ?? []);
405
+ return this.#inner.decryptEvent(eventB64, conversationKeys, signingKeys);
268
406
  }
269
407
  decryptEvents(events, signingKeys) {
270
- return this.#inner.decryptEvents(events, signingKeys ?? []);
408
+ return this.#inner.decryptEvents(events, signingKeys);
271
409
  }
272
410
  sign(data) { return this.#inner.sign(data); }
273
411
  verify(publicKeyB64, signature, data) { return this.#inner.verify(publicKeyB64, signature, data); }
274
412
  verifyKeyBinding(identityPublicKeyB64, signingPublicKeyB64, identityPublicKeySignatureB64) {
275
413
  return this.#inner.verifyKeyBinding(identityPublicKeyB64, signingPublicKeyB64, identityPublicKeySignatureB64);
276
414
  }
277
- encryptMessage(...args) { return this.#inner.encryptMessage(...args); }
278
- encryptReply(...args) { return this.#inner.encryptReply(...args); }
279
- encryptAddReaction(...args) { return this.#inner.encryptAddReaction(...args); }
280
- encryptRemoveReaction(...args) { return this.#inner.encryptRemoveReaction(...args); }
415
+ encryptMessage(params) { return this.#inner.encryptMessage(params); }
416
+ encryptReply(params) { return this.#inner.encryptReply(params); }
417
+ encryptAddReaction(params) { return this.#inner.encryptAddReaction(params); }
418
+ encryptRemoveReaction(params) { return this.#inner.encryptRemoveReaction(params); }
281
419
  encryptStream(plaintext, key) { return this.#inner.encryptStream(plaintext, key); }
282
420
  decryptStream(encrypted, key) { return this.#inner.decryptStream(encrypted, key); }
421
+ streamEncryptor(key) { return this.#inner.streamEncryptor(key); }
422
+ streamDecryptor(key) { return this.#inner.streamDecryptor(key); }
283
423
  encrypt(plaintext, key) { return this.#inner.encrypt(plaintext, key); }
284
424
  decrypt(ciphertext, key) { return this.#inner.decrypt(ciphertext, key); }
285
- encryptConversationKeyForRecipients(key, recipients) {
286
- return this.#inner.encryptConversationKeyForRecipients(key, recipients);
287
- }
288
- prepareConversationKeys(publicKeys) {
289
- return this.#inner.prepareConversationKeys(publicKeys);
425
+ prepareConversationKeyChange(params) { return this.#inner.prepareConversationKeyChange(params); }
426
+ prepareGroupMembersChange(params) { return this.#inner.prepareGroupMembersChange(params); }
427
+ prepareGroupCreate(params) { return this.#inner.prepareGroupCreate(params); }
428
+
429
+ /**
430
+ * Release the WASM-side crypto engine: clears key material (`lock()`) and
431
+ * frees the underlying WASM object. The instance must not be used
432
+ * afterwards. When reusing the instance, `lock()` alone suffices for key
433
+ * hygiene.
434
+ */
435
+ free() {
436
+ this.#inner.lock();
437
+ this.#inner.free();
290
438
  }
291
- signAddMembers(...args) { return this.#inner.signAddMembers(...args); }
292
- signKeyChange(...args) { return this.#inner.signKeyChange(...args); }
293
439
  }
294
440
 
295
441
  /**
@@ -299,7 +445,9 @@ export class ChatWithJuicebox {
299
445
  * @param {string} options.juiceboxConfig - Juicebox configuration JSON from X API
300
446
  * @param {Function} options.getAuthToken - Async function to get auth token for a realm
301
447
  * Signature: (realmId: string) => Promise<string>
302
- * @returns {Promise<Chat>} Initialized Chat instance
448
+ * @param {number} [options.maxGuessCount] - Optional override for the PIN guess
449
+ * budget (see CreateChatOptions)
450
+ * @returns {Promise<ChatWithJuicebox>} Initialized chat instance
303
451
  *
304
452
  * @example
305
453
  * const chat = await createChat({
@@ -328,21 +476,29 @@ export async function createChat(options) {
328
476
  throw new Error('getAuthToken must be an async function');
329
477
  }
330
478
 
331
- // Load Juicebox WASM SDK
479
+ // Load Juicebox WASM SDK. Tests inject stub constructors through the
480
+ // internal `juiceboxModule` option so this flow runs without the
481
+ // juicebox-sdk WASM or network realms; it is not part of the public API.
332
482
  let JuiceboxClientCtor;
333
483
  let JuiceboxConfiguration;
334
- try {
335
- const juiceboxModule = await loadJuiceboxSdk();
336
- JuiceboxClientCtor = juiceboxModule.Client;
337
- JuiceboxConfiguration = juiceboxModule.Configuration;
338
- } catch (e) {
339
- throw new Error(
340
- 'Failed to load juicebox-sdk. ' +
341
- 'Build it from https://github.com/juicebox-systems/juicebox-sdk:\n' +
342
- ' cd juicebox-sdk/rust/sdk/bridge/wasm && wasm-pack build --target nodejs --out-dir pkg --out-name juicebox-sdk\n' +
343
- ' npm install /path/to/juicebox-sdk/rust/sdk/bridge/wasm/pkg\n' +
344
- 'Original error: ' + e.message
345
- );
484
+ if (options.juiceboxModule) {
485
+ JuiceboxClientCtor = options.juiceboxModule.Client;
486
+ JuiceboxConfiguration = options.juiceboxModule.Configuration;
487
+ } else {
488
+ try {
489
+ const juiceboxModule = await loadJuiceboxSdk();
490
+ JuiceboxClientCtor = juiceboxModule.Client;
491
+ JuiceboxConfiguration = juiceboxModule.Configuration;
492
+ } catch (e) {
493
+ throw new Error(
494
+ "Failed to load juicebox-sdk (an optional peer dependency). " +
495
+ "Install it with `npm install juicebox-sdk`, or build it from " +
496
+ "https://github.com/juicebox-systems/juicebox-sdk:\n" +
497
+ " cd juicebox-sdk/rust/sdk/bridge/wasm && wasm-pack build --target nodejs --out-dir pkg --out-name juicebox-sdk\n" +
498
+ " npm install /path/to/juicebox-sdk/rust/sdk/bridge/wasm/pkg\n" +
499
+ "Original error: " + e.message
500
+ );
501
+ }
346
502
  }
347
503
  if (!JuiceboxConfiguration) {
348
504
  throw new Error('juicebox-sdk Configuration export is missing');
@@ -350,8 +506,10 @@ export async function createChat(options) {
350
506
 
351
507
  // The juicebox-sdk WASM resolves `JuiceboxGetAuthToken` as a bare-name
352
508
  // global on every register/recover/delete call (not just construction).
353
- // We must keep it set for the lifetime of the client.
354
- globalThis.JuiceboxGetAuthToken = async (realmId) => {
509
+ // The global is process-wide, so each instance re-arms it with its own
510
+ // token getter immediately before every Juicebox operation; concurrent
511
+ // Juicebox operations across instances are not supported (last-armed wins).
512
+ const authTokenHook = async (realmId) => {
355
513
  const realmIdHex =
356
514
  typeof realmId === "string"
357
515
  ? realmId
@@ -360,15 +518,16 @@ export async function createChat(options) {
360
518
  .join("");
361
519
  return await getAuthToken(realmIdHex);
362
520
  };
521
+ const armAuthTokenHook = () => {
522
+ globalThis.JuiceboxGetAuthToken = authTokenHook;
523
+ };
524
+ armAuthTokenHook();
363
525
 
364
526
  const configValue = JSON.parse(juiceboxConfig);
365
- const config = new JuiceboxConfiguration(configValue);
527
+ const config = new JuiceboxConfiguration(juiceboxClientConfig(configValue));
366
528
  const juiceboxClient = new JuiceboxClientCtor(config, []);
367
529
 
368
- const numGuesses =
369
- Number.isFinite(maxGuessCount) && Number(maxGuessCount) > 0
370
- ? Math.floor(Number(maxGuessCount))
371
- : 5;
530
+ const numGuesses = resolveMaxGuessCount(configValue, maxGuessCount);
372
531
 
373
532
  // Load Rust WASM crypto engine
374
533
  const wasmModule = await initWasmModule();
@@ -380,6 +539,8 @@ export async function createChat(options) {
380
539
  numGuesses,
381
540
  JuiceboxConfiguration,
382
541
  JuiceboxClientCtor,
542
+ armAuthTokenHook,
543
+ maxGuessCount,
383
544
  );
384
545
  }
385
546
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xdevplatform/chat-xdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "X Chat SDK - End-to-end encryption for X Direct Messages",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -8,10 +8,14 @@
8
8
  "exports": {
9
9
  ".": "./index.js"
10
10
  },
11
+ "engines": {
12
+ "node": ">=18"
13
+ },
11
14
  "files": [
12
15
  "index.js",
13
16
  "index.d.ts",
14
- "pkg/**"
17
+ "pkg",
18
+ "LICENSE"
15
19
  ],
16
20
  "publishConfig": {
17
21
  "access": "public"