@cero-base/core 1.1.0 → 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.
@@ -1,3 +1,5 @@
1
+ import fs from 'fs'
2
+
1
3
  import HypercoreStorage from 'hypercore-storage'
2
4
  import Corestore from 'corestore'
3
5
  import HyperDB from 'hyperdb'
@@ -7,12 +9,15 @@ import { ROCKS, BEE, SINGLE, COLLECTION } from '../lib/constants.js'
7
9
  import { genId, subscribe } from '../lib/utils.js'
8
10
  import { CeroError } from '../lib/errors.js'
9
11
 
12
+ const RANGE_OPS = new Set(['gt', 'gte', 'lt', 'lte', 'reverse', 'limit'])
13
+
10
14
  /**
11
15
  * @typedef {object} StorageOpts
12
16
  * @property {{ database: any, meta?: { ns?: string, refs?: Record<string, { kind?: string }> } }} spec
13
17
  * @property {'rocks' | 'bee'} backend
14
18
  * @property {any} [root] Pre-existing HypercoreStorage to reuse.
15
19
  * @property {any} [store] Pre-existing Corestore to reuse.
20
+ * @property {Uint8Array} [storageKey] 32-byte key encrypting the backing core at rest (bee backend only).
16
21
  *
17
22
  * @typedef {{ name: string, kind: string }} Ref
18
23
  * @typedef {{ id?: string, createdAt: number, updatedAt: number, [k: string]: any }} StoredRow
@@ -31,17 +36,22 @@ export class Storage extends ReadyResource {
31
36
  * @param {string} dir
32
37
  * @param {StorageOpts} [opts]
33
38
  */
34
- constructor(dir, { spec, backend, root, store } = {}) {
39
+ constructor(dir, { spec, backend, root, store, storageKey } = {}) {
35
40
  super()
36
41
  if (!root && !store && (typeof dir !== 'string' || !dir))
37
42
  throw CeroError.REQUIRED('dir, root, or store')
38
43
  if (!spec || !spec.database) throw CeroError.REQUIRED('spec.database')
39
44
  if (backend !== ROCKS && backend !== BEE)
40
45
  throw CeroError.INVALID('backend must be "rocks" or "bee"')
46
+ if (storageKey && backend === ROCKS)
47
+ throw CeroError.INVALID('storageKey requires the bee backend')
48
+ if (storageKey && storageKey.byteLength !== 32)
49
+ throw CeroError.INVALID('storageKey must be 32 bytes')
41
50
 
42
51
  this.dir = dir
43
52
  this.spec = spec
44
53
  this.backend = backend
54
+ this.storageKey = storageKey || null
45
55
  this.ns = spec.meta?.ns || 'cero'
46
56
  this.refs = spec.meta?.refs || {}
47
57
 
@@ -77,6 +87,7 @@ export class Storage extends ReadyResource {
77
87
  if (this._ownsRoot) {
78
88
  this.root = new HypercoreStorage(this.dir, { columnFamilies: [this._cf] })
79
89
  await this.root.ready()
90
+ await fs.promises.chmod(this.dir, 0o700)
80
91
  }
81
92
 
82
93
  if (this._ownsStore) {
@@ -90,7 +101,7 @@ export class Storage extends ReadyResource {
90
101
  const cf = this.root.rocks.columnFamily(this._cf)
91
102
  this.db = HyperDB.rocks(cf, this.spec.database)
92
103
  } else {
93
- const core = this.store.get({ name: 'local' })
104
+ const core = this.store.get({ name: 'local', encryptionKey: this.storageKey })
94
105
  await core.ready()
95
106
  this.db = HyperDB.bee(core, this.spec.database, { extension: false, autoUpdate: true })
96
107
  }
@@ -193,9 +204,19 @@ export class Storage extends ReadyResource {
193
204
  return { data: (await this.db.get(col, { id: query })) ?? null }
194
205
  }
195
206
 
207
+ // hyperdb only honors range/limit operators — equality fields must be
208
+ // filtered here or they are silently ignored and every row comes back
209
+ const eq = Object.keys(query || {}).filter((k) => !RANGE_OPS.has(k))
210
+ if (eq.length) {
211
+ const rows = await this.db.find(col, {}).toArray()
212
+ const matched = rows.filter((r) => eq.every((k) => r[k] === query[k]))
213
+ const data = query.limit != null ? matched.slice(0, query.limit) : matched
214
+ return { data, total: matched.length, size: data.length }
215
+ }
216
+
196
217
  const data = await this.db.find(col, query || {}).toArray()
197
- // a filter (or limit) caps `data`, so `total` needs a full count; with no
198
- // query `data` is already everything — skip the second scan.
218
+ // a limit caps `data`, so `total` needs a full count; with no query
219
+ // `data` is already everything — skip the second scan.
199
220
  const filtered = query && Object.keys(query).length > 0
200
221
  const total = filtered ? (await this.db.find(col, {}).toArray()).length : data.length
201
222
  return { data, total, size: data.length }
@@ -212,8 +233,10 @@ export class Storage extends ReadyResource {
212
233
  this._guard()
213
234
  const ref = this._ref(name)
214
235
  const col = this._col(ref)
215
- const rows = await this.db.find(col, query || {}).toArray()
216
- return { data: rows.length }
236
+ const eq = Object.keys(query || {}).filter((k) => !RANGE_OPS.has(k))
237
+ const rows = await this.db.find(col, eq.length ? {} : query || {}).toArray()
238
+ const matched = eq.length ? rows.filter((r) => eq.every((k) => r[k] === query[k])) : rows
239
+ return { data: matched.length }
217
240
  }
218
241
 
219
242
  /**
@@ -259,8 +282,7 @@ export class Storage extends ReadyResource {
259
282
  async _read(ref, id) {
260
283
  if (ref.kind === SINGLE) return this.db.findOne(this._col(ref), {})
261
284
  if (id == null) return null
262
- const rows = await this.db.find(this._col(ref), {}).toArray()
263
- return rows.find((r) => r.id === id) ?? null
285
+ return (await this.db.get(this._col(ref), { id })) ?? null
264
286
  }
265
287
 
266
288
  /** @param {Ref} ref @param {Record<string, any>} row @returns {Promise<void>} */
@@ -5,6 +5,7 @@
5
5
  * @param {{ dispatch: { Router: Function }, meta?: { refs?: Record<string, { kind?: string, builtin?: boolean, verb?: string }> } }} spec Generated hyperdispatch spec.
6
6
  * @param {string} ns Namespace prefix for collection and op names.
7
7
  * @param {Record<string, Function>} routes Custom action handlers keyed by route name.
8
+ * @param {(err: Error) => void} [onerror] Called when a malformed node is skipped.
8
9
  * @returns {{ dispatcher: object, apply: (nodes: Array<{ value: Buffer, key: Buffer }>, view: object, host: object) => Promise<void> }}
9
10
  */
10
11
  export function makeDispatcher(spec: {
@@ -18,7 +19,7 @@ export function makeDispatcher(spec: {
18
19
  verb?: string;
19
20
  }>;
20
21
  };
21
- }, ns: string, routes: Record<string, Function>): {
22
+ }, ns: string, routes: Record<string, Function>, onerror?: (err: Error) => void, getDbKey?: () => any): {
22
23
  dispatcher: object;
23
24
  apply: (nodes: Array<{
24
25
  value: Buffer;
@@ -9,12 +9,13 @@
9
9
  * @property {Uint8Array | null} [encryptionKey] Optional encryption key; falls back to identity's key.
10
10
  * @property {(nodes: any, view: any, host: any) => Promise<void>} [apply] Override the default apply function.
11
11
  * @property {Uint8Array | null} [key] Existing autobee key to reopen.
12
+ * @property {boolean} [passive] Join discovery server-only (reachable but not searching). Flip at runtime with `setActive`.
12
13
  * @property {import('../identity/index.js').KeyPair} [keyPair] Device writer keypair; defaults to identity's keypair.
13
- * @property {(err: Error) => void} [onerror] Called when a background after-hook or onApply callback fails.
14
+ * @property {(err: Error) => void} [onerror] Called when a background after-hook or onApply callback fails, a malformed node is skipped, or the bee errors.
14
15
  *
15
16
  * @typedef {{ data: any | null }} SingleResult
16
- * @typedef {{ data: any[], total: number, size: number }} ListResult
17
- * @typedef {{ gt?: string, gte?: string, lt?: string, lte?: string, reverse?: boolean, limit?: number, search?: string, fields?: string[] }} Query
17
+ * @typedef {{ data: any[], total: number | null, size: number }} ListResult `total` is null when a limited read skipped the full count — pass `{ total: true }` to force it.
18
+ * @typedef {{ gt?: string, gte?: string, lt?: string, lte?: string, reverse?: boolean, limit?: number, search?: string, fields?: string[], total?: boolean }} Query
18
19
  * @typedef {{ kind: string, verb: string, name: string }} Ref
19
20
  * @typedef {(ctx: any) => any | Promise<any>} HookFn
20
21
  */
@@ -58,6 +59,7 @@ export class Database extends ReadyResource {
58
59
  encryptionKey: Uint8Array<ArrayBufferLike>;
59
60
  applyOverride: (nodes: any, view: any, host: any) => Promise<void>;
60
61
  key: Uint8Array<ArrayBufferLike>;
62
+ passive: boolean;
61
63
  keyPair: import("../index.js").KeyPair | {
62
64
  publicKey: Uint8Array<ArrayBufferLike>;
63
65
  secretKey: Uint8Array<ArrayBufferLike>;
@@ -73,13 +75,22 @@ export class Database extends ReadyResource {
73
75
  };
74
76
  beforeHooks: Map<any, any>;
75
77
  afterHooks: Map<any, any>;
76
- updaters: Set<any>;
78
+ updaters: Map<any, any>;
77
79
  onApplyHooks: Set<any>;
78
80
  _applySeq: number;
81
+ _touched: Set<any>;
79
82
  txQueue: any[];
80
83
  _discovery: import("../network/discovery.js").Discovery;
81
84
  /** Open the underlying autobee, wire dispatcher + apply, attach to network. */
82
85
  openBee(): Promise<void>;
86
+ /**
87
+ * Flip announce mode at runtime — passive stays reachable (server) but
88
+ * stops actively looking (client). Cheap; use it to demote idle rooms.
89
+ *
90
+ * @param {boolean} active
91
+ * @returns {Promise<void>}
92
+ */
93
+ setActive(active: boolean): Promise<void>;
83
94
  /** @returns {Uint8Array | null} discovery key of the underlying bee */
84
95
  get discoveryKey(): Uint8Array | null;
85
96
  /** @returns {Uint8Array | null} this device's local writer key */
@@ -107,12 +118,16 @@ export class Database extends ReadyResource {
107
118
  */
108
119
  after(op: string, fn: HookFn): () => void;
109
120
  /**
110
- * Subscribe to local apply notifications. Fires whenever the view updates.
121
+ * Subscribe to local apply notifications. Fires whenever the view updates;
122
+ * pass `scope` (a ref name) to fire only when that ref was touched.
111
123
  *
112
124
  * @param {() => void} fn
125
+ * @param {string} [scope]
113
126
  * @returns {() => void} disposer
114
127
  */
115
- onUpdate(fn: () => void): () => void;
128
+ onUpdate(fn: () => void, scope?: string): () => void;
129
+ _touch(nodes: any): void;
130
+ _byVerb: Map<any, any>;
116
131
  /**
117
132
  * Observe every applied op — local AND replicated (apply processes the merged
118
133
  * log). The callback receives `{ op, name, row, writerKey, seq }` and runs
@@ -210,6 +225,9 @@ export class Database extends ReadyResource {
210
225
  * @returns {Promise<SingleResult | ListResult>}
211
226
  */
212
227
  get(name: string, query?: string | Query): Promise<SingleResult | ListResult>;
228
+ _orderReady(name: any, col: any): Promise<any>;
229
+ _orderOk: Map<any, any>;
230
+ _total(col: any, query: any, rows: any): Promise<any>;
213
231
  /**
214
232
  * Number of rows that match `query` (or total if omitted).
215
233
  *
@@ -359,21 +377,28 @@ export type DatabaseOpts = {
359
377
  * Existing autobee key to reopen.
360
378
  */
361
379
  key?: Uint8Array | null;
380
+ /**
381
+ * Join discovery server-only (reachable but not searching). Flip at runtime with `setActive`.
382
+ */
383
+ passive?: boolean;
362
384
  /**
363
385
  * Device writer keypair; defaults to identity's keypair.
364
386
  */
365
387
  keyPair?: import("../identity/index.js").KeyPair;
366
388
  /**
367
- * Called when a background after-hook or onApply callback fails.
389
+ * Called when a background after-hook or onApply callback fails, a malformed node is skipped, or the bee errors.
368
390
  */
369
391
  onerror?: (err: Error) => void;
370
392
  };
371
393
  export type SingleResult = {
372
394
  data: any | null;
373
395
  };
396
+ /**
397
+ * `total` is null when a limited read skipped the full count — pass `{ total: true }` to force it.
398
+ */
374
399
  export type ListResult = {
375
400
  data: any[];
376
- total: number;
401
+ total: number | null;
377
402
  size: number;
378
403
  };
379
404
  export type Query = {
@@ -385,6 +410,7 @@ export type Query = {
385
410
  limit?: number;
386
411
  search?: string;
387
412
  fields?: string[];
413
+ total?: boolean;
388
414
  };
389
415
  export type Ref = {
390
416
  kind: string;
@@ -125,6 +125,15 @@ export class Identity {
125
125
  * @type {Uint8Array}
126
126
  */
127
127
  seed: Uint8Array;
128
+ /**
129
+ * Redacted — secretKey/encryptionKey/seed must never reach JSON.stringify
130
+ * or a structured logger.
131
+ *
132
+ * @returns {{ id: string }}
133
+ */
134
+ toJSON(): {
135
+ id: string;
136
+ };
128
137
  /**
129
138
  * Detached Ed25519 signature over `message`.
130
139
  *
@@ -42,6 +42,12 @@ export class CeroError extends Error {
42
42
  * @param {string} name
43
43
  */
44
44
  static NOT_READY(resource: string, name: string): CeroError;
45
+ /**
46
+ * Operation raced an existing state (e.g. double init).
47
+ *
48
+ * @param {string} msg
49
+ */
50
+ static CONFLICT(msg: string): CeroError;
45
51
  /**
46
52
  * Resource has been destroyed.
47
53
  *
@@ -39,11 +39,9 @@ export const toId: (key: Uint8Array) => string;
39
39
  * @type {(id: string) => Uint8Array}
40
40
  */
41
41
  export const toKey: (id: string) => Uint8Array;
42
- /**
43
- * Test whether a string is a well-formed canonical id.
44
- *
45
- * @type {(id: string) => boolean}
46
- */
47
- export const isKeyId: (id: string) => boolean;
42
+ /** @type {(dbKey: Uint8Array, writer: Uint8Array, appender: Uint8Array) => Uint8Array} */
43
+ export const addWriterPayload: (dbKey: Uint8Array, writer: Uint8Array, appender: Uint8Array) => Uint8Array;
44
+ /** @type {(dbKey: Uint8Array, writer: Uint8Array) => Uint8Array} */
45
+ export const claimWriterPayload: (dbKey: Uint8Array, writer: Uint8Array) => Uint8Array;
48
46
  export function grants(a: any, b: any): boolean;
49
47
  export function outranks(a: any, b: any): boolean;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Derive a stable 128-bit BLE service UUID from a topic. Only devices that
3
+ * compute the same UUID (same channel / same invite) ever discover each other.
4
+ *
5
+ * @param {Uint8Array} topic
6
+ * @param {string} [tag] Namespace so channel and invite meshes never collide.
7
+ * @returns {string}
8
+ */
9
+ export function toServiceUUID(topic: Uint8Array, tag?: string): string;
10
+ /**
11
+ * Dual-role BLE transport: advertises + scans one service UUID, opens an L2CAP
12
+ * channel to each discovered peer, and feeds it into `network.inject`. From
13
+ * there replication and pairing are transport-agnostic (see Network.inject).
14
+ *
15
+ * Choreography mirrors the proven bare-mobile-doctor worklet: server adds a
16
+ * readable PSM characteristic at startup and answers reads dynamically; the
17
+ * central connects, reads "<psm>:<nodeId>", tie-breaks, then opens the channel.
18
+ * `backend` is bare-bluetooth in production and a mock in tests.
19
+ *
20
+ * ponytail: capability-handshake DoS link-scoring is deferred — it needs a
21
+ * replication-progress signal (design §4b). v1 caps links + times out dials.
22
+ *
23
+ * @extends ReadyResource
24
+ */
25
+ export class BluetoothTransport extends ReadyResource {
26
+ /**
27
+ * @param {object} opts
28
+ * @param {any} opts.backend bare-bluetooth-shaped module (Central, Server, Service, Characteristic).
29
+ * @param {import('./index.js').Network} opts.network
30
+ * @param {Uint8Array} opts.uuid The 32-byte topic the service UUID derives from.
31
+ * @param {Uint8Array} opts.nodeId Stable local id (identity/device key) for the initiate tie-break.
32
+ * @param {string} [opts.tag] UUID namespace (channel mesh vs invite mesh).
33
+ * @param {number} [opts.cap] Max concurrent links; gossip covers the rest.
34
+ * @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
35
+ * @param {boolean} [opts.keepLinks] On close, stop the radio but leave established links alive (invite rendezvous: the link outlives the QR and carries the initial replication).
36
+ */
37
+ constructor({ backend, network, uuid, nodeId, tag, cap, scanOptions, keepLinks }: {
38
+ backend: any;
39
+ network: import("./index.js").Network;
40
+ uuid: Uint8Array;
41
+ nodeId: Uint8Array;
42
+ tag?: string;
43
+ cap?: number;
44
+ scanOptions?: {
45
+ scanMode?: any;
46
+ };
47
+ keepLinks?: boolean;
48
+ });
49
+ backend: any;
50
+ network: import("./index.js").Network;
51
+ nodeId: Uint8Array<ArrayBufferLike>;
52
+ nodeHex: any;
53
+ serviceUUID: string;
54
+ cap: number;
55
+ scanOptions: {
56
+ scanMode?: any;
57
+ };
58
+ keepLinks: boolean;
59
+ state: string;
60
+ central: any;
61
+ server: any;
62
+ psm: any;
63
+ _scanning: boolean;
64
+ _advertising: boolean;
65
+ _serviceAdded: boolean;
66
+ /** peripheral id being dialed → its connect-timeout timer */
67
+ _dialing: Map<any, any>;
68
+ /** live injected links keyed by remote node id hex */
69
+ peers: Map<any, any>;
70
+ /**
71
+ * Whether we should be the one to open the connection to `peerNodeId`. The
72
+ * lexicographically smaller id initiates; the larger waits — so a pair
73
+ * connects once, not twice. Equal (our own reflection) → false.
74
+ *
75
+ * @param {Uint8Array} peerNodeId
76
+ * @returns {boolean}
77
+ */
78
+ shouldInitiate(peerNodeId: Uint8Array): boolean;
79
+ get linkCount(): number;
80
+ _startServer(Service: any, Characteristic: any): void;
81
+ _maybeAdvertise(): void;
82
+ _startScan(): void;
83
+ _onState(raw: any): void;
84
+ _onDiscover(peripheral: any): void;
85
+ _onConnect(peripheral: any): void;
86
+ _abortDial(peripheral: any, _reason: any): void;
87
+ _clearDial(id: any): void;
88
+ _onCentralError(err: any): void;
89
+ _onChannel(l2cap: any, isInitiator: any, peripheralId: any): void;
90
+ _track(conn: any, peripheralId: any): void;
91
+ _untrack(conn: any): void;
92
+ }
93
+ import ReadyResource from 'ready-resource';
@@ -28,9 +28,44 @@ export class Network extends ReadyResource {
28
28
  wakeup: any;
29
29
  _replicateables: Set<any>;
30
30
  _discoveries: Set<any>;
31
+ _injected: Set<any>;
32
+ _blind: any;
33
+ /**
34
+ * Feed an externally-established connection — a Bluetooth L2CAP channel, a
35
+ * serial link, an in-process pair, any duplex — into the network. A raw
36
+ * duplex is wrapped in NoiseSecretStream (pass `isInitiator`); a stream
37
+ * that already IS one is used as-is. From here it gets the exact same
38
+ * treatment as a swarm connection: wakeup, replication of every attached
39
+ * core, pairing, and the 'connection' event.
40
+ *
41
+ * @param {any} stream Duplex transport, or a ready NoiseSecretStream.
42
+ * @param {{ isInitiator?: boolean }} [opts] Which side initiates the noise handshake (raw duplexes only).
43
+ * @returns {any} The encrypted connection stream.
44
+ */
45
+ inject(stream: any, { isInitiator }?: {
46
+ isInitiator?: boolean;
47
+ }): any;
48
+ /**
49
+ * Lazily create the network-shared BlindPairing. One instance serves every
50
+ * handle's pairing member — per-handle instances each added their own swarm
51
+ * and DHT listeners plus a protomux channel per connection.
52
+ *
53
+ * @returns {Promise<any>}
54
+ */
55
+ blind(): Promise<any>;
56
+ /**
57
+ * Re-attach pairing channels on injected connections. blind-pairing only
58
+ * auto-attaches refs that existed when a connection arrived — swarm peers
59
+ * meet again over topic joins, injected links (Bluetooth, pipes) don't, so
60
+ * a member/candidate added later must re-run the attach. Idempotent:
61
+ * protomux refuses duplicate channels.
62
+ *
63
+ * @returns {Promise<void>}
64
+ */
65
+ refreshInjected(): Promise<void>;
31
66
  /** @returns {Map<string, any>} Known peers keyed by public-key string. */
32
67
  get peers(): Map<string, any>;
33
- /** @returns {Set<any>} Live connection streams. */
68
+ /** @returns {Set<any>} Live connection streams — swarm and injected. */
34
69
  get connections(): Set<any>;
35
70
  /** @returns {boolean} */
36
71
  get suspended(): boolean;
@@ -4,6 +4,7 @@
4
4
  * @property {'rocks' | 'bee'} backend
5
5
  * @property {any} [root] Pre-existing HypercoreStorage to reuse.
6
6
  * @property {any} [store] Pre-existing Corestore to reuse.
7
+ * @property {Uint8Array} [storageKey] 32-byte key encrypting the backing core at rest (bee backend only).
7
8
  *
8
9
  * @typedef {{ name: string, kind: string }} Ref
9
10
  * @typedef {{ id?: string, createdAt: number, updatedAt: number, [k: string]: any }} StoredRow
@@ -35,7 +36,7 @@ export class Storage extends ReadyResource {
35
36
  * @param {string} dir
36
37
  * @param {StorageOpts} [opts]
37
38
  */
38
- constructor(dir: string, { spec, backend, root, store }?: StorageOpts);
39
+ constructor(dir: string, { spec, backend, root, store, storageKey }?: StorageOpts);
39
40
  dir: string;
40
41
  spec: {
41
42
  database: any;
@@ -47,6 +48,7 @@ export class Storage extends ReadyResource {
47
48
  };
48
49
  };
49
50
  backend: "rocks" | "bee";
51
+ storageKey: Uint8Array<ArrayBufferLike>;
50
52
  ns: string;
51
53
  refs: Record<string, {
52
54
  kind?: string;
@@ -143,6 +145,10 @@ export type StorageOpts = {
143
145
  * Pre-existing Corestore to reuse.
144
146
  */
145
147
  store?: any;
148
+ /**
149
+ * 32-byte key encrypting the backing core at rest (bee backend only).
150
+ */
151
+ storageKey?: Uint8Array;
146
152
  };
147
153
  export type Ref = {
148
154
  name: string;
@@ -1,3 +0,0 @@
1
- <claude-mem-context>
2
-
3
- </claude-mem-context>
@@ -1,3 +0,0 @@
1
- <claude-mem-context>
2
-
3
- </claude-mem-context>
package/src/lib/CLAUDE.md DELETED
@@ -1,3 +0,0 @@
1
- <claude-mem-context>
2
-
3
- </claude-mem-context>