@cero-base/core 0.8.10 → 1.0.1
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 +25 -9
- package/src/blobs/codec.js +52 -0
- package/src/blobs/index.js +42 -164
- package/src/blobs/server.js +48 -0
- package/src/database/bootstrap.js +24 -12
- package/src/database/dispatch.js +91 -17
- package/src/database/index.js +112 -26
- package/src/identity/index.js +1 -1
- package/src/index.js +25 -2
- package/src/lib/constants.js +21 -1
- package/src/lib/schema.js +2 -0
- package/src/lib/spec/index.js +106 -120
- package/src/lib/spec/schema.json +74 -80
- package/src/lib/utils.js +33 -2
- package/src/network/index.js +1 -1
- package/src/pairing/index.js +57 -56
- package/src/pairing/invite.js +19 -18
- package/src/rpc/client.js +4 -42
- package/src/rpc/index.js +8 -4
- package/src/rpc/peer.js +46 -0
- package/src/rpc/server.js +4 -42
- package/src/storage/index.js +16 -6
- package/types/blobs/codec.d.ts +26 -0
- package/types/blobs/index.d.ts +25 -90
- package/types/blobs/server.d.ts +35 -0
- package/types/database/bootstrap.d.ts +1 -1
- package/types/database/dispatch.d.ts +26 -3
- package/types/database/index.d.ts +53 -17
- package/types/index.d.ts +50 -2
- package/types/lib/constants.d.ts +24 -1
- package/types/lib/schema.d.ts +1 -0
- package/types/lib/spec/index.d.ts +9 -12
- package/types/lib/utils.d.ts +12 -2
- package/types/pairing/index.d.ts +15 -4
- package/types/pairing/invite.d.ts +11 -11
- package/types/rpc/client.d.ts +4 -15
- package/types/rpc/peer.d.ts +18 -0
- package/types/rpc/server.d.ts +4 -15
- package/types/storage/index.d.ts +9 -4
package/src/lib/utils.js
CHANGED
|
@@ -3,6 +3,8 @@ import { Readable } from 'streamx'
|
|
|
3
3
|
import z32 from 'z32'
|
|
4
4
|
import { encode, decode, isValid } from 'hypercore-id-encoding'
|
|
5
5
|
|
|
6
|
+
import { ROLE_PERMS, RANK } from './constants.js'
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* Generate a short opaque id (z32-encoded 16 random bytes).
|
|
8
10
|
*
|
|
@@ -33,6 +35,21 @@ export const toKey = decode
|
|
|
33
35
|
*/
|
|
34
36
|
export const isKeyId = isValid
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Whether `role` is granted `perm` under the default policy.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} role
|
|
42
|
+
* @param {string} perm
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
export function can(role, perm) {
|
|
46
|
+
const perms = ROLE_PERMS[role]
|
|
47
|
+
return !!perms && (perms.includes('*') || perms.includes(perm))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const grants = (a, b) => RANK[a] != null && RANK[b] != null && RANK[a] >= RANK[b]
|
|
51
|
+
export const outranks = (a, b) => RANK[a] != null && RANK[b] != null && RANK[a] > RANK[b]
|
|
52
|
+
|
|
36
53
|
/**
|
|
37
54
|
* Stream-of-snapshots primitive. Couples a `get` (returns the latest value)
|
|
38
55
|
* with a `watch` (re-fires on change) and emits the newest snapshot every
|
|
@@ -41,24 +58,38 @@ export const isKeyId = isValid
|
|
|
41
58
|
* @template T
|
|
42
59
|
* @param {object} args
|
|
43
60
|
* @param {() => Promise<T> | T} args.get
|
|
44
|
-
* @param {(fn: () => void) => (() => void) | void} args.watch
|
|
61
|
+
* @param {(fn: () => void) => (() => void) | void} args.watch
|
|
45
62
|
* @returns {import('streamx').Readable<T>}
|
|
46
63
|
*/
|
|
47
64
|
export function subscribe({ get, watch }) {
|
|
48
65
|
let stop = null
|
|
66
|
+
let version = 0
|
|
67
|
+
let last
|
|
49
68
|
const stream = new Readable({
|
|
50
69
|
destroy(cb) {
|
|
51
70
|
Promise.resolve(stop?.()).then(() => cb(null), cb)
|
|
52
71
|
}
|
|
53
72
|
})
|
|
73
|
+
|
|
74
|
+
const emit = (data) => {
|
|
75
|
+
const snap = JSON.stringify(data)
|
|
76
|
+
if (snap === last) return
|
|
77
|
+
last = snap
|
|
78
|
+
stream.push(data)
|
|
79
|
+
}
|
|
80
|
+
|
|
54
81
|
const push = async () => {
|
|
55
82
|
if (stream.destroyed) return
|
|
83
|
+
const v = ++version
|
|
56
84
|
try {
|
|
57
|
-
|
|
85
|
+
const data = await get()
|
|
86
|
+
if (v !== version || stream.destroyed) return
|
|
87
|
+
emit(data)
|
|
58
88
|
} catch (e) {
|
|
59
89
|
stream.destroy(e)
|
|
60
90
|
}
|
|
61
91
|
}
|
|
92
|
+
|
|
62
93
|
stop = watch(push)
|
|
63
94
|
push()
|
|
64
95
|
return stream
|
package/src/network/index.js
CHANGED
package/src/pairing/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { Invite } from './invite.js'
|
|
|
9
9
|
import { getEncoding } from '../lib/spec/index.js'
|
|
10
10
|
import { CeroError } from '../lib/errors.js'
|
|
11
11
|
|
|
12
|
-
const
|
|
12
|
+
const STATUS_ACCEPTED = 0
|
|
13
13
|
const STATUS_DENIED = 1
|
|
14
14
|
|
|
15
15
|
// Wire envelope carried in BlindPairing's `additional.data` on the confirm response.
|
|
@@ -25,11 +25,13 @@ const Response = getEncoding('@cero/confirm')
|
|
|
25
25
|
* @property {Uint8Array} [topic] Optional override topic; defaults to `identity.publicKey`.
|
|
26
26
|
* @property {any} [inviteEncoding] compact-encoding type for the invite payload `data`.
|
|
27
27
|
* @property {any} [joinerEncoding] compact-encoding type for the joiner-supplied `userData`.
|
|
28
|
+
* @property {(err: Error) => void} [onerror] Called when a background candidate fails to process.
|
|
28
29
|
*
|
|
29
30
|
* @typedef {object} CreateInviteOpts
|
|
30
31
|
* @property {string} [role] Role tag bound into the invite.
|
|
31
32
|
* @property {number} [expiresIn] TTL in ms; `0`/omitted = no expiry.
|
|
32
33
|
* @property {any} [data] Arbitrary payload (encoded via `inviteEncoding` when set).
|
|
34
|
+
* @property {boolean} [multiUse] Reusable until expiry/revoke; default `false` (consumed on first settle).
|
|
33
35
|
*
|
|
34
36
|
* @typedef {object} JoinOpts
|
|
35
37
|
* @property {any} [userData] Payload sent with the candidate request.
|
|
@@ -42,7 +44,7 @@ const Response = getEncoding('@cero/confirm')
|
|
|
42
44
|
*
|
|
43
45
|
* @typedef {{ key: Uint8Array, encryptionKey: Uint8Array | null, additional: Uint8Array | null }} JoinResult
|
|
44
46
|
*
|
|
45
|
-
* @typedef {object}
|
|
47
|
+
* @typedef {object} RequestOpts
|
|
46
48
|
* @property {Pairing} pairing Owning Pairing instance.
|
|
47
49
|
* @property {any} req blind-pairing candidate request being answered.
|
|
48
50
|
* @property {Invite} invite Invite the candidate paired against.
|
|
@@ -59,7 +61,14 @@ const Response = getEncoding('@cero/confirm')
|
|
|
59
61
|
*/
|
|
60
62
|
export class Pairing extends ReadyResource {
|
|
61
63
|
/** @param {PairingOpts} [opts] */
|
|
62
|
-
constructor({
|
|
64
|
+
constructor({
|
|
65
|
+
network,
|
|
66
|
+
identity,
|
|
67
|
+
topic,
|
|
68
|
+
inviteEncoding = null,
|
|
69
|
+
joinerEncoding = null,
|
|
70
|
+
onerror = safetyCatch
|
|
71
|
+
} = {}) {
|
|
63
72
|
super()
|
|
64
73
|
if (!network) throw CeroError.REQUIRED('network')
|
|
65
74
|
if (!identity) throw CeroError.REQUIRED('identity')
|
|
@@ -69,6 +78,7 @@ export class Pairing extends ReadyResource {
|
|
|
69
78
|
this.topic = topic || identity.publicKey
|
|
70
79
|
this.inviteEncoding = inviteEncoding
|
|
71
80
|
this.joinerEncoding = joinerEncoding
|
|
81
|
+
this._onerror = onerror
|
|
72
82
|
|
|
73
83
|
this._blind = null
|
|
74
84
|
this._member = null
|
|
@@ -83,7 +93,7 @@ export class Pairing extends ReadyResource {
|
|
|
83
93
|
|
|
84
94
|
this._member = this._blind.addMember({
|
|
85
95
|
discoveryKey: discoveryKey(this.topic),
|
|
86
|
-
onadd: (req) => this._onCandidate(req).catch(
|
|
96
|
+
onadd: (req) => this._onCandidate(req).catch(this._onerror)
|
|
87
97
|
})
|
|
88
98
|
}
|
|
89
99
|
|
|
@@ -112,7 +122,7 @@ export class Pairing extends ReadyResource {
|
|
|
112
122
|
*/
|
|
113
123
|
static inviteTopic(invite) {
|
|
114
124
|
try {
|
|
115
|
-
return Invite.parse(invite).
|
|
125
|
+
return Invite.parse(invite).discoveryKey
|
|
116
126
|
} catch {
|
|
117
127
|
return null
|
|
118
128
|
}
|
|
@@ -124,7 +134,7 @@ export class Pairing extends ReadyResource {
|
|
|
124
134
|
* @param {CreateInviteOpts} [opts]
|
|
125
135
|
* @returns {Promise<string>}
|
|
126
136
|
*/
|
|
127
|
-
async createInvite({ role = '', expiresIn = 0, data = null } = {}) {
|
|
137
|
+
async createInvite({ role = '', expiresIn = 0, data = null, multiUse = false } = {}) {
|
|
128
138
|
if (this.closing || this.closed) throw CeroError.CLOSED('Pairing')
|
|
129
139
|
if (!this.opened) await this.ready()
|
|
130
140
|
|
|
@@ -138,7 +148,7 @@ export class Pairing extends ReadyResource {
|
|
|
138
148
|
role,
|
|
139
149
|
expiresIn,
|
|
140
150
|
data,
|
|
141
|
-
|
|
151
|
+
blind: blind.invite,
|
|
142
152
|
encoding: this.inviteEncoding
|
|
143
153
|
})
|
|
144
154
|
|
|
@@ -147,7 +157,8 @@ export class Pairing extends ReadyResource {
|
|
|
147
157
|
id: blind.id,
|
|
148
158
|
seed: blind.seed,
|
|
149
159
|
publicKey: blind.publicKey,
|
|
150
|
-
invite
|
|
160
|
+
invite,
|
|
161
|
+
multiUse
|
|
151
162
|
})
|
|
152
163
|
|
|
153
164
|
return invite.toString()
|
|
@@ -202,8 +213,8 @@ export class Pairing extends ReadyResource {
|
|
|
202
213
|
|
|
203
214
|
if (invite.expired) throw CeroError.EXPIRED()
|
|
204
215
|
|
|
205
|
-
const
|
|
206
|
-
const candidate = new
|
|
216
|
+
const data = encodeUserData(userData, this.joinerEncoding)
|
|
217
|
+
const candidate = new Candidate(this, invite, data, timeout)
|
|
207
218
|
return candidate.start()
|
|
208
219
|
}
|
|
209
220
|
|
|
@@ -250,6 +261,10 @@ export class Pairing extends ReadyResource {
|
|
|
250
261
|
const id = b4a.toString(req.inviteId, 'hex')
|
|
251
262
|
const record = this._invites.get(id)
|
|
252
263
|
if (!record) return
|
|
264
|
+
if (record.invite.expired) {
|
|
265
|
+
this._invites.delete(id)
|
|
266
|
+
return
|
|
267
|
+
}
|
|
253
268
|
|
|
254
269
|
try {
|
|
255
270
|
req.open(record.publicKey)
|
|
@@ -260,29 +275,31 @@ export class Pairing extends ReadyResource {
|
|
|
260
275
|
|
|
261
276
|
const userData = decodeUserData(req.userData, this.joinerEncoding)
|
|
262
277
|
|
|
263
|
-
const
|
|
278
|
+
const request = new Request({
|
|
264
279
|
pairing: this,
|
|
265
280
|
req,
|
|
266
281
|
invite: record.invite,
|
|
267
282
|
seed: record.seed,
|
|
268
283
|
userData,
|
|
269
284
|
onsettle: () => {
|
|
270
|
-
this._invites.delete(id)
|
|
285
|
+
if (!record.multiUse) this._invites.delete(id)
|
|
271
286
|
}
|
|
272
287
|
})
|
|
273
288
|
|
|
274
|
-
this.emit('candidate',
|
|
289
|
+
this.emit('candidate', request)
|
|
275
290
|
}
|
|
276
291
|
}
|
|
277
292
|
|
|
278
293
|
/**
|
|
279
|
-
* Internal —
|
|
280
|
-
* `
|
|
294
|
+
* Internal — a pairing request from an incoming candidate, awaiting the host's
|
|
295
|
+
* accept/deny. Built in `_onCandidate` and emitted on the `'candidate'` event so
|
|
296
|
+
* the host can decide via `confirm` / `deny`. (The candidate is the joiner; this
|
|
297
|
+
* is the host's handle to answer it.)
|
|
281
298
|
*
|
|
282
299
|
* @property {boolean} _settled Whether confirm/deny has already run.
|
|
283
300
|
*/
|
|
284
|
-
class
|
|
285
|
-
/** @param {
|
|
301
|
+
class Request {
|
|
302
|
+
/** @param {RequestOpts} opts */
|
|
286
303
|
constructor({ pairing, req, invite, seed, userData, onsettle }) {
|
|
287
304
|
this.pairing = pairing
|
|
288
305
|
this._req = req
|
|
@@ -302,36 +319,15 @@ class Candidate {
|
|
|
302
319
|
* @returns {Promise<void>}
|
|
303
320
|
*/
|
|
304
321
|
async confirm({ key, encryptionKey = null, additional = null } = {}) {
|
|
322
|
+
if (this._settled) return
|
|
305
323
|
if (!key || key.length !== 32) throw CeroError.INVALID('key must be a 32-byte buffer')
|
|
306
|
-
if (encryptionKey
|
|
324
|
+
if (encryptionKey && encryptionKey.length !== 32) {
|
|
307
325
|
throw CeroError.INVALID('encryptionKey must be a 32-byte buffer')
|
|
308
326
|
}
|
|
309
|
-
if (
|
|
310
|
-
additional !== null &&
|
|
311
|
-
additional !== undefined &&
|
|
312
|
-
!b4a.isBuffer(additional) &&
|
|
313
|
-
!(additional instanceof Uint8Array)
|
|
314
|
-
) {
|
|
327
|
+
if (additional && !b4a.isBuffer(additional)) {
|
|
315
328
|
throw CeroError.INVALID('additional must be a buffer')
|
|
316
329
|
}
|
|
317
|
-
|
|
318
|
-
this._settled = true
|
|
319
|
-
this._onsettle()
|
|
320
|
-
|
|
321
|
-
const data = c.encode(Response, {
|
|
322
|
-
status: STATUS_OK,
|
|
323
|
-
reason: '',
|
|
324
|
-
key,
|
|
325
|
-
encryptionKey: encryptionKey || null,
|
|
326
|
-
extra: additional || null
|
|
327
|
-
})
|
|
328
|
-
const signature = sign(data, keyPair(this._seed).secretKey)
|
|
329
|
-
|
|
330
|
-
this._req.confirm({
|
|
331
|
-
key: this.pairing.topic,
|
|
332
|
-
encryptionKey: undefined,
|
|
333
|
-
additional: { data, signature }
|
|
334
|
-
})
|
|
330
|
+
this._respond({ status: STATUS_ACCEPTED, reason: '', key, encryptionKey, extra: additional })
|
|
335
331
|
}
|
|
336
332
|
|
|
337
333
|
/**
|
|
@@ -342,18 +338,22 @@ class Candidate {
|
|
|
342
338
|
*/
|
|
343
339
|
async deny(reason = '') {
|
|
344
340
|
if (this._settled) return
|
|
345
|
-
this.
|
|
346
|
-
this._onsettle()
|
|
347
|
-
|
|
348
|
-
const data = c.encode(Response, {
|
|
341
|
+
this._respond({
|
|
349
342
|
status: STATUS_DENIED,
|
|
350
|
-
reason
|
|
343
|
+
reason,
|
|
351
344
|
key: null,
|
|
352
345
|
encryptionKey: null,
|
|
353
346
|
extra: null
|
|
354
347
|
})
|
|
355
|
-
|
|
348
|
+
}
|
|
356
349
|
|
|
350
|
+
// sign the envelope with the per-invite seed and answer the candidate
|
|
351
|
+
// (callers guard idempotency before this runs)
|
|
352
|
+
_respond(envelope) {
|
|
353
|
+
this._settled = true
|
|
354
|
+
this._onsettle()
|
|
355
|
+
const data = c.encode(Response, envelope)
|
|
356
|
+
const signature = sign(data, keyPair(this._seed).secretKey)
|
|
357
357
|
this._req.confirm({
|
|
358
358
|
key: this.pairing.topic,
|
|
359
359
|
encryptionKey: undefined,
|
|
@@ -363,8 +363,9 @@ class Candidate {
|
|
|
363
363
|
}
|
|
364
364
|
|
|
365
365
|
/**
|
|
366
|
-
* Internal — joiner
|
|
367
|
-
* `Pairing.join()`, settled
|
|
366
|
+
* Internal — the joiner side: a candidate's handshake state machine (this *is* the
|
|
367
|
+
* blind-pairing `addCandidate`). One-shot: created by `Pairing.join()`, settled
|
|
368
|
+
* exactly once via `_done` or `_fail`.
|
|
368
369
|
*
|
|
369
370
|
* @property {any} _candidate blind-pairing addCandidate handle (null until started).
|
|
370
371
|
* @property {ReturnType<typeof setTimeout> | null} _timer Pending timeout, if any.
|
|
@@ -372,7 +373,7 @@ class Candidate {
|
|
|
372
373
|
* @property {((err: Error) => void) | null} _reject Promise rejecter, set in `start`.
|
|
373
374
|
* @property {boolean} _settled Whether the handshake has already settled.
|
|
374
375
|
*/
|
|
375
|
-
class
|
|
376
|
+
class Candidate {
|
|
376
377
|
/**
|
|
377
378
|
* @param {Pairing} pairing
|
|
378
379
|
* @param {Invite} invite
|
|
@@ -409,7 +410,7 @@ class JoinerCandidate {
|
|
|
409
410
|
|
|
410
411
|
try {
|
|
411
412
|
this._candidate = this.pairing._blind.addCandidate({
|
|
412
|
-
invite: this.invite.
|
|
413
|
+
invite: this.invite.blind,
|
|
413
414
|
userData: this.userData,
|
|
414
415
|
onadd: (result) => this._done(result)
|
|
415
416
|
})
|
|
@@ -466,10 +467,10 @@ class JoinerCandidate {
|
|
|
466
467
|
}
|
|
467
468
|
}
|
|
468
469
|
|
|
469
|
-
function encodeUserData(
|
|
470
|
-
if (
|
|
471
|
-
if (encoding) return c.encode(encoding,
|
|
472
|
-
if (b4a.isBuffer(
|
|
470
|
+
function encodeUserData(data, encoding) {
|
|
471
|
+
if (data == null) return null
|
|
472
|
+
if (encoding) return c.encode(encoding, data)
|
|
473
|
+
if (b4a.isBuffer(data)) return data
|
|
473
474
|
throw CeroError.INVALID('userData must be a buffer when no joinerEncoding is provided')
|
|
474
475
|
}
|
|
475
476
|
|
package/src/pairing/invite.js
CHANGED
|
@@ -17,7 +17,7 @@ const SignBody = getEncoding('@cero/invite-body')
|
|
|
17
17
|
* @property {string} role Optional role tag baked into the signed body.
|
|
18
18
|
* @property {number} expires Absolute expiry timestamp; `0` means never.
|
|
19
19
|
* @property {Uint8Array | null} data Encoded payload (raw or via `encoding`).
|
|
20
|
-
* @property {Uint8Array}
|
|
20
|
+
* @property {Uint8Array} blind Raw blind-pairing invite handed to candidates.
|
|
21
21
|
* @property {Uint8Array} [sig] Ed25519 signature over the canonical body.
|
|
22
22
|
* @property {string | null} [_str] Cached z32 string form.
|
|
23
23
|
*
|
|
@@ -27,7 +27,7 @@ const SignBody = getEncoding('@cero/invite-body')
|
|
|
27
27
|
* @property {string} role Optional role tag for the joiner.
|
|
28
28
|
* @property {number} expiresIn TTL in ms from now; `0` means never expires.
|
|
29
29
|
* @property {any} data Caller payload; encoded via `encoding` when provided.
|
|
30
|
-
* @property {Uint8Array}
|
|
30
|
+
* @property {Uint8Array} blind Raw blind-pairing invite to wrap.
|
|
31
31
|
* @property {any} [encoding] compact-encoding type used to encode `data`.
|
|
32
32
|
*
|
|
33
33
|
* @typedef {object} ParseInviteOpts
|
|
@@ -40,7 +40,7 @@ const SignBody = getEncoding('@cero/invite-body')
|
|
|
40
40
|
* authenticated by the joiner before any handshake happens.
|
|
41
41
|
*
|
|
42
42
|
* @property {any} [_rawData] Decoded payload kept alongside the encoded `data` for convenience.
|
|
43
|
-
* @property {
|
|
43
|
+
* @property {Uint8Array} _discoveryKey Cached discovery key of the wrapped blind invite.
|
|
44
44
|
*/
|
|
45
45
|
export class Invite {
|
|
46
46
|
/** @param {InviteFields} fields */
|
|
@@ -50,12 +50,12 @@ export class Invite {
|
|
|
50
50
|
this.role = fields.role
|
|
51
51
|
this.expires = fields.expires
|
|
52
52
|
this.data = fields.data
|
|
53
|
-
this.
|
|
53
|
+
this.blind = fields.blind
|
|
54
54
|
this.sig = fields.sig
|
|
55
55
|
this._str = fields._str || null
|
|
56
56
|
|
|
57
|
-
//
|
|
58
|
-
this.
|
|
57
|
+
// cached discovery key of the wrapped blind invite
|
|
58
|
+
this._discoveryKey = null
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/**
|
|
@@ -68,13 +68,15 @@ export class Invite {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
*
|
|
71
|
+
* Discovery key of the wrapped blind-pairing invite — the topic it targets.
|
|
72
72
|
*
|
|
73
|
-
* @returns {
|
|
73
|
+
* @returns {Uint8Array}
|
|
74
74
|
*/
|
|
75
|
-
get
|
|
76
|
-
if (!this.
|
|
77
|
-
|
|
75
|
+
get discoveryKey() {
|
|
76
|
+
if (!this._discoveryKey) {
|
|
77
|
+
this._discoveryKey = BlindPairing.decodeInvite(this.blind).discoveryKey
|
|
78
|
+
}
|
|
79
|
+
return this._discoveryKey
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
/**
|
|
@@ -105,18 +107,17 @@ export class Invite {
|
|
|
105
107
|
* @param {CreateInviteOpts} opts
|
|
106
108
|
* @returns {Invite}
|
|
107
109
|
*/
|
|
108
|
-
static create({ secretKey, publicKey, role, expiresIn, data,
|
|
110
|
+
static create({ secretKey, publicKey, role, expiresIn, data, blind, encoding }) {
|
|
109
111
|
if (!secretKey || secretKey.length !== 64)
|
|
110
112
|
throw CeroError.INVALID('secretKey must be a 64-byte buffer')
|
|
111
113
|
if (!publicKey || publicKey.length !== 32)
|
|
112
114
|
throw CeroError.INVALID('publicKey must be a 32-byte buffer')
|
|
113
|
-
if (!b4a.isBuffer(
|
|
114
|
-
throw CeroError.INVALID('
|
|
115
|
+
if (!b4a.isBuffer(blind)) {
|
|
116
|
+
throw CeroError.INVALID('blind invite must be a buffer')
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
const expires = expiresIn ? Date.now() + expiresIn : 0
|
|
118
|
-
const encoded =
|
|
119
|
-
data === undefined || data === null ? null : encoding ? c.encode(encoding, data) : data
|
|
120
|
+
const encoded = data == null ? null : encoding ? c.encode(encoding, data) : data
|
|
120
121
|
|
|
121
122
|
const fields = {
|
|
122
123
|
version: VERSION,
|
|
@@ -124,7 +125,7 @@ export class Invite {
|
|
|
124
125
|
role: role || '',
|
|
125
126
|
expires,
|
|
126
127
|
data: encoded,
|
|
127
|
-
|
|
128
|
+
blind
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
const body = c.encode(SignBody, fields)
|
|
@@ -190,7 +191,7 @@ export class Invite {
|
|
|
190
191
|
try {
|
|
191
192
|
const buf = z32.decode(str)
|
|
192
193
|
const fields = c.decode(Envelope, buf)
|
|
193
|
-
if (fields.
|
|
194
|
+
if (fields.blind) BlindPairing.decodeInvite(fields.blind)
|
|
194
195
|
return true
|
|
195
196
|
} catch {
|
|
196
197
|
return false
|
package/src/rpc/client.js
CHANGED
|
@@ -1,45 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
import safetyCatch from 'safety-catch'
|
|
3
|
-
import FramedStream from 'framed-stream'
|
|
4
|
-
|
|
5
|
-
import { bindCodec } from './index.js'
|
|
1
|
+
import { RPCPeer } from './peer.js'
|
|
6
2
|
|
|
7
3
|
/**
|
|
8
|
-
* Client-side RPC adapter
|
|
9
|
-
*
|
|
10
|
-
* to issue requests. Frames are paused until `_open` runs so handlers
|
|
11
|
-
* registered before `ready()` see a clean slate.
|
|
4
|
+
* Client-side RPC adapter — issues requests over the framed IPC stream.
|
|
5
|
+
* All wiring lives in {@link RPCPeer}; this is the client-named role.
|
|
12
6
|
*/
|
|
13
|
-
export class RPCClient extends
|
|
14
|
-
/**
|
|
15
|
-
* @param {any} ipc Duplex IPC stream (e.g. a socket or pipe).
|
|
16
|
-
* @param {import('./index.js').Spec} spec Spec object exposing `rpc` and `schema`.
|
|
17
|
-
*/
|
|
18
|
-
constructor(ipc, spec) {
|
|
19
|
-
super()
|
|
20
|
-
if (!ipc) throw new TypeError('ipc is required')
|
|
21
|
-
if (!spec) throw new TypeError('spec is required')
|
|
22
|
-
if (!spec.rpc) throw new TypeError('spec.rpc is required')
|
|
23
|
-
|
|
24
|
-
this.ipc = ipc
|
|
25
|
-
this.spec = spec
|
|
26
|
-
this.framed = new FramedStream(ipc)
|
|
27
|
-
this.framed.pause()
|
|
28
|
-
this.rpc = new spec.rpc(this.framed)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async _open() {
|
|
32
|
-
if (!this.spec.codec) bindCodec(this.spec)
|
|
33
|
-
this.framed.resume()
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async _close() {
|
|
37
|
-
if (this.framed && !this.framed.destroyed) {
|
|
38
|
-
try {
|
|
39
|
-
this.framed.destroy()
|
|
40
|
-
} catch (err) {
|
|
41
|
-
safetyCatch(err)
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
7
|
+
export class RPCClient extends RPCPeer {}
|
package/src/rpc/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import b4a from 'b4a'
|
|
|
2
2
|
import c from 'compact-encoding'
|
|
3
3
|
|
|
4
4
|
import { getEncoding } from '../lib/spec/index.js'
|
|
5
|
+
import { CeroError } from '../lib/errors.js'
|
|
5
6
|
|
|
6
7
|
export { RPCServer } from './server.js'
|
|
7
8
|
export { RPCClient } from './client.js'
|
|
@@ -44,9 +45,9 @@ const DEFAULT_CREATE = getEncoding('@cero/create')
|
|
|
44
45
|
* @returns {Spec}
|
|
45
46
|
*/
|
|
46
47
|
export function bindCodec(spec) {
|
|
47
|
-
if (!spec) throw
|
|
48
|
+
if (!spec) throw CeroError.REQUIRED('spec')
|
|
48
49
|
const schema = spec.schema
|
|
49
|
-
if (!schema) throw
|
|
50
|
+
if (!schema) throw CeroError.REQUIRED('spec.schema')
|
|
50
51
|
|
|
51
52
|
const ns = spec.meta?.ns
|
|
52
53
|
const ROWS = ns ? `@${ns}/rows` : null
|
|
@@ -83,8 +84,11 @@ export function bindCodec(spec) {
|
|
|
83
84
|
if (!buf || buf.length === 0) return undefined
|
|
84
85
|
const env = decodeEnvelope(schema, QUERY, DEFAULT_QUERY, buf)
|
|
85
86
|
const out = {}
|
|
86
|
-
// Hyperschema fills optional uint/bool with 0/false defaults
|
|
87
|
-
//
|
|
87
|
+
// Hyperschema fills optional uint/bool with 0/false defaults, so we can only
|
|
88
|
+
// recover fields the encoder actually wrote (truthy). KNOWN LIMITATION: a
|
|
89
|
+
// `limit: 0` query (degenerate — "return nothing") is indistinguishable from
|
|
90
|
+
// "no limit" over the wire and is treated as unset. Falsy cursors (gt: '')
|
|
91
|
+
// are harmless — an empty-string bound matches everything anyway.
|
|
88
92
|
if (env.gt) out.gt = env.gt
|
|
89
93
|
if (env.gte) out.gte = env.gte
|
|
90
94
|
if (env.lt) out.lt = env.lt
|
package/src/rpc/peer.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import ReadyResource from 'ready-resource'
|
|
2
|
+
import safetyCatch from 'safety-catch'
|
|
3
|
+
import FramedStream from 'framed-stream'
|
|
4
|
+
|
|
5
|
+
import { bindCodec } from './index.js'
|
|
6
|
+
import { CeroError } from '../lib/errors.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Shared RPC peer: wraps an IPC duplex in a length-framed stream and constructs
|
|
10
|
+
* the spec's hrpc binding. Frames are paused until `_open` runs so handlers
|
|
11
|
+
* registered before `ready()` see a clean slate. `RPCClient` and `RPCServer`
|
|
12
|
+
* are thin named subclasses — identical wiring, distinct roles.
|
|
13
|
+
*/
|
|
14
|
+
export class RPCPeer extends ReadyResource {
|
|
15
|
+
/**
|
|
16
|
+
* @param {any} ipc Duplex IPC stream (e.g. a socket or pipe).
|
|
17
|
+
* @param {import('./index.js').Spec} spec Spec object exposing `rpc` and `schema`.
|
|
18
|
+
*/
|
|
19
|
+
constructor(ipc, spec) {
|
|
20
|
+
super()
|
|
21
|
+
if (!ipc) throw CeroError.REQUIRED('ipc')
|
|
22
|
+
if (!spec) throw CeroError.REQUIRED('spec')
|
|
23
|
+
if (!spec.rpc) throw CeroError.REQUIRED('spec.rpc')
|
|
24
|
+
|
|
25
|
+
this.ipc = ipc
|
|
26
|
+
this.spec = spec
|
|
27
|
+
this.framed = new FramedStream(ipc)
|
|
28
|
+
this.framed.pause()
|
|
29
|
+
this.rpc = new spec.rpc(this.framed)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async _open() {
|
|
33
|
+
if (!this.spec.codec) bindCodec(this.spec)
|
|
34
|
+
this.framed.resume()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async _close() {
|
|
38
|
+
if (this.framed && !this.framed.destroyed) {
|
|
39
|
+
try {
|
|
40
|
+
this.framed.destroy()
|
|
41
|
+
} catch (err) {
|
|
42
|
+
safetyCatch(err)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/rpc/server.js
CHANGED
|
@@ -1,45 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
import safetyCatch from 'safety-catch'
|
|
3
|
-
import FramedStream from 'framed-stream'
|
|
4
|
-
|
|
5
|
-
import { bindCodec } from './index.js'
|
|
1
|
+
import { RPCPeer } from './peer.js'
|
|
6
2
|
|
|
7
3
|
/**
|
|
8
|
-
* Server-side RPC adapter
|
|
9
|
-
*
|
|
10
|
-
* envelope encoders. Reads are paused until `_open` runs so the
|
|
11
|
-
* application has a chance to register handlers before frames flow.
|
|
4
|
+
* Server-side RPC adapter — serves the spec's handlers over the framed IPC
|
|
5
|
+
* stream. All wiring lives in {@link RPCPeer}; this is the server-named role.
|
|
12
6
|
*/
|
|
13
|
-
export class RPCServer extends
|
|
14
|
-
/**
|
|
15
|
-
* @param {any} ipc Duplex IPC stream (e.g. a socket or pipe).
|
|
16
|
-
* @param {import('./index.js').Spec} spec Spec object exposing `rpc` and `schema`.
|
|
17
|
-
*/
|
|
18
|
-
constructor(ipc, spec) {
|
|
19
|
-
super()
|
|
20
|
-
if (!ipc) throw new TypeError('ipc is required')
|
|
21
|
-
if (!spec) throw new TypeError('spec is required')
|
|
22
|
-
if (!spec.rpc) throw new TypeError('spec.rpc is required')
|
|
23
|
-
|
|
24
|
-
this.ipc = ipc
|
|
25
|
-
this.spec = spec
|
|
26
|
-
this.framed = new FramedStream(ipc)
|
|
27
|
-
this.framed.pause()
|
|
28
|
-
this.rpc = new spec.rpc(this.framed)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async _open() {
|
|
32
|
-
if (!this.spec.codec) bindCodec(this.spec)
|
|
33
|
-
this.framed.resume()
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async _close() {
|
|
37
|
-
if (this.framed && !this.framed.destroyed) {
|
|
38
|
-
try {
|
|
39
|
-
this.framed.destroy()
|
|
40
|
-
} catch (err) {
|
|
41
|
-
safetyCatch(err)
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
7
|
+
export class RPCServer extends RPCPeer {}
|