@tikoci/centrs 0.1.1 → 0.1.3

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 (95) hide show
  1. package/CHANGELOG.md +107 -2
  2. package/GLOSSARY.txt +87 -0
  3. package/README.md +25 -9
  4. package/SECURITY.md +3 -3
  5. package/commands/AGENTS.md +3 -2
  6. package/commands/api/AGENTS.md +212 -0
  7. package/commands/api/README.md +177 -0
  8. package/commands/api/examples.md +428 -0
  9. package/commands/btest/README.md +56 -31
  10. package/commands/btest/examples.md +35 -10
  11. package/commands/check/README.md +371 -53
  12. package/commands/check/examples.md +390 -0
  13. package/commands/devices/README.md +177 -37
  14. package/commands/devices/examples.md +148 -0
  15. package/commands/execute/README.md +48 -7
  16. package/commands/execute/examples.md +90 -6
  17. package/commands/retrieve/README.md +37 -51
  18. package/commands/retrieve/examples.md +40 -9
  19. package/commands/settings/README.md +286 -22
  20. package/commands/settings/examples.md +408 -0
  21. package/commands/terminal/README.md +6 -0
  22. package/commands/transfer/README.md +32 -2
  23. package/commands/transfer/examples.md +66 -0
  24. package/docs/CONSTITUTION.md +40 -15
  25. package/docs/MATRIX.md +69 -142
  26. package/docs/errors/README.md +2 -1
  27. package/docs/errors/cdb/empty-selection.md +19 -0
  28. package/docs/errors/input/incomplete-gps.md +9 -0
  29. package/docs/errors/input/invalid-altitude.md +9 -0
  30. package/docs/errors/input/invalid-bbox.md +9 -0
  31. package/docs/errors/input/invalid-coordinate.md +9 -0
  32. package/docs/errors/input/invalid-radius.md +9 -0
  33. package/docs/errors/internal/settings-failed.md +9 -0
  34. package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
  35. package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
  36. package/docs/errors/settings/consequential-value.md +26 -0
  37. package/docs/errors/settings/invalid-boolean.md +21 -3
  38. package/docs/errors/settings/invalid-format.md +18 -3
  39. package/docs/errors/settings/invalid-integer.md +18 -3
  40. package/docs/errors/settings/invalid-timeout.md +20 -3
  41. package/docs/errors/settings/invalid-via.md +22 -3
  42. package/docs/errors/settings/reserved-key.md +29 -0
  43. package/docs/errors/settings/skip-env-file-active.md +25 -0
  44. package/docs/errors/settings/unknown-key.md +25 -0
  45. package/docs/errors/usage/fanout-not-supported.md +16 -0
  46. package/docs/errors/usage/invalid-method.md +23 -0
  47. package/package.json +9 -9
  48. package/src/api-fanout.ts +454 -0
  49. package/src/api.ts +1627 -0
  50. package/src/btest.ts +61 -1
  51. package/src/cli/api.ts +660 -0
  52. package/src/cli/btest.ts +20 -2
  53. package/src/cli/common.ts +75 -0
  54. package/src/cli/devices.ts +235 -6
  55. package/src/cli/discover.ts +3 -1
  56. package/src/cli/execute.ts +107 -6
  57. package/src/cli/mcp.ts +8 -1
  58. package/src/cli/retrieve.ts +135 -90
  59. package/src/cli/selection.ts +200 -0
  60. package/src/cli/settings.ts +286 -0
  61. package/src/cli/terminal.ts +22 -7
  62. package/src/cli/transfer.ts +171 -47
  63. package/src/cli.ts +29 -0
  64. package/src/core/error-catalog.ts +69 -0
  65. package/src/core/fanout.ts +301 -0
  66. package/src/core/inspect.ts +170 -0
  67. package/src/devices.ts +212 -20
  68. package/src/discover.ts +7 -2
  69. package/src/execute-fanout.ts +438 -0
  70. package/src/execute.ts +134 -70
  71. package/src/index.ts +166 -9
  72. package/src/mcp/config.ts +12 -2
  73. package/src/mcp/tools.ts +137 -2
  74. package/src/protocols/adapter.ts +357 -9
  75. package/src/protocols/btest-session.ts +279 -46
  76. package/src/protocols/btest.ts +6 -2
  77. package/src/protocols/index.ts +11 -5
  78. package/src/protocols/native-api.ts +223 -30
  79. package/src/resolver/cdb.ts +10 -2
  80. package/src/resolver/comment-kv.ts +58 -4
  81. package/src/resolver/config-file.ts +136 -0
  82. package/src/resolver/facts.ts +78 -0
  83. package/src/resolver/geo.ts +453 -0
  84. package/src/resolver/index.ts +46 -0
  85. package/src/resolver/mac.ts +12 -3
  86. package/src/resolver/selection.ts +304 -0
  87. package/src/resolver/settings.ts +54 -5
  88. package/src/resolver/target.ts +20 -2
  89. package/src/retrieve-fanout.ts +232 -268
  90. package/src/retrieve.ts +69 -94
  91. package/src/settings.ts +1078 -0
  92. package/src/terminal.ts +11 -1
  93. package/src/transfer-fanout.ts +527 -0
  94. package/src/transfer.ts +61 -11
  95. package/commands/stream/README.md +0 -70
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Target-selection grammar (the full fan-out selector).
3
+ *
4
+ * Generalizes `expandCdbGroup` (a single `--group`) into the constitution's
5
+ * one grammar for every non-terminal command: `<router>` positionals (one or
6
+ * more), repeatable `--group`, `--all` (every CDB record), `--default` (the
7
+ * reserved `__default__` record), and repeatable `--where <attr>=<value>` (a
8
+ * device-class selector over CDB facts + core fields). The union is de-duped by
9
+ * CDB record index (literals by normalized input) and ordered by record index so
10
+ * repeated runs diff cleanly (see `docs/CONSTITUTION.md`, Target selection).
11
+ *
12
+ * Boundaries this module is careful about:
13
+ * - The CDB is loaded + decrypted ONCE for the whole expansion.
14
+ * - A positional is matched against the already-loaded entries with
15
+ * {@link showDevice} (the identity matcher), NOT `resolveCdb` — `resolveCdb`
16
+ * re-loads the CDB and, when a `__default__` record exists, returns a
17
+ * synthetic resolution for ANY unmatched target, which would collide two
18
+ * distinct ad-hoc literals on the default record's index. A non-matching
19
+ * positional stays a literal (de-duped by input); default credentials are
20
+ * applied later by the per-target resolver, not here.
21
+ * - `--all` / `--group` / `--where` never include the `__default__` record (it
22
+ * is a credential-fallback record, not a fleet member). Only `--default` (or
23
+ * an explicit `__default__` positional) selects it; the per-target resolver
24
+ * guards it from being dialed as the literal hostname `"__default__"`.
25
+ * - `allowAdhoc` is the surface policy: CLI passes `true` (literal positionals
26
+ * allowed); MCP passes `false`, so an unregistered literal is rejected with
27
+ * `cdb/target-not-registered` and selection cannot widen the CDB allowlist.
28
+ */
29
+
30
+ import {
31
+ type LoadedCdb,
32
+ loadCdb,
33
+ resolveDevicesSettings,
34
+ showDevice,
35
+ } from "../devices.ts";
36
+ import { CentrsError } from "../errors.ts";
37
+ import {
38
+ type CdbResolution,
39
+ DEFAULT_RECORD_TARGET,
40
+ type ResolverWarning,
41
+ resolutionFromEntry,
42
+ } from "./cdb.ts";
43
+ import { entryFacts, entryLocation, matchesWhere } from "./facts.ts";
44
+ import {
45
+ type BboxPredicate,
46
+ matchesBbox,
47
+ matchesNear,
48
+ type NearPredicate,
49
+ } from "./geo.ts";
50
+
51
+ /** One `--where attr=value` device-class clause (exact-match, AND-combined). */
52
+ export interface SelectionWhereClause {
53
+ key: string;
54
+ value: string;
55
+ }
56
+
57
+ /** The parsed target selection a command hands to {@link expandCdbSelection}. */
58
+ export interface TargetSelection {
59
+ /** Literal/lookup positionals (NOT operation args — the caller splits those). */
60
+ positionals: readonly string[];
61
+ /** Repeatable `--group <name>`. */
62
+ groups: readonly string[];
63
+ /** `--all`: every CDB record except `__default__`. */
64
+ all: boolean;
65
+ /** `--default`: the reserved `__default__` record. */
66
+ default: boolean;
67
+ /** Repeatable `--where attr=value`, AND-combined. */
68
+ where: readonly SelectionWhereClause[];
69
+ /** `--near <lat>,<lon>,<radius>`: a geo union predicate (device GPS within radius). */
70
+ near?: NearPredicate;
71
+ /** `--bbox <south>,<west>,<north>,<east>`: a geo union predicate (device GPS in box). */
72
+ bbox?: BboxPredicate;
73
+ }
74
+
75
+ export interface CdbSelectionResolveInput {
76
+ cdbFile?: string;
77
+ cdbPassword?: string;
78
+ /** CLI: true (literal positionals allowed). MCP: false (CDB is the allowlist). */
79
+ allowAdhoc: boolean;
80
+ }
81
+
82
+ /** One resolved selection member: a CDB record or an ad-hoc literal target. */
83
+ export type CdbSelectionMember =
84
+ | { kind: "cdb"; resolution: CdbResolution; recordIndex: number }
85
+ | { kind: "literal"; input: string };
86
+
87
+ export interface CdbSelectionExpansion {
88
+ /** Members ordered by record index; literals appended in positional order. */
89
+ targets: readonly CdbSelectionMember[];
90
+ warnings: readonly ResolverWarning[];
91
+ /** True when nothing matched (an empty/unknown selection is not an error). */
92
+ empty: boolean;
93
+ }
94
+
95
+ /** True when a resolved target is the reserved credential-fallback record. */
96
+ export function isDefaultRecordTarget(target: string): boolean {
97
+ return target === DEFAULT_RECORD_TARGET;
98
+ }
99
+
100
+ const ENV_CDB_FILE = "CENTRS_CDB_FILE";
101
+ const ENV_CDB_PASSWORD = "CENTRS_CDB_PASSWORD";
102
+
103
+ /**
104
+ * Expand a {@link TargetSelection} into N resolved members. The CDB is loaded
105
+ * once; selectors and positionals union into a record-index set (plus literals),
106
+ * de-duped, ordered by record index. Empty/unknown selection → `empty: true`
107
+ * with a warning, never an error. A decrypt/parse failure throws (the fan-out's
108
+ * outer envelope reports it).
109
+ *
110
+ * When no CDB exists and none was requested explicitly, the expansion proceeds
111
+ * over an empty registry — so a pure-literal selection (`api r1 r2 …`) still
112
+ * fans out (mirroring single-target `resolveCdb`, which treats a `<router>` as a
113
+ * literal host when no CDB is present). An explicit `--cdb-file` that is missing
114
+ * still throws.
115
+ */
116
+ export async function expandCdbSelection(
117
+ selection: TargetSelection,
118
+ input: CdbSelectionResolveInput,
119
+ env: Record<string, string | undefined>,
120
+ config: Record<string, string | undefined> = {},
121
+ ): Promise<CdbSelectionExpansion> {
122
+ const settings = resolveDevicesSettings({
123
+ cdbFile: input.cdbFile,
124
+ cdbPassword: input.cdbPassword,
125
+ env,
126
+ config,
127
+ });
128
+ const explicitCdb =
129
+ input.cdbFile !== undefined ||
130
+ input.cdbPassword !== undefined ||
131
+ env[ENV_CDB_FILE] !== undefined ||
132
+ env[ENV_CDB_PASSWORD] !== undefined ||
133
+ config[ENV_CDB_FILE] !== undefined;
134
+ const emptyCdb = (): LoadedCdb => ({
135
+ entries: [],
136
+ warnings: [],
137
+ encrypted: false,
138
+ settings,
139
+ });
140
+ let cdb: LoadedCdb;
141
+ // Mirror `resolveCdb`: an absent, non-explicit default CDB is an empty registry,
142
+ // NOT an error and NOT a freshly created file (`loadCdb` would create one).
143
+ if (!explicitCdb && !(await Bun.file(settings.cdbFile.value).exists())) {
144
+ cdb = emptyCdb();
145
+ } else {
146
+ try {
147
+ cdb = await loadCdb({
148
+ cdbFile: input.cdbFile,
149
+ cdbPassword: input.cdbPassword,
150
+ env,
151
+ config,
152
+ });
153
+ } catch (error) {
154
+ if (
155
+ !explicitCdb &&
156
+ error instanceof CentrsError &&
157
+ error.code === "cdb/not-found"
158
+ ) {
159
+ cdb = emptyCdb();
160
+ } else {
161
+ throw error;
162
+ }
163
+ }
164
+ }
165
+ const warnings: ResolverWarning[] = cdb.warnings.map((warning) => ({
166
+ code: warning.code,
167
+ message: warning.message,
168
+ context: warning.context,
169
+ }));
170
+
171
+ const groupSet = new Set(selection.groups);
172
+ const indices = new Set<number>();
173
+
174
+ for (let index = 0; index < cdb.entries.length; index += 1) {
175
+ const entry = cdb.entries[index];
176
+ if (!entry || entry.target === DEFAULT_RECORD_TARGET) {
177
+ continue;
178
+ }
179
+ if (selection.all) {
180
+ indices.add(index);
181
+ continue;
182
+ }
183
+ if (groupSet.size > 0 && groupSet.has(entry.group)) {
184
+ indices.add(index);
185
+ continue;
186
+ }
187
+ if (
188
+ selection.where.length > 0 &&
189
+ matchesWhere(
190
+ entryFacts(entry.comment, entry.target, entry.group),
191
+ selection.where,
192
+ )
193
+ ) {
194
+ indices.add(index);
195
+ continue;
196
+ }
197
+ // Geo predicates are additional OR-union terms (like `--group`/`--where`):
198
+ // an entry matches when its GPS falls in `--near`'s radius OR `--bbox`.
199
+ // Geo-less entries carry no location, so they never match (not an error).
200
+ if (selection.near !== undefined || selection.bbox !== undefined) {
201
+ const loc = entryLocation(entry.comment);
202
+ if (
203
+ loc !== undefined &&
204
+ ((selection.near !== undefined && matchesNear(loc, selection.near)) ||
205
+ (selection.bbox !== undefined && matchesBbox(loc, selection.bbox)))
206
+ ) {
207
+ indices.add(index);
208
+ }
209
+ }
210
+ }
211
+
212
+ if (selection.default) {
213
+ const defaultIndex = cdb.entries.findIndex(
214
+ (entry) => entry?.target === DEFAULT_RECORD_TARGET,
215
+ );
216
+ if (defaultIndex >= 0) {
217
+ indices.add(defaultIndex);
218
+ } else {
219
+ warnings.push({
220
+ code: "cdb/empty-selection",
221
+ message: "No `__default__` record exists to select with `--default`.",
222
+ context: { selector: "--default" },
223
+ });
224
+ }
225
+ }
226
+
227
+ const literals: CdbSelectionMember[] = [];
228
+ const seenLiteral = new Set<string>();
229
+ for (const positional of selection.positionals) {
230
+ try {
231
+ const shown = showDevice({ cdb, target: positional });
232
+ indices.add(shown.data.entry.cdbRecordIndex);
233
+ } catch (error) {
234
+ if (
235
+ error instanceof CentrsError &&
236
+ error.code === "cdb/not-found-target"
237
+ ) {
238
+ if (!input.allowAdhoc) {
239
+ throw new CentrsError({
240
+ code: "cdb/target-not-registered",
241
+ summary: `"${positional}" is not a registered CDB target.`,
242
+ remediation:
243
+ "Register the device with `centrs devices add`, or select registered targets via `--group` / `--where` / `--all`.",
244
+ context: { target: positional },
245
+ });
246
+ }
247
+ const key = positional.trim().toLowerCase();
248
+ if (!seenLiteral.has(key)) {
249
+ seenLiteral.add(key);
250
+ literals.push({ kind: "literal", input: positional });
251
+ }
252
+ continue;
253
+ }
254
+ throw error;
255
+ }
256
+ }
257
+
258
+ const cdbMembers: CdbSelectionMember[] = [...indices]
259
+ .sort((a, b) => a - b)
260
+ .map((index) => ({
261
+ kind: "cdb",
262
+ resolution: resolutionFromEntry(
263
+ cdb.entries[index] as NonNullable<(typeof cdb.entries)[number]>,
264
+ index,
265
+ ),
266
+ recordIndex: index,
267
+ }));
268
+
269
+ const targets = [...cdbMembers, ...literals];
270
+ if (targets.length === 0) {
271
+ const groupOnly =
272
+ groupSet.size > 0 &&
273
+ selection.positionals.length === 0 &&
274
+ !selection.all &&
275
+ !selection.default &&
276
+ selection.where.length === 0 &&
277
+ selection.near === undefined &&
278
+ selection.bbox === undefined;
279
+ warnings.push(
280
+ groupOnly
281
+ ? {
282
+ code: "cdb/empty-group",
283
+ message: `No CDB entries matched group ${[...groupSet].map((g) => `"${g}"`).join(", ")}.`,
284
+ context: { groups: [...groupSet] },
285
+ }
286
+ : {
287
+ code: "cdb/empty-selection",
288
+ message: "The target selection matched no CDB records.",
289
+ context: {
290
+ groups: [...groupSet],
291
+ all: selection.all,
292
+ default: selection.default,
293
+ where: selection.where.length,
294
+ near: selection.near !== undefined,
295
+ bbox: selection.bbox !== undefined,
296
+ positionals: selection.positionals.length,
297
+ },
298
+ },
299
+ );
300
+ return { targets: [], warnings, empty: true };
301
+ }
302
+
303
+ return { targets, warnings, empty: false };
304
+ }
@@ -48,6 +48,23 @@ export interface ResolvedSetting<T> {
48
48
  /** A pre-coerced comment-kv override layer (sits below env, above config). */
49
49
  export type CommentKvLayer<T> = ResolvedSetting<T> | undefined;
50
50
 
51
+ /**
52
+ * Env keys that must never flow through the `config` (`centrs.env`) tier,
53
+ * even if a user hand-adds them to the file — credential-shaped or
54
+ * self-referential keys. Kept here (not in `src/settings.ts`, which owns the
55
+ * human-facing refused-key registry with its remediation text) so
56
+ * `resolver/config-file.ts` can filter on it without a resolver → top-level
57
+ * command import cycle; `test/unit/settings-registry.test.ts` asserts this
58
+ * list stays in sync with `settingsRefusedKeys`' `envKey` values.
59
+ */
60
+ export const REFUSED_CONFIG_ENV_KEYS: readonly string[] = [
61
+ "CENTRS_PASSWORD",
62
+ "CENTRS_USERNAME",
63
+ "CENTRS_CDB_PASSWORD",
64
+ "CENTRS_SKIP_ENV_FILE",
65
+ "CENTRS_RUN_FAST_INTEGRATION",
66
+ ];
67
+
51
68
  export function toCoreSource(source: ResolverSettingSource): CoreSettingSource {
52
69
  switch (source.kind) {
53
70
  case "explicit":
@@ -61,9 +78,9 @@ export function toCoreSource(source: ResolverSettingSource): CoreSettingSource {
61
78
  /**
62
79
  * Resolve a string-valued setting across the precedence ladder.
63
80
  *
64
- * Order checked (highest first): explicit/cli → env → comment-kv → default.
65
- * `normalize` runs only against explicit/env/default raw strings; the
66
- * comment-kv layer is already coerced to `T`.
81
+ * Order checked (highest first): explicit/cli → env → comment-kv → config →
82
+ * default. `normalize` runs against explicit/env/config/default raw strings;
83
+ * the comment-kv layer is already coerced to `T`.
67
84
  */
68
85
  export function resolveStringSetting<T = string>(
69
86
  explicit: string | undefined,
@@ -73,6 +90,7 @@ export function resolveStringSetting<T = string>(
73
90
  key: string,
74
91
  normalize?: (value: string) => T,
75
92
  commentKv?: CommentKvLayer<T>,
93
+ config?: Record<string, string | undefined>,
76
94
  ): ResolvedSetting<T> | undefined {
77
95
  if (explicit !== undefined) {
78
96
  return {
@@ -99,6 +117,14 @@ export function resolveStringSetting<T = string>(
99
117
  return commentKv;
100
118
  }
101
119
 
120
+ const configValue = config?.[envName];
121
+ if (configValue !== undefined) {
122
+ return {
123
+ value: normalize ? normalize(configValue) : (configValue as T),
124
+ source: { kind: "config", key: envName },
125
+ };
126
+ }
127
+
102
128
  if (defaultValue !== undefined) {
103
129
  return {
104
130
  value: normalize ? normalize(defaultValue) : (defaultValue as T),
@@ -114,7 +140,7 @@ export function resolveStringSetting<T = string>(
114
140
 
115
141
  /**
116
142
  * Resolve a boolean-valued setting. Order: explicit → env → comment-kv →
117
- * default.
143
+ * config → default.
118
144
  */
119
145
  export function resolveBooleanSetting(
120
146
  explicit: boolean | undefined,
@@ -123,6 +149,7 @@ export function resolveBooleanSetting(
123
149
  defaultValue: boolean,
124
150
  key: string,
125
151
  commentKv?: CommentKvLayer<boolean>,
152
+ config?: Record<string, string | undefined>,
126
153
  ): ResolvedSetting<boolean> {
127
154
  if (explicit !== undefined) {
128
155
  return { value: explicit, source: { kind: "explicit", key } };
@@ -140,12 +167,20 @@ export function resolveBooleanSetting(
140
167
  return commentKv;
141
168
  }
142
169
 
170
+ const configValue = config?.[envName];
171
+ if (configValue !== undefined) {
172
+ return {
173
+ value: parseBoolean(configValue, envName),
174
+ source: { kind: "config", key: envName },
175
+ };
176
+ }
177
+
143
178
  return { value: defaultValue, source: { kind: "default", key } };
144
179
  }
145
180
 
146
181
  /**
147
182
  * Resolve an optional positive-integer setting (no built-in default). Order:
148
- * explicit → env → comment-kv → unset.
183
+ * explicit → env → comment-kv → config → unset.
149
184
  */
150
185
  export function resolveOptionalIntegerSetting(
151
186
  explicit: number | undefined,
@@ -153,6 +188,7 @@ export function resolveOptionalIntegerSetting(
153
188
  envName: string,
154
189
  key: string,
155
190
  commentKv?: CommentKvLayer<number>,
191
+ config?: Record<string, string | undefined>,
156
192
  ): ResolvedSetting<number> | undefined {
157
193
  if (explicit !== undefined) {
158
194
  if (!Number.isInteger(explicit) || explicit <= 0) {
@@ -182,6 +218,19 @@ export function resolveOptionalIntegerSetting(
182
218
  return commentKv;
183
219
  }
184
220
 
221
+ const configValue = config?.[envName];
222
+ if (configValue !== undefined) {
223
+ const parsed = Number.parseInt(configValue, 10);
224
+ if (!Number.isInteger(parsed) || parsed <= 0) {
225
+ throw new CentrsError({
226
+ code: "settings/invalid-integer",
227
+ summary: `${envName} must be a positive integer. Received: ${configValue}`,
228
+ remediation: `Set ${envName} to a positive integer value in centrs.env.`,
229
+ });
230
+ }
231
+ return { value: parsed, source: { kind: "config", key: envName } };
232
+ }
233
+
185
234
  return undefined;
186
235
  }
187
236
 
@@ -84,6 +84,7 @@ export function resolveTarget(
84
84
  env: Record<string, string | undefined>,
85
85
  via: RouterOsProtocol,
86
86
  cdb?: CdbResolution,
87
+ config: Record<string, string | undefined> = {},
87
88
  ): ResolvedTarget {
88
89
  const hostSetting = resolveStringSetting(
89
90
  input.host,
@@ -91,6 +92,9 @@ export function resolveTarget(
91
92
  ENV_HOST,
92
93
  cdb?.target ?? input.targetInput,
93
94
  "host",
95
+ undefined,
96
+ undefined,
97
+ config,
94
98
  );
95
99
 
96
100
  if (!hostSetting || hostSetting.value.trim().length === 0) {
@@ -137,6 +141,9 @@ export function resolveTarget(
137
141
  ENV_HOST,
138
142
  undefined,
139
143
  "host",
144
+ undefined,
145
+ undefined,
146
+ config,
140
147
  );
141
148
  const macPortSetting = resolveOptionalIntegerSetting(
142
149
  input.port,
@@ -144,6 +151,7 @@ export function resolveTarget(
144
151
  ENV_PORT,
145
152
  "port",
146
153
  cdb?.overrides.port,
154
+ config,
147
155
  );
148
156
  // The device identity is the MAC (positional or CDB), NOT --host/CENTRS_HOST,
149
157
  // which are only the UDP delivery endpoint. Attribute meta.target.source to
@@ -217,6 +225,7 @@ export function resolveTarget(
217
225
  ENV_PORT,
218
226
  "port",
219
227
  cdb?.overrides.port,
228
+ config,
220
229
  );
221
230
  const scheme = parsedUrl.protocol === "https:" ? "https" : "http";
222
231
 
@@ -320,7 +329,14 @@ export function resolveAuth(
320
329
  credentials: { username?: string; password?: string; sshKey?: string },
321
330
  env: Record<string, string | undefined>,
322
331
  cdb?: CdbResolution,
332
+ config: Record<string, string | undefined> = {},
323
333
  ): ResolvedAuth {
334
+ // `CENTRS_USERNAME`/`CENTRS_PASSWORD` are deliberately NOT threaded through
335
+ // the `config` tier: `settings` refuses to write either (constitution: the
336
+ // `__default__` CDB record is the credential store), and the config-file
337
+ // loader itself does not surface them even when hand-added to the file —
338
+ // a stronger defense than read-time redaction alone, since a hand-edited
339
+ // credential line in `centrs.env` then can't function as a live source.
324
340
  const username = resolveStringSetting(
325
341
  credentials.username,
326
342
  env,
@@ -328,8 +344,9 @@ export function resolveAuth(
328
344
  undefined,
329
345
  "username",
330
346
  );
331
- // SSH key path: --ssh-key → CENTRS_SSH_KEY → CDB `ssh-key=` comment-kv → unset.
332
- // Path only; the SFTP/SSH transport reads the file, centrs never stores material.
347
+ // SSH key path: --ssh-key → CENTRS_SSH_KEY → CDB `ssh-key=` comment-kv →
348
+ // config → unset. Path only; the SFTP/SSH transport reads the file, centrs
349
+ // never stores material.
333
350
  const sshKey = resolveStringSetting(
334
351
  credentials.sshKey,
335
352
  env,
@@ -338,6 +355,7 @@ export function resolveAuth(
338
355
  "ssh-key",
339
356
  undefined,
340
357
  cdb?.overrides.sshKey,
358
+ config,
341
359
  );
342
360
  const password = resolveStringSetting(
343
361
  credentials.password,