@decidio/sdk 0.1.9 → 0.1.11

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/inngest.js CHANGED
@@ -1,48 +1,13 @@
1
1
  import { verifyResumeSignature } from "./resume.js";
2
- import { signBoundProof, signConfirm } from "./signing.js";
3
- /** The Inngest event Decidio's verdict is translated into. The waiting function
4
- * correlates on `data.decisionId`. */
2
+ import { gateAuthorize, gateConfirm, assertAuthorizeVerdict, verdictFrom, checkEngineVersion } from "./adapters/_gate.js";
5
3
  export const DECIDIO_RESUME_EVENT = "decidio/decision.resolved";
6
- /**
7
- * Gate an action INSIDE an Inngest function, with durable suspend on routing.
8
- * • proceed → run the action (as a step) + confirm.
9
- * • route → `step.waitForEvent` durably suspends until Decidio's resume event for
10
- * THIS decision arrives; approved → run + confirm; rejected → status.
11
- * • block → return blocked (the action never runs).
12
- * The action runs in YOUR process with YOUR credentials — Decidio only signals.
13
- * `config.resumeUrl` must point at the bridge endpoint (createInngestResumeBridge),
14
- * so Decidio's webhook is translated into the event this step waits on.
15
- */
16
4
  export async function decidioGateStep(args) {
17
5
  const { step, config, ctx } = args;
18
- const base = config.apiUrl.replace(/\/$/, "");
19
- const f = config.fetchImpl ?? fetch;
20
- const authHeaders = { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) };
21
- // 1. authorize (memoized as a step, so a replay doesn't re-ask the gate)
22
- const auth = await step.run("decidio-authorize", async () => {
23
- // Sign the bound proof so an Inngest-path agent can prove its identity + auto-approve.
24
- const requesterIdentity = (config.agentKey && config.agentDid)
25
- ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
26
- : undefined;
27
- const res = await f(base + "/agent/authorize", {
28
- method: "POST", headers: authHeaders,
29
- body: JSON.stringify({
30
- requester: config.agentId, action: ctx.action, amount: ctx.amount, scope: ctx.scope,
31
- sourceSystem: config.sourceSystem,
32
- ...(requesterIdentity ? { requesterIdentity } : {}),
33
- ...(config.resumeUrl ? { resumeUrl: config.resumeUrl } : {}),
34
- }),
35
- });
36
- if (!res.ok)
37
- throw new Error(`decidio authorize ${res.status}`);
38
- return res.json();
39
- });
6
+ const auth = await step.run("decidio-authorize", async () => gateAuthorize(config, ctx));
7
+ assertAuthorizeVerdict(auth, auth?.decisionId);
40
8
  if (auth.decision === "block")
41
9
  return { status: "blocked", decisionId: auth.decisionId };
42
10
  if (auth.decision === "route") {
43
- // 2. durably SUSPEND until the resume event for THIS decision arrives. Zero
44
- // compute while waiting; the worker/process may be recycled — Inngest resumes
45
- // from this checkpoint. `if` correlates the awaited event to this decisionId.
46
11
  const ev = await step.waitForEvent("decidio-await-approval", {
47
12
  event: DECIDIO_RESUME_EVENT,
48
13
  timeout: args.timeout ?? "30d",
@@ -50,31 +15,16 @@ export async function decidioGateStep(args) {
50
15
  });
51
16
  if (!ev)
52
17
  throw new Error(`decidio approval timed out (${auth.decisionId})`);
53
- const verdict = ev.data?.verdict;
18
+ const verdict = verdictFrom(ev.data);
54
19
  if (verdict === "rejected")
55
20
  return { status: "rejected", decisionId: auth.decisionId };
56
21
  if (verdict !== "approved" && verdict !== "auto_approved")
57
22
  return { status: "blocked", decisionId: auth.decisionId };
58
23
  }
59
- // 3. authorized → perform the agent's OWN action (a step), then confirm the captured
60
- // response so Decidio seals application_confirmed (never executes it itself).
61
24
  const result = await step.run("agent-action", args.run);
62
- await step.run("decidio-confirm", async () => {
63
- const requesterIdentity = signConfirm(auth.decisionId, config);
64
- await f(base + "/agent/confirm", {
65
- method: "POST", headers: authHeaders,
66
- body: JSON.stringify({ decisionId: auth.decisionId, capturedResponse: result ?? null, source: "wrapper", ...(requesterIdentity ? { requesterIdentity } : {}) }),
67
- });
68
- return true;
69
- });
25
+ await step.run("decidio-confirm", async () => { await gateConfirm(config, auth.decisionId, result); return true; });
70
26
  return { status: "executed", decisionId: auth.decisionId, result };
71
27
  }
72
- /**
73
- * Decidio → Inngest bridge. Mount as an HTTP endpoint and register it as the agent's
74
- * `resumeUrl`. It verifies Decidio's signed resume webhook (same HMAC as the core
75
- * handler) and emits `DECIDIO_RESUME_EVENT` into Inngest, waking the suspended
76
- * `decidioGateStep`. Fails CLOSED without a configured secret (unless allowUnsigned).
77
- */
78
28
  export function createInngestResumeBridge(opts) {
79
29
  const secret = opts.webhookSecret ?? "";
80
30
  const eventName = opts.eventName ?? DECIDIO_RESUME_EVENT;
@@ -110,5 +60,5 @@ export function createInngestResumeBridge(opts) {
110
60
  });
111
61
  };
112
62
  }
113
- // Re-export the signature check so a host can verify the webhook out-of-band if needed.
114
63
  export { verifyResumeSignature };
64
+ checkEngineVersion("inngest");
package/dist/resume.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { RequestListener } from "node:http";
2
+ import { type EvidenceOutcome, type EvidenceTier } from "./evidence.js";
2
3
  /** A routed action parked on the agent side, awaiting human approval. The `args`
3
4
  * are the original call arguments, replayed verbatim when the action resumes. */
4
5
  export interface PendingAction {
@@ -9,8 +10,8 @@ export interface PendingAction {
9
10
  scope?: string;
10
11
  args: unknown[];
11
12
  createdAt: string;
12
- /** Integrity binding over {decisionId, action, amount, scope, args} (round-6 acceptance
13
- * catch: the parked payload was mutable between routing and execution). ed25519 = detached
13
+ /** Integrity binding over {decisionId, action, amount, scope, args} — without it the parked
14
+ * payload would be mutable between routing and execution. ed25519 = detached
14
15
  * signature by the agent's key; hmac = keyed by the webhook secret. Verified before ANY
15
16
  * re-execution; a mismatch REFUSES and leaves the file for inspection. When the controller
16
17
  * holds an agent key, a MISSING signature is also refused (a stripped signature is
@@ -41,6 +42,9 @@ export interface PendingStore {
41
42
  claim?(decisionId: string): Promise<boolean>;
42
43
  /** Undo a claim so a failed execution is retryable. */
43
44
  release?(decisionId: string): Promise<void>;
45
+ /** Entries currently CLAIMED (excluded from list()). Needed for honest recovery reporting —
46
+ * a claimed entry with no journalled result is the indeterminate case. */
47
+ listClaimed?(): Promise<PendingAction[]>;
44
48
  }
45
49
  export declare class MemoryPendingStore implements PendingStore {
46
50
  private m;
@@ -51,7 +55,13 @@ export declare class MemoryPendingStore implements PendingStore {
51
55
  list(): Promise<PendingAction[]>;
52
56
  claim(id: string): Promise<boolean>;
53
57
  release(id: string): Promise<void>;
58
+ listClaimed(): Promise<PendingAction[]>;
54
59
  }
60
+ /** Restrict a directory to its owner. On Windows POSIX modes are a no-op, so strip inheritance
61
+ * and grant only the effective user. A FAILURE IS SAID, never swallowed: these files hold the
62
+ * arguments of actions awaiting approval, and "silently kept the directory's inherited
63
+ * permissions" is exactly the outcome an operator must not discover from an auditor. */
64
+ export declare function hardenDir(dir: string): void;
55
65
  /** File-backed store so a COLD process (the original requester long gone) can still
56
66
  * resume — the proof that nothing is "kept alive". One JSON file per decision. A claim
57
67
  * renames `<id>.json` → `<id>.claimed` atomically (same-volume rename), so two processes
@@ -69,6 +79,9 @@ export declare class FilePendingStore implements PendingStore {
69
79
  list(): Promise<PendingAction[]>;
70
80
  claim(id: string): Promise<boolean>;
71
81
  release(id: string): Promise<void>;
82
+ /** Claimed entries (`.claimed` files) — invisible to list() by design, but a stranded one is
83
+ * exactly what recovery must surface. */
84
+ listClaimed(): Promise<PendingAction[]>;
72
85
  }
73
86
  /** Thrown by a `withApproval`-wrapped call when the action is routed and configured
74
87
  * for async resume: the action is now SUSPENDED (parked in the store), not failed.
@@ -80,18 +93,57 @@ export declare class DecidioSuspendedError extends Error {
80
93
  constructor(decisionId: string, action: string);
81
94
  }
82
95
  export type ResumeStatus = "executed" | "rejected" | "blocked" | "no_handler" | "unknown";
96
+ /** A durable note that an action ALREADY RAN and what must still be reported. It lives OUTSIDE
97
+ * the pending store on purpose: a journal entry carries no handler binding, so replaying it can
98
+ * only ever re-send a report — it can never re-run the business action. That is what makes
99
+ * recovery safe on stores with and without claim(). */
100
+ export interface ExecutionJournalEntry {
101
+ decisionId: string;
102
+ action: string;
103
+ /** The canonical value that was reported — the durable FACT, journalled before the first
104
+ * attempt. Deliberately NOT the signed request: both proofs on a confirm are short-lived (the
105
+ * identity proof lives 120s, the execution report 5 minutes), so a replayed envelope is
106
+ * refused by the server for any outage longer than that — which is every outage worth
107
+ * recovering from. `reconfirm` re-signs at SEND time over these same bytes, so the report
108
+ * stays replay-safe (it still cannot re-run anything: no handler is journalled) while staying
109
+ * usable a week later. */
110
+ reported: unknown;
111
+ at: string;
112
+ }
113
+ /** The states a durable action can actually be in — every one of them nameable, including the
114
+ * one nobody likes. */
115
+ export type RecoveryState =
116
+ /** Parked, waiting on a human. Nothing has executed. */
117
+ "parked"
118
+ /** Claimed for execution and NOT journalled: the process died somewhere between claiming and
119
+ * recording the result. The action MAY OR MAY NOT have run — this cannot be resolved locally
120
+ * and must never be retried automatically. */
121
+ | "indeterminate"
122
+ /** Executed, result captured, report not yet accepted by Decidio. Safe to `reconfirm`. */
123
+ | "executed_unreported";
124
+ export interface RecoveryItem {
125
+ decisionId: string;
126
+ action?: string;
127
+ state: RecoveryState;
128
+ /** What a human should do about it, in words. */
129
+ guidance: string;
130
+ }
83
131
  export interface ResumeOutcome {
84
132
  status: ResumeStatus;
85
133
  decisionId: string;
86
134
  action?: string;
87
135
  result?: unknown;
88
136
  reason?: string;
89
- /** executed only: did the execution-evidence report reach Decidio AND come back with a recorded
90
- * tier? `status: "executed"` describes the LOCAL run; this describes the record (prodsec F-03:
91
- * the two were conflated, and a lost confirmation read as success). */
92
- confirmed?: boolean;
93
- /** executed only: the evidence tier the server says it recorded (e.g. application_confirmed). */
94
- evidenceTier?: string;
137
+ /** executed only: Decidio ACCEPTED the execution report and named the tier it recorded.
138
+ * `status: "executed"` describes the LOCAL run; this describes the record. */
139
+ reportRecorded?: boolean;
140
+ /** executed only: the recorded tier attests a COMMITTED execution. Deliberately a different
141
+ * question — and a different name — from `reportRecorded`. */
142
+ executionConfirmed?: boolean;
143
+ /** executed only: the evidence tier Decidio recorded, verbatim. */
144
+ evidenceTier?: EvidenceTier;
145
+ /** executed only: why the evidence landed where it did, when that is worth explaining. */
146
+ diagnostic?: string;
95
147
  }
96
148
  /** Minimal config the controller needs to confirm outcomes + poll status. Shared
97
149
  * with GuardConfig (apiUrl/apiToken). */
@@ -110,13 +162,19 @@ export interface ResumeConfig {
110
162
  /** Shared secret Decidio signs the resume webhook with (HMAC-SHA256). Defaults to
111
163
  * the API token if unset. Verified before any re-execution. */
112
164
  webhookSecret?: string;
165
+ /** Where the execution journal lives (default `<pending dir>/reports`). Separate from the
166
+ * pending store by design: journal entries can only re-send a report, never re-run an action. */
167
+ reportDir?: string;
168
+ /** Map the action's natural return value to the evidence Decidio records — same option, same
169
+ * meaning as `protect`'s, so a durable resume reports exactly what the inline path would. */
170
+ evidence?: (result: any) => unknown;
113
171
  /** Escape hatch to accept UNSIGNED resume webhooks (no secret). Off by default:
114
172
  * with no secret and this unset, the webhook handler rejects every call (fail
115
173
  * closed) so a misconfigured agent can't be driven by forged callbacks. Setting it
116
174
  * true REMOVES webhook authenticity — anyone who can reach the endpoint can signal
117
- * work — and the controller says so loudly at startup (prodsec F-07: an absolute
118
- * fail-closed claim must disclose its opt-out). Only behind a trusted network
119
- * boundary (e.g. mTLS / private mesh), never on an internet-reachable endpoint. */
175
+ * work — and the controller says so loudly at startup (an absolute fail-closed
176
+ * guarantee must disclose its opt-out). Only behind a trusted network boundary
177
+ * (e.g. mTLS / private mesh), never on an internet-reachable endpoint. */
120
178
  allowUnsigned?: boolean;
121
179
  /** The callback URL Decidio should POST the verdict to (webhook transport). Sent
122
180
  * on /agent/authorize so the decision carries it; omit when using the poll worker. */
@@ -158,27 +216,26 @@ export interface ResumeController extends ResumeConfig {
158
216
  }): {
159
217
  stop: () => void;
160
218
  };
219
+ /** Re-send a journalled execution report, IDEMPOTENTLY. Replays the exact request built before
220
+ * the first attempt — it can never re-run the business action, because the journal holds no
221
+ * handler binding. Returns the evidence outcome; a decision with nothing journalled returns
222
+ * `reportRecorded: false` with guidance rather than pretending. */
223
+ reconfirm(decisionId: string): Promise<EvidenceOutcome>;
224
+ /** Every durable item that needs a human's attention, with its state named — including the one
225
+ * state that CANNOT be resolved automatically: `indeterminate`, where a process died between
226
+ * claiming an action and journalling its result, so whether the action ran is unknown locally.
227
+ * This never executes or retries anything; it reports. */
228
+ recover(): Promise<RecoveryItem[]>;
161
229
  }
162
230
  declare function sign(secret: string, body: string): string;
163
231
  /** Verify Decidio's `x-decidio-signature` over the raw webhook body (HMAC-SHA256).
164
232
  * Exported so engine adapters (e.g. the Inngest bridge) reuse the exact check. */
165
233
  export declare function verifyResumeSignature(secret: string, body: string, header: string | undefined): boolean;
166
- /** The ONE confirm-body rule, used by ALL THREE TS confirm doors (resume, inline withApproval,
167
- * adapter gate — guard equivalent-path catch: it lived only on the resume door). Transport
168
- * success is not confirmation (prodsec F-03): a 2xx whose body carries no recorded tier is a
169
- * FAILED confirm; a SIGNED confirm coming back below application_confirmed is disclosed, never
170
- * silent. Returns the recorded tier or throws. */
234
+ /** The ONE confirm-body rule, shared by every confirm door (resume, inline withApproval,
235
+ * adapter gate). Transport success is not confirmation: a 2xx whose body carries no recorded
236
+ * tier is a FAILED confirm; a SIGNED confirm coming back below application_confirmed is
237
+ * disclosed, never silent. Returns the recorded tier or throws. */
171
238
  export declare function assertConfirmRecorded(body: any, decisionId: string, signed: boolean): string;
172
- /**
173
- * Build the resume controller. Spread it into your GuardConfig as `resume`, then
174
- * either mount `webhookHandler()` (production) or run `worker()` (CLI/laptop).
175
- *
176
- * const resume = createDecidioResume({ apiUrl, apiToken, store: new FilePendingStore(".decidio-pending"), resumeUrl });
177
- * const guard = { apiUrl, agentId, apiToken, resume };
178
- * const createOpportunity = withApproval(createOpportunityRaw, describe, guard);
179
- * // production: http.createServer(resume.webhookHandler()).listen(4100)
180
- * // or CLI: resume.worker()
181
- */
182
239
  export declare function createDecidioResume(opts: ResumeConfig & {
183
240
  store: PendingStore;
184
241
  }): ResumeController;