diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index c223d1a6..90ba36d8 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -20,6 +20,7 @@ import { CheckCircle2, XCircle, Bot, + BookOpen, } from 'lucide-react' import type { PendingOperation, PendingOperationStatus } from '@/types' @@ -28,6 +29,9 @@ const operationLabels: Record = { send_invoice: 'Genom att klicka godkänn så skickas fakturan till kunden.', mark_invoice_paid: 'Genom att klicka godkänn så bokförs en betalning på fakturan.', mark_invoice_sent: 'Genom att klicka godkänn så märks fakturan som skickad och en verifikation skapas.', + create_voucher: 'Genom att klicka godkänn så bokförs verifikationen med ett nytt verifikationsnummer.', + correct_entry: 'Genom att klicka godkänn så bokförs en storno och en ny korrigerad verifikation i samma period (BFL 5 kap 5§).', + reverse_entry: 'Genom att klicka godkänn så makuleras verifikationen via en storno i samma period.', } function singleActionWarning(operationType: string): string { @@ -208,6 +215,136 @@ function CreateTransactionPreview({ data }: { data: Record }) { ) } +type VoucherLine = { + account_number: string + account_name?: string | null + debit_amount: number + credit_amount: number + line_description?: string | null +} + +function VoucherLinesTable({ lines, currency }: { lines: VoucherLine[]; currency?: string }) { + return ( +
+ {lines.map((line, i) => ( +
+ {line.account_number} + + {line.account_name || line.line_description || '—'} + + + {line.debit_amount > 0 ? formatCurrency(line.debit_amount, currency || 'SEK') : ''} + + + {line.credit_amount > 0 ? formatCurrency(line.credit_amount, currency || 'SEK') : ''} + +
+ ))} +
+ ) +} + +function VoucherPreview({ data }: { data: Record }) { + const lines = (data.lines as VoucherLine[]) || [] + const totalDebit = data.total_debit as number | undefined + const totalCredit = data.total_credit as number | undefined + + return ( +
+
+ Datum + {String(data.entry_date ?? '')} + Beskrivning + {String(data.description ?? '')} + Serie + {String(data.voucher_series ?? 'A')} +
+ {lines.length > 0 && ( +
+
+ Konto + Text + Debet + Kredit +
+ +
+ )} + {totalDebit != null && totalCredit != null && ( +
+ + Summa + + {formatCurrency(totalDebit)} + + + {formatCurrency(totalCredit)} + +
+ )} +
+ ) +} + +function CorrectEntryPreview({ data }: { data: Record }) { + const original = (data.original as { + voucher?: string + entry_date?: string + description?: string + lines?: VoucherLine[] + }) || {} + const correction = (data.correction as { + total_debit?: number + total_credit?: number + line_count?: number + lines?: VoucherLine[] + }) || {} + + return ( +
+
+

+ Originalverifikation V{original.voucher ?? ''} — {original.entry_date ?? ''} +

+

{original.description ?? ''}

+ {original.lines && original.lines.length > 0 && ( + + )} +
+
+

+ Korrigerad verifikation ({correction.line_count ?? correction.lines?.length ?? 0} rader) +

+ {correction.lines && correction.lines.length > 0 && ( + + )} + {correction.total_debit != null && ( +
+ + Summa + + {formatCurrency(correction.total_debit)} + + + {formatCurrency(correction.total_credit ?? 0)} + +
+ )} +
+
+ ) +} + +// Render a primitive (string/number/bool) or a short summary of an array/object. +// Used by GenericPreview to avoid the "[object Object]" stringification that +// occurs when an operation_type has no dedicated preview component. +function renderPrimitive(value: unknown): string { + if (value == null) return '' + if (Array.isArray(value)) return `${value.length} rader` + if (typeof value === 'object') return JSON.stringify(value) + return String(value) +} + function GenericPreview({ data }: { data: Record }) { const entries = Object.entries(data).filter(([, v]) => v != null && v !== '') return ( @@ -216,7 +353,7 @@ function GenericPreview({ data }: { data: Record }) { {key.replace(/_/g, ' ')} - {String(value)} + {renderPrimitive(value)} ))} @@ -234,6 +371,10 @@ function OperationPreview({ op }: { op: PendingOperation }) { return case 'create_transaction': return + case 'create_voucher': + return + case 'correct_entry': + return default: return } diff --git a/app/(dashboard)/settings/api/page.tsx b/app/(dashboard)/settings/api/page.tsx index a829a1d4..9df84ae3 100644 --- a/app/(dashboard)/settings/api/page.tsx +++ b/app/(dashboard)/settings/api/page.tsx @@ -1,11 +1,13 @@ 'use client' import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel' +import { OAuthClientsPanel } from '@/components/settings/OAuthClientsPanel' export default function ApiSettingsPage() { return (
+
) } diff --git a/app/.well-known/oauth-authorization-server/route.ts b/app/.well-known/oauth-authorization-server/route.ts index 1422725a..524ec32a 100644 --- a/app/.well-known/oauth-authorization-server/route.ts +++ b/app/.well-known/oauth-authorization-server/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server' +import { PUBLIC_OAUTH_METADATA_SCOPES } from '@/lib/auth/api-keys' /** * RFC 8414 — OAuth 2.0 Authorization Server Metadata. @@ -16,6 +17,11 @@ export async function GET() { grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['none', 'client_secret_post'], - scopes_supported: ['mcp'], + // Advertise only the safe read-only default scopes plus the coarse + // `mcp` marker. Destructive scopes (*:write, pending_operations:approve, + // bookkeeping:write) are still accepted by /authorize when requested + // explicitly, but enumerating them in public discovery aids + // scope-escalation reconnaissance (CC6.1, defense-in-depth). + scopes_supported: ['mcp', ...PUBLIC_OAUTH_METADATA_SCOPES], }) } diff --git a/app/api/mcp-oauth/authorize/route.ts b/app/api/mcp-oauth/authorize/route.ts index 32b4de20..4eb946e0 100644 --- a/app/api/mcp-oauth/authorize/route.ts +++ b/app/api/mcp-oauth/authorize/route.ts @@ -1,8 +1,11 @@ +import crypto from 'crypto' import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { createAuthCode } from '@/lib/auth/oauth-codes' import { requireCompanyId } from '@/lib/company/context' import { getBranding } from '@/lib/branding/service' +import { isAllowedRedirectUri } from '@/lib/auth/oauth-allowlist' +import { API_KEY_SCOPES, type ApiKeyScope } from '@/lib/auth/api-keys' /** * OAuth 2.0 Authorization Endpoint. @@ -14,16 +17,76 @@ import { getBranding } from '@/lib/branding/service' * after PKCE verification, preventing orphaned keys on abandoned flows. */ -// Allowed redirect URI patterns — prevent open redirect attacks -const ALLOWED_REDIRECT_PATTERNS = [ - /^https:\/\/claude\.ai\/api\//, // Claude.ai API callbacks (connector IDs vary in path) - /^https:\/\/claude\.com\/api\//, // Claude.com API callbacks - /^http:\/\/localhost(:\d+)?\//, // Local development - /^http:\/\/127\.0\.0\.1(:\d+)?\//, // Local development -] +type ScopeParseResult = + | { kind: 'ok'; scopes: ApiKeyScope[] | undefined } + | { kind: 'invalid_scope'; description: string } -function isAllowedRedirectUri(uri: string): boolean { - return ALLOWED_REDIRECT_PATTERNS.some((pattern) => pattern.test(uri)) +/** + * Parse the OAuth `scope` query param (RFC 6749 §3.3 — space-delimited list) + * into the subset of API_KEY_SCOPES that the user actually granted. + * + * Returns: + * - { ok, scopes: undefined } when no scope param was supplied — the token + * endpoint will fall back to DEFAULT_OAUTH_SCOPES (read-only, GDPR + * Art.25(2) data-protection-by-default). + * - { ok, scopes: [...] } when at least one valid scope was requested. + * - { invalid_scope } when a scope param was supplied but every value was + * unknown — refusing the request is safer than silently widening the + * grant to ALL_SCOPES (V10.2.6). + * + * The bare `mcp` marker is treated as "no granular scopes" and accepted for + * backwards compatibility with Claude's connector — it falls through to + * `undefined` so the default-OAuth fallback applies. + */ +function parseRequestedScopes(scopeParam: string | null): ScopeParseResult { + if (!scopeParam) return { kind: 'ok', scopes: undefined } + const requested = scopeParam.split(/\s+/).filter(Boolean) + if (requested.length === 0) return { kind: 'ok', scopes: undefined } + // The coarse-grained `mcp` marker is treated as "no granular request" so + // we can keep Claude's existing flow working unchanged. + const onlyMcp = requested.length === 1 && requested[0] === 'mcp' + if (onlyMcp) return { kind: 'ok', scopes: undefined } + const valid = requested.filter((s): s is ApiKeyScope => s in API_KEY_SCOPES) + if (valid.length === 0) { + return { + kind: 'invalid_scope', + description: 'none of the requested scopes are recognised', + } + } + return { kind: 'ok', scopes: valid } +} + +/** + * Sign the scope payload so a tampered POST cannot widen the grant + * displayed at GET. The HMAC binds the originally requested scope param to + * the consent page that the user actually saw (V10.3.1). + * + * Derived from SUPABASE_SERVICE_ROLE_KEY — same root secret the auth-code + * AEAD uses, so deploying the OAuth surface doesn't require a separate + * signing key. Missing env vars cause /authorize to fail closed. + */ +function getScopeSigningKey(): Buffer { + const secret = process.env.SUPABASE_SERVICE_ROLE_KEY + if (!secret) throw new Error('SUPABASE_SERVICE_ROLE_KEY is required for OAuth scope binding') + return crypto.createHash('sha256').update(`oauth-scope:${secret}`).digest() +} + +function signScopeBinding(scopeParam: string): string { + return crypto.createHmac('sha256', getScopeSigningKey()).update(scopeParam).digest('base64url') +} + +function verifyScopeBinding(scopeParam: string, signature: string): boolean { + if (typeof signature !== 'string' || signature.length === 0) return false + const expected = signScopeBinding(scopeParam) + const expectedBuf = Buffer.from(expected, 'base64url') + let presentedBuf: Buffer + try { + presentedBuf = Buffer.from(signature, 'base64url') + } catch { + return false + } + if (expectedBuf.length !== presentedBuf.length) return false + return crypto.timingSafeEqual(expectedBuf, presentedBuf) } function buildLoginRedirect(request: Request): Response { @@ -48,10 +111,12 @@ function errorRedirect(redirectUri: string, state: string | null, error: string, export async function GET(request: Request) { const url = new URL(request.url) const redirectUri = url.searchParams.get('redirect_uri') - const state = url.searchParams.get('state') - const codeChallenge = url.searchParams.get('code_challenge') + // state and code_challenge are carried through to the POST handler via + // the form action's url.search, so we don't read them here — they're only + // validated on POST. const codeChallengeMethod = url.searchParams.get('code_challenge_method') || 'S256' const responseType = url.searchParams.get('response_type') + const scopeParam = url.searchParams.get('scope') if (responseType !== 'code') { return NextResponse.json( @@ -67,17 +132,19 @@ export async function GET(request: Request) { ) } - // Validate redirect_uri against allowlist (prevents open redirect) - if (!isAllowedRedirectUri(redirectUri)) { + if (codeChallengeMethod !== 'S256') { return NextResponse.json( - { error: 'invalid_request', error_description: 'redirect_uri is not allowed' }, + { error: 'invalid_request', error_description: 'Only S256 code_challenge_method is supported' }, { status: 400 } ) } - if (codeChallengeMethod !== 'S256') { + // Parse the requested scopes up front so the consent display reflects the + // exact grant. Reject early if the client sent only unknown scopes (V10.2.6). + const parsed = parseRequestedScopes(scopeParam) + if (parsed.kind === 'invalid_scope') { return NextResponse.json( - { error: 'invalid_request', error_description: 'Only S256 code_challenge_method is supported' }, + { error: 'invalid_scope', error_description: parsed.description }, { status: 400 } ) } @@ -90,6 +157,15 @@ export async function GET(request: Request) { return buildLoginRedirect(request) } + // Validate redirect_uri against allowlist (prevents open redirect). Passing + // the authenticated client makes the trust boundary explicit (SOC 2 CC6.1). + if (!(await isAllowedRedirectUri(redirectUri, supabase))) { + return NextResponse.json( + { error: 'invalid_request', error_description: 'redirect_uri is not allowed' }, + { status: 400 } + ) + } + const companyId = await requireCompanyId(supabase, user.id) // Get company name for the consent page @@ -103,6 +179,12 @@ export async function GET(request: Request) { const appNameLower = escapeHtml(getBranding().appName.toLowerCase()) + // Bind the requested scope to the consent display. The HMAC signature is + // verified on POST so a tampered form submission cannot widen the grant + // beyond what the user actually saw (V10.3.1). + const scopeBindingValue = scopeParam ?? '' + const scopeBindingSignature = signScopeBinding(scopeBindingValue) + // Render consent page const html = ` @@ -142,10 +224,14 @@ export async function GET(request: Request) {
+ +
+ +
@@ -166,18 +252,12 @@ export async function POST(request: Request) { const redirectUri = url.searchParams.get('redirect_uri') const state = url.searchParams.get('state') const codeChallenge = url.searchParams.get('code_challenge') || '' + const querystringScopeParam = url.searchParams.get('scope') if (!redirectUri) { return NextResponse.json({ error: 'invalid_request' }, { status: 400 }) } - if (!isAllowedRedirectUri(redirectUri)) { - return NextResponse.json( - { error: 'invalid_request', error_description: 'redirect_uri is not allowed' }, - { status: 400 } - ) - } - // Check auth const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() @@ -186,6 +266,15 @@ export async function POST(request: Request) { return buildLoginRedirect(request) } + // Pass the authenticated client so the lookup is bound to the same session + // that the consent display ran under (SOC 2 CC6.1). + if (!(await isAllowedRedirectUri(redirectUri, supabase))) { + return NextResponse.json( + { error: 'invalid_request', error_description: 'redirect_uri is not allowed' }, + { status: 400 } + ) + } + await requireCompanyId(supabase, user.id) // Parse form body @@ -196,11 +285,41 @@ export async function POST(request: Request) { return errorRedirect(redirectUri, state, 'access_denied', 'User denied the request') } + // Verify the scope binding signed at consent display matches what was + // submitted with the form. This prevents a tampered POST from widening the + // grant beyond what the user actually saw (V10.3.1). + const presentedScopeBinding = formData.get('scope_binding') + const presentedScopeBindingSig = formData.get('scope_binding_sig') + const presentedScopeStr = typeof presentedScopeBinding === 'string' ? presentedScopeBinding : '' + const presentedSigStr = typeof presentedScopeBindingSig === 'string' ? presentedScopeBindingSig : '' + const expectedScopeStr = querystringScopeParam ?? '' + if ( + presentedScopeStr !== expectedScopeStr || + !verifyScopeBinding(presentedScopeStr, presentedSigStr) + ) { + return errorRedirect( + redirectUri, + state, + 'invalid_request', + 'Scope binding mismatch — consent token is invalid or has been tampered with' + ) + } + + // Parse the bound scope rather than re-reading the querystring at POST time + // so the auth code always reflects the consent the user gave. parseRequestedScopes + // already rejects requests where every scope is unknown (V10.2.6). + const parsed = parseRequestedScopes(querystringScopeParam) + if (parsed.kind === 'invalid_scope') { + return errorRedirect(redirectUri, state, 'invalid_scope', parsed.description) + } + const requestedScopes = parsed.scopes + // Create auth code with userId (NO API key — that's created at /token after PKCE) const code = createAuthCode({ userId: user.id, codeChallenge, redirectUri, + ...(requestedScopes ? { scopes: requestedScopes } : {}), }) // Redirect to callback with the code diff --git a/app/api/mcp-oauth/register/route.ts b/app/api/mcp-oauth/register/route.ts index 198889ca..997a2828 100644 --- a/app/api/mcp-oauth/register/route.ts +++ b/app/api/mcp-oauth/register/route.ts @@ -1,24 +1,50 @@ import { NextResponse } from 'next/server' import crypto from 'crypto' - -// Allowed redirect URI patterns (must match CLAUDE.md allowlist) -const ALLOWED_REDIRECT_PATTERNS = [ - /^https:\/\/claude\.ai\/api\//, - /^https:\/\/claude\.com\/api\//, - /^http:\/\/localhost(:\d+)?(\/|$)/, - /^http:\/\/127\.0\.0\.1(:\d+)?(\/|$)/, -] - -function isRedirectUriAllowed(uri: string): boolean { - return ALLOWED_REDIRECT_PATTERNS.some(pattern => pattern.test(uri)) -} +import { isAllowedRedirectUri } from '@/lib/auth/oauth-allowlist' +import { createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { checkRateLimit } from '@/lib/auth/rate-limit-http' +import { truncateIp } from '@/lib/api/v1/with-api-v1' /** * RFC 7591 — Dynamic Client Registration. - * Claude Desktop registers itself as an OAuth client before starting the auth flow. - * Validates redirect_uris against the allowlist before accepting registration. + * + * Claude Desktop and self-hosted MCP clients register themselves before + * starting the auth flow. The redirect URIs they declare are validated + * against built-in patterns (Claude/localhost) and the user-managed + * oauth_client_registrations table (self-hosted custom apps). + * + * Security model: + * - Anonymous by design (RFC 7591 §3 allows it); the endpoint does NOT + * write to oauth_client_registrations — only owner/admin users can + * insert via /api/settings/oauth-clients. This endpoint just echoes + * a client_id back to callers whose redirect_uris are already on the + * allowlist. + * - Per-/24 sliding-window rate-limit prevents the endpoint being used + * as a high-rate oracle for enumerating registered URIs. + * - Error responses are uniform across "built-in", "DB-registered", and + * "disallowed" so an attacker cannot distinguish between them. */ + +const REGISTER_RATE_LIMIT = { + // 10 attempts per minute per /24 — enough headroom for a developer + // iterating on a custom MCP client, low enough to make enumeration + // attacks impractical. + maxRequests: 10, + windowMs: 60 * 1000, +} + export async function POST(request: Request) { + // ── IP-based rate limit ───────────────────────────────────── + const fwd = request.headers.get('x-forwarded-for') + const rawIp = fwd ? fwd.split(',')[0]?.trim() : request.headers.get('x-real-ip') ?? undefined + const ipIdentifier = truncateIp(rawIp || undefined) ?? 'unknown' + const rl = await checkRateLimit({ + prefix: 'mcp-oauth:register', + identifier: ipIdentifier, + ...REGISTER_RATE_LIMIT, + }) + if (!rl.ok) return rl.response! + let body: Record try { body = await request.json() @@ -26,12 +52,26 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'invalid_request' }, { status: 400 }) } - // Validate redirect_uris against allowlist + // Service-role client used for the allowlist lookup. Building it once + // keeps the per-URI loop from re-instantiating the client and surfaces + // the trust boundary at the callsite (SOC 2 CC6.1). + let allowlistClient: ReturnType | undefined + try { + allowlistClient = createServiceClientNoCookies() + } catch { + // Env vars missing — built-in patterns still resolve, DB-backed + // registrations will all return false (fail closed). + allowlistClient = undefined + } + const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [] for (const uri of redirectUris) { - if (typeof uri !== 'string' || !isRedirectUriAllowed(uri)) { + if (typeof uri !== 'string' || !(await isAllowedRedirectUri(uri, allowlistClient))) { + // Single error shape regardless of whether the URI is malformed, + // unknown, or revoked — prevents the endpoint being used as an + // enumeration oracle for the user-managed allowlist (CC6.6). return NextResponse.json( - { error: 'invalid_redirect_uri', error_description: `Redirect URI not allowed: ${uri}` }, + { error: 'invalid_redirect_uri', error_description: 'Redirect URI not allowed' }, { status: 400 } ) } diff --git a/app/api/mcp-oauth/token/__tests__/route.test.ts b/app/api/mcp-oauth/token/__tests__/route.test.ts index b3263acd..d3a16d27 100644 --- a/app/api/mcp-oauth/token/__tests__/route.test.ts +++ b/app/api/mcp-oauth/token/__tests__/route.test.ts @@ -267,4 +267,162 @@ describe('POST /api/mcp-oauth/token', () => { expect(body.error_description).toContain('already used') }) }) + + describe('scope plumbing', () => { + it('falls back to read-only DEFAULT_OAUTH_SCOPES when the auth code carries no scopes', async () => { + vi.mocked(decryptAuthCode).mockReturnValue({ + userId: 'user-1', + codeChallenge: 'challenge', + redirectUri: 'https://claude.ai/api/cb', + exp: Date.now() + 60_000, + }) + vi.mocked(verifyPkce).mockReturnValue(true) + + const { supabase, enqueueMany } = createQueuedMockSupabase() + mocks.supabaseFactory.mockReturnValue(supabase) + enqueueMany([ + { data: null, error: null }, + { data: null, error: null }, + { data: null, error: null }, + ]) + + const res = await POST( + formRequest({ + grant_type: 'authorization_code', + code: 'ciphertext', + code_verifier: 'verifier', + redirect_uri: 'https://claude.ai/api/cb', + }) + ) + expect(res.status).toBe(200) + const body = await res.json() + // DEFAULT_OAUTH_SCOPES is read-only by design (GDPR Art. 25(2) — + // destructive scopes must be requested explicitly). + const granted = body.scope.split(' ') + expect(granted).toContain('transactions:read') + expect(granted).toContain('reports:read') + expect(granted).not.toContain('bookkeeping:write') + expect(granted).not.toContain('pending_operations:approve') + expect(granted).not.toContain('transactions:write') + }) + + it('honours scopes from the auth code when present', async () => { + vi.mocked(decryptAuthCode).mockReturnValue({ + userId: 'user-1', + codeChallenge: 'challenge', + redirectUri: 'https://claude.ai/api/cb', + scopes: ['transactions:read', 'invoices:read'], + exp: Date.now() + 60_000, + }) + vi.mocked(verifyPkce).mockReturnValue(true) + + const { supabase, enqueueMany } = createQueuedMockSupabase() + mocks.supabaseFactory.mockReturnValue(supabase) + enqueueMany([ + { data: null, error: null }, + { data: null, error: null }, + { data: null, error: null }, + ]) + + const res = await POST( + formRequest({ + grant_type: 'authorization_code', + code: 'ciphertext', + code_verifier: 'verifier', + redirect_uri: 'https://claude.ai/api/cb', + }) + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.scope).toBe('transactions:read invoices:read') + }) + + it('rejects a code whose embedded scopes are all unknown', async () => { + // V9.2.1 defense-in-depth: even though /authorize already filters + // unknown scopes, the token endpoint must not silently mint a + // key with empty scopes — the auth code payload boundary is + // treated as hostile. + vi.mocked(decryptAuthCode).mockReturnValue({ + userId: 'user-1', + codeChallenge: 'challenge', + redirectUri: 'https://claude.ai/api/cb', + scopes: ['unknown:scope', 'definitely:not:real'] as unknown as string[], + exp: Date.now() + 60_000, + }) + vi.mocked(verifyPkce).mockReturnValue(true) + + const { supabase, enqueueMany } = createQueuedMockSupabase() + mocks.supabaseFactory.mockReturnValue(supabase) + enqueueMany([ + { data: null, error: null }, // insert into oauth_used_codes + { data: null, error: null }, // delete expired codes + ]) + + const res = await POST( + formRequest({ + grant_type: 'authorization_code', + code: 'ciphertext', + code_verifier: 'verifier', + redirect_uri: 'https://claude.ai/api/cb', + }) + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toBe('invalid_grant') + }) + }) + + describe('refresh_token scope response', () => { + it('returns the granular scopes the api_key was minted with', async () => { + // Greptile P1 — refresh response previously hardcoded scope:'mcp', + // causing OAuth 2.1 clients to think they had lost their grant. + const { supabase, enqueueMany } = createQueuedMockSupabase() + mocks.supabaseFactory.mockReturnValue(supabase) + enqueueMany([ + { + data: { + id: 'key-1', + revoked_at: null, + scopes: ['transactions:read', 'invoices:read', 'invoices:write'], + }, + error: null, + }, // SELECT + { data: [{ id: 'key-1' }], error: null }, // UPDATE + ]) + + const res = await POST( + formRequest({ + grant_type: 'refresh_token', + refresh_token: 'gnubok_rt_anything', + }) + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.scope.split(' ').sort()).toEqual( + ['transactions:read', 'invoices:read', 'invoices:write'].sort() + ) + }) + + it('falls back to DEFAULT_OAUTH_SCOPES for legacy keys with null scopes', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + mocks.supabaseFactory.mockReturnValue(supabase) + enqueueMany([ + { data: { id: 'key-1', revoked_at: null, scopes: null }, error: null }, + { data: [{ id: 'key-1' }], error: null }, + ]) + + const res = await POST( + formRequest({ + grant_type: 'refresh_token', + refresh_token: 'gnubok_rt_anything', + }) + ) + expect(res.status).toBe(200) + const body = await res.json() + const granted = body.scope.split(' ') + expect(granted).toContain('transactions:read') + expect(granted).not.toContain('bookkeeping:write') + expect(granted).not.toContain('pending_operations:approve') + }) + }) }) diff --git a/app/api/mcp-oauth/token/route.ts b/app/api/mcp-oauth/token/route.ts index d6234f79..c8da9bc3 100644 --- a/app/api/mcp-oauth/token/route.ts +++ b/app/api/mcp-oauth/token/route.ts @@ -5,7 +5,9 @@ import { generateRefreshToken, hashRefreshToken, createServiceClientNoCookies, - ALL_SCOPES, + validateScopes, + DEFAULT_OAUTH_SCOPES, + type ApiKeyScope, } from '@/lib/auth/api-keys' import { requireCompanyId } from '@/lib/company/context' @@ -121,6 +123,27 @@ async function handleAuthorizationCodeGrant(params: URLSearchParams) { const { key, hash, prefix } = generateApiKey() const refresh = generateRefreshToken() + // Use the scopes the user consented to during /authorize. Re-validate + // every value against API_KEY_SCOPES even though /authorize already did + // — the auth code is AEAD-encrypted but we treat the boundary as + // hostile by default (V9.2.1, defense-in-depth). + let grantedScopes: ApiKeyScope[] + if (payload.scopes && Array.isArray(payload.scopes) && payload.scopes.length > 0) { + const revalidated = validateScopes(payload.scopes) + if (!revalidated) { + return NextResponse.json( + { error: 'invalid_grant', error_description: 'Authorization code carried no valid scopes' }, + { status: 400 } + ) + } + grantedScopes = revalidated + } else { + // Code minted with no scope (Claude's existing flow). Fall back to the + // read-only OAuth defaults — destructive scopes must be requested + // explicitly (GDPR Art. 25(2)). + grantedScopes = DEFAULT_OAUTH_SCOPES + } + const { error: insertError } = await supabase .from('api_keys') .insert({ @@ -129,7 +152,7 @@ async function handleAuthorizationCodeGrant(params: URLSearchParams) { key_hash: hash, key_prefix: prefix, name: 'MCP-klient (OAuth)', - scopes: ALL_SCOPES, + scopes: grantedScopes, refresh_token_hash: refresh.hash, }) @@ -145,7 +168,7 @@ async function handleAuthorizationCodeGrant(params: URLSearchParams) { token_type: 'Bearer', expires_in: ACCESS_TOKEN_TTL_SECONDS, refresh_token: refresh.token, - scope: 'mcp', + scope: grantedScopes.join(' '), }) } @@ -162,10 +185,13 @@ async function handleRefreshTokenGrant(params: URLSearchParams) { const presentedHash = hashRefreshToken(refreshToken) // Look up the api_key row by refresh_token_hash. The hash is unique among - // non-null values, so there's at most one match. + // non-null values, so there's at most one match. We pull `scopes` so the + // rotated token response advertises the same granular grant the key + // already carries (otherwise OAuth 2.1 clients would re-authorize on + // every refresh). const { data: row, error: lookupError } = await supabase .from('api_keys') - .select('id, revoked_at') + .select('id, revoked_at, scopes') .eq('refresh_token_hash', presentedHash) .maybeSingle() @@ -223,11 +249,16 @@ async function handleRefreshTokenGrant(params: URLSearchParams) { ) } + // Return the granular scopes the key was originally minted with. Falling + // back to the read-only OAuth defaults preserves the pre-scope-plumbing + // behaviour for legacy keys whose scopes column is null. + const persistedScopes = validateScopes(row.scopes) ?? DEFAULT_OAUTH_SCOPES + return NextResponse.json({ access_token: newKey, token_type: 'Bearer', expires_in: ACCESS_TOKEN_TTL_SECONDS, refresh_token: rotated.token, - scope: 'mcp', + scope: persistedScopes.join(' '), }) } diff --git a/app/api/pending-operations/[id]/commit/route.ts b/app/api/pending-operations/[id]/commit/route.ts index d0273160..c8d9577e 100644 --- a/app/api/pending-operations/[id]/commit/route.ts +++ b/app/api/pending-operations/[id]/commit/route.ts @@ -43,7 +43,7 @@ export async function POST( user.id, companyId, op as PendingOperation, - { userEmail: user.email } + { userEmail: user.email, commitMethod: 'user_accept' } ) if (result.status === 'committed') { diff --git a/app/api/pending-operations/bulk-commit/__tests__/route.test.ts b/app/api/pending-operations/bulk-commit/__tests__/route.test.ts index ec5c73ed..812ab194 100644 --- a/app/api/pending-operations/bulk-commit/__tests__/route.test.ts +++ b/app/api/pending-operations/bulk-commit/__tests__/route.test.ts @@ -235,7 +235,10 @@ describe('POST /api/pending-operations/bulk-commit', () => { 'user-1', 'company-1', expect.objectContaining({ id: VALID_ID_1 }), - { userEmail: 'test@test.se' } + // commit_method must be 'bulk_accept' so any journal_entries created + // during bulk approval are tagged distinctly from single-approval ones + // (BFNAR 2013:2 behandlingshistorik). + { userEmail: 'test@test.se', commitMethod: 'bulk_accept' } ) }) diff --git a/app/api/pending-operations/bulk-commit/route.ts b/app/api/pending-operations/bulk-commit/route.ts index 97b18077..02df3e0a 100644 --- a/app/api/pending-operations/bulk-commit/route.ts +++ b/app/api/pending-operations/bulk-commit/route.ts @@ -67,6 +67,7 @@ export async function POST(request: Request) { const result = await commitPendingOperation(supabase, user.id, companyId, op, { userEmail: user.email, + commitMethod: 'bulk_accept', }) if (result.status === 'committed') { results.push({ id, status: 'committed' }) diff --git a/app/api/settings/oauth-clients/[id]/route.ts b/app/api/settings/oauth-clients/[id]/route.ts new file mode 100644 index 00000000..59670c2a --- /dev/null +++ b/app/api/settings/oauth-clients/[id]/route.ts @@ -0,0 +1,81 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { requireWritePermission } from '@/lib/auth/require-write' +import { requireCompanyId } from '@/lib/company/context' +import { appendProcessingHistory } from '@/lib/processing-history/append' +import { createLogger } from '@/lib/logger' + +const log = createLogger('oauth-clients:delete') + +/** + * DELETE /api/settings/oauth-clients/[id] — revoke a redirect URI + * registration. Soft-delete via revoked_at so the audit trail survives + * and the same URI can be re-registered later. + * + * Emits an audit event so revocations are visible in processing_history — + * revoking an OAuth client is a security-relevant access-control change + * (SOC 2 CC7.2). Returns 404 when the row is unknown or already revoked + * so callers can surface failures rather than treating "no-op" as success. + */ +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const supabase = await createClient() + const { id } = await params + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const { data: rows, error } = await supabase + .from('oauth_client_registrations') + .update({ revoked_at: new Date().toISOString() }) + .eq('id', id) + .eq('user_id', user.id) + .is('revoked_at', null) + .select('id, redirect_uri, client_name') + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + if (!rows || rows.length === 0) { + return NextResponse.json( + { error: 'OAuth-klient hittades inte eller är redan återkallad.' }, + { status: 404 } + ) + } + + // Audit the revocation. Failure to append must not break the user flow — + // the revocation has already happened in the DB. We log the appendError + // so a systematic outage is visible in operations rather than silently + // degraded. + try { + const companyId = await requireCompanyId(supabase, user.id) + await appendProcessingHistory({ + companyId, + correlationId: id, + aggregateType: 'System', + aggregateId: id, + eventType: 'OAuthClientRevoked', + payload: { + client_id: id, + // Note: redirect_uri may contain a host the user identifies with their + // own infrastructure but is not PII per Art. 4(1). Stored to satisfy + // SOC 2 CC7.2 "what was revoked" evidence trail. + redirect_uri: rows[0].redirect_uri, + }, + actor: { type: 'user', id: user.id }, + occurredAt: new Date(), + }) + } catch (auditErr) { + log.warn('Failed to append OAuthClientRevoked audit event', auditErr) + } + + return NextResponse.json({ success: true }) +} diff --git a/app/api/settings/oauth-clients/route.ts b/app/api/settings/oauth-clients/route.ts new file mode 100644 index 00000000..d6eed189 --- /dev/null +++ b/app/api/settings/oauth-clients/route.ts @@ -0,0 +1,91 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { requireWritePermission } from '@/lib/auth/require-write' +import { z } from 'zod' + +/** + * GET /api/settings/oauth-clients — list the current user's registered + * redirect URIs. + * POST /api/settings/oauth-clients — register a new redirect URI for use + * with the MCP OAuth flow. + * + * Built-in patterns (claude.ai, claude.com, localhost) bypass this table + * entirely — registrations here are only for self-hosted custom apps. + */ + +const RegistrationSchema = z.object({ + client_name: z.string().trim().min(1).max(100), + // Require https for non-loopback URIs. We reject loopback here because + // localhost is already on the built-in allowlist — there's no reason to + // register it explicitly. + redirect_uri: z + .string() + .url('redirect_uri must be a valid URL') + .refine((u) => u.startsWith('https://'), 'redirect_uri must use https://') + .refine( + (u) => !/^https:\/\/(localhost|127\.0\.0\.1|::1)(:|\/|$)/i.test(u), + 'localhost is already allowed without registration' + ) + .max(500), +}) + +export async function GET() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { data, error } = await supabase + .from('oauth_client_registrations') + .select('id, client_name, redirect_uri, created_at, revoked_at') + .eq('user_id', user.id) + .order('created_at', { ascending: false }) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }) +} + +export async function POST(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + let body: z.infer + try { + const json = await request.json() + body = RegistrationSchema.parse(json) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Invalid request body' }, + { status: 400 } + ) + } + + const { data, error } = await supabase + .from('oauth_client_registrations') + .insert({ + user_id: user.id, + client_name: body.client_name, + redirect_uri: body.redirect_uri, + }) + .select('id, client_name, redirect_uri, created_at') + .single() + + if (error) { + // Unique-index violation on redirect_uri → 409 + if (error.code === '23505') { + return NextResponse.json( + { error: 'Den här redirect URI:n är redan registrerad.' }, + { status: 409 } + ) + } + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }) +} diff --git a/components/settings/ApiKeysPanel.tsx b/components/settings/ApiKeysPanel.tsx index 6b13fa70..2fa38691 100644 --- a/components/settings/ApiKeysPanel.tsx +++ b/components/settings/ApiKeysPanel.tsx @@ -19,85 +19,120 @@ import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui import { useToast } from '@/components/ui/use-toast' import { Loader2, Plus, Copy, Check, Trash2, Key, ChevronDown } from 'lucide-react' import { getBranding } from '@/lib/branding/service' +import type { ApiKeyScope } from '@/lib/auth/api-keys' const branding = getBranding() const connectorName = branding.appName.toLowerCase() -const SCOPE_GROUPS = [ +type ScopeEntry = { + scope: ApiKeyScope + label: string + /** Number of MCP tools gated by this scope. 0 = REST-API-only scope. */ + tools: number +} + +type ScopeGroup = { + domain: string + label: string + read: ScopeEntry | null + write: ScopeEntry | null +} + +const SCOPE_GROUPS: ScopeGroup[] = [ { domain: 'transactions', label: 'Transaktioner', - read: 'transactions:read' as const, - readLabel: 'Läs — lista transaktioner, mallar, kategorier', - readTools: 3, - write: 'transactions:write' as const, - writeLabel: 'Skriv — kategorisera, kvittomatchning, koppling mot faktura', - writeTools: 3, + read: { scope: 'transactions:read', label: 'Läs — lista transaktioner, mallar, kategorier, inbox', tools: 8 }, + write: { scope: 'transactions:write', label: 'Skriv — kategorisera, kvittomatchning, koppling mot faktura, dokumentuppladdning', tools: 8 }, }, { domain: 'customers', label: 'Kunder', - read: 'customers:read' as const, - readLabel: 'Läs — lista kunder', - readTools: 1, - write: 'customers:write' as const, - writeLabel: 'Skriv — skapa kunder', - writeTools: 1, + read: { scope: 'customers:read', label: 'Läs — lista kunder', tools: 1 }, + write: { scope: 'customers:write', label: 'Skriv — skapa kunder', tools: 1 }, }, { domain: 'invoices', label: 'Fakturor', - read: 'invoices:read' as const, - readLabel: 'Läs — lista fakturor', - readTools: 1, - write: 'invoices:write' as const, - writeLabel: 'Skriv — skapa, skicka, markera betald/skickad', - writeTools: 4, + read: { scope: 'invoices:read', label: 'Läs — lista fakturor', tools: 1 }, + write: { scope: 'invoices:write', label: 'Skriv — skapa, skicka, markera betald/skickad, kreditera, konvertera', tools: 6 }, }, { domain: 'suppliers', label: 'Leverantörer', - read: 'suppliers:read' as const, - readLabel: 'Läs — lista leverantörer och leverantörsfakturor', - readTools: 2, - write: null, - writeLabel: null, - writeTools: 0, + read: { scope: 'suppliers:read', label: 'Läs — lista leverantörer och leverantörsfakturor', tools: 2 }, + write: { scope: 'suppliers:write', label: 'Skriv — godkänn, kreditera, skapa leverantörsfaktura från inbox', tools: 3 }, }, { domain: 'reports', label: 'Rapporter', - read: 'reports:read' as const, - readLabel: 'Läs — kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, kundreskontra, leverantörsreskontra, räkenskapsperioder, bankavstämning', - readTools: 11, + read: { scope: 'reports:read', label: 'Läs — kontoplan, huvudbok, BR, RR, moms, KPI, reskontra, perioder, bankavstämning, SIE-export', tools: 18 }, write: null, - writeLabel: null, - writeTools: 0, }, -] as const + { + domain: 'bookkeeping', + label: 'Bokföring', + read: null, + write: { scope: 'bookkeeping:write', label: 'Skriv — stänga/låsa perioder, IB, bokslut, SIE-import, verifikat, korrigeringar (alla stagas)', tools: 11 }, + }, + { + domain: 'payroll', + label: 'Löner', + read: { scope: 'payroll:read', label: 'Läs — lista anställda, lönekörningar, lönejournal', tools: 3 }, + write: { scope: 'payroll:write', label: 'Skriv — skapa lönekörning, beräkna, generera AGI', tools: 3 }, + }, + { + domain: 'pending_operations', + label: 'Stagade operationer', + read: { scope: 'pending_operations:read', label: 'Läs — lista pending_operations som väntar på godkännande', tools: 1 }, + write: { scope: 'pending_operations:approve', label: 'Godkänn — committa eller avvisa staged ops via API (ersätter web-UI:s granskning)', tools: 2 }, + }, + { + domain: 'documents', + label: 'Dokument (REST API)', + read: { scope: 'documents:read', label: 'Läs — lista och hämta dokumentbilagor', tools: 0 }, + write: { scope: 'documents:write', label: 'Skriv — ladda upp och koppla dokument till verifikationer', tools: 0 }, + }, + { + domain: 'companies', + label: 'Företag (REST API)', + read: { scope: 'companies:read', label: 'Läs — företagsprofiler nyckeln har åtkomst till', tools: 0 }, + write: null, + }, + { + domain: 'events', + label: 'Händelser (REST API)', + read: { scope: 'events:read', label: 'Läs — polla event_log som webhook-fallback', tools: 0 }, + write: null, + }, + { + domain: 'webhooks', + label: 'Webhooks (REST API)', + read: null, + write: { scope: 'webhooks:manage', label: 'Hantera — skapa, lista, uppdatera, radera prenumerationer', tools: 0 }, + }, + { + domain: 'operations', + label: 'Operationer (REST API)', + read: { scope: 'operations:read', label: 'Läs — status för långkörande operationer (import, bokslut, omvärdering)', tools: 0 }, + write: null, + }, + { + domain: 'compliance', + label: 'Compliance (REST API)', + read: { scope: 'compliance:read', label: 'Läs — pre-flight: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet', tools: 0 }, + write: null, + }, +] -type Scope = - | 'transactions:read' | 'transactions:write' - | 'customers:read' | 'customers:write' - | 'invoices:read' | 'invoices:write' - | 'suppliers:read' - | 'reports:read' +type Scope = ApiKeyScope -const ALL_SCOPES: Scope[] = SCOPE_GROUPS.flatMap((g) => - g.write ? [g.read, g.write] : [g.read] -) - -/** Map scope key → display label for badges */ -const SCOPE_LABELS: Record = { - 'transactions:read': 'Transaktioner (läs)', - 'transactions:write': 'Transaktioner (skriv)', - 'customers:read': 'Kunder (läs)', - 'customers:write': 'Kunder (skriv)', - 'invoices:read': 'Fakturor (läs)', - 'invoices:write': 'Fakturor (skriv)', - 'suppliers:read': 'Leverantörer (läs)', - 'reports:read': 'Rapporter (läs)', -} +const ALL_SCOPES: Scope[] = SCOPE_GROUPS.flatMap((g) => { + const out: Scope[] = [] + if (g.read) out.push(g.read.scope) + if (g.write) out.push(g.write.scope) + return out +}) interface ApiKey { id: string @@ -426,46 +461,52 @@ export function ApiKeysPanel() {

{group.label}

- - {group.write && ( + {group.read && ( + )} + {group.write && ( + )}
diff --git a/components/settings/OAuthClientsPanel.tsx b/components/settings/OAuthClientsPanel.tsx new file mode 100644 index 00000000..a5087f9e --- /dev/null +++ b/components/settings/OAuthClientsPanel.tsx @@ -0,0 +1,234 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' +import { useToast } from '@/components/ui/use-toast' +import { Loader2, Plus, Trash2, Globe } from 'lucide-react' + +interface OAuthClient { + id: string + client_name: string + redirect_uri: string + created_at: string + revoked_at: string | null +} + +export function OAuthClientsPanel() { + const { toast } = useToast() + const { dialogProps: revokeDialogProps, confirm: confirmRevoke } = useDestructiveConfirm() + + const [clients, setClients] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [isCreating, setIsCreating] = useState(false) + const [showCreateDialog, setShowCreateDialog] = useState(false) + const [clientName, setClientName] = useState('') + const [redirectUri, setRedirectUri] = useState('') + + const fetchClients = useCallback(async () => { + try { + const res = await fetch('/api/settings/oauth-clients') + const json = await res.json() + if (json.data) { + setClients(json.data.filter((c: OAuthClient) => !c.revoked_at)) + } + } catch { + toast({ title: 'Kunde inte hämta OAuth-klienter', variant: 'destructive' }) + } finally { + setIsLoading(false) + } + }, [toast]) + + useEffect(() => { + fetchClients() + }, [fetchClients]) + + async function handleCreate() { + setIsCreating(true) + try { + const res = await fetch('/api/settings/oauth-clients', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: clientName.trim() || 'OAuth-klient', + redirect_uri: redirectUri.trim(), + }), + }) + const json = await res.json() + + if (!res.ok) { + toast({ title: json.error ?? 'Kunde inte registrera redirect URI', variant: 'destructive' }) + return + } + + setShowCreateDialog(false) + setClientName('') + setRedirectUri('') + fetchClients() + } catch { + toast({ title: 'Kunde inte registrera redirect URI', variant: 'destructive' }) + } finally { + setIsCreating(false) + } + } + + async function handleRevoke(id: string, name: string) { + const ok = await confirmRevoke({ + title: 'Återkalla OAuth-klient', + description: `"${name}" tas bort från allowlist. Pågående auth-flöden slutar fungera direkt; redan utfärdade API-nycklar fortsätter att gälla tills de återkallas separat.`, + confirmLabel: 'Återkalla', + }) + if (!ok) return + + try { + const res = await fetch(`/api/settings/oauth-clients/${id}`, { method: 'DELETE' }) + if (!res.ok) { + // Surface the server error rather than optimistically pretending the + // revocation succeeded — a silent fail leaves the row in the + // allowlist while the UI says it's gone, which is the opposite of + // what the user expected. + const body = await res.json().catch(() => ({})) + toast({ + title: body?.error || 'Kunde inte återkalla klient', + variant: 'destructive', + }) + return + } + setClients((prev) => prev.filter((c) => c.id !== id)) + toast({ title: 'Klient återkallad' }) + } catch { + toast({ title: 'Kunde inte återkalla klient', variant: 'destructive' }) + } + } + + function formatDate(iso: string) { + return new Date(iso).toLocaleDateString('sv-SE', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + } + + return ( +
+ + +
+
+ OAuth-klienter + + Registrera redirect-URI:er för egenutvecklade MCP-klienter. Claude.ai och localhost + är redan godkända som standard — registrera bara här om du bygger en egen app. + +
+ +
+
+ + {isLoading ? ( +
+ +
+ ) : clients.length === 0 ? ( +
+ +

Inga egna OAuth-klienter registrerade.

+

+ Bygger du en agent som ska ansluta via OAuth? Registrera dess callback-URI här. +

+
+ ) : ( +
+ {clients.map((c) => ( +
+
+

{c.client_name}

+
+ + {c.redirect_uri} + + + Registrerad {formatDate(c.created_at)} + +
+
+ +
+ ))} +
+ )} +
+
+ + + + + Registrera redirect URI + + Måste vara en exakt URL som börjar med https://. Den jämförs sedan ord-för-ord mot + redirect_uri-parametern i OAuth-flödet. + + +
+
+ + setClientName(e.target.value)} + /> +
+
+ + setRedirectUri(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && redirectUri && handleCreate()} + /> +
+
+ + + + +
+
+ + +
+ ) +} diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index 01707571..8b6518a3 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -3,7 +3,7 @@ import { NextResponse } from 'next/server' import { createClient } from '@supabase/supabase-js' import { z } from 'zod' import { uploadDocument } from '@/lib/core/documents/document-service' -import { extractInvoiceFields } from './lib/extract-invoice-fields' +import { extractInvoiceFields, ExtractionSchema, emptyResult } from './lib/extract-invoice-fields' import { verifyInboundWebhook, fetchReceivingEmail, @@ -107,7 +107,8 @@ async function uploadAndExtract( companyId: string, file: { name: string; buffer: ArrayBuffer; type: string }, source: 'upload' | 'email', - emailMeta?: EmailMeta + emailMeta?: EmailMeta, + opts: { skipExtraction?: boolean } = {} ) { const correlationId = crypto.randomUUID() @@ -139,11 +140,18 @@ async function uploadAndExtract( console.error('[invoice-inbox] Failed to append DocumentIngested:', err) } - const { data: extracted, rawText } = await extractInvoiceFields({ - buffer: Buffer.from(file.buffer), - mimeType: file.type, - fileName: file.name, - }) + // Bring-your-own-extraction: skip the Bedrock call entirely and seed an + // empty extraction skeleton. The caller is expected to PUT the parsed + // fields via /items/:id/extracted-data before converting to a supplier + // invoice. extracted_data is never null in the DB; an empty skeleton + // keeps downstream readers (UI, MCP) happy. + const { data: extracted, rawText } = opts.skipExtraction + ? { data: emptyResult(), rawText: null } + : await extractInvoiceFields({ + buffer: Buffer.from(file.buffer), + mimeType: file.type, + fileName: file.name, + }) // Supplier match by org-nr, then case-insensitive name (no AI fuzz). let matchedSupplierId: string | null = null @@ -275,6 +283,12 @@ export const invoiceInboxExtension: Extension = { const formData = await request.formData() const file = formData.get('file') as File | null + // Opt-out of the built-in Claude/Bedrock OCR. Agents with their own + // extraction pipeline upload the document, get the inbox row, then + // PUT /items/:id/extracted-data with their parsed fields. + const skipExtraction = + formData.get('skip_extraction') === 'true' || + formData.get('skip_extraction') === '1' if (!file) return NextResponse.json({ error: 'No file provided' }, { status: 400 }) if (file.size > MAX_FILE_SIZE) { @@ -294,7 +308,9 @@ export const invoiceInboxExtension: Extension = { ctx.userId, ctx.companyId, { name: file.name, buffer, type: file.type }, - 'upload' + 'upload', + undefined, + { skipExtraction } ) return NextResponse.json({ data: result }) } catch (error) { @@ -466,6 +482,141 @@ export const invoiceInboxExtension: Extension = { }, }, + // ── Replace extracted_data wholesale (BYO extraction) ──── + // Used by agents that ran their own OCR/extraction pipeline. Validates + // the full InvoiceExtractionResult shape via the same Zod schema that + // gates Bedrock output, so downstream consumers (UI, supplier-invoice + // creation) cannot tell apart agent-supplied from AI-extracted data. + { + method: 'PUT', + path: '/items/:id/extracted-data', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const url = new URL(request.url) + const id = url.searchParams.get('_id') + if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 }) + + // Rate-limit BYO extraction the same way fresh uploads are limited — + // both paths inject extracted_data into invoice_inbox_items, so an + // unbounded BYO loop is the same abuse surface as an upload flood + // (ISO 27001 A.8.12, data-injection guard). + const rl = await checkInboxUploadRateLimit(ctx.supabase, ctx.companyId) + if (!rl.ok) { + return NextResponse.json( + { error: `För många förfrågningar — försök igen om en stund.` }, + { + status: 429, + headers: rl.retryAfterSec ? { 'Retry-After': String(rl.retryAfterSec) } : undefined, + } + ) + } + + let extracted: InvoiceExtractionResult + try { + const json = await request.json() + // ExtractionSchema doesn't include `confidence` (the AI path tacks + // it on after parsing). BYO data gets 0.95 so downstream UI can + // distinguish it from a perfect AI parse — financial-data + // provenance per ISO 27001 A.8.12. + const parsed = ExtractionSchema.parse(json) + extracted = { ...parsed, confidence: 0.95 } + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Invalid extracted_data shape' }, + { status: 400 } + ) + } + + const { data: item } = await ctx.supabase + .from('invoice_inbox_items') + .select('id, company_id, created_supplier_invoice_id') + .eq('id', id) + .eq('company_id', ctx.companyId) + .maybeSingle() + + if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + // Explicit tenant boundary assertion alongside the .eq filter + // (V4.5.1 defense-in-depth). Surfaces any future change that + // accidentally bypasses the where-clause. + if (item.company_id !== ctx.companyId) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + if (item.created_supplier_invoice_id) { + return NextResponse.json( + { error: 'Posten är redan kopplad till en leverantörsfaktura och kan inte ändras.' }, + { status: 409 } + ) + } + + // Re-run supplier match so the agent's parsed fields trigger the + // same auto-link the AI path uses. Skipped if neither key is present. + let matchedSupplierId: string | null = null + if (extracted.supplier.orgNumber) { + const { data: s } = await ctx.supabase + .from('suppliers') + .select('id') + .eq('company_id', ctx.companyId) + .eq('org_number', extracted.supplier.orgNumber) + .limit(1) + .maybeSingle() + if (s) matchedSupplierId = s.id + } + if (!matchedSupplierId && extracted.supplier.name) { + const { data: s } = await ctx.supabase + .from('suppliers') + .select('id') + .eq('company_id', ctx.companyId) + .ilike('name', extracted.supplier.name) + .limit(1) + .maybeSingle() + if (s) matchedSupplierId = s.id + } + + const { data: updated, error: updateError } = await ctx.supabase + .from('invoice_inbox_items') + .update({ + extracted_data: extracted as unknown as Record, + matched_supplier_id: matchedSupplierId, + }) + .eq('id', id) + .eq('company_id', ctx.companyId) + .select('id, extracted_data, matched_supplier_id') + .single() + + if (updateError) { + return NextResponse.json({ error: updateError.message }, { status: 500 }) + } + + // Audit the BYO override so financial-data provenance is traceable + // (GDPR Art. 5(1)(f), SOC 2 CC9.2). Failure logged but never blocks + // the response — the override has already happened. + try { + await appendProcessingHistory({ + companyId: ctx.companyId, + correlationId: id, + aggregateType: 'Document', + aggregateId: id, + eventType: 'DocumentExtractionOverridden', + payload: { + inbox_item_id: id, + channel: 'rest_api', + has_supplier_org_number: extracted.supplier.orgNumber != null, + has_invoice_number: extracted.invoice.invoiceNumber != null, + extracted_total: extracted.totals.total, + matched_supplier_id: matchedSupplierId, + }, + actor: { type: 'user', id: ctx.userId }, + occurredAt: new Date(), + }) + } catch (auditErr) { + console.error('[invoice-inbox] Failed to append DocumentExtractionOverridden:', auditErr) + } + + return NextResponse.json({ data: updated }) + }, + }, + // ── Attach a source document to an existing inbox item ── { method: 'POST', diff --git a/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts b/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts index 5de510d0..0f09d9bc 100644 --- a/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts +++ b/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts @@ -54,7 +54,7 @@ export interface ExtractionOutput { rawText: string | null } -const ExtractionSchema = z.object({ +export const ExtractionSchema = z.object({ supplier: z.object({ name: z.string().nullable(), orgNumber: z.string().nullable(), @@ -159,7 +159,7 @@ Rules: - lineItems: include every line. Empty array is fine if the document has no itemised lines. - vatBreakdown: include one entry per distinct VAT rate. Empty array is fine.` -function emptyResult(): InvoiceExtractionResult { +export function emptyResult(): InvoiceExtractionResult { return { supplier: { name: null, diff --git a/extensions/general/mcp-server/__tests__/pending-operations-tools.test.ts b/extensions/general/mcp-server/__tests__/pending-operations-tools.test.ts new file mode 100644 index 00000000..37a5ba2a --- /dev/null +++ b/extensions/general/mcp-server/__tests__/pending-operations-tools.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys' + +const commitSpy = vi.fn() + +vi.mock('@/lib/pending-operations/commit', () => ({ + commitPendingOperation: (...args: unknown[]) => commitSpy(...args), +})) + +// Import after mock so the tool registry binds to the mocked module +import { tools } from '../server' + +const listTool = tools.find((t) => t.name === 'gnubok_list_pending_operations')! +const approveTool = tools.find((t) => t.name === 'gnubok_approve_pending_operation')! +const rejectTool = tools.find((t) => t.name === 'gnubok_reject_pending_operation')! + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('pending_operations MCP tools — registration', () => { + it('all three tools are registered', () => { + expect(listTool).toBeDefined() + expect(approveTool).toBeDefined() + expect(rejectTool).toBeDefined() + }) + + it('list is gated by pending_operations:read', () => { + expect(TOOL_SCOPE_MAP['gnubok_list_pending_operations']).toBe('pending_operations:read') + }) + + it('approve and reject are gated by pending_operations:approve', () => { + expect(TOOL_SCOPE_MAP['gnubok_approve_pending_operation']).toBe('pending_operations:approve') + expect(TOOL_SCOPE_MAP['gnubok_reject_pending_operation']).toBe('pending_operations:approve') + }) + + it('input schemas have additionalProperties: false', () => { + for (const t of [listTool, approveTool, rejectTool]) { + expect((t.inputSchema as Record).additionalProperties).toBe(false) + } + }) +}) + +describe('gnubok_list_pending_operations', () => { + it('returns operations with pagination envelope', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const ops = [ + { id: 'op-1', operation_type: 'create_invoice', status: 'pending', risk_level: 'medium', created_at: '2026-05-01T00:00:00Z' }, + { id: 'op-2', operation_type: 'create_voucher', status: 'pending', risk_level: 'high', created_at: '2026-05-02T00:00:00Z' }, + ] + enqueue({ data: ops, error: null, count: 2 }) + + const result = (await listTool.execute( + {}, + 'company-1', + 'user-1', + supabase as never, + { type: 'api_key' } + )) as { + operations: Array<{ id: string }> + count: number + total_count: number + has_more: boolean + } + + expect(result.operations).toHaveLength(2) + expect(result.count).toBe(2) + expect(result.total_count).toBe(2) + expect(result.has_more).toBe(false) + }) + + it('signals has_more + next_offset when more rows exist beyond the page', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: [{ id: 'op-1' }], error: null, count: 50 }) + + const result = (await listTool.execute( + { limit: 1, offset: 0 }, + 'company-1', + 'user-1', + supabase as never, + { type: 'api_key' } + )) as { has_more: boolean; next_offset?: number } + + expect(result.has_more).toBe(true) + expect(result.next_offset).toBe(1) + }) +}) + +describe('gnubok_approve_pending_operation', () => { + it('fetches the op then delegates to commitPendingOperation', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const op = { id: 'op-1', operation_type: 'create_invoice', company_id: 'company-1', status: 'pending', risk_level: 'medium', params: {} } + enqueue({ data: op, error: null }) // fetch + commitSpy.mockResolvedValue({ status: 'committed', data: { invoice_id: 'inv-1' } }) + + const result = (await approveTool.execute( + { operation_id: 'op-1' }, + 'company-1', + 'user-1', + supabase as never, + { type: 'api_key' } + )) as { status: string; operation_id: string; data?: { invoice_id: string } } + + expect(commitSpy).toHaveBeenCalledTimes(1) + expect(commitSpy.mock.calls[0][3]).toMatchObject({ id: 'op-1' }) + // commit options always include commitMethod; userEmail is added when + // the supabase mock supports auth.admin.getUserById (it doesn't here, so + // the resolution silently fails and we fall back to just commitMethod). + expect(commitSpy.mock.calls[0][4]).toMatchObject({ commitMethod: 'user_accept' }) + expect(result.status).toBe('committed') + expect(result.operation_id).toBe('op-1') + expect(result.data?.invoice_id).toBe('inv-1') + }) + + it('refuses to approve a risk_level=high op without confirmed=true', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const op = { + id: 'op-1', + operation_type: 'create_voucher', + company_id: 'company-1', + status: 'pending', + risk_level: 'high', + params: {}, + } + enqueue({ data: op, error: null }) // fetch + + await expect( + approveTool.execute( + { operation_id: 'op-1' }, + 'company-1', + 'user-1', + supabase as never, + { type: 'api_key' } + ) + ).rejects.toThrow(/confirmed=true/i) + expect(commitSpy).not.toHaveBeenCalled() + }) + + it('approves a risk_level=high op when confirmed=true is supplied', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const op = { + id: 'op-1', + operation_type: 'create_voucher', + company_id: 'company-1', + status: 'pending', + risk_level: 'high', + params: {}, + } + enqueue({ data: op, error: null }) // fetch + commitSpy.mockResolvedValue({ status: 'committed' }) + + const result = (await approveTool.execute( + { operation_id: 'op-1', confirmed: true }, + 'company-1', + 'user-1', + supabase as never, + { type: 'api_key' } + )) as { status: string; operation_id: string } + + expect(commitSpy).toHaveBeenCalledTimes(1) + expect(result.status).toBe('committed') + }) + + it('throws when the operation is not found', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: { message: 'not found' } }) + + await expect( + approveTool.execute({ operation_id: 'missing' }, 'company-1', 'user-1', supabase as never) + ).rejects.toThrow(/not found/i) + expect(commitSpy).not.toHaveBeenCalled() + }) + + it('surfaces failed status from the executor', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1', operation_type: 'create_voucher', company_id: 'company-1', status: 'pending', params: {} }, error: null }) + commitSpy.mockResolvedValue({ status: 'failed', error: 'Period locked', http_status: 423 }) + + const result = (await approveTool.execute( + { operation_id: 'op-1' }, + 'company-1', + 'user-1', + supabase as never + )) as { status: string; error?: string } + + expect(result.status).toBe('failed') + expect(result.error).toBe('Period locked') + }) +}) + +describe('gnubok_reject_pending_operation', () => { + it('flips status to rejected and never invokes the executor', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1', status: 'pending' }, error: null }) // fetch + enqueue({ data: [{ id: 'op-1' }], error: null }) // update CAS — returns rows + + const result = (await rejectTool.execute( + { operation_id: 'op-1' }, + 'company-1', + 'user-1', + supabase as never + )) as { status: string; operation_id: string } + + expect(result.status).toBe('rejected') + expect(result.operation_id).toBe('op-1') + expect(commitSpy).not.toHaveBeenCalled() + }) + + it('throws when the CAS update affects 0 rows (concurrent claim)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1', status: 'pending' }, error: null }) // fetch + enqueue({ data: [], error: null }) // update CAS — 0 rows (lost race) + + await expect( + rejectTool.execute({ operation_id: 'op-1' }, 'company-1', 'user-1', supabase as never) + ).rejects.toThrow(/no longer pending/i) + }) + + it('throws 409-style error if the op is already resolved', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1', status: 'committed' }, error: null }) + + await expect( + rejectTool.execute({ operation_id: 'op-1' }, 'company-1', 'user-1', supabase as never) + ).rejects.toThrow(/already committed/i) + }) + + it('throws when the op is missing', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: { message: 'not found' } }) + + await expect( + rejectTool.execute({ operation_id: 'missing' }, 'company-1', 'user-1', supabase as never) + ).rejects.toThrow(/not found/i) + }) +}) diff --git a/extensions/general/mcp-server/__tests__/set-inbox-extracted-data.test.ts b/extensions/general/mcp-server/__tests__/set-inbox-extracted-data.test.ts new file mode 100644 index 00000000..91ff7d85 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/set-inbox-extracted-data.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { tools } from '../server' +import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys' + +const tool = tools.find((t) => t.name === 'gnubok_set_inbox_extracted_data')! + +beforeEach(() => { + vi.clearAllMocks() +}) + +function validPayload() { + return { + supplier: { + name: 'Anthropic Inc.', + orgNumber: null, + vatNumber: null, + address: null, + bankgiro: null, + plusgiro: null, + }, + invoice: { + invoiceNumber: 'A-2026-0001', + invoiceDate: '2026-05-12', + dueDate: '2026-06-11', + paymentReference: null, + currency: 'USD', + }, + lineItems: [ + { + description: 'Claude API usage', + quantity: 1, + unitPrice: 50, + lineTotal: 50, + vatRate: 0, + accountSuggestion: null, + }, + ], + totals: { subtotal: 50, vatAmount: 0, total: 50 }, + vatBreakdown: [{ rate: 0, base: 50, amount: 0 }], + } +} + +describe('gnubok_set_inbox_extracted_data — registration', () => { + it('is registered with the right scope', () => { + expect(tool).toBeDefined() + expect(TOOL_SCOPE_MAP['gnubok_set_inbox_extracted_data']).toBe('suppliers:write') + }) + + it('has additionalProperties: false on the top-level inputSchema', () => { + expect((tool.inputSchema as Record).additionalProperties).toBe(false) + }) +}) + +describe('gnubok_set_inbox_extracted_data — happy path', () => { + it('validates the payload, fetches the item, matches supplier, and updates', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + // fetch inbox item — must include company_id so the defense-in-depth + // tenant check passes. + enqueue({ + data: { id: 'inbox-1', company_id: 'company-1', created_supplier_invoice_id: null }, + error: null, + }) + // supplier match by orgNumber — payload has none, skipped + // supplier match by name (ILIKE) — found + enqueue({ data: { id: 'sup-1' }, error: null }) + // update inbox_items + enqueue({ data: null, error: null }) + + const result = (await tool.execute( + { inbox_item_id: 'inbox-1', extracted_data: validPayload() }, + 'company-1', + 'user-1', + supabase as never, + { type: 'api_key' } + )) as { inbox_item_id: string; matched_supplier_id: string | null; extracted_data: { confidence: number } } + + expect(result.inbox_item_id).toBe('inbox-1') + expect(result.matched_supplier_id).toBe('sup-1') + // BYO data is marked confidence 0.95 (vs 1.0 for AI-perfect parse) so + // downstream provenance is distinguishable. + expect(result.extracted_data.confidence).toBe(0.95) + }) +}) + +describe('gnubok_set_inbox_extracted_data — validation & guards', () => { + it('rejects malformed extracted_data with a Zod error', async () => { + const { supabase } = createQueuedMockSupabase() + await expect( + tool.execute( + { inbox_item_id: 'inbox-1', extracted_data: { supplier: 'not-an-object' } }, + 'company-1', + 'user-1', + supabase as never + ) + ).rejects.toThrow() + }) + + it('throws when the inbox item does not exist', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: null }) + await expect( + tool.execute( + { inbox_item_id: 'missing', extracted_data: validPayload() }, + 'company-1', + 'user-1', + supabase as never + ) + ).rejects.toThrow(/not found/i) + }) + + it('refuses to overwrite when the item already created a supplier invoice', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: { id: 'inbox-1', company_id: 'company-1', created_supplier_invoice_id: 'sinv-1' }, + error: null, + }) + await expect( + tool.execute( + { inbox_item_id: 'inbox-1', extracted_data: validPayload() }, + 'company-1', + 'user-1', + supabase as never + ) + ).rejects.toThrow(/already linked/i) + }) + + it('rejects when the fetched row belongs to a different company (defense-in-depth)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + // The .eq('company_id', companyId) on the SELECT should already prevent + // this in practice, but the explicit assert catches any future query + // change that bypasses the where-clause (V4.5.1). + enqueue({ + data: { id: 'inbox-1', company_id: 'company-other', created_supplier_invoice_id: null }, + error: null, + }) + await expect( + tool.execute( + { inbox_item_id: 'inbox-1', extracted_data: validPayload() }, + 'company-1', + 'user-1', + supabase as never + ) + ).rejects.toThrow(/different company/i) + }) + + it('requires inbox_item_id', async () => { + const { supabase } = createQueuedMockSupabase() + await expect( + tool.execute( + { inbox_item_id: '', extracted_data: validPayload() }, + 'company-1', + 'user-1', + supabase as never + ) + ).rejects.toThrow(/inbox_item_id/i) + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 443e2e42..45124371 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -60,10 +60,12 @@ import { generateInvoiceEmailSubject, } from '@/lib/email/invoice-templates' import { uploadDocument, MAX_DOCUMENT_SIZE } from '@/lib/core/documents/document-service' -import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields' +import { extractInvoiceFields, ExtractionSchema as InvoiceExtractionSchema } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields' +import { commitPendingOperation } from '@/lib/pending-operations/commit' +import { appendProcessingHistory } from '@/lib/processing-history/append' // ensureInitialized() is called by the extension router (ext/[...path]/route.ts) // which dispatches to this handler — no duplicate call needed here. -import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem } from '@/types' +import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem, PendingOperation } from '@/types' // ── Actor context ──────────────────────────────────────────── @@ -6194,6 +6196,401 @@ export const tools: McpTool[] = [ }) }, }, + + // ── Pending operations: list / approve / reject ────────────── + // Mirrors the /pending web UI for agents that self-review before committing. + { + name: 'gnubok_list_pending_operations', + description: 'List staged pending_operations. Filter by status (default pending), risk_level, or operation_type. Use to review the queue before calling gnubok_approve_pending_operation or gnubok_reject_pending_operation.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + status: { type: 'string', enum: ['pending', 'committing', 'committed', 'rejected'], description: 'Default: pending' }, + risk_level: { type: 'string', enum: ['low', 'medium', 'high'] }, + operation_type: { type: 'string', description: 'Filter to a single operation_type (e.g. "create_invoice")' }, + limit: { type: 'number', minimum: 1, maximum: 200, description: 'Default 50' }, + offset: { type: 'number', minimum: 0, description: 'Default 0' }, + }, + required: [], + }, + outputSchema: paginatedSchema('operations'), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + async execute(args, companyId, _userId, supabase) { + const status = (args.status as string) ?? 'pending' + const limit = Math.min(200, Math.max(1, (args.limit as number) ?? 50)) + const offset = Math.max(0, (args.offset as number) ?? 0) + + // `params` holds the raw operation inputs (invoice line items, supplier + // PII, voucher descriptions) — excluded from the list response to + // satisfy data-minimisation (GDPR Art. 5(1)(b)). Use preview_data for + // a redacted, human-readable summary, or call the underlying entity + // endpoint when the agent needs the full payload. + let query = supabase + .from('pending_operations') + .select( + 'id, operation_type, title, preview_data, status, risk_level, actor_type, actor_id, actor_label, created_at, resolved_at, result_data', + { count: 'exact' } + ) + .eq('company_id', companyId) + .eq('status', status) + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1) + + if (args.risk_level) query = query.eq('risk_level', args.risk_level as string) + if (args.operation_type) query = query.eq('operation_type', args.operation_type as string) + + const { data, error, count } = await query + if (error) throw new Error(`Failed to list pending operations: ${error.message}`) + + const operations = data ?? [] + const totalCount = count ?? operations.length + const hasMore = offset + operations.length < totalCount + return { + operations, + count: operations.length, + total_count: totalCount, + has_more: hasMore, + ...(hasMore ? { next_offset: offset + operations.length } : {}), + } + }, + }, + + { + name: 'gnubok_approve_pending_operation', + description: 'Approve a staged pending_operation. Runs the same commit path as web-UI approval — atomic claim → executor → status update. High-risk operations require confirmed=true. Returns status=committed on success, or status=rejected/failed with error details.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + operation_id: { type: 'string', description: 'UUID of the pending_operations row to approve' }, + confirmed: { + type: 'boolean', + description: 'Required when the operation has risk_level=high (create_voucher, correct_entry, reverse_entry, year-end, period lock/close). Acknowledges the BFL/BFNAR irreversibility implications. The web UI surfaces the same gate via an explicit warning dialog.', + }, + }, + required: ['operation_id'], + }, + outputSchema: { + type: 'object', + additionalProperties: false, + properties: { + status: { type: 'string', enum: ['committed', 'rejected', 'failed'] }, + operation_id: { type: 'string' }, + data: { type: 'object' }, + error: { type: 'string' }, + auto_rejected: { type: 'boolean' }, + }, + required: ['status', 'operation_id'], + }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + async execute(args, companyId, userId, supabase, actor) { + const operationId = args.operation_id as string + if (!operationId) throw new Error('operation_id is required') + + const { data: op, error: fetchError } = await supabase + .from('pending_operations') + .select('*') + .eq('id', operationId) + .eq('company_id', companyId) + .single() + + if (fetchError || !op) throw new Error('Pending operation not found') + + // High-risk operations require explicit confirmation in addition to the + // standard pending_operations:approve scope. Mirrors the web-UI gate + // (BFL 5 kap 5§ — irreversible postings require positive acknowledgment). + const operation = op as PendingOperation + if (operation.risk_level === 'high' && args.confirmed !== true) { + throw new Error( + `Operation "${operation.operation_type}" is risk_level=high — pass confirmed=true to approve. The web UI requires the same positive acknowledgment per BFL 5 kap 5§ (irreversible postings).` + ) + } + + // Resolve the user's email so commitPendingOperation can attribute the + // journal_entries.committed_by_email and any user-facing email side + // effects (send_invoice cc) to the actor — matches the web-UI commit + // path attribution (V8.2.1, GDPR Art. 25(1)). + let userEmail: string | undefined + try { + const { data: userData } = await supabase.auth.admin.getUserById(userId) + userEmail = userData.user?.email ?? undefined + } catch (err) { + log.warn('Failed to resolve user email for MCP approval', { userId, err }) + } + + const result = await commitPendingOperation( + supabase, + userId, + companyId, + operation, + { commitMethod: 'user_accept', ...(userEmail ? { userEmail } : {}) } + ) + + // Audit the MCP-initiated approval. Failure must not break the user + // flow — the side-effects have already happened. + try { + await appendProcessingHistory({ + companyId, + correlationId: operationId, + aggregateType: 'System', + aggregateId: operationId, + eventType: 'PendingOperationApproved', + payload: { + operation_id: operationId, + operation_type: operation.operation_type, + risk_level: operation.risk_level, + outcome: result.status, + commit_method: 'user_accept', + channel: 'mcp', + confirmed: args.confirmed === true, + }, + actor: { + type: actor?.type === 'api_key' ? 'api_key' : 'user', + id: actor?.id ?? userId, + ...(actor?.label ? { label: actor.label } : {}), + }, + occurredAt: new Date(), + }) + } catch (auditErr) { + log.warn('Failed to append PendingOperationApproved audit event', auditErr) + } + + return { + status: result.status, + operation_id: operationId, + ...(result.data ? { data: result.data } : {}), + ...(result.error ? { error: result.error } : {}), + ...(result.auto_rejected ? { auto_rejected: true } : {}), + } + }, + }, + + { + name: 'gnubok_reject_pending_operation', + description: 'Reject a staged pending_operation without executing it. Status flips to rejected; no journal entries, invoices, or other side-effects created. Idempotent on already-resolved ops (returns 409).', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + operation_id: { type: 'string', description: 'UUID of the pending_operations row to reject' }, + reason: { + type: 'string', + description: 'Optional human-readable reason recorded in result_data for the audit trail', + maxLength: 500, + }, + }, + required: ['operation_id'], + }, + outputSchema: { + type: 'object', + additionalProperties: false, + properties: { + status: { type: 'string', enum: ['rejected'] }, + operation_id: { type: 'string' }, + }, + required: ['status', 'operation_id'], + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + async execute(args, companyId, userId, supabase, actor) { + const operationId = args.operation_id as string + if (!operationId) throw new Error('operation_id is required') + + const reason = typeof args.reason === 'string' ? args.reason.slice(0, 500) : undefined + + const { data: op, error: fetchError } = await supabase + .from('pending_operations') + .select('id, status, operation_type, risk_level') + .eq('id', operationId) + .eq('company_id', companyId) + .single() + + if (fetchError || !op) throw new Error('Pending operation not found') + if (op.status !== 'pending') throw new Error(`Operation already ${op.status}`) + + // Atomic claim — flips pending → rejected only when the row is still + // pending AND in the caller's tenant (V8.3.1, CC6.3 tenant isolation). + // The .eq('status', 'pending') guard makes this a CAS so a concurrent + // approval cannot lose to a parallel reject. + const { data: updated, error: updateError } = await supabase + .from('pending_operations') + .update({ + status: 'rejected', + resolved_at: new Date().toISOString(), + result_data: { + rejected_by: userId, + rejected_via: actor?.type ?? 'user', + ...(actor?.id ? { actor_id: actor.id } : {}), + ...(reason ? { reason } : {}), + }, + }) + .eq('id', operationId) + .eq('company_id', companyId) + .eq('status', 'pending') + .select('id') + + if (updateError) throw new Error(`Failed to reject operation: ${updateError.message}`) + if (!updated || updated.length === 0) { + throw new Error('Operation no longer pending — another caller claimed it') + } + + // Audit the rejection so the trail mirrors the approval path. + try { + await appendProcessingHistory({ + companyId, + correlationId: operationId, + aggregateType: 'System', + aggregateId: operationId, + eventType: 'PendingOperationRejected', + payload: { + operation_id: operationId, + operation_type: op.operation_type, + risk_level: op.risk_level, + channel: 'mcp', + ...(reason ? { has_reason: true } : { has_reason: false }), + }, + actor: { + type: actor?.type === 'api_key' ? 'api_key' : 'user', + id: actor?.id ?? userId, + ...(actor?.label ? { label: actor.label } : {}), + }, + occurredAt: new Date(), + }) + } catch (auditErr) { + log.warn('Failed to append PendingOperationRejected audit event', auditErr) + } + + return { status: 'rejected' as const, operation_id: operationId } + }, + }, + + // ── Bring-your-own-extraction for inbox items ──────────────── + { + name: 'gnubok_set_inbox_extracted_data', + description: 'Replace extracted_data on an inbox item with agent-supplied fields (bring-your-own-extraction). Use when your own pipeline parses the document better than gnubok\'s OCR. Follow with gnubok_create_supplier_invoice_from_inbox to stage.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + inbox_item_id: { type: 'string', description: 'UUID of the invoice_inbox_items row' }, + extracted_data: { + type: 'object', + description: 'Full InvoiceExtractionResult (supplier, invoice, lineItems, totals, vatBreakdown). Validated server-side via the same Zod schema as the AI extractor.', + }, + }, + required: ['inbox_item_id', 'extracted_data'], + }, + outputSchema: { + type: 'object', + additionalProperties: false, + properties: { + inbox_item_id: { type: 'string' }, + matched_supplier_id: { type: ['string', 'null'] }, + extracted_data: { type: 'object' }, + }, + required: ['inbox_item_id', 'extracted_data'], + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + async execute(args, companyId, userId, supabase, actor) { + const inboxItemId = args.inbox_item_id as string + if (!inboxItemId) throw new Error('inbox_item_id is required') + + const parsed = InvoiceExtractionSchema.parse(args.extracted_data) + // BYO extraction: confidence 0.95 marks the result as agent-supplied + // (vs 1.0 the AI extractor uses on a perfect parse) so downstream UI + // can render the provenance differently (ISO 27001 A.8.12). + const extracted = { ...parsed, confidence: 0.95 } + + const { data: item, error: fetchError } = await supabase + .from('invoice_inbox_items') + .select('id, company_id, created_supplier_invoice_id') + .eq('id', inboxItemId) + .eq('company_id', companyId) + .maybeSingle() + + if (fetchError) throw new Error(`Failed to fetch inbox item: ${fetchError.message}`) + if (!item) throw new Error('Inbox item not found') + // Explicit defense-in-depth tenant check (V4.5.1) alongside the .eq() + // filter on the SELECT — surfaces a tampered service-role query + // before it reaches the UPDATE. + if (item.company_id !== companyId) { + throw new Error('Inbox item belongs to a different company') + } + if (item.created_supplier_invoice_id) { + throw new Error('Inbox item is already linked to a supplier invoice and cannot be modified') + } + + // Re-run supplier match so agent-supplied fields trigger the same + // auto-link the AI path does (org-nr → name, ILIKE). + let matchedSupplierId: string | null = null + if (extracted.supplier.orgNumber) { + const { data: s } = await supabase + .from('suppliers') + .select('id') + .eq('company_id', companyId) + .eq('org_number', extracted.supplier.orgNumber) + .limit(1) + .maybeSingle() + if (s) matchedSupplierId = s.id + } + if (!matchedSupplierId && extracted.supplier.name) { + const { data: s } = await supabase + .from('suppliers') + .select('id') + .eq('company_id', companyId) + .ilike('name', extracted.supplier.name) + .limit(1) + .maybeSingle() + if (s) matchedSupplierId = s.id + } + + const { error: updateError } = await supabase + .from('invoice_inbox_items') + .update({ + extracted_data: extracted as unknown as Record, + matched_supplier_id: matchedSupplierId, + }) + .eq('id', inboxItemId) + .eq('company_id', companyId) + + if (updateError) throw new Error(`Failed to update inbox item: ${updateError.message}`) + + // Audit the BYO override so financial-data provenance is traceable + // (GDPR Art. 5(1)(f), SOC 2 CC9.2). Failure must not block the user + // flow — the override has already landed in the DB. + try { + await appendProcessingHistory({ + companyId, + correlationId: inboxItemId, + aggregateType: 'Document', + aggregateId: inboxItemId, + eventType: 'DocumentExtractionOverridden', + payload: { + inbox_item_id: inboxItemId, + channel: 'mcp', + has_supplier_org_number: extracted.supplier.orgNumber != null, + has_invoice_number: extracted.invoice.invoiceNumber != null, + extracted_total: extracted.totals.total, + matched_supplier_id: matchedSupplierId, + }, + actor: { + type: actor?.type === 'api_key' ? 'api_key' : 'user', + id: actor?.id ?? userId, + ...(actor?.label ? { label: actor.label } : {}), + }, + occurredAt: new Date(), + }) + } catch (auditErr) { + log.warn('Failed to append DocumentExtractionOverridden audit event', auditErr) + } + + return { + inbox_item_id: inboxItemId, + matched_supplier_id: matchedSupplierId, + extracted_data: extracted as unknown as Record, + } + }, + }, ] // ── MCP Protocol Handler ───────────────────────────────────── diff --git a/lib/auth/__tests__/oauth-allowlist.test.ts b/lib/auth/__tests__/oauth-allowlist.test.ts new file mode 100644 index 00000000..a32a279c --- /dev/null +++ b/lib/auth/__tests__/oauth-allowlist.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from 'vitest' +import { isBuiltInRedirectUri, isAllowedRedirectUri } from '../oauth-allowlist' +import type { SupabaseClient } from '@supabase/supabase-js' + +describe('isBuiltInRedirectUri', () => { + it.each([ + ['https://claude.ai/api/oauth/callback', true], + ['https://claude.com/api/oauth/callback', true], + ['http://localhost:3000/cb', true], + ['http://localhost/cb', true], + ['http://127.0.0.1:8080/cb', true], + ['https://evil.com/cb', false], + ['https://example.com/api/foo', false], + ['ftp://localhost/cb', false], + ['', false], + ])('classifies %s as %s', (uri, expected) => { + expect(isBuiltInRedirectUri(uri)).toBe(expected) + }) +}) + +function makeFakeSupabase(rows: Array<{ id: string }>): SupabaseClient { + // Chainable thenable that resolves to { data, error } when awaited via + // .maybeSingle(). Matches the shape isAllowedRedirectUri actually invokes. + const chain = { + from() { return chain }, + select() { return chain }, + eq() { return chain }, + is() { return chain }, + limit() { return chain }, + async maybeSingle() { + return { data: rows[0] ?? null, error: null } + }, + } + return chain as unknown as SupabaseClient +} + +describe('isAllowedRedirectUri', () => { + it('short-circuits to true for built-in patterns without touching the DB', async () => { + const sb = { + from: vi.fn(() => { + throw new Error('should not be called') + }), + } as unknown as SupabaseClient + expect(await isAllowedRedirectUri('https://claude.ai/api/cb', sb)).toBe(true) + expect(await isAllowedRedirectUri('http://localhost:3000/cb', sb)).toBe(true) + }) + + it('returns true when the DB has a registration for the URI', async () => { + const sb = makeFakeSupabase([{ id: 'reg-1' }]) + expect(await isAllowedRedirectUri('https://myapp.example.com/cb', sb)).toBe(true) + }) + + it('returns false when no registration exists', async () => { + const sb = makeFakeSupabase([]) + expect(await isAllowedRedirectUri('https://evil.com/cb', sb)).toBe(false) + }) + + it('returns false for empty / non-string inputs', async () => { + expect(await isAllowedRedirectUri('')).toBe(false) + expect(await isAllowedRedirectUri(undefined as unknown as string)).toBe(false) + }) +}) diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 786a1f93..32eaa71d 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -27,6 +27,8 @@ export const API_KEY_SCOPES = { 'documents:read': { label: 'Dokument — läs', description: 'Lista och hämta dokumentbilagor' }, 'documents:write': { label: 'Dokument — skriv', description: 'Ladda upp och koppla dokument till verifikationer' }, 'compliance:read': { label: 'Compliance — läs', description: 'Pre-flight-kontroller: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet' }, + 'pending_operations:read': { label: 'Stagade operationer — läs', description: 'Lista pending_operations (staged writes awaiting approval)' }, + 'pending_operations:approve': { label: 'Stagade operationer — godkänn', description: 'Godkänn eller avvisa stagade operationer via API/MCP — agenten ersätter web-UI:s granskning' }, } as const export type ApiKeyScope = keyof typeof API_KEY_SCOPES @@ -42,15 +44,79 @@ export const DEFAULT_SCOPES: ApiKeyScope[] = [ 'reports:read', ] +/** + * Read-only fallback granted to OAuth-issued keys when the client did not + * pass an explicit `scope` parameter at /authorize. Per GDPR Art. 25(2) + * (data protection by default), the silent fallback must never include + * destructive scopes (*:write, pending_operations:approve, bookkeeping:write). + * Destructive scopes must be requested explicitly by the client and consented + * to by the user. + */ +export const DEFAULT_OAUTH_SCOPES: ApiKeyScope[] = [ + 'transactions:read', + 'customers:read', + 'invoices:read', + 'suppliers:read', + 'reports:read', + 'companies:read', + 'events:read', + 'operations:read', + 'documents:read', + 'compliance:read', + 'payroll:read', + 'pending_operations:read', +] + +/** + * Scopes advertised in the RFC 8414 authorization-server metadata document + * (/.well-known/oauth-authorization-server). Restricted to the same set that + * /authorize will grant by default — destructive scopes still work when + * requested explicitly, they just aren't enumerated for unauthenticated + * callers (defense-in-depth against scope-escalation reconnaissance). + */ +export const PUBLIC_OAUTH_METADATA_SCOPES: ApiKeyScope[] = [...DEFAULT_OAUTH_SCOPES] + +/** + * Scopes that allow staging a pending_operation. Used to detect a + * segregation-of-duties conflict when paired with `pending_operations:approve` + * on the same API key (ISO 27001:2022 A.5.3, SOC 2 CC6.1). + */ +export const STAGING_SCOPES: ApiKeyScope[] = [ + 'transactions:write', + 'customers:write', + 'invoices:write', + 'suppliers:write', + 'bookkeeping:write', + 'payroll:write', + 'documents:write', +] + +/** + * Detect a segregation-of-duties conflict between staging and approval scopes + * on the same key. Returns the offending staging scope, or null when the + * combination is clean. Callers may choose to block, warn, or record an + * acknowledged risk acceptance. + * + * Granting both stage+approve to the same actor lets an automated agent both + * stage AND commit financial postings without a human-in-the-loop review, + * which is the explicit control surface for BFNAR 2013:2 (behandlingshistorik) + * and BFL 5 kap 5§ traceability requirements. + */ +export function findStageApproveConflict(scopes: ApiKeyScope[]): ApiKeyScope | null { + if (!scopes.includes('pending_operations:approve')) return null + return scopes.find((s) => STAGING_SCOPES.includes(s)) ?? null +} + /** Scope domain groups for UI rendering */ export const SCOPE_GROUPS = [ - { domain: 'transactions', label: 'Transaktioner', read: 'transactions:read' as const, write: 'transactions:write' as const }, - { domain: 'customers', label: 'Kunder', read: 'customers:read' as const, write: 'customers:write' as const }, - { domain: 'invoices', label: 'Fakturor', read: 'invoices:read' as const, write: 'invoices:write' as const }, - { domain: 'suppliers', label: 'Leverantörer', read: 'suppliers:read' as const, write: 'suppliers:write' as const }, - { domain: 'reports', label: 'Rapporter', read: 'reports:read' as const, write: null }, - { domain: 'bookkeeping', label: 'Bokföring', read: null, write: 'bookkeeping:write' as const }, - { domain: 'payroll', label: 'Löner', read: 'payroll:read' as const, write: 'payroll:write' as const }, + { domain: 'transactions', label: 'Transaktioner', read: 'transactions:read' as const, write: 'transactions:write' as const }, + { domain: 'customers', label: 'Kunder', read: 'customers:read' as const, write: 'customers:write' as const }, + { domain: 'invoices', label: 'Fakturor', read: 'invoices:read' as const, write: 'invoices:write' as const }, + { domain: 'suppliers', label: 'Leverantörer', read: 'suppliers:read' as const, write: 'suppliers:write' as const }, + { domain: 'reports', label: 'Rapporter', read: 'reports:read' as const, write: null }, + { domain: 'bookkeeping', label: 'Bokföring', read: null, write: 'bookkeeping:write' as const }, + { domain: 'payroll', label: 'Löner', read: 'payroll:read' as const, write: 'payroll:write' as const }, + { domain: 'pending_operations', label: 'Stagade operationer', read: 'pending_operations:read' as const, write: 'pending_operations:approve' as const }, ] as const /** Map MCP tool name → required scope. Tools omitted from this map are available to any authenticated key (e.g. discovery/search/skill loading). */ @@ -126,6 +192,7 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_approve_supplier_invoice: 'suppliers:write', gnubok_credit_supplier_invoice: 'suppliers:write', gnubok_create_supplier_invoice_from_inbox: 'suppliers:write', + gnubok_set_inbox_extracted_data: 'suppliers:write', // Invoice conversion + crediting gnubok_convert_invoice: 'invoices:write', gnubok_credit_invoice: 'invoices:write', @@ -133,6 +200,10 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_create_voucher: 'bookkeeping:write', gnubok_correct_entry: 'bookkeeping:write', gnubok_reverse_journal_entry: 'bookkeeping:write', + // Pending operations approval (mirrors the /pending web UI) + gnubok_list_pending_operations: 'pending_operations:read', + gnubok_approve_pending_operation: 'pending_operations:approve', + gnubok_reject_pending_operation: 'pending_operations:approve', } export function validateScopes(scopes: unknown): ApiKeyScope[] | null { diff --git a/lib/auth/oauth-allowlist.ts b/lib/auth/oauth-allowlist.ts new file mode 100644 index 00000000..6851a4a2 --- /dev/null +++ b/lib/auth/oauth-allowlist.ts @@ -0,0 +1,60 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { createServiceClientNoCookies } from './api-keys' + +/** + * Built-in redirect URI patterns. These bypass the DB lookup entirely so + * Claude's connector keeps working without seeded rows, and so local + * development never depends on having a registration. + */ +export const BUILT_IN_REDIRECT_PATTERNS: readonly RegExp[] = [ + /^https:\/\/claude\.ai\/api\//, + /^https:\/\/claude\.com\/api\//, + /^http:\/\/localhost(:\d+)?(\/|$)/, + /^http:\/\/127\.0\.0\.1(:\d+)?(\/|$)/, +] + +export function isBuiltInRedirectUri(uri: string): boolean { + return BUILT_IN_REDIRECT_PATTERNS.some((pattern) => pattern.test(uri)) +} + +/** + * Resolve whether a redirect URI is allowed. Built-in patterns short-circuit; + * otherwise we look for a non-revoked registration in oauth_client_registrations. + * + * The supabase client should be supplied explicitly by the caller so the + * trust boundary is visible at the callsite (SOC 2 CC6.1). When omitted, the + * function falls back to a service-role client — required for the /register + * endpoint which has no user session yet. The lookup is by exact URI; the + * unique partial index on the table ensures at most one active row. + * + * Fails closed on any error (client construction, DB query) — for an + * allowlist, "unknown → deny" is the safe default. + */ +export async function isAllowedRedirectUri( + uri: string, + supabase?: SupabaseClient +): Promise { + if (typeof uri !== 'string' || uri.length === 0) return false + if (isBuiltInRedirectUri(uri)) return true + + // Service-role client construction can throw when Supabase env vars are + // absent (unit tests, misconfigured deploys). Treat that as "not allowed" + // — failing closed is the safe default for an allowlist. + let client: SupabaseClient + try { + client = supabase ?? createServiceClientNoCookies() + } catch { + return false + } + + const { data, error } = await client + .from('oauth_client_registrations') + .select('id') + .eq('redirect_uri', uri) + .is('revoked_at', null) + .limit(1) + .maybeSingle() + + if (error) return false + return data !== null +} diff --git a/lib/auth/oauth-codes.ts b/lib/auth/oauth-codes.ts index d6f1b365..4f570a86 100644 --- a/lib/auth/oauth-codes.ts +++ b/lib/auth/oauth-codes.ts @@ -22,6 +22,12 @@ export interface AuthCodePayload { userId: string codeChallenge: string redirectUri: string + /** + * Scopes the user consented to grant the resulting API key. Undefined on + * codes minted before this field was added — the token endpoint falls back + * to ALL_SCOPES so existing Claude flows are unaffected. + */ + scopes?: string[] exp: number } diff --git a/lib/pending-operations/__tests__/voucher-executors.test.ts b/lib/pending-operations/__tests__/voucher-executors.test.ts index 8a22b96a..2cd560a8 100644 --- a/lib/pending-operations/__tests__/voucher-executors.test.ts +++ b/lib/pending-operations/__tests__/voucher-executors.test.ts @@ -104,7 +104,10 @@ describe('commitPendingOperation: create_voucher', () => { description: 'Capitalize Cursor subscription to 1010', source_type: 'manual', }), - 'mcp_create_voucher' + // Default commit_method when opts.commitMethod is not passed. + // Must be one of the values allowed by the DB CHECK constraint + // (migration 20260420120001_journal_entry_commit_metadata.sql). + 'user_accept' ) // findFiscalPeriod must NOT be called when fiscal_period_id is supplied — // it's the caller's explicit choice. @@ -218,7 +221,7 @@ describe('commitPendingOperation: create_voucher', () => { 'company-1', 'user-1', expect.objectContaining({ source_type: 'manual' }), - 'mcp_create_voucher' + 'user_accept' ) expect(createJournalEntry).not.toHaveBeenCalledWith( expect.anything(), @@ -229,6 +232,46 @@ describe('commitPendingOperation: create_voucher', () => { ) }) + it('passes bulk_accept commit_method when invoked from the bulk-commit path', async () => { + // The bulk-commit route passes opts.commitMethod = 'bulk_accept' so the + // resulting journal_entry rows are tagged correctly per BFNAR 2013:2 + // behandlingshistorik. Without this assertion, a regression that drops + // opts on the way to the engine would silently book everything as + // 'user_accept'. + vi.mocked(createJournalEntry).mockResolvedValueOnce( + makeJournalEntry({ id: 'je-bulk', voucher_number: 9 }) + ) + + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher's commit update + + const op = makePendingOp({ + params: { + entry_date: '2026-05-12', + description: 'bulk-approved voucher', + fiscal_period_id: 'fp-1', + lines: [ + { account_number: '1010', debit_amount: 100, credit_amount: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 100 }, + ], + }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op, { + commitMethod: 'bulk_accept', + }) + + expect(result.status).toBe('committed') + expect(createJournalEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.anything(), + 'bulk_accept' + ) + }) + it('returns 400 with Swedish error when params are unbalanced (tamper defense)', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index e35ad009..e6559b14 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -82,6 +82,16 @@ export interface CommitResult { export interface CommitOptions { /** Email address used as cc on send_invoice (typically the human user's email). */ userEmail?: string + /** + * commit_method recorded on any journal_entries created by this operation. + * Must match the CHECK constraint on journal_entries.commit_method: + * 'user_accept' | 'bulk_accept' | 'timing_ceiling' | 'migration' | 'legacy'. + * Single-approval route passes 'user_accept' (default); bulk-approval passes + * 'bulk_accept'. Defaults to 'user_accept' since the dispatcher is only + * invoked from human-approval paths after agent auto-commit was removed + * (migration 20260505190027_drop_agent_auto_commit). + */ + commitMethod?: 'user_accept' | 'bulk_accept' } // ── Helper: ensure fiscal period covers the date ────────────────── @@ -1628,7 +1638,8 @@ async function commitCreateVoucher( supabase: SupabaseClient, userId: string, companyId: string, - params: Record + params: Record, + opts: CommitOptions = {} ): Promise { const entryDate = params.entry_date as string const description = params.description as string @@ -1681,7 +1692,11 @@ async function commitCreateVoucher( notes: (params.notes as string) || undefined, lines, }, - 'mcp_create_voucher' + // commit_method records HOW it was committed, not who staged it. MCP- + // staged ops still go through human approval, so 'user_accept' (or + // 'bulk_accept' from the bulk route) is the correct value. The DB CHECK + // constraint rejects anything else (migration 20260420120001). + opts.commitMethod ?? 'user_accept' ) return { @@ -1981,7 +1996,7 @@ export async function commitPendingOperation( result = await commitImportSie(supabase, userId, companyId, pendingOp.params) break case 'create_voucher': - result = await commitCreateVoucher(supabase, userId, companyId, pendingOp.params) + result = await commitCreateVoucher(supabase, userId, companyId, pendingOp.params, opts) break case 'correct_entry': result = await commitCorrectEntry(supabase, userId, companyId, pendingOp.params) diff --git a/supabase/migrations/20260518120000_oauth_client_registrations.sql b/supabase/migrations/20260518120000_oauth_client_registrations.sql new file mode 100644 index 00000000..c9aef507 --- /dev/null +++ b/supabase/migrations/20260518120000_oauth_client_registrations.sql @@ -0,0 +1,61 @@ +-- OAuth dynamic client registration allowlist. +-- +-- The /api/mcp-oauth/{register,authorize} endpoints used to hardcode a +-- regex allowlist of redirect URIs (claude.ai/api/*, claude.com/api/*, +-- localhost). That blocked self-hosted custom apps from completing OAuth +-- against gnubok, even though the rest of the flow (PKCE, refresh +-- rotation, AES-256-GCM auth codes) is provider-agnostic. +-- +-- This table lets users register their own redirect URIs through the +-- settings UI. The hardcoded patterns remain in code as the built-in +-- fallback (so Claude continues to work without seeding rows). +-- +-- Defense against open-redirect abuse: +-- * exact URI match only (no regex) +-- * registration requires owner/admin role (enforced in API route) +-- * unique constraint on redirect_uri so two users can't both claim it +-- * revoke flips revoked_at instead of deleting (preserves audit trail) +CREATE TABLE public.oauth_client_registrations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES auth.users ON DELETE CASCADE NOT NULL, + client_name TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ +); + +-- Only one active registration per URI. Allows the same URI to be +-- re-registered after revocation (partial unique index). +CREATE UNIQUE INDEX oauth_client_registrations_uri_active + ON public.oauth_client_registrations (redirect_uri) + WHERE revoked_at IS NULL; + +CREATE INDEX oauth_client_registrations_user_id_idx + ON public.oauth_client_registrations (user_id); + +ALTER TABLE public.oauth_client_registrations ENABLE ROW LEVEL SECURITY; + +-- Users see and manage only their own registrations. +CREATE POLICY "oauth_client_registrations_select_own" + ON public.oauth_client_registrations FOR SELECT + USING (user_id = auth.uid()); + +CREATE POLICY "oauth_client_registrations_insert_own" + ON public.oauth_client_registrations FOR INSERT + WITH CHECK (user_id = auth.uid()); + +CREATE POLICY "oauth_client_registrations_update_own" + ON public.oauth_client_registrations FOR UPDATE + USING (user_id = auth.uid()) + WITH CHECK (user_id = auth.uid()); + +CREATE POLICY "oauth_client_registrations_delete_own" + ON public.oauth_client_registrations FOR DELETE + USING (user_id = auth.uid()); + +CREATE TRIGGER oauth_client_registrations_set_updated_at + BEFORE UPDATE ON public.oauth_client_registrations + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +NOTIFY pgrst, 'reload schema';