@pushary/server 1.4.0 → 2.0.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/README.md +81 -4
- package/dist/adapters.cjs +353 -51
- package/dist/adapters.cjs.map +1 -1
- package/dist/adapters.d.cts +162 -8
- package/dist/adapters.d.ts +162 -8
- package/dist/adapters.js +167 -4
- package/dist/adapters.js.map +1 -1
- package/dist/{chunk-22FUZ6F5.js → chunk-2B6ABKTM.js} +181 -49
- package/dist/chunk-2B6ABKTM.js.map +1 -0
- package/dist/index.cjs +180 -48
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/{util-Ihfh4f82.d.cts → util-BvCioL1a.d.cts} +240 -3
- package/dist/{util-Ihfh4f82.d.ts → util-BvCioL1a.d.ts} +240 -3
- package/package.json +1 -1
- package/dist/chunk-22FUZ6F5.js.map +0 -1
package/README.md
CHANGED
|
@@ -140,8 +140,8 @@ const { approved, value, status } = await pushary.decisions.ask({
|
|
|
140
140
|
if (approved) await issueRefund()
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
-
`ask()` creates
|
|
144
|
-
durably until the human answers or the deadline passes (default 55s
|
|
143
|
+
`ask()` creates a fresh decision per call and polls
|
|
144
|
+
durably until the human answers or the deadline passes (default 55s for a positive local wait budget).
|
|
145
145
|
`approved` is true only when the person actually said yes, so a declined, expired, or
|
|
146
146
|
unanswered decision safely blocks the action.
|
|
147
147
|
|
|
@@ -195,8 +195,8 @@ import {
|
|
|
195
195
|
const idempotencyKey = deterministicKey(['run-abc', 'step-3', 'user-123'])
|
|
196
196
|
|
|
197
197
|
// isApproved(decision: { status, type?, value }): boolean
|
|
198
|
-
//
|
|
199
|
-
//
|
|
198
|
+
// For confirm: true only for an affirmative answer. For select/input: true
|
|
199
|
+
// means answered, not permission to act. Check the actual value for those types.
|
|
200
200
|
isApproved({ status: 'answered', type: 'confirm', value: 'yes' }) // true
|
|
201
201
|
isApproved({ status: 'answered', type: 'confirm', value: 'no' }) // false
|
|
202
202
|
isApproved({ status: 'pending', type: 'confirm', value: null }) // false
|
|
@@ -252,14 +252,77 @@ export const approve = async (toolName: string, callId: string, input: unknown)
|
|
|
252
252
|
sessionId: currentRunId,
|
|
253
253
|
question: renderApprovalQuestion(toolName, input),
|
|
254
254
|
externalId: kernel.requireExternalId(currentUserId),
|
|
255
|
+
// optional: lets a rule decide on the arguments, not only the action name
|
|
256
|
+
input,
|
|
255
257
|
})
|
|
256
258
|
return decision // { approved: true } | { approved: false, reason }
|
|
257
259
|
}
|
|
258
260
|
```
|
|
259
261
|
|
|
262
|
+
The gate asks your policy before it asks a person. A rule that names the action
|
|
263
|
+
resolves it with nobody paged: `allow` returns approved and opens no decision, `deny`
|
|
264
|
+
returns a reason the model reads, and anything no rule names still asks. A site with
|
|
265
|
+
no rules behaves exactly as before, because a rule has to name the action and `*` is
|
|
266
|
+
never selected. Pass `policy: false` to restore the always-ask gate.
|
|
267
|
+
|
|
260
268
|
The label passed to `createAdapterKernel` is what appears in the error when a key or
|
|
261
269
|
an end-user is missing, so it names your helpers rather than ours.
|
|
262
270
|
|
|
271
|
+
### One protected action: `protect()`
|
|
272
|
+
|
|
273
|
+
`createGate` answers a yes/no. `protect()` is that plus running the thing, so the
|
|
274
|
+
authorize, escalate and execute steps stop being three pieces of plumbing in your
|
|
275
|
+
business code.
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
const protect = kernel.protect({ apiKey: process.env.PUSHARY_API_KEY! })
|
|
279
|
+
|
|
280
|
+
const outcome = await protect({
|
|
281
|
+
action: 'refund.create',
|
|
282
|
+
target: 'order_4471',
|
|
283
|
+
externalId: customer.id,
|
|
284
|
+
facts: { amount: 4800, currency: 'EUR' }, // what a rule may decide on
|
|
285
|
+
callId: attemptId,
|
|
286
|
+
runId: sessionId,
|
|
287
|
+
run: () => stripe.refunds.create({ charge, amount: 4800 }),
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
outcome.ok ? outcome.result : outcome.reason
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
A rule that names `refund.create` resolves it with nobody paged. Anything no rule
|
|
294
|
+
names asks a person. `run` is called only after the action is authorized, and an
|
|
295
|
+
error it throws is not caught: reporting it as `ok: false` would be
|
|
296
|
+
indistinguishable from a refusal.
|
|
297
|
+
|
|
298
|
+
`protect()` is **at most once**. The decision is idempotent — a replay of the same
|
|
299
|
+
`runId` + `callId` + action lands on the same approval instead of asking twice — and
|
|
300
|
+
the approval is then spent against a durable permit before `run` is called. A retry, a
|
|
301
|
+
concurrent worker and a resumed run all reach the same permit and exactly one of them
|
|
302
|
+
proceeds; the rest come back `ok: false` with a reason worded to stop the model rather
|
|
303
|
+
than invite another attempt.
|
|
304
|
+
|
|
305
|
+
The permit is bound to the exact subject that was authorized — the action, its target,
|
|
306
|
+
the actor, the environment, the end-user and every fact you sent — so changing the
|
|
307
|
+
amount between the approval and the execution leaves you with no permit for the action
|
|
308
|
+
you now want to run. `protect()` then records `succeeded` or `failed` against it; a
|
|
309
|
+
process that dies mid-action leaves the permit unresolved, which is visible and
|
|
310
|
+
reconcilable, rather than an action that ran twice, which is not.
|
|
311
|
+
|
|
312
|
+
The two calls underneath are public if you own your own execution path:
|
|
313
|
+
`pushary.consumeAuthorization(binding)` and `pushary.recordExecution({ permitId,
|
|
314
|
+
outcome, summary })`. A refusal carries a `refusal` you can branch on:
|
|
315
|
+
`not_authorized`, `action_mismatch`, `expired` or `already_consumed`.
|
|
316
|
+
|
|
317
|
+
Python is the same two-step surface: `protect = kernel.create_protect()`, then
|
|
318
|
+
`protect(action, run, external_id=..., call_id=..., run_id=...)`. Building it once is
|
|
319
|
+
what makes a missing key raise where the protector is defined rather than on the
|
|
320
|
+
first action.
|
|
321
|
+
|
|
322
|
+
A runnable end-to-end version, against the sandbox and with no phone involved, is
|
|
323
|
+
in [`examples/protected-action.ts`](./examples/protected-action.ts).
|
|
324
|
+
|
|
325
|
+
|
|
263
326
|
Also exported: `describeAnswer` (turn an outcome into an instruction the model cannot
|
|
264
327
|
misread), `resolvePusharyCallback` (verify and parse a webhook in one call),
|
|
265
328
|
`idempotencyKeyFor`, and `isAffirmative`.
|
|
@@ -274,6 +337,10 @@ ask_human = kernel.ask_human
|
|
|
274
337
|
gate = kernel.create_gate()
|
|
275
338
|
```
|
|
276
339
|
|
|
340
|
+
`create_gate(policy=False)` is the same escape hatch. Neither language evaluates a
|
|
341
|
+
rule locally; the verdict is the server's, so the two cannot disagree about what a
|
|
342
|
+
policy means.
|
|
343
|
+
|
|
277
344
|
Both give you what the shipped adapters have: idempotency keyed on the call so a
|
|
278
345
|
replay never asks twice, a denial reason the model can read, and silence treated as a
|
|
279
346
|
no.
|
|
@@ -327,3 +394,13 @@ import {
|
|
|
327
394
|
## License
|
|
328
395
|
|
|
329
396
|
MIT. See `LICENSE`.
|
|
397
|
+
|
|
398
|
+
## Retries and deadlines
|
|
399
|
+
|
|
400
|
+
For retries of one business operation, pass `idempotencyKey: deterministicKey([runId, stepId, userId])`. Independent asks get fresh keys, even with identical questions. Shared adapter durable creates require this key; missing keys fail before a request is sent. Keep a run ID unique per operation and stable across its retries.
|
|
401
|
+
|
|
402
|
+
A positive `timeoutMs` covers creation and polling. `timeoutMs: 0` retains create-only behavior. A timeout during creation throws; retry with the same explicit key to recover the decision. A timeout during polling returns the known pending decision ID for later `get()` or a webhook. `signal` cancels the local wait and throws; it does not cancel the remote decision. Other HTTP calls use `requestTimeoutMs` (65 seconds by default).
|
|
403
|
+
|
|
404
|
+
### Upgrading to 2.0
|
|
405
|
+
|
|
406
|
+
Upgrade the framework adapter alongside this SDK. Durable helpers now require an explicit `idempotencyKey` tied to the run and step. The 0.3 LangGraph, Mastra and OpenAI adapters support this contract. Earlier adapters must keep the 1.x SDK until migrated. Independent blocking calls no longer deduplicate by question text.
|
package/dist/adapters.cjs
CHANGED
|
@@ -20,21 +20,29 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/adapters.ts
|
|
21
21
|
var adapters_exports = {};
|
|
22
22
|
__export(adapters_exports, {
|
|
23
|
+
DENIED_BY_POLICY: () => DENIED_BY_POLICY,
|
|
23
24
|
DENIED_REFUSED: () => DENIED_REFUSED,
|
|
24
25
|
DENIED_UNANSWERED: () => DENIED_UNANSWERED,
|
|
25
26
|
SIGNATURE_HEADER: () => SIGNATURE_HEADER,
|
|
27
|
+
UNBOUND_APPROVAL: () => UNBOUND_APPROVAL,
|
|
28
|
+
UNCONFIRMED_PERMIT: () => UNCONFIRMED_PERMIT,
|
|
26
29
|
createAdapterKernel: () => createAdapterKernel,
|
|
30
|
+
defaultProtectQuestion: () => defaultProtectQuestion,
|
|
31
|
+
deniedByPolicy: () => deniedByPolicy,
|
|
32
|
+
deriveParameters: () => deriveParameters,
|
|
27
33
|
describeAnswer: () => describeAnswer,
|
|
28
34
|
deterministicKey: () => deterministicKey,
|
|
29
35
|
idempotencyKeyFor: () => idempotencyKeyFor,
|
|
30
36
|
isAffirmative: () => isAffirmative,
|
|
31
37
|
isApproved: () => isApproved,
|
|
32
38
|
parseDecisionCallback: () => parseDecisionCallback,
|
|
39
|
+
refusedPermit: () => refusedPermit,
|
|
33
40
|
renderApprovalQuestion: () => renderApprovalQuestion,
|
|
34
41
|
resolvePusharyCallback: () => resolvePusharyCallback,
|
|
35
42
|
verifyWebhookSignature: () => verifyWebhookSignature
|
|
36
43
|
});
|
|
37
44
|
module.exports = __toCommonJS(adapters_exports);
|
|
45
|
+
var import_node_crypto = require("crypto");
|
|
38
46
|
|
|
39
47
|
// src/error.ts
|
|
40
48
|
var PusharyApiError = class _PusharyApiError extends Error {
|
|
@@ -85,11 +93,12 @@ var handleResponse = async (response) => {
|
|
|
85
93
|
}
|
|
86
94
|
return response.json();
|
|
87
95
|
};
|
|
88
|
-
var createRequest = (ctx) => async (method, path, body) => {
|
|
96
|
+
var createRequest = (ctx) => async (method, path, body, options) => {
|
|
89
97
|
const isGet = method === "GET";
|
|
90
98
|
const url = isGet && body ? buildUrl(ctx.baseUrl, path, body) : `${ctx.baseUrl}${path}`;
|
|
91
99
|
const response = await fetch(url, {
|
|
92
100
|
method,
|
|
101
|
+
signal: options?.signal ?? AbortSignal.timeout(ctx.timeoutMs ?? 65e3),
|
|
93
102
|
headers: ctx.headers,
|
|
94
103
|
body: !isGet && body ? JSON.stringify(body) : void 0
|
|
95
104
|
});
|
|
@@ -172,52 +181,80 @@ var DEFAULT_ASK_TIMEOUT_MS = 55e3;
|
|
|
172
181
|
var createDecisionsResource = (request) => {
|
|
173
182
|
const ask = async (data) => {
|
|
174
183
|
const idempotencyKey = data.idempotencyKey ?? (0, import_crypto2.randomUUID)();
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
184
|
+
const timeoutMs = Math.max(0, data.timeoutMs ?? DEFAULT_ASK_TIMEOUT_MS);
|
|
185
|
+
const deadline = Date.now() + timeoutMs;
|
|
186
|
+
const controller = new AbortController();
|
|
187
|
+
const abort = () => controller.abort(data.signal?.reason);
|
|
188
|
+
if (data.signal?.aborted) abort();
|
|
189
|
+
else data.signal?.addEventListener("abort", abort, { once: true });
|
|
190
|
+
const timer = setTimeout(() => controller.abort(new DOMException("Pushary ask timed out", "TimeoutError")), timeoutMs || 65e3);
|
|
191
|
+
try {
|
|
192
|
+
const created = await request("POST", "/decisions", {
|
|
193
|
+
question: data.question,
|
|
194
|
+
type: data.type,
|
|
195
|
+
options: data.options,
|
|
196
|
+
externalId: data.externalId,
|
|
197
|
+
email: data.email,
|
|
198
|
+
agentName: data.agentName,
|
|
199
|
+
context: data.context,
|
|
200
|
+
placeholder: data.placeholder,
|
|
201
|
+
toolName: data.toolName,
|
|
202
|
+
toolTarget: data.toolTarget,
|
|
203
|
+
actor: data.actor,
|
|
204
|
+
environment: data.environment,
|
|
205
|
+
parameters: data.parameters,
|
|
206
|
+
// Its changes name parameters rather than carrying values, so what the
|
|
207
|
+
// person sees is read from the same numbers a rule compares.
|
|
208
|
+
presentation: data.presentation,
|
|
209
|
+
approvalUrl: data.approvalUrl,
|
|
210
|
+
expiresInSeconds: data.expiresInSeconds,
|
|
211
|
+
poweredBy: data.poweredBy,
|
|
212
|
+
callbackUrl: data.callbackUrl,
|
|
213
|
+
requireReachable: data.requireReachable,
|
|
214
|
+
// Read only by a key issued on your workspace's sandbox site, where the
|
|
215
|
+
// simulator answers instead of a person. Ignored in production.
|
|
216
|
+
sandboxOutcome: data.sandboxOutcome,
|
|
217
|
+
idempotencyKey,
|
|
218
|
+
wait: false
|
|
219
|
+
}, { signal: controller.signal });
|
|
220
|
+
const type = created.type ?? data.type ?? "confirm";
|
|
221
|
+
let status = created.status;
|
|
222
|
+
let answered = created.answered;
|
|
223
|
+
let value = created.value ?? null;
|
|
224
|
+
while (status === "pending" && Date.now() < deadline) {
|
|
225
|
+
const waitSeconds = Math.max(
|
|
226
|
+
1,
|
|
227
|
+
Math.min(POLL_WINDOW_SECONDS, Math.ceil((deadline - Date.now()) / 1e3))
|
|
228
|
+
);
|
|
229
|
+
try {
|
|
230
|
+
const polled = await request("GET", `/decisions/${created.decisionId}`, {
|
|
231
|
+
wait: waitSeconds
|
|
232
|
+
}, { signal: controller.signal });
|
|
233
|
+
status = polled.status;
|
|
234
|
+
answered = polled.answered;
|
|
235
|
+
value = polled.value;
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (controller.signal.aborted && !data.signal?.aborted) break;
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
decisionId: created.decisionId,
|
|
243
|
+
status,
|
|
244
|
+
answered,
|
|
245
|
+
value,
|
|
246
|
+
type,
|
|
247
|
+
approved: isApproved({ status, type, value }),
|
|
248
|
+
// Surfaced from the create response so a caller can tell an unanswered
|
|
249
|
+
// decision that reached nobody (reachable:false) apart from a real decline.
|
|
250
|
+
reachable: created.reachable,
|
|
251
|
+
reachableChannels: created.reachableChannels,
|
|
252
|
+
deviceCount: created.deviceCount
|
|
253
|
+
};
|
|
254
|
+
} finally {
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
data.signal?.removeEventListener("abort", abort);
|
|
207
257
|
}
|
|
208
|
-
return {
|
|
209
|
-
decisionId: created.decisionId,
|
|
210
|
-
status,
|
|
211
|
-
answered,
|
|
212
|
-
value,
|
|
213
|
-
type,
|
|
214
|
-
approved: isApproved({ status, type, value }),
|
|
215
|
-
// Surfaced from the create response so a caller can tell an unanswered
|
|
216
|
-
// decision that reached nobody (reachable:false) apart from a real decline.
|
|
217
|
-
reachable: created.reachable,
|
|
218
|
-
reachableChannels: created.reachableChannels,
|
|
219
|
-
deviceCount: created.deviceCount
|
|
220
|
-
};
|
|
221
258
|
};
|
|
222
259
|
return Object.freeze({
|
|
223
260
|
create: (data) => request("POST", "/decisions", data),
|
|
@@ -237,6 +274,102 @@ var createKeysResource = (request) => Object.freeze({
|
|
|
237
274
|
revoke: (keyPrefix) => request("DELETE", `/keys/${encodeURIComponent(keyPrefix)}`)
|
|
238
275
|
});
|
|
239
276
|
|
|
277
|
+
// src/authorize.ts
|
|
278
|
+
var defaultQuestion = (toolName, toolTarget) => `Approve ${toolName}${toolTarget ? ` ${toolTarget}` : ""}?`;
|
|
279
|
+
var authorizationSubject = (input) => ({
|
|
280
|
+
toolName: input.toolName,
|
|
281
|
+
toolTarget: input.toolTarget,
|
|
282
|
+
actor: input.actor,
|
|
283
|
+
environment: input.environment,
|
|
284
|
+
parameters: input.parameters
|
|
285
|
+
});
|
|
286
|
+
var createEvaluateAuthorization = (request) => (input) => request("POST", "/authorize", {
|
|
287
|
+
...authorizationSubject(input),
|
|
288
|
+
externalId: input.externalId,
|
|
289
|
+
question: input.question,
|
|
290
|
+
agentName: input.agentName
|
|
291
|
+
});
|
|
292
|
+
var createAuthorize = (request, decisions) => {
|
|
293
|
+
const evaluate = createEvaluateAuthorization(request);
|
|
294
|
+
return async (input) => {
|
|
295
|
+
const subject = authorizationSubject(input);
|
|
296
|
+
const evaluation = await evaluate(input);
|
|
297
|
+
if (evaluation.verdict !== "requires_human") {
|
|
298
|
+
return {
|
|
299
|
+
approved: evaluation.verdict === "allow",
|
|
300
|
+
resolvedBy: "policy",
|
|
301
|
+
policy: evaluation.policy,
|
|
302
|
+
reason: evaluation.reason,
|
|
303
|
+
decisionId: null,
|
|
304
|
+
authorizationId: evaluation.authorizationId
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
const answer = await decisions.ask({
|
|
308
|
+
question: input.question ?? defaultQuestion(input.toolName, input.toolTarget),
|
|
309
|
+
type: "confirm",
|
|
310
|
+
externalId: input.externalId,
|
|
311
|
+
agentName: input.agentName,
|
|
312
|
+
...subject,
|
|
313
|
+
timeoutMs: input.timeoutMs,
|
|
314
|
+
expiresInSeconds: input.expiresInSeconds,
|
|
315
|
+
idempotencyKey: input.idempotencyKey,
|
|
316
|
+
requireReachable: input.requireReachable
|
|
317
|
+
});
|
|
318
|
+
return {
|
|
319
|
+
approved: answer.approved,
|
|
320
|
+
resolvedBy: "human",
|
|
321
|
+
policy: evaluation.policy,
|
|
322
|
+
reason: answer.approved ? "A person approved it." : answer.answered ? "A person denied it." : "Nobody answered, so this was not approved.",
|
|
323
|
+
decisionId: answer.decisionId,
|
|
324
|
+
authorizationId: null
|
|
325
|
+
};
|
|
326
|
+
};
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// src/permits.ts
|
|
330
|
+
var REFUSALS = [
|
|
331
|
+
"not_authorized",
|
|
332
|
+
"action_mismatch",
|
|
333
|
+
"expired",
|
|
334
|
+
"already_consumed"
|
|
335
|
+
];
|
|
336
|
+
var EXECUTION_STATES = ["running", "succeeded", "failed"];
|
|
337
|
+
var refusalOf = (error) => {
|
|
338
|
+
if (!(error instanceof PusharyApiError)) return null;
|
|
339
|
+
const body = error.body;
|
|
340
|
+
const refusal = body?.refusal;
|
|
341
|
+
if (typeof refusal !== "string" || !REFUSALS.includes(refusal)) return null;
|
|
342
|
+
const state = body?.executionState;
|
|
343
|
+
return {
|
|
344
|
+
consumed: false,
|
|
345
|
+
refusal,
|
|
346
|
+
reason: error.message,
|
|
347
|
+
...typeof state === "string" && EXECUTION_STATES.includes(state) ? { executionState: state } : {}
|
|
348
|
+
};
|
|
349
|
+
};
|
|
350
|
+
var createConsumeAuthorization = (request) => async (binding) => {
|
|
351
|
+
try {
|
|
352
|
+
const permit = await request("POST", "/authorizations/consume", {
|
|
353
|
+
authorizationId: binding.authorizationId,
|
|
354
|
+
toolName: binding.toolName,
|
|
355
|
+
toolTarget: binding.toolTarget,
|
|
356
|
+
actor: binding.actor,
|
|
357
|
+
environment: binding.environment,
|
|
358
|
+
externalId: binding.externalId,
|
|
359
|
+
parameters: binding.parameters
|
|
360
|
+
});
|
|
361
|
+
return { consumed: true, permit };
|
|
362
|
+
} catch (error) {
|
|
363
|
+
const refused = refusalOf(error);
|
|
364
|
+
if (refused) return refused;
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
var createRecordExecution = (request) => (receipt) => request("POST", `/authorizations/${encodeURIComponent(receipt.permitId)}/receipt`, {
|
|
369
|
+
outcome: receipt.outcome,
|
|
370
|
+
summary: receipt.summary
|
|
371
|
+
});
|
|
372
|
+
|
|
240
373
|
// src/client.ts
|
|
241
374
|
var DEFAULT_BASE_URL = "https://pushary.com/api/v1/server";
|
|
242
375
|
var validateApiKey = (apiKey) => {
|
|
@@ -251,6 +384,7 @@ var createContext = (config) => {
|
|
|
251
384
|
validateApiKey(config.apiKey);
|
|
252
385
|
return Object.freeze({
|
|
253
386
|
baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
|
|
387
|
+
timeoutMs: config.requestTimeoutMs ?? 65e3,
|
|
254
388
|
headers: Object.freeze({
|
|
255
389
|
"Content-Type": "application/json",
|
|
256
390
|
"Authorization": `Bearer ${config.apiKey}`
|
|
@@ -260,15 +394,21 @@ var createContext = (config) => {
|
|
|
260
394
|
var createPusharyServer = (config) => {
|
|
261
395
|
const ctx = createContext(config);
|
|
262
396
|
const request = createRequest(ctx);
|
|
397
|
+
const decisions = createDecisionsResource(request);
|
|
263
398
|
return Object.freeze({
|
|
264
399
|
subscribers: createSubscribersResource(request),
|
|
265
400
|
campaigns: createCampaignsResource(request),
|
|
266
401
|
templates: createTemplatesResource(request),
|
|
267
402
|
notifications: createNotificationsResource(request),
|
|
268
403
|
flows: createFlowsResource(request),
|
|
269
|
-
decisions
|
|
404
|
+
decisions,
|
|
270
405
|
keys: createKeysResource(request),
|
|
271
|
-
|
|
406
|
+
authorize: createAuthorize(request, decisions),
|
|
407
|
+
evaluateAuthorization: createEvaluateAuthorization(request),
|
|
408
|
+
consumeAuthorization: createConsumeAuthorization(request),
|
|
409
|
+
recordExecution: createRecordExecution(request),
|
|
410
|
+
enroll: (externalId) => request("POST", "/enroll", { externalId }),
|
|
411
|
+
reachability: (externalId) => request("GET", "/reachability", { externalId })
|
|
272
412
|
});
|
|
273
413
|
};
|
|
274
414
|
|
|
@@ -303,8 +443,8 @@ var parseDecisionCallback = (rawBody) => {
|
|
|
303
443
|
};
|
|
304
444
|
|
|
305
445
|
// src/adapters.ts
|
|
306
|
-
var
|
|
307
|
-
var idempotencyKeyFor = (input) =>
|
|
446
|
+
var defaultProtectQuestion = (action, target) => `Approve ${action}${target ? ` ${target}` : ""}?`;
|
|
447
|
+
var idempotencyKeyFor = (input) => input.idempotencyKey ?? (0, import_node_crypto.randomUUID)();
|
|
308
448
|
var isAffirmative = (answer) => isApproved({ status: "answered", type: "confirm", value: answer ?? null });
|
|
309
449
|
var describeAnswer = (type, result) => {
|
|
310
450
|
if (!result.answered) {
|
|
@@ -331,6 +471,36 @@ var resolvePusharyCallback = (rawBody, signature, secret) => {
|
|
|
331
471
|
var MAX_INPUT_CHARS = 300;
|
|
332
472
|
var DENIED_UNANSWERED = "No answer from the approver, so this was denied. Do not retry the same action.";
|
|
333
473
|
var DENIED_REFUSED = "The approver denied this action.";
|
|
474
|
+
var DENIED_BY_POLICY = "Denied by policy. Do not retry the same action.";
|
|
475
|
+
var UNBOUND_APPROVAL = "This was approved, but the approval could not be bound to a single execution, so it was not run. Do not retry the same action.";
|
|
476
|
+
var UNCONFIRMED_PERMIT = "This was approved, but it could not be confirmed as still unspent, so it was not run. Ask again rather than retrying this one.";
|
|
477
|
+
var REFUSED_REPLAY = "This authorization was already used. The action has run once and must not run again.";
|
|
478
|
+
var refusedPermit = (refused) => refused.refusal === "already_consumed" ? REFUSED_REPLAY : `${refused.reason} Do not retry the same action.`;
|
|
479
|
+
var deniedByPolicy = (evaluation) => evaluation.reason ? `${evaluation.reason} Do not retry the same action.` : DENIED_BY_POLICY;
|
|
480
|
+
var PARAMETERS_MAX_KEYS = 32;
|
|
481
|
+
var PARAMETER_KEY_MAX_LENGTH = 64;
|
|
482
|
+
var PARAMETER_VALUE_MAX_LENGTH = 200;
|
|
483
|
+
var deriveParameters = (input) => {
|
|
484
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) return void 0;
|
|
485
|
+
const entries = Object.entries(input);
|
|
486
|
+
if (entries.length === 0 || entries.length > PARAMETERS_MAX_KEYS) return void 0;
|
|
487
|
+
const parameters = {};
|
|
488
|
+
for (const [key, value] of entries) {
|
|
489
|
+
if (key.length === 0 || key.length > PARAMETER_KEY_MAX_LENGTH) return void 0;
|
|
490
|
+
if (typeof value === "boolean") {
|
|
491
|
+
parameters[key] = value;
|
|
492
|
+
} else if (typeof value === "number") {
|
|
493
|
+
if (!Number.isFinite(value)) return void 0;
|
|
494
|
+
parameters[key] = value;
|
|
495
|
+
} else if (typeof value === "string") {
|
|
496
|
+
if (value.length > PARAMETER_VALUE_MAX_LENGTH) return void 0;
|
|
497
|
+
parameters[key] = value;
|
|
498
|
+
} else {
|
|
499
|
+
return void 0;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return parameters;
|
|
503
|
+
};
|
|
334
504
|
var renderApprovalQuestion = (toolName, toolInput) => {
|
|
335
505
|
if (toolInput === void 0 || toolInput === null) return `Approve ${toolName}?`;
|
|
336
506
|
const summary = summarise(toolInput);
|
|
@@ -375,7 +545,48 @@ var createAdapterKernel = (helpersLabel) => {
|
|
|
375
545
|
apiKey: requireApiKey(config.apiKey),
|
|
376
546
|
...config.baseUrl ? { baseUrl: config.baseUrl } : {}
|
|
377
547
|
});
|
|
548
|
+
const usePolicy = config.policy ?? true;
|
|
549
|
+
const evaluate = async (ask, facts) => {
|
|
550
|
+
if (!usePolicy) return null;
|
|
551
|
+
try {
|
|
552
|
+
return await gateClient.evaluateAuthorization({
|
|
553
|
+
toolName: ask.toolName,
|
|
554
|
+
toolTarget: ask.toolTarget,
|
|
555
|
+
actor: ask.actor,
|
|
556
|
+
environment: ask.environment,
|
|
557
|
+
parameters: facts,
|
|
558
|
+
externalId: ask.externalId,
|
|
559
|
+
question: ask.question,
|
|
560
|
+
...config.agentName ? { agentName: config.agentName } : {}
|
|
561
|
+
});
|
|
562
|
+
} catch {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
};
|
|
378
566
|
return async (ask) => {
|
|
567
|
+
const facts = ask.parameters ?? deriveParameters(ask.input);
|
|
568
|
+
const evaluation = await evaluate(ask, facts);
|
|
569
|
+
if (evaluation?.verdict === "allow") {
|
|
570
|
+
return {
|
|
571
|
+
approved: true,
|
|
572
|
+
// Exactly the subject POST /authorize recorded, which is the whole of
|
|
573
|
+
// what it was sent.
|
|
574
|
+
...evaluation.authorizationId ? {
|
|
575
|
+
authorization: {
|
|
576
|
+
authorizationId: evaluation.authorizationId,
|
|
577
|
+
toolName: ask.toolName,
|
|
578
|
+
toolTarget: ask.toolTarget,
|
|
579
|
+
actor: ask.actor,
|
|
580
|
+
environment: ask.environment,
|
|
581
|
+
externalId: ask.externalId,
|
|
582
|
+
parameters: facts
|
|
583
|
+
}
|
|
584
|
+
} : {}
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
if (evaluation?.verdict === "deny") {
|
|
588
|
+
return { approved: false, reason: deniedByPolicy(evaluation) };
|
|
589
|
+
}
|
|
379
590
|
const result = await gateClient.decisions.ask({
|
|
380
591
|
question: ask.question,
|
|
381
592
|
type: "confirm",
|
|
@@ -384,11 +595,43 @@ var createAdapterKernel = (helpersLabel) => {
|
|
|
384
595
|
...config.expiresInSeconds ? { expiresInSeconds: config.expiresInSeconds } : {},
|
|
385
596
|
...config.timeoutMs !== void 0 ? { timeoutMs: config.timeoutMs } : {},
|
|
386
597
|
...config.requireReachable ? { requireReachable: config.requireReachable } : {},
|
|
598
|
+
// The gated tool, recorded on the decision. The gate already knows it
|
|
599
|
+
// and keyed on it below; sending it is what lets the decision be grouped
|
|
600
|
+
// and matched later instead of surviving only as prose in the question.
|
|
601
|
+
toolName: ask.toolName,
|
|
602
|
+
// The rest of the subject only when the evaluation carried it and the
|
|
603
|
+
// server accepted it. An evaluation that failed proves nothing about what
|
|
604
|
+
// this ask would accept, and this path has to stay byte-for-byte the call
|
|
605
|
+
// that worked before policy existed.
|
|
606
|
+
...evaluation ? {
|
|
607
|
+
toolTarget: ask.toolTarget,
|
|
608
|
+
actor: ask.actor,
|
|
609
|
+
environment: ask.environment,
|
|
610
|
+
parameters: facts
|
|
611
|
+
} : {},
|
|
387
612
|
// Stable across replays, so a re-run of this call resolves to the same
|
|
388
613
|
// decision instead of asking twice.
|
|
389
614
|
idempotencyKey: deterministicKey([ask.sessionId, ask.callId, ask.toolName])
|
|
390
615
|
});
|
|
391
|
-
if (result.approved)
|
|
616
|
+
if (result.approved) {
|
|
617
|
+
return {
|
|
618
|
+
approved: true,
|
|
619
|
+
authorization: {
|
|
620
|
+
authorizationId: result.decisionId,
|
|
621
|
+
toolName: ask.toolName,
|
|
622
|
+
externalId: ask.externalId,
|
|
623
|
+
// Mirrors the ask above exactly. The decision recorded the rest of
|
|
624
|
+
// the subject only when the evaluation carried it, so claiming it
|
|
625
|
+
// here regardless would describe an action the record does not hold.
|
|
626
|
+
...evaluation ? {
|
|
627
|
+
toolTarget: ask.toolTarget,
|
|
628
|
+
actor: ask.actor,
|
|
629
|
+
environment: ask.environment,
|
|
630
|
+
parameters: facts
|
|
631
|
+
} : {}
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
}
|
|
392
635
|
return { approved: false, reason: result.answered ? DENIED_REFUSED : DENIED_UNANSWERED };
|
|
393
636
|
};
|
|
394
637
|
};
|
|
@@ -400,9 +643,17 @@ var createAdapterKernel = (helpersLabel) => {
|
|
|
400
643
|
context: input.context,
|
|
401
644
|
agentName: config.agentName,
|
|
402
645
|
timeoutMs: config.timeoutMs,
|
|
646
|
+
// The node/step name doubles as the action label. The default is not
|
|
647
|
+
// sent: grouping every unnamed ask across every framework under one
|
|
648
|
+
// bucket would mine into a suggestion to auto-approve "ask-human", which
|
|
649
|
+
// spans unrelated questions.
|
|
650
|
+
toolName: input.node,
|
|
403
651
|
idempotencyKey: idempotencyKeyFor(input)
|
|
404
652
|
});
|
|
405
653
|
const createDurableDecision = async (config, input) => {
|
|
654
|
+
if (!input.idempotencyKey?.trim()) {
|
|
655
|
+
throw new Error("Pushary: durable decisions require an idempotencyKey tied to the run and step.");
|
|
656
|
+
}
|
|
406
657
|
const created = await client(config).decisions.create({
|
|
407
658
|
question: input.question,
|
|
408
659
|
type: input.type,
|
|
@@ -411,6 +662,7 @@ var createAdapterKernel = (helpersLabel) => {
|
|
|
411
662
|
context: input.context,
|
|
412
663
|
callbackUrl: input.callbackUrl,
|
|
413
664
|
agentName: config.agentName,
|
|
665
|
+
toolName: input.node,
|
|
414
666
|
idempotencyKey: idempotencyKeyFor(input),
|
|
415
667
|
wait: false
|
|
416
668
|
});
|
|
@@ -424,27 +676,77 @@ var createAdapterKernel = (helpersLabel) => {
|
|
|
424
676
|
};
|
|
425
677
|
};
|
|
426
678
|
const connect = (config, externalId) => client(config).enroll(externalId);
|
|
679
|
+
const protect = (config) => {
|
|
680
|
+
const gate = createGate(config);
|
|
681
|
+
const permitClient = client(config);
|
|
682
|
+
const report = async (permitId, outcome, summary) => {
|
|
683
|
+
try {
|
|
684
|
+
await permitClient.recordExecution({ permitId, outcome, summary });
|
|
685
|
+
} catch {
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
return async (action) => {
|
|
689
|
+
const decision = await gate({
|
|
690
|
+
toolName: action.action,
|
|
691
|
+
toolTarget: action.target,
|
|
692
|
+
callId: action.callId,
|
|
693
|
+
sessionId: action.runId,
|
|
694
|
+
question: action.question ?? defaultProtectQuestion(action.action, action.target),
|
|
695
|
+
externalId: action.externalId,
|
|
696
|
+
actor: action.actor,
|
|
697
|
+
environment: action.environment,
|
|
698
|
+
parameters: action.facts
|
|
699
|
+
});
|
|
700
|
+
if (!decision.approved) return { ok: false, reason: decision.reason };
|
|
701
|
+
if (!decision.authorization) return { ok: false, reason: UNBOUND_APPROVAL };
|
|
702
|
+
let consumed;
|
|
703
|
+
try {
|
|
704
|
+
consumed = await permitClient.consumeAuthorization(decision.authorization);
|
|
705
|
+
} catch {
|
|
706
|
+
return { ok: false, reason: UNCONFIRMED_PERMIT };
|
|
707
|
+
}
|
|
708
|
+
if (!consumed.consumed) return { ok: false, reason: refusedPermit(consumed) };
|
|
709
|
+
const permitId = consumed.permit.permitId;
|
|
710
|
+
let result;
|
|
711
|
+
try {
|
|
712
|
+
result = await action.run();
|
|
713
|
+
} catch (error) {
|
|
714
|
+
await report(permitId, "failed", error instanceof Error ? error.message : "unknown error");
|
|
715
|
+
throw error;
|
|
716
|
+
}
|
|
717
|
+
await report(permitId, "succeeded");
|
|
718
|
+
return { ok: true, result };
|
|
719
|
+
};
|
|
720
|
+
};
|
|
427
721
|
return Object.freeze({
|
|
428
722
|
client,
|
|
429
723
|
askExternalUser,
|
|
430
724
|
createDurableDecision,
|
|
431
725
|
connect,
|
|
432
726
|
createGate,
|
|
727
|
+
protect,
|
|
433
728
|
requireExternalId
|
|
434
729
|
});
|
|
435
730
|
};
|
|
436
731
|
// Annotate the CommonJS export names for ESM import in node:
|
|
437
732
|
0 && (module.exports = {
|
|
733
|
+
DENIED_BY_POLICY,
|
|
438
734
|
DENIED_REFUSED,
|
|
439
735
|
DENIED_UNANSWERED,
|
|
440
736
|
SIGNATURE_HEADER,
|
|
737
|
+
UNBOUND_APPROVAL,
|
|
738
|
+
UNCONFIRMED_PERMIT,
|
|
441
739
|
createAdapterKernel,
|
|
740
|
+
defaultProtectQuestion,
|
|
741
|
+
deniedByPolicy,
|
|
742
|
+
deriveParameters,
|
|
442
743
|
describeAnswer,
|
|
443
744
|
deterministicKey,
|
|
444
745
|
idempotencyKeyFor,
|
|
445
746
|
isAffirmative,
|
|
446
747
|
isApproved,
|
|
447
748
|
parseDecisionCallback,
|
|
749
|
+
refusedPermit,
|
|
448
750
|
renderApprovalQuestion,
|
|
449
751
|
resolvePusharyCallback,
|
|
450
752
|
verifyWebhookSignature
|