@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/index.js CHANGED
@@ -1,12 +1,12 @@
1
- import { DecidioSuspendedError } from "./resume.js";
2
- import { signBoundProof, signConfirm, assertSafeTransport, carriesCredential } from "./signing.js";
1
+ import { DecidioSuspendedError, checkApproval } from "./resume.js";
2
+ import { signBoundProof, signConfirm, assertSafeTransport, carriesCredential, bindingMaterial, requestCommitment } from "./signing.js";
3
3
  import { canonicalize, canonicalizeArgs } from "./canonical.js";
4
4
  import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
5
- export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
5
+ export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded, checkApproval } from "./resume.js";
6
6
  export { guard, Guard } from "./guard.js";
7
7
  export { DecidioHttpError, decisionIdProblem } from "./errors.js";
8
8
  import { DecidioHttpError, decisionIdProblem } from "./errors.js";
9
- 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
10
  export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
11
11
  export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS } from "./evidence.js";
12
12
  export class DecidioBlockedError extends Error {
@@ -29,6 +29,38 @@ export class DecidioRejectedError extends Error {
29
29
  this.name = "DecidioRejectedError";
30
30
  }
31
31
  }
32
+ export class DecidioRequestChangedError extends DecidioBlockedError {
33
+ code = "request_changed";
34
+ constructor(decisionId, detail) {
35
+ super(detail ?? `the request changed after approval (decision ${decisionId})`, decisionId);
36
+ this.name = "DecidioRequestChangedError";
37
+ this.message = `[decidio] the request changed after approval: decision ${decisionId} was approved for a different request than the one about to run. Nothing was executed. ${detail && !/^the request changed after approval/.test(detail) ? `(${detail}) ` : ""}A changed request needs its own approval — reject ${decisionId} and run the action again.`;
38
+ }
39
+ }
40
+ export class DecidioApprovalUnverifiedError extends DecidioBlockedError {
41
+ code;
42
+ constructor(decisionId, code, detail) {
43
+ super(detail, decisionId);
44
+ this.code = code;
45
+ this.name = "DecidioApprovalUnverifiedError";
46
+ this.message = `[decidio] cannot confirm that decision ${decisionId} approved THIS request: ${detail}. Nothing was executed.`;
47
+ }
48
+ }
49
+ export function approvalRefusal(check) {
50
+ const id = check.decisionId;
51
+ switch (check.code) {
52
+ case "rejected": return new DecidioRejectedError(check.reason ?? "rejected by approver", id);
53
+ case "blocked": return new DecidioBlockedError(check.reason ?? "blocked by policy", id);
54
+ case "request_changed": return new DecidioRequestChangedError(id, check.reason);
55
+ case "commitment_missing": return new DecidioApprovalUnverifiedError(id, "commitment_missing", check.reason ?? "Decidio answered without the request commitment this SDK sent");
56
+ case "unverifiable": return new DecidioApprovalUnverifiedError(id, "unverifiable", check.reason ?? "the approval could not be tied to this request");
57
+ case "pending": return new DecidioBlockedError(`not approved - ${check.reason ?? `Decidio records ${id} as pending`}`, id);
58
+ default: return new DecidioBlockedError(check.reason ?? `not approved (Decidio answered ${JSON.stringify(check.state)})`, id);
59
+ }
60
+ }
61
+ export function coreRequest(ctx, args) {
62
+ return { action: ctx.action, amount: ctx.amount, ...(ctx.scope !== undefined && ctx.scope !== null ? { scope: ctx.scope } : {}), args };
63
+ }
32
64
  export class DecidioTimeoutError extends Error {
33
65
  decisionId;
34
66
  withdrawn;
@@ -153,12 +185,16 @@ export function withApproval(fn, describe, config, opts) {
153
185
  ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
154
186
  : undefined;
155
187
  assertDescribed(ctx);
188
+ const material = bindingMaterial(config);
189
+ const committed = coreRequest(ctx, args);
190
+ const sentCommitment = material ? requestCommitment(material, committed) : undefined;
156
191
  const auth = await post(config, "/agent/authorize", {
157
192
  requester: config.agentId, action: ctx.action, amount: ctx.amount,
158
193
  scope: ctx.scope, sourceSystem: config.sourceSystem,
159
194
  ...(ctx.context ? { context: ctx.context } : {}),
160
195
  ...(requesterIdentity ? { requesterIdentity } : {}),
161
196
  ...(resumeUrl ? { resumeUrl } : {}),
197
+ ...(sentCommitment ? { requestCommitment: sentCommitment } : {}),
162
198
  });
163
199
  if (auth.decision !== "proceed" && auth.decision !== "route" && auth.decision !== "block")
164
200
  throw new DecidioBlockedError(`unrecognized gate verdict: ${String(auth.decision)}`, auth.decisionId);
@@ -181,14 +217,18 @@ export function withApproval(fn, describe, config, opts) {
181
217
  await config.resume.park({
182
218
  decisionId: auth.decisionId, receiptId: auth.receiptId,
183
219
  action: ctx.action, amount: ctx.amount, scope: ctx.scope, args,
220
+ ...(sentCommitment ? { requestCommitment: sentCommitment } : {}),
184
221
  });
185
222
  throw new DecidioSuspendedError(auth.decisionId, ctx.action);
186
223
  }
187
224
  const deadline = Date.now() + pollTimeout;
225
+ let approvedStatus;
188
226
  while (true) {
189
227
  const st = await get(config, `/agent/status/${encodeURIComponent(auth.decisionId)}`);
190
- if (st.state === "approved" || st.state === "auto_approved")
228
+ if (st.state === "approved" || st.state === "auto_approved") {
229
+ approvedStatus = st;
191
230
  break;
231
+ }
192
232
  if (st.state === "rejected")
193
233
  throw new DecidioRejectedError(st.reason ?? "rejected by approver", auth.decisionId);
194
234
  if (st.state === "blocked")
@@ -215,6 +255,9 @@ export function withApproval(fn, describe, config, opts) {
215
255
  }
216
256
  await sleep(pollInterval);
217
257
  }
258
+ const check = await checkApproval(config, auth.decisionId, committed, { sent: !!sentCommitment, ...(approvedStatus ? { status: approvedStatus } : {}) });
259
+ if (!check.ok)
260
+ throw approvalRefusal(check);
218
261
  }
219
262
  const result = opts?.withContext
220
263
  ? await fn({ decisionId: auth.decisionId, attemptId: null, idempotencyKey: auth.decisionId }, ...args)
package/dist/inngest.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { RequestListener } from "node:http";
2
2
  import { verifyResumeSignature } from "./resume.js";
3
- import type { GuardConfig, ApprovalContext, EvidenceOutcome } from "./index.js";
3
+ import { type GuardConfig, type ApprovalContext, type EvidenceOutcome } from "./index.js";
4
4
  /** The Inngest event Decidio's verdict is translated into. The waiting function
5
5
  * correlates on `data.decisionId`. */
6
6
  export declare const DECIDIO_RESUME_EVENT = "decidio/decision.resolved";
@@ -51,7 +51,10 @@ export type GateStepOutcome<R> =
51
51
  * Gate an action INSIDE an Inngest function, with durable suspend on routing.
52
52
  * • proceed → run the action (as a step) + confirm.
53
53
  * • route → `step.waitForEvent` durably suspends until Decidio's resume event for
54
- * THIS decision arrives; approved → run + confirm; rejected → status.
54
+ * THIS decision arrives - a WAKE-UP (0.7.0): a memoized step then confirms with
55
+ * Decidio that THIS request was approved; approved → run + confirm; rejected /
56
+ * blocked → status; still pending → wait for the next event; the request changed
57
+ * after approval → DecidioRequestChangedError.
55
58
  * • block → return blocked (the action never runs).
56
59
  * The action runs in YOUR process with YOUR credentials — Decidio only signals.
57
60
  * `config.resumeUrl` must point at the bridge endpoint (createInngestResumeBridge),
package/dist/inngest.js CHANGED
@@ -1,5 +1,6 @@
1
- import { verifyResumeSignature } from "./resume.js";
2
- import { gateAuthorize, gateConfirm, assertAuthorizeVerdict, verdictFrom, checkEngineVersion } from "./adapters/_gate.js";
1
+ import { verifyResumeSignature, WEBHOOK_SECRET_REMEDY } from "./resume.js";
2
+ import { gateAuthorize, gateConfirm, assertAuthorizeVerdict, gateCheckApproval, assertApprovalCheckShape, checkEngineVersion } from "./adapters/_gate.js";
3
+ import { approvalRefusal } from "./index.js";
3
4
  export const DECIDIO_RESUME_EVENT = "decidio/decision.resolved";
4
5
  export async function decidioGateStep(args) {
5
6
  const { step, config, ctx } = args;
@@ -8,18 +9,25 @@ export async function decidioGateStep(args) {
8
9
  if (auth.decision === "block")
9
10
  return { status: "blocked", decisionId: auth.decisionId };
10
11
  if (auth.decision === "route") {
11
- const ev = await step.waitForEvent("decidio-await-approval", {
12
- event: DECIDIO_RESUME_EVENT,
13
- timeout: args.timeout ?? "30d",
14
- if: `async.data.decisionId == "${auth.decisionId}"`,
15
- });
16
- if (!ev)
17
- throw new Error(`decidio approval timed out (${auth.decisionId})`);
18
- const verdict = verdictFrom(ev.data);
19
- if (verdict === "rejected")
20
- return { status: "rejected", decisionId: auth.decisionId };
21
- if (verdict !== "approved" && verdict !== "auto_approved")
22
- return { status: "blocked", decisionId: auth.decisionId };
12
+ for (let n = 0;; n++) {
13
+ const suffix = n === 0 ? "" : `-${n}`;
14
+ const ev = await step.waitForEvent(`decidio-await-approval${suffix}`, {
15
+ event: DECIDIO_RESUME_EVENT,
16
+ timeout: args.timeout ?? "30d",
17
+ if: `async.data.decisionId == "${auth.decisionId}"`,
18
+ });
19
+ if (!ev)
20
+ throw new Error(`decidio approval timed out (${auth.decisionId})`);
21
+ const check = assertApprovalCheckShape(await step.run(`decidio-verify-approval${suffix}`, async () => gateCheckApproval(config, auth.decisionId, ctx)), auth.decisionId);
22
+ if (check.ok)
23
+ break;
24
+ if (check.code === "rejected")
25
+ return { status: "rejected", decisionId: auth.decisionId };
26
+ if (check.code === "blocked")
27
+ return { status: "blocked", decisionId: auth.decisionId };
28
+ if (check.code !== "pending")
29
+ throw approvalRefusal(check);
30
+ }
23
31
  }
24
32
  const result = await step.run("agent-action", args.run);
25
33
  const confirmation = await step.run("decidio-confirm", async () => gateConfirm(config, auth.decisionId, result, args.evidence));
@@ -42,7 +50,7 @@ export function createInngestResumeBridge(opts) {
42
50
  if (!secret) {
43
51
  if (!opts.allowUnsigned) {
44
52
  res.statusCode = 401;
45
- return res.end(JSON.stringify({ error: "resume webhook unsigned and no secret configured" }));
53
+ return res.end(JSON.stringify({ error: `resume webhook unsigned and no secret configured. ${WEBHOOK_SECRET_REMEDY}` }));
46
54
  }
47
55
  }
48
56
  else if (!verifyResumeSignature(secret, raw, req.headers["x-decidio-signature"])) {
package/dist/resume.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { RequestListener } from "node:http";
2
+ import { type CommittedRequest } from "./signing.js";
2
3
  import { type EvidenceOutcome, type EvidenceTier } from "./evidence.js";
3
4
  export declare function signEvidence(decisionId: string, attemptId: string | null, evidence: unknown, agentKey?: string, webhookSecret?: string): {
4
5
  kind: "ed25519" | "hmac";
@@ -30,6 +31,13 @@ export interface PendingAction {
30
31
  kind: "ed25519" | "hmac";
31
32
  sig: string;
32
33
  };
34
+ /** The request commitment this SDK SENT with /agent/authorize (0.7.0) — see `requestCommitment`
35
+ * in signing.ts. Recorded so `resolve()` can apply the one fail-closed rule that needs to know
36
+ * what was sent: a gate that answers with no commitment for a request this SDK bound is refused.
37
+ * The value that decides execution is always a FRESH recompute over these parked arguments,
38
+ * compared with the commitment Decidio returns. Absent on entries parked before 0.7.0 (and by
39
+ * an agent with no key material), which are checked on Decidio's state alone. */
40
+ requestCommitment?: string;
33
41
  }
34
42
  /** Where suspended actions live between request and approval. The default is a
35
43
  * file store; swap in Redis/Postgres/your queue for production. AGENT-SIDE only —
@@ -462,7 +470,17 @@ export declare class DecidioSuspendedError extends Error {
462
470
  * parked for a later signal. It used to read `unknown` (the word for "not ours") for the claim loser and
463
471
  * `no_handler` for the boundary loser although a handler was registered (prodsec V040-01). A loss to a FINAL
464
472
  * answer (a denial, an execution) stays `already_resolved`. Additive, like the two above. */
465
- export type ResumeStatus = "executed" | "rejected" | "blocked" | "no_handler" | "unknown" | "indeterminate" | "already_resolved" | "lost_race";
473
+ /** `not_approved` (0.7.0): the signal said "approved" but DECIDIO does not record an approval for this
474
+ * decision (it is still pending, or answered a state this SDK does not recognise). Every resume signal
475
+ * is a wake-up now, never the authority: the approval is confirmed with GET /agent/status right before
476
+ * the action runs. Nothing ran; the entry stays parked and a later signal resolves it. Not a resolution,
477
+ * so `onResolved` does not fire (like `unknown`).
478
+ * `request_unverified` (0.7.0): Decidio records an approval, but it cannot be tied to THIS parked
479
+ * request — the request changed after approval (the commitment Decidio holds does not match a fresh
480
+ * recompute over the parked arguments), the gate kept no commitment for a request this SDK bound, or
481
+ * this process lacks the key material to recompute it. `reason` says which. Nothing ran; the entry
482
+ * stays parked for inspection. Both members are additive, announced in the changelog. */
483
+ export type ResumeStatus = "executed" | "rejected" | "blocked" | "no_handler" | "unknown" | "indeterminate" | "already_resolved" | "lost_race" | "not_approved" | "request_unverified";
466
484
  /** The states a durable action can actually be in — every one of them nameable, including the
467
485
  * one nobody likes. */
468
486
  export type RecoveryState =
@@ -558,8 +576,15 @@ export interface ResumeConfig {
558
576
  /** The workspace the agent acts in (DECIDIO_WORKSPACE_ID) — bound into the cold-resume confirm
559
577
  * proof (D-B3.6) so it's tenant-scoped too. Default from env when unset. */
560
578
  workspaceId?: string;
561
- /** Shared secret Decidio signs the resume webhook with (HMAC-SHA256). Defaults to
562
- * the API token if unset. Verified before any re-execution. */
579
+ /** Shared secret Decidio signs the resume webhook with (HMAC-SHA256) — DECIDIO_WEBHOOK_SECRET, the
580
+ * dedicated secret your Decidio deployment signs resume webhooks with (never its API_AUTH_TOKEN, the
581
+ * admin bearer). NO FALLBACK since 0.7.0: it used to default to
582
+ * the API token, so anyone holding a leaked agent token could sign an "approved" callback. With
583
+ * no secret (and no `allowUnsigned`) the webhook handlers refuse every call. The poll worker
584
+ * (`worker()`) needs no inbound secret. The webhook is only a WAKE-UP either way: the approval
585
+ * is confirmed with Decidio (GET /agent/status + the request commitment) before anything runs.
586
+ * (The parked-payload integrity HMAC still falls back to the API token when this is unset — that
587
+ * key never authenticates a signal.) */
563
588
  webhookSecret?: string;
564
589
  /** @deprecated Ignored since 0.2.0 and accepted only so existing callers keep compiling.
565
590
  * Execution evidence now lives in the PendingStore's own execution record, beside the state
@@ -604,13 +629,22 @@ export interface ResumeController extends ResumeConfig {
604
629
  park(p: Omit<PendingAction, "createdAt" | "argsSig">): Promise<void>;
605
630
  /** Drive a parked decision to completion once a verdict is known. Idempotent: a second call
606
631
  * after success answers `already_resolved` (no double-write); an id this SDK never parked
607
- * answers `unknown`; a call that loses the pre-invocation claim answers `lost_race`. */
632
+ * answers `unknown`; a call that loses the pre-invocation claim answers `lost_race`.
633
+ *
634
+ * `verdict: "approved"` is a REQUEST to execute, never the authority (0.7.0): right before the
635
+ * action runs, resolve() confirms with Decidio that THIS parked request was approved (GET
636
+ * /agent/status + the request commitment — `checkApproval`). Decidio still pending → `not_approved`;
637
+ * Decidio says rejected/blocked → recorded as that denial; an approval that cannot be tied to these
638
+ * arguments → `request_unverified`. A transport failure during that check throws (nothing ran). A
639
+ * `rejected`/`blocked` verdict is applied as given — it can only ever prevent execution. */
608
640
  resolve(input: {
609
641
  decisionId: string;
610
642
  verdict: "approved" | "rejected" | "blocked";
611
643
  reason?: string;
612
644
  }): Promise<ResumeOutcome>;
613
- /** node:http listener for Decidio's signed approval webhook (Express/Fastify/node). */
645
+ /** node:http listener for Decidio's signed approval webhook (Express/Fastify/node). The webhook is a
646
+ * WAKE-UP (0.7.0): after its signature is verified — with `webhookSecret` only, never the API token —
647
+ * only its decision id is read; the outcome follows Decidio's own state for that decision. */
614
648
  webhookHandler(): RequestListener;
615
649
  /** Web-Fetch handler (Request → Response) for Next.js route handlers, Hono,
616
650
  * Cloudflare Workers, Bun, Deno. Same fail-closed signature check. */
@@ -666,6 +700,65 @@ export declare function verifyResumeSignature(secret: string, body: string, head
666
700
  * is never evidence). Its own comment claimed it was "shared by every confirm door", and no door
667
701
  * called it: the claim was what kept anyone from noticing the disagreement. */
668
702
  export declare function assertConfirmRecorded(body: any, decisionId: string, signed: boolean): string;
703
+ /** What GET /agent/status answers. `requestCommitment` (0.7.0) is the commitment the decision was
704
+ * authorized with, or null for a decision whose request was never bound. */
705
+ export interface DecisionStatus {
706
+ state: string;
707
+ reason?: string | null;
708
+ requestCommitment?: string | null;
709
+ }
710
+ type StatusConfig = {
711
+ apiUrl: string;
712
+ apiToken?: string;
713
+ agentKey?: string;
714
+ agentDid?: string;
715
+ fetchImpl?: typeof fetch;
716
+ };
717
+ /** Why an approval check did not pass.
718
+ * pending / unrecognized_state — Decidio records no approval (yet): nothing may run.
719
+ * rejected / blocked — Decidio's final answer is no.
720
+ * request_changed — the approval is for a DIFFERENT request than the one about to run.
721
+ * commitment_missing — this SDK bound the request, the gate answered without a commitment.
722
+ * unverifiable — Decidio holds a commitment this process cannot recompute (no or
723
+ * unreadable key material), or answered a malformed one. */
724
+ export type ApprovalCheckCode = "pending" | "unrecognized_state" | "rejected" | "blocked" | "request_changed" | "commitment_missing" | "unverifiable";
725
+ /** The outcome of the one check. Plain JSON on purpose: an engine that runs it as an activity or a
726
+ * step (Temporal, Inngest) memoizes it across its serialization boundary. */
727
+ export interface ApprovalCheck {
728
+ ok: boolean;
729
+ decisionId: string;
730
+ /** Decidio's derived state, verbatim ("" when it answered none). */
731
+ state: string;
732
+ code?: ApprovalCheckCode;
733
+ /** The server's (tokenized) reason for a rejection, or this SDK's explanation of a refusal. */
734
+ reason?: string;
735
+ }
736
+ /** CONFIRM WITH DECIDIO THAT *THIS* REQUEST WAS APPROVED — the check every door runs right before it
737
+ * executes a routed approval: the inline blocking wait, the durable resume (webhook AND worker), and
738
+ * each engine adapter (LangGraph, Temporal, Inngest, OpenAI Agents). A resume signal is a wake-up; it
739
+ * is never the authority.
740
+ *
741
+ * Approved means: GET /agent/status/:decisionId answers approved/auto_approved AND the commitment it
742
+ * returns equals a FRESH recompute over `request` — the exact request about to run. The fail-closed
743
+ * rules:
744
+ * - Decidio holds a commitment → it must match; no key material to recompute it → refused.
745
+ * - Decidio holds none, but this SDK bound the request (`sent`, default: this config has key
746
+ * material, so it would have sent one) → refused: the gate predates request binding.
747
+ * - Decidio holds none and nothing was bound (a keyless agent, or an entry parked before 0.7.0) →
748
+ * the state alone decides — nothing on either side could say more.
749
+ * A transport failure THROWS (it is not an answer); every other outcome is a value.
750
+ *
751
+ * `status` lets a caller that has just read the status (the blocking poll, the worker) pass it in
752
+ * instead of reading it twice. */
753
+ export declare function checkApproval(cfg: StatusConfig, decisionId: string, request: CommittedRequest, opts?: {
754
+ sent?: boolean;
755
+ status?: DecisionStatus;
756
+ }): Promise<ApprovalCheck>;
757
+ /** The webhook handlers' refusal when no webhook secret is configured - with the remedy. */
758
+ /** The one remedy every webhook-secret refusal names (#4B review W3): the dedicated webhook secret - NEVER the
759
+ * server's API_AUTH_TOKEN, the admin bearer, which a refusal that said "the value your deployment signs
760
+ * with" used to steer operators into copying onto agent hosts. */
761
+ export declare 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.";
669
762
  export declare function createDecidioResume(opts: ResumeConfig & {
670
763
  store: PendingStore;
671
764
  }): ResumeController;