Files
accounted/app/api/events/route.ts
T
Jakob Wennberg a717f03898 feat(mcp-oauth): let an account with no company connect and sign up from the OAuth popup (#1814 PR 1) (#1855)
* feat(mcp-oauth): let an account with no company connect and sign up from the OAuth popup

Identity unlock for agent-first onboarding (#1814, shape B+). A person
with no Accounted account can now connect from an MCP client, create the
account inside the Connect popup and finish the OAuth dance.

- authorize/token no longer require a company: consent renders a
  companyless variant and the key is minted with company_id NULL.
- validateApiKey returns companyId string|null and binds an unbound key
  to the user's first company on the first validation after it exists.
- MCP server: company-dependent tools and data resources answer with a
  structured NO_COMPANY_YET error; the company-independent tools still
  run; telemetry skips when there is no company scope.
- /api/events fails closed instead of throwing for an unbound key.
- authorize forces TOTP enrollment (not just verification) for password
  accounts with no factor, since the middleware skips enrollment for
  zero-company users; BankID-linked accounts stay exempt.
- /login forwards next to /register; register, GoogleAuthButton and
  /auth/callback carry it back to the consent page (callback honours
  only /api/mcp-oauth/authorize, via safeReturnTo); /mfa/enroll
  hard-navigates to /api/* destinations like /mfa/verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* refactor(company): move getActiveCompanyId out of the next/headers module

lib/auth/api-keys.ts needs the resolver for unbound-key binding, but
lib/company/context.ts imports next/headers for the legacy company cookie
and Turbopack refuses that import on some of api-keys' import paths (the
preview build failed). The resolver and CompanyContextError now live in
lib/company/active-company.ts; context.ts re-exports them so every caller
and test mock is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(mcp-oauth): fail closed on a failed assurance lookup; enroll Back aborts instead of looping

Review findings on #1855: requireAal2 let consent through at AAL1 when
getAuthenticatorAssuranceLevel() returned nothing and a verified factor
existed. Only a positive AAL2 answer passes now; a failed lookup and the
inconsistent verified-factor-at-AAL1 case both step up to /mfa/verify.
Back on /mfa/enroll with the consent page as returnTo went straight back
into the redirect loop; it now aborts to the app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:25:42 +02:00

224 lines
8.9 KiB
TypeScript

import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import {
extractBearerToken,
validateApiKey,
createServiceClientNoCookies,
hasScope,
type ApiKeyMode,
} from '@/lib/auth/api-keys'
import { validateQuery } from '@/lib/api/validate'
import { EventsQuerySchema } from '@/lib/api/schemas'
import { getActiveCompanyId } from '@/lib/company/context'
import type { SupabaseClient } from '@supabase/supabase-js'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { minimisePayload } from '@/lib/webhooks/handler'
import { createLogger } from '@/lib/logger'
const log = createLogger('api/events')
/**
* Scope that gates this endpoint. Declared in lib/auth/api-keys.ts as
* "Polla händelseloggen (event_log) som webhook-fallback": the scope existed
* from day one but was never enforced here, so a key holding only
* DEFAULT_SCOPES (the six read scopes a legacy null-scope key falls back to,
* none of which is events:read) could still drain the whole event log.
*/
const REQUIRED_SCOPE = 'events:read' as const
/** Response header mirroring the v1 wrapper so integrators can see the key mode. */
const MODE_HEADER = 'X-Gnubok-Mode'
interface EventLogRow {
sequence: number
event_type: string
entity_id: string | null
data: unknown
created_at: string
}
/**
* Project a stored event payload through the same minimisation the webhook
* fan-out applies (lib/webhooks/handler.ts `minimisePayload`).
*
* The event_log row stores the emit-site payload minus userId/companyId
* (lib/events/handlers/event-log-handler.ts `stripMetaFields`), which is a
* different and narrower projection than the webhook one. Routing the polled
* rows through minimisePayload keeps the pull surface from ever handing out
* more than the push surface for the same event, and means a future tightening
* (e.g. stripping personnummer from payroll payloads) lands in one place for
* both. GDPR Art.5(1)(c) data minimisation.
*/
function minimiseEventData(value: unknown): unknown {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
return minimisePayload(value as Record<string, unknown>)
}
/**
* GET /api/events
*
* Cursor-based polling endpoint for external automation platforms (n8n, Make, Zapier).
* Returns events from the event_log table in sequence order.
*
* Query params:
* - after (bigint, optional): return events with sequence > this value
* - types (string, optional): comma-separated event type filter
* - limit (int, optional): max results, default 50, cap 100
*
* Supports both session auth (browser) and API key auth (automation platforms).
*/
export async function GET(request: Request) {
// Dual auth: API key or session. This is a deliberate withRouteContext
// opt-out: the wrapper is cookie-session only and cannot express the API-key
// branch. The session branch below still goes through requireAuth(), so MFA
// (AAL2) stays enforced; the key branch runs the same guards the other two
// validateApiKey call sites run (lib/api/v1/with-api-v1.ts,
// extensions/general/mcp-server): scope, then company membership.
let userId: string
let supabase: SupabaseClient
// When authenticated via an API key, the key is BOUND to a specific company.
// Honor that binding (least privilege) rather than resolving the user's
// active company: otherwise a key scoped to company A would leak company B's
// events whenever the user's active_company_id happened to point elsewhere.
let keyCompanyId: string | null = null
let keyMode: ApiKeyMode = 'live'
const token = extractBearerToken(request)
if (token?.startsWith('gnubok_sk_')) {
const authResult = await validateApiKey(token)
if ('error' in authResult) {
// validateApiKey only ever returns 401 (bad/unknown/refresh token) or
// 429 (rate limit): map both onto the canonical envelope, same as v1.
return errorResponseFromCode(
authResult.status === 429 ? 'RATE_LIMITED' : 'UNAUTHORIZED',
log,
{ reason: authResult.error },
)
}
// Guard 1: scope. Mirrors with-api-v1.ts step 4.
if (!hasScope(authResult.scopes, REQUIRED_SCOPE)) {
return errorResponseFromCode('INSUFFICIENT_SCOPE', log, {
details: { required_scope: REQUIRED_SCOPE, granted_scopes: authResult.scopes },
})
}
userId = authResult.userId
keyCompanyId = authResult.companyId
// Guard 3: test keys. A test key is simulation-only for WRITES (the v1
// wrapper forces dry-run and blocks non-simulatable mutations with
// TEST_KEY_WRITE_BLOCKED). This endpoint has no write path at all, so the
// consistent behaviour for a read is the one v1 already ships: serve the
// key's own bound company and label the response, rather than 403 with a
// "cannot be simulated" message that would be untrue for a GET.
keyMode = authResult.mode
// Service-role client: RLS does NOT apply below this line, which is exactly
// why the membership re-check further down is mandatory.
supabase = createServiceClientNoCookies()
} else {
// Session auth: requireAuth enforces MFA (AAL2) on hosted, unlike a bare
// getUser call which skips the assurance-level check.
const auth = await requireAuth()
if (auth.error) return auth.error
supabase = auth.supabase
userId = auth.user.id
}
// Session auth resolves the active company; API-key auth uses the key's bound company.
// A key minted before the user's first company exists (companyless OAuth,
// issue #1814) has no bound company either; both cases resolve to null and
// fail closed below rather than throwing.
const companyId = keyCompanyId ?? await getActiveCompanyId(supabase, userId)
// Defense in depth: never run the event_log query with an empty/undefined
// scope. A user with no company resolves to null, and the guard also covers
// a malformed key binding so it can't widen the query scope.
if (!companyId) {
return errorResponseFromCode('FORBIDDEN', log)
}
// Guard 2: membership re-check, API-key path only.
//
// company_id on the api_keys row is a snapshot taken when the key was minted.
// Offboarding a user from a company does not revoke their keys, and the
// service-role client above bypasses RLS, so without this the key keeps
// draining the event log of a company its owner no longer belongs to. Both
// sibling call sites already re-check (with-api-v1.ts :349-370 and
// mcp-server/company-routing.ts :90-102); this closes the third.
//
// archived_at IS NULL follows company-routing: an archived company is a
// deactivated tenant and should stop feeding automation platforms.
//
// The session path is deliberately excluded: it runs on the request-scoped
// client where RLS (user_company_ids()) already enforces the same rule.
if (keyCompanyId !== null) {
const { data: membership, error: membershipError } = await supabase
.from('company_members')
.select('company_id, companies!inner(archived_at)')
.eq('user_id', userId)
.eq('company_id', companyId)
.is('companies.archived_at', null)
.maybeSingle()
if (membershipError) {
log.error('failed to resolve company membership', membershipError)
return errorResponseFromCode('INTERNAL_ERROR', log, {
details: { reason: getUserErrorMessage(membershipError) },
})
}
if (!membership) {
// 404 rather than 403, matching v1 and MCP: do not confirm the company
// exists to a caller who is no longer allowed to see it.
return errorResponseFromCode('NOT_FOUND', log, { details: { companyId } })
}
}
// Validate query params
const result = validateQuery(request, EventsQuerySchema)
if (!result.success) return result.response
const { after, types, limit } = result.data
// Build query
let query = supabase
.from('event_log')
.select('sequence, event_type, entity_id, data, created_at')
.eq('company_id', companyId)
.order('sequence', { ascending: true })
.limit(limit)
if (after !== undefined) {
query = query.gt('sequence', after)
}
if (types && types.length > 0) {
query = query.in('event_type', types)
}
const { data, error } = await query
if (error) {
log.error('event_log query failed', error)
return errorResponseFromCode('INTERNAL_ERROR', log, {
details: { reason: getUserErrorMessage(error) },
})
}
const rows = (data ?? []) as EventLogRow[]
const events = rows.map((row) => ({ ...row, data: minimiseEventData(row.data) }))
const response = NextResponse.json({
data: events,
cursor: events.length > 0 ? events[events.length - 1].sequence : (after ?? 0),
has_more: events.length === limit,
})
// Signal test mode the same way the v1 wrapper does, so an integrator can see
// which key mode served the response without inspecting the body.
if (keyMode === 'test') {
response.headers.set(MODE_HEADER, 'test')
}
return response
}