whisper-id 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +74 -17
  2. package/index.d.ts +461 -4
  3. package/index.js +401 -24
  4. package/package.json +33 -7
package/README.md CHANGED
@@ -2,10 +2,11 @@
2
2
 
3
3
  A real, routable **IPv6 identity** and **safe egress** for any Node agent.
4
4
 
5
- **Two-tier, by design** (Postel's Law): **with no key** the keyless half works everywhere —
6
- `verify`, `verifyDetails`, `rdap` — so anyone can prove an address is a Whisper agent. **With
7
- your key** (`opts.apiKey` or `WHISPER_API_KEY`) the whole control plane unlocks — list, policy,
8
- logs, revoke, identity, agent — plus **egress** that sources your traffic from your `/128`.
5
+ **Two-tier, by design** (Postel's Law): **with no key** the keyless half works everywhere.
6
+ `verify`, `verifyDetails`, and `rdap` let anyone prove an address is a Whisper agent. **With
7
+ your key** (`opts.apiKey` or `WHISPER_API_KEY`) the whole control plane unlocks (list, policy,
8
+ logs, revoke, identity, agent), plus the keyed **`graph`** namespace (Cypher intelligence over
9
+ the Whisper Security graph) and **egress** that sources your traffic from your `/128`.
9
10
 
10
11
  ```sh
11
12
  npm i whisper-id
@@ -13,7 +14,7 @@ npm i whisper-id
13
14
 
14
15
  ```js
15
16
  import { verify } from "whisper-id";
16
- await verify("2a04:2a01:…"); // keyless: true if it's a real Whisper agent
17
+ await verify("2a04:2a01:..."); // keyless: true if it's a real Whisper agent
17
18
  ```
18
19
 
19
20
  ```js
@@ -28,14 +29,14 @@ await withEgress(async (e) => {
28
29
 
29
30
  ## API
30
31
 
31
- ### Keyless — no key, no CLI (pure HTTPS)
32
+ ### Keyless: no key, no CLI (pure HTTPS)
32
33
 
33
34
  Run anywhere `fetch` runs (Cloudflare Workers, Vercel, Deno, Lambda, the browser).
34
35
 
35
36
  | Call | Does |
36
37
  |------|------|
37
38
  | `verify(address)` | Is `address` a real Whisper agent? (server-side DANE + DNSSEC + reverse-DNS + JWS) → `boolean` |
38
- | `verifyDetails(address)` | The full verdict (`is_whisper_agent`, `fqdn`, `operator`, `dane_ok`, `jws_ok`, …) or `null` |
39
+ | `verifyDetails(address)` | The full verdict (`is_whisper_agent`, `fqdn`, `operator`, `dane_ok`, `jws_ok`, and more) or `null` |
39
40
  | `rdap(address)` | The public RDAP record for a `/128`, or `null` |
40
41
  | `egressIp()` | The IP this process leaves from (a `/128` when routed, else the platform's) → `string` |
41
42
 
@@ -44,21 +45,21 @@ import { verify, rdap } from "whisper-id";
44
45
  if (await verify(addr)) console.log((await rdap(addr)).name);
45
46
  ```
46
47
 
47
- ### Control plane — with a key (pure HTTP, no CLI)
48
+ ### Control plane: with a key (pure HTTP, no CLI)
48
49
 
49
- Pure `fetch` to the control endpoint — no `whisper` CLI, no child process. The key comes from
50
+ Pure `fetch` to the control endpoint, with no `whisper` CLI and no child process. The key comes from
50
51
  `opts.apiKey` or `WHISPER_API_KEY` and is sent only in the `X-API-Key` header (never on argv,
51
52
  never logged). Each also takes `{ apiKey, timeout }`. On failure they throw a `WhisperError`
52
53
  carrying the server's `detail` (plus `.status` / `.retryAfter`).
53
54
 
54
55
  | Call | Does |
55
56
  |------|------|
56
- | `list({ kind })` | List your fleet — `kind`: `"agents"` (default) \| `"identities"` \| `"records"`. → items `{ label, fqdn, address, agent, created, state }` |
57
+ | `list({ kind })` | List your fleet. `kind`: `"agents"` (default) \| `"identities"` \| `"records"`. → items `{ label, fqdn, address, agent, created, state }` |
57
58
  | `identity({ label, contact_email })` | Allocate your own `/128`. Release with `{ release: true, address }`. → `{ agent, address, fqdn, ptr, state }` |
58
59
  | `agent(idOrAddress)` | One agent's detail + live counters. → the record, or `null` |
59
60
  | `policy({ default, allow, block })` | Read (no fields) or set your per-tenant DNS policy. → `{ key, value }` rows |
60
61
  | `logs({ agent, kind, from, to, limit })` | Recent DNS/conn/alloc activity from warm storage. → event records |
61
- | `revoke(idOrAddress)` | Fully revoke an agent — **irreversible** (needs `admin:dns`). → `{ status }` |
62
+ | `revoke(idOrAddress)` | Fully revoke an agent, **irreversible** (needs `admin:dns`). → `{ status }` |
62
63
 
63
64
  ```js
64
65
  import { list, policy, logs } from "whisper-id"; // WHISPER_API_KEY in the env
@@ -68,11 +69,67 @@ for (const a of await list()) console.log(a.address, a.label);
68
69
  const recent = await logs({ kind: "dns", limit: 100 });
69
70
  ```
70
71
 
72
+ ### Graph (keyed Cypher, pure HTTP)
73
+
74
+ The intelligence half of the keyed surface. `import { graph } from "whisper-id"` and you get one
75
+ typed method per catalog entry over the Whisper Security graph, plus a raw `graph.query` escape
76
+ hatch. Same auth as the control plane: the key comes from `opts.apiKey` or `WHISPER_API_KEY` and
77
+ is sent only in `X-API-Key`. If it is Cypher, it needs a key: this whole namespace is keyed (the
78
+ keyless `verify` / `verifyDetails` / `rdap` above touch no Cypher and stay key-free). Every method
79
+ resolves to `{ columns, rows, statistics }` where `rows` are objects keyed by column name.
80
+
81
+ ```js
82
+ import { graph } from "whisper-id"; // WHISPER_API_KEY in the env, or pass { apiKey }
83
+
84
+ const r = await graph.identify("api.openai.com", { apiKey: "whisper-..." });
85
+ console.log(r.rows[0].vendor_id, r.rows[0].canonical_name); // → openai OpenAI
86
+
87
+ await graph.assess("185.220.101.33"); // threat posture: malicious / benign / unknown
88
+ await graph.variants("paypal.com"); // registered look-alike domains
89
+ await graph.origins("cloudflare.com"); // real origin IPs behind a CDN
90
+ await graph.query("MATCH (n) RETURN n LIMIT $lim", { lim: 5 }); // raw Cypher escape hatch
91
+ ```
92
+
93
+ | Call | Does |
94
+ |------|------|
95
+ | `graph.identify(value)` | Name the vendor and operator role behind a host or IP |
96
+ | `graph.assess(value)` | Labelled threat posture for a host or IP (malicious / benign / unknown) |
97
+ | `graph.variants(value)` | Look-alike domain variants of a brand, and which are registered |
98
+ | `graph.walk(value)` | Walk to the nearest known vendors behind a host, with channel + confidence |
99
+ | `graph.explain(value)` | Score an indicator against the threat feeds and explain why |
100
+ | `graph.pslTldplusone(value)` | Reduce any hostname to its registrable apex (eTLD+1) |
101
+ | `graph.pslAffiliation(value)` | Is a domain a PSL private-section suffix, and who submitted it |
102
+ | `graph.origins(value)` | Real origin IPs behind a CDN-fronted domain, ranked by confidence |
103
+ | `graph.history(value)` | Full historical WHOIS timeline for a domain |
104
+ | `graph.historyWhois(value)` | WHOIS-only historical timeline for a domain |
105
+ | `graph.asset(value)` | Member ASNs of an AS-SET macro |
106
+ | `graph.lookupTorRelay(value)` | Is an IP a known Tor exit relay |
107
+ | `graph.dbSchema()` | Every node + relationship type in the graph, with counts and examples |
108
+ | `graph.submit({ kind, ...fields })` | Contribute an indicator observation or feedback (keyed write) |
109
+ | `graph.query(cypher, params)` | Raw Cypher escape hatch. → the full `{ columns, rows, statistics }` |
110
+
111
+ Fifteen multi-step **workflow** methods (`attackPath`, `attackSurface`, `blastRadius`, `typosquat`,
112
+ `indicator`, `routeHealth`, and 9 more) execute on the Whisper workflow runner and stream
113
+ Server-Sent Events; one call collects the whole run into `{ slug, steps, complete, events }`:
114
+
115
+ ```js
116
+ const run = await graph.typosquat("paypal.com"); // bare value → the flow's primary input
117
+ console.log(run.steps.length, "steps"); // each step: its bound Cypher, columns, rows
118
+ console.log(run.complete.totalLatencyMs, "ms"); // the terminal aggregate context
119
+
120
+ await graph.attackSurface({ domain: "github.com", level: "deep" }); // named inputs + knobs
121
+ ```
122
+
123
+ Every `graph` method is generated from the Whisper query catalog (never hand-copied, so it
124
+ cannot drift) and links its canonical docs: `graph.identify.docs` →
125
+ `https://www.whisper.security/docs/whisper-graph/procedures/identify`, and the same URL is on
126
+ each method's `@see` tag in your editor.
127
+
71
128
  ### Egress + CLI-backed
72
129
 
73
130
  | Call | Does |
74
131
  |------|------|
75
- | `register(name, { newKey })` | Create a named agent via the CLI — a routable `/128`. `newKey: true` mints a new agent **and** its own API key. → `Agent { address, id, name }` |
132
+ | `register(name, { newKey })` | Create a named agent via the CLI: a routable `/128`. `newKey: true` mints a new agent **and** its own API key. → `Agent { address, id, name }` |
76
133
  | `egress({ agent, tier, setEnv })` | Bring up egress bound to your `/128`. → `Egress { port, proxyUrl, socksUrl, proxies, close() }`. `tier: "wireguard"` for a routed `/128`. Sets proxy env unless `setEnv:false`; call `close()` to restore. |
77
134
  | `withEgress([opts], fn)` | Run `fn(egress)` with egress up, restoring the environment afterwards. |
78
135
  | `ip()` | Your current egress IP via the CLI, proving it's your `/128`. → `string` |
@@ -99,7 +156,7 @@ transparently inside `withEgress`.
99
156
  ## Requirements
100
157
 
101
158
  Zero dependencies. The **keyless** checks and the **control plane** are pure HTTP (Node ≥ 18's
102
- global `fetch`) — no CLI, nothing to install. For the control plane, set `WHISPER_API_KEY` (or
159
+ global `fetch`), with no CLI and nothing to install. For the control plane, set `WHISPER_API_KEY` (or
103
160
  pass `{ apiKey }`).
104
161
 
105
162
  Only **egress** and the CLI-backed calls (`register`, `egress`, `withEgress`, `ip`) need the
@@ -109,12 +166,12 @@ Only **egress** and the CLI-backed calls (`register`, `egress`, `withEgress`, `i
109
166
  curl get.whisper.online | sh
110
167
  ```
111
168
 
112
- (`$WHISPER_BIN` overrides the CLI path; `$WHISPER_CONTROL_URL` / `$WHISPER_RDAP_URL` override the
113
- endpoints.)
169
+ (`$WHISPER_BIN` overrides the CLI path; `$WHISPER_CONTROL_URL` / `$WHISPER_GRAPH_URL` /
170
+ `$WHISPER_RDAP_URL` override the endpoints.)
114
171
 
115
172
  ## Links
116
173
 
117
- - Site — https://whisper.online
118
- - CLI — https://github.com/whisper-sec/whisper-cli
174
+ - Site: https://whisper.online
175
+ - CLI: https://github.com/whisper-sec/whisper-cli
119
176
 
120
177
  MIT licensed.
package/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  export declare class WhisperError extends Error {}
4
4
 
5
5
  export interface Agent {
6
- /** The routable IPv6 /128 — both the identity and the auth. */
6
+ /** The routable IPv6 /128 - both the identity and the auth. */
7
7
  address: string;
8
8
  id: string | null;
9
9
  name: string;
@@ -51,7 +51,7 @@ export function egressIp(opts?: { timeout?: number }): Promise<string>;
51
51
  export function ip(opts?: { timeout?: number }): Promise<string>;
52
52
  export function firstAddr(obj: unknown): string | null;
53
53
 
54
- // ── Control plane (pure HTTP, keyed — no CLI) ───────────────────────────────────────
54
+ // ── Control plane (pure HTTP, keyed - no CLI) ───────────────────────────────────────
55
55
  // The governance half of the two-tier surface: pure `fetch` to the control endpoint, so
56
56
  // it runs anywhere (serverless, edge, a plain server). The key is read from `opts.apiKey`
57
57
  // or `WHISPER_API_KEY` and sent only in the `X-API-Key` header. On failure these throw a
@@ -59,7 +59,7 @@ export function firstAddr(obj: unknown): string | null;
59
59
 
60
60
  /** Common options for every keyed control call. */
61
61
  export interface ControlOptions {
62
- /** The Whisper owner key (`whisper_live_…`). Falls back to `process.env.WHISPER_API_KEY`. */
62
+ /** The Whisper owner key (`whisper_live_...`). Falls back to `process.env.WHISPER_API_KEY`. */
63
63
  apiKey?: string;
64
64
  /** Per-call timeout in ms (default 60000). */
65
65
  timeout?: number;
@@ -142,7 +142,7 @@ export function list(opts?: ListOptions): Promise<AgentItem[]>;
142
142
  export function policy(opts?: PolicyOptions): Promise<PolicyEntry[]>;
143
143
  /** Query recent DNS/conn/alloc activity from warm storage. → event records. */
144
144
  export function logs(opts?: LogsOptions): Promise<LogEvent[]>;
145
- /** Fully revoke an agent — IRREVERSIBLE (admin:dns). → `{ status, … }`. */
145
+ /** Fully revoke an agent - IRREVERSIBLE (admin:dns). → `{ status, ... }`. */
146
146
  export function revoke(agent: string, opts?: ControlOptions): Promise<{ status: string; [k: string]: unknown }>;
147
147
  export function revoke(opts: RevokeOptions): Promise<{ status: string; [k: string]: unknown }>;
148
148
  /** Allocate your own /128 (or release with `{release:true,address}`). → `{agent,address,fqdn,ptr,state}`. */
@@ -150,3 +150,460 @@ export function identity(opts?: IdentityOptions): Promise<AgentItem | null>;
150
150
  /** One agent's detail + live counters. Accepts an id, a /128, or `{agent}`/`{address}`. */
151
151
  export function agent(sel: string, opts?: ControlOptions): Promise<AgentItem | null>;
152
152
  export function agent(opts?: AgentSelectOptions): Promise<AgentItem | null>;
153
+
154
+ // ── Graph (keyed: Cypher + flows over the Whisper Security graph) ────────────────────
155
+ // The intelligence half of the keyed surface. Postel two-tier: the keyless identity calls
156
+ // above (verify/verifyDetails/rdap) run with no key and no Cypher; this whole `graph`
157
+ // namespace is KEYED (Kaveh's rule: if it is Cypher, it needs a key). The 14 `direct`
158
+ // methods POST their catalog Cypher to /api/query; the 15 `flow` methods execute on the
159
+ // workflow runner (POST gallery/run, Server-Sent Events) and resolve to the collected
160
+ // stream. The key rides only in `X-API-Key`. The method surface below is GENERATED from
161
+ // the Whisper query catalog by scripts/gen-graph.mjs; every method's docs live at the
162
+ // `@see` URL and are also on the function itself as `.docs`.
163
+
164
+ /** Per-call options for the keyed graph namespace (same shape as the control plane). */
165
+ export interface GraphOptions extends ControlOptions {}
166
+
167
+ /** Result of a keyed graph query. `rows` are objects keyed by column name. */
168
+ export interface GraphResult {
169
+ columns: string[];
170
+ rows: Record<string, unknown>[];
171
+ statistics?: { rowCount?: number; executionTimeMs?: number; [k: string]: unknown } | null;
172
+ }
173
+
174
+ /** A `graph.submit` payload: an indicator observation or a piece of feedback. */
175
+ export interface GraphSubmission {
176
+ kind: "indicator" | "feedback" | string;
177
+ identifier_kind?: string;
178
+ identifierKind?: string;
179
+ value?: string;
180
+ observation_id?: string;
181
+ observationId?: string;
182
+ confidence?: number;
183
+ first_seen?: string;
184
+ firstSeen?: string;
185
+ provenance?: string;
186
+ query?: string;
187
+ results?: unknown;
188
+ comment?: string;
189
+ severity?: string;
190
+ v?: number;
191
+ [k: string]: unknown;
192
+ }
193
+
194
+ /**
195
+ * Input for a flow method: a bare value for the flow's primary input, a plain object of
196
+ * named inputs (a known knob name routes to params), or an explicit `{ inputs, params }`.
197
+ */
198
+ export type GraphFlowInput =
199
+ | string
200
+ | number
201
+ | Record<string, unknown>
202
+ | { inputs?: Record<string, unknown>; params?: Record<string, unknown> };
203
+
204
+ /** One Server-Sent Event from a flow run; `data` is JSON-decoded when possible. */
205
+ export interface GraphFlowEvent {
206
+ event: string;
207
+ data: unknown;
208
+ }
209
+
210
+ /**
211
+ * Result of a flow run: every `step` event's payload (each carries the step's bound
212
+ * Cypher, columns, and rows), the terminal `complete` payload (the aggregate context;
213
+ * `null` if the stream ended early), and the full raw event list.
214
+ */
215
+ export interface GraphFlowResult {
216
+ slug: string;
217
+ steps: Record<string, unknown>[];
218
+ complete: Record<string, unknown> | null;
219
+ events: GraphFlowEvent[];
220
+ }
221
+
222
+ /**
223
+ * The keyed graph namespace: one typed method per catalog entry (14 direct Cypher reads +
224
+ * 15 workflow-runner flows), plus a raw `query` escape hatch. Every method needs a key
225
+ * (`{ apiKey }` or `WHISPER_API_KEY`) and carries its canonical docs URL as `.docs`.
226
+ */
227
+ export interface GraphNamespace {
228
+ /**
229
+ * Raw Cypher escape hatch: run arbitrary Cypher with `params` bound server-side as
230
+ * $-parameters. Returns the full `{columns,rows,statistics}`.
231
+ * @see https://www.whisper.security/docs/cypher-api
232
+ */
233
+ query(cypher: string, params?: Record<string, unknown>, opts?: GraphOptions): Promise<GraphResult>;
234
+
235
+ // BEGIN GENERATED: graph catalog (scripts/gen-graph.mjs) - do not edit by hand
236
+ /**
237
+ * Assess how resilient a country's core DNS is if it were cut off from the world.
238
+ *
239
+ * Could a country still resolve names if it were isolated? Counts how many of the 13 root
240
+ * letters have in-country DNS_ROOT_INSTANCE anycast nodes, the Global-vs-Local split, the BGP
241
+ * origin ASNs hosting them, and a resilience grade.
242
+ *
243
+ * KEYED flow: streams Server-Sent Events from the workflow runner (slug "anycast-dns-root-sovereignty")
244
+ * and resolves to the collected { slug, steps, complete, events }.
245
+ * Inputs: country. Params: instanceType.
246
+ * @see https://www.whisper.security/docs/recipes/compliance
247
+ */
248
+ anycastDnsRootSovereignty(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
249
+
250
+ /**
251
+ * Find the choke points an attacker would target - and how any two things connect.
252
+ *
253
+ * From a starting foothold, finds shared dependencies whose compromise reaches furthest; for
254
+ * any two indicators, traces how they are actually connected.
255
+ *
256
+ * KEYED flow: streams Server-Sent Events from the workflow runner (slug "attack-path")
257
+ * and resolves to the collected { slug, steps, complete, events }.
258
+ * Inputs: value, other. Params: level.
259
+ * @see https://www.whisper.security/docs/recipes/attack-path
260
+ */
261
+ attackPath(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
262
+
263
+ /**
264
+ * Map everything about a domain that's exposed to the outside world, scored for risk.
265
+ *
266
+ * Maps the full external footprint - subdomains, name/mail servers, registrant, third-party
267
+ * services, connected web - and scores the exposure.
268
+ *
269
+ * KEYED flow: streams Server-Sent Events from the workflow runner (slug "attack-surface")
270
+ * and resolves to the collected { slug, steps, complete, events }.
271
+ * Inputs: domain. Params: level.
272
+ * @see https://www.whisper.security/docs/recipes/pentest-recon
273
+ */
274
+ attackSurface(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
275
+
276
+ /**
277
+ * Grade a network's routing security and trace conflicts to the domains they'd expose.
278
+ *
279
+ * Grades a network on the conflicts/gaps that make route hijacking possible, then traces any
280
+ * conflict to the specific domains and organisations exposed on the affected blocks.
281
+ *
282
+ * KEYED flow: streams Server-Sent Events from the workflow runner (slug "bgp-hijack-exposure")
283
+ * and resolves to the collected { slug, steps, complete, events }.
284
+ * Inputs: value.
285
+ * @see https://www.whisper.security/docs/recipes/bgp-routing
286
+ */
287
+ bgpHijackExposure(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
288
+
289
+ /**
290
+ * Pick one asset and see what would break if it failed - and what it depends on in turn.
291
+ *
292
+ * Maps dependencies in both directions: everything that breaks if the asset fails (SPOFs) and
293
+ * everything it relies on (its own DNS/mail/hosting/network supply chain).
294
+ *
295
+ * KEYED flow: streams Server-Sent Events from the workflow runner (slug "blast-radius")
296
+ * and resolves to the collected { slug, steps, complete, events }.
297
+ * Inputs: indicator. Params: depth.
298
+ * @see https://www.whisper.security/docs/recipes/soc
299
+ */
300
+ blastRadius(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
301
+
302
+ /**
303
+ * Assemble a ready-to-submit dossier for taking down a scam or phishing domain.
304
+ *
305
+ * One-pass takedown package: reputation verdict, owner (WHOIS), abuse-list listings, and
306
+ * surrounding infrastructure, laid out ready to hand to a registrar/host.
307
+ *
308
+ * KEYED flow: streams Server-Sent Events from the workflow runner (slug "build-takedown-evidence-package")
309
+ * and resolves to the collected { slug, steps, complete, events }.
310
+ * Inputs: domain.
311
+ * @see https://www.whisper.security/docs/recipes/threat-intel
312
+ */
313
+ buildTakedownEvidencePackage(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
314
+
315
+ /**
316
+ * Map an organisation's externally visible AI and agent endpoints from the outside.
317
+ *
318
+ * Maps externally visible AI/agent hosts (API, model, agent) from the outside via heuristic
319
+ * hostname patterns (api./mcp./ai./vector./llm./agent./chat./copilot.). Best-effort leads.
320
+ *
321
+ * KEYED flow: streams Server-Sent Events from the workflow runner (slug "discover-ai-agent-infrastructure")
322
+ * and resolves to the collected { slug, steps, complete, events }.
323
+ * Inputs: value.
324
+ * @see https://www.whisper.security/docs/recipes/pentest-recon
325
+ */
326
+ discoverAiAgentInfrastructure(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
327
+
328
+ /**
329
+ * Investigate one suspicious domain, IP, or network in depth and get a clear picture of the threat and everything connected to it.
330
+ *
331
+ * Deep-dive: works outward across the whole footprint (related domains, real origins behind
332
+ * CDN, neighbouring infra), threat-checks each node, never inherits maliciousness from shared
333
+ * infra; labelled posture, no score.
334
+ *
335
+ * KEYED flow: streams Server-Sent Events from the workflow runner (slug "indicator")
336
+ * and resolves to the collected { slug, steps, complete, events }.
337
+ * Inputs: indicator.
338
+ * @see https://www.whisper.security/docs/recipes/soc
339
+ */
340
+ indicator(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
341
+
342
+ /**
343
+ * Turn one domain or IP into a full context card - owner, hosting, mail, location, reputation at a glance.
344
+ *
345
+ * Fills the picture for one indicator: registrant (WHOIS), hosting + country, mail/name
346
+ * servers, network behind it, reputation read.
347
+ *
348
+ * KEYED flow: streams Server-Sent Events from the workflow runner (slug "indicator-enrichment")
349
+ * and resolves to the collected { slug, steps, complete, events }.
350
+ * Inputs: value.
351
+ * @see https://www.whisper.security/docs/recipes/dns-email
352
+ */
353
+ indicatorEnrichment(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
354
+
355
+ /**
356
+ * Trace one indicator to its true owner and full estate, even behind privacy screens and CDNs.
357
+ *
358
+ * Works out the true operator (even behind privacy WHOIS), de-cloaks CDN-fronted sites to real
359
+ * servers, pivots to the rest of that owner's estate across every layer.
360
+ *
361
+ * KEYED flow: streams Server-Sent Events from the workflow runner (slug "infrastructure-mapping")
362
+ * and resolves to the collected { slug, steps, complete, events }.
363
+ * Inputs: value. Params: level.
364
+ * @see https://www.whisper.security/docs/recipes/compliance
365
+ */
366
+ infrastructureMapping(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
367
+
368
+ /**
369
+ * Grade an organisation for over-reliance on single providers, regions, or facilities.
370
+ *
371
+ * Grades how concentrated infra is - too much riding on one provider/region/data-centre/cable
372
+ * landing - surfacing SPOFs for resilience and DORA/NIS2 fourth-party risk.
373
+ *
374
+ * KEYED flow: streams Server-Sent Events from the workflow runner (slug "map-supply-chain-concentration")
375
+ * and resolves to the collected { slug, steps, complete, events }.
376
+ * Inputs: domain.
377
+ * @see https://www.whisper.security/docs/recipes/compliance
378
+ */
379
+ mapSupplyChainConcentration(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
380
+
381
+ /**
382
+ * Check a domain's name servers for the misconfigurations that enable DNS hijacking.
383
+ *
384
+ * Audits delegation, flags stale/mismatched/lame nameservers, sizes each provider's share,
385
+ * surfaces registry facts - catches delegation weakness before exploit.
386
+ *
387
+ * KEYED flow: streams Server-Sent Events from the workflow runner (slug "nameserver-hijack-dns-consistency")
388
+ * and resolves to the collected { slug, steps, complete, events }.
389
+ * Inputs: value.
390
+ * @see https://www.whisper.security/docs/recipes/dns-email
391
+ */
392
+ nameserverHijackDnsConsistency(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
393
+
394
+ /**
395
+ * Profile a network or address block into a full routing and reachability health card.
396
+ *
397
+ * Health card for a network/block: what it announces, peers/transit, single-upstream lean,
398
+ * RPKI protection - the one-look reach & resilience report.
399
+ *
400
+ * KEYED flow: streams Server-Sent Events from the workflow runner (slug "route-health")
401
+ * and resolves to the collected { slug, steps, complete, events }.
402
+ * Inputs: target.
403
+ * @see https://www.whisper.security/docs/recipes/bgp-routing
404
+ */
405
+ routeHealth(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
406
+
407
+ /**
408
+ * Find subdomains that point at abandoned services an attacker could claim.
409
+ *
410
+ * Walks subdomains and flags ones aiming at deprovisioned targets (dangling CNAME whose target
411
+ * no longer resolves) so you can reclaim/remove before someone else does. CNAME layer is
412
+ * prod-ahead.
413
+ *
414
+ * KEYED flow: streams Server-Sent Events from the workflow runner (slug "subdomain-takeover")
415
+ * and resolves to the collected { slug, steps, complete, events }.
416
+ * Inputs: value.
417
+ * @see https://www.whisper.security/docs/recipes/pentest-recon
418
+ */
419
+ subdomainTakeover(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
420
+
421
+ /**
422
+ * Find registered look-alikes of your brand and check which ones are dangerous.
423
+ *
424
+ * Finds registered impersonations across misspellings/risky TLDs, separates your own defensive
425
+ * domains from strangers', flags fresh/privacy/abuse-listed ones into a prioritised list.
426
+ *
427
+ * KEYED flow: streams Server-Sent Events from the workflow runner (slug "typosquat")
428
+ * and resolves to the collected { slug, steps, complete, events }.
429
+ * Inputs: domain.
430
+ * @see https://www.whisper.security/docs/recipes/brand-protection
431
+ */
432
+ typosquat(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
433
+
434
+ /**
435
+ * Name the vendor and operator role behind a host or IP in one call.
436
+ *
437
+ * Resolves a host/IP to its canonical vendor, category and operator roles (DNS_OPERATOR / CDN
438
+ * / ORIGIN_AS / MAIL_RECEIVER) with a confidence band.
439
+ *
440
+ * KEYED. Cypher: CALL whisper.identify([$v]) YIELD host, vendor_id, canonical_name, category, roles, host_class, band
441
+ * Returns columns: host, vendor_id, canonical_name, category, roles, host_class, band.
442
+ * @see https://www.whisper.security/docs/whisper-graph/procedures/identify
443
+ */
444
+ identify(value: string, opts?: GraphOptions): Promise<GraphResult>;
445
+
446
+ /**
447
+ * Get a labelled threat posture for a host or IP - malicious, benign, or unknown.
448
+ *
449
+ * Returns a posture label + severity band + sub-labels + coverage + evidence for an indicator;
450
+ * benign-allowlisted vs malicious-evidenced, never a bare score.
451
+ *
452
+ * KEYED. Cypher: CALL whisper.assess([$v]) YIELD host, label, band, sub_labels, coverage, evidence
453
+ * Returns columns: host, label, band, sub_labels, coverage, evidence.
454
+ * @see https://www.whisper.security/docs/whisper-graph/procedures
455
+ */
456
+ assess(value: string, opts?: GraphOptions): Promise<GraphResult>;
457
+
458
+ /**
459
+ * Generate look-alike domain variants of a brand and see which are registered.
460
+ *
461
+ * Enumerates permutations (omission, bitsquatting, ...) of a domain, flags which exist, with a
462
+ * per-variant confidence.
463
+ *
464
+ * KEYED. Cypher: CALL whisper.variants($v) YIELD variant, method, exists, confidence
465
+ * Returns columns: variant, method, exists, confidence.
466
+ * @see https://www.whisper.security/docs/whisper-graph/procedures/variants
467
+ */
468
+ variants(value: string, opts?: GraphOptions): Promise<GraphResult>;
469
+
470
+ /**
471
+ * Walk the graph to the nearest known vendors behind a host, with the channel and confidence.
472
+ *
473
+ * Structural attribution: returns nearest_known_vendors with the channel (DELEGATED_TO /
474
+ * ORIGIN_AS) and confidence, plus siblings and coverage - explains WHY a host maps to a
475
+ * vendor.
476
+ *
477
+ * KEYED. Cypher: CALL whisper.walk($v) YIELD coverage, host, nearest_known_vendors, no_atlas_match, siblings
478
+ * Returns columns: coverage, host, nearest_known_vendors, no_atlas_match, siblings.
479
+ * @see https://www.whisper.security/docs/whisper-graph/procedures
480
+ */
481
+ walk(value: string, opts?: GraphOptions): Promise<GraphResult>;
482
+
483
+ /**
484
+ * Score an indicator against the threat feeds and explain exactly why.
485
+ *
486
+ * Threat-intel scorer: score + level + human explanation + the feeds it is listed in (feedId,
487
+ * weight, firstSeen/lastSeen). Two live forms: bare CALL explain($v) (full columns) and CALL
488
+ * whisper.explain($v) (restricted YIELD).
489
+ *
490
+ * KEYED. Cypher: CALL whisper.explain($v) YIELD indicator, score, level, explanation, sources
491
+ * Returns columns: indicator, score, level, explanation, sources.
492
+ * @see https://www.whisper.security/docs/whisper-graph/procedures/explain
493
+ */
494
+ explain(value: string, opts?: GraphOptions): Promise<GraphResult>;
495
+
496
+ /**
497
+ * Reduce any hostname to its registrable apex (eTLD+1) via the Public Suffix List.
498
+ *
499
+ * PSL-correct eTLD+1: www.foo.co.uk -> foo.co.uk. The right way to group hosts by the thing
500
+ * someone actually registered.
501
+ *
502
+ * KEYED. Cypher: CALL whisper.psl.tldPlusOne($v) YIELD apex
503
+ * Returns columns: apex.
504
+ * @see https://www.whisper.security/docs/whisper-graph/procedures/helpers
505
+ */
506
+ pslTldplusone(value: string, opts?: GraphOptions): Promise<GraphResult>;
507
+
508
+ /**
509
+ * Check whether a domain is a PSL private-section suffix and who submitted it.
510
+ *
511
+ * For a PSL private-section suffix, returns the submitting org/login and evidence kind +
512
+ * confidence; found=false for ordinary registrable domains.
513
+ *
514
+ * KEYED. Cypher: CALL whisper.psl.affiliation($v) YIELD found, suffix, submitterOrg, submitterLogin, evidenceKind, confidence
515
+ * Returns columns: found, suffix, submitterOrg, submitterLogin, evidenceKind, confidence.
516
+ * @see https://www.whisper.security/docs/whisper-graph/procedures/helpers
517
+ */
518
+ pslAffiliation(value: string, opts?: GraphOptions): Promise<GraphResult>;
519
+
520
+ /**
521
+ * Find the real origin IPs behind a CDN-fronted domain, ranked by confidence.
522
+ *
523
+ * Candidate origin IPs behind a CDN with confidence, the methods that found them (e.g.
524
+ * links_to), and the hosting ASN/name - de-cloaks to the real server.
525
+ *
526
+ * KEYED. Cypher: CALL whisper.origins($v) YIELD ip, confidence, methods, asn, asnName, kind
527
+ * Returns columns: ip, confidence, methods, asn, asnName, kind.
528
+ * @see https://www.whisper.security/docs/whisper-graph/procedures/origins
529
+ */
530
+ origins(value: string, opts?: GraphOptions): Promise<GraphResult>;
531
+
532
+ /**
533
+ * Get the full historical WHOIS timeline for a domain.
534
+ *
535
+ * Every observed WHOIS snapshot over time: create/update/expiry, registrar, registrant,
536
+ * country, nameservers - the ownership/registration story.
537
+ *
538
+ * KEYED. Cypher: CALL whisper.history($v)
539
+ * Returns columns: indicator, type, queryTime, createDate, updateDate, expiryDate, registrar, registrant, country, nameServers, cached.
540
+ * @see https://www.whisper.security/docs/whisper-graph/procedures/history
541
+ */
542
+ history(value: string, opts?: GraphOptions): Promise<GraphResult>;
543
+
544
+ /**
545
+ * Get the WHOIS-only historical timeline for a domain.
546
+ *
547
+ * The registration-record projection of the history timeline: create/update/expiry, registrar,
548
+ * registrant, country, nameservers per snapshot.
549
+ *
550
+ * KEYED. Cypher: CALL whisper.history.whois($v) YIELD queryTime, createDate, updateDate, expiryDate, registrar, registrant, country, nameServers
551
+ * Returns columns: queryTime, createDate, updateDate, expiryDate, registrar, registrant, country, nameServers.
552
+ * @see https://www.whisper.security/docs/whisper-graph/procedures/history
553
+ */
554
+ historyWhois(value: string, opts?: GraphOptions): Promise<GraphResult>;
555
+
556
+ /**
557
+ * List the member ASNs of an AS-SET macro.
558
+ *
559
+ * Expands an AS-SET name (e.g. AS-CLOUDFLARE) to its member ASNs and source RIR. Arg is a
560
+ * STRING as-set name, not an integer ASN. (Membership is sparsely populated in the current
561
+ * graph.)
562
+ *
563
+ * KEYED. Cypher: CALL whisper.asSet($v) YIELD asSetName, memberAsn, sourceRir
564
+ * Returns columns: asSetName, memberAsn, sourceRir.
565
+ * @see https://www.whisper.security/docs/whisper-graph/procedures
566
+ */
567
+ asset(value: string, opts?: GraphOptions): Promise<GraphResult>;
568
+
569
+ /**
570
+ * Check whether an IP is a known Tor exit relay.
571
+ *
572
+ * Live Tor exit-node check: found + fingerprint + exit-address count + source + ingest time
573
+ * for an IPv4.
574
+ *
575
+ * KEYED. Cypher: CALL whisper.lookupTorRelay($v) YIELD indicator, found, fingerprint, exitAddressCount, source, ingestedAt
576
+ * Returns columns: indicator, found, fingerprint, exitAddressCount, source, ingestedAt.
577
+ * @see https://www.whisper.security/docs/whisper-graph/procedures/helpers
578
+ */
579
+ lookupTorRelay(value: string, opts?: GraphOptions): Promise<GraphResult>;
580
+
581
+ /**
582
+ * List every node and relationship type in the graph with counts and examples.
583
+ *
584
+ * The self-describing schema: node/relationship types with counts, descriptions, examples,
585
+ * source/target labels and query best-practices - the map of what the graph holds.
586
+ *
587
+ * KEYED. Cypher: CALL db.schema()
588
+ * Returns columns: type, name, count, description, example, sourceLabels, targetLabels, fastPatterns, slowPatterns, bestPractices.
589
+ * @see https://www.whisper.security/docs/whisper-graph/schema
590
+ */
591
+ dbSchema(opts?: GraphOptions): Promise<GraphResult>;
592
+
593
+ /**
594
+ * Contribute an indicator observation or feedback back into the graph (requires an API key).
595
+ *
596
+ * The write channel: submit an indicator/feedback with an attributable API key (anonymous
597
+ * submits are refused to preserve K-anonymity). Keyed-only by design.
598
+ *
599
+ * KEYED write: CALL whisper.submit({...}) with the payload fields bound as $-parameters.
600
+ * Fields: kind, identifier_kind, value, observation_id, confidence, first_seen, provenance, query, results, comment, severity, v (kind required).
601
+ * Returns columns: observation_id, kind, accepted, idempotent, promotion_state, k_bucket, advisory, v.
602
+ * @see https://www.whisper.security/docs/cypher-api
603
+ */
604
+ submit(payload: GraphSubmission, opts?: GraphOptions): Promise<GraphResult>;
605
+ // END GENERATED: graph catalog
606
+ }
607
+
608
+ /** The keyed graph namespace. `import { graph } from "whisper-id"`. */
609
+ export const graph: GraphNamespace;
package/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Copyright (c) 2026 viaGraph B.V. (Whisper Security)
3
- // whisper-id — a routable IPv6 identity and safe egress for any Node agent.
3
+ // whisper-id - a routable IPv6 identity and safe egress for any Node agent.
4
4
  // A thin, dependency-free wrapper over the `whisper` CLI (https://whisper.online): the CLI
5
5
  // holds the auth, control plane, and egress tunnel; this gives you register/egress/verify/ip.
6
6
  import { execFile } from "node:child_process";
@@ -72,7 +72,7 @@ function getKey(obj, ...keys) {
72
72
  return null;
73
73
  }
74
74
 
75
- /** Create a named agent identity — a routable Whisper IPv6 /128. */
75
+ /** Create a named agent identity - a routable Whisper IPv6 /128. */
76
76
  export async function register(name, { newKey = false, timeout = 120000 } = {}) {
77
77
  if (!name || !name.trim()) throw new WhisperError("register() needs a non-empty agent name");
78
78
  const args = ["create", "--name", name];
@@ -129,7 +129,7 @@ export async function withEgress(optsOrFn, maybeFn) {
129
129
  }
130
130
 
131
131
  // Keyless public endpoint base (the same surface the CLI uses, server-side). No key, no `whisper`
132
- // CLI — these work in any runtime that can fetch() (serverless, edge, browser). Override via env.
132
+ // CLI - these work in any runtime that can fetch() (serverless, edge, browser). Override via env.
133
133
  function rdapBase() {
134
134
  return (process.env.WHISPER_RDAP_URL || "https://rdap.whisper.online").replace(/\/+$/, "");
135
135
  }
@@ -155,7 +155,7 @@ async function httpGetJson(url, { timeout = 60000 } = {}) {
155
155
  }
156
156
 
157
157
  /**
158
- * Return true iff `address` is a real Whisper agent. **Keyless and CLI-free** — one HTTPS GET to
158
+ * Return true iff `address` is a real Whisper agent. **Keyless and CLI-free** - one HTTPS GET to
159
159
  * the public /verify-identity endpoint (server-side DANE+DNSSEC+reverse-DNS+JWS chain). Works in
160
160
  * any runtime. 200 ⇒ agent, 404 ⇒ not. Throws only if the endpoint is unreachable.
161
161
  */
@@ -163,7 +163,7 @@ export async function verify(address, { timeout = 60000 } = {}) {
163
163
  return (await verifyDetails(address, { timeout })) !== null;
164
164
  }
165
165
 
166
- /** Full verify verdict for a real agent (is_whisper_agent, fqdn, operator, tenant, dane_ok, jws_ok, …), else null. Keyless, CLI-free. */
166
+ /** Full verify verdict for a real agent (is_whisper_agent, fqdn, operator, tenant, dane_ok, jws_ok, ...), else null. Keyless, CLI-free. */
167
167
  export async function verifyDetails(address, { timeout = 60000 } = {}) {
168
168
  if (!address || !address.trim()) throw new WhisperError("verify() needs an address");
169
169
  const url = `${rdapBase()}/verify-identity?ip=${encodeURIComponent(address.trim())}`;
@@ -172,10 +172,10 @@ export async function verifyDetails(address, { timeout = 60000 } = {}) {
172
172
  return body;
173
173
  }
174
174
 
175
- /** Public RDAP record for a Whisper /128 (handle, name, status, entities, …), or null. Keyless, CLI-free. */
175
+ /** Public RDAP record for a Whisper /128 (handle, name, status, entities, ...), or null. Keyless, CLI-free. */
176
176
  export async function rdap(address, { timeout = 60000 } = {}) {
177
177
  if (!address || !address.trim()) throw new WhisperError("rdap() needs an address");
178
- const url = `${rdapBase()}/ip/${encodeURI(address.trim())}`; // path segment — keep the literal colons
178
+ const url = `${rdapBase()}/ip/${encodeURI(address.trim())}`; // path segment - keep the literal colons
179
179
  const { status, body } = await httpGetJson(url, { timeout });
180
180
  return status === 200 ? body : null;
181
181
  }
@@ -193,10 +193,10 @@ export async function ip({ timeout = 60000 } = {}) {
193
193
  return getKey(env, "ip", "agent") || firstAddr(env) || "";
194
194
  }
195
195
 
196
- // ── Control plane (pure HTTP — no CLI) ──────────────────────────────────────────────
196
+ // ── Control plane (pure HTTP - no CLI) ──────────────────────────────────────────────
197
197
  // The governance half of the two-tier surface (Postel: keyless verify above works with no
198
- // key; these unlock with the caller's key). One control verb — `whisper.agents({op,args})`
199
- // — POSTed straight to the control endpoint. No `whisper` CLI, no child process, no key on
198
+ // key; these unlock with the caller's key). One control verb - `whisper.agents({op,args})`
199
+ // - POSTed straight to the control endpoint. No `whisper` CLI, no child process, no key on
200
200
  // argv: works in any runtime with fetch() (serverless, edge, a plain server). The key is
201
201
  // read from opts.apiKey or $WHISPER_API_KEY and sent ONLY in the X-API-Key header.
202
202
 
@@ -204,8 +204,8 @@ function controlUrl() {
204
204
  return process.env.WHISPER_CONTROL_URL || "https://graph.whisper.security/api/query";
205
205
  }
206
206
 
207
- // Conservative-emit (RFC 761): render a value as a Cypher literal so no input — however
208
- // hostile — can break out of the map. A ' is doubled and a \ is doubled (order matters:
207
+ // Conservative-emit (RFC 761): render a value as a Cypher literal so no input - however
208
+ // hostile - can break out of the map. A ' is doubled and a \ is doubled (order matters:
209
209
  // backslashes first). Mirrors the reference client's cypher builder byte-for-byte.
210
210
  function escapeCypher(s) {
211
211
  return String(s).replace(/\\/g, "\\\\").replace(/'/g, "''");
@@ -235,8 +235,8 @@ function buildAgentsQuery(op, args) {
235
235
  }
236
236
 
237
237
  // Liberal-accept (RFC 761): decode BOTH documented envelope shapes into one normalised
238
- // { ok, status, result, error, retryAfter } — the flat {ok,status,result,error} dev shape
239
- // AND the live procedure-row table whose single row carries the per-op envelope — plus a
238
+ // { ok, status, result, error, retryAfter } - the flat {ok,status,result,error} dev shape
239
+ // AND the live procedure-row table whose single row carries the per-op envelope - plus a
240
240
  // bare RFC-7807 problem object. Never throws on shape; a fault becomes error, not a crash.
241
241
  function decodeEnvelope(body, httpStatus) {
242
242
  const out = { ok: false, status: httpStatus, result: null, error: null, retryAfter: null };
@@ -254,9 +254,9 @@ function decodeEnvelope(body, httpStatus) {
254
254
  if (out.error && !out.error.status) out.error.status = out.status;
255
255
  return out;
256
256
  };
257
- // Shape B — an explicit ok flag at the top level.
257
+ // Shape B - an explicit ok flag at the top level.
258
258
  if (typeof body.ok === "boolean") return applyRow(body);
259
- // Shape A — the procedure-row table: rows[0] carries {op,ok,status,result,error,…}. The
259
+ // Shape A - the procedure-row table: rows[0] carries {op,ok,status,result,error,...}. The
260
260
  // row may be an object already, or a positional array aligned to top-level columns.
261
261
  if (Array.isArray(body.rows) && body.rows.length > 0) {
262
262
  let row0 = body.rows[0];
@@ -287,7 +287,7 @@ function decodeEnvelope(body, httpStatus) {
287
287
  }
288
288
 
289
289
  // Turn a {columns,rows} result into column-keyed records. Rows may be positional arrays
290
- // (aligned to columns) or already-keyed objects — both are accepted.
290
+ // (aligned to columns) or already-keyed objects - both are accepted.
291
291
  function recordsFrom(result) {
292
292
  if (!result || !Array.isArray(result.rows)) return [];
293
293
  const cols = Array.isArray(result.columns) ? result.columns : [];
@@ -312,7 +312,7 @@ async function callControl(op, args, opts = {}) {
312
312
  const key = resolveKey(opts);
313
313
  if (!key) {
314
314
  throw new WhisperError(
315
- `\`${op}\` needs a Whisper API key — pass { apiKey } or set WHISPER_API_KEY. ` +
315
+ `\`${op}\` needs a Whisper API key - pass { apiKey } or set WHISPER_API_KEY. ` +
316
316
  "(verify/verifyDetails/rdap are keyless and need no key.)"
317
317
  );
318
318
  }
@@ -383,7 +383,7 @@ export async function policy(opts = {}) {
383
383
  }
384
384
 
385
385
  /**
386
- * Query recent activity from warm storage (op:logs) — the poll counterpart of the live
386
+ * Query recent activity from warm storage (op:logs) - the poll counterpart of the live
387
387
  * monitor. All filters optional: `{ agent, kind:"dns"|"conn"|"alloc", from, to, limit }`
388
388
  * (from/to accept epoch-ms, RFC-3339, or relative like "-1h"). → an array of event records.
389
389
  */
@@ -399,9 +399,9 @@ export async function logs(opts = {}) {
399
399
  }
400
400
 
401
401
  /**
402
- * Fully revoke an agent — IRREVERSIBLE (op:revoke; needs admin:dns). Liberal input:
403
- * `revoke("agent-id")`, `revoke("2a04:2a01:…")`, or `revoke({ agent, apiKey })`.
404
- * → `{ status, … }`.
402
+ * Fully revoke an agent - IRREVERSIBLE (op:revoke; needs admin:dns). Liberal input:
403
+ * `revoke("agent-id")`, `revoke("2a04:2a01:...")`, or `revoke({ agent, apiKey })`.
404
+ * → `{ status, ... }`.
405
405
  */
406
406
  export async function revoke(target, maybeOpts) {
407
407
  const o = typeof target === "string" ? maybeOpts || {} : target || {};
@@ -414,7 +414,7 @@ export async function revoke(target, maybeOpts) {
414
414
 
415
415
  /**
416
416
  * Allocate your own /128 identity (op:identity): `identity({ label, contact_email })`.
417
- * Release form: `identity({ release:true, address:"2a04:2a01:…" })`.
417
+ * Release form: `identity({ release:true, address:"2a04:2a01:..." })`.
418
418
  * → `{ agent, address, fqdn, ptr, state }`.
419
419
  */
420
420
  export async function identity(opts = {}) {
@@ -434,7 +434,7 @@ export async function identity(opts = {}) {
434
434
  }
435
435
 
436
436
  /**
437
- * One agent's detail + live counters (op:agent). Liberal input: `agent("2a04:2a01:…")`,
437
+ * One agent's detail + live counters (op:agent). Liberal input: `agent("2a04:2a01:...")`,
438
438
  * `agent("agent-id")`, or `agent({ agent })` / `agent({ address })`. A value with a colon
439
439
  * is treated as a /128 address. → the detail record, or null if not in your account.
440
440
  */
@@ -454,3 +454,380 @@ export async function agent(sel = {}, maybeOpts) {
454
454
  const recs = await callControl("agent", args, o);
455
455
  return recs.length ? recs[0] : null;
456
456
  }
457
+
458
+ // ── Graph (keyed Cypher over the Whisper Security graph) ─────────────────────────────
459
+ // The intelligence half of the keyed surface. Postel two-tier holds cleanly: the keyless
460
+ // identity calls above (verify/verifyDetails/rdap) run with NO key and touch NO Cypher;
461
+ // this whole `graph` namespace is KEYED (Kaveh's absolute rule: if it is Cypher, it needs
462
+ // a key). It POSTs raw Cypher to the SAME endpoint the control plane uses
463
+ // (https://graph.whisper.security/api/query, X-API-Key), reusing resolveKey() so there is
464
+ // one auth path, not two. It does NOT go through the whisper.agents() control wrapper: the
465
+ // graph contract is different (body {query,parameters}; reply {columns,rows,statistics}
466
+ // where rows are already column-keyed objects), so it has its own choke point, callGraph().
467
+ //
468
+ // Source of truth for the 29 methods is the Whisper query catalog (catalog.json +
469
+ // sdk-methods.json): the GRAPH_METHODS table below is GENERATED from it by
470
+ // scripts/gen-graph.mjs (`npm run gen`), so this SDK cannot drift from the catalog. The
471
+ // 14 `direct` entries run their catalog Cypher against /api/query; the 15 `flow` entries
472
+ // EXECUTE on the workflow runner (POST gallery/run, Server-Sent Events) via callFlow().
473
+ // Every method carries its canonical docs URL as `.docs`. graph.query() is the raw
474
+ // escape hatch.
475
+
476
+ function graphUrl() {
477
+ // Defaults to the control endpoint, which already IS the graph endpoint. Override for tests
478
+ // or a private deployment. WHISPER_GRAPH_URL wins, then the shared control URL.
479
+ return process.env.WHISPER_GRAPH_URL || controlUrl();
480
+ }
481
+
482
+ // Normalise the /api/query reply into { columns, rows, statistics }. Per the contract rows
483
+ // are OBJECTS keyed by column name; we also accept a positional-array row (aligned to
484
+ // columns) so a liberal server shape still decodes. Never throws on shape.
485
+ function normalizeGraph(body) {
486
+ if (!body || typeof body !== "object") return { columns: [], rows: [], statistics: null };
487
+ const columns = Array.isArray(body.columns) ? body.columns : [];
488
+ const rawRows = Array.isArray(body.rows) ? body.rows : [];
489
+ const rows = rawRows.map((row) => {
490
+ if (Array.isArray(row)) {
491
+ const m = {};
492
+ columns.forEach((c, i) => { if (i < row.length) m[c] = row[i]; });
493
+ return m;
494
+ }
495
+ return row && typeof row === "object" ? row : { value: row };
496
+ });
497
+ return { columns, rows, statistics: body.statistics || null };
498
+ }
499
+
500
+ // The one place that talks to the graph. Requires a key (via the shared resolveKey), POSTs
501
+ // {query,parameters}, and on an HTTP>=400 or an RFC-7807 problem body throws a WhisperError
502
+ // carrying the server's helpful detail (plus .status/.retryAfter). The key is only ever sent
503
+ // in X-API-Key: never logged, never on argv, never returned.
504
+ async function callGraph(cypher, params, opts = {}, methodName = "query") {
505
+ const key = resolveKey(opts);
506
+ if (!key) {
507
+ throw new WhisperError(
508
+ `\`graph.${methodName}\` needs a Whisper API key: pass { apiKey } or set WHISPER_API_KEY. ` +
509
+ "(verify/verifyDetails/rdap are keyless and need no key; graph queries are Cypher, so they do.)"
510
+ );
511
+ }
512
+ const url = graphUrl();
513
+ const timeout = opts.timeout || 60000;
514
+ const ctrl = new AbortController();
515
+ const t = setTimeout(() => ctrl.abort(), timeout);
516
+ let res;
517
+ try {
518
+ res = await fetch(url, {
519
+ method: "POST",
520
+ headers: {
521
+ "Content-Type": "application/json",
522
+ Accept: "application/json",
523
+ "X-API-Key": key,
524
+ "User-Agent": UA,
525
+ },
526
+ body: JSON.stringify({ query: cypher, parameters: params || {} }),
527
+ signal: ctrl.signal,
528
+ });
529
+ } catch (err) {
530
+ throw new WhisperError(`could not reach the Whisper graph at ${url}: ${err && err.message ? err.message : err}`);
531
+ } finally {
532
+ clearTimeout(t);
533
+ }
534
+ let body = null;
535
+ try {
536
+ body = await res.json();
537
+ } catch {
538
+ body = null;
539
+ }
540
+ // A fault is either an HTTP error status or a problem body (has error/detail/title but no
541
+ // result table). A valid table always carries columns/rows, so those are never a fault.
542
+ const hasTable = body && typeof body === "object" && (Array.isArray(body.columns) || Array.isArray(body.rows));
543
+ const problem = body && typeof body === "object" && (body.error || (!hasTable && (body.detail || body.title || body.type)));
544
+ if (res.status >= 400 || problem) {
545
+ const e = (body && (body.error || body)) || {};
546
+ const werr = new WhisperError(e.detail || e.title || e.message || e.type || `graph query returned HTTP ${res.status}`);
547
+ werr.status = e.status || res.status;
548
+ if (e.retry_after != null) werr.retryAfter = e.retry_after;
549
+ throw werr;
550
+ }
551
+ return normalizeGraph(body);
552
+ }
553
+
554
+ // BEGIN GENERATED: graph catalog (scripts/gen-graph.mjs) - do not edit by hand
555
+ // The Whisper query catalog, one row per entry (14 direct + 15 flow), in catalog order.
556
+ // A direct row carries its catalog Cypher (input bound as a $-parameter); a flow row
557
+ // carries its gallery/run slug plus the input/param names the workflow runner accepts;
558
+ // the map-call row (submit) enumerates its fields. `docs` is the canonical docs URL.
559
+ const GRAPH_DOCS_BASE = "https://www.whisper.security";
560
+ const GRAPH_FLOW_RUN_URL = "https://console.whisper.security/api/gallery/run";
561
+ const GRAPH_METHODS = [
562
+ { name: "anycastDnsRootSovereignty", mode: "flow", slug: "anycast-dns-root-sovereignty", primary: "country", inputs: ["country"], params: ["instanceType"], docs: GRAPH_DOCS_BASE + "/docs/recipes/compliance" },
563
+ { name: "attackPath", mode: "flow", slug: "attack-path", primary: "value", inputs: ["value", "other"], params: ["level"], docs: GRAPH_DOCS_BASE + "/docs/recipes/attack-path" },
564
+ { name: "attackSurface", mode: "flow", slug: "attack-surface", primary: "domain", inputs: ["domain"], params: ["level"], docs: GRAPH_DOCS_BASE + "/docs/recipes/pentest-recon" },
565
+ { name: "bgpHijackExposure", mode: "flow", slug: "bgp-hijack-exposure", primary: "value", inputs: ["value"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/bgp-routing" },
566
+ { name: "blastRadius", mode: "flow", slug: "blast-radius", primary: "indicator", inputs: ["indicator"], params: ["depth"], docs: GRAPH_DOCS_BASE + "/docs/recipes/soc" },
567
+ { name: "buildTakedownEvidencePackage", mode: "flow", slug: "build-takedown-evidence-package", primary: "domain", inputs: ["domain"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/threat-intel" },
568
+ { name: "discoverAiAgentInfrastructure", mode: "flow", slug: "discover-ai-agent-infrastructure", primary: "value", inputs: ["value"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/pentest-recon" },
569
+ { name: "indicator", mode: "flow", slug: "indicator", primary: "indicator", inputs: ["indicator"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/soc" },
570
+ { name: "indicatorEnrichment", mode: "flow", slug: "indicator-enrichment", primary: "value", inputs: ["value"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/dns-email" },
571
+ { name: "infrastructureMapping", mode: "flow", slug: "infrastructure-mapping", primary: "value", inputs: ["value"], params: ["level"], docs: GRAPH_DOCS_BASE + "/docs/recipes/compliance" },
572
+ { name: "mapSupplyChainConcentration", mode: "flow", slug: "map-supply-chain-concentration", primary: "domain", inputs: ["domain"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/compliance" },
573
+ { name: "nameserverHijackDnsConsistency", mode: "flow", slug: "nameserver-hijack-dns-consistency", primary: "value", inputs: ["value"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/dns-email" },
574
+ { name: "routeHealth", mode: "flow", slug: "route-health", primary: "target", inputs: ["target"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/bgp-routing" },
575
+ { name: "subdomainTakeover", mode: "flow", slug: "subdomain-takeover", primary: "value", inputs: ["value"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/pentest-recon" },
576
+ { name: "typosquat", mode: "flow", slug: "typosquat", primary: "domain", inputs: ["domain"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/brand-protection" },
577
+ { name: "identify", mode: "direct", input: "v", cypher: "CALL whisper.identify([$v]) YIELD host, vendor_id, canonical_name, category, roles, host_class, band", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/identify" },
578
+ { name: "assess", mode: "direct", input: "v", cypher: "CALL whisper.assess([$v]) YIELD host, label, band, sub_labels, coverage, evidence", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures" },
579
+ { name: "variants", mode: "direct", input: "v", cypher: "CALL whisper.variants($v) YIELD variant, method, exists, confidence", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/variants" },
580
+ { name: "walk", mode: "direct", input: "v", cypher: "CALL whisper.walk($v) YIELD coverage, host, nearest_known_vendors, no_atlas_match, siblings", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures" },
581
+ { name: "explain", mode: "direct", input: "v", cypher: "CALL whisper.explain($v) YIELD indicator, score, level, explanation, sources", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/explain" },
582
+ { name: "pslTldplusone", mode: "direct", input: "v", cypher: "CALL whisper.psl.tldPlusOne($v) YIELD apex", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/helpers" },
583
+ { name: "pslAffiliation", mode: "direct", input: "v", cypher: "CALL whisper.psl.affiliation($v) YIELD found, suffix, submitterOrg, submitterLogin, evidenceKind, confidence", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/helpers" },
584
+ { name: "origins", mode: "direct", input: "v", cypher: "CALL whisper.origins($v) YIELD ip, confidence, methods, asn, asnName, kind", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/origins" },
585
+ { name: "history", mode: "direct", input: "v", cypher: "CALL whisper.history($v)", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/history" },
586
+ { name: "historyWhois", mode: "direct", input: "v", cypher: "CALL whisper.history.whois($v) YIELD queryTime, createDate, updateDate, expiryDate, registrar, registrant, country, nameServers", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/history" },
587
+ { name: "asset", mode: "direct", input: "v", cypher: "CALL whisper.asSet($v) YIELD asSetName, memberAsn, sourceRir", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures" },
588
+ { name: "lookupTorRelay", mode: "direct", input: "v", cypher: "CALL whisper.lookupTorRelay($v) YIELD indicator, found, fingerprint, exitAddressCount, source, ingestedAt", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/helpers" },
589
+ { name: "dbSchema", mode: "direct", input: null, cypher: "CALL db.schema()", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/schema" },
590
+ { name: "submit", mode: "direct", proc: "whisper.submit", fields: [["kind", "kind"], ["identifierKind", "identifier_kind"], ["value", "value"], ["observationId", "observation_id"], ["confidence", "confidence"], ["firstSeen", "first_seen"], ["provenance", "provenance"], ["query", "query"], ["results", "results"], ["comment", "comment"], ["severity", "severity"], ["v", "v"]], requires: ["kind"], docs: GRAPH_DOCS_BASE + "/docs/cypher-api" },
591
+ ];
592
+ // END GENERATED: graph catalog
593
+
594
+ // Coerce a liberal first arg into the string value bound as $v. Accepts a bare value or a
595
+ // { value } / { v } wrapper; empty is rejected with a clear error, never an opaque failure.
596
+ function graphValue(name, value) {
597
+ let v = value;
598
+ if (v && typeof v === "object" && !Array.isArray(v)) {
599
+ if (v.value !== undefined) v = v.value;
600
+ else if (v.v !== undefined) v = v.v;
601
+ }
602
+ if (v === null || v === undefined || String(v).trim() === "") {
603
+ throw new WhisperError(`graph.${name}() needs a value (a host, IP, domain, or ASN)`);
604
+ }
605
+ return String(v).trim();
606
+ }
607
+
608
+ /**
609
+ * Run raw Cypher against the Whisper Security graph (keyed). The escape hatch under every
610
+ * typed method: `graph.query("MATCH (n) RETURN n LIMIT 1", {}, { apiKey })`. Returns the
611
+ * full `{ columns, rows, statistics }` (rows are objects keyed by column name).
612
+ * @see https://www.whisper.security/docs/cypher-api
613
+ */
614
+ async function graphQuery(cypher, params, opts = {}) {
615
+ if (!cypher || !String(cypher).trim()) throw new WhisperError("graph.query() needs a Cypher string");
616
+ return callGraph(String(cypher), params || {}, opts, "query");
617
+ }
618
+
619
+ // ── Flow runner (gallery/run, Server-Sent Events) ───────────────────────────────────
620
+ // A flow entry is a multi-step read workflow orchestrated by the Whisper workflow runner:
621
+ // POST {slug, inputs, params} to the gallery/run endpoint (keyed, X-API-Key) and consume
622
+ // the SSE stream. Each `step` event carries a step's bound Cypher + rows; the terminal
623
+ // `complete` event carries the aggregate context. callFlow() collects the whole stream and
624
+ // resolves to { slug, steps, complete, events } - one call, the full flow.
625
+
626
+ function flowRunUrl() {
627
+ return process.env.WHISPER_FLOW_URL || GRAPH_FLOW_RUN_URL;
628
+ }
629
+
630
+ // Liberal-accept (RFC 761): coerce the caller's input into the runner's {inputs, params}.
631
+ // A bare value binds to the flow's primary input; a plain object routes each key by name
632
+ // (a known knob name goes to params, everything else to inputs); an explicit
633
+ // { inputs, params } wrapper passes through untouched.
634
+ function flowBody(m, input) {
635
+ const inputs = {};
636
+ const params = {};
637
+ if (input === null || input === undefined) return { inputs, params };
638
+ if (typeof input !== "object" || Array.isArray(input)) {
639
+ const v = String(input).trim();
640
+ if (v === "") throw new WhisperError(`graph.${m.name}() needs a value for its \`${m.primary}\` input (a host, IP, domain, ASN, or prefix)`);
641
+ inputs[m.primary] = v;
642
+ return { inputs, params };
643
+ }
644
+ if ((input.inputs && typeof input.inputs === "object") || (input.params && typeof input.params === "object")) {
645
+ return { inputs: { ...(input.inputs || {}) }, params: { ...(input.params || {}) } };
646
+ }
647
+ for (const [k, v] of Object.entries(input)) {
648
+ if (v === undefined) continue;
649
+ if (m.params.includes(k)) params[k] = v;
650
+ else inputs[k] = v;
651
+ }
652
+ return { inputs, params };
653
+ }
654
+
655
+ // Parse one SSE record ("event: x\ndata: {...}") into { event, data }; data is JSON-decoded
656
+ // when possible, else kept as the raw string. Never throws on shape.
657
+ function parseSseRecord(record) {
658
+ let event = "message";
659
+ const dataLines = [];
660
+ for (const line of record.split("\n")) {
661
+ if (line.startsWith("event:")) event = line.slice(6).trim();
662
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
663
+ }
664
+ let data = dataLines.join("\n");
665
+ try {
666
+ data = JSON.parse(data);
667
+ } catch {
668
+ // not JSON - keep the raw string
669
+ }
670
+ return { event, data };
671
+ }
672
+
673
+ // The one place that talks to the workflow runner. Requires a key (shared resolveKey),
674
+ // POSTs {slug, inputs, params}, consumes the SSE stream, and resolves to the collected
675
+ // { slug, steps, complete, events }. Flows are multi-step, so the default timeout is 5
676
+ // minutes (override with opts.timeout). On an HTTP error, an `error` event, or a stream
677
+ // that ends with no result at all, throws a WhisperError with the server's clear detail.
678
+ async function callFlow(m, input, opts = {}) {
679
+ const key = resolveKey(opts);
680
+ if (!key) {
681
+ throw new WhisperError(
682
+ `\`graph.${m.name}\` needs a Whisper API key: pass { apiKey } or set WHISPER_API_KEY. ` +
683
+ "(verify/verifyDetails/rdap are keyless and need no key; flows run keyed on the workflow runner.)"
684
+ );
685
+ }
686
+ const { inputs, params } = flowBody(m, input);
687
+ const url = flowRunUrl();
688
+ const timeout = opts.timeout || 300000;
689
+ const ctrl = new AbortController();
690
+ const t = setTimeout(() => ctrl.abort(), timeout);
691
+ try {
692
+ let res;
693
+ try {
694
+ res = await fetch(url, {
695
+ method: "POST",
696
+ headers: {
697
+ "Content-Type": "application/json",
698
+ Accept: "text/event-stream",
699
+ "X-API-Key": key,
700
+ "User-Agent": UA,
701
+ },
702
+ body: JSON.stringify({ slug: m.slug, inputs, params }),
703
+ signal: ctrl.signal,
704
+ });
705
+ } catch (err) {
706
+ throw new WhisperError(`could not reach the Whisper workflow runner at ${url}: ${err && err.message ? err.message : err}`);
707
+ }
708
+ if (res.status >= 400) {
709
+ let body = null;
710
+ try {
711
+ body = await res.json();
712
+ } catch {
713
+ body = null;
714
+ }
715
+ const e = body && typeof body === "object" ? body : {};
716
+ const werr = new WhisperError(
717
+ e.message || e.detail || (typeof e.error === "string" ? e.error : null) || `the workflow runner returned HTTP ${res.status}`
718
+ );
719
+ werr.status = res.status;
720
+ throw werr;
721
+ }
722
+ // Liberal accept: a plain-JSON reply (no stream) is decoded as the complete result.
723
+ const ct = res.headers && typeof res.headers.get === "function" ? res.headers.get("content-type") || "" : "";
724
+ if (ct.includes("json")) {
725
+ let body = null;
726
+ try {
727
+ body = await res.json();
728
+ } catch {
729
+ body = null;
730
+ }
731
+ return { slug: m.slug, steps: [], complete: body && typeof body === "object" ? body : null, events: [] };
732
+ }
733
+ // The SSE stream: split records on blank lines (CRLF-safe), decode each.
734
+ const events = [];
735
+ const decoder = new TextDecoder("utf-8");
736
+ let buf = "";
737
+ const drain = () => {
738
+ buf = buf.replace(/\r\n/g, "\n");
739
+ let i;
740
+ while ((i = buf.indexOf("\n\n")) !== -1) {
741
+ const rec = buf.slice(0, i);
742
+ buf = buf.slice(i + 2);
743
+ if (rec.trim() !== "") events.push(parseSseRecord(rec));
744
+ }
745
+ };
746
+ for await (const chunk of res.body) {
747
+ buf += decoder.decode(chunk, { stream: true });
748
+ drain();
749
+ }
750
+ buf += decoder.decode();
751
+ drain();
752
+ if (buf.trim() !== "") events.push(parseSseRecord(buf));
753
+ const steps = events.filter((ev) => ev.event === "step").map((ev) => ev.data);
754
+ const completeEv = events.findLast((ev) => ev.event === "complete");
755
+ const complete = completeEv && completeEv.data && typeof completeEv.data === "object" ? completeEv.data : null;
756
+ const errEv = events.find((ev) => ev.event === "error");
757
+ if (errEv && !complete) {
758
+ const d = errEv.data && typeof errEv.data === "object" ? errEv.data : { detail: String(errEv.data || "") };
759
+ const werr = new WhisperError(d.message || d.detail || (typeof d.error === "string" ? d.error : null) || `graph.${m.name} failed on the workflow runner`);
760
+ werr.events = events;
761
+ throw werr;
762
+ }
763
+ if (!complete && steps.length === 0) {
764
+ const werr = new WhisperError(`graph.${m.name}: the flow stream ended without a step or complete event`);
765
+ werr.events = events;
766
+ throw werr;
767
+ }
768
+ return { slug: m.slug, steps, complete, events };
769
+ } catch (err) {
770
+ if (err instanceof WhisperError) throw err;
771
+ if ((err && err.name === "AbortError") || ctrl.signal.aborted) {
772
+ throw new WhisperError(`graph.${m.name} timed out after ${timeout}ms (flows are multi-step; raise { timeout } if you need longer)`);
773
+ }
774
+ throw new WhisperError(`graph.${m.name} failed while streaming from the workflow runner: ${err && err.message ? err.message : err}`);
775
+ } finally {
776
+ clearTimeout(t);
777
+ }
778
+ }
779
+
780
+ // The map-call write (whisper.submit): each payload field becomes one entry of a Cypher
781
+ // map with its value bound as its own $-parameter (injection-safe), keys in SORTED order
782
+ // so the query is byte-stable. Field names are accepted in both catalog spellings
783
+ // (camelCase id or snake_case paramName) and normalised to the wire form; any extra key
784
+ // must be a plain identifier, and the server validates the full set per kind.
785
+ function makeMapCall(m) {
786
+ const canon = new Map();
787
+ for (const [id, param] of m.fields) {
788
+ canon.set(id, param);
789
+ canon.set(param, param);
790
+ }
791
+ return async (payload, opts = {}) => {
792
+ const p = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
793
+ const bound = {};
794
+ for (const [k, v] of Object.entries(p)) {
795
+ if (v === undefined) continue;
796
+ const name = canon.get(k) || k;
797
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
798
+ throw new WhisperError(`graph.${m.name}() field ${JSON.stringify(k)} is not a valid field name (letters, digits, _)`);
799
+ }
800
+ bound[name] = v;
801
+ }
802
+ for (const req of m.requires) {
803
+ if (bound[req] === null || bound[req] === undefined || String(bound[req]).trim() === "") {
804
+ throw new WhisperError(`graph.${m.name}() needs a { ${req} } of 'indicator' or 'feedback' (plus its fields)`);
805
+ }
806
+ }
807
+ const keys = Object.keys(bound).sort();
808
+ const cypher = `CALL ${m.proc}({` + keys.map((k) => `${k}:$${k}`).join(", ") + "})";
809
+ return callGraph(cypher, bound, opts, m.name);
810
+ };
811
+ }
812
+
813
+ // Build the namespace from the generated catalog table: query + one method per entry.
814
+ // Direct methods run their catalog Cypher; the map-call write builds its parameter map;
815
+ // flow methods execute on the workflow runner. Every method carries its canonical docs
816
+ // URL as `.docs` (docsBase + the entry's docPath).
817
+ const graph = { query: graphQuery };
818
+ graph.query.docs = GRAPH_DOCS_BASE + "/docs/cypher-api";
819
+
820
+ for (const m of GRAPH_METHODS) {
821
+ if (m.mode === "flow") {
822
+ graph[m.name] = async (input, opts = {}) => callFlow(m, input, opts);
823
+ } else if (m.fields) {
824
+ graph[m.name] = makeMapCall(m);
825
+ } else if (m.input) {
826
+ graph[m.name] = async (value, opts = {}) => callGraph(m.cypher, { [m.input]: graphValue(m.name, value) }, opts, m.name);
827
+ } else {
828
+ graph[m.name] = async (opts = {}) => callGraph(m.cypher, {}, opts, m.name);
829
+ }
830
+ graph[m.name].docs = m.docs;
831
+ }
832
+
833
+ export { graph };
package/package.json CHANGED
@@ -1,16 +1,42 @@
1
1
  {
2
2
  "name": "whisper-id",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "A routable Whisper IPv6 identity and safe egress for any Node agent.",
5
5
  "type": "module",
6
- "exports": { ".": { "types": "./index.d.ts", "import": "./index.js" } },
6
+ "exports": {
7
+ ".": {
8
+ "types": "./index.d.ts",
9
+ "import": "./index.js"
10
+ }
11
+ },
7
12
  "types": "./index.d.ts",
8
- "files": ["index.js", "index.d.ts", "README.md", "LICENSE"],
9
- "engines": { "node": ">=18" },
10
- "scripts": { "test": "node --test" },
11
- "keywords": ["whisper", "agent", "identity", "ipv6", "egress", "proxy", "dns"],
13
+ "files": [
14
+ "index.js",
15
+ "index.d.ts",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "scripts": {
23
+ "test": "node --test",
24
+ "gen": "node scripts/gen-graph.mjs"
25
+ },
26
+ "keywords": [
27
+ "whisper",
28
+ "agent",
29
+ "identity",
30
+ "ipv6",
31
+ "egress",
32
+ "proxy",
33
+ "dns"
34
+ ],
12
35
  "homepage": "https://whisper.online",
13
- "repository": { "type": "git", "url": "https://github.com/whisper-sec/whisper-node.git" },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/whisper-sec/whisper-node.git"
39
+ },
14
40
  "license": "MIT",
15
41
  "author": "Whisper Security"
16
42
  }