@cero-base/cero 1.1.1 → 1.2.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 CHANGED
@@ -46,18 +46,19 @@ Ships with TypeScript declarations (`.d.ts`) generated from JSDoc.
46
46
 
47
47
  ## `cero(dir, spec, opts?)`
48
48
 
49
- | Opt | Meaning |
50
- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
51
- | `bootstrap` | Hyperswarm bootstrap nodes. |
52
- | `channel` | Optional network-isolation label. Peers connect only to peers on the **same** channel (it salts every swarm topic); omit it for the global network. A storage remembers its channel and refuses to reopen under a different one. Any string works. |
53
- | `name` / `isMobile` | Stamped on the device's `add-writer` event. |
54
- | `seed` / `phrase` | Restore from explicit 16-/32-byte entropy or a BIP-39 mnemonic. Without either, a stored identity is loaded if present, else a fresh one is generated. |
55
- | `key` | Open against an existing bee key (multi-device flow). |
56
- | `recovery` | `true` triggers `bootstrap({ recovering: true })` — for a second device opening with `key` + the same identity. Needed under autobee 1.0.3 so the second device swaps off the identity-keyed bootstrap-writer slot and starts applying remote appends. |
57
- | `recoveryTimeout` | Bound on the recovery wait. |
58
- | `routes` | Custom action handlers keyed by route name. |
59
- | `encryptionKey` | Override the per-identity encryption key. |
60
- | `onerror` | Called with background/async failures that would otherwise be swallowed (failed after-hooks, `onApply` callbacks, pairing candidate errors); the app decides how to log or report them. |
49
+ | Opt | Meaning |
50
+ | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
51
+ | `bootstrap` | Hyperswarm bootstrap nodes. |
52
+ | `channel` | Optional network-isolation label. Peers connect only to peers on the **same** channel (it salts every swarm topic); omit it for the global network. A storage remembers its channel and refuses to reopen under a different one. Any string works. |
53
+ | `name` / `isMobile` | Stamped on the device's `add-writer` event. |
54
+ | `seed` / `phrase` | Restore from explicit 16-/32-byte entropy or a BIP-39 mnemonic. Without either, a stored identity is loaded if present, else a fresh one is generated. |
55
+ | `key` | Open against an existing bee key (multi-device flow). |
56
+ | `recovery` | `true` triggers `bootstrap({ recovering: true })` — for a second device opening with `key` + the same identity. Needed under autobee 1.0.3 so the second device swaps off the identity-keyed bootstrap-writer slot and starts applying remote appends. |
57
+ | `recoveryTimeout` | Bound on the recovery wait. |
58
+ | `routes` | Custom action handlers keyed by route name. |
59
+ | `encryptionKey` | Override the per-identity encryption key. |
60
+ | `storageKey` | 32-byte key encrypting local key material (master seed, device keypairs) at rest. Source it from the OS keychain — cero never stores it, and the same key must be passed on every open. Without it, key material sits plaintext on disk: rely on full-disk encryption. |
61
+ | `onerror` | Called with background/async failures that would otherwise be swallowed (failed after-hooks, `onApply` callbacks, pairing candidate errors); the app decides how to log or report them. |
61
62
 
62
63
  Reads `me.id`, `me.device`, `me.identity` for canonical metadata. `me.identity.toPhrase()` renders the seed phrase.
63
64
 
@@ -240,11 +241,11 @@ cero.watch(me.files).on('data', ({ data }) => render(data)) // live
240
241
 
241
242
  ### The `file()` column type
242
243
 
243
- For a file referenced from a row — an avatar, a room icon, a message attachment — declare the field `cero.t.file()`. Store the file's `id`; read it back already resolved to `{ id, name, type, size, url }`, with **no extra lookup**:
244
+ For a file referenced from a row — an avatar, a room icon, a message attachment — declare the field `cero.t.file` (a bare marker, not a call). Store the file's `id`; read it back already resolved to `{ id, name, type, size, url }`, with **no extra lookup**:
244
245
 
245
246
  ```js
246
247
  // schema.js
247
- profile: cero.t.single({ name: cero.t.string, avatar: cero.t.file() })
248
+ profile: cero.t.single({ name: cero.t.string, avatar: cero.t.file })
248
249
 
249
250
  // save: upload, then store the id on the row
250
251
  const { data: pic } = await cero.put(me.files, { data: bytes, type: 'image/png' })
@@ -255,8 +256,6 @@ const { data: profile } = await cero.get(me.profile)
255
256
  img.src = profile.avatar.url
256
257
  ```
257
258
 
258
- `cero.t.file({ embed: true })` also stores the file's `name` inline — handy for a list of named attachments, so rendering needs no per-item lookup.
259
-
260
259
  ### URLs are local and ephemeral
261
260
 
262
261
  A `.url` points at a localhost server this device runs, with a per-session token — it changes across restarts and is **not** shareable to other peers. Never persist a `.url`: store the **id** (cero does), and re-read to get a current one. Each member derives their own url from the same id, and the bytes download on demand when the url is first fetched.
@@ -309,12 +308,14 @@ Non-functions in the map are skipped; the handle is returned.
309
308
 
310
309
  ## Extensions
311
310
 
312
- An extension bundles **schema** (build-time) with **behavior** (runtime). Register it with `cero.use()` — `build()` folds in its schema and `cero()` runs its `setup` once the handle is ready. cero core stays schema-agnostic; apps opt into the behaviors they want.
311
+ An extension bundles **schema** (build-time) with **behavior** (runtime). Register it with `cero.use()` — `build()` folds in its schema and `cero()` runs its `setup` once the handle is ready.
312
+
313
+ The two bundled extensions (`profileSync`, `handleSync`) are **on by default** — no `cero.use()` needed. To reconfigure one, `cero.use(profileSync({ fields }))` replaces the default (named extensions replace by name, so double registration is harmless). To leave them out entirely, pass `{ extensions: false }` to **both** `build()` and `cero()` — the built spec and the runtime must agree.
313
314
 
314
315
  ```js
315
316
  import { profileSync } from '@cero-base/cero/extensions'
316
317
 
317
- cero.use(profileSync()) // before build() (for the schema) and before cero() (for the behavior)
318
+ cero.use(profileSync({ fields: { status: cero.t.string } })) // reconfigure the default
318
319
  await build('./spec', schema)
319
320
  ```
320
321
 
@@ -373,10 +374,9 @@ setup(me) {
373
374
 
374
375
  ### `profileSync` (bundled)
375
376
 
376
- Mirrors your `profile` onto your `member` row in every room, so others see your name and avatar. It **declares its own `profile` single** (`name` + the synced fields) and extends `member` with them — no app schema required. Defaults to syncing `avatar`:
377
+ On by default. Mirrors your `profile` onto your `member` row in every room, so others see your name and avatar. It **declares its own `profile` single** (`name` + the synced fields) and extends `member` with them — no app schema required. Defaults to syncing `avatar`:
377
378
 
378
379
  ```js
379
- cero.use(profileSync())
380
380
  cero.use(profileSync({ fields: { status: cero.t.string } })) // sync extra fields
381
381
  ```
382
382
 
@@ -384,11 +384,9 @@ Want a richer profile (e.g. a `bio` that doesn't sync)? Declare your own `profil
384
384
 
385
385
  ### `handleSync` (bundled)
386
386
 
387
- Mirrors a child handle's `profile` (name + avatar) onto its row in the parent's `handles` list — so a handle/room list renders names and photos without opening each handle. Adds `avatar` (or your `fields`) to the `handle` builtin and reflects the handle's own `profile` (which your app owns) onto the row.
387
+ On by default. Mirrors a child handle's `profile` (name + avatar) onto its row in the parent's `handles` list — so a handle/room list renders names and photos without opening each handle. Adds `avatar` (or your `fields`) to the `handle` builtin and reflects the handle's own `profile` (which your app owns) onto the row.
388
388
 
389
389
  ```js
390
- cero.use(handleSync())
391
-
392
390
  // your handle type declares a `profile`; your app sets it:
393
391
  const room = await cero.open(me.room)
394
392
  await cero.set(room.profile, { name: 'general', avatar: 'pic.png' })
@@ -412,7 +410,7 @@ import { spec } from './spec/index.js'
412
410
 
413
411
  // serve builds and owns the root cero — pass storage + the built spec, not a handle.
414
412
  // Returns a Server instance (call server.close() to shut down).
415
- const server = await serve(Bare.IPC, { storage: './data', spec, seed: '…' })
413
+ const server = await serve(Bare.IPC, { storage: './data', spec, phrase: '…' })
416
414
  ```
417
415
 
418
416
  ### Client (the UI process)
@@ -486,6 +484,37 @@ await serve(s, { storage: './data', spec })
486
484
  const remote = await connect(c, spec)
487
485
  ```
488
486
 
487
+ ## Nearby sync (Bluetooth)
488
+
489
+ Sync with no internet at all. Bluetooth is a transport, not a parallel API — it only changes _how peers meet and carry bytes_; the same refs, guards, roles, and reactivity ride over it unchanged. Volunteers in range converge; hop-by-hop gossip heals partitions as people move.
490
+
491
+ ```js
492
+ const me = await cero(dir, spec, { channel, bluetooth: true })
493
+
494
+ me.bluetooth.state // 'unsupported' | 'unauthorized' | 'off' | 'waiting' | 'on'
495
+ await me.bluetooth.start() // runtime toggle
496
+ await me.bluetooth.stop()
497
+ me.bluetooth.peers // Map of live BLE links
498
+ me.bluetooth.on('update', () => {}) // state / peer changes
499
+ ```
500
+
501
+ Inherited for free from the stack: `channel` isolation (the BLE service UUID is derived from the channel, so only same-channel devices discover each other), capability-gated replication (a connected stranger syncs nothing), and offline invite→join. Requires `bare-bluetooth` (an optional peer dep, bundled into the mobile/desktop worklet); absent it, `state` is `'unsupported'` — loud, never silent. macOS + iOS + Android; Linux desktop has no BLE backend today.
502
+
503
+ **Offline join** — a late volunteer incorporates on the spot, zero internet. Both sides derive a rendezvous UUID from the invite itself; the join side is automatic:
504
+
505
+ ```js
506
+ // organizer, while the invite QR is on screen:
507
+ const invite = await room.invite({ role: 'member', expiresIn: 3600_000 })
508
+ const stop = me.bluetooth.announce(invite)
509
+ // … QR closed:
510
+ stop() // stops the rendezvous; an established link stays and carries the initial replication
511
+
512
+ // late volunteer — the exact same code as online:
513
+ const room = await cero.open(me.room, invite) // rendezvouses over BLE automatically
514
+ ```
515
+
516
+ The advertisement auto-stops at the invite's `expiresIn` — a photographed QR must not stay an ambient admission ticket.
517
+
489
518
  ## Exports
490
519
 
491
520
  | Path | What you get |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cero-base/cero",
3
- "version": "1.1.1",
3
+ "version": "1.2.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,
@@ -18,7 +18,8 @@
18
18
  "src",
19
19
  "types",
20
20
  "README.md",
21
- "LICENSE"
21
+ "LICENSE",
22
+ "!**/CLAUDE.md"
22
23
  ],
23
24
  "publishConfig": {
24
25
  "access": "public"
@@ -80,16 +81,17 @@
80
81
  "build:types": "rm -rf types && tsc -p .",
81
82
  "pretest": "npm run build:test",
82
83
  "prepublishOnly": "npm run build:types",
83
- "test": "ls test/*.test.js | xargs -P1 -n1 brittle-node"
84
+ "test": "ls test/*.test.js | xargs -P1 -n1 brittle-node",
85
+ "pretest:bare": "npm run build:test",
86
+ "test:bare": "npx bare test/bare-smoke.js"
84
87
  },
85
88
  "dependencies": {
86
- "@cero-base/core": "^1.1.1",
89
+ "@cero-base/core": "^1.2.0",
87
90
  "b4a": "^1.8.1",
88
91
  "bare-abort-controller": "^1.1.2",
89
92
  "bare-crypto": "^1.15.3",
90
93
  "bare-fs": "^4.7.2",
91
94
  "bare-path": "^3.0.1",
92
- "blind-pairing": "^2.3.1",
93
95
  "compact-encoding": "^3.2.0",
94
96
  "corestore": "^7.10.1",
95
97
  "hrpc": "^4.3.0",
@@ -107,7 +109,16 @@
107
109
  "devDependencies": {
108
110
  "@hyperswarm/testnet": "^3.1.4",
109
111
  "brittle": "^4.0.2",
110
- "typescript": "^6.0.3"
112
+ "typescript": "^5.9.3"
111
113
  },
112
- "license": "Apache-2.0"
114
+ "license": "Apache-2.0",
115
+ "x": 1,
116
+ "peerDependencies": {
117
+ "bare-bluetooth": ">=0.2.0"
118
+ },
119
+ "peerDependenciesMeta": {
120
+ "bare-bluetooth": {
121
+ "optional": true
122
+ }
123
+ }
113
124
  }
@@ -0,0 +1,172 @@
1
+ import ReadyResource from 'ready-resource'
2
+ import safetyCatch from 'safety-catch'
3
+
4
+ import { BluetoothTransport } from '@cero-base/core/network/bluetooth'
5
+ import { Pairing } from '@cero-base/core/pairing'
6
+ import { Invite } from '@cero-base/core/invite'
7
+
8
+ /**
9
+ * Lazily resolve the bare-bluetooth backend. Absent (Linux, or dep not
10
+ * bundled) → null, which the facade reports as `state: 'unsupported'`.
11
+ * Never throws — a missing optional dep is a state, not a crash.
12
+ *
13
+ * @returns {Promise<any | null>}
14
+ */
15
+ async function loadBackend() {
16
+ try {
17
+ return await import('bare-bluetooth')
18
+ } catch {
19
+ return null
20
+ }
21
+ }
22
+
23
+ /**
24
+ * `me.bluetooth` — the whole app-facing surface for nearby (Bluetooth) sync.
25
+ * Bluetooth is a cero feature, not a parallel API: it only changes how peers
26
+ * meet and carry bytes; capability-gated replication still decides what syncs.
27
+ *
28
+ * ```js
29
+ * const me = await cero(dir, spec, { channel, bluetooth: true })
30
+ * me.bluetooth.state // 'unsupported' | 'unauthorized' | 'off' | 'waiting' | 'on'
31
+ * await me.bluetooth.start()
32
+ * me.bluetooth.peers // Map of live BLE links
33
+ * me.bluetooth.on('update', () => {})
34
+ * ```
35
+ *
36
+ * @extends ReadyResource
37
+ */
38
+ export class Bluetooth extends ReadyResource {
39
+ /**
40
+ * @param {object} handle Root cero Handle (network + identity + channel).
41
+ * @param {object} [opts]
42
+ * @param {any} [opts.backend] Injected bare-bluetooth-shaped backend (tests); lazy-loaded when absent.
43
+ * @param {boolean} [opts.autoStart] Start on handle open (from `cero({ bluetooth: true })`).
44
+ */
45
+ constructor(handle, { backend, autoStart } = {}) {
46
+ super()
47
+ this._handle = handle
48
+ this._backend = backend || null
49
+ this._autoStart = autoStart === true
50
+ this._transport = null
51
+ this._announces = new Set()
52
+ /** @type {'unsupported'|'unauthorized'|'off'|'waiting'|'starting'|'on'} */
53
+ this.state = 'off'
54
+ }
55
+
56
+ /**
57
+ * Offline join rendezvous. Both sides derive the same BLE service UUID from
58
+ * the invite, so they find each other with zero DHT: the host calls this
59
+ * while the invite QR is on screen; the joiner's `open(me.room, invite)`
60
+ * calls it automatically for the duration of the join. Returns a stop
61
+ * function — closing the QR must stop the advertisement so a photographed
62
+ * invite doesn't stay an ambient admission ticket. Auto-stops at the
63
+ * invite's expiry, on `bluetooth.stop()`, and on close.
64
+ *
65
+ * @param {string} invite Z32 invite string.
66
+ * @returns {() => void}
67
+ */
68
+ announce(invite) {
69
+ if (!this._backend || this.state === 'unsupported') return () => {}
70
+ const topic = Pairing.inviteTopic(invite)
71
+ if (!topic) return () => {}
72
+
73
+ const transport = new BluetoothTransport({
74
+ backend: this._backend,
75
+ network: this._handle.network,
76
+ uuid: topic,
77
+ nodeId: this._handle.identity.publicKey,
78
+ tag: 'cero-ble-invite',
79
+ // the QR closing stops the rendezvous, not the just-established link —
80
+ // that link carries the joiner's initial replication
81
+ keepLinks: true
82
+ })
83
+ transport.ready().catch(safetyCatch)
84
+ this._announces.add(transport)
85
+
86
+ const { expires } = Invite.parse(invite)
87
+ const timer = expires > 0 ? setTimeout(() => stop(), Math.max(0, expires - Date.now())) : null
88
+
89
+ const stop = () => {
90
+ if (timer) clearTimeout(timer)
91
+ if (!this._announces.delete(transport)) return
92
+ transport.close().catch(safetyCatch)
93
+ }
94
+ return stop
95
+ }
96
+
97
+ /** @returns {Map<string, any>} Live BLE links, keyed by peer node id. */
98
+ get peers() {
99
+ return this._transport ? this._transport.peers : new Map()
100
+ }
101
+
102
+ async _open() {
103
+ if (this._backend === null) this._backend = await loadBackend()
104
+ if (!this._backend) {
105
+ this.state = 'unsupported'
106
+ return
107
+ }
108
+ if (this._autoStart) await this.start()
109
+ }
110
+
111
+ /**
112
+ * Begin advertising + scanning on the channel-derived UUID. Idempotent.
113
+ * No-op (stays `unsupported`) when no backend is present.
114
+ *
115
+ * @returns {Promise<void>}
116
+ */
117
+ async start() {
118
+ if (this.state === 'unsupported') return
119
+ if (this._transport) return
120
+ const handle = this._handle
121
+
122
+ this._transport = new BluetoothTransport({
123
+ backend: this._backend,
124
+ network: handle.network,
125
+ // channel isolation for free: same channel → same UUID, like the swarm.
126
+ // No channel → the identity topic (unchanged global-mesh semantics).
127
+ uuid: handle.network.channel
128
+ ? Buffer.from(handle.network.channel)
129
+ : handle.identity.publicKey,
130
+ nodeId: handle.identity.publicKey
131
+ })
132
+ this._transport.on('update', () => {
133
+ this.state = this._transport.state
134
+ this.emit('update')
135
+ })
136
+ try {
137
+ await this._transport.ready()
138
+ this.state = this._transport.state
139
+ } catch (err) {
140
+ this._transport = null
141
+ this.state = 'off'
142
+ throw err
143
+ }
144
+ this.emit('update')
145
+ }
146
+
147
+ /**
148
+ * Stop advertising/scanning and drop links. Idempotent. Sync stops; local
149
+ * data and the rest of the network (DHT) are untouched.
150
+ *
151
+ * @returns {Promise<void>}
152
+ */
153
+ async stop() {
154
+ for (const t of [...this._announces]) {
155
+ this._announces.delete(t)
156
+ await t.close().catch(safetyCatch)
157
+ }
158
+ if (!this._transport) return
159
+ try {
160
+ await this._transport.close()
161
+ } catch (err) {
162
+ safetyCatch(err)
163
+ }
164
+ this._transport = null
165
+ this.state = 'off'
166
+ this.emit('update')
167
+ }
168
+
169
+ async _close() {
170
+ await this.stop()
171
+ }
172
+ }
@@ -44,7 +44,7 @@ import {
44
44
  */
45
45
  export { getHyperdbType } from './builtins.js'
46
46
 
47
- export async function build(specDir, schema, { ns = NS } = {}) {
47
+ export async function build(specDir, schema, { ns = NS, extensions = true } = {}) {
48
48
  const raw = /** @type {SchemaDefs & { local?: SchemaDefs }} */ (schema?.defs || schema)
49
49
  if (!raw || typeof raw !== 'object') throw CeroError.REQUIRED('schema')
50
50
 
@@ -57,14 +57,18 @@ export async function build(specDir, schema, { ns = NS } = {}) {
57
57
  if (v && v.kind === 'extend') {
58
58
  const type = refs.main[k]?.type
59
59
  if (!type) throw CeroError.INVALID(`'${k}' is not an extendable builtin`)
60
- extend[type] = { ...extend[type], ...v.fields }
60
+ // app schema wins on field conflicts, extensions only add new fields
61
+ extend[type] = fromExt ? { ...v.fields, ...extend[type] } : { ...extend[type], ...v.fields }
61
62
  } else if (!fromExt || !(k in defs)) {
62
63
  defs[k] = v // app schema wins over an extension's default
63
64
  }
64
65
  }
65
66
  }
66
67
  collect(raw)
67
- for (const ext of internal.extensions) if (ext.schema) collect(ext.schema, true)
68
+ for (const ext of internal.extensions) {
69
+ if (ext.bundled && extensions === false) continue
70
+ if (ext.schema) collect(ext.schema, true)
71
+ }
68
72
 
69
73
  const main = compile(splitMain(defs), ns, 'main')
70
74
  const local = compile(defs.local || {}, ns, 'local')
@@ -128,7 +132,8 @@ function compile(root, ns, scope = 'main') {
128
132
  dispatches: [],
129
133
  indexes: [],
130
134
  meta: { ns, refs: {} },
131
- ns
135
+ ns,
136
+ scope
132
137
  }
133
138
 
134
139
  Object.assign(ctx.meta.refs, builtinRefs(ns, scope))
@@ -207,6 +212,14 @@ function register(name, node, ctx) {
207
212
  }
208
213
  ctx.meta.refs[name].indexes = node.indexes
209
214
  }
215
+ // implicit secondary index on the auto-increment `index` — lets reads push
216
+ // reverse/limit down to hyperdb instead of scanning the whole collection.
217
+ // main scope only: local (Storage) rows never get `index` stamped, and an
218
+ // undefined index key crashes the encoder on insert.
219
+ if (ctx.scope === 'main' && !node.indexes?.index) {
220
+ ctx.indexes.push({ name: `${name}-index`, collection: fqn, key: ['index'] })
221
+ refEntry.orderIndex = true
222
+ }
210
223
  }
211
224
  }
212
225
 
@@ -126,7 +126,9 @@ export const rpc = {
126
126
  },
127
127
  'req-invite': {
128
128
  handle: required(string),
129
- role: string
129
+ role: string,
130
+ expiresIn: uint,
131
+ multiUse: bool
130
132
  },
131
133
  'req-revoke': {
132
134
  handle: required(string),
@@ -1,5 +1,5 @@
1
1
  import { t } from '../lib/spec.js'
2
- import { set, watch } from '../lib/operators.js'
2
+ import { get, set, watch } from '../lib/operators.js'
3
3
 
4
4
  /**
5
5
  * Mirror a child handle's `profile` (name + avatar) onto its row in the parent's
@@ -14,15 +14,26 @@ import { set, watch } from '../lib/operators.js'
14
14
  */
15
15
  export function handleSync({ fields = { avatar: t.string } } = {}) {
16
16
  return {
17
+ name: 'handle-sync',
18
+ bundled: true,
17
19
  schema: { handles: t.extend(fields) },
18
20
  setup(me) {
21
+ const keys = ['name', ...Object.keys(fields)]
22
+ // skip the write when the row already mirrors the profile — every open
23
+ // re-emits the watch snapshot, and an unconditional set is a new op in
24
+ // the replicated log forever
25
+ const reflect = async (child, data) => {
26
+ child.name = data.name
27
+ const { data: row } = await get(me.handles, child.id)
28
+ if (row && keys.every((k) => row[k] === data[k])) return
29
+ await set(me.handles, { id: child.id, ...data }, { upsert: false })
30
+ }
19
31
  const onHandle = (child, opts) => {
20
32
  if (!child.profile) return
21
33
  if (opts.name) set(child.profile, { name: opts.name }).catch(me._onerror)
22
34
  watch(child.profile).on('data', ({ data }) => {
23
35
  if (!data?.name) return
24
- child.name = data.name
25
- set(me.handles, { id: child.id, ...data }, { upsert: false }).catch(me._onerror)
36
+ reflect(child, data).catch(me._onerror)
26
37
  })
27
38
  }
28
39
  me.on('handle', onHandle, { signal: me.signal })
@@ -12,24 +12,32 @@ import { get, set, after } from '../lib/operators.js'
12
12
  */
13
13
  export function profileSync({ fields = { avatar: t.string } } = {}) {
14
14
  return {
15
+ name: 'profile-sync',
16
+ bundled: true,
15
17
  schema: {
16
18
  profile: t.single({ name: t.string, ...fields }),
17
19
  members: t.extend(fields)
18
20
  },
19
21
  setup(me) {
22
+ const keys = ['name', ...Object.keys(fields)]
20
23
  const publish = (child, profile) =>
21
24
  set(child.members, { id: me.identity.id, ...profile }, { upsert: false }).catch(me._onerror)
22
25
 
26
+ // skip the broadcast when the member row already mirrors the profile —
27
+ // an unconditional set on every open is a room-wide op forever
23
28
  const onHandle = async (child) => {
24
29
  const { data: profile } = await get(me.profile)
25
- if (profile) publish(child, profile)
30
+ if (!profile) return
31
+ const { data: member } = await get(child.members, me.identity.id)
32
+ if (member && keys.every((k) => member[k] === profile[k])) return
33
+ publish(child, profile)
26
34
  }
27
35
 
28
36
  const onProfile = (ctx) => {
29
37
  if (ctx.row) me.children.forEach((child) => publish(child, ctx.row))
30
38
  }
31
39
 
32
- me.on('handle', onHandle, { signal: me.signal })
40
+ me.on('handle', (child) => onHandle(child).catch(me._onerror), { signal: me.signal })
33
41
  after(me.profile, onProfile, { signal: me.signal })
34
42
  }
35
43
  }
@@ -9,7 +9,7 @@ import z32 from 'z32'
9
9
  import { Identity } from '@cero-base/core/identity'
10
10
  import { Database } from '@cero-base/core/database'
11
11
  import { Pairing } from '@cero-base/core/pairing'
12
- import { toId, grants } from '@cero-base/core/utils'
12
+ import { toId, grants, addWriterPayload } from '@cero-base/core/utils'
13
13
  import { CeroError } from '@cero-base/core/errors'
14
14
  import { Blobs } from '@cero-base/core/blobs'
15
15
  import { FileServer } from '@cero-base/core/blobs/server'
@@ -42,6 +42,7 @@ export { Ref } from '../lib/utils.js'
42
42
  * @property {Uint8Array} [encryptionKey] Existing encryption key.
43
43
  * @property {string} [namespace] Corestore namespace.
44
44
  * @property {KeyPair} [keyPair] Writer keypair.
45
+ * @property {boolean} [passive] Join discovery server-only; flip later with `setActive`.
45
46
  * @property {boolean} [pair] When `false`, skips creating a `Pairing` session.
46
47
  *
47
48
  * @typedef {object} CreateChildOpts
@@ -132,6 +133,7 @@ export class Handle extends ReadyResource {
132
133
  encryptionKey: opts.encryptionKey,
133
134
  namespace: opts.namespace,
134
135
  keyPair: opts.keyPair,
136
+ passive: opts.passive,
135
137
  onerror: this._onerror
136
138
  })
137
139
  this.pair = null
@@ -156,6 +158,8 @@ export class Handle extends ReadyResource {
156
158
 
157
159
  async _close() {
158
160
  this.root._coreKeys.delete(b4a.toString(this.store.key, 'hex'))
161
+ if (this._blobs?.key) this.root._coreKeys.delete(b4a.toString(this._blobs.key, 'hex'))
162
+ for (const hex of this._blobKeys || []) this.root._coreKeys.delete(hex)
159
163
  for (const r of [...this._owned]) r.destroy?.()
160
164
  this._owned.clear()
161
165
  if (this._blobs) await this._blobs.close()
@@ -302,7 +306,7 @@ export class Handle extends ReadyResource {
302
306
  this.root._coreKeys.set(b4a.toString(this._blobs.key, 'hex'), this.store.encryptionKey)
303
307
  }
304
308
  })
305
- .catch(() => {})
309
+ .catch(this._onerror)
306
310
  }
307
311
  return this._blobs
308
312
  }
@@ -359,6 +363,18 @@ export class Handle extends ReadyResource {
359
363
  await this.store.bee.update()
360
364
  }
361
365
 
366
+ /**
367
+ * Flip this handle's swarm announce mode — `setActive(false)` demotes an
368
+ * idle/background room to server-only (still reachable, stops searching);
369
+ * `setActive(true)` promotes it back on focus. Cheap, safe to call often.
370
+ *
371
+ * @param {boolean} active
372
+ * @returns {Promise<void>}
373
+ */
374
+ setActive(active) {
375
+ return this.store.setActive(active)
376
+ }
377
+
362
378
  /**
363
379
  * Mint a pairing invite for this handle.
364
380
  *
@@ -405,6 +421,11 @@ export class Handle extends ReadyResource {
405
421
  throw CeroError.INVALID(`role '${role}' exceeds the invite role '${candidate.invite.role}'`)
406
422
  }
407
423
 
424
+ // confirm must answer within the incoming pairing request's lifetime —
425
+ // any await before it (even ~100ms) and the response is dropped, the
426
+ // joiner times out. So the key is revealed before the membership writes
427
+ // land; if they fail the joiner holds the key un-admitted, which is
428
+ // recoverable (re-pair) and surfaced via onerror in _wireAccept.
408
429
  await candidate.confirm({ key: this.store.key, encryptionKey: this.store.encryptionKey })
409
430
 
410
431
  const ts = Date.now()
@@ -423,7 +444,9 @@ export class Handle extends ReadyResource {
423
444
  return
424
445
  }
425
446
 
426
- const sig = this.identity.sign(b4a.concat([writerKey, this.store.writerKey]))
447
+ const sig = this.identity.sign(
448
+ addWriterPayload(this.store.key, writerKey, this.store.writerKey)
449
+ )
427
450
  await this.store.tx(async () => {
428
451
  await this.store.call('add-writer', {
429
452
  sig,
@@ -480,7 +503,7 @@ export class Handle extends ReadyResource {
480
503
  await child.store.call('add-writer', {
481
504
  master: this.identity.publicKey,
482
505
  writer: writerKey,
483
- sig: this.identity.sign(b4a.concat([writerKey, child.store.writerKey]))
506
+ sig: this.identity.sign(addWriterPayload(child.store.key, writerKey, child.store.writerKey))
484
507
  })
485
508
  await child.store.call('add-member', {
486
509
  id: this.identity.id,
@@ -535,6 +558,10 @@ export class Handle extends ReadyResource {
535
558
  if (existing) return this._load(type, existing.id)
536
559
  }
537
560
 
561
+ // offline join: with nearby sync on, also rendezvous on the invite-derived
562
+ // BLE UUID for the duration of the join — pairing rides the injected link
563
+ const stopNearby = this.root.bluetooth ? this.root.bluetooth.announce(invite) : null
564
+
538
565
  const child = /** @type {Child} */ (
539
566
  await Handle.join(invite, {
540
567
  parent: this,
@@ -542,7 +569,7 @@ export class Handle extends ReadyResource {
542
569
  namespace: `${NS}/handle/${type}/${randomNs()}`,
543
570
  routes,
544
571
  timeout
545
- })
572
+ }).finally(() => stopNearby?.())
546
573
  )
547
574
  // whenWritable timing out (host offline) is a normal failure — close the
548
575
  // fully-opened child rather than leak its Database/pairing/swarm session
@@ -607,7 +634,8 @@ export class Handle extends ReadyResource {
607
634
  async _reopen(type, id) {
608
635
  const { data } = await this.store.get('handles', id)
609
636
  if (!data) throw CeroError.UNKNOWN('handle', id)
610
- if (data.type !== type) throw new Error(`handle ${id} is type ${data.type}, not ${type}`)
637
+ if (data.type !== type)
638
+ throw CeroError.INVALID(`handle ${id} is type ${data.type}, not ${type}`)
611
639
  let writer = await this._loadKeyPair(id)
612
640
  const firstTime = !writer
613
641
  if (firstTime) {
@@ -643,7 +671,7 @@ export class Handle extends ReadyResource {
643
671
  async suspend() {
644
672
  if (this.parent || this.closing || this.closed || this._suspended) return
645
673
  this._suspended = true
646
- for (const c of this.children) if (c.pair) await c.pair.suspend()
674
+ await Promise.all([...this.children].map((c) => c.pair?.suspend()))
647
675
  await this.network.suspend()
648
676
  try {
649
677
  await this.store.store.suspend()
@@ -683,10 +711,10 @@ export class Handle extends ReadyResource {
683
711
  ) {
684
712
  const net = network || parent?.network
685
713
  const id = identity || parent?.identity
686
- if (!net) throw new TypeError('network is required')
687
- if (!id) throw new TypeError('identity is required')
688
- if (!store && !parent) throw new TypeError('store is required')
689
- if (!spec) throw new TypeError('spec is required')
714
+ if (!net) throw CeroError.REQUIRED('network')
715
+ if (!id) throw CeroError.REQUIRED('identity')
716
+ if (!store && !parent) throw CeroError.REQUIRED('store')
717
+ if (!spec) throw CeroError.REQUIRED('spec')
690
718
 
691
719
  const writer = Identity.randomKeyPair()
692
720
  const pair = new Pairing({ network: net, identity: id })