machine-bridge-mcp 0.16.0 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.2 - 2026-07-13
4
+
5
+ ### Fixed
6
+
7
+ - Package the entire `src/worker` directory (instead of just `src/worker/index.ts`) in the published npm package so that global installations have all files required to compile/deploy the Worker.
8
+ - Add regression coverage in packaging tests to verify all worker modules are present.
9
+
10
+ ## 0.16.1 - 2026-07-13
11
+
12
+ ### Fixed
13
+
14
+ - Fix `machine-mcp` startup after 0.16.0 by keeping normalized policy capabilities immutable while allowing the sealed CLI-owned state record to update persistence metadata.
15
+ - Add regression coverage proving `updatedAt` remains writable without permitting capability fields or undeclared fields to be mutated.
16
+
3
17
  ## 0.16.0 - 2026-07-13
4
18
 
5
19
  ### Runtime boundaries and lifecycle
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Machine Bridge Browser",
4
- "version": "0.16.0",
4
+ "version": "0.16.2",
5
5
  "description": "Connects the current Chromium browser profile to the local Machine Bridge runtime for user-authorized automation.",
6
6
  "permissions": [
7
7
  "tabs",
@@ -30,5 +30,5 @@
30
30
  "action": {
31
31
  "default_title": "Machine Bridge Browser"
32
32
  },
33
- "version_name": "0.16.0"
33
+ "version_name": "0.16.2"
34
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
4
4
  "description": "Cross-client MCP bridge for local agent context, structured browser and application automation, files, Git, processes, resources, and durable jobs over stdio or OAuth relay.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,7 +20,7 @@
20
20
  "browser-extension",
21
21
  "src/local",
22
22
  "src/shared",
23
- "src/worker/index.ts",
23
+ "src/worker",
24
24
  "scripts",
25
25
  "wrangler.jsonc",
26
26
  "tsconfig.json",
@@ -215,8 +215,6 @@ async function listWindowsApplications(configuredRoots, home, context, throwIfCa
215
215
  const roots = configuredRoots || [
216
216
  join(process.env.APPDATA || home, "Microsoft", "Windows", "Start Menu", "Programs"),
217
217
  process.env.ProgramData ? join(process.env.ProgramData, "Microsoft", "Windows", "Start Menu", "Programs") : "",
218
- process.env.ProgramFiles,
219
- process.env["ProgramFiles(x86)"],
220
218
  ].filter(Boolean);
221
219
  return listExecutableRoots(roots, new Set([".exe", ".lnk"]), context, throwIfCancelled);
222
220
  }
@@ -15,14 +15,26 @@ export function resolvePolicy(args = {}, stored = {}) {
15
15
  const explicitKeys = ["profile", ...POLICY_OVERRIDE_KEYS];
16
16
  const hasExplicit = explicitKeys.some((key) => Object.prototype.hasOwnProperty.call(args, key));
17
17
  const base = { ...selectPolicyBase(args, stored, hasStored) };
18
- if (!hasExplicit) return normalizePolicy(base);
18
+ if (!hasExplicit) return policyState(base);
19
19
  applyPolicyOverrides(base, args);
20
20
  if (args.profile === undefined || POLICY_OVERRIDE_KEYS.some((key) => Object.prototype.hasOwnProperty.call(args, key))) {
21
21
  base.profile = "custom";
22
22
  base.origin = "custom";
23
23
  base.revision = DEFAULT_POLICY_REVISION;
24
24
  }
25
- return normalizePolicy(base);
25
+ return policyState(base);
26
+ }
27
+
28
+ function policyState(policy) {
29
+ // Capability fields retain the canonical immutable contract. The CLI owns a
30
+ // sealed persistence record with exactly one writable metadata field.
31
+ const normalized = normalizePolicy(policy);
32
+ const state = {};
33
+ for (const [key, value] of Object.entries(normalized)) {
34
+ Object.defineProperty(state, key, { value, enumerable: true, writable: false, configurable: false });
35
+ }
36
+ Object.defineProperty(state, "updatedAt", { value: undefined, enumerable: true, writable: true, configurable: false });
37
+ return Object.seal(state);
26
38
  }
27
39
 
28
40
  function selectPolicyBase(args, stored, hasStored) {
@@ -697,10 +697,17 @@ function runnerProcessIsCurrent(status, dir, { ownerOnly = false } = {}) {
697
697
 
698
698
  function scrubFinishedPlan(dir, status) {
699
699
  if (PLAN_RETAINING_STATES.has(status.status)) return;
700
- rmSync(join(dir, "plan.json"), { force: true });
701
- rmSync(join(dir, "runner.pid"), { force: true });
702
- rmSync(join(dir, "recovery.lock"), { force: true });
703
- rmSync(join(dir, "transition.lock"), { force: true });
700
+ const safeRm = (path) => {
701
+ try {
702
+ rmSync(path, { force: true });
703
+ } catch (error) {
704
+ if (!["ENOENT", "EPERM", "EACCES"].includes(error.code)) throw error;
705
+ }
706
+ };
707
+ safeRm(join(dir, "plan.json"));
708
+ safeRm(join(dir, "runner.pid"));
709
+ safeRm(join(dir, "recovery.lock"));
710
+ safeRm(join(dir, "transition.lock"));
704
711
  }
705
712
 
706
713
  function reviewablePlan(plan) {
@@ -0,0 +1,41 @@
1
+ const WORKER_ERROR_CODES = new Set([
2
+ "cancelled", "timeout", "authentication_failed", "authorization_denied", "policy_denied",
3
+ "invalid_request", "not_found", "conflict", "limit_exceeded", "permission_denied",
4
+ "path_boundary", "network_error", "protocol_error", "unavailable", "integrity_error",
5
+ "execution_failed", "internal_error",
6
+ ]);
7
+
8
+ export class WorkerToolError extends Error {
9
+ readonly code: string;
10
+ readonly retryable: boolean;
11
+
12
+ constructor(code: string, message: string, retryable = false) {
13
+ super(message);
14
+ this.name = "WorkerToolError";
15
+ this.code = normalizeCode(code);
16
+ this.retryable = retryable;
17
+ }
18
+ }
19
+
20
+ export function daemonToolError(value: unknown): WorkerToolError {
21
+ const input = asObject(value);
22
+ const code = normalizeCode(input.code);
23
+ const message = typeof input.message === "string" && input.message
24
+ ? input.message.slice(0, 2000)
25
+ : "daemon tool failed";
26
+ return new WorkerToolError(code, message, input.retryable === true);
27
+ }
28
+
29
+ export function publicWorkerToolError(error: unknown): { code: string; message: string; retryable: boolean } {
30
+ if (error instanceof WorkerToolError) return { code: error.code, message: error.message, retryable: error.retryable };
31
+ return { code: "execution_failed", message: "tool execution failed", retryable: false };
32
+ }
33
+
34
+ function normalizeCode(value: unknown): string {
35
+ const code = String(value || "execution_failed").toLowerCase().replace(/[^a-z0-9_-]/g, "_").slice(0, 64);
36
+ return WORKER_ERROR_CODES.has(code) ? code : "execution_failed";
37
+ }
38
+
39
+ function asObject(value: unknown): Record<string, unknown> {
40
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
41
+ }
@@ -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.0";
20
+ const SERVER_VERSION = "0.16.2";
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";
@@ -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
+ }