@decentrl/sdk 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/dist/client.d.ts +36 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +192 -0
- package/dist/contract-manager.d.ts +23 -0
- package/dist/contract-manager.d.ts.map +1 -0
- package/dist/contract-manager.js +91 -0
- package/dist/define-app.d.ts +8 -0
- package/dist/define-app.d.ts.map +1 -0
- package/dist/define-app.js +7 -0
- package/dist/direct-transport.d.ts +69 -0
- package/dist/direct-transport.d.ts.map +1 -0
- package/dist/direct-transport.js +450 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +10 -0
- package/dist/event-processor.d.ts +19 -0
- package/dist/event-processor.d.ts.map +1 -0
- package/dist/event-processor.js +93 -0
- package/dist/identity-manager.d.ts +22 -0
- package/dist/identity-manager.d.ts.map +1 -0
- package/dist/identity-manager.js +62 -0
- package/dist/identity-serialization.d.ts +5 -0
- package/dist/identity-serialization.d.ts.map +1 -0
- package/dist/identity-serialization.js +30 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/persistence.d.ts +11 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +82 -0
- package/dist/state-store.d.ts +12 -0
- package/dist/state-store.d.ts.map +1 -0
- package/dist/state-store.js +32 -0
- package/dist/sync-manager.d.ts +33 -0
- package/dist/sync-manager.d.ts.map +1 -0
- package/dist/sync-manager.js +244 -0
- package/dist/tag-templates.d.ts +2 -0
- package/dist/tag-templates.d.ts.map +1 -0
- package/dist/tag-templates.js +23 -0
- package/dist/test-helpers.d.ts +15 -0
- package/dist/test-helpers.d.ts.map +1 -0
- package/dist/test-helpers.js +65 -0
- package/dist/transport.d.ts +41 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +1 -0
- package/dist/types.d.ts +131 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/websocket-transport.d.ts +36 -0
- package/dist/websocket-transport.d.ts.map +1 -0
- package/dist/websocket-transport.js +160 -0
- package/package.json +35 -0
- package/src/client.ts +277 -0
- package/src/contract-manager.test.ts +207 -0
- package/src/contract-manager.ts +130 -0
- package/src/define-app.ts +25 -0
- package/src/direct-transport.test.ts +460 -0
- package/src/direct-transport.ts +729 -0
- package/src/errors.ts +23 -0
- package/src/event-processor.ts +133 -0
- package/src/identity-manager.ts +91 -0
- package/src/identity-serialization.ts +33 -0
- package/src/index.ts +43 -0
- package/src/persistence.ts +103 -0
- package/src/sdk.e2e.test.ts +367 -0
- package/src/state-store.ts +42 -0
- package/src/sync-manager.test.ts +414 -0
- package/src/sync-manager.ts +308 -0
- package/src/tag-templates.test.ts +111 -0
- package/src/tag-templates.ts +30 -0
- package/src/test-helpers.ts +88 -0
- package/src/transport.ts +65 -0
- package/src/types.ts +191 -0
- package/src/websocket-transport.ts +233 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { ContractManager } from './contract-manager.js';
|
|
2
|
+
import { DirectTransport } from './direct-transport.js';
|
|
3
|
+
import { DecentrlSDKError } from './errors.js';
|
|
4
|
+
import { EventProcessor } from './event-processor.js';
|
|
5
|
+
import { IdentityManager } from './identity-manager.js';
|
|
6
|
+
import {
|
|
7
|
+
clearPersisted,
|
|
8
|
+
loadPersistedIdentity,
|
|
9
|
+
loadPersistedState,
|
|
10
|
+
persistIdentity,
|
|
11
|
+
persistState,
|
|
12
|
+
} from './persistence.js';
|
|
13
|
+
import { StateStore } from './state-store.js';
|
|
14
|
+
import { SyncManager } from './sync-manager.js';
|
|
15
|
+
import type { DecentrlTransport } from './transport.js';
|
|
16
|
+
import type {
|
|
17
|
+
DecentrlAppConfig,
|
|
18
|
+
DecentrlClientConfig,
|
|
19
|
+
EventDefinitions,
|
|
20
|
+
EventEnvelope,
|
|
21
|
+
EventMeta,
|
|
22
|
+
InferState,
|
|
23
|
+
PaginatedResult,
|
|
24
|
+
PublishOptions,
|
|
25
|
+
QueryOptions,
|
|
26
|
+
StateDefinitions,
|
|
27
|
+
StateListener,
|
|
28
|
+
SyncOptions,
|
|
29
|
+
} from './types.js';
|
|
30
|
+
import type { ConnectionStatus } from './websocket-transport.js';
|
|
31
|
+
|
|
32
|
+
export class DecentrlClient<
|
|
33
|
+
TEvents extends EventDefinitions,
|
|
34
|
+
TState extends StateDefinitions<TEvents>,
|
|
35
|
+
> {
|
|
36
|
+
private stateStore: StateStore<InferState<TState>>;
|
|
37
|
+
private eventProcessor: EventProcessor<TEvents, TState>;
|
|
38
|
+
private syncManager: SyncManager<TEvents, TState>;
|
|
39
|
+
private transport: DecentrlTransport;
|
|
40
|
+
private unsubscribePersistState: (() => void) | null = null;
|
|
41
|
+
private unsubscribePersistIdentity: (() => void) | null = null;
|
|
42
|
+
|
|
43
|
+
readonly identity: IdentityManager;
|
|
44
|
+
readonly contracts: ContractManager;
|
|
45
|
+
|
|
46
|
+
constructor(
|
|
47
|
+
private appConfig: DecentrlAppConfig<TEvents, TState>,
|
|
48
|
+
private clientConfig: DecentrlClientConfig,
|
|
49
|
+
) {
|
|
50
|
+
// Build initial state from slice definitions
|
|
51
|
+
const initialState = {} as InferState<TState>;
|
|
52
|
+
|
|
53
|
+
for (const key of Object.keys(appConfig.state)) {
|
|
54
|
+
// TS can't statically index InferState<TState> during construction
|
|
55
|
+
(initialState as any)[key] = appConfig.state[key].initial;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Restore persisted state if available
|
|
59
|
+
const persistOpts = clientConfig.persist;
|
|
60
|
+
let restoredState = initialState;
|
|
61
|
+
|
|
62
|
+
if (persistOpts) {
|
|
63
|
+
const saved = loadPersistedState<InferState<TState>>(persistOpts);
|
|
64
|
+
|
|
65
|
+
if (saved) {
|
|
66
|
+
// Merge: use saved values for slices that exist, fall back to initial for new slices
|
|
67
|
+
restoredState = { ...initialState, ...saved };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.stateStore = new StateStore(restoredState);
|
|
72
|
+
this.identity = new IdentityManager();
|
|
73
|
+
this.contracts = new ContractManager();
|
|
74
|
+
|
|
75
|
+
// Set up transport (auto-create DirectTransport if none provided)
|
|
76
|
+
this.transport = clientConfig.transport ?? new DirectTransport();
|
|
77
|
+
this.identity.setTransport(this.transport);
|
|
78
|
+
this.contracts.setTransport(this.transport);
|
|
79
|
+
|
|
80
|
+
// Seed identity from transport (e.g. extension already has it after connect)
|
|
81
|
+
const transportIdentity = this.transport.getIdentity();
|
|
82
|
+
|
|
83
|
+
if (transportIdentity) {
|
|
84
|
+
try {
|
|
85
|
+
this.identity.load(transportIdentity);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.debug('[Decentrl] Failed to seed identity from transport:', err);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.eventProcessor = new EventProcessor(appConfig.events, appConfig.state, this.stateStore);
|
|
92
|
+
|
|
93
|
+
this.syncManager = new SyncManager(
|
|
94
|
+
this.eventProcessor,
|
|
95
|
+
this.contracts,
|
|
96
|
+
this.identity,
|
|
97
|
+
this.transport,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Restore persisted identity
|
|
101
|
+
if (persistOpts) {
|
|
102
|
+
const savedIdentity = loadPersistedIdentity(persistOpts);
|
|
103
|
+
|
|
104
|
+
if (savedIdentity) {
|
|
105
|
+
try {
|
|
106
|
+
this.identity.load(savedIdentity);
|
|
107
|
+
} catch {
|
|
108
|
+
// Corrupted identity data — clear it
|
|
109
|
+
persistIdentity(persistOpts, null);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
this.setupPersistence();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private setupPersistence(): void {
|
|
118
|
+
const persistOpts = this.clientConfig.persist;
|
|
119
|
+
|
|
120
|
+
if (!persistOpts) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Auto-persist identity on changes
|
|
125
|
+
this.unsubscribePersistIdentity = this.identity.onChange((identity) => {
|
|
126
|
+
if (identity) {
|
|
127
|
+
persistIdentity(persistOpts, this.identity.serialize());
|
|
128
|
+
} else {
|
|
129
|
+
persistIdentity(persistOpts, null);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Auto-persist state on changes (debounced)
|
|
134
|
+
let persistTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
135
|
+
this.unsubscribePersistState = this.stateStore.subscribe((state) => {
|
|
136
|
+
if (persistTimeout) {
|
|
137
|
+
clearTimeout(persistTimeout);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
persistTimeout = setTimeout(() => persistState(persistOpts, state), 100);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async publish<K extends string & keyof TEvents>(
|
|
145
|
+
eventType: K,
|
|
146
|
+
data: TEvents[K] extends { schema: infer S extends import('zod').ZodType }
|
|
147
|
+
? import('zod').infer<S>
|
|
148
|
+
: never,
|
|
149
|
+
options?: PublishOptions,
|
|
150
|
+
): Promise<void> {
|
|
151
|
+
// 1. Validate data against schema
|
|
152
|
+
this.eventProcessor.validate(eventType, data);
|
|
153
|
+
|
|
154
|
+
// 2. Build envelope
|
|
155
|
+
const meta: EventMeta = {
|
|
156
|
+
senderDid: this.identity.requireIdentity().did,
|
|
157
|
+
timestamp: Date.now(),
|
|
158
|
+
eventId: crypto.randomUUID(),
|
|
159
|
+
...(options?.ephemeral ? { ephemeral: true } : {}),
|
|
160
|
+
};
|
|
161
|
+
const envelope = this.eventProcessor.buildEnvelope(eventType, data, meta);
|
|
162
|
+
|
|
163
|
+
// 3. Compute tags
|
|
164
|
+
const tags = this.eventProcessor.computeTags(eventType, data);
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
// 4. Publish via transport
|
|
168
|
+
await this.transport.publishEvent(envelope, {
|
|
169
|
+
tags,
|
|
170
|
+
recipient: options?.recipient,
|
|
171
|
+
ephemeral: options?.ephemeral,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// 5. Optimistic local state update
|
|
175
|
+
this.eventProcessor.processEvent(envelope);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
throw new DecentrlSDKError(
|
|
178
|
+
`Failed to publish event "${eventType}": ${error instanceof Error ? error.message : String(error)}`,
|
|
179
|
+
'PUBLISH_FAILED',
|
|
180
|
+
{ eventType, data, error },
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async query(options?: QueryOptions): Promise<PaginatedResult<EventEnvelope>> {
|
|
186
|
+
if (!this.transport.queryEvents) {
|
|
187
|
+
throw new DecentrlSDKError('Transport does not support queryEvents', 'QUERY_NOT_SUPPORTED');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
return await this.transport.queryEvents(options);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error instanceof DecentrlSDKError) {
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
throw new DecentrlSDKError(
|
|
198
|
+
`Failed to query events: ${error instanceof Error ? error.message : String(error)}`,
|
|
199
|
+
'QUERY_FAILED',
|
|
200
|
+
{ options, error },
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
onEvent(listener: (envelope: EventEnvelope) => void): () => void {
|
|
206
|
+
return this.eventProcessor.onEvent(listener);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async queryAndProcess(options?: QueryOptions): Promise<{ processed: number; total: number }> {
|
|
210
|
+
const result = await this.query(options);
|
|
211
|
+
const processed = this.eventProcessor.processBatch(result.data);
|
|
212
|
+
|
|
213
|
+
return { processed, total: result.pagination.total };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
getState = (): InferState<TState> => {
|
|
217
|
+
return this.stateStore.getState();
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
subscribe = (listener: StateListener<InferState<TState>>): (() => void) => {
|
|
221
|
+
return this.stateStore.subscribe(listener);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
startSync(options?: SyncOptions): void {
|
|
225
|
+
this.syncManager.start(options);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
stopSync(): void {
|
|
229
|
+
this.syncManager.stop();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async sync(): Promise<void> {
|
|
233
|
+
await this.syncManager.tick();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
getConnectionStatus = (): ConnectionStatus => {
|
|
237
|
+
return this.syncManager.getConnectionStatus();
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
onConnectionStatusChange = (listener: (status: ConnectionStatus) => void): (() => void) => {
|
|
241
|
+
return this.syncManager.onConnectionStatusChange(listener);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
reset(): void {
|
|
245
|
+
this.syncManager.stop();
|
|
246
|
+
this.identity.reset();
|
|
247
|
+
this.contracts.reset();
|
|
248
|
+
this.eventProcessor.reset();
|
|
249
|
+
|
|
250
|
+
if (this.unsubscribePersistState) {
|
|
251
|
+
this.unsubscribePersistState();
|
|
252
|
+
this.unsubscribePersistState = null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (this.unsubscribePersistIdentity) {
|
|
256
|
+
this.unsubscribePersistIdentity();
|
|
257
|
+
this.unsubscribePersistIdentity = null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Rebuild initial state
|
|
261
|
+
const initialState = {} as InferState<TState>;
|
|
262
|
+
|
|
263
|
+
for (const key of Object.keys(this.appConfig.state)) {
|
|
264
|
+
(initialState as any)[key] = this.appConfig.state[key].initial;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
this.stateStore.reset(initialState);
|
|
268
|
+
|
|
269
|
+
// Clear persisted data
|
|
270
|
+
if (this.clientConfig.persist) {
|
|
271
|
+
clearPersisted(this.clientConfig.persist);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Re-subscribe persistence listeners
|
|
275
|
+
this.setupPersistence();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { ContractManager } from './contract-manager.js';
|
|
3
|
+
import { createMockTransport, makeContract } from './test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('ContractManager', () => {
|
|
6
|
+
let manager: ContractManager;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
manager = new ContractManager();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('getContractByDid', () => {
|
|
13
|
+
it('returns undefined when no contracts exist', () => {
|
|
14
|
+
expect(manager.getContractByDid('did:decentrl:bob')).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('returns the contract for a given DID', async () => {
|
|
18
|
+
const contract = makeContract({
|
|
19
|
+
participantDid: 'did:decentrl:bob',
|
|
20
|
+
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
21
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const transport = createMockTransport({
|
|
25
|
+
refreshContracts: vi.fn(async () => [contract]),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
manager.setTransport(transport);
|
|
29
|
+
await manager.refresh();
|
|
30
|
+
|
|
31
|
+
const result = manager.getContractByDid('did:decentrl:bob');
|
|
32
|
+
expect(result).toBeDefined();
|
|
33
|
+
expect(result!.participantDid).toBe('did:decentrl:bob');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('prefers the contract with the latest expires_at', async () => {
|
|
37
|
+
const now = Math.floor(Date.now() / 1000);
|
|
38
|
+
|
|
39
|
+
const oldContract = makeContract({
|
|
40
|
+
id: 'old',
|
|
41
|
+
participantDid: 'did:decentrl:bob',
|
|
42
|
+
expiresAt: now + 3600,
|
|
43
|
+
timestamp: now,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const newContract = makeContract({
|
|
47
|
+
id: 'new',
|
|
48
|
+
participantDid: 'did:decentrl:bob',
|
|
49
|
+
expiresAt: now + 7200,
|
|
50
|
+
timestamp: now + 100,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const transport = createMockTransport({
|
|
54
|
+
refreshContracts: vi.fn(async () => [oldContract, newContract]),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
manager.setTransport(transport);
|
|
58
|
+
await manager.refresh();
|
|
59
|
+
|
|
60
|
+
const result = manager.getContractByDid('did:decentrl:bob');
|
|
61
|
+
expect(result!.id).toBe('new');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('onChange', () => {
|
|
66
|
+
it('fires listener on refresh', async () => {
|
|
67
|
+
const listener = vi.fn();
|
|
68
|
+
manager.onChange(listener);
|
|
69
|
+
|
|
70
|
+
const transport = createMockTransport({
|
|
71
|
+
refreshContracts: vi.fn(async () => []),
|
|
72
|
+
});
|
|
73
|
+
manager.setTransport(transport);
|
|
74
|
+
|
|
75
|
+
await manager.refresh();
|
|
76
|
+
|
|
77
|
+
expect(listener).toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns unsubscribe function', async () => {
|
|
81
|
+
const listener = vi.fn();
|
|
82
|
+
const unsub = manager.onChange(listener);
|
|
83
|
+
unsub();
|
|
84
|
+
|
|
85
|
+
const transport = createMockTransport({
|
|
86
|
+
refreshContracts: vi.fn(async () => []),
|
|
87
|
+
});
|
|
88
|
+
manager.setTransport(transport);
|
|
89
|
+
|
|
90
|
+
await manager.refresh();
|
|
91
|
+
|
|
92
|
+
expect(listener).not.toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('processAutoRenewals (with transport)', () => {
|
|
97
|
+
it('delegates to transport when available', async () => {
|
|
98
|
+
const transport = createMockTransport();
|
|
99
|
+
manager.setTransport(transport);
|
|
100
|
+
|
|
101
|
+
await manager.processAutoRenewals(0.3);
|
|
102
|
+
|
|
103
|
+
expect(transport.processAutoRenewals).toHaveBeenCalledWith(0.3);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('calls refresh after transport auto-renewal', async () => {
|
|
107
|
+
const contract = makeContract({
|
|
108
|
+
participantDid: 'did:decentrl:bob',
|
|
109
|
+
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
110
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const transport = createMockTransport({
|
|
114
|
+
refreshContracts: vi.fn(async () => [contract]),
|
|
115
|
+
});
|
|
116
|
+
manager.setTransport(transport);
|
|
117
|
+
|
|
118
|
+
await manager.processAutoRenewals();
|
|
119
|
+
|
|
120
|
+
expect(transport.refreshContracts).toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('processContractCleanup (with transport)', () => {
|
|
125
|
+
it('delegates to transport when available', async () => {
|
|
126
|
+
const transport = createMockTransport();
|
|
127
|
+
manager.setTransport(transport);
|
|
128
|
+
|
|
129
|
+
await manager.processContractCleanup();
|
|
130
|
+
|
|
131
|
+
expect(transport.processContractCleanup).toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('calls refresh after transport cleanup', async () => {
|
|
135
|
+
const transport = createMockTransport();
|
|
136
|
+
manager.setTransport(transport);
|
|
137
|
+
|
|
138
|
+
await manager.processContractCleanup();
|
|
139
|
+
|
|
140
|
+
expect(transport.refreshContracts).toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('reset', () => {
|
|
145
|
+
it('clears all contracts and state', async () => {
|
|
146
|
+
const now = Math.floor(Date.now() / 1000);
|
|
147
|
+
const contract = makeContract({
|
|
148
|
+
participantDid: 'did:decentrl:bob',
|
|
149
|
+
expiresAt: now + 3600,
|
|
150
|
+
timestamp: now,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const transport = createMockTransport({
|
|
154
|
+
refreshContracts: vi.fn(async () => [contract]),
|
|
155
|
+
});
|
|
156
|
+
manager.setTransport(transport);
|
|
157
|
+
await manager.refresh();
|
|
158
|
+
|
|
159
|
+
expect(manager.getActiveContracts()).toHaveLength(1);
|
|
160
|
+
|
|
161
|
+
manager.reset();
|
|
162
|
+
|
|
163
|
+
expect(manager.getActiveContracts()).toHaveLength(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('fires onChange listeners', () => {
|
|
167
|
+
const listener = vi.fn();
|
|
168
|
+
manager.onChange(listener);
|
|
169
|
+
|
|
170
|
+
manager.reset();
|
|
171
|
+
|
|
172
|
+
expect(listener).toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('getActiveContracts', () => {
|
|
177
|
+
it('returns only active contracts', async () => {
|
|
178
|
+
const now = Math.floor(Date.now() / 1000);
|
|
179
|
+
|
|
180
|
+
const active = makeContract({
|
|
181
|
+
id: 'active',
|
|
182
|
+
participantDid: 'did:decentrl:bob',
|
|
183
|
+
expiresAt: now + 3600,
|
|
184
|
+
timestamp: now,
|
|
185
|
+
status: 'active',
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const expired = makeContract({
|
|
189
|
+
id: 'expired',
|
|
190
|
+
participantDid: 'did:decentrl:carol',
|
|
191
|
+
expiresAt: now - 100,
|
|
192
|
+
timestamp: now - 7200,
|
|
193
|
+
status: 'expired',
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const transport = createMockTransport({
|
|
197
|
+
refreshContracts: vi.fn(async () => [active, expired]),
|
|
198
|
+
});
|
|
199
|
+
manager.setTransport(transport);
|
|
200
|
+
await manager.refresh();
|
|
201
|
+
|
|
202
|
+
const activeContracts = manager.getActiveContracts();
|
|
203
|
+
expect(activeContracts).toHaveLength(1);
|
|
204
|
+
expect(activeContracts[0].id).toBe('active');
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { DecentrlSDKError } from './errors.js';
|
|
2
|
+
import type { DecentrlTransport } from './transport.js';
|
|
3
|
+
import type { PendingContractRequest, StoredSignedContract } from './types.js';
|
|
4
|
+
|
|
5
|
+
type ContractChangeListener = () => void;
|
|
6
|
+
|
|
7
|
+
export class ContractManager {
|
|
8
|
+
private contracts = new Map<string, StoredSignedContract>();
|
|
9
|
+
private listeners = new Set<ContractChangeListener>();
|
|
10
|
+
private cachedActiveContracts: StoredSignedContract[] = [];
|
|
11
|
+
private transport: DecentrlTransport | null = null;
|
|
12
|
+
|
|
13
|
+
setTransport(transport: DecentrlTransport): void {
|
|
14
|
+
this.transport = transport;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
onChange(listener: ContractChangeListener): () => void {
|
|
18
|
+
this.listeners.add(listener);
|
|
19
|
+
|
|
20
|
+
return () => {
|
|
21
|
+
this.listeners.delete(listener);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private notify(): void {
|
|
26
|
+
this.cachedActiveContracts = Array.from(this.contracts.values()).filter(
|
|
27
|
+
(c) => c.status === 'active',
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
for (const listener of this.listeners) {
|
|
31
|
+
listener();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async request(recipientDid: string, expiresIn = 3600 * 24): Promise<void> {
|
|
36
|
+
if (!this.transport) {
|
|
37
|
+
throw new DecentrlSDKError('Transport not set', 'NO_TRANSPORT');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await this.transport.requestContract(recipientDid, expiresIn);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async getPending(): Promise<PendingContractRequest[]> {
|
|
44
|
+
if (!this.transport) {
|
|
45
|
+
throw new DecentrlSDKError('Transport not set', 'NO_TRANSPORT');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return this.transport.getPendingContracts();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async accept(
|
|
52
|
+
pendingId: string,
|
|
53
|
+
encryptedPayload: string,
|
|
54
|
+
requestorEphemeralPublicKey: string,
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
if (!this.transport) {
|
|
57
|
+
throw new DecentrlSDKError('Transport not set', 'NO_TRANSPORT');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await this.transport.acceptContract(pendingId, encryptedPayload, requestorEphemeralPublicKey);
|
|
61
|
+
await this.refresh();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async refresh(): Promise<void> {
|
|
65
|
+
if (!this.transport) {
|
|
66
|
+
throw new DecentrlSDKError('Transport not set', 'NO_TRANSPORT');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const contracts = await this.transport.refreshContracts();
|
|
70
|
+
this.contracts.clear();
|
|
71
|
+
|
|
72
|
+
for (const contract of contracts) {
|
|
73
|
+
this.contracts.set(contract.id, contract);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
this.notify();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getActiveContracts(): StoredSignedContract[] {
|
|
80
|
+
return this.cachedActiveContracts;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getContractByDid(did: string): StoredSignedContract | undefined {
|
|
84
|
+
let best: StoredSignedContract | undefined;
|
|
85
|
+
|
|
86
|
+
for (const contract of this.contracts.values()) {
|
|
87
|
+
if (contract.participantDid !== did) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!best) {
|
|
92
|
+
best = contract;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const bestExpiry = best.signedCommunicationContract?.communication_contract?.expires_at ?? 0;
|
|
97
|
+
const currExpiry =
|
|
98
|
+
contract.signedCommunicationContract?.communication_contract?.expires_at ?? 0;
|
|
99
|
+
|
|
100
|
+
if (currExpiry > bestExpiry) {
|
|
101
|
+
best = contract;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return best;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async processAutoRenewals(threshold = 0.2): Promise<void> {
|
|
109
|
+
if (!this.transport?.processAutoRenewals) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await this.transport.processAutoRenewals(threshold);
|
|
114
|
+
await this.refresh();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async processContractCleanup(): Promise<void> {
|
|
118
|
+
if (!this.transport?.processContractCleanup) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await this.transport.processContractCleanup();
|
|
123
|
+
await this.refresh();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
reset(): void {
|
|
127
|
+
this.contracts.clear();
|
|
128
|
+
this.notify();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { DecentrlClient } from './client.js';
|
|
2
|
+
import type {
|
|
3
|
+
DecentrlAppConfig,
|
|
4
|
+
DecentrlClientConfig,
|
|
5
|
+
EventDefinitions,
|
|
6
|
+
StateDefinitions,
|
|
7
|
+
} from './types.js';
|
|
8
|
+
|
|
9
|
+
export interface DecentrlApp<
|
|
10
|
+
TEvents extends EventDefinitions,
|
|
11
|
+
TState extends StateDefinitions<TEvents>,
|
|
12
|
+
> {
|
|
13
|
+
config: DecentrlAppConfig<TEvents, TState>;
|
|
14
|
+
createClient(clientConfig: DecentrlClientConfig): DecentrlClient<TEvents, TState>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function defineDecentrlApp<
|
|
18
|
+
TEvents extends EventDefinitions,
|
|
19
|
+
TState extends StateDefinitions<TEvents>,
|
|
20
|
+
>(config: DecentrlAppConfig<TEvents, TState>): DecentrlApp<TEvents, TState> {
|
|
21
|
+
return {
|
|
22
|
+
config,
|
|
23
|
+
createClient: (clientConfig: DecentrlClientConfig) => new DecentrlClient(config, clientConfig),
|
|
24
|
+
};
|
|
25
|
+
}
|