f266c386f3
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
510 lines
20 KiB
TypeScript
510 lines
20 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 { z } from 'zod'
|
|
import type { ZodTypeAny } from 'zod'
|
|
import type { ApiKeyScope } from '@/lib/auth/api-keys'
|
|
import { API_V1_VERSION } from './version'
|
|
|
|
/**
|
|
* The audit block surfaced inline on write responses (see `AuditBlock` in
|
|
* `lib/api/v1/response.ts`) so an agent gets the voucher number / audit-trail
|
|
* URL without a second round-trip. Every field is optional.
|
|
*/
|
|
const ResponseAuditSchema = z.object({
|
|
voucher_number: z.string().optional(),
|
|
voucher_url: z.string().optional(),
|
|
audit_trail_url: z.string().optional(),
|
|
immutable_at: z.string().optional(),
|
|
})
|
|
|
|
/**
|
|
* The `meta` block echoed in every v1 response envelope (see
|
|
* `lib/api/v1/response.ts`). List endpoints additionally populate
|
|
* `next_cursor`; it is absent on the final page. Writes may surface an
|
|
* `audit` block, and soft-degraded `?expand=` responses a `partial_expansions`
|
|
* list: both optional, so reads and lists omit them.
|
|
*/
|
|
export const ResponseMetaSchema = z.object({
|
|
request_id: z.string(),
|
|
api_version: z.string(),
|
|
next_cursor: z.string().nullable().optional(),
|
|
audit: ResponseAuditSchema.optional(),
|
|
partial_expansions: z.array(z.string()).optional(),
|
|
})
|
|
|
|
/**
|
|
* The `{ data, meta }` envelope that every list endpoint actually returns via
|
|
* `paginated()`. Declare a list endpoint's `response.success` with this so the
|
|
* OpenAPI contract matches the runtime body.
|
|
*
|
|
* Previously each list endpoint declared a bare `{ <name>: [...] }` success
|
|
* object (e.g. `{ companies: [...] }`) that no handler ever emits: the
|
|
* generated spec advertised a shape the API never returns. See issue #781.
|
|
*/
|
|
export function listEnvelope<T extends ZodTypeAny>(item: T) {
|
|
return z.object({
|
|
data: z.array(item),
|
|
meta: ResponseMetaSchema,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The `{ data, meta }` envelope for an endpoint that returns a single OBJECT
|
|
* under `data` (via `ok()`), rather than a bare array under `data`.
|
|
*
|
|
* Most list endpoints return `{ data: [...] }` (use {@link listEnvelope}). A
|
|
* few (`accounts`, `fiscal-periods`, `webhooks`) deliberately wrap their
|
|
* array in a named key (`{ data: { accounts: [...] } }`); their handlers and
|
|
* route tests lock that shape in. Declare those with
|
|
* `dataEnvelope(z.object({ <name>: z.array(Item) }))` so the OpenAPI contract
|
|
* matches what they actually return.
|
|
*/
|
|
export function dataEnvelope<T extends ZodTypeAny>(data: T) {
|
|
return z.object({
|
|
data,
|
|
meta: ResponseMetaSchema,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Sentinel `response.success` for endpoints that return 204 No Content with an
|
|
* empty body: e.g. DELETE handlers calling `noContent()`. The OpenAPI
|
|
* generator emits a bare `204` response (no schema) for these instead of a
|
|
* `200 { data, meta }`, and the envelope contract test exempts them.
|
|
*
|
|
* Identified by REFERENCE equality, so every 204 route MUST import this exact
|
|
* constant rather than declaring its own `z.object({})`: that is what lets the
|
|
* generator and the contract test recognise the "no body" intent.
|
|
*/
|
|
export const NoBodyResponse = z.object({})
|
|
|
|
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())
|
|
}
|
|
|
|
|
|
/**
|
|
* Resolve the registered endpoint for a CONCRETE request path (e.g.
|
|
* `/api/v1/companies/abc/customers`) by matching it against the registered
|
|
* `:param` patterns. Used by the wrapper to read an endpoint's `dryRunSupported`
|
|
* flag at request time: the route module being served has already run its
|
|
* `registerEndpoint()` call, so its pattern is present. Returns undefined when
|
|
* no pattern matches (the wrapper treats that as "cannot be simulated").
|
|
*/
|
|
export function getEndpointByConcretePath(
|
|
method: string,
|
|
concretePath: string,
|
|
): EndpointDefinition | undefined {
|
|
for (const def of ENDPOINTS.values()) {
|
|
if (def.method !== method) continue
|
|
const regex = new RegExp('^' + def.path.replace(/:[^/]+/g, '[^/]+') + '$')
|
|
if (regex.test(concretePath)) return def
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// 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':
|
|
// A `.default()` field accepts the inner type on input; the object case
|
|
// below additionally treats it as not-required.
|
|
case 'default':
|
|
case 'ZodDefault': {
|
|
const inner = (def as { innerType: ZodTypeAny }).innerType
|
|
return zodToJsonSchema(inner)
|
|
}
|
|
case 'record':
|
|
case 'ZodRecord': {
|
|
const valueType = (def as { valueType?: ZodTypeAny }).valueType
|
|
return {
|
|
type: 'object',
|
|
additionalProperties: valueType ? zodToJsonSchema(valueType) : true,
|
|
}
|
|
}
|
|
// `.transform()` / `.pipe()` wrappers: describe the INPUT side, which is
|
|
// what an API caller must send. `z.preprocess()` is the mirror image:
|
|
// its input side IS the callable (a ZodTransform, no describable type),
|
|
// and the schema the cleaned value must satisfy sits on the output side.
|
|
case 'pipe':
|
|
case 'ZodPipeline': {
|
|
const pipeDef = def as { in?: ZodTypeAny; out?: ZodTypeAny }
|
|
const inDef = (pipeDef.in as unknown as { _def?: { type?: string; typeName?: string } } | undefined)?._def
|
|
const inDisc = inDef?.type ?? inDef?.typeName ?? ''
|
|
const side = ['transform', 'ZodEffects'].includes(inDisc) ? pipeDef.out : pipeDef.in
|
|
return side ? zodToJsonSchema(side) : {}
|
|
}
|
|
case 'effects':
|
|
case 'ZodEffects': {
|
|
const inner = (def as { schema?: ZodTypeAny }).schema
|
|
return inner ? 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)
|
|
// A field may be omitted exactly when the schema accepts undefined:
|
|
// covers optional and defaulted fields, and wrappers that only carry
|
|
// optionality inside (e.g. a preprocess pipe over `.optional()`),
|
|
// which a top-level discriminator check misclassifies as required.
|
|
const mayOmit = value.safeParse(undefined).success
|
|
if (!mayOmit) {
|
|
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.
|
|
// The registry's worked `example` travels with the schema as an OpenAPI
|
|
// media-type `example`. Without this the examples reached only the docs
|
|
// markdown builder (lib/docs/content/reference.ts): the spec itself carried
|
|
// none, so neither /api/v1/openapi.json consumers nor the generated
|
|
// skills/accounted-api ever saw a concrete request or response body.
|
|
// Attached to JSON media types only: a binary response (application/pdf)
|
|
// has no meaningful JSON example to show.
|
|
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),
|
|
example: def.example.response,
|
|
},
|
|
}
|
|
|
|
// 204 No Content endpoints (DELETEs returning noContent()) carry no body:
|
|
// emit a bare 204 instead of a 200 { data, meta } so the spec stops
|
|
// advertising a response shape these handlers never send.
|
|
const successResponse = def.response.success === NoBodyResponse
|
|
? { '204': { description: 'No Content' } }
|
|
: { '200': { description: 'Success', content: successContent } }
|
|
|
|
// Path parameters, derived from the `:param` pattern itself so every
|
|
// templated segment is declared even though routes don't register a
|
|
// params schema. All v1 path params are string ids.
|
|
const parameters = [...def.path.matchAll(/:([^/]+)/g)].map(([, name]) => ({
|
|
name,
|
|
in: 'path',
|
|
required: true,
|
|
schema: { type: 'string' },
|
|
}))
|
|
|
|
// Request body from the registered Zod schema. In multipart bodies a
|
|
// part registered as `z.unknown()` is by convention the binary file part
|
|
// (see documents.upload); the converter turns it into an empty schema,
|
|
// which is rewritten here to `format: binary` so client generators
|
|
// produce correct multipart uploads.
|
|
let requestBody: Record<string, unknown> | undefined
|
|
if (def.request?.body) {
|
|
const contentType = def.request.contentType ?? 'application/json'
|
|
let bodySchema = zodToJsonSchema(def.request.body)
|
|
if (contentType === 'multipart/form-data' && bodySchema.properties) {
|
|
bodySchema = {
|
|
...bodySchema,
|
|
properties: Object.fromEntries(
|
|
Object.entries(bodySchema.properties).map(([key, prop]) => [
|
|
key,
|
|
Object.keys(prop).length === 0
|
|
? { type: 'string', format: 'binary' }
|
|
: prop,
|
|
]),
|
|
),
|
|
}
|
|
}
|
|
requestBody = {
|
|
required: true,
|
|
content: {
|
|
[contentType]: {
|
|
schema: bodySchema,
|
|
// Only JSON bodies carry a worked example; a multipart upload's
|
|
// example would be a file part, which JSON cannot express.
|
|
...(def.example.request && contentType === 'application/json'
|
|
? { example: def.example.request }
|
|
: {}),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
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 } : {}),
|
|
...(parameters.length > 0 ? { parameters } : {}),
|
|
...(requestBody ? { requestBody } : {}),
|
|
responses: {
|
|
...successResponse,
|
|
'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: 'Accounted API',
|
|
version: API_V1_VERSION,
|
|
description:
|
|
'Public REST API for Accounted: 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,
|
|
}
|
|
}
|
|
|