@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 +33 -0
- package/README.md +3 -1
- package/dist/cli.js +104 -32
- package/dist/demo-gate.d.ts +16 -0
- package/dist/demo-gate.js +161 -0
- package/openapi.yaml +1 -1
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ? ",
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 ? ",
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
log(
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
87
|
+
"@decidio/verify": "0.6.0"
|
|
88
88
|
},
|
|
89
89
|
"author": "OmniTwin Technologies Inc.",
|
|
90
90
|
"bugs": {
|