@ainra/middleware 0.4.0

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,90 @@
1
+ <!-- SPDX-License-Identifier: Apache-2.0 OR MIT -->
2
+ # @ainra/middleware
3
+
4
+ A fail-closed gate for requests made by AI agents. Five lines in front of your handler, and a request gets through
5
+ only if it carried a valid [AINRA](https://ainra.vercel.app/) passport.
6
+
7
+ **Offline. Fail-closed. Zero telemetry.** The check is local — no account with us, no per-request call, nothing that
8
+ can be down. A request with no passport, a revoked one, or one whose status cannot be freshly confirmed is refused
9
+ with a machine-readable reason.
10
+
11
+ ```sh
12
+ npm install @ainra/middleware
13
+ ```
14
+
15
+ ## Connect / Express
16
+
17
+ ```js
18
+ import express from "express";
19
+ import { Verifier, ainraGate } from "@ainra/middleware";
20
+
21
+ const verifier = Verifier.fromDirectoryB64(directory, roots.root_ed25519, roots.root_slh);
22
+
23
+ const app = express();
24
+ app.use("/agent", ainraGate(verifier)); // every /agent request must carry a valid passport
25
+
26
+ app.post("/agent/order", (req, res) => {
27
+ req.ainra; // the GateResult: { allow, reason?, verdict, event }
28
+ });
29
+ ```
30
+
31
+ The passport arrives in the `x-ainra-passport` header (raw JSON or base64url of it), or in `body.ainra_passport`.
32
+ On refusal the gate answers **403** with `{ error, reason }`. Every response — allowed or refused — carries
33
+ `x-ainra-verdict`, the canonical verdict event.
34
+
35
+ Options: `header` (default `x-ainra-passport`), `now` (your clock, for tests and fixed demo windows), and `onDeny`
36
+ for logging. It never receives secrets and never throws.
37
+
38
+ ## Any other framework
39
+
40
+ `checkRequest` is the same decision without the Connect shape — hand it the bundle, get a result:
41
+
42
+ ```js
43
+ import { checkRequest } from "@ainra/middleware";
44
+
45
+ const result = checkRequest(verifier, req.headers.get("x-ainra-passport"));
46
+ if (!result.allow) return new Response(result.reason, { status: 403 });
47
+ ```
48
+
49
+ It accepts a decoded object or the base64url-of-JSON string a header carries, and returns
50
+ `{ allow, reason?, verdict, event }`. Anything that is not a bundle verifying `valid` — including a parse failure —
51
+ comes back `allow: false`.
52
+
53
+ ## What "fail-closed" costs you
54
+
55
+ Nothing on the happy path, and it is the whole point on the unhappy one. When status freshness cannot be
56
+ established this gate refuses, rather than admitting the request and logging a warning. That is the behaviour you
57
+ want on the day a key is stolen, and it is the behaviour that becomes impossible to add later, once something
58
+ depends on the lenient path.
59
+
60
+ ## Underneath
61
+
62
+ Verification is [`@ainra/sdk`](https://www.npmjs.com/package/@ainra/sdk) — one of the three implementations that
63
+ agree on all 1153 conformance vectors, on verdict and reason. This package adds only the request plumbing: header decoding, the 403, the
64
+ verdict event, and `req.ainra`.
65
+
66
+ Licensed Apache-2.0 OR MIT.
67
+
68
+ ## Gating a running copy (ADR-019)
69
+
70
+ The gate accepts an instance credential wherever it accepts a passport — build the `Verifier` with your audience
71
+ and nothing else changes:
72
+
73
+ ```js
74
+ const verifier = Verifier.fromDirectoryB64(directory, roots.root_ed25519, roots.root_slh, "F2", false,
75
+ "https://api.example"); // ← YOUR audience; a presenter can never supply it
76
+ app.use("/agent", ainraGate(verifier));
77
+ ```
78
+
79
+ Refusals arrive with their own reason, so a 403 tells the caller what to fix:
80
+
81
+ | reason | what the caller should do |
82
+ |---|---|
83
+ | `instance_expired` | mint a fresh credential — the passport is fine |
84
+ | `instance_scope_exceeds` | the copy asked for more than its passport holds |
85
+ | `instance_sig_invalid` | not minted by that passport's control key, or bound to another passport |
86
+ | `instance_pop_invalid` | wrong audience, stale timestamp, or no proof of possession |
87
+ | `revoked` | the **passport** is revoked — every live copy under it is dead |
88
+
89
+ The verdict event gains `instance_iid` and `instance_exp` (both `null` for a plain passport), so an operator
90
+ watching the log sees short expiries and can spot a long one. Proven by `make instance-gate`.
@@ -0,0 +1,41 @@
1
+ import { Verifier, type Verdict, type VerdictEvent } from "@ainra/sdk";
2
+ export interface GateOptions {
3
+ /** Verifier's clock, unix seconds. Default: `Date.now()/1000`. Pass a fixed value for a demo/test window. */
4
+ now?: () => number;
5
+ /** Header the presentation bundle (JSON, or base64url-of-JSON) arrives in. Default `x-ainra-passport`. */
6
+ header?: string;
7
+ /** Called on deny (before the 403 is sent) — for logging. Never receives secrets. */
8
+ onDeny?: (reason: string) => void;
9
+ }
10
+ export interface GateResult {
11
+ allow: boolean;
12
+ reason?: string;
13
+ verdict: Verdict;
14
+ /** The canonical verdict event (docs/PRESENTATION.md) — identical shape across CLI, MCP, and this middleware. */
15
+ event: VerdictEvent;
16
+ }
17
+ /** Verify one presentation bundle. FAIL CLOSED: anything that isn't a bundle that verifies VALID → `allow:false`.
18
+ * Never throws. `bundle` may be a decoded object or a base64url-of-JSON string (what a header carries). */
19
+ export declare function checkRequest(verifier: Verifier, bundle: unknown, opts?: GateOptions): GateResult;
20
+ interface ReqLike {
21
+ headers: Record<string, string | string[] | undefined>;
22
+ body?: unknown;
23
+ ainra?: GateResult;
24
+ }
25
+ interface ResLike {
26
+ status(code: number): ResLike;
27
+ json(body: unknown): unknown;
28
+ setHeader(name: string, value: string): void;
29
+ }
30
+ type Next = () => void;
31
+ /**
32
+ * Connect/Express middleware. Reads the presentation from the configured header (or `req.body.ainra_passport`),
33
+ * verifies it, and either calls `next()` (attaching `req.ainra`) or responds **403 fail-closed** with the reason.
34
+ *
35
+ * ```ts
36
+ * const verifier = Verifier.fromDirectoryB64(directory, roots.root_ed25519, roots.root_slh)!;
37
+ * app.use("/agent", ainraGate(verifier)); // every /agent request must carry a valid passport
38
+ * ```
39
+ */
40
+ export declare function ainraGate(verifier: Verifier, opts?: GateOptions): (req: ReqLike, res: ResLike, next: Next) => void;
41
+ export { Verifier, verdictEvent, serializeVerdictEvent, type VerdictEvent } from "@ainra/sdk";
package/dist/index.js ADDED
@@ -0,0 +1,66 @@
1
+ // SPDX-License-Identifier: Apache-2.0 OR MIT
2
+ //! `@ainra/middleware` — the verifier wedge: a fail-closed gate that checks an AINRA passport presented on a
3
+ //! request and allows or denies it, in ~5 lines of integration. Pure over `@ainra/sdk`'s `Verifier`: no network,
4
+ //! no state, no telemetry — a request either carries a presentation bundle that verifies against the trusted
5
+ //! directory, or it is denied with a machine-readable reason.
6
+ //!
7
+ //! Two surfaces:
8
+ //! * `ainraGate(verifier, opts)` → Connect/Express `(req, res, next)` middleware.
9
+ //! * `checkRequest(verifier, bundle, opts)` → framework-agnostic `{ allow, reason?, verdict }` (edge/fetch).
10
+ import { verdictEvent, serializeVerdictEvent } from "@ainra/sdk";
11
+ const DENY_SCHEMA = { verdict: "invalid", reason: "schema_violation" };
12
+ /** Verify one presentation bundle. FAIL CLOSED: anything that isn't a bundle that verifies VALID → `allow:false`.
13
+ * Never throws. `bundle` may be a decoded object or a base64url-of-JSON string (what a header carries). */
14
+ export function checkRequest(verifier, bundle, opts = {}) {
15
+ const now = (opts.now ?? (() => Math.floor(Date.now() / 1000)))();
16
+ let parsed;
17
+ try {
18
+ if (typeof bundle === "string") {
19
+ // Accept raw JSON or base64url(JSON) — a header is easiest as the latter.
20
+ const text = bundle.trim().startsWith("{") ? bundle : Buffer.from(bundle, "base64url").toString("utf8");
21
+ parsed = JSON.parse(text);
22
+ }
23
+ else if (bundle && typeof bundle === "object") {
24
+ parsed = bundle;
25
+ }
26
+ else {
27
+ return { allow: false, reason: "schema_violation", verdict: DENY_SCHEMA, event: verdictEvent({}, DENY_SCHEMA, now) };
28
+ }
29
+ }
30
+ catch {
31
+ return { allow: false, reason: "schema_violation", verdict: DENY_SCHEMA, event: verdictEvent({}, DENY_SCHEMA, now) };
32
+ }
33
+ const verdict = verifier.verify(parsed, now); // never throws; invalid on any failure
34
+ const allow = verdict.verdict === "valid";
35
+ return { allow, reason: allow ? undefined : verdict.reason, verdict, event: verdictEvent(parsed, verdict, now) };
36
+ }
37
+ /**
38
+ * Connect/Express middleware. Reads the presentation from the configured header (or `req.body.ainra_passport`),
39
+ * verifies it, and either calls `next()` (attaching `req.ainra`) or responds **403 fail-closed** with the reason.
40
+ *
41
+ * ```ts
42
+ * const verifier = Verifier.fromDirectoryB64(directory, roots.root_ed25519, roots.root_slh)!;
43
+ * app.use("/agent", ainraGate(verifier)); // every /agent request must carry a valid passport
44
+ * ```
45
+ */
46
+ export function ainraGate(verifier, opts = {}) {
47
+ const header = (opts.header ?? "x-ainra-passport").toLowerCase();
48
+ return (req, res, next) => {
49
+ const raw = req.headers[header] ?? req.body?.ainra_passport;
50
+ const result = raw === undefined
51
+ ? { allow: false, reason: "schema_violation", verdict: DENY_SCHEMA, event: verdictEvent({}, DENY_SCHEMA, (opts.now ?? (() => Math.floor(Date.now() / 1000)))()) }
52
+ : checkRequest(verifier, Array.isArray(raw) ? raw[0] : raw, opts);
53
+ req.ainra = result;
54
+ // Emit the canonical verdict event on every request (allow or deny) — one event shape everywhere (PRESENTATION.md).
55
+ res.setHeader("x-ainra-verdict", serializeVerdictEvent(result.event));
56
+ if (result.allow) {
57
+ next();
58
+ return;
59
+ }
60
+ if (opts.onDeny)
61
+ opts.onDeny(result.reason ?? "schema_violation");
62
+ res.setHeader("x-ainra-reason", result.reason ?? "schema_violation");
63
+ res.status(403).json({ error: "ainra: passport not valid", reason: result.reason });
64
+ };
65
+ }
66
+ export { Verifier, verdictEvent, serializeVerdictEvent } from "@ainra/sdk";
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@ainra/middleware",
3
+ "version": "0.4.0",
4
+ "description": "AINRA verifier middleware — a fail-closed gate (Connect/Express + framework-agnostic) that verifies an AINRA passport on a request, over @ainra/sdk. The verification wedge: local, offline, ~5-line integration.",
5
+ "license": "Apache-2.0 OR MIT",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "files": [
10
+ "dist/"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.json",
14
+ "test": "node --test"
15
+ },
16
+ "dependencies": {
17
+ "@ainra/sdk": "^0.4.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^26.1.1",
21
+ "typescript": "^5.6.0"
22
+ },
23
+ "homepage": "https://ainra.vercel.app/",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/JacobJandon/ainra.git"
27
+ }
28
+ }