mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 18:05:11 +03:00
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Feature — a new UI Library block. Stacked on #49573 (already in main) Fixes AI-1064 ## What is the new behavior? Adds `headless-app-tanstack`: customers sign in, authorize an MCP client, and use the product through agent tool calls. It composes the existing Password-Based Auth, OAuth Consent, and MCP Server blocks. - `/agents` provides a copyable connection prompt, lists OAuth authorizations, and lets customers revoke access. - The shared MCP runtime exposes `whoami` plus example task CRUD tools. Tools use the caller's Supabase client, with database grants and RLS enforcing ownership. - A root-level `supabase/` directory supplies local Auth/OAuth configuration, a declarative tasks schema, and Edge Function files, including `.env.example`. - Docs cover local setup, signing keys, migrations, environment configuration, deployment, and extending the tools. `/example/headless-app` previews the sign-in, consent, connect, and connected states. Shared block fixes make a fresh install work: - Explicit public URL resolution fixes OAuth discovery in local Edge Runtime when middleware runtime detection fails. Both external OAuth access tokens and ordinary authenticated app session tokens remain supported; embedded agents do not need an additional consent flow. - Registry targets keep backend files outside `src/`, and generated consumer routes omit source-only TypeScript suppressions. - Signup respects `auth.email.enable_confirmations`; sign-in/signup preserve the return destination. Missing consent IDs retain the existing error state without serializing `null` into the URL. ## How to test Use the UI Library on **staging** and follow the block pages' instructions. 1. Open the **Headless App** block page for TanStack Start. Install it into a fresh app and follow the setup instructions through connecting an MCP client. 2. Sign up, open `/agents`, and use the connection prompt to authorize a client. Call `whoami`, then create, list, update, and delete a task. 3. Confirm the client appears on `/agents`. Revoke access and verify it disappears and token refresh fails. An existing access token can continue working until it expires. 4. Follow the **MCP Server** block page's embedded-agent instructions using an authenticated app session. Confirm tools work without another OAuth consent flow and `whoami` returns `client_id: null`. 5. With a second user, confirm each user can only access their own tasks. Check that signup behaves correctly for the configured email-confirmation setting. 6. Check the Headless App preview states and run the installed app's typecheck and production build. ## Validation performed Fresh local installation and browser/SDK verification passed: 26 live MCP/Data API checks, 10 Deno tests, and 7 connection-page component tests. Also passed UI Library typecheck, targeted lint, registry/Markdown builds, and fresh consumer typecheck/production build. Both OAuth and ordinary app session authentication were exercised. Hosted deployment and consuming the confirmation-email link were not tested. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added a TanStack Headless App example with sign-in, OAuth consent, MCP connection, and connected-agent screens. - Added task management tools for listing, creating, updating, and deleting tasks through MCP. - Added connected-agent management, including server URL and prompt copying, refresh, and access revocation. - Added a new Headless App registry block and documentation. - **Bug Fixes** - Preserved intended destinations through sign-up, email confirmation, and protected-route login redirects. - Improved OAuth discovery URL handling across forwarded-host deployments. - **Documentation** - Updated setup, environment, deployment, and Supabase CLI guidance for headless apps and MCP servers. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Saxon Fletcher <SaxonF@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: repro <repro@local> Co-authored-by: Raúl Barroso <code@raulb.dev>
158 lines
6.4 KiB
TypeScript
158 lines
6.4 KiB
TypeScript
import type { OAuthGrant } from '@supabase/supabase-js'
|
|
import { act } from 'react'
|
|
import { createRoot, type Root } from 'react-dom/client'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { ConnectedAgents } from '@/registry/default/blocks/headless-app-tanstack/components/connected-agents'
|
|
|
|
const { listGrants, revokeGrant } = vi.hoisted(() => ({
|
|
listGrants: vi.fn(),
|
|
revokeGrant: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/registry/default/clients/tanstack/lib/supabase/client', () => ({
|
|
createClient: () => ({ auth: { oauth: { listGrants, revokeGrant } } }),
|
|
}))
|
|
|
|
const serverUrl = 'https://example.supabase.co/functions/v1/mcp-server'
|
|
const grant: OAuthGrant = {
|
|
client: { id: 'test-agent', name: 'Test agent', uri: '', logo_uri: '' },
|
|
granted_at: '2026-09-01T00:00:00Z',
|
|
scopes: ['openid'],
|
|
}
|
|
const success = (grants: OAuthGrant[]) => ({ data: grants, error: null })
|
|
|
|
let container: HTMLDivElement
|
|
let root: Root
|
|
let writeText: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
vi.resetAllMocks()
|
|
listGrants.mockResolvedValue(success([]))
|
|
revokeGrant.mockResolvedValue({ error: null })
|
|
writeText = vi.fn().mockResolvedValue(undefined)
|
|
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
|
container = document.createElement('div')
|
|
document.body.append(container)
|
|
root = createRoot(container)
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await act(async () => root.unmount())
|
|
container.remove()
|
|
})
|
|
|
|
const render = async () => {
|
|
await act(async () =>
|
|
root.render(<ConnectedAgents mcpServerUrl={serverUrl} productName="Acme" />)
|
|
)
|
|
}
|
|
|
|
const button = (label: string) => {
|
|
const match = Array.from(container.querySelectorAll('button')).find(
|
|
(button) => button.textContent === label
|
|
)
|
|
if (!match) throw new Error(`Missing button: ${label}`)
|
|
return match
|
|
}
|
|
|
|
const click = async (label: string) => {
|
|
await act(async () => button(label).click())
|
|
}
|
|
|
|
describe('Headless app connections', () => {
|
|
it('keeps the server URL available after an agent is authorized', async () => {
|
|
listGrants.mockResolvedValue(success([grant]))
|
|
await render()
|
|
expect(container.textContent).toContain(serverUrl)
|
|
expect(container.textContent).toContain('Test agent')
|
|
await click('Copy URL')
|
|
expect(writeText).toHaveBeenCalledWith(serverUrl)
|
|
await click('Copy prompt')
|
|
expect(writeText).toHaveBeenLastCalledWith(expect.stringContaining('Then call whoami'))
|
|
expect(writeText).toHaveBeenLastCalledWith(expect.stringContaining(serverUrl))
|
|
})
|
|
|
|
it('shows a recovery message if clipboard access is denied', async () => {
|
|
writeText.mockRejectedValue(new Error('Clipboard denied'))
|
|
await render()
|
|
await click('Copy URL')
|
|
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
|
|
'Select and copy the server URL above.'
|
|
)
|
|
expect(button('Copy URL').disabled).toBe(false)
|
|
writeText.mockResolvedValue(undefined)
|
|
await click('Copy URL')
|
|
expect(container.querySelector('[role="alert"]')).toBeNull()
|
|
})
|
|
|
|
it('distinguishes loading and failure from an empty grant list, and supports retry', async () => {
|
|
const pending = Promise.withResolvers<ReturnType<typeof success>>()
|
|
listGrants.mockReturnValueOnce(pending.promise)
|
|
await render()
|
|
expect(container.textContent).toContain('Loading connected agents...')
|
|
expect(container.textContent).not.toContain('No agents authorized yet.')
|
|
await act(async () => pending.reject(new Error('Network unavailable')))
|
|
expect(container.textContent).toContain('Unable to load connected agents. Network unavailable')
|
|
expect(container.textContent).not.toContain('No agents authorized yet.')
|
|
expect(container.textContent).toContain(serverUrl)
|
|
await click('Refresh')
|
|
expect(container.textContent).toContain('No agents authorized yet.')
|
|
expect(container.querySelector('[role="alert"]')).toBeNull()
|
|
})
|
|
|
|
it('refreshes grants when the customer returns from authorizing an agent', async () => {
|
|
listGrants.mockResolvedValueOnce(success([])).mockResolvedValueOnce(success([grant]))
|
|
await render()
|
|
await act(async () => window.dispatchEvent(new Event('focus')))
|
|
expect(listGrants).toHaveBeenCalledTimes(2)
|
|
expect(container.textContent).toContain('Test agent')
|
|
})
|
|
|
|
it('does not allow a stale list response to restore a revoked grant', async () => {
|
|
const pending = Promise.withResolvers<ReturnType<typeof success>>()
|
|
listGrants.mockResolvedValueOnce(success([grant])).mockReturnValueOnce(pending.promise)
|
|
await render()
|
|
// Start revocation and an older list request in the same render window.
|
|
await act(async () => {
|
|
window.dispatchEvent(new Event('focus'))
|
|
button('Revoke access').click()
|
|
})
|
|
expect(revokeGrant).toHaveBeenCalledWith({ clientId: grant.client.id })
|
|
expect(container.textContent).not.toContain('Test agent')
|
|
await act(async () => pending.resolve(success([grant])))
|
|
expect(container.textContent).not.toContain('Test agent')
|
|
expect(container.textContent).toContain('No agents authorized yet.')
|
|
})
|
|
|
|
it('prevents duplicate revocations and recovers after a rejected request', async () => {
|
|
const pending = Promise.withResolvers<{ error: null }>()
|
|
listGrants.mockResolvedValue(success([grant]))
|
|
revokeGrant.mockReturnValueOnce(pending.promise)
|
|
await render()
|
|
await act(async () => {
|
|
button('Revoke access').click()
|
|
button('Revoke access').click()
|
|
})
|
|
expect(revokeGrant).toHaveBeenCalledTimes(1)
|
|
expect(button('Revoking access...').disabled).toBe(true)
|
|
await act(async () => pending.reject(new Error('Network unavailable')))
|
|
expect(container.textContent).toContain('Test agent')
|
|
expect(container.textContent).toContain('Unable to revoke access. Network unavailable')
|
|
expect(button('Revoke access').disabled).toBe(false)
|
|
await click('Revoke access')
|
|
expect(container.textContent).not.toContain('Test agent')
|
|
})
|
|
|
|
it('keeps the grant when Supabase returns a revocation error', async () => {
|
|
listGrants.mockResolvedValue(success([grant]))
|
|
revokeGrant.mockResolvedValueOnce({ error: new Error('Session expired') })
|
|
await render()
|
|
await click('Revoke access')
|
|
expect(container.textContent).toContain('Unable to revoke access. Session expired')
|
|
expect(container.textContent).toContain('Test agent')
|
|
expect(button('Revoke access').disabled).toBe(false)
|
|
})
|
|
})
|