@decidio/sdk 0.5.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -4,6 +4,39 @@ Release notes for the client SDK. The Python package `decidio` ships the same ch
4
4
  version (the two are conformance-locked twins). Each entry names what changed, what a reader must do
5
5
  on upgrade, and what proved it.
6
6
 
7
+ ## 0.6.0 — `quickstart --demo`: the whole tour on your machine, no account
8
+
9
+ An uninvited developer could not run the tour at all: a workspace needs an invite (no open signup, by ruling). A
10
+ hosted sandbox was designed, red-teamed by the privacy council (BLOCK as written; eight design-level highs) and then
11
+ costed against the alternatives: the cheapest, least-maintenance path for a stranger's first two minutes is a LOCAL
12
+ demo with a hosted page for the phone moment. Founder ruling 2026-09-05.
13
+
14
+ - **`npx @decidio/sdk quickstart --demo`** runs the SAME tour code against an in-process stand-in of the Decidio API
15
+ on 127.0.0.1 (`demo-gate.ts`, shipped in this package): register, mint, gate, route, approve or reject, execute
16
+ once, and a REAL Ed25519 receipt signed by a key generated for the run — verifiable offline with the shipped
17
+ verifier, and provably NOT the hosted issuer's (pinning it to the hosted DID fails). Nothing hosted, no account, no
18
+ workspace: the tour runs in a temporary folder and leaves exactly one file behind, the receipt (never over an
19
+ existing one). Its content says `recordSeries: local-demo`.
20
+ - **The web and phone doors still work.** The stand-in asks a hosted RELAY (`POST /demo/relay`) for a code, a link to
21
+ the demo decision page (`app.decidioai.com/demo/<code>`) and a QR; the page shows the product's decision surface for
22
+ the tour's fixed scenario and writes one word — approve or reject — which the stand-in reads while the tour waits.
23
+ The relay holds no account, no record and no personal data; a code lives ten minutes, takes one verdict, and every
24
+ failure answers with one shape. Offline (relay unreachable) the tour is terminal-only and says so.
25
+ - **Honesty is asserted, not hoped:** the frames say LOCAL DEMO; ④ says the issuer is a key this demo generated and
26
+ where real tours pin theirs; the smoke plants the forbidden phrases ("PINNED to your workspace issuer", "hosted
27
+ synthetic sandbox", "your trust anchor") and asserts the directory diff is the receipt alone; a decision made on the
28
+ page reaches the terminal with no keystroke. The sign-in dead end now offers `--demo`.
29
+ - **On a phone the demo IS the phone app's screen.** A phone that opens the demo link (the QR, or the link on a phone)
30
+ lands on the phone build's own decision screen — the same Detail the installed app renders, with Approve and Reject
31
+ where they always are — not the desktop panel squeezed into a phone viewport (founder UAT round 2). It holds no
32
+ session and says LOCAL DEMO in one line; the decided moment says the terminal seals. Host-side only: nothing in this
33
+ package changed for it.
34
+ - **The demo leaves your sign-in alone.** `quickstart --demo` never signs in, so its cleanup no longer ends a stored
35
+ sign-in that happens to be on the computer (it did, in the founder's UAT; the smoke now plants one and asserts it
36
+ survives, silently).
37
+ - TypeScript CLI first — `npx @decidio/sdk quickstart` is where an uninvited developer arrives; the Python twin gains
38
+ `--demo` when asked for (the version bump is in lockstep).
39
+
7
40
  ## 0.5.0 — `verify` pins by default
8
41
 
9
42
  **Breaking, on purpose (founder ruling 2026-09-04, after the prodsec review of 0.4.3).** Every CLI verify door - `npx
package/README.md CHANGED
@@ -9,7 +9,8 @@ is the two-minute tour, the sign-in and the token admin around it. Install, wrap
9
9
  ## The two-minute tour
10
10
 
11
11
  ```bash
12
- npx @decidio/sdk quickstart
12
+ npx @decidio/sdk quickstart # against your workspace (you need an invite - request one below)
13
+ npx @decidio/sdk quickstart --demo # no invite yet? the same tour on this machine, against a local stand-in of the API
13
14
  ```
14
15
 
15
16
  One command, guided: it registers a fresh agent (key generated locally), gates a demo action
@@ -653,6 +654,7 @@ that fits every other confirm error.
653
654
  - `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) Also ends the stored sign-in.
654
655
  - **Update check.** Network commands ask the public npm registry once a day whether a newer version exists; if so, an interactive run asks `Upgrade now? [y/n]` first (`y` upgrades the way you installed - `npm i @decidio/sdk@latest` in the project for a local install, `-g` for a global one, and under `npx` it prints the `@latest` form - then re-runs your command once; `n` exits), a headless run prints the notice and continues (`DECIDIO_REQUIRE_LATEST=1` makes it exit). `DECIDIO_NO_UPDATE_CHECK=1` skips it. Nothing is sent to Decidio; `verify` never checks.
655
656
  - **The token recovery journal** (`.decidio-token-journal`, owner-only, gitignored; ids and timestamps, never a token value; append-only by the CLI's behaviour — not OS-enforced or tamper-evident). One JSON row per event: `minted` (a token was issued, written BEFORE its credential file), `retired how=persisted` (the credential file now holds it - the token is live and that file is its handle; not revoked), `retired how=revoked` (revoked server-side), `probe` (a durability check). `doctor` names rows still outstanding; `quickstart cleanup` sweeps them.
657
+ - `quickstart --demo` — the same tour with no account: an in-process stand-in of the API on 127.0.0.1 (shipped in this package) answers every door, a key generated for the run signs a real receipt (its content says `local-demo`; pinning it to the hosted issuer fails), and the web/phone door is a hosted demo decision page reached through a one-word relay (no account, no record, ten minutes). Your folder gains exactly one file, the receipt. Offline, the tour is terminal-only and says so.
656
658
  - `quickstart --phone web` — the QR at the human step opens the phone web rendering even when the Decidio app is installed (by default it opens the app when there is one).
657
659
  - `login [email]` / `logout` — sign in ONCE (the CLI emails you a link; you paste it) and forget that sign-in on this computer (the session token itself stays valid until it expires (up to 30 days) — there is no server-side revocation yet). The session is stored in your user profile, owner-only, bound to the terminal window that created it, for 30 days; `DECIDIO_SESSION_TOKEN` in the environment outranks it (headless shells).
658
660
  - `dev [--target URL] [--port N]` — local relay for the resume webhook (operator setups)
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, openSync, writeSync, fsyncSync, closeSync, renameSync, mkdirSync } from "node:fs";
2
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, openSync, writeSync, fsyncSync, closeSync, renameSync, mkdirSync, mkdtempSync, readdirSync, copyFileSync } from "node:fs";
3
3
  import { randomBytes } from "node:crypto";
4
- import { homedir } from "node:os";
4
+ import { homedir, tmpdir } from "node:os";
5
5
  import { join as joinPath, resolve as resolvePath, sep as pathSep } from "node:path";
6
6
  import { createServer } from "node:http";
7
7
  import { execFileSync, spawnSync } from "node:child_process";
@@ -13,6 +13,7 @@ import { generateKeypair, assertSafeTransport, redactUrl } from "./signing.js";
13
13
  import { hardenFile } from "./resume.js";
14
14
  import { withApproval, DecidioRateLimitError, DecidioRejectedError, DecidioTimeoutError, didFromPrivateKey } from "./index.js";
15
15
  import { verifyReceipt, runVerify, pinRequiredMessage } from "@decidio/verify";
16
+ import { startDemoGate } from "./demo-gate.js";
16
17
  const VERSION = (() => {
17
18
  try {
18
19
  return createRequire(import.meta.url)("../package.json").version;
@@ -65,7 +66,13 @@ if (!OFFLINE_CMD)
65
66
  if (process.env[k] === undefined)
66
67
  process.env[k] = v;
67
68
  }
68
- const API = (process.env.DECIDIO_API_URL ?? "https://decidio-api.onrender.com").replace(/\/$/, "");
69
+ let API = (process.env.DECIDIO_API_URL ?? "https://decidio-api.onrender.com").replace(/\/$/, "");
70
+ let DEMO = false;
71
+ function enterDemo(url) { API = url; process.env.DECIDIO_API_URL = url; DEMO = true; }
72
+ const pinWords = () => (DEMO ? "PINNED to this demo's own key" : "PINNED to your workspace issuer");
73
+ const issuerNote = () => (DEMO
74
+ ? "· a key this demo generated for this run — real tours pin the host's published DID at https://decidio-api.onrender.com/api/did"
75
+ : `· your trust anchor — first seen over this signed-in connection; cross-check ${API}/api/did, then verify --issuer <did> <receipt.json>`);
69
76
  const TOKEN = () => process.env.DECIDIO_API_TOKEN;
70
77
  const ENV_PATH = ".env";
71
78
  const log = (...a) => console.log(...a);
@@ -399,6 +406,8 @@ function recordKindOf(credential) {
399
406
  }
400
407
  }
401
408
  function sessionReminder() {
409
+ if (DEMO)
410
+ return;
402
411
  log(` ⚠ agent code launched from THIS terminal inherits your sign-in — run logout first (it forgets the sign-in here; the token itself stays valid until it expires); if you`);
403
412
  log(` exported DECIDIO_SESSION_TOKEN, unset it ${dim("(bash/zsh: unset DECIDIO_SESSION_TOKEN · PowerShell: Remove-Item Env:DECIDIO_SESSION_TOKEN)")}`);
404
413
  }
@@ -564,6 +573,7 @@ async function loginFlow(purpose, emailArg) {
564
573
  }
565
574
  log(` ✔ sign-in email requested for ${email} — check your inbox and spam; paste the link (or its login= code; single-use, 15 minutes)`);
566
575
  log(dim(` invite-only lab: a link arrives only for an invited address — not invited yet? request access: decidioai.com/#early-access`));
576
+ log(dim(` or see the whole story on this machine right now, no account: npx @decidio/sdk quickstart --demo`));
567
577
  const pasted = await ask(" link or code: ");
568
578
  if (!pasted) {
569
579
  log(" ✘ nothing pasted — re-run when you have the email.");
@@ -1146,6 +1156,38 @@ function valuedOpt(args, flag) {
1146
1156
  return eq ? eq.slice(flag.length + 1) : undefined;
1147
1157
  }
1148
1158
  async function cmdQuickstart(opts = {}) {
1159
+ if (!opts.demo)
1160
+ return cmdQuickstartInner(opts);
1161
+ const relayBase = SHELL_ENV.DECIDIO_DEMO_RELAY_URL === undefined ? "https://decidio-api.onrender.com" : (SHELL_ENV.DECIDIO_DEMO_RELAY_URL || null);
1162
+ const gate = await startDemoGate({ relayBase });
1163
+ enterDemo(gate.url);
1164
+ const origin = process.cwd();
1165
+ const work = mkdtempSync(joinPath(tmpdir(), "decidio-demo-"));
1166
+ process.chdir(work);
1167
+ try {
1168
+ return await cmdQuickstartInner(opts);
1169
+ }
1170
+ finally {
1171
+ process.chdir(origin);
1172
+ for (const f of readdirSync(work)) {
1173
+ if (!/^decidio-receipt-.*\.json$/.test(f))
1174
+ continue;
1175
+ let target = joinPath(origin, f);
1176
+ if (existsSync(target))
1177
+ target = joinPath(origin, f.replace(/\.json$/, `.${Date.now()}.json`));
1178
+ try {
1179
+ copyFileSync(joinPath(work, f), target);
1180
+ log(` receipt kept here: ${target.slice(origin.length + 1)} ${dim("(the demo's agent, token and files lived in a temporary folder, now removed)")}`);
1181
+ }
1182
+ catch (e) {
1183
+ err(` ⚠ could not copy the receipt into this folder (${e?.message ?? e}) — it is at ${joinPath(work, f)}`);
1184
+ }
1185
+ }
1186
+ rmSync(work, { recursive: true, force: true });
1187
+ await gate.close();
1188
+ }
1189
+ }
1190
+ async function cmdQuickstartInner(opts = {}) {
1149
1191
  const t0 = Date.now();
1150
1192
  EXPLAIN = !!opts.explain;
1151
1193
  if (opts.phone !== undefined && opts.phone !== "app" && opts.phone !== "web") {
@@ -1153,7 +1195,10 @@ async function cmdQuickstart(opts = {}) {
1153
1195
  return 2;
1154
1196
  }
1155
1197
  log(bold("Decidio quickstart") + " — gate an action, route it to a human, execute on approval, hold the proof.");
1156
- log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : dim(" (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)")}`);
1198
+ if (DEMO)
1199
+ log(` Target: LOCAL DEMO — an in-process stand-in for the Decidio API on this machine (${API}); nothing hosted, nothing kept`);
1200
+ else
1201
+ log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : dim(" (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)")}`);
1157
1202
  hostAnnounced = true;
1158
1203
  if (EXPLAIN) {
1159
1204
  log(" This tour will: register one synthetic agent · mint a 7-DAY floor-limited token ·");
@@ -1163,12 +1208,16 @@ async function cmdQuickstart(opts = {}) {
1163
1208
  log(" · create one synthetic decision and its sealed record (records are permanent by design) · wait up to");
1164
1209
  log(" 5 minutes for your y (approve) or n (reject) at the human step. Cleanup offer at the end.");
1165
1210
  }
1211
+ else if (DEMO) {
1212
+ log(dim(" Creates here: the receipt only (decidio-receipt-<id>.json). The demo's agent, token and working files live in a temporary"));
1213
+ log(dim(" folder that is removed at the end; the web/phone door is a hosted demo page — nothing signs in, no record exists anywhere else."));
1214
+ }
1166
1215
  else {
1167
1216
  log(dim(" Creates here: a synthetic agent + 7-day token (.env), .decidio-token-journal (revocation ids, no secrets), .gitignore entries incl."));
1168
1217
  log(dim(" .decidio-pending/ (the durable store), the receipt as decidio-receipt-<id>.json, one sealed record (permanent). --explain for the long form."));
1169
1218
  }
1170
1219
  log("");
1171
- const session = await acquireSession("The quickstart");
1220
+ const session = DEMO ? "demo-session" : await acquireSession("The quickstart");
1172
1221
  if (!session)
1173
1222
  return 1;
1174
1223
  const prior = readEnvFile();
@@ -1180,7 +1229,8 @@ async function cmdQuickstart(opts = {}) {
1180
1229
  err(`✘ cannot reach ${API}${reach.status ? ` (HTTP ${reach.status})` : " (no response)"} — check the network (or DECIDIO_API_URL) and re-run. Nothing was written.`);
1181
1230
  return 1;
1182
1231
  }
1183
- ensureGitignore();
1232
+ if (!DEMO)
1233
+ ensureGitignore();
1184
1234
  let agentId = `quickstart-${randomBytes(5).toString("hex")}`;
1185
1235
  if (EXPLAIN)
1186
1236
  log(` Registering agent "${agentId}"…`);
@@ -1304,7 +1354,7 @@ async function cmdQuickstart(opts = {}) {
1304
1354
  block("①", "Register a synthetic agent", [
1305
1355
  `${bold("agent")} ${agentId}`,
1306
1356
  `${bold("key")} generated locally · only the public half was registered`,
1307
- `${bold("token")} 7-day, floor-limited${mint.body.expiresAt ? ` · expires ${String(mint.body.expiresAt).slice(0, 10)}` : ""} · written to ${envFile}`,
1357
+ `${bold("token")} ${DEMO ? "demo token (one hour, this machine only)" : "7-day, floor-limited"}${mint.body.expiresAt ? ` · expires ${String(mint.body.expiresAt).slice(0, 10)}` : ""} · written to ${envFile}`,
1308
1358
  ]);
1309
1359
  if (EXPLAIN) {
1310
1360
  log(` The tour runs exactly this, the BLOCKING primitive (your real agent uses guard.protect instead:`);
@@ -1364,10 +1414,12 @@ async function cmdQuickstart(opts = {}) {
1364
1414
  const plainLink = `${APP_URL}/queue?focus=${encodeURIComponent(decisionId)}`;
1365
1415
  const rows = [
1366
1416
  `${bold("here")} Type y to approve or n to reject, then Enter ${dim("(waits up to 5 minutes)")}`,
1367
- `${bold("web app")} ${link(doorLink ?? plainLink)} ${dim(doorLink ? "(one-time sign-in · 5 minutes)" : "(sign in there if it asks)")}`,
1417
+ DEMO
1418
+ ? (doorLink ? `${bold("web page")} ${link(doorLink)} ${dim("(the demo decision page · nothing signs in)")}` : `${bold("web page")} ${dim("the web and phone doors need the internet — the demo relay was unreachable, so this run is terminal-only")}`)
1419
+ : `${bold("web app")} ${link(doorLink ?? plainLink)} ${dim(doorLink ? "(one-time sign-in · 5 minutes)" : "(sign in there if it asks)")}`,
1368
1420
  ];
1369
1421
  if (qr) {
1370
- rows.push(`${bold("your phone")} scan — the same one-time sign-in${opts.phone === "web" ? ", in the phone browser (--phone web)" : ": the Decidio app if it is installed, the browser if not"}`);
1422
+ rows.push(DEMO ? `${bold("your phone")} scan — the same demo decision page, in your phone's browser` : `${bold("your phone")} scan — the same one-time sign-in${opts.phone === "web" ? ", in the phone browser (--phone web)" : ": the Decidio app if it is installed, the browser if not"}`);
1371
1423
  for (const l of qr.split("\n"))
1372
1424
  if (l.trim().length)
1373
1425
  rows.push(" " + l);
@@ -1491,17 +1543,17 @@ async function cmdQuickstart(opts = {}) {
1491
1543
  return failTour(1);
1492
1544
  const issuer = rec.body?.issuer;
1493
1545
  const v = verifyReceipt(rec.body.credential, issuer);
1494
- const kindLine = `${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", PINNED to your workspace issuer" : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
1546
+ const kindLine = `${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", " + pinWords() : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
1495
1547
  if (!v.valid || !issuer) {
1496
1548
  log(` ${kindLine}`);
1497
1549
  err(` ✘ the denial's receipt did not verify PINNED (${!issuer ? "no issuer DID from the server" : v.reason ?? "no reason given"}) — the tour did not complete.`);
1498
1550
  return failTour(1);
1499
1551
  }
1500
1552
  block("④", "The record", [
1501
- `✔ VALID — ${fname} ${dim("(signature + content binding, offline, PINNED to your workspace issuer)")}`,
1553
+ `✔ VALID — ${fname} ${dim(`(signature + content binding, offline, ${pinWords()})`)}`,
1502
1554
  `${bold("decision")} payInvoice $86,000 · REJECTED · routed to a human by policy (no auto-approve rule for this agent)`,
1503
1555
  `${bold("record")} record kind: ${recordKindOf(rec.body.credential) ?? "?"} ${dim("— the denial is sealed too")}`,
1504
- `${bold("issuer")} ${issuer} ${dim(`· your trust anchor — first seen over this signed-in connection; cross-check ${API}/api/did, then verify --issuer <did> <receipt.json>`)}`,
1556
+ `${bold("issuer")} ${issuer} ${dim(issuerNote())}`,
1505
1557
  ], "bad");
1506
1558
  denialVerified = true;
1507
1559
  }
@@ -1529,7 +1581,8 @@ async function cmdQuickstart(opts = {}) {
1529
1581
  }
1530
1582
  log("");
1531
1583
  sessionReminder();
1532
- log(`${bold("Where next")} your queue ${link(APP_URL)} · README "Production mode: durable async resume" ${dim("· built by OmniTwin — decidioai.com/#early-access")}`);
1584
+ if (!DEMO)
1585
+ log(`${bold("Where next")} your queue ${link(APP_URL)} · README "Production mode: durable async resume" ${dim("· built by OmniTwin — decidioai.com/#early-access")}`);
1533
1586
  }
1534
1587
  else {
1535
1588
  const ap = decidedOutside ? { status: 200, body: { alreadyResolved: true } } : await api(`/api/decisions/${encodeURIComponent(decisionId)}/approve`, { method: "POST", body: JSON.stringify({ reason: opts.autoApproveDemo && !interactive ? "quickstart --auto-approve-demo (automated)" : "quickstart" }) }, session);
@@ -1565,7 +1618,7 @@ async function cmdQuickstart(opts = {}) {
1565
1618
  return failTour(1);
1566
1619
  const issuer = rec.body?.issuer;
1567
1620
  const v = verifyReceipt(rec.body.credential, issuer);
1568
- const kindLine = `${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", PINNED to your workspace issuer" : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
1621
+ const kindLine = `${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", " + pinWords() : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
1569
1622
  if (!v.valid || !issuer)
1570
1623
  log(` ${kindLine}`);
1571
1624
  if (!v.valid) {
@@ -1583,33 +1636,44 @@ async function cmdQuickstart(opts = {}) {
1583
1636
  const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
1584
1637
  const tier = detail.body?.record?.confirmation?.status;
1585
1638
  block("④", "The receipt", [
1586
- `✔ VALID — ${fname} ${dim("(signature + content binding, offline, PINNED to your workspace issuer)")}`,
1639
+ `✔ VALID — ${fname} ${dim(`(signature + content binding, offline, ${pinWords()})`)}`,
1587
1640
  `${bold("decision")} payInvoice $86,000 · APPROVED · routed to a human by policy (no auto-approve rule for this agent)`,
1588
1641
  `${bold("record")} record kind: ${recordKindOf(rec.body.credential) ?? "?"}${tier ? ` · evidence: ${tier}` : ""} ${dim("— the receipt seals the DECISION; execution evidence lives on the record")}`,
1589
- `${bold("issuer")} ${issuer} ${dim(`· your trust anchor — first seen over this signed-in connection; cross-check ${API}/api/did, then verify --issuer <did> <receipt.json>`)}`,
1642
+ `${bold("issuer")} ${issuer} ${dim(issuerNote())}`,
1590
1643
  ], "good");
1591
1644
  const secs = Math.round((Date.now() - t0) / 1000);
1592
1645
  block("✔", `Done in ${secs}s`, [
1593
1646
  `${bold("gated")} payInvoice $86,000 — ROUTED to a human as ${decisionId}`,
1594
1647
  `${bold("decided")} ${decidedOutside ? "approved in the web app (or on your phone)" : opts.autoApproveDemo ? "approved by --auto-approve-demo (automated, and labeled so on the record)" : "approved by you, here"}`,
1595
1648
  `${bold("executed")} your function ran once, AFTER approval`,
1596
- `${bold("receipt")} VALID — verified offline, pinned to your workspace issuer (④ above)`,
1649
+ `${bold("receipt")} VALID — verified offline, pinned to ${DEMO ? "this demo's own key" : "your workspace issuer"} (④ above)`,
1597
1650
  dim("gated → routed to a human → approved → executed once → a receipt you can verify offline"),
1598
1651
  ], "good");
1599
1652
  log("");
1600
- log(bold("Take it to your real agent — the .env here already works:"));
1601
- log(` import { guard } from "@decidio/sdk";`);
1602
- log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));${dim(" // durable by default")}`);
1603
- log("");
1604
- if (EXPLAIN) {
1605
- log(` The README's seven-step flow is this same blocking flow in your own code; if you keep this agent, its .env`);
1606
- log(` already works here, so skip that flow's init step. Production is DURABLE mode (default) + one worker.`);
1653
+ if (DEMO) {
1654
+ log(bold("That was the mechanism — on this machine, against a stand-in. The product runs it against your workspace:"));
1655
+ log(` request access: decidioai.com/#early-access · have an invite? npx @decidio/sdk login, then npx @decidio/sdk quickstart`);
1656
+ log("");
1657
+ }
1658
+ else {
1659
+ log(bold("Take it to your real agent — the .env here already works:"));
1660
+ log(` import { guard } from "@decidio/sdk";`);
1661
+ log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));${dim(" // durable by default")}`);
1662
+ log("");
1663
+ if (EXPLAIN) {
1664
+ log(` The README's seven-step flow is this same blocking flow in your own code; if you keep this agent, its .env`);
1665
+ log(` already works here, so skip that flow's init step. Production is DURABLE mode (default) + one worker.`);
1666
+ }
1607
1667
  }
1608
1668
  sessionReminder();
1609
- log(`${bold("Where next")} your queue ${link(APP_URL)} · README "Production mode: durable async resume" ${dim("· built by OmniTwin — decidioai.com/#early-access")}`);
1669
+ if (!DEMO)
1670
+ log(`${bold("Where next")} your queue ${link(APP_URL)} · README "Production mode: durable async resume" ${dim("· built by OmniTwin — decidioai.com/#early-access")}`);
1610
1671
  }
1611
1672
  let doCleanup = false;
1612
- if (interactive) {
1673
+ if (DEMO) {
1674
+ doCleanup = true;
1675
+ }
1676
+ else if (interactive) {
1613
1677
  const answer = await ask(`\nKeep this agent for development? [Enter = keep · c + Enter = clean up now]: `);
1614
1678
  doCleanup = answer.trim().toLowerCase() === "c";
1615
1679
  if (!doCleanup)
@@ -1757,7 +1821,7 @@ async function quickstartCleanup(session, envFile) {
1757
1821
  });
1758
1822
  if (kept.length === 0) {
1759
1823
  rmSync(envFile);
1760
- log(` ✔ ${envFile} deleted (the agent's local key is gone — the registered agent is now inert)`);
1824
+ log(` ✔ ${envFile} deleted (the agent's local key is gone — ${DEMO ? "the temporary folder follows" : "the registered agent is now inert"})`);
1761
1825
  }
1762
1826
  else {
1763
1827
  writeSecretFile(envFile, kept.join("\n") + "\n");
@@ -1773,9 +1837,13 @@ async function quickstartCleanup(session, envFile) {
1773
1837
  log(` npx @decidio/sdk approvals reject ${c.id} "quickstart cleanup"`);
1774
1838
  }
1775
1839
  }
1776
- log(` • What remains, by design: the sealed decision record (records are permanent — that is the product)`);
1777
- log(` and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap).`);
1778
- if (existsSync(JOURNAL_PATH))
1840
+ if (DEMO)
1841
+ log(` • nothing remains: the demo's stand-in agent, token and files go with the temporary folder — only the receipt is kept`);
1842
+ else {
1843
+ log(` • What remains, by design: the sealed decision record (records are permanent — that is the product)`);
1844
+ log(` and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap).`);
1845
+ }
1846
+ if (!DEMO && existsSync(JOURNAL_PATH))
1779
1847
  log(` • Also kept: ${JOURNAL_PATH} — the ids and timestamps of what this directory minted and revoked (no secrets). Gitignored; owner-only where the OS supports it.`);
1780
1848
  if (existsSync(".decidio-pending")) {
1781
1849
  log(` • Also kept: .decidio-pending/ — this agent's own execution ledger: whatever this agent parked (call arguments,`);
@@ -1783,6 +1851,8 @@ async function quickstartCleanup(session, envFile) {
1783
1851
  log(` pending and you do not want the history, remove it:`);
1784
1852
  log(` ${process.platform === "win32" ? "Remove-Item -Recurse .decidio-pending" : "rm -r .decidio-pending"}`);
1785
1853
  }
1854
+ if (DEMO)
1855
+ return 0;
1786
1856
  if (endSession())
1787
1857
  log(` ✔ signed out — the stored sign-in is removed from this computer (the token itself stays valid until it expires; npx @decidio/sdk login to sign in again).`);
1788
1858
  else if (process.env.DECIDIO_SESSION_TOKEN)
@@ -1923,6 +1993,8 @@ THE TOUR
1923
1993
  registration row and the permanent sealed record stay)
1924
1994
  (and end the stored sign-in)
1925
1995
  npx @decidio/sdk quickstart --auto-approve-demo headless tour; cleans up after itself
1996
+ npx @decidio/sdk quickstart --demo the same tour against a LOCAL stand-in of the API — no account needed; the
1997
+ web/phone door is a hosted demo page; the receipt is the one thing kept
1926
1998
  (add --keep-agent to KEEP the tour agent + token)
1927
1999
 
1928
2000
  SIGN IN (once)
@@ -2089,7 +2161,7 @@ async function main(argv) {
2089
2161
  }
2090
2162
  const argsIn = argv.slice(3);
2091
2163
  const KNOWN = {
2092
- quickstart: { flags: ["--explain", "--auto-approve-demo", "--keep-agent"], valued: ["--phone"] },
2164
+ quickstart: { flags: ["--explain", "--auto-approve-demo", "--keep-agent", "--demo"], valued: ["--phone"] },
2093
2165
  login: { flags: [] },
2094
2166
  logout: { flags: [] },
2095
2167
  init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
@@ -2185,7 +2257,7 @@ async function main(argv) {
2185
2257
  return 1;
2186
2258
  return quickstartCleanup(session, envFile);
2187
2259
  }
2188
- return cmdQuickstart({ autoApproveDemo: argsIn.includes("--auto-approve-demo"), keepAgent: argsIn.includes("--keep-agent"), explain: argsIn.includes("--explain"), phone: valuedOpt(argsIn, "--phone") });
2260
+ return cmdQuickstart({ demo: argsIn.includes("--demo"), autoApproveDemo: argsIn.includes("--auto-approve-demo"), keepAgent: argsIn.includes("--keep-agent"), explain: argsIn.includes("--explain"), phone: valuedOpt(argsIn, "--phone") });
2189
2261
  }
2190
2262
  case "init": {
2191
2263
  const unique = argsIn.includes("--unique");
@@ -0,0 +1,16 @@
1
+ export interface DemoGate {
2
+ /** http://127.0.0.1:<port> — point the tour here. */
3
+ url: string;
4
+ /** The did:key of the key generated for THIS run (never the hosted issuer's). */
5
+ issuerDid: string;
6
+ /** Whether the hosted relay answered (the web/phone door exists) — null until session-link was asked. */
7
+ relayReachable: () => boolean | null;
8
+ close(): Promise<void>;
9
+ }
10
+ export interface DemoGateOptions {
11
+ /** Base URL of the hosted relay; null = offline (terminal-only). Default: the hosted sandbox API. */
12
+ relayBase?: string | null;
13
+ /** Milliseconds to wait for the relay on each call. */
14
+ relayTimeoutMs?: number;
15
+ }
16
+ export declare function startDemoGate(opts?: DemoGateOptions): Promise<DemoGate>;
@@ -0,0 +1,161 @@
1
+ import { createServer } from "node:http";
2
+ import { createHash, generateKeyPairSync, randomBytes, sign as edSign } from "node:crypto";
3
+ import { jcsCanonicalize } from "@decidio/verify";
4
+ const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
5
+ function b58(buf) {
6
+ const digits = [0];
7
+ for (const byte of buf) {
8
+ let carry = byte;
9
+ for (let i = 0; i < digits.length; i++) {
10
+ carry += digits[i] << 8;
11
+ digits[i] = carry % 58;
12
+ carry = (carry / 58) | 0;
13
+ }
14
+ while (carry) {
15
+ digits.push(carry % 58);
16
+ carry = (carry / 58) | 0;
17
+ }
18
+ }
19
+ let out = "";
20
+ for (const b of buf) {
21
+ if (b === 0)
22
+ out += B58[0];
23
+ else
24
+ break;
25
+ }
26
+ return out + digits.reverse().map((d) => B58[d]).join("");
27
+ }
28
+ const DECISION_ID = "D-DEMO-1";
29
+ const D_NUMBER = "D·0001";
30
+ export async function startDemoGate(opts = {}) {
31
+ const relayBase = opts.relayBase === undefined ? "https://decidio-api.onrender.com" : opts.relayBase;
32
+ const relayTimeoutMs = opts.relayTimeoutMs ?? 2000;
33
+ const issuerKey = generateKeyPairSync("ed25519");
34
+ const issuerRaw = Buffer.from(issuerKey.publicKey.export({ format: "der", type: "spki" })).subarray(-32);
35
+ const issuerDid = "did:key:z" + b58(Buffer.concat([Buffer.from([0xed, 0x01]), issuerRaw]));
36
+ const sealedAt = new Date().toISOString();
37
+ let verdict = null;
38
+ let relayCode = null;
39
+ let relayOk = null;
40
+ const relayFetch = async (path, init = {}) => {
41
+ if (!relayBase)
42
+ return null;
43
+ const ctl = new AbortController();
44
+ const t = setTimeout(() => ctl.abort(), relayTimeoutMs);
45
+ try {
46
+ const r = await fetch(`${relayBase.replace(/\/$/, "")}${path}`, { ...init, signal: ctl.signal, headers: { "content-type": "application/json", ...(init.headers ?? {}) } });
47
+ if (r.status === 204)
48
+ return {};
49
+ if (!r.ok)
50
+ return null;
51
+ return await r.json().catch(() => ({}));
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ finally {
57
+ clearTimeout(t);
58
+ }
59
+ };
60
+ const issueReceipt = (kind) => {
61
+ const canonical = `{"accountableParty":"[PERSON_1]","action":"payInvoice","amount":"[AMOUNT_2]","recordKind":"${kind}","recordSeries":"local-demo"}`;
62
+ const contentHash = createHash("sha256").update(canonical, "utf8").digest("hex");
63
+ const context = ["https://www.w3.org/ns/credentials/v2"];
64
+ const doc = {
65
+ "@context": context,
66
+ type: ["VerifiableCredential", "DecidioAuthorityRecord"],
67
+ issuer: issuerDid,
68
+ validFrom: sealedAt,
69
+ credentialSubject: { id: `urn:decidio:decision:${DECISION_ID}`, recordId: DECISION_ID, dNumber: D_NUMBER, contentHash, prevHash: null, recordCanonical: canonical },
70
+ };
71
+ const cfg = {
72
+ "@context": context, type: "DataIntegrityProof", cryptosuite: "eddsa-jcs-2022",
73
+ created: sealedAt, verificationMethod: `${issuerDid}#${issuerDid.slice("did:key:".length)}`, proofPurpose: "assertionMethod",
74
+ };
75
+ const signingInput = Buffer.concat([
76
+ createHash("sha256").update(Buffer.from(jcsCanonicalize(cfg), "utf8")).digest(),
77
+ createHash("sha256").update(Buffer.from(jcsCanonicalize(doc), "utf8")).digest(),
78
+ ]);
79
+ return { ...doc, proof: { ...cfg, proofValue: "z" + b58(edSign(null, signingInput, issuerKey.privateKey)) } };
80
+ };
81
+ const server = createServer((req, res) => {
82
+ let raw = "";
83
+ req.on("data", (c) => (raw += c));
84
+ req.on("end", async () => {
85
+ let body;
86
+ try {
87
+ body = raw ? JSON.parse(raw) : undefined;
88
+ }
89
+ catch {
90
+ body = undefined;
91
+ }
92
+ const path = (req.url ?? "").split("?")[0];
93
+ const send = (o, status = 200) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(o)); };
94
+ try {
95
+ if (path === "/api/health")
96
+ return send({ ok: true, demo: true });
97
+ if (path === "/api/did")
98
+ return send({ did: issuerDid, method: "did:key", alg: "Ed25519", demo: true });
99
+ if (path === "/api/decisions/cards")
100
+ return send({ cards: [] });
101
+ if (path === "/api/agents/register")
102
+ return send({ agentId: body?.agentId, did: body?.did });
103
+ if (path === "/api/workspace/agent-token" && req.method === "POST")
104
+ return send({ token: "demo-agent-token", workspaceId: "local-demo", jti: `jti-demo-${randomBytes(4).toString("hex")}`, expiresAt: new Date(Date.now() + 3_600_000).toISOString() });
105
+ if (path === "/api/workspace/agent-token" && req.method === "GET")
106
+ return send({ tokens: [] });
107
+ if (/^\/api\/workspace\/agent-token\/[^/]+$/.test(path) && req.method === "DELETE")
108
+ return send({ ok: true, changed: true });
109
+ if (path === "/agent/authorize") {
110
+ verdict = null;
111
+ return send({ decision: "route", decisionId: DECISION_ID, receiptId: "R-DEMO-1" });
112
+ }
113
+ if (path.startsWith("/agent/status/")) {
114
+ if (!verdict && relayCode) {
115
+ const r = await relayFetch(`/demo/relay/${relayCode}`);
116
+ if (r && (r.verdict === "approve" || r.verdict === "reject"))
117
+ verdict = r.verdict;
118
+ }
119
+ return send(verdict === "reject" ? { state: "rejected", reason: "quickstart: rejected by the approver" } : { state: verdict === "approve" ? "approved" : "pending" });
120
+ }
121
+ if (/^\/api\/decisions\/[^/]+\/(approve|reject)$/.test(path)) {
122
+ const already = verdict !== null;
123
+ verdict = path.endsWith("/reject") ? "reject" : "approve";
124
+ if (relayCode && !already)
125
+ void relayFetch(`/demo/relay/${relayCode}`, { method: "PUT", body: JSON.stringify({ verdict }) });
126
+ return send({ ok: true, decisionId: DECISION_ID, sealed: true, ...(already ? { alreadyResolved: true } : {}) });
127
+ }
128
+ if (path === "/agent/confirm")
129
+ return send({ ok: true, confirmation: "application_confirmed", recordId: DECISION_ID });
130
+ if (path === "/demo/session-link") {
131
+ const r = await relayFetch("/demo/relay", { method: "POST", body: JSON.stringify({ decisionId: DECISION_ID }) });
132
+ if (r && typeof r.code === "string" && /^[0-9A-HJKMNP-TV-Z]{13}$/.test(r.code) && typeof r.link === "string" && /^https?:\/\//.test(r.link)) {
133
+ relayCode = r.code;
134
+ relayOk = true;
135
+ return send({ status: "ok", link: r.link, qr: typeof r.qr === "string" ? r.qr : null, expiresAt: r.expiresAt ?? null });
136
+ }
137
+ relayOk = false;
138
+ return send({ status: "unavailable", error: "the demo relay is unreachable — this run is terminal-only" }, 503);
139
+ }
140
+ if (/^\/api\/records\/[^/]+\/verify$/.test(path)) {
141
+ const cred = issueReceipt(verdict === "reject" ? "denial" : "approval");
142
+ return send({ dNumber: D_NUMBER, issuer: issuerDid, alg: "Ed25519", valid: true, signatureValid: true, contentBound: true, matchesRecord: true, payloadMatches: true, hash: cred.credentialSubject.contentHash, prevHash: null, credential: cred });
143
+ }
144
+ if (/^\/api\/records\/[^/]+$/.test(path))
145
+ return send({ record: { confirmation: verdict === "approve" ? { status: "application_confirmed", confirmed: true } : { status: "pending", confirmed: false } } });
146
+ return send({ error: "not part of the local demo" }, 404);
147
+ }
148
+ catch (e) {
149
+ return send({ error: String(e?.message ?? e) }, 500);
150
+ }
151
+ });
152
+ });
153
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
154
+ const port = server.address().port;
155
+ return {
156
+ url: `http://127.0.0.1:${port}`,
157
+ issuerDid,
158
+ relayReachable: () => relayOk,
159
+ close: () => new Promise((r) => server.close(() => r())),
160
+ };
161
+ }
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.5.0 # tracks the SDK release - checked by packages/conformance/manifests.mjs
8
+ version: 0.6.0 # tracks the SDK release - checked by packages/conformance/manifests.mjs
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.5.0",
3
+ "version": "0.6.0",
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",
@@ -84,7 +84,7 @@
84
84
  "prepublishOnly": "npm run typecheck && npm run build && node ../publish-boundary/check.mjs --only @decidio/sdk"
85
85
  },
86
86
  "dependencies": {
87
- "@decidio/verify": "0.5.0"
87
+ "@decidio/verify": "0.6.0"
88
88
  },
89
89
  "author": "OmniTwin Technologies Inc.",
90
90
  "bugs": {