@xdevplatform/chat-xdk 0.1.0 → 0.2.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.
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,74 @@ 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` shape, 5
163
+ * otherwise. An explicit `maxGuessCount` option overrides the config under
164
+ * the same integer rule. Exported for tests; `createChat` calls it
165
+ * internally.
166
+ *
167
+ * @param {Object} configValue - Parsed Juicebox config JSON from the X API
168
+ * @param {number} [maxGuessCount] - Explicit override from createChat options
169
+ * @returns {number} The number of PIN guesses to register with
170
+ */
171
+ export function resolveMaxGuessCount(configValue, maxGuessCount) {
172
+ if (Number.isSafeInteger(maxGuessCount) && maxGuessCount >= 0) {
173
+ return maxGuessCount;
174
+ }
175
+ const fromConfig = configValue?.max_guess_count;
176
+ if (Number.isSafeInteger(fromConfig) && fromConfig >= 0) {
177
+ return fromConfig;
178
+ }
179
+ return typeof configValue?.sdk_config === "string" ? 20 : 5;
180
+ }
181
+
182
+ /**
183
+ * Derive the value to hand the Juicebox `Configuration` constructor from a
184
+ * parsed X API config. The `sdk_config` wrapper shape is unwrapped to its
185
+ * embedded SDK config JSON string (which the Juicebox constructor accepts
186
+ * directly); a raw realms config passes through unchanged; the `token_map`
187
+ * shape (an array of `{ key, value: { address } }` entries) is converted to
188
+ * a realms config with majority recover threshold, the same derivation the
189
+ * native bindings apply. A non-array `token_map` is rejected here, with the
190
+ * same message the core parser uses, rather than handed to the Juicebox
191
+ * constructor to fail obscurely. Realm auth tokens are not taken from the
192
+ * config in this wrapper — they come from the `getAuthToken` callback.
193
+ */
194
+ export function juiceboxClientConfig(configValue) {
195
+ if (typeof configValue?.sdk_config === "string") {
196
+ return configValue.sdk_config;
197
+ }
198
+ if (
199
+ configValue?.token_map !== undefined &&
200
+ configValue?.realms === undefined &&
201
+ !Array.isArray(configValue.token_map)
202
+ ) {
203
+ throw new Error("Missing token_map or sdk_config");
204
+ }
205
+ if (Array.isArray(configValue?.token_map) && configValue?.realms === undefined) {
206
+ const realms = configValue.token_map.map((entry) => {
207
+ const id = entry?.key;
208
+ const address = entry?.value?.address;
209
+ if (typeof id !== "string" || typeof address !== "string") {
210
+ throw new Error(
211
+ "Invalid token_map entry: expected { key, value: { address } }",
212
+ );
213
+ }
214
+ return { id, address };
215
+ });
216
+ return {
217
+ realms,
218
+ register_threshold: realms.length,
219
+ recover_threshold: Math.floor(realms.length / 2) + 1,
220
+ pin_hashing_mode: "Standard2019",
221
+ };
222
+ }
223
+ return configValue;
224
+ }
225
+
144
226
  // ChatWithJuicebox — JS wrapper that owns both a WasmChat and a Juicebox client
145
227
 
146
228
  /**
@@ -153,18 +235,36 @@ export class ChatWithJuicebox {
153
235
  #inner;
154
236
  #juiceboxClient;
155
237
  #numGuesses;
238
+ // The raw `maxGuessCount` createChat option (undefined when not provided),
239
+ // kept so updateConfig re-resolves the budget under the same override
240
+ // semantics as construction: an explicit option keeps winning.
241
+ #maxGuessCountOverride;
156
242
  #JuiceboxConfiguration;
157
243
  #JuiceboxClient;
244
+ // Points the process-global `JuiceboxGetAuthToken` at this instance's token
245
+ // getter. Called before every Juicebox network operation so sequential use
246
+ // of multiple instances presents the right tokens (last-armed wins).
247
+ #armAuthTokenHook;
158
248
 
159
- constructor(wasmChat, juiceboxClient, numGuesses, JuiceboxConfiguration, JuiceboxClientCtor) {
249
+ constructor(
250
+ wasmChat,
251
+ juiceboxClient,
252
+ numGuesses,
253
+ JuiceboxConfiguration,
254
+ JuiceboxClientCtor,
255
+ armAuthTokenHook,
256
+ maxGuessCountOverride,
257
+ ) {
160
258
  this.#inner = wasmChat;
161
259
  this.#juiceboxClient = juiceboxClient;
162
260
  this.#numGuesses = numGuesses;
261
+ this.#maxGuessCountOverride = maxGuessCountOverride;
163
262
  this.#JuiceboxConfiguration = JuiceboxConfiguration;
164
263
  this.#JuiceboxClient = JuiceboxClientCtor;
264
+ this.#armAuthTokenHook = armAuthTokenHook ?? (() => {});
165
265
  }
166
266
 
167
- // Juicebox lifecycle (JS-only no globalThis, no singletons)
267
+ // Juicebox lifecycle (handled in this JS layer)
168
268
 
169
269
  /**
170
270
  * Register existing keys with Juicebox. Call generateKeypairs() first.
@@ -178,6 +278,7 @@ export class ChatWithJuicebox {
178
278
  validatePinStrength(pinBytes);
179
279
  const secretBytes = this.#inner.exportKeys();
180
280
  try {
281
+ this.#armAuthTokenHook();
181
282
  await this.#juiceboxClient.register(
182
283
  pinBytes,
183
284
  secretBytes,
@@ -202,6 +303,7 @@ export class ChatWithJuicebox {
202
303
  const ownsPin = typeof pin === 'string';
203
304
  let secretBytes;
204
305
  try {
306
+ this.#armAuthTokenHook();
205
307
  secretBytes = await this.#juiceboxClient.recover(pinBytes, new Uint8Array(0));
206
308
  } catch (err) {
207
309
  throw new Error(`Juicebox recovery failed: ${formatJuiceboxError(err, RECOVER_REASON_BY_VALUE)}`);
@@ -217,6 +319,7 @@ export class ChatWithJuicebox {
217
319
 
218
320
  /** Delete keys from Juicebox. Warning: Irreversible. */
219
321
  async delete() {
322
+ this.#armAuthTokenHook();
220
323
  await this.#juiceboxClient.delete();
221
324
  this.#inner.lock();
222
325
  }
@@ -229,6 +332,7 @@ export class ChatWithJuicebox {
229
332
  await this.unlock(oldPin);
230
333
  const secretBytes = this.#inner.exportKeys();
231
334
  try {
335
+ this.#armAuthTokenHook();
232
336
  await this.#juiceboxClient.register(
233
337
  newPinBytes,
234
338
  secretBytes,
@@ -243,11 +347,16 @@ export class ChatWithJuicebox {
243
347
  }
244
348
  }
245
349
 
246
- /** Update Juicebox config (e.g. to refresh auth tokens). Re-creates the client. */
350
+ /**
351
+ * Update Juicebox config (e.g. to refresh auth tokens). Re-creates the
352
+ * client and re-resolves the PIN guess budget from the new config; an
353
+ * explicit createChat `maxGuessCount` override keeps winning.
354
+ */
247
355
  updateConfig(juiceboxConfig) {
248
356
  const configValue = JSON.parse(juiceboxConfig);
249
- const config = new this.#JuiceboxConfiguration(configValue);
357
+ const config = new this.#JuiceboxConfiguration(juiceboxClientConfig(configValue));
250
358
  this.#juiceboxClient = new this.#JuiceboxClient(config, []);
359
+ this.#numGuesses = resolveMaxGuessCount(configValue, this.#maxGuessCountOverride);
251
360
  }
252
361
 
253
362
  // Forwarded crypto methods — delegate to inner WasmChat
@@ -261,35 +370,42 @@ export class ChatWithJuicebox {
261
370
  hasIdentityKey() { return this.#inner.hasIdentityKey(); }
262
371
  lock() { return this.#inner.lock(); }
263
372
  decryptConversationKey(b64) { return this.#inner.decryptConversationKey(b64); }
264
- generateConversationKey() { return this.#inner.generateConversationKey(); }
265
373
  extractConversationKeys(events) { return this.#inner.extractConversationKeys(events); }
266
374
  decryptEvent(eventB64, conversationKeys, signingKeys) {
267
- return this.#inner.decryptEvent(eventB64, conversationKeys, signingKeys ?? []);
375
+ return this.#inner.decryptEvent(eventB64, conversationKeys, signingKeys);
268
376
  }
269
377
  decryptEvents(events, signingKeys) {
270
- return this.#inner.decryptEvents(events, signingKeys ?? []);
378
+ return this.#inner.decryptEvents(events, signingKeys);
271
379
  }
272
380
  sign(data) { return this.#inner.sign(data); }
273
381
  verify(publicKeyB64, signature, data) { return this.#inner.verify(publicKeyB64, signature, data); }
274
382
  verifyKeyBinding(identityPublicKeyB64, signingPublicKeyB64, identityPublicKeySignatureB64) {
275
383
  return this.#inner.verifyKeyBinding(identityPublicKeyB64, signingPublicKeyB64, identityPublicKeySignatureB64);
276
384
  }
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); }
385
+ encryptMessage(params) { return this.#inner.encryptMessage(params); }
386
+ encryptReply(params) { return this.#inner.encryptReply(params); }
387
+ encryptAddReaction(params) { return this.#inner.encryptAddReaction(params); }
388
+ encryptRemoveReaction(params) { return this.#inner.encryptRemoveReaction(params); }
281
389
  encryptStream(plaintext, key) { return this.#inner.encryptStream(plaintext, key); }
282
390
  decryptStream(encrypted, key) { return this.#inner.decryptStream(encrypted, key); }
391
+ streamEncryptor(key) { return this.#inner.streamEncryptor(key); }
392
+ streamDecryptor(key) { return this.#inner.streamDecryptor(key); }
283
393
  encrypt(plaintext, key) { return this.#inner.encrypt(plaintext, key); }
284
394
  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);
395
+ prepareConversationKeyChange(params) { return this.#inner.prepareConversationKeyChange(params); }
396
+ prepareGroupMembersChange(params) { return this.#inner.prepareGroupMembersChange(params); }
397
+ prepareGroupCreate(params) { return this.#inner.prepareGroupCreate(params); }
398
+
399
+ /**
400
+ * Release the WASM-side crypto engine: clears key material (`lock()`) and
401
+ * frees the underlying WASM object. The instance must not be used
402
+ * afterwards. When reusing the instance, `lock()` alone suffices for key
403
+ * hygiene.
404
+ */
405
+ free() {
406
+ this.#inner.lock();
407
+ this.#inner.free();
290
408
  }
291
- signAddMembers(...args) { return this.#inner.signAddMembers(...args); }
292
- signKeyChange(...args) { return this.#inner.signKeyChange(...args); }
293
409
  }
294
410
 
295
411
  /**
@@ -299,7 +415,9 @@ export class ChatWithJuicebox {
299
415
  * @param {string} options.juiceboxConfig - Juicebox configuration JSON from X API
300
416
  * @param {Function} options.getAuthToken - Async function to get auth token for a realm
301
417
  * Signature: (realmId: string) => Promise<string>
302
- * @returns {Promise<Chat>} Initialized Chat instance
418
+ * @param {number} [options.maxGuessCount] - Optional override for the PIN guess
419
+ * budget (see CreateChatOptions)
420
+ * @returns {Promise<ChatWithJuicebox>} Initialized chat instance
303
421
  *
304
422
  * @example
305
423
  * const chat = await createChat({
@@ -328,21 +446,29 @@ export async function createChat(options) {
328
446
  throw new Error('getAuthToken must be an async function');
329
447
  }
330
448
 
331
- // Load Juicebox WASM SDK
449
+ // Load Juicebox WASM SDK. Tests inject stub constructors through the
450
+ // internal `juiceboxModule` option so this flow runs without the
451
+ // juicebox-sdk WASM or network realms; it is not part of the public API.
332
452
  let JuiceboxClientCtor;
333
453
  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
- );
454
+ if (options.juiceboxModule) {
455
+ JuiceboxClientCtor = options.juiceboxModule.Client;
456
+ JuiceboxConfiguration = options.juiceboxModule.Configuration;
457
+ } else {
458
+ try {
459
+ const juiceboxModule = await loadJuiceboxSdk();
460
+ JuiceboxClientCtor = juiceboxModule.Client;
461
+ JuiceboxConfiguration = juiceboxModule.Configuration;
462
+ } catch (e) {
463
+ throw new Error(
464
+ "Failed to load juicebox-sdk (an optional peer dependency). " +
465
+ "Install it with `npm install juicebox-sdk`, or build it from " +
466
+ "https://github.com/juicebox-systems/juicebox-sdk:\n" +
467
+ " cd juicebox-sdk/rust/sdk/bridge/wasm && wasm-pack build --target nodejs --out-dir pkg --out-name juicebox-sdk\n" +
468
+ " npm install /path/to/juicebox-sdk/rust/sdk/bridge/wasm/pkg\n" +
469
+ "Original error: " + e.message
470
+ );
471
+ }
346
472
  }
347
473
  if (!JuiceboxConfiguration) {
348
474
  throw new Error('juicebox-sdk Configuration export is missing');
@@ -350,8 +476,10 @@ export async function createChat(options) {
350
476
 
351
477
  // The juicebox-sdk WASM resolves `JuiceboxGetAuthToken` as a bare-name
352
478
  // 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) => {
479
+ // The global is process-wide, so each instance re-arms it with its own
480
+ // token getter immediately before every Juicebox operation; concurrent
481
+ // Juicebox operations across instances are not supported (last-armed wins).
482
+ const authTokenHook = async (realmId) => {
355
483
  const realmIdHex =
356
484
  typeof realmId === "string"
357
485
  ? realmId
@@ -360,15 +488,16 @@ export async function createChat(options) {
360
488
  .join("");
361
489
  return await getAuthToken(realmIdHex);
362
490
  };
491
+ const armAuthTokenHook = () => {
492
+ globalThis.JuiceboxGetAuthToken = authTokenHook;
493
+ };
494
+ armAuthTokenHook();
363
495
 
364
496
  const configValue = JSON.parse(juiceboxConfig);
365
- const config = new JuiceboxConfiguration(configValue);
497
+ const config = new JuiceboxConfiguration(juiceboxClientConfig(configValue));
366
498
  const juiceboxClient = new JuiceboxClientCtor(config, []);
367
499
 
368
- const numGuesses =
369
- Number.isFinite(maxGuessCount) && Number(maxGuessCount) > 0
370
- ? Math.floor(Number(maxGuessCount))
371
- : 5;
500
+ const numGuesses = resolveMaxGuessCount(configValue, maxGuessCount);
372
501
 
373
502
  // Load Rust WASM crypto engine
374
503
  const wasmModule = await initWasmModule();
@@ -380,6 +509,8 @@ export async function createChat(options) {
380
509
  numGuesses,
381
510
  JuiceboxConfiguration,
382
511
  JuiceboxClientCtor,
512
+ armAuthTokenHook,
513
+ maxGuessCount,
383
514
  );
384
515
  }
385
516
 
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.0",
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"