From bac49b6ee6af7c1c0a5e48e298d1a2b6e6463ffa Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:31:04 +0100 Subject: [PATCH] fix: OAuth callback redirect and timeout resilience (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: include reversed entries in all reports (general ledger, trial balance, VAT, SIE, NE, INK2) Reversed entries (storno) must appear alongside their original posted entries in reports for a complete audit trail. Previously, filtering by status='posted' excluded them, causing discrepancies when corrections had been made. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: semi-manual invoice payment booking with editable journal lines When marking an invoice as paid, users now see a dialog where they can: - Choose which bank/cash account the payment goes to (1910, 1920, 1930, etc.) - Review and edit the proposed journal entry lines before committing - The happy path remains fast — lines are pre-filled correctly Implementation: - Pure proposePaymentLines() function for line computation (accrual + cash) - PaymentBookingDialog with AccountCombobox, balance validation, date picker - API accepts optional custom lines, falls back to auto-generation without them - 18 tests (8 unit + 10 API) all passing Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address Greptile review — validation fallback, balance check, error handling - P1: Return 400 on invalid body instead of silently falling back to auto-generated lines (split JSON parse from schema validation) - P1: Add server-side balance check for custom lines before committing (debit must equal credit, totalDebit > 0) - P2: Wrap PaymentBookingDialog init() in try/catch with toast on failure and auto-close instead of silent empty state - Add 2 new tests: unbalanced lines → 400, invalid schema → 400 Co-Authored-By: Claude Opus 4.6 (1M context) * fix: OAuth callback redirect for local dev and timeout resilience - Pass redirectUri dynamically from NEXT_PUBLIC_APP_URL so OAuth callbacks work on localhost (not just production) - Encode consentId/provider in OAuth state (base64url JSON) so the callback doesn't depend on session storage - Add skipAuth flag to extension API routes for OAuth callbacks (external provider redirects have no user session cookie) - Wrap AbortError in descriptive timeout messages in arcim-client - Make preview endpoint resilient to partial failures (company info and SIE fetch are individually non-blocking) - Simplify login page (remove unused magic link auth mode) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: create journal entry before marking invoice as paid Move journal entry creation before the invoice status update so that if accounting fails, the invoice is not permanently marked paid without a corresponding entry. Previously the error was silently swallowed. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update mark-paid tests for journal-first ordering Reorder mock queue to match new flow (settings before update), update failure test to expect 500 instead of silent success, add try-catch with proper error response in route handler. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- app/(auth)/login/page.tsx | 200 +++++------------- app/api/extensions/ext/[...path]/route.ts | 58 +++-- .../[id]/mark-paid/__tests__/route.test.ts | 30 +-- app/api/invoices/[id]/mark-paid/route.ts | 146 +++++++------ extensions/general/arcim-migration/index.ts | 75 +++++-- .../arcim-migration/lib/arcim-client.ts | 37 +++- lib/extensions/types.ts | 2 + 7 files changed, 265 insertions(+), 283 deletions(-) diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 6d0570b1..c24b33ba 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -12,12 +12,9 @@ import { Loader2, Mail, ArrowLeft, KeyRound } from 'lucide-react' import Image from 'next/image' import { getErrorMessage } from '@/lib/errors/get-error-message' -type AuthMode = 'password' | 'magic-link' - export default function LoginPage() { const [email, setEmail] = useState('') const [password, setPassword] = useState('') - const [authMode, setAuthMode] = useState('password') const [isLoading, setIsLoading] = useState(false) const [isEmailSent, setIsEmailSent] = useState(false) const [showResetPassword, setShowResetPassword] = useState(false) @@ -70,47 +67,6 @@ export default function LoginPage() { } } - const handleMagicLink = async (e: React.FormEvent) => { - e.preventDefault() - setIsLoading(true) - - const formData = new FormData(e.currentTarget) - const emailValue = (formData.get('email') as string) || email - - try { - const { error } = await supabase.auth.signInWithOtp({ - email: emailValue, - options: { - emailRedirectTo: `${window.location.origin}/auth/callback`, - }, - }) - - if (error) { - toast({ - title: 'Kunde inte skicka länk', - description: getErrorMessage(error, { context: 'auth' }), - variant: 'destructive', - }) - return - } - - setEmail(emailValue) - setIsEmailSent(true) - toast({ - title: 'E-post skickad!', - description: 'Kolla din inkorg för att logga in.', - }) - } catch (error) { - toast({ - title: 'Kunde inte skicka länk', - description: getErrorMessage(error, { context: 'auth' }), - variant: 'destructive', - }) - } finally { - setIsLoading(false) - } - } - const handleResetPassword = async (e: React.FormEvent) => { e.preventDefault() setIsLoading(true) @@ -269,87 +225,57 @@ export default function LoginPage() {
- {authMode === 'password' ? ( -
-
- - setEmail(e.target.value)} - required - disabled={isLoading} - className="h-11" - /> + +
+ + setEmail(e.target.value)} + required + disabled={isLoading} + className="h-11" + /> +
+
+
+ +
-
-
- - -
- setPassword(e.target.value)} - required - disabled={isLoading} - className="h-11" - /> -
- - - ) : ( -
-
- - setEmail(e.target.value)} - required - disabled={isLoading} - className="h-11" - /> -
- -
- )} + setPassword(e.target.value)} + required + disabled={isLoading} + className="h-11" + /> +
+ +
@@ -363,32 +289,14 @@ export default function LoginPage() {
-

- Har du inget konto?{' '} - - Skapa konto - -

-

Genom att logga in godkänner du våra{' '} diff --git a/app/api/extensions/ext/[...path]/route.ts b/app/api/extensions/ext/[...path]/route.ts index ce2621be..0ddc3f78 100644 --- a/app/api/extensions/ext/[...path]/route.ts +++ b/app/api/extensions/ext/[...path]/route.ts @@ -71,6 +71,45 @@ async function handleRequest( return NextResponse.json({ error: 'Extension not found' }, { status: 404 }) } + // Match route BEFORE auth so we can check skipAuth (e.g. OAuth callbacks) + let matchedRoute: ApiRouteDefinition | null = null + let extractedParams: Record = {} + + for (const route of extension.apiRoutes) { + if (route.method !== method) continue + + const routeParams = matchPath(route.path, routePath) + if (routeParams !== null) { + matchedRoute = route + extractedParams = routeParams + break + } + } + + if (!matchedRoute) { + return NextResponse.json({ error: 'Route not found' }, { status: 404 }) + } + + // For skipAuth routes (e.g. OAuth callbacks from external providers), + // skip user auth, toggle check, and AI consent — dispatch immediately + if (matchedRoute.skipAuth) { + let handlerRequest = request + if (Object.keys(extractedParams).length > 0) { + const url = new URL(request.url) + for (const [key, value] of Object.entries(extractedParams)) { + url.searchParams.set(`_${key}`, value) + } + handlerRequest = new Request(url.toString(), { + method: request.method, + headers: request.headers, + body: request.body, + // @ts-expect-error -- duplex needed for streaming body + duplex: 'half', + }) + } + return matchedRoute.handler(handlerRequest) + } + // Auth check const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() @@ -97,25 +136,6 @@ async function handleRequest( } } - // Find matching route (supports :param patterns) - let matchedRoute: ApiRouteDefinition | null = null - let extractedParams: Record = {} - - for (const route of extension.apiRoutes) { - if (route.method !== method) continue - - const params = matchPath(route.path, routePath) - if (params !== null) { - matchedRoute = route - extractedParams = params - break - } - } - - if (!matchedRoute) { - return NextResponse.json({ error: 'Route not found' }, { status: 404 }) - } - // If path params were extracted, create a new Request with them as search params let handlerRequest = request if (Object.keys(extractedParams).length > 0) { diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts index 06f0a9ca..667d22ea 100644 --- a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -114,10 +114,10 @@ describe('POST /api/invoices/[id]/mark-paid', () => { // Fetch invoice enqueue({ data: invoice, error: null }) + // Fetch company settings (now before update due to journal-first ordering) + enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) // Update invoice status enqueue({ data: null, error: null }) - // Fetch company settings - enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' }) @@ -154,8 +154,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => { }) enqueue({ data: invoice, error: null }) - enqueue({ data: null, error: null }) enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: null, error: null }) mockCreateInvoiceCashEntry.mockResolvedValue({ id: 'je-2' }) @@ -178,25 +178,19 @@ describe('POST /api/invoices/[id]/mark-paid', () => { ) }) - it('returns success with null journal_entry_id when journal entry creation fails', async () => { + it('returns 500 when journal entry creation fails (invoice not marked paid)', async () => { const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 }) enqueue({ data: invoice, error: null }) - enqueue({ data: null, error: null }) enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockRejectedValue(new Error('Period locked')) + mockCreateInvoicePaymentJournalEntry.mockRejectedValueOnce(new Error('Period locked')) const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) - const { status, body } = await parseJsonResponse<{ - success: boolean - journal_entry_id: string | null - }>(response) + const { status } = await parseJsonResponse(response) - expect(status).toBe(200) - expect(body.success).toBe(true) - expect(body.journal_entry_id).toBeNull() + expect(status).toBe(500) }) it('uses custom lines when provided instead of auto-generating', async () => { @@ -204,10 +198,10 @@ describe('POST /api/invoices/[id]/mark-paid', () => { // Fetch invoice enqueue({ data: invoice, error: null }) + // Fetch company settings (before update — journal-first ordering) + enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) // Update invoice status enqueue({ data: null, error: null }) - // Fetch company settings - enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) mockFindFiscalPeriod.mockResolvedValue('fp-1') mockCreateJournalEntry.mockResolvedValue({ id: 'je-custom' }) @@ -253,10 +247,6 @@ describe('POST /api/invoices/[id]/mark-paid', () => { // Fetch invoice enqueue({ data: invoice, error: null }) - // Update invoice status - enqueue({ data: null, error: null }) - // Fetch company settings - enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) const unbalancedLines = [ { account_number: '1920', debit_amount: 12500, credit_amount: 0 }, @@ -309,8 +299,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => { }) enqueue({ data: invoice, error: null }) - enqueue({ data: null, error: null }) enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: null, error: null }) mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-auto' }) diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts index 4fcd6cf0..02bb0bb5 100644 --- a/app/api/invoices/[id]/mark-paid/route.ts +++ b/app/api/invoices/[id]/mark-paid/route.ts @@ -79,7 +79,83 @@ export async function POST( const now = new Date().toISOString() const paymentDate = bodyPaymentDate || now.split('T')[0] - // Update status to paid + // Fetch accounting method + const { data: settings } = await supabase + .from('company_settings') + .select('accounting_method, entity_type') + .eq('user_id', user.id) + .single() + + const accountingMethod = settings?.accounting_method || 'accrual' + const entityType = (settings?.entity_type as EntityType) || 'enskild_firma' + + // Create journal entry FIRST — only mark paid if accounting succeeds + const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice' + let journalEntryId: string | null = null + + if (isRealInvoice) { + try { + if (customLines) { + // Server-side balance validation — never commit imbalanced entries + const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0) + const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0) + if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) { + return NextResponse.json( + { error: 'Verifikationsraderna är inte balanserade (debet ≠ kredit)' }, + { status: 400 } + ) + } + + // User-provided lines from PaymentBookingDialog + const fiscalPeriodId = await findFiscalPeriod(supabase, user.id, paymentDate) + if (!fiscalPeriodId) { + return NextResponse.json( + { error: 'Ingen öppen räkenskapsperiod för betalningsdatumet' }, + { status: 400 } + ) + } + const sourceType = accountingMethod === 'accrual' ? 'invoice_paid' : 'invoice_cash_payment' + const input: CreateJournalEntryInput = { + fiscal_period_id: fiscalPeriodId, + entry_date: paymentDate, + description: `Betalning faktura ${invoice.invoice_number}`, + source_type: sourceType, + source_id: invoice.id, + lines: customLines, + } + const journalEntry = await createJournalEntry(supabase, user.id, input) + journalEntryId = journalEntry?.id ?? null + } else if (accountingMethod === 'accrual') { + // Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510) + const journalEntry = await createInvoicePaymentJournalEntry( + supabase, + user.id, + invoice as Invoice, + paymentDate, + exchangeRateDifference + ) + journalEntryId = journalEntry?.id ?? null + } else { + // Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx) + const journalEntry = await createInvoiceCashEntry( + supabase, + user.id, + invoice as Invoice, + paymentDate, + entityType + ) + journalEntryId = journalEntry?.id ?? null + } + } catch (err) { + console.error('Failed to create payment journal entry:', err) + return NextResponse.json( + { error: 'Kunde inte bokföra betalningen' }, + { status: 500 } + ) + } + } + + // Update status to paid — only after journal entry succeeds const { error: updateError } = await supabase .from('invoices') .update({ @@ -94,74 +170,6 @@ export async function POST( return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 }) } - // Fetch accounting method - const { data: settings } = await supabase - .from('company_settings') - .select('accounting_method, entity_type') - .eq('user_id', user.id) - .single() - - const accountingMethod = settings?.accounting_method || 'accrual' - const entityType = (settings?.entity_type as EntityType) || 'enskild_firma' - - // Only create journal entries for real invoices (not proformas or delivery notes) - const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice' - let journalEntryId: string | null = null - - if (isRealInvoice) { - try { - if (customLines) { - // Server-side balance validation — never commit imbalanced entries - const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0) - const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0) - if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) { - return NextResponse.json( - { error: 'Verifikationsraderna är inte balanserade (debet ≠ kredit)' }, - { status: 400 } - ) - } - - // User-provided lines from PaymentBookingDialog - const fiscalPeriodId = await findFiscalPeriod(supabase, user.id, paymentDate) - if (fiscalPeriodId) { - const sourceType = accountingMethod === 'accrual' ? 'invoice_paid' : 'invoice_cash_payment' - const input: CreateJournalEntryInput = { - fiscal_period_id: fiscalPeriodId, - entry_date: paymentDate, - description: `Betalning faktura ${invoice.invoice_number}`, - source_type: sourceType, - source_id: invoice.id, - lines: customLines, - } - const journalEntry = await createJournalEntry(supabase, user.id, input) - journalEntryId = journalEntry?.id ?? null - } - } else if (accountingMethod === 'accrual') { - // Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510) - const journalEntry = await createInvoicePaymentJournalEntry( - supabase, - user.id, - invoice as Invoice, - paymentDate, - exchangeRateDifference - ) - journalEntryId = journalEntry?.id ?? null - } else { - // Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx) - const journalEntry = await createInvoiceCashEntry( - supabase, - user.id, - invoice as Invoice, - paymentDate, - entityType - ) - journalEntryId = journalEntry?.id ?? null - } - } catch (err) { - console.error('Failed to create payment journal entry on mark-paid:', err) - } - } - return NextResponse.json({ success: true, status: 'paid', diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts index c3658178..96a5c62b 100644 --- a/extensions/general/arcim-migration/index.ts +++ b/extensions/general/arcim-migration/index.ts @@ -97,8 +97,16 @@ export const arcimMigrationExtension: Extension = { // Generate OTC for OAuth flow const otc = await generateOtc(consent.id) - // Get OAuth URL from Arcim (redirect URI is configured server-side in the gateway) - const { url } = await getAuthUrl(provider, otc.code) + // Build the OAuth callback URL using the current app URL (localhost in dev, production in prod) + const appUrl = process.env.NEXT_PUBLIC_APP_URL || '' + const callbackUrl = `${appUrl}/api/extensions/ext/arcim-migration/callback` + + // Encode consentId + provider in state so the callback doesn't depend on session storage + const statePayload = JSON.stringify({ otc: otc.code, consentId: consent.id, provider }) + const stateEncoded = Buffer.from(statePayload).toString('base64url') + + // Pass callbackUrl as redirectUri so Fortnox redirects here (works for localhost and production) + const { url } = await getAuthUrl(provider, stateEncoded, callbackUrl) return NextResponse.json({ consentId: consent.id, @@ -181,37 +189,63 @@ export const arcimMigrationExtension: Extension = { }, // ── OAuth callback ──────────────────────────────────────────── + // This handler is called by the OAuth provider redirect. It does NOT + // require user auth — the request comes from the provider, not the user's + // browser session. Authentication is validated via the OTC code + consent. + // The 'skipAuth' flag is checked by the extension dispatch route. { method: 'GET', path: '/callback', + skipAuth: true, handler: async (request: Request, ctx?: ExtensionContext) => { const log = ctx?.log ?? console const url = new URL(request.url) const code = url.searchParams.get('code') - const state = url.searchParams.get('state') // OTC code + const stateRaw = url.searchParams.get('state') - if (!code || !state) { + if (!code || !stateRaw) { return NextResponse.json({ error: 'Missing code or state' }, { status: 400 }) } try { - // The state is the OTC code, and the code is the OAuth auth code - // Exchange with the Arcim gateway - const consentId = ctx?.settings - ? await ctx.settings.get('consent_id') - : null - const provider = ctx?.settings - ? await ctx.settings.get('provider') - : null + // Decode state — supports both new format (base64url JSON with consentId/provider) + // and legacy format (plain OTC code string) + let consentId: string | null = null + let provider: ArcimProvider | null = null + let otcCode: string = stateRaw + + try { + const decoded = JSON.parse(Buffer.from(stateRaw, 'base64url').toString()) + if (decoded.consentId && decoded.provider && decoded.otc) { + consentId = decoded.consentId + provider = decoded.provider as ArcimProvider + otcCode = decoded.otc + } + } catch { + // Legacy: state is just the OTC code — fall back to ctx.settings + } + + // Fall back to session-based settings if state didn't contain the data + if (!consentId || !provider) { + consentId = ctx?.settings + ? await ctx.settings.get('consent_id') + : null + provider = ctx?.settings + ? await ctx.settings.get('provider') + : null + } if (!consentId || !provider) { return NextResponse.json({ error: 'No active migration session' }, { status: 400 }) } - await exchangeAuthToken(consentId, provider, state, code) + // The redirectUri used for the token exchange must match the one used in the auth URL + const appUrl = process.env.NEXT_PUBLIC_APP_URL || '' + const redirectUri = `${appUrl}/api/extensions/ext/arcim-migration/callback` + + await exchangeAuthToken(consentId, provider, otcCode, code, redirectUri) // Redirect to import page with success - const appUrl = process.env.NEXT_PUBLIC_APP_URL || '' return NextResponse.redirect(`${appUrl}/import?migration=connected&consentId=${consentId}`) } catch (error) { log.error('OAuth callback error:', error) @@ -251,11 +285,16 @@ export const arcimMigrationExtension: Extension = { ) } - // Fetch company info for preview - const companyInfo = await fetchCompanyInfo(consentId) - const mapped = companyInfo ? mapCompanyInfo(companyInfo) : null + // Fetch company info for preview (non-blocking — continue if it fails) + let mapped = null + try { + const companyInfo = await fetchCompanyInfo(consentId) + mapped = companyInfo ? mapCompanyInfo(companyInfo) : null + } catch (err) { + log.info('Company info fetch failed:', err instanceof Error ? err.message : String(err)) + } - // Try to fetch SIE stats + // Try to fetch SIE stats (non-blocking — continue if it fails) let sieAvailable = false let sieStats: { accountCount: number; transactionCount: number; fiscalYears: number[] } | null = null diff --git a/extensions/general/arcim-migration/lib/arcim-client.ts b/extensions/general/arcim-migration/lib/arcim-client.ts index 6c37ae9f..dc1a8dd6 100644 --- a/extensions/general/arcim-migration/lib/arcim-client.ts +++ b/extensions/general/arcim-migration/lib/arcim-client.ts @@ -38,15 +38,26 @@ async function request( const controller = new AbortController() const timer = setTimeout(() => controller.abort(), timeoutMs) - const response = await fetch(url, { - ...options, - signal: controller.signal, - headers: { - 'Authorization': `Bearer ${getApiKey()}`, - 'Content-Type': 'application/json', - ...options.headers, - }, - }).finally(() => clearTimeout(timer)) + let response: Response + try { + response = await fetch(url, { + ...options, + signal: controller.signal, + headers: { + 'Authorization': `Bearer ${getApiKey()}`, + 'Content-Type': 'application/json', + ...options.headers, + }, + }) + } catch (err) { + clearTimeout(timer) + if (err instanceof DOMException || (err instanceof Error && err.name === 'AbortError')) { + throw new Error(`Arcim API timeout after ${Math.round(timeoutMs / 1000)}s: ${path}`) + } + throw err + } finally { + clearTimeout(timer) + } if (!response.ok) { const body = await response.text().catch(() => '') @@ -92,10 +103,12 @@ export async function deleteConsent(consentId: string): Promise { export async function getAuthUrl( provider: ArcimProvider, - state?: string + state?: string, + redirectUri?: string ): Promise<{ url: string }> { const params = new URLSearchParams() if (state) params.set('state', state) + if (redirectUri) params.set('redirectUri', redirectUri) const qs = params.toString() return request<{ url: string }>(`/api/v1/auth/${provider}/url${qs ? `?${qs}` : ''}`) } @@ -104,7 +117,8 @@ export async function exchangeAuthToken( consentId: string, provider: ArcimProvider, otcCode: string, - oauthCode: string + oauthCode: string, + redirectUri?: string ): Promise<{ success: boolean; consentId: string }> { return request(`/api/v1/auth/${provider}/callback`, { method: 'POST', @@ -112,6 +126,7 @@ export async function exchangeAuthToken( code: oauthCode, consentId, otcCode, + ...(redirectUri ? { redirectUri } : {}), }), }) } diff --git a/lib/extensions/types.ts b/lib/extensions/types.ts index 3dbf4d0a..7c57a7a1 100644 --- a/lib/extensions/types.ts +++ b/lib/extensions/types.ts @@ -77,6 +77,8 @@ export interface RouteDefinition { export interface ApiRouteDefinition { method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' path: string + /** Skip auth check for this route (e.g. OAuth callbacks from external providers) */ + skipAuth?: boolean handler: (request: Request, ctx?: ExtensionContext) => Promise }