dacument 1.2.0 → 1.2.2
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 +1 -1
- package/README.md +32 -23
- package/dist/CRArray/class.d.ts +3 -0
- package/dist/CRArray/class.js +147 -30
- package/dist/CRText/class.d.ts +4 -1
- package/dist/CRText/class.js +70 -17
- package/dist/Dacument/class.d.ts +6 -3
- package/dist/Dacument/class.js +130 -64
- package/dist/Dacument/types.d.ts +4 -0
- package/dist/Dacument/types.js +3 -1
- package/package.json +3 -3
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -51,6 +51,7 @@ doc.body.insertAt(0, "H");
|
|
|
51
51
|
doc.tags.add("draft");
|
|
52
52
|
doc.items.push("milk");
|
|
53
53
|
|
|
54
|
+
// Wire ops to your transport.
|
|
54
55
|
doc.addEventListener("change", (event) => channel.send(event.ops));
|
|
55
56
|
channel.onmessage = (ops) => doc.merge(ops);
|
|
56
57
|
|
|
@@ -60,13 +61,13 @@ doc.addEventListener("merge", ({ actor, target, method, data }) => {
|
|
|
60
61
|
```
|
|
61
62
|
|
|
62
63
|
`create()` returns `roleKeys` for owner/manager/editor; store them securely and
|
|
63
|
-
distribute the highest role key
|
|
64
|
+
distribute the highest role key each actor should hold.
|
|
64
65
|
|
|
65
66
|
## Schema and fields
|
|
66
67
|
|
|
67
68
|
- `register` fields behave like normal properties: `doc.title = "hi"`.
|
|
68
69
|
- Other fields return safe CRDT views: `doc.items.push("x")`.
|
|
69
|
-
- Unknown fields
|
|
70
|
+
- Unknown fields or schema bypasses throw.
|
|
70
71
|
- UI updates should listen to `merge` events.
|
|
71
72
|
|
|
72
73
|
Supported CRDT field types:
|
|
@@ -78,7 +79,9 @@ Supported CRDT field types:
|
|
|
78
79
|
- `map` - OR-Map.
|
|
79
80
|
- `record` - OR-Record.
|
|
80
81
|
|
|
81
|
-
Map keys must be JSON-compatible values (`string`, `number`, `boolean`, `null`,
|
|
82
|
+
Map keys must be JSON-compatible values (`string`, `number`, `boolean`, `null`,
|
|
83
|
+
arrays, or objects). For string-keyed data, prefer `record`. For non-JSON or
|
|
84
|
+
identity-based keys, use `CRMap` with a stable `key` function.
|
|
82
85
|
|
|
83
86
|
## Roles and ACL
|
|
84
87
|
|
|
@@ -101,14 +104,17 @@ await doc.flush();
|
|
|
101
104
|
|
|
102
105
|
Before any schema/load/create, call `await Dacument.setActorInfo(...)` once per
|
|
103
106
|
process. The actor id must be a 256-bit base64url string (e.g.
|
|
104
|
-
`bytecodec`
|
|
105
|
-
|
|
106
|
-
actor's `publicKeyJwk` to its own ACL entry (if missing)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
107
|
+
`bytecodec` library's `generateNonce()`), and the actor key pair must be ES256 (P-256).
|
|
108
|
+
Updating actor info requires providing the current keys. On first merge, Dacument
|
|
109
|
+
auto-attaches the actor's `publicKeyJwk` to its own ACL entry (if missing) and
|
|
110
|
+
pins it for actor-signature verification. To rotate actor keys in-process, call
|
|
111
|
+
`Dacument.setActorInfo` again with the new keys plus
|
|
112
|
+
`currentPrivateKeyJwk`/`currentPublicKeyJwk`.
|
|
113
|
+
|
|
114
|
+
Write ops are role-signed; acks are actor-signed with the per-actor key set via
|
|
115
|
+
`setActorInfo`. Load with the highest role key you have; viewers load without a
|
|
116
|
+
key. Role keys are generated once at `create()`; role public keys are embedded
|
|
117
|
+
in the snapshot and never rotated.
|
|
112
118
|
|
|
113
119
|
## Networking and sync
|
|
114
120
|
|
|
@@ -144,17 +150,19 @@ const bob = await Dacument.load({
|
|
|
144
150
|
});
|
|
145
151
|
```
|
|
146
152
|
|
|
147
|
-
Snapshots do not include schema or schema ids; callers must supply the schema on load.
|
|
153
|
+
Snapshots do not include the schema or schema ids; callers must supply the schema on load.
|
|
148
154
|
|
|
149
155
|
## Events and values
|
|
150
156
|
|
|
151
|
-
- `doc.addEventListener("change", handler)` emits ops for network sync
|
|
157
|
+
- `doc.addEventListener("change", handler)` emits ops for network sync
|
|
158
|
+
(writer ops are role-signed; acks are actor-signed by non-revoked actors and
|
|
159
|
+
verified against ACL-pinned actor public keys).
|
|
152
160
|
- `doc.addEventListener("merge", handler)` emits `{ actor, target, method, data }`.
|
|
153
161
|
- `doc.addEventListener("error", handler)` emits signing/verification errors.
|
|
154
162
|
- `doc.addEventListener("revoked", handler)` fires when the current actor is revoked.
|
|
155
163
|
- `doc.addEventListener("reset", handler)` emits `{ oldDocId, newDocId, ts, by, reason }`.
|
|
156
164
|
- `doc.selfRevoke()` emits a signed ACL op that revokes the current actor.
|
|
157
|
-
- `await doc.accessReset({ reason })` creates a new
|
|
165
|
+
- `await doc.accessReset({ reason })` creates a new Dacument with fresh keys and emits a reset op.
|
|
158
166
|
- `doc.getResetState()` returns reset metadata (or `null`).
|
|
159
167
|
- `await doc.flush()` waits for pending signatures so all local ops are emitted.
|
|
160
168
|
- `doc.snapshot()` returns a loadable op log (`{ docId, roleKeys, ops }`).
|
|
@@ -164,14 +172,14 @@ Snapshots do not include schema or schema ids; callers must supply the schema on
|
|
|
164
172
|
## Access reset (key compromise response)
|
|
165
173
|
|
|
166
174
|
If an owner suspects role key compromise, call `accessReset()` to fork to a new
|
|
167
|
-
|
|
175
|
+
docId and revoke the old one:
|
|
168
176
|
|
|
169
177
|
```ts
|
|
170
178
|
const { newDoc, oldDocOps, newDocSnapshot, roleKeys } =
|
|
171
179
|
await doc.accessReset({ reason: "suspected compromise" });
|
|
172
180
|
```
|
|
173
181
|
|
|
174
|
-
`accessReset()` materializes the current state into a new
|
|
182
|
+
`accessReset()` materializes the current state into a new Dacument with fresh
|
|
175
183
|
role keys, emits a signed `reset` op for the old doc, and returns the new
|
|
176
184
|
snapshot + keys. The reset is stored as a CRDT op so all replicas converge. Once
|
|
177
185
|
reset, the old doc rejects any ops after the reset stamp and throws on writes:
|
|
@@ -192,17 +200,17 @@ from the ACL at the op stamp and returns a summary plus failures.
|
|
|
192
200
|
|
|
193
201
|
Dacument tracks per-actor `ack` ops and compacts tombstones once all non-revoked
|
|
194
202
|
actors (including viewers) have acknowledged a given HLC. Acks are emitted
|
|
195
|
-
automatically after merges that apply new non-ack ops. Acks are
|
|
196
|
-
|
|
203
|
+
automatically after merges that apply new non-ack ops. Acks are ES256 actor-signed
|
|
204
|
+
by non-revoked actors and verified against ACL-pinned actor public keys.
|
|
197
205
|
If any non-revoked actor is offline and never acks, tombstones are kept.
|
|
198
206
|
|
|
199
207
|
## Guarantees
|
|
200
208
|
|
|
201
209
|
- Schema enforcement is strict; unknown fields are rejected.
|
|
202
|
-
- Ops are accepted only if the CRDT patch is valid and the signature
|
|
203
|
-
|
|
210
|
+
- Ops are accepted only if the CRDT patch is valid and the role signature
|
|
211
|
+
verifies; acks require a valid actor signature.
|
|
204
212
|
- Role checks are applied at the op stamp time (HLC).
|
|
205
|
-
- IDs are base64url nonces from `bytecodec`
|
|
213
|
+
- IDs are base64url nonces from `bytecodec` library's `generateNonce()` (32 random bytes).
|
|
206
214
|
- Private keys are returned by `create()` and never stored by Dacument.
|
|
207
215
|
- Snapshots may include ops that are rejected; invalid ops are ignored on load.
|
|
208
216
|
|
|
@@ -211,8 +219,9 @@ replicas. Dacument does not provide transport; use `change` events to wire it up
|
|
|
211
219
|
|
|
212
220
|
## Possible threats and how to handle
|
|
213
221
|
|
|
214
|
-
-
|
|
215
|
-
|
|
222
|
+
- Role key compromise: role keys cannot be rotated; use `accessReset` or
|
|
223
|
+
snapshot into a new Dacument with fresh keys. Actor keys can be rotated by
|
|
224
|
+
providing the current keys.
|
|
216
225
|
- Shared role keys: attribution is role-level, not per-user; treat roles as
|
|
217
226
|
trust groups and log merge events if you need auditing.
|
|
218
227
|
- Insider DoS/flooding: rate-limit ops, cap payload sizes, and monitor merge
|
package/dist/CRArray/class.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { DAGNode } from "../DAGNode/class.js";
|
|
|
2
2
|
export declare class CRArray<T> {
|
|
3
3
|
private readonly nodes;
|
|
4
4
|
private readonly nodeById;
|
|
5
|
+
private aliveCount;
|
|
6
|
+
private lastAliveIndex;
|
|
5
7
|
private readonly listeners;
|
|
6
8
|
constructor(snapshot?: readonly DAGNode<T>[]);
|
|
7
9
|
get length(): number;
|
|
@@ -29,6 +31,7 @@ export declare class CRArray<T> {
|
|
|
29
31
|
sort(compareFn?: (a: DAGNode<T>, b: DAGNode<T>) => number): this;
|
|
30
32
|
private alive;
|
|
31
33
|
private lastAliveId;
|
|
34
|
+
private recomputeLastAliveIndex;
|
|
32
35
|
private afterIdForAliveInsertAt;
|
|
33
36
|
private emit;
|
|
34
37
|
}
|
package/dist/CRArray/class.js
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
import { DAGNode } from "../DAGNode/class.js";
|
|
2
2
|
const ROOT = [];
|
|
3
3
|
function afterKey(after) {
|
|
4
|
-
return after.join(",");
|
|
4
|
+
return after.length < 2 ? (after[0] ?? "") : after.join(",");
|
|
5
|
+
}
|
|
6
|
+
function isIndexKey(value) {
|
|
7
|
+
const length = value.length;
|
|
8
|
+
if (length === 0)
|
|
9
|
+
return false;
|
|
10
|
+
const first = value.charCodeAt(0);
|
|
11
|
+
if (first < 48 || first > 57)
|
|
12
|
+
return false;
|
|
13
|
+
if (length > 1 && first === 48)
|
|
14
|
+
return false;
|
|
15
|
+
for (let i = 1; i < length; i++) {
|
|
16
|
+
const code = value.charCodeAt(i);
|
|
17
|
+
if (code < 48 || code > 57)
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
return true;
|
|
5
21
|
}
|
|
6
22
|
export class CRArray {
|
|
7
23
|
nodes = [];
|
|
8
24
|
nodeById = new Map();
|
|
25
|
+
aliveCount = 0;
|
|
26
|
+
lastAliveIndex = -1;
|
|
9
27
|
listeners = new Set();
|
|
10
28
|
constructor(snapshot) {
|
|
11
29
|
if (snapshot) {
|
|
@@ -14,6 +32,8 @@ export class CRArray {
|
|
|
14
32
|
continue;
|
|
15
33
|
this.nodes.push(node);
|
|
16
34
|
this.nodeById.set(node.id, node);
|
|
35
|
+
if (!node.deleted)
|
|
36
|
+
this.aliveCount++;
|
|
17
37
|
}
|
|
18
38
|
}
|
|
19
39
|
this.sort();
|
|
@@ -22,13 +42,13 @@ export class CRArray {
|
|
|
22
42
|
if (typeof property === "string") {
|
|
23
43
|
if (property === "length")
|
|
24
44
|
return target.length;
|
|
25
|
-
if (
|
|
45
|
+
if (isIndexKey(property))
|
|
26
46
|
return target.at(Number(property));
|
|
27
47
|
}
|
|
28
48
|
return Reflect.get(target, property, receiver);
|
|
29
49
|
},
|
|
30
50
|
set: (target, property, value, receiver) => {
|
|
31
|
-
if (typeof property === "string" &&
|
|
51
|
+
if (typeof property === "string" && isIndexKey(property)) {
|
|
32
52
|
const index = Number(property);
|
|
33
53
|
target.setAt(index, value);
|
|
34
54
|
return true;
|
|
@@ -36,7 +56,7 @@ export class CRArray {
|
|
|
36
56
|
return Reflect.set(target, property, value, receiver);
|
|
37
57
|
},
|
|
38
58
|
has: (target, property) => {
|
|
39
|
-
if (typeof property === "string" &&
|
|
59
|
+
if (typeof property === "string" && isIndexKey(property)) {
|
|
40
60
|
return Number(property) < target.length;
|
|
41
61
|
}
|
|
42
62
|
return Reflect.has(target, property);
|
|
@@ -49,7 +69,7 @@ export class CRArray {
|
|
|
49
69
|
return keys;
|
|
50
70
|
},
|
|
51
71
|
getOwnPropertyDescriptor: (target, property) => {
|
|
52
|
-
if (typeof property === "string" &&
|
|
72
|
+
if (typeof property === "string" && isIndexKey(property)) {
|
|
53
73
|
if (Number(property) >= target.length)
|
|
54
74
|
return undefined;
|
|
55
75
|
return {
|
|
@@ -64,11 +84,7 @@ export class CRArray {
|
|
|
64
84
|
});
|
|
65
85
|
}
|
|
66
86
|
get length() {
|
|
67
|
-
|
|
68
|
-
for (const node of this.nodes)
|
|
69
|
-
if (!node.deleted)
|
|
70
|
-
count++;
|
|
71
|
-
return count;
|
|
87
|
+
return this.aliveCount;
|
|
72
88
|
}
|
|
73
89
|
// --- public API ---
|
|
74
90
|
onChange(listener) {
|
|
@@ -79,9 +95,8 @@ export class CRArray {
|
|
|
79
95
|
return this.nodes.slice();
|
|
80
96
|
}
|
|
81
97
|
push(...items) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
: ROOT;
|
|
98
|
+
const lastAliveId = this.lastAliveId();
|
|
99
|
+
let after = lastAliveId ? [lastAliveId] : ROOT;
|
|
85
100
|
const changed = [];
|
|
86
101
|
for (const item of items) {
|
|
87
102
|
const node = new DAGNode({ value: item, after });
|
|
@@ -89,6 +104,7 @@ export class CRArray {
|
|
|
89
104
|
this.nodeById.set(node.id, node);
|
|
90
105
|
changed.push(node);
|
|
91
106
|
after = [node.id];
|
|
107
|
+
this.aliveCount++;
|
|
92
108
|
}
|
|
93
109
|
this.sort();
|
|
94
110
|
this.emit(changed);
|
|
@@ -103,19 +119,26 @@ export class CRArray {
|
|
|
103
119
|
this.nodeById.set(node.id, node);
|
|
104
120
|
changed.push(node);
|
|
105
121
|
after = [node.id];
|
|
122
|
+
this.aliveCount++;
|
|
106
123
|
}
|
|
107
124
|
this.sort();
|
|
108
125
|
this.emit(changed);
|
|
109
126
|
return this.length;
|
|
110
127
|
}
|
|
111
128
|
pop() {
|
|
112
|
-
for (let index = this.
|
|
129
|
+
for (let index = this.lastAliveIndex; index >= 0; index--) {
|
|
113
130
|
const node = this.nodes[index];
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
131
|
+
if (node.deleted)
|
|
132
|
+
continue;
|
|
133
|
+
node.deleted = true;
|
|
134
|
+
this.aliveCount--;
|
|
135
|
+
this.lastAliveIndex = index - 1;
|
|
136
|
+
while (this.lastAliveIndex >= 0 &&
|
|
137
|
+
this.nodes[this.lastAliveIndex].deleted) {
|
|
138
|
+
this.lastAliveIndex--;
|
|
118
139
|
}
|
|
140
|
+
this.emit([node]);
|
|
141
|
+
return node.value;
|
|
119
142
|
}
|
|
120
143
|
return undefined;
|
|
121
144
|
}
|
|
@@ -123,6 +146,9 @@ export class CRArray {
|
|
|
123
146
|
for (const node of this.nodes) {
|
|
124
147
|
if (!node.deleted) {
|
|
125
148
|
node.deleted = true;
|
|
149
|
+
this.aliveCount--;
|
|
150
|
+
if (this.aliveCount === 0)
|
|
151
|
+
this.lastAliveIndex = -1;
|
|
126
152
|
this.emit([node]);
|
|
127
153
|
return node.value;
|
|
128
154
|
}
|
|
@@ -130,7 +156,23 @@ export class CRArray {
|
|
|
130
156
|
return undefined;
|
|
131
157
|
}
|
|
132
158
|
at(index) {
|
|
133
|
-
|
|
159
|
+
const length = this.aliveCount;
|
|
160
|
+
let target = Math.trunc(Number(index));
|
|
161
|
+
if (Number.isNaN(target))
|
|
162
|
+
target = 0;
|
|
163
|
+
if (target < 0)
|
|
164
|
+
target = length + target;
|
|
165
|
+
if (target < 0 || target >= length)
|
|
166
|
+
return undefined;
|
|
167
|
+
let aliveIndex = 0;
|
|
168
|
+
for (const node of this.nodes) {
|
|
169
|
+
if (node.deleted)
|
|
170
|
+
continue;
|
|
171
|
+
if (aliveIndex === target)
|
|
172
|
+
return node.value;
|
|
173
|
+
aliveIndex++;
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
134
176
|
}
|
|
135
177
|
setAt(index, value) {
|
|
136
178
|
if (!Number.isInteger(index))
|
|
@@ -144,6 +186,7 @@ export class CRArray {
|
|
|
144
186
|
continue;
|
|
145
187
|
if (aliveIndex === index) {
|
|
146
188
|
node.deleted = true;
|
|
189
|
+
this.aliveCount--;
|
|
147
190
|
deletedNode = node;
|
|
148
191
|
break;
|
|
149
192
|
}
|
|
@@ -155,19 +198,70 @@ export class CRArray {
|
|
|
155
198
|
const newNode = new DAGNode({ value, after });
|
|
156
199
|
this.nodes.push(newNode);
|
|
157
200
|
this.nodeById.set(newNode.id, newNode);
|
|
201
|
+
this.aliveCount++;
|
|
158
202
|
this.sort();
|
|
159
203
|
const changed = deletedNode ? [deletedNode, newNode] : [newNode];
|
|
160
204
|
this.emit(changed);
|
|
161
205
|
return this;
|
|
162
206
|
}
|
|
163
207
|
slice(start, end) {
|
|
164
|
-
|
|
208
|
+
const length = this.aliveCount;
|
|
209
|
+
let from = start === undefined ? 0 : Math.trunc(Number(start));
|
|
210
|
+
if (Number.isNaN(from))
|
|
211
|
+
from = 0;
|
|
212
|
+
if (from < 0)
|
|
213
|
+
from = Math.max(length + from, 0);
|
|
214
|
+
else if (from > length)
|
|
215
|
+
from = length;
|
|
216
|
+
let to = end === undefined ? length : Math.trunc(Number(end));
|
|
217
|
+
if (Number.isNaN(to))
|
|
218
|
+
to = 0;
|
|
219
|
+
if (to < 0)
|
|
220
|
+
to = Math.max(length + to, 0);
|
|
221
|
+
else if (to > length)
|
|
222
|
+
to = length;
|
|
223
|
+
if (to <= from)
|
|
224
|
+
return [];
|
|
225
|
+
const resultLength = to - from;
|
|
226
|
+
const result = new Array(resultLength);
|
|
227
|
+
let aliveIndex = 0;
|
|
228
|
+
let resultIndex = 0;
|
|
229
|
+
for (const node of this.nodes) {
|
|
230
|
+
if (node.deleted)
|
|
231
|
+
continue;
|
|
232
|
+
if (aliveIndex >= to)
|
|
233
|
+
break;
|
|
234
|
+
if (aliveIndex >= from)
|
|
235
|
+
result[resultIndex++] = node.value;
|
|
236
|
+
aliveIndex++;
|
|
237
|
+
}
|
|
238
|
+
if (resultIndex !== resultLength)
|
|
239
|
+
result.length = resultIndex;
|
|
240
|
+
return result;
|
|
165
241
|
}
|
|
166
242
|
includes(value) {
|
|
167
|
-
|
|
243
|
+
const valueIsNaN = value !== value;
|
|
244
|
+
for (const node of this.nodes) {
|
|
245
|
+
if (node.deleted)
|
|
246
|
+
continue;
|
|
247
|
+
const nodeValue = node.value;
|
|
248
|
+
if (nodeValue === value)
|
|
249
|
+
return true;
|
|
250
|
+
if (valueIsNaN && nodeValue !== nodeValue)
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
return false;
|
|
168
254
|
}
|
|
169
255
|
indexOf(value) {
|
|
170
|
-
|
|
256
|
+
let aliveIndex = 0;
|
|
257
|
+
for (const node of this.nodes) {
|
|
258
|
+
if (node.deleted)
|
|
259
|
+
continue;
|
|
260
|
+
if (node.value === value)
|
|
261
|
+
return aliveIndex;
|
|
262
|
+
aliveIndex++;
|
|
263
|
+
}
|
|
264
|
+
return -1;
|
|
171
265
|
}
|
|
172
266
|
find(predicate, thisArg) {
|
|
173
267
|
return this.alive().find(predicate, thisArg);
|
|
@@ -207,10 +301,13 @@ export class CRArray {
|
|
|
207
301
|
const clone = structuredClone(remote);
|
|
208
302
|
this.nodes.push(clone);
|
|
209
303
|
this.nodeById.set(clone.id, clone);
|
|
304
|
+
if (!clone.deleted)
|
|
305
|
+
this.aliveCount++;
|
|
210
306
|
changed.push(clone);
|
|
211
307
|
}
|
|
212
308
|
else if (!local.deleted && remote.deleted) {
|
|
213
309
|
local.deleted = true;
|
|
310
|
+
this.aliveCount--;
|
|
214
311
|
changed.push(local);
|
|
215
312
|
}
|
|
216
313
|
}
|
|
@@ -223,6 +320,7 @@ export class CRArray {
|
|
|
223
320
|
sort(compareFn) {
|
|
224
321
|
if (compareFn) {
|
|
225
322
|
this.nodes.sort(compareFn);
|
|
323
|
+
this.recomputeLastAliveIndex();
|
|
226
324
|
return this;
|
|
227
325
|
}
|
|
228
326
|
this.nodes.sort((left, right) => {
|
|
@@ -240,23 +338,42 @@ export class CRArray {
|
|
|
240
338
|
return left.id > right.id ? -1 : 1;
|
|
241
339
|
return left.id < right.id ? -1 : 1;
|
|
242
340
|
});
|
|
341
|
+
this.recomputeLastAliveIndex();
|
|
243
342
|
return this;
|
|
244
343
|
}
|
|
245
344
|
// --- internals ---
|
|
246
345
|
alive() {
|
|
247
|
-
const values =
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
346
|
+
const values = new Array(this.aliveCount);
|
|
347
|
+
let aliveIndex = 0;
|
|
348
|
+
for (const node of this.nodes) {
|
|
349
|
+
if (node.deleted)
|
|
350
|
+
continue;
|
|
351
|
+
values[aliveIndex++] = node.value;
|
|
352
|
+
}
|
|
353
|
+
if (aliveIndex !== values.length)
|
|
354
|
+
values.length = aliveIndex;
|
|
251
355
|
return values;
|
|
252
356
|
}
|
|
253
357
|
lastAliveId() {
|
|
358
|
+
if (this.lastAliveIndex < 0)
|
|
359
|
+
return null;
|
|
360
|
+
const node = this.nodes[this.lastAliveIndex];
|
|
361
|
+
if (!node || node.deleted) {
|
|
362
|
+
this.recomputeLastAliveIndex();
|
|
363
|
+
if (this.lastAliveIndex < 0)
|
|
364
|
+
return null;
|
|
365
|
+
return this.nodes[this.lastAliveIndex].id;
|
|
366
|
+
}
|
|
367
|
+
return node.id;
|
|
368
|
+
}
|
|
369
|
+
recomputeLastAliveIndex() {
|
|
254
370
|
for (let index = this.nodes.length - 1; index >= 0; index--) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
return
|
|
371
|
+
if (!this.nodes[index].deleted) {
|
|
372
|
+
this.lastAliveIndex = index;
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
258
375
|
}
|
|
259
|
-
|
|
376
|
+
this.lastAliveIndex = -1;
|
|
260
377
|
}
|
|
261
378
|
afterIdForAliveInsertAt(index) {
|
|
262
379
|
if (index === 0)
|
package/dist/CRText/class.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { DAGNode } from "../DAGNode/class.js";
|
|
|
2
2
|
export declare class CRText<CharT extends string = string> {
|
|
3
3
|
private readonly nodes;
|
|
4
4
|
private readonly nodeById;
|
|
5
|
+
private aliveCount;
|
|
6
|
+
private lastAliveIndex;
|
|
5
7
|
private readonly listeners;
|
|
6
8
|
constructor(snapshot?: readonly DAGNode<CharT>[]);
|
|
7
9
|
get length(): number;
|
|
@@ -13,7 +15,8 @@ export declare class CRText<CharT extends string = string> {
|
|
|
13
15
|
deleteAt(index: number): CharT | undefined;
|
|
14
16
|
merge(remoteSnapshot: DAGNode<CharT>[] | DAGNode<CharT>): DAGNode<CharT>[];
|
|
15
17
|
sort(compareFn?: (a: DAGNode<CharT>, b: DAGNode<CharT>) => number): this;
|
|
16
|
-
private alive;
|
|
17
18
|
private afterIdForAliveInsertAt;
|
|
19
|
+
private lastAliveId;
|
|
20
|
+
private recomputeLastAliveIndex;
|
|
18
21
|
private emit;
|
|
19
22
|
}
|
package/dist/CRText/class.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { DAGNode } from "../DAGNode/class.js";
|
|
2
2
|
const ROOT = [];
|
|
3
3
|
function afterKey(after) {
|
|
4
|
-
return after.join(",");
|
|
4
|
+
return after.length < 2 ? (after[0] ?? "") : after.join(",");
|
|
5
5
|
}
|
|
6
6
|
export class CRText {
|
|
7
7
|
nodes = [];
|
|
8
8
|
nodeById = new Map();
|
|
9
|
+
aliveCount = 0;
|
|
10
|
+
lastAliveIndex = -1;
|
|
9
11
|
listeners = new Set();
|
|
10
12
|
constructor(snapshot) {
|
|
11
13
|
if (snapshot) {
|
|
@@ -14,16 +16,14 @@ export class CRText {
|
|
|
14
16
|
continue;
|
|
15
17
|
this.nodes.push(node);
|
|
16
18
|
this.nodeById.set(node.id, node);
|
|
19
|
+
if (!node.deleted)
|
|
20
|
+
this.aliveCount++;
|
|
17
21
|
}
|
|
18
22
|
}
|
|
19
23
|
this.sort();
|
|
20
24
|
}
|
|
21
25
|
get length() {
|
|
22
|
-
|
|
23
|
-
for (const node of this.nodes)
|
|
24
|
-
if (!node.deleted)
|
|
25
|
-
count++;
|
|
26
|
-
return count;
|
|
26
|
+
return this.aliveCount;
|
|
27
27
|
}
|
|
28
28
|
// --- public API ---
|
|
29
29
|
onChange(listener) {
|
|
@@ -41,19 +41,41 @@ export class CRText {
|
|
|
41
41
|
return output;
|
|
42
42
|
}
|
|
43
43
|
at(index) {
|
|
44
|
-
|
|
44
|
+
let target = Math.trunc(Number(index));
|
|
45
|
+
if (Number.isNaN(target))
|
|
46
|
+
target = 0;
|
|
47
|
+
if (target < 0)
|
|
48
|
+
target = this.length + target;
|
|
49
|
+
if (target < 0)
|
|
50
|
+
return undefined;
|
|
51
|
+
let aliveIndex = 0;
|
|
52
|
+
for (const node of this.nodes) {
|
|
53
|
+
if (node.deleted)
|
|
54
|
+
continue;
|
|
55
|
+
if (aliveIndex === target)
|
|
56
|
+
return node.value;
|
|
57
|
+
aliveIndex++;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
45
60
|
}
|
|
46
61
|
insertAt(index, char) {
|
|
47
62
|
if (!Number.isInteger(index))
|
|
48
63
|
throw new TypeError("CRText.insertAt: index must be an integer");
|
|
49
64
|
if (index < 0)
|
|
50
65
|
throw new RangeError("CRText.insertAt: negative index not supported");
|
|
51
|
-
|
|
66
|
+
const length = this.aliveCount;
|
|
67
|
+
if (index > length)
|
|
52
68
|
throw new RangeError("CRText.insertAt: index out of bounds");
|
|
53
|
-
const
|
|
69
|
+
const lastAliveId = this.lastAliveId();
|
|
70
|
+
const after = index === length
|
|
71
|
+
? lastAliveId
|
|
72
|
+
? [lastAliveId]
|
|
73
|
+
: ROOT
|
|
74
|
+
: this.afterIdForAliveInsertAt(index);
|
|
54
75
|
const node = new DAGNode({ value: char, after });
|
|
55
76
|
this.nodes.push(node);
|
|
56
77
|
this.nodeById.set(node.id, node);
|
|
78
|
+
this.aliveCount++;
|
|
57
79
|
this.sort();
|
|
58
80
|
this.emit([node]);
|
|
59
81
|
return this;
|
|
@@ -64,11 +86,23 @@ export class CRText {
|
|
|
64
86
|
if (index < 0)
|
|
65
87
|
throw new RangeError("CRText.deleteAt: negative index not supported");
|
|
66
88
|
let aliveIndex = 0;
|
|
67
|
-
for (
|
|
89
|
+
for (let idx = 0; idx < this.nodes.length; idx++) {
|
|
90
|
+
const node = this.nodes[idx];
|
|
68
91
|
if (node.deleted)
|
|
69
92
|
continue;
|
|
70
93
|
if (aliveIndex === index) {
|
|
71
94
|
node.deleted = true;
|
|
95
|
+
this.aliveCount--;
|
|
96
|
+
if (this.aliveCount === 0) {
|
|
97
|
+
this.lastAliveIndex = -1;
|
|
98
|
+
}
|
|
99
|
+
else if (idx === this.lastAliveIndex) {
|
|
100
|
+
this.lastAliveIndex = idx - 1;
|
|
101
|
+
while (this.lastAliveIndex >= 0 &&
|
|
102
|
+
this.nodes[this.lastAliveIndex].deleted) {
|
|
103
|
+
this.lastAliveIndex--;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
72
106
|
this.emit([node]);
|
|
73
107
|
return node.value;
|
|
74
108
|
}
|
|
@@ -87,10 +121,13 @@ export class CRText {
|
|
|
87
121
|
const clone = structuredClone(remote);
|
|
88
122
|
this.nodes.push(clone);
|
|
89
123
|
this.nodeById.set(clone.id, clone);
|
|
124
|
+
if (!clone.deleted)
|
|
125
|
+
this.aliveCount++;
|
|
90
126
|
changed.push(clone);
|
|
91
127
|
}
|
|
92
128
|
else if (!local.deleted && remote.deleted) {
|
|
93
129
|
local.deleted = true;
|
|
130
|
+
this.aliveCount--;
|
|
94
131
|
changed.push(local);
|
|
95
132
|
}
|
|
96
133
|
}
|
|
@@ -103,6 +140,7 @@ export class CRText {
|
|
|
103
140
|
sort(compareFn) {
|
|
104
141
|
if (compareFn) {
|
|
105
142
|
this.nodes.sort(compareFn);
|
|
143
|
+
this.recomputeLastAliveIndex();
|
|
106
144
|
return this;
|
|
107
145
|
}
|
|
108
146
|
this.nodes.sort((left, right) => {
|
|
@@ -120,16 +158,10 @@ export class CRText {
|
|
|
120
158
|
return left.id > right.id ? -1 : 1;
|
|
121
159
|
return left.id < right.id ? -1 : 1;
|
|
122
160
|
});
|
|
161
|
+
this.recomputeLastAliveIndex();
|
|
123
162
|
return this;
|
|
124
163
|
}
|
|
125
164
|
// --- internals ---
|
|
126
|
-
alive() {
|
|
127
|
-
const values = [];
|
|
128
|
-
for (const node of this.nodes)
|
|
129
|
-
if (!node.deleted)
|
|
130
|
-
values.push(node.value);
|
|
131
|
-
return values;
|
|
132
|
-
}
|
|
133
165
|
afterIdForAliveInsertAt(index) {
|
|
134
166
|
if (index === 0)
|
|
135
167
|
return ROOT;
|
|
@@ -147,6 +179,27 @@ export class CRText {
|
|
|
147
179
|
return [previousAliveId];
|
|
148
180
|
return ROOT;
|
|
149
181
|
}
|
|
182
|
+
lastAliveId() {
|
|
183
|
+
if (this.lastAliveIndex < 0)
|
|
184
|
+
return null;
|
|
185
|
+
const node = this.nodes[this.lastAliveIndex];
|
|
186
|
+
if (!node || node.deleted) {
|
|
187
|
+
this.recomputeLastAliveIndex();
|
|
188
|
+
if (this.lastAliveIndex < 0)
|
|
189
|
+
return null;
|
|
190
|
+
return this.nodes[this.lastAliveIndex].id;
|
|
191
|
+
}
|
|
192
|
+
return node.id;
|
|
193
|
+
}
|
|
194
|
+
recomputeLastAliveIndex() {
|
|
195
|
+
for (let index = this.nodes.length - 1; index >= 0; index--) {
|
|
196
|
+
if (!this.nodes[index].deleted) {
|
|
197
|
+
this.lastAliveIndex = index;
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
this.lastAliveIndex = -1;
|
|
202
|
+
}
|
|
150
203
|
emit(nodes) {
|
|
151
204
|
if (nodes.length === 0)
|
|
152
205
|
return;
|
package/dist/Dacument/class.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AclAssignment, type
|
|
1
|
+
import { type AclAssignment, type ActorInfoUpdate, type DacumentEventMap, type DocFieldAccess, type DocSnapshot, type RoleKeys, type RolePublicKeys, type SchemaDefinition, type SchemaId, type SignedOp, type VerificationResult, type VerifyActorIntegrityOptions, type Role, array, map, record, register, set, text } from "./types.js";
|
|
2
2
|
type ResetStateInfo = {
|
|
3
3
|
ts: AclAssignment["stamp"];
|
|
4
4
|
by: string;
|
|
@@ -8,11 +8,13 @@ type ResetStateInfo = {
|
|
|
8
8
|
export declare class Dacument<S extends SchemaDefinition> {
|
|
9
9
|
private static actorInfo?;
|
|
10
10
|
private static actorSigner?;
|
|
11
|
-
static
|
|
11
|
+
private static actorInfoPrevious?;
|
|
12
|
+
static setActorInfo(info: ActorInfoUpdate): Promise<void>;
|
|
12
13
|
private static requireActorInfo;
|
|
13
14
|
private static requireActorSigner;
|
|
14
15
|
private static signActorToken;
|
|
15
16
|
private static isValidActorId;
|
|
17
|
+
private static actorInfoForPublicKey;
|
|
16
18
|
private static assertActorKeyJwk;
|
|
17
19
|
private static assertActorPrivateKey;
|
|
18
20
|
private static assertActorPublicKey;
|
|
@@ -23,7 +25,7 @@ export declare class Dacument<S extends SchemaDefinition> {
|
|
|
23
25
|
static set: typeof set;
|
|
24
26
|
static map: typeof map;
|
|
25
27
|
static record: typeof record;
|
|
26
|
-
static computeSchemaId
|
|
28
|
+
private static computeSchemaId;
|
|
27
29
|
static create<Schema extends SchemaDefinition>(params: {
|
|
28
30
|
schema: Schema;
|
|
29
31
|
docId?: string;
|
|
@@ -106,6 +108,7 @@ export declare class Dacument<S extends SchemaDefinition> {
|
|
|
106
108
|
}>;
|
|
107
109
|
private rebuildFromVerified;
|
|
108
110
|
private maybePublishActorKey;
|
|
111
|
+
private actorSignatureKey;
|
|
109
112
|
private ack;
|
|
110
113
|
private scheduleAck;
|
|
111
114
|
private computeGcBarrier;
|
package/dist/Dacument/class.js
CHANGED
|
@@ -9,7 +9,7 @@ import { CRSet } from "../CRSet/class.js";
|
|
|
9
9
|
import { CRText } from "../CRText/class.js";
|
|
10
10
|
import { AclLog } from "./acl.js";
|
|
11
11
|
import { HLC, compareHLC } from "./clock.js";
|
|
12
|
-
import { decodeToken,
|
|
12
|
+
import { decodeToken, signToken, validateActorKeyPair, verifyDetached, verifyToken, } from "./crypto.js";
|
|
13
13
|
import { array, map, record, register, set, text, isJsValue, isValueOfType, schemaIdInput, } from "./types.js";
|
|
14
14
|
const TOKEN_TYP = "DACOP";
|
|
15
15
|
function nowSeconds() {
|
|
@@ -169,15 +169,34 @@ function toPublicRoleKeys(roleKeys) {
|
|
|
169
169
|
export class Dacument {
|
|
170
170
|
static actorInfo;
|
|
171
171
|
static actorSigner;
|
|
172
|
+
static actorInfoPrevious;
|
|
172
173
|
static async setActorInfo(info) {
|
|
173
|
-
|
|
174
|
-
|
|
174
|
+
const existing = Dacument.actorInfo;
|
|
175
|
+
if (existing) {
|
|
176
|
+
if (info.id !== existing.id)
|
|
177
|
+
throw new Error("Dacument.setActorInfo: actor id already set");
|
|
178
|
+
const samePrivate = jwkEquals(info.privateKeyJwk, existing.privateKeyJwk);
|
|
179
|
+
const samePublic = jwkEquals(info.publicKeyJwk, existing.publicKeyJwk);
|
|
180
|
+
if (samePrivate && samePublic)
|
|
181
|
+
return;
|
|
182
|
+
if (!info.currentPrivateKeyJwk || !info.currentPublicKeyJwk)
|
|
183
|
+
throw new Error("Dacument.setActorInfo: current keys required to update actor info");
|
|
184
|
+
if (!jwkEquals(info.currentPrivateKeyJwk, existing.privateKeyJwk) ||
|
|
185
|
+
!jwkEquals(info.currentPublicKeyJwk, existing.publicKeyJwk))
|
|
186
|
+
throw new Error("Dacument.setActorInfo: current keys do not match existing actor info");
|
|
187
|
+
}
|
|
175
188
|
if (!Dacument.isValidActorId(info.id))
|
|
176
189
|
throw new Error("Dacument.setActorInfo: id must be 256-bit base64url");
|
|
177
190
|
Dacument.assertActorPrivateKey(info.privateKeyJwk);
|
|
178
191
|
Dacument.assertActorPublicKey(info.publicKeyJwk);
|
|
179
192
|
await validateActorKeyPair(info.privateKeyJwk, info.publicKeyJwk);
|
|
180
|
-
|
|
193
|
+
if (existing)
|
|
194
|
+
Dacument.actorInfoPrevious = existing;
|
|
195
|
+
Dacument.actorInfo = {
|
|
196
|
+
id: info.id,
|
|
197
|
+
privateKeyJwk: info.privateKeyJwk,
|
|
198
|
+
publicKeyJwk: info.publicKeyJwk,
|
|
199
|
+
};
|
|
181
200
|
Dacument.actorSigner = new SigningAgent(info.privateKeyJwk);
|
|
182
201
|
}
|
|
183
202
|
static requireActorInfo() {
|
|
@@ -190,14 +209,31 @@ export class Dacument {
|
|
|
190
209
|
throw new Error("Dacument: actor info not set; call Dacument.setActorInfo()");
|
|
191
210
|
return Dacument.actorSigner;
|
|
192
211
|
}
|
|
193
|
-
static async signActorToken(token) {
|
|
194
|
-
const
|
|
212
|
+
static async signActorToken(token, privateKeyJwk) {
|
|
213
|
+
const current = Dacument.actorInfo;
|
|
214
|
+
const signer = privateKeyJwk &&
|
|
215
|
+
current &&
|
|
216
|
+
jwkEquals(privateKeyJwk, current.privateKeyJwk)
|
|
217
|
+
? Dacument.requireActorSigner()
|
|
218
|
+
: privateKeyJwk
|
|
219
|
+
? new SigningAgent(privateKeyJwk)
|
|
220
|
+
: Dacument.requireActorSigner();
|
|
195
221
|
const signature = await signer.sign(Bytes.fromString(token));
|
|
196
222
|
return Bytes.toBase64UrlString(signature);
|
|
197
223
|
}
|
|
198
224
|
static isValidActorId(actorId) {
|
|
199
225
|
return isValidNonceId(actorId);
|
|
200
226
|
}
|
|
227
|
+
static actorInfoForPublicKey(publicKeyJwk) {
|
|
228
|
+
if (!publicKeyJwk)
|
|
229
|
+
return null;
|
|
230
|
+
if (Dacument.actorInfo && jwkEquals(publicKeyJwk, Dacument.actorInfo.publicKeyJwk))
|
|
231
|
+
return Dacument.actorInfo;
|
|
232
|
+
if (Dacument.actorInfoPrevious &&
|
|
233
|
+
jwkEquals(publicKeyJwk, Dacument.actorInfoPrevious.publicKeyJwk))
|
|
234
|
+
return Dacument.actorInfoPrevious;
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
201
237
|
static assertActorKeyJwk(jwk, label) {
|
|
202
238
|
if (!jwk || typeof jwk !== "object")
|
|
203
239
|
throw new Error(`Dacument.setActorInfo: ${label} must be a JWK object`);
|
|
@@ -805,33 +841,32 @@ export class Dacument {
|
|
|
805
841
|
rejected++;
|
|
806
842
|
continue;
|
|
807
843
|
}
|
|
808
|
-
|
|
809
|
-
payload.kind === "ack" &&
|
|
810
|
-
decoded.header.typ === TOKEN_TYP;
|
|
811
|
-
if (decoded.header.alg === "none" && !isUnsignedAck) {
|
|
812
|
-
rejected++;
|
|
813
|
-
continue;
|
|
814
|
-
}
|
|
815
|
-
if (payload.kind === "ack" && decoded.header.alg !== "none") {
|
|
844
|
+
if (decoded.header.alg === "none") {
|
|
816
845
|
rejected++;
|
|
817
846
|
continue;
|
|
818
847
|
}
|
|
819
848
|
let stored = this.verifiedOps.get(token);
|
|
820
849
|
if (!stored) {
|
|
821
|
-
|
|
822
|
-
|
|
850
|
+
const signerKind = parseSignerKind(decoded.header.kid, payload.iss);
|
|
851
|
+
if (!signerKind) {
|
|
852
|
+
rejected++;
|
|
853
|
+
continue;
|
|
823
854
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
continue;
|
|
829
|
-
}
|
|
830
|
-
if (signerKind === "actor") {
|
|
831
|
-
if (payload.kind !== "acl.set") {
|
|
855
|
+
if (signerKind === "actor") {
|
|
856
|
+
if (payload.kind === "ack") {
|
|
857
|
+
const publicKey = this.aclLog.publicKeyAt(payload.iss, payload.stamp);
|
|
858
|
+
if (!publicKey) {
|
|
832
859
|
rejected++;
|
|
833
860
|
continue;
|
|
834
861
|
}
|
|
862
|
+
const verified = await verifyToken(publicKey, token, TOKEN_TYP);
|
|
863
|
+
if (!verified) {
|
|
864
|
+
rejected++;
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
stored = { payload, signerRole: "actor" };
|
|
868
|
+
}
|
|
869
|
+
else if (payload.kind === "acl.set") {
|
|
835
870
|
const patch = isAclPatch(payload.patch) ? payload.patch : null;
|
|
836
871
|
if (!patch || patch.target !== payload.iss) {
|
|
837
872
|
rejected++;
|
|
@@ -844,12 +879,6 @@ export class Dacument {
|
|
|
844
879
|
continue;
|
|
845
880
|
}
|
|
846
881
|
const existingKey = this.aclLog.publicKeyAt(payload.iss, payload.stamp);
|
|
847
|
-
if (existingKey &&
|
|
848
|
-
patch.publicKeyJwk &&
|
|
849
|
-
!jwkEquals(existingKey, patch.publicKeyJwk)) {
|
|
850
|
-
rejected++;
|
|
851
|
-
continue;
|
|
852
|
-
}
|
|
853
882
|
const publicKey = existingKey ?? patch.publicKeyJwk;
|
|
854
883
|
if (!publicKey) {
|
|
855
884
|
rejected++;
|
|
@@ -863,14 +892,22 @@ export class Dacument {
|
|
|
863
892
|
stored = { payload, signerRole: "actor" };
|
|
864
893
|
}
|
|
865
894
|
else {
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
895
|
+
rejected++;
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
if (payload.kind === "ack") {
|
|
901
|
+
rejected++;
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
const publicKey = this.roleKeys[signerKind];
|
|
905
|
+
const verified = await verifyToken(publicKey, token, TOKEN_TYP);
|
|
906
|
+
if (!verified) {
|
|
907
|
+
rejected++;
|
|
908
|
+
continue;
|
|
873
909
|
}
|
|
910
|
+
stored = { payload, signerRole: signerKind };
|
|
874
911
|
}
|
|
875
912
|
this.verifiedOps.set(token, stored);
|
|
876
913
|
if (!this.opTokens.has(token)) {
|
|
@@ -982,18 +1019,18 @@ export class Dacument {
|
|
|
982
1019
|
}
|
|
983
1020
|
else {
|
|
984
1021
|
const roleAt = this.roleAt(payload.iss, payload.stamp);
|
|
985
|
-
const
|
|
1022
|
+
const isSelf = patch.target === payload.iss;
|
|
1023
|
+
const isSelfRevoke = isSelf && patch.role === "revoked";
|
|
986
1024
|
const targetKey = this.aclLog.publicKeyAt(patch.target, payload.stamp);
|
|
987
|
-
const
|
|
1025
|
+
const keyMismatch = Boolean(patch.publicKeyJwk) &&
|
|
1026
|
+
Boolean(targetKey) &&
|
|
1027
|
+
!jwkEquals(targetKey, patch.publicKeyJwk);
|
|
1028
|
+
const isSelfKeyUpdate = isSelf &&
|
|
988
1029
|
patch.publicKeyJwk &&
|
|
989
1030
|
patch.role === roleAt &&
|
|
990
|
-
roleAt !== "revoked"
|
|
991
|
-
|
|
992
|
-
if (patch.publicKeyJwk &&
|
|
993
|
-
targetKey &&
|
|
994
|
-
!jwkEquals(targetKey, patch.publicKeyJwk)) {
|
|
1031
|
+
roleAt !== "revoked";
|
|
1032
|
+
if (keyMismatch && signerRole !== "actor")
|
|
995
1033
|
continue;
|
|
996
|
-
}
|
|
997
1034
|
if (isSelfRevoke) {
|
|
998
1035
|
if (signerRole === "actor") {
|
|
999
1036
|
allowed = true;
|
|
@@ -1010,7 +1047,7 @@ export class Dacument {
|
|
|
1010
1047
|
if (this.canWriteAclTarget(signerRole, patch.role, patch.target, payload.stamp)) {
|
|
1011
1048
|
allowed = true;
|
|
1012
1049
|
}
|
|
1013
|
-
else if (isSelfKeyUpdate) {
|
|
1050
|
+
else if (isSelfKeyUpdate && !keyMismatch) {
|
|
1014
1051
|
allowed = true;
|
|
1015
1052
|
}
|
|
1016
1053
|
}
|
|
@@ -1021,7 +1058,7 @@ export class Dacument {
|
|
|
1021
1058
|
if (payload.kind === "ack") {
|
|
1022
1059
|
if (roleAt === "revoked")
|
|
1023
1060
|
continue;
|
|
1024
|
-
if (signerRole !==
|
|
1061
|
+
if (signerRole !== "actor")
|
|
1025
1062
|
continue;
|
|
1026
1063
|
allowed = true;
|
|
1027
1064
|
}
|
|
@@ -1065,8 +1102,11 @@ export class Dacument {
|
|
|
1065
1102
|
return;
|
|
1066
1103
|
const entry = this.aclLog.currentEntry(this.actorId);
|
|
1067
1104
|
if (entry?.publicKeyJwk) {
|
|
1068
|
-
|
|
1069
|
-
|
|
1105
|
+
const actorInfo = Dacument.requireActorInfo();
|
|
1106
|
+
if (jwkEquals(entry.publicKeyJwk, actorInfo.publicKeyJwk)) {
|
|
1107
|
+
this.actorKeyPublishPending = false;
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1070
1110
|
}
|
|
1071
1111
|
if (this.actorKeyPublishPending)
|
|
1072
1112
|
return;
|
|
@@ -1075,6 +1115,13 @@ export class Dacument {
|
|
|
1075
1115
|
if (!entry)
|
|
1076
1116
|
return;
|
|
1077
1117
|
const actorInfo = Dacument.requireActorInfo();
|
|
1118
|
+
const signerInfo = entry.publicKeyJwk
|
|
1119
|
+
? Dacument.actorInfoForPublicKey(entry.publicKeyJwk)
|
|
1120
|
+
: actorInfo;
|
|
1121
|
+
if (entry.publicKeyJwk && !signerInfo) {
|
|
1122
|
+
this.emitError(new Error("Dacument: actor key mismatch; update requires current key material"));
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1078
1125
|
const stamp = this.clock.next();
|
|
1079
1126
|
const payload = {
|
|
1080
1127
|
iss: this.actorId,
|
|
@@ -1091,19 +1138,37 @@ export class Dacument {
|
|
|
1091
1138
|
},
|
|
1092
1139
|
};
|
|
1093
1140
|
this.actorKeyPublishPending = true;
|
|
1094
|
-
this.queueActorOp(payload,
|
|
1095
|
-
|
|
1141
|
+
this.queueActorOp(payload, {
|
|
1142
|
+
signer: (signerInfo ?? actorInfo).privateKeyJwk,
|
|
1143
|
+
onError: () => {
|
|
1144
|
+
this.actorKeyPublishPending = false;
|
|
1145
|
+
},
|
|
1096
1146
|
});
|
|
1097
1147
|
}
|
|
1148
|
+
actorSignatureKey() {
|
|
1149
|
+
const entry = this.aclLog.currentEntry(this.actorId);
|
|
1150
|
+
if (!entry?.publicKeyJwk)
|
|
1151
|
+
return null;
|
|
1152
|
+
const actorInfo = Dacument.actorInfoForPublicKey(entry.publicKeyJwk);
|
|
1153
|
+
return actorInfo?.privateKeyJwk ?? null;
|
|
1154
|
+
}
|
|
1098
1155
|
ack() {
|
|
1099
1156
|
this.assertNotReset();
|
|
1100
1157
|
const stamp = this.clock.next();
|
|
1101
1158
|
const role = this.roleAt(this.actorId, stamp);
|
|
1102
1159
|
if (role === "revoked")
|
|
1103
1160
|
throw new Error("Dacument: revoked actors cannot acknowledge");
|
|
1161
|
+
const entry = this.aclLog.currentEntry(this.actorId);
|
|
1162
|
+
if (!entry?.publicKeyJwk)
|
|
1163
|
+
return;
|
|
1164
|
+
const actorInfo = Dacument.actorInfoForPublicKey(entry.publicKeyJwk);
|
|
1165
|
+
if (!actorInfo) {
|
|
1166
|
+
this.emitError(new Error("Dacument: actor key not available to sign ack"));
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1104
1169
|
const seen = this.clock.current;
|
|
1105
1170
|
this.ackByActor.set(this.actorId, seen);
|
|
1106
|
-
this.
|
|
1171
|
+
this.queueActorOp({
|
|
1107
1172
|
iss: this.actorId,
|
|
1108
1173
|
sub: this.docId,
|
|
1109
1174
|
iat: nowSeconds(),
|
|
@@ -1111,7 +1176,7 @@ export class Dacument {
|
|
|
1111
1176
|
kind: "ack",
|
|
1112
1177
|
schema: this.schemaId,
|
|
1113
1178
|
patch: { seen },
|
|
1114
|
-
},
|
|
1179
|
+
}, { signer: actorInfo.privateKeyJwk });
|
|
1115
1180
|
}
|
|
1116
1181
|
scheduleAck() {
|
|
1117
1182
|
if (this.ackScheduled)
|
|
@@ -1753,7 +1818,7 @@ export class Dacument {
|
|
|
1753
1818
|
const shadow = this.shadowFor(field, state);
|
|
1754
1819
|
const { patches, result } = this.capturePatches((listener) => shadow.onChange(listener), () => mutate(shadow));
|
|
1755
1820
|
if (patches.length === 0)
|
|
1756
|
-
return;
|
|
1821
|
+
return result;
|
|
1757
1822
|
this.queueLocalOp({
|
|
1758
1823
|
iss: this.actorId,
|
|
1759
1824
|
sub: this.docId,
|
|
@@ -1781,10 +1846,7 @@ export class Dacument {
|
|
|
1781
1846
|
queueLocalOp(payload, role) {
|
|
1782
1847
|
this.assertNotReset();
|
|
1783
1848
|
if (payload.kind === "ack") {
|
|
1784
|
-
|
|
1785
|
-
const token = encodeToken(header, payload);
|
|
1786
|
-
this.emitEvent("change", { type: "change", ops: [{ token }] });
|
|
1787
|
-
return;
|
|
1849
|
+
throw new Error("Dacument: ack ops must be actor-signed");
|
|
1788
1850
|
}
|
|
1789
1851
|
if (!roleNeedsKey(role))
|
|
1790
1852
|
throw new Error(`Dacument: role '${role}' cannot sign ops`);
|
|
@@ -1793,30 +1855,34 @@ export class Dacument {
|
|
|
1793
1855
|
const header = { alg: "ES256", typ: TOKEN_TYP, kid: `${payload.iss}:${role}` };
|
|
1794
1856
|
const promise = signToken(this.roleKey, header, payload)
|
|
1795
1857
|
.then(async (token) => {
|
|
1796
|
-
const
|
|
1797
|
-
const
|
|
1858
|
+
const actorSigKey = this.actorSignatureKey();
|
|
1859
|
+
const actorSig = actorSigKey
|
|
1860
|
+
? await Dacument.signActorToken(token, actorSigKey)
|
|
1861
|
+
: undefined;
|
|
1862
|
+
const op = actorSig ? { token, actorSig } : { token };
|
|
1798
1863
|
this.emitEvent("change", { type: "change", ops: [op] });
|
|
1799
1864
|
})
|
|
1800
1865
|
.catch((error) => this.emitError(error instanceof Error ? error : new Error(String(error))));
|
|
1801
1866
|
this.pending.add(promise);
|
|
1802
1867
|
promise.finally(() => this.pending.delete(promise));
|
|
1803
1868
|
}
|
|
1804
|
-
queueActorOp(payload,
|
|
1869
|
+
queueActorOp(payload, options) {
|
|
1805
1870
|
this.assertNotReset();
|
|
1806
1871
|
const actorInfo = Dacument.requireActorInfo();
|
|
1872
|
+
const signingKey = options?.signer ?? actorInfo.privateKeyJwk;
|
|
1807
1873
|
const header = {
|
|
1808
1874
|
alg: "ES256",
|
|
1809
1875
|
typ: TOKEN_TYP,
|
|
1810
1876
|
kid: `${payload.iss}:actor`,
|
|
1811
1877
|
};
|
|
1812
|
-
const promise = signToken(
|
|
1878
|
+
const promise = signToken(signingKey, header, payload)
|
|
1813
1879
|
.then(async (token) => {
|
|
1814
|
-
const actorSig = await Dacument.signActorToken(token);
|
|
1880
|
+
const actorSig = await Dacument.signActorToken(token, signingKey);
|
|
1815
1881
|
const op = { token, actorSig };
|
|
1816
1882
|
this.emitEvent("change", { type: "change", ops: [op] });
|
|
1817
1883
|
})
|
|
1818
1884
|
.catch((error) => {
|
|
1819
|
-
onError?.();
|
|
1885
|
+
options?.onError?.();
|
|
1820
1886
|
this.emitError(error instanceof Error ? error : new Error(String(error)));
|
|
1821
1887
|
});
|
|
1822
1888
|
this.pending.add(promise);
|
package/dist/Dacument/types.d.ts
CHANGED
|
@@ -32,6 +32,10 @@ export type ActorInfo = {
|
|
|
32
32
|
privateKeyJwk: JsonWebKey;
|
|
33
33
|
publicKeyJwk: JsonWebKey;
|
|
34
34
|
};
|
|
35
|
+
export type ActorInfoUpdate = ActorInfo & {
|
|
36
|
+
currentPrivateKeyJwk?: JsonWebKey;
|
|
37
|
+
currentPublicKeyJwk?: JsonWebKey;
|
|
38
|
+
};
|
|
35
39
|
export type RegisterSchema<T extends JsTypeName = JsTypeName> = {
|
|
36
40
|
crdt: "register";
|
|
37
41
|
jsType: T;
|
package/dist/Dacument/types.js
CHANGED
|
@@ -2,8 +2,10 @@ export function isJsValue(value) {
|
|
|
2
2
|
if (value === null)
|
|
3
3
|
return true;
|
|
4
4
|
const valueType = typeof value;
|
|
5
|
-
if (valueType === "string" || valueType === "
|
|
5
|
+
if (valueType === "string" || valueType === "boolean")
|
|
6
6
|
return true;
|
|
7
|
+
if (valueType === "number")
|
|
8
|
+
return Number.isFinite(value);
|
|
7
9
|
if (Array.isArray(value))
|
|
8
10
|
return value.every(isJsValue);
|
|
9
11
|
if (valueType === "object") {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dacument",
|
|
3
|
-
"version": "1.2.
|
|
4
|
-
"description": "Schema-driven CRDT document with signed ops, role-based ACLs, and
|
|
3
|
+
"version": "1.2.2",
|
|
4
|
+
"description": "Schema-driven CRDT document with signed ops, role-based ACLs, and per-actor auditability.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"crdt",
|
|
7
7
|
"document",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"bytecodec": "^2.1.0",
|
|
54
54
|
"uuid": "^13.0.0",
|
|
55
|
-
"zeyra": "^2.
|
|
55
|
+
"zeyra": "^2.2.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/node": "^25.0.3",
|