@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
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
import {
|
|
2
|
+
base64Decode,
|
|
3
|
+
base64Encode,
|
|
4
|
+
decryptString,
|
|
5
|
+
deriveSharedSecret,
|
|
6
|
+
encryptString,
|
|
7
|
+
generateEncryptedTag,
|
|
8
|
+
generateIdentityKeys,
|
|
9
|
+
} from '@decentrl/crypto';
|
|
10
|
+
import { DecentrlEventStore } from '@decentrl/event-store';
|
|
11
|
+
import { generateDirectAuthenticatedMediatorCommand } from '@decentrl/identity/communication-channels/mediator/direct-authenticated/command/command.service';
|
|
12
|
+
import type { QueryCommunicationContractsMediatorCommandResponse } from '@decentrl/identity/communication-channels/mediator/direct-authenticated/command/query-communication-contracts.schema';
|
|
13
|
+
import type { QueryPendingCommunicationContractRequestsMediatorCommandResponse } from '@decentrl/identity/communication-channels/mediator/direct-authenticated/command/query-pending-communication-contracts.schema';
|
|
14
|
+
import type { RequestCommunicationContractMediatorCommandResponse } from '@decentrl/identity/communication-channels/mediator/direct-authenticated/command/request-communication-contract.schema';
|
|
15
|
+
import {
|
|
16
|
+
createCommunicationContractRequest,
|
|
17
|
+
decryptContractRequest,
|
|
18
|
+
generateContractId,
|
|
19
|
+
signCommunicationContract,
|
|
20
|
+
} from '@decentrl/identity/communication-contract/communication-contract.service';
|
|
21
|
+
import { createDecentrlDidFromKeys } from '@decentrl/identity/did-decentrl/did-decentrl.service';
|
|
22
|
+
import { resolveDid } from '@decentrl/identity/did-resolver/resolver';
|
|
23
|
+
import { resolveMediatorServiceEndpoint } from '@decentrl/identity/mediator/mediator.resolver';
|
|
24
|
+
import axios from 'axios';
|
|
25
|
+
import { DecentrlSDKError } from './errors.js';
|
|
26
|
+
import { deserializeKeys, serializeIdentity } from './identity-serialization.js';
|
|
27
|
+
import type { DecentrlTransport } from './transport.js';
|
|
28
|
+
import type {
|
|
29
|
+
ArchivedContract,
|
|
30
|
+
EventEnvelope,
|
|
31
|
+
IdentityState,
|
|
32
|
+
PaginatedResult,
|
|
33
|
+
PendingContractRequest,
|
|
34
|
+
QueryOptions,
|
|
35
|
+
SerializedIdentity,
|
|
36
|
+
StoredSignedContract,
|
|
37
|
+
} from './types.js';
|
|
38
|
+
|
|
39
|
+
export type HttpPost = <T = unknown>(
|
|
40
|
+
url: string,
|
|
41
|
+
data: unknown,
|
|
42
|
+
options?: { timeout?: number },
|
|
43
|
+
) => Promise<{ data: T }>;
|
|
44
|
+
|
|
45
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
46
|
+
const MEDIATOR_CONTRACT_LIFETIME_SECONDS = 3600 * 24 * 365; // 1 year
|
|
47
|
+
const DEFAULT_CONTRACT_LIFETIME_SECONDS = 3600 * 24; // 24 hours
|
|
48
|
+
|
|
49
|
+
export interface DirectTransportOptions {
|
|
50
|
+
httpPost?: HttpPost;
|
|
51
|
+
onEphemeralKeysChanged?: (keys: Record<string, string>) => void | Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class DirectTransport implements DecentrlTransport {
|
|
55
|
+
private identity: IdentityState | null = null;
|
|
56
|
+
private eventStore: DecentrlEventStore | null = null;
|
|
57
|
+
private activeContracts: StoredSignedContract[] = [];
|
|
58
|
+
private httpPost: HttpPost;
|
|
59
|
+
private onEphemeralKeysChanged?: (keys: Record<string, string>) => void | Promise<void>;
|
|
60
|
+
|
|
61
|
+
private pendingEphemeralKeys = new Map<string, string>();
|
|
62
|
+
private knownRootSecrets = new Map<string, string>(); // contractId → rootSecret (survives refreshContracts)
|
|
63
|
+
private renewalInFlight = new Set<string>();
|
|
64
|
+
private supersededContracts = new Map<string, number>(); // contractId → supersededAt
|
|
65
|
+
private confirmedNewContracts = new Set<string>(); // contractIds confirmed by counterparty events
|
|
66
|
+
// Staging set: filled by processContractCleanup(), drained by refreshContracts().
|
|
67
|
+
// Must call refreshContracts() after cleanup to apply purges.
|
|
68
|
+
private purgedContractIds = new Set<string>();
|
|
69
|
+
|
|
70
|
+
constructor(options?: DirectTransportOptions) {
|
|
71
|
+
this.httpPost =
|
|
72
|
+
options?.httpPost ??
|
|
73
|
+
(<T>(url: string, data: unknown, opts?: { timeout?: number }) =>
|
|
74
|
+
axios.post<T>(url, data, { timeout: opts?.timeout ?? DEFAULT_TIMEOUT_MS }));
|
|
75
|
+
this.onEphemeralKeysChanged = options?.onEphemeralKeysChanged;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- Identity ---
|
|
79
|
+
|
|
80
|
+
async createIdentity(options: {
|
|
81
|
+
alias: string;
|
|
82
|
+
mediatorDid: string;
|
|
83
|
+
}): Promise<SerializedIdentity> {
|
|
84
|
+
const { alias, mediatorDid } = options;
|
|
85
|
+
|
|
86
|
+
const keys = generateIdentityKeys();
|
|
87
|
+
const did = createDecentrlDidFromKeys(alias, keys, mediatorDid);
|
|
88
|
+
const mediatorEndpoint = await resolveMediatorServiceEndpoint(mediatorDid);
|
|
89
|
+
|
|
90
|
+
const { encryptedPayload, ephemeralKeyPair } = await createCommunicationContractRequest(
|
|
91
|
+
did,
|
|
92
|
+
mediatorDid,
|
|
93
|
+
keys,
|
|
94
|
+
MEDIATOR_CONTRACT_LIFETIME_SECONDS,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const registrationCommand = generateDirectAuthenticatedMediatorCommand(
|
|
98
|
+
did,
|
|
99
|
+
`${did}#signing`,
|
|
100
|
+
mediatorDid,
|
|
101
|
+
{
|
|
102
|
+
type: 'REQUEST_COMMUNICATION_CONTRACT',
|
|
103
|
+
encrypted_contract_request: encryptedPayload,
|
|
104
|
+
requestor_ephemeral_public_key: base64Encode(ephemeralKeyPair.publicKey),
|
|
105
|
+
},
|
|
106
|
+
keys,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const response = await this.httpPost<RequestCommunicationContractMediatorCommandResponse>(
|
|
110
|
+
mediatorEndpoint,
|
|
111
|
+
registrationCommand,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
if (
|
|
115
|
+
response.data.type !== 'SUCCESS' ||
|
|
116
|
+
response.data.code !== 'MEDIATOR_REGISTRATION_SUCCESS'
|
|
117
|
+
) {
|
|
118
|
+
throw new Error('Failed to register with mediator');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.identity = {
|
|
122
|
+
did,
|
|
123
|
+
alias,
|
|
124
|
+
mediatorDid,
|
|
125
|
+
mediatorEndpoint,
|
|
126
|
+
keys,
|
|
127
|
+
mediatorContract: response.data.payload.signed_communication_contract,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
this.eventStore = null;
|
|
131
|
+
|
|
132
|
+
return serializeIdentity(this.identity);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
getIdentity(): SerializedIdentity | null {
|
|
136
|
+
return this.identity ? serializeIdentity(this.identity) : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
loadIdentity(serialized: SerializedIdentity): void {
|
|
140
|
+
this.identity = {
|
|
141
|
+
did: serialized.did,
|
|
142
|
+
alias: serialized.alias,
|
|
143
|
+
mediatorDid: serialized.mediatorDid,
|
|
144
|
+
mediatorEndpoint: serialized.mediatorEndpoint,
|
|
145
|
+
keys: deserializeKeys(serialized),
|
|
146
|
+
mediatorContract: serialized.mediatorContract,
|
|
147
|
+
};
|
|
148
|
+
this.eventStore = null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
loadContracts(contracts: StoredSignedContract[]): void {
|
|
152
|
+
this.activeContracts = [...contracts];
|
|
153
|
+
|
|
154
|
+
for (const c of contracts) {
|
|
155
|
+
if (c.rootSecret) {
|
|
156
|
+
this.knownRootSecrets.set(c.id, c.rootSecret);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
this.eventStore = null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
loadEphemeralKeys(keys: Record<string, string>): void {
|
|
164
|
+
this.pendingEphemeralKeys = new Map(Object.entries(keys));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
getEphemeralKeys(): Record<string, string> {
|
|
168
|
+
return Object.fromEntries(this.pendingEphemeralKeys);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- Events ---
|
|
172
|
+
|
|
173
|
+
async publishEvent(
|
|
174
|
+
envelope: EventEnvelope,
|
|
175
|
+
options: { tags: string[]; recipient?: string; ephemeral?: boolean },
|
|
176
|
+
): Promise<void> {
|
|
177
|
+
const eventStore = this.requireEventStore();
|
|
178
|
+
await eventStore.publishEvent(envelope, {
|
|
179
|
+
tags: options.tags,
|
|
180
|
+
recipient: options.recipient,
|
|
181
|
+
ephemeral: options.ephemeral,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async processPendingEvents(): Promise<EventEnvelope[]> {
|
|
186
|
+
const eventStore = this.requireEventStore();
|
|
187
|
+
|
|
188
|
+
return eventStore.processPendingEvents<EventEnvelope>();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async processPreFetchedPendingEvents(
|
|
192
|
+
rawEvents: Array<{ id: string; sender_did: string; payload: string }>,
|
|
193
|
+
): Promise<EventEnvelope[]> {
|
|
194
|
+
const eventStore = this.requireEventStore();
|
|
195
|
+
|
|
196
|
+
return eventStore.processPreFetchedPendingEvents<EventEnvelope>(rawEvents);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async queryEvents(options?: QueryOptions): Promise<PaginatedResult<EventEnvelope>> {
|
|
200
|
+
const eventStore = this.requireEventStore();
|
|
201
|
+
|
|
202
|
+
return eventStore.queryEvents<EventEnvelope>(options);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async updateEventTags(events: Array<{ eventId: string; tags: string[] }>): Promise<void> {
|
|
206
|
+
const identity = this.requireIdentity();
|
|
207
|
+
const encrypted = events.map((e) => ({
|
|
208
|
+
eventId: e.eventId,
|
|
209
|
+
encryptedTags: e.tags.map((tag) =>
|
|
210
|
+
generateEncryptedTag(identity.keys.signing.privateKey, tag),
|
|
211
|
+
),
|
|
212
|
+
}));
|
|
213
|
+
const eventStore = this.requireEventStore();
|
|
214
|
+
await eventStore.updateEventTags(encrypted);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async queryUnprocessedEvents(pagination?: {
|
|
218
|
+
page: number;
|
|
219
|
+
pageSize: number;
|
|
220
|
+
}): Promise<PaginatedResult<EventEnvelope>> {
|
|
221
|
+
const eventStore = this.requireEventStore();
|
|
222
|
+
|
|
223
|
+
return eventStore.queryUnprocessedEvents<EventEnvelope>(pagination);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- Contracts ---
|
|
227
|
+
|
|
228
|
+
async requestContract(
|
|
229
|
+
recipientDid: string,
|
|
230
|
+
expiresIn = DEFAULT_CONTRACT_LIFETIME_SECONDS,
|
|
231
|
+
): Promise<void> {
|
|
232
|
+
const identity = this.requireIdentity();
|
|
233
|
+
|
|
234
|
+
const { request, ephemeralKeyPair, encryptedPayload } =
|
|
235
|
+
await createCommunicationContractRequest(
|
|
236
|
+
identity.did,
|
|
237
|
+
recipientDid,
|
|
238
|
+
identity.keys,
|
|
239
|
+
expiresIn,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const ephemeralKeyKey = `${identity.did}|${recipientDid}|${request.communication_contract.timestamp}`;
|
|
243
|
+
const encryptedEphemeralKey = encryptString(
|
|
244
|
+
base64Encode(ephemeralKeyPair.privateKey),
|
|
245
|
+
identity.keys.storageKey,
|
|
246
|
+
);
|
|
247
|
+
this.pendingEphemeralKeys.set(ephemeralKeyKey, encryptedEphemeralKey);
|
|
248
|
+
await this.notifyEphemeralKeysChanged();
|
|
249
|
+
|
|
250
|
+
const command = generateDirectAuthenticatedMediatorCommand(
|
|
251
|
+
identity.did,
|
|
252
|
+
`${identity.did}#signing`,
|
|
253
|
+
recipientDid,
|
|
254
|
+
{
|
|
255
|
+
type: 'REQUEST_COMMUNICATION_CONTRACT',
|
|
256
|
+
encrypted_contract_request: encryptedPayload,
|
|
257
|
+
requestor_ephemeral_public_key: base64Encode(ephemeralKeyPair.publicKey),
|
|
258
|
+
},
|
|
259
|
+
identity.keys,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const response = await this.httpPost<RequestCommunicationContractMediatorCommandResponse>(
|
|
263
|
+
identity.mediatorEndpoint,
|
|
264
|
+
command,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
if (response.data.type !== 'SUCCESS') {
|
|
268
|
+
throw new Error('Failed to request communication contract');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async getPendingContracts(): Promise<PendingContractRequest[]> {
|
|
273
|
+
const identity = this.requireIdentity();
|
|
274
|
+
|
|
275
|
+
const command = generateDirectAuthenticatedMediatorCommand(
|
|
276
|
+
identity.did,
|
|
277
|
+
`${identity.did}#signing`,
|
|
278
|
+
identity.mediatorDid,
|
|
279
|
+
{
|
|
280
|
+
type: 'QUERY_PENDING_COMMUNICATION_CONTRACT_REQUESTS',
|
|
281
|
+
pagination: { page: 0, page_size: 50 },
|
|
282
|
+
},
|
|
283
|
+
identity.keys,
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const response =
|
|
287
|
+
await this.httpPost<QueryPendingCommunicationContractRequestsMediatorCommandResponse>(
|
|
288
|
+
identity.mediatorEndpoint,
|
|
289
|
+
command,
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
if (response.data.type !== 'SUCCESS') {
|
|
293
|
+
throw new Error('Failed to query pending contract requests');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return response.data.payload.pending_communication_contract_requests.map((req) => ({
|
|
297
|
+
id: req.id,
|
|
298
|
+
senderDid: req.sender_did,
|
|
299
|
+
encryptedPayload: req.encrypted_contract_request,
|
|
300
|
+
requestorEphemeralPublicKey: req.requestor_ephemeral_public_key,
|
|
301
|
+
}));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async acceptContract(
|
|
305
|
+
pendingId: string,
|
|
306
|
+
encryptedPayload: string,
|
|
307
|
+
requestorEphemeralPublicKey: string,
|
|
308
|
+
): Promise<void> {
|
|
309
|
+
const identity = this.requireIdentity();
|
|
310
|
+
|
|
311
|
+
const contractRequest = decryptContractRequest(
|
|
312
|
+
encryptedPayload,
|
|
313
|
+
identity.keys.encryption.privateKey,
|
|
314
|
+
requestorEphemeralPublicKey,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const acknowledgeCommand = generateDirectAuthenticatedMediatorCommand(
|
|
318
|
+
identity.did,
|
|
319
|
+
`${identity.did}#signing`,
|
|
320
|
+
identity.mediatorDid,
|
|
321
|
+
{
|
|
322
|
+
type: 'ACKNOWLEDGE_PENDING_COMMUNICATION_CONTRACT_REQUESTS',
|
|
323
|
+
communication_contract_ids: [pendingId],
|
|
324
|
+
},
|
|
325
|
+
identity.keys,
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
await this.httpPost(identity.mediatorEndpoint, acknowledgeCommand);
|
|
329
|
+
|
|
330
|
+
const { signedContract, rootSecret } = await signCommunicationContract(
|
|
331
|
+
contractRequest,
|
|
332
|
+
identity.keys,
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const contractId = generateContractId(signedContract.communication_contract);
|
|
336
|
+
|
|
337
|
+
const saveCommand = generateDirectAuthenticatedMediatorCommand(
|
|
338
|
+
identity.did,
|
|
339
|
+
`${identity.did}#signing`,
|
|
340
|
+
identity.mediatorDid,
|
|
341
|
+
{
|
|
342
|
+
type: 'SAVE_COMMUNICATION_CONTRACT',
|
|
343
|
+
signed_communication_contract: signedContract,
|
|
344
|
+
},
|
|
345
|
+
identity.keys,
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
await this.httpPost(identity.mediatorEndpoint, saveCommand);
|
|
349
|
+
|
|
350
|
+
const responseCommand = generateDirectAuthenticatedMediatorCommand(
|
|
351
|
+
identity.did,
|
|
352
|
+
`${identity.did}#signing`,
|
|
353
|
+
contractRequest.communication_contract.requestor_did,
|
|
354
|
+
{
|
|
355
|
+
type: 'COMMUNICATION_CONTRACT_RESPONSE',
|
|
356
|
+
signed_communication_contract: signedContract,
|
|
357
|
+
},
|
|
358
|
+
identity.keys,
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
await this.httpPost(identity.mediatorEndpoint, responseCommand);
|
|
362
|
+
|
|
363
|
+
const participantDid = contractRequest.communication_contract.requestor_did;
|
|
364
|
+
const participantDoc = await resolveDid(participantDid);
|
|
365
|
+
const stored: StoredSignedContract = {
|
|
366
|
+
id: contractId,
|
|
367
|
+
participantDid,
|
|
368
|
+
participantAlias: participantDoc?.alias?.[0],
|
|
369
|
+
signedCommunicationContract: signedContract,
|
|
370
|
+
rootSecret: base64Encode(rootSecret),
|
|
371
|
+
createdAt: Date.now(),
|
|
372
|
+
status: 'active',
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
this.knownRootSecrets.set(contractId, stored.rootSecret);
|
|
376
|
+
this.activeContracts = [...this.activeContracts, stored];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async refreshContracts(): Promise<StoredSignedContract[]> {
|
|
380
|
+
const identity = this.requireIdentity();
|
|
381
|
+
|
|
382
|
+
const command = generateDirectAuthenticatedMediatorCommand(
|
|
383
|
+
identity.did,
|
|
384
|
+
`${identity.did}#signing`,
|
|
385
|
+
identity.mediatorDid,
|
|
386
|
+
{
|
|
387
|
+
type: 'QUERY_COMMUNICATION_CONTRACTS',
|
|
388
|
+
filter: {},
|
|
389
|
+
pagination: { page: 0, page_size: 100 },
|
|
390
|
+
},
|
|
391
|
+
identity.keys,
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
const response = await this.httpPost<QueryCommunicationContractsMediatorCommandResponse>(
|
|
395
|
+
identity.mediatorEndpoint,
|
|
396
|
+
command,
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
if (response.data.type !== 'SUCCESS') {
|
|
400
|
+
throw new Error('Failed to query communication contracts');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const contracts: StoredSignedContract[] = [];
|
|
404
|
+
|
|
405
|
+
for (const contract of response.data.payload.communication_contracts) {
|
|
406
|
+
const signed = contract.signed_communication_contract;
|
|
407
|
+
const participantDid =
|
|
408
|
+
signed.communication_contract.requestor_did === identity.did
|
|
409
|
+
? signed.communication_contract.recipient_did
|
|
410
|
+
: signed.communication_contract.requestor_did;
|
|
411
|
+
|
|
412
|
+
const isExpired = signed.communication_contract.expires_at * 1000 < Date.now();
|
|
413
|
+
|
|
414
|
+
const contractId = generateContractId(signed.communication_contract);
|
|
415
|
+
|
|
416
|
+
let rootSecret = '';
|
|
417
|
+
const existingContract = this.activeContracts.find((c) => c.id === contractId);
|
|
418
|
+
|
|
419
|
+
const knownSecret = this.knownRootSecrets.get(contractId);
|
|
420
|
+
|
|
421
|
+
if (knownSecret) {
|
|
422
|
+
rootSecret = knownSecret;
|
|
423
|
+
} else if (existingContract?.rootSecret) {
|
|
424
|
+
rootSecret = existingContract.rootSecret;
|
|
425
|
+
} else {
|
|
426
|
+
const ephemeralKeyKey = `${identity.did}|${participantDid}|${signed.communication_contract.timestamp}`;
|
|
427
|
+
const encryptedEphemeralKey = this.pendingEphemeralKeys.get(ephemeralKeyKey);
|
|
428
|
+
|
|
429
|
+
if (encryptedEphemeralKey) {
|
|
430
|
+
const ephemeralPrivateKeyB64 = decryptString(
|
|
431
|
+
encryptedEphemeralKey,
|
|
432
|
+
identity.keys.storageKey,
|
|
433
|
+
);
|
|
434
|
+
const ephemeralPrivateKey = base64Decode(ephemeralPrivateKeyB64);
|
|
435
|
+
|
|
436
|
+
// DH(requestor_ephemeral_private, recipient_ephemeral_public)
|
|
437
|
+
const recipientEphemeralPublicKey =
|
|
438
|
+
signed.communication_contract.recipient_encryption_public_key;
|
|
439
|
+
|
|
440
|
+
if (recipientEphemeralPublicKey) {
|
|
441
|
+
const secret = deriveSharedSecret(
|
|
442
|
+
ephemeralPrivateKey,
|
|
443
|
+
base64Decode(recipientEphemeralPublicKey),
|
|
444
|
+
);
|
|
445
|
+
rootSecret = base64Encode(secret);
|
|
446
|
+
this.knownRootSecrets.set(contractId, rootSecret);
|
|
447
|
+
this.pendingEphemeralKeys.delete(ephemeralKeyKey);
|
|
448
|
+
await this.notifyEphemeralKeysChanged();
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
let participantAlias = existingContract?.participantAlias;
|
|
454
|
+
|
|
455
|
+
if (!participantAlias) {
|
|
456
|
+
const participantDoc = await resolveDid(participantDid);
|
|
457
|
+
participantAlias = participantDoc?.alias?.[0];
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
contracts.push({
|
|
461
|
+
id: contractId,
|
|
462
|
+
participantDid,
|
|
463
|
+
participantAlias,
|
|
464
|
+
signedCommunicationContract: signed,
|
|
465
|
+
rootSecret,
|
|
466
|
+
createdAt: Date.now(),
|
|
467
|
+
status: isExpired ? 'expired' : 'active',
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
this.activeContracts = contracts.filter(
|
|
472
|
+
(c) => c.status === 'active' && !this.purgedContractIds.has(c.id),
|
|
473
|
+
);
|
|
474
|
+
this.purgedContractIds.clear();
|
|
475
|
+
|
|
476
|
+
return contracts;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
getActiveContracts(): StoredSignedContract[] {
|
|
480
|
+
return this.activeContracts;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async processAutoRenewals(threshold = 0.2): Promise<void> {
|
|
484
|
+
await this.initiateRenewals(threshold);
|
|
485
|
+
await this.autoAcceptRenewals();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private async initiateRenewals(threshold: number): Promise<void> {
|
|
489
|
+
const identity = this.requireIdentity();
|
|
490
|
+
const now = Date.now();
|
|
491
|
+
|
|
492
|
+
for (const contract of this.activeContracts) {
|
|
493
|
+
const cc = contract.signedCommunicationContract.communication_contract;
|
|
494
|
+
const lifetimeMs = (cc.expires_at - cc.timestamp) * 1000;
|
|
495
|
+
const elapsedMs = now - cc.timestamp * 1000;
|
|
496
|
+
|
|
497
|
+
if (elapsedMs < lifetimeMs * (1 - threshold)) {
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Deterministic initiator: lower DID always sends the renewal request
|
|
502
|
+
if (identity.did >= contract.participantDid) {
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (this.renewalInFlight.has(contract.participantDid)) {
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
this.renewalInFlight.add(contract.participantDid);
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
const lifetimeSeconds = cc.expires_at - cc.timestamp;
|
|
514
|
+
await this.requestContract(contract.participantDid, lifetimeSeconds);
|
|
515
|
+
} catch (err) {
|
|
516
|
+
console.error('[Decentrl] Auto-renewal request failed:', err);
|
|
517
|
+
} finally {
|
|
518
|
+
this.renewalInFlight.delete(contract.participantDid);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private async autoAcceptRenewals(): Promise<void> {
|
|
524
|
+
const identity = this.requireIdentity();
|
|
525
|
+
|
|
526
|
+
const activeDids = new Map<string, StoredSignedContract>();
|
|
527
|
+
|
|
528
|
+
for (const contract of this.activeContracts) {
|
|
529
|
+
const existing = activeDids.get(contract.participantDid);
|
|
530
|
+
|
|
531
|
+
if (
|
|
532
|
+
!existing ||
|
|
533
|
+
contract.signedCommunicationContract.communication_contract.expires_at >
|
|
534
|
+
existing.signedCommunicationContract.communication_contract.expires_at
|
|
535
|
+
) {
|
|
536
|
+
activeDids.set(contract.participantDid, contract);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
let pending: PendingContractRequest[];
|
|
541
|
+
|
|
542
|
+
try {
|
|
543
|
+
pending = await this.getPendingContracts();
|
|
544
|
+
} catch (err) {
|
|
545
|
+
console.error('[Decentrl] Auto-renewal: failed to fetch pending contracts:', err);
|
|
546
|
+
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
for (const req of pending) {
|
|
551
|
+
const existingContract = activeDids.get(req.senderDid);
|
|
552
|
+
|
|
553
|
+
if (!existingContract) {
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
const decrypted = decryptContractRequest(
|
|
559
|
+
req.encryptedPayload,
|
|
560
|
+
identity.keys.encryption.privateKey,
|
|
561
|
+
req.requestorEphemeralPublicKey,
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
const existingCc = existingContract.signedCommunicationContract.communication_contract;
|
|
565
|
+
const existingLifetime = existingCc.expires_at - existingCc.timestamp;
|
|
566
|
+
const newCc = decrypted.communication_contract;
|
|
567
|
+
const newLifetime = newCc.expires_at - newCc.timestamp;
|
|
568
|
+
|
|
569
|
+
if (existingLifetime !== newLifetime) {
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
await this.acceptContract(req.id, req.encryptedPayload, req.requestorEphemeralPublicKey);
|
|
574
|
+
} catch (err) {
|
|
575
|
+
console.error('[Decentrl] Auto-renewal accept failed:', err);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async processContractCleanup(): Promise<void> {
|
|
581
|
+
const now = Date.now();
|
|
582
|
+
const CLEANUP_TIMEOUT_MS = 7 * 24 * 60 * 60 * 1000;
|
|
583
|
+
|
|
584
|
+
// Group active contracts by participantDid
|
|
585
|
+
const byParticipant = new Map<string, StoredSignedContract[]>();
|
|
586
|
+
|
|
587
|
+
for (const contract of this.activeContracts) {
|
|
588
|
+
const list = byParticipant.get(contract.participantDid) ?? [];
|
|
589
|
+
list.push(contract);
|
|
590
|
+
byParticipant.set(contract.participantDid, list);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const toArchive: Array<{ contract: StoredSignedContract; supersededBy: string }> = [];
|
|
594
|
+
|
|
595
|
+
for (const [, contracts] of byParticipant) {
|
|
596
|
+
if (contracts.length < 2) {
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
contracts.sort(
|
|
601
|
+
(a, b) =>
|
|
602
|
+
b.signedCommunicationContract.communication_contract.expires_at -
|
|
603
|
+
a.signedCommunicationContract.communication_contract.expires_at,
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
const newestId = contracts[0].id;
|
|
607
|
+
|
|
608
|
+
for (let i = 1; i < contracts.length; i++) {
|
|
609
|
+
const old = contracts[i];
|
|
610
|
+
|
|
611
|
+
if (!this.supersededContracts.has(old.id)) {
|
|
612
|
+
this.supersededContracts.set(old.id, now);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const supersededAt = this.supersededContracts.get(old.id)!;
|
|
616
|
+
const isTimedOut = now - supersededAt >= CLEANUP_TIMEOUT_MS;
|
|
617
|
+
const isCounterpartyConfirmed = this.confirmedNewContracts.has(newestId);
|
|
618
|
+
|
|
619
|
+
if (isTimedOut || isCounterpartyConfirmed) {
|
|
620
|
+
this.purgedContractIds.add(old.id);
|
|
621
|
+
this.supersededContracts.delete(old.id);
|
|
622
|
+
toArchive.push({ contract: old, supersededBy: newestId });
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Remove purged contracts immediately so getActiveContracts() reflects the change.
|
|
628
|
+
// refreshContracts() will also filter them (and clear the staging set).
|
|
629
|
+
if (this.purgedContractIds.size > 0) {
|
|
630
|
+
this.activeContracts = this.activeContracts.filter((c) => !this.purgedContractIds.has(c.id));
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Publish archived events (best-effort)
|
|
634
|
+
if (toArchive.length > 0 && this.identity) {
|
|
635
|
+
const identity = this.identity;
|
|
636
|
+
|
|
637
|
+
for (const { contract, supersededBy } of toArchive) {
|
|
638
|
+
try {
|
|
639
|
+
const cc = contract.signedCommunicationContract.communication_contract;
|
|
640
|
+
await this.requireEventStore().publishEvent(
|
|
641
|
+
{
|
|
642
|
+
type: 'decentrl.contract.archived',
|
|
643
|
+
data: {
|
|
644
|
+
contractId: contract.id,
|
|
645
|
+
participantDid: contract.participantDid,
|
|
646
|
+
participantAlias: contract.participantAlias,
|
|
647
|
+
timestamp: cc.timestamp,
|
|
648
|
+
expiresAt: cc.expires_at,
|
|
649
|
+
supersededAt: now,
|
|
650
|
+
supersededBy,
|
|
651
|
+
},
|
|
652
|
+
meta: {
|
|
653
|
+
senderDid: identity.did,
|
|
654
|
+
timestamp: now,
|
|
655
|
+
eventId: crypto.randomUUID(),
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
|
+
{ tags: ['decentrl.contract.archived'] },
|
|
659
|
+
);
|
|
660
|
+
} catch (err) {
|
|
661
|
+
console.error('[Decentrl] Failed to archive contract:', err);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async getContractHistory(): Promise<ArchivedContract[]> {
|
|
668
|
+
const eventStore = this.requireEventStore();
|
|
669
|
+
const result = await eventStore.queryEvents<EventEnvelope>({
|
|
670
|
+
tags: ['decentrl.contract.archived'],
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
return result.data
|
|
674
|
+
.filter((e): e is EventEnvelope & { data: ArchivedContract } => {
|
|
675
|
+
const d = e.data;
|
|
676
|
+
|
|
677
|
+
return (
|
|
678
|
+
d != null &&
|
|
679
|
+
typeof d === 'object' &&
|
|
680
|
+
'contractId' in d &&
|
|
681
|
+
'participantDid' in d &&
|
|
682
|
+
'supersededBy' in d
|
|
683
|
+
);
|
|
684
|
+
})
|
|
685
|
+
.map((e) => e.data);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
dispose(): void {
|
|
689
|
+
this.eventStore = null;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// --- Internal helpers ---
|
|
693
|
+
|
|
694
|
+
private async notifyEphemeralKeysChanged(): Promise<void> {
|
|
695
|
+
await this.onEphemeralKeysChanged?.(Object.fromEntries(this.pendingEphemeralKeys));
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private requireIdentity(): IdentityState {
|
|
699
|
+
if (!this.identity) {
|
|
700
|
+
throw new DecentrlSDKError(
|
|
701
|
+
'Identity not initialized. Call createIdentity() or loadIdentity() first.',
|
|
702
|
+
'IDENTITY_NOT_INITIALIZED',
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
return this.identity;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
private requireEventStore(): DecentrlEventStore {
|
|
710
|
+
const identity = this.requireIdentity();
|
|
711
|
+
|
|
712
|
+
if (!this.eventStore) {
|
|
713
|
+
this.eventStore = new DecentrlEventStore({
|
|
714
|
+
identity: {
|
|
715
|
+
did: identity.did,
|
|
716
|
+
keys: identity.keys,
|
|
717
|
+
mediatorEndpoint: identity.mediatorEndpoint,
|
|
718
|
+
mediatorDid: identity.mediatorDid,
|
|
719
|
+
},
|
|
720
|
+
communicationContracts: () => this.activeContracts,
|
|
721
|
+
onContractUsed: (contractId) => {
|
|
722
|
+
this.confirmedNewContracts.add(contractId);
|
|
723
|
+
},
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return this.eventStore;
|
|
728
|
+
}
|
|
729
|
+
}
|