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.
Files changed (44) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +21 -12
  3. package/SECURITY.md +17 -9
  4. package/browser-extension/manifest.json +2 -2
  5. package/docs/ARCHITECTURE.md +8 -6
  6. package/docs/AUDIT.md +3 -3
  7. package/docs/CLIENTS.md +3 -1
  8. package/docs/GETTING_STARTED.md +404 -0
  9. package/docs/LOGGING.md +2 -2
  10. package/docs/MANAGED_JOBS.md +1 -1
  11. package/docs/MULTI_ACCOUNT.md +123 -0
  12. package/docs/OPERATIONS.md +4 -4
  13. package/docs/POLICY_REFERENCE.md +1 -1
  14. package/docs/PRIVACY.md +1 -1
  15. package/docs/PROJECT_STANDARDS.md +1 -1
  16. package/docs/TESTING.md +4 -4
  17. package/package.json +4 -3
  18. package/src/local/account-access.mjs +37 -0
  19. package/src/local/account-admin.mjs +105 -0
  20. package/src/local/bounded-output.mjs +50 -0
  21. package/src/local/call-registry.mjs +10 -0
  22. package/src/local/cli-account-admin.mjs +79 -0
  23. package/src/local/cli-options.mjs +9 -4
  24. package/src/local/cli-policy.mjs +7 -28
  25. package/src/local/cli.mjs +48 -78
  26. package/src/local/daemon-process.mjs +3 -2
  27. package/src/local/errors.mjs +0 -14
  28. package/src/local/log.mjs +1 -1
  29. package/src/local/managed-jobs.mjs +1 -3
  30. package/src/local/process-execution.mjs +90 -60
  31. package/src/local/relay-connection.mjs +11 -3
  32. package/src/local/runtime.mjs +27 -2
  33. package/src/local/service.mjs +7 -26
  34. package/src/local/shell.mjs +17 -29
  35. package/src/local/state-inventory.mjs +2 -2
  36. package/src/local/state.mjs +30 -85
  37. package/src/local/tool-executor.mjs +8 -2
  38. package/src/shared/access-contract.json +23 -0
  39. package/src/shared/policy-contract.json +30 -7
  40. package/src/worker/access.ts +46 -0
  41. package/src/worker/account-admin.ts +103 -0
  42. package/src/worker/index.ts +75 -48
  43. package/src/worker/oauth-state.ts +184 -2
  44. 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
+ }
@@ -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, authorizationIdentity, pkceS256, pruneAuthFailures, pruneClientRecordByExpiry, pruneRecordByExpiry,
10
- randomToken, recordAuthorizationFailure, safeEqual, sha256Hex, validateAuthorizationRequest,
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.17.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
- MCP_OAUTH_PASSWORD: string;
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 === "/oauth/register") {
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
- return this.callDaemonTool(name, args, requestKey);
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({ type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs }));
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 store = (await this.ctx.storage.get<OAuthStore>("oauth")) ?? { clients: {}, codes: {}, tokens: {}, auth_failures: {} };
591
- store.clients ||= {};
592
- store.codes ||= {};
593
- store.tokens ||= {};
594
- store.auth_failures ||= {};
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
- if (value.expires_at <= now) {
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
- if (value.expires_at <= now) {
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>Connection password<br><input name="login_token" type="password" autocomplete="current-password" autofocus required style="width: 100%; box-sizing: border-box; padding: 8px;"></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 expectedLogin = this.env.MCP_OAUTH_PASSWORD ?? "";
765
- if (!expectedLogin || !(await safeEqual(String(body.login_token ?? ""), expectedLogin))) {
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 connection password.", body, status, validation.value);
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
- return { tokenKey: key, clientId: record.client_id };
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.MCP_OAUTH_PASSWORD;
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:${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
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 [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
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
+ }