fix: let TIC lookup run during onboarding + tolerate lowercase TIC status (#346)

* 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>
This commit is contained in:
Jakob Wennberg
2026-04-22 15:24:51 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 8fd3f112f8
commit 1014d7cc2c
3 changed files with 65 additions and 8 deletions
+24 -2
View File
@@ -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)
+22 -5
View File
@@ -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)
+19 -1
View File
@@ -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<Response>
}