@suluk/hono 0.1.0 → 0.1.2
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 +35 -0
- package/package.json +19 -8
- package/src/contract.ts +11 -1
- package/src/emit.ts +50 -2
- package/src/enforce.ts +112 -0
- package/src/errors.ts +113 -0
- package/src/index.ts +7 -0
- package/src/on-error.ts +34 -0
- package/src/ratelimit.ts +126 -0
- package/test/enforce.test.ts +121 -0
- package/test/errors.test.ts +121 -0
- package/test/ratelimit.test.ts +104 -0
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.
|
|
3
|
+
"version": "0.1.2",
|
|
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
|
-
"
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
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.
|
|
12
|
-
"@suluk/zod": "0.1.
|
|
22
|
+
"@suluk/core": "^0.1.7",
|
|
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.
|
|
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/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
|
-
|
|
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,10 @@ 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
|
+
export { SulukHttpError, HttpErrors, type SulukHttpErrorInit } from "./errors";
|
|
14
|
+
export { onError, type OnErrorOptions } from "./on-error";
|
|
15
|
+
export {
|
|
16
|
+
enforceRateLimit, MemoryRateLimitStore,
|
|
17
|
+
type EnforceRateLimitConfig, type RateLimitStore, type RateLimitResult, type RateLimitConsumeOptions,
|
|
18
|
+
} from "./ratelimit";
|
package/src/on-error.ts
ADDED
|
@@ -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
|
+
}
|
package/src/ratelimit.ts
ADDED
|
@@ -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,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
|
+
});
|