Files
pad/internal/server
xarmian fc6afd01be feat(connect): unified Connect-to-agent modal + claim-code endpoint (TASK-1525) (#586)
* 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.
2026-05-18 10:25:08 -04:00
..