Skip to content
Back to skills

Server Api

ASecurity

Implement HTTP entrypoint protocols with @owlmeans/server-api handlers<Context>().body(), params(), request(), and uploadedFile(). Load before writing an API handler.

  • 3 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 22, 2026
developmentapi

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 6, 2026

npx -y skills add owlmeans/common --skill server-api --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Server Api?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Server Api
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/owlmeans-server-api-common/badge)](https://www.skillsdirectory.com/skills/owlmeans-server-api-common)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: server-api
description: Implement HTTP entrypoint protocols with @owlmeans/server-api handlers<Context>().body(), params(), request(), and uploadedFile(). Load before writing an API handler.
user-invocable: false
---
<!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->

# @owlmeans/server-api

**Install:** `bun add @owlmeans/server-api@^0.1.18-rc.52`

Make handlers from the protocol declaration so input and output types stay coupled to the shared
contract:

```ts
const api = handlers<AppContext>()

const create = api.body(projectProtocols.create, async (body, context, request) =>
  context.projects.create(body, request.auth)
)

const get = api.params(projectProtocols.get, async ({ id }, context) =>
  context.projects.get(id)
)

const search = api.request(projectProtocols.search, async (request, context) =>
  context.projects.search(request.query)
)

export const serverBindings = [
  bind(projectProtocols.create, create),
  bind(projectProtocols.get, get),
  bind(projectProtocols.search, search),
]
```

`body` and `params` are available only for a protocol declaring that section. `request` works for
any protocol and receives all its typed sections plus request metadata. A successful callback
return resolves the entrypoint with `EntrypointOutcome.Ok`; a thrown error rejects it.

A body contract may be a scalar (`schema<string>({ type: 'string' })`, a number, a boolean): the
server's JSON parser takes any JSON value, so `"abc"`, `42` or `false` reach the handler as that
value. An unquoted string or an empty body under `application/json` is invalid JSON and answers
400 before the handler runs — `@owlmeans/api` never sends either (it quotes a scalar body).
`tests/json-body.spec.ts` runs it through `appendApiServer`.

## Wrap exactly once

A handler is wrapped by `handlers<Context>()` exactly once — either shape above (create the bound
handler and bind it directly) is correct on its own. Never combine them: a handler module that
already exports a bound handler must be bound directly, not wrapped again where it is bound.

```ts
// WRONG — bound once in the handler module, wrapped a second time here
export const create = api.body(projectProtocols.create, async (body, context) => ...)
bind(projectProtocols.create, api.body(projectProtocols.create, create))
```

`tsc` rejects the double wrap (`TS2345 "Argument of type 'BoundEntrypointHandler<…>' is not
assignable"`). At runtime, `body`/`params`/`request` return an already-bound handler for the SAME
protocol unchanged, with a one-time warning; anything else that is not a plain function fails only
that one route with `HandlerMisconfiguredError`, instead of the opaque
`TypeError: handler is not a function`.

## The status a thrown error answers

A thrown error (or a rejected response) is answered with a body chosen by the exposure (§ Error
exposure) and a status from `httpErrorHelper.errorStatus(error)` (`./utils`), resolved in this order:

| Error | Status |
|---|---|
| `AuthForbidden`, `AccessError` or a subclass — by class or registered type name | 403 |
| `AuthorizationError`, `AuthFailedError` or a subclass — by class or registered type name | 401 |
| a class declaring `static httpStatus` as an integer 400–499, or 500–599 with `static allowServerErrorStatus = true` | that status |
| anything else, including a 5xx declared without the opt-in | 500 |

- **A refusal of the caller's condition declares its status; a fault declares nothing.** 400 a
  malformed request, 402 an unpaid balance or plan, 404 an addressed target that does not exist
  (or is another organization's), 409 a target whose current state conflicts, 422 content or a body
  that is understood and refused — and a missing configuration, a broken peer, a timeout or a bug
  stays 500, because monitoring, logs, proxies and retry logic read a 5xx as the server failing.
  The table and the leaf-class rule are the `error` skill's.
- The storage refusals of `@owlmeans/resource` declare theirs: `UnknownRecordError` answers 404,
  `RecordExists` 409, every other `ResourceError` 500 — so a handler that lets a `get()` miss
  escape answers 404, and one reading a row the request did not address uses `load` instead.
- Declare it on the class: `public static httpStatus = 409` (`override` only when an ancestor
  already declares one). The declaration is structural — the package declaring an error never
  imports `@owlmeans/server-api` — and a static property is inherited, so a subclass answers its
  nearest declaring ancestor's status and redeclares to change it.
- The auth branches win over a declaration, in that order (`AuthForbidden extends
  AuthorizationError`, so 403 is tested first). An entitlement or permission refusal extends
  `AuthForbidden` rather than declaring 403.
- `httpErrorHelper.handleError` resolves the status on the error AS THROWN first, and asks the ENSURED
  (`ResilientError.ensure`) error only when that answers 500. The thrown object is the one whose
  class is certainly what was raised; the rebuild is what gives a status to a marshalled error that
  crossed a hop as a plain `Error`. `ensure` returns an error from any `@owlmeans/error` copy
  untouched, so a development body keeps the thrown class's `type` (`AuthFailedError|||api:auth:…`) even in a
  process holding duplicate module copies (`bun --preserve-symlinks`). `payloadHelper.executeResponse` ensures
  nothing and answers the rejected error's status. `@owlmeans/server-socket` answers an upgrade
  through the same `httpErrorHelper.handleError`.
- An auth family is recognised by `instanceof` OR by an exact registered type name — the instance's
  `type` or any static `typeName` on its constructor chain — so a class from another module copy
  answers the same status. Match whole names, never substrings: a subclass's `typeName` does not
  reliably embed its parent's (`EntitlementRefusal` extends `AuthForbidden`). A declared
  `httpStatus` is a structural static read and survives duplicate copies as it is.
- **The status is what a production client can act on.** Only a development body is rebuilt into
  its class; a production body is the incident id alone, so a client knows a refusal by its status
  (§ Error exposure) — give a refusal the client must act on its own distinct 4xx. Nothing in the
  framework treats a 404 specially — a missing route is a Fastify JSON body the client turns into
  a 404 `ApiStatusError`.

`uploadedFile(request)` is the Fastify multipart boundary. Keep raw Fastify access there rather
than reaching through `request.original` in application code.

## Logging

The server logs through `@owlmeans/log`, not a second pino: Fastify gets a pino-shaped adapter as its
`loggerInstance` (scope `http`) with its own request/response lines disabled. A request is one `debug`
record from `onResponse` (method, path without the query, status, ms). A failed request is logged once,
in `httpErrorHelper.handleError`, by what it means: **5xx → `error`** (the error with its stack and the incident id),
**403 → `warn`, `event: 'access.forbidden'`**, 401 → `debug`, `event: 'auth.refused'`, any other 4xx →
`debug`. The level and format come from `cfg.log` (`/log`); access lines at info are
`cfg.log.debug: 'http'`.

## Error exposure

An exact IAM `AuthForbidden` or `AccessError` refusal answers with
`X-OwlMeans-Denial: access-denied`, exposed through CORS. Subclasses such as entitlement refusals
do not get this marker. The marker survives production exposure so browser clients can present a
permission message while keeping diagnostic bodies private.

`httpErrorHelper.handleError` always assigns an incident UUID, attaches it to the logged error and returns it in the
`X-Incident-ID` response header (`INCIDENT_ID_HEADER` in `./utils`, the same name and value
`@owlmeans/api` exports; exposed through CORS).

- **Production (the default):** a non-2xx body carries ONLY that incident id, under the resolved
  HTTP status. A typed refusal is never exposed — its class, message and packed fields stay in the
  server log under the id.
- **Development** (an explicit `cfg.http.errors.exposure = 'development'`): the typed marshalled
  form with message and stack, which the client rebuilds into its class.
- A client reads a production failure with `@owlmeans/api`'s `./status` subpath: `apiStatusHelper.httpStatusOf(e)`
  (the status a 428 or 409 is acted on by) and `.incidentIdOf(e)` (the id a person reports). Keep
  the default production-safe, and tell a client to report the incident id.

Do not use unbound compatibility handler wrappers. For a WebSocket route use
`@owlmeans/server-socket`'s `connection(protocol, callback)`.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…