@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.
- package/CHANGELOG.md +1030 -0
- package/README.md +9 -2
- package/bin/w2m-localside.mjs +0 -0
- package/bin/w2m-rabbit.mjs +6 -0
- package/bin/w2m-stun.mjs +0 -0
- package/deploy/server/README.md +109 -0
- package/deploy/server/deploy-w2m.sh +161 -0
- package/deploy/server/w2m-localside.service +31 -0
- package/deploy/server/w2m-rabbit.service +26 -0
- package/deploy/server/w2m-stun.service +26 -0
- package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
- package/package.json +33 -22
- package/src/chatops/account.mjs +361 -0
- package/src/chatops/actions.mjs +227 -0
- package/src/chatops/approval-broker.mjs +320 -0
- package/src/chatops/auth.mjs +376 -0
- package/src/chatops/bridge.mjs +344 -0
- package/src/chatops/channel.mjs +192 -0
- package/src/chatops/commands.mjs +409 -0
- package/src/chatops/config-route.mjs +332 -0
- package/src/chatops/device-signing.mjs +218 -0
- package/src/chatops/durability.mjs +202 -0
- package/src/chatops/file-safety.mjs +282 -0
- package/src/chatops/fleet-scope.mjs +176 -0
- package/src/chatops/fleet-wiring.mjs +199 -0
- package/src/chatops/fleet.mjs +272 -0
- package/src/chatops/handoff.mjs +258 -0
- package/src/chatops/ilink-protocol.mjs +165 -0
- package/src/chatops/ilink-transport.mjs +168 -0
- package/src/chatops/ilink.mjs +423 -0
- package/src/chatops/index.mjs +429 -0
- package/src/chatops/outbound-queue.mjs +282 -0
- package/src/chatops/policy.mjs +356 -0
- package/src/chatops/run-command.mjs +308 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/server.mjs +96 -0
- 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
|
+
}
|