@cero-base/core 0.8.9 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,16 @@
1
- import { SINGLE, COLLECTION, ACTION, COUNTERS } from '../lib/constants.js'
2
- import { toId, toKey } from '../lib/utils.js'
1
+ import b4a from 'b4a'
2
+
3
+ import {
4
+ SINGLE,
5
+ COLLECTION,
6
+ ACTION,
7
+ COUNTERS,
8
+ INVITE,
9
+ REMOVE,
10
+ ASSIGN,
11
+ WRITE
12
+ } from '../lib/constants.js'
13
+ import { toId, toKey, can, grants, outranks } from '../lib/utils.js'
3
14
  import { Identity } from '../identity/index.js'
4
15
 
5
16
  // Built-in collections wired into every cero spec.
@@ -7,15 +18,43 @@ export const BUILTINS = [
7
18
  { name: 'members', verb: 'member' },
8
19
  { name: 'devices', verb: 'device' },
9
20
  { name: 'invites', verb: 'invite' },
10
- { name: 'handles', verb: 'handle' }
21
+ { name: 'handles', verb: 'handle' },
22
+ { name: 'files', verb: 'file' }
11
23
  ]
12
24
 
25
+ /**
26
+ * Build the hyperdispatch router for a spec: wires membership, builtin, and
27
+ * spec-defined collection/action ops, returning the router plus an apply loop.
28
+ *
29
+ * @param {{ dispatch: { Router: Function }, meta?: { refs?: Record<string, { kind?: string, builtin?: boolean, verb?: string }> } }} spec Generated hyperdispatch spec.
30
+ * @param {string} ns Namespace prefix for collection and op names.
31
+ * @param {Record<string, Function>} routes Custom action handlers keyed by route name.
32
+ * @returns {{ dispatcher: object, apply: (nodes: Array<{ value: Buffer, key: Buffer }>, view: object, host: object) => Promise<void> }}
33
+ */
13
34
  export function makeDispatcher(spec, ns, routes) {
14
35
  const dispatcher = new spec.dispatch.Router()
15
36
 
16
37
  const countersCol = `@${ns}/${COUNTERS}`
17
- const member = (view, id) => view.get(`@${ns}/members`, { id })
18
- const device = (view, id) => view.get(`@${ns}/devices`, { id })
38
+ const getMember = (view, id) => view.get(`@${ns}/members`, { id })
39
+ const getDevice = (view, id) => view.get(`@${ns}/devices`, { id })
40
+
41
+ const getRole = async (view, identityKey) =>
42
+ identityKey ? ((await getMember(view, toId(identityKey)))?.role ?? null) : null
43
+
44
+ const getSignerRole = async (view, writerKey) => {
45
+ if (!writerKey) return null
46
+ const d = await getDevice(view, toId(writerKey))
47
+ return d?.memberId ? ((await getMember(view, d.memberId))?.role ?? null) : null
48
+ }
49
+ const isGenesis = async (view) => !(await view.findOne(`@${ns}/members`, {}))
50
+ const getSignerMember = async (view, key) =>
51
+ (key ? (await getDevice(view, toId(key)))?.memberId : null) ?? null
52
+ const canRemoveMember = async (view, signerKey, targetMemberId) => {
53
+ const r = await getSignerRole(view, signerKey)
54
+ if (!can(r, REMOVE)) return false
55
+ const tRole = targetMemberId ? (await getMember(view, targetMemberId))?.role : null
56
+ return !tRole || outranks(r, tRole)
57
+ }
19
58
 
20
59
  async function insert(view, name, col, op) {
21
60
  if (name !== COUNTERS) {
@@ -32,7 +71,7 @@ export function makeDispatcher(spec, ns, routes) {
32
71
  }
33
72
 
34
73
  const upsert = (name, col, kind) => async (op, ctx) => {
35
- const d = ctx.key && (await device(ctx.view, toId(ctx.key)))
74
+ const d = ctx.key && (await getDevice(ctx.view, toId(ctx.key)))
36
75
  op.memberId = d?.memberId || null
37
76
  if (kind === COLLECTION) await insert(ctx.view, name, col, op)
38
77
  else await ctx.view.insert(col, op)
@@ -46,7 +85,8 @@ export function makeDispatcher(spec, ns, routes) {
46
85
  const add = (verb, fn) => dispatcher.add(`@${ns}/${verb}`, fn)
47
86
 
48
87
  add('add-writer', async (op, ctx) => {
49
- if (!Identity.verify(op.master, op.writer, op.sig)) return
88
+ if (!Identity.verify(op.master, b4a.concat([op.writer, ctx.key]), op.sig)) return
89
+ if (!(await isGenesis(ctx.view)) && !can(await getRole(ctx.view, op.master), INVITE)) return
50
90
  await ctx.host.addWriter(op.writer, { isIndexer: op.isIndexer !== false })
51
91
  const ts = Date.now()
52
92
  await insert(ctx.view, 'devices', `@${ns}/devices`, {
@@ -58,15 +98,19 @@ export function makeDispatcher(spec, ns, routes) {
58
98
  })
59
99
 
60
100
  add('del-writer', async (op, ctx) => {
61
- if (!Identity.verify(op.master, op.writer, op.sig)) return
101
+ const target = await getDevice(ctx.view, toId(op.writer))
102
+ if (target?.memberId !== (await getSignerMember(ctx.view, ctx.key))) {
103
+ if (!(await canRemoveMember(ctx.view, ctx.key, target?.memberId))) return
104
+ }
62
105
  await ctx.host.removeWriter(op.writer)
63
106
  await ctx.view.delete(`@${ns}/devices`, { id: toId(op.writer) })
64
107
  })
65
108
 
66
109
  add('claim-writer', async (op, ctx) => {
67
110
  if (!Identity.verify(op.identity, op.writer, op.sig)) return
111
+ if (!b4a.equals(ctx.key, op.writer)) return
68
112
  const memberId = toId(op.identity)
69
- if (!(await member(ctx.view, memberId))) return
113
+ if (!can(await getRole(ctx.view, op.identity), WRITE)) return
70
114
  await ctx.host.addWriter(op.writer, { isIndexer: true })
71
115
  const ts = Date.now()
72
116
  await insert(ctx.view, 'devices', `@${ns}/devices`, {
@@ -78,9 +122,13 @@ export function makeDispatcher(spec, ns, routes) {
78
122
  })
79
123
 
80
124
  add('add-member', async (op, ctx) => {
125
+ if (!(await isGenesis(ctx.view))) {
126
+ const r = await getSignerRole(ctx.view, ctx.key)
127
+ if (!can(r, INVITE) || !grants(r, op.role)) return
128
+ }
81
129
  await insert(ctx.view, 'members', `@${ns}/members`, op)
82
130
  const deviceId = toId(op.key)
83
- const existingDevice = await device(ctx.view, deviceId)
131
+ const existingDevice = await getDevice(ctx.view, deviceId)
84
132
  const ts = Date.now()
85
133
  await insert(ctx.view, 'devices', `@${ns}/devices`, {
86
134
  id: deviceId,
@@ -93,21 +141,35 @@ export function makeDispatcher(spec, ns, routes) {
93
141
  })
94
142
 
95
143
  add('set-member', async (op, ctx) => {
96
- const existing = await member(ctx.view, op.id)
144
+ const existing = await getMember(ctx.view, op.id)
97
145
  if (!existing) return
98
- await insert(ctx.view, 'members', `@${ns}/members`, { ...existing, ...op })
146
+ const next = { ...existing, ...op }
147
+ if (next.role !== existing.role) {
148
+ const r = await getSignerRole(ctx.view, ctx.key)
149
+ if (!can(r, ASSIGN) || !grants(r, next.role) || !outranks(r, existing.role)) {
150
+ next.role = existing.role
151
+ }
152
+ }
153
+ await insert(ctx.view, 'members', `@${ns}/members`, next)
99
154
  })
100
155
 
101
156
  add('del-member', async (op, ctx) => {
102
- const existing = await member(ctx.view, op.id)
157
+ const existing = await getMember(ctx.view, op.id)
103
158
  if (!existing) return
159
+ if (op.id !== (await getSignerMember(ctx.view, ctx.key))) {
160
+ const r = await getSignerRole(ctx.view, ctx.key)
161
+ if (!can(r, REMOVE) || !outranks(r, existing.role)) return
162
+ }
104
163
  if (existing.key) await ctx.host.removeWriter(existing.key)
105
164
  await ctx.view.delete(`@${ns}/members`, op)
106
165
  })
107
166
 
108
167
  add('del-device', async (op, ctx) => {
109
- const existing = await device(ctx.view, op.id)
168
+ const existing = await getDevice(ctx.view, op.id)
110
169
  if (!existing) return
170
+ if (existing.memberId !== (await getSignerMember(ctx.view, ctx.key))) {
171
+ if (!(await canRemoveMember(ctx.view, ctx.key, existing.memberId))) return
172
+ }
111
173
  await ctx.host.removeWriter(toKey(existing.id))
112
174
  await ctx.view.delete(`@${ns}/devices`, op)
113
175
  })
@@ -120,7 +182,7 @@ export function makeDispatcher(spec, ns, routes) {
120
182
  await insert(ctx.view, b.name, col, { ...op, memberId: op.memberId ?? null })
121
183
  })
122
184
  add(`set-${b.verb}`, async (op, ctx) => {
123
- const existing = await device(ctx.view, op.id)
185
+ const existing = await getDevice(ctx.view, op.id)
124
186
  const ts = Date.now()
125
187
  await insert(ctx.view, b.name, col, {
126
188
  ...op,
@@ -131,7 +193,15 @@ export function makeDispatcher(spec, ns, routes) {
131
193
  })
132
194
  continue
133
195
  }
134
- add(`add-${b.verb}`, upsert(b.name, col, COLLECTION))
196
+ if (b.verb === 'file') {
197
+ add('add-file', async (op, ctx) => {
198
+ if (!can(await getSignerRole(ctx.view, ctx.key), WRITE)) return
199
+ const memberId = await getSignerMember(ctx.view, ctx.key)
200
+ await insert(ctx.view, b.name, col, { id: op.id, name: op.name ?? null, memberId })
201
+ })
202
+ } else {
203
+ add(`add-${b.verb}`, upsert(b.name, col, COLLECTION))
204
+ }
135
205
  add(`set-${b.verb}`, update(b.name, col))
136
206
  add(`del-${b.verb}`, remove(col))
137
207
  }
@@ -146,7 +216,11 @@ export function makeDispatcher(spec, ns, routes) {
146
216
  const col = `@${ns}/${name}`
147
217
  const kind = info.kind === SINGLE ? SINGLE : COLLECTION
148
218
  add(`set-${name}`, kind === SINGLE ? upsert(name, col, kind) : update(name, col))
149
- if (info.kind === SINGLE) continue
219
+ if (info.kind === SINGLE) {
220
+ // wipe the keyless single row (the dummy id in the op is ignored)
221
+ add(`del-${name}`, async (op, ctx) => ctx.view.delete(col, {}))
222
+ continue
223
+ }
150
224
  add(`add-${name}`, upsert(name, col, COLLECTION))
151
225
  add(`del-${name}`, remove(col))
152
226
  }
@@ -5,7 +5,7 @@ import ReadyResource from 'ready-resource'
5
5
  import safetyCatch from 'safety-catch'
6
6
  import b4a from 'b4a'
7
7
 
8
- import { NAMESPACE, SINGLE, COLLECTION } from '../lib/constants.js'
8
+ import { NAMESPACE, SINGLE, COLLECTION, ACTION } from '../lib/constants.js'
9
9
  import { genId, subscribe } from '../lib/utils.js'
10
10
  import { CeroError } from '../lib/errors.js'
11
11
  import { bootstrap } from './bootstrap.js'
@@ -23,6 +23,7 @@ import { makeDispatcher } from './dispatch.js'
23
23
  * @property {(nodes: any, view: any, host: any) => Promise<void>} [apply] Override the default apply function.
24
24
  * @property {Uint8Array | null} [key] Existing autobee key to reopen.
25
25
  * @property {import('../identity/index.js').KeyPair} [keyPair] Device writer keypair; defaults to identity's keypair.
26
+ * @property {(err: Error) => void} [onerror] Called when a background after-hook or onApply callback fails.
26
27
  *
27
28
  * @typedef {{ data: any | null }} SingleResult
28
29
  * @typedef {{ data: any[], total: number, size: number }} ListResult
@@ -65,6 +66,7 @@ export class Database extends ReadyResource {
65
66
  secretKey: opts.identity.secretKey
66
67
  }
67
68
 
69
+ this._onerror = opts.onerror || safetyCatch
68
70
  this.bee = null
69
71
  this.dispatcher = null
70
72
 
@@ -89,6 +91,9 @@ export class Database extends ReadyResource {
89
91
  this._discovery = null
90
92
  }
91
93
  if (this.bee) {
94
+ // detach before closing — else the closed bee lingers in the network's
95
+ // _replicateables set and gets replicated into every new connection
96
+ if (this.network) this.network.detach(this.bee)
92
97
  await this.bee.close()
93
98
  this.bee = null
94
99
  }
@@ -126,26 +131,34 @@ export class Database extends ReadyResource {
126
131
 
127
132
  if (this.network) {
128
133
  this.network.attach(bee)
134
+ // a writer swap re-opens the bee — tear down the prior discovery session
135
+ // first so it isn't orphaned by the reassignment below
136
+ if (this._discovery) await this._discovery.destroy()
129
137
  this._discovery = this.network.join(bee.discoveryKey)
130
138
  }
131
139
  }
132
140
 
141
+ /** @returns {Uint8Array | null} discovery key of the underlying bee */
133
142
  get discoveryKey() {
134
143
  return this.bee?.discoveryKey || null
135
144
  }
136
145
 
146
+ /** @returns {Uint8Array | null} this device's local writer key */
137
147
  get writerKey() {
138
148
  return this.bee?.local?.key || null
139
149
  }
140
150
 
151
+ /** @returns {boolean} whether the bee accepts local writes */
141
152
  get writable() {
142
153
  return this.bee?.writable === true
143
154
  }
144
155
 
156
+ /** @returns {number} number of ops in the local writer */
145
157
  get length() {
146
158
  return this.bee?.local?.length || 0
147
159
  }
148
160
 
161
+ /** @returns {object | null} the materialized HyperDB view */
149
162
  get view() {
150
163
  return this.bee?.view || null
151
164
  }
@@ -220,7 +233,7 @@ export class Database extends ReadyResource {
220
233
  try {
221
234
  fn(event)
222
235
  } catch (err) {
223
- safetyCatch(err)
236
+ this._onerror(err)
224
237
  }
225
238
  }
226
239
  }
@@ -259,7 +272,7 @@ export class Database extends ReadyResource {
259
272
  try {
260
273
  await fn(ctx)
261
274
  } catch (err) {
262
- safetyCatch(err)
275
+ this._onerror(err)
263
276
  }
264
277
  }
265
278
  }
@@ -276,6 +289,7 @@ export class Database extends ReadyResource {
276
289
  async put(name, row) {
277
290
  this.guard()
278
291
  const ref = this.ref(name)
292
+ this._checkFields(name, row)
279
293
  const id = row.id || genId()
280
294
  const ts = Date.now()
281
295
  const stored = { createdAt: ts, updatedAt: ts, ...row, id }
@@ -303,6 +317,7 @@ export class Database extends ReadyResource {
303
317
  async set(name, row, { upsert = true } = {}) {
304
318
  this.guard()
305
319
  const ref = this.ref(name)
320
+ this._checkFields(name, row)
306
321
  const col = this.col(ref)
307
322
  const ts = Date.now()
308
323
  const existing =
@@ -347,7 +362,10 @@ export class Database extends ReadyResource {
347
362
  const ctx = { op: 'del', name, id }
348
363
  if ((await this.runBefore('del', ctx)) === false) return null
349
364
 
350
- await this.write([[`del-${ref.verb}`, { id }]])
365
+ // Singles have no id — the apply handler wipes the keyless row; the dummy id
366
+ // just satisfies the shared del-by-id encoding.
367
+ const payload = ref.kind === SINGLE ? { id: '' } : { id }
368
+ await this.write([[`del-${ref.verb}`, payload]])
351
369
 
352
370
  await this.runAfter('del', ctx)
353
371
  }
@@ -361,6 +379,13 @@ export class Database extends ReadyResource {
361
379
  */
362
380
  async call(op, data = {}) {
363
381
  this.guard()
382
+ // A declared action with no local route would apply as a silent no-op — fail
383
+ // loud before writing a dead op. (Builtin verbs aren't refs, so they pass.)
384
+ if (this.refs[op]?.kind === ACTION && typeof this.routes[op] !== 'function') {
385
+ throw CeroError.INVALID(
386
+ `action '${op}' has no route — pass { routes: { ${op} } } when opening the handle`
387
+ )
388
+ }
364
389
  await this.write([[op, data]])
365
390
  }
366
391
 
@@ -373,19 +398,28 @@ export class Database extends ReadyResource {
373
398
  * @returns {Promise<T>}
374
399
  */
375
400
  async tx(fn) {
401
+ // Nested tx() (called while an outer tx's fn is running) joins the outer queue.
376
402
  if (this.txQueue) return fn()
377
- const queue = []
378
- this.txQueue = queue
379
- let result
380
- try {
381
- result = await fn()
382
- } catch (err) {
383
- this.txQueue = null
384
- throw err
403
+ // Outer transactions serialize — the run is deferred onto a chain so two
404
+ // concurrent tx() calls each get their own queue instead of the second
405
+ // buffering into the first's (and being dropped if the first rolls back).
406
+ const run = async () => {
407
+ const queue = []
408
+ this.txQueue = queue
409
+ try {
410
+ const result = await fn()
411
+ this.txQueue = null
412
+ if (queue.length) await this.write(queue)
413
+ return result
414
+ } catch (err) {
415
+ this.txQueue = null
416
+ throw err
417
+ }
385
418
  }
386
- this.txQueue = null
387
- if (queue.length) await this.write(queue)
388
- return result
419
+ const prev = this._txChain || Promise.resolve()
420
+ // chain past prev's outcome so one failure doesn't wedge later transactions
421
+ this._txChain = prev.then(run, run)
422
+ return this._txChain
389
423
  }
390
424
 
391
425
  /**
@@ -430,8 +464,9 @@ export class Database extends ReadyResource {
430
464
 
431
465
  const idx = this.matchIndex(name, query)
432
466
  if (idx) {
433
- const data = await this.view.find(idx.path, idx.range).toArray()
434
- return { data, total: data.length, size: data.length }
467
+ const rows = await this.view.find(idx.path, idx.range).toArray()
468
+ const data = paginate(rows, query)
469
+ return { data, total: rows.length, size: data.length }
435
470
  }
436
471
 
437
472
  const all = await this.view.find(col, {}).toArray()
@@ -466,7 +501,6 @@ export class Database extends ReadyResource {
466
501
  */
467
502
  watch(name, query) {
468
503
  this.guard()
469
- this.ref(name)
470
504
  return subscribe({
471
505
  get: () => this.get(name, query),
472
506
  watch: (fn) => this.onUpdate(fn)
@@ -571,11 +605,22 @@ export class Database extends ReadyResource {
571
605
  return this._write('del-writer', 'removeWriter', publicKey)
572
606
  }
573
607
 
608
+ /**
609
+ * Throw if the handle is closing/closed or the bee isn't ready yet.
610
+ *
611
+ * @returns {void}
612
+ */
574
613
  guard() {
575
614
  if (this.closing || this.closed) throw CeroError.CLOSED('Database')
576
615
  if (!this.bee) throw CeroError.NOT_READY('Database', 'db')
577
616
  }
578
617
 
618
+ /**
619
+ * Resolve a table name to its normalized `{ name, kind, verb }` ref.
620
+ *
621
+ * @param {string} name
622
+ * @returns {Ref}
623
+ */
579
624
  ref(name) {
580
625
  if (typeof name !== 'string' || !name)
581
626
  throw CeroError.INVALID('name must be a non-empty string')
@@ -584,11 +629,37 @@ export class Database extends ReadyResource {
584
629
  return { name, kind: ref.kind || COLLECTION, verb: ref.verb || name }
585
630
  }
586
631
 
632
+ /**
633
+ * Namespaced collection path for a ref.
634
+ *
635
+ * @param {Ref} ref
636
+ * @returns {string}
637
+ */
587
638
  col(ref) {
588
639
  return `@${this.ns}/${ref.name}`
589
640
  }
590
641
 
591
- // Route an exact-field query to a declared secondary index, when one matches.
642
+ // Reject a write that carries a field the schema doesn't declare — the encoder
643
+ // would silently drop it, so the returned row would lie. Builtins (and specs
644
+ // built before field info shipped) carry no `fields`, so they skip validation.
645
+ _checkFields(name, row) {
646
+ const declared = this.refs[name]?.fields
647
+ if (!declared || !row) return
648
+ for (const key of Object.keys(row)) {
649
+ if (declared.includes(key) || SYSTEM_FIELDS.has(key)) continue
650
+ throw CeroError.INVALID(
651
+ `unknown field '${key}' on '${name}' — declared: ${declared.join(', ') || '(none)'}`
652
+ )
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Find a secondary index whose fields exactly match the query's equality keys.
658
+ *
659
+ * @param {string} name
660
+ * @param {Query} [query]
661
+ * @returns {{ path: string, range: { gte: object, lte: object } } | null}
662
+ */
592
663
  matchIndex(name, query) {
593
664
  const indexes = this.refs[name]?.indexes
594
665
  if (!indexes || !query) return null
@@ -598,10 +669,8 @@ export class Database extends ReadyResource {
598
669
  if (idxFields.length === fields.length && idxFields.every((f) => fields.includes(f))) {
599
670
  const point = {}
600
671
  for (const f of idxFields) point[f] = query[f]
601
- const range = { gte: point, lte: point }
602
- if (query.limit !== undefined) range.limit = query.limit
603
- if (query.reverse) range.reverse = true
604
- return { path: `@${this.ns}/${name}-${idx}`, range }
672
+ // point lookup only search/range/reverse/limit are applied by paginate
673
+ return { path: `@${this.ns}/${name}-${idx}`, range: { gte: point, lte: point } }
605
674
  }
606
675
  }
607
676
  return null
@@ -620,7 +689,7 @@ export class Database extends ReadyResource {
620
689
 
621
690
  async _write(verb, hook, publicKey) {
622
691
  this.guard()
623
- if (!b4a.isBuffer(publicKey) && !(publicKey instanceof Uint8Array)) {
692
+ if (!b4a.isBuffer(publicKey)) {
624
693
  throw CeroError.INVALID('publicKey must be a buffer')
625
694
  }
626
695
  const ctx = { op: hook, publicKey }
@@ -629,17 +698,32 @@ export class Database extends ReadyResource {
629
698
  version: this.store.manifestVersion,
630
699
  signers: [{ publicKey }]
631
700
  })
632
- const sig = this.identity.sign(writerKey)
701
+ const sig = this.identity.sign(b4a.concat([writerKey, this.writerKey]))
633
702
  await this.write([[verb, { master: this.identity.publicKey, writer: writerKey, sig }]])
634
703
  await this.runAfter(hook, ctx)
635
704
  }
636
705
  }
637
706
 
638
707
  const INDEX_RESERVED = new Set(['gt', 'gte', 'lt', 'lte', 'limit', 'reverse', 'search', 'fields'])
708
+ // Fields the write path stamps itself — always allowed even if not user-declared.
709
+ const SYSTEM_FIELDS = new Set(['id', 'memberId', 'index', 'createdAt', 'updatedAt'])
710
+
711
+ // Match a query value against a stored value (bytes compared by content).
712
+ const valueEq = (a, b) => {
713
+ if (a instanceof Uint8Array && b instanceof Uint8Array) return b4a.equals(a, b)
714
+ return a === b
715
+ }
639
716
 
640
717
  function paginate(rows, query) {
641
718
  if (!query) return rows
642
719
  let out = rows
720
+ // Exact-field equality: every non-reserved key filters in memory. The secondary
721
+ // index is an optimization, not a correctness gate — so an unindexed field, or
722
+ // one passed alongside search/range, still filters instead of silently dropping.
723
+ for (const key of Object.keys(query)) {
724
+ if (INDEX_RESERVED.has(key)) continue
725
+ out = out.filter((r) => valueEq(r[key], query[key]))
726
+ }
643
727
  if (query.gt !== undefined) out = out.filter((r) => r.id > query.gt)
644
728
  if (query.gte !== undefined) out = out.filter((r) => r.id >= query.gte)
645
729
  if (query.lt !== undefined) out = out.filter((r) => r.id < query.lt)
@@ -662,7 +746,9 @@ const fold = (s) =>
662
746
  // every string field). 'smith' → 'John Smith', 'jo sm' → both must appear.
663
747
  function searchHit(row, term, fields) {
664
748
  const terms = String(term).split(/\s+/).map(fold).filter(Boolean)
665
- const keys = fields && fields.length ? fields : Object.keys(row)
749
+ // default to all fields except memberId a framework-stamped random z32 whose value would
750
+ // otherwise produce spurious, non-deterministic search hits (user-set id/code stay searchable)
751
+ const keys = fields && fields.length ? fields : Object.keys(row).filter((k) => k !== 'memberId')
666
752
  const hay = keys.map((k) => (typeof row[k] === 'string' ? fold(row[k]) : '')).join(' ')
667
753
  return terms.every((t) => hay.includes(t))
668
754
  }
@@ -157,7 +157,7 @@ export class Identity {
157
157
  * @returns {x is Uint8Array}
158
158
  */
159
159
  static isSeed(x) {
160
- if (!b4a.isBuffer(x) && !(x instanceof Uint8Array)) return false
160
+ if (!b4a.isBuffer(x)) return false
161
161
  return x.length === 16 || x.length === 32
162
162
  }
163
163
 
package/src/index.js CHANGED
@@ -8,13 +8,36 @@
8
8
  */
9
9
 
10
10
  export * from './identity/index.js'
11
- export * from './storage/index.js'
11
+ // `database`, `pairing` and `pairing/invite` declare a few generically-named
12
+ // result/option typedefs (`Ref`, `SingleResult`, `ListResult`,
13
+ // `CreateInviteOpts`) that also exist on `storage`/`pairing`. Re-export the
14
+ // lower-level modules' colliding names under namespaced aliases so the barrel
15
+ // stays unambiguous while the canonical (`database`/`pairing`) versions own the
16
+ // bare names.
17
+ export { Storage } from './storage/index.js'
18
+ /**
19
+ * `Ref` / `SingleResult` / `ListResult` are JSDoc typedefs (types, not runtime exports), so they
20
+ * are re-exported as namespaced type aliases — `export { Ref as StorageRef }` fails at import
21
+ * because the module provides no runtime `Ref` export.
22
+ * @typedef {import('./storage/index.js').Ref} StorageRef
23
+ * @typedef {import('./storage/index.js').SingleResult} StorageSingleResult
24
+ * @typedef {import('./storage/index.js').ListResult} StorageListResult
25
+ * @typedef {import('./storage/index.js').StorageOpts} StorageOpts
26
+ * @typedef {import('./storage/index.js').StoredRow} StoredRow
27
+ * @typedef {import('./storage/index.js').GetByIdResult} GetByIdResult
28
+ */
12
29
  export * from './network/index.js'
13
30
  export * from './database/index.js'
14
31
  export * from './blobs/index.js'
15
32
  export * from './rpc/index.js'
16
33
  export * from './pairing/index.js'
17
- export * from './pairing/invite.js'
34
+ export { Invite } from './pairing/invite.js'
35
+ /**
36
+ * `CreateInviteOpts` is a typedef (not a runtime export); re-export it as a type alias.
37
+ * @typedef {import('./pairing/invite.js').CreateInviteOpts} MintInviteOpts
38
+ * @typedef {import('./pairing/invite.js').InviteFields} InviteFields
39
+ * @typedef {import('./pairing/invite.js').ParseInviteOpts} ParseInviteOpts
40
+ */
18
41
  export * from './lib/schema.js'
19
42
  export * from './lib/utils.js'
20
43
  export * from './lib/errors.js'
@@ -14,8 +14,28 @@ export const BEE = 'bee'
14
14
 
15
15
  // Roles
16
16
  export const OWNER = 'owner'
17
- export const WRITE = 'write'
17
+ export const ADMIN = 'admin'
18
+ export const MEMBER = 'member'
19
+ export const READER = 'reader'
20
+
21
+ // Permissions
18
22
  export const READ = 'read'
23
+ export const WRITE = 'write'
24
+ export const DELETE = 'delete'
25
+ export const INVITE = 'invite'
26
+ export const REMOVE = 'remove'
27
+ export const ASSIGN = 'assign'
28
+
29
+ // Default role → permissions
30
+ export const ROLE_PERMS = {
31
+ [OWNER]: ['*'],
32
+ [ADMIN]: [READ, WRITE, DELETE, INVITE, REMOVE, ASSIGN],
33
+ [MEMBER]: [READ, WRITE, INVITE],
34
+ [READER]: [READ]
35
+ }
36
+
37
+ // Role ordering (higher = more authority)
38
+ export const RANK = { [OWNER]: 3, [ADMIN]: 2, [MEMBER]: 1, [READER]: 0 }
19
39
 
20
40
  // Identity / hypercore namespace
21
41
  export const NAMESPACE = 'cero'
package/src/lib/schema.js CHANGED
@@ -25,6 +25,8 @@ export const t = {
25
25
  fixed32: prim('fixed32'),
26
26
  fixed64: prim('fixed64'),
27
27
 
28
+ file: prim('file'),
29
+
28
30
  /**
29
31
  * Mark a field as required. Fields are optional by default.
30
32
  *