@decidio/sdk 0.1.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/LICENSE +202 -0
- package/README.md +163 -0
- package/dist/adapters/_gate.d.ts +13 -0
- package/dist/adapters/_gate.js +60 -0
- package/dist/adapters/langgraph.d.ts +10 -0
- package/dist/adapters/langgraph.js +28 -0
- package/dist/adapters/openai.d.ts +26 -0
- package/dist/adapters/openai.js +45 -0
- package/dist/adapters/temporal.d.ts +16 -0
- package/dist/adapters/temporal.js +34 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +314 -0
- package/dist/guard.d.ts +52 -0
- package/dist/guard.js +76 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.js +197 -0
- package/dist/inngest.d.ts +61 -0
- package/dist/inngest.js +114 -0
- package/dist/resume.d.ts +152 -0
- package/dist/resume.js +301 -0
- package/dist/signing.d.ts +48 -0
- package/dist/signing.js +83 -0
- package/openapi.yaml +171 -0
- package/package.json +83 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// @decidio/sdk — the one-line approval gate for AI-agent actions.
|
|
2
|
+
//
|
|
3
|
+
// import { guard } from "@decidio/sdk";
|
|
4
|
+
// const createOpportunity = guard.protect(createOpportunityRaw, (a) => ({
|
|
5
|
+
// action: "createOpportunity", amount: a.amount, scope: "Opportunity",
|
|
6
|
+
// }));
|
|
7
|
+
//
|
|
8
|
+
// NOTE: `guard` is the preconfigured SINGLETON (a Guard instance, see guard.ts). It is NOT a
|
|
9
|
+
// GuardConfig, so it cannot be passed as withApproval's third argument — this comment used to
|
|
10
|
+
// show exactly that, and the error propagated into the product's own copy-paste snippets.
|
|
11
|
+
// `withApproval(fn, describe, config)` is the lower-level export and takes a config OBJECT.
|
|
12
|
+
//
|
|
13
|
+
// On call, the wrapper asks Decidio's gate to authorize the action. Decidio's
|
|
14
|
+
// policy engine (Cedar / computeChain) returns proceed | route | block:
|
|
15
|
+
// • proceed → the wrapped fn runs immediately (auto-approved under policy)
|
|
16
|
+
// • route → the wrapper WAITS for a human to approve in Decidio's queue, then
|
|
17
|
+
// runs the fn (or throws if rejected)
|
|
18
|
+
// • block → the wrapper throws; the fn never runs
|
|
19
|
+
// The wrapper is a LISTENER: it captures the real source-system response the wrapped
|
|
20
|
+
// fn returns and reports it to Decidio, which seals an `application_confirmed`
|
|
21
|
+
// Authority Receipt (Decidio minimizes + tokenizes before sealing — the immutable
|
|
22
|
+
// seal never stores raw payloads). The agent executes its own action; Decidio
|
|
23
|
+
// authorizes, records, and (optionally) independently verifies it. Decidio holds NO
|
|
24
|
+
// write credentials for your system — only the authority decision and the record.
|
|
25
|
+
//
|
|
26
|
+
// Framework-agnostic by design: it wraps at the tool-call boundary, so it works with
|
|
27
|
+
// any agent runtime (LangGraph, OpenAI Agents SDK, CrewAI, a raw tool loop) and any
|
|
28
|
+
// action (Salesforce, M365, SAP, a DB write) — the wrapper doesn't care what the fn
|
|
29
|
+
// does, only that it returns the source response.
|
|
30
|
+
import { DecidioSuspendedError } from "./resume.js";
|
|
31
|
+
import { signBoundProof, signConfirm } from "./signing.js";
|
|
32
|
+
export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume } from "./resume.js";
|
|
33
|
+
export { guard, Guard } from "./guard.js";
|
|
34
|
+
export { generateKeypair, signBoundProof, signConfirm, canonicalize } from "./signing.js";
|
|
35
|
+
export class DecidioBlockedError extends Error {
|
|
36
|
+
reason;
|
|
37
|
+
decisionId;
|
|
38
|
+
constructor(reason, decisionId) {
|
|
39
|
+
super(`Decidio blocked the action: ${reason}`);
|
|
40
|
+
this.reason = reason;
|
|
41
|
+
this.decisionId = decisionId;
|
|
42
|
+
this.name = "DecidioBlockedError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export class DecidioRejectedError extends Error {
|
|
46
|
+
reason;
|
|
47
|
+
decisionId;
|
|
48
|
+
constructor(reason, decisionId) {
|
|
49
|
+
super(`A human rejected the action: ${reason}`);
|
|
50
|
+
this.reason = reason;
|
|
51
|
+
this.decisionId = decisionId;
|
|
52
|
+
this.name = "DecidioRejectedError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export class DecidioTimeoutError extends Error {
|
|
56
|
+
decisionId;
|
|
57
|
+
constructor(decisionId) {
|
|
58
|
+
super(`Timed out waiting for human approval (${decisionId})`);
|
|
59
|
+
this.decisionId = decisionId;
|
|
60
|
+
this.name = "DecidioTimeoutError";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
64
|
+
async function post(cfg, path, body) {
|
|
65
|
+
const f = cfg.fetchImpl ?? fetch;
|
|
66
|
+
const res = await f(cfg.apiUrl.replace(/\/$/, "") + path, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
69
|
+
body: JSON.stringify(body),
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok)
|
|
72
|
+
throw new Error(`decidio ${path} ${res.status}: ${await res.text().catch(() => "")}`);
|
|
73
|
+
return res.json();
|
|
74
|
+
}
|
|
75
|
+
async function get(cfg, path) {
|
|
76
|
+
const f = cfg.fetchImpl ?? fetch;
|
|
77
|
+
const res = await f(cfg.apiUrl.replace(/\/$/, "") + path, {
|
|
78
|
+
headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
79
|
+
});
|
|
80
|
+
if (!res.ok)
|
|
81
|
+
throw new Error(`decidio ${path} ${res.status}`);
|
|
82
|
+
return res.json();
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Wrap an async action behind the Decidio gate. `describe` extracts the approval
|
|
86
|
+
* context (action, amount, scope) from the call arguments. Returns a function with
|
|
87
|
+
* the SAME signature as `fn` — drop-in.
|
|
88
|
+
*/
|
|
89
|
+
/** `<appUrl>/d/<decisionId>` — the deep link that opens this decision directly, on the web app or
|
|
90
|
+
* the phone build. Null when DECIDIO_APP_URL is not configured, never a half-built URL. */
|
|
91
|
+
export function decisionUrl(appUrl, decisionId) {
|
|
92
|
+
if (!appUrl)
|
|
93
|
+
return null;
|
|
94
|
+
return `${appUrl.replace(/\/$/, "")}/d/${encodeURIComponent(decisionId)}`;
|
|
95
|
+
}
|
|
96
|
+
export function withApproval(fn, describe, config) {
|
|
97
|
+
const pollInterval = config.pollIntervalMs ?? 2_000;
|
|
98
|
+
const pollTimeout = config.pollTimeoutMs ?? 600_000;
|
|
99
|
+
const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
|
|
100
|
+
return async (...args) => {
|
|
101
|
+
const ctx = describe(...args);
|
|
102
|
+
// 1. ask the gate (carry the resume URL so a routed decision knows where to call back).
|
|
103
|
+
// When a signing key is configured, sign a request-bound identity proof so the floor
|
|
104
|
+
// can PROVE this agent rather than trust the `requester` label (registered agents must
|
|
105
|
+
// sign to auto-approve). Unsigned still works for generic/non-agent callers.
|
|
106
|
+
const requesterIdentity = (config.agentKey && config.agentDid)
|
|
107
|
+
? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
|
|
108
|
+
: undefined;
|
|
109
|
+
const auth = await post(config, "/agent/authorize", {
|
|
110
|
+
requester: config.agentId, action: ctx.action, amount: ctx.amount,
|
|
111
|
+
scope: ctx.scope, sourceSystem: config.sourceSystem,
|
|
112
|
+
...(ctx.context ? { context: ctx.context } : {}),
|
|
113
|
+
...(requesterIdentity ? { requesterIdentity } : {}),
|
|
114
|
+
...(resumeUrl ? { resumeUrl } : {}),
|
|
115
|
+
});
|
|
116
|
+
// Allow-list, not deny-list: an unrecognised verdict means this SDK is older than the
|
|
117
|
+
// server, and the safe reading of an answer you do not understand is NO. Without this, the
|
|
118
|
+
// day a fourth verdict ships every deployed SDK falls through and executes it.
|
|
119
|
+
if (auth.decision !== "proceed" && auth.decision !== "route" && auth.decision !== "block")
|
|
120
|
+
throw new DecidioBlockedError(`unrecognized gate verdict: ${String(auth.decision)}`, auth.decisionId);
|
|
121
|
+
if (auth.decision === "block")
|
|
122
|
+
throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
|
|
123
|
+
// 2. routed → either SUSPEND for async resume (durable) or BLOCK-poll (interactive)
|
|
124
|
+
if (auth.decision === "route") {
|
|
125
|
+
// Hand back a link straight to the decision. An agent that suspends and says only "waiting"
|
|
126
|
+
// leaves the operator to go hunting through a queue; one that prints the URL does not.
|
|
127
|
+
const approvalUrl = decisionUrl(config.appUrl, auth.decisionId);
|
|
128
|
+
if (config.onPending)
|
|
129
|
+
config.onPending({ decisionId: auth.decisionId, receiptId: auth.receiptId, url: approvalUrl });
|
|
130
|
+
else if (approvalUrl)
|
|
131
|
+
console.log(`[decidio] ${ctx.action} is awaiting approval — approve it at ${approvalUrl}`);
|
|
132
|
+
// Durable transport: park the args agent-side, register the raw fn so a (cold)
|
|
133
|
+
// resume can replay it, and SUSPEND. The process may exit now; the controller's
|
|
134
|
+
// webhook handler or poll worker re-executes THIS fn when a human approves.
|
|
135
|
+
// Decidio never executes — it only signals.
|
|
136
|
+
if (config.resume) {
|
|
137
|
+
// Propagate the agent's signing identity to the resume controller so the eventual
|
|
138
|
+
// (possibly cold) confirm on resume is SIGNED too — earning application_confirmed on
|
|
139
|
+
// the durable path, not just inline. (No-op if the controller already has its own.)
|
|
140
|
+
config.resume.agentId ??= config.agentId;
|
|
141
|
+
config.resume.agentKey ??= config.agentKey;
|
|
142
|
+
config.resume.agentDid ??= config.agentDid;
|
|
143
|
+
config.resume.register(ctx.action, fn);
|
|
144
|
+
await config.resume.store.put({
|
|
145
|
+
decisionId: auth.decisionId, receiptId: auth.receiptId,
|
|
146
|
+
action: ctx.action, amount: ctx.amount, scope: ctx.scope,
|
|
147
|
+
args, createdAt: new Date().toISOString(),
|
|
148
|
+
});
|
|
149
|
+
throw new DecidioSuspendedError(auth.decisionId, ctx.action);
|
|
150
|
+
}
|
|
151
|
+
// Interactive transport (no resume configured): block until decided. Only
|
|
152
|
+
// appropriate for short, watched approvals (demos, synchronous tools).
|
|
153
|
+
const deadline = Date.now() + pollTimeout;
|
|
154
|
+
// eslint-disable-next-line no-constant-condition
|
|
155
|
+
while (true) {
|
|
156
|
+
const st = await get(config, `/agent/status/${encodeURIComponent(auth.decisionId)}`);
|
|
157
|
+
if (st.state === "approved" || st.state === "auto_approved")
|
|
158
|
+
break;
|
|
159
|
+
if (st.state === "rejected")
|
|
160
|
+
throw new DecidioRejectedError(st.reason ?? "rejected by approver", auth.decisionId);
|
|
161
|
+
if (st.state === "blocked")
|
|
162
|
+
throw new DecidioBlockedError(st.reason ?? "blocked", auth.decisionId);
|
|
163
|
+
if (Date.now() > deadline) {
|
|
164
|
+
// The founder-visible failure of blocking: the wait has a ceiling, and past it the
|
|
165
|
+
// work is LOST — the decision stays open in Decidio but nothing is parked to resume.
|
|
166
|
+
// Say so, say the limit, and say the durable way out.
|
|
167
|
+
console.warn(`[decidio] blocking wait hit its ${Math.round(pollTimeout / 1000)}s limit — decision ${auth.decisionId} is still open in Decidio, but this process stopped waiting and nothing was executed. Re-run to request again, or use durable resume (guard.worker()) so an approval is never lost.`);
|
|
168
|
+
throw new DecidioTimeoutError(auth.decisionId);
|
|
169
|
+
}
|
|
170
|
+
await sleep(pollInterval);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// 3. authorized inline (proceed, or interactive route just approved) → run the
|
|
174
|
+
// REAL action and capture the source response (listener)
|
|
175
|
+
const result = await fn(...args);
|
|
176
|
+
// 4. report the captured outcome → Decidio seals application_confirmed
|
|
177
|
+
// (server minimizes + tokenizes the response before sealing). Best-effort:
|
|
178
|
+
// a confirmation failure must not undo a successful business action.
|
|
179
|
+
try {
|
|
180
|
+
// Sign the confirmation too (bound to {action:"confirm", scope:decisionId}) so the gate
|
|
181
|
+
// can PROVE this is the receipt's original requester before granting the wrapper tier
|
|
182
|
+
// (application_confirmed). Without a key the report is capped at agent_asserted server-side.
|
|
183
|
+
const confirmIdentity = signConfirm(auth.decisionId, config);
|
|
184
|
+
await post(config, "/agent/confirm", {
|
|
185
|
+
decisionId: auth.decisionId,
|
|
186
|
+
capturedResponse: result ?? null,
|
|
187
|
+
source: "wrapper", // the SDK captured the genuine response, not an agent self-report
|
|
188
|
+
...(confirmIdentity ? { requesterIdentity: confirmIdentity } : {}),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
// surface but don't throw — the action already happened
|
|
193
|
+
console.warn(`[decidio] confirmation post failed (action succeeded): ${e.message}`);
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { RequestListener } from "node:http";
|
|
2
|
+
import { verifyResumeSignature } from "./resume.js";
|
|
3
|
+
import type { GuardConfig, ApprovalContext } from "./index.js";
|
|
4
|
+
/** The Inngest event Decidio's verdict is translated into. The waiting function
|
|
5
|
+
* correlates on `data.decisionId`. */
|
|
6
|
+
export declare const DECIDIO_RESUME_EVENT = "decidio/decision.resolved";
|
|
7
|
+
/** The minimal slices of the Inngest step + client APIs we use, typed structurally so
|
|
8
|
+
* this file doesn't force an `inngest` type dependency on the core build. */
|
|
9
|
+
export interface InngestStepLike {
|
|
10
|
+
run<T>(id: string, fn: () => Promise<T>): Promise<T>;
|
|
11
|
+
waitForEvent(id: string, opts: {
|
|
12
|
+
event: string;
|
|
13
|
+
timeout: string;
|
|
14
|
+
match?: string;
|
|
15
|
+
if?: string;
|
|
16
|
+
}): Promise<{
|
|
17
|
+
name: string;
|
|
18
|
+
data?: Record<string, any>;
|
|
19
|
+
} | null>;
|
|
20
|
+
}
|
|
21
|
+
export interface InngestClientLike {
|
|
22
|
+
send(event: {
|
|
23
|
+
name: string;
|
|
24
|
+
data: Record<string, any>;
|
|
25
|
+
}): Promise<unknown>;
|
|
26
|
+
}
|
|
27
|
+
export type GateStepOutcome<R> = {
|
|
28
|
+
status: "executed" | "rejected" | "blocked";
|
|
29
|
+
decisionId: string;
|
|
30
|
+
result?: R;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Gate an action INSIDE an Inngest function, with durable suspend on routing.
|
|
34
|
+
* • proceed → run the action (as a step) + confirm.
|
|
35
|
+
* • route → `step.waitForEvent` durably suspends until Decidio's resume event for
|
|
36
|
+
* THIS decision arrives; approved → run + confirm; rejected → status.
|
|
37
|
+
* • block → return blocked (the action never runs).
|
|
38
|
+
* The action runs in YOUR process with YOUR credentials — Decidio only signals.
|
|
39
|
+
* `config.resumeUrl` must point at the bridge endpoint (createInngestResumeBridge),
|
|
40
|
+
* so Decidio's webhook is translated into the event this step waits on.
|
|
41
|
+
*/
|
|
42
|
+
export declare function decidioGateStep<R>(args: {
|
|
43
|
+
step: InngestStepLike;
|
|
44
|
+
config: GuardConfig;
|
|
45
|
+
ctx: ApprovalContext;
|
|
46
|
+
run: () => Promise<R>;
|
|
47
|
+
timeout?: string;
|
|
48
|
+
}): Promise<GateStepOutcome<R>>;
|
|
49
|
+
/**
|
|
50
|
+
* Decidio → Inngest bridge. Mount as an HTTP endpoint and register it as the agent's
|
|
51
|
+
* `resumeUrl`. It verifies Decidio's signed resume webhook (same HMAC as the core
|
|
52
|
+
* handler) and emits `DECIDIO_RESUME_EVENT` into Inngest, waking the suspended
|
|
53
|
+
* `decidioGateStep`. Fails CLOSED without a configured secret (unless allowUnsigned).
|
|
54
|
+
*/
|
|
55
|
+
export declare function createInngestResumeBridge(opts: {
|
|
56
|
+
inngest: InngestClientLike;
|
|
57
|
+
webhookSecret?: string;
|
|
58
|
+
allowUnsigned?: boolean;
|
|
59
|
+
eventName?: string;
|
|
60
|
+
}): RequestListener;
|
|
61
|
+
export { verifyResumeSignature };
|
package/dist/inngest.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
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`. */
|
|
5
|
+
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
|
+
export async function decidioGateStep(args) {
|
|
17
|
+
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
|
+
});
|
|
40
|
+
if (auth.decision === "block")
|
|
41
|
+
return { status: "blocked", decisionId: auth.decisionId };
|
|
42
|
+
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
|
+
const ev = await step.waitForEvent("decidio-await-approval", {
|
|
47
|
+
event: DECIDIO_RESUME_EVENT,
|
|
48
|
+
timeout: args.timeout ?? "30d",
|
|
49
|
+
if: `async.data.decisionId == "${auth.decisionId}"`,
|
|
50
|
+
});
|
|
51
|
+
if (!ev)
|
|
52
|
+
throw new Error(`decidio approval timed out (${auth.decisionId})`);
|
|
53
|
+
const verdict = ev.data?.verdict;
|
|
54
|
+
if (verdict === "rejected")
|
|
55
|
+
return { status: "rejected", decisionId: auth.decisionId };
|
|
56
|
+
if (verdict !== "approved" && verdict !== "auto_approved")
|
|
57
|
+
return { status: "blocked", decisionId: auth.decisionId };
|
|
58
|
+
}
|
|
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
|
+
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
|
+
});
|
|
70
|
+
return { status: "executed", decisionId: auth.decisionId, result };
|
|
71
|
+
}
|
|
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
|
+
export function createInngestResumeBridge(opts) {
|
|
79
|
+
const secret = opts.webhookSecret ?? "";
|
|
80
|
+
const eventName = opts.eventName ?? DECIDIO_RESUME_EVENT;
|
|
81
|
+
return (req, res) => {
|
|
82
|
+
if (req.method !== "POST") {
|
|
83
|
+
res.statusCode = 405;
|
|
84
|
+
return res.end("method not allowed");
|
|
85
|
+
}
|
|
86
|
+
let raw = "";
|
|
87
|
+
req.on("data", (c) => (raw += c));
|
|
88
|
+
req.on("end", async () => {
|
|
89
|
+
try {
|
|
90
|
+
if (!secret) {
|
|
91
|
+
if (!opts.allowUnsigned) {
|
|
92
|
+
res.statusCode = 401;
|
|
93
|
+
return res.end(JSON.stringify({ error: "resume webhook unsigned and no secret configured" }));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else if (!verifyResumeSignature(secret, raw, req.headers["x-decidio-signature"])) {
|
|
97
|
+
res.statusCode = 401;
|
|
98
|
+
return res.end(JSON.stringify({ error: "bad signature" }));
|
|
99
|
+
}
|
|
100
|
+
const body = JSON.parse(raw || "{}");
|
|
101
|
+
await opts.inngest.send({ name: eventName, data: { decisionId: String(body.decisionId), verdict: body.verdict, reason: body.reason ?? null } });
|
|
102
|
+
res.statusCode = 200;
|
|
103
|
+
res.setHeader("content-type", "application/json");
|
|
104
|
+
res.end(JSON.stringify({ ok: true, forwarded: eventName, decisionId: body.decisionId }));
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
res.statusCode = 500;
|
|
108
|
+
res.end(JSON.stringify({ error: e?.message ?? "bridge failed" }));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// Re-export the signature check so a host can verify the webhook out-of-band if needed.
|
|
114
|
+
export { verifyResumeSignature };
|
package/dist/resume.d.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { RequestListener } from "node:http";
|
|
2
|
+
/** A routed action parked on the agent side, awaiting human approval. The `args`
|
|
3
|
+
* are the original call arguments, replayed verbatim when the action resumes. */
|
|
4
|
+
export interface PendingAction {
|
|
5
|
+
decisionId: string;
|
|
6
|
+
receiptId?: string;
|
|
7
|
+
action: string;
|
|
8
|
+
amount: number;
|
|
9
|
+
scope?: string;
|
|
10
|
+
args: unknown[];
|
|
11
|
+
createdAt: string;
|
|
12
|
+
}
|
|
13
|
+
/** Where suspended actions live between request and approval. The default is a
|
|
14
|
+
* file store; swap in Redis/Postgres/your queue for production. AGENT-SIDE only —
|
|
15
|
+
* Decidio never sees this. */
|
|
16
|
+
export interface PendingStore {
|
|
17
|
+
put(p: PendingAction): Promise<void>;
|
|
18
|
+
get(decisionId: string): Promise<PendingAction | null>;
|
|
19
|
+
delete(decisionId: string): Promise<void>;
|
|
20
|
+
list(): Promise<PendingAction[]>;
|
|
21
|
+
/** Atomically take ownership of a parked action for execution. Returns true if THIS
|
|
22
|
+
* caller claimed it, false if it's already claimed/gone — the cross-process single-use
|
|
23
|
+
* guarantee (a production store backs this with rename/SETNX/SELECT-FOR-UPDATE). A
|
|
24
|
+
* claimed entry is excluded from list(). Optional: if absent, the controller falls
|
|
25
|
+
* back to an in-process guard (same-process only). */
|
|
26
|
+
claim?(decisionId: string): Promise<boolean>;
|
|
27
|
+
/** Undo a claim so a failed execution is retryable. */
|
|
28
|
+
release?(decisionId: string): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export declare class MemoryPendingStore implements PendingStore {
|
|
31
|
+
private m;
|
|
32
|
+
private claimed;
|
|
33
|
+
put(p: PendingAction): Promise<void>;
|
|
34
|
+
get(id: string): Promise<PendingAction | null>;
|
|
35
|
+
delete(id: string): Promise<void>;
|
|
36
|
+
list(): Promise<PendingAction[]>;
|
|
37
|
+
claim(id: string): Promise<boolean>;
|
|
38
|
+
release(id: string): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
/** File-backed store so a COLD process (the original requester long gone) can still
|
|
41
|
+
* resume — the proof that nothing is "kept alive". One JSON file per decision. A claim
|
|
42
|
+
* renames `<id>.json` → `<id>.claimed` atomically (same-volume rename), so two processes
|
|
43
|
+
* sharing the dir can't both execute. (A stale `.claimed` from a crash mid-execute needs
|
|
44
|
+
* TTL recovery — tracked in the backlog.) */
|
|
45
|
+
export declare class FilePendingStore implements PendingStore {
|
|
46
|
+
private dir;
|
|
47
|
+
constructor(dir: string);
|
|
48
|
+
private key;
|
|
49
|
+
private path;
|
|
50
|
+
private claimedPath;
|
|
51
|
+
put(p: PendingAction): Promise<void>;
|
|
52
|
+
get(id: string): Promise<PendingAction | null>;
|
|
53
|
+
delete(id: string): Promise<void>;
|
|
54
|
+
list(): Promise<PendingAction[]>;
|
|
55
|
+
claim(id: string): Promise<boolean>;
|
|
56
|
+
release(id: string): Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
/** Thrown by a `withApproval`-wrapped call when the action is routed and configured
|
|
59
|
+
* for async resume: the action is now SUSPENDED (parked in the store), not failed.
|
|
60
|
+
* The caller can stop here — the process may even exit; the resume transport will
|
|
61
|
+
* complete it later. */
|
|
62
|
+
export declare class DecidioSuspendedError extends Error {
|
|
63
|
+
decisionId: string;
|
|
64
|
+
action: string;
|
|
65
|
+
constructor(decisionId: string, action: string);
|
|
66
|
+
}
|
|
67
|
+
export type ResumeStatus = "executed" | "rejected" | "blocked" | "no_handler" | "unknown";
|
|
68
|
+
export interface ResumeOutcome {
|
|
69
|
+
status: ResumeStatus;
|
|
70
|
+
decisionId: string;
|
|
71
|
+
action?: string;
|
|
72
|
+
result?: unknown;
|
|
73
|
+
reason?: string;
|
|
74
|
+
}
|
|
75
|
+
/** Minimal config the controller needs to confirm outcomes + poll status. Shared
|
|
76
|
+
* with GuardConfig (apiUrl/apiToken). */
|
|
77
|
+
export interface ResumeConfig {
|
|
78
|
+
apiUrl: string;
|
|
79
|
+
apiToken?: string;
|
|
80
|
+
/** Agent identity for SIGNING the confirm call on resume (so the durable path can earn the
|
|
81
|
+
* application_confirmed tier, not just the inline path). Default from DECIDIO_AGENT_ID/
|
|
82
|
+
* _KEY/_DID env when unset — a cold resume process that has the env signs automatically. */
|
|
83
|
+
agentId?: string;
|
|
84
|
+
agentKey?: string;
|
|
85
|
+
agentDid?: string;
|
|
86
|
+
/** The workspace the agent acts in (DECIDIO_WORKSPACE_ID) — bound into the cold-resume confirm
|
|
87
|
+
* proof (D-B3.6) so it's tenant-scoped too. Default from env when unset. */
|
|
88
|
+
workspaceId?: string;
|
|
89
|
+
/** Shared secret Decidio signs the resume webhook with (HMAC-SHA256). Defaults to
|
|
90
|
+
* the API token if unset. Verified before any re-execution. */
|
|
91
|
+
webhookSecret?: string;
|
|
92
|
+
/** Escape hatch to accept UNSIGNED resume webhooks (no secret). Off by default:
|
|
93
|
+
* with no secret and this unset, the webhook handler rejects every call (fail
|
|
94
|
+
* closed) so a misconfigured agent can't be driven by forged callbacks. Only set
|
|
95
|
+
* true behind a trusted network boundary (e.g. mTLS / private mesh). */
|
|
96
|
+
allowUnsigned?: boolean;
|
|
97
|
+
/** The callback URL Decidio should POST the verdict to (webhook transport). Sent
|
|
98
|
+
* on /agent/authorize so the decision carries it; omit when using the poll worker. */
|
|
99
|
+
resumeUrl?: string;
|
|
100
|
+
/** Fired after every terminal resolution (executed / rejected / blocked / no_handler),
|
|
101
|
+
* via either transport. Use it to log, close a listener, or exit a CLI. */
|
|
102
|
+
onResolved?: (o: ResumeOutcome) => void;
|
|
103
|
+
fetchImpl?: typeof fetch;
|
|
104
|
+
}
|
|
105
|
+
/** Re-execute the agent's own fn on approval, confirm the captured outcome to
|
|
106
|
+
* Decidio, and clear the parked entry. Bound to a store + a registry of raw fns. */
|
|
107
|
+
export interface ResumeController extends ResumeConfig {
|
|
108
|
+
store: PendingStore;
|
|
109
|
+
/** Register a RAW fn under its action name so a (possibly cold) resume can replay
|
|
110
|
+
* it. `withApproval` auto-registers on the hot path; a cold worker/service should
|
|
111
|
+
* register at startup so it can resume actions it never originated. */
|
|
112
|
+
register(action: string, fn: (...args: any[]) => Promise<any>): void;
|
|
113
|
+
/** Drive a parked decision to completion once a verdict is known. Idempotent:
|
|
114
|
+
* a second call after success finds nothing parked → "unknown" (no double-write). */
|
|
115
|
+
resolve(input: {
|
|
116
|
+
decisionId: string;
|
|
117
|
+
verdict: "approved" | "rejected" | "blocked";
|
|
118
|
+
reason?: string;
|
|
119
|
+
}): Promise<ResumeOutcome>;
|
|
120
|
+
/** node:http listener for Decidio's signed approval webhook (Express/Fastify/node). */
|
|
121
|
+
webhookHandler(): RequestListener;
|
|
122
|
+
/** Web-Fetch handler (Request → Response) for Next.js route handlers, Hono,
|
|
123
|
+
* Cloudflare Workers, Bun, Deno. Same fail-closed signature check. */
|
|
124
|
+
fetchHandler(): (req: Request) => Promise<Response>;
|
|
125
|
+
/** Poll-based resume for hosts with no inbound URL (CLI/laptop). A long-lived
|
|
126
|
+
* watcher — this is a durable WORKER, not the request path blocking. */
|
|
127
|
+
worker(opts?: {
|
|
128
|
+
intervalMs?: number;
|
|
129
|
+
timeoutMs?: number;
|
|
130
|
+
onResolved?: (o: ResumeOutcome) => void;
|
|
131
|
+
}): {
|
|
132
|
+
stop: () => void;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
declare function sign(secret: string, body: string): string;
|
|
136
|
+
/** Verify Decidio's `x-decidio-signature` over the raw webhook body (HMAC-SHA256).
|
|
137
|
+
* Exported so engine adapters (e.g. the Inngest bridge) reuse the exact check. */
|
|
138
|
+
export declare function verifyResumeSignature(secret: string, body: string, header: string | undefined): boolean;
|
|
139
|
+
/**
|
|
140
|
+
* Build the resume controller. Spread it into your GuardConfig as `resume`, then
|
|
141
|
+
* either mount `webhookHandler()` (production) or run `worker()` (CLI/laptop).
|
|
142
|
+
*
|
|
143
|
+
* const resume = createDecidioResume({ apiUrl, apiToken, store: new FilePendingStore(".decidio-pending"), resumeUrl });
|
|
144
|
+
* const guard = { apiUrl, agentId, apiToken, resume };
|
|
145
|
+
* const createOpportunity = withApproval(createOpportunityRaw, describe, guard);
|
|
146
|
+
* // production: http.createServer(resume.webhookHandler()).listen(4100)
|
|
147
|
+
* // or CLI: resume.worker()
|
|
148
|
+
*/
|
|
149
|
+
export declare function createDecidioResume(opts: ResumeConfig & {
|
|
150
|
+
store: PendingStore;
|
|
151
|
+
}): ResumeController;
|
|
152
|
+
export { sign as _signResumeBody };
|