whisper-id 0.4.0 → 0.5.1

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