dacument 1.2.2 → 2.0.0
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 +257 -255
- package/dist/Dacument/class.js +3 -3
- package/dist/Dacument/types.d.ts +12 -6
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,258 +1,260 @@
|
|
|
1
|
-
# Dacument
|
|
2
|
-
|
|
3
|
-
Dacument is a schema-driven CRDT document that signs every operation and
|
|
4
|
-
enforces role-based ACLs at merge time. Local writes emit signed ops; state
|
|
5
|
-
advances only when ops are merged. It gives you a JS object-like API for
|
|
6
|
-
register fields and safe CRDT views for all other field types.
|
|
7
|
-
|
|
8
|
-
## Install
|
|
9
|
-
|
|
10
|
-
```sh
|
|
11
|
-
npm install dacument
|
|
12
|
-
# or
|
|
13
|
-
pnpm add dacument
|
|
14
|
-
# or
|
|
15
|
-
yarn add dacument
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
## Quick start
|
|
19
|
-
|
|
20
|
-
```ts
|
|
21
|
-
import { generateNonce } from "bytecodec";
|
|
22
|
-
import { generateSignPair } from "zeyra";
|
|
23
|
-
import { Dacument } from "dacument";
|
|
24
|
-
|
|
25
|
-
const actorId = generateNonce(); // 256-bit base64url id
|
|
26
|
-
const actorKeys = await generateSignPair();
|
|
27
|
-
await Dacument.setActorInfo({
|
|
28
|
-
id: actorId,
|
|
29
|
-
privateKeyJwk: actorKeys.signingJwk,
|
|
30
|
-
publicKeyJwk: actorKeys.verificationJwk,
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
const schema = Dacument.schema({
|
|
34
|
-
title: Dacument.register({ jsType: "string", regex: /^[a-z ]+$/i }),
|
|
35
|
-
body: Dacument.text(),
|
|
36
|
-
items: Dacument.array({ jsType: "string" }),
|
|
37
|
-
tags: Dacument.set({ jsType: "string" }),
|
|
38
|
-
meta: Dacument.record({ jsType: "string" }),
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
const { docId, snapshot, roleKeys } = await Dacument.create({ schema });
|
|
42
|
-
|
|
43
|
-
const doc = await Dacument.load({
|
|
44
|
-
schema,
|
|
45
|
-
roleKey: roleKeys.owner.privateKey,
|
|
46
|
-
snapshot,
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
doc.title = "Hello world";
|
|
50
|
-
doc.body.insertAt(0, "H");
|
|
51
|
-
doc.tags.add("draft");
|
|
52
|
-
doc.items.push("milk");
|
|
53
|
-
|
|
54
|
-
// Wire ops to your transport.
|
|
55
|
-
doc.addEventListener("
|
|
56
|
-
channel.onmessage = (ops) => doc.merge(ops);
|
|
57
|
-
|
|
58
|
-
doc.addEventListener("merge", ({ actor, target, method, data }) => {
|
|
59
|
-
// Update UI from a single merge stream.
|
|
60
|
-
});
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
`create()` returns `roleKeys` for owner/manager/editor; store them securely and
|
|
64
|
-
distribute the highest role key each actor should hold.
|
|
65
|
-
|
|
66
|
-
## Schema and fields
|
|
67
|
-
|
|
68
|
-
- `register` fields behave like normal properties: `doc.title = "hi"`.
|
|
69
|
-
- Other fields return safe CRDT views: `doc.items.push("x")`.
|
|
70
|
-
- Unknown fields or schema bypasses throw.
|
|
71
|
-
- UI updates should listen to `merge` events.
|
|
72
|
-
|
|
73
|
-
Supported CRDT field types:
|
|
74
|
-
|
|
75
|
-
- `register` - last writer wins register.
|
|
76
|
-
- `text` - text RGA.
|
|
77
|
-
- `array` - array RGA.
|
|
78
|
-
- `set` - OR-Set.
|
|
79
|
-
- `map` - OR-Map.
|
|
80
|
-
- `record` - OR-Record.
|
|
81
|
-
|
|
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.
|
|
85
|
-
|
|
86
|
-
## Roles and ACL
|
|
87
|
-
|
|
88
|
-
Roles are evaluated at the op stamp time (HLC).
|
|
89
|
-
|
|
90
|
-
- Owner: full control (including ownership transfer).
|
|
91
|
-
- Manager: can grant editor/viewer/revoked roles (cannot change owner).
|
|
92
|
-
- Editor: can write non-ACL fields.
|
|
93
|
-
- Viewer: read-only.
|
|
94
|
-
- Revoked: reads are masked to initial values; writes are rejected.
|
|
95
|
-
|
|
96
|
-
Grant roles via `doc.acl` (viewer/revoked have no key):
|
|
97
|
-
|
|
98
|
-
```ts
|
|
99
|
-
const bobId = generateNonce();
|
|
100
|
-
doc.acl.setRole(bobId, "editor");
|
|
101
|
-
doc.acl.setRole("user-viewer", "viewer");
|
|
102
|
-
await doc.flush();
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
Before any schema/load/create, call `await Dacument.setActorInfo(...)` once per
|
|
106
|
-
process. The actor id must be a 256-bit base64url string (e.g.
|
|
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.
|
|
118
|
-
|
|
119
|
-
## Networking and sync
|
|
120
|
-
|
|
121
|
-
Use `
|
|
122
|
-
|
|
123
|
-
```ts
|
|
124
|
-
doc.addEventListener("
|
|
125
|
-
|
|
126
|
-
// on remote
|
|
127
|
-
await peer.merge(ops);
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
Local writes do not update state until merged. If you want a single UI update
|
|
131
|
-
path, broadcast ops (even back to yourself) and drive UI from `merge` events.
|
|
132
|
-
|
|
133
|
-
`merge` events mirror the confirmed operation parameters (e.g. `insertAt`,
|
|
134
|
-
`deleteAt`, `push`, `pop`, `set`, `add`) so UIs can apply minimal updates
|
|
135
|
-
without snapshotting.
|
|
136
|
-
|
|
137
|
-
To add a new replica, share a snapshot and load it:
|
|
138
|
-
|
|
139
|
-
```ts
|
|
140
|
-
const bobKeys = await generateSignPair();
|
|
141
|
-
await Dacument.setActorInfo({
|
|
142
|
-
id: bobId,
|
|
143
|
-
privateKeyJwk: bobKeys.signingJwk,
|
|
144
|
-
publicKeyJwk: bobKeys.verificationJwk,
|
|
145
|
-
});
|
|
146
|
-
const bob = await Dacument.load({
|
|
147
|
-
schema,
|
|
148
|
-
roleKey: bobKey.privateKey,
|
|
149
|
-
snapshot,
|
|
150
|
-
});
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
Snapshots do not include the schema or schema ids; callers must supply the schema on load.
|
|
154
|
-
|
|
155
|
-
## Events and values
|
|
156
|
-
|
|
157
|
-
- `doc.addEventListener("
|
|
158
|
-
(writer ops are role-signed; acks are actor-signed by non-revoked actors and
|
|
159
|
-
verified against ACL-pinned actor public keys).
|
|
160
|
-
- `doc.addEventListener("merge", handler)` emits `{ actor, target, method, data }`.
|
|
161
|
-
- `doc.addEventListener("error", handler)` emits signing/verification errors.
|
|
162
|
-
- `doc.addEventListener("revoked", handler)` fires when the current actor is revoked.
|
|
163
|
-
- `doc.addEventListener("reset", handler)` emits `{ oldDocId, newDocId, ts, by, reason }`.
|
|
164
|
-
- `doc.selfRevoke()` emits a signed ACL op that revokes the current actor.
|
|
165
|
-
- `await doc.accessReset({ reason })` creates a new Dacument with fresh keys and emits a reset op.
|
|
166
|
-
- `doc.getResetState()` returns reset metadata (or `null`).
|
|
167
|
-
- `await doc.flush()` waits for pending signatures so all local ops are emitted.
|
|
168
|
-
- `doc.snapshot()` returns a loadable op log (`{ docId, roleKeys, ops }`).
|
|
169
|
-
- `await doc.verifyActorIntegrity(...)` verifies per-actor signatures on demand.
|
|
170
|
-
- Revoked actors cannot snapshot; reads are masked to initial values.
|
|
171
|
-
|
|
172
|
-
## Access reset (key compromise response)
|
|
173
|
-
|
|
174
|
-
If an owner suspects role key compromise, call `accessReset()` to fork to a new
|
|
175
|
-
docId and revoke the old one:
|
|
176
|
-
|
|
177
|
-
```ts
|
|
178
|
-
const { newDoc, oldDocOps, newDocSnapshot, roleKeys } =
|
|
179
|
-
await doc.accessReset({ reason: "suspected compromise" });
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
`accessReset()` materializes the current state into a new Dacument with fresh
|
|
183
|
-
role keys, emits a signed `reset` op for the old doc, and returns the new
|
|
184
|
-
snapshot + keys. The reset is stored as a CRDT op so all replicas converge. Once
|
|
185
|
-
reset, the old doc rejects any ops after the reset stamp and throws on writes:
|
|
186
|
-
`Dacument is reset/deprecated. Use newDocId: <id>`. Snapshots and verification
|
|
187
|
-
still work so you can archive/inspect history.
|
|
188
|
-
If an attacker already has the owner key, they can also reset; this is a
|
|
189
|
-
response tool for suspected compromise, not a prevention mechanism.
|
|
190
|
-
|
|
191
|
-
## Actor identity (cold path)
|
|
192
|
-
|
|
193
|
-
Every op may include an `actorSig` (detached ES256 signature over the op token).
|
|
194
|
-
Merges ignore `actorSig` by default to keep the hot path fast. When you need
|
|
195
|
-
attribution or forensic checks, call `verifyActorIntegrity()` with a token,
|
|
196
|
-
ops list, or snapshot. It verifies `actorSig` against the actor's `publicKeyJwk`
|
|
197
|
-
from the ACL at the op stamp and returns a summary plus failures.
|
|
198
|
-
|
|
199
|
-
## Garbage collection
|
|
200
|
-
|
|
201
|
-
Dacument tracks per-actor `ack` ops and compacts tombstones once all non-revoked
|
|
202
|
-
actors (including viewers) have acknowledged a given HLC. Acks are emitted
|
|
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.
|
|
205
|
-
If any non-revoked actor is offline and never acks, tombstones are kept.
|
|
206
|
-
|
|
207
|
-
## Guarantees
|
|
208
|
-
|
|
209
|
-
- Schema enforcement is strict; unknown fields 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.
|
|
212
|
-
- Role checks are applied at the op stamp time (HLC).
|
|
213
|
-
- IDs are base64url nonces from `bytecodec` library's `generateNonce()` (32 random bytes).
|
|
214
|
-
- Private keys are returned by `create()` and never stored by Dacument.
|
|
215
|
-
- Snapshots may include ops that are rejected; invalid ops are ignored on load.
|
|
216
|
-
|
|
217
|
-
Eventual consistency is achieved when all signed ops are delivered to all
|
|
218
|
-
replicas. Dacument does not provide transport; use `
|
|
219
|
-
|
|
220
|
-
## Possible threats and how to handle
|
|
221
|
-
|
|
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.
|
|
225
|
-
- Shared role keys: attribution is role-level, not per-user; treat roles as
|
|
226
|
-
trust groups and log merge events if you need auditing.
|
|
227
|
-
- Insider DoS/flooding: rate-limit ops, cap payload sizes, and monitor merge
|
|
228
|
-
errors at the application layer.
|
|
229
|
-
- Withholding/delivery delays: eventual consistency depends on ops arriving;
|
|
230
|
-
use reliable transport and resync via snapshot when needed.
|
|
231
|
-
- No built-in encryption: use TLS or E2E encryption and treat snapshots as
|
|
232
|
-
sensitive data.
|
|
233
|
-
|
|
234
|
-
## Compatibility
|
|
235
|
-
|
|
236
|
-
- ESM only (`type: module`).
|
|
237
|
-
- Requires WebCrypto (`node >= 18` or modern browsers).
|
|
238
|
-
|
|
239
|
-
## Scripts
|
|
240
|
-
|
|
1
|
+
# Dacument
|
|
2
|
+
|
|
3
|
+
Dacument is a schema-driven CRDT document that signs every operation and
|
|
4
|
+
enforces role-based ACLs at merge time. Local writes emit signed ops; state
|
|
5
|
+
advances only when ops are merged. It gives you a JS object-like API for
|
|
6
|
+
register fields and safe CRDT views for all other field types.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install dacument
|
|
12
|
+
# or
|
|
13
|
+
pnpm add dacument
|
|
14
|
+
# or
|
|
15
|
+
yarn add dacument
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { generateNonce } from "bytecodec";
|
|
22
|
+
import { generateSignPair } from "zeyra";
|
|
23
|
+
import { Dacument } from "dacument";
|
|
24
|
+
|
|
25
|
+
const actorId = generateNonce(); // 256-bit base64url id
|
|
26
|
+
const actorKeys = await generateSignPair();
|
|
27
|
+
await Dacument.setActorInfo({
|
|
28
|
+
id: actorId,
|
|
29
|
+
privateKeyJwk: actorKeys.signingJwk,
|
|
30
|
+
publicKeyJwk: actorKeys.verificationJwk,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const schema = Dacument.schema({
|
|
34
|
+
title: Dacument.register({ jsType: "string", regex: /^[a-z ]+$/i }),
|
|
35
|
+
body: Dacument.text(),
|
|
36
|
+
items: Dacument.array({ jsType: "string" }),
|
|
37
|
+
tags: Dacument.set({ jsType: "string" }),
|
|
38
|
+
meta: Dacument.record({ jsType: "string" }),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const { docId, snapshot, roleKeys } = await Dacument.create({ schema });
|
|
42
|
+
|
|
43
|
+
const doc = await Dacument.load({
|
|
44
|
+
schema,
|
|
45
|
+
roleKey: roleKeys.owner.privateKey,
|
|
46
|
+
snapshot,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
doc.title = "Hello world";
|
|
50
|
+
doc.body.insertAt(0, "H");
|
|
51
|
+
doc.tags.add("draft");
|
|
52
|
+
doc.items.push("milk");
|
|
53
|
+
|
|
54
|
+
// Wire ops to your transport.
|
|
55
|
+
doc.addEventListener("delta", (event) => channel.send(event.ops));
|
|
56
|
+
channel.onmessage = (ops) => doc.merge(ops);
|
|
57
|
+
|
|
58
|
+
doc.addEventListener("merge", ({ actor, target, method, data }) => {
|
|
59
|
+
// Update UI from a single merge stream.
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`create()` returns `roleKeys` for owner/manager/editor; store them securely and
|
|
64
|
+
distribute the highest role key each actor should hold.
|
|
65
|
+
|
|
66
|
+
## Schema and fields
|
|
67
|
+
|
|
68
|
+
- `register` fields behave like normal properties: `doc.title = "hi"`.
|
|
69
|
+
- Other fields return safe CRDT views: `doc.items.push("x")`.
|
|
70
|
+
- Unknown fields or schema bypasses throw.
|
|
71
|
+
- UI updates should listen to `merge` events.
|
|
72
|
+
|
|
73
|
+
Supported CRDT field types:
|
|
74
|
+
|
|
75
|
+
- `register` - last writer wins register.
|
|
76
|
+
- `text` - text RGA.
|
|
77
|
+
- `array` - array RGA.
|
|
78
|
+
- `set` - OR-Set.
|
|
79
|
+
- `map` - OR-Map.
|
|
80
|
+
- `record` - OR-Record.
|
|
81
|
+
|
|
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.
|
|
85
|
+
|
|
86
|
+
## Roles and ACL
|
|
87
|
+
|
|
88
|
+
Roles are evaluated at the op stamp time (HLC).
|
|
89
|
+
|
|
90
|
+
- Owner: full control (including ownership transfer).
|
|
91
|
+
- Manager: can grant editor/viewer/revoked roles (cannot change owner).
|
|
92
|
+
- Editor: can write non-ACL fields.
|
|
93
|
+
- Viewer: read-only.
|
|
94
|
+
- Revoked: reads are masked to initial values; writes are rejected.
|
|
95
|
+
|
|
96
|
+
Grant roles via `doc.acl` (viewer/revoked have no key):
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
const bobId = generateNonce();
|
|
100
|
+
doc.acl.setRole(bobId, "editor");
|
|
101
|
+
doc.acl.setRole("user-viewer", "viewer");
|
|
102
|
+
await doc.flush();
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Before any schema/load/create, call `await Dacument.setActorInfo(...)` once per
|
|
106
|
+
process. The actor id must be a 256-bit base64url string (e.g.
|
|
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.
|
|
118
|
+
|
|
119
|
+
## Networking and sync
|
|
120
|
+
|
|
121
|
+
Use `delta` events to relay signed ops, and `merge` to apply them:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
doc.addEventListener("delta", (event) => send(event.ops));
|
|
125
|
+
|
|
126
|
+
// on remote
|
|
127
|
+
await peer.merge(ops);
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Local writes do not update state until merged. If you want a single UI update
|
|
131
|
+
path, broadcast ops (even back to yourself) and drive UI from `merge` events.
|
|
132
|
+
|
|
133
|
+
`merge` events mirror the confirmed operation parameters (e.g. `insertAt`,
|
|
134
|
+
`deleteAt`, `push`, `pop`, `set`, `add`) so UIs can apply minimal updates
|
|
135
|
+
without snapshotting.
|
|
136
|
+
|
|
137
|
+
To add a new replica, share a snapshot and load it:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
const bobKeys = await generateSignPair();
|
|
141
|
+
await Dacument.setActorInfo({
|
|
142
|
+
id: bobId,
|
|
143
|
+
privateKeyJwk: bobKeys.signingJwk,
|
|
144
|
+
publicKeyJwk: bobKeys.verificationJwk,
|
|
145
|
+
});
|
|
146
|
+
const bob = await Dacument.load({
|
|
147
|
+
schema,
|
|
148
|
+
roleKey: bobKey.privateKey,
|
|
149
|
+
snapshot,
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Snapshots do not include the schema or schema ids; callers must supply the schema on load.
|
|
154
|
+
|
|
155
|
+
## Events and values
|
|
156
|
+
|
|
157
|
+
- `doc.addEventListener("delta", 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).
|
|
160
|
+
- `doc.addEventListener("merge", handler)` emits `{ actor, target, method, data }`.
|
|
161
|
+
- `doc.addEventListener("error", handler)` emits signing/verification errors.
|
|
162
|
+
- `doc.addEventListener("revoked", handler)` fires when the current actor is revoked.
|
|
163
|
+
- `doc.addEventListener("reset", handler)` emits `{ oldDocId, newDocId, ts, by, reason }`.
|
|
164
|
+
- `doc.selfRevoke()` emits a signed ACL op that revokes the current actor.
|
|
165
|
+
- `await doc.accessReset({ reason })` creates a new Dacument with fresh keys and emits a reset op.
|
|
166
|
+
- `doc.getResetState()` returns reset metadata (or `null`).
|
|
167
|
+
- `await doc.flush()` waits for pending signatures so all local ops are emitted.
|
|
168
|
+
- `doc.snapshot()` returns a loadable op log (`{ docId, roleKeys, ops }`).
|
|
169
|
+
- `await doc.verifyActorIntegrity(...)` verifies per-actor signatures on demand.
|
|
170
|
+
- Revoked actors cannot snapshot; reads are masked to initial values.
|
|
171
|
+
|
|
172
|
+
## Access reset (key compromise response)
|
|
173
|
+
|
|
174
|
+
If an owner suspects role key compromise, call `accessReset()` to fork to a new
|
|
175
|
+
docId and revoke the old one:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
const { newDoc, oldDocOps, newDocSnapshot, roleKeys } =
|
|
179
|
+
await doc.accessReset({ reason: "suspected compromise" });
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`accessReset()` materializes the current state into a new Dacument with fresh
|
|
183
|
+
role keys, emits a signed `reset` op for the old doc, and returns the new
|
|
184
|
+
snapshot + keys. The reset is stored as a CRDT op so all replicas converge. Once
|
|
185
|
+
reset, the old doc rejects any ops after the reset stamp and throws on writes:
|
|
186
|
+
`Dacument is reset/deprecated. Use newDocId: <id>`. Snapshots and verification
|
|
187
|
+
still work so you can archive/inspect history.
|
|
188
|
+
If an attacker already has the owner key, they can also reset; this is a
|
|
189
|
+
response tool for suspected compromise, not a prevention mechanism.
|
|
190
|
+
|
|
191
|
+
## Actor identity (cold path)
|
|
192
|
+
|
|
193
|
+
Every op may include an `actorSig` (detached ES256 signature over the op token).
|
|
194
|
+
Merges ignore `actorSig` by default to keep the hot path fast. When you need
|
|
195
|
+
attribution or forensic checks, call `verifyActorIntegrity()` with a token,
|
|
196
|
+
ops list, or snapshot. It verifies `actorSig` against the actor's `publicKeyJwk`
|
|
197
|
+
from the ACL at the op stamp and returns a summary plus failures.
|
|
198
|
+
|
|
199
|
+
## Garbage collection
|
|
200
|
+
|
|
201
|
+
Dacument tracks per-actor `ack` ops and compacts tombstones once all non-revoked
|
|
202
|
+
actors (including viewers) have acknowledged a given HLC. Acks are emitted
|
|
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.
|
|
205
|
+
If any non-revoked actor is offline and never acks, tombstones are kept.
|
|
206
|
+
|
|
207
|
+
## Guarantees
|
|
208
|
+
|
|
209
|
+
- Schema enforcement is strict; unknown fields 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.
|
|
212
|
+
- Role checks are applied at the op stamp time (HLC).
|
|
213
|
+
- IDs are base64url nonces from `bytecodec` library's `generateNonce()` (32 random bytes).
|
|
214
|
+
- Private keys are returned by `create()` and never stored by Dacument.
|
|
215
|
+
- Snapshots may include ops that are rejected; invalid ops are ignored on load.
|
|
216
|
+
|
|
217
|
+
Eventual consistency is achieved when all signed ops are delivered to all
|
|
218
|
+
replicas. Dacument does not provide transport; use `delta` events to wire it up.
|
|
219
|
+
|
|
220
|
+
## Possible threats and how to handle
|
|
221
|
+
|
|
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.
|
|
225
|
+
- Shared role keys: attribution is role-level, not per-user; treat roles as
|
|
226
|
+
trust groups and log merge events if you need auditing.
|
|
227
|
+
- Insider DoS/flooding: rate-limit ops, cap payload sizes, and monitor merge
|
|
228
|
+
errors at the application layer.
|
|
229
|
+
- Withholding/delivery delays: eventual consistency depends on ops arriving;
|
|
230
|
+
use reliable transport and resync via snapshot when needed.
|
|
231
|
+
- No built-in encryption: use TLS or E2E encryption and treat snapshots as
|
|
232
|
+
sensitive data.
|
|
233
|
+
|
|
234
|
+
## Compatibility
|
|
235
|
+
|
|
236
|
+
- ESM only (`type: module`).
|
|
237
|
+
- Requires WebCrypto (`node >= 18` or modern browsers).
|
|
238
|
+
|
|
239
|
+
## Scripts
|
|
240
|
+
|
|
241
241
|
- `npm test` runs the test suite (build included).
|
|
242
|
+
- `npm run test:types` runs compile-time inference checks.
|
|
243
|
+
- `npm run test:playwright` runs browser/unit/integration/e2e coverage via Playwright.
|
|
242
244
|
- `npm run bench` runs all CRDT micro-benchmarks (build included).
|
|
243
245
|
- `npm run sim` runs a worker-thread stress simulation.
|
|
244
|
-
- `npm run verify` runs tests, benchmarks, and the simulation in one go.
|
|
245
|
-
|
|
246
|
-
## Benchmarks
|
|
247
|
-
|
|
248
|
-
`npm run bench` prints CRDT timings alongside native structure baselines
|
|
249
|
-
(Array/Set/Map/string). Use environment variables like `RUNS`, `SIZE`, `READS`,
|
|
250
|
-
`WRITES`, and `MERGE_SIZE` to tune scale. Compare the CRDT lines to the native
|
|
251
|
-
lines in the output to estimate overhead on your machine. CRDT ops retain
|
|
252
|
-
causal metadata and tombstones, so write-heavy paths will be slower than native
|
|
253
|
-
structures; the baselines show the relative cost.
|
|
254
|
-
|
|
255
|
-
## Advanced exports
|
|
256
|
-
|
|
257
|
-
`CRArray`, `CRMap`, `CRRecord`, `CRRegister`, `CRSet`, and `CRText` are exported
|
|
258
|
-
from the package for building custom CRDT workflows.
|
|
246
|
+
- `npm run verify` runs tests (including type + Playwright), benchmarks, and the simulation in one go.
|
|
247
|
+
|
|
248
|
+
## Benchmarks
|
|
249
|
+
|
|
250
|
+
`npm run bench` prints CRDT timings alongside native structure baselines
|
|
251
|
+
(Array/Set/Map/string). Use environment variables like `RUNS`, `SIZE`, `READS`,
|
|
252
|
+
`WRITES`, and `MERGE_SIZE` to tune scale. Compare the CRDT lines to the native
|
|
253
|
+
lines in the output to estimate overhead on your machine. CRDT ops retain
|
|
254
|
+
causal metadata and tombstones, so write-heavy paths will be slower than native
|
|
255
|
+
structures; the baselines show the relative cost.
|
|
256
|
+
|
|
257
|
+
## Advanced exports
|
|
258
|
+
|
|
259
|
+
`CRArray`, `CRMap`, `CRRecord`, `CRRegister`, `CRSet`, and `CRText` are exported
|
|
260
|
+
from the package for building custom CRDT workflows.
|
package/dist/Dacument/class.js
CHANGED
|
@@ -746,7 +746,7 @@ export class Dacument {
|
|
|
746
746
|
const token = await signToken(this.roleKey, header, payload);
|
|
747
747
|
const actorSig = await Dacument.signActorToken(token);
|
|
748
748
|
const oldDocOps = [{ token, actorSig }];
|
|
749
|
-
this.emitEvent("
|
|
749
|
+
this.emitEvent("delta", { type: "delta", ops: oldDocOps });
|
|
750
750
|
await this.merge(oldDocOps);
|
|
751
751
|
return {
|
|
752
752
|
newDoc,
|
|
@@ -1860,7 +1860,7 @@ export class Dacument {
|
|
|
1860
1860
|
? await Dacument.signActorToken(token, actorSigKey)
|
|
1861
1861
|
: undefined;
|
|
1862
1862
|
const op = actorSig ? { token, actorSig } : { token };
|
|
1863
|
-
this.emitEvent("
|
|
1863
|
+
this.emitEvent("delta", { type: "delta", ops: [op] });
|
|
1864
1864
|
})
|
|
1865
1865
|
.catch((error) => this.emitError(error instanceof Error ? error : new Error(String(error))));
|
|
1866
1866
|
this.pending.add(promise);
|
|
@@ -1879,7 +1879,7 @@ export class Dacument {
|
|
|
1879
1879
|
.then(async (token) => {
|
|
1880
1880
|
const actorSig = await Dacument.signActorToken(token, signingKey);
|
|
1881
1881
|
const op = { token, actorSig };
|
|
1882
|
-
this.emitEvent("
|
|
1882
|
+
this.emitEvent("delta", { type: "delta", ops: [op] });
|
|
1883
1883
|
})
|
|
1884
1884
|
.catch((error) => {
|
|
1885
1885
|
options?.onError?.();
|
package/dist/Dacument/types.d.ts
CHANGED
|
@@ -47,17 +47,20 @@ export type TextSchema = {
|
|
|
47
47
|
jsType: "string";
|
|
48
48
|
initial?: string;
|
|
49
49
|
};
|
|
50
|
+
type KeyFn<T> = {
|
|
51
|
+
bivarianceHack(value: T): string;
|
|
52
|
+
}["bivarianceHack"];
|
|
50
53
|
export type ArraySchema<T extends JsTypeName = JsTypeName> = {
|
|
51
54
|
crdt: "array";
|
|
52
55
|
jsType: T;
|
|
53
56
|
initial?: JsTypeValue<T>[];
|
|
54
|
-
key?:
|
|
57
|
+
key?: KeyFn<JsTypeValue<T>>;
|
|
55
58
|
};
|
|
56
59
|
export type SetSchema<T extends JsTypeName = JsTypeName> = {
|
|
57
60
|
crdt: "set";
|
|
58
61
|
jsType: T;
|
|
59
62
|
initial?: JsTypeValue<T>[];
|
|
60
|
-
key?:
|
|
63
|
+
key?: KeyFn<JsTypeValue<T>>;
|
|
61
64
|
};
|
|
62
65
|
export type MapSchema<T extends JsTypeName = JsTypeName> = {
|
|
63
66
|
crdt: "map";
|
|
@@ -98,8 +101,8 @@ export type ResetState = {
|
|
|
98
101
|
newDocId: string;
|
|
99
102
|
reason?: string;
|
|
100
103
|
};
|
|
101
|
-
export type
|
|
102
|
-
type: "
|
|
104
|
+
export type DacumentDeltaEvent = {
|
|
105
|
+
type: "delta";
|
|
103
106
|
ops: SignedOp[];
|
|
104
107
|
};
|
|
105
108
|
export type DacumentMergeEvent = {
|
|
@@ -129,7 +132,7 @@ export type DacumentResetEvent = {
|
|
|
129
132
|
reason?: string;
|
|
130
133
|
};
|
|
131
134
|
export type DacumentEventMap = {
|
|
132
|
-
|
|
135
|
+
delta: DacumentDeltaEvent;
|
|
133
136
|
merge: DacumentMergeEvent;
|
|
134
137
|
error: DacumentErrorEvent;
|
|
135
138
|
revoked: DacumentRevokedEvent;
|
|
@@ -219,7 +222,9 @@ export type MapView<V> = {
|
|
|
219
222
|
export type RecordView<T> = Record<string, T> & {};
|
|
220
223
|
export type FieldValue<F extends FieldSchema> = F["crdt"] extends "register" ? JsTypeValue<F["jsType"]> : F["crdt"] extends "text" ? TextView : F["crdt"] extends "array" ? ArrayView<JsTypeValue<F["jsType"]>> : F["crdt"] extends "set" ? SetView<JsTypeValue<F["jsType"]>> : F["crdt"] extends "map" ? MapView<JsTypeValue<F["jsType"]>> : F["crdt"] extends "record" ? RecordView<JsTypeValue<F["jsType"]>> : never;
|
|
221
224
|
export type DocFieldAccess<S extends SchemaDefinition> = {
|
|
222
|
-
[K in keyof S]: FieldValue<S[K]>;
|
|
225
|
+
[K in keyof S as S[K]["crdt"] extends "register" ? K : never]: FieldValue<S[K]>;
|
|
226
|
+
} & {
|
|
227
|
+
readonly [K in keyof S as S[K]["crdt"] extends "register" ? never : K]: FieldValue<S[K]>;
|
|
223
228
|
};
|
|
224
229
|
export declare function isJsValue(value: unknown): value is JsValue;
|
|
225
230
|
export declare function isValueOfType(value: unknown, jsType: JsTypeName): boolean;
|
|
@@ -256,3 +261,4 @@ export declare function record<T extends JsTypeName>(options: {
|
|
|
256
261
|
jsType: T;
|
|
257
262
|
initial?: Record<string, JsTypeValue<T>>;
|
|
258
263
|
}): RecordSchema<T>;
|
|
264
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dacument",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Schema-driven CRDT document with signed ops, role-based ACLs, and per-actor auditability.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"crdt",
|
|
@@ -19,9 +19,11 @@
|
|
|
19
19
|
"scripts": {
|
|
20
20
|
"build": "tsc -p tsconfig.json",
|
|
21
21
|
"test": "npm run build && node --test test/*.test.js",
|
|
22
|
+
"test:types": "tsc -p tsconfig.types.json",
|
|
23
|
+
"test:playwright": "npm run build && playwright test",
|
|
22
24
|
"bench": "npm run build && node bench/index.js",
|
|
23
25
|
"sim": "npm run build && node bench/dacument.sim.js",
|
|
24
|
-
"verify": "npm run test && npm run bench && npm run sim"
|
|
26
|
+
"verify": "npm run test && npm run test:types && npm run test:playwright && npm run bench && npm run sim"
|
|
25
27
|
},
|
|
26
28
|
"main": "dist/index.js",
|
|
27
29
|
"types": "dist/index.d.ts",
|
|
@@ -56,6 +58,7 @@
|
|
|
56
58
|
},
|
|
57
59
|
"devDependencies": {
|
|
58
60
|
"@types/node": "^25.0.3",
|
|
61
|
+
"playwright": "^1.57.0",
|
|
59
62
|
"typescript": "^5.9.3"
|
|
60
63
|
}
|
|
61
64
|
}
|