@decidio/sdk 0.6.3 → 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/CHANGELOG.md +189 -0
- package/README.md +105 -24
- package/dist/adapters/_gate.d.ts +27 -2
- package/dist/adapters/_gate.js +33 -2
- package/dist/adapters/langgraph.d.ts +5 -11
- package/dist/adapters/langgraph.js +57 -6
- package/dist/adapters/openai.d.ts +40 -10
- package/dist/adapters/openai.js +19 -11
- package/dist/adapters/temporal.d.ts +21 -2
- package/dist/adapters/temporal.js +18 -5
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +257 -38
- package/dist/demo-gate.js +14 -3
- package/dist/guard.js +2 -2
- package/dist/index.d.ts +42 -5
- package/dist/index.js +69 -8
- package/dist/inngest.d.ts +5 -2
- package/dist/inngest.js +23 -15
- package/dist/resume.d.ts +98 -5
- package/dist/resume.js +203 -109
- package/dist/signing.d.ts +35 -0
- package/dist/signing.js +40 -1
- package/openapi.yaml +65 -6
- package/package.json +2 -2
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
|
-
|
|
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 —
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1188
|
-
|
|
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
|
|
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
|
|
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;
|
|
@@ -1610,8 +1816,12 @@ async function cmdQuickstartInner(opts = {}) {
|
|
|
1610
1816
|
log(`③ the tour's blocking call gave up after 5 minutes while waiting for your answer, so your answer is NOT posted:`);
|
|
1611
1817
|
if (DEMO)
|
|
1612
1818
|
log(` the demo ends here; nothing is kept (the demo's agent, token and folder go now). Re-run and answer within 5 minutes.`);
|
|
1819
|
+
else if (earlyError.withdrawn) {
|
|
1820
|
+
log(` the request was WITHDRAWN from your queue (it no longer waits for an answer; deciding it there does nothing). Re-run the tour and answer within 5 minutes.`);
|
|
1821
|
+
log(` NOTE: this tour's credentials are still live in ${envFile} - remove them: npx @decidio/sdk quickstart cleanup`);
|
|
1822
|
+
}
|
|
1613
1823
|
else {
|
|
1614
|
-
log(` the request is still ROUTED in your queue - decide it there, or re-run the tour and answer within 5 minutes.`);
|
|
1824
|
+
log(` the request is still ROUTED in your queue (the withdraw call did not land) - decide it there, or re-run the tour and answer within 5 minutes.`);
|
|
1615
1825
|
log(` NOTE: this tour's credentials are still live in ${envFile} - remove them: npx @decidio/sdk quickstart cleanup`);
|
|
1616
1826
|
}
|
|
1617
1827
|
log("");
|
|
@@ -1701,12 +1911,13 @@ async function cmdQuickstartInner(opts = {}) {
|
|
|
1701
1911
|
const wrote = writeReceiptFile(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
|
|
1702
1912
|
if (wrote === "conflict")
|
|
1703
1913
|
return failTour(1);
|
|
1704
|
-
const issuer = rec.body
|
|
1914
|
+
const issuer = hostAnchor(rec.body) ?? undefined;
|
|
1705
1915
|
const v = verifyReceipt(rec.body.credential, issuer);
|
|
1706
|
-
const
|
|
1707
|
-
|
|
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) {
|
|
1708
1919
|
log(` ${kindLine}`);
|
|
1709
|
-
err(` ✘ the denial's receipt did not verify PINNED (${!issuer ? "no issuer
|
|
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.`);
|
|
1710
1921
|
return failTour(1);
|
|
1711
1922
|
}
|
|
1712
1923
|
block("④", "The record", [
|
|
@@ -1776,19 +1987,24 @@ async function cmdQuickstartInner(opts = {}) {
|
|
|
1776
1987
|
const fname = receiptFileName(decisionId);
|
|
1777
1988
|
if (writeReceiptFile(fname, JSON.stringify(rec.body.credential, null, 2) + "\n") === "conflict")
|
|
1778
1989
|
return failTour(1);
|
|
1779
|
-
const issuer = rec.body
|
|
1990
|
+
const issuer = hostAnchor(rec.body) ?? undefined;
|
|
1780
1991
|
const v = verifyReceipt(rec.body.credential, issuer);
|
|
1781
|
-
const
|
|
1782
|
-
|
|
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)
|
|
1783
1995
|
log(` ${kindLine}`);
|
|
1784
1996
|
if (!v.valid) {
|
|
1785
1997
|
err(` ✘ the receipt did not verify (${v.reason ?? "no reason given"}) — the tour did not complete.`);
|
|
1786
1998
|
return failTour(1);
|
|
1787
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
|
+
}
|
|
1788
2004
|
if (!issuer) {
|
|
1789
|
-
err(` ⚠ the server
|
|
1790
|
-
err(` receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
|
|
1791
|
-
err(`
|
|
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:`);
|
|
1792
2008
|
err(` npx @decidio/sdk verify --issuer <did:key:...> ${fname}`);
|
|
1793
2009
|
err(` ✘ the tour does not count an UNPINNED check as success.`);
|
|
1794
2010
|
return failTour(1);
|
|
@@ -2194,8 +2410,10 @@ APPROVALS AND RECEIPTS
|
|
|
2194
2410
|
npx @decidio/sdk receipt <id> [--json] download a sealed decision's Authority Receipt to a .json FILE; --json prints
|
|
2195
2411
|
a summary to stdout (file name, evidence, issuer, the identities you may see) -
|
|
2196
2412
|
the credential itself is the file
|
|
2197
|
-
npx @decidio/sdk verify --issuer <did> <receipt.json> [more...] verify receipts OFFLINE, pinned to
|
|
2198
|
-
|
|
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)
|
|
2199
2417
|
|
|
2200
2418
|
DEVELOPMENT
|
|
2201
2419
|
npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
|
|
@@ -2204,7 +2422,7 @@ DEVELOPMENT
|
|
|
2204
2422
|
NOTES
|
|
2205
2423
|
Every NETWORK command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
|
|
2206
2424
|
DECIDIO_API_URL says otherwise; the target host is announced on stderr each run. "verify" is
|
|
2207
|
-
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).
|
|
2208
2426
|
Update check: network commands (and the bare command on a terminal) ask the public npm registry once a day
|
|
2209
2427
|
whether a newer version exists. If so,
|
|
2210
2428
|
an interactive run asks "Upgrade now? [y/n]" first (y upgrades the way you installed - global, or in the
|
|
@@ -2221,8 +2439,9 @@ NOTES
|
|
|
2221
2439
|
(quickstart, durable resume, workers, adapters).`;
|
|
2222
2440
|
const GATED_CMDS = new Set(["quickstart", "init", "login", "doctor", "approvals", "receipt", "tokens"]);
|
|
2223
2441
|
function semverNewer(a, b) {
|
|
2224
|
-
const
|
|
2225
|
-
|
|
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)
|
|
2226
2445
|
return false;
|
|
2227
2446
|
for (let i = 0; i < 3; i++)
|
|
2228
2447
|
if (pa[i] !== pb[i])
|
|
@@ -2233,7 +2452,7 @@ async function latestPublished() {
|
|
|
2233
2452
|
const cache = joinPath(sessionDir(), "update-check.json");
|
|
2234
2453
|
try {
|
|
2235
2454
|
const c = JSON.parse(readFileSync(cache, "utf8"));
|
|
2236
|
-
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)
|
|
2237
2456
|
return c.latest;
|
|
2238
2457
|
}
|
|
2239
2458
|
catch { }
|
package/dist/demo-gate.js
CHANGED
|
@@ -35,10 +35,12 @@ export async function startDemoGate(opts = {}) {
|
|
|
35
35
|
const issuerDid = "did:key:z" + b58(Buffer.concat([Buffer.from([0xed, 0x01]), issuerRaw]));
|
|
36
36
|
const sealedAt = new Date().toISOString();
|
|
37
37
|
let verdict = null;
|
|
38
|
+
let withdrawnDemo = false;
|
|
38
39
|
let decidedBy = "the human at the terminal";
|
|
39
40
|
let lastReason = "quickstart";
|
|
40
41
|
let relayCode = null;
|
|
41
42
|
let relayOk = null;
|
|
43
|
+
let committed = null;
|
|
42
44
|
const relayFetch = async (path, init = {}) => {
|
|
43
45
|
if (!relayBase)
|
|
44
46
|
return null;
|
|
@@ -110,7 +112,8 @@ export async function startDemoGate(opts = {}) {
|
|
|
110
112
|
return send({ ok: true, changed: true });
|
|
111
113
|
if (path === "/agent/authorize") {
|
|
112
114
|
verdict = null;
|
|
113
|
-
|
|
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 });
|
|
114
117
|
}
|
|
115
118
|
if (path.startsWith("/agent/status/")) {
|
|
116
119
|
if (!verdict && relayCode) {
|
|
@@ -121,7 +124,15 @@ export async function startDemoGate(opts = {}) {
|
|
|
121
124
|
lastReason = "decided on the demo page";
|
|
122
125
|
}
|
|
123
126
|
}
|
|
124
|
-
|
|
127
|
+
if (withdrawnDemo && !verdict)
|
|
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 });
|
|
130
|
+
}
|
|
131
|
+
if (path.startsWith("/agent/withdraw/")) {
|
|
132
|
+
if (verdict)
|
|
133
|
+
return send({ code: "NOT_OPEN", state: verdict === "approve" ? "approved" : "rejected", decisionId: DECISION_ID }, 409);
|
|
134
|
+
withdrawnDemo = true;
|
|
135
|
+
return send({ ok: true, withdrawn: true, state: "rejected", decisionId: DECISION_ID, reason: "requester_stopped_waiting" });
|
|
125
136
|
}
|
|
126
137
|
if (/^\/api\/decisions\/[^/]+\/(approve|reject)$/.test(path)) {
|
|
127
138
|
const already = verdict !== null;
|
|
@@ -148,7 +159,7 @@ export async function startDemoGate(opts = {}) {
|
|
|
148
159
|
}
|
|
149
160
|
if (/^\/api\/records\/[^/]+\/verify$/.test(path)) {
|
|
150
161
|
const cred = issueReceipt(verdict === "reject" ? "denial" : "approval");
|
|
151
|
-
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 });
|
|
152
163
|
}
|
|
153
164
|
if (/^\/api\/records\/[^/]+$/.test(path))
|
|
154
165
|
return send({ record: { confirmation: verdict === "approve" ? { status: "application_confirmed", confirmed: true } : { status: "pending", confirmed: false } } });
|
package/dist/guard.js
CHANGED
|
@@ -65,8 +65,8 @@ export class Guard {
|
|
|
65
65
|
if ((opts?.mode ?? "durable") === "blocking") {
|
|
66
66
|
const ceilingMs = opts?.pollTimeoutMs ?? base.pollTimeoutMs ?? 600_000;
|
|
67
67
|
console.warn(`[decidio] WARNING: blocking wait — this call gives up after ${Math.round(ceilingMs / 1000)}s ` +
|
|
68
|
-
`(pollTimeoutMs), and when it does,
|
|
69
|
-
`a later approval
|
|
68
|
+
`(pollTimeoutMs), and when it does, this process stops waiting and the request is withdrawn in Decidio: ` +
|
|
69
|
+
`a later approval does nothing. Only safe for short, watched approvals — omit mode:"blocking" for ` +
|
|
70
70
|
`durable async resume on real (minutes-to-days) approvals.`);
|
|
71
71
|
return withApproval(fn, describe, base, { ...(opts?.evidence ? { evidence: opts.evidence } : {}), ...(opts?.withContext ? { withContext: true } : {}) });
|
|
72
72
|
}
|
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,9 +82,44 @@ 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;
|
|
118
|
+
/** `withdrawn` (0.6.4+): true when the SDK told Decidio it gave up and the request was closed as
|
|
119
|
+
* WITHDRAWN (the queue no longer carries it; a later approval does nothing); false when that
|
|
120
|
+
* best-effort call failed, so the request may still be open in Decidio. Nothing was executed
|
|
121
|
+
* locally either way. */
|
|
122
|
+
constructor(decisionId: string, withdrawn?: boolean);
|
|
86
123
|
}
|
|
87
124
|
/** A transport failure with GUIDANCE instead of a raw fetch stack. Carries the endpoint, the
|
|
88
125
|
* operation, the underlying code, and the safety state a caller cares about most: whether
|