@decidio/sdk 0.1.7 → 0.1.8

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 CHANGED
@@ -11,22 +11,29 @@ npx @decidio/sdk quickstart
11
11
  One command, guided: it registers a fresh agent (key generated locally), gates a demo action
12
12
  with the same one line you'll use on your real function, routes it to a human — **you** — lets
13
13
  you approve right there (or from your Decidio app), executes on approval, then downloads the
14
- sealed Authority Receipt and verifies it offline in front of you. Sign in when prompted, or
15
- export `DECIDIO_SESSION_TOKEN` for headless terminals. No config: with no `DECIDIO_API_URL` set
16
- it targets the hosted synthetic sandbox.
14
+ sealed Authority Receipt and verifies it offline in front of you, pinned to your workspace's
15
+ issuer. It mints a **7-day** tour token (`init` mints 90-day tokens — different jobs, different
16
+ lifetimes) and ends by offering to clean itself up (`quickstart cleanup` works later too).
17
+ Sign in when prompted. No config: with no `DECIDIO_API_URL` set it targets the hosted synthetic
18
+ sandbox. Headless, the tour **stops at the routed decision** — approving is a human act, so it
19
+ prints the approve command and exits rather than approve its own request (CI can pass
20
+ `--auto-approve-demo`, which labels that authority as automated).
17
21
 
18
22
  ## Quickstart — seven steps to your first sealed receipt
19
23
 
20
24
  Under 30 minutes, no help needed. You'll need Node 20.6+, a terminal, and a Decidio workspace
21
25
  (request one at [decidioai.com](https://decidioai.com/#early-access) — the hosted sandbox is
22
- synthetic data, safe to experiment in).
26
+ synthetic data, safe to experiment in). Lab workspaces have a **governed-action allowance**
27
+ (currently 30 actions/hour); hitting it raises a typed `DecidioRateLimitError` naming the limit
28
+ and reset time — nothing is routed or executed — and `tokens list` shows your remaining
29
+ allowance any time.
23
30
 
24
31
  **PowerShell users** — the equivalents for every Bash idiom used below:
25
32
 
26
33
  ```powershell
27
34
  mkdir decidio-quickstart; cd decidio-quickstart # instead of mkdir ... && cd ...
28
35
  $env:DECIDIO_API_URL = "https://decidio-api.onrender.com" # instead of export NAME=value
29
- $env:DECIDIO_SOURCE_SYSTEM = "salesforce"; npx @decidio/sdk init my-sf-agent
36
+ $env:DECIDIO_AGENT_ID = "my-agent"; npx @decidio/sdk init
30
37
  # instead of VAR=value npx ... (inline env)
31
38
  ```
32
39
 
@@ -37,13 +44,15 @@ mkdir decidio-quickstart && cd decidio-quickstart
37
44
  npm install @decidio/sdk
38
45
  ```
39
46
 
40
- **2. Point at your Decidio and register your agent**
47
+ **2. Register your agent**
41
48
 
42
49
  ```bash
43
- export DECIDIO_API_URL=https://decidio-api.onrender.com # the hosted sandbox
44
50
  npx @decidio/sdk init --unique my-agent # --unique appends a random suffix: collision-proof
45
51
  ```
46
52
 
53
+ Every CLI command talks to the hosted sandbox by default and announces its target host on
54
+ stderr each run — set `DECIDIO_API_URL` (env or `.env`) to point at another Decidio instead.
55
+
47
56
  `init` signs you into your workspace (email + password, or paste the sign-in link from your
48
57
  workspace email), generates the agent's Ed25519 keypair **locally** (only the public `did:key`
49
58
  is sent), registers the agent, mints its **floor-limited API token** (limited to the agent runtime — request authorization, poll status, report signed confirmation; it cannot administer the workspace or approve anything — and your workspace session never touches the agent's disk), and writes `.env`.
@@ -89,11 +98,9 @@ node --env-file=.env quickstart.mjs
89
98
  A brand-new agent matches **no** auto-approve rule, so Decidio **routes every request to a
90
99
  human by default** — that is the point: nothing executes without either a named policy rule or
91
100
  a person. (Routing-by-default is distinct from a policy **block**, which refuses the action
92
- outright — no human can approve a blocked request.) The script prints the decision id and waits. (To later see a policy `proceed`,
93
- register a **new** agent in the sandbox's pre-policied lane — an existing agent's lane can't be
94
- changed: `DECIDIO_SOURCE_SYSTEM=salesforce npx @decidio/sdk init my-sf-agent`. That lane's
95
- named rule auto-approves amounts under $1M when the request carries the agent's signed
96
- identity proof, so even this $86k example would proceed there.)
101
+ outright — no human can approve a blocked request. Policy `proceed` rules — named auto-approve
102
+ for requests carrying the agent's signed identity proof — are configured per workspace by its
103
+ owner.) The script prints the decision id and waits.
97
104
 
98
105
  **5. Approve it** — in a second terminal (same directory; replace `<decisionId>` with the id
99
106
  step 4 printed — angle brackets are placeholders, never typed literally). Approvals are a
@@ -142,6 +149,10 @@ The server caps the evidence at the weaker `agent_asserted`, and the SDK warns a
142
149
  time.) Immutable seal + accruing evidence is the design, not an omission — corrections and
143
150
  later facts supersede, they never rewrite.
144
151
 
152
+ One sharing note: a receipt is **not a secret, but it is not metadata-free** — it names the
153
+ action, amount limits, policy, agent and issuer identifiers, and the decision reason. Share it
154
+ with auditors and counterparties deliberately, the way you would any business record.
155
+
145
156
  **7. Prove a replay is rejected:**
146
157
 
147
158
  ```bash
@@ -264,9 +275,15 @@ imports none of them. Each adapter's shipped `.d.ts` header documents its wiring
264
275
  - `doctor` — config, connectivity, and what your current token can do
265
276
  - `approvals [--mine] [--json]` / `approvals approve|reject <id> [reason]` — the human side, from your terminal (the list is workspace-wide; `--mine` narrows it to this directory's agent)
266
277
  - `receipt <id>` — download a sealed decision's Authority Receipt to a `.json` file
267
- - `verify [--issuer <did>] <file>...` — verify receipts offline (the canonical verifier; the standalone `@decidio/verify` package is the same implementation, for auditors who want zero Decidio dependency)
278
+ - `verify [--issuer <did>] <file>...` — verify receipts offline (the canonical verifier; the standalone `@decidio/verify` package is the same implementation, for auditors who want zero Decidio dependency). Unknown flags are refused, never ignored.
279
+ - `tokens list` / `tokens revoke <jti>` — see and kill agent tokens (values are never re-shown; the jti is the revocation handle). Each row carries its status — `active`, `expired`, or `REVOKED <date>` — so the list confirms the revocations it performs; re-revoking is an honest no-op (exit 1)
280
+ - `quickstart cleanup` — revoke the tour's token and delete its local credentials. What stays, honestly: sealed records (permanent by design) and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap)
268
281
  - `dev [--target URL]` — local relay for the resume webhook (operator setups)
269
282
 
283
+ The CLI's stream contract: **stdout carries results only** (safe to pipe — `--json` output is
284
+ pure JSON); errors, usage guidance, and the per-run target-host announcement go to **stderr**.
285
+ Exit codes: 0 success · 1 failure or honest no-op · 2 usage error.
286
+
270
287
  Commands that administer your workspace (`init`, `approvals`, `receipt`) use a **session**
271
288
  sign-in held in memory for that run. What lands on disk in `.env`: the agent's floor-limited
272
289
  API token, its locally-generated private signing key, and a webhook secret (used only in
package/dist/cli.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync } from "node:fs";
3
- import { randomBytes } from "node:crypto";
2
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync } from "node:fs";
3
+ import { randomBytes, createHash } from "node:crypto";
4
4
  import { createServer } from "node:http";
5
+ import { execFileSync } from "node:child_process";
5
6
  import { createInterface } from "node:readline";
6
7
  import { createRequire } from "node:module";
7
8
  import { generateKeypair } from "./signing.js";
8
- import { withApproval } from "./index.js";
9
+ import { withApproval, DecidioRateLimitError } from "./index.js";
9
10
  import { verifyReceipt } from "@decidio/verify";
10
11
  const VERSION = (() => {
11
12
  try {
@@ -27,32 +28,37 @@ function readEnvFile() {
27
28
  }
28
29
  for (const [k, v] of Object.entries(readEnvFile())) {
29
30
  if (k === "DECIDIO_SESSION_TOKEN") {
30
- console.log("⚠ ignoring DECIDIO_SESSION_TOKEN found in .env — session tokens belong in your shell or CI secret store, never on disk. Remove that line; export it in the shell instead.");
31
+ console.error("⚠ ignoring DECIDIO_SESSION_TOKEN found in .env — session tokens belong in your shell or CI secret store, never on disk. Remove that line; export it in the shell instead.");
31
32
  continue;
32
33
  }
33
34
  if (process.env[k] === undefined)
34
35
  process.env[k] = v;
35
36
  }
36
- const IS_QUICKSTART = process.argv[2] === "quickstart";
37
- const API = (process.env.DECIDIO_API_URL ?? (IS_QUICKSTART ? "https://decidio-api.onrender.com" : "http://localhost:4000")).replace(/\/$/, "");
37
+ const API = (process.env.DECIDIO_API_URL ?? "https://decidio-api.onrender.com").replace(/\/$/, "");
38
38
  const TOKEN = () => process.env.DECIDIO_API_TOKEN;
39
39
  const ENV_PATH = ".env";
40
40
  const log = (...a) => console.log(...a);
41
+ const err = (...a) => console.error(...a);
42
+ let hostAnnounced = false;
41
43
  async function api(path, init = {}, bearer) {
44
+ if (!hostAnnounced) {
45
+ hostAnnounced = true;
46
+ err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
47
+ }
42
48
  const auth = bearer ?? TOKEN();
43
49
  let res;
44
50
  try {
45
51
  res = await fetch(API + path, { ...init, headers: { "content-type": "application/json", ...(auth ? { authorization: `Bearer ${auth}` } : {}), ...(init.headers ?? {}) } });
46
52
  }
47
53
  catch (e) {
48
- log(`✘ could not reach Decidio at ${API} (${e?.cause?.code ?? e?.message ?? "network error"}).`);
54
+ err(`✘ could not reach Decidio at ${API} (${e?.cause?.code ?? e?.message ?? "network error"}).`);
49
55
  if (API.includes("localhost")) {
50
- log(` You're pointed at localhost — for the hosted sandbox run:`);
51
- log(` export DECIDIO_API_URL=https://decidio-api.onrender.com`);
52
- log(` (or put that line in .env), then re-run this command.`);
56
+ err(` You're pointed at localhost — for the hosted sandbox run:`);
57
+ err(` export DECIDIO_API_URL=https://decidio-api.onrender.com`);
58
+ err(` (or put that line in .env), then re-run this command.`);
53
59
  }
54
60
  else {
55
- log(` Check your network, then re-run. If the URL is wrong, set DECIDIO_API_URL and retry.`);
61
+ err(` Check your network, then re-run. If the URL is wrong, set DECIDIO_API_URL and retry.`);
56
62
  }
57
63
  process.exit(1);
58
64
  }
@@ -72,6 +78,14 @@ function writeSecretFile(path, body) {
72
78
  chmodSync(path, 0o600);
73
79
  }
74
80
  catch { }
81
+ if (process.platform === "win32") {
82
+ try {
83
+ execFileSync("icacls", [path, "/inheritance:r", "/grant:r", `${process.env.USERNAME ?? process.env.USER ?? ""}:F`], { stdio: "ignore" });
84
+ }
85
+ catch {
86
+ console.warn(`⚠ could not restrict ${path} to owner-only on this Windows volume — it keeps the directory's inherited permissions. For production, move these values to a secret manager.`);
87
+ }
88
+ }
75
89
  }
76
90
  function upsertEnv(updates) {
77
91
  const cur = readEnvFile();
@@ -103,14 +117,14 @@ function ask(question, mute = false) {
103
117
  }
104
118
  async function acquireSession(purpose) {
105
119
  if (/^http:\/\//i.test(API) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(API)) {
106
- log(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
120
+ err(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
107
121
  log(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
108
122
  return null;
109
123
  }
110
124
  if (process.env.DECIDIO_SESSION_TOKEN)
111
125
  return process.env.DECIDIO_SESSION_TOKEN;
112
126
  if (!process.stdin.isTTY) {
113
- log(`✘ ${purpose} needs a Decidio workspace sign-in, and this terminal can't prompt for one.`);
127
+ err(`✘ ${purpose} needs a Decidio workspace sign-in, and this terminal can't prompt for one.`);
114
128
  log(` Fix: set DECIDIO_SESSION_TOKEN and re-run. To mint one:`);
115
129
  log(` 1. Request a sign-in link from your workspace's sign-in screen (or use your welcome email).`);
116
130
  log(` 2. POST its ?login= value — the response's "token" is your session token:`);
@@ -151,7 +165,7 @@ async function cmdInit(agentId, opts = {}) {
151
165
  const existingKey = existingEnv.DECIDIO_AGENT_KEY;
152
166
  const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM || existingEnv.DECIDIO_SOURCE_SYSTEM;
153
167
  if (existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId && !opts.force) {
154
- log(`✘ this directory's .env already holds the key for a DIFFERENT agent ("${existingEnv.DECIDIO_AGENT_ID}").`);
168
+ err(`✘ this directory's .env already holds the key for a DIFFERENT agent ("${existingEnv.DECIDIO_AGENT_ID}").`);
155
169
  log(` Registering "${agentId}" here would overwrite that key — and a private key that is`);
156
170
  log(` overwritten is gone for good (it never left this machine). Nothing was written.`);
157
171
  log(` Pick one:`);
@@ -179,7 +193,7 @@ async function cmdInit(agentId, opts = {}) {
179
193
  }
180
194
  else {
181
195
  const suggestion = `${agentId}-${randomBytes(2).toString("hex")}`;
182
- log(`✘ "${agentId}" is already registered in this workspace, and its private key is not on this machine`);
196
+ err(`✘ "${agentId}" is already registered in this workspace, and its private key is not on this machine`);
183
197
  log(` (keys never leave the machine that generated them — likely a previous run, or another directory).`);
184
198
  log(` Nothing was written. Two ways forward:`);
185
199
  log(` 1. Fresh agent (most common): npx @decidio/sdk init ${suggestion}`);
@@ -193,11 +207,11 @@ async function cmdInit(agentId, opts = {}) {
193
207
  log(`✔ registered (did ${did}) — key generated locally; only the public half went to Decidio`);
194
208
  }
195
209
  else {
196
- log(`✘ registration failed (${r.status}): ${JSON.stringify(r.body)}`);
210
+ err(`✘ registration failed (${r.status}): ${JSON.stringify(r.body)}`);
197
211
  log(` Fix the cause above and re-run — init is safe to repeat.`);
198
212
  return 1;
199
213
  }
200
- let agentToken, workspaceId;
214
+ let agentToken, workspaceId, tokenJti;
201
215
  const mintBearer = session ?? TOKEN();
202
216
  if (mintBearer) {
203
217
  const expiresInDays = opts.expiresDays ?? 90;
@@ -205,10 +219,11 @@ async function cmdInit(agentId, opts = {}) {
205
219
  if (mint.status >= 200 && mint.status < 300 && mint.body?.token) {
206
220
  agentToken = mint.body.token;
207
221
  workspaceId = mint.body.workspaceId;
222
+ tokenJti = mint.body.jti;
208
223
  log(`✔ minted the agent's API token — floor-limited: it can request authorization, poll status and report confirmation; it cannot administer the workspace or approve anything${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ` (${expiresInDays} days)`}`);
209
224
  }
210
225
  else if (session) {
211
- log(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
226
+ err(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
212
227
  log(` Without it the SDK has no runtime credential. Fix the cause above and re-run init — it's safe to repeat.`);
213
228
  return 1;
214
229
  }
@@ -228,6 +243,8 @@ async function cmdInit(agentId, opts = {}) {
228
243
  envOut.DECIDIO_API_TOKEN = agentToken;
229
244
  if (workspaceId)
230
245
  envOut.DECIDIO_WORKSPACE_ID = workspaceId;
246
+ if (tokenJti)
247
+ envOut.DECIDIO_TOKEN_JTI = tokenJti;
231
248
  ensureGitignore();
232
249
  if (opts.force && existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId) {
233
250
  const backup = `.env.${existingEnv.DECIDIO_AGENT_ID.replace(/[^\w.-]/g, "_")}.bak`;
@@ -270,10 +287,11 @@ async function cmdDoctor() {
270
287
  const env = readEnvFile();
271
288
  let ok = true;
272
289
  const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
273
- line(!!(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL), `API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (unset — defaulting to localhost; the hosted sandbox is https://decidio-api.onrender.com)"}`);
290
+ log(`✔ API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
274
291
  line(!!(process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID), `agent id: ${process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID || "(unset — run: npx @decidio/sdk init <agent-name>)"}`);
275
292
  const secret = process.env.DECIDIO_WEBHOOK_SECRET || env.DECIDIO_WEBHOOK_SECRET;
276
- line(!!secret, secret ? "webhook secret: set — operator-only transport (inactive against the hosted sandbox; the poll worker guard.worker() is the self-serve resume path)" : "webhook secret: MISSING — the resume webhook (operator deployments) would fail closed; init generates one");
293
+ log(secret ? "✔ webhook secret: set — operator-only transport (inactive against the hosted sandbox; the poll worker guard.worker() is the self-serve resume path)"
294
+ : "• webhook secret: not set — only needed for the operator resume-webhook transport; the poll worker guard.worker() is the self-serve resume path (init generates one)");
277
295
  const signing = (process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) && (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
278
296
  if (signing)
279
297
  log("✔ signing key: set — the agent can prove its identity, making it ELIGIBLE for policy auto-approve. Whether a rule matches is your workspace policy's decision; with no matching rule every request routes to a human (deny-by-default).");
@@ -286,11 +304,13 @@ async function cmdDoctor() {
286
304
  try {
287
305
  const payload = JSON.parse(Buffer.from(tok.split(".")[1] ?? "", "base64url").toString("utf8"));
288
306
  if (typeof payload?.exp === "number") {
289
- const days = Math.floor((payload.exp * 1000 - Date.now()) / 86_400_000);
290
- if (days < 0)
307
+ const msLeft = payload.exp * 1000 - Date.now();
308
+ const days = Math.floor(msLeft / 86_400_000);
309
+ const left = msLeft < 86_400_000 ? `${Math.max(1, Math.floor(msLeft / 3_600_000))} hour(s)` : `${days} day(s)`;
310
+ if (msLeft < 0)
291
311
  line(false, `agent token: EXPIRED ${-days} day(s) ago — re-run \`npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}\` to mint a fresh one.`);
292
312
  else if (days <= 14)
293
- log(`⚠ agent token: expires in ${days} day(s) — mint a fresh one before it does: npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}`);
313
+ log(`⚠ agent token: expires in ${left} — mint a fresh one before it does: npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}`);
294
314
  else
295
315
  log(`✔ agent token: valid for another ${days} days`);
296
316
  }
@@ -319,6 +339,16 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
319
339
  const mineOnly = !!flags.mine;
320
340
  if (sub?.startsWith("--"))
321
341
  sub = undefined;
342
+ if ((sub === "approve" || sub === "reject") && !id) {
343
+ err(`usage: npx @decidio/sdk approvals ${sub} <decisionId> [reason]`);
344
+ return 2;
345
+ }
346
+ if (mineOnly && !process.env.DECIDIO_AGENT_ID) {
347
+ err(`✘ --mine needs an agent identity, and there is no DECIDIO_AGENT_ID in this directory's .env or your environment.`);
348
+ err(` Run this from your agent's directory (where init wrote .env), or drop --mine for the workspace-wide list.`);
349
+ return 2;
350
+ }
351
+ const decisionArg = id ?? "";
322
352
  let bearer;
323
353
  const probe = await api("/api/decisions/cards");
324
354
  if (probe.status === 401 || probe.status === 403) {
@@ -328,11 +358,7 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
328
358
  bearer = session;
329
359
  }
330
360
  if (sub === "approve" || sub === "reject") {
331
- if (!id) {
332
- log(`usage: npx @decidio/sdk approvals ${sub} <decisionId> [reason]`);
333
- return 2;
334
- }
335
- const r = await api(`/api/decisions/${encodeURIComponent(id)}/${sub === "approve" ? "approve" : "reject"}`, { method: "POST", body: JSON.stringify({ reason: reason ?? undefined }) }, bearer);
361
+ const r = await api(`/api/decisions/${encodeURIComponent(decisionArg)}/${sub === "approve" ? "approve" : "reject"}`, { method: "POST", body: JSON.stringify({ reason: reason ?? undefined }) }, bearer);
336
362
  if (r.status >= 200 && r.status < 300) {
337
363
  if (r.body?.alreadyResolved) {
338
364
  log(`• ${id} was already resolved — nothing changed.`);
@@ -340,7 +366,7 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
340
366
  log(` See its sealed record: npx @decidio/sdk receipt ${id}`);
341
367
  return 1;
342
368
  }
343
- log(`✔ ${sub}d ${id}${r.body?.resumeSignal ? ` — agent signalled (${r.body.resumeSignal.ok ? "ok" : r.body.resumeSignal.error})` : ""}`);
369
+ log(`✔ ${sub === "approve" ? "approved" : "rejected"} ${id}${r.body?.resumeSignal ? ` — agent signalled (${r.body.resumeSignal.ok ? "ok" : r.body.resumeSignal.error})` : ""}`);
344
370
  if (sub === "approve")
345
371
  log(` The agent's function runs next — a blocking call resumes now; a durable one when its worker or resume route picks this up.`);
346
372
  else
@@ -349,30 +375,30 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
349
375
  return 0;
350
376
  }
351
377
  if (r.status === 404) {
352
- log(`✘ no decision "${id}" in this workspace — check the id (npx @decidio/sdk approvals lists pending ones).`);
378
+ err(`✘ no decision "${id}" in this workspace — check the id (npx @decidio/sdk approvals lists pending ones).`);
353
379
  return 1;
354
380
  }
355
- log(`✘ ${sub} failed (${r.status}): ${JSON.stringify(r.body)}`);
381
+ err(`✘ ${sub} failed (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
356
382
  return 1;
357
383
  }
358
384
  const r = bearer ? await api("/api/decisions/cards", {}, bearer) : probe;
359
385
  if (!(r.status >= 200 && r.status < 300)) {
360
- log(`✘ could not list approvals (${r.status}): ${JSON.stringify(r.body)}`);
386
+ err(`✘ could not list approvals (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
361
387
  return 1;
362
388
  }
363
389
  const cards = r.body?.cards ?? [];
364
390
  const pending = cards.filter((c) => c.isAgentRequest && c.status !== "sealed");
391
+ const myAgent = process.env.DECIDIO_AGENT_ID;
392
+ const isMine = (c) => !!myAgent && typeof c.title === "string" && c.title.startsWith(`${myAgent}:`);
393
+ const shown = mineOnly ? pending.filter(isMine) : pending;
365
394
  if (jsonMode) {
366
- log(JSON.stringify(pending.map((c) => ({ decisionId: c.id, action: c.agentRequest?.action ?? null, amount: c.agentRequest?.amount ?? null, title: c.title ?? null })), null, 2));
395
+ log(JSON.stringify(shown.map((c) => ({ decisionId: c.id, action: c.agentRequest?.action ?? null, amount: c.agentRequest?.amount ?? null, title: c.title ?? null })), null, 2));
367
396
  return 0;
368
397
  }
369
398
  if (!pending.length) {
370
399
  log("No pending agent approvals. Trigger one from your protected function, then re-run this.");
371
400
  return 0;
372
401
  }
373
- const myAgent = process.env.DECIDIO_AGENT_ID;
374
- const isMine = (c) => !!myAgent && typeof c.title === "string" && c.title.startsWith(`${myAgent}:`);
375
- const shown = mineOnly ? pending.filter(isMine) : pending;
376
402
  if (mineOnly && !shown.length) {
377
403
  log(`No pending approvals for "${myAgent}" (${pending.length} pending elsewhere in this workspace — drop --mine to see them).`);
378
404
  return 0;
@@ -388,9 +414,9 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
388
414
  }
389
415
  async function cmdReceipt(id, jsonMode = false) {
390
416
  if (!id) {
391
- log("usage: npx @decidio/sdk receipt <decisionId>");
392
- log(" Downloads the decision's sealed Authority Receipt (a W3C Verifiable Credential) to a .json file.");
393
- log(" Find decision ids via `npx @decidio/sdk approvals` or in your Decidio app.");
417
+ err("usage: npx @decidio/sdk receipt <decisionId>");
418
+ err(" Downloads the decision's sealed Authority Receipt (a W3C Verifiable Credential) to a .json file.");
419
+ err(" Find decision ids via `npx @decidio/sdk approvals` or in your Decidio app.");
394
420
  return 2;
395
421
  }
396
422
  let bearer;
@@ -403,26 +429,26 @@ async function cmdReceipt(id, jsonMode = false) {
403
429
  r = await api(`/api/records/${encodeURIComponent(id)}/verify`, {}, bearer);
404
430
  }
405
431
  if (r.status === 404) {
406
- log(`✘ no sealed record for "${id}" in this workspace.`);
407
- log(` Receipts exist once a decision is SEALED (approved or rejected). Still pending? npx @decidio/sdk approvals`);
432
+ err(`✘ no sealed record for "${id}" in this workspace.`);
433
+ err(` Receipts exist once a decision is SEALED (approved or rejected). Still pending? npx @decidio/sdk approvals`);
408
434
  return 1;
409
435
  }
410
436
  if (!(r.status >= 200 && r.status < 300) || !r.body?.credential) {
411
- log(`✘ could not fetch the receipt (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
437
+ err(`✘ could not fetch the receipt (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
412
438
  return 1;
413
439
  }
414
440
  const name = `decidio-receipt-${id.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
415
441
  const nextBytes = JSON.stringify(r.body.credential, null, 2) + "\n";
416
442
  if (existsSync(name)) {
417
443
  if (readFileSync(name, "utf8") !== nextBytes) {
418
- log(`✘ ${name} already exists with DIFFERENT bytes — refusing to overwrite an audit artifact. Move it aside or delete it, then re-run.`);
444
+ err(`✘ ${name} already exists with DIFFERENT bytes — refusing to overwrite an audit artifact. Move it aside or delete it, then re-run.`);
419
445
  return 1;
420
446
  }
421
- log(`• ${name} already exists (byte-identical — receipts are immutable); leaving it in place.`);
447
+ (jsonMode ? err : log)(`• ${name} already exists (byte-identical — receipts are immutable); leaving it in place.`);
422
448
  }
423
449
  else {
424
450
  writeFileSync(name, nextBytes);
425
- log(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
451
+ (jsonMode ? err : log)(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
426
452
  }
427
453
  const detail = await api(`/api/records/${encodeURIComponent(id)}`, {}, bearer);
428
454
  const conf = detail.body?.record?.confirmation;
@@ -502,7 +528,7 @@ function chainOf(vcs, expectedIssuer) {
502
528
  let prev = null;
503
529
  let anchor = "segment";
504
530
  for (let i = 0; i < vcs.length; i++) {
505
- const r = verifyReceipt(vcs[i], expectedIssuer);
531
+ const r = verifyReceiptDoor(vcs[i], expectedIssuer);
506
532
  if (!r.valid)
507
533
  return { valid: false, length: i, reason: `receipt ${i} (${r.recordId ?? "?"}) invalid: ${r.reason}` };
508
534
  const cs = vcs[i]?.credentialSubject ?? {};
@@ -514,22 +540,61 @@ function chainOf(vcs, expectedIssuer) {
514
540
  }
515
541
  return { valid: true, length: vcs.length, anchor };
516
542
  }
543
+ function verifyReceiptDoor(vc, expectedIssuer) {
544
+ const res = verifyReceipt(vc, expectedIssuer);
545
+ if (!res.valid && res.reason === "issuer DID is not the expected issuer") {
546
+ const cs = vc?.credentialSubject ?? {};
547
+ const derive = (canonical, prevHash) => createHash("sha256").update((prevHash ?? "") + canonical).digest("hex");
548
+ const bound = typeof cs.recordCanonical === "string" && typeof cs.contentHash === "string"
549
+ && derive(cs.recordCanonical, typeof cs.prevHash === "string" ? cs.prevHash : null) === cs.contentHash;
550
+ return { ...res, contentBound: bound, recordId: typeof cs.recordId === "string" ? cs.recordId : res.recordId, reason: "issuer DID is not the expected issuer (the receipt itself may be internally consistent — it was just not issued by who you pinned)" };
551
+ }
552
+ return res;
553
+ }
517
554
  function cmdVerify(argsIn) {
518
- const issIdx = argsIn.indexOf("--issuer");
519
- if (issIdx >= 0 && (argsIn[issIdx + 1] === undefined || argsIn[issIdx + 1].startsWith("--"))) {
520
- log("✘ --issuer needs a did:key value.");
521
- return 2;
555
+ let expectedIssuer;
556
+ const files = [];
557
+ for (let i = 0; i < argsIn.length; i++) {
558
+ const a = argsIn[i];
559
+ if (a === "--issuer") {
560
+ const v = argsIn[i + 1];
561
+ if (v === undefined || v.startsWith("-")) {
562
+ err("✘ --issuer needs a did:key value.");
563
+ return 2;
564
+ }
565
+ expectedIssuer = v;
566
+ i++;
567
+ }
568
+ else if (a.startsWith("--issuer=")) {
569
+ const v = a.slice("--issuer=".length);
570
+ if (!v) {
571
+ err("✘ --issuer needs a did:key value.");
572
+ return 2;
573
+ }
574
+ expectedIssuer = v;
575
+ }
576
+ else if (!a.startsWith("-")) {
577
+ files.push(a);
578
+ }
522
579
  }
523
- const expectedIssuer = issIdx >= 0 ? argsIn[issIdx + 1] : undefined;
524
- const files = argsIn.filter((a, i) => !a.startsWith("--") && !(issIdx >= 0 && i === issIdx + 1));
525
580
  if (!files.length) {
526
- log("usage: npx @decidio/sdk verify [--issuer <did:key>] <receipt.json> [more.json ...]");
527
- log(" Verifies Authority Receipts OFFLINE — signature, content binding, and (for several files) chain order.");
581
+ err("usage: npx @decidio/sdk verify [--issuer <did:key>] <receipt.json> [more.json ...]");
582
+ err(" Verifies Authority Receipts OFFLINE — signature, content binding, and (for several files) chain order.");
528
583
  return 2;
529
584
  }
585
+ const texts = [];
586
+ for (const f of files) {
587
+ try {
588
+ texts.push(readFileSync(f, "utf8"));
589
+ }
590
+ catch (e) {
591
+ err(`✘ cannot read ${f}: ${e?.code === "ENOENT" ? "no such file" : e?.message ?? e} (nothing was verified)`);
592
+ return 1;
593
+ }
594
+ }
530
595
  let vcs;
531
596
  try {
532
- vcs = files.map((f) => parseReceiptStrict(readFileSync(f, "utf8"), f));
597
+ vcs = files.map((f, i) => parseReceiptStrict(texts[i], f));
533
598
  }
534
599
  catch (e) {
535
600
  log(`✘ INVALID ${String(e?.message ?? e)}`);
@@ -537,7 +602,7 @@ function cmdVerify(argsIn) {
537
602
  }
538
603
  let allOk = true;
539
604
  vcs.forEach((vc, i) => {
540
- const res = verifyReceipt(vc, expectedIssuer);
605
+ const res = verifyReceiptDoor(vc, expectedIssuer);
541
606
  allOk = allOk && res.valid;
542
607
  log(`${res.valid ? "✔ VALID " : "✘ INVALID"} ${files[i]}`);
543
608
  log(` record: ${res.recordId ?? "?"} issuer: ${res.issuer}${expectedIssuer ? "" : " (unpinned — pass --issuer to bind to your workspace's DID)"}`);
@@ -550,11 +615,15 @@ function cmdVerify(argsIn) {
550
615
  }
551
616
  return allOk ? 0 : 1;
552
617
  }
553
- async function cmdQuickstart() {
618
+ async function cmdQuickstart(opts = {}) {
554
619
  const t0 = Date.now();
555
620
  log("Decidio quickstart — gate an action, route it to a human, execute on approval, hold the proof.");
556
621
  log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : " (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)"}`);
557
622
  log("");
623
+ log(" This tour will: register one synthetic agent · mint a 7-DAY floor-limited token ·");
624
+ log(" write .env (or .env.quickstart) + .gitignore entries · create one synthetic decision");
625
+ log(" and its sealed record (records are permanent by design). Cleanup offer at the end.");
626
+ log("");
558
627
  const session = await acquireSession("The quickstart");
559
628
  if (!session)
560
629
  return 1;
@@ -563,30 +632,40 @@ async function cmdQuickstart() {
563
632
  const { did, privateKeyBase64 } = generateKeypair();
564
633
  const reg = await api("/api/agents/register", { method: "POST", body: JSON.stringify({ agentId, did }) }, session);
565
634
  if (!(reg.status >= 200 && reg.status < 300)) {
566
- log(`✘ registration failed (${reg.status}): ${JSON.stringify(reg.body)}`);
635
+ err(`✘ registration failed (${reg.status}): ${JSON.stringify(reg.body)}`);
567
636
  return 1;
568
637
  }
569
638
  const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart", expiresInDays: 7 }) }, session);
570
639
  if (!(mint.status >= 200 && mint.status < 300) || !mint.body?.token) {
571
- log(`✘ token mint failed (${mint.status}): ${JSON.stringify(mint.body)}`);
640
+ err(`✘ token mint failed (${mint.status}): ${JSON.stringify(mint.body)}`);
572
641
  return 1;
573
642
  }
574
643
  const envOut = {
575
644
  DECIDIO_API_URL: API, DECIDIO_AGENT_ID: agentId, DECIDIO_AGENT_DID: reg.body?.did ?? did,
576
645
  DECIDIO_AGENT_KEY: privateKeyBase64, DECIDIO_API_TOKEN: mint.body.token,
577
- DECIDIO_WORKSPACE_ID: mint.body.workspaceId ?? "", DECIDIO_WEBHOOK_SECRET: readEnvFile().DECIDIO_WEBHOOK_SECRET || randomBytes(32).toString("hex"),
646
+ DECIDIO_WORKSPACE_ID: mint.body.workspaceId ?? "",
647
+ ...(mint.body.jti ? { DECIDIO_TOKEN_JTI: mint.body.jti } : {}),
578
648
  };
579
649
  let envFile = ENV_PATH;
580
650
  if (readEnvFile().DECIDIO_AGENT_KEY) {
581
651
  envFile = ".env.quickstart";
582
- writeFileSync(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
652
+ ensureGitignore();
653
+ if (existsSync(envFile)) {
654
+ const prev = readFileSync(envFile, "utf8");
655
+ const prevId = prev.match(/^DECIDIO_AGENT_ID=(.+)$/m)?.[1];
656
+ if (prevId && prevId !== agentId) {
657
+ writeSecretFile(".env.quickstart.bak", prev);
658
+ log(` ✔ previous tour agent (${prevId}) backed up to .env.quickstart.bak — its key would otherwise be unrecoverable`);
659
+ }
660
+ }
661
+ writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
583
662
  log(` ✔ this directory already has an agent in .env — tour credentials went to ${envFile} (your agent is untouched)`);
584
663
  }
585
664
  else {
665
+ ensureGitignore();
586
666
  upsertEnv(envOut);
587
667
  }
588
- ensureGitignore();
589
- log(` ✔ key generated locally (public half registered) · floor-limited token minted · ${envFile} written`);
668
+ log(` ✔ key generated locally (public half registered) · 7-day floor-limited token minted${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ""} · ${envFile} written`);
590
669
  log("");
591
670
  log(`② Gating a demo action — the tour runs this (blocking mode: watch it live):`);
592
671
  log("");
@@ -603,26 +682,50 @@ async function cmdQuickstart() {
603
682
  };
604
683
  const payInvoice = withApproval(async (inv) => ({ paid: true, id: inv.id }), (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }), cfg);
605
684
  const running = payInvoice({ id: "INV-2026-001", amount: 86_000 });
606
- running.catch(() => { });
685
+ let earlyError;
686
+ running.catch((e) => { earlyError = e; });
607
687
  const waitStart = Date.now();
608
- while (!decisionId && Date.now() - waitStart < 30_000)
688
+ while (!decisionId && !earlyError && Date.now() - waitStart < 30_000)
609
689
  await new Promise((r) => setTimeout(r, 250));
690
+ if (!decisionId && earlyError instanceof DecidioRateLimitError) {
691
+ err(`✘ this workspace's governed-action allowance is used up${earlyError.limit != null ? ` (${earlyError.limit} actions/hour on lab workspaces)` : ""}.`);
692
+ err(` Nothing was routed or executed — the cap is the sandbox protecting itself, not a fault${earlyError.resetAt ? `. It resets ${earlyError.resetAt}` : ""}.`);
693
+ err(` Re-run the tour after the reset (or talk to us about a production workspace).`);
694
+ return 1;
695
+ }
696
+ if (!decisionId && earlyError) {
697
+ err(`✘ the gate refused before routing: ${earlyError?.message ?? earlyError}`);
698
+ return 1;
699
+ }
610
700
  if (!decisionId) {
611
- log("✘ the gate did not route within 30s — check connectivity (npx @decidio/sdk doctor) and re-run.");
701
+ err("✘ the gate did not route within 30s — check connectivity (npx @decidio/sdk doctor) and re-run.");
612
702
  return 1;
613
703
  }
614
704
  log(` ✔ $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — this agent has no auto-approve rule,`);
615
705
  log(` so deny-by-default sends everything to a person. Nothing executes until someone decides.`);
616
706
  log("");
617
- if (process.stdin.isTTY) {
707
+ const interactive = process.stdin.isTTY;
708
+ if (interactive) {
618
709
  await ask(`③ YOU are the approver. Press Enter to approve it here (or approve it in your Decidio queue first, then Enter): `);
619
710
  }
711
+ else if (opts.autoApproveDemo) {
712
+ log("③ --auto-approve-demo: approving with your session credential. This is AUTOMATED authority");
713
+ log(" (a flag you passed, not a human decision in the moment) — fine for CI, not the real story.");
714
+ }
620
715
  else {
621
- log("③ Headless terminal — auto-approving with your session so the tour can continue.");
716
+ log("③ This terminal can't put a human in the loop, and an approval IS a human act — the tour");
717
+ log(" stops here rather than approve its own request. The decision is routed and waiting:");
718
+ log("");
719
+ log(` npx @decidio/sdk approvals approve ${decisionId} (or approve it in your Decidio app)`);
720
+ log("");
721
+ log(" Then re-run the quickstart interactively to watch the whole loop — or, for CI, re-run");
722
+ log(" with --auto-approve-demo to let the tour approve itself under that explicit label.");
723
+ log(` Tidy up this tour's agent + token any time: npx @decidio/sdk quickstart cleanup`);
724
+ process.exit(0);
622
725
  }
623
- const ap = await api(`/api/decisions/${encodeURIComponent(decisionId)}/approve`, { method: "POST", body: JSON.stringify({ reason: "quickstart" }) }, session);
726
+ const ap = await api(`/api/decisions/${encodeURIComponent(decisionId)}/approve`, { method: "POST", body: JSON.stringify({ reason: opts.autoApproveDemo && !interactive ? "quickstart --auto-approve-demo (automated)" : "quickstart" }) }, session);
624
727
  if (!(ap.status >= 200 && ap.status < 300)) {
625
- log(`✘ approve failed (${ap.status}): ${JSON.stringify(ap.body)}`);
728
+ err(`✘ approve failed (${ap.status}): ${JSON.stringify(ap.body)}`);
626
729
  return 1;
627
730
  }
628
731
  log(` ✔ approved${ap.body?.alreadyResolved ? " (it was already approved from your app — even better)" : ""}`);
@@ -631,7 +734,7 @@ async function cmdQuickstart() {
631
734
  result = await running;
632
735
  }
633
736
  catch (e) {
634
- log(`✘ the gated call did not complete: ${e?.message ?? e}`);
737
+ err(`✘ the gated call did not complete: ${e?.message ?? e}`);
635
738
  return 1;
636
739
  }
637
740
  log(` ✔ your function executed AFTER approval: ${JSON.stringify(result)}`);
@@ -639,7 +742,7 @@ async function cmdQuickstart() {
639
742
  log("④ Downloading the sealed Authority Receipt and verifying it OFFLINE…");
640
743
  const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
641
744
  if (!(rec.status >= 200 && rec.status < 300) || !rec.body?.credential) {
642
- log(`✘ receipt fetch failed (${rec.status})`);
745
+ err(`✘ receipt fetch failed (${rec.status})`);
643
746
  return 1;
644
747
  }
645
748
  const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
@@ -663,8 +766,129 @@ async function cmdQuickstart() {
663
766
  log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
664
767
  log("");
665
768
  log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")} · Next: the README's 7-step quickstart (durable, workers, adapters).`);
769
+ let doCleanup = false;
770
+ if (interactive) {
771
+ const answer = await ask(`\nKeep this agent for development? [Enter = keep · c + Enter = clean up now]: `);
772
+ doCleanup = answer.trim().toLowerCase() === "c";
773
+ if (!doCleanup)
774
+ log(` Kept. Tidy up any time: npx @decidio/sdk quickstart cleanup`);
775
+ }
776
+ else if (opts.autoApproveDemo && !opts.keepAgent) {
777
+ doCleanup = true;
778
+ log(`\nAutomated run: cleaning the tour up (pass --keep-agent to keep the agent + token).`);
779
+ }
780
+ if (doCleanup)
781
+ await quickstartCleanup(session, envFile);
666
782
  return v.valid ? 0 : 1;
667
783
  }
784
+ async function quickstartCleanup(session, envFile) {
785
+ const env = (() => {
786
+ const out = {};
787
+ if (existsSync(envFile))
788
+ for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
789
+ const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
790
+ if (m)
791
+ out[m[1]] = m[2];
792
+ }
793
+ return out;
794
+ })();
795
+ if (env.DECIDIO_TOKEN_JTI) {
796
+ const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(env.DECIDIO_TOKEN_JTI)}`, { method: "DELETE" }, session);
797
+ if (rev.status >= 200 && rev.status < 300)
798
+ log(` ✔ token revoked (jti ${env.DECIDIO_TOKEN_JTI})`);
799
+ else
800
+ log(` ⚠ token revoke returned ${rev.status} — it still expires on its own within 7 days. List tokens: npx @decidio/sdk tokens list`);
801
+ }
802
+ else {
803
+ log(` ⚠ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — revoke by hand: npx @decidio/sdk tokens list, then tokens revoke <jti>. It expires on its own within 7 days.`);
804
+ }
805
+ if (existsSync(envFile)) {
806
+ const TOUR_KEYS = new Set(["DECIDIO_API_URL", "DECIDIO_AGENT_ID", "DECIDIO_AGENT_DID", "DECIDIO_AGENT_KEY", "DECIDIO_API_TOKEN", "DECIDIO_WORKSPACE_ID", "DECIDIO_TOKEN_JTI"]);
807
+ const kept = readFileSync(envFile, "utf8").split(/\r?\n/).filter((line) => {
808
+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=/);
809
+ return m ? !TOUR_KEYS.has(m[1]) : line.trim() !== "";
810
+ });
811
+ if (kept.length === 0) {
812
+ rmSync(envFile);
813
+ log(` ✔ ${envFile} deleted (the agent's local key is gone — the registered agent is now inert)`);
814
+ }
815
+ else {
816
+ writeSecretFile(envFile, kept.join("\n") + "\n");
817
+ log(` ✔ tour credentials removed from ${envFile} — its ${kept.length} other line${kept.length === 1 ? "" : "s"} untouched (the agent's local key is gone — the registered agent is now inert)`);
818
+ }
819
+ }
820
+ const tourAgent = env.DECIDIO_AGENT_ID;
821
+ if (tourAgent) {
822
+ const cards = await api("/api/decisions/cards", {}, session);
823
+ const stranded = (cards.body?.cards ?? []).filter((c) => c.isAgentRequest && c.status !== "sealed" && typeof c.title === "string" && c.title.startsWith(`${tourAgent}:`));
824
+ for (const c of stranded) {
825
+ log(` ⚠ this tour's decision ${c.id} is still PENDING in your workspace queue — clear it with:`);
826
+ log(` npx @decidio/sdk approvals reject ${c.id} "quickstart cleanup"`);
827
+ }
828
+ }
829
+ log(` • What remains, by design: the sealed decision record (records are permanent — that is the product)`);
830
+ log(` and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap).`);
831
+ return 0;
832
+ }
833
+ async function cmdTokens(sub, jti) {
834
+ if (sub !== "list" && sub !== "revoke") {
835
+ err("usage: npx @decidio/sdk tokens list");
836
+ err(" npx @decidio/sdk tokens revoke <jti>");
837
+ err(" Agent tokens are shown by jti (their revocation id) — the token VALUE is never shown again after minting.");
838
+ return 2;
839
+ }
840
+ if (sub === "revoke" && !jti) {
841
+ err("usage: npx @decidio/sdk tokens revoke <jti> (find the jti with: npx @decidio/sdk tokens list)");
842
+ return 2;
843
+ }
844
+ let bearer;
845
+ let r = await api("/api/workspace/agent-token");
846
+ if (r.status === 401 || r.status === 403) {
847
+ const session = await acquireSession("Managing tokens");
848
+ if (!session)
849
+ return 1;
850
+ bearer = session;
851
+ if (sub === "list")
852
+ r = await api("/api/workspace/agent-token", {}, bearer);
853
+ }
854
+ if (sub === "revoke") {
855
+ const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
856
+ if (rev.status >= 200 && rev.status < 300) {
857
+ if (rev.body?.changed === false) {
858
+ log(`• ${jti} was already revoked — nothing changed.`);
859
+ log(` Revocation is permanent; there is no re-revoke. See it: npx @decidio/sdk tokens list`);
860
+ return 1;
861
+ }
862
+ log(`✔ revoked ${jti} — the token is dead now (its agent stays registered; mint a new one with init).`);
863
+ return 0;
864
+ }
865
+ if (rev.status === 404) {
866
+ err(`✘ no token with jti "${jti}" — list them: npx @decidio/sdk tokens list`);
867
+ return 1;
868
+ }
869
+ err(`✘ revoke failed (${rev.status}): ${JSON.stringify(rev.body).slice(0, 300)}`);
870
+ return 1;
871
+ }
872
+ if (!(r.status >= 200 && r.status < 300)) {
873
+ err(`✘ could not list tokens (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
874
+ return 1;
875
+ }
876
+ const tokens = r.body?.tokens ?? r.body ?? [];
877
+ if (!Array.isArray(tokens) || !tokens.length) {
878
+ log("No agent tokens in this workspace.");
879
+ return 0;
880
+ }
881
+ const status = (t) => t.revokedAt ? `REVOKED ${String(t.revokedAt).slice(0, 10)}`
882
+ : (t.expiresAt && Date.parse(t.expiresAt) < Date.now()) ? "expired" : "active";
883
+ log(`Agent tokens (${tokens.length}):`);
884
+ for (const t of tokens)
885
+ log(` ${t.jti} ${status(t)} agent=${t.agentId ?? "?"} label=${t.label ?? ""} expires=${t.expiresAt ?? "?"}`);
886
+ const al = r.body?.allowance;
887
+ if (al?.limit != null)
888
+ log(`\nGoverned-action allowance: ${al.remaining}/${al.limit} remaining this hour${al.resetAt ? ` (resets ${al.resetAt})` : ""}.`);
889
+ log(`\nRevoke one: npx @decidio/sdk tokens revoke <jti>`);
890
+ return 0;
891
+ }
668
892
  function cmdDev(target, port) {
669
893
  log(`decidio dev relay on http://localhost:${port}/decidio/resume → forwarding to ${target}`);
670
894
  createServer((req, res) => {
@@ -701,6 +925,13 @@ const USAGE = `decidio — the Decidio SDK command line
701
925
  in, or writes files unless you ask it to.)
702
926
 
703
927
  npx @decidio/sdk quickstart the guided two-minute tour: gate → approve → execute → proof
928
+ (interactive: YOUR Enter is the approval. Headless it stops
929
+ at the routed decision; --auto-approve-demo for CI, labeled
930
+ automated; ends with a keep-or-cleanup choice)
931
+ npx @decidio/sdk quickstart cleanup revoke the tour's token + delete its local credentials
932
+ npx @decidio/sdk tokens list agent tokens with status (active/expired/REVOKED) by jti —
933
+ token values are never re-shown
934
+ npx @decidio/sdk tokens revoke <jti> kill a token now instead of waiting out its lifetime
704
935
  npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
705
936
  --unique append a random suffix (collision-proof samples)
706
937
  --force replace a different agent already in this .env
@@ -715,12 +946,15 @@ const USAGE = `decidio — the Decidio SDK command line
715
946
  npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
716
947
  npx @decidio/sdk --version print the CLI version
717
948
 
949
+ Every command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
950
+ DECIDIO_API_URL says otherwise; the target host is announced on stderr each run.
718
951
  Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error.
952
+ Errors and usage go to stderr; stdout carries results only (safe to pipe).
719
953
  Docs: the README that shipped with this package (quickstart, durable resume, adapters).`;
720
954
  async function main(argv) {
721
955
  const [cmd, a, b, ...rest] = argv.slice(2);
722
956
  const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
723
- if (cmd === "--version" || cmd === "-v" || cmd === "version") {
957
+ if (cmd === "--version" || cmd === "-v" || cmd === "-V" || cmd === "version") {
724
958
  log(VERSION);
725
959
  return 0;
726
960
  }
@@ -732,15 +966,80 @@ async function main(argv) {
732
966
  log(USAGE);
733
967
  return 0;
734
968
  }
969
+ const argsIn = argv.slice(3);
970
+ const KNOWN = {
971
+ quickstart: { flags: ["--auto-approve-demo", "--keep-agent"] },
972
+ init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
973
+ doctor: { flags: [] },
974
+ approvals: { flags: ["--json", "--mine"] },
975
+ receipt: { flags: ["--json"] },
976
+ verify: { flags: [], valued: ["--issuer"] },
977
+ tokens: { flags: [] },
978
+ dev: { flags: [], valued: ["--target", "--port"] },
979
+ };
980
+ const spec = KNOWN[cmd];
981
+ if (spec) {
982
+ const valued = spec.valued ?? [];
983
+ const positionals = [];
984
+ for (let i = 0; i < argsIn.length; i++) {
985
+ const a = argsIn[i];
986
+ if (!a.startsWith("--")) {
987
+ positionals.push(a);
988
+ continue;
989
+ }
990
+ if (valued.includes(a)) {
991
+ i++;
992
+ continue;
993
+ }
994
+ if (valued.some((v) => a.startsWith(v + "=")))
995
+ continue;
996
+ if (!spec.flags.includes(a)) {
997
+ err(`✘ unknown option "${a}" for \`${cmd}\` — refusing to guess (a flag you meant to matter must never be silently ignored).\n\n${USAGE}`);
998
+ return 2;
999
+ }
1000
+ }
1001
+ const MAX_POS = { quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0 };
1002
+ const exemptTail = (cmd === "approvals" && (positionals[0] === "approve" || positionals[0] === "reject")) || cmd === "verify";
1003
+ if (!exemptTail && cmd === "approvals" && positionals.length > 0) {
1004
+ err(`✘ unknown approvals subcommand "${positionals[0]}" — use approve or reject (or no subcommand to list).`);
1005
+ return 2;
1006
+ }
1007
+ if (!exemptTail && MAX_POS[cmd] !== undefined && positionals.length > MAX_POS[cmd]) {
1008
+ err(`✘ unexpected argument "${positionals[MAX_POS[cmd]]}" for \`${cmd}\` — refusing to guess (an argument you meant to matter must never be silently ignored).\n\n${USAGE}`);
1009
+ return 2;
1010
+ }
1011
+ if (cmd === "quickstart" && positionals.length === 1 && positionals[0] !== "cleanup") {
1012
+ err(`✘ unknown quickstart subcommand "${positionals[0]}" — the only one is: npx @decidio/sdk quickstart cleanup`);
1013
+ return 2;
1014
+ }
1015
+ if (cmd === "tokens" && positionals[0] === "list" && positionals.length > 1) {
1016
+ err(`✘ unexpected argument "${positionals[1]}" for \`tokens list\` — it takes none (an argument you meant to matter must never be silently ignored).`);
1017
+ return 2;
1018
+ }
1019
+ }
735
1020
  switch (cmd) {
736
- case "quickstart": return cmdQuickstart();
1021
+ case "quickstart": {
1022
+ if (argsIn.includes("cleanup")) {
1023
+ const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
1024
+ : (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH : undefined;
1025
+ if (!envFile) {
1026
+ log("Nothing to clean up here: no .env.quickstart, and .env doesn't belong to a quickstart agent.");
1027
+ return 0;
1028
+ }
1029
+ const session = await acquireSession("Cleaning the quickstart up");
1030
+ if (!session)
1031
+ return 1;
1032
+ return quickstartCleanup(session, envFile);
1033
+ }
1034
+ return cmdQuickstart({ autoApproveDemo: argsIn.includes("--auto-approve-demo"), keepAgent: argsIn.includes("--keep-agent") });
1035
+ }
737
1036
  case "init": {
738
- const argsIn = argv.slice(3);
739
1037
  const unique = argsIn.includes("--unique");
740
1038
  const expIdx = argsIn.indexOf("--expires-days");
741
- const expRaw = expIdx >= 0 ? argsIn[expIdx + 1] : undefined;
742
- if (expIdx >= 0 && (!expRaw || !/^\d+$/.test(expRaw) || Number(expRaw) < 1)) {
743
- log("✘ --expires-days needs a positive whole number of days (e.g. --expires-days 30).");
1039
+ const expEq = argsIn.find((x) => x.startsWith("--expires-days="));
1040
+ const expRaw = expIdx >= 0 ? argsIn[expIdx + 1] : expEq ? expEq.slice("--expires-days=".length) : undefined;
1041
+ if ((expIdx >= 0 || expEq) && (!expRaw || !/^\d+$/.test(expRaw) || Number(expRaw) < 1)) {
1042
+ err("✘ --expires-days needs a positive whole number of days (e.g. --expires-days 30).");
744
1043
  return 2;
745
1044
  }
746
1045
  const nameArg = argsIn.find((x, i) => !x.startsWith("-") && !(expIdx >= 0 && i === expIdx + 1));
@@ -751,25 +1050,38 @@ async function main(argv) {
751
1050
  }
752
1051
  case "doctor": return cmdDoctor();
753
1052
  case "approvals": {
754
- const argsIn = argv.slice(3);
755
1053
  const positional = argsIn.filter((x) => !x.startsWith("-"));
756
1054
  return cmdApprovals(positional[0], positional[1], positional.slice(2).join(" ") || undefined, { json: argsIn.includes("--json"), mine: argsIn.includes("--mine") });
757
1055
  }
758
- case "receipt": {
759
- const argsIn = argv.slice(3);
760
- return cmdReceipt(argsIn.find((x) => !x.startsWith("-")), argsIn.includes("--json"));
1056
+ case "receipt": return cmdReceipt(argsIn.find((x) => !x.startsWith("-")), argsIn.includes("--json"));
1057
+ case "verify": return cmdVerify(argsIn);
1058
+ case "tokens": {
1059
+ const positional = argsIn.filter((x) => !x.startsWith("-"));
1060
+ return cmdTokens(positional[0], positional[1]);
761
1061
  }
762
- case "verify": return cmdVerify(argv.slice(3));
763
1062
  case "dev": {
764
- const args = argv.slice(3);
765
- const flag = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
1063
+ const flag = (name) => {
1064
+ const i = argsIn.indexOf(name);
1065
+ if (i >= 0)
1066
+ return argsIn[i + 1];
1067
+ const eq = argsIn.find((x) => x.startsWith(name + "="));
1068
+ return eq ? eq.slice(name.length + 1) : undefined;
1069
+ };
766
1070
  const target = flag("--target") || process.env.DECIDIO_AGENT_RESUME_URL || "http://localhost:4100/decidio/resume";
767
- const port = Number(flag("--port") || process.env.DECIDIO_DEV_PORT || 4099);
768
- return cmdDev(target, port);
1071
+ const portRaw = flag("--port") || process.env.DECIDIO_DEV_PORT || "4099";
1072
+ if (!/^\d+$/.test(portRaw) || Number(portRaw) < 1 || Number(portRaw) > 65535) {
1073
+ err(`✘ --port needs a number between 1 and 65535 (got "${portRaw}").`);
1074
+ return 2;
1075
+ }
1076
+ if (!/^https?:\/\//.test(target)) {
1077
+ err(`✘ --target needs an http(s) URL to forward the webhook to (got "${target}").`);
1078
+ return 2;
1079
+ }
1080
+ return cmdDev(target, Number(portRaw));
769
1081
  }
770
1082
  default:
771
- log(`✘ unknown command "${cmd}"\n\n${USAGE}`);
772
- return 1;
1083
+ err(`✘ unknown command "${cmd}"\n\n${USAGE}`);
1084
+ return 2;
773
1085
  }
774
1086
  }
775
1087
  main(process.argv).then((code) => { if (code !== 0)
package/dist/index.d.ts CHANGED
@@ -45,6 +45,12 @@ export declare class DecidioTimeoutError extends Error {
45
45
  decisionId: string;
46
46
  constructor(decisionId: string);
47
47
  }
48
+ export declare class DecidioRateLimitError extends Error {
49
+ limit: number | undefined;
50
+ remaining: number | undefined;
51
+ resetAt: string | undefined;
52
+ constructor(limit: number | undefined, remaining: number | undefined, resetAt: string | undefined, serverMessage?: string);
53
+ }
48
54
  export declare class DecidioNetworkError extends Error {
49
55
  endpoint: string;
50
56
  op: string;
package/dist/index.js CHANGED
@@ -32,12 +32,28 @@ export class DecidioTimeoutError extends Error {
32
32
  }
33
33
  }
34
34
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
35
+ export class DecidioRateLimitError extends Error {
36
+ limit;
37
+ remaining;
38
+ resetAt;
39
+ constructor(limit, remaining, resetAt, serverMessage) {
40
+ super(`[decidio] governed-action rate limit reached${limit != null ? ` (${limit}/hour on this workspace)` : ""}${resetAt ? ` — resets ${resetAt}` : ""}. ` +
41
+ `${serverMessage ? serverMessage + " " : ""}Nothing was routed or executed; retry after the reset.`);
42
+ this.limit = limit;
43
+ this.remaining = remaining;
44
+ this.resetAt = resetAt;
45
+ this.name = "DecidioRateLimitError";
46
+ }
47
+ }
35
48
  export class DecidioNetworkError extends Error {
36
49
  endpoint;
37
50
  op;
38
51
  code;
39
52
  constructor(endpoint, op, code, cause) {
40
53
  super(`[decidio] could not reach ${endpoint} during ${op}${code ? ` (${code})` : ""}. ` +
54
+ (/localhost|127\.0\.0\.1/.test(endpoint) && code === "ECONNREFUSED"
55
+ ? `A refused localhost connection usually means the SDK never saw your config: if your settings live in .env, run node with --env-file=.env (or export DECIDIO_API_URL — the hosted sandbox is https://decidio-api.onrender.com). `
56
+ : "") +
41
57
  `Nothing was executed locally and no verdict was received — if a request reached Decidio it is ` +
42
58
  `visible in your queue. Check the network/URL (DECIDIO_API_URL) and retry; retrying is safe ` +
43
59
  `(a retry creates a new request — it can never double-execute).`);
@@ -63,6 +79,10 @@ async function post(cfg, path, body) {
63
79
  headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
64
80
  body: JSON.stringify(body),
65
81
  });
82
+ if (res.status === 429) {
83
+ const b = await res.json().catch(() => null);
84
+ throw new DecidioRateLimitError(b?.limit, b?.remaining, b?.resetAt, b?.message);
85
+ }
66
86
  if (!res.ok)
67
87
  throw new Error(`decidio ${path} ${res.status}: ${await res.text().catch(() => "")}`);
68
88
  return res.json();
package/openapi.yaml CHANGED
@@ -5,7 +5,7 @@
5
5
  openapi: 3.1.0
6
6
  info:
7
7
  title: Decidio Agent Gate
8
- version: 0.1.7
8
+ version: 0.1.8
9
9
  description: >
10
10
  Authorize an AI-agent action (proceed | route | block), confirm the captured outcome, and
11
11
  receive the signed resume webhook when a routed action is decided. Every outcome seals a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decidio/sdk",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "One-line approval gate for AI-agent actions. Wrap a risky tool call with guard.protect(); Decidio's policy engine decides proceed | route | block, a human approves routed actions, and every outcome is sealed as a verifiable Authority Receipt.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -81,7 +81,7 @@
81
81
  "prepublishOnly": "npm run typecheck && npm run build"
82
82
  },
83
83
  "dependencies": {
84
- "@decidio/verify": "^0.1.3"
84
+ "@decidio/verify": "0.1.7"
85
85
  },
86
86
  "author": "OmniTwin Technologies Inc.",
87
87
  "bugs": {