@decidio/sdk 0.6.4 → 0.7.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/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ import { randomBytes } from "node:crypto";
4
4
  import { homedir, tmpdir } from "node:os";
5
5
  import { join as joinPath, resolve as resolvePath, sep as pathSep, basename } from "node:path";
6
6
  import { createServer } from "node:http";
7
+ import { isIP } from "node:net";
7
8
  import { execFileSync, spawnSync } from "node:child_process";
8
9
  import { createInterface } from "node:readline";
9
10
  import { pathToFileURL as toFileUrl, fileURLToPath as fromFileUrl } from "node:url";
@@ -12,7 +13,7 @@ import { createRequire } from "node:module";
12
13
  import { generateKeypair, assertSafeTransport, redactUrl } from "./signing.js";
13
14
  import { hardenFile } from "./resume.js";
14
15
  import { withApproval, DecidioRateLimitError, DecidioRejectedError, DecidioTimeoutError, didFromPrivateKey } from "./index.js";
15
- import { verifyReceipt, runVerify, pinRequiredMessage } from "@decidio/verify";
16
+ import { verifyReceipt, runVerify, pinRequiredMessage, builtInAnchor, KNOWN_ISSUERS } from "@decidio/verify";
16
17
  import { startDemoGate } from "./demo-gate.js";
17
18
  const VERSION = (() => {
18
19
  try {
@@ -63,16 +64,19 @@ if (!OFFLINE_CMD)
63
64
  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.");
64
65
  continue;
65
66
  }
67
+ if (!k.startsWith("DECIDIO_"))
68
+ continue;
66
69
  if (process.env[k] === undefined)
67
70
  process.env[k] = v;
68
71
  }
69
- let API = (process.env.DECIDIO_API_URL ?? "https://decidio-api.onrender.com").replace(/\/$/, "");
72
+ const DEFAULT_API = "https://decidio-api.onrender.com";
73
+ let API = (process.env.DECIDIO_API_URL ?? DEFAULT_API).replace(/\/$/, "");
70
74
  let DEMO = false;
71
75
  function enterDemo(url) { API = url; process.env.DECIDIO_API_URL = url; DEMO = true; }
72
76
  const pinWords = () => (DEMO ? "PINNED to this demo's own key" : "PINNED to your workspace issuer");
73
77
  const issuerNote = () => (DEMO
74
78
  ? "· 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>`);
79
+ : `· your trust anchor — named by this server over the signed-in connection, never read from the receipt; cross-check ${API}/api/did`);
76
80
  const TOKEN = () => process.env.DECIDIO_API_TOKEN;
77
81
  const ENV_PATH = ".env";
78
82
  const log = (...a) => console.log(...a);
@@ -509,14 +513,15 @@ if (process.env.DECIDIO_DEBUG_INPUT && process.stdin.isTTY) {
509
513
  }
510
514
  function terminalId() {
511
515
  for (const k of ["WT_SESSION", "VSCODE_IPC_HOOK_CLI", "TERM_SESSION_ID", "ITERM_SESSION_ID", "TMUX_PANE", "KONSOLE_DBUS_SESSION", "GNOME_TERMINAL_SCREEN", "WINDOWID"]) {
512
- const v = process.env[k];
516
+ const v = SHELL_ENV[k];
513
517
  if (v)
514
518
  return `${k}=${v}`;
515
519
  }
516
520
  return "";
517
521
  }
518
522
  function sessionDir() {
519
- const base = process.platform === "win32" ? (process.env.LOCALAPPDATA ?? joinPath(homedir(), "AppData", "Local")) : (process.env.XDG_CONFIG_HOME ?? joinPath(homedir(), ".config"));
523
+ const home = process.platform === "win32" ? (SHELL_ENV.USERPROFILE ?? homedir()) : (SHELL_ENV.HOME ?? homedir());
524
+ const base = process.platform === "win32" ? (SHELL_ENV.LOCALAPPDATA ?? joinPath(home, "AppData", "Local")) : (SHELL_ENV.XDG_CONFIG_HOME ?? joinPath(home, ".config"));
520
525
  return joinPath(base, "decidio");
521
526
  }
522
527
  const sessionFile = () => joinPath(sessionDir(), "session.json");
@@ -572,6 +577,140 @@ function endSession() {
572
577
  return false;
573
578
  }
574
579
  }
580
+ const issuersFile = () => joinPath(sessionDir(), "issuers.json");
581
+ const DID_KEY_SHAPE = /^did:key:z[1-9A-HJ-NP-Za-km-z]{40,60}$/;
582
+ const STORE_HOST_LABELS = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
583
+ export function issuerStoreKey(url) {
584
+ const raw = url.trim();
585
+ const low = raw.replace(/\/+$/, "").toLowerCase();
586
+ const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/?#]*)(?:[/?#]|$)/.exec(raw);
587
+ if (!m)
588
+ return low;
589
+ const scheme = m[1].toLowerCase();
590
+ if (scheme !== "http" && scheme !== "https")
591
+ return low;
592
+ let authority = m[2];
593
+ const at = authority.lastIndexOf("@");
594
+ if (at >= 0)
595
+ authority = authority.slice(at + 1);
596
+ let host, portStr = "";
597
+ if (authority.startsWith("[")) {
598
+ const rb = authority.indexOf("]");
599
+ if (rb < 0)
600
+ return low;
601
+ const inner = authority.slice(1, rb).toLowerCase();
602
+ const rest = authority.slice(rb + 1);
603
+ if (isIP(inner) !== 6)
604
+ return low;
605
+ if (rest !== "") {
606
+ if (!/^:\d+$/.test(rest))
607
+ return low;
608
+ portStr = rest.slice(1);
609
+ }
610
+ host = `[${inner}]`;
611
+ }
612
+ else {
613
+ const c = authority.lastIndexOf(":");
614
+ if (c >= 0) {
615
+ const p = authority.slice(c + 1);
616
+ if (!/^\d+$/.test(p))
617
+ return low;
618
+ portStr = p;
619
+ host = authority.slice(0, c).toLowerCase();
620
+ }
621
+ else
622
+ host = authority.toLowerCase();
623
+ if (!STORE_HOST_LABELS.test(host))
624
+ return low;
625
+ }
626
+ const port = portStr === "" ? null : Number(portStr);
627
+ const dflt = scheme === "https" ? 443 : 80;
628
+ return port !== null && port !== dflt ? `${scheme}://${host}:${port}` : `${scheme}://${host}`;
629
+ }
630
+ function readIssuerStore() {
631
+ const f = issuersFile();
632
+ if (!existsSync(f))
633
+ return {};
634
+ const rawText = readFileSync(f, "utf8");
635
+ const s = JSON.parse(rawText);
636
+ const vLiteral = /"v"\s*:\s*(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/.exec(rawText)?.[1] ?? null;
637
+ const hosts = s?.v === 1 && vLiteral === "1" && s.hosts && typeof s.hosts === "object" && !Array.isArray(s.hosts) ? s.hosts : null;
638
+ if (!hosts)
639
+ throw new Error("not a v1 issuer store");
640
+ const out = {};
641
+ for (const [h, e] of Object.entries(hosts)) {
642
+ if (typeof e?.did !== "string" || !DID_KEY_SHAPE.test(e.did) || typeof e?.firstSeen !== "string")
643
+ throw new Error(`malformed entry for ${h}`);
644
+ const k = issuerStoreKey(h);
645
+ if (out[k] && out[k].did !== e.did)
646
+ throw new Error(`two entries for ${k} name different DIDs`);
647
+ out[k] ??= { did: e.did, firstSeen: e.firstSeen };
648
+ }
649
+ return out;
650
+ }
651
+ function rememberedIssuer(host = API) { return readIssuerStore()[issuerStoreKey(host)] ?? null; }
652
+ const verifyHost = () => (SHELL_ENV.DECIDIO_API_URL ?? DEFAULT_API).replace(/\/$/, "");
653
+ function warnIssuerChanged(known, seen, via) {
654
+ const key = issuerStoreKey(API);
655
+ err(`⚠ THE ISSUER DID OF ${key} HAS CHANGED — it was NOT adopted.`);
656
+ err(` remembered (first seen ${known.firstSeen.slice(0, 10)}): ${known.did}`);
657
+ err(` ${via} now says: ${seen}`);
658
+ err(` Receipts from this host are still verified against the REMEMBERED DID. A new DID means a new signing key: a planned`);
659
+ err(` rotation, another deployment behind the same address, or someone answering in its place. Confirm it with the host's`);
660
+ err(` operator out of band before trusting it; to adopt it, remove the "${key}" entry from ${issuersFile()} and sign in again.`);
661
+ }
662
+ async function rememberHostIssuer() {
663
+ if (DEMO)
664
+ return;
665
+ let store;
666
+ try {
667
+ store = readIssuerStore();
668
+ }
669
+ catch (e) {
670
+ err(`⚠ the issuer DIDs this computer remembers (${issuersFile()}) could not be read (${e?.message ?? e}) — left as found, nothing new remembered. Repair the file or move it aside.`);
671
+ return;
672
+ }
673
+ const r = await api("/api/did", {}, "", true);
674
+ const did = typeof r.body?.did === "string" && DID_KEY_SHAPE.test(r.body.did) ? r.body.did : null;
675
+ if (!did) {
676
+ log(dim(` (${API}/api/did did not answer with a DID (${r.status || "no response"}), so this host's issuer is not remembered yet — verify needs --issuer for its receipts until a later sign-in remembers it)`));
677
+ return;
678
+ }
679
+ const key = issuerStoreKey(API);
680
+ const known = store[key];
681
+ if (known?.did === did)
682
+ return;
683
+ if (known) {
684
+ warnIssuerChanged(known, did, `${API}/api/did`);
685
+ return;
686
+ }
687
+ store[key] = { did, firstSeen: new Date().toISOString() };
688
+ try {
689
+ mkdirSync(sessionDir(), { recursive: true });
690
+ writeSecretFile(issuersFile(), JSON.stringify({ v: 1, hosts: store }, null, 2) + "\n");
691
+ }
692
+ catch (e) {
693
+ err(`⚠ could not remember this host's issuer DID (${issuersFile()}: ${e?.message ?? e}) — verify will need --issuer for its receipts.`);
694
+ return;
695
+ }
696
+ log(` ✔ remembered this host's issuer DID (from ${API}/api/did) — verify pins its receipts to it, no --issuer needed: ${did}`);
697
+ }
698
+ function hostAnchor(body) {
699
+ const named = typeof body?.expectedIssuer === "string" && DID_KEY_SHAPE.test(body.expectedIssuer) ? body.expectedIssuer : null;
700
+ if (DEMO)
701
+ return named;
702
+ let mem;
703
+ try {
704
+ mem = rememberedIssuer();
705
+ }
706
+ catch (e) {
707
+ err(`⚠ the issuer DIDs this computer remembers (${issuersFile()}) could not be read (${e?.message ?? e}) — no trust anchor until it is repaired or moved aside.`);
708
+ return null;
709
+ }
710
+ if (mem && named && mem.did !== named)
711
+ warnIssuerChanged(mem, named, "this server's verify response");
712
+ return mem?.did ?? named;
713
+ }
575
714
  async function loginFlow(purpose, emailArg) {
576
715
  log(purpose === "Signing in" ? `Sign in once — kept on this computer for 30 days, or until this terminal closes, logout, or cleanup.` : `${purpose} needs your Decidio workspace sign-in — once: kept on this computer for 30 days, or until this terminal closes, logout, or cleanup.`);
577
716
  const email = emailArg ?? await ask(" email: ");
@@ -585,6 +724,7 @@ async function loginFlow(purpose, emailArg) {
585
724
  if (r.status >= 200 && r.status < 300 && r.body?.token) {
586
725
  const s = saveSession(String(r.body.token), email);
587
726
  log(` ✔ signed in as ${email} until ${s.expiresAt.slice(0, 10)}${s.terminal ? "" : dim(" (no terminal id here: the sign-in ends on logout or cleanup, not on close)")}`);
727
+ await rememberHostIssuer();
588
728
  return s.token;
589
729
  }
590
730
  log(` ✘ password sign-in failed (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}. No problem — the emailed link works for every account:`);
@@ -610,6 +750,7 @@ async function loginFlow(purpose, emailArg) {
610
750
  if (r.status >= 200 && r.status < 300 && r.body?.token) {
611
751
  const s = saveSession(String(r.body.token), email);
612
752
  log(` ✔ signed in as ${email} until ${s.expiresAt.slice(0, 10)}${s.terminal ? "" : dim(" (no terminal id here: the sign-in ends on logout or cleanup, not on close)")}`);
753
+ await rememberHostIssuer();
613
754
  return s.token;
614
755
  }
615
756
  if (isLink) {
@@ -897,6 +1038,7 @@ async function cmdInit(agentId, opts = {}) {
897
1038
  log(`✔ wrote ${ENV_PATH} (owner-only where the OS supports it) — every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
898
1039
  if (privateKey)
899
1040
  log(` (the agent's private signing key is in there — for production, move it to your secret manager)`);
1041
+ await rememberHostIssuer();
900
1042
  log("");
901
1043
  log("Next: protect one function and watch it route. Two steps:");
902
1044
  log("");
@@ -1151,19 +1293,42 @@ async function cmdReceipt(id, jsonMode = false) {
1151
1293
  }
1152
1294
  const name = receiptFileName(id);
1153
1295
  const nextBytes = JSON.stringify(r.body.credential, null, 2) + "\n";
1154
- const wrote = writeReceiptFile(name, nextBytes, jsonMode ? err : log);
1296
+ const issuer = hostAnchor(r.body);
1297
+ const local = issuer ? verifyReceipt(r.body.credential, issuer) : null;
1298
+ const serverInvalid = r.body?.valid === false;
1299
+ const localInvalid = !!local && !local.valid;
1300
+ const bad = serverInvalid || localInvalid;
1301
+ const say = jsonMode ? err : log;
1302
+ const wrote = writeReceiptFile(name, nextBytes, say);
1155
1303
  if (wrote === "conflict")
1156
1304
  return 1;
1157
1305
  if (wrote === "written")
1158
- (jsonMode ? err : log)(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
1306
+ say(bad ? `• wrote ${name} — kept for inspection only: it does NOT verify (below)` : `✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
1159
1307
  const detail = await api(`/api/records/${encodeURIComponent(id)}`, {}, bearer);
1160
1308
  const conf = detail.body?.record?.confirmation;
1161
- const issuer = r.body?.issuer;
1162
1309
  const resolved = r.body?.resolved;
1310
+ const badSaid = () => {
1311
+ if (serverInvalid) {
1312
+ err(`✘ the server's own check says this record does NOT verify: ${r.body?.reason ?? "no reason given"}`);
1313
+ err(` (signature ${r.body?.signatureValid === false ? "INVALID" : "valid"} · content bound ${r.body?.contentBound === false ? "NO" : "yes"} · matches the stored record ${r.body?.matchesRecord === false ? "NO" : "yes"} · stored payload ${r.body?.payloadMatches === false ? "ALTERED" : "intact"})`);
1314
+ }
1315
+ if (localInvalid)
1316
+ err(`✘ this receipt does NOT verify against this Decidio's issuer DID (${issuer}): ${local.reason ?? "no reason given"}`);
1317
+ err(` Do not rely on ${name}. Report it to the operator of ${API} — a sealed record is immutable, so this is tampering, a defect, or an unannounced key change (see any warning above), never a normal state.`);
1318
+ };
1163
1319
  if (jsonMode) {
1164
- log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed, issuer: issuer ?? null, ...(resolved ? { resolved } : {}) }, null, 2));
1320
+ const valid = bad ? false : local?.valid === true ? true : null;
1321
+ log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed, issuer: issuer ?? null, valid, ...(bad ? { reason: serverInvalid ? r.body?.reason ?? null : local?.reason ?? null } : {}), ...(resolved ? { resolved } : {}) }, null, 2));
1322
+ if (bad) {
1323
+ badSaid();
1324
+ return 1;
1325
+ }
1165
1326
  return 0;
1166
1327
  }
1328
+ if (bad) {
1329
+ badSaid();
1330
+ return 1;
1331
+ }
1167
1332
  if (conf?.status)
1168
1333
  log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
1169
1334
  if (resolved && Object.keys(resolved).length && !process.stdout.isTTY) {
@@ -1176,19 +1341,37 @@ async function cmdReceipt(id, jsonMode = false) {
1176
1341
  }
1177
1342
  log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
1178
1343
  if (issuer) {
1179
- log(` Record your workspace's issuer DID — copy this line, and pin it forever:`);
1344
+ const vHost = verifyHost();
1345
+ let plain = false, via = "";
1346
+ try {
1347
+ const mem = rememberedIssuer(vHost);
1348
+ const claimed = String(r.body.credential?.issuer ?? "");
1349
+ plain = (mem ? mem.did : KNOWN_ISSUERS.find((k) => k.did === claimed)?.did) === issuer;
1350
+ via = mem ? "remembered on this computer" : "built into the verifier";
1351
+ }
1352
+ catch {
1353
+ plain = false;
1354
+ }
1355
+ if (plain) {
1356
+ log(` npx @decidio/sdk verify ${name}`);
1357
+ log(` No --issuer needed here: it pins to this Decidio's issuer (${via}), which the server also named:`);
1358
+ }
1359
+ else {
1360
+ log(` Record your workspace's issuer DID — copy this line, and pin it forever:`);
1361
+ }
1180
1362
  log(``);
1181
1363
  log(`${issuer}`);
1182
1364
  log(``);
1183
1365
  log(` Cross-check it out of band — the same DID is published by this Decidio host at ${API}/api/did (public, no sign-in).`);
1184
- log(` Then verify, pinned to it (the same DID, inline — copy from the line above, not this one,`);
1185
- log(` which your terminal may wrap):`);
1366
+ log(plain ? ` Anywhere else, pin it explicitly (copy the DID from the line above — this one may wrap):` : ` Then verify, pinned to it (the same DID, inline — copy from the line above, not this one, which your terminal may wrap):`);
1186
1367
  log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
1187
- log(` The verifier refuses to run without --issuer: a pin is what makes VALID mean "issued by your`);
1188
- log(` workspace"; --allow-unpinned runs a structural check only, and says so.`);
1368
+ if (!plain && issuerStoreKey(vHost) !== issuerStoreKey(API))
1369
+ log(` (verify reads no .env, so a flagless run pins to the DID for ${vHost}, not ${API} - export DECIDIO_API_URL=${API} in your shell to verify this host's receipts without --issuer.)`);
1370
+ else if (!plain)
1371
+ log(` Or sign in once (npx @decidio/sdk login): this computer then remembers the DID, and verify needs no --issuer.`);
1189
1372
  }
1190
1373
  else {
1191
- log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
1374
+ log(` To also prove WHO issued it, pin your Decidio's issuer DID — published at ${API}/api/did (public, no sign-in), never read off the receipt:`);
1192
1375
  log(` npx @decidio/sdk verify --issuer <did:key:...> ${name}`);
1193
1376
  }
1194
1377
  return 0;
@@ -1237,7 +1420,7 @@ function cmdVerify(argsIn) {
1237
1420
  }
1238
1421
  }
1239
1422
  if (!files.length) {
1240
- err("usage: npx @decidio/sdk verify --issuer <did:key> <receipt.json> [more.json ...] (or --allow-unpinned)");
1423
+ err("usage: npx @decidio/sdk verify [--issuer <did:key>] <receipt.json> [more.json ...] (or --allow-unpinned)");
1241
1424
  err(" Verifies Authority Receipts OFFLINE — signature, content binding, and (for several files) chain order.");
1242
1425
  return 2;
1243
1426
  }
@@ -1245,10 +1428,6 @@ function cmdVerify(argsIn) {
1245
1428
  err("✘ --allow-unpinned and --issuer together — an unpinned check and a pinned one are different questions; ask one.");
1246
1429
  return 2;
1247
1430
  }
1248
- if (expectedIssuer === undefined && !allowUnpinned) {
1249
- err(pinRequiredMessage("npx @decidio/sdk verify"));
1250
- return 2;
1251
- }
1252
1431
  const texts = new Map();
1253
1432
  for (const f of files) {
1254
1433
  try {
@@ -1259,7 +1438,34 @@ function cmdVerify(argsIn) {
1259
1438
  return 1;
1260
1439
  }
1261
1440
  }
1262
- const { ok, lines } = runVerify(files, expectedIssuer, (f) => texts.get(f));
1441
+ const read = (f) => texts.get(f);
1442
+ let anchor = expectedIssuer !== undefined ? "--issuer" : undefined;
1443
+ if (expectedIssuer === undefined && !allowUnpinned) {
1444
+ let mem;
1445
+ try {
1446
+ mem = rememberedIssuer(verifyHost());
1447
+ }
1448
+ catch (e) {
1449
+ err(`✘ the issuer DIDs this computer remembers (${issuersFile()}) could not be read (${e?.message ?? e}) — refusing to fall back to another trust anchor.`);
1450
+ err(` Pass --issuer <did:key> for this run, or repair the file (or move it aside and sign in again). Nothing was verified.`);
1451
+ return 2;
1452
+ }
1453
+ if (mem) {
1454
+ expectedIssuer = mem.did;
1455
+ anchor = `remembered for ${issuerStoreKey(verifyHost())} since ${mem.firstSeen.slice(0, 10)}`;
1456
+ }
1457
+ else {
1458
+ const pick = builtInAnchor(files, read);
1459
+ if ("refusal" in pick) {
1460
+ err(pinRequiredMessage("npx @decidio/sdk verify", pick.refusal));
1461
+ err(` Nothing is remembered for ${issuerStoreKey(verifyHost())} on this computer: signing in once (npx @decidio/sdk login) remembers its DID, and then no --issuer is needed.`);
1462
+ return 2;
1463
+ }
1464
+ expectedIssuer = pick.anchor.did;
1465
+ anchor = `built in: ${pick.anchor.label}, ${pick.anchor.host}`;
1466
+ }
1467
+ }
1468
+ const { ok, lines } = runVerify(files, expectedIssuer, read, anchor);
1263
1469
  for (const l of lines)
1264
1470
  log(l);
1265
1471
  return ok ? 0 : 1;
@@ -1705,12 +1911,13 @@ async function cmdQuickstartInner(opts = {}) {
1705
1911
  const wrote = writeReceiptFile(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
1706
1912
  if (wrote === "conflict")
1707
1913
  return failTour(1);
1708
- const issuer = rec.body?.issuer;
1914
+ const issuer = hostAnchor(rec.body) ?? undefined;
1709
1915
  const v = verifyReceipt(rec.body.credential, issuer);
1710
- const kindLine = `${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", " + pinWords() : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
1711
- if (!v.valid || !issuer) {
1916
+ const serverInvalid = rec.body?.valid === false;
1917
+ const kindLine = `${v.valid && !serverInvalid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", " + pinWords() : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
1918
+ if (!v.valid || !issuer || serverInvalid) {
1712
1919
  log(` ${kindLine}`);
1713
- 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.`);
1920
+ err(` ✘ the denial's receipt did not verify PINNED (${!issuer ? "no trust anchor: the server named no issuer and none is remembered for this host" : !v.valid ? v.reason ?? "no reason given" : `the server's own check says it does NOT verify: ${rec.body?.reason ?? "no reason given"}`}) — the tour did not complete.`);
1714
1921
  return failTour(1);
1715
1922
  }
1716
1923
  block("④", "The record", [
@@ -1780,19 +1987,24 @@ async function cmdQuickstartInner(opts = {}) {
1780
1987
  const fname = receiptFileName(decisionId);
1781
1988
  if (writeReceiptFile(fname, JSON.stringify(rec.body.credential, null, 2) + "\n") === "conflict")
1782
1989
  return failTour(1);
1783
- const issuer = rec.body?.issuer;
1990
+ const issuer = hostAnchor(rec.body) ?? undefined;
1784
1991
  const v = verifyReceipt(rec.body.credential, issuer);
1785
- const kindLine = `${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", " + pinWords() : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
1786
- if (!v.valid || !issuer)
1992
+ const serverInvalid = rec.body?.valid === false;
1993
+ const kindLine = `${v.valid && !serverInvalid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", " + pinWords() : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
1994
+ if (!v.valid || !issuer || serverInvalid)
1787
1995
  log(` ${kindLine}`);
1788
1996
  if (!v.valid) {
1789
1997
  err(` ✘ the receipt did not verify (${v.reason ?? "no reason given"}) — the tour did not complete.`);
1790
1998
  return failTour(1);
1791
1999
  }
2000
+ if (serverInvalid) {
2001
+ err(` ✘ the server's own check says this record does NOT verify (${rec.body?.reason ?? "no reason given"}) — the tour did not complete.`);
2002
+ return failTour(1);
2003
+ }
1792
2004
  if (!issuer) {
1793
- err(` ⚠ the server did not return an issuer DID, so this check ran UNPINNED — it proves the`);
1794
- err(` receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
1795
- err(` Get your workspace's issuer DID from the app's record-verify panel, then re-check:`);
2005
+ err(` ⚠ the server named no issuer (expectedIssuer) and no DID is remembered for this host, so this check ran UNPINNED —`);
2006
+ err(` it proves the receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
2007
+ err(` Take your Decidio's issuer DID from ${API}/api/did (public, no sign-in) — never from the receipt — then re-check:`);
1796
2008
  err(` npx @decidio/sdk verify --issuer <did:key:...> ${fname}`);
1797
2009
  err(` ✘ the tour does not count an UNPINNED check as success.`);
1798
2010
  return failTour(1);
@@ -2198,8 +2410,10 @@ APPROVALS AND RECEIPTS
2198
2410
  npx @decidio/sdk receipt <id> [--json] download a sealed decision's Authority Receipt to a .json FILE; --json prints
2199
2411
  a summary to stdout (file name, evidence, issuer, the identities you may see) -
2200
2412
  the credential itself is the file
2201
- npx @decidio/sdk verify --issuer <did> <receipt.json> [more...] verify receipts OFFLINE, pinned to your
2202
- workspace's issuer (--allow-unpinned: structural check only)
2413
+ npx @decidio/sdk verify [--issuer <did>] <receipt.json> [more...] verify receipts OFFLINE, pinned to a trust
2414
+ anchor it prints first: --issuer if given, else the DID this computer remembered
2415
+ for your Decidio when you signed in, else the built-in hosted-service issuer
2416
+ (never the receipt's own issuer field; --allow-unpinned: structural check only)
2203
2417
 
2204
2418
  DEVELOPMENT
2205
2419
  npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
@@ -2208,7 +2422,7 @@ DEVELOPMENT
2208
2422
  NOTES
2209
2423
  Every NETWORK command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
2210
2424
  DECIDIO_API_URL says otherwise; the target host is announced on stderr each run. "verify" is
2211
- fully offline — it touches no network and reads no .env.
2425
+ fully offline — it touches no network and reads no .env (only the issuer DIDs remembered at sign-in).
2212
2426
  Update check: network commands (and the bare command on a terminal) ask the public npm registry once a day
2213
2427
  whether a newer version exists. If so,
2214
2428
  an interactive run asks "Upgrade now? [y/n]" first (y upgrades the way you installed - global, or in the
@@ -2225,8 +2439,9 @@ NOTES
2225
2439
  (quickstart, durable resume, workers, adapters).`;
2226
2440
  const GATED_CMDS = new Set(["quickstart", "init", "login", "doctor", "approvals", "receipt", "tokens"]);
2227
2441
  function semverNewer(a, b) {
2228
- const pa = a.split(".").map((x) => parseInt(x, 10)), pb = b.split(".").map((x) => parseInt(x, 10));
2229
- if (pa.length < 3 || pb.length < 3 || pa.some((x) => Number.isNaN(x)) || pb.some((x) => Number.isNaN(x)))
2442
+ const parse = (s) => (/^\d+\.\d+\.\d+$/.test(s) ? s.split(".").map((x) => parseInt(x, 10)) : null);
2443
+ const pa = parse(a), pb = parse(b);
2444
+ if (!pa || !pb)
2230
2445
  return false;
2231
2446
  for (let i = 0; i < 3; i++)
2232
2447
  if (pa[i] !== pb[i])
@@ -2237,7 +2452,7 @@ async function latestPublished() {
2237
2452
  const cache = joinPath(sessionDir(), "update-check.json");
2238
2453
  try {
2239
2454
  const c = JSON.parse(readFileSync(cache, "utf8"));
2240
- if (c && typeof c.latest === "string" && Date.now() - Number(c.checkedAt) < 24 * 3600 * 1000)
2455
+ if (c && typeof c.latest === "string" && /^\d+\.\d+\.\d+$/.test(c.latest) && Date.now() - Number(c.checkedAt) < 24 * 3600 * 1000)
2241
2456
  return c.latest;
2242
2457
  }
2243
2458
  catch { }
package/dist/demo-gate.js CHANGED
@@ -40,6 +40,7 @@ export async function startDemoGate(opts = {}) {
40
40
  let lastReason = "quickstart";
41
41
  let relayCode = null;
42
42
  let relayOk = null;
43
+ let committed = null;
43
44
  const relayFetch = async (path, init = {}) => {
44
45
  if (!relayBase)
45
46
  return null;
@@ -111,7 +112,8 @@ export async function startDemoGate(opts = {}) {
111
112
  return send({ ok: true, changed: true });
112
113
  if (path === "/agent/authorize") {
113
114
  verdict = null;
114
- return send({ decision: "route", decisionId: DECISION_ID, receiptId: "R-DEMO-1" });
115
+ committed = typeof body?.requestCommitment === "string" && /^[0-9a-f]{64}$/.test(body.requestCommitment) ? body.requestCommitment : null;
116
+ return send({ decision: "route", decisionId: DECISION_ID, receiptId: "R-DEMO-1", requestCommitment: committed });
115
117
  }
116
118
  if (path.startsWith("/agent/status/")) {
117
119
  if (!verdict && relayCode) {
@@ -123,8 +125,8 @@ export async function startDemoGate(opts = {}) {
123
125
  }
124
126
  }
125
127
  if (withdrawnDemo && !verdict)
126
- return send({ state: "rejected", reason: "quickstart: withdrawn — the tour stopped waiting" });
127
- return send(verdict === "reject" ? { state: "rejected", reason: "quickstart: rejected by the approver" } : { state: verdict === "approve" ? "approved" : "pending" });
128
+ return send({ state: "rejected", reason: "quickstart: withdrawn — the tour stopped waiting", requestCommitment: committed });
129
+ return send(verdict === "reject" ? { state: "rejected", reason: "quickstart: rejected by the approver", requestCommitment: committed } : { state: verdict === "approve" ? "approved" : "pending", requestCommitment: committed });
128
130
  }
129
131
  if (path.startsWith("/agent/withdraw/")) {
130
132
  if (verdict)
@@ -157,7 +159,7 @@ export async function startDemoGate(opts = {}) {
157
159
  }
158
160
  if (/^\/api\/records\/[^/]+\/verify$/.test(path)) {
159
161
  const cred = issueReceipt(verdict === "reject" ? "denial" : "approval");
160
- 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 });
162
+ return send({ dNumber: D_NUMBER, issuer: issuerDid, expectedIssuer: issuerDid, alg: "Ed25519", valid: true, signatureValid: true, contentBound: true, matchesRecord: true, payloadMatches: true, hash: cred.credentialSubject.contentHash, prevHash: null, credential: cred });
161
163
  }
162
164
  if (/^\/api\/records\/[^/]+$/.test(path))
163
165
  return send({ record: { confirmation: verdict === "approve" ? { status: "application_confirmed", confirmed: true } : { status: "pending", confirmed: false } } });
package/dist/index.d.ts CHANGED
@@ -1,11 +1,13 @@
1
- import type { ResumeController } from "./resume.js";
1
+ import type { ResumeController, ApprovalCheck } from "./resume.js";
2
+ import { type CommittedRequest } from "./signing.js";
2
3
  import { type EvidenceOutcome } from "./evidence.js";
3
- export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
4
- export type { PendingAction, PendingStore, ResumeController, ResumeOutcome } from "./resume.js";
4
+ export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded, checkApproval } from "./resume.js";
5
+ export type { PendingAction, PendingStore, ResumeController, ResumeOutcome, ApprovalCheck, ApprovalCheckCode, DecisionStatus } from "./resume.js";
5
6
  export { guard, Guard } from "./guard.js";
6
7
  export type { GuardFacadeConfig, ProtectOptions } from "./guard.js";
7
8
  export { DecidioHttpError, decisionIdProblem } from "./errors.js";
8
- export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential } from "./signing.js";
9
+ export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential, bindingMaterial, requestCommitment, idempotencyKey, committedRequestJson } from "./signing.js";
10
+ export type { BindingMaterial, CommittedRequest } from "./signing.js";
9
11
  export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
10
12
  export type { JsonValue, JsonPrimitive } from "./canonical.js";
11
13
  export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS } from "./evidence.js";
@@ -80,6 +82,36 @@ export declare class DecidioRejectedError extends Error {
80
82
  decisionId: string;
81
83
  constructor(reason: string, decisionId: string);
82
84
  }
85
+ /** THE REQUEST CHANGED AFTER APPROVAL (0.7.0). Decidio records an approval for `decisionId`, but it is
86
+ * bound (by a keyed commitment) to a DIFFERENT request than the one about to run — the arguments, the
87
+ * amount or the described context changed after a human approved, or this agent's key material changed
88
+ * since it asked. Nothing was executed. A new request needs a new approval: reject this decision and
89
+ * run the action again. Extends DecidioBlockedError, so code that already treats a block as "did not
90
+ * run" keeps doing so. */
91
+ export declare class DecidioRequestChangedError extends DecidioBlockedError {
92
+ readonly code: "request_changed";
93
+ constructor(decisionId: string, detail?: string);
94
+ }
95
+ /** An approval that cannot be TIED to this request (0.7.0): Decidio answered without the request
96
+ * commitment this SDK sent (`commitment_missing` — the gate predates request binding), or holds one
97
+ * this process cannot recompute (`unverifiable` — no or unreadable key material, or a malformed
98
+ * answer). Fail closed: nothing was executed. Extends DecidioBlockedError. */
99
+ export declare class DecidioApprovalUnverifiedError extends DecidioBlockedError {
100
+ readonly code: "commitment_missing" | "unverifiable";
101
+ constructor(decisionId: string, code: "commitment_missing" | "unverifiable", detail: string);
102
+ }
103
+ /** The typed refusal for an approval check that did not pass — ONE mapping for every door that throws
104
+ * (inline, LangGraph, Temporal, Inngest, OpenAI Agents). `pending` is included for the doors that
105
+ * cannot wait again; the doors that can (LangGraph, Temporal, Inngest) wait instead of calling this. */
106
+ export declare function approvalRefusal(check: ApprovalCheck): Error;
107
+ /** What a routed approval must be bound to on the CORE door: exactly what executes — the canonical
108
+ * argument snapshot plus the described action/amount/scope. The durable resume recomputes the same
109
+ * shape over the parked payload. */
110
+ export declare function coreRequest(ctx: {
111
+ action: string;
112
+ amount: number;
113
+ scope?: string | null;
114
+ }, args: readonly unknown[]): CommittedRequest;
83
115
  export declare class DecidioTimeoutError extends Error {
84
116
  decisionId: string;
85
117
  withdrawn: boolean;