@keepcv/api
v0.7.0
Published
The KeepCV HTTP boundary: Hono routes, Zod validation, problem+json errors and a typed client.
Maintainers
Readme
@keepcv/api
The HTTP boundary for KeepCV: Hono routes,
Zod validation, RFC 9457 problem+json errors, a generated OpenAPI document and
a typed client.
This is a first-class artifact rather than an emitted side effect. A store people run themselves is one whose client and server drift out of step, so the boundary between them is designed, versioned and stable - which framework-internal RPC would not be.
Status: early development. The public API is unstable. Today it serves the whole record store - the profile, contact channels, organisations, custom sections, records, points, phrasings, tags, drafts and everything hanging off them - the composition a resume is, its history with version comparison and restore, and the boot payload and the native export and import. There is no search route and no composition route: both are pure functions over the boot payload, in
@keepcv/core.
Installation
pnpm add @keepcv/apiUsage
The package builds an app; it does not open a store, choose a port or decide who
the caller is. Those belong to whatever is hosting it - locally that is the
keepcv launcher.
import { createApi, sessionTokenAuth } from "@keepcv/api";
import { openLocalStore, runAsOwner } from "@keepcv/db";
const store = openLocalStore({ dataDir: "./keepcv-data" });
await store.migrate();
const ownerId = await store.ensureLocalOwner();
const api = createApi({
unitOfWork: store.unitOfWork,
runAsOwner,
authenticate: sessionTokenAuth(process.env.KEEPCV_TOKEN, ownerId),
});
// api.fetch is a standard fetch handler: serve it with @hono/node-server,
// or hand it straight to a runtime that speaks Request and Response.Every content transaction receives the incoming Request's signal, including
native import and writes that plan multiple rows. The host connects transport
disconnects and operation deadlines to that Request; aborting a separate client
promise does not establish server cancellation. The supplied unit-of-work
implementation controls interruption and cleanup. The PostgreSQL adapter stops
the checked-out query and rolls back before COMMIT where possible. Once COMMIT
has been dispatched, cancellation cannot promise rollback; re-read after an
ambiguous disconnect before deciding whether to retry. Lifecycle and erasure
calls made separately on the store never inherit a Request signal.
The typed client resolves the route types inside this package, so a consumer does not re-infer them:
import { createClient } from "@keepcv/api";
const client = createClient("http://127.0.0.1:4319", { sessionToken });
const response = await client.v1.profile.$get();createApi accepts reportUnexpectedError. Omit it to retain console.error,
pass null to disable reporting, or supply an UnexpectedErrorReporter:
const api = createApi({
unitOfWork: store.unitOfWork,
runAsOwner,
authenticate,
reportUnexpectedError: () => {
console.error("keepcv.unexpected_error");
},
});The callback receives the original unexpected error, whose causes may contain
SQL parameters or authored content. The caller chooses what is safe to report;
the API adds no request or authentication data. Expected problems do not invoke
it. Reporting is best effort: synchronous throws and rejected promises are
ignored, with no fallback console output. Reports do not delay the response.
Every reporting choice retains the same generic 500 problem and the existing
validation, conflict, stale-write, restore and export-change responses.
problemFor(error, instance, reportUnexpectedError) accepts the same optional
reporter for callers using the mapper directly.
What the boundary guarantees
- The owner is never a parameter.
authenticateresolves a request to an owner and the API enters that scope once; every repository call underneath is scoped by construction, so "forgot to scope this query" is not a mistake a handler is able to make. - Every error is a typed problem. One shape,
application/problem+json, with atypethe client switches on. Nothing downstream parses prose. - A stale write answers with the state the server holds. Mutations carry the
updated_atthey were based on - as{ expectedUpdatedAt, patch }, beside the changes rather than among them - and a mismatch returns409with the current row attached, so the UI can show both sides instead of discarding one. - A refused write names the constraint. A taken sort key is a
409, because the caller resolves it by re-reading; a parent that does not exist, or a column the record's kind may not carry, is a422. A driver error never reaches the client, since a caller mistake reported as500sends everybody looking in the wrong place. DELETEarchives. Nothing the user wrote is destroyed, the row stays readable by id, and?archived=includebrings it back into a list.- Phrasing text is append-only.
PATCH /v1/phrasings/{id}has no field that could carry text; changing what a phrasing says isPOST /v1/phrasings/{id}/revisions, which keeps the superseded wording and moves a pointer. It is the one write with no concurrency token, because two people writing different wordings at once must both keep their text. - In-progress text survives a closed tab.
PUT /v1/drafts/{targetKind}/{targetId}/{field}keeps uncommitted editor state outside history, overwritable and without a concurrency token, so keystrokes never become revisions and nothing half-written is lost. Drafts arrive with the boot payload, so an editor knows one is waiting before it opens. - A resume is a selection, and it is composed rather than copied.
/v1/resumesholds what a resume is aimed at;/v1/resume-sections,/v1/resume-entriesand/v1/resume-entry-pointshold what it selects, each narrowed by?resumeId=. Toggling something off setsisVisible, so the wording chosen and the position it sat in survive, and a point can appear at most once on one resume. Reading a whole resume iscomposition(store, id)in@keepcv/core, not a route. - A version records what was sent, and the store captures it.
POST /v1/resume-versionstakes the resume and the trigger, never a manifest: the store freezes what the resume says, assigns the sequence, and answers200with the current version rather than appending a duplicate when nothing has moved. A version is immutable, so it has noPATCH; a snapshot is the label you put on one, and/v1/resume-snapshotsis an ordinary collection./v1/points/{id}/usageand/v1/records/{id}/usageanswer which versions printed a row, which is what "if I archive this, what does it affect?" reads. - One request boots a client.
GET /v1/storeanswers the whole store, archived rows included, so filters and counts are selectors over cached data rather than requests. It carries the wording each phrasing says now and not the ones it used to;GET /v1/exportcarries every wording ever written. - Export is never gated. Not by an account, not by a licence, not by any entitlement state.
- The OpenAPI document comes from the same Zod schemas that validate the
requests, so the description and the validator cannot disagree. It is served
at
/v1/openapi.json, without a token, because tooling fetches it before it has one.
Licence
MIT
createApi accepts an optional templateTypes catalog of trusted validators
from @keepcv/templates. Ordinary template writes validate the installed type and
payload; native import preserves bounded unavailable definitions without running
them. Built-in validators cover Default and HTML.
Assistant deployment controls
createApi accepts optional agent, agentLimits and agentScope settings.
agentScope(request, ownerId) asynchronously derives a trusted authorization
scope for turns, grants and proposal decisions; never derive it from untrusted
request body claims. Runtime authorize revalidates current authority during work.
See the runtime package's public authorization and cancellation contract.
agentLimits lowers file bytes, text-file bytes, selected file bytes per turn,
attachment count and message character limits. /v1/agent/capabilities reports
the effective minimum of API and runtime limits, generation available and
historyAvailable. File metadata includes decoded sizeBytes; original download
bytes remain unchanged. Existing custom runtimes may omit the new limit fields.
Omitting agent does not disable conversation creation, attachment storage,
history or native export/import routes. Those remain authenticated owner-scoped
storage operations. Enforce any additional storage authorization at the consumer's
request/unit-of-work boundary. Editor configuration only changes presentation.
Generation refusal or provider failure leaves ordinary editing and export usable.
Authentication, provider credentials, deployment rate limiting and storage
accounting belong to the embedding application.
Stop is a local cancellation request. The built-in runtime returns a boolean confirming whether it held a controller; legacy void cancellation remains accepted but cannot confirm one. If no controller is confirmed and the owned conversation has an unexpired bounded running lease, POST cancel returns 409 with guidance to wait for expiry or stop the executing process. It still invalidates local task access; it never clears the stored lease. A 200 saved response does not promise completion or immediate lease release. Expired leases permit a fresh turn without restoring grants or proposal authority. Committed edits and receipts remain intact.
