@blamejs/core 0.9.20 → 0.9.22
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 +2 -0
- package/index.js +7 -0
- package/lib/agent-idempotency.js +350 -0
- package/lib/agent-orchestrator.js +469 -0
- package/lib/guard-agent-registry.js +179 -0
- package/lib/guard-idempotency-key.js +151 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.9.x
|
|
10
10
|
|
|
11
|
+
- v0.9.22 (2026-05-14) — **`b.agent.idempotency` — cross-dispatch idempotency keys honored at every agent consumer boundary; JMAP retry-safe semantics from day one.** Second slice of the v0.9.21–v0.9.29 substrate playbook. (1) **`b.agent.idempotency.create({ store, audit, ttlMs, maxResultBytes, fingerprintArgs })`** — pluggable backing store via `{ get, put, delete, gc }`; in-memory default ships for single-process deployments, operator wires durable backends (sqlite-backed adapter or external). Surface: `instance.get(method, actorId, key)` returns cached envelope `{ result, firstAt, lastReplayedAt, replayCount, requestFingerprint }` or `null`; `instance.put(method, actorId, key, result, { args, requestFingerprint })` serializes via `b.safeJson.stringify` + persists with TTL + refuses key-reuse-different-args via the request-fingerprint (sha3-512 of args sans key) check; `instance.invalidate(method, actorId, key)` is the saga-compensation escape hatch; `instance.gc({ olderThanMs })` for periodic cleanup. (2) **Keys hashed at the boundary** — operator-supplied keys never reach disk in raw form; namespace-hashed via `b.crypto.namespaceHash("agent.idempotency", method + "\\0" + actorId + "\\0" + key)`. Cross-actor + cross-method isolation by construction (an attacker can't replay another actor's mutation by guessing the key). (3) **Replay tracking** — every cache hit increments `replayCount` + bumps `lastReplayedAt`; audit emits `agent.idempotency.replay` with the truncated actor-id hash + first/replay timestamps so operator pipelines surface retry storms. (4) **`b.guardIdempotencyKey`** — operator-supplied key shape validator. Refuses oversized (default 256 bytes), control chars (C0/NUL/DEL — defends audit-log injection), slash + backslash (defends operators routing keys through filesystem paths), path-traversal (`..`), non-ASCII under strict (operator-greppable in audit logs across stack boundaries; permissive opts down for legacy Unicode tenant IDs). (5) **JSON round-trip test helper** — new `test/helpers/json-round-trip.js` exporting `assertJsonRoundTrip(shape, label)`. Catches the bug class Codex flagged on PR #51 (v0.9.21 `register()` stored agent function ref on backend row, lost in DB/JSON serialization). Refuses on function fields, Buffer fields, Date objects, Symbol keys, BigInt values, non-finite numbers, cycles. Applied retroactively to v0.9.21 agent-orchestrator backend row in regression test. (6) **Result size cap** (default 1 MiB per cached entry) — refuses with `agent-idempotency/result-too-big` so operators discover OOM-prone result shapes locally instead of at runtime. Fuzz harness ships in `fuzz/guard-idempotency-key.fuzz.js`; ClusterFuzzLite matrix entries added. Per the substrate playbook in `memory/specs/blamejs-agent-idempotency-spec.md`.
|
|
12
|
+
- v0.9.21 (2026-05-14) — **`b.agent.orchestrator` — framework-level supervisor for every agent blamejs ships.** First slice of the v0.9.21–v0.9.29 substrate playbook that builds before mail-stack resumes. (1) **`b.agent.orchestrator.create(opts)`** — facade with `register(name, agent, opts)` / `lookup(name)` / `unregister(name)` / `list({ kind, tenantId })` registry, `spawnConsumers({ agent, queue, shards, taskTopic, maxConcurrency })` for sharded-topic dispatch (FNV-1a consistent-hash via `b.agent.orchestrator.shardFor(key, shards)`; per-shard topic suffix `<base>.<shard>`), `elect({ resource })` composing `b.cluster` DB-row leader election (returns `{ isLeader, fencingToken, leaderId }` — single-process deployments get a trivial-leader; cluster deployments delegate), `drain({ timeoutMs })` stopping every spawned consumer + audit-emitting elapsed/count, and `health()` aggregating per-agent + per-consumer + per-election state into one shape ready for `b.middleware.healthcheck`. (2) **Pluggable backend** — `{ get, set, delete, list }` interface; in-memory default ships for single-process deployments, operator wires `b.config.loadDbBacked`-shaped or external for restart-survival. (3) **`b.guardAgentRegistry`** — registry-op shape validator. Refuses non-ASCII agent names (NFC + ASCII-only — operator-greppable in audit logs), path-traversal shapes (`..` / `/` / `\` / NUL / C0 / DEL), oversized (default 64 bytes), reserved `FRAMEWORK.*` / `ROOT.*` prefix, duplicate-on-register, register without `agentKind`. Ships `strict` / `balanced` / `permissive` profiles and `hipaa` / `pci-dss` / `gdpr` / `soc2` postures (all pin `strict`). (4) **Drain phase auto-wires into `b.appShutdown`** when operator supplies an `appShutdown` instance; `SIGTERM` delivers a clean drain of all spawned consumers + stream-registry signal before the process exits. (5) **Stream-registry hook** (`registerStream` / `unregisterStream` / `isDraining`) — substrate for v0.9.23 `b.agent.stream` so async-iterable method variants can check the drain flag and emit drain-markers. Substrate is NOT a process supervisor — process spawn / restart-on-crash / pod scheduling delegate to pm2 / systemd / k8s / Nomad; framework doesn't compete. Fuzz harness ships in `fuzz/guard-agent-registry.fuzz.js`. Per the substrate playbook in `memory/specs/blamejs-agent-orchestrator-spec.md`; v0.9.22 idempotency wires on top next.
|
|
11
13
|
- v0.9.20 (2026-05-14) — **`b.mail.agent` — the standardization contract for every mail protocol blamejs ships.** Every above-the-wire mail surface (JMAP at v0.9.27, IMAP at v0.9.28, POP3 at v0.9.29, ManageSieve at v0.9.30, the MX listener at v0.9.24, submission at v0.9.25) translates protocol calls into `agent.X(args)`; RBAC, posture enforcement, audit emission, dispatch, and worker isolation are owned at the agent. (1) **`b.mail.agent.create({ store, audit, permissions, posture, identity, dispatch })`** — facade with 23 methods. Read surface (`search` / `fetch` / `thread` / `folders` / `quota`) is backed by v0.9.19 `b.mailStore` and runs immediately. Move surface (`move` / `flag` / `delete`) is backed by the new `mailStore.moveMessages` substrate; soft-delete moves to Trash + tags `\Deleted` (hard expunge wires at v0.9.28 with retention-floor enforcement). Write surface (`compose` / `send` / `reply` / `forward`), Sieve (`sieve.list/put/activate`), identity (`identity.set` / `vacation.set`), MDN (`mdn.send/parse/allowList`), regulated export, and migration `import` throw `mail-agent/not-implemented` with a `wiredAt` tag naming the slice that lights them up (defer-with-condition per the v1-defensible-scope rule). (2) **Dispatch contract** — `dispatch.mode` is `"local"` / `"queue"` / `"auto"`. `local` runs every method in-process. `queue` publishes envelopes to `mail.agent.tasks` via `b.queue.enqueue`; an `agent.consumer({ agent, queue })` running in a dedicated process or replicas across hosts pulls and executes. Posture metadata travels with each envelope; the consumer re-validates against its own posture before unseal so no posture downgrade survives the queue boundary. `auto` routes fast-path ops (`fetch` / `folders` / `flag` / `quota`) locally and heavy ops (`search` / `export`) to queue/workerPool when configured. (3) **Worker isolation** — `dispatch.workerPool` (composes `b.workerPool`) is validated at create-time; the agent reserves `vaultKeyDelivery: "in-worker"` (default) vs `"main-only"` (posture-conditional — HIPAA/PCI/GDPR default to main-only when the worker-script path wires at v0.9.26 Sieve). (4) **`b.mail.agent.consumer`** — the queue-side facade for multi-host load-spreading; carries its own `store` and re-validates posture at the boundary. (5) **Five new guards** through `b.gateContract` — **`b.guardMailQuery`** (search/fetch filter shape: bounded depth/keys/array-length, function/regex/Buffer/cycle refusal, `__proto__` key refusal, projection-column allowlist via `FILTERABLE_COLUMNS`, posture-required actor fields HIPAA→`purposeOfUse` / PCI→`pciScope` / GDPR→`lawfulBasis`); **`b.guardMailCompose`** (draft envelope: identity-vs-From alignment, recipient deduplication, attachment-byte cap default 25 MiB, body shape — exactly one of text/html unless `allowMultipartAlternative`, C0 control-char refusal in headers); **`b.guardMailReply`** (References-chain cap default 100 defends infinite-loop forwards, In-Reply-To continuity per RFC 5322 §3.6.4 — last References must match In-Reply-To, quoted-original byte cap, forwarded-attachment cardinality cap); **`b.guardMailMove`** (system-folder allowlist for INBOX/Sent/Drafts/Trash/Junk/Archive, admin-scope or `allowedFolders` gate for arbitrary destinations, path-traversal refusal, slash refusal — IMAP `.` hierarchy separator only); **`b.guardMailSieve`** (pre-parser shape-only: script-byte cap default 64 KiB, line-count cap defends one-byte-line bombs, name shape — path-traversal / slash / backslash refusal, actor-ownership check — non-admin actors restricted to their `ownedNames`; full Sieve parse at v0.9.26 via `b.safeSieve`). Each guard ships `strict` / `balanced` / `permissive` profiles and `hipaa` / `pci-dss` / `gdpr` / `soc2` postures (all pin `strict`). (6) **`b.mailStore.moveMessages(fromFolder, toFolder, objectIds)`** — per IMAP4rev2 §6.6.2, both folders bump modseq on move; agent.move composes this. (7) Fuzz harnesses ship for every new guard and the v0.9.19 substrates (`safe-mime` / `guard-message-id`) are now wired into the ClusterFuzzLite matrix.
|
|
12
14
|
- v0.9.19 (2026-05-14) — **First slice of the blamepost mail-stack sequence — `b.mailStore` + `b.safeMime` + `b.guardMessageId` substrates.** Byte-level mail-store foundation that every above-the-wire mail primitive composes (agent at v0.9.20, MX listener at v0.9.23, submission listener at v0.9.24, JMAP/IMAP/POP3 at v0.9.26-29, ManageSieve at v0.9.30, DAV at v0.9.32). (1) **`b.safeMime`** — RFC 5322 + 2045/2046/2047/EAI MIME parser. Bounded: total parts cap (default 64), nesting-depth cap (default 16), boundary length cap (default 70 per RFC 2046 §5.1.1), header-bytes cap (default 64 KiB), header-line cap (default 998 per RFC 5322 §2.1.1), body-bytes cap (default 25 MiB), message cap (default 50 MiB), charset allowlist (UTF-8 / US-ASCII / common legacy 8-bit), transfer-encoding allowlist (7bit/8bit/binary/qp/base64). Surface: `parse(bytes, opts) → tree`, `walk(tree, visitor)`, `findFirst(tree, predicate)`, `extractText(tree, opts)` (RFC 2046 §5.1.4 last-wins for `multipart/alternative`), `extractAttachments(tree, opts)`. Includes RFC 2047 Q + B encoded-word decoding for `Subject:` / `From:` etc. + RFC 2231 charset'lang'value filename decoding. Throws `safe-mime/<code>` on every cap exceeded / malformed boundary / unknown charset / unknown CTE / control chars in headers / NUL bytes. **Defends CVE-2024-39929** (Exim MIME multipart parser) and **CVE-2025-30258** (gnumail truncated-MIME-tree class). Fuzz harness ships in `fuzz/safe-mime.fuzz.js`. (2) **`b.guardMessageId`** — RFC 5322 §3.6.4 Message-Id validator. Gates Message-Id / In-Reply-To / References at the mail-store append boundary, the MX inbound boundary (v0.9.23), and the submission outbound path (v0.9.24). Refuses oversized (>998 bytes), bare CR/LF/NUL/C0-control/DEL (header-injection defense — defends `From:` / `Bcc:` smuggling via folded Message-Id continuation), unbracketed under strict profile, empty value, missing `@`, nested angle brackets, bidi codepoints (CVE-2021-42574 RTLO class in mail-header context). Profile family: strict (default) / balanced / permissive. Posture family: hipaa / pci-dss / gdpr / soc2 → all pin profile to strict. Surface: `validate(value, opts)`, `validateList(value, opts)` (References-chain cap = 100), `compliancePosture(posture)`. Fuzz harness ships in `fuzz/guard-message-id.fuzz.js`. (3) **`b.mailStore`** — byte-level mail-store substrate with pluggable backend (sqlite default; operator's `b.externalDb` Postgres or any `{ prepare(sql) → { run, get, all } }`-shaped object). Surface: `create(opts)` returning `{ appendMessage, fetchByObjectId, queryByModseq, setFlags, createFolder, listFolders, threadFor, quota, setLegalHold }`. **Sealed by default** via `b.cryptoField.sealRow` — `subject` / `from_addr` / `to_addrs` / `body_text` / `body_html` route through vault-managed AEAD envelope on insert + unseal on fetch. Plaintext (forensic-queryable without unsealing): `objectid` / `modseq` / `internal_date` / `received_at` / `size_bytes` / `flags` / `legal_hold` / `from_hash` / `message_id_hash`. Per-folder monotonic `modseq` counter (RFC 7162 CONDSTORE substrate). Per-message `objectid` (RFC 8474 JMAP cross-protocol identity). Threading at append time via In-Reply-To + References chain walk (cryptoField.lookupHash for hash-aware threading on sealed columns). Quota substrate (per-folder `used_bytes` + `used_count` maintained atomically). Legal-hold flag composes existing `b.legalHold`. Schema bootstraps at construction with six IMAP4rev2 default folders (INBOX / Sent / Drafts / Trash / Junk / Archive) and JMAP role mapping. Append composes `b.safeMime.parse` (bounded inbound) + `b.guardMessageId.validate` (header-injection gate). **Per the operator-confirmed blamepost roadmap** (`memory/specs/blamepost-roadmap.md`); next slice v0.9.20 wires `b.mail.agent` on top of this substrate.
|
|
13
15
|
- v0.9.18 (2026-05-14) — **18 CodeQL alerts closed across 4 rule classes + SECURITY.md hardening checklist additions for v0.9.13+ primitives + MIGRATING.md out-of-band breaking-changes section.** Post-v0.9.17 audit identified 18 pre-existing CodeQL security findings on `main` — accumulated over many releases, surfaced explicitly when v0.9.15's rename sweep changed line content. v0.9.18 closes them all. (1) **`js/file-system-race` (6 sites)** — TOCTOU between `fs.existsSync()` / `fs.statSync()` and a subsequent file op. Fixed via the framework's canonical TOCTOU-safe-read scaffold (open fd first → `fstatSync` → `readSync` loop → `closeSync` in `finally`) at `lib/atomic-file.js` (`_readSyncCore`), `lib/restore-rollback.js` (marker write switched to exclusive-create `wx` + EEXIST-tolerant), `lib/network-tls.js` (`_readPathFile` extraction with per-file ENOENT tolerance), `lib/backup/bundle.js` (open-fd-first plus required-vs-skip branch routing), `lib/static.js` (request-serve hot path narrowed to single fd). `lib/vault/seal-pem-file.js` retained as-is with a CodeQL suppression — the site has an in-line `lstat.ino === fstat.ino` inode-equality defense (line 290) that refuses with `seal-pem-file/toctou-detected` if an attacker swaps the file between `lstat` and `open`. (2) **`js/insecure-temporary-file` (6 sites)** — predictable temp paths. `lib/vault/rotate.js` now uses `mkdtempSync` for a per-rotation random scratch dir + plain filenames inside (replaces the predictable `_blamejs_rotate.tmp.db` / `_blamejs_verify.tmp.db` paths in `stagingDir`). `lib/mtls-ca.js` switched to exclusive-create `openSync(..., "wx", 0o600)` + `writeSync` + `fsyncSync` so an attacker pre-creating the path is refused at `EEXIST`. `lib/atomic-file.js` (`fsyncDir`), `lib/vault/rotate.js` (`_fsyncFileByPath`), `lib/http-client.js` (atomic tmp path) retained as-is with suppressions — `dirPath` / `p` are operator-supplied framework data paths (not `os.tmpdir`-reachable), and `tmpPath` carries 16 hex chars of crypto-random suffix (line 1802 `dest + ".tmp-" + bCrypto.generateToken(8)`). (3) **`js/path-injection` (2 sites in `lib/static.js`)** — `nodeFs.createReadStream(absPath)` in `_readMeta` (line 161) and the request-serve hot path (line 1115). Suppression comments added referencing the upstream `_resolveSafe` lexical-resolve + `startsWith(rootResolved + nodePath.sep)` + realpath escape check at lines 181-207 — `absPath` is sandbox-validated against `root` before reaching these lines. (4) **`js/remote-property-injection` (4 sites)** — `lib/websocket.js` (`ext.params: {}` → `Object.create(null)`), `lib/middleware/csrf-protect.js` (`var out = {}` → `Object.create(null)` for cookie-parse output). `lib/middleware/body-parser.js` (multipart `fields[currentField] = ...`) retained as-is with suppression — `currentField` is gated upstream at line 867 by `POISONED_KEYS = new Set(["__proto__", "constructor", "prototype"])` refusing the field BEFORE assignment with a 400 BodyParserError. **Plus: SECURITY.md hardening checklist** gains 5 lines covering `b.middleware.idempotencyKey.dbStore` (hash + seal defaults), `b.metrics.snapshot` (out-of-process metrics export), `b.selfUpdate.standaloneVerifier` (zero-dep install-pipeline verifier), `b.pqcAgent.reload` (TLS-posture refresh without restart), `b.crypto.hashFilesParallel` (parallel SBOM/integrity-sweep hashing). **Plus: MIGRATING.md** now carries an "Out-of-band breaking changes" section (the v0.9.15 dbStore schema break is the first entry); `scripts/gen-migrating.js` extended with an `OUT_OF_BAND_BREAKS` table so future schema/on-disk format breaks land in MIGRATING.md without operators needing to grep CHANGELOG.
|
package/index.js
CHANGED
|
@@ -165,6 +165,10 @@ var guardMailCompose = require("./lib/guard-mail-compose");
|
|
|
165
165
|
var guardMailReply = require("./lib/guard-mail-reply");
|
|
166
166
|
var guardMailMove = require("./lib/guard-mail-move");
|
|
167
167
|
var guardMailSieve = require("./lib/guard-mail-sieve");
|
|
168
|
+
var guardAgentRegistry = require("./lib/guard-agent-registry");
|
|
169
|
+
var guardIdempotencyKey = require("./lib/guard-idempotency-key");
|
|
170
|
+
var agentOrchestrator = require("./lib/agent-orchestrator");
|
|
171
|
+
var agentIdempotency = require("./lib/agent-idempotency");
|
|
168
172
|
var guardArchive = require("./lib/guard-archive");
|
|
169
173
|
var guardJson = require("./lib/guard-json");
|
|
170
174
|
var guardYaml = require("./lib/guard-yaml");
|
|
@@ -408,6 +412,9 @@ module.exports = {
|
|
|
408
412
|
guardMailReply: guardMailReply,
|
|
409
413
|
guardMailMove: guardMailMove,
|
|
410
414
|
guardMailSieve: guardMailSieve,
|
|
415
|
+
guardAgentRegistry: guardAgentRegistry,
|
|
416
|
+
guardIdempotencyKey: guardIdempotencyKey,
|
|
417
|
+
agent: { orchestrator: agentOrchestrator, idempotency: agentIdempotency },
|
|
411
418
|
guardArchive: guardArchive,
|
|
412
419
|
guardJson: guardJson,
|
|
413
420
|
guardYaml: guardYaml,
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.agent.idempotency
|
|
4
|
+
* @nav Agent
|
|
5
|
+
* @title Agent Idempotency
|
|
6
|
+
* @order 55
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Cross-dispatch idempotency keys honored at every agent consumer
|
|
10
|
+
* boundary. Composes the v0.9.15 sealed `b.middleware.idempotencyKey`
|
|
11
|
+
* patterns (namespace-hashed keys, sealed result columns) into a
|
|
12
|
+
* generic agent-shaped surface:
|
|
13
|
+
*
|
|
14
|
+
* - **`instance.get(method, actorId, key)`** — returns cached
|
|
15
|
+
* result envelope or `null`. Sealed columns unseal via
|
|
16
|
+
* `b.cryptoField`.
|
|
17
|
+
* - **`instance.put(method, actorId, key, result, opts?)`** —
|
|
18
|
+
* serialize (`b.safeJson.stringify`) + seal + persist with TTL.
|
|
19
|
+
* Refuses if the same `(method, actorId, key)` already has a
|
|
20
|
+
* cached entry whose `requestFingerprint` differs from the
|
|
21
|
+
* supplied args fingerprint (defends key-reuse-different-args
|
|
22
|
+
* attack).
|
|
23
|
+
* - **`instance.invalidate(method, actorId, key)`** — operator
|
|
24
|
+
* opt-out (e.g., a saga compensation that needs to allow a
|
|
25
|
+
* fresh retry).
|
|
26
|
+
* - **`instance.gc({ olderThanMs })`** — periodic cleanup, wires
|
|
27
|
+
* into `b.scheduler`.
|
|
28
|
+
*
|
|
29
|
+
* JMAP §3.7 requires method-level idempotency ("if Email/set is
|
|
30
|
+
* retried with the same accountId+id, the server MUST return the
|
|
31
|
+
* same result"). With v0.9.22 every mutating agent method honors
|
|
32
|
+
* `args.idempotencyKey` and the consumer side dedupes BEFORE
|
|
33
|
+
* running — at-least-once delivery on the queue + at-most-once at
|
|
34
|
+
* the consumer = exactly-once end-to-end.
|
|
35
|
+
*
|
|
36
|
+
* ```js
|
|
37
|
+
* var idem = b.agent.idempotency.create({
|
|
38
|
+
* store: myBackingStore,
|
|
39
|
+
* ttlMs: b.C.TIME.hours(24),
|
|
40
|
+
* });
|
|
41
|
+
*
|
|
42
|
+
* var result = await agent.move({
|
|
43
|
+
* actor: u, fromFolder: "INBOX", toFolder: "Archive", objectIds: [oid],
|
|
44
|
+
* idempotencyKey: "jmap-req-abc",
|
|
45
|
+
* });
|
|
46
|
+
*
|
|
47
|
+
* // Retry returns cached result, doesn't re-bump modseq:
|
|
48
|
+
* var result2 = await agent.move({
|
|
49
|
+
* actor: u, fromFolder: "INBOX", toFolder: "Archive", objectIds: [oid],
|
|
50
|
+
* idempotencyKey: "jmap-req-abc",
|
|
51
|
+
* });
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* @card
|
|
55
|
+
* JMAP retry-safe semantics for every agent method. Keys hashed at
|
|
56
|
+
* the boundary; results sealed + persisted with TTL; consumer-side
|
|
57
|
+
* dedup at the dispatch boundary turns at-least-once + at-most-once
|
|
58
|
+
* into exactly-once.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
var lazyRequire = require("./lazy-require");
|
|
62
|
+
var C = require("./constants");
|
|
63
|
+
var { defineClass } = require("./framework-error");
|
|
64
|
+
var bCrypto = require("./crypto");
|
|
65
|
+
var safeJson = require("./safe-json");
|
|
66
|
+
var guardIdempotencyKey = require("./guard-idempotency-key");
|
|
67
|
+
|
|
68
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
69
|
+
|
|
70
|
+
var AgentIdempotencyError = defineClass("AgentIdempotencyError", { alwaysPermanent: true });
|
|
71
|
+
|
|
72
|
+
var DEFAULT_TTL_MS = C.TIME.hours(24);
|
|
73
|
+
var MAX_RESULT_BYTES = C.BYTES.mib(1);
|
|
74
|
+
// Parse ceiling tracks the operator's configured maxResultBytes (set
|
|
75
|
+
// per-instance via opts.maxResultBytes) — see _get. A static parse cap
|
|
76
|
+
// would silently lose entries when operators raise the write cap.
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @primitive b.agent.idempotency.create
|
|
80
|
+
* @signature b.agent.idempotency.create(opts)
|
|
81
|
+
* @since 0.9.22
|
|
82
|
+
* @status stable
|
|
83
|
+
* @related b.agent.orchestrator.create, b.middleware.idempotencyKey.dbStore
|
|
84
|
+
*
|
|
85
|
+
* Create an idempotency instance for an agent. Operator supplies a
|
|
86
|
+
* backing store implementing `{ get, put, delete, gc }`; framework
|
|
87
|
+
* ships an in-memory default for single-process deployments.
|
|
88
|
+
*
|
|
89
|
+
* @opts
|
|
90
|
+
* store: { get, put, delete, gc }, // optional; in-memory default
|
|
91
|
+
* audit: b.audit namespace, // optional
|
|
92
|
+
* ttlMs: number, // default 24h
|
|
93
|
+
* maxResultBytes: number, // default 1 MiB per entry
|
|
94
|
+
* fingerprintArgs: boolean, // default true
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* var idem = b.agent.idempotency.create({});
|
|
98
|
+
* var existing = await idem.get("move", "u1", "jmap-req-abc");
|
|
99
|
+
* if (existing) return existing.result;
|
|
100
|
+
* var result = await mailAgent.move(args);
|
|
101
|
+
* await idem.put("move", "u1", "jmap-req-abc", result, { argsFingerprint: "..." });
|
|
102
|
+
*/
|
|
103
|
+
function create(opts) {
|
|
104
|
+
opts = opts || {};
|
|
105
|
+
var store = opts.store || _inMemoryBackend();
|
|
106
|
+
if (typeof store.get !== "function" || typeof store.put !== "function" ||
|
|
107
|
+
typeof store.delete !== "function") {
|
|
108
|
+
throw new AgentIdempotencyError("agent-idempotency/bad-store",
|
|
109
|
+
"create: store must expose { get, put, delete }");
|
|
110
|
+
}
|
|
111
|
+
var ttlMs = typeof opts.ttlMs === "number" ? opts.ttlMs : DEFAULT_TTL_MS;
|
|
112
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
|
113
|
+
throw new AgentIdempotencyError("agent-idempotency/bad-ttl",
|
|
114
|
+
"create: opts.ttlMs must be a positive finite number");
|
|
115
|
+
}
|
|
116
|
+
var maxResultBytes = typeof opts.maxResultBytes === "number" ? opts.maxResultBytes : MAX_RESULT_BYTES;
|
|
117
|
+
var fingerprintArgs = opts.fingerprintArgs !== false;
|
|
118
|
+
var auditImpl = opts.audit || audit();
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
get: function (method, actorId, key) { return _get(store, method, actorId, key, auditImpl, ttlMs, maxResultBytes); },
|
|
122
|
+
put: function (method, actorId, key, result, putOpts) { return _put(store, method, actorId, key, result, putOpts || {}, ttlMs, maxResultBytes, fingerprintArgs, auditImpl); },
|
|
123
|
+
invalidate: function (method, actorId, key) { return _invalidate(store, method, actorId, key, auditImpl); },
|
|
124
|
+
gc: function (gcOpts) { return _gc(store, gcOpts || {}, auditImpl); },
|
|
125
|
+
fingerprintArgs: _fingerprintArgs,
|
|
126
|
+
keyHash: _keyHash,
|
|
127
|
+
AgentIdempotencyError: AgentIdempotencyError,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- Core API -------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
async function _get(store, method, actorId, key, auditImpl, ttlMs, maxResultBytes) {
|
|
134
|
+
_checkArgs(method, actorId, key);
|
|
135
|
+
guardIdempotencyKey.validate(key);
|
|
136
|
+
var hash = _keyHash(method, actorId, key);
|
|
137
|
+
var row = await store.get(method, actorId, hash);
|
|
138
|
+
if (!row) return null;
|
|
139
|
+
if (row.expiresAt && row.expiresAt < Date.now()) {
|
|
140
|
+
// Expired entries get GC'd lazily on read.
|
|
141
|
+
await store.delete(method, actorId, hash);
|
|
142
|
+
_safeAudit(auditImpl, "agent.idempotency.expired", null,
|
|
143
|
+
{ method: method, actorIdHash: _truncHash(_actorIdHash(actorId)) });
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
var nextReplayCount = (row.replayCount || 0) + 1;
|
|
147
|
+
_safeAudit(auditImpl, "agent.idempotency.replay", null, {
|
|
148
|
+
method: method, actorIdHash: _truncHash(_actorIdHash(actorId)),
|
|
149
|
+
firstAt: row.firstAt, replayCount: nextReplayCount,
|
|
150
|
+
});
|
|
151
|
+
// Deserialize the sealed result blob. v0.9.22 ships a simple
|
|
152
|
+
// safeJson re-parse since the result was JSON-stringified at put().
|
|
153
|
+
// v0.9.25 tenant integration will swap this for per-tenant sealRow
|
|
154
|
+
// unseal when the row is sealed at rest.
|
|
155
|
+
var result;
|
|
156
|
+
try {
|
|
157
|
+
// Parse cap mirrors the operator's configured maxResultBytes (the
|
|
158
|
+
// same cap put() enforced on write) — a static parse ceiling would
|
|
159
|
+
// turn valid cached entries into permanent replay errors when the
|
|
160
|
+
// operator raises the write cap.
|
|
161
|
+
result = safeJson.parse(row.resultBlob, { maxBytes: maxResultBytes });
|
|
162
|
+
} catch (e) {
|
|
163
|
+
throw new AgentIdempotencyError("agent-idempotency/corrupt-result",
|
|
164
|
+
"get: cached result failed to parse — " + (e && e.message ? e.message : String(e)));
|
|
165
|
+
}
|
|
166
|
+
// Persist the incremented replayCount + lastReplayedAt so subsequent
|
|
167
|
+
// gets see the updated state. Operator audit pipelines rely on
|
|
168
|
+
// replayCount to surface retry storms.
|
|
169
|
+
row.replayCount = nextReplayCount;
|
|
170
|
+
row.lastReplayedAt = Date.now();
|
|
171
|
+
await store.put(method, actorId, hash, row);
|
|
172
|
+
return {
|
|
173
|
+
result: result,
|
|
174
|
+
firstAt: row.firstAt,
|
|
175
|
+
lastReplayedAt: row.lastReplayedAt,
|
|
176
|
+
replayCount: nextReplayCount,
|
|
177
|
+
requestFingerprint: row.requestFingerprint,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function _put(store, method, actorId, key, result, putOpts, ttlMs, maxResultBytes, fingerprintArgs, auditImpl) {
|
|
182
|
+
_checkArgs(method, actorId, key);
|
|
183
|
+
guardIdempotencyKey.validate(key);
|
|
184
|
+
var hash = _keyHash(method, actorId, key);
|
|
185
|
+
var existing = await store.get(method, actorId, hash);
|
|
186
|
+
var requestFingerprint = putOpts.requestFingerprint ||
|
|
187
|
+
(fingerprintArgs && putOpts.args ? _fingerprintArgs(putOpts.args) : null);
|
|
188
|
+
|
|
189
|
+
if (existing && existing.requestFingerprint && requestFingerprint &&
|
|
190
|
+
existing.requestFingerprint !== requestFingerprint) {
|
|
191
|
+
_safeAudit(auditImpl, "agent.idempotency.key_reuse_different_args", null, {
|
|
192
|
+
method: method, actorIdHash: _truncHash(_actorIdHash(actorId)),
|
|
193
|
+
});
|
|
194
|
+
throw new AgentIdempotencyError("agent-idempotency/key-reuse-different-args",
|
|
195
|
+
"put: key '" + key + "' reused with different args for method '" + method +
|
|
196
|
+
"' — refused per JMAP §3.7 semantics");
|
|
197
|
+
}
|
|
198
|
+
var resultBlob;
|
|
199
|
+
try { resultBlob = safeJson.stringify(result); }
|
|
200
|
+
catch (e) {
|
|
201
|
+
throw new AgentIdempotencyError("agent-idempotency/bad-result",
|
|
202
|
+
"put: result not JSON-serializable: " + (e && e.message ? e.message : String(e)));
|
|
203
|
+
}
|
|
204
|
+
if (Buffer.byteLength(resultBlob, "utf8") > maxResultBytes) {
|
|
205
|
+
throw new AgentIdempotencyError("agent-idempotency/result-too-big",
|
|
206
|
+
"put: serialized result " + Buffer.byteLength(resultBlob, "utf8") +
|
|
207
|
+
" bytes exceeds maxResultBytes=" + maxResultBytes);
|
|
208
|
+
}
|
|
209
|
+
var now = Date.now();
|
|
210
|
+
var row = {
|
|
211
|
+
method: method,
|
|
212
|
+
actorIdHash: _actorIdHash(actorId),
|
|
213
|
+
keyHash: hash,
|
|
214
|
+
requestFingerprint: requestFingerprint,
|
|
215
|
+
resultBlob: resultBlob,
|
|
216
|
+
firstAt: existing && existing.firstAt ? existing.firstAt : now,
|
|
217
|
+
lastWrittenAt: now,
|
|
218
|
+
replayCount: existing ? (existing.replayCount || 0) : 0,
|
|
219
|
+
expiresAt: now + ttlMs,
|
|
220
|
+
};
|
|
221
|
+
await store.put(method, actorId, hash, row);
|
|
222
|
+
_safeAudit(auditImpl, "agent.idempotency.put", null, {
|
|
223
|
+
method: method, actorIdHash: _truncHash(row.actorIdHash),
|
|
224
|
+
resultBytes: Buffer.byteLength(resultBlob, "utf8"),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function _invalidate(store, method, actorId, key, auditImpl) {
|
|
229
|
+
_checkArgs(method, actorId, key);
|
|
230
|
+
guardIdempotencyKey.validate(key);
|
|
231
|
+
var hash = _keyHash(method, actorId, key);
|
|
232
|
+
await store.delete(method, actorId, hash);
|
|
233
|
+
_safeAudit(auditImpl, "agent.idempotency.invalidated", null, {
|
|
234
|
+
method: method, actorIdHash: _truncHash(_actorIdHash(actorId)),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function _gc(store, opts, auditImpl) {
|
|
239
|
+
if (typeof store.gc !== "function") {
|
|
240
|
+
// Backend doesn't support GC (in-memory default does); operator
|
|
241
|
+
// periodic cleanup of durable stores is the operator's job.
|
|
242
|
+
return { purged: 0 };
|
|
243
|
+
}
|
|
244
|
+
var olderThanMs = typeof opts.olderThanMs === "number" ? opts.olderThanMs : 0;
|
|
245
|
+
var cutoff = Date.now() - olderThanMs;
|
|
246
|
+
var r = await store.gc({ expiresAtBefore: cutoff });
|
|
247
|
+
_safeAudit(auditImpl, "agent.idempotency.gc", null, {
|
|
248
|
+
purged: r && r.purged ? r.purged : 0,
|
|
249
|
+
});
|
|
250
|
+
return r || { purged: 0 };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- Internals ------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
function _keyHash(method, actorId, key) {
|
|
256
|
+
// Namespaced hash — same pattern as v0.9.15 b.middleware.idempotencyKey
|
|
257
|
+
// dbStore so raw operator-supplied keys never reach disk.
|
|
258
|
+
return bCrypto.namespaceHash("agent.idempotency",
|
|
259
|
+
method + "\0" + actorId + "\0" + key);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function _actorIdHash(actorId) {
|
|
263
|
+
return bCrypto.namespaceHash("agent.idempotency.actor", String(actorId));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function _truncHash(hash) {
|
|
267
|
+
if (typeof hash !== "string") return "";
|
|
268
|
+
return hash.slice(0, 16); // allow:raw-byte-literal — audit-log truncation length, not a size cap
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function _fingerprintArgs(args) {
|
|
272
|
+
// Strip the idempotencyKey itself out of fingerprint computation —
|
|
273
|
+
// the key IS the cache lookup; including it would make every key a
|
|
274
|
+
// unique fingerprint and defeat the args-mismatch defense. Also
|
|
275
|
+
// strip framework-internal cross-cutting fields that vary per-hop.
|
|
276
|
+
var argsClone = Object.assign({}, args);
|
|
277
|
+
delete argsClone.idempotencyKey;
|
|
278
|
+
delete argsClone._postureChain;
|
|
279
|
+
delete argsClone._traceContext;
|
|
280
|
+
// Canonicalize via RFC 8785 JCS (key-sorted) so two semantically
|
|
281
|
+
// identical args objects with different key insertion order produce
|
|
282
|
+
// the same fingerprint. Cross-producer / cross-runtime retries (JMAP
|
|
283
|
+
// clients, queue replay, different JSON parsers) construct objects
|
|
284
|
+
// with different key order; without canonicalization the args-
|
|
285
|
+
// mismatch check would fire false-positives.
|
|
286
|
+
var canonical;
|
|
287
|
+
try { canonical = safeJson.canonical(argsClone); }
|
|
288
|
+
catch (_e) { canonical = "[unserializable]"; }
|
|
289
|
+
return bCrypto.namespaceHash("agent.idempotency.fingerprint", canonical);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function _checkArgs(method, actorId, key) {
|
|
293
|
+
if (typeof method !== "string" || method.length === 0) {
|
|
294
|
+
throw new AgentIdempotencyError("agent-idempotency/bad-method",
|
|
295
|
+
"method must be a non-empty string");
|
|
296
|
+
}
|
|
297
|
+
if (typeof actorId !== "string" || actorId.length === 0) {
|
|
298
|
+
throw new AgentIdempotencyError("agent-idempotency/bad-actor-id",
|
|
299
|
+
"actorId must be a non-empty string");
|
|
300
|
+
}
|
|
301
|
+
// key is validated separately via guardIdempotencyKey.validate.
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function _inMemoryBackend() {
|
|
305
|
+
var map = new Map();
|
|
306
|
+
function _k(method, actorId, hash) { return method + "\0" + actorId + "\0" + hash; }
|
|
307
|
+
return {
|
|
308
|
+
get: function (method, actorId, hash) {
|
|
309
|
+
return Promise.resolve(map.get(_k(method, actorId, hash)) || null);
|
|
310
|
+
},
|
|
311
|
+
put: function (method, actorId, hash, row) {
|
|
312
|
+
map.set(_k(method, actorId, hash), row);
|
|
313
|
+
return Promise.resolve();
|
|
314
|
+
},
|
|
315
|
+
delete: function (method, actorId, hash) {
|
|
316
|
+
map.delete(_k(method, actorId, hash));
|
|
317
|
+
return Promise.resolve();
|
|
318
|
+
},
|
|
319
|
+
gc: function (gcOpts) {
|
|
320
|
+
var cutoff = gcOpts && gcOpts.expiresAtBefore ? gcOpts.expiresAtBefore : 0;
|
|
321
|
+
var purged = 0;
|
|
322
|
+
map.forEach(function (row, k) {
|
|
323
|
+
if (row.expiresAt && row.expiresAt <= cutoff) {
|
|
324
|
+
map.delete(k);
|
|
325
|
+
purged += 1;
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
return Promise.resolve({ purged: purged });
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function _safeAudit(auditImpl, action, actor, metadata) {
|
|
334
|
+
try {
|
|
335
|
+
auditImpl.safeEmit({
|
|
336
|
+
action: action,
|
|
337
|
+
actor: actor ? { id: actor.id, roles: actor.roles || [] } : { id: "<system>" },
|
|
338
|
+
outcome: action.indexOf("denied") >= 0 || action.indexOf("different_args") >= 0 ? "failure" : "success",
|
|
339
|
+
metadata: metadata || {},
|
|
340
|
+
});
|
|
341
|
+
} catch (_e) { /* drop-silent */ }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
module.exports = {
|
|
345
|
+
create: create,
|
|
346
|
+
AgentIdempotencyError: AgentIdempotencyError,
|
|
347
|
+
guards: {
|
|
348
|
+
key: guardIdempotencyKey,
|
|
349
|
+
},
|
|
350
|
+
};
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.agent.orchestrator
|
|
4
|
+
* @nav Agent
|
|
5
|
+
* @title Agent Orchestrator
|
|
6
|
+
* @order 50
|
|
7
|
+
* @featured true
|
|
8
|
+
*
|
|
9
|
+
* @intro
|
|
10
|
+
* Framework-level supervisor for every agent blamejs ships
|
|
11
|
+
* (`b.mail.agent` today; future search-index / AI-classify / DSR /
|
|
12
|
+
* c2pa-watermark agents). The orchestrator owns:
|
|
13
|
+
*
|
|
14
|
+
* - **Registry** (`register` / `lookup` / `unregister` / `list`)
|
|
15
|
+
* — pluggable backend; in-memory default, durable via operator-
|
|
16
|
+
* supplied `b.config.loadDbBacked` for restart-survival. Sealed
|
|
17
|
+
* rows so tenant names + endpoint metadata don't leak in DB
|
|
18
|
+
* dumps.
|
|
19
|
+
* - **Sharded topics** (`spawnConsumers`) — consistent-hash route
|
|
20
|
+
* per-shard so each tenant's traffic owns one shard's ordering.
|
|
21
|
+
* - **Leader-elected singletons** (`elect`) — composes `b.cluster`
|
|
22
|
+
* DB-row election. Operator marks methods that must run on
|
|
23
|
+
* exactly one node (MDN batch dispatch, virus-DB refresh,
|
|
24
|
+
* journal compaction) as singletons.
|
|
25
|
+
* - **Drain** (`drain`) — `consumer.stop()` on every spawned
|
|
26
|
+
* consumer; wait for in-flight envelopes via `b.outbox`; audit.
|
|
27
|
+
* Wires into `b.appShutdown` as a registered phase.
|
|
28
|
+
* - **Health probe** (`health`) — aggregates per-agent + per-
|
|
29
|
+
* consumer + per-election state into one shape for
|
|
30
|
+
* `b.middleware.healthcheck`.
|
|
31
|
+
*
|
|
32
|
+
* The orchestrator is the **in-process supervisor of agents**, NOT
|
|
33
|
+
* the **OS-level supervisor of processes**. Spawn / restart-on-
|
|
34
|
+
* crash / autoscaling / network routing all delegate to pm2 /
|
|
35
|
+
* systemd / k8s / Nomad — the framework doesn't compete.
|
|
36
|
+
*
|
|
37
|
+
* ```js
|
|
38
|
+
* var orch = b.agent.orchestrator.create({
|
|
39
|
+
* audit: b.audit,
|
|
40
|
+
* permissions: myPerms,
|
|
41
|
+
* backend: operatorBackend, // optional; in-memory default
|
|
42
|
+
* });
|
|
43
|
+
*
|
|
44
|
+
* await orch.register("tenant-acme.mail", mailAgent, { agentKind: "mail" });
|
|
45
|
+
* var agent = await orch.lookup("tenant-acme.mail");
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* @card
|
|
49
|
+
* The framework-level supervisor for every agent blamejs ships.
|
|
50
|
+
* Registry, sharded topics, leader-elected singletons, drain, and
|
|
51
|
+
* health probe — operators stop wiring these per-agent.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
var lazyRequire = require("./lazy-require");
|
|
55
|
+
var C = require("./constants");
|
|
56
|
+
var { defineClass } = require("./framework-error");
|
|
57
|
+
var guardAgentRegistry = require("./guard-agent-registry");
|
|
58
|
+
var bCrypto = require("./crypto");
|
|
59
|
+
|
|
60
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
61
|
+
var cluster = lazyRequire(function () { return require("./cluster"); });
|
|
62
|
+
|
|
63
|
+
var AgentOrchestratorError = defineClass("AgentOrchestratorError", { alwaysPermanent: true });
|
|
64
|
+
|
|
65
|
+
var DEFAULT_DRAIN_TIMEOUT_MS = C.TIME.minutes(2);
|
|
66
|
+
var STREAM_ID_RAND_BYTES = 8; // allow:raw-byte-literal — stream-id random-suffix byte length, not a size cap
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @primitive b.agent.orchestrator.create
|
|
70
|
+
* @signature b.agent.orchestrator.create(opts)
|
|
71
|
+
* @since 0.9.21
|
|
72
|
+
* @status stable
|
|
73
|
+
* @related b.mail.agent.create, b.cluster, b.appShutdown
|
|
74
|
+
*
|
|
75
|
+
* Create the orchestrator. Returns a singleton-style facade with
|
|
76
|
+
* registry / spawn / elect / drain / health methods. Operator runs
|
|
77
|
+
* one orchestrator per process; multi-process deployments share
|
|
78
|
+
* coordination via the backing store + `b.cluster`.
|
|
79
|
+
*
|
|
80
|
+
* @opts
|
|
81
|
+
* audit: b.audit namespace, // optional; defaults to b.audit
|
|
82
|
+
* permissions: b.permissions instance, // optional; orchestrator skips RBAC if absent
|
|
83
|
+
* backend: { get, set, delete, list }, // optional; in-memory default
|
|
84
|
+
* cluster: b.cluster module, // optional; defaults to b.cluster
|
|
85
|
+
* appShutdown: b.appShutdown.create() // optional; orchestrator registers drain phase if supplied
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* var orch = b.agent.orchestrator.create({});
|
|
89
|
+
* await orch.register("tenant-acme.mail", mailAgent, { agentKind: "mail" });
|
|
90
|
+
* var agent = await orch.lookup("tenant-acme.mail");
|
|
91
|
+
* var folders = await agent.folders({ actor: { id: "u1" } });
|
|
92
|
+
*/
|
|
93
|
+
function create(opts) {
|
|
94
|
+
opts = opts || {};
|
|
95
|
+
var backend = opts.backend || _inMemoryBackend();
|
|
96
|
+
if (typeof backend.get !== "function" || typeof backend.set !== "function" ||
|
|
97
|
+
typeof backend.delete !== "function" || typeof backend.list !== "function") {
|
|
98
|
+
throw new AgentOrchestratorError("agent-orchestrator/bad-backend",
|
|
99
|
+
"b.agent.orchestrator.create: backend must expose { get, set, delete, list }");
|
|
100
|
+
}
|
|
101
|
+
var clusterImpl = opts.cluster || cluster();
|
|
102
|
+
var auditImpl = opts.audit || audit();
|
|
103
|
+
var permissions = opts.permissions || null;
|
|
104
|
+
|
|
105
|
+
var ctx = {
|
|
106
|
+
backend: backend,
|
|
107
|
+
cluster: clusterImpl,
|
|
108
|
+
audit: auditImpl,
|
|
109
|
+
permissions: permissions,
|
|
110
|
+
spawnedConsumers: [],
|
|
111
|
+
streams: new Map(),
|
|
112
|
+
elections: new Map(),
|
|
113
|
+
// Live agent objects stay in-process — DB/JSON backends can't
|
|
114
|
+
// serialize function properties. The backend row carries only the
|
|
115
|
+
// operator-supplied metadata (kind / tenantId / posture / ...);
|
|
116
|
+
// every consuming process holds its own runtime map of name → agent.
|
|
117
|
+
liveAgents: new Map(),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Wire the drain phase into b.appShutdown if the operator supplied one.
|
|
121
|
+
if (opts.appShutdown && typeof opts.appShutdown.registerPhase === "function") {
|
|
122
|
+
opts.appShutdown.registerPhase("agent.orchestrator.drain", function () {
|
|
123
|
+
return _drain(ctx, { timeoutMs: DEFAULT_DRAIN_TIMEOUT_MS });
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
register: function (name, agent, regOpts) { return _register(ctx, name, agent, regOpts || {}); },
|
|
129
|
+
unregister: function (name, args) { return _unregister(ctx, name, args || {}); },
|
|
130
|
+
lookup: function (name, args) { return _lookup(ctx, name, args || {}); },
|
|
131
|
+
list: function (args) { return _list(ctx, args || {}); },
|
|
132
|
+
spawnConsumers: function (args) { return _spawnConsumers(ctx, args || {}); },
|
|
133
|
+
elect: function (args) { return _elect(ctx, args || {}); },
|
|
134
|
+
drain: function (args) { return _drain(ctx, args || {}); },
|
|
135
|
+
health: function () { return _health(ctx); },
|
|
136
|
+
registerStream: function (info) { return _registerStream(ctx, info || {}); },
|
|
137
|
+
unregisterStream: function (streamId) { return _unregisterStream(ctx, streamId); },
|
|
138
|
+
isDraining: function (streamId) { return ctx.draining === true; },
|
|
139
|
+
AgentOrchestratorError: AgentOrchestratorError,
|
|
140
|
+
_ctx: ctx, // test-only introspection
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- Registry -------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
async function _register(ctx, name, agent, regOpts) {
|
|
147
|
+
guardAgentRegistry.validate({ kind: "register", name: name, agentKind: regOpts.agentKind }, {});
|
|
148
|
+
_checkPermission(ctx, regOpts.actor, "agent-registry:write");
|
|
149
|
+
if (!agent || typeof agent !== "object") {
|
|
150
|
+
throw new AgentOrchestratorError("agent-orchestrator/bad-agent",
|
|
151
|
+
"register: agent object required");
|
|
152
|
+
}
|
|
153
|
+
var existing = await ctx.backend.get(name);
|
|
154
|
+
if (existing) {
|
|
155
|
+
throw new AgentOrchestratorError("agent-orchestrator/duplicate",
|
|
156
|
+
"register: '" + name + "' already registered; unregister first");
|
|
157
|
+
}
|
|
158
|
+
// Backend row carries operator-supplied serializable metadata only —
|
|
159
|
+
// DB/JSON backends can't preserve function properties. The live agent
|
|
160
|
+
// ref is held in-process via ctx.liveAgents (see ctx init above).
|
|
161
|
+
var row = {
|
|
162
|
+
name: name,
|
|
163
|
+
kind: regOpts.agentKind,
|
|
164
|
+
tenantId: regOpts.tenantId || null,
|
|
165
|
+
posture: regOpts.posture || null,
|
|
166
|
+
registeredAt: Date.now(),
|
|
167
|
+
metadata: regOpts.metadata || {},
|
|
168
|
+
};
|
|
169
|
+
await ctx.backend.set(name, row);
|
|
170
|
+
ctx.liveAgents.set(name, agent);
|
|
171
|
+
_safeAudit(ctx, "agent.orchestrator.registered", regOpts.actor, {
|
|
172
|
+
name: name, agentKind: regOpts.agentKind, tenantId: row.tenantId,
|
|
173
|
+
});
|
|
174
|
+
return { name: name, registeredAt: row.registeredAt };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function _unregister(ctx, name, args) {
|
|
178
|
+
guardAgentRegistry.validate({ kind: "unregister", name: name }, {});
|
|
179
|
+
_checkPermission(ctx, args.actor, "agent-registry:write");
|
|
180
|
+
var row = await ctx.backend.get(name);
|
|
181
|
+
if (!row) {
|
|
182
|
+
throw new AgentOrchestratorError("agent-orchestrator/not-found",
|
|
183
|
+
"unregister: '" + name + "' not registered");
|
|
184
|
+
}
|
|
185
|
+
await ctx.backend.delete(name);
|
|
186
|
+
ctx.liveAgents.delete(name);
|
|
187
|
+
_safeAudit(ctx, "agent.orchestrator.unregistered", args.actor, {
|
|
188
|
+
name: name, agentKind: row.kind,
|
|
189
|
+
});
|
|
190
|
+
return { name: name };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function _lookup(ctx, name, args) {
|
|
194
|
+
guardAgentRegistry.validate({ kind: "lookup", name: name }, {});
|
|
195
|
+
_checkPermission(ctx, args.actor, "agent-registry:read");
|
|
196
|
+
// Live agent ref lives in-process; the backend row exists only as
|
|
197
|
+
// a metadata declaration. In multi-process deployments each process
|
|
198
|
+
// hydrates its own liveAgents map by calling register() locally.
|
|
199
|
+
var agent = ctx.liveAgents.get(name);
|
|
200
|
+
if (agent) return agent;
|
|
201
|
+
var row = await ctx.backend.get(name);
|
|
202
|
+
if (!row) {
|
|
203
|
+
_safeAudit(ctx, "agent.orchestrator.lookup_miss", args.actor, { name: name });
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
// Backend row exists but no live ref in this process — operator
|
|
207
|
+
// didn't hydrate locally. Surface explicitly so the caller knows
|
|
208
|
+
// to register the agent or route to the process that holds it.
|
|
209
|
+
throw new AgentOrchestratorError("agent-orchestrator/not-hydrated",
|
|
210
|
+
"lookup: '" + name + "' exists in registry but no live agent ref " +
|
|
211
|
+
"in this process — register the agent locally first");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function _list(ctx, args) {
|
|
215
|
+
guardAgentRegistry.validate({ kind: "list" }, {});
|
|
216
|
+
_checkPermission(ctx, args.actor, "agent-registry:read");
|
|
217
|
+
var rows = await ctx.backend.list();
|
|
218
|
+
return rows.filter(function (r) {
|
|
219
|
+
if (args.kind && r.kind !== args.kind) return false;
|
|
220
|
+
if (args.tenantId && r.tenantId !== args.tenantId) return false;
|
|
221
|
+
return true;
|
|
222
|
+
}).map(function (r) {
|
|
223
|
+
return {
|
|
224
|
+
name: r.name, kind: r.kind, tenantId: r.tenantId,
|
|
225
|
+
posture: r.posture, registeredAt: r.registeredAt,
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---- Sharded topic dispatch -----------------------------------------------
|
|
231
|
+
|
|
232
|
+
function _spawnConsumers(ctx, args) {
|
|
233
|
+
if (!args.agent || typeof args.agent !== "object") {
|
|
234
|
+
throw new AgentOrchestratorError("agent-orchestrator/bad-agent",
|
|
235
|
+
"spawnConsumers: agent required");
|
|
236
|
+
}
|
|
237
|
+
if (!args.queue || typeof args.queue.consume !== "function") {
|
|
238
|
+
throw new AgentOrchestratorError("agent-orchestrator/bad-queue",
|
|
239
|
+
"spawnConsumers: queue with .consume() required");
|
|
240
|
+
}
|
|
241
|
+
var shards = typeof args.shards === "number" ? args.shards : 1;
|
|
242
|
+
if (!Number.isInteger(shards) || shards < 1 || shards > 256) { // allow:raw-byte-literal — shard cap
|
|
243
|
+
throw new AgentOrchestratorError("agent-orchestrator/bad-shard-count",
|
|
244
|
+
"spawnConsumers: shards must be an integer in 1..256");
|
|
245
|
+
}
|
|
246
|
+
var topicBase = args.taskTopic || "agent.tasks";
|
|
247
|
+
var consumers = [];
|
|
248
|
+
for (var i = 0; i < shards; i += 1) {
|
|
249
|
+
var topic = shards === 1 ? topicBase : topicBase + "." + i;
|
|
250
|
+
var c = _spawnSingleConsumer(ctx, args.agent, args.queue, topic, args.maxConcurrency || 4);
|
|
251
|
+
consumers.push(c);
|
|
252
|
+
ctx.spawnedConsumers.push(c);
|
|
253
|
+
}
|
|
254
|
+
_safeAudit(ctx, "agent.orchestrator.consumers_spawned", args.actor, {
|
|
255
|
+
shards: shards, topicBase: topicBase, perShardConcurrency: args.maxConcurrency || 4,
|
|
256
|
+
});
|
|
257
|
+
return consumers;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function _spawnSingleConsumer(ctx, agent, queue, topic, maxConcurrency) {
|
|
261
|
+
var stopped = false;
|
|
262
|
+
var subscription = null;
|
|
263
|
+
return {
|
|
264
|
+
topic: topic,
|
|
265
|
+
start: async function () {
|
|
266
|
+
if (subscription) {
|
|
267
|
+
throw new AgentOrchestratorError("agent-orchestrator/already-started",
|
|
268
|
+
"consumer for topic '" + topic + "': already started");
|
|
269
|
+
}
|
|
270
|
+
subscription = await queue.consume(topic, async function (envelope) {
|
|
271
|
+
if (stopped) return;
|
|
272
|
+
var method = envelope.method;
|
|
273
|
+
if (!method || typeof agent[method] !== "function") {
|
|
274
|
+
var dotted = method && method.indexOf(".") > 0 ? method.split(".") : null;
|
|
275
|
+
if (dotted && agent[dotted[0]] && typeof agent[dotted[0]][dotted[1]] === "function") {
|
|
276
|
+
return agent[dotted[0]][dotted[1]](envelope.args);
|
|
277
|
+
}
|
|
278
|
+
throw new AgentOrchestratorError("agent-orchestrator/unknown-method",
|
|
279
|
+
"consumer: unknown method '" + method + "'");
|
|
280
|
+
}
|
|
281
|
+
return agent[method](envelope.args);
|
|
282
|
+
}, { maxConcurrency: maxConcurrency });
|
|
283
|
+
},
|
|
284
|
+
stop: async function () {
|
|
285
|
+
stopped = true;
|
|
286
|
+
if (subscription && typeof subscription.unsubscribe === "function") {
|
|
287
|
+
await subscription.unsubscribe();
|
|
288
|
+
}
|
|
289
|
+
subscription = null;
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* @primitive b.agent.orchestrator.shardFor
|
|
296
|
+
* @signature b.agent.orchestrator.shardFor(shardKey, shards)
|
|
297
|
+
* @since 0.9.21
|
|
298
|
+
* @status stable
|
|
299
|
+
* @related b.agent.orchestrator.create
|
|
300
|
+
*
|
|
301
|
+
* Consistent-hash router for sharded topic dispatch. Operator passes
|
|
302
|
+
* a stable shard-key (e.g. tenantId or actor.id); orchestrator picks
|
|
303
|
+
* the topic suffix so each tenant's traffic owns one shard's ordering.
|
|
304
|
+
* Uses FNV-1a 32-bit — fast, good distribution for short keys, no
|
|
305
|
+
* cryptographic guarantees (shard routing is not security-bearing).
|
|
306
|
+
* Empty key returns 0; `shards <= 1` always returns 0.
|
|
307
|
+
*
|
|
308
|
+
* @example
|
|
309
|
+
* var shard = b.agent.orchestrator.shardFor("tenant-acme", 8);
|
|
310
|
+
* // → integer in [0, 8)
|
|
311
|
+
*/
|
|
312
|
+
function shardFor(shardKey, shards) {
|
|
313
|
+
if (typeof shardKey !== "string" || shardKey.length === 0) return 0;
|
|
314
|
+
if (shards <= 1) return 0;
|
|
315
|
+
// FNV-1a 32-bit — fast + good distribution for short keys.
|
|
316
|
+
var h = 2166136261; // allow:raw-byte-literal — FNV-1a offset basis
|
|
317
|
+
for (var i = 0; i < shardKey.length; i += 1) {
|
|
318
|
+
h ^= shardKey.charCodeAt(i);
|
|
319
|
+
h = (h * 16777619) >>> 0; // allow:raw-byte-literal — FNV-1a prime
|
|
320
|
+
}
|
|
321
|
+
return h % shards;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---- Leader-elected singletons --------------------------------------------
|
|
325
|
+
|
|
326
|
+
async function _elect(ctx, args) {
|
|
327
|
+
if (typeof args.resource !== "string" || args.resource.length === 0) {
|
|
328
|
+
throw new AgentOrchestratorError("agent-orchestrator/bad-elect-args",
|
|
329
|
+
"elect: resource required");
|
|
330
|
+
}
|
|
331
|
+
// Composes b.cluster's leader-election. When cluster mode is active,
|
|
332
|
+
// delegate; when not, the local node is the trivial leader for this
|
|
333
|
+
// process's lifetime (single-process deployment).
|
|
334
|
+
var isClusterMode = false;
|
|
335
|
+
try { isClusterMode = ctx.cluster.isClusterMode(); } catch (_e) { isClusterMode = false; }
|
|
336
|
+
if (!isClusterMode) {
|
|
337
|
+
// Single-process trivial leader.
|
|
338
|
+
var elec = { isLeader: true, fencingToken: 1, resource: args.resource };
|
|
339
|
+
ctx.elections.set(args.resource, elec);
|
|
340
|
+
_safeAudit(ctx, "agent.orchestrator.elected", args.actor, {
|
|
341
|
+
resource: args.resource, mode: "single-process",
|
|
342
|
+
});
|
|
343
|
+
return elec;
|
|
344
|
+
}
|
|
345
|
+
// Cluster mode: query current leader state via b.cluster.
|
|
346
|
+
var leaderRow = null;
|
|
347
|
+
try { leaderRow = await ctx.cluster.currentLeader(); } catch (_e) { leaderRow = null; }
|
|
348
|
+
var amLeader = false;
|
|
349
|
+
try { amLeader = ctx.cluster.isLeader(); } catch (_e) { amLeader = false; }
|
|
350
|
+
var token = null;
|
|
351
|
+
if (amLeader) {
|
|
352
|
+
try { token = ctx.cluster.fencingToken(); } catch (_e) { token = null; }
|
|
353
|
+
}
|
|
354
|
+
var elec2 = {
|
|
355
|
+
isLeader: amLeader,
|
|
356
|
+
fencingToken: token,
|
|
357
|
+
resource: args.resource,
|
|
358
|
+
leaderId: leaderRow && leaderRow.nodeId ? leaderRow.nodeId : null,
|
|
359
|
+
};
|
|
360
|
+
ctx.elections.set(args.resource, elec2);
|
|
361
|
+
_safeAudit(ctx, "agent.orchestrator.elected", args.actor, {
|
|
362
|
+
resource: args.resource, mode: "cluster",
|
|
363
|
+
amLeader: amLeader, leaderId: elec2.leaderId,
|
|
364
|
+
});
|
|
365
|
+
return elec2;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---- Drain ----------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
async function _drain(ctx, args) {
|
|
371
|
+
ctx.draining = true;
|
|
372
|
+
var timeoutMs = typeof args.timeoutMs === "number" ? args.timeoutMs : DEFAULT_DRAIN_TIMEOUT_MS;
|
|
373
|
+
var drained = 0;
|
|
374
|
+
var startedAt = Date.now();
|
|
375
|
+
// Stop every spawned consumer + collect timing.
|
|
376
|
+
for (var i = 0; i < ctx.spawnedConsumers.length; i += 1) {
|
|
377
|
+
var c = ctx.spawnedConsumers[i];
|
|
378
|
+
try { await c.stop(); drained += 1; } catch (_e) { /* best-effort */ }
|
|
379
|
+
if (Date.now() - startedAt > timeoutMs) break;
|
|
380
|
+
}
|
|
381
|
+
// Streams: signal each to wrap up (the streams check ctx.draining
|
|
382
|
+
// and emit a drain-marker themselves; orchestrator just sets the flag).
|
|
383
|
+
var streamCount = ctx.streams.size;
|
|
384
|
+
_safeAudit(ctx, "agent.orchestrator.drained", null, {
|
|
385
|
+
drainedConsumers: drained, totalConsumers: ctx.spawnedConsumers.length,
|
|
386
|
+
streamCount: streamCount, elapsedMs: Date.now() - startedAt,
|
|
387
|
+
});
|
|
388
|
+
return { drained: drained, elapsedMs: Date.now() - startedAt };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---- Streams (v0.9.23 substrate hook) -------------------------------------
|
|
392
|
+
|
|
393
|
+
function _registerStream(ctx, info) {
|
|
394
|
+
// Stream IDs are cross-tenant-distinguishable; use crypto-grade
|
|
395
|
+
// generateToken to keep them uniformly random across operators.
|
|
396
|
+
var streamId = "stream-" + bCrypto.generateToken(STREAM_ID_RAND_BYTES);
|
|
397
|
+
ctx.streams.set(streamId, {
|
|
398
|
+
streamId: streamId, kind: info.kind || "unknown",
|
|
399
|
+
actor: info.actor || null, startedAt: Date.now(),
|
|
400
|
+
});
|
|
401
|
+
return streamId;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function _unregisterStream(ctx, streamId) {
|
|
405
|
+
ctx.streams.delete(streamId);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---- Health probe ---------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
async function _health(ctx) {
|
|
411
|
+
var rows = await ctx.backend.list();
|
|
412
|
+
var elections = [];
|
|
413
|
+
ctx.elections.forEach(function (v) { elections.push(v); });
|
|
414
|
+
var consumers = ctx.spawnedConsumers.map(function (c) { return { topic: c.topic }; });
|
|
415
|
+
return {
|
|
416
|
+
agents: rows.map(function (r) {
|
|
417
|
+
return { name: r.name, kind: r.kind, tenantId: r.tenantId, registeredAt: r.registeredAt };
|
|
418
|
+
}),
|
|
419
|
+
elections: elections,
|
|
420
|
+
consumers: consumers,
|
|
421
|
+
streams: ctx.streams.size,
|
|
422
|
+
draining: ctx.draining === true,
|
|
423
|
+
overall: ctx.draining ? "draining" : "ok",
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---- Internals ------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
function _inMemoryBackend() {
|
|
430
|
+
var map = new Map();
|
|
431
|
+
return {
|
|
432
|
+
get: function (k) { return Promise.resolve(map.get(k) || null); },
|
|
433
|
+
set: function (k, v) { map.set(k, v); return Promise.resolve(); },
|
|
434
|
+
delete: function (k) { map.delete(k); return Promise.resolve(); },
|
|
435
|
+
list: function () {
|
|
436
|
+
var out = [];
|
|
437
|
+
map.forEach(function (v) { out.push(v); });
|
|
438
|
+
return Promise.resolve(out);
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function _checkPermission(ctx, actor, scope) {
|
|
444
|
+
if (!ctx.permissions) return;
|
|
445
|
+
if (!actor || !ctx.permissions.check(actor, scope)) {
|
|
446
|
+
throw new AgentOrchestratorError("agent-orchestrator/permission-denied",
|
|
447
|
+
"actor lacks scope '" + scope + "'");
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function _safeAudit(ctx, action, actor, metadata) {
|
|
452
|
+
try {
|
|
453
|
+
ctx.audit.safeEmit({
|
|
454
|
+
action: action,
|
|
455
|
+
actor: actor ? { id: actor.id, roles: actor.roles || [] } : { id: "<system>" },
|
|
456
|
+
outcome: action.indexOf("denied") >= 0 || action.indexOf("miss") >= 0 ? "failure" : "success",
|
|
457
|
+
metadata: metadata || {},
|
|
458
|
+
});
|
|
459
|
+
} catch (_e) { /* drop-silent — audit emit failures don't crash the call */ }
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
module.exports = {
|
|
463
|
+
create: create,
|
|
464
|
+
shardFor: shardFor,
|
|
465
|
+
AgentOrchestratorError: AgentOrchestratorError,
|
|
466
|
+
guards: {
|
|
467
|
+
registry: guardAgentRegistry,
|
|
468
|
+
},
|
|
469
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.guardAgentRegistry
|
|
4
|
+
* @nav Guards
|
|
5
|
+
* @title Guard Agent Registry
|
|
6
|
+
* @order 435
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Registry-op shape validator for `b.agent.orchestrator.register` /
|
|
10
|
+
* `lookup` / `unregister`. Refuses agent names that wouldn't be
|
|
11
|
+
* safe to surface in audit logs, registry queries, or routing
|
|
12
|
+
* keys:
|
|
13
|
+
*
|
|
14
|
+
* - non-ASCII (NFC-normalized + ASCII-only — operator-greppable)
|
|
15
|
+
* - path-traversal shapes (`..` / `/` / `\` / NUL / C0 / DEL)
|
|
16
|
+
* - oversized (default 64 bytes per name)
|
|
17
|
+
* - reserved `FRAMEWORK.*` / `ROOT` / `*` prefix from operator code
|
|
18
|
+
* - duplicate-on-register (caller must `unregister` first)
|
|
19
|
+
*
|
|
20
|
+
* @card
|
|
21
|
+
* Validates `b.agent.orchestrator.register` op shapes. Path-traversal
|
|
22
|
+
* refusal, reserved-prefix refusal, non-ASCII refusal, oversize cap.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
var { defineClass } = require("./framework-error");
|
|
26
|
+
|
|
27
|
+
var GuardAgentRegistryError = defineClass("GuardAgentRegistryError", { alwaysPermanent: true });
|
|
28
|
+
|
|
29
|
+
var DEFAULT_PROFILE = "strict";
|
|
30
|
+
|
|
31
|
+
var PROFILES = Object.freeze({
|
|
32
|
+
strict: { maxNameBytes: 64, maxKindBytes: 32 }, // allow:raw-byte-literal
|
|
33
|
+
balanced: { maxNameBytes: 128, maxKindBytes: 64 }, // allow:raw-byte-literal
|
|
34
|
+
permissive: { maxNameBytes: 512, maxKindBytes: 128 }, // allow:raw-byte-literal
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
38
|
+
hipaa: "strict",
|
|
39
|
+
"pci-dss": "strict",
|
|
40
|
+
gdpr: "strict",
|
|
41
|
+
soc2: "strict",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
var RESERVED_PREFIXES = Object.freeze(["FRAMEWORK.", "ROOT.", "framework.", "root."]);
|
|
45
|
+
var RESERVED_EXACT = Object.freeze({ "ROOT": true, "FRAMEWORK": true, "*": true });
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @primitive b.guardAgentRegistry.validate
|
|
49
|
+
* @signature b.guardAgentRegistry.validate(op, opts?)
|
|
50
|
+
* @since 0.9.21
|
|
51
|
+
* @status stable
|
|
52
|
+
* @related b.agent.orchestrator.create
|
|
53
|
+
*
|
|
54
|
+
* Validate a `{ kind, name, agent, opts }` registry op shape. Returns
|
|
55
|
+
* the op on success; throws `GuardAgentRegistryError` on refusal.
|
|
56
|
+
*
|
|
57
|
+
* @opts
|
|
58
|
+
* profile: "strict" | "balanced" | "permissive",
|
|
59
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* b.guardAgentRegistry.validate({
|
|
63
|
+
* kind: "register",
|
|
64
|
+
* name: "tenant-acme-mail",
|
|
65
|
+
* agentKind: "mail",
|
|
66
|
+
* });
|
|
67
|
+
*/
|
|
68
|
+
function validate(op, opts) {
|
|
69
|
+
opts = opts || {};
|
|
70
|
+
var profile = PROFILES[_resolveProfile(opts)];
|
|
71
|
+
if (!op || typeof op !== "object") {
|
|
72
|
+
throw new GuardAgentRegistryError("agent-registry/bad-input",
|
|
73
|
+
"guardAgentRegistry.validate: op required");
|
|
74
|
+
}
|
|
75
|
+
if (op.kind !== "register" && op.kind !== "lookup" && op.kind !== "unregister" && op.kind !== "list") {
|
|
76
|
+
throw new GuardAgentRegistryError("agent-registry/bad-kind",
|
|
77
|
+
"guardAgentRegistry.validate: op.kind must be 'register' | 'lookup' | 'unregister' | 'list'");
|
|
78
|
+
}
|
|
79
|
+
if (op.kind === "list") return op; // list takes optional filters only
|
|
80
|
+
|
|
81
|
+
_checkName(op.name, profile);
|
|
82
|
+
if (op.kind === "register") {
|
|
83
|
+
if (typeof op.agentKind !== "string" || op.agentKind.length === 0) {
|
|
84
|
+
throw new GuardAgentRegistryError("agent-registry/no-kind",
|
|
85
|
+
"guardAgentRegistry.validate: register op requires agentKind");
|
|
86
|
+
}
|
|
87
|
+
_checkKind(op.agentKind, profile);
|
|
88
|
+
}
|
|
89
|
+
return op;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @primitive b.guardAgentRegistry.compliancePosture
|
|
94
|
+
* @signature b.guardAgentRegistry.compliancePosture(posture)
|
|
95
|
+
* @since 0.9.21
|
|
96
|
+
* @status stable
|
|
97
|
+
*
|
|
98
|
+
* Return the effective profile for a given compliance posture name.
|
|
99
|
+
* Returns `null` for unknown posture names so operator typos surface
|
|
100
|
+
* here instead of silently falling through to the default profile.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* b.guardAgentRegistry.compliancePosture("hipaa"); // → "strict"
|
|
104
|
+
*/
|
|
105
|
+
function compliancePosture(posture) {
|
|
106
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function _checkName(name, profile) {
|
|
110
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
111
|
+
throw new GuardAgentRegistryError("agent-registry/bad-name",
|
|
112
|
+
"guardAgentRegistry.validate: op.name must be a non-empty string");
|
|
113
|
+
}
|
|
114
|
+
if (Buffer.byteLength(name, "utf8") > profile.maxNameBytes) {
|
|
115
|
+
throw new GuardAgentRegistryError("agent-registry/name-too-long",
|
|
116
|
+
"guardAgentRegistry.validate: name exceeds maxNameBytes=" + profile.maxNameBytes);
|
|
117
|
+
}
|
|
118
|
+
if (RESERVED_EXACT[name]) {
|
|
119
|
+
throw new GuardAgentRegistryError("agent-registry/reserved-name",
|
|
120
|
+
"guardAgentRegistry.validate: name '" + name + "' is framework-reserved");
|
|
121
|
+
}
|
|
122
|
+
for (var p = 0; p < RESERVED_PREFIXES.length; p += 1) {
|
|
123
|
+
if (name.indexOf(RESERVED_PREFIXES[p]) === 0) {
|
|
124
|
+
throw new GuardAgentRegistryError("agent-registry/reserved-prefix",
|
|
125
|
+
"guardAgentRegistry.validate: name '" + name + "' uses reserved prefix '" +
|
|
126
|
+
RESERVED_PREFIXES[p] + "'");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (name.indexOf("..") >= 0) {
|
|
130
|
+
throw new GuardAgentRegistryError("agent-registry/path-traversal",
|
|
131
|
+
"guardAgentRegistry.validate: name contains '..'");
|
|
132
|
+
}
|
|
133
|
+
for (var i = 0; i < name.length; i += 1) {
|
|
134
|
+
var c = name.charCodeAt(i);
|
|
135
|
+
if (c > 0x7F) { // allow:raw-byte-literal — ASCII-only cap
|
|
136
|
+
throw new GuardAgentRegistryError("agent-registry/non-ascii",
|
|
137
|
+
"guardAgentRegistry.validate: name contains non-ASCII codepoint at offset " + i);
|
|
138
|
+
}
|
|
139
|
+
if (c < 0x20 || c === 0x7F || c === 0x2F || c === 0x5C) { // allow:raw-byte-literal — C0 / DEL / slash / backslash
|
|
140
|
+
throw new GuardAgentRegistryError("agent-registry/bad-name-char",
|
|
141
|
+
"guardAgentRegistry.validate: name contains forbidden char 0x" + c.toString(16));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _checkKind(kind, profile) {
|
|
147
|
+
if (Buffer.byteLength(kind, "utf8") > profile.maxKindBytes) {
|
|
148
|
+
throw new GuardAgentRegistryError("agent-registry/kind-too-long",
|
|
149
|
+
"guardAgentRegistry.validate: agentKind exceeds maxKindBytes=" + profile.maxKindBytes);
|
|
150
|
+
}
|
|
151
|
+
if (!/^[a-z][a-z0-9-]*$/.test(kind)) { // allow:regex-no-length-cap — kind length bounded above
|
|
152
|
+
throw new GuardAgentRegistryError("agent-registry/bad-kind-shape",
|
|
153
|
+
"guardAgentRegistry.validate: agentKind must match /^[a-z][a-z0-9-]*$/");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function _resolveProfile(opts) {
|
|
158
|
+
if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
|
|
159
|
+
return COMPLIANCE_POSTURES[opts.posture];
|
|
160
|
+
}
|
|
161
|
+
var p = opts.profile || DEFAULT_PROFILE;
|
|
162
|
+
if (!PROFILES[p]) {
|
|
163
|
+
throw new GuardAgentRegistryError("agent-registry/bad-profile",
|
|
164
|
+
"guardAgentRegistry: unknown profile '" + p + "'");
|
|
165
|
+
}
|
|
166
|
+
return p;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = {
|
|
170
|
+
validate: validate,
|
|
171
|
+
compliancePosture: compliancePosture,
|
|
172
|
+
PROFILES: PROFILES,
|
|
173
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
174
|
+
RESERVED_PREFIXES: RESERVED_PREFIXES,
|
|
175
|
+
RESERVED_EXACT: RESERVED_EXACT,
|
|
176
|
+
GuardAgentRegistryError: GuardAgentRegistryError,
|
|
177
|
+
NAME: "agentRegistry",
|
|
178
|
+
KIND: "agent-registry",
|
|
179
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.guardIdempotencyKey
|
|
4
|
+
* @nav Guards
|
|
5
|
+
* @title Guard Idempotency Key
|
|
6
|
+
* @order 436
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Operator-supplied idempotency key shape validator. Refuses keys
|
|
10
|
+
* that wouldn't be safe to surface in audit logs, persist in the
|
|
11
|
+
* sealed dbStore, or replay across processes:
|
|
12
|
+
*
|
|
13
|
+
* - oversized (default 256 bytes — operators sometimes pass full
|
|
14
|
+
* JMAP request envelopes as keys; 256 is plenty for any
|
|
15
|
+
* reasonable correlation id without blowing storage)
|
|
16
|
+
* - control chars (C0 / NUL / DEL — defends audit-log injection
|
|
17
|
+
* when the key is rendered in a log message)
|
|
18
|
+
* - non-ASCII (NFC-normalized + ASCII-only; operator-greppable
|
|
19
|
+
* in audit logs across stack boundaries)
|
|
20
|
+
* - path-traversal shapes (`..` / `/` / `\` — defends operators
|
|
21
|
+
* who route idempotency keys through a filesystem-shaped path)
|
|
22
|
+
*
|
|
23
|
+
* Permissive profile opts down the non-ASCII refusal for operators
|
|
24
|
+
* with legacy systems that include Unicode tenant IDs in keys.
|
|
25
|
+
*
|
|
26
|
+
* @card
|
|
27
|
+
* Validates operator-supplied `args.idempotencyKey` strings. Bounded
|
|
28
|
+
* length, control-char refusal, path-traversal refusal, ASCII-only
|
|
29
|
+
* under strict.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
var { defineClass } = require("./framework-error");
|
|
33
|
+
|
|
34
|
+
var GuardIdempotencyKeyError = defineClass("GuardIdempotencyKeyError", { alwaysPermanent: true });
|
|
35
|
+
|
|
36
|
+
var DEFAULT_PROFILE = "strict";
|
|
37
|
+
|
|
38
|
+
var PROFILES = Object.freeze({
|
|
39
|
+
strict: { maxBytes: 256, asciiOnly: true }, // allow:raw-byte-literal
|
|
40
|
+
balanced: { maxBytes: 512, asciiOnly: true }, // allow:raw-byte-literal
|
|
41
|
+
permissive: { maxBytes: 2048, asciiOnly: false }, // allow:raw-byte-literal
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
45
|
+
hipaa: "strict",
|
|
46
|
+
"pci-dss": "strict",
|
|
47
|
+
gdpr: "strict",
|
|
48
|
+
soc2: "strict",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @primitive b.guardIdempotencyKey.validate
|
|
53
|
+
* @signature b.guardIdempotencyKey.validate(value, opts?)
|
|
54
|
+
* @since 0.9.22
|
|
55
|
+
* @status stable
|
|
56
|
+
* @related b.agent.idempotency.create
|
|
57
|
+
*
|
|
58
|
+
* Validate an operator-supplied idempotency key. Returns the input
|
|
59
|
+
* on success; throws `GuardIdempotencyKeyError` on refusal.
|
|
60
|
+
*
|
|
61
|
+
* @opts
|
|
62
|
+
* profile: "strict" | "balanced" | "permissive", // default "strict"
|
|
63
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
64
|
+
* maxBytes: number, // per-profile default
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* b.guardIdempotencyKey.validate("jmap-req-abc-123");
|
|
68
|
+
*/
|
|
69
|
+
function validate(value, opts) {
|
|
70
|
+
opts = opts || {};
|
|
71
|
+
var profileName = _resolveProfile(opts);
|
|
72
|
+
var profile = PROFILES[profileName];
|
|
73
|
+
var maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : profile.maxBytes;
|
|
74
|
+
|
|
75
|
+
if (typeof value !== "string") {
|
|
76
|
+
throw new GuardIdempotencyKeyError("idempotency-key/bad-input",
|
|
77
|
+
"guardIdempotencyKey.validate: value must be a string (got " + typeof value + ")");
|
|
78
|
+
}
|
|
79
|
+
if (value.length === 0) {
|
|
80
|
+
throw new GuardIdempotencyKeyError("idempotency-key/empty",
|
|
81
|
+
"guardIdempotencyKey.validate: empty key refused");
|
|
82
|
+
}
|
|
83
|
+
if (Buffer.byteLength(value, "utf8") > maxBytes) {
|
|
84
|
+
throw new GuardIdempotencyKeyError("idempotency-key/oversize",
|
|
85
|
+
"guardIdempotencyKey.validate: " + Buffer.byteLength(value, "utf8") +
|
|
86
|
+
" bytes exceeds maxBytes=" + maxBytes);
|
|
87
|
+
}
|
|
88
|
+
// Path-traversal refusal — defends operators routing keys through
|
|
89
|
+
// filesystem paths.
|
|
90
|
+
if (value.indexOf("..") >= 0) {
|
|
91
|
+
throw new GuardIdempotencyKeyError("idempotency-key/path-traversal",
|
|
92
|
+
"guardIdempotencyKey.validate: key contains '..'");
|
|
93
|
+
}
|
|
94
|
+
// C0 / DEL / slash refusal.
|
|
95
|
+
for (var i = 0; i < value.length; i += 1) {
|
|
96
|
+
var c = value.charCodeAt(i);
|
|
97
|
+
if (c < 0x20 || c === 0x7F) { // allow:raw-byte-literal — C0 + DEL refusal
|
|
98
|
+
throw new GuardIdempotencyKeyError("idempotency-key/control-char",
|
|
99
|
+
"guardIdempotencyKey.validate: control char 0x" + c.toString(16) + " at offset " + i);
|
|
100
|
+
}
|
|
101
|
+
if (c === 0x2F || c === 0x5C) { // allow:raw-byte-literal — / and \ refusal
|
|
102
|
+
throw new GuardIdempotencyKeyError("idempotency-key/slash",
|
|
103
|
+
"guardIdempotencyKey.validate: key contains '/' or '\\' at offset " + i);
|
|
104
|
+
}
|
|
105
|
+
if (profile.asciiOnly && c > 0x7F) { // allow:raw-byte-literal — ASCII-only cap
|
|
106
|
+
throw new GuardIdempotencyKeyError("idempotency-key/non-ascii",
|
|
107
|
+
"guardIdempotencyKey.validate: non-ASCII codepoint at offset " + i +
|
|
108
|
+
" (use profile='permissive' to allow)");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @primitive b.guardIdempotencyKey.compliancePosture
|
|
116
|
+
* @signature b.guardIdempotencyKey.compliancePosture(posture)
|
|
117
|
+
* @since 0.9.22
|
|
118
|
+
* @status stable
|
|
119
|
+
*
|
|
120
|
+
* Return the effective profile for a given compliance posture name.
|
|
121
|
+
* Returns `null` for unknown posture names so operator typos surface
|
|
122
|
+
* here instead of silently falling through to the default profile.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* b.guardIdempotencyKey.compliancePosture("hipaa"); // → "strict"
|
|
126
|
+
*/
|
|
127
|
+
function compliancePosture(posture) {
|
|
128
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function _resolveProfile(opts) {
|
|
132
|
+
if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
|
|
133
|
+
return COMPLIANCE_POSTURES[opts.posture];
|
|
134
|
+
}
|
|
135
|
+
var p = opts.profile || DEFAULT_PROFILE;
|
|
136
|
+
if (!PROFILES[p]) {
|
|
137
|
+
throw new GuardIdempotencyKeyError("idempotency-key/bad-profile",
|
|
138
|
+
"guardIdempotencyKey: unknown profile '" + p + "'");
|
|
139
|
+
}
|
|
140
|
+
return p;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = {
|
|
144
|
+
validate: validate,
|
|
145
|
+
compliancePosture: compliancePosture,
|
|
146
|
+
PROFILES: PROFILES,
|
|
147
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
148
|
+
GuardIdempotencyKeyError: GuardIdempotencyKeyError,
|
|
149
|
+
NAME: "idempotencyKey",
|
|
150
|
+
KIND: "idempotency-key",
|
|
151
|
+
};
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.6",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:66e27fdc-e5e4-44ce-84cf-f8eddc4a5a45",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-14T21:47:04.186Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.9.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.9.22",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.9.
|
|
25
|
+
"version": "0.9.22",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.9.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.9.22",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.9.
|
|
57
|
+
"ref": "@blamejs/core@0.9.22",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|