@suluk/hono 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ <p align="center">
2
+ <a href="https://github.com/MahmoodKhalil57/suluk">
3
+ <img src="https://raw.githubusercontent.com/MahmoodKhalil57/suluk/main/branding/export/wordmark.png" alt="Suluk" width="360" />
4
+ </a>
5
+ </p>
6
+
7
+ <h1 align="center">@suluk/hono</h1>
8
+
9
+ <p align="center"><b>The Suluk derivation engine: minimal Hono+Zod RouteContracts in; v4 doc (dynamic per principal+time), validation, contract tests, and doc-coverage audit out.</b></p>
10
+
11
+ <p align="center">
12
+ <em>Part of <a href="https://github.com/MahmoodKhalil57/suluk">Suluk</a> — one typed OpenAPI v4 contract projecting into every full-stack layer.</em>
13
+ </p>
14
+
15
+ ---
16
+
17
+ > **CANDIDATE tooling — not official OpenAPI.** Suluk is a single-contributor candidate for
18
+ > OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable
19
+ > to ratify anything on the SIG's behalf.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ bun add @suluk/hono
25
+ ```
26
+
27
+ ## The Suluk cycle
28
+
29
+ `@suluk/hono` is one station on the Suluk walk — author one v4 source, then **validate · audit ·
30
+ preview · generate · deploy** the whole stack from it. Explore the full toolchain in the
31
+ [main repository](https://github.com/MahmoodKhalil57/suluk) or drive it from the [VS Code cockpit](https://marketplace.visualstudio.com/items?itemName=MahmoodKhalil.suluk-vscode).
32
+
33
+ ## License
34
+
35
+ Apache-2.0
package/package.json CHANGED
@@ -1,15 +1,26 @@
1
1
  {
2
2
  "name": "@suluk/hono",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "The Suluk derivation engine: minimal Hono+Zod RouteContracts in; v4 doc (dynamic per principal+time), validation, contract tests, and doc-coverage audit out. CANDIDATE tooling.",
5
- "type": "module",
6
- "main": "src/index.ts",
7
- "exports": {
8
- ".": "./src/index.ts"
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "license": "Apache-2.0",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/MahmoodKhalil57/suluk.git",
12
+ "directory": "tooling/ts/packages/hono"
13
+ },
14
+ "homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
15
+ "bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
16
+ "type": "module",
17
+ "main": "src/index.ts",
18
+ "exports": {
19
+ ".": "./src/index.ts"
9
20
  },
10
21
  "dependencies": {
11
- "@suluk/core": "0.1.0",
12
- "@suluk/zod": "0.1.0",
22
+ "@suluk/core": "^0.1.11",
23
+ "@suluk/zod": "^0.1.2",
13
24
  "ajv": "^8.20.0",
14
25
  "ajv-formats": "^3.0.1"
15
26
  },
@@ -20,7 +31,7 @@
20
31
  },
21
32
  "devDependencies": {
22
33
  "@types/bun": "latest",
23
- "@suluk/openapi-compat": "0.1.0",
34
+ "@suluk/openapi-compat": "^0.1.2",
24
35
  "zod": "^4.4.3",
25
36
  "hono": "^4.0.0",
26
37
  "@hono/zod-validator": "^0.8.0"
package/src/access.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The ROW-LEVEL authorization engine that pairs with {@link enforceAccess} (the wire-level facet enforcer). A
3
+ * generic CRUD app declares each entity's access MODE; this maps mode → per-operation Rule → a decision: may the
4
+ * caller run it, and is the query SCOPED to their own rows. Pure (no Hono Context — takes the resolved
5
+ * `{ isAdmin, principal }`), so it is testable and reusable. `ruleToRequires` projects a Rule to the wire
6
+ * `requires` level so ONE declaration drives BOTH the CRUD gate AND the `x-suluk-access` facet/docs.
7
+ */
8
+ import type { AccessRequires } from "./enforce";
9
+
10
+ /** A CRUD operation's authorization rule. */
11
+ export type Rule = "any" | "owner" | "admin" | "none";
12
+ /** The five CRUD operations' rules for one access mode. */
13
+ export interface Policy { list: Rule; get: Rule; create: Rule; update: Rule; delete: Rule }
14
+ /** The built-in access modes (a sensible SaaS/commerce default set; override via `policyFor`'s `policies` arg). */
15
+ export type AccessMode = "public" | "admin" | "submit" | "owned" | "ownedAppend" | "ownedReadonly" | "review";
16
+
17
+ /** The opt-in default mode→policy preset. Adopt by reference, or pass your own matrix to {@link policyFor}. */
18
+ export const DEFAULT_POLICIES: Record<AccessMode, Policy> = {
19
+ // catalog + content: world-readable, admin-writable
20
+ public: { list: "any", get: "any", create: "admin", update: "admin", delete: "admin" },
21
+ // sensitive (e.g. discount codes): admin-only — even reads (listing all is a leak)
22
+ admin: { list: "admin", get: "admin", create: "admin", update: "admin", delete: "admin" },
23
+ // public submissions (contact, newsletter): anyone may create; only admins read/modify
24
+ submit: { list: "admin", get: "admin", create: "any", update: "admin", delete: "admin" },
25
+ // user-owned: each caller only sees/mutates their own rows (admin sees all)
26
+ owned: { list: "owner", get: "owner", create: "owner", update: "owner", delete: "owner" },
27
+ // owned + append-only to the user — place + read your own, but only the system/admin mutates (no self-mark-paid)
28
+ ownedAppend: { list: "owner", get: "owner", create: "owner", update: "admin", delete: "admin" },
29
+ // owned but READ-ONLY to the user — the system/admin even creates it (e.g. billing rows)
30
+ ownedReadonly: { list: "owner", get: "owner", create: "admin", update: "admin", delete: "admin" },
31
+ // public-read, owner-write (e.g. product reviews): everyone reads; you only edit your own
32
+ review: { list: "any", get: "any", create: "owner", update: "owner", delete: "owner" },
33
+ };
34
+
35
+ /** The policy for an access mode (default: owned when an ownerCol is present, else public). `policies` overrides the preset. */
36
+ export function policyFor(access: AccessMode | undefined, ownerCol?: string, policies: Record<AccessMode, Policy> = DEFAULT_POLICIES): Policy {
37
+ return policies[access ?? (ownerCol ? "owned" : "public")];
38
+ }
39
+
40
+ /** The resolved caller identity a gate decision needs (compute from your Hono Context: isAdmin flag + principal id). */
41
+ export interface GateIdentity { isAdmin: boolean; principal: string | null }
42
+ /** A gate decision: may the op run, scope the query to the owner, and — when denied — the honest status. */
43
+ export interface GateDecision { ok: boolean; scopeOwner: boolean; status?: 401 | 403 }
44
+
45
+ /**
46
+ * Decide whether a caller may run an op (per the rule), whether to scope the query to their own rows, and the honest
47
+ * deny status. FAIL-CLOSED: an `owner` op with no principal is 401 (the wire must enforce what `x-suluk-access`
48
+ * claims — a null-scoped empty 200 would let the facet lie); `admin` with no principal is 401, signed-in-non-admin is
49
+ * 403; `none` hard-denies 403. A signed-in owner is scoped to their rows; an admin sees all.
50
+ */
51
+ export function gate(rule: Rule, id: GateIdentity): GateDecision {
52
+ switch (rule) {
53
+ case "any": return { ok: true, scopeOwner: false };
54
+ case "owner":
55
+ if (id.isAdmin) return { ok: true, scopeOwner: false }; // admin sees all
56
+ if (!id.principal) return { ok: false, scopeOwner: false, status: 401 }; // owner op needs a verified caller
57
+ return { ok: true, scopeOwner: true }; // signed-in: scoped to own rows
58
+ case "admin":
59
+ if (!id.principal) return { ok: false, scopeOwner: false, status: 401 }; // authenticate first (RFC 7235)
60
+ return { ok: id.isAdmin, scopeOwner: false, status: 403 }; // signed-in but not admin → forbidden
61
+ default: return { ok: false, scopeOwner: false, status: 403 };
62
+ }
63
+ }
64
+
65
+ /** Project a CRUD Rule to the wire-level `requires` (so one rule drives BOTH the gate AND the x-suluk-access facet). */
66
+ const RULE_TO_REQUIRES: Record<Rule, AccessRequires> = { any: "anyone", owner: "authenticated", admin: "admin", none: "admin" };
67
+ export function ruleToRequires(rule: Rule): AccessRequires { return RULE_TO_REQUIRES[rule]; }
package/src/contract.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * the pure derivations (emitV4 / audit / contractChecks) need no running server — only mount() touches Hono.
5
5
  */
6
6
  import type * as z from "zod";
7
- import type { SecurityRequirement } from "@suluk/core";
7
+ import type { SecurityRequirement, SulukRateLimit } from "@suluk/core";
8
8
 
9
9
  export type Method = "get" | "post" | "put" | "patch" | "delete" | "head" | "options";
10
10
 
@@ -51,6 +51,16 @@ export interface RouteContract {
51
51
  security?: SecurityRequirement[];
52
52
  /** Required scopes. Drives BOTH the per-principal filter (the "who") and synthesized security. */
53
53
  scopes?: string[];
54
+ /**
55
+ * Error statuses this operation can return. Synthesized into RFC-9457 error responses by emitV4 (alongside
56
+ * the auto-derived 401/403 for auth-gated ops, 429 when rate-limited, and an always-present 500).
57
+ */
58
+ errors?: number[];
59
+ /**
60
+ * The declared rate budget (the `x-suluk-ratelimit` facet). emitV4 stamps it onto the operation + synthesizes a
61
+ * 429 response; @suluk/hono's enforceRateLimit middleware ENFORCES it on the wire. Advisory vendor extension.
62
+ */
63
+ rateLimit?: SulukRateLimit;
54
64
  request?: RouteRequest;
55
65
  /** Responses, as a list (each carries its own status) or a status-keyed map. */
56
66
  responses?: RouteResponse[] | Record<string, RouteResponse>;
package/src/emit.ts CHANGED
@@ -4,9 +4,10 @@
4
4
  * NOT a static file: the document is a pure function of the contracts × the requesting principal (scopes,
5
5
  * the "who") × time (now, the "when"). A public export is just emitV4(routes) with no principal/now.
6
6
  */
7
- import { buildAda } from "@suluk/core";
7
+ import { buildAda, PROBLEM_CONTENT_TYPE, PROBLEM_DETAILS_SCHEMA } from "@suluk/core";
8
8
  import type {
9
9
  OpenAPIv4Document, PathItem, Request, Response, ParameterSchema, SecurityRequirement, Server, Info, SecurityScheme,
10
+ Components, Schema,
10
11
  } from "@suluk/core";
11
12
  import { zodToV4 } from "@suluk/zod";
12
13
  import { responseList, type RouteContract, type Method } from "./contract";
@@ -24,6 +25,31 @@ export interface EmitContext {
24
25
  securitySchemes?: Record<string, SecurityScheme>;
25
26
  /** Include operations flagged deprecated (default true; they are marked, not hidden). */
26
27
  includeDeprecated?: boolean;
28
+ /**
29
+ * Synthesize RFC-9457 error responses (401/403 from access, 429 from a rate-limit facet, always-500, plus any
30
+ * `route.errors`) + a shared `components.schemas.ProblemDetails`. Default true — the SDK's `isApiError` guard and
31
+ * testgen's error-conformance need declared non-2xx responses to check. Set false for a success-only projection.
32
+ */
33
+ synthesizeErrors?: boolean;
34
+ }
35
+
36
+ /** A human title per synthesized error status (RFC-9457 `description`). */
37
+ const ERROR_DESCRIPTION: Readonly<Record<number, string>> = {
38
+ 400: "Bad request", 401: "Unauthorized", 402: "Payment required", 403: "Forbidden",
39
+ 404: "Not found", 409: "Conflict", 429: "Too many requests", 500: "Internal server error", 502: "Bad gateway",
40
+ };
41
+
42
+ /**
43
+ * Which error statuses an operation declares it can return: explicit `route.errors`, + 401/403 when the op is
44
+ * auth-gated (it can deny), + 429 when it declares a rate-limit budget, + always-500 (any handler can fail).
45
+ */
46
+ function errorStatusesFor(route: RouteContract, ctx: EmitContext): number[] {
47
+ if (ctx.synthesizeErrors === false) return [];
48
+ const set = new Set<number>(route.errors ?? []);
49
+ if ((route.scopes && route.scopes.length > 0) || route.security) { set.add(401); set.add(403); }
50
+ if (route.rateLimit) set.add(429);
51
+ set.add(500);
52
+ return [...set].sort((a, b) => a - b);
27
53
  }
28
54
 
29
55
  export interface EmitDiagnostic {
@@ -97,8 +123,22 @@ function buildRequest(route: RouteContract, deprecated: boolean, ctx: EmitContex
97
123
  responses[String(r.status)] = resp;
98
124
  }
99
125
  if (Object.keys(responses).length === 0) responses["200"] = { status: 200 };
126
+ // synthesize RFC-9457 error responses — but never clobber a user-declared one for the same status.
127
+ for (const status of errorStatusesFor(route, ctx)) {
128
+ const key = String(status);
129
+ if (responses[key]) continue;
130
+ responses[key] = {
131
+ status,
132
+ description: ERROR_DESCRIPTION[status] ?? "Error",
133
+ contentType: PROBLEM_CONTENT_TYPE,
134
+ contentSchema: { $ref: "#/components/schemas/ProblemDetails" },
135
+ };
136
+ }
100
137
  req.responses = responses;
101
138
 
139
+ // stamp the declared rate-limit facet so rateLimitIndex/coverage + the middleware can read it off the document.
140
+ if (route.rateLimit) req["x-suluk-ratelimit"] = route.rateLimit;
141
+
102
142
  // security: explicit wins; else synthesize from scopes if a scheme name is configured.
103
143
  const security: SecurityRequirement[] | undefined =
104
144
  route.security ?? (route.scopes && ctx.securityScheme ? [{ [ctx.securityScheme]: route.scopes }] : undefined);
@@ -153,7 +193,15 @@ export function emitV4(routes: readonly RouteContract[], ctx: EmitContext = {}):
153
193
  paths,
154
194
  };
155
195
  if (ctx.servers) document.servers = ctx.servers;
156
- if (ctx.securitySchemes) document.components = { securitySchemes: ctx.securitySchemes };
196
+
197
+ // components: securitySchemes (C014) + the shared ProblemDetails schema iff any op synthesized a problem+json response.
198
+ const usesProblem = Object.values(paths).some((pi) =>
199
+ Object.values(pi.requests).some((r) =>
200
+ Object.values(r.responses).some((resp) => resp.contentType === PROBLEM_CONTENT_TYPE)));
201
+ const components: Components = {};
202
+ if (ctx.securitySchemes) components.securitySchemes = ctx.securitySchemes;
203
+ if (usesProblem) components.schemas = { ProblemDetails: PROBLEM_DETAILS_SCHEMA as unknown as Schema };
204
+ if (Object.keys(components).length > 0) document.components = components;
157
205
 
158
206
  // static collision audit over the ADA (detect-and-tolerate; surfaced as diagnostics, never a gate).
159
207
  for (const c of buildAda(document).collisions) {
package/src/enforce.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * ENFORCEMENT — the reusable server-side authz primitive (saastarter-parity roadmap, Phase 0).
3
+ *
4
+ * Suluk's facets are ADVISORY; the server is the only authz boundary (C022 inv.3). For CRUD routes the
5
+ * derivation engine can scope rows, but CUSTOM operations are raw handlers — so without a shared primitive an
6
+ * `x-suluk-access` facet on a custom op is DECORATIVE: nothing makes the wire honor it. This module is that
7
+ * primitive, in two shapes:
8
+ *
9
+ * • `enforceAccess(cfg)` — ONE facet-driven middleware: for each request it resolves the operation, reads its
10
+ * declared `x-suluk-access`, and enforces it on the wire (anon → 401 on a non-public op; wrong scope → 403).
11
+ * Applied once, it makes EVERY operation's access facet load-bearing — the contract-first way to close the
12
+ * "facet is decorative on custom ops" gap. Row-level `scope: "owner"` stays the app's concern (the CRUD
13
+ * factory filters rows); this enforces the requires-LEVEL (anyone < authenticated < admin) + named scopes.
14
+ * • `createGuard(cfg)` — explicit per-route middlewares (`requireAuth` / `requireAdmin` / `requireScopes(...)`)
15
+ * for apps that want to gate specific routes by hand, or that have no ADA to match against.
16
+ *
17
+ * Identity is INJECTED (the app knows its own principal/scope model — Better Auth session, API token, etc.), so
18
+ * the primitive is generic over any Suluk app. It never trusts a header it didn't verify; the app's `principal`
19
+ * / `isAdmin` callbacks are responsible for verification.
20
+ */
21
+ import { PROBLEM_CONTENT_TYPE, toProblemDetails } from "@suluk/core";
22
+ import type { Context, MiddlewareHandler } from "hono";
23
+
24
+ export type AccessRequires = "anyone" | "authenticated" | "admin";
25
+ export interface AccessFacet { requires?: AccessRequires | string; scope?: string }
26
+
27
+ /** Read identity from a request — the app supplies these (it owns its principal/scope model). */
28
+ export interface IdentityConfig {
29
+ /** the caller's verified principal id, or null/undefined for anonymous. */
30
+ principal: (c: Context) => string | null | undefined;
31
+ /** fast-path admin check (verified). If omitted, the literal "admin" scope is used. */
32
+ isAdmin?: (c: Context) => boolean;
33
+ /** the caller's granted scopes (e.g. ["admin"], ["org:1:read"]). Default: none. */
34
+ scopes?: (c: Context) => string[] | undefined;
35
+ }
36
+
37
+ /** A deny response — the shared RFC-9457 Problem Details envelope (@suluk/core), so deny + the error model agree. */
38
+ function deny(c: Context, status: 401 | 403, scope?: string): Response {
39
+ const body = status === 401
40
+ ? toProblemDetails({ tag: "UnauthorizedError", detail: "authentication required" })
41
+ : toProblemDetails({ tag: "ForbiddenError", detail: scope ? `requires scope: ${scope}` : "insufficient permissions" });
42
+ return c.json(body, status, { "content-type": PROBLEM_CONTENT_TYPE });
43
+ }
44
+
45
+ function hasScope(cfg: IdentityConfig, c: Context, scope: string): boolean {
46
+ if (scope === "admin" && cfg.isAdmin) return cfg.isAdmin(c);
47
+ return (cfg.scopes?.(c) ?? []).includes(scope);
48
+ }
49
+
50
+ export interface EnforceAccessConfig extends IdentityConfig {
51
+ /** the operation name for this request, or undefined for non-contract paths (static/auth/docs → allowed). */
52
+ operationOf: (c: Context) => string | undefined;
53
+ /** the declared access facet for an operation (e.g. from the document's x-suluk-access). */
54
+ accessOf: (operation: string) => AccessFacet | undefined;
55
+ /**
56
+ * what an operation that declares NO access facet requires. Defaults to "authenticated" — DENY BY DEFAULT, so a
57
+ * dropped/missing facet is a 401 in tests, NEVER a silent public route (a fail-open default is how an annotation
58
+ * gap becomes a live breach). Mark genuinely-public ops explicitly `requires:"anyone"`.
59
+ */
60
+ defaultRequires?: AccessRequires;
61
+ }
62
+
63
+ /** Normalize a wire-supplied `requires` to the CLOSED enum; an unknown/typo'd value is `null` → fail closed. */
64
+ function normalizeRequires(raw: string | undefined, fallback: AccessRequires): AccessRequires | null {
65
+ const v = (raw ?? fallback).toLowerCase().trim();
66
+ return v === "anyone" || v === "authenticated" || v === "admin" ? v : null;
67
+ }
68
+
69
+ /**
70
+ * The facet-driven gate. Apply once (after identity is resolved, before the handlers): every operation is then
71
+ * enforced at the level its `x-suluk-access` declares. FAIL-CLOSED throughout — a missing facet denies (deny-by-
72
+ * default), an unknown/mis-cased `requires` denies, and a non-owner `scope` is enforced even when `requires` is
73
+ * "anyone" (a named scope implies authentication). Non-contract paths (operationOf → undefined) pass untouched;
74
+ * a consumer's operationOf MUST be at least as strict as the router and MUST fail closed if it can't resolve.
75
+ */
76
+ export function enforceAccess(cfg: EnforceAccessConfig): MiddlewareHandler {
77
+ const fallback = cfg.defaultRequires ?? "authenticated"; // deny by default
78
+ return async (c, next) => {
79
+ const op = cfg.operationOf(c);
80
+ if (!op) return next(); // not a contract operation (static asset, /api/auth, docs) — out of scope
81
+ const facet = cfg.accessOf(op);
82
+ const requires = normalizeRequires(facet?.requires, fallback);
83
+ if (requires === null) return deny(c, 403); // unknown/typo'd level — fail closed, never degrade to authenticated
84
+ // a named (non-owner) scope is a real requirement; row-level "owner" is the app's CRUD concern, not enforced here
85
+ const namedScope = facet?.scope && facet.scope !== "owner" ? facet.scope : undefined;
86
+ if (requires === "anyone" && !namedScope) return next(); // truly public
87
+ if (!cfg.principal(c)) return deny(c, 401); // authenticated / admin / a named scope all need a caller
88
+ if (requires === "admin" && !hasScope(cfg, c, "admin")) return deny(c, 403, "admin");
89
+ if (namedScope && !hasScope(cfg, c, namedScope)) return deny(c, 403, namedScope);
90
+ return next();
91
+ };
92
+ }
93
+
94
+ export interface Guard {
95
+ /** 401 unless a verified principal is present. */
96
+ requireAuth: MiddlewareHandler;
97
+ /** 401 if anonymous, else 403 unless the caller is admin. */
98
+ requireAdmin: MiddlewareHandler;
99
+ /** 401 if anonymous, else 403 unless the caller holds EVERY named scope. */
100
+ requireScopes: (...need: string[]) => MiddlewareHandler;
101
+ }
102
+
103
+ /** Build explicit, hand-applied guards bound to one identity model (for fine-grained per-route gating). */
104
+ export function createGuard(cfg: IdentityConfig): Guard {
105
+ const requireAuth: MiddlewareHandler = async (c, next) => (cfg.principal(c) ? next() : deny(c, 401));
106
+ const requireScopes = (...need: string[]): MiddlewareHandler => async (c, next) => {
107
+ if (!cfg.principal(c)) return deny(c, 401);
108
+ for (const s of need) if (!hasScope(cfg, c, s)) return deny(c, 403, s);
109
+ return next();
110
+ };
111
+ return { requireAuth, requireScopes, requireAdmin: requireScopes("admin") };
112
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The error model (saastarter-parity Phase 0) — the portable value extracted from saastarter's dropped Effect
3
+ * route-handler (src/lib/effect/route-handler.ts): a closed set of typed throws, each mapped to an HTTP status +
4
+ * an RFC-9457 Problem Details body. In plain Hono the idiom is throw-a-typed-error → an `onError` handler maps it
5
+ * to the wire (see on-error.ts). The status/title/code mapping is the SHARED core table (@suluk/core errors.ts),
6
+ * so the SDK's `isApiError`, testgen's error-conformance, and enforce.ts's deny body all agree on one envelope.
7
+ */
8
+ import {
9
+ PROBLEM_STATUS_TABLE, TITLE_BY_TAG, toProblemDetails, retryAfterSeconds,
10
+ type ErrorTag, type ProblemDetails, type ProblemStatus,
11
+ } from "@suluk/core";
12
+
13
+ export interface SulukHttpErrorInit {
14
+ /** the human-readable explanation (RFC-9457 `detail`). */
15
+ detail?: string;
16
+ /** a URI reference identifying the specific occurrence (RFC-9457 `instance`). */
17
+ instance?: string;
18
+ /** structured validation errors (saastarter's `details`). */
19
+ errors?: Record<string, unknown>;
20
+ /** override the `type` URI (default "about:blank"). */
21
+ type?: string;
22
+ /** RateLimitedError: ms until the window resets — drives the Retry-After header (route-handler.ts:75). */
23
+ retryAfterMs?: number;
24
+ /** server-only diagnostic context (cause/service/op) — LOGGED by onError, never sent on the wire. */
25
+ logContext?: unknown;
26
+ }
27
+
28
+ /**
29
+ * A typed, throwable HTTP error. `tag` selects the status + title from the frozen core tables; the instance
30
+ * renders to a Problem Details body via {@link toProblem}. Throw one from a handler; `onError()` maps it.
31
+ */
32
+ export class SulukHttpError extends Error {
33
+ readonly tag: ErrorTag;
34
+ readonly instance?: string;
35
+ readonly errors?: Record<string, unknown>;
36
+ readonly problemType?: string;
37
+ readonly retryAfterMs?: number;
38
+ readonly logContext?: unknown;
39
+
40
+ constructor(tag: ErrorTag, init: SulukHttpErrorInit = {}) {
41
+ super(init.detail ?? TITLE_BY_TAG[tag]);
42
+ this.name = tag;
43
+ this.tag = tag;
44
+ if (init.detail !== undefined) this.detail = init.detail;
45
+ this.instance = init.instance;
46
+ this.errors = init.errors;
47
+ this.problemType = init.type;
48
+ this.retryAfterMs = init.retryAfterMs;
49
+ this.logContext = init.logContext;
50
+ }
51
+
52
+ /** the human `detail` (distinct from Error.message, which mirrors it for stack-trace readability). */
53
+ readonly detail?: string;
54
+
55
+ /** the HTTP status this error renders as (the frozen core mapping). */
56
+ get status(): ProblemStatus {
57
+ return PROBLEM_STATUS_TABLE[this.tag];
58
+ }
59
+
60
+ /** seconds for the Retry-After header (RateLimitedError only) — `ceil(retryAfterMs/1000)`, else undefined. */
61
+ get retryAfterSeconds(): number | undefined {
62
+ return this.retryAfterMs == null ? undefined : retryAfterSeconds({ windowMs: this.retryAfterMs });
63
+ }
64
+
65
+ /** render to the canonical RFC-9457 Problem Details body. */
66
+ toProblem(): ProblemDetails {
67
+ return toProblemDetails({
68
+ tag: this.tag,
69
+ detail: this.detail,
70
+ instance: this.instance,
71
+ errors: this.errors,
72
+ type: this.problemType,
73
+ });
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Factory helpers mirroring saastarter's TaggedError set (errors.ts) with the SAME field semantics the route-handler
79
+ * rendered (route-handler.ts:24-86). `externalService`/`internal` keep their detail GENERIC on the wire and stash
80
+ * the cause in `logContext` (route-handler.ts:63,81 log it server-side, never leak it).
81
+ */
82
+ export const HttpErrors = {
83
+ /** 401 (route-handler.ts:26-30). */
84
+ unauthorized: (detail?: string) => new SulukHttpError("UnauthorizedError", { detail }),
85
+ /** 403 (route-handler.ts:32-36); `resource` becomes the instance. */
86
+ forbidden: (detail?: string, resource?: string) =>
87
+ new SulukHttpError("ForbiddenError", { detail, instance: resource }),
88
+ /** 401 (route-handler.ts:38-39); the key reason is the detail. */
89
+ invalidApiKey: (reason: string) => new SulukHttpError("InvalidApiKeyError", { detail: reason }),
90
+ /** 400 (route-handler.ts:41-45); `details` → `errors`. */
91
+ validation: (message: string, details?: Record<string, unknown>) =>
92
+ new SulukHttpError("ValidationError", { detail: message, errors: details }),
93
+ /** 404 (route-handler.ts:47-51); detail is `${resource} not found`, id → instance. */
94
+ notFound: (resource: string, id?: string) =>
95
+ new SulukHttpError("NotFoundError", { detail: `${resource} not found`, instance: id ? `${resource}/${id}` : undefined }),
96
+ /** 409 (route-handler.ts:53-54). */
97
+ conflict: (message: string) => new SulukHttpError("ConflictError", { detail: message }),
98
+ /** 402 (route-handler.ts:56-57); optional Stripe-style `code` → errors. */
99
+ payment: (message: string, code?: string) =>
100
+ new SulukHttpError("PaymentError", { detail: message, errors: code ? { code } : undefined }),
101
+ /** 400 (route-handler.ts:59-60); the discount code → errors, reason → detail. */
102
+ invalidDiscount: (code: string, reason: string) =>
103
+ new SulukHttpError("InvalidDiscountError", { detail: reason, errors: { code } }),
104
+ /** 502 (route-handler.ts:62-67); GENERIC wire detail, cause logged only. */
105
+ externalService: (service: string, operation: string, cause?: unknown) =>
106
+ new SulukHttpError("ExternalServiceError", { logContext: { service, operation, cause } }),
107
+ /** 429 (route-handler.ts:69-78); retryAfterMs drives the Retry-After header. */
108
+ rateLimited: (retryAfterMs: number) =>
109
+ new SulukHttpError("RateLimitedError", { retryAfterMs }),
110
+ /** 500 (route-handler.ts:80-85); GENERIC wire detail, cause logged only. */
111
+ internal: (message?: string, cause?: unknown) =>
112
+ new SulukHttpError("PayloadOperationError", { logContext: message || cause ? { message, cause } : undefined }),
113
+ };
package/src/index.ts CHANGED
@@ -9,3 +9,12 @@ export { audit, coverage, autofill, type Finding } from "./audit";
9
9
  export { contractChecks, runContractChecks, type Check, type CheckRun } from "./checks";
10
10
  export { validateSchema2020, type SchemaCheck } from "./schema-check";
11
11
  export { mount } from "./mount";
12
+ export { enforceAccess, createGuard, type EnforceAccessConfig, type IdentityConfig, type Guard, type AccessFacet, type AccessRequires } from "./enforce";
13
+ // the row-level CRUD authorization engine (mode→policy→rule→decision + owner-scoping) that pairs with enforceAccess.
14
+ export { gate, policyFor, ruleToRequires, DEFAULT_POLICIES, type Rule, type Policy, type AccessMode, type GateIdentity, type GateDecision } from "./access";
15
+ export { SulukHttpError, HttpErrors, type SulukHttpErrorInit } from "./errors";
16
+ export { onError, type OnErrorOptions } from "./on-error";
17
+ export {
18
+ enforceRateLimit, MemoryRateLimitStore,
19
+ type EnforceRateLimitConfig, type RateLimitStore, type RateLimitResult, type RateLimitConsumeOptions,
20
+ } from "./ratelimit";
@@ -0,0 +1,34 @@
1
+ /**
2
+ * onError — the Hono error handler that bridges a thrown typed error to the wire as RFC-9457 Problem Details.
3
+ * The plain-Hono counterpart of saastarter's `handleRoute` catchAll/catchAllDefect (route-handler.ts:109-125):
4
+ * a {@link SulukHttpError} maps to its declared status + body; ANY other throw is a defect → 500 internal (the
5
+ * cause logged, never leaked). Wire it once with `app.onError(onError())`.
6
+ */
7
+ import { PROBLEM_CONTENT_TYPE, toProblemDetails } from "@suluk/core";
8
+ import type { Context } from "hono";
9
+ import { SulukHttpError } from "./errors";
10
+
11
+ export interface OnErrorOptions {
12
+ /** sink for server-only diagnostics (defaults to console.error). Receives (message, context). */
13
+ log?: (message: string, context: unknown) => void;
14
+ }
15
+
16
+ type ErrorHandler = (err: Error, c: Context) => Response;
17
+
18
+ /** Build the Hono error handler. Every response carries `content-type: application/problem+json`. */
19
+ export function onError(opts: OnErrorOptions = {}): ErrorHandler {
20
+ const log = opts.log ?? ((m, ctx) => console.error(m, ctx));
21
+ return (err, c) => {
22
+ if (err instanceof SulukHttpError) {
23
+ if (err.logContext !== undefined) log(`${err.tag}:`, err.logContext); // external/internal causes (route-handler.ts:63,81)
24
+ const headers: Record<string, string> = { "content-type": PROBLEM_CONTENT_TYPE };
25
+ const retry = err.retryAfterSeconds;
26
+ if (retry !== undefined) headers["retry-after"] = String(retry); // route-handler.ts:74-76
27
+ return c.json(err.toProblem(), err.status, headers);
28
+ }
29
+ // an untyped throw is a defect — never leak it (route-handler.ts:117-122).
30
+ log("Unhandled error in handler:", err);
31
+ const body = toProblemDetails({ tag: "PayloadOperationError" });
32
+ return c.json(body, body.status, { "content-type": PROBLEM_CONTENT_TYPE });
33
+ };
34
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Rate-limit middleware (saastarter-parity Phase 0) — the facet-driven counterpart of enforceAccess: for each
3
+ * request it resolves the operation, reads its declared `x-suluk-ratelimit` budget, derives a key, consults a
4
+ * SWAPPABLE store, and emits 429 + Retry-After (the shared RFC-9457 envelope) when the fixed window is exceeded.
5
+ *
6
+ * Ports saastarter's checkRateLimit fixed-window algorithm (src/lib/effect/rate-limit.ts:16-38) into a Hono
7
+ * middleware. Three deliberate shapes:
8
+ * • The durable counter is a SWAPPABLE BINDING (the {@link RateLimitStore} interface) — `MemoryRateLimitStore`
9
+ * is a DEV default only; the production KV / Durable-Object store lives in @suluk/deploy (roadmap Open-Decision
10
+ * #4, KV vs DO). The package never hosts a production runtime (the L3 line).
11
+ * • ONE clock owner: the middleware computes `now` (cfg.now ?? Date.now) and passes it into `store.consume`, so
12
+ * the store stays a pure function of its inputs and tests inject a deterministic clock.
13
+ * • Default-UNLIMITED, opt-in (NOT enforceAccess's deny-by-default): an op without a budget is unmetered — the
14
+ * threat models differ (a missing access facet is a breach; a missing rate budget is just unmetered). An
15
+ * optional `defaultFacet` is the escape hatch for a blanket floor.
16
+ *
17
+ * DEVIATION from saastarter (receipted): saastarter callers prefix the key per-route (`payment-${ip}`); we key by
18
+ * `${operation}:${scope}:${baseKey}` so per-operation budgets never collide — necessary because each op declares
19
+ * its OWN window/max (a shared bare-IP counter with two different limits would be incoherent).
20
+ */
21
+ import { PROBLEM_CONTENT_TYPE, toProblemDetails, retryAfterSeconds, type SulukRateLimit } from "@suluk/core";
22
+ import type { Context, MiddlewareHandler } from "hono";
23
+
24
+ export interface RateLimitConsumeOptions {
25
+ maxRequests: number;
26
+ windowMs: number;
27
+ /** the current epoch-ms, supplied by the middleware (the single clock owner). */
28
+ now: number;
29
+ }
30
+
31
+ export interface RateLimitResult {
32
+ /** true ⇒ this request is OVER the budget and must be rejected. */
33
+ limited: boolean;
34
+ /** requests remaining in the window after this one (≥ 0). */
35
+ remaining: number;
36
+ /** ms until the window resets — drives Retry-After. 0 when not limited. */
37
+ retryAfterMs: number;
38
+ }
39
+
40
+ /**
41
+ * The swap point for a durable counter. `consume` atomically records one hit for `key` under the budget and
42
+ * reports whether it's now over. A production impl (KV / Durable Object) MUST be atomic-per-key; the in-memory
43
+ * default is per-instance and NOT durable, so it is dev-only.
44
+ */
45
+ export interface RateLimitStore {
46
+ consume(key: string, opts: RateLimitConsumeOptions): Promise<RateLimitResult> | RateLimitResult;
47
+ }
48
+
49
+ /**
50
+ * DEV-ONLY fixed-window store — a single in-process Map, ported from saastarter rate-limit.ts:7-38. Per-instance
51
+ * (does NOT coordinate across workers/isolates) so it must NOT back production; use a @suluk/deploy KV/DO binding
52
+ * there. Retry-After is the FULL `windowMs` (saastarter parity, rate-limit.ts:35); the precise `resetAt - now` is a
53
+ * documented alternative a durable store may choose instead.
54
+ */
55
+ export class MemoryRateLimitStore implements RateLimitStore {
56
+ private readonly store = new Map<string, { count: number; resetAt: number }>();
57
+
58
+ consume(key: string, { maxRequests, windowMs, now }: RateLimitConsumeOptions): RateLimitResult {
59
+ const entry = this.store.get(key);
60
+ if (!entry || now > entry.resetAt) {
61
+ this.store.set(key, { count: 1, resetAt: now + windowMs });
62
+ return { limited: false, remaining: Math.max(0, maxRequests - 1), retryAfterMs: 0 };
63
+ }
64
+ entry.count++;
65
+ const limited = entry.count > maxRequests;
66
+ return {
67
+ limited,
68
+ remaining: Math.max(0, maxRequests - entry.count),
69
+ retryAfterMs: limited ? windowMs : 0, // saastarter parity (rate-limit.ts:35); precise alt: entry.resetAt - now
70
+ };
71
+ }
72
+ }
73
+
74
+ export interface EnforceRateLimitConfig {
75
+ /** Resolve the contract operation for a request (undefined ⇒ a non-contract path, passed through). */
76
+ operationOf: (c: Context) => string | undefined;
77
+ /** The declared rate budget for an operation (e.g. read off the document's `x-suluk-ratelimit`). */
78
+ rateLimitOf: (operation: string) => SulukRateLimit | undefined;
79
+ /** The durable counter (default: a per-instance {@link MemoryRateLimitStore} — DEV ONLY). */
80
+ store?: RateLimitStore;
81
+ /** Derive the caller key from a request + facet (default: client IP from x-forwarded-for / x-real-ip). */
82
+ keyOf?: (c: Context, facet: SulukRateLimit) => string;
83
+ /** The clock (default: `Date.now`) — the single source of `now`. */
84
+ now?: () => number;
85
+ /** A blanket budget applied to operations that declare none (escape hatch; default: unmetered). */
86
+ defaultFacet?: SulukRateLimit;
87
+ }
88
+
89
+ /** Default key: the client IP. `"global"` shares one bucket; `"principal"`/`"api-key"` fall back to IP until the
90
+ * Principal model lands (roadmap Open-Decision #5) — a consumer that has a principal supplies its own `keyOf`. */
91
+ function defaultKeyOf(c: Context, facet: SulukRateLimit): string {
92
+ if (facet.key === "global") return "global";
93
+ const fwd = c.req.header("x-forwarded-for");
94
+ const first = fwd ? fwd.split(",")[0]?.trim() : undefined;
95
+ return first || c.req.header("x-real-ip") || "unknown";
96
+ }
97
+
98
+ /** A 429 response in the shared RFC-9457 envelope + a Retry-After header (agrees with the B1 error model). */
99
+ function denyRateLimited(c: Context, retryAfterMs: number): Response {
100
+ return c.json(toProblemDetails({ tag: "RateLimitedError" }), 429, {
101
+ "content-type": PROBLEM_CONTENT_TYPE,
102
+ "retry-after": String(retryAfterSeconds({ windowMs: retryAfterMs })),
103
+ });
104
+ }
105
+
106
+ /**
107
+ * The facet-driven rate-limit gate. Apply once (typically after identity, alongside enforceAccess): every operation
108
+ * that DECLARES an `x-suluk-ratelimit` budget is metered; the rest pass untouched. On overflow → 429 + Retry-After.
109
+ */
110
+ export function enforceRateLimit(cfg: EnforceRateLimitConfig): MiddlewareHandler {
111
+ const store = cfg.store ?? new MemoryRateLimitStore();
112
+ const clock = cfg.now ?? (() => Date.now());
113
+ const keyOf = cfg.keyOf ?? defaultKeyOf;
114
+ return async (c, next) => {
115
+ const op = cfg.operationOf(c);
116
+ if (!op) return next(); // not a contract operation
117
+ const facet = cfg.rateLimitOf(op) ?? cfg.defaultFacet;
118
+ if (!facet) return next(); // unmetered (opt-in)
119
+ const base = keyOf(c, facet);
120
+ const key = `${op}:${facet.scope ?? ""}:${base}`;
121
+ const res = await store.consume(key, { maxRequests: facet.maxRequests, windowMs: facet.windowMs, now: clock() });
122
+ if (res.limited) return denyRateLimited(c, res.retryAfterMs);
123
+ c.header("x-ratelimit-remaining", String(Math.max(0, res.remaining)));
124
+ return next();
125
+ };
126
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The row-level CRUD authorization engine — gate() decisions (fail-closed 401/403 + owner-scoping), policyFor
3
+ * preset resolution + override, and ruleToRequires (the rule→wire-requires projection that keeps the CRUD gate and
4
+ * the x-suluk-access facet in lockstep).
5
+ */
6
+ import { test, expect, describe } from "bun:test";
7
+ import { gate, policyFor, ruleToRequires, DEFAULT_POLICIES, type Rule } from "../src/index";
8
+
9
+ describe("gate", () => {
10
+ test("any → open, no scope", () => {
11
+ expect(gate("any", { isAdmin: false, principal: null })).toEqual({ ok: true, scopeOwner: false });
12
+ });
13
+ test("owner: anon → 401, signed-in → scoped, admin → all", () => {
14
+ expect(gate("owner", { isAdmin: false, principal: null })).toEqual({ ok: false, scopeOwner: false, status: 401 });
15
+ expect(gate("owner", { isAdmin: false, principal: "u1" })).toEqual({ ok: true, scopeOwner: true });
16
+ expect(gate("owner", { isAdmin: true, principal: "u1" })).toEqual({ ok: true, scopeOwner: false }); // admin sees all
17
+ });
18
+ test("admin: anon → 401 (authenticate first), signed-in-non-admin → 403, admin → ok", () => {
19
+ expect(gate("admin", { isAdmin: false, principal: null })).toEqual({ ok: false, scopeOwner: false, status: 401 });
20
+ expect(gate("admin", { isAdmin: false, principal: "u1" })).toEqual({ ok: false, scopeOwner: false, status: 403 });
21
+ expect(gate("admin", { isAdmin: true, principal: "u1" })).toEqual({ ok: true, scopeOwner: false });
22
+ });
23
+ test("none → hard 403", () => {
24
+ expect(gate("none", { isAdmin: true, principal: "u1" })).toEqual({ ok: false, scopeOwner: false, status: 403 });
25
+ });
26
+ });
27
+
28
+ describe("policyFor", () => {
29
+ test("resolves modes, defaults owned-with-ownerCol / public-without, honors an override matrix", () => {
30
+ expect(policyFor("owned").update).toBe("owner");
31
+ expect(policyFor("ownedAppend").update).toBe("admin"); // no self-mutate
32
+ expect(policyFor(undefined, "customerId")).toEqual(DEFAULT_POLICIES.owned);
33
+ expect(policyFor(undefined)).toEqual(DEFAULT_POLICIES.public);
34
+ const custom = { ...DEFAULT_POLICIES, public: { ...DEFAULT_POLICIES.public, list: "admin" as Rule } };
35
+ expect(policyFor("public", undefined, custom).list).toBe("admin");
36
+ });
37
+ });
38
+
39
+ describe("ruleToRequires", () => {
40
+ test("projects a CRUD rule to the wire requires level", () => {
41
+ expect(ruleToRequires("any")).toBe("anyone");
42
+ expect(ruleToRequires("owner")).toBe("authenticated");
43
+ expect(ruleToRequires("admin")).toBe("admin");
44
+ expect(ruleToRequires("none")).toBe("admin");
45
+ });
46
+ });
@@ -0,0 +1,121 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { Hono } from "hono";
3
+ import { enforceAccess, createGuard, type AccessFacet } from "../src/index";
4
+
5
+ // a tiny app identity model: the x-user header is the "principal", x-admin:1 marks admin, x-scopes is csv.
6
+ const ID = {
7
+ principal: (c: any) => c.req.header("x-user") || null,
8
+ isAdmin: (c: any) => c.req.header("x-admin") === "1",
9
+ scopes: (c: any) => (c.req.header("x-scopes") || "").split(",").filter(Boolean),
10
+ };
11
+
12
+ describe("@suluk/hono enforceAccess — facet-driven wire enforcement (the server is the boundary)", () => {
13
+ // a fake contract: op name → access facet + the path it lives at
14
+ const OPS: Record<string, { path: string; access?: AccessFacet }> = {
15
+ listPet: { path: "/pet", access: { requires: "anyone" } },
16
+ createPet: { path: "/pet-create", access: { requires: "admin" } },
17
+ myCart: { path: "/cart", access: { requires: "authenticated", scope: "owner" } },
18
+ orgReport: { path: "/report", access: { requires: "authenticated", scope: "org:read" } },
19
+ undeclared: { path: "/undeclared" }, // no facet → DENY by default
20
+ scopedPublic: { path: "/scoped-public", access: { requires: "anyone", scope: "admin" } }, // a named scope despite anyone
21
+ miscased: { path: "/miscased", access: { requires: "Admin" } }, // a casing typo must still gate as admin
22
+ unknownLevel: { path: "/unknown", access: { requires: "superuser" } }, // an unknown level must fail closed
23
+ };
24
+ const byPath = Object.fromEntries(Object.entries(OPS).map(([name, o]) => [o.path, name]));
25
+
26
+ const app = new Hono();
27
+ app.use("*", enforceAccess({
28
+ operationOf: (c) => byPath[new URL(c.req.url).pathname],
29
+ accessOf: (op) => OPS[op]?.access,
30
+ ...ID,
31
+ }));
32
+ for (const [name, o] of Object.entries(OPS)) app.get(o.path, (c) => c.json({ ok: name }));
33
+ app.get("/static.css", (c) => c.text("body{}")); // a non-contract path
34
+
35
+ const get = (path: string, h: Record<string, string> = {}) => app.request(path, { headers: h });
36
+
37
+ test("public op (requires: anyone) is reachable by anon", async () => {
38
+ expect((await get("/pet")).status).toBe(200);
39
+ });
40
+
41
+ test("admin op: anon → 401, signed-in non-admin → 403, admin → 200", async () => {
42
+ expect((await get("/pet-create")).status).toBe(401);
43
+ expect((await get("/pet-create", { "x-user": "u1" })).status).toBe(403);
44
+ expect((await get("/pet-create", { "x-user": "u1", "x-admin": "1" })).status).toBe(200);
45
+ });
46
+
47
+ test("authenticated op (owner-scoped): anon → 401, any signed-in caller → 200 (row-scoping is the app's job)", async () => {
48
+ expect((await get("/cart")).status).toBe(401);
49
+ expect((await get("/cart", { "x-user": "alice" })).status).toBe(200); // owner scope NOT enforced here
50
+ });
51
+
52
+ test("named-scope op: signed-in WITHOUT the scope → 403, WITH it → 200", async () => {
53
+ expect((await get("/report", { "x-user": "u1" })).status).toBe(403);
54
+ expect((await get("/report", { "x-user": "u1", "x-scopes": "org:read" })).status).toBe(200);
55
+ });
56
+
57
+ test("FAIL-CLOSED: an undeclared op denies by default (anon → 401), not opens publicly", async () => {
58
+ expect((await get("/undeclared")).status).toBe(401); // deny-by-default (was the fail-open hole)
59
+ expect((await get("/undeclared", { "x-user": "u" })).status).toBe(200); // a signed-in caller passes the default
60
+ });
61
+
62
+ test("FAIL-CLOSED: a named scope is enforced even when requires is \"anyone\" (no silent scope-drop)", async () => {
63
+ expect((await get("/scoped-public")).status).toBe(401); // anon can't reach a scope-gated op
64
+ expect((await get("/scoped-public", { "x-user": "u" })).status).toBe(403); // signed-in but no admin scope
65
+ expect((await get("/scoped-public", { "x-user": "u", "x-admin": "1" })).status).toBe(200);
66
+ });
67
+
68
+ test("FAIL-CLOSED: a mis-cased requires (\"Admin\") still gates as admin (not degraded to authenticated)", async () => {
69
+ expect((await get("/miscased", { "x-user": "u" })).status).toBe(403);
70
+ expect((await get("/miscased", { "x-user": "u", "x-admin": "1" })).status).toBe(200);
71
+ });
72
+
73
+ test("FAIL-CLOSED: an UNKNOWN requires level denies everyone (a typo can't open a route)", async () => {
74
+ expect((await get("/unknown")).status).toBe(403);
75
+ expect((await get("/unknown", { "x-user": "u" })).status).toBe(403);
76
+ expect((await get("/unknown", { "x-user": "u", "x-admin": "1" })).status).toBe(403); // even admin — a typo is fixed, not bypassed
77
+ });
78
+
79
+ test("a non-contract path (no matched op) is passed straight through", async () => {
80
+ expect((await get("/static.css")).status).toBe(200);
81
+ });
82
+
83
+ test("deny responses are RFC-9457-shaped problem+json", async () => {
84
+ const r = await get("/pet-create");
85
+ expect(r.headers.get("content-type")).toContain("application/problem+json");
86
+ const body = await r.json();
87
+ expect(body).toMatchObject({ error: "unauthorized", status: 401 });
88
+ });
89
+
90
+ test("admin can be expressed via a scope when no isAdmin callback is given", async () => {
91
+ const app2 = new Hono();
92
+ app2.use("*", enforceAccess({ operationOf: () => "x", accessOf: () => ({ requires: "admin" }), principal: (c: any) => c.req.header("x-user") || null, scopes: (c: any) => (c.req.header("x-scopes") || "").split(",").filter(Boolean) }));
93
+ app2.get("/x", (c) => c.json({ ok: true }));
94
+ expect((await app2.request("/x", { headers: { "x-user": "u" } })).status).toBe(403);
95
+ expect((await app2.request("/x", { headers: { "x-user": "u", "x-scopes": "admin" } })).status).toBe(200);
96
+ });
97
+ });
98
+
99
+ describe("@suluk/hono createGuard — explicit per-route guards", () => {
100
+ const guard = createGuard(ID);
101
+ const app = new Hono();
102
+ app.get("/auth", guard.requireAuth, (c) => c.json({ ok: true }));
103
+ app.get("/admin", guard.requireAdmin, (c) => c.json({ ok: true }));
104
+ app.get("/write", guard.requireScopes("write:pets", "read:pets"), (c) => c.json({ ok: true }));
105
+ const get = (path: string, h: Record<string, string> = {}) => app.request(path, { headers: h });
106
+
107
+ test("requireAuth: 401 anon, 200 signed-in", async () => {
108
+ expect((await get("/auth")).status).toBe(401);
109
+ expect((await get("/auth", { "x-user": "u" })).status).toBe(200);
110
+ });
111
+ test("requireAdmin: 401 anon, 403 non-admin, 200 admin", async () => {
112
+ expect((await get("/admin")).status).toBe(401);
113
+ expect((await get("/admin", { "x-user": "u" })).status).toBe(403);
114
+ expect((await get("/admin", { "x-user": "u", "x-admin": "1" })).status).toBe(200);
115
+ });
116
+ test("requireScopes needs EVERY scope (401 anon, 403 partial, 200 all)", async () => {
117
+ expect((await get("/write")).status).toBe(401);
118
+ expect((await get("/write", { "x-user": "u", "x-scopes": "write:pets" })).status).toBe(403); // missing read:pets
119
+ expect((await get("/write", { "x-user": "u", "x-scopes": "write:pets,read:pets" })).status).toBe(200);
120
+ });
121
+ });
@@ -0,0 +1,121 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { Hono } from "hono";
3
+ import { HttpErrors } from "../src/errors";
4
+ import { onError as onErrorHandler } from "../src/on-error";
5
+ import { emitV4 } from "../src/emit";
6
+ import { isProblemDetails } from "@suluk/core";
7
+
8
+ describe("@suluk/hono error model — typed throws → RFC-9457 (ported from saastarter route-handler.ts)", () => {
9
+ test("each factory maps to its saastarter status", () => {
10
+ expect(HttpErrors.unauthorized().status).toBe(401);
11
+ expect(HttpErrors.forbidden().status).toBe(403);
12
+ expect(HttpErrors.invalidApiKey("bad").status).toBe(401);
13
+ expect(HttpErrors.validation("nope").status).toBe(400);
14
+ expect(HttpErrors.notFound("Pet", "7").status).toBe(404);
15
+ expect(HttpErrors.conflict("dup").status).toBe(409);
16
+ expect(HttpErrors.payment("declined").status).toBe(402);
17
+ expect(HttpErrors.invalidDiscount("SAVE", "expired").status).toBe(400);
18
+ expect(HttpErrors.externalService("stripe", "charge").status).toBe(502);
19
+ expect(HttpErrors.rateLimited(60_000).status).toBe(429);
20
+ expect(HttpErrors.internal().status).toBe(500);
21
+ });
22
+
23
+ test("toProblem renders a valid Problem Details body with ported field semantics", () => {
24
+ const nf = HttpErrors.notFound("Pet", "7").toProblem();
25
+ expect(isProblemDetails(nf)).toBe(true);
26
+ expect(nf).toMatchObject({ status: 404, title: "Not found", detail: "Pet not found", instance: "Pet/7", error: "not_found" });
27
+
28
+ const val = HttpErrors.validation("bad body", { name: "required" }).toProblem();
29
+ expect(val).toMatchObject({ status: 400, detail: "bad body", errors: { name: "required" } });
30
+ });
31
+
32
+ test("externalService/internal keep the wire detail GENERIC and stash the cause for server logs only", () => {
33
+ const ext = HttpErrors.externalService("stripe", "charge", new Error("boom"));
34
+ expect(ext.toProblem().detail).toBeUndefined(); // not leaked
35
+ expect(ext.toProblem().title).toBe("External service unavailable");
36
+ expect(ext.logContext).toMatchObject({ service: "stripe", operation: "charge" });
37
+ });
38
+
39
+ test("retryAfterSeconds is ceil(retryAfterMs/1000), only for RateLimitedError", () => {
40
+ expect(HttpErrors.rateLimited(60_000).retryAfterSeconds).toBe(60);
41
+ expect(HttpErrors.rateLimited(1500).retryAfterSeconds).toBe(2);
42
+ expect(HttpErrors.notFound("X").retryAfterSeconds).toBeUndefined();
43
+ });
44
+ });
45
+
46
+ describe("onError handler — bridges a thrown typed error to the wire", () => {
47
+ function appThrowing(err: unknown) {
48
+ const logs: unknown[] = [];
49
+ const app = new Hono();
50
+ app.onError(onErrorHandler({ log: (m, ctx) => logs.push([m, ctx]) }));
51
+ app.get("/boom", () => { throw err; });
52
+ return { app, logs };
53
+ }
54
+
55
+ test("a SulukHttpError → its status + application/problem+json body", async () => {
56
+ const { app } = appThrowing(HttpErrors.forbidden("nope"));
57
+ const r = await app.request("/boom");
58
+ expect(r.status).toBe(403);
59
+ expect(r.headers.get("content-type")).toContain("application/problem+json");
60
+ const body = await r.json();
61
+ expect(body).toMatchObject({ status: 403, title: "Forbidden", error: "forbidden", detail: "nope" });
62
+ });
63
+
64
+ test("a 429 carries a Retry-After header (seconds)", async () => {
65
+ const { app } = appThrowing(HttpErrors.rateLimited(60_000));
66
+ const r = await app.request("/boom");
67
+ expect(r.status).toBe(429);
68
+ expect(r.headers.get("retry-after")).toBe("60");
69
+ });
70
+
71
+ test("an untyped throw is a defect → 500, cause logged, never leaked", async () => {
72
+ const { app, logs } = appThrowing(new Error("secret stack detail"));
73
+ const r = await app.request("/boom");
74
+ expect(r.status).toBe(500);
75
+ const body = await r.json();
76
+ expect(body.title).toBe("Internal server error");
77
+ expect(JSON.stringify(body)).not.toContain("secret stack detail");
78
+ expect(logs.length).toBe(1); // logged server-side
79
+ });
80
+ });
81
+
82
+ describe("emitV4 — synthesizes RFC-9457 error responses + a shared ProblemDetails schema", () => {
83
+ test("an auth-gated op gets 401/403/500 problem+json responses + components.schemas.ProblemDetails", () => {
84
+ const { document } = emitV4([
85
+ { method: "get", path: "/admin/report", name: "getReport", scopes: ["admin"] },
86
+ ], { securityScheme: "bearerAuth" });
87
+ const resps = document.paths["admin/report"].requests.getReport.responses;
88
+ expect(Object.keys(resps).sort()).toEqual(["200", "401", "403", "500"]);
89
+ expect(resps["401"].contentType).toBe("application/problem+json");
90
+ expect(resps["401"].contentSchema).toEqual({ $ref: "#/components/schemas/ProblemDetails" });
91
+ expect(document.components?.schemas?.ProblemDetails).toBeDefined();
92
+ });
93
+
94
+ test("a public op gets only the always-500 error (no 401/403 without auth)", () => {
95
+ const { document } = emitV4([{ method: "get", path: "/health", name: "health" }]);
96
+ expect(Object.keys(document.paths.health.requests.health.responses).sort()).toEqual(["200", "500"]);
97
+ });
98
+
99
+ test("explicit route.errors + rateLimit synthesize 404 + 429; a user-declared response is never clobbered", () => {
100
+ const { document } = emitV4([
101
+ {
102
+ method: "post", path: "/orders", name: "createOrder",
103
+ errors: [404],
104
+ rateLimit: { windowMs: 60_000, maxRequests: 20, key: "ip" },
105
+ responses: [{ status: 201, description: "created" }],
106
+ },
107
+ ]);
108
+ const resps = document.paths.orders.requests.createOrder.responses;
109
+ expect(Object.keys(resps).sort()).toEqual(["201", "404", "429", "500"]);
110
+ expect(resps["201"].description).toBe("created"); // user's response preserved
111
+ expect(document.paths.orders.requests.createOrder["x-suluk-ratelimit"]).toMatchObject({ maxRequests: 20 });
112
+ });
113
+
114
+ test("synthesizeErrors:false yields a success-only projection (no error responses, no ProblemDetails schema)", () => {
115
+ const { document } = emitV4([
116
+ { method: "get", path: "/admin/report", name: "getReport", scopes: ["admin"] },
117
+ ], { synthesizeErrors: false });
118
+ expect(Object.keys(document.paths["admin/report"].requests.getReport.responses)).toEqual(["200"]);
119
+ expect(document.components?.schemas?.ProblemDetails).toBeUndefined();
120
+ });
121
+ });
@@ -0,0 +1,104 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { Hono } from "hono";
3
+ import { enforceRateLimit, MemoryRateLimitStore } from "../src/ratelimit";
4
+ import type { SulukRateLimit } from "@suluk/core";
5
+
6
+ describe("MemoryRateLimitStore — fixed window (ported from saastarter rate-limit.ts)", () => {
7
+ test("permits up to maxRequests, then limits; Retry-After is the full window (saastarter parity)", () => {
8
+ const s = new MemoryRateLimitStore();
9
+ const opts = { maxRequests: 2, windowMs: 60_000, now: 1_000 };
10
+ expect(s.consume("k", opts)).toMatchObject({ limited: false, remaining: 1 });
11
+ expect(s.consume("k", opts)).toMatchObject({ limited: false, remaining: 0 });
12
+ const third = s.consume("k", opts);
13
+ expect(third.limited).toBe(true);
14
+ expect(third.retryAfterMs).toBe(60_000); // rate-limit.ts:35
15
+ });
16
+
17
+ test("the window resets once now passes resetAt", () => {
18
+ const s = new MemoryRateLimitStore();
19
+ s.consume("k", { maxRequests: 1, windowMs: 1_000, now: 0 });
20
+ expect(s.consume("k", { maxRequests: 1, windowMs: 1_000, now: 500 }).limited).toBe(true); // same window
21
+ expect(s.consume("k", { maxRequests: 1, windowMs: 1_000, now: 2_000 }).limited).toBe(false); // window rolled
22
+ });
23
+
24
+ test("distinct keys hold independent counters", () => {
25
+ const s = new MemoryRateLimitStore();
26
+ const opts = { maxRequests: 1, windowMs: 1_000, now: 0 };
27
+ expect(s.consume("a", opts).limited).toBe(false);
28
+ expect(s.consume("b", opts).limited).toBe(false); // b is not affected by a
29
+ expect(s.consume("a", opts).limited).toBe(true);
30
+ });
31
+ });
32
+
33
+ describe("enforceRateLimit middleware — facet-driven, 429 + Retry-After", () => {
34
+ const BUDGET: SulukRateLimit = { windowMs: 60_000, maxRequests: 2, key: "ip" };
35
+
36
+ // a controllable clock + an operation router, mirroring enforce.test.ts's harness style.
37
+ function makeApp(facets: Record<string, SulukRateLimit | undefined>, opts?: { now?: () => number }) {
38
+ const byPath: Record<string, string> = {
39
+ "/limited": "limited", "/other": "other", "/free": "free",
40
+ };
41
+ const app = new Hono();
42
+ app.use("*", enforceRateLimit({
43
+ operationOf: (c) => byPath[new URL(c.req.url).pathname],
44
+ rateLimitOf: (op) => facets[op],
45
+ store: new MemoryRateLimitStore(),
46
+ now: opts?.now,
47
+ }));
48
+ app.get("/limited", (c) => c.text("ok"));
49
+ app.get("/other", (c) => c.text("ok"));
50
+ app.get("/free", (c) => c.text("ok"));
51
+ app.get("/static.css", (c) => c.text("body{}")); // non-contract path
52
+ return app;
53
+ }
54
+ const ip = { "x-forwarded-for": "1.2.3.4" };
55
+
56
+ test("an op WITHOUT a facet is unmetered (never 429)", async () => {
57
+ const app = makeApp({ free: undefined });
58
+ for (let i = 0; i < 10; i++) expect((await app.request("/free", { headers: ip })).status).toBe(200);
59
+ });
60
+
61
+ test("a metered op returns 429 + Retry-After once the budget is exceeded", async () => {
62
+ let t = 1000;
63
+ const app = makeApp({ limited: BUDGET }, { now: () => t });
64
+ expect((await app.request("/limited", { headers: ip })).status).toBe(200);
65
+ expect((await app.request("/limited", { headers: ip })).status).toBe(200);
66
+ const r = await app.request("/limited", { headers: ip });
67
+ expect(r.status).toBe(429);
68
+ expect(r.headers.get("content-type")).toContain("application/problem+json");
69
+ expect(r.headers.get("retry-after")).toBe("60");
70
+ const body = await r.json();
71
+ expect(body).toMatchObject({ status: 429, title: "Too many requests", error: "rate_limited" });
72
+ });
73
+
74
+ test("advancing the clock past the window lets the caller through again", async () => {
75
+ let t = 1000;
76
+ const app = makeApp({ limited: BUDGET }, { now: () => t });
77
+ await app.request("/limited", { headers: ip });
78
+ await app.request("/limited", { headers: ip });
79
+ expect((await app.request("/limited", { headers: ip })).status).toBe(429);
80
+ t += 60_001; // roll the window
81
+ expect((await app.request("/limited", { headers: ip })).status).toBe(200);
82
+ });
83
+
84
+ test("budgets are PER-OPERATION (a different op does not drain this op's counter)", async () => {
85
+ const app = makeApp({ limited: BUDGET, other: BUDGET });
86
+ await app.request("/limited", { headers: ip });
87
+ await app.request("/limited", { headers: ip });
88
+ expect((await app.request("/limited", { headers: ip })).status).toBe(429);
89
+ expect((await app.request("/other", { headers: ip })).status).toBe(200); // independent budget
90
+ });
91
+
92
+ test("budgets are per-IP (a different client is unaffected)", async () => {
93
+ const app = makeApp({ limited: BUDGET });
94
+ await app.request("/limited", { headers: ip });
95
+ await app.request("/limited", { headers: ip });
96
+ expect((await app.request("/limited", { headers: ip })).status).toBe(429);
97
+ expect((await app.request("/limited", { headers: { "x-forwarded-for": "9.9.9.9" } })).status).toBe(200);
98
+ });
99
+
100
+ test("a non-contract path passes straight through", async () => {
101
+ const app = makeApp({ limited: BUDGET });
102
+ expect((await app.request("/static.css")).status).toBe(200);
103
+ });
104
+ });