Files
pad/internal/server/handlers_claim_code.go
T
xarmian ae7f77d9d8 feat(mcp): construct MCP off cloud, mount always, gate per request (TASK-2317, PLAN-2310 U2) (#1663)
* feat(mcp): construct MCP off cloud, mount always, gate per request (TASK-2317, PLAN-2310 U2)

The remote MCP capability (transport, in-process dispatcher and
resource fetcher, audit writer, session tracker) is now constructed on
every install by wireMCP, and the OAuth server, claim secret and
connections backfill whenever the resolved auth-server URL is https.
Nothing is served until Server.mcpAvailable() / oauthAvailable() say
so, per request: every DR-5 route is mounted always and answers the
JSON 404 while unavailable, before auth, audit or rate limiting. The
two audit-history routes are unchanged.

- Discovery takes its URLs from configuration only. The four
  "https://"+r.Host fallbacks and rewriteMcpSubdomain are gone; with
  nothing configured the documents answer 404, and the /mcp 401 is a
  bare Bearer realm="pad" unless OAuth is available.
- resource_metadata follows RFC 9728 section 3.1, so a resource with a
  path (a self-host's <origin>/mcp) points at a route that exists.
  Byte-identical for a path-less resource (cloud).
- An explicitly set PAD_MCP_PUBLIC_URL / PAD_AUTH_SERVER_URL keeps its
  historical spelling (TrimRight "/"), so the OAuth audience of every
  existing deployment is byte-identical and issued tokens keep
  validating.
- Protected share-link image signing gets its own key field, set
  exactly where the claim secret used to be (cloud with
  PAD_MCP_PUBLIC_URL), so building OAuth on an https self-host changes
  nothing on share pages (lead ruling B; BUG-3305 is the deliberate fix).
- The session and setup payloads keep mcp_public_url cloud-only until
  the connect modal gains its PAT path (DR-8, unit 6).
- The claim routes answer 404 instead of 412 claim_disabled where OAuth
  is not available; the auth-server metadata answers 404 instead of 503.

Tests: the DR-5 route table across the eight acceptance states and the
upgrade test (no mcp_enabled row, https origin: every gated route 404,
the audit routes unchanged, a valid dispatcher claim refused; the
control leg reaches every route and the same claim succeeds), both over
the production wiring; an https self-host OAuth flow for audience
<origin>/mcp; a PAT call over http that writes an audit row.

Release gate (PLAN-2310): must not ship without units 3, 4 and 7.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* docs(mcp): the availability gates run inside the API perimeter on /api/v1 routes (TASK-2317 codex r1)

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
2026-09-29 14:13:03 -04:00

166 lines
6.8 KiB
Go

package server
import (
"net/http"
"time"
)
// GET /api/v1/workspaces/{slug}/claim-code — claim-code generation +
// smart-suppression endpoint (PLAN-1519 / TASK-1525 / IDEA-1517 §4).
//
// The "Connect a project" modal in the web UI calls this to render
// either:
//
// - A 6-digit claim code the user reads to their agent, which then
// calls `pad_workspace.action: claim` to add this workspace to
// its OAuth grant's allow-list (handled by handleOAuthClaim).
//
// - A "your agent can already see this workspace" hint when smart
// suppression detects the workspace is already covered by one of
// the user's active OAuth connections — IDEA-1517 §4: "if the
// user has any active grant they personally own with
// include_future_workspaces=on, the modal detects the workspace
// is already auto-covered and replaces the code with…"
//
// We broaden the suppression predicate slightly from the strict IDEA
// reading to cover any active-and-covering grant — wildcard
// (all_current_workspaces=1) and explicit allow-list entries alike —
// because the user-facing answer to "would my existing agent already
// see this workspace today?" is identical regardless of which flag
// got it onto the allow-list. include_future_workspaces is the most
// common path but not the only one.
//
// **Why a workspace-scoped GET.** The route mounts under
// `/api/v1/workspaces/{slug}` so it inherits RequireWorkspaceAccess —
// the same membership gate the rest of the workspace-scoped surface
// uses. The handler then re-derives the code for (current user,
// workspace) so a viewer/editor/owner can each pull a fresh code
// for any workspace they belong to. Membership IS the consent —
// IDEA-1517 §4: "the user generating + handing over the code IS the
// consent."
//
// **Idempotency / freshness.** DeriveClaimCode is stateless: the
// same (user, workspace, 5-min bucket) always returns the same six
// digits. A page that polls this endpoint will see the digits roll
// over every 5 minutes. `expires_at` reports the END of the CURRENT
// bucket so the client UI can render a countdown; the verification
// path accepts the previous bucket too, so a code is usable for a
// sliding 5-10 minute window.
//
// **Error envelope.**
// - 412 claim_disabled — deployment hasn't wired the claim secret
// while OAuth is served (defense in depth: startup sets them
// together, and without OAuth the route answers 404 before this
// handler runs, PLAN-2310 DR-5).
// - 401 auth_required — defense in depth (route is RequireAuth).
// - 404 — RequireWorkspaceAccess handles non-members.
// - 500 internal_error — DB I/O failure on the coverage query.
func (s *Server) handleWorkspaceClaimCode(w http.ResponseWriter, r *http.Request) {
if len(s.claimSecret) < 16 {
writeError(w, http.StatusPreconditionFailed, "claim_disabled",
"Claim-code redemption is not enabled on this deployment.")
return
}
user := currentUser(r)
if user == nil {
writeError(w, http.StatusUnauthorized, "auth_required", "Authentication required.")
return
}
ws, ok := s.getWorkspace(w, r)
if !ok {
return
}
// Explicit membership check. RequireWorkspaceAccess admits
// grant-only guests (per middleware_auth.go) — they can see an
// item-scoped slice of the workspace without being a member.
// Claim-code REDEMPTION at handleOAuthClaim requires full
// membership (it calls GetWorkspaceMember), so generating a code
// for a non-member here would hand the user a valid-looking
// 6-digit code + prompt that the claim path always rejects. Fail
// closed on the same envelope shape RequireWorkspaceAccess uses
// for guests on the workspace-as-a-whole.
member, err := s.store.GetWorkspaceMember(ws.ID, user.ID)
if err != nil {
writeInternalError(w, err)
return
}
if member == nil {
writeError(w, http.StatusForbidden, "not_a_member",
"Claim codes can only be generated by workspace members. "+
"Ask the owner for an invitation, or sign in as a member.")
return
}
// Smart suppression: does the calling user already have an active
// OAuth connection that covers this workspace? If so, the modal
// has no claim code to offer — it points the user at
// /console/connected-apps instead. Failure here is non-fatal for
// the page (we'd still want to render SOMETHING rather than 500
// over a non-critical hint) but we surface a 500 so the bug is
// visible — silent suppression failures would be worse than a
// loud one.
connName, covered, err := s.store.IsWorkspaceCoveredForUser(user.ID, ws.ID)
if err != nil {
writeInternalError(w, err)
return
}
now := time.Now()
resp := map[string]any{
"workspace": ws.Slug,
"suppressed": covered,
// expires_at reports the end of the CURRENT 5-min bucket in
// UTC RFC3339. Verification still accepts the previous bucket
// for up to ~5 additional minutes (sliding window), so this
// is the conservative "fresh through" timestamp the UI should
// use to drive a countdown without over-promising lifetime.
"expires_at": bucketEndTime(now).UTC().Format(time.RFC3339),
}
if covered {
// Hand the connection name back so the UI can render
// "your agent '<name>' can already see this workspace —
// go to Connected apps." Empty string is fine (the
// connection may not have been named at /authorize); the
// UI falls back to a generic phrasing in that case.
resp["suppression_grant_name"] = connName
writeJSON(w, http.StatusOK, resp)
return
}
// Not covered by any grant. Two sub-cases the modal renders
// differently: the user has a connection that just doesn't reach
// this workspace (a scoped grant — the claim code is the right
// tool), versus the user has no connection at all (the code is
// inert — steer them to set up an agent first). Surface the flag
// so the client can branch without a second round-trip. Failure
// is a 500 for the same reason the suppression check is: a silent
// wrong answer here reintroduces the dead-end code path.
hasAnyConnection, err := s.store.HasActiveConnectionForUser(user.ID)
if err != nil {
writeInternalError(w, err)
return
}
resp["has_any_connection"] = hasAnyConnection
resp["code"] = DeriveClaimCode(s.claimSecret, user.ID, ws.ID, now)
writeJSON(w, http.StatusOK, resp)
}
// bucketEndTime returns the Unix timestamp at which the CURRENT
// 5-minute claim-code bucket rolls over to the next one. Used as
// the `expires_at` value in the generation response so the modal
// can show a countdown without re-implementing the bucket math.
//
// The claim verifier accepts the PREVIOUS bucket too, so a code is
// actually usable for ~5 minutes past this timestamp — but the
// "guaranteed fresh" promise ends here, and the UI shouldn't
// over-promise lifetime.
func bucketEndTime(at time.Time) time.Time {
bucket := at.UTC().Unix() / claimBucketSeconds
return time.Unix((bucket+1)*claimBucketSeconds, 0).UTC()
}