@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/cli.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @decidio/sdk CLI — the on-ramp. Run via `npx @decidio/sdk <cmd>` (or tsx in-repo).
|
|
3
|
+
// init [agentId] sign in, register the agent, mint its API token, write .env
|
|
4
|
+
// doctor check config + connectivity + durability readiness
|
|
5
|
+
// approvals list pending agent approvals; `approvals approve|reject <id> [reason]`
|
|
6
|
+
// dev [--target U] signal-only relay: forwards Decidio's webhook to your local agent URL
|
|
7
|
+
//
|
|
8
|
+
// TWO TOKEN KINDS, deliberately (self-serve design 2026-08-28): commands that ADMINISTER a
|
|
9
|
+
// workspace (init's register+mint, approvals) need the owner's SESSION token — acquired
|
|
10
|
+
// interactively per run or from DECIDIO_SESSION_TOKEN, held in memory, NEVER written to disk.
|
|
11
|
+
// What lands in .env as DECIDIO_API_TOKEN is the floor-limited AGENT token the runtime SDK
|
|
12
|
+
// needs (it can call /agent/* and nothing else). A session-capable secret must never live in
|
|
13
|
+
// an agent host's .env — that was the pre-publish design's hole.
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
16
|
+
import { createServer } from "node:http";
|
|
17
|
+
import { createInterface } from "node:readline";
|
|
18
|
+
import { generateKeypair } from "./signing.js";
|
|
19
|
+
const API = (process.env.DECIDIO_API_URL ?? "http://localhost:4000").replace(/\/$/, "");
|
|
20
|
+
const TOKEN = process.env.DECIDIO_API_TOKEN;
|
|
21
|
+
const ENV_PATH = ".env";
|
|
22
|
+
const log = (...a) => console.log(...a);
|
|
23
|
+
async function api(path, init = {}, bearer) {
|
|
24
|
+
const auth = bearer ?? TOKEN;
|
|
25
|
+
const res = await fetch(API + path, { ...init, headers: { "content-type": "application/json", ...(auth ? { authorization: `Bearer ${auth}` } : {}), ...(init.headers ?? {}) } });
|
|
26
|
+
const text = await res.text();
|
|
27
|
+
let body = null;
|
|
28
|
+
try {
|
|
29
|
+
body = text ? JSON.parse(text) : null;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
body = text;
|
|
33
|
+
}
|
|
34
|
+
return { status: res.status, body };
|
|
35
|
+
}
|
|
36
|
+
function readEnv() {
|
|
37
|
+
const out = {};
|
|
38
|
+
if (existsSync(ENV_PATH))
|
|
39
|
+
for (const line of readFileSync(ENV_PATH, "utf8").split(/\r?\n/)) {
|
|
40
|
+
const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
|
|
41
|
+
if (m)
|
|
42
|
+
out[m[1]] = m[2];
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
function upsertEnv(updates) {
|
|
47
|
+
const cur = readEnv();
|
|
48
|
+
const merged = { ...cur, ...updates };
|
|
49
|
+
const body = Object.entries(merged).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
|
|
50
|
+
writeFileSync(ENV_PATH, body);
|
|
51
|
+
}
|
|
52
|
+
// ---- interactive input (session sign-in) ---------------------------------------------------
|
|
53
|
+
function ask(question, mute = false) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
56
|
+
if (mute) {
|
|
57
|
+
// Hide password echo: after the prompt renders, swallow subsequent output writes.
|
|
58
|
+
const anyRl = rl;
|
|
59
|
+
const write = anyRl._writeToOutput?.bind(rl);
|
|
60
|
+
anyRl._writeToOutput = (s) => { if (s.includes(question))
|
|
61
|
+
write?.(s); };
|
|
62
|
+
}
|
|
63
|
+
rl.question(question, (answer) => { rl.close(); if (mute)
|
|
64
|
+
process.stdout.write("\n"); resolve(answer.trim()); });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** Owner SESSION token, memory-only. Sources in order: DECIDIO_SESSION_TOKEN env, interactive
|
|
68
|
+
* email+password (`/demo/login`), pasted sign-in link (`/demo/login-consume` — for the
|
|
69
|
+
* password-less accounts the welcome flow creates). Never persisted. */
|
|
70
|
+
async function acquireSession() {
|
|
71
|
+
if (process.env.DECIDIO_SESSION_TOKEN)
|
|
72
|
+
return process.env.DECIDIO_SESSION_TOKEN;
|
|
73
|
+
// Same rule the server applies to resume URLs: credentials never ride plaintext http to a
|
|
74
|
+
// non-loopback host. Refuse before prompting, not after collecting a password.
|
|
75
|
+
if (/^http:\/\//i.test(API) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(API)) {
|
|
76
|
+
log(`✘ refusing to send sign-in credentials over plaintext http to ${API} — use https (or localhost for local dev).`);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
if (!process.stdin.isTTY) {
|
|
80
|
+
log("✘ a Decidio sign-in is needed and this terminal is non-interactive.");
|
|
81
|
+
log(" Set DECIDIO_SESSION_TOKEN (grab a sign-in link from your workspace email; its ?login= value is the token).");
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
log(`Sign in to your Decidio workspace (${API}). Press Enter at the password prompt to use an emailed sign-in link instead.`);
|
|
85
|
+
const email = await ask(" email: ");
|
|
86
|
+
if (email) {
|
|
87
|
+
const password = await ask(" password (Enter to skip): ", true);
|
|
88
|
+
if (password) {
|
|
89
|
+
const r = await api("/demo/login", { method: "POST", body: JSON.stringify({ email, password }) }, "");
|
|
90
|
+
if (r.status >= 200 && r.status < 300 && r.body?.token)
|
|
91
|
+
return r.body.token;
|
|
92
|
+
log(` ✘ sign-in failed (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}. Trying the link path…`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
log(" Request a sign-in link from the app's sign-in screen (or use your welcome email), then paste the LINK or its token here.");
|
|
96
|
+
const pasted = await ask(" link or token: ");
|
|
97
|
+
if (!pasted)
|
|
98
|
+
return null;
|
|
99
|
+
const m = pasted.match(/[?&]login=([^&\s]+)/);
|
|
100
|
+
const linkToken = m ? decodeURIComponent(m[1]) : pasted;
|
|
101
|
+
const r = await api("/demo/login-consume", { method: "POST", body: JSON.stringify({ token: linkToken }) }, "");
|
|
102
|
+
if (r.status >= 200 && r.status < 300 && r.body?.token)
|
|
103
|
+
return r.body.token;
|
|
104
|
+
log(` ✘ link sign-in failed (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}`);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
async function cmdInit(agentId) {
|
|
108
|
+
log(`Registering agent "${agentId}" with Decidio at ${API}…`);
|
|
109
|
+
// CLIENT-SIDE KEYGEN: generate the Ed25519 keypair LOCALLY and register only the PUBLIC
|
|
110
|
+
// did:key. The private key NEVER crosses the wire — it goes straight to this host's env.
|
|
111
|
+
const existingKey = readEnv().DECIDIO_AGENT_KEY;
|
|
112
|
+
const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM || readEnv().DECIDIO_SOURCE_SYSTEM;
|
|
113
|
+
const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
|
|
114
|
+
const registerBody = JSON.stringify({ agentId, did: localDid, ...(sourceSystem ? { sourceSystem } : {}) });
|
|
115
|
+
// First try whatever bearer the env already carries (the local-dev / operator path — a static
|
|
116
|
+
// admin token, or dev-open auth). On 401/403 fall through to the self-serve session flow.
|
|
117
|
+
let session = null;
|
|
118
|
+
let r = await api("/api/agents/register", { method: "POST", body: registerBody });
|
|
119
|
+
if (r.status === 401 || r.status === 403) {
|
|
120
|
+
session = await acquireSession();
|
|
121
|
+
if (!session) {
|
|
122
|
+
log("✘ unauthorized — sign in (or set DECIDIO_SESSION_TOKEN / DECIDIO_API_TOKEN) and retry.");
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
r = await api("/api/agents/register", { method: "POST", body: registerBody }, session);
|
|
126
|
+
}
|
|
127
|
+
let did, privateKey;
|
|
128
|
+
if (r.status === 409) {
|
|
129
|
+
// Already registered to a different (server-held) did. Reuse it; the local key we just
|
|
130
|
+
// minted does NOT match, so don't write it — the operator must supply the existing key.
|
|
131
|
+
log(`• agent already registered (did ${r.body?.did}); reusing it.`);
|
|
132
|
+
did = r.body?.did;
|
|
133
|
+
if (!existingKey)
|
|
134
|
+
log("⚠ this agent was registered earlier — set its existing DECIDIO_AGENT_KEY to sign (auto-approve needs a proof).");
|
|
135
|
+
}
|
|
136
|
+
else if (r.status >= 200 && r.status < 300) {
|
|
137
|
+
did = r.body?.did;
|
|
138
|
+
privateKey = localKey;
|
|
139
|
+
log(`✔ registered (did ${did}) — key generated locally, public did only sent to Decidio`);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
log(`✘ register failed (${r.status}): ${JSON.stringify(r.body)}`);
|
|
143
|
+
return 1;
|
|
144
|
+
}
|
|
145
|
+
// Mint the runtime credential: the floor-limited AGENT token (can call /agent/* only). This is
|
|
146
|
+
// what .env gets — the session token stays in memory and dies with this process. On boxes
|
|
147
|
+
// without the tester-lab fence (plain local dev) the mint endpoint refuses; that's fine —
|
|
148
|
+
// local dev auths with its existing env and we say so instead of failing the init.
|
|
149
|
+
let agentToken, workspaceId;
|
|
150
|
+
const mintBearer = session ?? TOKEN;
|
|
151
|
+
if (mintBearer) {
|
|
152
|
+
const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "sdk-cli" }) }, mintBearer);
|
|
153
|
+
if (mint.status >= 200 && mint.status < 300 && mint.body?.token) {
|
|
154
|
+
agentToken = mint.body.token;
|
|
155
|
+
workspaceId = mint.body.workspaceId;
|
|
156
|
+
log(`✔ minted the agent API token (shown once server-side; expires ${mint.body.expiresAt ?? "per policy"})`);
|
|
157
|
+
}
|
|
158
|
+
else if (session) {
|
|
159
|
+
log(`✘ could not mint the agent token (${mint.status}): ${JSON.stringify(mint.body)} — the SDK has no runtime credential; fix and re-run init.`);
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
log(`• no agent token minted (${mint.status}) — assuming local/operator auth via the existing DECIDIO_API_TOKEN.`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const secret = readEnv().DECIDIO_WEBHOOK_SECRET || randomBytes(32).toString("hex");
|
|
167
|
+
const envOut = { DECIDIO_API_URL: API, DECIDIO_AGENT_ID: agentId, DECIDIO_WEBHOOK_SECRET: secret };
|
|
168
|
+
if (sourceSystem)
|
|
169
|
+
envOut.DECIDIO_SOURCE_SYSTEM = sourceSystem;
|
|
170
|
+
if (did)
|
|
171
|
+
envOut.DECIDIO_AGENT_DID = did;
|
|
172
|
+
if (privateKey)
|
|
173
|
+
envOut.DECIDIO_AGENT_KEY = privateKey; // written locally; never left this host
|
|
174
|
+
if (agentToken)
|
|
175
|
+
envOut.DECIDIO_API_TOKEN = agentToken; // the FLOOR-LIMITED token — never the session
|
|
176
|
+
if (workspaceId)
|
|
177
|
+
envOut.DECIDIO_WORKSPACE_ID = workspaceId;
|
|
178
|
+
upsertEnv(envOut);
|
|
179
|
+
log(`✔ wrote ${ENV_PATH} (DECIDIO_API_URL, DECIDIO_AGENT_ID, DECIDIO_WEBHOOK_SECRET${did ? ", DECIDIO_AGENT_DID" : ""}${privateKey ? ", DECIDIO_AGENT_KEY" : ""}${agentToken ? ", DECIDIO_API_TOKEN [agent-scope], DECIDIO_WORKSPACE_ID" : ""})`);
|
|
180
|
+
if (privateKey)
|
|
181
|
+
log(`\n✔ signing key stored locally as DECIDIO_AGENT_KEY in ${ENV_PATH} (keep that file out of git; move it to your secret manager for production).\n`);
|
|
182
|
+
log("Add one line to your agent:");
|
|
183
|
+
log(` import { guard } from "@decidio/sdk";`);
|
|
184
|
+
log(` const createOpp = guard.protect(createOppRaw, o => ({ action: "createOpportunity", amount: o.Amount, scope: "Opportunity" }));`);
|
|
185
|
+
log("Mount the resume route (durable async approval):");
|
|
186
|
+
log(` // Express: app.post("/decidio/resume", guard.resumeHandler())`);
|
|
187
|
+
log(` // Next.js: export const POST = (req) => guard.resumeFetchHandler()(req)`);
|
|
188
|
+
log("");
|
|
189
|
+
return cmdDoctor();
|
|
190
|
+
}
|
|
191
|
+
async function cmdDoctor() {
|
|
192
|
+
const env = readEnv();
|
|
193
|
+
let ok = true;
|
|
194
|
+
const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
|
|
195
|
+
line(!!(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL), `API URL: ${API}`);
|
|
196
|
+
line(!!(process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID), `agent id: ${process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID || "(unset)"}`);
|
|
197
|
+
const secret = process.env.DECIDIO_WEBHOOK_SECRET || env.DECIDIO_WEBHOOK_SECRET;
|
|
198
|
+
line(!!secret, secret ? "webhook secret: set (durable resume will fail-closed verify)" : "webhook secret: MISSING — resume webhook will fail closed");
|
|
199
|
+
// Signing key — REQUIRED for auto-approve. Without it the SDK can't prove identity, so the
|
|
200
|
+
// gate routes EVERY request to a human (safe, but surprising). Surface it loudly.
|
|
201
|
+
const signing = (process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) && (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
|
|
202
|
+
if (signing)
|
|
203
|
+
log("✔ signing key: set — agent proves identity, so policy auto-approve is in effect");
|
|
204
|
+
else
|
|
205
|
+
log("⚠ signing key: MISSING (DECIDIO_AGENT_KEY/DID) — without it the agent can't prove identity, so EVERY request routes to a human. Run `npx @decidio/sdk init` to generate one.");
|
|
206
|
+
// Reachability and token role are SEPARATE checks (the old probe called any <500 green, so a
|
|
207
|
+
// wrong-kind token looked healthy). /api/health is unauthenticated truth about reachability;
|
|
208
|
+
// /api/records then classifies the token: 2xx = session/admin scope, 401 = agent-floor scope
|
|
209
|
+
// (correct for the runtime; init/approvals need a sign-in).
|
|
210
|
+
try {
|
|
211
|
+
const h = await api("/api/health", {}, "");
|
|
212
|
+
line(h.status >= 200 && h.status < 300, `connectivity: API reachable (${h.status})`);
|
|
213
|
+
const rec = await api("/api/records");
|
|
214
|
+
if (rec.status >= 200 && rec.status < 300)
|
|
215
|
+
log("✔ token scope: session/admin (init + approvals will work from this env)");
|
|
216
|
+
else if (rec.status === 401)
|
|
217
|
+
log("✔ token scope: agent floor — correct for the runtime SDK (approvals/init will prompt for a sign-in)");
|
|
218
|
+
else
|
|
219
|
+
log(`⚠ token scope: unexpected ${rec.status} from /api/records`);
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
line(false, `connectivity: cannot reach ${API} — ${e?.message}`);
|
|
223
|
+
}
|
|
224
|
+
log(ok ? "\ndoctor: all green — durable resume ready." : "\ndoctor: fix the ✘ items above.");
|
|
225
|
+
return ok ? 0 : 1;
|
|
226
|
+
}
|
|
227
|
+
async function cmdApprovals(sub, id, reason) {
|
|
228
|
+
// Approvals are a HUMAN act on the workspace — session scope, never the agent token. If the
|
|
229
|
+
// env's token is agent-floor (401 on /api/*), sign in interactively; memory-only as always.
|
|
230
|
+
let bearer;
|
|
231
|
+
const probe = await api("/api/decisions/cards");
|
|
232
|
+
if (probe.status === 401 || probe.status === 403) {
|
|
233
|
+
const session = await acquireSession();
|
|
234
|
+
if (!session) {
|
|
235
|
+
log("✘ approvals need a workspace sign-in.");
|
|
236
|
+
return 1;
|
|
237
|
+
}
|
|
238
|
+
bearer = session;
|
|
239
|
+
}
|
|
240
|
+
if (sub === "approve" || sub === "reject") {
|
|
241
|
+
if (!id) {
|
|
242
|
+
log(`usage: approvals ${sub} <decisionId> [reason]`);
|
|
243
|
+
return 2;
|
|
244
|
+
}
|
|
245
|
+
const r = await api(`/api/decisions/${encodeURIComponent(id)}/${sub === "approve" ? "approve" : "reject"}`, { method: "POST", body: JSON.stringify({ reason: reason ?? undefined }) }, bearer);
|
|
246
|
+
if (r.status >= 200 && r.status < 300) {
|
|
247
|
+
log(`✔ ${sub}d ${id}${r.body?.resumeSignal ? ` — agent signalled (${r.body.resumeSignal.ok ? "ok" : r.body.resumeSignal.error})` : ""}`);
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
log(`✘ ${sub} failed (${r.status}): ${JSON.stringify(r.body)}`);
|
|
251
|
+
return 1;
|
|
252
|
+
}
|
|
253
|
+
const r = bearer ? await api("/api/decisions/cards", {}, bearer) : probe;
|
|
254
|
+
const cards = r.body?.cards ?? [];
|
|
255
|
+
const pending = cards.filter((c) => c.isAgentRequest && c.status !== "sealed");
|
|
256
|
+
if (!pending.length) {
|
|
257
|
+
log("No pending agent approvals.");
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
log(`Pending agent approvals (${pending.length}):`);
|
|
261
|
+
for (const c of pending) {
|
|
262
|
+
const ar = c.agentRequest ?? {};
|
|
263
|
+
log(` ${c.id} ${ar.action ?? "?"}${ar.amount != null ? ` $${Number(ar.amount).toLocaleString()}` : ""} — ${c.title ?? ""}`);
|
|
264
|
+
}
|
|
265
|
+
log(`\nApprove: decidio approvals approve <id> [reason] · Reject: decidio approvals reject <id> [reason]`);
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
function cmdDev(target, port) {
|
|
269
|
+
// Signal-only relay: receive Decidio's webhook here (a stable URL) and forward it to
|
|
270
|
+
// your local agent's resume listener. Useful when the agent isn't directly reachable.
|
|
271
|
+
log(`decidio dev relay on http://localhost:${port}/decidio/resume → forwarding to ${target}`);
|
|
272
|
+
createServer((req, res) => {
|
|
273
|
+
if (req.method !== "POST") {
|
|
274
|
+
res.statusCode = 405;
|
|
275
|
+
return res.end();
|
|
276
|
+
}
|
|
277
|
+
let raw = "";
|
|
278
|
+
req.on("data", (c) => (raw += c));
|
|
279
|
+
req.on("end", async () => {
|
|
280
|
+
try {
|
|
281
|
+
const fwd = await fetch(target, { method: "POST", headers: { "content-type": "application/json", ...(req.headers["x-decidio-signature"] ? { "x-decidio-signature": String(req.headers["x-decidio-signature"]) } : {}) }, body: raw });
|
|
282
|
+
const body = JSON.parse(raw || "{}");
|
|
283
|
+
log(`→ relayed decision ${body.decisionId} (${body.verdict}) → ${fwd.status}`);
|
|
284
|
+
res.statusCode = fwd.status;
|
|
285
|
+
res.end(await fwd.text());
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
res.statusCode = 502;
|
|
289
|
+
res.end(JSON.stringify({ error: e?.message }));
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
}).listen(port);
|
|
293
|
+
return 0;
|
|
294
|
+
}
|
|
295
|
+
async function main(argv) {
|
|
296
|
+
const [cmd, a, b, ...rest] = argv.slice(2);
|
|
297
|
+
switch (cmd) {
|
|
298
|
+
case "init": return cmdInit(a ?? process.env.DECIDIO_AGENT_ID ?? "salesops-agent");
|
|
299
|
+
case "doctor": return cmdDoctor();
|
|
300
|
+
case "approvals": return cmdApprovals(a, b, rest.join(" ") || undefined);
|
|
301
|
+
case "dev": {
|
|
302
|
+
const args = argv.slice(3);
|
|
303
|
+
const flag = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
|
304
|
+
const target = flag("--target") || process.env.DECIDIO_AGENT_RESUME_URL || "http://localhost:4100/decidio/resume";
|
|
305
|
+
const port = Number(flag("--port") || process.env.DECIDIO_DEV_PORT || 4099);
|
|
306
|
+
return cmdDev(target, port);
|
|
307
|
+
}
|
|
308
|
+
default:
|
|
309
|
+
log("decidio <init|doctor|approvals|dev>\n init [agentId] · doctor · approvals [approve|reject <id> [reason]] · dev [--target URL] [--port N]");
|
|
310
|
+
return cmd ? 1 : 0;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
main(process.argv).then((code) => { if (code !== 0)
|
|
314
|
+
process.exitCode = code; }).catch((e) => { console.error(e); process.exit(1); });
|
package/dist/guard.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type ApprovalContext } from "./index.js";
|
|
2
|
+
import { type ResumeController, type ResumeOutcome } from "./resume.js";
|
|
3
|
+
export interface GuardFacadeConfig {
|
|
4
|
+
apiUrl: string;
|
|
5
|
+
agentId: string;
|
|
6
|
+
apiToken?: string;
|
|
7
|
+
sourceSystem?: string;
|
|
8
|
+
resumeUrl?: string;
|
|
9
|
+
webhookSecret?: string;
|
|
10
|
+
pendingDir?: string;
|
|
11
|
+
/** Local signing key (DECIDIO_AGENT_KEY) + public did (DECIDIO_AGENT_DID), from `init`.
|
|
12
|
+
* When present the gate authorizes against a PROVEN identity, not a claimed label. */
|
|
13
|
+
agentKey?: string;
|
|
14
|
+
agentDid?: string;
|
|
15
|
+
/** The workspace this agent acts in (DECIDIO_WORKSPACE_ID). Bound into the signed proof (D-B3.6)
|
|
16
|
+
* so a captured proof can't be replayed in another tenant. */
|
|
17
|
+
workspaceId?: string;
|
|
18
|
+
/** Base URL of the Decidio app (DECIDIO_APP_URL) — used to deep-link a routed decision. */
|
|
19
|
+
appUrl?: string;
|
|
20
|
+
fetchImpl?: typeof fetch;
|
|
21
|
+
}
|
|
22
|
+
export interface ProtectOptions {
|
|
23
|
+
/** null/"generic" → the core gate; engine adapters (langgraph/openai/temporal/inngest/mcp)
|
|
24
|
+
* live in @decidio/sdk/adapters and take the engine handle. */
|
|
25
|
+
adapter?: "generic" | "langgraph" | "openai" | "temporal" | "inngest" | "mcp";
|
|
26
|
+
/** "durable" (default) suspends for async approval; "blocking" polls (short approvals only). */
|
|
27
|
+
mode?: "durable" | "blocking";
|
|
28
|
+
}
|
|
29
|
+
export declare class Guard {
|
|
30
|
+
config: GuardFacadeConfig;
|
|
31
|
+
resume?: ResumeController;
|
|
32
|
+
constructor(config: GuardFacadeConfig, resume?: ResumeController);
|
|
33
|
+
static fromEnv(): Guard;
|
|
34
|
+
/** Mount on a node/Express/Fastify route: `app.post("/decidio/resume", guard.resumeHandler())`. */
|
|
35
|
+
resumeHandler(): import("http").RequestListener;
|
|
36
|
+
/** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */
|
|
37
|
+
resumeFetchHandler(): (req: Request) => Promise<Response>;
|
|
38
|
+
/** Durable resume with NOTHING to host: a background watcher polls Decidio outward and finishes
|
|
39
|
+
* any parked approval — so a suspended action survives a process restart. One line at startup.
|
|
40
|
+
* Production may swap this for the webhook (resumeUrl); the suspend/resume shape is identical. */
|
|
41
|
+
worker(opts?: {
|
|
42
|
+
intervalMs?: number;
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
onResolved?: (o: ResumeOutcome) => void;
|
|
45
|
+
}): {
|
|
46
|
+
stop: () => void;
|
|
47
|
+
};
|
|
48
|
+
private ensureResume;
|
|
49
|
+
protect<A extends any[], R>(fn: (...a: A) => Promise<R>, describe: (...a: A) => ApprovalContext, opts?: ProtectOptions): (...a: A) => Promise<R>;
|
|
50
|
+
}
|
|
51
|
+
/** Preconfigured singleton — `import { guard } from "@decidio/sdk"`. */
|
|
52
|
+
export declare const guard: Guard;
|
package/dist/guard.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The Guard facade — `guard.protect(fn, describe)`, the one agnostic line (TS mirror
|
|
2
|
+
// of decidio-guard Python). A thin layer over the verified primitives: durable mode
|
|
3
|
+
// builds a resume controller and suspends; blocking mode polls (opt-in, loud). The
|
|
4
|
+
// agent executes its own action; Decidio gates + records + signals — never executes.
|
|
5
|
+
import { withApproval } from "./index.js";
|
|
6
|
+
import { createDecidioResume, FilePendingStore } from "./resume.js";
|
|
7
|
+
export class Guard {
|
|
8
|
+
config;
|
|
9
|
+
resume;
|
|
10
|
+
constructor(config, resume) {
|
|
11
|
+
this.config = config;
|
|
12
|
+
this.resume = resume;
|
|
13
|
+
}
|
|
14
|
+
static fromEnv() {
|
|
15
|
+
return new Guard({
|
|
16
|
+
apiUrl: process.env.DECIDIO_API_URL ?? "http://localhost:4000",
|
|
17
|
+
agentId: process.env.DECIDIO_AGENT_ID ?? "agent",
|
|
18
|
+
apiToken: process.env.DECIDIO_API_TOKEN,
|
|
19
|
+
sourceSystem: process.env.DECIDIO_SOURCE_SYSTEM,
|
|
20
|
+
resumeUrl: process.env.DECIDIO_RESUME_URL,
|
|
21
|
+
webhookSecret: process.env.DECIDIO_WEBHOOK_SECRET,
|
|
22
|
+
pendingDir: process.env.DECIDIO_PENDING_DIR,
|
|
23
|
+
agentKey: process.env.DECIDIO_AGENT_KEY,
|
|
24
|
+
agentDid: process.env.DECIDIO_AGENT_DID,
|
|
25
|
+
workspaceId: process.env.DECIDIO_WORKSPACE_ID,
|
|
26
|
+
appUrl: process.env.DECIDIO_APP_URL,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** Mount on a node/Express/Fastify route: `app.post("/decidio/resume", guard.resumeHandler())`. */
|
|
30
|
+
resumeHandler() { return this.ensureResume().webhookHandler(); }
|
|
31
|
+
/** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */
|
|
32
|
+
resumeFetchHandler() { return this.ensureResume().fetchHandler(); }
|
|
33
|
+
/** Durable resume with NOTHING to host: a background watcher polls Decidio outward and finishes
|
|
34
|
+
* any parked approval — so a suspended action survives a process restart. One line at startup.
|
|
35
|
+
* Production may swap this for the webhook (resumeUrl); the suspend/resume shape is identical. */
|
|
36
|
+
worker(opts) {
|
|
37
|
+
return this.ensureResume().worker(opts);
|
|
38
|
+
}
|
|
39
|
+
ensureResume() {
|
|
40
|
+
if (!this.resume) {
|
|
41
|
+
const c = this.config;
|
|
42
|
+
this.resume = createDecidioResume({
|
|
43
|
+
apiUrl: c.apiUrl, apiToken: c.apiToken, webhookSecret: c.webhookSecret,
|
|
44
|
+
resumeUrl: c.resumeUrl, store: new FilePendingStore(c.pendingDir ?? ".decidio-pending"),
|
|
45
|
+
// Carry the signing identity so the durable/cold confirm on resume is signed too (incl. the
|
|
46
|
+
// workspace binding — D-B3.6 — so a cold-resume confirm proof is tenant-scoped as well).
|
|
47
|
+
agentId: c.agentId, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId,
|
|
48
|
+
fetchImpl: c.fetchImpl,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return this.resume;
|
|
52
|
+
}
|
|
53
|
+
protect(fn, describe, opts) {
|
|
54
|
+
if (opts?.adapter && opts.adapter !== "generic") {
|
|
55
|
+
// Engine adapters are bound explicitly via @decidio/sdk/adapters (they need the
|
|
56
|
+
// engine handle at call time). Surfaced as a clear error until wired in this build.
|
|
57
|
+
throw new Error(`[decidio] adapter "${opts.adapter}" must be bound via @decidio/sdk/adapters/${opts.adapter} (it needs the engine handle).`);
|
|
58
|
+
}
|
|
59
|
+
const c = this.config;
|
|
60
|
+
const base = {
|
|
61
|
+
apiUrl: c.apiUrl, agentId: c.agentId, apiToken: c.apiToken,
|
|
62
|
+
sourceSystem: c.sourceSystem, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId, appUrl: c.appUrl, fetchImpl: c.fetchImpl,
|
|
63
|
+
};
|
|
64
|
+
if ((opts?.mode ?? "durable") === "blocking") {
|
|
65
|
+
console.warn("[decidio] WARNING: blocking wait — only safe for short, watched approvals. " +
|
|
66
|
+
"Omit mode:\"blocking\" for durable async resume on real (minutes-to-days) approvals.");
|
|
67
|
+
// No resumeUrl on the blocking path — the server must not fire a webhook to an
|
|
68
|
+
// endpoint a blocking agent isn't serving; the poll is the only resume channel.
|
|
69
|
+
return withApproval(fn, describe, base);
|
|
70
|
+
}
|
|
71
|
+
// durable: refuse-don't-downgrade — always wire a resume transport, never silently block.
|
|
72
|
+
return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume: this.ensureResume() });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Preconfigured singleton — `import { guard } from "@decidio/sdk"`. */
|
|
76
|
+
export const guard = Guard.fromEnv();
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ResumeController } from "./resume.js";
|
|
2
|
+
export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume } from "./resume.js";
|
|
3
|
+
export type { PendingAction, PendingStore, ResumeController, ResumeOutcome } from "./resume.js";
|
|
4
|
+
export { guard, Guard } from "./guard.js";
|
|
5
|
+
export type { GuardFacadeConfig, ProtectOptions } from "./guard.js";
|
|
6
|
+
export { generateKeypair, signBoundProof, signConfirm, canonicalize } from "./signing.js";
|
|
7
|
+
export type { RequesterIdentity } from "./signing.js";
|
|
8
|
+
export interface GuardConfig {
|
|
9
|
+
/** Base URL of the Decidio gate, e.g. "https://api.decidio.io". */
|
|
10
|
+
apiUrl: string;
|
|
11
|
+
/** The agent identity registered with Decidio (onboarding wizard), e.g. "salesforce/SalesforceBot". */
|
|
12
|
+
agentId: string;
|
|
13
|
+
/** Bearer token for the REST floor (a trusted, already-authenticated internal
|
|
14
|
+
* caller). Lives ONLY in the agent host's environment — never in client code. */
|
|
15
|
+
apiToken?: string;
|
|
16
|
+
/** Source system label sealed onto the receipt (e.g. "salesforce"). */
|
|
17
|
+
sourceSystem?: string;
|
|
18
|
+
/** The agent's PRIVATE signing key (PKCS8 base64), generated locally by `init` and held
|
|
19
|
+
* ONLY in the agent host's env as DECIDIO_AGENT_KEY. When set (with `agentDid`), the SDK
|
|
20
|
+
* signs a request-bound identity proof on each authorize so the floor PROVES the requester
|
|
21
|
+
* instead of trusting the label — required for a registered agent to auto-approve. */
|
|
22
|
+
agentKey?: string;
|
|
23
|
+
/** The agent's PUBLIC did:key (the one registered with Decidio). Pairs with `agentKey`. */
|
|
24
|
+
agentDid?: string;
|
|
25
|
+
/** The workspace this agent acts in (DECIDIO_WORKSPACE_ID, from the owner's agent-token mint).
|
|
26
|
+
* Bound into the signed proof (D-B3.6) so a captured proof can't be replayed in another tenant.
|
|
27
|
+
* For a tester's per-workspace agent token this is the tester's workspace id. */
|
|
28
|
+
workspaceId?: string;
|
|
29
|
+
/** Poll cadence + ceiling while a routed action awaits human approval. Used ONLY
|
|
30
|
+
* by the blocking transport (no `resume` configured). */
|
|
31
|
+
pollIntervalMs?: number;
|
|
32
|
+
pollTimeoutMs?: number;
|
|
33
|
+
/** Optional async notifier so a long human wait surfaces in your logs/UI. */
|
|
34
|
+
onPending?: (info: {
|
|
35
|
+
decisionId: string;
|
|
36
|
+
receiptId: string;
|
|
37
|
+
url: string | null;
|
|
38
|
+
}) => void;
|
|
39
|
+
/** Base URL of the Decidio app (DECIDIO_APP_URL). When set, a routed action can say WHERE to
|
|
40
|
+
* approve it — `<appUrl>/d/<decisionId>` opens that decision directly, on web or on the phone
|
|
41
|
+
* build. This link used to be built only inside our own example, so a developer who pasted the
|
|
42
|
+
* env block set DECIDIO_APP_URL and nothing ever read it. */
|
|
43
|
+
appUrl?: string;
|
|
44
|
+
/** Durable async resume. When set, a routed action SUSPENDS (parks its args in the
|
|
45
|
+
* store, throws DecidioSuspendedError) instead of blocking — the process may exit
|
|
46
|
+
* and resume later via the controller's webhook handler or poll worker. This is
|
|
47
|
+
* the production transport for real (minutes-to-days) human approvals. */
|
|
48
|
+
resume?: ResumeController;
|
|
49
|
+
/** Callback URL Decidio POSTs the verdict to on approval (webhook transport).
|
|
50
|
+
* Sent on /agent/authorize so the decision carries it. Falls back to
|
|
51
|
+
* resume.resumeUrl. Omit when using the poll worker. */
|
|
52
|
+
resumeUrl?: string;
|
|
53
|
+
fetchImpl?: typeof fetch;
|
|
54
|
+
}
|
|
55
|
+
export interface ApprovalContext {
|
|
56
|
+
action: string;
|
|
57
|
+
amount: number;
|
|
58
|
+
scope?: string;
|
|
59
|
+
/** Optional human-facing DETAILS of the action (e.g. { Account, "Lead Source", Stage }) shown to
|
|
60
|
+
* the approver as "what you're approving". Display-only — the policy decides on `amount`/`scope`,
|
|
61
|
+
* never these. Residual-safe tokenized server-side before persistence. */
|
|
62
|
+
context?: Record<string, string | number>;
|
|
63
|
+
}
|
|
64
|
+
export declare class DecidioBlockedError extends Error {
|
|
65
|
+
reason: string;
|
|
66
|
+
decisionId: string;
|
|
67
|
+
constructor(reason: string, decisionId: string);
|
|
68
|
+
}
|
|
69
|
+
export declare class DecidioRejectedError extends Error {
|
|
70
|
+
reason: string;
|
|
71
|
+
decisionId: string;
|
|
72
|
+
constructor(reason: string, decisionId: string);
|
|
73
|
+
}
|
|
74
|
+
export declare class DecidioTimeoutError extends Error {
|
|
75
|
+
decisionId: string;
|
|
76
|
+
constructor(decisionId: string);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Wrap an async action behind the Decidio gate. `describe` extracts the approval
|
|
80
|
+
* context (action, amount, scope) from the call arguments. Returns a function with
|
|
81
|
+
* the SAME signature as `fn` — drop-in.
|
|
82
|
+
*/
|
|
83
|
+
/** `<appUrl>/d/<decisionId>` — the deep link that opens this decision directly, on the web app or
|
|
84
|
+
* the phone build. Null when DECIDIO_APP_URL is not configured, never a half-built URL. */
|
|
85
|
+
export declare function decisionUrl(appUrl: string | undefined, decisionId: string): string | null;
|
|
86
|
+
export declare function withApproval<A extends any[], R>(fn: (...args: A) => Promise<R>, describe: (...args: A) => ApprovalContext, config: GuardConfig): (...args: A) => Promise<R>;
|