whisper-id 0.5.0 → 0.6.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 +87 -76
  2. package/index.d.ts +84 -30
  3. package/index.js +134 -71
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -1,34 +1,103 @@
1
1
  # whisper-id
2
2
 
3
- A real, routable **IPv6 identity** and **safe egress** for any Node agent.
4
-
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`.
3
+ Three things for any Node agent, in one dependency-free package: query the **Whisper security graph** (3.6B nodes / 30B relationships, Cypher), give the agent a routable **IPv6 identity** with safe **egress**, and drive the full **control plane**.
10
4
 
11
5
  ```sh
12
6
  npm i whisper-id
13
7
  ```
14
8
 
9
+ ## The security graph (keyless, zero setup)
10
+
11
+ The Whisper graph knows who operates a host, its threat posture, its look-alikes, the real origins behind a CDN, WHOIS history, and 20+ named investigations. The **direct read verbs run with no key at all** (rate-limited taste, ~100/window). One import, real answers:
12
+
15
13
  ```js
16
- import { verify } from "whisper-id";
17
- await verify("2a04:2a01:..."); // keyless: true if it's a real Whisper agent
14
+ import { graph } from "whisper-id"; // no key needed for the read verbs
15
+
16
+ await graph.assess("8.8.8.8"); // -> { rows: [{ host: "8.8.8.8", label: "benign-allowlisted", band: "INFO", ... }] }
17
+ await graph.identify("api.openai.com"); // who operates this host -> vendor + operator roles
18
+ await graph.origins("cloudflare.com"); // the real origin IPs behind a CDN
19
+ await graph.explain("paypal.com"); // threat-feed score + why
18
20
  ```
19
21
 
22
+ Set `WHISPER_API_KEY` (or pass `{ apiKey }`) to lift the rate limit and unlock **raw Cypher** and the **multi-step flows**:
23
+
24
+ ```js
25
+ // raw Cypher, your own query, parameters bound as $-params (never string-built):
26
+ await graph.query("MATCH (h:HOSTNAME {name:$n})-[:RESOLVES_TO]->(ip) RETURN ip.name AS ip LIMIT 5", { n: "github.com" });
27
+
28
+ // a named catalog recipe (a multi-step investigation, streamed over SSE):
29
+ await graph.typosquat("paypal.com"); // look-alike sweep -> registered variants + verdict
30
+ await graph.attackSurface({ domain: "github.com" }); // full external footprint, scored
31
+
32
+ // discover the whole catalog (29 queries + flows) with no key, no network:
33
+ for (const r of graph.recipes()) {
34
+ console.log(r.method, r.keyless ? "keyless" : "keyed", r.docsUrl);
35
+ }
36
+ ```
37
+
38
+ Every verb maps to a catalog entry with its own docs page under [whisper.security/docs](https://www.whisper.security/docs) (e.g. [`assess`](https://www.whisper.security/docs/whisper-graph/procedures), [`identify`](https://www.whisper.security/docs/whisper-graph/procedures/identify)); `recipes()` carries the exact `docsUrl` for each, and the same URL sits on every method as `graph.identify.docs` and on its `@see` tag in your editor. The 13 direct reads are keyless; the 15 multi-step flows and the `submit` write channel are keyed. Full query reference: [whisper-catalog](https://github.com/whisper-sec/whisper-catalog).
39
+
40
+ ## Identity + egress
41
+
20
42
  ```js
21
43
  import { identity, withEgress } from "whisper-id"; // with a key (WHISPER_API_KEY)
44
+
22
45
  const me = await identity({ label: "my-bot" }); // allocate a routable Whisper /128
23
46
  await withEgress(async (e) => {
24
47
  // proxy env (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) now points at your local Whisper proxy
25
- // → curl, axios, got, and other env-aware clients leave from your /128.
48
+ // -> curl, axios, got, and other env-aware clients leave from your /128.
26
49
  console.log("egress on", e.proxyUrl);
27
50
  });
28
51
  ```
29
52
 
53
+ And anyone, with no key, can verify an address is a real Whisper agent:
54
+
55
+ ```js
56
+ import { verify, rdap } from "whisper-id";
57
+ if (await verify(addr)) console.log((await rdap(addr)).name); // keyless, pure HTTPS
58
+ ```
59
+
30
60
  ## API
31
61
 
62
+ ### The graph (two-tier)
63
+
64
+ `import { graph } from "whisper-id"`: one typed method per catalog entry, plus `graph.query`
65
+ (raw Cypher) and `graph.recipes()` (catalog discovery). Direct reads resolve to
66
+ `{ columns, rows, statistics }` where `rows` are objects keyed by column name.
67
+
68
+ | Call | Keyless | Does |
69
+ |------|:-------:|------|
70
+ | `graph.assess(value)` | yes | Labelled threat posture for a host or IP (malicious / benign / unknown) |
71
+ | `graph.identify(value)` | yes | Name the vendor and operator role behind a host or IP |
72
+ | `graph.variants(value)` | yes | Look-alike domain variants of a brand, and which are registered |
73
+ | `graph.walk(value)` | yes | Walk to the nearest known vendors behind a host, with channel + confidence |
74
+ | `graph.explain(value)` | yes | Score an indicator against the threat feeds and explain why |
75
+ | `graph.origins(value)` | yes | Real origin IPs behind a CDN-fronted domain, ranked by confidence |
76
+ | `graph.history(value)` / `graph.historyWhois(value)` | yes | Historical WHOIS timeline for a domain |
77
+ | `graph.pslTldplusone(value)` / `graph.pslAffiliation(value)` | yes | Registrable apex (eTLD+1) / PSL private-section affiliation |
78
+ | `graph.asset(value)` | yes | Member ASNs of an AS-SET macro |
79
+ | `graph.lookupTorRelay(value)` | yes | Is an IP a known Tor exit relay |
80
+ | `graph.dbSchema()` | yes | Every node + relationship type in the graph, with counts and examples |
81
+ | `graph.query(cypher, params)` | key | Raw Cypher escape hatch. -> the full `{ columns, rows, statistics }` |
82
+ | `graph.submit({ kind, ...fields })` | key | Contribute an indicator observation or feedback (the write channel) |
83
+ | `graph.recipes()` | yes (offline) | The whole catalog: `{ method, keyless, mode, summary, params, docsUrl }` per verb |
84
+
85
+ Fifteen multi-step **workflow** methods (`attackPath`, `attackSurface`, `blastRadius`, `typosquat`,
86
+ `indicator`, `routeHealth`, and 9 more) execute keyed on the Whisper workflow runner and stream
87
+ Server-Sent Events; one call collects the whole run into `{ slug, steps, complete, events }`:
88
+
89
+ ```js
90
+ const run = await graph.typosquat("paypal.com"); // bare value -> the flow's primary input
91
+ console.log(run.steps.length, "steps"); // each step: its bound Cypher, columns, rows
92
+ console.log(run.complete.totalLatencyMs, "ms"); // the terminal aggregate context
93
+
94
+ await graph.attackSurface({ domain: "github.com", level: "deep" }); // named inputs + knobs
95
+ ```
96
+
97
+ Every `graph` method is generated from the Whisper query catalog (never hand-copied, so it
98
+ cannot drift). A key, when present, rides only in the `X-API-Key` header; a keyless call sends
99
+ no auth header at all.
100
+
32
101
  ### Keyless: no key, no CLI (pure HTTPS)
33
102
 
34
103
  Run anywhere `fetch` runs (Cloudflare Workers, Vercel, Deno, Lambda, the browser).
@@ -40,11 +109,6 @@ Run anywhere `fetch` runs (Cloudflare Workers, Vercel, Deno, Lambda, the browser
40
109
  | `rdap(address)` | The public RDAP record for a `/128`, or `null` |
41
110
  | `egressIp()` | The IP this process leaves from (a `/128` when routed, else the platform's) → `string` |
42
111
 
43
- ```js
44
- import { verify, rdap } from "whisper-id";
45
- if (await verify(addr)) console.log((await rdap(addr)).name);
46
- ```
47
-
48
112
  ### Control plane: with a key (pure HTTP, no CLI)
49
113
 
50
114
  Pure `fetch` to the control endpoint, with no `whisper` CLI and no child process. The key comes from
@@ -69,62 +133,6 @@ for (const a of await list()) console.log(a.address, a.label);
69
133
  const recent = await logs({ kind: "dns", limit: 100 });
70
134
  ```
71
135
 
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
-
128
136
  ### Egress + CLI-backed
129
137
 
130
138
  | Call | Does |
@@ -135,7 +143,7 @@ each method's `@see` tag in your editor.
135
143
  | `ip()` | Your current egress IP via the CLI, proving it's your `/128`. → `string` |
136
144
 
137
145
  `register`, `egress`, `withEgress`, and `ip` need the `whisper` CLI on the host (egress brings up
138
- the local proxy). Everything under **Keyless** and **Control plane** is pure HTTP and needs no CLI.
146
+ the local proxy). Everything else, the graph included, is pure HTTP and needs no CLI.
139
147
 
140
148
  ### Using it with `fetch`
141
149
 
@@ -155,9 +163,10 @@ transparently inside `withEgress`.
155
163
 
156
164
  ## Requirements
157
165
 
158
- Zero dependencies. The **keyless** checks and the **control plane** are pure HTTP (Node ≥ 18's
159
- global `fetch`), with no CLI and nothing to install. For the control plane, set `WHISPER_API_KEY` (or
160
- pass `{ apiKey }`).
166
+ Zero dependencies. The **graph read verbs**, the **keyless** checks, and the **control plane** are
167
+ pure HTTP (Node ≥ 18's global `fetch`), with no CLI and nothing to install. Set `WHISPER_API_KEY`
168
+ (or pass `{ apiKey }`) for the control plane and the keyed graph (raw Cypher + flows + `submit`);
169
+ the graph read verbs, `verify`, and `rdap` need no key.
161
170
 
162
171
  Only **egress** and the CLI-backed calls (`register`, `egress`, `withEgress`, `ip`) need the
163
172
  `whisper` CLI on your `PATH`:
@@ -167,11 +176,13 @@ curl get.whisper.online | sh
167
176
  ```
168
177
 
169
178
  (`$WHISPER_BIN` overrides the CLI path; `$WHISPER_CONTROL_URL` / `$WHISPER_GRAPH_URL` /
170
- `$WHISPER_RDAP_URL` override the endpoints.)
179
+ `$WHISPER_FLOW_URL` / `$WHISPER_RDAP_URL` override the endpoints.)
171
180
 
172
181
  ## Links
173
182
 
174
183
  - Site: https://whisper.online
184
+ - Docs: https://www.whisper.security/docs
185
+ - Query catalog: https://github.com/whisper-sec/whisper-catalog
175
186
  - CLI: https://github.com/whisper-sec/whisper-cli
176
187
 
177
188
  MIT licensed.
package/index.d.ts CHANGED
@@ -151,19 +151,36 @@ export function identity(opts?: IdentityOptions): Promise<AgentItem | null>;
151
151
  export function agent(sel: string, opts?: ControlOptions): Promise<AgentItem | null>;
152
152
  export function agent(opts?: AgentSelectOptions): Promise<AgentItem | null>;
153
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). */
154
+ // ── Graph (the Whisper Security graph: Cypher intelligence, two-tier) ────────────────
155
+ // The intelligence pillar, Postel two-tier with both halves honest: the 13 direct READ
156
+ // verbs (identify, assess, variants, walk, explain, origins, history, and friends) run
157
+ // KEYLESS - no key at all, a rate-limited taste with real answers; a key ({ apiKey } or
158
+ // WHISPER_API_KEY) lifts the limit. Raw Cypher (`graph.query`), the 15 multi-step `flow`
159
+ // methods (workflow runner: POST gallery/run, Server-Sent Events), and the `submit`
160
+ // write stay KEYED. A present key rides only in `X-API-Key`; an absent one sends no auth
161
+ // header at all. The method surface below is GENERATED from the Whisper query catalog by
162
+ // scripts/gen-graph.mjs; every method's docs live at the `@see` URL and are also on the
163
+ // function itself as `.docs`, and `graph.recipes()` lists the whole catalog.
164
+
165
+ /** Per-call options for the graph namespace (same shape as the control plane). */
165
166
  export interface GraphOptions extends ControlOptions {}
166
167
 
168
+ /** One catalog entry from `graph.recipes()`: a graph method and its two-tier access. */
169
+ export interface GraphRecipe {
170
+ /** The method name on the `graph` namespace (e.g. "assess", "typosquat"). */
171
+ method: string;
172
+ /** True for the 13 direct read verbs that run with no key at all (rate-limited). */
173
+ keyless: boolean;
174
+ /** "direct" (one Cypher read/write) or "flow" (multi-step, workflow runner). */
175
+ mode: "direct" | "flow";
176
+ /** What the method answers, in one line. */
177
+ summary: string;
178
+ /** The method's input names (a flow's inputs, a direct verb's $-parameter, the submit fields). */
179
+ params: string[];
180
+ /** The canonical docs page for this method. */
181
+ docsUrl: string;
182
+ }
183
+
167
184
  /** Result of a keyed graph query. `rows` are objects keyed by column name. */
168
185
  export interface GraphResult {
169
186
  columns: string[];
@@ -220,18 +237,29 @@ export interface GraphFlowResult {
220
237
  }
221
238
 
222
239
  /**
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`.
240
+ * The graph namespace: one typed method per catalog entry (14 direct Cypher reads +
241
+ * 15 workflow-runner flows), plus a raw `query` escape hatch and `recipes()` discovery.
242
+ * Two-tier: the 13 direct read verbs run keyless (no key needed, rate-limited; a key
243
+ * lifts the limit); `query`, the flows, and `submit` need a key (`{ apiKey }` or
244
+ * `WHISPER_API_KEY`). Every method carries its canonical docs URL as `.docs`.
226
245
  */
227
246
  export interface GraphNamespace {
228
247
  /**
229
- * Raw Cypher escape hatch: run arbitrary Cypher with `params` bound server-side as
230
- * $-parameters. Returns the full `{columns,rows,statistics}`.
248
+ * Raw Cypher escape hatch (KEYED): run arbitrary Cypher with `params` bound
249
+ * server-side as $-parameters. Returns the full `{columns,rows,statistics}`.
231
250
  * @see https://www.whisper.security/docs/cypher-api
232
251
  */
233
252
  query(cypher: string, params?: Record<string, unknown>, opts?: GraphOptions): Promise<GraphResult>;
234
253
 
254
+ /**
255
+ * Discover the whole catalog: every graph method with `{ method, keyless, mode,
256
+ * summary, params, docsUrl }`. Baked from the Whisper query catalog at generation
257
+ * time - no key, no network. `keyless: true` marks the direct read verbs that run
258
+ * with no key at all.
259
+ * @see https://www.whisper.security/docs/recipes
260
+ */
261
+ recipes(): GraphRecipe[];
262
+
235
263
  // BEGIN GENERATED: graph catalog (scripts/gen-graph.mjs) - do not edit by hand
236
264
  /**
237
265
  * Assess how resilient a country's core DNS is if it were cut off from the world.
@@ -437,7 +465,9 @@ export interface GraphNamespace {
437
465
  * Resolves a host/IP to its canonical vendor, category and operator roles (DNS_OPERATOR / CDN
438
466
  * / ORIGIN_AS / MAIL_RECEIVER) with a confidence band.
439
467
  *
440
- * KEYED. Cypher: CALL whisper.identify([$v]) YIELD host, vendor_id, canonical_name, category, roles, host_class, band
468
+ * KEYLESS direct read: runs with NO key at all (a rate-limited taste, real
469
+ * answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
470
+ * Cypher: CALL whisper.identify([$v]) YIELD host, vendor_id, canonical_name, category, roles, host_class, band
441
471
  * Returns columns: host, vendor_id, canonical_name, category, roles, host_class, band.
442
472
  * @see https://www.whisper.security/docs/whisper-graph/procedures/identify
443
473
  */
@@ -449,7 +479,9 @@ export interface GraphNamespace {
449
479
  * Returns a posture label + severity band + sub-labels + coverage + evidence for an indicator;
450
480
  * benign-allowlisted vs malicious-evidenced, never a bare score.
451
481
  *
452
- * KEYED. Cypher: CALL whisper.assess([$v]) YIELD host, label, band, sub_labels, coverage, evidence
482
+ * KEYLESS direct read: runs with NO key at all (a rate-limited taste, real
483
+ * answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
484
+ * Cypher: CALL whisper.assess([$v]) YIELD host, label, band, sub_labels, coverage, evidence
453
485
  * Returns columns: host, label, band, sub_labels, coverage, evidence.
454
486
  * @see https://www.whisper.security/docs/whisper-graph/procedures
455
487
  */
@@ -461,7 +493,9 @@ export interface GraphNamespace {
461
493
  * Enumerates permutations (omission, bitsquatting, ...) of a domain, flags which exist, with a
462
494
  * per-variant confidence.
463
495
  *
464
- * KEYED. Cypher: CALL whisper.variants($v) YIELD variant, method, exists, confidence
496
+ * KEYLESS direct read: runs with NO key at all (a rate-limited taste, real
497
+ * answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
498
+ * Cypher: CALL whisper.variants($v) YIELD variant, method, exists, confidence
465
499
  * Returns columns: variant, method, exists, confidence.
466
500
  * @see https://www.whisper.security/docs/whisper-graph/procedures/variants
467
501
  */
@@ -474,7 +508,9 @@ export interface GraphNamespace {
474
508
  * ORIGIN_AS) and confidence, plus siblings and coverage - explains WHY a host maps to a
475
509
  * vendor.
476
510
  *
477
- * KEYED. Cypher: CALL whisper.walk($v) YIELD coverage, host, nearest_known_vendors, no_atlas_match, siblings
511
+ * KEYLESS direct read: runs with NO key at all (a rate-limited taste, real
512
+ * answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
513
+ * Cypher: CALL whisper.walk($v) YIELD coverage, host, nearest_known_vendors, no_atlas_match, siblings
478
514
  * Returns columns: coverage, host, nearest_known_vendors, no_atlas_match, siblings.
479
515
  * @see https://www.whisper.security/docs/whisper-graph/procedures
480
516
  */
@@ -487,7 +523,9 @@ export interface GraphNamespace {
487
523
  * weight, firstSeen/lastSeen). Two live forms: bare CALL explain($v) (full columns) and CALL
488
524
  * whisper.explain($v) (restricted YIELD).
489
525
  *
490
- * KEYED. Cypher: CALL whisper.explain($v) YIELD indicator, score, level, explanation, sources
526
+ * KEYLESS direct read: runs with NO key at all (a rate-limited taste, real
527
+ * answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
528
+ * Cypher: CALL whisper.explain($v) YIELD indicator, score, level, explanation, sources
491
529
  * Returns columns: indicator, score, level, explanation, sources.
492
530
  * @see https://www.whisper.security/docs/whisper-graph/procedures/explain
493
531
  */
@@ -499,7 +537,9 @@ export interface GraphNamespace {
499
537
  * PSL-correct eTLD+1: www.foo.co.uk -> foo.co.uk. The right way to group hosts by the thing
500
538
  * someone actually registered.
501
539
  *
502
- * KEYED. Cypher: CALL whisper.psl.tldPlusOne($v) YIELD apex
540
+ * KEYLESS direct read: runs with NO key at all (a rate-limited taste, real
541
+ * answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
542
+ * Cypher: CALL whisper.psl.tldPlusOne($v) YIELD apex
503
543
  * Returns columns: apex.
504
544
  * @see https://www.whisper.security/docs/whisper-graph/procedures/helpers
505
545
  */
@@ -511,7 +551,9 @@ export interface GraphNamespace {
511
551
  * For a PSL private-section suffix, returns the submitting org/login and evidence kind +
512
552
  * confidence; found=false for ordinary registrable domains.
513
553
  *
514
- * KEYED. Cypher: CALL whisper.psl.affiliation($v) YIELD found, suffix, submitterOrg, submitterLogin, evidenceKind, confidence
554
+ * KEYLESS direct read: runs with NO key at all (a rate-limited taste, real
555
+ * answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
556
+ * Cypher: CALL whisper.psl.affiliation($v) YIELD found, suffix, submitterOrg, submitterLogin, evidenceKind, confidence
515
557
  * Returns columns: found, suffix, submitterOrg, submitterLogin, evidenceKind, confidence.
516
558
  * @see https://www.whisper.security/docs/whisper-graph/procedures/helpers
517
559
  */
@@ -523,7 +565,9 @@ export interface GraphNamespace {
523
565
  * Candidate origin IPs behind a CDN with confidence, the methods that found them (e.g.
524
566
  * links_to), and the hosting ASN/name - de-cloaks to the real server.
525
567
  *
526
- * KEYED. Cypher: CALL whisper.origins($v) YIELD ip, confidence, methods, asn, asnName, kind
568
+ * KEYLESS direct read: runs with NO key at all (a rate-limited taste, real
569
+ * answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
570
+ * Cypher: CALL whisper.origins($v) YIELD ip, confidence, methods, asn, asnName, kind
527
571
  * Returns columns: ip, confidence, methods, asn, asnName, kind.
528
572
  * @see https://www.whisper.security/docs/whisper-graph/procedures/origins
529
573
  */
@@ -535,7 +579,9 @@ export interface GraphNamespace {
535
579
  * Every observed WHOIS snapshot over time: create/update/expiry, registrar, registrant,
536
580
  * country, nameservers - the ownership/registration story.
537
581
  *
538
- * KEYED. Cypher: CALL whisper.history($v)
582
+ * KEYLESS direct read: runs with NO key at all (a rate-limited taste, real
583
+ * answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
584
+ * Cypher: CALL whisper.history($v)
539
585
  * Returns columns: indicator, type, queryTime, createDate, updateDate, expiryDate, registrar, registrant, country, nameServers, cached.
540
586
  * @see https://www.whisper.security/docs/whisper-graph/procedures/history
541
587
  */
@@ -547,7 +593,9 @@ export interface GraphNamespace {
547
593
  * The registration-record projection of the history timeline: create/update/expiry, registrar,
548
594
  * registrant, country, nameservers per snapshot.
549
595
  *
550
- * KEYED. Cypher: CALL whisper.history.whois($v) YIELD queryTime, createDate, updateDate, expiryDate, registrar, registrant, country, nameServers
596
+ * KEYLESS direct read: runs with NO key at all (a rate-limited taste, real
597
+ * answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
598
+ * Cypher: CALL whisper.history.whois($v) YIELD queryTime, createDate, updateDate, expiryDate, registrar, registrant, country, nameServers
551
599
  * Returns columns: queryTime, createDate, updateDate, expiryDate, registrar, registrant, country, nameServers.
552
600
  * @see https://www.whisper.security/docs/whisper-graph/procedures/history
553
601
  */
@@ -560,7 +608,9 @@ export interface GraphNamespace {
560
608
  * STRING as-set name, not an integer ASN. (Membership is sparsely populated in the current
561
609
  * graph.)
562
610
  *
563
- * KEYED. Cypher: CALL whisper.asSet($v) YIELD asSetName, memberAsn, sourceRir
611
+ * KEYLESS direct read: runs with NO key at all (a rate-limited taste, real
612
+ * answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
613
+ * Cypher: CALL whisper.asSet($v) YIELD asSetName, memberAsn, sourceRir
564
614
  * Returns columns: asSetName, memberAsn, sourceRir.
565
615
  * @see https://www.whisper.security/docs/whisper-graph/procedures
566
616
  */
@@ -572,7 +622,9 @@ export interface GraphNamespace {
572
622
  * Live Tor exit-node check: found + fingerprint + exit-address count + source + ingest time
573
623
  * for an IPv4.
574
624
  *
575
- * KEYED. Cypher: CALL whisper.lookupTorRelay($v) YIELD indicator, found, fingerprint, exitAddressCount, source, ingestedAt
625
+ * KEYLESS direct read: runs with NO key at all (a rate-limited taste, real
626
+ * answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
627
+ * Cypher: CALL whisper.lookupTorRelay($v) YIELD indicator, found, fingerprint, exitAddressCount, source, ingestedAt
576
628
  * Returns columns: indicator, found, fingerprint, exitAddressCount, source, ingestedAt.
577
629
  * @see https://www.whisper.security/docs/whisper-graph/procedures/helpers
578
630
  */
@@ -584,7 +636,9 @@ export interface GraphNamespace {
584
636
  * The self-describing schema: node/relationship types with counts, descriptions, examples,
585
637
  * source/target labels and query best-practices - the map of what the graph holds.
586
638
  *
587
- * KEYED. Cypher: CALL db.schema()
639
+ * KEYLESS direct read: runs with NO key at all (a rate-limited taste, real
640
+ * answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
641
+ * Cypher: CALL db.schema()
588
642
  * Returns columns: type, name, count, description, example, sourceLabels, targetLabels, fastPatterns, slowPatterns, bestPractices.
589
643
  * @see https://www.whisper.security/docs/whisper-graph/schema
590
644
  */
@@ -605,5 +659,5 @@ export interface GraphNamespace {
605
659
  // END GENERATED: graph catalog
606
660
  }
607
661
 
608
- /** The keyed graph namespace. `import { graph } from "whisper-id"`. */
662
+ /** The Whisper security graph namespace (two-tier). `import { graph } from "whisper-id"`. */
609
663
  export const graph: GraphNamespace;
package/index.js CHANGED
@@ -1,8 +1,10 @@
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.
4
- // A thin, dependency-free wrapper over the `whisper` CLI (https://whisper.online): the CLI
5
- // holds the auth, control plane, and egress tunnel; this gives you register/egress/verify/ip.
3
+ // whisper-id - the Whisper security graph, a routable IPv6 identity, and safe egress for
4
+ // any Node agent. Dependency-free and two-tier (Postel): the graph's direct read verbs and
5
+ // the identity checks run KEYLESS over plain fetch() (graph.assess("8.8.8.8") needs no key
6
+ // at all); the control plane, raw Cypher, and the flows unlock with WHISPER_API_KEY; only
7
+ // register/egress/ip lean on the `whisper` CLI (https://whisper.online).
6
8
  import { execFile } from "node:child_process";
7
9
  import { promisify } from "node:util";
8
10
 
@@ -313,7 +315,7 @@ async function callControl(op, args, opts = {}) {
313
315
  if (!key) {
314
316
  throw new WhisperError(
315
317
  `\`${op}\` needs a Whisper API key - pass { apiKey } or set WHISPER_API_KEY. ` +
316
- "(verify/verifyDetails/rdap are keyless and need no key.)"
318
+ "(verify/verifyDetails/rdap and the graph's direct read verbs are keyless and need no key.)"
317
319
  );
318
320
  }
319
321
  const url = controlUrl();
@@ -455,23 +457,27 @@ export async function agent(sel = {}, maybeOpts) {
455
457
  return recs.length ? recs[0] : null;
456
458
  }
457
459
 
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().
460
+ // ── Graph (the Whisper Security graph: Cypher intelligence, two-tier) ────────────────
461
+ // The intelligence pillar, Postel two-tier with both halves honest: the 13 DIRECT READ
462
+ // verbs (identify, assess, variants, walk, explain, pslTldplusone, pslAffiliation,
463
+ // origins, history, historyWhois, asset, lookupTorRelay, dbSchema) run KEYLESS - no key
464
+ // at all, a rate-limited taste (~100/window) with real answers; a key (opts.apiKey or
465
+ // WHISPER_API_KEY) lifts the limit. Raw Cypher (graph.query), the 15 multi-step flows,
466
+ // and the submit write channel stay KEYED. Everything POSTs to the SAME endpoint the
467
+ // control plane uses (https://graph.whisper.security/api/query), reusing resolveKey() so
468
+ // there is one auth path, not two: a present key rides ONLY in the X-API-Key header, an
469
+ // absent one sends NO auth header at all. It does NOT go through the whisper.agents()
470
+ // control wrapper: the graph contract is different (body {query,parameters}; reply
471
+ // {columns,rows,statistics} where rows are already column-keyed objects), so it has its
472
+ // own choke point, callGraph().
467
473
  //
468
474
  // Source of truth for the 29 methods is the Whisper query catalog (catalog.json +
469
475
  // sdk-methods.json): the GRAPH_METHODS table below is GENERATED from it by
470
476
  // scripts/gen-graph.mjs (`npm run gen`), so this SDK cannot drift from the catalog. The
471
477
  // 14 `direct` entries run their catalog Cypher against /api/query; the 15 `flow` entries
472
478
  // 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.
479
+ // Every method carries its canonical docs URL as `.docs`; graph.recipes() lists the
480
+ // whole catalog (keyless, no network). graph.query() is the raw escape hatch.
475
481
 
476
482
  function graphUrl() {
477
483
  // Defaults to the control endpoint, which already IS the graph endpoint. Override for tests
@@ -497,32 +503,37 @@ function normalizeGraph(body) {
497
503
  return { columns, rows, statistics: body.statistics || null };
498
504
  }
499
505
 
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) {
506
+ // The one place that talks to the graph. POSTs {query,parameters}, and on an HTTP>=400 or
507
+ // an RFC-7807 problem body throws a WhisperError carrying the server's helpful detail
508
+ // (plus .status/.retryAfter). Two-tier auth (Postel: liberal in what we accept): a keyless
509
+ // method (`keyless: true`) NEVER throws for a missing key - with a key it runs unlimited,
510
+ // without one the X-API-Key header is omitted entirely and the server serves the
511
+ // rate-limited keyless taste. A keyed method with no key fails fast with a helpful error.
512
+ // The key is only ever sent in X-API-Key: never logged, never on argv, never returned.
513
+ async function callGraph(cypher, params, opts = {}, methodName = "query", keyless = false) {
514
+ const key = resolveKey(opts) || "";
515
+ if (!key && !keyless) {
507
516
  throw new WhisperError(
508
517
  `\`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.)"
518
+ "(The direct read verbs - assess, identify, explain, origins, and friends - run keyless " +
519
+ "with no key at all; raw Cypher, the flows, and submit are keyed. See graph.recipes().)"
510
520
  );
511
521
  }
512
522
  const url = graphUrl();
513
523
  const timeout = opts.timeout || 60000;
514
524
  const ctrl = new AbortController();
515
525
  const t = setTimeout(() => ctrl.abort(), timeout);
526
+ const headers = {
527
+ "Content-Type": "application/json",
528
+ Accept: "application/json",
529
+ "User-Agent": UA,
530
+ };
531
+ if (key) headers["X-API-Key"] = key; // present => keyed (unlimited); absent => keyless taste
516
532
  let res;
517
533
  try {
518
534
  res = await fetch(url, {
519
535
  method: "POST",
520
- headers: {
521
- "Content-Type": "application/json",
522
- Accept: "application/json",
523
- "X-API-Key": key,
524
- "User-Agent": UA,
525
- },
536
+ headers,
526
537
  body: JSON.stringify({ query: cypher, parameters: params || {} }),
527
538
  signal: ctrl.signal,
528
539
  });
@@ -555,39 +566,41 @@ async function callGraph(cypher, params, opts = {}, methodName = "query") {
555
566
  // The Whisper query catalog, one row per entry (14 direct + 15 flow), in catalog order.
556
567
  // A direct row carries its catalog Cypher (input bound as a $-parameter); a flow row
557
568
  // 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.
569
+ // the map-call row (submit) enumerates its fields. `keyless` marks the 13 direct read
570
+ // verbs that run with no key at all (two-tier, straight from the catalog's requiresKey);
571
+ // `summary` feeds recipes() discovery; `docs` is the canonical docs URL.
559
572
  const GRAPH_DOCS_BASE = "https://www.whisper.security";
560
573
  const GRAPH_FLOW_RUN_URL = "https://console.whisper.security/api/gallery/run";
561
574
  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" },
575
+ { name: "anycastDnsRootSovereignty", mode: "flow", keyless: false, slug: "anycast-dns-root-sovereignty", primary: "country", inputs: ["country"], params: ["instanceType"], summary: "Assess how resilient a country's core DNS is if it were cut off from the world.", docs: GRAPH_DOCS_BASE + "/docs/recipes/compliance" },
576
+ { name: "attackPath", mode: "flow", keyless: false, slug: "attack-path", primary: "value", inputs: ["value", "other"], params: ["level"], summary: "Find the choke points an attacker would target - and how any two things connect.", docs: GRAPH_DOCS_BASE + "/docs/recipes/attack-path" },
577
+ { name: "attackSurface", mode: "flow", keyless: false, slug: "attack-surface", primary: "domain", inputs: ["domain"], params: ["level"], summary: "Map everything about a domain that's exposed to the outside world, scored for risk.", docs: GRAPH_DOCS_BASE + "/docs/recipes/pentest-recon" },
578
+ { name: "bgpHijackExposure", mode: "flow", keyless: false, slug: "bgp-hijack-exposure", primary: "value", inputs: ["value"], params: [], summary: "Grade a network's routing security and trace conflicts to the domains they'd expose.", docs: GRAPH_DOCS_BASE + "/docs/recipes/bgp-routing" },
579
+ { name: "blastRadius", mode: "flow", keyless: false, slug: "blast-radius", primary: "indicator", inputs: ["indicator"], params: ["depth"], summary: "Pick one asset and see what would break if it failed - and what it depends on in turn.", docs: GRAPH_DOCS_BASE + "/docs/recipes/soc" },
580
+ { name: "buildTakedownEvidencePackage", mode: "flow", keyless: false, slug: "build-takedown-evidence-package", primary: "domain", inputs: ["domain"], params: [], summary: "Assemble a ready-to-submit dossier for taking down a scam or phishing domain.", docs: GRAPH_DOCS_BASE + "/docs/recipes/threat-intel" },
581
+ { name: "discoverAiAgentInfrastructure", mode: "flow", keyless: false, slug: "discover-ai-agent-infrastructure", primary: "value", inputs: ["value"], params: [], summary: "Map an organisation's externally visible AI and agent endpoints from the outside.", docs: GRAPH_DOCS_BASE + "/docs/recipes/pentest-recon" },
582
+ { name: "indicator", mode: "flow", keyless: false, slug: "indicator", primary: "indicator", inputs: ["indicator"], params: [], summary: "Investigate one suspicious domain, IP, or network in depth and get a clear picture of the threat and everything connected to it.", docs: GRAPH_DOCS_BASE + "/docs/recipes/soc" },
583
+ { name: "indicatorEnrichment", mode: "flow", keyless: false, slug: "indicator-enrichment", primary: "value", inputs: ["value"], params: [], summary: "Turn one domain or IP into a full context card - owner, hosting, mail, location, reputation at a glance.", docs: GRAPH_DOCS_BASE + "/docs/recipes/dns-email" },
584
+ { name: "infrastructureMapping", mode: "flow", keyless: false, slug: "infrastructure-mapping", primary: "value", inputs: ["value"], params: ["level"], summary: "Trace one indicator to its true owner and full estate, even behind privacy screens and CDNs.", docs: GRAPH_DOCS_BASE + "/docs/recipes/compliance" },
585
+ { name: "mapSupplyChainConcentration", mode: "flow", keyless: false, slug: "map-supply-chain-concentration", primary: "domain", inputs: ["domain"], params: [], summary: "Grade an organisation for over-reliance on single providers, regions, or facilities.", docs: GRAPH_DOCS_BASE + "/docs/recipes/compliance" },
586
+ { name: "nameserverHijackDnsConsistency", mode: "flow", keyless: false, slug: "nameserver-hijack-dns-consistency", primary: "value", inputs: ["value"], params: [], summary: "Check a domain's name servers for the misconfigurations that enable DNS hijacking.", docs: GRAPH_DOCS_BASE + "/docs/recipes/dns-email" },
587
+ { name: "routeHealth", mode: "flow", keyless: false, slug: "route-health", primary: "target", inputs: ["target"], params: [], summary: "Profile a network or address block into a full routing and reachability health card.", docs: GRAPH_DOCS_BASE + "/docs/recipes/bgp-routing" },
588
+ { name: "subdomainTakeover", mode: "flow", keyless: false, slug: "subdomain-takeover", primary: "value", inputs: ["value"], params: [], summary: "Find subdomains that point at abandoned services an attacker could claim.", docs: GRAPH_DOCS_BASE + "/docs/recipes/pentest-recon" },
589
+ { name: "typosquat", mode: "flow", keyless: false, slug: "typosquat", primary: "domain", inputs: ["domain"], params: [], summary: "Find registered look-alikes of your brand and check which ones are dangerous.", docs: GRAPH_DOCS_BASE + "/docs/recipes/brand-protection" },
590
+ { name: "identify", mode: "direct", keyless: true, input: "v", cypher: "CALL whisper.identify([$v]) YIELD host, vendor_id, canonical_name, category, roles, host_class, band", summary: "Name the vendor and operator role behind a host or IP in one call.", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/identify" },
591
+ { name: "assess", mode: "direct", keyless: true, input: "v", cypher: "CALL whisper.assess([$v]) YIELD host, label, band, sub_labels, coverage, evidence", summary: "Get a labelled threat posture for a host or IP - malicious, benign, or unknown.", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures" },
592
+ { name: "variants", mode: "direct", keyless: true, input: "v", cypher: "CALL whisper.variants($v) YIELD variant, method, exists, confidence", summary: "Generate look-alike domain variants of a brand and see which are registered.", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/variants" },
593
+ { name: "walk", mode: "direct", keyless: true, input: "v", cypher: "CALL whisper.walk($v) YIELD coverage, host, nearest_known_vendors, no_atlas_match, siblings", summary: "Walk the graph to the nearest known vendors behind a host, with the channel and confidence.", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures" },
594
+ { name: "explain", mode: "direct", keyless: true, input: "v", cypher: "CALL whisper.explain($v) YIELD indicator, score, level, explanation, sources", summary: "Score an indicator against the threat feeds and explain exactly why.", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/explain" },
595
+ { name: "pslTldplusone", mode: "direct", keyless: true, input: "v", cypher: "CALL whisper.psl.tldPlusOne($v) YIELD apex", summary: "Reduce any hostname to its registrable apex (eTLD+1) via the Public Suffix List.", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/helpers" },
596
+ { name: "pslAffiliation", mode: "direct", keyless: true, input: "v", cypher: "CALL whisper.psl.affiliation($v) YIELD found, suffix, submitterOrg, submitterLogin, evidenceKind, confidence", summary: "Check whether a domain is a PSL private-section suffix and who submitted it.", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/helpers" },
597
+ { name: "origins", mode: "direct", keyless: true, input: "v", cypher: "CALL whisper.origins($v) YIELD ip, confidence, methods, asn, asnName, kind", summary: "Find the real origin IPs behind a CDN-fronted domain, ranked by confidence.", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/origins" },
598
+ { name: "history", mode: "direct", keyless: true, input: "v", cypher: "CALL whisper.history($v)", summary: "Get the full historical WHOIS timeline for a domain.", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/history" },
599
+ { name: "historyWhois", mode: "direct", keyless: true, input: "v", cypher: "CALL whisper.history.whois($v) YIELD queryTime, createDate, updateDate, expiryDate, registrar, registrant, country, nameServers", summary: "Get the WHOIS-only historical timeline for a domain.", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/history" },
600
+ { name: "asset", mode: "direct", keyless: true, input: "v", cypher: "CALL whisper.asSet($v) YIELD asSetName, memberAsn, sourceRir", summary: "List the member ASNs of an AS-SET macro.", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures" },
601
+ { name: "lookupTorRelay", mode: "direct", keyless: true, input: "v", cypher: "CALL whisper.lookupTorRelay($v) YIELD indicator, found, fingerprint, exitAddressCount, source, ingestedAt", summary: "Check whether an IP is a known Tor exit relay.", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/helpers" },
602
+ { name: "dbSchema", mode: "direct", keyless: true, input: null, cypher: "CALL db.schema()", summary: "List every node and relationship type in the graph with counts and examples.", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/schema" },
603
+ { name: "submit", mode: "direct", keyless: false, 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"], summary: "Contribute an indicator observation or feedback back into the graph (requires an API key).", docs: GRAPH_DOCS_BASE + "/docs/cypher-api" },
591
604
  ];
592
605
  // END GENERATED: graph catalog
593
606
 
@@ -616,9 +629,28 @@ async function graphQuery(cypher, params, opts = {}) {
616
629
  return callGraph(String(cypher), params || {}, opts, "query");
617
630
  }
618
631
 
632
+ /**
633
+ * Discover the whole catalog: every graph method with `{ method, keyless, mode, summary,
634
+ * params, docsUrl }` - baked from the Whisper query catalog at generation time, so it
635
+ * needs no key and no network. `keyless: true` marks the direct read verbs that run with
636
+ * no key at all; `params` are the method's input names (a flow's inputs, a direct verb's
637
+ * $-parameter, the submit fields). Call any method by name, or check its docsUrl.
638
+ * @see https://www.whisper.security/docs/recipes
639
+ */
640
+ function graphRecipes() {
641
+ return GRAPH_METHODS.map((m) => ({
642
+ method: m.name,
643
+ keyless: !!m.keyless,
644
+ mode: m.mode,
645
+ summary: m.summary,
646
+ params: m.mode === "flow" ? m.inputs.slice() : m.fields ? m.fields.map(([, param]) => param) : m.input ? [m.input] : [],
647
+ docsUrl: m.docs,
648
+ }));
649
+ }
650
+
619
651
  // ── Flow runner (gallery/run, Server-Sent Events) ───────────────────────────────────
620
652
  // 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
653
+ // POST {slug, value, paramValues} to the gallery/run endpoint (keyed, X-API-Key) and consume
622
654
  // the SSE stream. Each `step` event carries a step's bound Cypher + rows; the terminal
623
655
  // `complete` event carries the aggregate context. callFlow() collects the whole stream and
624
656
  // resolves to { slug, steps, complete, events } - one call, the full flow.
@@ -652,6 +684,36 @@ function flowBody(m, input) {
652
684
  return { inputs, params };
653
685
  }
654
686
 
687
+ // Map the routed { inputs, params } onto the runner's wire contract {slug, value, paramValues}:
688
+ // `value` is the ONE primary entity (a host / IP / ASN), or `values` for a bulk list; the FIRST
689
+ // input becomes the anchor `value`, and every other input plus every tuning knob rides in
690
+ // `paramValues`. We send ONLY the keys the runner reads - an `inputs`/`params` map is silently
691
+ // ignored, so the flow would fall back to its default anchor. A nullish input is skipped so the
692
+ // flow's own default applies (Postel: sensible defaults, liberal in what we accept).
693
+ function flowWireBody(m, input) {
694
+ const { inputs, params } = flowBody(m, input);
695
+ const paramValues = {};
696
+ let value;
697
+ let values;
698
+ let first = true;
699
+ for (const [name, val] of Object.entries(inputs)) {
700
+ if (val === undefined || val === null) continue;
701
+ if (first) {
702
+ if (Array.isArray(val)) values = val.map((x) => String(x));
703
+ else value = String(val);
704
+ first = false;
705
+ } else {
706
+ paramValues[name] = val;
707
+ }
708
+ }
709
+ for (const [k, v] of Object.entries(params)) paramValues[k] = v;
710
+ const body = { slug: m.slug };
711
+ if (value !== undefined) body.value = value;
712
+ if (values !== undefined) body.values = values;
713
+ if (Object.keys(paramValues).length > 0) body.paramValues = paramValues;
714
+ return body;
715
+ }
716
+
655
717
  // Parse one SSE record ("event: x\ndata: {...}") into { event, data }; data is JSON-decoded
656
718
  // when possible, else kept as the raw string. Never throws on shape.
657
719
  function parseSseRecord(record) {
@@ -671,7 +733,7 @@ function parseSseRecord(record) {
671
733
  }
672
734
 
673
735
  // 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
736
+ // POSTs {slug, value, paramValues}, consumes the SSE stream, and resolves to the collected
675
737
  // { slug, steps, complete, events }. Flows are multi-step, so the default timeout is 5
676
738
  // minutes (override with opts.timeout). On an HTTP error, an `error` event, or a stream
677
739
  // that ends with no result at all, throws a WhisperError with the server's clear detail.
@@ -680,10 +742,10 @@ async function callFlow(m, input, opts = {}) {
680
742
  if (!key) {
681
743
  throw new WhisperError(
682
744
  `\`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.)"
745
+ "(The graph's direct read verbs run keyless; flows run keyed on the workflow runner.)"
684
746
  );
685
747
  }
686
- const { inputs, params } = flowBody(m, input);
748
+ const body = flowWireBody(m, input);
687
749
  const url = flowRunUrl();
688
750
  const timeout = opts.timeout || 300000;
689
751
  const ctrl = new AbortController();
@@ -699,7 +761,7 @@ async function callFlow(m, input, opts = {}) {
699
761
  "X-API-Key": key,
700
762
  "User-Agent": UA,
701
763
  },
702
- body: JSON.stringify({ slug: m.slug, inputs, params }),
764
+ body: JSON.stringify(body),
703
765
  signal: ctrl.signal,
704
766
  });
705
767
  } catch (err) {
@@ -810,12 +872,13 @@ function makeMapCall(m) {
810
872
  };
811
873
  }
812
874
 
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 };
875
+ // Build the namespace from the generated catalog table: query + recipes + one method per
876
+ // entry. Direct methods run their catalog Cypher (the 13 keyless reads never require a
877
+ // key); the map-call write builds its parameter map; flow methods execute on the workflow
878
+ // runner. Every method carries its canonical docs URL as `.docs` (docsBase + docPath).
879
+ const graph = { query: graphQuery, recipes: graphRecipes };
818
880
  graph.query.docs = GRAPH_DOCS_BASE + "/docs/cypher-api";
881
+ graph.recipes.docs = GRAPH_DOCS_BASE + "/docs/recipes";
819
882
 
820
883
  for (const m of GRAPH_METHODS) {
821
884
  if (m.mode === "flow") {
@@ -823,9 +886,9 @@ for (const m of GRAPH_METHODS) {
823
886
  } else if (m.fields) {
824
887
  graph[m.name] = makeMapCall(m);
825
888
  } else if (m.input) {
826
- graph[m.name] = async (value, opts = {}) => callGraph(m.cypher, { [m.input]: graphValue(m.name, value) }, opts, m.name);
889
+ graph[m.name] = async (value, opts = {}) => callGraph(m.cypher, { [m.input]: graphValue(m.name, value) }, opts, m.name, m.keyless);
827
890
  } else {
828
- graph[m.name] = async (opts = {}) => callGraph(m.cypher, {}, opts, m.name);
891
+ graph[m.name] = async (opts = {}) => callGraph(m.cypher, {}, opts, m.name, m.keyless);
829
892
  }
830
893
  graph[m.name].docs = m.docs;
831
894
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "whisper-id",
3
- "version": "0.5.0",
4
- "description": "A routable Whisper IPv6 identity and safe egress for any Node agent.",
3
+ "version": "0.6.0",
4
+ "description": "Give any Node agent the Whisper security graph (keyless reads + keyed Cypher), a real routable Whisper IPv6 identity, safe egress, and the full control plane.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {