diff --git a/app/api/extensions/ext/[...path]/route.ts b/app/api/extensions/ext/[...path]/route.ts index 7786ebee..e06e716c 100644 --- a/app/api/extensions/ext/[...path]/route.ts +++ b/app/api/extensions/ext/[...path]/route.ts @@ -89,6 +89,21 @@ async function handleRequest( 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) { @@ -118,8 +133,6 @@ async function handleRequest( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const companyId = await requireCompanyId(supabase, user.id) - // If path params were extracted, create a new Request with them as search params let handlerRequest = request if (Object.keys(extractedParams).length > 0) { @@ -138,6 +151,15 @@ async function handleRequest( }) } + // 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) diff --git a/extensions/general/tic/index.ts b/extensions/general/tic/index.ts index 878aed6b..e85a8e28 100644 --- a/extensions/general/tic/index.ts +++ b/extensions/general/tic/index.ts @@ -49,11 +49,21 @@ async function fetchAndStoreEnrichment( hasSecureUrl: !!enrichment.secureUrl, }) - // Accept both fully and partially completed runs — if the tenant only has - // SPAR enabled (not CompanyRoles), we still want the address data. - const usable = (enrichment.status === 'Completed' || enrichment.status === 'PartiallyCompleted') - && enrichment.secureUrl - if (!usable) return + // Case-insensitive status comparison: TIC has been observed returning + // lowercase values ('completed', 'failed') in addition to the docs' canonical + // capitalized form. Accept both fully and partially completed runs — if the + // tenant only has SPAR enabled (not CompanyRoles) we still want the address. + const statusLower = String(enrichment.status ?? '').toLowerCase() + const isCompleted = statusLower === 'completed' || statusLower === 'partiallycompleted' + const usable = isCompleted && enrichment.secureUrl + if (!usable) { + // Log the full response shape (sans secureUrl — time-limited token) + // so we can diagnose why a real-user enrichment comes back non-usable. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { secureUrl: _omit, ...responseDiagnostic } = enrichment + log.warn('enrichment not usable — inspect response for diagnostic fields', responseDiagnostic) + return + } const enrichmentData = await fetchEnrichmentData(enrichment.secureUrl) @@ -193,6 +203,10 @@ export const ticExtension: Extension = { { method: 'GET', path: '/lookup', + // Used during onboarding (Step2CompanyDetails debounced lookup + the + // BankID picker) — user is authenticated but does not yet have a + // company. Must not require a company context. + skipCompanyContext: true, handler: async (request: Request, ctx?) => { const log = ctx?.log ?? console const url = new URL(request.url) @@ -305,6 +319,9 @@ export const ticExtension: Extension = { { method: 'GET', path: '/profile', + // Used during onboarding to render richer company profile details — + // user is authenticated but may not yet have a company. See /lookup. + skipCompanyContext: true, handler: async (request: Request, ctx?) => { const log = ctx?.log ?? console const url = new URL(request.url) diff --git a/lib/extensions/types.ts b/lib/extensions/types.ts index 02e92073..1253a528 100644 --- a/lib/extensions/types.ts +++ b/lib/extensions/types.ts @@ -62,12 +62,30 @@ export interface RouteDefinition { label: string } -/** An API route exposed by an extension */ +/** + * An API route exposed by an extension. + * + * Auth/context modes (mutually exclusive — combining throws at dispatch time): + * - default: requires auth AND a resolved company; ctx is passed to the handler + * - `skipAuth: true`: no auth, no ctx (e.g. OAuth callbacks) + * - `skipCompanyContext: true`: auth required, no ctx (pre-onboarding routes) + */ 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 + /** + * Require auth but NOT a resolved company context. Use for routes that + * legitimately run during onboarding (before the user has a company) — + * e.g. TIC /lookup used by Step2CompanyDetails to fetch company info + * while the user types their org number. Handler is called without a + * ctx argument; handlers that opt in must tolerate a missing context. + * + * Must NOT be combined with `skipAuth: true` — the dispatcher treats + * that as a misconfiguration and returns 500. + */ + skipCompanyContext?: boolean handler: (request: Request, ctx?: ExtensionContext) => Promise }