diff --git a/backend/src/routes/oauth.test.ts b/backend/src/routes/oauth.test.ts new file mode 100644 index 00000000..c90d5107 --- /dev/null +++ b/backend/src/routes/oauth.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, vi } from 'vitest' +import { Hono } from 'hono' +import { createOAuthRoutes, buildOAuthFailure } from './oauth' +import { createStubOpenCodeClient } from '../../test/helpers/stub-opencode-client' +import { PROVIDER_AUTH_ERROR_NAMES } from '../../../shared/src/schemas/auth' + +function createTestApp(clientOverrides: Parameters[0] = {}): Hono { + const app = new Hono() + app.route('/oauth', createOAuthRoutes(createStubOpenCodeClient(clientOverrides))) + return app +} + +function upstreamFailure(body: unknown, status = 400): Parameters[0] { + return { + forward: vi.fn(async () => new Response(JSON.stringify(body), { status })), + } +} + +describe('buildOAuthFailure against captured opencode 1.18.7 responses', () => { + it('classifies a real ProviderAuthOauthMissing callback response', () => { + const failure = buildOAuthFailure( + '{"name":"ProviderAuthOauthMissing","data":{"providerID":"openai"}}', + 400, + 'callback', + ) + expect(failure.status).toBe(400) + expect(failure.payload).toEqual({ + error: 'OAuth callback failed', + code: 'ProviderAuthOauthMissing', + }) + }) + + it('flattens the multiline message a real BadRequest payload carries', () => { + const failure = buildOAuthFailure( + '{"name":"BadRequest","data":{"message":"Missing key\\n at [\\"method\\"]","kind":"Payload"}}', + 400, + 'callback', + ) + expect(failure.payload.code).toBe('BadRequest') + expect(failure.payload.detail).toBe('Missing key at ["method"]') + }) + + it('falls back without a code for a real UnknownError defect response', () => { + const failure = buildOAuthFailure( + '{"name":"UnknownError","data":{"message":"Unexpected server error. Check server logs for details.","ref":"err_00568566"}}', + 500, + 'authorize', + ) + expect(failure.status).toBe(500) + expect(failure.payload).toEqual({ error: 'OAuth authorization failed' }) + }) +}) + +describe('buildOAuthFailure', () => { + it('forwards every upstream ProviderAuthError name as the code', () => { + for (const name of PROVIDER_AUTH_ERROR_NAMES) { + const failure = buildOAuthFailure(JSON.stringify({ name, data: {} }), 400, 'authorize') + expect(failure.payload.code).toBe(name) + expect(failure.status).toBe(400) + } + }) + + it('forwards the tagged InvalidRequestError shape', () => { + const failure = buildOAuthFailure( + JSON.stringify({ _tag: 'InvalidRequestError', message: 'method out of range' }), + 400, + 'authorize', + ) + expect(failure.payload.code).toBe('InvalidRequestError') + expect(failure.payload.detail).toBe('method out of range') + }) + + it('surfaces upstream message and field as detail for validation failures', () => { + const failure = buildOAuthFailure( + JSON.stringify({ + name: 'ProviderAuthValidationFailed', + data: { field: 'apiKey', message: 'must start with sk-' }, + }), + 400, + 'authorize', + ) + expect(failure.payload.code).toBe('ProviderAuthValidationFailed') + expect(failure.payload.detail).toBe('must start with sk- — field: apiKey') + }) + + it('omits detail when upstream carries no message or field', () => { + const failure = buildOAuthFailure( + JSON.stringify({ name: 'ProviderAuthOauthCallbackFailed', data: {} }), + 400, + 'callback', + ) + expect(failure.payload.detail).toBeUndefined() + expect(failure.payload.error).toBe('OAuth callback failed') + }) + + it('falls back to the phase generic without a code for non-JSON bodies', () => { + const failure = buildOAuthFailure('upstream exploded', 500, 'authorize') + expect(failure.payload).toEqual({ error: 'OAuth authorization failed' }) + expect(failure.status).toBe(500) + }) + + it('falls back to the phase generic for JSON that is not the upstream contract', () => { + const failure = buildOAuthFailure(JSON.stringify({ name: 'SomethingElse' }), 400, 'callback') + expect(failure.payload).toEqual({ error: 'OAuth callback failed' }) + }) + + it('replaces out-of-range upstream statuses with 502', () => { + expect(buildOAuthFailure('boom', 200, 'authorize').status).toBe(502) + expect(buildOAuthFailure('boom', 0, 'authorize').status).toBe(502) + }) +}) + +describe('oauth routes /auth-methods', () => { + it('wraps the upstream catalogue as { providers }', async () => { + const upstream = { anthropic: [{ type: 'oauth', label: 'Anthropic OAuth' }] } + const app = createTestApp({ + forward: vi.fn(async () => new Response(JSON.stringify(upstream), { status: 200 })), + }) + const res = await app.request('/oauth/auth-methods') + expect(res.status).toBe(200) + const data = (await res.json()) as { providers: typeof upstream } + expect(data.providers).toEqual(upstream) + }) + + it('returns 500 when upstream fails', async () => { + const app = createTestApp({ + forward: vi.fn(async () => new Response('internal failure', { status: 500 })), + }) + const res = await app.request('/oauth/auth-methods') + expect(res.status).toBe(500) + }) +}) + +describe('oauth routes /:id/oauth/authorize', () => { + const validBody = { method: 0 } + + function authorizeRequest(app: Hono, body: unknown = validBody) { + return app.request('/oauth/openai/oauth/authorize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + it('forwards the validated body and returns the parsed response', async () => { + const upstream = { + url: 'https://auth.example.com', + method: 'auto' as const, + instructions: 'Open this page', + } + const forward = vi.fn(async () => new Response(JSON.stringify(upstream), { status: 200 })) + const app = createTestApp({ forward }) + const res = await authorizeRequest(app) + expect(res.status).toBe(200) + expect(await res.json()).toEqual(upstream) + expect(forward).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/provider/openai/oauth/authorize', + }), + ) + }) + + it('returns 400 on invalid body', async () => { + const res = await authorizeRequest(createTestApp(), {}) + expect(res.status).toBe(400) + }) + + it('forwards the upstream error code and 400 status', async () => { + const app = createTestApp( + upstreamFailure({ name: 'ProviderAuthOauthMissing', data: { providerID: 'openai' } }), + ) + const res = await authorizeRequest(app) + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ + error: 'OAuth authorization failed', + code: 'ProviderAuthOauthMissing', + }) + }) + + it('falls back to the generic authorize message for unrecognized upstream errors', async () => { + const app = createTestApp(upstreamFailure('totally unexpected', 500)) + const res = await authorizeRequest(app) + expect(res.status).toBe(500) + expect(await res.json()).toEqual({ error: 'OAuth authorization failed' }) + }) +}) + +describe('oauth routes /:id/oauth/callback', () => { + const validBody = { method: 0 } + + function callbackRequest(app: Hono, body: unknown = validBody) { + return app.request('/oauth/openai/oauth/callback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + it('forwards the validated body and returns upstream data', async () => { + const forward = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })) + const app = createTestApp({ forward }) + const res = await callbackRequest(app) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true }) + expect(forward).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/provider/openai/oauth/callback', + }), + ) + }) + + it('returns 400 on invalid callback body', async () => { + const res = await callbackRequest(createTestApp(), { foo: 'bar' }) + expect(res.status).toBe(400) + }) + + it('forwards the upstream code and validation detail', async () => { + const app = createTestApp( + upstreamFailure({ + name: 'ProviderAuthValidationFailed', + data: { field: 'code', message: 'code is not valid' }, + }), + ) + const res = await callbackRequest(app) + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ + error: 'OAuth callback failed', + code: 'ProviderAuthValidationFailed', + detail: 'code is not valid — field: code', + }) + }) + + it('falls back to the generic callback message for unrecognized upstream errors', async () => { + const app = createTestApp(upstreamFailure('unknown boom', 500)) + const res = await callbackRequest(app) + expect(res.status).toBe(500) + expect(await res.json()).toEqual({ error: 'OAuth callback failed' }) + }) +}) diff --git a/backend/src/routes/oauth.ts b/backend/src/routes/oauth.ts index 516e4ae9..94393c85 100644 --- a/backend/src/routes/oauth.ts +++ b/backend/src/routes/oauth.ts @@ -1,15 +1,65 @@ import { Hono } from 'hono' +import type { ContentfulStatusCode } from 'hono/utils/http-status' import type { OpenCodeClient } from '../services/opencode/client' import { z } from 'zod' import { logger } from '../utils/logger' import { OAuthAuthorizeRequestSchema, OAuthAuthorizeResponseSchema, - OAuthCallbackRequestSchema + OAuthCallbackRequestSchema, + OpenCodeOAuthErrorSchema, + oauthErrorCode } from '../../../shared/src/schemas/auth' +import type { OAuthErrorCode } from '../../../shared/src/schemas/auth' import { reloadOpenCodeConfig } from '../services/opencode-restart' import type { OpenCodeSupervisor } from '../services/opencode-supervisor' +type OAuthPhase = 'authorize' | 'callback' + +const PHASE_FALLBACK: Record = { + authorize: 'OAuth authorization failed', + callback: 'OAuth callback failed', +} + +interface OAuthFailure { + payload: { error: string; code?: OAuthErrorCode; detail?: string } + status: ContentfulStatusCode +} + +export function buildOAuthFailure(body: string, upstreamStatus: number, phase: OAuthPhase): OAuthFailure { + const status: ContentfulStatusCode = + upstreamStatus >= 400 && upstreamStatus <= 599 ? (upstreamStatus as ContentfulStatusCode) : 502 + + let parsed: unknown + try { + parsed = JSON.parse(body) + } catch { + return { payload: { error: PHASE_FALLBACK[phase] }, status } + } + + const result = OpenCodeOAuthErrorSchema.safeParse(parsed) + if (!result.success) { + return { payload: { error: PHASE_FALLBACK[phase] }, status } + } + + const error = result.data + const isTagged = '_tag' in error + const message = isTagged ? error.message : error.data.message + const field = isTagged ? error.field : error.data.field + const detail = [message?.replace(/\s+/g, ' ').trim(), field ? `field: ${field}` : undefined] + .filter(Boolean) + .join(' — ') + + return { + payload: { + error: PHASE_FALLBACK[phase], + code: oauthErrorCode(error), + ...(detail ? { detail } : {}), + }, + status, + } +} + export function createOAuthRoutes(openCodeClient: OpenCodeClient, openCodeSupervisor?: OpenCodeSupervisor) { const app = new Hono() @@ -29,7 +79,8 @@ export function createOAuthRoutes(openCodeClient: OpenCodeClient, openCodeSuperv if (!response.ok) { const error = await response.text() logger.error(`OAuth authorize failed for ${providerId}:`, error) - return c.json({ error: 'OAuth authorization failed' }, 500) + const failure = buildOAuthFailure(error, response.status, 'authorize') + return c.json(failure.payload, failure.status) } const data = await response.json() @@ -61,7 +112,8 @@ export function createOAuthRoutes(openCodeClient: OpenCodeClient, openCodeSuperv if (!response.ok) { const error = await response.text() logger.error(`OAuth callback failed for ${providerId}:`, error) - return c.json({ error: 'OAuth callback failed' }, 500) + const failure = buildOAuthFailure(error, response.status, 'callback') + return c.json(failure.payload, failure.status) } const data = await response.json() diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 1d529e06..89a0e586 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -578,20 +578,27 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic }, 500) } - const contentToWrite = patchResult.removedFields && patchResult.removedFields.length > 0 + const removedFields = patchResult.removedFields ?? [] + const contentToWrite = removedFields.length > 0 ? JSON.stringify(patchResult.appliedConfig ?? config.content, null, 2) : config.rawContent - + await writeFileContent(configPath, contentToWrite) logger.info(`Wrote default config to: ${configPath}`) - - if (patchResult.removedFields && patchResult.removedFields.length > 0) { - logger.info(`Config applied with auto-removed fields: ${patchResult.removedFields.join(', ')}`) - return c.json({ ...config, removedFields: patchResult.removedFields }) + + if (removedFields.length > 0) { + logger.info(`Config applied with auto-removed fields: ${removedFields.join(', ')}`) + const persisted = settingsService.updateOpenCodeConfig(configName, { content: contentToWrite }, userId) + if (!persisted) { + return c.json({ + error: 'OpenCode config was removed while applying recovered fields', + }, 409) + } + return c.json({ ...persisted, removedFields }) } } } - + return c.json(config) } catch (error) { logger.error('Failed to update OpenCode config:', error) diff --git a/backend/test/routes/settings.test.ts b/backend/test/routes/settings.test.ts index 17fb097f..d0598649 100644 --- a/backend/test/routes/settings.test.ts +++ b/backend/test/routes/settings.test.ts @@ -425,6 +425,122 @@ describe('Settings Routes - OpenCode Upgrade', () => { ) expect(json.removedFields).toEqual(['command.review']) }) + + it('persists recovery-cleaned content back to the DB after a default-config PUT with removedFields (audit regression)', async () => { + mockGetOpenCodeConfigByName.mockReturnValue({ + id: 2, + name: 'cleaned', + content: {}, + rawContent: '{}', + isValid: true, + isDefault: true, + createdAt: 1, + updatedAt: 1, + }) + const firstConfig = { + id: 2, + name: 'cleaned', + content: { command: { review: true }, theme: 'dark' }, + rawContent: '{"command":{"review":true},"theme":"dark"}', + isValid: true, + isDefault: true, + createdAt: 1, + updatedAt: 2, + } + const persistedConfig = { + ...firstConfig, + content: { theme: 'dark' }, + rawContent: '{\n "theme": "dark"\n}', + updatedAt: 3, + } + mockUpdateOpenCodeConfig + .mockReturnValueOnce(firstConfig) + .mockReturnValueOnce(persistedConfig) + mockPatchConfigWithRecovery.mockResolvedValueOnce({ + success: true, + appliedConfig: { theme: 'dark' }, + removedFields: ['command.review'], + }) + + const req = new Request('http://localhost/opencode-configs/cleaned', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: '{"command":{"review":true},"theme":"dark"}', + isDefault: true, + }), + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(200) + expect(json.removedFields).toEqual(['command.review']) + expect(mockUpdateOpenCodeConfig).toHaveBeenCalledTimes(2) + expect(mockUpdateOpenCodeConfig).toHaveBeenNthCalledWith( + 2, + 'cleaned', + { content: '{\n "theme": "dark"\n}' }, + 'default', + ) + expect(mockWriteFileContent).toHaveBeenCalledWith( + '/tmp/test-workspace/.config/opencode.json', + '{\n "theme": "dark"\n}', + ) + }) + + it('returns 409 instead of 200 when the recovery persistence write reports the config row was removed concurrently (audit regression)', async () => { + mockGetOpenCodeConfigByName.mockReturnValue({ + id: 2, + name: 'cleaned', + content: {}, + rawContent: '{}', + isValid: true, + isDefault: true, + createdAt: 1, + updatedAt: 1, + }) + const firstConfig = { + id: 2, + name: 'cleaned', + content: { command: { review: true }, theme: 'dark' }, + rawContent: '{"command":{"review":true},"theme":"dark"}', + isValid: true, + isDefault: true, + createdAt: 1, + updatedAt: 2, + } + mockUpdateOpenCodeConfig + .mockReturnValueOnce(firstConfig) + .mockReturnValueOnce(null) + mockPatchConfigWithRecovery.mockResolvedValueOnce({ + success: true, + appliedConfig: { theme: 'dark' }, + removedFields: ['command.review'], + }) + + const req = new Request('http://localhost/opencode-configs/cleaned', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: '{"command":{"review":true},"theme":"dark"}', + isDefault: true, + }), + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(409) + expect(json.error).toBe( + 'OpenCode config was removed while applying recovered fields', + ) + expect(mockUpdateOpenCodeConfig).toHaveBeenCalledTimes(2) + expect(mockUpdateOpenCodeConfig).toHaveBeenNthCalledWith( + 2, + 'cleaned', + { content: '{\n "theme": "dark"\n}' }, + 'default', + ) + }) }) describe('OpenCode import routes', () => { diff --git a/backend/test/services/opencode-config-provider-schema.test.ts b/backend/test/services/opencode-config-provider-schema.test.ts new file mode 100644 index 00000000..2d316d56 --- /dev/null +++ b/backend/test/services/opencode-config-provider-schema.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { OpenCodeConfigSchema } from "@opencode-manager/shared/schemas"; + +describe("OpenCodeConfigSchema - provider api/npm round-trip", () => { + it("preserves a provider-level api URL through parse", () => { + const input = { + provider: { + "my-api": { + name: "My API", + api: "https://api.example.com/v1", + options: { baseURL: "https://api.example.com/v1" }, + }, + }, + }; + const parsed = OpenCodeConfigSchema.parse(input); + expect(parsed.provider?.["my-api"]?.api).toBe("https://api.example.com/v1"); + expect(parsed.provider?.["my-api"]?.options?.baseURL).toBe("https://api.example.com/v1"); + }); + + it("preserves a provider-level npm package through parse", () => { + const input = { + provider: { + "my-npm": { + name: "My NPM Provider", + npm: "@scope/opencode-provider", + }, + }, + }; + const parsed = OpenCodeConfigSchema.parse(input); + expect(parsed.provider?.["my-npm"]?.npm).toBe("@scope/opencode-provider"); + }); + + it("preserves whitelist and blacklist model filters through parse", () => { + const input = { + provider: { + openai: { + whitelist: ["gpt-4o", "gpt-4o-mini"], + blacklist: ["gpt-3.5"], + }, + }, + }; + const parsed = OpenCodeConfigSchema.parse(input); + expect(parsed.provider?.openai?.whitelist).toEqual(["gpt-4o", "gpt-4o-mini"]); + expect(parsed.provider?.openai?.blacklist).toEqual(["gpt-3.5"]); + }); + + it("round-trips a full provider-with-models config without losing api or npm", () => { + const input = { + "$schema": "https://opencode.ai/config.json", + provider: { + custom: { + name: "Custom", + api: "https://api.custom.example/v1", + npm: "custom-provider", + models: { + "custom-1": { + id: "custom-1", + name: "Custom 1", + limit: { context: 200000, output: 8192 }, + }, + }, + }, + }, + }; + const parsed = OpenCodeConfigSchema.parse(input); + const roundTripped = OpenCodeConfigSchema.parse(JSON.parse(JSON.stringify(parsed))); + expect(roundTripped.provider?.custom?.api).toBe("https://api.custom.example/v1"); + expect(roundTripped.provider?.custom?.npm).toBe("custom-provider"); + expect(roundTripped.provider?.custom?.models?.["custom-1"]?.id).toBe("custom-1"); + }); +}); diff --git a/frontend/src/api/oauth.ts b/frontend/src/api/oauth.ts index f0e62249..19a523d2 100644 --- a/frontend/src/api/oauth.ts +++ b/frontend/src/api/oauth.ts @@ -1,6 +1,6 @@ import { API_BASE_URL } from "@/config" import type { components, operations } from "./opencode-types" -import { fetchWrapper, FetchError } from "./fetchWrapper" +import { fetchWrapper } from "./fetchWrapper" type OpenCodeAuthorizeRequest = NonNullable["content"]["application/json"] @@ -14,46 +14,25 @@ export interface ProviderAuthMethods { [providerId: string]: ProviderAuthMethod[] } -function handleApiError(error: unknown, context: string): never { - if (error instanceof FetchError) { - throw new Error(`${context}: ${error.message}`) - } - throw error -} - export const oauthApi = { - authorize: async (providerId: string, method: number, inputs?: OpenCodeAuthorizeRequest["inputs"]): Promise => { - try { - return await fetchWrapper(`${API_BASE_URL}/api/oauth/${providerId}/oauth/authorize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ method, inputs }), - }) - } catch (error) { - handleApiError(error, "OAuth authorization failed") - } - }, - - callback: async (providerId: string, request: OAuthCallbackRequest): Promise => { - try { - return await fetchWrapper(`${API_BASE_URL}/api/oauth/${providerId}/oauth/callback`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), - }) - } catch (error) { - handleApiError(error, "OAuth callback failed") - } - }, + authorize: async (providerId: string, method: number, inputs?: OpenCodeAuthorizeRequest["inputs"]): Promise => + fetchWrapper(`${API_BASE_URL}/api/oauth/${providerId}/oauth/authorize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method, inputs }), + }), + + callback: async (providerId: string, request: OAuthCallbackRequest): Promise => + fetchWrapper(`${API_BASE_URL}/api/oauth/${providerId}/oauth/callback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }), getAuthMethods: async (): Promise => { - try { - const { providers, ...rest } = await fetchWrapper<{ providers?: ProviderAuthMethods } & ProviderAuthMethods>( - `${API_BASE_URL}/api/oauth/auth-methods` - ) - return providers || rest - } catch (error) { - handleApiError(error, "Failed to get provider auth methods") - } + const { providers, ...rest } = await fetchWrapper<{ providers?: ProviderAuthMethods } & ProviderAuthMethods>( + `${API_BASE_URL}/api/oauth/auth-methods` + ) + return providers || rest }, } diff --git a/frontend/src/lib/oauthErrors.test.ts b/frontend/src/lib/oauthErrors.test.ts new file mode 100644 index 00000000..70a70e86 --- /dev/null +++ b/frontend/src/lib/oauthErrors.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { FetchError } from '@opencode-manager/shared' +import { OAUTH_ERROR_CODES } from '@opencode-manager/shared/schemas' +import { mapOAuthError } from './oauthErrors' + +describe('mapOAuthError', () => { + it('maps every upstream error code to a distinct specific message', () => { + const messages = OAUTH_ERROR_CODES.map((code) => + mapOAuthError(new FetchError('OAuth authorization failed', 400, code), 'authorize'), + ) + for (const message of messages) { + expect(message).not.toBe('OAuth authorization failed') + expect(message).not.toBe('Failed to initiate OAuth authorization') + } + expect(new Set(messages).size).toBe(OAUTH_ERROR_CODES.length) + }) + + it('appends upstream detail when present', () => { + const err = new FetchError( + 'OAuth callback failed', + 400, + 'ProviderAuthValidationFailed', + 'must start with sk- — field: apiKey', + ) + expect(mapOAuthError(err, 'callback')).toContain('must start with sk- — field: apiKey') + }) + + it('falls back to the raw message for codes outside the contract', () => { + const err = new FetchError('Upstream unavailable', 502, 'SOMETHING_ELSE') + expect(mapOAuthError(err, 'authorize')).toBe('Upstream unavailable') + }) + + it('falls back to the phase default for non-Error values', () => { + expect(mapOAuthError('nope', 'authorize')).toBe('Failed to initiate OAuth authorization') + expect(mapOAuthError(undefined, 'callback')).toBe('Failed to complete OAuth callback') + }) +}) diff --git a/frontend/src/lib/oauthErrors.ts b/frontend/src/lib/oauthErrors.ts index 0f00e220..5ca793ba 100644 --- a/frontend/src/lib/oauthErrors.ts +++ b/frontend/src/lib/oauthErrors.ts @@ -1,10 +1,17 @@ -const ERROR_MAPPINGS: Record = { - 'invalid code': 'Invalid authorization code. Please try the OAuth flow again.', - 'expired': 'Authorization code has expired. Please try the OAuth flow again.', - 'access denied': 'Access was denied. Please check the permissions and try again.', - 'server error': 'Server error occurred. Please try again later.', - 'provider not found': 'Provider is not available or does not support OAuth.', - 'invalid method': 'Invalid authentication method selected.', +import { FetchError } from '@opencode-manager/shared' +import { OAUTH_ERROR_CODES, type OAuthErrorCode } from '@opencode-manager/shared/schemas' + +const ERROR_MESSAGES: Record = { + BadRequest: 'The provider rejected the request. Please try the OAuth flow again.', + ProviderAuthOauthMissing: 'No authorization is in progress for this provider. Please start the OAuth flow again.', + ProviderAuthOauthCodeMissing: 'An authorization code is required. Please paste the code from the provider.', + ProviderAuthOauthCallbackFailed: 'The provider rejected the authorization. It may have expired — please try again.', + ProviderAuthValidationFailed: 'Some authentication details were invalid. Please check them and try again.', + InvalidRequestError: 'The request was invalid. Please check your details and try again.', +} + +function isOAuthErrorCode(code: string): code is OAuthErrorCode { + return (OAUTH_ERROR_CODES as readonly string[]).includes(code) } export function mapOAuthError(err: unknown, context: 'authorize' | 'callback'): string { @@ -14,10 +21,10 @@ export function mapOAuthError(err: unknown, context: 'authorize' | 'callback'): if (!(err instanceof Error)) return defaultMessage - for (const [key, message] of Object.entries(ERROR_MAPPINGS)) { - if (err.message.toLowerCase().includes(key)) { - return message - } + if (err instanceof FetchError && err.code && isOAuthErrorCode(err.code)) { + return err.detail + ? `${ERROR_MESSAGES[err.code]} (${err.detail})` + : ERROR_MESSAGES[err.code] } return err.message || defaultMessage diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63149eab..18c14f8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -320,6 +320,9 @@ importers: specifier: ^4.1.12 version: 4.3.2 devDependencies: + '@opencode-ai/sdk': + specifier: 1.18.8 + version: 1.18.8 typescript: specifier: ^5 version: 5.9.3 @@ -1081,6 +1084,9 @@ packages: resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} + '@opencode-ai/sdk@1.18.8': + resolution: {integrity: sha512-8vi5UBKFFgc+fnyKhZhGUxiIWMtUuN00VAInnK9JO6g6EC8WvWefP+ccTBQD023zod69JaEoAzGgO8hdxXHUaw==} + '@opentui/core-darwin-arm64@0.1.107': resolution: {integrity: sha512-Yqt2/9Ntw0IdtPA/qmHvXCE16y4Jq5/btCmuzN9/opzqZ5rYGYYVtiBii3LezGcTZYuJQZthjvh8MLPXXwA2EQ==} cpu: [arm64] @@ -5621,6 +5627,10 @@ snapshots: '@noble/hashes@2.0.1': {} + '@opencode-ai/sdk@1.18.8': + dependencies: + cross-spawn: 7.0.6 + '@opentui/core-darwin-arm64@0.1.107': optional: true diff --git a/shared/package.json b/shared/package.json index fa591a73..d524738d 100644 --- a/shared/package.json +++ b/shared/package.json @@ -29,6 +29,7 @@ "dotenv": "^17.2.3" }, "devDependencies": { + "@opencode-ai/sdk": "1.18.8", "typescript": "^5" } } diff --git a/shared/src/schemas/auth.ts b/shared/src/schemas/auth.ts index b954f0c7..9adcbf0d 100644 --- a/shared/src/schemas/auth.ts +++ b/shared/src/schemas/auth.ts @@ -1,4 +1,8 @@ import { z } from "zod"; +import type { + ProviderOauthAuthorizeErrors, + ProviderOauthCallbackErrors, +} from "@opencode-ai/sdk/v2/types"; export const AuthEntrySchema = z.object({ type: z.enum(["api", "oauth"]), @@ -71,3 +75,59 @@ export const OAuthCallbackRequestSchema = z.object({ export const ProviderAuthMethodsResponseSchema = z.object({ providers: z.record(z.string(), z.array(ProviderAuthMethodSchema)), }).or(ProviderAuthMethodsSchema); + +export const PROVIDER_AUTH_ERROR_NAMES = [ + "BadRequest", + "ProviderAuthOauthMissing", + "ProviderAuthOauthCodeMissing", + "ProviderAuthOauthCallbackFailed", + "ProviderAuthValidationFailed", +] as const; + +export type ProviderAuthErrorName = (typeof PROVIDER_AUTH_ERROR_NAMES)[number]; + +export const ProviderAuthErrorSchema = z.object({ + name: z.enum(PROVIDER_AUTH_ERROR_NAMES), + data: z.object({ + providerID: z.string().optional(), + field: z.string().optional(), + message: z.string().optional(), + kind: z.string().optional(), + }), +}); + +export const InvalidRequestErrorSchema = z.object({ + _tag: z.literal("InvalidRequestError"), + message: z.string(), + kind: z.string().optional(), + field: z.string().optional(), +}); + +export const OpenCodeOAuthErrorSchema = z.union([ + ProviderAuthErrorSchema, + InvalidRequestErrorSchema, +]); + +export type OpenCodeOAuthError = z.infer; + +export const OAUTH_ERROR_CODES = [ + ...PROVIDER_AUTH_ERROR_NAMES, + "InvalidRequestError", +] as const; + +export type OAuthErrorCode = (typeof OAUTH_ERROR_CODES)[number]; + +export function oauthErrorCode(error: OpenCodeOAuthError): OAuthErrorCode { + return "_tag" in error ? error._tag : error.name; +} + +type MutuallyAssignable = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type Assert = T; + +export type OpenCodeOAuthErrorMatchesAuthorize = Assert< + MutuallyAssignable +>; + +export type OpenCodeOAuthErrorMatchesCallback = Assert< + MutuallyAssignable +>; diff --git a/shared/src/schemas/settings.ts b/shared/src/schemas/settings.ts index 3a6eea81..679d685d 100644 --- a/shared/src/schemas/settings.ts +++ b/shared/src/schemas/settings.ts @@ -301,6 +301,10 @@ export const ProviderConfigSchema = z.object({ source: ProviderSourceSchema.optional(), env: z.array(z.string()).optional().default([]), key: z.string().optional(), + api: z.string().optional(), + npm: z.string().optional(), + whitelist: z.array(z.string()).optional(), + blacklist: z.array(z.string()).optional(), options: z.record(z.string(), z.any()).optional(), models: z.record(z.string(), ModelConfigSchema).optional(), });