@cero-base/cero 0.4.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.
@@ -0,0 +1,600 @@
1
+ import b4a from 'b4a'
2
+ import Hypercore from 'hypercore'
3
+ import ReadyResource from 'ready-resource'
4
+ import safetyCatch from 'safety-catch'
5
+ import z32 from 'z32'
6
+
7
+ import { Identity } from '@cero-base/core/identity'
8
+ import { Database } from '@cero-base/core/database'
9
+ import { Pairing } from '@cero-base/core/pairing'
10
+ import { toId } from '@cero-base/core/utils'
11
+ import { CeroError } from '@cero-base/core/errors'
12
+
13
+ import { attachRefs } from '../lib/utils.js'
14
+
15
+ export { Ref } from '../lib/utils.js'
16
+
17
+ /**
18
+ * @typedef {import('@cero-base/core/network').Network} Network
19
+ * @typedef {import('../local/index.js').Local} Local
20
+ * @typedef {import('@cero-base/core/identity').KeyPair} KeyPair
21
+ *
22
+ * @typedef {object} HandleOpts
23
+ * @property {Handle} [parent] Parent handle when this is a child slot.
24
+ * @property {Identity} [identity] Long-lived user identity. Inherited from `parent` if omitted.
25
+ * @property {Network} [network] Shared swarm. Inherited from `parent` if omitted.
26
+ * @property {any} [store] Pre-existing Corestore. Falls back to `parent.store.store`.
27
+ * @property {any} [spec] Built cero spec.
28
+ * @property {Local} [local] Local store for per-handle keypairs.
29
+ * @property {any} [storage] Owned HypercoreStorage to close on shutdown.
30
+ * @property {any} [discovery] Owned identity Discovery to destroy on shutdown.
31
+ * @property {string} [dir] Data directory (root handles only).
32
+ * @property {any} [opts] Pass-through cero(...) options.
33
+ * @property {Record<string, Function>} [routes]
34
+ * @property {Uint8Array} [key] Existing database key.
35
+ * @property {Uint8Array} [encryptionKey] Existing encryption key.
36
+ * @property {string} [namespace] Corestore namespace.
37
+ * @property {KeyPair} [keyPair] Writer keypair.
38
+ * @property {boolean} [pair] When `false`, skips creating a `Pairing` session.
39
+ *
40
+ * @typedef {object} CreateChildOpts
41
+ * @property {string | null} [name]
42
+ * @property {Record<string, Function>} [routes]
43
+ * @property {string} [role]
44
+ * @property {boolean} [accept]
45
+ *
46
+ * @typedef {object} JoinChildOpts
47
+ * @property {Record<string, Function>} [routes]
48
+ * @property {number} [timeout]
49
+ *
50
+ * @typedef {object} StaticJoinOpts
51
+ * @property {Handle} [parent]
52
+ * @property {Network} [network]
53
+ * @property {Identity} [identity]
54
+ * @property {any} [store]
55
+ * @property {any} [spec]
56
+ * @property {string} [namespace]
57
+ * @property {Record<string, Function>} [routes]
58
+ * @property {number} [timeout]
59
+ *
60
+ * @typedef {object} AcceptOpts
61
+ * @property {string} [role] Role to grant the joining peer. Falls back to the invite's role, then `'write'`.
62
+ * @property {string | null} [name]
63
+ *
64
+ * @typedef {object} RecoverOpts
65
+ * @property {number} [timeout]
66
+ * @property {string | null} [name]
67
+ * @property {boolean} [isMobile]
68
+ *
69
+ * @typedef {object} HandleExtra
70
+ * @property {string | null} [name] Display name; set on child handles by the owner flow.
71
+ * @property {import('../lib/utils.js').Ref} [profile] `profile` ref, attached dynamically when the schema declares one.
72
+ * @property {import('../lib/utils.js').Ref} [members] `members` ref, attached dynamically when the schema declares one.
73
+ *
74
+ * @typedef {Handle & HandleExtra} Child A child handle plus its dynamically-attached refs.
75
+ */
76
+
77
+ /**
78
+ * A cero handle — a single writable database session attached to a swarm.
79
+ * The "root" handle is the user's facade; child handles (created via
80
+ * `_create`/`_join`/`_load`) live under it and share the same identity,
81
+ * network and corestore. Ref properties (`profile`, `members`, ...) are
82
+ * attached dynamically per the schema; child handles also carry a `name`.
83
+ */
84
+ export class Handle extends ReadyResource {
85
+ /** @param {HandleOpts} [opts] */
86
+ constructor(opts = {}) {
87
+ super()
88
+
89
+ const { parent = null } = opts
90
+ const identity = opts.identity || parent?.identity
91
+ const network = opts.network || parent?.network
92
+ const store = opts.store || parent?.store?.store
93
+ const spec = opts.spec
94
+
95
+ if (!identity) throw CeroError.REQUIRED('identity')
96
+ if (!network) throw CeroError.REQUIRED('network')
97
+ if (!store) throw CeroError.REQUIRED('store')
98
+ if (!spec) throw CeroError.REQUIRED('spec')
99
+
100
+ this.identity = identity
101
+ this.network = network
102
+ this.spec = spec
103
+ this.parent = parent
104
+
105
+ this.local = opts.local || null
106
+ this._storage = opts.storage || null
107
+ this._discovery = opts.discovery || null
108
+ this._dir = opts.dir || null
109
+ this._opts = opts.opts || {}
110
+ this._onerror = this._opts.onerror || safetyCatch
111
+ this.children = parent ? null : new Set()
112
+
113
+ this.store = new Database({
114
+ store: store,
115
+ identity,
116
+ network,
117
+ spec,
118
+ routes: opts.routes,
119
+ key: opts.key,
120
+ encryptionKey: opts.encryptionKey,
121
+ namespace: opts.namespace,
122
+ keyPair: opts.keyPair
123
+ })
124
+ this.pair = null
125
+ this._wantsPair = opts.pair !== false
126
+ }
127
+
128
+ async _open() {
129
+ await this.store.ready()
130
+ if (this._wantsPair) {
131
+ this.pair = new Pairing({
132
+ network: this.network,
133
+ identity: this.identity,
134
+ topic: this.store.key
135
+ })
136
+ await this.pair.ready()
137
+ }
138
+ attachRefs(this, this.store.refs)
139
+ if (!this.children) return
140
+
141
+ this._offUpdate = this.store.onUpdate(() => {
142
+ if (this.closing || this.closed) return
143
+ for (const c of this.children) this._syncMember(c).catch(this._onerror)
144
+ })
145
+ }
146
+
147
+ async _close() {
148
+ this._offUpdate?.()
149
+ if (this.children) {
150
+ for (const c of [...this.children]) await c.close()
151
+ this.children.clear()
152
+ }
153
+ if (this.pair) await this.pair.close()
154
+
155
+ if (this.parent) {
156
+ this.parent.children?.delete(this)
157
+ await this.store.close()
158
+ return
159
+ }
160
+
161
+ if (this.local) await this.local.close()
162
+ const store = this.store.store
163
+ await this.store.close()
164
+ if (this._discovery) await this._discovery.destroy()
165
+ await this.network.close()
166
+ await store.close()
167
+ if (this._storage) await this._storage.close()
168
+ }
169
+
170
+ /** Canonical id — identity id for the root handle, store key for children. */
171
+ get id() {
172
+ if (!this.parent) return this.identity.id
173
+ return this.store?.key ? toId(this.store.key) : null
174
+ }
175
+
176
+ /** This device's id + name. `null` on child handles. */
177
+ get device() {
178
+ if (this.parent) return null
179
+ const k = this.store?.writerKey
180
+ return k ? { id: toId(k), name: this._opts.name || null } : null
181
+ }
182
+
183
+ get suspended() {
184
+ return this._suspended === true
185
+ }
186
+
187
+ /**
188
+ * Initialise a fresh database: write the genesis claim, derive the writer.
189
+ * Forwards to `Database.bootstrap`.
190
+ *
191
+ * @param {any} [opts]
192
+ * @returns {Promise<any>}
193
+ */
194
+ bootstrap(opts) {
195
+ return this.store.bootstrap(opts)
196
+ }
197
+
198
+ /**
199
+ * Claim writer capability on an existing database (paired-device flow).
200
+ * Forwards to `Database.claim`.
201
+ *
202
+ * @param {{ name?: string | null, isMobile?: boolean }} [opts]
203
+ * @returns {Promise<void>}
204
+ */
205
+ claim(opts) {
206
+ return this.store.claim(opts)
207
+ }
208
+
209
+ /**
210
+ * Claim + wait until this peer becomes a writer + bring the bee up to date.
211
+ * Used by `restore()` after wiping local state.
212
+ *
213
+ * @param {RecoverOpts} [opts]
214
+ * @returns {Promise<void>}
215
+ */
216
+ async recover({ timeout = 30000, name = null, isMobile = false } = {}) {
217
+ if (this.store.writable) return
218
+ await this.store.claim({ name, isMobile })
219
+ await this.store.whenWritable({ timeout })
220
+ await this.store.bee.update()
221
+ }
222
+
223
+ /**
224
+ * Mint a pairing invite for this handle.
225
+ *
226
+ * @param {{ role?: string, expiresIn?: number, data?: any }} [opts]
227
+ * @returns {Promise<string>} Z32-encoded invite string.
228
+ */
229
+ invite(opts) {
230
+ return this.pair.createInvite(opts)
231
+ }
232
+
233
+ /**
234
+ * Revoke a previously-minted invite by its string form.
235
+ *
236
+ * @param {string} invite
237
+ * @returns {boolean} `true` if the invite was found and removed.
238
+ */
239
+ revoke(invite) {
240
+ return this.pair.revoke(invite)
241
+ }
242
+
243
+ /**
244
+ * Accept a paired candidate — adds them as a writer (or read-only member)
245
+ * and confirms the pairing so they receive this handle's keys.
246
+ *
247
+ * @param {any} candidate
248
+ * @param {AcceptOpts} [opts]
249
+ * @returns {Promise<void>}
250
+ */
251
+ async accept(candidate, { role, name } = {}) {
252
+ const data = candidate.userData
253
+ if (!b4a.isBuffer(data) || data.length !== 64) {
254
+ throw CeroError.INVALID(
255
+ 'candidate userData must be a 64-byte buffer (identity + writer pubkey)'
256
+ )
257
+ }
258
+ role = role || candidate.invite.role || 'write'
259
+
260
+ await candidate.confirm({ key: this.store.key, encryptionKey: this.store.encryptionKey })
261
+
262
+ const ts = Date.now()
263
+ const writerKey = Hypercore.key({ version: 2, signers: [{ publicKey: data.subarray(32, 64) }] })
264
+ const member = {
265
+ id: toId(data.subarray(0, 32)),
266
+ key: writerKey,
267
+ role,
268
+ name: name || null,
269
+ createdAt: ts,
270
+ updatedAt: ts
271
+ }
272
+
273
+ if (role === 'read') {
274
+ await this.store.call('add-member', member)
275
+ return
276
+ }
277
+
278
+ const sig = this.identity.sign(writerKey)
279
+ await this.store.tx(async () => {
280
+ await this.store.call('add-writer', {
281
+ sig,
282
+ master: this.identity.publicKey,
283
+ writer: writerKey,
284
+ name: name || null,
285
+ isMobile: false
286
+ })
287
+ await this.store.call('add-member', member)
288
+ })
289
+ }
290
+
291
+ /**
292
+ * Leave a child handle — removes it from the parent's `handles` collection
293
+ * and closes the session. No-op on root handles.
294
+ *
295
+ * @returns {Promise<void>}
296
+ */
297
+ async leave() {
298
+ if (!this.parent) return
299
+ await this.parent.store.call('del-handle', { id: toId(this.store.key) })
300
+ await this.close()
301
+ }
302
+
303
+ /**
304
+ * Create a new child handle of `type`. Owner-flow — generates a fresh
305
+ * writer, adds it as a writer + member, and registers the child on the
306
+ * parent's `handles` collection.
307
+ *
308
+ * @param {string} type
309
+ * @param {CreateChildOpts} [opts]
310
+ * @returns {Promise<Handle>}
311
+ */
312
+ async _create(type, { name = null, routes, role, accept } = {}) {
313
+ const writer = Identity.randomKeyPair()
314
+ const child = /** @type {Child} */ (
315
+ new Handle({
316
+ parent: this,
317
+ spec: pickHandle(this.spec, type),
318
+ namespace: `cero/handle/${type}/${writer.id}`,
319
+ routes,
320
+ keyPair: writer
321
+ })
322
+ )
323
+ await child.ready()
324
+ child.name = name
325
+
326
+ const id = toId(child.store.key)
327
+ await this._saveKeyPair(id, writer)
328
+
329
+ const ts = Date.now()
330
+ const writerKey = child.store.writerKey
331
+ await child.store.call('add-writer', {
332
+ master: this.identity.publicKey,
333
+ writer: writerKey,
334
+ sig: this.identity.sign(writerKey),
335
+ name,
336
+ isMobile: false
337
+ })
338
+ await child.store.call('add-member', {
339
+ id: this.identity.id,
340
+ key: writerKey,
341
+ role: 'owner',
342
+ name: null,
343
+ createdAt: ts,
344
+ updatedAt: ts
345
+ })
346
+ if (name && child.profile) await child.store.set('profile', { name })
347
+ await this.store.call('add-handle', {
348
+ id,
349
+ type,
350
+ key: child.store.key,
351
+ encryptionKey: child.store.encryptionKey,
352
+ name,
353
+ createdAt: ts,
354
+ updatedAt: ts
355
+ })
356
+
357
+ if (accept !== false) this._wireAccept(child, { role })
358
+ this.children.add(child)
359
+ await this._syncMember(child)
360
+ return child
361
+ }
362
+
363
+ /**
364
+ * Join a child handle by invite (joiner-flow). Waits for writer
365
+ * capability and registers the child on the parent's `handles`
366
+ * collection.
367
+ *
368
+ * @param {string} invite
369
+ * @param {string} type
370
+ * @param {JoinChildOpts} [opts]
371
+ * @returns {Promise<Handle>}
372
+ */
373
+ async _join(invite, type, { routes, timeout } = {}) {
374
+ const deadline = timeout || 30000
375
+ const child = /** @type {Child} */ (
376
+ await Handle.join(invite, {
377
+ parent: this,
378
+ spec: pickHandle(this.spec, type),
379
+ namespace: `cero/handle/${type}/${randomNs()}`,
380
+ routes,
381
+ timeout
382
+ })
383
+ )
384
+ await child.ready()
385
+ if (!child.store.writable) await child.store.whenWritable({ timeout: deadline })
386
+
387
+ const id = toId(child.store.key)
388
+ await this._saveKeyPair(id, child.store.keyPair)
389
+
390
+ const name = child.profile ? await waitForProfileName(child, deadline) : null
391
+ const ts = Date.now()
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
+ })
401
+ this._wireAccept(child)
402
+ this.children.add(child)
403
+ await this._syncMember(child)
404
+ return child
405
+ }
406
+
407
+ /**
408
+ * Re-open an existing child handle by id. Reuses the stored writer
409
+ * keypair if available; otherwise generates a fresh one and claims
410
+ * writer capability.
411
+ *
412
+ * @param {string} type
413
+ * @param {string} id
414
+ * @returns {Promise<Handle>}
415
+ */
416
+ async _load(type, id) {
417
+ for (const c of this.children) if (c.id === id) return c
418
+ const { data } = await this.store.get('handles', id)
419
+ if (!data) throw CeroError.UNKNOWN('handle', id)
420
+ if (data.type !== type) throw new Error(`handle ${id} is type ${data.type}, not ${type}`)
421
+ let writer = await this._loadKeyPair(id)
422
+ const firstTime = !writer
423
+ if (firstTime) {
424
+ writer = Identity.randomKeyPair()
425
+ }
426
+ const child = new Handle({
427
+ parent: this,
428
+ spec: pickHandle(this.spec, type),
429
+ namespace: `cero/handle/${type}/${id}`,
430
+ key: data.key,
431
+ encryptionKey: data.encryptionKey,
432
+ keyPair: /** @type {KeyPair} */ (writer)
433
+ })
434
+ await child.ready()
435
+ if (firstTime && !child.store.writable) {
436
+ await child.store.claim({
437
+ name: this._opts.name || null,
438
+ isMobile: this._opts.isMobile === true
439
+ })
440
+ }
441
+ if (firstTime) {
442
+ await this._saveKeyPair(id, writer)
443
+ }
444
+ this._wireAccept(child)
445
+ this.children.add(child)
446
+ return child
447
+ }
448
+
449
+ /**
450
+ * Pause networking + storage. Idempotent; no-op on child handles.
451
+ *
452
+ * @returns {Promise<void>}
453
+ */
454
+ async suspend() {
455
+ if (this.parent || this.closing || this.closed || this._suspended) return
456
+ this._suspended = true
457
+ for (const c of this.children) if (c.pair) await c.pair.suspend()
458
+ await this.network.suspend()
459
+ try {
460
+ await this.store.store.suspend()
461
+ } catch (err) {
462
+ this._onerror(err)
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Resume a suspended root handle. Idempotent; no-op on child handles.
468
+ *
469
+ * @returns {Promise<void>}
470
+ */
471
+ async resume() {
472
+ if (this.parent || this.closing || this.closed || !this._suspended) return
473
+ this._suspended = false
474
+ try {
475
+ await this.store.store.resume()
476
+ } catch (err) {
477
+ this._onerror(err)
478
+ }
479
+ await this.network.resume()
480
+ await Promise.all([...this.children].map((child) => child.pair?.resume()))
481
+ }
482
+
483
+ /**
484
+ * Pair into an existing handle via an invite, returning a brand-new
485
+ * `Handle` already configured with the resolved key + encryption key.
486
+ *
487
+ * @param {string} invite
488
+ * @param {StaticJoinOpts} [opts]
489
+ * @returns {Promise<Handle>}
490
+ */
491
+ static async join(
492
+ invite,
493
+ { parent, network, identity, store, spec, namespace, routes, timeout = 30000 } = {}
494
+ ) {
495
+ const net = network || parent?.network
496
+ const id = identity || parent?.identity
497
+ if (!net) throw new TypeError('network is required')
498
+ if (!id) throw new TypeError('identity is required')
499
+ if (!store && !parent) throw new TypeError('store is required')
500
+ if (!spec) throw new TypeError('spec is required')
501
+
502
+ const writer = Identity.randomKeyPair()
503
+ const pair = new Pairing({ network: net, identity: id })
504
+ await pair.ready()
505
+
506
+ let key, encryptionKey
507
+ try {
508
+ const userData = b4a.concat([id.publicKey, writer.publicKey])
509
+ ;({ key, encryptionKey } = await pair.join(invite, { userData, timeout }))
510
+ } finally {
511
+ await pair.close()
512
+ }
513
+
514
+ return new Handle({
515
+ parent,
516
+ store,
517
+ identity: id,
518
+ network: net,
519
+ spec,
520
+ namespace,
521
+ routes,
522
+ key,
523
+ encryptionKey,
524
+ keyPair: writer
525
+ })
526
+ }
527
+
528
+ async _syncMember(child) {
529
+ if (!child.store.writable) return
530
+ const { data: profile } = await this.store.get('profile')
531
+ if (!profile) return
532
+ const { data: existing } = await child.store.get('members', this.identity.id)
533
+ if (!existing) return
534
+ await child.store.call('set-member', {
535
+ ...existing,
536
+ ...profile,
537
+ id: existing.id,
538
+ updatedAt: Date.now()
539
+ })
540
+ }
541
+
542
+ /**
543
+ * @param {Handle} child
544
+ * @param {{ role?: string }} [opts]
545
+ */
546
+ _wireAccept(child, { role } = {}) {
547
+ child.pair.on('candidate', (cand) => {
548
+ if (this.closing || this.closed || child.closing || child.closed) return
549
+ child.accept(cand, { role }).catch(this._onerror)
550
+ })
551
+ }
552
+
553
+ /**
554
+ * @param {string} id
555
+ * @param {KeyPair | { publicKey: Uint8Array, secretKey: Uint8Array } | null} keyPair
556
+ * @returns {Promise<void>}
557
+ */
558
+ async _saveKeyPair(id, keyPair) {
559
+ if (!this.local || !keyPair) return
560
+ await this.local.store.put('handle-keypairs', {
561
+ id,
562
+ publicKey: keyPair.publicKey,
563
+ secretKey: keyPair.secretKey
564
+ })
565
+ }
566
+
567
+ /**
568
+ * @param {string} id
569
+ * @returns {Promise<{ publicKey: Uint8Array, secretKey: Uint8Array } | null>}
570
+ */
571
+ async _loadKeyPair(id) {
572
+ if (!this.local) return null
573
+ const { data } = await this.local.store.get('handle-keypairs', id)
574
+ if (!data) return null
575
+ return {
576
+ publicKey: data.publicKey,
577
+ secretKey: data.secretKey
578
+ }
579
+ }
580
+ }
581
+
582
+ function pickHandle(spec, type) {
583
+ const h = spec.handles?.[type]
584
+ if (!h) throw CeroError.UNKNOWN('handle type', type)
585
+ return h
586
+ }
587
+
588
+ function randomNs() {
589
+ return z32.encode(Identity.randomBytes(8))
590
+ }
591
+
592
+ async function waitForProfileName(child, timeout) {
593
+ const deadline = Date.now() + timeout
594
+ while (Date.now() < deadline) {
595
+ const { data } = await child.store.get('profile')
596
+ if (data?.name) return data.name
597
+ await new Promise((r) => setTimeout(r, 100))
598
+ }
599
+ return null
600
+ }