@didis/client 0.1.0 → 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,46 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@didis/client` are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the package uses [Semantic Versioning](https://semver.org/). Before 1.0.0, a minor version (0.x.0) may add features; breaking changes are called out under **Changed** with a migration note.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.2.1] - 2026-10-14
8
+
9
+ ### Added
10
+
11
+ - Guard option `capability` (on `check`, `wrap` and `withDidisGuard`): the DID.is capability a tool needs when it differs from the tool name. MCP and OpenAI tool names cannot contain `:`, so use `{ tool: "weather_forecast", capability: "weather:forecast" }`. Pins stay keyed by the tool name; DID.is is asked about the capability; the ALLOW cache is keyed by capability. Without `capability`, behaviour is unchanged (the tool name is the capability).
12
+ - `onDecision` events and `DidisGuardError.detail` now include `capability`.
13
+ - Examples for the MCP TypeScript SDK (`mcp-client.mjs`), LangChain (`langchain-tool.mjs`) and the OpenAI Agents SDK (`openai-agents-tool.mjs`).
14
+
15
+ ### Changed
16
+
17
+ - A capability containing `*`, empty segments or whitespace is refused with `GUARD_MISCONFIGURED` before any network call. Wildcards belong in delegations, not in tool calls.
18
+
19
+ ## [0.2.0] - 2026-10-13
20
+
21
+ ### Added
22
+
23
+ - Fail-closed tool guard: `createDidisGuard`, `withDidisGuard` and `DidisGuardError`. A guarded handler runs only when the tool definition still matches its pin and DID.is `verifyTool` answers `ALLOW` for the configured agent (and root, if set). Drift, `DENY`, network errors, `429` and unexpected answers all throw, and the handler does not run. See [Guard agent tools](https://docs.did.is/build/guard-tools/).
24
+ - Tool definition pinning: `canonicalJson` (RFC 8785 JCS), `toolDefinitionSha256`, `mcpInventoryHash` and `pinToolDefinitions`. Hashes are identical to the `definitionSha256` and `inventoryHash` values DID.is reports.
25
+ - `attestMcpPins(client, endpoint, pins)` compares a public MCP server, as observed by DID.is, with your pins.
26
+ - `verifyCredential` options `expectedNonce` and `trustedIssuerCertificates` (SD-JWT VC key binding and caller-chosen X.509 anchors), with the `CredentialVerifyOptions` type.
27
+ - Response types `IssuerTrust` and `SdJwtEvidence`, and the optional `issuerTrust` and `sdJwt` fields on `CredentialVerification`.
28
+
29
+ ### Fixed
30
+
31
+ - Node 18: WebCrypto is loaded from `node:crypto` when there is no global `crypto`. Before this, `verifyWebhookSignature` and `verifyCustomerMonitorSignature` failed on Node 18 with `crypto is not defined`.
32
+
33
+ ## [0.1.0] - 2026-10-05
34
+
35
+ ### Added
36
+
37
+ - First public release as `@didis/client`. Zero dependencies, ESM, Node 18+, Deno, Bun, browsers and edge runtimes.
38
+ - `DidisClient` for the DID.is public API (default base URL `https://did.is/api`): `health`, `resolve`, `resolveW3c`, `dereference`, `stream` (server-sent events), `history`, `diff`, `graph`, `verifyCredential`, `evaluatePolicy`, `inspectMcp`, `inspectA2a`, `verifyDelegation`, `authorize`, `verifyTool`, `fastVerifyDid` and `fastVerifyJws`.
39
+ - Self-hosted monitoring methods that need an admin token: `watch`, `watches`, `unwatch` and `events`.
40
+ - `DidisError` carrying the RFC 9457 problem body and `Retry-After`.
41
+ - Webhook helpers `verifyWebhookSignature` and `verifyCustomerMonitorSignature`.
42
+
43
+ [Unreleased]: https://github.com/did-is/didis-js/compare/v0.2.1...HEAD
44
+ [0.2.1]: https://github.com/did-is/didis-js/compare/v0.2.0...v0.2.1
45
+ [0.2.0]: https://github.com/did-is/didis-js/compare/v0.1.0...v0.2.0
46
+ [0.1.0]: https://github.com/did-is/didis-js/releases/tag/v0.1.0
package/README.md CHANGED
@@ -33,6 +33,13 @@ for await (const ev of didis.stream("did:web:identity.foundation")) {
33
33
 
34
34
  // Credentials: VC DM 2.0 / 1.1 with Data Integrity (eddsa-jcs-2022, ecdsa-jcs-2019) or VC-JOSE/VC-JWT.
35
35
  // RDFC suites, ecdsa-sd-2023 and bbs-2023 are reported as UNSUPPORTED, never as valid.
36
+ const credential = {
37
+ "@context": ["https://www.w3.org/ns/credentials/v2"],
38
+ type: ["VerifiableCredential"],
39
+ issuer: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
40
+ validFrom: "2026-01-01T00:00:00Z",
41
+ credentialSubject: { id: "did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLdmQR63zLFKfvUwi3" },
42
+ };
36
43
  const vc = await didis.verifyCredential(credential);
37
44
  console.log(vc.status, vc.headline);
38
45
 
@@ -44,6 +51,7 @@ const { evaluation } = await didis.evaluatePolicy("did:web:example.com", {
44
51
  // Agents.
45
52
  const mcp = await didis.inspectMcp("https://mcp.example.com/mcp");
46
53
  const card = await didis.inspectA2a("https://agent.example.com");
54
+ const chain: string[] = []; // delegation receipt JWS strings, root first
47
55
  const { authorization } = await didis.authorize(chain, "payments.refund", { trustedRoots: ["did:web:example.com"] });
48
56
 
49
57
  try {
@@ -55,9 +63,62 @@ try {
55
63
 
56
64
  Identifiers are percent-encoded exactly once by the client. Non-2xx responses throw `DidisError` with the RFC 9457 problem body (including the resolution `trace` where available).
57
65
 
66
+ ## Guard agent tools
67
+
68
+ Wrap a tool handler so it runs only when (1) the tool definition the agent received still matches the one you approved and (2) DID.is says the agent's registered delegation grants the tool. Every failure throws `DidisGuardError` and the handler does not run (fail-closed).
69
+
70
+ ```ts
71
+ import { DidisClient, createDidisGuard, pinToolDefinitions } from "@didis/client";
72
+
73
+ const didis = new DidisClient();
74
+ // Once, after reviewing the server's tools/list: store these pins in your config.
75
+ const pins = await pinToolDefinitions(reviewedTools);
76
+
77
+ const guard = createDidisGuard({
78
+ client: didis,
79
+ agent: process.env.AGENT_DID!, // from configuration, never from model output
80
+ root: process.env.ROOT_DID, // require this delegation root
81
+ pins,
82
+ requirePins: true,
83
+ });
84
+
85
+ const createIssue = guard.wrap(
86
+ {
87
+ tool: "github_create_issue", // the name in tools/list (pin key)
88
+ capability: "github:create_issue", // what the delegation must grant
89
+ definition: () => currentTools.get("github_create_issue"),
90
+ },
91
+ async (args) => github.createIssue(args),
92
+ );
93
+ ```
94
+
95
+ **Tool name vs capability.** MCP and OpenAI tool names may only contain letters, digits, `_`, `-` and `.`, so a tool cannot be called `github:create_issue`. DID.is capabilities use `:` namespaces so that `github:*` can grant a whole group. Pass both: `tool` is used for the pin and error messages, `capability` is what DID.is is asked about. Without `capability`, the tool name is used. A capability must be exact (no `*`, no empty segments, no spaces); an invalid one throws `GUARD_MISCONFIGURED` before any network call. Since 0.2.1.
96
+
97
+ Guarding MCP clients, LangChain and the OpenAI Agents SDK: see [`examples/`](examples/) and [Guard agent tools](https://docs.did.is/build/guard-tools/#use-with-mcp-and-agent-frameworks).
98
+
99
+ - The definition check is local (RFC 8785 JCS + SHA-256, the same `definitionSha256` DID.is reports) and makes no network call.
100
+ - The delegation check calls `verifyTool`. An ALLOW is reused for 30 s by default (`allowCacheMs`), never past the delegation expiry; DENY and errors are never cached.
101
+ - Error codes: `TOOL_DRIFT`, `TOOL_DEFINITION_MISSING`, `TOOL_NOT_AUTHORIZED`, `GUARD_UNAVAILABLE`, `GUARD_MISCONFIGURED`.
102
+ - One tool only: `withDidisGuard(handler, { client, agent, root, tool, capability, pin, definition })`.
103
+ - `onDecision` and `DidisGuardError.detail` include both `tool` and `capability`.
104
+ - `attestMcpPins(client, endpoint, pins)` asks DID.is to inspect a public MCP server and compares its hashes with your pins. Run it at startup or deploy time, not per call: it is an outbound, rate-limited request, and servers that need login or run on a private network cannot be inspected.
105
+
58
106
  ## Webhooks
59
107
 
60
- Monitoring routes require an admin token (`new DidisClient({ adminToken })`) and must only be used server-side. Deliveries are signed:
108
+ Console project monitor events use `verifyCustomerMonitorSignature`:
109
+
110
+ ```ts
111
+ import { verifyCustomerMonitorSignature } from "@didis/client";
112
+
113
+ const ok = await verifyCustomerMonitorSignature(
114
+ secret,
115
+ rawBody,
116
+ req.headers["x-didis-signature"],
117
+ { tenantId, projectId, eventId: req.headers["x-didis-event-id"] },
118
+ );
119
+ ```
120
+
121
+ For self-hosted admin daemon monitoring deliveries, use `verifyWebhookSignature`:
61
122
 
62
123
  ```ts
63
124
  import { verifyWebhookSignature } from "@didis/client";
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Fail-closed pre-execution guard for agent tools.
3
+ *
4
+ * Before a wrapped tool handler runs, the guard checks:
5
+ * 1. Definition pin (local, no network): the tool definition the agent actually received
6
+ * hashes to the SHA-256 you approved (same RFC 8785 JCS hash DID.is reports as
7
+ * `definitionSha256`). Catches MCP "rug-pull" drift, including servers DID.is cannot reach.
8
+ * 2. Delegation (DID.is `verify-tool`): the configured agent DID holds a registered, VALID
9
+ * delegation whose leaf capabilities grant the tool's capability. ALLOW is cached briefly;
10
+ * DENY and errors are never cached.
11
+ *
12
+ * Tool name vs capability: MCP tool names may only use `A-Z a-z 0-9 _ - .` (SEP-986), while
13
+ * DID.is capabilities are namespaced with `:` (e.g. `weather:forecast`). Pass both when they
14
+ * differ: `{ tool: "weather_forecast", capability: "weather:forecast" }`. Pins are keyed by the
15
+ * tool name (what `tools/list` returns); DID.is is asked about the capability. When
16
+ * `capability` is omitted, the tool name is used as the capability.
17
+ *
18
+ * Any failure (drift, DENY, network error, 429, malformed answer) throws `DidisGuardError`
19
+ * and the handler is not called. `attestMcpPins` is an optional, separate startup check that
20
+ * asks DID.is to fetch a public MCP server and compares its hashes with your pins; do not call
21
+ * it per tool call (it makes an outbound request and is rate limited).
22
+ */
23
+ import type { DidisClient, McpInspection } from "./index.js";
24
+ export type DidisGuardErrorCode = "TOOL_DRIFT" | "TOOL_DEFINITION_MISSING" | "TOOL_NOT_AUTHORIZED" | "GUARD_UNAVAILABLE" | "GUARD_MISCONFIGURED";
25
+ export declare class DidisGuardError extends Error {
26
+ readonly code: DidisGuardErrorCode;
27
+ readonly tool: string;
28
+ readonly detail?: Record<string, unknown>;
29
+ constructor(code: DidisGuardErrorCode, tool: string, message: string, detail?: Record<string, unknown>);
30
+ }
31
+ /** RFC 8785 (JCS) canonical JSON, matching the DID.is server's `rfc8785-binary64-v1` profile. */
32
+ export declare function canonicalJson(value: unknown): string;
33
+ /** SHA-256 of a tool definition exactly as an MCP server returned it in `tools/list`. */
34
+ export declare function toolDefinitionSha256(definition: unknown): Promise<string>;
35
+ /** Inventory hash of a `tools/list` result, identical to DID.is `inventoryHash`. */
36
+ export declare function mcpInventoryHash(tools: {
37
+ name?: unknown;
38
+ }[]): Promise<string>;
39
+ /** Builds `{ toolName: definitionSha256 }` pins from a `tools/list` result you have reviewed. */
40
+ export declare function pinToolDefinitions(tools: {
41
+ name?: unknown;
42
+ }[]): Promise<Record<string, string>>;
43
+ type Definition = unknown | (() => unknown | Promise<unknown>);
44
+ export interface DidisGuardOptions {
45
+ client: DidisClient;
46
+ /** The agent DID from your runtime configuration. Never take it from model output. */
47
+ agent: string;
48
+ /** Require the delegation chain to start at this root DID. Strongly recommended. */
49
+ root?: string;
50
+ /** Approved `{ toolName: definitionSha256 }`. A pinned tool must be given its definition. */
51
+ pins?: Record<string, string>;
52
+ /** Refuse tools that have no pin. Default `false`. */
53
+ requirePins?: boolean;
54
+ /** How long an ALLOW is reused (ms). Default 30 s; 0 disables. Capped by the delegation expiry. */
55
+ allowCacheMs?: number;
56
+ /** Called after every decision (for audit logs). Must not throw. */
57
+ onDecision?: (event: {
58
+ tool: string;
59
+ capability: string;
60
+ allowed: boolean;
61
+ code?: DidisGuardErrorCode;
62
+ reason: string;
63
+ cached: boolean;
64
+ }) => void;
65
+ }
66
+ export interface GuardCallOptions {
67
+ /** The tool name the agent sees (MCP `tools/list` name, pin key), e.g. `github_create_issue`. */
68
+ tool: string;
69
+ /**
70
+ * The DID.is capability this tool needs, e.g. `github:create_issue`. Defaults to `tool`.
71
+ * Must be an exact capability (no `*`): wildcards belong in delegations, not in tool calls.
72
+ */
73
+ capability?: string;
74
+ /** The definition the agent received, or a function returning the current one. */
75
+ definition?: Definition;
76
+ }
77
+ export interface DidisGuard {
78
+ /** Throws `DidisGuardError` unless the call may proceed. */
79
+ check(call: GuardCallOptions): Promise<void>;
80
+ /** Wraps a handler so it only runs after `check` passes. */
81
+ wrap<A extends unknown[], R>(call: GuardCallOptions, handler: (...args: A) => R | Promise<R>): (...args: A) => Promise<Awaited<R>>;
82
+ /** Drops cached ALLOW decisions (e.g. after revoking a delegation). */
83
+ clearCache(): void;
84
+ }
85
+ export declare function createDidisGuard(options: DidisGuardOptions): DidisGuard;
86
+ /**
87
+ * One-line form: `withDidisGuard(handler, { client, agent, root, tool, capability, pin, definition })`.
88
+ * For several tools, create one guard with `createDidisGuard` so the ALLOW cache is shared.
89
+ */
90
+ export declare function withDidisGuard<A extends unknown[], R>(handler: (...args: A) => R | Promise<R>, options: Omit<DidisGuardOptions, "pins"> & GuardCallOptions & {
91
+ pin?: string;
92
+ }): (...args: A) => Promise<Awaited<R>>;
93
+ export interface McpAttestation {
94
+ ok: boolean;
95
+ inventoryHash: string;
96
+ /** Pinned tools whose DID.is-observed hash differs, or that DID.is did not see. */
97
+ mismatched: {
98
+ tool: string;
99
+ expected: string;
100
+ observed?: string;
101
+ }[];
102
+ /** Tools DID.is saw that you have not pinned. */
103
+ unpinned: string[];
104
+ status: McpInspection["status"];
105
+ }
106
+ /**
107
+ * Optional startup check: asks DID.is to inspect a public MCP endpoint and compares the observed
108
+ * `definitionSha256` values with your pins. Use at deploy/startup, never per tool call.
109
+ * `AUTH_REQUIRED` or any non-INSPECTED status returns `ok: false`.
110
+ */
111
+ export declare function attestMcpPins(client: DidisClient, endpoint: string, pins: Record<string, string>): Promise<McpAttestation>;
112
+ export {};
package/dist/guard.js ADDED
@@ -0,0 +1,218 @@
1
+ import { subtle } from "./webcrypto.js";
2
+ export class DidisGuardError extends Error {
3
+ code;
4
+ tool;
5
+ detail;
6
+ constructor(code, tool, message, detail) {
7
+ super(`${code}: ${message}`);
8
+ this.name = "DidisGuardError";
9
+ this.code = code;
10
+ this.tool = tool;
11
+ this.detail = detail;
12
+ }
13
+ }
14
+ /** RFC 8785 (JCS) canonical JSON, matching the DID.is server's `rfc8785-binary64-v1` profile. */
15
+ export function canonicalJson(value) {
16
+ if (value === null)
17
+ return "null";
18
+ switch (typeof value) {
19
+ case "boolean":
20
+ return value ? "true" : "false";
21
+ case "number":
22
+ if (!Number.isFinite(value))
23
+ throw new TypeError("JCS: non-finite numbers are not JSON");
24
+ return Object.is(value, -0) ? "0" : String(value);
25
+ case "string":
26
+ return JSON.stringify(value);
27
+ case "object": {
28
+ if (Array.isArray(value))
29
+ return `[${value.map((v) => canonicalJson(v === undefined ? null : v)).join(",")}]`;
30
+ const obj = value;
31
+ const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort(); // UTF-16 code unit order
32
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
33
+ }
34
+ default:
35
+ throw new TypeError(`JCS: unsupported value of type ${typeof value}`);
36
+ }
37
+ }
38
+ async function sha256Hex(text) {
39
+ const digest = new Uint8Array(await (await subtle()).digest("SHA-256", new TextEncoder().encode(text)));
40
+ return Array.from(digest, (b) => b.toString(16).padStart(2, "0")).join("");
41
+ }
42
+ /** SHA-256 of a tool definition exactly as an MCP server returned it in `tools/list`. */
43
+ export function toolDefinitionSha256(definition) {
44
+ return sha256Hex(canonicalJson(definition));
45
+ }
46
+ function byteCompare(a, b) {
47
+ const x = new TextEncoder().encode(a);
48
+ const y = new TextEncoder().encode(b);
49
+ for (let i = 0; i < Math.min(x.length, y.length); i++)
50
+ if (x[i] !== y[i])
51
+ return x[i] - y[i];
52
+ return x.length - y.length;
53
+ }
54
+ /** Inventory hash of a `tools/list` result, identical to DID.is `inventoryHash`. */
55
+ export async function mcpInventoryHash(tools) {
56
+ const pairs = await Promise.all(tools.map(async (t) => [typeof t.name === "string" ? t.name : "", await toolDefinitionSha256(t)]));
57
+ pairs.sort((a, b) => byteCompare(a[0], b[0]));
58
+ return sha256Hex(canonicalJson(pairs));
59
+ }
60
+ /** Builds `{ toolName: definitionSha256 }` pins from a `tools/list` result you have reviewed. */
61
+ export async function pinToolDefinitions(tools) {
62
+ const pins = {};
63
+ for (const t of tools) {
64
+ if (typeof t.name !== "string" || !t.name)
65
+ throw new TypeError("every tool needs a string name");
66
+ if (t.name in pins)
67
+ throw new TypeError(`duplicate tool name '${t.name}'`);
68
+ pins[t.name] = await toolDefinitionSha256(t);
69
+ }
70
+ return pins;
71
+ }
72
+ const DID = /^did:[a-z0-9]+:\S+$/;
73
+ /** An exact DID.is capability: `:`-separated non-empty segments, no whitespace, no `*`. */
74
+ const CAPABILITY = /^[^\s:*]+(?::[^\s:*]+)*$/;
75
+ export function createDidisGuard(options) {
76
+ const { client, agent, root, pins = {}, requirePins = false, onDecision } = options;
77
+ const allowCacheMs = options.allowCacheMs ?? 30_000;
78
+ if (!client || typeof client.verifyTool !== "function")
79
+ throw new DidisGuardError("GUARD_MISCONFIGURED", "", "a DidisClient is required");
80
+ if (typeof agent !== "string" || !DID.test(agent))
81
+ throw new DidisGuardError("GUARD_MISCONFIGURED", "", "agent must be a DID");
82
+ if (root !== undefined && (typeof root !== "string" || !DID.test(root)))
83
+ throw new DidisGuardError("GUARD_MISCONFIGURED", "", "root must be a DID");
84
+ if (!Number.isFinite(allowCacheMs) || allowCacheMs < 0)
85
+ throw new DidisGuardError("GUARD_MISCONFIGURED", "", "allowCacheMs must be >= 0");
86
+ for (const [name, pin] of Object.entries(pins)) {
87
+ if (!/^[0-9a-f]{64}$/.test(pin))
88
+ throw new DidisGuardError("GUARD_MISCONFIGURED", name, "pins must be lowercase hex SHA-256");
89
+ }
90
+ const pinFor = (tool) => (Object.prototype.hasOwnProperty.call(pins, tool) ? pins[tool] : undefined);
91
+ const allowed = new Map();
92
+ const report = (tool, capability, ok, reason, cached, code) => {
93
+ try {
94
+ onDecision?.({ tool, capability, allowed: ok, code, reason, cached });
95
+ }
96
+ catch {
97
+ /* audit hooks must not change the decision */
98
+ }
99
+ };
100
+ const fail = (code, tool, capability, reason, detail) => {
101
+ report(tool, capability, false, reason, false, code);
102
+ throw new DidisGuardError(code, tool, reason, { ...detail, capability });
103
+ };
104
+ async function checkDefinition(tool, capability, definition) {
105
+ const pin = pinFor(tool);
106
+ if (pin === undefined) {
107
+ if (requirePins)
108
+ fail("TOOL_DEFINITION_MISSING", tool, capability, `no pinned definition for '${tool}'`);
109
+ return;
110
+ }
111
+ let def;
112
+ try {
113
+ def = typeof definition === "function" ? await definition() : definition;
114
+ }
115
+ catch {
116
+ return fail("TOOL_DEFINITION_MISSING", tool, capability, `could not read the current definition of '${tool}'`);
117
+ }
118
+ if (def === undefined || def === null)
119
+ fail("TOOL_DEFINITION_MISSING", tool, capability, `'${tool}' is pinned but no definition was supplied`);
120
+ let actual;
121
+ try {
122
+ actual = await toolDefinitionSha256(def);
123
+ }
124
+ catch {
125
+ return fail("TOOL_DRIFT", tool, capability, `the definition of '${tool}' is not valid JSON`);
126
+ }
127
+ if (def.name !== tool)
128
+ fail("TOOL_DRIFT", tool, capability, `definition name does not match '${tool}'`, { expected: pin, actual });
129
+ if (actual !== pin)
130
+ fail("TOOL_DRIFT", tool, capability, `definition of '${tool}' changed since it was approved`, { expected: pin, actual });
131
+ }
132
+ async function checkDelegation(tool, capability) {
133
+ const now = Date.now();
134
+ const until = allowed.get(capability);
135
+ if (until !== undefined && until > now) {
136
+ report(tool, capability, true, "cached ALLOW", true);
137
+ return;
138
+ }
139
+ allowed.delete(capability);
140
+ let auth;
141
+ try {
142
+ auth = await client.verifyTool(agent, capability, root);
143
+ }
144
+ catch (e) {
145
+ const status = e.status;
146
+ return fail("GUARD_UNAVAILABLE", tool, capability, `DID.is verify-tool failed${status ? ` (HTTP ${status})` : ""}`, { status });
147
+ }
148
+ const exact = auth && auth.decision === "ALLOW" && auth.authorized === true && auth.agent === agent && auth.tool === capability;
149
+ if (!exact) {
150
+ if (auth && (auth.decision === "DENY" || auth.authorized === false)) {
151
+ fail("TOOL_NOT_AUTHORIZED", tool, capability, auth.reason || "delegation does not grant this capability", { matchedCapability: auth.matchedCapability });
152
+ }
153
+ fail("GUARD_UNAVAILABLE", tool, capability, "DID.is returned an unexpected authorization answer");
154
+ }
155
+ if (root !== undefined && auth.root !== root)
156
+ fail("TOOL_NOT_AUTHORIZED", tool, capability, "delegation root does not match the configured root");
157
+ if (allowCacheMs > 0) {
158
+ const expires = auth.expires ? Date.parse(auth.expires) : NaN;
159
+ const until2 = Math.min(now + allowCacheMs, Number.isFinite(expires) ? expires : now + allowCacheMs);
160
+ if (until2 > now)
161
+ allowed.set(capability, until2);
162
+ }
163
+ report(tool, capability, true, auth.reason, false);
164
+ }
165
+ async function check({ tool, capability, definition }) {
166
+ if (typeof tool !== "string" || !tool.trim() || tool !== tool.trim())
167
+ fail("GUARD_MISCONFIGURED", String(tool), String(capability ?? tool), "tool must be a non-empty, trimmed name");
168
+ const cap = capability === undefined ? tool : capability;
169
+ if (typeof cap !== "string" || !CAPABILITY.test(cap)) {
170
+ fail("GUARD_MISCONFIGURED", tool, String(cap), "capability must be exact: ':'-separated non-empty segments, no spaces, no '*'");
171
+ }
172
+ await checkDefinition(tool, cap, definition);
173
+ await checkDelegation(tool, cap);
174
+ }
175
+ return {
176
+ check,
177
+ wrap(call, handler) {
178
+ if (typeof handler !== "function")
179
+ throw new DidisGuardError("GUARD_MISCONFIGURED", call?.tool ?? "", "handler must be a function");
180
+ return async (...args) => {
181
+ await check(call);
182
+ return await handler(...args);
183
+ };
184
+ },
185
+ clearCache() {
186
+ allowed.clear();
187
+ },
188
+ };
189
+ }
190
+ /**
191
+ * One-line form: `withDidisGuard(handler, { client, agent, root, tool, capability, pin, definition })`.
192
+ * For several tools, create one guard with `createDidisGuard` so the ALLOW cache is shared.
193
+ */
194
+ export function withDidisGuard(handler, options) {
195
+ const { tool, capability, definition, pin, ...rest } = options;
196
+ const guard = createDidisGuard({ ...rest, pins: pin ? { [tool]: pin } : undefined });
197
+ return guard.wrap({ tool, capability, definition }, handler);
198
+ }
199
+ /**
200
+ * Optional startup check: asks DID.is to inspect a public MCP endpoint and compares the observed
201
+ * `definitionSha256` values with your pins. Use at deploy/startup, never per tool call.
202
+ * `AUTH_REQUIRED` or any non-INSPECTED status returns `ok: false`.
203
+ */
204
+ export async function attestMcpPins(client, endpoint, pins) {
205
+ const inspection = await client.inspectMcp(endpoint);
206
+ const seen = new Map(inspection.tools.map((t) => [t.name, t.definitionSha256]));
207
+ const mismatched = Object.entries(pins)
208
+ .filter(([tool, expected]) => seen.get(tool) !== expected)
209
+ .map(([tool, expected]) => ({ tool, expected, observed: seen.get(tool) }));
210
+ const unpinned = [...seen.keys()].filter((t) => !Object.prototype.hasOwnProperty.call(pins, t));
211
+ return {
212
+ ok: inspection.status === "INSPECTED" && mismatched.length === 0,
213
+ inventoryHash: inspection.inventoryHash,
214
+ mismatched,
215
+ unpinned,
216
+ status: inspection.status,
217
+ };
218
+ }
package/dist/index.d.ts CHANGED
@@ -226,6 +226,35 @@ export interface Check {
226
226
  status: "PASS" | "FAIL" | "WARN" | "SKIP" | "UNSUPPORTED" | "INDETERMINATE" | string;
227
227
  detail: string;
228
228
  }
229
+ export interface IssuerTrust {
230
+ anchor: "DID" | "X509_CERTIFICATE" | "ISSUER_METADATA" | "NONE";
231
+ result: "NOT_APPLICABLE" | "NOT_ESTABLISHED" | "CALLER_ANCHORED" | "CHAIN_INVALID";
232
+ trustedListChecked: false;
233
+ chain?: {
234
+ sha256: string;
235
+ subjectCn?: string;
236
+ issuerCn?: string;
237
+ notAfter?: string;
238
+ }[];
239
+ metadataUrl?: string;
240
+ proves: string;
241
+ doesNotProve: string;
242
+ }
243
+ export interface SdJwtEvidence {
244
+ sdAlg: string;
245
+ disclosures: number;
246
+ decoyDigests: number;
247
+ registeredClaims: Record<string, unknown>;
248
+ keyBinding: "VERIFIED" | "ABSENT" | "INVALID" | "UNSUPPORTED" | "INDETERMINATE";
249
+ }
250
+ export interface CredentialVerifyOptions {
251
+ /** JWT `aud` / SD-JWT Key Binding `aud` constraint. */
252
+ expectedAudience?: string;
253
+ /** SD-JWT Key Binding `nonce` constraint; without it replay is not excluded. */
254
+ expectedNonce?: string;
255
+ /** Caller-chosen X.509 anchors (PEM or base64 DER) for SD-JWT VC `x5c`. Without them `x5c` is never VALID. */
256
+ trustedIssuerCertificates?: string[];
257
+ }
229
258
  export interface CredentialVerification {
230
259
  status: string;
231
260
  headline: string;
@@ -271,6 +300,11 @@ export interface CredentialVerification {
271
300
  domainBinding?: string;
272
301
  detail: string;
273
302
  };
303
+ /** SD-JWT VC only. Categorical, never a score; EU trusted lists are never checked. */
304
+ issuerTrust?: IssuerTrust;
305
+ /** SD-JWT VC only. No disclosed claim values are ever returned. */
306
+ sdJwt?: SdJwtEvidence;
307
+ /** For SD-JWT VC: disclosed claim paths and digests only, never values. */
274
308
  claims: unknown;
275
309
  credential: unknown;
276
310
  limitations: string[];
@@ -629,9 +663,7 @@ export declare class DidisClient {
629
663
  diff(did: string, from?: string, to?: string): Promise<SemanticDiff>;
630
664
  graph(did: string): Promise<GraphResponse>;
631
665
  /** Audience is checked only when an expected recipient is supplied. Pass raw JSON as a string to preserve member identity. */
632
- verifyCredential(credential: unknown, options?: {
633
- expectedAudience?: string;
634
- }): Promise<CredentialVerification>;
666
+ verifyCredential(credential: unknown, options?: CredentialVerifyOptions): Promise<CredentialVerification>;
635
667
  evaluatePolicy(did: string, policy: {
636
668
  name?: string;
637
669
  rules: PolicyRules;
@@ -674,3 +706,4 @@ export declare function verifyCustomerMonitorSignature(secret: string, body: str
674
706
  projectId: string;
675
707
  eventId: string;
676
708
  }, maxAgeSeconds?: number): Promise<boolean>;
709
+ export * from "./guard.js";
package/dist/index.js CHANGED
@@ -1,9 +1,4 @@
1
- /**
2
- * DID.is client SDK (TypeScript — Node 18+, Deno, Bun, browsers, edge runtimes).
3
- *
4
- * Every identifier is percent-encoded exactly once; the resolver decodes it exactly once.
5
- * Errors are thrown as `DidisError`, carrying the RFC 9457 problem object returned by the API.
6
- */
1
+ import { subtle } from "./webcrypto.js";
7
2
  // ---------------------------------------------------------------------------------------------
8
3
  // Client
9
4
  // ---------------------------------------------------------------------------------------------
@@ -137,7 +132,12 @@ export class DidisClient {
137
132
  }
138
133
  /** Audience is checked only when an expected recipient is supplied. Pass raw JSON as a string to preserve member identity. */
139
134
  verifyCredential(credential, options = {}) {
140
- return this.request("POST", "/v1/credentials/verify", { credential, expectedAudience: options.expectedAudience });
135
+ return this.request("POST", "/v1/credentials/verify", {
136
+ credential,
137
+ expectedAudience: options.expectedAudience,
138
+ expectedNonce: options.expectedNonce,
139
+ trustedIssuerCertificates: options.trustedIssuerCertificates,
140
+ });
141
141
  }
142
142
  evaluatePolicy(did, policy, credential) {
143
143
  return this.request("POST", "/v1/policies/evaluate", { did, policy, credential });
@@ -187,8 +187,8 @@ export async function verifyWebhookSignature(secret, timestamp, body, signatureH
187
187
  if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSeconds)
188
188
  return false;
189
189
  const te = new TextEncoder();
190
- const key = await crypto.subtle.importKey("raw", te.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
191
- const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, te.encode(`${timestamp}.${body}`)));
190
+ const key = await (await subtle()).importKey("raw", te.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
191
+ const mac = new Uint8Array(await (await subtle()).sign("HMAC", key, te.encode(`${timestamp}.${body}`)));
192
192
  const expected = `sha256=${Array.from(mac, (b) => b.toString(16).padStart(2, "0")).join("")}`;
193
193
  if (expected.length !== signatureHeader.length)
194
194
  return false;
@@ -209,9 +209,9 @@ export async function verifyCustomerMonitorSignature(secret, body, signature, ex
209
209
  if (!match || !Number.isSafeInteger(timestamp) || !Number.isInteger(maxAgeSeconds) || maxAgeSeconds < 1 || maxAgeSeconds > 30 * 86400 || timestamp > now + 60 || now - timestamp >= maxAgeSeconds || bytes.length > 300 * 1024 || !secret)
210
210
  return false;
211
211
  try {
212
- const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
212
+ const key = await (await subtle()).importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
213
213
  const mac = Uint8Array.from(match[2].match(/../g), value => parseInt(value, 16));
214
- if (!await crypto.subtle.verify("HMAC", key, mac, new TextEncoder().encode(`${match[1]}.${body}`)))
214
+ if (!await (await subtle()).verify("HMAC", key, mac, new TextEncoder().encode(`${match[1]}.${body}`)))
215
215
  return false;
216
216
  const event = JSON.parse(body);
217
217
  return event.specVersion === "didis.customer-monitor.v1" && event.createdAt === timestamp
@@ -223,3 +223,4 @@ export async function verifyCustomerMonitorSignature(secret, body, signature, ex
223
223
  return false;
224
224
  }
225
225
  }
226
+ export * from "./guard.js";
@@ -0,0 +1 @@
1
+ export declare function subtle(): Promise<SubtleCrypto>;
@@ -0,0 +1,16 @@
1
+ /** WebCrypto: global in Node 19+, browsers, Deno, Bun and edge; Node 18 needs `node:crypto`. */
2
+ let cached;
3
+ export function subtle() {
4
+ if (!cached) {
5
+ const g = globalThis.crypto;
6
+ cached = g?.subtle
7
+ ? Promise.resolve(g.subtle)
8
+ : import("node:crypto").then((m) => {
9
+ if (!m.webcrypto?.subtle)
10
+ throw new Error("WebCrypto is not available in this runtime");
11
+ return m.webcrypto.subtle;
12
+ });
13
+ cached.catch(() => { cached = undefined; });
14
+ }
15
+ return cached;
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@didis/client",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Official TypeScript/JavaScript SDK for the DID.is public API: DID resolution with evidence, credentials, policies and agent trust",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -15,7 +15,8 @@
15
15
  "files": [
16
16
  "dist",
17
17
  "README.md",
18
- "LICENSE"
18
+ "LICENSE",
19
+ "CHANGELOG.md"
19
20
  ],
20
21
  "sideEffects": false,
21
22
  "engines": {