@cero-base/tools 1.1.1 → 1.3.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 +17 -7
- package/bin/cero-tools.js +7 -4
- package/package.json +22 -7
- package/src/connect.js +11 -6
- package/src/server.js +7 -0
- package/src/tap.js +10 -3
- package/src/transport.js +5 -4
- package/types/connect.d.ts +4 -2
- package/types/server.d.ts +1 -0
- package/types/tap.d.ts +2 -1
- package/types/transport.d.ts +2 -1
- package/src/CLAUDE.md +0 -3
package/README.md
CHANGED
|
@@ -22,15 +22,21 @@ cero.use(devtools({ transport: loopback({ port: 9111 }) }))
|
|
|
22
22
|
|
|
23
23
|
`devtools(opts)`:
|
|
24
24
|
|
|
25
|
-
| opt | default | meaning
|
|
26
|
-
| ---------------- | ------- |
|
|
27
|
-
| `transport` | — | a `{ accept(handler) }` source of consumer connections (use `loopback({ port })`)
|
|
28
|
-
| `bufferSize` | `1000` | event ring buffer size (drop-oldest)
|
|
29
|
-
| `sampleInterval` | `1000` | stats sampling interval (ms)
|
|
30
|
-
| `redact` | — | a `(ref, row) => row` masker, or a config `{ fields, match, deny }` (see Redaction)
|
|
25
|
+
| opt | default | meaning |
|
|
26
|
+
| ---------------- | ------- | ------------------------------------------------------------------------------------ |
|
|
27
|
+
| `transport` | — | a `{ accept(handler) }` source of consumer connections (use `loopback({ port })`) |
|
|
28
|
+
| `bufferSize` | `1000` | event ring buffer size (drop-oldest) |
|
|
29
|
+
| `sampleInterval` | `1000` | stats sampling interval (ms) |
|
|
30
|
+
| `redact` | — | a `(ref, row) => row` masker, or a config `{ fields, match, deny }` (see Redaction) |
|
|
31
|
+
| `token` | random | auth token gating the tap — printed in the startup banner; `false` disables the gate |
|
|
31
32
|
|
|
32
33
|
Zero footprint when not registered; all cleanup is bound to the root handle's `close`.
|
|
33
34
|
|
|
35
|
+
Any local process can dial the loopback port, so the tap requires a per-run token: the first
|
|
36
|
+
frame of a connection must carry it or the connection is dropped. The banner prints the exact
|
|
37
|
+
`cero-tools <port> --token <token>` command. Keep devtools out of production builds regardless —
|
|
38
|
+
the tap is a development tool.
|
|
39
|
+
|
|
34
40
|
## Consumer side — connect
|
|
35
41
|
|
|
36
42
|
```js
|
|
@@ -50,7 +56,7 @@ session.close()
|
|
|
50
56
|
## CLI
|
|
51
57
|
|
|
52
58
|
```sh
|
|
53
|
-
cero-tools <port> [refs...]
|
|
59
|
+
cero-tools <port> --token <token> [refs...]
|
|
54
60
|
# prints the handle tree + state for each ref, then tails events + stats until Ctrl-C
|
|
55
61
|
```
|
|
56
62
|
|
|
@@ -64,6 +70,10 @@ devtools({ redact: { fields: ['profile.apiKey'] } })
|
|
|
64
70
|
devtools({ redact: { match: (key) => key.endsWith('Secret') } })
|
|
65
71
|
```
|
|
66
72
|
|
|
73
|
+
The default is a **denylist by field name** — a secret stored under an off-list name (`otp`,
|
|
74
|
+
`recoveryCode`, …) transits unmasked. Add such fields via `fields`/`match`, or pass your own
|
|
75
|
+
`(ref, row) => row` masker for allowlist semantics.
|
|
76
|
+
|
|
67
77
|
Identity credentials (the seed and keypairs) need no redaction: they're `local` builtins and the inspection surface only serves declared `meta.refs`, so they're never reachable.
|
|
68
78
|
|
|
69
79
|
## API
|
package/bin/cero-tools.js
CHANGED
|
@@ -3,9 +3,12 @@ import { dial } from '../src/transport.js'
|
|
|
3
3
|
import { connect } from '../src/connect.js'
|
|
4
4
|
import { formatHandles, formatState, formatError, formatEvent, formatStats } from '../src/format.js'
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const argv = process.argv.slice(2)
|
|
7
|
+
const ti = argv.indexOf('--token')
|
|
8
|
+
const token = ti >= 0 ? argv.splice(ti, 2)[1] : undefined
|
|
9
|
+
const [arg, ...refs] = argv
|
|
7
10
|
if (!arg) {
|
|
8
|
-
console.log('usage: cero-tools [host:]<port> [refs...]')
|
|
11
|
+
console.log('usage: cero-tools [host:]<port> [--token <token>] [refs...]')
|
|
9
12
|
process.exit(1)
|
|
10
13
|
}
|
|
11
14
|
|
|
@@ -28,11 +31,11 @@ socket.on('error', (err) => {
|
|
|
28
31
|
process.exit(1)
|
|
29
32
|
})
|
|
30
33
|
|
|
31
|
-
const session = await connect(socket)
|
|
34
|
+
const session = await connect(socket, { token })
|
|
32
35
|
|
|
33
36
|
console.log(formatHandles(await session.handles()))
|
|
34
37
|
|
|
35
|
-
for (const ref of
|
|
38
|
+
for (const ref of refs) {
|
|
36
39
|
try {
|
|
37
40
|
console.log(formatState(ref, await session.get(ref)))
|
|
38
41
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cero-base/tools",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Passive devtools for cero — observe data, events, and p2p/replication stats over a local out-of-band pipe.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
"net": {
|
|
22
22
|
"bare": "bare-net",
|
|
23
23
|
"default": "net"
|
|
24
|
+
},
|
|
25
|
+
"crypto": {
|
|
26
|
+
"bare": "bare-crypto",
|
|
27
|
+
"default": "crypto"
|
|
24
28
|
}
|
|
25
29
|
},
|
|
26
30
|
"files": [
|
|
@@ -28,7 +32,8 @@
|
|
|
28
32
|
"types",
|
|
29
33
|
"bin",
|
|
30
34
|
"README.md",
|
|
31
|
-
"LICENSE"
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"!**/CLAUDE.md"
|
|
32
37
|
],
|
|
33
38
|
"publishConfig": {
|
|
34
39
|
"access": "public"
|
|
@@ -50,14 +55,14 @@
|
|
|
50
55
|
"scripts": {
|
|
51
56
|
"build:types": "rm -rf types && tsc -p .",
|
|
52
57
|
"prepublishOnly": "npm run build:types",
|
|
53
|
-
"test": "ls test/*.test.js | xargs -P1 -n1 brittle-node"
|
|
58
|
+
"test": "ls test/*.test.js | xargs -P1 -n1 brittle-node",
|
|
59
|
+
"test:bare": "npx bare test/bare-smoke.js"
|
|
54
60
|
},
|
|
55
61
|
"dependencies": {
|
|
56
|
-
"@cero-base/cero": "^1.
|
|
57
|
-
"@cero-base/core": "^1.1.1",
|
|
62
|
+
"@cero-base/cero": "^1.3.0",
|
|
58
63
|
"b4a": "^1.8.1",
|
|
64
|
+
"bare-crypto": "^1.15.3",
|
|
59
65
|
"bare-net": "^2.3.2",
|
|
60
|
-
"compact-encoding": "^3.2.0",
|
|
61
66
|
"streamx": "^2.28.0"
|
|
62
67
|
},
|
|
63
68
|
"devDependencies": {
|
|
@@ -65,5 +70,15 @@
|
|
|
65
70
|
"brittle": "^4.0.2",
|
|
66
71
|
"typescript": "^5.9.3"
|
|
67
72
|
},
|
|
68
|
-
"license": "Apache-2.0"
|
|
73
|
+
"license": "Apache-2.0",
|
|
74
|
+
"typesVersions": {
|
|
75
|
+
"*": {
|
|
76
|
+
"connect": [
|
|
77
|
+
"./types/connect.d.ts"
|
|
78
|
+
],
|
|
79
|
+
"stats": [
|
|
80
|
+
"./types/stats.d.ts"
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
}
|
|
69
84
|
}
|
package/src/connect.js
CHANGED
|
@@ -19,13 +19,14 @@ import { Framed } from './protocol.js'
|
|
|
19
19
|
* @param {object} stream A streamx Duplex connected to a tap server.
|
|
20
20
|
* @returns {Promise<{ handles(): Promise<Handle[]>, get(ref: string, query?: Query, handleId?: string): Promise<unknown>, count(ref: string, query?: Query, handleId?: string): Promise<number>, watch(ref: string, query?: Query, handleId?: string): import('streamx').Readable, events(): import('streamx').Readable, stats(): import('streamx').Readable, close(): void }>}
|
|
21
21
|
*/
|
|
22
|
-
export async function connect(stream) {
|
|
23
|
-
return new Session(stream)
|
|
22
|
+
export async function connect(stream, { token } = {}) {
|
|
23
|
+
return new Session(stream, { token })
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export class Session {
|
|
27
|
-
constructor(stream) {
|
|
27
|
+
constructor(stream, { token } = {}) {
|
|
28
28
|
this.stream = stream
|
|
29
|
+
this.token = token || null
|
|
29
30
|
this.pending = new Map()
|
|
30
31
|
this.streams = new Map()
|
|
31
32
|
this.seq = 0
|
|
@@ -67,11 +68,15 @@ export class Session {
|
|
|
67
68
|
return ++this.seq
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
_send(frame) {
|
|
72
|
+
this.wire.send(this.token ? { token: this.token, ...frame } : frame)
|
|
73
|
+
}
|
|
74
|
+
|
|
70
75
|
_request(method, fields) {
|
|
71
76
|
return new Promise((resolve, reject) => {
|
|
72
77
|
const id = this._newId()
|
|
73
78
|
this.pending.set(id, { resolve, reject })
|
|
74
|
-
this.
|
|
79
|
+
this._send({ id, method, ...fields })
|
|
75
80
|
})
|
|
76
81
|
}
|
|
77
82
|
|
|
@@ -80,12 +85,12 @@ export class Session {
|
|
|
80
85
|
const r = new Readable({
|
|
81
86
|
destroy: (cb) => {
|
|
82
87
|
if (this.streams.delete(id))
|
|
83
|
-
this.
|
|
88
|
+
this._send({ id: this._newId(), method: 'cancel', cancelId: id })
|
|
84
89
|
cb(null)
|
|
85
90
|
}
|
|
86
91
|
})
|
|
87
92
|
this.streams.set(id, r)
|
|
88
|
-
this.
|
|
93
|
+
this._send({ id, method, ...fields })
|
|
89
94
|
return r
|
|
90
95
|
}
|
|
91
96
|
|
package/src/server.js
CHANGED
|
@@ -30,6 +30,7 @@ export class TapServer {
|
|
|
30
30
|
this.redact = opts.redact || ((ref, row) => row)
|
|
31
31
|
this.opts = opts
|
|
32
32
|
this.tracked = new Map()
|
|
33
|
+
this._authed = !opts.token
|
|
33
34
|
this.wire = new Framed(stream, (req) => this.onRequest(req))
|
|
34
35
|
this._cleanup = () => this._teardown()
|
|
35
36
|
stream.on('close', this._cleanup)
|
|
@@ -45,6 +46,12 @@ export class TapServer {
|
|
|
45
46
|
async onRequest(req) {
|
|
46
47
|
// a malformed frame (JSON null / scalar) has no method — ignore it
|
|
47
48
|
if (!req || typeof req !== 'object') return
|
|
49
|
+
// token gate: the first frame must carry the tap token — one shot, a
|
|
50
|
+
// mismatch drops the connection (no probing across attempts)
|
|
51
|
+
if (!this._authed) {
|
|
52
|
+
if (req.token !== this.opts.token) return this.stream.destroy()
|
|
53
|
+
this._authed = true
|
|
54
|
+
}
|
|
48
55
|
const handler = this[req.method]
|
|
49
56
|
if (!handler || !METHODS.has(req.method)) {
|
|
50
57
|
this._send({
|
package/src/tap.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import crypto from 'crypto'
|
|
2
|
+
|
|
1
3
|
import { stats } from './stats.js'
|
|
2
4
|
import { serve } from './server.js'
|
|
3
5
|
import { redact } from './redact.js'
|
|
@@ -25,7 +27,7 @@ import { loopback } from './transport.js'
|
|
|
25
27
|
* consumers. By default it serves a loopback TCP server on `port` (9111); pass
|
|
26
28
|
* a custom `transport` to serve it some other way. Nothing touches the app swarm.
|
|
27
29
|
*
|
|
28
|
-
* @param {{ port?: number, host?: string, bufferSize?: number, sampleInterval?: number, redact?: ((ref: string, row: Record<string, unknown>) => Record<string, unknown>) | { fields?: string[], match?: (key: string, value: unknown, path: string) => boolean, deny?: RegExp | false }, transport?: { accept(handler: (stream: object) => void): void } }} [opts]
|
|
30
|
+
* @param {{ port?: number, host?: string, token?: string | false, bufferSize?: number, sampleInterval?: number, redact?: ((ref: string, row: Record<string, unknown>) => Record<string, unknown>) | { fields?: string[], match?: (key: string, value: unknown, path: string) => boolean, deny?: RegExp | false }, transport?: { accept(handler: (stream: object) => void): void } }} [opts]
|
|
29
31
|
* @returns {{ setup(me: object): () => void }}
|
|
30
32
|
*/
|
|
31
33
|
export function devtools(opts = {}) {
|
|
@@ -34,7 +36,12 @@ export function devtools(opts = {}) {
|
|
|
34
36
|
const events = new Ring(opts.bufferSize ?? 1000)
|
|
35
37
|
const sampler = new Sampler(me, opts.sampleInterval ?? 1000)
|
|
36
38
|
const redactor = makeRedactor(opts.redact)
|
|
37
|
-
|
|
39
|
+
// any local process can dial the loopback port — a per-run token gates the
|
|
40
|
+
// tap so co-tenant processes can't read app data. `token: false` opts out.
|
|
41
|
+
const token =
|
|
42
|
+
opts.token === false ? null : opts.token || crypto.randomBytes(16).toString('hex')
|
|
43
|
+
const transport =
|
|
44
|
+
opts.transport || loopback({ port: opts.port ?? 9111, host: opts.host, token })
|
|
38
45
|
const offs = []
|
|
39
46
|
const servers = new Set()
|
|
40
47
|
|
|
@@ -51,7 +58,7 @@ export function devtools(opts = {}) {
|
|
|
51
58
|
me.on('handle', (child) => follow(child), { signal: me.signal })
|
|
52
59
|
|
|
53
60
|
transport.accept((stream) => {
|
|
54
|
-
const server = serve(stream, me, { events, stats: sampler, redact: redactor })
|
|
61
|
+
const server = serve(stream, me, { events, stats: sampler, redact: redactor, token })
|
|
55
62
|
servers.add(server)
|
|
56
63
|
stream.on('close', () => servers.delete(server))
|
|
57
64
|
})
|
package/src/transport.js
CHANGED
|
@@ -5,7 +5,7 @@ import net from 'net'
|
|
|
5
5
|
* plus lifecycle. Each inbound connection's socket is a streamx-compatible
|
|
6
6
|
* Duplex, ready to pass straight to `serve()`.
|
|
7
7
|
*
|
|
8
|
-
* @param {{ port?: number, host?: string }} [opts]
|
|
8
|
+
* @param {{ port?: number, host?: string, token?: string | null }} [opts]
|
|
9
9
|
* @returns {{ accept(handler: (socket: any) => void): void, ready(): Promise<{ port: number }>, port: number | null, close(): void }}
|
|
10
10
|
*/
|
|
11
11
|
export function loopback(opts = {}) {
|
|
@@ -34,7 +34,7 @@ export function loopback(opts = {}) {
|
|
|
34
34
|
})
|
|
35
35
|
})
|
|
36
36
|
ready.catch(() => {}) // ready() may never be called — don't go unhandled
|
|
37
|
-
server.on('listening', () => console.log(banner(host, server.address().port)))
|
|
37
|
+
server.on('listening', () => console.log(banner(host, server.address().port, opts.token)))
|
|
38
38
|
server.listen(port, host)
|
|
39
39
|
},
|
|
40
40
|
ready() {
|
|
@@ -62,16 +62,17 @@ export function dial(opts = {}) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
// VITE-style startup banner printed when the tap starts listening.
|
|
65
|
-
function banner(host, port) {
|
|
65
|
+
function banner(host, port, token) {
|
|
66
66
|
const e = '\x1b['
|
|
67
67
|
const r = `${e}0m`
|
|
68
68
|
const dim = `${e}2m`
|
|
69
69
|
const bold = `${e}1m`
|
|
70
70
|
const cyan = `${e}36m`
|
|
71
71
|
const green = `${e}32m`
|
|
72
|
+
const cmd = token ? `cero-tools ${port} --token ${token}` : `cero-tools ${port}`
|
|
72
73
|
return (
|
|
73
74
|
`\n ${bold}${cyan}cero devtools${r} ${dim}tap ready${r}\n\n` +
|
|
74
75
|
` ${green}➜${r} ${bold}Listening${r}: ${cyan}${host}:${port}${r}\n` +
|
|
75
|
-
` ${green}➜${r} ${bold}Connect${r}: ${dim}
|
|
76
|
+
` ${green}➜${r} ${bold}Connect${r}: ${dim}${cmd}${r}\n`
|
|
76
77
|
)
|
|
77
78
|
}
|
package/types/connect.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* @param {object} stream A streamx Duplex connected to a tap server.
|
|
14
14
|
* @returns {Promise<{ handles(): Promise<Handle[]>, get(ref: string, query?: Query, handleId?: string): Promise<unknown>, count(ref: string, query?: Query, handleId?: string): Promise<number>, watch(ref: string, query?: Query, handleId?: string): import('streamx').Readable, events(): import('streamx').Readable, stats(): import('streamx').Readable, close(): void }>}
|
|
15
15
|
*/
|
|
16
|
-
export function connect(stream: object): Promise<{
|
|
16
|
+
export function connect(stream: object, { token }?: {}): Promise<{
|
|
17
17
|
handles(): Promise<Handle[]>;
|
|
18
18
|
get(ref: string, query?: Query, handleId?: string): Promise<unknown>;
|
|
19
19
|
count(ref: string, query?: Query, handleId?: string): Promise<number>;
|
|
@@ -23,8 +23,9 @@ export function connect(stream: object): Promise<{
|
|
|
23
23
|
close(): void;
|
|
24
24
|
}>;
|
|
25
25
|
export class Session {
|
|
26
|
-
constructor(stream: any);
|
|
26
|
+
constructor(stream: any, { token }?: {});
|
|
27
27
|
stream: any;
|
|
28
|
+
token: any;
|
|
28
29
|
pending: Map<any, any>;
|
|
29
30
|
streams: Map<any, any>;
|
|
30
31
|
seq: number;
|
|
@@ -32,6 +33,7 @@ export class Session {
|
|
|
32
33
|
_onClose(): void;
|
|
33
34
|
_onMessage(msg: any): void;
|
|
34
35
|
_newId(): number;
|
|
36
|
+
_send(frame: any): void;
|
|
35
37
|
_request(method: any, fields: any): Promise<any>;
|
|
36
38
|
_stream(method: any, fields: any): Readable<import("streamx").ReadableEvents>;
|
|
37
39
|
/**
|
package/types/server.d.ts
CHANGED
package/types/tap.d.ts
CHANGED
|
@@ -18,12 +18,13 @@
|
|
|
18
18
|
* consumers. By default it serves a loopback TCP server on `port` (9111); pass
|
|
19
19
|
* a custom `transport` to serve it some other way. Nothing touches the app swarm.
|
|
20
20
|
*
|
|
21
|
-
* @param {{ port?: number, host?: string, bufferSize?: number, sampleInterval?: number, redact?: ((ref: string, row: Record<string, unknown>) => Record<string, unknown>) | { fields?: string[], match?: (key: string, value: unknown, path: string) => boolean, deny?: RegExp | false }, transport?: { accept(handler: (stream: object) => void): void } }} [opts]
|
|
21
|
+
* @param {{ port?: number, host?: string, token?: string | false, bufferSize?: number, sampleInterval?: number, redact?: ((ref: string, row: Record<string, unknown>) => Record<string, unknown>) | { fields?: string[], match?: (key: string, value: unknown, path: string) => boolean, deny?: RegExp | false }, transport?: { accept(handler: (stream: object) => void): void } }} [opts]
|
|
22
22
|
* @returns {{ setup(me: object): () => void }}
|
|
23
23
|
*/
|
|
24
24
|
export function devtools(opts?: {
|
|
25
25
|
port?: number;
|
|
26
26
|
host?: string;
|
|
27
|
+
token?: string | false;
|
|
27
28
|
bufferSize?: number;
|
|
28
29
|
sampleInterval?: number;
|
|
29
30
|
redact?: ((ref: string, row: Record<string, unknown>) => Record<string, unknown>) | {
|
package/types/transport.d.ts
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
* plus lifecycle. Each inbound connection's socket is a streamx-compatible
|
|
4
4
|
* Duplex, ready to pass straight to `serve()`.
|
|
5
5
|
*
|
|
6
|
-
* @param {{ port?: number, host?: string }} [opts]
|
|
6
|
+
* @param {{ port?: number, host?: string, token?: string | null }} [opts]
|
|
7
7
|
* @returns {{ accept(handler: (socket: any) => void): void, ready(): Promise<{ port: number }>, port: number | null, close(): void }}
|
|
8
8
|
*/
|
|
9
9
|
export function loopback(opts?: {
|
|
10
10
|
port?: number;
|
|
11
11
|
host?: string;
|
|
12
|
+
token?: string | null;
|
|
12
13
|
}): {
|
|
13
14
|
accept(handler: (socket: any) => void): void;
|
|
14
15
|
ready(): Promise<{
|
package/src/CLAUDE.md
DELETED