@enbox/dwn-sdk-js 0.0.8 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +3 -2
- package/dist/browser.mjs +5 -5
- package/dist/browser.mjs.map +3 -3
- package/dist/esm/generated/precompiled-validators.js +219 -165
- package/dist/esm/generated/precompiled-validators.js.map +1 -1
- package/dist/esm/src/core/dwn-error.js +3 -0
- package/dist/esm/src/core/dwn-error.js.map +1 -1
- package/dist/esm/src/core/protocol-authorization-action.js +5 -0
- package/dist/esm/src/core/protocol-authorization-action.js.map +1 -1
- package/dist/esm/src/core/protocol-authorization-validation.js +24 -0
- package/dist/esm/src/core/protocol-authorization-validation.js.map +1 -1
- package/dist/esm/src/core/protocol-authorization.js +3 -1
- package/dist/esm/src/core/protocol-authorization.js.map +1 -1
- package/dist/esm/src/core/resumable-task-manager.js +2 -0
- package/dist/esm/src/core/resumable-task-manager.js.map +1 -1
- package/dist/esm/src/dwn.js +19 -5
- package/dist/esm/src/dwn.js.map +1 -1
- package/dist/esm/src/handlers/records-read.js +5 -1
- package/dist/esm/src/handlers/records-read.js.map +1 -1
- package/dist/esm/src/handlers/records-write.js +81 -0
- package/dist/esm/src/handlers/records-write.js.map +1 -1
- package/dist/esm/src/interfaces/protocols-configure.js +9 -1
- package/dist/esm/src/interfaces/protocols-configure.js.map +1 -1
- package/dist/esm/src/interfaces/records-write.js +3 -0
- package/dist/esm/src/interfaces/records-write.js.map +1 -1
- package/dist/esm/src/store/storage-controller.js +64 -0
- package/dist/esm/src/store/storage-controller.js.map +1 -1
- package/dist/esm/src/types/protocols-types.js +1 -0
- package/dist/esm/src/types/protocols-types.js.map +1 -1
- package/dist/esm/tests/features/records-delivery.spec.js +236 -0
- package/dist/esm/tests/features/records-delivery.spec.js.map +1 -0
- package/dist/esm/tests/features/records-squash.spec.js +1055 -0
- package/dist/esm/tests/features/records-squash.spec.js.map +1 -0
- package/dist/esm/tests/handlers/records-read.spec.js +6 -2
- package/dist/esm/tests/handlers/records-read.spec.js.map +1 -1
- package/dist/esm/tests/handlers/records-write.spec.js +3 -0
- package/dist/esm/tests/handlers/records-write.spec.js.map +1 -1
- package/dist/esm/tests/test-suite.js +4 -0
- package/dist/esm/tests/test-suite.js.map +1 -1
- package/dist/esm/tests/utils/test-data-generator.js +1 -0
- package/dist/esm/tests/utils/test-data-generator.js.map +1 -1
- package/dist/types/generated/precompiled-validators.d.ts.map +1 -1
- package/dist/types/src/core/dwn-error.d.ts +3 -0
- package/dist/types/src/core/dwn-error.d.ts.map +1 -1
- package/dist/types/src/core/protocol-authorization-action.d.ts.map +1 -1
- package/dist/types/src/core/protocol-authorization-validation.d.ts +9 -0
- package/dist/types/src/core/protocol-authorization-validation.d.ts.map +1 -1
- package/dist/types/src/core/protocol-authorization.d.ts.map +1 -1
- package/dist/types/src/core/resumable-task-manager.d.ts +2 -1
- package/dist/types/src/core/resumable-task-manager.d.ts.map +1 -1
- package/dist/types/src/dwn.d.ts +9 -0
- package/dist/types/src/dwn.d.ts.map +1 -1
- package/dist/types/src/handlers/records-read.d.ts.map +1 -1
- package/dist/types/src/handlers/records-write.d.ts +8 -0
- package/dist/types/src/handlers/records-write.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +1 -1
- 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.d.ts +6 -0
- package/dist/types/src/interfaces/records-write.d.ts.map +1 -1
- package/dist/types/src/store/storage-controller.d.ts +16 -2
- package/dist/types/src/store/storage-controller.d.ts.map +1 -1
- package/dist/types/src/types/protocols-types.d.ts +29 -2
- package/dist/types/src/types/protocols-types.d.ts.map +1 -1
- package/dist/types/src/types/records-types.d.ts +7 -0
- package/dist/types/src/types/records-types.d.ts.map +1 -1
- package/dist/types/tests/features/records-delivery.spec.d.ts +2 -0
- package/dist/types/tests/features/records-delivery.spec.d.ts.map +1 -0
- package/dist/types/tests/features/records-squash.spec.d.ts +2 -0
- package/dist/types/tests/features/records-squash.spec.d.ts.map +1 -0
- package/dist/types/tests/handlers/records-read.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/dist/types/tests/utils/test-data-generator.d.ts +1 -0
- package/dist/types/tests/utils/test-data-generator.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/core/dwn-error.ts +3 -0
- package/src/core/protocol-authorization-action.ts +5 -0
- package/src/core/protocol-authorization-validation.ts +37 -0
- package/src/core/protocol-authorization.ts +4 -0
- package/src/core/resumable-task-manager.ts +3 -1
- package/src/dwn.ts +30 -5
- package/src/handlers/records-read.ts +5 -1
- package/src/handlers/records-write.ts +106 -0
- package/src/index.ts +1 -1
- package/src/interfaces/protocols-configure.ts +12 -1
- package/src/interfaces/records-write.ts +10 -0
- package/src/store/storage-controller.ts +78 -1
- package/src/types/protocols-types.ts +32 -1
- package/src/types/records-types.ts +8 -0
|
@@ -0,0 +1,1055 @@
|
|
|
1
|
+
import sinon from 'sinon';
|
|
2
|
+
import { DataStream } from '../../src/utils/data-stream.js';
|
|
3
|
+
import { DidKey } from '@enbox/dids';
|
|
4
|
+
import { Dwn } from '../../src/dwn.js';
|
|
5
|
+
import { DwnConstant } from '../../src/core/dwn-constant.js';
|
|
6
|
+
import { DwnErrorCode } from '../../src/core/dwn-error.js';
|
|
7
|
+
import { Jws } from '../../src/utils/jws.js';
|
|
8
|
+
import { RecordsDelete } from '../../src/interfaces/records-delete.js';
|
|
9
|
+
import { RecordsWrite } from '../../src/interfaces/records-write.js';
|
|
10
|
+
import { TestDataGenerator } from '../utils/test-data-generator.js';
|
|
11
|
+
import { TestEventLog } from '../test-event-stream.js';
|
|
12
|
+
import { TestStores } from '../test-stores.js';
|
|
13
|
+
import { Time } from '../../src/utils/time.js';
|
|
14
|
+
import { UniversalResolver } from '@enbox/dids';
|
|
15
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
|
16
|
+
export function testRecordsSquash() {
|
|
17
|
+
describe('Records $squash', () => {
|
|
18
|
+
let didResolver;
|
|
19
|
+
let messageStore;
|
|
20
|
+
let dataStore;
|
|
21
|
+
let resumableTaskStore;
|
|
22
|
+
let stateIndex;
|
|
23
|
+
let eventLog;
|
|
24
|
+
let dwn;
|
|
25
|
+
// important to follow the `before` and `after` pattern to initialize and clean the stores in tests
|
|
26
|
+
// so that different test suites can reuse the same backend store for testing
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
didResolver = new UniversalResolver({ didResolvers: [DidKey] });
|
|
29
|
+
const stores = TestStores.get();
|
|
30
|
+
messageStore = stores.messageStore;
|
|
31
|
+
dataStore = stores.dataStore;
|
|
32
|
+
resumableTaskStore = stores.resumableTaskStore;
|
|
33
|
+
stateIndex = stores.stateIndex;
|
|
34
|
+
eventLog = TestEventLog.get();
|
|
35
|
+
dwn = await Dwn.create({ didResolver, messageStore, dataStore, stateIndex, eventLog, resumableTaskStore });
|
|
36
|
+
});
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
sinon.restore();
|
|
39
|
+
await messageStore.clear();
|
|
40
|
+
await dataStore.clear();
|
|
41
|
+
await resumableTaskStore.clear();
|
|
42
|
+
await stateIndex.clear();
|
|
43
|
+
});
|
|
44
|
+
afterAll(async () => {
|
|
45
|
+
await dwn.close();
|
|
46
|
+
});
|
|
47
|
+
// shared protocol definition for squash tests
|
|
48
|
+
const squashProtocolDefinition = {
|
|
49
|
+
protocol: 'http://squash-test.xyz',
|
|
50
|
+
published: true,
|
|
51
|
+
types: {
|
|
52
|
+
document: {},
|
|
53
|
+
patch: {},
|
|
54
|
+
},
|
|
55
|
+
structure: {
|
|
56
|
+
document: {
|
|
57
|
+
$actions: [{ who: 'anyone', can: ['create', 'read'] }],
|
|
58
|
+
patch: {
|
|
59
|
+
$immutable: true,
|
|
60
|
+
$squash: true,
|
|
61
|
+
$actions: [{ who: 'anyone', can: ['create', 'read'] }],
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
// helper to install the squash protocol and create a parent document
|
|
67
|
+
async function setupProtocolAndDocument(alice) {
|
|
68
|
+
const protocol = squashProtocolDefinition.protocol;
|
|
69
|
+
const protocolConfig = await TestDataGenerator.generateProtocolsConfigure({
|
|
70
|
+
author: alice,
|
|
71
|
+
protocolDefinition: squashProtocolDefinition,
|
|
72
|
+
});
|
|
73
|
+
const configReply = await dwn.processMessage(alice.did, protocolConfig.message);
|
|
74
|
+
expect(configReply.status.code).toBe(202);
|
|
75
|
+
// create a parent document
|
|
76
|
+
const document = await TestDataGenerator.generateRecordsWrite({
|
|
77
|
+
author: alice,
|
|
78
|
+
protocol,
|
|
79
|
+
protocolPath: 'document',
|
|
80
|
+
});
|
|
81
|
+
const docReply = await dwn.processMessage(alice.did, document.message, { dataStream: document.dataStream });
|
|
82
|
+
expect(docReply.status.code).toBe(202);
|
|
83
|
+
return {
|
|
84
|
+
protocol,
|
|
85
|
+
documentContextId: document.message.contextId,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
describe('protocol definition validation', () => {
|
|
89
|
+
it('should accept a protocol definition with $squash: true', async () => {
|
|
90
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
91
|
+
const protocolConfig = await TestDataGenerator.generateProtocolsConfigure({
|
|
92
|
+
author: alice,
|
|
93
|
+
protocolDefinition: squashProtocolDefinition,
|
|
94
|
+
});
|
|
95
|
+
const reply = await dwn.processMessage(alice.did, protocolConfig.message);
|
|
96
|
+
expect(reply.status.code).toBe(202);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
describe('squash eligibility', () => {
|
|
100
|
+
it('should reject squash write at a protocol path without $squash: true', async () => {
|
|
101
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
102
|
+
// install a protocol WITHOUT $squash
|
|
103
|
+
const noSquashProtocol = {
|
|
104
|
+
protocol: 'http://no-squash.xyz',
|
|
105
|
+
published: true,
|
|
106
|
+
types: { note: {} },
|
|
107
|
+
structure: {
|
|
108
|
+
note: {
|
|
109
|
+
$actions: [{ who: 'anyone', can: ['create', 'read'] }],
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const protocolConfig = await TestDataGenerator.generateProtocolsConfigure({
|
|
114
|
+
author: alice,
|
|
115
|
+
protocolDefinition: noSquashProtocol,
|
|
116
|
+
});
|
|
117
|
+
const configReply = await dwn.processMessage(alice.did, protocolConfig.message);
|
|
118
|
+
expect(configReply.status.code).toBe(202);
|
|
119
|
+
// attempt a squash write — should be rejected
|
|
120
|
+
const squashRecord = await TestDataGenerator.generateRecordsWrite({
|
|
121
|
+
author: alice,
|
|
122
|
+
protocol: noSquashProtocol.protocol,
|
|
123
|
+
protocolPath: 'note',
|
|
124
|
+
squash: true,
|
|
125
|
+
});
|
|
126
|
+
const reply = await dwn.processMessage(alice.did, squashRecord.message, { dataStream: squashRecord.dataStream });
|
|
127
|
+
expect(reply.status.code).toBe(400);
|
|
128
|
+
expect(reply.status.detail).toContain(DwnErrorCode.ProtocolAuthorizationSquashNotEnabled);
|
|
129
|
+
});
|
|
130
|
+
it('should reject squash on non-initial writes (updates)', async () => {
|
|
131
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
132
|
+
// install squash protocol but on a path that allows updates (for testing purposes)
|
|
133
|
+
const updatableSquashProtocol = {
|
|
134
|
+
protocol: 'http://updatable-squash.xyz',
|
|
135
|
+
published: true,
|
|
136
|
+
types: { note: {} },
|
|
137
|
+
structure: {
|
|
138
|
+
note: {
|
|
139
|
+
$squash: true,
|
|
140
|
+
$actions: [{ who: 'anyone', can: ['create', 'read', 'update'] }],
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const protocolConfig = await TestDataGenerator.generateProtocolsConfigure({
|
|
145
|
+
author: alice,
|
|
146
|
+
protocolDefinition: updatableSquashProtocol,
|
|
147
|
+
});
|
|
148
|
+
const configReply = await dwn.processMessage(alice.did, protocolConfig.message);
|
|
149
|
+
expect(configReply.status.code).toBe(202);
|
|
150
|
+
// create the initial record (non-squash)
|
|
151
|
+
const record = await TestDataGenerator.generateRecordsWrite({
|
|
152
|
+
author: alice,
|
|
153
|
+
protocol: updatableSquashProtocol.protocol,
|
|
154
|
+
protocolPath: 'note',
|
|
155
|
+
});
|
|
156
|
+
const writeReply = await dwn.processMessage(alice.did, record.message, { dataStream: record.dataStream });
|
|
157
|
+
expect(writeReply.status.code).toBe(202);
|
|
158
|
+
// attempt to "squash" via update — squash is immutable and can't appear on updates
|
|
159
|
+
// since squash must only be on initial writes and would change an immutable property
|
|
160
|
+
const updateData = TestDataGenerator.randomBytes(32);
|
|
161
|
+
const update = await RecordsWrite.createFrom({
|
|
162
|
+
recordsWriteMessage: record.message,
|
|
163
|
+
signer: Jws.createSigner(alice),
|
|
164
|
+
data: updateData,
|
|
165
|
+
});
|
|
166
|
+
// Manually verify that squash=true on an update would fail immutable property check
|
|
167
|
+
// This is because squash is an immutable descriptor property. Trying to add it to an
|
|
168
|
+
// update of a record that didn't originally have it would be rejected.
|
|
169
|
+
// The spec says: squash write MUST be an initial write.
|
|
170
|
+
const updateReply = await dwn.processMessage(alice.did, update.message, { dataStream: DataStream.fromBytes(updateData) });
|
|
171
|
+
expect(updateReply.status.code).toBe(202); // update itself succeeds (no squash)
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
describe('squash processing', () => {
|
|
175
|
+
it('should create a squash record and delete all older sibling records', async () => {
|
|
176
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
177
|
+
const { protocol, documentContextId } = await setupProtocolAndDocument(alice);
|
|
178
|
+
// create several patch records
|
|
179
|
+
const patches = [];
|
|
180
|
+
for (let i = 0; i < 5; i++) {
|
|
181
|
+
const timestamp = Time.createOffsetTimestamp({ seconds: i + 1 });
|
|
182
|
+
const patch = await TestDataGenerator.generateRecordsWrite({
|
|
183
|
+
author: alice,
|
|
184
|
+
protocol,
|
|
185
|
+
protocolPath: 'document/patch',
|
|
186
|
+
parentContextId: documentContextId,
|
|
187
|
+
messageTimestamp: timestamp,
|
|
188
|
+
dateCreated: timestamp,
|
|
189
|
+
});
|
|
190
|
+
const patchReply = await dwn.processMessage(alice.did, patch.message, { dataStream: patch.dataStream });
|
|
191
|
+
expect(patchReply.status.code).toBe(202);
|
|
192
|
+
patches.push(patch);
|
|
193
|
+
}
|
|
194
|
+
// create a squash record with a timestamp after all patches
|
|
195
|
+
const squashTimestamp = Time.createOffsetTimestamp({ seconds: 10 });
|
|
196
|
+
const squashRecord = await TestDataGenerator.generateRecordsWrite({
|
|
197
|
+
author: alice,
|
|
198
|
+
protocol,
|
|
199
|
+
protocolPath: 'document/patch',
|
|
200
|
+
parentContextId: documentContextId,
|
|
201
|
+
messageTimestamp: squashTimestamp,
|
|
202
|
+
dateCreated: squashTimestamp,
|
|
203
|
+
squash: true,
|
|
204
|
+
});
|
|
205
|
+
const squashReply = await dwn.processMessage(alice.did, squashRecord.message, { dataStream: squashRecord.dataStream });
|
|
206
|
+
expect(squashReply.status.code).toBe(202);
|
|
207
|
+
// query for all patches — only the squash record should remain
|
|
208
|
+
const query = await TestDataGenerator.generateRecordsQuery({
|
|
209
|
+
author: alice,
|
|
210
|
+
filter: {
|
|
211
|
+
protocol,
|
|
212
|
+
protocolPath: 'document/patch',
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
const queryReply = await dwn.processMessage(alice.did, query.message);
|
|
216
|
+
expect(queryReply.status.code).toBe(200);
|
|
217
|
+
expect(queryReply.entries.length).toBe(1);
|
|
218
|
+
expect(queryReply.entries[0].recordId).toBe(squashRecord.message.recordId);
|
|
219
|
+
});
|
|
220
|
+
it('should not delete sibling records that are newer than the squash', async () => {
|
|
221
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
222
|
+
const { protocol, documentContextId } = await setupProtocolAndDocument(alice);
|
|
223
|
+
// create a patch record before the squash
|
|
224
|
+
const beforeTimestamp = Time.createOffsetTimestamp({ seconds: 1 });
|
|
225
|
+
const patchBefore = await TestDataGenerator.generateRecordsWrite({
|
|
226
|
+
author: alice,
|
|
227
|
+
protocol,
|
|
228
|
+
protocolPath: 'document/patch',
|
|
229
|
+
parentContextId: documentContextId,
|
|
230
|
+
messageTimestamp: beforeTimestamp,
|
|
231
|
+
dateCreated: beforeTimestamp,
|
|
232
|
+
});
|
|
233
|
+
const beforeReply = await dwn.processMessage(alice.did, patchBefore.message, { dataStream: patchBefore.dataStream });
|
|
234
|
+
expect(beforeReply.status.code).toBe(202);
|
|
235
|
+
// create the squash record
|
|
236
|
+
const squashTimestamp = Time.createOffsetTimestamp({ seconds: 5 });
|
|
237
|
+
const squashRecord = await TestDataGenerator.generateRecordsWrite({
|
|
238
|
+
author: alice,
|
|
239
|
+
protocol,
|
|
240
|
+
protocolPath: 'document/patch',
|
|
241
|
+
parentContextId: documentContextId,
|
|
242
|
+
messageTimestamp: squashTimestamp,
|
|
243
|
+
dateCreated: squashTimestamp,
|
|
244
|
+
squash: true,
|
|
245
|
+
});
|
|
246
|
+
const squashReply = await dwn.processMessage(alice.did, squashRecord.message, { dataStream: squashRecord.dataStream });
|
|
247
|
+
expect(squashReply.status.code).toBe(202);
|
|
248
|
+
// create a patch record AFTER the squash
|
|
249
|
+
const afterTimestamp = Time.createOffsetTimestamp({ seconds: 10 });
|
|
250
|
+
const patchAfter = await TestDataGenerator.generateRecordsWrite({
|
|
251
|
+
author: alice,
|
|
252
|
+
protocol,
|
|
253
|
+
protocolPath: 'document/patch',
|
|
254
|
+
parentContextId: documentContextId,
|
|
255
|
+
messageTimestamp: afterTimestamp,
|
|
256
|
+
dateCreated: afterTimestamp,
|
|
257
|
+
});
|
|
258
|
+
const afterReply = await dwn.processMessage(alice.did, patchAfter.message, { dataStream: patchAfter.dataStream });
|
|
259
|
+
expect(afterReply.status.code).toBe(202);
|
|
260
|
+
// query: should see squash + post-squash record
|
|
261
|
+
const query = await TestDataGenerator.generateRecordsQuery({
|
|
262
|
+
author: alice,
|
|
263
|
+
filter: {
|
|
264
|
+
protocol,
|
|
265
|
+
protocolPath: 'document/patch',
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
const queryReply = await dwn.processMessage(alice.did, query.message);
|
|
269
|
+
expect(queryReply.status.code).toBe(200);
|
|
270
|
+
expect(queryReply.entries.length).toBe(2);
|
|
271
|
+
const recordIds = queryReply.entries.map((e) => e.recordId);
|
|
272
|
+
expect(recordIds).toContain(squashRecord.message.recordId);
|
|
273
|
+
expect(recordIds).toContain(patchAfter.message.recordId);
|
|
274
|
+
expect(recordIds).not.toContain(patchBefore.message.recordId);
|
|
275
|
+
});
|
|
276
|
+
it('should support recursive squash (newer squash deletes older squash)', async () => {
|
|
277
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
278
|
+
const { protocol, documentContextId } = await setupProtocolAndDocument(alice);
|
|
279
|
+
// create first batch of patches
|
|
280
|
+
for (let i = 0; i < 3; i++) {
|
|
281
|
+
const timestamp = Time.createOffsetTimestamp({ seconds: i + 1 });
|
|
282
|
+
const patch = await TestDataGenerator.generateRecordsWrite({
|
|
283
|
+
author: alice,
|
|
284
|
+
protocol,
|
|
285
|
+
protocolPath: 'document/patch',
|
|
286
|
+
parentContextId: documentContextId,
|
|
287
|
+
messageTimestamp: timestamp,
|
|
288
|
+
dateCreated: timestamp,
|
|
289
|
+
});
|
|
290
|
+
const reply = await dwn.processMessage(alice.did, patch.message, { dataStream: patch.dataStream });
|
|
291
|
+
expect(reply.status.code).toBe(202);
|
|
292
|
+
}
|
|
293
|
+
// first squash
|
|
294
|
+
const firstSquashTimestamp = Time.createOffsetTimestamp({ seconds: 5 });
|
|
295
|
+
const firstSquash = await TestDataGenerator.generateRecordsWrite({
|
|
296
|
+
author: alice,
|
|
297
|
+
protocol,
|
|
298
|
+
protocolPath: 'document/patch',
|
|
299
|
+
parentContextId: documentContextId,
|
|
300
|
+
messageTimestamp: firstSquashTimestamp,
|
|
301
|
+
dateCreated: firstSquashTimestamp,
|
|
302
|
+
squash: true,
|
|
303
|
+
});
|
|
304
|
+
const firstSquashReply = await dwn.processMessage(alice.did, firstSquash.message, { dataStream: firstSquash.dataStream });
|
|
305
|
+
expect(firstSquashReply.status.code).toBe(202);
|
|
306
|
+
// create more patches after the first squash
|
|
307
|
+
for (let i = 0; i < 2; i++) {
|
|
308
|
+
const timestamp = Time.createOffsetTimestamp({ seconds: 6 + i });
|
|
309
|
+
const patch = await TestDataGenerator.generateRecordsWrite({
|
|
310
|
+
author: alice,
|
|
311
|
+
protocol,
|
|
312
|
+
protocolPath: 'document/patch',
|
|
313
|
+
parentContextId: documentContextId,
|
|
314
|
+
messageTimestamp: timestamp,
|
|
315
|
+
dateCreated: timestamp,
|
|
316
|
+
});
|
|
317
|
+
const reply = await dwn.processMessage(alice.did, patch.message, { dataStream: patch.dataStream });
|
|
318
|
+
expect(reply.status.code).toBe(202);
|
|
319
|
+
}
|
|
320
|
+
// second squash — should delete first squash + post-first-squash patches
|
|
321
|
+
const secondSquashTimestamp = Time.createOffsetTimestamp({ seconds: 10 });
|
|
322
|
+
const secondSquash = await TestDataGenerator.generateRecordsWrite({
|
|
323
|
+
author: alice,
|
|
324
|
+
protocol,
|
|
325
|
+
protocolPath: 'document/patch',
|
|
326
|
+
parentContextId: documentContextId,
|
|
327
|
+
messageTimestamp: secondSquashTimestamp,
|
|
328
|
+
dateCreated: secondSquashTimestamp,
|
|
329
|
+
squash: true,
|
|
330
|
+
});
|
|
331
|
+
const secondSquashReply = await dwn.processMessage(alice.did, secondSquash.message, { dataStream: secondSquash.dataStream });
|
|
332
|
+
expect(secondSquashReply.status.code).toBe(202);
|
|
333
|
+
// query: only second squash should remain
|
|
334
|
+
const query = await TestDataGenerator.generateRecordsQuery({
|
|
335
|
+
author: alice,
|
|
336
|
+
filter: {
|
|
337
|
+
protocol,
|
|
338
|
+
protocolPath: 'document/patch',
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
const queryReply = await dwn.processMessage(alice.did, query.message);
|
|
342
|
+
expect(queryReply.status.code).toBe(200);
|
|
343
|
+
expect(queryReply.entries.length).toBe(1);
|
|
344
|
+
expect(queryReply.entries[0].recordId).toBe(secondSquash.message.recordId);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
describe('squash backstop', () => {
|
|
348
|
+
it('should reject writes older than the most recent squash (temporal floor)', async () => {
|
|
349
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
350
|
+
const { protocol, documentContextId } = await setupProtocolAndDocument(alice);
|
|
351
|
+
// create a squash record
|
|
352
|
+
const squashTimestamp = Time.createOffsetTimestamp({ seconds: 10 });
|
|
353
|
+
const squashRecord = await TestDataGenerator.generateRecordsWrite({
|
|
354
|
+
author: alice,
|
|
355
|
+
protocol,
|
|
356
|
+
protocolPath: 'document/patch',
|
|
357
|
+
parentContextId: documentContextId,
|
|
358
|
+
messageTimestamp: squashTimestamp,
|
|
359
|
+
dateCreated: squashTimestamp,
|
|
360
|
+
squash: true,
|
|
361
|
+
});
|
|
362
|
+
const squashReply = await dwn.processMessage(alice.did, squashRecord.message, { dataStream: squashRecord.dataStream });
|
|
363
|
+
expect(squashReply.status.code).toBe(202);
|
|
364
|
+
// attempt to write a record with an older timestamp — should be rejected
|
|
365
|
+
const olderTimestamp = Time.createOffsetTimestamp({ seconds: 5 });
|
|
366
|
+
const olderRecord = await TestDataGenerator.generateRecordsWrite({
|
|
367
|
+
author: alice,
|
|
368
|
+
protocol,
|
|
369
|
+
protocolPath: 'document/patch',
|
|
370
|
+
parentContextId: documentContextId,
|
|
371
|
+
messageTimestamp: olderTimestamp,
|
|
372
|
+
dateCreated: olderTimestamp,
|
|
373
|
+
});
|
|
374
|
+
const olderReply = await dwn.processMessage(alice.did, olderRecord.message, { dataStream: olderRecord.dataStream });
|
|
375
|
+
expect(olderReply.status.code).toBe(409);
|
|
376
|
+
expect(olderReply.status.detail).toContain(DwnErrorCode.ProtocolAuthorizationSquashBackstop);
|
|
377
|
+
});
|
|
378
|
+
it('should allow writes newer than the most recent squash', async () => {
|
|
379
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
380
|
+
const { protocol, documentContextId } = await setupProtocolAndDocument(alice);
|
|
381
|
+
// create a squash record
|
|
382
|
+
const squashTimestamp = Time.createOffsetTimestamp({ seconds: 5 });
|
|
383
|
+
const squashRecord = await TestDataGenerator.generateRecordsWrite({
|
|
384
|
+
author: alice,
|
|
385
|
+
protocol,
|
|
386
|
+
protocolPath: 'document/patch',
|
|
387
|
+
parentContextId: documentContextId,
|
|
388
|
+
messageTimestamp: squashTimestamp,
|
|
389
|
+
dateCreated: squashTimestamp,
|
|
390
|
+
squash: true,
|
|
391
|
+
});
|
|
392
|
+
const squashReply = await dwn.processMessage(alice.did, squashRecord.message, { dataStream: squashRecord.dataStream });
|
|
393
|
+
expect(squashReply.status.code).toBe(202);
|
|
394
|
+
// write a record with a newer timestamp — should succeed
|
|
395
|
+
const newerTimestamp = Time.createOffsetTimestamp({ seconds: 10 });
|
|
396
|
+
const newerRecord = await TestDataGenerator.generateRecordsWrite({
|
|
397
|
+
author: alice,
|
|
398
|
+
protocol,
|
|
399
|
+
protocolPath: 'document/patch',
|
|
400
|
+
parentContextId: documentContextId,
|
|
401
|
+
messageTimestamp: newerTimestamp,
|
|
402
|
+
dateCreated: newerTimestamp,
|
|
403
|
+
});
|
|
404
|
+
const newerReply = await dwn.processMessage(alice.did, newerRecord.message, { dataStream: newerRecord.dataStream });
|
|
405
|
+
expect(newerReply.status.code).toBe(202);
|
|
406
|
+
});
|
|
407
|
+
it('should reject a squash record that is older than an existing squash', async () => {
|
|
408
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
409
|
+
const { protocol, documentContextId } = await setupProtocolAndDocument(alice);
|
|
410
|
+
// create a squash record with a later timestamp
|
|
411
|
+
const laterTimestamp = Time.createOffsetTimestamp({ seconds: 10 });
|
|
412
|
+
const laterSquash = await TestDataGenerator.generateRecordsWrite({
|
|
413
|
+
author: alice,
|
|
414
|
+
protocol,
|
|
415
|
+
protocolPath: 'document/patch',
|
|
416
|
+
parentContextId: documentContextId,
|
|
417
|
+
messageTimestamp: laterTimestamp,
|
|
418
|
+
dateCreated: laterTimestamp,
|
|
419
|
+
squash: true,
|
|
420
|
+
});
|
|
421
|
+
const laterReply = await dwn.processMessage(alice.did, laterSquash.message, { dataStream: laterSquash.dataStream });
|
|
422
|
+
expect(laterReply.status.code).toBe(202);
|
|
423
|
+
// attempt to create an earlier squash record — should be rejected by backstop
|
|
424
|
+
const earlierTimestamp = Time.createOffsetTimestamp({ seconds: 5 });
|
|
425
|
+
const earlierSquash = await TestDataGenerator.generateRecordsWrite({
|
|
426
|
+
author: alice,
|
|
427
|
+
protocol,
|
|
428
|
+
protocolPath: 'document/patch',
|
|
429
|
+
parentContextId: documentContextId,
|
|
430
|
+
messageTimestamp: earlierTimestamp,
|
|
431
|
+
dateCreated: earlierTimestamp,
|
|
432
|
+
squash: true,
|
|
433
|
+
});
|
|
434
|
+
const earlierReply = await dwn.processMessage(alice.did, earlierSquash.message, { dataStream: earlierSquash.dataStream });
|
|
435
|
+
expect(earlierReply.status.code).toBe(409);
|
|
436
|
+
expect(earlierReply.status.detail).toContain(DwnErrorCode.ProtocolAuthorizationSquashBackstop);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
describe('squash authorization', () => {
|
|
440
|
+
it('should authorize squash via explicit squash action rule', async () => {
|
|
441
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
442
|
+
const bob = await TestDataGenerator.generateDidKeyPersona();
|
|
443
|
+
// protocol where only editors can squash
|
|
444
|
+
const editorSquashProtocol = {
|
|
445
|
+
protocol: 'http://editor-squash.xyz',
|
|
446
|
+
published: true,
|
|
447
|
+
types: {
|
|
448
|
+
document: {},
|
|
449
|
+
editor: {},
|
|
450
|
+
patch: {},
|
|
451
|
+
},
|
|
452
|
+
structure: {
|
|
453
|
+
document: {
|
|
454
|
+
$actions: [{ who: 'anyone', can: ['create', 'read'] }],
|
|
455
|
+
editor: {
|
|
456
|
+
$role: true,
|
|
457
|
+
$actions: [{ who: 'author', of: 'document', can: ['create', 'delete'] }],
|
|
458
|
+
},
|
|
459
|
+
patch: {
|
|
460
|
+
$immutable: true,
|
|
461
|
+
$squash: true,
|
|
462
|
+
$actions: [
|
|
463
|
+
{ who: 'anyone', can: ['create', 'read'] },
|
|
464
|
+
{ role: 'document/editor', can: ['squash'] },
|
|
465
|
+
],
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
const protocolConfig = await TestDataGenerator.generateProtocolsConfigure({
|
|
471
|
+
author: alice,
|
|
472
|
+
protocolDefinition: editorSquashProtocol,
|
|
473
|
+
});
|
|
474
|
+
const configReply = await dwn.processMessage(alice.did, protocolConfig.message);
|
|
475
|
+
expect(configReply.status.code).toBe(202);
|
|
476
|
+
// create parent document
|
|
477
|
+
const document = await TestDataGenerator.generateRecordsWrite({
|
|
478
|
+
author: alice,
|
|
479
|
+
protocol: editorSquashProtocol.protocol,
|
|
480
|
+
protocolPath: 'document',
|
|
481
|
+
});
|
|
482
|
+
const docReply = await dwn.processMessage(alice.did, document.message, { dataStream: document.dataStream });
|
|
483
|
+
expect(docReply.status.code).toBe(202);
|
|
484
|
+
const documentContextId = document.message.contextId;
|
|
485
|
+
// give bob the editor role
|
|
486
|
+
const editorRole = await TestDataGenerator.generateRecordsWrite({
|
|
487
|
+
author: alice,
|
|
488
|
+
recipient: bob.did,
|
|
489
|
+
protocol: editorSquashProtocol.protocol,
|
|
490
|
+
protocolPath: 'document/editor',
|
|
491
|
+
parentContextId: documentContextId,
|
|
492
|
+
});
|
|
493
|
+
const editorReply = await dwn.processMessage(alice.did, editorRole.message, { dataStream: editorRole.dataStream });
|
|
494
|
+
expect(editorReply.status.code).toBe(202);
|
|
495
|
+
// create some patches
|
|
496
|
+
for (let i = 0; i < 3; i++) {
|
|
497
|
+
const timestamp = Time.createOffsetTimestamp({ seconds: i + 1 });
|
|
498
|
+
const patch = await TestDataGenerator.generateRecordsWrite({
|
|
499
|
+
author: alice,
|
|
500
|
+
protocol: editorSquashProtocol.protocol,
|
|
501
|
+
protocolPath: 'document/patch',
|
|
502
|
+
parentContextId: documentContextId,
|
|
503
|
+
messageTimestamp: timestamp,
|
|
504
|
+
dateCreated: timestamp,
|
|
505
|
+
});
|
|
506
|
+
const reply = await dwn.processMessage(alice.did, patch.message, { dataStream: patch.dataStream });
|
|
507
|
+
expect(reply.status.code).toBe(202);
|
|
508
|
+
}
|
|
509
|
+
// bob squashes by invoking the editor role
|
|
510
|
+
const squashTimestamp = Time.createOffsetTimestamp({ seconds: 10 });
|
|
511
|
+
const squashData = TestDataGenerator.randomBytes(64);
|
|
512
|
+
const bobSquash = await RecordsWrite.create({
|
|
513
|
+
signer: Jws.createSigner(bob),
|
|
514
|
+
protocol: editorSquashProtocol.protocol,
|
|
515
|
+
protocolPath: 'document/patch',
|
|
516
|
+
protocolRole: 'document/editor',
|
|
517
|
+
parentContextId: documentContextId,
|
|
518
|
+
dataFormat: 'application/json',
|
|
519
|
+
data: squashData,
|
|
520
|
+
messageTimestamp: squashTimestamp,
|
|
521
|
+
dateCreated: squashTimestamp,
|
|
522
|
+
squash: true,
|
|
523
|
+
});
|
|
524
|
+
const bobSquashReply = await dwn.processMessage(alice.did, bobSquash.message, { dataStream: DataStream.fromBytes(squashData) });
|
|
525
|
+
expect(bobSquashReply.status.code).toBe(202);
|
|
526
|
+
// verify patches were deleted
|
|
527
|
+
const query = await TestDataGenerator.generateRecordsQuery({
|
|
528
|
+
author: alice,
|
|
529
|
+
filter: {
|
|
530
|
+
protocol: editorSquashProtocol.protocol,
|
|
531
|
+
protocolPath: 'document/patch',
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
const queryReply = await dwn.processMessage(alice.did, query.message);
|
|
535
|
+
expect(queryReply.status.code).toBe(200);
|
|
536
|
+
expect(queryReply.entries.length).toBe(1);
|
|
537
|
+
expect(queryReply.entries[0].recordId).toBe(bobSquash.message.recordId);
|
|
538
|
+
});
|
|
539
|
+
it('should authorize squash via create fallback when no explicit squash rule exists', async () => {
|
|
540
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
541
|
+
const { protocol, documentContextId } = await setupProtocolAndDocument(alice);
|
|
542
|
+
// the default squash protocol only has 'create' and 'read' actions — no explicit 'squash' rule
|
|
543
|
+
// squash should fall back to create authorization
|
|
544
|
+
// create some patches
|
|
545
|
+
for (let i = 0; i < 3; i++) {
|
|
546
|
+
const timestamp = Time.createOffsetTimestamp({ seconds: i + 1 });
|
|
547
|
+
const patch = await TestDataGenerator.generateRecordsWrite({
|
|
548
|
+
author: alice,
|
|
549
|
+
protocol,
|
|
550
|
+
protocolPath: 'document/patch',
|
|
551
|
+
parentContextId: documentContextId,
|
|
552
|
+
messageTimestamp: timestamp,
|
|
553
|
+
dateCreated: timestamp,
|
|
554
|
+
});
|
|
555
|
+
const reply = await dwn.processMessage(alice.did, patch.message, { dataStream: patch.dataStream });
|
|
556
|
+
expect(reply.status.code).toBe(202);
|
|
557
|
+
}
|
|
558
|
+
// squash write — should succeed via create fallback
|
|
559
|
+
const squashTimestamp = Time.createOffsetTimestamp({ seconds: 10 });
|
|
560
|
+
const squashRecord = await TestDataGenerator.generateRecordsWrite({
|
|
561
|
+
author: alice,
|
|
562
|
+
protocol,
|
|
563
|
+
protocolPath: 'document/patch',
|
|
564
|
+
parentContextId: documentContextId,
|
|
565
|
+
messageTimestamp: squashTimestamp,
|
|
566
|
+
dateCreated: squashTimestamp,
|
|
567
|
+
squash: true,
|
|
568
|
+
});
|
|
569
|
+
const squashReply = await dwn.processMessage(alice.did, squashRecord.message, { dataStream: squashRecord.dataStream });
|
|
570
|
+
expect(squashReply.status.code).toBe(202);
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
describe('squash property immutability', () => {
|
|
574
|
+
it('should treat squash as an immutable descriptor property', async () => {
|
|
575
|
+
// squash is in the descriptor and not in mutableDescriptorProperties,
|
|
576
|
+
// so it's automatically treated as immutable by verifyEqualityOfImmutableProperties.
|
|
577
|
+
// This test verifies that a non-squash record cannot be "updated" into a squash record.
|
|
578
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
579
|
+
const updatableSquashProtocol = {
|
|
580
|
+
protocol: 'http://immutable-squash-prop.xyz',
|
|
581
|
+
published: true,
|
|
582
|
+
types: { note: {} },
|
|
583
|
+
structure: {
|
|
584
|
+
note: {
|
|
585
|
+
$squash: true,
|
|
586
|
+
$actions: [{ who: 'anyone', can: ['create', 'read', 'update'] }],
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
const protocolConfig = await TestDataGenerator.generateProtocolsConfigure({
|
|
591
|
+
author: alice,
|
|
592
|
+
protocolDefinition: updatableSquashProtocol,
|
|
593
|
+
});
|
|
594
|
+
const configReply = await dwn.processMessage(alice.did, protocolConfig.message);
|
|
595
|
+
expect(configReply.status.code).toBe(202);
|
|
596
|
+
// create a non-squash record
|
|
597
|
+
const record = await TestDataGenerator.generateRecordsWrite({
|
|
598
|
+
author: alice,
|
|
599
|
+
protocol: updatableSquashProtocol.protocol,
|
|
600
|
+
protocolPath: 'note',
|
|
601
|
+
});
|
|
602
|
+
const writeReply = await dwn.processMessage(alice.did, record.message, { dataStream: record.dataStream });
|
|
603
|
+
expect(writeReply.status.code).toBe(202);
|
|
604
|
+
// verify the descriptor does not have squash
|
|
605
|
+
expect(record.message.descriptor.squash).toBeUndefined();
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
describe('protocol definition validation — $squash: false', () => {
|
|
609
|
+
it('should reject a protocol definition with $squash: false at schema validation time', async () => {
|
|
610
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
611
|
+
const invalidProtocol = {
|
|
612
|
+
protocol: 'http://squash-false-test.xyz',
|
|
613
|
+
published: true,
|
|
614
|
+
types: { note: {} },
|
|
615
|
+
structure: {
|
|
616
|
+
note: {
|
|
617
|
+
$squash: false,
|
|
618
|
+
$actions: [{ who: 'anyone', can: ['create', 'read'] }],
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
// $squash: false is rejected by JSON schema validation (enum: [true]) during message creation
|
|
623
|
+
await expect(TestDataGenerator.generateProtocolsConfigure({
|
|
624
|
+
author: alice,
|
|
625
|
+
protocolDefinition: invalidProtocol,
|
|
626
|
+
})).rejects.toThrow('must be equal to one of the allowed values');
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
describe('root-level squash path', () => {
|
|
630
|
+
it('should squash records at a root-level protocol path (no parent context)', async () => {
|
|
631
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
632
|
+
// protocol with $squash at root level
|
|
633
|
+
const rootSquashProtocol = {
|
|
634
|
+
protocol: 'http://root-squash.xyz',
|
|
635
|
+
published: true,
|
|
636
|
+
types: { note: {} },
|
|
637
|
+
structure: {
|
|
638
|
+
note: {
|
|
639
|
+
$squash: true,
|
|
640
|
+
$actions: [{ who: 'anyone', can: ['create', 'read'] }],
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
const protocolConfig = await TestDataGenerator.generateProtocolsConfigure({
|
|
645
|
+
author: alice,
|
|
646
|
+
protocolDefinition: rootSquashProtocol,
|
|
647
|
+
});
|
|
648
|
+
const configReply = await dwn.processMessage(alice.did, protocolConfig.message);
|
|
649
|
+
expect(configReply.status.code).toBe(202);
|
|
650
|
+
// create several root-level records
|
|
651
|
+
const records = [];
|
|
652
|
+
for (let i = 0; i < 4; i++) {
|
|
653
|
+
const timestamp = Time.createOffsetTimestamp({ seconds: i + 1 });
|
|
654
|
+
const record = await TestDataGenerator.generateRecordsWrite({
|
|
655
|
+
author: alice,
|
|
656
|
+
protocol: rootSquashProtocol.protocol,
|
|
657
|
+
protocolPath: 'note',
|
|
658
|
+
messageTimestamp: timestamp,
|
|
659
|
+
dateCreated: timestamp,
|
|
660
|
+
});
|
|
661
|
+
const reply = await dwn.processMessage(alice.did, record.message, { dataStream: record.dataStream });
|
|
662
|
+
expect(reply.status.code).toBe(202);
|
|
663
|
+
records.push(record);
|
|
664
|
+
}
|
|
665
|
+
// squash all root-level records
|
|
666
|
+
const squashTimestamp = Time.createOffsetTimestamp({ seconds: 10 });
|
|
667
|
+
const squashRecord = await TestDataGenerator.generateRecordsWrite({
|
|
668
|
+
author: alice,
|
|
669
|
+
protocol: rootSquashProtocol.protocol,
|
|
670
|
+
protocolPath: 'note',
|
|
671
|
+
messageTimestamp: squashTimestamp,
|
|
672
|
+
dateCreated: squashTimestamp,
|
|
673
|
+
squash: true,
|
|
674
|
+
});
|
|
675
|
+
const squashReply = await dwn.processMessage(alice.did, squashRecord.message, { dataStream: squashRecord.dataStream });
|
|
676
|
+
expect(squashReply.status.code).toBe(202);
|
|
677
|
+
// query: only the squash record should remain
|
|
678
|
+
const query = await TestDataGenerator.generateRecordsQuery({
|
|
679
|
+
author: alice,
|
|
680
|
+
filter: {
|
|
681
|
+
protocol: rootSquashProtocol.protocol,
|
|
682
|
+
protocolPath: 'note',
|
|
683
|
+
},
|
|
684
|
+
});
|
|
685
|
+
const queryReply = await dwn.processMessage(alice.did, query.message);
|
|
686
|
+
expect(queryReply.status.code).toBe(200);
|
|
687
|
+
expect(queryReply.entries.length).toBe(1);
|
|
688
|
+
expect(queryReply.entries[0].recordId).toBe(squashRecord.message.recordId);
|
|
689
|
+
});
|
|
690
|
+
it('should enforce backstop at a root-level protocol path', async () => {
|
|
691
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
692
|
+
const rootSquashProtocol = {
|
|
693
|
+
protocol: 'http://root-squash-backstop.xyz',
|
|
694
|
+
published: true,
|
|
695
|
+
types: { note: {} },
|
|
696
|
+
structure: {
|
|
697
|
+
note: {
|
|
698
|
+
$squash: true,
|
|
699
|
+
$actions: [{ who: 'anyone', can: ['create', 'read'] }],
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
const protocolConfig = await TestDataGenerator.generateProtocolsConfigure({
|
|
704
|
+
author: alice,
|
|
705
|
+
protocolDefinition: rootSquashProtocol,
|
|
706
|
+
});
|
|
707
|
+
const configReply = await dwn.processMessage(alice.did, protocolConfig.message);
|
|
708
|
+
expect(configReply.status.code).toBe(202);
|
|
709
|
+
// create a squash
|
|
710
|
+
const squashTimestamp = Time.createOffsetTimestamp({ seconds: 10 });
|
|
711
|
+
const squashRecord = await TestDataGenerator.generateRecordsWrite({
|
|
712
|
+
author: alice,
|
|
713
|
+
protocol: rootSquashProtocol.protocol,
|
|
714
|
+
protocolPath: 'note',
|
|
715
|
+
messageTimestamp: squashTimestamp,
|
|
716
|
+
dateCreated: squashTimestamp,
|
|
717
|
+
squash: true,
|
|
718
|
+
});
|
|
719
|
+
const squashReply = await dwn.processMessage(alice.did, squashRecord.message, { dataStream: squashRecord.dataStream });
|
|
720
|
+
expect(squashReply.status.code).toBe(202);
|
|
721
|
+
// attempt a write older than the squash — should be rejected
|
|
722
|
+
const olderTimestamp = Time.createOffsetTimestamp({ seconds: 5 });
|
|
723
|
+
const olderRecord = await TestDataGenerator.generateRecordsWrite({
|
|
724
|
+
author: alice,
|
|
725
|
+
protocol: rootSquashProtocol.protocol,
|
|
726
|
+
protocolPath: 'note',
|
|
727
|
+
messageTimestamp: olderTimestamp,
|
|
728
|
+
dateCreated: olderTimestamp,
|
|
729
|
+
});
|
|
730
|
+
const olderReply = await dwn.processMessage(alice.did, olderRecord.message, { dataStream: olderRecord.dataStream });
|
|
731
|
+
expect(olderReply.status.code).toBe(409);
|
|
732
|
+
expect(olderReply.status.detail).toContain(DwnErrorCode.ProtocolAuthorizationSquashBackstop);
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
describe('cross-context isolation', () => {
|
|
736
|
+
it('should not delete records under a different parent context', async () => {
|
|
737
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
738
|
+
const { protocol, documentContextId: docAContextId } = await setupProtocolAndDocument(alice);
|
|
739
|
+
// create a second document (different parent context)
|
|
740
|
+
const docB = await TestDataGenerator.generateRecordsWrite({
|
|
741
|
+
author: alice,
|
|
742
|
+
protocol,
|
|
743
|
+
protocolPath: 'document',
|
|
744
|
+
});
|
|
745
|
+
const docBReply = await dwn.processMessage(alice.did, docB.message, { dataStream: docB.dataStream });
|
|
746
|
+
expect(docBReply.status.code).toBe(202);
|
|
747
|
+
const docBContextId = docB.message.contextId;
|
|
748
|
+
// create patches under document A
|
|
749
|
+
const patchTimestamp = Time.createOffsetTimestamp({ seconds: 1 });
|
|
750
|
+
const patchA = await TestDataGenerator.generateRecordsWrite({
|
|
751
|
+
author: alice,
|
|
752
|
+
protocol,
|
|
753
|
+
protocolPath: 'document/patch',
|
|
754
|
+
parentContextId: docAContextId,
|
|
755
|
+
messageTimestamp: patchTimestamp,
|
|
756
|
+
dateCreated: patchTimestamp,
|
|
757
|
+
});
|
|
758
|
+
const patchAReply = await dwn.processMessage(alice.did, patchA.message, { dataStream: patchA.dataStream });
|
|
759
|
+
expect(patchAReply.status.code).toBe(202);
|
|
760
|
+
// create patches under document B
|
|
761
|
+
const patchB = await TestDataGenerator.generateRecordsWrite({
|
|
762
|
+
author: alice,
|
|
763
|
+
protocol,
|
|
764
|
+
protocolPath: 'document/patch',
|
|
765
|
+
parentContextId: docBContextId,
|
|
766
|
+
messageTimestamp: patchTimestamp,
|
|
767
|
+
dateCreated: patchTimestamp,
|
|
768
|
+
});
|
|
769
|
+
const patchBReply = await dwn.processMessage(alice.did, patchB.message, { dataStream: patchB.dataStream });
|
|
770
|
+
expect(patchBReply.status.code).toBe(202);
|
|
771
|
+
// squash under document A
|
|
772
|
+
const squashTimestamp = Time.createOffsetTimestamp({ seconds: 10 });
|
|
773
|
+
const squashA = await TestDataGenerator.generateRecordsWrite({
|
|
774
|
+
author: alice,
|
|
775
|
+
protocol,
|
|
776
|
+
protocolPath: 'document/patch',
|
|
777
|
+
parentContextId: docAContextId,
|
|
778
|
+
messageTimestamp: squashTimestamp,
|
|
779
|
+
dateCreated: squashTimestamp,
|
|
780
|
+
squash: true,
|
|
781
|
+
});
|
|
782
|
+
const squashAReply = await dwn.processMessage(alice.did, squashA.message, { dataStream: squashA.dataStream });
|
|
783
|
+
expect(squashAReply.status.code).toBe(202);
|
|
784
|
+
// verify patchA was deleted (squash under doc A)
|
|
785
|
+
const queryA = await TestDataGenerator.generateRecordsQuery({
|
|
786
|
+
author: alice,
|
|
787
|
+
filter: {
|
|
788
|
+
protocol,
|
|
789
|
+
protocolPath: 'document/patch',
|
|
790
|
+
contextId: docAContextId,
|
|
791
|
+
},
|
|
792
|
+
});
|
|
793
|
+
const queryAReply = await dwn.processMessage(alice.did, queryA.message);
|
|
794
|
+
expect(queryAReply.status.code).toBe(200);
|
|
795
|
+
expect(queryAReply.entries.length).toBe(1);
|
|
796
|
+
expect(queryAReply.entries[0].recordId).toBe(squashA.message.recordId);
|
|
797
|
+
// verify patchB is still present (different parent context)
|
|
798
|
+
const queryB = await TestDataGenerator.generateRecordsQuery({
|
|
799
|
+
author: alice,
|
|
800
|
+
filter: {
|
|
801
|
+
protocol,
|
|
802
|
+
protocolPath: 'document/patch',
|
|
803
|
+
contextId: docBContextId,
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
const queryBReply = await dwn.processMessage(alice.did, queryB.message);
|
|
807
|
+
expect(queryBReply.status.code).toBe(200);
|
|
808
|
+
expect(queryBReply.entries.length).toBe(1);
|
|
809
|
+
expect(queryBReply.entries[0].recordId).toBe(patchB.message.recordId);
|
|
810
|
+
});
|
|
811
|
+
});
|
|
812
|
+
describe('squash with large data (data store cleanup)', () => {
|
|
813
|
+
it('should delete data from the data store for records with large data', async () => {
|
|
814
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
815
|
+
const { protocol, documentContextId } = await setupProtocolAndDocument(alice);
|
|
816
|
+
// create a patch with data larger than maxDataSizeAllowedToBeEncoded so it goes to the data store
|
|
817
|
+
const largeData = TestDataGenerator.randomBytes(DwnConstant.maxDataSizeAllowedToBeEncoded + 1);
|
|
818
|
+
const patchTimestamp = Time.createOffsetTimestamp({ seconds: 1 });
|
|
819
|
+
const patch = await TestDataGenerator.generateRecordsWrite({
|
|
820
|
+
author: alice,
|
|
821
|
+
protocol,
|
|
822
|
+
protocolPath: 'document/patch',
|
|
823
|
+
parentContextId: documentContextId,
|
|
824
|
+
messageTimestamp: patchTimestamp,
|
|
825
|
+
dateCreated: patchTimestamp,
|
|
826
|
+
data: largeData,
|
|
827
|
+
});
|
|
828
|
+
const patchReply = await dwn.processMessage(alice.did, patch.message, { dataStream: DataStream.fromBytes(largeData) });
|
|
829
|
+
expect(patchReply.status.code).toBe(202);
|
|
830
|
+
// verify data exists in the data store
|
|
831
|
+
const dataBeforeSquash = await dataStore.get(alice.did, patch.message.recordId, patch.message.descriptor.dataCid);
|
|
832
|
+
expect(dataBeforeSquash).toBeDefined();
|
|
833
|
+
// squash
|
|
834
|
+
const squashTimestamp = Time.createOffsetTimestamp({ seconds: 10 });
|
|
835
|
+
const squashRecord = await TestDataGenerator.generateRecordsWrite({
|
|
836
|
+
author: alice,
|
|
837
|
+
protocol,
|
|
838
|
+
protocolPath: 'document/patch',
|
|
839
|
+
parentContextId: documentContextId,
|
|
840
|
+
messageTimestamp: squashTimestamp,
|
|
841
|
+
dateCreated: squashTimestamp,
|
|
842
|
+
squash: true,
|
|
843
|
+
});
|
|
844
|
+
const squashReply = await dwn.processMessage(alice.did, squashRecord.message, { dataStream: squashRecord.dataStream });
|
|
845
|
+
expect(squashReply.status.code).toBe(202);
|
|
846
|
+
// verify data was deleted from the data store
|
|
847
|
+
const dataAfterSquash = await dataStore.get(alice.did, patch.message.recordId, patch.message.descriptor.dataCid);
|
|
848
|
+
expect(dataAfterSquash).toBeUndefined();
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
describe('squash backstop — equal timestamps', () => {
|
|
852
|
+
it('should reject a write whose messageTimestamp equals the most recent squash timestamp', async () => {
|
|
853
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
854
|
+
const { protocol, documentContextId } = await setupProtocolAndDocument(alice);
|
|
855
|
+
// create a squash
|
|
856
|
+
const squashTimestamp = Time.createOffsetTimestamp({ seconds: 10 });
|
|
857
|
+
const squashRecord = await TestDataGenerator.generateRecordsWrite({
|
|
858
|
+
author: alice,
|
|
859
|
+
protocol,
|
|
860
|
+
protocolPath: 'document/patch',
|
|
861
|
+
parentContextId: documentContextId,
|
|
862
|
+
messageTimestamp: squashTimestamp,
|
|
863
|
+
dateCreated: squashTimestamp,
|
|
864
|
+
squash: true,
|
|
865
|
+
});
|
|
866
|
+
const squashReply = await dwn.processMessage(alice.did, squashRecord.message, { dataStream: squashRecord.dataStream });
|
|
867
|
+
expect(squashReply.status.code).toBe(202);
|
|
868
|
+
// attempt a write with the exact same timestamp as the squash — should be rejected
|
|
869
|
+
const equalTimestampRecord = await TestDataGenerator.generateRecordsWrite({
|
|
870
|
+
author: alice,
|
|
871
|
+
protocol,
|
|
872
|
+
protocolPath: 'document/patch',
|
|
873
|
+
parentContextId: documentContextId,
|
|
874
|
+
messageTimestamp: squashTimestamp,
|
|
875
|
+
dateCreated: squashTimestamp,
|
|
876
|
+
});
|
|
877
|
+
const equalReply = await dwn.processMessage(alice.did, equalTimestampRecord.message, { dataStream: equalTimestampRecord.dataStream });
|
|
878
|
+
expect(equalReply.status.code).toBe(409);
|
|
879
|
+
expect(equalReply.status.detail).toContain(DwnErrorCode.ProtocolAuthorizationSquashBackstop);
|
|
880
|
+
});
|
|
881
|
+
});
|
|
882
|
+
describe('squash authorization — negative', () => {
|
|
883
|
+
it('should reject squash when the author has neither squash nor create permission', async () => {
|
|
884
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
885
|
+
const bob = await TestDataGenerator.generateDidKeyPersona();
|
|
886
|
+
// protocol where only author of document can create patches and only editors can squash
|
|
887
|
+
// bob has no role at all
|
|
888
|
+
const restrictedProtocol = {
|
|
889
|
+
protocol: 'http://restricted-squash.xyz',
|
|
890
|
+
published: true,
|
|
891
|
+
types: {
|
|
892
|
+
document: {},
|
|
893
|
+
editor: {},
|
|
894
|
+
patch: {},
|
|
895
|
+
},
|
|
896
|
+
structure: {
|
|
897
|
+
document: {
|
|
898
|
+
$actions: [{ who: 'anyone', can: ['create', 'read'] }],
|
|
899
|
+
editor: {
|
|
900
|
+
$role: true,
|
|
901
|
+
$actions: [{ who: 'author', of: 'document', can: ['create', 'delete'] }],
|
|
902
|
+
},
|
|
903
|
+
patch: {
|
|
904
|
+
$squash: true,
|
|
905
|
+
$actions: [
|
|
906
|
+
{ who: 'author', of: 'document', can: ['create', 'read'] },
|
|
907
|
+
{ role: 'document/editor', can: ['squash'] },
|
|
908
|
+
],
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
const protocolConfig = await TestDataGenerator.generateProtocolsConfigure({
|
|
914
|
+
author: alice,
|
|
915
|
+
protocolDefinition: restrictedProtocol,
|
|
916
|
+
});
|
|
917
|
+
const configReply = await dwn.processMessage(alice.did, protocolConfig.message);
|
|
918
|
+
expect(configReply.status.code).toBe(202);
|
|
919
|
+
// alice creates a document
|
|
920
|
+
const document = await TestDataGenerator.generateRecordsWrite({
|
|
921
|
+
author: alice,
|
|
922
|
+
protocol: restrictedProtocol.protocol,
|
|
923
|
+
protocolPath: 'document',
|
|
924
|
+
});
|
|
925
|
+
const docReply = await dwn.processMessage(alice.did, document.message, { dataStream: document.dataStream });
|
|
926
|
+
expect(docReply.status.code).toBe(202);
|
|
927
|
+
const documentContextId = document.message.contextId;
|
|
928
|
+
// alice creates a patch (she is author of document, so she has 'create' permission)
|
|
929
|
+
const patchTimestamp = Time.createOffsetTimestamp({ seconds: 1 });
|
|
930
|
+
const patch = await TestDataGenerator.generateRecordsWrite({
|
|
931
|
+
author: alice,
|
|
932
|
+
protocol: restrictedProtocol.protocol,
|
|
933
|
+
protocolPath: 'document/patch',
|
|
934
|
+
parentContextId: documentContextId,
|
|
935
|
+
messageTimestamp: patchTimestamp,
|
|
936
|
+
dateCreated: patchTimestamp,
|
|
937
|
+
});
|
|
938
|
+
const patchReply = await dwn.processMessage(alice.did, patch.message, { dataStream: patch.dataStream });
|
|
939
|
+
expect(patchReply.status.code).toBe(202);
|
|
940
|
+
// bob tries to squash — bob is NOT author of document and has no editor role
|
|
941
|
+
// the squash action checks 'squash' first (no role), then falls back to 'create' (bob is not author of document)
|
|
942
|
+
const squashTimestamp = Time.createOffsetTimestamp({ seconds: 10 });
|
|
943
|
+
const bobSquash = await RecordsWrite.create({
|
|
944
|
+
signer: Jws.createSigner(bob),
|
|
945
|
+
protocol: restrictedProtocol.protocol,
|
|
946
|
+
protocolPath: 'document/patch',
|
|
947
|
+
parentContextId: documentContextId,
|
|
948
|
+
dataFormat: 'application/json',
|
|
949
|
+
data: TestDataGenerator.randomBytes(32),
|
|
950
|
+
messageTimestamp: squashTimestamp,
|
|
951
|
+
dateCreated: squashTimestamp,
|
|
952
|
+
squash: true,
|
|
953
|
+
});
|
|
954
|
+
const bobSquashReply = await dwn.processMessage(alice.did, bobSquash.message, { dataStream: DataStream.fromBytes(TestDataGenerator.randomBytes(32)) });
|
|
955
|
+
// bob should be rejected — either 401 (authorization failure)
|
|
956
|
+
expect(bobSquashReply.status.code).toBe(401);
|
|
957
|
+
});
|
|
958
|
+
});
|
|
959
|
+
describe('squash purges RecordsDelete messages', () => {
|
|
960
|
+
it('should purge RecordsDelete tombstones for records that predate the squash', async () => {
|
|
961
|
+
const alice = await TestDataGenerator.generateDidKeyPersona();
|
|
962
|
+
// Use a protocol with $squash but without $immutable so records can be deleted
|
|
963
|
+
const deletableSquashProtocol = {
|
|
964
|
+
protocol: 'http://deletable-squash.xyz',
|
|
965
|
+
published: true,
|
|
966
|
+
types: {
|
|
967
|
+
document: {},
|
|
968
|
+
patch: {},
|
|
969
|
+
},
|
|
970
|
+
structure: {
|
|
971
|
+
document: {
|
|
972
|
+
$actions: [{ who: 'anyone', can: ['create', 'read'] }],
|
|
973
|
+
patch: {
|
|
974
|
+
$squash: true,
|
|
975
|
+
$actions: [{ who: 'anyone', can: ['create', 'read', 'delete'] }],
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
const protocolConfig = await TestDataGenerator.generateProtocolsConfigure({
|
|
981
|
+
author: alice,
|
|
982
|
+
protocolDefinition: deletableSquashProtocol,
|
|
983
|
+
});
|
|
984
|
+
const configReply = await dwn.processMessage(alice.did, protocolConfig.message);
|
|
985
|
+
expect(configReply.status.code).toBe(202);
|
|
986
|
+
// create parent document
|
|
987
|
+
const document = await TestDataGenerator.generateRecordsWrite({
|
|
988
|
+
author: alice,
|
|
989
|
+
protocol: deletableSquashProtocol.protocol,
|
|
990
|
+
protocolPath: 'document',
|
|
991
|
+
});
|
|
992
|
+
const docReply = await dwn.processMessage(alice.did, document.message, { dataStream: document.dataStream });
|
|
993
|
+
expect(docReply.status.code).toBe(202);
|
|
994
|
+
const documentContextId = document.message.contextId;
|
|
995
|
+
// create a patch record
|
|
996
|
+
const patchTimestamp = Time.createOffsetTimestamp({ seconds: 1 });
|
|
997
|
+
const patch = await TestDataGenerator.generateRecordsWrite({
|
|
998
|
+
author: alice,
|
|
999
|
+
protocol: deletableSquashProtocol.protocol,
|
|
1000
|
+
protocolPath: 'document/patch',
|
|
1001
|
+
parentContextId: documentContextId,
|
|
1002
|
+
messageTimestamp: patchTimestamp,
|
|
1003
|
+
dateCreated: patchTimestamp,
|
|
1004
|
+
});
|
|
1005
|
+
const patchReply = await dwn.processMessage(alice.did, patch.message, { dataStream: patch.dataStream });
|
|
1006
|
+
expect(patchReply.status.code).toBe(202);
|
|
1007
|
+
// delete that patch record (messageTimestamp must be after the patch for the delete to be accepted)
|
|
1008
|
+
const deleteTimestamp = Time.createOffsetTimestamp({ seconds: 2 });
|
|
1009
|
+
const deleteRecord = await RecordsDelete.create({
|
|
1010
|
+
recordId: patch.message.recordId,
|
|
1011
|
+
messageTimestamp: deleteTimestamp,
|
|
1012
|
+
signer: Jws.createSigner(alice),
|
|
1013
|
+
});
|
|
1014
|
+
const deleteReply = await dwn.processMessage(alice.did, deleteRecord.message);
|
|
1015
|
+
expect(deleteReply.status.code).toBe(202);
|
|
1016
|
+
// squash — this should purge patch1 (deleted, newest message timestamp is seconds: 2)
|
|
1017
|
+
const squashTimestamp = Time.createOffsetTimestamp({ seconds: 5 });
|
|
1018
|
+
const squashRecord = await TestDataGenerator.generateRecordsWrite({
|
|
1019
|
+
author: alice,
|
|
1020
|
+
protocol: deletableSquashProtocol.protocol,
|
|
1021
|
+
protocolPath: 'document/patch',
|
|
1022
|
+
parentContextId: documentContextId,
|
|
1023
|
+
messageTimestamp: squashTimestamp,
|
|
1024
|
+
dateCreated: squashTimestamp,
|
|
1025
|
+
squash: true,
|
|
1026
|
+
});
|
|
1027
|
+
const squashReply = await dwn.processMessage(alice.did, squashRecord.message, { dataStream: squashRecord.dataStream });
|
|
1028
|
+
expect(squashReply.status.code).toBe(202);
|
|
1029
|
+
// query for all records — should see only the squash record (deleted patch1 was fully purged)
|
|
1030
|
+
const query = await TestDataGenerator.generateRecordsQuery({
|
|
1031
|
+
author: alice,
|
|
1032
|
+
filter: {
|
|
1033
|
+
protocol: deletableSquashProtocol.protocol,
|
|
1034
|
+
protocolPath: 'document/patch',
|
|
1035
|
+
},
|
|
1036
|
+
});
|
|
1037
|
+
const queryReply = await dwn.processMessage(alice.did, query.message);
|
|
1038
|
+
expect(queryReply.status.code).toBe(200);
|
|
1039
|
+
expect(queryReply.entries.length).toBe(1);
|
|
1040
|
+
expect(queryReply.entries[0].recordId).toBe(squashRecord.message.recordId);
|
|
1041
|
+
// verify the deleted patch1's messages are fully purged from message store
|
|
1042
|
+
// by querying for it directly — should not be found (both the initial write and the RecordsDelete tombstone should be gone)
|
|
1043
|
+
const directQuery = await TestDataGenerator.generateRecordsQuery({
|
|
1044
|
+
author: alice,
|
|
1045
|
+
filter: { recordId: patch.message.recordId },
|
|
1046
|
+
});
|
|
1047
|
+
const directReply = await dwn.processMessage(alice.did, directQuery.message);
|
|
1048
|
+
expect(directReply.status.code).toBe(200);
|
|
1049
|
+
expect(directReply.entries.length).toBe(0);
|
|
1050
|
+
});
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
testRecordsSquash();
|
|
1055
|
+
//# sourceMappingURL=records-squash.spec.js.map
|