dacument 1.2.0 → 1.2.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/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 per actor as needed.
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 and schema bypasses throw.
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`, arrays, or objects). For string-keyed data, prefer `record`.
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` libarys `generateNonce()`), and the actor key pair must be ES256 (P-256).
105
- Subsequent calls are ignored. On first merge, Dacument auto-attaches the
106
- actor's `publicKeyJwk` to its own ACL entry (if missing).
107
-
108
- Each actor signs with the role key they were given (owner/manager/editor). Load
109
- with the highest role key you have; viewers load without a key.
110
- Role keys are generated once at `create()`; public keys are embedded in the
111
- snapshot and never rotated.
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 (writer ops are signed; acks are unsigned).
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 dacument with fresh keys and emits a reset op.
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
- doc id and revoke the old one:
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 dacument with fresh
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 unsigned
196
- (`alg: "none"`); signed acks are rejected.
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 verifies
203
- (acks are unsigned and signed acks are rejected).
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` librarys `generateNonce()` (32 random bytes).
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
- - Key compromise: no rotation exists, so migrate by snapshotting into a new
215
- dacument with fresh keys.
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
@@ -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
  }
@@ -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 (/^(0|[1-9]\d*)$/.test(property))
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" && /^(0|[1-9]\d*)$/.test(property)) {
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" && /^(0|[1-9]\d*)$/.test(property)) {
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" && /^(0|[1-9]\d*)$/.test(property)) {
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
- let count = 0;
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
- let after = this.lastAliveId()
83
- ? [this.lastAliveId()]
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.nodes.length - 1; index >= 0; index--) {
129
+ for (let index = this.lastAliveIndex; index >= 0; index--) {
113
130
  const node = this.nodes[index];
114
- if (!node.deleted) {
115
- node.deleted = true;
116
- this.emit([node]);
117
- return node.value;
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
- return this.alive().at(index);
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
- return this.alive().slice(start, end);
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
- return this.alive().includes(value);
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
- return this.alive().indexOf(value);
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
- for (const node of this.nodes)
249
- if (!node.deleted)
250
- values.push(node.value);
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
- const node = this.nodes[index];
256
- if (!node.deleted)
257
- return node.id;
371
+ if (!this.nodes[index].deleted) {
372
+ this.lastAliveIndex = index;
373
+ return;
374
+ }
258
375
  }
259
- return null;
376
+ this.lastAliveIndex = -1;
260
377
  }
261
378
  afterIdForAliveInsertAt(index) {
262
379
  if (index === 0)
@@ -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
  }
@@ -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
- let count = 0;
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
- return this.alive().at(index);
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
- if (index > this.length)
66
+ const length = this.aliveCount;
67
+ if (index > length)
52
68
  throw new RangeError("CRText.insertAt: index out of bounds");
53
- const after = this.afterIdForAliveInsertAt(index);
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 (const node of this.nodes) {
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;
@@ -1,4 +1,4 @@
1
- import { type AclAssignment, type ActorInfo, 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";
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 setActorInfo(info: ActorInfo): Promise<void>;
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(schema: SchemaDefinition): Promise<SchemaId>;
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;
@@ -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, encodeToken, signToken, validateActorKeyPair, verifyDetached, verifyToken, } from "./crypto.js";
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
- if (Dacument.actorInfo)
174
- return;
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
- Dacument.actorInfo = info;
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 signer = Dacument.requireActorSigner();
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
- const isUnsignedAck = decoded.header.alg === "none" &&
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
- if (isUnsignedAck) {
822
- stored = { payload, signerRole: null };
850
+ const signerKind = parseSignerKind(decoded.header.kid, payload.iss);
851
+ if (!signerKind) {
852
+ rejected++;
853
+ continue;
823
854
  }
824
- else {
825
- const signerKind = parseSignerKind(decoded.header.kid, payload.iss);
826
- if (!signerKind) {
827
- rejected++;
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) {
859
+ rejected++;
860
+ continue;
861
+ }
862
+ const verified = await verifyToken(publicKey, token, TOKEN_TYP);
863
+ if (!verified) {
832
864
  rejected++;
833
865
  continue;
834
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
- const publicKey = this.roleKeys[signerKind];
867
- const verified = await verifyToken(publicKey, token, TOKEN_TYP);
868
- if (!verified) {
869
- rejected++;
870
- continue;
871
- }
872
- stored = { payload, signerRole: signerKind };
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 isSelfRevoke = patch.target === payload.iss && patch.role === "revoked";
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 isSelfKeyUpdate = patch.target === payload.iss &&
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
- (!targetKey || jwkEquals(targetKey, patch.publicKeyJwk));
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 !== null)
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
- this.actorKeyPublishPending = false;
1069
- return;
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
- this.actorKeyPublishPending = false;
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.queueLocalOp({
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
- }, role);
1179
+ }, { signer: actorInfo.privateKeyJwk });
1115
1180
  }
1116
1181
  scheduleAck() {
1117
1182
  if (this.ackScheduled)
@@ -1781,10 +1846,7 @@ export class Dacument {
1781
1846
  queueLocalOp(payload, role) {
1782
1847
  this.assertNotReset();
1783
1848
  if (payload.kind === "ack") {
1784
- const header = { alg: "none", typ: TOKEN_TYP };
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 actorSig = await Dacument.signActorToken(token);
1797
- const op = { token, actorSig };
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, onError) {
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(actorInfo.privateKeyJwk, header, payload)
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);
@@ -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;
@@ -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 === "number" || valueType === "boolean")
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.0",
4
- "description": "Schema-driven CRDT document with signed ops, role-based ACLs, and optional per-actor verification.",
3
+ "version": "1.2.1",
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.1.0"
55
+ "zeyra": "^2.2.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/node": "^25.0.3",