@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.
- package/.turbo/turbo-build.log +4 -0
- package/README.md +214 -0
- package/dist/event-store.d.ts +64 -0
- package/dist/event-store.d.ts.map +1 -0
- package/dist/event-store.js +355 -0
- package/dist/event-store.test.d.ts +2 -0
- package/dist/event-store.test.d.ts.map +1 -0
- package/dist/event-store.test.js +273 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/types.d.ts +53 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +10 -0
- package/package.json +29 -0
- package/src/event-store.test.ts +341 -0
- package/src/event-store.ts +574 -0
- package/src/index.ts +10 -0
- package/src/types.ts +61 -0
- package/tsconfig.json +15 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +13 -0
package/README.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# @decentrl/event-store
|
|
2
|
+
|
|
3
|
+
A library for event-driven storage and communication in the decentrl ecosystem. This library abstracts away the complexity of event handling, encryption, and mediator communication, allowing you to focus on business logic.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Event Publishing**: Store events locally and optionally send to recipients
|
|
8
|
+
- **Event Querying**: Retrieve stored events with filtering and pagination
|
|
9
|
+
- **Pending Event Processing**: Handle incoming events from other participants
|
|
10
|
+
- **Automatic Encryption/Decryption**: Transparent handling of cryptographic operations
|
|
11
|
+
- **Type Safety**: Full TypeScript support with generic event types
|
|
12
|
+
- **Error Handling**: Consistent error handling with detailed error information
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm add @decentrl/event-store
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Basic Usage
|
|
21
|
+
|
|
22
|
+
### 1. Setup Event Store
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { DecentrlEventStore, EventStoreConfig } from '@decentrl/event-store';
|
|
26
|
+
import { useIdentityStore } from './stores/identityStore';
|
|
27
|
+
import { useSignedCommunicationContractsStore } from './stores/contractsStore';
|
|
28
|
+
|
|
29
|
+
const eventStoreConfig: EventStoreConfig = {
|
|
30
|
+
identity: {
|
|
31
|
+
did: identity.identityDid,
|
|
32
|
+
keys: identity.getIdentityKeys(),
|
|
33
|
+
mediatorEndpoint: identity.identityMediatorEndpoint,
|
|
34
|
+
mediatorDid: identity.mediatorDid,
|
|
35
|
+
},
|
|
36
|
+
communicationContracts: () => contractsStore.getActiveContracts(),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const eventStore = new DecentrlEventStore(eventStoreConfig);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### 2. Define Event Types
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
interface ChatCreateEvent {
|
|
46
|
+
type: "chat.create";
|
|
47
|
+
data: {
|
|
48
|
+
id: string;
|
|
49
|
+
participants: [string, string];
|
|
50
|
+
createdBy: string;
|
|
51
|
+
createdAt: Date;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface MessageSendEvent {
|
|
56
|
+
type: "message.send";
|
|
57
|
+
data: {
|
|
58
|
+
id: string;
|
|
59
|
+
chatId: string;
|
|
60
|
+
content: string;
|
|
61
|
+
sender: string;
|
|
62
|
+
timestamp: Date;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type ChatEvent = ChatCreateEvent | MessageSendEvent;
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 3. Publish Events
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
// Publish event locally and send to recipient
|
|
73
|
+
await eventStore.publishEvent(chatCreateEvent, {
|
|
74
|
+
recipient: recipientDid,
|
|
75
|
+
tags: ["chat", `chat.${chatId}`, `participant.${recipientDid}`]
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Publish event locally only
|
|
79
|
+
await eventStore.publishEvent(messageEvent, {
|
|
80
|
+
tags: [`chat.${chatId}`]
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 4. Query Events
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
// Query all chat creation events
|
|
88
|
+
const chatEvents = await eventStore.queryEvents<ChatCreateEvent>({
|
|
89
|
+
tags: ["chat"]
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Query messages for specific chat
|
|
93
|
+
const messageEvents = await eventStore.queryEvents<MessageSendEvent>({
|
|
94
|
+
tags: [`chat.${chatId}`]
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Query with pagination and filters
|
|
98
|
+
const events = await eventStore.queryEvents<ChatEvent>({
|
|
99
|
+
tags: ["chat"],
|
|
100
|
+
participantDid: "did:decentrl:participant123",
|
|
101
|
+
afterTimestamp: Date.now() - 86400000, // Last 24 hours
|
|
102
|
+
pagination: { page: 0, pageSize: 50 }
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### 5. Process Pending Events
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
// Process all pending events from known contacts
|
|
110
|
+
const newEvents = await eventStore.processPendingEvents<ChatEvent>();
|
|
111
|
+
|
|
112
|
+
newEvents.forEach(event => {
|
|
113
|
+
if (event.type === "chat.create") {
|
|
114
|
+
// Handle new chat
|
|
115
|
+
} else if (event.type === "message.send") {
|
|
116
|
+
// Handle new message
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## API Reference
|
|
122
|
+
|
|
123
|
+
### DecentrlEventStore
|
|
124
|
+
|
|
125
|
+
#### `publishEvent<T>(event: T, options: PublishOptions): Promise<void>`
|
|
126
|
+
|
|
127
|
+
Publishes an event, storing it locally and optionally sending to a recipient.
|
|
128
|
+
|
|
129
|
+
**Parameters:**
|
|
130
|
+
- `event`: The event object to publish
|
|
131
|
+
- `options.recipient?`: DID of the recipient (if sending to someone)
|
|
132
|
+
- `options.tags`: Array of tags for querying the event later
|
|
133
|
+
|
|
134
|
+
#### `queryEvents<T>(options?: QueryOptions): Promise<T[]>`
|
|
135
|
+
|
|
136
|
+
Queries stored events from the mediator.
|
|
137
|
+
|
|
138
|
+
**Parameters:**
|
|
139
|
+
- `options.tags?`: Filter by encrypted tags
|
|
140
|
+
- `options.participantDid?`: Filter by participant DID
|
|
141
|
+
- `options.afterTimestamp?`: Filter events after timestamp
|
|
142
|
+
- `options.beforeTimestamp?`: Filter events before timestamp
|
|
143
|
+
- `options.pagination?`: Pagination options
|
|
144
|
+
|
|
145
|
+
#### `processPendingEvents<T>(): Promise<T[]>`
|
|
146
|
+
|
|
147
|
+
Processes pending events from other participants and stores them locally.
|
|
148
|
+
|
|
149
|
+
**Returns:** Array of new events that were processed
|
|
150
|
+
|
|
151
|
+
## Error Handling
|
|
152
|
+
|
|
153
|
+
The library provides structured error handling with the `EventStoreError` class:
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
import { EventStoreError } from '@decentrl/event-store';
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
await eventStore.publishEvent(event, options);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (error instanceof EventStoreError) {
|
|
162
|
+
console.error(`Event store error [${error.code}]:`, error.message);
|
|
163
|
+
console.error('Details:', error.details);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Benefits Over Manual Implementation
|
|
169
|
+
|
|
170
|
+
- **90% Less Boilerplate**: Eliminates repetitive encryption, command generation, and HTTP handling
|
|
171
|
+
- **Consistent Error Handling**: Centralized error handling with detailed error information
|
|
172
|
+
- **Type Safety**: Full TypeScript support with generic event types
|
|
173
|
+
- **Testing**: Much easier to unit test business logic without infrastructure concerns
|
|
174
|
+
- **Maintainability**: Clear separation between business logic and infrastructure
|
|
175
|
+
|
|
176
|
+
## Example: Before vs After
|
|
177
|
+
|
|
178
|
+
**Before (with manual implementation):**
|
|
179
|
+
```typescript
|
|
180
|
+
// 140+ lines of infrastructure code for sending a message
|
|
181
|
+
sendMessage: async (chatId: string, content: string) => {
|
|
182
|
+
// Identity validation...
|
|
183
|
+
// Contract lookup...
|
|
184
|
+
// Encryption key derivation...
|
|
185
|
+
// Event encryption for recipient...
|
|
186
|
+
// Command generation...
|
|
187
|
+
// HTTP request...
|
|
188
|
+
// Self-encryption...
|
|
189
|
+
// Tag generation...
|
|
190
|
+
// Local storage command...
|
|
191
|
+
// Another HTTP request...
|
|
192
|
+
// Error handling...
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**After (with event store):**
|
|
197
|
+
```typescript
|
|
198
|
+
// ~15 lines focused on business logic
|
|
199
|
+
sendMessage: async (chatId: string, content: string) => {
|
|
200
|
+
const message = { id: uuid(), chatId, content, sender: userDid, timestamp: new Date() };
|
|
201
|
+
const event = { type: "message.send", data: message };
|
|
202
|
+
|
|
203
|
+
await eventStore.publishEvent(event, {
|
|
204
|
+
recipient: recipientDid,
|
|
205
|
+
tags: [`chat.${chatId}`]
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
addMessageToState(message);
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Integration with Zustand Stores
|
|
213
|
+
|
|
214
|
+
See `example-integration.ts` for a complete example of how to refactor an existing Zustand store to use the event store library.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type EventStoreConfig, type PaginatedResult, type PublishOptions, type QueryOptions } from './types';
|
|
2
|
+
export declare class DecentrlEventStore {
|
|
3
|
+
private config;
|
|
4
|
+
constructor(config: EventStoreConfig);
|
|
5
|
+
/**
|
|
6
|
+
* Publish an event - stores locally and optionally sends to recipient
|
|
7
|
+
*/
|
|
8
|
+
publishEvent<T>(event: T, options: PublishOptions): Promise<void>;
|
|
9
|
+
/**
|
|
10
|
+
* Query stored events from mediator
|
|
11
|
+
*/
|
|
12
|
+
queryEvents<T>(options?: QueryOptions): Promise<PaginatedResult<T>>;
|
|
13
|
+
/**
|
|
14
|
+
* Process pending events from other participants
|
|
15
|
+
*/
|
|
16
|
+
processPendingEvents<T>(): Promise<T[]>;
|
|
17
|
+
/**
|
|
18
|
+
* Process pre-fetched pending events (e.g. from WebSocket push).
|
|
19
|
+
* Same logic as processPendingEvents but skips the HTTP query.
|
|
20
|
+
*/
|
|
21
|
+
processPreFetchedPendingEvents<T>(rawEvents: Array<{
|
|
22
|
+
id: string;
|
|
23
|
+
sender_did: string;
|
|
24
|
+
payload: string;
|
|
25
|
+
}>): Promise<T[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Update event tags on the mediator (idempotent replace).
|
|
28
|
+
*/
|
|
29
|
+
updateEventTags(events: Array<{
|
|
30
|
+
eventId: string;
|
|
31
|
+
encryptedTags: string[];
|
|
32
|
+
}>): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Query events that haven't been processed by the app yet.
|
|
35
|
+
* Returns mediator event IDs alongside decrypted data for tag updates.
|
|
36
|
+
*/
|
|
37
|
+
queryUnprocessedEvents<T>(pagination?: {
|
|
38
|
+
page: number;
|
|
39
|
+
pageSize: number;
|
|
40
|
+
}): Promise<PaginatedResult<T & {
|
|
41
|
+
_mediatorEventId?: string;
|
|
42
|
+
}>>;
|
|
43
|
+
/**
|
|
44
|
+
* Shared logic: filter by known senders, decrypt, store locally, acknowledge.
|
|
45
|
+
*/
|
|
46
|
+
private decryptStoreAndAck;
|
|
47
|
+
/**
|
|
48
|
+
* Send event to recipient via two-way private channel
|
|
49
|
+
*/
|
|
50
|
+
private sendToRecipient;
|
|
51
|
+
/**
|
|
52
|
+
* Store event locally with encrypted tags
|
|
53
|
+
*/
|
|
54
|
+
private storeLocally;
|
|
55
|
+
/**
|
|
56
|
+
* Store received event with appropriate tags
|
|
57
|
+
*/
|
|
58
|
+
private storeReceivedEvent;
|
|
59
|
+
/**
|
|
60
|
+
* Acknowledge processed pending events
|
|
61
|
+
*/
|
|
62
|
+
private acknowledgePendingEvents;
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=event-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"event-store.d.ts","sourceRoot":"","sources":["../src/event-store.ts"],"names":[],"mappings":"AAmBA,OAAO,EACN,KAAK,gBAAgB,EAErB,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,MAAM,SAAS,CAAC;AAQjB,qBAAa,kBAAkB;IAClB,OAAO,CAAC,MAAM;gBAAN,MAAM,EAAE,gBAAgB;IAE5C;;OAEG;IACG,YAAY,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;IAoBvE;;OAEG;IACG,WAAW,CAAC,CAAC,EAAE,OAAO,GAAE,YAAiB,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC;IA2E7E;;OAEG;IACG,oBAAoB,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,EAAE,CAAC;IAiD7C;;;OAGG;IACG,8BAA8B,CAAC,CAAC,EACrC,SAAS,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,GACnE,OAAO,CAAC,CAAC,EAAE,CAAC;IAgBf;;OAEG;IACG,eAAe,CACpB,MAAM,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,GACzD,OAAO,CAAC,IAAI,CAAC;IAoBhB;;;OAGG;IACG,sBAAsB,CAAC,CAAC,EAAE,UAAU,CAAC,EAAE;QAC5C,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,EAAE,MAAM,CAAC;KACjB,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC,GAAG;QAAE,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAkE/D;;OAEG;YACW,kBAAkB;IA8EhC;;OAEG;YACW,eAAe;IAkD7B;;OAEG;YACW,YAAY;IA2C1B;;OAEG;YACW,kBAAkB;IAyChC;;OAEG;YACW,wBAAwB;CAmBtC"}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { base64Decode, decryptString, encryptString, generateEncryptedTag, multibaseDecode, signJsonObject, verifyJsonSignature, } from '@decentrl/crypto';
|
|
2
|
+
import { generateDirectAuthenticatedMediatorCommand, generateTwoWayPrivateMediatorCommand, } from '@decentrl/identity/communication-channels/mediator/direct-authenticated/command/command.service';
|
|
3
|
+
import axiosModule from 'axios';
|
|
4
|
+
import { EventStoreError, } from './types';
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
6
|
+
const axios = {
|
|
7
|
+
post: (url, data) => axiosModule.post(url, data, { timeout: DEFAULT_TIMEOUT_MS }),
|
|
8
|
+
};
|
|
9
|
+
export class DecentrlEventStore {
|
|
10
|
+
config;
|
|
11
|
+
constructor(config) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Publish an event - stores locally and optionally sends to recipient
|
|
16
|
+
*/
|
|
17
|
+
async publishEvent(event, options) {
|
|
18
|
+
try {
|
|
19
|
+
// 1. Handle recipient delivery (if specified)
|
|
20
|
+
if (options.recipient) {
|
|
21
|
+
await this.sendToRecipient(event, options.recipient);
|
|
22
|
+
}
|
|
23
|
+
// 2. Store locally for own queries (skip for ephemeral events)
|
|
24
|
+
if (!options.ephemeral) {
|
|
25
|
+
await this.storeLocally(event, options.tags, options.recipient);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
throw new EventStoreError(`Failed to publish event: ${error instanceof Error ? error.message : String(error)}`, 'PUBLISH_FAILED', { event, options, error });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Query stored events from mediator
|
|
34
|
+
*/
|
|
35
|
+
async queryEvents(options = {}) {
|
|
36
|
+
try {
|
|
37
|
+
const { identity } = this.config;
|
|
38
|
+
const identityKeys = identity.keys;
|
|
39
|
+
const encryptedTags = options.tags?.map((tag) => generateEncryptedTag(identityKeys.signing.privateKey, tag));
|
|
40
|
+
const page = options.pagination?.page ?? 0;
|
|
41
|
+
const pageSize = options.pagination?.pageSize ?? 100;
|
|
42
|
+
const queryCommand = generateDirectAuthenticatedMediatorCommand(identity.did, `${identity.did}#signing`, identity.mediatorDid, {
|
|
43
|
+
type: 'QUERY_EVENTS',
|
|
44
|
+
filter: {
|
|
45
|
+
encrypted_tags: encryptedTags,
|
|
46
|
+
participant_did: options.participantDid,
|
|
47
|
+
after_timestamp: options.afterTimestamp,
|
|
48
|
+
before_timestamp: options.beforeTimestamp,
|
|
49
|
+
unprocessed_only: options.unprocessedOnly,
|
|
50
|
+
},
|
|
51
|
+
pagination: { page, page_size: pageSize },
|
|
52
|
+
}, identityKeys);
|
|
53
|
+
const response = await axios.post(identity.mediatorEndpoint, queryCommand);
|
|
54
|
+
if (response.data.type !== 'SUCCESS') {
|
|
55
|
+
throw new EventStoreError('Query failed', 'QUERY_FAILED', response.data);
|
|
56
|
+
}
|
|
57
|
+
const storageKey = identityKeys.storageKey;
|
|
58
|
+
const data = [];
|
|
59
|
+
for (const event of response.data.payload.events) {
|
|
60
|
+
try {
|
|
61
|
+
const decrypted = decryptString(event.payload, storageKey);
|
|
62
|
+
data.push(JSON.parse(decrypted));
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
console.warn(`[EventStore] Decryption failure for event: ${error instanceof Error ? error.message : String(error)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
data,
|
|
70
|
+
pagination: {
|
|
71
|
+
page: response.data.payload.pagination.page,
|
|
72
|
+
pageSize: response.data.payload.pagination.page_size,
|
|
73
|
+
total: response.data.payload.pagination.total,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error instanceof EventStoreError) {
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
throw new EventStoreError(`Failed to query events: ${error instanceof Error ? error.message : String(error)}`, 'QUERY_FAILED', { options, error });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Process pending events from other participants
|
|
86
|
+
*/
|
|
87
|
+
async processPendingEvents() {
|
|
88
|
+
try {
|
|
89
|
+
const { identity, communicationContracts } = this.config;
|
|
90
|
+
const activeContracts = communicationContracts();
|
|
91
|
+
if (activeContracts.length === 0) {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
// Query pending events
|
|
95
|
+
const queryCommand = generateDirectAuthenticatedMediatorCommand(identity.did, `${identity.did}#signing`, identity.mediatorDid, {
|
|
96
|
+
type: 'QUERY_PENDING_EVENTS',
|
|
97
|
+
filter: {},
|
|
98
|
+
pagination: { page: 0, page_size: 100 },
|
|
99
|
+
}, identity.keys);
|
|
100
|
+
const response = await axios.post(identity.mediatorEndpoint, queryCommand);
|
|
101
|
+
if (response.data.type !== 'SUCCESS') {
|
|
102
|
+
throw new EventStoreError('Failed to query pending events', 'PENDING_QUERY_FAILED', response.data);
|
|
103
|
+
}
|
|
104
|
+
return await this.decryptStoreAndAck(response.data.payload.pending_events);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (error instanceof EventStoreError) {
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
throw new EventStoreError(`Failed to process pending events: ${error instanceof Error ? error.message : String(error)}`, 'PENDING_PROCESS_FAILED', { error });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Process pre-fetched pending events (e.g. from WebSocket push).
|
|
115
|
+
* Same logic as processPendingEvents but skips the HTTP query.
|
|
116
|
+
*/
|
|
117
|
+
async processPreFetchedPendingEvents(rawEvents) {
|
|
118
|
+
try {
|
|
119
|
+
return await this.decryptStoreAndAck(rawEvents);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (error instanceof EventStoreError) {
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
throw new EventStoreError(`Failed to process pre-fetched pending events: ${error instanceof Error ? error.message : String(error)}`, 'PENDING_PROCESS_FAILED', { error });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Update event tags on the mediator (idempotent replace).
|
|
130
|
+
*/
|
|
131
|
+
async updateEventTags(events) {
|
|
132
|
+
const { identity } = this.config;
|
|
133
|
+
const command = generateDirectAuthenticatedMediatorCommand(identity.did, `${identity.did}#signing`, identity.mediatorDid, {
|
|
134
|
+
type: 'UPDATE_EVENT_TAGS',
|
|
135
|
+
events: events.map((e) => ({
|
|
136
|
+
event_id: e.eventId,
|
|
137
|
+
encrypted_tags: e.encryptedTags,
|
|
138
|
+
})),
|
|
139
|
+
}, identity.keys);
|
|
140
|
+
await axios.post(identity.mediatorEndpoint, command);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Query events that haven't been processed by the app yet.
|
|
144
|
+
* Returns mediator event IDs alongside decrypted data for tag updates.
|
|
145
|
+
*/
|
|
146
|
+
async queryUnprocessedEvents(pagination) {
|
|
147
|
+
try {
|
|
148
|
+
const { identity } = this.config;
|
|
149
|
+
const identityKeys = identity.keys;
|
|
150
|
+
const page = pagination?.page ?? 0;
|
|
151
|
+
const pageSize = pagination?.pageSize ?? 100;
|
|
152
|
+
const queryCommand = generateDirectAuthenticatedMediatorCommand(identity.did, `${identity.did}#signing`, identity.mediatorDid, {
|
|
153
|
+
type: 'QUERY_EVENTS',
|
|
154
|
+
filter: { unprocessed_only: true },
|
|
155
|
+
pagination: { page, page_size: pageSize },
|
|
156
|
+
}, identityKeys);
|
|
157
|
+
const response = await axios.post(identity.mediatorEndpoint, queryCommand);
|
|
158
|
+
if (response.data.type !== 'SUCCESS') {
|
|
159
|
+
throw new EventStoreError('Query failed', 'QUERY_FAILED', response.data);
|
|
160
|
+
}
|
|
161
|
+
const storageKey = identityKeys.storageKey;
|
|
162
|
+
const data = [];
|
|
163
|
+
for (const event of response.data.payload.events) {
|
|
164
|
+
try {
|
|
165
|
+
const decrypted = decryptString(event.payload, storageKey);
|
|
166
|
+
const parsed = JSON.parse(decrypted);
|
|
167
|
+
parsed._mediatorEventId = event.id;
|
|
168
|
+
data.push(parsed);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
console.warn(`[EventStore] Decryption failure for event: ${error instanceof Error ? error.message : String(error)}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
data,
|
|
176
|
+
pagination: {
|
|
177
|
+
page: response.data.payload.pagination.page,
|
|
178
|
+
pageSize: response.data.payload.pagination.page_size,
|
|
179
|
+
total: response.data.payload.pagination.total,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (error instanceof EventStoreError) {
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
throw new EventStoreError(`Failed to query unprocessed events: ${error instanceof Error ? error.message : String(error)}`, 'QUERY_FAILED', { error });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Shared logic: filter by known senders, decrypt, store locally, acknowledge.
|
|
192
|
+
*/
|
|
193
|
+
async decryptStoreAndAck(pendingEvents) {
|
|
194
|
+
const { communicationContracts } = this.config;
|
|
195
|
+
const activeContracts = communicationContracts();
|
|
196
|
+
if (activeContracts.length === 0) {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
const knownSenders = new Set(activeContracts.map((c) => c.participantDid));
|
|
200
|
+
const filtered = pendingEvents.filter((event) => knownSenders.has(event.sender_did));
|
|
201
|
+
const processedEvents = [];
|
|
202
|
+
const ackEventIds = [];
|
|
203
|
+
for (const pendingEvent of filtered) {
|
|
204
|
+
try {
|
|
205
|
+
const matchingContracts = activeContracts
|
|
206
|
+
.filter((c) => c.participantDid === pendingEvent.sender_did && c.rootSecret)
|
|
207
|
+
.sort((a, b) => b.signedCommunicationContract.communication_contract.expires_at -
|
|
208
|
+
a.signedCommunicationContract.communication_contract.expires_at);
|
|
209
|
+
let decrypted = null;
|
|
210
|
+
let contract = null;
|
|
211
|
+
for (const candidate of matchingContracts) {
|
|
212
|
+
try {
|
|
213
|
+
decrypted = decryptString(pendingEvent.payload, base64Decode(candidate.rootSecret));
|
|
214
|
+
contract = candidate;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
catch { }
|
|
218
|
+
}
|
|
219
|
+
if (!decrypted || !contract) {
|
|
220
|
+
console.warn(`[EventStore] No contract could decrypt event ${pendingEvent.id}`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
this.config.onContractUsed?.(contract.id, pendingEvent.sender_did);
|
|
224
|
+
const envelope = JSON.parse(decrypted);
|
|
225
|
+
const senderSigningKey = extractSigningKeyFromDid(pendingEvent.sender_did);
|
|
226
|
+
if (senderSigningKey && envelope.signature) {
|
|
227
|
+
const { signature, ...envelopeData } = envelope;
|
|
228
|
+
const isValid = verifyJsonSignature(envelopeData, signature, senderSigningKey);
|
|
229
|
+
if (!isValid) {
|
|
230
|
+
console.warn(`Invalid signature on event ${pendingEvent.id}, skipping`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const event = JSON.parse(envelope.event);
|
|
235
|
+
if (!event?.meta?.ephemeral) {
|
|
236
|
+
await this.storeReceivedEvent(event, pendingEvent.sender_did, contract.id);
|
|
237
|
+
}
|
|
238
|
+
processedEvents.push(event);
|
|
239
|
+
ackEventIds.push(pendingEvent.id);
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
console.warn(`Failed to process pending event ${pendingEvent.id}:`, error);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (ackEventIds.length > 0) {
|
|
246
|
+
await this.acknowledgePendingEvents(ackEventIds);
|
|
247
|
+
}
|
|
248
|
+
return processedEvents;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Send event to recipient via two-way private channel
|
|
252
|
+
*/
|
|
253
|
+
async sendToRecipient(event, recipientDid) {
|
|
254
|
+
const { identity, communicationContracts } = this.config;
|
|
255
|
+
const contract = communicationContracts()
|
|
256
|
+
.filter((c) => c.participantDid === recipientDid && c.rootSecret && c.signedCommunicationContract)
|
|
257
|
+
.sort((a, b) => b.signedCommunicationContract.communication_contract.expires_at -
|
|
258
|
+
a.signedCommunicationContract.communication_contract.expires_at)[0];
|
|
259
|
+
if (!contract) {
|
|
260
|
+
throw new EventStoreError(`No communication contract found with ${recipientDid}`, 'CONTRACT_NOT_FOUND', { recipientDid });
|
|
261
|
+
}
|
|
262
|
+
const rootSecret = base64Decode(contract.rootSecret);
|
|
263
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
264
|
+
const envelopeData = {
|
|
265
|
+
contract_id: contract.id,
|
|
266
|
+
event: JSON.stringify(event),
|
|
267
|
+
timestamp,
|
|
268
|
+
};
|
|
269
|
+
const signature = signJsonObject(envelopeData, identity.keys.signing.privateKey);
|
|
270
|
+
const envelope = {
|
|
271
|
+
...envelopeData,
|
|
272
|
+
signature,
|
|
273
|
+
};
|
|
274
|
+
const encryptedPayload = encryptString(JSON.stringify(envelope), rootSecret);
|
|
275
|
+
const twoWayPrivateCommand = generateTwoWayPrivateMediatorCommand(identity.did, `${identity.did}#signing`, recipientDid, encryptedPayload, identity.keys);
|
|
276
|
+
await axios.post(identity.mediatorEndpoint, twoWayPrivateCommand);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Store event locally with encrypted tags
|
|
280
|
+
*/
|
|
281
|
+
async storeLocally(event, tags, recipientDid) {
|
|
282
|
+
const { identity, communicationContracts } = this.config;
|
|
283
|
+
const storageKey = identity.keys.storageKey;
|
|
284
|
+
const encryptedEventForSelf = encryptString(JSON.stringify(event), storageKey);
|
|
285
|
+
const encryptedTags = tags.map((tag) => generateEncryptedTag(identity.keys.signing.privateKey, tag));
|
|
286
|
+
let contractId;
|
|
287
|
+
if (recipientDid) {
|
|
288
|
+
const contract = communicationContracts().find((c) => c.participantDid === recipientDid);
|
|
289
|
+
contractId = contract?.id;
|
|
290
|
+
}
|
|
291
|
+
const saveEventCommand = generateDirectAuthenticatedMediatorCommand(identity.did, `${identity.did}#signing`, identity.mediatorDid, {
|
|
292
|
+
type: 'SAVE_EVENTS',
|
|
293
|
+
events: [
|
|
294
|
+
{
|
|
295
|
+
sender_did: identity.did,
|
|
296
|
+
recipient_did: recipientDid || identity.did,
|
|
297
|
+
contract_id: contractId,
|
|
298
|
+
payload: encryptedEventForSelf,
|
|
299
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
300
|
+
encrypted_tags: encryptedTags,
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
}, identity.keys);
|
|
304
|
+
await axios.post(identity.mediatorEndpoint, saveEventCommand);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Store received event with appropriate tags
|
|
308
|
+
*/
|
|
309
|
+
async storeReceivedEvent(event, senderDid, contractId) {
|
|
310
|
+
const { identity } = this.config;
|
|
311
|
+
const storageKey = identity.keys.storageKey;
|
|
312
|
+
const encryptedEventForSelf = encryptString(JSON.stringify(event), storageKey);
|
|
313
|
+
const participantTag = generateEncryptedTag(identity.keys.signing.privateKey, `participant.${senderDid}`);
|
|
314
|
+
const saveEventCommand = generateDirectAuthenticatedMediatorCommand(identity.did, `${identity.did}#signing`, identity.mediatorDid, {
|
|
315
|
+
type: 'SAVE_EVENTS',
|
|
316
|
+
events: [
|
|
317
|
+
{
|
|
318
|
+
sender_did: senderDid,
|
|
319
|
+
recipient_did: identity.did,
|
|
320
|
+
contract_id: contractId,
|
|
321
|
+
payload: encryptedEventForSelf,
|
|
322
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
323
|
+
encrypted_tags: [participantTag],
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
}, identity.keys);
|
|
327
|
+
await axios.post(identity.mediatorEndpoint, saveEventCommand);
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Acknowledge processed pending events
|
|
331
|
+
*/
|
|
332
|
+
async acknowledgePendingEvents(eventIds) {
|
|
333
|
+
const { identity } = this.config;
|
|
334
|
+
const ackCommand = generateDirectAuthenticatedMediatorCommand(identity.did, `${identity.did}#signing`, identity.mediatorDid, {
|
|
335
|
+
type: 'ACKNOWLEDGE_PENDING_EVENTS',
|
|
336
|
+
event_ids: eventIds,
|
|
337
|
+
}, identity.keys);
|
|
338
|
+
await axios.post(identity.mediatorEndpoint, ackCommand);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const extractSigningKeyFromDid = (did) => {
|
|
342
|
+
if (!did.startsWith('did:decentrl:')) {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
const parts = did.replace('did:decentrl:', '').split(':');
|
|
346
|
+
if (parts.length !== 4) {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
return multibaseDecode(parts[1]);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"event-store.test.d.ts","sourceRoot":"","sources":["../src/event-store.test.ts"],"names":[],"mappings":""}
|