machine-bridge-mcp 0.16.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/CONTRIBUTING.md +14 -0
  3. package/README.md +1 -1
  4. package/browser-extension/manifest.json +2 -2
  5. package/browser-extension/service-worker.js +52 -29
  6. package/docs/ENGINEERING.md +3 -1
  7. package/docs/PROJECT_STANDARDS.md +143 -0
  8. package/docs/RELEASING.md +12 -0
  9. package/docs/TESTING.md +7 -5
  10. package/docs/TOOL_REFERENCE.md +2836 -0
  11. package/package.json +14 -5
  12. package/scripts/commit-message-check.mjs +63 -0
  13. package/scripts/coverage-check.mjs +9 -1
  14. package/scripts/generate-policy-reference.mjs +1 -4
  15. package/scripts/generate-tool-reference.mjs +87 -0
  16. package/scripts/generate-worker-types.mjs +19 -0
  17. package/scripts/markdown.mjs +9 -0
  18. package/src/local/agent-context.mjs +8 -15
  19. package/src/local/cli.mjs +1 -107
  20. package/src/local/daemon-process.mjs +16 -4
  21. package/src/local/default-instructions.mjs +2 -45
  22. package/src/local/git-service.mjs +4 -9
  23. package/src/local/managed-job-plan.mjs +2 -7
  24. package/src/local/managed-jobs.mjs +13 -11
  25. package/src/local/numbers.mjs +9 -0
  26. package/src/local/process-execution.mjs +4 -9
  27. package/src/local/process-sessions.mjs +5 -10
  28. package/src/local/project-metadata.mjs +50 -0
  29. package/src/local/project-package.mjs +2 -45
  30. package/src/local/records.mjs +5 -0
  31. package/src/local/runtime.mjs +5 -12
  32. package/src/local/state-inventory.mjs +139 -0
  33. package/src/local/workspace-file-service.mjs +6 -12
  34. package/src/worker/errors.ts +41 -0
  35. package/src/worker/http.ts +252 -0
  36. package/src/worker/index.ts +33 -17
  37. package/src/worker/oauth-state.ts +170 -0
  38. package/src/worker/observability.ts +111 -0
  39. package/src/worker/pending-calls.ts +109 -0
  40. package/src/worker/policy.ts +79 -0
  41. package/tsconfig.json +1 -1
@@ -0,0 +1,252 @@
1
+ const UNSAFE_DISPLAY_CONTROLS = /[\u0000-\u001f\u007f\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g;
2
+
3
+ export class HttpError extends Error {
4
+ readonly status: number;
5
+ readonly code: string;
6
+
7
+ constructor(status: number, code: string, message: string) {
8
+ super(message);
9
+ this.name = "HttpError";
10
+ this.status = status;
11
+ this.code = code;
12
+ }
13
+ }
14
+
15
+ export function methodNotAllowed(allow: string): Response {
16
+ return new Response(JSON.stringify({ error: "method_not_allowed" }), {
17
+ status: 405,
18
+ headers: {
19
+ allow,
20
+ "cache-control": "no-store",
21
+ "content-type": "application/json; charset=utf-8",
22
+ "x-content-type-options": "nosniff",
23
+ },
24
+ });
25
+ }
26
+
27
+ export function oauthRedirect(location: URL): Response {
28
+ return new Response(null, {
29
+ status: 303,
30
+ headers: {
31
+ location: location.href,
32
+ "cache-control": "no-store",
33
+ "referrer-policy": "no-referrer",
34
+ "x-content-type-options": "nosniff",
35
+ },
36
+ });
37
+ }
38
+
39
+ export function authorizationRedirectLocation(redirectUri: string, code: string, state: string): URL {
40
+ const location = new URL(redirectUri);
41
+ location.searchParams.append("code", code);
42
+ if (state) location.searchParams.append("state", state);
43
+ return location;
44
+ }
45
+
46
+ export function json(value: unknown, status = 200): Response {
47
+ return new Response(JSON.stringify(value), {
48
+ status,
49
+ headers: {
50
+ "content-type": "application/json; charset=utf-8",
51
+ "cache-control": "no-store",
52
+ "x-content-type-options": "nosniff",
53
+ },
54
+ });
55
+ }
56
+
57
+ export function html(value: string, status = 200): Response {
58
+ return new Response(value, {
59
+ status,
60
+ headers: {
61
+ "content-type": "text/html; charset=utf-8",
62
+ "cache-control": "no-store",
63
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
64
+ "referrer-policy": "no-referrer",
65
+ "x-content-type-options": "nosniff",
66
+ "x-frame-options": "DENY",
67
+ },
68
+ });
69
+ }
70
+
71
+ export function baseUrl(request: Request): string {
72
+ return new URL(request.url).origin;
73
+ }
74
+
75
+ export function bearerToken(request: Request): string {
76
+ const match = (request.headers.get("Authorization") ?? "").match(/^Bearer\s+(.+)$/i);
77
+ return match?.[1]?.trim() ?? "";
78
+ }
79
+
80
+ export async function parseJsonRequest(request: Request, limit: number): Promise<unknown> {
81
+ const text = await readBoundedText(request, limit);
82
+ try {
83
+ return JSON.parse(text);
84
+ } catch {
85
+ throw new HttpError(400, "invalid_json", "Request body is not valid JSON");
86
+ }
87
+ }
88
+
89
+ export async function parseRequestBody(request: Request, limit: number): Promise<Record<string, unknown>> {
90
+ const contentType = (request.headers.get("content-type") ?? "").toLowerCase();
91
+ const text = await readBoundedText(request, limit);
92
+ if (contentType.includes("application/json") || text.trim().startsWith("{")) {
93
+ let parsed: unknown;
94
+ try {
95
+ parsed = text.trim() ? JSON.parse(text) : {};
96
+ } catch {
97
+ throw new HttpError(400, "invalid_json", "Request body is not valid JSON");
98
+ }
99
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
100
+ throw new HttpError(400, "bad_request", "JSON body must be an object");
101
+ }
102
+ return parsed as Record<string, unknown>;
103
+ }
104
+ return searchParamsObject(new URLSearchParams(text));
105
+ }
106
+
107
+ export async function readBoundedText(request: Request, limit: number): Promise<string> {
108
+ const length = Number(request.headers.get("content-length") ?? "0");
109
+ if (Number.isFinite(length) && length > limit) {
110
+ throw new HttpError(413, "request_body_too_large", `request body exceeds ${limit} bytes`);
111
+ }
112
+ if (!request.body) return "";
113
+ const reader = request.body.getReader();
114
+ const chunks: Uint8Array[] = [];
115
+ let total = 0;
116
+ try {
117
+ for (;;) {
118
+ const { done, value } = await reader.read();
119
+ if (done) break;
120
+ if (!value) continue;
121
+ total += value.byteLength;
122
+ if (total > limit) {
123
+ await reader.cancel();
124
+ throw new HttpError(413, "request_body_too_large", `request body exceeds ${limit} bytes`);
125
+ }
126
+ chunks.push(value);
127
+ }
128
+ } finally {
129
+ reader.releaseLock();
130
+ }
131
+ const combined = new Uint8Array(total);
132
+ let offset = 0;
133
+ for (const chunk of chunks) {
134
+ combined.set(chunk, offset);
135
+ offset += chunk.byteLength;
136
+ }
137
+ try {
138
+ return new TextDecoder("utf-8", { fatal: true }).decode(combined);
139
+ } catch {
140
+ throw new HttpError(400, "invalid_encoding", "Request body must be valid UTF-8");
141
+ }
142
+ }
143
+
144
+ export function normalizeRedirectUri(value: string): string | null {
145
+ try {
146
+ const url = new URL(value);
147
+ if (url.username || url.password || url.hash) return null;
148
+ if (url.protocol === "https:" && url.hostname) return url.toString();
149
+ if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return url.toString();
150
+ return null;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ export function corsPreflight(request: Request, base: string, configured: string): Response {
157
+ const origin = request.headers.get("Origin") ?? "";
158
+ if (!isConfiguredOrSameOrigin(origin, base, configured)) return json({ error: "origin_not_allowed" }, 403);
159
+ const requestedMethod = (request.headers.get("Access-Control-Request-Method") ?? "").toUpperCase();
160
+ if (requestedMethod && !["GET", "POST"].includes(requestedMethod)) return methodNotAllowed("GET, POST, OPTIONS");
161
+ return new Response(null, {
162
+ status: 204,
163
+ headers: {
164
+ "access-control-allow-origin": origin,
165
+ "access-control-allow-methods": "GET, POST, OPTIONS",
166
+ "access-control-allow-headers": "authorization, content-type, mcp-protocol-version, mcp-session-id",
167
+ "access-control-max-age": "600",
168
+ "cache-control": "no-store",
169
+ "vary": "Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
170
+ },
171
+ });
172
+ }
173
+
174
+ export function applyCors(response: Response, request: Request, base: string, configured: string): Response {
175
+ if (response.status === 101) return response;
176
+ const origin = request.headers.get("Origin") ?? "";
177
+ if (!origin || !isConfiguredOrSameOrigin(origin, base, configured)) return response;
178
+ const headers = new Headers(response.headers);
179
+ headers.set("access-control-allow-origin", origin);
180
+ headers.set("access-control-expose-headers", "www-authenticate, mcp-session-id");
181
+ appendVary(headers, "Origin");
182
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
183
+ }
184
+
185
+ export function validateOrigin(request: Request, base: string, configured = ""): boolean {
186
+ const origin = request.headers.get("Origin");
187
+ return !origin || isConfiguredOrSameOrigin(origin, base, configured);
188
+ }
189
+
190
+ export function searchParamsEntries(params: URLSearchParams): Array<[string, string]> {
191
+ const entries: Array<[string, string]> = [];
192
+ params.forEach((value, key) => entries.push([key, value]));
193
+ return entries;
194
+ }
195
+
196
+ export function searchParamsObject(params: URLSearchParams): Record<string, unknown> {
197
+ const out: Record<string, unknown> = {};
198
+ params.forEach((value, key) => {
199
+ if (out[key] === undefined) out[key] = value;
200
+ else if (Array.isArray(out[key])) (out[key] as string[]).push(value);
201
+ else out[key] = [out[key] as string, value];
202
+ });
203
+ return out;
204
+ }
205
+
206
+ export function normalizeDisplayText(value: string, maxLength: number, fallback = "MCP Client"): string {
207
+ const normalized = value.replace(UNSAFE_DISPLAY_CONTROLS, " ").replace(/\s+/g, " ").trim();
208
+ return (normalized || fallback).slice(0, maxLength);
209
+ }
210
+
211
+ export function sanitizeMetadataText(value: unknown, maxLength: number): string | undefined {
212
+ if (typeof value !== "string") return undefined;
213
+ const normalized = value.replace(UNSAFE_DISPLAY_CONTROLS, " ").replace(/\s+/g, " ").trim();
214
+ return normalized ? normalized.slice(0, maxLength) : undefined;
215
+ }
216
+
217
+ export function escapeHtml(value: string): string {
218
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
219
+ }
220
+
221
+ export function workerErrorClass(error: unknown): string {
222
+ if (error instanceof HttpError) return error.code;
223
+ if (error instanceof TypeError) return "type_error";
224
+ if (error instanceof RangeError) return "range_error";
225
+ if (error instanceof Error) return error.name.replace(/[^A-Za-z0-9_-]/g, "_").toLowerCase().slice(0, 64) || "error";
226
+ return "unknown_error";
227
+ }
228
+
229
+ function appendVary(headers: Headers, value: string): void {
230
+ const existing = headers.get("vary");
231
+ const values = new Set((existing ?? "").split(",").map((item) => item.trim()).filter(Boolean));
232
+ values.add(value);
233
+ headers.set("vary", [...values].join(", "));
234
+ }
235
+
236
+ function isConfiguredOrSameOrigin(origin: string, base: string, configured: string): boolean {
237
+ if (isDefaultAllowedOrigin(origin, base)) return true;
238
+ const allowed = configured.split(",").map((item) => item.trim()).filter((item) => item && item !== "null");
239
+ return allowed.includes(origin);
240
+ }
241
+
242
+ function isDefaultAllowedOrigin(origin: string, base: string): boolean {
243
+ try {
244
+ return new URL(origin).origin === new URL(base).origin;
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
249
+
250
+ function isLoopbackHost(hostname: string): boolean {
251
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
252
+ }
@@ -17,7 +17,7 @@ import {
17
17
  } from "./http";
18
18
 
19
19
  const SERVER_NAME = String(serverMetadata.name);
20
- const SERVER_VERSION = "0.16.1";
20
+ const SERVER_VERSION = "0.17.0";
21
21
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
22
22
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
23
23
  const JSONRPC_VERSION = "2.0";
@@ -162,14 +162,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
162
162
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
163
163
  const size = typeof message === "string" ? new TextEncoder().encode(message).byteLength : message.byteLength;
164
164
  if (size > MAX_DAEMON_MESSAGE_BYTES) {
165
- try { ws.close(1009, "message too large"); } catch {}
165
+ closeWebSocketQuietly(ws, 1009, "message too large");
166
166
  return;
167
167
  }
168
168
  let text: string;
169
169
  try {
170
170
  text = typeof message === "string" ? message : new TextDecoder("utf-8", { fatal: true }).decode(message);
171
171
  } catch {
172
- try { ws.close(1007, "invalid UTF-8"); } catch {}
172
+ closeWebSocketQuietly(ws, 1007, "invalid UTF-8");
173
173
  return;
174
174
  }
175
175
  let parsed: unknown;
@@ -187,12 +187,12 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
187
187
 
188
188
  const socketAttachment = this.socketAttachment(ws);
189
189
  if (!socketAttachment) {
190
- try { ws.close(1008, "missing daemon attachment"); } catch {}
190
+ closeWebSocketQuietly(ws, 1008, "missing daemon attachment");
191
191
  return;
192
192
  }
193
193
 
194
194
  if (socketAttachment.role === "expired") {
195
- try { ws.close(1008, "expired daemon candidate"); } catch {}
195
+ closeWebSocketQuietly(ws, 1008, "expired daemon candidate");
196
196
  return;
197
197
  }
198
198
 
@@ -204,7 +204,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
204
204
  const previousDaemons = this.daemonSockets().filter((socket) => socket !== ws);
205
205
  if (socketAttachment.role === "candidate") {
206
206
  if (!isFreshDaemonCandidate(socketAttachment.connectedAt)) {
207
- try { ws.close(1008, "stale daemon candidate"); } catch {}
207
+ closeWebSocketQuietly(ws, 1008, "stale daemon candidate");
208
208
  await this.scheduleCandidateAlarm();
209
209
  return;
210
210
  }
@@ -225,17 +225,17 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
225
225
  role: "expired",
226
226
  connectedAt: socketAttachment.connectedAt,
227
227
  } satisfies DaemonAttachment);
228
- try { ws.close(1011, "daemon hello acknowledgement failed"); } catch {}
228
+ closeWebSocketQuietly(ws, 1011, "daemon hello acknowledgement failed");
229
229
  return;
230
230
  }
231
231
  for (const socket of previousDaemons) {
232
- try { socket.close(1012, "replaced by authenticated daemon"); } catch {}
232
+ closeWebSocketQuietly(socket, 1012, "replaced by authenticated daemon");
233
233
  }
234
234
  return;
235
235
  }
236
236
 
237
237
  if (socketAttachment.role !== "daemon") {
238
- try { ws.close(1008, "daemon hello required"); } catch {}
238
+ closeWebSocketQuietly(ws, 1008, "daemon hello required");
239
239
  return;
240
240
  }
241
241
 
@@ -387,7 +387,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
387
387
  clientRequestKey: requestKey,
388
388
  timeoutMs,
389
389
  onTimeout: (record) => {
390
- try { record.socket.send(JSON.stringify({ type: "cancel_call", id: record.id })); } catch {}
390
+ sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
391
391
  return new WorkerToolError("timeout", `daemon tool timed out: ${name}`, true);
392
392
  },
393
393
  });
@@ -410,7 +410,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
410
410
  private cancelClientRequest(requestKey?: string): void {
411
411
  if (!requestKey) return;
412
412
  this.pending.cancelRequest(requestKey, (record) => {
413
- try { record.socket.send(JSON.stringify({ type: "cancel_call", id: record.id })); } catch {}
413
+ sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
414
414
  return new WorkerToolError("cancelled", "tool call cancelled by client");
415
415
  });
416
416
  }
@@ -422,7 +422,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
422
422
  if (!expected || !(await safeEqual(supplied, expected))) return new Response("Unauthorized daemon", { status: 401 });
423
423
 
424
424
  for (const socket of this.nonDaemonSockets()) {
425
- try { socket.close(1012, "replaced by newer daemon candidate"); } catch {}
425
+ closeWebSocketQuietly(socket, 1012, "replaced by newer daemon candidate");
426
426
  }
427
427
 
428
428
  const pair = new WebSocketPair();
@@ -508,8 +508,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
508
508
  role: "expired",
509
509
  connectedAt: attachment?.connectedAt ?? new Date(0).toISOString(),
510
510
  } satisfies DaemonAttachment);
511
- try { socket.send(JSON.stringify({ type: "error", error: "daemon_hello_timeout" })); } catch {}
512
- try { socket.close(1008, "daemon hello timeout"); } catch {}
511
+ sendWebSocketQuietly(socket, { type: "error", error: "daemon_hello_timeout" });
512
+ closeWebSocketQuietly(socket, 1008, "daemon hello timeout");
513
513
  continue;
514
514
  }
515
515
  nextDeadline = Math.min(nextDeadline, deadline);
@@ -524,7 +524,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
524
524
  const attachment = this.socketAttachment(socket);
525
525
  const connectedAt = Date.parse(attachment?.connectedAt ?? "");
526
526
  if (!Number.isFinite(connectedAt)) {
527
- try { socket.close(1008, "invalid daemon candidate timestamp"); } catch {}
527
+ closeWebSocketQuietly(socket, 1008, "invalid daemon candidate timestamp");
528
528
  continue;
529
529
  }
530
530
  nextDeadline = Math.min(nextDeadline, connectedAt + DAEMON_HELLO_TIMEOUT_MS);
@@ -885,8 +885,24 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
885
885
  }
886
886
 
887
887
  function rejectDaemonMessage(ws: WebSocket, error: string, closeCode: number, closeReason: string): void {
888
- try { ws.send(JSON.stringify({ type: "error", error })); } catch {}
889
- try { ws.close(closeCode, closeReason); } catch {}
888
+ sendWebSocketQuietly(ws, { type: "error", error });
889
+ closeWebSocketQuietly(ws, closeCode, closeReason);
890
+ }
891
+
892
+ function sendWebSocketQuietly(ws: WebSocket, value: unknown): void {
893
+ try {
894
+ ws.send(typeof value === "string" ? value : JSON.stringify(value));
895
+ } catch {
896
+ // Best-effort protocol cleanup must not replace the primary timeout or rejection.
897
+ }
898
+ }
899
+
900
+ function closeWebSocketQuietly(ws: WebSocket, code?: number, reason?: string): void {
901
+ try {
902
+ ws.close(code, reason);
903
+ } catch {
904
+ // The socket may already be closed or detached; no recovery remains at this boundary.
905
+ }
890
906
  }
891
907
 
892
908
  function isJsonRpcRequest(value: unknown): value is JsonRpcRequest {
@@ -0,0 +1,170 @@
1
+ export interface OAuthClient {
2
+ client_id: string;
3
+ client_name: string;
4
+ redirect_uris: string[];
5
+ created_at: number;
6
+ last_used_at: number;
7
+ registration_identity?: string;
8
+ }
9
+
10
+ export interface OAuthCode {
11
+ client_id: string;
12
+ redirect_uri: string;
13
+ code_challenge: string;
14
+ scope: string;
15
+ resource: string;
16
+ expires_at: number;
17
+ }
18
+
19
+ export interface OAuthToken {
20
+ client_id: string;
21
+ scope: string;
22
+ resource: string;
23
+ version: string;
24
+ expires_at: number;
25
+ }
26
+
27
+ export interface OAuthFailure {
28
+ count: number;
29
+ window_started: number;
30
+ blocked_until: number;
31
+ last_attempt: number;
32
+ }
33
+
34
+ export interface OAuthStore {
35
+ clients: Record<string, OAuthClient>;
36
+ codes: Record<string, OAuthCode>;
37
+ tokens: Record<string, OAuthToken>;
38
+ auth_failures: Record<string, OAuthFailure>;
39
+ }
40
+
41
+ export interface ValidatedAuthorization {
42
+ client: OAuthClient;
43
+ clientId: string;
44
+ redirectUri: string;
45
+ codeChallenge: string;
46
+ requestedResource: string;
47
+ scope: string;
48
+ state: string;
49
+ }
50
+
51
+ const AUTH_FAILURE_WINDOW_SECONDS = 10 * 60;
52
+ export const AUTH_BLOCK_SECONDS = 15 * 60;
53
+ const AUTH_FAILURE_LIMIT = 10;
54
+
55
+ export function validateAuthorizationRequest(
56
+ body: Record<string, unknown>,
57
+ base: string,
58
+ serverName: string,
59
+ store: OAuthStore,
60
+ ): { value: ValidatedAuthorization } | { error: string; status: number } {
61
+ const responseType = String(body.response_type ?? "");
62
+ const clientId = String(body.client_id ?? "");
63
+ const redirectUri = String(body.redirect_uri ?? "");
64
+ const codeChallenge = String(body.code_challenge ?? "");
65
+ const codeChallengeMethod = String(body.code_challenge_method ?? "");
66
+ const requestedResource = String(body.resource ?? `${base}/mcp`);
67
+ const scope = String(body.scope ?? serverName).trim();
68
+ const state = body.state === undefined ? "" : typeof body.state === "string" ? body.state : "";
69
+
70
+ if (responseType !== "code") return { error: "response_type must be code.", status: 400 };
71
+ if (requestedResource !== `${base}/mcp`) return { error: "resource mismatch.", status: 400 };
72
+ if (scope !== serverName) return { error: "unsupported scope.", status: 400 };
73
+ if (body.state !== undefined && typeof body.state !== "string") return { error: "state must be a string.", status: 400 };
74
+ if (state.length > 1024) return { error: "state is too long.", status: 400 };
75
+ if (codeChallengeMethod !== "S256" || !/^[A-Za-z0-9_-]{43}$/.test(codeChallenge)) {
76
+ return { error: "A valid PKCE S256 challenge is required.", status: 400 };
77
+ }
78
+ const client = store.clients[clientId];
79
+ if (!client) return { error: "Unknown OAuth client.", status: 400 };
80
+ if (!client.redirect_uris.includes(redirectUri)) return { error: "redirect_uri is not registered.", status: 400 };
81
+ return { value: { client, clientId, redirectUri, codeChallenge, requestedResource, scope, state } };
82
+ }
83
+
84
+ export function pruneClientRecordByExpiry<T extends { client_id: string; expires_at: number }>(record: Record<string, T>, clientId: string, keep: number): void {
85
+ const allowed = new Set(Object.entries(record)
86
+ .filter(([, value]) => value.client_id === clientId)
87
+ .sort((left, right) => right[1].expires_at - left[1].expires_at)
88
+ .slice(0, keep)
89
+ .map(([key]) => key));
90
+ for (const [key, value] of Object.entries(record)) {
91
+ if (value.client_id === clientId && !allowed.has(key)) delete record[key];
92
+ }
93
+ }
94
+
95
+ export function pruneRecordByExpiry<T extends { expires_at: number }>(record: Record<string, T>, keep: number): void {
96
+ const allowed = new Set(Object.entries(record)
97
+ .sort((left, right) => right[1].expires_at - left[1].expires_at)
98
+ .slice(0, keep)
99
+ .map(([key]) => key));
100
+ for (const key of Object.keys(record)) if (!allowed.has(key)) delete record[key];
101
+ }
102
+
103
+ export async function authorizationIdentity(request: Request, keyMaterial: string): Promise<string> {
104
+ const source = (request.headers.get("CF-Connecting-IP") || "unknown").slice(0, 128);
105
+ const encoder = new TextEncoder();
106
+ const key = await crypto.subtle.importKey(
107
+ "raw",
108
+ encoder.encode(keyMaterial),
109
+ { name: "HMAC", hash: "SHA-256" },
110
+ false,
111
+ ["sign"],
112
+ );
113
+ const digest = await crypto.subtle.sign("HMAC", key, encoder.encode(source));
114
+ return `hmac-sha256:${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
115
+ }
116
+
117
+ export function recordAuthorizationFailure(store: OAuthStore, identity: string, now: number): void {
118
+ const current = store.auth_failures[identity];
119
+ const activeWindow = current && current.window_started + AUTH_FAILURE_WINDOW_SECONDS > now;
120
+ const count = activeWindow ? current.count + 1 : 1;
121
+ store.auth_failures[identity] = {
122
+ count,
123
+ window_started: activeWindow ? current.window_started : now,
124
+ blocked_until: count >= AUTH_FAILURE_LIMIT ? now + AUTH_BLOCK_SECONDS : 0,
125
+ last_attempt: now,
126
+ };
127
+ }
128
+
129
+ export function pruneAuthFailures(store: OAuthStore, keep: number): void {
130
+ const allowed = new Set(Object.entries(store.auth_failures)
131
+ .sort((left, right) => right[1].last_attempt - left[1].last_attempt)
132
+ .slice(0, keep)
133
+ .map(([key]) => key));
134
+ for (const key of Object.keys(store.auth_failures)) if (!allowed.has(key)) delete store.auth_failures[key];
135
+ }
136
+
137
+ export function randomToken(prefix: string): string {
138
+ const bytes = new Uint8Array(32);
139
+ crypto.getRandomValues(bytes);
140
+ return `${prefix}_${base64Url(bytes)}`;
141
+ }
142
+
143
+ export async function sha256Hex(value: string): Promise<string> {
144
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
145
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
146
+ }
147
+
148
+ export async function safeEqual(left: string, right: string): Promise<boolean> {
149
+ const encoder = new TextEncoder();
150
+ const [leftHash, rightHash] = await Promise.all([
151
+ crypto.subtle.digest("SHA-256", encoder.encode(left)),
152
+ crypto.subtle.digest("SHA-256", encoder.encode(right)),
153
+ ]);
154
+ const leftBytes = new Uint8Array(leftHash);
155
+ const rightBytes = new Uint8Array(rightHash);
156
+ let diff = leftBytes.length ^ rightBytes.length;
157
+ for (let index = 0; index < Math.max(leftBytes.length, rightBytes.length); index += 1) {
158
+ diff |= (leftBytes[index] ?? 0) ^ (rightBytes[index] ?? 0);
159
+ }
160
+ return diff === 0;
161
+ }
162
+
163
+ export async function pkceS256(verifier: string): Promise<string> {
164
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
165
+ return base64Url(new Uint8Array(digest));
166
+ }
167
+
168
+ function base64Url(bytes: Uint8Array): string {
169
+ return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
170
+ }
@@ -0,0 +1,111 @@
1
+ const MAX_ERROR_CODES = 64;
2
+ const MAX_TOOLS = 128;
3
+ const SENSITIVE_FIELD = /(?:authorization|cookie|credential|password|secret|token|verifier|private[_-]?key)/i;
4
+
5
+ export class WorkerObservability {
6
+ private readonly startedAt = Date.now();
7
+ private readonly requests = { total: 0, successful: 0, client_error: 0, server_error: 0 };
8
+ private readonly calls = { started: 0, completed: 0, failed: 0, cancelled: 0, timed_out: 0 };
9
+ private readonly sockets = { candidates: 0, authenticated: 0, disconnected: 0, protocol_errors: 0 };
10
+ private readonly errors = new Map<string, number>();
11
+ private readonly tools = new Map<string, { started: number; completed: number; failed: number; active: number }>();
12
+
13
+ requestFinished(status: number): void {
14
+ this.requests.total += 1;
15
+ if (status >= 500) this.requests.server_error += 1;
16
+ else if (status >= 400) this.requests.client_error += 1;
17
+ else this.requests.successful += 1;
18
+ }
19
+
20
+ callStarted(tool: string): void {
21
+ this.calls.started += 1;
22
+ const metric = this.toolMetric(tool);
23
+ metric.started += 1;
24
+ metric.active += 1;
25
+ }
26
+
27
+ callFinished(tool: string, code = ""): void {
28
+ const metric = this.toolMetric(tool);
29
+ metric.active = Math.max(0, metric.active - 1);
30
+ if (!code) {
31
+ this.calls.completed += 1;
32
+ metric.completed += 1;
33
+ return;
34
+ }
35
+ this.calls.failed += 1;
36
+ metric.failed += 1;
37
+ if (code === "cancelled") this.calls.cancelled += 1;
38
+ if (code === "timeout") this.calls.timed_out += 1;
39
+ this.incrementError(code);
40
+ }
41
+
42
+ socketCandidate(): void { this.sockets.candidates += 1; }
43
+ socketAuthenticated(): void { this.sockets.authenticated += 1; }
44
+ socketDisconnected(): void { this.sockets.disconnected += 1; }
45
+ socketProtocolError(code: string): void {
46
+ this.sockets.protocol_errors += 1;
47
+ this.incrementError(code || "protocol_error");
48
+ }
49
+
50
+ snapshot(): Record<string, unknown> {
51
+ return {
52
+ uptime_ms: Math.max(0, Date.now() - this.startedAt),
53
+ requests: { ...this.requests },
54
+ calls: { ...this.calls },
55
+ sockets: { ...this.sockets },
56
+ errors: Object.fromEntries([...this.errors.entries()].sort(([left], [right]) => left.localeCompare(right))),
57
+ tools: Object.fromEntries([...this.tools.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([name, metric]) => [name, { ...metric }])),
58
+ };
59
+ }
60
+
61
+ event(level: "info" | "warn" | "error", event: string, fields: Record<string, unknown> = {}): void {
62
+ const entry = {
63
+ timestamp: new Date().toISOString(),
64
+ level,
65
+ component: "worker",
66
+ event: sanitizeName(event),
67
+ ...sanitizeFields(fields),
68
+ };
69
+ const text = JSON.stringify(entry);
70
+ if (level === "error") console.error(text);
71
+ else if (level === "warn") console.warn(text);
72
+ else console.log(text);
73
+ }
74
+
75
+ private toolMetric(tool: string): { started: number; completed: number; failed: number; active: number } {
76
+ const name = sanitizeName(tool) || "unknown";
77
+ if (!this.tools.has(name)) {
78
+ if (this.tools.size >= MAX_TOOLS) return this.toolMetric("other");
79
+ this.tools.set(name, { started: 0, completed: 0, failed: 0, active: 0 });
80
+ }
81
+ return this.tools.get(name)!;
82
+ }
83
+
84
+ private incrementError(code: string): void {
85
+ const key = sanitizeName(code) || "execution_failed";
86
+ if (!this.errors.has(key) && this.errors.size >= MAX_ERROR_CODES) {
87
+ this.errors.set("other", (this.errors.get("other") ?? 0) + 1);
88
+ return;
89
+ }
90
+ this.errors.set(key, (this.errors.get(key) ?? 0) + 1);
91
+ }
92
+ }
93
+
94
+ function sanitizeName(value: unknown): string {
95
+ return String(value || "").toLowerCase().replace(/[^a-z0-9._-]/g, "_").slice(0, 128);
96
+ }
97
+
98
+ function sanitizeFields(fields: Record<string, unknown>): Record<string, unknown> {
99
+ const out: Record<string, unknown> = {};
100
+ for (const [key, value] of Object.entries(fields).slice(0, 32)) {
101
+ const safeKey = sanitizeName(key);
102
+ if (!safeKey) continue;
103
+ if (SENSITIVE_FIELD.test(safeKey)) {
104
+ out[safeKey] = "<redacted>";
105
+ continue;
106
+ }
107
+ if (typeof value === "boolean" || typeof value === "number" || value === null) out[safeKey] = value;
108
+ else if (typeof value === "string") out[safeKey] = value.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 256);
109
+ }
110
+ return out;
111
+ }