@enbox/agent 0.1.4 → 0.1.6

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.
Files changed (66) hide show
  1. package/dist/browser.mjs +11 -11
  2. package/dist/browser.mjs.map +4 -4
  3. package/dist/esm/anonymous-dwn-api.js +184 -0
  4. package/dist/esm/anonymous-dwn-api.js.map +1 -0
  5. package/dist/esm/dwn-api.js +86 -777
  6. package/dist/esm/dwn-api.js.map +1 -1
  7. package/dist/esm/dwn-encryption.js +342 -0
  8. package/dist/esm/dwn-encryption.js.map +1 -0
  9. package/dist/esm/dwn-key-delivery.js +256 -0
  10. package/dist/esm/dwn-key-delivery.js.map +1 -0
  11. package/dist/esm/dwn-record-upgrade.js +119 -0
  12. package/dist/esm/dwn-record-upgrade.js.map +1 -0
  13. package/dist/esm/dwn-type-guards.js +23 -0
  14. package/dist/esm/dwn-type-guards.js.map +1 -0
  15. package/dist/esm/index.js +6 -0
  16. package/dist/esm/index.js.map +1 -1
  17. package/dist/esm/protocol-utils.js +158 -0
  18. package/dist/esm/protocol-utils.js.map +1 -0
  19. package/dist/esm/store-data-protocols.js +1 -1
  20. package/dist/esm/store-data-protocols.js.map +1 -1
  21. package/dist/esm/sync-engine-level.js +22 -353
  22. package/dist/esm/sync-engine-level.js.map +1 -1
  23. package/dist/esm/sync-messages.js +234 -0
  24. package/dist/esm/sync-messages.js.map +1 -0
  25. package/dist/esm/sync-topological-sort.js +143 -0
  26. package/dist/esm/sync-topological-sort.js.map +1 -0
  27. package/dist/esm/test-harness.js +20 -0
  28. package/dist/esm/test-harness.js.map +1 -1
  29. package/dist/types/anonymous-dwn-api.d.ts +140 -0
  30. package/dist/types/anonymous-dwn-api.d.ts.map +1 -0
  31. package/dist/types/dwn-api.d.ts +36 -179
  32. package/dist/types/dwn-api.d.ts.map +1 -1
  33. package/dist/types/dwn-encryption.d.ts +144 -0
  34. package/dist/types/dwn-encryption.d.ts.map +1 -0
  35. package/dist/types/dwn-key-delivery.d.ts +112 -0
  36. package/dist/types/dwn-key-delivery.d.ts.map +1 -0
  37. package/dist/types/dwn-record-upgrade.d.ts +33 -0
  38. package/dist/types/dwn-record-upgrade.d.ts.map +1 -0
  39. package/dist/types/dwn-type-guards.d.ts +9 -0
  40. package/dist/types/dwn-type-guards.d.ts.map +1 -0
  41. package/dist/types/index.d.ts +6 -0
  42. package/dist/types/index.d.ts.map +1 -1
  43. package/dist/types/protocol-utils.d.ts +70 -0
  44. package/dist/types/protocol-utils.d.ts.map +1 -0
  45. package/dist/types/sync-engine-level.d.ts +5 -42
  46. package/dist/types/sync-engine-level.d.ts.map +1 -1
  47. package/dist/types/sync-messages.d.ts +76 -0
  48. package/dist/types/sync-messages.d.ts.map +1 -0
  49. package/dist/types/sync-topological-sort.d.ts +15 -0
  50. package/dist/types/sync-topological-sort.d.ts.map +1 -0
  51. package/dist/types/test-harness.d.ts +10 -0
  52. package/dist/types/test-harness.d.ts.map +1 -1
  53. package/package.json +5 -5
  54. package/src/anonymous-dwn-api.ts +263 -0
  55. package/src/dwn-api.ts +160 -1015
  56. package/src/dwn-encryption.ts +481 -0
  57. package/src/dwn-key-delivery.ts +370 -0
  58. package/src/dwn-record-upgrade.ts +166 -0
  59. package/src/dwn-type-guards.ts +43 -0
  60. package/src/index.ts +6 -0
  61. package/src/protocol-utils.ts +185 -0
  62. package/src/store-data-protocols.ts +1 -1
  63. package/src/sync-engine-level.ts +24 -413
  64. package/src/sync-messages.ts +277 -0
  65. package/src/sync-topological-sort.ts +167 -0
  66. package/src/test-harness.ts +19 -0
@@ -0,0 +1,370 @@
1
+ import type { PublicKeyJwk } from '@enbox/crypto';
2
+ import type {
3
+ DerivedPrivateJwk,
4
+ EncryptionInput,
5
+ RecordsQueryReply,
6
+ RecordsReadReply,
7
+ } from '@enbox/dwn-sdk-js';
8
+
9
+ import type { Web5PlatformAgent } from './types/agent.js';
10
+ import type {
11
+ DwnMessage,
12
+ DwnMessageReply,
13
+ ProcessDwnRequest,
14
+ } from './types/dwn.js';
15
+
16
+ import {
17
+ ContentEncryptionAlgorithm,
18
+ DataStream,
19
+ KeyDerivationScheme,
20
+ Message,
21
+ Protocols,
22
+ Records,
23
+ } from '@enbox/dwn-sdk-js';
24
+
25
+ import { getDwnServiceEndpointUrls } from './utils.js';
26
+ import { KeyDeliveryProtocolDefinition } from './store-data-protocols.js';
27
+ import { buildEncryptionInput, encryptAndComputeCid, getEncryptionKeyDeriver, getKeyDecrypter, ivLength } from './dwn-encryption.js';
28
+ import { DwnInterface, dwnMessageConstructors } from './types/dwn.js';
29
+
30
+ /**
31
+ * Parameters for writeContextKeyRecord.
32
+ */
33
+ export type WriteContextKeyParams = {
34
+ tenantDid: string;
35
+ recipientDid: string;
36
+ contextKeyData: DerivedPrivateJwk;
37
+ sourceProtocol: string;
38
+ sourceContextId: string;
39
+ recipientKeyDeliveryPublicKey?: { rootKeyId: string; publicKeyJwk: PublicKeyJwk };
40
+ };
41
+
42
+ /**
43
+ * Parameters for fetchContextKeyRecord.
44
+ */
45
+ export type FetchContextKeyParams = {
46
+ ownerDid: string;
47
+ requesterDid: string;
48
+ sourceProtocol: string;
49
+ sourceContextId: string;
50
+ };
51
+
52
+ /** Callback type for processRequest, used by key-delivery functions. */
53
+ type ProcessRequestFn = <T extends DwnInterface>(
54
+ request: ProcessDwnRequest<T>
55
+ ) => Promise<{ reply: DwnMessageReply[T]; message?: DwnMessage[T]; messageCid: string }>;
56
+
57
+ /**
58
+ * Ensures the key delivery protocol is installed on the given tenant's DWN,
59
+ * with `$encryption` keys injected. Uses the same lazy initialization pattern
60
+ * as `DwnDataStore.initialize()`.
61
+ *
62
+ * @param agent - The platform agent
63
+ * @param tenantDid - The DID of the DWN owner
64
+ * @param processRequest - The agent's processRequest method (bound)
65
+ * @param getProtocolDefinition - Function to get a protocol definition
66
+ * @param installedCache - Cache for installation status
67
+ */
68
+ export async function ensureKeyDeliveryProtocol(
69
+ agent: Web5PlatformAgent,
70
+ tenantDid: string,
71
+ processRequest: ProcessRequestFn,
72
+ getProtocolDefinition: (tenantDid: string, protocolUri: string) => Promise<any>,
73
+ installedCache: { get(key: string): boolean | undefined; set(key: string, value: boolean): void; delete(key: string): void },
74
+ protocolDefinitionCache: { delete(key: string): void },
75
+ ): Promise<void> {
76
+ if (installedCache.get(tenantDid)) {
77
+ return;
78
+ }
79
+
80
+ const protocolUri = KeyDeliveryProtocolDefinition.protocol;
81
+ const existing = await getProtocolDefinition(tenantDid, protocolUri);
82
+
83
+ if (!existing) {
84
+ // Derive and inject $encryption keys for each type path
85
+ const keyDeriver = await getEncryptionKeyDeriver(agent, tenantDid);
86
+ const definitionWithKeys = await Protocols.deriveAndInjectPublicEncryptionKeys(
87
+ KeyDeliveryProtocolDefinition,
88
+ keyDeriver,
89
+ );
90
+
91
+ const { reply: { status } } = await processRequest({
92
+ author : tenantDid,
93
+ target : tenantDid,
94
+ messageType : DwnInterface.ProtocolsConfigure,
95
+ messageParams : { definition: definitionWithKeys },
96
+ });
97
+
98
+ if (status.code !== 202) {
99
+ throw new Error(`AgentDwnApi: Failed to install key delivery protocol: ${status.code} - ${status.detail}`);
100
+ }
101
+
102
+ // Invalidate protocol definition cache so subsequent reads pick up the new definition
103
+ protocolDefinitionCache.delete(`${tenantDid}~${protocolUri}`);
104
+ }
105
+
106
+ installedCache.set(tenantDid, true);
107
+ }
108
+
109
+ /**
110
+ * Writes a `contextKey` record to the owner's DWN, delivering an encrypted
111
+ * context key to a participant.
112
+ *
113
+ * The payload is encrypted to the **recipient's** ProtocolPath-derived public
114
+ * key on the key-delivery protocol, so only the recipient can decrypt it.
115
+ *
116
+ * @param agent - The platform agent
117
+ * @param params - The write parameters
118
+ * @param processRequest - The agent's processRequest method (bound)
119
+ * @param ensureProtocol - Function to ensure key delivery protocol is installed
120
+ * @param eagerSend - Function to eagerly send the record to the remote DWN
121
+ * @returns The recordId of the written contextKey record
122
+ */
123
+ export async function writeContextKeyRecord(
124
+ agent: Web5PlatformAgent,
125
+ params: WriteContextKeyParams,
126
+ processRequest: ProcessRequestFn,
127
+ ensureProtocol: (tenantDid: string) => Promise<void>,
128
+ eagerSend: (tenantDid: string, message: DwnMessage[DwnInterface.RecordsWrite]) => Promise<void>,
129
+ ): Promise<string> {
130
+ const { tenantDid, recipientDid, contextKeyData, sourceProtocol, sourceContextId, recipientKeyDeliveryPublicKey } = params;
131
+
132
+ // Ensure the key delivery protocol is installed on the owner's DWN
133
+ await ensureProtocol(tenantDid);
134
+
135
+ const protocolUri = KeyDeliveryProtocolDefinition.protocol;
136
+
137
+ // Serialize the payload to JSON bytes
138
+ const plaintextBytes = new TextEncoder().encode(JSON.stringify(contextKeyData));
139
+
140
+ // Common contextKey record parameters
141
+ const contextKeyParams = {
142
+ protocol : protocolUri,
143
+ protocolPath : 'contextKey',
144
+ dataFormat : 'application/json',
145
+ recipient : recipientDid,
146
+ tags : { protocol: sourceProtocol, contextId: sourceContextId },
147
+ };
148
+
149
+ let message: any;
150
+ let status: { code: number; detail: string };
151
+
152
+ if (recipientKeyDeliveryPublicKey) {
153
+ // --- Encrypt to the recipient's ProtocolPath key (cross-DWN delivery) ---
154
+ // Manually build encryption input targeting the recipient's key so the
155
+ // record is decryptable only by the recipient.
156
+ const algorithm = ContentEncryptionAlgorithm.A256GCM;
157
+ const dataEncryptionKey = crypto.getRandomValues(new Uint8Array(32));
158
+ const dataEncryptionIV = crypto.getRandomValues(new Uint8Array(ivLength(algorithm)));
159
+
160
+ const { encryptedBytes, dataCid, dataSize, authenticationTag } =
161
+ await encryptAndComputeCid(plaintextBytes, dataEncryptionKey, dataEncryptionIV, algorithm);
162
+
163
+ const encryptionInput = {
164
+ ...buildEncryptionInput(
165
+ dataEncryptionKey, dataEncryptionIV,
166
+ recipientKeyDeliveryPublicKey.rootKeyId,
167
+ recipientKeyDeliveryPublicKey.publicKeyJwk,
168
+ KeyDerivationScheme.ProtocolPath,
169
+ ),
170
+ authenticationTag,
171
+ } as EncryptionInput;
172
+
173
+ ({ message, reply: { status } } = await processRequest({
174
+ author : tenantDid,
175
+ target : tenantDid,
176
+ messageType : DwnInterface.RecordsWrite,
177
+ messageParams : { ...contextKeyParams, dataCid, dataSize, encryptionInput },
178
+ dataStream : new Blob([encryptedBytes]),
179
+ }));
180
+ } else {
181
+ // --- Fallback: encrypt to the owner's key (local self-delivery) ---
182
+ // When no recipient key is provided, use the generic processRequest
183
+ // encryption path which encrypts to the DWN owner's ProtocolPath key.
184
+ ({ message, reply: { status } } = await processRequest({
185
+ author : tenantDid,
186
+ target : tenantDid,
187
+ messageType : DwnInterface.RecordsWrite,
188
+ messageParams : contextKeyParams,
189
+ dataStream : new Blob([plaintextBytes], { type: 'application/json' }),
190
+ encryption : true,
191
+ }));
192
+ }
193
+
194
+ if (!(message && status.code === 202)) {
195
+ throw new Error(
196
+ `AgentDwnApi: Failed to write contextKey record for ${recipientDid}: ${status.code} - ${status.detail}`,
197
+ );
198
+ }
199
+
200
+ // Eagerly send the contextKey record to the tenant's remote DWN so that
201
+ // participants can fetch it immediately without waiting for sync.
202
+ // This is fire-and-forget — sync will guarantee eventual consistency.
203
+ eagerSend(tenantDid, message).catch((err: Error) => {
204
+ console.warn(
205
+ `AgentDwnApi: Eager send of contextKey record '${message.recordId}' ` +
206
+ `to remote DWN failed: ${err.message}. Sync will deliver it later.`
207
+ );
208
+ });
209
+
210
+ return message.recordId;
211
+ }
212
+
213
+ /**
214
+ * Eagerly sends a contextKey record to the tenant's remote DWN.
215
+ * This is best-effort — sync guarantees eventual consistency regardless.
216
+ *
217
+ * @param agent - The platform agent
218
+ * @param tenantDid - The DWN owner's DID
219
+ * @param contextKeyMessage - The context key message to send
220
+ * @param getDwnMessage - Function to read a full message from local DWN
221
+ * @param sendDwnRpcRequest - Function to send a DWN RPC request
222
+ */
223
+ export async function eagerSendContextKeyRecord(
224
+ agent: Web5PlatformAgent,
225
+ tenantDid: string,
226
+ contextKeyMessage: DwnMessage[DwnInterface.RecordsWrite],
227
+ getDwnMessage: (params: { author: string; messageType: DwnInterface; messageCid: string }) => Promise<{ message: any; data?: Blob }>,
228
+ sendDwnRpcRequest: (params: { targetDid: string; dwnEndpointUrls: string[]; message: any; data?: Blob }) => Promise<any>,
229
+ ): Promise<void> {
230
+ let dwnEndpointUrls: string[];
231
+ try {
232
+ dwnEndpointUrls = await getDwnServiceEndpointUrls(tenantDid, agent.did);
233
+ } catch {
234
+ // DID resolution or endpoint lookup failed — not fatal, sync will handle it.
235
+ return;
236
+ }
237
+
238
+ if (dwnEndpointUrls.length === 0) {
239
+ return;
240
+ }
241
+
242
+ // Read the full message (including data blob) from the local DWN
243
+ const { data } = await getDwnMessage({
244
+ author : tenantDid,
245
+ messageType : DwnInterface.RecordsWrite,
246
+ messageCid : await Message.getCid(contextKeyMessage),
247
+ });
248
+
249
+ await sendDwnRpcRequest({
250
+ targetDid : tenantDid,
251
+ dwnEndpointUrls,
252
+ message : contextKeyMessage,
253
+ data,
254
+ });
255
+ }
256
+
257
+ /**
258
+ * Fetches and decrypts a `contextKey` record from a DWN, returning the
259
+ * `DerivedPrivateJwk` payload.
260
+ *
261
+ * Supports both local reads (tenant queries own DWN) and remote reads
262
+ * (participant queries the context owner's DWN).
263
+ *
264
+ * @param agent - The platform agent
265
+ * @param params - The fetch parameters
266
+ * @param processRequest - The agent's processRequest method (bound)
267
+ * @param getSigner - Function to get a signer for a DID
268
+ * @param sendDwnRpcRequest - Function to send a DWN RPC request
269
+ * @returns The decrypted `DerivedPrivateJwk`, or `undefined` if no matching record found
270
+ */
271
+ export async function fetchContextKeyRecord(
272
+ agent: Web5PlatformAgent,
273
+ params: FetchContextKeyParams,
274
+ processRequest: ProcessRequestFn,
275
+ getSigner: (author: string) => Promise<any>,
276
+ sendDwnRpcRequest: (params: { targetDid: string; dwnEndpointUrls: string[]; message: any; data?: Blob }) => Promise<any>,
277
+ ): Promise<DerivedPrivateJwk | undefined> {
278
+ const { ownerDid, requesterDid, sourceProtocol, sourceContextId } = params;
279
+ const protocolUri = KeyDeliveryProtocolDefinition.protocol;
280
+ const isLocal = ownerDid === requesterDid;
281
+
282
+ // Shared query filter for both local and remote paths
283
+ const contextKeyFilter = {
284
+ protocol : protocolUri,
285
+ protocolPath : 'contextKey',
286
+ recipient : requesterDid,
287
+ tags : { protocol: sourceProtocol, contextId: sourceContextId },
288
+ };
289
+
290
+ /** Parse decrypted bytes into a DerivedPrivateJwk. */
291
+ const parsePayload = (bytes: Uint8Array): DerivedPrivateJwk =>
292
+ JSON.parse(new TextDecoder().decode(bytes)) as DerivedPrivateJwk;
293
+
294
+ if (isLocal) {
295
+ // Local query: owner queries their own DWN
296
+ const { reply } = await processRequest({
297
+ author : requesterDid,
298
+ target : ownerDid,
299
+ messageType : DwnInterface.RecordsQuery,
300
+ messageParams : { filter: contextKeyFilter },
301
+ });
302
+
303
+ if (reply.status.code !== 200 || !reply.entries?.length) {
304
+ return undefined;
305
+ }
306
+
307
+ // Read the full record to get the data (auto-decrypted by processRequest)
308
+ const recordId = reply.entries[0].recordId;
309
+ const { reply: readReply } = await processRequest({
310
+ author : requesterDid,
311
+ target : ownerDid,
312
+ messageType : DwnInterface.RecordsRead,
313
+ messageParams : { filter: { recordId } },
314
+ encryption : true,
315
+ });
316
+
317
+ const readResult = readReply as RecordsReadReply;
318
+ if (!readResult.entry?.data) {
319
+ return undefined;
320
+ }
321
+
322
+ return parsePayload(await DataStream.toBytes(readResult.entry.data));
323
+ } else {
324
+ // Remote query: participant queries the context owner's DWN
325
+ const signer = await getSigner(requesterDid);
326
+ const dwnEndpointUrls = await getDwnServiceEndpointUrls(ownerDid, agent.did);
327
+
328
+ const recordsQuery = await dwnMessageConstructors[DwnInterface.RecordsQuery].create({
329
+ signer,
330
+ filter: contextKeyFilter,
331
+ });
332
+
333
+ const queryReply = await sendDwnRpcRequest({
334
+ targetDid : ownerDid,
335
+ dwnEndpointUrls,
336
+ message : recordsQuery.message,
337
+ }) as RecordsQueryReply;
338
+
339
+ if (queryReply.status.code !== 200 || !queryReply.entries?.length) {
340
+ return undefined;
341
+ }
342
+
343
+ // Read the full record remotely
344
+ const recordId = queryReply.entries[0].recordId;
345
+ const recordsRead = await dwnMessageConstructors[DwnInterface.RecordsRead].create({
346
+ signer,
347
+ filter: { recordId },
348
+ });
349
+
350
+ const readReply = await sendDwnRpcRequest({
351
+ targetDid : ownerDid,
352
+ dwnEndpointUrls,
353
+ message : recordsRead.message,
354
+ }) as RecordsReadReply;
355
+
356
+ if (!readReply.entry?.data || !readReply.entry?.recordsWrite) {
357
+ return undefined;
358
+ }
359
+
360
+ // Decrypt the contextKey payload using the requester's key-delivery protocol path key
361
+ const keyDecrypter = await getKeyDecrypter(agent, requesterDid);
362
+ const decryptedStream = await Records.decrypt(
363
+ readReply.entry.recordsWrite,
364
+ keyDecrypter,
365
+ readReply.entry.data as ReadableStream<Uint8Array>,
366
+ );
367
+
368
+ return parsePayload(await DataStream.toBytes(decryptedStream));
369
+ }
370
+ }
@@ -0,0 +1,166 @@
1
+ import type { KeyIdentifier } from '@enbox/crypto';
2
+ import type {
3
+ Dwn,
4
+ EncryptionInput,
5
+ RecordsQueryReplyEntry,
6
+ RecordsWrite,
7
+ RecordsWriteMessage,
8
+ } from '@enbox/dwn-sdk-js';
9
+
10
+ import type { DwnSigner } from './types/dwn.js';
11
+ import type { Web5PlatformAgent } from './types/agent.js';
12
+
13
+ import {
14
+ Encoder,
15
+ KeyDerivationScheme,
16
+ Message,
17
+ Records,
18
+ } from '@enbox/dwn-sdk-js';
19
+
20
+ import { deriveContextEncryptionInput, getKeyDecrypter } from './dwn-encryption.js';
21
+ import { DwnInterface, dwnMessageConstructors } from './types/dwn.js';
22
+
23
+ /**
24
+ * Reactively upgrades an externally-authored root record that has only
25
+ * ProtocolPath encryption by appending a ProtocolContext recipient entry.
26
+ *
27
+ * After the upgrade, both the owner (ProtocolPath) and context key holders —
28
+ * including the external author (ProtocolContext) — can decrypt the record.
29
+ *
30
+ * Steps:
31
+ * 1. Decrypt the DEK using the owner's ProtocolPath-derived private key
32
+ * 2. Derive the context public key from the owner's #enc key
33
+ * 3. ECIES-encrypt the same DEK to the context public key
34
+ * 4. Append the ProtocolContext recipient entry (using PR 0b append mode)
35
+ * 5. Re-sign the record as owner
36
+ *
37
+ * @param agent - The platform agent
38
+ * @param tenantDid - The DWN owner's DID
39
+ * @param recordsWrite - The RecordsWrite message to upgrade
40
+ * @param dwn - The DWN instance
41
+ * @param getSigner - Function to get a DWN signer
42
+ * @param contextKeyCache - Cache for context key info
43
+ */
44
+ export async function upgradeExternalRootRecord(
45
+ agent: Web5PlatformAgent,
46
+ tenantDid: string,
47
+ recordsWrite: RecordsWriteMessage,
48
+ dwn: Dwn,
49
+ getSigner: (author: string) => Promise<DwnSigner>,
50
+ contextKeyCache: { set(key: string, value: { keyId: string; keyUri: KeyIdentifier; contextDerivationPath: string[] }): void },
51
+ ): Promise<void> {
52
+ const { encryption } = recordsWrite;
53
+ if (!encryption) { return; }
54
+
55
+ // Verify: has ProtocolPath but NOT ProtocolContext
56
+ const hasProtocolPath = encryption.recipients.some(
57
+ (r: { header: { derivationScheme: string } }) => r.header.derivationScheme === KeyDerivationScheme.ProtocolPath
58
+ );
59
+ const hasProtocolContext = encryption.recipients.some(
60
+ (r: { header: { derivationScheme: string } }) => r.header.derivationScheme === KeyDerivationScheme.ProtocolContext
61
+ );
62
+ if (!hasProtocolPath || hasProtocolContext) { return; }
63
+
64
+ // 1. Decrypt the DEK using the owner's ProtocolPath key
65
+ const keyDecrypter = await getKeyDecrypter(agent, tenantDid);
66
+
67
+ // Find the ProtocolPath recipient entry
68
+ const pathRecipient = encryption.recipients.find(
69
+ (r: { header: { derivationScheme: string } }) => r.header.derivationScheme === KeyDerivationScheme.ProtocolPath
70
+ )!;
71
+
72
+ const fullDerivationPath = Records.constructKeyDerivationPathUsingProtocolPathScheme(
73
+ recordsWrite.descriptor,
74
+ );
75
+
76
+ const dataEncryptionKey = await keyDecrypter.decrypt(
77
+ fullDerivationPath,
78
+ {
79
+ encryptedKey : Encoder.base64UrlToBytes(pathRecipient.encrypted_key),
80
+ ephemeralPublicKey : pathRecipient.header.epk,
81
+ },
82
+ );
83
+
84
+ // 2. Derive the context public key — contextId = recordId for root records
85
+ const contextId = recordsWrite.recordId;
86
+ const encryptionIV = Encoder.base64UrlToBytes(encryption.iv);
87
+
88
+ // 3 & 4. Append the ProtocolContext recipient entry using append mode.
89
+ // Append mode preserves the author's identity and authorization so that
90
+ // signAsOwner() can be called in step 5.
91
+ const { encryptionInput: contextEncryptionInput, keyId, keyUri, contextDerivationPath } =
92
+ await deriveContextEncryptionInput(agent, tenantDid, contextId, dataEncryptionKey, encryptionIV);
93
+
94
+ // Set the authentication tag from the existing JWE encryption property
95
+ const fullContextInput = { ...contextEncryptionInput, authenticationTag: Encoder.base64UrlToBytes(encryption.tag) };
96
+
97
+ // Parse the message to get a RecordsWrite instance we can mutate
98
+ const recordsWriteInstance = await dwnMessageConstructors[DwnInterface.RecordsWrite].parse(
99
+ recordsWrite,
100
+ ) as unknown as RecordsWrite;
101
+
102
+ await recordsWriteInstance.encryptSymmetricEncryptionKey(
103
+ fullContextInput as EncryptionInput,
104
+ { append: true },
105
+ );
106
+
107
+ // 5. Re-sign as owner — the author's signature is preserved but its
108
+ // encryptionCid is now stale; the owner's signature vouches for the
109
+ // updated encryption property.
110
+ const signer = await getSigner(tenantDid);
111
+ await recordsWriteInstance.signAsOwner(signer);
112
+
113
+ // Store the upgraded message directly via the message store, bypassing
114
+ // the handler's conflict resolution which doesn't support same-timestamp
115
+ // owner-augmented replacements. The data is unchanged — only the encryption
116
+ // metadata and authorization are updated.
117
+ //
118
+ // We must also update the state index and event stream to keep sync and
119
+ // real-time subscribers consistent — without this, the upgraded record
120
+ // would never propagate to remote DWNs or notify subscribers.
121
+ const { messageStore, stateIndex, eventStream } = dwn.storage;
122
+
123
+ // Validate the upgrade only changed encryption and authorization fields.
124
+ // The descriptor, recordId, contextId, and data must remain identical.
125
+ // Note: parse() may produce a new descriptor object, so we compare by value.
126
+ const upgradedMessage = recordsWriteInstance.message as RecordsQueryReplyEntry;
127
+ if (JSON.stringify(upgradedMessage.descriptor) !== JSON.stringify(recordsWrite.descriptor)) {
128
+ throw new Error('AgentDwnApi: upgradeExternalRootRecord() must not modify the descriptor.');
129
+ }
130
+ if (upgradedMessage.recordId !== recordsWrite.recordId) {
131
+ throw new Error('AgentDwnApi: upgradeExternalRootRecord() must not modify the recordId.');
132
+ }
133
+
134
+ // Fetch the stored original (which carries encodedData for small payloads)
135
+ const originalCid = await Message.getCid(recordsWrite);
136
+ const storedOriginal = await messageStore.get(tenantDid, originalCid) as RecordsQueryReplyEntry | undefined;
137
+
138
+ // Build indexes for the upgraded message (mark as latest base state)
139
+ const isLatestBaseState = true;
140
+ const upgradedIndexes = await recordsWriteInstance.constructIndexes(isLatestBaseState);
141
+
142
+ // Carry over the encoded data from the stored original (the handler
143
+ // base64url-encodes small payloads into encodedData during processMessage)
144
+ if (storedOriginal?.encodedData) {
145
+ upgradedMessage.encodedData = storedOriginal.encodedData;
146
+ }
147
+
148
+ // Use put-before-delete ordering: if a crash occurs after the put but
149
+ // before the delete, we end up with a duplicate (recoverable via the
150
+ // isLatestBaseState index) rather than data loss (unrecoverable).
151
+ const upgradedCid = await Message.getCid(upgradedMessage);
152
+ await messageStore.put(tenantDid, upgradedMessage, upgradedIndexes);
153
+ await stateIndex.insert(tenantDid, upgradedCid, upgradedIndexes);
154
+
155
+ // Now remove the original message and its state index entry.
156
+ await messageStore.delete(tenantDid, originalCid);
157
+ await stateIndex.delete(tenantDid, [originalCid]);
158
+
159
+ // Notify real-time subscribers (mirrors handler behavior)
160
+ if (eventStream !== undefined) {
161
+ eventStream.emit(tenantDid, { message: upgradedMessage }, upgradedIndexes);
162
+ }
163
+
164
+ // Cache context key info for subsequent writes in this context
165
+ contextKeyCache.set(contextId, { keyId, keyUri, contextDerivationPath });
166
+ }
@@ -0,0 +1,43 @@
1
+ import type { GenericMessage } from '@enbox/dwn-sdk-js';
2
+
3
+ import type {
4
+ DwnMessage,
5
+ DwnMessagesPermissionScope,
6
+ DwnPermissionScope,
7
+ DwnRecordsInterfaces,
8
+ DwnRecordsPermissionScope,
9
+ ProcessDwnRequest,
10
+ } from './types/dwn.js';
11
+
12
+ import { DwnInterfaceName } from '@enbox/dwn-sdk-js';
13
+
14
+ import { DwnInterface } from './types/dwn.js';
15
+
16
+ export function isDwnRequest<T extends DwnInterface>(
17
+ dwnRequest: ProcessDwnRequest<DwnInterface>, messageType: T
18
+ ): dwnRequest is ProcessDwnRequest<T> {
19
+ return dwnRequest.messageType === messageType;
20
+ }
21
+
22
+ export function isDwnMessage<T extends DwnInterface>(
23
+ messageType: T, message: GenericMessage
24
+ ): message is DwnMessage[T] {
25
+ const incomingMessageInterfaceName = message.descriptor.interface + message.descriptor.method;
26
+ return incomingMessageInterfaceName === messageType;
27
+ }
28
+
29
+ export function isRecordsType(messageType: DwnInterface): messageType is DwnRecordsInterfaces {
30
+ return messageType === DwnInterface.RecordsDelete ||
31
+ messageType === DwnInterface.RecordsQuery ||
32
+ messageType === DwnInterface.RecordsRead ||
33
+ messageType === DwnInterface.RecordsSubscribe ||
34
+ messageType === DwnInterface.RecordsWrite;
35
+ }
36
+
37
+ export function isRecordPermissionScope(scope: DwnPermissionScope): scope is DwnRecordsPermissionScope {
38
+ return scope.interface === DwnInterfaceName.Records;
39
+ }
40
+
41
+ export function isMessagesPermissionScope(scope: DwnPermissionScope): scope is DwnMessagesPermissionScope {
42
+ return scope.interface === DwnInterfaceName.Messages;
43
+ }
package/src/index.ts CHANGED
@@ -8,10 +8,16 @@ export type * from './types/sync.js';
8
8
  export type * from './types/vc.js';
9
9
 
10
10
  export * from './agent-did-resolver-cache.js';
11
+ export * from './anonymous-dwn-api.js';
11
12
  export * from './bearer-identity.js';
12
13
  export * from './crypto-api.js';
13
14
  export * from './did-api.js';
14
15
  export * from './dwn-api.js';
16
+ export * from './dwn-encryption.js';
17
+ export * from './dwn-key-delivery.js';
18
+ export * from './dwn-record-upgrade.js';
19
+ export * from './dwn-type-guards.js';
20
+ export * from './protocol-utils.js';
15
21
  export * from './hd-identity-vault.js';
16
22
  export * from './identity-api.js';
17
23
  export * from './local-key-manager.js';