@cero-base/cero 1.18.2 → 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 -644
- package/package.json +16 -12
- 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 +255 -300
- package/src/index.js +65 -112
- package/src/lib/bluetooth.js +25 -56
- package/src/lib/constants.js +0 -15
- package/src/lib/operators.js +24 -74
- package/src/lib/peek.js +4 -8
- package/src/lib/refs.js +46 -0
- package/src/lib/spec.js +2 -3
- package/src/local/index.js +4 -5
- package/src/rpc/client.js +27 -39
- package/src/rpc/index.js +3 -3
- package/src/rpc/server.js +29 -40
- package/types/build/index.d.ts +19 -7
- package/types/build/internal.d.ts +78 -0
- package/types/build/schemas.d.ts +3 -3
- package/types/extensions/handle-sync.d.ts +4 -9
- package/types/extensions/index.d.ts +24 -2
- package/types/extensions/profile-sync.d.ts +2 -6
- package/types/handle/index.d.ts +218 -254
- package/types/index.d.ts +78 -102
- package/types/lib/bluetooth.d.ts +24 -46
- package/types/lib/constants.d.ts +5 -16
- package/types/lib/operators.d.ts +49 -77
- package/types/lib/peek.d.ts +3 -4
- package/types/lib/refs.d.ts +36 -0
- package/types/lib/spec.d.ts +5 -1
- package/types/local/index.d.ts +24 -23
- package/types/rpc/client.d.ts +107 -127
- package/types/rpc/index.d.ts +7 -2
- package/types/rpc/server.d.ts +62 -76
- package/src/build/builtins.js +0 -174
- package/src/lib/internal.js +0 -9
- package/src/lib/utils.js +0 -67
- package/types/build/builtins.d.ts +0 -100
- package/types/lib/internal.d.ts +0 -24
- package/types/lib/utils.d.ts +0 -55
package/src/handle/index.js
CHANGED
|
@@ -12,7 +12,8 @@ import { Identity } from '@cero-base/core/identity'
|
|
|
12
12
|
import { Database } from '@cero-base/core/database'
|
|
13
13
|
import { epochEntries, blobEpochKey } from '@cero-base/core/database/encryption'
|
|
14
14
|
import { Pairing } from '@cero-base/core/pairing'
|
|
15
|
-
import
|
|
15
|
+
import hid from 'hypercore-id-encoding'
|
|
16
|
+
import { grants, can, isRank, admission, REMOVE, onAbort } from '@cero-base/core/utils'
|
|
16
17
|
import { CeroError } from '@cero-base/core/errors'
|
|
17
18
|
import { Blobs } from '@cero-base/core/blobs'
|
|
18
19
|
import { decodeId } from '@cero-base/core/blobs/codec'
|
|
@@ -20,10 +21,10 @@ import { FileServer } from '@cero-base/core/blobs/server'
|
|
|
20
21
|
|
|
21
22
|
import { NS, TIMEOUT } from '../lib/constants.js'
|
|
22
23
|
|
|
23
|
-
import {
|
|
24
|
+
import { Ref } from '../lib/refs.js'
|
|
24
25
|
import { bind } from '../lib/operators.js'
|
|
25
26
|
|
|
26
|
-
export { Ref } from '../lib/
|
|
27
|
+
export { Ref } from '../lib/refs.js'
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* @typedef {import('@cero-base/core/network').Network} Network
|
|
@@ -74,25 +75,18 @@ export { Ref } from '../lib/utils.js'
|
|
|
74
75
|
* @property {string} [role] Role to grant the joining peer. Falls back to the invite's role, then `'member'`.
|
|
75
76
|
* @property {string | null} [name]
|
|
76
77
|
*
|
|
77
|
-
* @typedef {object} RecoverOpts
|
|
78
|
-
* @property {number} [timeout]
|
|
79
|
-
*
|
|
80
78
|
* @typedef {object} HandleExtra
|
|
81
79
|
* @property {string | null} [name] Display name; set on child handles by the owner flow.
|
|
82
|
-
* @property {import('../lib/
|
|
83
|
-
* @property {import('../lib/
|
|
80
|
+
* @property {import('../lib/refs.js').Ref} [profile] `profile` ref, attached dynamically when the schema declares one.
|
|
81
|
+
* @property {import('../lib/refs.js').Ref} [members] `members` ref, attached dynamically when the schema declares one.
|
|
84
82
|
*
|
|
85
83
|
* @typedef {Handle & HandleExtra} Child A child handle plus its dynamically-attached refs.
|
|
86
84
|
*
|
|
87
|
-
* @typedef {Handle & Record<string, import('../lib/
|
|
85
|
+
* @typedef {Handle & Record<string, import('../lib/refs.js').Ref>} CeroHandle A handle with every schema ref reachable as a `Ref` property (e.g. `me.profile`, `room.messages`).
|
|
88
86
|
*/
|
|
89
87
|
|
|
90
88
|
/**
|
|
91
89
|
* A cero handle — a single writable database session attached to a swarm.
|
|
92
|
-
* The "root" handle is the user's facade; child handles (created via
|
|
93
|
-
* `_create`/`_join`/`_load`) live under it and share the same identity,
|
|
94
|
-
* network and corestore. Ref properties (`profile`, `members`, ...) are
|
|
95
|
-
* attached dynamically per the schema; child handles also carry a `name`.
|
|
96
90
|
*/
|
|
97
91
|
export class Handle extends ReadyResource {
|
|
98
92
|
/** @param {HandleOpts} [opts] */
|
|
@@ -152,6 +146,78 @@ export class Handle extends ReadyResource {
|
|
|
152
146
|
this._wantsPair = opts.pair !== false
|
|
153
147
|
}
|
|
154
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
|
+
|
|
155
221
|
async _open() {
|
|
156
222
|
await this.store.ready()
|
|
157
223
|
if (this._wantsPair) {
|
|
@@ -165,13 +231,12 @@ export class Handle extends ReadyResource {
|
|
|
165
231
|
}
|
|
166
232
|
})
|
|
167
233
|
await this.pair.ready()
|
|
168
|
-
// serve invites persisted by any member,
|
|
169
|
-
// replicate in or disappear (minted, revoked or consumed elsewhere)
|
|
234
|
+
// serve invites persisted by any member, in step with the rows
|
|
170
235
|
await this._syncInvites().catch(safetyCatch)
|
|
171
236
|
this._invitesSync = () => this._syncInvites().catch(safetyCatch)
|
|
172
237
|
this.store.on('update', this._invitesSync)
|
|
173
238
|
}
|
|
174
|
-
|
|
239
|
+
Ref.attach(this, this.store.refs)
|
|
175
240
|
this.root._coreKeys.set(b4a.toString(this.store.key, 'hex'), this.store.encryptionKey)
|
|
176
241
|
if (!this.parent) await this.fileServer.listen()
|
|
177
242
|
}
|
|
@@ -218,9 +283,9 @@ export class Handle extends ReadyResource {
|
|
|
218
283
|
}
|
|
219
284
|
|
|
220
285
|
/**
|
|
221
|
-
* Tie a destroyable resource (a `watch` stream, a timer, any `{ destroy }`)
|
|
222
|
-
*
|
|
223
|
-
*
|
|
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.
|
|
224
289
|
*
|
|
225
290
|
* @template {{ destroy?: Function, once?: Function }} T
|
|
226
291
|
* @param {T} resource
|
|
@@ -236,22 +301,6 @@ export class Handle extends ReadyResource {
|
|
|
236
301
|
return resource
|
|
237
302
|
}
|
|
238
303
|
|
|
239
|
-
/**
|
|
240
|
-
* An `AbortSignal` that fires when this handle closes. Pass it as
|
|
241
|
-
* `{ signal }` to `on`/`after`/`before`/`watch` to drop a subscription on
|
|
242
|
-
* close — or use your own `AbortController` for a finer scope.
|
|
243
|
-
*
|
|
244
|
-
* @returns {AbortSignal}
|
|
245
|
-
*/
|
|
246
|
-
get signal() {
|
|
247
|
-
if (!this._ac) {
|
|
248
|
-
this._ac = new AbortController()
|
|
249
|
-
if (this.closed) this._ac.abort()
|
|
250
|
-
else this.once('close', () => this._ac.abort())
|
|
251
|
-
}
|
|
252
|
-
return this._ac.signal
|
|
253
|
-
}
|
|
254
|
-
|
|
255
304
|
/**
|
|
256
305
|
* `EventEmitter.on` plus an optional `{ signal }` that removes the listener
|
|
257
306
|
* when the signal aborts — e.g. `me.on('handle', fn, { signal: me.signal })`.
|
|
@@ -267,42 +316,6 @@ export class Handle extends ReadyResource {
|
|
|
267
316
|
return this
|
|
268
317
|
}
|
|
269
318
|
|
|
270
|
-
/** The top-most handle in the parent chain — itself for a root handle. */
|
|
271
|
-
get root() {
|
|
272
|
-
let h = this
|
|
273
|
-
while (h.parent) h = h.parent
|
|
274
|
-
return h
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Lazily-built file server for this identity. Root-only — child handles
|
|
279
|
-
* reach it through `this.root.fileServer`.
|
|
280
|
-
*
|
|
281
|
-
* @returns {FileServer}
|
|
282
|
-
*/
|
|
283
|
-
get fileServer() {
|
|
284
|
-
if (this.parent) return this.root.fileServer
|
|
285
|
-
if (!this._fileServer) {
|
|
286
|
-
this._fileServer = new FileServer({
|
|
287
|
-
store: this.store.store,
|
|
288
|
-
resolve: (coreKey, info) => this._resolveCore(coreKey, info)
|
|
289
|
-
})
|
|
290
|
-
}
|
|
291
|
-
return this._fileServer
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* @param {Uint8Array} coreKey
|
|
296
|
-
* @param {object} info
|
|
297
|
-
* @returns {{ key: Uint8Array, encryptionKey: Uint8Array } | null}
|
|
298
|
-
*/
|
|
299
|
-
_resolveCore(coreKey, info) {
|
|
300
|
-
const hex = b4a.toString(coreKey, 'hex')
|
|
301
|
-
const encryptionKey = this.root._coreKeys.get(hex)
|
|
302
|
-
if (encryptionKey !== undefined) return { key: coreKey, encryptionKey }
|
|
303
|
-
return null
|
|
304
|
-
}
|
|
305
|
-
|
|
306
319
|
/**
|
|
307
320
|
* Resolve a durable file id to an ephemeral download url via this identity's
|
|
308
321
|
* file server.
|
|
@@ -314,112 +327,6 @@ export class Handle extends ReadyResource {
|
|
|
314
327
|
return this.root.fileServer.getLink(id)
|
|
315
328
|
}
|
|
316
329
|
|
|
317
|
-
/**
|
|
318
|
-
* Lazily-built blob store for THIS handle's writing device, at the current
|
|
319
|
-
* rotation epoch. One core per writer per epoch that writes files: the base
|
|
320
|
-
* era uses the handle's encryptionKey, rotated epochs use a key derived
|
|
321
|
-
* from the epoch entropy — so a removed member cannot decrypt files added
|
|
322
|
-
* after the rotation. Instances carry `.stamp` so the file row records
|
|
323
|
-
* which era its core belongs to.
|
|
324
|
-
*
|
|
325
|
-
* @returns {Blobs}
|
|
326
|
-
*/
|
|
327
|
-
get blobs() {
|
|
328
|
-
const stamp = this.store.keyring.current
|
|
329
|
-
if (!stamp) return this._baseBlobs()
|
|
330
|
-
let blobs = this._epochBlobs?.get(stamp)
|
|
331
|
-
if (!blobs) {
|
|
332
|
-
const entropy = this.store.keyring.entropy(stamp)
|
|
333
|
-
blobs = this._makeBlobs(`blobs-${stamp}`, blobEpochKey(entropy), stamp)
|
|
334
|
-
;(this._epochBlobs ??= new Map()).set(stamp, blobs)
|
|
335
|
-
}
|
|
336
|
-
return blobs
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
_baseBlobs() {
|
|
340
|
-
if (!this._blobs) this._blobs = this._makeBlobs('blobs', this.store.encryptionKey, 0)
|
|
341
|
-
return this._blobs
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
_makeBlobs(name, encryptionKey, stamp) {
|
|
345
|
-
const blobs = new Blobs({
|
|
346
|
-
store: this.store.store,
|
|
347
|
-
network: this.network,
|
|
348
|
-
encryptionKey,
|
|
349
|
-
name
|
|
350
|
-
})
|
|
351
|
-
blobs.stamp = stamp
|
|
352
|
-
blobs
|
|
353
|
-
.ready()
|
|
354
|
-
.then(() => {
|
|
355
|
-
// close prunes _coreKeys first, a late ready() must not re-insert the entry
|
|
356
|
-
if (this.closing || this.closed || !blobs.key) return
|
|
357
|
-
this.root._coreKeys.set(b4a.toString(blobs.key, 'hex'), encryptionKey)
|
|
358
|
-
})
|
|
359
|
-
.catch(this._onerror)
|
|
360
|
-
return blobs
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* Remember the blob-core key a file id points at so the file server can
|
|
365
|
-
* open the core. Lives on the Handle (not the shared operators) because the
|
|
366
|
-
* epoch-key derivation pulls native crypto — the RPC client must stay
|
|
367
|
-
* bundleable without it.
|
|
368
|
-
*
|
|
369
|
-
* @param {string} id
|
|
370
|
-
* @param {number} [stamp]
|
|
371
|
-
*/
|
|
372
|
-
_registerBlobCore(id, stamp) {
|
|
373
|
-
if (!id || !this.root?._coreKeys) return
|
|
374
|
-
try {
|
|
375
|
-
const { coreKey } = decodeId(id)
|
|
376
|
-
const hex = b4a.toString(coreKey, 'hex')
|
|
377
|
-
if (!this.root._coreKeys.has(hex)) {
|
|
378
|
-
// no stamp on a file-field value, look it up (fire-and-forget, idempotent)
|
|
379
|
-
if (stamp === undefined) {
|
|
380
|
-
this.store
|
|
381
|
-
.get('files', id)
|
|
382
|
-
.then(({ data }) => {
|
|
383
|
-
if (data && !this.closing && !this.closed) this._registerBlobCore(id, data.stamp || 0)
|
|
384
|
-
})
|
|
385
|
-
.catch(safetyCatch)
|
|
386
|
-
return
|
|
387
|
-
}
|
|
388
|
-
const key = this._blobCoreKey(stamp)
|
|
389
|
-
if (!key) return // unknown epoch — this device is not entitled to the core
|
|
390
|
-
this.root._coreKeys.set(hex, key)
|
|
391
|
-
}
|
|
392
|
-
// remember which handle read it, so close prunes the entry
|
|
393
|
-
if (this !== this.root) (this._blobKeys ??= new Set()).add(hex)
|
|
394
|
-
} catch {
|
|
395
|
-
// ignore invalid ids
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// base-era cores use the OWNING handle's key, rooms have their own
|
|
400
|
-
_blobCoreKey(stamp) {
|
|
401
|
-
if (!stamp) return this.store.encryptionKey
|
|
402
|
-
const entropy = this.store.keyring.entropy(stamp)
|
|
403
|
-
return entropy ? blobEpochKey(entropy) : null
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/** Canonical id — identity id for the root handle, store key for children. */
|
|
407
|
-
get id() {
|
|
408
|
-
if (!this.parent) return this.identity.id
|
|
409
|
-
return this.store?.key ? toId(this.store.key) : null
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/** This device's id + name. `null` on child handles. */
|
|
413
|
-
get device() {
|
|
414
|
-
if (this.parent) return null
|
|
415
|
-
const k = this.store?.writerKey
|
|
416
|
-
return k ? { id: toId(k), name: this._opts.name || null } : null
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
get suspended() {
|
|
420
|
-
return this._sus?.suspended === true
|
|
421
|
-
}
|
|
422
|
-
|
|
423
330
|
/**
|
|
424
331
|
* Initialise a fresh database: write the genesis claim, derive the writer.
|
|
425
332
|
* Forwards to `Database.bootstrap`.
|
|
@@ -442,23 +349,9 @@ export class Handle extends ReadyResource {
|
|
|
442
349
|
}
|
|
443
350
|
|
|
444
351
|
/**
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
* @param {RecoverOpts} [opts]
|
|
449
|
-
* @returns {Promise<void>}
|
|
450
|
-
*/
|
|
451
|
-
async recover({ timeout = TIMEOUT } = {}) {
|
|
452
|
-
if (this.store.writable) return
|
|
453
|
-
await this.store.claim()
|
|
454
|
-
await this.store.whenWritable({ timeout })
|
|
455
|
-
await this.store.bee.update()
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* Flip this handle's swarm announce mode — `setActive(false)` demotes an
|
|
460
|
-
* idle/background room to server-only (still reachable, stops searching);
|
|
461
|
-
* `setActive(true)` promotes it back on focus. Cheap, safe to call often.
|
|
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.
|
|
462
355
|
*
|
|
463
356
|
* @param {boolean} active
|
|
464
357
|
* @returns {Promise<void>}
|
|
@@ -501,12 +394,6 @@ export class Handle extends ReadyResource {
|
|
|
501
394
|
return str
|
|
502
395
|
}
|
|
503
396
|
|
|
504
|
-
async _syncInvites() {
|
|
505
|
-
if (!this.pair) return
|
|
506
|
-
const { data } = await this.store.get('invites')
|
|
507
|
-
this.pair.syncRows(data || [])
|
|
508
|
-
}
|
|
509
|
-
|
|
510
397
|
/**
|
|
511
398
|
* Revoke a previously-minted invite by its string form.
|
|
512
399
|
*
|
|
@@ -554,10 +441,8 @@ export class Handle extends ReadyResource {
|
|
|
554
441
|
throw CeroError.INVALID(`role '${role}' is not a rank (owner, admin, member, reader)`)
|
|
555
442
|
}
|
|
556
443
|
|
|
557
|
-
// confirm must answer within the
|
|
558
|
-
//
|
|
559
|
-
// membership writes land, and a failure there is recoverable by re-pairing.
|
|
560
|
-
// 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
|
|
561
446
|
const epochs = this.store.keyring.all()
|
|
562
447
|
await candidate.confirm({
|
|
563
448
|
key: this.store.key,
|
|
@@ -568,7 +453,7 @@ export class Handle extends ReadyResource {
|
|
|
568
453
|
const ts = Date.now()
|
|
569
454
|
const writerKey = Hypercore.key({ version: 2, signers: [{ publicKey: data.subarray(32, 64) }] })
|
|
570
455
|
const member = {
|
|
571
|
-
id:
|
|
456
|
+
id: hid.encode(data.subarray(0, 32)),
|
|
572
457
|
key: writerKey,
|
|
573
458
|
role,
|
|
574
459
|
name: name || null,
|
|
@@ -581,9 +466,7 @@ export class Handle extends ReadyResource {
|
|
|
581
466
|
return
|
|
582
467
|
}
|
|
583
468
|
|
|
584
|
-
const sig = this.identity.sign(
|
|
585
|
-
addWriterPayload(this.store.key, writerKey, this.store.writerKey)
|
|
586
|
-
)
|
|
469
|
+
const sig = this.identity.sign(admission(this.store.key, writerKey, this.store.writerKey))
|
|
587
470
|
await this.store.tx(async (tx) => {
|
|
588
471
|
await tx.call('add-writer', {
|
|
589
472
|
sig,
|
|
@@ -597,6 +480,118 @@ export class Handle extends ReadyResource {
|
|
|
597
480
|
})
|
|
598
481
|
}
|
|
599
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
|
+
|
|
600
595
|
// `grants` treats an unknown role as "no", so an app role name must fail loudly
|
|
601
596
|
async _checkGrant(role) {
|
|
602
597
|
if (!isRank(role)) {
|
|
@@ -611,21 +606,8 @@ export class Handle extends ReadyResource {
|
|
|
611
606
|
}
|
|
612
607
|
|
|
613
608
|
/**
|
|
614
|
-
*
|
|
615
|
-
* and
|
|
616
|
-
*
|
|
617
|
-
* @returns {Promise<void>}
|
|
618
|
-
*/
|
|
619
|
-
async leave() {
|
|
620
|
-
if (!this.parent) return
|
|
621
|
-
await this.parent.store.call('del-handle', { id: toId(this.store.key) })
|
|
622
|
-
await this.close()
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
/**
|
|
626
|
-
* Create a new child handle of `type`. Owner-flow — generates a fresh
|
|
627
|
-
* writer, adds it as a writer + member, and registers the child on the
|
|
628
|
-
* 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.
|
|
629
611
|
*
|
|
630
612
|
* @param {string} type
|
|
631
613
|
* @param {CreateChildOpts} [opts]
|
|
@@ -652,9 +634,8 @@ export class Handle extends ReadyResource {
|
|
|
652
634
|
await child.ready()
|
|
653
635
|
child.name = name
|
|
654
636
|
|
|
655
|
-
const id =
|
|
656
|
-
// add-handle makes the row visible before
|
|
657
|
-
// duplicate Handle over the same core deadlocks in ready()
|
|
637
|
+
const id = hid.encode(child.store.key)
|
|
638
|
+
// add-handle makes the row visible before create finishes; a second Handle on the same core deadlocks
|
|
658
639
|
const inflight = new Promise((resolve, reject) => {
|
|
659
640
|
publish = resolve
|
|
660
641
|
abort = reject
|
|
@@ -671,9 +652,7 @@ export class Handle extends ReadyResource {
|
|
|
671
652
|
await tx.call('add-writer', {
|
|
672
653
|
master: this.identity.publicKey,
|
|
673
654
|
writer: writerKey,
|
|
674
|
-
sig: this.identity.sign(
|
|
675
|
-
addWriterPayload(child.store.key, writerKey, child.store.writerKey)
|
|
676
|
-
),
|
|
655
|
+
sig: this.identity.sign(admission(child.store.key, writerKey, child.store.writerKey)),
|
|
677
656
|
ts
|
|
678
657
|
})
|
|
679
658
|
await tx.call('add-member', {
|
|
@@ -711,9 +690,7 @@ export class Handle extends ReadyResource {
|
|
|
711
690
|
}
|
|
712
691
|
|
|
713
692
|
/**
|
|
714
|
-
* Join a child handle by invite (joiner-flow).
|
|
715
|
-
* capability and registers the child on the parent's `handles`
|
|
716
|
-
* collection.
|
|
693
|
+
* Join a child handle by invite (joiner-flow).
|
|
717
694
|
*
|
|
718
695
|
* @param {string} invite
|
|
719
696
|
* @param {string} type
|
|
@@ -746,10 +723,8 @@ export class Handle extends ReadyResource {
|
|
|
746
723
|
async _pair(invite, type, { routes, timeout } = {}, target = null) {
|
|
747
724
|
const deadline = timeout || TIMEOUT
|
|
748
725
|
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
// only gets it re-removed, so fall through to a real pairing, which mints a
|
|
752
|
-
// 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
|
|
753
728
|
if (target) {
|
|
754
729
|
const { data: joined } = await this.store.get('handles')
|
|
755
730
|
const existing = joined.find(
|
|
@@ -758,7 +733,7 @@ export class Handle extends ReadyResource {
|
|
|
758
733
|
if (existing) {
|
|
759
734
|
const known = await this._load(type, existing.id)
|
|
760
735
|
const { data: me } = await known.store.get('members', this.identity.id)
|
|
761
|
-
const { data: device } = await known.store.get('devices',
|
|
736
|
+
const { data: device } = await known.store.get('devices', hid.encode(known.store.writerKey))
|
|
762
737
|
if (me && device) return known
|
|
763
738
|
await known.close().catch(safetyCatch)
|
|
764
739
|
}
|
|
@@ -784,7 +759,7 @@ export class Handle extends ReadyResource {
|
|
|
784
759
|
await child.ready()
|
|
785
760
|
if (!child.store.writable) await child.store.whenWritable({ timeout: deadline })
|
|
786
761
|
|
|
787
|
-
const id =
|
|
762
|
+
const id = hid.encode(child.store.key)
|
|
788
763
|
// same create/open race as _create, a concurrent _load must share this child
|
|
789
764
|
const inflight = new Promise((resolve, reject) => {
|
|
790
765
|
publish = resolve
|
|
@@ -821,9 +796,8 @@ export class Handle extends ReadyResource {
|
|
|
821
796
|
}
|
|
822
797
|
|
|
823
798
|
/**
|
|
824
|
-
* Get an open child by id, or re-open it. Concurrent calls for the same id
|
|
825
|
-
*
|
|
826
|
-
* 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.
|
|
827
801
|
*
|
|
828
802
|
* @param {string} type
|
|
829
803
|
* @param {string} id
|
|
@@ -884,15 +858,6 @@ export class Handle extends ReadyResource {
|
|
|
884
858
|
return child
|
|
885
859
|
}
|
|
886
860
|
|
|
887
|
-
/**
|
|
888
|
-
* Pause networking + storage. Idempotent; no-op on child handles.
|
|
889
|
-
*
|
|
890
|
-
* @returns {Promise<void>}
|
|
891
|
-
*/
|
|
892
|
-
async suspend() {
|
|
893
|
-
if (this._sus) await this._sus.suspend()
|
|
894
|
-
}
|
|
895
|
-
|
|
896
861
|
async _suspend() {
|
|
897
862
|
if (this.closing || this.closed) return
|
|
898
863
|
await Promise.all([...this.children].map((c) => c.pair?.suspend()))
|
|
@@ -905,15 +870,6 @@ export class Handle extends ReadyResource {
|
|
|
905
870
|
}
|
|
906
871
|
}
|
|
907
872
|
|
|
908
|
-
/**
|
|
909
|
-
* Resume a suspended root handle. Idempotent; no-op on child handles.
|
|
910
|
-
*
|
|
911
|
-
* @returns {Promise<void>}
|
|
912
|
-
*/
|
|
913
|
-
async resume() {
|
|
914
|
-
if (this._sus) await this._sus.resume()
|
|
915
|
-
}
|
|
916
|
-
|
|
917
873
|
async _resume() {
|
|
918
874
|
if (this.closing || this.closed) return
|
|
919
875
|
try {
|
|
@@ -926,6 +882,45 @@ export class Handle extends ReadyResource {
|
|
|
926
882
|
await Promise.all([...this.children].map((child) => child.pair?.resume()))
|
|
927
883
|
}
|
|
928
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
|
+
|
|
929
924
|
/**
|
|
930
925
|
* Pair into an existing handle via an invite, returning a brand-new
|
|
931
926
|
* `Handle` already configured with the resolved key + encryption key.
|
|
@@ -946,8 +941,7 @@ export class Handle extends ReadyResource {
|
|
|
946
941
|
if (!spec) throw CeroError.REQUIRED('spec')
|
|
947
942
|
|
|
948
943
|
const writer = Identity.randomKeyPair()
|
|
949
|
-
// join-only:
|
|
950
|
-
// identity-derived default topic ('Active member already exist')
|
|
944
|
+
// join-only: a member listener here would collide with concurrent joins on the identity topic
|
|
951
945
|
const pair = new Pairing({ network: net, identity: id, host: false })
|
|
952
946
|
await pair.ready()
|
|
953
947
|
|
|
@@ -983,45 +977,6 @@ export class Handle extends ReadyResource {
|
|
|
983
977
|
keyPair: writer
|
|
984
978
|
})
|
|
985
979
|
}
|
|
986
|
-
|
|
987
|
-
/**
|
|
988
|
-
* @param {Handle} child
|
|
989
|
-
* @param {{ role?: string }} [opts]
|
|
990
|
-
*/
|
|
991
|
-
_wireAccept(child, { role } = {}) {
|
|
992
|
-
child.pair.on('candidate', (cand) => {
|
|
993
|
-
if (this.closing || this.closed || child.closing || child.closed) return
|
|
994
|
-
child.accept(cand, { role }).catch(this._onerror)
|
|
995
|
-
})
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
/**
|
|
999
|
-
* @param {string} id
|
|
1000
|
-
* @param {KeyPair | { publicKey: Uint8Array, secretKey: Uint8Array } | null} keyPair
|
|
1001
|
-
* @returns {Promise<void>}
|
|
1002
|
-
*/
|
|
1003
|
-
async _saveKeyPair(id, keyPair) {
|
|
1004
|
-
if (!this.local || !keyPair) return
|
|
1005
|
-
await this.local.store.put('handle-keypairs', {
|
|
1006
|
-
id,
|
|
1007
|
-
publicKey: keyPair.publicKey,
|
|
1008
|
-
secretKey: keyPair.secretKey
|
|
1009
|
-
})
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
/**
|
|
1013
|
-
* @param {string} id
|
|
1014
|
-
* @returns {Promise<{ publicKey: Uint8Array, secretKey: Uint8Array } | null>}
|
|
1015
|
-
*/
|
|
1016
|
-
async _loadKeyPair(id) {
|
|
1017
|
-
if (!this.local) return null
|
|
1018
|
-
const { data } = await this.local.store.get('handle-keypairs', id)
|
|
1019
|
-
if (!data) return null
|
|
1020
|
-
return {
|
|
1021
|
-
publicKey: data.publicKey,
|
|
1022
|
-
secretKey: data.secretKey
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
980
|
}
|
|
1026
981
|
|
|
1027
982
|
function pickHandle(spec, type) {
|