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:
co-authored by
Claude Opus 4.7
parent
e31aee2455
commit
2ed8096150
@@ -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 },
|
||||
)
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user