@enbox/dwn-sdk-js 0.4.14 → 0.4.16

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 (85) hide show
  1. package/dist/browser.mjs +1 -1
  2. package/dist/browser.mjs.map +4 -4
  3. package/dist/esm/src/core/constants.js +2 -0
  4. package/dist/esm/src/core/constants.js.map +1 -1
  5. package/dist/esm/src/core/dwn-error.js +2 -0
  6. package/dist/esm/src/core/dwn-error.js.map +1 -1
  7. package/dist/esm/src/core/protocol-authorization-validation.js +9 -0
  8. package/dist/esm/src/core/protocol-authorization-validation.js.map +1 -1
  9. package/dist/esm/src/handlers/messages-query.js +2 -29
  10. package/dist/esm/src/handlers/messages-query.js.map +1 -1
  11. package/dist/esm/src/handlers/messages-subscribe.js +52 -1
  12. package/dist/esm/src/handlers/messages-subscribe.js.map +1 -1
  13. package/dist/esm/src/handlers/protocols-configure.js +157 -7
  14. package/dist/esm/src/handlers/protocols-configure.js.map +1 -1
  15. package/dist/esm/src/index.js +2 -2
  16. package/dist/esm/src/index.js.map +1 -1
  17. package/dist/esm/src/protocols/encryption.js +41 -10
  18. package/dist/esm/src/protocols/encryption.js.map +1 -1
  19. package/dist/esm/src/store/message-store-level.js +4 -2
  20. package/dist/esm/src/store/message-store-level.js.map +1 -1
  21. package/dist/esm/src/types/protocols-types.js.map +1 -1
  22. package/dist/esm/src/utils/messages.js +36 -0
  23. package/dist/esm/src/utils/messages.js.map +1 -1
  24. package/dist/esm/src/utils/replication.js +14 -0
  25. package/dist/esm/src/utils/replication.js.map +1 -1
  26. package/dist/esm/tests/features/protocol-composition.spec.js +5 -1
  27. package/dist/esm/tests/features/protocol-composition.spec.js.map +1 -1
  28. package/dist/esm/tests/handlers/messages-query.spec.js +7 -15
  29. package/dist/esm/tests/handlers/messages-query.spec.js.map +1 -1
  30. package/dist/esm/tests/handlers/messages-subscribe.spec.js +129 -1
  31. package/dist/esm/tests/handlers/messages-subscribe.spec.js.map +1 -1
  32. package/dist/esm/tests/handlers/protocols-configure.spec.js +244 -1
  33. package/dist/esm/tests/handlers/protocols-configure.spec.js.map +1 -1
  34. package/dist/esm/tests/handlers/records-read.spec.js +2 -1
  35. package/dist/esm/tests/handlers/records-read.spec.js.map +1 -1
  36. package/dist/esm/tests/handlers/records-write.spec.js +24 -1
  37. package/dist/esm/tests/handlers/records-write.spec.js.map +1 -1
  38. package/dist/esm/tests/protocols/encryption.spec.js +3 -1
  39. package/dist/esm/tests/protocols/encryption.spec.js.map +1 -1
  40. package/dist/esm/tests/store/message-store-level.spec.js +21 -0
  41. package/dist/esm/tests/store/message-store-level.spec.js.map +1 -1
  42. package/dist/types/src/core/constants.d.ts +2 -0
  43. package/dist/types/src/core/constants.d.ts.map +1 -1
  44. package/dist/types/src/core/dwn-error.d.ts +2 -0
  45. package/dist/types/src/core/dwn-error.d.ts.map +1 -1
  46. package/dist/types/src/core/protocol-authorization-validation.d.ts.map +1 -1
  47. package/dist/types/src/handlers/messages-query.d.ts +0 -2
  48. package/dist/types/src/handlers/messages-query.d.ts.map +1 -1
  49. package/dist/types/src/handlers/messages-subscribe.d.ts +16 -0
  50. package/dist/types/src/handlers/messages-subscribe.d.ts.map +1 -1
  51. package/dist/types/src/handlers/protocols-configure.d.ts +17 -0
  52. package/dist/types/src/handlers/protocols-configure.d.ts.map +1 -1
  53. package/dist/types/src/index.d.ts +2 -2
  54. package/dist/types/src/index.d.ts.map +1 -1
  55. package/dist/types/src/protocols/encryption.d.ts +3 -1
  56. package/dist/types/src/protocols/encryption.d.ts.map +1 -1
  57. package/dist/types/src/store/message-store-level.d.ts.map +1 -1
  58. package/dist/types/src/types/messages-types.d.ts +14 -0
  59. package/dist/types/src/types/messages-types.d.ts.map +1 -1
  60. package/dist/types/src/types/protocols-types.d.ts +2 -1
  61. package/dist/types/src/types/protocols-types.d.ts.map +1 -1
  62. package/dist/types/src/utils/messages.d.ts +8 -0
  63. package/dist/types/src/utils/messages.d.ts.map +1 -1
  64. package/dist/types/src/utils/replication.d.ts +8 -1
  65. package/dist/types/src/utils/replication.d.ts.map +1 -1
  66. package/dist/types/tests/features/protocol-composition.spec.d.ts.map +1 -1
  67. package/dist/types/tests/handlers/messages-query.spec.d.ts.map +1 -1
  68. package/dist/types/tests/handlers/messages-subscribe.spec.d.ts.map +1 -1
  69. package/dist/types/tests/handlers/protocols-configure.spec.d.ts.map +1 -1
  70. package/dist/types/tests/handlers/records-read.spec.d.ts.map +1 -1
  71. package/dist/types/tests/handlers/records-write.spec.d.ts.map +1 -1
  72. package/package.json +1 -1
  73. package/src/core/constants.ts +2 -0
  74. package/src/core/dwn-error.ts +2 -0
  75. package/src/core/protocol-authorization-validation.ts +14 -0
  76. package/src/handlers/messages-query.ts +3 -41
  77. package/src/handlers/messages-subscribe.ts +67 -3
  78. package/src/handlers/protocols-configure.ts +236 -7
  79. package/src/index.ts +3 -0
  80. package/src/protocols/encryption.ts +54 -11
  81. package/src/store/message-store-level.ts +4 -2
  82. package/src/types/messages-types.ts +14 -0
  83. package/src/types/protocols-types.ts +2 -1
  84. package/src/utils/messages.ts +45 -0
  85. package/src/utils/replication.ts +19 -1
@@ -1,3 +1,4 @@
1
+ import type { Filter } from '../types/query-types.js';
1
2
  import type { GenericMessageReply } from '../types/message-types.js';
2
3
  import type { ProgressToken } from '../types/subscriptions.js';
3
4
  import type { RecordsWriteMessage } from '../types/records-types.js';
@@ -6,12 +7,14 @@ import type { HandlerDependencies, MethodHandler } from '../types/method-handler
6
7
  import type { ProtocolDefinition, ProtocolRuleSet, ProtocolsConfigureMessage } from '../types/protocols-types.js';
7
8
 
8
9
  import { authenticate } from '../core/auth.js';
10
+ import { isEncryptionControlPath } from '../core/constants.js';
9
11
  import { Message } from '../core/message.js';
10
12
  import { messageReplyFromError } from '../core/message-reply.js';
11
13
  import { ProtocolAuthorization } from '../core/protocol-authorization.js';
12
14
  import { ProtocolsConfigure } from '../interfaces/protocols-configure.js';
13
15
  import { ProtocolsGrantAuthorization } from '../core/protocols-grant-authorization.js';
14
16
  import { RecordsWrite } from '../interfaces/records-write.js';
17
+ import { resolveProtocolTypesForPath } from '../core/protocol-authorization-validation.js';
15
18
  import { StorageController } from '../store/storage-controller.js';
16
19
  import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
17
20
  import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
@@ -22,6 +25,7 @@ type StoredInitialWriteConfigValidity = 'valid' | 'invalid' | 'unknown';
22
25
  const STORED_INITIAL_WRITE_CONFIG_INVALID_CODES = new Set<string>([
23
26
  DwnErrorCode.EncryptionControlValidateAudienceRolePathInvalid,
24
27
  DwnErrorCode.EncryptionControlValidateAudienceSealKeyIdMismatch,
28
+ DwnErrorCode.ProtocolAuthorizationEncryptionNotAllowed,
25
29
  DwnErrorCode.ProtocolAuthorizationEncryptionRequired,
26
30
  DwnErrorCode.ProtocolAuthorizationIncorrectDataFormat,
27
31
  DwnErrorCode.ProtocolAuthorizationInitialWriteRevalidationNotInitial,
@@ -91,8 +95,20 @@ export class ProtocolsConfigureHandler implements MethodHandler {
91
95
  }
92
96
  }
93
97
 
98
+ let newestMessage = await Message.getNewestMessage(existingMessages) as ProtocolsConfigureMessage | undefined;
99
+ let prefetchedRecordsWrites: RecordsWriteMessage[] | undefined;
100
+ try {
101
+ prefetchedRecordsWrites = await this.validateEncryptionPolicyIsImmutable(
102
+ tenant,
103
+ message.descriptor.definition,
104
+ message.descriptor.messageTimestamp,
105
+ newestMessage,
106
+ );
107
+ } catch (e) {
108
+ return messageReplyFromError(e, 400);
109
+ }
110
+
94
111
  // find newest message, and if the incoming message is the newest
95
- let newestMessage = await Message.getNewestMessage(existingMessages);
96
112
  let incomingMessageIsNewest = false;
97
113
  if (newestMessage === undefined || await Message.isNewer(message, newestMessage)) {
98
114
  incomingMessageIsNewest = true;
@@ -135,7 +151,11 @@ export class ProtocolsConfigureHandler implements MethodHandler {
135
151
  }
136
152
  }
137
153
 
138
- await this.purgeRecordsInvalidatedByProtocolConfig(tenant, message.descriptor.definition.protocol);
154
+ await this.purgeRecordsInvalidatedByProtocolConfig(
155
+ tenant,
156
+ message.descriptor.definition.protocol,
157
+ prefetchedRecordsWrites,
158
+ );
139
159
 
140
160
  return messageReply;
141
161
  };
@@ -183,21 +203,230 @@ export class ProtocolsConfigureHandler implements MethodHandler {
183
203
  }
184
204
  }
185
205
 
186
- private async purgeRecordsInvalidatedByProtocolConfig(tenant: string, protocol: string): Promise<void> {
206
+ /**
207
+ * Prevents a protocol URI from changing the at-rest representation of a path
208
+ * after records have been admitted under that path. A policy migration must use
209
+ * a new protocol URI so one store never contains both plaintext and ciphertext
210
+ * records governed by the same protocol version.
211
+ */
212
+ private async validateEncryptionPolicyIsImmutable(
213
+ tenant: string,
214
+ incomingDefinition: ProtocolDefinition,
215
+ incomingTimestamp: string,
216
+ previousConfiguration: ProtocolsConfigureMessage | undefined,
217
+ ): Promise<RecordsWriteMessage[] | undefined> {
218
+ if (previousConfiguration === undefined || !ProtocolsConfigureHandler.hasEncryptionPolicyChange(
219
+ previousConfiguration.descriptor.definition,
220
+ incomingDefinition,
221
+ )) {
222
+ return undefined;
223
+ }
224
+
225
+ const { messages } = await this.deps.messageStore.query(tenant, [{
226
+ interface : DwnInterfaceName.Records,
227
+ method : DwnMethodName.Write,
228
+ protocol : incomingDefinition.protocol,
229
+ }]);
230
+ const recordsWrites = messages as RecordsWriteMessage[];
231
+ const incomingPolicyByPath = new Map<string, boolean>();
232
+ for (const recordsWrite of recordsWrites) {
233
+ const { protocolPath } = recordsWrite.descriptor;
234
+ if (isEncryptionControlPath(protocolPath)) {
235
+ continue;
236
+ }
237
+
238
+ // Removing a path does not change its representation policy. Existing
239
+ // records remain governed by the configuration active at their timestamp.
240
+ if (getRuleSetAtPath(protocolPath, incomingDefinition.structure) === undefined) {
241
+ continue;
242
+ }
243
+
244
+ let incomingEncryptionRequired = incomingPolicyByPath.get(protocolPath);
245
+ if (incomingEncryptionRequired === undefined) {
246
+ const incomingTypes = await resolveProtocolTypesForPath(
247
+ tenant,
248
+ protocolPath,
249
+ incomingDefinition,
250
+ this.deps.validationStateReader,
251
+ incomingTimestamp,
252
+ );
253
+ incomingEncryptionRequired = incomingTypes[getTypeName(protocolPath)]?.encryptionRequired === true;
254
+ incomingPolicyByPath.set(protocolPath, incomingEncryptionRequired);
255
+ }
256
+
257
+ if ((recordsWrite.encryption !== undefined) === incomingEncryptionRequired) {
258
+ continue;
259
+ }
260
+
261
+ throw new DwnError(
262
+ DwnErrorCode.ProtocolsConfigureEncryptionPolicyImmutable,
263
+ `cannot change encryption policy for protocol path '${protocolPath}' after records exist; ` +
264
+ 'install the changed definition under a new protocol URI.'
265
+ );
266
+ }
267
+
268
+ await this.validateComposedEncryptionPolicyIsImmutable(tenant, incomingDefinition);
269
+ return recordsWrites;
270
+ }
271
+
272
+ /** Prevents a referenced protocol from changing the representation of records stored by composing protocols. */
273
+ private async validateComposedEncryptionPolicyIsImmutable(
274
+ tenant: string,
275
+ incomingDefinition: ProtocolDefinition,
276
+ ): Promise<void> {
277
+ const { messages: configurationMessages } = await this.deps.messageStore.query(tenant, [{
278
+ interface : DwnInterfaceName.Protocols,
279
+ isLatestBaseState : true,
280
+ method : DwnMethodName.Configure,
281
+ }]);
282
+ const policiesByProtocol = ProtocolsConfigureHandler.getComposedPathPolicies(
283
+ configurationMessages as ProtocolsConfigureMessage[],
284
+ incomingDefinition,
285
+ );
286
+ if (policiesByProtocol.size === 0) {
287
+ return;
288
+ }
289
+
290
+ const { messages: dependentRecordsWrites } = await this.deps.messageStore.query(
291
+ tenant,
292
+ [...policiesByProtocol.keys()].map((protocol): Filter => ({
293
+ interface : DwnInterfaceName.Records,
294
+ method : DwnMethodName.Write,
295
+ protocol,
296
+ })),
297
+ );
298
+ for (const message of dependentRecordsWrites) {
299
+ const recordsWrite = message as RecordsWriteMessage;
300
+ const { protocol, protocolPath } = recordsWrite.descriptor;
301
+ if (protocol === undefined) {
302
+ continue;
303
+ }
304
+
305
+ const encryptionRequired = policiesByProtocol.get(protocol)?.get(protocolPath);
306
+ if (encryptionRequired === undefined || (recordsWrite.encryption !== undefined) === encryptionRequired) {
307
+ continue;
308
+ }
309
+
310
+ throw new DwnError(
311
+ DwnErrorCode.ProtocolsConfigureEncryptionPolicyImmutable,
312
+ `cannot change encryption policy for protocol path '${protocolPath}' imported by protocol '${protocol}' ` +
313
+ `after records exist; install the changed definition under a new protocol URI.`
314
+ );
315
+ }
316
+ }
317
+
318
+ /** Maps each installed composing protocol to root `$ref` paths whose policy comes from the incoming definition. */
319
+ private static getComposedPathPolicies(
320
+ configurations: ProtocolsConfigureMessage[],
321
+ incomingDefinition: ProtocolDefinition,
322
+ ): Map<string, Map<string, boolean>> {
323
+ const policiesByProtocol = new Map<string, Map<string, boolean>>();
324
+ for (const configuration of configurations) {
325
+ const composingDefinition = configuration.descriptor.definition;
326
+ const pathPolicies = ProtocolsConfigureHandler.getImportedPathPolicies(
327
+ composingDefinition,
328
+ incomingDefinition,
329
+ );
330
+ if (pathPolicies.size > 0) {
331
+ policiesByProtocol.set(composingDefinition.protocol, pathPolicies);
332
+ }
333
+ }
334
+
335
+ return policiesByProtocol;
336
+ }
337
+
338
+ /** Resolves the effective policy for root `$ref` paths that import one definition. */
339
+ private static getImportedPathPolicies(
340
+ composingDefinition: ProtocolDefinition,
341
+ incomingDefinition: ProtocolDefinition,
342
+ ): Map<string, boolean> {
343
+ const pathPolicies = new Map<string, boolean>();
344
+ const importedAliases = new Set(
345
+ Object.entries(composingDefinition.uses ?? {})
346
+ .filter(([, protocol]): boolean => protocol === incomingDefinition.protocol)
347
+ .map(([alias]): string => alias),
348
+ );
349
+ if (importedAliases.size === 0) {
350
+ return pathPolicies;
351
+ }
352
+
353
+ for (const [protocolPath, ruleSet] of Object.entries(composingDefinition.structure)) {
354
+ const reference = ruleSet.$ref === undefined ? undefined : parseCrossProtocolRef(ruleSet.$ref);
355
+ if (reference === undefined || !importedAliases.has(reference.alias)) {
356
+ continue;
357
+ }
358
+
359
+ const typeName = getTypeName(protocolPath);
360
+ pathPolicies.set(protocolPath, incomingDefinition.types[typeName]?.encryptionRequired === true);
361
+ }
362
+
363
+ return pathPolicies;
364
+ }
365
+
366
+ /** Returns true when a configure can change a path's stored representation. */
367
+ private static hasEncryptionPolicyChange(
368
+ previousDefinition: ProtocolDefinition,
369
+ incomingDefinition: ProtocolDefinition,
370
+ ): boolean {
371
+ const typeNames = new Set([
372
+ ...Object.keys(previousDefinition.types),
373
+ ...Object.keys(incomingDefinition.types),
374
+ ]);
375
+ for (const typeName of typeNames) {
376
+ const previousRequired = previousDefinition.types[typeName]?.encryptionRequired === true;
377
+ const incomingRequired = incomingDefinition.types[typeName]?.encryptionRequired === true;
378
+ if (previousRequired !== incomingRequired) {
379
+ return true;
380
+ }
381
+ }
382
+
383
+ return ProtocolsConfigureHandler.getCompositionEncryptionPolicySignature(previousDefinition) !==
384
+ ProtocolsConfigureHandler.getCompositionEncryptionPolicySignature(incomingDefinition);
385
+ }
386
+
387
+ /** Captures only composition fields that can redirect a path to another type policy. */
388
+ private static getCompositionEncryptionPolicySignature(definition: ProtocolDefinition): string {
389
+ const references: string[] = [];
390
+ const visit = (ruleSet: ProtocolRuleSet, parentPath: string): void => {
391
+ for (const [typeName, value] of Object.entries(ruleSet)) {
392
+ if (typeName.startsWith('$')) {
393
+ continue;
394
+ }
395
+
396
+ const childRuleSet = value as ProtocolRuleSet;
397
+ const protocolPath = parentPath === '' ? typeName : `${parentPath}/${typeName}`;
398
+ if (childRuleSet.$ref !== undefined) {
399
+ references.push(`${protocolPath}=${childRuleSet.$ref}`);
400
+ }
401
+ visit(childRuleSet, protocolPath);
402
+ }
403
+ };
404
+ visit(definition.structure as ProtocolRuleSet, '');
405
+
406
+ const uses = Object.entries(definition.uses ?? {})
407
+ .sort(([left], [right]): number => left.localeCompare(right));
408
+ references.sort((left, right): number => left.localeCompare(right));
409
+ return JSON.stringify({ references, uses });
410
+ }
411
+
412
+ private async purgeRecordsInvalidatedByProtocolConfig(
413
+ tenant: string,
414
+ protocol: string,
415
+ prefetchedRecordsWrites?: RecordsWriteMessage[],
416
+ ): Promise<void> {
187
417
  const dataStore = this.deps.dataStore;
188
418
  if (dataStore === undefined) {
189
419
  return;
190
420
  }
191
421
 
192
- const { messages } = await this.deps.messageStore.query(tenant, [{
422
+ const recordsWrites = prefetchedRecordsWrites ?? (await this.deps.messageStore.query(tenant, [{
193
423
  interface : DwnInterfaceName.Records,
194
424
  method : DwnMethodName.Write,
195
425
  protocol,
196
- }]);
426
+ }])).messages as RecordsWriteMessage[];
197
427
 
198
428
  const checkedRecordIds = new Set<string>();
199
- for (const message of messages) {
200
- const recordsWriteMessage = message as RecordsWriteMessage;
429
+ for (const recordsWriteMessage of recordsWrites) {
201
430
  if (checkedRecordIds.has(recordsWriteMessage.recordId)) {
202
431
  continue;
203
432
  }
package/src/index.ts CHANGED
@@ -25,6 +25,8 @@ export {
25
25
  ENCRYPTION_CONTROL_PATHS,
26
26
  ENCRYPTION_CONTROL_ROOT_PATH,
27
27
  ENCRYPTION_PROTOCOL_URI,
28
+ ENCRYPTION_PROTOCOL_GRANT_KEY_PATH,
29
+ ENCRYPTION_PROTOCOL_WRAPPED_GRANT_KEY_PATH,
28
30
  PERMISSIONS_REVOCATION_PATH,
29
31
  isEncryptionControlPath,
30
32
  } from './core/constants.js';
@@ -98,6 +100,7 @@ export {
98
100
  getRoleAudienceContextId,
99
101
  getRoleContextPrefix,
100
102
  getRuleSetAtPath,
103
+ getTypeName,
101
104
  isCrossProtocolRef,
102
105
  parseCrossProtocolRef,
103
106
  } from './utils/protocols.js';
@@ -10,12 +10,16 @@ import type { ContentEncryptionAlgorithm, X25519KeyEncryptionBase } from '../uti
10
10
 
11
11
  import { DwnConstant } from '../core/dwn-constant.js';
12
12
  import { Encoder } from '../utils/encoder.js';
13
- import { ENCRYPTION_PROTOCOL_URI } from '../core/constants.js';
14
13
  import { FilterUtility } from '../utils/filter.js';
15
14
  import { Message } from '../core/message.js';
16
15
  import { Records } from '../utils/records.js';
17
16
  import { validateJsonSchema } from '../schema-validator.js';
18
17
  import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
18
+ import {
19
+ ENCRYPTION_PROTOCOL_GRANT_KEY_PATH,
20
+ ENCRYPTION_PROTOCOL_URI,
21
+ ENCRYPTION_PROTOCOL_WRAPPED_GRANT_KEY_PATH,
22
+ } from '../core/constants.js';
19
23
  import { grantKeyScopeCoversDeliveredScope, isGrantKeyEligibleRecordsScope, isGrantKeyRecordsScope } from '../utils/grant-key-coverage.js';
20
24
 
21
25
  export const WRAPPED_GRANT_KEY_FORMAT = 'enbox/wrapped-grant-key@1';
@@ -44,15 +48,21 @@ export function assertWrappedGrantKeyEnvelope(envelope: unknown): asserts envelo
44
48
 
45
49
  export class EncryptionProtocol implements CoreProtocol {
46
50
  public static readonly uri = ENCRYPTION_PROTOCOL_URI;
47
- public static readonly grantKeyPath = 'grantKey';
51
+ public static readonly grantKeyPath = ENCRYPTION_PROTOCOL_GRANT_KEY_PATH;
52
+ public static readonly wrappedGrantKeyPath = ENCRYPTION_PROTOCOL_WRAPPED_GRANT_KEY_PATH;
48
53
 
49
54
  public static readonly definition: ProtocolDefinition = {
50
55
  published : true,
51
56
  protocol : EncryptionProtocol.uri,
52
57
  types : {
53
58
  grantKey: {
59
+ dataFormats : ['application/json'],
60
+ encryptionRequired : true,
61
+ schema : 'https://identity.foundation/dwn/json-schemas/encryption/grant-key.json',
62
+ },
63
+ wrappedGrantKey: {
54
64
  dataFormats : ['application/json'],
55
- schema : 'https://identity.foundation/dwn/json-schemas/encryption/grant-key.json',
65
+ schema : 'https://identity.foundation/dwn/json-schemas/encryption/wrapped-grant-key-envelope.json',
56
66
  },
57
67
  },
58
68
  structure: {
@@ -71,6 +81,21 @@ export class EncryptionProtocol implements CoreProtocol {
71
81
  protocolPath : { type: 'string' },
72
82
  },
73
83
  },
84
+ wrappedGrantKey: {
85
+ $actions: [
86
+ { can: ['create'], who: 'anyone' },
87
+ { can: ['read'], of: 'wrappedGrantKey', who: 'recipient' },
88
+ ],
89
+ $immutable : true,
90
+ $tags : {
91
+ $allowUndefinedTags : false,
92
+ $requiredTags : ['grantId', 'protocol', 'keyId'],
93
+ grantId : { type: 'string' },
94
+ keyId : { maxLength: 43, minLength: 43, pattern: '^[A-Za-z0-9_-]{43}$', type: 'string' },
95
+ protocol : { type: 'string' },
96
+ protocolPath : { type: 'string' },
97
+ },
98
+ },
74
99
  }
75
100
  };
76
101
 
@@ -87,13 +112,13 @@ export class EncryptionProtocol implements CoreProtocol {
87
112
  message: RecordsWriteMessage,
88
113
  validationStateReader: ValidationStateReader,
89
114
  ): Promise<void> {
90
- if (message.descriptor.protocolPath === EncryptionProtocol.grantKeyPath) {
115
+ if (EncryptionProtocol.isGrantKeyPath(message.descriptor.protocolPath)) {
91
116
  await EncryptionProtocol.preProcessGrantKey(tenant, message, validationStateReader);
92
117
  }
93
118
  }
94
119
 
95
120
  public async validateRecord(message: RecordsWriteMessage, dataBytes: Uint8Array): Promise<void> {
96
- if (message.descriptor.protocolPath === EncryptionProtocol.grantKeyPath) {
121
+ if (EncryptionProtocol.isGrantKeyPath(message.descriptor.protocolPath)) {
97
122
  EncryptionProtocol.verifyGrantKeyDelivery(message, dataBytes);
98
123
  return;
99
124
  }
@@ -109,7 +134,7 @@ export class EncryptionProtocol implements CoreProtocol {
109
134
  message: RecordsWriteMessage,
110
135
  validationStateReader: ValidationStateReader,
111
136
  ): Promise<void> {
112
- EncryptionProtocol.verifyInlineWrappedGrantKeyDelivery(message);
137
+ EncryptionProtocol.verifyGrantKeyRepresentation(message);
113
138
 
114
139
  const grantId = EncryptionProtocol.getRequiredStringTag(message, 'grantId');
115
140
  const protocol = EncryptionProtocol.getRequiredStringTag(message, 'protocol');
@@ -130,21 +155,34 @@ export class EncryptionProtocol implements CoreProtocol {
130
155
  });
131
156
  }
132
157
 
133
- private static verifyInlineWrappedGrantKeyDelivery(message: RecordsWriteMessage): void {
134
- if (message.encryption !== undefined) {
158
+ private static verifyGrantKeyRepresentation(message: RecordsWriteMessage): void {
159
+ if (message.descriptor.protocolPath === EncryptionProtocol.grantKeyPath) {
160
+ if (message.encryption === undefined) {
161
+ throw new DwnError(
162
+ DwnErrorCode.EncryptionProtocolValidateEncryptedDeliveryMissingEncryption,
163
+ 'grantKey records must be encrypted.'
164
+ );
165
+ }
135
166
  return;
136
167
  }
137
168
 
169
+ if (message.encryption !== undefined) {
170
+ throw new DwnError(
171
+ DwnErrorCode.ProtocolAuthorizationEncryptionNotAllowed,
172
+ 'wrappedGrantKey records must be plaintext at the DWN record level.'
173
+ );
174
+ }
175
+
138
176
  if (message.descriptor.dataSize > DwnConstant.maxDataSizeAllowedToBeEncoded) {
139
177
  throw new DwnError(
140
178
  DwnErrorCode.EncryptionProtocolValidateEncryptedDeliveryMissingEncryption,
141
- 'plaintext grantKey records must carry an inline wrapped grantKey envelope.'
179
+ 'wrappedGrantKey records must carry an inline wrapped grantKey envelope.'
142
180
  );
143
181
  }
144
182
  }
145
183
 
146
184
  private static verifyGrantKeyDelivery(message: RecordsWriteMessage, dataBytes: Uint8Array): void {
147
- if (message.encryption !== undefined) {
185
+ if (message.descriptor.protocolPath === EncryptionProtocol.grantKeyPath) {
148
186
  return;
149
187
  }
150
188
 
@@ -159,11 +197,16 @@ export class EncryptionProtocol implements CoreProtocol {
159
197
  const detail = error instanceof Error ? error.message : String(error);
160
198
  throw new DwnError(
161
199
  DwnErrorCode.EncryptionProtocolValidateEncryptedDeliveryMissingEncryption,
162
- `${message.descriptor.protocolPath} records must be encrypted or carry a valid wrapped grantKey envelope: ${detail}`
200
+ `${message.descriptor.protocolPath} records must carry a valid wrapped grantKey envelope: ${detail}`
163
201
  );
164
202
  }
165
203
  }
166
204
 
205
+ private static isGrantKeyPath(protocolPath: string): boolean {
206
+ return protocolPath === EncryptionProtocol.grantKeyPath ||
207
+ protocolPath === EncryptionProtocol.wrappedGrantKeyPath;
208
+ }
209
+
167
210
  private static getRequiredStringTag(message: RecordsWriteMessage, tag: string): string {
168
211
  const value = message.descriptor.tags?.[tag];
169
212
  if (typeof value !== 'string') {
@@ -731,8 +731,10 @@ export class MessageStoreLevel implements MessageStore, ReplicationFeedReader {
731
731
  const startPosition = cursor === undefined ? 0n : BigInt(cursor.position);
732
732
 
733
733
  if (head === 0n) {
734
- // Nothing to scan — caught up at the input position.
735
- return { events: [], cursor, drained: true };
734
+ // Nothing to scan — caught up at the input position. A caller without a
735
+ // cursor still gets the position-zero anchor so an empty log yields a
736
+ // checkpointable token instead of forcing a rescan on every pass.
737
+ return { events: [], cursor: cursor ?? await this.buildToken(tenant, 0n), drained: true };
736
738
  }
737
739
 
738
740
  const maxResults = limit ?? Number.MAX_SAFE_INTEGER;
@@ -87,6 +87,20 @@ export type MessagesSubscribeMessage = {
87
87
 
88
88
  export type MessagesSubscribeReply = GenericMessageReply & {
89
89
  subscription?: MessageSubscription;
90
+ /**
91
+ * Feed fingerprint over the subscription's filter scopes, observed after the
92
+ * subscription became active. Present only when the filters map onto
93
+ * fingerprint domains and the message store exposes the replication feed.
94
+ */
95
+ fingerprint?: string;
96
+ /**
97
+ * High-water progress token of the tenant's replication log, observed after
98
+ * the subscription became active — the position-zero anchor when the log is
99
+ * empty. NOT a delivery cursor: adopting it as a checkpoint is sound only
100
+ * after verifying `fingerprint` against the local feed, since resuming from
101
+ * it skips every earlier log position.
102
+ */
103
+ head?: ProgressToken;
90
104
  /** Present when status.code is 410 — structured gap metadata. */
91
105
  error?: { code: 'ProgressGap' } & ProgressGapInfo;
92
106
  };
@@ -53,7 +53,8 @@ export type ProtocolType = {
53
53
  * DID must have an X25519 keyAgreement key; protocol installation will
54
54
  * fail if it does not.
55
55
  *
56
- * When `false` or omitted, encryption is not required for this type.
56
+ * When `false` or omitted, records of this type **must** be plaintext at the
57
+ * DWN record level. Applications cannot override this protocol policy per write.
57
58
  */
58
59
  encryptionRequired?: boolean,
59
60
  };
@@ -6,6 +6,7 @@ import type { MessagesFilter } from '../types/messages-types.js';
6
6
  import { FilterUtility } from './filter.js';
7
7
  import { normalizeProtocolUrl } from './url.js';
8
8
  import { Records } from './records.js';
9
+ import { Replication } from './replication.js';
9
10
  import { isEmptyObject, removeUndefinedProperties } from '@enbox/common';
10
11
 
11
12
  type StoredMessageWithEncodedData = GenericMessage & { encodedData?: string };
@@ -122,6 +123,50 @@ export class Messages {
122
123
  return metadataFilter;
123
124
  }
124
125
 
126
+ /**
127
+ * Maps a filter set onto the replication fingerprint domains it covers, or
128
+ * `undefined` when the filters do not correspond to fingerprint domains.
129
+ * An empty filter set covers the global domain; a set of protocol-only
130
+ * filters covers each protocol's domain plus its tagged core-protocol
131
+ * domains. Any other filter shape has no domain mapping.
132
+ */
133
+ public static computeFingerprintScopes(filters: MessagesFilter[]): string[] | undefined {
134
+ if (filters.length === 0) {
135
+ return [Replication.globalDomain];
136
+ }
137
+
138
+ const protocols = new Set<string>();
139
+ for (const filter of filters) {
140
+ const keys = Object.keys(filter);
141
+ if (keys.length !== 1 || typeof filter.protocol !== 'string') {
142
+ return undefined;
143
+ }
144
+
145
+ protocols.add(filter.protocol);
146
+ }
147
+
148
+ // Fail closed for filter sets naming a core protocol directly: the core
149
+ // protocols contribute no tagged domains for themselves, so the domain
150
+ // set would not span everything such filters enumerate — two feeds could
151
+ // fingerprint identically while differing in enumerable messages. No
152
+ // fingerprint beats one whose coverage is known-incomplete.
153
+ for (const protocol of protocols) {
154
+ if (Replication.isCoreProtocolUri(protocol)) {
155
+ return undefined;
156
+ }
157
+ }
158
+
159
+ const scopes: string[] = [];
160
+ for (const protocol of protocols) {
161
+ scopes.push(
162
+ Replication.protocolDomain(protocol),
163
+ ...Replication.taggedCoreProtocolDomains(protocol, protocols),
164
+ );
165
+ }
166
+
167
+ return scopes;
168
+ }
169
+
125
170
  /**
126
171
  * Returns a copy of a RecordsWrite message without inline encodedData, and the
127
172
  * detached encodedData value for wire surfaces that carry data beside the message.
@@ -1,5 +1,6 @@
1
1
  import type { GenericMessage } from '../types/message-types.js';
2
2
  import type { KeyValues } from '../types/query-types.js';
3
+ import type { ReplicationFeedReader } from '../types/subscriptions.js';
3
4
 
4
5
  import { EncryptionProtocol } from '../protocols/encryption.js';
5
6
  import { PermissionsProtocol } from '../protocols/permissions.js';
@@ -44,6 +45,22 @@ export class Replication {
44
45
  return domains;
45
46
  }
46
47
 
48
+ /**
49
+ * Narrows a message store to its replication feed surface when it implements
50
+ * one. Returns `undefined` for stores without replication feed support.
51
+ */
52
+ public static asFeedReader(candidate: unknown): ReplicationFeedReader | undefined {
53
+ const partial = candidate as Partial<ReplicationFeedReader>;
54
+ if (
55
+ typeof partial.logRead === 'function' &&
56
+ typeof partial.logBounds === 'function' &&
57
+ typeof partial.fingerprint === 'function' &&
58
+ typeof partial.epoch === 'function'
59
+ ) {
60
+ return partial as ReplicationFeedReader;
61
+ }
62
+ }
63
+
47
64
  public static async deriveStreamId(tenant: string): Promise<string> {
48
65
  const bytes = new TextEncoder().encode(tenant);
49
66
  const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
@@ -100,7 +117,8 @@ export class Replication {
100
117
  );
101
118
  }
102
119
 
103
- private static isCoreProtocolUri(protocolUri: string): boolean {
120
+ /** Whether a protocol URI is one of the core protocols (permissions, encryption). */
121
+ public static isCoreProtocolUri(protocolUri: string): boolean {
104
122
  return protocolUri === PermissionsProtocol.uri || protocolUri === EncryptionProtocol.uri;
105
123
  }
106
124