whisper-id 0.2.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.
Files changed (4) hide show
  1. package/README.md +63 -19
  2. package/index.d.ts +100 -0
  3. package/index.js +264 -1
  4. 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 — 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`, `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 { register, withEgress } from "whisper-id";
15
+ import { verify } from "whisper-id";
16
+ await verify("2a04:2a01:…"); // keyless: true if it's a real Whisper agent
17
+ ```
11
18
 
12
- const agent = await register("my-bot"); // a routable Whisper /128 identity
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,27 +28,57 @@ await withEgress(async (e) => {
19
28
 
20
29
  ## API
21
30
 
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` |
31
+ ### Keyless — no key, no CLI (pure HTTPS)
32
32
 
33
- ### Keyless / serverless (no CLI)
33
+ Run anywhere `fetch` runs (Cloudflare Workers, Vercel, Deno, Lambda, the browser).
34
34
 
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.:
35
+ | Call | Does |
36
+ |------|------|
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` |
36
41
 
37
42
  ```js
38
43
  import { verify, rdap } from "whisper-id";
39
44
  if (await verify(addr)) console.log((await rdap(addr)).name);
40
45
  ```
41
46
 
42
- `register`, `egress`, `withEgress`, and `ip` need the CLI on the host (egress needs the local proxy; register needs your key).
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 }` |
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. |
77
+ | `withEgress([opts], fn)` | Run `fn(egress)` with egress up, restoring the environment afterwards. |
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.
43
82
 
44
83
  ### Using it with `fetch`
45
84
 
@@ -59,14 +98,19 @@ transparently inside `withEgress`.
59
98
 
60
99
  ## Requirements
61
100
 
62
- The `whisper` CLI on your `PATH` (this package is a thin, dependency-free wrapper over it):
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`:
63
107
 
64
108
  ```sh
65
109
  curl get.whisper.online | sh
66
110
  ```
67
111
 
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.)
112
+ (`$WHISPER_BIN` overrides the CLI path; `$WHISPER_CONTROL_URL` / `$WHISPER_RDAP_URL` override the
113
+ endpoints.)
70
114
 
71
115
  ## Links
72
116
 
package/index.d.ts CHANGED
@@ -50,3 +50,103 @@ 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>;
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 {
@@ -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 {
@@ -191,3 +192,265 @@ 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whisper-id",
3
- "version": "0.2.0",
3
+ "version": "0.3.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" } },