@ciromaciel/contract
One response envelope, a closed catalogue of error codes, and helpers that make answering outside it impossible.
This package is the executable norm: what services import in order to respond, and what tests import in order to check. The rules it enforces are described below.
Installation
bun add @ciromaciel/contract
zod is an optional peer, needed only by the /schemas entrypoint, and
jose only by /token.
Quick start
import { ok, created, collection, failure } from '@ciromaciel/contract'
import { CODES } from '@ciromaciel/contract/codes'
return ok({ id, name })
return created({ id, name })
return failure(CODES.NOT_FOUND, 'project not found')
failure() derives the HTTP status from the code — there is no signature that
lets you return NOT_FOUND with a 200.
API
@ciromaciel/contract
| Export | What it does |
|---|---|
ok(resource, { status, headers }) | One resource, at the root. No data, no success, no message. |
created(resource, { headers }) | A resource that has just been born — 201. |
collection(list, page, { status, headers }) | { items, page: { limit, offset, total, hasMore } }. Without total, total and hasMore come back null — "nobody counted" is not the same as zero. |
page({ limit, offset, total, extra }) | Builds the page object for collection, requiring a numeric total. |
pagination(url, { defaultLimit, maxLimit }) | Reads limit/offset from a URL, with a ceiling. |
failure(code, message, { details, headers, status }) | An error Response. Status comes from the code. |
failureBody(code, message, details) | { body, status } for frameworks that assemble the response themselves. |
failureWithStatus(status, message, details, headers) | For a global error handler that only has the HTTP number at hand. |
CODES · STATUS_BY_CODE · RETRIABLE · ALIASES | The vocabulary and its tables. |
isCode · toCode · codeFromStatus | Code guards and conversions. |
@ciromaciel/contract/codes
The closed enum: UNAUTHORIZED, FORBIDDEN, WRONG_AUDIENCE,
INSUFFICIENT_SCOPE, NOT_FOUND, CONFLICT, NAME_TAKEN, EXPIRED,
HAS_RESOURCES, VALIDATION_ERROR, UNPROCESSABLE, RATE_LIMITED,
PAYLOAD_TOO_LARGE, PLAN_LIMIT, SUBSCRIPTION_REQUIRED, INTERNAL_ERROR,
SERVICE_UNAVAILABLE, UPSTREAM_ERROR.
code is the part the client reads; message is for the human and its wording
may change at any time. Renaming a code is a breaking change.
@ciromaciel/contract/schemas
import { errorSchema, collectionSchema, resourceSchema, pageSchema, codeSchema } from '@ciromaciel/contract/schemas'
Zod schemas for the same rules, from the reader's side. This path is separate on
purpose: schemas.js imports Zod, and a worker that only needs to respond
should not have to load a validator it never calls. While the index re-exported
the schemas, the import was eager — asking for failure pulled Zod in, and the
install broke with "Cannot find package 'zod'" for anyone who treated the peer
as optional.
@ciromaciel/contract/plan
import { createPlanGate } from '@ciromaciel/contract/plan'
export const plan = createPlanGate({
product: 'auth',
resource: 'applications',
tiers: [
{ slug: 'auth-applications-1', limit: 1 },
{ slug: 'auth-applications-5', limit: 5 },
{ slug: 'auth-applications-unlimited', limit: Infinity },
],
})
Also exports hasSlug, tierLimit, getPlanNotice, withPlanNotice,
gateTelemetry, recordGateDecision and resetGateTelemetry. A product owns
the prefix, the slugs and the wording of the notice; the cache, the fail-open,
the product-prefix check and the choice between PLAN_LIMIT and
SUBSCRIPTION_REQUIRED are the same reasoning everywhere.
@ciromaciel/contract/webhooks
import { webhookHeaders, verifyWebhook, generateSigningSecret } from '@ciromaciel/contract/webhooks'
const headers = await webhookHeaders(secret, { id: event.id, body }) // x-ccm-id, x-ccm-timestamp, x-ccm-signature
const result = await verifyWebhook([secret, previousSecret], { id, timestamp, signature, body })
One protocol for every webhook of the stack: Standard Webhooks with the x-ccm
prefix. The signature is v1,<base64 HMAC-SHA256 of "id.timestamp.body">, the
key is the base64 after whsec_, and a timestamp more than five minutes off is
refused. verifyWebhook takes several secrets so a rotation has a grace period,
and answers with a reason (stale_timestamp, bad_signature, …) instead of a
bare boolean. Also exports signWebhook, secretPrefixOf and
SIGNATURE_TOLERANCE_SECONDS.
@ciromaciel/contract/token
import { createTokenVerifier } from '@ciromaciel/contract/token'
const tokens = createTokenVerifier({ authUrl: 'https://auth.worker.myinfrastructure.click' })
await tokens.verifySession(bearer) // REST routes
await tokens.verifyAccessToken(bearer, { audience: 'https://hoster.worker.myinfrastructure.click/mcp' }) // MCP server
The Auth signs sessions and OAuth access tokens with the same key and issuer;
only the claims tell them apart. verifySession accepts a person's session and
refuses an access token (access_token_not_accepted): a token consented for one
resource and one scope must never act as the whole account on a REST route.
verifyAccessToken accepts only an access token whose aud is this server
(wrong_audience otherwise, RFC 8707) and returns its scopes.
Both return { ok: true, subject, email, name, scopes, claims } or
{ ok: false, reason }, never throw on a bad credential, and accept RS256 with
the riligar-auth issuer only. jose (5 or 6) is an optional peer, needed
only by this entrypoint.
@ciromaciel/contract/outbox
import { OUTBOX_SQL, envelope, enqueueEvent, deliverDue, releaseHeld, retryDelivery } from '@ciromaciel/contract/outbox'
The delivery queue every product shares. Each event is a row in outbox before it leaves, one per subscribed endpoint, signed with ./webhooks. A 2xx is sent; 401 and 403 are held (probed hourly, released at once by releaseHeld when the secret changes); 410 is failed; anything else retries after 1 min, 5 min, 30 min, 2 h and 12 h. OUTBOX_SQL is the schema to put in the product's first migration. The product supplies a D1-shaped db and decryptSecret(ciphertext).
@ciromaciel/contract/feedback
import { createFeedback, FEEDBACK_SQL } from '@ciromaciel/contract/feedback'
"Sugerir ou relatar", the corner button of every panel (FeedbackWidget in @ciromaciel/dashboard-kit), on the worker side. createFeedback({ product, prefix, error, requirePerson, locate, fields }) returns handleRoutes (GET/POST /api/feedback, behind the login), handleHooks (the Tasks' signed answers at /feedback/tasks and the build job's news at /feedback/build) and runCron (resend what the Tasks did not take; resend and then report what the triage never took). A feedback is addressed by its full UUID and only by it (2.0.0, 25/10/2026): its thread is <product>:<uuid> (the workKey of feedback.created), the build's news are { id, status, note? }, and a prefix, a code, a slug or an unknown id answers the same 404. The Tasks' thread itself goes by its own UUID (2.1.0): the feedback.job event carries workId (from the webhook's workId), never the key. <prefix>-<6 hex> (DK-1A2B3C) is the label people read in titles, never an address. FEEDBACK_SQL is the table; the product adds its own columns (deck_id) and names them in locate().columns. Only a person sends, and locate names only the account's own things. Env: FEEDBACK_URL, FEEDBACK_SECRET, TASKS_WEBHOOK_SECRET, BUILD_SECRET. FAILURE_OPTIONS are the choices of a job that stopped (1.5.0): each one the Ciro picks becomes the job that answers it (repair, retry, revert, ship, revise, build); the ones that move main share the product's deploy group.
Links
MIT © Ciro Cesar Maciel