machine-bridge-mcp 2.0.0 → 3.0.0-beta.9
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 +101 -0
- package/CONTRIBUTING.md +20 -25
- package/README.md +21 -14
- package/SECURITY.md +147 -123
- package/browser-extension/manifest.json +3 -2
- package/browser-extension/service-worker.js +31 -12
- package/docs/ARCHITECTURE.md +27 -13
- package/docs/AUDIT.md +85 -2
- package/docs/CLIENTS.md +2 -2
- package/docs/ENGINEERING.md +9 -3
- package/docs/GETTING_STARTED.md +10 -9
- package/docs/LOCAL_AUTHORIZATION.md +115 -72
- package/docs/LOGGING.md +12 -3
- package/docs/MANAGED_JOBS.md +24 -29
- package/docs/MULTI_ACCOUNT.md +98 -37
- package/docs/OPERATIONS.md +33 -22
- package/docs/OVERVIEW.md +7 -7
- package/docs/PROJECT_STANDARDS.md +9 -5
- package/docs/RELEASING.md +171 -64
- package/docs/TESTING.md +19 -7
- package/docs/THREAT_MODEL.md +138 -67
- package/docs/TOOL_REFERENCE.md +2 -2
- package/docs/UPGRADING.md +102 -18
- package/native/macos/MachineBridgeTrustBroker.swift +233 -0
- package/package.json +36 -5
- package/scripts/candidate-runtime-store.mjs +38 -0
- package/scripts/check-plan.mjs +19 -0
- package/scripts/check-runner.mjs +4 -1
- package/scripts/commit-message-check.mjs +5 -2
- package/scripts/coverage-check.mjs +30 -2
- package/scripts/github-push.mjs +11 -0
- package/scripts/github-release.mjs +38 -7
- package/scripts/install-published-prerelease.mjs +167 -0
- package/scripts/local-release-acceptance.mjs +23 -12
- package/scripts/npm-publication-policy.mjs +16 -0
- package/scripts/prerelease-activation.mjs +79 -0
- package/scripts/privacy-check.mjs +7 -5
- package/scripts/promotion-digest.mjs +121 -0
- package/scripts/publish-npm.mjs +51 -0
- package/scripts/published-release.mjs +80 -0
- package/scripts/release-acceptance.mjs +30 -1
- package/scripts/release-candidate-manifest.mjs +40 -0
- package/scripts/release-channel.mjs +106 -0
- package/scripts/release-impact-check.mjs +12 -22
- package/scripts/release-soak.mjs +237 -0
- package/scripts/start-release-candidate.mjs +113 -7
- package/src/local/account-access.mjs +6 -0
- package/src/local/account-admin.mjs +34 -16
- package/src/local/agent-context.mjs +45 -42
- package/src/local/app-automation.mjs +9 -4
- package/src/local/authority-context.mjs +106 -0
- package/src/local/browser-bridge-http.mjs +4 -1
- package/src/local/browser-bridge.mjs +14 -9
- package/src/local/browser-broker-routes.mjs +4 -0
- package/src/local/browser-broker-server.mjs +5 -4
- package/src/local/browser-extension-identity.mjs +50 -0
- package/src/local/browser-extension-protocol.mjs +9 -4
- package/src/local/browser-operation-service.mjs +7 -0
- package/src/local/browser-pairing-http.mjs +36 -0
- package/src/local/browser-pairing-store.mjs +56 -47
- package/src/local/cli-account-admin.mjs +41 -7
- package/src/local/cli-activate.mjs +90 -0
- package/src/local/cli-approval.mjs +14 -58
- package/src/local/cli-local-admin.mjs +5 -15
- package/src/local/cli-options.mjs +11 -7
- package/src/local/cli-service.mjs +35 -5
- package/src/local/cli.mjs +112 -58
- package/src/local/daemon-process.mjs +6 -0
- package/src/local/delegated-process-sandbox.mjs +147 -0
- package/src/local/device-identity.mjs +157 -48
- package/src/local/device-root-provider.mjs +87 -0
- package/src/local/errors.mjs +25 -3
- package/src/local/git-service.mjs +16 -15
- package/src/local/job-runner.mjs +35 -27
- package/src/local/log.mjs +14 -1
- package/src/local/macos-trust-broker.mjs +320 -0
- package/src/local/managed-job-lock.mjs +18 -0
- package/src/local/managed-job-projection.mjs +9 -1
- package/src/local/managed-job-runner.mjs +13 -1
- package/src/local/managed-job-storage.mjs +2 -1
- package/src/local/managed-job-terminal.mjs +138 -0
- package/src/local/managed-jobs.mjs +88 -32
- package/src/local/operation-authorization.mjs +75 -239
- package/src/local/operation-risk.mjs +28 -0
- package/src/local/operation-state-lock.mjs +7 -86
- package/src/local/owner-state-lock.mjs +104 -0
- package/src/local/policy.mjs +19 -0
- package/src/local/process-execution.mjs +21 -9
- package/src/local/process-sessions.mjs +27 -17
- package/src/local/process-tracker.mjs +30 -6
- package/src/local/process-tree-ownership.mjs +59 -0
- package/src/local/process-tree.mjs +22 -18
- package/src/local/relay-connection.mjs +47 -35
- package/src/local/relay-diagnostics.mjs +65 -0
- package/src/local/remote-configuration.mjs +48 -0
- package/src/local/runtime-activation.mjs +141 -0
- package/src/local/runtime-diagnostics.mjs +21 -0
- package/src/local/runtime-relay.mjs +9 -8
- package/src/local/runtime-reporting.mjs +8 -3
- package/src/local/runtime-tool-handlers.mjs +8 -8
- package/src/local/runtime.mjs +73 -39
- package/src/local/secure-file.mjs +5 -1
- package/src/local/security-audit-log.mjs +204 -0
- package/src/local/service-convergence.mjs +17 -1
- package/src/local/service-ownership.mjs +17 -0
- package/src/local/service-restart-handoff.mjs +50 -0
- package/src/local/service-restart-scheduler.mjs +49 -0
- package/src/local/service-status.mjs +47 -0
- package/src/local/service.mjs +142 -43
- package/src/local/state-inventory.mjs +21 -3
- package/src/local/state.mjs +155 -17
- package/src/local/stdio.mjs +5 -0
- package/src/local/tool-executor.mjs +39 -6
- package/src/local/tool-result-boundary.mjs +51 -0
- package/src/local/tools.mjs +1 -0
- package/src/local/trusted-executable.mjs +53 -0
- package/src/local/trusted-git-executable.mjs +34 -0
- package/src/local/trusted-github-cli.mjs +24 -0
- package/src/local/windows-service.mjs +20 -0
- package/src/local/worker-deployment.mjs +3 -4
- package/src/local/worker-secret-file.mjs +2 -2
- package/src/local/workspace-file-service.mjs +34 -31
- package/src/shared/admin-auth.d.mts +2 -1
- package/src/shared/admin-auth.mjs +3 -3
- package/src/shared/device-session-auth.d.mts +20 -0
- package/src/shared/device-session-auth.mjs +51 -0
- package/src/shared/server-metadata.json +1 -1
- package/src/shared/tool-catalog.json +2 -2
- package/src/worker/account-admin.ts +23 -22
- package/src/worker/authority.ts +9 -2
- package/src/worker/daemon-auth.ts +45 -67
- package/src/worker/daemon-sockets.ts +12 -2
- package/src/worker/device-session-verifier.ts +129 -0
- package/src/worker/dpop.ts +151 -0
- package/src/worker/http.ts +47 -16
- package/src/worker/index.ts +53 -16
- package/src/worker/nonce-store.ts +1 -5
- package/src/worker/oauth-client-admin.ts +45 -0
- package/src/worker/oauth-controller.ts +55 -6
- package/src/worker/oauth-refresh-families.ts +21 -7
- package/src/worker/oauth-state.ts +6 -0
- package/src/worker/oauth-tokens.ts +28 -8
- package/src/worker/websocket-protocol.ts +8 -2
package/src/worker/index.ts
CHANGED
|
@@ -19,11 +19,12 @@ import { daemonToolError, publicWorkerToolError, WorkerToolError } from "./error
|
|
|
19
19
|
import { sanitizeDaemonPolicy, sanitizeDaemonTools } from "./policy.ts";
|
|
20
20
|
import { accountRoleAllowsTool, accountRoleToolNames, type AccountRole } from "./access.ts";
|
|
21
21
|
import { OAuthController, type AuthorizedToken, type OAuthControllerEnv } from "./oauth-controller.ts";
|
|
22
|
+
import { consumeDpopProof, verifyDpopProof } from "./dpop.ts";
|
|
22
23
|
import { accountAuthoritySnapshot, decorateProjectOverview, describeDaemonCeiling } from "./authority.ts";
|
|
23
24
|
import { serverInfoTool, workspaceTools } from "./tool-catalog.ts";
|
|
24
25
|
import { OFFLINE_ACCESS_SCOPE, randomToken } from "./oauth-state.ts";
|
|
25
26
|
import {
|
|
26
|
-
HttpError, applyCors, baseUrl,
|
|
27
|
+
HttpError, applyCors, baseUrl, corsPreflight, discardRequestBody, json, methodNotAllowed, oauthAccessToken,
|
|
27
28
|
parseJsonRequest, workerErrorClass,
|
|
28
29
|
} from "./http.ts";
|
|
29
30
|
import {
|
|
@@ -31,11 +32,11 @@ import {
|
|
|
31
32
|
sessionInstructionText, textToolResult, validateProtocolVersionHeader, type JsonRpcRequest,
|
|
32
33
|
} from "./mcp-jsonrpc.ts";
|
|
33
34
|
import {
|
|
34
|
-
closeWebSocketQuietly, isObjectRecord, rejectDaemonMessage, sendWebSocketQuietly,
|
|
35
|
+
closeWebSocketQuietly, isObjectRecord, rejectDaemonMessage, sendWebSocketQuietly, trySendWebSocket,
|
|
35
36
|
} from "./websocket-protocol.ts";
|
|
36
37
|
|
|
37
38
|
const SERVER_NAME = String(serverMetadata.name);
|
|
38
|
-
const SERVER_VERSION = "
|
|
39
|
+
const SERVER_VERSION = "3.0.0-beta.9";
|
|
39
40
|
const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
40
41
|
const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
|
|
41
42
|
const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
@@ -46,7 +47,6 @@ const DAEMON_RECONNECT_GRACE_MS = 30_000;
|
|
|
46
47
|
|
|
47
48
|
interface BridgeEnv extends OAuthControllerEnv {
|
|
48
49
|
BRIDGE: DurableObjectNamespace<BridgeRoom>;
|
|
49
|
-
ACCOUNT_ADMIN_SECRET: string;
|
|
50
50
|
DAEMON_DEVICE_PUBLIC_KEY: string;
|
|
51
51
|
OAUTH_TOKEN_VERSION: string;
|
|
52
52
|
MBM_WORKER_MAX_BODY_BYTES?: string;
|
|
@@ -63,7 +63,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
63
63
|
|
|
64
64
|
constructor(ctx: DurableObjectState, env: BridgeEnv) {
|
|
65
65
|
super(ctx, env);
|
|
66
|
-
this.oauth = new OAuthController(ctx, env, SERVER_NAME);
|
|
66
|
+
this.oauth = new OAuthController(ctx, env, SERVER_NAME, SERVER_VERSION);
|
|
67
67
|
this.daemonRegistry = new DaemonSocketRegistry(ctx);
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -107,6 +107,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
107
107
|
}
|
|
108
108
|
if (url.pathname === "/admin/accounts") return await this.oauth.handleAccountAdmin(request, "accounts");
|
|
109
109
|
if (url.pathname === "/admin/accounts/rotate-password") return await this.oauth.handleAccountAdmin(request, "rotate-password");
|
|
110
|
+
if (url.pathname === "/admin/clients") return await this.oauth.handleClientAdmin(request);
|
|
110
111
|
if (url.pathname === "/oauth/register") {
|
|
111
112
|
if (request.method !== "POST") return methodNotAllowed("POST");
|
|
112
113
|
return await this.oauth.registerClient(request);
|
|
@@ -185,12 +186,20 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
185
186
|
rejectDaemonMessage(ws, "invalid_daemon_instance", 1002, "daemon instance id required");
|
|
186
187
|
return;
|
|
187
188
|
}
|
|
188
|
-
if (
|
|
189
|
+
if (
|
|
190
|
+
!socketAttachment.authChallenge
|
|
191
|
+
|| !socketAttachment.authIssuedAt
|
|
192
|
+
|| !socketAttachment.authExpiresAt
|
|
193
|
+
|| !socketAttachment.workerOrigin
|
|
194
|
+
|| !socketAttachment.authSessionPublicKeyJson
|
|
195
|
+
|| !socketAttachment.authSessionKeyId
|
|
196
|
+
|| !socketAttachment.authCertificateExpiresAt
|
|
197
|
+
) {
|
|
189
198
|
rejectDaemonMessage(ws, "missing_daemon_challenge", 1008, "daemon challenge missing");
|
|
190
199
|
return;
|
|
191
200
|
}
|
|
192
201
|
const authenticated = await verifyDaemonAuthentication({
|
|
193
|
-
publicKeyJson:
|
|
202
|
+
publicKeyJson: socketAttachment.authSessionPublicKeyJson,
|
|
194
203
|
authentication: body.authentication,
|
|
195
204
|
challenge: {
|
|
196
205
|
scheme: "device-signature-v1",
|
|
@@ -202,6 +211,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
202
211
|
server: SERVER_NAME,
|
|
203
212
|
version: SERVER_VERSION,
|
|
204
213
|
instanceId,
|
|
214
|
+
certificateExpiresAt: socketAttachment.authCertificateExpiresAt,
|
|
205
215
|
});
|
|
206
216
|
if (!authenticated) {
|
|
207
217
|
rejectDaemonMessage(ws, "daemon_authentication_failed", 1008, "daemon authentication failed");
|
|
@@ -238,7 +248,9 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
238
248
|
|
|
239
249
|
if (body.type === "heartbeat" || body.type === "ping") {
|
|
240
250
|
await this.touchDaemonSocket(ws);
|
|
241
|
-
|
|
251
|
+
if (!trySendWebSocket(ws, { type: "pong", ts: body.ts ?? Date.now() })) {
|
|
252
|
+
this.invalidateDaemonSocket(ws, "failed to acknowledge daemon heartbeat", "daemon pong failed", "daemon_transport_error");
|
|
253
|
+
}
|
|
242
254
|
return;
|
|
243
255
|
}
|
|
244
256
|
|
|
@@ -311,13 +323,31 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
311
323
|
});
|
|
312
324
|
}
|
|
313
325
|
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
326
|
+
const access = oauthAccessToken(request);
|
|
327
|
+
const authorized = await this.oauth.verifyAccessToken(access.token, base);
|
|
328
|
+
let dpopValid = true;
|
|
329
|
+
if (authorized?.dpopJkt) {
|
|
330
|
+
const proof = access.scheme === "dpop" ? await verifyDpopProof({
|
|
331
|
+
request,
|
|
332
|
+
expectedMethod: "POST",
|
|
333
|
+
expectedUrl: request.url,
|
|
334
|
+
accessToken: access.token,
|
|
335
|
+
expectedJkt: authorized.dpopJkt,
|
|
336
|
+
}) : null;
|
|
337
|
+
dpopValid = Boolean(proof && await consumeDpopProof(this.ctx.storage, proof));
|
|
338
|
+
} else if (authorized && access.scheme !== "bearer") {
|
|
339
|
+
dpopValid = false;
|
|
340
|
+
}
|
|
341
|
+
if (!authorized || !dpopValid) {
|
|
342
|
+
// Durable Object fetch forwarding transfers a live request stream. Consume it
|
|
343
|
+
// before returning, but retain no bytes, so the 401 cannot race a pending read
|
|
344
|
+
// and unauthenticated input never enters an in-memory request buffer.
|
|
345
|
+
await discardRequestBody(request, this.bodyLimitBytes());
|
|
346
|
+
const scheme = authorized?.dpopJkt ? "DPoP" : "Bearer";
|
|
347
|
+
return new Response(authorized?.dpopJkt ? "Valid DPoP proof required" : "OAuth bearer token required", {
|
|
318
348
|
status: 401,
|
|
319
349
|
headers: {
|
|
320
|
-
"WWW-Authenticate":
|
|
350
|
+
"WWW-Authenticate": `${scheme} resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"`,
|
|
321
351
|
"cache-control": "no-store",
|
|
322
352
|
"content-type": "text/plain; charset=utf-8",
|
|
323
353
|
"x-content-type-options": "nosniff",
|
|
@@ -509,6 +539,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
509
539
|
account_id: authorized.accountId,
|
|
510
540
|
account_version: authorized.accountVersion,
|
|
511
541
|
client_id: authorized.clientId,
|
|
542
|
+
family_id: authorized.familyId,
|
|
512
543
|
role: authorized.role,
|
|
513
544
|
},
|
|
514
545
|
}));
|
|
@@ -558,9 +589,9 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
558
589
|
const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
|
|
559
590
|
this.ctx.acceptWebSocket(server);
|
|
560
591
|
this.observability.socketCandidate();
|
|
561
|
-
this.daemonRegistry.beginCandidate(server, challenge);
|
|
592
|
+
this.daemonRegistry.beginCandidate(server, challenge, preflight);
|
|
562
593
|
await this.scheduleSocketAlarms();
|
|
563
|
-
server
|
|
594
|
+
const welcomed = trySendWebSocket(server, {
|
|
564
595
|
type: "welcome",
|
|
565
596
|
server: SERVER_NAME,
|
|
566
597
|
version: SERVER_VERSION,
|
|
@@ -571,7 +602,12 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
571
602
|
issued_at: challenge.issuedAt,
|
|
572
603
|
expires_at: challenge.expiresAt,
|
|
573
604
|
},
|
|
574
|
-
})
|
|
605
|
+
});
|
|
606
|
+
if (!welcomed) {
|
|
607
|
+
this.daemonRegistry.expire(server);
|
|
608
|
+
closeWebSocketQuietly(server, 1011, "daemon welcome failed");
|
|
609
|
+
await this.scheduleSocketAlarms();
|
|
610
|
+
}
|
|
575
611
|
return new Response(null, { status: 101, webSocket: client });
|
|
576
612
|
}
|
|
577
613
|
|
|
@@ -749,6 +785,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
749
785
|
response_types_supported: ["code"],
|
|
750
786
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
751
787
|
token_endpoint_auth_methods_supported: ["none"],
|
|
788
|
+
dpop_signing_alg_values_supported: ["ES256"],
|
|
752
789
|
code_challenge_methods_supported: ["S256"],
|
|
753
790
|
scopes_supported: [SERVER_NAME, OFFLINE_ACCESS_SCOPE],
|
|
754
791
|
};
|
|
@@ -42,12 +42,8 @@ async function consumeInStorage(
|
|
|
42
42
|
if (expiresAt <= options.now) delete nonces[nonce];
|
|
43
43
|
}
|
|
44
44
|
if (nonces[options.nonce]) return false;
|
|
45
|
+
if (Object.keys(nonces).length >= options.maximum) return false;
|
|
45
46
|
nonces[options.nonce] = options.expiresAt;
|
|
46
|
-
const entries = Object.entries(nonces).sort((left, right) => left[1] - right[1] || left[0].localeCompare(right[0]));
|
|
47
|
-
while (entries.length > options.maximum) {
|
|
48
|
-
const [nonce] = entries.shift()!;
|
|
49
|
-
delete nonces[nonce];
|
|
50
|
-
}
|
|
51
47
|
await storage.put(options.key, nonces);
|
|
52
48
|
return true;
|
|
53
49
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { json, parseRequestBody } from "./http.ts";
|
|
2
|
+
import { OAUTH_REFRESH_STORE_KEY } from "./oauth-refresh-families.ts";
|
|
3
|
+
import type { OAuthRefreshStore, OAuthStore } from "./oauth-state.ts";
|
|
4
|
+
|
|
5
|
+
const BODY_LIMIT_BYTES = 64 * 1024;
|
|
6
|
+
const MAX_OAUTH_CLIENTS = 128;
|
|
7
|
+
|
|
8
|
+
export async function handleOAuthClientAdminOperation(options: {
|
|
9
|
+
request: Request;
|
|
10
|
+
store: OAuthStore;
|
|
11
|
+
refreshStore: OAuthRefreshStore;
|
|
12
|
+
storage: DurableObjectStorage;
|
|
13
|
+
now: number;
|
|
14
|
+
}): Promise<Response> {
|
|
15
|
+
const { request, store, refreshStore, storage, now } = options;
|
|
16
|
+
if (request.method === "GET") {
|
|
17
|
+
const clients = Object.values(store.clients)
|
|
18
|
+
.sort((left, right) => right.last_used_at - left.last_used_at || left.client_name.localeCompare(right.client_name))
|
|
19
|
+
.map((client) => ({
|
|
20
|
+
client_id: client.client_id,
|
|
21
|
+
client_name: client.client_name,
|
|
22
|
+
redirect_uris: [...client.redirect_uris],
|
|
23
|
+
created_at: client.created_at,
|
|
24
|
+
last_used_at: client.last_used_at,
|
|
25
|
+
trusted_account_id: client.trusted_account_id ?? null,
|
|
26
|
+
trusted_account_version: client.trusted_account_version ?? null,
|
|
27
|
+
trusted_role: client.trusted_role ?? null,
|
|
28
|
+
trusted_at: client.trusted_at ?? null,
|
|
29
|
+
active_access_tokens: Object.values(store.tokens).filter((token) => token.client_id === client.client_id && token.expires_at > now).length,
|
|
30
|
+
active_refresh_tokens: Object.values(refreshStore.tokens).filter((token) => token.client_id === client.client_id && token.expires_at > now).length,
|
|
31
|
+
}));
|
|
32
|
+
return json({ clients, maximum: MAX_OAUTH_CLIENTS });
|
|
33
|
+
}
|
|
34
|
+
if (request.method !== "DELETE") return json({ error: "method_not_allowed" }, 405, { Allow: "GET, DELETE" });
|
|
35
|
+
const body = await parseRequestBody(request, BODY_LIMIT_BYTES);
|
|
36
|
+
const clientId = String(body.client_id ?? "");
|
|
37
|
+
if (!/^mcp_client_[A-Za-z0-9_-]{43}$/.test(clientId)) return json({ error: "invalid_client_id" }, 400);
|
|
38
|
+
if (!store.clients[clientId]) return json({ error: "client_not_found" }, 404);
|
|
39
|
+
delete store.clients[clientId];
|
|
40
|
+
for (const [key, value] of Object.entries(store.codes)) if (value.client_id === clientId) delete store.codes[key];
|
|
41
|
+
for (const [key, value] of Object.entries(store.tokens)) if (value.client_id === clientId) delete store.tokens[key];
|
|
42
|
+
for (const [key, value] of Object.entries(refreshStore.tokens)) if (value.client_id === clientId) delete refreshStore.tokens[key];
|
|
43
|
+
await storage.put({ oauth: store, [OAUTH_REFRESH_STORE_KEY]: refreshStore });
|
|
44
|
+
return json({ removed: true, client_id: clientId });
|
|
45
|
+
}
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
normalizeRedirectUri, oauthRedirect, parseRequestBody, searchParamsObject,
|
|
13
13
|
} from "./http.ts";
|
|
14
14
|
import { authorizationPage } from "./oauth-authorization-page.ts";
|
|
15
|
+
import { handleOAuthClientAdminOperation } from "./oauth-client-admin.ts";
|
|
16
|
+
import { loadOAuthRefreshStore } from "./oauth-refresh-families.ts";
|
|
15
17
|
|
|
16
18
|
const OAUTH_BODY_LIMIT_BYTES = 64 * 1024;
|
|
17
19
|
const OAUTH_UNUSED_CLIENT_TTL_SECONDS = 60 * 60;
|
|
@@ -23,7 +25,6 @@ const MAX_OAUTH_CODES = 200;
|
|
|
23
25
|
const MAX_AUTH_FAILURE_IDENTITIES = 200;
|
|
24
26
|
|
|
25
27
|
export interface OAuthControllerEnv {
|
|
26
|
-
ACCOUNT_ADMIN_SECRET: string;
|
|
27
28
|
DAEMON_DEVICE_PUBLIC_KEY: string;
|
|
28
29
|
OAUTH_TOKEN_VERSION: string;
|
|
29
30
|
}
|
|
@@ -33,6 +34,8 @@ export interface AuthorizedToken {
|
|
|
33
34
|
accountId: string;
|
|
34
35
|
accountVersion: number;
|
|
35
36
|
clientId: string;
|
|
37
|
+
familyId: string;
|
|
38
|
+
dpopJkt: string;
|
|
36
39
|
role: AccountRole;
|
|
37
40
|
}
|
|
38
41
|
|
|
@@ -40,12 +43,14 @@ export class OAuthController {
|
|
|
40
43
|
private readonly ctx: DurableObjectState;
|
|
41
44
|
private readonly env: OAuthControllerEnv;
|
|
42
45
|
private readonly serverName: string;
|
|
46
|
+
private readonly serverVersion: string;
|
|
43
47
|
private oauthQueue: Promise<void> = Promise.resolve();
|
|
44
48
|
|
|
45
|
-
constructor(ctx: DurableObjectState, env: OAuthControllerEnv, serverName: string) {
|
|
49
|
+
constructor(ctx: DurableObjectState, env: OAuthControllerEnv, serverName: string, serverVersion: string) {
|
|
46
50
|
this.ctx = ctx;
|
|
47
51
|
this.env = env;
|
|
48
52
|
this.serverName = serverName;
|
|
53
|
+
this.serverVersion = serverVersion;
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
private async oauthStore(): Promise<OAuthStore> {
|
|
@@ -108,7 +113,14 @@ export class OAuthController {
|
|
|
108
113
|
async handleAccountAdmin(request: Request, operation: "accounts" | "rotate-password"): Promise<Response> {
|
|
109
114
|
return this.withOAuthLock(async () => {
|
|
110
115
|
const now = Math.floor(Date.now() / 1000);
|
|
111
|
-
const authorization = await accountAdminAuthorized(
|
|
116
|
+
const authorization = await accountAdminAuthorized(
|
|
117
|
+
request,
|
|
118
|
+
this.env.DAEMON_DEVICE_PUBLIC_KEY ?? "",
|
|
119
|
+
new URL(request.url).origin,
|
|
120
|
+
this.serverName,
|
|
121
|
+
this.serverVersion,
|
|
122
|
+
now,
|
|
123
|
+
);
|
|
112
124
|
if (!authorization || !(await consumeAccountAdminNonce(this.ctx.storage, authorization, now))) {
|
|
113
125
|
return json({ error: "unauthorized" }, 401);
|
|
114
126
|
}
|
|
@@ -120,6 +132,26 @@ export class OAuthController {
|
|
|
120
132
|
});
|
|
121
133
|
}
|
|
122
134
|
|
|
135
|
+
async handleClientAdmin(request: Request): Promise<Response> {
|
|
136
|
+
return this.withOAuthLock(async () => {
|
|
137
|
+
const now = Math.floor(Date.now() / 1000);
|
|
138
|
+
const authorization = await accountAdminAuthorized(
|
|
139
|
+
request,
|
|
140
|
+
this.env.DAEMON_DEVICE_PUBLIC_KEY ?? "",
|
|
141
|
+
new URL(request.url).origin,
|
|
142
|
+
this.serverName,
|
|
143
|
+
this.serverVersion,
|
|
144
|
+
now,
|
|
145
|
+
);
|
|
146
|
+
if (!authorization || !(await consumeAccountAdminNonce(this.ctx.storage, authorization, now))) {
|
|
147
|
+
return json({ error: "unauthorized" }, 401);
|
|
148
|
+
}
|
|
149
|
+
const store = await this.oauthStore();
|
|
150
|
+
const refreshStore = await loadOAuthRefreshStore(store, this.ctx.storage);
|
|
151
|
+
return handleOAuthClientAdminOperation({ request, store, refreshStore, storage: this.ctx.storage, now });
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
123
155
|
async registerClient(request: Request): Promise<Response> {
|
|
124
156
|
const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
|
|
125
157
|
const redirectUris = body.redirect_uris;
|
|
@@ -212,9 +244,16 @@ export class OAuthController {
|
|
|
212
244
|
const status = store.auth_failures[identity]?.blocked_until > now ? 429 : 401;
|
|
213
245
|
return authorizationPage({ request, base, serverName: this.serverName, error: "Invalid account credentials.", submitted: body, status, authorization: validation.value });
|
|
214
246
|
}
|
|
247
|
+
if (client.trusted_account_id && client.trusted_account_id !== account.account_id) {
|
|
248
|
+
return authorizationPage({ request, base, serverName: this.serverName, error: "This client is already bound to another account. Revoke it locally before changing accounts.", submitted: body, status: 409, authorization: validation.value });
|
|
249
|
+
}
|
|
215
250
|
delete store.auth_failures[identity];
|
|
216
251
|
client.last_used_at = now;
|
|
217
252
|
client.has_been_authorized = true;
|
|
253
|
+
client.trusted_account_id = account.account_id;
|
|
254
|
+
client.trusted_account_version = account.version;
|
|
255
|
+
client.trusted_role = account.role;
|
|
256
|
+
client.trusted_at ||= now;
|
|
218
257
|
|
|
219
258
|
const code = randomToken("mcp_code");
|
|
220
259
|
const redirectLocation = authorizationRedirectLocation(redirectUri, code, state);
|
|
@@ -263,14 +302,24 @@ export class OAuthController {
|
|
|
263
302
|
if (!record.version || !currentVersion || !(await safeEqual(record.version, currentVersion))) return null;
|
|
264
303
|
if (record.resource !== `${base}/mcp`) return null;
|
|
265
304
|
const account = store.accounts[record.account_id];
|
|
266
|
-
|
|
305
|
+
const client = store.clients[record.client_id];
|
|
306
|
+
if (
|
|
307
|
+
!account
|
|
308
|
+
|| !account.active
|
|
309
|
+
|| account.version !== record.account_version
|
|
310
|
+
|| account.role !== record.role
|
|
311
|
+
|| !client
|
|
312
|
+
|| client.trusted_account_id !== account.account_id
|
|
313
|
+
|| client.trusted_account_version !== account.version
|
|
314
|
+
|| client.trusted_role !== account.role
|
|
315
|
+
) {
|
|
267
316
|
delete store.tokens[key];
|
|
268
317
|
await this.ctx.storage.put("oauth", store);
|
|
269
318
|
return null;
|
|
270
319
|
}
|
|
271
320
|
return {
|
|
272
321
|
tokenKey: key, accountId: account.account_id,
|
|
273
|
-
accountVersion: account.version, clientId: record.client_id, role: account.role,
|
|
322
|
+
accountVersion: account.version, clientId: record.client_id, familyId: String(record.family_id || ""), dpopJkt: String(record.dpop_jkt || ""), role: account.role,
|
|
274
323
|
};
|
|
275
324
|
});
|
|
276
325
|
}
|
|
@@ -288,7 +337,7 @@ export class OAuthController {
|
|
|
288
337
|
}
|
|
289
338
|
|
|
290
339
|
identityKey(): string {
|
|
291
|
-
const key = this.env.OAUTH_TOKEN_VERSION
|
|
340
|
+
const key = this.env.OAUTH_TOKEN_VERSION;
|
|
292
341
|
if (!key) throw new HttpError(503, "server_not_configured", "OAuth identity key is not configured");
|
|
293
342
|
return key;
|
|
294
343
|
}
|
|
@@ -54,13 +54,14 @@ export async function loadOAuthRefreshStore(
|
|
|
54
54
|
changed = true;
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
-
if (pruneOAuthRefreshReplayState(store)) changed = true;
|
|
57
|
+
if (pruneOAuthRefreshReplayState(store, oauthStore)) changed = true;
|
|
58
58
|
if (changed || migrated) await storage.put(OAUTH_REFRESH_STORE_KEY, store);
|
|
59
59
|
return store;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
export function recordConsumedRefreshToken(
|
|
64
|
+
oauthStore: OAuthStore,
|
|
64
65
|
store: OAuthRefreshStore,
|
|
65
66
|
tokenHash: string,
|
|
66
67
|
familyId: string,
|
|
@@ -74,16 +75,17 @@ export function recordConsumedRefreshToken(
|
|
|
74
75
|
throw new Error("consumed refresh-token lifetime is invalid");
|
|
75
76
|
}
|
|
76
77
|
store.consumed[tokenHash] = { family_id: familyId, consumed_at: consumedAt, expires_at: expiresAt };
|
|
77
|
-
pruneOAuthRefreshReplayState(store);
|
|
78
|
+
pruneOAuthRefreshReplayState(store, oauthStore);
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
export function pruneOAuthRefreshReplayState(store: OAuthRefreshStore): boolean {
|
|
81
|
+
export function pruneOAuthRefreshReplayState(store: OAuthRefreshStore, oauthStore?: OAuthStore): boolean {
|
|
81
82
|
let changed = false;
|
|
82
83
|
const consumed = Object.entries(store.consumed).sort((left, right) => (
|
|
83
84
|
left[1].consumed_at - right[1].consumed_at || left[0].localeCompare(right[0])
|
|
84
85
|
));
|
|
85
86
|
while (consumed.length > MAX_CONSUMED_REFRESH_TOKENS) {
|
|
86
|
-
const [tokenHash] = consumed.shift()!;
|
|
87
|
+
const [tokenHash, marker] = consumed.shift()!;
|
|
88
|
+
revokeRefreshFamilyRecords(oauthStore, store, marker.family_id, marker.expires_at);
|
|
87
89
|
delete store.consumed[tokenHash];
|
|
88
90
|
changed = true;
|
|
89
91
|
}
|
|
@@ -115,6 +117,7 @@ function validRefreshStoreRecords(store: OAuthRefreshStore): boolean {
|
|
|
115
117
|
&& token.version.length > 0
|
|
116
118
|
&& token.version.length <= 256
|
|
117
119
|
&& FAMILY_ID_PATTERN.test(token.family_id)
|
|
120
|
+
&& (token.dpop_jkt === undefined || /^[A-Za-z0-9_-]{43}$/.test(token.dpop_jkt))
|
|
118
121
|
&& validTimestamp(token.issued_at)
|
|
119
122
|
&& validTimestamp(token.expires_at)
|
|
120
123
|
&& validTimestamp(token.family_expires_at)
|
|
@@ -160,13 +163,24 @@ export function revokeOAuthRefreshFamily(
|
|
|
160
163
|
refreshStore: OAuthRefreshStore,
|
|
161
164
|
familyId: string,
|
|
162
165
|
expiresAt: number,
|
|
166
|
+
): void {
|
|
167
|
+
revokeRefreshFamilyRecords(oauthStore, refreshStore, familyId, expiresAt);
|
|
168
|
+
pruneOAuthRefreshReplayState(refreshStore, oauthStore);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function revokeRefreshFamilyRecords(
|
|
172
|
+
oauthStore: OAuthStore | undefined,
|
|
173
|
+
refreshStore: OAuthRefreshStore,
|
|
174
|
+
familyId: string,
|
|
175
|
+
expiresAt: number,
|
|
163
176
|
): void {
|
|
164
177
|
for (const [key, token] of Object.entries(refreshStore.tokens)) {
|
|
165
178
|
if (token.family_id === familyId) delete refreshStore.tokens[key];
|
|
166
179
|
}
|
|
167
|
-
|
|
168
|
-
|
|
180
|
+
if (oauthStore) {
|
|
181
|
+
for (const [key, token] of Object.entries(oauthStore.tokens)) {
|
|
182
|
+
if (token.family_id === familyId) delete oauthStore.tokens[key];
|
|
183
|
+
}
|
|
169
184
|
}
|
|
170
185
|
refreshStore.revoked_families[familyId] = { expires_at: expiresAt, reason: "replay" };
|
|
171
|
-
pruneOAuthRefreshReplayState(refreshStore);
|
|
172
186
|
}
|
|
@@ -28,8 +28,13 @@ export interface OAuthClient {
|
|
|
28
28
|
last_used_at: number;
|
|
29
29
|
has_been_authorized?: boolean;
|
|
30
30
|
registration_identity?: string;
|
|
31
|
+
trusted_account_id?: string;
|
|
32
|
+
trusted_account_version?: number;
|
|
33
|
+
trusted_role?: AccountRole;
|
|
34
|
+
trusted_at?: number;
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
|
|
33
38
|
export interface OAuthCode {
|
|
34
39
|
client_id: string;
|
|
35
40
|
account_id: string;
|
|
@@ -52,6 +57,7 @@ export interface OAuthToken {
|
|
|
52
57
|
version: string;
|
|
53
58
|
expires_at: number;
|
|
54
59
|
family_id?: string;
|
|
60
|
+
dpop_jkt?: string;
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
export interface OAuthRefreshToken extends OAuthToken {
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "./oauth-state.ts";
|
|
6
6
|
import { HttpError, json, parseRequestBody } from "./http.ts";
|
|
7
7
|
import { loadOAuthRefreshStore, OAUTH_REFRESH_STORE_KEY, recordConsumedRefreshToken, revokeOAuthRefreshFamily } from "./oauth-refresh-families.ts";
|
|
8
|
+
import { consumeDpopProof, verifyDpopProof, type VerifiedDpopProof } from "./dpop.ts";
|
|
8
9
|
|
|
9
10
|
const ACCESS_TOKEN_TTL_SECONDS = 15 * 60;
|
|
10
11
|
const REFRESH_TOKEN_IDLE_TTL_SECONDS = 60 * 60 * 24 * 14;
|
|
@@ -36,9 +37,12 @@ export async function exchangeOAuthToken(
|
|
|
36
37
|
options: OAuthTokenExchangeOptions,
|
|
37
38
|
): Promise<Response> {
|
|
38
39
|
const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
|
|
40
|
+
const hasDpop = Boolean(request.headers.get("DPoP"));
|
|
41
|
+
const dpop = hasDpop ? await verifyDpopProof({ request, expectedMethod: "POST", expectedUrl: request.url }) : null;
|
|
42
|
+
if (hasDpop && !dpop) return json({ error: "invalid_dpop_proof" }, 400);
|
|
39
43
|
const grantType = String(body.grant_type ?? "");
|
|
40
|
-
if (grantType === "authorization_code") return exchangeAuthorizationCode(body, base, options);
|
|
41
|
-
if (grantType === "refresh_token") return exchangeRefreshToken(body, base, options);
|
|
44
|
+
if (grantType === "authorization_code") return exchangeAuthorizationCode(body, base, options, dpop || undefined);
|
|
45
|
+
if (grantType === "refresh_token") return exchangeRefreshToken(body, base, options, dpop || undefined);
|
|
42
46
|
return json({ error: "unsupported_grant_type" }, 400);
|
|
43
47
|
}
|
|
44
48
|
|
|
@@ -46,6 +50,7 @@ async function exchangeAuthorizationCode(
|
|
|
46
50
|
body: Record<string, unknown>,
|
|
47
51
|
base: string,
|
|
48
52
|
options: OAuthTokenExchangeOptions,
|
|
53
|
+
dpop?: VerifiedDpopProof,
|
|
49
54
|
): Promise<Response> {
|
|
50
55
|
const code = String(body.code ?? "");
|
|
51
56
|
const verifier = String(body.code_verifier ?? "");
|
|
@@ -69,11 +74,14 @@ async function exchangeAuthorizationCode(
|
|
|
69
74
|
const client = oauthStore.clients[record.client_id];
|
|
70
75
|
if (!client) return json({ error: "invalid_grant", error_description: "unknown client" }, 400);
|
|
71
76
|
|
|
72
|
-
|
|
77
|
+
if (dpop && !(await consumeDpopProof(options.storage, dpop))) {
|
|
78
|
+
return json({ error: "invalid_dpop_proof" }, 400);
|
|
79
|
+
}
|
|
80
|
+
const issued = await issueTokenPair(oauthStore, refreshStore, record, options.tokenVersion, dpop?.jkt);
|
|
73
81
|
delete oauthStore.codes[code];
|
|
74
82
|
client.last_used_at = Math.floor(Date.now() / 1000);
|
|
75
83
|
await saveOAuthStores(oauthStore, refreshStore, options.storage);
|
|
76
|
-
return tokenResponse(issued, record.scope);
|
|
84
|
+
return tokenResponse(issued, record.scope, dpop?.jkt);
|
|
77
85
|
});
|
|
78
86
|
}
|
|
79
87
|
|
|
@@ -81,6 +89,7 @@ async function exchangeRefreshToken(
|
|
|
81
89
|
body: Record<string, unknown>,
|
|
82
90
|
base: string,
|
|
83
91
|
options: OAuthTokenExchangeOptions,
|
|
92
|
+
dpop?: VerifiedDpopProof,
|
|
84
93
|
): Promise<Response> {
|
|
85
94
|
const refreshToken = String(body.refresh_token ?? "");
|
|
86
95
|
if (!/^mcp_rt_[A-Za-z0-9_-]{43}$/.test(refreshToken)) return json({ error: "invalid_grant" }, 400);
|
|
@@ -97,6 +106,8 @@ async function exchangeRefreshToken(
|
|
|
97
106
|
}
|
|
98
107
|
return json({ error: "invalid_grant" }, 400);
|
|
99
108
|
}
|
|
109
|
+
if (record.dpop_jkt && record.dpop_jkt !== dpop?.jkt) return json({ error: "invalid_dpop_proof" }, 400);
|
|
110
|
+
if (!record.dpop_jkt && dpop?.jkt) record.dpop_jkt = dpop.jkt;
|
|
100
111
|
if (refreshStore.revoked_families[record.family_id]) {
|
|
101
112
|
delete refreshStore.tokens[refreshKey];
|
|
102
113
|
await options.storage.put(OAUTH_REFRESH_STORE_KEY, refreshStore);
|
|
@@ -124,15 +135,21 @@ async function exchangeRefreshToken(
|
|
|
124
135
|
|| account.version !== record.account_version
|
|
125
136
|
|| account.role !== record.role
|
|
126
137
|
|| !client
|
|
138
|
+
|| client.trusted_account_id !== account.account_id
|
|
139
|
+
|| client.trusted_account_version !== account.version
|
|
140
|
+
|| client.trusted_role !== account.role
|
|
127
141
|
) {
|
|
128
142
|
delete refreshStore.tokens[refreshKey];
|
|
129
143
|
await options.storage.put(OAUTH_REFRESH_STORE_KEY, refreshStore);
|
|
130
144
|
return json({ error: "invalid_grant" }, 400);
|
|
131
145
|
}
|
|
132
146
|
|
|
147
|
+
if (dpop && !(await consumeDpopProof(options.storage, dpop))) {
|
|
148
|
+
return json({ error: "invalid_dpop_proof" }, 400);
|
|
149
|
+
}
|
|
133
150
|
let issued: IssuedTokenPair;
|
|
134
151
|
try {
|
|
135
|
-
issued = await issueTokenPair(oauthStore, refreshStore, record, options.tokenVersion);
|
|
152
|
+
issued = await issueTokenPair(oauthStore, refreshStore, record, options.tokenVersion, dpop?.jkt);
|
|
136
153
|
} catch (error) {
|
|
137
154
|
if (error instanceof HttpError && error.code === "invalid_grant") {
|
|
138
155
|
delete refreshStore.tokens[refreshKey];
|
|
@@ -143,6 +160,7 @@ async function exchangeRefreshToken(
|
|
|
143
160
|
}
|
|
144
161
|
delete refreshStore.tokens[refreshKey];
|
|
145
162
|
recordConsumedRefreshToken(
|
|
163
|
+
oauthStore,
|
|
146
164
|
refreshStore,
|
|
147
165
|
refreshKey,
|
|
148
166
|
record.family_id,
|
|
@@ -151,7 +169,7 @@ async function exchangeRefreshToken(
|
|
|
151
169
|
);
|
|
152
170
|
client.last_used_at = Math.floor(Date.now() / 1000);
|
|
153
171
|
await saveOAuthStores(oauthStore, refreshStore, options.storage);
|
|
154
|
-
return tokenResponse(issued, record.scope);
|
|
172
|
+
return tokenResponse(issued, record.scope, dpop?.jkt || record.dpop_jkt);
|
|
155
173
|
});
|
|
156
174
|
}
|
|
157
175
|
|
|
@@ -160,6 +178,7 @@ async function issueTokenPair(
|
|
|
160
178
|
refreshStore: OAuthRefreshStore,
|
|
161
179
|
source: OAuthCode | OAuthRefreshToken,
|
|
162
180
|
tokenVersion: string,
|
|
181
|
+
dpopJkt?: string,
|
|
163
182
|
): Promise<IssuedTokenPair> {
|
|
164
183
|
if (!tokenVersion) throw new HttpError(503, "server_error", "OAuth token version is not configured");
|
|
165
184
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -179,6 +198,7 @@ async function issueTokenPair(
|
|
|
179
198
|
resource: source.resource,
|
|
180
199
|
version: tokenVersion,
|
|
181
200
|
family_id: familyId,
|
|
201
|
+
...(dpopJkt ? { dpop_jkt: dpopJkt } : {}),
|
|
182
202
|
};
|
|
183
203
|
oauthStore.tokens[`sha256:${await sha256Hex(accessToken)}`] = {
|
|
184
204
|
...common,
|
|
@@ -198,11 +218,11 @@ async function issueTokenPair(
|
|
|
198
218
|
return { accessToken, refreshToken };
|
|
199
219
|
}
|
|
200
220
|
|
|
201
|
-
function tokenResponse(issued: IssuedTokenPair, scope: string): Response {
|
|
221
|
+
function tokenResponse(issued: IssuedTokenPair, scope: string, dpopJkt?: string): Response {
|
|
202
222
|
return json({
|
|
203
223
|
access_token: issued.accessToken,
|
|
204
224
|
refresh_token: issued.refreshToken,
|
|
205
|
-
token_type: "Bearer",
|
|
225
|
+
token_type: dpopJkt ? "DPoP" : "Bearer",
|
|
206
226
|
expires_in: ACCESS_TOKEN_TTL_SECONDS,
|
|
207
227
|
scope,
|
|
208
228
|
});
|
|
@@ -7,14 +7,20 @@ export function rejectDaemonMessage(ws: WebSocket, error: string, closeCode: num
|
|
|
7
7
|
closeWebSocketQuietly(ws, closeCode, closeReason);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
export function
|
|
10
|
+
export function trySendWebSocket(ws: WebSocket, value: unknown): boolean {
|
|
11
11
|
try {
|
|
12
12
|
ws.send(typeof value === "string" ? value : JSON.stringify(value));
|
|
13
|
+
return true;
|
|
13
14
|
} catch {
|
|
14
|
-
|
|
15
|
+
return false;
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
export function sendWebSocketQuietly(ws: WebSocket, value: unknown): void {
|
|
20
|
+
// Best-effort protocol cleanup must not replace the primary timeout or rejection.
|
|
21
|
+
trySendWebSocket(ws, value);
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
export function closeWebSocketQuietly(ws: WebSocket, code?: number, reason?: string): void {
|
|
19
25
|
try {
|
|
20
26
|
ws.close(code, reason);
|