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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dignity.js",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "description": "REST-like object API over peer-to-peer replication",
5
5
  "resources": {
6
6
  "collections/{collection}/{id}": {
@@ -12,8 +12,16 @@
12
12
  "method": "read(collection, id)"
13
13
  },
14
14
  "update": {
15
- "method": "update(collection, id, patch)",
16
- "authorization": "owner-only"
15
+ "method": "update(collection, id, patch, options)",
16
+ "authorization": "owner-only",
17
+ "options": {
18
+ "expectedVersion": "optional number; throws VERSION_CONFLICT when mismatched",
19
+ "broadcastScope": "optional scoped broadcast password namespace"
20
+ }
21
+ },
22
+ "updateWithRetry": {
23
+ "method": "updateWithRetry(collection, id, patchFn, options)",
24
+ "description": "read-modify-write helper with automatic retry on version conflicts"
17
25
  },
18
26
  "delete": {
19
27
  "method": "remove(collection, id)",
@@ -26,6 +34,31 @@
26
34
  }
27
35
  }
28
36
  },
37
+ "events": {
38
+ "change": "object create/update/delete applied",
39
+ "conflict": "local or remote version mismatch",
40
+ "peerdiscovered": "peer joined discovery scope",
41
+ "peerleft": "peer left or timed out",
42
+ "message": "custom decrypted message received"
43
+ },
44
+ "persistence": {
45
+ "IndexedDBPersistence": {
46
+ "method": "attach(node)",
47
+ "options": [
48
+ "dbName",
49
+ "storeName",
50
+ "collections"
51
+ ]
52
+ }
53
+ },
54
+ "react": {
55
+ "entrypoint": "dignity.js/react",
56
+ "hooks": [
57
+ "useDignity",
58
+ "useCollection",
59
+ "usePeers"
60
+ ]
61
+ },
29
62
  "signaling": {
30
63
  "defaults": {
31
64
  "cloudflare": "enabled by default",
@@ -45,13 +78,18 @@
45
78
  "signingEnabled": true,
46
79
  "encryptionEnabled": true,
47
80
  "powEnabled": true,
48
- "powTargetMs": 1000
81
+ "powSteps": 22,
82
+ "powTargetMs": 1000,
83
+ "kdfIterations": 100000,
84
+ "banDurationMs": 172800000
49
85
  },
50
86
  "broadcast": {
51
- "encryption": "aes-256-gcm with appPassword-derived key"
87
+ "encryption": "AES secretbox with PBKDF2-SHA256 derived key",
88
+ "scopePasswords": "broadcastPasswords map keyed by broadcastScope",
89
+ "legacyKdf": "single-hash fallback accepted for older peers"
52
90
  },
53
91
  "direct": {
54
- "encryption": "aes-256-gcm payload + rsa-oaep wrapped session key"
92
+ "encryption": "NaCl box (X25519 + XSalsa20-Poly1305) to recipient public key"
55
93
  },
56
94
  "pow": {
57
95
  "algorithm": "Sloth VDF",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dignity.js",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "P2P object API for decentralized JavaScript applications",
5
5
  "main": "dist/dignity.cjs.js",
6
6
  "module": "dist/dignity.esm.js",
@@ -12,6 +12,11 @@
12
12
  "require": "./dist/dignity.cjs.js",
13
13
  "import": "./dist/dignity.esm.js",
14
14
  "default": "./dist/dignity.esm.js"
15
+ },
16
+ "./react": {
17
+ "require": "./src/react/index.js",
18
+ "import": "./src/react/index.js",
19
+ "default": "./src/react/index.js"
15
20
  }
16
21
  },
17
22
  "files": [
@@ -27,7 +32,7 @@
27
32
  "test:pow-calibrate": "jest tests/unit/sloth-vdf-timing.test.js --runInBand",
28
33
  "build": "node scripts/build.js",
29
34
  "docs:serve": "npx http-server docs -p 4173 -o",
30
- "docs:check": "node -e \"require('fs').accessSync('docs/index.html')\"",
35
+ "docs:check": "node -e \"const fs=require('fs');['docs/index.html','docs/assets/highlight/highlight.min.js','docs/assets/highlight/github.min.css','docs/assets/highlight/github-dark.min.css'].forEach(p=>fs.accessSync(p));\"",
31
36
  "example:tictactoe": "node examples/decentralized-tictactoe.js",
32
37
  "example:chess": "node examples/decentralized-chess-lite.js",
33
38
  "prepublishOnly": "npm test && npm run build"
@@ -39,10 +44,23 @@
39
44
  "rest",
40
45
  "objects"
41
46
  ],
42
- "license": "MIT",
47
+ "license": "Apache 2.0",
48
+ "peerDependencies": {
49
+ "react": ">=18"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "react": {
53
+ "optional": true
54
+ }
55
+ },
43
56
  "devDependencies": {
57
+ "@testing-library/react": "^16.3.0",
44
58
  "esbuild": "^0.28.0",
45
- "jest": "^29.7.0"
59
+ "fake-indexeddb": "^6.0.0",
60
+ "jest": "^29.7.0",
61
+ "jest-environment-jsdom": "^29.7.0",
62
+ "react": "^19.1.0",
63
+ "react-dom": "^19.1.0"
46
64
  },
47
65
  "dependencies": {
48
66
  "peerjs": "^1.5.5",
@@ -178,6 +178,23 @@ class DignityP2P extends EventEmitter {
178
178
  throw new Error(`Only owner ${existing.ownerId} can update object ${id}`);
179
179
  }
180
180
 
181
+ if (typeof options.expectedVersion === 'number' && existing.version !== options.expectedVersion) {
182
+ this.emitConflict({
183
+ kind: 'update',
184
+ collection: collectionName,
185
+ id,
186
+ expectedVersion: options.expectedVersion,
187
+ currentVersion: existing.version,
188
+ phase: 'local'
189
+ });
190
+
191
+ const error = new Error(
192
+ `Version conflict on ${collectionName}/${id}: expected ${options.expectedVersion}, current ${existing.version}`
193
+ );
194
+ error.code = 'VERSION_CONFLICT';
195
+ throw error;
196
+ }
197
+
181
198
  const operation = {
182
199
  opId: this.idGenerator(),
183
200
  kind: 'update',
@@ -201,6 +218,31 @@ class DignityP2P extends EventEmitter {
201
218
  return this.read(collectionName, id);
202
219
  }
203
220
 
221
+ async updateWithRetry(collectionName, id, patchFn, options = {}) {
222
+ const maxAttempts = typeof options.maxAttempts === 'number' ? options.maxAttempts : 5;
223
+
224
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
225
+ const current = this.read(collectionName, id);
226
+ if (!current) {
227
+ throw new Error(`Object ${id} does not exist in ${collectionName}`);
228
+ }
229
+
230
+ const patch = await patchFn(current);
231
+ try {
232
+ return await this.update(collectionName, id, patch, {
233
+ ...options,
234
+ expectedVersion: current.version
235
+ });
236
+ } catch (error) {
237
+ if (error.code !== 'VERSION_CONFLICT' || attempt === maxAttempts - 1) {
238
+ throw error;
239
+ }
240
+ }
241
+ }
242
+
243
+ throw new Error(`Unable to update ${collectionName}/${id} after ${maxAttempts} attempts`);
244
+ }
245
+
204
246
  async remove(collectionName, id, options = {}) {
205
247
  const existing = this.getCollection(collectionName).get(id);
206
248
 
@@ -534,6 +576,34 @@ class DignityP2P extends EventEmitter {
534
576
  return this.getBanInfo(peerId) !== null;
535
577
  }
536
578
 
579
+ emitConflict(details) {
580
+ this.emit('conflict', details);
581
+ }
582
+
583
+ restoreRecord(collectionName, record) {
584
+ if (!record || !record.id) {
585
+ return false;
586
+ }
587
+
588
+ const collection = this.getCollection(collectionName);
589
+ const current = collection.get(record.id);
590
+ if (current && current.version >= record.version) {
591
+ return false;
592
+ }
593
+
594
+ collection.set(record.id, {
595
+ id: record.id,
596
+ ownerId: record.ownerId,
597
+ data: { ...(record.data || {}) },
598
+ createdAt: record.createdAt,
599
+ updatedAt: record.updatedAt,
600
+ deletedAt: record.deletedAt || null,
601
+ version: record.version
602
+ });
603
+
604
+ return true;
605
+ }
606
+
537
607
  applyOperation(operation) {
538
608
  if (!operation || !operation.opId || this.appliedOperations.has(operation.opId)) {
539
609
  return false;
@@ -571,6 +641,15 @@ class DignityP2P extends EventEmitter {
571
641
  }
572
642
 
573
643
  if (typeof operation.baseVersion === 'number' && operation.baseVersion !== current.version) {
644
+ this.emitConflict({
645
+ kind: operation.kind,
646
+ collection: operation.collectionName,
647
+ id: operation.id,
648
+ expectedVersion: operation.baseVersion,
649
+ currentVersion: current.version,
650
+ phase: 'remote',
651
+ operation
652
+ });
574
653
  return false;
575
654
  }
576
655
 
package/src/index.js CHANGED
@@ -15,6 +15,7 @@ const {
15
15
  InMemoryNetworkHub,
16
16
  InMemoryNetworkAdapter
17
17
  } = require('./network/in-memory-network');
18
+ const IndexedDBPersistence = require('./persistence/indexeddb-persistence');
18
19
  const {
19
20
  DEFAULT_CLOUDFLARE_SIGNALING_URLS,
20
21
  DEFAULT_SIGNALING_FALLBACK_URLS
@@ -34,6 +35,7 @@ module.exports = {
34
35
  PeerJSSignalingProvider,
35
36
  InMemoryNetworkHub,
36
37
  InMemoryNetworkAdapter,
38
+ IndexedDBPersistence,
37
39
  DEFAULT_CLOUDFLARE_SIGNALING_URLS,
38
40
  DEFAULT_SIGNALING_FALLBACK_URLS,
39
41
  VDF,
@@ -0,0 +1,182 @@
1
+ class IndexedDBPersistence {
2
+ constructor({
3
+ dbName = 'dignity',
4
+ storeName = 'records',
5
+ collections = null,
6
+ indexedDB = typeof globalThis !== 'undefined' ? globalThis.indexedDB : null
7
+ } = {}) {
8
+ this.dbName = dbName;
9
+ this.storeName = storeName;
10
+ this.collections = collections;
11
+ this.indexedDB = indexedDB;
12
+ this.node = null;
13
+ this.changeHandler = null;
14
+ }
15
+
16
+ recordKey(collection, id) {
17
+ return `${collection}:${id}`;
18
+ }
19
+
20
+ shouldPersist(collection) {
21
+ if (!this.collections) {
22
+ return true;
23
+ }
24
+
25
+ return this.collections.includes(collection);
26
+ }
27
+
28
+ openDb() {
29
+ if (!this.indexedDB) {
30
+ return Promise.reject(new Error('IndexedDB is not available'));
31
+ }
32
+
33
+ return new Promise((resolve, reject) => {
34
+ const request = this.indexedDB.open(this.dbName, 1);
35
+
36
+ request.onupgradeneeded = () => {
37
+ const db = request.result;
38
+ if (!db.objectStoreNames.contains(this.storeName)) {
39
+ db.createObjectStore(this.storeName, { keyPath: 'key' });
40
+ }
41
+ };
42
+
43
+ request.onsuccess = () => resolve(request.result);
44
+ request.onerror = () => reject(request.error || new Error('Unable to open IndexedDB'));
45
+ });
46
+ }
47
+
48
+ runTransaction(mode, handler) {
49
+ return this.openDb().then((db) => new Promise((resolve, reject) => {
50
+ const transaction = db.transaction(this.storeName, mode);
51
+ const store = transaction.objectStore(this.storeName);
52
+
53
+ Promise.resolve(handler(store))
54
+ .then(resolve)
55
+ .catch(reject);
56
+
57
+ transaction.oncomplete = () => db.close();
58
+ transaction.onerror = () => reject(transaction.error || new Error('IndexedDB transaction failed'));
59
+ transaction.onabort = () => reject(transaction.error || new Error('IndexedDB transaction aborted'));
60
+ }));
61
+ }
62
+
63
+ serializeRecord(collection, id) {
64
+ const record = this.node.getCollection(collection).get(id);
65
+ if (!record) {
66
+ return null;
67
+ }
68
+
69
+ return {
70
+ key: this.recordKey(collection, id),
71
+ collection,
72
+ id,
73
+ ownerId: record.ownerId,
74
+ data: { ...record.data },
75
+ createdAt: record.createdAt,
76
+ updatedAt: record.updatedAt,
77
+ deletedAt: record.deletedAt,
78
+ version: record.version
79
+ };
80
+ }
81
+
82
+ async persistRecord(collection, id) {
83
+ if (!this.node || !this.shouldPersist(collection)) {
84
+ return;
85
+ }
86
+
87
+ const serialized = this.serializeRecord(collection, id);
88
+ const key = this.recordKey(collection, id);
89
+
90
+ if (!serialized) {
91
+ await this.runTransaction('readwrite', (store) => new Promise((resolve, reject) => {
92
+ const request = store.delete(key);
93
+ request.onsuccess = () => resolve();
94
+ request.onerror = () => reject(request.error);
95
+ }));
96
+ return;
97
+ }
98
+
99
+ await this.runTransaction('readwrite', (store) => new Promise((resolve, reject) => {
100
+ const request = store.put(serialized);
101
+ request.onsuccess = () => resolve();
102
+ request.onerror = () => reject(request.error);
103
+ }));
104
+ }
105
+
106
+ persistChange(event) {
107
+ if (!event || !event.collection || !event.id) {
108
+ return;
109
+ }
110
+
111
+ this.persistRecord(event.collection, event.id).catch((error) => {
112
+ this.node.emit('warning', {
113
+ type: 'persistence-failed',
114
+ collection: event.collection,
115
+ id: event.id,
116
+ error
117
+ });
118
+ });
119
+ }
120
+
121
+ async loadAllRecords() {
122
+ return this.runTransaction('readonly', (store) => new Promise((resolve, reject) => {
123
+ const request = store.getAll();
124
+ request.onsuccess = () => resolve(request.result || []);
125
+ request.onerror = () => reject(request.error);
126
+ }));
127
+ }
128
+
129
+ async hydrate() {
130
+ if (!this.node) {
131
+ throw new Error('IndexedDBPersistence requires an attached node before hydrate');
132
+ }
133
+
134
+ const storedRecords = await this.loadAllRecords();
135
+ for (const stored of storedRecords) {
136
+ if (!this.shouldPersist(stored.collection)) {
137
+ continue;
138
+ }
139
+
140
+ this.node.restoreRecord(stored.collection, {
141
+ id: stored.id,
142
+ ownerId: stored.ownerId,
143
+ data: stored.data,
144
+ createdAt: stored.createdAt,
145
+ updatedAt: stored.updatedAt,
146
+ deletedAt: stored.deletedAt,
147
+ version: stored.version
148
+ });
149
+ }
150
+ }
151
+
152
+ async attach(node) {
153
+ if (!node) {
154
+ throw new Error('IndexedDBPersistence.attach requires a DignityP2P node');
155
+ }
156
+
157
+ this.node = node;
158
+ await this.hydrate();
159
+
160
+ this.changeHandler = (event) => this.persistChange(event);
161
+ node.on('change', this.changeHandler);
162
+ }
163
+
164
+ async detach() {
165
+ if (this.node && this.changeHandler) {
166
+ this.node.off('change', this.changeHandler);
167
+ }
168
+
169
+ this.changeHandler = null;
170
+ this.node = null;
171
+ }
172
+
173
+ async clear() {
174
+ await this.runTransaction('readwrite', (store) => new Promise((resolve, reject) => {
175
+ const request = store.clear();
176
+ request.onsuccess = () => resolve();
177
+ request.onerror = () => reject(request.error);
178
+ }));
179
+ }
180
+ }
181
+
182
+ module.exports = IndexedDBPersistence;
@@ -0,0 +1,114 @@
1
+ const { useCallback, useEffect, useState } = require('react');
2
+ const DignityP2P = require('../core/dignity-p2p');
3
+
4
+ function useDignity(config) {
5
+ const [node, setNode] = useState(null);
6
+ const [status, setStatus] = useState('idle');
7
+ const [error, setError] = useState(null);
8
+
9
+ useEffect(() => {
10
+ if (!config) {
11
+ return undefined;
12
+ }
13
+
14
+ let cancelled = false;
15
+ const instance = new DignityP2P(config);
16
+ setNode(instance);
17
+ setStatus('starting');
18
+ setError(null);
19
+
20
+ instance.start()
21
+ .then(() => {
22
+ if (!cancelled) {
23
+ setStatus('running');
24
+ }
25
+ })
26
+ .catch((startError) => {
27
+ if (!cancelled) {
28
+ setError(startError);
29
+ setStatus('error');
30
+ }
31
+ });
32
+
33
+ return () => {
34
+ cancelled = true;
35
+ instance.stop()
36
+ .catch(() => undefined)
37
+ .finally(() => {
38
+ setStatus('stopped');
39
+ setNode(null);
40
+ });
41
+ };
42
+ }, [config]);
43
+
44
+ return {
45
+ node,
46
+ status,
47
+ error
48
+ };
49
+ }
50
+
51
+ function useCollection(node, collectionName) {
52
+ const [records, setRecords] = useState([]);
53
+
54
+ const refresh = useCallback(() => {
55
+ if (!node || !collectionName) {
56
+ setRecords([]);
57
+ return;
58
+ }
59
+
60
+ setRecords(node.list(collectionName));
61
+ }, [node, collectionName]);
62
+
63
+ useEffect(() => {
64
+ refresh();
65
+
66
+ if (!node) {
67
+ return undefined;
68
+ }
69
+
70
+ node.on('change', refresh);
71
+ return () => node.off('change', refresh);
72
+ }, [node, refresh]);
73
+
74
+ return records;
75
+ }
76
+
77
+ function usePeers(node, scope = 'main', options = {}) {
78
+ const includeSelf = options.includeSelf !== false;
79
+ const [peers, setPeers] = useState([]);
80
+
81
+ const refresh = useCallback(() => {
82
+ if (!node) {
83
+ setPeers([]);
84
+ return;
85
+ }
86
+
87
+ setPeers(node.listPeers(scope, { includeSelf }));
88
+ }, [node, scope, includeSelf]);
89
+
90
+ useEffect(() => {
91
+ refresh();
92
+
93
+ if (!node) {
94
+ return undefined;
95
+ }
96
+
97
+ const handlePresenceChange = () => refresh();
98
+ node.on('peerdiscovered', handlePresenceChange);
99
+ node.on('peerleft', handlePresenceChange);
100
+
101
+ return () => {
102
+ node.off('peerdiscovered', handlePresenceChange);
103
+ node.off('peerleft', handlePresenceChange);
104
+ };
105
+ }, [node, refresh]);
106
+
107
+ return peers;
108
+ }
109
+
110
+ module.exports = {
111
+ useDignity,
112
+ useCollection,
113
+ usePeers
114
+ };
@@ -12,7 +12,8 @@ const DEFAULT_SECURITY_OPTIONS = {
12
12
  broadcastPasswords: {},
13
13
  resolveBroadcastPassword: null,
14
14
  powSteps: 22,
15
- trustedPeerKeys: {}
15
+ trustedPeerKeys: {},
16
+ kdfIterations: 100000
16
17
  };
17
18
 
18
19
  function stableStringify(value) {
@@ -47,6 +48,37 @@ function utf8ToBytes(value) {
47
48
  return naclUtil.decodeUTF8(value);
48
49
  }
49
50
 
51
+ async function deriveBroadcastKey(password, salt, iterations) {
52
+ const subtle = globalThis.crypto && globalThis.crypto.subtle;
53
+
54
+ if (subtle) {
55
+ const keyMaterial = await subtle.importKey(
56
+ 'raw',
57
+ utf8ToBytes(password),
58
+ 'PBKDF2',
59
+ false,
60
+ ['deriveBits']
61
+ );
62
+ const bits = await subtle.deriveBits(
63
+ { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
64
+ keyMaterial,
65
+ 256
66
+ );
67
+ return new Uint8Array(bits);
68
+ }
69
+
70
+ try {
71
+ const { pbkdf2Sync } = require('crypto');
72
+ return new Uint8Array(pbkdf2Sync(password, Buffer.from(salt), iterations, 32, 'sha256'));
73
+ } catch (_ignored) {
74
+ return hash32(concatBytes(utf8ToBytes(password), salt));
75
+ }
76
+ }
77
+
78
+ function legacyBroadcastKey(password, salt) {
79
+ return hash32(concatBytes(utf8ToBytes(password), salt));
80
+ }
81
+
50
82
  function normalizePeerPublicKey(publicKey) {
51
83
  if (!publicKey || typeof publicKey !== 'object') {
52
84
  throw new Error('Public key must be an object with signingPublicKey and encryptionPublicKey');
@@ -240,7 +272,7 @@ class MessageSecurityService {
240
272
  this.verifySignature(envelope);
241
273
  }
242
274
 
243
- const payload = this.decryptPayload(envelope);
275
+ const payload = await this.decryptPayload(envelope);
244
276
 
245
277
  return {
246
278
  ignored: false,
@@ -320,7 +352,8 @@ class MessageSecurityService {
320
352
  const nonce = nacl.randomBytes(nacl.secretbox.nonceLength);
321
353
  const salt = nacl.randomBytes(16);
322
354
  const password = this.resolveBroadcastPassword(scope);
323
- const key = hash32(concatBytes(utf8ToBytes(password), salt));
355
+ const iterations = this.options.kdfIterations || DEFAULT_SECURITY_OPTIONS.kdfIterations;
356
+ const key = await deriveBroadcastKey(password, salt, iterations);
324
357
  const encrypted = nacl.secretbox(plainText, nonce, key);
325
358
 
326
359
  return {
@@ -330,12 +363,14 @@ class MessageSecurityService {
330
363
  mode: 'broadcast',
331
364
  scope,
332
365
  nonce: naclUtil.encodeBase64(nonce),
333
- salt: naclUtil.encodeBase64(salt)
366
+ salt: naclUtil.encodeBase64(salt),
367
+ kdf: 'pbkdf2',
368
+ kdfIterations: iterations
334
369
  }
335
370
  };
336
371
  }
337
372
 
338
- decryptPayload(envelope) {
373
+ async decryptPayload(envelope) {
339
374
  const encryption = envelope.security ? envelope.security.encryption : null;
340
375
 
341
376
  if (!encryption || !encryption.enabled || !this.options.encryptionEnabled) {
@@ -349,7 +384,15 @@ class MessageSecurityService {
349
384
  const password = this.resolveBroadcastPassword(scope);
350
385
  const salt = naclUtil.decodeBase64(encryption.salt);
351
386
  const nonce = naclUtil.decodeBase64(encryption.nonce);
352
- const key = hash32(concatBytes(utf8ToBytes(password), salt));
387
+
388
+ let key;
389
+ if (encryption.kdf === 'pbkdf2') {
390
+ const iterations = encryption.kdfIterations || DEFAULT_SECURITY_OPTIONS.kdfIterations;
391
+ key = await deriveBroadcastKey(password, salt, iterations);
392
+ } else {
393
+ key = legacyBroadcastKey(password, salt);
394
+ }
395
+
353
396
  const decrypted = nacl.secretbox.open(encryptedBuffer, nonce, key);
354
397
 
355
398
  if (!decrypted) {
@@ -470,5 +513,7 @@ class MessageSecurityService {
470
513
  module.exports = {
471
514
  MessageSecurityService,
472
515
  stableStringify,
516
+ deriveBroadcastKey,
517
+ legacyBroadcastKey,
473
518
  DEFAULT_SECURITY_OPTIONS
474
519
  };