@leofcoin/peernet 3.0.0 → 3.1.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
@@ -35,6 +35,8 @@ import Peernet from '@leofcoin/peernet'
35
35
  const config = {
36
36
  network: 'leofcoin:peach',
37
37
  networkVersion: 'peach',
38
+ // Optional per-installation transport identity; defaults to the wallet identity.
39
+ routingId: 'lfc-peer-a1b2c3d4',
38
40
  stars: ['wss://peach.leofcoin.org']
39
41
  }
40
42
 
@@ -48,6 +50,49 @@ await node.start()
48
50
  console.log(globalThis.peernet)
49
51
  ```
50
52
 
53
+ `node.id` is the routing identity used for peer discovery and swarm connections.
54
+ When `routingId` is omitted it remains the wallet identity for backward
55
+ compatibility. `node.peerId` always identifies the loaded wallet and should be
56
+ used by applications that need the wallet identity, such as chain transaction
57
+ signing. Applications with multiple installations of the same wallet can
58
+ provide a distinct, persisted `routingId` for each installation.
59
+
60
+ ### Encrypted chat archive mode
61
+
62
+ Applications that already create and verify encrypted chat envelopes can opt in
63
+ to Peernet's bounded ciphertext archive. Archive settings and records are keyed
64
+ by the complete Peernet network string (including its version, such as
65
+ `leofcoin:peach`) in the message store. Archive mode is disabled by default.
66
+ Records expire after 30 days and are limited to 500 messages per network.
67
+ Plaintext message fields are rejected; the application supplies verification
68
+ functions for the signed envelope, inbox request and delivery receipt.
69
+
70
+ ```js
71
+ const archive = node.createArchiveMode({
72
+ verifyEnvelope,
73
+ verifyInboxRequest,
74
+ verifyReceipt
75
+ })
76
+
77
+ await archive.setEnabled(true) // Explicit opt-in for this node and network.
78
+ archive.onMessage(({ envelope }) => {
79
+ // Decrypt the envelope in the application, then acknowledge with a signed receipt.
80
+ })
81
+ await archive.start({ account, deviceId })
82
+ await archive.requestInbox(signedInboxRequest)
83
+
84
+ // The envelope must contain ciphertext, recipient account/device, timestamp,
85
+ // signature, and either `network` or a network-bound device certificate.
86
+ await archive.send(encryptedEnvelope)
87
+ await archive.acknowledge(signedDeliveryReceipt)
88
+ ```
89
+
90
+ Inbox requests and receipts must be signed and verified by the application.
91
+ Archives forward only ciphertext and never decrypt messages. Disabling archive
92
+ mode deletes this network's locally archived copies; it does not revoke or
93
+ erase copies on other nodes. Delivery and best-effort deletion depend on
94
+ reachable peers.
95
+
51
96
  ## API
52
97
 
53
98
  ### addRequestHandler
@@ -1,4 +1,4 @@
1
- import { L as LittlePubSub, d as deflate_1, i as inflate_1, c as createDebugger } from './peernet-Db43ag2D.js';
1
+ import { L as LittlePubSub, d as deflate_1, i as inflate_1, c as createDebugger } from './peernet-DjddqiXc.js';
2
2
  import './identity-Cf8KKnho.js';
3
3
  import 'crypto';
4
4
  import './value-C3vAp-wb.js';
@@ -1,4 +1,4 @@
1
- import { F as FormatInterface } from './peernet-Db43ag2D.js';
1
+ import { F as FormatInterface } from './peernet-DjddqiXc.js';
2
2
  import './identity-Cf8KKnho.js';
3
3
  import 'crypto';
4
4
  import './value-C3vAp-wb.js';
@@ -7090,23 +7090,23 @@ const getTokens = (proto) => {
7090
7090
  return tokens;
7091
7091
  };
7092
7092
 
7093
- const encoder = new TextEncoder();
7093
+ const encoder$1 = new TextEncoder();
7094
7094
  const toType = (data) => {
7095
7095
  if (data instanceof Uint8Array)
7096
7096
  return data;
7097
7097
  if (data instanceof ArrayBuffer)
7098
7098
  return new Uint8Array(data);
7099
7099
  if (typeof data === 'bigint')
7100
- return encoder.encode(data.toString());
7100
+ return encoder$1.encode(data.toString());
7101
7101
  if (typeof data === 'string')
7102
- return encoder.encode(data);
7102
+ return encoder$1.encode(data);
7103
7103
  if (typeof data === 'object')
7104
- return encoder.encode(JSON.stringify(data, jsonStringifyBigInt$1));
7104
+ return encoder$1.encode(JSON.stringify(data, jsonStringifyBigInt$1));
7105
7105
  if (typeof data === 'number' || typeof data === 'boolean')
7106
- return encoder.encode(data.toString());
7106
+ return encoder$1.encode(data.toString());
7107
7107
  throw new Error(`unsuported type ${typeof data || data}`);
7108
7108
  };
7109
- const encode = (proto, input, compress) => {
7109
+ const encode$1 = (proto, input, compress) => {
7110
7110
  const tokens = getTokens(proto);
7111
7111
  const set = [];
7112
7112
  for (let i = 0; i < tokens.length; i++) {
@@ -7163,8 +7163,8 @@ var index$1 = {
7163
7163
  toString: toString$1};
7164
7164
 
7165
7165
  const { toString } = index$1;
7166
- const decoder = new TextDecoder();
7167
- const decode = (proto, uint8Array, compressed) => {
7166
+ const decoder$1 = new TextDecoder();
7167
+ const decode$1 = (proto, uint8Array, compressed) => {
7168
7168
  if (compressed)
7169
7169
  uint8Array = pako.inflate(uint8Array);
7170
7170
  let deconcated = index$4(uint8Array);
@@ -7179,13 +7179,13 @@ const decode = (proto, uint8Array, compressed) => {
7179
7179
  else if (token.type === 'string')
7180
7180
  output[token.key] = toString(deconcated[i]);
7181
7181
  else if (token.type === 'boolean')
7182
- output[token.key] = decoder.decode(deconcated[i]) === 'true';
7182
+ output[token.key] = decoder$1.decode(deconcated[i]) === 'true';
7183
7183
  else if (token.type === 'number')
7184
- output[token.key] = Number(decoder.decode(deconcated[i]));
7184
+ output[token.key] = Number(decoder$1.decode(deconcated[i]));
7185
7185
  else if (token.type === 'bigint')
7186
- output[token.key] = BigInt(decoder.decode(deconcated[i]));
7186
+ output[token.key] = BigInt(decoder$1.decode(deconcated[i]));
7187
7187
  else if (token.type === 'object' || token.type === 'array')
7188
- output[token.key] = JSON.parse(decoder.decode(deconcated[i]), jsonParseBigInt$1);
7188
+ output[token.key] = JSON.parse(decoder$1.decode(deconcated[i]), jsonParseBigInt$1);
7189
7189
  if (token.optional) {
7190
7190
  if (!output[token.key] || output[token.key].length === 0)
7191
7191
  delete output[token.key];
@@ -7196,7 +7196,7 @@ const decode = (proto, uint8Array, compressed) => {
7196
7196
  return output;
7197
7197
  };
7198
7198
 
7199
- var index = { encode, decode };
7199
+ var index = { encode: encode$1, decode: decode$1 };
7200
7200
 
7201
7201
  const jsonStringifyBigInt = (key, value) => {
7202
7202
  if (typeof value === 'bigint')
@@ -8662,6 +8662,439 @@ class FileTransfer {
8662
8662
  }
8663
8663
  }
8664
8664
 
8665
+ const VERSION = 1;
8666
+ const MAX_PACKET_BYTES = 64_000;
8667
+ const MAX_ENVELOPE_BYTES = 32_000;
8668
+ const MAX_ARCHIVES = 500;
8669
+ const MAX_OUTBOX = 500;
8670
+ const MAX_AGE = 30 * 24 * 60 * 60 * 1000;
8671
+ const MAX_CLOCK_SKEW = 10 * 60 * 1000;
8672
+ const MAX_HOPS = 8;
8673
+ const encoder = new TextEncoder();
8674
+ const decoder = new TextDecoder();
8675
+ function isUnsubscribe(value) {
8676
+ return typeof value === 'function';
8677
+ }
8678
+ function encode(value) {
8679
+ return encoder.encode(JSON.stringify(value));
8680
+ }
8681
+ function decode(payload) {
8682
+ if (!(payload instanceof Uint8Array))
8683
+ throw new TypeError('Archive packets must be encoded bytes.');
8684
+ if (payload.byteLength > MAX_PACKET_BYTES)
8685
+ throw new RangeError('Archive packet exceeds the size limit.');
8686
+ return JSON.parse(decoder.decode(payload));
8687
+ }
8688
+ function randomId() {
8689
+ if (typeof crypto.randomUUID === 'function')
8690
+ return crypto.randomUUID();
8691
+ return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, '0')).join('');
8692
+ }
8693
+ function keyPrefix(network) {
8694
+ return `peernet:archive:v1:${encodeURIComponent(network)}:`;
8695
+ }
8696
+ function recordKey(network, kind, id) {
8697
+ return `${keyPrefix(network)}${kind}:${encodeURIComponent(id)}`;
8698
+ }
8699
+ function stateKey(network) {
8700
+ return `${keyPrefix(network)}state`;
8701
+ }
8702
+ async function storeGet(store, key) {
8703
+ if (store.has && !(await store.has(key)))
8704
+ return undefined;
8705
+ try {
8706
+ return await store.get(key);
8707
+ }
8708
+ catch (error) {
8709
+ if (error?.code === 'LEVEL_NOT_FOUND' ||
8710
+ error?.name === 'NotFoundError')
8711
+ return undefined;
8712
+ throw error;
8713
+ }
8714
+ }
8715
+ function validateEnvelope(envelope, network) {
8716
+ const envelopeNetwork = envelope?.network ||
8717
+ envelope?.senderCertificate?.network ||
8718
+ envelope?.recipientCertificate?.network;
8719
+ if (!envelope ||
8720
+ typeof envelope !== 'object' ||
8721
+ envelopeNetwork !== network ||
8722
+ typeof envelope.id !== 'string' ||
8723
+ !envelope.id ||
8724
+ typeof envelope.recipientAccount !== 'string' ||
8725
+ typeof envelope.recipientDeviceId !== 'string' ||
8726
+ typeof envelope.ciphertext !== 'string' ||
8727
+ !envelope.ciphertext ||
8728
+ typeof envelope.signature !== 'string' ||
8729
+ !Number.isFinite(envelope.timestamp) ||
8730
+ envelope.timestamp < Date.now() - MAX_AGE ||
8731
+ envelope.timestamp > Date.now() + MAX_CLOCK_SKEW)
8732
+ throw new Error('Only a valid, network-scoped encrypted message envelope can be archived.');
8733
+ if ('text' in envelope || 'plaintext' in envelope || 'message' in envelope)
8734
+ throw new Error('Plaintext message fields cannot be archived.');
8735
+ if (encode(envelope).byteLength > MAX_ENVELOPE_BYTES)
8736
+ throw new RangeError('Encrypted message envelope exceeds the archive size limit.');
8737
+ }
8738
+ function validatePacket(value, network) {
8739
+ if (!value || typeof value !== 'object' || Array.isArray(value))
8740
+ return;
8741
+ const packet = value;
8742
+ if (packet.version !== VERSION ||
8743
+ packet.network !== network ||
8744
+ typeof packet.id !== 'string' ||
8745
+ !packet.id ||
8746
+ typeof packet.origin !== 'string' ||
8747
+ !packet.origin ||
8748
+ !Number.isFinite(packet.timestamp) ||
8749
+ packet.timestamp < Date.now() - MAX_AGE ||
8750
+ packet.timestamp > Date.now() + MAX_CLOCK_SKEW ||
8751
+ !Number.isInteger(packet.hopsRemaining) ||
8752
+ packet.hopsRemaining < 0 ||
8753
+ packet.hopsRemaining > MAX_HOPS ||
8754
+ !['direct', 'inbox-request', 'archive-delivery', 'delivery-receipt'].includes(packet.type) ||
8755
+ !packet.payload ||
8756
+ typeof packet.payload !== 'object' ||
8757
+ Array.isArray(packet.payload))
8758
+ return;
8759
+ return packet;
8760
+ }
8761
+ class PeernetArchiveMode {
8762
+ #peernet;
8763
+ #store;
8764
+ #network;
8765
+ #topic;
8766
+ #verifyEnvelope;
8767
+ #verifyInboxRequest;
8768
+ #verifyReceipt;
8769
+ #handlers = new Set();
8770
+ #seen = new Map();
8771
+ #archives = new Set();
8772
+ #outbox = new Set();
8773
+ #enabled = false;
8774
+ #started = false;
8775
+ #unsubscribe;
8776
+ #retryTimer;
8777
+ #account = '';
8778
+ #deviceId = '';
8779
+ constructor(options) {
8780
+ if (!options?.peernet?.network || typeof options.peernet.publish !== 'function' ||
8781
+ typeof options.peernet.subscribe !== 'function')
8782
+ throw new Error('A Peernet instance is required for archive mode.');
8783
+ const store = options.store || globalThis.messageStore;
8784
+ if (!store || typeof store.get !== 'function' || typeof store.put !== 'function' ||
8785
+ typeof store.delete !== 'function' || typeof store.keys !== 'function')
8786
+ throw new Error('A persistent Peernet message store is required for archive mode.');
8787
+ if (typeof options.verifyEnvelope !== 'function' ||
8788
+ typeof options.verifyInboxRequest !== 'function' ||
8789
+ typeof options.verifyReceipt !== 'function')
8790
+ throw new Error('Archive mode requires envelope, inbox-request and receipt verification functions.');
8791
+ this.#peernet = options.peernet;
8792
+ this.#store = store;
8793
+ this.#network = options.peernet.network;
8794
+ this.#topic = `peernet:archive:v1:${this.#network}`;
8795
+ this.#verifyEnvelope = options.verifyEnvelope;
8796
+ this.#verifyInboxRequest = options.verifyInboxRequest;
8797
+ this.#verifyReceipt = options.verifyReceipt;
8798
+ }
8799
+ get network() {
8800
+ return this.#network;
8801
+ }
8802
+ get enabled() {
8803
+ return this.#enabled;
8804
+ }
8805
+ get archiveCount() {
8806
+ return this.#archives.size;
8807
+ }
8808
+ get pendingCount() {
8809
+ return this.#outbox.size;
8810
+ }
8811
+ onMessage(handler) {
8812
+ if (typeof handler !== 'function')
8813
+ throw new TypeError('An archive message handler is required.');
8814
+ this.#handlers.add(handler);
8815
+ return () => this.#handlers.delete(handler);
8816
+ }
8817
+ async start({ account, deviceId }) {
8818
+ if (this.#started)
8819
+ return;
8820
+ if (!account || !deviceId)
8821
+ throw new Error('A local account and chat device ID are required.');
8822
+ this.#account = account;
8823
+ this.#deviceId = deviceId;
8824
+ await this.#load();
8825
+ const receiver = (payload) => {
8826
+ this.#receive(payload).catch((error) => console.error('Peernet archive packet failed:', error));
8827
+ };
8828
+ const unsubscribe = await this.#peernet.subscribe(this.#topic, receiver);
8829
+ this.#unsubscribe = isUnsubscribe(unsubscribe)
8830
+ ? unsubscribe
8831
+ : typeof globalThis.globalSub?.unsubscribe === 'function'
8832
+ ? () => globalThis.globalSub.unsubscribe(this.#topic, receiver)
8833
+ : undefined;
8834
+ this.#started = true;
8835
+ this.#retryTimer = setInterval(() => {
8836
+ this.#retryOutbox().catch((error) => console.error('Peernet archive retry failed:', error));
8837
+ }, 30_000);
8838
+ try {
8839
+ for (const id of this.#outbox) {
8840
+ const bytes = await storeGet(this.#store, recordKey(this.#network, 'outbox', id));
8841
+ if (bytes)
8842
+ await this.#publish('direct', { envelope: JSON.parse(decoder.decode(bytes)) });
8843
+ }
8844
+ }
8845
+ catch (error) {
8846
+ this.stop();
8847
+ throw error;
8848
+ }
8849
+ }
8850
+ stop() {
8851
+ clearInterval(this.#retryTimer);
8852
+ this.#retryTimer = undefined;
8853
+ this.#unsubscribe?.();
8854
+ this.#unsubscribe = undefined;
8855
+ this.#started = false;
8856
+ this.#account = '';
8857
+ this.#deviceId = '';
8858
+ }
8859
+ async setEnabled(enabled) {
8860
+ if (typeof enabled !== 'boolean')
8861
+ throw new TypeError('Archive mode must be enabled or disabled explicitly.');
8862
+ if (!this.#started)
8863
+ await this.#load();
8864
+ this.#enabled = enabled;
8865
+ await this.#store.put(stateKey(this.#network), encode({ version: VERSION, enabled }));
8866
+ if (!enabled) {
8867
+ for (const id of this.#archives)
8868
+ await this.#store.delete(recordKey(this.#network, 'archive', id));
8869
+ this.#archives.clear();
8870
+ }
8871
+ }
8872
+ async send(envelope) {
8873
+ validateEnvelope(envelope, this.#network);
8874
+ if (!(await this.#verifyEnvelope(envelope)))
8875
+ throw new Error('Encrypted message signature verification failed.');
8876
+ if (this.#outbox.has(envelope.id))
8877
+ throw new Error('This encrypted message is already pending.');
8878
+ await this.#store.put(recordKey(this.#network, 'outbox', envelope.id), encode(envelope));
8879
+ this.#outbox.add(envelope.id);
8880
+ if (this.#outbox.size > MAX_OUTBOX)
8881
+ await this.#dropOldest('outbox');
8882
+ await this.#publish('direct', { envelope });
8883
+ }
8884
+ async requestInbox(request) {
8885
+ if (request.network !== this.#network ||
8886
+ request.account !== this.#account ||
8887
+ request.deviceId !== this.#deviceId ||
8888
+ !(await this.#validInboxRequest(request)))
8889
+ throw new Error('A valid signed inbox request for this Peernet network and device is required.');
8890
+ await this.#publish('inbox-request', { request });
8891
+ }
8892
+ async acknowledge(receipt) {
8893
+ if (receipt.network !== this.#network ||
8894
+ receipt.recipientAccount !== this.#account ||
8895
+ receipt.recipientDeviceId !== this.#deviceId ||
8896
+ typeof receipt.signature !== 'string' ||
8897
+ !Number.isFinite(receipt.timestamp) ||
8898
+ Math.abs(Date.now() - receipt.timestamp) > MAX_CLOCK_SKEW)
8899
+ throw new Error('Delivery receipt does not belong to this Peernet network and device.');
8900
+ await this.#publish('delivery-receipt', { receipt });
8901
+ }
8902
+ async #load() {
8903
+ const state = await storeGet(this.#store, stateKey(this.#network));
8904
+ if (state) {
8905
+ const saved = JSON.parse(decoder.decode(state));
8906
+ if (saved?.version !== VERSION || typeof saved.enabled !== 'boolean')
8907
+ throw new Error('Saved Peernet archive settings are invalid.');
8908
+ this.#enabled = saved.enabled;
8909
+ }
8910
+ const prefix = keyPrefix(this.#network);
8911
+ const keys = await this.#store.keys();
8912
+ for (const keyInput of keys) {
8913
+ const key = keyInput.toString().replace(/^\/+/, '');
8914
+ if (!key.startsWith(prefix))
8915
+ continue;
8916
+ const archiveMarker = `${prefix}archive:`;
8917
+ const outboxMarker = `${prefix}outbox:`;
8918
+ if (key.startsWith(archiveMarker))
8919
+ this.#archives.add(decodeURIComponent(key.slice(archiveMarker.length)));
8920
+ if (key.startsWith(outboxMarker))
8921
+ this.#outbox.add(decodeURIComponent(key.slice(outboxMarker.length)));
8922
+ }
8923
+ await this.#prune();
8924
+ }
8925
+ async #publish(type, payload, hopsRemaining = MAX_HOPS) {
8926
+ if (!this.#started)
8927
+ throw new Error('Peernet archive mode is not started.');
8928
+ const packet = {
8929
+ version: VERSION,
8930
+ id: randomId(),
8931
+ network: this.#network,
8932
+ origin: this.#peernet.id,
8933
+ timestamp: Date.now(),
8934
+ hopsRemaining,
8935
+ type,
8936
+ payload
8937
+ };
8938
+ const bytes = encode(packet);
8939
+ if (bytes.byteLength > MAX_PACKET_BYTES)
8940
+ throw new RangeError('Peernet archive packet exceeds its size limit.');
8941
+ this.#seen.set(packet.id, packet.timestamp);
8942
+ await this.#peernet.publish(this.#topic, bytes);
8943
+ }
8944
+ async #receive(payload) {
8945
+ const packet = validatePacket(decode(payload), this.#network);
8946
+ if (!packet || packet.origin === this.#peernet.id || this.#wasSeen(packet.id))
8947
+ return;
8948
+ this.#markSeen(packet.id);
8949
+ let forward = false;
8950
+ switch (packet.type) {
8951
+ case 'direct':
8952
+ case 'archive-delivery': {
8953
+ const envelope = packet.payload.envelope;
8954
+ try {
8955
+ validateEnvelope(envelope, this.#network);
8956
+ }
8957
+ catch {
8958
+ break;
8959
+ }
8960
+ if (!(await this.#verifyEnvelope(envelope)))
8961
+ break;
8962
+ forward = true;
8963
+ if (packet.type === 'direct' && this.#enabled)
8964
+ await this.#saveArchive(envelope);
8965
+ if (envelope.recipientAccount === this.#account && envelope.recipientDeviceId === this.#deviceId) {
8966
+ for (const handler of this.#handlers)
8967
+ handler({ type: 'message', envelope });
8968
+ }
8969
+ break;
8970
+ }
8971
+ case 'inbox-request': {
8972
+ const request = packet.payload.request;
8973
+ if (!(await this.#validInboxRequest(request)))
8974
+ break;
8975
+ forward = true;
8976
+ if (this.#enabled && !(request.account === this.#account && request.deviceId === this.#deviceId))
8977
+ await this.#deliverTo(request);
8978
+ break;
8979
+ }
8980
+ case 'delivery-receipt': {
8981
+ const receipt = packet.payload.receipt;
8982
+ forward = await this.#receiveReceipt(receipt);
8983
+ break;
8984
+ }
8985
+ }
8986
+ if (forward && packet.hopsRemaining > 0) {
8987
+ await this.#peernet.publish(this.#topic, encode({ ...packet, hopsRemaining: packet.hopsRemaining - 1 }));
8988
+ }
8989
+ }
8990
+ async #validInboxRequest(request) {
8991
+ return Boolean(request &&
8992
+ request.network === this.#network &&
8993
+ typeof request.account === 'string' &&
8994
+ typeof request.deviceId === 'string' &&
8995
+ Number.isFinite(request.timestamp) &&
8996
+ Math.abs(Date.now() - request.timestamp) <= MAX_CLOCK_SKEW &&
8997
+ typeof request.signature === 'string' &&
8998
+ await this.#verifyInboxRequest(request));
8999
+ }
9000
+ async #saveArchive(envelope) {
9001
+ if (this.#archives.has(envelope.id))
9002
+ return;
9003
+ await this.#store.put(recordKey(this.#network, 'archive', envelope.id), encode({
9004
+ envelope,
9005
+ storedAt: Date.now()
9006
+ }));
9007
+ this.#archives.add(envelope.id);
9008
+ if (this.#archives.size > MAX_ARCHIVES)
9009
+ await this.#dropOldest('archive');
9010
+ }
9011
+ async #deliverTo(request) {
9012
+ for (const id of this.#archives) {
9013
+ const bytes = await storeGet(this.#store, recordKey(this.#network, 'archive', id));
9014
+ if (!bytes) {
9015
+ this.#archives.delete(id);
9016
+ continue;
9017
+ }
9018
+ const record = JSON.parse(decoder.decode(bytes));
9019
+ const envelope = record?.envelope;
9020
+ if (envelope?.recipientAccount === request.account && envelope.recipientDeviceId === request.deviceId)
9021
+ await this.#publish('archive-delivery', { envelope });
9022
+ }
9023
+ }
9024
+ async #receiveReceipt(receipt) {
9025
+ if (!receipt || receipt.network !== this.#network ||
9026
+ typeof receipt.messageId !== 'string' ||
9027
+ !Number.isFinite(receipt.timestamp) ||
9028
+ Math.abs(Date.now() - receipt.timestamp) > MAX_CLOCK_SKEW)
9029
+ return false;
9030
+ const keys = ['archive', 'outbox'];
9031
+ let verified = false;
9032
+ for (const kind of keys) {
9033
+ const key = recordKey(this.#network, kind, receipt.messageId);
9034
+ const bytes = await storeGet(this.#store, key);
9035
+ if (!bytes)
9036
+ continue;
9037
+ const stored = JSON.parse(decoder.decode(bytes));
9038
+ const envelope = kind === 'archive' ? stored.envelope : stored;
9039
+ if (!(await this.#verifyReceipt(receipt, envelope)))
9040
+ continue;
9041
+ await this.#store.delete(key);
9042
+ (kind === 'archive' ? this.#archives : this.#outbox).delete(receipt.messageId);
9043
+ verified = true;
9044
+ }
9045
+ return verified;
9046
+ }
9047
+ async #retryOutbox() {
9048
+ if (!this.#started)
9049
+ return;
9050
+ await this.#prune();
9051
+ for (const id of this.#outbox) {
9052
+ const bytes = await storeGet(this.#store, recordKey(this.#network, 'outbox', id));
9053
+ if (bytes)
9054
+ await this.#publish('direct', { envelope: JSON.parse(decoder.decode(bytes)) });
9055
+ }
9056
+ }
9057
+ async #prune() {
9058
+ const cutoff = Date.now() - MAX_AGE;
9059
+ for (const [kind, ids] of [['archive', this.#archives], ['outbox', this.#outbox]]) {
9060
+ for (const id of ids) {
9061
+ const key = recordKey(this.#network, kind, id);
9062
+ const bytes = await storeGet(this.#store, key);
9063
+ if (!bytes) {
9064
+ ids.delete(id);
9065
+ continue;
9066
+ }
9067
+ const saved = JSON.parse(decoder.decode(bytes));
9068
+ const timestamp = kind === 'archive' ? saved.storedAt : saved.timestamp;
9069
+ if (!Number.isFinite(timestamp) || timestamp < cutoff) {
9070
+ await this.#store.delete(key);
9071
+ ids.delete(id);
9072
+ }
9073
+ }
9074
+ }
9075
+ }
9076
+ async #dropOldest(kind) {
9077
+ const ids = kind === 'archive' ? this.#archives : this.#outbox;
9078
+ const id = ids.values().next().value;
9079
+ if (!id)
9080
+ return;
9081
+ await this.#store.delete(recordKey(this.#network, kind, id));
9082
+ ids.delete(id);
9083
+ }
9084
+ #wasSeen(id) {
9085
+ const timestamp = this.#seen.get(id);
9086
+ return timestamp !== undefined && Date.now() - timestamp < MAX_AGE;
9087
+ }
9088
+ #markSeen(id) {
9089
+ this.#seen.set(id, Date.now());
9090
+ if (this.#seen.size > 4_000) {
9091
+ const oldestId = this.#seen.keys().next().value;
9092
+ if (oldestId !== undefined)
9093
+ this.#seen.delete(oldestId);
9094
+ }
9095
+ }
9096
+ }
9097
+
8665
9098
  const DEFAULT_CHUNK_SIZE = 256 * 1024;
8666
9099
  const DEFAULT_BLOCK_CHUNK_SIZE = 256 * 1024;
8667
9100
  const DEFAULT_BLOCK_CHUNK_THRESHOLD = 1024 * 1024;
@@ -8688,7 +9121,7 @@ class Peernet {
8688
9121
  root;
8689
9122
  identity;
8690
9123
  stores = [];
8691
- peerId;
9124
+ routingId;
8692
9125
  /**
8693
9126
  * @type {Object}
8694
9127
  * @property {Object} peer Instance of Peer
@@ -8723,6 +9156,7 @@ class Peernet {
8723
9156
  * @param {String} options.root - path to root directory
8724
9157
  * @param {String} options.version - path to root directory
8725
9158
  * @param {String} options.storePrefix - prefix for datatores (lfc)
9159
+ * @param {String} options.routingId - optional transport identity, separate from the wallet identity
8726
9160
  * @param {Object} options.transport - transport selection and fallback options passed to @netpeer/swarm
8727
9161
  *
8728
9162
  * @return {Promise} instance of Peernet
@@ -8735,6 +9169,9 @@ class Peernet {
8735
9169
  * @property {String} network - current network
8736
9170
  */
8737
9171
  this.network = options.network || 'leofcoin';
9172
+ if (options.routingId !== undefined && (typeof options.routingId !== 'string' || !options.routingId.trim()))
9173
+ throw new TypeError('routingId must be a non-empty string');
9174
+ this.routingId = options.routingId?.trim();
8738
9175
  this.autoStart = options.autoStart === undefined ? true : options.autoStart;
8739
9176
  this.stars = options.stars;
8740
9177
  this.transport = options.transport;
@@ -8766,6 +9203,9 @@ class Peernet {
8766
9203
  return this._init(options, password);
8767
9204
  }
8768
9205
  get id() {
9206
+ return this.routingId || this.identity.id;
9207
+ }
9208
+ get peerId() {
8769
9209
  return this.identity.id;
8770
9210
  }
8771
9211
  get selectedAccount() {
@@ -8787,6 +9227,9 @@ class Peernet {
8787
9227
  addCodec(codec) {
8788
9228
  return utils.addCodec(codec);
8789
9229
  }
9230
+ createArchiveMode(options) {
9231
+ return new PeernetArchiveMode({ ...options, peernet: this });
9232
+ }
8790
9233
  async addStore(name, prefix, root, isPrivate = true) {
8791
9234
  if (name === 'block' ||
8792
9235
  name === 'transaction' ||
@@ -8840,7 +9283,7 @@ class Peernet {
8840
9283
  await getAddress();
8841
9284
  this.storePrefix = options.storePrefix;
8842
9285
  this.root = options.root;
8843
- const { RequestMessage, ResponseMessage, PeerMessage, PeerMessageResponse, PeernetMessage, DHTMessage, DHTMessageResponse, DataMessage, DataMessageResponse, PsMessage, ChatMessage, PeernetFile } = await import(/* webpackChunkName: "messages" */ './messages-BGHaihSi.js');
9286
+ const { RequestMessage, ResponseMessage, PeerMessage, PeerMessageResponse, PeernetMessage, DHTMessage, DHTMessageResponse, DataMessage, DataMessageResponse, PsMessage, ChatMessage, PeernetFile } = await import(/* webpackChunkName: "messages" */ './messages-DCePyqKC.js');
8844
9287
  /**
8845
9288
  * proto Object containing protos
8846
9289
  * @type {Object}
@@ -8873,7 +9316,6 @@ class Peernet {
8873
9316
  this.identity = new Identity(this.network);
8874
9317
  await this.identity.load(password, options.freshIdentity === true);
8875
9318
  this._peerHandler = new PeerDiscovery(this.id);
8876
- this.peerId = this.id;
8877
9319
  /**
8878
9320
  * converts data -> message -> proto
8879
9321
  * @see DataHandler
@@ -8887,7 +9329,7 @@ class Peernet {
8887
9329
  if (this.#starting || this.#started)
8888
9330
  return;
8889
9331
  this.#starting = true;
8890
- const importee = await import('./client-C_pBoi1g.js');
9332
+ const importee = await import('./client-DKKzPSlb.js');
8891
9333
  /**
8892
9334
  * @access public
8893
9335
  * @type {PeernetClient}
@@ -9681,4 +10123,4 @@ class Peernet {
9681
10123
  }
9682
10124
  globalThis.Peernet = Peernet;
9683
10125
 
9684
- export { FormatInterface as F, LittlePubSub as L, Peernet as P, FileTransfer as a, createDebugger as c, deflate_1 as d, inflate_1 as i };
10126
+ export { FormatInterface as F, LittlePubSub as L, PeernetArchiveMode as P, FileTransfer as a, Peernet as b, createDebugger as c, deflate_1 as d, inflate_1 as i };
@@ -1,4 +1,4 @@
1
- export { a as FileTransfer, P as default } from './peernet-Db43ag2D.js';
1
+ export { a as FileTransfer, P as PeernetArchiveMode, b as default } from './peernet-DjddqiXc.js';
2
2
  import './identity-Cf8KKnho.js';
3
3
  import './value-C3vAp-wb.js';
4
4
  import 'crypto';
@@ -480,6 +480,439 @@ class FileTransfer {
480
480
  }
481
481
  }
482
482
 
483
+ const VERSION = 1;
484
+ const MAX_PACKET_BYTES = 64_000;
485
+ const MAX_ENVELOPE_BYTES = 32_000;
486
+ const MAX_ARCHIVES = 500;
487
+ const MAX_OUTBOX = 500;
488
+ const MAX_AGE = 30 * 24 * 60 * 60 * 1000;
489
+ const MAX_CLOCK_SKEW = 10 * 60 * 1000;
490
+ const MAX_HOPS = 8;
491
+ const encoder = new TextEncoder();
492
+ const decoder = new TextDecoder();
493
+ function isUnsubscribe(value) {
494
+ return typeof value === 'function';
495
+ }
496
+ function encode(value) {
497
+ return encoder.encode(JSON.stringify(value));
498
+ }
499
+ function decode(payload) {
500
+ if (!(payload instanceof Uint8Array))
501
+ throw new TypeError('Archive packets must be encoded bytes.');
502
+ if (payload.byteLength > MAX_PACKET_BYTES)
503
+ throw new RangeError('Archive packet exceeds the size limit.');
504
+ return JSON.parse(decoder.decode(payload));
505
+ }
506
+ function randomId() {
507
+ if (typeof crypto.randomUUID === 'function')
508
+ return crypto.randomUUID();
509
+ return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, '0')).join('');
510
+ }
511
+ function keyPrefix(network) {
512
+ return `peernet:archive:v1:${encodeURIComponent(network)}:`;
513
+ }
514
+ function recordKey(network, kind, id) {
515
+ return `${keyPrefix(network)}${kind}:${encodeURIComponent(id)}`;
516
+ }
517
+ function stateKey(network) {
518
+ return `${keyPrefix(network)}state`;
519
+ }
520
+ async function storeGet(store, key) {
521
+ if (store.has && !(await store.has(key)))
522
+ return undefined;
523
+ try {
524
+ return await store.get(key);
525
+ }
526
+ catch (error) {
527
+ if (error?.code === 'LEVEL_NOT_FOUND' ||
528
+ error?.name === 'NotFoundError')
529
+ return undefined;
530
+ throw error;
531
+ }
532
+ }
533
+ function validateEnvelope(envelope, network) {
534
+ const envelopeNetwork = envelope?.network ||
535
+ envelope?.senderCertificate?.network ||
536
+ envelope?.recipientCertificate?.network;
537
+ if (!envelope ||
538
+ typeof envelope !== 'object' ||
539
+ envelopeNetwork !== network ||
540
+ typeof envelope.id !== 'string' ||
541
+ !envelope.id ||
542
+ typeof envelope.recipientAccount !== 'string' ||
543
+ typeof envelope.recipientDeviceId !== 'string' ||
544
+ typeof envelope.ciphertext !== 'string' ||
545
+ !envelope.ciphertext ||
546
+ typeof envelope.signature !== 'string' ||
547
+ !Number.isFinite(envelope.timestamp) ||
548
+ envelope.timestamp < Date.now() - MAX_AGE ||
549
+ envelope.timestamp > Date.now() + MAX_CLOCK_SKEW)
550
+ throw new Error('Only a valid, network-scoped encrypted message envelope can be archived.');
551
+ if ('text' in envelope || 'plaintext' in envelope || 'message' in envelope)
552
+ throw new Error('Plaintext message fields cannot be archived.');
553
+ if (encode(envelope).byteLength > MAX_ENVELOPE_BYTES)
554
+ throw new RangeError('Encrypted message envelope exceeds the archive size limit.');
555
+ }
556
+ function validatePacket(value, network) {
557
+ if (!value || typeof value !== 'object' || Array.isArray(value))
558
+ return;
559
+ const packet = value;
560
+ if (packet.version !== VERSION ||
561
+ packet.network !== network ||
562
+ typeof packet.id !== 'string' ||
563
+ !packet.id ||
564
+ typeof packet.origin !== 'string' ||
565
+ !packet.origin ||
566
+ !Number.isFinite(packet.timestamp) ||
567
+ packet.timestamp < Date.now() - MAX_AGE ||
568
+ packet.timestamp > Date.now() + MAX_CLOCK_SKEW ||
569
+ !Number.isInteger(packet.hopsRemaining) ||
570
+ packet.hopsRemaining < 0 ||
571
+ packet.hopsRemaining > MAX_HOPS ||
572
+ !['direct', 'inbox-request', 'archive-delivery', 'delivery-receipt'].includes(packet.type) ||
573
+ !packet.payload ||
574
+ typeof packet.payload !== 'object' ||
575
+ Array.isArray(packet.payload))
576
+ return;
577
+ return packet;
578
+ }
579
+ class PeernetArchiveMode {
580
+ #peernet;
581
+ #store;
582
+ #network;
583
+ #topic;
584
+ #verifyEnvelope;
585
+ #verifyInboxRequest;
586
+ #verifyReceipt;
587
+ #handlers = new Set();
588
+ #seen = new Map();
589
+ #archives = new Set();
590
+ #outbox = new Set();
591
+ #enabled = false;
592
+ #started = false;
593
+ #unsubscribe;
594
+ #retryTimer;
595
+ #account = '';
596
+ #deviceId = '';
597
+ constructor(options) {
598
+ if (!options?.peernet?.network || typeof options.peernet.publish !== 'function' ||
599
+ typeof options.peernet.subscribe !== 'function')
600
+ throw new Error('A Peernet instance is required for archive mode.');
601
+ const store = options.store || globalThis.messageStore;
602
+ if (!store || typeof store.get !== 'function' || typeof store.put !== 'function' ||
603
+ typeof store.delete !== 'function' || typeof store.keys !== 'function')
604
+ throw new Error('A persistent Peernet message store is required for archive mode.');
605
+ if (typeof options.verifyEnvelope !== 'function' ||
606
+ typeof options.verifyInboxRequest !== 'function' ||
607
+ typeof options.verifyReceipt !== 'function')
608
+ throw new Error('Archive mode requires envelope, inbox-request and receipt verification functions.');
609
+ this.#peernet = options.peernet;
610
+ this.#store = store;
611
+ this.#network = options.peernet.network;
612
+ this.#topic = `peernet:archive:v1:${this.#network}`;
613
+ this.#verifyEnvelope = options.verifyEnvelope;
614
+ this.#verifyInboxRequest = options.verifyInboxRequest;
615
+ this.#verifyReceipt = options.verifyReceipt;
616
+ }
617
+ get network() {
618
+ return this.#network;
619
+ }
620
+ get enabled() {
621
+ return this.#enabled;
622
+ }
623
+ get archiveCount() {
624
+ return this.#archives.size;
625
+ }
626
+ get pendingCount() {
627
+ return this.#outbox.size;
628
+ }
629
+ onMessage(handler) {
630
+ if (typeof handler !== 'function')
631
+ throw new TypeError('An archive message handler is required.');
632
+ this.#handlers.add(handler);
633
+ return () => this.#handlers.delete(handler);
634
+ }
635
+ async start({ account, deviceId }) {
636
+ if (this.#started)
637
+ return;
638
+ if (!account || !deviceId)
639
+ throw new Error('A local account and chat device ID are required.');
640
+ this.#account = account;
641
+ this.#deviceId = deviceId;
642
+ await this.#load();
643
+ const receiver = (payload) => {
644
+ this.#receive(payload).catch((error) => console.error('Peernet archive packet failed:', error));
645
+ };
646
+ const unsubscribe = await this.#peernet.subscribe(this.#topic, receiver);
647
+ this.#unsubscribe = isUnsubscribe(unsubscribe)
648
+ ? unsubscribe
649
+ : typeof globalThis.globalSub?.unsubscribe === 'function'
650
+ ? () => globalThis.globalSub.unsubscribe(this.#topic, receiver)
651
+ : undefined;
652
+ this.#started = true;
653
+ this.#retryTimer = setInterval(() => {
654
+ this.#retryOutbox().catch((error) => console.error('Peernet archive retry failed:', error));
655
+ }, 30_000);
656
+ try {
657
+ for (const id of this.#outbox) {
658
+ const bytes = await storeGet(this.#store, recordKey(this.#network, 'outbox', id));
659
+ if (bytes)
660
+ await this.#publish('direct', { envelope: JSON.parse(decoder.decode(bytes)) });
661
+ }
662
+ }
663
+ catch (error) {
664
+ this.stop();
665
+ throw error;
666
+ }
667
+ }
668
+ stop() {
669
+ clearInterval(this.#retryTimer);
670
+ this.#retryTimer = undefined;
671
+ this.#unsubscribe?.();
672
+ this.#unsubscribe = undefined;
673
+ this.#started = false;
674
+ this.#account = '';
675
+ this.#deviceId = '';
676
+ }
677
+ async setEnabled(enabled) {
678
+ if (typeof enabled !== 'boolean')
679
+ throw new TypeError('Archive mode must be enabled or disabled explicitly.');
680
+ if (!this.#started)
681
+ await this.#load();
682
+ this.#enabled = enabled;
683
+ await this.#store.put(stateKey(this.#network), encode({ version: VERSION, enabled }));
684
+ if (!enabled) {
685
+ for (const id of this.#archives)
686
+ await this.#store.delete(recordKey(this.#network, 'archive', id));
687
+ this.#archives.clear();
688
+ }
689
+ }
690
+ async send(envelope) {
691
+ validateEnvelope(envelope, this.#network);
692
+ if (!(await this.#verifyEnvelope(envelope)))
693
+ throw new Error('Encrypted message signature verification failed.');
694
+ if (this.#outbox.has(envelope.id))
695
+ throw new Error('This encrypted message is already pending.');
696
+ await this.#store.put(recordKey(this.#network, 'outbox', envelope.id), encode(envelope));
697
+ this.#outbox.add(envelope.id);
698
+ if (this.#outbox.size > MAX_OUTBOX)
699
+ await this.#dropOldest('outbox');
700
+ await this.#publish('direct', { envelope });
701
+ }
702
+ async requestInbox(request) {
703
+ if (request.network !== this.#network ||
704
+ request.account !== this.#account ||
705
+ request.deviceId !== this.#deviceId ||
706
+ !(await this.#validInboxRequest(request)))
707
+ throw new Error('A valid signed inbox request for this Peernet network and device is required.');
708
+ await this.#publish('inbox-request', { request });
709
+ }
710
+ async acknowledge(receipt) {
711
+ if (receipt.network !== this.#network ||
712
+ receipt.recipientAccount !== this.#account ||
713
+ receipt.recipientDeviceId !== this.#deviceId ||
714
+ typeof receipt.signature !== 'string' ||
715
+ !Number.isFinite(receipt.timestamp) ||
716
+ Math.abs(Date.now() - receipt.timestamp) > MAX_CLOCK_SKEW)
717
+ throw new Error('Delivery receipt does not belong to this Peernet network and device.');
718
+ await this.#publish('delivery-receipt', { receipt });
719
+ }
720
+ async #load() {
721
+ const state = await storeGet(this.#store, stateKey(this.#network));
722
+ if (state) {
723
+ const saved = JSON.parse(decoder.decode(state));
724
+ if (saved?.version !== VERSION || typeof saved.enabled !== 'boolean')
725
+ throw new Error('Saved Peernet archive settings are invalid.');
726
+ this.#enabled = saved.enabled;
727
+ }
728
+ const prefix = keyPrefix(this.#network);
729
+ const keys = await this.#store.keys();
730
+ for (const keyInput of keys) {
731
+ const key = keyInput.toString().replace(/^\/+/, '');
732
+ if (!key.startsWith(prefix))
733
+ continue;
734
+ const archiveMarker = `${prefix}archive:`;
735
+ const outboxMarker = `${prefix}outbox:`;
736
+ if (key.startsWith(archiveMarker))
737
+ this.#archives.add(decodeURIComponent(key.slice(archiveMarker.length)));
738
+ if (key.startsWith(outboxMarker))
739
+ this.#outbox.add(decodeURIComponent(key.slice(outboxMarker.length)));
740
+ }
741
+ await this.#prune();
742
+ }
743
+ async #publish(type, payload, hopsRemaining = MAX_HOPS) {
744
+ if (!this.#started)
745
+ throw new Error('Peernet archive mode is not started.');
746
+ const packet = {
747
+ version: VERSION,
748
+ id: randomId(),
749
+ network: this.#network,
750
+ origin: this.#peernet.id,
751
+ timestamp: Date.now(),
752
+ hopsRemaining,
753
+ type,
754
+ payload
755
+ };
756
+ const bytes = encode(packet);
757
+ if (bytes.byteLength > MAX_PACKET_BYTES)
758
+ throw new RangeError('Peernet archive packet exceeds its size limit.');
759
+ this.#seen.set(packet.id, packet.timestamp);
760
+ await this.#peernet.publish(this.#topic, bytes);
761
+ }
762
+ async #receive(payload) {
763
+ const packet = validatePacket(decode(payload), this.#network);
764
+ if (!packet || packet.origin === this.#peernet.id || this.#wasSeen(packet.id))
765
+ return;
766
+ this.#markSeen(packet.id);
767
+ let forward = false;
768
+ switch (packet.type) {
769
+ case 'direct':
770
+ case 'archive-delivery': {
771
+ const envelope = packet.payload.envelope;
772
+ try {
773
+ validateEnvelope(envelope, this.#network);
774
+ }
775
+ catch {
776
+ break;
777
+ }
778
+ if (!(await this.#verifyEnvelope(envelope)))
779
+ break;
780
+ forward = true;
781
+ if (packet.type === 'direct' && this.#enabled)
782
+ await this.#saveArchive(envelope);
783
+ if (envelope.recipientAccount === this.#account && envelope.recipientDeviceId === this.#deviceId) {
784
+ for (const handler of this.#handlers)
785
+ handler({ type: 'message', envelope });
786
+ }
787
+ break;
788
+ }
789
+ case 'inbox-request': {
790
+ const request = packet.payload.request;
791
+ if (!(await this.#validInboxRequest(request)))
792
+ break;
793
+ forward = true;
794
+ if (this.#enabled && !(request.account === this.#account && request.deviceId === this.#deviceId))
795
+ await this.#deliverTo(request);
796
+ break;
797
+ }
798
+ case 'delivery-receipt': {
799
+ const receipt = packet.payload.receipt;
800
+ forward = await this.#receiveReceipt(receipt);
801
+ break;
802
+ }
803
+ }
804
+ if (forward && packet.hopsRemaining > 0) {
805
+ await this.#peernet.publish(this.#topic, encode({ ...packet, hopsRemaining: packet.hopsRemaining - 1 }));
806
+ }
807
+ }
808
+ async #validInboxRequest(request) {
809
+ return Boolean(request &&
810
+ request.network === this.#network &&
811
+ typeof request.account === 'string' &&
812
+ typeof request.deviceId === 'string' &&
813
+ Number.isFinite(request.timestamp) &&
814
+ Math.abs(Date.now() - request.timestamp) <= MAX_CLOCK_SKEW &&
815
+ typeof request.signature === 'string' &&
816
+ await this.#verifyInboxRequest(request));
817
+ }
818
+ async #saveArchive(envelope) {
819
+ if (this.#archives.has(envelope.id))
820
+ return;
821
+ await this.#store.put(recordKey(this.#network, 'archive', envelope.id), encode({
822
+ envelope,
823
+ storedAt: Date.now()
824
+ }));
825
+ this.#archives.add(envelope.id);
826
+ if (this.#archives.size > MAX_ARCHIVES)
827
+ await this.#dropOldest('archive');
828
+ }
829
+ async #deliverTo(request) {
830
+ for (const id of this.#archives) {
831
+ const bytes = await storeGet(this.#store, recordKey(this.#network, 'archive', id));
832
+ if (!bytes) {
833
+ this.#archives.delete(id);
834
+ continue;
835
+ }
836
+ const record = JSON.parse(decoder.decode(bytes));
837
+ const envelope = record?.envelope;
838
+ if (envelope?.recipientAccount === request.account && envelope.recipientDeviceId === request.deviceId)
839
+ await this.#publish('archive-delivery', { envelope });
840
+ }
841
+ }
842
+ async #receiveReceipt(receipt) {
843
+ if (!receipt || receipt.network !== this.#network ||
844
+ typeof receipt.messageId !== 'string' ||
845
+ !Number.isFinite(receipt.timestamp) ||
846
+ Math.abs(Date.now() - receipt.timestamp) > MAX_CLOCK_SKEW)
847
+ return false;
848
+ const keys = ['archive', 'outbox'];
849
+ let verified = false;
850
+ for (const kind of keys) {
851
+ const key = recordKey(this.#network, kind, receipt.messageId);
852
+ const bytes = await storeGet(this.#store, key);
853
+ if (!bytes)
854
+ continue;
855
+ const stored = JSON.parse(decoder.decode(bytes));
856
+ const envelope = kind === 'archive' ? stored.envelope : stored;
857
+ if (!(await this.#verifyReceipt(receipt, envelope)))
858
+ continue;
859
+ await this.#store.delete(key);
860
+ (kind === 'archive' ? this.#archives : this.#outbox).delete(receipt.messageId);
861
+ verified = true;
862
+ }
863
+ return verified;
864
+ }
865
+ async #retryOutbox() {
866
+ if (!this.#started)
867
+ return;
868
+ await this.#prune();
869
+ for (const id of this.#outbox) {
870
+ const bytes = await storeGet(this.#store, recordKey(this.#network, 'outbox', id));
871
+ if (bytes)
872
+ await this.#publish('direct', { envelope: JSON.parse(decoder.decode(bytes)) });
873
+ }
874
+ }
875
+ async #prune() {
876
+ const cutoff = Date.now() - MAX_AGE;
877
+ for (const [kind, ids] of [['archive', this.#archives], ['outbox', this.#outbox]]) {
878
+ for (const id of ids) {
879
+ const key = recordKey(this.#network, kind, id);
880
+ const bytes = await storeGet(this.#store, key);
881
+ if (!bytes) {
882
+ ids.delete(id);
883
+ continue;
884
+ }
885
+ const saved = JSON.parse(decoder.decode(bytes));
886
+ const timestamp = kind === 'archive' ? saved.storedAt : saved.timestamp;
887
+ if (!Number.isFinite(timestamp) || timestamp < cutoff) {
888
+ await this.#store.delete(key);
889
+ ids.delete(id);
890
+ }
891
+ }
892
+ }
893
+ }
894
+ async #dropOldest(kind) {
895
+ const ids = kind === 'archive' ? this.#archives : this.#outbox;
896
+ const id = ids.values().next().value;
897
+ if (!id)
898
+ return;
899
+ await this.#store.delete(recordKey(this.#network, kind, id));
900
+ ids.delete(id);
901
+ }
902
+ #wasSeen(id) {
903
+ const timestamp = this.#seen.get(id);
904
+ return timestamp !== undefined && Date.now() - timestamp < MAX_AGE;
905
+ }
906
+ #markSeen(id) {
907
+ this.#seen.set(id, Date.now());
908
+ if (this.#seen.size > 4_000) {
909
+ const oldestId = this.#seen.keys().next().value;
910
+ if (oldestId !== undefined)
911
+ this.#seen.delete(oldestId);
912
+ }
913
+ }
914
+ }
915
+
483
916
  const DEFAULT_CHUNK_SIZE = 256 * 1024;
484
917
  const DEFAULT_BLOCK_CHUNK_SIZE = 256 * 1024;
485
918
  const DEFAULT_BLOCK_CHUNK_THRESHOLD = 1024 * 1024;
@@ -506,7 +939,7 @@ class Peernet {
506
939
  root;
507
940
  identity;
508
941
  stores = [];
509
- peerId;
942
+ routingId;
510
943
  /**
511
944
  * @type {Object}
512
945
  * @property {Object} peer Instance of Peer
@@ -541,6 +974,7 @@ class Peernet {
541
974
  * @param {String} options.root - path to root directory
542
975
  * @param {String} options.version - path to root directory
543
976
  * @param {String} options.storePrefix - prefix for datatores (lfc)
977
+ * @param {String} options.routingId - optional transport identity, separate from the wallet identity
544
978
  * @param {Object} options.transport - transport selection and fallback options passed to @netpeer/swarm
545
979
  *
546
980
  * @return {Promise} instance of Peernet
@@ -553,6 +987,9 @@ class Peernet {
553
987
  * @property {String} network - current network
554
988
  */
555
989
  this.network = options.network || 'leofcoin';
990
+ if (options.routingId !== undefined && (typeof options.routingId !== 'string' || !options.routingId.trim()))
991
+ throw new TypeError('routingId must be a non-empty string');
992
+ this.routingId = options.routingId?.trim();
556
993
  this.autoStart = options.autoStart === undefined ? true : options.autoStart;
557
994
  this.stars = options.stars;
558
995
  this.transport = options.transport;
@@ -584,6 +1021,9 @@ class Peernet {
584
1021
  return this._init(options, password);
585
1022
  }
586
1023
  get id() {
1024
+ return this.routingId || this.identity.id;
1025
+ }
1026
+ get peerId() {
587
1027
  return this.identity.id;
588
1028
  }
589
1029
  get selectedAccount() {
@@ -605,6 +1045,9 @@ class Peernet {
605
1045
  addCodec(codec) {
606
1046
  return utils.addCodec(codec);
607
1047
  }
1048
+ createArchiveMode(options) {
1049
+ return new PeernetArchiveMode({ ...options, peernet: this });
1050
+ }
608
1051
  async addStore(name, prefix, root, isPrivate = true) {
609
1052
  if (name === 'block' ||
610
1053
  name === 'transaction' ||
@@ -691,7 +1134,6 @@ class Peernet {
691
1134
  this.identity = new Identity(this.network);
692
1135
  await this.identity.load(password, options.freshIdentity === true);
693
1136
  this._peerHandler = new PeerDiscovery(this.id);
694
- this.peerId = this.id;
695
1137
  /**
696
1138
  * converts data -> message -> proto
697
1139
  * @see DataHandler
@@ -1499,4 +1941,4 @@ class Peernet {
1499
1941
  }
1500
1942
  globalThis.Peernet = Peernet;
1501
1943
 
1502
- export { FileTransfer, Peernet as default };
1944
+ export { FileTransfer, PeernetArchiveMode, Peernet as default };
package/exports/store.js CHANGED
@@ -102,6 +102,9 @@ class Store {
102
102
  this.root = await init(root, homedir);
103
103
  this.version = version;
104
104
  this.db = new ClassicLevel(join(this.root, this.name), { valueEncoding: 'view' });
105
+ if (typeof this.db.open === 'function') {
106
+ await this.db.open();
107
+ }
105
108
  }
106
109
  toKeyPath(key) {
107
110
  if (!key.isKeyPath())
@@ -0,0 +1,69 @@
1
+ export type ArchivedEnvelope = {
2
+ id: string;
3
+ network?: string;
4
+ recipientAccount: string;
5
+ recipientDeviceId: string;
6
+ ciphertext: string;
7
+ timestamp: number;
8
+ [key: string]: unknown;
9
+ };
10
+ export type InboxRequest = {
11
+ account: string;
12
+ deviceId: string;
13
+ network: string;
14
+ timestamp: number;
15
+ signature: string;
16
+ [key: string]: unknown;
17
+ };
18
+ export type DeliveryReceipt = {
19
+ messageId: string;
20
+ recipientAccount: string;
21
+ recipientDeviceId: string;
22
+ network: string;
23
+ timestamp: number;
24
+ signature: string;
25
+ [key: string]: unknown;
26
+ };
27
+ export type ArchiveStore = {
28
+ has?(key: string): Promise<boolean | unknown[]>;
29
+ get(key: string): Promise<Uint8Array>;
30
+ put(key: string, value: Uint8Array): Promise<unknown>;
31
+ delete(key: string): Promise<unknown>;
32
+ keys(): Promise<Array<string | {
33
+ toString(): string;
34
+ }>>;
35
+ };
36
+ export type ArchivePeernet = {
37
+ network: string;
38
+ id: string;
39
+ publish(topic: string, payload: Uint8Array): Promise<unknown>;
40
+ subscribe(topic: string, callback: (payload: Uint8Array) => void): Promise<unknown>;
41
+ };
42
+ export type ArchiveModeOptions = {
43
+ peernet: ArchivePeernet;
44
+ store?: ArchiveStore;
45
+ verifyEnvelope: (envelope: ArchivedEnvelope) => Promise<boolean>;
46
+ verifyInboxRequest: (request: InboxRequest) => Promise<boolean>;
47
+ verifyReceipt: (receipt: DeliveryReceipt, envelope: ArchivedEnvelope) => Promise<boolean>;
48
+ };
49
+ export declare class PeernetArchiveMode {
50
+ #private;
51
+ constructor(options: ArchiveModeOptions);
52
+ get network(): string;
53
+ get enabled(): boolean;
54
+ get archiveCount(): number;
55
+ get pendingCount(): number;
56
+ onMessage(handler: (event: {
57
+ type: 'message';
58
+ envelope: ArchivedEnvelope;
59
+ }) => void): () => void;
60
+ start({ account, deviceId }: {
61
+ account: string;
62
+ deviceId: string;
63
+ }): Promise<void>;
64
+ stop(): void;
65
+ setEnabled(enabled: boolean): Promise<void>;
66
+ send(envelope: ArchivedEnvelope): Promise<void>;
67
+ requestInbox(request: InboxRequest): Promise<void>;
68
+ acknowledge(receipt: DeliveryReceipt): Promise<void>;
69
+ }
@@ -6,6 +6,7 @@ import { Storage as LeofcoinStorageClass } from '@leofcoin/storage';
6
6
  import Identity from './identity.js';
7
7
  import swarm from '@netpeer/swarm/client';
8
8
  import FileTransfer from './file-transfer.js';
9
+ import { PeernetArchiveMode, type ArchiveModeOptions } from './archive.js';
9
10
  declare global {
10
11
  var LeofcoinStorage: typeof LeofcoinStorageClass;
11
12
  var peernet: Peernet;
@@ -30,7 +31,7 @@ export default class Peernet {
30
31
  root: string;
31
32
  identity: Identity;
32
33
  stores: string[];
33
- peerId: string;
34
+ readonly routingId?: string;
34
35
  /**
35
36
  * @type {Object}
36
37
  * @property {Object} peer Instance of Peer
@@ -65,6 +66,7 @@ export default class Peernet {
65
66
  * @param {String} options.root - path to root directory
66
67
  * @param {String} options.version - path to root directory
67
68
  * @param {String} options.storePrefix - prefix for datatores (lfc)
69
+ * @param {String} options.routingId - optional transport identity, separate from the wallet identity
68
70
  * @param {Object} options.transport - transport selection and fallback options passed to @netpeer/swarm
69
71
  *
70
72
  * @return {Promise} instance of Peernet
@@ -74,12 +76,14 @@ export default class Peernet {
74
76
  */
75
77
  constructor(options: any, password: any);
76
78
  get id(): string;
79
+ get peerId(): string;
77
80
  get selectedAccount(): string;
78
81
  get accounts(): Promise<[[name: string, externalAddress: string, internalAddress: string]]>;
79
82
  get defaultStores(): string[];
80
83
  selectAccount(account: string): Promise<unknown>;
81
84
  addProto(name: any, proto: any): void;
82
85
  addCodec(codec: any): void;
86
+ createArchiveMode(options: Omit<ArchiveModeOptions, 'peernet'>): PeernetArchiveMode;
83
87
  addStore(name: any, prefix: any, root: any, isPrivate?: boolean): Promise<void>;
84
88
  /**
85
89
  * @see MessageHandler
@@ -362,3 +366,5 @@ export default class Peernet {
362
366
  get Buffer(): BufferConstructor;
363
367
  }
364
368
  export { FileTransfer };
369
+ export { PeernetArchiveMode };
370
+ export type { ArchivedEnvelope, DeliveryReceipt, InboxRequest } from './archive.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leofcoin/peernet",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -100,7 +100,7 @@
100
100
  "scripts": {
101
101
  "build": "rollup -c",
102
102
  "watch": "rollup -c -w",
103
- "test": "node --test test/peernet.test.js test/file-transfer.test.js",
103
+ "test": "node --test test/peernet.test.js test/file-transfer.test.js test/archive.test.js",
104
104
  "test:package": "node test/standalone-install.js",
105
105
  "server": "discovery-swarm-webrtc --port=4000",
106
106
  "demo": "jsproject --serve ./ --port 6868"