* 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>
85 lines
4.6 KiB
TypeScript
85 lines
4.6 KiB
TypeScript
/**
|
|
* Side-effect import that ensures every v1 route module's top-level
|
|
* `registerEndpoint()` call has been executed before the OpenAPI generator
|
|
* reads the registry.
|
|
*
|
|
* Why this exists: route files register themselves at module load time. The
|
|
* OpenAPI endpoint runs in its own module which would otherwise not pull in
|
|
* the other route files. Importing them here as side-effects populates the
|
|
* shared `ENDPOINTS` map.
|
|
*
|
|
* When a new v1 route is added, append a `import '...'` line.
|
|
*/
|
|
|
|
// Phase 1 surface.
|
|
import '@/app/api/v1/health/route'
|
|
import '@/app/api/v1/companies/route'
|
|
|
|
// Phase 4 PR-2 (foundation) — async operations polling endpoint.
|
|
import '@/app/api/v1/operations/[id]/route'
|
|
|
|
// Phase 4 PR-2 — journal-entries primitives + voucher-gap-explanations.
|
|
import '@/app/api/v1/companies/[companyId]/journal-entries/route'
|
|
import '@/app/api/v1/companies/[companyId]/journal-entries/[id]/route'
|
|
import '@/app/api/v1/companies/[companyId]/journal-entries/[id]/commit/route'
|
|
import '@/app/api/v1/companies/[companyId]/journal-entries/[id]/reverse/route'
|
|
import '@/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route'
|
|
import '@/app/api/v1/companies/[companyId]/journal-entries/batch-create/route'
|
|
import '@/app/api/v1/companies/[companyId]/voucher-gap-explanations/route'
|
|
|
|
// Phase 4 PR-2 — compliance-check (gnubok's defensible edge).
|
|
import '@/app/api/v1/companies/[companyId]/compliance/check/route'
|
|
|
|
// Phase 4 PR-2 — fiscal-periods async ops (lock/close/year-end/opening-balances/currency-revaluation).
|
|
import '@/app/api/v1/companies/[companyId]/fiscal-periods/[id]/lock/route'
|
|
import '@/app/api/v1/companies/[companyId]/fiscal-periods/[id]/close/route'
|
|
import '@/app/api/v1/companies/[companyId]/fiscal-periods/[id]/year-end/route'
|
|
import '@/app/api/v1/companies/[companyId]/fiscal-periods/[id]/opening-balances/route'
|
|
import '@/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route'
|
|
|
|
// Phase 4 PR-3 — Documents (multipart).
|
|
import '@/app/api/v1/companies/[companyId]/documents/route'
|
|
import '@/app/api/v1/companies/[companyId]/documents/[id]/download/route'
|
|
import '@/app/api/v1/companies/[companyId]/documents/[id]/link/route'
|
|
|
|
// Phase 2 PR-A — invoice + customer reads.
|
|
import '@/app/api/v1/companies/[companyId]/invoices/route'
|
|
import '@/app/api/v1/companies/[companyId]/invoices/[id]/route'
|
|
import '@/app/api/v1/companies/[companyId]/customers/route'
|
|
import '@/app/api/v1/companies/[companyId]/customers/[id]/route'
|
|
// Phase 2 PR-B-2b — invoice action verbs.
|
|
import '@/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route'
|
|
import '@/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route'
|
|
import '@/app/api/v1/companies/[companyId]/invoices/[id]/credit/route'
|
|
import '@/app/api/v1/companies/[companyId]/invoices/[id]/send/route'
|
|
import '@/app/api/v1/companies/[companyId]/invoices/bulk-create/route'
|
|
// Phase 2 PR-B-3 — invoice PDF + customer bulk-create.
|
|
import '@/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route'
|
|
import '@/app/api/v1/companies/[companyId]/customers/bulk-create/route'
|
|
|
|
// Phase 3 — transactions + reconciliation vertical.
|
|
import '@/app/api/v1/companies/[companyId]/transactions/route'
|
|
import '@/app/api/v1/companies/[companyId]/transactions/[id]/route'
|
|
import '@/app/api/v1/companies/[companyId]/accounts/route'
|
|
import '@/app/api/v1/companies/[companyId]/fiscal-periods/route'
|
|
import '@/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route'
|
|
import '@/app/api/v1/companies/[companyId]/transactions/[id]/uncategorize/route'
|
|
import '@/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route'
|
|
import '@/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route'
|
|
import '@/app/api/v1/companies/[companyId]/transactions/ingest/route'
|
|
import '@/app/api/v1/companies/[companyId]/transactions/batch-categorize/route'
|
|
import '@/app/api/v1/companies/[companyId]/reconciliation/bank/run/route'
|
|
import '@/app/api/v1/companies/[companyId]/reconciliation/bank/status/route'
|
|
|
|
// Phase 4 PR-1 — AP world: suppliers + supplier-invoices verticals.
|
|
import '@/app/api/v1/companies/[companyId]/suppliers/route'
|
|
import '@/app/api/v1/companies/[companyId]/suppliers/[id]/route'
|
|
import '@/app/api/v1/companies/[companyId]/suppliers/bulk-create/route'
|
|
import '@/app/api/v1/companies/[companyId]/supplier-invoices/route'
|
|
import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route'
|
|
import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/approve/route'
|
|
import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route'
|
|
import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route'
|
|
|
|
export {}
|