@tikoci/centrs 0.1.1 → 0.1.2

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.
@@ -0,0 +1,325 @@
1
+ # api — examples
2
+
3
+ Each numbered example is an executable spec, asserted by CHR integration tests
4
+ (booted by `@tikoci/quickchr`): `test/integration/api.test.ts` (rest-api
5
+ examples), `test/integration/api-native.test.ts` (native-api examples, N…), and
6
+ `test/integration/api-listen.test.ts` (streaming examples, L…), with example N ↔
7
+ assertion N. If a line here is not exercised by a test, the test file is wrong; if
8
+ a line passes only with `--validate=false`, the implementation is wrong (see
9
+ [`docs/CONSTITUTION.md`](../../docs/CONSTITUTION.md)).
10
+
11
+ `$R` is `<host>:<rest-port>` resolved by quickchr. `$A` is `<host>` and
12
+ `$API_PORT` is the native API port (`chr.ports.api`). `$U` / `$P` are CHR
13
+ credentials provided by the harness. `$ID` is the `.id` returned by the preceding
14
+ add in the same transport section. All write-shaped examples pass `--yes`.
15
+
16
+ ## rest-api (`--via rest-api`, the default)
17
+
18
+ ### 1. GET a list
19
+
20
+ ```bash
21
+ centrs api $R ip/address --username $U --password $P
22
+ ```
23
+
24
+ Envelope: `ok: true`, `data` is an array of rest-style records,
25
+ `meta.via=rest-api`, `meta.validation.source` includes `/console/inspect request=child`.
26
+
27
+ ### 2. Endpoint normalization variants resolve to one path
28
+
29
+ ```bash
30
+ centrs api $R "/rest/ip/address" --username $U --password $P
31
+ centrs api $R "rest/ip/address" --username $U --password $P
32
+ centrs api $R "ip address" --username $U --password $P
33
+ ```
34
+
35
+ Each resolves to the same canonical path; `meta.request.path == "/ip/address"`
36
+ and the `data` array matches example 1.
37
+
38
+ ### 3. GET a singleton
39
+
40
+ ```bash
41
+ centrs api $R system/resource --username $U --password $P
42
+ ```
43
+
44
+ Envelope: `ok: true`, `data` is a single object (not an array) with `uptime`,
45
+ `version`, etc.
46
+
47
+ ### 4. PUT add (RouterOS create; documents the PUT≠POST trap)
48
+
49
+ ```bash
50
+ centrs api $R ip/address -X PUT -f address=198.51.100.10/32 -f interface=ether1 -f comment=centrs-api-rest --username $U --password $P --yes
51
+ ```
52
+
53
+ Envelope: `ok: true`, `data[".id"]` matches `/^\*[0-9A-F]+$/`, `meta.via=rest-api`.
54
+ A subsequent GET of `/ip/address` contains the new row.
55
+
56
+ ### 5. PATCH set by id-in-path
57
+
58
+ ```bash
59
+ centrs api $R ip/address/$ID -X PATCH -f comment=centrs-api-rest-set --username $U --password $P --yes
60
+ ```
61
+
62
+ Envelope: `ok: true`; re-reading `ip/address/$ID` shows the new comment.
63
+
64
+ ### 6. DELETE remove by id-in-path
65
+
66
+ ```bash
67
+ centrs api $R ip/address/$ID -X DELETE --username $U --password $P --yes
68
+ ```
69
+
70
+ Envelope: `ok: true`; a subsequent GET of `/ip/address` does not contain `$ID`.
71
+
72
+ ### 7. Raw JSON body with `-d`
73
+
74
+ ```bash
75
+ centrs api $R ip/address -X PUT -d '{"address":"198.51.100.11/32","interface":"ether1"}' --username $U --password $P --yes
76
+ ```
77
+
78
+ Envelope: `ok: true`, `data[".id"]` present.
79
+
80
+ ### 8. Body from stdin with `--input -`
81
+
82
+ ```bash
83
+ echo '{"address":"198.51.100.12/32","interface":"ether1"}' | centrs api $R ip/address -X PUT --input - --username $U --password $P --yes
84
+ ```
85
+
86
+ Envelope: `ok: true`, `data[".id"]` present.
87
+
88
+ ### 9. Server-side filter with `--query`
89
+
90
+ ```bash
91
+ centrs api $R interface --query type=ether --username $U --password $P
92
+ ```
93
+
94
+ Envelope: `ok: true`, every returned record has `type=ether`; the request carried
95
+ a REST `.query` (not client-side filtering).
96
+
97
+ ### 10. Projection with `--proplist`
98
+
99
+ ```bash
100
+ centrs api $R ip/address --proplist address,interface --username $U --password $P
101
+ ```
102
+
103
+ Envelope: `ok: true`, each record has only the requested properties (plus `.id`).
104
+
105
+ ### 11. Validation rejects an unknown attribute before the write
106
+
107
+ ```bash
108
+ centrs api $R ip/address -X PUT -f address=198.51.100.13/32 -f interface=ether1 -f no-such-arg=x --username $U --password $P --yes
109
+ ```
110
+
111
+ Envelope: `ok: false`, `error.code=validation/unknown-attribute`,
112
+ `meta.validation.source=/console/inspect`, and no address is added.
113
+
114
+ ### 12. Validation rejects an unknown path
115
+
116
+ ```bash
117
+ centrs api $R ip/no-such-menu --username $U --password $P
118
+ ```
119
+
120
+ Envelope: `ok: false`, `error.code=validation/unknown-path`, no request issued.
121
+
122
+ ### 13. `--raw` success prints the bare RouterOS body
123
+
124
+ ```bash
125
+ centrs api $R ip/address --raw --username $U --password $P
126
+ ```
127
+
128
+ stdout is the bare RouterOS JSON array (no `meta`/`warnings`); validation is
129
+ skipped; exit code 0.
130
+
131
+ ### 14. `--raw` RouterOS error → stderr + nonzero exit
132
+
133
+ ```bash
134
+ centrs api $R ip/address -X PUT -f address=not-an-ip -f interface=ether1 --raw --username $U --password $P --yes
135
+ ```
136
+
137
+ stdout is empty; stderr carries the RouterOS error payload as JSON; exit code is
138
+ nonzero.
139
+
140
+ ### 15. Mutating + non-TTY + no `--yes` is refused
141
+
142
+ ```bash
143
+ centrs api $R ip/address -X PUT -f address=198.51.100.14/32 -f interface=ether1 --username $U --password $P </dev/null
144
+ ```
145
+
146
+ Envelope: `ok: false`, `error.code=usage/confirmation-required`, no request
147
+ issued, no address added. (Holds under `--raw` too.)
148
+
149
+ ### 16. POST run a console command via `/rest/execute`
150
+
151
+ ```bash
152
+ centrs api $R execute -X POST -f script=':put [/system/identity/get name]' --username $U --password $P --yes
153
+ ```
154
+
155
+ Envelope: `ok: true`, `data` is string-shaped and contains the CHR identity;
156
+ `meta.validation.semantic=not-applicable` (script, not a path).
157
+
158
+ ### 17. `--via rest-api --listen` is rejected
159
+
160
+ ```bash
161
+ centrs api $R ip/address --listen --via rest-api --username $U --password $P
162
+ ```
163
+
164
+ Envelope: `ok: false`, `error.code=transport/capability-unsupported` (REST cannot
165
+ follow); no silent open-ended poll.
166
+
167
+ ### 18. `--query` not-equal maps to a negated stack word
168
+
169
+ ```bash
170
+ centrs api $R interface --query type!=ether --username $U --password $P
171
+ ```
172
+
173
+ Envelope: `ok: true`, no returned record has `type=ether`; the request carried
174
+ REST `.query` words `["type=ether","#!"]` (eq then NOT-top).
175
+
176
+ ### 19. `--raw-query` expresses OR
177
+
178
+ ```bash
179
+ centrs api $R interface --raw-query type=ether --raw-query type=loopback --raw-query '#|' --username $U --password $P
180
+ ```
181
+
182
+ Envelope: `ok: true`, returns the union of `ether` and `loopback` interfaces; the
183
+ raw words are passed through verbatim as `.query` (REST) / `?` words (native).
184
+
185
+ ### 20. Bounded `duration=` command returns a `.section` array (not a stream)
186
+
187
+ ```bash
188
+ centrs api $R interface/monitor-traffic -X POST -f interface=ether1 -f duration=5s --username $U --password $P --yes
189
+ ```
190
+
191
+ Envelope: `ok: true`, `data` is an **array** of `.section`-keyed records (one per
192
+ ~second), `meta.via=rest-api`. This is an ordinary bounded call — no `--listen`,
193
+ no NDJSON. `monitor-traffic` is a command (not `print`/`get`), so it is a
194
+ `POST` and write-classed (needs `--yes`); centrs never relaxes that to read-only.
195
+ (REST bounds it at the 60 s cap; native has no cap.)
196
+
197
+ ### 21. GET one row by id together with `--proplist`
198
+
199
+ ```bash
200
+ centrs api $R ip/address/$ID --proplist address --username $U --password $P
201
+ ```
202
+
203
+ Envelope: `ok: true`, `data` is a **single object** (not an array) carrying only
204
+ the projected property `address` (RouterOS omits `.id` unless it is in the
205
+ proplist). Combining an id with `--query`/`--proplist` folds the id into the REST
206
+ `.query` (`.id=$ID`) and rides a `POST …/print`, then unwraps the one row —
207
+ matching the native `?.id=` read. (Plain id-only reads still use the `GET …/$ID`
208
+ URL form; see example 5's re-read.)
209
+
210
+ ## native-api (`--via native-api`)
211
+
212
+ The same contract over the binary API. Validation still runs through
213
+ `/console/inspect`, issued as native commands. Values are strings.
214
+
215
+ ### N1. GET a list
216
+
217
+ ```bash
218
+ centrs api $A interface -X GET --via native-api --port $API_PORT --username $U --password $P
219
+ ```
220
+
221
+ Envelope: `ok: true`, `data` is an array of rest-style records (string values),
222
+ `meta.via=native-api`.
223
+
224
+ ### N2. PUT add (returns `.id`)
225
+
226
+ ```bash
227
+ centrs api $A ip/address -X PUT -f address=198.51.100.20/32 -f interface=ether1 --via native-api --port $API_PORT --username $U --password $P --yes
228
+ ```
229
+
230
+ Envelope: `ok: true`, `data[".id"]` present.
231
+
232
+ ### N3. PATCH set by id (id → `=.id=` word)
233
+
234
+ ```bash
235
+ centrs api $A ip/address/$ID -X PATCH -f comment=centrs-api-native-set --via native-api --port $API_PORT --username $U --password $P --yes
236
+ ```
237
+
238
+ Envelope: `ok: true`; re-reading shows the new comment.
239
+
240
+ ### N4. DELETE remove by id (id → `=.id=` word)
241
+
242
+ ```bash
243
+ centrs api $A ip/address/$ID -X DELETE --via native-api --port $API_PORT --username $U --password $P --yes
244
+ ```
245
+
246
+ Envelope: `ok: true`; the row is gone.
247
+
248
+ ### N5. GET one by id (→ `print ?.id=`)
249
+
250
+ ```bash
251
+ centrs api $A ip/address/$ID2 -X GET --via native-api --port $API_PORT --username $U --password $P
252
+ ```
253
+
254
+ Envelope: `ok: true`, `data` is a single object whose `.id` is `$ID2`.
255
+
256
+ ### N6. Validation rejects an unknown attribute over native
257
+
258
+ ```bash
259
+ centrs api $A ip/address -X PUT -f address=198.51.100.21/32 -f interface=ether1 -f no-such-arg=x --via native-api --port $API_PORT --username $U --password $P --yes
260
+ ```
261
+
262
+ Envelope: `ok: false`, `error.code=validation/unknown-attribute`, no row added —
263
+ caught by the inspect gate, not a native `!trap`.
264
+
265
+ ### N7. `--raw` over native
266
+
267
+ ```bash
268
+ centrs api $A interface --raw --via native-api --port $API_PORT --username $U --password $P
269
+ ```
270
+
271
+ stdout is the bare rest-style array (string values); exit code 0.
272
+
273
+ ### N8. POST run a console command via native `/execute`
274
+
275
+ ```bash
276
+ centrs api $A execute -X POST -f script=':put [/system/identity/get name]' --via native-api --port $API_PORT --username $U --password $P --yes
277
+ ```
278
+
279
+ Envelope: `ok: true`, `data` contains the CHR identity.
280
+
281
+ ## listen / `--stream` (native-api only)
282
+
283
+ Open-ended follow. `--stream` is the primary flag; `--listen` is an accepted
284
+ alias and a trailing `/listen` endpoint segment infers it. Output is NDJSON: one
285
+ envelope per change frame, then a terminating summary envelope.
286
+
287
+ ### L1. `--stream` streams a change as an NDJSON frame, then a summary
288
+
289
+ ```bash
290
+ centrs api $A ip/address --stream --count 1 --via native-api --port $API_PORT --username $U --password $P
291
+ ```
292
+
293
+ While listening, the harness adds an address over REST. stdout is NDJSON: at least
294
+ one envelope frame for the new row (`meta.operation.stream.kind=frame`), then a
295
+ final summary envelope (`meta.operation.stream.kind=summary`,
296
+ `data.stopReason=count-reached`, `data.frames>=1`). Exit code 0 (stream started
297
+ cleanly).
298
+
299
+ ### L2. A deletion frame carries `.dead`
300
+
301
+ ```bash
302
+ centrs api $A ip/address --stream --duration 3s --via native-api --port $API_PORT --username $U --password $P
303
+ ```
304
+
305
+ The harness removes a pre-seeded address; an emitted frame's record carries
306
+ `.dead=true` (a minimal `{ ".id", ".dead" }` record, per the CHR-grounded
307
+ `commands/api/AGENTS.md`).
308
+
309
+ ### L3. `/listen` endpoint infers native + streaming
310
+
311
+ ```bash
312
+ centrs api $A "ip/address/listen" --count 1 --port $API_PORT --username $U --password $P
313
+ ```
314
+
315
+ No `--stream` / `--via` given; the `/listen` endpoint infers `--stream` and
316
+ `--via native-api` (`meta.via=native-api`). Same NDJSON shape as L1.
317
+
318
+ ### L4. Bounded `--duration` reports its stop reason
319
+
320
+ ```bash
321
+ centrs api $A ip/address --stream --duration 2s --via native-api --port $API_PORT --username $U --password $P
322
+ ```
323
+
324
+ With no change during the window, the stream ends after ~2 s with a summary
325
+ envelope whose `data.stopReason=duration-elapsed`. Exit code 0.
@@ -17,7 +17,10 @@ on the centrs **server**; `test/integration/btest-client.test.ts` runs the centr
17
17
  **client** against a real RouterOS `/tool/bandwidth-server` over a host→guest
18
18
  `tcp:2000` forward — unauthenticated TCP receive, an **EC-SRP5 client proof
19
19
  verified by RouterOS's own verifier**, and a wrong-password reject. TCP
20
- multi-connection (`connection-count > 1`) data fan-out is a follow-up. See
20
+ multi-connection (`connection-count > 1`) data fan-out is implemented for
21
+ unauthenticated tests (the client joins the negotiated extra connections; the join
22
+ format is grounded against RouterOS 7.23.1); authenticated sessions stay
23
+ single-stream. See
21
24
  `docs/MATRIX.md` ("Peer measurement (`btest`)") for the cell states — that is the
22
25
  only status surface. v1 scope: **both** modes (client + server), **EC-SRP5 +
23
26
  unauthenticated** auth, **TCP and UDP** tests. Legacy pre-6.43 double-MD5 is out of
@@ -85,7 +88,7 @@ centrs btest server [--authenticate[=false]] [--user U] [--password P] \
85
88
  | `--direction <receive\|transmit\|both>` | `direction`, default `receive` | `receive` = download (server→client), `transmit` = upload. |
86
89
  | `--duration <dur>` | `duration` | Bounds the run; reuses `parseDuration`. Omit for open-ended (Ctrl-C). |
87
90
  | `--interval <dur>` | `interval`, default `1s` | Report cadence (`20ms`..`5s`). One report frame per interval. |
88
- | `--connection-count <n>` | `connection-count` (1..255) | **TCP only.** Parallel TCP data connections. |
91
+ | `--connection-count <n>` | `connection-count` (1..255) | **TCP only.** Parallel TCP data connections. centrs opens the negotiated extra connections (join format grounded against RouterOS 7.23.1) so throughput aggregates across streams. **Authenticated (EC-SRP5) sessions stay single-stream** — the post-auth token is not captured — and emit a `routeros/btest-connection-count-single-stream` warning. |
89
92
  | `--local-udp-tx-size <n>` | `local-udp-tx-size` (28..64000) | **UDP only.** Client→server packet size. |
90
93
  | `--remote-udp-tx-size <n>` | `remote-udp-tx-size` (28..64000) | **UDP only.** Server→client packet size. |
91
94
  | `--local-tx-speed <bps>` | `local-tx-speed` | Cap on client→server rate (bits/sec). |
@@ -120,10 +123,20 @@ test, data then flows on the server's allocated UDP ports
120
123
  from sequence gaps. For a TCP test, the bulk data rides `connection-count` TCP
121
124
  connections and goodput is measured on the data stream (TCP/IP headers excluded,
122
125
  matching RouterOS accounting). For multi-connection TCP the server's OK response
123
- carries a 2-byte **session token**; the extra connections reconnect and present
124
- that token to join the same test (no re-auth). Throughput and CPU are exchanged
126
+ carries a 2-byte **session token**; the client opens `connection-count − 1`
127
+ additional connections, each presenting a 16-byte join `[token:u16 BE][0x02][0 …]`
128
+ (grounded against RouterOS 7.23.1, direction-independent) to join the same test
129
+ without re-auth, and drives them into the shared counters so throughput aggregates.
130
+ Throughput and CPU are exchanged
125
131
  once per `interval` as a 12-byte **status message** on the TCP channel
126
- (`[0x07][0x80|cpu][00 00][seq u32 LE][bytesReceived u32 LE]`).
132
+ (`[0x07][0x80|cpu][00 00][seq u32 LE][bytesReceived u32 LE]`). The receiver of a
133
+ transmit (server for `transmit`, both peers for `both`) sends this so the
134
+ transmitter paces from the peer's `bytesReceived`. For `direction=both` the
135
+ status frames are **interleaved into the bulk data stream** (no length framing),
136
+ so the client demuxes them out of its receive loop by their structural marker —
137
+ without that feedback the client's TX saturates the link and starves
138
+ server→client RX. RouterOS clears the CPU high bit the centrs client sets, so the
139
+ marker matches on the two reserved zero bytes, not the high bit.
127
140
 
128
141
  ### Authentication (EC-SRP5)
129
142
 
@@ -141,7 +154,7 @@ will not offer EC-SRP5 against an `authenticate=yes` server) maps to
141
154
 
142
155
  btest runs over time, so it emits **live records** during the run plus a final
143
156
  **summary**. v1 keeps the formats simple and **defers a broader JSON-streaming
144
- decision** — it does *not* adopt the `stream` command's NDJSON-stream-of-envelopes
157
+ decision** — it does *not* adopt `api --stream`'s NDJSON-stream-of-envelopes
145
158
  contract yet (see Open questions):
146
159
 
147
160
  - **`text`** (default) — human-readable live reports during the run
@@ -214,10 +227,11 @@ Decided with the user. Both cells have direct gated `CHR-passed` evidence:
214
227
  maps onto the guest bandwidth server on 2000 (the same SLIRP inbound path that
215
228
  already carries REST/SSH — no firewall change). The centrs client dials
216
229
  `127.0.0.1:<host port>` and proves **TCP receive**, unauth and **EC-SRP5** (its
217
- client proof verified by RouterOS's real server verifier), plus a wrong-password
218
- reject. **TCP only** — UDP would need the server's datagrams to traverse SLIRP
219
- back to the host, which this forward does not provide, so UDP client→server stays
220
- loopback/transitive.
230
+ client proof verified by RouterOS's real server verifier), a wrong-password
231
+ reject, **TCP multi-connection fan-out** (`connection-count=4`), and **UDP
232
+ receive/both**. The UDP server→client return needs no UDP forward: it rides the
233
+ guest→host SLIRP gateway (`10.0.2.2:clientUdpPort`, the same path the server cell
234
+ uses), which works because the client socket is left unconnected (#86, #88).
221
235
 
222
236
  The fast/deterministic backstop under both is **centrs client ↔ centrs server
223
237
  loopback** (`127.0.0.1`, injected sockets) covering the full client↔server matrix,
@@ -226,32 +240,43 @@ EC-SRP5 both roles, and the option-grammar rejects.
226
240
  while implementing the EC-SRP5 framing and the server-side verifier; it is **not**
227
241
  an integration test (it cannot run in CI) and **not** part of the long-term plan.
228
242
 
229
- **Honest grounding caveat:** both cells are now directly CHR-passed. The remaining
230
- transitive-only paths are **UDP client→CHR-server** (the SLIRP reverse-NAT path the
231
- TCP forward does not cover) and TCP **multi-connection** fan-out — both deferred,
232
- not yet gated.
243
+ **Honest grounding caveat:** both cells are now directly CHR-passed, **including
244
+ TCP multi-connection fan-out** — `test/integration/btest-client.test.ts` gates a
245
+ centrs client opening `connection-count=4` against a real RouterOS
246
+ `/tool/bandwidth-server`; RouterOS accepts all 3 secondary joins and data flows on
247
+ every connection (`activeConnections: 4`). The *throughput increase* multiple
248
+ streams give is a WAN/latency property and is not observable over the near-zero-
249
+ latency SLIRP loopback, so the gate asserts the fan-out, not a higher number (the
250
+ per-connection drive is asserted deterministically by the loopback unit test).
251
+ **UDP client receive/both is now CHR-gated too** — the server→client return rides
252
+ the guest→host SLIRP gateway, no UDP forward needed (#88). The remaining fan-out
253
+ edge is **authenticated** multi-connection (capturing the EC-SRP5 post-auth token),
254
+ still single-stream.
233
255
 
234
256
  ## Open questions
235
257
 
236
- - **JSON streaming is deliberately deferred.** btest does not adopt the `stream`
237
- command's NDJSON-stream-of-envelopes contract yet; v1 streams `text` / `csv` and
238
- returns a single summary envelope for `json` / `yaml`. Revisit a streaming-JSON
239
- shape — ideally shared with `stream` — once that contract is settled.
240
- - **UDP receive/both through SLIRP is unproven.** Before the integration suite
241
- relies on server→guest UDP, a **one-shot CHR smoke test** must confirm the path
242
- works — i.e. that the RouterOS client originates an initial UDP/NAT probe that
243
- opens the SLIRP reverse mapping. Until then the gated UDP coverage is
244
- **transmit** (guest→host); TCP already covers both directions.
245
- - **UDP client→CHR-server and TCP multi-connection are not yet gated.** The direct
246
- client→CHR-server gate (`test/integration/btest-client.test.ts`) is **TCP only**;
247
- a UDP variant needs the server's datagrams to traverse SLIRP back to the host
248
- (the reverse-NAT path), and `connection-count > 1` needs the parallel-stream
249
- fan-out. Both remain loopback/transitive until gated. `btest.exe` (wine) is the
250
- coding-time grounding peer, not CI.
258
+ - **JSON streaming is deliberately deferred.** btest does not adopt
259
+ `api --stream`'s NDJSON-stream-of-envelopes contract yet; v1 streams `text` /
260
+ `csv` and returns a single summary envelope for `json` / `yaml`. Revisit a
261
+ streaming-JSON shape — ideally shared with `api --stream` — once it has settled.
262
+ - **UDP client receive/both is gated (#88).** The client cell
263
+ (`test/integration/btest-client.test.ts`) lands real UDP server→client throughput:
264
+ the return rides the guest→host SLIRP gateway (`10.0.2.2:clientUdpPort`), needing
265
+ no UDP forward and no quickchr change — it works because the client socket is left
266
+ unconnected (#86).
267
+ - **Only the server-cell host→guest UDP direction stays unproven.** The server cell
268
+ gates UDP *transmit* (guest→host, which SLIRP NATs cleanly); the reverse (centrs
269
+ server → CHR client, host→guest) would need a UDP hostfwd for the data ports, so it
270
+ is not yet asserted. `btest.exe` (wine) is the coding-time grounding peer, not CI.
271
+ - **Authenticated multi-connection stays single-stream.** Fan-out negotiates the
272
+ session token from the unauthenticated OK; capturing the EC-SRP5 post-auth token
273
+ to fan out authenticated tests is a follow-up (#103). centrs warns
274
+ (`routeros/btest-connection-count-single-stream`) when the realized connection
275
+ count falls short of the request. The **server-side** secondary-accept is its own
276
+ follow-up (#100).
251
277
 
252
278
  ## Out of scope (v1)
253
279
 
254
- Legacy pre-6.43 double-MD5 auth; a UDP client→CHR-server gated test (TCP is gated;
255
- UDP needs SLIRP reverse-NAT); the ≥3-router "test-through" topology RouterOS
280
+ Legacy pre-6.43 double-MD5 auth; the ≥3-router "test-through" topology RouterOS
256
281
  recommends for measuring a *transit* device (centrs is an endpoint peer); TUI/proxy
257
282
  btest frontends (later, over the stable core).
@@ -15,17 +15,19 @@ matrix's honest grounding split (`docs/MATRIX.md`, "Peer measurement (`btest`)")
15
15
  loss / direction against the CHR's own `/tool/bandwidth-test` status output
16
16
  (CHR 7.23.1, `bun run test:integration`). Examples **4** (wrong-credentials) and
17
17
  **5** (multi-connection TCP) are **designed but not yet gated** — the auth-reject
18
- path is covered by the loopback unit test, and the multi-stream fan-out is a
19
- documented follow-up — so they are not part of the asserted run.
18
+ path is covered by the loopback unit test, and example 5 is the **server-side**
19
+ secondary-accept (distinct from the now-gated **client** fan-out in example 11),
20
+ still a documented follow-up — so they are not part of the asserted run.
20
21
  - **Gated `CHR-passed` (client cell): centrs client → CHR bandwidth-server** —
21
22
  examples **6** (TCP receive) and **8** (EC-SRP5 client role + wrong-password
22
23
  reject) are *also* asserted directly against a real RouterOS
23
24
  `/tool/bandwidth-server`, over a host→guest `tcp:2000` forward
24
25
  (`test/integration/btest-client.test.ts`, CHR 7.23.1). This is what makes the
25
26
  **client** cell `CHR-passed`: centrs's EC-SRP5 **client proof** is verified by
26
- RouterOS's own server verifier. TCP only — the EC-SRP5 example is run over TCP
27
- receive here (the loopback variant below uses UDP); UDP client→server stays
28
- loopback (README, Open questions).
27
+ RouterOS's own server verifier. Example **11** (TCP **multi-connection**
28
+ fan-out), a TCP `direction=both` #85 regression guard, and **UDP receive/both**
29
+ (#88 — the server→client return over the guest→host SLIRP gateway) are gated in
30
+ the same suite.
29
31
  - **Unit / loopback (client cell + codec)** — examples 6–10. centrs client ↔
30
32
  centrs server on `127.0.0.1` with injected sockets; the EC-SRP5 both-roles math
31
33
  and the pre-socket option-grammar rejects (`test/unit/btest.test.ts`). These are
@@ -113,12 +115,13 @@ contains a `transport/auth-failed` entry (the EC-SRP5 proof did not verify) and
113
115
  `data.sessions` is empty — no session opened, no throughput. The CHR side reports
114
116
  an authentication failure.
115
117
 
116
- ### 5. Multi-connection TCP transmit
118
+ ### 5. Multi-connection TCP transmit (server-side accept)
117
119
 
118
- > *Designed, not yet gated* — the server's parallel-stream fan-out for
119
- > `connection-count > 1` is not built yet (the session token is negotiated, but
120
- > secondary TCP joins are not served). Documented follow-up; not part of the
121
- > asserted run.
120
+ > *Designed, not yet gated* — this is the **server-side** fan-out: centrs's
121
+ > `btest server` accepting parallel secondary joins from a client. The session
122
+ > token is negotiated but centrs's server does not yet serve secondary TCP joins.
123
+ > Distinct from the **client** fan-out (example 11), which **is** implemented and
124
+ > gated. Tracked as a follow-up (#100); not part of the asserted run.
122
125
 
123
126
  ```bash
124
127
  centrs btest server --authenticate=false --bind 0.0.0.0 --duration 20s --format json
@@ -188,3 +191,25 @@ centrs btest client $SRV --protocol udp --local-udp-tx-size 99999
188
191
  `ok: false`, `error.code = "validation/option"` (UDP size must be `28..64000`), no
189
192
  socket opened. The same gate rejects `connection-count` outside `1..255`,
190
193
  `interval` outside `20ms..5s`, and (server) `max-sessions` outside `1..1000`.
194
+
195
+ ## centrs client TCP multi-connection (gated; client cell)
196
+
197
+ ### 11. Multi-connection TCP receive fan-out
198
+
199
+ ```bash
200
+ centrs btest client <router> --protocol tcp --direction receive --connection-count 4 --duration 4s --format json
201
+ ```
202
+
203
+ Against a real RouterOS `/tool/bandwidth-server` (`authenticate=no`), centrs reads
204
+ the session token from the primary's OK and opens the 3 extra TCP data
205
+ connections, each presenting the grounded join `[token:u16 BE][0x02][0 …]`. The
206
+ summary is `ok: true` with `data.activeConnections: 4` and non-zero aggregate
207
+ throughput — real RouterOS accepted all 3 secondary joins and data flowed on every
208
+ connection (the per-connection drive is also asserted deterministically by the
209
+ loopback unit test). The *throughput increase* multiple streams provide is a
210
+ high-latency/WAN-link property and is **not** observable over the near-zero-latency
211
+ SLIRP loopback (a single TCP stream already saturates it), so the gate asserts the
212
+ fan-out itself, not a higher number. Gated in
213
+ `test/integration/btest-client.test.ts` (CHR 7.23.1). An **authenticated**
214
+ `--connection-count > 1` run instead stays single-stream (`activeConnections: 1`)
215
+ and carries a `routeros/btest-connection-count-single-stream` warning.
@@ -52,6 +52,12 @@ Status: `rest-api`, `native-api`, `mac-telnet`, and `ssh` are `CHR-passed` (see
52
52
  else passes its `detail`/body plus the HTTP status through
53
53
  `mapRouterOsError({ transport: "rest-api", httpStatus })`. There is no longer
54
54
  a hand-rolled `routeros/*` table on the REST side.
55
+ - **Run-time fault mapping — later (JG-28).** `mapRouterOsError`'s table covers
56
+ the faults seen so far; a known gap is errors that *pass* `:parse`/validation
57
+ but fail at **run time** (a syntactically valid command the router rejects when
58
+ executed). `tikoci/vscode-tikbook` has an error-string mapping for these worth
59
+ porting into `src/core/routeros-errors.ts`. Background research, not yet
60
+ scheduled — captured here so it isn't lost (no GitHub issue by design).
55
61
  - Output is *string*-shaped for script-mode; richer parsing is a future
56
62
  concern. The envelope must still distinguish RouterOS errors from successful
57
63
  runs (a 200 with a RouterOS error string is still an error — see
@@ -195,9 +201,9 @@ Protocol selection: an unresolved MAC target auto-selects mac-telnet (covered by
195
201
  ## Open shape questions
196
202
 
197
203
  - ~~How to expose multi-line / async / progress output~~ — **resolved:**
198
- open-ended follow/streaming reads are the separate read-only `stream` verb
199
- (NDJSON stream of envelopes; see `commands/stream/README.md`). `execute` stays
200
- single-shot read/write; it does not grow a follow mode.
204
+ open-ended follow/streaming reads are `api … --stream` (NDJSON stream of
205
+ envelopes; see `commands/api/README.md`). `execute` stays single-shot
206
+ read/write; it does not grow a follow mode.
201
207
  - ~~Whether `--strict` should reject "succeeded with stderr-like content."~~ —
202
208
  **decided:** `--strict` promotes any warning on an otherwise-successful run to
203
209
  `ok: false` (nonzero exit), like `-Werror`; the default stays lenient
@@ -45,7 +45,7 @@ centrs retrieve <router> snmp <oid|MIB name> [flags]
45
45
  | `--attributes <a,b,c>` | Alias for `--attribute` with comma-separated input. |
46
46
  | `--all-attributes` | Equivalent to RouterOS `details=true`. Mutually exclusive with `--attribute(s)`. |
47
47
  | `--list-attributes` (alias `--list`) | Return the available attribute names for the path. No `print`/`get` is run. |
48
- | `--once` | Bounded single read of a monitor-style menu (RouterOS `once`): returns **one** envelope and never follows. Open-ended follow is the separate `stream` verb. See constitution: protocol selection. |
48
+ | `--once` | Bounded single read of a monitor-style menu (RouterOS `once`): returns **one** envelope and never follows. Open-ended follow is `api … --stream`. See constitution: protocol selection. |
49
49
  | `--query <expr>` | RouterOS-side **row** filter (maps to `.query`), repeatable; returns matching rows as an **array**. This is how you read one row by name (`--query name=ether1`). **Not Implemented** yet — returns `validation/not-implemented`. |
50
50
  | `--filter <expr>` | RouterOS row filter; same `.query` mapping and not-implemented handling as `--query`. |
51
51
  | `--where <attr>=<value>` | Device-class selector: fan out across CDB records whose stored fact/comment-kv matches (e.g. `--where board=RB5009`). Repeatable (AND). Filters *which devices*, not RouterOS rows. See constitution: target selection. |
@@ -228,8 +228,8 @@ repeats. The union is **de-duped by CDB record index** and run through the same
228
228
  per-target pipeline; the `data` array is reassembled in resolved record order,
229
229
  not completion order, so repeated runs diff cleanly. Destructive multi-target
230
230
  operations (e.g. `devices remove` across a group) require `--force`. `terminal`
231
- and single-session `stream` are not fan-out surfaces and reject N>1 with
232
- `usage/fanout-not-supported`.
231
+ and a single-session `api --stream` follow are not fan-out surfaces and reject
232
+ N>1 with `usage/fanout-not-supported`.
233
233
 
234
234
  `--where` filters *which devices* by CDB-stored facts; keep it distinct from
235
235
  `retrieve`/`execute`'s `--query`/`--filter`, which filter *RouterOS rows* in the
@@ -266,7 +266,7 @@ Per-operation preferences, downgrade order in parens:
266
266
  | Operation | Preferred | Downgrade order |
267
267
  | --------- | --------- | --------------- |
268
268
  | retrieve | rest-api, native-api; snmp for OID/MIB reads (future) | rest-api, native-api |
269
- | stream | native-api, ssh | (REST cannot follow — 60s cap; bounded or rejected) |
269
+ | api | rest-api, native-api; `--stream` follow native-api only | rest-api, native-api (no silent downgrade) |
270
270
  | execute | native-api → rest-api → mac-telnet | native-api, rest-api, mac-telnet, ssh, romon, winbox-terminal |
271
271
  | terminal | ssh | mac-telnet (L2 only when ssh fails or MAC given) |
272
272
  | transfer | size/direction-aware: rest-api/native-api for ≤60 KB writes & all reads (chunked), sftp for large uploads | sftp ⇄ rest/native by size; scp · fetch · ftp explicit-only |
@@ -288,9 +288,9 @@ Rules:
288
288
  access needs explicit ARP opt-in.
289
289
  - **`measure` (btest) is explicit-only** — never in the auto-select/downgrade
290
290
  chain, never a substitute, and no other command rides it.
291
- - Per-command selection **depth** lives in the command READMEs: `stream`'s
292
- follow/NDJSON contract and the REST-cannot-follow constraint
293
- (`commands/stream/README.md`), `transfer`'s size/direction-aware method
291
+ - Per-command selection **depth** lives in the command READMEs: `api`'s
292
+ `--stream` follow/NDJSON contract and the REST-cannot-follow constraint
293
+ (`commands/api/README.md`), `transfer`'s size/direction-aware method
294
294
  selection and the explicit-only `scp`/`ftp`/`fetch`
295
295
  (`commands/transfer/README.md`), SNMP's MIB-cache
296
296
  (`commands/retrieve/README.md`), and btest's port/auth