@cero-base/cero 1.19.0 → 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 +38 -643
- package/package.json +9 -6
- package/src/build/index.js +21 -50
- package/src/build/internal.js +121 -0
- package/src/build/schemas.js +2 -8
- package/src/extensions/handle-sync.js +3 -10
- package/src/extensions/index.js +6 -0
- package/src/extensions/profile-sync.js +0 -5
- package/src/handle/index.js +241 -266
- package/src/index.js +20 -51
- package/src/lib/bluetooth.js +25 -56
- package/src/lib/constants.js +0 -15
- package/src/lib/operators.js +22 -72
- package/src/lib/peek.js +4 -8
- package/src/lib/refs.js +2 -5
- package/src/lib/spec.js +2 -3
- package/src/local/index.js +2 -3
- package/src/rpc/client.js +20 -34
- package/src/rpc/index.js +3 -3
- package/src/rpc/server.js +23 -35
- package/types/build/index.d.ts +1 -9
- package/types/build/{builtins.d.ts → internal.d.ts} +20 -38
- package/types/extensions/handle-sync.d.ts +2 -7
- package/types/extensions/index.d.ts +22 -0
- package/types/extensions/profile-sync.d.ts +0 -4
- package/types/handle/index.d.ts +83 -101
- package/types/index.d.ts +4 -8
- package/types/lib/bluetooth.d.ts +8 -32
- package/types/lib/constants.d.ts +0 -11
- package/types/lib/operators.d.ts +18 -46
- package/types/lib/peek.d.ts +2 -3
- package/types/lib/refs.d.ts +1 -3
- package/types/lib/spec.d.ts +2 -3
- package/types/local/index.d.ts +2 -3
- package/types/rpc/client.d.ts +10 -18
- package/types/rpc/index.d.ts +3 -3
- package/types/rpc/server.d.ts +6 -9
- package/src/build/builtins.js +0 -174
- package/src/lib/internal.js +0 -9
- package/types/lib/internal.d.ts +0 -24
package/src/handle/index.js
CHANGED
|
@@ -87,10 +87,6 @@ export { Ref } from '../lib/refs.js'
|
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
89
|
* A cero handle — a single writable database session attached to a swarm.
|
|
90
|
-
* The "root" handle is the user's facade; child handles (created via
|
|
91
|
-
* `_create`/`_join`/`_load`) live under it and share the same identity,
|
|
92
|
-
* network and corestore. Ref properties (`profile`, `members`, ...) are
|
|
93
|
-
* attached dynamically per the schema; child handles also carry a `name`.
|
|
94
90
|
*/
|
|
95
91
|
export class Handle extends ReadyResource {
|
|
96
92
|
/** @param {HandleOpts} [opts] */
|
|
@@ -150,6 +146,78 @@ export class Handle extends ReadyResource {
|
|
|
150
146
|
this._wantsPair = opts.pair !== false
|
|
151
147
|
}
|
|
152
148
|
|
|
149
|
+
/**
|
|
150
|
+
* An `AbortSignal` that fires when this handle closes.
|
|
151
|
+
*
|
|
152
|
+
* @returns {AbortSignal}
|
|
153
|
+
*/
|
|
154
|
+
get signal() {
|
|
155
|
+
if (!this._ac) {
|
|
156
|
+
this._ac = new AbortController()
|
|
157
|
+
if (this.closed) this._ac.abort()
|
|
158
|
+
else this.once('close', () => this._ac.abort())
|
|
159
|
+
}
|
|
160
|
+
return this._ac.signal
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The top-most handle in the parent chain — itself for a root handle. */
|
|
164
|
+
get root() {
|
|
165
|
+
let h = this
|
|
166
|
+
while (h.parent) h = h.parent
|
|
167
|
+
return h
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Lazily-built file server for this identity. Root-only — child handles
|
|
172
|
+
* reach it through `this.root.fileServer`.
|
|
173
|
+
*
|
|
174
|
+
* @returns {FileServer}
|
|
175
|
+
*/
|
|
176
|
+
get fileServer() {
|
|
177
|
+
if (this.parent) return this.root.fileServer
|
|
178
|
+
if (!this._fileServer) {
|
|
179
|
+
this._fileServer = new FileServer({
|
|
180
|
+
store: this.store.store,
|
|
181
|
+
resolve: (coreKey, info) => this._resolveCore(coreKey, info)
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
return this._fileServer
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Lazily-built blob store for THIS handle's writing device, at the current rotation epoch.
|
|
189
|
+
*
|
|
190
|
+
* @returns {Blobs}
|
|
191
|
+
*/
|
|
192
|
+
get blobs() {
|
|
193
|
+
const stamp = this.store.keyring.current
|
|
194
|
+
if (!stamp) return this._baseBlobs()
|
|
195
|
+
let blobs = this._epochBlobs?.get(stamp)
|
|
196
|
+
if (!blobs) {
|
|
197
|
+
const entropy = this.store.keyring.entropy(stamp)
|
|
198
|
+
blobs = this._makeBlobs(`blobs-${stamp}`, blobEpochKey(entropy), stamp)
|
|
199
|
+
;(this._epochBlobs ??= new Map()).set(stamp, blobs)
|
|
200
|
+
}
|
|
201
|
+
return blobs
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Canonical id — identity id for the root handle, store key for children. */
|
|
205
|
+
get id() {
|
|
206
|
+
if (!this.parent) return this.identity.id
|
|
207
|
+
return this.store?.key ? hid.encode(this.store.key) : null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** This device's id + name. `null` on child handles. */
|
|
211
|
+
get device() {
|
|
212
|
+
if (this.parent) return null
|
|
213
|
+
const k = this.store?.writerKey
|
|
214
|
+
return k ? { id: hid.encode(k), name: this._opts.name || null } : null
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
get suspended() {
|
|
218
|
+
return this._sus?.suspended === true
|
|
219
|
+
}
|
|
220
|
+
|
|
153
221
|
async _open() {
|
|
154
222
|
await this.store.ready()
|
|
155
223
|
if (this._wantsPair) {
|
|
@@ -163,8 +231,7 @@ export class Handle extends ReadyResource {
|
|
|
163
231
|
}
|
|
164
232
|
})
|
|
165
233
|
await this.pair.ready()
|
|
166
|
-
// serve invites persisted by any member,
|
|
167
|
-
// replicate in or disappear (minted, revoked or consumed elsewhere)
|
|
234
|
+
// serve invites persisted by any member, in step with the rows
|
|
168
235
|
await this._syncInvites().catch(safetyCatch)
|
|
169
236
|
this._invitesSync = () => this._syncInvites().catch(safetyCatch)
|
|
170
237
|
this.store.on('update', this._invitesSync)
|
|
@@ -216,9 +283,9 @@ export class Handle extends ReadyResource {
|
|
|
216
283
|
}
|
|
217
284
|
|
|
218
285
|
/**
|
|
219
|
-
* Tie a destroyable resource (a `watch` stream, a timer, any `{ destroy }`)
|
|
220
|
-
*
|
|
221
|
-
*
|
|
286
|
+
* Tie a destroyable resource (a `watch` stream, a timer, any `{ destroy }`) to this
|
|
287
|
+
* handle's lifecycle — it's destroyed automatically on close, so callers don't track
|
|
288
|
+
* cleanup.
|
|
222
289
|
*
|
|
223
290
|
* @template {{ destroy?: Function, once?: Function }} T
|
|
224
291
|
* @param {T} resource
|
|
@@ -234,22 +301,6 @@ export class Handle extends ReadyResource {
|
|
|
234
301
|
return resource
|
|
235
302
|
}
|
|
236
303
|
|
|
237
|
-
/**
|
|
238
|
-
* An `AbortSignal` that fires when this handle closes. Pass it as
|
|
239
|
-
* `{ signal }` to `on`/`after`/`before`/`watch` to drop a subscription on
|
|
240
|
-
* close — or use your own `AbortController` for a finer scope.
|
|
241
|
-
*
|
|
242
|
-
* @returns {AbortSignal}
|
|
243
|
-
*/
|
|
244
|
-
get signal() {
|
|
245
|
-
if (!this._ac) {
|
|
246
|
-
this._ac = new AbortController()
|
|
247
|
-
if (this.closed) this._ac.abort()
|
|
248
|
-
else this.once('close', () => this._ac.abort())
|
|
249
|
-
}
|
|
250
|
-
return this._ac.signal
|
|
251
|
-
}
|
|
252
|
-
|
|
253
304
|
/**
|
|
254
305
|
* `EventEmitter.on` plus an optional `{ signal }` that removes the listener
|
|
255
306
|
* when the signal aborts — e.g. `me.on('handle', fn, { signal: me.signal })`.
|
|
@@ -265,42 +316,6 @@ export class Handle extends ReadyResource {
|
|
|
265
316
|
return this
|
|
266
317
|
}
|
|
267
318
|
|
|
268
|
-
/** The top-most handle in the parent chain — itself for a root handle. */
|
|
269
|
-
get root() {
|
|
270
|
-
let h = this
|
|
271
|
-
while (h.parent) h = h.parent
|
|
272
|
-
return h
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Lazily-built file server for this identity. Root-only — child handles
|
|
277
|
-
* reach it through `this.root.fileServer`.
|
|
278
|
-
*
|
|
279
|
-
* @returns {FileServer}
|
|
280
|
-
*/
|
|
281
|
-
get fileServer() {
|
|
282
|
-
if (this.parent) return this.root.fileServer
|
|
283
|
-
if (!this._fileServer) {
|
|
284
|
-
this._fileServer = new FileServer({
|
|
285
|
-
store: this.store.store,
|
|
286
|
-
resolve: (coreKey, info) => this._resolveCore(coreKey, info)
|
|
287
|
-
})
|
|
288
|
-
}
|
|
289
|
-
return this._fileServer
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* @param {Uint8Array} coreKey
|
|
294
|
-
* @param {object} info
|
|
295
|
-
* @returns {{ key: Uint8Array, encryptionKey: Uint8Array } | null}
|
|
296
|
-
*/
|
|
297
|
-
_resolveCore(coreKey, info) {
|
|
298
|
-
const hex = b4a.toString(coreKey, 'hex')
|
|
299
|
-
const encryptionKey = this.root._coreKeys.get(hex)
|
|
300
|
-
if (encryptionKey !== undefined) return { key: coreKey, encryptionKey }
|
|
301
|
-
return null
|
|
302
|
-
}
|
|
303
|
-
|
|
304
319
|
/**
|
|
305
320
|
* Resolve a durable file id to an ephemeral download url via this identity's
|
|
306
321
|
* file server.
|
|
@@ -312,112 +327,6 @@ export class Handle extends ReadyResource {
|
|
|
312
327
|
return this.root.fileServer.getLink(id)
|
|
313
328
|
}
|
|
314
329
|
|
|
315
|
-
/**
|
|
316
|
-
* Lazily-built blob store for THIS handle's writing device, at the current
|
|
317
|
-
* rotation epoch. One core per writer per epoch that writes files: the base
|
|
318
|
-
* era uses the handle's encryptionKey, rotated epochs use a key derived
|
|
319
|
-
* from the epoch entropy — so a removed member cannot decrypt files added
|
|
320
|
-
* after the rotation. Instances carry `.stamp` so the file row records
|
|
321
|
-
* which era its core belongs to.
|
|
322
|
-
*
|
|
323
|
-
* @returns {Blobs}
|
|
324
|
-
*/
|
|
325
|
-
get blobs() {
|
|
326
|
-
const stamp = this.store.keyring.current
|
|
327
|
-
if (!stamp) return this._baseBlobs()
|
|
328
|
-
let blobs = this._epochBlobs?.get(stamp)
|
|
329
|
-
if (!blobs) {
|
|
330
|
-
const entropy = this.store.keyring.entropy(stamp)
|
|
331
|
-
blobs = this._makeBlobs(`blobs-${stamp}`, blobEpochKey(entropy), stamp)
|
|
332
|
-
;(this._epochBlobs ??= new Map()).set(stamp, blobs)
|
|
333
|
-
}
|
|
334
|
-
return blobs
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
_baseBlobs() {
|
|
338
|
-
if (!this._blobs) this._blobs = this._makeBlobs('blobs', this.store.encryptionKey, 0)
|
|
339
|
-
return this._blobs
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
_makeBlobs(name, encryptionKey, stamp) {
|
|
343
|
-
const blobs = new Blobs({
|
|
344
|
-
store: this.store.store,
|
|
345
|
-
network: this.network,
|
|
346
|
-
encryptionKey,
|
|
347
|
-
name
|
|
348
|
-
})
|
|
349
|
-
blobs.stamp = stamp
|
|
350
|
-
blobs
|
|
351
|
-
.ready()
|
|
352
|
-
.then(() => {
|
|
353
|
-
// close prunes _coreKeys first, a late ready() must not re-insert the entry
|
|
354
|
-
if (this.closing || this.closed || !blobs.key) return
|
|
355
|
-
this.root._coreKeys.set(b4a.toString(blobs.key, 'hex'), encryptionKey)
|
|
356
|
-
})
|
|
357
|
-
.catch(this._onerror)
|
|
358
|
-
return blobs
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/**
|
|
362
|
-
* Remember the blob-core key a file id points at so the file server can
|
|
363
|
-
* open the core. Lives on the Handle (not the shared operators) because the
|
|
364
|
-
* epoch-key derivation pulls native crypto — the RPC client must stay
|
|
365
|
-
* bundleable without it.
|
|
366
|
-
*
|
|
367
|
-
* @param {string} id
|
|
368
|
-
* @param {number} [stamp]
|
|
369
|
-
*/
|
|
370
|
-
_registerBlobCore(id, stamp) {
|
|
371
|
-
if (!id || !this.root?._coreKeys) return
|
|
372
|
-
try {
|
|
373
|
-
const { coreKey } = decodeId(id)
|
|
374
|
-
const hex = b4a.toString(coreKey, 'hex')
|
|
375
|
-
if (!this.root._coreKeys.has(hex)) {
|
|
376
|
-
// no stamp on a file-field value, look it up (fire-and-forget, idempotent)
|
|
377
|
-
if (stamp === undefined) {
|
|
378
|
-
this.store
|
|
379
|
-
.get('files', id)
|
|
380
|
-
.then(({ data }) => {
|
|
381
|
-
if (data && !this.closing && !this.closed) this._registerBlobCore(id, data.stamp || 0)
|
|
382
|
-
})
|
|
383
|
-
.catch(safetyCatch)
|
|
384
|
-
return
|
|
385
|
-
}
|
|
386
|
-
const key = this._blobCoreKey(stamp)
|
|
387
|
-
if (!key) return // unknown epoch — this device is not entitled to the core
|
|
388
|
-
this.root._coreKeys.set(hex, key)
|
|
389
|
-
}
|
|
390
|
-
// remember which handle read it, so close prunes the entry
|
|
391
|
-
if (this !== this.root) (this._blobKeys ??= new Set()).add(hex)
|
|
392
|
-
} catch {
|
|
393
|
-
// ignore invalid ids
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// base-era cores use the OWNING handle's key, rooms have their own
|
|
398
|
-
_blobCoreKey(stamp) {
|
|
399
|
-
if (!stamp) return this.store.encryptionKey
|
|
400
|
-
const entropy = this.store.keyring.entropy(stamp)
|
|
401
|
-
return entropy ? blobEpochKey(entropy) : null
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/** Canonical id — identity id for the root handle, store key for children. */
|
|
405
|
-
get id() {
|
|
406
|
-
if (!this.parent) return this.identity.id
|
|
407
|
-
return this.store?.key ? hid.encode(this.store.key) : null
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/** This device's id + name. `null` on child handles. */
|
|
411
|
-
get device() {
|
|
412
|
-
if (this.parent) return null
|
|
413
|
-
const k = this.store?.writerKey
|
|
414
|
-
return k ? { id: hid.encode(k), name: this._opts.name || null } : null
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
get suspended() {
|
|
418
|
-
return this._sus?.suspended === true
|
|
419
|
-
}
|
|
420
|
-
|
|
421
330
|
/**
|
|
422
331
|
* Initialise a fresh database: write the genesis claim, derive the writer.
|
|
423
332
|
* Forwards to `Database.bootstrap`.
|
|
@@ -440,9 +349,9 @@ export class Handle extends ReadyResource {
|
|
|
440
349
|
}
|
|
441
350
|
|
|
442
351
|
/**
|
|
443
|
-
* Flip this handle's swarm announce mode — `setActive(false)` demotes an
|
|
444
|
-
*
|
|
445
|
-
*
|
|
352
|
+
* Flip this handle's swarm announce mode — `setActive(false)` demotes an idle/background
|
|
353
|
+
* room to server-only (still reachable, stops searching); `setActive(true)` promotes it
|
|
354
|
+
* back on focus.
|
|
446
355
|
*
|
|
447
356
|
* @param {boolean} active
|
|
448
357
|
* @returns {Promise<void>}
|
|
@@ -485,12 +394,6 @@ export class Handle extends ReadyResource {
|
|
|
485
394
|
return str
|
|
486
395
|
}
|
|
487
396
|
|
|
488
|
-
async _syncInvites() {
|
|
489
|
-
if (!this.pair) return
|
|
490
|
-
const { data } = await this.store.get('invites')
|
|
491
|
-
this.pair.syncRows(data || [])
|
|
492
|
-
}
|
|
493
|
-
|
|
494
397
|
/**
|
|
495
398
|
* Revoke a previously-minted invite by its string form.
|
|
496
399
|
*
|
|
@@ -538,10 +441,8 @@ export class Handle extends ReadyResource {
|
|
|
538
441
|
throw CeroError.INVALID(`role '${role}' is not a rank (owner, admin, member, reader)`)
|
|
539
442
|
}
|
|
540
443
|
|
|
541
|
-
// confirm must answer within the
|
|
542
|
-
//
|
|
543
|
-
// membership writes land, and a failure there is recoverable by re-pairing.
|
|
544
|
-
// Epoch secrets ride along so a post-rotation joiner reads full history.
|
|
444
|
+
// confirm must answer within the request's lifetime, so the key goes out before the membership
|
|
445
|
+
// writes land; epoch secrets ride along so a post-rotation joiner reads full history
|
|
545
446
|
const epochs = this.store.keyring.all()
|
|
546
447
|
await candidate.confirm({
|
|
547
448
|
key: this.store.key,
|
|
@@ -579,6 +480,118 @@ export class Handle extends ReadyResource {
|
|
|
579
480
|
})
|
|
580
481
|
}
|
|
581
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Leave a child handle — removes it from the parent's `handles` collection
|
|
485
|
+
* and closes the session. No-op on root handles.
|
|
486
|
+
*
|
|
487
|
+
* @returns {Promise<void>}
|
|
488
|
+
*/
|
|
489
|
+
async leave() {
|
|
490
|
+
if (!this.parent) return
|
|
491
|
+
await this.parent.store.call('del-handle', { id: hid.encode(this.store.key) })
|
|
492
|
+
await this.close()
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Pause networking + storage. Idempotent; no-op on child handles.
|
|
497
|
+
*
|
|
498
|
+
* @returns {Promise<void>}
|
|
499
|
+
*/
|
|
500
|
+
async suspend() {
|
|
501
|
+
if (this._sus) await this._sus.suspend()
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Resume a suspended root handle. Idempotent; no-op on child handles.
|
|
506
|
+
*
|
|
507
|
+
* @returns {Promise<void>}
|
|
508
|
+
*/
|
|
509
|
+
async resume() {
|
|
510
|
+
if (this._sus) await this._sus.resume()
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* @param {Uint8Array} coreKey
|
|
515
|
+
* @param {object} info
|
|
516
|
+
* @returns {{ key: Uint8Array, encryptionKey: Uint8Array } | null}
|
|
517
|
+
*/
|
|
518
|
+
_resolveCore(coreKey, info) {
|
|
519
|
+
const hex = b4a.toString(coreKey, 'hex')
|
|
520
|
+
const encryptionKey = this.root._coreKeys.get(hex)
|
|
521
|
+
if (encryptionKey !== undefined) return { key: coreKey, encryptionKey }
|
|
522
|
+
return null
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
_baseBlobs() {
|
|
526
|
+
if (!this._blobs) this._blobs = this._makeBlobs('blobs', this.store.encryptionKey, 0)
|
|
527
|
+
return this._blobs
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
_makeBlobs(name, encryptionKey, stamp) {
|
|
531
|
+
const blobs = new Blobs({
|
|
532
|
+
store: this.store.store,
|
|
533
|
+
network: this.network,
|
|
534
|
+
encryptionKey,
|
|
535
|
+
name
|
|
536
|
+
})
|
|
537
|
+
blobs.stamp = stamp
|
|
538
|
+
blobs
|
|
539
|
+
.ready()
|
|
540
|
+
.then(() => {
|
|
541
|
+
// close prunes _coreKeys first, a late ready() must not re-insert the entry
|
|
542
|
+
if (this.closing || this.closed || !blobs.key) return
|
|
543
|
+
this.root._coreKeys.set(b4a.toString(blobs.key, 'hex'), encryptionKey)
|
|
544
|
+
})
|
|
545
|
+
.catch(this._onerror)
|
|
546
|
+
return blobs
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Remember the blob-core key a file id points at so the file server can open the core.
|
|
551
|
+
*
|
|
552
|
+
* @param {string} id
|
|
553
|
+
* @param {number} [stamp]
|
|
554
|
+
*/
|
|
555
|
+
_registerBlobCore(id, stamp) {
|
|
556
|
+
if (!id || !this.root?._coreKeys) return
|
|
557
|
+
try {
|
|
558
|
+
const { coreKey } = decodeId(id)
|
|
559
|
+
const hex = b4a.toString(coreKey, 'hex')
|
|
560
|
+
if (!this.root._coreKeys.has(hex)) {
|
|
561
|
+
// no stamp on a file-field value, look it up (fire-and-forget, idempotent)
|
|
562
|
+
if (stamp === undefined) {
|
|
563
|
+
this.store
|
|
564
|
+
.get('files', id)
|
|
565
|
+
.then(({ data }) => {
|
|
566
|
+
if (data && !this.closing && !this.closed) this._registerBlobCore(id, data.stamp || 0)
|
|
567
|
+
})
|
|
568
|
+
.catch(safetyCatch)
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
const key = this._blobCoreKey(stamp)
|
|
572
|
+
if (!key) return // unknown epoch — this device is not entitled to the core
|
|
573
|
+
this.root._coreKeys.set(hex, key)
|
|
574
|
+
}
|
|
575
|
+
// remember which handle read it, so close prunes the entry
|
|
576
|
+
if (this !== this.root) (this._blobKeys ??= new Set()).add(hex)
|
|
577
|
+
} catch {
|
|
578
|
+
// ignore invalid ids
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// base-era cores use the OWNING handle's key, rooms have their own
|
|
583
|
+
_blobCoreKey(stamp) {
|
|
584
|
+
if (!stamp) return this.store.encryptionKey
|
|
585
|
+
const entropy = this.store.keyring.entropy(stamp)
|
|
586
|
+
return entropy ? blobEpochKey(entropy) : null
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async _syncInvites() {
|
|
590
|
+
if (!this.pair) return
|
|
591
|
+
const { data } = await this.store.get('invites')
|
|
592
|
+
this.pair.syncRows(data || [])
|
|
593
|
+
}
|
|
594
|
+
|
|
582
595
|
// `grants` treats an unknown role as "no", so an app role name must fail loudly
|
|
583
596
|
async _checkGrant(role) {
|
|
584
597
|
if (!isRank(role)) {
|
|
@@ -593,21 +606,8 @@ export class Handle extends ReadyResource {
|
|
|
593
606
|
}
|
|
594
607
|
|
|
595
608
|
/**
|
|
596
|
-
*
|
|
597
|
-
* and
|
|
598
|
-
*
|
|
599
|
-
* @returns {Promise<void>}
|
|
600
|
-
*/
|
|
601
|
-
async leave() {
|
|
602
|
-
if (!this.parent) return
|
|
603
|
-
await this.parent.store.call('del-handle', { id: hid.encode(this.store.key) })
|
|
604
|
-
await this.close()
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
/**
|
|
608
|
-
* Create a new child handle of `type`. Owner-flow — generates a fresh
|
|
609
|
-
* writer, adds it as a writer + member, and registers the child on the
|
|
610
|
-
* parent's `handles` collection.
|
|
609
|
+
* Create a new child handle of `type`. Owner-flow — generates a fresh writer, adds it as a
|
|
610
|
+
* writer + member, and registers the child on the parent's `handles` collection.
|
|
611
611
|
*
|
|
612
612
|
* @param {string} type
|
|
613
613
|
* @param {CreateChildOpts} [opts]
|
|
@@ -635,8 +635,7 @@ export class Handle extends ReadyResource {
|
|
|
635
635
|
child.name = name
|
|
636
636
|
|
|
637
637
|
const id = hid.encode(child.store.key)
|
|
638
|
-
// add-handle makes the row visible before
|
|
639
|
-
// duplicate Handle over the same core deadlocks in ready()
|
|
638
|
+
// add-handle makes the row visible before create finishes; a second Handle on the same core deadlocks
|
|
640
639
|
const inflight = new Promise((resolve, reject) => {
|
|
641
640
|
publish = resolve
|
|
642
641
|
abort = reject
|
|
@@ -691,9 +690,7 @@ export class Handle extends ReadyResource {
|
|
|
691
690
|
}
|
|
692
691
|
|
|
693
692
|
/**
|
|
694
|
-
* Join a child handle by invite (joiner-flow).
|
|
695
|
-
* capability and registers the child on the parent's `handles`
|
|
696
|
-
* collection.
|
|
693
|
+
* Join a child handle by invite (joiner-flow).
|
|
697
694
|
*
|
|
698
695
|
* @param {string} invite
|
|
699
696
|
* @param {string} type
|
|
@@ -726,10 +723,8 @@ export class Handle extends ReadyResource {
|
|
|
726
723
|
async _pair(invite, type, { routes, timeout } = {}, target = null) {
|
|
727
724
|
const deadline = timeout || TIMEOUT
|
|
728
725
|
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
// only gets it re-removed, so fall through to a real pairing, which mints a
|
|
732
|
-
// fresh keypair. A member row outlives a revoked device, so test the device.
|
|
726
|
+
// a stored writer still admitted reopens without waiting; a removed writer's core is
|
|
727
|
+
// frozen, so fall through to a real pairing and a fresh keypair
|
|
733
728
|
if (target) {
|
|
734
729
|
const { data: joined } = await this.store.get('handles')
|
|
735
730
|
const existing = joined.find(
|
|
@@ -801,9 +796,8 @@ export class Handle extends ReadyResource {
|
|
|
801
796
|
}
|
|
802
797
|
|
|
803
798
|
/**
|
|
804
|
-
* Get an open child by id, or re-open it. Concurrent calls for the same id
|
|
805
|
-
*
|
|
806
|
-
* exactly once.
|
|
799
|
+
* Get an open child by id, or re-open it. Concurrent calls for the same id share one
|
|
800
|
+
* in-flight load, so the child is built — and `handle` emitted — exactly once.
|
|
807
801
|
*
|
|
808
802
|
* @param {string} type
|
|
809
803
|
* @param {string} id
|
|
@@ -864,15 +858,6 @@ export class Handle extends ReadyResource {
|
|
|
864
858
|
return child
|
|
865
859
|
}
|
|
866
860
|
|
|
867
|
-
/**
|
|
868
|
-
* Pause networking + storage. Idempotent; no-op on child handles.
|
|
869
|
-
*
|
|
870
|
-
* @returns {Promise<void>}
|
|
871
|
-
*/
|
|
872
|
-
async suspend() {
|
|
873
|
-
if (this._sus) await this._sus.suspend()
|
|
874
|
-
}
|
|
875
|
-
|
|
876
861
|
async _suspend() {
|
|
877
862
|
if (this.closing || this.closed) return
|
|
878
863
|
await Promise.all([...this.children].map((c) => c.pair?.suspend()))
|
|
@@ -885,15 +870,6 @@ export class Handle extends ReadyResource {
|
|
|
885
870
|
}
|
|
886
871
|
}
|
|
887
872
|
|
|
888
|
-
/**
|
|
889
|
-
* Resume a suspended root handle. Idempotent; no-op on child handles.
|
|
890
|
-
*
|
|
891
|
-
* @returns {Promise<void>}
|
|
892
|
-
*/
|
|
893
|
-
async resume() {
|
|
894
|
-
if (this._sus) await this._sus.resume()
|
|
895
|
-
}
|
|
896
|
-
|
|
897
873
|
async _resume() {
|
|
898
874
|
if (this.closing || this.closed) return
|
|
899
875
|
try {
|
|
@@ -906,6 +882,45 @@ export class Handle extends ReadyResource {
|
|
|
906
882
|
await Promise.all([...this.children].map((child) => child.pair?.resume()))
|
|
907
883
|
}
|
|
908
884
|
|
|
885
|
+
/**
|
|
886
|
+
* @param {Handle} child
|
|
887
|
+
* @param {{ role?: string }} [opts]
|
|
888
|
+
*/
|
|
889
|
+
_wireAccept(child, { role } = {}) {
|
|
890
|
+
child.pair.on('candidate', (cand) => {
|
|
891
|
+
if (this.closing || this.closed || child.closing || child.closed) return
|
|
892
|
+
child.accept(cand, { role }).catch(this._onerror)
|
|
893
|
+
})
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* @param {string} id
|
|
898
|
+
* @param {KeyPair | { publicKey: Uint8Array, secretKey: Uint8Array } | null} keyPair
|
|
899
|
+
* @returns {Promise<void>}
|
|
900
|
+
*/
|
|
901
|
+
async _saveKeyPair(id, keyPair) {
|
|
902
|
+
if (!this.local || !keyPair) return
|
|
903
|
+
await this.local.store.put('handle-keypairs', {
|
|
904
|
+
id,
|
|
905
|
+
publicKey: keyPair.publicKey,
|
|
906
|
+
secretKey: keyPair.secretKey
|
|
907
|
+
})
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* @param {string} id
|
|
912
|
+
* @returns {Promise<{ publicKey: Uint8Array, secretKey: Uint8Array } | null>}
|
|
913
|
+
*/
|
|
914
|
+
async _loadKeyPair(id) {
|
|
915
|
+
if (!this.local) return null
|
|
916
|
+
const { data } = await this.local.store.get('handle-keypairs', id)
|
|
917
|
+
if (!data) return null
|
|
918
|
+
return {
|
|
919
|
+
publicKey: data.publicKey,
|
|
920
|
+
secretKey: data.secretKey
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
909
924
|
/**
|
|
910
925
|
* Pair into an existing handle via an invite, returning a brand-new
|
|
911
926
|
* `Handle` already configured with the resolved key + encryption key.
|
|
@@ -926,8 +941,7 @@ export class Handle extends ReadyResource {
|
|
|
926
941
|
if (!spec) throw CeroError.REQUIRED('spec')
|
|
927
942
|
|
|
928
943
|
const writer = Identity.randomKeyPair()
|
|
929
|
-
// join-only:
|
|
930
|
-
// identity-derived default topic ('Active member already exist')
|
|
944
|
+
// join-only: a member listener here would collide with concurrent joins on the identity topic
|
|
931
945
|
const pair = new Pairing({ network: net, identity: id, host: false })
|
|
932
946
|
await pair.ready()
|
|
933
947
|
|
|
@@ -963,45 +977,6 @@ export class Handle extends ReadyResource {
|
|
|
963
977
|
keyPair: writer
|
|
964
978
|
})
|
|
965
979
|
}
|
|
966
|
-
|
|
967
|
-
/**
|
|
968
|
-
* @param {Handle} child
|
|
969
|
-
* @param {{ role?: string }} [opts]
|
|
970
|
-
*/
|
|
971
|
-
_wireAccept(child, { role } = {}) {
|
|
972
|
-
child.pair.on('candidate', (cand) => {
|
|
973
|
-
if (this.closing || this.closed || child.closing || child.closed) return
|
|
974
|
-
child.accept(cand, { role }).catch(this._onerror)
|
|
975
|
-
})
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
/**
|
|
979
|
-
* @param {string} id
|
|
980
|
-
* @param {KeyPair | { publicKey: Uint8Array, secretKey: Uint8Array } | null} keyPair
|
|
981
|
-
* @returns {Promise<void>}
|
|
982
|
-
*/
|
|
983
|
-
async _saveKeyPair(id, keyPair) {
|
|
984
|
-
if (!this.local || !keyPair) return
|
|
985
|
-
await this.local.store.put('handle-keypairs', {
|
|
986
|
-
id,
|
|
987
|
-
publicKey: keyPair.publicKey,
|
|
988
|
-
secretKey: keyPair.secretKey
|
|
989
|
-
})
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
/**
|
|
993
|
-
* @param {string} id
|
|
994
|
-
* @returns {Promise<{ publicKey: Uint8Array, secretKey: Uint8Array } | null>}
|
|
995
|
-
*/
|
|
996
|
-
async _loadKeyPair(id) {
|
|
997
|
-
if (!this.local) return null
|
|
998
|
-
const { data } = await this.local.store.get('handle-keypairs', id)
|
|
999
|
-
if (!data) return null
|
|
1000
|
-
return {
|
|
1001
|
-
publicKey: data.publicKey,
|
|
1002
|
-
secretKey: data.secretKey
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
980
|
}
|
|
1006
981
|
|
|
1007
982
|
function pickHandle(spec, type) {
|