Files
accounted/lib/auth/scopes.ts
T
Jakob WennbergandClaude Opus 4.7 e96cbe05d0 feat(api): v1 invoice draft writes (Phase 2 PR-B-2a) (#453)
* feat(api): v1 invoice draft writes (Phase 2 PR-B-2a)

POST /api/v1/companies/:companyId/invoices creates a draft invoice,
proforma, or delivery note. Reuses the established v1 discipline:
- Idempotency-Key mandatory (wrapper option).
- Dry-runnable: ?dry_run=true returns the validated would-be invoice +
  computed items with VAT totals; no DB writes, no number allocation,
  no event emission.
- Explicit column projections (no SELECT *).
- Per-item VAT rate validated against the customer's allowed rates from
  getVatRules() — mixed-rate invoices supported.
- Currency conversion via fetchExchangeRate() (best-effort, non-fatal).
- F-series number allocation via ensureInvoiceNumber() with soft-cancel
  rollback if allocation fails — preserves sequence integrity for
  ML 17 kap 24§ (no gaps in F-series).
- invoice.created event emitted for real invoices (not proformas /
  delivery notes).

PATCH /api/v1/companies/:companyId/invoices/:id updates a DRAFT invoice's
metadata fields only:
- Allowed: invoice_date, due_date, delivery_date, your_reference,
  our_reference, notes.
- NOT allowed (intentional): customer_id, currency, document_type, items,
  status. Structural changes go through delete-and-recreate (drafts are
  cheap); status transitions via the action verbs in PR-B-2b.
- 409 INVOICE_DELETE_NOT_DRAFT if the invoice has already been sent /
  paid / credited / cancelled. The error code is shared with DELETE
  (reused rather than introducing a new "not draft" code).
- Race-condition guard: the .update() also matches .eq('status', 'draft')
  so a concurrent :send between pre-flight and write returns the same 409.

Dry-run for invoice DRAFT create uses dryRunPreview() (validation-only)
rather than dryRunStaged() — drafts have no journal-entry side effects
yet, so there's nothing to stage in pending_operations. The dryRunStaged()
helper from PR-B-1 stays unused this PR; PR-B-2b's :send will be its
first real consumer (voucher number, journal lines, account deltas).

Tests: 12 new (5 POST + 7 PATCH) covering happy path, customer not
found, VAT rate violation, dry-run preview shape, scope enforcement,
Idempotency-Key requirement, draft-only PATCH guard, forbidden field
rejection, UUID validation, empty body. Stubs ensureInvoiceNumber and
fetchExchangeRate to keep tests deterministic.

3165/3165 vitest pass; build clean; lint clean on v1 paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(api): address PR #453 review (Greptile + swarm + Swedish compliance)

Real fixes (all reviewers agreed):

- Greptile P1 + SOC 2 CC6.3: PATCH was reusing INVOICE_DELETE_NOT_DRAFT
  (httpStatus 400) for a semantically different operation; docstrings +
  tests claimed 409 while code returned 400. Introduced
  INVOICE_UPDATE_NOT_DRAFT with httpStatus 409 in structured-errors.ts.
  PATCH now returns 409 consistently; test name and assertion aligned.
- Greptile P1: POST rollback DELETE on items-insert failure now scoped
  by company_id (defense in depth) AND its error is destructured/logged
  so a double-failure is visible in audit trails (was previously silent
  on the rollback path).
- Greptile P1: refetch error after invoice insert is now logged with
  invoiceId + companyId at warn level; the response gracefully falls
  back to the header-only shape rather than misleading the agent with
  a 5xx (the data WAS committed).

GDPR Art.5(1)(f) × 2, ISO A.8.11 × 2, SOC 2 CC7.2 × 2: client-facing
error responses no longer echo raw Postgres pg_message strings (which
can interpolate field values from constraint detail). pg_code is kept
in the response (machine-readable, no PII leak); pg_message moves to
the internal structured log entry only. Applies to
INVOICE_CREATE_INSERT_FAILED and INVOICE_CREATE_ITEMS_FAILED.

OWASP V2.2: defensive UUID validation on ctx.companyId at POST handler
entry. The wrapper already validated membership, but mirroring the
detail-route's pattern for path params eliminates a class of edge-case
queries with malformed predicates.

Swedish compliance (ML 17 kap 24§ p.2 — most substantive finding):
ensureInvoiceNumber is NO LONGER called at draft-create. The doc string
already said "F-series invoice_number is allocated atomically on the
first send action (PR-B-2b)" but the code contradicted it by allocating
at POST. Code now matches intent: drafts (invoices and proformas) keep
invoice_number=null until :send. Delivery notes continue to allocate
their separate D-series number on insert (different sequence, no F-series
gap concern). This eliminates the soft-cancel path entirely for the
common case where a user creates and abandons a draft — no more legal
gaps in the löpnummer series from ordinary workflow.

Pushing back on:
- Atomicity / Postgres RPC wrapping (V8.2.1 × 2, CC6.1) — substantial
  refactor; the existing internal /api/invoices POST has the identical
  multi-step pattern; not a v1 regression. Track for a future RPC-
  consolidation PR across both surfaces.
- Float-point VAT rounding (V2.3, Swedish #3) — matches internal route
  precisely; consistency over premature decimal-library migration.
- TOCTOU rewrite to single UPDATE-WHERE-RETURNING (V8.2.1, CC6.1) —
  current pre-flight + scoped UPDATE is correct; the suggested cleanup
  is stylistic.
- PATCH response verbose projection (A.8.3, Art.25) — consistency with
  detail endpoint; the agent that just updated likely wants the full
  record back.
- per-line moms_ruta (Swedish #4) — schema migration; the existing
  header-only column is what the codebase has.
- Event emission failure alerting (A.8.15) — defer to PR-C webhooks.
- Test fixture A.8.33 — already addressed (NODE_ENV guard at test
  bootstrap, clearly synthetic UUIDs).

Test fixture UUID v4 fix: COMPANY_ID upgraded to proper v4 format
(was 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', which fails Zod 4's
.uuid() version-digit check now that the POST handler validates
companyId).

3165/3165 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>
2026-05-12 22:58:56 +02:00

111 lines
4.2 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 — reads; Phase 2 PR-B-1 — writes)
'GET /api/v1/companies/:companyId/customers': 'customers:read',
'GET /api/v1/companies/:companyId/customers/:id': 'customers:read',
'POST /api/v1/companies/:companyId/customers': 'customers:write',
'PATCH /api/v1/companies/:companyId/customers/:id': 'customers:write',
'DELETE /api/v1/companies/:companyId/customers/:id': 'customers:write',
// Invoices (Phase 2 PR-A — reads; Phase 2 PR-B-2a — draft writes)
'GET /api/v1/companies/:companyId/invoices': 'invoices:read',
'GET /api/v1/companies/:companyId/invoices/:id': 'invoices:read',
'POST /api/v1/companies/:companyId/invoices': 'invoices:write',
'PATCH /api/v1/companies/:companyId/invoices/:id': 'invoices:write',
// 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
}