mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 03:16:43 +00:00
fc6afd01be
* feat(connect): unified Connect-to-agent modal + claim-code endpoint (TASK-1525)
Phase E of PLAN-1519. Repurposes the avatar-menu "Connect a project…"
modal as a one-stop hub where users can connect ANY agent surface
(claim-code → existing OAuth grant, fresh MCP OAuth, or local CLI)
to the current workspace.
Backend
- GET /api/v1/workspaces/{slug}/claim-code — generates a stateless
6-digit HMAC claim code (re-uses the verifier's secret + bucket
math) for the calling member, OR reports `suppressed: true` when
smart-suppression detects the workspace is already covered by one
of the user's active OAuth connections (wildcard OR explicit
allow-list rows). Returns `expires_at` at the current bucket
boundary so the UI can drive a countdown.
- store.IsWorkspaceCoveredForUser — single indexed query against
oauth_connections + oauth_*_tokens; filters by ACTIVE tokens so a
dangling revoked connection row doesn't suppress fresh modals.
- Tests cover 412 (disabled), 404 (non-member), 200 + matching code,
wildcard suppression, explicit-allow-list suppression, and the
revoked-connection-doesn't-suppress invariant.
Frontend
- ConnectWorkspaceModal rewritten as a tabbed unified modal:
- Agent (claim code) — fetches on activate, live countdown,
auto-refetches at bucket roll-over, renders smart-suppression
panel that links to /console/connected-apps, and renders the
locked prompt block
`Authorize the pad workspace '<slug>' with claim code <code>.`
per IDEA-1517 §4.
- MCP setup — subsumed from the now-deleted ConnectMCPModal: URL
block + client-card grid linking to per-client docs.
- CLI — existing install + `pad init` flow, unchanged.
- Default tab: Agent when mcpPublicUrl is set; CLI when not. MCP tab
hidden entirely on self-host without a public MCP URL.
- ConnectBanner simplified: single modal state, generic "Connect an
AI agent to this workspace" copy, no MCP/CLI dual-modal branching.
- ConnectMCPModal.svelte deleted (fully subsumed).
- API client gets `workspaces.claimCode(slug)` + `ClaimCodeResponse`
TypeScript type.
- TopBar + workspace home callsites pass `mcpPublicUrl` from
authStore so the unified modal can pick the right default tab.
Verification
- go build ./... clean
- go test ./... — all packages green (server + store)
- cd web && npm run build — clean
Parent: PLAN-1519. Phases A-D already shipped (oauth_connections
schema, MCP claim action, /authorize redesign, connections-page
mutation UI); this lands Phase E. Phase F (TASK-1526) will wire
post-create auto-open from IDEA-1516's new-workspace modal; Phase G
(TASK-1527) is cross-agent paste validation of the locked prompt
string.
* fix(connect): require membership at claim-code generation; guard modal against stale-response races per Codex review (round 1)
1. Guest-grant generation gap. RequireWorkspaceAccess admits item-grant
guests who aren't workspace members; claim-code REDEMPTION requires
full membership. Generating without the same check handed guests a
valid-looking code + prompt that the claim endpoint always 404s.
Add an explicit GetWorkspaceMember check after getWorkspace and
return 403 not_a_member to fail closed on the same response shape
the redemption path would have used.
2. Stale-response race in the modal. ConnectWorkspaceModal stays
mounted across workspace switches (TopBar reuses the same
instance), so an older claimCode fetch can resolve AFTER a newer
one and stomp claimState with suppression or a code for the wrong
workspace. Add a monotonic seq + captured-slug guard mirroring the
refreshHasAgentActivity pattern already in ConnectBanner.
Test additions:
- TestHandleWorkspaceClaimCode_GrantOnlyGuest_403 asserts a non-member
authenticated caller never gets a 200 + code from the generation
endpoint.
150 lines
6.1 KiB
Go
150 lines
6.1 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
|
|
// (self-host without cloud-mode OAuth). Endpoint exists but
|
|
// can't produce a redeemable code.
|
|
// - 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
|
|
}
|
|
|
|
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()
|
|
}
|