pi-roundtable-line 0.0.0-stage → 0.9.13

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/src/token.ts ADDED
@@ -0,0 +1,215 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { scrubDiagnostic } from "pi-roundtable/kit";
3
+ import { LineApiError, LineError } from "./errors.ts";
4
+
5
+ /** How the package gets the channel access token it calls LINE with. */
6
+ export type TokenMode = "stateless" | { static: string };
7
+
8
+ /** The channel's credentials. The secret also verifies every webhook signature. */
9
+ export interface LineCredentials {
10
+ /** The channel id, a number written as a string. It is not secret. */
11
+ channelId: string;
12
+ channelSecret: string;
13
+ /**
14
+ * `"stateless"` (the default) asks LINE for a token of about 15 minutes with the channel id and
15
+ * secret, and asks again when it lapses. `{ static: token }` uses a long-lived token a console
16
+ * issued; it cannot be reissued, so a `401` is then final.
17
+ */
18
+ token?: TokenMode;
19
+ }
20
+
21
+ export const LINE_API = "https://api.line.me";
22
+ const TOKEN_PATH = "/oauth2/v3/token";
23
+ /** A token is replaced this long before LINE would refuse it. */
24
+ const EARLY_MS = 60_000;
25
+ /** The request timeout for the token endpoint. */
26
+ const ISSUE_TIMEOUT_MS = 15_000;
27
+
28
+ export interface TokenSourceOptions {
29
+ credentials: LineCredentials;
30
+ /** Replaces `fetch`, for tests. */
31
+ fetch?: typeof fetch;
32
+ /** The current time in milliseconds, for tests. */
33
+ now?: () => number;
34
+ baseUrl?: string;
35
+ }
36
+
37
+ interface Issued {
38
+ token: string;
39
+ /** Epoch milliseconds after which the token is replaced. */
40
+ validUntil: number;
41
+ }
42
+
43
+ /**
44
+ * Hands out the channel access token. It issues stateless tokens one at a time (concurrent callers
45
+ * share one request), keeps a token in memory only until a minute before it lapses, and forgets
46
+ * one LINE rejected. The token and the secret live in private fields: they are in no log, no error
47
+ * message and no serialized form of this object.
48
+ */
49
+ export class TokenSource {
50
+ readonly #id: string;
51
+ readonly #secret: string;
52
+ readonly #static: string | undefined;
53
+ readonly #fetch: typeof fetch;
54
+ readonly #now: () => number;
55
+ readonly #base: string;
56
+ #current: Issued | undefined;
57
+ #inflight: Promise<Issued> | undefined;
58
+ #issued = 0;
59
+ #lastFailure: string | undefined;
60
+
61
+ constructor(options: TokenSourceOptions) {
62
+ const {
63
+ channelId,
64
+ channelSecret,
65
+ token = "stateless",
66
+ } = options.credentials;
67
+ if (typeof channelId !== "string" || !/^[0-9]+$/.test(channelId))
68
+ throw new LineError(
69
+ "bad_credentials",
70
+ "line: credentials.channelId must be the channel id, digits written as a string",
71
+ );
72
+ if (typeof channelSecret !== "string" || channelSecret.length === 0)
73
+ throw new LineError(
74
+ "bad_credentials",
75
+ "line: credentials.channelSecret must be a non-empty string",
76
+ );
77
+ if (
78
+ token !== "stateless" &&
79
+ (typeof token !== "object" ||
80
+ token === null ||
81
+ typeof token.static !== "string" ||
82
+ token.static.length === 0)
83
+ )
84
+ throw new LineError(
85
+ "bad_credentials",
86
+ 'line: credentials.token must be "stateless" or { static: <token> }',
87
+ );
88
+ this.#id = channelId;
89
+ this.#secret = channelSecret;
90
+ this.#static = token === "stateless" ? undefined : token.static;
91
+ this.#fetch = options.fetch ?? fetch;
92
+ this.#now = options.now ?? Date.now;
93
+ this.#base = options.baseUrl ?? LINE_API;
94
+ }
95
+
96
+ get channelId(): string {
97
+ return this.#id;
98
+ }
99
+
100
+ /**
101
+ * Whether `signature` (the base64 `x-line-signature` header) is the HMAC-SHA256 of `body` under
102
+ * the channel secret, compared in constant time. The secret itself never leaves this object.
103
+ */
104
+ verifies(body: Uint8Array, signature: string): boolean {
105
+ const given = Buffer.from(signature, "base64");
106
+ const expected = createHmac("sha256", this.#secret).update(body).digest();
107
+ // A signature of another length is wrong; timingSafeEqual would throw on it.
108
+ return given.length === expected.length && timingSafeEqual(given, expected);
109
+ }
110
+
111
+ /** How many stateless tokens were issued, and the last failure's code; neither holds a secret. */
112
+ health(): { issued: number; lastFailure: string | undefined } {
113
+ return { issued: this.#issued, lastFailure: this.#lastFailure };
114
+ }
115
+
116
+ /** A token that is valid now, issuing one when there is none. */
117
+ async token(): Promise<string> {
118
+ if (this.#static) return this.#static;
119
+ const held = this.#current;
120
+ if (held && held.validUntil > this.#now()) return held.token;
121
+ this.#inflight ??= this.#issue().finally(() => {
122
+ this.#inflight = undefined;
123
+ });
124
+ return (await this.#inflight).token;
125
+ }
126
+
127
+ /**
128
+ * Drops `rejected` when LINE refused it, so the next `token()` issues a new one. Returns whether
129
+ * a new token can come: a static token cannot be replaced.
130
+ */
131
+ reject(rejected: string): boolean {
132
+ if (this.#static) return false;
133
+ if (this.#current?.token === rejected) this.#current = undefined;
134
+ return true;
135
+ }
136
+
137
+ async #issue(): Promise<Issued> {
138
+ let response: Response;
139
+ try {
140
+ response = await this.#fetch(`${this.#base}${TOKEN_PATH}`, {
141
+ method: "POST",
142
+ headers: { "content-type": "application/x-www-form-urlencoded" },
143
+ body: new URLSearchParams({
144
+ grant_type: "client_credentials",
145
+ client_id: this.#id,
146
+ client_secret: this.#secret,
147
+ }),
148
+ signal: AbortSignal.timeout(ISSUE_TIMEOUT_MS),
149
+ });
150
+ } catch (error) {
151
+ this.#lastFailure = "token_unreachable";
152
+ throw new LineError(
153
+ "token_unreachable",
154
+ `line: the token endpoint did not answer (${this.redact(error instanceof Error ? error.message : String(error))})`,
155
+ );
156
+ }
157
+ if (!response.ok) {
158
+ this.#lastFailure = "token_refused";
159
+ // The body is not read into the message: an endpoint may echo what it was sent.
160
+ await response.body?.cancel().catch(() => undefined);
161
+ throw new LineApiError(
162
+ response.status,
163
+ "token_refused",
164
+ `line: the token endpoint answered ${response.status}`,
165
+ response.headers.get("x-line-request-id") ?? undefined,
166
+ );
167
+ }
168
+ const body = (await response.json().catch(() => undefined)) as
169
+ | { access_token?: unknown; expires_in?: unknown }
170
+ | undefined;
171
+ const token = body?.access_token;
172
+ const lifetime = Number(body?.expires_in);
173
+ if (typeof token !== "string" || token === "" || !(lifetime > 0)) {
174
+ this.#lastFailure = "token_malformed";
175
+ throw new LineError(
176
+ "token_malformed",
177
+ "line: the token endpoint answered without a usable access token",
178
+ );
179
+ }
180
+ this.#issued++;
181
+ this.#lastFailure = undefined;
182
+ const lifeMs = lifetime * 1000;
183
+ // A very short token still lives for half its life instead of being refused at once.
184
+ const margin = Math.min(EARLY_MS, lifeMs / 2);
185
+ const issued = { token, validUntil: this.#now() + lifeMs - margin };
186
+ this.#current = issued;
187
+ return issued;
188
+ }
189
+
190
+ /** Text safe to show: credentials are masked and the length is bounded, whatever a failing call quoted. */
191
+ redact(message: string, max = 200): string {
192
+ let text = scrubDiagnostic(message, max);
193
+ for (const secret of [this.#secret, this.#current?.token, this.#static])
194
+ if (secret)
195
+ // A form body spells the secret percent-encoded, a header or URL may spell it either way.
196
+ for (const spelling of new Set([
197
+ secret,
198
+ encodeURIComponent(secret),
199
+ new URLSearchParams({ s: secret }).toString().slice(2),
200
+ ]))
201
+ text = text.split(spelling).join("[redacted]");
202
+ return text;
203
+ }
204
+
205
+ toJSON(): { channelId: string; token: string } {
206
+ return {
207
+ channelId: this.#id,
208
+ token: this.#static ? "static" : "stateless",
209
+ };
210
+ }
211
+
212
+ [Symbol.for("nodejs.util.inspect.custom")](): string {
213
+ return `TokenSource(${this.#id}, ${this.#static ? "static" : "stateless"})`;
214
+ }
215
+ }
package/src/webhook.ts ADDED
@@ -0,0 +1,110 @@
1
+ import type { SQL } from "bun";
2
+ import type { Logger } from "pi-roundtable";
3
+ import { errorCode, LineError } from "./errors.ts";
4
+ import { parseWebhook } from "./events.ts";
5
+ import { persistEvents } from "./intake.ts";
6
+ import type { TokenSource } from "./token.ts";
7
+
8
+ /** The most a webhook request body may hold: LINE sends far less, and anything near this is not LINE. */
9
+ export const WEBHOOK_MAX_BYTES = 1024 * 1024;
10
+
11
+ export interface WebhookOptions {
12
+ sql: SQL;
13
+ tokens: TokenSource;
14
+ logger: Logger;
15
+ now: () => Date;
16
+ /** The bot's user id, once known; a webhook for another bot is refused. Unknown means the signature alone vouches. */
17
+ botUserId: () => string | undefined;
18
+ /** Called after events were stored, so the worker looks at the inbox now rather than at its next poll. */
19
+ stored: () => void;
20
+ maxBytes?: number;
21
+ }
22
+
23
+ const reply = (status: number, text: string) =>
24
+ new Response(text, { status, headers: { "cache-control": "no-store" } });
25
+
26
+ /**
27
+ * Reads a body of at most `max` bytes; undefined when it is larger. The declared length is
28
+ * checked first, and a body that lies about it is cut off as soon as it passes the limit.
29
+ */
30
+ async function readCapped(
31
+ request: Request,
32
+ max: number,
33
+ ): Promise<Uint8Array | undefined> {
34
+ const declared = Number(request.headers.get("content-length"));
35
+ if (Number.isFinite(declared) && declared > max) return undefined;
36
+ if (!request.body) return new Uint8Array();
37
+ const chunks: Uint8Array[] = [];
38
+ let size = 0;
39
+ const reader = request.body.getReader();
40
+ for (;;) {
41
+ const { done, value } = await reader.read();
42
+ if (done) break;
43
+ size += value.byteLength;
44
+ if (size > max) {
45
+ await reader.cancel().catch(() => undefined);
46
+ return undefined;
47
+ }
48
+ chunks.push(value);
49
+ }
50
+ const body = new Uint8Array(size);
51
+ let at = 0;
52
+ for (const chunk of chunks) {
53
+ body.set(chunk, at);
54
+ at += chunk.byteLength;
55
+ }
56
+ return body;
57
+ }
58
+
59
+ /**
60
+ * LINE's webhook: the raw body is verified against `x-line-signature` before anything is parsed,
61
+ * every event is stored in one transaction, and only then does it answer `200`. A database that
62
+ * is down answers `500`, which makes LINE's redelivery (if the channel has it on) resend.
63
+ * An empty `events` array (LINE's Verify button) is a `200` that stores nothing.
64
+ */
65
+ export function webhookHandler(
66
+ options: WebhookOptions,
67
+ ): (request: Request) => Promise<Response> {
68
+ const max = options.maxBytes ?? WEBHOOK_MAX_BYTES;
69
+ return async (request) => {
70
+ if (request.method !== "POST") return reply(405, "Method Not Allowed");
71
+ const body = await readCapped(request, max);
72
+ if (!body) return reply(413, "Payload Too Large");
73
+ const signature = request.headers.get("x-line-signature");
74
+ if (!signature || !options.tokens.verifies(body, signature)) {
75
+ options.logger.warn("a LINE webhook was refused: bad signature");
76
+ return reply(401, "Unauthorized");
77
+ }
78
+ let parsed: Awaited<ReturnType<typeof parseWebhook>>;
79
+ try {
80
+ parsed = await parseWebhook(body);
81
+ } catch (error) {
82
+ if (!(error instanceof LineError)) throw error;
83
+ options.logger.warn({ code: error.code }, "a LINE webhook was refused");
84
+ return reply(400, "Bad Request");
85
+ }
86
+ const bot = options.botUserId();
87
+ if (bot !== undefined && parsed.destination !== bot) {
88
+ options.logger.warn("a LINE webhook was refused: it is for another bot");
89
+ return reply(403, "Forbidden");
90
+ }
91
+ if (parsed.events.length === 0) return reply(200, "OK");
92
+ try {
93
+ const result = await persistEvents(
94
+ options.sql,
95
+ parsed.events,
96
+ options.now(),
97
+ );
98
+ options.logger.info(result, "LINE events stored");
99
+ if (result.queued > 0) options.stored();
100
+ } catch (error) {
101
+ // The code only: the message could quote a column of the event being stored.
102
+ options.logger.error(
103
+ { code: errorCode(error) },
104
+ "LINE events could not be stored",
105
+ );
106
+ return reply(500, "Internal Server Error");
107
+ }
108
+ return reply(200, "OK");
109
+ };
110
+ }