@pushary/server 1.5.0 → 2.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/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 the decision, derives a collision-safe idempotency key, and polls
144
- durably until the human answers or the deadline passes (default 55s, serverless-safe).
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
- // Fail-closed: true ONLY for an answered confirm whose value is yes. Pending,
199
- // expired, declined and free-text answers all return false.
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
@@ -226,6 +226,14 @@ app.post('/webhooks/pushary', async (req, res) => {
226
226
 
227
227
  ## Writing a framework adapter: `@pushary/server/adapters`
228
228
 
229
+ Customers answer in the native Pushary app. Confirm, select, and input questions keep their existing meanings; a choice or typed answer is data, not permission to execute a protected tool. Enroll the customer from trusted application identity before asking. Legacy web links remain an optional delivery path.
230
+
231
+ Both blocking and durable adapter calls accept `DecisionSubject` fields, including `parameters` and `presentation`, plus expiration, reachability, and placeholder settings. Durable calls require an operation-specific idempotency key; persist the returned decision ID alongside the framework's suspended run. Verify callbacks and read the authoritative decision before resuming that run.
232
+
233
+ For an explicit human gate, set `policy: false`. The gate keeps the full review subject and binds its decision key to the run, call, recipient, question, and complete JSON arguments. `decisionFingerprint(value)` exposes the same stable JSON fingerprint for deferred bindings. It rejects cycles, nonfinite numbers, and non-JSON objects instead of silently dropping action details.
234
+
235
+ **Upgrade handling:** version 2.1 changes human gate keys. Finish already-started operations on the SDK version that created them, or migrate their persisted bindings explicitly. Do not replay previously executed operations through the new key scheme. A fingerprint identifies the proposed action; the application's execution record and framework persistence still own recovery after an uncertain tool execution.
236
+
229
237
  Every official Pushary adapter (`@pushary/eve`, `@pushary/ai-sdk`,
230
238
  `@pushary/langgraph`, `@pushary/mastra`, `@pushary/openai-agents`) is a thin binding
231
239
  over one shared kernel, and that kernel is public. If you run an in-house harness, or
@@ -394,3 +402,13 @@ import {
394
402
  ## License
395
403
 
396
404
  MIT. See `LICENSE`.
405
+
406
+ ## Retries and deadlines
407
+
408
+ 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.
409
+
410
+ 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).
411
+
412
+ ### Upgrading to 2.0
413
+
414
+ 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
@@ -27,6 +27,7 @@ __export(adapters_exports, {
27
27
  UNBOUND_APPROVAL: () => UNBOUND_APPROVAL,
28
28
  UNCONFIRMED_PERMIT: () => UNCONFIRMED_PERMIT,
29
29
  createAdapterKernel: () => createAdapterKernel,
30
+ decisionFingerprint: () => decisionFingerprint,
30
31
  defaultProtectQuestion: () => defaultProtectQuestion,
31
32
  deniedByPolicy: () => deniedByPolicy,
32
33
  deriveParameters: () => deriveParameters,
@@ -42,6 +43,7 @@ __export(adapters_exports, {
42
43
  verifyWebhookSignature: () => verifyWebhookSignature
43
44
  });
44
45
  module.exports = __toCommonJS(adapters_exports);
46
+ var import_node_crypto = require("crypto");
45
47
 
46
48
  // src/error.ts
47
49
  var PusharyApiError = class _PusharyApiError extends Error {
@@ -92,11 +94,12 @@ var handleResponse = async (response) => {
92
94
  }
93
95
  return response.json();
94
96
  };
95
- var createRequest = (ctx) => async (method, path, body) => {
97
+ var createRequest = (ctx) => async (method, path, body, options) => {
96
98
  const isGet = method === "GET";
97
99
  const url = isGet && body ? buildUrl(ctx.baseUrl, path, body) : `${ctx.baseUrl}${path}`;
98
100
  const response = await fetch(url, {
99
101
  method,
102
+ signal: options?.signal ?? AbortSignal.timeout(ctx.timeoutMs ?? 65e3),
100
103
  headers: ctx.headers,
101
104
  body: !isGet && body ? JSON.stringify(body) : void 0
102
105
  });
@@ -156,6 +159,25 @@ var import_crypto2 = require("crypto");
156
159
  // src/util.ts
157
160
  var import_crypto = require("crypto");
158
161
  var deterministicKey = (parts) => (0, import_crypto.createHash)("sha256").update(parts.join("\0")).digest("hex").slice(0, 40);
162
+ var decisionFingerprint = (value) => {
163
+ const ancestors = /* @__PURE__ */ new Set();
164
+ const serialize = (item) => {
165
+ if (item === null || typeof item === "string" || typeof item === "boolean") return JSON.stringify(item);
166
+ if (typeof item === "number" && Number.isFinite(item)) return JSON.stringify(item);
167
+ if (typeof item !== "object" || ancestors.has(item)) {
168
+ throw new TypeError("Pushary: decision identity must contain finite, acyclic JSON values.");
169
+ }
170
+ if (!Array.isArray(item) && Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null) {
171
+ throw new TypeError("Pushary: decision identity must contain plain JSON objects.");
172
+ }
173
+ ancestors.add(item);
174
+ const entries = Object.entries(item).filter(([, entry]) => entry !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
175
+ const serialized = Array.isArray(item) ? `[${Array.from(item, serialize).join(",")}]` : `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${serialize(entry)}`).join(",")}}`;
176
+ ancestors.delete(item);
177
+ return serialized;
178
+ };
179
+ return (0, import_crypto.createHash)("sha256").update(serialize(value)).digest("hex");
180
+ };
159
181
  var AFFIRMATIVE = /* @__PURE__ */ new Set([
160
182
  "yes",
161
183
  "y",
@@ -179,64 +201,80 @@ var DEFAULT_ASK_TIMEOUT_MS = 55e3;
179
201
  var createDecisionsResource = (request) => {
180
202
  const ask = async (data) => {
181
203
  const idempotencyKey = data.idempotencyKey ?? (0, import_crypto2.randomUUID)();
182
- const created = await request("POST", "/decisions", {
183
- question: data.question,
184
- type: data.type,
185
- options: data.options,
186
- externalId: data.externalId,
187
- email: data.email,
188
- agentName: data.agentName,
189
- context: data.context,
190
- placeholder: data.placeholder,
191
- toolName: data.toolName,
192
- toolTarget: data.toolTarget,
193
- actor: data.actor,
194
- environment: data.environment,
195
- parameters: data.parameters,
196
- // Its changes name parameters rather than carrying values, so what the
197
- // person sees is read from the same numbers a rule compares.
198
- presentation: data.presentation,
199
- approvalUrl: data.approvalUrl,
200
- expiresInSeconds: data.expiresInSeconds,
201
- poweredBy: data.poweredBy,
202
- callbackUrl: data.callbackUrl,
203
- requireReachable: data.requireReachable,
204
- // Read only by a key issued on your workspace's sandbox site, where the
205
- // simulator answers instead of a person. Ignored in production.
206
- sandboxOutcome: data.sandboxOutcome,
207
- idempotencyKey,
208
- wait: false
209
- });
210
- const type = created.type ?? data.type ?? "confirm";
211
- let status = created.status;
212
- let answered = created.answered;
213
- let value = created.value ?? null;
214
- const deadline = Date.now() + Math.max(0, data.timeoutMs ?? DEFAULT_ASK_TIMEOUT_MS);
215
- while (status === "pending" && Date.now() < deadline) {
216
- const waitSeconds = Math.max(
217
- 1,
218
- Math.min(POLL_WINDOW_SECONDS, Math.ceil((deadline - Date.now()) / 1e3))
219
- );
220
- const polled = await request("GET", `/decisions/${created.decisionId}`, {
221
- wait: waitSeconds
222
- });
223
- status = polled.status;
224
- answered = polled.answered;
225
- value = polled.value;
204
+ const timeoutMs = Math.max(0, data.timeoutMs ?? DEFAULT_ASK_TIMEOUT_MS);
205
+ const deadline = Date.now() + timeoutMs;
206
+ const controller = new AbortController();
207
+ const abort = () => controller.abort(data.signal?.reason);
208
+ if (data.signal?.aborted) abort();
209
+ else data.signal?.addEventListener("abort", abort, { once: true });
210
+ const timer = setTimeout(() => controller.abort(new DOMException("Pushary ask timed out", "TimeoutError")), timeoutMs || 65e3);
211
+ try {
212
+ const created = await request("POST", "/decisions", {
213
+ question: data.question,
214
+ type: data.type,
215
+ options: data.options,
216
+ externalId: data.externalId,
217
+ email: data.email,
218
+ agentName: data.agentName,
219
+ context: data.context,
220
+ placeholder: data.placeholder,
221
+ toolName: data.toolName,
222
+ toolTarget: data.toolTarget,
223
+ actor: data.actor,
224
+ environment: data.environment,
225
+ parameters: data.parameters,
226
+ // Its changes name parameters rather than carrying values, so what the
227
+ // person sees is read from the same numbers a rule compares.
228
+ presentation: data.presentation,
229
+ approvalUrl: data.approvalUrl,
230
+ expiresInSeconds: data.expiresInSeconds,
231
+ poweredBy: data.poweredBy,
232
+ callbackUrl: data.callbackUrl,
233
+ requireReachable: data.requireReachable,
234
+ // Read only by a key issued on your workspace's sandbox site, where the
235
+ // simulator answers instead of a person. Ignored in production.
236
+ sandboxOutcome: data.sandboxOutcome,
237
+ idempotencyKey,
238
+ wait: false
239
+ }, { signal: controller.signal });
240
+ const type = created.type ?? data.type ?? "confirm";
241
+ let status = created.status;
242
+ let answered = created.answered;
243
+ let value = created.value ?? null;
244
+ while (status === "pending" && Date.now() < deadline) {
245
+ const waitSeconds = Math.max(
246
+ 1,
247
+ Math.min(POLL_WINDOW_SECONDS, Math.ceil((deadline - Date.now()) / 1e3))
248
+ );
249
+ try {
250
+ const polled = await request("GET", `/decisions/${created.decisionId}`, {
251
+ wait: waitSeconds
252
+ }, { signal: controller.signal });
253
+ status = polled.status;
254
+ answered = polled.answered;
255
+ value = polled.value;
256
+ } catch (error) {
257
+ if (controller.signal.aborted && !data.signal?.aborted) break;
258
+ throw error;
259
+ }
260
+ }
261
+ return {
262
+ decisionId: created.decisionId,
263
+ status,
264
+ answered,
265
+ value,
266
+ type,
267
+ approved: isApproved({ status, type, value }),
268
+ // Surfaced from the create response so a caller can tell an unanswered
269
+ // decision that reached nobody (reachable:false) apart from a real decline.
270
+ reachable: created.reachable,
271
+ reachableChannels: created.reachableChannels,
272
+ deviceCount: created.deviceCount
273
+ };
274
+ } finally {
275
+ clearTimeout(timer);
276
+ data.signal?.removeEventListener("abort", abort);
226
277
  }
227
- return {
228
- decisionId: created.decisionId,
229
- status,
230
- answered,
231
- value,
232
- type,
233
- approved: isApproved({ status, type, value }),
234
- // Surfaced from the create response so a caller can tell an unanswered
235
- // decision that reached nobody (reachable:false) apart from a real decline.
236
- reachable: created.reachable,
237
- reachableChannels: created.reachableChannels,
238
- deviceCount: created.deviceCount
239
- };
240
278
  };
241
279
  return Object.freeze({
242
280
  create: (data) => request("POST", "/decisions", data),
@@ -366,6 +404,7 @@ var createContext = (config) => {
366
404
  validateApiKey(config.apiKey);
367
405
  return Object.freeze({
368
406
  baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
407
+ timeoutMs: config.requestTimeoutMs ?? 65e3,
369
408
  headers: Object.freeze({
370
409
  "Content-Type": "application/json",
371
410
  "Authorization": `Bearer ${config.apiKey}`
@@ -424,9 +463,20 @@ var parseDecisionCallback = (rawBody) => {
424
463
  };
425
464
 
426
465
  // src/adapters.ts
427
- var DEFAULT_NODE = "ask-human";
428
466
  var defaultProtectQuestion = (action, target) => `Approve ${action}${target ? ` ${target}` : ""}?`;
429
- var idempotencyKeyFor = (input) => deterministicKey([input.externalId, input.node ?? DEFAULT_NODE, input.question]);
467
+ var idempotencyKeyFor = (input) => input.idempotencyKey ?? (0, import_node_crypto.randomUUID)();
468
+ var decisionSubject = (input) => ({
469
+ toolTarget: input.toolTarget,
470
+ actor: input.actor,
471
+ environment: input.environment,
472
+ parameters: input.parameters,
473
+ presentation: input.presentation,
474
+ approvalUrl: input.approvalUrl,
475
+ expiresInSeconds: input.expiresInSeconds,
476
+ requireReachable: input.requireReachable,
477
+ placeholder: input.placeholder,
478
+ callbackUrl: input.callbackUrl
479
+ });
430
480
  var isAffirmative = (answer) => isApproved({ status: "answered", type: "confirm", value: answer ?? null });
431
481
  var describeAnswer = (type, result) => {
432
482
  if (!result.answered) {
@@ -515,11 +565,12 @@ var createAdapterKernel = (helpersLabel) => {
515
565
  ...config.baseUrl ? { baseUrl: config.baseUrl } : {}
516
566
  });
517
567
  const requireExternalId = (externalId) => {
518
- if (!externalId) {
568
+ if (!externalId?.trim()) {
519
569
  throw new Error(
520
570
  `Pushary: no end-user to ask. Pass { externalId } to ${helpersLabel}, or run user-scoped auth so the framework supplies one.`
521
571
  );
522
572
  }
573
+ if (externalId.length > 256) throw new Error("Pushary: externalId must not exceed 256 characters.");
523
574
  return externalId;
524
575
  };
525
576
  const createGate = (config) => {
@@ -546,8 +597,12 @@ var createAdapterKernel = (helpersLabel) => {
546
597
  }
547
598
  };
548
599
  return async (ask) => {
600
+ if (!ask.callId?.trim()) throw new Error("Pushary: a stable tool call ID is required to resolve an approval.");
601
+ requireExternalId(ask.externalId);
549
602
  const facts = ask.parameters ?? deriveParameters(ask.input);
603
+ const idempotencyKey = decisionFingerprint({ ...ask, parameters: facts });
550
604
  const evaluation = await evaluate(ask, facts);
605
+ const includeSubject = evaluation !== null || !usePolicy;
551
606
  if (evaluation?.verdict === "allow") {
552
607
  return {
553
608
  approved: true,
@@ -573,6 +628,7 @@ var createAdapterKernel = (helpersLabel) => {
573
628
  question: ask.question,
574
629
  type: "confirm",
575
630
  externalId: ask.externalId,
631
+ context: ask.context,
576
632
  ...config.agentName ? { agentName: config.agentName } : {},
577
633
  ...config.expiresInSeconds ? { expiresInSeconds: config.expiresInSeconds } : {},
578
634
  ...config.timeoutMs !== void 0 ? { timeoutMs: config.timeoutMs } : {},
@@ -581,11 +637,8 @@ var createAdapterKernel = (helpersLabel) => {
581
637
  // and keyed on it below; sending it is what lets the decision be grouped
582
638
  // and matched later instead of surviving only as prose in the question.
583
639
  toolName: ask.toolName,
584
- // The rest of the subject only when the evaluation carried it and the
585
- // server accepted it. An evaluation that failed proves nothing about what
586
- // this ask would accept, and this path has to stay byte-for-byte the call
587
- // that worked before policy existed.
588
- ...evaluation ? {
640
+ ...includeSubject ? {
641
+ presentation: ask.presentation,
589
642
  toolTarget: ask.toolTarget,
590
643
  actor: ask.actor,
591
644
  environment: ask.environment,
@@ -593,7 +646,7 @@ var createAdapterKernel = (helpersLabel) => {
593
646
  } : {},
594
647
  // Stable across replays, so a re-run of this call resolves to the same
595
648
  // decision instead of asking twice.
596
- idempotencyKey: deterministicKey([ask.sessionId, ask.callId, ask.toolName])
649
+ idempotencyKey
597
650
  });
598
651
  if (result.approved) {
599
652
  return {
@@ -602,10 +655,7 @@ var createAdapterKernel = (helpersLabel) => {
602
655
  authorizationId: result.decisionId,
603
656
  toolName: ask.toolName,
604
657
  externalId: ask.externalId,
605
- // Mirrors the ask above exactly. The decision recorded the rest of
606
- // the subject only when the evaluation carried it, so claiming it
607
- // here regardless would describe an action the record does not hold.
608
- ...evaluation ? {
658
+ ...includeSubject ? {
609
659
  toolTarget: ask.toolTarget,
610
660
  actor: ask.actor,
611
661
  environment: ask.environment,
@@ -618,10 +668,11 @@ var createAdapterKernel = (helpersLabel) => {
618
668
  };
619
669
  };
620
670
  const askExternalUser = (config, input) => client(config).decisions.ask({
671
+ ...decisionSubject(input),
621
672
  question: input.question,
622
673
  type: input.type,
623
674
  options: input.options,
624
- externalId: input.externalId,
675
+ externalId: requireExternalId(input.externalId),
625
676
  context: input.context,
626
677
  agentName: config.agentName,
627
678
  timeoutMs: config.timeoutMs,
@@ -629,19 +680,23 @@ var createAdapterKernel = (helpersLabel) => {
629
680
  // sent: grouping every unnamed ask across every framework under one
630
681
  // bucket would mine into a suggestion to auto-approve "ask-human", which
631
682
  // spans unrelated questions.
632
- toolName: input.node,
683
+ toolName: input.toolName ?? input.node,
633
684
  idempotencyKey: idempotencyKeyFor(input)
634
685
  });
635
686
  const createDurableDecision = async (config, input) => {
687
+ if (!input.idempotencyKey?.trim()) {
688
+ throw new Error("Pushary: durable decisions require an idempotencyKey tied to the run and step.");
689
+ }
636
690
  const created = await client(config).decisions.create({
691
+ ...decisionSubject(input),
637
692
  question: input.question,
638
693
  type: input.type,
639
694
  options: input.options,
640
- externalId: input.externalId,
695
+ externalId: requireExternalId(input.externalId),
641
696
  context: input.context,
642
697
  callbackUrl: input.callbackUrl,
643
698
  agentName: config.agentName,
644
- toolName: input.node,
699
+ toolName: input.toolName ?? input.node,
645
700
  idempotencyKey: idempotencyKeyFor(input),
646
701
  wait: false
647
702
  });
@@ -654,7 +709,7 @@ var createAdapterKernel = (helpersLabel) => {
654
709
  deviceCount: created.deviceCount
655
710
  };
656
711
  };
657
- const connect = (config, externalId) => client(config).enroll(externalId);
712
+ const connect = (config, externalId) => client(config).enroll(requireExternalId(externalId));
658
713
  const protect = (config) => {
659
714
  const gate = createGate(config);
660
715
  const permitClient = client(config);
@@ -716,6 +771,7 @@ var createAdapterKernel = (helpersLabel) => {
716
771
  UNBOUND_APPROVAL,
717
772
  UNCONFIRMED_PERMIT,
718
773
  createAdapterKernel,
774
+ decisionFingerprint,
719
775
  defaultProtectQuestion,
720
776
  deniedByPolicy,
721
777
  deriveParameters,