@decidio/sdk 0.6.4 → 0.8.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 +167 -0
- package/README.md +104 -24
- package/dist/adapters/_gate.d.ts +47 -2
- package/dist/adapters/_gate.js +49 -3
- 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 +59 -0
- package/dist/cli.js +2095 -194
- package/dist/demo-gate.js +6 -4
- package/dist/index.d.ts +36 -4
- package/dist/index.js +50 -5
- package/dist/inngest.d.ts +5 -2
- package/dist/inngest.js +23 -15
- package/dist/resume.d.ts +108 -5
- package/dist/resume.js +215 -109
- package/dist/signing.d.ts +40 -0
- package/dist/signing.js +43 -1
- package/openapi.yaml +65 -6
- package/package.json +2 -2
package/dist/resume.js
CHANGED
|
@@ -3,11 +3,11 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { userInfo } from "node:os";
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
6
|
-
import { signConfirm, canonicalize as jcs, assertSafeTransport, carriesCredential } from "./signing.js";
|
|
6
|
+
import { signConfirm, canonicalize as jcs, assertSafeTransport, carriesCredential, bindingMaterial, requestCommitment } from "./signing.js";
|
|
7
7
|
import { canonicalize as toCanonical, DecidioUnsafeArgumentsError } from "./canonical.js";
|
|
8
8
|
import { readConfirmResponse, buildConfirmBody, tierConfirms, tombstoneTier } from "./evidence.js";
|
|
9
9
|
import { DecidioHttpError, decisionIdProblem } from "./errors.js";
|
|
10
|
-
const argsBindingBytes = (p) => Buffer.from(jcs({ decisionId: p.decisionId, action: p.action, amount: p.amount, ...(p.scope !== undefined ? { scope: p.scope } : {}), args: p.args }), "utf8");
|
|
10
|
+
const argsBindingBytes = (p) => Buffer.from(jcs({ decisionId: p.decisionId, action: p.action, amount: p.amount, ...(p.scope !== undefined ? { scope: p.scope } : {}), args: p.args, ...(p.requestCommitment !== undefined ? { requestCommitment: p.requestCommitment } : {}) }), "utf8");
|
|
11
11
|
function signArgs(p, agentKey, webhookSecret) {
|
|
12
12
|
const bytes = argsBindingBytes(p);
|
|
13
13
|
if (agentKey) {
|
|
@@ -826,6 +826,62 @@ async function statusOf(cfg, decisionId) {
|
|
|
826
826
|
throw new DecidioHttpError(r.status, `/agent/status/${decisionId}`);
|
|
827
827
|
return r.json();
|
|
828
828
|
}
|
|
829
|
+
export async function checkApproval(cfg, decisionId, request, opts = {}) {
|
|
830
|
+
const st = opts.status ?? await statusOf(cfg, decisionId);
|
|
831
|
+
const state = typeof st?.state === "string" ? st.state : "";
|
|
832
|
+
const serverReason = typeof st?.reason === "string" && st.reason ? st.reason : undefined;
|
|
833
|
+
const no = (code, reason) => ({ ok: false, decisionId, state, code, ...(reason ? { reason } : {}) });
|
|
834
|
+
if (state === "rejected")
|
|
835
|
+
return no("rejected", serverReason);
|
|
836
|
+
if (state === "blocked")
|
|
837
|
+
return no("blocked", serverReason);
|
|
838
|
+
if (state === "pending")
|
|
839
|
+
return no("pending", `Decidio records decision ${decisionId} as pending - no human has approved it`);
|
|
840
|
+
if (state !== "approved" && state !== "auto_approved")
|
|
841
|
+
return no("unrecognized_state", `Decidio answered state ${JSON.stringify(st?.state ?? null)} for decision ${decisionId}, which is not an approval this SDK recognises (an answer it does not understand is never permission)`);
|
|
842
|
+
if (st?.spent === true)
|
|
843
|
+
return no("spent", `decision ${decisionId} was approved once and that approval is already spent (an execution is recorded on it) - ask again for a fresh approval; nothing was executed`);
|
|
844
|
+
if (typeof st?.grantExpiresAt === "string") {
|
|
845
|
+
const until = Date.parse(st.grantExpiresAt);
|
|
846
|
+
if (!Number.isFinite(until))
|
|
847
|
+
return no("unverifiable", `Decidio answered a malformed grant expiry for decision ${decisionId} (${JSON.stringify(st.grantExpiresAt)}), so the approval cannot be known to be in force`);
|
|
848
|
+
if (until <= Date.now())
|
|
849
|
+
return no("expired", `decision ${decisionId}'s approval expired at ${st.grantExpiresAt} before anything ran - ask again for a fresh approval; nothing was executed`);
|
|
850
|
+
}
|
|
851
|
+
else if (typeof st?.spent === "boolean" && st?.grantExpiresAt === null) {
|
|
852
|
+
return no("expired", `decision ${decisionId} is approved but Decidio names no expiry for its grant (its age is unknown) - ask again for a fresh approval; nothing was executed`);
|
|
853
|
+
}
|
|
854
|
+
const server = st?.requestCommitment;
|
|
855
|
+
if (server !== undefined && server !== null) {
|
|
856
|
+
if (typeof server !== "string" || !/^[0-9a-f]{64}$/.test(server))
|
|
857
|
+
return no("unverifiable", `Decidio answered a malformed request commitment for decision ${decisionId}, so the approval cannot be tied to this request`);
|
|
858
|
+
let material;
|
|
859
|
+
try {
|
|
860
|
+
material = bindingMaterial(cfg);
|
|
861
|
+
}
|
|
862
|
+
catch (e) {
|
|
863
|
+
return no("unverifiable", String(e?.message ?? e));
|
|
864
|
+
}
|
|
865
|
+
if (!material)
|
|
866
|
+
return no("unverifiable", `Decidio bound decision ${decisionId} to a request commitment, but this process has no key material to recompute it (DECIDIO_AGENT_KEY + DECIDIO_AGENT_DID, or DECIDIO_API_TOKEN) - restore the agent's environment`);
|
|
867
|
+
let fresh;
|
|
868
|
+
try {
|
|
869
|
+
fresh = requestCommitment(material, request);
|
|
870
|
+
}
|
|
871
|
+
catch (e) {
|
|
872
|
+
return no("unverifiable", `the request about to run could not be canonicalized for the commitment check (${e?.message ?? e})`);
|
|
873
|
+
}
|
|
874
|
+
const a = Buffer.from(fresh), b = Buffer.from(server);
|
|
875
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
876
|
+
return no("request_changed", `the request changed after approval: decision ${decisionId} was approved for a different request than the one about to run (or this agent's key material changed since it asked)`);
|
|
877
|
+
}
|
|
878
|
+
return { ok: true, decisionId, state };
|
|
879
|
+
}
|
|
880
|
+
if (opts.sent ?? !!((cfg.agentKey && cfg.agentDid) || cfg.apiToken)) {
|
|
881
|
+
return no("commitment_missing", `Decidio answered for decision ${decisionId} without the request commitment this SDK sent, so it cannot confirm the approval covers THIS request - the gate predates request binding (upgrade the Decidio gate; nothing was executed)`);
|
|
882
|
+
}
|
|
883
|
+
return { ok: true, decisionId, state };
|
|
884
|
+
}
|
|
829
885
|
const V2_STORE_METHODS = [
|
|
830
886
|
"put", "get", "delete", "list",
|
|
831
887
|
"claim", "markInvoking", "recordExecuted", "markIndeterminate", "markReported",
|
|
@@ -842,6 +898,8 @@ function assertStoreV2(store) {
|
|
|
842
898
|
` Fix: implement the missing methods (see the PendingStore docs), or use the shipped FilePendingStore / MemoryPendingStore. Nothing was started.`);
|
|
843
899
|
}
|
|
844
900
|
let reportDirWarned = false;
|
|
901
|
+
export const WEBHOOK_SECRET_REMEDY = "Set webhookSecret (DECIDIO_WEBHOOK_SECRET) to the dedicated secret your Decidio deployment signs resume webhooks with - never the server's API_AUTH_TOKEN (its admin bearer, which must not be copied onto an agent host) - or use the poll worker (guard.worker()), which needs no inbound secret.";
|
|
902
|
+
const NO_WEBHOOK_SECRET = `resume webhook refused: no webhookSecret is configured (and allowUnsigned is off). Since 0.7.0 the API token is no longer accepted as the webhook key. ${WEBHOOK_SECRET_REMEDY}`;
|
|
845
903
|
function webhookBody(o) {
|
|
846
904
|
return {
|
|
847
905
|
ok: true,
|
|
@@ -863,7 +921,15 @@ function webhookBody(o) {
|
|
|
863
921
|
export function createDecidioResume(opts) {
|
|
864
922
|
assertStoreV2(opts.store);
|
|
865
923
|
const registry = new Map();
|
|
866
|
-
const secret = opts.webhookSecret ??
|
|
924
|
+
const secret = opts.webhookSecret ?? "";
|
|
925
|
+
const integrityKey = () => ctrl.webhookSecret ?? ctrl.apiToken ?? "";
|
|
926
|
+
let unsignedHandlerWarned = false;
|
|
927
|
+
const warnIfNoWebhookKey = () => {
|
|
928
|
+
if (secret || opts.allowUnsigned || unsignedHandlerWarned)
|
|
929
|
+
return;
|
|
930
|
+
unsignedHandlerWarned = true;
|
|
931
|
+
console.warn(`[decidio] this resume webhook handler has no webhookSecret, so it will REFUSE every webhook (401).${opts.apiToken ? " Since 0.7.0 the API token is no longer accepted as the webhook key — a leaked agent token must not be able to sign an approval signal." : ""} ${WEBHOOK_SECRET_REMEDY}`);
|
|
932
|
+
};
|
|
867
933
|
if (opts.reportDir && !reportDirWarned) {
|
|
868
934
|
reportDirWarned = true;
|
|
869
935
|
console.warn(`[decidio] reportDir is ignored since 0.2.0 — execution evidence now lives in the PendingStore's own execution ledger, beside the state that makes a decision non-replayable. Reports written by 0.1.x under <pendingDir>/reports are migrated into that ledger on first use of a FilePendingStore, so nothing is lost; remove the option when convenient.`);
|
|
@@ -888,7 +954,7 @@ export function createDecidioResume(opts) {
|
|
|
888
954
|
let journalled = false;
|
|
889
955
|
if (haveReported) {
|
|
890
956
|
try {
|
|
891
|
-
await ctrl.store.recordExecuted(decisionId, attemptId, reported, signEvidence(decisionId, attemptId, reported, ctrl.agentKey,
|
|
957
|
+
await ctrl.store.recordExecuted(decisionId, attemptId, reported, signEvidence(decisionId, attemptId, reported, ctrl.agentKey, integrityKey()), buildProblem);
|
|
892
958
|
journalled = true;
|
|
893
959
|
}
|
|
894
960
|
catch (e) {
|
|
@@ -969,13 +1035,146 @@ export function createDecidioResume(opts) {
|
|
|
969
1035
|
refusalWarned.add(decisionId);
|
|
970
1036
|
console.error(msg + (tail ?? " (This prints once per worker process — the entry stays parked until you act.)"));
|
|
971
1037
|
};
|
|
1038
|
+
async function resolveCore({ decisionId, verdict, reason }, prefetched) {
|
|
1039
|
+
const finish = (o) => { if (o.status !== "unknown")
|
|
1040
|
+
opts.onResolved?.(o); return o; };
|
|
1041
|
+
const idProblem = decisionIdProblem(decisionId);
|
|
1042
|
+
if (idProblem)
|
|
1043
|
+
return { status: "unknown", decisionId: String(decisionId ?? ""), reason: `not a decision id this SDK will act on (${idProblem})` };
|
|
1044
|
+
const pending = await ctrl.store.get(decisionId);
|
|
1045
|
+
if (!pending) {
|
|
1046
|
+
const mapped = outcomeFromHead(await ctrl.store.getExecution(decisionId), decisionId);
|
|
1047
|
+
if (mapped)
|
|
1048
|
+
return finish(mapped);
|
|
1049
|
+
return { status: "unknown", decisionId };
|
|
1050
|
+
}
|
|
1051
|
+
if (verdict === "rejected" || verdict === "blocked") {
|
|
1052
|
+
await ctrl.store.markDiscarded(decisionId, verdict, reason);
|
|
1053
|
+
await ctrl.store.delete(decisionId);
|
|
1054
|
+
return finish({ status: verdict, decisionId, action: pending.action, reason });
|
|
1055
|
+
}
|
|
1056
|
+
if (verdict !== "approved" && verdict !== "auto_approved")
|
|
1057
|
+
return { status: "unknown", decisionId };
|
|
1058
|
+
if (pending.decisionId !== decisionId) {
|
|
1059
|
+
refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: the parked entry in this slot belongs to a DIFFERENT decision (${pending.decisionId}) — the pending file was moved or swapped. Delete it and re-run the action.`);
|
|
1060
|
+
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload decision-id mismatch" });
|
|
1061
|
+
}
|
|
1062
|
+
const strictBinding = !!ctrl.agentKey;
|
|
1063
|
+
const integrity = verifyArgs(pending, ctrl.agentKey, integrityKey());
|
|
1064
|
+
if (integrity === "mismatch") {
|
|
1065
|
+
refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: the parked payload FAILED its integrity check — the arguments on disk are not the arguments that were routed for approval. The pending file is untouched for inspection; delete it and re-run the action to request a fresh approval.`);
|
|
1066
|
+
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload integrity check failed" });
|
|
1067
|
+
}
|
|
1068
|
+
if (integrity === "unverifiable") {
|
|
1069
|
+
refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: the parked payload is signed but this process lacks the key material to verify it (DECIDIO_AGENT_KEY / DECIDIO_WEBHOOK_SECRET). Restore the agent's env and retry.`);
|
|
1070
|
+
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unverifiable" });
|
|
1071
|
+
}
|
|
1072
|
+
if (integrity === "unbound" && strictBinding) {
|
|
1073
|
+
refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: the parked payload has NO integrity signature, but this agent has keys and would have signed it — a missing signature means the file was tampered with. Delete it and re-run the action.`);
|
|
1074
|
+
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unsigned (expected a signature)" });
|
|
1075
|
+
}
|
|
1076
|
+
if (integrity === "unbound")
|
|
1077
|
+
console.warn(`[decidio] executing ${decisionId} from an UNBOUND parked entry (this agent has no key material to bind or verify payloads) — its arguments cannot be integrity-checked. Run \`npx @decidio/sdk init\` to enable binding.`);
|
|
1078
|
+
const fn = registry.get(pending.action)?.fn;
|
|
1079
|
+
if (!fn) {
|
|
1080
|
+
console.error(`[decidio] cannot resume ${decisionId}: no function is registered for action "${pending.action}" in THIS process.`);
|
|
1081
|
+
console.error(` A worker only re-executes actions registered where it runs. At startup, either declare the action on the wrapper:`);
|
|
1082
|
+
console.error(` const doIt = guard.protect(doItRaw, describe, { action: "${pending.action}" });`);
|
|
1083
|
+
console.error(` or register the raw function directly:`);
|
|
1084
|
+
console.error(` guard.register("${pending.action}", doItRaw);`);
|
|
1085
|
+
console.error(` then start the worker. The approved action stays parked until then — nothing is lost.`);
|
|
1086
|
+
return finish({ status: "no_handler", decisionId, action: pending.action, reason: `no fn registered for action "${pending.action}" in this process — declare { action } on protect(), or call guard.register("${pending.action}", fn) at startup` });
|
|
1087
|
+
}
|
|
1088
|
+
const attemptId = randomUUID();
|
|
1089
|
+
const claim = await ctrl.store.claim(decisionId, attemptId);
|
|
1090
|
+
if (!claim.ok) {
|
|
1091
|
+
if (claim.state && NON_REPLAYABLE.includes(claim.state)) {
|
|
1092
|
+
refuseOnce(decisionId, claim.state === "discarded"
|
|
1093
|
+
? `[decidio] REFUSING to execute ${decisionId}: it was DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}) and that answer is final. Nothing ran. A denial cannot be reconciled or retried — if this action should now proceed, it needs a NEW request and a new approval.`
|
|
1094
|
+
: `[decidio] REFUSING to execute ${decisionId}: its durable state is ${claim.state} — invocation already began, so re-running it could duplicate a committed effect. Inspect it with guard.resume.recover(); settle it with guard.resume.reconcile("${decisionId}", …).`, claim.state === "discarded" ? " (This prints once per worker process. The decision is closed — no action is needed.)" : undefined);
|
|
1095
|
+
if (claim.state === "invoking" || claim.state === "indeterminate") {
|
|
1096
|
+
return finish({ status: "indeterminate", decisionId, action: pending.action, reason: claim.state === "invoking"
|
|
1097
|
+
? `this decision is in durable state invoking: its action may still be running, may have crashed before any effect, or may have finished without reporting - it may or may not have run. Nothing will retry it; check the worker and the downstream system, then settle it with reconcile("${decisionId}", …).`
|
|
1098
|
+
: claim.reason });
|
|
1099
|
+
}
|
|
1100
|
+
return finish({ status: "already_resolved", decisionId, action: pending.action, reason: claim.state === "discarded" ? `this decision was already DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}); that answer is final and this signal lost the race to it` : `this decision already ran (durable state ${claim.state}); this signal lost the race to it` });
|
|
1101
|
+
}
|
|
1102
|
+
const mapped = outcomeFromHead(await ctrl.store.getExecution(decisionId), decisionId, pending.action);
|
|
1103
|
+
if (mapped)
|
|
1104
|
+
return finish(mapped);
|
|
1105
|
+
return finish({ status: "lost_race", decisionId, action: pending.action, reason: `another attempt holds the pre-invocation claim on this decision${claim.state ? ` (durable state ${claim.state})` : ""}; this signal lost the race and did nothing. If that attempt is alive it will report; if it died, recover() lists the decision and reconcile() can settle it` });
|
|
1106
|
+
}
|
|
1107
|
+
let check;
|
|
1108
|
+
try {
|
|
1109
|
+
check = await checkApproval(ctrl, decisionId, { action: pending.action, amount: pending.amount, ...(pending.scope !== undefined ? { scope: pending.scope } : {}), args: pending.args }, { sent: !!pending.requestCommitment, ...(prefetched ? { status: prefetched } : {}) });
|
|
1110
|
+
}
|
|
1111
|
+
catch (e) {
|
|
1112
|
+
await ctrl.store.releasePreinvoke(decisionId, attemptId).catch(() => { });
|
|
1113
|
+
throw e;
|
|
1114
|
+
}
|
|
1115
|
+
if (!check.ok) {
|
|
1116
|
+
if (check.code === "rejected" || check.code === "blocked") {
|
|
1117
|
+
await ctrl.store.markDiscarded(decisionId, check.code, check.reason);
|
|
1118
|
+
await ctrl.store.delete(decisionId);
|
|
1119
|
+
return finish({ status: check.code, decisionId, action: pending.action, ...(check.reason ? { reason: check.reason } : {}) });
|
|
1120
|
+
}
|
|
1121
|
+
await ctrl.store.releasePreinvoke(decisionId, attemptId).catch(() => { });
|
|
1122
|
+
if (check.code === "pending" || check.code === "unrecognized_state") {
|
|
1123
|
+
return { status: "not_approved", decisionId, action: pending.action, reason: `${check.reason}. This signal was a wake-up, not an approval: nothing ran, and the entry stays parked for the real answer.` };
|
|
1124
|
+
}
|
|
1125
|
+
refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: ${check.reason}. Nothing ran; the parked entry is untouched for inspection. If the request really changed, reject this decision and re-run the action to request a fresh approval.`);
|
|
1126
|
+
return finish({ status: "request_unverified", decisionId, action: pending.action, reason: check.reason });
|
|
1127
|
+
}
|
|
1128
|
+
try {
|
|
1129
|
+
await ctrl.store.markInvoking(decisionId, attemptId);
|
|
1130
|
+
}
|
|
1131
|
+
catch (e) {
|
|
1132
|
+
await ctrl.store.releasePreinvoke(decisionId, attemptId).catch(() => { });
|
|
1133
|
+
console.error(`[decidio] ${decisionId}: could not record the invocation boundary (${e?.message ?? e}) — refusing to run the action rather than run it unrecorded.`);
|
|
1134
|
+
const mapped = outcomeFromHead(await ctrl.store.getExecution(decisionId), decisionId, pending.action);
|
|
1135
|
+
if (mapped)
|
|
1136
|
+
return finish(mapped);
|
|
1137
|
+
return finish({ status: "lost_race", decisionId, action: pending.action, reason: `could not durably record the invocation boundary (${e?.message ?? e}); nothing ran, the entry stays parked, and a later signal may retry` });
|
|
1138
|
+
}
|
|
1139
|
+
let result;
|
|
1140
|
+
try {
|
|
1141
|
+
const entry = registry.get(pending.action);
|
|
1142
|
+
result = entry?.withContext
|
|
1143
|
+
? await fn({ decisionId, attemptId, idempotencyKey: decisionId }, ...pending.args)
|
|
1144
|
+
: await fn(...pending.args);
|
|
1145
|
+
}
|
|
1146
|
+
catch (e) {
|
|
1147
|
+
const why = String(e?.message ?? e);
|
|
1148
|
+
await ctrl.store.markIndeterminate(decisionId, attemptId, why).catch((se) => console.error(`[decidio] ${decisionId}: could not record INDETERMINATE (${se?.message ?? se}) — treat this decision as possibly executed.`));
|
|
1149
|
+
console.error(`[decidio] ${decisionId}: "${pending.action}" was INVOKED and then threw (${why}). Whether the downstream effect committed is not knowable from here, so it will NOT be retried automatically. Check the downstream system, then: guard.resume.reconcile("${decisionId}", { outcome: "committed", evidence: … }) or { outcome: "not_committed", reason: … }.`);
|
|
1150
|
+
return finish({ status: "indeterminate", decisionId, action: pending.action, reason: why });
|
|
1151
|
+
}
|
|
1152
|
+
return finish(await reportAfterExecution(pending, decisionId, result, attemptId));
|
|
1153
|
+
}
|
|
1154
|
+
async function wake(decisionId) {
|
|
1155
|
+
const idProblem = decisionIdProblem(decisionId);
|
|
1156
|
+
if (idProblem)
|
|
1157
|
+
return { status: "unknown", decisionId: String(decisionId ?? ""), reason: `not a decision id this SDK will act on (${idProblem})` };
|
|
1158
|
+
const id = decisionId;
|
|
1159
|
+
const pending = await ctrl.store.get(id);
|
|
1160
|
+
if (!pending)
|
|
1161
|
+
return resolveCore({ decisionId: id, verdict: "approved" });
|
|
1162
|
+
const st = await statusOf(ctrl, id);
|
|
1163
|
+
const state = typeof st?.state === "string" ? st.state : "";
|
|
1164
|
+
const reason = typeof st?.reason === "string" && st.reason ? st.reason : undefined;
|
|
1165
|
+
if (state === "approved" || state === "auto_approved")
|
|
1166
|
+
return resolveCore({ decisionId: id, verdict: "approved" }, st);
|
|
1167
|
+
if (state === "rejected" || state === "blocked")
|
|
1168
|
+
return resolveCore({ decisionId: id, verdict: state, ...(reason ? { reason } : {}) });
|
|
1169
|
+
return { status: "not_approved", decisionId: id, action: pending.action, reason: `Decidio records decision ${id} as ${JSON.stringify(state || null)}; a webhook is a wake-up, not an approval, so nothing ran and the entry stays parked` };
|
|
1170
|
+
}
|
|
972
1171
|
const ctrl = {
|
|
973
1172
|
...opts,
|
|
974
1173
|
agentId: opts.agentId ?? process.env.DECIDIO_AGENT_ID,
|
|
975
1174
|
agentKey: opts.agentKey ?? process.env.DECIDIO_AGENT_KEY,
|
|
976
1175
|
agentDid: opts.agentDid ?? process.env.DECIDIO_AGENT_DID,
|
|
977
1176
|
workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
|
|
978
|
-
webhookSecret:
|
|
1177
|
+
webhookSecret: opts.webhookSecret,
|
|
979
1178
|
store: opts.store,
|
|
980
1179
|
register(action, fn, evidence, withContext) {
|
|
981
1180
|
const prev = registry.get(action);
|
|
@@ -995,107 +1194,14 @@ export function createDecidioResume(opts) {
|
|
|
995
1194
|
catch (e) {
|
|
996
1195
|
throw new DecidioUnsafeArgumentsError(`${e?.message ?? e} NOT parked and NOT executable — but decision ${p.decisionId} may already be routed; reject it in your queue: npx @decidio/sdk approvals reject ${p.decisionId}`, p.decisionId);
|
|
997
1196
|
}
|
|
998
|
-
const argsSig = signArgs(p, ctrl.agentKey,
|
|
1197
|
+
const argsSig = signArgs(p, ctrl.agentKey, integrityKey());
|
|
999
1198
|
if (!argsSig)
|
|
1000
1199
|
console.warn(`[decidio] parking ${p.decisionId} UNBOUND — no agent key or webhook secret to sign the payload with, so a modified pending file could not be detected. Run \`npx @decidio/sdk init\` to configure keys.`);
|
|
1001
1200
|
await ctrl.store.put({ ...p, createdAt: new Date().toISOString(), ...(argsSig ? { argsSig } : {}) });
|
|
1002
1201
|
},
|
|
1003
|
-
async resolve({
|
|
1004
|
-
const finish = (o) => { if (o.status !== "unknown")
|
|
1005
|
-
opts.onResolved?.(o); return o; };
|
|
1006
|
-
const idProblem = decisionIdProblem(decisionId);
|
|
1007
|
-
if (idProblem)
|
|
1008
|
-
return { status: "unknown", decisionId: String(decisionId ?? ""), reason: `not a decision id this SDK will act on (${idProblem})` };
|
|
1009
|
-
const pending = await ctrl.store.get(decisionId);
|
|
1010
|
-
if (!pending) {
|
|
1011
|
-
const mapped = outcomeFromHead(await ctrl.store.getExecution(decisionId), decisionId);
|
|
1012
|
-
if (mapped)
|
|
1013
|
-
return finish(mapped);
|
|
1014
|
-
return { status: "unknown", decisionId };
|
|
1015
|
-
}
|
|
1016
|
-
if (verdict === "rejected" || verdict === "blocked") {
|
|
1017
|
-
await ctrl.store.markDiscarded(decisionId, verdict, reason);
|
|
1018
|
-
await ctrl.store.delete(decisionId);
|
|
1019
|
-
return finish({ status: verdict, decisionId, action: pending.action, reason });
|
|
1020
|
-
}
|
|
1021
|
-
if (verdict !== "approved" && verdict !== "auto_approved")
|
|
1022
|
-
return { status: "unknown", decisionId };
|
|
1023
|
-
if (pending.decisionId !== decisionId) {
|
|
1024
|
-
refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: the parked entry in this slot belongs to a DIFFERENT decision (${pending.decisionId}) — the pending file was moved or swapped. Delete it and re-run the action.`);
|
|
1025
|
-
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload decision-id mismatch" });
|
|
1026
|
-
}
|
|
1027
|
-
const strictBinding = !!ctrl.agentKey;
|
|
1028
|
-
const integrity = verifyArgs(pending, ctrl.agentKey, ctrl.webhookSecret);
|
|
1029
|
-
if (integrity === "mismatch") {
|
|
1030
|
-
refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: the parked payload FAILED its integrity check — the arguments on disk are not the arguments that were routed for approval. The pending file is untouched for inspection; delete it and re-run the action to request a fresh approval.`);
|
|
1031
|
-
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload integrity check failed" });
|
|
1032
|
-
}
|
|
1033
|
-
if (integrity === "unverifiable") {
|
|
1034
|
-
refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: the parked payload is signed but this process lacks the key material to verify it (DECIDIO_AGENT_KEY / DECIDIO_WEBHOOK_SECRET). Restore the agent's env and retry.`);
|
|
1035
|
-
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unverifiable" });
|
|
1036
|
-
}
|
|
1037
|
-
if (integrity === "unbound" && strictBinding) {
|
|
1038
|
-
refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: the parked payload has NO integrity signature, but this agent has keys and would have signed it — a missing signature means the file was tampered with. Delete it and re-run the action.`);
|
|
1039
|
-
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unsigned (expected a signature)" });
|
|
1040
|
-
}
|
|
1041
|
-
if (integrity === "unbound")
|
|
1042
|
-
console.warn(`[decidio] executing ${decisionId} from an UNBOUND parked entry (this agent has no key material to bind or verify payloads) — its arguments cannot be integrity-checked. Run \`npx @decidio/sdk init\` to enable binding.`);
|
|
1043
|
-
const fn = registry.get(pending.action)?.fn;
|
|
1044
|
-
if (!fn) {
|
|
1045
|
-
console.error(`[decidio] cannot resume ${decisionId}: no function is registered for action "${pending.action}" in THIS process.`);
|
|
1046
|
-
console.error(` A worker only re-executes actions registered where it runs. At startup, either declare the action on the wrapper:`);
|
|
1047
|
-
console.error(` const doIt = guard.protect(doItRaw, describe, { action: "${pending.action}" });`);
|
|
1048
|
-
console.error(` or register the raw function directly:`);
|
|
1049
|
-
console.error(` guard.register("${pending.action}", doItRaw);`);
|
|
1050
|
-
console.error(` then start the worker. The approved action stays parked until then — nothing is lost.`);
|
|
1051
|
-
return finish({ status: "no_handler", decisionId, action: pending.action, reason: `no fn registered for action "${pending.action}" in this process — declare { action } on protect(), or call guard.register("${pending.action}", fn) at startup` });
|
|
1052
|
-
}
|
|
1053
|
-
const attemptId = randomUUID();
|
|
1054
|
-
const claim = await ctrl.store.claim(decisionId, attemptId);
|
|
1055
|
-
if (!claim.ok) {
|
|
1056
|
-
if (claim.state && NON_REPLAYABLE.includes(claim.state)) {
|
|
1057
|
-
refuseOnce(decisionId, claim.state === "discarded"
|
|
1058
|
-
? `[decidio] REFUSING to execute ${decisionId}: it was DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}) and that answer is final. Nothing ran. A denial cannot be reconciled or retried — if this action should now proceed, it needs a NEW request and a new approval.`
|
|
1059
|
-
: `[decidio] REFUSING to execute ${decisionId}: its durable state is ${claim.state} — invocation already began, so re-running it could duplicate a committed effect. Inspect it with guard.resume.recover(); settle it with guard.resume.reconcile("${decisionId}", …).`, claim.state === "discarded" ? " (This prints once per worker process. The decision is closed — no action is needed.)" : undefined);
|
|
1060
|
-
if (claim.state === "invoking" || claim.state === "indeterminate") {
|
|
1061
|
-
return finish({ status: "indeterminate", decisionId, action: pending.action, reason: claim.state === "invoking"
|
|
1062
|
-
? `this decision is in durable state invoking: its action may still be running, may have crashed before any effect, or may have finished without reporting - it may or may not have run. Nothing will retry it; check the worker and the downstream system, then settle it with reconcile("${decisionId}", …).`
|
|
1063
|
-
: claim.reason });
|
|
1064
|
-
}
|
|
1065
|
-
return finish({ status: "already_resolved", decisionId, action: pending.action, reason: claim.state === "discarded" ? `this decision was already DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}); that answer is final and this signal lost the race to it` : `this decision already ran (durable state ${claim.state}); this signal lost the race to it` });
|
|
1066
|
-
}
|
|
1067
|
-
const mapped = outcomeFromHead(await ctrl.store.getExecution(decisionId), decisionId, pending.action);
|
|
1068
|
-
if (mapped)
|
|
1069
|
-
return finish(mapped);
|
|
1070
|
-
return finish({ status: "lost_race", decisionId, action: pending.action, reason: `another attempt holds the pre-invocation claim on this decision${claim.state ? ` (durable state ${claim.state})` : ""}; this signal lost the race and did nothing. If that attempt is alive it will report; if it died, recover() lists the decision and reconcile() can settle it` });
|
|
1071
|
-
}
|
|
1072
|
-
try {
|
|
1073
|
-
await ctrl.store.markInvoking(decisionId, attemptId);
|
|
1074
|
-
}
|
|
1075
|
-
catch (e) {
|
|
1076
|
-
await ctrl.store.releasePreinvoke(decisionId, attemptId).catch(() => { });
|
|
1077
|
-
console.error(`[decidio] ${decisionId}: could not record the invocation boundary (${e?.message ?? e}) — refusing to run the action rather than run it unrecorded.`);
|
|
1078
|
-
const mapped = outcomeFromHead(await ctrl.store.getExecution(decisionId), decisionId, pending.action);
|
|
1079
|
-
if (mapped)
|
|
1080
|
-
return finish(mapped);
|
|
1081
|
-
return finish({ status: "lost_race", decisionId, action: pending.action, reason: `could not durably record the invocation boundary (${e?.message ?? e}); nothing ran, the entry stays parked, and a later signal may retry` });
|
|
1082
|
-
}
|
|
1083
|
-
let result;
|
|
1084
|
-
try {
|
|
1085
|
-
const entry = registry.get(pending.action);
|
|
1086
|
-
result = entry?.withContext
|
|
1087
|
-
? await fn({ decisionId, attemptId, idempotencyKey: decisionId }, ...pending.args)
|
|
1088
|
-
: await fn(...pending.args);
|
|
1089
|
-
}
|
|
1090
|
-
catch (e) {
|
|
1091
|
-
const why = String(e?.message ?? e);
|
|
1092
|
-
await ctrl.store.markIndeterminate(decisionId, attemptId, why).catch((se) => console.error(`[decidio] ${decisionId}: could not record INDETERMINATE (${se?.message ?? se}) — treat this decision as possibly executed.`));
|
|
1093
|
-
console.error(`[decidio] ${decisionId}: "${pending.action}" was INVOKED and then threw (${why}). Whether the downstream effect committed is not knowable from here, so it will NOT be retried automatically. Check the downstream system, then: guard.resume.reconcile("${decisionId}", { outcome: "committed", evidence: … }) or { outcome: "not_committed", reason: … }.`);
|
|
1094
|
-
return finish({ status: "indeterminate", decisionId, action: pending.action, reason: why });
|
|
1095
|
-
}
|
|
1096
|
-
return finish(await reportAfterExecution(pending, decisionId, result, attemptId));
|
|
1097
|
-
},
|
|
1202
|
+
async resolve(input) { return resolveCore(input); },
|
|
1098
1203
|
webhookHandler() {
|
|
1204
|
+
warnIfNoWebhookKey();
|
|
1099
1205
|
return (req, res) => {
|
|
1100
1206
|
if (req.method !== "POST") {
|
|
1101
1207
|
res.statusCode = 405;
|
|
@@ -1108,7 +1214,7 @@ export function createDecidioResume(opts) {
|
|
|
1108
1214
|
if (!secret) {
|
|
1109
1215
|
if (!opts.allowUnsigned) {
|
|
1110
1216
|
res.statusCode = 401;
|
|
1111
|
-
return res.end(JSON.stringify({ error:
|
|
1217
|
+
return res.end(JSON.stringify({ error: NO_WEBHOOK_SECRET }));
|
|
1112
1218
|
}
|
|
1113
1219
|
}
|
|
1114
1220
|
else if (!verifyResumeSignature(secret, raw, req.headers["x-decidio-signature"])) {
|
|
@@ -1116,8 +1222,7 @@ export function createDecidioResume(opts) {
|
|
|
1116
1222
|
return res.end(JSON.stringify({ error: "bad signature" }));
|
|
1117
1223
|
}
|
|
1118
1224
|
const body = JSON.parse(raw || "{}");
|
|
1119
|
-
const
|
|
1120
|
-
const outcome = await ctrl.resolve({ decisionId: body.decisionId, verdict, reason: body.reason });
|
|
1225
|
+
const outcome = await wake(body.decisionId);
|
|
1121
1226
|
res.statusCode = 200;
|
|
1122
1227
|
res.setHeader("content-type", "application/json");
|
|
1123
1228
|
res.end(JSON.stringify(webhookBody(outcome)));
|
|
@@ -1130,20 +1235,21 @@ export function createDecidioResume(opts) {
|
|
|
1130
1235
|
};
|
|
1131
1236
|
},
|
|
1132
1237
|
fetchHandler() {
|
|
1238
|
+
warnIfNoWebhookKey();
|
|
1133
1239
|
return async (req) => {
|
|
1134
1240
|
if (req.method !== "POST")
|
|
1135
1241
|
return new Response("method not allowed", { status: 405 });
|
|
1136
1242
|
const raw = await req.text();
|
|
1137
1243
|
if (!secret) {
|
|
1138
1244
|
if (!opts.allowUnsigned)
|
|
1139
|
-
return new Response(JSON.stringify({ error:
|
|
1245
|
+
return new Response(JSON.stringify({ error: NO_WEBHOOK_SECRET }), { status: 401 });
|
|
1140
1246
|
}
|
|
1141
1247
|
else if (!verifyResumeSignature(secret, raw, req.headers.get("x-decidio-signature") ?? undefined)) {
|
|
1142
1248
|
return new Response(JSON.stringify({ error: "bad signature" }), { status: 401 });
|
|
1143
1249
|
}
|
|
1144
1250
|
try {
|
|
1145
1251
|
const body = JSON.parse(raw || "{}");
|
|
1146
|
-
const outcome = await
|
|
1252
|
+
const outcome = await wake(body.decisionId);
|
|
1147
1253
|
return new Response(JSON.stringify(webhookBody(outcome)), { status: 200, headers: { "content-type": "application/json" } });
|
|
1148
1254
|
}
|
|
1149
1255
|
catch (e) {
|
|
@@ -1173,7 +1279,7 @@ export function createDecidioResume(opts) {
|
|
|
1173
1279
|
return { reportRecorded: false, executionConfirmed: false, diagnostic: `the evidence you passed to reconcile("${decisionId}") is not reportable as-is (${e?.message ?? e}). Nothing was settled. Pass plain JSON — the same rule your action's return value follows.` };
|
|
1174
1280
|
}
|
|
1175
1281
|
const settleSig = opts.outcome === "committed"
|
|
1176
|
-
? signEvidence(decisionId, entry.attemptId ?? null, settleEvidence, ctrl.agentKey,
|
|
1282
|
+
? signEvidence(decisionId, entry.attemptId ?? null, settleEvidence, ctrl.agentKey, integrityKey())
|
|
1177
1283
|
: undefined;
|
|
1178
1284
|
try {
|
|
1179
1285
|
await ctrl.store.settle(decisionId, opts.outcome, { evidence: settleEvidence, reason: opts.reason, evidenceSig: settleSig, workerStopped: opts.workerStopped });
|
|
@@ -1239,7 +1345,7 @@ export function createDecidioResume(opts) {
|
|
|
1239
1345
|
};
|
|
1240
1346
|
}
|
|
1241
1347
|
const evStrict = !!ctrl.agentKey;
|
|
1242
|
-
const evStatus = verifyEvidence(entry, ctrl.agentKey,
|
|
1348
|
+
const evStatus = verifyEvidence(entry, ctrl.agentKey, integrityKey());
|
|
1243
1349
|
if (evStatus === "mismatch") {
|
|
1244
1350
|
return { reportRecorded: false, executionConfirmed: false, diagnostic: `the recorded evidence for ${decisionId} does not match its integrity binding — it was altered after this agent wrote it. REFUSING to attest it. Inspect the pending store; if you know the true outcome, settle it with reconcile("${decisionId}", …).` };
|
|
1245
1351
|
}
|
|
@@ -1353,7 +1459,7 @@ export function createDecidioResume(opts) {
|
|
|
1353
1459
|
try {
|
|
1354
1460
|
let outcome;
|
|
1355
1461
|
try {
|
|
1356
|
-
outcome = await
|
|
1462
|
+
outcome = await resolveCore({ decisionId: p.decisionId, verdict, ...(typeof st.reason === "string" && st.reason ? { reason: st.reason } : {}) }, st);
|
|
1357
1463
|
}
|
|
1358
1464
|
catch (e) {
|
|
1359
1465
|
if (e instanceof DecidioStateCorruptError) {
|
package/dist/signing.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type JsonValue } from "./canonical.js";
|
|
1
2
|
/** Sorted-key JCS — must match apps/api/src/canonical.ts byte-for-byte.
|
|
2
3
|
*
|
|
3
4
|
* ONE IMPLEMENTATION (F-006). This was a module-local copy that reached `JSON.stringify` for every
|
|
@@ -91,6 +92,45 @@ export declare const carriesCredential: (c: {
|
|
|
91
92
|
agentKey?: string;
|
|
92
93
|
agentDid?: string;
|
|
93
94
|
}) => boolean;
|
|
95
|
+
/** The agent-held secret a request commitment (and the LangGraph idempotency key) is keyed with. */
|
|
96
|
+
export interface BindingMaterial {
|
|
97
|
+
readonly kind: "ed25519" | "api-token";
|
|
98
|
+
readonly secret: Buffer;
|
|
99
|
+
}
|
|
100
|
+
/** The key material this configuration binds approvals with: the Ed25519 signing key when signing is
|
|
101
|
+
* configured (`agentKey` AND `agentDid`, the same predicate the identity proof uses), else the API
|
|
102
|
+
* token, else none. Throws on a signing key that cannot be read - the identity proof would refuse the
|
|
103
|
+
* same key, and silently binding with the token instead would make two processes disagree. */
|
|
104
|
+
export declare function bindingMaterial(cfg: {
|
|
105
|
+
agentKey?: string;
|
|
106
|
+
agentDid?: string;
|
|
107
|
+
apiToken?: string;
|
|
108
|
+
}): BindingMaterial | undefined;
|
|
109
|
+
/** The exact request an approval covers. The core wrapper commits to what EXECUTES — the canonical
|
|
110
|
+
* argument snapshot plus the described action/amount/scope; an engine adapter cannot see inside its
|
|
111
|
+
* `run()` closure, so it commits to the whole described context (display details included). */
|
|
112
|
+
export interface CommittedRequest {
|
|
113
|
+
action: string;
|
|
114
|
+
amount: number;
|
|
115
|
+
scope?: string | null;
|
|
116
|
+
context?: Record<string, string | number> | null;
|
|
117
|
+
args?: readonly unknown[];
|
|
118
|
+
/** Python only (keyword arguments); present in the shape so the two languages share one model. */
|
|
119
|
+
kwargs?: Record<string, unknown>;
|
|
120
|
+
}
|
|
121
|
+
/** The canonical bytes a commitment covers: optional members OMITTED when absent (null counts as
|
|
122
|
+
* absent), so the two languages hash the same bytes. */
|
|
123
|
+
export declare function committedRequestJson(request: CommittedRequest): string;
|
|
124
|
+
/** lowercase-hex HMAC-SHA256 over the canonical request, under the agent-held key. */
|
|
125
|
+
export declare function requestCommitment(material: BindingMaterial, request: CommittedRequest): string;
|
|
126
|
+
/** lowercase-hex idempotency key over an execution identity (e.g. LangGraph's thread + task +
|
|
127
|
+
* commitment), keyed so the host's thread ids never reach Decidio in the clear. */
|
|
128
|
+
export declare function idempotencyKey(material: BindingMaterial, identity: Record<string, JsonValue>): string;
|
|
129
|
+
/** The Claude Code hook's request scope: `cmd-` + 16 hex of a KEYED digest over the command text and its subject
|
|
130
|
+
* (chat approvals follow-up, invariant-guard 2026-10-15). The text may carry a credential the display redacts; an
|
|
131
|
+
* unkeyed digest sealed beside the redacted text would be an offline oracle for a weak password. Keyed under its own
|
|
132
|
+
* domain, so it is deterministic for this agent and nothing to anyone without the key material. */
|
|
133
|
+
export declare function boundScope(material: BindingMaterial, text: string): string;
|
|
94
134
|
/** A signed, DOMAIN-SEPARATED, versioned attestation that THIS agent reported THIS captured
|
|
95
135
|
* response for THIS decision. Distinct from the identity proof (`signConfirm`) in every field
|
|
96
136
|
* that matters, so a report can never be lifted from one decision onto another: the decision id,
|
package/dist/signing.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify, createHash, randomUUID } from "node:crypto";
|
|
1
|
+
import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify, createHash, createHmac, randomUUID } from "node:crypto";
|
|
2
2
|
import { canonicalJson } from "./canonical.js";
|
|
3
3
|
const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
4
4
|
function b58encode(buf) {
|
|
@@ -159,6 +159,48 @@ export function assertSafeTransport(url, hasCredential) {
|
|
|
159
159
|
: `[decidio] refusing to talk to ${u.origin} over plaintext http — Decidio is https, and an unauthenticated plaintext reply cannot be trusted to carry an authority decision. Use https, or localhost for local dev. Nothing was sent.`);
|
|
160
160
|
}
|
|
161
161
|
export const carriesCredential = (c) => !!c.apiToken || !!(c.agentKey && c.agentDid);
|
|
162
|
+
export function bindingMaterial(cfg) {
|
|
163
|
+
if (cfg.agentKey && cfg.agentDid) {
|
|
164
|
+
let jwk;
|
|
165
|
+
try {
|
|
166
|
+
jwk = createPrivateKey({ key: Buffer.from(cfg.agentKey, "base64"), format: "der", type: "pkcs8" }).export({ format: "jwk" });
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
throw new Error(`[decidio] DECIDIO_AGENT_KEY is not a readable private key (PKCS8, base64): ${e?.message ?? e}. It binds this agent's approvals to their requests; restore the key \`init\` wrote, or unset DECIDIO_AGENT_KEY/DECIDIO_AGENT_DID. Nothing was sent or executed.`);
|
|
170
|
+
}
|
|
171
|
+
if (jwk.crv !== "Ed25519" || typeof jwk.d !== "string") {
|
|
172
|
+
throw new Error(`[decidio] DECIDIO_AGENT_KEY is not an Ed25519 private key (got ${JSON.stringify(jwk.crv ?? null)}). Restore the key \`init\` wrote. Nothing was sent or executed.`);
|
|
173
|
+
}
|
|
174
|
+
return { kind: "ed25519", secret: Buffer.from(jwk.d, "base64url") };
|
|
175
|
+
}
|
|
176
|
+
if (cfg.apiToken)
|
|
177
|
+
return { kind: "api-token", secret: Buffer.from(cfg.apiToken, "utf8") };
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
function bindingSubkey(m, label) {
|
|
181
|
+
return createHmac("sha256", m.secret).update(`${label}/${m.kind}`, "utf8").digest();
|
|
182
|
+
}
|
|
183
|
+
export function committedRequestJson(request) {
|
|
184
|
+
const r = { action: request.action, amount: request.amount };
|
|
185
|
+
if (request.scope !== undefined && request.scope !== null)
|
|
186
|
+
r.scope = request.scope;
|
|
187
|
+
if (request.context !== undefined && request.context !== null)
|
|
188
|
+
r.context = request.context;
|
|
189
|
+
if (request.args !== undefined && request.args !== null)
|
|
190
|
+
r.args = request.args;
|
|
191
|
+
if (request.kwargs !== undefined && request.kwargs !== null)
|
|
192
|
+
r.kwargs = request.kwargs;
|
|
193
|
+
return canonicalJson(r, "request commitment");
|
|
194
|
+
}
|
|
195
|
+
export function requestCommitment(material, request) {
|
|
196
|
+
return createHmac("sha256", bindingSubkey(material, "decidio.request-commitment.v1")).update(committedRequestJson(request), "utf8").digest("hex");
|
|
197
|
+
}
|
|
198
|
+
export function idempotencyKey(material, identity) {
|
|
199
|
+
return createHmac("sha256", bindingSubkey(material, "decidio.idempotency-key.v1")).update(canonicalJson(identity, "idempotency identity"), "utf8").digest("hex");
|
|
200
|
+
}
|
|
201
|
+
export function boundScope(material, text) {
|
|
202
|
+
return "cmd-" + createHmac("sha256", bindingSubkey(material, "decidio.hook-scope.v1")).update(text, "utf8").digest("hex").slice(0, 16);
|
|
203
|
+
}
|
|
162
204
|
export function signExecutionReport(privateKeyBase64, fields) {
|
|
163
205
|
const key = createPrivateKey({ key: Buffer.from(privateKeyBase64, "base64"), format: "der", type: "pkcs8" });
|
|
164
206
|
const nonce = fields.nonce ?? randomUUID();
|