@cero-base/cero 1.19.0 → 2.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.
- package/README.md +38 -643
- package/package.json +9 -6
- package/src/build/index.js +63 -61
- package/src/build/internal.js +123 -0
- package/src/build/schemas.js +6 -11
- package/src/extensions/handle-sync.js +3 -11
- package/src/extensions/index.js +86 -0
- package/src/extensions/profile-sync.js +15 -16
- package/src/handle/index.js +290 -284
- package/src/index.js +24 -81
- package/src/lib/bluetooth.js +25 -56
- package/src/lib/constants.js +0 -15
- package/src/lib/operators.js +35 -134
- package/src/lib/peek.js +4 -8
- package/src/lib/refs.js +10 -8
- package/src/lib/spec.js +2 -3
- package/src/local/index.js +2 -3
- package/src/rpc/client.js +54 -77
- package/src/rpc/index.js +3 -3
- package/src/rpc/server.js +40 -43
- package/types/build/index.d.ts +11 -9
- package/types/build/{builtins.d.ts → internal.d.ts} +20 -38
- package/types/build/schemas.d.ts +4 -3
- package/types/extensions/handle-sync.d.ts +2 -8
- package/types/extensions/index.d.ts +102 -0
- package/types/extensions/profile-sync.d.ts +0 -5
- package/types/handle/index.d.ts +90 -108
- package/types/index.d.ts +21 -17
- package/types/lib/bluetooth.d.ts +8 -32
- package/types/lib/constants.d.ts +0 -11
- package/types/lib/operators.d.ts +30 -83
- package/types/lib/peek.d.ts +2 -3
- package/types/lib/refs.d.ts +5 -5
- package/types/lib/spec.d.ts +2 -3
- package/types/local/index.d.ts +2 -3
- package/types/rpc/client.d.ts +29 -26
- package/types/rpc/index.d.ts +3 -3
- package/types/rpc/server.d.ts +7 -10
- 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
|
@@ -22,7 +22,8 @@ import { FileServer } from '@cero-base/core/blobs/server'
|
|
|
22
22
|
import { NS, TIMEOUT } from '../lib/constants.js'
|
|
23
23
|
|
|
24
24
|
import { Ref } from '../lib/refs.js'
|
|
25
|
-
import { bind } from '../
|
|
25
|
+
import { extensionsOf, operatorsOf, bind } from '../extensions/index.js'
|
|
26
|
+
import { before, after } from '../lib/operators.js'
|
|
26
27
|
|
|
27
28
|
export { Ref } from '../lib/refs.js'
|
|
28
29
|
|
|
@@ -48,7 +49,6 @@ export { Ref } from '../lib/refs.js'
|
|
|
48
49
|
* @property {Array<{ epoch: number, entropy: Uint8Array }>} [epochs] Rotation epochs delivered at join.
|
|
49
50
|
* @property {string} [namespace] Corestore namespace.
|
|
50
51
|
* @property {KeyPair} [keyPair] Writer keypair.
|
|
51
|
-
* @property {boolean} [passive] Join discovery server-only; flip later with `setActive`.
|
|
52
52
|
* @property {boolean} [pair] When `false`, skips creating a `Pairing` session.
|
|
53
53
|
*
|
|
54
54
|
* @typedef {object} CreateChildOpts
|
|
@@ -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] */
|
|
@@ -118,8 +114,11 @@ export class Handle extends ReadyResource {
|
|
|
118
114
|
this._discovery = opts.discovery || null
|
|
119
115
|
this._dir = opts.dir || null
|
|
120
116
|
this._opts = opts.opts || {}
|
|
117
|
+
this.extensions = parent?.extensions || extensionsOf(spec, this._opts.extensions)
|
|
118
|
+
this.operators = parent?.operators || operatorsOf(spec, this._opts.operators)
|
|
121
119
|
this._onerror = this._opts.onerror || safetyCatch
|
|
122
120
|
this.children = parent ? null : new Set()
|
|
121
|
+
this._typeHooks = parent ? null : new Set()
|
|
123
122
|
this._loading = parent ? null : new Map()
|
|
124
123
|
this._joining = parent ? null : new Map()
|
|
125
124
|
this._coreKeys = parent ? null : new Map()
|
|
@@ -143,13 +142,90 @@ export class Handle extends ReadyResource {
|
|
|
143
142
|
epochs: opts.epochs,
|
|
144
143
|
namespace: opts.namespace,
|
|
145
144
|
keyPair: opts.keyPair,
|
|
146
|
-
|
|
145
|
+
pinned: !parent,
|
|
147
146
|
onerror: this._onerror
|
|
148
147
|
})
|
|
149
148
|
this.pair = null
|
|
150
149
|
this._wantsPair = opts.pair !== false
|
|
151
150
|
}
|
|
152
151
|
|
|
152
|
+
/**
|
|
153
|
+
* An `AbortSignal` that fires when this handle closes.
|
|
154
|
+
*
|
|
155
|
+
* @returns {AbortSignal}
|
|
156
|
+
*/
|
|
157
|
+
get signal() {
|
|
158
|
+
if (!this._ac) {
|
|
159
|
+
this._ac = new AbortController()
|
|
160
|
+
if (this.closed) this._ac.abort()
|
|
161
|
+
else this.once('close', () => this._ac.abort())
|
|
162
|
+
}
|
|
163
|
+
return this._ac.signal
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The top-most handle in the parent chain — itself for a root handle. */
|
|
167
|
+
get root() {
|
|
168
|
+
let h = this
|
|
169
|
+
while (h.parent) h = h.parent
|
|
170
|
+
return h
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Lazily-built file server for this identity. Root-only — child handles
|
|
175
|
+
* reach it through `this.root.fileServer`.
|
|
176
|
+
*
|
|
177
|
+
* @returns {FileServer}
|
|
178
|
+
*/
|
|
179
|
+
get fileServer() {
|
|
180
|
+
if (this.parent) return this.root.fileServer
|
|
181
|
+
if (!this._fileServer) {
|
|
182
|
+
this._fileServer = new FileServer({
|
|
183
|
+
store: this.store.store,
|
|
184
|
+
resolve: (coreKey, info) => this._resolveCore(coreKey, info)
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
return this._fileServer
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Lazily-built blob store for THIS handle's writing device, at the current rotation epoch.
|
|
192
|
+
*
|
|
193
|
+
* @returns {Blobs}
|
|
194
|
+
*/
|
|
195
|
+
get blobs() {
|
|
196
|
+
const stamp = this.store.keyring.current
|
|
197
|
+
if (!stamp) return this._baseBlobs()
|
|
198
|
+
let blobs = this._epochBlobs?.get(stamp)
|
|
199
|
+
if (!blobs) {
|
|
200
|
+
const entropy = this.store.keyring.entropy(stamp)
|
|
201
|
+
blobs = this._makeBlobs(`blobs-${stamp}`, blobEpochKey(entropy), stamp)
|
|
202
|
+
;(this._epochBlobs ??= new Map()).set(stamp, blobs)
|
|
203
|
+
}
|
|
204
|
+
return blobs
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The handle type of a child, null on the root. */
|
|
208
|
+
get type() {
|
|
209
|
+
return this.spec.meta.type || null
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Canonical id — identity id for the root handle, store key for children. */
|
|
213
|
+
get id() {
|
|
214
|
+
if (!this.parent) return this.identity.id
|
|
215
|
+
return this.store?.key ? hid.encode(this.store.key) : null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** This device's id + name. `null` on child handles. */
|
|
219
|
+
get device() {
|
|
220
|
+
if (this.parent) return null
|
|
221
|
+
const k = this.store?.writerKey
|
|
222
|
+
return k ? { id: hid.encode(k), name: this._opts.name || null } : null
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
get suspended() {
|
|
226
|
+
return this._sus?.suspended === true
|
|
227
|
+
}
|
|
228
|
+
|
|
153
229
|
async _open() {
|
|
154
230
|
await this.store.ready()
|
|
155
231
|
if (this._wantsPair) {
|
|
@@ -163,13 +239,14 @@ export class Handle extends ReadyResource {
|
|
|
163
239
|
}
|
|
164
240
|
})
|
|
165
241
|
await this.pair.ready()
|
|
166
|
-
// serve invites persisted by any member,
|
|
167
|
-
// replicate in or disappear (minted, revoked or consumed elsewhere)
|
|
242
|
+
// serve invites persisted by any member, in step with the rows
|
|
168
243
|
await this._syncInvites().catch(safetyCatch)
|
|
169
|
-
this._invitesSync = () =>
|
|
244
|
+
this._invitesSync = (touched) => {
|
|
245
|
+
if (touched.has('*') || touched.has('invites')) this._syncInvites().catch(safetyCatch)
|
|
246
|
+
}
|
|
170
247
|
this.store.on('update', this._invitesSync)
|
|
171
248
|
}
|
|
172
|
-
Ref.attach(this, this.store.refs)
|
|
249
|
+
Ref.attach(this, this.store.refs, this.spec.handles)
|
|
173
250
|
this.root._coreKeys.set(b4a.toString(this.store.key, 'hex'), this.store.encryptionKey)
|
|
174
251
|
if (!this.parent) await this.fileServer.listen()
|
|
175
252
|
}
|
|
@@ -216,9 +293,9 @@ export class Handle extends ReadyResource {
|
|
|
216
293
|
}
|
|
217
294
|
|
|
218
295
|
/**
|
|
219
|
-
* Tie a destroyable resource (a `watch` stream, a timer, any `{ destroy }`)
|
|
220
|
-
*
|
|
221
|
-
*
|
|
296
|
+
* Tie a destroyable resource (a `watch` stream, a timer, any `{ destroy }`) to this
|
|
297
|
+
* handle's lifecycle — it's destroyed automatically on close, so callers don't track
|
|
298
|
+
* cleanup.
|
|
222
299
|
*
|
|
223
300
|
* @template {{ destroy?: Function, once?: Function }} T
|
|
224
301
|
* @param {T} resource
|
|
@@ -234,22 +311,6 @@ export class Handle extends ReadyResource {
|
|
|
234
311
|
return resource
|
|
235
312
|
}
|
|
236
313
|
|
|
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
314
|
/**
|
|
254
315
|
* `EventEmitter.on` plus an optional `{ signal }` that removes the listener
|
|
255
316
|
* when the signal aborts — e.g. `me.on('handle', fn, { signal: me.signal })`.
|
|
@@ -265,42 +326,6 @@ export class Handle extends ReadyResource {
|
|
|
265
326
|
return this
|
|
266
327
|
}
|
|
267
328
|
|
|
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
329
|
/**
|
|
305
330
|
* Resolve a durable file id to an ephemeral download url via this identity's
|
|
306
331
|
* file server.
|
|
@@ -312,112 +337,6 @@ export class Handle extends ReadyResource {
|
|
|
312
337
|
return this.root.fileServer.getLink(id)
|
|
313
338
|
}
|
|
314
339
|
|
|
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
340
|
/**
|
|
422
341
|
* Initialise a fresh database: write the genesis claim, derive the writer.
|
|
423
342
|
* Forwards to `Database.bootstrap`.
|
|
@@ -440,15 +359,13 @@ export class Handle extends ReadyResource {
|
|
|
440
359
|
}
|
|
441
360
|
|
|
442
361
|
/**
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
* `setActive(true)` promotes it back on focus. Cheap, safe to call often.
|
|
362
|
+
* `true` ranks this handle as just touched, `false` takes it out of the swarm until the
|
|
363
|
+
* next update lands in it.
|
|
446
364
|
*
|
|
447
365
|
* @param {boolean} active
|
|
448
|
-
* @returns {Promise<void>}
|
|
449
366
|
*/
|
|
450
367
|
setActive(active) {
|
|
451
|
-
|
|
368
|
+
this.store.setActive(active)
|
|
452
369
|
}
|
|
453
370
|
|
|
454
371
|
/**
|
|
@@ -485,12 +402,6 @@ export class Handle extends ReadyResource {
|
|
|
485
402
|
return str
|
|
486
403
|
}
|
|
487
404
|
|
|
488
|
-
async _syncInvites() {
|
|
489
|
-
if (!this.pair) return
|
|
490
|
-
const { data } = await this.store.get('invites')
|
|
491
|
-
this.pair.syncRows(data || [])
|
|
492
|
-
}
|
|
493
|
-
|
|
494
405
|
/**
|
|
495
406
|
* Revoke a previously-minted invite by its string form.
|
|
496
407
|
*
|
|
@@ -538,10 +449,8 @@ export class Handle extends ReadyResource {
|
|
|
538
449
|
throw CeroError.INVALID(`role '${role}' is not a rank (owner, admin, member, reader)`)
|
|
539
450
|
}
|
|
540
451
|
|
|
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.
|
|
452
|
+
// confirm must answer within the request's lifetime, so the key goes out before the membership
|
|
453
|
+
// writes land; epoch secrets ride along so a post-rotation joiner reads full history
|
|
545
454
|
const epochs = this.store.keyring.all()
|
|
546
455
|
await candidate.confirm({
|
|
547
456
|
key: this.store.key,
|
|
@@ -566,19 +475,131 @@ export class Handle extends ReadyResource {
|
|
|
566
475
|
}
|
|
567
476
|
|
|
568
477
|
const sig = this.identity.sign(admission(this.store.key, writerKey, this.store.writerKey))
|
|
478
|
+
// one batch: the member row, then the writer that belongs to it; a refusal discards both
|
|
569
479
|
await this.store.tx(async (tx) => {
|
|
480
|
+
await tx.call('add-member', member)
|
|
570
481
|
await tx.call('add-writer', {
|
|
571
482
|
sig,
|
|
572
483
|
master: this.identity.publicKey,
|
|
573
484
|
writer: writerKey,
|
|
574
|
-
// add-member in the same transaction decides the rank, a refusal discards both
|
|
575
485
|
memberId: member.id,
|
|
576
486
|
ts: member.updatedAt || Date.now()
|
|
577
487
|
})
|
|
578
|
-
await tx.call('add-member', member)
|
|
579
488
|
})
|
|
580
489
|
}
|
|
581
490
|
|
|
491
|
+
/**
|
|
492
|
+
* Leave a child handle — removes it from the parent's `handles` collection
|
|
493
|
+
* and closes the session. No-op on root handles.
|
|
494
|
+
*
|
|
495
|
+
* @returns {Promise<void>}
|
|
496
|
+
*/
|
|
497
|
+
async leave() {
|
|
498
|
+
if (!this.parent) return
|
|
499
|
+
await this.parent.store.call('del-handle', { id: hid.encode(this.store.key) })
|
|
500
|
+
await this.close()
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Pause networking + storage. Idempotent; no-op on child handles.
|
|
505
|
+
*
|
|
506
|
+
* @returns {Promise<void>}
|
|
507
|
+
*/
|
|
508
|
+
async suspend() {
|
|
509
|
+
if (this._sus) await this._sus.suspend()
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Resume a suspended root handle. Idempotent; no-op on child handles.
|
|
514
|
+
*
|
|
515
|
+
* @returns {Promise<void>}
|
|
516
|
+
*/
|
|
517
|
+
async resume() {
|
|
518
|
+
if (this._sus) await this._sus.resume()
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* @param {Uint8Array} coreKey
|
|
523
|
+
* @param {object} info
|
|
524
|
+
* @returns {{ key: Uint8Array, encryptionKey: Uint8Array } | null}
|
|
525
|
+
*/
|
|
526
|
+
_resolveCore(coreKey, info) {
|
|
527
|
+
const hex = b4a.toString(coreKey, 'hex')
|
|
528
|
+
const encryptionKey = this.root._coreKeys.get(hex)
|
|
529
|
+
if (encryptionKey !== undefined) return { key: coreKey, encryptionKey }
|
|
530
|
+
return null
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
_baseBlobs() {
|
|
534
|
+
if (!this._blobs) this._blobs = this._makeBlobs('blobs', this.store.encryptionKey, 0)
|
|
535
|
+
return this._blobs
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
_makeBlobs(name, encryptionKey, stamp) {
|
|
539
|
+
const blobs = new Blobs({
|
|
540
|
+
store: this.store.store,
|
|
541
|
+
network: this.network,
|
|
542
|
+
encryptionKey,
|
|
543
|
+
name
|
|
544
|
+
})
|
|
545
|
+
blobs.stamp = stamp
|
|
546
|
+
blobs
|
|
547
|
+
.ready()
|
|
548
|
+
.then(() => {
|
|
549
|
+
// close prunes _coreKeys first, a late ready() must not re-insert the entry
|
|
550
|
+
if (this.closing || this.closed || !blobs.key) return
|
|
551
|
+
this.root._coreKeys.set(b4a.toString(blobs.key, 'hex'), encryptionKey)
|
|
552
|
+
})
|
|
553
|
+
.catch(this._onerror)
|
|
554
|
+
return blobs
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Remember the blob-core key a file id points at so the file server can open the core.
|
|
559
|
+
*
|
|
560
|
+
* @param {string} id
|
|
561
|
+
* @param {number} [stamp]
|
|
562
|
+
*/
|
|
563
|
+
_registerBlobCore(id, stamp) {
|
|
564
|
+
if (!id || !this.root?._coreKeys) return
|
|
565
|
+
try {
|
|
566
|
+
const { coreKey } = decodeId(id)
|
|
567
|
+
const hex = b4a.toString(coreKey, 'hex')
|
|
568
|
+
if (!this.root._coreKeys.has(hex)) {
|
|
569
|
+
// no stamp on a file-field value, look it up (fire-and-forget, idempotent)
|
|
570
|
+
if (stamp === undefined) {
|
|
571
|
+
this.store
|
|
572
|
+
.get('files', id)
|
|
573
|
+
.then(({ data }) => {
|
|
574
|
+
if (data && !this.closing && !this.closed) this._registerBlobCore(id, data.stamp || 0)
|
|
575
|
+
})
|
|
576
|
+
.catch(safetyCatch)
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
const key = this._blobCoreKey(stamp)
|
|
580
|
+
if (!key) return // unknown epoch — this device is not entitled to the core
|
|
581
|
+
this.root._coreKeys.set(hex, key)
|
|
582
|
+
}
|
|
583
|
+
// remember which handle read it, so close prunes the entry
|
|
584
|
+
if (this !== this.root) (this._blobKeys ??= new Set()).add(hex)
|
|
585
|
+
} catch {
|
|
586
|
+
// ignore invalid ids
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// base-era cores use the OWNING handle's key, rooms have their own
|
|
591
|
+
_blobCoreKey(stamp) {
|
|
592
|
+
if (!stamp) return this.store.encryptionKey
|
|
593
|
+
const entropy = this.store.keyring.entropy(stamp)
|
|
594
|
+
return entropy ? blobEpochKey(entropy) : null
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async _syncInvites() {
|
|
598
|
+
if (!this.pair) return
|
|
599
|
+
const { data } = await this.store.get('invites')
|
|
600
|
+
this.pair.syncRows(data || [])
|
|
601
|
+
}
|
|
602
|
+
|
|
582
603
|
// `grants` treats an unknown role as "no", so an app role name must fail loudly
|
|
583
604
|
async _checkGrant(role) {
|
|
584
605
|
if (!isRank(role)) {
|
|
@@ -593,21 +614,8 @@ export class Handle extends ReadyResource {
|
|
|
593
614
|
}
|
|
594
615
|
|
|
595
616
|
/**
|
|
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.
|
|
617
|
+
* Create a new child handle of `type`. Owner-flow — generates a fresh writer, adds it as a
|
|
618
|
+
* writer + member, and registers the child on the parent's `handles` collection.
|
|
611
619
|
*
|
|
612
620
|
* @param {string} type
|
|
613
621
|
* @param {CreateChildOpts} [opts]
|
|
@@ -635,8 +643,7 @@ export class Handle extends ReadyResource {
|
|
|
635
643
|
child.name = name
|
|
636
644
|
|
|
637
645
|
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()
|
|
646
|
+
// add-handle makes the row visible before create finishes; a second Handle on the same core deadlocks
|
|
640
647
|
const inflight = new Promise((resolve, reject) => {
|
|
641
648
|
publish = resolve
|
|
642
649
|
abort = reject
|
|
@@ -676,9 +683,7 @@ export class Handle extends ReadyResource {
|
|
|
676
683
|
})
|
|
677
684
|
|
|
678
685
|
if (accept !== false) this._wireAccept(child, { role })
|
|
679
|
-
|
|
680
|
-
this.children.add(child)
|
|
681
|
-
this.emit('handle', child, { name, role })
|
|
686
|
+
this._adopt(child, { name, role })
|
|
682
687
|
publish(child)
|
|
683
688
|
this._loading?.delete(id)
|
|
684
689
|
return child
|
|
@@ -691,9 +696,7 @@ export class Handle extends ReadyResource {
|
|
|
691
696
|
}
|
|
692
697
|
|
|
693
698
|
/**
|
|
694
|
-
* Join a child handle by invite (joiner-flow).
|
|
695
|
-
* capability and registers the child on the parent's `handles`
|
|
696
|
-
* collection.
|
|
699
|
+
* Join a child handle by invite (joiner-flow).
|
|
697
700
|
*
|
|
698
701
|
* @param {string} invite
|
|
699
702
|
* @param {string} type
|
|
@@ -726,10 +729,8 @@ export class Handle extends ReadyResource {
|
|
|
726
729
|
async _pair(invite, type, { routes, timeout } = {}, target = null) {
|
|
727
730
|
const deadline = timeout || TIMEOUT
|
|
728
731
|
|
|
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.
|
|
732
|
+
// a stored writer still admitted reopens without waiting; a removed writer's core is
|
|
733
|
+
// frozen, so fall through to a real pairing and a fresh keypair
|
|
733
734
|
if (target) {
|
|
734
735
|
const { data: joined } = await this.store.get('handles')
|
|
735
736
|
const existing = joined.find(
|
|
@@ -786,9 +787,7 @@ export class Handle extends ReadyResource {
|
|
|
786
787
|
updatedAt: ts
|
|
787
788
|
})
|
|
788
789
|
this._wireAccept(child)
|
|
789
|
-
|
|
790
|
-
this.children.add(child)
|
|
791
|
-
this.emit('handle', child, {})
|
|
790
|
+
this._adopt(child, {})
|
|
792
791
|
publish(child)
|
|
793
792
|
this._loading?.delete(id)
|
|
794
793
|
return child
|
|
@@ -801,9 +800,8 @@ export class Handle extends ReadyResource {
|
|
|
801
800
|
}
|
|
802
801
|
|
|
803
802
|
/**
|
|
804
|
-
* Get an open child by id, or re-open it. Concurrent calls for the same id
|
|
805
|
-
*
|
|
806
|
-
* exactly once.
|
|
803
|
+
* Get an open child by id, or re-open it. Concurrent calls for the same id share one
|
|
804
|
+
* in-flight load, so the child is built — and `handle` emitted — exactly once.
|
|
807
805
|
*
|
|
808
806
|
* @param {string} type
|
|
809
807
|
* @param {string} id
|
|
@@ -858,21 +856,10 @@ export class Handle extends ReadyResource {
|
|
|
858
856
|
}
|
|
859
857
|
// `accept: false` is a host-approval gate, re-arming it silently is worse
|
|
860
858
|
if (opts?.accept !== false) this._wireAccept(child, { role: opts?.role })
|
|
861
|
-
|
|
862
|
-
this.children.add(child)
|
|
863
|
-
this.emit('handle', child, {})
|
|
859
|
+
this._adopt(child, {})
|
|
864
860
|
return child
|
|
865
861
|
}
|
|
866
862
|
|
|
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
863
|
async _suspend() {
|
|
877
864
|
if (this.closing || this.closed) return
|
|
878
865
|
await Promise.all([...this.children].map((c) => c.pair?.suspend()))
|
|
@@ -885,15 +872,6 @@ export class Handle extends ReadyResource {
|
|
|
885
872
|
}
|
|
886
873
|
}
|
|
887
874
|
|
|
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
875
|
async _resume() {
|
|
898
876
|
if (this.closing || this.closed) return
|
|
899
877
|
try {
|
|
@@ -906,6 +884,74 @@ export class Handle extends ReadyResource {
|
|
|
906
884
|
await Promise.all([...this.children].map((child) => child.pair?.resume()))
|
|
907
885
|
}
|
|
908
886
|
|
|
887
|
+
// a child is a child once it has its operators, the hooks declared for its type, and a slot
|
|
888
|
+
_adopt(child, info) {
|
|
889
|
+
bind(child, child.type, this.operators)
|
|
890
|
+
for (const hook of this._typeHooks) hook.apply(child)
|
|
891
|
+
this.children.add(child)
|
|
892
|
+
this.emit('handle', child, info)
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// before(me.room.notes, fn): on every room open now and every one opened later
|
|
896
|
+
_hookType(op, ref, fn, opts) {
|
|
897
|
+
const offs = new Map()
|
|
898
|
+
const hook = {
|
|
899
|
+
apply: (child) => {
|
|
900
|
+
if (child.type !== ref.type) return
|
|
901
|
+
offs.set(child, op(child[ref.name], fn))
|
|
902
|
+
child.once('close', () => offs.delete(child))
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
this._typeHooks.add(hook)
|
|
906
|
+
for (const child of this.children) hook.apply(child)
|
|
907
|
+
const off = () => {
|
|
908
|
+
this._typeHooks.delete(hook)
|
|
909
|
+
for (const o of offs.values()) o()
|
|
910
|
+
offs.clear()
|
|
911
|
+
}
|
|
912
|
+
onAbort(opts?.signal, off)
|
|
913
|
+
return off
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* @param {Handle} child
|
|
918
|
+
* @param {{ role?: string }} [opts]
|
|
919
|
+
*/
|
|
920
|
+
_wireAccept(child, { role } = {}) {
|
|
921
|
+
child.pair.on('candidate', (cand) => {
|
|
922
|
+
if (this.closing || this.closed || child.closing || child.closed) return
|
|
923
|
+
child.accept(cand, { role }).catch(this._onerror)
|
|
924
|
+
})
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* @param {string} id
|
|
929
|
+
* @param {KeyPair | { publicKey: Uint8Array, secretKey: Uint8Array } | null} keyPair
|
|
930
|
+
* @returns {Promise<void>}
|
|
931
|
+
*/
|
|
932
|
+
async _saveKeyPair(id, keyPair) {
|
|
933
|
+
if (!this.local || !keyPair) return
|
|
934
|
+
await this.local.store.put('handle-keypairs', {
|
|
935
|
+
id,
|
|
936
|
+
publicKey: keyPair.publicKey,
|
|
937
|
+
secretKey: keyPair.secretKey
|
|
938
|
+
})
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* @param {string} id
|
|
943
|
+
* @returns {Promise<{ publicKey: Uint8Array, secretKey: Uint8Array } | null>}
|
|
944
|
+
*/
|
|
945
|
+
async _loadKeyPair(id) {
|
|
946
|
+
if (!this.local) return null
|
|
947
|
+
const { data } = await this.local.store.get('handle-keypairs', id)
|
|
948
|
+
if (!data) return null
|
|
949
|
+
return {
|
|
950
|
+
publicKey: data.publicKey,
|
|
951
|
+
secretKey: data.secretKey
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
909
955
|
/**
|
|
910
956
|
* Pair into an existing handle via an invite, returning a brand-new
|
|
911
957
|
* `Handle` already configured with the resolved key + encryption key.
|
|
@@ -926,8 +972,7 @@ export class Handle extends ReadyResource {
|
|
|
926
972
|
if (!spec) throw CeroError.REQUIRED('spec')
|
|
927
973
|
|
|
928
974
|
const writer = Identity.randomKeyPair()
|
|
929
|
-
// join-only:
|
|
930
|
-
// identity-derived default topic ('Active member already exist')
|
|
975
|
+
// join-only: a member listener here would collide with concurrent joins on the identity topic
|
|
931
976
|
const pair = new Pairing({ network: net, identity: id, host: false })
|
|
932
977
|
await pair.ready()
|
|
933
978
|
|
|
@@ -963,45 +1008,6 @@ export class Handle extends ReadyResource {
|
|
|
963
1008
|
keyPair: writer
|
|
964
1009
|
})
|
|
965
1010
|
}
|
|
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
1011
|
}
|
|
1006
1012
|
|
|
1007
1013
|
function pickHandle(spec, type) {
|