@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/README.md +134 -29
- package/dist/adapters/_gate.d.ts +25 -1
- package/dist/adapters/_gate.js +69 -26
- package/dist/adapters/langgraph.js +2 -15
- package/dist/adapters/openai.js +2 -5
- package/dist/adapters/temporal.js +18 -20
- package/dist/canonical.d.ts +18 -0
- package/dist/canonical.js +104 -0
- package/dist/cli.js +146 -272
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +18 -0
- package/dist/evidence.d.ts +41 -0
- package/dist/evidence.js +57 -0
- package/dist/guard.d.ts +29 -8
- package/dist/guard.js +28 -35
- package/dist/index.d.ts +19 -10
- package/dist/index.js +49 -113
- package/dist/inngest.js +6 -56
- package/dist/resume.d.ts +83 -26
- package/dist/resume.js +213 -150
- package/dist/signing.d.ts +72 -6
- package/dist/signing.js +107 -41
- package/openapi.yaml +47 -1
- package/package.json +3 -9
package/dist/inngest.js
CHANGED
|
@@ -1,48 +1,13 @@
|
|
|
1
1
|
import { verifyResumeSignature } from "./resume.js";
|
|
2
|
-
import {
|
|
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
|
|
19
|
-
|
|
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
|
|
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}
|
|
13
|
-
*
|
|
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:
|
|
90
|
-
*
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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 (
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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,
|
|
167
|
-
* adapter gate
|
|
168
|
-
*
|
|
169
|
-
*
|
|
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;
|