@cero-base/cero 0.8.10 → 1.1.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.
@@ -1,4 +1,4 @@
1
- import AbortController from 'abort-controller'
1
+ import AbortController from 'bare-abort-controller'
2
2
  import b4a from 'b4a'
3
3
  import Hypercore from 'hypercore'
4
4
  import { discoveryKey } from 'hypercore-crypto'
@@ -9,8 +9,10 @@ import z32 from 'z32'
9
9
  import { Identity } from '@cero-base/core/identity'
10
10
  import { Database } from '@cero-base/core/database'
11
11
  import { Pairing } from '@cero-base/core/pairing'
12
- import { toId } from '@cero-base/core/utils'
12
+ import { toId, grants } from '@cero-base/core/utils'
13
13
  import { CeroError } from '@cero-base/core/errors'
14
+ import { Blobs } from '@cero-base/core/blobs'
15
+ import { FileServer } from '@cero-base/core/blobs/server'
14
16
 
15
17
  import { NS, TIMEOUT } from '../lib/constants.js'
16
18
 
@@ -63,7 +65,7 @@ export { Ref } from '../lib/utils.js'
63
65
  * @property {number} [timeout]
64
66
  *
65
67
  * @typedef {object} AcceptOpts
66
- * @property {string} [role] Role to grant the joining peer. Falls back to the invite's role, then `'write'`.
68
+ * @property {string} [role] Role to grant the joining peer. Falls back to the invite's role, then `'member'`.
67
69
  * @property {string | null} [name]
68
70
  *
69
71
  * @typedef {object} RecoverOpts
@@ -75,6 +77,8 @@ export { Ref } from '../lib/utils.js'
75
77
  * @property {import('../lib/utils.js').Ref} [members] `members` ref, attached dynamically when the schema declares one.
76
78
  *
77
79
  * @typedef {Handle & HandleExtra} Child A child handle plus its dynamically-attached refs.
80
+ *
81
+ * @typedef {Handle & Record<string, import('../lib/utils.js').Ref>} CeroHandle A handle with every schema ref reachable as a `Ref` property (e.g. `me.profile`, `room.messages`).
78
82
  */
79
83
 
80
84
  /**
@@ -113,6 +117,9 @@ export class Handle extends ReadyResource {
113
117
  this._onerror = this._opts.onerror || safetyCatch
114
118
  this.children = parent ? null : new Set()
115
119
  this._loading = parent ? null : new Map()
120
+ this._coreKeys = parent ? null : new Map()
121
+ this._fileServer = null
122
+ this._blobs = null
116
123
  this._owned = new Set()
117
124
 
118
125
  this.store = new Database({
@@ -124,7 +131,8 @@ export class Handle extends ReadyResource {
124
131
  key: opts.key,
125
132
  encryptionKey: opts.encryptionKey,
126
133
  namespace: opts.namespace,
127
- keyPair: opts.keyPair
134
+ keyPair: opts.keyPair,
135
+ onerror: this._onerror
128
136
  })
129
137
  this.pair = null
130
138
  this._wantsPair = opts.pair !== false
@@ -136,16 +144,21 @@ export class Handle extends ReadyResource {
136
144
  this.pair = new Pairing({
137
145
  network: this.network,
138
146
  identity: this.identity,
139
- topic: this.store.key
147
+ topic: this.store.key,
148
+ onerror: this._onerror
140
149
  })
141
150
  await this.pair.ready()
142
151
  }
143
152
  attachRefs(this, this.store.refs)
153
+ this.root._coreKeys.set(b4a.toString(this.store.key, 'hex'), this.store.encryptionKey)
154
+ if (!this.parent) await this.fileServer.listen()
144
155
  }
145
156
 
146
157
  async _close() {
158
+ this.root._coreKeys.delete(b4a.toString(this.store.key, 'hex'))
147
159
  for (const r of [...this._owned]) r.destroy?.()
148
160
  this._owned.clear()
161
+ if (this._blobs) await this._blobs.close()
149
162
  if (this.children) {
150
163
  for (const c of [...this.children]) await c.close()
151
164
  this.children.clear()
@@ -159,6 +172,10 @@ export class Handle extends ReadyResource {
159
172
  }
160
173
 
161
174
  if (this.local) await this.local.close()
175
+ if (this._fileServer) {
176
+ await this._fileServer.close()
177
+ this._fileServer = null
178
+ }
162
179
  const store = this.store.store
163
180
  await this.store.close()
164
181
  if (this._discovery) await this._discovery.destroy()
@@ -217,6 +234,79 @@ export class Handle extends ReadyResource {
217
234
  return this
218
235
  }
219
236
 
237
+ /** The top-most handle in the parent chain — itself for a root handle. */
238
+ get root() {
239
+ let h = this
240
+ while (h.parent) h = h.parent
241
+ return h
242
+ }
243
+
244
+ /**
245
+ * Lazily-built file server for this identity. Root-only — child handles
246
+ * reach it through `this.root.fileServer`.
247
+ *
248
+ * @returns {FileServer}
249
+ */
250
+ get fileServer() {
251
+ if (this.parent) return this.root.fileServer
252
+ if (!this._fileServer) {
253
+ this._fileServer = new FileServer({
254
+ store: this.store.store,
255
+ resolve: (coreKey, info) => this._resolveCore(coreKey, info)
256
+ })
257
+ }
258
+ return this._fileServer
259
+ }
260
+
261
+ /**
262
+ * @param {Uint8Array} coreKey
263
+ * @param {object} info
264
+ * @returns {{ key: Uint8Array, encryptionKey: Uint8Array } | null}
265
+ */
266
+ _resolveCore(coreKey, info) {
267
+ const hex = b4a.toString(coreKey, 'hex')
268
+ const encryptionKey = this.root._coreKeys.get(hex)
269
+ if (encryptionKey !== undefined) return { key: coreKey, encryptionKey }
270
+ return null
271
+ }
272
+
273
+ /**
274
+ * Resolve a durable file id to an ephemeral download url via this identity's
275
+ * file server.
276
+ *
277
+ * @param {string} id
278
+ * @returns {string}
279
+ */
280
+ getLink(id) {
281
+ return this.root.fileServer.getLink(id)
282
+ }
283
+
284
+ /**
285
+ * Lazily-built blob store for THIS handle's writing device. One core per
286
+ * writer, encrypted with the handle's encryptionKey and attached to the
287
+ * shared network for replication.
288
+ *
289
+ * @returns {Blobs}
290
+ */
291
+ get blobs() {
292
+ if (!this._blobs) {
293
+ this._blobs = new Blobs({
294
+ store: this.store.store,
295
+ network: this.network,
296
+ encryptionKey: this.store.encryptionKey
297
+ })
298
+ this._blobs
299
+ .ready()
300
+ .then(() => {
301
+ if (this._blobs && this._blobs.key) {
302
+ this.root._coreKeys.set(b4a.toString(this._blobs.key, 'hex'), this.store.encryptionKey)
303
+ }
304
+ })
305
+ .catch(() => {})
306
+ }
307
+ return this._blobs
308
+ }
309
+
220
310
  /** Canonical id — identity id for the root handle, store key for children. */
221
311
  get id() {
222
312
  if (!this.parent) return this.identity.id
@@ -249,11 +339,10 @@ export class Handle extends ReadyResource {
249
339
  * Claim writer capability on an existing database (paired-device flow).
250
340
  * Forwards to `Database.claim`.
251
341
  *
252
- * @param {{ name?: string | null, isMobile?: boolean }} [opts]
253
342
  * @returns {Promise<void>}
254
343
  */
255
- claim(opts) {
256
- return this.store.claim(opts)
344
+ claim() {
345
+ return this.store.claim()
257
346
  }
258
347
 
259
348
  /**
@@ -277,6 +366,10 @@ export class Handle extends ReadyResource {
277
366
  * @returns {Promise<string>} Z32-encoded invite string.
278
367
  */
279
368
  invite(opts) {
369
+ if (!this.pair)
370
+ throw CeroError.INVALID(
371
+ 'invite() is not available on the root handle — open a child handle first'
372
+ )
280
373
  return this.pair.createInvite(opts)
281
374
  }
282
375
 
@@ -287,6 +380,7 @@ export class Handle extends ReadyResource {
287
380
  * @returns {boolean} `true` if the invite was found and removed.
288
381
  */
289
382
  revoke(invite) {
383
+ if (!this.pair) throw CeroError.INVALID('revoke() is not available on the root handle')
290
384
  return this.pair.revoke(invite)
291
385
  }
292
386
 
@@ -305,7 +399,11 @@ export class Handle extends ReadyResource {
305
399
  'candidate userData must be a 64-byte buffer (identity + writer pubkey)'
306
400
  )
307
401
  }
308
- role = role || candidate.invite.role || 'write'
402
+ if (candidate.invite.expired) throw CeroError.EXPIRED()
403
+ role = role || candidate.invite.role || 'member'
404
+ if (candidate.invite.role && !grants(candidate.invite.role, role)) {
405
+ throw CeroError.INVALID(`role '${role}' exceeds the invite role '${candidate.invite.role}'`)
406
+ }
309
407
 
310
408
  await candidate.confirm({ key: this.store.key, encryptionKey: this.store.encryptionKey })
311
409
 
@@ -320,12 +418,12 @@ export class Handle extends ReadyResource {
320
418
  updatedAt: ts
321
419
  }
322
420
 
323
- if (role === 'read') {
421
+ if (role === 'reader') {
324
422
  await this.store.call('add-member', member)
325
423
  return
326
424
  }
327
425
 
328
- const sig = this.identity.sign(writerKey)
426
+ const sig = this.identity.sign(b4a.concat([writerKey, this.store.writerKey]))
329
427
  await this.store.tx(async () => {
330
428
  await this.store.call('add-writer', {
331
429
  sig,
@@ -368,42 +466,49 @@ export class Handle extends ReadyResource {
368
466
  keyPair: writer
369
467
  })
370
468
  )
371
- await child.ready()
372
- child.name = name
469
+ // a failure after the child opens must close it — else it leaks its Database,
470
+ // swarm session and attached bee, never reaped by the parent (not yet a child)
471
+ try {
472
+ await child.ready()
473
+ child.name = name
373
474
 
374
- const id = toId(child.store.key)
375
- await this._saveKeyPair(id, writer)
475
+ const id = toId(child.store.key)
476
+ await this._saveKeyPair(id, writer)
376
477
 
377
- const ts = Date.now()
378
- const writerKey = child.store.writerKey
379
- await child.store.call('add-writer', {
380
- master: this.identity.publicKey,
381
- writer: writerKey,
382
- sig: this.identity.sign(writerKey)
383
- })
384
- await child.store.call('add-member', {
385
- id: this.identity.id,
386
- key: writerKey,
387
- role: 'owner',
388
- name: null,
389
- createdAt: ts,
390
- updatedAt: ts
391
- })
392
- await this.store.call('add-handle', {
393
- id,
394
- type,
395
- key: child.store.key,
396
- encryptionKey: child.store.encryptionKey,
397
- name,
398
- createdAt: ts,
399
- updatedAt: ts
400
- })
478
+ const ts = Date.now()
479
+ const writerKey = child.store.writerKey
480
+ await child.store.call('add-writer', {
481
+ master: this.identity.publicKey,
482
+ writer: writerKey,
483
+ sig: this.identity.sign(b4a.concat([writerKey, child.store.writerKey]))
484
+ })
485
+ await child.store.call('add-member', {
486
+ id: this.identity.id,
487
+ key: writerKey,
488
+ role: 'owner',
489
+ name: null,
490
+ createdAt: ts,
491
+ updatedAt: ts
492
+ })
493
+ await this.store.call('add-handle', {
494
+ id,
495
+ type,
496
+ key: child.store.key,
497
+ encryptionKey: child.store.encryptionKey,
498
+ name,
499
+ createdAt: ts,
500
+ updatedAt: ts
501
+ })
401
502
 
402
- if (accept !== false) this._wireAccept(child, { role })
403
- bind(child, type)
404
- this.children.add(child)
405
- this.emit('handle', child, { name, role })
406
- return child
503
+ if (accept !== false) this._wireAccept(child, { role })
504
+ bind(child, type)
505
+ this.children.add(child)
506
+ this.emit('handle', child, { name, role })
507
+ return child
508
+ } catch (err) {
509
+ await child.close().catch(safetyCatch)
510
+ throw err
511
+ }
407
512
  }
408
513
 
409
514
  /**
@@ -439,27 +544,34 @@ export class Handle extends ReadyResource {
439
544
  timeout
440
545
  })
441
546
  )
442
- await child.ready()
443
- if (!child.store.writable) await child.store.whenWritable({ timeout: deadline })
444
-
445
- const id = toId(child.store.key)
446
- await this._saveKeyPair(id, child.store.keyPair)
447
-
448
- const ts = Date.now()
449
- await this.store.call('add-handle', {
450
- id,
451
- type,
452
- key: child.store.key,
453
- encryptionKey: child.store.encryptionKey,
454
- name: null,
455
- createdAt: ts,
456
- updatedAt: ts
457
- })
458
- this._wireAccept(child)
459
- bind(child, type)
460
- this.children.add(child)
461
- this.emit('handle', child, {})
462
- return child
547
+ // whenWritable timing out (host offline) is a normal failure — close the
548
+ // fully-opened child rather than leak its Database/pairing/swarm session
549
+ try {
550
+ await child.ready()
551
+ if (!child.store.writable) await child.store.whenWritable({ timeout: deadline })
552
+
553
+ const id = toId(child.store.key)
554
+ await this._saveKeyPair(id, child.store.keyPair)
555
+
556
+ const ts = Date.now()
557
+ await this.store.call('add-handle', {
558
+ id,
559
+ type,
560
+ key: child.store.key,
561
+ encryptionKey: child.store.encryptionKey,
562
+ name: null,
563
+ createdAt: ts,
564
+ updatedAt: ts
565
+ })
566
+ this._wireAccept(child)
567
+ bind(child, type)
568
+ this.children.add(child)
569
+ this.emit('handle', child, {})
570
+ return child
571
+ } catch (err) {
572
+ await child.close().catch(safetyCatch)
573
+ throw err
574
+ }
463
575
  }
464
576
 
465
577
  /**
@@ -511,10 +623,7 @@ export class Handle extends ReadyResource {
511
623
  })
512
624
  await child.ready()
513
625
  if (firstTime && !child.store.writable) {
514
- await child.store.claim({
515
- name: this._opts.name || null,
516
- isMobile: this._opts.isMobile === true
517
- })
626
+ await child.store.claim()
518
627
  }
519
628
  if (firstTime) {
520
629
  await this._saveKeyPair(id, writer)
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import HypercoreStorage from 'hypercore-storage'
2
2
  import Corestore from 'corestore'
3
+ import safetyCatch from 'safety-catch'
3
4
  import fs from 'fs'
4
5
 
5
6
  import { Identity } from '@cero-base/core/identity'
@@ -20,9 +21,9 @@ import {
20
21
  before,
21
22
  after,
22
23
  bind,
23
- define,
24
- peek
24
+ define
25
25
  } from './lib/operators.js'
26
+ import { peek } from './lib/peek.js'
26
27
  import { t, schema } from './lib/spec.js'
27
28
  import { FLUSH } from './lib/constants.js'
28
29
  import { internal } from './lib/internal.js'
@@ -40,11 +41,15 @@ export {
40
41
  before,
41
42
  after,
42
43
  bind,
43
- define,
44
- peek
44
+ define
45
45
  } from './lib/operators.js'
46
+ export { peek } from './lib/peek.js'
46
47
  export { t, schema } from './lib/spec.js'
47
48
 
49
+ /**
50
+ * @typedef {import('./handle/index.js').CeroHandle} CeroHandle
51
+ */
52
+
48
53
  /**
49
54
  * @typedef {object} CeroOpts
50
55
  * @property {Identity} [identity] Pre-resolved identity. If absent, derived from `seed`/`phrase` or generated.
@@ -54,6 +59,7 @@ export { t, schema } from './lib/spec.js'
54
59
  * @property {string | null} [name] Friendly device name persisted on the identity claim.
55
60
  * @property {boolean} [isMobile] Marks this device as mobile.
56
61
  * @property {Array<{ host: string, port: number }>} [bootstrap] Custom DHT bootstrap nodes.
62
+ * @property {string} [channel] Optional network-isolation label; only same-channel peers connect.
57
63
  * @property {Uint8Array} [key] Pre-existing database key (skip bootstrap).
58
64
  * @property {Uint8Array} [encryptionKey] Pre-existing encryption key.
59
65
  * @property {Record<string, Function>} [routes] Custom RPC routes for the database dispatcher.
@@ -70,87 +76,112 @@ export { t, schema } from './lib/spec.js'
70
76
  * @param {string} dir Data directory.
71
77
  * @param {any} spec Built spec — output of `cero/build`.
72
78
  * @param {CeroOpts} [opts]
73
- * @returns {Promise<Handle>}
79
+ * @returns {Promise<CeroHandle>}
74
80
  */
75
81
  export async function cero(dir, spec, opts = {}) {
76
82
  if (typeof dir !== 'string' || !dir) throw CeroError.INVALID('dir must be a non-empty string')
77
83
  if (!spec) throw CeroError.REQUIRED('spec')
78
84
 
79
85
  const storage = new HypercoreStorage(`${dir}/main`)
80
- await storage.ready()
81
- const store = new Corestore(storage, { manifestVersion: 2 })
82
- await store.ready()
83
-
86
+ let store = null
84
87
  let local = null
85
- if (spec.local && spec.meta?.local) {
86
- local = new Local(null, spec, { store })
87
- await local.ready()
88
- }
88
+ let network = null
89
+ let discovery = null
90
+ let me = null
91
+ // Any failure during open (bad phrase, network/bootstrap error, recovery
92
+ // timeout, extension setup) must close whatever opened — else the corestore
93
+ // lock leaks and a retry in the same process hits locked storage.
94
+ try {
95
+ await storage.ready()
96
+ store = new Corestore(storage, { manifestVersion: 2 })
97
+ await store.ready()
89
98
 
90
- const identity = await resolveIdentity(opts, local)
91
- const writer = local ? (await local.store.get('keypair')).data : null
92
-
93
- const network = new Network({ bootstrap: opts.bootstrap })
94
- await network.ready()
95
- const discovery = network.join(identity.topic)
96
- await Promise.race([discovery.flush(), new Promise((r) => setTimeout(r, FLUSH))])
97
-
98
- const me = new Handle({
99
- storage,
100
- store,
101
- local,
102
- discovery,
103
- identity,
104
- network,
105
- spec,
106
- opts,
107
- dir,
108
- routes: opts.routes,
109
- key: opts.key,
110
- encryptionKey: opts.encryptionKey,
111
- keyPair: writer ? { publicKey: writer.publicKey, secretKey: writer.secretKey } : undefined,
112
- pair: false
113
- })
114
- await me.ready()
99
+ if (spec.local && spec.meta?.local) {
100
+ local = new Local(null, spec, { store })
101
+ await local.ready()
102
+ }
115
103
 
116
- if (!writer && me.store.length === 0 && (!opts.key || opts.recovery)) {
117
- const result = await me.bootstrap({
118
- name: opts.name || null,
119
- isMobile: opts.isMobile === true,
120
- recovering: opts.recovery === true
121
- })
122
- if (local && result?.writer) {
123
- await local.store.set('keypair', {
124
- publicKey: result.writer.publicKey,
125
- secretKey: result.writer.secretKey
126
- })
104
+ const identity = await resolveIdentity(opts, local)
105
+ const writer = local ? (await local.store.get('keypair')).data : null
106
+
107
+ // Channel stamp: a storage remembers its channel; reopening it under a different channel is a
108
+ // misconfiguration that could leak data across networks, so reject it. Only when a channel is
109
+ // set (no channel = global, no stamp, unchanged).
110
+ if (local && opts.channel) {
111
+ const stored = (await local.store.get('environment')).data?.channel
112
+ if (stored == null) await local.store.set('environment', { channel: opts.channel })
113
+ else if (stored !== opts.channel) throw CeroError.CHANNEL_MISMATCH()
127
114
  }
128
- if (!opts.recovery) {
129
- const ts = Date.now()
130
- await me.store.call('add-member', {
131
- id: identity.id,
132
- key: me.store.writerKey,
133
- role: 'owner',
115
+
116
+ network = new Network({ bootstrap: opts.bootstrap, channel: opts.channel })
117
+ await network.ready()
118
+ discovery = network.join(identity.topic)
119
+ await Promise.race([discovery.flush(), new Promise((r) => setTimeout(r, FLUSH))])
120
+
121
+ me = new Handle({
122
+ storage,
123
+ store,
124
+ local,
125
+ discovery,
126
+ identity,
127
+ network,
128
+ spec,
129
+ opts,
130
+ dir,
131
+ routes: opts.routes,
132
+ key: opts.key,
133
+ encryptionKey: opts.encryptionKey,
134
+ keyPair: writer ? { publicKey: writer.publicKey, secretKey: writer.secretKey } : undefined,
135
+ pair: false
136
+ })
137
+ await me.ready()
138
+
139
+ if (!writer && me.store.length === 0 && (!opts.key || opts.recovery)) {
140
+ const result = await me.bootstrap({
134
141
  name: opts.name || null,
135
- createdAt: ts,
136
- updatedAt: ts
142
+ isMobile: opts.isMobile === true,
143
+ recovering: opts.recovery === true
137
144
  })
145
+ if (local && result?.writer) {
146
+ await local.store.set('keypair', {
147
+ publicKey: result.writer.publicKey,
148
+ secretKey: result.writer.secretKey
149
+ })
150
+ }
151
+ if (!opts.recovery) {
152
+ const ts = Date.now()
153
+ await me.store.call('add-member', {
154
+ id: identity.id,
155
+ key: me.store.writerKey,
156
+ role: 'owner',
157
+ name: opts.name || null,
158
+ createdAt: ts,
159
+ updatedAt: ts
160
+ })
161
+ }
138
162
  }
139
- }
140
- if (opts.recovery) await me.recover({ timeout: opts.recoveryTimeout })
163
+ if (opts.recovery) await me.recover({ timeout: opts.recoveryTimeout })
141
164
 
142
- try {
143
165
  for (const ext of internal.extensions) {
144
166
  const off = await ext.setup?.(me)
145
167
  if (typeof off === 'function') me.once('close', off)
146
168
  }
169
+
170
+ bind(me, null)
171
+ return me
147
172
  } catch (err) {
148
- await me.close().catch(() => {}) // a failed setup must not leak the half-built instance
173
+ // once the root Handle exists it owns (and closes) everything; before that,
174
+ // tear the raw resources down in reverse order.
175
+ if (me) await me.close().catch(safetyCatch)
176
+ else {
177
+ await discovery?.destroy().catch(safetyCatch)
178
+ await network?.close().catch(safetyCatch)
179
+ await local?.close().catch(safetyCatch)
180
+ await store?.close().catch(safetyCatch)
181
+ await storage.close().catch(safetyCatch)
182
+ }
149
183
  throw err
150
184
  }
151
-
152
- bind(me, null)
153
- return me
154
185
  }
155
186
 
156
187
  /**
@@ -16,5 +16,6 @@ export const DB_TYPE = {
16
16
  bytes: 'buffer',
17
17
  json: 'json',
18
18
  fixed32: 'fixed32',
19
- fixed64: 'fixed64'
19
+ fixed64: 'fixed64',
20
+ file: 'string'
20
21
  }