@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,574 @@
1
+ import {
2
+ base64Decode,
3
+ decryptString,
4
+ encryptString,
5
+ generateEncryptedTag,
6
+ multibaseDecode,
7
+ signJsonObject,
8
+ verifyJsonSignature,
9
+ } from '@decentrl/crypto';
10
+ import type { AcknowledgePendingEventsMediatorCommandResponse } from '@decentrl/identity/communication-channels/mediator/direct-authenticated/command/acknowledge-pending-events.schema';
11
+ import {
12
+ generateDirectAuthenticatedMediatorCommand,
13
+ generateTwoWayPrivateMediatorCommand,
14
+ } from '@decentrl/identity/communication-channels/mediator/direct-authenticated/command/command.service';
15
+ import type { QueryEventsMediatorCommandResponse } from '@decentrl/identity/communication-channels/mediator/direct-authenticated/command/query-events.schema';
16
+ import type { QueryPendingEventsMediatorCommandResponse } from '@decentrl/identity/communication-channels/mediator/direct-authenticated/command/query-pending-events.schema';
17
+ import type { SaveEventsMediatorCommandResponse } from '@decentrl/identity/communication-channels/mediator/direct-authenticated/command/save-events.schema';
18
+ import type { UpdateEventTagsMediatorCommandResponse } from '@decentrl/identity/communication-channels/mediator/direct-authenticated/command/update-event-tags.schema';
19
+ import axiosModule from 'axios';
20
+ import {
21
+ type EventStoreConfig,
22
+ EventStoreError,
23
+ type PaginatedResult,
24
+ type PublishOptions,
25
+ type QueryOptions,
26
+ } from './types';
27
+
28
+ const DEFAULT_TIMEOUT_MS = 30_000;
29
+ const axios = {
30
+ post: <T>(url: string, data: unknown) =>
31
+ axiosModule.post<T>(url, data, { timeout: DEFAULT_TIMEOUT_MS }),
32
+ };
33
+
34
+ export class DecentrlEventStore {
35
+ constructor(private config: EventStoreConfig) {}
36
+
37
+ /**
38
+ * Publish an event - stores locally and optionally sends to recipient
39
+ */
40
+ async publishEvent<T>(event: T, options: PublishOptions): Promise<void> {
41
+ try {
42
+ // 1. Handle recipient delivery (if specified)
43
+ if (options.recipient) {
44
+ await this.sendToRecipient(event, options.recipient);
45
+ }
46
+
47
+ // 2. Store locally for own queries (skip for ephemeral events)
48
+ if (!options.ephemeral) {
49
+ await this.storeLocally(event, options.tags, options.recipient);
50
+ }
51
+ } catch (error) {
52
+ throw new EventStoreError(
53
+ `Failed to publish event: ${error instanceof Error ? error.message : String(error)}`,
54
+ 'PUBLISH_FAILED',
55
+ { event, options, error },
56
+ );
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Query stored events from mediator
62
+ */
63
+ async queryEvents<T>(options: QueryOptions = {}): Promise<PaginatedResult<T>> {
64
+ try {
65
+ const { identity } = this.config;
66
+ const identityKeys = identity.keys;
67
+
68
+ const encryptedTags = options.tags?.map((tag) =>
69
+ generateEncryptedTag(identityKeys.signing.privateKey, tag),
70
+ );
71
+
72
+ const page = options.pagination?.page ?? 0;
73
+ const pageSize = options.pagination?.pageSize ?? 100;
74
+
75
+ const queryCommand = generateDirectAuthenticatedMediatorCommand(
76
+ identity.did,
77
+ `${identity.did}#signing`,
78
+ identity.mediatorDid,
79
+ {
80
+ type: 'QUERY_EVENTS',
81
+ filter: {
82
+ encrypted_tags: encryptedTags,
83
+ participant_did: options.participantDid,
84
+ after_timestamp: options.afterTimestamp,
85
+ before_timestamp: options.beforeTimestamp,
86
+ unprocessed_only: options.unprocessedOnly,
87
+ },
88
+ pagination: { page, page_size: pageSize },
89
+ },
90
+ identityKeys,
91
+ );
92
+
93
+ const response = await axios.post<QueryEventsMediatorCommandResponse>(
94
+ identity.mediatorEndpoint,
95
+ queryCommand,
96
+ );
97
+
98
+ if (response.data.type !== 'SUCCESS') {
99
+ throw new EventStoreError('Query failed', 'QUERY_FAILED', response.data);
100
+ }
101
+
102
+ const storageKey = identityKeys.storageKey;
103
+
104
+ const data: T[] = [];
105
+
106
+ for (const event of response.data.payload.events) {
107
+ try {
108
+ const decrypted = decryptString(event.payload, storageKey);
109
+ data.push(JSON.parse(decrypted) as T);
110
+ } catch (error) {
111
+ console.warn(
112
+ `[EventStore] Decryption failure for event: ${error instanceof Error ? error.message : String(error)}`,
113
+ );
114
+ }
115
+ }
116
+
117
+ return {
118
+ data,
119
+ pagination: {
120
+ page: response.data.payload.pagination.page,
121
+ pageSize: response.data.payload.pagination.page_size,
122
+ total: response.data.payload.pagination.total,
123
+ },
124
+ };
125
+ } catch (error) {
126
+ if (error instanceof EventStoreError) {
127
+ throw error;
128
+ }
129
+
130
+ throw new EventStoreError(
131
+ `Failed to query events: ${error instanceof Error ? error.message : String(error)}`,
132
+ 'QUERY_FAILED',
133
+ { options, error },
134
+ );
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Process pending events from other participants
140
+ */
141
+ async processPendingEvents<T>(): Promise<T[]> {
142
+ try {
143
+ const { identity, communicationContracts } = this.config;
144
+ const activeContracts = communicationContracts();
145
+
146
+ if (activeContracts.length === 0) {
147
+ return [];
148
+ }
149
+
150
+ // Query pending events
151
+ const queryCommand = generateDirectAuthenticatedMediatorCommand(
152
+ identity.did,
153
+ `${identity.did}#signing`,
154
+ identity.mediatorDid,
155
+ {
156
+ type: 'QUERY_PENDING_EVENTS',
157
+ filter: {},
158
+ pagination: { page: 0, page_size: 100 },
159
+ },
160
+ identity.keys,
161
+ );
162
+
163
+ const response = await axios.post<QueryPendingEventsMediatorCommandResponse>(
164
+ identity.mediatorEndpoint,
165
+ queryCommand,
166
+ );
167
+
168
+ if (response.data.type !== 'SUCCESS') {
169
+ throw new EventStoreError(
170
+ 'Failed to query pending events',
171
+ 'PENDING_QUERY_FAILED',
172
+ response.data,
173
+ );
174
+ }
175
+
176
+ return await this.decryptStoreAndAck<T>(response.data.payload.pending_events);
177
+ } catch (error) {
178
+ if (error instanceof EventStoreError) {
179
+ throw error;
180
+ }
181
+
182
+ throw new EventStoreError(
183
+ `Failed to process pending events: ${error instanceof Error ? error.message : String(error)}`,
184
+ 'PENDING_PROCESS_FAILED',
185
+ { error },
186
+ );
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Process pre-fetched pending events (e.g. from WebSocket push).
192
+ * Same logic as processPendingEvents but skips the HTTP query.
193
+ */
194
+ async processPreFetchedPendingEvents<T>(
195
+ rawEvents: Array<{ id: string; sender_did: string; payload: string }>,
196
+ ): Promise<T[]> {
197
+ try {
198
+ return await this.decryptStoreAndAck<T>(rawEvents);
199
+ } catch (error) {
200
+ if (error instanceof EventStoreError) {
201
+ throw error;
202
+ }
203
+
204
+ throw new EventStoreError(
205
+ `Failed to process pre-fetched pending events: ${error instanceof Error ? error.message : String(error)}`,
206
+ 'PENDING_PROCESS_FAILED',
207
+ { error },
208
+ );
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Update event tags on the mediator (idempotent replace).
214
+ */
215
+ async updateEventTags(
216
+ events: Array<{ eventId: string; encryptedTags: string[] }>,
217
+ ): Promise<void> {
218
+ const { identity } = this.config;
219
+
220
+ const command = generateDirectAuthenticatedMediatorCommand(
221
+ identity.did,
222
+ `${identity.did}#signing`,
223
+ identity.mediatorDid,
224
+ {
225
+ type: 'UPDATE_EVENT_TAGS',
226
+ events: events.map((e) => ({
227
+ event_id: e.eventId,
228
+ encrypted_tags: e.encryptedTags,
229
+ })),
230
+ },
231
+ identity.keys,
232
+ );
233
+
234
+ await axios.post<UpdateEventTagsMediatorCommandResponse>(identity.mediatorEndpoint, command);
235
+ }
236
+
237
+ /**
238
+ * Query events that haven't been processed by the app yet.
239
+ * Returns mediator event IDs alongside decrypted data for tag updates.
240
+ */
241
+ async queryUnprocessedEvents<T>(pagination?: {
242
+ page: number;
243
+ pageSize: number;
244
+ }): Promise<PaginatedResult<T & { _mediatorEventId?: string }>> {
245
+ try {
246
+ const { identity } = this.config;
247
+ const identityKeys = identity.keys;
248
+
249
+ const page = pagination?.page ?? 0;
250
+ const pageSize = pagination?.pageSize ?? 100;
251
+
252
+ const queryCommand = generateDirectAuthenticatedMediatorCommand(
253
+ identity.did,
254
+ `${identity.did}#signing`,
255
+ identity.mediatorDid,
256
+ {
257
+ type: 'QUERY_EVENTS',
258
+ filter: { unprocessed_only: true },
259
+ pagination: { page, page_size: pageSize },
260
+ },
261
+ identityKeys,
262
+ );
263
+
264
+ const response = await axios.post<QueryEventsMediatorCommandResponse>(
265
+ identity.mediatorEndpoint,
266
+ queryCommand,
267
+ );
268
+
269
+ if (response.data.type !== 'SUCCESS') {
270
+ throw new EventStoreError('Query failed', 'QUERY_FAILED', response.data);
271
+ }
272
+
273
+ const storageKey = identityKeys.storageKey;
274
+ const data: (T & { _mediatorEventId?: string })[] = [];
275
+
276
+ for (const event of response.data.payload.events) {
277
+ try {
278
+ const decrypted = decryptString(event.payload, storageKey);
279
+ const parsed = JSON.parse(decrypted) as T & { _mediatorEventId?: string };
280
+ parsed._mediatorEventId = event.id;
281
+ data.push(parsed);
282
+ } catch (error) {
283
+ console.warn(
284
+ `[EventStore] Decryption failure for event: ${error instanceof Error ? error.message : String(error)}`,
285
+ );
286
+ }
287
+ }
288
+
289
+ return {
290
+ data,
291
+ pagination: {
292
+ page: response.data.payload.pagination.page,
293
+ pageSize: response.data.payload.pagination.page_size,
294
+ total: response.data.payload.pagination.total,
295
+ },
296
+ };
297
+ } catch (error) {
298
+ if (error instanceof EventStoreError) {
299
+ throw error;
300
+ }
301
+
302
+ throw new EventStoreError(
303
+ `Failed to query unprocessed events: ${error instanceof Error ? error.message : String(error)}`,
304
+ 'QUERY_FAILED',
305
+ { error },
306
+ );
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Shared logic: filter by known senders, decrypt, store locally, acknowledge.
312
+ */
313
+ private async decryptStoreAndAck<T>(
314
+ pendingEvents: Array<{ id: string; sender_did: string; payload: string }>,
315
+ ): Promise<T[]> {
316
+ const { communicationContracts } = this.config;
317
+ const activeContracts = communicationContracts();
318
+
319
+ if (activeContracts.length === 0) {
320
+ return [];
321
+ }
322
+
323
+ const knownSenders = new Set(activeContracts.map((c) => c.participantDid));
324
+ const filtered = pendingEvents.filter((event) => knownSenders.has(event.sender_did));
325
+
326
+ const processedEvents: T[] = [];
327
+ const ackEventIds: string[] = [];
328
+
329
+ for (const pendingEvent of filtered) {
330
+ try {
331
+ const matchingContracts = activeContracts
332
+ .filter((c) => c.participantDid === pendingEvent.sender_did && c.rootSecret)
333
+ .sort(
334
+ (a, b) =>
335
+ b.signedCommunicationContract.communication_contract.expires_at -
336
+ a.signedCommunicationContract.communication_contract.expires_at,
337
+ );
338
+
339
+ let decrypted: string | null = null;
340
+ let contract: (typeof matchingContracts)[number] | null = null;
341
+
342
+ for (const candidate of matchingContracts) {
343
+ try {
344
+ decrypted = decryptString(pendingEvent.payload, base64Decode(candidate.rootSecret));
345
+ contract = candidate;
346
+ break;
347
+ } catch {}
348
+ }
349
+
350
+ if (!decrypted || !contract) {
351
+ console.warn(`[EventStore] No contract could decrypt event ${pendingEvent.id}`);
352
+ continue;
353
+ }
354
+
355
+ this.config.onContractUsed?.(contract.id, pendingEvent.sender_did);
356
+
357
+ const envelope = JSON.parse(decrypted);
358
+
359
+ const senderSigningKey = extractSigningKeyFromDid(pendingEvent.sender_did);
360
+
361
+ if (senderSigningKey && envelope.signature) {
362
+ const { signature, ...envelopeData } = envelope;
363
+ const isValid = verifyJsonSignature(envelopeData, signature, senderSigningKey);
364
+
365
+ if (!isValid) {
366
+ console.warn(`Invalid signature on event ${pendingEvent.id}, skipping`);
367
+ continue;
368
+ }
369
+ }
370
+
371
+ const event = JSON.parse(envelope.event) as T;
372
+
373
+ if (!(event as any)?.meta?.ephemeral) {
374
+ await this.storeReceivedEvent(event, pendingEvent.sender_did, contract.id);
375
+ }
376
+
377
+ processedEvents.push(event);
378
+ ackEventIds.push(pendingEvent.id);
379
+ } catch (error) {
380
+ console.warn(`Failed to process pending event ${pendingEvent.id}:`, error);
381
+ }
382
+ }
383
+
384
+ if (ackEventIds.length > 0) {
385
+ await this.acknowledgePendingEvents(ackEventIds);
386
+ }
387
+
388
+ return processedEvents;
389
+ }
390
+
391
+ /**
392
+ * Send event to recipient via two-way private channel
393
+ */
394
+ private async sendToRecipient<T>(event: T, recipientDid: string): Promise<void> {
395
+ const { identity, communicationContracts } = this.config;
396
+
397
+ const contract = communicationContracts()
398
+ .filter(
399
+ (c) => c.participantDid === recipientDid && c.rootSecret && c.signedCommunicationContract,
400
+ )
401
+ .sort(
402
+ (a, b) =>
403
+ b.signedCommunicationContract.communication_contract.expires_at -
404
+ a.signedCommunicationContract.communication_contract.expires_at,
405
+ )[0];
406
+
407
+ if (!contract) {
408
+ throw new EventStoreError(
409
+ `No communication contract found with ${recipientDid}`,
410
+ 'CONTRACT_NOT_FOUND',
411
+ { recipientDid },
412
+ );
413
+ }
414
+
415
+ const rootSecret = base64Decode(contract.rootSecret);
416
+ const timestamp = Math.floor(Date.now() / 1000);
417
+
418
+ const envelopeData = {
419
+ contract_id: contract.id,
420
+ event: JSON.stringify(event),
421
+ timestamp,
422
+ };
423
+
424
+ const signature = signJsonObject(envelopeData, identity.keys.signing.privateKey);
425
+
426
+ const envelope = {
427
+ ...envelopeData,
428
+ signature,
429
+ };
430
+
431
+ const encryptedPayload = encryptString(JSON.stringify(envelope), rootSecret);
432
+
433
+ const twoWayPrivateCommand = generateTwoWayPrivateMediatorCommand(
434
+ identity.did,
435
+ `${identity.did}#signing`,
436
+ recipientDid,
437
+ encryptedPayload,
438
+ identity.keys,
439
+ );
440
+
441
+ await axios.post(identity.mediatorEndpoint, twoWayPrivateCommand);
442
+ }
443
+
444
+ /**
445
+ * Store event locally with encrypted tags
446
+ */
447
+ private async storeLocally<T>(event: T, tags: string[], recipientDid?: string): Promise<void> {
448
+ const { identity, communicationContracts } = this.config;
449
+
450
+ const storageKey = identity.keys.storageKey;
451
+ const encryptedEventForSelf = encryptString(JSON.stringify(event), storageKey);
452
+
453
+ const encryptedTags = tags.map((tag) =>
454
+ generateEncryptedTag(identity.keys.signing.privateKey, tag),
455
+ );
456
+
457
+ let contractId: string | undefined;
458
+
459
+ if (recipientDid) {
460
+ const contract = communicationContracts().find((c) => c.participantDid === recipientDid);
461
+ contractId = contract?.id;
462
+ }
463
+
464
+ const saveEventCommand = generateDirectAuthenticatedMediatorCommand(
465
+ identity.did,
466
+ `${identity.did}#signing`,
467
+ identity.mediatorDid,
468
+ {
469
+ type: 'SAVE_EVENTS',
470
+ events: [
471
+ {
472
+ sender_did: identity.did,
473
+ recipient_did: recipientDid || identity.did,
474
+ contract_id: contractId,
475
+ payload: encryptedEventForSelf,
476
+ timestamp: Math.floor(Date.now() / 1000),
477
+ encrypted_tags: encryptedTags,
478
+ },
479
+ ],
480
+ },
481
+ identity.keys,
482
+ );
483
+
484
+ await axios.post<SaveEventsMediatorCommandResponse>(
485
+ identity.mediatorEndpoint,
486
+ saveEventCommand,
487
+ );
488
+ }
489
+
490
+ /**
491
+ * Store received event with appropriate tags
492
+ */
493
+ private async storeReceivedEvent<T>(
494
+ event: T,
495
+ senderDid: string,
496
+ contractId?: string,
497
+ ): Promise<void> {
498
+ const { identity } = this.config;
499
+
500
+ const storageKey = identity.keys.storageKey;
501
+ const encryptedEventForSelf = encryptString(JSON.stringify(event), storageKey);
502
+
503
+ const participantTag = generateEncryptedTag(
504
+ identity.keys.signing.privateKey,
505
+ `participant.${senderDid}`,
506
+ );
507
+
508
+ const saveEventCommand = generateDirectAuthenticatedMediatorCommand(
509
+ identity.did,
510
+ `${identity.did}#signing`,
511
+ identity.mediatorDid,
512
+ {
513
+ type: 'SAVE_EVENTS',
514
+ events: [
515
+ {
516
+ sender_did: senderDid,
517
+ recipient_did: identity.did,
518
+ contract_id: contractId,
519
+ payload: encryptedEventForSelf,
520
+ timestamp: Math.floor(Date.now() / 1000),
521
+ encrypted_tags: [participantTag],
522
+ },
523
+ ],
524
+ },
525
+ identity.keys,
526
+ );
527
+
528
+ await axios.post<SaveEventsMediatorCommandResponse>(
529
+ identity.mediatorEndpoint,
530
+ saveEventCommand,
531
+ );
532
+ }
533
+
534
+ /**
535
+ * Acknowledge processed pending events
536
+ */
537
+ private async acknowledgePendingEvents(eventIds: string[]): Promise<void> {
538
+ const { identity } = this.config;
539
+
540
+ const ackCommand = generateDirectAuthenticatedMediatorCommand(
541
+ identity.did,
542
+ `${identity.did}#signing`,
543
+ identity.mediatorDid,
544
+ {
545
+ type: 'ACKNOWLEDGE_PENDING_EVENTS',
546
+ event_ids: eventIds,
547
+ },
548
+ identity.keys,
549
+ );
550
+
551
+ await axios.post<AcknowledgePendingEventsMediatorCommandResponse>(
552
+ identity.mediatorEndpoint,
553
+ ackCommand,
554
+ );
555
+ }
556
+ }
557
+
558
+ const extractSigningKeyFromDid = (did: string): Uint8Array | null => {
559
+ if (!did.startsWith('did:decentrl:')) {
560
+ return null;
561
+ }
562
+
563
+ const parts = did.replace('did:decentrl:', '').split(':');
564
+
565
+ if (parts.length !== 4) {
566
+ return null;
567
+ }
568
+
569
+ try {
570
+ return multibaseDecode(parts[1]);
571
+ } catch {
572
+ return null;
573
+ }
574
+ };
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { DecentrlEventStore } from './event-store';
2
+ export type {
3
+ EventStoreConfig,
4
+ PaginatedResult,
5
+ PaginationMeta,
6
+ PublishOptions,
7
+ QueryOptions,
8
+ StoredSignedContract,
9
+ } from './types';
10
+ export { EventStoreError } from './types';
package/src/types.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type { DecentrlIdentityKeys } from '@decentrl/crypto';
2
+ import type { SignedCommunicationContract } from '@decentrl/identity/communication-contract/communication-contract.schema';
3
+
4
+ export interface EventStoreConfig {
5
+ identity: {
6
+ did: string;
7
+ keys: DecentrlIdentityKeys;
8
+ mediatorEndpoint: string;
9
+ mediatorDid: string;
10
+ };
11
+ communicationContracts: () => StoredSignedContract[];
12
+ onContractUsed?: (contractId: string, participantDid: string) => void;
13
+ }
14
+
15
+ /**
16
+ * Minimal contract shape needed by the event store.
17
+ * The canonical definition lives in @decentrl/sdk types.
18
+ */
19
+ export interface StoredSignedContract {
20
+ id: string;
21
+ participantDid: string;
22
+ signedCommunicationContract: SignedCommunicationContract;
23
+ rootSecret: string;
24
+ }
25
+
26
+ export interface PublishOptions {
27
+ recipient?: string; // If provided, sends via two-way private
28
+ tags: string[]; // For querying later
29
+ ephemeral?: boolean; // If true, skip local storage
30
+ }
31
+
32
+ export interface QueryOptions {
33
+ tags?: string[];
34
+ participantDid?: string;
35
+ afterTimestamp?: number;
36
+ beforeTimestamp?: number;
37
+ pagination?: { page: number; pageSize: number };
38
+ unprocessedOnly?: boolean;
39
+ }
40
+
41
+ export interface PaginationMeta {
42
+ page: number;
43
+ pageSize: number;
44
+ total: number;
45
+ }
46
+
47
+ export interface PaginatedResult<T> {
48
+ data: T[];
49
+ pagination: PaginationMeta;
50
+ }
51
+
52
+ export class EventStoreError extends Error {
53
+ constructor(
54
+ message: string,
55
+ public code: string,
56
+ public details?: unknown,
57
+ ) {
58
+ super(message);
59
+ this.name = 'EventStoreError';
60
+ }
61
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "noEmit": false,
9
+ "composite": false,
10
+ "allowImportingTsExtensions": false,
11
+ "moduleResolution": "bundler"
12
+ },
13
+ "include": ["src/**/*"],
14
+ "exclude": ["node_modules", "dist"]
15
+ }