@opengeni/api-router 0.30.3 → 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/dist/access-grant-rls.d.ts +3 -0
- package/dist/admission-trace.d.ts +8 -0
- package/dist/api-websocket.d.ts +18 -0
- package/dist/app.d.ts +50 -2
- package/dist/app.js +12 -1
- package/dist/artifact-catalog-cursor.d.ts +27 -0
- package/dist/auth/managed-auth-attempt-context.d.ts +10 -0
- package/dist/auth/managed-auth-rate-limits.d.ts +167 -0
- package/dist/auth/managed-auth-session-adapter.d.ts +6 -0
- package/dist/auth/managed-auth.d.ts +102 -3
- package/dist/auth/managed-email.d.ts +29 -0
- package/dist/auth/managed-sign-in-notifications.d.ts +4 -0
- package/dist/auth/new-signups-gate.d.ts +23 -0
- package/dist/auth/organization-user-setup.d.ts +80 -0
- package/dist/auth/signup-funnel-metrics.d.ts +44 -0
- package/dist/browser-controller-authority.d.ts +1 -61
- package/dist/browser-ephemeral.d.ts +5 -0
- package/dist/browser-state-authority.d.ts +1 -27
- package/dist/chunk-6TZ5LG3O.js +123948 -0
- package/dist/chunk-6TZ5LG3O.js.map +1 -0
- package/dist/chunk-OGQDXVXY.js +18 -0
- package/dist/chunk-OGQDXVXY.js.map +1 -0
- package/dist/claude-subscription-account-conflicts.d.ts +1 -0
- package/dist/claude-subscription-account-usage.d.ts +39 -0
- package/dist/claude-subscription-oauth.d.ts +25 -0
- package/dist/claude-subscription-profile.d.ts +9 -0
- package/dist/claude-subscription-usage.d.ts +44 -0
- package/dist/claude-workspace-connection.d.ts +15 -0
- package/dist/codemode.d.ts +32 -1
- package/dist/company-brain-okf.d.ts +24 -0
- package/dist/connected-machine-computer-access.d.ts +11 -0
- package/dist/connection-ownership.d.ts +54 -0
- package/dist/controller-data-plane.d.ts +32 -0
- package/dist/editable-artifact-source-session.d.ts +3 -0
- package/dist/editable-artifact-websocket.d.ts +10 -18
- package/dist/editable-artifact-workspace-files.d.ts +1 -1
- package/dist/fatal-process-boundary.d.ts +25 -0
- package/dist/github-access.d.ts +31 -2
- package/dist/http/acting-person.d.ts +24 -0
- package/dist/http/api-contract-compat.d.ts +19 -0
- package/dist/http/api-error.d.ts +27 -1
- package/dist/http/cors.d.ts +17 -0
- package/dist/http/deprecation.d.ts +42 -0
- package/dist/http/interaction-control-error.d.ts +13 -0
- package/dist/http/keyed-admission.d.ts +12 -0
- package/dist/http/local-browser-boundary.d.ts +72 -0
- package/dist/http/metrics-listener.d.ts +22 -0
- package/dist/http/path-identifier.d.ts +17 -0
- package/dist/http/registered-route-label.d.ts +16 -0
- package/dist/http/request-body.d.ts +27 -0
- package/dist/http/request-source.d.ts +52 -0
- package/dist/http/sse.d.ts +17 -6
- package/dist/http/unmatched-route.d.ts +18 -0
- package/dist/http/user-content.d.ts +36 -0
- package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
- package/dist/index.d.ts +10 -3
- package/dist/index.js +1091 -162
- package/dist/index.js.map +1 -1
- package/dist/integration-endpoint-test.d.ts +20 -0
- package/dist/integrations/api-integrations.d.ts +2 -2
- package/dist/integrations/atlassian.d.ts +29 -3
- package/dist/integrations/connect-authority.d.ts +1 -0
- package/dist/integrations/connect-callback-return.d.ts +6 -0
- package/dist/integrations/fiken.d.ts +16 -2
- package/dist/integrations/github-app-connect.d.ts +47 -0
- package/dist/integrations/github-installation-proof.d.ts +3 -0
- package/dist/integrations/github-lens-connect.d.ts +116 -0
- package/dist/integrations/github-skill-source.d.ts +1 -5
- package/dist/integrations/google-drive.d.ts +24 -0
- package/dist/integrations/integration-commit-authority.d.ts +13 -0
- package/dist/integrations/marketplace-plugin.d.ts +2691 -0
- package/dist/integrations/oauth-client.d.ts +178 -18
- package/dist/integrations/oauth-profiles.d.ts +222 -0
- package/dist/integrations/oauth-return-path.d.ts +1 -0
- package/dist/integrations/personal-github-repositories.d.ts +84 -0
- package/dist/integrations/personal-github.d.ts +42 -0
- package/dist/integrations/pr-review-provider.d.ts +22 -0
- package/dist/integrations/provider-oauth.d.ts +19 -1
- package/dist/integrations/slack-app-home.d.ts +24 -0
- package/dist/integrations/slack-bot.d.ts +367 -12
- package/dist/integrations/slack-file-upload-flow.d.ts +62 -0
- package/dist/integrations/slack-install.d.ts +20 -0
- package/dist/integrations/slack-interactions.d.ts +107 -9
- package/dist/integrations/slack-mrkdwn.d.ts +41 -0
- package/dist/integrations/slack-routing.d.ts +120 -0
- package/dist/integrations/slack-session-defaults.d.ts +58 -0
- package/dist/integrations/slack-task-file-upload.d.ts +36 -0
- package/dist/integrations/social-oauth.d.ts +7 -0
- package/dist/interaction-agent-access.d.ts +15 -0
- package/dist/interaction-frame-proxy.d.ts +68 -0
- package/dist/interaction-metrics.d.ts +2 -0
- package/dist/mcp/action-catalog-types.d.ts +9 -0
- package/dist/mcp/action-catalog.gen.d.ts +2 -0
- package/dist/mcp/capability-account-readiness.d.ts +7 -0
- package/dist/mcp/child-read-evidence.d.ts +9 -0
- package/dist/mcp/command-read.d.ts +24 -0
- package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
- package/dist/mcp/contract-input.d.ts +11 -0
- package/dist/mcp/documents.d.ts +7 -5
- package/dist/mcp/files.d.ts +2 -2
- package/dist/mcp/first-party-tool-permissions.d.ts +650 -0
- package/dist/mcp/knowledge-entries.d.ts +5 -0
- package/dist/mcp/orchestration-failure-diagnostic.d.ts +43 -0
- package/dist/mcp/receipts.d.ts +9 -0
- package/dist/mcp/request-abort.d.ts +19 -0
- package/dist/mcp/scheduled-task-input.d.ts +1189 -0
- package/dist/mcp/scheduled-task-view.d.ts +22 -4
- package/dist/mcp/server.d.ts +255 -3
- package/dist/mcp/session-event-view.d.ts +94 -0
- package/dist/mcp/session-view.d.ts +66 -2
- package/dist/mcp/session-wait.d.ts +174 -0
- package/dist/mcp-oauth.d.ts +52 -0
- package/dist/model-catalog.d.ts +11 -31
- package/dist/observability.d.ts +3 -1
- package/dist/organization-mcp.d.ts +52 -0
- package/dist/organization-recovery-notifications.d.ts +41 -0
- package/dist/routes/analytics-consent.d.ts +26 -0
- package/dist/routes/api-integrations.d.ts +25 -0
- package/dist/routes/api-keys.d.ts +17 -0
- package/dist/routes/artifact-catalog.d.ts +6 -0
- package/dist/routes/automations.d.ts +19 -0
- package/dist/routes/billing.d.ts +49 -0
- package/dist/routes/browser-sessions.d.ts +59 -2
- package/dist/routes/claude-subscription-accounts.d.ts +3 -0
- package/dist/routes/claude-subscription-oauth.d.ts +3 -0
- package/dist/routes/client-errors.d.ts +146 -0
- package/dist/routes/codex.d.ts +76 -2
- package/dist/routes/company-brain.d.ts +3 -0
- package/dist/routes/company-profile.d.ts +2 -1
- package/dist/routes/computer-sessions.d.ts +14 -0
- package/dist/routes/connect.d.ts +5 -0
- package/dist/routes/editable-artifacts.d.ts +2 -0
- package/dist/routes/external-identity-links.d.ts +6 -0
- package/dist/routes/feedback.d.ts +3 -0
- package/dist/routes/github-browser-pages.d.ts +12 -0
- package/dist/routes/github.d.ts +2 -0
- package/dist/routes/insights-response-cache.d.ts +10 -0
- package/dist/routes/insights-usage.d.ts +12 -0
- package/dist/routes/insights.d.ts +30 -1
- package/dist/routes/knowledge.d.ts +3 -0
- package/dist/routes/managed-auth-session-sets.d.ts +26 -0
- package/dist/routes/managed-onboarding.d.ts +29 -0
- package/dist/routes/managed-sign-in-methods.d.ts +6 -0
- package/dist/routes/model-connection-access.d.ts +3 -0
- package/dist/routes/organization-integration-policy.d.ts +3 -0
- package/dist/routes/organization-integrations.d.ts +6 -0
- package/dist/routes/organization-mcp-connections.d.ts +3 -0
- package/dist/routes/organization-model-providers.d.ts +3 -0
- package/dist/routes/organization-recovery.d.ts +19 -0
- package/dist/routes/organization-service-accounts.d.ts +3 -0
- package/dist/routes/organization-sessions.d.ts +26 -0
- package/dist/routes/personal-github-git-broker.d.ts +29 -0
- package/dist/routes/personal-github.d.ts +3 -0
- package/dist/routes/pr-review-github.d.ts +3 -0
- package/dist/routes/pr-review.d.ts +3 -0
- package/dist/routes/session-artifact-associations.d.ts +4 -0
- package/dist/routes/session-history-imports.d.ts +9 -0
- package/dist/routes/sessions.d.ts +44 -6
- package/dist/routes/skill-content.d.ts +4 -0
- package/dist/routes/skill-install-authority.d.ts +6 -0
- package/dist/routes/subscription-account-pools.d.ts +34 -0
- package/dist/routes/subscription-pool-access.d.ts +16 -0
- package/dist/routes/supergrok.d.ts +6 -1
- package/dist/routes/usage-allowances.d.ts +12 -0
- package/dist/routes/user-resource-authorities.d.ts +3 -0
- package/dist/routes/workspace-artifacts.d.ts +2 -1
- package/dist/routes/workspace-capture.d.ts +10 -2
- package/dist/routes/workspace-integrations.d.ts +35 -0
- package/dist/routes/workspace-learning.d.ts +3 -0
- package/dist/routes/workspace-model-providers.d.ts +4 -0
- package/dist/routes/workspaces.d.ts +29 -0
- package/dist/sandbox/auth-callout.d.ts +8 -0
- package/dist/sandbox/channel-a.d.ts +19 -1
- package/dist/sandbox/connection-authority.d.ts +3 -0
- package/dist/sandbox/enrollment.d.ts +9 -1
- package/dist/sandbox/machines.d.ts +5 -2
- package/dist/sandbox/metrics-ingestion.d.ts +28 -20
- package/dist/sandbox/rematerialize.d.ts +2 -0
- package/dist/sandbox/session-attach-variable-sets.d.ts +30 -0
- package/dist/sandbox/viewer.d.ts +26 -1
- package/dist/sandbox-file-artifacts.d.ts +50 -0
- package/dist/scheduled-task-deletion.d.ts +13 -0
- package/dist/session-create-observability.d.ts +5 -0
- package/dist/session-event-view-MPS2OZD3.js +292 -0
- package/dist/session-event-view-MPS2OZD3.js.map +1 -0
- package/dist/site-session-origin.d.ts +13 -0
- package/dist/site-uploads.d.ts +41 -0
- package/dist/slack-reaction-files.d.ts +1 -1
- package/dist/temporal-schedule-cleanup.d.ts +1 -0
- package/dist/temporal-schedule-sync.d.ts +26 -0
- package/dist/transcription/providers/xai-subscription.d.ts +1 -0
- package/dist/transcription/service.d.ts +2 -1
- package/dist/work-discovery-observability.d.ts +33 -0
- package/dist/workspace-artifact-content.d.ts +28 -0
- package/dist/workspace-artifact-provenance.d.ts +7 -0
- package/dist/workspace-delete-observability.d.ts +10 -0
- package/dist/workspace-deletion.d.ts +6 -0
- package/dist/workspace-state-projection.d.ts +3 -5
- package/dist/workspace-tool-gateway-observability.d.ts +17 -0
- package/dist/workspace-tool-gateway.d.ts +123 -0
- package/dist/workspace-webhook-dispatch.d.ts +26 -0
- package/package.json +23 -19
- package/src/access-grant-rls.ts +75 -0
- package/src/admission-trace.ts +14 -0
- package/src/api-websocket.ts +23 -0
- package/src/app.ts +1967 -252
- package/src/artifact-catalog-cursor.ts +60 -0
- package/src/auth/managed-auth-attempt-context.ts +70 -0
- package/src/auth/managed-auth-rate-limits.ts +252 -0
- package/src/auth/managed-auth-session-adapter.ts +222 -0
- package/src/auth/managed-auth.ts +536 -60
- package/src/auth/managed-email.ts +174 -0
- package/src/auth/managed-sign-in-notifications.ts +81 -0
- package/src/auth/new-signups-gate.ts +49 -0
- package/src/auth/organization-user-setup.ts +307 -0
- package/src/auth/signup-funnel-metrics.ts +164 -0
- package/src/browser-controller-authority.ts +1 -186
- package/src/browser-ephemeral.ts +39 -0
- package/src/browser-state-authority.ts +1 -236
- package/src/claude-subscription-account-conflicts.ts +12 -0
- package/src/claude-subscription-account-usage.ts +48 -0
- package/src/claude-subscription-oauth.ts +362 -0
- package/src/claude-subscription-profile.ts +73 -0
- package/src/claude-subscription-usage.ts +97 -0
- package/src/claude-workspace-connection.ts +82 -0
- package/src/codemode.ts +194 -30
- package/src/codex-realtime.ts +8 -2
- package/src/company-brain-okf.ts +340 -0
- package/src/connected-machine-computer-access.ts +33 -0
- package/src/connection-ownership.ts +199 -0
- package/src/controller-data-plane.ts +81 -0
- package/src/editable-artifact-native-kernel.ts +26 -15
- package/src/editable-artifact-office-import.ts +28 -3
- package/src/editable-artifact-production.ts +23 -7
- package/src/editable-artifact-source-session.ts +123 -0
- package/src/editable-artifact-websocket.ts +27 -21
- package/src/editable-artifact-workspace-files.ts +31 -6
- package/src/fatal-process-boundary.ts +246 -0
- package/src/gateway-realtime.ts +1 -1
- package/src/github-access.ts +147 -5
- package/src/http/acting-person.ts +71 -0
- package/src/http/api-contract-compat.ts +49 -0
- package/src/http/api-error.ts +115 -1
- package/src/http/auth.ts +48 -0
- package/src/http/cors.ts +67 -0
- package/src/http/deprecation.ts +118 -0
- package/src/http/interaction-control-error.ts +164 -0
- package/src/http/keyed-admission.ts +29 -0
- package/src/http/local-browser-boundary.ts +343 -0
- package/src/http/metrics-listener.ts +57 -0
- package/src/http/path-identifier.ts +54 -0
- package/src/http/registered-route-label.ts +34 -0
- package/src/http/request-body.ts +127 -0
- package/src/http/request-source.ts +178 -0
- package/src/http/sse.ts +486 -132
- package/src/http/unmatched-route.ts +60 -0
- package/src/http/user-content.ts +158 -0
- package/src/http/websocket-upgrade-dispatch.ts +29 -0
- package/src/index.ts +157 -52
- package/src/integration-endpoint-test.ts +291 -0
- package/src/integrations/api-integrations.ts +98 -11
- package/src/integrations/atlassian.ts +474 -136
- package/src/integrations/connect-authority.ts +2 -0
- package/src/integrations/connect-callback-return.ts +86 -0
- package/src/integrations/fiken.ts +354 -64
- package/src/integrations/github-app-connect.ts +435 -0
- package/src/integrations/github-installation-proof.ts +60 -0
- package/src/integrations/github-lens-connect.ts +76 -0
- package/src/integrations/github-skill-source.ts +2 -142
- package/src/integrations/google-drive.ts +717 -225
- package/src/integrations/integration-commit-authority.ts +69 -0
- package/src/integrations/marketplace-plugin.ts +48 -0
- package/src/integrations/oauth-client.ts +1356 -459
- package/src/integrations/oauth-profiles.ts +537 -0
- package/src/integrations/oauth-return-path.ts +18 -0
- package/src/integrations/personal-github-repositories.ts +674 -0
- package/src/integrations/personal-github.ts +825 -0
- package/src/integrations/pr-review-provider.ts +246 -0
- package/src/integrations/provider-oauth.ts +250 -22
- package/src/integrations/slack-app-home.ts +300 -0
- package/src/integrations/slack-bot.ts +963 -239
- package/src/integrations/slack-file-upload-flow.ts +147 -0
- package/src/integrations/slack-install.ts +83 -0
- package/src/integrations/slack-interactions.ts +3167 -474
- package/src/integrations/slack-mrkdwn.ts +193 -0
- package/src/integrations/slack-routing.ts +337 -0
- package/src/integrations/slack-session-defaults.ts +224 -0
- package/src/integrations/slack-task-file-upload.ts +323 -0
- package/src/integrations/social-oauth.ts +213 -15
- package/src/interaction-agent-access.ts +48 -0
- package/src/interaction-frame-proxy.ts +409 -0
- package/src/interaction-metrics.ts +30 -0
- package/src/mcp/action-catalog-types.ts +9 -0
- package/src/mcp/action-catalog.gen.ts +6696 -0
- package/src/mcp/capability-account-readiness.ts +17 -0
- package/src/mcp/child-read-evidence.ts +19 -0
- package/src/mcp/command-read.ts +109 -0
- package/src/mcp/company-profile-agent-admin.ts +198 -0
- package/src/mcp/contract-input.ts +141 -0
- package/src/mcp/documents.ts +65 -377
- package/src/mcp/editable-artifacts.ts +12 -6
- package/src/mcp/files.ts +80 -43
- package/src/mcp/first-party-tool-permissions.ts +305 -0
- package/src/mcp/knowledge-entries.ts +322 -0
- package/src/mcp/orchestration-failure-diagnostic.ts +43 -0
- package/src/mcp/receipts.ts +34 -0
- package/src/mcp/request-abort.ts +44 -0
- package/src/mcp/scheduled-task-input.ts +76 -0
- package/src/mcp/scheduled-task-view.ts +44 -3
- package/src/mcp/server.ts +3030 -1267
- package/src/mcp/session-event-view.ts +383 -0
- package/src/mcp/session-view.ts +84 -3
- package/src/mcp/session-wait.ts +665 -0
- package/src/mcp-oauth.ts +1123 -0
- package/src/model-catalog.ts +91 -333
- package/src/observability.ts +15 -1
- package/src/organization-mcp.ts +397 -0
- package/src/organization-recovery-notifications.ts +103 -0
- package/src/routes/analytics-consent.ts +123 -0
- package/src/routes/api-integrations.ts +364 -74
- package/src/routes/api-keys.ts +336 -10
- package/src/routes/artifact-catalog.ts +279 -0
- package/src/routes/automations.ts +768 -0
- package/src/routes/billing.ts +461 -46
- package/src/routes/browser-sessions.ts +780 -112
- package/src/routes/canonical-human-identities.ts +90 -14
- package/src/routes/capabilities.ts +107 -6
- package/src/routes/channels.ts +20 -3
- package/src/routes/claude-subscription-accounts.ts +334 -0
- package/src/routes/claude-subscription-oauth.ts +142 -0
- package/src/routes/client-errors.ts +362 -0
- package/src/routes/codex.ts +833 -116
- package/src/routes/company-brain.ts +322 -0
- package/src/routes/company-profile.ts +65 -1
- package/src/routes/computer-sessions.ts +557 -106
- package/src/routes/connect.ts +1938 -0
- package/src/routes/connections.ts +1039 -349
- package/src/routes/documents.ts +488 -287
- package/src/routes/editable-artifacts.ts +79 -9
- package/src/routes/enrollments.ts +135 -39
- package/src/routes/environments.ts +154 -58
- package/src/routes/external-identity-links.ts +201 -0
- package/src/routes/feedback.ts +113 -0
- package/src/routes/files.ts +267 -33
- package/src/routes/github-browser-pages.ts +162 -0
- package/src/routes/github.ts +446 -144
- package/src/routes/insights-response-cache.ts +83 -0
- package/src/routes/insights-usage.ts +345 -0
- package/src/routes/insights.ts +153 -19
- package/src/routes/install.ts +133 -38
- package/src/routes/integration-facets.ts +107 -33
- package/src/routes/interaction-resources.ts +30 -3
- package/src/routes/knowledge.ts +488 -0
- package/src/routes/machines.ts +313 -15
- package/src/routes/managed-auth-session-sets.ts +1218 -0
- package/src/routes/managed-onboarding.ts +337 -0
- package/src/routes/managed-sign-in-methods.ts +433 -0
- package/src/routes/memory-slack-publications.ts +3 -2
- package/src/routes/model-connection-access.ts +208 -0
- package/src/routes/organization-integration-policy.ts +116 -0
- package/src/routes/organization-integrations.ts +326 -0
- package/src/routes/organization-mcp-connections.ts +178 -0
- package/src/routes/organization-memberships.ts +1019 -27
- package/src/routes/organization-model-providers.ts +293 -0
- package/src/routes/organization-recovery.ts +325 -0
- package/src/routes/organization-service-accounts.ts +128 -0
- package/src/routes/organization-sessions.ts +350 -0
- package/src/routes/personal-github-git-broker.ts +785 -0
- package/src/routes/personal-github.ts +370 -0
- package/src/routes/plugins.ts +251 -22
- package/src/routes/pr-review-github.ts +881 -0
- package/src/routes/pr-review.ts +622 -0
- package/src/routes/preference-registry.ts +12 -93
- package/src/routes/rigs.ts +167 -50
- package/src/routes/scheduled-tasks.ts +346 -41
- package/src/routes/session-artifact-associations.ts +87 -0
- package/src/routes/session-history-imports.ts +280 -0
- package/src/routes/sessions.ts +2306 -264
- package/src/routes/skill-content.ts +297 -0
- package/src/routes/skill-install-authority.ts +64 -0
- package/src/routes/skills.ts +62 -9
- package/src/routes/social.ts +46 -18
- package/src/routes/subscription-account-pools.ts +436 -0
- package/src/routes/subscription-pool-access.ts +126 -0
- package/src/routes/supergrok.ts +219 -303
- package/src/routes/transcription-recordings.ts +38 -3
- package/src/routes/transcriptions.ts +4 -0
- package/src/routes/usage-allowances.ts +378 -0
- package/src/routes/user-resource-authorities.ts +138 -0
- package/src/routes/video-generation.ts +14 -10
- package/src/routes/workspace-artifacts.ts +275 -69
- package/src/routes/workspace-capture.ts +21 -0
- package/src/routes/workspace-integrations.ts +576 -0
- package/src/routes/workspace-learning.ts +159 -0
- package/src/routes/workspace-model-providers.ts +148 -0
- package/src/routes/workspace-state.ts +38 -25
- package/src/routes/workspaces.ts +1004 -127
- package/src/sandbox/auth-callout.ts +96 -19
- package/src/sandbox/channel-a.ts +323 -24
- package/src/sandbox/connection-authority.ts +3 -0
- package/src/sandbox/enrollment.ts +81 -6
- package/src/sandbox/machines.ts +218 -76
- package/src/sandbox/metrics-ingestion.ts +411 -62
- package/src/sandbox/rematerialize.ts +121 -19
- package/src/sandbox/session-attach-variable-sets.ts +131 -0
- package/src/sandbox/viewer.ts +280 -25
- package/src/sandbox-file-artifacts.ts +413 -0
- package/src/scheduled-task-deletion.ts +138 -0
- package/src/session-create-observability.ts +46 -0
- package/src/session-realtime-context.ts +6 -3
- package/src/site-session-origin.ts +48 -0
- package/src/site-uploads.ts +224 -0
- package/src/slack-reaction-files.ts +16 -5
- package/src/temporal-schedule-cleanup.ts +13 -0
- package/src/temporal-schedule-sync.ts +74 -0
- package/src/transcription/providers/codex-subscription.ts +2 -0
- package/src/transcription/providers/openai.ts +1 -0
- package/src/transcription/providers/xai-subscription.ts +49 -3
- package/src/transcription/segmenter.ts +8 -2
- package/src/transcription/service.ts +43 -6
- package/src/work-discovery-observability.ts +121 -0
- package/src/workspace-artifact-content.ts +144 -0
- package/src/workspace-artifact-provenance.ts +104 -0
- package/src/workspace-delete-observability.ts +75 -0
- package/src/workspace-deletion.ts +73 -0
- package/src/workspace-state-projection.ts +18 -148
- package/src/workspace-tool-gateway-observability.ts +100 -0
- package/src/workspace-tool-gateway.ts +870 -0
- package/src/workspace-webhook-dispatch.ts +190 -0
- package/src/xai-subscription-auth.ts +9 -2
- package/dist/chunk-WCXHI3FF.js +0 -49920
- package/dist/chunk-WCXHI3FF.js.map +0 -1
- package/dist/routes/packs.d.ts +0 -3
- package/src/routes/packs.ts +0 -729
package/src/mcp-oauth.ts
ADDED
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
MCP_OAUTH_ACCESS_TOKEN_TTL_SECONDS,
|
|
4
|
+
MCP_OAUTH_AUTHORIZATION_CODE_TTL_SECONDS,
|
|
5
|
+
MCP_OAUTH_CONSENT_TTL_SECONDS,
|
|
6
|
+
MCP_OAUTH_REFRESH_TOKEN_TTL_SECONDS,
|
|
7
|
+
MCP_OAUTH_SCOPE,
|
|
8
|
+
McpOAuthAuthorizationServerMetadata,
|
|
9
|
+
McpOAuthClientRegistrationRequest,
|
|
10
|
+
McpOAuthClientRegistrationResponse,
|
|
11
|
+
McpOAuthProtectedResourceMetadata,
|
|
12
|
+
McpOAuthTokenResponse,
|
|
13
|
+
OrganizationAccessPolicy,
|
|
14
|
+
organizationAccessPresetPermissions,
|
|
15
|
+
type AccessContext,
|
|
16
|
+
type AccessGrant,
|
|
17
|
+
type ToolGatewayCatalog,
|
|
18
|
+
type ToolGatewayIdentity,
|
|
19
|
+
} from "@opengeni/contracts";
|
|
20
|
+
import {
|
|
21
|
+
consumeMcpOAuthAuthorizationRequest,
|
|
22
|
+
createMcpOAuthAuthorizationRequest,
|
|
23
|
+
deleteMcpOAuthAuthorizationRequest,
|
|
24
|
+
exchangeMcpOAuthAuthorizationCode,
|
|
25
|
+
getManagedAccount,
|
|
26
|
+
getMcpOAuthAuthorizationRequest,
|
|
27
|
+
getMcpOAuthClient,
|
|
28
|
+
getWorkspace,
|
|
29
|
+
listSharedWorkspacesForAccount,
|
|
30
|
+
listWorkspacesForSubject,
|
|
31
|
+
McpOAuthClientRegistrationRateLimitError,
|
|
32
|
+
rebindMcpOAuthAuthorizationRequest,
|
|
33
|
+
registerMcpOAuthClient,
|
|
34
|
+
resolveLiveMcpOAuthGrant,
|
|
35
|
+
resolveMcpOAuthAccessToken,
|
|
36
|
+
rotateMcpOAuthRefreshToken,
|
|
37
|
+
setMcpOAuthRequestOrganizationAccess,
|
|
38
|
+
type McpOAuthAccess,
|
|
39
|
+
} from "@opengeni/db";
|
|
40
|
+
import {
|
|
41
|
+
hasPermission,
|
|
42
|
+
requireAccessContext,
|
|
43
|
+
requireAccessGrantAuthorization,
|
|
44
|
+
type ApiRouteDeps,
|
|
45
|
+
} from "@opengeni/core";
|
|
46
|
+
import { Hono, type Context } from "hono";
|
|
47
|
+
import { HTTPException } from "hono/http-exception";
|
|
48
|
+
import { z } from "zod";
|
|
49
|
+
import { trustedRequestSourceRateLimitKey, type RequestSourceTrust } from "./http/request-source";
|
|
50
|
+
import { requireNotAgent } from "./http/acting-person";
|
|
51
|
+
import { requireSameOriginBrowserMutation } from "./routes/codex";
|
|
52
|
+
import {
|
|
53
|
+
prepareWorkspaceToolGateway,
|
|
54
|
+
requireWorkspaceToolGatewayAuthorization,
|
|
55
|
+
} from "./workspace-tool-gateway";
|
|
56
|
+
|
|
57
|
+
const CLIENT_PREFIX = "ogmcp_client_";
|
|
58
|
+
const REQUEST_PREFIX = "ogmcp_req_";
|
|
59
|
+
const CODE_PREFIX = "ogmcp_code_";
|
|
60
|
+
const ACCESS_PREFIX = "ogmcp_at_";
|
|
61
|
+
const REFRESH_PREFIX = "ogmcp_rt_";
|
|
62
|
+
const PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/u;
|
|
63
|
+
const PKCE_CHALLENGE = /^[A-Za-z0-9_-]{43}$/u;
|
|
64
|
+
const WORKSPACE_MCP_PATH = /^\/v1\/workspaces\/([0-9a-f-]{36})\/mcp(?:\/(docs|files))?$/u;
|
|
65
|
+
|
|
66
|
+
/** One organization MCP server for everyone; the sign-in chooses the organization. */
|
|
67
|
+
export const ORGANIZATION_MCP_PATH = "/v1/mcp";
|
|
68
|
+
|
|
69
|
+
export type McpOAuthResource = {
|
|
70
|
+
resource: string;
|
|
71
|
+
/** Null for the organization server. */
|
|
72
|
+
workspaceId: string | null;
|
|
73
|
+
kind: "all" | "docs" | "files" | "organization";
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type McpOAuthRouteAccess = {
|
|
77
|
+
grant: AccessGrant;
|
|
78
|
+
allowedToolIdentities: ToolGatewayIdentity[];
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export function registerMcpOAuthRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
82
|
+
app.get("/.well-known/oauth-authorization-server", (c) => {
|
|
83
|
+
requireMcpOAuthEnabled(deps);
|
|
84
|
+
const issuer = mcpOAuthIssuer(deps);
|
|
85
|
+
c.header("cache-control", "public, max-age=300");
|
|
86
|
+
return c.json(
|
|
87
|
+
McpOAuthAuthorizationServerMetadata.parse({
|
|
88
|
+
issuer,
|
|
89
|
+
authorization_endpoint: `${issuer}/oauth/authorize`,
|
|
90
|
+
token_endpoint: `${issuer}/oauth/token`,
|
|
91
|
+
registration_endpoint: `${issuer}/oauth/register`,
|
|
92
|
+
response_types_supported: ["code"],
|
|
93
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
94
|
+
code_challenge_methods_supported: ["S256"],
|
|
95
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
96
|
+
scopes_supported: [MCP_OAUTH_SCOPE],
|
|
97
|
+
authorization_response_iss_parameter_supported: true,
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
app.get("/.well-known/oauth-protected-resource/*", (c) => {
|
|
103
|
+
requireMcpOAuthEnabled(deps);
|
|
104
|
+
const pathname = new URL(c.req.url).pathname.replace(
|
|
105
|
+
"/.well-known/oauth-protected-resource",
|
|
106
|
+
"",
|
|
107
|
+
);
|
|
108
|
+
const resource = parseMcpOAuthResource(deps, `${mcpOAuthIssuer(deps)}${pathname}`);
|
|
109
|
+
c.header("cache-control", "public, max-age=300");
|
|
110
|
+
return c.json(
|
|
111
|
+
McpOAuthProtectedResourceMetadata.parse({
|
|
112
|
+
resource: resource.resource,
|
|
113
|
+
authorization_servers: [mcpOAuthIssuer(deps)],
|
|
114
|
+
scopes_supported: [MCP_OAUTH_SCOPE],
|
|
115
|
+
bearer_methods_supported: ["header"],
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
app.post("/oauth/register", async (c) => {
|
|
121
|
+
requireMcpOAuthEnabled(deps);
|
|
122
|
+
const parsed = McpOAuthClientRegistrationRequest.safeParse(
|
|
123
|
+
await c.req.json().catch(() => null),
|
|
124
|
+
);
|
|
125
|
+
if (!parsed.success) return oauthError(c, "invalid_client_metadata", 400);
|
|
126
|
+
let redirectUris: string[];
|
|
127
|
+
try {
|
|
128
|
+
redirectUris = [...new Set(parsed.data.redirect_uris.map(validateRedirectUri))];
|
|
129
|
+
} catch {
|
|
130
|
+
return oauthError(c, "invalid_redirect_uri", 400);
|
|
131
|
+
}
|
|
132
|
+
let client;
|
|
133
|
+
try {
|
|
134
|
+
client = await registerMcpOAuthClient(deps.db, {
|
|
135
|
+
clientId: opaque(CLIENT_PREFIX),
|
|
136
|
+
redirectUris,
|
|
137
|
+
clientName: parsed.data.client_name ?? null,
|
|
138
|
+
grantTypes: parsed.data.grant_types,
|
|
139
|
+
responseTypes: ["code"],
|
|
140
|
+
registrationScopeHash: tokenHash(mcpOAuthRegistrationClientKey(c, deps.settings)),
|
|
141
|
+
});
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error instanceof McpOAuthClientRegistrationRateLimitError) {
|
|
144
|
+
c.header("retry-after", "600");
|
|
145
|
+
return oauthError(c, "temporarily_unavailable", 429);
|
|
146
|
+
}
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
c.header("cache-control", "no-store");
|
|
150
|
+
return c.json(
|
|
151
|
+
McpOAuthClientRegistrationResponse.parse({
|
|
152
|
+
client_id: client.clientId,
|
|
153
|
+
client_id_issued_at: Math.floor(client.createdAt.getTime() / 1_000),
|
|
154
|
+
redirect_uris: client.redirectUris,
|
|
155
|
+
...(client.clientName ? { client_name: client.clientName } : {}),
|
|
156
|
+
...(parsed.data.application_type ? { application_type: parsed.data.application_type } : {}),
|
|
157
|
+
...(parsed.data.scope ? { scope: parsed.data.scope } : {}),
|
|
158
|
+
token_endpoint_auth_method: "none",
|
|
159
|
+
grant_types: client.grantTypes,
|
|
160
|
+
response_types: client.responseTypes,
|
|
161
|
+
}),
|
|
162
|
+
201,
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
app.get("/oauth/authorize", async (c) => {
|
|
167
|
+
requireMcpOAuthEnabled(deps);
|
|
168
|
+
const query = new URL(c.req.url).searchParams;
|
|
169
|
+
const client = await requireAuthorizationClient(deps, query);
|
|
170
|
+
const resource = requireAuthorizationResource(deps, query);
|
|
171
|
+
if (resource.kind === "organization") {
|
|
172
|
+
// The person picks the organization, what the agent can do and where on
|
|
173
|
+
// the web app's sign-in page, which reads and answers this request.
|
|
174
|
+
const web = new URL(deps.settings.webBaseUrl ?? mcpOAuthIssuer(deps));
|
|
175
|
+
let person: { context: AccessContext };
|
|
176
|
+
try {
|
|
177
|
+
person = await requireBrowserPerson(c, deps);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (!(error instanceof HTTPException) || error.status !== 401) throw error;
|
|
180
|
+
// Not signed in here yet: sign in on the web app, which returns to
|
|
181
|
+
// this exact authorization request (a same-origin path only).
|
|
182
|
+
const url = new URL(c.req.url);
|
|
183
|
+
c.header("cache-control", "no-store");
|
|
184
|
+
return c.redirect(
|
|
185
|
+
`${web.origin}/connect-agent?authorize=${encodeURIComponent(`${url.pathname}${url.search}`)}`,
|
|
186
|
+
302,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const accountId =
|
|
190
|
+
person.context.defaultAccountId ?? person.context.accountGrants[0]?.accountId;
|
|
191
|
+
if (!accountId) {
|
|
192
|
+
throw new HTTPException(403, { message: "no organization is available for MCP OAuth" });
|
|
193
|
+
}
|
|
194
|
+
const requestToken = opaque(REQUEST_PREFIX);
|
|
195
|
+
await createMcpOAuthAuthorizationRequest(deps.db, {
|
|
196
|
+
requestHash: tokenHash(requestToken),
|
|
197
|
+
clientId: client.clientId,
|
|
198
|
+
accountId,
|
|
199
|
+
workspaceId: null,
|
|
200
|
+
subjectId: person.context.subjectId,
|
|
201
|
+
resource: resource.resource,
|
|
202
|
+
redirectUri: requireRedirectUri(client.redirectUris, query.get("redirect_uri")),
|
|
203
|
+
codeChallenge: requireCodeChallenge(query),
|
|
204
|
+
state: boundedState(query.get("state")),
|
|
205
|
+
permissions: [],
|
|
206
|
+
toolIdentities: [],
|
|
207
|
+
organizationAccess: DEFAULT_ORGANIZATION_ACCESS(),
|
|
208
|
+
expiresAt: expiresIn(MCP_OAUTH_CONSENT_TTL_SECONDS),
|
|
209
|
+
});
|
|
210
|
+
c.header("cache-control", "no-store");
|
|
211
|
+
return c.redirect(
|
|
212
|
+
`${web.origin}/connect-agent?request=${encodeURIComponent(requestToken)}`,
|
|
213
|
+
302,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const context = await requireAccessContext(c, deps);
|
|
217
|
+
const workspaces = await listConsentWorkspaces(deps, context);
|
|
218
|
+
if (workspaces.length === 0) {
|
|
219
|
+
throw new HTTPException(403, { message: "no workspace is available for MCP OAuth" });
|
|
220
|
+
}
|
|
221
|
+
const selectedWorkspace =
|
|
222
|
+
workspaces.find((workspace) => workspace.id === resource.workspaceId) ?? workspaces[0]!;
|
|
223
|
+
const authorization = await requireAccessGrantAuthorization(
|
|
224
|
+
c,
|
|
225
|
+
deps,
|
|
226
|
+
selectedWorkspace.id,
|
|
227
|
+
"workspace:read",
|
|
228
|
+
);
|
|
229
|
+
const grant = requireWorkspaceToolGatewayAuthorization(authorization);
|
|
230
|
+
const prepared = await prepareWorkspaceToolGateway(deps, authorization);
|
|
231
|
+
const requestToken = opaque(REQUEST_PREFIX);
|
|
232
|
+
try {
|
|
233
|
+
await createMcpOAuthAuthorizationRequest(deps.db, {
|
|
234
|
+
requestHash: tokenHash(requestToken),
|
|
235
|
+
clientId: client.clientId,
|
|
236
|
+
accountId: grant.accountId,
|
|
237
|
+
workspaceId: grant.workspaceId,
|
|
238
|
+
subjectId: grant.subjectId,
|
|
239
|
+
resource: resource.resource,
|
|
240
|
+
redirectUri: requireRedirectUri(client.redirectUris, query.get("redirect_uri")),
|
|
241
|
+
codeChallenge: requireCodeChallenge(query),
|
|
242
|
+
state: boundedState(query.get("state")),
|
|
243
|
+
permissions: grant.permissions,
|
|
244
|
+
toolIdentities: mcpOAuthConsentToolIdentities(prepared.toolGatewayCatalog),
|
|
245
|
+
organizationAccess: null,
|
|
246
|
+
expiresAt: expiresIn(MCP_OAUTH_CONSENT_TTL_SECONDS),
|
|
247
|
+
});
|
|
248
|
+
} finally {
|
|
249
|
+
await prepared.close();
|
|
250
|
+
}
|
|
251
|
+
const accounts = await consentAccountsForWorkspaces(deps, workspaces);
|
|
252
|
+
c.header(
|
|
253
|
+
"content-security-policy",
|
|
254
|
+
"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'",
|
|
255
|
+
);
|
|
256
|
+
c.header("cache-control", "no-store");
|
|
257
|
+
return c.html(
|
|
258
|
+
renderMcpOAuthConsentPage({
|
|
259
|
+
clientName: client.clientName ?? "MCP client",
|
|
260
|
+
requestToken,
|
|
261
|
+
accounts,
|
|
262
|
+
workspaces: workspaces.map((workspace) => ({
|
|
263
|
+
id: workspace.id,
|
|
264
|
+
accountId: workspace.accountId,
|
|
265
|
+
name: workspace.name,
|
|
266
|
+
kind: workspace.kind,
|
|
267
|
+
})),
|
|
268
|
+
selectedWorkspaceId: grant.workspaceId,
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
app.post("/oauth/authorize", async (c) => {
|
|
274
|
+
requireMcpOAuthEnabled(deps);
|
|
275
|
+
const form = new URLSearchParams(await c.req.text());
|
|
276
|
+
const requestToken = form.get("request") ?? "";
|
|
277
|
+
if (!requestToken.startsWith(REQUEST_PREFIX)) {
|
|
278
|
+
return oauthAuthorizeBrowserError(
|
|
279
|
+
c,
|
|
280
|
+
"This authorization request is missing or already used.",
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
const request = await getMcpOAuthAuthorizationRequest(deps.db, tokenHash(requestToken));
|
|
284
|
+
if (!request) {
|
|
285
|
+
return oauthAuthorizeBrowserError(
|
|
286
|
+
c,
|
|
287
|
+
"This authorization request expired or was already approved.",
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
// Organization sign-ins are answered on the Opengeni page they opened.
|
|
291
|
+
if (request.workspaceId === null) {
|
|
292
|
+
return oauthAuthorizeBrowserError(c, "Answer this sign-in on the Opengeni page it opened.");
|
|
293
|
+
}
|
|
294
|
+
const requestWorkspaceId = request.workspaceId;
|
|
295
|
+
if (form.get("decision") !== "approve") {
|
|
296
|
+
const authorization = await requireAccessGrantAuthorization(
|
|
297
|
+
c,
|
|
298
|
+
deps,
|
|
299
|
+
requestWorkspaceId,
|
|
300
|
+
"workspace:read",
|
|
301
|
+
);
|
|
302
|
+
if (requireWorkspaceToolGatewayAuthorization(authorization).subjectId !== request.subjectId) {
|
|
303
|
+
throw new HTTPException(403, { message: "OAuth consent authority changed" });
|
|
304
|
+
}
|
|
305
|
+
await deleteMcpOAuthAuthorizationRequest(deps.db, request.requestHash);
|
|
306
|
+
return completeAuthorizationRedirect(
|
|
307
|
+
c,
|
|
308
|
+
authorizationRedirect(request.redirectUri, {
|
|
309
|
+
error: "access_denied",
|
|
310
|
+
iss: mcpOAuthIssuer(deps),
|
|
311
|
+
...(request.state ? { state: request.state } : {}),
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
const selectedWorkspaceId = form.get("workspace_id") || requestWorkspaceId;
|
|
316
|
+
if (!isWorkspaceId(selectedWorkspaceId)) {
|
|
317
|
+
return oauthAuthorizeBrowserError(c, "Choose a workspace before authorizing this client.");
|
|
318
|
+
}
|
|
319
|
+
const authorization = await requireAccessGrantAuthorization(
|
|
320
|
+
c,
|
|
321
|
+
deps,
|
|
322
|
+
selectedWorkspaceId,
|
|
323
|
+
"workspace:read",
|
|
324
|
+
);
|
|
325
|
+
const grant = requireWorkspaceToolGatewayAuthorization(authorization);
|
|
326
|
+
if (grant.subjectId !== request.subjectId) {
|
|
327
|
+
throw new HTTPException(403, {
|
|
328
|
+
message: "OAuth consent authority changed",
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
if (grant.workspaceId !== request.workspaceId || grant.accountId !== request.accountId) {
|
|
332
|
+
const prepared = await prepareWorkspaceToolGateway(deps, authorization);
|
|
333
|
+
try {
|
|
334
|
+
const rebound = await rebindMcpOAuthAuthorizationRequest(deps.db, {
|
|
335
|
+
requestHash: request.requestHash,
|
|
336
|
+
subjectId: grant.subjectId,
|
|
337
|
+
accountId: grant.accountId,
|
|
338
|
+
workspaceId: grant.workspaceId,
|
|
339
|
+
permissions: grant.permissions,
|
|
340
|
+
toolIdentities: mcpOAuthConsentToolIdentities(prepared.toolGatewayCatalog),
|
|
341
|
+
});
|
|
342
|
+
if (!rebound) {
|
|
343
|
+
return oauthAuthorizeBrowserError(
|
|
344
|
+
c,
|
|
345
|
+
"This authorization request expired or was already approved.",
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
} finally {
|
|
349
|
+
await prepared.close();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const code = opaque(CODE_PREFIX);
|
|
353
|
+
const consumed = await consumeMcpOAuthAuthorizationRequest(deps.db, {
|
|
354
|
+
requestHash: request.requestHash,
|
|
355
|
+
subjectId: grant.subjectId,
|
|
356
|
+
codeHash: tokenHash(code),
|
|
357
|
+
codeExpiresAt: expiresIn(MCP_OAUTH_AUTHORIZATION_CODE_TTL_SECONDS),
|
|
358
|
+
});
|
|
359
|
+
if (!consumed) {
|
|
360
|
+
return oauthAuthorizeBrowserError(
|
|
361
|
+
c,
|
|
362
|
+
"This authorization request expired or was already approved.",
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
return completeAuthorizationRedirect(
|
|
366
|
+
c,
|
|
367
|
+
authorizationRedirect(consumed.redirectUri, {
|
|
368
|
+
code,
|
|
369
|
+
iss: mcpOAuthIssuer(deps),
|
|
370
|
+
...(consumed.state ? { state: consumed.state } : {}),
|
|
371
|
+
}),
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// The web sign-in page for the organization server reads the pending request…
|
|
376
|
+
app.get("/v1/mcp-connections/requests/:request", async (c) => {
|
|
377
|
+
requireMcpOAuthEnabled(deps);
|
|
378
|
+
const person = await requireBrowserPerson(c, deps);
|
|
379
|
+
const request = await requirePendingOrganizationRequest(deps, c.req.param("request"), person);
|
|
380
|
+
const client = await getMcpOAuthClient(deps.db, request.clientId);
|
|
381
|
+
const organizations = await consentOrganizations(deps, person.context);
|
|
382
|
+
c.header("cache-control", "no-store");
|
|
383
|
+
return c.json({
|
|
384
|
+
client: {
|
|
385
|
+
name: client?.clientName ?? "MCP client",
|
|
386
|
+
host: redirectHost(request.redirectUri),
|
|
387
|
+
},
|
|
388
|
+
person: { name: person.context.subjectLabel ?? null },
|
|
389
|
+
organizations,
|
|
390
|
+
defaultOrganizationId: organizations.some((each) => each.id === request.accountId)
|
|
391
|
+
? request.accountId
|
|
392
|
+
: (organizations[0]?.id ?? null),
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// …and answers it. Approving returns the agent's redirect with a code.
|
|
397
|
+
app.post("/v1/mcp-connections/requests/:request", async (c) => {
|
|
398
|
+
requireMcpOAuthEnabled(deps);
|
|
399
|
+
requireSameOriginBrowserMutation(c, deps);
|
|
400
|
+
const person = await requireBrowserPerson(c, deps);
|
|
401
|
+
const request = await requirePendingOrganizationRequest(deps, c.req.param("request"), person);
|
|
402
|
+
const parsed = OrganizationConsentDecision.safeParse(await c.req.json().catch(() => null));
|
|
403
|
+
if (!parsed.success) throw new HTTPException(400, { message: "invalid sign-in decision" });
|
|
404
|
+
const decision = parsed.data;
|
|
405
|
+
if (decision.decision === "deny") {
|
|
406
|
+
await deleteMcpOAuthAuthorizationRequest(deps.db, request.requestHash);
|
|
407
|
+
return c.json({
|
|
408
|
+
redirectTo: authorizationRedirect(request.redirectUri, {
|
|
409
|
+
error: "access_denied",
|
|
410
|
+
iss: mcpOAuthIssuer(deps),
|
|
411
|
+
...(request.state ? { state: request.state } : {}),
|
|
412
|
+
}),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
const organizations = await consentOrganizations(deps, person.context);
|
|
416
|
+
const organization = organizations.find((each) => each.id === decision.organizationId);
|
|
417
|
+
if (!organization) throw new HTTPException(403, { message: "organization is not available" });
|
|
418
|
+
const access = decision.access;
|
|
419
|
+
if (access.workspaceScope.kind === "selected") {
|
|
420
|
+
const reachable = new Set(organization.workspaces.map((workspace) => workspace.id));
|
|
421
|
+
if (access.workspaceScope.workspaceIds.some((id) => !reachable.has(id))) {
|
|
422
|
+
throw new HTTPException(403, { message: "a selected workspace is not available" });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
const bound = await setMcpOAuthRequestOrganizationAccess(deps.db, {
|
|
426
|
+
requestHash: request.requestHash,
|
|
427
|
+
subjectId: person.context.subjectId,
|
|
428
|
+
accountId: organization.id,
|
|
429
|
+
organizationAccess: access,
|
|
430
|
+
});
|
|
431
|
+
const code = opaque(CODE_PREFIX);
|
|
432
|
+
const consumed = bound
|
|
433
|
+
? await consumeMcpOAuthAuthorizationRequest(deps.db, {
|
|
434
|
+
requestHash: request.requestHash,
|
|
435
|
+
subjectId: person.context.subjectId,
|
|
436
|
+
codeHash: tokenHash(code),
|
|
437
|
+
codeExpiresAt: expiresIn(MCP_OAUTH_AUTHORIZATION_CODE_TTL_SECONDS),
|
|
438
|
+
})
|
|
439
|
+
: null;
|
|
440
|
+
if (!consumed) {
|
|
441
|
+
throw new HTTPException(410, { message: "This sign-in expired or was already answered." });
|
|
442
|
+
}
|
|
443
|
+
return c.json({
|
|
444
|
+
redirectTo: authorizationRedirect(consumed.redirectUri, {
|
|
445
|
+
code,
|
|
446
|
+
iss: mcpOAuthIssuer(deps),
|
|
447
|
+
...(consumed.state ? { state: consumed.state } : {}),
|
|
448
|
+
}),
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
app.post("/oauth/token", async (c) => {
|
|
453
|
+
requireMcpOAuthEnabled(deps);
|
|
454
|
+
const form = new URLSearchParams(await c.req.text());
|
|
455
|
+
const grantType = form.get("grant_type");
|
|
456
|
+
const clientId = form.get("client_id") ?? "";
|
|
457
|
+
const resource = form.get("resource");
|
|
458
|
+
const client = await getMcpOAuthClient(deps.db, clientId);
|
|
459
|
+
if (!client) return oauthError(c, "invalid_client", 401);
|
|
460
|
+
if (grantType !== "authorization_code" && grantType !== "refresh_token") {
|
|
461
|
+
return oauthError(c, "unsupported_grant_type", 400);
|
|
462
|
+
}
|
|
463
|
+
if (!client.grantTypes.includes(grantType)) {
|
|
464
|
+
return oauthError(c, "unauthorized_client", 400);
|
|
465
|
+
}
|
|
466
|
+
const requestedScope = form.get("scope");
|
|
467
|
+
if (requestedScope !== null && requestedScope !== MCP_OAUTH_SCOPE) {
|
|
468
|
+
return oauthError(c, "invalid_scope", 400);
|
|
469
|
+
}
|
|
470
|
+
if (!resource) return oauthError(c, "invalid_target", 400);
|
|
471
|
+
let parsedResource: McpOAuthResource;
|
|
472
|
+
try {
|
|
473
|
+
parsedResource = parseMcpOAuthResource(deps, resource);
|
|
474
|
+
} catch {
|
|
475
|
+
return oauthError(c, "invalid_target", 400);
|
|
476
|
+
}
|
|
477
|
+
const accessToken = opaque(ACCESS_PREFIX);
|
|
478
|
+
const refreshToken = client.grantTypes.includes("refresh_token")
|
|
479
|
+
? opaque(REFRESH_PREFIX)
|
|
480
|
+
: null;
|
|
481
|
+
const tokenInput = {
|
|
482
|
+
clientId,
|
|
483
|
+
resource: parsedResource.resource,
|
|
484
|
+
accessTokenHash: tokenHash(accessToken),
|
|
485
|
+
accessExpiresAt: expiresIn(MCP_OAUTH_ACCESS_TOKEN_TTL_SECONDS),
|
|
486
|
+
refreshExpiresAt: expiresIn(MCP_OAUTH_REFRESH_TOKEN_TTL_SECONDS),
|
|
487
|
+
};
|
|
488
|
+
const access =
|
|
489
|
+
grantType === "authorization_code"
|
|
490
|
+
? await exchangeAuthorizationCode(deps, client.redirectUris, form, {
|
|
491
|
+
...tokenInput,
|
|
492
|
+
refreshTokenHash: refreshToken ? tokenHash(refreshToken) : null,
|
|
493
|
+
})
|
|
494
|
+
: refreshToken
|
|
495
|
+
? await rotateRefreshToken(deps, form, {
|
|
496
|
+
...tokenInput,
|
|
497
|
+
nextRefreshTokenHash: tokenHash(refreshToken),
|
|
498
|
+
})
|
|
499
|
+
: null;
|
|
500
|
+
if (!access) return oauthError(c, "invalid_grant", 400);
|
|
501
|
+
c.header("cache-control", "no-store");
|
|
502
|
+
c.header("pragma", "no-cache");
|
|
503
|
+
return c.json(
|
|
504
|
+
McpOAuthTokenResponse.parse({
|
|
505
|
+
access_token: accessToken,
|
|
506
|
+
token_type: "Bearer",
|
|
507
|
+
expires_in: MCP_OAUTH_ACCESS_TOKEN_TTL_SECONDS,
|
|
508
|
+
...(refreshToken ? { refresh_token: refreshToken } : {}),
|
|
509
|
+
scope: MCP_OAUTH_SCOPE,
|
|
510
|
+
}),
|
|
511
|
+
);
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export async function resolveMcpOAuthRouteAccess(
|
|
516
|
+
deps: ApiRouteDeps,
|
|
517
|
+
request: Request,
|
|
518
|
+
workspaceId: string,
|
|
519
|
+
): Promise<McpOAuthRouteAccess | null> {
|
|
520
|
+
const token = mcpOAuthBearerToken(request);
|
|
521
|
+
if (!token) return null;
|
|
522
|
+
requireMcpOAuthEnabled(deps);
|
|
523
|
+
const access = await resolveMcpOAuthAccessToken(deps.db, tokenHash(token));
|
|
524
|
+
const requestResource = parseMcpOAuthResource(
|
|
525
|
+
deps,
|
|
526
|
+
`${mcpOAuthIssuer(deps)}${new URL(request.url).pathname}`,
|
|
527
|
+
);
|
|
528
|
+
// The client URL is the resource indicator. Consent may bind a different
|
|
529
|
+
// workspace than the path UUID; the token grant is the authorized workspace.
|
|
530
|
+
if (
|
|
531
|
+
!access ||
|
|
532
|
+
requestResource.workspaceId !== workspaceId ||
|
|
533
|
+
access.resource !== requestResource.resource
|
|
534
|
+
) {
|
|
535
|
+
throw new HTTPException(401, { message: "invalid MCP OAuth access token" });
|
|
536
|
+
}
|
|
537
|
+
const grant = await resolveLiveMcpOAuthGrant(deps.db, access);
|
|
538
|
+
if (!grant)
|
|
539
|
+
throw new HTTPException(401, {
|
|
540
|
+
message: "MCP OAuth authority is no longer active",
|
|
541
|
+
});
|
|
542
|
+
const allowedToolIdentities = access.toolIdentities.filter((identity) =>
|
|
543
|
+
requestResource.kind === "all" ? true : identity.serverId === requestResource.kind,
|
|
544
|
+
);
|
|
545
|
+
return { grant, allowedToolIdentities };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export function mcpOAuthBearerToken(request: Request): string | null {
|
|
549
|
+
const authorization = request.headers.get("authorization");
|
|
550
|
+
const token = authorization?.match(/^Bearer +(\S+)$/iu)?.[1];
|
|
551
|
+
if (!token) return null;
|
|
552
|
+
return token.startsWith(ACCESS_PREFIX) ? token : null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export function isMcpOAuthResourcePath(pathname: string): boolean {
|
|
556
|
+
return WORKSPACE_MCP_PATH.test(pathname) || pathname === ORGANIZATION_MCP_PATH;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export function mcpOAuthConsentToolIdentities(catalog: ToolGatewayCatalog): ToolGatewayIdentity[] {
|
|
560
|
+
return catalog.entries.map((entry) => entry.identity);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function isMcpOAuthPublicProtocolPath(pathname: string): boolean {
|
|
564
|
+
return (
|
|
565
|
+
pathname === "/.well-known/oauth-authorization-server" ||
|
|
566
|
+
pathname.startsWith("/.well-known/oauth-protected-resource/") ||
|
|
567
|
+
pathname === "/oauth/register" ||
|
|
568
|
+
pathname === "/oauth/authorize" ||
|
|
569
|
+
pathname === "/oauth/token"
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function mcpOAuthAuthenticateHeader(deps: ApiRouteDeps, pathname: string): string {
|
|
574
|
+
return `Bearer resource_metadata="${mcpOAuthIssuer(deps)}/.well-known/oauth-protected-resource${pathname}", scope="${MCP_OAUTH_SCOPE}"`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function requireMcpOAuthEnabled(deps: ApiRouteDeps): void {
|
|
578
|
+
if (!deps.settings.mcpOauthEnabled) throw new HTTPException(404, { message: "not found" });
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function mcpOAuthIssuer(deps: ApiRouteDeps): string {
|
|
582
|
+
if (!deps.settings.publicBaseUrl) throw new Error("MCP OAuth public base URL is not configured");
|
|
583
|
+
return new URL(deps.settings.publicBaseUrl).origin;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function parseMcpOAuthResource(deps: ApiRouteDeps, value: string): McpOAuthResource {
|
|
587
|
+
let url: URL;
|
|
588
|
+
try {
|
|
589
|
+
url = new URL(value);
|
|
590
|
+
} catch {
|
|
591
|
+
throw new HTTPException(400, { message: "invalid OAuth resource" });
|
|
592
|
+
}
|
|
593
|
+
const issuer = mcpOAuthIssuer(deps);
|
|
594
|
+
if (
|
|
595
|
+
url.pathname === ORGANIZATION_MCP_PATH &&
|
|
596
|
+
url.origin === issuer &&
|
|
597
|
+
!url.username &&
|
|
598
|
+
!url.password &&
|
|
599
|
+
!url.search &&
|
|
600
|
+
!url.hash
|
|
601
|
+
) {
|
|
602
|
+
return {
|
|
603
|
+
resource: `${issuer}${ORGANIZATION_MCP_PATH}`,
|
|
604
|
+
workspaceId: null,
|
|
605
|
+
kind: "organization",
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
const match = url.pathname.match(WORKSPACE_MCP_PATH);
|
|
609
|
+
if (url.origin !== issuer || url.username || url.password || url.search || url.hash || !match) {
|
|
610
|
+
throw new HTTPException(400, { message: "invalid OAuth resource" });
|
|
611
|
+
}
|
|
612
|
+
return {
|
|
613
|
+
resource: `${issuer}${url.pathname}`,
|
|
614
|
+
workspaceId: match[1]!,
|
|
615
|
+
kind: match[2] === "docs" ? "docs" : match[2] === "files" ? "files" : "all",
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function requireAuthorizationClient(deps: ApiRouteDeps, query: URLSearchParams) {
|
|
620
|
+
if (query.get("response_type") !== "code" || query.get("scope") !== MCP_OAUTH_SCOPE) {
|
|
621
|
+
throw new HTTPException(400, {
|
|
622
|
+
message: "unsupported OAuth authorization request",
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
const clientId = query.get("client_id") ?? "";
|
|
626
|
+
const client = await getMcpOAuthClient(deps.db, clientId);
|
|
627
|
+
if (!client || !client.grantTypes.includes("authorization_code")) {
|
|
628
|
+
throw new HTTPException(400, { message: "invalid OAuth client" });
|
|
629
|
+
}
|
|
630
|
+
requireRedirectUri(client.redirectUris, query.get("redirect_uri"));
|
|
631
|
+
requireCodeChallenge(query);
|
|
632
|
+
return client;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function requireAuthorizationResource(
|
|
636
|
+
deps: ApiRouteDeps,
|
|
637
|
+
query: URLSearchParams,
|
|
638
|
+
): McpOAuthResource {
|
|
639
|
+
const resources = query.getAll("resource");
|
|
640
|
+
if (resources.length !== 1)
|
|
641
|
+
throw new HTTPException(400, { message: "one OAuth resource is required" });
|
|
642
|
+
return parseMcpOAuthResource(deps, resources[0]!);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function requireRedirectUri(registered: string[], value: string | null): string {
|
|
646
|
+
if (!value || !registered.includes(value)) {
|
|
647
|
+
throw new HTTPException(400, {
|
|
648
|
+
message: "OAuth redirect_uri is not registered",
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
return value;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function requireCodeChallenge(query: URLSearchParams): string {
|
|
655
|
+
const challenge = query.get("code_challenge") ?? "";
|
|
656
|
+
if (query.get("code_challenge_method") !== "S256" || !PKCE_CHALLENGE.test(challenge)) {
|
|
657
|
+
throw new HTTPException(400, { message: "PKCE S256 is required" });
|
|
658
|
+
}
|
|
659
|
+
return challenge;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function exchangeAuthorizationCode(
|
|
663
|
+
deps: ApiRouteDeps,
|
|
664
|
+
redirectUris: string[],
|
|
665
|
+
form: URLSearchParams,
|
|
666
|
+
input: {
|
|
667
|
+
clientId: string;
|
|
668
|
+
resource: string;
|
|
669
|
+
accessTokenHash: string;
|
|
670
|
+
refreshTokenHash: string | null;
|
|
671
|
+
accessExpiresAt: Date;
|
|
672
|
+
refreshExpiresAt: Date;
|
|
673
|
+
},
|
|
674
|
+
) {
|
|
675
|
+
const code = form.get("code") ?? "";
|
|
676
|
+
const verifier = form.get("code_verifier") ?? "";
|
|
677
|
+
if (!code.startsWith(CODE_PREFIX) || !PKCE_VERIFIER.test(verifier)) return null;
|
|
678
|
+
const redirectUri = form.get("redirect_uri");
|
|
679
|
+
if (!redirectUri || !redirectUris.includes(redirectUri)) return null;
|
|
680
|
+
for (const candidate of mcpOAuthRedirectUriCandidates(redirectUris, redirectUri)) {
|
|
681
|
+
const access = await exchangeMcpOAuthAuthorizationCode(deps.db, {
|
|
682
|
+
codeHash: tokenHash(code),
|
|
683
|
+
clientId: input.clientId,
|
|
684
|
+
redirectUri: candidate,
|
|
685
|
+
resource: input.resource,
|
|
686
|
+
codeChallenge: pkceChallenge(verifier),
|
|
687
|
+
accessTokenHash: input.accessTokenHash,
|
|
688
|
+
refreshTokenHash: input.refreshTokenHash,
|
|
689
|
+
accessExpiresAt: input.accessExpiresAt,
|
|
690
|
+
refreshExpiresAt: input.refreshExpiresAt,
|
|
691
|
+
});
|
|
692
|
+
if (access) return access;
|
|
693
|
+
}
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export function mcpOAuthRedirectUriCandidates(registered: string[], requested: string): string[] {
|
|
698
|
+
if (!registered.includes(requested)) return [];
|
|
699
|
+
return [requested, ...registered.filter((uri) => uri !== requested)];
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function rotateRefreshToken(
|
|
703
|
+
deps: ApiRouteDeps,
|
|
704
|
+
form: URLSearchParams,
|
|
705
|
+
input: {
|
|
706
|
+
clientId: string;
|
|
707
|
+
resource: string;
|
|
708
|
+
accessTokenHash: string;
|
|
709
|
+
nextRefreshTokenHash: string;
|
|
710
|
+
accessExpiresAt: Date;
|
|
711
|
+
refreshExpiresAt: Date;
|
|
712
|
+
},
|
|
713
|
+
) {
|
|
714
|
+
const refreshToken = form.get("refresh_token") ?? "";
|
|
715
|
+
if (!refreshToken.startsWith(REFRESH_PREFIX)) return null;
|
|
716
|
+
return await rotateMcpOAuthRefreshToken(deps.db, {
|
|
717
|
+
refreshTokenHash: tokenHash(refreshToken),
|
|
718
|
+
...input,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const BLOCKED_REDIRECT_PROTOCOLS = new Set([
|
|
723
|
+
"javascript:",
|
|
724
|
+
"data:",
|
|
725
|
+
"file:",
|
|
726
|
+
"about:",
|
|
727
|
+
"blob:",
|
|
728
|
+
"vbscript:",
|
|
729
|
+
]);
|
|
730
|
+
|
|
731
|
+
function isNativeAppRedirect(url: URL): boolean {
|
|
732
|
+
return (
|
|
733
|
+
/^[a-z][a-z0-9+.-]*:$/u.test(url.protocol) &&
|
|
734
|
+
!BLOCKED_REDIRECT_PROTOCOLS.has(url.protocol) &&
|
|
735
|
+
url.protocol !== "http:" &&
|
|
736
|
+
url.protocol !== "https:" &&
|
|
737
|
+
Boolean(url.hostname) &&
|
|
738
|
+
url.pathname.startsWith("/") &&
|
|
739
|
+
url.pathname !== "/"
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function validateRedirectUri(value: string): string {
|
|
744
|
+
const url = new URL(value);
|
|
745
|
+
const loopback =
|
|
746
|
+
url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
747
|
+
if (
|
|
748
|
+
url.username ||
|
|
749
|
+
url.password ||
|
|
750
|
+
url.hash ||
|
|
751
|
+
(url.protocol !== "https:" && !loopback && !isNativeAppRedirect(url))
|
|
752
|
+
) {
|
|
753
|
+
throw new HTTPException(400, {
|
|
754
|
+
message: "invalid public OAuth redirect URI",
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
return url.toString();
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function boundedState(value: string | null): string | null {
|
|
761
|
+
if (value === null) return null;
|
|
762
|
+
if (!value || new TextEncoder().encode(value).byteLength > 1_024) {
|
|
763
|
+
throw new HTTPException(400, { message: "invalid OAuth state" });
|
|
764
|
+
}
|
|
765
|
+
return value;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function pkceChallenge(verifier: string): string {
|
|
769
|
+
return createHash("sha256").update(verifier, "ascii").digest("base64url");
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function opaque(prefix: string): string {
|
|
773
|
+
return `${prefix}${randomBytes(32).toString("base64url")}`;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function tokenHash(value: string): string {
|
|
777
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function mcpOAuthRegistrationClientKey(c: Context, trust: RequestSourceTrust): string {
|
|
781
|
+
return `mcp-oauth-registration:${trustedRequestSourceRateLimitKey(c, trust)}`;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function expiresIn(seconds: number): Date {
|
|
785
|
+
return new Date(Date.now() + seconds * 1_000);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function authorizationRedirect(redirectUri: string, params: Record<string, string>): string {
|
|
789
|
+
const url = new URL(redirectUri);
|
|
790
|
+
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
|
|
791
|
+
return url.toString();
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
export function completeAuthorizationRedirect(c: Context, redirectTo: string) {
|
|
795
|
+
// Consent posts under CSP form-action 'self'. A 302 to the client's
|
|
796
|
+
// registered redirect_uri (loopback HTTP or a custom scheme) is blocked,
|
|
797
|
+
// so the waiter never receives the code. Return 200 HTML that navigates
|
|
798
|
+
// to the exact requested URI from script and Refresh.
|
|
799
|
+
c.header("cache-control", "no-store");
|
|
800
|
+
c.header(
|
|
801
|
+
"content-security-policy",
|
|
802
|
+
"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; frame-ancestors 'none'",
|
|
803
|
+
);
|
|
804
|
+
c.header("refresh", `0;url=${redirectTo}`);
|
|
805
|
+
return c.html(renderMcpOAuthContinuePage(redirectTo));
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function oauthAuthorizeBrowserError(c: Context, message: string) {
|
|
809
|
+
c.header("cache-control", "no-store");
|
|
810
|
+
c.header("pragma", "no-cache");
|
|
811
|
+
c.header(
|
|
812
|
+
"content-security-policy",
|
|
813
|
+
"default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'",
|
|
814
|
+
);
|
|
815
|
+
return c.html(renderMcpOAuthExpiredPage(message), 400);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
export type McpOAuthConsentAccount = {
|
|
819
|
+
id: string;
|
|
820
|
+
name: string;
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
export type McpOAuthConsentWorkspace = {
|
|
824
|
+
id: string;
|
|
825
|
+
accountId: string;
|
|
826
|
+
name: string;
|
|
827
|
+
kind: "personal" | "shared";
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
export function renderMcpOAuthConsentPage(input: {
|
|
831
|
+
clientName: string;
|
|
832
|
+
requestToken: string;
|
|
833
|
+
accounts: McpOAuthConsentAccount[];
|
|
834
|
+
workspaces: McpOAuthConsentWorkspace[];
|
|
835
|
+
selectedWorkspaceId: string;
|
|
836
|
+
}): string {
|
|
837
|
+
const selectedWorkspace =
|
|
838
|
+
input.workspaces.find((workspace) => workspace.id === input.selectedWorkspaceId) ??
|
|
839
|
+
input.workspaces[0];
|
|
840
|
+
if (!selectedWorkspace) {
|
|
841
|
+
throw new Error("MCP OAuth consent requires at least one workspace");
|
|
842
|
+
}
|
|
843
|
+
const selectedAccountId = selectedWorkspace.accountId;
|
|
844
|
+
const accountOptions = input.accounts
|
|
845
|
+
.map(
|
|
846
|
+
(account) =>
|
|
847
|
+
`<option value="${escapeHtml(account.id)}"${account.id === selectedAccountId ? " selected" : ""}>${escapeHtml(account.name)}</option>`,
|
|
848
|
+
)
|
|
849
|
+
.join("");
|
|
850
|
+
const workspaceOptions = input.workspaces
|
|
851
|
+
.filter((workspace) => workspace.accountId === selectedAccountId)
|
|
852
|
+
.map(
|
|
853
|
+
(workspace) =>
|
|
854
|
+
`<option value="${escapeHtml(workspace.id)}"${workspace.id === selectedWorkspace.id ? " selected" : ""}>${escapeHtml(consentWorkspaceLabel(workspace))}</option>`,
|
|
855
|
+
)
|
|
856
|
+
.join("");
|
|
857
|
+
const workspacePayload = input.workspaces.map((workspace) => ({
|
|
858
|
+
id: workspace.id,
|
|
859
|
+
accountId: workspace.accountId,
|
|
860
|
+
label: consentWorkspaceLabel(workspace),
|
|
861
|
+
}));
|
|
862
|
+
const body = `<p class="lede"><strong>${escapeHtml(input.clientName)}</strong> wants MCP access to the organization and workspace you choose.</p>
|
|
863
|
+
<form method="post" action="/oauth/authorize" onsubmit="var submitter=event.submitter;if(submitter&&submitter.name){var input=document.createElement('input');input.type='hidden';input.name=submitter.name;input.value=submitter.value;this.appendChild(input)}this.querySelectorAll('button').forEach(function(button){button.disabled=true})">
|
|
864
|
+
<input type="hidden" name="request" value="${escapeHtml(input.requestToken)}">
|
|
865
|
+
<label class="field"><span>Organization</span><select id="organization" name="organization" autocomplete="off">${accountOptions}</select></label>
|
|
866
|
+
<label class="field"><span>Workspace</span><select id="workspace_id" name="workspace_id" autocomplete="off">${workspaceOptions}</select></label>
|
|
867
|
+
<p class="note">The grant is limited to tools available in the workspace you authorize. Opengeni rechecks live authority on every request.</p>
|
|
868
|
+
<div class="actions"><button class="approve" name="decision" value="approve">Authorize</button><button class="deny" name="decision" value="deny">Deny</button></div>
|
|
869
|
+
</form>
|
|
870
|
+
<script type="application/json" id="mcp-oauth-workspaces">${jsonForScript(workspacePayload)}</script>
|
|
871
|
+
<script>
|
|
872
|
+
(function () {
|
|
873
|
+
var data = JSON.parse(document.getElementById("mcp-oauth-workspaces").textContent);
|
|
874
|
+
var account = document.getElementById("organization");
|
|
875
|
+
var workspace = document.getElementById("workspace_id");
|
|
876
|
+
function sync() {
|
|
877
|
+
var selected = workspace.value;
|
|
878
|
+
var items = data.filter(function (item) { return item.accountId === account.value; });
|
|
879
|
+
workspace.replaceChildren();
|
|
880
|
+
items.forEach(function (item) {
|
|
881
|
+
var option = document.createElement("option");
|
|
882
|
+
option.value = item.id;
|
|
883
|
+
option.textContent = item.label;
|
|
884
|
+
workspace.appendChild(option);
|
|
885
|
+
});
|
|
886
|
+
if (items.some(function (item) { return item.id === selected; })) workspace.value = selected;
|
|
887
|
+
}
|
|
888
|
+
account.addEventListener("change", sync);
|
|
889
|
+
})();
|
|
890
|
+
</script>`;
|
|
891
|
+
return oauthDocument({
|
|
892
|
+
title: "Authorize MCP access",
|
|
893
|
+
heading: "Authorize this client",
|
|
894
|
+
body,
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
export function renderMcpOAuthContinuePage(redirectTo: string): string {
|
|
899
|
+
return oauthDocument({
|
|
900
|
+
title: "Returning to the app",
|
|
901
|
+
heading: "Returning to the app",
|
|
902
|
+
head: `<meta http-equiv="refresh" content="0;url=${escapeHtml(redirectTo)}">`,
|
|
903
|
+
body: `<p class="lede">Authorization succeeded. You can close this window.</p><script>(function (url) { try { location.replace(url); } catch (error) {} try { location.href = url; } catch (error) {} var frame = document.createElement("iframe"); frame.src = url; frame.style.display = "none"; document.body.appendChild(frame); })(${JSON.stringify(redirectTo)});</script>`,
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
export function renderMcpOAuthExpiredPage(message: string): string {
|
|
908
|
+
return oauthDocument({
|
|
909
|
+
title: "Authorization expired",
|
|
910
|
+
heading: "Authorization expired",
|
|
911
|
+
body: `<p class="lede">${escapeHtml(message)}</p><p class="note">Close this window and start authorization again from the client.</p>`,
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
async function listConsentWorkspaces(deps: ApiRouteDeps, context: AccessContext) {
|
|
916
|
+
const readableWorkspaceIds = [
|
|
917
|
+
...new Set(
|
|
918
|
+
context.workspaceGrants
|
|
919
|
+
.filter((grant) => hasPermission(grant.permissions, "workspace:read"))
|
|
920
|
+
.map((grant) => grant.workspaceId),
|
|
921
|
+
),
|
|
922
|
+
];
|
|
923
|
+
const readableAccountIds = context.accountGrants
|
|
924
|
+
.filter((grant) => hasPermission(grant.permissions, "workspace:read"))
|
|
925
|
+
.map((grant) => grant.accountId);
|
|
926
|
+
const [fromGrants, memberships, shared] = await Promise.all([
|
|
927
|
+
Promise.all(readableWorkspaceIds.map((workspaceId) => getWorkspace(deps.db, workspaceId))),
|
|
928
|
+
listWorkspacesForSubject(deps.db, context.subjectId),
|
|
929
|
+
Promise.all(
|
|
930
|
+
readableAccountIds.map((accountId) => listSharedWorkspacesForAccount(deps.db, accountId)),
|
|
931
|
+
),
|
|
932
|
+
]);
|
|
933
|
+
const byId = new Map<string, NonNullable<(typeof fromGrants)[number]>>();
|
|
934
|
+
for (const workspace of [...fromGrants, ...memberships, ...shared.flat()]) {
|
|
935
|
+
if (workspace) byId.set(workspace.id, workspace);
|
|
936
|
+
}
|
|
937
|
+
return [...byId.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
async function consentAccountsForWorkspaces(
|
|
941
|
+
deps: ApiRouteDeps,
|
|
942
|
+
workspaces: Array<{ accountId: string }>,
|
|
943
|
+
): Promise<McpOAuthConsentAccount[]> {
|
|
944
|
+
const accountIds = [...new Set(workspaces.map((workspace) => workspace.accountId))];
|
|
945
|
+
return (
|
|
946
|
+
await Promise.all(
|
|
947
|
+
accountIds.map(async (accountId) => {
|
|
948
|
+
const account = await getManagedAccount(deps.db, accountId);
|
|
949
|
+
return { id: accountId, name: account?.name.trim() || "Organization" };
|
|
950
|
+
}),
|
|
951
|
+
)
|
|
952
|
+
).sort((left, right) => left.name.localeCompare(right.name));
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function consentWorkspaceLabel(workspace: McpOAuthConsentWorkspace): string {
|
|
956
|
+
const name = workspace.name.trim() || "Workspace";
|
|
957
|
+
return workspace.kind === "personal" ? `${name} (Personal)` : name;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function isWorkspaceId(value: string): boolean {
|
|
961
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function oauthDocument(input: {
|
|
965
|
+
title: string;
|
|
966
|
+
heading: string;
|
|
967
|
+
body: string;
|
|
968
|
+
head?: string;
|
|
969
|
+
}): string {
|
|
970
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(input.title)}</title>${input.head ?? ""}<style>${OAUTH_PAGE_CSS}</style></head><body><main><p class="mark">Opengeni</p><h1>${escapeHtml(input.heading)}</h1>${input.body}</main></body></html>`;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const OAUTH_PAGE_CSS =
|
|
974
|
+
'html,body{margin:0}body{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;background:#f4f4f5;color:#18181b;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;font-size:15px;line-height:1.5;-webkit-font-smoothing:antialiased}main{width:min(100%,28rem);background:#fff;border:1px solid #e4e4e7;border-radius:20px;padding:24px}h1{margin:8px 0 0;font-size:22px;line-height:1.3;font-weight:650}p{margin:12px 0 0}strong{font-weight:600}.mark{margin:0;font-size:12px;font-weight:600;color:#71717a}.lede,.note{color:#52525b}.field{display:flex;flex-direction:column;gap:6px;margin-top:16px;font-size:13px;color:#71717a}select{width:100%;border:1px solid #e4e4e7;border-radius:10px;background:#f4f4f5;color:#18181b;padding:10px 12px;font:inherit}.actions{display:flex;gap:12px;margin-top:20px}button{flex:1;min-height:40px;border:0;border-radius:10px;padding:10px 16px;font:inherit;font-weight:600;cursor:pointer}.approve{background:#18181b;color:#fff}.deny{background:#fff;color:#18181b;border:1px solid #e4e4e7}button:disabled{opacity:0.6;cursor:wait}@media(prefers-color-scheme:dark){body{background:#09090b;color:#fafafa}main{background:#18181b;border-color:#3f3f46}h1{color:#fafafa}.mark{color:#a1a1aa}.lede,.note{color:#a1a1aa}select{background:#27272a;border-color:#3f3f46;color:#fafafa}.approve{background:#fafafa;color:#18181b}.deny{background:#27272a;color:#fafafa;border-color:#3f3f46}}';
|
|
975
|
+
|
|
976
|
+
function jsonForScript(value: unknown): string {
|
|
977
|
+
return JSON.stringify(value).replace(/</gu, "\\u003c");
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function escapeHtml(value: string): string {
|
|
981
|
+
return value.replace(
|
|
982
|
+
/[&<>"']/gu,
|
|
983
|
+
(character) =>
|
|
984
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!,
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function oauthError(c: Context, error: string, status: 400 | 401 | 429) {
|
|
989
|
+
c.header("cache-control", "no-store");
|
|
990
|
+
c.header("pragma", "no-cache");
|
|
991
|
+
return c.json({ error }, status);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/* ----------------------------------------------------------------------------
|
|
995
|
+
Organization server sign-in: a person picks the organization, what the agent
|
|
996
|
+
can do and where. The agent then acts as that person, never beyond their
|
|
997
|
+
live access, on every request.
|
|
998
|
+
-------------------------------------------------------------------------- */
|
|
999
|
+
|
|
1000
|
+
/** Read only, every workspace: the least surprising default until the person chooses. */
|
|
1001
|
+
function DEFAULT_ORGANIZATION_ACCESS(): OrganizationAccessPolicy {
|
|
1002
|
+
return {
|
|
1003
|
+
preset: "read_only",
|
|
1004
|
+
permissions: organizationAccessPresetPermissions("read_only"),
|
|
1005
|
+
workspaceScope: { kind: "all" },
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const OrganizationConsentDecision = z.discriminatedUnion("decision", [
|
|
1010
|
+
z.object({ decision: z.literal("deny") }).strict(),
|
|
1011
|
+
z
|
|
1012
|
+
.object({
|
|
1013
|
+
decision: z.literal("approve"),
|
|
1014
|
+
organizationId: z.string().uuid(),
|
|
1015
|
+
access: OrganizationAccessPolicy,
|
|
1016
|
+
})
|
|
1017
|
+
.strict(),
|
|
1018
|
+
]);
|
|
1019
|
+
|
|
1020
|
+
/** A person signed in to Opengeni in this browser: never a key, bearer or service. */
|
|
1021
|
+
async function requireBrowserPerson(
|
|
1022
|
+
c: Context,
|
|
1023
|
+
deps: ApiRouteDeps,
|
|
1024
|
+
): Promise<{ context: AccessContext }> {
|
|
1025
|
+
requireNotAgent(c, "Approving a connection");
|
|
1026
|
+
if (
|
|
1027
|
+
deps.settings.productAccessMode !== "managed" ||
|
|
1028
|
+
!c.req.header("cookie") ||
|
|
1029
|
+
c.req.header("authorization")
|
|
1030
|
+
) {
|
|
1031
|
+
throw new HTTPException(401, { message: "sign in to Opengeni in this browser first" });
|
|
1032
|
+
}
|
|
1033
|
+
const context = await requireAccessContext(c, deps);
|
|
1034
|
+
if (context.mode !== "managed" || !context.subjectId.startsWith("user:")) {
|
|
1035
|
+
throw new HTTPException(401, { message: "sign in to Opengeni in this browser first" });
|
|
1036
|
+
}
|
|
1037
|
+
return { context };
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
async function requirePendingOrganizationRequest(
|
|
1041
|
+
deps: ApiRouteDeps,
|
|
1042
|
+
requestToken: string,
|
|
1043
|
+
person: { context: AccessContext },
|
|
1044
|
+
) {
|
|
1045
|
+
const request = requestToken.startsWith(REQUEST_PREFIX)
|
|
1046
|
+
? await getMcpOAuthAuthorizationRequest(deps.db, tokenHash(requestToken))
|
|
1047
|
+
: null;
|
|
1048
|
+
if (!request || request.workspaceId !== null) {
|
|
1049
|
+
throw new HTTPException(410, { message: "This sign-in expired or was already answered." });
|
|
1050
|
+
}
|
|
1051
|
+
if (request.subjectId !== person.context.subjectId) {
|
|
1052
|
+
throw new HTTPException(403, { message: "This sign-in belongs to someone else." });
|
|
1053
|
+
}
|
|
1054
|
+
return request;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/** Organizations the person belongs to, with what they could hand an agent there. */
|
|
1058
|
+
async function consentOrganizations(deps: ApiRouteDeps, context: AccessContext) {
|
|
1059
|
+
const memberOf = new Set(context.accountGrants.map((grant) => grant.accountId));
|
|
1060
|
+
const workspaces = (await listConsentWorkspaces(deps, context)).filter((workspace) =>
|
|
1061
|
+
memberOf.has(workspace.accountId),
|
|
1062
|
+
);
|
|
1063
|
+
const accounts = await consentAccountsForWorkspaces(
|
|
1064
|
+
deps,
|
|
1065
|
+
[...memberOf].map((accountId) => ({ accountId })),
|
|
1066
|
+
);
|
|
1067
|
+
const canonical = organizationAccessPresetPermissions("full");
|
|
1068
|
+
return accounts.map((account) => {
|
|
1069
|
+
const accountGrant = context.accountGrants.find((grant) => grant.accountId === account.id);
|
|
1070
|
+
const workspaceGrants = context.workspaceGrants.filter(
|
|
1071
|
+
(grant) => grant.accountId === account.id,
|
|
1072
|
+
);
|
|
1073
|
+
return {
|
|
1074
|
+
id: account.id,
|
|
1075
|
+
name: account.name,
|
|
1076
|
+
workspaces: workspaces
|
|
1077
|
+
.filter((workspace) => workspace.accountId === account.id)
|
|
1078
|
+
.map((workspace) => ({
|
|
1079
|
+
id: workspace.id,
|
|
1080
|
+
name: workspace.name,
|
|
1081
|
+
personal: workspace.kind === "personal",
|
|
1082
|
+
})),
|
|
1083
|
+
grantable: canonical.filter(
|
|
1084
|
+
(permission) =>
|
|
1085
|
+
(accountGrant !== undefined && hasPermission(accountGrant.permissions, permission)) ||
|
|
1086
|
+
workspaceGrants.some((grant) => hasPermission(grant.permissions, permission)),
|
|
1087
|
+
),
|
|
1088
|
+
};
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function redirectHost(redirectUri: string): string | null {
|
|
1093
|
+
try {
|
|
1094
|
+
const url = new URL(redirectUri);
|
|
1095
|
+
return url.host || url.protocol.replace(/:$/u, "");
|
|
1096
|
+
} catch {
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* The organization connection behind an organization-server bearer, or null
|
|
1103
|
+
* when the request carries no MCP OAuth token. Invalid, revoked, expired or
|
|
1104
|
+
* workspace-bound tokens are refused.
|
|
1105
|
+
*/
|
|
1106
|
+
export async function resolveOrganizationMcpOAuthAccess(
|
|
1107
|
+
deps: ApiRouteDeps,
|
|
1108
|
+
request: Request,
|
|
1109
|
+
): Promise<(McpOAuthAccess & { organizationAccess: OrganizationAccessPolicy }) | null> {
|
|
1110
|
+
const token = mcpOAuthBearerToken(request);
|
|
1111
|
+
if (!token) return null;
|
|
1112
|
+
requireMcpOAuthEnabled(deps);
|
|
1113
|
+
const access = await resolveMcpOAuthAccessToken(deps.db, tokenHash(token));
|
|
1114
|
+
if (
|
|
1115
|
+
!access ||
|
|
1116
|
+
access.workspaceId !== null ||
|
|
1117
|
+
access.organizationAccess === null ||
|
|
1118
|
+
access.resource !== `${mcpOAuthIssuer(deps)}${ORGANIZATION_MCP_PATH}`
|
|
1119
|
+
) {
|
|
1120
|
+
throw new HTTPException(401, { message: "invalid MCP OAuth access token" });
|
|
1121
|
+
}
|
|
1122
|
+
return { ...access, organizationAccess: access.organizationAccess };
|
|
1123
|
+
}
|