@decentrl/sdk 0.0.7 → 0.0.8

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/src/define-app.ts CHANGED
@@ -1,23 +1,37 @@
1
1
  import { DecentrlClient } from './client.js';
2
2
  import type {
3
+ ChannelDefinitions,
3
4
  DecentrlAppConfig,
4
5
  DecentrlClientConfig,
5
6
  EventDefinitions,
7
+ PublicEventDefinitions,
6
8
  StateDefinitions,
7
9
  } from './types.js';
8
10
 
9
11
  export interface DecentrlApp<
10
12
  TEvents extends EventDefinitions,
11
- TState extends StateDefinitions<TEvents>,
13
+ TPublicEvents extends PublicEventDefinitions,
14
+ TChannels extends ChannelDefinitions,
15
+ TState extends StateDefinitions<TEvents, TPublicEvents, TChannels>,
12
16
  > {
13
- config: DecentrlAppConfig<TEvents, TState>;
14
- createClient(clientConfig: DecentrlClientConfig): DecentrlClient<TEvents, TState>;
17
+ config: DecentrlAppConfig<TEvents, TPublicEvents, TChannels, TState>;
18
+ createClient(
19
+ clientConfig: DecentrlClientConfig,
20
+ ): DecentrlClient<TEvents, TPublicEvents, TChannels, TState>;
15
21
  }
16
22
 
17
23
  export function defineDecentrlApp<
18
24
  TEvents extends EventDefinitions,
19
- TState extends StateDefinitions<TEvents>,
20
- >(config: DecentrlAppConfig<TEvents, TState>): DecentrlApp<TEvents, TState> {
25
+ TPublicEvents extends PublicEventDefinitions = PublicEventDefinitions,
26
+ TChannels extends ChannelDefinitions = ChannelDefinitions,
27
+ TState extends StateDefinitions<TEvents, TPublicEvents, TChannels> = StateDefinitions<
28
+ TEvents,
29
+ TPublicEvents,
30
+ TChannels
31
+ >,
32
+ >(
33
+ config: DecentrlAppConfig<TEvents, TPublicEvents, TChannels, TState>,
34
+ ): DecentrlApp<TEvents, TPublicEvents, TChannels, TState> {
21
35
  return {
22
36
  config,
23
37
  createClient: (clientConfig: DecentrlClientConfig) => new DecentrlClient(config, clientConfig),
@@ -0,0 +1,139 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { DirectTransport } from './direct-transport.js';
3
+
4
+ vi.mock('@decentrl/event-store', () => {
5
+ const mockPublishPublicEvent = vi.fn(async () => ({ publicEventId: 'pub-123' }));
6
+ const mockDeletePublicEvent = vi.fn(async () => {});
7
+ const mockFetchPublicEvents = vi.fn(async () => ({
8
+ data: [
9
+ {
10
+ id: 'evt-1',
11
+ channelId: 'blog',
12
+ event: '{"type":"blog.post","data":{"title":"Hello"}}',
13
+ tags: ['blog'],
14
+ timestamp: 1710000000,
15
+ eventSignature: 'sig-1',
16
+ },
17
+ ],
18
+ pagination: { page: 0, pageSize: 20, total: 1 },
19
+ }));
20
+
21
+ return {
22
+ DecentrlEventStore: class {
23
+ publishPublicEvent = mockPublishPublicEvent;
24
+ deletePublicEvent = mockDeletePublicEvent;
25
+ static fetchPublicEvents = mockFetchPublicEvents;
26
+ },
27
+ __mockPublishPublicEvent: mockPublishPublicEvent,
28
+ __mockDeletePublicEvent: mockDeletePublicEvent,
29
+ __mockFetchPublicEvents: mockFetchPublicEvents,
30
+ };
31
+ });
32
+
33
+ vi.mock(
34
+ '@decentrl/identity/communication-channels/mediator/direct-authenticated/command/command.service',
35
+ () => ({
36
+ generateDirectAuthenticatedMediatorCommand: vi.fn(() => ({ mock: 'command' })),
37
+ generateTwoWayPrivateMediatorCommand: vi.fn(() => ({ mock: 'two-way-command' })),
38
+ }),
39
+ );
40
+
41
+ vi.mock('@decentrl/identity/communication-contract/communication-contract.service', () => ({
42
+ createCommunicationContractRequest: vi.fn(),
43
+ decryptContractRequest: vi.fn(),
44
+ signCommunicationContract: vi.fn(),
45
+ generateContractId: vi.fn(),
46
+ }));
47
+
48
+ vi.mock('@decentrl/identity/did-decentrl/did-decentrl.service', () => ({
49
+ createDecentrlDidFromKeys: vi.fn(),
50
+ }));
51
+
52
+ vi.mock('@decentrl/identity/mediator/mediator.resolver', () => ({
53
+ resolveMediatorServiceEndpoint: vi.fn(),
54
+ }));
55
+
56
+ vi.mock('@decentrl/identity/did-resolver/resolver', () => ({
57
+ resolveDid: vi.fn(),
58
+ }));
59
+
60
+ vi.mock('@decentrl/crypto', () => ({
61
+ base64Decode: vi.fn((s: string) => new TextEncoder().encode(s)),
62
+ base64Encode: vi.fn((u: Uint8Array) => new TextDecoder().decode(u)),
63
+ decryptString: vi.fn(),
64
+ encryptString: vi.fn((plaintext: string) => `encrypted:${plaintext}`),
65
+ generateEncryptedTag: vi.fn((_key: unknown, tag: string) => `etag:${tag}`),
66
+ signJsonObject: vi.fn(() => 'mock-signature'),
67
+ verifyJsonSignature: vi.fn(() => true),
68
+ multibaseDecode: vi.fn(() => new Uint8Array(32)),
69
+ generateIdentityKeys: vi.fn(),
70
+ deriveSharedSecret: vi.fn(),
71
+ }));
72
+
73
+ describe('DirectTransport — public events', () => {
74
+ let transport: DirectTransport;
75
+
76
+ beforeEach(() => {
77
+ transport = new DirectTransport({
78
+ httpPost: vi.fn(async () => ({ data: { type: 'SUCCESS' } })) as any,
79
+ });
80
+
81
+ // Inject identity so the event store can be created
82
+ (transport as any).identity = {
83
+ did: 'did:decentrl:alice',
84
+ alias: 'alice',
85
+ mediatorDid: 'did:web:mediator',
86
+ mediatorEndpoint: 'http://mediator:8080',
87
+ keys: {
88
+ signing: { privateKey: new Uint8Array(32), publicKey: new Uint8Array(32) },
89
+ encryption: { privateKey: new Uint8Array(32), publicKey: new Uint8Array(32) },
90
+ storageKey: new Uint8Array(32),
91
+ },
92
+ mediatorContract: null,
93
+ };
94
+ });
95
+
96
+ describe('publishPublicEvent', () => {
97
+ it('delegates to event store and returns publicEventId', async () => {
98
+ const result = await transport.publishPublicEvent({
99
+ channelId: 'blog',
100
+ event: '{"type":"blog.post","data":{"title":"Hello"}}',
101
+ tags: ['blog'],
102
+ });
103
+
104
+ expect(result.publicEventId).toBe('pub-123');
105
+ });
106
+ });
107
+
108
+ describe('deletePublicEvent', () => {
109
+ it('delegates to event store', async () => {
110
+ await expect(transport.deletePublicEvent('pub-123')).resolves.toBeUndefined();
111
+ });
112
+ });
113
+
114
+ describe('fetchPublicEvents', () => {
115
+ it('fetches events via static method (no identity needed)', async () => {
116
+ const result = await transport.fetchPublicEvents({
117
+ mediatorEndpoint: 'http://mediator:8080',
118
+ publisherDid: 'did:decentrl:bob',
119
+ channelId: 'blog',
120
+ });
121
+
122
+ expect(result.events).toHaveLength(1);
123
+ expect(result.events[0].id).toBe('evt-1');
124
+ expect(result.events[0].channelId).toBe('blog');
125
+ expect(result.pagination.total).toBe(1);
126
+ });
127
+
128
+ it('returns events and pagination from result', async () => {
129
+ const result = await transport.fetchPublicEvents({
130
+ mediatorEndpoint: 'http://mediator:8080',
131
+ publisherDid: 'did:decentrl:bob',
132
+ });
133
+
134
+ expect(result.events).toHaveLength(1);
135
+ expect(result.events[0].channelId).toBe('blog');
136
+ expect(result.pagination).toEqual({ page: 0, pageSize: 20, total: 1 });
137
+ });
138
+ });
139
+ });
@@ -685,6 +685,61 @@ export class DirectTransport implements DecentrlTransport {
685
685
  .map((e) => e.data);
686
686
  }
687
687
 
688
+ async publishPublicEvent(options: {
689
+ channelId: string;
690
+ event: string;
691
+ tags: string[];
692
+ }): Promise<{ publicEventId: string }> {
693
+ const eventStore = this.requireEventStore();
694
+
695
+ return eventStore.publishPublicEvent(options.event, {
696
+ channelId: options.channelId,
697
+ tags: options.tags,
698
+ });
699
+ }
700
+
701
+ async deletePublicEvent(publicEventId: string): Promise<void> {
702
+ const eventStore = this.requireEventStore();
703
+ await eventStore.deletePublicEvent(publicEventId);
704
+ }
705
+
706
+ async fetchPublicEvents(options: {
707
+ mediatorEndpoint: string;
708
+ publisherDid: string;
709
+ channelId?: string;
710
+ tags?: string[];
711
+ afterTimestamp?: number;
712
+ beforeTimestamp?: number;
713
+ page?: number;
714
+ pageSize?: number;
715
+ }): Promise<{
716
+ events: Array<{
717
+ id: string;
718
+ channelId: string;
719
+ event: string;
720
+ tags: string[];
721
+ timestamp: number;
722
+ eventSignature: string;
723
+ }>;
724
+ pagination: { page: number; pageSize: number; total: number };
725
+ }> {
726
+ const result = await DecentrlEventStore.fetchPublicEvents({
727
+ mediatorEndpoint: options.mediatorEndpoint,
728
+ publisherDid: options.publisherDid,
729
+ channelId: options.channelId,
730
+ tags: options.tags,
731
+ afterTimestamp: options.afterTimestamp,
732
+ beforeTimestamp: options.beforeTimestamp,
733
+ pagination:
734
+ options.page != null ? { page: options.page, pageSize: options.pageSize ?? 20 } : undefined,
735
+ });
736
+
737
+ return {
738
+ events: result.data,
739
+ pagination: result.pagination,
740
+ };
741
+ }
742
+
688
743
  dispose(): void {
689
744
  this.eventStore = null;
690
745
  }
@@ -0,0 +1,343 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { z } from 'zod';
3
+ import { EventProcessor } from './event-processor.js';
4
+ import { StateStore } from './state-store.js';
5
+ import type { EventDefinitions, StateDefinitions } from './types.js';
6
+
7
+ // Helper to create processor without strict generics in tests
8
+ function createProcessor(
9
+ stateDefinitions: Record<string, { initial: unknown; reduce: Record<string, unknown> }>,
10
+ publicEventDefinitions?: Record<string, { schema: z.ZodType; tags: string[]; channelId: string }>,
11
+ channelDefinitions?: Record<string, { schema: z.ZodType }>,
12
+ ) {
13
+ const store = new StateStore(
14
+ Object.fromEntries(Object.entries(stateDefinitions).map(([k, v]) => [k, v.initial])),
15
+ );
16
+ const processor = new EventProcessor(
17
+ eventDefinitions as EventDefinitions,
18
+ stateDefinitions as StateDefinitions,
19
+ store as StateStore<Record<string, unknown>>,
20
+ publicEventDefinitions,
21
+ channelDefinitions,
22
+ );
23
+
24
+ return { processor, store };
25
+ }
26
+
27
+ const eventDefinitions = {
28
+ 'chat.message': {
29
+ schema: z.object({ text: z.string() }),
30
+ tags: ['chat'],
31
+ },
32
+ };
33
+
34
+ const publicEventDefinitions = {
35
+ 'blog.post': {
36
+ schema: z.object({ title: z.string(), body: z.string() }),
37
+ tags: ['blog', 'post:${title}'],
38
+ channelId: 'blog',
39
+ },
40
+ };
41
+
42
+ const channelDefinitions = {
43
+ 'feed.item': {
44
+ schema: z.object({ content: z.string() }),
45
+ },
46
+ };
47
+
48
+ describe('EventProcessor — public events', () => {
49
+ describe('validatePublicEvent', () => {
50
+ it('validates known public event types', () => {
51
+ const { processor } = createProcessor(
52
+ { items: { initial: [], reduce: {} } },
53
+ publicEventDefinitions,
54
+ );
55
+
56
+ expect(() =>
57
+ processor.validatePublicEvent('blog.post', { title: 'Hi', body: 'World' }),
58
+ ).not.toThrow();
59
+ });
60
+
61
+ it('throws for unknown public event type', () => {
62
+ const { processor } = createProcessor(
63
+ { items: { initial: [], reduce: {} } },
64
+ publicEventDefinitions,
65
+ );
66
+
67
+ expect(() => processor.validatePublicEvent('blog.unknown', { foo: 'bar' })).toThrow(
68
+ 'Unknown public event type',
69
+ );
70
+ });
71
+
72
+ it('throws for invalid data', () => {
73
+ const { processor } = createProcessor(
74
+ { items: { initial: [], reduce: {} } },
75
+ publicEventDefinitions,
76
+ );
77
+
78
+ expect(() => processor.validatePublicEvent('blog.post', { title: 123 })).toThrow(
79
+ 'Schema validation failed',
80
+ );
81
+ });
82
+ });
83
+
84
+ describe('computePublicTags', () => {
85
+ it('evaluates tag templates for public events', () => {
86
+ const { processor } = createProcessor(
87
+ { items: { initial: [], reduce: {} } },
88
+ publicEventDefinitions,
89
+ );
90
+
91
+ const tags = processor.computePublicTags('blog.post', {
92
+ title: 'Hello',
93
+ body: 'Content',
94
+ });
95
+ expect(tags).toEqual(['blog', 'post:Hello']);
96
+ });
97
+
98
+ it('throws for unknown public event type', () => {
99
+ const { processor } = createProcessor(
100
+ { items: { initial: [], reduce: {} } },
101
+ publicEventDefinitions,
102
+ );
103
+
104
+ expect(() => processor.computePublicTags('unknown', {})).toThrow('Unknown public event type');
105
+ });
106
+ });
107
+
108
+ describe('getPublicChannelId', () => {
109
+ it('returns the channelId for a public event type', () => {
110
+ const { processor } = createProcessor(
111
+ { items: { initial: [], reduce: {} } },
112
+ publicEventDefinitions,
113
+ );
114
+
115
+ expect(processor.getPublicChannelId('blog.post')).toBe('blog');
116
+ });
117
+ });
118
+
119
+ describe('processPublicEvent', () => {
120
+ it('dispatches to public: prefixed reducers', () => {
121
+ const { processor, store } = createProcessor(
122
+ {
123
+ posts: {
124
+ initial: [] as Array<{ title: string }>,
125
+ reduce: {
126
+ 'public:blog.post': (state: unknown[], data: unknown) => [
127
+ ...state,
128
+ { title: (data as { title: string }).title },
129
+ ],
130
+ },
131
+ },
132
+ },
133
+ publicEventDefinitions,
134
+ );
135
+
136
+ const processed = processor.processPublicEvent({
137
+ type: 'blog.post',
138
+ data: { title: 'Test', body: 'Content' },
139
+ meta: {
140
+ publisherDid: 'did:decentrl:alice',
141
+ timestamp: 1234567890,
142
+ eventId: 'evt-1',
143
+ channelId: 'blog',
144
+ },
145
+ });
146
+
147
+ expect(processed).toBe(true);
148
+ expect(store.getState().posts).toEqual([{ title: 'Test' }]);
149
+ });
150
+
151
+ it('deduplicates by eventId', () => {
152
+ const { processor, store } = createProcessor(
153
+ {
154
+ count: {
155
+ initial: 0,
156
+ reduce: {
157
+ 'public:blog.post': (state: number) => state + 1,
158
+ },
159
+ },
160
+ },
161
+ publicEventDefinitions,
162
+ );
163
+
164
+ const envelope = {
165
+ type: 'blog.post',
166
+ data: { title: 'Test', body: 'Content' },
167
+ meta: {
168
+ publisherDid: 'did:decentrl:alice',
169
+ timestamp: 1234567890,
170
+ eventId: 'same-id',
171
+ channelId: 'blog',
172
+ },
173
+ };
174
+
175
+ processor.processPublicEvent(envelope);
176
+ processor.processPublicEvent(envelope);
177
+
178
+ expect(store.getState().count).toBe(1);
179
+ });
180
+
181
+ it('notifies public event listeners', () => {
182
+ const { processor } = createProcessor(
183
+ { items: { initial: [], reduce: {} } },
184
+ publicEventDefinitions,
185
+ );
186
+
187
+ const received: unknown[] = [];
188
+ processor.onPublicEvent((envelope) => received.push(envelope));
189
+
190
+ processor.processPublicEvent({
191
+ type: 'blog.post',
192
+ data: { title: 'Test', body: 'Content' },
193
+ meta: {
194
+ publisherDid: 'did:decentrl:alice',
195
+ timestamp: 1234567890,
196
+ eventId: 'evt-1',
197
+ channelId: 'blog',
198
+ },
199
+ });
200
+
201
+ expect(received).toHaveLength(1);
202
+ expect((received[0] as { type: string }).type).toBe('blog.post');
203
+ });
204
+ });
205
+
206
+ describe('processChannelEvent', () => {
207
+ it('dispatches to channel: prefixed reducers', () => {
208
+ const { processor, store } = createProcessor(
209
+ {
210
+ feed: {
211
+ initial: [] as Array<{ content: string; from: string }>,
212
+ reduce: {
213
+ 'channel:feed.item': (state: unknown[], data: unknown, meta: unknown) => [
214
+ ...state,
215
+ {
216
+ content: (data as { content: string }).content,
217
+ from: (meta as { publisherDid: string }).publisherDid,
218
+ },
219
+ ],
220
+ },
221
+ },
222
+ },
223
+ publicEventDefinitions,
224
+ channelDefinitions,
225
+ );
226
+
227
+ const processed = processor.processChannelEvent({
228
+ type: 'feed.item',
229
+ data: { content: 'Hello from external' },
230
+ meta: {
231
+ publisherDid: 'did:decentrl:bob',
232
+ timestamp: 1234567890,
233
+ eventId: 'ch-1',
234
+ channelId: 'feed',
235
+ },
236
+ });
237
+
238
+ expect(processed).toBe(true);
239
+ expect(store.getState().feed).toEqual([
240
+ { content: 'Hello from external', from: 'did:decentrl:bob' },
241
+ ]);
242
+ });
243
+
244
+ it('deduplicates channel events separately from public events', () => {
245
+ const { processor, store } = createProcessor(
246
+ {
247
+ count: {
248
+ initial: 0,
249
+ reduce: {
250
+ 'channel:feed.item': (state: number) => state + 1,
251
+ 'public:blog.post': (state: number) => state + 1,
252
+ },
253
+ },
254
+ },
255
+ publicEventDefinitions,
256
+ channelDefinitions,
257
+ );
258
+
259
+ processor.processPublicEvent({
260
+ type: 'blog.post',
261
+ data: { title: 'Test', body: 'Content' },
262
+ meta: { publisherDid: 'a', timestamp: 0, eventId: 'same', channelId: 'blog' },
263
+ });
264
+
265
+ processor.processChannelEvent({
266
+ type: 'feed.item',
267
+ data: { content: 'Hello' },
268
+ meta: { publisherDid: 'b', timestamp: 0, eventId: 'same', channelId: 'feed' },
269
+ });
270
+
271
+ expect(store.getState().count).toBe(2);
272
+ });
273
+ });
274
+
275
+ describe('validateChannelEvent', () => {
276
+ it('validates known channel event types', () => {
277
+ const { processor } = createProcessor(
278
+ { items: { initial: [], reduce: {} } },
279
+ publicEventDefinitions,
280
+ channelDefinitions,
281
+ );
282
+
283
+ expect(processor.validateChannelEvent('feed.item', { content: 'hi' })).toBe(true);
284
+ });
285
+
286
+ it('returns false for invalid data', () => {
287
+ const { processor } = createProcessor(
288
+ { items: { initial: [], reduce: {} } },
289
+ publicEventDefinitions,
290
+ channelDefinitions,
291
+ );
292
+
293
+ expect(processor.validateChannelEvent('feed.item', { content: 123 })).toBe(false);
294
+ });
295
+
296
+ it('returns false for unknown channel types', () => {
297
+ const { processor } = createProcessor(
298
+ { items: { initial: [], reduce: {} } },
299
+ publicEventDefinitions,
300
+ channelDefinitions,
301
+ );
302
+
303
+ expect(processor.validateChannelEvent('unknown.type', { foo: 'bar' })).toBe(false);
304
+ });
305
+ });
306
+
307
+ describe('reset', () => {
308
+ it('clears public event listeners and dedup cache', () => {
309
+ const { processor, store } = createProcessor(
310
+ {
311
+ count: {
312
+ initial: 0,
313
+ reduce: { 'public:blog.post': (s: number) => s + 1 },
314
+ },
315
+ },
316
+ publicEventDefinitions,
317
+ );
318
+
319
+ const received: unknown[] = [];
320
+ processor.onPublicEvent((e) => received.push(e));
321
+
322
+ processor.processPublicEvent({
323
+ type: 'blog.post',
324
+ data: { title: 'T', body: 'B' },
325
+ meta: { publisherDid: 'a', timestamp: 0, eventId: 'e1', channelId: 'blog' },
326
+ });
327
+
328
+ expect(received).toHaveLength(1);
329
+
330
+ processor.reset();
331
+
332
+ // Same eventId should process again (dedup cleared), but listener cleared
333
+ processor.processPublicEvent({
334
+ type: 'blog.post',
335
+ data: { title: 'T2', body: 'B2' },
336
+ meta: { publisherDid: 'a', timestamp: 0, eventId: 'e1', channelId: 'blog' },
337
+ });
338
+
339
+ expect(received).toHaveLength(1);
340
+ expect(store.getState().count).toBe(2);
341
+ });
342
+ });
343
+ });