@decentrl/event-store 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,341 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('@decentrl/crypto', () => ({
4
+ base64Decode: vi.fn((s: string) => new TextEncoder().encode(s)),
5
+ base64Encode: vi.fn((u: Uint8Array) => new TextDecoder().decode(u)),
6
+ decryptString: vi.fn(),
7
+ encryptString: vi.fn((plaintext: string) => `encrypted:${plaintext}`),
8
+ generateEncryptedTag: vi.fn((_key: unknown, tag: string) => `etag:${tag}`),
9
+ signJsonObject: vi.fn(() => 'mock-signature'),
10
+ verifyJsonSignature: vi.fn(() => true),
11
+ multibaseDecode: vi.fn(() => new Uint8Array(32)),
12
+ }));
13
+
14
+ vi.mock(
15
+ '@decentrl/identity/communication-channels/mediator/direct-authenticated/command/command.service',
16
+ () => ({
17
+ generateDirectAuthenticatedMediatorCommand: vi.fn(() => ({ mock: 'command' })),
18
+ generateTwoWayPrivateMediatorCommand: vi.fn(() => ({ mock: 'two-way-command' })),
19
+ }),
20
+ );
21
+
22
+ vi.mock('axios', () => ({
23
+ default: {
24
+ post: vi.fn(async () => ({ data: { type: 'SUCCESS', payload: {} } })),
25
+ },
26
+ }));
27
+
28
+ import { decryptString } from '@decentrl/crypto';
29
+ import { DecentrlEventStore } from './event-store.js';
30
+ import type { StoredSignedContract } from './types.js';
31
+
32
+ const makeContract = (opts: {
33
+ id: string;
34
+ participantDid: string;
35
+ expiresAt: number;
36
+ timestamp: number;
37
+ rootSecret?: string;
38
+ }): StoredSignedContract => ({
39
+ id: opts.id,
40
+ participantDid: opts.participantDid,
41
+ signedCommunicationContract: {
42
+ communication_contract: {
43
+ requestor_did: 'did:decentrl:alice',
44
+ recipient_did: opts.participantDid,
45
+ requestor_signing_key_id: 'did:decentrl:alice#signing',
46
+ recipient_signing_key_id: `${opts.participantDid}#signing`,
47
+ requestor_encryption_public_key: 'key-a',
48
+ recipient_encryption_public_key: 'key-b',
49
+ expires_at: opts.expiresAt,
50
+ timestamp: opts.timestamp,
51
+ },
52
+ requestor_signature: 'sig-a',
53
+ recipient_signature: 'sig-b',
54
+ },
55
+ rootSecret: opts.rootSecret ?? 'secret-key',
56
+ });
57
+
58
+ const mockIdentity = {
59
+ did: 'did:decentrl:alice',
60
+ keys: {
61
+ signing: {
62
+ privateKey: new Uint8Array(32),
63
+ publicKey: new Uint8Array(32),
64
+ },
65
+ encryption: {
66
+ privateKey: new Uint8Array(32),
67
+ publicKey: new Uint8Array(32),
68
+ },
69
+ storageKey: new Uint8Array(32),
70
+ },
71
+ mediatorEndpoint: 'http://mediator',
72
+ mediatorDid: 'did:web:mediator',
73
+ };
74
+
75
+ describe('DecentrlEventStore', () => {
76
+ describe('processPreFetchedPendingEvents', () => {
77
+ it('tries multiple contracts for decryption', async () => {
78
+ const now = Math.floor(Date.now() / 1000);
79
+
80
+ const oldContract = makeContract({
81
+ id: 'old-contract',
82
+ participantDid: 'did:decentrl:bob',
83
+ expiresAt: now + 3600,
84
+ timestamp: now,
85
+ rootSecret: 'old-secret',
86
+ });
87
+
88
+ const newContract = makeContract({
89
+ id: 'new-contract',
90
+ participantDid: 'did:decentrl:bob',
91
+ expiresAt: now + 7200,
92
+ timestamp: now + 100,
93
+ rootSecret: 'new-secret',
94
+ });
95
+
96
+ const mockDecrypt = vi.mocked(decryptString);
97
+ mockDecrypt
98
+ .mockImplementationOnce(() => {
99
+ // First attempt with new-contract key fails
100
+ throw new Error('decryption failed');
101
+ })
102
+ .mockImplementationOnce(() => {
103
+ // Second attempt with old-contract key succeeds
104
+ return JSON.stringify({
105
+ contract_id: 'old-contract',
106
+ event: JSON.stringify({ type: 'test', data: 'hello' }),
107
+ timestamp: now,
108
+ signature: 'mock-signature',
109
+ });
110
+ });
111
+
112
+ const eventStore = new DecentrlEventStore({
113
+ identity: mockIdentity,
114
+ communicationContracts: () => [oldContract, newContract],
115
+ });
116
+
117
+ const rawEvents = [
118
+ {
119
+ id: 'event-1',
120
+ sender_did: 'did:decentrl:bob',
121
+ payload: 'encrypted-payload',
122
+ },
123
+ ];
124
+
125
+ const result = await eventStore.processPreFetchedPendingEvents(rawEvents);
126
+
127
+ expect(result).toHaveLength(1);
128
+ expect(mockDecrypt).toHaveBeenCalledTimes(2);
129
+ });
130
+
131
+ it('skips events from unknown senders', async () => {
132
+ const now = Math.floor(Date.now() / 1000);
133
+
134
+ const contract = makeContract({
135
+ id: 'c1',
136
+ participantDid: 'did:decentrl:bob',
137
+ expiresAt: now + 3600,
138
+ timestamp: now,
139
+ });
140
+
141
+ const eventStore = new DecentrlEventStore({
142
+ identity: mockIdentity,
143
+ communicationContracts: () => [contract],
144
+ });
145
+
146
+ const rawEvents = [
147
+ {
148
+ id: 'event-1',
149
+ sender_did: 'did:decentrl:unknown',
150
+ payload: 'encrypted-payload',
151
+ },
152
+ ];
153
+
154
+ const result = await eventStore.processPreFetchedPendingEvents(rawEvents);
155
+
156
+ expect(result).toHaveLength(0);
157
+ });
158
+
159
+ it('calls onContractUsed when event is successfully decrypted', async () => {
160
+ const now = Math.floor(Date.now() / 1000);
161
+
162
+ const contract = makeContract({
163
+ id: 'test-contract',
164
+ participantDid: 'did:decentrl:bob',
165
+ expiresAt: now + 3600,
166
+ timestamp: now,
167
+ });
168
+
169
+ const mockDecrypt = vi.mocked(decryptString);
170
+ mockDecrypt.mockReturnValue(
171
+ JSON.stringify({
172
+ contract_id: 'test-contract',
173
+ event: JSON.stringify({ type: 'test', data: 'hello' }),
174
+ timestamp: now,
175
+ signature: 'mock-signature',
176
+ }),
177
+ );
178
+
179
+ const onContractUsed = vi.fn();
180
+
181
+ const eventStore = new DecentrlEventStore({
182
+ identity: mockIdentity,
183
+ communicationContracts: () => [contract],
184
+ onContractUsed,
185
+ });
186
+
187
+ const rawEvents = [
188
+ {
189
+ id: 'event-1',
190
+ sender_did: 'did:decentrl:bob',
191
+ payload: 'encrypted-payload',
192
+ },
193
+ ];
194
+
195
+ await eventStore.processPreFetchedPendingEvents(rawEvents);
196
+
197
+ expect(onContractUsed).toHaveBeenCalledWith('test-contract', 'did:decentrl:bob');
198
+ });
199
+
200
+ it('returns empty array when no active contracts exist', async () => {
201
+ const eventStore = new DecentrlEventStore({
202
+ identity: mockIdentity,
203
+ communicationContracts: () => [],
204
+ });
205
+
206
+ const rawEvents = [
207
+ {
208
+ id: 'event-1',
209
+ sender_did: 'did:decentrl:bob',
210
+ payload: 'encrypted-payload',
211
+ },
212
+ ];
213
+
214
+ const result = await eventStore.processPreFetchedPendingEvents(rawEvents);
215
+ expect(result).toHaveLength(0);
216
+ });
217
+
218
+ it('skips events that no contract can decrypt', async () => {
219
+ const now = Math.floor(Date.now() / 1000);
220
+
221
+ const contract = makeContract({
222
+ id: 'c1',
223
+ participantDid: 'did:decentrl:bob',
224
+ expiresAt: now + 3600,
225
+ timestamp: now,
226
+ });
227
+
228
+ const mockDecrypt = vi.mocked(decryptString);
229
+ mockDecrypt.mockImplementation(() => {
230
+ throw new Error('decryption failed');
231
+ });
232
+
233
+ const eventStore = new DecentrlEventStore({
234
+ identity: mockIdentity,
235
+ communicationContracts: () => [contract],
236
+ });
237
+
238
+ const rawEvents = [
239
+ {
240
+ id: 'event-1',
241
+ sender_did: 'did:decentrl:bob',
242
+ payload: 'encrypted-payload',
243
+ },
244
+ ];
245
+
246
+ const result = await eventStore.processPreFetchedPendingEvents(rawEvents);
247
+ expect(result).toHaveLength(0);
248
+ });
249
+
250
+ it('prefers contract with latest expires_at for decryption', async () => {
251
+ const now = Math.floor(Date.now() / 1000);
252
+
253
+ const oldContract = makeContract({
254
+ id: 'old',
255
+ participantDid: 'did:decentrl:bob',
256
+ expiresAt: now + 3600,
257
+ timestamp: now,
258
+ rootSecret: 'old-key',
259
+ });
260
+
261
+ const newContract = makeContract({
262
+ id: 'new',
263
+ participantDid: 'did:decentrl:bob',
264
+ expiresAt: now + 7200,
265
+ timestamp: now + 100,
266
+ rootSecret: 'new-key',
267
+ });
268
+
269
+ const decryptCalls: string[] = [];
270
+ const mockDecrypt = vi.mocked(decryptString);
271
+ mockDecrypt.mockImplementation((_payload, key) => {
272
+ const keyStr = new TextDecoder().decode(key as Uint8Array);
273
+ decryptCalls.push(keyStr);
274
+
275
+ if (keyStr === 'new-key') {
276
+ return JSON.stringify({
277
+ contract_id: 'new',
278
+ event: JSON.stringify({ type: 'test' }),
279
+ timestamp: now,
280
+ signature: 'mock-signature',
281
+ });
282
+ }
283
+
284
+ throw new Error('wrong key');
285
+ });
286
+
287
+ const eventStore = new DecentrlEventStore({
288
+ identity: mockIdentity,
289
+ communicationContracts: () => [oldContract, newContract],
290
+ });
291
+
292
+ const rawEvents = [
293
+ {
294
+ id: 'event-1',
295
+ sender_did: 'did:decentrl:bob',
296
+ payload: 'encrypted-payload',
297
+ },
298
+ ];
299
+
300
+ await eventStore.processPreFetchedPendingEvents(rawEvents);
301
+
302
+ // Should try new-key first (latest expires_at)
303
+ expect(decryptCalls[0]).toBe('new-key');
304
+ });
305
+ });
306
+
307
+ describe('publishEvent', () => {
308
+ beforeEach(() => {
309
+ vi.mocked(decryptString).mockReset();
310
+ });
311
+
312
+ it('stores locally for non-ephemeral events', async () => {
313
+ const axios = await import('axios');
314
+
315
+ const eventStore = new DecentrlEventStore({
316
+ identity: mockIdentity,
317
+ communicationContracts: () => [],
318
+ });
319
+
320
+ await eventStore.publishEvent({ type: 'test' }, { tags: ['tag1'] });
321
+
322
+ // Should have called axios.post for SAVE_EVENTS
323
+ expect(axios.default.post).toHaveBeenCalled();
324
+ });
325
+
326
+ it('skips local storage for ephemeral events without recipient', async () => {
327
+ const axios = await import('axios');
328
+ vi.mocked(axios.default.post).mockClear();
329
+
330
+ const eventStore = new DecentrlEventStore({
331
+ identity: mockIdentity,
332
+ communicationContracts: () => [],
333
+ });
334
+
335
+ await eventStore.publishEvent({ type: 'test' }, { tags: ['tag1'], ephemeral: true });
336
+
337
+ // Should NOT have called axios.post since no recipient and ephemeral
338
+ expect(axios.default.post).not.toHaveBeenCalled();
339
+ });
340
+ });
341
+ });