Skip to content

feat: user editable oauth proxy server config#2122

Merged
simplesagar merged 27 commits intomainfrom
sagar/age-1737-feat-editable-oauth-proxy-server-config
Apr 9, 2026
Merged

feat: user editable oauth proxy server config#2122
simplesagar merged 27 commits intomainfrom
sagar/age-1737-feat-editable-oauth-proxy-server-config

Conversation

@simplesagar
Copy link
Copy Markdown
Member

@simplesagar simplesagar commented Apr 8, 2026

Summary

Closes AGE-1737. Adds an editable path for OAuth proxy server configuration so admins can change the audience, endpoints, scopes, auth methods, and environment slug on an existing OAuth proxy without unlinking and recreating it (which is the current pain point).

Backend

  • New Goa method updateOAuthProxyServer (POST /rpc/toolsets.updateOAuthProxyServer) — admin-only, mirrors the shape of addOAuthProxyServer
  • Three new SQLc queries in server/internal/oauth/repo/queries.sql: GetOAuthProxyProviderByServer, UpdateOAuthProxyServerAudience, UpdateOAuthProxyProviderFields. PATCH semantics via COALESCE(sqlc.narg(...), col)nil parameter = no change, empty slice = clear
  • New service method (*Service).UpdateOAuthProxyServer in its own file server/internal/toolsets/updateoauthproxyserver.go. Validates auth methods, rejects gram-managed proxies (custom-only), enforces project isolation, performs a transactional update of the server + provider rows, and does a JSON read-modify-write of the secrets JSONB blob when environment_slug changes
  • New audit action toolset:update_oauth_proxy mirroring the existing attach/detach event shapes
  • 11 service-level tests covering happy path, no-op short-circuit, gram rejection, missing proxy, validation failures, environment slug update, soft-delete safety, cross-project isolation, and PATCH array clearing

Frontend

  • New "Edit" button on OAuthDetailsModal's custom-OAuth branch (Gram-managed proxies stay view-only)
  • New editMode prop on ConnectOAuthModal and OAuthTabModal that pre-fills the proxy form via useEffect, disables the slug input, and dispatches useUpdateOAuthProxyServerMutation instead of the create mutation
  • Modal title and submit button labels swap between "Connect OAuth" / "Edit OAuth Proxy" and "Configure OAuth Proxy" / "Save changes"

Test Plan

  • mise build:server (or arch -arm64 go build ./server/... on Apple Silicon dev machines) — backend compiles
  • cd server && go test ./internal/toolsets/... — all 11 new UpdateOAuthProxyServer tests pass alongside existing toolsets tests
  • cd server && go test ./... — full backend test suite (1000+ tests across 51 packages) passes
  • cd client/dashboard && pnpm tsc --noEmit — dashboard typechecks
  • Manual smoke test in dev:
    • Create an MCP server with a custom OAuth proxy
    • Open OAuth details modal → click Edit → modal pre-fills with current values
    • Change scopes → click "Save changes" → success toast appears
    • Re-open details → confirm new scopes are visible
    • Verify Gram-managed OAuth proxies do NOT show an Edit button
    • Verify the audit log shows a toolset:update_oauth_proxy event

🤖 Generated with Claude Code

@linear
Copy link
Copy Markdown

linear bot commented Apr 8, 2026

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 8, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
gram-docs-redirect Ready Ready Preview, Comment Apr 9, 2026 3:52pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 8, 2026

🦋 Changeset detected

Latest commit: f6be371

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
server Minor
@gram/client Patch
dashboard Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions bot added the preview Spawn a preview environment label Apr 8, 2026
@speakeasybot
Copy link
Copy Markdown
Collaborator

speakeasybot commented Apr 8, 2026

🚀 Preview Environment (PR #2122)

Preview URL: https://pr-2122.dev.getgram.ai

Component Status Details Updated (UTC)
✅ Database Ready Existing database reused 2026-04-09 15:56:15.
✅ Images Available Container images ready 2026-04-09 15:55:57.

Gram Preview Bot

@simplesagar simplesagar requested a review from walker-tx April 8, 2026 03:33
@simplesagar simplesagar force-pushed the sagar/age-1737-feat-editable-oauth-proxy-server-config branch from c4d0fd8 to b3d4260 Compare April 8, 2026 03:37
@simplesagar simplesagar changed the title feat(toolsets): editable OAuth proxy server config feat: user editable OAuth proxy server config Apr 8, 2026
@simplesagar simplesagar marked this pull request as ready for review April 8, 2026 03:47
@simplesagar simplesagar requested review from a team as code owners April 8, 2026 03:47
Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@blacksmith-sh

This comment has been minimized.

@simplesagar simplesagar force-pushed the sagar/age-1737-feat-editable-oauth-proxy-server-config branch from a135708 to f118134 Compare April 8, 2026 18:51
Copy link
Copy Markdown
Member

@walker-tx walker-tx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks fine. Please review Devin's comments, and also resolve merge conflicts, and then I'll approve.

@simplesagar
Copy link
Copy Markdown
Member Author

@walker-tx thanks for the review — addressing your feedback:

Merge conflicts ✅ resolved. Rebased onto 1b702be83 (main) at fbf843fb1. PR is now mergeable: MERGEABLE. The rebase picked up main's new listToolsetsForOrg endpoint cleanly; auto-merge handled the cli/internal/api/toolsets.go NewClient wrapper ordering correctly.

Your inline comment on the useMemo ✅ addressed in fbf843fb1 (see my reply on that thread). You were right — the useMemo was papering over the root cause. Fixed at the effect level instead by depending on the stable inner editMode?.proxyServer reference. Cleaner, less ceremony.

Devin's remaining open comments — all either resolved or intentionally deferred:

  • ✅ Unstable editMode reference → fixed (now at the effect level, cleaner than my original useMemo)
  • ✅ Audience cannot be cleared in edit mode → fixed with a prefilled-value comparison ref (8939a5c83) that prevents silent NULL → "" mutation
  • ✅ SDK version downgrade (two related findings) → fixed by setting the version to 0.32.38 (main's + 1) after the latest rebase
  • 🚩 deferred No admin-specific role check — consistent with AddOAuthProxyServer which has the same gap. Fixing it properly requires touching both endpoints + the auth middleware; tracked for a follow-up. I dropped the misleading (admin only) suffix from the new endpoint's description in the meantime so the generated OpenAPI spec isn't lying about enforcement.
  • 🚩 deferred secrets JSONB read-modify-write race on concurrent edits — valid concern but extremely rare in practice (admin OAuth config changes don't get made concurrently). Proper fix would be SELECT ... FOR UPDATE on the provider row or using jsonb_set() server-side. Tracked for a follow-up.

Also included from my self-review (commit c6cecf4e9):

  • Audit log now captures before/after toolset snapshots via LogToolsetUpdateOAuthProxy, mirroring the LogToolsetUpdate pattern. The AudienceOnly test got new assertions decoding the snapshot blobs to verify the audience values are captured correctly. This closes a real gap — prior to this the audit log only knew "an update happened" not "what was updated."

Ready for re-review when you have a moment.

simplesagar and others added 2 commits April 8, 2026 16:57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Review feedback: document why provider_type and slug are excluded from
OAuthProxyServerUpdateForm, and align oauth_proxy_server attribute
description with AddOAuthProxyServerForm conventions.
…ion failures (AGE-1737)

Review feedback: make audit log assertions symmetric across all error
case tests. InvalidAuthMethodRejected, EmptyEnvironmentSlugRejected,
and EnvironmentSlugNotFound now match the beforeCount/afterCount
pattern already used by GramProviderRejected and EmptyFormIsNoOp.
Introduces isOAuthEditModalOpen state and a second ConnectOAuthModal
instance bound to it. Adds an Edit button to OAuthDetailsModal's
custom-OAuth branch alongside the existing Unlink button. The ConnectOAuthModal
editMode prop wiring is added in the next commit (Task 9); until then
the dashboard typecheck will fail with 'Property editMode does not exist'.
…ng (AGE-1737)

Adds editMode prop to ConnectOAuthModal and OAuthTabModal, pre-fills
the proxy form state via useEffect, dispatches useUpdateOAuthProxyServerMutation
when in edit mode, and swaps the modal title and submit button labels.
Slug and provider_type remain immutable in edit mode.
…ields unconditionally (AGE-1737)

Review feedback on Task 9:
- handleProxySubmit's environment slug guard was unconditional, blocking
  edit-mode submission when the prefilled slug was empty even though the
  button's disabled condition correctly exempted edit mode. Add the same
  !editMode gate to the runtime guard.
- The useEffect prefill skipped provider-level fields when
  oauthProxyProviders[0] was absent, leaving stale state. Remove the
  if (provider) wrapper and use optional chaining so all form fields
  are reset on every editMode change.
Adds the three test cases listed in the spec but missing from Task 5:
- ClearScopes: verifies that an explicit empty slice clears scopes
  (PATCH semantics distinguishing nil vs empty)
- SoftDeletedProxyRejected: verifies updates against a toolset whose
  proxy association was removed return 404 (no proxy attached)
- WrongProjectIsolation: verifies cross-project lookups are rejected
  with CodeNotFound

All 11/11 tests pass.
After rebasing onto origin/main, regenerated openapi spec, SDK, and
speakeasy workflow lock to reconcile with main's recent changes. The
SDK package.json version was corrected from 0.32.28 (artifact of
multiple pre-commit regen runs during the original feature work) to
0.32.21 (main's 0.32.20 + one canonical Speakeasy bump).
- cli/internal/api/toolsets.go: pass the new UpdateOAuthProxyServer
  endpoint to toolsets.NewClient (the generated NewClient now requires
  11 endpoints after Goa added the update method)
- updateoauthproxyserver_test.go: replace require.True(errors.As(...))
  with require.ErrorAs(...) to satisfy the testifylint linter, and
  drop the now-unused errors import
Two real bugs flagged by Devin Review:

1. Unstable editMode object reference (MCPDetails.tsx)
   The editMode prop was created as an inline object literal in JSX,
   producing a new reference on every parent re-render. This caused
   OAuthTabModal's useEffect([editMode]) to fire on every parent
   re-render, wiping any user-typed input from the form. Memoize the
   editMode object via useMemo so its reference is only invalidated
   when toolset.oauthProxyServer actually changes.

2. Audience field cannot be cleared in edit mode (MCPDetails.tsx)
   The submit handler used 'audience: proxyAudience || undefined'
   which coerced empty strings to undefined, causing the field to be
   omitted from the request body. The server then skipped the update
   and the user could never clear a previously-set audience. Pass
   proxyAudience as-is so an empty input is sent and applied.

Plus: correct an SDK version downgrade (also flagged by Devin)
Main's client/sdk is at 0.32.28 across gen.yaml/jsr.json/package.json/
config.ts/gen.lock, but the post-rebase regen produced 0.32.21 due
to the rebase taking the pre-rebase gen.yaml (at 0.32.20) via
-X theirs and Speakeasy bumping from there. Manually set the version
to 0.32.29 (= main's 0.32.28 + 1 for the new updateOAuthProxyServer
endpoint surface).
…E-1737)

Devin Review flagged that the previous fix introduced a worse bug:
removing '|| undefined' from audience meant that opening the edit
modal on a proxy with audience=NULL would silently submit
'audience: ""' on save (because the form prefills empty-string for
null DB values), mutating NULL → "" server-side. Empty audience and
absent audience are NOT equivalent in OAuth authorization URL
construction (?audience= vs no audience= param).

Fix: snapshot the prefilled audience in a useRef and only send the
audience field in the update request when the user actually changed
it. This preserves the user's explicit clear (still works) without
silently mutating untouched audience values.
…737)

Two improvements from self-review:

1. Drop '(admin only)' from updateOAuthProxyServer description
   The endpoint's Security() block is just session + project_slug (or
   apikey + producer scope) — there's no actual admin role enforcement.
   The description was misleading callers reading the generated OpenAPI
   spec. Remove the misleading suffix; proper admin enforcement across
   both add and update endpoints is tracked as a follow-up ticket since
   AddOAuthProxyServer has the same gap.

2. Capture before/after toolset snapshots in LogToolsetUpdateOAuthProxy
   Previously the audit log captured only oauth_proxy_server_id, slug,
   and toolset_version_after in metadata. For an audit log this is too
   thin — a 'who changed our prod audience' investigation cannot
   reconstruct the configuration from the audit row alone. Mirror the
   LogToolsetUpdate pattern: clone the pre-mutation *types.Toolset,
   strip the Tools field to avoid audit bloat, and serialize both
   snapshots via marshalAuditPayload alongside the existing metadata.

   The AudienceOnly test gets new assertions that decode the snapshot
   blobs and verify the before/after audience values are captured
   (previously the test only asserted the snapshot fields were nil).
Second rebase onto main brought in listToolsetsForOrg (#2128 area)
which required regenerating Goa service and SDK outputs. The
-X theirs strategy took my pre-rebase versions of several generated
files, so a fresh mise gen:goa-server and mise gen:sdk run was
needed to reconcile with main.

Also: corrected the SDK version to 0.32.38 (= main's 0.32.37 + 1 for
the new updateOAuthProxyServer endpoint). Speakeasy's fresh regen
produced 0.32.31 because the rebase took gen.yaml from my pre-rebase
branch via -X theirs; manually bumping to 0.32.38 across gen.yaml,
gen.lock, jsr.json, package.json, and config.ts. This is the same
pattern as the previous rebase — worth documenting in CLAUDE.md as
a rebase-time gotcha for branches that touch the SDK.
…ad of the parent (AGE-1737)

Review feedback from Walker: the useMemo I added to stabilize the
editMode reference was papering over the root cause. The underlying
issue is that the useEffect's dep array was keyed on editMode (a
wrapper object recreated inline in JSX on every parent render)
instead of the stable inner reference editMode.proxyServer.

Fix at the source:
- Remove the useMemo from MCPSettingsTab and restore the inline
  editMode literal in JSX
- In OAuthTabModal, extract editProxyServer = editMode?.proxyServer
  and use that as the useEffect dependency

Why this works: toolset.oauthProxyServer has a stable reference
across parent re-renders thanks to react-query's default
structuralSharing (if the underlying data hasn't changed, the nested
ref is preserved). So the effect only re-fires when the actual proxy
server data changes — not when the parent re-renders for unrelated
reasons. User-typed form state is preserved across window-focus
refetches and other parent render triggers.

Tradeoff vs useMemo approach: this eliminates a layer of indirection
and moves the 'why' into the effect's dep array comment, which is
where a future reader would naturally look. One less thing to
maintain in MCPSettingsTab.
…GE-1737)

Third rebase onto main (now at 1b702be..b328938) pulled in more
Speakeasy auto-bumps. Main's SDK version files are intentionally
divergent:
- gen.yaml/gen.lock/jsr.json/config.ts: 0.32.41 (Speakeasy-managed)
- package.json: 0.32.38 (Changesets-managed, last released)

Speakeasy's mise gen:sdk propagates one version across all 5 files
during regen, but Changesets releases reset package.json to its own
tracking. This commit matches main's split:
- 4 Speakeasy files → 0.32.42 (= main's 0.32.41 + 1 for the endpoint)
- package.json → 0.32.38 (matches main; Changesets will handle the
  future bump via the existing editable-oauth-proxy-server.md entry)

Also refreshed server/gen/http/openapi3.json and .speakeasy/workflow.lock
from the goa + sdk regen.
@simplesagar simplesagar force-pushed the sagar/age-1737-feat-editable-oauth-proxy-server-config branch from fbf843f to 346494d Compare April 9, 2026 00:00
devin-ai-integration[bot]

This comment was marked as resolved.

…r (AGE-1737)

Devin review finding: AddOAuthProxyServer validates that
authorization_endpoint and token_endpoint are non-empty for custom
providers (impl.go:1008-1012), but UpdateOAuthProxyServer had no
equivalent guard. An API caller bypassing the UI could send
authorization_endpoint: "" or token_endpoint: "", which flows through
conv.PtrToPGText as pgtype.Text{Valid: true, String: ""}, and the
COALESCE in UpdateOAuthProxyProviderFields would overwrite the
existing endpoint with empty string — breaking the OAuth proxy for
all users of the toolset.

Add empty-string guards pre-transaction alongside the existing
token_endpoint_auth_methods_supported allowlist check. The UI already
prevents this case, so impact was limited to direct API callers, but
API boundaries should defend themselves.

Plus two new tests mirroring EmptyEnvironmentSlugRejected:
- EmptyAuthorizationEndpointRejected
- EmptyTokenEndpointRejected

Both assert no audit row is written on validation failure.
devin-ai-integration[bot]

This comment was marked as resolved.

@simplesagar simplesagar changed the title feat: user editable OAuth proxy server config feat: user editable oauth proxy server config Apr 9, 2026
…xyServer (AGE-1737)

Devin review finding: AddOAuthProxyServer requires non-empty
scopes_supported and token_endpoint_auth_methods_supported for custom
providers (impl.go:1013-1018), but UpdateOAuthProxyServer allowed
sending [] to clear them entirely. An OAuth proxy with zero scopes
or zero auth methods is invalid and can't function.

Add pre-transaction guards rejecting empty (non-nil) arrays for both
fields. PATCH semantics are preserved: nil = no change (skip), but
empty array is now a validation error rather than a clear.

This changes the previously-documented behavior from the original
spec (Q3: 'empty array = clear'). Business validation (non-empty
scopes required for custom providers) overrides the generic PATCH
semantic — you cannot update a proxy into an invalid state.

Replaced the ClearScopes happy-path test with EmptyScopesRejected
and added EmptyAuthMethodsRejected. Test suite now 14 cases.
@simplesagar simplesagar merged commit 3a3acd3 into main Apr 9, 2026
35 checks passed
@simplesagar simplesagar deleted the sagar/age-1737-feat-editable-oauth-proxy-server-config branch April 9, 2026 19:17
@github-actions github-actions bot locked and limited conversation to collaborators Apr 9, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

preview Spawn a preview environment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants