* feat(api): v1 invoice + customer read endpoints (Phase 2 PR-A) First slice of the Phase 2 invoices vertical. Read-only endpoints landing in this PR; writes + webhooks land in PR-B and PR-C. After all three a developer can ship an end-to-end invoicing integration. New endpoints (all wrapped, scoped, cursor-paginated): - GET /api/v1/companies/:companyId/invoices — list, filters: status, customer_id, document_type, currency. Cursor on (invoice_date DESC, id DESC). Customer name embedded inline; ?expand=customer for full record, ?expand=items for line items. - GET /api/v1/companies/:companyId/invoices/:id — detail with embedded customer. ?expand=items,payments. - GET /api/v1/companies/:companyId/customers — list, filters: customer_type, search (name/org_number prefix), include_archived. Cursor on (created_at ASC, id ASC). - GET /api/v1/companies/:companyId/customers/:id — detail. ?expand=invoices embeds open invoices in a single round-trip. Shared infra: - lib/api/v1/expand.ts — parseExpand() validates ?expand=a,b,c against a per-endpoint allowlist; unknown keys yield VALIDATION_ERROR with the full invalid list and the allowlist (agent-friendly). - All four routes register with the Zod schema registry so they show up in /api/v1/openapi.json with x-action-risk and use-when / do-not-use-for metadata. - Compound keyset filter on both list endpoints (per Greptile review on PR #450) — no skipped or duplicated rows on page boundaries. Tests: - lib/api/v1/__tests__/expand.test.ts (8 tests) - app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts (10 tests) - app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts (8 tests) Full repo suite green (3127/3127), build clean, lint clean on v1 paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): address PR #451 review (Greptile + compliance swarm) - Greptile P1 (customers search) + OWASP V1.2.5: customer search term now escapes both PostgREST .or() delimiters (,()) AND SQL LIKE wildcards (% _ \). '100%' searches for the literal string instead of any customer containing '100'. - OWASP V8.2.1 + V16.1: detail endpoints now UUID-validate the :id path param before touching the database, and no longer echo the raw id in the NOT_FOUND response details. Adds a structured warn log on 404 with the queried (id, companyId) for audit purposes. - V4.5 + Art.25(1) + A.8.3 / A.8.11 + CC6.3 + PI1.3 (~12 findings): every select('*') replaced with explicit column lists per the documented Zod schemas. Includes joined sub-queries — customer:customers(...), items:invoice_items(...), payments:invoice_payments(...). Future schema migrations adding sensitive columns must now update these projections before the field becomes visible on the public API. - A.8.5: hardcoded 'Bearer gnubok_sk_x' in test fixtures replaced with 'Bearer test-fixture-not-a-real-key' to avoid false-positive secret scanner alerts. Fixture UUIDs upgraded to valid v4 format (Zod 4's .uuid() enforces version+variant digits). - Art.5(1)(f): customer-invoices expansion soft-degrade now logs only the error code + message rather than the full Supabase error object. Pushing back on: - Art.5(1)(b) org_number in customer list — Bolagsverket-public data (same triage as PR #450; required by integration use case) - Art.25(2) customer_name always-joined — denormalising via trigger is a real schema migration for a marginal data-flow gain - A.8.15 _partial flag on soft-degrade — ?expand is documented as a hint 50/50 v1 tests; 3131/3131 full suite; build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): second-pass review on PR #451 — partial_expansions + fake fixtures Address the residual compliance-swarm findings after the first fix round: - CC6.1 (medium): the customer-detail handler now sets meta.partial_expansions=['invoices'] when the ?expand=invoices subquery fails, signalling the degraded response to the caller without escalating to error-level logs (alert fatigue). The primary resource still returns with an empty invoices array. New ResponseOptions.partialExpansions threaded through buildMeta(). 1 new test for the failure path, plus a happy-path assertion that the flag is absent. - A.8.33 (low): SAMPLE_CUSTOMER fixture's org_number and vat_number replaced with 'TEST-0000-0001' / 'SETEST00000001' — cannot be confused with real Bolagsverket entries or pass external VIES validation. Pushing back on: - CC6.3 (medium) — separate scope for ?expand=items on invoices: every accounting API I know (Stripe, QuickBooks, Fortnox) treats line items as part of the invoice resource. Splitting would violate principle of least surprise for integrators; the plan deliberately treats invoices:read as covering the full invoice including items. 3132/3132 vitest pass; build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): third-pass review on PR #451 — PII minimisation refinements Third compliance-swarm sweep (6 → 0 highs, 4 medium, 2 low). Addressed: - Art.5(1)(c) personnummer leakage: customer LIST response now masks org_number AND vat_number for customer_type IN ('individual', 'eu_individual') — for sole traders (enskild firma) org_number IS the personnummer. Business customers' Bolagsverket-public org_numbers stay visible. Detail endpoint (deliberate single-record fetch) unchanged. - Art.5(1)(c) over-broad invoice-list expansion: ?expand=customer on the invoice LIST endpoint now uses a new CUSTOMER_LIST_CONTEXT_COLUMNS projection (id, name, customer_type, email, country, archived_at) — full address/phone/notes/vat_number stay on the customer DETAIL endpoint. Drops PII transmitted in bulk-list contexts by ~60%. - A.8.15 permission-error differentiation: customer-detail soft-degrade for ?expand=invoices now bumps Postgres error class 42 (insufficient privilege, RLS denial) to error-level log so Sentry alerts on misconfigurations. Transient errors stay at warn. - PI1.1 ISO-4217 currency: invoice list ?currency now requires /^[A-Z]{3}$/ instead of accepting any 3-8 char string. Two new tests. Pushing back on: - Art.5(1)(f) UUID logging on 404 — UUIDs have 122 bits of entropy; you cannot enumerate the space, so the "log scraping = enumeration" framing doesn't hold. Operational audit value > theoretical risk. - Art.25(1) notes-by-default in customer DETAIL — kept inline. Detail is a deliberate single-record fetch; the dashboard shows notes inline; agents calling /customers/{id} reasonably expect them. Notes are already excluded from the LIST endpoint AND from the invoice-list ?expand=customer projection (above). 3135/3135 vitest pass; build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
106 lines
3.8 KiB
TypeScript
106 lines
3.8 KiB
TypeScript
/**
|
|
* v1 REST API endpoint → required scope map.
|
|
*
|
|
* This is the REST-route analogue of `TOOL_SCOPE_MAP` in api-keys.ts (which
|
|
* maps MCP tool names to scopes). Both share the same `ApiKeyScope` registry.
|
|
*
|
|
* Key format: `<METHOD> <pattern>` where pattern uses `:param` for path
|
|
* variables, matching Next.js dynamic-segment conventions (one for one).
|
|
*
|
|
* Endpoints not listed here are public (no auth) — only the discovery routes
|
|
* (`/llms.txt`, `/.well-known/skills`, `/api/v1/health`, `/api/v1/openapi.json`)
|
|
* fall into that bucket. Everything else under `/api/v1/` MUST be in this map
|
|
* or the wrapper will refuse the request with INSUFFICIENT_SCOPE.
|
|
*/
|
|
|
|
import type { ApiKeyScope } from './api-keys'
|
|
|
|
/**
|
|
* Routes that require authentication but no scope check beyond "is the key
|
|
* valid?". The wrapper still validates the key and runs rate limiting.
|
|
*/
|
|
export const V1_PUBLIC_ENDPOINTS: ReadonlyArray<string> = [
|
|
'GET /api/v1/health',
|
|
'GET /api/v1/openapi.json',
|
|
'GET /api/v1/openapi.yaml',
|
|
]
|
|
|
|
/**
|
|
* Map of v1 endpoint pattern → required scope.
|
|
*
|
|
* Patterns use `:param` placeholders that match a single path segment.
|
|
* The wrapper compiles these into regexes at startup and matches incoming
|
|
* requests by (method, normalized-path) tuple.
|
|
*
|
|
* When adding a new endpoint, add it here BEFORE shipping the route file —
|
|
* otherwise the wrapper will reject all requests to it.
|
|
*/
|
|
export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
|
|
// Companies
|
|
'GET /api/v1/companies': 'companies:read',
|
|
'GET /api/v1/companies/:companyId': 'companies:read',
|
|
|
|
// Operations (async long-running tasks)
|
|
'GET /api/v1/operations/:id': 'operations:read',
|
|
|
|
// Events (webhook fallback / event log polling)
|
|
'GET /api/v1/companies/:companyId/events': 'events:read',
|
|
|
|
// Customers (Phase 2 PR-A)
|
|
'GET /api/v1/companies/:companyId/customers': 'customers:read',
|
|
'GET /api/v1/companies/:companyId/customers/:id': 'customers:read',
|
|
|
|
// Invoices (Phase 2 PR-A)
|
|
'GET /api/v1/companies/:companyId/invoices': 'invoices:read',
|
|
'GET /api/v1/companies/:companyId/invoices/:id': 'invoices:read',
|
|
|
|
// Webhooks (Phase 6 — placeholder so the catalogue is complete)
|
|
'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
|
|
'POST /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
|
|
'GET /api/v1/companies/:companyId/webhooks/:id': 'webhooks:manage',
|
|
'PATCH /api/v1/companies/:companyId/webhooks/:id': 'webhooks:manage',
|
|
'DELETE /api/v1/companies/:companyId/webhooks/:id': 'webhooks:manage',
|
|
}
|
|
|
|
interface CompiledRoute {
|
|
method: string
|
|
regex: RegExp
|
|
scope: ApiKeyScope
|
|
}
|
|
|
|
let compiledCache: CompiledRoute[] | null = null
|
|
|
|
function compileAll(): CompiledRoute[] {
|
|
if (compiledCache) return compiledCache
|
|
compiledCache = Object.entries(V1_ENDPOINT_SCOPES).map(([pattern, scope]) => {
|
|
const [method, path] = pattern.split(' ', 2)
|
|
const regexStr = '^' + path.replace(/:[^/]+/g, '[^/]+') + '$'
|
|
return { method, regex: new RegExp(regexStr), scope }
|
|
})
|
|
return compiledCache
|
|
}
|
|
|
|
/**
|
|
* Resolve the required scope for a given (method, path) request.
|
|
*
|
|
* - Returns the scope when a registered v1 endpoint matches.
|
|
* - Returns 'public' for paths in V1_PUBLIC_ENDPOINTS (no scope check needed,
|
|
* but the wrapper may still want to log the key id).
|
|
* - Returns null when the path is unknown — the wrapper should treat this as
|
|
* a 404 NOT_FOUND rather than letting the request through unauthenticated.
|
|
*/
|
|
export function resolveRequiredScope(method: string, path: string): ApiKeyScope | 'public' | null {
|
|
const key = `${method} ${path}`
|
|
|
|
if (V1_PUBLIC_ENDPOINTS.includes(key)) return 'public'
|
|
|
|
const compiled = compileAll()
|
|
for (const route of compiled) {
|
|
if (route.method === method && route.regex.test(path)) {
|
|
return route.scope
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|