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>
This commit is contained in:
Jakob Wennberg
2026-05-13 22:30:35 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent e31aee2455
commit 2ed8096150
7 changed files with 719 additions and 4 deletions
@@ -0,0 +1,155 @@
/**
* GET /api/v1/companies/{companyId}/documents/{id}/download
*
* Returns a signed Supabase Storage URL (15-minute expiry) for the
* document's current version. The signed URL is a direct-download link
* the caller can fetch from any HTTP client without re-presenting an
* API key — keep it server-side and don't surface to end-users beyond
* the immediate transaction.
*
* The endpoint emits a `document.accessed` event (best-effort) so the
* audit trail records every download.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { eventBus } from '@/lib/events'
const DocumentDownloadResponse = z.object({
id: z.string().uuid(),
file_name: z.string(),
mime_type: z.string().nullable(),
sha256_hash: z.string(),
/**
* False when the requested id is a SUPERSEDED version (a newer version
* exists). The signed URL is still issued — old versions are retained
* for BFL 7 kap audit — but agents should treat the response as
* historical and re-resolve the current version via GET /documents
* if they need the latest bytes.
*/
is_current_version: z.boolean(),
download_url: z.string().url(),
expires_in_seconds: z.number().int(),
})
// 15 minutes. Three bots converged on this (SOC 2 CC6.1, GDPR Art. 5(1)(f),
// ISO 27001 A.8.12) when the original 60-minute window was flagged as a
// bearer-token-equivalent with too wide an exposure window. The dashboard
// internal route still issues 60min URLs because it's gated by an active
// session; the v1 surface has no session, only the URL itself — so the
// shorter window applies. A caller that needs longer than 15 minutes for
// a single download should re-request the URL.
const SIGNED_URL_TTL_SECONDS = 15 * 60
registerEndpoint({
operation: 'documents.download',
method: 'GET',
path: '/api/v1/companies/:companyId/documents/:id/download',
summary: 'Get a time-limited signed download URL for a document.',
description: `Returns a Supabase Storage signed URL valid for ${SIGNED_URL_TTL_SECONDS / 60} minutes. The URL itself is the canonical download — fetch it with any HTTP client; no API key needed on the storage host. Verify file integrity client-side against the returned sha256_hash if your workflow requires it.`,
useWhen:
'You need the bytes of an archived document (e.g. for OCR, attachment to an email, regulatory export). Always re-fetch the URL before each download — old URLs expire.',
doNotUseFor:
'Persisting the URL anywhere — it expires. Storing the URL in a webhook payload or audit log makes the audit trail dependent on URL state.',
pitfalls: [
`The signed URL expires after ${SIGNED_URL_TTL_SECONDS / 60} minutes. Don't cache it beyond the immediate transaction.`,
'The URL leaks the Supabase Storage origin; this is benign (the signature alone authorizes the read) but rate-limit any forwarding so you don\'t reveal the storage layout to untrusted callers.',
'Each call emits a document.accessed event. Polling this endpoint produces audit noise; cache the URL for its full TTL.',
],
example: {
response: {
data: {
id: '0e9c…',
file_name: 'kvitto-2026-05-12.pdf',
mime_type: 'application/pdf',
sha256_hash: '8a7f…',
download_url: 'https://…supabase.co/storage/v1/object/sign/…',
expires_in_seconds: 900,
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'documents:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: DocumentDownloadResponse },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'documents.download',
async (_request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'document id must be a UUID.' },
})
}
const documentId = idParse.data
const { data: doc, error: docErr } = await ctx.supabase
.from('document_attachments')
.select('id, file_name, mime_type, sha256_hash, storage_path, is_current_version')
.eq('id', documentId)
.eq('company_id', ctx.companyId!)
.maybeSingle()
if (docErr) return v1ErrorResponse(docErr, ctx.log, { requestId: ctx.requestId })
if (!doc) {
// Enumeration hardening — wrong id and cross-tenant id are
// indistinguishable from outside.
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'document' },
})
}
const typed = doc as {
id: string; file_name: string; mime_type: string | null; sha256_hash: string;
storage_path: string; is_current_version: boolean;
}
const { data: signed, error: signErr } = await ctx.supabase.storage
.from('documents')
.createSignedUrl(typed.storage_path, SIGNED_URL_TTL_SECONDS)
if (signErr || !signed?.signedUrl) {
ctx.log.error('createSignedUrl failed', signErr as Error, { documentId })
return v1ErrorResponseFromCode('DOC_DOWNLOAD_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { reason: signErr?.message ?? 'unknown' },
})
}
try {
await eventBus.emit({
type: 'document.accessed',
payload: {
document: { id: typed.id, file_name: typed.file_name },
userId: ctx.userId,
companyId: ctx.companyId!,
},
})
} catch (err) {
ctx.log.warn('document.accessed emit failed', err as Error)
}
return ok(
{
id: typed.id,
file_name: typed.file_name,
mime_type: typed.mime_type,
sha256_hash: typed.sha256_hash,
is_current_version: typed.is_current_version,
download_url: signed.signedUrl,
expires_in_seconds: SIGNED_URL_TTL_SECONDS,
},
{ requestId: ctx.requestId },
)
},
)
@@ -0,0 +1,242 @@
/**
* POST /api/v1/companies/{companyId}/documents/{id}/link
*
* Link an already-uploaded document to a journal entry (and optionally a
* specific line). Wraps lib/core/documents/document-service.linkToJournalEntry.
*
* Body: `{ journal_entry_id: UUID, journal_entry_line_id?: UUID }`.
*
* The link is REVERSIBLE — set journal_entry_id back via the dashboard if
* needed (no unlink endpoint in v1 yet to keep the WORM contract tight).
* Once the journal entry it points at is committed (status='posted'), the
* document row is effectively immutable per BFL 7 kap.
*
* Idempotent (mandatory Idempotency-Key). Dry-runnable: confirms the JE
* and document both exist + belong to the company without persisting.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
const Body = z
.object({
journal_entry_id: z.string().uuid(),
journal_entry_line_id: z.string().uuid().optional(),
})
.strict()
const DocumentLinkedResponse = z.object({
id: z.string().uuid(),
journal_entry_id: z.string().uuid(),
journal_entry_line_id: z.string().uuid().nullable(),
file_name: z.string(),
})
registerEndpoint({
operation: 'documents.link',
method: 'POST',
path: '/api/v1/companies/:companyId/documents/:id/link',
summary: 'Link a document to a journal entry.',
description:
'Sets journal_entry_id (and optionally journal_entry_line_id) on an existing document. Use this after /documents upload when the link target was unknown at upload time, or to re-link a stray document. Once the target JE is posted, the document row is effectively immutable per BFL 7 kap retention.',
useWhen:
'A document was uploaded without a journal_entry_id (e.g. bulk import) and you now want to attach it to a posted verifikation.',
doNotUseFor:
'Unlinking — no v1 unlink endpoint. The dashboard exposes a manual override; v1 keeps the WORM contract by refusing to revert posted-JE links.',
pitfalls: [
'Idempotency-Key is mandatory.',
'Both the document and the journal_entry_id must belong to the caller\'s company. NOT_FOUND on mismatch (enumeration hardening).',
'Re-linking an already-linked document overwrites the previous journal_entry_id — confirm the old target is what you intend to break.',
],
example: {
request: { journal_entry_id: 'a8f1…' },
response: {
data: {
id: '0e9c…',
journal_entry_id: 'a8f1…',
journal_entry_line_id: null,
file_name: 'kvitto-2026-05-12.pdf',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'documents:write',
risk: 'medium',
idempotent: true,
reversible: false,
dryRunSupported: true,
request: { body: Body },
response: { success: DocumentLinkedResponse },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'documents.link',
async (request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'document id must be a UUID.' },
})
}
const documentId = idParse.data
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = Body.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
})
}
const body = parsed.data
// Ownership pre-check: document AND target JE must both belong to the
// caller's company before the link write. Otherwise the row could
// persist with a cross-tenant journal_entry_id pointer. Capture
// `.error` on both — a DB fault must not silently masquerade as a
// NOT_FOUND (round-1 finding).
const [docRes, jeRes] = await Promise.all([
ctx.supabase
.from('document_attachments')
.select('id, file_name, journal_entry_id')
.eq('id', documentId)
.eq('company_id', ctx.companyId!)
.maybeSingle(),
ctx.supabase
.from('journal_entries')
.select('id')
.eq('id', body.journal_entry_id)
.eq('company_id', ctx.companyId!)
.maybeSingle(),
])
if (docRes.error) {
ctx.log.error('documents.link doc pre-check DB error', docRes.error as Error, { documentId })
return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId, details: { step: 'doc_ownership_check' },
})
}
if (jeRes.error) {
ctx.log.error('documents.link JE pre-check DB error', jeRes.error as Error, { journalEntryId: body.journal_entry_id })
return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId, details: { step: 'je_ownership_check' },
})
}
const doc = docRes.data as { id: string; file_name: string; journal_entry_id: string | null } | null
const je = jeRes.data
if (!doc) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'document' },
})
}
if (!je) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'journal_entry', field: 'journal_entry_id' },
})
}
// WORM guard: if this document is ALREADY linked to a posted JE, refuse
// the overwrite. BFL 5 kap 5 § + 7 kap require posted räkenskaps-
// information (incl. the link to underlying documents) to remain
// immutable. The pre-check confirms the new target — without this
// additional check the caller could silently break the link to an
// already-posted verifikation.
if (doc.journal_entry_id && doc.journal_entry_id !== body.journal_entry_id) {
const { data: existingJe } = await ctx.supabase
.from('journal_entries')
.select('id, status')
.eq('id', doc.journal_entry_id)
.eq('company_id', ctx.companyId!)
.maybeSingle()
if (existingJe && (existingJe as { status: string }).status === 'posted') {
return v1ErrorResponseFromCode('CONFLICT', ctx.log, {
requestId: ctx.requestId,
details: {
reason: 'document_already_linked_to_posted_entry',
current_journal_entry_id: doc.journal_entry_id,
remediation:
'Documents linked to posted verifikationer cannot be re-linked (BFL 5 kap 5 §). Upload a new document and link the new one to the new target.',
},
})
}
}
// journal_entry_line_id ownership: must belong to the target JE.
// Skipped above (only document + JE) because the line ownership is
// transitively bound through journal_entry_id (which we just verified).
if (body.journal_entry_line_id) {
const { data: lineRow, error: lineErr } = await ctx.supabase
.from('journal_entry_lines')
.select('id')
.eq('id', body.journal_entry_line_id)
.eq('journal_entry_id', body.journal_entry_id)
.maybeSingle()
if (lineErr) {
ctx.log.error('documents.link line pre-check DB error', lineErr as Error)
return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId, details: { step: 'je_line_ownership_check' },
})
}
if (!lineRow) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'journal_entry_line', field: 'journal_entry_line_id' },
})
}
}
if (ctx.dryRun) {
return dryRunPreview(
{
id: documentId,
journal_entry_id: body.journal_entry_id,
journal_entry_line_id: body.journal_entry_line_id ?? null,
file_name: doc.file_name,
},
{ requestId: ctx.requestId, log: ctx.log },
)
}
try {
const updated = await linkToJournalEntry(
ctx.supabase,
ctx.companyId!,
documentId,
body.journal_entry_id,
body.journal_entry_line_id,
)
return ok(
{
id: updated.id,
journal_entry_id: updated.journal_entry_id!,
journal_entry_line_id: updated.journal_entry_line_id,
file_name: updated.file_name,
},
{ requestId: ctx.requestId },
)
} catch (err) {
ctx.log.error('documents.link failed', err as Error, { documentId, journalEntryId: body.journal_entry_id })
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
}
},
{ requireIdempotencyKey: true },
)
@@ -0,0 +1,300 @@
/**
* POST /api/v1/companies/{companyId}/documents
*
* Multipart upload of a document into the WORM archive. Wraps
* lib/core/documents/document-service.uploadDocument: hashes the bytes
* (SHA-256), writes to Supabase Storage under documents/{userId}/...,
* inserts an immutable row into document_attachments (version=1,
* is_current_version=true).
*
* multipart/form-data parts:
* file (required, binary) — the document; MIME validated
* upload_source (optional) — 'file_upload' (default) | 'camera' | 'email' | 'api'
* journal_entry_id (optional UUID) — link the document to a JE at upload time
* journal_entry_line_id (optional UUID) — link to a specific JE line
*
* Idempotent (mandatory Idempotency-Key — the SHA-256 of the bytes is the
* deduplication anchor on retry inside the engine's `upsert: false` storage
* write).
*
* Dry-run is NOT supported on this endpoint — the engine hashes + stores +
* inserts atomically; the "dry-run" equivalent is a client-side
* size+MIME check before submitting. Future iteration may add a header-only
* preflight; held back to keep the multipart contract minimal.
*
* WORM (BFL 7 kap): once inserted, the row cannot be modified or deleted
* if it is linked to a posted journal entry — the DB trigger blocks both.
* Updating a document means uploading a new VERSION via the dashboard
* (no v1 endpoint today; bypassing through the dashboard is intentional
* until the contract is hardened with audit-trail tests).
*/
import { z } from 'zod'
import { created } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import {
uploadDocument,
validateDocumentFile,
MAX_DOCUMENT_SIZE,
ALLOWED_DOCUMENT_TYPES,
} from '@/lib/core/documents/document-service'
import type { DocumentUploadSource } from '@/types'
const DocumentUploaded = z.object({
id: z.string().uuid(),
file_name: z.string(),
mime_type: z.string().nullable(),
file_size_bytes: z.number(),
sha256_hash: z.string(),
version: z.number().int(),
is_current_version: z.boolean(),
upload_source: z.string().nullable(),
journal_entry_id: z.string().uuid().nullable(),
journal_entry_line_id: z.string().uuid().nullable(),
created_at: z.string(),
})
// For the registry, the Zod body is a metadata-only shape (everything the
// caller MIGHT supply in the multipart envelope besides the file itself).
// The actual multipart parsing happens in-route via request.formData().
const MultipartBodySchema = z.object({
file: z.unknown(), // OpenAPI generator renders this as { type: 'string', format: 'binary' }
upload_source: z.enum(['file_upload', 'camera', 'email', 'api']).optional(),
journal_entry_id: z.string().uuid().optional(),
journal_entry_line_id: z.string().uuid().optional(),
})
registerEndpoint({
operation: 'documents.upload',
method: 'POST',
path: '/api/v1/companies/:companyId/documents',
summary: 'Upload a document to the WORM archive.',
description: `Multipart upload of a document (PDF / image) under the BFL 7 kap retention regime. The bytes are hashed (SHA-256), written to Supabase Storage, and recorded in document_attachments at version=1. Allowed MIME types: ${ALLOWED_DOCUMENT_TYPES.join(', ')}. Max size: ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB.`,
useWhen:
'You have a receipt, invoice scan, or supporting document for a posted verifikation and want it archived for the 7-year BFL retention period. Optionally link to a journal entry at upload time via journal_entry_id.',
doNotUseFor:
'Updating an existing document (no v1 update endpoint; new versions go through the dashboard). Bulk uploads — call once per file.',
pitfalls: [
'Idempotency-Key is mandatory; multipart retries with the same key replay the cached response.',
`Max size ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB enforced server-side — DOC_UPLOAD_TOO_LARGE on overrun.`,
`Only ${ALLOWED_DOCUMENT_TYPES.join(' / ')} accepted — DOC_UPLOAD_UNSUPPORTED_TYPE otherwise.`,
'WORM: once linked to a posted journal entry, the document row cannot be modified or deleted (DB trigger). Upload-then-link is reversible (the document exists with journal_entry_id=null until linked); once linked, treat as immutable.',
'Dry-run is not supported on this endpoint — the engine hashes + stores + inserts in one atomic flow.',
],
example: {
request: {
// OpenAPI generator renders these as multipart parts.
file: '<binary>',
upload_source: 'api',
journal_entry_id: 'a8f1…',
},
response: {
data: {
id: '0e9c…',
file_name: 'kvitto-2026-05-12.pdf',
mime_type: 'application/pdf',
file_size_bytes: 184320,
sha256_hash: '8a7f…',
version: 1,
is_current_version: true,
journal_entry_id: 'a8f1…',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'documents:write',
risk: 'medium',
idempotent: true,
reversible: false,
dryRunSupported: false,
request: { body: MultipartBodySchema, contentType: 'multipart/form-data' },
response: { success: DocumentUploaded },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'documents.upload',
async (request, ctx) => {
let formData: FormData
try {
formData = await request.formData()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'body',
message:
'Body must be multipart/form-data with a `file` part. Set Content-Type accordingly.',
},
})
}
const file = formData.get('file')
if (!file || !(file instanceof File)) {
return v1ErrorResponseFromCode('DOC_UPLOAD_NO_FILE', ctx.log, {
requestId: ctx.requestId,
})
}
const validationError = validateDocumentFile({ size: file.size, type: file.type })
if (validationError) {
// The validator returns a Swedish string. Bucket by error category
// so the agent receives a stable code.
const code = /storlek|stor|MB|tom/i.test(validationError)
? 'DOC_UPLOAD_TOO_LARGE'
: 'DOC_UPLOAD_UNSUPPORTED_TYPE'
return v1ErrorResponseFromCode(code, ctx.log, {
requestId: ctx.requestId,
details: {
reason: validationError,
file_size_bytes: file.size,
mime_type: file.type,
max_size_bytes: MAX_DOCUMENT_SIZE,
allowed_types: ALLOWED_DOCUMENT_TYPES,
},
})
}
// Optional metadata fields. upload_source is enum-validated at runtime
// (the column has no CHECK constraint, so an unrecognised value would
// persist as-is otherwise).
const uploadSourceRaw = formData.get('upload_source')
const UploadSourceSchema = z.enum(['file_upload', 'camera', 'email', 'api'])
let uploadSource: DocumentUploadSource = 'file_upload'
if (typeof uploadSourceRaw === 'string') {
const parsed = UploadSourceSchema.safeParse(uploadSourceRaw)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'upload_source',
message: `upload_source must be one of: ${UploadSourceSchema.options.join(', ')}.`,
attempted: uploadSourceRaw,
},
})
}
uploadSource = parsed.data
}
const journalEntryIdRaw = formData.get('journal_entry_id')
const journalEntryId = typeof journalEntryIdRaw === 'string' ? journalEntryIdRaw : undefined
if (journalEntryId && !z.string().uuid().safeParse(journalEntryId).success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'journal_entry_id', message: 'must be a UUID' },
})
}
const journalEntryLineIdRaw = formData.get('journal_entry_line_id')
const journalEntryLineId = typeof journalEntryLineIdRaw === 'string' ? journalEntryLineIdRaw : undefined
if (journalEntryLineId && !z.string().uuid().safeParse(journalEntryLineId).success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'journal_entry_line_id', message: 'must be a UUID' },
})
}
// If the caller supplied journal_entry_id, verify it belongs to the
// caller's company before the upload commits. Otherwise we'd persist a
// document whose `journal_entry_id` points at another company's JE —
// the DB has no FK enforcing cross-table tenancy.
if (journalEntryId) {
const { data: jeRow, error: jeErr } = await ctx.supabase
.from('journal_entries')
.select('id')
.eq('id', journalEntryId)
.eq('company_id', ctx.companyId!)
.maybeSingle()
if (jeErr) {
ctx.log.error('documents.upload JE pre-check DB error', jeErr as Error, { journalEntryId })
return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId, details: { step: 'je_ownership_check' },
})
}
if (!jeRow) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'journal_entry', field: 'journal_entry_id' },
})
}
}
// If the caller supplied journal_entry_line_id, verify the line belongs
// to the supplied JE (and transitively to the company we already
// validated). Without this guard the row would persist with a
// line-level pointer to another company's JE line.
if (journalEntryLineId) {
if (!journalEntryId) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'journal_entry_line_id',
message: 'journal_entry_line_id requires journal_entry_id.',
},
})
}
const { data: lineRow, error: lineErr } = await ctx.supabase
.from('journal_entry_lines')
.select('id')
.eq('id', journalEntryLineId)
.eq('journal_entry_id', journalEntryId)
.maybeSingle()
if (lineErr) {
ctx.log.error('documents.upload JE-line pre-check DB error', lineErr as Error, { journalEntryLineId })
return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId, details: { step: 'je_line_ownership_check' },
})
}
if (!lineRow) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'journal_entry_line', field: 'journal_entry_line_id' },
})
}
}
const opLog = ctx.log.child({ filename: file.name, sizeBytes: file.size })
try {
const buffer = await file.arrayBuffer()
const document = await uploadDocument(
ctx.supabase,
ctx.userId,
ctx.companyId!,
{ name: file.name, buffer, type: file.type },
{
upload_source: uploadSource,
journal_entry_id: journalEntryId,
journal_entry_line_id: journalEntryLineId,
},
)
// `storage_path` is deliberately omitted from the public response —
// the path encodes internal layout (userId prefix + timestamp) which
// /download deliberately keeps hidden. Use /download/{id} to fetch
// the actual bytes via a short-lived signed URL.
return created(
{
id: document.id,
file_name: document.file_name,
mime_type: document.mime_type,
file_size_bytes: document.file_size_bytes,
sha256_hash: document.sha256_hash,
version: document.version,
is_current_version: document.is_current_version,
upload_source: document.upload_source,
journal_entry_id: document.journal_entry_id,
journal_entry_line_id: document.journal_entry_line_id,
created_at: document.created_at,
},
{ requestId: ctx.requestId },
)
} catch (err) {
opLog.error('document upload failed', err as Error)
return v1ErrorResponseFromCode('DOC_UPLOAD_STORAGE_FAILED', opLog, {
requestId: ctx.requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
})
}
},
{ requireIdempotencyKey: true },
)
+5
View File
@@ -37,6 +37,11 @@ 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'
+8
View File
@@ -74,6 +74,14 @@ export interface EndpointDefinition {
query?: ZodTypeAny
/** Request body. */
body?: ZodTypeAny
/**
* Body content-type. Defaults to 'application/json' when omitted.
* Set to 'multipart/form-data' for upload endpoints (Phase 4 PR-3:
* documents). The OpenAPI generator emits the appropriate schema
* (`{ type: 'string', format: 'binary' }` for the file part) so
* code generators produce correct multipart clients.
*/
contentType?: 'application/json' | 'multipart/form-data'
}
response: {
/** Successful response body. */
+4 -4
View File
@@ -105,10 +105,10 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'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',
// Note: documents (multipart) scopes are intentionally NOT pre-registered
// here — they ship in the dedicated documents follow-up PR so an API key
// issued today with documents:write cannot match a route that doesn't
// yet exist.
// 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
+5
View File
@@ -1046,6 +1046,11 @@ const DOCUMENT: Record<string, StructuredErrorEntry> = {
message_sv: 'Filen kunde inte sparas.',
message_en: 'Document storage failed.',
},
DOC_DOWNLOAD_FAILED: {
httpStatus: 500,
message_sv: 'Det gick inte att skapa nedladdningslänken.',
message_en: 'Failed to create signed download URL.',
},
DOC_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Dokumentet kunde inte hittas.',