3447da027a
* feat(api): surface the registry's worked examples in the OpenAPI spec and generated skill EndpointDefinition.example is required and every one of the 125 v1 endpoints populates example.response, but generateOpenApiSpec() never emitted it. The examples reached only the docs markdown builder, so /api/v1/openapi.json carried none and the generated skills/accounted-api had zero json blocks in all 12 reference files: every agent reading the spec or installing the skill got schemas with no concrete body. Emit example on the application/json media types (request body and 200 response) and teach the portable renderOperationMd to print it as a fenced json block. 178 worked examples now reach the skill. SKILL.md is unchanged: the examples land in the on-demand reference files, not the entry file. Attached to JSON media types only, so a multipart body and a binary application/pdf response do not advertise an example they cannot send. Adds the one missing example.request (currency-revaluation) so the new exhaustive coverage assertions hold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): emit Retry-After on a v1 429 so the documented contract is real The published accounted-api skill has told agents to honor Retry-After on a 429 since it shipped, but no /api/v1 route ever sent one: the wrapper's auth failure path early-returns through v1ErrorResponseFromCode, whose finalize() set only X-Request-Id and Gnubok-Version. Unattended clients had nothing to pace against and had to back off blindly. 60 seconds is an exact upper bound rather than a guess: the rate limiter is a fixed one-minute tumbling window per key row and the limited branch does not slide it. The value moves into an exported constant next to that limiter, so the MCP server's hardcoded '60' now reads from the same place. Also corrects the withApiV1 doc comment, which claimed step 8 stamps X-RateLimit-Limit. It never did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(mcp): guard the tools/list payload for the namespace new installs get The payload ratchet only ever serialized the gnubok_* projection. The accounted_* projection is inherently larger (every tool reference gains 3 chars, ~209 tokens across the default catalog) and CLAUDE.md points new MCP installs at exactly that namespace, so the payload a new user's client receives was never measured. It had already drifted ~90 tokens past the 63.4K ceiling while the guarded number sat comfortably under it. Measure both and assert on the larger. The ceiling moves to 63.6K to cover the real worst case; this buys no new catalog surface. A second test pins the direction of the delta so Math.max cannot silently stop describing reality. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
243 lines
9.0 KiB
TypeScript
243 lines
9.0 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, ScopeGroup } 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!
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|
|
| { 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,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
}
|