f266c386f3
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
import crypto from 'crypto'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { createServiceRoleClient } from '@/lib/supabase/service-client'
|
|
// Not lib/company/context: that module imports next/headers for the legacy
|
|
// company cookie, and this file is reachable from bundles where that import
|
|
// is a build error.
|
|
import { getActiveCompanyId } from '@/lib/company/active-company'
|
|
|
|
const KEY_PREFIX = 'gnubok_sk_'
|
|
const REFRESH_TOKEN_PREFIX = 'gnubok_rt_'
|
|
|
|
// ── API Key Scopes ──────────────────────────────────────────
|
|
// The catalogue lives in scope-catalog.ts (no server imports, safe for client
|
|
// bundles) and is re-exported here so existing imports keep working.
|
|
export {
|
|
API_KEY_SCOPES,
|
|
ALL_SCOPES,
|
|
DEFAULT_SCOPES,
|
|
DEFAULT_OAUTH_SCOPES,
|
|
PUBLIC_OAUTH_METADATA_SCOPES,
|
|
STAGING_SCOPES,
|
|
findStageApproveConflict,
|
|
SCOPE_GROUPS,
|
|
scopeKind,
|
|
TOOL_SCOPE_MAP,
|
|
TOOL_COUNT_BY_SCOPE,
|
|
} from './scope-catalog'
|
|
export type { ApiKeyScope } from './scope-catalog'
|
|
import { API_KEY_SCOPES, DEFAULT_SCOPES, type ApiKeyScope } from './scope-catalog'
|
|
|
|
export function validateScopes(scopes: unknown): ApiKeyScope[] | null {
|
|
if (scopes === null || scopes === undefined) return null
|
|
if (!Array.isArray(scopes)) return null
|
|
const valid = scopes.filter((s): s is ApiKeyScope => s in API_KEY_SCOPES)
|
|
return valid.length > 0 ? valid : null
|
|
}
|
|
|
|
/**
|
|
* Create a Supabase service client that doesn't require cookies.
|
|
* Used for API key validation (MCP, webhooks) where there's no browser session.
|
|
*/
|
|
export function createServiceClientNoCookies() {
|
|
return createServiceRoleClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Name of every api_keys row minted by the MCP OAuth token route
|
|
* (app/api/mcp-oauth/token). It is the only marker those rows carry (there
|
|
* is no source column), so the Hem checklist's "Anslut till Claude" step
|
|
* matches on it to know a client completed its first sign-in. Renaming it
|
|
* would untick the step for every existing connection. The manual create
|
|
* route (app/api/settings/api-keys) rejects this name so a hand-minted key
|
|
* cannot fake the connection.
|
|
*/
|
|
export const OAUTH_MCP_KEY_NAME = 'MCP-klient (OAuth)'
|
|
|
|
export function generateApiKey(mode: ApiKeyMode = 'live'): { key: string; hash: string; prefix: string } {
|
|
const random = crypto.randomBytes(32).toString('base64url')
|
|
// Test keys carry an explicit `test_` infix so integrators can tell at a
|
|
// glance which environment a key targets (matches the llms.txt contract:
|
|
// `gnubok_sk_test_<random>`). The infix is purely cosmetic: the authoritative
|
|
// mode is the `mode` column on api_keys, read back by hash in validateApiKey,
|
|
// so nothing trusts the key string. Both variants keep the `gnubok_sk_`
|
|
// prefix so the `startsWith(KEY_PREFIX)` check in validateApiKey still holds.
|
|
const key = mode === 'test' ? `${KEY_PREFIX}test_${random}` : `${KEY_PREFIX}${random}`
|
|
const hash = hashApiKey(key)
|
|
// First 18 chars: 'gnubok_sk_test_xyz' for test keys, 'gnubok_sk_xxxxxxxx'
|
|
// for live: the stored prefix is what the settings UI shows, so the test_
|
|
// infix is visible in the key list without exposing the secret.
|
|
const prefix = key.slice(0, KEY_PREFIX.length + 8)
|
|
return { key, hash, prefix }
|
|
}
|
|
|
|
/**
|
|
* SHA-256, deliberately, and NOT a slow KDF like bcrypt/argon2.
|
|
*
|
|
* CodeQL flags this as js/insufficient-password-hash. That rule exists for
|
|
* user-chosen passwords, which are low-entropy and brute-forceable, so the
|
|
* defence is to make each guess expensive. This input is not a password: keys
|
|
* come from generateApiKey as 32 CSPRNG bytes (`gnubok_sk_<base64url>`), and no
|
|
* work factor moves the needle on a 256-bit random secret.
|
|
*
|
|
* A slow KDF would also be actively worse here: this runs on the hot path of
|
|
* every MCP request, where the hash is the primary-key lookup used to find the
|
|
* row, so per-request cost is real latency for zero security gain.
|
|
*
|
|
* Do NOT "fix" this by changing the algorithm. The hash IS the stored
|
|
* credential, so a different function invalidates every live `gnubok_sk_` key,
|
|
* breaking existing MCP connections with no migration path.
|
|
*/
|
|
export function hashApiKey(key: string): string {
|
|
return crypto.createHash('sha256').update(key).digest('hex')
|
|
}
|
|
|
|
export function generateRefreshToken(): { token: string; hash: string } {
|
|
const random = crypto.randomBytes(32).toString('base64url')
|
|
const token = `${REFRESH_TOKEN_PREFIX}${random}`
|
|
const hash = crypto.createHash('sha256').update(token).digest('hex')
|
|
return { token, hash }
|
|
}
|
|
|
|
export function hashRefreshToken(token: string): string {
|
|
return crypto.createHash('sha256').update(token).digest('hex')
|
|
}
|
|
|
|
export function isRefreshToken(token: string): boolean {
|
|
return token.startsWith(REFRESH_TOKEN_PREFIX)
|
|
}
|
|
|
|
export function extractBearerToken(request: Request): string | null {
|
|
const authHeader = request.headers.get('authorization')
|
|
if (!authHeader?.startsWith('Bearer ')) return null
|
|
return authHeader.slice(7)
|
|
}
|
|
|
|
/**
|
|
* Validate an API key and enforce rate limiting.
|
|
* Uses the DB RPC for atomic check + increment.
|
|
* Returns the user_id, company_id, api_key_id, name, and effective scopes on
|
|
* success, or an error with HTTP status.
|
|
* null scopes in DB → DEFAULT_SCOPES (read-only).
|
|
*
|
|
* api_key_id and api_key_name are returned so callers (e.g. the MCP server)
|
|
* can record actor attribution on pending_operations and audit_log.
|
|
* They may be undefined when the deployed DB hasn't yet run the migration
|
|
* that adds them to the RPC return shape.
|
|
*/
|
|
/**
|
|
* Operating mode of the API key. 'live' keys see real company data; 'test' keys
|
|
* are bound to deterministic sandbox companies. Keys created before the Phase 1
|
|
* migration default to 'live' for backwards compatibility.
|
|
*/
|
|
export type ApiKeyMode = 'live' | 'test'
|
|
|
|
/**
|
|
* Seconds a rate-limited caller should wait before retrying.
|
|
*
|
|
* `validate_and_increment_api_key` enforces a FIXED one-minute tumbling
|
|
* window per key row (`rate_limit_window_start`), and the limited branch
|
|
* deliberately does not slide the window, so the current window can never
|
|
* have more than 60 seconds left to run. 60 is therefore an exact upper
|
|
* bound rather than a guess, which is what `Retry-After` requires.
|
|
*
|
|
* The exact reset instant is `rate_limit_window_start + 1 minute` and is
|
|
* known inside the RPC, but its RETURNS TABLE carries no window column, so
|
|
* TypeScript cannot see it. Emitting the IETF `RateLimit` / `RateLimit-Policy`
|
|
* fields (draft-ietf-httpapi-ratelimit-headers) needs that column first.
|
|
*/
|
|
export const RATE_LIMIT_RETRY_AFTER_SECONDS = 60
|
|
|
|
export async function validateApiKey(
|
|
key: string
|
|
): Promise<
|
|
| {
|
|
userId: string
|
|
/**
|
|
* The key's default company. null only while the key's user has no
|
|
* company at all (minted from the OAuth popup before onboarding, issue
|
|
* #1814): the first validation after a company exists binds the key.
|
|
*/
|
|
companyId: string | null
|
|
apiKeyId?: string
|
|
apiKeyName?: string
|
|
scopes: ApiKeyScope[]
|
|
mode: ApiKeyMode
|
|
/**
|
|
* SEK ceiling on what this key may commit WITHOUT human approval, or
|
|
* null for no limit. Null is the only representation of "no limit": a
|
|
* stored 0 is forbidden by CHECK, because reading absence as 0 would
|
|
* block every commit for the key.
|
|
*/
|
|
unattendedCommitLimit: number | null
|
|
}
|
|
| { error: string; status: number }
|
|
> {
|
|
if (isRefreshToken(key)) {
|
|
return {
|
|
error: 'Refresh token cannot be used as access token; exchange it at /api/mcp-oauth/token',
|
|
status: 401,
|
|
}
|
|
}
|
|
|
|
if (!key.startsWith(KEY_PREFIX)) {
|
|
return { error: 'Invalid API key format', status: 401 }
|
|
}
|
|
|
|
const hash = hashApiKey(key)
|
|
const supabase = createServiceClientNoCookies()
|
|
|
|
const { data, error } = await supabase.rpc('validate_and_increment_api_key', {
|
|
p_key_hash: hash,
|
|
})
|
|
|
|
if (error || !data || data.length === 0) {
|
|
return { error: 'Invalid API key', status: 401 }
|
|
}
|
|
|
|
const row = data[0]
|
|
|
|
if (row.rate_limited) {
|
|
return { error: 'Rate limit exceeded', status: 429 }
|
|
}
|
|
|
|
const companyId: string | null =
|
|
row.company_id ?? (await bindUnboundKey(supabase, row.user_id, row.api_key_id))
|
|
|
|
return {
|
|
userId: row.user_id,
|
|
companyId,
|
|
apiKeyId: row.api_key_id,
|
|
apiKeyName: row.api_key_name,
|
|
scopes: validateScopes(row.scopes) ?? DEFAULT_SCOPES,
|
|
// `mode` may be undefined when the deployed DB hasn't yet run the Phase 1
|
|
// migration that adds it to the RPC return. Default to 'live' so existing
|
|
// keys behave unchanged.
|
|
mode: (row.mode === 'test' ? 'test' : 'live') as ApiKeyMode,
|
|
// PostgREST returns numeric as a STRING, so parse explicitly rather than
|
|
// relying on `>` coercion at the comparison site. Anything unparseable,
|
|
// absent (a DB that has not run the migration), or non-positive becomes
|
|
// null, i.e. no limit: this control must fail OPEN. Blocking a company's
|
|
// month-end because a defence-in-depth read blipped would be far worse
|
|
// than not enforcing.
|
|
unattendedCommitLimit: parseUnattendedCommitLimit(row.unattended_commit_limit),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Null unless the value is a finite number strictly greater than zero.
|
|
*
|
|
* Written NULL-first on purpose. Never `?? 0`, never `|| 0`, never
|
|
* `Number(undefined)` (which yields NaN, and NaN comparisons are false, so it
|
|
* would silently disable the control rather than loudly fail).
|
|
*/
|
|
function parseUnattendedCommitLimit(value: unknown): number | null {
|
|
if (value === null || value === undefined) return null
|
|
const parsed = typeof value === 'number' ? value : Number(value)
|
|
if (!Number.isFinite(parsed) || parsed <= 0) return null
|
|
return parsed
|
|
}
|
|
|
|
/**
|
|
* Late binding for keys minted before the user's first company existed.
|
|
*
|
|
* The OAuth token endpoint stores company_id NULL for such keys. Company
|
|
* creation happens in the web app (a Server Action) which knows nothing about
|
|
* the user's keys, so the binding is healed here, on the first validation after
|
|
* a company exists: one place, regardless of how the company was created.
|
|
* Returns null while the user still has no company. The UPDATE is best-effort:
|
|
* a failed write only means the next call resolves again.
|
|
*/
|
|
async function bindUnboundKey(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
apiKeyId: string | undefined
|
|
): Promise<string | null> {
|
|
let companyId: string | null
|
|
try {
|
|
companyId = await getActiveCompanyId(supabase, userId)
|
|
} catch {
|
|
return null
|
|
}
|
|
if (!companyId) return null
|
|
if (apiKeyId) {
|
|
await supabase
|
|
.from('api_keys')
|
|
.update({ company_id: companyId })
|
|
.eq('id', apiKeyId)
|
|
.is('company_id', null)
|
|
}
|
|
return companyId
|
|
}
|
|
|
|
/**
|
|
* Check if a given scope is allowed by the key's scopes.
|
|
*/
|
|
export function hasScope(keyScopes: ApiKeyScope[], required: ApiKeyScope): boolean {
|
|
return keyScopes.includes(required)
|
|
}
|