@cero-base/cero 1.5.2 → 1.7.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 CHANGED
@@ -216,6 +216,75 @@ await cero.open(me.room, { id: existingRoomId }) // load an existing room by id
216
216
 
217
217
  Returns the child handle, ready to operate on (`cero.put(joined.messages, …)` etc.).
218
218
 
219
+ ### `cero.rotate(handle)`
220
+
221
+ Rotate the handle's encryption epoch. A fresh secret is sealed to every current
222
+ member and announced through the log — members removed **before** the rotation
223
+ cannot decrypt anything written after it (rows **and** files). Requires the
224
+ `remove` permission (admin or owner). Returns `{ epoch }`.
225
+
226
+ ```js
227
+ await cero.del(room.members, memberId) // revoke write access
228
+ await cero.rotate(room) // revoke read access for everything that follows
229
+ ```
230
+
231
+ Rotation is opt-in per room: without it, `del` alone is a soft removal (write
232
+ revocation only). Once a room has rotated at least once, later removals
233
+ self-heal — a `del` without an explicit `rotate` triggers an automatic re-key
234
+ from any online admin device. Standalone rotations (no removal) are valid too,
235
+ as periodic key hygiene. Full design: [`docs/key-rotation.md`](../../docs/key-rotation.md).
236
+
237
+ ### Watching removals
238
+
239
+ There is no separate removal event — `members` is a collection, so the normal
240
+ streams already are the membership feed. A removal arrives as an ordinary
241
+ delete (`next: null`), carrying the row that was removed:
242
+
243
+ ```js
244
+ for await (const { changes } of cero.changes(room.members)) {
245
+ for (const { prev, next } of changes) {
246
+ if (next === null) console.log('removed:', prev.name)
247
+ }
248
+ }
249
+ ```
250
+
251
+ To detect **your own** removal — the signal an app renders as "you were removed
252
+ from this room" — listen for the store losing writability:
253
+
254
+ ```js
255
+ room.store.on('unwritable', () => onRemoved()) // freeze the UI, close the room
256
+ ```
257
+
258
+ This works even when the removal is followed by a rotation: the removal lands
259
+ before the new key, so a removed member always receives it. After that their
260
+ streams stay open but go silent — reads freeze at the moment of removal and
261
+ writes reject with `NOT_WRITABLE`.
262
+
263
+ ### Store events
264
+
265
+ Beyond the ref streams, the store reports its own lifecycle:
266
+
267
+ ```js
268
+ room.store.on('writable', () => {}) // admitted — this device can write
269
+ room.store.on('unwritable', () => {}) // access ended (removed)
270
+ room.store.on('update', () => {}) // an apply batch committed
271
+ room.store.on('behind', (v) => {}) // ops from a newer app version — see below
272
+ room.store.on('rebuild', () => {}) // view replayed after catching up
273
+ ```
274
+
275
+ `before`/`after` hooks only fire on the device performing the write. To observe
276
+ **every** applied op — local _and_ replicated — use `onApply`, which returns an
277
+ unsubscribe fn:
278
+
279
+ ```js
280
+ const off = room.store.onApply(({ op, name, row, writerKey, seq }) => {
281
+ if (op === 'del' && name === 'member') auditLog(row)
282
+ })
283
+ ```
284
+
285
+ The callback runs synchronously inside apply, so keep it cheap — enqueue and
286
+ return. It costs nothing when nobody is subscribed.
287
+
219
288
  ### `cero.before(ref, fn, opts?)` / `cero.after(ref, fn, opts?)`
220
289
 
221
290
  Hook into writes to a ref. `before` runs **in-path** before the write commits — return `false` to cancel, or mutate `ctx.row`. `after` is a non-blocking **event** that fires once the write has committed. Both return an unsubscribe fn — call it to stop early, ignore it for a subscription that lives as long as the handle, or pass `{ signal }` to unsubscribe when an `AbortSignal` fires (e.g. `{ signal: me.signal }` to stop on close).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cero-base/cero",
3
- "version": "1.5.2",
3
+ "version": "1.7.0",
4
4
  "description": "The ideal p2p API — everything is a handle, handles contain refs, refs contain rows.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -95,7 +95,7 @@
95
95
  "test:node": "ls test/*.test.js | xargs -P1 -n1 brittle-node"
96
96
  },
97
97
  "dependencies": {
98
- "@cero-base/core": "^1.5.2",
98
+ "@cero-base/core": "^1.7.0",
99
99
  "b4a": "^1.8.1",
100
100
  "bare-abort-controller": "^1.1.2",
101
101
  "bare-crypto": "^1.15.3",
@@ -2,7 +2,7 @@
2
2
  // maps into the type / collection / dispatch / command descriptors the builder
3
3
  // registers. Every cero schema includes these; app refs are added on top.
4
4
  import { CeroError } from '@cero-base/core/errors'
5
- import { COUNTERS, DB_TYPE } from '../lib/constants.js'
5
+ import { COUNTERS, EPOCHS, DB_TYPE } from '../lib/constants.js'
6
6
  import * as schemas from './schemas.js'
7
7
 
8
8
  // Builtin collections by scope — ref name → { type, kind? }. kind defaults to
@@ -79,7 +79,10 @@ export const builtinCollections = (ns, scope) => {
79
79
  schema: at(ns, def.type),
80
80
  key: keyOf(def)
81
81
  }))
82
- if (scope === 'main') out.push({ name: COUNTERS, schema: at(ns, 'counter'), key: ['name'] })
82
+ if (scope === 'main') {
83
+ out.push({ name: COUNTERS, schema: at(ns, 'counter'), key: ['name'] })
84
+ out.push({ name: EPOCHS, schema: at(ns, 'epoch'), key: ['epoch'] })
85
+ }
83
86
  return out
84
87
  }
85
88
 
@@ -95,6 +98,13 @@ export const builtinDispatches = (ns) => [
95
98
  ])
96
99
  ]
97
100
 
101
+ // Registered AFTER the app's own dispatches (see build/index.js): hyperdispatch
102
+ // numbers routes positionally and persists them, so a spec built before
103
+ // rotation must see rotate-key appended at the end — inserting it into the
104
+ // builtin group would collide with the app's persisted route ids on an
105
+ // incremental rebuild.
106
+ export const rotateDispatch = (ns) => ({ name: 'rotate-key', requestType: at(ns, 'epoch') })
107
+
98
108
  export const rpcCommands = (ns) => {
99
109
  const ref = (n) => at(ns, n)
100
110
  return [
@@ -145,6 +155,7 @@ export const rpcCommands = (ns) => {
145
155
  name: 'changes',
146
156
  request: { name: ref('req-query') },
147
157
  response: { name: ref('res-changes'), stream: true }
148
- }
158
+ },
159
+ { name: 'rotate', request: { name: ref('req-handle') }, response: { name: ref('res-epoch') } }
149
160
  ]
150
161
  }
@@ -16,6 +16,7 @@ import {
16
16
  builtinTypes,
17
17
  builtinCollections,
18
18
  builtinDispatches,
19
+ rotateDispatch,
19
20
  rpcTypes,
20
21
  rpcCommands,
21
22
  getHyperdbType
@@ -272,6 +273,7 @@ function emitMain(dir, ns, { types, collections, dispatches, indexes = [] }, { r
272
273
  const xns = d.namespace(ns)
273
274
  for (const desc of builtinDispatches(ns)) xns.register(desc)
274
275
  for (const desc of dispatches) xns.register(desc)
276
+ xns.register(rotateDispatch(ns))
275
277
  Hyperdispatch.toDisk(d, dispatchDir, { esm: true })
276
278
 
277
279
  if (rpc) {
@@ -66,13 +66,21 @@ export const main = {
66
66
  name: string,
67
67
  createdAt: int,
68
68
  updatedAt: int,
69
- index: uint
69
+ index: uint,
70
+ stamp: uint
70
71
  },
71
72
  claim: {
72
73
  identity: required(bytes),
73
74
  writer: required(bytes),
74
75
  sig: required(bytes),
75
76
  ts: int
77
+ },
78
+ epoch: {
79
+ epoch: required(uint),
80
+ wrapped: required(bytes),
81
+ createdAt: int,
82
+ commit: bytes,
83
+ stamp: uint
76
84
  }
77
85
  }
78
86
 
@@ -188,5 +196,8 @@ export const rpc = {
188
196
  },
189
197
  'res-ok': {
190
198
  ok: bool
199
+ },
200
+ 'res-epoch': {
201
+ epoch: required(uint)
191
202
  }
192
203
  }
@@ -1,5 +1,6 @@
1
1
  import AbortController from 'bare-abort-controller'
2
2
  import b4a from 'b4a'
3
+ import c from 'compact-encoding'
3
4
  import Hypercore from 'hypercore'
4
5
  import { discoveryKey } from 'hypercore-crypto'
5
6
  import ReadyResource from 'ready-resource'
@@ -8,6 +9,7 @@ import z32 from 'z32'
8
9
 
9
10
  import { Identity } from '@cero-base/core/identity'
10
11
  import { Database } from '@cero-base/core/database'
12
+ import { epochEntries, blobEpochKey } from '@cero-base/core/database/encryption'
11
13
  import { Pairing } from '@cero-base/core/pairing'
12
14
  import { toId, grants, addWriterPayload } from '@cero-base/core/utils'
13
15
  import { CeroError } from '@cero-base/core/errors'
@@ -40,6 +42,7 @@ export { Ref } from '../lib/utils.js'
40
42
  * @property {Record<string, Function>} [routes]
41
43
  * @property {Uint8Array} [key] Existing database key.
42
44
  * @property {Uint8Array} [encryptionKey] Existing encryption key.
45
+ * @property {Array<{ epoch: number, entropy: Uint8Array }>} [epochs] Rotation epochs delivered at join.
43
46
  * @property {string} [namespace] Corestore namespace.
44
47
  * @property {KeyPair} [keyPair] Writer keypair.
45
48
  * @property {boolean} [passive] Join discovery server-only; flip later with `setActive`.
@@ -121,6 +124,7 @@ export class Handle extends ReadyResource {
121
124
  this._coreKeys = parent ? null : new Map()
122
125
  this._fileServer = null
123
126
  this._blobs = null
127
+ this._epochBlobs = null
124
128
  this._owned = new Set()
125
129
 
126
130
  this.store = new Database({
@@ -131,6 +135,7 @@ export class Handle extends ReadyResource {
131
135
  routes: opts.routes,
132
136
  key: opts.key,
133
137
  encryptionKey: opts.encryptionKey,
138
+ epochs: opts.epochs,
134
139
  namespace: opts.namespace,
135
140
  keyPair: opts.keyPair,
136
141
  passive: opts.passive,
@@ -159,10 +164,15 @@ export class Handle extends ReadyResource {
159
164
  async _close() {
160
165
  this.root._coreKeys.delete(b4a.toString(this.store.key, 'hex'))
161
166
  if (this._blobs?.key) this.root._coreKeys.delete(b4a.toString(this._blobs.key, 'hex'))
167
+ for (const b of this._epochBlobs?.values() || []) {
168
+ if (b.key) this.root._coreKeys.delete(b4a.toString(b.key, 'hex'))
169
+ }
162
170
  for (const hex of this._blobKeys || []) this.root._coreKeys.delete(hex)
163
171
  for (const r of [...this._owned]) r.destroy?.()
164
172
  this._owned.clear()
165
173
  if (this._blobs) await this._blobs.close()
174
+ for (const b of this._epochBlobs?.values() || []) await b.close().catch(safetyCatch)
175
+ this._epochBlobs = null
166
176
  if (this.children) {
167
177
  for (const c of [...this.children]) await c.close()
168
178
  this.children.clear()
@@ -286,31 +296,49 @@ export class Handle extends ReadyResource {
286
296
  }
287
297
 
288
298
  /**
289
- * Lazily-built blob store for THIS handle's writing device. One core per
290
- * writer, encrypted with the handle's encryptionKey and attached to the
291
- * shared network for replication.
299
+ * Lazily-built blob store for THIS handle's writing device, at the current
300
+ * rotation epoch. One core per writer per epoch that writes files: the base
301
+ * era uses the handle's encryptionKey, rotated epochs use a key derived
302
+ * from the epoch entropy — so a removed member cannot decrypt files added
303
+ * after the rotation. Instances carry `.stamp` so the file row records
304
+ * which era its core belongs to.
292
305
  *
293
306
  * @returns {Blobs}
294
307
  */
295
308
  get blobs() {
296
- if (!this._blobs) {
297
- this._blobs = new Blobs({
298
- store: this.store.store,
299
- network: this.network,
300
- encryptionKey: this.store.encryptionKey
301
- })
302
- this._blobs
303
- .ready()
304
- .then(() => {
305
- if (this._blobs && this._blobs.key) {
306
- this.root._coreKeys.set(b4a.toString(this._blobs.key, 'hex'), this.store.encryptionKey)
307
- }
308
- })
309
- .catch(this._onerror)
309
+ const stamp = this.store.keyring.current
310
+ if (!stamp) return this._baseBlobs()
311
+ let blobs = this._epochBlobs?.get(stamp)
312
+ if (!blobs) {
313
+ const entropy = this.store.keyring.entropy(stamp)
314
+ blobs = this._makeBlobs(`blobs-${stamp}`, blobEpochKey(entropy), stamp)
315
+ ;(this._epochBlobs ??= new Map()).set(stamp, blobs)
310
316
  }
317
+ return blobs
318
+ }
319
+
320
+ _baseBlobs() {
321
+ if (!this._blobs) this._blobs = this._makeBlobs('blobs', this.store.encryptionKey, 0)
311
322
  return this._blobs
312
323
  }
313
324
 
325
+ _makeBlobs(name, encryptionKey, stamp) {
326
+ const blobs = new Blobs({
327
+ store: this.store.store,
328
+ network: this.network,
329
+ encryptionKey,
330
+ name
331
+ })
332
+ blobs.stamp = stamp
333
+ blobs
334
+ .ready()
335
+ .then(() => {
336
+ if (blobs.key) this.root._coreKeys.set(b4a.toString(blobs.key, 'hex'), encryptionKey)
337
+ })
338
+ .catch(this._onerror)
339
+ return blobs
340
+ }
341
+
314
342
  /** Canonical id — identity id for the root handle, store key for children. */
315
343
  get id() {
316
344
  if (!this.parent) return this.identity.id
@@ -426,7 +454,13 @@ export class Handle extends ReadyResource {
426
454
  // joiner times out. So the key is revealed before the membership writes
427
455
  // land; if they fail the joiner holds the key un-admitted, which is
428
456
  // recoverable (re-pair) and surfaced via onerror in _wireAccept.
429
- await candidate.confirm({ key: this.store.key, encryptionKey: this.store.encryptionKey })
457
+ // Epoch secrets ride along so a post-rotation joiner reads full history.
458
+ const epochs = this.store.keyring.all()
459
+ await candidate.confirm({
460
+ key: this.store.key,
461
+ encryptionKey: this.store.encryptionKey,
462
+ additional: epochs.length ? c.encode(epochEntries, epochs) : null
463
+ })
430
464
 
431
465
  const ts = Date.now()
432
466
  const writerKey = Hypercore.key({ version: 2, signers: [{ publicKey: data.subarray(32, 64) }] })
@@ -487,7 +521,10 @@ export class Handle extends ReadyResource {
487
521
  spec: pickHandle(this.spec, type),
488
522
  namespace: `${NS}/handle/${type}/${writer.id}`,
489
523
  routes,
490
- keyPair: writer
524
+ keyPair: writer,
525
+ // each room gets its own key — inheriting identity.encryptionKey would let
526
+ // any member decrypt every room this identity ever created, plus its root db
527
+ encryptionKey: Identity.randomBytes(32)
491
528
  })
492
529
  )
493
530
  // a failure after the child opens must close it — else it leaks its Database,
@@ -551,15 +588,24 @@ export class Handle extends ReadyResource {
551
588
  async _join(invite, type, { routes, timeout } = {}) {
552
589
  const deadline = timeout || TIMEOUT
553
590
 
554
- // Idempotent: if the invite targets a handle we already have, return it
555
- // without pairing, so there's no waiting on a peer to confirm.
591
+ // Idempotent: if the invite targets a handle we already have AND we are
592
+ // still a member of it, return it — without pairing, so there's no
593
+ // waiting on a peer to confirm. If our member row is gone (we were
594
+ // removed), the stored writer is revoked and the old session can never
595
+ // become writable again — holding a fresh invite is exactly the
596
+ // re-admission path, so fall through to a real pairing instead.
556
597
  const target = Pairing.inviteTopic(invite)
557
598
  if (target) {
558
599
  const { data: joined } = await this.store.get('handles')
559
600
  const existing = joined.find(
560
601
  (h) => h.type === type && b4a.equals(discoveryKey(h.key), target)
561
602
  )
562
- if (existing) return this._load(type, existing.id)
603
+ if (existing) {
604
+ const known = await this._load(type, existing.id)
605
+ const { data: me } = await known.store.get('members', this.identity.id)
606
+ if (me) return known
607
+ await known.close().catch(safetyCatch)
608
+ }
563
609
  }
564
610
 
565
611
  // offline join: with nearby sync on, also rendezvous on the invite-derived
@@ -724,14 +770,26 @@ export class Handle extends ReadyResource {
724
770
  const pair = new Pairing({ network: net, identity: id })
725
771
  await pair.ready()
726
772
 
727
- let key, encryptionKey
773
+ let key, encryptionKey, additional
728
774
  try {
729
775
  const userData = b4a.concat([id.publicKey, writer.publicKey])
730
- ;({ key, encryptionKey } = await pair.join(invite, { userData, timeout }))
776
+ ;({ key, encryptionKey, additional } = await pair.join(invite, { userData, timeout }))
731
777
  } finally {
732
778
  await pair.close()
733
779
  }
734
780
 
781
+ let epochs = null
782
+ if (additional?.byteLength) {
783
+ // the epoch set is load-bearing for a rotated room — a malformed
784
+ // delivery must fail the join loudly, not produce a silently inert
785
+ // member (hosts predating rotation send no additional payload at all)
786
+ try {
787
+ epochs = c.decode(epochEntries, additional)
788
+ } catch {
789
+ throw CeroError.INVALID('malformed epoch delivery in pairing confirm')
790
+ }
791
+ }
792
+
735
793
  return new Handle({
736
794
  parent,
737
795
  store,
@@ -742,6 +800,7 @@ export class Handle extends ReadyResource {
742
800
  routes,
743
801
  key,
744
802
  encryptionKey,
803
+ epochs,
745
804
  keyPair: writer
746
805
  })
747
806
  }
package/src/index.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  changes,
21
21
  call,
22
22
  open,
23
+ rotate,
23
24
  before,
24
25
  after,
25
26
  bind,
@@ -41,6 +42,7 @@ export {
41
42
  changes,
42
43
  call,
43
44
  open,
45
+ rotate,
44
46
  before,
45
47
  after,
46
48
  bind,
@@ -285,6 +287,7 @@ cero.watch = watch
285
287
  cero.changes = changes
286
288
  cero.call = call
287
289
  cero.open = open
290
+ cero.rotate = rotate
288
291
  cero.before = before
289
292
  cero.after = after
290
293
  cero.peek = peek
@@ -2,6 +2,7 @@
2
2
 
3
3
  export const NS = 'cero'
4
4
  export const COUNTERS = 'counters'
5
+ export const EPOCHS = 'epochs'
5
6
 
6
7
  // Writer-admission / pairing timeout (ms), and the initial discovery-flush wait (ms).
7
8
  export const TIMEOUT = 30000
@@ -2,6 +2,7 @@ import { Readable } from 'streamx'
2
2
  import b4a from 'b4a'
3
3
 
4
4
  import { encodeId, decodeId } from '@cero-base/core/blobs/codec'
5
+ import { blobEpochKey } from '@cero-base/core/database/encryption'
5
6
  import { CeroError } from '@cero-base/core/errors'
6
7
  import { onAbort } from './utils.js'
7
8
 
@@ -45,10 +46,11 @@ async function putFile(ref, row) {
45
46
  const handle = ref.handle
46
47
  if (handle.rpc) return handle.put('files', row)
47
48
  const { data, type, name = null } = row
48
- await handle.blobs.ready()
49
- const blobId = await handle.blobs.put(data)
50
- const id = encodeId(handle.blobs.key, blobId, type)
51
- await handle.store.call('add-file', { id, name })
49
+ const blobs = handle.blobs // captured once — the instance carries its epoch stamp
50
+ await blobs.ready()
51
+ const blobId = await blobs.put(data)
52
+ const id = encodeId(blobs.key, blobId, type)
53
+ await handle.store.call('add-file', { id, name, stamp: blobs.stamp || 0 })
52
54
  return { data: resolveFile(handle, id, name) }
53
55
  }
54
56
 
@@ -166,12 +168,12 @@ function resolveRow(ref, row) {
166
168
  if (!row || typeof row !== 'object') return row
167
169
  if (ref.handle.rpc) return ref.handle._resolveRow(ref.name, ref.handle._refInfo(ref.name), row)
168
170
  const handle = ref.handle
169
- const resolve = (id, name) => {
170
- registerBlobCore(handle, id)
171
+ const resolve = (id, name, stamp) => {
172
+ registerBlobCore(handle, id, stamp)
171
173
  return resolveFile(handle, id, name)
172
174
  }
173
175
  if (ref.name === 'files') {
174
- return { ...row, ...resolve(row.id, row.name) }
176
+ return { ...row, ...resolve(row.id, row.name, row.stamp) }
175
177
  }
176
178
  const fields = handle.store.refs?.[ref.name]?.files
177
179
  if (!fields || !fields.length) return row
@@ -184,13 +186,24 @@ function resolveRow(ref, row) {
184
186
  return out
185
187
  }
186
188
 
187
- function registerBlobCore(handle, id) {
189
+ function registerBlobCore(handle, id, stamp) {
188
190
  if (!id || !handle.root?._coreKeys) return
189
191
  try {
190
192
  const { coreKey } = decodeId(id)
191
193
  const hex = b4a.toString(coreKey, 'hex')
192
194
  if (!handle.root._coreKeys.has(hex)) {
193
- handle.root._coreKeys.set(hex, handle.root.store.encryptionKey)
195
+ // a file-field value carries no stamp — look it up from the files row
196
+ // (fire-and-forget: idempotent, resolution happens again per request)
197
+ if (stamp === undefined) {
198
+ handle.store
199
+ .get('files', id)
200
+ .then(({ data }) => data && registerBlobCore(handle, id, data.stamp || 0))
201
+ .catch(() => {})
202
+ return
203
+ }
204
+ const key = blobCoreKey(handle, stamp)
205
+ if (!key) return // unknown epoch — this device is not entitled to the core
206
+ handle.root._coreKeys.set(hex, key)
194
207
  }
195
208
  // remember which handle read it, so close prunes the entry (re-registered
196
209
  // on the next read if another handle still serves the same core)
@@ -200,6 +213,14 @@ function registerBlobCore(handle, id) {
200
213
  }
201
214
  }
202
215
 
216
+ // Base-era blob cores use the OWNING handle's key (not the root's — rooms have
217
+ // their own keys); rotated-era cores derive from the epoch entropy.
218
+ function blobCoreKey(handle, stamp) {
219
+ if (!stamp) return handle.store.encryptionKey
220
+ const entropy = handle.store.keyring.entropy(stamp)
221
+ return entropy ? blobEpochKey(entropy) : null
222
+ }
223
+
203
224
  /**
204
225
  * Read from `ref`. For data refs, dispatches to the underlying store. For
205
226
  * `handle`-kind refs, lists existing child handles of that type from the
@@ -360,6 +381,20 @@ export const open = (ref, arg) => {
360
381
  return ref.handle._create(ref.name, arg)
361
382
  }
362
383
 
384
+ /**
385
+ * Rotate a handle's encryption epoch. A fresh secret is sealed to every
386
+ * current member and announced through the log — members removed before the
387
+ * rotation cannot decrypt anything written after it. Requires the remove
388
+ * permission (admin or owner). Compose with removal:
389
+ *
390
+ * await cero.del(room.members, memberId)
391
+ * await cero.rotate(room)
392
+ *
393
+ * @param {any} handle
394
+ * @returns {Promise<{ epoch: number }>}
395
+ */
396
+ export const rotate = (handle) => handle.store.rotate()
397
+
363
398
  // ─── custom operators ──────────────────────────────────────────────────────
364
399
  // App business logic lives as custom operators: pure functions whose first arg
365
400
  // is the handle they act on, composed from the operators above. `define`
package/src/rpc/client.js CHANGED
@@ -5,10 +5,22 @@ import z32 from 'z32'
5
5
  import { decodeId } from '@cero-base/core/blobs/codec'
6
6
 
7
7
  import { attachRefs } from '../lib/utils.js'
8
- import { put, set, get, del, count, watch, call, open, bind, define } from '../lib/operators.js'
8
+ import {
9
+ put,
10
+ set,
11
+ get,
12
+ del,
13
+ count,
14
+ watch,
15
+ call,
16
+ open,
17
+ rotate,
18
+ bind,
19
+ define
20
+ } from '../lib/operators.js'
9
21
  import { t, schema } from '../lib/spec.js'
10
22
 
11
- export { put, set, get, del, count, watch, call, open, bind, define, t, schema }
23
+ export { put, set, get, del, count, watch, call, open, rotate, bind, define, t, schema }
12
24
 
13
25
  /**
14
26
  * @typedef {import('@cero-base/core/rpc').RPCClient} BaseRPCClient
@@ -392,6 +404,16 @@ const operators = {
392
404
  async revoke(invite) {
393
405
  const { ok } = await this.rpc.revoke({ handle: this.id, invite })
394
406
  return ok
407
+ },
408
+
409
+ /**
410
+ * Rotate this handle's encryption epoch on the server.
411
+ *
412
+ * @returns {Promise<{ epoch: number }>}
413
+ */
414
+ async rotate() {
415
+ const { epoch } = await this.rpc.rotate({ handle: this.id })
416
+ return { epoch }
395
417
  }
396
418
  }
397
419
 
@@ -598,6 +620,7 @@ cero.count = count
598
620
  cero.watch = watch
599
621
  cero.call = call
600
622
  cero.open = open
623
+ cero.rotate = rotate
601
624
  cero.bind = bind
602
625
  cero.define = define
603
626
  cero.schema = schema
package/src/rpc/server.js CHANGED
@@ -117,11 +117,12 @@ export class Server extends RPCServer {
117
117
  _wireData() {
118
118
  this.rpc.onAddFile(async ({ handle, data, name, type }) => {
119
119
  const h = this._resolve(handle)
120
- await h.blobs.ready()
121
- const blobId = await h.blobs.put(data)
122
- const id = encodeId(h.blobs.key, blobId, type || '')
120
+ const blobs = h.blobs // captured once — the instance carries its epoch stamp
121
+ await blobs.ready()
122
+ const blobId = await blobs.put(data)
123
+ const id = encodeId(blobs.key, blobId, type || '')
123
124
  const codec = h.spec.codec
124
- await h.store.call('add-file', { id, name: name || null })
125
+ await h.store.call('add-file', { id, name: name || null, stamp: blobs.stamp || 0 })
125
126
  const { data: row } = await get(h.files, id)
126
127
  return { data: codec.encodeRow(h.files.schema, row) }
127
128
  })
@@ -281,6 +282,14 @@ export class Server extends RPCServer {
281
282
  return { ok }
282
283
  })
283
284
 
285
+ // specs built before rotation have no rotate command
286
+ if (typeof this.rpc.onRotate === 'function') {
287
+ this.rpc.onRotate(async ({ handle }) => {
288
+ const { epoch } = await this._resolve(handle).store.rotate()
289
+ return { epoch }
290
+ })
291
+ }
292
+
284
293
  this.rpc.onJoin(async ({ parent, ref, invite }) => {
285
294
  if (this._resolve(parent) !== this.me) throw CeroError.UNSUPPORTED('nested handles')
286
295
  const child = await this.me._join(invite, ref)
@@ -75,6 +75,10 @@ export function builtinDispatches(ns: any): {
75
75
  name: string;
76
76
  requestType: string;
77
77
  }[];
78
+ export function rotateDispatch(ns: any): {
79
+ name: string;
80
+ requestType: string;
81
+ };
78
82
  export function rpcCommands(ns: any): ({
79
83
  name: string;
80
84
  request: {
@@ -60,6 +60,7 @@ export const main: {
60
60
  createdAt: import("@cero-base/core").Prim;
61
61
  updatedAt: import("@cero-base/core").Prim;
62
62
  index: import("@cero-base/core").Prim;
63
+ stamp: import("@cero-base/core").Prim;
63
64
  };
64
65
  claim: {
65
66
  identity: import("@cero-base/core").Prim;
@@ -67,6 +68,13 @@ export const main: {
67
68
  sig: import("@cero-base/core").Prim;
68
69
  ts: import("@cero-base/core").Prim;
69
70
  };
71
+ epoch: {
72
+ epoch: import("@cero-base/core").Prim;
73
+ wrapped: import("@cero-base/core").Prim;
74
+ createdAt: import("@cero-base/core").Prim;
75
+ commit: import("@cero-base/core").Prim;
76
+ stamp: import("@cero-base/core").Prim;
77
+ };
70
78
  };
71
79
  export const local: {
72
80
  master: {
@@ -180,4 +188,7 @@ export const rpc: {
180
188
  'res-ok': {
181
189
  ok: import("@cero-base/core").Prim;
182
190
  };
191
+ 'res-epoch': {
192
+ epoch: import("@cero-base/core").Prim;
193
+ };
183
194
  };
@@ -18,6 +18,7 @@ export { Ref } from "../lib/utils.js";
18
18
  * @property {Record<string, Function>} [routes]
19
19
  * @property {Uint8Array} [key] Existing database key.
20
20
  * @property {Uint8Array} [encryptionKey] Existing encryption key.
21
+ * @property {Array<{ epoch: number, entropy: Uint8Array }>} [epochs] Rotation epochs delivered at join.
21
22
  * @property {string} [namespace] Corestore namespace.
22
23
  * @property {KeyPair} [keyPair] Writer keypair.
23
24
  * @property {boolean} [passive] Join discovery server-only; flip later with `setActive`.
@@ -93,6 +94,7 @@ export class Handle extends ReadyResource {
93
94
  _coreKeys: Map<any, any>;
94
95
  _fileServer: FileServer;
95
96
  _blobs: Blobs;
97
+ _epochBlobs: any;
96
98
  _owned: Set<any>;
97
99
  store: Database;
98
100
  pair: Pairing;
@@ -158,13 +160,18 @@ export class Handle extends ReadyResource {
158
160
  */
159
161
  getLink(id: string): string;
160
162
  /**
161
- * Lazily-built blob store for THIS handle's writing device. One core per
162
- * writer, encrypted with the handle's encryptionKey and attached to the
163
- * shared network for replication.
163
+ * Lazily-built blob store for THIS handle's writing device, at the current
164
+ * rotation epoch. One core per writer per epoch that writes files: the base
165
+ * era uses the handle's encryptionKey, rotated epochs use a key derived
166
+ * from the epoch entropy — so a removed member cannot decrypt files added
167
+ * after the rotation. Instances carry `.stamp` so the file row records
168
+ * which era its core belongs to.
164
169
  *
165
170
  * @returns {Blobs}
166
171
  */
167
172
  get blobs(): Blobs;
173
+ _baseBlobs(): Blobs;
174
+ _makeBlobs(name: any, encryptionKey: any, stamp: any): Blobs;
168
175
  /** Canonical id — identity id for the root handle, store key for children. */
169
176
  get id(): any;
170
177
  /** This device's id + name. `null` on child handles. */
@@ -370,6 +377,13 @@ export type HandleOpts = {
370
377
  * Existing encryption key.
371
378
  */
372
379
  encryptionKey?: Uint8Array;
380
+ /**
381
+ * Rotation epochs delivered at join.
382
+ */
383
+ epochs?: Array<{
384
+ epoch: number;
385
+ entropy: Uint8Array;
386
+ }>;
373
387
  /**
374
388
  * Corestore namespace.
375
389
  */
package/types/index.d.ts CHANGED
@@ -44,6 +44,7 @@ export namespace cero {
44
44
  export { changes };
45
45
  export { call };
46
46
  export { open };
47
+ export { rotate };
47
48
  export { before };
48
49
  export { after };
49
50
  export { peek };
@@ -158,6 +159,7 @@ import { watch } from './lib/operators.js';
158
159
  import { changes } from './lib/operators.js';
159
160
  import { call } from './lib/operators.js';
160
161
  import { open } from './lib/operators.js';
162
+ import { rotate } from './lib/operators.js';
161
163
  import { before } from './lib/operators.js';
162
164
  import { after } from './lib/operators.js';
163
165
  import { peek } from './lib/peek.js';
@@ -170,5 +172,5 @@ import { Ref } from './handle/index.js';
170
172
  import { Local } from './local/index.js';
171
173
  import { Identity } from '@cero-base/core/identity';
172
174
  export { Handle, Ref, Local };
173
- export { put, set, get, del, count, watch, changes, call, open, before, after, bind, define } from "./lib/operators.js";
175
+ export { put, set, get, del, count, watch, changes, call, open, rotate, before, after, bind, define } from "./lib/operators.js";
174
176
  export { t, schema } from "./lib/spec.js";
@@ -1,5 +1,6 @@
1
1
  export const NS: "cero";
2
2
  export const COUNTERS: "counters";
3
+ export const EPOCHS: "epochs";
3
4
  export const TIMEOUT: 30000;
4
5
  export const FLUSH: 500;
5
6
  export namespace DB_TYPE {
@@ -81,6 +81,9 @@ export function open(ref: Ref, arg?: string | {
81
81
  role?: string;
82
82
  accept?: boolean;
83
83
  } | undefined): Promise<CeroHandle>;
84
+ export function rotate(handle: any): Promise<{
85
+ epoch: number;
86
+ }>;
84
87
  export type Ref = import("./utils.js").Ref;
85
88
  export type CeroHandle = import("../handle/index.js").CeroHandle;
86
89
  export type SingleResult = {
@@ -41,6 +41,7 @@ export namespace cero {
41
41
  export { watch };
42
42
  export { call };
43
43
  export { open };
44
+ export { rotate };
44
45
  export { bind };
45
46
  export { define };
46
47
  export { schema };
@@ -141,6 +142,7 @@ import { count } from '../lib/operators.js';
141
142
  import { watch } from '../lib/operators.js';
142
143
  import { call } from '../lib/operators.js';
143
144
  import { open } from '../lib/operators.js';
145
+ import { rotate } from '../lib/operators.js';
144
146
  import { bind } from '../lib/operators.js';
145
147
  import { define } from '../lib/operators.js';
146
148
  import { schema } from '../lib/spec.js';
@@ -189,4 +191,4 @@ declare class Handle {
189
191
  /** Tear down the remote handle and drop membership. */
190
192
  leave(): any;
191
193
  }
192
- export { put, set, get, del, count, watch, call, open, bind, define, t, schema };
194
+ export { put, set, get, del, count, watch, call, open, rotate, bind, define, t, schema };