1014d7cc2c
* fix: let TIC lookup run during onboarding; tolerate lowercase TIC status Two bugs found in prod testing of the BankID picker: 1. Extension dispatcher required a resolved company context for every non-skipAuth route. /api/extensions/ext/tic/lookup is hit by Step2CompanyDetails' debounced fetcher (and the BankID picker's one-click path) during onboarding — before the user has a company — so requireCompanyId threw "No company context" and the call 500'd. Added a `skipCompanyContext` flag to ApiRouteDefinition. Marks /lookup and /profile on the TIC extension so they bypass company resolution but still require auth. Handlers don't use ctx for these routes, so no downstream changes were needed. 2. TIC enrichment has been observed returning lowercase 'failed' (and presumably other lowercase status values). The previous `=== 'Completed'` strict-case check would silently reject even a legitimately completed enrichment if TIC normalizes to lowercase. Now compares case-insensitively against 'completed' and 'partiallycompleted'. On non-usable enrichment, we now log the full response shape (minus the time-limited secureUrl token) so we can diagnose why real-user enrichments come back failed — useful for debugging TIC tenant config issues where status='failed' but no documented error field is set. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: reject skipAuth + skipCompanyContext combination (PR review) Greptile P2 finding: if a future route accidentally sets both flags, skipAuth fires first and silently drops the auth requirement that skipCompanyContext implicitly assumes. No current route combines them, but this prevents the mistake from reaching prod. - Dispatcher throws 500 at matching time if both flags are set, with a descriptive log line naming the misconfigured route. - Type JSDoc now lists the three mutually-exclusive modes upfront and marks the combination as explicitly forbidden. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
173 lines
6.0 KiB
TypeScript
173 lines
6.0 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { extensionRegistry } from '@/lib/extensions/registry'
|
|
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
|
import { requireCompanyId } from '@/lib/company/context'
|
|
import type { ApiRouteDefinition } from '@/lib/extensions/types'
|
|
|
|
ensureInitialized()
|
|
|
|
// Heavy extension routes (SIE import, migration) need up to 5 minutes
|
|
export const maxDuration = 300
|
|
|
|
/**
|
|
* Match a request path against a route pattern.
|
|
* Supports :param wildcards (e.g., /:id/confirm).
|
|
* Returns extracted params on match, null on mismatch.
|
|
*/
|
|
function matchPath(
|
|
pattern: string,
|
|
requestPath: string
|
|
): Record<string, string> | null {
|
|
const patternParts = pattern.split('/').filter(Boolean)
|
|
const requestParts = requestPath.split('/').filter(Boolean)
|
|
|
|
if (patternParts.length !== requestParts.length) return null
|
|
|
|
const params: Record<string, string> = {}
|
|
|
|
for (let i = 0; i < patternParts.length; i++) {
|
|
if (patternParts[i].startsWith(':')) {
|
|
params[patternParts[i].slice(1)] = requestParts[i]
|
|
} else if (patternParts[i] !== requestParts[i]) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
return params
|
|
}
|
|
|
|
/**
|
|
* Catch-all route for extension-declared API routes.
|
|
*
|
|
* URL scheme: /api/extensions/ext/{extensionId}/{...routePath}
|
|
* Example: /api/extensions/ext/mcp-server/mcp → POST /mcp
|
|
*
|
|
* - Looks up the extension in the registry
|
|
* - Checks the extension toggle (disabled → 403)
|
|
* - Matches method + path pattern to registered apiRoutes
|
|
* - Extracts path params and appends them as URL search params
|
|
* - Builds an ExtensionContext and passes it to the handler
|
|
*/
|
|
async function handleRequest(
|
|
request: Request,
|
|
{ params }: { params: Promise<{ path: string[] }> }
|
|
): Promise<Response> {
|
|
const segments = await params
|
|
|
|
if (!segments.path || segments.path.length < 1) {
|
|
return NextResponse.json({ error: 'Invalid extension route' }, { status: 400 })
|
|
}
|
|
|
|
const [extensionId, ...rest] = segments.path
|
|
const routePath = '/' + rest.join('/')
|
|
const method = request.method as 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
|
|
|
// Look up extension
|
|
const extension = extensionRegistry.get(extensionId)
|
|
if (!extension || !extension.apiRoutes || extension.apiRoutes.length === 0) {
|
|
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<string, string> = {}
|
|
|
|
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 })
|
|
}
|
|
|
|
// Config sanity check: these flags are orthogonal and the combination is
|
|
// nonsensical. `skipAuth` already implies no company resolution, so adding
|
|
// `skipCompanyContext: true` is at best redundant — and if a maintainer
|
|
// intended "auth required, no company" but also wrote `skipAuth: true`,
|
|
// the auth requirement would be silently dropped (skipAuth fires first
|
|
// below). Fail loudly instead of masking the mistake.
|
|
if (matchedRoute.skipAuth && matchedRoute.skipCompanyContext) {
|
|
console.error('[extension-dispatcher] route misconfigured: skipAuth + skipCompanyContext are mutually exclusive', {
|
|
extensionId,
|
|
routePath,
|
|
method,
|
|
})
|
|
return NextResponse.json({ error: 'Route misconfigured' }, { status: 500 })
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
const cloned = request.clone()
|
|
handlerRequest = new Request(url.toString(), {
|
|
method: cloned.method,
|
|
headers: cloned.headers,
|
|
body: cloned.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()
|
|
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
// If path params were extracted, create a new Request with them as search params
|
|
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)
|
|
}
|
|
// Clone first to avoid body stream locking issues when transferring to new Request
|
|
const cloned = request.clone()
|
|
handlerRequest = new Request(url.toString(), {
|
|
method: cloned.method,
|
|
headers: cloned.headers,
|
|
body: cloned.body,
|
|
// @ts-expect-error -- duplex needed for streaming body
|
|
duplex: 'half',
|
|
})
|
|
}
|
|
|
|
// Routes that are authenticated but run before a company exists (TIC
|
|
// /lookup during onboarding, for example) opt out of company resolution.
|
|
// Dispatch without a context — handlers that opt in must not rely on ctx.
|
|
if (matchedRoute.skipCompanyContext) {
|
|
return matchedRoute.handler(handlerRequest)
|
|
}
|
|
|
|
const companyId = await requireCompanyId(supabase, user.id)
|
|
|
|
// Build context and dispatch
|
|
const ctx = createExtensionContext(supabase, user.id, companyId, extensionId)
|
|
return matchedRoute.handler(handlerRequest, ctx)
|
|
}
|
|
|
|
export const GET = handleRequest
|
|
export const POST = handleRequest
|
|
export const PUT = handleRequest
|
|
export const DELETE = handleRequest
|
|
export const PATCH = handleRequest
|