@cero-base/core 0.2.0 → 0.4.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.
Files changed (52) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +180 -136
  3. package/package.json +85 -26
  4. package/src/blobs/index.js +297 -0
  5. package/src/database/CLAUDE.md +3 -0
  6. package/src/database/bootstrap.js +76 -0
  7. package/src/database/dispatch.js +156 -0
  8. package/src/database/index.js +572 -0
  9. package/src/identity/CLAUDE.md +3 -0
  10. package/src/identity/index.js +232 -0
  11. package/src/index.js +20 -1
  12. package/src/lib/CLAUDE.md +3 -0
  13. package/src/lib/constants.js +24 -4
  14. package/src/lib/errors.js +150 -0
  15. package/src/lib/schema.js +58 -440
  16. package/src/lib/spec/index.js +353 -0
  17. package/src/lib/spec/schema.json +284 -0
  18. package/src/lib/utils.js +54 -49
  19. package/src/network/discovery.js +80 -0
  20. package/src/network/index.js +231 -0
  21. package/src/pairing/index.js +482 -0
  22. package/src/pairing/invite.js +199 -0
  23. package/src/rpc/client.js +45 -0
  24. package/src/rpc/index.js +141 -0
  25. package/src/rpc/server.js +45 -0
  26. package/src/storage/index.js +261 -0
  27. package/types/blobs/index.d.ts +169 -0
  28. package/types/database/bootstrap.d.ts +17 -0
  29. package/types/database/dispatch.d.ts +8 -0
  30. package/types/database/index.d.ts +329 -0
  31. package/types/identity/index.d.ts +160 -0
  32. package/types/index.d.ts +11 -0
  33. package/types/lib/constants.d.ts +13 -0
  34. package/types/lib/errors.d.ts +110 -0
  35. package/types/lib/schema.d.ts +53 -0
  36. package/types/lib/spec/index.d.ts +95 -0
  37. package/types/lib/utils.d.ts +39 -0
  38. package/types/network/discovery.d.ts +44 -0
  39. package/types/network/index.d.ts +115 -0
  40. package/types/pairing/index.d.ts +194 -0
  41. package/types/pairing/invite.d.ts +157 -0
  42. package/types/rpc/client.d.ts +18 -0
  43. package/types/rpc/index.d.ts +67 -0
  44. package/types/rpc/server.d.ts +18 -0
  45. package/types/storage/index.d.ts +163 -0
  46. package/src/lib/base.js +0 -84
  47. package/src/lib/batch.js +0 -98
  48. package/src/lib/builder.js +0 -24
  49. package/src/lib/collection.js +0 -252
  50. package/src/lib/crypto.js +0 -6
  51. package/src/lib/index.js +0 -6
  52. package/src/lib/room.js +0 -145
@@ -0,0 +1,482 @@
1
+ import BlindPairing from 'blind-pairing'
2
+ import ReadyResource from 'ready-resource'
3
+ import safetyCatch from 'safety-catch'
4
+ import b4a from 'b4a'
5
+ import c from 'compact-encoding'
6
+ import { discoveryKey, keyPair, sign } from 'hypercore-crypto'
7
+
8
+ import { Invite } from './invite.js'
9
+ import { getEncoding } from '../lib/spec/index.js'
10
+ import { CeroError } from '../lib/errors.js'
11
+
12
+ const STATUS_OK = 0
13
+ const STATUS_DENIED = 1
14
+
15
+ // Wire envelope carried in BlindPairing's `additional.data` on the confirm response.
16
+ // We piggyback on the confirm path for *both* success and deny — blind-pairing's
17
+ // native deny() has no payload field, and its discoveryKey check pins the wire
18
+ // `key` to the invite, so the real resource key flows through this envelope.
19
+ const Response = getEncoding('@cero/confirm')
20
+
21
+ /**
22
+ * @typedef {object} PairingOpts
23
+ * @property {import('../network/index.js').Network} network Network to host the BlindPairing member.
24
+ * @property {import('../identity/index.js').Identity} identity Identity used to sign invites and derive the topic.
25
+ * @property {Uint8Array} [topic] Optional override topic; defaults to `identity.publicKey`.
26
+ * @property {any} [inviteEncoding] compact-encoding type for the invite payload `data`.
27
+ * @property {any} [joinerEncoding] compact-encoding type for the joiner-supplied `userData`.
28
+ *
29
+ * @typedef {object} CreateInviteOpts
30
+ * @property {string} [role] Role tag bound into the invite.
31
+ * @property {number} [expiresIn] TTL in ms; `0`/omitted = no expiry.
32
+ * @property {any} [data] Arbitrary payload (encoded via `inviteEncoding` when set).
33
+ *
34
+ * @typedef {object} JoinOpts
35
+ * @property {any} [userData] Payload sent with the candidate request.
36
+ * @property {number} [timeout] Deadline for the handshake, in ms. Defaults to 30000.
37
+ *
38
+ * @typedef {object} ConfirmOpts
39
+ * @property {Uint8Array} [key] 32-byte resource key delivered to the joiner; required at runtime.
40
+ * @property {Uint8Array | null} [encryptionKey] Optional 32-byte symmetric key.
41
+ * @property {Uint8Array | null} [additional] Extra opaque bytes piggybacked on the response.
42
+ *
43
+ * @typedef {{ key: Uint8Array, encryptionKey: Uint8Array | null, additional: Uint8Array | null }} JoinResult
44
+ *
45
+ * @typedef {object} CandidateOpts
46
+ * @property {Pairing} pairing Owning Pairing instance.
47
+ * @property {any} req blind-pairing candidate request being answered.
48
+ * @property {Invite} invite Invite the candidate paired against.
49
+ * @property {Uint8Array} seed Per-invite seed used to sign the response.
50
+ * @property {any} userData Decoded joiner payload (raw bytes when no encoding).
51
+ * @property {() => void} onsettle Called once when the candidate is confirmed or denied.
52
+ */
53
+
54
+ /**
55
+ * Blind-pairing membership wrapper. Hosts invites, dispatches incoming
56
+ * candidates to the application for accept/deny, and provides the joiner
57
+ * side of the handshake. All wire payloads are signed and routed through
58
+ * the supplied network.
59
+ */
60
+ export class Pairing extends ReadyResource {
61
+ /** @param {PairingOpts} [opts] */
62
+ constructor({ network, identity, topic, inviteEncoding = null, joinerEncoding = null } = {}) {
63
+ super()
64
+ if (!network) throw CeroError.REQUIRED('network')
65
+ if (!identity) throw CeroError.REQUIRED('identity')
66
+
67
+ this.network = network
68
+ this.identity = identity
69
+ this.topic = topic || identity.publicKey
70
+ this.inviteEncoding = inviteEncoding
71
+ this.joinerEncoding = joinerEncoding
72
+
73
+ this._blind = null
74
+ this._member = null
75
+ this._invites = new Map() // inviteId.hex → record
76
+ this._candidates = new Set() // active joiner candidates
77
+ }
78
+
79
+ async _open() {
80
+ await this.network.ready()
81
+ this._blind = new BlindPairing(this.network.swarm)
82
+ await this._blind.ready()
83
+
84
+ this._member = this._blind.addMember({
85
+ discoveryKey: discoveryKey(this.topic),
86
+ onadd: (req) => this._onCandidate(req).catch(safetyCatch)
87
+ })
88
+ }
89
+
90
+ async _close() {
91
+ for (const candidate of [...this._candidates]) candidate._fail(CeroError.CLOSED('Pairing'))
92
+ this._candidates.clear()
93
+
94
+ if (this._member) {
95
+ await this._member.close()
96
+ this._member = null
97
+ }
98
+ if (this._blind) {
99
+ await this._blind.close()
100
+ this._blind = null
101
+ }
102
+ this._invites.clear()
103
+ }
104
+
105
+ /**
106
+ * Mint a new pairing invite. Returns its canonical wire form (z32 string).
107
+ *
108
+ * @param {CreateInviteOpts} [opts]
109
+ * @returns {Promise<string>}
110
+ */
111
+ async createInvite({ role = '', expiresIn = 0, data = null } = {}) {
112
+ if (this.closing || this.closed) throw CeroError.CLOSED('Pairing')
113
+ if (!this.opened) await this.ready()
114
+
115
+ // Rendezvous key matches the Member topic so candidates land on the right
116
+ // Pairing instance (per-handle keys avoid identity-wide collisions).
117
+ const blind = BlindPairing.createInvite(this.topic)
118
+
119
+ const invite = Invite.create({
120
+ secretKey: this.identity.secretKey,
121
+ publicKey: this.identity.publicKey,
122
+ role,
123
+ expiresIn,
124
+ data,
125
+ blindInvite: blind.invite,
126
+ encoding: this.inviteEncoding
127
+ })
128
+
129
+ const id = b4a.toString(blind.id, 'hex')
130
+ this._invites.set(id, {
131
+ id: blind.id,
132
+ seed: blind.seed,
133
+ publicKey: blind.publicKey,
134
+ invite
135
+ })
136
+
137
+ return invite.toString()
138
+ }
139
+
140
+ /**
141
+ * Forget a previously-minted invite. New candidates carrying it will be
142
+ * dropped silently.
143
+ *
144
+ * @param {string} inviteStr
145
+ * @returns {boolean}
146
+ */
147
+ revoke(inviteStr) {
148
+ for (const [id, record] of this._invites) {
149
+ if (record.invite.toString() === inviteStr) {
150
+ this._invites.delete(id)
151
+ return true
152
+ }
153
+ }
154
+ return false
155
+ }
156
+
157
+ /**
158
+ * Cheap structural test for a candidate invite string.
159
+ *
160
+ * @param {unknown} str
161
+ * @returns {boolean}
162
+ */
163
+ static isInvite(str) {
164
+ return Invite.isInvite(str)
165
+ }
166
+
167
+ /**
168
+ * Joiner side — start a pairing handshake against `inviteStr` and resolve
169
+ * with the host's response when the host confirms.
170
+ *
171
+ * @param {string} inviteStr
172
+ * @param {JoinOpts} [opts]
173
+ * @returns {Promise<JoinResult>}
174
+ */
175
+ async join(inviteStr, { userData = null, timeout = 30000 } = {}) {
176
+ if (this.closing || this.closed) throw CeroError.CLOSED('Pairing')
177
+ if (!this.opened) await this.ready()
178
+
179
+ let invite
180
+ try {
181
+ invite = Invite.parse(inviteStr, { encoding: this.inviteEncoding })
182
+ } catch (err) {
183
+ if (err instanceof CeroError) throw err
184
+ throw CeroError.INVALID_INVITE(err.message)
185
+ }
186
+
187
+ if (invite.expired) throw CeroError.EXPIRED()
188
+
189
+ const encodedUserData = encodeUserData(userData, this.joinerEncoding)
190
+ const candidate = new JoinerCandidate(this, invite, encodedUserData, timeout)
191
+ return candidate.start()
192
+ }
193
+
194
+ /**
195
+ * Whether the underlying blind-pairing layer is suspended.
196
+ *
197
+ * @returns {boolean}
198
+ */
199
+ get suspended() {
200
+ return this._blind?.suspended === true
201
+ }
202
+
203
+ /**
204
+ * Pause blind-pairing — keeps state, drops sockets. Idempotent.
205
+ *
206
+ * @returns {Promise<void>}
207
+ */
208
+ async suspend() {
209
+ if (!this._blind || this.closing || this.closed) return
210
+ if (this._blind.suspended) return
211
+ try {
212
+ await this._blind.suspend()
213
+ } catch (err) {
214
+ safetyCatch(err)
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Resume a suspended blind-pairing layer. Idempotent.
220
+ *
221
+ * @returns {Promise<void>}
222
+ */
223
+ async resume() {
224
+ if (!this._blind || this.closing || this.closed) return
225
+ if (!this._blind.suspended) return
226
+ try {
227
+ await this._blind.resume()
228
+ } catch (err) {
229
+ safetyCatch(err)
230
+ }
231
+ }
232
+
233
+ async _onCandidate(req) {
234
+ const id = b4a.toString(req.inviteId, 'hex')
235
+ const record = this._invites.get(id)
236
+ if (!record) return
237
+
238
+ try {
239
+ req.open(record.publicKey)
240
+ } catch (err) {
241
+ safetyCatch(err)
242
+ return
243
+ }
244
+
245
+ const userData = decodeUserData(req.userData, this.joinerEncoding)
246
+
247
+ const candidate = new Candidate({
248
+ pairing: this,
249
+ req,
250
+ invite: record.invite,
251
+ seed: record.seed,
252
+ userData,
253
+ onsettle: () => {
254
+ this._invites.delete(id)
255
+ }
256
+ })
257
+
258
+ this.emit('candidate', candidate)
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Internal — single incoming candidate awaiting host accept/deny. Created by
264
+ * `Pairing` and emitted on the `'candidate'` event so the host can decide.
265
+ *
266
+ * @property {boolean} _settled Whether confirm/deny has already run.
267
+ */
268
+ class Candidate {
269
+ /** @param {CandidateOpts} opts */
270
+ constructor({ pairing, req, invite, seed, userData, onsettle }) {
271
+ this.pairing = pairing
272
+ this._req = req
273
+ this._seed = seed
274
+ this._settled = false
275
+ this._onsettle = onsettle
276
+
277
+ this.invite = invite
278
+ this.userData = userData
279
+ this.publicKey = req.publicKey
280
+ }
281
+
282
+ /**
283
+ * Accept the candidate and reveal the resource key. Idempotent.
284
+ *
285
+ * @param {ConfirmOpts} opts
286
+ * @returns {Promise<void>}
287
+ */
288
+ async confirm({ key, encryptionKey = null, additional = null } = {}) {
289
+ if (!key || key.length !== 32) throw CeroError.INVALID('key must be a 32-byte buffer')
290
+ if (encryptionKey !== null && encryptionKey !== undefined && encryptionKey.length !== 32) {
291
+ throw CeroError.INVALID('encryptionKey must be a 32-byte buffer')
292
+ }
293
+ if (
294
+ additional !== null &&
295
+ additional !== undefined &&
296
+ !b4a.isBuffer(additional) &&
297
+ !(additional instanceof Uint8Array)
298
+ ) {
299
+ throw CeroError.INVALID('additional must be a buffer')
300
+ }
301
+ if (this._settled) return
302
+ this._settled = true
303
+ this._onsettle()
304
+
305
+ const data = c.encode(Response, {
306
+ status: STATUS_OK,
307
+ reason: '',
308
+ key,
309
+ encryptionKey: encryptionKey || null,
310
+ extra: additional || null
311
+ })
312
+ const signature = sign(data, keyPair(this._seed).secretKey)
313
+
314
+ this._req.confirm({
315
+ key: this.pairing.topic,
316
+ encryptionKey: undefined,
317
+ additional: { data, signature }
318
+ })
319
+ }
320
+
321
+ /**
322
+ * Reject the candidate with an optional reason. Idempotent.
323
+ *
324
+ * @param {string} [reason]
325
+ * @returns {Promise<void>}
326
+ */
327
+ async deny(reason = '') {
328
+ if (this._settled) return
329
+ this._settled = true
330
+ this._onsettle()
331
+
332
+ const data = c.encode(Response, {
333
+ status: STATUS_DENIED,
334
+ reason: String(reason || ''),
335
+ key: null,
336
+ encryptionKey: null,
337
+ extra: null
338
+ })
339
+ const signature = sign(data, keyPair(this._seed).secretKey)
340
+
341
+ this._req.confirm({
342
+ key: this.pairing.topic,
343
+ encryptionKey: undefined,
344
+ additional: { data, signature }
345
+ })
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Internal — joiner-side handshake state machine. One-shot: created by
351
+ * `Pairing.join()`, settled exactly once via `_done` or `_fail`.
352
+ *
353
+ * @property {any} _candidate blind-pairing addCandidate handle (null until started).
354
+ * @property {ReturnType<typeof setTimeout> | null} _timer Pending timeout, if any.
355
+ * @property {((result: JoinResult) => void) | null} _resolve Promise resolver, set in `start`.
356
+ * @property {((err: Error) => void) | null} _reject Promise rejecter, set in `start`.
357
+ * @property {boolean} _settled Whether the handshake has already settled.
358
+ */
359
+ class JoinerCandidate {
360
+ /**
361
+ * @param {Pairing} pairing
362
+ * @param {Invite} invite
363
+ * @param {Uint8Array | null} encodedUserData
364
+ * @param {number} timeout
365
+ */
366
+ constructor(pairing, invite, encodedUserData, timeout) {
367
+ this.pairing = pairing
368
+ this.invite = invite
369
+ this.userData = encodedUserData
370
+ this.timeout = timeout
371
+ this._candidate = null
372
+ this._timer = null
373
+ this._resolve = null
374
+ this._reject = null
375
+ this._settled = false
376
+ }
377
+
378
+ /**
379
+ * Kick off the handshake; returns a promise that settles with the host response.
380
+ *
381
+ * @returns {Promise<JoinResult>}
382
+ */
383
+ start() {
384
+ this.pairing._candidates.add(this)
385
+
386
+ return new Promise((resolve, reject) => {
387
+ this._resolve = resolve
388
+ this._reject = reject
389
+
390
+ if (this.timeout > 0) {
391
+ this._timer = setTimeout(() => this._fail(CeroError.TIMEOUT()), this.timeout)
392
+ }
393
+
394
+ try {
395
+ this._candidate = this.pairing._blind.addCandidate({
396
+ invite: this.invite.blindInvite,
397
+ userData: this.userData,
398
+ onadd: (result) => this._done(result)
399
+ })
400
+ this._candidate.request.on('rejected', (err) => this._fail(fromBlindError(err)))
401
+ } catch (err) {
402
+ this._fail(err instanceof CeroError ? err : CeroError.NETWORK_ERROR(err.message))
403
+ }
404
+ })
405
+ }
406
+
407
+ _done(result) {
408
+ if (this._settled) return
409
+
410
+ let envelope = null
411
+ const buf = result?.data ?? null
412
+ if (buf) {
413
+ try {
414
+ envelope = c.decode(Response, buf)
415
+ } catch {
416
+ envelope = null
417
+ }
418
+ }
419
+
420
+ if (envelope && envelope.status === STATUS_DENIED) {
421
+ this._fail(CeroError.DENIED(envelope.reason || null))
422
+ return
423
+ }
424
+
425
+ if (!envelope) {
426
+ this._fail(CeroError.NETWORK_ERROR('malformed pairing response'))
427
+ return
428
+ }
429
+
430
+ this._settled = true
431
+ this.pairing._candidates.delete(this)
432
+ clearTimeout(this._timer)
433
+
434
+ this._resolve({
435
+ key: envelope.key,
436
+ encryptionKey: envelope.encryptionKey,
437
+ additional: envelope.extra
438
+ })
439
+
440
+ this._candidate.close().catch(safetyCatch)
441
+ }
442
+
443
+ _fail(err) {
444
+ if (this._settled) return
445
+ this._settled = true
446
+ this.pairing._candidates.delete(this)
447
+ clearTimeout(this._timer)
448
+ if (this._candidate) this._candidate.close().catch(safetyCatch)
449
+ this._reject(err)
450
+ }
451
+ }
452
+
453
+ function encodeUserData(userData, encoding) {
454
+ if (userData === null || userData === undefined) return null
455
+ if (encoding) return c.encode(encoding, userData)
456
+ if (b4a.isBuffer(userData) || userData instanceof Uint8Array) return userData
457
+ throw CeroError.INVALID('userData must be a buffer when no joinerEncoding is provided')
458
+ }
459
+
460
+ function decodeUserData(buf, encoding) {
461
+ if (!buf || buf.length === 0) return null
462
+ if (!encoding) return buf
463
+ try {
464
+ return c.decode(encoding, buf)
465
+ } catch {
466
+ return buf
467
+ }
468
+ }
469
+
470
+ function fromBlindError(err) {
471
+ if (!err || !err.code) return CeroError.DENIED(err?.message || null)
472
+ switch (err.code) {
473
+ case 'INVITE_EXPIRED':
474
+ return CeroError.EXPIRED(err.message)
475
+ case 'INVITE_USED':
476
+ return CeroError.DENIED('used', err.message)
477
+ case 'PAIRING_REJECTED':
478
+ return CeroError.DENIED(null, err.message)
479
+ default:
480
+ return CeroError.DENIED(err.message)
481
+ }
482
+ }
@@ -0,0 +1,199 @@
1
+ import BlindPairing from 'blind-pairing'
2
+ import sodium from 'sodium-universal'
3
+ import b4a from 'b4a'
4
+ import z32 from 'z32'
5
+ import c from 'compact-encoding'
6
+
7
+ import { getEncoding } from '../lib/spec/index.js'
8
+ import { CeroError } from '../lib/errors.js'
9
+
10
+ const VERSION = 1
11
+ const Envelope = getEncoding('@cero/invite')
12
+ const SignBody = getEncoding('@cero/invite-body')
13
+
14
+ /**
15
+ * @typedef {object} InviteFields
16
+ * @property {Uint8Array} publicKey Signer's long-lived public key.
17
+ * @property {string} role Optional role tag baked into the signed body.
18
+ * @property {number} expires Absolute expiry timestamp; `0` means never.
19
+ * @property {Uint8Array | null} data Encoded payload (raw or via `encoding`).
20
+ * @property {Uint8Array} blindInvite Raw blind-pairing invite handed to candidates.
21
+ * @property {Uint8Array} [sig] Ed25519 signature over the canonical body.
22
+ * @property {string | null} [_str] Cached z32 string form.
23
+ *
24
+ * @typedef {object} CreateInviteOpts
25
+ * @property {Uint8Array} secretKey 64-byte Ed25519 secret key used to sign.
26
+ * @property {Uint8Array} publicKey 32-byte Ed25519 public key matching `secretKey`.
27
+ * @property {string} role Optional role tag for the joiner.
28
+ * @property {number} expiresIn TTL in ms from now; `0` means never expires.
29
+ * @property {any} data Caller payload; encoded via `encoding` when provided.
30
+ * @property {Uint8Array} blindInvite Raw blind-pairing invite to wrap.
31
+ * @property {any} [encoding] compact-encoding type used to encode `data`.
32
+ *
33
+ * @typedef {object} ParseInviteOpts
34
+ * @property {any} [encoding] compact-encoding type used to decode the embedded payload.
35
+ */
36
+
37
+ /**
38
+ * Signed, expirable pairing invite. Wraps a blind-pairing invite with a
39
+ * role, optional payload and an Ed25519 signature so the host can be
40
+ * authenticated by the joiner before any handshake happens.
41
+ *
42
+ * @property {any} [_rawData] Decoded payload kept alongside the encoded `data` for convenience.
43
+ * @property {any} _blind Lazily-decoded blind-pairing invite view (cache).
44
+ */
45
+ export class Invite {
46
+ /** @param {InviteFields} fields */
47
+ constructor(fields) {
48
+ this.version = VERSION
49
+ this.publicKey = fields.publicKey
50
+ this.role = fields.role
51
+ this.expires = fields.expires
52
+ this.data = fields.data
53
+ this.blindInvite = fields.blindInvite
54
+ this.sig = fields.sig
55
+ this._str = fields._str || null
56
+
57
+ // decoded blind-pairing invite info (cached)
58
+ this._blind = null
59
+ }
60
+
61
+ /**
62
+ * Whether the invite is past its TTL (always `false` when `expires === 0`).
63
+ *
64
+ * @returns {boolean}
65
+ */
66
+ get expired() {
67
+ return this.expires > 0 && Date.now() > this.expires
68
+ }
69
+
70
+ /**
71
+ * Lazily-decoded view of the underlying blind-pairing invite.
72
+ *
73
+ * @returns {any}
74
+ */
75
+ get blind() {
76
+ if (!this._blind) this._blind = BlindPairing.decodeInvite(this.blindInvite)
77
+ return this._blind
78
+ }
79
+
80
+ /**
81
+ * Verify the embedded signature against the encoded body.
82
+ *
83
+ * @returns {boolean}
84
+ */
85
+ verify() {
86
+ const body = c.encode(SignBody, this)
87
+ return sodium.crypto_sign_verify_detached(this.sig, body, this.publicKey)
88
+ }
89
+
90
+ /**
91
+ * Serialise to the canonical z32 wire form (cached).
92
+ *
93
+ * @returns {string}
94
+ */
95
+ toString() {
96
+ if (this._str) return this._str
97
+ const buf = c.encode(Envelope, this)
98
+ this._str = z32.encode(buf)
99
+ return this._str
100
+ }
101
+
102
+ /**
103
+ * Build and sign a new invite envelope wrapping a blind-pairing invite.
104
+ *
105
+ * @param {CreateInviteOpts} opts
106
+ * @returns {Invite}
107
+ */
108
+ static create({ secretKey, publicKey, role, expiresIn, data, blindInvite, encoding }) {
109
+ if (!secretKey || secretKey.length !== 64)
110
+ throw CeroError.INVALID('secretKey must be a 64-byte buffer')
111
+ if (!publicKey || publicKey.length !== 32)
112
+ throw CeroError.INVALID('publicKey must be a 32-byte buffer')
113
+ if (!b4a.isBuffer(blindInvite) && !(blindInvite instanceof Uint8Array)) {
114
+ throw CeroError.INVALID('blindInvite must be a buffer')
115
+ }
116
+
117
+ const expires = expiresIn ? Date.now() + expiresIn : 0
118
+ const encoded =
119
+ data === undefined || data === null ? null : encoding ? c.encode(encoding, data) : data
120
+
121
+ const fields = {
122
+ version: VERSION,
123
+ publicKey,
124
+ role: role || '',
125
+ expires,
126
+ data: encoded,
127
+ blindInvite
128
+ }
129
+
130
+ const body = c.encode(SignBody, fields)
131
+ const sig = b4a.alloc(64)
132
+ sodium.crypto_sign_detached(sig, body, secretKey)
133
+ fields.sig = sig
134
+
135
+ const inv = new Invite(fields)
136
+ inv._rawData = data // remember decoded form for convenience
137
+ return inv
138
+ }
139
+
140
+ /**
141
+ * Parse a z32 invite string, verify its signature and (optionally) decode
142
+ * its payload.
143
+ *
144
+ * @param {string} str
145
+ * @param {ParseInviteOpts} [opts]
146
+ * @returns {Invite}
147
+ */
148
+ static parse(str, { encoding } = {}) {
149
+ if (typeof str !== 'string') throw CeroError.INVALID_INVITE('invite must be a string')
150
+
151
+ let buf
152
+ try {
153
+ buf = z32.decode(str)
154
+ } catch {
155
+ throw CeroError.INVALID_INVITE('invite is not valid z32')
156
+ }
157
+
158
+ let fields
159
+ try {
160
+ fields = c.decode(Envelope, buf)
161
+ } catch {
162
+ throw CeroError.INVALID_INVITE('invite envelope failed to decode')
163
+ }
164
+
165
+ if (fields.version !== VERSION) throw CeroError.INVALID_INVITE('unknown invite version')
166
+
167
+ const inv = new Invite({ ...fields, _str: str })
168
+ if (!inv.verify()) throw CeroError.INVALID_INVITE('invite signature is invalid')
169
+
170
+ if (encoding && inv.data) {
171
+ try {
172
+ inv._rawData = c.decode(encoding, inv.data)
173
+ } catch {
174
+ throw CeroError.INVALID_INVITE('invite data failed to decode')
175
+ }
176
+ }
177
+
178
+ return inv
179
+ }
180
+
181
+ /**
182
+ * Cheap structural test — does `str` decode as an invite envelope? Does not
183
+ * verify the signature.
184
+ *
185
+ * @param {unknown} str
186
+ * @returns {boolean}
187
+ */
188
+ static isInvite(str) {
189
+ if (typeof str !== 'string') return false
190
+ try {
191
+ const buf = z32.decode(str)
192
+ const fields = c.decode(Envelope, buf)
193
+ if (fields.blindInvite) BlindPairing.decodeInvite(fields.blindInvite)
194
+ return true
195
+ } catch {
196
+ return false
197
+ }
198
+ }
199
+ }