pi-roundtable-line 0.13.0 → 0.14.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 CHANGED
@@ -5,6 +5,17 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.14.0] - 2026-10-14
9
+
10
+ ### Added
11
+
12
+ - `LINE.health().webhooks.rejected.signatureForBot` counts the requests that failed the signature check and whose body names this bot's user id as its `destination`; they are still counted in `rejected.signature` as well. A scanner does not know the bot's id, so this counter rising while `accepted` stays flat means a wrong channel secret or URL. The body is read only within the existing size limit and is neither logged nor kept; before the plugin knows the bot's id the count stays zero.
13
+
14
+ ### Changed
15
+
16
+ - `WebhookHealth.rejected` has the new required field `signatureForBot`, so a caller that built a `WebhookHealth` or a `LineBridgePort` of its own must add it.
17
+ - Release in lockstep with pi-roundtable 0.14.0. The core peer range moves to `>=0.14.0 <0.15.0`.
18
+
8
19
  ## [0.13.0] - 2026-10-13
9
20
 
10
21
  ### Added
package/README.md CHANGED
@@ -11,7 +11,7 @@ It covers intake (the credentials and token source, the webhook, the allowlist o
11
11
 
12
12
  ## Requirements
13
13
 
14
- - Bun 1.3 or later, and pi-roundtable `>=0.13.0 <0.14.0` as a peer dependency.
14
+ - Bun 1.3 or later, and pi-roundtable `>=0.14.0 <0.15.0` as a peer dependency.
15
15
  - PostgreSQL, which the host already needs.
16
16
  - An HTTP listener on the host that LINE can reach over HTTPS (the webhook URL is set in the channel's settings).
17
17
  - A Messaging API channel: its channel id and channel secret.
@@ -90,6 +90,7 @@ webhooks: {
90
90
  accepted: number; // answered 200, a Verify included
91
91
  rejected: {
92
92
  signature: number; // 401: the signature is missing or wrong
93
+ signatureForBot: number; // of those, the body names this bot as its destination
93
94
  destination: number; // 403: signed, but for another bot
94
95
  malformed: number; // 400 and 413: not a LINE events object, or over 1 MiB
95
96
  };
@@ -100,7 +101,9 @@ webhooks: {
100
101
 
101
102
  They are counts and times only: no body, address or signature is kept, and a request adds one to a counter and nothing else.
102
103
  A `405` and a `500` (the database could not store the events) are in no counter.
103
- A host that watches for a channel whose webhook URL or secret has drifted can alarm on `rejected.signature` growing while `accepted` does not.
104
+ A host that watches for a channel whose webhook URL or secret has drifted can alarm on `rejected.signatureForBot` growing while `accepted` does not: a scanner that finds the URL does not know the bot's user id, so a request that fails its signature yet names this bot as its `destination` is far more likely LINE with a wrong channel secret (or a rotated one) than noise.
105
+ To tell, the handler reads the `destination` of a body that failed its signature, only within the 1 MiB limit, and never logs or keeps it; `signatureForBot` also counts in `signature`.
106
+ Until the plugin knows the bot's user id it cannot tell, and `signatureForBot` stays at zero.
104
107
 
105
108
  ## Chats and the allowlist
106
109
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-line",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "A LINE Messaging API transport for pi-roundtable: a signed webhook that persists events before it answers, a durable inbox worker, and an allowlist of the groups and chats it mirrors",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,14 +33,14 @@
33
33
  "test": "bun test"
34
34
  },
35
35
  "peerDependencies": {
36
- "pi-roundtable": ">=0.13.0 <0.14.0"
36
+ "pi-roundtable": ">=0.14.0 <0.15.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@biomejs/biome": "2.5.15",
40
40
  "@earendil-works/pi-ai": ">=1.0.0 <2",
41
41
  "@earendil-works/pi-coding-agent": ">=1.0.0 <2",
42
42
  "@types/bun": "1.4.2",
43
- "pi-roundtable": "0.13.0",
43
+ "pi-roundtable": "0.14.0",
44
44
  "typebox": "1.3.34",
45
45
  "typescript": "7.0.2"
46
46
  }
package/src/webhook.ts CHANGED
@@ -13,10 +13,18 @@ export interface WebhookHealth {
13
13
  /** Requests with a valid signature for this bot that were answered `200` (an empty Verify included). */
14
14
  accepted: number;
15
15
  /**
16
- * Requests turned away: `signature` is a missing or wrong `x-line-signature`, `destination` is a
17
- * signed webhook for another bot, `malformed` is a body that is too large or not a webhook.
16
+ * Requests turned away: `signature` is a missing or wrong `x-line-signature`, `signatureForBot`
17
+ * is the part of those whose body names this bot as its `destination` (a scanner does not know
18
+ * the bot's id, so it rising while `accepted` stays flat means a wrong channel secret or URL),
19
+ * `destination` is a signed webhook for another bot, `malformed` is a body that is too large or
20
+ * not a webhook.
18
21
  */
19
- rejected: { signature: number; destination: number; malformed: number };
22
+ rejected: {
23
+ signature: number;
24
+ signatureForBot: number;
25
+ destination: number;
26
+ malformed: number;
27
+ };
20
28
  /** When the last request was accepted, as an ISO time; left out until one was. */
21
29
  lastAcceptedAt?: string;
22
30
  /** When the last request was rejected, as an ISO time; left out until one was. */
@@ -26,7 +34,12 @@ export interface WebhookHealth {
26
34
  /** The webhook's counters: each request adds to one of them and nothing else is kept. */
27
35
  export class WebhookStats {
28
36
  #accepted = 0;
29
- #rejected = { signature: 0, destination: 0, malformed: 0 };
37
+ #rejected = {
38
+ signature: 0,
39
+ signatureForBot: 0,
40
+ destination: 0,
41
+ malformed: 0,
42
+ };
30
43
  #acceptedAt: string | undefined;
31
44
  #rejectedAt: string | undefined;
32
45
 
@@ -35,11 +48,20 @@ export class WebhookStats {
35
48
  this.#acceptedAt = at.toISOString();
36
49
  }
37
50
 
38
- reject(reason: keyof WebhookHealth["rejected"], at: Date): void {
51
+ reject(
52
+ reason: Exclude<keyof WebhookHealth["rejected"], "signatureForBot">,
53
+ at: Date,
54
+ ): void {
39
55
  this.#rejected[reason]++;
40
56
  this.#rejectedAt = at.toISOString();
41
57
  }
42
58
 
59
+ /** A refused signature; `forBot` when the body names this bot, which counts in `signatureForBot` too. */
60
+ rejectSignature(forBot: boolean, at: Date): void {
61
+ this.reject("signature", at);
62
+ if (forBot) this.#rejected.signatureForBot++;
63
+ }
64
+
43
65
  health(): WebhookHealth {
44
66
  return {
45
67
  accepted: this.#accepted,
@@ -100,6 +122,24 @@ async function readCapped(
100
122
  return body;
101
123
  }
102
124
 
125
+ /**
126
+ * The `destination` a body that failed its signature claims, only to tell a request meant for this
127
+ * bot from a scan. The body is already within the size limit; it is neither logged nor kept, and a
128
+ * body that is not JSON, or has no text `destination`, names nobody.
129
+ */
130
+ function claimedDestination(body: Uint8Array): string | undefined {
131
+ try {
132
+ const value: unknown = JSON.parse(
133
+ new TextDecoder("utf-8", { fatal: true }).decode(body),
134
+ );
135
+ if (typeof value !== "object" || value === null) return undefined;
136
+ const destination = (value as { destination?: unknown }).destination;
137
+ return typeof destination === "string" ? destination : undefined;
138
+ } catch {
139
+ return undefined;
140
+ }
141
+ }
142
+
103
143
  /**
104
144
  * LINE's webhook: the raw body is verified against `x-line-signature` before anything is parsed,
105
145
  * every event is stored in one transaction, and only then does it answer `200`. A database that
@@ -121,7 +161,11 @@ export function webhookHandler(
121
161
  }
122
162
  const signature = request.headers.get("x-line-signature");
123
163
  if (!signature || !options.tokens.verifies(body, signature)) {
124
- stats.reject("signature", options.now());
164
+ const bot = options.botUserId();
165
+ stats.rejectSignature(
166
+ bot !== undefined && claimedDestination(body) === bot,
167
+ options.now(),
168
+ );
125
169
  options.logger.warn("a LINE webhook was refused: bad signature");
126
170
  return reply(401, "Unauthorized");
127
171
  }