@ainra/middleware 0.4.0 → 0.5.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 CHANGED
@@ -2,7 +2,7 @@
2
2
  # @ainra/middleware
3
3
 
4
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.
5
+ only if it carried a valid [AINRA](https://ainra.org/) passport.
6
6
 
7
7
  **Offline. Fail-closed. Zero telemetry.** The check is local — no account with us, no per-request call, nothing that
8
8
  can be down. A request with no passport, a revoked one, or one whose status cannot be freshly confirmed is refused
@@ -10,6 +10,7 @@ with a machine-readable reason.
10
10
 
11
11
  ```sh
12
12
  npm install @ainra/middleware
13
+ npm audit signatures # optional: check the Sigstore provenance before you trust it
13
14
  ```
14
15
 
15
16
  ## Connect / Express
@@ -29,6 +30,20 @@ app.post("/agent/order", (req, res) => {
29
30
  ```
30
31
 
31
32
  The passport arrives in the `x-ainra-passport` header (raw JSON or base64url of it), or in `body.ainra_passport`.
33
+
34
+ **In production, send the bundle once** (M36, D-065). A full bundle is ~60 KB — over every common header limit —
35
+ so a running copy posts it once and names it by digest after that:
36
+
37
+ ```ts
38
+ import { ainraGate, ainraPrime, createPresentationStore, PRIME_PATH, Verifier } from "@ainra/middleware";
39
+ const store = createPresentationStore(); // bounded, in memory; share one behind a pool
40
+ app.post(PRIME_PATH, express.json({ limit: "256kb" }), ainraPrime(verifier, { store })); // 201 {ref}
41
+ app.use("/agent", ainraGate(verifier, { store, requireSignature: true, seenNonce })); // requests name the ref
42
+ ```
43
+
44
+ Each request then carries `x-ainra-passport: <ref>`, `x-ainra-pop` and the RFC 9421 signature — ~11 KB in total,
45
+ no line over 8 KB. A gate that does not hold the named bundle answers **428** `presentation_unknown`: send it again.
46
+ Only bundles that verify VALID are stored, and every request re-verifies what it names.
32
47
  On refusal the gate answers **403** with `{ error, reason }`. Every response — allowed or refused — carries
33
48
  `x-ainra-verdict`, the canonical verdict event.
34
49
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,17 @@
1
- import { Verifier, type Verdict, type VerdictEvent } from "@ainra/sdk";
1
+ import { Verifier, type LinkIndex, type StatusPublication, type LinkVerdict, type Verdict, type VerdictEvent, type DecisionLog } from "@ainra/sdk";
2
+ /** Where the gate keeps bundles a running copy has sent once. Only bundles that verified VALID when they arrived
3
+ * are ever stored, the key is the SHA-256 of the content, and every request re-verifies what it names — so the
4
+ * store can lose entries (the client just sends again) but cannot make anything pass that would not. */
5
+ export interface PresentationStore {
6
+ get(ref: string, now: number): Record<string, unknown> | undefined;
7
+ put(ref: string, stable: Record<string, unknown>, expiresAt: number): void;
8
+ }
9
+ /** The default store: in memory, bounded, oldest-first eviction, and nothing outlives the credential it holds.
10
+ * Bounded because whoever can mint valid bundles can otherwise fill it; eviction costs them nothing but costs an
11
+ * honest client one re-send, which is the right way round. One process only — behind several, share a store. */
12
+ export declare function createPresentationStore(opts?: {
13
+ max?: number;
14
+ }): PresentationStore;
2
15
  export interface GateOptions {
3
16
  /** Verifier's clock, unix seconds. Default: `Date.now()/1000`. Pass a fixed value for a demo/test window. */
4
17
  now?: () => number;
@@ -6,6 +19,43 @@ export interface GateOptions {
6
19
  header?: string;
7
20
  /** Called on deny (before the 403 is sent) — for logging. Never receives secrets. */
8
21
  onDeny?: (reason: string) => void;
22
+ /** Require the REQUEST itself to be signed by the running copy's instance key (RFC 9421, D-062).
23
+ *
24
+ * Off by default, and that default is a compatibility choice rather than a security opinion: turning it on
25
+ * silently would reject every integrator who upgraded this package without changing a line of their agent.
26
+ * Leave it off and a captured presentation replays against any route on this host until the credential
27
+ * expires — which is what `docs/PLAN-M34.md` probed and found. Turn it on and the presentation is bound to one
28
+ * method, one authority, one path and, when there is a body, that body.
29
+ *
30
+ * Requires the bundle to carry an instance credential: there is no key to check a signature against without
31
+ * one, so a passport-only presentation is refused rather than waved through. */
32
+ requireSignature?: boolean;
33
+ /** Close the replay window completely. Return true if this nonce has been seen before.
34
+ *
35
+ * Without it, a captured signature stays usable against that exact target for five minutes. The cache cannot
36
+ * live in the SDK (`ainra-core` is N7 — no state), so it lives with the caller who has somewhere to put it. */
37
+ seenNonce?: (nonce: string) => boolean;
38
+ /** M36 (D-065): where bundles sent to PRIME_PATH are kept, so requests can name them by digest. Pass the SAME
39
+ * store to `ainraPrime`. Without one, a request that names a bundle by digest is refused `presentation_unknown`. */
40
+ store?: PresentationStore;
41
+ /** Header the per-request proof of possession arrives in when the bundle is named by digest. Default `x-ainra-pop`. */
42
+ popHeader?: string;
43
+ /** D-079: record every decision this gate can attribute to a named passport in a signed, chained log
44
+ * (`createDecisionLog` from `@ainra/sdk`). A request that names no parseable passport has no subject and is not
45
+ * recorded. The operator is recorded only on a valid decision: on an invalid one, the name in the claims is
46
+ * the presenter's word. Never receives the request body. */
47
+ decisions?: DecisionLog;
48
+ }
49
+ /** What `requireSignature` needs from the request, beyond the bundle. A framework-agnostic shape so an edge
50
+ * worker, a Node server and a test can all produce it. */
51
+ export interface RequestBinding {
52
+ method: string;
53
+ /** host or host:port — no scheme, no path. */
54
+ authority: string;
55
+ /** path, including any query string, exactly as sent */
56
+ path: string;
57
+ headers: Record<string, string | string[] | undefined>;
58
+ body?: Uint8Array;
9
59
  }
10
60
  export interface GateResult {
11
61
  allow: boolean;
@@ -16,10 +66,21 @@ export interface GateResult {
16
66
  }
17
67
  /** Verify one presentation bundle. FAIL CLOSED: anything that isn't a bundle that verifies VALID → `allow:false`.
18
68
  * 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;
69
+ export declare function checkRequest(verifier: Verifier, bundle: unknown, opts?: GateOptions & {
70
+ binding?: RequestBinding;
71
+ pop?: string;
72
+ }): GateResult;
20
73
  interface ReqLike {
21
74
  headers: Record<string, string | string[] | undefined>;
22
75
  body?: unknown;
76
+ /** Present on any Node/Express request; needed only when `requireSignature` is on. */
77
+ method?: string;
78
+ url?: string;
79
+ originalUrl?: string;
80
+ /** The RAW request body. A parsed body cannot be re-serialised back to the bytes that were signed, so when a
81
+ * signer covered `content-digest` and this is absent, the gate refuses rather than guessing — capture it with
82
+ * body-parser's `verify` hook (`(req, _res, buf) => { req.rawBody = buf; }`). */
83
+ rawBody?: Uint8Array;
23
84
  ainra?: GateResult;
24
85
  }
25
86
  interface ResLike {
@@ -38,4 +99,49 @@ type Next = () => void;
38
99
  * ```
39
100
  */
40
101
  export declare function ainraGate(verifier: Verifier, opts?: GateOptions): (req: ReqLike, res: ResLike, next: Next) => void;
102
+ /**
103
+ * M36 (D-065): the handler a running copy sends its bundle to, once. Mount it at `PRIME_PATH` with the same store
104
+ * as the gate. It verifies the bundle IN FULL — proof of possession included — and keeps only bundles that verify
105
+ * VALID, keyed by the digest of their stable part; it answers 201 with that digest. Sending a bundle grants nothing
106
+ * by itself: every request that names it must still verify, and, with `requireSignature`, be signed.
107
+ *
108
+ * ```ts
109
+ * const store = createPresentationStore();
110
+ * app.post(PRIME_PATH, express.json({ limit: "256kb" }), ainraPrime(verifier, { store }));
111
+ * app.use("/agent", ainraGate(verifier, { store, requireSignature: true, seenNonce }));
112
+ * ```
113
+ */
114
+ export declare function ainraPrime(verifier: Verifier, opts: {
115
+ store: PresentationStore;
116
+ now?: () => number;
117
+ onDeny?: (reason: string) => void;
118
+ }): (req: ReqLike, res: ResLike) => void;
119
+ export interface LinkGateOptions {
120
+ /** The Ed25519 key that verified THIS request's signature (your signature-agent, WIMSE or other verifier), or null
121
+ * if it did not verify. AINRA never treats a key as verified on its own say-so. */
122
+ verifiedKey: (req: ReqLike) => Uint8Array | null | Promise<Uint8Array | null>;
123
+ /** The registrar's link index and signed status publication, as `GET /links` and `GET /status-list` serve them.
124
+ * Cache them as you like; the status must stay inside the gate's freshness class or links read `stale_status`. */
125
+ source: () => Promise<{
126
+ index: LinkIndex;
127
+ status: StatusPublication;
128
+ }> | {
129
+ index: LinkIndex;
130
+ status: StatusPublication;
131
+ };
132
+ /** The capability this request exercises, if links may be narrowed. */
133
+ capability?: (req: ReqLike) => string | undefined;
134
+ now?: () => number;
135
+ decisions?: DecisionLog;
136
+ onDeny?: (reason: string) => void;
137
+ }
138
+ /**
139
+ * Connect/Express middleware in link mode: requests whose signing key is linked to an accountable operator, and not
140
+ * revoked, pass (with `req.ainraLink` attached); everything else gets 403 and a named reason, `not_linked` included.
141
+ * The Verifier carries the trust (directory, roots, freshness class), exactly as for passports.
142
+ */
143
+ export declare function ainraLinkGate(verifier: Verifier, opts: LinkGateOptions): (req: ReqLike & {
144
+ ainraLink?: LinkVerdict;
145
+ }, res: ResLike, next: Next) => Promise<void>;
41
146
  export { Verifier, verdictEvent, serializeVerdictEvent, type VerdictEvent } from "@ainra/sdk";
147
+ export { PRIME_PATH, POP_HEADER, presentationRef, isPresentationRef } from "@ainra/sdk";
package/dist/index.js CHANGED
@@ -7,13 +7,88 @@
7
7
  //! Two surfaces:
8
8
  //! * `ainraGate(verifier, opts)` → Connect/Express `(req, res, next)` middleware.
9
9
  //! * `checkRequest(verifier, bundle, opts)` → framework-agnostic `{ allow, reason?, verdict }` (edge/fetch).
10
- import { verdictEvent, serializeVerdictEvent } from "@ainra/sdk";
10
+ import { verdictEvent, serializeVerdictEvent, decodeInstance, verifyPresentation, isPresentationRef, splitPresentation, joinPresentation, presentationRef, POP_HEADER, PRIME_PATH, decisionRequestDigest, linkFromIndex, jwkThumbprintEd25519, } from "@ainra/sdk";
11
+ import { createHash } from "node:crypto";
12
+ /** The default store: in memory, bounded, oldest-first eviction, and nothing outlives the credential it holds.
13
+ * Bounded because whoever can mint valid bundles can otherwise fill it; eviction costs them nothing but costs an
14
+ * honest client one re-send, which is the right way round. One process only — behind several, share a store. */
15
+ export function createPresentationStore(opts = {}) {
16
+ const max = Math.max(1, opts.max ?? 1024);
17
+ const m = new Map();
18
+ return {
19
+ get(ref, now) {
20
+ const e = m.get(ref);
21
+ if (!e)
22
+ return undefined;
23
+ if (now >= e.exp) {
24
+ m.delete(ref);
25
+ return undefined;
26
+ }
27
+ m.delete(ref);
28
+ m.set(ref, e); // most recently used last, so eviction takes the idlest
29
+ return e.stable;
30
+ },
31
+ put(ref, stable, exp) {
32
+ m.delete(ref);
33
+ m.set(ref, { stable, exp });
34
+ while (m.size > max)
35
+ m.delete(m.keys().next().value);
36
+ },
37
+ };
38
+ }
39
+ /** How long a stored bundle may be named. A running copy's credential expires on its own (ADR-019, ≤ 1 h); a
40
+ * passport presented directly carries a status list whose freshness is what matters, so it is kept briefly and
41
+ * re-sent. Every use re-verifies at the current time regardless — this bounds memory, not trust. */
42
+ const DIRECT_PASSPORT_TTL_SECS = 300;
11
43
  const DENY_SCHEMA = { verdict: "invalid", reason: "schema_violation" };
44
+ /** Returns null when the request is bound to this presentation, or the reason it is not.
45
+ *
46
+ * Presentation reasons are a SEPARATE vocabulary from the frozen credential reasons, on purpose: `sig_invalid`
47
+ * means the registrar's signature is broken and sends an integrator to the wrong half of the system, when what
48
+ * actually happened is that a perfectly good credential arrived on a request it was not signed for. */
49
+ function checkBinding(parsed, opts, now) {
50
+ const req = opts.binding;
51
+ if (!req)
52
+ return "schema_violation"; // asked to require a signature with nothing to check it over
53
+ if (!parsed.instance)
54
+ return "presentation_unsigned"; // no instance key to verify against
55
+ let ic;
56
+ try {
57
+ ({ ic } = decodeInstance(parsed.instance));
58
+ }
59
+ catch {
60
+ return "schema_violation";
61
+ }
62
+ const r = verifyPresentation({
63
+ req: { method: req.method, authority: req.authority, path: req.path, headers: req.headers, body: req.body },
64
+ instance: { iid: ic.iid, ikey: ic.ikey },
65
+ now,
66
+ seenNonce: opts.seenNonce,
67
+ });
68
+ return r.ok ? null : r.reason;
69
+ }
12
70
  /** Verify one presentation bundle. FAIL CLOSED: anything that isn't a bundle that verifies VALID → `allow:false`.
13
71
  * Never throws. `bundle` may be a decoded object or a base64url-of-JSON string (what a header carries). */
14
72
  export function checkRequest(verifier, bundle, opts = {}) {
15
73
  const now = (opts.now ?? (() => Math.floor(Date.now() / 1000)))();
16
74
  let parsed;
75
+ // M36 (D-065): the request names a bundle it sent earlier. Look it up and put the per-request proof of possession
76
+ // back where the verifier expects it — from here on it is the same bundle, checked the same way.
77
+ if (typeof bundle === "string" && isPresentationRef(bundle)) {
78
+ const stable = opts.store?.get(bundle.trim(), now);
79
+ if (!stable)
80
+ return { allow: false, reason: "presentation_unknown", verdict: DENY_SCHEMA, event: verdictEvent({}, DENY_SCHEMA, now) };
81
+ let pop = null;
82
+ if (opts.pop !== undefined) {
83
+ try {
84
+ pop = JSON.parse(Buffer.from(opts.pop.trim(), "base64url").toString("utf8"));
85
+ }
86
+ catch {
87
+ return { allow: false, reason: "schema_violation", verdict: DENY_SCHEMA, event: verdictEvent({}, DENY_SCHEMA, now) };
88
+ }
89
+ }
90
+ bundle = joinPresentation(stable, pop);
91
+ }
17
92
  try {
18
93
  if (typeof bundle === "string") {
19
94
  // Accept raw JSON or base64url(JSON) — a header is easiest as the latter.
@@ -31,8 +106,72 @@ export function checkRequest(verifier, bundle, opts = {}) {
31
106
  return { allow: false, reason: "schema_violation", verdict: DENY_SCHEMA, event: verdictEvent({}, DENY_SCHEMA, now) };
32
107
  }
33
108
  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) };
109
+ let allow = verdict.verdict === "valid";
110
+ let reason = verdict.verdict === "valid" ? undefined : verdict.reason;
111
+ // The credential is good. That says an agent exists and is entitled — it says nothing about THIS request, which
112
+ // is the gap PLAN-M34 probed: the same bundle verified three times over with nothing naming a method, a host or
113
+ // a path. When the caller asks for it, bind the two together before allowing anything.
114
+ if (allow && opts.requireSignature) {
115
+ const bound = checkBinding(parsed, opts, now);
116
+ if (bound !== null) {
117
+ allow = false;
118
+ reason = bound;
119
+ }
120
+ }
121
+ return { allow, reason, verdict, event: verdictEvent(parsed, verdict, now) };
122
+ }
123
+ /** Build the signed-over view of an Express/Node request. `authority` comes from the Host header, which is what
124
+ * the client addressed and therefore what it signed. */
125
+ function bindingFromReq(req) {
126
+ const host = Array.isArray(req.headers.host) ? req.headers.host[0] : req.headers.host;
127
+ return {
128
+ method: req.method ?? "GET",
129
+ authority: host ?? "",
130
+ path: req.originalUrl ?? req.url ?? "/",
131
+ headers: req.headers,
132
+ body: req.rawBody,
133
+ };
134
+ }
135
+ /** D-079: write one decision to the gate's log, when the request named a passport. Nothing here can fail the
136
+ * request: a decision log that cannot be written is the operator's alarm, not the agent's refusal. */
137
+ function recordDecision(log, raw, result, req, opts) {
138
+ try {
139
+ const sub = result.event.name;
140
+ if (typeof sub !== "string" || sub === "")
141
+ return; // nothing parseable named: no subject to record
142
+ let b = {};
143
+ const v = Array.isArray(raw) ? raw[0] : raw;
144
+ if (typeof v === "string" && !isPresentationRef(v)) {
145
+ try {
146
+ b = JSON.parse(v.trim().startsWith("{") ? v : Buffer.from(v, "base64url").toString("utf8"));
147
+ }
148
+ catch { /* digests only */ }
149
+ }
150
+ else if (v && typeof v === "object")
151
+ b = v;
152
+ const binding = bindingFromReq(req);
153
+ const cd = binding.headers["content-digest"];
154
+ const valid = result.allow;
155
+ const digest = (text) => createHash("sha256").update(text).digest("base64url");
156
+ log.record({
157
+ at: (opts.now ?? (() => Math.floor(Date.now() / 1000)))(),
158
+ kind: "passport",
159
+ subject: sub,
160
+ ...(valid ? { operator: sub.split(":")[2] } : {}),
161
+ verdict: valid ? "valid" : "invalid",
162
+ ...(valid ? {} : { reason: result.reason ?? "schema_violation" }),
163
+ request: decisionRequestDigest({ method: binding.method, authority: binding.authority, path: binding.path, contentDigest: Array.isArray(cd) ? cd[0] : cd }),
164
+ ...(typeof b.status_uri === "string" && Number.isSafeInteger(b.status_issued_at) && typeof b.status_list === "string"
165
+ ? { status: { uri: b.status_uri, issued_at: b.status_issued_at, list: digest(b.status_list) } } : {}),
166
+ ...(b.checkpoint && typeof b.checkpoint.origin === "string" && Number.isSafeInteger(b.checkpoint.size) && typeof b.checkpoint.root === "string"
167
+ ? { checkpoint: { origin: b.checkpoint.origin, size: b.checkpoint.size, root: b.checkpoint.root } } : {}),
168
+ });
169
+ }
170
+ catch (e) {
171
+ // A presentation reason (e.g. presentation_unsigned) is not a credential reason and has no place in the closed
172
+ // record; and any other failure to record must surface without changing the decision.
173
+ console.error(`ainra: decision not recorded: ${e.message}`);
174
+ }
36
175
  }
37
176
  /**
38
177
  * Connect/Express middleware. Reads the presentation from the configured header (or `req.body.ainra_passport`),
@@ -45,12 +184,20 @@ export function checkRequest(verifier, bundle, opts = {}) {
45
184
  */
46
185
  export function ainraGate(verifier, opts = {}) {
47
186
  const header = (opts.header ?? "x-ainra-passport").toLowerCase();
187
+ const popHeader = (opts.popHeader ?? POP_HEADER).toLowerCase();
48
188
  return (req, res, next) => {
49
189
  const raw = req.headers[header] ?? req.body?.ainra_passport;
190
+ const popRaw = req.headers[popHeader];
50
191
  const result = raw === undefined
51
192
  ? { 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);
193
+ : checkRequest(verifier, Array.isArray(raw) ? raw[0] : raw, {
194
+ ...opts,
195
+ binding: opts.requireSignature ? bindingFromReq(req) : undefined,
196
+ pop: Array.isArray(popRaw) ? popRaw[0] : popRaw,
197
+ });
53
198
  req.ainra = result;
199
+ if (opts.decisions)
200
+ recordDecision(opts.decisions, raw, result, req, opts);
54
201
  // Emit the canonical verdict event on every request (allow or deny) — one event shape everywhere (PRESENTATION.md).
55
202
  res.setHeader("x-ainra-verdict", serializeVerdictEvent(result.event));
56
203
  if (result.allow) {
@@ -60,7 +207,121 @@ export function ainraGate(verifier, opts = {}) {
60
207
  if (opts.onDeny)
61
208
  opts.onDeny(result.reason ?? "schema_violation");
62
209
  res.setHeader("x-ainra-reason", result.reason ?? "schema_violation");
210
+ if (result.reason === "presentation_unknown") {
211
+ // Not a refusal of the agent: this gate simply does not hold the bundle the request names. 428 tells a client
212
+ // to send it (again) and retry, rather than to give up.
213
+ res.setHeader("link", `<${PRIME_PATH}>; rel="ainra-prime"`);
214
+ res.status(428).json({ error: "ainra: send the presentation first", reason: result.reason, prime: PRIME_PATH });
215
+ return;
216
+ }
63
217
  res.status(403).json({ error: "ainra: passport not valid", reason: result.reason });
64
218
  };
65
219
  }
220
+ /**
221
+ * M36 (D-065): the handler a running copy sends its bundle to, once. Mount it at `PRIME_PATH` with the same store
222
+ * as the gate. It verifies the bundle IN FULL — proof of possession included — and keeps only bundles that verify
223
+ * VALID, keyed by the digest of their stable part; it answers 201 with that digest. Sending a bundle grants nothing
224
+ * by itself: every request that names it must still verify, and, with `requireSignature`, be signed.
225
+ *
226
+ * ```ts
227
+ * const store = createPresentationStore();
228
+ * app.post(PRIME_PATH, express.json({ limit: "256kb" }), ainraPrime(verifier, { store }));
229
+ * app.use("/agent", ainraGate(verifier, { store, requireSignature: true, seenNonce }));
230
+ * ```
231
+ */
232
+ export function ainraPrime(verifier, opts) {
233
+ return (req, res) => {
234
+ const now = (opts.now ?? (() => Math.floor(Date.now() / 1000)))();
235
+ let body = req.body;
236
+ if ((body === undefined || typeof body !== "object") && req.rawBody) {
237
+ try {
238
+ body = JSON.parse(Buffer.from(req.rawBody).toString("utf8"));
239
+ }
240
+ catch {
241
+ body = undefined;
242
+ }
243
+ }
244
+ const deny = (reason) => {
245
+ if (opts.onDeny)
246
+ opts.onDeny(reason);
247
+ res.setHeader("x-ainra-reason", reason);
248
+ res.status(403).json({ error: "ainra: presentation not stored", reason });
249
+ };
250
+ if (!body || typeof body !== "object" || Array.isArray(body))
251
+ return deny("schema_violation");
252
+ if ((req.method ?? "POST").toUpperCase() !== "POST")
253
+ return deny("schema_violation");
254
+ const result = checkRequest(verifier, body, { now: () => now });
255
+ res.setHeader("x-ainra-verdict", serializeVerdictEvent(result.event));
256
+ if (!result.allow)
257
+ return deny(result.reason ?? "schema_violation");
258
+ const bundle = body;
259
+ const { stable } = splitPresentation(bundle);
260
+ const iexp = typeof bundle.instance?.exp === "number" ? bundle.instance.exp : undefined;
261
+ const expiresAt = iexp ?? now + DIRECT_PASSPORT_TTL_SECS;
262
+ const ref = presentationRef(bundle);
263
+ opts.store.put(ref, stable, expiresAt);
264
+ res.status(201).json({ ref, expires: expiresAt });
265
+ };
266
+ }
267
+ /**
268
+ * Connect/Express middleware in link mode: requests whose signing key is linked to an accountable operator, and not
269
+ * revoked, pass (with `req.ainraLink` attached); everything else gets 403 and a named reason, `not_linked` included.
270
+ * The Verifier carries the trust (directory, roots, freshness class), exactly as for passports.
271
+ */
272
+ export function ainraLinkGate(verifier, opts) {
273
+ const clock = opts.now ?? (() => Math.floor(Date.now() / 1000));
274
+ return async (req, res, next) => {
275
+ const t = clock();
276
+ let key = null;
277
+ try {
278
+ key = await opts.verifiedKey(req);
279
+ }
280
+ catch {
281
+ key = null;
282
+ }
283
+ let r;
284
+ let tp = null;
285
+ if (!key || key.length !== 32) {
286
+ r = { verdict: "invalid", reason: "sig_invalid" };
287
+ }
288
+ else {
289
+ tp = jwkThumbprintEd25519(key);
290
+ let link = null;
291
+ try {
292
+ const { index, status } = await opts.source();
293
+ link = linkFromIndex(index, status, tp);
294
+ }
295
+ catch {
296
+ link = null;
297
+ }
298
+ r = link ? verifier.verifyLink(link, key, t, opts.capability?.(req)) : { verdict: "invalid", reason: "not_linked" };
299
+ }
300
+ if (opts.decisions && tp) {
301
+ try {
302
+ const binding = bindingFromReq(req);
303
+ const cd = binding.headers["content-digest"];
304
+ opts.decisions.record({
305
+ at: t, kind: "link", subject: tp,
306
+ ...(r.verdict === "valid" ? { passport: r.passport, operator: r.operator } : {}),
307
+ verdict: r.verdict,
308
+ ...(r.verdict === "valid" ? {} : { reason: r.reason }),
309
+ request: decisionRequestDigest({ method: binding.method, authority: binding.authority, path: binding.path, contentDigest: Array.isArray(cd) ? cd[0] : cd }),
310
+ });
311
+ }
312
+ catch (e) {
313
+ console.error(`ainra: decision not recorded: ${e.message}`);
314
+ }
315
+ }
316
+ if (r.verdict === "valid") {
317
+ req.ainraLink = r;
318
+ next();
319
+ return;
320
+ }
321
+ opts.onDeny?.(r.reason);
322
+ res.setHeader("x-ainra-reason", r.reason);
323
+ res.status(403).json({ error: "ainra: key not attributable", reason: r.reason });
324
+ };
325
+ }
66
326
  export { Verifier, verdictEvent, serializeVerdictEvent } from "@ainra/sdk";
327
+ export { PRIME_PATH, POP_HEADER, presentationRef, isPresentationRef } from "@ainra/sdk";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ainra/middleware",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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,13 +14,13 @@
14
14
  "test": "node --test"
15
15
  },
16
16
  "dependencies": {
17
- "@ainra/sdk": "^0.4.0"
17
+ "@ainra/sdk": "^0.5.0"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@types/node": "^26.1.1",
21
21
  "typescript": "^5.6.0"
22
22
  },
23
- "homepage": "https://ainra.vercel.app/",
23
+ "homepage": "https://ainra.org/",
24
24
  "repository": {
25
25
  "type": "git",
26
26
  "url": "git+https://github.com/JacobJandon/ainra.git"