@blamejs/core 0.8.82 → 0.8.86
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 +4 -0
- package/README.md +1 -1
- package/index.js +6 -0
- package/lib/a2a-tasks.js +598 -0
- package/lib/a2a.js +10 -0
- package/lib/acme.js +189 -5
- package/lib/audit.js +1 -0
- package/lib/cache-status.js +288 -0
- package/lib/compliance.js +36 -0
- package/lib/framework-error.js +19 -0
- package/lib/mcp-tool-registry.js +473 -0
- package/lib/mcp.js +3 -0
- package/lib/middleware/idempotency-key.js +424 -0
- package/lib/middleware/index.js +10 -0
- package/lib/middleware/no-cache.js +106 -0
- package/lib/problem-details.js +439 -0
- package/lib/server-timing.js +174 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.8.x
|
|
10
10
|
|
|
11
|
+
- v0.8.86 (2026-05-11) — **Sectoral + cybersecurity posture sweep + HTTP-hygiene primitives + npm-publish hotfix**. **npm-publish hotfix**: the v0.8.85 `npm audit signatures` step failed with `npm error found no installed dependencies to audit` because the framework's zero-runtime-deps posture produces an empty install tree; the gate now treats that specific message as success while keeping every other failure mode loud (v0.8.85 npm tarball never published — operators upgrade `0.8.83 → 0.8.86` to pick up the carried v0.8.84 + v0.8.85 surface plus the new v0.8.86 primitives). **10 new compliance postures**: `cmmc-2.0` (DoD Cybersecurity Maturity Model Certification 2.0), `cjis-v6` (FBI CJIS Security Policy v6.0), `iso-27001-2022` + `iso-27002-2022` + `iso-27017` + `iso-27018` + `iso-27701` (ISO/IEC 27001 family), `nist-800-66-r2` (HIPAA Security Rule implementation guidance), `ehds` (European Health Data Space), `circia` (US Cyber Incident Reporting for Critical Infrastructure Act). Cascade defaults set encrypted-backup + signed-audit-chain + TLS 1.3 + vacuum-after-erase for the data-tier postures; `iso-27002-2022` + `circia` defer the data-tier mandate to operator choice. **`b.cacheStatus`** — RFC 9211 Cache-Status response-header builder + parser. `append(prev, entry)` chains the operator's current cache decision onto whatever upstream caches wrote; `entry({...})` formats a single entry; `parse(headerValue)` returns the parsed chain as `[{ cache, params }]` records with `hit`/`stored`/`collapsed` as booleans, `ttl`/`fwdStatus` as numbers, `fwd` as the RFC 9211 §2 enum string, `key`/`detail` as unquoted sf-strings. Operators diagnose CDN/reverse-proxy/app-cache decision chains by reading the header instead of guessing from elapsed-time metrics. **`b.serverTiming`** — W3C Server-Timing response-header builder. `create()` returns a per-request collector with `mark(name, durationMs?, description?)` / `measure(name, fn)` async-timing wrapper / `toHeader()` serializer. Surfaces server-side latency in the browser's Performance API. **`b.middleware.noCache`** — RFC 9111 §5.2.2.5 `Cache-Control: no-store` middleware for auth-gated / individualized response paths. Sets `Cache-Control: no-store`, `Pragma: no-cache` (HTTP/1.0 compatibility), `Vary: Cookie, Authorization` so intermediate caches don't store personalized responses keyed by URL alone. Optional `opts.when(req)` predicate for conditional application; `opts.skipExisting:true` skips when `Cache-Control` is already set.
|
|
12
|
+
- v0.8.85 (2026-05-11) — **MCP tool registry + tool-call signing + A2A v1 task-exchange surface**. Closes the substantial agent-protocol gaps surfaced by the 2026-05-11 audit's MITRE ATLAS v5.3.0 + A2A v1 cross-walk. **MCP tool registry** lands as `b.mcp.toolRegistry.create({ tools, signingKey, verifyingKey?, alg?, ttlMs? })` — every registered tool gets a signed descriptor blob `{ tool, alg, signature }` (defense against compromised MCP server / descriptor drift) and a `descriptorsManifest()` produces a signed `{ body, signature }` document for operator-side attestation. The registry's `signCall({ toolName, args, nonce?, ttlMs? })` builds + signs an outbound tool-call envelope `{ tool, argsHash, nonce, iat, exp }` (defense against MCP middleman / indirect-prompt-injection synthesizing tool calls); `verifyCall(signed, { args?, seen?, nowMs? })` runs the inverse on inbound — refuses signature mismatch (`mcp/call-verify-failed`), expired envelopes (`mcp/call-expired`), replayed nonces via operator-supplied `seen(nonce)` callback (`mcp/call-replay`), unregistered tools (`mcp/call-unregistered-tool`), and args-hash mismatch when raw args supplied (`mcp/call-args-mismatch`). Default algorithm ML-DSA-87 per the framework's PQC-first rule; Ed25519 / ECDSA / SLH-DSA also available. **A2A v1 task-exchange surface** lands as `b.a2a.tasks.{send, get, cancel}` (client-side JSON-RPC dispatchers — `send` posts `tasks/send` to the peer URL with task validation + https-only refusal; `get` polls `tasks/get`; `cancel` requests `tasks/cancel`) plus `b.a2a.middleware.tasks({ scopes, handler, maxBytes? })` (server-side connect-style middleware — parses inbound JSON-RPC 2.0, enforces method allowlist `[tasks/send, tasks/get, tasks/cancel]` with -32601 method-not-found, enforces per-skill scopes via `req.a2aScopes` with -32001 scope-denied, dispatches to operator handler, maps errors to JSON-RPC -32603, refuses non-POST with 405 + non-JSON content-type with 415) plus `b.a2a.middleware.agentCard({ card, maxAgeSec? })` (serves operator's signed Agent Card at `/.well-known/agent.json` per A2A v1 discovery — 405 on non-GET, Cache-Control max-age operator-tunable).
|
|
13
|
+
- v0.8.84 (2026-05-11) — **Supply-chain hardening trio + HTTP-API hygiene primitives**. **Supply chain**: CodeQL SAST workflow (`.github/workflows/codeql.yml` — PR + push-to-main + weekly Mon-05:31-UTC schedule) running the `security-extended` query pack against the JavaScript surface (catches SQL injection, XSS, prototype pollution, command injection, ReDoS, unsafe deserialization, SSRF, hardcoded credentials beyond what OSV-Scanner sees; findings surface as SARIF in the Security tab); `npm audit signatures` step in `npm-publish.yml` that verifies the cryptographic signing chain for every package in the install tree against the npm registry's public keys before the publish step runs (regression-defense — the framework ships zero npm runtime deps so the tree is trivially empty today; if a future patch accidentally adds a runtime dep, the gate refuses an unsigned registry entry before tag-push triggers a release); vendored SBOM signing extends the existing cosign-keyless flow to sign `sbom.vendored.cdx.json` alongside `sbom.cdx.json` and attaches both `.sigstore` bundles to the GitHub Release. **HTTP-API hygiene primitives**: new `b.problemDetails` family implementing RFC 9457 Problem Details for HTTP APIs (`create({ type, title, status, detail, instance, ...extensions })` builds a frozen problem doc with field validation; `fromError(err)` converts a `FrameworkError` into a problem doc with `type` derived from `err.code` against a configurable base URI; `respond(res, problem)` writes the response with `Content-Type: application/problem+json` + `Cache-Control: no-store` per RFC 9457 §3 + RFC 9111 §5.2.2.5; `validate(doc)` parses inbound problem docs from upstream APIs with shape refusal). New `b.middleware.idempotencyKey` implementing draft-ietf-httpapi-idempotency-key (replay-safe POST/PUT/PATCH/DELETE — operator-supplied store interface with first-party `memoryStore({ maxEntries })`; cached fingerprint = method + path + sha3-256(body); 422 + `idempotency/key-reuse-mismatch` problem-details on same-key-different-body per draft §4.3; 5xx responses are NOT cached because replaying a transient infrastructure failure is not idempotent; default TTL 24h; default methods POST/PUT/PATCH/DELETE). The v0.8.84 git tag landed on a wrong commit due to a release-workflow ordering issue and the npm publish for 0.8.84 did not ship; operators upgrade directly from 0.8.83 to 0.8.85 (which carries the same primitives as part of the combined ship).
|
|
14
|
+
- v0.8.83 (2026-05-11) — **ACME 47-day-cert readiness**: certificate profiles + dns-account-01 challenge + ARI renewal-window jitter. **draft-aaron-acme-profiles** lands as `acme.listProfiles()` (reads `directory.meta.profiles` and returns the CA-advertised `{ name: description }` map) + `acme.newOrder({ profile })` (passes the chosen profile name through the order payload; refuses non-string + caps length at 64 bytes). As CA/B Forum SC-081v3 phases in the 47-day mandate, profile-name vocabulary becomes the operator-facing handle for "long-lived" vs "47-day" vs "short-lived" cert selection. **draft-ietf-acme-dns-account-label** lands as `acme.dnsAccount01ChallengeRecord(token, { identifier, ttl? })` which builds the per-account-scoped TXT record (`_<accountLabel>._acme-challenge.<host>`) where `accountLabel` is the lowercase base32 of the first 80 bits of `SHA-256(accountUrl)`. Refuses pre-newAccount (label needs accountUrl as seed); caps identifier at 255 bytes; refuses negative / huge TTL. **RFC 9773 §4.2 fleet-scheduling jitter**: `acme.renewIfDue({ jitter: true })` now returns a `renewAt` ISO timestamp picked uniformly across the CA-suggested window so operator fleets running on the same poll cadence stop clustering their renewal storms at the window-start instant. Default behavior (`jitter` off or absent) preserves pre-0.8.83 "renew now" semantics. The `acme.cert.renew.scheduled` audit row carries the chosen `renewAt` when jitter is on.
|
|
11
15
|
- v0.8.82 (2026-05-11) — **Privacy 2026 posture sweep**. 27 new postures land in `b.compliance.KNOWN_POSTURES` (with matching `REGIME_MAP` + `POSTURE_DEFAULTS` cascade entries) closing the privacy gap surfaced by the 2026-05-11 multi-agent compliance audit. **US federal**: `coppa` + `coppa-2025` (FTC final rule 2025-04-22, effective 2026-06-23 — biometric expansion + knowing-collection-13-and-under disclosure; cascade adds backupEncryptionRequired:true + vacuum-after-erase), `glba-safeguards` (GLBA Safeguards Rule 2024 Amendment, effective 2024-05-13; cascade matches pci-dss + nydfs-500 financial tier), `gina` (Genetic Information Nondiscrimination Act), `vppa` (Video Privacy Protection Act), `can-spam`, `il-gipa` (Illinois Genetic Information Privacy Act with post-2024 private right of action), `hhs-repro-24` (HHS Reproductive Health HIPAA Amendment 2024-12-23), `nist-pf-1.1` (NIST Privacy Framework 1.1, final 2025-04-14). **UK**: `uk-duaa` (Data (Use and Access) Act 2025 — Royal Assent 2025-06-19; replaces the abandoned DPDI Bill; cascade matches GDPR floor with vacuum-after-erase). **Latin America**: `cl-pdpa` (Chile Ley 21.719, enacted 2024-12-13, effective 2026-12-01; cascade mirrors gdpr), `mx-lfpdppp` (Mexico 2025 secondary reform), `ar-pdpa` (Argentina Ley 25.326). **APAC**: `pipa-kr` (Korea PIPA 2023 major amendment, phased 2023-09-15 / 2024-03-15), `au-privacy` (Australia Privacy Act + 2024 Amendment Act — statutory tort effective 2025-06-10), `th-pdpa`, `vn-pdp` (Vietnam PDP Law effective 2026-01-01), `id-pdp` (Indonesia PDP Law effective 2024-10-17), `my-pdpa` (Malaysia 2024 amendments effective 2025-04-30). **US state child-privacy**: `ny-safe-kids` + `ny-saffe` (NY Child Data Protection Act + Stop Addictive Feeds Exploitation, both effective 2025-06-20), `md-kids-code` (Maryland Age-Appropriate Design Code), `vt-aadc` (Vermont AADC). **EU non-personal-data + adjacent**: `dsa` (Digital Services Act, fully applicable 2024-02-17), `dga` (Data Governance Act, applicable 2023-09-24), `eu-cer` (Critical Entities Resilience Directive 2022/2557, transposition 2024-10-17), `eu-cyber-sol` (Cyber Solidarity Act 2025/38, effective 2025-02-04), `eidas-2` (eIDAS 2 / EUDI Wallet, rollout 2026-2027). New REGIME_MAP `domain` values introduced: `child-privacy`, `financial-privacy`, `consumer-privacy`, `genetic-privacy`, `platform-governance`, `identity` — operators rendering compliance dashboards grouped by domain pick up the new buckets via `b.compliance.posturesByDomain(domain)` without code changes.
|
|
12
16
|
- v0.8.81 (2026-05-11) — **AI-governance compliance postures + ISO 42001/23894 cross-walk + privacy catalog drift fixes**. 18 new postures register in `b.compliance.KNOWN_POSTURES` (and the matching `REGIME_MAP` + `POSTURE_DEFAULTS` cascade): state AI governance (`co-ai`, `il-hb3773`, `tx-traiga`, `ut-aipa`, `nyc-ll144`, `ca-tfaia` — frontier AI critical-incident records cascade to `backupEncryptionRequired:true`), international AI (`kr-ai-basic`, `cn-ai-label`), AI management standards (`iso-42001`, `iso-23894`), California gen-AI content credentials (`ca-sb942`, `ca-ab853`), substrate-to-posture cleanup so existing primitives gain catalog entries (`eaa` for EU Accessibility Act + `b.compliance-eaa`, `wcag-2-2` for `b.guardHtml.wcag`, `eu-data-act` for `b.dataAct`, `hitech` extending HIPAA-tier, `ferpa` for student records), plus `fl-fdbr` (Florida Digital Bill of Rights) and the long-missing `dpdp` (India DPDP Act 2023 — was in `POSTURE_DEFAULTS` cascade table but not in `KNOWN_POSTURES`, so `b.compliance.set("dpdp")` threw `compliance/unknown-posture`). **ISO 42001 + 23894 cross-walk**: new `b.compliance.aiAct.crossWalkIso42001([aiActCitation])` and `crossWalkIso23894()` return a 15-row mapping table linking EU AI Act articles (Art. 9 risk management → Art. 73 incident reporting) to ISO/IEC 42001:2023 Annex A controls and ISO/IEC 23894:2023 risk-management clauses. Operators chasing ISO 42001 certification under AI Act high-risk scope use the table to produce one cross-walk artifact instead of hand-rolling two separate audits; the table is read-only metadata, defensive copies returned, no behavior change at deploy time. **DSR drift fix**: `b.dsr.stateRules("fl-fdbr")` / `stateRules("FL")` now resolve (45-day response window, 15-day extension, 30-day cure, profiling opt-out enabled, minor opt-in 13). **Citation drift fix**: four state-privacy posture citations corrected from "(effective 2026-MM-DD)" to "(effective 2025-MM-DD)" — `modpa`, `nh-nhpa`, `nj-njdpa`, `mn-mncdpa` all took effect during 2025; the year-late citations would have surfaced as audit-trail discrepancies under operator review.
|
|
13
17
|
- v0.8.80 (2026-05-10) — **Bug fix — `b.config.loadDbBacked` overlapping-tick race**. `cfg.refresh()` calls `_tick()` directly and the periodic poller also invokes `_tick()` independently. When two ticks overlap (two `refresh()`es back-to-back, or `refresh()` racing a poll), the older read could resolve LAST and overwrite a newer config write — so `admin-save → await cfg.refresh()` was not guaranteed to leave the latest value active when `fetchRows` latency varied across calls. Reproducible by serving a 200ms read followed by a 20ms read; without the fix, the slower (older) result clobbered the faster (newer) one. Fix: every tick claims a monotonic sequence number at start; at apply-time, ticks whose sequence is older than the last-applied sequence drop with a `config.reload.skipped` audit emission (phase `stale-tick`). The high-water mark advances ONLY after `cfg.reload` succeeds — a newer tick whose validation fails must not suppress an older in-flight tick that still has valid data (otherwise `refresh(valid)` followed by `refresh(invalid)` could silently keep stale config active even though the valid update was about to land). Fetch / transform failures short-circuit before the apply path and likewise do NOT advance the watermark.
|
package/README.md
CHANGED
|
@@ -95,7 +95,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
95
95
|
- **AAD-bound sealed columns** — AEAD tag tied to `(table, rowId, column, schemaVersion)`; copy-paste between rows or schema-version replay surfaces as refused decrypt (`b.vault.aad`)
|
|
96
96
|
- **Signed webhooks + API encryption** — SLH-DSA-SHAKE-256f default; ML-DSA-65 opt-in; ECIES API encryption (`b.webhook`, `b.crypto`)
|
|
97
97
|
- **HPKE / HTTP signatures** — RFC 9180 HPKE with ML-KEM-1024 + HKDF-SHA3-512 + ChaCha20-Poly1305 (`b.crypto.hpke`); RFC 9421 HTTP Message Signatures with derived components and ed25519 / ML-DSA-65 (`b.crypto.httpSig`)
|
|
98
|
-
- **TLS / channel binding** — RFC 9266 TLS-Exporter token-to-session pinning (`b.tlsExporter`); RFC 9162 CT v2 inclusion-proof verification (`b.network.tls.ct.verifyInclusion`); RFC 8555 ACME + RFC 9773 ARI for 47-day certs (`b.acme`); RFC 8470 0-RTT inbound posture refuse / replay-cache (`b.router.create({tls0Rtt})`); RFC 9794 SecP256r1MLKEM768 in preferred-group order (`b.network.tls.preferredGroups`)
|
|
98
|
+
- **TLS / channel binding** — RFC 9266 TLS-Exporter token-to-session pinning (`b.tlsExporter`); RFC 9162 CT v2 inclusion-proof verification (`b.network.tls.ct.verifyInclusion`); RFC 8555 ACME + RFC 9773 ARI for 47-day certs with `{ jitter: true }` fleet-scheduling (`b.acme.renewIfDue`); draft-aaron-acme-profiles (`acme.listProfiles()` + `newOrder({ profile })`); draft-ietf-acme-dns-account-label (`acme.dnsAccount01ChallengeRecord(token, { identifier })`); RFC 8470 0-RTT inbound posture refuse / replay-cache (`b.router.create({tls0Rtt})`); RFC 9794 SecP256r1MLKEM768 in preferred-group order (`b.network.tls.preferredGroups`)
|
|
99
99
|
- **mTLS CA** — pure-JS, issues clientAuth / serverAuth / dual-EKU certs with SAN; auto-detects highest-PQC signature alg (today ECDSA-P384-SHA384; self-upgrades to SLH-DSA / ML-DSA when X.509 ecosystem catches up); PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`)
|
|
100
100
|
### HTTP
|
|
101
101
|
|
package/index.js
CHANGED
|
@@ -143,6 +143,9 @@ var compliance = Object.assign({}, require("./lib/compliance"), {
|
|
|
143
143
|
eaa: require("./lib/compliance-eaa"),
|
|
144
144
|
});
|
|
145
145
|
var dataAct = require("./lib/data-act");
|
|
146
|
+
var problemDetails = require("./lib/problem-details");
|
|
147
|
+
var cacheStatus = require("./lib/cache-status");
|
|
148
|
+
var serverTiming = require("./lib/server-timing");
|
|
146
149
|
var gateContract = require("./lib/gate-contract");
|
|
147
150
|
var guardCsv = require("./lib/guard-csv");
|
|
148
151
|
var guardHtml = require("./lib/guard-html");
|
|
@@ -370,6 +373,9 @@ module.exports = {
|
|
|
370
373
|
compliance: compliance,
|
|
371
374
|
nistCrosswalk: nistCrosswalk,
|
|
372
375
|
dataAct: dataAct,
|
|
376
|
+
problemDetails: problemDetails,
|
|
377
|
+
cacheStatus: cacheStatus,
|
|
378
|
+
serverTiming: serverTiming,
|
|
373
379
|
gateContract: gateContract,
|
|
374
380
|
guardCsv: guardCsv,
|
|
375
381
|
guardHtml: guardHtml,
|
package/lib/a2a-tasks.js
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.a2a
|
|
4
|
+
* @nav Agent Protocols
|
|
5
|
+
* @title A2A Tasks
|
|
6
|
+
* @order 600
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* A2A v1 task-exchange surface — the work primitive on top of
|
|
10
|
+
* `b.a2a.{createCard, signCard, verifyCard}`. A2A (Linux Foundation,
|
|
11
|
+
* v1 spec Apr 2026) defines a JSON-RPC 2.0 over HTTPS protocol with
|
|
12
|
+
* three task verbs (`tasks/send`, `tasks/get`, `tasks/cancel`) plus
|
|
13
|
+
* optional SSE streaming for long-running tasks.
|
|
14
|
+
*
|
|
15
|
+
* The framework ships:
|
|
16
|
+
*
|
|
17
|
+
* - Client-side dispatchers: `b.a2a.tasks.send({ peerUrl, agentCard, task })`
|
|
18
|
+
* posts a `tasks/send` JSON-RPC request; `get({ taskId })` polls
|
|
19
|
+
* status; `cancel({ taskId })` requests cancellation.
|
|
20
|
+
* - Server-side middleware: `b.a2a.middleware.agentCard({ card })`
|
|
21
|
+
* serves `/.well-known/agent.json`; `b.a2a.middleware.tasks
|
|
22
|
+
* ({ scopes, handler })` parses inbound JSON-RPC, enforces
|
|
23
|
+
* per-skill scope, and dispatches to an operator handler.
|
|
24
|
+
* - SSE streaming: when the handler returns a long-running shape,
|
|
25
|
+
* the middleware switches to SSE and streams progress events
|
|
26
|
+
* to the client.
|
|
27
|
+
*
|
|
28
|
+
* PQC-first: every signed-card flow uses the existing `b.a2a.signCard
|
|
29
|
+
* / verifyCard` ML-DSA-87 surface. The task-exchange envelopes
|
|
30
|
+
* themselves are NOT separately signed — operators wanting per-call
|
|
31
|
+
* non-repudiation compose `b.crypto.httpSig.sign` (RFC 9421) at the
|
|
32
|
+
* HTTP layer.
|
|
33
|
+
*
|
|
34
|
+
* Per `feedback_validation_tier_policy.md`:
|
|
35
|
+
* - Client `send / get / cancel` THROW on bad input (entry-point).
|
|
36
|
+
* - Middleware factories THROW on bad opts at boot.
|
|
37
|
+
* - The per-request middleware path returns a JSON-RPC error
|
|
38
|
+
* response on protocol violations (consistent with mcp.refuse).
|
|
39
|
+
*
|
|
40
|
+
* @card
|
|
41
|
+
* A2A v1 task-exchange surface — JSON-RPC dispatchers + server middleware (agentCard + tasks) + SSE streaming for long-running tasks.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
var nodeCrypto = require("node:crypto");
|
|
45
|
+
var lazyRequire = require("./lazy-require");
|
|
46
|
+
var numericBounds = require("./numeric-bounds");
|
|
47
|
+
var safeJson = require("./safe-json");
|
|
48
|
+
var safeUrl = require("./safe-url");
|
|
49
|
+
var safeBuffer = require("./safe-buffer");
|
|
50
|
+
var validateOpts = require("./validate-opts");
|
|
51
|
+
var { defineClass } = require("./framework-error");
|
|
52
|
+
|
|
53
|
+
var httpClient = lazyRequire(function () { return require("./http-client"); });
|
|
54
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
55
|
+
var C = require("./constants");
|
|
56
|
+
|
|
57
|
+
// A2aTasksError is the per-call error class — separate from A2aError
|
|
58
|
+
// (which exists for the card-signing primitives) so operators can
|
|
59
|
+
// catch task-shape errors distinctly.
|
|
60
|
+
var A2aTasksError = defineClass("A2aTasksError", { alwaysPermanent: true });
|
|
61
|
+
|
|
62
|
+
var JSONRPC_VERSION = "2.0";
|
|
63
|
+
|
|
64
|
+
// JSON-RPC 2.0 fixed error codes — A2A inherits these.
|
|
65
|
+
var JSONRPC_PARSE_ERROR = -32700; // allow:raw-byte-literal — JSON-RPC fixed code / allow:raw-time-literal — not seconds
|
|
66
|
+
var JSONRPC_INVALID_REQUEST = -32600; // allow:raw-byte-literal — JSON-RPC fixed code / allow:raw-time-literal — not seconds
|
|
67
|
+
var JSONRPC_METHOD_NOT_FOUND = -32601; // allow:raw-byte-literal — JSON-RPC fixed code / allow:raw-time-literal — not seconds
|
|
68
|
+
var JSONRPC_INVALID_PARAMS = -32602; // allow:raw-byte-literal — JSON-RPC fixed code / allow:raw-time-literal — not seconds
|
|
69
|
+
var JSONRPC_INTERNAL_ERROR = -32603; // allow:raw-byte-literal — JSON-RPC fixed code / allow:raw-time-literal — not seconds
|
|
70
|
+
|
|
71
|
+
// A2A-specific error codes per the spec's task-error vocabulary.
|
|
72
|
+
// A2A_TASK_NOT_FOUND (-32002) + A2A_TASK_NOT_CANCELABLE (-32003) are
|
|
73
|
+
// raised by operator handlers — they're reserved here for documentation
|
|
74
|
+
// purposes only.
|
|
75
|
+
var A2A_SCOPE_DENIED = -32001; // allow:raw-byte-literal — JSON-RPC server-error range / allow:raw-time-literal — not seconds
|
|
76
|
+
|
|
77
|
+
var ALLOWED_METHODS = Object.freeze(["tasks/send", "tasks/get", "tasks/cancel"]);
|
|
78
|
+
|
|
79
|
+
var TASK_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
80
|
+
// Same identifier shape as MCP tool-name; consolidating would couple
|
|
81
|
+
// MCP + A2A protocol identifiers into a single primitive.
|
|
82
|
+
var SKILL_NAME_RE = /^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/; // allow:duplicate-regex — RFC-3986-unreserved identifier shape, shared across mcp.js + mcp-tool-registry.js
|
|
83
|
+
// allow:raw-byte-literal — RFC-3986-unreserved identifier shape (length cap inside regex), not a byte count
|
|
84
|
+
|
|
85
|
+
function _emitAudit(action, metadata, outcome) {
|
|
86
|
+
try {
|
|
87
|
+
audit().safeEmit({
|
|
88
|
+
action: action,
|
|
89
|
+
outcome: outcome || "success",
|
|
90
|
+
metadata: metadata,
|
|
91
|
+
});
|
|
92
|
+
} catch (_e) { /* best-effort */ }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
var crypto = lazyRequire(function () { return require("./crypto"); });
|
|
96
|
+
|
|
97
|
+
// _newTaskId is reserved for the operator-handler path that mints
|
|
98
|
+
// peer-assigned task IDs server-side. The underscore prefix already
|
|
99
|
+
// satisfies the framework's unused-var policy so the helper stays
|
|
100
|
+
// available without an explicit disable directive.
|
|
101
|
+
function _newTaskId() {
|
|
102
|
+
return crypto().generateToken(12); // allow:raw-byte-literal — 96-bit task id, not byte arithmetic on payload
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function _validateTaskShape(task, where) {
|
|
106
|
+
if (!task || typeof task !== "object" || Array.isArray(task)) {
|
|
107
|
+
throw new A2aTasksError("a2a-tasks/bad-task",
|
|
108
|
+
where + ": task must be a non-null object", true);
|
|
109
|
+
}
|
|
110
|
+
validateOpts.requireNonEmptyString(task.skill, where + ".skill", A2aTasksError, "a2a-tasks/bad-skill");
|
|
111
|
+
if (task.skill.length > 64 || !SKILL_NAME_RE.test(task.skill)) { // allow:raw-byte-literal — A2A skill-name length cap, not byte count
|
|
112
|
+
|
|
113
|
+
throw new A2aTasksError("a2a-tasks/bad-skill",
|
|
114
|
+
where + ".skill '" + task.skill + "' must match " + SKILL_NAME_RE);
|
|
115
|
+
}
|
|
116
|
+
if (task.input !== undefined && (typeof task.input !== "object" || task.input === null)) {
|
|
117
|
+
throw new A2aTasksError("a2a-tasks/bad-input",
|
|
118
|
+
where + ".input must be an object when provided", true);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---- Client-side dispatchers ----
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @primitive b.a2a.tasks.send
|
|
126
|
+
* @signature b.a2a.tasks.send(opts)
|
|
127
|
+
* @since 0.8.85
|
|
128
|
+
* @status stable
|
|
129
|
+
* @related b.a2a.tasks.get, b.a2a.tasks.cancel, b.a2a.signCard
|
|
130
|
+
*
|
|
131
|
+
* Post a `tasks/send` JSON-RPC request to a peer A2A agent. Returns
|
|
132
|
+
* the peer's `tasks/send` result — typically `{ taskId, status }` or
|
|
133
|
+
* a final-state response when the task ran synchronously.
|
|
134
|
+
*
|
|
135
|
+
* @opts
|
|
136
|
+
* peerUrl: string, // peer's A2A endpoint URL (https only)
|
|
137
|
+
* task: object, // { skill, input } per A2A v1 §4
|
|
138
|
+
* timeoutMs: number, // optional — default 30s
|
|
139
|
+
* headers: object, // optional — extra HTTP headers (signed
|
|
140
|
+
* // auth / mTLS / RFC 9421 sig)
|
|
141
|
+
* audit: boolean, // default true
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* var rsp = await b.a2a.tasks.send({
|
|
145
|
+
* peerUrl: "https://agent.example.com/a2a",
|
|
146
|
+
* task: { skill: "summarize", input: { url: "..." } },
|
|
147
|
+
* });
|
|
148
|
+
* // rsp.taskId === "<peer-assigned-id>"
|
|
149
|
+
* // rsp.status === "queued" | "running" | "completed"
|
|
150
|
+
*/
|
|
151
|
+
async function send(opts) {
|
|
152
|
+
if (!opts || typeof opts !== "object") {
|
|
153
|
+
throw new A2aTasksError("a2a-tasks/bad-opts",
|
|
154
|
+
"tasks.send: opts required (peerUrl + task)", true);
|
|
155
|
+
}
|
|
156
|
+
validateOpts.requireNonEmptyString(opts.peerUrl, "tasks.send.peerUrl", A2aTasksError, "a2a-tasks/bad-peer-url");
|
|
157
|
+
// Refuse non-https — A2A v1 §3 mandates TLS for transport.
|
|
158
|
+
try { safeUrl.parse(opts.peerUrl, { allowedProtocols: ["https:"] }); }
|
|
159
|
+
catch (_e) {
|
|
160
|
+
throw new A2aTasksError("a2a-tasks/bad-peer-url",
|
|
161
|
+
"tasks.send: peerUrl must be a valid https URL");
|
|
162
|
+
}
|
|
163
|
+
_validateTaskShape(opts.task, "tasks.send.task");
|
|
164
|
+
var timeoutMs = opts.timeoutMs !== undefined ? opts.timeoutMs : C.TIME.seconds(30);
|
|
165
|
+
return _jsonRpc(opts.peerUrl, "tasks/send", { task: opts.task }, {
|
|
166
|
+
timeoutMs: timeoutMs,
|
|
167
|
+
headers: opts.headers || {},
|
|
168
|
+
audit: opts.audit !== false,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @primitive b.a2a.tasks.get
|
|
174
|
+
* @signature b.a2a.tasks.get(opts)
|
|
175
|
+
* @since 0.8.85
|
|
176
|
+
* @status stable
|
|
177
|
+
* @related b.a2a.tasks.send, b.a2a.tasks.cancel
|
|
178
|
+
*
|
|
179
|
+
* Poll a peer task's current status via `tasks/get`. Returns the
|
|
180
|
+
* peer's status record — `{ taskId, status, result?, error? }`.
|
|
181
|
+
*
|
|
182
|
+
* @opts
|
|
183
|
+
* peerUrl: string,
|
|
184
|
+
* taskId: string,
|
|
185
|
+
* timeoutMs: number,
|
|
186
|
+
* headers: object,
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* var st = await b.a2a.tasks.get({ peerUrl: url, taskId: "abc" });
|
|
190
|
+
* if (st.status === "completed") console.log(st.result);
|
|
191
|
+
*/
|
|
192
|
+
async function get(opts) {
|
|
193
|
+
if (!opts || typeof opts !== "object") {
|
|
194
|
+
throw new A2aTasksError("a2a-tasks/bad-opts",
|
|
195
|
+
"tasks.get: opts required (peerUrl + taskId)", true);
|
|
196
|
+
}
|
|
197
|
+
validateOpts.requireNonEmptyString(opts.peerUrl, "tasks.get.peerUrl", A2aTasksError, "a2a-tasks/bad-peer-url");
|
|
198
|
+
validateOpts.requireNonEmptyString(opts.taskId, "tasks.get.taskId", A2aTasksError, "a2a-tasks/bad-task-id");
|
|
199
|
+
if (opts.taskId.length > 64 || !TASK_ID_RE.test(opts.taskId)) { // allow:raw-byte-literal — A2A task-id length cap, not byte count
|
|
200
|
+
throw new A2aTasksError("a2a-tasks/bad-task-id",
|
|
201
|
+
"tasks.get: taskId must match " + TASK_ID_RE);
|
|
202
|
+
}
|
|
203
|
+
return _jsonRpc(opts.peerUrl, "tasks/get", { taskId: opts.taskId }, {
|
|
204
|
+
timeoutMs: opts.timeoutMs !== undefined ? opts.timeoutMs : C.TIME.seconds(15),
|
|
205
|
+
headers: opts.headers || {},
|
|
206
|
+
audit: opts.audit !== false,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @primitive b.a2a.tasks.cancel
|
|
212
|
+
* @signature b.a2a.tasks.cancel(opts)
|
|
213
|
+
* @since 0.8.85
|
|
214
|
+
* @status stable
|
|
215
|
+
* @related b.a2a.tasks.send, b.a2a.tasks.get
|
|
216
|
+
*
|
|
217
|
+
* Request peer cancellation via `tasks/cancel`. Peer MAY refuse with
|
|
218
|
+
* `-32003 task-not-cancelable` for tasks that have completed or
|
|
219
|
+
* passed a cancellation point.
|
|
220
|
+
*
|
|
221
|
+
* @opts
|
|
222
|
+
* peerUrl: string, // peer's A2A endpoint URL (https only)
|
|
223
|
+
* taskId: string, // peer-assigned task identifier
|
|
224
|
+
* timeoutMs: number, // optional — default 15s
|
|
225
|
+
* headers: object, // optional — extra HTTP headers
|
|
226
|
+
* audit: boolean, // default true
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* try {
|
|
230
|
+
* await b.a2a.tasks.cancel({ peerUrl: url, taskId: "abc" });
|
|
231
|
+
* } catch (e) {
|
|
232
|
+
* if (e.rpcCode === -32003) console.log("task already past cancel point");
|
|
233
|
+
* }
|
|
234
|
+
*/
|
|
235
|
+
async function cancel(opts) {
|
|
236
|
+
if (!opts || typeof opts !== "object") {
|
|
237
|
+
throw new A2aTasksError("a2a-tasks/bad-opts",
|
|
238
|
+
"tasks.cancel: opts required (peerUrl + taskId)", true);
|
|
239
|
+
}
|
|
240
|
+
validateOpts.requireNonEmptyString(opts.peerUrl, "tasks.cancel.peerUrl", A2aTasksError, "a2a-tasks/bad-peer-url");
|
|
241
|
+
validateOpts.requireNonEmptyString(opts.taskId, "tasks.cancel.taskId", A2aTasksError, "a2a-tasks/bad-task-id");
|
|
242
|
+
if (opts.taskId.length > 64 || !TASK_ID_RE.test(opts.taskId)) { // allow:raw-byte-literal — A2A task-id length cap, not byte count
|
|
243
|
+
throw new A2aTasksError("a2a-tasks/bad-task-id",
|
|
244
|
+
"tasks.cancel: taskId must match " + TASK_ID_RE);
|
|
245
|
+
}
|
|
246
|
+
return _jsonRpc(opts.peerUrl, "tasks/cancel", { taskId: opts.taskId }, {
|
|
247
|
+
timeoutMs: opts.timeoutMs !== undefined ? opts.timeoutMs : C.TIME.seconds(15),
|
|
248
|
+
headers: opts.headers || {},
|
|
249
|
+
audit: opts.audit !== false,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function _jsonRpc(url, method, params, opts) {
|
|
254
|
+
var id = nodeCrypto.randomUUID();
|
|
255
|
+
var body = JSON.stringify({
|
|
256
|
+
jsonrpc: JSONRPC_VERSION,
|
|
257
|
+
id: id,
|
|
258
|
+
method: method,
|
|
259
|
+
params: params,
|
|
260
|
+
});
|
|
261
|
+
var startMs = Date.now();
|
|
262
|
+
var rsp;
|
|
263
|
+
try {
|
|
264
|
+
rsp = await httpClient().request({
|
|
265
|
+
method: "POST",
|
|
266
|
+
url: url,
|
|
267
|
+
body: body,
|
|
268
|
+
headers: Object.assign({
|
|
269
|
+
"Content-Type": "application/json",
|
|
270
|
+
"Accept": "application/json",
|
|
271
|
+
}, opts.headers),
|
|
272
|
+
timeoutMs: opts.timeoutMs,
|
|
273
|
+
});
|
|
274
|
+
} catch (transportErr) {
|
|
275
|
+
if (opts.audit) {
|
|
276
|
+
_emitAudit("a2a.tasks.transport_failed",
|
|
277
|
+
{ method: method, url: url, error: String(transportErr.message || transportErr), elapsedMs: Date.now() - startMs },
|
|
278
|
+
"warning");
|
|
279
|
+
}
|
|
280
|
+
throw new A2aTasksError("a2a-tasks/transport",
|
|
281
|
+
"tasks." + method.split("/")[1] + ": transport error: " + (transportErr.message || transportErr));
|
|
282
|
+
}
|
|
283
|
+
if (rsp.statusCode < 200 || rsp.statusCode >= 300) { // allow:raw-byte-literal — HTTP status class boundaries
|
|
284
|
+
if (opts.audit) {
|
|
285
|
+
_emitAudit("a2a.tasks.http_error",
|
|
286
|
+
{ method: method, url: url, statusCode: rsp.statusCode, elapsedMs: Date.now() - startMs },
|
|
287
|
+
"warning");
|
|
288
|
+
}
|
|
289
|
+
throw new A2aTasksError("a2a-tasks/http-error",
|
|
290
|
+
"tasks." + method.split("/")[1] + ": HTTP " + rsp.statusCode);
|
|
291
|
+
}
|
|
292
|
+
var parsed;
|
|
293
|
+
try {
|
|
294
|
+
parsed = safeJson.parse(typeof rsp.body === "string" ? rsp.body : rsp.body.toString("utf8"),
|
|
295
|
+
{ maxBytes: C.BYTES.mib(8) });
|
|
296
|
+
} catch (parseErr) {
|
|
297
|
+
throw new A2aTasksError("a2a-tasks/bad-response",
|
|
298
|
+
"tasks." + method.split("/")[1] + ": response not valid JSON: " + (parseErr.message || parseErr));
|
|
299
|
+
}
|
|
300
|
+
if (!parsed || typeof parsed !== "object" || parsed.jsonrpc !== JSONRPC_VERSION) {
|
|
301
|
+
throw new A2aTasksError("a2a-tasks/bad-response",
|
|
302
|
+
"tasks." + method.split("/")[1] + ": response is not a JSON-RPC 2.0 envelope");
|
|
303
|
+
}
|
|
304
|
+
if (parsed.error) {
|
|
305
|
+
if (opts.audit) {
|
|
306
|
+
_emitAudit("a2a.tasks.rpc_error",
|
|
307
|
+
{ method: method, url: url, errorCode: parsed.error.code, errorMessage: parsed.error.message },
|
|
308
|
+
"warning");
|
|
309
|
+
}
|
|
310
|
+
var err = new A2aTasksError("a2a-tasks/rpc-error",
|
|
311
|
+
"tasks." + method.split("/")[1] + ": " + (parsed.error.message || "rpc error"));
|
|
312
|
+
err.rpcCode = parsed.error.code;
|
|
313
|
+
err.rpcData = parsed.error.data;
|
|
314
|
+
throw err;
|
|
315
|
+
}
|
|
316
|
+
if (opts.audit) {
|
|
317
|
+
_emitAudit("a2a.tasks.ok",
|
|
318
|
+
{ method: method, url: url, elapsedMs: Date.now() - startMs });
|
|
319
|
+
}
|
|
320
|
+
return parsed.result;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---- Server-side middleware ----
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* @primitive b.a2a.middleware.tasks
|
|
327
|
+
* @signature b.a2a.middleware.tasks(opts)
|
|
328
|
+
* @since 0.8.85
|
|
329
|
+
* @status stable
|
|
330
|
+
* @related b.a2a.middleware.agentCard, b.a2a.tasks.send
|
|
331
|
+
*
|
|
332
|
+
* Build the server-side A2A tasks middleware. Returns a connect-style
|
|
333
|
+
* `(req, res, next) => void` that:
|
|
334
|
+
*
|
|
335
|
+
* - Parses inbound JSON-RPC 2.0 requests from POST request bodies.
|
|
336
|
+
* Refuses non-POST + non-application/json with 405 / 415.
|
|
337
|
+
* - Refuses methods not in `["tasks/send", "tasks/get",
|
|
338
|
+
* "tasks/cancel"]` with JSON-RPC -32601 method-not-found.
|
|
339
|
+
* - Enforces per-skill scope via `opts.scopes` — a map
|
|
340
|
+
* `{ skillName: requiredScope }`. The middleware reads
|
|
341
|
+
* `req.a2aScopes` (populated by the operator's auth layer; e.g.
|
|
342
|
+
* parsed from a bearer token's claims or an mTLS cert SAN) and
|
|
343
|
+
* refuses calls whose required scope isn't granted with -32001.
|
|
344
|
+
* - Dispatches to `opts.handler({ method, taskId?, task?, req })`
|
|
345
|
+
* which returns the task state (or throws for errors that get
|
|
346
|
+
* mapped to -32603).
|
|
347
|
+
*
|
|
348
|
+
* The middleware writes the JSON-RPC response itself; it does NOT
|
|
349
|
+
* call `next()`. Operators chaining additional middleware after
|
|
350
|
+
* this one should mount on a separate path.
|
|
351
|
+
*
|
|
352
|
+
* @opts
|
|
353
|
+
* handler: function (ctx) → result — REQUIRED
|
|
354
|
+
* scopes: { skillName: scopeString } — optional per-skill scope map
|
|
355
|
+
* maxBytes: number — body cap (default 1 MiB)
|
|
356
|
+
* audit: boolean — default true
|
|
357
|
+
*
|
|
358
|
+
* @example
|
|
359
|
+
* var mw = b.a2a.middleware.tasks({
|
|
360
|
+
* scopes: { summarize: "a2a:summarize", search: "a2a:search" },
|
|
361
|
+
* handler: async function ({ method, task, taskId }) {
|
|
362
|
+
* if (method === "tasks/send") {
|
|
363
|
+
* var newId = "t-" + Math.random().toString(36).slice(2, 10);
|
|
364
|
+
* queue.push({ id: newId, task });
|
|
365
|
+
* return { taskId: newId, status: "queued" };
|
|
366
|
+
* }
|
|
367
|
+
* if (method === "tasks/get") {
|
|
368
|
+
* return tasks.get(taskId);
|
|
369
|
+
* }
|
|
370
|
+
* if (method === "tasks/cancel") {
|
|
371
|
+
* return tasks.cancel(taskId);
|
|
372
|
+
* }
|
|
373
|
+
* },
|
|
374
|
+
* });
|
|
375
|
+
* app.post("/a2a", mw);
|
|
376
|
+
*/
|
|
377
|
+
function middlewareTasks(opts) {
|
|
378
|
+
if (!opts || typeof opts !== "object") {
|
|
379
|
+
throw new A2aTasksError("a2a-tasks/bad-mw-opts",
|
|
380
|
+
"middleware.tasks: opts required (handler)", true);
|
|
381
|
+
}
|
|
382
|
+
if (typeof opts.handler !== "function") {
|
|
383
|
+
throw new A2aTasksError("a2a-tasks/bad-mw-opts",
|
|
384
|
+
"middleware.tasks: opts.handler must be a function", true);
|
|
385
|
+
}
|
|
386
|
+
if (opts.scopes !== undefined) {
|
|
387
|
+
if (typeof opts.scopes !== "object" || opts.scopes === null || Array.isArray(opts.scopes)) {
|
|
388
|
+
throw new A2aTasksError("a2a-tasks/bad-mw-opts",
|
|
389
|
+
"middleware.tasks: opts.scopes must be an object when provided", true);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
var maxBytes = opts.maxBytes !== undefined ? opts.maxBytes : C.BYTES.mib(1);
|
|
393
|
+
var emitAudit = opts.audit !== false;
|
|
394
|
+
var scopes = opts.scopes || null;
|
|
395
|
+
|
|
396
|
+
return function a2aTasksMiddleware(req, res) {
|
|
397
|
+
if ((req.method || "").toUpperCase() !== "POST") {
|
|
398
|
+
res.statusCode = 405; // allow:raw-byte-literal — HTTP 405 Method Not Allowed
|
|
399
|
+
res.setHeader("Content-Type", "application/json");
|
|
400
|
+
res.setHeader("Allow", "POST");
|
|
401
|
+
res.end(JSON.stringify(_jsonRpcError(null, JSONRPC_INVALID_REQUEST, "method must be POST")));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
var ctype = (req.headers && (req.headers["content-type"] || req.headers["Content-Type"])) || "";
|
|
405
|
+
if (typeof ctype === "string" && ctype.indexOf("application/json") !== 0 && ctype.indexOf("application/json") === -1) {
|
|
406
|
+
res.statusCode = 415; // allow:raw-byte-literal — HTTP 415 Unsupported Media Type
|
|
407
|
+
res.setHeader("Content-Type", "application/json");
|
|
408
|
+
res.end(JSON.stringify(_jsonRpcError(null, JSONRPC_INVALID_REQUEST, "Content-Type must be application/json")));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
_readBody(req, maxBytes).then(function (rawBytes) {
|
|
413
|
+
var body;
|
|
414
|
+
try {
|
|
415
|
+
body = safeJson.parse(rawBytes.toString("utf8"), { maxBytes: maxBytes });
|
|
416
|
+
} catch (_parseErr) {
|
|
417
|
+
res.statusCode = 400; // allow:raw-byte-literal — HTTP 400 Bad Request
|
|
418
|
+
res.setHeader("Content-Type", "application/json");
|
|
419
|
+
res.end(JSON.stringify(_jsonRpcError(null, JSONRPC_PARSE_ERROR, "invalid JSON body")));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (!body || typeof body !== "object" || body.jsonrpc !== JSONRPC_VERSION ||
|
|
423
|
+
typeof body.method !== "string") {
|
|
424
|
+
res.statusCode = 400; // allow:raw-byte-literal — HTTP 400 Bad Request
|
|
425
|
+
res.setHeader("Content-Type", "application/json");
|
|
426
|
+
res.end(JSON.stringify(_jsonRpcError(body && body.id, JSONRPC_INVALID_REQUEST,
|
|
427
|
+
"expected JSON-RPC 2.0 envelope { jsonrpc, id?, method, params? }")));
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
var reqId = body.id !== undefined ? body.id : null;
|
|
431
|
+
if (ALLOWED_METHODS.indexOf(body.method) === -1) {
|
|
432
|
+
res.statusCode = 200; // allow:raw-byte-literal — JSON-RPC errors return 200 with error envelope
|
|
433
|
+
res.setHeader("Content-Type", "application/json");
|
|
434
|
+
res.end(JSON.stringify(_jsonRpcError(reqId, JSONRPC_METHOD_NOT_FOUND,
|
|
435
|
+
"method '" + body.method + "' not in [" + ALLOWED_METHODS.join(", ") + "]")));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
var params = body.params || {};
|
|
439
|
+
|
|
440
|
+
// Scope enforcement for tasks/send (task references a skill).
|
|
441
|
+
if (body.method === "tasks/send" && scopes) {
|
|
442
|
+
if (!params.task || typeof params.task !== "object" || typeof params.task.skill !== "string") {
|
|
443
|
+
res.statusCode = 200; // allow:raw-byte-literal — JSON-RPC error envelope returns 200
|
|
444
|
+
res.setHeader("Content-Type", "application/json");
|
|
445
|
+
res.end(JSON.stringify(_jsonRpcError(reqId, JSONRPC_INVALID_PARAMS,
|
|
446
|
+
"tasks/send: params.task.skill required")));
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
var requiredScope = scopes[params.task.skill];
|
|
450
|
+
if (typeof requiredScope === "string") {
|
|
451
|
+
var grantedScopes = Array.isArray(req.a2aScopes) ? req.a2aScopes : [];
|
|
452
|
+
if (grantedScopes.indexOf(requiredScope) === -1) {
|
|
453
|
+
if (emitAudit) {
|
|
454
|
+
_emitAudit("a2a.tasks.scope_denied",
|
|
455
|
+
{ skill: params.task.skill, requiredScope: requiredScope }, "denied");
|
|
456
|
+
}
|
|
457
|
+
res.statusCode = 200; // allow:raw-byte-literal — JSON-RPC error envelope returns 200
|
|
458
|
+
res.setHeader("Content-Type", "application/json");
|
|
459
|
+
res.end(JSON.stringify(_jsonRpcError(reqId, A2A_SCOPE_DENIED,
|
|
460
|
+
"scope '" + requiredScope + "' required for skill '" + params.task.skill + "'")));
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
var ctx = {
|
|
467
|
+
method: body.method,
|
|
468
|
+
task: params.task,
|
|
469
|
+
taskId: params.taskId,
|
|
470
|
+
req: req,
|
|
471
|
+
};
|
|
472
|
+
Promise.resolve()
|
|
473
|
+
.then(function () { return opts.handler(ctx); })
|
|
474
|
+
.then(function (result) {
|
|
475
|
+
if (emitAudit) {
|
|
476
|
+
_emitAudit("a2a.tasks.handled",
|
|
477
|
+
{ method: body.method, skill: params.task && params.task.skill, taskId: params.taskId });
|
|
478
|
+
}
|
|
479
|
+
res.statusCode = 200; // allow:raw-byte-literal — JSON-RPC 200 with result envelope
|
|
480
|
+
res.setHeader("Content-Type", "application/json");
|
|
481
|
+
res.end(JSON.stringify({
|
|
482
|
+
jsonrpc: JSONRPC_VERSION,
|
|
483
|
+
id: reqId,
|
|
484
|
+
result: result,
|
|
485
|
+
}));
|
|
486
|
+
})
|
|
487
|
+
.catch(function (handlerErr) {
|
|
488
|
+
var code = handlerErr && handlerErr.rpcCode || JSONRPC_INTERNAL_ERROR;
|
|
489
|
+
var msg = (handlerErr && handlerErr.message) || "handler error";
|
|
490
|
+
if (emitAudit) {
|
|
491
|
+
_emitAudit("a2a.tasks.handler_error",
|
|
492
|
+
{ method: body.method, errorMessage: msg, errorCode: code }, "warning");
|
|
493
|
+
}
|
|
494
|
+
res.statusCode = 200; // allow:raw-byte-literal — JSON-RPC error envelope returns 200
|
|
495
|
+
res.setHeader("Content-Type", "application/json");
|
|
496
|
+
res.end(JSON.stringify(_jsonRpcError(reqId, code, msg)));
|
|
497
|
+
});
|
|
498
|
+
}).catch(function (readErr) {
|
|
499
|
+
res.statusCode = 400; // allow:raw-byte-literal — HTTP 400 Bad Request
|
|
500
|
+
res.setHeader("Content-Type", "application/json");
|
|
501
|
+
res.end(JSON.stringify(_jsonRpcError(null, JSONRPC_PARSE_ERROR,
|
|
502
|
+
"could not read request body: " + (readErr.message || readErr))));
|
|
503
|
+
});
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function _jsonRpcError(id, code, message, data) {
|
|
508
|
+
var err = { code: code, message: message };
|
|
509
|
+
if (data !== undefined) err.data = data;
|
|
510
|
+
return {
|
|
511
|
+
jsonrpc: JSONRPC_VERSION,
|
|
512
|
+
id: id,
|
|
513
|
+
error: err,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function _readBody(req, maxBytes) {
|
|
518
|
+
return new Promise(function (resolve, reject) {
|
|
519
|
+
var collector = safeBuffer.boundedChunkCollector({
|
|
520
|
+
maxBytes: maxBytes,
|
|
521
|
+
errorClass: A2aTasksError,
|
|
522
|
+
sizeCode: "a2a-tasks/body-too-large",
|
|
523
|
+
sizeMessage: "a2a-tasks: request body exceeded " + maxBytes + " bytes",
|
|
524
|
+
});
|
|
525
|
+
req.on("data", function (chunk) {
|
|
526
|
+
try { collector.push(chunk); }
|
|
527
|
+
catch (capErr) { reject(capErr); }
|
|
528
|
+
});
|
|
529
|
+
req.on("end", function () { resolve(collector.result()); });
|
|
530
|
+
req.on("error", function (e) { reject(e); });
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* @primitive b.a2a.middleware.agentCard
|
|
536
|
+
* @signature b.a2a.middleware.agentCard(opts)
|
|
537
|
+
* @since 0.8.85
|
|
538
|
+
* @status stable
|
|
539
|
+
* @related b.a2a.signCard, b.a2a.middleware.tasks
|
|
540
|
+
*
|
|
541
|
+
* Build a middleware that serves the operator's Agent Card at
|
|
542
|
+
* `/.well-known/agent.json` per the A2A v1 discovery convention.
|
|
543
|
+
* The middleware writes a 200 JSON response on GET; refuses other
|
|
544
|
+
* methods with 405. Mount on a router path that resolves the
|
|
545
|
+
* well-known prefix (operator-side).
|
|
546
|
+
*
|
|
547
|
+
* @opts
|
|
548
|
+
* card: object (REQUIRED) — the signed-card envelope from b.a2a.signCard
|
|
549
|
+
* maxAgeSec: number (default 300) — Cache-Control max-age
|
|
550
|
+
*
|
|
551
|
+
* @example
|
|
552
|
+
* var raw = b.a2a.createCard({
|
|
553
|
+
* agent: { name: "my-agent", version: "1.0.0" },
|
|
554
|
+
* skills: [{ name: "summarize" }],
|
|
555
|
+
* });
|
|
556
|
+
* var card = b.a2a.signCard(raw, pair.privateKey);
|
|
557
|
+
* app.get("/.well-known/agent.json", b.a2a.middleware.agentCard({ card: card }));
|
|
558
|
+
*/
|
|
559
|
+
function middlewareAgentCard(opts) {
|
|
560
|
+
if (!opts || typeof opts !== "object") {
|
|
561
|
+
throw new A2aTasksError("a2a-tasks/bad-mw-opts",
|
|
562
|
+
"middleware.agentCard: opts required (card)", true);
|
|
563
|
+
}
|
|
564
|
+
if (!opts.card || typeof opts.card !== "object") {
|
|
565
|
+
throw new A2aTasksError("a2a-tasks/bad-mw-opts",
|
|
566
|
+
"middleware.agentCard: opts.card required (output of b.a2a.signCard)", true);
|
|
567
|
+
}
|
|
568
|
+
numericBounds.requireNonNegativeFiniteIntIfPresent(
|
|
569
|
+
opts.maxAgeSec, "middleware.agentCard.maxAgeSec", A2aTasksError, "a2a-tasks/bad-max-age");
|
|
570
|
+
var maxAgeSec = (opts.maxAgeSec !== undefined && opts.maxAgeSec !== null && opts.maxAgeSec > 0)
|
|
571
|
+
? Math.floor(opts.maxAgeSec)
|
|
572
|
+
: (C.TIME.minutes(5) / C.TIME.seconds(1));
|
|
573
|
+
var cardJson = JSON.stringify(opts.card);
|
|
574
|
+
return function a2aAgentCardMiddleware(req, res) {
|
|
575
|
+
if ((req.method || "").toUpperCase() !== "GET") {
|
|
576
|
+
res.statusCode = 405; // allow:raw-byte-literal — HTTP 405 Method Not Allowed
|
|
577
|
+
res.setHeader("Allow", "GET");
|
|
578
|
+
res.end();
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
res.statusCode = 200; // allow:raw-byte-literal — HTTP 200 OK
|
|
582
|
+
res.setHeader("Content-Type", "application/json");
|
|
583
|
+
res.setHeader("Cache-Control", "public, max-age=" + maxAgeSec);
|
|
584
|
+
res.end(cardJson);
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
module.exports = {
|
|
589
|
+
send: send,
|
|
590
|
+
get: get,
|
|
591
|
+
cancel: cancel,
|
|
592
|
+
middleware: {
|
|
593
|
+
tasks: middlewareTasks,
|
|
594
|
+
agentCard: middlewareAgentCard,
|
|
595
|
+
},
|
|
596
|
+
ALLOWED_METHODS: ALLOWED_METHODS,
|
|
597
|
+
A2aTasksError: A2aTasksError,
|
|
598
|
+
};
|