@enbox/dwn-sdk-js 0.4.12 → 0.4.14

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 (145) hide show
  1. package/dist/browser.mjs +1 -1
  2. package/dist/browser.mjs.map +4 -4
  3. package/dist/esm/src/core/dwn-error.js +8 -1
  4. package/dist/esm/src/core/dwn-error.js.map +1 -1
  5. package/dist/esm/src/core/encryption-control.js +38 -2
  6. package/dist/esm/src/core/encryption-control.js.map +1 -1
  7. package/dist/esm/src/core/message-reply.js +14 -1
  8. package/dist/esm/src/core/message-reply.js.map +1 -1
  9. package/dist/esm/src/core/protocol-authorization-action.js +130 -107
  10. package/dist/esm/src/core/protocol-authorization-action.js.map +1 -1
  11. package/dist/esm/src/core/protocol-authorization.js +10 -0
  12. package/dist/esm/src/core/protocol-authorization.js.map +1 -1
  13. package/dist/esm/src/event-stream/broadcast-channel-wake-publisher.js +63 -0
  14. package/dist/esm/src/event-stream/broadcast-channel-wake-publisher.js.map +1 -0
  15. package/dist/esm/src/handlers/protocols-configure.js +2 -0
  16. package/dist/esm/src/handlers/protocols-configure.js.map +1 -1
  17. package/dist/esm/src/handlers/records-subscribe.js +73 -46
  18. package/dist/esm/src/handlers/records-subscribe.js.map +1 -1
  19. package/dist/esm/src/handlers/records-write.js +132 -73
  20. package/dist/esm/src/handlers/records-write.js.map +1 -1
  21. package/dist/esm/src/index.js +1 -0
  22. package/dist/esm/src/index.js.map +1 -1
  23. package/dist/esm/src/interfaces/protocols-configure.js +3 -5
  24. package/dist/esm/src/interfaces/protocols-configure.js.map +1 -1
  25. package/dist/esm/src/interfaces/records-write-query.js +2 -3
  26. package/dist/esm/src/interfaces/records-write-query.js.map +1 -1
  27. package/dist/esm/src/interfaces/records-write.js +50 -35
  28. package/dist/esm/src/interfaces/records-write.js.map +1 -1
  29. package/dist/esm/src/jose/jws/general/verifier.js +1 -1
  30. package/dist/esm/src/store/blockstore-mock.js +3 -0
  31. package/dist/esm/src/store/blockstore-mock.js.map +1 -1
  32. package/dist/esm/src/store/index-level-compound.js +34 -18
  33. package/dist/esm/src/store/index-level-compound.js.map +1 -1
  34. package/dist/esm/src/store/index-level.js +5 -5
  35. package/dist/esm/src/store/index-level.js.map +1 -1
  36. package/dist/esm/src/store/message-store-level.js +2 -2
  37. package/dist/esm/src/store/message-store-level.js.map +1 -1
  38. package/dist/esm/src/store/storage-controller.js +26 -17
  39. package/dist/esm/src/store/storage-controller.js.map +1 -1
  40. package/dist/esm/src/types/message-types.js.map +1 -1
  41. package/dist/esm/src/utils/filter.js +4 -8
  42. package/dist/esm/src/utils/filter.js.map +1 -1
  43. package/dist/esm/src/utils/messages.js +46 -28
  44. package/dist/esm/src/utils/messages.js.map +1 -1
  45. package/dist/esm/src/utils/protocols.js +2 -2
  46. package/dist/esm/src/utils/protocols.js.map +1 -1
  47. package/dist/esm/src/utils/records.js +49 -33
  48. package/dist/esm/src/utils/records.js.map +1 -1
  49. package/dist/esm/src/utils/secp256r1.js +1 -1
  50. package/dist/esm/src/utils/secp256r1.js.map +1 -1
  51. package/dist/esm/src/utils/url.js +1 -1
  52. package/dist/esm/src/utils/url.js.map +1 -1
  53. package/dist/esm/tests/broadcast-channel-wake-publisher.spec.js +74 -0
  54. package/dist/esm/tests/broadcast-channel-wake-publisher.spec.js.map +1 -0
  55. package/dist/esm/tests/core/message-reply.spec.js +27 -0
  56. package/dist/esm/tests/core/message-reply.spec.js.map +1 -1
  57. package/dist/esm/tests/core/process-message-parity.spec.js +3 -0
  58. package/dist/esm/tests/core/process-message-parity.spec.js.map +1 -1
  59. package/dist/esm/tests/durable-event-log.spec.js +37 -0
  60. package/dist/esm/tests/durable-event-log.spec.js.map +1 -1
  61. package/dist/esm/tests/features/records-squash.spec.js +5 -0
  62. package/dist/esm/tests/features/records-squash.spec.js.map +1 -1
  63. package/dist/esm/tests/handlers/messages-query.spec.js +14 -28
  64. package/dist/esm/tests/handlers/messages-query.spec.js.map +1 -1
  65. package/dist/esm/tests/handlers/protocols-configure.spec.js +441 -1
  66. package/dist/esm/tests/handlers/protocols-configure.spec.js.map +1 -1
  67. package/dist/esm/tests/handlers/records-write.spec.js +1 -0
  68. package/dist/esm/tests/handlers/records-write.spec.js.map +1 -1
  69. package/dist/esm/tests/test-suite.js +8 -0
  70. package/dist/esm/tests/test-suite.js.map +1 -1
  71. package/dist/types/src/core/dwn-error.d.ts +14 -1
  72. package/dist/types/src/core/dwn-error.d.ts.map +1 -1
  73. package/dist/types/src/core/encryption-control.d.ts +16 -0
  74. package/dist/types/src/core/encryption-control.d.ts.map +1 -1
  75. package/dist/types/src/core/message-reply.d.ts +5 -0
  76. package/dist/types/src/core/message-reply.d.ts.map +1 -1
  77. package/dist/types/src/core/protocol-authorization-action.d.ts +6 -1
  78. package/dist/types/src/core/protocol-authorization-action.d.ts.map +1 -1
  79. package/dist/types/src/core/protocol-authorization.d.ts.map +1 -1
  80. package/dist/types/src/event-stream/broadcast-channel-wake-publisher.d.ts +35 -0
  81. package/dist/types/src/event-stream/broadcast-channel-wake-publisher.d.ts.map +1 -0
  82. package/dist/types/src/handlers/protocols-configure.d.ts.map +1 -1
  83. package/dist/types/src/handlers/records-subscribe.d.ts +18 -0
  84. package/dist/types/src/handlers/records-subscribe.d.ts.map +1 -1
  85. package/dist/types/src/handlers/records-write.d.ts +28 -0
  86. package/dist/types/src/handlers/records-write.d.ts.map +1 -1
  87. package/dist/types/src/index.d.ts +2 -0
  88. package/dist/types/src/index.d.ts.map +1 -1
  89. package/dist/types/src/interfaces/protocols-configure.d.ts.map +1 -1
  90. package/dist/types/src/interfaces/records-write-query.d.ts.map +1 -1
  91. package/dist/types/src/interfaces/records-write.d.ts +12 -0
  92. package/dist/types/src/interfaces/records-write.d.ts.map +1 -1
  93. package/dist/types/src/store/blockstore-mock.d.ts.map +1 -1
  94. package/dist/types/src/store/index-level-compound.d.ts +4 -1
  95. package/dist/types/src/store/index-level-compound.d.ts.map +1 -1
  96. package/dist/types/src/store/index-level.d.ts.map +1 -1
  97. package/dist/types/src/store/storage-controller.d.ts +6 -0
  98. package/dist/types/src/store/storage-controller.d.ts.map +1 -1
  99. package/dist/types/src/types/jose-types.d.ts +3 -2
  100. package/dist/types/src/types/jose-types.d.ts.map +1 -1
  101. package/dist/types/src/types/message-types.d.ts +13 -0
  102. package/dist/types/src/types/message-types.d.ts.map +1 -1
  103. package/dist/types/src/types/subscriptions.d.ts +6 -1
  104. package/dist/types/src/types/subscriptions.d.ts.map +1 -1
  105. package/dist/types/src/utils/filter.d.ts.map +1 -1
  106. package/dist/types/src/utils/messages.d.ts +14 -0
  107. package/dist/types/src/utils/messages.d.ts.map +1 -1
  108. package/dist/types/src/utils/records.d.ts +10 -0
  109. package/dist/types/src/utils/records.d.ts.map +1 -1
  110. package/dist/types/tests/broadcast-channel-wake-publisher.spec.d.ts +2 -0
  111. package/dist/types/tests/broadcast-channel-wake-publisher.spec.d.ts.map +1 -0
  112. package/dist/types/tests/features/records-squash.spec.d.ts.map +1 -1
  113. package/dist/types/tests/handlers/messages-query.spec.d.ts.map +1 -1
  114. package/dist/types/tests/handlers/protocols-configure.spec.d.ts.map +1 -1
  115. package/dist/types/tests/handlers/records-write.spec.d.ts.map +1 -1
  116. package/dist/types/tests/test-suite.d.ts.map +1 -1
  117. package/package.json +4 -4
  118. package/src/core/dwn-error.ts +15 -1
  119. package/src/core/encryption-control.ts +66 -2
  120. package/src/core/message-reply.ts +18 -2
  121. package/src/core/protocol-authorization-action.ts +167 -117
  122. package/src/core/protocol-authorization.ts +13 -0
  123. package/src/event-stream/broadcast-channel-wake-publisher.ts +71 -0
  124. package/src/handlers/protocols-configure.ts +2 -0
  125. package/src/handlers/records-subscribe.ts +98 -47
  126. package/src/handlers/records-write.ts +177 -91
  127. package/src/index.ts +2 -0
  128. package/src/interfaces/protocols-configure.ts +6 -8
  129. package/src/interfaces/records-write-query.ts +2 -3
  130. package/src/interfaces/records-write.ts +62 -44
  131. package/src/jose/jws/general/verifier.ts +1 -1
  132. package/src/store/blockstore-mock.ts +3 -0
  133. package/src/store/index-level-compound.ts +55 -24
  134. package/src/store/index-level.ts +6 -4
  135. package/src/store/message-store-level.ts +2 -2
  136. package/src/store/storage-controller.ts +27 -16
  137. package/src/types/jose-types.ts +3 -2
  138. package/src/types/message-types.ts +16 -0
  139. package/src/types/subscriptions.ts +6 -1
  140. package/src/utils/filter.ts +4 -8
  141. package/src/utils/messages.ts +53 -30
  142. package/src/utils/protocols.ts +2 -2
  143. package/src/utils/records.ts +76 -51
  144. package/src/utils/secp256r1.ts +1 -1
  145. package/src/utils/url.ts +1 -1
@@ -1,3 +1,4 @@
1
+ import type { CoreProtocol } from '../core/core-protocol.js';
1
2
  import type { ProgressToken } from '../types/subscriptions.js';
2
3
  import type { GenericMessage, GenericMessageReply } from '../types/message-types.js';
3
4
  import type { HandlerDependencies, MethodHandler } from '../types/method-handler.js';
@@ -49,13 +50,9 @@ export class RecordsWriteHandler implements MethodHandler {
49
50
  // mutable validation. An already-stored message has already passed
50
51
  // admission; replay should not be reinterpreted against current protocol,
51
52
  // parent, role, grant, or record-limit state.
52
- const incomingCid = await Message.getCid(message);
53
- for (const existingMessage of existingMessages) {
54
- if (await Message.getCid(existingMessage) !== incomingCid) {
55
- continue;
56
- }
57
-
58
- return { status: { code: 409, detail: 'Conflict' } };
53
+ const conflictReply = await this.findConflictingMessageReply(message, existingMessages);
54
+ if (conflictReply !== undefined) {
55
+ return conflictReply;
59
56
  }
60
57
 
61
58
  const newMessageIsInitialWrite = await recordsWrite.isInitialWrite();
@@ -81,12 +78,9 @@ export class RecordsWriteHandler implements MethodHandler {
81
78
  return messageReplyFromError(e, 401);
82
79
  }
83
80
 
84
- if (initialWrite !== undefined) {
85
- try {
86
- this.verifyImmutableProperties(initialWrite, message);
87
- } catch (e) {
88
- return messageReplyFromError(e, 400);
89
- }
81
+ const immutablePropertyReply = this.verifyImmutablePropertiesReply(initialWrite, message);
82
+ if (immutablePropertyReply !== undefined) {
83
+ return immutablePropertyReply;
90
84
  }
91
85
 
92
86
  // Squash backstop: if the protocol path has $squash: true, reject any write whose
@@ -111,88 +105,23 @@ export class RecordsWriteHandler implements MethodHandler {
111
105
 
112
106
  // Look up the core protocol (if any) for the incoming message so that lifecycle hooks
113
107
  // can be dispatched generically rather than checking for specific protocol URIs.
114
- const coreProtocol = message.descriptor.protocol === undefined
115
- ? undefined
116
- : this.deps.coreProtocols?.get(message.descriptor.protocol);
108
+ const coreProtocol = this.getCoreProtocolForWrite(message);
117
109
  let position: ProgressToken | undefined;
118
110
 
119
111
  try {
120
- if (newestExistingMessage?.descriptor.method === DwnMethodName.Delete) {
121
- throw new DwnError(
122
- DwnErrorCode.RecordsWriteNotAllowedAfterDelete,
123
- 'RecordsWrite is not allowed after a RecordsDelete.'
124
- );
125
- }
126
-
127
- // Dispatch pre-processing hooks to the core protocol, if applicable.
128
- // This allows core protocols to perform cross-record validation before storage
129
- // (e.g. ensuring revocation tag consistency with the parent grant's scoped protocol).
130
- if (coreProtocol?.preProcessWrite !== undefined) {
131
- await coreProtocol.preProcessWrite(tenant, message, this.deps.validationStateReader);
132
- }
133
- if (EncryptionControl.isControlMessage(message)) {
134
- await EncryptionControl.preProcessWrite(tenant, message, this.deps.validationStateReader);
135
- if (dataStream === undefined) {
136
- throw new DwnError(
137
- DwnErrorCode.EncryptionControlValidateUnexpectedRecord,
138
- 'encryption control records require data.'
139
- );
140
- }
141
- }
142
-
143
- // NOTE: We allow isLatestBaseState to be true ONLY if the incoming message comes with data, or if the incoming message is NOT an initial write
144
- // This would allow an initial write to be written to the DB without data, but having it not queryable,
145
- // because query implementation filters on `isLatestBaseState` being `true`
146
- // thus preventing a user's attempt to gain authorized access to data by referencing the dataCid of a private data in their initial writes,
147
- // See: https://github.com/enboxorg/enbox/issues/359 for more info
148
- let isLatestBaseState = false;
149
- let messageWithOptionalEncodedData = message as RecordsQueryReplyEntry;
150
-
151
- if (dataStream === undefined) {
152
- // data stream is NOT provided
153
-
154
- // if the incoming message is not an initial write, and no dataStream is provided, we would allow it provided it passes validation
155
- // processMessageWithoutDataStream() abstracts that logic
156
- if (!newMessageIsInitialWrite) {
157
- const newestExistingWrite = newestExistingMessage as RecordsQueryReplyEntry;
158
- messageWithOptionalEncodedData = await this.processMessageWithoutDataStream(tenant, message, newestExistingWrite );
159
- isLatestBaseState = true;
160
- }
161
- } else {
162
- messageWithOptionalEncodedData = await this.processMessageWithDataStream(tenant, message, dataStream);
163
- isLatestBaseState = true;
164
- }
165
-
166
- const indexes = await recordsWrite.constructIndexes(isLatestBaseState);
167
-
168
- // store the new message and displace every other message for this record in one atomic
169
- // commit, retaining only the initial write as non-latest state — readers can never observe
170
- // two latest-state rows for the record
171
- const putResult = await StorageController.commitLatestStateTransition(
172
- tenant,
112
+ const commitResult = await this.commitRecordsWrite({
113
+ coreProtocol,
114
+ dataStream,
173
115
  existingMessages,
174
- { message: messageWithOptionalEncodedData, indexes },
175
- [],
176
- this.deps.messageStore,
177
- this.deps.dataStore!,
178
- );
179
- position = putResult.position;
116
+ message,
117
+ newestExistingMessage,
118
+ newMessageIsInitialWrite,
119
+ recordsWrite,
120
+ tenant,
121
+ });
122
+ position = commitResult.position;
180
123
  } catch (error) {
181
- if (error instanceof DwnError) {
182
- if (error.code === DwnErrorCode.RecordsWriteMissingEncodedDataInPrevious ||
183
- error.code === DwnErrorCode.RecordsWriteMissingDataInPrevious ||
184
- error.code === DwnErrorCode.RecordsWriteNotAllowedAfterDelete ||
185
- error.code === DwnErrorCode.RecordsWriteDataCidMismatch ||
186
- error.code === DwnErrorCode.RecordsWriteDataSizeMismatch ||
187
- error.code.startsWith('SchemaValidator') ||
188
- EncryptionControl.mapErrorToStatusCode(error.code) !== undefined ||
189
- this.deps.coreProtocols?.mapErrorToStatusCode(error.code) !== undefined) {
190
- return messageReplyFromError(error, 400);
191
- }
192
- }
193
-
194
- // else throw
195
- throw error;
124
+ return this.mapCommitWriteErrorToReply(error);
196
125
  }
197
126
 
198
127
  const messageReply = {
@@ -227,6 +156,162 @@ export class RecordsWriteHandler implements MethodHandler {
227
156
  return messageReply;
228
157
  };
229
158
 
159
+ /**
160
+ * Returns a 409 Conflict reply if a message with the same CID as the incoming message already
161
+ * exists among `existingMessages`, else `undefined`. An already-stored message has already
162
+ * passed admission; replay should not be reinterpreted against current protocol, parent, role,
163
+ * grant, or record-limit state.
164
+ */
165
+ private async findConflictingMessageReply(
166
+ message: RecordsWriteMessage,
167
+ existingMessages: GenericMessage[],
168
+ ): Promise<GenericMessageReply | undefined> {
169
+ const incomingCid = await Message.getCid(message);
170
+ for (const existingMessage of existingMessages) {
171
+ if (await Message.getCid(existingMessage) !== incomingCid) {
172
+ continue;
173
+ }
174
+
175
+ return { status: { code: 409, detail: 'Conflict' } };
176
+ }
177
+
178
+ return undefined;
179
+ }
180
+
181
+ /**
182
+ * Verifies that immutable properties are unchanged relative to the `initialWrite`, returning a
183
+ * 400 reply if verification fails. Returns `undefined` (skipping verification) when there is no
184
+ * `initialWrite` to compare against, i.e. the incoming message is itself the initial write.
185
+ */
186
+ private verifyImmutablePropertiesReply(
187
+ initialWrite: RecordsWriteMessage | undefined,
188
+ message: RecordsWriteMessage,
189
+ ): GenericMessageReply | undefined {
190
+ if (initialWrite === undefined) {
191
+ return undefined;
192
+ }
193
+
194
+ try {
195
+ this.verifyImmutableProperties(initialWrite, message);
196
+ return undefined;
197
+ } catch (e) {
198
+ return messageReplyFromError(e, 400);
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Looks up the core protocol (if any) for the incoming message so that lifecycle hooks
204
+ * can be dispatched generically rather than checking for specific protocol URIs.
205
+ */
206
+ private getCoreProtocolForWrite(message: RecordsWriteMessage): CoreProtocol | undefined {
207
+ if (message.descriptor.protocol === undefined) {
208
+ return undefined;
209
+ }
210
+
211
+ return this.deps.coreProtocols?.get(message.descriptor.protocol);
212
+ }
213
+
214
+ /**
215
+ * Runs the pre-processing hooks, resolves whether the write is queryable (`isLatestBaseState`),
216
+ * and commits the new message and its indexes as the latest state transition for the record.
217
+ */
218
+ private async commitRecordsWrite(input: {
219
+ coreProtocol: CoreProtocol | undefined;
220
+ dataStream?: ReadableStream<Uint8Array>;
221
+ existingMessages: GenericMessage[];
222
+ message: RecordsWriteMessage;
223
+ newestExistingMessage: GenericMessage | undefined;
224
+ newMessageIsInitialWrite: boolean;
225
+ recordsWrite: RecordsWrite;
226
+ tenant: string;
227
+ }): Promise<{ position?: ProgressToken }> {
228
+ const { coreProtocol, dataStream, existingMessages, message, newestExistingMessage, newMessageIsInitialWrite, recordsWrite, tenant } = input;
229
+
230
+ if (newestExistingMessage?.descriptor.method === DwnMethodName.Delete) {
231
+ throw new DwnError(
232
+ DwnErrorCode.RecordsWriteNotAllowedAfterDelete,
233
+ 'RecordsWrite is not allowed after a RecordsDelete.'
234
+ );
235
+ }
236
+
237
+ // Dispatch pre-processing hooks to the core protocol, if applicable.
238
+ // This allows core protocols to perform cross-record validation before storage
239
+ // (e.g. ensuring revocation tag consistency with the parent grant's scoped protocol).
240
+ if (coreProtocol?.preProcessWrite !== undefined) {
241
+ await coreProtocol.preProcessWrite(tenant, message, this.deps.validationStateReader);
242
+ }
243
+ if (EncryptionControl.isControlMessage(message)) {
244
+ await EncryptionControl.preProcessWrite(tenant, message, this.deps.validationStateReader);
245
+ if (dataStream === undefined) {
246
+ throw new DwnError(
247
+ DwnErrorCode.EncryptionControlValidateUnexpectedRecord,
248
+ 'encryption control records require data.'
249
+ );
250
+ }
251
+ }
252
+
253
+ // NOTE: We allow isLatestBaseState to be true ONLY if the incoming message comes with data, or if the incoming message is NOT an initial write
254
+ // This would allow an initial write to be written to the DB without data, but having it not queryable,
255
+ // because query implementation filters on `isLatestBaseState` being `true`
256
+ // thus preventing a user's attempt to gain authorized access to data by referencing the dataCid of a private data in their initial writes,
257
+ // See: https://github.com/enboxorg/enbox/issues/359 for more info
258
+ let isLatestBaseState = false;
259
+ let messageWithOptionalEncodedData = message as RecordsQueryReplyEntry;
260
+
261
+ if (dataStream === undefined) {
262
+ // data stream is NOT provided
263
+
264
+ // if the incoming message is not an initial write, and no dataStream is provided, we would allow it provided it passes validation
265
+ // processMessageWithoutDataStream() abstracts that logic
266
+ if (!newMessageIsInitialWrite) {
267
+ const newestExistingWrite = newestExistingMessage as RecordsQueryReplyEntry;
268
+ messageWithOptionalEncodedData = await this.processMessageWithoutDataStream(tenant, message, newestExistingWrite );
269
+ isLatestBaseState = true;
270
+ }
271
+ } else {
272
+ messageWithOptionalEncodedData = await this.processMessageWithDataStream(tenant, message, dataStream);
273
+ isLatestBaseState = true;
274
+ }
275
+
276
+ const indexes = await recordsWrite.constructIndexes(isLatestBaseState);
277
+
278
+ // store the new message and displace every other message for this record in one atomic
279
+ // commit, retaining only the initial write as non-latest state — readers can never observe
280
+ // two latest-state rows for the record
281
+ const putResult = await StorageController.commitLatestStateTransition(
282
+ tenant,
283
+ existingMessages,
284
+ { message: messageWithOptionalEncodedData, indexes },
285
+ [],
286
+ this.deps.messageStore,
287
+ this.deps.dataStore!,
288
+ );
289
+
290
+ return { position: putResult.position };
291
+ }
292
+
293
+ /**
294
+ * Maps a `commitRecordsWrite()` failure to a 400 reply when the error is a recognized,
295
+ * client-facing validation failure; otherwise rethrows the original error.
296
+ */
297
+ private mapCommitWriteErrorToReply(error: unknown): GenericMessageReply {
298
+ if (error instanceof DwnError) {
299
+ if (error.code === DwnErrorCode.RecordsWriteMissingEncodedDataInPrevious ||
300
+ error.code === DwnErrorCode.RecordsWriteMissingDataInPrevious ||
301
+ error.code === DwnErrorCode.RecordsWriteNotAllowedAfterDelete ||
302
+ error.code === DwnErrorCode.RecordsWriteDataCidMismatch ||
303
+ error.code === DwnErrorCode.RecordsWriteDataSizeMismatch ||
304
+ error.code.startsWith('SchemaValidator') ||
305
+ EncryptionControl.mapErrorToStatusCode(error.code) !== undefined ||
306
+ this.deps.coreProtocols?.mapErrorToStatusCode(error.code) !== undefined) {
307
+ return messageReplyFromError(error, 400);
308
+ }
309
+ }
310
+
311
+ // else throw
312
+ throw error;
313
+ }
314
+
230
315
  /**
231
316
  * Returns a `RecordsQueryReplyEntry` with a copy of the incoming message and the incoming data encoded to `Base64URL`.
232
317
  */
@@ -414,7 +499,8 @@ export class RecordsWriteHandler implements MethodHandler {
414
499
  DwnErrorCode.ProtocolAuthorizationSquashBackstop,
415
500
  `incoming message timestamp '${message.descriptor.messageTimestamp}' is not newer than ` +
416
501
  `the most recent squash record timestamp '${newestSquash.descriptor.messageTimestamp}' ` +
417
- `at protocol path '${message.descriptor.protocolPath}'.`
502
+ `at protocol path '${message.descriptor.protocolPath}'.`,
503
+ { info: { squashFloorTimestamp: newestSquash.descriptor.messageTimestamp } }
418
504
  );
419
505
  }
420
506
  }
package/src/index.ts CHANGED
@@ -48,6 +48,7 @@ export { Dwn } from './dwn.js';
48
48
  export type { MessageOptions } from './dwn.js';
49
49
  export { DwnConstant } from './core/dwn-constant.js';
50
50
  export { DwnError, DwnErrorCode } from './core/dwn-error.js';
51
+ export type { DwnErrorInfo } from './core/dwn-error.js';
51
52
  export { DwnInterfaceName, DwnMethodName } from './enums/dwn-interface-method.js';
52
53
  export { Encoder } from './utils/encoder.js';
53
54
  export { MessagesSubscribe } from './interfaces/messages-subscribe.js';
@@ -135,6 +136,7 @@ export * from './types/permission-types.js';
135
136
  export * from './types/records-types.js';
136
137
 
137
138
  // concrete event log implementations
139
+ export { BroadcastChannelWakePublisher } from './event-stream/broadcast-channel-wake-publisher.js';
138
140
  export { EventEmitterWakePublisher } from './event-stream/event-emitter-wake-publisher.js';
139
141
  export { DurableEventLog } from './event-stream/durable-event-log.js';
140
142
  export type { DurableEventLogConfig, DurableEventLogStore } from './event-stream/durable-event-log.js';
@@ -464,14 +464,12 @@ export class ProtocolsConfigure extends AbstractMessage<ProtocolsConfigureMessag
464
464
  `More than one action rule per role ${actionRule.role} not allowed within a rule set: ${JSON.stringify(actionRule)}`
465
465
  );
466
466
  }
467
- } else {
468
- if (actionRule.who === otherActionRule.who && actionRule.of === otherActionRule.of) {
469
- throw new DwnError(
470
- DwnErrorCode.ProtocolsConfigureDuplicateActorInRuleSet,
471
- `More than one action rule per actor ${actionRule.who} of ${actionRule.of} ` +
472
- `not allowed within a rule set: ${JSON.stringify(actionRule)}`
473
- );
474
- }
467
+ } else if (actionRule.who === otherActionRule.who && actionRule.of === otherActionRule.of) {
468
+ throw new DwnError(
469
+ DwnErrorCode.ProtocolsConfigureDuplicateActorInRuleSet,
470
+ `More than one action rule per actor ${actionRule.who} of ${actionRule.of} ` +
471
+ `not allowed within a rule set: ${JSON.stringify(actionRule)}`
472
+ );
475
473
  }
476
474
  }
477
475
  }
@@ -44,14 +44,13 @@ export function verifyEqualityOfImmutableProperties(
44
44
 
45
45
  // get distinct property names that exist in either the existing message given or new message
46
46
  let descriptorPropertyNames: string[] = [];
47
- descriptorPropertyNames.push(...Object.keys(existingWriteMessage.descriptor));
48
- descriptorPropertyNames.push(...Object.keys(newMessage.descriptor));
47
+ descriptorPropertyNames.push(...Object.keys(existingWriteMessage.descriptor), ...Object.keys(newMessage.descriptor));
49
48
  descriptorPropertyNames = [...new Set(descriptorPropertyNames)]; // step to remove duplicates
50
49
 
51
50
  // ensure all immutable properties are not modified
52
51
  for (const descriptorPropertyName of descriptorPropertyNames) {
53
52
  // if property is supposed to be immutable
54
- if (mutableDescriptorProperties.indexOf(descriptorPropertyName) === -1) {
53
+ if (!mutableDescriptorProperties.includes(descriptorPropertyName)) {
55
54
  const valueInExistingWrite = (existingWriteMessage.descriptor as Record<string, unknown>)[descriptorPropertyName];
56
55
  const valueInNewMessage = (newMessage.descriptor as Record<string, unknown>)[descriptorPropertyName];
57
56
  if (valueInNewMessage !== valueInExistingWrite) {
@@ -253,7 +253,7 @@ export class RecordsWrite implements MessageInterface<RecordsWriteMessage> {
253
253
  */
254
254
  public static async parse(recordsWriteMessage: RecordsWriteMessage): Promise<RecordsWrite> {
255
255
  // Make a copy so that the stored copy is not subject to external, unexpected modification.
256
- const message = JSON.parse(JSON.stringify(recordsWriteMessage)) as RecordsWriteMessage;
256
+ const message = structuredClone(recordsWriteMessage);
257
257
 
258
258
  // Validate the message against the JSON schema.
259
259
  // We strip internal properties that the MessageStore may attach to stored messages
@@ -438,16 +438,14 @@ export class RecordsWrite implements MessageInterface<RecordsWriteMessage> {
438
438
  // if given explicitly published dated
439
439
  if (options.datePublished) {
440
440
  datePublished = options.datePublished;
441
- } else {
441
+ } else if (published) {
442
442
  // if this RecordsWrite will publish the record
443
- if (published) {
444
- // the parent was already published, inherit the same published date
445
- if (sourceMessage.descriptor.published) {
446
- datePublished = sourceMessage.descriptor.datePublished;
447
- } else {
448
- // this is a toggle from unpublished to published, use current time
449
- datePublished = currentTime;
450
- }
443
+ // the parent was already published, inherit the same published date
444
+ if (sourceMessage.descriptor.published) {
445
+ datePublished = sourceMessage.descriptor.datePublished;
446
+ } else {
447
+ // this is a toggle from unpublished to published, use current time
448
+ datePublished = currentTime;
451
449
  }
452
450
  }
453
451
 
@@ -630,29 +628,7 @@ export class RecordsWrite implements MessageInterface<RecordsWriteMessage> {
630
628
  private async validateIntegrity(): Promise<void> {
631
629
  // if the new message is the initial write
632
630
  const isInitialWrite = await this.isInitialWrite();
633
- if (isInitialWrite) {
634
- // `messageTimestamp` and `dateCreated` equality check
635
- const dateRecordCreated = this.message.descriptor.dateCreated;
636
- const messageTimestamp = this.message.descriptor.messageTimestamp;
637
- if (messageTimestamp !== dateRecordCreated) {
638
- throw new DwnError(
639
- DwnErrorCode.RecordsWriteValidateIntegrityDateCreatedMismatch,
640
- `messageTimestamp ${messageTimestamp} must match dateCreated ${dateRecordCreated} for the initial write`
641
- );
642
- }
643
-
644
- // if the message is a protocol context root, the `contextId` must match the expected deterministic value
645
- if (this.message.descriptor.parentId === undefined) {
646
- const expectedContextId = await this.getEntryId();
647
-
648
- if (this.message.contextId !== expectedContextId) {
649
- throw new DwnError(
650
- DwnErrorCode.RecordsWriteValidateIntegrityContextIdMismatch,
651
- `contextId in message: ${this.message.contextId} does not match deterministic contextId: ${expectedContextId}`
652
- );
653
- }
654
- }
655
- }
631
+ await this.validateInitialWriteIntegrity(isInitialWrite);
656
632
 
657
633
  // NOTE: validateSignatureStructure() call earlier enforces the presence of `authorization` and thus `signature` in RecordsWrite
658
634
  const signaturePayload = this.signaturePayload!;
@@ -687,6 +663,59 @@ export class RecordsWrite implements MessageInterface<RecordsWriteMessage> {
687
663
  }
688
664
  }
689
665
 
666
+ await this.validateEncryptionCidIntegrity(signaturePayload);
667
+
668
+ validateProtocolUrlNormalized(this.message.descriptor.protocol);
669
+ if (this.message.descriptor.schema !== undefined) {
670
+ validateSchemaUrlNormalized(this.message.descriptor.schema);
671
+ }
672
+
673
+ Time.validateTimestamp(this.message.descriptor.messageTimestamp);
674
+ Time.validateTimestamp(this.message.descriptor.dateCreated);
675
+ if (this.message.descriptor.datePublished) {
676
+ Time.validateTimestamp(this.message.descriptor.datePublished);
677
+ }
678
+ }
679
+
680
+ /**
681
+ * Validates the `messageTimestamp`/`dateCreated` equality and, for a protocol context root,
682
+ * the deterministic `contextId` — both of which only apply to the initial write of a record.
683
+ * No-op when `isInitialWrite` is `false`.
684
+ */
685
+ private async validateInitialWriteIntegrity(isInitialWrite: boolean): Promise<void> {
686
+ if (!isInitialWrite) {
687
+ return;
688
+ }
689
+
690
+ // `messageTimestamp` and `dateCreated` equality check
691
+ const dateRecordCreated = this.message.descriptor.dateCreated;
692
+ const messageTimestamp = this.message.descriptor.messageTimestamp;
693
+ if (messageTimestamp !== dateRecordCreated) {
694
+ throw new DwnError(
695
+ DwnErrorCode.RecordsWriteValidateIntegrityDateCreatedMismatch,
696
+ `messageTimestamp ${messageTimestamp} must match dateCreated ${dateRecordCreated} for the initial write`
697
+ );
698
+ }
699
+
700
+ // if the message is a protocol context root, the `contextId` must match the expected deterministic value
701
+ if (this.message.descriptor.parentId === undefined) {
702
+ const expectedContextId = await this.getEntryId();
703
+
704
+ if (this.message.contextId !== expectedContextId) {
705
+ throw new DwnError(
706
+ DwnErrorCode.RecordsWriteValidateIntegrityContextIdMismatch,
707
+ `contextId in message: ${this.message.contextId} does not match deterministic contextId: ${expectedContextId}`
708
+ );
709
+ }
710
+ }
711
+ }
712
+
713
+ /**
714
+ * Validates that `encryption` in the message and `encryptionCid` in the signature payload are
715
+ * consistently present/absent, and that when both are present, `encryptionCid` matches the CID
716
+ * computed from `encryption`.
717
+ */
718
+ private async validateEncryptionCidIntegrity(signaturePayload: RecordsWriteSignaturePayload): Promise<void> {
690
719
  // if `encryption` is given in message, make sure the correct `encryptionCid` is in the payload of the message signature
691
720
  if (this.message.encryption !== undefined && signaturePayload.encryptionCid === undefined) {
692
721
  throw new DwnError(
@@ -712,17 +741,6 @@ export class RecordsWrite implements MessageInterface<RecordsWriteMessage> {
712
741
  );
713
742
  }
714
743
  }
715
-
716
- validateProtocolUrlNormalized(this.message.descriptor.protocol);
717
- if (this.message.descriptor.schema !== undefined) {
718
- validateSchemaUrlNormalized(this.message.descriptor.schema);
719
- }
720
-
721
- Time.validateTimestamp(this.message.descriptor.messageTimestamp);
722
- Time.validateTimestamp(this.message.descriptor.dateCreated);
723
- if (this.message.descriptor.datePublished) {
724
- Time.validateTimestamp(this.message.descriptor.datePublished);
725
- }
726
744
  }
727
745
 
728
746
  /**
@@ -24,7 +24,7 @@ export class GeneralJwsVerifier {
24
24
  cache: Cache;
25
25
 
26
26
  private constructor(cache?: Cache) {
27
- this.cache = cache || new MemoryCache(600);
27
+ this.cache = cache ?? new MemoryCache(600);
28
28
  }
29
29
 
30
30
  private static get singleton(): GeneralJwsVerifier {
@@ -17,9 +17,11 @@ import {
17
17
  export class BlockstoreMock implements Blockstore {
18
18
 
19
19
  async open(): Promise<void> {
20
+ // no-op: mock blockstore has no underlying storage to open
20
21
  }
21
22
 
22
23
  async close(): Promise<void> {
24
+ // no-op: mock blockstore has no underlying storage to close
23
25
  }
24
26
 
25
27
  async put(key: CID, _val: BlockstoreInput, _options?: BlockstoreAbortOptions): Promise<CID> {
@@ -62,5 +64,6 @@ export class BlockstoreMock implements Blockstore {
62
64
  * deletes all entries
63
65
  */
64
66
  async clear(): Promise<void> {
67
+ // no-op: mock blockstore has no underlying storage to clear
65
68
  }
66
69
  }
@@ -1,5 +1,5 @@
1
1
  import type { CompoundIndexDefinition, IndexedItem, IndexLevelOptions } from './index-level.js';
2
- import type { Filter, KeyValues, QueryOptions } from '../types/query-types.js';
2
+ import type { Filter, KeyValues, PaginationCursor, QueryOptions } from '../types/query-types.js';
3
3
  import type { LevelWrapper, LevelWrapperBatchOperation, LevelWrapperIteratorOptions } from './level-wrapper.js';
4
4
 
5
5
  import { FilterUtility } from '../utils/filter.js';
@@ -8,6 +8,9 @@ import { SortDirection } from '../types/query-types.js';
8
8
  /** Separator between compound key segments (higher than \x00 so prefix scans work correctly). */
9
9
  export const COMPOUND_SEGMENT_SEPARATOR = '\x01';
10
10
 
11
+ /** A value that can be encoded into a lexicographically sortable index key. */
12
+ type IndexableValue = string | number | boolean;
13
+
11
14
  /**
12
15
  * Gets the compound index partition for a given compound index definition.
13
16
  * Compound index sublevels use the naming convention `__compound:<name>__`.
@@ -28,7 +31,7 @@ export async function getCompoundIndexPartition(
28
31
  */
29
32
  export function buildCompoundKey(
30
33
  messageCid: string, indexes: KeyValues, compoundIndex: CompoundIndexDefinition,
31
- encodeValue: (value: string | number | boolean) => string, delimiter: string
34
+ encodeValue: (value: IndexableValue) => string, delimiter: string
32
35
  ): string | undefined {
33
36
  const segments: string[] = [];
34
37
 
@@ -198,6 +201,45 @@ export async function queryWithCompoundIndex(
198
201
  const partition = await getCompoundIndexPartition(db, tenant, compoundIndex);
199
202
 
200
203
  // determine the iterator bounds from the prefix
204
+ const iteratorOptions = buildCompoundQueryIteratorOptions(prefix, sortDirection, cursor, encodeValue, delimiter);
205
+
206
+ // determine which filter properties are NOT covered by the compound index
207
+ // (need in-memory verification for these)
208
+ // NOTE: the compound index equality properties are fully covered by the prefix scan,
209
+ // but the sort property is only covered for ordering — any range filter on the sort
210
+ // property must still be applied as a residual filter.
211
+ const { residualFilter, hasResidualFilter } = computeCompoundIndexResidualFilter(filter, compoundIndex);
212
+
213
+ const matches: IndexedItem[] = [];
214
+ for await (const [_key, value] of partition.iterator(iteratorOptions, options)) {
215
+ if (limit !== undefined && matches.length === limit) {
216
+ break;
217
+ }
218
+
219
+ const item = JSON.parse(value) as IndexedItem;
220
+
221
+ // verify any residual filter properties in memory
222
+ if (hasResidualFilter && !FilterUtility.matchFilter(item.indexes, residualFilter)) {
223
+ continue;
224
+ }
225
+
226
+ matches.push(item);
227
+ }
228
+
229
+ return matches;
230
+ }
231
+
232
+ /**
233
+ * Determines the LevelDB iterator bounds for a compound-index prefix scan, accounting for the
234
+ * sort direction and an optional pagination cursor.
235
+ */
236
+ function buildCompoundQueryIteratorOptions(
237
+ prefix: string,
238
+ sortDirection: SortDirection,
239
+ cursor: PaginationCursor | undefined,
240
+ encodeValue: (value: IndexableValue) => string,
241
+ delimiter: string,
242
+ ): LevelWrapperIteratorOptions<string> {
201
243
  const iteratorOptions: LevelWrapperIteratorOptions<string> = {};
202
244
 
203
245
  if (cursor === undefined) {
@@ -226,11 +268,16 @@ export async function queryWithCompoundIndex(
226
268
  }
227
269
  }
228
270
 
229
- // determine which filter properties are NOT covered by the compound index
230
- // (need in-memory verification for these)
231
- // NOTE: the compound index equality properties are fully covered by the prefix scan,
232
- // but the sort property is only covered for ordering — any range filter on the sort
233
- // property must still be applied as a residual filter.
271
+ return iteratorOptions;
272
+ }
273
+
274
+ /**
275
+ * Determines which filter properties are NOT covered by a compound index's equality-prefix scan
276
+ * and therefore require in-memory verification.
277
+ */
278
+ function computeCompoundIndexResidualFilter(
279
+ filter: Filter, compoundIndex: CompoundIndexDefinition
280
+ ): { residualFilter: Filter; hasResidualFilter: boolean } {
234
281
  const coveredEqualityProperties = new Set(compoundIndex.properties);
235
282
  const residualFilter: Filter = {};
236
283
  let hasResidualFilter = false;
@@ -241,23 +288,7 @@ export async function queryWithCompoundIndex(
241
288
  }
242
289
  }
243
290
 
244
- const matches: IndexedItem[] = [];
245
- for await (const [_key, value] of partition.iterator(iteratorOptions, options)) {
246
- if (limit !== undefined && matches.length === limit) {
247
- break;
248
- }
249
-
250
- const item = JSON.parse(value) as IndexedItem;
251
-
252
- // verify any residual filter properties in memory
253
- if (hasResidualFilter && !FilterUtility.matchFilter(item.indexes, residualFilter)) {
254
- continue;
255
- }
256
-
257
- matches.push(item);
258
- }
259
-
260
- return matches;
291
+ return { residualFilter, hasResidualFilter };
261
292
  }
262
293
 
263
294
  /**