dignity.js 0.2.0 → 0.4.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/README.md CHANGED
@@ -11,12 +11,13 @@
11
11
  </p>
12
12
 
13
13
  <p align="center">
14
+ <a href="https://jose-compu.github.io/dignity.js/"><img src="https://img.shields.io/badge/docs-online-5B7FFF" alt="documentation"></a>
14
15
  <a href="https://www.npmjs.com/package/dignity.js"><img src="https://img.shields.io/npm/v/dignity.js?color=cb3837&label=npm" alt="npm version"></a>
15
16
  <a href="https://www.npmjs.com/package/dignity.js"><img src="https://img.shields.io/npm/dm/dignity.js?color=blue" alt="npm downloads"></a>
16
- <img src="https://img.shields.io/badge/tests-29%20passing-brightgreen" alt="tests passing">
17
- <img src="https://img.shields.io/badge/coverage-88%25-brightgreen" alt="coverage">
18
- <img src="https://img.shields.io/badge/license-MIT-black" alt="license">
19
- <img src="https://img.shields.io/badge/minified-51KB-purple" alt="bundle size">
17
+ <a href="https://github.com/jose-compu/dignity.js/actions/workflows/ci.yml"><img src="https://github.com/jose-compu/dignity.js/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
18
+ <img src="https://img.shields.io/badge/tests-150%2B%20passing-brightgreen" alt="tests passing">
19
+ <img src="https://img.shields.io/badge/coverage-99%25-brightgreen" alt="coverage">
20
+ <img src="https://img.shields.io/badge/license-Apache%202.0-black" alt="license">
20
21
  </p>
21
22
 
22
23
  REST-like P2P object API for decentralized JavaScript applications.
@@ -35,7 +36,10 @@ REST-like P2P object API for decentralized JavaScript applications.
35
36
  - default `powSteps: 22` (calibrated on this machine to about 1000ms)
36
37
  - automatic peer ban on invalid signature/PoW (`48h` default)
37
38
  - Team/subapp scoped broadcast passwords (`broadcastScope` + `broadcastPasswords`)
38
- - Browser-first distribution with minified build (`dist/dignity.min.js`)
39
+ - Optimistic concurrency helpers (`expectedVersion`, `updateWithRetry`, `conflict` events)
40
+ - Optional IndexedDB persistence for browser reload survival
41
+ - Optional React hooks via `dignity.js/react`
42
+ - Browser-first: published npm package includes IIFE, ESM, and CJS builds
39
43
 
40
44
  ## Install
41
45
 
@@ -146,15 +150,60 @@ bob.registerPeerPublicKey('alice', alice.getPublicKey());
146
150
  await alice.sendDirectMessage('bob', 'dm', { text: 'private payload' });
147
151
  ```
148
152
 
149
- ## Browser Builds
153
+ ## Optimistic Concurrency
150
154
 
151
- Generated artifacts:
155
+ Updates carry a monotonic `version`. Remote peers reject stale operations when `baseVersion` does not match.
152
156
 
153
- - `dist/dignity.min.js` (IIFE, global `DignityJS`)
154
- - `dist/dignity.esm.js` (ESM)
155
- - `dist/dignity.cjs.js` (CommonJS)
157
+ ```js
158
+ node.on('conflict', (event) => {
159
+ console.log('conflict', event.phase, event.expectedVersion, event.currentVersion);
160
+ });
161
+
162
+ await node.update('games', 'g1', { score: 10 }, { expectedVersion: 3 });
163
+
164
+ await node.updateWithRetry('games', 'g1', (current) => ({
165
+ score: current.data.score + 1
166
+ }));
167
+ ```
168
+
169
+ Use `expectedVersion` for fail-fast local writes. Use `updateWithRetry` for read-modify-write loops in fast multiplayer state.
170
+
171
+ ## IndexedDB Persistence
172
+
173
+ Persist replicated collections across page reloads:
156
174
 
157
- Example with CDN:
175
+ ```js
176
+ const { DignityP2P, IndexedDBPersistence } = require('dignity.js');
177
+
178
+ const node = new DignityP2P({ nodeId, networkAdapter, security });
179
+ const persistence = new IndexedDBPersistence({
180
+ dbName: 'my-app',
181
+ collections: ['games', 'matches']
182
+ });
183
+
184
+ await node.start();
185
+ await persistence.attach(node);
186
+ ```
187
+
188
+ ## React Hooks
189
+
190
+ Optional React integration (`react >= 18` peer dependency):
191
+
192
+ ```js
193
+ import { useDignity, useCollection, usePeers } from 'dignity.js/react';
194
+
195
+ function Room() {
196
+ const { node, status } = useDignity(config);
197
+ const games = useCollection(node, 'games');
198
+ const peers = usePeers(node, 'room:chess', { includeSelf: false });
199
+
200
+ return <pre>{JSON.stringify({ status, games, peers }, null, 2)}</pre>;
201
+ }
202
+ ```
203
+
204
+ ## Browser Usage
205
+
206
+ The published npm package includes pre-built bundles (IIFE, ESM, CJS) generated at publish time. The `dist/` folder is not checked into the repository.
158
207
 
159
208
  ```html
160
209
  <script src="https://unpkg.com/dignity.js/dist/dignity.min.js"></script>
@@ -163,6 +212,19 @@ Example with CDN:
163
212
  </script>
164
213
  ```
165
214
 
215
+ ## Security Model
216
+
217
+ `dignity.js` provides two encryption modes:
218
+
219
+ - **Direct mode** (`targetId` set): true end-to-end encryption using X25519 key exchange between sender and recipient. Only the intended recipient can decrypt.
220
+ - **Broadcast mode** (no `targetId`): symmetric encryption using a shared password. All peers that know the password can decrypt all broadcast traffic in that scope. This is a **group shared-secret cipher**, not end-to-end encryption.
221
+
222
+ Broadcast encryption uses PBKDF2-SHA256 (default 100,000 iterations) with a random salt per message to derive the symmetric key. This protects against offline brute-force of weak passwords. The iteration count is configurable via `kdfIterations`.
223
+
224
+ Messages from peers running older versions that used the legacy single-hash KDF are still accepted and decrypted automatically (backward compatible).
225
+
226
+ **Important:** if the broadcast password leaks, all past captured traffic for that scope is retroactively decryptable. For sensitive data, use direct mode with per-peer public keys.
227
+
166
228
  ## Signaling Servers
167
229
 
168
230
  Default signaling URLs include PeerJS-compatible public endpoints:
@@ -189,7 +251,8 @@ npm run test:pow-calibrate
189
251
 
190
252
  ## Docs and Examples
191
253
 
192
- - Docs site source: `docs/index.html`
254
+ - **Documentation:** [jose-compu.github.io/dignity.js](https://jose-compu.github.io/dignity.js/)
255
+ - Docs site source: `docs/index.html` (serve locally with `npm run docs:serve`)
193
256
  - API metadata: `docs/openapi-like.json`
194
257
  - Minimal demos:
195
258
  - `examples/decentralized-tictactoe.js`
@@ -198,11 +261,11 @@ npm run test:pow-calibrate
198
261
  ## Publish
199
262
 
200
263
  ```bash
201
- npm test
202
- npm run build
203
264
  npm publish --access public
204
265
  ```
205
266
 
267
+ The `prepublishOnly` script runs tests and build automatically.
268
+
206
269
  ## License
207
270
 
208
- Apache 2.0
271
+ Apache 2.0 — see [LICENSE](LICENSE).
@@ -2434,7 +2434,8 @@ var require_message_security_service = __commonJS({
2434
2434
  broadcastPasswords: {},
2435
2435
  resolveBroadcastPassword: null,
2436
2436
  powSteps: 22,
2437
- trustedPeerKeys: {}
2437
+ trustedPeerKeys: {},
2438
+ kdfIterations: 1e5
2438
2439
  };
2439
2440
  function stableStringify(value) {
2440
2441
  if (value === null || typeof value !== "object") {
@@ -2461,6 +2462,33 @@ var require_message_security_service = __commonJS({
2461
2462
  function utf8ToBytes(value) {
2462
2463
  return naclUtil.decodeUTF8(value);
2463
2464
  }
2465
+ async function deriveBroadcastKey(password, salt, iterations) {
2466
+ const subtle = globalThis.crypto && globalThis.crypto.subtle;
2467
+ if (subtle) {
2468
+ const keyMaterial = await subtle.importKey(
2469
+ "raw",
2470
+ utf8ToBytes(password),
2471
+ "PBKDF2",
2472
+ false,
2473
+ ["deriveBits"]
2474
+ );
2475
+ const bits = await subtle.deriveBits(
2476
+ { name: "PBKDF2", salt, iterations, hash: "SHA-256" },
2477
+ keyMaterial,
2478
+ 256
2479
+ );
2480
+ return new Uint8Array(bits);
2481
+ }
2482
+ try {
2483
+ const { pbkdf2Sync } = require("crypto");
2484
+ return new Uint8Array(pbkdf2Sync(password, Buffer.from(salt), iterations, 32, "sha256"));
2485
+ } catch (_ignored) {
2486
+ return hash32(concatBytes(utf8ToBytes(password), salt));
2487
+ }
2488
+ }
2489
+ function legacyBroadcastKey(password, salt) {
2490
+ return hash32(concatBytes(utf8ToBytes(password), salt));
2491
+ }
2464
2492
  function normalizePeerPublicKey(publicKey) {
2465
2493
  if (!publicKey || typeof publicKey !== "object") {
2466
2494
  throw new Error("Public key must be an object with signingPublicKey and encryptionPublicKey");
@@ -2621,7 +2649,7 @@ var require_message_security_service = __commonJS({
2621
2649
  if (envelope.security && envelope.security.signing && envelope.security.signing.enabled && this.options.signingEnabled) {
2622
2650
  this.verifySignature(envelope);
2623
2651
  }
2624
- const payload = this.decryptPayload(envelope);
2652
+ const payload = await this.decryptPayload(envelope);
2625
2653
  return {
2626
2654
  ignored: false,
2627
2655
  messageType: envelope.messageType,
@@ -2686,7 +2714,8 @@ var require_message_security_service = __commonJS({
2686
2714
  const nonce = nacl.randomBytes(nacl.secretbox.nonceLength);
2687
2715
  const salt = nacl.randomBytes(16);
2688
2716
  const password = this.resolveBroadcastPassword(scope);
2689
- const key = hash32(concatBytes(utf8ToBytes(password), salt));
2717
+ const iterations = this.options.kdfIterations || DEFAULT_SECURITY_OPTIONS2.kdfIterations;
2718
+ const key = await deriveBroadcastKey(password, salt, iterations);
2690
2719
  const encrypted = nacl.secretbox(plainText, nonce, key);
2691
2720
  return {
2692
2721
  payload: naclUtil.encodeBase64(encrypted),
@@ -2695,11 +2724,13 @@ var require_message_security_service = __commonJS({
2695
2724
  mode: "broadcast",
2696
2725
  scope,
2697
2726
  nonce: naclUtil.encodeBase64(nonce),
2698
- salt: naclUtil.encodeBase64(salt)
2727
+ salt: naclUtil.encodeBase64(salt),
2728
+ kdf: "pbkdf2",
2729
+ kdfIterations: iterations
2699
2730
  }
2700
2731
  };
2701
2732
  }
2702
- decryptPayload(envelope) {
2733
+ async decryptPayload(envelope) {
2703
2734
  const encryption = envelope.security ? envelope.security.encryption : null;
2704
2735
  if (!encryption || !encryption.enabled || !this.options.encryptionEnabled) {
2705
2736
  return envelope.payload;
@@ -2710,7 +2741,13 @@ var require_message_security_service = __commonJS({
2710
2741
  const password = this.resolveBroadcastPassword(scope);
2711
2742
  const salt = naclUtil.decodeBase64(encryption.salt);
2712
2743
  const nonce = naclUtil.decodeBase64(encryption.nonce);
2713
- const key = hash32(concatBytes(utf8ToBytes(password), salt));
2744
+ let key;
2745
+ if (encryption.kdf === "pbkdf2") {
2746
+ const iterations = encryption.kdfIterations || DEFAULT_SECURITY_OPTIONS2.kdfIterations;
2747
+ key = await deriveBroadcastKey(password, salt, iterations);
2748
+ } else {
2749
+ key = legacyBroadcastKey(password, salt);
2750
+ }
2714
2751
  const decrypted = nacl.secretbox.open(encryptedBuffer, nonce, key);
2715
2752
  if (!decrypted) {
2716
2753
  throw new Error("Unable to decrypt broadcast payload");
@@ -2804,6 +2841,8 @@ var require_message_security_service = __commonJS({
2804
2841
  module2.exports = {
2805
2842
  MessageSecurityService: MessageSecurityService2,
2806
2843
  stableStringify,
2844
+ deriveBroadcastKey,
2845
+ legacyBroadcastKey,
2807
2846
  DEFAULT_SECURITY_OPTIONS: DEFAULT_SECURITY_OPTIONS2
2808
2847
  };
2809
2848
  }
@@ -2941,6 +2980,21 @@ var require_dignity_p2p = __commonJS({
2941
2980
  if (existing.ownerId !== this.nodeId) {
2942
2981
  throw new Error(`Only owner ${existing.ownerId} can update object ${id}`);
2943
2982
  }
2983
+ if (typeof options.expectedVersion === "number" && existing.version !== options.expectedVersion) {
2984
+ this.emitConflict({
2985
+ kind: "update",
2986
+ collection: collectionName,
2987
+ id,
2988
+ expectedVersion: options.expectedVersion,
2989
+ currentVersion: existing.version,
2990
+ phase: "local"
2991
+ });
2992
+ const error = new Error(
2993
+ `Version conflict on ${collectionName}/${id}: expected ${options.expectedVersion}, current ${existing.version}`
2994
+ );
2995
+ error.code = "VERSION_CONFLICT";
2996
+ throw error;
2997
+ }
2944
2998
  const operation = {
2945
2999
  opId: this.idGenerator(),
2946
3000
  kind: "update",
@@ -2961,6 +3015,27 @@ var require_dignity_p2p = __commonJS({
2961
3015
  });
2962
3016
  return this.read(collectionName, id);
2963
3017
  }
3018
+ async updateWithRetry(collectionName, id, patchFn, options = {}) {
3019
+ const maxAttempts = typeof options.maxAttempts === "number" ? options.maxAttempts : 5;
3020
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
3021
+ const current = this.read(collectionName, id);
3022
+ if (!current) {
3023
+ throw new Error(`Object ${id} does not exist in ${collectionName}`);
3024
+ }
3025
+ const patch = await patchFn(current);
3026
+ try {
3027
+ return await this.update(collectionName, id, patch, {
3028
+ ...options,
3029
+ expectedVersion: current.version
3030
+ });
3031
+ } catch (error) {
3032
+ if (error.code !== "VERSION_CONFLICT" || attempt === maxAttempts - 1) {
3033
+ throw error;
3034
+ }
3035
+ }
3036
+ }
3037
+ throw new Error(`Unable to update ${collectionName}/${id} after ${maxAttempts} attempts`);
3038
+ }
2964
3039
  async remove(collectionName, id, options = {}) {
2965
3040
  const existing = this.getCollection(collectionName).get(id);
2966
3041
  if (!existing || existing.deletedAt) {
@@ -3238,6 +3313,29 @@ var require_dignity_p2p = __commonJS({
3238
3313
  isPeerBanned(peerId) {
3239
3314
  return this.getBanInfo(peerId) !== null;
3240
3315
  }
3316
+ emitConflict(details) {
3317
+ this.emit("conflict", details);
3318
+ }
3319
+ restoreRecord(collectionName, record) {
3320
+ if (!record || !record.id) {
3321
+ return false;
3322
+ }
3323
+ const collection = this.getCollection(collectionName);
3324
+ const current = collection.get(record.id);
3325
+ if (current && current.version >= record.version) {
3326
+ return false;
3327
+ }
3328
+ collection.set(record.id, {
3329
+ id: record.id,
3330
+ ownerId: record.ownerId,
3331
+ data: { ...record.data || {} },
3332
+ createdAt: record.createdAt,
3333
+ updatedAt: record.updatedAt,
3334
+ deletedAt: record.deletedAt || null,
3335
+ version: record.version
3336
+ });
3337
+ return true;
3338
+ }
3241
3339
  applyOperation(operation) {
3242
3340
  if (!operation || !operation.opId || this.appliedOperations.has(operation.opId)) {
3243
3341
  return false;
@@ -3268,6 +3366,15 @@ var require_dignity_p2p = __commonJS({
3268
3366
  return false;
3269
3367
  }
3270
3368
  if (typeof operation.baseVersion === "number" && operation.baseVersion !== current.version) {
3369
+ this.emitConflict({
3370
+ kind: operation.kind,
3371
+ collection: operation.collectionName,
3372
+ id: operation.id,
3373
+ expectedVersion: operation.baseVersion,
3374
+ currentVersion: current.version,
3375
+ phase: "remote",
3376
+ operation
3377
+ });
3271
3378
  return false;
3272
3379
  }
3273
3380
  if (operation.kind === "update") {
@@ -10345,6 +10452,163 @@ var require_in_memory_network = __commonJS({
10345
10452
  }
10346
10453
  });
10347
10454
 
10455
+ // src/persistence/indexeddb-persistence.js
10456
+ var require_indexeddb_persistence = __commonJS({
10457
+ "src/persistence/indexeddb-persistence.js"(exports2, module2) {
10458
+ var IndexedDBPersistence2 = class {
10459
+ constructor({
10460
+ dbName = "dignity",
10461
+ storeName = "records",
10462
+ collections = null,
10463
+ indexedDB = typeof globalThis !== "undefined" ? globalThis.indexedDB : null
10464
+ } = {}) {
10465
+ this.dbName = dbName;
10466
+ this.storeName = storeName;
10467
+ this.collections = collections;
10468
+ this.indexedDB = indexedDB;
10469
+ this.node = null;
10470
+ this.changeHandler = null;
10471
+ }
10472
+ recordKey(collection, id) {
10473
+ return `${collection}:${id}`;
10474
+ }
10475
+ shouldPersist(collection) {
10476
+ if (!this.collections) {
10477
+ return true;
10478
+ }
10479
+ return this.collections.includes(collection);
10480
+ }
10481
+ openDb() {
10482
+ if (!this.indexedDB) {
10483
+ return Promise.reject(new Error("IndexedDB is not available"));
10484
+ }
10485
+ return new Promise((resolve, reject) => {
10486
+ const request = this.indexedDB.open(this.dbName, 1);
10487
+ request.onupgradeneeded = () => {
10488
+ const db = request.result;
10489
+ if (!db.objectStoreNames.contains(this.storeName)) {
10490
+ db.createObjectStore(this.storeName, { keyPath: "key" });
10491
+ }
10492
+ };
10493
+ request.onsuccess = () => resolve(request.result);
10494
+ request.onerror = () => reject(request.error || new Error("Unable to open IndexedDB"));
10495
+ });
10496
+ }
10497
+ runTransaction(mode, handler) {
10498
+ return this.openDb().then((db) => new Promise((resolve, reject) => {
10499
+ const transaction = db.transaction(this.storeName, mode);
10500
+ const store = transaction.objectStore(this.storeName);
10501
+ Promise.resolve(handler(store)).then(resolve).catch(reject);
10502
+ transaction.oncomplete = () => db.close();
10503
+ transaction.onerror = () => reject(transaction.error || new Error("IndexedDB transaction failed"));
10504
+ transaction.onabort = () => reject(transaction.error || new Error("IndexedDB transaction aborted"));
10505
+ }));
10506
+ }
10507
+ serializeRecord(collection, id) {
10508
+ const record = this.node.getCollection(collection).get(id);
10509
+ if (!record) {
10510
+ return null;
10511
+ }
10512
+ return {
10513
+ key: this.recordKey(collection, id),
10514
+ collection,
10515
+ id,
10516
+ ownerId: record.ownerId,
10517
+ data: { ...record.data },
10518
+ createdAt: record.createdAt,
10519
+ updatedAt: record.updatedAt,
10520
+ deletedAt: record.deletedAt,
10521
+ version: record.version
10522
+ };
10523
+ }
10524
+ async persistRecord(collection, id) {
10525
+ if (!this.node || !this.shouldPersist(collection)) {
10526
+ return;
10527
+ }
10528
+ const serialized = this.serializeRecord(collection, id);
10529
+ const key = this.recordKey(collection, id);
10530
+ if (!serialized) {
10531
+ await this.runTransaction("readwrite", (store) => new Promise((resolve, reject) => {
10532
+ const request = store.delete(key);
10533
+ request.onsuccess = () => resolve();
10534
+ request.onerror = () => reject(request.error);
10535
+ }));
10536
+ return;
10537
+ }
10538
+ await this.runTransaction("readwrite", (store) => new Promise((resolve, reject) => {
10539
+ const request = store.put(serialized);
10540
+ request.onsuccess = () => resolve();
10541
+ request.onerror = () => reject(request.error);
10542
+ }));
10543
+ }
10544
+ persistChange(event) {
10545
+ if (!event || !event.collection || !event.id) {
10546
+ return;
10547
+ }
10548
+ this.persistRecord(event.collection, event.id).catch((error) => {
10549
+ this.node.emit("warning", {
10550
+ type: "persistence-failed",
10551
+ collection: event.collection,
10552
+ id: event.id,
10553
+ error
10554
+ });
10555
+ });
10556
+ }
10557
+ async loadAllRecords() {
10558
+ return this.runTransaction("readonly", (store) => new Promise((resolve, reject) => {
10559
+ const request = store.getAll();
10560
+ request.onsuccess = () => resolve(request.result || []);
10561
+ request.onerror = () => reject(request.error);
10562
+ }));
10563
+ }
10564
+ async hydrate() {
10565
+ if (!this.node) {
10566
+ throw new Error("IndexedDBPersistence requires an attached node before hydrate");
10567
+ }
10568
+ const storedRecords = await this.loadAllRecords();
10569
+ for (const stored of storedRecords) {
10570
+ if (!this.shouldPersist(stored.collection)) {
10571
+ continue;
10572
+ }
10573
+ this.node.restoreRecord(stored.collection, {
10574
+ id: stored.id,
10575
+ ownerId: stored.ownerId,
10576
+ data: stored.data,
10577
+ createdAt: stored.createdAt,
10578
+ updatedAt: stored.updatedAt,
10579
+ deletedAt: stored.deletedAt,
10580
+ version: stored.version
10581
+ });
10582
+ }
10583
+ }
10584
+ async attach(node) {
10585
+ if (!node) {
10586
+ throw new Error("IndexedDBPersistence.attach requires a DignityP2P node");
10587
+ }
10588
+ this.node = node;
10589
+ await this.hydrate();
10590
+ this.changeHandler = (event) => this.persistChange(event);
10591
+ node.on("change", this.changeHandler);
10592
+ }
10593
+ async detach() {
10594
+ if (this.node && this.changeHandler) {
10595
+ this.node.off("change", this.changeHandler);
10596
+ }
10597
+ this.changeHandler = null;
10598
+ this.node = null;
10599
+ }
10600
+ async clear() {
10601
+ await this.runTransaction("readwrite", (store) => new Promise((resolve, reject) => {
10602
+ const request = store.clear();
10603
+ request.onsuccess = () => resolve();
10604
+ request.onerror = () => reject(request.error);
10605
+ }));
10606
+ }
10607
+ };
10608
+ module2.exports = IndexedDBPersistence2;
10609
+ }
10610
+ });
10611
+
10348
10612
  // src/index.js
10349
10613
  var DignityP2P = require_dignity_p2p();
10350
10614
  var createDefaultSignalingPool = require_create_default_signaling_pool();
@@ -10355,6 +10619,7 @@ var {
10355
10619
  InMemoryNetworkHub,
10356
10620
  InMemoryNetworkAdapter
10357
10621
  } = require_in_memory_network();
10622
+ var IndexedDBPersistence = require_indexeddb_persistence();
10358
10623
  var {
10359
10624
  DEFAULT_CLOUDFLARE_SIGNALING_URLS,
10360
10625
  DEFAULT_SIGNALING_FALLBACK_URLS
@@ -10373,6 +10638,7 @@ module.exports = {
10373
10638
  PeerJSSignalingProvider,
10374
10639
  InMemoryNetworkHub,
10375
10640
  InMemoryNetworkAdapter,
10641
+ IndexedDBPersistence,
10376
10642
  DEFAULT_CLOUDFLARE_SIGNALING_URLS,
10377
10643
  DEFAULT_SIGNALING_FALLBACK_URLS,
10378
10644
  VDF,