message-cp 0.0.0-stage → 0.1.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/dist/http.js ADDED
@@ -0,0 +1,1933 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { IncomingMessage, ServerResponse, createServer as createHttpServer } from "node:http";
3
+ import { PassThrough } from "node:stream";
4
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
+ import { LATEST_PROTOCOL_VERSION, isInitializeRequest, isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js";
6
+ import { accountForToken, accountName, listApprovals } from "./accounts.js";
7
+ import { DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT, PersonError, ToolError, WrongPersonError, markWaiting, nameOfIdentity, ownAgentOf, registerWithIdentity, registeredBy } from "./agents.js";
8
+ import { DEFAULT_CHANNEL_LIMIT, MAX_CHANNEL_LIMIT, listAccountChannels, postAsPerson, readAccountChannel, } from "./channels.js";
9
+ import { APPROVE_HEADERS, approvePage, errorPage } from "./approve.js";
10
+ import { mcpEndpoint } from "./connect.js";
11
+ import { DASHBOARD_FONTS, DASHBOARD_HEADERS, DASHBOARD_HTML } from "./dashboard.js";
12
+ import { LoginError, LoginRefused, NotAdminError, OwnPersonError, addInvitedPerson, addTokenBy, cancelInvite, dashboardUrl, endSigninBy, listAccountPeople, listInvites, loginLink, makeInvite, redeemLoginCode, listWebhooksBy, removeAgentBy, removeWebhookBy, addWebhookBy, listSigninsBy, listTokensBy, revokeApprovalBy, revokeTokenBy, resetAgentKeyBy, removePersonBy, setAdminBy, } from "./login.js";
13
+ import { DEFAULT_INBOX_LIMIT, MAX_INBOX_LIMIT, deleteConversation, hasUnread, listAccountMessages, listAgentsUnread, searchAccount, sendAsPerson, unreadSummary, } from "./messages.js";
14
+ import { fetchClientMetadata } from "./client-metadata.js";
15
+ import { OAuthError, approveAuthorization, checkMetadataUrl, clientFromMetadata, exchangeCode, isMetadataUrl, redirectTo, registerClient, startAuthorization, freeAgentName, } from "./oauth.js";
16
+ import { DEFAULT_MAX_SESSIONS, Session, createServer } from "./server.js";
17
+ import { endSession, forgetIdleSessions, restorableSession, saveSession, sessionEnded, touchSession } from "./sessions.js";
18
+ import { DEFAULT_WAIT_HOLD_MS, formatUnread } from "./wait.js";
19
+ import { WebhookError, listAccountDeliveries, sendOptions, sendPending } from "./webhooks.js";
20
+ export const DEFAULT_HTTP_PORT = 8787;
21
+ export const DEFAULT_HTTP_HOST = "127.0.0.1";
22
+ export const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
23
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
24
+ /**
25
+ * The body limit for POST /api/send and /api/post. A 4000-character body is at most 48 KiB as JSON (12 bytes for an
26
+ * escaped astral character), so this fits any valid request, and a full cap of them buffers at most 16 MiB.
27
+ */
28
+ const MAX_PERSON_BODY_BYTES = 64 * 1024;
29
+ /** What POST /api/send and /api/post answer a full token, which acts as no person, with its 403. */
30
+ const FULL_TOKEN_SENDS = "A full token cannot send or post as a person. Sign in as a person to send: run message-cp login-link --url <public url> --account <account> --person <name> on the server";
31
+ /** What the /api/people and /api/invites routes answer a full token, which is no admin person, with its 403. */
32
+ const FULL_TOKEN_INVITES = "A full token cannot add, mark or remove people or make invites. Sign in as an admin person: run message-cp login-link --url <public url> --account <account> --person <name> on the server";
33
+ /** What DELETE /api/agents and POST /api/agents/key answer a full token, which is no admin person, with its 403. */
34
+ const FULL_TOKEN_AGENTS = "A full token cannot remove agents or reset their keys. Run message-cp agent remove or agent key reset on the server, or sign in as an admin person: run message-cp login-link --url <public url> --account <account> --person <name> on the server";
35
+ /** The fields a POST /api/webhooks body may have. */
36
+ const WEBHOOK_FIELDS = ["url", "on", "includeText", "header", "maxPerHour", "body"];
37
+ /** What GET, POST and DELETE /api/webhooks answer a full token, which is no admin person, with its 403. */
38
+ const FULL_TOKEN_WEBHOOKS = "A full token cannot list, add or remove webhooks over the API. Run message-cp webhook list, webhook add or webhook remove on the server, or sign in as an admin person: run message-cp login-link --url <public url> --account <account> --person <name> on the server";
39
+ /** What DELETE /api/approvals answers a full token, which is no admin person, with its 403. */
40
+ const FULL_TOKEN_APPROVALS = "A full token cannot revoke approved clients over the API. Run message-cp token list, then message-cp token revoke <id>, on the server, or sign in as an admin person: run message-cp login-link --url <public url> --account <account> --person <name> on the server";
41
+ /** What /api/tokens answers a full token, which is no admin person, with its 403. */
42
+ const FULL_TOKEN_TOKENS = "A full token cannot list, make or revoke tokens over the API. Run message-cp token list, token add <account> --read-only or --agent-only, or token revoke <id> on the server, or sign in as an admin person: run message-cp login-link --url <public url> --account <account> --person <name> on the server";
43
+ /** What /api/signins answers a full token, which is no admin person, with its 403. */
44
+ const FULL_TOKEN_SIGNINS = "A full token cannot list or end dashboard sign-ins over the API. Sign in as an admin person: run message-cp login-link --url <public url> --account <account> --person <name> on the server, or run message-cp token revoke <id> on the server to end every sign-in of a token";
45
+ /** The body limit for the OAuth registration, approval and token endpoints, which take a few short fields. */
46
+ const MAX_OAUTH_BODY_BYTES = 16 * 1024;
47
+ /** An agent seen this recently counts as online in GET /api/agents, as does one with a GET /wait held now. */
48
+ export const ONLINE_WINDOW_MS = 5 * 60 * 1000;
49
+ /** How often a pass over the webhook delivery queue is due; a tick while a pass is still running is skipped. */
50
+ export const DEFAULT_WEBHOOK_INTERVAL_MS = 5000;
51
+ const isBusy = (err) => {
52
+ const code = err?.code;
53
+ return typeof code === "string" && code.startsWith("SQLITE_BUSY");
54
+ };
55
+ /**
56
+ * Runs the database work that keeps a registered session restorable. It never throws: if it fails, the session works
57
+ * as before, but the work is not retried, so a restart may lose the session. A lock held past the busy timeout is
58
+ * skipped quietly; any other error logs one fixed line, never anything about the session. Returns false if the lock was
59
+ * held past the busy timeout.
60
+ */
61
+ function recordSession(work) {
62
+ try {
63
+ work();
64
+ }
65
+ catch (err) {
66
+ if (isBusy(err))
67
+ return false;
68
+ console.error("message-cp serve: could not record a session for restarts");
69
+ }
70
+ return true;
71
+ }
72
+ /**
73
+ * Initializes a new transport for a restored session in-process, so that it accepts the client's requests on the
74
+ * session id it already holds. The transport accepts nothing but an initialize until it has handled one. Its answer
75
+ * is discarded; the server reads nothing from it.
76
+ */
77
+ async function prime(transport) {
78
+ const socket = new PassThrough();
79
+ socket.resume();
80
+ const req = new IncomingMessage(socket);
81
+ req.method = "POST";
82
+ req.url = "/mcp";
83
+ // The transport builds a URL from the Host header, so it needs one; nothing else reads it.
84
+ const headers = { host: DEFAULT_HTTP_HOST, "content-type": "application/json", accept: "application/json, text/event-stream" };
85
+ req.headers = headers;
86
+ req.rawHeaders = Object.entries(headers).flat();
87
+ req.push(null);
88
+ const res = new ServerResponse(req);
89
+ res.assignSocket(socket);
90
+ const finished = new Promise((resolve) => res.on("finish", () => resolve()));
91
+ await transport.handleRequest(req, res, {
92
+ jsonrpc: "2.0",
93
+ id: 0,
94
+ method: "initialize",
95
+ params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "message-cp restore", version: "0" } },
96
+ });
97
+ await finished;
98
+ socket.destroy();
99
+ if (res.statusCode !== 200)
100
+ throw new Error("could not restore a session");
101
+ }
102
+ /**
103
+ * The agent and key a request names in X-Message-CP-Agent and X-Message-CP-Agent-Key: undefined if it sends neither,
104
+ * null if it sends only one or either more than once. `headersDistinct` keeps a repeated header's values apart, where
105
+ * `headers` would join them into one.
106
+ */
107
+ function agentHeaders(req) {
108
+ const name = req.headersDistinct["x-message-cp-agent"];
109
+ const key = req.headersDistinct["x-message-cp-agent-key"];
110
+ if (name === undefined && key === undefined)
111
+ return undefined;
112
+ if (name?.length !== 1 || key?.length !== 1)
113
+ return null;
114
+ return { name: name[0], key: key[0] };
115
+ }
116
+ /** Whether `identity` is the registration `agent` holds now; false for none. */
117
+ const isAgent = (identity, agent) => identity !== undefined && identity.id === agent.id && identity.registeredAt === agent.registeredAt && identity.instance === agent.instance;
118
+ /**
119
+ * Whether a request with `access` may act through a session registered as `identity` (undefined for none yet): a bound
120
+ * token only as its agent; a token that registers its own agents only as one it registered, or through a session not yet
121
+ * registered or whose registration was removed, which `Session.current()` treats as unregistered and where its
122
+ * `register` is checked; any other token as any agent of its account.
123
+ */
124
+ const mayActAs = (db, access, identity) => {
125
+ if (access.agent)
126
+ return isAgent(identity, access.agent);
127
+ if (access.registers !== undefined) {
128
+ return identity === undefined || nameOfIdentity(db, access.account, identity) === undefined || registeredBy(db, access.account, identity, access.registers);
129
+ }
130
+ return true;
131
+ };
132
+ /** The account the request's bearer token belongs to, checked afresh on every request so a revoked token stops at once. */
133
+ function accountOf(db, header, ownerToken) {
134
+ const m = /^bearer +(.*)$/i.exec(header ?? "");
135
+ // Hash something even without a header, so a refusal takes about as long either way.
136
+ const account = accountForToken(db, m?.[1] ?? "", ownerToken);
137
+ return m === null ? undefined : account;
138
+ }
139
+ function reply(res, status, message, headers = {}) {
140
+ res.writeHead(status, { "Content-Type": "application/json", ...headers });
141
+ res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message }, id: null }));
142
+ }
143
+ function sendJson(res, value) {
144
+ res.writeHead(200, { "Content-Type": "application/json" });
145
+ res.end(JSON.stringify(value));
146
+ }
147
+ /**
148
+ * The `limit` and `offset` of a GET /api/* query, or undefined if either is bad. A key given twice is refused like a
149
+ * bad value, so no value supplied is silently ignored.
150
+ */
151
+ function page(query, defaultLimit, maxLimit) {
152
+ const number = (key, fallback, min, max) => {
153
+ const values = query.getAll(key);
154
+ if (values.length === 0)
155
+ return fallback;
156
+ if (values.length > 1)
157
+ return undefined;
158
+ const raw = values[0];
159
+ const n = /^[0-9]+$/.test(raw) ? Number(raw) : NaN;
160
+ return Number.isSafeInteger(n) && n >= min && n <= max ? n : undefined;
161
+ };
162
+ const limit = number("limit", defaultLimit, 1, maxLimit);
163
+ const offset = number("offset", 0, 0, Number.MAX_SAFE_INTEGER);
164
+ return limit === undefined || offset === undefined ? undefined : { limit, offset };
165
+ }
166
+ /**
167
+ * Runs the database work of an /api/* request. A lock held past the busy timeout is transient, so it answers 503 with
168
+ * Retry-After rather than 500, and returns undefined; any other error is thrown.
169
+ */
170
+ function unlessBusy(res, work) {
171
+ try {
172
+ return work();
173
+ }
174
+ catch (err) {
175
+ const code = err?.code;
176
+ if (typeof code !== "string" || !code.startsWith("SQLITE_BUSY"))
177
+ throw err;
178
+ reply(res, 503, "Database busy", { "Retry-After": "1" });
179
+ return undefined;
180
+ }
181
+ }
182
+ async function readText(req, maxBytes) {
183
+ const chunks = [];
184
+ let size = 0;
185
+ for await (const chunk of req) {
186
+ size += chunk.length;
187
+ if (size > maxBytes)
188
+ throw new RangeError("body too large");
189
+ chunks.push(chunk);
190
+ }
191
+ return Buffer.concat(chunks).toString("utf8");
192
+ }
193
+ const readBody = async (req, maxBytes = MAX_BODY_BYTES) => JSON.parse(await readText(req, maxBytes));
194
+ /** An OAuth error answer (RFC 6749 section 5.2), never cached. Its description never quotes the request. */
195
+ function oauthError(res, status, error, description, headers = {}) {
196
+ res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers });
197
+ res.end(JSON.stringify({ error, error_description: description }));
198
+ }
199
+ function sendOAuthJson(res, status, value) {
200
+ res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
201
+ res.end(JSON.stringify(value));
202
+ }
203
+ /**
204
+ * What `serve --public-url` advertises for MCP's authorization flow: the protected resource metadata (RFC 9728) for
205
+ * /mcp, the authorization server metadata (RFC 8414), and the 401's WWW-Authenticate challenge pointing at the first.
206
+ * `publicUrl` is one `serve` checked with `publicUrl` in connect.ts.
207
+ */
208
+ function oauthUrls(publicUrl) {
209
+ const base = new URL(publicUrl);
210
+ const prefix = base.pathname.replace(/\/+$/, "");
211
+ const at = (path) => {
212
+ const url = new URL(base.href);
213
+ // Set as the path, never resolved as a reference, so no path can change the host.
214
+ url.pathname = `${prefix}${path}`;
215
+ return url.href;
216
+ };
217
+ // The public URL, without the slash a URL with no path gets, as an issuer is usually written.
218
+ const issuer = prefix ? at("") : base.origin;
219
+ const resourceMetadata = at("/.well-known/oauth-protected-resource/mcp");
220
+ return {
221
+ resource: { resource: mcpEndpoint(publicUrl).href, authorization_servers: [issuer], bearer_methods_supported: ["header"] },
222
+ server: {
223
+ issuer,
224
+ authorization_endpoint: at("/oauth/authorize"),
225
+ token_endpoint: at("/oauth/token"),
226
+ registration_endpoint: at("/oauth/register"),
227
+ response_types_supported: ["code"],
228
+ grant_types_supported: ["authorization_code"],
229
+ code_challenge_methods_supported: ["S256"],
230
+ token_endpoint_auth_methods_supported: ["none"],
231
+ client_id_metadata_document_supported: true,
232
+ },
233
+ challenge: `Bearer resource_metadata="${resourceMetadata}"`,
234
+ // Where each metadata document is asked for: at the root, and, for a public URL with a path, with that path after
235
+ // the well-known part, as RFC 8414 and RFC 9728 build it from an issuer or resource with a path.
236
+ resourcePaths: new Set([
237
+ "/.well-known/oauth-protected-resource",
238
+ "/.well-known/oauth-protected-resource/mcp",
239
+ `/.well-known/oauth-protected-resource${prefix}/mcp`,
240
+ ]),
241
+ serverPaths: new Set(["/.well-known/oauth-authorization-server", `/.well-known/oauth-authorization-server${prefix}`]),
242
+ // The origin a browser names in Origin on a request from the approval page or the dashboard, and the path the
243
+ // sign-in cookie is sent under: the approval page's directory, /oauth/ under the public URL's path.
244
+ origin: base.origin,
245
+ cookiePath: `${prefix}/oauth/`,
246
+ // Whether the sign-in cookie is offered at all: only under an https public URL, so the cookie is always Secure. A
247
+ // browser sends a cookie to every port of its host, so over http one would also reach any other server on that host
248
+ // that the browser opens, such as a client's loopback redirect URI, with the token in it.
249
+ signin: base.protocol === "https:",
250
+ };
251
+ }
252
+ /** The cookie the approval page signs in with: the dashboard's token, set by POST /oauth/signin. */
253
+ const SIGNIN_COOKIE = "message-cp-signin";
254
+ /** The value of the sign-in cookie in `header`, or undefined for none or more than one. */
255
+ function signinCookie(header) {
256
+ const values = (header ?? "")
257
+ .split(";")
258
+ .map((part) => part.trim())
259
+ .filter((part) => part.startsWith(`${SIGNIN_COOKIE}=`))
260
+ .map((part) => part.slice(SIGNIN_COOKIE.length + 1));
261
+ if (values.length !== 1)
262
+ return undefined;
263
+ try {
264
+ return decodeURIComponent(values[0]);
265
+ }
266
+ catch {
267
+ return undefined;
268
+ }
269
+ }
270
+ /**
271
+ * Whether `req` comes from a page of this server's own origin, as a browser says: Origin, which it sends on every
272
+ * POST and DELETE, equal to `origin`; or, without Origin (a same-origin GET), Sec-Fetch-Site "same-origin". A request
273
+ * with neither, as from a script outside a browser, is not.
274
+ */
275
+ function sameOrigin(req, origin) {
276
+ const named = req.headersDistinct.origin;
277
+ if (named !== undefined)
278
+ return named.length === 1 && named[0] === origin;
279
+ const site = req.headersDistinct["sec-fetch-site"];
280
+ return site?.length === 1 && site[0] === "same-origin";
281
+ }
282
+ /**
283
+ * Serves the MCP tools over streamable HTTP at /mcp, one `Session` per MCP session. Every request needs the token,
284
+ * except GET / for the dashboard page, GET on each of the dashboard's font paths (`DASHBOARD_FONTS`), /health and, with
285
+ * a public URL, the authorization flow's metadata, registration, authorization page and token endpoint, and
286
+ * /oauth/signin and a token-less /oauth/approve, which use the sign-in cookie from this server's own origin instead.
287
+ */
288
+ export async function startHttp(db, options) {
289
+ if (!options.token)
290
+ throw new Error("an HTTP token is required");
291
+ const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
292
+ if (!Number.isFinite(idleTimeoutMs) || idleTimeoutMs < 0)
293
+ throw new Error("idleTimeoutMs must be a finite number >= 0");
294
+ const maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
295
+ if (!Number.isInteger(maxSessions) || maxSessions < 1)
296
+ throw new Error("maxSessions must be a positive integer");
297
+ const waitHoldMs = options.waitHoldMs ?? DEFAULT_WAIT_HOLD_MS;
298
+ if (!Number.isFinite(waitHoldMs) || waitHoldMs < 0)
299
+ throw new Error("waitHoldMs must be a finite number >= 0");
300
+ const webhookIntervalMs = options.webhookIntervalMs ?? DEFAULT_WEBHOOK_INTERVAL_MS;
301
+ if (!Number.isFinite(webhookIntervalMs) || webhookIntervalMs < 10)
302
+ throw new Error("webhookIntervalMs must be a finite number >= 10");
303
+ const webhookOptions = sendOptions(options.webhookOptions ?? {});
304
+ const sessions = new Map();
305
+ let initializing = 0;
306
+ let waiting = 0;
307
+ // POST /mcp requests for an existing session whose body is being read, so a session cannot buffer bodies unbounded.
308
+ let reading = 0;
309
+ // POST /api/send, /api/post, /api/invites, /api/tokens and /api/webhooks, and POST and PATCH /api/people, requests
310
+ // between taking a slot and answering, so buffered bodies are bounded.
311
+ let actingAsPerson = 0;
312
+ // Held GET /wait requests per account and agent name (lower-cased, as names are unique regardless of case).
313
+ const held = new Map();
314
+ const holding = (account, name, delta) => {
315
+ const agents = held.get(account) ?? new Map();
316
+ const key = name.toLowerCase();
317
+ const n = (agents.get(key) ?? 0) + delta;
318
+ if (n > 0)
319
+ agents.set(key, n);
320
+ else
321
+ agents.delete(key);
322
+ if (agents.size > 0)
323
+ held.set(account, agents);
324
+ else
325
+ held.delete(account);
326
+ };
327
+ /**
328
+ * The lower-cased name of the agent `live` is currently registered as, or undefined if none. It never throws: a lock
329
+ * held past the busy timeout is `null`, to try again later; any other error logs one fixed line and counts as none.
330
+ */
331
+ const agentOf = (live) => {
332
+ try {
333
+ return live.session?.current()?.toLowerCase();
334
+ }
335
+ catch (err) {
336
+ if (isBusy(err))
337
+ return null;
338
+ console.error("message-cp serve: could not check a session's agent");
339
+ return undefined;
340
+ }
341
+ };
342
+ /** Whether `live`'s current agent has a GET /wait held, looked up under the session's own account only. */
343
+ const agentWaiting = (live) => {
344
+ const waits = held.get(live.account);
345
+ if (!waits)
346
+ return false;
347
+ const name = agentOf(live);
348
+ return name === null ? null : name !== undefined && waits.has(name);
349
+ };
350
+ /**
351
+ * Marks used now every session of `account` currently registered as `name`. It waits out the busy timeout once at
352
+ * most: after a busy write the rest are marked used here but not recorded, and a busy read ends the pass.
353
+ */
354
+ const touchSessionsOf = (account, name) => {
355
+ const key = name.toLowerCase();
356
+ const now = Date.now();
357
+ let busy = false;
358
+ for (const [id, live] of sessions) {
359
+ if (live.account !== account)
360
+ continue;
361
+ const agent = agentOf(live);
362
+ if (agent === null)
363
+ return;
364
+ if (agent !== key)
365
+ continue;
366
+ live.lastUsed = now;
367
+ if (!busy)
368
+ busy = !recordSession(() => touchSession(db, id, now));
369
+ }
370
+ };
371
+ const drop = (id) => {
372
+ const live = sessions.get(id);
373
+ if (!live)
374
+ return;
375
+ sessions.delete(id);
376
+ void live.transport.close().catch(() => { });
377
+ };
378
+ // Dropping a session in close() keeps its row, so the restart it is part of can restore it; the sweep forgets it.
379
+ // Ended rows are kept for a second idle timeout, which covers every server's live copy only while the servers sharing
380
+ // the database use the same timeout (`serve` always uses the default). Should one be swept early and recorded again,
381
+ // it can still only restore the registration that recorded it, and not once that is removed.
382
+ // A session whose agent has a GET /wait held counts as used now, so an agent stays registered while it waits. After the
383
+ // first busy result a pass does no more database work, so it waits out the busy timeout once at most: an idle session
384
+ // whose account has a wait held is kept for this pass, any other is dropped, and rows are forgotten on a later pass.
385
+ const sweep = setInterval(() => {
386
+ const now = Date.now();
387
+ const cutoff = now - idleTimeoutMs;
388
+ let busy = false;
389
+ for (const [id, live] of sessions) {
390
+ if (live.lastUsed >= cutoff)
391
+ continue;
392
+ const waiting = busy ? (held.has(live.account) ? null : false) : agentWaiting(live);
393
+ if (waiting === false)
394
+ drop(id);
395
+ else if (waiting === null)
396
+ busy = true;
397
+ else {
398
+ live.lastUsed = now;
399
+ if (!recordSession(() => touchSession(db, id, now)))
400
+ busy = true;
401
+ }
402
+ }
403
+ if (!busy)
404
+ recordSession(() => forgetIdleSessions(db, cutoff, cutoff - idleTimeoutMs));
405
+ }, Math.max(10, Math.min(60_000, idleTimeoutMs / 2)));
406
+ sweep.unref();
407
+ // Webhook deliveries go out from here only: one pass at a time, each sending at most a batch, so outbound work is
408
+ // bounded. `close()` stops new claims and waits for the pass in progress.
409
+ const stopping = new AbortController();
410
+ let sending;
411
+ const deliver = setInterval(() => {
412
+ if (sending)
413
+ return;
414
+ sending = (async () => {
415
+ try {
416
+ await sendPending(db, { ...webhookOptions, signal: stopping.signal });
417
+ }
418
+ catch (err) {
419
+ // A lock held past the busy timeout is retried on the next pass; nothing about a delivery is logged.
420
+ const code = err?.code;
421
+ if (typeof code !== "string" || !code.startsWith("SQLITE_BUSY"))
422
+ console.error("message-cp serve: webhook pass failed");
423
+ }
424
+ finally {
425
+ sending = undefined;
426
+ }
427
+ })();
428
+ }, webhookIntervalMs);
429
+ deliver.unref();
430
+ /**
431
+ * What each session's tools are given: `wait_for_mail` and `ask` holds count with waiting GET /wait requests against
432
+ * the same cap, and a hold ends once the response that would carry its answer has closed.
433
+ */
434
+ const serverOptions = (live) => ({
435
+ remote: true,
436
+ publicUrl: options.publicUrl,
437
+ hold: () => {
438
+ if (waiting >= maxSessions)
439
+ return undefined;
440
+ waiting++;
441
+ return () => void waiting--;
442
+ },
443
+ closed: (id) => live.open.get(id)?.signal,
444
+ });
445
+ /**
446
+ * One check for mail, for a name or for one registration; a lock held past the busy timeout counts as no mail this
447
+ * time, as in `message-cp wait`.
448
+ */
449
+ const poll = (account, reader) => {
450
+ try {
451
+ return unreadSummary(db, account, reader);
452
+ }
453
+ catch (err) {
454
+ const code = err?.code;
455
+ if (typeof code !== "string" || !code.startsWith("SQLITE_BUSY"))
456
+ throw err;
457
+ return { count: 0, senders: [], posts: 0, channels: [], mentions: 0, mentionChannels: [] };
458
+ }
459
+ };
460
+ /**
461
+ * Answers 200 with the unread summary as soon as the account's agent has mail or unread posts in a channel it has
462
+ * joined, 204 after `waitHoldMs` without any.
463
+ * Another account's agent is answered like an unknown one. A token bound to an agent waits only for that agent: for it
464
+ * when `agent` is left out, and as for an unknown agent when `agent` names another. Every poll of a bound wait reads
465
+ * the token's registration, not its name, so a hold whose agent is removed meanwhile answers 404 rather than another
466
+ * agent's mail under the same name. A token that registers its own agents waits only for one it registered, named by
467
+ * `agent`, and reads that agent's registration in the same way; any other name is answered as an unknown agent.
468
+ */
469
+ const wait = async (req, res, account, bound, registers) => {
470
+ if (req.method !== "GET")
471
+ return reply(res, 405, "Method not allowed", { Allow: "GET" });
472
+ const asked = new URL(req.url ?? "", "http://x").searchParams.get("agent");
473
+ if (bound && asked && asked.toLowerCase() !== bound.name.toLowerCase())
474
+ return reply(res, 404, "No such agent");
475
+ let own;
476
+ if (registers !== undefined && asked) {
477
+ const found = unlessBusy(res, () => ownAgentOf(db, account, asked, registers) ?? null);
478
+ if (found === undefined)
479
+ return;
480
+ if (found === null)
481
+ return reply(res, 404, "No such agent");
482
+ own = found;
483
+ }
484
+ if (registers !== undefined && !own)
485
+ return reply(res, 404, "No such agent");
486
+ const name = bound ? bound.name : own ? own.name : asked;
487
+ const reader = bound ?? (own ? { id: own.id, registeredAt: own.registeredAt, instance: own.instance } : asked);
488
+ const first = reader ? poll(account, reader) : undefined;
489
+ if (!first)
490
+ return reply(res, 404, "No such agent");
491
+ // Mail that is already there is answered at once, whatever the cap.
492
+ if (hasUnread(first)) {
493
+ res.writeHead(200, { "Content-Type": "text/plain" });
494
+ return void res.end(`${formatUnread(first)}\n`);
495
+ }
496
+ // Held requests are capped like sessions, so a runaway client cannot pin the server's connections.
497
+ if (waiting >= maxSessions)
498
+ return reply(res, 503, "Too many waiting requests", { "Retry-After": "5" });
499
+ waiting++;
500
+ holding(account, name, 1);
501
+ let gone = false;
502
+ let wake = () => { };
503
+ res.on("close", () => {
504
+ gone = true;
505
+ wake();
506
+ });
507
+ const deadline = Date.now() + waitHoldMs;
508
+ try {
509
+ await hold(first, account, reader, deadline, res, () => gone, (w) => (wake = w));
510
+ }
511
+ finally {
512
+ waiting--;
513
+ holding(account, name, -1);
514
+ // The agent's sessions count as used as the hold ends, so the gap before the wait loop asks again cannot drop them.
515
+ touchSessionsOf(account, name);
516
+ }
517
+ };
518
+ const hold = async (first, account, reader, deadline, res, isGone, setWake) => {
519
+ for (let unread = first;; unread = poll(account, reader)) {
520
+ if (isGone())
521
+ return;
522
+ if (!unread)
523
+ return reply(res, 404, "No such agent");
524
+ if (hasUnread(unread)) {
525
+ res.writeHead(200, { "Content-Type": "text/plain" });
526
+ return void res.end(`${formatUnread(unread)}\n`);
527
+ }
528
+ const left = deadline - Date.now();
529
+ if (left <= 0) {
530
+ res.writeHead(204);
531
+ return void res.end();
532
+ }
533
+ // A held check that found nothing marks the agent waiting, as `wait_for_mail` does.
534
+ markWaiting(db, account, reader);
535
+ // Sleep a second, or until the client goes away; the timer is cleared either way.
536
+ await new Promise((resolve) => {
537
+ const timer = setTimeout(resolve, Math.min(1000, left));
538
+ setWake(() => {
539
+ clearTimeout(timer);
540
+ resolve();
541
+ });
542
+ });
543
+ }
544
+ };
545
+ /**
546
+ * GET /api/token: whether the request's token is read-only and whether it is agent-only, so the dashboard tells an
547
+ * agent token that it cannot use the page, and for a person token the person's name, which the dashboard then posts
548
+ * and sends as (the server still refuses any other), and `admin: true` while that person is an admin, so the
549
+ * dashboard offers Delete. A full token is neither and has no person: the dashboard offers it Delete but no posting,
550
+ * which the server refuses it. It gives nothing else about the token: no token, hash, id or account.
551
+ */
552
+ const apiToken = (req, res, access) => {
553
+ if (req.method !== "GET")
554
+ return reply(res, 405, "Method not allowed", { Allow: "GET" });
555
+ sendJson(res, {
556
+ readOnly: access.readOnly,
557
+ agentOnly: access.agentOnly,
558
+ ...(access.person && { person: access.person.name }),
559
+ ...(access.person?.admin && { admin: true }),
560
+ });
561
+ };
562
+ /**
563
+ * GET /api/agents: one page of the account's live agents, as `list_agents` pages them, each with its unread message,
564
+ * post and mention counts, its status line while current (null otherwise) and whether it is online. Counts only,
565
+ * never a message or post; it reads nothing as read.
566
+ */
567
+ const apiAgents = (req, res, account) => {
568
+ if (req.method !== "GET")
569
+ return reply(res, 405, "Method not allowed", { Allow: "GET, DELETE" });
570
+ const query = new URL(req.url ?? "", "http://x").searchParams;
571
+ const paging = page(query, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
572
+ if (paging === undefined)
573
+ return reply(res, 400, `limit must be 1-${MAX_LIST_LIMIT} and offset 0 or more`);
574
+ const waits = held.get(account);
575
+ const recent = Date.now() - ONLINE_WINDOW_MS;
576
+ // One query, so the page and its counts describe the same moment.
577
+ const agents = unlessBusy(res, () => listAgentsUnread(db, account, paging.limit, paging.offset));
578
+ if (agents === undefined)
579
+ return;
580
+ sendJson(res, agents.map((a) => ({
581
+ ...a,
582
+ // A person is never online: their lastSeen is when they were added, and they do not wait.
583
+ online: !a.person && ((waits?.get(a.name.toLowerCase()) ?? 0) > 0 || Date.parse(a.lastSeen) >= recent),
584
+ })));
585
+ };
586
+ /**
587
+ * GET /api/channels: one page of the account's channels by name, each with its post count, last post time and the
588
+ * names of the live agents that have joined it. No post bodies; it marks nothing read.
589
+ */
590
+ const apiChannels = (req, res, account) => {
591
+ if (req.method !== "GET")
592
+ return reply(res, 405, "Method not allowed", { Allow: "GET" });
593
+ const query = new URL(req.url ?? "", "http://x").searchParams;
594
+ const paging = page(query, DEFAULT_CHANNEL_LIMIT, MAX_CHANNEL_LIMIT);
595
+ if (paging === undefined)
596
+ return reply(res, 400, `limit must be 1-${MAX_CHANNEL_LIMIT} and offset 0 or more`);
597
+ const channels = unlessBusy(res, () => listAccountChannels(db, account, paging.limit, paging.offset));
598
+ if (channels === undefined)
599
+ return;
600
+ sendJson(res, channels);
601
+ };
602
+ /**
603
+ * GET /api/channel-posts?channel=<name>: one page of the account's channel, oldest first, each post with whether each
604
+ * agent of the account it mentions has read the mention. `?unread=true` answers only the posts with at least one
605
+ * unread mention of an agent. It moves no agent's read position and marks nothing read. A channel only another account
606
+ * has is answered like one that does not exist.
607
+ */
608
+ const apiChannelPosts = (req, res, account) => {
609
+ if (req.method !== "GET")
610
+ return reply(res, 405, "Method not allowed", { Allow: "GET" });
611
+ const query = new URL(req.url ?? "", "http://x").searchParams;
612
+ const names = query.getAll("channel");
613
+ if (names.length !== 1)
614
+ return reply(res, 404, "No such channel");
615
+ const paging = page(query, DEFAULT_CHANNEL_LIMIT, MAX_CHANNEL_LIMIT);
616
+ if (paging === undefined)
617
+ return reply(res, 400, `limit must be 1-${MAX_CHANNEL_LIMIT} and offset 0 or more`);
618
+ const unread = query.getAll("unread");
619
+ if (unread.length > 1 || (unread.length === 1 && unread[0] !== "true"))
620
+ return reply(res, 400, "unread must be true");
621
+ const unreadOnly = unread.length === 1;
622
+ let channel;
623
+ try {
624
+ channel = unlessBusy(res, () => readAccountChannel(db, account, names[0], paging.limit, paging.offset, unreadOnly));
625
+ }
626
+ catch (err) {
627
+ // An invalid name and a missing channel are the same answer, so neither says more than the other.
628
+ if (err instanceof ToolError)
629
+ return reply(res, 404, "No such channel");
630
+ throw err;
631
+ }
632
+ if (channel === undefined)
633
+ return;
634
+ sendJson(res, channel);
635
+ };
636
+ /**
637
+ * GET /api/messages: one page of the account's direct messages, oldest first, each with its recipient, whether it
638
+ * has been read, and `starting`: whether it is to an agent that has not read it and a webhook delivered it.
639
+ * `?unread=true` answers only the messages to an agent, not to a person, whose recipient has not read them. It marks
640
+ * nothing read, so no unread count, `lastSeen` or /wait answer moves.
641
+ */
642
+ const apiMessages = (req, res, account) => {
643
+ if (req.method !== "GET")
644
+ return reply(res, 405, "Method not allowed", { Allow: "GET" });
645
+ const query = new URL(req.url ?? "", "http://x").searchParams;
646
+ const paging = page(query, DEFAULT_INBOX_LIMIT, MAX_INBOX_LIMIT);
647
+ if (paging === undefined)
648
+ return reply(res, 400, `limit must be 1-${MAX_INBOX_LIMIT} and offset 0 or more`);
649
+ const unread = query.getAll("unread");
650
+ if (unread.length > 1 || (unread.length === 1 && unread[0] !== "true"))
651
+ return reply(res, 400, "unread must be true");
652
+ const unreadOnly = unread.length === 1;
653
+ const messages = unlessBusy(res, () => listAccountMessages(db, account, paging.limit, paging.offset, unreadOnly));
654
+ if (messages === undefined)
655
+ return;
656
+ sendJson(res, messages);
657
+ };
658
+ /**
659
+ * DELETE /api/conversation?a=<name>&b=<name>: deletes the account's direct messages between a and b, either way, and
660
+ * answers how many. Reads no body and holds nothing open, so it takes no slot.
661
+ */
662
+ const apiConversation = (req, res, account) => {
663
+ if (req.method !== "DELETE")
664
+ return reply(res, 405, "Method not allowed", { Allow: "DELETE" });
665
+ const query = new URL(req.url ?? "", "http://x").searchParams;
666
+ const [a, b] = [query.getAll("a"), query.getAll("b")];
667
+ if (a.length !== 1 || b.length !== 1 || a[0] === "" || b[0] === "")
668
+ return reply(res, 400, "Give a and b once each");
669
+ const deleted = unlessBusy(res, () => deleteConversation(db, account, a[0], b[0]));
670
+ if (deleted === undefined)
671
+ return;
672
+ sendJson(res, { deleted });
673
+ };
674
+ /**
675
+ * GET /api/webhook-deliveries: one page of the deliveries for the account's webhooks, newest first, each with its
676
+ * status under this server's `maxAttempts`. Never a webhook's URL or secret, or a message or post body.
677
+ */
678
+ const apiWebhookDeliveries = (req, res, account) => {
679
+ if (req.method !== "GET")
680
+ return reply(res, 405, "Method not allowed", { Allow: "GET" });
681
+ const query = new URL(req.url ?? "", "http://x").searchParams;
682
+ const paging = page(query, DEFAULT_INBOX_LIMIT, MAX_INBOX_LIMIT);
683
+ if (paging === undefined)
684
+ return reply(res, 400, `limit must be 1-${MAX_INBOX_LIMIT} and offset 0 or more`);
685
+ const deliveries = unlessBusy(res, () => listAccountDeliveries(db, account, webhookOptions.maxAttempts, paging.limit, paging.offset));
686
+ if (deliveries === undefined)
687
+ return;
688
+ sendJson(res, deliveries);
689
+ };
690
+ /**
691
+ * GET /api/approvals: one page of the account's unrevoked tokens the OAuth code exchange issued, newest first, each with
692
+ * its id, whether it registers its own agents, its agent's name (null for one that registers), for one that registers
693
+ * the names of the live agents it registered (empty for none), the client's name and metadata host (each null for
694
+ * none) and when it was issued. A single-agent token whose agent was removed is left out. Never a token, hash, code or
695
+ * redirect URI. DELETE is routed to `apiRevokeApproval` before this, so any method but GET is answered 405 here.
696
+ */
697
+ const apiApprovals = (req, res, account) => {
698
+ if (req.method !== "GET")
699
+ return reply(res, 405, "Method not allowed", { Allow: "GET, DELETE" });
700
+ const query = new URL(req.url ?? "", "http://x").searchParams;
701
+ const paging = page(query, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
702
+ if (paging === undefined)
703
+ return reply(res, 400, `limit must be 1-${MAX_LIST_LIMIT} and offset 0 or more`);
704
+ const approvals = unlessBusy(res, () => listApprovals(db, account, paging.limit, paging.offset));
705
+ if (approvals === undefined)
706
+ return;
707
+ sendJson(res, approvals);
708
+ };
709
+ /**
710
+ * GET /api/search?text=<text>: one page of the account's messages and channel posts whose body contains `text`,
711
+ * newest first, with the `search` tool's filters and errors. It marks nothing read and moves no read position.
712
+ */
713
+ const apiSearch = (req, res, account) => {
714
+ if (req.method !== "GET")
715
+ return reply(res, 405, "Method not allowed", { Allow: "GET" });
716
+ const query = new URL(req.url ?? "", "http://x").searchParams;
717
+ const texts = query.getAll("text");
718
+ if (texts.length !== 1)
719
+ return reply(res, 400, "Give text once");
720
+ const filters = {};
721
+ for (const key of ["channel", "from", "since", "until"]) {
722
+ const values = query.getAll(key);
723
+ if (values.length > 1)
724
+ return reply(res, 400, `Give ${key} at most once`);
725
+ if (values.length === 1)
726
+ filters[key] = values[0];
727
+ }
728
+ const paging = page(query, DEFAULT_INBOX_LIMIT, MAX_INBOX_LIMIT);
729
+ if (paging === undefined)
730
+ return reply(res, 400, `limit must be 1-${MAX_INBOX_LIMIT} and offset 0 or more`);
731
+ let found;
732
+ try {
733
+ found = unlessBusy(res, () => searchAccount(db, account, texts[0], filters, paging.limit, paging.offset));
734
+ }
735
+ catch (err) {
736
+ if (err instanceof ToolError)
737
+ return reply(res, 400, err.message);
738
+ throw err;
739
+ }
740
+ if (found === undefined)
741
+ return;
742
+ sendJson(res, found);
743
+ };
744
+ /**
745
+ * POST /api/send and /api/post: reads a JSON object whose `fields` are all strings and answers with what `act` returns
746
+ * for it. A slot is taken before the body is read and given back at the end, so held bodies are capped like sessions.
747
+ * Only a person token reaches it, and an `as` that is not the token's own live person, an unknown or removed name
748
+ * included, gets 403; `act` answers undefined, which gets 404, only when given no person to match, which no route does.
749
+ * A tool's refusal gets 400 with its message, which never quotes the body.
750
+ */
751
+ const asPerson = async (req, res, fields, act) => {
752
+ if (req.method !== "POST")
753
+ return reply(res, 405, "Method not allowed", { Allow: "POST" });
754
+ if (actingAsPerson >= maxSessions)
755
+ return reply(res, 503, "Too many requests", { Connection: "close" });
756
+ actingAsPerson++;
757
+ try {
758
+ let input;
759
+ try {
760
+ input = await readBody(req, MAX_PERSON_BODY_BYTES);
761
+ }
762
+ catch (err) {
763
+ return err instanceof RangeError ? reply(res, 413, "Request body too large") : reply(res, 400, "Invalid JSON");
764
+ }
765
+ // Anything but an object with each field a string, an array or `null` included, has some field missing.
766
+ if (fields.some((f) => typeof input?.[f] !== "string")) {
767
+ return reply(res, 400, `Give ${fields.join(", ")} as strings`);
768
+ }
769
+ let done;
770
+ try {
771
+ // null for no such person, kept apart from the undefined `unlessBusy` gives once it has answered 503.
772
+ done = unlessBusy(res, () => act(input) ?? null);
773
+ }
774
+ catch (err) {
775
+ if (err instanceof WrongPersonError)
776
+ return reply(res, 403, err.message);
777
+ if (err instanceof ToolError)
778
+ return reply(res, 400, err.message);
779
+ throw err;
780
+ }
781
+ if (done === undefined)
782
+ return;
783
+ if (done === null)
784
+ return reply(res, 404, "No such person");
785
+ sendJson(res, done);
786
+ }
787
+ finally {
788
+ actingAsPerson--;
789
+ }
790
+ };
791
+ /**
792
+ * POST /api/send: `{ as, to, body }`, sent as `send_message` sends it, from the account's person `as`, who must be
793
+ * `only`, the person token's person.
794
+ */
795
+ const apiSend = (req, res, account, only) => asPerson(req, res, ["as", "to", "body"], (m) => {
796
+ // The stored ids are send_message's alone: this answer stays `{ delivered, recipients }`.
797
+ const sent = sendAsPerson(db, account, m.as, m.to, m.body, only);
798
+ return sent && { delivered: sent.delivered, recipients: sent.recipients };
799
+ });
800
+ /** POST /api/post: `{ as, channel, body }`, posted as `post` posts it, by the account's person `as`; as `apiSend` limits it. */
801
+ const apiPost = (req, res, account, only) => asPerson(req, res, ["as", "channel", "body"], (m) => postAsPerson(db, account, m.as, m.channel, m.body, only));
802
+ /** An invite as POST /api/people and /api/invites answer it: with its link only when `serve` has a public URL. */
803
+ const inviteJson = (invite) => ({
804
+ id: invite.id,
805
+ code: invite.code,
806
+ expiresAt: new Date(invite.expiresAt).toISOString(),
807
+ ...(options.publicUrl !== undefined && { link: loginLink(dashboardUrl(options.publicUrl), invite.code).href }),
808
+ });
809
+ /**
810
+ * POST and PATCH /api/people and POST /api/invites, /api/tokens and /api/webhooks: reads a JSON object and answers
811
+ * with what `act` makes of it, never cached, as it may carry an invite's code, a new token or a webhook's secret. A
812
+ * slot is taken before the body is read and given back at the end, sharing
813
+ * `asPerson`'s count. `act` answers a string for 400, undefined for 404, and throws `PersonError` for 400, `LoginError`
814
+ * for 503, `OwnPersonError` for 403 and `NotAdminError`, for a token's person removed or unmarked while the body was
815
+ * read, for 403.
816
+ */
817
+ const asAdmin = async (req, res, act) => {
818
+ if (actingAsPerson >= maxSessions)
819
+ return reply(res, 503, "Too many requests", { Connection: "close" });
820
+ actingAsPerson++;
821
+ try {
822
+ let input;
823
+ try {
824
+ input = await readBody(req, MAX_PERSON_BODY_BYTES);
825
+ }
826
+ catch (err) {
827
+ return err instanceof RangeError ? reply(res, 413, "Request body too large") : reply(res, 400, "Invalid JSON");
828
+ }
829
+ let done;
830
+ try {
831
+ // null for no such person, kept apart from the undefined `unlessBusy` gives once it has answered 503.
832
+ done = unlessBusy(res, () => act(typeof input === "object" && input !== null ? input : {}) ?? null);
833
+ }
834
+ catch (err) {
835
+ if (err instanceof PersonError)
836
+ return reply(res, 400, err.message);
837
+ if (err instanceof NotAdminError || err instanceof OwnPersonError)
838
+ return reply(res, 403, err.message);
839
+ if (err instanceof LoginError)
840
+ return reply(res, 503, err.message);
841
+ throw err;
842
+ }
843
+ if (done === undefined)
844
+ return;
845
+ if (done === null)
846
+ return reply(res, 404, "No such person");
847
+ if (typeof done === "string")
848
+ return reply(res, 400, done);
849
+ sendOAuthJson(res, 200, done);
850
+ }
851
+ finally {
852
+ actingAsPerson--;
853
+ }
854
+ };
855
+ /**
856
+ * /api/people. GET: one page of the account's live people by name, each `{ name, admin, addedAt }`; it reads no body
857
+ * and takes no slot. POST `{ name, admin }`: adds the person `name`, an admin with `admin` true, to the account and makes
858
+ * their first invite, as `addInvitedPerson` does. Answers `{ name, admin, invite: { id, code, expiresAt, link? } }`.
859
+ * PATCH `?name=<name>` with `{ admin }`: marks the account's live person `name` (any case) an admin, or unmarks them
860
+ * with `admin` false, as `setAdminBy` does, answering `{ name, admin }`. DELETE `?name=<name>`: removes them as
861
+ * `removePersonBy` does, answering `{ name, messages, mentions, tokens, signins }`: the counts deleted, and how many of
862
+ * their tokens and dashboard sign-ins stopped working, though none is revoked or deleted. Both answer 404 for a name no
863
+ * live person of the account has, another account's included, and 403 to the token's own person unmarking or removing
864
+ * themselves. DELETE reads no body and takes no slot.
865
+ */
866
+ const apiPeople = (req, res, account, by) => {
867
+ if (req.method === "POST") {
868
+ return asAdmin(req, res, (input) => {
869
+ if (typeof input.name !== "string" || typeof input.admin !== "boolean")
870
+ return "Give name as a string and admin as true or false";
871
+ const added = addInvitedPerson(db, account, by, input.name, input.admin);
872
+ return { name: added.name, admin: added.admin, invite: inviteJson(added.invite) };
873
+ });
874
+ }
875
+ const query = new URL(req.url ?? "", "http://x").searchParams;
876
+ if (req.method === "GET") {
877
+ const paging = page(query, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
878
+ if (paging === undefined)
879
+ return reply(res, 400, `limit must be 1-${MAX_LIST_LIMIT} and offset 0 or more`);
880
+ const people = unlessBusy(res, () => listAccountPeople(db, account, paging.limit, paging.offset));
881
+ if (people === undefined)
882
+ return;
883
+ return sendJson(res, people);
884
+ }
885
+ if (req.method !== "PATCH" && req.method !== "DELETE")
886
+ return reply(res, 405, "Method not allowed", { Allow: "GET, POST, PATCH, DELETE" });
887
+ const names = query.getAll("name");
888
+ // A PATCH's body is left unread, so its 400 closes the connection.
889
+ if (names.length !== 1 || names[0] === "")
890
+ return reply(res, 400, "Give name once", req.method === "PATCH" ? { Connection: "close" } : {});
891
+ const name = names[0];
892
+ if (req.method === "PATCH") {
893
+ return asAdmin(req, res, (input) => {
894
+ if (typeof input.admin !== "boolean")
895
+ return "Give admin as true or false";
896
+ return setAdminBy(db, account, by, name, input.admin);
897
+ });
898
+ }
899
+ let removed;
900
+ try {
901
+ // null for no such person, kept apart from the undefined `unlessBusy` gives once it has answered 503.
902
+ removed = unlessBusy(res, () => removePersonBy(db, account, by, name) ?? null);
903
+ }
904
+ catch (err) {
905
+ if (err instanceof NotAdminError || err instanceof OwnPersonError)
906
+ return reply(res, 403, err.message);
907
+ throw err;
908
+ }
909
+ if (removed === undefined)
910
+ return;
911
+ if (removed === null)
912
+ return reply(res, 404, "No such person");
913
+ return sendJson(res, {
914
+ name: removed.name,
915
+ messages: removed.messages,
916
+ mentions: removed.mentions,
917
+ tokens: removed.tokens,
918
+ signins: removed.signins,
919
+ });
920
+ };
921
+ /**
922
+ * /api/invites. POST `{ person }`: a new invite for the account's live person `person` (any case), answered as POST
923
+ * /api/people answers, or 404 for any other name. GET: one page of the account's invites not used, cancelled or
924
+ * expired, newest first, never a code. DELETE `?id=<id>`: cancels the account's invite `id` while it is not used,
925
+ * cancelled or expired, else 404, the same for another account's. GET and DELETE read no body and take no slot.
926
+ */
927
+ const apiInvites = (req, res, account, by) => {
928
+ const query = new URL(req.url ?? "", "http://x").searchParams;
929
+ if (req.method === "POST") {
930
+ return asAdmin(req, res, (input) => {
931
+ if (typeof input.person !== "string")
932
+ return "Give person as a string";
933
+ const made = makeInvite(db, account, by, input.person);
934
+ return made && { name: made.name, admin: made.admin, invite: inviteJson(made.invite) };
935
+ });
936
+ }
937
+ if (req.method === "GET") {
938
+ const paging = page(query, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
939
+ if (paging === undefined)
940
+ return reply(res, 400, `limit must be 1-${MAX_LIST_LIMIT} and offset 0 or more`);
941
+ const invites = unlessBusy(res, () => listInvites(db, account, paging.limit, paging.offset));
942
+ if (invites === undefined)
943
+ return;
944
+ return sendJson(res, invites);
945
+ }
946
+ if (req.method === "DELETE") {
947
+ const ids = query.getAll("id");
948
+ if (ids.length !== 1)
949
+ return reply(res, 400, "Give id once");
950
+ const id = /^[1-9][0-9]{0,15}$/.test(ids[0]) ? Number(ids[0]) : NaN;
951
+ const cancelled = Number.isSafeInteger(id) ? unlessBusy(res, () => cancelInvite(db, account, id)) : false;
952
+ if (cancelled === undefined)
953
+ return;
954
+ if (!cancelled)
955
+ return reply(res, 404, "No such invite");
956
+ return sendJson(res, { cancelled: true });
957
+ }
958
+ return reply(res, 405, "Method not allowed", { Allow: "GET, POST, DELETE" });
959
+ };
960
+ /**
961
+ * DELETE /api/agents?name=<name> and POST /api/agents/key?name=<name>: the agent named in the query, which neither reads
962
+ * a body, so neither takes a slot. `act` answers undefined for 404, and throws `NotAdminError`, for a token's person
963
+ * removed or unmarked since the token was checked, for 403.
964
+ */
965
+ const asAdminOnAgent = (req, res, act, answer) => {
966
+ const names = new URL(req.url ?? "", "http://x").searchParams.getAll("name");
967
+ if (names.length !== 1 || names[0] === "")
968
+ return reply(res, 400, "Give name once");
969
+ let done;
970
+ try {
971
+ // null for no such agent, kept apart from the undefined `unlessBusy` gives once it has answered 503.
972
+ done = unlessBusy(res, () => act(names[0]) ?? null);
973
+ }
974
+ catch (err) {
975
+ if (err instanceof NotAdminError)
976
+ return reply(res, 403, err.message);
977
+ throw err;
978
+ }
979
+ if (done === undefined)
980
+ return;
981
+ if (done === null)
982
+ return reply(res, 404, "No such agent");
983
+ answer(res, done);
984
+ };
985
+ /**
986
+ * DELETE /api/agents?name=<name>: removes the account's agent `name` (any case), live or only left as removed rows,
987
+ * with its direct messages and mentions, as `removeAgentBy` does. Answers `{ name, messages, mentions, tokens }`, the
988
+ * counts deleted and the live agent's tokens that stopped working, or 404 for a name no agent of the account has had,
989
+ * a person's or another account's agent's included.
990
+ */
991
+ const apiRemoveAgent = (req, res, account, by) => asAdminOnAgent(req, res, (name) => removeAgentBy(db, account, by, name), sendJson);
992
+ /**
993
+ * POST /api/agents/key?name=<name>: gives the account's live agent `name` (any case) a new key, ending its sessions,
994
+ * as `resetAgentKeyBy` does. Answers `{ name, key }`, never cached, as it carries the key, or 404 for a name no
995
+ * live agent of the account has, a person's or another account's agent's included.
996
+ */
997
+ const apiAgentKey = (req, res, account, by) => {
998
+ if (req.method !== "POST")
999
+ return reply(res, 405, "Method not allowed", { Allow: "POST" });
1000
+ return asAdminOnAgent(req, res, (name) => resetAgentKeyBy(db, account, by, name), (r, value) => sendOAuthJson(r, 200, value));
1001
+ };
1002
+ /**
1003
+ * /api/webhooks. GET: one page of the account's webhooks, oldest first, as `listWebhooksBy` gives them: each URL's
1004
+ * origin only, never a secret, header value or fixed body. POST `{ url, on, includeText?, header?, maxPerHour?, body? }`:
1005
+ * adds a webhook to the account as `addWebhookBy` does, answering it as GET lists it plus its secret, shown this once
1006
+ * and never cached, or 400 with `addWebhook`'s refusal, which never repeats the URL or header value. It takes a slot
1007
+ * through `asAdmin`. DELETE `?id=<id>`: removes the account's webhook `id` with its deliveries, as `removeWebhookBy`
1008
+ * does, answering `{ id, deliveries }`, or 404 for an id the account has no webhook with, another account's included.
1009
+ * GET and DELETE read no body, so neither takes a slot. `NotAdminError`, for a token's person removed or unmarked
1010
+ * since the token was checked, answers 403.
1011
+ */
1012
+ const apiWebhooks = (req, res, account, by) => {
1013
+ if (req.method === "POST") {
1014
+ return asAdmin(req, res, (input) => {
1015
+ // asAdmin gives anything but an object as {}, which has no url; an array is an object with index keys.
1016
+ if (Array.isArray(input) || Object.keys(input).some((k) => !WEBHOOK_FIELDS.includes(k))) {
1017
+ return `Give a JSON object with only ${WEBHOOK_FIELDS.join(", ")}`;
1018
+ }
1019
+ const { url, on, includeText = true, header, maxPerHour, body } = input;
1020
+ if (typeof url !== "string" ||
1021
+ typeof on !== "string" ||
1022
+ typeof includeText !== "boolean" ||
1023
+ (header !== undefined && typeof header !== "string") ||
1024
+ (maxPerHour !== undefined && typeof maxPerHour !== "number") ||
1025
+ (body !== undefined && typeof body !== "string")) {
1026
+ return "Give url and on as strings, and optionally includeText as true or false, header and body as strings and maxPerHour as a number";
1027
+ }
1028
+ try {
1029
+ return addWebhookBy(db, account, by, { url, on, includeText, header, maxPerHour, body });
1030
+ }
1031
+ catch (err) {
1032
+ // Its messages never repeat the URL, header value or body.
1033
+ if (err instanceof WebhookError)
1034
+ return err.message;
1035
+ throw err;
1036
+ }
1037
+ });
1038
+ }
1039
+ const query = new URL(req.url ?? "", "http://x").searchParams;
1040
+ let work;
1041
+ if (req.method === "GET") {
1042
+ const paging = page(query, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
1043
+ if (paging === undefined)
1044
+ return reply(res, 400, `limit must be 1-${MAX_LIST_LIMIT} and offset 0 or more`);
1045
+ work = () => listWebhooksBy(db, account, by, paging.limit, paging.offset);
1046
+ }
1047
+ else if (req.method === "DELETE") {
1048
+ const ids = query.getAll("id");
1049
+ if (ids.length !== 1)
1050
+ return reply(res, 400, "Give id once");
1051
+ const id = /^[0-9]{1,16}$/.test(ids[0]) ? Number(ids[0]) : NaN;
1052
+ if (!Number.isSafeInteger(id))
1053
+ return reply(res, 400, "Give id as a whole number");
1054
+ work = () => removeWebhookBy(db, account, by, id);
1055
+ }
1056
+ else {
1057
+ return reply(res, 405, "Method not allowed", { Allow: "GET, POST, DELETE" });
1058
+ }
1059
+ let done;
1060
+ try {
1061
+ // null for no such webhook, kept apart from the undefined `unlessBusy` gives once it has answered 503.
1062
+ done = unlessBusy(res, () => work() ?? null);
1063
+ }
1064
+ catch (err) {
1065
+ if (err instanceof NotAdminError)
1066
+ return reply(res, 403, err.message);
1067
+ throw err;
1068
+ }
1069
+ if (done === undefined)
1070
+ return;
1071
+ if (done === null)
1072
+ return reply(res, 404, "No such webhook");
1073
+ sendJson(res, done);
1074
+ };
1075
+ /**
1076
+ * DELETE /api/approvals?id=<id>: revokes the account's approval `id`, as `revokeApprovalBy` does, answering `{ id,
1077
+ * agents }`, with `agents` the names of the agents the revoke ended (empty for none), or 404 for an id that is no
1078
+ * unrevoked OAuth token of the account, another account's included. It reads no body, so it takes no slot.
1079
+ * `NotAdminError`, for a token's person removed or unmarked since the token was checked, answers 403.
1080
+ */
1081
+ const apiRevokeApproval = (req, res, account, by) => {
1082
+ const ids = new URL(req.url ?? "", "http://x").searchParams.getAll("id");
1083
+ if (ids.length !== 1)
1084
+ return reply(res, 400, "Give id once");
1085
+ const id = /^[0-9]{1,16}$/.test(ids[0]) ? Number(ids[0]) : NaN;
1086
+ if (!Number.isSafeInteger(id))
1087
+ return reply(res, 400, "Give id as a whole number");
1088
+ let done;
1089
+ try {
1090
+ // null for no such approval, kept apart from the undefined `unlessBusy` gives once it has answered 503.
1091
+ done = unlessBusy(res, () => revokeApprovalBy(db, account, by, id) ?? null);
1092
+ }
1093
+ catch (err) {
1094
+ if (err instanceof NotAdminError)
1095
+ return reply(res, 403, err.message);
1096
+ throw err;
1097
+ }
1098
+ if (done === undefined)
1099
+ return;
1100
+ if (done === null)
1101
+ return reply(res, 404, "No such approval");
1102
+ sendJson(res, done);
1103
+ };
1104
+ /**
1105
+ * /api/tokens. GET: one page of the account's unrevoked tokens, oldest first, as `listTokensBy` gives them, each with
1106
+ * `lastUsedAt`, when a request last used it to within a minute (earlier if a write was skipped or failed), or null
1107
+ * until a request is recorded: never a token or its hash. POST `{ kind }`, with kind "read-only" or "agent" and no
1108
+ * other field: makes a new unbound token of that kind for the account, as `addTokenBy` does, answering `{ id, kind, token }`, never cached; the raw token is in
1109
+ * that answer only. Any other body answers 400, or 413 past 64 KB, and makes nothing. DELETE `?id=<id>`: revokes the account's token `id`
1110
+ * of any kind, as `revokeTokenBy` does, the token this request carries included, answering `{ id, signins, agents }`, with `signins`
1111
+ * the number of the token's dashboard sign-ins that this revoke ended (0 for none) and `agents` the names of the agents
1112
+ * it ended (empty for none), or 404 for an id
1113
+ * that is no unrevoked token of the account, another account's included. A request already running on a revoked
1114
+ * token may finish; its next request gets 401. GET and DELETE read no body and take no slot; POST takes `asAdmin`'s.
1115
+ * `NotAdminError`, for a token's person removed or unmarked since the token was checked, answers 403.
1116
+ */
1117
+ const apiTokens = (req, res, account, by) => {
1118
+ if (req.method === "POST") {
1119
+ return asAdmin(req, res, (input) => {
1120
+ const keys = Object.keys(input);
1121
+ if (keys.length !== 1 || keys[0] !== "kind" || (input.kind !== "read-only" && input.kind !== "agent")) {
1122
+ return 'Give only kind, as "read-only" or "agent"';
1123
+ }
1124
+ return addTokenBy(db, account, by, input.kind);
1125
+ });
1126
+ }
1127
+ const query = new URL(req.url ?? "", "http://x").searchParams;
1128
+ let work;
1129
+ if (req.method === "GET") {
1130
+ const paging = page(query, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
1131
+ if (paging === undefined)
1132
+ return reply(res, 400, `limit must be 1-${MAX_LIST_LIMIT} and offset 0 or more`);
1133
+ work = () => listTokensBy(db, account, by, paging.limit, paging.offset);
1134
+ }
1135
+ else if (req.method === "DELETE") {
1136
+ const ids = query.getAll("id");
1137
+ if (ids.length !== 1)
1138
+ return reply(res, 400, "Give id once");
1139
+ const id = /^[0-9]{1,16}$/.test(ids[0]) ? Number(ids[0]) : NaN;
1140
+ if (!Number.isSafeInteger(id))
1141
+ return reply(res, 400, "Give id as a whole number");
1142
+ work = () => revokeTokenBy(db, account, by, id);
1143
+ }
1144
+ else {
1145
+ return reply(res, 405, "Method not allowed", { Allow: "GET, POST, DELETE" });
1146
+ }
1147
+ let done;
1148
+ try {
1149
+ // null for no such token, kept apart from the undefined `unlessBusy` gives once it has answered 503.
1150
+ done = unlessBusy(res, () => work() ?? null);
1151
+ }
1152
+ catch (err) {
1153
+ if (err instanceof NotAdminError)
1154
+ return reply(res, 403, err.message);
1155
+ throw err;
1156
+ }
1157
+ if (done === undefined)
1158
+ return;
1159
+ if (done === null)
1160
+ return reply(res, 404, "No such token");
1161
+ sendJson(res, done);
1162
+ };
1163
+ /**
1164
+ * /api/signins. GET: one page of the account's live dashboard sign-ins, oldest first, as `listSigninsBy` gives them,
1165
+ * with `current` on the request's own (`current`, its `access.session`): never a session id, its hash or a token.
1166
+ * DELETE `?id=<id>`: ends the account's sign-in `id`, as `endSigninBy` does, the request's own included, answering
1167
+ * `{ id }`, or 404 for an id that is no live sign-in of the account, another account's included. A request already
1168
+ * running on an ended sign-in may finish; its next request gets 401. Neither reads a body, so neither takes a slot.
1169
+ * `NotAdminError`, for a token's person removed or unmarked since the token was checked, answers 403.
1170
+ */
1171
+ const apiSignins = (req, res, account, by, current) => {
1172
+ const query = new URL(req.url ?? "", "http://x").searchParams;
1173
+ let work;
1174
+ if (req.method === "GET") {
1175
+ const paging = page(query, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
1176
+ if (paging === undefined)
1177
+ return reply(res, 400, `limit must be 1-${MAX_LIST_LIMIT} and offset 0 or more`);
1178
+ work = () => listSigninsBy(db, account, by, current, paging.limit, paging.offset);
1179
+ }
1180
+ else if (req.method === "DELETE") {
1181
+ const ids = query.getAll("id");
1182
+ if (ids.length !== 1)
1183
+ return reply(res, 400, "Give id once");
1184
+ const id = /^[0-9]{1,16}$/.test(ids[0]) ? Number(ids[0]) : NaN;
1185
+ if (!Number.isSafeInteger(id))
1186
+ return reply(res, 400, "Give id as a whole number");
1187
+ work = () => endSigninBy(db, account, by, id);
1188
+ }
1189
+ else {
1190
+ return reply(res, 405, "Method not allowed", { Allow: "GET, DELETE" });
1191
+ }
1192
+ let done;
1193
+ try {
1194
+ // null for no such sign-in, kept apart from the undefined `unlessBusy` gives once it has answered 503.
1195
+ done = unlessBusy(res, () => work() ?? null);
1196
+ }
1197
+ catch (err) {
1198
+ if (err instanceof NotAdminError)
1199
+ return reply(res, 403, err.message);
1200
+ throw err;
1201
+ }
1202
+ if (done === undefined)
1203
+ return;
1204
+ if (done === null)
1205
+ return reply(res, 404, "No such sign-in");
1206
+ sendJson(res, done);
1207
+ };
1208
+ // MCP's authorization flow, served only with a public URL, from which every address it advertises is built: as
1209
+ // `mcpEndpoint` builds /mcp, each path is set on the parsed URL, never resolved as a reference.
1210
+ const oauth = options.publicUrl === undefined ? undefined : oauthUrls(options.publicUrl);
1211
+ // POST /oauth/register, /oauth/approve, /oauth/token and /api/login requests between taking a slot and answering.
1212
+ let authorizing = 0;
1213
+ // GET /oauth/authorize requests fetching a client's metadata document, from before the fetch begins until it ends.
1214
+ let fetchingMetadata = 0;
1215
+ const fetchMetadata = options.fetchClientMetadata ?? ((url) => fetchClientMetadata(url));
1216
+ /**
1217
+ * Takes a slot for an OAuth or POST /api/login request with a body, before it is read, and answers what `act` does with
1218
+ * the body's text (at most MAX_OAUTH_BODY_BYTES); the slot is given back at the end. A lock held past the busy timeout
1219
+ * answers 503. /api/login uses it with or without a public URL.
1220
+ */
1221
+ const withOAuthBody = async (req, res, act) => {
1222
+ if (req.method !== "POST")
1223
+ return reply(res, 405, "Method not allowed", { Allow: "POST" });
1224
+ if (authorizing >= maxSessions)
1225
+ return oauthError(res, 503, "temporarily_unavailable", "Too many requests", { Connection: "close", "Retry-After": "5" });
1226
+ authorizing++;
1227
+ try {
1228
+ let text;
1229
+ try {
1230
+ text = await readText(req, MAX_OAUTH_BODY_BYTES);
1231
+ }
1232
+ catch (err) {
1233
+ if (err instanceof RangeError)
1234
+ return oauthError(res, 413, "invalid_request", "Request body too large");
1235
+ throw err;
1236
+ }
1237
+ try {
1238
+ act(text);
1239
+ }
1240
+ catch (err) {
1241
+ if (!isBusy(err))
1242
+ throw err;
1243
+ oauthError(res, 503, "temporarily_unavailable", "Database busy", { "Retry-After": "1" });
1244
+ }
1245
+ }
1246
+ finally {
1247
+ authorizing--;
1248
+ }
1249
+ };
1250
+ /** POST /oauth/register: registers a public client (RFC 7591) from a JSON body, with no token. */
1251
+ const oauthRegister = (req, res) => withOAuthBody(req, res, (text) => {
1252
+ let body;
1253
+ try {
1254
+ body = JSON.parse(text);
1255
+ }
1256
+ catch {
1257
+ return oauthError(res, 400, "invalid_client_metadata", "The body must be JSON.");
1258
+ }
1259
+ try {
1260
+ const client = registerClient(db, body);
1261
+ sendOAuthJson(res, 201, {
1262
+ client_id: client.clientId,
1263
+ ...(client.clientName === null ? {} : { client_name: client.clientName }),
1264
+ redirect_uris: client.redirectUris,
1265
+ grant_types: ["authorization_code"],
1266
+ response_types: ["code"],
1267
+ token_endpoint_auth_method: "none",
1268
+ });
1269
+ }
1270
+ catch (err) {
1271
+ if (err instanceof OAuthError)
1272
+ return oauthError(res, 400, err.error, err.message);
1273
+ throw err;
1274
+ }
1275
+ });
1276
+ const showError = (res, message) => {
1277
+ res.writeHead(400, APPROVE_HEADERS);
1278
+ res.end(errorPage(message));
1279
+ };
1280
+ /**
1281
+ * GET /oauth/authorize: checks the request, then shows the approval page, an error page for a client or redirect URI
1282
+ * that is not registered, or redirects back with the error for anything else. A `client_id` that starts with https://
1283
+ * is the URL of the client's metadata document: once the URL is checked, a slot is taken and the document fetched,
1284
+ * outside any transaction and with nothing of this request; a URL, fetch or document refused gets the error page with
1285
+ * a fixed reason, never a redirect.
1286
+ */
1287
+ const oauthAuthorize = async (req, res, resource) => {
1288
+ if (req.method !== "GET")
1289
+ return reply(res, 405, "Method not allowed", { Allow: "GET" });
1290
+ const query = new URL(req.url ?? "", "http://x").searchParams;
1291
+ const ids = query.getAll("client_id");
1292
+ let fetched;
1293
+ if (ids.length === 1 && isMetadataUrl(ids[0])) {
1294
+ let url;
1295
+ try {
1296
+ url = checkMetadataUrl(ids[0]);
1297
+ }
1298
+ catch (err) {
1299
+ if (err instanceof OAuthError)
1300
+ return showError(res, err.message);
1301
+ throw err;
1302
+ }
1303
+ // An HTML page, not an OAuth error's JSON as for the other caps: this request is a person's browser.
1304
+ if (fetchingMetadata >= maxSessions) {
1305
+ res.writeHead(503, { ...APPROVE_HEADERS, "Retry-After": "5" });
1306
+ return void res.end(errorPage("Too many clients are connecting at once. Try again in a moment."));
1307
+ }
1308
+ fetchingMetadata++;
1309
+ let doc;
1310
+ try {
1311
+ doc = await fetchMetadata(url);
1312
+ }
1313
+ catch {
1314
+ return showError(res, "This server could not fetch the client's description. Start connecting again from the client.");
1315
+ }
1316
+ finally {
1317
+ fetchingMetadata--;
1318
+ }
1319
+ try {
1320
+ fetched = clientFromMetadata(url, doc);
1321
+ }
1322
+ catch (err) {
1323
+ if (err instanceof OAuthError)
1324
+ return showError(res, err.message);
1325
+ throw err;
1326
+ }
1327
+ }
1328
+ const outcome = unlessBusy(res, () => startAuthorization(db, query, resource, fetched));
1329
+ if (outcome === undefined)
1330
+ return;
1331
+ if (outcome.page === "redirect") {
1332
+ res.writeHead(302, { Location: outcome.location, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" });
1333
+ return void res.end();
1334
+ }
1335
+ if (outcome.page === "error")
1336
+ return showError(res, outcome.message);
1337
+ const { authorization: a } = outcome;
1338
+ res.writeHead(200, APPROVE_HEADERS);
1339
+ res.end(approvePage(a, redirectTo(a.redirectUri, { error: "access_denied" }, a.state), oauth?.signin === true));
1340
+ };
1341
+ /** Who `access`, a full or person token's, approves as: its account's name and, for a person token, its person's. */
1342
+ const signedInAs = (access) => ({
1343
+ account: accountName(db, access.account),
1344
+ ...(access.person && { person: access.person.name }),
1345
+ });
1346
+ /**
1347
+ * POST /oauth/approve: `{ request, state, agent, fresh, as }` from the approval page, with a full or person token of the
1348
+ * account the client's agent is made or found in. `fresh: true` says `agent` is the name the page suggested, left as
1349
+ * it was, so a live agent of that name is refused rather than connected as. `registers: true` in place of `agent`
1350
+ * approves a client that registers its own agents, so its token is bound to no agent. Signed in by the cookie (`signedIn`, who
1351
+ * GET /oauth/signin says it signs in as now), `as` must be the `{ account, person? }` the page showed, so a sign-in
1352
+ * that changed since the page showed it is refused with 409 rather than approving into another account. Answers
1353
+ * `{ location }`, the redirect URI with the code, or 400 or 409 with the reason.
1354
+ */
1355
+ const oauthApprove = (req, res, account, signedIn) => withOAuthBody(req, res, (text) => {
1356
+ let input;
1357
+ try {
1358
+ input = JSON.parse(text);
1359
+ }
1360
+ catch {
1361
+ input = undefined;
1362
+ }
1363
+ const state = input?.state;
1364
+ const registers = input?.registers === true;
1365
+ if (typeof input?.request !== "string" ||
1366
+ (registers ? input.agent !== undefined : typeof input.agent !== "string") ||
1367
+ (state !== null && typeof state !== "string")) {
1368
+ return sendOAuthJson(res, 400, { message: "Give request, state and agent, or registers: true in place of agent." });
1369
+ }
1370
+ const fresh = input.fresh === true;
1371
+ const as = input.as;
1372
+ if (signedIn && (typeof as !== "object" || as === null || as.account !== signedIn.account || (as.person ?? undefined) !== signedIn.person)) {
1373
+ return sendOAuthJson(res, 409, { message: "This browser is now signed in as someone else. Check who approves, then approve again." });
1374
+ }
1375
+ try {
1376
+ sendOAuthJson(res, 200, { location: approveAuthorization(db, account, input.request, state, registers ? null : input.agent, fresh) });
1377
+ }
1378
+ catch (err) {
1379
+ if (err instanceof OAuthError || err instanceof ToolError)
1380
+ return sendOAuthJson(res, 400, { message: err.message });
1381
+ throw err;
1382
+ }
1383
+ });
1384
+ /**
1385
+ * /oauth/signin: the approval page's sign-in, a cookie holding the dashboard's token, `HttpOnly` so no page script reads
1386
+ * it, `SameSite=Strict` so the browser sends it only on requests from this site, `Secure`, and sent only under the
1387
+ * approval page's path. It exists only under an https public URL (`signin` in `oauthUrls`). Every method needs a request from this server's own origin (`sameOrigin`).
1388
+ * POST, from the dashboard once a token is confirmed, sets it to the request's bearer token, which must be a full or
1389
+ * person token (403 for any other, 401 for none). GET, from the approval page, answers who the cookie's token signs in
1390
+ * as: `{ account, person? }`, with `name`, a name for the client's agent no live agent or person of the account has,
1391
+ * made from the query's `base` when it gives one; a missing or refused cookie gets 401 (and a refused one is cleared).
1392
+ * DELETE, from the dashboard's "Forget token", clears it. The token is checked afresh on each GET and approval, so a
1393
+ * revoked token stops at the next request.
1394
+ */
1395
+ const oauthSignin = (req, res, o) => {
1396
+ if (req.method !== "GET" && req.method !== "POST" && req.method !== "DELETE") {
1397
+ return reply(res, 405, "Method not allowed", { Allow: "GET, POST, DELETE" });
1398
+ }
1399
+ if (!sameOrigin(req, o.origin))
1400
+ return reply(res, 403, "Only this server's own pages can sign in");
1401
+ const attributes = `Path=${o.cookiePath}; HttpOnly; SameSite=Strict; Secure`;
1402
+ const clear = `${SIGNIN_COOKIE}=; ${attributes}; Max-Age=0`;
1403
+ const noStore = { "Cache-Control": "no-store" };
1404
+ if (req.method === "DELETE") {
1405
+ res.writeHead(204, { "Set-Cookie": clear, ...noStore });
1406
+ return void res.end();
1407
+ }
1408
+ if (req.method === "POST") {
1409
+ const m = /^bearer +(.*)$/i.exec(req.headers.authorization ?? "");
1410
+ const access = accountOf(db, req.headers.authorization, options.token);
1411
+ if (!m || access === undefined)
1412
+ return reply(res, 401, "Unauthorized");
1413
+ if (access.agentOnly || access.readOnly)
1414
+ return reply(res, 403, "Only a full or person token can approve a client");
1415
+ res.writeHead(204, { "Set-Cookie": `${SIGNIN_COOKIE}=${encodeURIComponent(m[1])}; ${attributes}`, ...noStore });
1416
+ return void res.end();
1417
+ }
1418
+ const token = signinCookie(req.headers.cookie);
1419
+ const query = new URL(req.url ?? "", "http://x").searchParams;
1420
+ const bases = query.getAll("base");
1421
+ const base = bases.length === 1 && /^[a-z0-9_-]{1,24}$/.test(bases[0]) ? bases[0] : undefined;
1422
+ const answer = unlessBusy(res, () => {
1423
+ const access = token === undefined ? undefined : accountForToken(db, token, options.token);
1424
+ if (access === undefined || access.agentOnly || access.readOnly)
1425
+ return null;
1426
+ const name = base === undefined ? undefined : freeAgentName(db, access.account, base);
1427
+ return { ...signedInAs(access), ...(name !== undefined && { name }) };
1428
+ });
1429
+ if (answer === undefined)
1430
+ return;
1431
+ if (answer === null) {
1432
+ return reply(res, 401, "Not signed in", token === undefined ? noStore : { "Set-Cookie": clear, ...noStore });
1433
+ }
1434
+ res.writeHead(200, { "Content-Type": "application/json", ...noStore });
1435
+ res.end(JSON.stringify(answer));
1436
+ };
1437
+ /** POST /oauth/token: exchanges a code for an agent token, from a form-encoded body (`authorization_code` only). */
1438
+ const oauthToken = (req, res) => withOAuthBody(req, res, (text) => {
1439
+ const form = new URLSearchParams(text);
1440
+ const one = (key) => {
1441
+ const values = form.getAll(key);
1442
+ return values.length === 1 ? values[0] : undefined;
1443
+ };
1444
+ if (form.getAll("grant_type").length !== 1)
1445
+ return oauthError(res, 400, "invalid_request", "Give grant_type once.");
1446
+ if (one("grant_type") !== "authorization_code")
1447
+ return oauthError(res, 400, "unsupported_grant_type", "Only authorization_code is supported.");
1448
+ const [code, clientId, redirectUri, verifier] = ["code", "client_id", "redirect_uri", "code_verifier"].map(one);
1449
+ const resources = form.getAll("resource");
1450
+ if (!code || !clientId || !redirectUri || !verifier || resources.length > 1) {
1451
+ return oauthError(res, 400, "invalid_request", "Give code, client_id, redirect_uri and code_verifier once each, and resource at most once.");
1452
+ }
1453
+ let token;
1454
+ try {
1455
+ token = exchangeCode(db, { code, clientId, redirectUri, verifier, resource: resources[0] ?? null });
1456
+ }
1457
+ catch (err) {
1458
+ if (err instanceof OAuthError)
1459
+ return oauthError(res, 400, err.error, err.message);
1460
+ throw err;
1461
+ }
1462
+ sendOAuthJson(res, 200, { access_token: token, token_type: "Bearer" });
1463
+ });
1464
+ /**
1465
+ * POST /api/login: `{ code }`, a one-time code from a `message-cp login-link` link or an invite, with no token. Answers
1466
+ * `{ session }`, the id of a dashboard sign-in session for a new full or person token of the code's account (the token
1467
+ * itself is returned to nobody), or 400 with `reason` "cancelled" (an invite an admin cancelled), "used" or "invalid"
1468
+ * and a message that never quotes the code.
1469
+ */
1470
+ const apiLogin = (req, res) => withOAuthBody(req, res, (text) => {
1471
+ let code;
1472
+ try {
1473
+ code = JSON.parse(text)?.code;
1474
+ }
1475
+ catch {
1476
+ code = undefined;
1477
+ }
1478
+ if (typeof code !== "string")
1479
+ return sendOAuthJson(res, 400, { reason: "invalid", message: "Give code." });
1480
+ try {
1481
+ sendOAuthJson(res, 200, { session: redeemLoginCode(db, code) });
1482
+ }
1483
+ catch (err) {
1484
+ if (err instanceof LoginRefused)
1485
+ return sendOAuthJson(res, 400, { reason: err.reason, message: err.message });
1486
+ throw err;
1487
+ }
1488
+ });
1489
+ const handle = async (req, res) => {
1490
+ const path = (req.url ?? "").split("?")[0];
1491
+ // The dashboard page is the same constant for everyone and holds no data, so it comes before the token.
1492
+ if (req.method === "GET" && path === "/") {
1493
+ res.writeHead(200, DASHBOARD_HEADERS);
1494
+ return void res.end(DASHBOARD_HTML);
1495
+ }
1496
+ // The dashboard's fonts are the same bytes for everyone, read when the module loaded, and found by exact path only, so
1497
+ // they too come before the token. The paths carry no version and a package upgrade can change the bytes, so caches keep a
1498
+ // font for a day and are not told it is immutable.
1499
+ const font = req.method === "GET" ? DASHBOARD_FONTS.get(path) : undefined;
1500
+ if (font) {
1501
+ res.writeHead(200, {
1502
+ "Content-Type": "font/woff2",
1503
+ "X-Content-Type-Options": "nosniff",
1504
+ "Cache-Control": "public, max-age=86400",
1505
+ });
1506
+ return void res.end(font);
1507
+ }
1508
+ // The health check is the same constant for everyone, reads nothing and touches no account, so it too comes before the token.
1509
+ if (path === "/health") {
1510
+ if (req.method !== "GET")
1511
+ return reply(res, 405, "Method not allowed", { Allow: "GET" });
1512
+ return sendJson(res, { ok: true });
1513
+ }
1514
+ // The authorization flow's metadata, registration, authorization page and token endpoint are the same for everyone and
1515
+ // reveal no account, so they too come before the token; without a public URL none of them exists.
1516
+ if (oauth && (oauth.serverPaths.has(path) || oauth.resourcePaths.has(path))) {
1517
+ if (req.method !== "GET")
1518
+ return reply(res, 405, "Method not allowed", { Allow: "GET" });
1519
+ return sendJson(res, oauth.serverPaths.has(path) ? oauth.server : oauth.resource);
1520
+ }
1521
+ if (oauth && path === "/oauth/register")
1522
+ return oauthRegister(req, res);
1523
+ if (oauth && path === "/oauth/authorize")
1524
+ return oauthAuthorize(req, res, oauth.resource.resource);
1525
+ if (oauth && path === "/oauth/token")
1526
+ return oauthToken(req, res);
1527
+ // The sign-in cookie is checked by its own route, which checks any token it is given itself.
1528
+ if (oauth?.signin && path === "/oauth/signin")
1529
+ return oauthSignin(req, res, oauth);
1530
+ // A full or person token approves a client, into its own account: an agent or read-only token is refused before a slot is
1531
+ // taken or a body read. The token is the request's Authorization header, as for every other path; only a request with
1532
+ // none, from a page of this server's own origin, is signed in by the sign-in cookie instead (under an https public URL
1533
+ // only), so another site cannot make a signed-in browser approve, and a script's Bearer token works as before.
1534
+ if (oauth && path === "/oauth/approve") {
1535
+ const cookie = oauth.signin && req.headers.authorization === undefined && sameOrigin(req, oauth.origin) ? signinCookie(req.headers.cookie) : undefined;
1536
+ const access = cookie === undefined ? accountOf(db, req.headers.authorization, options.token) : accountForToken(db, cookie, options.token);
1537
+ if (access === undefined)
1538
+ return reply(res, 401, "Unauthorized", { "WWW-Authenticate": oauth.challenge, Connection: "close" });
1539
+ if (access.agentOnly || access.readOnly) {
1540
+ return reply(res, 403, "Only a full or person token can approve a client", { Connection: "close" });
1541
+ }
1542
+ return oauthApprove(req, res, access.account, cookie === undefined ? undefined : signedInAs(access));
1543
+ }
1544
+ // A login link's code stands in for the token, and redeeming it reveals nothing of any account but to its holder, so
1545
+ // it too comes before the token, with or without a public URL.
1546
+ if (path === "/api/login")
1547
+ return apiLogin(req, res);
1548
+ // The token is the first thing any other request meets, and decides the account everything after it acts as.
1549
+ const access = accountOf(db, req.headers.authorization, options.token);
1550
+ if (access === undefined) {
1551
+ return reply(res, 401, "Unauthorized", { "WWW-Authenticate": oauth ? oauth.challenge : "Bearer" });
1552
+ }
1553
+ const { account } = access;
1554
+ // A listen token (`issueListenToken`) only waits for its own agent's mail on GET /wait; `wait` refuses another agent.
1555
+ // Anything else is refused before a slot is taken or a body read.
1556
+ if (access.listen && path !== "/wait")
1557
+ return reply(res, 403, "A listen token only waits on GET /wait", { Connection: "close" });
1558
+ // A POST or PATCH to /api/people or /api/invites carries a body its 403 leaves unread, so their 403s close the
1559
+ // connection.
1560
+ const inviting = path === "/api/people" || path === "/api/invites";
1561
+ // Removing an agent and resetting its key, which only an admin person token does; GET /api/agents is a read, and any
1562
+ // other method there gets apiAgents' 405. Neither reads a body; their 403s close the connection as the routes above do.
1563
+ const managing = (path === "/api/agents" && req.method === "DELETE") || path === "/api/agents/key";
1564
+ // Listing, adding and removing webhooks, which only an admin person token does. A POST carries a body the token
1565
+ // refusals below leave unread; those 403s close the connection as the routes above do.
1566
+ const hooks = path === "/api/webhooks";
1567
+ // Revoking an approved client, which only an admin person token does; any other method on /api/approvals is a read,
1568
+ // or apiApprovals' 405. It reads no body; its 403s close the connection as the routes above do.
1569
+ const revoking = path === "/api/approvals" && req.method === "DELETE";
1570
+ // Listing, making and revoking the account's tokens, which only an admin person token does. A POST carries a body its
1571
+ // 403 leaves unread, so their 403s close the connection as the routes above do.
1572
+ const tokens = path === "/api/tokens";
1573
+ // Listing and ending the account's dashboard sign-ins, which only an admin person token or sign-in does. Neither
1574
+ // method reads a body; their 403s close the connection as the routes above do.
1575
+ const signins = path === "/api/signins";
1576
+ // An agent token reaches /mcp and /wait, where it reads as its agent does, and of the /api paths only GET /api/token,
1577
+ // so the dashboard can say why it will not work; it cannot read or delete the account's conversations there. It is
1578
+ // refused before a slot is taken or a body read.
1579
+ if (access.agentOnly &&
1580
+ (path === "/api" || path.startsWith("/api/")) &&
1581
+ !(path === "/api/token" && req.method === "GET")) {
1582
+ return reply(res, 403, "Agent tokens cannot use the dashboard API", inviting || managing || hooks || revoking || tokens || signins ? { Connection: "close" } : {});
1583
+ }
1584
+ // A dashboard sign-in session (a login link's or an invite's) reaches what its token reaches but /mcp and /wait, so the
1585
+ // session itself never acts as an agent. It is refused before a slot is taken or a body read.
1586
+ if (access.session !== undefined && (path === "/mcp" || path === "/wait")) {
1587
+ return reply(res, 403, "A dashboard sign-in cannot use MCP or /wait", { Connection: "close" });
1588
+ }
1589
+ if (path === "/api/token")
1590
+ return apiToken(req, res, access);
1591
+ if (path === "/api/agents" && !managing)
1592
+ return apiAgents(req, res, account);
1593
+ if (path === "/api/channels")
1594
+ return apiChannels(req, res, account);
1595
+ if (path === "/api/channel-posts")
1596
+ return apiChannelPosts(req, res, account);
1597
+ if (path === "/api/messages")
1598
+ return apiMessages(req, res, account);
1599
+ if (path === "/api/search")
1600
+ return apiSearch(req, res, account);
1601
+ if (path === "/api/webhook-deliveries")
1602
+ return apiWebhookDeliveries(req, res, account);
1603
+ if (path === "/api/approvals" && !revoking)
1604
+ return apiApprovals(req, res, account);
1605
+ // A read-only token reaches the read-only endpoints above and nothing else: /mcp can write, and so can any path added later.
1606
+ if (access.readOnly)
1607
+ return reply(res, 403, "This token is read-only", inviting || managing || hooks || revoking || tokens || signins ? { Connection: "close" } : {});
1608
+ // A person token also sends and posts, only as its person (checked by row once the body is read), approves clients
1609
+ // (above), and, while its person is an admin, deletes the account's conversations as a full token does, lists, adds,
1610
+ // marks, unmarks and removes people, makes, lists and cancels invites, removes agents and resets their keys, lists,
1611
+ // adds and removes webhooks, revokes approved clients, lists, makes (read-only and agent only) and revokes the
1612
+ // account's tokens, and lists and ends its dashboard sign-ins. It reaches nothing else: not /mcp, /wait, nor any path
1613
+ // added later. It is refused before a slot is taken or a body read.
1614
+ if (access.person &&
1615
+ path !== "/api/send" &&
1616
+ path !== "/api/post" &&
1617
+ !(access.person.admin && (inviting || managing || hooks || revoking || tokens || signins || path === "/api/conversation"))) {
1618
+ return reply(res, 403, access.person.admin
1619
+ ? "An admin person token only reads, sends, posts, deletes, invites, changes people, manages agents, webhooks, tokens and sign-ins and revokes approved clients"
1620
+ : "A person token only reads, sends and posts", { Connection: "close" });
1621
+ }
1622
+ // Only a person token sends and posts, as its own person, so nobody acts as another person: a full token, which has
1623
+ // no person, is refused before a slot is taken or a body read.
1624
+ if (path === "/api/send" || path === "/api/post") {
1625
+ if (!access.person)
1626
+ return reply(res, 403, FULL_TOKEN_SENDS, { Connection: "close" });
1627
+ return (path === "/api/send" ? apiSend : apiPost)(req, res, account, access.person.id);
1628
+ }
1629
+ // Only an admin person lists, adds, marks, unmarks and removes people and makes, lists and cancels invites: a full
1630
+ // token, which is no person, is refused before a slot is taken or a body read. A plain person token was refused above.
1631
+ if (inviting) {
1632
+ if (!access.person)
1633
+ return reply(res, 403, FULL_TOKEN_INVITES, { Connection: "close" });
1634
+ return (path === "/api/people" ? apiPeople : apiInvites)(req, res, account, access.person.id);
1635
+ }
1636
+ // Likewise only an admin person removes agents and resets their keys; a full token is refused before anything runs.
1637
+ if (managing) {
1638
+ if (!access.person)
1639
+ return reply(res, 403, FULL_TOKEN_AGENTS, { Connection: "close" });
1640
+ return (path === "/api/agents" ? apiRemoveAgent : apiAgentKey)(req, res, account, access.person.id);
1641
+ }
1642
+ // And only an admin person lists, adds and removes webhooks over the API; a full token is refused before a slot is
1643
+ // taken or a body read.
1644
+ if (hooks) {
1645
+ if (!access.person)
1646
+ return reply(res, 403, FULL_TOKEN_WEBHOOKS, { Connection: "close" });
1647
+ return apiWebhooks(req, res, account, access.person.id);
1648
+ }
1649
+ // And only an admin person revokes approved clients over the API; a full token is refused before anything runs.
1650
+ if (revoking) {
1651
+ if (!access.person)
1652
+ return reply(res, 403, FULL_TOKEN_APPROVALS, { Connection: "close" });
1653
+ return apiRevokeApproval(req, res, account, access.person.id);
1654
+ }
1655
+ // And only an admin person lists, makes and revokes tokens over the API; a full token is refused before a slot is
1656
+ // taken or a body read.
1657
+ if (tokens) {
1658
+ if (!access.person)
1659
+ return reply(res, 403, FULL_TOKEN_TOKENS, { Connection: "close" });
1660
+ return apiTokens(req, res, account, access.person.id);
1661
+ }
1662
+ // And only an admin person lists and ends dashboard sign-ins; a full token is refused before anything runs.
1663
+ if (signins) {
1664
+ if (!access.person)
1665
+ return reply(res, 403, FULL_TOKEN_SIGNINS, { Connection: "close" });
1666
+ return apiSignins(req, res, account, access.person.id, access.session);
1667
+ }
1668
+ if (path === "/api/conversation")
1669
+ return apiConversation(req, res, account);
1670
+ if (path === "/wait")
1671
+ return wait(req, res, account, access.agent, access.registers);
1672
+ if (path !== "/mcp")
1673
+ return reply(res, 404, "Not found");
1674
+ if (req.method !== "POST" && req.method !== "GET" && req.method !== "DELETE") {
1675
+ return reply(res, 405, "Method not allowed", { Allow: "GET, POST, DELETE" });
1676
+ }
1677
+ const header = req.headers["mcp-session-id"];
1678
+ const id = Array.isArray(header) ? header[0] : header;
1679
+ // A session id this server does not hold may be one a server before it gave out: one that registered is restored
1680
+ // if it still may be. Anything else is answered at once, so that only a restore can meet the cap below.
1681
+ if (id && !sessions.has(id)) {
1682
+ const identity = unlessBusy(res, () => restorableSession(db, id, account, Date.now() - idleTimeoutMs) ?? null);
1683
+ if (identity === undefined)
1684
+ return;
1685
+ // A token bound to an agent restores only a session of that agent, and one that registers its own agents only a
1686
+ // session of one of them; any other looks like one that does not exist.
1687
+ if (identity === null || !mayActAs(db, access, identity)) {
1688
+ return reply(res, 404, "Unknown session", { Connection: "close" });
1689
+ }
1690
+ if (sessions.size + initializing >= maxSessions)
1691
+ return reply(res, 503, "Too many sessions", { Connection: "close" });
1692
+ restore(id, account, identity, access.approval !== undefined);
1693
+ }
1694
+ // A request without a session may name the agent its new session acts as. A malformed pair is answered before the
1695
+ // cap, so the cap limits only work that can go ahead, closing the connection so its unread body cannot hold it open.
1696
+ const named = id ? undefined : agentHeaders(req);
1697
+ if (named === null) {
1698
+ return reply(res, 400, "Send both X-Message-CP-Agent and X-Message-CP-Agent-Key once each, or neither", {
1699
+ Connection: "close",
1700
+ });
1701
+ }
1702
+ // A token bound to an agent acts as it with no headers; headers naming another agent are refused before the cap.
1703
+ if (named && access.agent && named.name.toLowerCase() !== access.agent.name.toLowerCase()) {
1704
+ return reply(res, 403, `This token acts only as agent '${access.agent.name}'`, { Connection: "close" });
1705
+ }
1706
+ // A token that registers its own agents takes no key, and headers always carry one: its sessions call `register`.
1707
+ if (named && access.registers !== undefined) {
1708
+ return reply(res, 403, "This token registers its own agents: call register instead of sending agent headers", { Connection: "close" });
1709
+ }
1710
+ // A request without a session can only start one. Refuse it at the cap, closing the connection so an unread
1711
+ // body cannot hold it open; otherwise take a slot now, before the body is buffered, and give it back at the end.
1712
+ if (!id && sessions.size + initializing >= maxSessions) {
1713
+ return reply(res, 503, "Too many sessions", { Connection: "close" });
1714
+ }
1715
+ // A request for a session takes a slot before its body is read, once the session is known to be the token's account's
1716
+ // (another account's looks like one that does not exist), and gives it back as soon as the body has been read, so a
1717
+ // tool call held open afterwards does not keep it.
1718
+ if (id) {
1719
+ const live = sessions.get(id);
1720
+ if (!live || live.account !== account || !mayActAs(db, access, live.session?.registration)) {
1721
+ return reply(res, 404, "Unknown session", { Connection: "close" });
1722
+ }
1723
+ }
1724
+ let slot = !!id && req.method === "POST";
1725
+ if (slot && reading >= maxSessions)
1726
+ return reply(res, 503, "Too many requests", { Connection: "close" });
1727
+ if (slot)
1728
+ reading++;
1729
+ const bodyRead = () => {
1730
+ if (slot)
1731
+ reading--;
1732
+ slot = false;
1733
+ };
1734
+ const reserved = !id && req.method === "POST";
1735
+ if (reserved)
1736
+ initializing++;
1737
+ try {
1738
+ await serve(req, res, id, access, named, bodyRead);
1739
+ }
1740
+ finally {
1741
+ bodyRead();
1742
+ if (reserved)
1743
+ initializing--;
1744
+ }
1745
+ };
1746
+ const serve = async (req, res, id, access, named, bodyRead) => {
1747
+ const { account, agent: bound, registers, approval } = access;
1748
+ // Tools read the token's agent from here, so a bound token cannot `register` or remove another name on any session,
1749
+ // the row id of a token that registers its own agents, so it registers and removes only its own, and the row id of
1750
+ // an approval's token, which `register` and `listen_token` issue a listen token through.
1751
+ const extra = { ...(bound && { agent: bound.name }), ...(registers !== undefined && { registers }), ...(approval !== undefined && { approval }) };
1752
+ if (Object.keys(extra).length > 0)
1753
+ req.auth = { token: "", clientId: "", scopes: [], extra };
1754
+ let body;
1755
+ if (req.method === "POST") {
1756
+ try {
1757
+ body = await readBody(req);
1758
+ }
1759
+ catch (err) {
1760
+ return err instanceof RangeError ? reply(res, 413, "Request body too large") : reply(res, 400, "Invalid JSON");
1761
+ }
1762
+ finally {
1763
+ bodyRead?.();
1764
+ }
1765
+ }
1766
+ if (id) {
1767
+ const live = sessions.get(id);
1768
+ // Another account's session looks like one that does not exist, as does another agent's to a bound token, or to a
1769
+ // token that registers its own agents.
1770
+ if (!live || live.account !== account || !mayActAs(db, access, live.session?.registration)) {
1771
+ return reply(res, 404, "Unknown session");
1772
+ }
1773
+ await live.ready;
1774
+ // A client that went away while the session was restored is not listening, and its close has already fired, so
1775
+ // `track` would never see it.
1776
+ if (res.closed)
1777
+ return;
1778
+ // A session another server sharing the database ended with DELETE is ended here too.
1779
+ const ended = unlessBusy(res, () => sessionEnded(db, id));
1780
+ if (ended === undefined)
1781
+ return;
1782
+ if (ended) {
1783
+ drop(id);
1784
+ return reply(res, 404, "Unknown session");
1785
+ }
1786
+ live.lastUsed = Date.now();
1787
+ track(live, res, body);
1788
+ await live.transport.handleRequest(req, res, body);
1789
+ live.lastUsed = Date.now();
1790
+ // A session dropped by close() while a request was open keeps its row, so the restart can restore it; any other
1791
+ // request keeps a registered one restorable for another idle timeout.
1792
+ if (req.method !== "DELETE" && sessions.get(id) === live)
1793
+ recordSession(() => touchSession(db, id, live.lastUsed));
1794
+ return;
1795
+ }
1796
+ if (req.method !== "POST" || !isInitializeRequest(body)) {
1797
+ return reply(res, 400, "Send an initialize request first");
1798
+ }
1799
+ // Registers the named agent as `register` would, in the token's account. A refusal is 403, not 401, so a client
1800
+ // does not take it for a bad token; its message names no key.
1801
+ let identity;
1802
+ if (named) {
1803
+ let registered;
1804
+ try {
1805
+ registered = unlessBusy(res, () => registerWithIdentity(db, account, named.name, undefined, named.key));
1806
+ }
1807
+ catch (err) {
1808
+ if (err instanceof ToolError)
1809
+ return reply(res, 403, err.message);
1810
+ throw err;
1811
+ }
1812
+ if (registered === undefined)
1813
+ return;
1814
+ identity = registered.identity;
1815
+ }
1816
+ else if (bound) {
1817
+ identity = { id: bound.id, registeredAt: bound.registeredAt, instance: bound.instance };
1818
+ }
1819
+ const live = { lastUsed: Date.now(), transport: undefined, account, open: new Map() };
1820
+ const transport = new StreamableHTTPServerTransport({
1821
+ sessionIdGenerator: () => randomUUID(),
1822
+ onsessioninitialized: (sid) => {
1823
+ sessions.set(sid, live);
1824
+ // Now that the session is held, binding it records it, as a `register` on it would.
1825
+ if (identity)
1826
+ session.bind(identity);
1827
+ },
1828
+ onsessionclosed: endRecorded,
1829
+ });
1830
+ live.transport = transport;
1831
+ transport.onclose = () => {
1832
+ if (transport.sessionId)
1833
+ sessions.delete(transport.sessionId);
1834
+ };
1835
+ // Only a session this server still holds is recorded: a `register` that finishes after a DELETE ended it, before it
1836
+ // had a row to mark, must not make it restorable.
1837
+ const session = new Session(account, (identity) => {
1838
+ const sid = transport.sessionId;
1839
+ if (sessions.get(sid) === live)
1840
+ recordSession(() => saveSession(db, sid, account, identity));
1841
+ });
1842
+ live.session = session;
1843
+ await createServer(db, session, { ...serverOptions(live), agent: bound?.name, approval: approval !== undefined }).connect(transport);
1844
+ await transport.handleRequest(req, res, body);
1845
+ };
1846
+ /**
1847
+ * Notes each JSON-RPC request in a POST body as open until its response closes, whether answered or abandoned by the
1848
+ * client, so a held tool call can stop. A later request reusing an id replaces the entry, and is the one kept.
1849
+ */
1850
+ const track = (live, res, body) => {
1851
+ for (const message of Array.isArray(body) ? body : [body]) {
1852
+ if (!isJSONRPCRequest(message))
1853
+ continue;
1854
+ const controller = new AbortController();
1855
+ live.open.set(message.id, controller);
1856
+ res.once("close", () => {
1857
+ controller.abort();
1858
+ if (live.open.get(message.id) === controller)
1859
+ live.open.delete(message.id);
1860
+ });
1861
+ }
1862
+ };
1863
+ /**
1864
+ * Called by a transport once it has accepted a DELETE, before it answers. If the session cannot be marked ended, this
1865
+ * throws, so the transport answers 500 instead of 200. The row stays restorable, so the DELETE can be sent again.
1866
+ */
1867
+ const endRecorded = (sid) => endSession(db, sid);
1868
+ /**
1869
+ * Holds session `id` again, bound to `identity`, which `restorableSession` has just checked. It takes its place in
1870
+ * `sessions` at once, so it counts against the cap and a second request on it waits for this one; if it cannot be
1871
+ * initialized it is dropped again. `approval` is whether the request's token came from an approval, so the session
1872
+ * offers `listen_token`.
1873
+ */
1874
+ const restore = (id, account, identity, approval) => {
1875
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => id, onsessionclosed: endRecorded });
1876
+ const live = { lastUsed: Date.now(), transport, account, open: new Map() };
1877
+ sessions.set(id, live);
1878
+ transport.onclose = () => {
1879
+ if (sessions.get(id) === live)
1880
+ sessions.delete(id);
1881
+ };
1882
+ // Its row is already there, so it is not recorded again: one another server deleted meanwhile stays deleted. The
1883
+ // session is set at once, so a bound token's request on it meanwhile can check whose it is.
1884
+ const session = new Session(account, (bound) => {
1885
+ if (sessions.get(id) === live)
1886
+ recordSession(() => saveSession(db, id, account, bound));
1887
+ });
1888
+ session.restore(identity);
1889
+ live.session = session;
1890
+ live.ready = (async () => {
1891
+ try {
1892
+ await createServer(db, session, { ...serverOptions(live), approval }).connect(transport);
1893
+ await prime(transport);
1894
+ }
1895
+ catch (err) {
1896
+ drop(id);
1897
+ throw err;
1898
+ }
1899
+ })();
1900
+ // Each request awaits it and answers 500 if it failed; this keeps the failure from going unhandled meanwhile.
1901
+ live.ready.catch(() => { });
1902
+ };
1903
+ const server = createHttpServer((req, res) => {
1904
+ handle(req, res).catch((err) => {
1905
+ console.error(`message-cp serve: ${err instanceof Error ? err.message : String(err)}`);
1906
+ if (!res.headersSent)
1907
+ reply(res, 500, "Internal error");
1908
+ else
1909
+ res.end();
1910
+ });
1911
+ });
1912
+ await new Promise((resolve, reject) => {
1913
+ server.once("error", reject);
1914
+ server.listen(options.port ?? DEFAULT_HTTP_PORT, options.host ?? DEFAULT_HTTP_HOST, () => resolve());
1915
+ });
1916
+ const address = server.address();
1917
+ const port = typeof address === "object" && address ? address.port : (options.port ?? DEFAULT_HTTP_PORT);
1918
+ return {
1919
+ server,
1920
+ port,
1921
+ sessionCount: () => sessions.size,
1922
+ close: async () => {
1923
+ clearInterval(sweep);
1924
+ clearInterval(deliver);
1925
+ stopping.abort();
1926
+ await sending;
1927
+ for (const id of [...sessions.keys()])
1928
+ drop(id);
1929
+ server.closeAllConnections();
1930
+ await new Promise((resolve) => server.close(() => resolve()));
1931
+ },
1932
+ };
1933
+ }