machine-bridge-mcp 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +21 -12
- package/SECURITY.md +17 -9
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +8 -6
- package/docs/AUDIT.md +3 -3
- package/docs/CLIENTS.md +3 -1
- package/docs/GETTING_STARTED.md +404 -0
- package/docs/LOGGING.md +2 -2
- package/docs/MANAGED_JOBS.md +1 -1
- package/docs/MULTI_ACCOUNT.md +123 -0
- package/docs/OPERATIONS.md +4 -4
- package/docs/POLICY_REFERENCE.md +1 -1
- package/docs/PRIVACY.md +1 -1
- package/docs/PROJECT_STANDARDS.md +1 -1
- package/docs/TESTING.md +4 -4
- package/package.json +4 -3
- package/src/local/account-access.mjs +37 -0
- package/src/local/account-admin.mjs +105 -0
- package/src/local/bounded-output.mjs +50 -0
- package/src/local/call-registry.mjs +10 -0
- package/src/local/cli-account-admin.mjs +79 -0
- package/src/local/cli-options.mjs +9 -4
- package/src/local/cli-policy.mjs +7 -28
- package/src/local/cli.mjs +48 -78
- package/src/local/daemon-process.mjs +3 -2
- package/src/local/errors.mjs +0 -14
- package/src/local/log.mjs +1 -1
- package/src/local/managed-jobs.mjs +1 -3
- package/src/local/process-execution.mjs +90 -60
- package/src/local/relay-connection.mjs +11 -3
- package/src/local/runtime.mjs +27 -2
- package/src/local/service.mjs +7 -26
- package/src/local/shell.mjs +17 -29
- package/src/local/state-inventory.mjs +2 -2
- package/src/local/state.mjs +30 -85
- package/src/local/tool-executor.mjs +8 -2
- package/src/shared/access-contract.json +23 -0
- package/src/shared/policy-contract.json +30 -7
- package/src/worker/access.ts +46 -0
- package/src/worker/account-admin.ts +103 -0
- package/src/worker/index.ts +75 -48
- package/src/worker/oauth-state.ts +184 -2
- package/src/worker/tool-catalog.ts +13 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { json, methodNotAllowed, parseRequestBody } from "./http";
|
|
2
|
+
import {
|
|
3
|
+
accountByName, createAccount, publicAccount, replaceAccountPassword, revokeAccountCredentials,
|
|
4
|
+
safeEqual, updateAccount, type AccountRecord, type OAuthStore,
|
|
5
|
+
} from "./oauth-state";
|
|
6
|
+
|
|
7
|
+
const BODY_LIMIT_BYTES = 64 * 1024;
|
|
8
|
+
const MAX_ACCOUNTS = 64;
|
|
9
|
+
|
|
10
|
+
export async function accountAdminAuthorized(request: Request, expected: string): Promise<boolean> {
|
|
11
|
+
const header = request.headers.get("authorization") ?? "";
|
|
12
|
+
const supplied = /^Bearer\s+(.+)$/i.exec(header)?.[1] ?? "";
|
|
13
|
+
return Boolean(expected && await safeEqual(supplied, expected));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function handleAccountAdminOperation(options: {
|
|
17
|
+
request: Request;
|
|
18
|
+
operation: "accounts" | "rotate-password";
|
|
19
|
+
store: OAuthStore;
|
|
20
|
+
save: () => Promise<void>;
|
|
21
|
+
now: number;
|
|
22
|
+
}): Promise<Response> {
|
|
23
|
+
const { request, operation, store, save, now } = options;
|
|
24
|
+
if (operation === "rotate-password") return rotatePassword(request, store, save, now);
|
|
25
|
+
if (request.method === "GET") {
|
|
26
|
+
const accounts = Object.values(store.accounts)
|
|
27
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
28
|
+
.map(publicAccount);
|
|
29
|
+
return json({ accounts, maximum: MAX_ACCOUNTS });
|
|
30
|
+
}
|
|
31
|
+
const body = await parseRequestBody(request, BODY_LIMIT_BYTES);
|
|
32
|
+
if (request.method === "POST") return create(request, body, store, save, now);
|
|
33
|
+
if (request.method === "PATCH") return update(body, store, save, now);
|
|
34
|
+
if (request.method === "DELETE") return remove(body, store, save);
|
|
35
|
+
return methodNotAllowed("GET, POST, PATCH, DELETE");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function create(
|
|
39
|
+
_request: Request,
|
|
40
|
+
body: Record<string, unknown>,
|
|
41
|
+
store: OAuthStore,
|
|
42
|
+
save: () => Promise<void>,
|
|
43
|
+
now: number,
|
|
44
|
+
): Promise<Response> {
|
|
45
|
+
if (Object.keys(store.accounts).length >= MAX_ACCOUNTS) return json({ error: "account_limit_reached" }, 409);
|
|
46
|
+
if (accountByName(store, body.name)) return json({ error: "account_name_exists" }, 409);
|
|
47
|
+
if (Object.keys(store.accounts).length === 0 && body.role !== "owner") return json({ error: "first_account_must_be_owner" }, 409);
|
|
48
|
+
let account: AccountRecord;
|
|
49
|
+
try {
|
|
50
|
+
account = await createAccount({ name: body.name, displayName: body.display_name, role: body.role, password: body.password, now });
|
|
51
|
+
} catch {
|
|
52
|
+
return json({ error: "invalid_account", message: "account name, display name, role, or password is invalid" }, 400);
|
|
53
|
+
}
|
|
54
|
+
store.accounts[account.account_id] = account;
|
|
55
|
+
await save();
|
|
56
|
+
return json({ account: publicAccount(account) }, 201);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function update(body: Record<string, unknown>, store: OAuthStore, save: () => Promise<void>, now: number): Promise<Response> {
|
|
60
|
+
const account = store.accounts[String(body.account_id ?? "")];
|
|
61
|
+
if (!account) return json({ error: "account_not_found" }, 404);
|
|
62
|
+
const removesLastOwner = account.active && account.role === "owner" && activeOwnerCount(store) === 1
|
|
63
|
+
&& (body.active === false || (body.role !== undefined && body.role !== "owner"));
|
|
64
|
+
if (removesLastOwner) return json({ error: "last_owner_required" }, 409);
|
|
65
|
+
try {
|
|
66
|
+
updateAccount(account, { displayName: body.display_name, role: body.role, active: body.active }, now);
|
|
67
|
+
} catch {
|
|
68
|
+
return json({ error: "invalid_account", message: "account display name, role, or active state is invalid" }, 400);
|
|
69
|
+
}
|
|
70
|
+
revokeAccountCredentials(store, account.account_id);
|
|
71
|
+
await save();
|
|
72
|
+
return json({ account: publicAccount(account) });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function remove(body: Record<string, unknown>, store: OAuthStore, save: () => Promise<void>): Promise<Response> {
|
|
76
|
+
const accountId = String(body.account_id ?? "");
|
|
77
|
+
const account = store.accounts[accountId];
|
|
78
|
+
if (!account) return json({ error: "account_not_found" }, 404);
|
|
79
|
+
if (account.active && account.role === "owner" && activeOwnerCount(store) === 1) return json({ error: "last_owner_required" }, 409);
|
|
80
|
+
revokeAccountCredentials(store, accountId);
|
|
81
|
+
delete store.accounts[accountId];
|
|
82
|
+
await save();
|
|
83
|
+
return new Response(null, { status: 204 });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function rotatePassword(request: Request, store: OAuthStore, save: () => Promise<void>, now: number): Promise<Response> {
|
|
87
|
+
if (request.method !== "POST") return methodNotAllowed("POST");
|
|
88
|
+
const body = await parseRequestBody(request, BODY_LIMIT_BYTES);
|
|
89
|
+
const account = store.accounts[String(body.account_id ?? "")];
|
|
90
|
+
if (!account) return json({ error: "account_not_found" }, 404);
|
|
91
|
+
try {
|
|
92
|
+
await replaceAccountPassword(account, body.password, now);
|
|
93
|
+
} catch {
|
|
94
|
+
return json({ error: "invalid_password", message: "account password must be a generated 256-bit token" }, 400);
|
|
95
|
+
}
|
|
96
|
+
revokeAccountCredentials(store, account.account_id);
|
|
97
|
+
await save();
|
|
98
|
+
return json({ account: publicAccount(account) });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function activeOwnerCount(store: OAuthStore): number {
|
|
102
|
+
return Object.values(store.accounts).filter((account) => account.active && account.role === "owner").length;
|
|
103
|
+
}
|
package/src/worker/index.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { DurableObject } from "cloudflare:workers";
|
|
2
|
-
import toolCatalog from "../shared/tool-catalog.json";
|
|
3
2
|
import serverMetadata from "../shared/server-metadata.json";
|
|
4
3
|
import { PendingCallRegistry } from "./pending-calls";
|
|
5
4
|
import { WorkerObservability } from "./observability";
|
|
6
5
|
import { daemonToolError, publicWorkerToolError, WorkerToolError } from "./errors";
|
|
7
6
|
import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./policy";
|
|
7
|
+
import { accountRoleAllowsTool, accountRoleToolNames, type AccountRole } from "./access";
|
|
8
|
+
import { accountAdminAuthorized, handleAccountAdminOperation } from "./account-admin";
|
|
9
|
+
import { serverInfoTool, workspaceTools } from "./tool-catalog";
|
|
8
10
|
import {
|
|
9
|
-
AUTH_BLOCK_SECONDS,
|
|
10
|
-
|
|
11
|
+
AUTH_BLOCK_SECONDS, accountByName, authorizationIdentity, emptyOAuthStore, isCurrentOAuthStore,
|
|
12
|
+
pkceS256, pruneAuthFailures, pruneClientRecordByExpiry, pruneRecordByExpiry, randomToken,
|
|
13
|
+
recordAuthorizationFailure, safeEqual, sha256Hex, validateAuthorizationRequest, verifyAccountPassword,
|
|
11
14
|
type OAuthClient, type OAuthStore, type ValidatedAuthorization,
|
|
12
15
|
} from "./oauth-state";
|
|
13
16
|
import {
|
|
@@ -17,7 +20,7 @@ import {
|
|
|
17
20
|
} from "./http";
|
|
18
21
|
|
|
19
22
|
const SERVER_NAME = String(serverMetadata.name);
|
|
20
|
-
const SERVER_VERSION = "0.
|
|
23
|
+
const SERVER_VERSION = "0.18.0";
|
|
21
24
|
const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
22
25
|
const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
|
|
23
26
|
const JSONRPC_VERSION = "2.0";
|
|
@@ -41,7 +44,7 @@ const AUTHORIZATION_FIELDS = new Set(["response_type", "client_id", "redirect_ur
|
|
|
41
44
|
|
|
42
45
|
interface BridgeEnv {
|
|
43
46
|
BRIDGE: DurableObjectNamespace<BridgeRoom>;
|
|
44
|
-
|
|
47
|
+
ACCOUNT_ADMIN_SECRET: string;
|
|
45
48
|
DAEMON_SHARED_SECRET: string;
|
|
46
49
|
OAUTH_TOKEN_VERSION: string;
|
|
47
50
|
MBM_WORKER_MAX_BODY_BYTES?: string;
|
|
@@ -64,27 +67,16 @@ interface DaemonAttachment {
|
|
|
64
67
|
tools?: string[];
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
|
|
68
|
-
|
|
69
70
|
interface AuthorizedToken {
|
|
70
71
|
tokenKey: string;
|
|
71
72
|
clientId: string;
|
|
73
|
+
accountId: string;
|
|
74
|
+
accountVersion: number;
|
|
75
|
+
role: AccountRole;
|
|
72
76
|
}
|
|
73
77
|
|
|
74
|
-
type ToolDefinition = Record<string, unknown> & { name: string; availability?: string };
|
|
75
|
-
|
|
76
|
-
const allCatalogTools = toolCatalog as ToolDefinition[];
|
|
77
|
-
const serverInfoTool = publicTool(allCatalogTools.find((tool) => tool.name === "server_info")!);
|
|
78
|
-
const workspaceTools = allCatalogTools.filter((tool) => tool.name !== "server_info").map(publicTool);
|
|
79
|
-
|
|
80
78
|
const MCP_INSTRUCTIONS = serverMetadata.instructions.map((value) => String(value)).join("\n");
|
|
81
79
|
|
|
82
|
-
|
|
83
|
-
function publicTool(tool: ToolDefinition): ToolDefinition {
|
|
84
|
-
const { availability: _availability, ...definition } = tool;
|
|
85
|
-
return structuredClone(definition) as ToolDefinition;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
80
|
export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
89
81
|
private readonly pending = new PendingCallRegistry(MAX_PENDING_CALLS);
|
|
90
82
|
private readonly observability = new WorkerObservability();
|
|
@@ -133,7 +125,9 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
133
125
|
if (request.method !== "GET") return methodNotAllowed("GET");
|
|
134
126
|
return json(this.protectedResourceMetadata(base));
|
|
135
127
|
}
|
|
136
|
-
if (url.pathname === "/
|
|
128
|
+
if (url.pathname === "/admin/accounts") return await this.handleAccountAdmin(request, "accounts");
|
|
129
|
+
if (url.pathname === "/admin/accounts/rotate-password") return await this.handleAccountAdmin(request, "rotate-password");
|
|
130
|
+
if (url.pathname === "/oauth/register") {
|
|
137
131
|
if (request.method !== "POST") return methodNotAllowed("POST");
|
|
138
132
|
return await this.registerClient(request);
|
|
139
133
|
}
|
|
@@ -278,6 +272,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
278
272
|
|
|
279
273
|
const authorized = await this.verifyAccessToken(bearerToken(request), base);
|
|
280
274
|
if (!authorized) {
|
|
275
|
+
try { await request.arrayBuffer(); } catch { /* The rejected body may already be unavailable; the 401 remains authoritative. */ }
|
|
281
276
|
return new Response("OAuth bearer token required", {
|
|
282
277
|
status: 401,
|
|
283
278
|
headers: {
|
|
@@ -306,7 +301,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
306
301
|
? requested
|
|
307
302
|
: MCP_PROTOCOL_VERSION;
|
|
308
303
|
const bootstrap = this.daemonToolEnabled("session_bootstrap")
|
|
309
|
-
? await this.callDaemonTool("session_bootstrap", { path: "." }).catch(() => null)
|
|
304
|
+
? await this.callDaemonTool("session_bootstrap", { path: "." }, authorized).catch(() => null)
|
|
310
305
|
: null;
|
|
311
306
|
const localInstructions = sessionInstructionText(bootstrap);
|
|
312
307
|
return rpcResult(request.id, {
|
|
@@ -328,14 +323,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
328
323
|
}
|
|
329
324
|
if (request.method === "logging/setLevel") return rpcResult(request.id, {});
|
|
330
325
|
if (request.method === "ping") return rpcResult(request.id, {});
|
|
331
|
-
if (request.method === "tools/list") return rpcResult(request.id, { tools: this.allTools() });
|
|
326
|
+
if (request.method === "tools/list") return rpcResult(request.id, { tools: this.allTools(authorized.role) });
|
|
332
327
|
if (request.method === "tools/call") {
|
|
333
328
|
if (request.id === undefined || request.id === null) return rpcError(null, -32600, "tools/call requires a non-null request id");
|
|
334
329
|
const params = asObject(request.params);
|
|
335
330
|
const name = requiredString(params, "name");
|
|
336
331
|
const args = asObject(params.arguments);
|
|
337
332
|
try {
|
|
338
|
-
const result = await this.callTool(name, args, base, clientRequestKey(authorized, request.id));
|
|
333
|
+
const result = await this.callTool(name, args, base, authorized, clientRequestKey(authorized, request.id));
|
|
339
334
|
return rpcResult(request.id, textToolResult(result));
|
|
340
335
|
} catch (error) {
|
|
341
336
|
return rpcResult(request.id, textToolResult({ error: publicWorkerToolError(error) }, true));
|
|
@@ -344,15 +339,16 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
344
339
|
return rpcError(request.id, -32601, `Method not found: ${request.method}`);
|
|
345
340
|
}
|
|
346
341
|
|
|
347
|
-
private async callTool(name: string, args: Record<string, unknown>, base: string, requestKey?: string): Promise<unknown> {
|
|
342
|
+
private async callTool(name: string, args: Record<string, unknown>, base: string, authorized: AuthorizedToken, requestKey?: string): Promise<unknown> {
|
|
348
343
|
if (name === "server_info") {
|
|
349
344
|
const daemon = this.daemonStatus(true);
|
|
350
|
-
const tools = this.allTools().map((tool) => tool.name);
|
|
345
|
+
const tools = this.allTools(authorized.role).map((tool) => tool.name);
|
|
351
346
|
return {
|
|
352
347
|
name: SERVER_NAME,
|
|
353
348
|
version: SERVER_VERSION,
|
|
354
349
|
mcp_url: `${base}/mcp`,
|
|
355
350
|
oauth: this.authorizationServerMetadata(base),
|
|
351
|
+
account: { account_id: authorized.accountId, role: authorized.role, version: authorized.accountVersion },
|
|
356
352
|
daemon,
|
|
357
353
|
worker: {
|
|
358
354
|
pending_calls: this.pending.snapshot(),
|
|
@@ -371,12 +367,13 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
371
367
|
}
|
|
372
368
|
if (workspaceTools.some((tool) => tool.name === name)) {
|
|
373
369
|
if (!this.daemonToolEnabled(name)) throw new Error(`tool disabled by local daemon policy: ${name}`);
|
|
374
|
-
|
|
370
|
+
if (!accountRoleAllowsTool(authorized.role, name)) throw new WorkerToolError("authorization_denied", "tool is not allowed for this account role");
|
|
371
|
+
return this.callDaemonTool(name, args, authorized, requestKey);
|
|
375
372
|
}
|
|
376
373
|
throw new Error(`unknown tool: ${name}`);
|
|
377
374
|
}
|
|
378
375
|
|
|
379
|
-
private async callDaemonTool(name: string, args: Record<string, unknown>, requestKey?: string): Promise<unknown> {
|
|
376
|
+
private async callDaemonTool(name: string, args: Record<string, unknown>, authorized: AuthorizedToken, requestKey?: string): Promise<unknown> {
|
|
380
377
|
const socket = this.daemonSockets()[0];
|
|
381
378
|
if (!socket) throw new WorkerToolError("unavailable", "local daemon is not connected; keep the CLI start command running", true);
|
|
382
379
|
const id = randomToken("call");
|
|
@@ -393,7 +390,10 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
393
390
|
});
|
|
394
391
|
this.observability.callStarted(name);
|
|
395
392
|
try {
|
|
396
|
-
socket.send(JSON.stringify({
|
|
393
|
+
socket.send(JSON.stringify({
|
|
394
|
+
type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs,
|
|
395
|
+
authorization: { account_id: authorized.accountId, account_version: authorized.accountVersion, role: authorized.role },
|
|
396
|
+
}));
|
|
397
397
|
} catch {
|
|
398
398
|
this.pending.reject(id, new WorkerToolError("network_error", "failed to send daemon tool call", true), socket);
|
|
399
399
|
}
|
|
@@ -438,8 +438,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
438
438
|
return new Response(null, { status: 101, webSocket: client });
|
|
439
439
|
}
|
|
440
440
|
|
|
441
|
-
private allTools(): Array<Record<string, unknown>> {
|
|
442
|
-
const advertised = this.daemonAdvertisedTools();
|
|
441
|
+
private allTools(role: AccountRole): Array<Record<string, unknown>> {
|
|
442
|
+
const advertised = accountRoleToolNames(role, this.daemonAdvertisedTools());
|
|
443
443
|
const localTools = workspaceTools.filter((tool) => advertised.has(tool.name));
|
|
444
444
|
return [serverInfoTool, ...localTools].map((tool) => structuredClone(tool));
|
|
445
445
|
}
|
|
@@ -587,21 +587,24 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
587
587
|
}
|
|
588
588
|
|
|
589
589
|
private async oauthStore(): Promise<OAuthStore> {
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
store
|
|
595
|
-
const now = Math.floor(Date.now() / 1000);
|
|
590
|
+
const raw = await this.ctx.storage.get<unknown>("oauth");
|
|
591
|
+
if (raw !== undefined && !isCurrentOAuthStore(raw)) {
|
|
592
|
+
throw new HttpError(503, "oauth_state_schema_mismatch", "OAuth state requires the one-time multi-account upgrade");
|
|
593
|
+
}
|
|
594
|
+
const store = isCurrentOAuthStore(raw) ? raw : emptyOAuthStore();
|
|
596
595
|
let changed = false;
|
|
596
|
+
const now = Math.floor(Date.now() / 1000);
|
|
597
|
+
|
|
597
598
|
for (const [code, value] of Object.entries(store.codes)) {
|
|
598
|
-
|
|
599
|
+
const account = store.accounts[value.account_id];
|
|
600
|
+
if (value.expires_at <= now || !account || !account.active || account.version !== value.account_version || account.role !== value.role) {
|
|
599
601
|
delete store.codes[code];
|
|
600
602
|
changed = true;
|
|
601
603
|
}
|
|
602
604
|
}
|
|
603
605
|
for (const [token, value] of Object.entries(store.tokens)) {
|
|
604
|
-
|
|
606
|
+
const account = store.accounts[value.account_id];
|
|
607
|
+
if (value.expires_at <= now || !account || !account.active || account.version !== value.account_version || account.role !== value.role) {
|
|
605
608
|
delete store.tokens[token];
|
|
606
609
|
changed = true;
|
|
607
610
|
}
|
|
@@ -621,10 +624,6 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
621
624
|
delete client.registration_identity;
|
|
622
625
|
changed = true;
|
|
623
626
|
}
|
|
624
|
-
if (!client.last_used_at) {
|
|
625
|
-
client.last_used_at = client.created_at;
|
|
626
|
-
changed = true;
|
|
627
|
-
}
|
|
628
627
|
const ttl = client.last_used_at === client.created_at ? OAUTH_UNUSED_CLIENT_TTL_SECONDS : OAUTH_CLIENT_IDLE_TTL_SECONDS;
|
|
629
628
|
if (!activeClientIds.has(clientId) && client.last_used_at + ttl <= now) {
|
|
630
629
|
delete store.clients[clientId];
|
|
@@ -635,6 +634,17 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
635
634
|
return store;
|
|
636
635
|
}
|
|
637
636
|
|
|
637
|
+
private async handleAccountAdmin(request: Request, operation: "accounts" | "rotate-password"): Promise<Response> {
|
|
638
|
+
if (!(await accountAdminAuthorized(request, this.env.ACCOUNT_ADMIN_SECRET ?? ""))) return json({ error: "unauthorized" }, 401);
|
|
639
|
+
return this.withOAuthLock(async () => {
|
|
640
|
+
const store = await this.oauthStore();
|
|
641
|
+
return handleAccountAdminOperation({
|
|
642
|
+
request, operation, store, now: Math.floor(Date.now() / 1000),
|
|
643
|
+
save: () => this.ctx.storage.put("oauth", store),
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
638
648
|
private async registerClient(request: Request): Promise<Response> {
|
|
639
649
|
const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
|
|
640
650
|
const redirectUris = body.redirect_uris;
|
|
@@ -727,7 +737,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
727
737
|
const form = allowSubmit
|
|
728
738
|
? `<form method="post" action="/oauth/authorize">
|
|
729
739
|
${hidden}
|
|
730
|
-
<label>
|
|
740
|
+
<label>Account name<br><input name="account_name" autocomplete="username" autofocus required style="width: 100%; box-sizing: border-box; padding: 8px;"></label>
|
|
741
|
+
<p><label>Account password<br><input name="account_password" type="password" autocomplete="current-password" required style="width: 100%; box-sizing: border-box; padding: 8px;"></label></p>
|
|
731
742
|
<p><button type="submit">Authorize</button></p>
|
|
732
743
|
</form>`
|
|
733
744
|
: "<p>Authorization cannot continue. Return to the MCP client and start the connection again.</p>";
|
|
@@ -761,13 +772,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
761
772
|
return this.authorizePage(request, base, "Too many failed attempts. Try again later.", body, 429, validation.value);
|
|
762
773
|
}
|
|
763
774
|
|
|
764
|
-
const
|
|
765
|
-
|
|
775
|
+
const account = accountByName(store, body.account_name);
|
|
776
|
+
const credentialsValid = Boolean(account?.active && await verifyAccountPassword(account, body.account_password));
|
|
777
|
+
if (!account || !credentialsValid) {
|
|
766
778
|
recordAuthorizationFailure(store, identity, now);
|
|
767
779
|
pruneAuthFailures(store, MAX_AUTH_FAILURE_IDENTITIES);
|
|
768
780
|
await this.ctx.storage.put("oauth", store);
|
|
769
781
|
const status = store.auth_failures[identity]?.blocked_until > now ? 429 : 401;
|
|
770
|
-
return this.authorizePage(request, base, "Invalid
|
|
782
|
+
return this.authorizePage(request, base, "Invalid account credentials.", body, status, validation.value);
|
|
771
783
|
}
|
|
772
784
|
delete store.auth_failures[identity];
|
|
773
785
|
client.last_used_at = now;
|
|
@@ -776,6 +788,9 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
776
788
|
const redirectLocation = authorizationRedirectLocation(redirectUri, code, state);
|
|
777
789
|
store.codes[code] = {
|
|
778
790
|
client_id: clientId,
|
|
791
|
+
account_id: account.account_id,
|
|
792
|
+
account_version: account.version,
|
|
793
|
+
role: account.role,
|
|
779
794
|
redirect_uri: redirectUri,
|
|
780
795
|
code_challenge: codeChallenge,
|
|
781
796
|
scope,
|
|
@@ -817,6 +832,9 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
817
832
|
const accessToken = randomToken("mcp_at");
|
|
818
833
|
store.tokens[`sha256:${await sha256Hex(accessToken)}`] = {
|
|
819
834
|
client_id: record.client_id,
|
|
835
|
+
account_id: record.account_id,
|
|
836
|
+
account_version: record.account_version,
|
|
837
|
+
role: record.role,
|
|
820
838
|
scope: record.scope,
|
|
821
839
|
resource: `${base}/mcp`,
|
|
822
840
|
version: tokenVersion,
|
|
@@ -844,7 +862,16 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
844
862
|
const currentVersion = this.env.OAUTH_TOKEN_VERSION ?? "";
|
|
845
863
|
if (!record.version || !currentVersion || !(await safeEqual(record.version, currentVersion))) return null;
|
|
846
864
|
if (record.resource !== `${base}/mcp`) return null;
|
|
847
|
-
|
|
865
|
+
const account = store.accounts[record.account_id];
|
|
866
|
+
if (!account || !account.active || account.version !== record.account_version || account.role !== record.role) {
|
|
867
|
+
delete store.tokens[key];
|
|
868
|
+
await this.ctx.storage.put("oauth", store);
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
return {
|
|
872
|
+
tokenKey: key, clientId: record.client_id, accountId: account.account_id,
|
|
873
|
+
accountVersion: account.version, role: account.role,
|
|
874
|
+
};
|
|
848
875
|
});
|
|
849
876
|
}
|
|
850
877
|
|
|
@@ -861,7 +888,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
861
888
|
}
|
|
862
889
|
|
|
863
890
|
private identityKey(): string {
|
|
864
|
-
const key = this.env.OAUTH_TOKEN_VERSION || this.env.DAEMON_SHARED_SECRET || this.env.
|
|
891
|
+
const key = this.env.OAUTH_TOKEN_VERSION || this.env.DAEMON_SHARED_SECRET || this.env.ACCOUNT_ADMIN_SECRET;
|
|
865
892
|
if (!key) throw new HttpError(503, "server_not_configured", "OAuth identity key is not configured");
|
|
866
893
|
return key;
|
|
867
894
|
}
|
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
import { normalizeAccountRole, type AccountRole } from "./access";
|
|
2
|
+
|
|
3
|
+
export const OAUTH_STORE_SCHEMA_VERSION = 1;
|
|
4
|
+
const PASSWORD_TOKEN_PATTERN = /^[a-z][a-z0-9_]{2,31}_[A-Za-z0-9_-]{43}$/;
|
|
5
|
+
|
|
6
|
+
export interface AccountRecord {
|
|
7
|
+
account_id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
display_name: string;
|
|
10
|
+
role: AccountRole;
|
|
11
|
+
active: boolean;
|
|
12
|
+
version: number;
|
|
13
|
+
password_salt: string;
|
|
14
|
+
password_hash: string;
|
|
15
|
+
created_at: number;
|
|
16
|
+
updated_at: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
1
19
|
export interface OAuthClient {
|
|
2
20
|
client_id: string;
|
|
3
21
|
client_name: string;
|
|
@@ -9,6 +27,9 @@ export interface OAuthClient {
|
|
|
9
27
|
|
|
10
28
|
export interface OAuthCode {
|
|
11
29
|
client_id: string;
|
|
30
|
+
account_id: string;
|
|
31
|
+
account_version: number;
|
|
32
|
+
role: AccountRole;
|
|
12
33
|
redirect_uri: string;
|
|
13
34
|
code_challenge: string;
|
|
14
35
|
scope: string;
|
|
@@ -18,6 +39,9 @@ export interface OAuthCode {
|
|
|
18
39
|
|
|
19
40
|
export interface OAuthToken {
|
|
20
41
|
client_id: string;
|
|
42
|
+
account_id: string;
|
|
43
|
+
account_version: number;
|
|
44
|
+
role: AccountRole;
|
|
21
45
|
scope: string;
|
|
22
46
|
resource: string;
|
|
23
47
|
version: string;
|
|
@@ -32,6 +56,8 @@ export interface OAuthFailure {
|
|
|
32
56
|
}
|
|
33
57
|
|
|
34
58
|
export interface OAuthStore {
|
|
59
|
+
schema_version: number;
|
|
60
|
+
accounts: Record<string, AccountRecord>;
|
|
35
61
|
clients: Record<string, OAuthClient>;
|
|
36
62
|
codes: Record<string, OAuthCode>;
|
|
37
63
|
tokens: Record<string, OAuthToken>;
|
|
@@ -52,6 +78,28 @@ const AUTH_FAILURE_WINDOW_SECONDS = 10 * 60;
|
|
|
52
78
|
export const AUTH_BLOCK_SECONDS = 15 * 60;
|
|
53
79
|
const AUTH_FAILURE_LIMIT = 10;
|
|
54
80
|
|
|
81
|
+
export function emptyOAuthStore(): OAuthStore {
|
|
82
|
+
return {
|
|
83
|
+
schema_version: OAUTH_STORE_SCHEMA_VERSION,
|
|
84
|
+
accounts: {},
|
|
85
|
+
clients: {},
|
|
86
|
+
codes: {},
|
|
87
|
+
tokens: {},
|
|
88
|
+
auth_failures: {},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isCurrentOAuthStore(value: unknown): value is OAuthStore {
|
|
93
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
94
|
+
const store = value as Partial<OAuthStore>;
|
|
95
|
+
return store.schema_version === OAUTH_STORE_SCHEMA_VERSION
|
|
96
|
+
&& isRecord(store.accounts)
|
|
97
|
+
&& isRecord(store.clients)
|
|
98
|
+
&& isRecord(store.codes)
|
|
99
|
+
&& isRecord(store.tokens)
|
|
100
|
+
&& isRecord(store.auth_failures);
|
|
101
|
+
}
|
|
102
|
+
|
|
55
103
|
export function validateAuthorizationRequest(
|
|
56
104
|
body: Record<string, unknown>,
|
|
57
105
|
base: string,
|
|
@@ -81,6 +129,106 @@ export function validateAuthorizationRequest(
|
|
|
81
129
|
return { value: { client, clientId, redirectUri, codeChallenge, requestedResource, scope, state } };
|
|
82
130
|
}
|
|
83
131
|
|
|
132
|
+
export function normalizeAccountName(value: unknown): string | null {
|
|
133
|
+
const name = String(value ?? "").trim().toLowerCase();
|
|
134
|
+
return /^[a-z0-9](?:[a-z0-9._-]{1,62}[a-z0-9])?$/.test(name) ? name : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function accountByName(store: OAuthStore, name: unknown): AccountRecord | null {
|
|
138
|
+
const normalized = normalizeAccountName(name);
|
|
139
|
+
if (!normalized) return null;
|
|
140
|
+
return Object.values(store.accounts).find((account) => account.name === normalized) ?? null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function createAccount(input: { name: unknown; displayName?: unknown; role: unknown; password: unknown; now: number }): Promise<AccountRecord> {
|
|
144
|
+
const name = normalizeAccountName(input.name);
|
|
145
|
+
const role = normalizeAccountRole(input.role);
|
|
146
|
+
const password = normalizePassword(input.password);
|
|
147
|
+
if (!name) throw new Error("account name must be 3-64 lowercase letters, digits, dots, underscores, or hyphens");
|
|
148
|
+
if (!role) throw new Error("account role is invalid");
|
|
149
|
+
const displayName = normalizeDisplayName(input.displayName, name);
|
|
150
|
+
const salt = new Uint8Array(16);
|
|
151
|
+
crypto.getRandomValues(salt);
|
|
152
|
+
return {
|
|
153
|
+
account_id: randomToken("acct"),
|
|
154
|
+
name,
|
|
155
|
+
display_name: displayName,
|
|
156
|
+
role,
|
|
157
|
+
active: true,
|
|
158
|
+
version: 1,
|
|
159
|
+
password_salt: base64Url(salt),
|
|
160
|
+
password_hash: await derivePasswordHash(password, salt),
|
|
161
|
+
created_at: input.now,
|
|
162
|
+
updated_at: input.now,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function replaceAccountPassword(account: AccountRecord, passwordValue: unknown, now: number): Promise<void> {
|
|
167
|
+
const password = normalizePassword(passwordValue);
|
|
168
|
+
const salt = new Uint8Array(16);
|
|
169
|
+
crypto.getRandomValues(salt);
|
|
170
|
+
account.password_salt = base64Url(salt);
|
|
171
|
+
account.password_hash = await derivePasswordHash(password, salt);
|
|
172
|
+
account.version += 1;
|
|
173
|
+
account.updated_at = now;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function verifyAccountPassword(account: AccountRecord, passwordValue: unknown): Promise<boolean> {
|
|
177
|
+
try {
|
|
178
|
+
const password = normalizePassword(passwordValue);
|
|
179
|
+
const salt = base64UrlDecode(account.password_salt);
|
|
180
|
+
const hash = await derivePasswordHash(password, salt);
|
|
181
|
+
return safeEqual(hash, account.password_hash);
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function publicAccount(account: AccountRecord): Record<string, unknown> {
|
|
188
|
+
return {
|
|
189
|
+
account_id: account.account_id,
|
|
190
|
+
name: account.name,
|
|
191
|
+
display_name: account.display_name,
|
|
192
|
+
role: account.role,
|
|
193
|
+
active: account.active,
|
|
194
|
+
version: account.version,
|
|
195
|
+
created_at: account.created_at,
|
|
196
|
+
updated_at: account.updated_at,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function updateAccount(account: AccountRecord, input: { displayName?: unknown; role?: unknown; active?: unknown }, now: number): void {
|
|
201
|
+
let changed = false;
|
|
202
|
+
if (input.displayName !== undefined) {
|
|
203
|
+
account.display_name = normalizeDisplayName(input.displayName, account.name);
|
|
204
|
+
changed = true;
|
|
205
|
+
}
|
|
206
|
+
if (input.role !== undefined) {
|
|
207
|
+
const role = normalizeAccountRole(input.role);
|
|
208
|
+
if (!role) throw new Error("account role is invalid");
|
|
209
|
+
if (role !== account.role) {
|
|
210
|
+
account.role = role;
|
|
211
|
+
changed = true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (input.active !== undefined) {
|
|
215
|
+
if (typeof input.active !== "boolean") throw new Error("active must be a boolean");
|
|
216
|
+
if (account.active !== input.active) {
|
|
217
|
+
account.active = input.active;
|
|
218
|
+
changed = true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (changed) {
|
|
222
|
+
account.version += 1;
|
|
223
|
+
account.updated_at = now;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function revokeAccountCredentials(store: OAuthStore, accountId: string): void {
|
|
228
|
+
for (const [key, value] of Object.entries(store.codes)) if (value.account_id === accountId) delete store.codes[key];
|
|
229
|
+
for (const [key, value] of Object.entries(store.tokens)) if (value.account_id === accountId) delete store.tokens[key];
|
|
230
|
+
}
|
|
231
|
+
|
|
84
232
|
export function pruneClientRecordByExpiry<T extends { client_id: string; expires_at: number }>(record: Record<string, T>, clientId: string, keep: number): void {
|
|
85
233
|
const allowed = new Set(Object.entries(record)
|
|
86
234
|
.filter(([, value]) => value.client_id === clientId)
|
|
@@ -111,7 +259,7 @@ export async function authorizationIdentity(request: Request, keyMaterial: strin
|
|
|
111
259
|
["sign"],
|
|
112
260
|
);
|
|
113
261
|
const digest = await crypto.subtle.sign("HMAC", key, encoder.encode(source));
|
|
114
|
-
return `hmac-sha256:${
|
|
262
|
+
return `hmac-sha256:${hex(new Uint8Array(digest))}`;
|
|
115
263
|
}
|
|
116
264
|
|
|
117
265
|
export function recordAuthorizationFailure(store: OAuthStore, identity: string, now: number): void {
|
|
@@ -142,7 +290,7 @@ export function randomToken(prefix: string): string {
|
|
|
142
290
|
|
|
143
291
|
export async function sha256Hex(value: string): Promise<string> {
|
|
144
292
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
145
|
-
return
|
|
293
|
+
return hex(new Uint8Array(digest));
|
|
146
294
|
}
|
|
147
295
|
|
|
148
296
|
export async function safeEqual(left: string, right: string): Promise<boolean> {
|
|
@@ -165,6 +313,40 @@ export async function pkceS256(verifier: string): Promise<string> {
|
|
|
165
313
|
return base64Url(new Uint8Array(digest));
|
|
166
314
|
}
|
|
167
315
|
|
|
316
|
+
function normalizePassword(value: unknown): string {
|
|
317
|
+
if (typeof value !== "string" || !PASSWORD_TOKEN_PATTERN.test(value)) {
|
|
318
|
+
throw new Error("account password must be a generated 256-bit token");
|
|
319
|
+
}
|
|
320
|
+
return value;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function normalizeDisplayName(value: unknown, fallback: string): string {
|
|
324
|
+
const text = typeof value === "string" ? value.trim().replace(/[\r\n\t\u0000-\u001f\u007f]/g, " ").slice(0, 128) : "";
|
|
325
|
+
return text || fallback;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function derivePasswordHash(password: string, salt: Uint8Array): Promise<string> {
|
|
329
|
+
if (salt.byteLength !== 16) throw new Error("account password salt is invalid");
|
|
330
|
+
const saltBuffer = salt.buffer.slice(salt.byteOffset, salt.byteOffset + salt.byteLength) as ArrayBuffer;
|
|
331
|
+
const key = await crypto.subtle.importKey("raw", saltBuffer, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
332
|
+
const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(password));
|
|
333
|
+
return base64Url(new Uint8Array(signature));
|
|
334
|
+
}
|
|
335
|
+
|
|
168
336
|
function base64Url(bytes: Uint8Array): string {
|
|
169
337
|
return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
170
338
|
}
|
|
339
|
+
|
|
340
|
+
function base64UrlDecode(value: string): Uint8Array {
|
|
341
|
+
const normalized = value.replaceAll("-", "+").replaceAll("_", "/");
|
|
342
|
+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
|
343
|
+
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function hex(bytes: Uint8Array): string {
|
|
347
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
351
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
352
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import toolCatalog from "../shared/tool-catalog.json";
|
|
2
|
+
|
|
3
|
+
export type WorkerToolDefinition = Record<string, unknown> & { name: string; availability?: string };
|
|
4
|
+
|
|
5
|
+
const allTools = toolCatalog as WorkerToolDefinition[];
|
|
6
|
+
|
|
7
|
+
export const serverInfoTool = publicTool(allTools.find((tool) => tool.name === "server_info")!);
|
|
8
|
+
export const workspaceTools = Object.freeze(allTools.filter((tool) => tool.name !== "server_info").map(publicTool));
|
|
9
|
+
|
|
10
|
+
function publicTool(tool: WorkerToolDefinition): WorkerToolDefinition {
|
|
11
|
+
const { availability: _availability, ...definition } = tool;
|
|
12
|
+
return structuredClone(definition) as WorkerToolDefinition;
|
|
13
|
+
}
|