whisper-id 0.3.0 → 0.4.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 +62 -17
  2. package/index.d.ts +102 -4
  3. package/index.js +222 -24
  4. package/package.json +1 -1
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,55 @@ 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) run via the Whisper workflow runner, not a single Cypher
113
+ call, so they are not wired into this SDK yet: calling one rejects with a clear `WhisperError`
114
+ (carrying `.runVia`) telling you which workflow to run. Use `graph.query` for raw Cypher meanwhile.
115
+
71
116
  ### Egress + CLI-backed
72
117
 
73
118
  | Call | Does |
74
119
  |------|------|
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 }` |
120
+ | `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
121
  | `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
122
  | `withEgress([opts], fn)` | Run `fn(egress)` with egress up, restoring the environment afterwards. |
78
123
  | `ip()` | Your current egress IP via the CLI, proving it's your `/128`. → `string` |
@@ -99,7 +144,7 @@ transparently inside `withEgress`.
99
144
  ## Requirements
100
145
 
101
146
  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
147
+ global `fetch`), with no CLI and nothing to install. For the control plane, set `WHISPER_API_KEY` (or
103
148
  pass `{ apiKey }`).
104
149
 
105
150
  Only **egress** and the CLI-backed calls (`register`, `egress`, `withEgress`, `ip`) need the
@@ -109,12 +154,12 @@ Only **egress** and the CLI-backed calls (`register`, `egress`, `withEgress`, `i
109
154
  curl get.whisper.online | sh
110
155
  ```
111
156
 
112
- (`$WHISPER_BIN` overrides the CLI path; `$WHISPER_CONTROL_URL` / `$WHISPER_RDAP_URL` override the
113
- endpoints.)
157
+ (`$WHISPER_BIN` overrides the CLI path; `$WHISPER_CONTROL_URL` / `$WHISPER_GRAPH_URL` /
158
+ `$WHISPER_RDAP_URL` override the endpoints.)
114
159
 
115
160
  ## Links
116
161
 
117
- - Site — https://whisper.online
118
- - CLI — https://github.com/whisper-sec/whisper-cli
162
+ - Site: https://whisper.online
163
+ - CLI: https://github.com/whisper-sec/whisper-cli
119
164
 
120
165
  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,101 @@ 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 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). It POSTs raw Cypher to
158
+ // the same endpoint the control plane uses, with the key only in `X-API-Key`. `direct`
159
+ // methods run their Cypher and resolve to a `GraphResult`; the 15 `flow` methods are
160
+ // workflow-runner reads not wired here, so calling one rejects with a clear `WhisperError`.
161
+
162
+ /** Per-call options for the keyed graph namespace (same shape as the control plane). */
163
+ export interface GraphOptions extends ControlOptions {}
164
+
165
+ /** Result of a keyed graph query. `rows` are objects keyed by column name. */
166
+ export interface GraphResult {
167
+ columns: string[];
168
+ rows: Record<string, unknown>[];
169
+ statistics?: { rowCount?: number; executionTimeMs?: number; [k: string]: unknown } | null;
170
+ }
171
+
172
+ /** A `graph.submit` payload: an indicator observation or a piece of feedback. */
173
+ export interface GraphSubmission {
174
+ kind: "indicator" | "feedback" | string;
175
+ [k: string]: unknown;
176
+ }
177
+
178
+ /**
179
+ * The keyed graph namespace: typed Cypher methods over the Whisper Security graph, plus a
180
+ * raw `query` escape hatch. Every method needs a key (`{ apiKey }` or `WHISPER_API_KEY`).
181
+ */
182
+ export interface GraphNamespace {
183
+ /** Raw Cypher escape hatch. Returns the full `{columns,rows,statistics}`. */
184
+ query(cypher: string, params?: Record<string, unknown>, opts?: GraphOptions): Promise<GraphResult>;
185
+
186
+ /** Name the vendor and operator role behind a host or IP in one call. */
187
+ identify(value: string, opts?: GraphOptions): Promise<GraphResult>;
188
+ /** Labelled threat posture for a host or IP: malicious, benign, or unknown. */
189
+ assess(value: string, opts?: GraphOptions): Promise<GraphResult>;
190
+ /** Generate look-alike domain variants of a brand and see which are registered. */
191
+ variants(value: string, opts?: GraphOptions): Promise<GraphResult>;
192
+ /** Walk the graph to the nearest known vendors behind a host, with channel and confidence. */
193
+ walk(value: string, opts?: GraphOptions): Promise<GraphResult>;
194
+ /** Score an indicator against the threat feeds and explain exactly why. */
195
+ explain(value: string, opts?: GraphOptions): Promise<GraphResult>;
196
+ /** Reduce any hostname to its registrable apex (eTLD+1) via the Public Suffix List. */
197
+ pslTldplusone(value: string, opts?: GraphOptions): Promise<GraphResult>;
198
+ /** Check whether a domain is a PSL private-section suffix and who submitted it. */
199
+ pslAffiliation(value: string, opts?: GraphOptions): Promise<GraphResult>;
200
+ /** Find the real origin IPs behind a CDN-fronted domain, ranked by confidence. */
201
+ origins(value: string, opts?: GraphOptions): Promise<GraphResult>;
202
+ /** Full historical WHOIS timeline for a domain. */
203
+ history(value: string, opts?: GraphOptions): Promise<GraphResult>;
204
+ /** WHOIS-only historical timeline for a domain. */
205
+ historyWhois(value: string, opts?: GraphOptions): Promise<GraphResult>;
206
+ /** List the member ASNs of an AS-SET macro. */
207
+ asset(value: string, opts?: GraphOptions): Promise<GraphResult>;
208
+ /** Check whether an IP is a known Tor exit relay. */
209
+ lookupTorRelay(value: string, opts?: GraphOptions): Promise<GraphResult>;
210
+ /** List every node and relationship type in the graph with counts and examples. No input. */
211
+ dbSchema(opts?: GraphOptions): Promise<GraphResult>;
212
+ /** Contribute an indicator observation or feedback back into the graph (keyed WRITE). */
213
+ submit(payload: GraphSubmission, opts?: GraphOptions): Promise<GraphResult>;
214
+
215
+ // The 15 `flow` workflow-runner methods. Not wired into this SDK: each rejects with a clear
216
+ // WhisperError (carrying `.runVia`). Run them via the console/agent workflow runner.
217
+ /** Workflow (run_workflow): assess a country's DNS-root sovereignty. Not wired here. */
218
+ anycastDnsRootSovereignty(opts?: GraphOptions): Promise<never>;
219
+ /** Workflow (run_workflow): find the choke points an attacker would target. Not wired here. */
220
+ attackPath(opts?: GraphOptions): Promise<never>;
221
+ /** Workflow (run_workflow): map a domain's externally exposed attack surface. Not wired here. */
222
+ attackSurface(opts?: GraphOptions): Promise<never>;
223
+ /** Workflow (run_workflow): grade a network's routing security. Not wired here. */
224
+ bgpHijackExposure(opts?: GraphOptions): Promise<never>;
225
+ /** Workflow (run_workflow): what breaks if one asset fails, and its dependencies. Not wired here. */
226
+ blastRadius(opts?: GraphOptions): Promise<never>;
227
+ /** Workflow (run_workflow): assemble a takedown evidence dossier. Not wired here. */
228
+ buildTakedownEvidencePackage(opts?: GraphOptions): Promise<never>;
229
+ /** Workflow (run_workflow): map an org's externally visible AI/agent endpoints. Not wired here. */
230
+ discoverAiAgentInfrastructure(opts?: GraphOptions): Promise<never>;
231
+ /** Workflow (run_workflow): investigate one indicator in depth. Not wired here. */
232
+ indicator(opts?: GraphOptions): Promise<never>;
233
+ /** Workflow (run_workflow): turn one domain/IP into a full context card. Not wired here. */
234
+ indicatorEnrichment(opts?: GraphOptions): Promise<never>;
235
+ /** Workflow (run_workflow): trace an indicator to its true owner and estate. Not wired here. */
236
+ infrastructureMapping(opts?: GraphOptions): Promise<never>;
237
+ /** Workflow (run_workflow): grade an org for supply-chain concentration. Not wired here. */
238
+ mapSupplyChainConcentration(opts?: GraphOptions): Promise<never>;
239
+ /** Workflow (run_workflow): check name servers for DNS-hijack misconfigurations. Not wired here. */
240
+ nameserverHijackDnsConsistency(opts?: GraphOptions): Promise<never>;
241
+ /** Workflow (run_workflow): profile a network/block into a routing health card. Not wired here. */
242
+ routeHealth(opts?: GraphOptions): Promise<never>;
243
+ /** Workflow (run_workflow): find subdomains that point at claimable services. Not wired here. */
244
+ subdomainTakeover(opts?: GraphOptions): Promise<never>;
245
+ /** Workflow (run_workflow): find registered look-alikes of your brand. Not wired here. */
246
+ typosquat(opts?: GraphOptions): Promise<never>;
247
+ }
248
+
249
+ /** The keyed graph namespace. `import { graph } from "whisper-id"`. */
250
+ 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,201 @@ 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 tables below are copied from it (keyed by exec.mode). A test
470
+ // asserts the count so the two cannot silently drift. 14 `direct` entries run their catalog
471
+ // Cypher against /api/query; 15 `flow` entries run via the workflow runner (run_workflow),
472
+ // which is NOT a single /api/query Cypher, so they are shipped as clearly-noted stubs and
473
+ // are not wired to an engine here. graph.query() is the raw escape hatch.
474
+
475
+ function graphUrl() {
476
+ // Defaults to the control endpoint, which already IS the graph endpoint. Override for tests
477
+ // or a private deployment. WHISPER_GRAPH_URL wins, then the shared control URL.
478
+ return process.env.WHISPER_GRAPH_URL || controlUrl();
479
+ }
480
+
481
+ // Normalise the /api/query reply into { columns, rows, statistics }. Per the contract rows
482
+ // are OBJECTS keyed by column name; we also accept a positional-array row (aligned to
483
+ // columns) so a liberal server shape still decodes. Never throws on shape.
484
+ function normalizeGraph(body) {
485
+ if (!body || typeof body !== "object") return { columns: [], rows: [], statistics: null };
486
+ const columns = Array.isArray(body.columns) ? body.columns : [];
487
+ const rawRows = Array.isArray(body.rows) ? body.rows : [];
488
+ const rows = rawRows.map((row) => {
489
+ if (Array.isArray(row)) {
490
+ const m = {};
491
+ columns.forEach((c, i) => { if (i < row.length) m[c] = row[i]; });
492
+ return m;
493
+ }
494
+ return row && typeof row === "object" ? row : { value: row };
495
+ });
496
+ return { columns, rows, statistics: body.statistics || null };
497
+ }
498
+
499
+ // The one place that talks to the graph. Requires a key (via the shared resolveKey), POSTs
500
+ // {query,parameters}, and on an HTTP>=400 or an RFC-7807 problem body throws a WhisperError
501
+ // carrying the server's helpful detail (plus .status/.retryAfter). The key is only ever sent
502
+ // in X-API-Key: never logged, never on argv, never returned.
503
+ async function callGraph(cypher, params, opts = {}, methodName = "query") {
504
+ const key = resolveKey(opts);
505
+ if (!key) {
506
+ throw new WhisperError(
507
+ `\`graph.${methodName}\` needs a Whisper API key: pass { apiKey } or set WHISPER_API_KEY. ` +
508
+ "(verify/verifyDetails/rdap are keyless and need no key; graph queries are Cypher, so they do.)"
509
+ );
510
+ }
511
+ const url = graphUrl();
512
+ const timeout = opts.timeout || 60000;
513
+ const ctrl = new AbortController();
514
+ const t = setTimeout(() => ctrl.abort(), timeout);
515
+ let res;
516
+ try {
517
+ res = await fetch(url, {
518
+ method: "POST",
519
+ headers: {
520
+ "Content-Type": "application/json",
521
+ Accept: "application/json",
522
+ "X-API-Key": key,
523
+ "User-Agent": UA,
524
+ },
525
+ body: JSON.stringify({ query: cypher, parameters: params || {} }),
526
+ signal: ctrl.signal,
527
+ });
528
+ } catch (err) {
529
+ throw new WhisperError(`could not reach the Whisper graph at ${url}: ${err && err.message ? err.message : err}`);
530
+ } finally {
531
+ clearTimeout(t);
532
+ }
533
+ let body = null;
534
+ try {
535
+ body = await res.json();
536
+ } catch {
537
+ body = null;
538
+ }
539
+ // A fault is either an HTTP error status or a problem body (has error/detail/title but no
540
+ // result table). A valid table always carries columns/rows, so those are never a fault.
541
+ const hasTable = body && typeof body === "object" && (Array.isArray(body.columns) || Array.isArray(body.rows));
542
+ const problem = body && typeof body === "object" && (body.error || (!hasTable && (body.detail || body.title || body.type)));
543
+ if (res.status >= 400 || problem) {
544
+ const e = (body && (body.error || body)) || {};
545
+ const werr = new WhisperError(e.detail || e.title || e.message || e.type || `graph query returned HTTP ${res.status}`);
546
+ werr.status = e.status || res.status;
547
+ if (e.retry_after != null) werr.retryAfter = e.retry_after;
548
+ throw werr;
549
+ }
550
+ return normalizeGraph(body);
551
+ }
552
+
553
+ // The 14 `direct` catalog entries: id (camelCased) -> its catalog Cypher. Every one binds a
554
+ // single input as $v (paramName `v` in the catalog), except dbSchema (no input) and submit
555
+ // (a write map, handled below). Copied verbatim from whisper-catalog/catalog.json.
556
+ const GRAPH_DIRECT = {
557
+ identify: "CALL whisper.identify([$v]) YIELD host, vendor_id, canonical_name, category, roles, host_class, band",
558
+ assess: "CALL whisper.assess([$v]) YIELD host, label, band, sub_labels, coverage, evidence",
559
+ variants: "CALL whisper.variants($v) YIELD variant, method, exists, confidence",
560
+ walk: "CALL whisper.walk($v) YIELD coverage, host, nearest_known_vendors, no_atlas_match, siblings",
561
+ explain: "CALL whisper.explain($v) YIELD indicator, score, level, explanation, sources",
562
+ pslTldplusone: "CALL whisper.psl.tldPlusOne($v) YIELD apex",
563
+ pslAffiliation: "CALL whisper.psl.affiliation($v) YIELD found, suffix, submitterOrg, submitterLogin, evidenceKind, confidence",
564
+ origins: "CALL whisper.origins($v) YIELD ip, confidence, methods, asn, asnName, kind",
565
+ history: "CALL whisper.history($v)",
566
+ historyWhois: "CALL whisper.history.whois($v) YIELD queryTime, createDate, updateDate, expiryDate, registrar, registrant, country, nameServers",
567
+ asset: "CALL whisper.asSet($v) YIELD asSetName, memberAsn, sourceRir",
568
+ lookupTorRelay: "CALL whisper.lookupTorRelay($v) YIELD indicator, found, fingerprint, exitAddressCount, source, ingestedAt",
569
+ };
570
+
571
+ // The 15 `flow` catalog entries: id (camelCased) -> its runVia slug. These are multi-step
572
+ // read workflows run by the console/agent workflow runner, NOT a single /api/query Cypher,
573
+ // so they are not wired to an engine in this SDK. Copied verbatim from the catalog.
574
+ const GRAPH_FLOW = {
575
+ anycastDnsRootSovereignty: "run_workflow (anycast-dns-root-sovereignty)",
576
+ attackPath: "run_workflow (attack-path)",
577
+ attackSurface: "run_workflow (attack-surface)",
578
+ bgpHijackExposure: "run_workflow (bgp-hijack-exposure)",
579
+ blastRadius: "run_workflow (blast-radius)",
580
+ buildTakedownEvidencePackage: "run_workflow (build-takedown-evidence-package)",
581
+ discoverAiAgentInfrastructure: "run_workflow (discover-ai-agent-infrastructure)",
582
+ indicator: "run_workflow (indicator)",
583
+ indicatorEnrichment: "run_workflow (indicator-enrichment)",
584
+ infrastructureMapping: "run_workflow (infrastructure-mapping)",
585
+ mapSupplyChainConcentration: "run_workflow (map-supply-chain-concentration)",
586
+ nameserverHijackDnsConsistency: "run_workflow (nameserver-hijack-dns-consistency)",
587
+ routeHealth: "run_workflow (route-health)",
588
+ subdomainTakeover: "run_workflow (subdomain-takeover)",
589
+ typosquat: "run_workflow (typosquat)",
590
+ };
591
+
592
+ // Coerce a liberal first arg into the string value bound as $v. Accepts a bare value or a
593
+ // { value } / { v } wrapper; empty is rejected with a clear error, never an opaque failure.
594
+ function graphValue(name, value) {
595
+ let v = value;
596
+ if (v && typeof v === "object" && !Array.isArray(v)) {
597
+ if (v.value !== undefined) v = v.value;
598
+ else if (v.v !== undefined) v = v.v;
599
+ }
600
+ if (v === null || v === undefined || String(v).trim() === "") {
601
+ throw new WhisperError(`graph.${name}() needs a value (a host, IP, domain, or ASN)`);
602
+ }
603
+ return String(v).trim();
604
+ }
605
+
606
+ /**
607
+ * Run raw Cypher against the Whisper Security graph (keyed). The escape hatch under every
608
+ * typed method: `graph.query("MATCH (n) RETURN n LIMIT 1", {}, { apiKey })`. Returns the
609
+ * full `{ columns, rows, statistics }` (rows are objects keyed by column name).
610
+ */
611
+ async function graphQuery(cypher, params, opts = {}) {
612
+ if (!cypher || !String(cypher).trim()) throw new WhisperError("graph.query() needs a Cypher string");
613
+ return callGraph(String(cypher), params || {}, opts, "query");
614
+ }
615
+
616
+ // Build the namespace: query + a typed method per catalog entry. Direct methods run their
617
+ // Cypher; flow methods throw a clear, honest "runs via the workflow runner, not wired here"
618
+ // error (never a silent no-op) and carry .runVia for programmatic inspection.
619
+ const graph = { query: graphQuery };
620
+
621
+ for (const [name, cypher] of Object.entries(GRAPH_DIRECT)) {
622
+ graph[name] = async (value, opts = {}) => callGraph(cypher, { v: graphValue(name, value) }, opts, name);
623
+ }
624
+
625
+ /** List every node and relationship type in the graph with counts and examples (keyed). No input. */
626
+ graph.dbSchema = async (opts = {}) => callGraph("CALL db.schema()", {}, opts, "dbSchema");
627
+
628
+ /**
629
+ * Contribute an indicator observation or feedback back into the graph (keyed WRITE). Pass
630
+ * `{ kind: "indicator" | "feedback", ...fields }`. The payload is bound as a single Cypher
631
+ * parameter (injection-safe). The server enforces its own write scopes and rejects anonymous
632
+ * callers, so a scope/quota fault surfaces as the server's WhisperError detail.
633
+ */
634
+ graph.submit = async (payload, opts = {}) => {
635
+ const p = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
636
+ if (!p.kind || String(p.kind).trim() === "") {
637
+ throw new WhisperError("graph.submit() needs a { kind } of 'indicator' or 'feedback' (plus its fields)");
638
+ }
639
+ return callGraph("CALL whisper.submit($submission)", { submission: p }, opts, "submit");
640
+ };
641
+
642
+ for (const [name, runVia] of Object.entries(GRAPH_FLOW)) {
643
+ graph[name] = () => {
644
+ const err = new WhisperError(
645
+ `graph.${name}() is a multi-step read workflow: it runs via the Whisper workflow runner ` +
646
+ `(${runVia}), not a single /api/query Cypher, and is not wired into this SDK yet. Use ` +
647
+ "graph.query() for a raw Cypher call, or run the workflow via the console/agent runner."
648
+ );
649
+ err.runVia = runVia;
650
+ return Promise.reject(err);
651
+ };
652
+ }
653
+
654
+ export { graph };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whisper-id",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A routable Whisper IPv6 identity and safe egress for any Node agent.",
5
5
  "type": "module",
6
6
  "exports": { ".": { "types": "./index.d.ts", "import": "./index.js" } },