@mudraid/adapter-node 1.1.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.
@@ -0,0 +1,387 @@
1
+ /**
2
+ * V2 enforcement control loop — the adapter-decision semantics, natively in TS.
3
+ *
4
+ * This module encodes the portable adapter-decision contract
5
+ * (`mudraid.adapter.decision/1`) directly, so a Node platform reaches the SAME
6
+ * normalized outcome as the Kong `mudraid-enforce` Lua handler, the reference
7
+ * runner in `shared/mudraid_contracts`, and the Python middleware — for the same
8
+ * facts.
9
+ *
10
+ * The decision-tree order mirrors `handler.lua:access` (and the Python
11
+ * `_v2_control_loop.evaluate_v2`) EXACTLY:
12
+ *
13
+ * 1. reserved `x-mudraid-*` headers are stripped FIRST, before any evaluation,
14
+ * and even on requests that will be denied — trusted context is never
15
+ * accepted as an input fact;
16
+ * 2. fail CLOSED when no verified signed bundle is active → not_safely_decided;
17
+ * 3. method classification — control verbs pass, non-POST denies;
18
+ * 4. bounded JSON-RPC framing — oversized/unreadable bodies deny, a JSON
19
+ * *array* (batch) is rejected wholesale, never partially evaluated;
20
+ * 5. exact, case-sensitive canonical action resolution — never fuzzy/prefix;
21
+ * 6. a live `/decide` call, required for every protected tool invocation and
22
+ * **deny-closed** on timeout/error/unconfigured;
23
+ * 7. allow — and only then is trusted context injected downstream.
24
+ *
25
+ * "not safely decided" (no bundle, `/decide` timeout/error) is deny-closed,
26
+ * never optimistically treated as allow.
27
+ */
28
+
29
+ import { randomUUID } from 'node:crypto';
30
+
31
+ import {
32
+ MAX_TOOL_NAME_LEN,
33
+ RESERVED_HEADER_PREFIX,
34
+ type AdapterCode,
35
+ type Decision,
36
+ type DecideClient,
37
+ type Outcome,
38
+ type ReasonTier,
39
+ type RequestFacts,
40
+ type TrustedContextHeader,
41
+ } from './types.js';
42
+
43
+ /** Streamable-HTTP control verbs — carry no JSON-RPC request, cannot invoke a tool. */
44
+ const CONTROL_VERBS: ReadonlySet<string> = new Set(['GET', 'HEAD', 'OPTIONS', 'DELETE']);
45
+
46
+ /**
47
+ * Control/discovery JSON-RPC methods allowed to pass a protected surface without
48
+ * a tool invocation (mirrors the plugin `public_methods` default). Client
49
+ * `notifications/*` are handled by prefix, separately.
50
+ */
51
+ // Pre-launch scan SSC-05: `resources/list` and `prompts/list` used to be here
52
+ // while the Kong plugin and the Python middleware denied them, so the same
53
+ // client got 403 from one deployed adapter and a pass-through from another.
54
+ // Enumeration on a protected surface is disclosure; the conservative three
55
+ // are the contract, pinned by two corpus fixtures every runner consumes.
56
+ const DEFAULT_PUBLIC_METHODS: ReadonlySet<string> = new Set([
57
+ 'initialize',
58
+ 'ping',
59
+ 'tools/list',
60
+ ]);
61
+
62
+ /**
63
+ * `/decide` transport-failure mode → normalized not-safely-decided reason. Every
64
+ * one deny-closes; the reason differs by failure mode but the surfaced adapter
65
+ * code is uniformly `ENFORCE_DECIDE_UNAVAILABLE`.
66
+ */
67
+ const DECIDE_UNAVAILABLE_REASONS: Readonly<Record<string, string>> = {
68
+ timeout: 'deadline_exceeded',
69
+ error: 'authority_source_unavailable',
70
+ unreachable: 'authority_source_unavailable',
71
+ unconfigured: 'adapter_config_stale',
72
+ credential_unconfigured: 'adapter_config_stale',
73
+ };
74
+
75
+ /**
76
+ * Reasons that are NOT deny outcomes. A `/decide` response that tries to label a
77
+ * *deny* with one of these cannot leak an allow/soft outcome through the deny
78
+ * path; we fall back to the generic deny reason. This is a self-contained safety
79
+ * guard, deliberately NOT a mirror of the full governed reason-code registry
80
+ * (this SDK takes no dependency on the internal `mudraid_contracts` package),
81
+ * matching the Python middleware's guard. Extend only with reasons whose
82
+ * canonical outcome is provably not "deny".
83
+ */
84
+ const NON_DENY_REASONS: ReadonlySet<string> = new Set([
85
+ 'authorized', // allow
86
+ 'adapter_config_stale', // not_safely_decided
87
+ 'deadline_exceeded', // not_safely_decided
88
+ 'authority_source_unavailable', // not_safely_decided
89
+ ]);
90
+
91
+ const DECIDE_DENY_DEFAULT_REASON = 'policy_rule_denied';
92
+
93
+ const NO_TRUSTED_CONTEXT: readonly TrustedContextHeader[] = Object.freeze([]);
94
+
95
+ /**
96
+ * Reserved headers stripped from the request before evaluation (contract A03-04).
97
+ *
98
+ * Case-insensitive prefix match; applied on every protected request regardless
99
+ * of the eventual outcome (original case is preserved for auditability). On an
100
+ * unprotected surface the request passes through untouched, so nothing is
101
+ * stripped.
102
+ */
103
+ export function normalizeStrippedHeaders(
104
+ reservedHeadersPresented: readonly string[],
105
+ { protectedSurface }: { readonly protectedSurface: boolean },
106
+ ): readonly string[] {
107
+ if (!protectedSurface) {
108
+ return [];
109
+ }
110
+ return reservedHeadersPresented.filter((h) =>
111
+ h.toLowerCase().startsWith(RESERVED_HEADER_PREFIX),
112
+ );
113
+ }
114
+
115
+ /** True when a header name is a reserved `x-mudraid-*` context header. */
116
+ export function isReservedHeader(name: string): boolean {
117
+ return name.toLowerCase().startsWith(RESERVED_HEADER_PREFIX);
118
+ }
119
+
120
+ /** A usable canonical action name: non-empty, within the byte bound. */
121
+ export function validToolName(name: string | null | undefined): name is string {
122
+ if (typeof name !== 'string') {
123
+ return false;
124
+ }
125
+ const byteLen = Buffer.byteLength(name, 'utf-8');
126
+ return byteLen > 0 && byteLen <= MAX_TOOL_NAME_LEN;
127
+ }
128
+
129
+ /** A fresh correlation id for a bound allow, forwarded as trusted context. */
130
+ export function newDecisionId(): string {
131
+ return randomUUID();
132
+ }
133
+
134
+ function deny(
135
+ reasonCode: string,
136
+ tier: ReasonTier,
137
+ httpStatus: number,
138
+ adapterCode: AdapterCode,
139
+ message: string,
140
+ stripped: readonly string[],
141
+ outcome: Outcome = 'deny',
142
+ ): Decision {
143
+ return {
144
+ outcome,
145
+ reasonCode,
146
+ reasonTier: tier,
147
+ httpStatus,
148
+ adapterCode,
149
+ message,
150
+ strippedReservedHeaders: stripped,
151
+ trustedContext: NO_TRUSTED_CONTEXT,
152
+ };
153
+ }
154
+
155
+ function passThrough(reasonCode: string, stripped: readonly string[]): Decision {
156
+ return {
157
+ outcome: 'allow',
158
+ reasonCode,
159
+ reasonTier: 'transport',
160
+ httpStatus: 200,
161
+ adapterCode: null,
162
+ message: '',
163
+ strippedReservedHeaders: stripped,
164
+ trustedContext: NO_TRUSTED_CONTEXT,
165
+ };
166
+ }
167
+
168
+ /** Whether a decision means the request should forward to the wrapped handler. */
169
+ export function shouldForward(decision: Decision): boolean {
170
+ return decision.outcome === 'allow';
171
+ }
172
+
173
+ /**
174
+ * Reproduce the reference control loop's outcome for one request.
175
+ *
176
+ * `decide` is invoked EXACTLY at the `/decide` branch (a mapped `tools/call` on
177
+ * an active bundle) and nowhere else, so a control verb, an unmapped action, or
178
+ * a framing rejection never triggers a live call. Any transport error thrown by
179
+ * the injected client is treated as `"error"` and deny-closed. Any "not safely
180
+ * decided" state is deny-closed, never allow.
181
+ */
182
+ export async function evaluateV2(facts: RequestFacts, decide: DecideClient): Promise<Decision> {
183
+ const stripped = normalizeStrippedHeaders(facts.reservedHeadersPresented ?? [], {
184
+ protectedSurface: facts.protected,
185
+ });
186
+
187
+ // 1. Unprotected surface: pass through untouched (not a bundled surface).
188
+ if (!facts.protected) {
189
+ return passThrough('surface_not_protected', stripped);
190
+ }
191
+
192
+ // 2. Fail CLOSED: no verified signed bundle active → not_safely_decided.
193
+ if (!facts.bundleActive) {
194
+ return deny(
195
+ 'adapter_config_stale',
196
+ 'authorization',
197
+ 503,
198
+ 'ENFORCE_NO_VALID_BUNDLE',
199
+ 'no verified signed bundle is active; request cannot be safely decided',
200
+ stripped,
201
+ 'not_safely_decided',
202
+ );
203
+ }
204
+
205
+ // 3. Method classification. Control verbs pass; anything neither a control verb
206
+ // nor POST is denied.
207
+ const method = (facts.method ?? '').toUpperCase();
208
+ if (CONTROL_VERBS.has(method)) {
209
+ return passThrough('control_plane_passthrough', stripped);
210
+ }
211
+ if (method !== 'POST') {
212
+ return deny(
213
+ 'method_not_allowed',
214
+ 'transport',
215
+ 405,
216
+ 'ENFORCE_METHOD_NOT_ALLOWED',
217
+ 'method not allowed on a protected MCP surface',
218
+ stripped,
219
+ );
220
+ }
221
+
222
+ // 4. Bounded framing: oversized/unreadable bodies deny, never partial eval.
223
+ if (facts.bodyTooLarge === true) {
224
+ return deny(
225
+ 'body_too_large',
226
+ 'transport',
227
+ 413,
228
+ 'ENFORCE_BODY_TOO_LARGE',
229
+ 'request body exceeds the bounded framing limit',
230
+ stripped,
231
+ );
232
+ }
233
+ if (facts.bodyReadable === false) {
234
+ return deny(
235
+ 'body_unreadable',
236
+ 'transport',
237
+ 400,
238
+ 'ENFORCE_BODY_UNREADABLE',
239
+ 'request body could not be read',
240
+ stripped,
241
+ );
242
+ }
243
+
244
+ // 5. Parse exactly once. Non-object bodies (scalar / non-JSON) are malformed;
245
+ // a JSON *array* is a batch and rejected wholesale.
246
+ const jsonShape = facts.jsonShape ?? 'object';
247
+ if (jsonShape === 'array') {
248
+ return deny(
249
+ 'batch_unsupported',
250
+ 'transport',
251
+ 400,
252
+ 'ENFORCE_BATCH_UNSUPPORTED',
253
+ 'JSON-RPC batch requests are not supported',
254
+ stripped,
255
+ );
256
+ }
257
+ if (jsonShape !== 'object') {
258
+ return deny(
259
+ 'malformed_request',
260
+ 'transport',
261
+ 400,
262
+ 'ENFORCE_MALFORMED_REQUEST',
263
+ 'request body is not a single JSON-RPC 2.0 object',
264
+ stripped,
265
+ );
266
+ }
267
+ const rpcMethod = facts.rpcMethod;
268
+ if (facts.jsonrpc !== '2.0' || typeof rpcMethod !== 'string' || rpcMethod === '') {
269
+ return deny(
270
+ 'malformed_request',
271
+ 'transport',
272
+ 400,
273
+ 'ENFORCE_MALFORMED_REQUEST',
274
+ 'request body is not a single JSON-RPC 2.0 object',
275
+ stripped,
276
+ );
277
+ }
278
+
279
+ // 6. Non-tool protocol messages: allowlisted control/discovery + client
280
+ // notifications pass; everything else on a protected surface denies rather
281
+ // than slipping through because extraction found no action.
282
+ if (rpcMethod !== 'tools/call') {
283
+ if (rpcMethod.startsWith('notifications/')) {
284
+ return passThrough('notification_passthrough', stripped);
285
+ }
286
+ if (DEFAULT_PUBLIC_METHODS.has(rpcMethod)) {
287
+ return passThrough('control_plane_passthrough', stripped);
288
+ }
289
+ return deny(
290
+ 'message_not_allowed',
291
+ 'transport',
292
+ 403,
293
+ 'ENFORCE_MESSAGE_NOT_ALLOWED',
294
+ 'JSON-RPC method is not permitted on a protected surface',
295
+ stripped,
296
+ );
297
+ }
298
+
299
+ // 7. Exact canonical action resolution — never fuzzy.
300
+ if (!validToolName(facts.toolName)) {
301
+ return deny(
302
+ 'malformed_request',
303
+ 'transport',
304
+ 400,
305
+ 'ENFORCE_MALFORMED_REQUEST',
306
+ 'tools/call params.name is missing or exceeds the action-name bound',
307
+ stripped,
308
+ );
309
+ }
310
+ if (facts.actionMapped !== true) {
311
+ return deny(
312
+ 'action_unmapped',
313
+ 'authorization',
314
+ 403,
315
+ 'ENFORCE_ACTION_UNMAPPED',
316
+ 'no exact canonical action is mapped for this tool',
317
+ stripped,
318
+ );
319
+ }
320
+
321
+ // 8. Live /decide — required for every protected call; deny-closed on
322
+ // timeout/error/unconfigured. A thrown transport error is treated as
323
+ // "error" (deny-closed), never optimistically allowed.
324
+ const action = facts.action ?? facts.toolName;
325
+ let result;
326
+ try {
327
+ result = await decide(action);
328
+ } catch {
329
+ // No error detail is surfaced: a transport exception may carry secrets.
330
+ return deny(
331
+ 'authority_source_unavailable',
332
+ 'authorization',
333
+ 503,
334
+ 'ENFORCE_DECIDE_UNAVAILABLE',
335
+ 'the authority could not be reached; request cannot be safely decided',
336
+ stripped,
337
+ 'not_safely_decided',
338
+ );
339
+ }
340
+
341
+ if (result.status === 'allow') {
342
+ const trusted: readonly TrustedContextHeader[] = [
343
+ ['x-mudraid-action-key', action],
344
+ ['x-mudraid-decision-id', result.decisionId ?? newDecisionId()],
345
+ ];
346
+ return {
347
+ outcome: 'allow',
348
+ reasonCode: 'authorized',
349
+ reasonTier: 'authorization',
350
+ httpStatus: 200,
351
+ adapterCode: null,
352
+ message: '',
353
+ strippedReservedHeaders: stripped,
354
+ trustedContext: trusted,
355
+ };
356
+ }
357
+
358
+ if (result.status === 'deny') {
359
+ let reason = result.reason ?? DECIDE_DENY_DEFAULT_REASON;
360
+ // A decide-supplied reason that is not provably a deny code cannot leak an
361
+ // allow/soft outcome through the deny path.
362
+ if (NON_DENY_REASONS.has(reason)) {
363
+ reason = DECIDE_DENY_DEFAULT_REASON;
364
+ }
365
+ return deny(
366
+ reason,
367
+ 'authorization',
368
+ 403,
369
+ 'ENFORCE_DECISION_DENY',
370
+ 'the authority denied this action',
371
+ stripped,
372
+ );
373
+ }
374
+
375
+ // timeout | error | unreachable | unconfigured | credential_unconfigured
376
+ // → deny-closed 503.
377
+ const reason = DECIDE_UNAVAILABLE_REASONS[result.status] ?? 'authority_source_unavailable';
378
+ return deny(
379
+ reason,
380
+ 'authorization',
381
+ 503,
382
+ 'ENFORCE_DECIDE_UNAVAILABLE',
383
+ 'the authority could not be reached; request cannot be safely decided',
384
+ stripped,
385
+ 'not_safely_decided',
386
+ );
387
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `/decide` seam helpers.
3
+ *
4
+ * The REAL HTTP `/decide` client (authenticated call to the MudraID authority,
5
+ * with timeout/retry) is a DEFERRED remainder of EP-120-US-05. This module
6
+ * provides only the injectable seam and in-memory fakes, so the control loop can
7
+ * be exercised — and proven deny-closed — with no network.
8
+ */
9
+
10
+ import type { DecideClient, DecideResult } from './types.js';
11
+
12
+ /** A seam that always returns the same fixed `/decide` result. */
13
+ export function staticDecideClient(result: DecideResult): DecideClient {
14
+ return async () => result;
15
+ }
16
+
17
+ /**
18
+ * A seam that rejects (simulating a transport exception). The control loop
19
+ * catches this and deny-closes as `ENFORCE_DECIDE_UNAVAILABLE`.
20
+ */
21
+ export function throwingDecideClient(error?: unknown): DecideClient {
22
+ return async () => {
23
+ throw error ?? new Error('decide transport failure');
24
+ };
25
+ }
@@ -0,0 +1,43 @@
1
+ /** Exact request binding; supplied business values are not independent facts. */
2
+ import {createHash} from 'node:crypto';
3
+ import {canonicalJson, type JsonObject, type VerifiedBundle} from './signedBundle.js';
4
+
5
+ export interface ExecutionContext {
6
+ readonly presentedAuthorization: string;
7
+ readonly httpMethod: string;
8
+ readonly path: string;
9
+ readonly contentType: string;
10
+ readonly body: Uint8Array;
11
+ }
12
+
13
+ export const sha256 = (bytes: string | Uint8Array): string => createHash('sha256').update(bytes).digest('hex');
14
+
15
+ export function bindExecution(snapshot: VerifiedBundle, action: Readonly<JsonObject>, context: ExecutionContext): {digest: string; execution: JsonObject} {
16
+ if (!(context.body instanceof Uint8Array) || context.body.byteLength > 8 * 1024 * 1024) throw new Error('Invalid request body');
17
+ if (!context.contentType || context.contentType.length > 256 || /[\r\n]/.test(context.contentType)) throw new Error('Invalid content type');
18
+ let token = context.presentedAuthorization.trim();
19
+ if (token.toLowerCase().startsWith('bearer ')) token = token.slice(7).trim();
20
+ if (!token) throw new Error('Missing caller');
21
+ const scopes = action['required_scopes'];
22
+ if (!Array.isArray(scopes) || scopes.some(scope => typeof scope !== 'string' || !/^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope))) throw new Error('Invalid action scopes');
23
+ const material: JsonObject = {
24
+ profile: 'mudraid.execution.request/1', body_sha256: sha256(context.body),
25
+ content_type: context.contentType, http_method: context.httpMethod, path: context.path,
26
+ caller_token_sha256: sha256(token), platform_id: snapshot.surface['platform_id'],
27
+ environment: snapshot.surface['environment'], resource: snapshot.surface['canonical_resource_uri'],
28
+ action_key: action['action_key'], action_version: action['action_version'],
29
+ mapping_id: action['mapping_id'], mapping_version: action['mapping_revision'],
30
+ bundle_version: snapshot.version, bundle_payload_digest: snapshot.digest,
31
+ required_scopes: [...new Set(scopes)].sort(),
32
+ };
33
+ if (Object.values(material).some(value => value === undefined || value === null || value === '')) throw new Error('Incomplete execution binding');
34
+ return {digest: sha256(canonicalJson(material)), execution: {
35
+ profile: material['profile'], body_sha256: material['body_sha256'], content_type: material['content_type'],
36
+ ...(action['argument_profile'] == null ? {} : {body_base64: boundedArgumentBody(context.body)}),
37
+ }};
38
+ }
39
+
40
+ function boundedArgumentBody(body: Uint8Array): string {
41
+ if (body.byteLength === 0 || body.byteLength > 65536) throw new Error('Argument body exceeds bounds');
42
+ return Buffer.from(body).toString('base64');
43
+ }
@@ -0,0 +1,185 @@
1
+ /** Authenticated, bounded authority transport. No decision or upstream retries. */
2
+ import { randomUUID } from 'node:crypto';
3
+ import { instant, object, verifyBundle, verifyClaims, type BundleBinding, type JsonObject, type VerifiedBundle } from './signedBundle.js';
4
+ import type { DecideResult } from './types.js';
5
+
6
+ import {bindExecution, type ExecutionContext} from './executionBinding.js';
7
+ export type InvocationContext = ExecutionContext;
8
+ export interface AuthorityOptions {
9
+ readonly apiBase: string;
10
+ readonly adapterToken: string;
11
+ readonly binding: BundleBinding;
12
+ readonly timeoutMs?: number;
13
+ readonly adapterType?: 'node_server_adapter' | 'node_sidecar';
14
+ readonly fetch?: typeof fetch;
15
+ }
16
+
17
+ function keyMap(entries: unknown): Record<string, string> {
18
+ if (!Array.isArray(entries)) throw new Error('Invalid verification key set');
19
+ const keys: Record<string, string> = Object.create(null);
20
+ for (const entry of entries) {
21
+ const row = object(entry);
22
+ if (typeof row['key_id'] !== 'string' || typeof row['public_key_pem'] !== 'string' || Object.hasOwn(keys, row['key_id'])) throw new Error('Invalid verification key');
23
+ keys[row['key_id']] = row['public_key_pem'];
24
+ }
25
+ return keys;
26
+ }
27
+
28
+ export class HttpAuthority {
29
+ private readonly base: URL;
30
+ private readonly token: string;
31
+ private readonly binding: BundleBinding;
32
+ private readonly timeout: number;
33
+ private readonly adapterType: 'node_server_adapter' | 'node_sidecar';
34
+ private readonly fetcher: typeof fetch;
35
+ private current: VerifiedBundle | undefined;
36
+ private lastAccepted: VerifiedBundle | undefined;
37
+ private decisionKeys: Record<string, string> = Object.create(null);
38
+ private refreshPending: Promise<boolean> | undefined;
39
+ private observed: {version: number; digest: string; at: string} | undefined;
40
+
41
+ constructor(options: AuthorityOptions) {
42
+ this.base = new URL(options.apiBase);
43
+ if (this.base.protocol !== 'https:' || this.base.username || this.base.password || this.base.search || this.base.hash || this.base.pathname !== '/') throw new Error('Authority must be an HTTPS origin');
44
+ if (!options.adapterToken || options.adapterToken.length > 256 || /\s/.test(options.adapterToken)) throw new Error('Invalid adapter credential');
45
+ this.token = options.adapterToken;
46
+ this.adapterType = options.adapterType ?? 'node_server_adapter';
47
+ this.binding = Object.freeze({...options.binding});
48
+ this.timeout = options.timeoutMs ?? 5000;
49
+ if (!Number.isSafeInteger(this.timeout) || this.timeout < 1 || this.timeout > 30000) throw new Error('Invalid authority timeout');
50
+ this.fetcher = options.fetch ?? globalThis.fetch;
51
+ }
52
+
53
+ get bundle(): VerifiedBundle | undefined {
54
+ return this.current && this.current.expiresAt > Date.now() ? this.current : undefined;
55
+ }
56
+
57
+ private async request(path: string, method: string, body?: unknown, authenticated = true): Promise<JsonObject> {
58
+ const controller = new AbortController();
59
+ const timer = setTimeout(() => controller.abort(), this.timeout);
60
+ try {
61
+ const response = await this.fetcher(new URL(`/api/v1/adapter/enforcement/${path}`, this.base), {
62
+ method, redirect: 'error', signal: controller.signal,
63
+ headers: {Accept: 'application/json', ...(authenticated ? {Authorization: `Bearer ${this.token}`} : {}), ...(body === undefined ? {} : {'Content-Type': 'application/json'})},
64
+ ...(body === undefined ? {} : {body: JSON.stringify(body)}),
65
+ });
66
+ if (!response.ok || !response.body) throw new Error('Authority unavailable');
67
+ const reader = response.body.getReader();
68
+ const chunks: Uint8Array[] = [];
69
+ let size = 0;
70
+ try {
71
+ while (true) {
72
+ const item = await reader.read();
73
+ if (item.done) break;
74
+ size += item.value.byteLength;
75
+ if (size > 2 * 1024 * 1024) throw new Error('Authority response too large');
76
+ chunks.push(item.value);
77
+ }
78
+ } finally {
79
+ await reader.cancel();
80
+ }
81
+ return object(JSON.parse(Buffer.concat(chunks).toString('utf8')));
82
+ } finally {
83
+ clearTimeout(timer);
84
+ }
85
+ }
86
+
87
+ /** Single-flight refresh. A failed refresh never activates unverified data. */
88
+ refresh(): Promise<boolean> {
89
+ if (!this.refreshPending) this.refreshPending = this.refreshOnce().finally(() => { this.refreshPending = undefined; });
90
+ return this.refreshPending;
91
+ }
92
+
93
+ private async refreshOnce(): Promise<boolean> {
94
+ try {
95
+ const heartbeat = await this.request('heartbeat', 'POST');
96
+ if (heartbeat['platform_id'] !== this.binding.platformId) {
97
+ this.current = undefined;
98
+ return false;
99
+ }
100
+ const keyResponse = await this.request('keys', 'GET', undefined, false);
101
+ const bundleKeys = keyMap(keyResponse['keys']);
102
+ this.decisionKeys = Object.create(null);
103
+ if (Array.isArray(keyResponse['key_sets'])) {
104
+ for (const item of keyResponse['key_sets']) {
105
+ const set = object(item);
106
+ if (set['purpose'] === 'enforcement_decision_signing') this.decisionKeys = keyMap(set['keys']);
107
+ }
108
+ }
109
+ const served = await this.request('bundle', 'GET');
110
+ const verified = verifyBundle(served, bundleKeys, this.binding, this.lastAccepted);
111
+ // Heartbeat attribution is authoritative; a stale advertised bundle is not active.
112
+ if (heartbeat['desired_bundle_version'] !== verified.version || heartbeat['desired_payload_digest'] !== verified.digest) throw new Error('Desired bundle mismatch');
113
+ this.current = verified;
114
+ this.lastAccepted = verified;
115
+ const now = new Date().toISOString();
116
+ const observedAt = this.observed?.version === verified.version && this.observed.digest === verified.digest
117
+ ? this.observed.at : undefined;
118
+ await this.request('acknowledgements', 'POST', {
119
+ report_id: randomUUID(), received_version: verified.version, received_at: now,
120
+ validated_version: verified.version, validated_at: now, active_version: verified.version,
121
+ active_at: now, bundle_digest: verified.digest,
122
+ ...(observedAt === undefined ? {} : {first_observed_decision_at: observedAt}),
123
+ });
124
+ return true;
125
+ } catch {
126
+ // Fail closed until the next verified refresh; do not hide key revocation
127
+ // behind a cached bundle after an authoritative key-set replacement.
128
+ this.current = undefined;
129
+ return false;
130
+ }
131
+ }
132
+
133
+ async decide(toolName: string, context: InvocationContext, snapshot = this.bundle): Promise<DecideResult> {
134
+ if (!snapshot || snapshot !== this.bundle) return {status: 'unconfigured'};
135
+ const mapped = snapshot.actions[toolName];
136
+ if (!mapped) return {status: 'deny', reason: 'action_unmapped'};
137
+ if (!context.presentedAuthorization || context.presentedAuthorization.length > 8192) return {status: 'deny', reason: 'credential_missing'};
138
+ const decisionId = randomUUID();
139
+ const action = mapped['action_key'];
140
+ try {
141
+ const bound = bindExecution(snapshot, mapped, context);
142
+ const response = await this.request('decide', 'POST', {
143
+ schema_version: 'mudraid.enforce.decide-request/1', decision_id: decisionId,
144
+ adapter: {type: this.adapterType, version: '1.1.0'},
145
+ bundle: {version: snapshot.version, payload_digest: snapshot.digest},
146
+ surface: snapshot.surface, action: mapped,
147
+ request: {transport: 'mcp_streamable_http', http_method: context.httpMethod, path: context.path},
148
+ presented_authorization: context.presentedAuthorization, execution: bound.execution,
149
+ });
150
+ if (response['schema_version'] !== '2.0' || response['decision_id'] !== decisionId) throw new Error('Unbound decision');
151
+ const now = Date.now();
152
+ const decidedAt = instant(response['decided_at']);
153
+ if (decidedAt > now + 30000 || now - decidedAt > 60000) throw new Error('Stale decision');
154
+ if (instant(response['deadline_at']) <= now) throw new Error('Expired decision');
155
+ // This new runtime always requires signed decisions; there is no downgrade toggle.
156
+ const signature = object(response['signature']);
157
+ const claims = object(signature['claims']);
158
+ const keyId = signature['key_id'];
159
+ const profile = 'mudraid.decision.signature/1';
160
+ if (typeof keyId !== 'string' || !Object.hasOwn(this.decisionKeys, keyId) || signature['profile'] !== profile || signature['algorithm'] !== 'RS256' || claims['profile'] !== profile || claims['algorithm'] !== 'RS256' || claims['key_id'] !== keyId) throw new Error('Invalid decision signature');
161
+ verifyClaims(claims, signature['signature'], this.decisionKeys[keyId]);
162
+ if (claims['execution_request_digest'] !== bound.digest) throw new Error('Execution binding mismatch');
163
+ for (const field of ['decision_id', 'decision', 'outcome', 'decided_at', 'deadline_at']) {
164
+ if ((claims[field] ?? null) !== (response[field] ?? null)) throw new Error('Altered decision');
165
+ }
166
+ const reason = object(response['reason']);
167
+ if (claims['reason_primary'] !== reason['primary']) throw new Error('Altered reason');
168
+ for (const [field, expected] of Object.entries({platform_id: this.binding.platformId, environment: this.binding.environment, resource: this.binding.resource, action_key: action, bundle_version: snapshot.version})) {
169
+ if (claims[field] !== expected) throw new Error('Decision binding mismatch');
170
+ }
171
+ if (instant(claims['not_before']) > now + 30000 || instant(claims['expires_at']) <= now) throw new Error('Invalid decision window');
172
+ if (snapshot !== this.bundle) throw new Error('Bundle changed during decision');
173
+ if (response['decision'] !== 'allow' && response['decision'] !== 'deny') throw new Error('Invalid decision outcome');
174
+ // Observation is a verified decision, not proof that the application
175
+ // executed it. Report on the next refresh without delaying execution or
176
+ // replaying a decision. Keep at most one bundle's observation in memory.
177
+ if (this.observed?.version !== snapshot.version || this.observed.digest !== snapshot.digest) {
178
+ this.observed = {version: snapshot.version, digest: snapshot.digest, at: new Date(now).toISOString()};
179
+ }
180
+ return {status: response['decision'], decisionId, ...(typeof reason['primary'] === 'string' ? {reason: reason['primary']} : {})};
181
+ } catch {
182
+ return {status: 'error'};
183
+ }
184
+ }
185
+ }
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `@mudraid/adapter-node` — framework-neutral TypeScript/Node server adapter for
3
+ * MudraID enforcement (EP-120-US-05, first slice).
4
+ *
5
+ * Public surface: the typed decision vocabulary, the V2 control loop, and the
6
+ * injectable `/decide` seam. The framework hooks (Express/Fastify/MCP), the real
7
+ * HTTP `/decide` client, and live fact extraction are DEFERRED remainders.
8
+ */
9
+
10
+ export {
11
+ ADAPTER_DECISION_CONTRACT_VERSION,
12
+ MAX_TOOL_NAME_LEN,
13
+ RESERVED_HEADER_PREFIX,
14
+ type AdapterCode,
15
+ type Decision,
16
+ type DecideClient,
17
+ type DecideResult,
18
+ type DecideStatus,
19
+ type JsonShape,
20
+ type Outcome,
21
+ type ReasonTier,
22
+ type RequestFacts,
23
+ type TrustedContextHeader,
24
+ } from './types.js';
25
+
26
+ export {
27
+ evaluateV2,
28
+ isReservedHeader,
29
+ newDecisionId,
30
+ normalizeStrippedHeaders,
31
+ shouldForward,
32
+ validToolName,
33
+ } from './controlLoop.js';
34
+
35
+ export { staticDecideClient, throwingDecideClient } from './decideClient.js';
36
+
37
+ export { verifyBundle, type BundleBinding, type VerifiedBundle } from './signedBundle.js';
38
+ export { HttpAuthority, type AuthorityOptions, type InvocationContext } from './httpAuthority.js';