whisper-id 0.2.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 +110 -21
  2. package/index.d.ts +199 -1
  3. package/index.js +469 -8
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,15 +1,25 @@
1
1
  # whisper-id
2
2
 
3
- A real, routable **IPv6 identity** and **safe egress** for any Node agent — in two calls.
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`.
4
10
 
5
11
  ```sh
6
12
  npm i whisper-id
7
13
  ```
8
14
 
9
15
  ```js
10
- import { register, withEgress } from "whisper-id";
16
+ import { verify } from "whisper-id";
17
+ await verify("2a04:2a01:..."); // keyless: true if it's a real Whisper agent
18
+ ```
11
19
 
12
- const agent = await register("my-bot"); // a routable Whisper /128 identity
20
+ ```js
21
+ import { identity, withEgress } from "whisper-id"; // with a key (WHISPER_API_KEY)
22
+ const me = await identity({ label: "my-bot" }); // allocate a routable Whisper /128
13
23
  await withEgress(async (e) => {
14
24
  // proxy env (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) now points at your local Whisper proxy
15
25
  // → curl, axios, got, and other env-aware clients leave from your /128.
@@ -19,27 +29,101 @@ await withEgress(async (e) => {
19
29
 
20
30
  ## API
21
31
 
22
- | Call | Does |
23
- |------|------|
24
- | `register(name, { newKey })` | Create a named agent — a routable `/128`. `newKey: true` mints a new agent **and** its own API key. → `Agent { address, id, name }` |
25
- | `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. |
26
- | `withEgress([opts], fn)` | Run `fn(egress)` with egress up, restoring the environment afterwards. |
27
- | `verify(address)` | Keyless — is `address` a real Whisper agent? (DANE + DNSSEC + reverse-DNS + JWS) → `boolean` |
28
- | `verifyDetails(address)` | Keyless — the full verdict (`is_whisper_agent`, `fqdn`, `operator`, `dane_ok`, `jws_ok`, …) or `null` |
29
- | `rdap(address)` | Keyless — the public RDAP record for a `/128`, or `null` |
30
- | `egressIp()` | Keyless — the IP this process leaves from (a `/128` when routed, else the platform's) → `string` |
31
- | `ip()` | Your current egress IP via the CLI, proving it's your `/128`. → `string` |
32
+ ### Keyless: no key, no CLI (pure HTTPS)
32
33
 
33
- ### Keyless / serverless (no CLI)
34
+ Run anywhere `fetch` runs (Cloudflare Workers, Vercel, Deno, Lambda, the browser).
34
35
 
35
- `verify`, `verifyDetails`, `rdap`, and `egressIp` are **keyless** — pure HTTPS via `fetch`, **no `whisper` CLI and no key** — so they run in Cloudflare Workers, Vercel, Deno, Lambda, etc.:
36
+ | Call | Does |
37
+ |------|------|
38
+ | `verify(address)` | Is `address` a real Whisper agent? (server-side DANE + DNSSEC + reverse-DNS + JWS) → `boolean` |
39
+ | `verifyDetails(address)` | The full verdict (`is_whisper_agent`, `fqdn`, `operator`, `dane_ok`, `jws_ok`, and more) or `null` |
40
+ | `rdap(address)` | The public RDAP record for a `/128`, or `null` |
41
+ | `egressIp()` | The IP this process leaves from (a `/128` when routed, else the platform's) → `string` |
36
42
 
37
43
  ```js
38
44
  import { verify, rdap } from "whisper-id";
39
45
  if (await verify(addr)) console.log((await rdap(addr)).name);
40
46
  ```
41
47
 
42
- `register`, `egress`, `withEgress`, and `ip` need the CLI on the host (egress needs the local proxy; register needs your key).
48
+ ### Control plane: with a key (pure HTTP, no CLI)
49
+
50
+ Pure `fetch` to the control endpoint, with no `whisper` CLI and no child process. The key comes from
51
+ `opts.apiKey` or `WHISPER_API_KEY` and is sent only in the `X-API-Key` header (never on argv,
52
+ never logged). Each also takes `{ apiKey, timeout }`. On failure they throw a `WhisperError`
53
+ carrying the server's `detail` (plus `.status` / `.retryAfter`).
54
+
55
+ | Call | Does |
56
+ |------|------|
57
+ | `list({ kind })` | List your fleet. `kind`: `"agents"` (default) \| `"identities"` \| `"records"`. → items `{ label, fqdn, address, agent, created, state }` |
58
+ | `identity({ label, contact_email })` | Allocate your own `/128`. Release with `{ release: true, address }`. → `{ agent, address, fqdn, ptr, state }` |
59
+ | `agent(idOrAddress)` | One agent's detail + live counters. → the record, or `null` |
60
+ | `policy({ default, allow, block })` | Read (no fields) or set your per-tenant DNS policy. → `{ key, value }` rows |
61
+ | `logs({ agent, kind, from, to, limit })` | Recent DNS/conn/alloc activity from warm storage. → event records |
62
+ | `revoke(idOrAddress)` | Fully revoke an agent, **irreversible** (needs `admin:dns`). → `{ status }` |
63
+
64
+ ```js
65
+ import { list, policy, logs } from "whisper-id"; // WHISPER_API_KEY in the env
66
+
67
+ await policy({ default: "deny", allow: ["api.openai.com", "*.githubusercontent.com"] });
68
+ for (const a of await list()) console.log(a.address, a.label);
69
+ const recent = await logs({ kind: "dns", limit: 100 });
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
+
116
+ ### Egress + CLI-backed
117
+
118
+ | Call | Does |
119
+ |------|------|
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 }` |
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. |
122
+ | `withEgress([opts], fn)` | Run `fn(egress)` with egress up, restoring the environment afterwards. |
123
+ | `ip()` | Your current egress IP via the CLI, proving it's your `/128`. → `string` |
124
+
125
+ `register`, `egress`, `withEgress`, and `ip` need the `whisper` CLI on the host (egress brings up
126
+ the local proxy). Everything under **Keyless** and **Control plane** is pure HTTP and needs no CLI.
43
127
 
44
128
  ### Using it with `fetch`
45
129
 
@@ -59,18 +143,23 @@ transparently inside `withEgress`.
59
143
 
60
144
  ## Requirements
61
145
 
62
- The `whisper` CLI on your `PATH` (this package is a thin, dependency-free wrapper over it):
146
+ Zero dependencies. The **keyless** checks and the **control plane** are pure HTTP (Node ≥ 18's
147
+ global `fetch`), with no CLI and nothing to install. For the control plane, set `WHISPER_API_KEY` (or
148
+ pass `{ apiKey }`).
149
+
150
+ Only **egress** and the CLI-backed calls (`register`, `egress`, `withEgress`, `ip`) need the
151
+ `whisper` CLI on your `PATH`:
63
152
 
64
153
  ```sh
65
154
  curl get.whisper.online | sh
66
155
  ```
67
156
 
68
- For authenticated calls (`register`, `egress`, `ip`), set `WHISPER_API_KEY` or run
69
- `whisper login`. `verify` is keyless. (`$WHISPER_BIN` overrides the CLI path.)
157
+ (`$WHISPER_BIN` overrides the CLI path; `$WHISPER_CONTROL_URL` / `$WHISPER_GRAPH_URL` /
158
+ `$WHISPER_RDAP_URL` override the endpoints.)
70
159
 
71
160
  ## Links
72
161
 
73
- - Site — https://whisper.online
74
- - CLI — https://github.com/whisper-sec/whisper-cli
162
+ - Site: https://whisper.online
163
+ - CLI: https://github.com/whisper-sec/whisper-cli
75
164
 
76
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;
@@ -50,3 +50,201 @@ export function egressIp(opts?: { timeout?: number }): Promise<string>;
50
50
  /** Your current egress IP via the CLI (proves your /128). */
51
51
  export function ip(opts?: { timeout?: number }): Promise<string>;
52
52
  export function firstAddr(obj: unknown): string | null;
53
+
54
+ // ── Control plane (pure HTTP, keyed - no CLI) ───────────────────────────────────────
55
+ // The governance half of the two-tier surface: pure `fetch` to the control endpoint, so
56
+ // it runs anywhere (serverless, edge, a plain server). The key is read from `opts.apiKey`
57
+ // or `WHISPER_API_KEY` and sent only in the `X-API-Key` header. On failure these throw a
58
+ // `WhisperError` carrying the server's `detail` (and `.status` / `.retryAfter`).
59
+
60
+ /** Common options for every keyed control call. */
61
+ export interface ControlOptions {
62
+ /** The Whisper owner key (`whisper_live_...`). Falls back to `process.env.WHISPER_API_KEY`. */
63
+ apiKey?: string;
64
+ /** Per-call timeout in ms (default 60000). */
65
+ timeout?: number;
66
+ }
67
+
68
+ /** One agent as returned by `list` / `agent` (fields present depend on the op). */
69
+ export interface AgentItem {
70
+ label?: string;
71
+ fqdn?: string;
72
+ address?: string;
73
+ agent?: string;
74
+ created?: string | number;
75
+ state?: string;
76
+ [k: string]: unknown;
77
+ }
78
+
79
+ /** A `{ key, value }` row of the per-tenant DNS policy. */
80
+ export interface PolicyEntry {
81
+ key: string;
82
+ value: unknown;
83
+ [k: string]: unknown;
84
+ }
85
+
86
+ /** One activity event from `logs` (columns depend on `kind`). */
87
+ export interface LogEvent {
88
+ ts?: string | number;
89
+ kind?: string;
90
+ qname?: string;
91
+ qtype?: string;
92
+ rcode?: string;
93
+ decision?: string;
94
+ source?: string;
95
+ answer?: string;
96
+ agent?: string;
97
+ peer?: string;
98
+ [k: string]: unknown;
99
+ }
100
+
101
+ export interface ListOptions extends ControlOptions {
102
+ /** What to list: "agents" (default) | "identities" | "records". */
103
+ kind?: "agents" | "identities" | "records" | string;
104
+ }
105
+ export interface PolicyOptions extends ControlOptions {
106
+ /** Default action when nothing matches: "allow" | "deny". Omit all fields to READ. */
107
+ default?: "allow" | "deny" | string;
108
+ /** Names to allow. */
109
+ allow?: string[];
110
+ /** Names to block. */
111
+ block?: string[];
112
+ }
113
+ export interface LogsOptions extends ControlOptions {
114
+ agent?: string;
115
+ kind?: "dns" | "conn" | "alloc" | string;
116
+ from?: string | number;
117
+ to?: string | number;
118
+ limit?: number;
119
+ }
120
+ export interface RevokeOptions extends ControlOptions {
121
+ agent?: string;
122
+ }
123
+ export interface IdentityOptions extends ControlOptions {
124
+ /** The agent's human name (required to allocate). */
125
+ label?: string;
126
+ /** Opt-in public contact email (surfaced in RDAP). */
127
+ contact_email?: string;
128
+ /** Release form: set true and pass `address` to release an existing /128. */
129
+ release?: boolean;
130
+ /** The /128 to release (with `release:true`). */
131
+ address?: string;
132
+ }
133
+ export interface AgentSelectOptions extends ControlOptions {
134
+ agent?: string;
135
+ address?: string;
136
+ }
137
+
138
+ /** List your fleet (keyed). → items (`{label, fqdn, address, agent, created, state}`). */
139
+ export function list(kind: string, opts?: ControlOptions): Promise<AgentItem[]>;
140
+ export function list(opts?: ListOptions): Promise<AgentItem[]>;
141
+ /** Read (no policy fields) or set your per-tenant DNS resolver policy. → `{key,value}` rows. */
142
+ export function policy(opts?: PolicyOptions): Promise<PolicyEntry[]>;
143
+ /** Query recent DNS/conn/alloc activity from warm storage. → event records. */
144
+ export function logs(opts?: LogsOptions): Promise<LogEvent[]>;
145
+ /** Fully revoke an agent - IRREVERSIBLE (admin:dns). → `{ status, ... }`. */
146
+ export function revoke(agent: string, opts?: ControlOptions): Promise<{ status: string; [k: string]: unknown }>;
147
+ export function revoke(opts: RevokeOptions): Promise<{ status: string; [k: string]: unknown }>;
148
+ /** Allocate your own /128 (or release with `{release:true,address}`). → `{agent,address,fqdn,ptr,state}`. */
149
+ export function identity(opts?: IdentityOptions): Promise<AgentItem | null>;
150
+ /** One agent's detail + live counters. Accepts an id, a /128, or `{agent}`/`{address}`. */
151
+ export function agent(sel: string, opts?: ControlOptions): Promise<AgentItem | null>;
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";
@@ -11,6 +11,7 @@ const pexec = promisify(execFile);
11
11
  const ADDR_RE = /2a04:2a01:[0-9a-fA-F:]{2,}/; // the public Whisper agent prefix (AS219419)
12
12
  const HOSTPORT_RE = /127\.0\.0\.1:(\d{2,5})/;
13
13
  const PROXY_VARS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
14
+ const UA = "whisper-id-node"; // client identifier for the keyless + control HTTP calls
14
15
 
15
16
  /** Error thrown when a `whisper` invocation fails, or the CLI is not installed. */
16
17
  export class WhisperError extends Error {
@@ -71,7 +72,7 @@ function getKey(obj, ...keys) {
71
72
  return null;
72
73
  }
73
74
 
74
- /** Create a named agent identity — a routable Whisper IPv6 /128. */
75
+ /** Create a named agent identity - a routable Whisper IPv6 /128. */
75
76
  export async function register(name, { newKey = false, timeout = 120000 } = {}) {
76
77
  if (!name || !name.trim()) throw new WhisperError("register() needs a non-empty agent name");
77
78
  const args = ["create", "--name", name];
@@ -128,7 +129,7 @@ export async function withEgress(optsOrFn, maybeFn) {
128
129
  }
129
130
 
130
131
  // Keyless public endpoint base (the same surface the CLI uses, server-side). No key, no `whisper`
131
- // 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.
132
133
  function rdapBase() {
133
134
  return (process.env.WHISPER_RDAP_URL || "https://rdap.whisper.online").replace(/\/+$/, "");
134
135
  }
@@ -138,7 +139,7 @@ async function httpGetJson(url, { timeout = 60000 } = {}) {
138
139
  const t = setTimeout(() => ctrl.abort(), timeout);
139
140
  let res;
140
141
  try {
141
- res = await fetch(url, { headers: { Accept: "application/rdap+json, application/json, */*", "User-Agent": "whisper-id-node/0.2.0" }, signal: ctrl.signal });
142
+ res = await fetch(url, { headers: { Accept: "application/rdap+json, application/json, */*", "User-Agent": UA }, signal: ctrl.signal });
142
143
  } catch (err) {
143
144
  throw new WhisperError(`could not reach ${url}: ${err && err.message ? err.message : err}`);
144
145
  } finally {
@@ -154,7 +155,7 @@ async function httpGetJson(url, { timeout = 60000 } = {}) {
154
155
  }
155
156
 
156
157
  /**
157
- * 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
158
159
  * the public /verify-identity endpoint (server-side DANE+DNSSEC+reverse-DNS+JWS chain). Works in
159
160
  * any runtime. 200 ⇒ agent, 404 ⇒ not. Throws only if the endpoint is unreachable.
160
161
  */
@@ -162,7 +163,7 @@ export async function verify(address, { timeout = 60000 } = {}) {
162
163
  return (await verifyDetails(address, { timeout })) !== null;
163
164
  }
164
165
 
165
- /** 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. */
166
167
  export async function verifyDetails(address, { timeout = 60000 } = {}) {
167
168
  if (!address || !address.trim()) throw new WhisperError("verify() needs an address");
168
169
  const url = `${rdapBase()}/verify-identity?ip=${encodeURIComponent(address.trim())}`;
@@ -171,10 +172,10 @@ export async function verifyDetails(address, { timeout = 60000 } = {}) {
171
172
  return body;
172
173
  }
173
174
 
174
- /** 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. */
175
176
  export async function rdap(address, { timeout = 60000 } = {}) {
176
177
  if (!address || !address.trim()) throw new WhisperError("rdap() needs an address");
177
- 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
178
179
  const { status, body } = await httpGetJson(url, { timeout });
179
180
  return status === 200 ? body : null;
180
181
  }
@@ -191,3 +192,463 @@ export async function ip({ timeout = 60000 } = {}) {
191
192
  const env = await runJson(["ip"], { timeout });
192
193
  return getKey(env, "ip", "agent") || firstAddr(env) || "";
193
194
  }
195
+
196
+ // ── Control plane (pure HTTP - no CLI) ──────────────────────────────────────────────
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
200
+ // argv: works in any runtime with fetch() (serverless, edge, a plain server). The key is
201
+ // read from opts.apiKey or $WHISPER_API_KEY and sent ONLY in the X-API-Key header.
202
+
203
+ function controlUrl() {
204
+ return process.env.WHISPER_CONTROL_URL || "https://graph.whisper.security/api/query";
205
+ }
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:
209
+ // backslashes first). Mirrors the reference client's cypher builder byte-for-byte.
210
+ function escapeCypher(s) {
211
+ return String(s).replace(/\\/g, "\\\\").replace(/'/g, "''");
212
+ }
213
+ function quoteCypher(s) {
214
+ return "'" + escapeCypher(s) + "'";
215
+ }
216
+ function lit(v) {
217
+ if (v === null || v === undefined) return "null";
218
+ if (typeof v === "string") return quoteCypher(v);
219
+ if (typeof v === "boolean") return v ? "true" : "false";
220
+ if (typeof v === "number") return Number.isFinite(v) ? String(v) : "null";
221
+ if (Array.isArray(v)) return "[" + v.map(lit).join(",") + "]";
222
+ if (typeof v === "object") return cypherMap(v);
223
+ return quoteCypher(String(v));
224
+ }
225
+ // A Cypher map literal with keys in SORTED order → the query is byte-stable. undefined
226
+ // values are dropped so callers can pass a sparse args object.
227
+ function cypherMap(m) {
228
+ const keys = Object.keys(m).filter((k) => m[k] !== undefined).sort();
229
+ if (keys.length === 0) return "{}";
230
+ return "{" + keys.map((k) => k + ":" + lit(m[k])).join(",") + "}";
231
+ }
232
+ function buildAgentsQuery(op, args) {
233
+ const inner = args && Object.keys(args).some((k) => args[k] !== undefined) ? cypherMap(args) : "{}";
234
+ return "CALL whisper.agents({op:" + quoteCypher(op) + ", args:" + inner + "})";
235
+ }
236
+
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
240
+ // bare RFC-7807 problem object. Never throws on shape; a fault becomes error, not a crash.
241
+ function decodeEnvelope(body, httpStatus) {
242
+ const out = { ok: false, status: httpStatus, result: null, error: null, retryAfter: null };
243
+ if (!body || typeof body !== "object") {
244
+ out.error = { status: httpStatus, detail: httpStatus >= 400 ? "control plane returned a non-JSON error reply" : "control plane returned an unexpected reply" };
245
+ return out;
246
+ }
247
+ const applyRow = (row) => {
248
+ out.ok = !!row.ok;
249
+ out.result = row.result || null;
250
+ out.error = row.error || null;
251
+ if (row.status) out.status = row.status;
252
+ if (row.retry_after != null) out.retryAfter = row.retry_after;
253
+ if (!out.ok && !out.error) out.error = { status: out.status, detail: "control plane reported failure" };
254
+ if (out.error && !out.error.status) out.error.status = out.status;
255
+ return out;
256
+ };
257
+ // Shape B - an explicit ok flag at the top level.
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
260
+ // row may be an object already, or a positional array aligned to top-level columns.
261
+ if (Array.isArray(body.rows) && body.rows.length > 0) {
262
+ let row0 = body.rows[0];
263
+ if (Array.isArray(row0) && Array.isArray(body.columns)) {
264
+ const obj = {};
265
+ body.columns.forEach((c, i) => { obj[c] = row0[i]; });
266
+ row0 = obj;
267
+ }
268
+ if (row0 && typeof row0 === "object" && !Array.isArray(row0)) {
269
+ if (typeof row0.ok === "boolean") return applyRow(row0);
270
+ if (row0.result) { out.ok = true; out.result = row0.result; return out; }
271
+ }
272
+ }
273
+ // A bare RFC-7807 problem object (no ok, no rows).
274
+ if (body.error || body.detail || body.title || body.type) {
275
+ out.error = body.error || { status: body.status || httpStatus, detail: body.detail, title: body.title, type: body.type };
276
+ if (!out.error.status) out.error.status = httpStatus;
277
+ return out;
278
+ }
279
+ // A top-level result, or a shapeless-but-valid table → success (read ops fail OPEN).
280
+ if (body.result) { out.ok = true; out.result = body.result; return out; }
281
+ if (Array.isArray(body.columns) || Array.isArray(body.rows)) {
282
+ out.ok = true; out.result = { columns: body.columns || [], rows: body.rows || [] }; return out;
283
+ }
284
+ if (httpStatus >= 400) { out.error = { status: httpStatus, detail: "control plane reported failure" }; return out; }
285
+ out.ok = true; out.result = { columns: [], rows: [] };
286
+ return out;
287
+ }
288
+
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.
291
+ function recordsFrom(result) {
292
+ if (!result || !Array.isArray(result.rows)) return [];
293
+ const cols = Array.isArray(result.columns) ? result.columns : [];
294
+ return result.rows.map((row) => {
295
+ if (!Array.isArray(row)) return row && typeof row === "object" ? row : { value: row };
296
+ const m = {};
297
+ cols.forEach((c, i) => { if (i < row.length) m[c] = row[i]; });
298
+ return m;
299
+ });
300
+ }
301
+
302
+ function resolveKey(opts) {
303
+ const key = (opts && opts.apiKey) || process.env.WHISPER_API_KEY;
304
+ return key && String(key).trim();
305
+ }
306
+
307
+ // The one place that talks to the control plane. Builds the injection-proof query, POSTs
308
+ // it with the key in X-API-Key, decodes either envelope shape, and throws WhisperError
309
+ // (carrying the server's helpful `detail`, plus .status/.retryAfter) on ok:false. The key
310
+ // is never logged, never placed on argv, never returned.
311
+ async function callControl(op, args, opts = {}) {
312
+ const key = resolveKey(opts);
313
+ if (!key) {
314
+ throw new WhisperError(
315
+ `\`${op}\` needs a Whisper API key - pass { apiKey } or set WHISPER_API_KEY. ` +
316
+ "(verify/verifyDetails/rdap are keyless and need no key.)"
317
+ );
318
+ }
319
+ const url = controlUrl();
320
+ const timeout = opts.timeout || 60000;
321
+ const ctrl = new AbortController();
322
+ const t = setTimeout(() => ctrl.abort(), timeout);
323
+ let res;
324
+ try {
325
+ res = await fetch(url, {
326
+ method: "POST",
327
+ headers: {
328
+ "Content-Type": "application/json",
329
+ Accept: "application/json",
330
+ "X-API-Key": key,
331
+ "User-Agent": UA,
332
+ },
333
+ body: JSON.stringify({ query: buildAgentsQuery(op, args || {}) }),
334
+ signal: ctrl.signal,
335
+ });
336
+ } catch (err) {
337
+ throw new WhisperError(`could not reach the Whisper control plane at ${url}: ${err && err.message ? err.message : err}`);
338
+ } finally {
339
+ clearTimeout(t);
340
+ }
341
+ let body = null;
342
+ try {
343
+ body = await res.json();
344
+ } catch {
345
+ body = null;
346
+ }
347
+ const env = decodeEnvelope(body, res.status);
348
+ if (!env.ok) {
349
+ const e = env.error || {};
350
+ const werr = new WhisperError(e.detail || e.title || e.type || `control plane returned HTTP ${env.status}`);
351
+ werr.status = env.status;
352
+ if (env.retryAfter != null) werr.retryAfter = env.retryAfter;
353
+ if (e.suggestions) werr.suggestions = e.suggestions;
354
+ throw werr;
355
+ }
356
+ return recordsFrom(env.result);
357
+ }
358
+
359
+ /**
360
+ * List your fleet (keyed control plane). `kind`: "agents" (default) | "identities" | "records".
361
+ * Liberal input: `list("identities")` or `list({ kind, apiKey })`. → an array of items
362
+ * (`{label, fqdn, address, agent, created, state}`), newest-first is up to the caller.
363
+ */
364
+ export async function list(opts = {}) {
365
+ const o = typeof opts === "string" ? { kind: opts } : opts || {};
366
+ const recs = await callControl("list", { kind: o.kind || "agents" }, o);
367
+ // op:list rows are {kind,item}; unwrap to the item map when present.
368
+ return recs.map((r) => (r && typeof r.item === "object" && r.item !== null ? r.item : r));
369
+ }
370
+
371
+ /**
372
+ * Read or set your per-tenant DNS resolver policy (op:policy). NO policy fields ⇒ READ.
373
+ * To set: `policy({ default:"deny", block:["x.com"], allow:["y.com"] })`. → an array of
374
+ * `{ key, value }` rows (the effective policy).
375
+ */
376
+ export async function policy(opts = {}) {
377
+ const o = opts || {};
378
+ const args = {};
379
+ if (o.default != null && o.default !== "") args.default = o.default;
380
+ if (Array.isArray(o.allow) && o.allow.length) args.allow = o.allow;
381
+ if (Array.isArray(o.block) && o.block.length) args.block = o.block;
382
+ return callControl("policy", args, o);
383
+ }
384
+
385
+ /**
386
+ * Query recent activity from warm storage (op:logs) - the poll counterpart of the live
387
+ * monitor. All filters optional: `{ agent, kind:"dns"|"conn"|"alloc", from, to, limit }`
388
+ * (from/to accept epoch-ms, RFC-3339, or relative like "-1h"). → an array of event records.
389
+ */
390
+ export async function logs(opts = {}) {
391
+ const o = opts || {};
392
+ const args = {};
393
+ if (o.agent) args.agent = o.agent;
394
+ if (o.kind) args.kind = o.kind;
395
+ if (o.from != null && o.from !== "") args.from = o.from;
396
+ if (o.to != null && o.to !== "") args.to = o.to;
397
+ if (o.limit != null && Number(o.limit) > 0) args.limit = Number(o.limit);
398
+ return callControl("logs", args, o);
399
+ }
400
+
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, ... }`.
405
+ */
406
+ export async function revoke(target, maybeOpts) {
407
+ const o = typeof target === "string" ? maybeOpts || {} : target || {};
408
+ const sel = typeof target === "string" ? target : o.agent;
409
+ if (!sel || !String(sel).trim()) throw new WhisperError("revoke() needs an agent id or /128 address");
410
+ const recs = await callControl("revoke", { agent: String(sel).trim() }, o);
411
+ const rec = recs.length ? recs[0] : {};
412
+ return { status: rec.status || rec.state || "submitted", ...rec };
413
+ }
414
+
415
+ /**
416
+ * Allocate your own /128 identity (op:identity): `identity({ label, contact_email })`.
417
+ * Release form: `identity({ release:true, address:"2a04:2a01:..." })`.
418
+ * → `{ agent, address, fqdn, ptr, state }`.
419
+ */
420
+ export async function identity(opts = {}) {
421
+ const o = opts || {};
422
+ const args = {};
423
+ if (o.release) {
424
+ if (!o.address || !String(o.address).trim()) throw new WhisperError("identity({ release:true }) needs the address to release");
425
+ args.release = true;
426
+ args.address = String(o.address).trim();
427
+ } else {
428
+ if (!o.label || !String(o.label).trim()) throw new WhisperError("identity() needs a label (the agent's name)");
429
+ args.label = String(o.label).trim();
430
+ if (o.contact_email) args.contact_email = o.contact_email;
431
+ }
432
+ const recs = await callControl("identity", args, o);
433
+ return recs.length ? recs[0] : null;
434
+ }
435
+
436
+ /**
437
+ * One agent's detail + live counters (op:agent). Liberal input: `agent("2a04:2a01:...")`,
438
+ * `agent("agent-id")`, or `agent({ agent })` / `agent({ address })`. A value with a colon
439
+ * is treated as a /128 address. → the detail record, or null if not in your account.
440
+ */
441
+ export async function agent(sel = {}, maybeOpts) {
442
+ const o = typeof sel === "string" ? maybeOpts || {} : sel || {};
443
+ const args = {};
444
+ if (typeof sel === "string") {
445
+ if (sel.includes(":")) args.address = sel.trim();
446
+ else args.agent = sel.trim();
447
+ } else if (o.address) {
448
+ args.address = String(o.address).trim();
449
+ } else if (o.agent) {
450
+ if (String(o.agent).includes(":")) args.address = String(o.agent).trim();
451
+ else args.agent = String(o.agent).trim();
452
+ }
453
+ if (!args.agent && !args.address) throw new WhisperError("agent() needs an agent id or /128 address");
454
+ const recs = await callControl("agent", args, o);
455
+ return recs.length ? recs[0] : null;
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.2.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" } },