@tikoci/centrs 0.1.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 (182) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/GLOSSARY.txt +420 -0
  3. package/README.md +196 -0
  4. package/SECURITY.md +30 -0
  5. package/commands/AGENTS.md +50 -0
  6. package/commands/btest/README.md +257 -0
  7. package/commands/btest/examples.md +190 -0
  8. package/commands/check/README.md +59 -0
  9. package/commands/devices/README.md +405 -0
  10. package/commands/devices/examples.md +488 -0
  11. package/commands/discover/README.md +170 -0
  12. package/commands/discover/examples.md +98 -0
  13. package/commands/execute/README.md +208 -0
  14. package/commands/execute/examples.md +353 -0
  15. package/commands/mcp/README.md +260 -0
  16. package/commands/mcp/examples.md +158 -0
  17. package/commands/retrieve/README.md +209 -0
  18. package/commands/retrieve/examples.md +306 -0
  19. package/commands/settings/README.md +69 -0
  20. package/commands/stream/README.md +70 -0
  21. package/commands/terminal/README.md +177 -0
  22. package/commands/terminal/examples.md +144 -0
  23. package/commands/transfer/README.md +315 -0
  24. package/commands/transfer/examples.md +368 -0
  25. package/docs/CONSTITUTION.md +417 -0
  26. package/docs/MATRIX.md +224 -0
  27. package/docs/errors/README.md +74 -0
  28. package/docs/errors/auth/failed.md +9 -0
  29. package/docs/errors/cdb/already-exists.md +9 -0
  30. package/docs/errors/cdb/created.md +9 -0
  31. package/docs/errors/cdb/decrypt-failed.md +9 -0
  32. package/docs/errors/cdb/empty-group.md +9 -0
  33. package/docs/errors/cdb/invalid-option.md +9 -0
  34. package/docs/errors/cdb/invalid-record.md +9 -0
  35. package/docs/errors/cdb/not-found-target.md +9 -0
  36. package/docs/errors/cdb/not-found.md +9 -0
  37. package/docs/errors/cdb/override-applied.md +9 -0
  38. package/docs/errors/cdb/parse-failed.md +9 -0
  39. package/docs/errors/cdb/password-not-needed.md +9 -0
  40. package/docs/errors/cdb/password-required.md +9 -0
  41. package/docs/errors/cdb/reserved-key.md +9 -0
  42. package/docs/errors/cdb/reserved-option.md +9 -0
  43. package/docs/errors/cdb/target-not-registered.md +9 -0
  44. package/docs/errors/cdb/unknown-field.md +9 -0
  45. package/docs/errors/cdb/unknown-option.md +9 -0
  46. package/docs/errors/cdb/write-not-permitted.md +9 -0
  47. package/docs/errors/discover/broadcast-unavailable.md +9 -0
  48. package/docs/errors/discover/failed.md +9 -0
  49. package/docs/errors/identity/ambiguous.md +9 -0
  50. package/docs/errors/identity/no-match.md +9 -0
  51. package/docs/errors/input/invalid-command.md +9 -0
  52. package/docs/errors/input/invalid-match.md +9 -0
  53. package/docs/errors/input/invalid-path.md +9 -0
  54. package/docs/errors/input/invalid-routeros-path.md +9 -0
  55. package/docs/errors/input/invalid-target.md +9 -0
  56. package/docs/errors/input/local-file-not-found.md +9 -0
  57. package/docs/errors/input/mac-address.md +9 -0
  58. package/docs/errors/input/max-results-exceeded.md +9 -0
  59. package/docs/errors/internal/devices-failed.md +9 -0
  60. package/docs/errors/internal/mcp-start.md +9 -0
  61. package/docs/errors/internal/mcp-tool.md +9 -0
  62. package/docs/errors/internal/native-api-length.md +9 -0
  63. package/docs/errors/internal/unhandled.md +9 -0
  64. package/docs/errors/internal/unreachable.md +9 -0
  65. package/docs/errors/mndp/encode-failed.md +9 -0
  66. package/docs/errors/mndp/listen-failed.md +9 -0
  67. package/docs/errors/mndp/malformed.md +9 -0
  68. package/docs/errors/routeros/api-fatal.md +9 -0
  69. package/docs/errors/routeros/api-protocol.md +9 -0
  70. package/docs/errors/routeros/api-trap.md +9 -0
  71. package/docs/errors/routeros/btest-protocol.md +12 -0
  72. package/docs/errors/routeros/btest-too-many-sessions.md +9 -0
  73. package/docs/errors/routeros/command-failed.md +14 -0
  74. package/docs/errors/routeros/ec-srp5-protocol.md +11 -0
  75. package/docs/errors/routeros/error.md +9 -0
  76. package/docs/errors/routeros/invalid-value.md +14 -0
  77. package/docs/errors/routeros/mac-telnet-error.md +9 -0
  78. package/docs/errors/routeros/mac-telnet-not-ready.md +9 -0
  79. package/docs/errors/routeros/mac-telnet-protocol.md +9 -0
  80. package/docs/errors/routeros/mac-telnet-unsupported-auth.md +9 -0
  81. package/docs/errors/routeros/protocol-not-implemented.md +9 -0
  82. package/docs/errors/routeros/request-failed.md +9 -0
  83. package/docs/errors/routeros/session-closed.md +13 -0
  84. package/docs/errors/routeros/unknown-attribute.md +13 -0
  85. package/docs/errors/routeros/unknown-path.md +15 -0
  86. package/docs/errors/routeros/unsupported-capability.md +9 -0
  87. package/docs/errors/settings/invalid-boolean.md +9 -0
  88. package/docs/errors/settings/invalid-format.md +9 -0
  89. package/docs/errors/settings/invalid-integer.md +9 -0
  90. package/docs/errors/settings/invalid-timeout.md +9 -0
  91. package/docs/errors/settings/invalid-via.md +9 -0
  92. package/docs/errors/settings/unsafe-protocol-blocked.md +9 -0
  93. package/docs/errors/target/mac-not-in-arp.md +14 -0
  94. package/docs/errors/target/mac-required.md +23 -0
  95. package/docs/errors/target/mac-unresolved.md +17 -0
  96. package/docs/errors/target/unresolved.md +9 -0
  97. package/docs/errors/transport/auth-failed.md +9 -0
  98. package/docs/errors/transport/auto-method.md +9 -0
  99. package/docs/errors/transport/capability-unsupported.md +22 -0
  100. package/docs/errors/transport/checksum-unavailable.md +9 -0
  101. package/docs/errors/transport/connection-closed.md +9 -0
  102. package/docs/errors/transport/connection-refused.md +9 -0
  103. package/docs/errors/transport/dns.md +9 -0
  104. package/docs/errors/transport/host-key-mismatch.md +9 -0
  105. package/docs/errors/transport/incomplete-transfer.md +9 -0
  106. package/docs/errors/transport/insecure-trust.md +9 -0
  107. package/docs/errors/transport/local-tool-missing.md +11 -0
  108. package/docs/errors/transport/network.md +9 -0
  109. package/docs/errors/transport/timeout.md +9 -0
  110. package/docs/errors/transport/tls-certificate.md +9 -0
  111. package/docs/errors/transport/unreachable.md +9 -0
  112. package/docs/errors/transport/unsupported-operation.md +9 -0
  113. package/docs/errors/usage/confirmation-required.md +9 -0
  114. package/docs/errors/usage/conflicting-flags.md +9 -0
  115. package/docs/errors/usage/invalid-concurrency.md +9 -0
  116. package/docs/errors/usage/missing-group.md +9 -0
  117. package/docs/errors/usage/not-implemented.md +9 -0
  118. package/docs/errors/usage/target-exists.md +9 -0
  119. package/docs/errors/usage/timeout-out-of-range.md +9 -0
  120. package/docs/errors/validation/not-implemented.md +9 -0
  121. package/docs/errors/validation/option.md +9 -0
  122. package/docs/errors/validation/syntax.md +9 -0
  123. package/docs/errors/validation/unknown-attribute.md +9 -0
  124. package/docs/errors/validation/unknown-path.md +9 -0
  125. package/package.json +96 -0
  126. package/src/AGENTS.md +7 -0
  127. package/src/btest.ts +906 -0
  128. package/src/cli/btest.ts +455 -0
  129. package/src/cli/common.ts +53 -0
  130. package/src/cli/devices.ts +527 -0
  131. package/src/cli/discover.ts +186 -0
  132. package/src/cli/execute.ts +333 -0
  133. package/src/cli/mcp.ts +109 -0
  134. package/src/cli/retrieve.ts +383 -0
  135. package/src/cli/terminal.ts +303 -0
  136. package/src/cli/transfer.ts +471 -0
  137. package/src/cli.ts +115 -0
  138. package/src/core/envelope.ts +221 -0
  139. package/src/core/error-catalog.ts +469 -0
  140. package/src/core/routeros-errors.ts +268 -0
  141. package/src/data/AGENTS.md +11 -0
  142. package/src/data/dude-db.ts +0 -0
  143. package/src/data/mndp-cache.ts +132 -0
  144. package/src/data/mndp.ts +405 -0
  145. package/src/data/winbox-cdb-write.ts +200 -0
  146. package/src/data/winbox-cdb.ts +913 -0
  147. package/src/devices-schema.ts +107 -0
  148. package/src/devices.ts +1717 -0
  149. package/src/discover.ts +802 -0
  150. package/src/errors.ts +189 -0
  151. package/src/execute.ts +1350 -0
  152. package/src/index.ts +353 -0
  153. package/src/mcp/config.ts +66 -0
  154. package/src/mcp/resources.ts +98 -0
  155. package/src/mcp/safety.ts +119 -0
  156. package/src/mcp/server.ts +225 -0
  157. package/src/mcp/tools.ts +657 -0
  158. package/src/mcp.ts +33 -0
  159. package/src/protocols/AGENTS.md +14 -0
  160. package/src/protocols/adapter.ts +866 -0
  161. package/src/protocols/btest-session.ts +1493 -0
  162. package/src/protocols/btest.ts +447 -0
  163. package/src/protocols/ec-srp5.ts +466 -0
  164. package/src/protocols/index.ts +118 -0
  165. package/src/protocols/mac-telnet-console.ts +655 -0
  166. package/src/protocols/mac-telnet.ts +1299 -0
  167. package/src/protocols/mtwei.ts +47 -0
  168. package/src/protocols/native-api.ts +796 -0
  169. package/src/protocols/sftp.ts +427 -0
  170. package/src/protocols/ssh.ts +297 -0
  171. package/src/resolver/cdb.ts +580 -0
  172. package/src/resolver/comment-kv.ts +360 -0
  173. package/src/resolver/index.ts +74 -0
  174. package/src/resolver/mac.ts +227 -0
  175. package/src/resolver/settings.ts +228 -0
  176. package/src/resolver/target.ts +442 -0
  177. package/src/retrieve-fanout.ts +533 -0
  178. package/src/retrieve.ts +1215 -0
  179. package/src/terminal.ts +444 -0
  180. package/src/transfer.ts +1554 -0
  181. package/src/tui.ts +22 -0
  182. package/src/webproxy.ts +16 -0
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Grounded RouterOS error vocabulary.
3
+ *
4
+ * RouterOS surfaces faults as plain strings. Over REST those strings arrive in
5
+ * the HTTP `detail` field (HTTP >=400); over the native API they arrive as the
6
+ * `!trap` `message` attribute. Grounded on CHR 7.23, the two transports carry
7
+ * the **same** text for the same fault, so one ordered rule table maps both.
8
+ *
9
+ * `mapRouterOsError` is the single place that turns a raw RouterOS string into a
10
+ * typed `CentrsError`. New groundings are added by appending a rule to
11
+ * `routerOsErrorRules`; the matcher is ordered, first-match-wins. Every mapping
12
+ * preserves the original RouterOS string in `context.detail` so nothing is lost.
13
+ *
14
+ * The authoritative vocabulary is the live router's own strings (see
15
+ * `docs/CONSTITUTION.md`, "Error model"). Ground new mappings on CHR evidence,
16
+ * not on assumption.
17
+ */
18
+
19
+ import {
20
+ CentrsError,
21
+ type CentrsErrorCode,
22
+ type RouterOsErrorPosition,
23
+ } from "../errors.ts";
24
+
25
+ /**
26
+ * Matches RouterOS's `(line N column M)` source location. Not anchored — the
27
+ * console embeds it mid-string (e.g. `(evl bad parameter no-such-arg (line 1
28
+ * column 28) /ip/address/add)`), so the location is found wherever it appears.
29
+ */
30
+ const ROUTEROS_POSITION_RE = /\(line (\d+) column (\d+)\)/i;
31
+
32
+ /**
33
+ * Extract RouterOS's `(line N column M)` parse position from a raw fault string,
34
+ * or `undefined` when absent. The console `:parse` rejection carries it — either
35
+ * bare (`bad parameter address (line 1 column 35)`) or embedded in the parsed
36
+ * `(evl … (line N column M) /path)` form; REST/native `unknown parameter` strings
37
+ * do not. `column` is RouterOS's authoritative 1-based **byte** offset — see
38
+ * {@link RouterOsErrorPosition}.
39
+ */
40
+ export function parseRouterOsPosition(
41
+ text: string,
42
+ ): RouterOsErrorPosition | undefined {
43
+ const match = text.match(ROUTEROS_POSITION_RE);
44
+ if (!match) {
45
+ return undefined;
46
+ }
47
+ const line = Number.parseInt(match[1] ?? "", 10);
48
+ const column = Number.parseInt(match[2] ?? "", 10);
49
+ if (!Number.isFinite(line) || !Number.isFinite(column)) {
50
+ return undefined;
51
+ }
52
+ return { line, column };
53
+ }
54
+
55
+ /** Transport that produced the raw RouterOS string. */
56
+ export type RouterOsErrorTransport =
57
+ | "rest-api"
58
+ | "native-api"
59
+ | "mac-telnet"
60
+ | "ssh";
61
+
62
+ /** Options that refine how a raw RouterOS string is mapped. */
63
+ export interface MapRouterOsErrorOptions {
64
+ /** Transport the string came from; selects the catch-all code. */
65
+ transport?: RouterOsErrorTransport;
66
+ /** HTTP status, when the string came from a REST `detail` body. */
67
+ httpStatus?: number;
68
+ /** Extra context merged into the resulting error (caller wins). */
69
+ context?: Record<string, unknown>;
70
+ }
71
+
72
+ /** What a rule's `build` step contributes to the resulting error. */
73
+ export interface RouterOsErrorRuleResult {
74
+ summary: string;
75
+ remediation: string;
76
+ context?: Record<string, unknown>;
77
+ }
78
+
79
+ /**
80
+ * One ordered grounding rule. `test` is matched (case-insensitively) against the
81
+ * trimmed RouterOS string; the first rule whose `test` matches wins and its
82
+ * `build` produces the normalized error fields.
83
+ */
84
+ export interface RouterOsErrorRule {
85
+ /** Normalized `routeros/*` code this rule yields. */
86
+ code: CentrsErrorCode;
87
+ /** One-line human description of the fault class (used for docs/tests). */
88
+ description: string;
89
+ /** Pattern matched against the trimmed RouterOS string. */
90
+ test: RegExp;
91
+ /** Builds the normalized error fields from the regex match and raw string. */
92
+ build: (match: RegExpMatchArray, raw: string) => RouterOsErrorRuleResult;
93
+ }
94
+
95
+ function cleanToken(token: string): string {
96
+ return token.replace(/^["'`]+|["'`.,;:]+$/g, "");
97
+ }
98
+
99
+ /**
100
+ * Ordered RouterOS error groundings (CHR 7.23). First match wins. Append new
101
+ * rules here; keep the most specific patterns above broader ones.
102
+ */
103
+ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
104
+ {
105
+ code: "routeros/unknown-path",
106
+ description:
107
+ "RouterOS did not recognize the command path or menu item ('no such ...').",
108
+ test: /no such command prefix|no such item|no such entry/i,
109
+ build: (_match, raw) => ({
110
+ summary: `RouterOS does not recognize the path: ${raw.trim()}`,
111
+ remediation:
112
+ "Check the slash-prefixed RouterOS path against the device's command tree (use `--list-attributes` or `--no-validate` to narrow the mismatch).",
113
+ }),
114
+ },
115
+ {
116
+ code: "routeros/unknown-attribute",
117
+ description: "RouterOS rejected an unknown parameter/attribute name.",
118
+ // REST/native say "unknown parameter <name>"; the interactive console
119
+ // (mac-telnet) says "bad parameter <name> (line N column M)" for the same
120
+ // fault. Grounded on CHR 7.23.1.
121
+ test: /(?:unknown|bad) parameter\s+(\S+)/i,
122
+ build: (match) => {
123
+ const parameter = cleanToken(match[1] ?? "");
124
+ return {
125
+ summary: `RouterOS does not recognize the parameter "${parameter}".`,
126
+ remediation:
127
+ "Remove or rename the attribute; use `--list-attributes` to see the parameters this RouterOS path accepts.",
128
+ context: { parameter },
129
+ };
130
+ },
131
+ },
132
+ {
133
+ code: "routeros/unknown-path",
134
+ description:
135
+ "RouterOS console rejected the command word ('bad command name …').",
136
+ // Console form (mac-telnet) of an unrecognized command/path word.
137
+ test: /bad command name/i,
138
+ build: (_match, raw) => ({
139
+ summary: `RouterOS does not recognize the command: ${raw.trim()}`,
140
+ remediation:
141
+ "Check the slash-prefixed RouterOS path/command against the device's command tree.",
142
+ }),
143
+ },
144
+ {
145
+ code: "routeros/invalid-value",
146
+ description: "RouterOS rejected the value supplied for an argument.",
147
+ test: /invalid value (?:for argument|of)\s+(\S+)/i,
148
+ build: (match) => {
149
+ const argument = cleanToken(match[1] ?? "");
150
+ return {
151
+ summary: `RouterOS rejected the value supplied for "${argument}".`,
152
+ remediation:
153
+ "Supply a value that matches the argument's expected type/format for this RouterOS path.",
154
+ context: { argument },
155
+ };
156
+ },
157
+ },
158
+ {
159
+ code: "routeros/session-closed",
160
+ description:
161
+ "RouterOS closed the REST session before the request completed (60-second cap).",
162
+ test: /session closed/i,
163
+ build: () => ({
164
+ summary: "RouterOS closed the session before the request completed.",
165
+ remediation:
166
+ "RouterOS enforces a 60-second hard timeout on REST sessions; reduce the scope of the request or choose a path that completes within that ceiling.",
167
+ }),
168
+ },
169
+ {
170
+ code: "routeros/command-failed",
171
+ description:
172
+ "Generic RouterOS command failure carrying a `failure: <msg>` string.",
173
+ test: /^\s*failure:\s*(.+?)\s*$/i,
174
+ build: (match) => {
175
+ const failure = (match[1] ?? "").trim();
176
+ return {
177
+ summary: `RouterOS command failed: ${failure}`,
178
+ remediation:
179
+ "Inspect the RouterOS failure message, then adjust the path, attributes, or request shape accordingly.",
180
+ context: { failure },
181
+ };
182
+ },
183
+ },
184
+ {
185
+ code: "routeros/command-failed",
186
+ description:
187
+ "RouterOS /execute as-string returned a script :error value with source location.",
188
+ test: /^\s*(.+?)\s*\(:error; line \d+\)\s*$/i,
189
+ build: (match) => {
190
+ const failure = (match[1] ?? "").trim();
191
+ return {
192
+ summary: `RouterOS command failed: ${failure}`,
193
+ remediation:
194
+ "Inspect the RouterOS failure message, then adjust the script or request shape accordingly.",
195
+ context: { failure },
196
+ };
197
+ },
198
+ },
199
+ {
200
+ // Bare path-shaped `<path> not found` (no `failure:` prefix). Placed AFTER
201
+ // `routeros/command-failed` so a `failure: <object> not found` command
202
+ // rejection is classified as a command failure, not a path mismatch.
203
+ code: "routeros/unknown-path",
204
+ description: "RouterOS reported a path-shaped 'not found'.",
205
+ test: /not found/i,
206
+ build: (_match, raw) => ({
207
+ summary: `RouterOS does not recognize the path: ${raw.trim()}`,
208
+ remediation:
209
+ "Check the slash-prefixed RouterOS path against the device's command tree (use `--list-attributes` or `--no-validate` to narrow the mismatch).",
210
+ }),
211
+ },
212
+ ];
213
+
214
+ /**
215
+ * Maps a raw RouterOS error string to a normalized {@link CentrsError}.
216
+ *
217
+ * Matching is case-insensitive and tolerant of leading/trailing whitespace and
218
+ * an optional `failure: ` prefix. When no rule matches, the catch-all depends on
219
+ * the transport: native API yields `routeros/api-trap`, REST yields
220
+ * `routeros/request-failed`. The original string is always preserved in
221
+ * `context.detail` (and `cause` for the catch-all).
222
+ */
223
+ export function mapRouterOsError(
224
+ raw: string,
225
+ opts: MapRouterOsErrorOptions = {},
226
+ ): CentrsError {
227
+ const trimmed = raw.trim();
228
+ const position = parseRouterOsPosition(raw);
229
+ const baseContext: Record<string, unknown> = {
230
+ detail: raw,
231
+ ...(opts.httpStatus !== undefined ? { httpStatus: opts.httpStatus } : {}),
232
+ };
233
+
234
+ for (const rule of routerOsErrorRules) {
235
+ const match = trimmed.match(rule.test);
236
+ if (!match) {
237
+ continue;
238
+ }
239
+ const result = rule.build(match, raw);
240
+ return new CentrsError({
241
+ code: rule.code,
242
+ summary: result.summary,
243
+ remediation: result.remediation,
244
+ context: { ...baseContext, ...result.context, ...opts.context },
245
+ ...(position ? { position } : {}),
246
+ causeData: raw,
247
+ });
248
+ }
249
+
250
+ const catchAllCode: CentrsErrorCode =
251
+ opts.transport === "rest-api" ||
252
+ opts.transport === "mac-telnet" ||
253
+ opts.transport === "ssh"
254
+ ? "routeros/request-failed"
255
+ : "routeros/api-trap";
256
+
257
+ return new CentrsError({
258
+ code: catchAllCode,
259
+ summary: trimmed
260
+ ? `RouterOS reported an error: ${trimmed}`
261
+ : "RouterOS reported an unspecified error.",
262
+ remediation:
263
+ "Inspect the original RouterOS message; the command word, path, or an attribute is likely invalid.",
264
+ context: { ...baseContext, ...opts.context },
265
+ ...(position ? { position } : {}),
266
+ causeData: raw,
267
+ });
268
+ }
@@ -0,0 +1,11 @@
1
+ # Data Source Rules
2
+
3
+ Follow `docs/CONSTITUTION.md` (identity / CDB sections) and
4
+ `commands/devices/README.md`.
5
+
6
+ - Keep device source provenance visible: explicit input, environment, SQLite cache, WinBox CDB, The Dude `dude.db`, and MNDP are not equally authoritative.
7
+ - Do not silently persist credentials or discovered devices. Persistence must be controlled by a typed setting and explained in interactive output.
8
+ - MNDP is a hint source. Lack of MNDP data must not make a device invalid.
9
+ - Prefer shared SQLite infrastructure over one database per feature unless a spec justifies separation.
10
+ - Reuse tikoci `donny` knowledge for Dude database structures and related Nova/TLV encoding.
11
+ - `mndp.ts` TypeDoc should document the MNDP TLV structure and attribute values inline (type codes, field meanings), so the generated API docs are self-sufficient and centrs consumers do not have to depend on the external `routeros-mndp` skill for the wire format. The skill stays canonical; the typedoc must stand alone.
File without changes
@@ -0,0 +1,132 @@
1
+ /**
2
+ * In-memory MNDP neighbor cache with a TTL/expiry policy.
3
+ *
4
+ * MNDP devices announce themselves on a ~30/60s cycle (and reply within
5
+ * seconds to a refresh), so the natural shape is a short-lived cache keyed by
6
+ * MAC that forgets neighbors which stop announcing. This is a process-local
7
+ * cache only — the CDB remains the single durable datastore (see
8
+ * `docs/CONSTITUTION.md`); nothing here is persisted.
9
+ *
10
+ * TTL decision (folded into `commands/discover/README.md`): entries expire
11
+ * {@link MNDP_CACHE_DEFAULT_TTL_MS} (180s = three announce cycles) after they
12
+ * were last seen. Three cycles tolerates a couple of dropped broadcasts before
13
+ * a still-present neighbor is dropped. The clock is injectable so expiry is
14
+ * deterministic in tests.
15
+ */
16
+
17
+ import { type MndpNeighbor, mndpNeighborKey } from "./mndp.ts";
18
+
19
+ /** Default neighbor TTL: three ~60s announce cycles. */
20
+ export const MNDP_CACHE_DEFAULT_TTL_MS = 180_000;
21
+
22
+ export interface MndpCacheEntry {
23
+ /** Cache key (lower-cased MAC, or a synthetic key for MAC-less packets). */
24
+ key: string;
25
+ /** Most recent decoded announcement for this key. */
26
+ neighbor: MndpNeighbor;
27
+ /** Epoch ms when this key was first observed in the cache's lifetime. */
28
+ firstSeenAt: number;
29
+ /** Epoch ms when this key was last observed; drives expiry. */
30
+ lastSeenAt: number;
31
+ }
32
+
33
+ export interface MndpCacheOptions {
34
+ /** Time-to-live in ms after `lastSeenAt`. Default 180000. */
35
+ ttlMs?: number;
36
+ /** Clock injection (epoch ms). Defaults to `Date.now`. */
37
+ now?: () => number;
38
+ }
39
+
40
+ /** A neighbor cache that expires entries TTL ms after they were last seen. */
41
+ export class MndpCache {
42
+ private readonly ttlMs: number;
43
+ private readonly clock: () => number;
44
+ private readonly store = new Map<string, MndpCacheEntry>();
45
+
46
+ constructor(options: MndpCacheOptions = {}) {
47
+ this.ttlMs = options.ttlMs ?? MNDP_CACHE_DEFAULT_TTL_MS;
48
+ this.clock = options.now ?? Date.now;
49
+ }
50
+
51
+ /** TTL this cache enforces, in milliseconds. */
52
+ get timeToLiveMs(): number {
53
+ return this.ttlMs;
54
+ }
55
+
56
+ private isExpired(entry: MndpCacheEntry, at: number): boolean {
57
+ return at - entry.lastSeenAt >= this.ttlMs;
58
+ }
59
+
60
+ /**
61
+ * Record a freshly decoded neighbor. Upserts by {@link mndpNeighborKey},
62
+ * refreshing `lastSeenAt` and replacing the stored announcement. Returns the
63
+ * resulting entry.
64
+ */
65
+ observe(neighbor: MndpNeighbor, at: number = this.clock()): MndpCacheEntry {
66
+ const key = mndpNeighborKey(neighbor);
67
+ const existing = this.store.get(key);
68
+ const firstSeenAt =
69
+ existing && !this.isExpired(existing, at) ? existing.firstSeenAt : at;
70
+ const entry: MndpCacheEntry = {
71
+ key,
72
+ neighbor,
73
+ firstSeenAt,
74
+ lastSeenAt: at,
75
+ };
76
+ this.store.set(key, entry);
77
+ return entry;
78
+ }
79
+
80
+ /** Look up a single neighbor by MAC (case-insensitive); skips expired ones. */
81
+ get(mac: string, at: number = this.clock()): MndpCacheEntry | undefined {
82
+ const entry = this.store.get(mac.toLowerCase());
83
+ if (!entry || this.isExpired(entry, at)) {
84
+ return undefined;
85
+ }
86
+ return entry;
87
+ }
88
+
89
+ /** Drop expired entries; returns the number removed. */
90
+ prune(at: number = this.clock()): number {
91
+ let removed = 0;
92
+ for (const [key, entry] of this.store) {
93
+ if (this.isExpired(entry, at)) {
94
+ this.store.delete(key);
95
+ removed += 1;
96
+ }
97
+ }
98
+ return removed;
99
+ }
100
+
101
+ /** Number of non-expired entries currently held. */
102
+ size(at: number = this.clock()): number {
103
+ let count = 0;
104
+ for (const entry of this.store.values()) {
105
+ if (!this.isExpired(entry, at)) {
106
+ count += 1;
107
+ }
108
+ }
109
+ return count;
110
+ }
111
+
112
+ /** Live entries sorted by cache key (deterministic ordering). */
113
+ entries(at: number = this.clock()): readonly MndpCacheEntry[] {
114
+ const live: MndpCacheEntry[] = [];
115
+ for (const entry of this.store.values()) {
116
+ if (!this.isExpired(entry, at)) {
117
+ live.push(entry);
118
+ }
119
+ }
120
+ return live.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
121
+ }
122
+
123
+ /** Live neighbors sorted by cache key (deterministic ordering). */
124
+ neighbors(at: number = this.clock()): readonly MndpNeighbor[] {
125
+ return this.entries(at).map((entry) => entry.neighbor);
126
+ }
127
+
128
+ /** Remove every entry. */
129
+ clear(): void {
130
+ this.store.clear();
131
+ }
132
+ }