Files
accounted/lib/auth/scopes.ts
T
Jakob WennbergandClaude Opus 4.7 e4186523f9 feat(api): v1 invoice :mark-sent action verb (Phase 2 PR-B-2b-1) (#454)
* feat(api): v1 invoice :mark-sent action verb (Phase 2 PR-B-2b-1)

First invoice action verb. Transitions a DRAFT invoice to 'sent' status —
intended for invoices delivered outside gnubok (Peppol, postal, custom
SMTP). The full :send pipeline (PDF + email) builds on top of this in
PR-B-2b-3.

URL convention: plain /verb subpath (e.g. /invoices/:id/mark-sent), not
the AIP-style :verb suffix the plan originally proposed. Next.js routes
don't support `:` in folder names, and the Stripe/QuickBooks idiom is
plain subpaths anyway. The agent-facing docs can still describe the
action however we want.

What happens on commit:
1. F-series invoice_number allocated atomically via the
   generate_invoice_number RPC (per the PR-B-2a design — drafts have
   invoice_number=null until this transition, preserving the unbroken
   löpnummer series required by ML 17 kap 24§ p.2).
2. Status flips draft → sent.
3. For accrual + real invoices, posts the invoice journal entry via
   createInvoiceJournalEntry (Debit AR 1510 / Credit revenue 3xxx /
   Credit output VAT 26xx). Cash basis skips this; booking happens at
   payment time.
4. Writes journal_entry_id back onto the invoice row.
5. Emits invoice.sent.

Race-condition guard: the status update matches .eq('status', 'draft'),
so a concurrent transition between pre-flight and update returns 409
INVOICE_UPDATE_NOT_DRAFT.

Dry-run: returns a preview of the post-send invoice state including a
would_create_journal_entry flag and the resolved accounting_method.
invoice_number can't be predicted exactly (atomic sequence allocation)
so the preview shows a marker rather than a fake number.

PDF archival is deliberately NOT in this PR. The internal route does
it, but PDF rendering + document upload is a meaningful surface area
that belongs with :send (PR-B-2b-3) where email + PDF land together.

Test infrastructure: the makeFlexibleSupabase mock now supports
per-table result QUEUES (array form returns results in order across
multiple calls; single value returns same result every time). Required
to mock the pre-flight read (status=draft) and post-update read
(status=sent) on the same `invoices` table inside one request.

9 new tests covering happy path, idempotency, scope, draft-only guard,
delivery-note rejection, 404, UUID validation, dry-run preview shape,
and cash-method skip. 3174/3174 vitest pass; build clean.

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

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

Real bugs / contract violations:

- Greptile P1 (guard order): the delivery_note guard ran AFTER the
  status check, so a sent delivery note returned 409 instead of the
  documented 400. Reordered: document-shape guards (delivery_note +
  credit_note + missing moms_ruta) now run before the status check.
- Greptile P1 (journal_entry_id write-back): Supabase returns
  { data, error } and never rejects on DB errors, so a write-back
  failure produced no log and left the invoice with a real journal
  entry but no pointer. Now destructured + escalated to error log AND
  surfaced as a warning in the response.
- Swedish: credit notes (credited_invoice_id !== null) were not
  rejected — they would have been posted via createInvoiceJournalEntry
  with the wrong sign (Debit AR / Credit revenue instead of the
  inverse). Now explicitly rejected; credit-note path goes through
  POST /:id/credit (PR-B-2b-4).
- Swedish: moms_ruta now validated in the pre-flight. A null value
  would silently default to 25% domestic in the journal-entry
  generator — wrong for reverse-charge / EU-service / zero-rated
  invoices. Real ML 17 kap 24§ concern.

Partial-state visibility (Swedish + Swarm V2.3 + A.8.15 + PI1.3):

The response now carries an optional `warnings: [{ code, message }]`
field when the status flip succeeded but a follow-up step failed
(journal entry creation, event emission, or journal_entry_id write-
back). Three warning codes:
  - JOURNAL_ENTRY_NOT_POSTED — verifikation missing; BFL 5 kap
    reconciliation required
  - JOURNAL_ENTRY_ID_WRITEBACK_FAILED — entry exists but invoice row
    has no pointer
  - EVENT_EMIT_FAILED — webhook subscribers may miss this transition
All three escalate to error-level logs. The architectural fix
(transactional Postgres RPC that bundles allocation + status flip
+ journal entry) is tracked as cross-surface compliance work; the
warnings field is the agent-facing signal until that lands.

The F-series race window (number allocated before status flip; a
concurrent transition can leave a consumed-but-orphaned number, ML
17 kap 24§ p.2 gap) is now explicitly documented in the route
docstring rather than hidden in implementation. Same residual issue
exists in the internal route; fix needs the transactional RPC.

Pushing back (consistent with prior triage):
- V8.2.1 explicit ownership check (wrapper handles — false positive)
- Cross-tenant IDOR test (duplicates wrapper test coverage)
- Pseudonymise IDs in logs (operational value > theoretical risk)
- Structured audit event sink (current ctx.log.info IS structured)
- Test fixture A.8.33 (NODE_ENV guard in place; Acme AB is canonical
  synthetic placeholder)
- Projection column narrowing (fields ARE used in the flow)
- company_settings hard-fail on miss (accrual default is normal)

3 new tests covering credit-note rejection, missing moms_ruta, and
the journal-entry-failed warnings path. Plus the delivery-note test
now asserts the guard ordering works for sent delivery notes too.
3177/3177 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 23:19:21 +02:00

114 lines
4.4 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',
// Phase 2 PR-B-2b — action verbs. URL uses /verb subpath (not Google-AIP-style :verb)
// because Next.js routes don't support `:` in folder names.
'POST /api/v1/companies/:companyId/invoices/:id/mark-sent': '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
}