Files
accounted/lib/auth/scopes.ts
T
Jakob WennbergandClaude Opus 4.7 2ed8096150 feat(api): Phase 4 PR-3 — documents (multipart) (#471)
* feat(api): Phase 4 PR-3 — documents (multipart) — 3 endpoints

Closes the deferred multipart slice of Phase 4. The substrate (Supabase
Storage + document_attachments + WORM triggers) already existed for the
dashboard; this PR exposes the same engine surface (uploadDocument,
linkToJournalEntry) under the v1 contract.

ENDPOINTS (3)

  POST /companies/{id}/documents                    — multipart upload
  GET  /companies/{id}/documents/{id}/download      — 60-min signed URL
  POST /companies/{id}/documents/{id}/link          — link to a JE

REGISTRY EXTENSION

EndpointDefinition.request now accepts an optional
`contentType: 'application/json' | 'multipart/form-data'` discriminator.
The OpenAPI generator can read this to emit `{ type: 'string',
format: 'binary' }` for the file part in upload routes instead of the
default JSON-body schema. Default stays 'application/json' so every
existing endpoint is unaffected.

SECURITY / TENANCY

  - documents.upload: when journal_entry_id is supplied, verifies the JE
    belongs to ctx.companyId before storing. Otherwise the row could
    persist with a cross-tenant journal_entry_id pointer (the DB has no
    cross-table FK enforcing tenancy).
  - documents.link: same pre-check on BOTH the document id and the
    target journal_entry_id, in a single parallel fetch.
  - documents.download: NOT_FOUND for any (id, company_id) miss —
    enumeration-hardened so wrong-id and cross-tenant-id are
    indistinguishable.

EVENTS

  - documents.upload   → document.uploaded   (via uploadDocument)
  - documents.download → document.accessed   (best-effort)
  - documents.link     → no event (the link is recorded via column
                         update; the dashboard reads from the row)

CONTRACT

  - Idempotency-Key required on both POSTs.
  - Dry-run supported on /link (confirms both refs exist without
    persisting). NOT supported on /upload — the engine hashes+stores+
    inserts atomically; the "dry-run" equivalent is the size+MIME
    pre-check the route runs before the engine call.
  - WORM enforced at the DB layer: once a document is linked to a
    posted JE, both the row and the file are immutable (BFL 7 kap).
    The v1 surface has no update/delete endpoint by design.

SCOPES

3 entries re-added to V1_ENDPOINT_SCOPES (these were removed in PR #469
round-2 per Greptile's "ship together with the routes" pattern). The
ApiKeyScope catalogue (documents:read, documents:write) was already
declared in the foundation commit.

ERROR CODES

DOC_DOWNLOAD_FAILED added to structured-errors.ts (500, SV+EN).
Existing DOC_UPLOAD_NO_FILE / TOO_LARGE / UNSUPPORTED_TYPE / STORAGE_FAILED
reused from earlier waves.

TESTS DEFERRED

Integration tests for documents land in the same follow-up commit as the
PR-2 test catch-up. Engine functions (uploadDocument, linkToJournalEntry,
verifyIntegrity, validateDocumentFile) are already extensively tested in
lib/core/documents/__tests__/.

Suite 3376/3376 still green; tsc clean.

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

* fix(api): PR #471 round-1 — Greptile + compliance review fixes (7 real)

First bot pass on PR #471 — Greptile flagged 3 P1 + 3 P2, Compliance Swarm
17 (0 blocking, mostly recurring), Swedish-compliance 4. Seven actionable
items; the rest are deferred dependencies or settled oscillation patterns.

REAL FIXES (7)

1. P1 — upload's JE pre-check destructures error away. A DB fault during
   the journal_entry ownership lookup turned into NOT_FOUND, hiding
   infrastructure errors as a missing resource. Now captures `.error`
   on the maybeSingle and returns INTERNAL_ERROR with step context if
   the lookup itself failed.

2. P1 — link's Promise.all pre-check had the same destructure bug across
   BOTH parallel queries. Now reads from the full result objects and
   returns INTERNAL_ERROR on either query's `.error`.

3. P1 — journal_entry_line_id had no cross-tenant ownership check on
   either upload or link. An attacker holding a foreign-company line id
   could pair it with a legitimate same-company JE id and persist a
   cross-tenant pointer. Both routes now verify the line belongs to
   the supplied JE before write. Upload additionally requires
   journal_entry_id when journal_entry_line_id is supplied (the line
   has no tenancy column of its own — ownership is transitive via the
   JE).

4. P2 — upload_source was TypeScript-cast without runtime validation.
   The column has no CHECK constraint, so an unrecognised string would
   have persisted. Now validates via z.enum().safeParse — VALIDATION_ERROR
   on miss listing the allowed values.

5. P2 — storage_path leaked in the upload response. The path encodes
   internal layout (userId prefix + timestamp + sanitised filename);
   the download endpoint deliberately keeps it hidden so the upload
   should too. Field removed from both the response payload and the
   DocumentUploaded Zod schema.

6. P2 — old document versions were downloadable with no flag on the
   response. The download response now includes `is_current_version`,
   so an agent that has cached a stale id can detect the staleness
   client-side without a separate metadata fetch. Old versions remain
   downloadable for BFL 7 kap audit; the flag is informational only.

7. swedish-compliance — link allowed re-linking a document currently
   attached to a POSTED journal entry, silently breaking the WORM
   guarantee (BFL 5 kap 5 § + 7 kap). Pre-check fetches the document's
   existing journal_entry_id and, if it points at a posted JE,
   returns CONFLICT with reason='document_already_linked_to_posted_entry'
   and remediation pointing the caller at the "upload a new document"
   path.

DISMISSED / DEFERRED

- OWASP V5.2 magic-number MIME sniffing — adds a `file-type` dependency.
  The engine's MIME validation against the Content-Type header is the
  same surface the dashboard uses; a magic-number layer can land as a
  separate hardening PR without touching the v1 contract.

- OWASP V5.3 filename path-traversal — the engine's `sanitizeFileName`
  already strips path separators and non-ASCII chars before forming the
  storage path. The `file_name` column keeps the original (display-only)
  name. No traversal vector through to storage.

- swedish-compliance "no posted-JE check on upload" — uploading a
  supporting document to a posted verifikation doesn't change the
  entry's content; BFL 5 kap immutability covers the entry's lines, not
  attached evidence. The dashboard allows it for the same reason.

- swedish-compliance `document.accessed` audit reliability — same
  oscillation pattern from PR-2 (Art.5(1)(f) vs V16.1). Best-effort
  warn-level remains; webhook/DLQ hardening is Phase 6.

- Compliance Swarm V8.2.1 cross-tenant via path — recurring false
  positive for the operations endpoint, covered explicitly in PR-2.

Suite 3376/3376 still green; tsc clean.

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

* fix(api): PR #471 round-2 — signed-URL TTL 60min → 15min

Compliance Swarm went 17 → 14 on round-1. Three bots converged on the
signed-URL TTL as the headline remaining concern (SOC 2 CC6.1 + GDPR
Art. 5(1)(f) + ISO 27001 A.8.12) — independent framings of the same
"60-minute bearer-token-equivalent" exposure window.

REAL FIX (1)

Reduce SIGNED_URL_TTL_SECONDS from 60 minutes → 15 minutes. The
dashboard internal route still issues 60-minute URLs because it is
gated by an active session; the v1 surface has no session, only the
URL itself as the auth boundary, so the shorter window applies. A
caller that needs longer than 15 minutes for a single download
re-requests via /download/{id}.

Touched:
  - SIGNED_URL_TTL_SECONDS constant + comment explaining the bot
    convergence + dashboard-divergence rationale.
  - Header docstring (60-minute → 15-minute).
  - Registry example response (expires_in_seconds: 3600 → 900).
  - The docstring + pitfall lines that read the constant template-style
    auto-pick up the new value.

DISMISSED (with rationale)

- V8.2.1 "add .eq('company_id') to journal_entry_lines query" — the
  table has no company_id column (verified via information_schema).
  Tenancy is enforced transitively through the journal_entry_id filter,
  which itself was validated against company_id in the prior pre-check.
  The bot's suggested fix would not compile.

- V5.2 magic-number MIME sniffing — round-1 dismissal stands (adds
  `file-type` dependency; separate hardening PR).

- Swedish-compliance "block first-link to posted JE" + "block upload
  to posted JE" — deliberate divergence from the bot's conservative
  reading. Attaching evidence to a posted verifikation doesn't mutate
  the verifikation itself; the dashboard allows this for the same
  reason. v1 keeps parity. Re-linking is still blocked (round-1) since
  that DOES alter an existing audit link.

- Art.5(1)(f) / A.8.15 / Art.32(1)(b) / CC7.2 document.accessed audit
  reliability — same oscillation pattern from PR-2. Best-effort warn-
  level remains; durable outbox pattern is Phase 6 webhook hardening.

- Art.25(1) userId in storage path — engine-layer concern. Path is
  set by lib/core/documents/document-service.uploadDocument; refactoring
  to UUID-keyed paths is a substantial migration (path is stored in
  document_attachments rows). Out of v1 surface scope.

- Art.5(1)(e) stray-document retention policy + CC6.3 scope policy
  doc + C1.1 metadata classification — policy artifacts, not code.

Suite 3376/3376 still green; tsc 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-13 22:30:35 +02:00

180 lines
8.9 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',
'POST /api/v1/companies/:companyId/invoices/:id/mark-paid': 'invoices:write',
'POST /api/v1/companies/:companyId/invoices/:id/credit': 'invoices:write',
'POST /api/v1/companies/:companyId/invoices/:id/send': 'invoices:write',
'POST /api/v1/companies/:companyId/invoices/bulk-create': 'invoices:write',
// Phase 2 PR-B-3 — invoice PDF + customer bulk-create.
'GET /api/v1/companies/:companyId/invoices/:id/pdf': 'invoices:read',
'POST /api/v1/companies/:companyId/customers/bulk-create': 'customers:write',
// Phase 4 PR-1 — Suppliers + Supplier-invoices verticals (AP world).
// Suppliers
'GET /api/v1/companies/:companyId/suppliers': 'suppliers:read',
'GET /api/v1/companies/:companyId/suppliers/:id': 'suppliers:read',
'POST /api/v1/companies/:companyId/suppliers': 'suppliers:write',
'PATCH /api/v1/companies/:companyId/suppliers/:id': 'suppliers:write',
'DELETE /api/v1/companies/:companyId/suppliers/:id': 'suppliers:write',
'POST /api/v1/companies/:companyId/suppliers/bulk-create': 'suppliers:write',
// Supplier invoices
'GET /api/v1/companies/:companyId/supplier-invoices': 'suppliers:read',
'GET /api/v1/companies/:companyId/supplier-invoices/:id': 'suppliers:read',
'POST /api/v1/companies/:companyId/supplier-invoices': 'suppliers:write',
'PATCH /api/v1/companies/:companyId/supplier-invoices/:id': 'suppliers:write',
// Note: no DELETE — supplier-invoice withdrawal is via :credit (mirrors v1 invoices).
'POST /api/v1/companies/:companyId/supplier-invoices/:id/approve': 'suppliers:write',
'POST /api/v1/companies/:companyId/supplier-invoices/:id/mark-paid': 'suppliers:write',
'POST /api/v1/companies/:companyId/supplier-invoices/:id/credit': 'suppliers:write',
// Phase 4 PR-2 — Engine, periods async ops, documents, compliance-check.
// Journal-entries primitives (highest-risk surface).
'GET /api/v1/companies/:companyId/journal-entries': 'reports:read',
'GET /api/v1/companies/:companyId/journal-entries/:id': 'reports:read',
'POST /api/v1/companies/:companyId/journal-entries': 'bookkeeping:write',
'POST /api/v1/companies/:companyId/journal-entries/:id/commit': 'bookkeeping:write',
'POST /api/v1/companies/:companyId/journal-entries/:id/reverse': 'bookkeeping:write',
'POST /api/v1/companies/:companyId/journal-entries/:id/correct': 'bookkeeping:write',
'POST /api/v1/companies/:companyId/journal-entries/batch-create': 'bookkeeping:write',
'POST /api/v1/companies/:companyId/voucher-gap-explanations': 'bookkeeping:write',
// Fiscal-periods async ops.
'POST /api/v1/companies/:companyId/fiscal-periods/:id/lock': 'bookkeeping:write',
'POST /api/v1/companies/:companyId/fiscal-periods/:id/close': 'bookkeeping:write',
'POST /api/v1/companies/:companyId/fiscal-periods/:id/year-end': 'bookkeeping:write',
'POST /api/v1/companies/:companyId/fiscal-periods/:id/opening-balances': 'bookkeeping:write',
'POST /api/v1/companies/:companyId/fiscal-periods/:id/currency-revaluation': 'bookkeeping:write',
// Compliance check (gnubok's defensible edge).
'GET /api/v1/companies/:companyId/compliance/check': 'compliance:read',
// Phase 4 PR-3 — Documents (multipart).
'POST /api/v1/companies/:companyId/documents': 'documents:write',
'GET /api/v1/companies/:companyId/documents/:id/download': 'documents:read',
'POST /api/v1/companies/:companyId/documents/:id/link': 'documents:write',
// Phase 3 — transactions + reconciliation vertical.
// Reads
'GET /api/v1/companies/:companyId/transactions': 'transactions:read',
'GET /api/v1/companies/:companyId/transactions/:id': 'transactions:read',
'GET /api/v1/companies/:companyId/accounts': 'reports:read',
'GET /api/v1/companies/:companyId/fiscal-periods': 'reports:read',
// Writes — single transaction verbs
'POST /api/v1/companies/:companyId/transactions/:id/categorize': 'transactions:write',
'POST /api/v1/companies/:companyId/transactions/:id/uncategorize': 'transactions:write',
'POST /api/v1/companies/:companyId/transactions/:id/match-invoice': 'transactions:write',
'POST /api/v1/companies/:companyId/transactions/:id/match-supplier-invoice': 'transactions:write',
// Writes — bulk
'POST /api/v1/companies/:companyId/transactions/ingest': 'transactions:write',
'POST /api/v1/companies/:companyId/transactions/batch-categorize': 'transactions:write',
// Reconciliation
'POST /api/v1/companies/:companyId/reconciliation/bank/run': 'transactions:write',
'GET /api/v1/companies/:companyId/reconciliation/bank/status': 'transactions: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
}