@cero-base/cero 1.15.1 → 1.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cero-base/cero",
3
- "version": "1.15.1",
3
+ "version": "1.16.0",
4
4
  "description": "The ideal p2p API — everything is a handle, handles contain refs, refs contain rows.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -95,7 +95,7 @@
95
95
  "test:node": "ls test/*.test.js | xargs -P1 -n1 brittle-node"
96
96
  },
97
97
  "dependencies": {
98
- "@cero-base/core": "^1.15.1",
98
+ "@cero-base/core": "^1.16.0",
99
99
  "b4a": "^1.8.1",
100
100
  "bare-abort-controller": "^1.1.2",
101
101
  "bare-crypto": "^1.15.3",
@@ -205,6 +205,7 @@ function register(name, node, ctx) {
205
205
  schema: fqn,
206
206
  fields: Object.keys(node.fields)
207
207
  }
208
+ if (node.own) refEntry.own = true
208
209
  const fileFields = fileFieldNames(node.fields)
209
210
  if (fileFields.length) refEntry.files = fileFields
210
211
  ctx.meta.refs[name] = refEntry
@@ -14,7 +14,14 @@ export const main = {
14
14
  writer: required(bytes),
15
15
  sig: required(bytes),
16
16
  isIndexer: bool,
17
- ts: int
17
+ ts: int,
18
+ // Who this writer belongs to, and at what rank. Admission carries its own
19
+ // authorization so add-writer can refuse exactly when the paired
20
+ // add-member would — the two apply independently, so without this a
21
+ // refused grant still leaves an admitted writer behind. Absent on a
22
+ // device you add for yourself (bootstrap), where there is nothing to cap.
23
+ memberId: string,
24
+ role: string
18
25
  },
19
26
  counter: {
20
27
  name: required(string),
@@ -12,7 +12,7 @@ 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 { toId, grants, addWriterPayload } from '@cero-base/core/utils'
15
+ import { toId, grants, isRank, addWriterPayload } from '@cero-base/core/utils'
16
16
  import { CeroError } from '@cero-base/core/errors'
17
17
  import { Blobs } from '@cero-base/core/blobs'
18
18
  import { decodeId } from '@cero-base/core/blobs/codec'
@@ -483,6 +483,10 @@ export class Handle extends ReadyResource {
483
483
  throw CeroError.INVALID(
484
484
  'invite() is not available on the root handle — open a child handle first'
485
485
  )
486
+ // An invite is a capability: whoever holds it is admitted. Cap it here or
487
+ // a member mints one above their own rank, and the mismatch only surfaces
488
+ // at apply — as a silent drop that still leaves the joiner admitted.
489
+ if (opts?.role !== undefined && opts.role !== '') await this._checkGrant(opts.role)
486
490
  const str = await this.pair.createInvite(opts)
487
491
  const record = this.pair.recordOf(str)
488
492
  if (record) {
@@ -548,6 +552,14 @@ export class Handle extends ReadyResource {
548
552
  if (candidate.invite.role && !grants(candidate.invite.role, role)) {
549
553
  throw CeroError.INVALID(`role '${role}' exceeds the invite role '${candidate.invite.role}'`)
550
554
  }
555
+ // Refuse BEFORE confirm() — but without awaiting: confirm has to answer
556
+ // inside the pairing request's lifetime, and any read here drops it. Rank
557
+ // validation is synchronous and catches the dangerous case (an app role
558
+ // name silently grants nothing); the cap against our own rank is enforced
559
+ // at apply, where add-writer refuses to admit what it cannot grant.
560
+ if (!isRank(role)) {
561
+ throw CeroError.INVALID(`role '${role}' is not a rank (owner, admin, member, reader)`)
562
+ }
551
563
 
552
564
  // confirm must answer within the incoming pairing request's lifetime —
553
565
  // any await before it (even ~100ms) and the response is dropped, the
@@ -586,12 +598,32 @@ export class Handle extends ReadyResource {
586
598
  sig,
587
599
  master: this.identity.publicKey,
588
600
  writer: writerKey,
601
+ // admission carries who and at what rank, so a grant we cannot make
602
+ // admits nobody — the two ops apply independently and cannot roll
603
+ // each other back
604
+ memberId: member.id,
605
+ role,
589
606
  ts: member.updatedAt || Date.now()
590
607
  })
591
608
  await tx.call('add-member', member)
592
609
  })
593
610
  }
594
611
 
612
+ // Throws unless this handle may grant `role`: it must be a real rank, and
613
+ // never above our own. `grants` treats an unknown role as "no" everywhere,
614
+ // so an app role name must fail loudly here rather than silently downstream.
615
+ async _checkGrant(role) {
616
+ if (!isRank(role)) {
617
+ throw CeroError.INVALID(`role '${role}' is not a rank (owner, admin, member, reader)`)
618
+ }
619
+ const { data: me } = await this.store.get('members', this.identity.id)
620
+ // no member row yet = genesis (we are creating the room) — nothing to cap
621
+ if (!me) return
622
+ if (!grants(me.role, role)) {
623
+ throw CeroError.DENIED(null, `role '${role}' exceeds your own role '${me.role}'`)
624
+ }
625
+ }
626
+
595
627
  /**
596
628
  * Leave a child handle — removes it from the parent's `handles` collection
597
629
  * and closes the session. No-op on root handles.
@@ -822,11 +854,11 @@ export class Handle extends ReadyResource {
822
854
  * @param {string} id
823
855
  * @returns {Promise<Handle>}
824
856
  */
825
- async _load(type, id) {
857
+ async _load(type, id, opts) {
826
858
  for (const c of this.children) if (c.id === id) return c
827
859
  const existing = this._loading.get(id)
828
860
  if (existing) return existing
829
- const loading = this._reopen(type, id)
861
+ const loading = this._reopen(type, id, opts)
830
862
  this._loading.set(id, loading)
831
863
  try {
832
864
  return await loading
@@ -843,7 +875,7 @@ export class Handle extends ReadyResource {
843
875
  * @param {string} id
844
876
  * @returns {Promise<Handle>}
845
877
  */
846
- async _reopen(type, id) {
878
+ async _reopen(type, id, opts) {
847
879
  const { data } = await this.store.get('handles', id)
848
880
  if (!data) throw CeroError.UNKNOWN('handle', id)
849
881
  if (data.type !== type)
@@ -868,7 +900,10 @@ export class Handle extends ReadyResource {
868
900
  if (firstTime) {
869
901
  await this._saveKeyPair(id, writer)
870
902
  }
871
- this._wireAccept(child)
903
+ // a reopen must not silently re-arm auto-accept: `accept: false` is a
904
+ // host-approval gate, and one that comes back on the next open is worse
905
+ // than none at all
906
+ if (opts?.accept !== false) this._wireAccept(child, { role: opts?.role })
872
907
  bind(child, type)
873
908
  this.children.add(child)
874
909
  this.emit('handle', child, {})
@@ -341,7 +341,7 @@ function snapshotStream(src, map) {
341
341
  export const open = (ref, arg) => {
342
342
  if (typeof arg === 'string') return ref.handle._join(arg, ref.name)
343
343
  if (arg && typeof arg.invite === 'string') return ref.handle._join(arg.invite, ref.name)
344
- if (arg && typeof arg.id === 'string') return ref.handle._load(ref.name, arg.id)
344
+ if (arg && typeof arg.id === 'string') return ref.handle._load(ref.name, arg.id, arg)
345
345
  return ref.handle._create(ref.name, arg)
346
346
  }
347
347
 
@@ -8,6 +8,8 @@ export const main: {
8
8
  sig: import("@cero-base/core").Prim;
9
9
  isIndexer: import("@cero-base/core").Prim;
10
10
  ts: import("@cero-base/core").Prim;
11
+ memberId: import("@cero-base/core").Prim;
12
+ role: import("@cero-base/core").Prim;
11
13
  };
12
14
  counter: {
13
15
  name: import("@cero-base/core").Prim;
@@ -254,6 +254,7 @@ export class Handle extends ReadyResource {
254
254
  * @returns {Promise<void>}
255
255
  */
256
256
  accept(candidate: any, { role, name }?: AcceptOpts): Promise<void>;
257
+ _checkGrant(role: any): Promise<void>;
257
258
  /**
258
259
  * Leave a child handle — removes it from the parent's `handles` collection
259
260
  * and closes the session. No-op on root handles.
@@ -299,7 +300,7 @@ export class Handle extends ReadyResource {
299
300
  * @param {string} id
300
301
  * @returns {Promise<Handle>}
301
302
  */
302
- _load(type: string, id: string): Promise<Handle>;
303
+ _load(type: string, id: string, opts: any): Promise<Handle>;
303
304
  /**
304
305
  * Reconstruct a child handle by id. Reuses the stored writer keypair if
305
306
  * available; otherwise generates a fresh one and claims writer capability.
@@ -308,7 +309,7 @@ export class Handle extends ReadyResource {
308
309
  * @param {string} id
309
310
  * @returns {Promise<Handle>}
310
311
  */
311
- _reopen(type: string, id: string): Promise<Handle>;
312
+ _reopen(type: string, id: string, opts: any): Promise<Handle>;
312
313
  /**
313
314
  * Pause networking + storage. Idempotent; no-op on child handles.
314
315
  *