@twinsearth/w2m-dsh-plugin 0.6.3 → 0.8.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +1176 -0
  2. package/HANDOFF.md +173 -0
  3. package/README.md +9 -2
  4. package/bin/w2m-localside.mjs +325 -308
  5. package/bin/w2m-rabbit.mjs +6 -0
  6. package/bin/w2m-stun.mjs +0 -0
  7. package/deploy/mac/mac-self-update.mjs +257 -0
  8. package/deploy/server/README.md +109 -0
  9. package/deploy/server/deploy-w2m.sh +161 -0
  10. package/deploy/server/w2m-localside.service +31 -0
  11. package/deploy/server/w2m-rabbit.service +26 -0
  12. package/deploy/server/w2m-stun.service +26 -0
  13. package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
  14. package/package.json +34 -22
  15. package/src/agent/agent.mjs +24 -2
  16. package/src/chatops/account.mjs +401 -0
  17. package/src/chatops/actions.mjs +227 -0
  18. package/src/chatops/approval-broker.mjs +320 -0
  19. package/src/chatops/auth.mjs +376 -0
  20. package/src/chatops/bridge.mjs +344 -0
  21. package/src/chatops/channel.mjs +192 -0
  22. package/src/chatops/commands.mjs +409 -0
  23. package/src/chatops/config-route.mjs +332 -0
  24. package/src/chatops/device-signing.mjs +218 -0
  25. package/src/chatops/durability.mjs +202 -0
  26. package/src/chatops/file-safety.mjs +282 -0
  27. package/src/chatops/fleet-join.mjs +164 -0
  28. package/src/chatops/fleet-scope.mjs +176 -0
  29. package/src/chatops/fleet-wiring.mjs +258 -0
  30. package/src/chatops/fleet.mjs +272 -0
  31. package/src/chatops/handoff.mjs +258 -0
  32. package/src/chatops/ilink-fleet-bridge.mjs +197 -0
  33. package/src/chatops/ilink-protocol.mjs +165 -0
  34. package/src/chatops/ilink-transport.mjs +168 -0
  35. package/src/chatops/ilink.mjs +423 -0
  36. package/src/chatops/index.mjs +440 -0
  37. package/src/chatops/outbound-queue.mjs +282 -0
  38. package/src/chatops/policy.mjs +356 -0
  39. package/src/chatops/run-command.mjs +308 -0
  40. package/src/plugin/tools.mjs +1 -1
  41. package/src/relay/server.mjs +96 -0
  42. package/src/relay/state.mjs +2279 -2011
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Which devices a request may reach, and which fleet a pairing code admits to (v0.7.6).
3
+ *
4
+ * ## The defect this replaces, in one line
5
+ *
6
+ * `src/relay/state.mjs` L1259-1262:
7
+ *
8
+ * const all = [...this.devices.values()];
9
+ * const targets = Array.isArray(input.target_machines) && input.target_machines.length > 0
10
+ * ? all.filter((d) => input.target_machines.includes(d.machine_id))
11
+ * : all;
12
+ *
13
+ * `targets` is **every device on the relay**. No filter, because until now there was nothing to
14
+ * filter by: a device had a random `machine_id` and the `user_id` the relay stored was never read.
15
+ * So every account on a relay shares one pool, and any caller holding a valid operator token
16
+ * broadcasts to all of them. That is the finding this release fixes.
17
+ *
18
+ * ## Why the decision lives here rather than inline
19
+ *
20
+ * The relay is a large stateful object where a mistake is expensive to find. The rule "which
21
+ * devices may this request reach" is a **pure function of (requester's fleet, device's fleet,
22
+ * explicitly named targets)**, so it is expressed here, at that shape, and tested exhaustively.
23
+ * The relay calls it; it does not reimplement it.
24
+ *
25
+ * ## The three rules, and the reasoning that is not obvious
26
+ *
27
+ * **1. A request reaches only its own fleet.** Not "only its own fleet by default" — there is no
28
+ * parameter to widen it. A cross-fleet read is never legitimate, so it is not expressible.
29
+ *
30
+ * **2. An empty fleet matches nothing.** `fleet_id: undefined` on both sides must **not** compare
31
+ * equal. This is the failure that would silently undo everything: two ungrouped devices would
32
+ * match each other, which is exactly the "everything in one pool" behaviour being removed. So an
33
+ * absent fleet is a device that belongs to no fleet, and it is reachable by nobody.
34
+ *
35
+ * **3. A cross-fleet target is refused rather than ignored.** Silently dropping a named target
36
+ * leaves the caller believing it dispatched somewhere. The refusal **does not say whether the
37
+ * target exists** — a caller must not be able to probe another account's device ids.
38
+ */
39
+
40
+ /** Why a request reached nobody, or was refused outright. */
41
+ export const SCOPE_REFUSAL = Object.freeze({
42
+ /** The requester has no fleet, so it reaches no devices. */
43
+ NO_FLEET: 'no_fleet',
44
+ /** The fleet exists but holds no online device. */
45
+ NO_DEVICE_IN_FLEET: 'no_device_in_fleet',
46
+ /** A named target belongs to another fleet. Deliberately unspecific about which. */
47
+ TARGET_OUTSIDE_FLEET: 'target_outside_fleet',
48
+ /** The named target is not a device this relay holds at all. */
49
+ TARGET_UNKNOWN: 'target_unknown',
50
+ });
51
+
52
+ /**
53
+ * Does this device belong to this fleet?
54
+ *
55
+ * **Absent is not a wildcard.** Both sides must be a non-empty string and equal. A helper that
56
+ * treated `undefined === undefined` as a match would put every ungrouped device in one pool — the
57
+ * precise defect being removed — and it would pass every test that only ever sets a fleet.
58
+ */
59
+ export function inFleet(deviceFleet, requesterFleet) {
60
+ if (typeof deviceFleet !== 'string' || deviceFleet === '') return false;
61
+ if (typeof requesterFleet !== 'string' || requesterFleet === '') return false;
62
+ return deviceFleet === requesterFleet;
63
+ }
64
+
65
+ /**
66
+ * The devices a request may reach.
67
+ *
68
+ * @param {object} input
69
+ * @param {Array<{machine_id: string, fleet_id?: string, online?: boolean}>} input.devices
70
+ * @param {string|null} input.fleetId The requester's fleet, from its verified statement.
71
+ * @param {string[]} [input.targetMachines] Explicit targets, if any.
72
+ * @param {boolean} [input.requireOnline] Skip devices the relay believes are offline.
73
+ * @returns {{ok: true, targets: object[], skippedOffline: string[]}
74
+ * | {ok: false, reason: string, text: string}}
75
+ */
76
+ export function selectTargets({ devices, fleetId, targetMachines, requireOnline = true }) {
77
+ if (typeof fleetId !== 'string' || fleetId === '') {
78
+ // A requester with no fleet reaches nothing. Not "everything", and not an error the caller can
79
+ // retry differently — it needs an account.
80
+ return { ok: false, reason: SCOPE_REFUSAL.NO_FLEET, text: '这个请求没有舰队身份,所以它不能到达任何设备。' };
81
+ }
82
+
83
+ const mine = devices.filter((d) => inFleet(d.fleet_id, fleetId));
84
+
85
+ if (Array.isArray(targetMachines) && targetMachines.length > 0) {
86
+ const byId = new Map(mine.map((d) => [d.machine_id, d]));
87
+ const chosen = [];
88
+ for (const id of targetMachines) {
89
+ const device = byId.get(id);
90
+ if (device === undefined) {
91
+ // Two different worlds, deliberately reported as one answer. Saying "that machine exists
92
+ // but is not yours" would let a caller enumerate another account's fleet by trying ids.
93
+ return {
94
+ ok: false,
95
+ reason: SCOPE_REFUSAL.TARGET_OUTSIDE_FLEET,
96
+ text: '指定的目标不在这条舰队里。',
97
+ };
98
+ }
99
+ chosen.push(device);
100
+ }
101
+ return finish(chosen, requireOnline);
102
+ }
103
+
104
+ return finish(mine, requireOnline);
105
+ }
106
+
107
+ function finish(devices, requireOnline) {
108
+ const skippedOffline = [];
109
+ const targets = [];
110
+ for (const device of devices) {
111
+ if (requireOnline && device.online === false) {
112
+ // Counted, not dropped silently: "we tried and it was offline" and "we never considered it"
113
+ // produce the same empty result and different fixes.
114
+ skippedOffline.push(device.machine_id);
115
+ continue;
116
+ }
117
+ targets.push(device);
118
+ }
119
+ if (targets.length === 0) {
120
+ return {
121
+ ok: false,
122
+ reason: SCOPE_REFUSAL.NO_DEVICE_IN_FLEET,
123
+ text:
124
+ skippedOffline.length > 0
125
+ ? '这条舰队里的机器当前都不在线。'
126
+ : '这条舰队里没有机器。',
127
+ };
128
+ }
129
+ return { ok: true, targets, skippedOffline };
130
+ }
131
+
132
+ /**
133
+ * Which fleet a pairing code admits to.
134
+ *
135
+ * **The fleet comes from the code, never from the device.** This is the whole of the isolation
136
+ * argument: if a device could name its own fleet it would join any account, and every signature in
137
+ * `auth.mjs` would faithfully attest to a lie. A code with no fleet admits to no fleet, and the
138
+ * pairing is refused rather than defaulted.
139
+ *
140
+ * @param {object} input
141
+ * @param {{fleet_id?: string, used?: boolean, exp?: number}|null} input.code The pairing code record.
142
+ * @param {number} [input.now]
143
+ */
144
+ export function fleetFromPairingCode({ code, now = Date.now() }) {
145
+ if (code === null || code === undefined || typeof code !== 'object') {
146
+ return { ok: false, reason: 'unknown_code', text: '配对码无效。' };
147
+ }
148
+ if (code.used === true) return { ok: false, reason: 'code_used', text: '这个配对码已经被使用过了。' };
149
+ if (typeof code.exp === 'number' && Number.isFinite(code.exp) && now >= code.exp) {
150
+ return { ok: false, reason: 'code_expired', text: '配对码已过期。' };
151
+ }
152
+ if (typeof code.fleet_id !== 'string' || code.fleet_id === '') {
153
+ // A relay that issued a fleetless code would be handing out access to a pool with no owner.
154
+ return { ok: false, reason: 'code_has_no_fleet', text: '这个配对码没有绑定舰队,拒绝配对。' };
155
+ }
156
+ return { ok: true, fleetId: code.fleet_id };
157
+ }
158
+
159
+ /**
160
+ * The statement a relay issues for a device that has just paired.
161
+ *
162
+ * Kept here rather than inline in the relay so the fleet that goes into the signature is visibly
163
+ * the fleet that came from the code — one function, one place where that binding is made.
164
+ */
165
+ export function statementForDevice({ device, fleetId, sn, now = Date.now(), ttlMs }) {
166
+ return {
167
+ algorithm: 'ed25519',
168
+ // From the pairing code. Not from `device.fleet_id`, which would be the device's own claim.
169
+ fleet_id: fleetId,
170
+ machine_id: device.machineId,
171
+ sn,
172
+ public_key: device.publicKeyPem,
173
+ issued_at_ms: now,
174
+ expires_at_ms: now + (Number.isFinite(ttlMs) ? ttlMs : 90 * 24 * 60 * 60 * 1000),
175
+ };
176
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Wiring fleet scoping into the relay's decisions (v0.7.7).
3
+ *
4
+ * ## What this file is, and what it is not
5
+ *
6
+ * `fleet-scope.mjs` answers *"which devices may this request reach"*. This answers the two
7
+ * questions the relay needs answered **before** it can ask that one:
8
+ *
9
+ * 1. **What fleet is this requester in?** A relay receives a device token, not a fleet. The token
10
+ * maps to a device, and the device has a `fleet_id` — or does not, because it paired before
11
+ * fleets existed.
12
+ * 2. **What happens to a device that has no fleet?**
13
+ *
14
+ * ## The migration question, and why the answer is not "refuse"
15
+ *
16
+ * The shared relay holds four devices that paired under v0.6.3. None of them has a `fleet_id`.
17
+ * Refusing fleetless requests would take the whole fleet down at the moment of deployment — for a
18
+ * safety feature, that is the wrong trade.
19
+ *
20
+ * So there are two modes, and the relay's operator chose between them by configuring a fleet:
21
+ *
22
+ * | relay has | fleetless device | reaches |
23
+ * |---|---|---|
24
+ * | **no fleet configured** | legacy | every device, as before |
25
+ * | **a fleet configured** | `fleet_id: null` | **nothing** |
26
+ *
27
+ * That second row is the one that matters. Once an operator says *"this relay is for fleet X"*, a
28
+ * device with no fleet is not silently granted the run of it — it reaches nothing, and
29
+ * `scopeFleetId` says why. The transition is therefore **opt-in and visible**, rather than a
30
+ * background behaviour change that arrives with a version bump.
31
+ *
32
+ * A device with a `fleet_id` always reaches its own fleet and nothing else, in both modes. That is
33
+ * the isolation, and it does not depend on the relay's configuration.
34
+ */
35
+
36
+ import { selectTargets, SCOPE_REFUSAL } from './fleet-scope.mjs';
37
+ // v0.8.1: the account-derived fleet key, so a machine can join its own fleet with no human step.
38
+ import { planJoin } from './fleet-join.mjs';
39
+
40
+ /** Why a request reached nothing, before target selection even runs. */
41
+ export const WIRING_REFUSAL = Object.freeze({
42
+ /** The token belongs to no device this relay knows. */
43
+ UNKNOWN_TOKEN: 'unknown_token',
44
+ /** The relay is scoped to a fleet and this requester is not in one. */
45
+ REQUESTER_HAS_NO_FLEET: 'requester_has_no_fleet',
46
+ });
47
+
48
+ /**
49
+ * Which fleet the requester acts as.
50
+ *
51
+ * The value comes from **the device record**, which the relay wrote at pairing time from the
52
+ * pairing code — never from anything in the request. A request that could name its own fleet
53
+ * would be able to reach any fleet, and every signature would faithfully attest to it.
54
+ *
55
+ * @param {object} input
56
+ * @param {{machine_id?: string, fleet_id?: string|null}|null} input.device The requester's record.
57
+ * @param {string|null} [input.relayFleetId] The fleet this relay is scoped to, if the operator set one.
58
+ * @returns {{ok: true, fleetId: string|null, legacy: boolean}
59
+ * | {ok: false, reason: string, text: string}}
60
+ */
61
+ export function scopeFleetId({ device, relayFleetId = null }) {
62
+ if (device === null || device === undefined || typeof device !== 'object') {
63
+ return { ok: false, reason: WIRING_REFUSAL.UNKNOWN_TOKEN, text: '这个 token 不属于本 relay 已知的任何设备。' };
64
+ }
65
+
66
+ const deviceFleet = typeof device.fleet_id === 'string' && device.fleet_id !== '' ? device.fleet_id : null;
67
+
68
+ // Already grouped: it reaches its own fleet, whatever the relay is configured for.
69
+ if (deviceFleet !== null) return { ok: true, fleetId: deviceFleet, legacy: false };
70
+
71
+ // Not grouped, and the relay has not been claimed for a fleet: the pre-v0.7.7 behaviour, which
72
+ // is what keeps a deployment from being an outage. `legacy: true` is reported so a caller can
73
+ // log it rather than having to infer it.
74
+ if (relayFleetId === null || relayFleetId === '') return { ok: true, fleetId: null, legacy: true };
75
+
76
+ // Not grouped, and the relay *is* claimed. A fleetless device is not handed the run of someone
77
+ // else's fleet — it reaches nothing, and says so.
78
+ return {
79
+ ok: false,
80
+ reason: WIRING_REFUSAL.REQUESTER_HAS_NO_FLEET,
81
+ text: '这个 relay 已绑定到一条舰队,而这台设备不属于任何舰队,因此它不能派发到任何地方。',
82
+ };
83
+ }
84
+
85
+ /**
86
+ * The devices a request may reach, given a device list and a requester.
87
+ *
88
+ * The single entry point the relay needs. It applies `scopeFleetId`, then `selectTargets`, and
89
+ * then — only in legacy mode — falls back to the unfiltered list, because that is exactly what
90
+ * the pre-v0.7.7 code did and a migration must not change behaviour for devices that were never
91
+ * asked to change.
92
+ *
93
+ * ## The case a first version got wrong
94
+ *
95
+ * `POST /v1/task` authenticates with the **operator token**, and an operator is not a device:
96
+ * `authorizeTaskDispatch` returns `{ role: 'operator', machine_id: null }`. So `requester` is
97
+ * routinely absent on the most common path, and an earlier version of this function refused it as
98
+ * `unknown_token` — which turned every operator-token dispatch into an error and broke 74 relay
99
+ * tests at once.
100
+ *
101
+ * An **absent** requester and an **unknown** one are therefore different things:
102
+ *
103
+ * * absent, on an unscoped relay → the legacy rule, which is what an operator has always had;
104
+ * * absent, on a scoped relay → refused, because there is no fleet to attribute the request to
105
+ * and handing it the run of the fleet is the silent grant this work exists to remove;
106
+ * * unknown (a machine id that is not in the device table) → refused, always, because a token
107
+ * that maps to nothing is a broken caller rather than a legitimate operator.
108
+ *
109
+ * @param {object} input
110
+ * @param {Array<object>} input.devices Every device the relay holds.
111
+ * @param {object|null|undefined} input.requester The requester's device record, or absent for an
112
+ * operator-token dispatch.
113
+ * @param {string[]} [input.targetMachines]
114
+ * @param {string|null} [input.relayFleetId]
115
+ * @param {boolean} [input.requireOnline]
116
+ */
117
+ export function targetsFor({ devices, requester, targetMachines, relayFleetId = null, requireOnline = true }) {
118
+ const relayScoped = typeof relayFleetId === 'string' && relayFleetId !== '';
119
+
120
+ // An operator-token caller has no device at all, which is a legitimate identity rather than a
121
+ // broken one. On an unscoped relay it gets exactly the pre-v0.7.7 rule.
122
+ if (requester === null || requester === undefined) {
123
+ if (!relayScoped) return legacyTargets({ devices, targetMachines, requireOnline, reason: 'operator' });
124
+ return {
125
+ ok: false,
126
+ reason: WIRING_REFUSAL.REQUESTER_HAS_NO_FLEET,
127
+ text: '这个 relay 已绑定到一条舰队,而这个请求没有可以归属的设备身份,因此不能派发。',
128
+ legacy: false,
129
+ };
130
+ }
131
+
132
+ const scope = scopeFleetId({ device: requester, relayFleetId });
133
+ if (!scope.ok) return { ok: false, reason: scope.reason, text: scope.text, legacy: false };
134
+
135
+ if (scope.legacy) return legacyTargets({ devices, targetMachines, requireOnline, reason: 'fleetless' });
136
+
137
+ const result = selectTargets({ devices, fleetId: scope.fleetId, targetMachines, requireOnline });
138
+ return result.ok ? { ...result, legacy: false } : { ...result, legacy: false };
139
+ }
140
+
141
+ /** The pre-v0.7.7 rule, in one place so both callers cannot drift apart. */
142
+ function legacyTargets({ devices, targetMachines, requireOnline, reason }) {
143
+ const all = Array.isArray(devices) ? devices : [];
144
+ const chosen = Array.isArray(targetMachines) && targetMachines.length > 0
145
+ ? all.filter((d) => targetMachines.includes(d.machine_id))
146
+ : all;
147
+ const offline = chosen.filter((d) => d.online === false).map((d) => d.machine_id);
148
+ const targets = requireOnline ? chosen.filter((d) => d.online !== false) : chosen;
149
+ if (targets.length === 0) {
150
+ return {
151
+ ok: false,
152
+ reason: SCOPE_REFUSAL.NO_DEVICE_IN_FLEET,
153
+ text: offline.length > 0 ? '没有在线设备。' : 'relay 上没有任何设备。',
154
+ legacy: true,
155
+ };
156
+ }
157
+ return { ok: true, targets, skippedOffline: offline, legacy: true, legacyReason: reason };
158
+ }
159
+
160
+ /**
161
+ * The `fleet_id` a pairing code admits to, in the shape `state.mjs` stores.
162
+ *
163
+ * Returns `null` for a code that predates fleets, which is what keeps an existing relay working:
164
+ * a code issued before this release pairs a device into no fleet, and the relay's own mode decides
165
+ * what that means.
166
+ */
167
+ export function fleetForPairingCode(codeRecord) {
168
+ if (codeRecord === null || typeof codeRecord !== 'object') return null;
169
+ const fleet = codeRecord.fleet_id;
170
+ return typeof fleet === 'string' && fleet !== '' ? fleet : null;
171
+ }
172
+
173
+ /**
174
+ * The device fields that carry fleet membership, for a record the relay is about to write.
175
+ *
176
+ * Kept as a function so the "the fleet came from the code" rule lives in one place: a caller that
177
+ * builds a device literal by hand is the way a device ends up choosing its own fleet.
178
+ */
179
+ /**
180
+ * The `fleet_id` a device joins, and **where that fact came from**.
181
+ *
182
+ * Three sources, in order of authority, and the order is the whole security argument:
183
+ *
184
+ * | source | who decided | why |
185
+ * |---|---|---|
186
+ * | `pairing_code` | the operator, by issuing the code | explicit intent, and the only source that can place a device in a fleet it could not compute |
187
+ * | `fleet_key` | the account, by derivation | the auto-join path: the device proves membership by holding the key |
188
+ * | `request` | the relay operator, via `--fleet` | a relay that serves one fleet and is told which |
189
+ *
190
+ * ## Why the key is *derived from* rather than *compared against*
191
+ *
192
+ * A device presenting a `fleet_key` is **assigned** the fleet id computed from that key —
193
+ * `SHA-256(key)`. The relay does not decide whether to believe a claimed fleet id; it computes one.
194
+ * So a device can only ever land in the fleet whose key it holds, there is no trust-on-first-use to
195
+ * get wrong, and nothing to forge: a device without the key forms a different, empty fleet rather
196
+ * than joining somebody else's.
197
+ *
198
+ * This is what lets a second machine on one account join with **no human step and no relay
199
+ * configuration** — the gap that let a new PC sit invisible next to its own fleet.
200
+ *
201
+ * @param {object} input
202
+ * @param {object|null} input.codeRecord The pairing code's record, if one was used.
203
+ * @param {string|null} [input.fleetKeyEncoded] The account-derived key, if the device offered one.
204
+ * @param {string|null} [input.requestedFleet] An explicit fleet from the relay's own configuration.
205
+ * @returns {{fleet_id: string|null, fleet_source: string|null, fleet_reason?: string}}
206
+ */
207
+ export function deviceFleetFields({ codeRecord, fleetKeyEncoded = null, requestedFleet = null }) {
208
+ const fromCode = fleetForPairingCode(codeRecord);
209
+ if (fromCode !== null) {
210
+ /**
211
+ * The provenance has to say *how* the fleet was established, and a first version did not: it
212
+ * reported `pairing_code` for a device that never presented one, because the relay builds a
213
+ * synthetic code record for the key path so the binding reads from a single place.
214
+ *
215
+ * That mattered. `fleet_source` is what an operator reads in `devices.json` to answer *"how did
216
+ * this machine get into my fleet?"* — and answering "a pairing code" when there was none sends
217
+ * them looking for a code that was never issued. Found by a live pairing test, not by reading.
218
+ */
219
+ if (codeRecord?.from_fleet_key === true) return { fleet_id: fromCode, fleet_source: 'fleet_key' };
220
+ return { fleet_id: fromCode, fleet_source: 'pairing_code' };
221
+ }
222
+
223
+ /**
224
+ * The auto-join path. Tried before the operator's `--fleet` so that a device which knows its own
225
+ * account lands in its own fleet rather than being absorbed into whatever fleet the relay serves —
226
+ * a relay configured for one account must not silently become the wrong fleet for a device whose
227
+ * account says otherwise.
228
+ */
229
+ if (typeof fleetKeyEncoded === 'string' && fleetKeyEncoded !== '') {
230
+ const join = planJoin({ fleetKeyEncoded });
231
+ if (join.ok) return { fleet_id: join.fleetId, fleet_source: 'fleet_key' };
232
+ /**
233
+ * A malformed key does **not** pair the device into the relay's own fleet. Falling through would
234
+ * be the silent wrong answer: a device that meant to join its account would instead be placed in
235
+ * an unrelated fleet and told nothing.
236
+ */
237
+ return { fleet_id: null, fleet_source: null, fleet_reason: join.reason };
238
+ }
239
+
240
+ if (typeof requestedFleet === 'string' && requestedFleet !== '') {
241
+ // Recorded with its provenance so a fleet that arrived from a request is visible as such in
242
+ // `devices.json` rather than looking identical to one that came from a code or a key.
243
+ return { fleet_id: requestedFleet, fleet_source: 'request' };
244
+ }
245
+ return { fleet_id: null, fleet_source: null };
246
+ }
247
+
248
+ /**
249
+ * A one-line description of the scoping decision, for a log.
250
+ *
251
+ * Deliberately includes the legacy flag: an operator reading logs during a migration needs to see
252
+ * *which* rule applied, and "4 targets" alone does not say whether isolation is on.
253
+ */
254
+ export function describeScope({ scope, targetCount }) {
255
+ if (!scope.ok) return `scoped: refused (${scope.reason})`;
256
+ if (scope.legacy) return `scoped: legacy (no fleet on this relay), ${targetCount} target(s)`;
257
+ return `scoped: fleet ${scope.fleetId}, ${targetCount} target(s)`;
258
+ }