@ainra/middleware 0.7.0 → 0.8.1
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 +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +30 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -75,7 +75,7 @@ depends on the lenient path.
|
|
|
75
75
|
## Underneath
|
|
76
76
|
|
|
77
77
|
Verification is [`@ainra/sdk`](https://www.npmjs.com/package/@ainra/sdk) — one of the three implementations that
|
|
78
|
-
agree on all
|
|
78
|
+
agree on all 1169 conformance vectors, on verdict and reason. This package adds only the request plumbing: header decoding, the 403, the
|
|
79
79
|
verdict event, and `req.ainra`.
|
|
80
80
|
|
|
81
81
|
Licensed Apache-2.0 OR MIT.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { Verifier, type LinkIndex, type StatusPublication, type LinkVerdict, type Verdict, type VerdictEvent, type DecisionLog } from "@ainra/sdk";
|
|
2
|
+
/** The verdict event as an HTTP header value (2026-11-12). The event's `name` comes from the presenter's own claims,
|
|
3
|
+
* also when the bundle is refused, and `serializeVerdictEvent` keeps non-ASCII as it is (it must byte-match the Rust
|
|
4
|
+
* emitter). A header cannot hold most of those characters: Node's `setHeader` throws on them, which turned a refusal
|
|
5
|
+
* into a 500 in a framework that catches, and stopped a server whose handler did not. So every character outside
|
|
6
|
+
* printable ASCII is written as its JSON `\uXXXX` escape: still valid JSON, and it parses to exactly the same event. */
|
|
7
|
+
export declare function verdictHeaderValue(e: VerdictEvent): string;
|
|
2
8
|
/** Where the gate keeps bundles a running copy has sent once. Only bundles that verified VALID when they arrived
|
|
3
9
|
* are ever stored, the key is the SHA-256 of the content, and every request re-verifies what it names — so the
|
|
4
10
|
* store can lose entries (the client just sends again) but cannot make anything pass that would not. */
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
//! * `checkRequest(verifier, bundle, opts)` → framework-agnostic `{ allow, reason?, verdict }` (edge/fetch).
|
|
10
10
|
import { verdictEvent, serializeVerdictEvent, decodeInstance, verifyPresentation, isPresentationRef, splitPresentation, joinPresentation, presentationRef, POP_HEADER, PRIME_PATH, decisionRequestDigest, linkFromIndex, presentedKeyThumbprint, } from "@ainra/sdk";
|
|
11
11
|
import { createHash } from "node:crypto";
|
|
12
|
+
/** The verdict event as an HTTP header value (2026-11-12). The event's `name` comes from the presenter's own claims,
|
|
13
|
+
* also when the bundle is refused, and `serializeVerdictEvent` keeps non-ASCII as it is (it must byte-match the Rust
|
|
14
|
+
* emitter). A header cannot hold most of those characters: Node's `setHeader` throws on them, which turned a refusal
|
|
15
|
+
* into a 500 in a framework that catches, and stopped a server whose handler did not. So every character outside
|
|
16
|
+
* printable ASCII is written as its JSON `\uXXXX` escape: still valid JSON, and it parses to exactly the same event. */
|
|
17
|
+
export function verdictHeaderValue(e) {
|
|
18
|
+
return serializeVerdictEvent(e).replace(/[^\x20-\x7e]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
|
|
19
|
+
}
|
|
12
20
|
/** The default store: in memory, bounded, oldest-first eviction, and nothing outlives the credential it holds.
|
|
13
21
|
* Bounded because whoever can mint valid bundles can otherwise fill it; eviction costs them nothing but costs an
|
|
14
22
|
* honest client one re-send, which is the right way round. One process only — behind several, share a store. */
|
|
@@ -120,6 +128,22 @@ export function checkRequest(verifier, bundle, opts = {}) {
|
|
|
120
128
|
}
|
|
121
129
|
return { allow, reason, verdict, event: verdictEvent(parsed, verdict, now) };
|
|
122
130
|
}
|
|
131
|
+
/** Does the request carry a body the gate was not given the raw bytes of? */
|
|
132
|
+
function bodyUnseen(req) {
|
|
133
|
+
if (req.rawBody !== undefined)
|
|
134
|
+
return false;
|
|
135
|
+
const h = (k) => { const v = req.headers[k]; return Array.isArray(v) ? v[0] : v; };
|
|
136
|
+
if (Number(h("content-length") ?? 0) > 0 || h("transfer-encoding") !== undefined || h("content-digest") !== undefined)
|
|
137
|
+
return true;
|
|
138
|
+
const b = req.body;
|
|
139
|
+
if (typeof b === "string")
|
|
140
|
+
return b.length > 0;
|
|
141
|
+
if (b && typeof b === "object") {
|
|
142
|
+
const keys = Object.keys(b).filter((k) => k !== "ainra_passport");
|
|
143
|
+
return keys.length > 0;
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
123
147
|
/** Build the signed-over view of an Express/Node request. `authority` comes from the Host header, which is what
|
|
124
148
|
* the client addressed and therefore what it signed. */
|
|
125
149
|
function bindingFromReq(req) {
|
|
@@ -188,7 +212,10 @@ export function ainraGate(verifier, opts = {}) {
|
|
|
188
212
|
return (req, res, next) => {
|
|
189
213
|
const raw = req.headers[header] ?? req.body?.ainra_passport;
|
|
190
214
|
const popRaw = req.headers[popHeader];
|
|
191
|
-
|
|
215
|
+
// A signed request whose body the gate cannot see is refused, not checked as if it had none: a signature captured
|
|
216
|
+
// on a bodyless request would otherwise pass with any body attached (2026-11-07 review).
|
|
217
|
+
const blind = opts.requireSignature === true && bodyUnseen(req);
|
|
218
|
+
const result = raw === undefined || blind
|
|
192
219
|
? { allow: false, reason: "schema_violation", verdict: DENY_SCHEMA, event: verdictEvent({}, DENY_SCHEMA, (opts.now ?? (() => Math.floor(Date.now() / 1000)))()) }
|
|
193
220
|
: checkRequest(verifier, Array.isArray(raw) ? raw[0] : raw, {
|
|
194
221
|
...opts,
|
|
@@ -199,7 +226,7 @@ export function ainraGate(verifier, opts = {}) {
|
|
|
199
226
|
if (opts.decisions)
|
|
200
227
|
recordDecision(opts.decisions, raw, result, req, opts);
|
|
201
228
|
// Emit the canonical verdict event on every request (allow or deny) — one event shape everywhere (PRESENTATION.md).
|
|
202
|
-
res.setHeader("x-ainra-verdict",
|
|
229
|
+
res.setHeader("x-ainra-verdict", verdictHeaderValue(result.event));
|
|
203
230
|
if (result.allow) {
|
|
204
231
|
next();
|
|
205
232
|
return;
|
|
@@ -252,7 +279,7 @@ export function ainraPrime(verifier, opts) {
|
|
|
252
279
|
if ((req.method ?? "POST").toUpperCase() !== "POST")
|
|
253
280
|
return deny("schema_violation");
|
|
254
281
|
const result = checkRequest(verifier, body, { now: () => now });
|
|
255
|
-
res.setHeader("x-ainra-verdict",
|
|
282
|
+
res.setHeader("x-ainra-verdict", verdictHeaderValue(result.event));
|
|
256
283
|
if (!result.allow)
|
|
257
284
|
return deny(result.reason ?? "schema_violation");
|
|
258
285
|
const bundle = body;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ainra/middleware",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
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
5
|
"license": "Apache-2.0 OR MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"test": "node --test"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@ainra/sdk": "^0.
|
|
17
|
+
"@ainra/sdk": "^0.8.1"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"@types/node": "^26.1.1",
|