@moondeck/station 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +149 -2
- package/bin/uai-host.mjs +130 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/0005_host_env_vars.sql +5 -0
- package/db/migrations/0006_host_project_env.sql +9 -0
- package/db/migrations/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/0010_host_engine_accounts.sql +13 -0
- package/db/migrations/0011_codex_thread_id.sql +5 -0
- package/db/migrations/0012_agent_session_attach_key.sql +4 -0
- package/db/migrations/0013_github_credential_generations.sql +11 -0
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/0017_engine_account_scope.sql +7 -0
- package/db/migrations/0018_engine_account_position.sql +3 -0
- package/db/migrations/0019_engine_account_store_ref.sql +3 -0
- package/db/migrations/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/0021_project_env_store_ref.sql +3 -0
- package/db/migrations/0022_task_project_ids.sql +5 -0
- package/db/migrations/0023_project_env_org.sql +4 -0
- package/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
- package/db/migrations/meta/_journal.json +223 -0
- package/db/schema.ts +850 -0
- package/images/standard/Dockerfile +347 -0
- package/images/standard/README.md +167 -0
- package/images/standard/container/code-server-settings.json +63 -0
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +704 -0
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/images/standard/tool-versions +2 -0
- package/lib/__fixtures__/skill-index/SKILL.md +10 -0
- package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
- package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
- package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
- package/lib/agent-cli.ts +1507 -0
- package/lib/agent-errors.ts +22 -0
- package/lib/agent.ts +672 -0
- package/lib/agents/attach-compatibility.ts +51 -0
- package/lib/agents/claude.ts +918 -0
- package/lib/agents/codex-token-usage.fixture.json +130 -0
- package/lib/agents/codex.ts +1392 -0
- package/lib/agents/cursor-period-usage.ts +253 -0
- package/lib/agents/cursor.ts +562 -0
- package/lib/agents/dispatch.ts +78 -0
- package/lib/agents/durable-proc.ts +334 -0
- package/lib/agents/factory.ts +57 -0
- package/lib/agents/grok.ts +381 -0
- package/lib/agents/kimi.ts +329 -0
- package/lib/agents/machine-durable.ts +604 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/mode.ts +64 -0
- package/lib/agents/opencode.ts +508 -0
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/proc.ts +221 -0
- package/lib/agents/rate-limit.ts +337 -0
- package/lib/agents/registry.ts +182 -0
- package/lib/agents/session-state.ts +197 -0
- package/lib/agents/skill-index-preamble.fixture.ts +26 -0
- package/lib/agents/transport.ts +741 -0
- package/lib/agents/types.ts +254 -0
- package/lib/agents/uai.ts +106 -0
- package/lib/agents/usage.ts +44 -0
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +237 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/assignment-evidence.ts +94 -0
- package/lib/attachments.ts +48 -0
- package/lib/browser-testing.ts +1537 -0
- package/lib/capture-flow.ts +332 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2587 -0
- package/lib/cloud-credential-runtime.ts +1357 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/codex-auth.ts +456 -0
- package/lib/command-db.ts +310 -0
- package/lib/compose-network-recovery.ts +283 -0
- package/lib/connection-secret-redaction.ts +25 -0
- package/lib/container-runtime.ts +1453 -0
- package/lib/credential-cleanup-inventory.ts +490 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +797 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +702 -0
- package/lib/credential-consumers/mcp.ts +389 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/credential-store-pickup.ts +149 -0
- package/lib/credential-store-scope.ts +112 -0
- package/lib/credential-store.ts +932 -0
- package/lib/credentials-activation-authority.ts +709 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/custom-connection-capture.ts +395 -0
- package/lib/custom-connection-launch.ts +384 -0
- package/lib/custom-connection-runtime.ts +141 -0
- package/lib/db.ts +80 -0
- package/lib/disk-pressure.ts +42 -0
- package/lib/docker-exec.ts +156 -0
- package/lib/engine-accounts.ts +1970 -0
- package/lib/engine-connect.ts +321 -0
- package/lib/engine-credential-env.ts +52 -0
- package/lib/engine-login.ts +2933 -0
- package/lib/engines.ts +1342 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +246 -0
- package/lib/env-schema.ts +891 -0
- package/lib/env.ts +195 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +530 -0
- package/lib/git-identity.ts +715 -0
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +2439 -0
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-env.ts +424 -0
- package/lib/host-logs.ts +965 -0
- package/lib/internal-connections.ts +5 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/launch-mode.ts +25 -0
- package/lib/lease.ts +239 -0
- package/lib/legacy-host.ts +769 -0
- package/lib/legacy-runtime-retirement.ts +424 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/machine-exec.ts +238 -0
- package/lib/machine-gateway-tunnel.ts +344 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/machine-provider-aws.ts +923 -0
- package/lib/machine-provider-local.ts +338 -0
- package/lib/machine-provider.ts +90 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +57 -0
- package/lib/managed-runtime.ts +4296 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-capture.ts +865 -0
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-connections.ts +959 -0
- package/lib/mcp-gateway.ts +2594 -0
- package/lib/obs.ts +512 -0
- package/lib/openrouter-budget.ts +247 -0
- package/lib/orchestrator.ts +11409 -0
- package/lib/person-capture.ts +327 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/preview-sidecar.ts +331 -0
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-loader.ts +242 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/release-feed.ts +61 -0
- package/lib/release-manifest.ts +710 -0
- package/lib/release-trust.ts +46 -0
- package/lib/repo-clone.ts +38 -0
- package/lib/routing/shadow.ts +261 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +279 -0
- package/lib/runtime-archive.ts +1216 -0
- package/lib/runtime-authority.ts +166 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +1033 -0
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/address.ts +95 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +57 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +150 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +605 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/secrets.ts +71 -0
- package/lib/shared-files-s3.ts +976 -0
- package/lib/shared-files.ts +156 -0
- package/lib/sidecar-assign.ts +49 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +178 -0
- package/lib/sidecar.ts +69 -0
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/skill-index-example.fixture.ts +56 -0
- package/lib/skill-layout.ts +417 -0
- package/lib/skills.ts +489 -0
- package/lib/ssh.ts +401 -0
- package/lib/standard-image-cleanup.ts +117 -0
- package/lib/standard-image-pin.ts +207 -0
- package/lib/standard-image.ts +1697 -0
- package/lib/stopped-task-status-queue.ts +120 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-credential-cleanup.ts +752 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +91 -0
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +249 -0
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +860 -0
- package/lib/task-environment/docker.ts +1344 -0
- package/lib/task-environment/index.ts +466 -0
- package/lib/task-environment/legacy-adoption.ts +467 -0
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +1227 -0
- package/lib/task-environment/machine.ts +974 -0
- package/lib/task-environment/registry.ts +59 -0
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +566 -0
- package/lib/task-environment/types.ts +642 -0
- package/lib/task-environment/workspace-files.ts +685 -0
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-files-machine-retirement-outbox.ts +592 -0
- package/lib/task-files-machine-retirement.ts +116 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +47 -0
- package/lib/task-gateway-state.ts +512 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +682 -0
- package/lib/task-machine-credentials-activation.ts +95 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/task-person-credentials.ts +76 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1062 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-status.ts +46 -0
- package/lib/task-token.ts +46 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +105 -0
- package/lib/token-broker-local.ts +392 -0
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +525 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +123 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +165 -0
- package/lib/transcript.ts +128 -0
- package/lib/tunnel-registry.ts +220 -0
- package/lib/tunnel-runtime.ts +235 -0
- package/lib/ulid.ts +16 -0
- package/lib/user-bus-env.ts +116 -0
- package/lib/user-credentials-store.ts +435 -0
- package/lib/vnode-pressure.ts +142 -0
- package/package.json +131 -4
- package/runner/runner.mjs +442 -0
- package/scripts/agent/_common.sh +633 -0
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/moondeck-cli.sh +5 -0
- package/scripts/agent/task-down.sh +291 -0
- package/scripts/agent/task-status.sh +70 -0
- package/scripts/agent/task-up.sh +3761 -0
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/scripts/install/darwin.ts +1048 -0
- package/scripts/install/linux.ts +958 -0
- package/scripts/install/types.ts +78 -0
- package/scripts/install/util.ts +254 -0
- package/scripts/install/win.ts +143 -0
- package/src/activation-respawn.ts +155 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2583 -0
- package/src/cloud-credentials.ts +2144 -0
- package/src/cloud-origins.ts +23 -0
- package/src/credential-location.ts +248 -0
- package/src/credential-wire-validation.ts +25 -0
- package/src/custom-connections.ts +237 -0
- package/src/engine-account-paths.ts +11 -0
- package/src/engine-models.ts +39 -0
- package/src/engine-usage.ts +104 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2899 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6349 -0
- package/src/never-proxied-domains.ts +31 -0
- package/src/outbound-frame.ts +31 -0
- package/src/paths.ts +81 -0
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +396 -0
- package/src/project-env-wording.ts +53 -0
- package/src/protocol.ts +5545 -0
- package/src/runtime-bootstrap.ts +165 -0
- package/src/station-identity.ts +96 -0
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/tunnel-dial.ts +102 -0
- package/src/ui/server.ts +852 -0
- package/src/ui/types.ts +173 -0
- package/src/workspace-path-policy.ts +41 -0
- package/ui/app.js +1102 -0
- package/ui/index.html +90 -0
- package/ui/style.css +685 -0
- package/ui/uai-favicon.svg +21 -0
- package/ui/uai-logo-black.svg +9 -0
- package/ui/uai-wheel.svg +9 -0
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-authed MCP connections (ADR-057) — the host side of the generic
|
|
3
|
+
* handshake. Everything secret happens here: OAuth discovery (RFC 9728
|
|
4
|
+
* protected-resource metadata → RFC 8414 AS metadata), dynamic client
|
|
5
|
+
* registration (RFC 7591), PKCE, the code exchange, token refresh, and the
|
|
6
|
+
* sealed secret store. The cloud only ever sees non-secret status/URLs.
|
|
7
|
+
*
|
|
8
|
+
* Auth kinds:
|
|
9
|
+
* - "oauth" — discovered from a 401 + WWW-Authenticate chain.
|
|
10
|
+
* - "token" — a static header the user pasted (write-only over the bridge).
|
|
11
|
+
* - "none" — the URL answered an unauthenticated initialize.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
15
|
+
import { eq } from "drizzle-orm";
|
|
16
|
+
|
|
17
|
+
import { configuredCredentialStore, queueStoreWrite, userParameterName } from "./credential-store";
|
|
18
|
+
import { LeftOwnCredentialsError, whileOnOwnCredentials } from "./cloud-credential-host";
|
|
19
|
+
import { getDb, schema } from "./db";
|
|
20
|
+
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
21
|
+
import { signInBoxMode } from "./signin-box";
|
|
22
|
+
import type { McpOp } from "../src/protocol";
|
|
23
|
+
|
|
24
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
25
|
+
// Refresh an access token this long before its recorded expiry.
|
|
26
|
+
const REFRESH_LEAD_MS = 60_000;
|
|
27
|
+
|
|
28
|
+
export interface McpAck {
|
|
29
|
+
status: "connected" | "auth_required" | "disconnected";
|
|
30
|
+
authorizeUrl?: string;
|
|
31
|
+
scopes?: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface OauthSecret {
|
|
35
|
+
accessToken: string;
|
|
36
|
+
refreshToken?: string;
|
|
37
|
+
expiresAt?: number; // epoch ms; absent = treat as non-expiring
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface HeaderSecret {
|
|
41
|
+
headerName: string;
|
|
42
|
+
headerValue: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- sealed single-column packing (same format as host-env.ts) --------------
|
|
46
|
+
|
|
47
|
+
function pack(value: string): string {
|
|
48
|
+
const sealed = sealAesGcm(value);
|
|
49
|
+
return `${sealed.ct.toString("base64")}.${sealed.nonce.toString("base64")}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function unpack(enc: string): string {
|
|
53
|
+
const dot = enc.indexOf(".");
|
|
54
|
+
const ct = Buffer.from(enc.slice(0, dot), "base64");
|
|
55
|
+
const nonce = Buffer.from(enc.slice(dot + 1), "base64");
|
|
56
|
+
return openAesGcm(ct, nonce);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- fetch helpers -----------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
async function timedFetch(url: string, init?: RequestInit): Promise<Response> {
|
|
62
|
+
return fetch(url, {
|
|
63
|
+
...init,
|
|
64
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
65
|
+
redirect: "follow",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function fetchJson(url: string): Promise<Record<string, unknown> | null> {
|
|
70
|
+
try {
|
|
71
|
+
const res = await timedFetch(url, { headers: { accept: "application/json" } });
|
|
72
|
+
if (!res.ok) return null;
|
|
73
|
+
return (await res.json()) as Record<string, unknown>;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** POST an MCP `initialize` (streamable HTTP). Returns the raw Response. */
|
|
80
|
+
async function mcpInitialize(
|
|
81
|
+
url: string,
|
|
82
|
+
header?: HeaderSecret | null,
|
|
83
|
+
): Promise<Response> {
|
|
84
|
+
return timedFetch(url, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: {
|
|
87
|
+
"content-type": "application/json",
|
|
88
|
+
accept: "application/json, text/event-stream",
|
|
89
|
+
...(header ? { [header.headerName]: header.headerValue } : {}),
|
|
90
|
+
},
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
jsonrpc: "2.0",
|
|
93
|
+
id: 0,
|
|
94
|
+
method: "initialize",
|
|
95
|
+
params: {
|
|
96
|
+
protocolVersion: "2025-06-18",
|
|
97
|
+
capabilities: {},
|
|
98
|
+
clientInfo: { name: "uai-host", version: "1.0" },
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Collapse whitespace and cap length — this text lands in the connect dialog. */
|
|
105
|
+
function oneLine(s: string, max = 300): string {
|
|
106
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
107
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The vendor's own explanation for a failed response, so the connect dialog
|
|
112
|
+
* can show it instead of a bare status code. Vendors bury it in one of a few
|
|
113
|
+
* shapes — RFC 6750's `error_description`, a bare `error`/`message`, or a
|
|
114
|
+
* GraphQL-style `errors[]` — so try each, then fall back to the raw body.
|
|
115
|
+
* Never throws: a missing or unparseable body just means no reason to add.
|
|
116
|
+
*/
|
|
117
|
+
async function failureReason(res: Response): Promise<string | null> {
|
|
118
|
+
const text = await res.text().catch(() => "");
|
|
119
|
+
if (!text.trim()) return null;
|
|
120
|
+
let parsed: unknown;
|
|
121
|
+
try {
|
|
122
|
+
parsed = JSON.parse(text);
|
|
123
|
+
} catch {
|
|
124
|
+
return oneLine(text);
|
|
125
|
+
}
|
|
126
|
+
const body = (parsed ?? {}) as Record<string, unknown>;
|
|
127
|
+
const errors = body.errors;
|
|
128
|
+
const first = Array.isArray(errors)
|
|
129
|
+
? (errors[0] as Record<string, unknown> | undefined)
|
|
130
|
+
: undefined;
|
|
131
|
+
// `error` is a string in some payloads and an object wrapping `message` in
|
|
132
|
+
// others — New Relic alone returns both shapes plus `errors[]`, depending
|
|
133
|
+
// on which of its tiers answers. Prefer the descriptive fields; the bare
|
|
134
|
+
// `error` string ("unauthorized") is the last resort.
|
|
135
|
+
const nested =
|
|
136
|
+
typeof body.error === "object" && body.error !== null
|
|
137
|
+
? (body.error as Record<string, unknown>)
|
|
138
|
+
: undefined;
|
|
139
|
+
const reason = [
|
|
140
|
+
body.error_description,
|
|
141
|
+
body.message,
|
|
142
|
+
first?.message,
|
|
143
|
+
nested?.message,
|
|
144
|
+
body.error,
|
|
145
|
+
].find((c): c is string => typeof c === "string" && c.trim().length > 0);
|
|
146
|
+
return oneLine(reason ?? text);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- OAuth discovery (RFC 9728 → RFC 8414) -----------------------------------
|
|
150
|
+
|
|
151
|
+
/** Candidate well-known URLs, path-aware per spec, root fallback last. */
|
|
152
|
+
function wellKnownCandidates(base: string, suffix: string): string[] {
|
|
153
|
+
const u = new URL(base);
|
|
154
|
+
const path = u.pathname.replace(/\/$/, "");
|
|
155
|
+
const out: string[] = [];
|
|
156
|
+
if (path && path !== "") {
|
|
157
|
+
out.push(`${u.origin}/.well-known/${suffix}${path}`);
|
|
158
|
+
}
|
|
159
|
+
out.push(`${u.origin}/.well-known/${suffix}`);
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Parse `resource_metadata="…"` out of a WWW-Authenticate header. */
|
|
164
|
+
function resourceMetadataUrl(wwwAuth: string | null): string | null {
|
|
165
|
+
if (!wwwAuth) return null;
|
|
166
|
+
const m = /resource_metadata="([^"]+)"/i.exec(wwwAuth);
|
|
167
|
+
return m?.[1] ?? null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
interface Discovered {
|
|
171
|
+
authorizationEndpoint: string;
|
|
172
|
+
tokenEndpoint: string;
|
|
173
|
+
registrationEndpoint?: string;
|
|
174
|
+
scopesSupported?: string[];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* From the MCP URL + the 401's WWW-Authenticate, find the authorization
|
|
179
|
+
* server's metadata. Tolerates servers that skip RFC 9728 (falls back to
|
|
180
|
+
* treating the MCP origin as its own authorization server).
|
|
181
|
+
*/
|
|
182
|
+
async function discoverAuth(
|
|
183
|
+
mcpUrl: string,
|
|
184
|
+
wwwAuth: string | null,
|
|
185
|
+
): Promise<Discovered> {
|
|
186
|
+
// 1. Protected-resource metadata → authorization_servers[0].
|
|
187
|
+
const prmCandidates = [
|
|
188
|
+
...(resourceMetadataUrl(wwwAuth) ? [resourceMetadataUrl(wwwAuth)!] : []),
|
|
189
|
+
...wellKnownCandidates(mcpUrl, "oauth-protected-resource"),
|
|
190
|
+
];
|
|
191
|
+
let asBase: string | null = null;
|
|
192
|
+
let resourceScopes: string[] | undefined;
|
|
193
|
+
for (const url of prmCandidates) {
|
|
194
|
+
const prm = await fetchJson(url);
|
|
195
|
+
const servers = prm?.authorization_servers;
|
|
196
|
+
if (Array.isArray(servers) && typeof servers[0] === "string") {
|
|
197
|
+
asBase = servers[0];
|
|
198
|
+
if (Array.isArray(prm?.scopes_supported)) {
|
|
199
|
+
resourceScopes = prm.scopes_supported as string[];
|
|
200
|
+
}
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Servers that skip RFC 9728 often ARE their own AS.
|
|
205
|
+
asBase ??= new URL(mcpUrl).origin;
|
|
206
|
+
|
|
207
|
+
// 2. AS metadata (RFC 8414, then OIDC discovery).
|
|
208
|
+
const asCandidates = [
|
|
209
|
+
...wellKnownCandidates(asBase, "oauth-authorization-server"),
|
|
210
|
+
...wellKnownCandidates(asBase, "openid-configuration"),
|
|
211
|
+
];
|
|
212
|
+
for (const url of asCandidates) {
|
|
213
|
+
const meta = await fetchJson(url);
|
|
214
|
+
if (
|
|
215
|
+
typeof meta?.authorization_endpoint === "string" &&
|
|
216
|
+
typeof meta?.token_endpoint === "string"
|
|
217
|
+
) {
|
|
218
|
+
return {
|
|
219
|
+
authorizationEndpoint: meta.authorization_endpoint,
|
|
220
|
+
tokenEndpoint: meta.token_endpoint,
|
|
221
|
+
registrationEndpoint:
|
|
222
|
+
typeof meta.registration_endpoint === "string"
|
|
223
|
+
? meta.registration_endpoint
|
|
224
|
+
: undefined,
|
|
225
|
+
scopesSupported:
|
|
226
|
+
resourceScopes ??
|
|
227
|
+
(Array.isArray(meta.scopes_supported)
|
|
228
|
+
? (meta.scopes_supported as string[])
|
|
229
|
+
: undefined),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
throw new Error(
|
|
234
|
+
"the server requires auth but exposes no OAuth metadata (" +
|
|
235
|
+
"no /.well-known/oauth-protected-resource or …/oauth-authorization-server)",
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** RFC 7591 dynamic client registration. Returns client id (+secret). */
|
|
240
|
+
async function registerClient(
|
|
241
|
+
registrationEndpoint: string,
|
|
242
|
+
redirectUri: string,
|
|
243
|
+
): Promise<{ clientId: string; clientSecret?: string }> {
|
|
244
|
+
const res = await timedFetch(registrationEndpoint, {
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers: { "content-type": "application/json" },
|
|
247
|
+
body: JSON.stringify({
|
|
248
|
+
client_name: "Moondeck",
|
|
249
|
+
redirect_uris: [redirectUri],
|
|
250
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
251
|
+
response_types: ["code"],
|
|
252
|
+
token_endpoint_auth_method: "none",
|
|
253
|
+
}),
|
|
254
|
+
});
|
|
255
|
+
if (!res.ok) {
|
|
256
|
+
throw new Error(`dynamic client registration failed (${res.status})`);
|
|
257
|
+
}
|
|
258
|
+
const json = (await res.json()) as Record<string, unknown>;
|
|
259
|
+
if (typeof json.client_id !== "string") {
|
|
260
|
+
throw new Error("registration response carried no client_id");
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
clientId: json.client_id,
|
|
264
|
+
clientSecret:
|
|
265
|
+
typeof json.client_secret === "string" ? json.client_secret : undefined,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function b64url(buf: Buffer): string {
|
|
270
|
+
return buf.toString("base64url");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// --- op handlers --------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
export async function handleMcpOp(op: McpOp): Promise<McpAck> {
|
|
276
|
+
switch (op.kind) {
|
|
277
|
+
case "probe":
|
|
278
|
+
return probe(op);
|
|
279
|
+
case "oauth.complete":
|
|
280
|
+
return oauthComplete(op.connectionId, op.code);
|
|
281
|
+
case "disconnect": {
|
|
282
|
+
deleteMcpConnectionLocal(op.connectionId);
|
|
283
|
+
return { status: "disconnected" };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function probe(op: Extract<McpOp, { kind: "probe" }>): Promise<McpAck> {
|
|
289
|
+
const db = getDb();
|
|
290
|
+
const now = Date.now();
|
|
291
|
+
|
|
292
|
+
// Static-header connection: verify the header works, seal, done.
|
|
293
|
+
if (op.headerName && op.headerValue) {
|
|
294
|
+
const header = { headerName: op.headerName, headerValue: op.headerValue };
|
|
295
|
+
const res = await mcpInitialize(op.url, header);
|
|
296
|
+
if (res.status === 401 || res.status === 403) {
|
|
297
|
+
// 401 and 403 send you to different fixes, so don't collapse them into
|
|
298
|
+
// one "rejected" message. 401 is a credential the server did not
|
|
299
|
+
// recognise — re-check the key. 403 is one it DID recognise and then
|
|
300
|
+
// refused: the key is fine, but its user lacks permission or the
|
|
301
|
+
// account is not entitled to this server. Telling someone to re-paste
|
|
302
|
+
// a working key is the expensive failure here.
|
|
303
|
+
const lead =
|
|
304
|
+
res.status === 401
|
|
305
|
+
? "the server rejected the token (401)"
|
|
306
|
+
: "the token was accepted but this server refused the request " +
|
|
307
|
+
"(403) — the key is likely valid but lacks permission for it";
|
|
308
|
+
const why = await failureReason(res);
|
|
309
|
+
throw new Error(why ? `${lead}: ${why}` : lead);
|
|
310
|
+
}
|
|
311
|
+
if (!res.ok) {
|
|
312
|
+
const why = await failureReason(res);
|
|
313
|
+
const lead = `unexpected response (${res.status})`;
|
|
314
|
+
throw new Error(why ? `${lead}: ${why}` : lead);
|
|
315
|
+
}
|
|
316
|
+
if (signInBoxMode()) {
|
|
317
|
+
// ADR-136 sign-in box: the store first, a row with no secret.
|
|
318
|
+
await putMcpConnectionStoreFirst({
|
|
319
|
+
v: 1,
|
|
320
|
+
kind: "mcp",
|
|
321
|
+
id: op.connectionId,
|
|
322
|
+
userId: op.userId,
|
|
323
|
+
url: op.url,
|
|
324
|
+
authKind: "token",
|
|
325
|
+
tokenEndpoint: null,
|
|
326
|
+
redirectUri: null,
|
|
327
|
+
clientId: null,
|
|
328
|
+
clientSecret: null,
|
|
329
|
+
pkceVerifier: null,
|
|
330
|
+
secret: JSON.stringify(header),
|
|
331
|
+
status: "connected",
|
|
332
|
+
scopes: null,
|
|
333
|
+
updatedAt: now,
|
|
334
|
+
});
|
|
335
|
+
return { status: "connected" };
|
|
336
|
+
}
|
|
337
|
+
db.insert(schema.mcpConnections)
|
|
338
|
+
.values({
|
|
339
|
+
id: op.connectionId,
|
|
340
|
+
userId: op.userId,
|
|
341
|
+
url: op.url,
|
|
342
|
+
authKind: "token",
|
|
343
|
+
secretEnc: pack(JSON.stringify(header)),
|
|
344
|
+
status: "connected",
|
|
345
|
+
updatedAt: now,
|
|
346
|
+
})
|
|
347
|
+
.onConflictDoUpdate({
|
|
348
|
+
target: schema.mcpConnections.id,
|
|
349
|
+
set: {
|
|
350
|
+
url: op.url,
|
|
351
|
+
authKind: "token",
|
|
352
|
+
secretEnc: pack(JSON.stringify(header)),
|
|
353
|
+
status: "connected",
|
|
354
|
+
updatedAt: now,
|
|
355
|
+
},
|
|
356
|
+
})
|
|
357
|
+
.run();
|
|
358
|
+
mirrorMcpConnectionToStore(op.connectionId);
|
|
359
|
+
return { status: "connected" };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Bare probe.
|
|
363
|
+
const res = await mcpInitialize(op.url);
|
|
364
|
+
if (res.ok) {
|
|
365
|
+
if (signInBoxMode()) {
|
|
366
|
+
await putMcpConnectionStoreFirst({
|
|
367
|
+
v: 1,
|
|
368
|
+
kind: "mcp",
|
|
369
|
+
id: op.connectionId,
|
|
370
|
+
userId: op.userId,
|
|
371
|
+
url: op.url,
|
|
372
|
+
authKind: "none",
|
|
373
|
+
tokenEndpoint: null,
|
|
374
|
+
redirectUri: null,
|
|
375
|
+
clientId: null,
|
|
376
|
+
clientSecret: null,
|
|
377
|
+
pkceVerifier: null,
|
|
378
|
+
secret: null,
|
|
379
|
+
status: "connected",
|
|
380
|
+
scopes: null,
|
|
381
|
+
updatedAt: now,
|
|
382
|
+
});
|
|
383
|
+
return { status: "connected" };
|
|
384
|
+
}
|
|
385
|
+
db.insert(schema.mcpConnections)
|
|
386
|
+
.values({
|
|
387
|
+
id: op.connectionId,
|
|
388
|
+
userId: op.userId,
|
|
389
|
+
url: op.url,
|
|
390
|
+
authKind: "none",
|
|
391
|
+
status: "connected",
|
|
392
|
+
updatedAt: now,
|
|
393
|
+
})
|
|
394
|
+
.onConflictDoUpdate({
|
|
395
|
+
target: schema.mcpConnections.id,
|
|
396
|
+
set: { url: op.url, authKind: "none", status: "connected", updatedAt: now },
|
|
397
|
+
})
|
|
398
|
+
.run();
|
|
399
|
+
mirrorMcpConnectionToStore(op.connectionId);
|
|
400
|
+
return { status: "connected" };
|
|
401
|
+
}
|
|
402
|
+
if (res.status !== 401 && res.status !== 403) {
|
|
403
|
+
throw new Error(
|
|
404
|
+
`not an MCP server? initialize returned ${res.status} ${res.statusText}`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
if (!op.state || !op.redirectUri) {
|
|
408
|
+
throw new Error("server requires OAuth but no state/redirectUri provided");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// OAuth: discovery → client → PKCE → authorize URL.
|
|
412
|
+
const found = await discoverAuth(op.url, res.headers.get("www-authenticate"));
|
|
413
|
+
let clientId = op.clientId;
|
|
414
|
+
let clientSecret = op.clientSecret;
|
|
415
|
+
if (!clientId) {
|
|
416
|
+
if (!found.registrationEndpoint) {
|
|
417
|
+
throw new Error(
|
|
418
|
+
"the authorization server does not support dynamic client " +
|
|
419
|
+
"registration — enter a client id/secret manually",
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
const reg = await registerClient(found.registrationEndpoint, op.redirectUri);
|
|
423
|
+
clientId = reg.clientId;
|
|
424
|
+
clientSecret = reg.clientSecret;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const verifier = b64url(randomBytes(32));
|
|
428
|
+
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
|
429
|
+
const authorize = new URL(found.authorizationEndpoint);
|
|
430
|
+
authorize.searchParams.set("response_type", "code");
|
|
431
|
+
authorize.searchParams.set("client_id", clientId);
|
|
432
|
+
authorize.searchParams.set("redirect_uri", op.redirectUri);
|
|
433
|
+
authorize.searchParams.set("state", op.state);
|
|
434
|
+
authorize.searchParams.set("code_challenge", challenge);
|
|
435
|
+
authorize.searchParams.set("code_challenge_method", "S256");
|
|
436
|
+
// RFC 8707 resource indicator — REQUIRED by the MCP auth spec.
|
|
437
|
+
authorize.searchParams.set("resource", op.url);
|
|
438
|
+
if (found.scopesSupported?.length) {
|
|
439
|
+
authorize.searchParams.set("scope", found.scopesSupported.join(" "));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (signInBoxMode()) {
|
|
443
|
+
// ADR-136 sign-in box: the pending half-flow (verifier, client secret)
|
|
444
|
+
// lives in memory only until oauth.complete; nothing pending reaches the
|
|
445
|
+
// store or the row. A box restart mid-flow means "probe again".
|
|
446
|
+
boxPendingOauth.set(op.connectionId, { verifier, clientSecret: clientSecret ?? null });
|
|
447
|
+
upsertStoreHeldMcpConnectionRow(
|
|
448
|
+
{
|
|
449
|
+
id: op.connectionId,
|
|
450
|
+
userId: op.userId,
|
|
451
|
+
url: op.url,
|
|
452
|
+
authKind: "oauth",
|
|
453
|
+
tokenEndpoint: found.tokenEndpoint,
|
|
454
|
+
redirectUri: op.redirectUri,
|
|
455
|
+
clientId,
|
|
456
|
+
status: "pending",
|
|
457
|
+
scopes: found.scopesSupported?.join(" ") ?? null,
|
|
458
|
+
updatedAt: now,
|
|
459
|
+
},
|
|
460
|
+
null,
|
|
461
|
+
);
|
|
462
|
+
return { status: "auth_required", authorizeUrl: authorize.toString() };
|
|
463
|
+
}
|
|
464
|
+
const fields = {
|
|
465
|
+
userId: op.userId,
|
|
466
|
+
url: op.url,
|
|
467
|
+
authKind: "oauth",
|
|
468
|
+
tokenEndpoint: found.tokenEndpoint,
|
|
469
|
+
redirectUri: op.redirectUri,
|
|
470
|
+
clientId,
|
|
471
|
+
clientSecretEnc: clientSecret ? pack(clientSecret) : null,
|
|
472
|
+
pkceVerifierEnc: pack(verifier),
|
|
473
|
+
secretEnc: null,
|
|
474
|
+
status: "pending",
|
|
475
|
+
scopes: found.scopesSupported?.join(" ") ?? null,
|
|
476
|
+
updatedAt: now,
|
|
477
|
+
};
|
|
478
|
+
db.insert(schema.mcpConnections)
|
|
479
|
+
.values({ id: op.connectionId, ...fields })
|
|
480
|
+
.onConflictDoUpdate({ target: schema.mcpConnections.id, set: fields })
|
|
481
|
+
.run();
|
|
482
|
+
mirrorMcpConnectionToStore(op.connectionId);
|
|
483
|
+
return { status: "auth_required", authorizeUrl: authorize.toString() };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function oauthComplete(connectionId: string, code: string): Promise<McpAck> {
|
|
487
|
+
const db = getDb();
|
|
488
|
+
const row = db
|
|
489
|
+
.select()
|
|
490
|
+
.from(schema.mcpConnections)
|
|
491
|
+
.where(eq(schema.mcpConnections.id, connectionId))
|
|
492
|
+
.get();
|
|
493
|
+
if (!row) throw new Error("unknown connection — probe first");
|
|
494
|
+
const pending = signInBoxMode() ? (boxPendingOauth.get(connectionId) ?? null) : null;
|
|
495
|
+
if (
|
|
496
|
+
!row.tokenEndpoint ||
|
|
497
|
+
!row.clientId ||
|
|
498
|
+
!row.redirectUri ||
|
|
499
|
+
(!pending && !row.pkceVerifierEnc)
|
|
500
|
+
) {
|
|
501
|
+
throw new Error(
|
|
502
|
+
signInBoxMode() && !pending
|
|
503
|
+
? "the sign-in box restarted during this OAuth flow — probe again"
|
|
504
|
+
: "connection has no pending OAuth flow",
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
const verifier = pending ? pending.verifier : unpack(row.pkceVerifierEnc!);
|
|
508
|
+
const clientSecret = pending
|
|
509
|
+
? (pending.clientSecret ?? undefined)
|
|
510
|
+
: row.clientSecretEnc
|
|
511
|
+
? unpack(row.clientSecretEnc)
|
|
512
|
+
: undefined;
|
|
513
|
+
|
|
514
|
+
const { ok, status, json } = await postTokenEndpoint(
|
|
515
|
+
row.tokenEndpoint,
|
|
516
|
+
{
|
|
517
|
+
grant_type: "authorization_code",
|
|
518
|
+
code,
|
|
519
|
+
redirect_uri: row.redirectUri,
|
|
520
|
+
client_id: row.clientId,
|
|
521
|
+
code_verifier: verifier,
|
|
522
|
+
// RFC 8707, mirrored from the authorize request.
|
|
523
|
+
resource: row.url,
|
|
524
|
+
},
|
|
525
|
+
row.clientId,
|
|
526
|
+
clientSecret,
|
|
527
|
+
);
|
|
528
|
+
if (!ok || typeof json.access_token !== "string") {
|
|
529
|
+
const detail =
|
|
530
|
+
typeof json.error === "string" ? json.error : `status ${status}`;
|
|
531
|
+
throw new Error(`token exchange failed (${detail})`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const secret: OauthSecret = {
|
|
535
|
+
accessToken: json.access_token,
|
|
536
|
+
refreshToken:
|
|
537
|
+
typeof json.refresh_token === "string" ? json.refresh_token : undefined,
|
|
538
|
+
expiresAt:
|
|
539
|
+
typeof json.expires_in === "number"
|
|
540
|
+
? Date.now() + json.expires_in * 1000
|
|
541
|
+
: undefined,
|
|
542
|
+
};
|
|
543
|
+
const scopes =
|
|
544
|
+
typeof json.scope === "string" && json.scope.length > 0
|
|
545
|
+
? json.scope
|
|
546
|
+
: row.scopes;
|
|
547
|
+
if (pending) {
|
|
548
|
+
await putMcpConnectionStoreFirst({
|
|
549
|
+
v: 1,
|
|
550
|
+
kind: "mcp",
|
|
551
|
+
id: connectionId,
|
|
552
|
+
userId: row.userId,
|
|
553
|
+
url: row.url,
|
|
554
|
+
authKind: "oauth",
|
|
555
|
+
tokenEndpoint: row.tokenEndpoint,
|
|
556
|
+
redirectUri: row.redirectUri,
|
|
557
|
+
clientId: row.clientId,
|
|
558
|
+
clientSecret: pending.clientSecret,
|
|
559
|
+
pkceVerifier: null,
|
|
560
|
+
secret: JSON.stringify(secret),
|
|
561
|
+
status: "connected",
|
|
562
|
+
scopes,
|
|
563
|
+
updatedAt: Date.now(),
|
|
564
|
+
});
|
|
565
|
+
boxPendingOauth.delete(connectionId);
|
|
566
|
+
return {
|
|
567
|
+
status: "connected",
|
|
568
|
+
scopes: scopes ? scopes.split(" ").filter(Boolean) : undefined,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
db.update(schema.mcpConnections)
|
|
572
|
+
.set({
|
|
573
|
+
secretEnc: pack(JSON.stringify(secret)),
|
|
574
|
+
pkceVerifierEnc: null,
|
|
575
|
+
status: "connected",
|
|
576
|
+
scopes,
|
|
577
|
+
updatedAt: Date.now(),
|
|
578
|
+
})
|
|
579
|
+
.where(eq(schema.mcpConnections.id, connectionId))
|
|
580
|
+
.run();
|
|
581
|
+
mirrorMcpConnectionToStore(connectionId);
|
|
582
|
+
return {
|
|
583
|
+
status: "connected",
|
|
584
|
+
scopes: scopes ? scopes.split(" ").filter(Boolean) : undefined,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// --- gateway support ----------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
/** A connected row for `id`, or null. */
|
|
591
|
+
export function getConnection(id: string): schema.McpConnection | null {
|
|
592
|
+
return (
|
|
593
|
+
getDb()
|
|
594
|
+
.select()
|
|
595
|
+
.from(schema.mcpConnections)
|
|
596
|
+
.where(eq(schema.mcpConnections.id, id))
|
|
597
|
+
.get() ?? null
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** All CONNECTED connections owned by `userId` (task-up + gateway ACL). */
|
|
602
|
+
export function listConnectionsForUser(userId: string): schema.McpConnection[] {
|
|
603
|
+
return getDb()
|
|
604
|
+
.select()
|
|
605
|
+
.from(schema.mcpConnections)
|
|
606
|
+
.where(eq(schema.mcpConnections.userId, userId))
|
|
607
|
+
.all()
|
|
608
|
+
.filter((r) => r.status === "connected");
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* The Authorization (or custom) header for a connection, refreshing an
|
|
613
|
+
* expired OAuth access token first (rotating the stored refresh token when
|
|
614
|
+
* the server issues a new one). Null when the connection needs no header.
|
|
615
|
+
*/
|
|
616
|
+
export async function authHeaderFor(
|
|
617
|
+
row: schema.McpConnection,
|
|
618
|
+
forceRefresh = false,
|
|
619
|
+
): Promise<{ name: string; value: string } | null> {
|
|
620
|
+
if (row.authKind === "none") return null;
|
|
621
|
+
if (!row.secretEnc) throw new Error("connection has no stored secret");
|
|
622
|
+
if (row.authKind === "token") {
|
|
623
|
+
const h = JSON.parse(unpack(row.secretEnc)) as HeaderSecret;
|
|
624
|
+
return { name: h.headerName, value: h.headerValue };
|
|
625
|
+
}
|
|
626
|
+
let secret = JSON.parse(unpack(row.secretEnc)) as OauthSecret;
|
|
627
|
+
if (
|
|
628
|
+
forceRefresh ||
|
|
629
|
+
(secret.expiresAt && secret.expiresAt - REFRESH_LEAD_MS < Date.now())
|
|
630
|
+
) {
|
|
631
|
+
// ADR-154 §7: a refresh only while the runner is on its own
|
|
632
|
+
// credentials, counted whole (the rotated secret written, its store
|
|
633
|
+
// mirror queued): leaving them waits for it, and none begins after.
|
|
634
|
+
const current = secret;
|
|
635
|
+
const refreshing = whileOnOwnCredentials(() => refreshTokens(row, current));
|
|
636
|
+
if (refreshing === null) throw new LeftOwnCredentialsError();
|
|
637
|
+
secret = await refreshing;
|
|
638
|
+
}
|
|
639
|
+
return { name: "authorization", value: `Bearer ${secret.accessToken}` };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* POST a token-endpoint request. A client secret rides HTTP Basic first
|
|
644
|
+
* (RFC 6749's MUST-support method, what DCR vendors expect); on failure it
|
|
645
|
+
* retries once with `client_secret` in the body (client_secret_post) —
|
|
646
|
+
* some manual-creds vendors (Slack's user-token endpoint) only read body
|
|
647
|
+
* credentials and answer Basic with bad_client_secret.
|
|
648
|
+
*/
|
|
649
|
+
async function postTokenEndpoint(
|
|
650
|
+
tokenEndpoint: string,
|
|
651
|
+
params: Record<string, string>,
|
|
652
|
+
clientId: string,
|
|
653
|
+
clientSecret: string | undefined,
|
|
654
|
+
): Promise<{ ok: boolean; status: number; json: Record<string, unknown> }> {
|
|
655
|
+
const attempt = async (mode: "basic" | "post") => {
|
|
656
|
+
const body = new URLSearchParams(params);
|
|
657
|
+
const headers: Record<string, string> = {
|
|
658
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
659
|
+
accept: "application/json",
|
|
660
|
+
};
|
|
661
|
+
if (clientSecret) {
|
|
662
|
+
if (mode === "basic") {
|
|
663
|
+
headers.authorization =
|
|
664
|
+
"Basic " +
|
|
665
|
+
Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
|
|
666
|
+
} else {
|
|
667
|
+
body.set("client_secret", clientSecret);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const res = await timedFetch(tokenEndpoint, {
|
|
671
|
+
method: "POST",
|
|
672
|
+
headers,
|
|
673
|
+
body,
|
|
674
|
+
});
|
|
675
|
+
const json = (await res.json().catch(() => ({}))) as Record<
|
|
676
|
+
string,
|
|
677
|
+
unknown
|
|
678
|
+
>;
|
|
679
|
+
return {
|
|
680
|
+
ok: res.ok && typeof json.access_token === "string",
|
|
681
|
+
status: res.status,
|
|
682
|
+
json,
|
|
683
|
+
};
|
|
684
|
+
};
|
|
685
|
+
const basic = await attempt("basic");
|
|
686
|
+
if (basic.ok || !clientSecret) return basic;
|
|
687
|
+
return attempt("post");
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async function refreshTokens(
|
|
691
|
+
row: schema.McpConnection,
|
|
692
|
+
secret: OauthSecret,
|
|
693
|
+
): Promise<OauthSecret> {
|
|
694
|
+
if (!secret.refreshToken || !row.tokenEndpoint || !row.clientId) {
|
|
695
|
+
throw new Error("access token expired and no refresh token is stored");
|
|
696
|
+
}
|
|
697
|
+
const { ok, status, json } = await postTokenEndpoint(
|
|
698
|
+
row.tokenEndpoint,
|
|
699
|
+
{
|
|
700
|
+
grant_type: "refresh_token",
|
|
701
|
+
refresh_token: secret.refreshToken,
|
|
702
|
+
client_id: row.clientId,
|
|
703
|
+
resource: row.url,
|
|
704
|
+
},
|
|
705
|
+
row.clientId,
|
|
706
|
+
row.clientSecretEnc ? unpack(row.clientSecretEnc) : undefined,
|
|
707
|
+
);
|
|
708
|
+
if (!ok || typeof json.access_token !== "string") {
|
|
709
|
+
throw new Error(`token refresh failed (status ${status})`);
|
|
710
|
+
}
|
|
711
|
+
const next: OauthSecret = {
|
|
712
|
+
accessToken: json.access_token,
|
|
713
|
+
refreshToken:
|
|
714
|
+
typeof json.refresh_token === "string"
|
|
715
|
+
? json.refresh_token
|
|
716
|
+
: secret.refreshToken,
|
|
717
|
+
expiresAt:
|
|
718
|
+
typeof json.expires_in === "number"
|
|
719
|
+
? Date.now() + json.expires_in * 1000
|
|
720
|
+
: undefined,
|
|
721
|
+
};
|
|
722
|
+
getDb()
|
|
723
|
+
.update(schema.mcpConnections)
|
|
724
|
+
.set({ secretEnc: pack(JSON.stringify(next)), updatedAt: Date.now() })
|
|
725
|
+
.where(eq(schema.mcpConnections.id, row.id))
|
|
726
|
+
.run();
|
|
727
|
+
mirrorMcpConnectionToStore(row.id);
|
|
728
|
+
return next;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ---------------------------------------------------------------------------
|
|
732
|
+
// Credential store mirror (ADR-134 slice 2):
|
|
733
|
+
// `<prefix>/users/<userId>/mcp/<connectionId>`. The connection id is the
|
|
734
|
+
// cloud's, which is org-bound there; on the host the owner is the user.
|
|
735
|
+
// Owned hosts: no-ops.
|
|
736
|
+
// ---------------------------------------------------------------------------
|
|
737
|
+
|
|
738
|
+
export interface StoredMcpConnection {
|
|
739
|
+
v: 1;
|
|
740
|
+
kind: "mcp";
|
|
741
|
+
id: string;
|
|
742
|
+
userId: string;
|
|
743
|
+
url: string;
|
|
744
|
+
authKind: string;
|
|
745
|
+
tokenEndpoint: string | null;
|
|
746
|
+
redirectUri: string | null;
|
|
747
|
+
clientId: string | null;
|
|
748
|
+
clientSecret: string | null;
|
|
749
|
+
pkceVerifier: string | null;
|
|
750
|
+
/** The sealed JSON, decrypted: header or OAuth tokens. */
|
|
751
|
+
secret: string | null;
|
|
752
|
+
status: string;
|
|
753
|
+
scopes: string | null;
|
|
754
|
+
updatedAt: number;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
export function mcpConnectionParameterName(userId: string, id: string): string {
|
|
758
|
+
return userParameterName(userId, "mcp", id);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** The row as it is mirrored (secrets decrypted). Never logged. */
|
|
762
|
+
export function mcpConnectionStoreValue(id: string): StoredMcpConnection | null {
|
|
763
|
+
const row = getDb()
|
|
764
|
+
.select()
|
|
765
|
+
.from(schema.mcpConnections)
|
|
766
|
+
.where(eq(schema.mcpConnections.id, id))
|
|
767
|
+
.get();
|
|
768
|
+
if (!row) return null;
|
|
769
|
+
return {
|
|
770
|
+
v: 1,
|
|
771
|
+
kind: "mcp",
|
|
772
|
+
id: row.id,
|
|
773
|
+
userId: row.userId,
|
|
774
|
+
url: row.url,
|
|
775
|
+
authKind: row.authKind,
|
|
776
|
+
tokenEndpoint: row.tokenEndpoint,
|
|
777
|
+
redirectUri: row.redirectUri,
|
|
778
|
+
clientId: row.clientId,
|
|
779
|
+
clientSecret: row.clientSecretEnc ? unpack(row.clientSecretEnc) : null,
|
|
780
|
+
pkceVerifier: row.pkceVerifierEnc ? unpack(row.pkceVerifierEnc) : null,
|
|
781
|
+
secret: row.secretEnc ? unpack(row.secretEnc) : null,
|
|
782
|
+
status: row.status,
|
|
783
|
+
scopes: row.scopes,
|
|
784
|
+
updatedAt: row.updatedAt,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
export function setMcpConnectionStoreRef(id: string, storeRef: string): void {
|
|
789
|
+
getDb()
|
|
790
|
+
.update(schema.mcpConnections)
|
|
791
|
+
.set({ storeRef })
|
|
792
|
+
.where(eq(schema.mcpConnections.id, id))
|
|
793
|
+
.run();
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
export function listMcpConnectionStoreRows(): Array<{
|
|
797
|
+
id: string;
|
|
798
|
+
userId: string;
|
|
799
|
+
storeRef: string | null;
|
|
800
|
+
}> {
|
|
801
|
+
return getDb()
|
|
802
|
+
.select({
|
|
803
|
+
id: schema.mcpConnections.id,
|
|
804
|
+
userId: schema.mcpConnections.userId,
|
|
805
|
+
storeRef: schema.mcpConnections.storeRef,
|
|
806
|
+
})
|
|
807
|
+
.from(schema.mcpConnections)
|
|
808
|
+
.all();
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
export function mirrorMcpConnectionToStore(id: string): void {
|
|
812
|
+
// ADR-136 sign-in box: rows hold no secret; the store was written first,
|
|
813
|
+
// and a mirror of a secret-less row would overwrite the only copy.
|
|
814
|
+
if (signInBoxMode()) return;
|
|
815
|
+
queueStoreWrite(`mirror mcp connection ${id}`, async (store) => {
|
|
816
|
+
const value = mcpConnectionStoreValue(id);
|
|
817
|
+
if (!value) return;
|
|
818
|
+
const name = mcpConnectionParameterName(value.userId, id);
|
|
819
|
+
await store.put(name, JSON.stringify(value));
|
|
820
|
+
setMcpConnectionStoreRef(id, name);
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** Delete the row and its parameter (the disconnect op and reconcile). */
|
|
825
|
+
export function deleteMcpConnectionLocal(id: string): void {
|
|
826
|
+
const row = getDb()
|
|
827
|
+
.select({ userId: schema.mcpConnections.userId, storeRef: schema.mcpConnections.storeRef })
|
|
828
|
+
.from(schema.mcpConnections)
|
|
829
|
+
.where(eq(schema.mcpConnections.id, id))
|
|
830
|
+
.get();
|
|
831
|
+
getDb().delete(schema.mcpConnections).where(eq(schema.mcpConnections.id, id)).run();
|
|
832
|
+
if (!row) return;
|
|
833
|
+
const name = row.storeRef ?? mcpConnectionParameterName(row.userId, id);
|
|
834
|
+
queueStoreWrite(`delete mcp connection ${id}`, (store) => store.delete(name));
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ---------------------------------------------------------------------------
|
|
838
|
+
// ADR-136 sign-in box: connection rows here hold NO secret — the header or
|
|
839
|
+
// OAuth tokens, the client secret and the PKCE verifier live in the store (or,
|
|
840
|
+
// for a half-finished OAuth flow, in memory only). What the box lists (url,
|
|
841
|
+
// auth kind, status, scopes) also rides as tags so a replacement box needs no
|
|
842
|
+
// value read.
|
|
843
|
+
// ---------------------------------------------------------------------------
|
|
844
|
+
|
|
845
|
+
export const MCP_CONNECTION_TAGS = {
|
|
846
|
+
urlB64: "uai_url_b64",
|
|
847
|
+
authKind: "uai_auth_kind",
|
|
848
|
+
status: "uai_status",
|
|
849
|
+
scopesB64: "uai_scopes_b64",
|
|
850
|
+
updatedAt: "uai_updated_at",
|
|
851
|
+
} as const;
|
|
852
|
+
|
|
853
|
+
const boxPendingOauth = new Map<string, { verifier: string; clientSecret: string | null }>();
|
|
854
|
+
|
|
855
|
+
/** Test seam. */
|
|
856
|
+
export function pendingOauthForTesting(connectionId: string): boolean {
|
|
857
|
+
return boxPendingOauth.has(connectionId);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export type StoreHeldMcpConnectionRow = Pick<
|
|
861
|
+
StoredMcpConnection,
|
|
862
|
+
"id" | "userId" | "url" | "authKind" | "tokenEndpoint" | "redirectUri" | "clientId" | "status" | "scopes" | "updatedAt"
|
|
863
|
+
>;
|
|
864
|
+
|
|
865
|
+
export function mcpConnectionMetadataTags(value: {
|
|
866
|
+
url: string;
|
|
867
|
+
authKind: string;
|
|
868
|
+
status: string;
|
|
869
|
+
scopes: string | null;
|
|
870
|
+
updatedAt: number;
|
|
871
|
+
}): Record<string, string> {
|
|
872
|
+
return {
|
|
873
|
+
[MCP_CONNECTION_TAGS.urlB64]: Buffer.from(value.url, "utf8").toString("base64url").slice(0, 256),
|
|
874
|
+
[MCP_CONNECTION_TAGS.authKind]: value.authKind,
|
|
875
|
+
[MCP_CONNECTION_TAGS.status]: value.status,
|
|
876
|
+
[MCP_CONNECTION_TAGS.scopesB64]: value.scopes
|
|
877
|
+
? Buffer.from(value.scopes, "utf8").toString("base64url").slice(0, 256)
|
|
878
|
+
: "",
|
|
879
|
+
[MCP_CONNECTION_TAGS.updatedAt]: String(value.updatedAt),
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** Upsert a row with every secret column null. */
|
|
884
|
+
export function upsertStoreHeldMcpConnectionRow(
|
|
885
|
+
value: StoreHeldMcpConnectionRow,
|
|
886
|
+
storeRef: string | null,
|
|
887
|
+
): void {
|
|
888
|
+
const fields = {
|
|
889
|
+
userId: value.userId,
|
|
890
|
+
url: value.url,
|
|
891
|
+
authKind: value.authKind,
|
|
892
|
+
tokenEndpoint: value.tokenEndpoint,
|
|
893
|
+
redirectUri: value.redirectUri,
|
|
894
|
+
clientId: value.clientId,
|
|
895
|
+
clientSecretEnc: null,
|
|
896
|
+
pkceVerifierEnc: null,
|
|
897
|
+
secretEnc: null,
|
|
898
|
+
status: value.status,
|
|
899
|
+
scopes: value.scopes,
|
|
900
|
+
updatedAt: value.updatedAt,
|
|
901
|
+
storeRef,
|
|
902
|
+
};
|
|
903
|
+
getDb()
|
|
904
|
+
.insert(schema.mcpConnections)
|
|
905
|
+
.values({ id: value.id, ...fields })
|
|
906
|
+
.onConflictDoUpdate({ target: schema.mcpConnections.id, set: fields })
|
|
907
|
+
.run();
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** The store first, awaited (a refused put is the op's failure), then tags
|
|
911
|
+
* and a secret-less row. */
|
|
912
|
+
export async function putMcpConnectionStoreFirst(value: StoredMcpConnection): Promise<void> {
|
|
913
|
+
const store = configuredCredentialStore();
|
|
914
|
+
if (!store) throw new Error("the sign-in box has no credential store to hold the connection");
|
|
915
|
+
const name = mcpConnectionParameterName(value.userId, value.id);
|
|
916
|
+
await store.put(name, JSON.stringify(value));
|
|
917
|
+
try {
|
|
918
|
+
await store.tag(name, mcpConnectionMetadataTags(value));
|
|
919
|
+
} catch (err) {
|
|
920
|
+
console.warn(
|
|
921
|
+
`[mcp] connection ${value.id}: tagging ${name} failed (${err instanceof Error ? err.message : String(err)}) — a replacement box needs the transitional read to list it`,
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
upsertStoreHeldMcpConnectionRow(value, name);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Delete the row only (no store call). */
|
|
928
|
+
export function deleteMcpConnectionRowLocal(id: string): void {
|
|
929
|
+
boxPendingOauth.delete(id);
|
|
930
|
+
getDb().delete(schema.mcpConnections).where(eq(schema.mcpConnections.id, id)).run();
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** Reconcile import: a connection this host lacks. Existing rows win. */
|
|
934
|
+
export function importMcpConnectionFromStore(
|
|
935
|
+
value: StoredMcpConnection,
|
|
936
|
+
storeRef: string,
|
|
937
|
+
): boolean {
|
|
938
|
+
const result = getDb()
|
|
939
|
+
.insert(schema.mcpConnections)
|
|
940
|
+
.values({
|
|
941
|
+
id: value.id,
|
|
942
|
+
userId: value.userId,
|
|
943
|
+
url: value.url,
|
|
944
|
+
authKind: value.authKind,
|
|
945
|
+
tokenEndpoint: value.tokenEndpoint,
|
|
946
|
+
redirectUri: value.redirectUri,
|
|
947
|
+
clientId: value.clientId,
|
|
948
|
+
clientSecretEnc: value.clientSecret === null ? null : pack(value.clientSecret),
|
|
949
|
+
pkceVerifierEnc: value.pkceVerifier === null ? null : pack(value.pkceVerifier),
|
|
950
|
+
secretEnc: value.secret === null ? null : pack(value.secret),
|
|
951
|
+
status: value.status,
|
|
952
|
+
scopes: value.scopes,
|
|
953
|
+
updatedAt: value.updatedAt,
|
|
954
|
+
storeRef,
|
|
955
|
+
})
|
|
956
|
+
.onConflictDoNothing({ target: schema.mcpConnections.id })
|
|
957
|
+
.run();
|
|
958
|
+
return result.changes > 0;
|
|
959
|
+
}
|