* 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>
329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
/**
|
|
* Single source of truth for the v1 REST surface.
|
|
*
|
|
* Every endpoint registers its Zod request/response schemas + agent-facing
|
|
* metadata (description, use-when, do-not-use-for, pitfalls, example) +
|
|
* OpenAPI `x-*` extensions (`x-action-risk`, `x-idempotent`, `x-reversible`,
|
|
* `x-dry-run-supported`).
|
|
*
|
|
* Three artefacts are derived from this registry:
|
|
* 1. The OpenAPI 3.1 spec at /api/v1/openapi.json (this file).
|
|
* 2. The MCP tool list (future — Phase 5).
|
|
* 3. Runtime validators (Zod itself, used by handlers).
|
|
*
|
|
* Phase 1 ships a minimal Zod→JSON-Schema converter. Phase 2 will swap in
|
|
* `@asteasolutions/zod-to-openapi` once the schema surface justifies the
|
|
* dependency. The registry shape stays stable across that change.
|
|
*/
|
|
|
|
import type { ZodTypeAny } from 'zod'
|
|
import type { ApiKeyScope } from '@/lib/auth/api-keys'
|
|
import { API_V1_VERSION } from './version'
|
|
|
|
export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
|
|
|
export type ActionRisk = 'low' | 'medium' | 'high'
|
|
|
|
export interface EndpointDefinition {
|
|
/** HTTP method + path pattern, e.g. 'GET /api/v1/companies'. */
|
|
operation: string
|
|
method: HttpMethod
|
|
path: string
|
|
|
|
/** One-sentence summary; first sentence of the OpenAPI description. */
|
|
summary: string
|
|
|
|
/** Longer prose for the docs and the registered MCP tool description. */
|
|
description: string
|
|
|
|
/** Positive trigger — when should an agent reach for this endpoint? */
|
|
useWhen: string
|
|
|
|
/** Negative trigger — what looks similar but isn't this. */
|
|
doNotUseFor: string
|
|
|
|
/** Common pitfalls. Bullet-list style; agents see this in tool docs. */
|
|
pitfalls: string[]
|
|
|
|
/** One worked example (used by the contract-test suite). */
|
|
example: {
|
|
request?: Record<string, unknown>
|
|
response: Record<string, unknown>
|
|
}
|
|
|
|
/** Required scope; null for public endpoints. */
|
|
scope: ApiKeyScope | null
|
|
|
|
/** Action risk — informs whether the agent should confirm before calling. */
|
|
risk: ActionRisk
|
|
|
|
/** True for GET requests and well-known idempotent writes. */
|
|
idempotent: boolean
|
|
|
|
/** True for writes that can be undone by a single subsequent call (e.g. credit invoice). */
|
|
reversible: boolean
|
|
|
|
/** True for write endpoints that accept ?dry_run=true. */
|
|
dryRunSupported: boolean
|
|
|
|
/** Optional Zod schemas. */
|
|
request?: {
|
|
/** Path params (companyId, id, ...). */
|
|
params?: ZodTypeAny
|
|
/** Query params. */
|
|
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. */
|
|
success: ZodTypeAny
|
|
/** Stable error codes this endpoint can emit (cross-referenced with the docs). */
|
|
errorCodes?: string[]
|
|
/**
|
|
* Override the default 'application/json' content type for non-JSON
|
|
* responses (e.g. binary downloads). When set to 'application/pdf', the
|
|
* OpenAPI generator emits a `{ type: 'string', format: 'binary' }` schema
|
|
* instead of deriving from `success`. The `success` schema is still
|
|
* required (use `z.unknown()` as a marker) so existing registry consumers
|
|
* don't need to handle a missing field.
|
|
*/
|
|
contentType?: string
|
|
}
|
|
}
|
|
|
|
const ENDPOINTS = new Map<string, EndpointDefinition>()
|
|
|
|
/**
|
|
* Register an endpoint. Called from the route file at module load time:
|
|
*
|
|
* registerEndpoint({
|
|
* operation: 'companies.list',
|
|
* method: 'GET',
|
|
* path: '/api/v1/companies',
|
|
* ...
|
|
* })
|
|
*
|
|
* The wrapper does not depend on registration — scope resolution lives in
|
|
* `lib/auth/scopes.ts` so a missing register() call only affects docs, not
|
|
* runtime auth. CI test asserts every wrapped route appears in the registry.
|
|
*/
|
|
export function registerEndpoint(def: EndpointDefinition): void {
|
|
const key = `${def.method} ${def.path}`
|
|
if (ENDPOINTS.has(key)) {
|
|
// Duplicate registration is a bug — log loudly. Throwing during a route
|
|
// module's top-level eval would break unrelated routes; warn instead.
|
|
// eslint-disable-next-line no-console
|
|
console.warn(`[api/v1/registry] duplicate endpoint registration: ${key}`)
|
|
}
|
|
ENDPOINTS.set(key, def)
|
|
}
|
|
|
|
export function listEndpoints(): EndpointDefinition[] {
|
|
return Array.from(ENDPOINTS.values())
|
|
}
|
|
|
|
export function getEndpoint(method: HttpMethod, path: string): EndpointDefinition | undefined {
|
|
return ENDPOINTS.get(`${method} ${path}`)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Minimal Zod → JSON Schema converter
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Phase 1 only registers a handful of endpoints with simple schemas. We
|
|
// implement just enough to cover them: object, string, number, boolean,
|
|
// uuid, array, optional, enum, literal, date-string. When the registry
|
|
// surface grows past Phase 2, swap this for @asteasolutions/zod-to-openapi.
|
|
|
|
interface JsonSchema {
|
|
type?: string | string[]
|
|
properties?: Record<string, JsonSchema>
|
|
required?: string[]
|
|
items?: JsonSchema
|
|
enum?: unknown[]
|
|
const?: unknown
|
|
format?: string
|
|
description?: string
|
|
additionalProperties?: boolean | JsonSchema
|
|
}
|
|
|
|
function zodToJsonSchema(schema: ZodTypeAny): JsonSchema {
|
|
const def = (schema as unknown as { _def: { typeName?: string; type?: string } })._def
|
|
|
|
// Zod 4 uses string discriminators on _def.type ('string', 'object', etc.).
|
|
// Fall back to the legacy typeName for cross-version safety.
|
|
const discriminator = def.type ?? def.typeName ?? ''
|
|
|
|
switch (discriminator) {
|
|
case 'string':
|
|
case 'ZodString':
|
|
return { type: 'string' }
|
|
case 'number':
|
|
case 'ZodNumber':
|
|
return { type: 'number' }
|
|
case 'boolean':
|
|
case 'ZodBoolean':
|
|
return { type: 'boolean' }
|
|
case 'array':
|
|
case 'ZodArray': {
|
|
const inner = (def as { element?: ZodTypeAny; type?: ZodTypeAny }).element
|
|
?? (def as { type?: ZodTypeAny }).type
|
|
return { type: 'array', items: inner ? zodToJsonSchema(inner) : {} }
|
|
}
|
|
case 'optional':
|
|
case 'ZodOptional':
|
|
case 'nullable':
|
|
case 'ZodNullable': {
|
|
const inner = (def as { innerType: ZodTypeAny }).innerType
|
|
return zodToJsonSchema(inner)
|
|
}
|
|
case 'object':
|
|
case 'ZodObject': {
|
|
const shape = (schema as unknown as { shape: Record<string, ZodTypeAny> }).shape
|
|
const properties: Record<string, JsonSchema> = {}
|
|
const required: string[] = []
|
|
for (const [key, value] of Object.entries(shape)) {
|
|
properties[key] = zodToJsonSchema(value)
|
|
const valueDef = (value as unknown as { _def: { typeName?: string; type?: string } })._def
|
|
const valueDisc = valueDef.type ?? valueDef.typeName ?? ''
|
|
if (valueDisc !== 'optional' && valueDisc !== 'ZodOptional') {
|
|
required.push(key)
|
|
}
|
|
}
|
|
return {
|
|
type: 'object',
|
|
properties,
|
|
...(required.length > 0 ? { required } : {}),
|
|
additionalProperties: false,
|
|
}
|
|
}
|
|
case 'enum':
|
|
case 'ZodEnum': {
|
|
const enumDef = def as { values?: unknown[]; entries?: Record<string, unknown> }
|
|
const values =
|
|
enumDef.values ??
|
|
(enumDef.entries ? Object.values(enumDef.entries) : [])
|
|
return { type: 'string', enum: values }
|
|
}
|
|
case 'literal':
|
|
case 'ZodLiteral': {
|
|
const value = (def as { value?: unknown; values?: unknown[] }).value
|
|
?? (def as { values?: unknown[] }).values?.[0]
|
|
return { const: value }
|
|
}
|
|
case 'union':
|
|
case 'ZodUnion': {
|
|
// Best-effort: emit a oneOf with each member converted. No top-level
|
|
// `type` constraint — the individual branches carry their own types
|
|
// (valid JSON Schema for a union).
|
|
const options = (def as { options?: ZodTypeAny[] }).options ?? []
|
|
return { oneOf: options.map(zodToJsonSchema) } as unknown as JsonSchema
|
|
}
|
|
default:
|
|
// Unknown construct → empty schema, accept anything.
|
|
return {}
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// OpenAPI 3.1 spec generation
|
|
// ──────────────────────────────────────────────────────────────────
|
|
|
|
interface OpenApiSpec {
|
|
openapi: '3.1.0'
|
|
info: { title: string; version: string; description: string }
|
|
servers: Array<{ url: string }>
|
|
components: { securitySchemes: Record<string, unknown> }
|
|
security: Array<Record<string, unknown[]>>
|
|
paths: Record<string, Record<string, unknown>>
|
|
}
|
|
|
|
const SCHEME_NAME = 'ApiKey'
|
|
|
|
export function generateOpenApiSpec(serverUrl: string): OpenApiSpec {
|
|
const paths: OpenApiSpec['paths'] = {}
|
|
|
|
for (const def of ENDPOINTS.values()) {
|
|
// OpenAPI path syntax: {param} instead of :param.
|
|
const openApiPath = def.path.replace(/:([^/]+)/g, '{$1}')
|
|
|
|
// Binary responses (e.g. application/pdf) declare a `format: binary`
|
|
// schema rather than deriving from the Zod success type.
|
|
const successContent = def.response.contentType && def.response.contentType !== 'application/json'
|
|
? { [def.response.contentType]: { schema: { type: 'string', format: 'binary' } } }
|
|
: { 'application/json': { schema: zodToJsonSchema(def.response.success) } }
|
|
|
|
const operationDef: Record<string, unknown> = {
|
|
operationId: def.operation,
|
|
summary: def.summary,
|
|
description: [
|
|
def.description,
|
|
'',
|
|
`**Use when:** ${def.useWhen}`,
|
|
`**Do not use for:** ${def.doNotUseFor}`,
|
|
...(def.pitfalls.length > 0 ? ['', '**Pitfalls:**', ...def.pitfalls.map((p) => `- ${p}`)] : []),
|
|
].join('\n'),
|
|
'x-action-risk': def.risk,
|
|
'x-idempotent': def.idempotent,
|
|
'x-reversible': def.reversible,
|
|
'x-dry-run-supported': def.dryRunSupported,
|
|
...(def.scope ? { 'x-required-scope': def.scope } : {}),
|
|
responses: {
|
|
'200': {
|
|
description: 'Success',
|
|
content: successContent,
|
|
},
|
|
'400': { description: 'Validation error', $ref: '#/components/responses/Error' },
|
|
'401': { description: 'Unauthorized', $ref: '#/components/responses/Error' },
|
|
'403': { description: 'Insufficient scope', $ref: '#/components/responses/Error' },
|
|
'404': { description: 'Not found', $ref: '#/components/responses/Error' },
|
|
'429': { description: 'Rate limited', $ref: '#/components/responses/Error' },
|
|
'500': { description: 'Internal error', $ref: '#/components/responses/Error' },
|
|
},
|
|
}
|
|
|
|
if (!paths[openApiPath]) paths[openApiPath] = {}
|
|
paths[openApiPath][def.method.toLowerCase()] = operationDef
|
|
}
|
|
|
|
return {
|
|
openapi: '3.1.0',
|
|
info: {
|
|
title: 'gnubok API',
|
|
version: API_V1_VERSION,
|
|
description:
|
|
'Public REST API for gnubok — Swedish double-entry bookkeeping. ' +
|
|
'Every write supports dry-run via `?dry_run=true`. Every request must include ' +
|
|
'`Authorization: Bearer gnubok_sk_...`. See /docs/api for the cookbook.',
|
|
},
|
|
servers: [{ url: serverUrl }],
|
|
components: {
|
|
securitySchemes: {
|
|
[SCHEME_NAME]: {
|
|
type: 'http',
|
|
scheme: 'bearer',
|
|
bearerFormat: 'gnubok_sk_<live|test>_<random>',
|
|
},
|
|
},
|
|
},
|
|
security: [{ [SCHEME_NAME]: [] }],
|
|
paths,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test-only escape hatch. Clears the registry — used in unit tests so a test
|
|
* that registers a fake endpoint doesn't leak into the next test.
|
|
*/
|
|
export function _resetRegistryForTests(): void {
|
|
ENDPOINTS.clear()
|
|
}
|