@cero-base/cero 1.14.0 → 1.15.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.14.0",
3
+ "version": "1.15.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,15 +95,15 @@
95
95
  "test:node": "ls test/*.test.js | xargs -P1 -n1 brittle-node"
96
96
  },
97
97
  "dependencies": {
98
- "@cero-base/core": "^1.14.0",
98
+ "@cero-base/core": "^1.15.0",
99
99
  "b4a": "^1.8.1",
100
100
  "bare-abort-controller": "^1.1.2",
101
101
  "bare-crypto": "^1.15.3",
102
- "bare-fs": "^4.8.0",
102
+ "bare-fs": "^4.8.1",
103
103
  "bare-path": "^3.1.1",
104
104
  "ble-swarm": "^2.2.0",
105
105
  "compact-encoding": "^3.3.2",
106
- "corestore": "^7.12.0",
106
+ "corestore": "^7.12.2",
107
107
  "hrpc": "^4.3.1",
108
108
  "hypercore": "^11.35.2",
109
109
  "hypercore-crypto": "^3.7.0",
@@ -113,13 +113,13 @@
113
113
  "hyperschema": "^1.22.0",
114
114
  "ready-resource": "^1.2.0",
115
115
  "safety-catch": "^1.0.3",
116
- "streamx": "^2.28.0",
116
+ "streamx": "^2.28.1",
117
117
  "suspendify": "^1.7.2",
118
118
  "z32": "^1.1.0"
119
119
  },
120
120
  "devDependencies": {
121
121
  "@hyperswarm/testnet": "^3.1.4",
122
- "bare-events": "^2.9.1",
122
+ "bare-events": "^2.9.2",
123
123
  "bare-fetch": "^3.2.0",
124
124
  "bare-process": "^4.5.1",
125
125
  "bare-url": "^2.5.2",
@@ -212,7 +212,7 @@ export class Handle extends ReadyResource {
212
212
  }
213
213
  const store = this.store.store
214
214
  await this.store.close()
215
- if (this._discovery) await this._discovery.destroy()
215
+ if (this._discovery) this._discovery.destroy().catch(safetyCatch)
216
216
  await this.network.close()
217
217
  await store.close()
218
218
  if (this._storage) await this._storage.close()
@@ -732,12 +732,14 @@ export class Handle extends ReadyResource {
732
732
  async _pair(invite, type, { routes, timeout } = {}, target = null) {
733
733
  const deadline = timeout || TIMEOUT
734
734
 
735
- // Idempotent: if the invite targets a handle we already have AND we are
736
- // still a member of it, return it — without pairing, so there's no
737
- // waiting on a peer to confirm. If our member row is gone (we were
738
- // removed), the stored writer is revoked and the old session can never
739
- // become writable again holding a fresh invite is exactly the
740
- // re-admission path, so fall through to a real pairing instead.
735
+ // Idempotent: if the invite targets a handle we already have AND our
736
+ // stored writer is still admitted, return it — without pairing, so
737
+ // there's no waiting on a peer to confirm. A removed writer's core is
738
+ // frozen: the old session can never become writable again, and reusing
739
+ // its keypair would only get it re-removed. Holding a fresh invite is
740
+ // exactly the re-admission path, so fall through to a real pairing
741
+ // which mints a fresh keypair the host admits. Our member row can outlive
742
+ // our writer (one revoked device), so the device row is the real test.
741
743
  if (target) {
742
744
  const { data: joined } = await this.store.get('handles')
743
745
  const existing = joined.find(
@@ -746,7 +748,8 @@ export class Handle extends ReadyResource {
746
748
  if (existing) {
747
749
  const known = await this._load(type, existing.id)
748
750
  const { data: me } = await known.store.get('members', this.identity.id)
749
- if (me) return known
751
+ const { data: device } = await known.store.get('devices', toId(known.store.writerKey))
752
+ if (me && device) return known
750
753
  await known.close().catch(safetyCatch)
751
754
  }
752
755
  }
package/src/index.js CHANGED
@@ -64,6 +64,7 @@ export { t, schema } from './lib/spec.js'
64
64
  * @property {string | null} [name] Friendly device name persisted on the identity claim.
65
65
  * @property {boolean} [isMobile] Marks this device as mobile.
66
66
  * @property {Array<{ host: string, port: number }>} [bootstrap] Custom DHT bootstrap nodes.
67
+ * @property {number[]} [backoffs] Swarm reconnect backoff tiers in ms (testing/tuning).
67
68
  * @property {string} [channel] Optional network-isolation label; only same-channel peers connect.
68
69
  * @property {Array<string | Uint8Array>} [mirrors] Blind-peer public keys. Rooms and files are mirrored through them so peers sync even when never online at the same time. Mirrors hold only encrypted blocks — they never read your data.
69
70
  * @property {Uint8Array} [key] Pre-existing database key (skip bootstrap).
@@ -111,7 +112,7 @@ export async function cero(dir, spec, opts = {}) {
111
112
  await local.ready()
112
113
  }
113
114
 
114
- const identity = await resolveIdentity(opts, local)
115
+ const { identity, fresh } = await resolveIdentity(opts, local)
115
116
  const writer = local ? (await local.store.get('keypair')).data : null
116
117
 
117
118
  // Channel stamp: a storage remembers its channel; reopening it under a different channel —
@@ -127,13 +128,16 @@ export async function cero(dir, spec, opts = {}) {
127
128
 
128
129
  network = new Network({
129
130
  bootstrap: opts.bootstrap,
131
+ backoffs: opts.backoffs,
130
132
  channel: opts.channel,
131
133
  store,
132
134
  mirrors: opts.mirrors
133
135
  })
134
136
  await network.ready()
135
137
  discovery = network.join(identity.topic)
136
- await Promise.race([discovery.flush(), new Promise((r) => setTimeout(r, FLUSH))])
138
+ // a freshly minted identity has no peers yet — flushing the announce
139
+ // before proceeding only delays first onboarding
140
+ if (!fresh) await Promise.race([discovery.flush(), new Promise((r) => setTimeout(r, FLUSH))])
137
141
 
138
142
  me = new Handle({
139
143
  storage,
@@ -153,6 +157,21 @@ export async function cero(dir, spec, opts = {}) {
153
157
  })
154
158
  await me.ready()
155
159
 
160
+ // a supplied or stored identity may already have history elsewhere —
161
+ // authoring genesis twice forks the seed-derived writer core (a writable
162
+ // core has one author, ever). Give discovery a moment; any replicated
163
+ // history means the caller wanted { recovery: true }. Offline reuse is
164
+ // undetectable — this catches the reachable-peer case loudly.
165
+ if (!writer && !opts.key && !opts.recovery && !fresh) {
166
+ const until = Date.now() + 1500
167
+ while (Date.now() < until) {
168
+ if (network.connections.size > 0) await me.store.bee.update().catch(safetyCatch)
169
+ if (me.store.length > 0 || me.store.bee.local.length > 0) {
170
+ throw CeroError.CONFLICT('identity already has history — open with { recovery: true }')
171
+ }
172
+ await new Promise((r) => setTimeout(r, 100))
173
+ }
174
+ }
156
175
  if (!writer && me.store.length === 0 && (!opts.key || opts.recovery)) {
157
176
  const result = await me.bootstrap({
158
177
  name: opts.name || null,
@@ -315,18 +334,18 @@ cero.use = (...exts) => {
315
334
  }
316
335
 
317
336
  async function resolveIdentity(opts, local) {
318
- if (opts.identity) return opts.identity
337
+ if (opts.identity) return { identity: opts.identity, fresh: false }
319
338
 
320
339
  const provided = opts.seed || (opts.phrase && Identity.toSeed(opts.phrase))
321
340
  if (provided) {
322
341
  if (local) await local.store.set('master', { seed: provided })
323
- return Identity.fromSeed(provided)
342
+ return { identity: await Identity.fromSeed(provided), fresh: false }
324
343
  }
325
344
 
326
345
  const stored = local && (await local.store.get('master')).data?.seed
327
- if (stored) return Identity.fromSeed(stored)
346
+ if (stored) return { identity: await Identity.fromSeed(stored), fresh: false }
328
347
 
329
- const fresh = await Identity.generate({ words: opts.words })
330
- if (local) await local.store.set('master', { seed: fresh.seed })
331
- return fresh
348
+ const identity = await Identity.generate({ words: opts.words })
349
+ if (local) await local.store.set('master', { seed: identity.seed })
350
+ return { identity, fresh: true }
332
351
  }
package/types/index.d.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * @property {string | null} [name] Friendly device name persisted on the identity claim.
11
11
  * @property {boolean} [isMobile] Marks this device as mobile.
12
12
  * @property {Array<{ host: string, port: number }>} [bootstrap] Custom DHT bootstrap nodes.
13
+ * @property {number[]} [backoffs] Swarm reconnect backoff tiers in ms (testing/tuning).
13
14
  * @property {string} [channel] Optional network-isolation label; only same-channel peers connect.
14
15
  * @property {Array<string | Uint8Array>} [mirrors] Blind-peer public keys. Rooms and files are mirrored through them so peers sync even when never online at the same time. Mirrors hold only encrypted blocks — they never read your data.
15
16
  * @property {Uint8Array} [key] Pre-existing database key (skip bootstrap).
@@ -99,6 +100,10 @@ export type CeroOpts = {
99
100
  host: string;
100
101
  port: number;
101
102
  }>;
103
+ /**
104
+ * Swarm reconnect backoff tiers in ms (testing/tuning).
105
+ */
106
+ backoffs?: number[];
102
107
  /**
103
108
  * Optional network-isolation label; only same-channel peers connect.
104
109
  */