@suluk/hono - v0.1.5
    Preparing search index...

    @suluk/hono - v0.1.5

    Suluk

    @suluk/hono

    The derivation engine: author minimal Hono+Zod route contracts; everything else — the v4 doc, validation, contract tests, a doc-coverage audit, and on-the-wire access + rate-limit enforcement — is derived.


    CANDIDATE tooling — not official OpenAPI. Suluk is a single-contributor candidate for OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable to ratify anything on the SIG's behalf.

    bun add @suluk/hono
    

    Peer deps you supply: hono, zod (v4), and @hono/zod-validator.

    You author one thing — an array of RouteContracts (a Hono path + method + Zod schemas). From that single source, @suluk/hono projects:

    • The v4 documentemitV4(routes, ctx). Not a static file: the document is a pure function of the contracts × the requesting principal (the who — scope filtering) × time (the whendeprecatedSince/removedSince). It also synthesizes RFC-9457 error responses + a shared ProblemDetails schema.
    • The live appmount(app, routes), the only file that touches Hono. Request validation is derived from the same Zod schemas, so the doc and the running server cannot drift.
    • Contract testsrunContractChecks(routes) auto-generates checks (every schema is valid JSON Schema 2020-12, every example satisfies its schema, the emitted document passes the meta-schema, no two routes provably collide).
    • A doc-coverage auditaudit(doc) / coverage(doc) flag under-documented operations (advisory, never a gate); autofill(doc) fills the obvious gaps.
    • Server-side authorization — the row-level CRUD engine (gate / policyFor / DEFAULT_POLICIES) plus facet-driven, fail-closed wire middleware (enforceAccess, enforceRateLimit) and a typed RFC-9457 error model (HttpErrors + onError).

    Reach for @suluk/hono when you are defining HTTP routes and want the contract, the v4 document, request validation, and access/rate-limit enforcement to all derive from one declaration rather than being maintained by hand.

    It is the producer end of the Suluk walk — the thing the rest of the toolchain consumes. The v4 document it emits is what @suluk/scalar, @suluk/swagger, @suluk/reference, @suluk/sdk, and @suluk/testgen project out. If you already have a v4 document and only want to render or generate from it, reach for those packages instead.

    enforceAccess / enforceRateLimit are the only server-side enforcement home — Suluk's x-suluk-* facets are advisory, so without this middleware an access facet on a custom op is decorative. Apply these to make every operation's facet load-bearing on the wire.

    import * as z from "zod";
    import { contract, emitV4 } from "@suluk/hono";

    const Pet = z.object({ id: z.number().int().optional(), name: z.string().min(1), tags: z.array(z.string()) });

    const routes = contract([
    {
    method: "get", path: "/pet", name: "listPets",
    summary: "List pets", tags: ["pets"],
    responses: [{ status: 200, description: "ok", schema: z.array(Pet) }],
    },
    {
    method: "post", path: "/pet", name: "createPet",
    summary: "Create a pet", scopes: ["write:pets"],
    request: { json: Pet, examples: [{ name: "Rex", tags: [] }] },
    responses: [{ status: 201, description: "created", schema: Pet }],
    },
    {
    method: "get", path: "/pet/:petId", name: "getPet",
    summary: "Get a pet by id",
    request: { params: z.object({ petId: z.string() }) },
    responses: [{ status: 200, description: "ok", schema: Pet }],
    },
    ]);

    const { document, diagnostics } = emitV4(routes, { info: { title: "Pets", version: "1.0.0" } });
    // `document` is a v4 (4.0.0-candidate) OpenAPIv4Document — Hono ":petId" became uriTemplate "pet/{petId}".
    // WHO — a principal is filtered to the operations whose required scopes it holds:
    emitV4(routes, { principal: { scopes: [] } }); // createPet (requires write:pets) is omitted
    emitV4(routes, { principal: { scopes: ["write:pets"] } }); // createPet is included
    emitV4(routes); // no principal ⇒ the full public doc

    // WHEN — `now` drives deprecatedSince/removedSince:
    emitV4(routes, { now: "2031-06-01" }); // a route past its removedSince is hidden
    // a route past deprecatedSince (but before removedSince) stays, marked `deprecated: true`.

    // Map scopes onto a security scheme to synthesize 401/403 + security requirements:
    emitV4(routes, { securityScheme: "bearerAuth", securitySchemes: { bearerAuth: { type: "http", scheme: "bearer" } } });
    import { Hono } from "hono";
    import { mount } from "@suluk/hono";

    // each contract's `handler` is wired with @hono/zod-validator derived from its own schemas.
    const app = mount(new Hono(), routes);
    // POST /pet with { name: "" } → 400 (name.min(1) fails); valid body → the handler runs.
    import { runContractChecks, audit, coverage, autofill } from "@suluk/hono";

    const run = runContractChecks(routes);
    // run.total / run.passed / run.failures — wire this into `bun test` or CI.

    const { document } = emitV4(routes);
    audit(document); // Finding[] — under-documented operations (advisory)
    coverage(document); // a [0,1] score; 1 = no warn-level gaps
    coverage(autofill(document)); // autofill synthesizes summaries/descriptions → raises coverage

    enforceAccess reads each operation's declared x-suluk-access and enforces it; identity is injected (you own your principal/scope model). It is fail-closed — a missing facet denies by default, an unknown/mis-cased requires denies, a named scope is enforced even under requires: "anyone".

    import { enforceAccess, enforceRateLimit } from "@suluk/hono";

    app.use("*", enforceAccess({
    operationOf: (c) => /* resolve the contract op for this request */ resolveOp(c),
    accessOf: (op) => accessFacets[op], // the op's x-suluk-access ({ requires, scope })
    principal: (c) => c.get("userId") ?? null,
    isAdmin: (c) => c.get("isAdmin") === true,
    scopes: (c) => c.get("scopes"),
    }));

    app.use("*", enforceRateLimit({
    operationOf: (c) => resolveOp(c),
    rateLimitOf: (op) => budgets[op], // the op's x-suluk-ratelimit ({ windowMs, maxRequests, key })
    defaultFacet: { windowMs: 60_000, maxRequests: 300, key: "ip" }, // blanket floor (default: unmetered)
    }));

    For hand-applied per-route gating instead of one blanket middleware, use createGuard:

    import { createGuard } from "@suluk/hono";

    const guard = createGuard({ principal: (c) => c.get("userId") ?? null, isAdmin: (c) => c.get("isAdmin") === true });
    app.get("/me", guard.requireAuth, handler); // 401 unless signed in
    app.get("/admin", guard.requireAdmin, handler); // 401 anon, 403 non-admin
    app.post("/write", guard.requireScopes("write:pets"), handler); // 403 unless holds every scope

    gate is pure (no Hono Context) — it decides whether a caller may run a CRUD op and whether the query should be scoped to their own rows. ruleToRequires projects the same rule onto the wire requires level, so one declaration drives both the gate and the x-suluk-access facet.

    import { gate, policyFor, ruleToRequires, DEFAULT_POLICIES } from "@suluk/hono";

    const policy = policyFor("owned"); // one of 7 modes in DEFAULT_POLICIES (or pass your own matrix)
    const decision = gate(policy.update, { isAdmin: false, principal: "user_123" });
    // → { ok: true, scopeOwner: true } — signed-in caller may update, scoped to their own rows; admin sees all.

    ruleToRequires("owner"); // "authenticated" — feeds the same op's x-suluk-access facet/docs
    import { Hono } from "hono";
    import { HttpErrors, onError } from "@suluk/hono";

    const app = new Hono();
    app.onError(onError()); // maps a thrown SulukHttpError → its status + problem+json body
    app.get("/pet/:id", (c) => {
    throw HttpErrors.notFound("Pet", c.req.param("id")); // → 404 { status, title, detail, instance, error }
    });
    // An untyped throw becomes a 500 defect; its cause is logged server-side, never leaked on the wire.
    // HttpErrors.rateLimited(ms) additionally emits a Retry-After header.
    Export What it does
    contract / responseList identity helper to author a RouteContract[] with literal inference; normalize a responses list/map
    emitV4 project contracts → a v4 document for a given principal (who) + time (when)
    mount wire the same contracts onto a live Hono app (the only Hono-touching file)
    audit / coverage / autofill doc-coverage findings, a [0,1] score, gap-filling
    contractChecks / runContractChecks auto-generated contract tests (schema-valid, example⊨schema, meta-schema, no collisions)
    validateSchema2020 assert a value is well-formed JSON Schema 2020-12
    enforceAccess / createGuard facet-driven wire authz middleware; explicit per-route guards
    gate / policyFor / ruleToRequires / DEFAULT_POLICIES the pure row-level CRUD access engine
    enforceRateLimit / MemoryRateLimitStore facet-driven fixed-window rate limiting (swappable store)
    SulukHttpError / HttpErrors / onError the typed, throwable RFC-9457 error model + the Hono error handler

    This is the producer end of the projection — contract in → v4 document + a mounted app out. It renders and generates; it does not host. The seams are injected:

    • Identity is injected. enforceAccess / createGuard never trust a header they didn't verify — your principal / isAdmin / scopes callbacks own that (Better Auth session, API token, etc.).
    • The rate-limit store is a swappable binding. MemoryRateLimitStore is a per-process dev default only. The durable production store (KV / Durable Object) lives app-side / in @suluk/deploy.
    • Row scoping stays app-side. gate tells you whether to scope to the owner; your CRUD layer applies that to the actual query.

    Access enforcement is the one server-side enforcement home — new enforcement primitives or access modes belong here; apps keep their per-op access/budget tables as plain data.

    Apache-2.0