@twinsearth/w2m-dsh-plugin 0.6.3 → 0.8.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 (37) hide show
  1. package/CHANGELOG.md +1030 -0
  2. package/README.md +9 -2
  3. package/bin/w2m-localside.mjs +0 -0
  4. package/bin/w2m-rabbit.mjs +6 -0
  5. package/bin/w2m-stun.mjs +0 -0
  6. package/deploy/server/README.md +109 -0
  7. package/deploy/server/deploy-w2m.sh +161 -0
  8. package/deploy/server/w2m-localside.service +31 -0
  9. package/deploy/server/w2m-rabbit.service +26 -0
  10. package/deploy/server/w2m-stun.service +26 -0
  11. package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
  12. package/package.json +33 -22
  13. package/src/chatops/account.mjs +361 -0
  14. package/src/chatops/actions.mjs +227 -0
  15. package/src/chatops/approval-broker.mjs +320 -0
  16. package/src/chatops/auth.mjs +376 -0
  17. package/src/chatops/bridge.mjs +344 -0
  18. package/src/chatops/channel.mjs +192 -0
  19. package/src/chatops/commands.mjs +409 -0
  20. package/src/chatops/config-route.mjs +332 -0
  21. package/src/chatops/device-signing.mjs +218 -0
  22. package/src/chatops/durability.mjs +202 -0
  23. package/src/chatops/file-safety.mjs +282 -0
  24. package/src/chatops/fleet-scope.mjs +176 -0
  25. package/src/chatops/fleet-wiring.mjs +199 -0
  26. package/src/chatops/fleet.mjs +272 -0
  27. package/src/chatops/handoff.mjs +258 -0
  28. package/src/chatops/ilink-protocol.mjs +165 -0
  29. package/src/chatops/ilink-transport.mjs +168 -0
  30. package/src/chatops/ilink.mjs +423 -0
  31. package/src/chatops/index.mjs +429 -0
  32. package/src/chatops/outbound-queue.mjs +282 -0
  33. package/src/chatops/policy.mjs +356 -0
  34. package/src/chatops/run-command.mjs +308 -0
  35. package/src/plugin/tools.mjs +1 -1
  36. package/src/relay/server.mjs +96 -0
  37. package/src/relay/state.mjs +2233 -2011
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Getting messages out, in order, without a failure poisoning the channel (v0.7.0).
3
+ *
4
+ * ## The defect this file exists to make impossible
5
+ *
6
+ * `dsh-chatops` 0.1.0 chained every send onto a per-channel promise:
7
+ *
8
+ * this.outQueue = this.outQueue.then(() => this.sayInline(windowKey, text))
9
+ *
10
+ * and the inner send was **not** guarded. One network blip, or a credential that was
11
+ * never configured, therefore rejected the tail permanently: every later `say()` returned
12
+ * that same rejection, `sayInline` was never invoked again, and the channel was mute for
13
+ * the lifetime of the process — silently, while still reporting itself registered. The
14
+ * same shape appears in all four of its channels; three survived only because their inner
15
+ * send happened to catch, and iLink's bulk lane did not, so **one failed file send
16
+ * disabled all future `/log` output**.
17
+ *
18
+ * The fix is not a try/catch around the send. It is that the queue's tail must be a
19
+ * promise that **cannot reject**, so a failure is a value the caller receives rather than
20
+ * a state the queue is left in.
21
+ *
22
+ * ## The two lanes
23
+ *
24
+ * iLink's two-lane design was the best idea in that codebase and is kept unchanged: an
25
+ * interactive reply must never wait behind a twelve-message `/log` dump, and the bulk lane
26
+ * yields whenever interactive traffic is pending. Getting this wrong is not a performance
27
+ * problem — a queue that is busy for thirty seconds is one where `/approve` arrives after
28
+ * the approval has timed out.
29
+ *
30
+ * ## Ordering, and where it is deliberately not guaranteed
31
+ *
32
+ * Within one window, one lane, order is preserved and each send completes before the next
33
+ * starts. Across lanes there is no order, on purpose: that is what "interactive first"
34
+ * means. Across windows there is no order either, so a slow destination cannot stall a
35
+ * different conversation.
36
+ *
37
+ * The clock, the timer and the jitter source are injected, for the reason recorded in the
38
+ * approval broker: an injected clock with a real `setTimeout` is two clocks, and the test
39
+ * that advances one of them hangs on the other.
40
+ */
41
+
42
+ /** How a queued send finished. Returned, never thrown. */
43
+ export const SEND_RESULT = Object.freeze({
44
+ OK: 'ok',
45
+ /** The channel's own send rejected. The message is lost unless the caller requeues it. */
46
+ FAILED: 'failed',
47
+ /** Cancelled — in flight, or still waiting, when the queue was disposed. */
48
+ CANCELLED: 'cancelled',
49
+ });
50
+
51
+ /**
52
+ * Split text so that each chunk is at most `maxBytes` **and** no character is cut in half.
53
+ *
54
+ * Byte-bounded rather than length-bounded, because the limits that matter are byte limits:
55
+ * a 6000-byte WeChat message is roughly 2000 Chinese characters, and counting characters
56
+ * would send three times the permitted size. The cut falls back to a newline when one is
57
+ * near the boundary, so a split message reads as a split message rather than as a
58
+ * truncated line.
59
+ *
60
+ * @param {string} text
61
+ * @param {number} maxBytes
62
+ * @returns {string[]}
63
+ */
64
+ export function chunkText(text, maxBytes) {
65
+ if (typeof text !== 'string') throw new TypeError('chunkText needs a string');
66
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) throw new TypeError('chunkText needs a positive byte budget');
67
+ if (text === '') return [];
68
+
69
+ const encoder = new TextEncoder();
70
+ const chunks = [];
71
+ let current = '';
72
+ let currentBytes = 0;
73
+
74
+ // Iterating by code point, not by UTF-16 unit, so an emoji or a surrogate pair is never
75
+ // split into two invalid halves.
76
+ for (const char of text) {
77
+ const bytes = encoder.encode(char).length;
78
+ if (currentBytes + bytes > maxBytes && current !== '') {
79
+ // Prefer to break at the last newline if one is reasonably near the end; a chunk
80
+ // boundary mid-sentence is worse to read than one a few bytes early.
81
+ const nl = current.lastIndexOf('\n');
82
+ if (nl > current.length * 0.6) {
83
+ chunks.push(current.slice(0, nl + 1));
84
+ current = current.slice(nl + 1);
85
+ currentBytes = encoder.encode(current).length;
86
+ } else {
87
+ chunks.push(current);
88
+ current = '';
89
+ currentBytes = 0;
90
+ }
91
+ }
92
+ current += char;
93
+ currentBytes += bytes;
94
+ }
95
+ if (current !== '') chunks.push(current);
96
+ return chunks;
97
+ }
98
+
99
+ /**
100
+ * A per-window, two-lane outbound queue.
101
+ *
102
+ * @param {object} options
103
+ * @param {(chunk: string, meta: {window: string, lane: string, index: number, total: number}) => Promise<void>} options.send
104
+ * One chunk. Rejecting is normal and is reported as `FAILED`; it never poisons the queue.
105
+ * @param {number} [options.chunkBytes] Byte budget per message.
106
+ * @param {number} [options.throttleMs] Minimum gap between two sends in the **same lane**.
107
+ * @param {number} [options.jitterMs] Extra random delay, to avoid a fixed-rate signature.
108
+ * @param {() => number} [options.now]
109
+ * @param {(ms: number) => Promise<void>} [options.sleep]
110
+ * @param {() => number} [options.random]
111
+ * @param {(event: string, detail?: object) => void} [options.audit]
112
+ */
113
+ export function createOutboundQueue(options) {
114
+ const send = options.send;
115
+ if (typeof send !== 'function') throw new TypeError('createOutboundQueue requires a send()');
116
+ const chunkBytes = options.chunkBytes ?? 6000;
117
+ const throttleMs = options.throttleMs ?? 1200;
118
+ const jitterMs = options.jitterMs ?? 0;
119
+ const now = options.now ?? (() => Date.now());
120
+ const random = options.random ?? Math.random;
121
+ const audit = options.audit ?? (() => {});
122
+
123
+ const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
124
+ const sleep = options.sleep ?? realSleep;
125
+
126
+ /** window -> lane -> { tail, pending, lastSentAt } */
127
+ const lanes = new Map();
128
+ let disposed = false;
129
+
130
+ function laneFor(window, lane) {
131
+ let byLane = lanes.get(window);
132
+ if (!byLane) {
133
+ byLane = new Map();
134
+ lanes.set(window, byLane);
135
+ }
136
+ let state = byLane.get(lane);
137
+ if (!state) {
138
+ state = { tail: Promise.resolve(), pending: 0, lastSentAt: 0 };
139
+ byLane.set(lane, state);
140
+ }
141
+ return state;
142
+ }
143
+
144
+ /** Wait out the throttle, honouring the injected clock. */
145
+ async function throttle(state) {
146
+ if (throttleMs <= 0) return;
147
+ // The first message in a lane is not delayed: an operator who just spoke should not
148
+ // wait 1.2s for the acknowledgement.
149
+ if (state.lastSentAt === 0) return;
150
+ const elapsed = now() - state.lastSentAt;
151
+ const wait = throttleMs + (jitterMs > 0 ? Math.floor(random() * jitterMs) : 0) - elapsed;
152
+ if (wait > 0) await sleep(wait);
153
+ }
154
+
155
+ /**
156
+ * Queue one message.
157
+ *
158
+ * @param {object} input
159
+ * @param {string} input.window
160
+ * @param {string} input.text
161
+ * @param {'interactive'|'bulk'} [input.lane]
162
+ * @returns {Promise<{status: string, sent: number, failed: number, attempts: number}>}
163
+ * Never rejects. A failure is a value, because a rejected queue tail is the
164
+ * defect.
165
+ */
166
+ function enqueue({ window, text, lane = 'interactive' }) {
167
+ // Refused before anything is queued, so a late call cannot leave an entry behind on a
168
+ // cleared map. This — not the loop below — is what "stops accepting work" means.
169
+ if (disposed) {
170
+ return Promise.resolve({ status: SEND_RESULT.CANCELLED, sent: 0, failed: 0, attempts: 0 });
171
+ }
172
+ const state = laneFor(window, lane);
173
+ const chunks = chunkText(text, chunkBytes);
174
+ if (chunks.length === 0) {
175
+ return Promise.resolve({ status: SEND_RESULT.OK, sent: 0, failed: 0, attempts: 0 });
176
+ }
177
+
178
+ state.pending += 1;
179
+
180
+ // The critical property, and the whole reason this is a `.then` chain rather than an
181
+ // async function: `run` catches everything, so the tail it returns cannot reject. A
182
+ // chain whose tail can reject is a channel that dies on its first hiccup.
183
+ //
184
+ // Note what `run` does **not** do: it never consults `disposed`. Once a message has
185
+ // started, it is finished — a half-sent message is a worse outcome than a slow
186
+ // shutdown, and a reload that abandons one leaves the platform holding a fragment.
187
+ const run = async () => {
188
+ let sent = 0;
189
+ let failed = 0;
190
+ let attempts = 0;
191
+ try {
192
+ for (let i = 0; i < chunks.length; i += 1) {
193
+ await throttle(state);
194
+ attempts += 1;
195
+ try {
196
+ await send(chunks[i], { window, lane, index: i, total: chunks.length });
197
+ sent += 1;
198
+ state.lastSentAt = now();
199
+ } catch (error) {
200
+ // Counted and reported. Continuing is deliberate: the remaining chunks of a
201
+ // long dump are independent of the one that failed, and stopping would turn a
202
+ // transient platform hiccup into a permanently lost tail.
203
+ failed += 1;
204
+ audit('send/failed', {
205
+ window,
206
+ lane,
207
+ index: i,
208
+ detail: error instanceof Error ? error.message : String(error),
209
+ });
210
+ }
211
+ }
212
+ } catch (error) {
213
+ // Unreachable in practice, and kept because an exception escaping `run` is exactly
214
+ // the failure this module exists to prevent. Belt and braces, stated as such.
215
+ failed += 1;
216
+ audit('send/queue-error', { window, lane, detail: String(error) });
217
+ }
218
+ return {
219
+ status: failed === 0 ? SEND_RESULT.OK : SEND_RESULT.FAILED,
220
+ sent,
221
+ failed,
222
+ attempts,
223
+ };
224
+ };
225
+
226
+ const settled = state.tail.then(run, run);
227
+ // The tail must swallow a rejection *and* must not grow a rejection handler chain; it
228
+ // is a promise that always fulfils, so nothing downstream can be poisoned by it.
229
+ state.tail = settled.then(
230
+ () => undefined,
231
+ () => undefined,
232
+ );
233
+ settled.finally(() => {
234
+ state.pending -= 1;
235
+ });
236
+ return settled;
237
+ }
238
+
239
+ /**
240
+ * How much work is outstanding for a window, by lane.
241
+ *
242
+ * Reported so a caller can say "the channel is busy" rather than silently queueing
243
+ * behind something that will time out.
244
+ */
245
+ function depth(window) {
246
+ const byLane = lanes.get(window);
247
+ if (!byLane) return { interactive: 0, bulk: 0, total: 0 };
248
+ let interactive = 0;
249
+ let bulk = 0;
250
+ for (const [lane, state] of byLane) {
251
+ if (lane === 'bulk') bulk += state.pending;
252
+ else interactive += state.pending;
253
+ }
254
+ return { interactive, bulk, total: interactive + bulk };
255
+ }
256
+
257
+ /**
258
+ * Stop accepting work and let what is in flight finish.
259
+ *
260
+ * Waits on every lane tail rather than dropping them: a `/log` dump half-sent is worse
261
+ * than one not sent, and a reload that abandons a message mid-platform is how a channel
262
+ * ends up with orphaned state.
263
+ */
264
+ async function dispose() {
265
+ disposed = true;
266
+ const tails = [];
267
+ for (const byLane of lanes.values()) {
268
+ for (const state of byLane.values()) tails.push(state.tail);
269
+ }
270
+ await Promise.all(tails);
271
+ lanes.clear();
272
+ }
273
+
274
+ return {
275
+ enqueue,
276
+ depth,
277
+ dispose,
278
+ get disposed() {
279
+ return disposed;
280
+ },
281
+ };
282
+ }
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Who is allowed to command this machine (v0.7.0).
3
+ *
4
+ * ## The defect this file exists to make impossible
5
+ *
6
+ * `dsh-chatops` 0.1.0 adopts an owner from the first private message it receives on
7
+ * Feishu, DingTalk or WeCom, before any authorisation runs:
8
+ *
9
+ * if (windowKey.startsWith('fsu:') || … 'dsu:' || … 'wsu:') && !auth.hasOwners()) {
10
+ * auth.addOwner(msg.talkerId) // no pairing code, no allow-list, no confirmation
11
+ * }
12
+ *
13
+ * and it ships with `channels: [ilink, feishu, dingtalk, wecom]` enabled by default.
14
+ * `addOwner` persists to `bindings.json`, so the claim survives restarts and is
15
+ * revoked only by deleting the file. Whoever messages the bot first — a colleague in
16
+ * the tenant, a group guest — gets permanent remote control: every workspace and
17
+ * session, arbitrary prompts, `/log` transcripts, `/send` for file exfiltration,
18
+ * `/approve`, and `/policy 2` to `danger-full-access`.
19
+ *
20
+ * The rest of that plugin's authorisation is genuinely fail-closed — an unknown
21
+ * sender matches no list and is dropped and audited. The adoption step is the one
22
+ * hole, and it is unattended first-come-first-served by construction.
23
+ *
24
+ * ## The rule here
25
+ *
26
+ * **Nothing is trusted because it arrived.** An identity becomes trusted in exactly
27
+ * two ways, both of which need something the sender must already possess:
28
+ *
29
+ * 1. it was seeded by the operator, in configuration, before the plugin started; or
30
+ * 2. it redeemed a single-use pairing code that the operator read off their own
31
+ * screen — the same shape the relay half of this project already uses.
32
+ *
33
+ * A pairing code is single-use, expires, and is rate-limited per identity, so a
34
+ * stranger cannot brute-force admission and cannot hold a slot. There is no path
35
+ * that trusts a sender because no one else had spoken yet.
36
+ *
37
+ * ## Identity is (channel, id), never a bare id
38
+ *
39
+ * The 0.1.0 owner set was one flat `Set<string>` shared by five platforms, matched
40
+ * after the channel prefix had been stripped — so a raw id colliding across two
41
+ * platforms was silently cross-trusted. Hence a raw list rather than a per-channel
42
+ * map of allowed rooms, and the reason there is no `allowRooms: string[]` input.
43
+ *
44
+ * ## Rooms are not people
45
+ *
46
+ * 0.1.0's `isRoomTalkerAllowed` returned `true` for **every** member of an allowed
47
+ * room whenever `allowContacts` was empty, and for every future member forever. A
48
+ * room grant here is always an intersection: the room must be allowed *and* the
49
+ * speaker must be an individually trusted identity. "Anyone in this group" is an
50
+ * authorisation nobody should be able to acquire by being added to a group.
51
+ */
52
+
53
+ /** Why an inbound message was dropped. Reported, never thrown: a drop is normal. */
54
+ export const DENIAL = Object.freeze({
55
+ /** The channel is not one the operator enabled. */
56
+ CHANNEL_UNKNOWN: 'channel_unknown',
57
+ /** No owner has been established yet, so nothing is trusted. */
58
+ NOT_ADOPTED: 'not_adopted',
59
+ /** The sender is not individually trusted. */
60
+ SENDER_UNTRUSTED: 'sender_untrusted',
61
+ /** The message is from a room that is not allowed. */
62
+ ROOM_NOT_ALLOWED: 'room_not_allowed',
63
+ /** A room message that did not address the bot. */
64
+ NOT_ADDRESSED: 'not_addressed',
65
+ /** The request was structurally unusable. */
66
+ MALFORMED: 'malformed',
67
+ });
68
+
69
+ /**
70
+ * Channels this feature knows about, and the window-key prefix each uses.
71
+ *
72
+ * This is the **single** registry. In 0.1.0 the same information lived in four
73
+ * places that had to agree — `manager.ts:20`, two regexes in `auth.ts:61,68`, five
74
+ * registration calls in `index.ts`, and the settings client — and a channel missing
75
+ * from the authorisation regexes had every message from it silently dropped. One
76
+ * table cannot disagree with itself.
77
+ */
78
+ export const CHANNELS = Object.freeze({
79
+ ilink: Object.freeze({ prefixes: Object.freeze(['user', 'contact']), supportsRooms: false, selfKey: 'filehelper' }),
80
+ feishu: Object.freeze({ prefixes: Object.freeze(['fsu']), supportsRooms: true, roomPrefixes: Object.freeze(['fsc']) }),
81
+ dingtalk: Object.freeze({ prefixes: Object.freeze(['dsu']), supportsRooms: true, roomPrefixes: Object.freeze(['dsc']) }),
82
+ wecom: Object.freeze({ prefixes: Object.freeze(['wsu']), supportsRooms: true, roomPrefixes: Object.freeze(['wsc']) }),
83
+ wechaty: Object.freeze({ prefixes: Object.freeze(['wx', 'room', 'self']), supportsRooms: true, roomPrefixes: Object.freeze(['room']) }),
84
+ // The in-memory channel is a first-class member, not a test-only special case. Registering
85
+ // it here is what lets a dry run and the integration suite exercise the *real* admission
86
+ // and routing path rather than a bypass, and it is the worked example for what adding a
87
+ // sixth channel now costs: one entry, in one table.
88
+ memory: Object.freeze({ prefixes: Object.freeze(['mem']), supportsRooms: false, selfKey: 'mem' }),
89
+ });
90
+
91
+ /**
92
+ * Prefix -> what it means, built once from {@link CHANNELS}.
93
+ *
94
+ * Derived rather than written a second time: two tables that must agree is the
95
+ * defect this registry replaced.
96
+ */
97
+ const PREFIX_INDEX = (() => {
98
+ const index = new Map();
99
+ for (const [channel, spec] of Object.entries(CHANNELS)) {
100
+ for (const prefix of spec.prefixes) {
101
+ index.set(prefix, { channel, kind: prefix === spec.selfKey ? 'self' : 'contact' });
102
+ }
103
+ for (const prefix of spec.roomPrefixes ?? []) {
104
+ index.set(prefix, { channel, kind: 'room' });
105
+ }
106
+ }
107
+ // A bare `filehelper` key rather than `filehelper:filehelper`, which is how the
108
+ // personal-WeChat channel names the one window only its owner can see.
109
+ index.set('filehelper', { channel: 'ilink', kind: 'self' });
110
+ return index;
111
+ })();
112
+
113
+ /**
114
+ * Classify a window key.
115
+ *
116
+ * Three outcomes rather than two, because "this key is malformed" and "this key is
117
+ * well-formed but names a channel nobody enabled" are different reports and only one
118
+ * of them is the operator's config being wrong. Returning `null` for both — the first
119
+ * cut of this function — made the `CHANNEL_UNKNOWN` branch unreachable, which is the
120
+ * kind of dead code that reads like an implemented feature.
121
+ *
122
+ * @returns {{state: 'known', channel: string, id: string, prefix: string, kind: 'self'|'contact'|'room', room: boolean}
123
+ * | {state: 'unknown-channel', prefix: string, id: string}
124
+ * | {state: 'malformed'}}
125
+ */
126
+ export function classifyWindow(windowKey) {
127
+ if (typeof windowKey !== 'string' || windowKey === '') return { state: 'malformed' };
128
+ const i = windowKey.indexOf(':');
129
+ if (i === -1) {
130
+ const bare = PREFIX_INDEX.get(windowKey);
131
+ if (!bare) return { state: 'unknown-channel', prefix: windowKey, id: '' };
132
+ return { state: 'known', ...bare, id: windowKey, prefix: windowKey, room: bare.kind === 'room' };
133
+ }
134
+ if (i === 0 || i === windowKey.length - 1) return { state: 'malformed' };
135
+ const prefix = windowKey.slice(0, i);
136
+ const claimed = PREFIX_INDEX.get(prefix);
137
+ if (!claimed) return { state: 'unknown-channel', prefix, id: windowKey.slice(i + 1) };
138
+ return { state: 'known', ...claimed, id: windowKey.slice(i + 1), prefix, room: claimed.kind === 'room' };
139
+ }
140
+
141
+ /**
142
+ * Parse a window key into its parts, or `null` when no channel claims it.
143
+ *
144
+ * Kept as the narrow form for callers that only need "did a channel claim this".
145
+ */
146
+ export function parseWindowKey(windowKey) {
147
+ const c = classifyWindow(windowKey);
148
+ return c.state === 'known' ? c : null;
149
+ }
150
+
151
+ /** Canonical `channel\u0000id` form. The separator is a NUL so no id can forge another. */
152
+ function identityKey(channel, id) {
153
+ return `${channel}\u0000${id}`;
154
+ }
155
+
156
+ /**
157
+ * Create a policy.
158
+ *
159
+ * @param {object} [options]
160
+ * @param {string[]} [options.trustedContacts] Pre-seeded `channel:id` entries, e.g. `feishu:ou_abc`.
161
+ * @param {string[]} [options.allowedRooms] `channel:roomId` entries. Membership alone is never enough.
162
+ * @param {string} [options.pairingCode] Optional fixed code, for scripted onboarding.
163
+ * @param {() => number} [options.now]
164
+ * @param {() => string} [options.mintCode] Source for generated pairing codes.
165
+ * @param {number} [options.pairingTtlMs]
166
+ * @param {number} [options.maxPairAttemptsPerIdentity]
167
+ * @param {(event: string, detail?: object) => void} [options.audit]
168
+ */
169
+ export function createPolicy(options = {}) {
170
+ const now = options.now ?? (() => Date.now());
171
+ const mintCode = options.mintCode ?? (() => `PAIR-${Math.random().toString(36).slice(2, 10).toUpperCase()}`);
172
+ const audit = options.audit ?? (() => {});
173
+ const pairingTtlMs = options.pairingTtlMs ?? 15 * 60_000;
174
+ const maxAttempts = options.maxPairAttemptsPerIdentity ?? 5;
175
+
176
+ const trusted = new Set();
177
+ for (const entry of options.trustedContacts ?? []) {
178
+ const parsed = splitEntry(entry);
179
+ if (parsed) trusted.add(identityKey(parsed.channel, parsed.id));
180
+ }
181
+ const rooms = new Set();
182
+ for (const entry of options.allowedRooms ?? []) {
183
+ const parsed = splitEntry(entry);
184
+ if (parsed) rooms.add(identityKey(parsed.channel, parsed.id));
185
+ }
186
+
187
+ /** identity -> { attempts, firstAt }. Bounded by eviction in `sweep()`. */
188
+ const attempts = new Map();
189
+
190
+ let pairing = null;
191
+ if (typeof options.pairingCode === 'string' && options.pairingCode !== '') {
192
+ pairing = { code: options.pairingCode, expiresAt: now() + pairingTtlMs, used: false };
193
+ }
194
+
195
+ /** Split a `channel:id` config entry, refusing the ambiguous forms. */
196
+ function splitEntry(entry) {
197
+ if (typeof entry !== 'string') return null;
198
+ const i = entry.indexOf(':');
199
+ if (i <= 0 || i === entry.length - 1) return null;
200
+ return { channel: entry.slice(0, i).trim().toLowerCase(), id: entry.slice(i + 1).trim() };
201
+ }
202
+
203
+ /** Whether any owner exists. Until one does, the plugin is inert. */
204
+ function hasOwners() {
205
+ return trusted.size > 0;
206
+ }
207
+
208
+ /**
209
+ * Mint a pairing code, invalidating any previous one.
210
+ *
211
+ * Rotating on issue is deliberate: a code that stays valid while a new one is
212
+ * printed is a code nobody can reason about. Where the code is displayed — the
213
+ * terminal, a QR page — is the transport's business, not this module's.
214
+ */
215
+ function openPairing() {
216
+ pairing = { code: mintCode(), expiresAt: now() + pairingTtlMs, used: false };
217
+ audit('pairing/opened', { expires_at: pairing.expiresAt });
218
+ return { code: pairing.code, expires_at: pairing.expiresAt };
219
+ }
220
+
221
+ /** Redeem a code. Single-use, expiring, rate-limited per identity. */
222
+ function redeemPairing(channel, id, code) {
223
+ const key = identityKey(channel, id);
224
+ const record = attempts.get(key);
225
+ if (!record || now() - record.firstAt > pairingTtlMs) {
226
+ attempts.set(key, { attempts: 0, firstAt: now() });
227
+ }
228
+ const entry = attempts.get(key);
229
+ entry.attempts += 1;
230
+ if (entry.attempts > maxAttempts) {
231
+ audit('pairing/rate-limited', { channel, id });
232
+ return { ok: false, reason: 'rate_limited' };
233
+ }
234
+ if (!pairing || pairing.used) return { ok: false, reason: 'no_open_pairing' };
235
+ if (now() >= pairing.expiresAt) {
236
+ pairing = null;
237
+ return { ok: false, reason: 'expired' };
238
+ }
239
+ if (typeof code !== 'string' || code !== pairing.code) {
240
+ audit('pairing/mismatch', { channel, id });
241
+ return { ok: false, reason: 'mismatch' };
242
+ }
243
+ pairing.used = true;
244
+ trusted.add(key);
245
+ attempts.delete(key);
246
+ audit('owner/adopted', { channel, id, via: 'pairing' });
247
+ return { ok: true };
248
+ }
249
+
250
+ /** Add an identity without a code. Only the operator's own config path may call this. */
251
+ function trust(channel, id, via = 'config') {
252
+ const key = identityKey(channel, id);
253
+ if (trusted.has(key)) return { ok: false, reason: 'already_trusted' };
254
+ trusted.add(key);
255
+ audit('owner/adopted', { channel, id, via });
256
+ return { ok: true };
257
+ }
258
+
259
+ /** Remove an identity. 0.1.0 had no revocation short of deleting its state file. */
260
+ function distrust(channel, id) {
261
+ const key = identityKey(channel, id);
262
+ if (!trusted.delete(key)) return { ok: false, reason: 'not_trusted' };
263
+ audit('owner/revoked', { channel, id });
264
+ return { ok: true };
265
+ }
266
+
267
+ /**
268
+ * Decide one inbound message.
269
+ *
270
+ * @param {object} message
271
+ * @param {string} message.windowKey
272
+ * @param {string} [message.talkerId]
273
+ * @param {boolean} [message.addressed] Whether a room message named the bot.
274
+ * @returns {{allow: true, channel: string, id: string, kind: string}
275
+ * | {allow: false, reason: string, channel: string|null}}
276
+ */
277
+ function admit(message) {
278
+ const classified = classifyWindow(message?.windowKey);
279
+ if (classified.state === 'malformed') {
280
+ audit('message/denied', { window: message?.windowKey ?? null, reason: DENIAL.MALFORMED });
281
+ return { allow: false, reason: DENIAL.MALFORMED, channel: null };
282
+ }
283
+ // A well-formed key naming a channel nobody enabled. 0.1.0's prefix regexes did
284
+ // this silently; a named reason is the difference between "nobody is talking to
285
+ // me" and "my config is wrong", which is the diagnosis an operator actually needs.
286
+ if (classified.state === 'unknown-channel') {
287
+ audit('message/denied', { window: message?.windowKey ?? null, reason: DENIAL.CHANNEL_UNKNOWN });
288
+ return { allow: false, reason: DENIAL.CHANNEL_UNKNOWN, channel: null };
289
+ }
290
+ const parsed = classified;
291
+
292
+ // The speaker, for a room message, is the talker — not the room.
293
+ const speakerId = message.talkerId ?? parsed.id;
294
+ const speakerKey = identityKey(parsed.channel, speakerId);
295
+
296
+ if (!hasOwners()) {
297
+ // No owner yet: everything is refused until the operator seeds one or a pairing
298
+ // code is redeemed. This single branch is the fix for the first-contact hole.
299
+ audit('message/denied', { window: message.windowKey, reason: DENIAL.NOT_ADOPTED });
300
+ return { allow: false, reason: DENIAL.NOT_ADOPTED, channel: parsed.channel };
301
+ }
302
+
303
+ if (parsed.kind === 'room') {
304
+ if (!rooms.has(identityKey(parsed.channel, parsed.id))) {
305
+ audit('message/denied', { window: message.windowKey, reason: DENIAL.ROOM_NOT_ALLOWED });
306
+ return { allow: false, reason: DENIAL.ROOM_NOT_ALLOWED, channel: parsed.channel };
307
+ }
308
+ if (message.addressed !== true) {
309
+ audit('message/denied', { window: message.windowKey, reason: DENIAL.NOT_ADDRESSED });
310
+ return { allow: false, reason: DENIAL.NOT_ADDRESSED, channel: parsed.channel };
311
+ }
312
+ // Room allowed AND speaker individually trusted. Membership is not a grant.
313
+ if (!trusted.has(speakerKey)) {
314
+ audit('message/denied', { window: message.windowKey, reason: DENIAL.SENDER_UNTRUSTED });
315
+ return { allow: false, reason: DENIAL.SENDER_UNTRUSTED, channel: parsed.channel };
316
+ }
317
+ audit('message/allowed', { window: message.windowKey, kind: 'room' });
318
+ return { allow: true, channel: parsed.channel, id: speakerId, kind: 'room' };
319
+ }
320
+
321
+ if (!trusted.has(speakerKey)) {
322
+ audit('message/denied', { window: message.windowKey, reason: DENIAL.SENDER_UNTRUSTED });
323
+ return { allow: false, reason: DENIAL.SENDER_UNTRUSTED, channel: parsed.channel };
324
+ }
325
+ audit('message/allowed', { window: message.windowKey, kind: parsed.kind });
326
+ return { allow: true, channel: parsed.channel, id: speakerId, kind: parsed.kind };
327
+ }
328
+
329
+ /** Drop rate-limit bookkeeping that can no longer matter. */
330
+ function sweep() {
331
+ const cutoff = now() - pairingTtlMs;
332
+ let dropped = 0;
333
+ for (const [key, record] of attempts) {
334
+ if (record.firstAt < cutoff) {
335
+ attempts.delete(key);
336
+ dropped += 1;
337
+ }
338
+ }
339
+ return { dropped };
340
+ }
341
+
342
+ return {
343
+ admit,
344
+ redeemPairing,
345
+ openPairing,
346
+ trust,
347
+ distrust,
348
+ hasOwners,
349
+ sweep,
350
+ get ownerCount() {
351
+ return trusted.size;
352
+ },
353
+ /** For diagnostics and tests. */
354
+ _internals: { trusted, rooms, attempts, get pairing() { return pairing; } },
355
+ };
356
+ }