@enbox/dwn-sdk-js 0.4.12 → 0.4.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser.mjs +1 -1
- package/dist/browser.mjs.map +4 -4
- package/dist/esm/src/core/dwn-error.js +8 -1
- package/dist/esm/src/core/dwn-error.js.map +1 -1
- package/dist/esm/src/core/encryption-control.js +38 -2
- package/dist/esm/src/core/encryption-control.js.map +1 -1
- package/dist/esm/src/core/message-reply.js +14 -1
- package/dist/esm/src/core/message-reply.js.map +1 -1
- package/dist/esm/src/core/protocol-authorization-action.js +130 -107
- package/dist/esm/src/core/protocol-authorization-action.js.map +1 -1
- package/dist/esm/src/core/protocol-authorization.js +10 -0
- package/dist/esm/src/core/protocol-authorization.js.map +1 -1
- package/dist/esm/src/event-stream/broadcast-channel-wake-publisher.js +63 -0
- package/dist/esm/src/event-stream/broadcast-channel-wake-publisher.js.map +1 -0
- package/dist/esm/src/handlers/protocols-configure.js +2 -0
- package/dist/esm/src/handlers/protocols-configure.js.map +1 -1
- package/dist/esm/src/handlers/records-subscribe.js +73 -46
- package/dist/esm/src/handlers/records-subscribe.js.map +1 -1
- package/dist/esm/src/handlers/records-write.js +132 -73
- package/dist/esm/src/handlers/records-write.js.map +1 -1
- package/dist/esm/src/index.js +1 -0
- package/dist/esm/src/index.js.map +1 -1
- package/dist/esm/src/interfaces/protocols-configure.js +3 -5
- package/dist/esm/src/interfaces/protocols-configure.js.map +1 -1
- package/dist/esm/src/interfaces/records-write-query.js +2 -3
- package/dist/esm/src/interfaces/records-write-query.js.map +1 -1
- package/dist/esm/src/interfaces/records-write.js +50 -35
- package/dist/esm/src/interfaces/records-write.js.map +1 -1
- package/dist/esm/src/jose/jws/general/verifier.js +1 -1
- package/dist/esm/src/store/blockstore-mock.js +3 -0
- package/dist/esm/src/store/blockstore-mock.js.map +1 -1
- package/dist/esm/src/store/index-level-compound.js +34 -18
- package/dist/esm/src/store/index-level-compound.js.map +1 -1
- package/dist/esm/src/store/index-level.js +5 -5
- package/dist/esm/src/store/index-level.js.map +1 -1
- package/dist/esm/src/store/message-store-level.js +2 -2
- package/dist/esm/src/store/message-store-level.js.map +1 -1
- package/dist/esm/src/store/storage-controller.js +26 -17
- package/dist/esm/src/store/storage-controller.js.map +1 -1
- package/dist/esm/src/types/message-types.js.map +1 -1
- package/dist/esm/src/utils/filter.js +4 -8
- package/dist/esm/src/utils/filter.js.map +1 -1
- package/dist/esm/src/utils/messages.js +46 -28
- package/dist/esm/src/utils/messages.js.map +1 -1
- package/dist/esm/src/utils/protocols.js +2 -2
- package/dist/esm/src/utils/protocols.js.map +1 -1
- package/dist/esm/src/utils/records.js +49 -33
- package/dist/esm/src/utils/records.js.map +1 -1
- package/dist/esm/src/utils/secp256r1.js +1 -1
- package/dist/esm/src/utils/secp256r1.js.map +1 -1
- package/dist/esm/src/utils/url.js +1 -1
- package/dist/esm/src/utils/url.js.map +1 -1
- package/dist/esm/tests/broadcast-channel-wake-publisher.spec.js +74 -0
- package/dist/esm/tests/broadcast-channel-wake-publisher.spec.js.map +1 -0
- package/dist/esm/tests/core/message-reply.spec.js +27 -0
- package/dist/esm/tests/core/message-reply.spec.js.map +1 -1
- package/dist/esm/tests/core/process-message-parity.spec.js +3 -0
- package/dist/esm/tests/core/process-message-parity.spec.js.map +1 -1
- package/dist/esm/tests/durable-event-log.spec.js +37 -0
- package/dist/esm/tests/durable-event-log.spec.js.map +1 -1
- package/dist/esm/tests/features/records-squash.spec.js +5 -0
- package/dist/esm/tests/features/records-squash.spec.js.map +1 -1
- package/dist/esm/tests/handlers/messages-query.spec.js +14 -28
- package/dist/esm/tests/handlers/messages-query.spec.js.map +1 -1
- package/dist/esm/tests/handlers/protocols-configure.spec.js +441 -1
- package/dist/esm/tests/handlers/protocols-configure.spec.js.map +1 -1
- package/dist/esm/tests/handlers/records-write.spec.js +1 -0
- package/dist/esm/tests/handlers/records-write.spec.js.map +1 -1
- package/dist/esm/tests/test-suite.js +8 -0
- package/dist/esm/tests/test-suite.js.map +1 -1
- package/dist/types/src/core/dwn-error.d.ts +14 -1
- package/dist/types/src/core/dwn-error.d.ts.map +1 -1
- package/dist/types/src/core/encryption-control.d.ts +16 -0
- package/dist/types/src/core/encryption-control.d.ts.map +1 -1
- package/dist/types/src/core/message-reply.d.ts +5 -0
- package/dist/types/src/core/message-reply.d.ts.map +1 -1
- package/dist/types/src/core/protocol-authorization-action.d.ts +6 -1
- package/dist/types/src/core/protocol-authorization-action.d.ts.map +1 -1
- package/dist/types/src/core/protocol-authorization.d.ts.map +1 -1
- package/dist/types/src/event-stream/broadcast-channel-wake-publisher.d.ts +35 -0
- package/dist/types/src/event-stream/broadcast-channel-wake-publisher.d.ts.map +1 -0
- package/dist/types/src/handlers/protocols-configure.d.ts.map +1 -1
- package/dist/types/src/handlers/records-subscribe.d.ts +18 -0
- package/dist/types/src/handlers/records-subscribe.d.ts.map +1 -1
- package/dist/types/src/handlers/records-write.d.ts +28 -0
- package/dist/types/src/handlers/records-write.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +2 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/interfaces/protocols-configure.d.ts.map +1 -1
- package/dist/types/src/interfaces/records-write-query.d.ts.map +1 -1
- package/dist/types/src/interfaces/records-write.d.ts +12 -0
- package/dist/types/src/interfaces/records-write.d.ts.map +1 -1
- package/dist/types/src/store/blockstore-mock.d.ts.map +1 -1
- package/dist/types/src/store/index-level-compound.d.ts +4 -1
- package/dist/types/src/store/index-level-compound.d.ts.map +1 -1
- package/dist/types/src/store/index-level.d.ts.map +1 -1
- package/dist/types/src/store/storage-controller.d.ts +6 -0
- package/dist/types/src/store/storage-controller.d.ts.map +1 -1
- package/dist/types/src/types/jose-types.d.ts +3 -2
- package/dist/types/src/types/jose-types.d.ts.map +1 -1
- package/dist/types/src/types/message-types.d.ts +13 -0
- package/dist/types/src/types/message-types.d.ts.map +1 -1
- package/dist/types/src/utils/filter.d.ts.map +1 -1
- package/dist/types/src/utils/messages.d.ts +14 -0
- package/dist/types/src/utils/messages.d.ts.map +1 -1
- package/dist/types/src/utils/records.d.ts +10 -0
- package/dist/types/src/utils/records.d.ts.map +1 -1
- package/dist/types/tests/broadcast-channel-wake-publisher.spec.d.ts +2 -0
- package/dist/types/tests/broadcast-channel-wake-publisher.spec.d.ts.map +1 -0
- package/dist/types/tests/features/records-squash.spec.d.ts.map +1 -1
- package/dist/types/tests/handlers/messages-query.spec.d.ts.map +1 -1
- package/dist/types/tests/handlers/protocols-configure.spec.d.ts.map +1 -1
- package/dist/types/tests/handlers/records-write.spec.d.ts.map +1 -1
- package/dist/types/tests/test-suite.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/core/dwn-error.ts +15 -1
- package/src/core/encryption-control.ts +66 -2
- package/src/core/message-reply.ts +18 -2
- package/src/core/protocol-authorization-action.ts +167 -117
- package/src/core/protocol-authorization.ts +13 -0
- package/src/event-stream/broadcast-channel-wake-publisher.ts +71 -0
- package/src/handlers/protocols-configure.ts +2 -0
- package/src/handlers/records-subscribe.ts +98 -47
- package/src/handlers/records-write.ts +177 -91
- package/src/index.ts +2 -0
- package/src/interfaces/protocols-configure.ts +6 -8
- package/src/interfaces/records-write-query.ts +2 -3
- package/src/interfaces/records-write.ts +62 -44
- package/src/jose/jws/general/verifier.ts +1 -1
- package/src/store/blockstore-mock.ts +3 -0
- package/src/store/index-level-compound.ts +55 -24
- package/src/store/index-level.ts +6 -4
- package/src/store/message-store-level.ts +2 -2
- package/src/store/storage-controller.ts +27 -16
- package/src/types/jose-types.ts +3 -2
- package/src/types/message-types.ts +16 -0
- package/src/utils/filter.ts +4 -8
- package/src/utils/messages.ts +53 -30
- package/src/utils/protocols.ts +2 -2
- package/src/utils/records.ts +76 -51
- package/src/utils/secp256r1.ts +1 -1
- 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
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
116
|
+
message,
|
|
117
|
+
newestExistingMessage,
|
|
118
|
+
newMessageIsInitialWrite,
|
|
119
|
+
recordsWrite,
|
|
120
|
+
tenant,
|
|
121
|
+
});
|
|
122
|
+
position = commitResult.position;
|
|
180
123
|
} catch (error) {
|
|
181
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -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:
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
/**
|