whisper-id 0.1.0 → 0.3.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.
- package/README.md +67 -9
- package/index.d.ts +121 -0
- package/index.js +318 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
# whisper-id
|
|
2
2
|
|
|
3
|
-
A real, routable **IPv6 identity** and **safe egress** for any Node agent
|
|
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`, `rdap` — so anyone can prove an address is a Whisper agent. **With
|
|
7
|
+
your key** (`opts.apiKey` or `WHISPER_API_KEY`) the whole control plane unlocks — list, policy,
|
|
8
|
+
logs, revoke, identity, agent — plus **egress** that sources your traffic from your `/128`.
|
|
4
9
|
|
|
5
10
|
```sh
|
|
6
11
|
npm i whisper-id
|
|
7
12
|
```
|
|
8
13
|
|
|
9
14
|
```js
|
|
10
|
-
import {
|
|
15
|
+
import { verify } from "whisper-id";
|
|
16
|
+
await verify("2a04:2a01:…"); // keyless: true if it's a real Whisper agent
|
|
17
|
+
```
|
|
11
18
|
|
|
12
|
-
|
|
19
|
+
```js
|
|
20
|
+
import { identity, withEgress } from "whisper-id"; // with a key (WHISPER_API_KEY)
|
|
21
|
+
const me = await identity({ label: "my-bot" }); // allocate a routable Whisper /128
|
|
13
22
|
await withEgress(async (e) => {
|
|
14
23
|
// proxy env (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) now points at your local Whisper proxy
|
|
15
24
|
// → curl, axios, got, and other env-aware clients leave from your /128.
|
|
@@ -19,13 +28,57 @@ await withEgress(async (e) => {
|
|
|
19
28
|
|
|
20
29
|
## API
|
|
21
30
|
|
|
31
|
+
### Keyless — no key, no CLI (pure HTTPS)
|
|
32
|
+
|
|
33
|
+
Run anywhere `fetch` runs (Cloudflare Workers, Vercel, Deno, Lambda, the browser).
|
|
34
|
+
|
|
22
35
|
| Call | Does |
|
|
23
36
|
|------|------|
|
|
24
|
-
| `
|
|
37
|
+
| `verify(address)` | Is `address` a real Whisper agent? (server-side DANE + DNSSEC + reverse-DNS + JWS) → `boolean` |
|
|
38
|
+
| `verifyDetails(address)` | The full verdict (`is_whisper_agent`, `fqdn`, `operator`, `dane_ok`, `jws_ok`, …) or `null` |
|
|
39
|
+
| `rdap(address)` | The public RDAP record for a `/128`, or `null` |
|
|
40
|
+
| `egressIp()` | The IP this process leaves from (a `/128` when routed, else the platform's) → `string` |
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
import { verify, rdap } from "whisper-id";
|
|
44
|
+
if (await verify(addr)) console.log((await rdap(addr)).name);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Control plane — with a key (pure HTTP, no CLI)
|
|
48
|
+
|
|
49
|
+
Pure `fetch` to the control endpoint — no `whisper` CLI, no child process. The key comes from
|
|
50
|
+
`opts.apiKey` or `WHISPER_API_KEY` and is sent only in the `X-API-Key` header (never on argv,
|
|
51
|
+
never logged). Each also takes `{ apiKey, timeout }`. On failure they throw a `WhisperError`
|
|
52
|
+
carrying the server's `detail` (plus `.status` / `.retryAfter`).
|
|
53
|
+
|
|
54
|
+
| Call | Does |
|
|
55
|
+
|------|------|
|
|
56
|
+
| `list({ kind })` | List your fleet — `kind`: `"agents"` (default) \| `"identities"` \| `"records"`. → items `{ label, fqdn, address, agent, created, state }` |
|
|
57
|
+
| `identity({ label, contact_email })` | Allocate your own `/128`. Release with `{ release: true, address }`. → `{ agent, address, fqdn, ptr, state }` |
|
|
58
|
+
| `agent(idOrAddress)` | One agent's detail + live counters. → the record, or `null` |
|
|
59
|
+
| `policy({ default, allow, block })` | Read (no fields) or set your per-tenant DNS policy. → `{ key, value }` rows |
|
|
60
|
+
| `logs({ agent, kind, from, to, limit })` | Recent DNS/conn/alloc activity from warm storage. → event records |
|
|
61
|
+
| `revoke(idOrAddress)` | Fully revoke an agent — **irreversible** (needs `admin:dns`). → `{ status }` |
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
import { list, policy, logs } from "whisper-id"; // WHISPER_API_KEY in the env
|
|
65
|
+
|
|
66
|
+
await policy({ default: "deny", allow: ["api.openai.com", "*.githubusercontent.com"] });
|
|
67
|
+
for (const a of await list()) console.log(a.address, a.label);
|
|
68
|
+
const recent = await logs({ kind: "dns", limit: 100 });
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Egress + CLI-backed
|
|
72
|
+
|
|
73
|
+
| Call | Does |
|
|
74
|
+
|------|------|
|
|
75
|
+
| `register(name, { newKey })` | Create a named agent via the CLI — a routable `/128`. `newKey: true` mints a new agent **and** its own API key. → `Agent { address, id, name }` |
|
|
25
76
|
| `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
77
|
| `withEgress([opts], fn)` | Run `fn(egress)` with egress up, restoring the environment afterwards. |
|
|
27
|
-
| `
|
|
28
|
-
|
|
78
|
+
| `ip()` | Your current egress IP via the CLI, proving it's your `/128`. → `string` |
|
|
79
|
+
|
|
80
|
+
`register`, `egress`, `withEgress`, and `ip` need the `whisper` CLI on the host (egress brings up
|
|
81
|
+
the local proxy). Everything under **Keyless** and **Control plane** is pure HTTP and needs no CLI.
|
|
29
82
|
|
|
30
83
|
### Using it with `fetch`
|
|
31
84
|
|
|
@@ -45,14 +98,19 @@ transparently inside `withEgress`.
|
|
|
45
98
|
|
|
46
99
|
## Requirements
|
|
47
100
|
|
|
48
|
-
|
|
101
|
+
Zero dependencies. The **keyless** checks and the **control plane** are pure HTTP (Node ≥ 18's
|
|
102
|
+
global `fetch`) — no CLI, nothing to install. For the control plane, set `WHISPER_API_KEY` (or
|
|
103
|
+
pass `{ apiKey }`).
|
|
104
|
+
|
|
105
|
+
Only **egress** and the CLI-backed calls (`register`, `egress`, `withEgress`, `ip`) need the
|
|
106
|
+
`whisper` CLI on your `PATH`:
|
|
49
107
|
|
|
50
108
|
```sh
|
|
51
109
|
curl get.whisper.online | sh
|
|
52
110
|
```
|
|
53
111
|
|
|
54
|
-
|
|
55
|
-
|
|
112
|
+
(`$WHISPER_BIN` overrides the CLI path; `$WHISPER_CONTROL_URL` / `$WHISPER_RDAP_URL` override the
|
|
113
|
+
endpoints.)
|
|
56
114
|
|
|
57
115
|
## Links
|
|
58
116
|
|
package/index.d.ts
CHANGED
|
@@ -26,6 +26,127 @@ export function register(name: string, opts?: RegisterOptions): Promise<Agent>;
|
|
|
26
26
|
export function egress(opts?: EgressOptions): Promise<Egress>;
|
|
27
27
|
export function withEgress<T>(fn: (e: Egress) => Promise<T> | T): Promise<T>;
|
|
28
28
|
export function withEgress<T>(opts: EgressOptions, fn: (e: Egress) => Promise<T> | T): Promise<T>;
|
|
29
|
+
/** Full server-side verify verdict (keyless). */
|
|
30
|
+
export interface VerifyVerdict {
|
|
31
|
+
is_whisper_agent: boolean;
|
|
32
|
+
fqdn?: string;
|
|
33
|
+
operator?: string;
|
|
34
|
+
tenant?: string;
|
|
35
|
+
dane_ok?: boolean;
|
|
36
|
+
jws_ok?: boolean;
|
|
37
|
+
verified_at?: number;
|
|
38
|
+
evidence?: unknown;
|
|
39
|
+
[k: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Keyless, CLI-free: is `address` a real Whisper agent? (server-side DANE+DNSSEC+reverse-DNS+JWS) */
|
|
29
43
|
export function verify(address: string, opts?: { timeout?: number }): Promise<boolean>;
|
|
44
|
+
/** Keyless, CLI-free: the full verify verdict, or null if not an agent. */
|
|
45
|
+
export function verifyDetails(address: string, opts?: { timeout?: number }): Promise<VerifyVerdict | null>;
|
|
46
|
+
/** Keyless, CLI-free: the public RDAP record for a /128, or null. */
|
|
47
|
+
export function rdap(address: string, opts?: { timeout?: number }): Promise<Record<string, unknown> | null>;
|
|
48
|
+
/** Keyless, CLI-free: the caller's current egress IP (what this process leaves from). */
|
|
49
|
+
export function egressIp(opts?: { timeout?: number }): Promise<string>;
|
|
50
|
+
/** Your current egress IP via the CLI (proves your /128). */
|
|
30
51
|
export function ip(opts?: { timeout?: number }): Promise<string>;
|
|
31
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>;
|
package/index.js
CHANGED
|
@@ -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 {
|
|
@@ -127,11 +128,63 @@ export async function withEgress(optsOrFn, maybeFn) {
|
|
|
127
128
|
}
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
|
|
131
|
+
// Keyless public endpoint base (the same surface the CLI uses, server-side). No key, no `whisper`
|
|
132
|
+
// CLI — these work in any runtime that can fetch() (serverless, edge, browser). Override via env.
|
|
133
|
+
function rdapBase() {
|
|
134
|
+
return (process.env.WHISPER_RDAP_URL || "https://rdap.whisper.online").replace(/\/+$/, "");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function httpGetJson(url, { timeout = 60000 } = {}) {
|
|
138
|
+
const ctrl = new AbortController();
|
|
139
|
+
const t = setTimeout(() => ctrl.abort(), timeout);
|
|
140
|
+
let res;
|
|
141
|
+
try {
|
|
142
|
+
res = await fetch(url, { headers: { Accept: "application/rdap+json, application/json, */*", "User-Agent": UA }, signal: ctrl.signal });
|
|
143
|
+
} catch (err) {
|
|
144
|
+
throw new WhisperError(`could not reach ${url}: ${err && err.message ? err.message : err}`);
|
|
145
|
+
} finally {
|
|
146
|
+
clearTimeout(t);
|
|
147
|
+
}
|
|
148
|
+
let body = null;
|
|
149
|
+
try {
|
|
150
|
+
body = await res.json();
|
|
151
|
+
} catch {
|
|
152
|
+
body = null;
|
|
153
|
+
}
|
|
154
|
+
return { status: res.status, body };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Return true iff `address` is a real Whisper agent. **Keyless and CLI-free** — one HTTPS GET to
|
|
159
|
+
* the public /verify-identity endpoint (server-side DANE+DNSSEC+reverse-DNS+JWS chain). Works in
|
|
160
|
+
* any runtime. 200 ⇒ agent, 404 ⇒ not. Throws only if the endpoint is unreachable.
|
|
161
|
+
*/
|
|
131
162
|
export async function verify(address, { timeout = 60000 } = {}) {
|
|
163
|
+
return (await verifyDetails(address, { timeout })) !== null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Full verify verdict for a real agent (is_whisper_agent, fqdn, operator, tenant, dane_ok, jws_ok, …), else null. Keyless, CLI-free. */
|
|
167
|
+
export async function verifyDetails(address, { timeout = 60000 } = {}) {
|
|
132
168
|
if (!address || !address.trim()) throw new WhisperError("verify() needs an address");
|
|
133
|
-
const
|
|
134
|
-
|
|
169
|
+
const url = `${rdapBase()}/verify-identity?ip=${encodeURIComponent(address.trim())}`;
|
|
170
|
+
const { status, body } = await httpGetJson(url, { timeout });
|
|
171
|
+
if (status !== 200 || !body || !body.is_whisper_agent) return null;
|
|
172
|
+
return body;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Public RDAP record for a Whisper /128 (handle, name, status, entities, …), or null. Keyless, CLI-free. */
|
|
176
|
+
export async function rdap(address, { timeout = 60000 } = {}) {
|
|
177
|
+
if (!address || !address.trim()) throw new WhisperError("rdap() needs an address");
|
|
178
|
+
const url = `${rdapBase()}/ip/${encodeURI(address.trim())}`; // path segment — keep the literal colons
|
|
179
|
+
const { status, body } = await httpGetJson(url, { timeout });
|
|
180
|
+
return status === 200 ? body : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The caller's current egress IP as seen by Whisper (what this process leaves from). Keyless, CLI-free. */
|
|
184
|
+
export async function egressIp({ timeout = 60000 } = {}) {
|
|
185
|
+
const { status, body } = await httpGetJson(`${rdapBase()}/egress-ip`, { timeout });
|
|
186
|
+
if (status !== 200) throw new WhisperError(`egress-ip endpoint returned HTTP ${status}`);
|
|
187
|
+
return (body && body.ip) || "";
|
|
135
188
|
}
|
|
136
189
|
|
|
137
190
|
/** Return the current egress IP, proving it is your Whisper /128. */
|
|
@@ -139,3 +192,265 @@ export async function ip({ timeout = 60000 } = {}) {
|
|
|
139
192
|
const env = await runJson(["ip"], { timeout });
|
|
140
193
|
return getKey(env, "ip", "agent") || firstAddr(env) || "";
|
|
141
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
|
+
}
|
package/package.json
CHANGED