* fix: surface active TIC companies + block duplicate org numbers
Three fixes from live-prod testing:
1. Enrichment filter hid the user's directorships. Now accepts both
Completed and PartiallyCompleted status from TIC (tenants without
CompanyRoles enabled still get SPAR) and the /select-company role
filter no longer requires companyStatus === 'Aktivt' — real TIC
payloads have been observed with different values, and positionEnd
alone is the authoritative "currently a director" signal. Added
PII-free diagnostic logs so the next shape-mismatch is debuggable
from Vercel logs without a round trip.
2. Manual wizard silently allowed duplicate org numbers. Added:
- findExistingCompanyByOrgNumber helper in actions.ts (service role,
bypasses RLS to see cross-tenant rows)
- Server-side guard in createCompanyFromOnboarding — returns
'org_number_exists' before the create RPC so we don't leave ghost
companies
- New /api/company/check-org-number endpoint for debounced client
checks
- Warning + disabled submit in Step2CompanyDetails
- Friendly error toasts in WelcomeOnboarding + BankIdCompanyPicker
- Mirror cleaned org_number onto companies.org_number on creation so
future duplicate checks and lookups are reliable
3. /onboarding ignored ?org_number= when the picker routed there as a
fallback. Now reads searchParams and pre-fills settings; also fixed
a latent bug where Step1's entity-type change wiped the pre-fill on
*first* selection (it should only reset on a genuine change).
Tests: duplicate-org guard (with formatted-input normalization),
check-org-number route (auth + 400 + exists true/false +
normalization).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address PR review feedback on duplicate-org guard
Greptile P1 findings + swedish-compliance feedback:
- findExistingCompanyByOrgNumber now throws on Supabase error instead
of silently returning null. Previously a DB outage or RLS
misconfiguration would bypass the entire duplicate guard and allow
duplicates through.
- createCompanyFromOnboarding catches the throw and returns a
user-facing error ("Kunde inte verifiera organisationsnummer"),
failing closed instead of open.
- companies.update({ org_number }) error is now checked and triggers a
rollback. Silent failure would leave the company without an
org_number, breaking all future duplicate checks for that entity.
- New normalizeOrgNumber helper validates 10- or 12-digit input,
strips the century prefix for 12-digit personnummer form, and
rejects anything else. Malformed input would have corrupted SIE4
(#ORGNR) and SRU (INFO.SRU) exports downstream.
- /select-company now uses loose `== null` for positionEnd — TIC has
been observed returning `undefined` for open-ended positions, which
strict `=== null` would silently filter out. Documented the two
downstream isCeased guards so future maintainers don't remove one
without the other.
- createCompanyFromTicRole refuses to provision when lookup.isCeased
(BFL 2 kap — bokföringsskyldighet ends at avregistrering).
BankIdCompanyPicker surfaces this client-side too.
- WelcomeOnboarding + BankIdCompanyPicker recognise new error codes:
org_number_invalid, company_ceased.
Tests: +4 cases covering malformed input rejection, fail-closed
behaviour on DB error, 12-digit personnummer normalization, and the
ceased-company refusal path. Full suite: 2306 passing.
Out of scope for this PR (follow-up):
- Partial unique index on companies(org_number) WHERE archived_at IS
NULL. Closes the race-condition window but needs a migration plus
any existing-duplicate cleanup — too risky for this hotfix.
- Rate limiting on /api/company/check-org-number. Endpoint is
auth-gated so not an immediate concern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: add Luhn validation and extract org-number normalization
Third round of PR review feedback (swedish-compliance):
- Add Luhn-10 check-digit validation to normalizeOrgNumber. Rejects
structurally invalid org_numbers (wrong check digit) at the boundary
instead of letting them propagate into SIE4 #ORGNR and SRU INFO.SRU,
where Skatteverket and receiving accounting systems would reject
them later anyway. Reuses the existing luhnValidate helper from
lib/bankgiro/luhn.ts (Bankgirot 10-modulen — same algorithm applies
to both Bolagsverket org numbers and Swedish personnummer).
- Extract normalizeOrgNumber into lib/company-lookup/normalize-org-number.ts
so the server action and /api/company/check-org-number use the same
rule. Previously the API route only stripped hyphens/spaces, so a
12-digit input would miss a stored 10-digit duplicate and mislead the
client debounce check ("not a duplicate" → submit → server rejects).
- /api/company/check-org-number now returns exists=false for
Luhn-invalid input rather than querying the DB. The submit-time
server action surfaces org_number_invalid, which is the right place
for the error.
Test coverage: dedicated normalize-org-number.test.ts (10 cases
covering both-lengths, Luhn, whitespace tolerance, garbage). Updated
existing tests to use Luhn-valid numbers (real Volvo 5560125790,
synthetic personnummer 8001011231). New failing-Luhn test in
actions.test.ts. New 12-digit-normalization and
luhn-invalid-returns-false tests in route.test.ts.
Full suite: 2315 passing.
Not fixed (out of scope for this hotfix):
- 10↔12 digit round-trip fragility for personnummer born 2000+. This
is a codebase-wide architectural choice (see lib/skatteverket/format.ts
which uses a two-digit-year heuristic to choose 19/20 at export).
Migrating to 12-digit storage is a separate refactor.
- Server-side re-fetch of TIC /lookup for isCeased. The trust boundary
here is user-to-their-own-onboarding, not adversarial; doubling TIC
API cost isn't proportionate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
898 lines
32 KiB
TypeScript
898 lines
32 KiB
TypeScript
import type { Extension } from '@/lib/extensions/types'
|
|
import { NextResponse } from 'next/server'
|
|
import {
|
|
searchCompanyByOrgNumber,
|
|
getBankAccounts,
|
|
getSNICodes,
|
|
getEmails,
|
|
getPhones,
|
|
getCompanyPurpose,
|
|
getFinancialReportSummaries,
|
|
} from './lib/tic-client'
|
|
import {
|
|
startBankIdAuth,
|
|
pollBankIdSession,
|
|
collectBankIdResult,
|
|
cancelBankIdSession,
|
|
requestEnrichment,
|
|
fetchEnrichmentData,
|
|
} from './lib/bankid-client'
|
|
import { TICAPIError } from './lib/tic-types'
|
|
import type { TICCompanyProfile } from './lib/tic-types'
|
|
import type { BankIdCompleteRequest } from './lib/bankid-types'
|
|
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
|
import { hashPersonalNumber, encryptPersonalNumber } from '@/lib/auth/bankid'
|
|
import { createServiceClient } from '@/lib/supabase/server'
|
|
import { createLogger } from '@/lib/logger'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import crypto from 'crypto'
|
|
|
|
const log = createLogger('tic/bankid')
|
|
|
|
/**
|
|
* Request SPAR + CompanyRoles enrichment for a completed BankID session and
|
|
* cache the result in `extension_data` so /select-company and the onboarding
|
|
* wizard can pre-fill from it. Non-blocking: any failure is logged and
|
|
* swallowed — BankID auth must still succeed even if enrichment is down.
|
|
*/
|
|
async function fetchAndStoreEnrichment(
|
|
sessionId: string,
|
|
userId: string,
|
|
supabase: SupabaseClient,
|
|
): Promise<void> {
|
|
try {
|
|
const enrichment = await requestEnrichment(sessionId, ['SPAR', 'CompanyRoles'])
|
|
log.info('enrichment request returned', {
|
|
status: enrichment.status,
|
|
requestedTypes: enrichment.requestedTypes,
|
|
completedTypes: enrichment.completedTypes,
|
|
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
|
|
|
|
const enrichmentData = await fetchEnrichmentData(enrichment.secureUrl)
|
|
|
|
// Log a PII-free snapshot so we can debug the role filter in production.
|
|
// Raw personnummer/names are deliberately omitted.
|
|
const firstRole = enrichmentData.companyRoles?.[0]
|
|
log.info('enrichment data shape', {
|
|
hasSpar: !!enrichmentData.spar,
|
|
companyCount: enrichmentData.companyRoles?.length ?? 0,
|
|
firstRoleStatuses: firstRole
|
|
? {
|
|
companyStatus: firstRole.companyStatus,
|
|
positionEndIsNull: firstRole.positionEnd === null,
|
|
positionTypes: firstRole.positionTypes,
|
|
legalEntityType: firstRole.legalEntityType,
|
|
}
|
|
: null,
|
|
})
|
|
|
|
await supabase
|
|
.from('extension_data')
|
|
.upsert({
|
|
user_id: userId,
|
|
extension_id: 'tic',
|
|
key: 'bankid_enrichment',
|
|
value: enrichmentData,
|
|
}, { onConflict: 'user_id,extension_id,key' })
|
|
} catch (enrichError) {
|
|
log.warn('enrichment failed (non-blocking)', enrichError)
|
|
}
|
|
}
|
|
|
|
// Server-side per-IP rate limit for /bankid/start (each call = billable TIC session)
|
|
const bankIdStartCooldowns = new Map<string, number>()
|
|
const BANKID_START_COOLDOWN_MS = 5_000
|
|
|
|
/** Map TIC bankAccountType enum to human-readable string */
|
|
function bankAccountTypeLabel(type?: number): string {
|
|
switch (type) {
|
|
case 0: return 'bankkonto'
|
|
case 1: return 'bankgiro'
|
|
case 2: return 'plusgiro'
|
|
case 3: return 'iban'
|
|
default: return 'bankkonto'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Translate any error from the TIC pipeline into a structured HTTP response.
|
|
*
|
|
* Status mapping:
|
|
* - NOT_CONFIGURED → 503 (proxy URL missing)
|
|
* - RATE_LIMIT_EXCEEDED → 429 (TIC quota hit)
|
|
* - TIMEOUT → 504 (TIC took longer than 15s)
|
|
* - upstream 4xx → 400 (TIC rejected the input — typically a malformed org number)
|
|
* - upstream 5xx → 502 (TIC outage)
|
|
* - other / unknown → 500
|
|
*
|
|
* Always logs the cleaned org number so we can correlate failures with input
|
|
* in Vercel logs.
|
|
*/
|
|
function handleTicError(
|
|
error: unknown,
|
|
log: { error: (msg: string, meta?: unknown) => void } | Console,
|
|
route: 'lookup' | 'profile',
|
|
orgNumber: string,
|
|
fallbackMessage: string
|
|
): Response {
|
|
if (error instanceof TICAPIError) {
|
|
const meta = {
|
|
route,
|
|
orgNumber,
|
|
message: error.message,
|
|
statusCode: error.statusCode,
|
|
code: error.code,
|
|
}
|
|
|
|
if (error.code === 'NOT_CONFIGURED') {
|
|
log.error(`[tic] ${route}: not configured`, meta)
|
|
return NextResponse.json({ error: 'TIC is not configured' }, { status: 503 })
|
|
}
|
|
|
|
if (error.code === 'RATE_LIMIT_EXCEEDED') {
|
|
log.error(`[tic] ${route}: rate limit exceeded`, meta)
|
|
return NextResponse.json({ error: 'Rate limit exceeded, try again later' }, { status: 429 })
|
|
}
|
|
|
|
if (error.code === 'TIMEOUT') {
|
|
log.error(`[tic] ${route}: upstream timeout`, meta)
|
|
return NextResponse.json(
|
|
{ error: 'TIC service did not respond in time' },
|
|
{ status: 504 }
|
|
)
|
|
}
|
|
|
|
// Upstream returned a non-OK status we surfaced as a TICAPIError
|
|
if (typeof error.statusCode === 'number') {
|
|
if (error.statusCode >= 400 && error.statusCode < 500) {
|
|
log.error(`[tic] ${route}: upstream rejected request`, meta)
|
|
return NextResponse.json(
|
|
{ error: 'Invalid request to TIC (upstream rejected)' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
if (error.statusCode >= 500) {
|
|
log.error(`[tic] ${route}: upstream error`, meta)
|
|
return NextResponse.json(
|
|
{ error: 'TIC service is temporarily unavailable' },
|
|
{ status: 502 }
|
|
)
|
|
}
|
|
}
|
|
|
|
// Network/DNS/parse failure surfaced as a TICAPIError without code or statusCode
|
|
log.error(`[tic] ${route}: upstream failure`, meta)
|
|
return NextResponse.json(
|
|
{ error: 'TIC service is temporarily unavailable' },
|
|
{ status: 502 }
|
|
)
|
|
}
|
|
|
|
log.error(`[tic] ${route}: unexpected error`, {
|
|
route,
|
|
orgNumber,
|
|
error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : String(error),
|
|
})
|
|
return NextResponse.json({ error: fallbackMessage }, { status: 500 })
|
|
}
|
|
|
|
export const ticExtension: Extension = {
|
|
id: 'tic',
|
|
name: 'Bolagsuppgifter',
|
|
version: '1.0.0',
|
|
sector: 'general',
|
|
|
|
apiRoutes: [
|
|
{
|
|
method: 'GET',
|
|
path: '/lookup',
|
|
handler: async (request: Request, ctx?) => {
|
|
const log = ctx?.log ?? console
|
|
const url = new URL(request.url)
|
|
const orgNumber = url.searchParams.get('org_number')
|
|
|
|
if (!orgNumber) {
|
|
return NextResponse.json(
|
|
{ error: 'org_number query parameter is required' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
const cleanedOrgNumber = orgNumber.replace(/[\s-]/g, '')
|
|
|
|
try {
|
|
// Phase 1: Search — returns name, address, registration flags
|
|
const doc = await searchCompanyByOrgNumber(orgNumber)
|
|
|
|
if (!doc) {
|
|
return NextResponse.json(
|
|
{ error: 'Company not found' },
|
|
{ status: 404 }
|
|
)
|
|
}
|
|
|
|
// Extract company name (prefer 'name' type over other naming types)
|
|
const nameEntry =
|
|
doc.names.find((n) => n.companyNamingType === 'name') ?? doc.names[0]
|
|
const companyName = nameEntry?.nameOrIdentifier ?? ''
|
|
|
|
const isCeased = doc.activityStatus === 'ceased'
|
|
|
|
const address = doc.mostRecentRegisteredAddress
|
|
? {
|
|
street: doc.mostRecentRegisteredAddress.streetAddress
|
|
?? doc.mostRecentRegisteredAddress.street
|
|
?? null,
|
|
postalCode: doc.mostRecentRegisteredAddress.postalCode ?? null,
|
|
city: doc.mostRecentRegisteredAddress.city ?? null,
|
|
}
|
|
: null
|
|
|
|
const registration = {
|
|
fTax: doc.isRegisteredForFTax ?? false,
|
|
vat: doc.isRegisteredForVAT ?? false,
|
|
}
|
|
|
|
// Phase 2: Supplementary data (non-blocking)
|
|
const companyId = doc.companyId
|
|
const [bankResult, sniResult, emailResult, phoneResult] =
|
|
await Promise.allSettled([
|
|
getBankAccounts(companyId),
|
|
getSNICodes(companyId),
|
|
getEmails(companyId),
|
|
getPhones(companyId),
|
|
])
|
|
|
|
const bankAccounts =
|
|
bankResult.status === 'fulfilled' && bankResult.value
|
|
? bankResult.value.map((ba) => ({
|
|
type: bankAccountTypeLabel(ba.bankAccountType),
|
|
accountNumber: ba.accountNumber ?? '',
|
|
bic: ba.swift_BIC ?? null,
|
|
}))
|
|
: []
|
|
|
|
const sniCodes =
|
|
sniResult.status === 'fulfilled' && sniResult.value
|
|
? sniResult.value.map((s) => ({
|
|
code: s.sni_2007Code ?? '',
|
|
name: s.sni_2007Name ?? '',
|
|
}))
|
|
: []
|
|
|
|
const email =
|
|
emailResult.status === 'fulfilled' && emailResult.value?.[0]?.emailAddress
|
|
? emailResult.value[0].emailAddress
|
|
: null
|
|
|
|
const phone =
|
|
phoneResult.status === 'fulfilled' && phoneResult.value?.[0]?.phoneNumber
|
|
? phoneResult.value[0].phoneNumber
|
|
: null
|
|
|
|
// Log Phase 2 failures for debugging
|
|
if (bankResult.status === 'rejected') {
|
|
log.warn('[tic] bank accounts fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(bankResult.reason) })
|
|
}
|
|
if (sniResult.status === 'rejected') {
|
|
log.warn('[tic] SNI codes fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(sniResult.reason) })
|
|
}
|
|
|
|
const result: CompanyLookupResult = {
|
|
companyName,
|
|
isCeased,
|
|
address,
|
|
registration,
|
|
bankAccounts,
|
|
email,
|
|
phone,
|
|
sniCodes,
|
|
}
|
|
|
|
return NextResponse.json({ data: result })
|
|
} catch (error) {
|
|
return handleTicError(error, log, 'lookup', cleanedOrgNumber, 'Failed to look up company')
|
|
}
|
|
},
|
|
},
|
|
{
|
|
method: 'GET',
|
|
path: '/profile',
|
|
handler: async (request: Request, ctx?) => {
|
|
const log = ctx?.log ?? console
|
|
const url = new URL(request.url)
|
|
const orgNumber = url.searchParams.get('org_number')
|
|
|
|
if (!orgNumber) {
|
|
return NextResponse.json(
|
|
{ error: 'org_number query parameter is required' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
const cleanedOrgNumber = orgNumber.replace(/[\s-]/g, '')
|
|
|
|
try {
|
|
const doc = await searchCompanyByOrgNumber(orgNumber)
|
|
|
|
if (!doc) {
|
|
return NextResponse.json(
|
|
{ error: 'Company not found' },
|
|
{ status: 404 }
|
|
)
|
|
}
|
|
|
|
const nameEntry =
|
|
doc.names.find((n) => n.companyNamingType === 'name') ?? doc.names[0]
|
|
const companyName = nameEntry?.nameOrIdentifier ?? ''
|
|
const companyId = doc.companyId
|
|
|
|
// Phase 2: Supplementary data (non-blocking)
|
|
const [bankResult, sniResult, emailResult, phoneResult, purposeResult, reportsResult] =
|
|
await Promise.allSettled([
|
|
getBankAccounts(companyId),
|
|
getSNICodes(companyId),
|
|
getEmails(companyId),
|
|
getPhones(companyId),
|
|
getCompanyPurpose(companyId),
|
|
getFinancialReportSummaries(companyId),
|
|
])
|
|
|
|
const bankAccounts =
|
|
bankResult.status === 'fulfilled' && bankResult.value
|
|
? bankResult.value.map((ba) => ({
|
|
type: bankAccountTypeLabel(ba.bankAccountType),
|
|
accountNumber: ba.accountNumber ?? '',
|
|
bic: ba.swift_BIC ?? null,
|
|
}))
|
|
: []
|
|
|
|
const sniCodes =
|
|
sniResult.status === 'fulfilled' && sniResult.value
|
|
? sniResult.value.map((s) => ({
|
|
code: s.sni_2007Code ?? '',
|
|
name: s.sni_2007Name ?? '',
|
|
}))
|
|
: []
|
|
|
|
const email =
|
|
emailResult.status === 'fulfilled' && emailResult.value?.[0]?.emailAddress
|
|
? emailResult.value[0].emailAddress
|
|
: null
|
|
|
|
const phone =
|
|
phoneResult.status === 'fulfilled' && phoneResult.value?.[0]?.phoneNumber
|
|
? phoneResult.value[0].phoneNumber
|
|
: null
|
|
|
|
const financialReports =
|
|
reportsResult.status === 'fulfilled' && reportsResult.value
|
|
? reportsResult.value
|
|
: []
|
|
|
|
// Use dedicated purpose endpoint, fall back to search result
|
|
const purpose =
|
|
purposeResult.status === 'fulfilled' && purposeResult.value?.[0]?.purpose
|
|
? purposeResult.value[0].purpose
|
|
: doc.mostRecentPurpose ?? null
|
|
|
|
// Log Phase 2 failures
|
|
if (bankResult.status === 'rejected') {
|
|
log.warn('[tic] profile: bank accounts fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(bankResult.reason) })
|
|
}
|
|
if (sniResult.status === 'rejected') {
|
|
log.warn('[tic] profile: SNI codes fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(sniResult.reason) })
|
|
}
|
|
if (reportsResult.status === 'rejected') {
|
|
log.warn('[tic] profile: financial reports fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(reportsResult.reason) })
|
|
}
|
|
|
|
const fin = doc.mostRecentFinancialSummary
|
|
const financials = fin
|
|
? {
|
|
periodStart: fin.periodStart,
|
|
periodEnd: fin.periodEnd,
|
|
netSalesK: fin.rs_NetSalesK ?? null,
|
|
operatingProfitK: fin.rs_OperatingProfitOrLossK ?? null,
|
|
totalAssetsK: fin.bs_TotalAssetsK ?? null,
|
|
numberOfEmployees: fin.fn_NumberOfEmployees ?? null,
|
|
operatingMargin: fin.km_OperatingMargin ?? null,
|
|
netProfitMargin: fin.km_NetProfitMargin ?? null,
|
|
equityAssetsRatio: fin.km_EquityAssetsRatio ?? null,
|
|
}
|
|
: null
|
|
|
|
const profile: TICCompanyProfile = {
|
|
companyId,
|
|
orgNumber: doc.registrationNumber,
|
|
companyName,
|
|
legalEntityType: doc.legalEntityType,
|
|
registrationDate: doc.registrationDate,
|
|
activityStatus: doc.activityStatus ?? null,
|
|
purpose,
|
|
address: doc.mostRecentRegisteredAddress
|
|
? {
|
|
street: doc.mostRecentRegisteredAddress.streetAddress
|
|
?? doc.mostRecentRegisteredAddress.street
|
|
?? null,
|
|
postalCode: doc.mostRecentRegisteredAddress.postalCode ?? null,
|
|
city: doc.mostRecentRegisteredAddress.city ?? null,
|
|
}
|
|
: null,
|
|
registration: {
|
|
fTax: doc.isRegisteredForFTax ?? false,
|
|
vat: doc.isRegisteredForVAT ?? false,
|
|
payroll: doc.isRegisteredForPayroll ?? false,
|
|
},
|
|
sector: doc.cSector
|
|
? { code: doc.cSector.categoryCode, description: doc.cSector.categoryCodeDescription }
|
|
: null,
|
|
employeeRange: doc.cNbrEmployeesInterval?.categoryCodeDescription ?? null,
|
|
turnoverRange: doc.cTurnoverInterval?.categoryCodeDescription ?? null,
|
|
email,
|
|
phone,
|
|
sniCodes,
|
|
bankAccounts,
|
|
financials,
|
|
financialReports,
|
|
fetchedAt: new Date().toISOString(),
|
|
}
|
|
|
|
return NextResponse.json({ data: profile })
|
|
} catch (error) {
|
|
return handleTicError(error, log, 'profile', cleanedOrgNumber, 'Failed to fetch company profile')
|
|
}
|
|
},
|
|
},
|
|
// ── BankID Authentication ──────────────────────────────────────
|
|
// Routes for BankID login/signup via TIC Identity API.
|
|
// skipAuth: true on auth routes (user has no Supabase session yet).
|
|
|
|
{
|
|
method: 'POST',
|
|
path: '/bankid/start',
|
|
skipAuth: true,
|
|
handler: async (request: Request) => {
|
|
try {
|
|
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
|
|| request.headers.get('x-real-ip')
|
|
|| '127.0.0.1'
|
|
|
|
// Per-IP rate limit (each start = billable TIC session)
|
|
const now = Date.now()
|
|
const lastStart = bankIdStartCooldowns.get(ip) ?? 0
|
|
if (now - lastStart < BANKID_START_COOLDOWN_MS) {
|
|
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
|
|
}
|
|
bankIdStartCooldowns.set(ip, now)
|
|
|
|
// Prevent map from growing unbounded
|
|
if (bankIdStartCooldowns.size > 10_000) {
|
|
const cutoff = now - BANKID_START_COOLDOWN_MS
|
|
for (const [k, v] of bankIdStartCooldowns) {
|
|
if (v < cutoff) bankIdStartCooldowns.delete(k)
|
|
}
|
|
}
|
|
|
|
const userAgent = request.headers.get('user-agent') || undefined
|
|
|
|
const session = await startBankIdAuth(ip, userAgent)
|
|
return NextResponse.json({ data: session })
|
|
} catch (error) {
|
|
if (error instanceof TICAPIError) {
|
|
if (error.code === 'NOT_CONFIGURED') {
|
|
return NextResponse.json({ error: 'not_configured', message: 'BankID is not configured' }, { status: 503 })
|
|
}
|
|
if (error.code === 'RATE_LIMIT_EXCEEDED') {
|
|
return NextResponse.json({ error: 'rate_limit', message: 'Rate limit exceeded' }, { status: 429 })
|
|
}
|
|
if (error.code === 'TIMEOUT') {
|
|
log.error('start timed out — TIC Identity API unreachable', { statusCode: error.statusCode })
|
|
return NextResponse.json({ error: 'service_unavailable', message: 'BankID service is not responding' }, { status: 503 })
|
|
}
|
|
// TIC API returned an error (e.g. 5xx)
|
|
log.error('start failed — TIC API error', { statusCode: error.statusCode, code: error.code, message: error.message })
|
|
return NextResponse.json({ error: 'service_unavailable', message: 'BankID service is temporarily unavailable' }, { status: 502 })
|
|
}
|
|
log.error('start failed — unexpected error', error)
|
|
return NextResponse.json({ error: 'internal_error', message: 'Failed to start BankID session' }, { status: 500 })
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
method: 'POST',
|
|
path: '/bankid/poll',
|
|
skipAuth: true,
|
|
handler: async (request: Request) => {
|
|
try {
|
|
const body = await request.json()
|
|
const sessionId = body?.sessionId
|
|
if (!sessionId || typeof sessionId !== 'string') {
|
|
return NextResponse.json({ error: 'sessionId is required' }, { status: 400 })
|
|
}
|
|
|
|
const result = await pollBankIdSession(sessionId)
|
|
if (result.status !== 'pending') {
|
|
log.info('poll status', { status: result.status, hintCode: result.hintCode, hasUser: !!result.user?.personalNumber })
|
|
}
|
|
return NextResponse.json({ data: result })
|
|
} catch (error) {
|
|
if (error instanceof TICAPIError) {
|
|
if (error.code === 'RATE_LIMIT_EXCEEDED') {
|
|
return NextResponse.json({ error: 'rate_limit', message: 'Rate limit exceeded' }, { status: 429 })
|
|
}
|
|
if (error.code === 'TIMEOUT') {
|
|
log.error('poll timed out — TIC Identity API unreachable')
|
|
return NextResponse.json({ error: 'service_unavailable', message: 'BankID service is not responding' }, { status: 503 })
|
|
}
|
|
log.error('poll failed — TIC API error', { statusCode: error.statusCode, code: error.code, message: error.message })
|
|
return NextResponse.json({ error: 'service_unavailable', message: 'BankID service is temporarily unavailable' }, { status: 502 })
|
|
}
|
|
log.error('poll failed — unexpected error', error)
|
|
return NextResponse.json({ error: 'internal_error', message: 'Failed to poll BankID session' }, { status: 500 })
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
method: 'POST',
|
|
path: '/bankid/complete',
|
|
skipAuth: true,
|
|
handler: async (request: Request) => {
|
|
try {
|
|
const body: BankIdCompleteRequest = await request.json()
|
|
const { sessionId, mode, email } = body
|
|
|
|
if (!sessionId || !mode) {
|
|
return NextResponse.json(
|
|
{ error: 'sessionId and mode are required' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
const trimmedEmail = email?.trim().toLowerCase()
|
|
|
|
if (mode === 'signup' && !trimmedEmail) {
|
|
return NextResponse.json(
|
|
{ error: 'email is required for signup' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// Verify BankID session is complete
|
|
const session = await collectBankIdResult(sessionId)
|
|
if (session.status !== 'complete' || !session.user) {
|
|
return NextResponse.json(
|
|
{ error: 'session_invalid', message: 'BankID session is not complete' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
const { personalNumber, givenName, surname, name } = session.user
|
|
const pnrHash = hashPersonalNumber(personalNumber)
|
|
const supabase = createServiceClient()
|
|
|
|
// Look up existing BankID identity
|
|
const { data: existing } = await supabase
|
|
.from('bankid_identities')
|
|
.select('user_id')
|
|
.eq('personal_number_hash', pnrHash)
|
|
.single()
|
|
|
|
if (mode === 'login') {
|
|
if (!existing) {
|
|
return NextResponse.json({
|
|
error: 'no_account',
|
|
givenName,
|
|
surname,
|
|
}, { status: 404 })
|
|
}
|
|
|
|
// Returning user — generate magic link
|
|
const { data: userData } = await supabase.auth.admin.getUserById(existing.user_id)
|
|
if (!userData?.user?.email) {
|
|
return NextResponse.json(
|
|
{ error: 'session_invalid', message: 'User account not found' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
const { data: link, error: linkError } = await supabase.auth.admin.generateLink({
|
|
type: 'magiclink',
|
|
email: userData.user.email,
|
|
})
|
|
|
|
if (linkError || !link?.properties?.hashed_token) {
|
|
log.error('generateLink failed for login', { message: linkError?.message, code: linkError?.code })
|
|
return NextResponse.json(
|
|
{ error: 'Failed to create session' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
// Refresh enrichment so /select-company sees current Bolagsverket roles.
|
|
await fetchAndStoreEnrichment(sessionId, existing.user_id, supabase)
|
|
|
|
return NextResponse.json({
|
|
data: {
|
|
tokenHash: link.properties.hashed_token,
|
|
type: 'magiclink',
|
|
isNewUser: false,
|
|
},
|
|
})
|
|
}
|
|
|
|
// mode === 'signup'
|
|
if (existing) {
|
|
return NextResponse.json(
|
|
{ error: 'already_linked', message: 'This BankID is already linked to an account' },
|
|
{ status: 409 }
|
|
)
|
|
}
|
|
|
|
// Check if email is already taken by a non-BankID user
|
|
const { data: existingByEmail } = await supabase
|
|
.from('profiles')
|
|
.select('id')
|
|
.eq('email', trimmedEmail!)
|
|
.single()
|
|
|
|
let userId: string
|
|
let isNewUser = true
|
|
|
|
if (existingByEmail) {
|
|
// Email already exists — link BankID to existing account
|
|
userId = existingByEmail.id
|
|
isNewUser = false
|
|
|
|
await supabase.auth.admin.updateUserById(userId, {
|
|
app_metadata: { bankid_linked: true },
|
|
user_metadata: { full_name: name },
|
|
})
|
|
} else {
|
|
// Create new Supabase user
|
|
const randomPassword = crypto.randomBytes(32).toString('base64url')
|
|
const { data: newUser, error: createError } = await supabase.auth.admin.createUser({
|
|
email: trimmedEmail!,
|
|
email_confirm: true,
|
|
password: randomPassword,
|
|
user_metadata: { full_name: name },
|
|
})
|
|
|
|
if (createError || !newUser?.user) {
|
|
log.error('createUser failed', { email: trimmedEmail, status: createError?.status, code: createError?.code, message: createError?.message })
|
|
return NextResponse.json(
|
|
{ error: 'Failed to create account', message: createError?.message },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
userId = newUser.user.id
|
|
|
|
// Mark user as BankID-linked (skips TOTP MFA)
|
|
await supabase.auth.admin.updateUserById(userId, {
|
|
app_metadata: { bankid_linked: true },
|
|
})
|
|
}
|
|
|
|
// Store BankID identity
|
|
const { error: insertError } = await supabase
|
|
.from('bankid_identities')
|
|
.insert({
|
|
user_id: userId,
|
|
personal_number_hash: pnrHash,
|
|
personal_number_enc: encryptPersonalNumber(personalNumber),
|
|
given_name: givenName,
|
|
surname,
|
|
})
|
|
|
|
if (insertError) {
|
|
log.error('insert bankid_identities failed', { message: insertError.message, code: insertError.code })
|
|
return NextResponse.json(
|
|
{ error: 'Failed to link BankID identity' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
// Generate magic link for session
|
|
const { data: link, error: linkError } = await supabase.auth.admin.generateLink({
|
|
type: 'magiclink',
|
|
email: trimmedEmail!,
|
|
})
|
|
|
|
if (linkError || !link?.properties?.hashed_token) {
|
|
log.error('generateLink failed for signup', { message: linkError?.message, code: linkError?.code })
|
|
return NextResponse.json(
|
|
{ error: 'Account created but failed to create session' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
// Enrichment (SPAR + CompanyRoles) — pre-fills /select-company picker.
|
|
await fetchAndStoreEnrichment(sessionId, userId, supabase)
|
|
|
|
return NextResponse.json({
|
|
data: {
|
|
tokenHash: link.properties.hashed_token,
|
|
type: 'magiclink',
|
|
isNewUser,
|
|
},
|
|
})
|
|
} catch (error) {
|
|
if (error instanceof TICAPIError) {
|
|
log.error('complete failed — TIC API error', { statusCode: error.statusCode, code: error.code, message: error.message })
|
|
if (error.code === 'TIMEOUT') {
|
|
return NextResponse.json(
|
|
{ error: 'service_unavailable', message: 'BankID service is not responding' },
|
|
{ status: 503 }
|
|
)
|
|
}
|
|
return NextResponse.json(
|
|
{ error: 'service_unavailable', message: 'BankID verification failed' },
|
|
{ status: 502 }
|
|
)
|
|
}
|
|
log.error('complete failed — unexpected error', error)
|
|
return NextResponse.json(
|
|
{ error: 'internal_error', message: 'Failed to complete BankID authentication' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
method: 'DELETE',
|
|
path: '/bankid/:sessionId',
|
|
skipAuth: true,
|
|
handler: async (request: Request) => {
|
|
try {
|
|
const url = new URL(request.url)
|
|
const sessionId = url.searchParams.get('_sessionId')
|
|
if (!sessionId) {
|
|
return NextResponse.json({ error: 'sessionId is required' }, { status: 400 })
|
|
}
|
|
|
|
await cancelBankIdSession(sessionId)
|
|
return NextResponse.json({ data: { cancelled: true } })
|
|
} catch (error) {
|
|
log.error('cancel failed', error)
|
|
return NextResponse.json({ error: 'Failed to cancel session' }, { status: 500 })
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
method: 'POST',
|
|
path: '/bankid/link',
|
|
// skipAuth: false — requires existing Supabase session
|
|
handler: async (request: Request, ctx?) => {
|
|
try {
|
|
const body = await request.json()
|
|
const { sessionId } = body
|
|
|
|
if (!sessionId || !ctx?.userId) {
|
|
return NextResponse.json({ error: 'sessionId is required' }, { status: 400 })
|
|
}
|
|
|
|
// Verify BankID session
|
|
const session = await collectBankIdResult(sessionId)
|
|
if (session.status !== 'complete' || !session.user) {
|
|
return NextResponse.json(
|
|
{ error: 'session_invalid', message: 'BankID session is not complete' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
const { personalNumber, givenName, surname } = session.user
|
|
const pnrHash = hashPersonalNumber(personalNumber)
|
|
const supabase = createServiceClient()
|
|
|
|
// Check personnummer not already linked to another user
|
|
const { data: existing } = await supabase
|
|
.from('bankid_identities')
|
|
.select('user_id')
|
|
.eq('personal_number_hash', pnrHash)
|
|
.single()
|
|
|
|
if (existing && existing.user_id !== ctx.userId) {
|
|
return NextResponse.json(
|
|
{ error: 'already_linked', message: 'This BankID is already linked to another account' },
|
|
{ status: 409 }
|
|
)
|
|
}
|
|
|
|
if (existing && existing.user_id === ctx.userId) {
|
|
return NextResponse.json({ data: { linked: true, alreadyLinked: true } })
|
|
}
|
|
|
|
// Link BankID to current user
|
|
const { error: insertError } = await supabase
|
|
.from('bankid_identities')
|
|
.insert({
|
|
user_id: ctx.userId,
|
|
personal_number_hash: pnrHash,
|
|
personal_number_enc: encryptPersonalNumber(personalNumber),
|
|
given_name: givenName,
|
|
surname,
|
|
})
|
|
|
|
if (insertError) {
|
|
log.error('link insert failed', { message: insertError.message, code: insertError.code })
|
|
return NextResponse.json(
|
|
{ error: 'Failed to link BankID' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
// Mark user as BankID-linked (skips TOTP MFA)
|
|
await supabase.auth.admin.updateUserById(ctx.userId, {
|
|
app_metadata: { bankid_linked: true },
|
|
})
|
|
|
|
return NextResponse.json({ data: { linked: true } })
|
|
} catch (error) {
|
|
if (error instanceof TICAPIError) {
|
|
log.error('link failed — TIC API error', { statusCode: error.statusCode, code: error.code, message: error.message })
|
|
return NextResponse.json(
|
|
{ error: 'service_unavailable', message: 'BankID service is temporarily unavailable' },
|
|
{ status: 502 }
|
|
)
|
|
}
|
|
log.error('link failed — unexpected error', error)
|
|
return NextResponse.json(
|
|
{ error: 'internal_error', message: 'Failed to link BankID' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
method: 'POST',
|
|
path: '/bankid/unlink',
|
|
// skipAuth: false — requires existing Supabase session
|
|
handler: async (_request: Request, ctx?) => {
|
|
try {
|
|
if (!ctx?.userId) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const supabase = createServiceClient()
|
|
|
|
// Delete bankid_identities row
|
|
const { error: deleteError } = await supabase
|
|
.from('bankid_identities')
|
|
.delete()
|
|
.eq('user_id', ctx.userId)
|
|
|
|
if (deleteError) {
|
|
log.error('unlink delete failed', { message: deleteError.message, code: deleteError.code })
|
|
return NextResponse.json({ error: 'Failed to unlink BankID' }, { status: 500 })
|
|
}
|
|
|
|
// Clear app_metadata.bankid_linked so MFA enforcement resumes
|
|
await supabase.auth.admin.updateUserById(ctx.userId, {
|
|
app_metadata: { bankid_linked: false },
|
|
})
|
|
|
|
return NextResponse.json({ data: { unlinked: true } })
|
|
} catch (error) {
|
|
log.error('unlink failed', error)
|
|
return NextResponse.json({ error: 'Failed to unlink BankID' }, { status: 500 })
|
|
}
|
|
},
|
|
},
|
|
],
|
|
|
|
eventHandlers: [],
|
|
}
|