@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/dist/adapters.js CHANGED
@@ -1,16 +1,29 @@
1
1
  import {
2
2
  SIGNATURE_HEADER,
3
3
  createPusharyServer,
4
+ decisionFingerprint,
4
5
  deterministicKey,
5
6
  isApproved,
6
7
  parseDecisionCallback,
7
8
  verifyWebhookSignature
8
- } from "./chunk-E6DWMKLZ.js";
9
+ } from "./chunk-AG6DI4RF.js";
9
10
 
10
11
  // src/adapters.ts
11
- var DEFAULT_NODE = "ask-human";
12
+ import { randomUUID } from "crypto";
12
13
  var defaultProtectQuestion = (action, target) => `Approve ${action}${target ? ` ${target}` : ""}?`;
13
- var idempotencyKeyFor = (input) => deterministicKey([input.externalId, input.node ?? DEFAULT_NODE, input.question]);
14
+ var idempotencyKeyFor = (input) => input.idempotencyKey ?? randomUUID();
15
+ var decisionSubject = (input) => ({
16
+ toolTarget: input.toolTarget,
17
+ actor: input.actor,
18
+ environment: input.environment,
19
+ parameters: input.parameters,
20
+ presentation: input.presentation,
21
+ approvalUrl: input.approvalUrl,
22
+ expiresInSeconds: input.expiresInSeconds,
23
+ requireReachable: input.requireReachable,
24
+ placeholder: input.placeholder,
25
+ callbackUrl: input.callbackUrl
26
+ });
14
27
  var isAffirmative = (answer) => isApproved({ status: "answered", type: "confirm", value: answer ?? null });
15
28
  var describeAnswer = (type, result) => {
16
29
  if (!result.answered) {
@@ -99,11 +112,12 @@ var createAdapterKernel = (helpersLabel) => {
99
112
  ...config.baseUrl ? { baseUrl: config.baseUrl } : {}
100
113
  });
101
114
  const requireExternalId = (externalId) => {
102
- if (!externalId) {
115
+ if (!externalId?.trim()) {
103
116
  throw new Error(
104
117
  `Pushary: no end-user to ask. Pass { externalId } to ${helpersLabel}, or run user-scoped auth so the framework supplies one.`
105
118
  );
106
119
  }
120
+ if (externalId.length > 256) throw new Error("Pushary: externalId must not exceed 256 characters.");
107
121
  return externalId;
108
122
  };
109
123
  const createGate = (config) => {
@@ -130,8 +144,12 @@ var createAdapterKernel = (helpersLabel) => {
130
144
  }
131
145
  };
132
146
  return async (ask) => {
147
+ if (!ask.callId?.trim()) throw new Error("Pushary: a stable tool call ID is required to resolve an approval.");
148
+ requireExternalId(ask.externalId);
133
149
  const facts = ask.parameters ?? deriveParameters(ask.input);
150
+ const idempotencyKey = decisionFingerprint({ ...ask, parameters: facts });
134
151
  const evaluation = await evaluate(ask, facts);
152
+ const includeSubject = evaluation !== null || !usePolicy;
135
153
  if (evaluation?.verdict === "allow") {
136
154
  return {
137
155
  approved: true,
@@ -157,6 +175,7 @@ var createAdapterKernel = (helpersLabel) => {
157
175
  question: ask.question,
158
176
  type: "confirm",
159
177
  externalId: ask.externalId,
178
+ context: ask.context,
160
179
  ...config.agentName ? { agentName: config.agentName } : {},
161
180
  ...config.expiresInSeconds ? { expiresInSeconds: config.expiresInSeconds } : {},
162
181
  ...config.timeoutMs !== void 0 ? { timeoutMs: config.timeoutMs } : {},
@@ -165,11 +184,8 @@ var createAdapterKernel = (helpersLabel) => {
165
184
  // and keyed on it below; sending it is what lets the decision be grouped
166
185
  // and matched later instead of surviving only as prose in the question.
167
186
  toolName: ask.toolName,
168
- // The rest of the subject only when the evaluation carried it and the
169
- // server accepted it. An evaluation that failed proves nothing about what
170
- // this ask would accept, and this path has to stay byte-for-byte the call
171
- // that worked before policy existed.
172
- ...evaluation ? {
187
+ ...includeSubject ? {
188
+ presentation: ask.presentation,
173
189
  toolTarget: ask.toolTarget,
174
190
  actor: ask.actor,
175
191
  environment: ask.environment,
@@ -177,7 +193,7 @@ var createAdapterKernel = (helpersLabel) => {
177
193
  } : {},
178
194
  // Stable across replays, so a re-run of this call resolves to the same
179
195
  // decision instead of asking twice.
180
- idempotencyKey: deterministicKey([ask.sessionId, ask.callId, ask.toolName])
196
+ idempotencyKey
181
197
  });
182
198
  if (result.approved) {
183
199
  return {
@@ -186,10 +202,7 @@ var createAdapterKernel = (helpersLabel) => {
186
202
  authorizationId: result.decisionId,
187
203
  toolName: ask.toolName,
188
204
  externalId: ask.externalId,
189
- // Mirrors the ask above exactly. The decision recorded the rest of
190
- // the subject only when the evaluation carried it, so claiming it
191
- // here regardless would describe an action the record does not hold.
192
- ...evaluation ? {
205
+ ...includeSubject ? {
193
206
  toolTarget: ask.toolTarget,
194
207
  actor: ask.actor,
195
208
  environment: ask.environment,
@@ -202,10 +215,11 @@ var createAdapterKernel = (helpersLabel) => {
202
215
  };
203
216
  };
204
217
  const askExternalUser = (config, input) => client(config).decisions.ask({
218
+ ...decisionSubject(input),
205
219
  question: input.question,
206
220
  type: input.type,
207
221
  options: input.options,
208
- externalId: input.externalId,
222
+ externalId: requireExternalId(input.externalId),
209
223
  context: input.context,
210
224
  agentName: config.agentName,
211
225
  timeoutMs: config.timeoutMs,
@@ -213,19 +227,23 @@ var createAdapterKernel = (helpersLabel) => {
213
227
  // sent: grouping every unnamed ask across every framework under one
214
228
  // bucket would mine into a suggestion to auto-approve "ask-human", which
215
229
  // spans unrelated questions.
216
- toolName: input.node,
230
+ toolName: input.toolName ?? input.node,
217
231
  idempotencyKey: idempotencyKeyFor(input)
218
232
  });
219
233
  const createDurableDecision = async (config, input) => {
234
+ if (!input.idempotencyKey?.trim()) {
235
+ throw new Error("Pushary: durable decisions require an idempotencyKey tied to the run and step.");
236
+ }
220
237
  const created = await client(config).decisions.create({
238
+ ...decisionSubject(input),
221
239
  question: input.question,
222
240
  type: input.type,
223
241
  options: input.options,
224
- externalId: input.externalId,
242
+ externalId: requireExternalId(input.externalId),
225
243
  context: input.context,
226
244
  callbackUrl: input.callbackUrl,
227
245
  agentName: config.agentName,
228
- toolName: input.node,
246
+ toolName: input.toolName ?? input.node,
229
247
  idempotencyKey: idempotencyKeyFor(input),
230
248
  wait: false
231
249
  });
@@ -238,7 +256,7 @@ var createAdapterKernel = (helpersLabel) => {
238
256
  deviceCount: created.deviceCount
239
257
  };
240
258
  };
241
- const connect = (config, externalId) => client(config).enroll(externalId);
259
+ const connect = (config, externalId) => client(config).enroll(requireExternalId(externalId));
242
260
  const protect = (config) => {
243
261
  const gate = createGate(config);
244
262
  const permitClient = client(config);
@@ -299,6 +317,7 @@ export {
299
317
  UNBOUND_APPROVAL,
300
318
  UNCONFIRMED_PERMIT,
301
319
  createAdapterKernel,
320
+ decisionFingerprint,
302
321
  defaultProtectQuestion,
303
322
  deniedByPolicy,
304
323
  deriveParameters,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/adapters.ts"],"sourcesContent":["// The shared kernel behind every Pushary framework adapter. Published as\n// `@pushary/server/adapters` so a new adapter is a thin binding — map the\n// framework's context onto these shapes, map the result back — instead of another\n// copy of the same ~150 lines. Nothing here imports a framework.\n\nimport { createPusharyServer, type PusharyServer } from './client'\nimport { deterministicKey, isApproved } from './util'\nimport { parseDecisionCallback, verifyWebhookSignature } from './webhook'\nimport type {\n AskResult,\n AuthorizationBinding,\n AuthorizationEvaluation,\n AuthorizationVerdict,\n ConsumeAuthorizationResult,\n DecisionParameters,\n DecisionType,\n EnrollResult,\n ExecutionOutcome,\n} from './types'\n\n/** Config every adapter accepts, whatever framework it binds. */\nexport interface PusharyAdapterConfig {\n /** Pushary API key (pk_xxx.sk_xxx). Defaults to `process.env.PUSHARY_API_KEY`. */\n readonly apiKey?: string\n /** Shown on the approval so the human knows which agent is asking. */\n readonly agentName?: string\n /** How long a blocking ask waits before returning (default 55s, serverless-safe). */\n readonly timeoutMs?: number\n /** Override the API base URL (tests / self-host). */\n readonly baseUrl?: string\n}\n\n/** One ask, as an adapter hands it to the kernel. */\nexport interface AskHumanInput {\n readonly question: string\n /** Your own stable id for the end-user who decides. */\n readonly externalId: string\n /** Stable node/step/tool name, used to build a re-run-safe idempotency key. */\n readonly node?: string\n readonly type?: DecisionType\n readonly options?: readonly string[]\n readonly context?: string\n /** Present on the durable path; Pushary POSTs the answer here. */\n readonly callbackUrl?: string\n}\n\n/** A decision opened durably, before the framework parks its run. */\nexport interface CreatedDecision {\n readonly decisionId: string\n readonly correlationId: string\n readonly status: string\n readonly reachable?: boolean\n readonly reachableChannels?: number\n readonly deviceCount?: number\n}\n\n/** A verified, parsed answer callback, keyed by `correlationId`. */\nexport interface PusharyCallback {\n readonly correlationId: string\n readonly answer: string\n readonly value: string\n readonly approved: boolean\n readonly context?: string\n readonly answeredAt: string\n}\n\nconst DEFAULT_NODE = 'ask-human'\n\n/**\n * What the human is shown when the caller names no question.\n *\n * The action and its target, not a rendering of the facts: a protected action is\n * written by hand, so its name is already business language (\"refund.create\n * order_4471\") in a way a tool's raw input never is.\n */\nexport const defaultProtectQuestion = (action: string, target?: string): string =>\n `Approve ${action}${target ? ` ${target}` : ''}?`\n\n/**\n * Re-run-safe idempotency key. A node, step, or tool call that replays on resume\n * lands on the same decision instead of paging the human twice.\n */\nexport const idempotencyKeyFor = (input: AskHumanInput): string =>\n deterministicKey([input.externalId, input.node ?? DEFAULT_NODE, input.question])\n\n/** Fail-closed yes/no check for a confirm answer. */\nexport const isAffirmative = (answer: string | null | undefined): boolean =>\n isApproved({ status: 'answered', type: 'confirm', value: answer ?? null })\n\n/** Turn a decision outcome into an unambiguous instruction for the model. */\nexport const describeAnswer = (type: DecisionType, result: AskResult): string => {\n if (!result.answered) {\n return `No answer (status: ${result.status}). Treat this as NOT approved and do not proceed.`\n }\n if (type === 'confirm') {\n return result.approved\n ? 'The human approved. You may proceed.'\n : 'The human declined. Do not proceed.'\n }\n return `The human answered: ${result.value ?? ''}`\n}\n\n/**\n * Verify a callback signature and parse it, or return null. The `answer` is what\n * you feed back into the framework's resume seam.\n */\nexport const resolvePusharyCallback = (\n rawBody: string,\n signature: string | null | undefined,\n secret: string,\n): PusharyCallback | null => {\n if (!verifyWebhookSignature(rawBody, signature, secret)) return null\n const callback = parseDecisionCallback(rawBody)\n if (!callback) return null\n return {\n correlationId: callback.correlationId,\n answer: callback.answer,\n value: callback.value,\n approved: isAffirmative(callback.answer),\n context: callback.context,\n answeredAt: callback.answeredAt,\n }\n}\n\n/**\n * Connection-level options for a request-time approval gate. Everything that\n * varies per tool call lives on {@link ApprovalAsk} instead, so a gate holds one\n * client and no per-call state.\n */\nexport interface PusharyGateConfig {\n /** Pushary API key. Defaults to `process.env.PUSHARY_API_KEY`. */\n readonly apiKey?: string\n /** Shown on the approval so the human knows which agent is asking. */\n readonly agentName?: string\n /** How long the decision stays answerable. */\n readonly expiresInSeconds?: number\n /** How long to block waiting for an answer before failing closed. */\n readonly timeoutMs?: number\n /**\n * Refuse to open a decision nobody can receive, so an end-user with no connected\n * device is denied at request time instead of silently expiring.\n */\n readonly requireReachable?: boolean\n /**\n * Ask the site's policy before asking a person (default true). A rule that names\n * the action resolves it without paging anyone; everything else still asks.\n *\n * Set false to restore the always-ask gate: a site whose rules were written for\n * its own coding agents, and which happens to name a tool this gate also sits\n * on, would otherwise start resolving that tool automatically.\n */\n readonly policy?: boolean\n /** Override the API base URL (tests / self-host). */\n readonly baseUrl?: string\n}\n\n/**\n * One gated tool call, resolved down to what Pushary needs. The adapter owns the\n * mapping from its framework's context, so per-call resolvers stay typed against\n * the framework's own shapes rather than a lowest common denominator.\n */\nexport interface ApprovalAsk {\n /** The tool the model wants to run. */\n readonly toolName: string\n /** Unique per tool call. Keys the decision together with `sessionId`. */\n readonly callId: string\n /** Stable per run, session, or thread. */\n readonly sessionId: string\n /** What the human is shown. */\n readonly question: string\n /** The enrolled end-user who decides. */\n readonly externalId: string\n /** What the tool acts on, e.g. an order id. Narrows which rule governs. */\n readonly toolTarget?: string\n /** Whose authority the action claims, e.g. `user:u_44`. Not the approver. */\n readonly actor?: string\n /** Which deployment the action runs against, e.g. `production`. */\n readonly environment?: string\n /**\n * The tool's raw input. Bounded scalars are derived from it so a rule can decide\n * on the arguments and not only on the name; anything the bounds cannot carry\n * sends nothing, which escalates. Already rendered into the default question, so\n * passing it exposes nothing new.\n */\n readonly input?: unknown\n /** Explicit facts for policy, used instead of deriving them from `input`. */\n readonly parameters?: DecisionParameters\n}\n\n/**\n * What a gate answers. Denials always carry a reason the model can read.\n *\n * An approval also carries the binding: which authorization settled it and the\n * exact subject it was recorded against. Carried rather than rebuilt, because\n * only the gate knows what it actually sent — the ask records the full subject\n * only when the evaluation succeeded — and a caller that rebuilds it would send\n * a subject the record does not hold and be refused for its own approval.\n * Absent only from a server too old to name the authorization.\n */\nexport type ApprovalDecision =\n | { readonly approved: true; readonly authorization?: AuthorizationBinding }\n | { readonly approved: false; readonly reason: string }\n\n/**\n * A request-time gate: ask the site's policy, ask a person when policy defers,\n * fail closed either way.\n */\nexport type ApprovalGate = (ask: ApprovalAsk) => Promise<ApprovalDecision>\n\nconst MAX_INPUT_CHARS = 300\n\nexport const DENIED_UNANSWERED =\n 'No answer from the approver, so this was denied. Do not retry the same action.'\nexport const DENIED_REFUSED = 'The approver denied this action.'\nexport const DENIED_BY_POLICY = 'Denied by policy. Do not retry the same action.'\n\nexport const UNBOUND_APPROVAL =\n '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.'\n\nexport const UNCONFIRMED_PERMIT =\n 'This was approved, but it could not be confirmed as still unspent, so it was not run. Ask again rather than retrying this one.'\n\nconst REFUSED_REPLAY =\n 'This authorization was already used. The action has run once and must not run again.'\n\n/**\n * A refused permit, as the model reads it.\n *\n * `already_consumed` is worded to stop the caller rather than to invite a retry,\n * because a retry is the failure: something already ran this action once, and\n * the agent reading this cannot see that from its own state.\n */\nexport const refusedPermit = (\n refused: Extract<ConsumeAuthorizationResult, { consumed: false }>,\n): string => (refused.refusal === 'already_consumed' ? REFUSED_REPLAY : `${refused.reason} Do not retry the same action.`)\n\n/**\n * A policy denial, as the model reads it. The server already words the reason\n * (\"Denied by policy rule refund.create.\"); the retry instruction is added here so\n * every denial this gate returns ends the same way, whoever settled it.\n */\nexport const deniedByPolicy = (evaluation: AuthorizationEvaluation): string =>\n evaluation.reason ? `${evaluation.reason} Do not retry the same action.` : DENIED_BY_POLICY\n\nconst PARAMETERS_MAX_KEYS = 32\nconst PARAMETER_KEY_MAX_LENGTH = 64\nconst PARAMETER_VALUE_MAX_LENGTH = 200\n\n/**\n * Bounded facts for policy, derived from a tool's own input.\n *\n * All or nothing, deliberately. The bounds mirror what the API accepts, so a\n * partial object is always representable — and that is exactly the danger: a rule\n * reading `amount >= 500` stops denying the moment `amount` goes missing, so\n * dropping one entry can only ever move a verdict toward allow. Sending nothing\n * instead leaves the rule's parameter absent, which escalates to a person.\n *\n * The same bounds also mean this never turns a working tool call into a rejected\n * one: an input this cannot carry is simply not sent.\n */\nexport const deriveParameters = (input: unknown): DecisionParameters | undefined => {\n if (input === null || typeof input !== 'object' || Array.isArray(input)) return undefined\n const entries = Object.entries(input as Record<string, unknown>)\n if (entries.length === 0 || entries.length > PARAMETERS_MAX_KEYS) return undefined\n\n const parameters: Record<string, string | number | boolean> = {}\n for (const [key, value] of entries) {\n if (key.length === 0 || key.length > PARAMETER_KEY_MAX_LENGTH) return undefined\n if (typeof value === 'boolean') {\n parameters[key] = value\n } else if (typeof value === 'number') {\n if (!Number.isFinite(value)) return undefined\n parameters[key] = value\n } else if (typeof value === 'string') {\n if (value.length > PARAMETER_VALUE_MAX_LENGTH) return undefined\n parameters[key] = value\n } else {\n return undefined\n }\n }\n return parameters\n}\n\n/**\n * The default question: the tool name plus a truncated view of its input, so the\n * human sees what they are approving without a wall of JSON on a lock screen.\n */\nexport const renderApprovalQuestion = (toolName: string, toolInput: unknown): string => {\n if (toolInput === undefined || toolInput === null) return `Approve ${toolName}?`\n const summary = summarise(toolInput)\n return summary === undefined ? `Approve ${toolName}?` : `Approve ${toolName}? ${summary}`\n}\n\nconst summarise = (toolInput: unknown): string | undefined => {\n let rendered: string | undefined\n if (typeof toolInput === 'string') {\n rendered = toolInput\n } else {\n try {\n // Returns undefined for a function or a symbol, and throws on a cycle. Neither\n // is a reason to fail the tool call: a gate that cannot render its input still\n // has to ask, so fall back to the bare tool name.\n rendered = JSON.stringify(toolInput)\n } catch {\n return undefined\n }\n }\n if (rendered === undefined) return undefined\n return rendered.length > MAX_INPUT_CHARS ? `${rendered.slice(0, MAX_INPUT_CHARS)}...` : rendered\n}\n\n\n/**\n * One protected action: what it is, who it belongs to, and what it does.\n *\n * The shape a developer writes once instead of threading authorize, escalate and\n * run through their business code by hand. Everything above `run` is the same\n * subject `authorize()` and the decision both read, so the rule that governs the\n * action and the sentence the human sees describe the one thing.\n */\nexport interface ProtectedAction<TResult> {\n /** What is being done, e.g. `refund.create`. A rule has to name this. */\n readonly action: string\n /** What it is done to, e.g. an order id. Narrows which rule governs. */\n readonly target?: string\n /** The end-user this action belongs to, and who decides if policy defers. */\n readonly externalId: string\n /** Whose authority the action claims, e.g. `user:u_44`. Not the approver. */\n readonly actor?: string\n /** Which deployment it runs against, e.g. `production`. */\n readonly environment?: string\n /**\n * The bounded facts a rule may decide on, e.g. `{ amount: 4800 }`. Scalars\n * only. Passed as given rather than derived, because a protected action is\n * written by hand and the author knows which of its arguments matter.\n */\n readonly facts?: DecisionParameters\n /** Unique per attempt. Keys the decision together with `runId`. */\n readonly callId: string\n /** Stable per run, session or thread. */\n readonly runId: string\n /** What the human is shown. Defaults to the action and its target. */\n readonly question?: string\n /**\n * The side effect. Called only after the action is authorized, and never on a\n * denial or an unanswered escalation.\n */\n readonly run: () => Promise<TResult>\n}\n\n/**\n * What a protected action did.\n *\n * A discriminated union rather than a thrown error, because \"policy said no\" is\n * an ordinary outcome an agent has to be able to read and report, not an\n * exception. An error thrown by `run` itself is NOT caught: that is the caller's\n * failure, about their side effect, and swallowing it here would turn a failed\n * refund into a silent success.\n */\nexport type ProtectResult<TResult> =\n | { readonly ok: true; readonly result: TResult }\n | { readonly ok: false; readonly reason: string }\n\n/** Protect one action. Build it once per configuration, call it per action. */\nexport type Protect = <TResult>(action: ProtectedAction<TResult>) => Promise<ProtectResult<TResult>>\n\n/**\n * The calls an adapter makes, bound to one framework's name so a missing key or a\n * missing end-user says which helpers to configure. Build one per adapter module.\n */\nexport interface AdapterKernel {\n /** The underlying client, for anything the kernel does not cover. */\n readonly client: (config: PusharyAdapterConfig) => PusharyServer\n /**\n * Blocking ask: create a decision and poll durably until the human answers or\n * the deadline passes. Idempotency keyed by externalId + node + question.\n */\n readonly askExternalUser: (\n config: PusharyAdapterConfig,\n input: AskHumanInput,\n ) => Promise<AskResult>\n /**\n * Durable create: open a decision with a `callbackUrl` and return at once, for\n * a framework that parks its run and resumes on the webhook.\n */\n readonly createDurableDecision: (\n config: PusharyAdapterConfig,\n input: AskHumanInput,\n ) => Promise<CreatedDecision>\n /** Connect one end-user's phone (keyless). Show them the returned link. */\n readonly connect: (config: PusharyAdapterConfig, externalId: string) => Promise<EnrollResult>\n /**\n * Build a request-time approval gate. The client is created here, so a missing\n * key throws where the gate is defined rather than on the first tool call.\n */\n readonly createGate: (config: PusharyGateConfig) => ApprovalGate\n /**\n * Wrap one action so authorization, escalation and execution are one call.\n *\n * The whole of it is `createGate` plus \"run it if the answer was yes\", which is\n * the point: there is no second policy engine, no durable runtime and no new\n * abstraction under this. An adapter binds it by mapping its framework's\n * context onto ProtectedAction, exactly as it already does for the gate.\n *\n * At most once. The decision is idempotent — a replay of the same\n * `runId` + `callId` + action resolves to the same approval instead of asking\n * twice — and the approval is then spent against a durable permit before the\n * side effect runs. A retry, a concurrent worker and a resumed run all reach\n * the same permit, and exactly one of them proceeds; the rest are refused with\n * a reason that tells the model to stop rather than try again.\n *\n * `run` is called only after the permit is spent, never before, so the failure\n * mode of a crash mid-action is a permit with no receipt — which is visible —\n * rather than an action that runs twice, which is not recoverable.\n */\n readonly protect: (config: PusharyGateConfig) => Protect\n /**\n * The end-user to ask, or a clear error naming these helpers. Adapters call this\n * after applying their own resolver and the framework's own default.\n */\n readonly requireExternalId: (externalId: string | undefined) => string\n}\n\n/**\n * Build the kernel for one adapter.\n *\n * `helpersLabel` names the adapter in the missing-key error, so the message points\n * at the helpers the caller is actually using (\"the LangGraph helpers\").\n *\n * ```ts\n * const kernel = createAdapterKernel('the Acme helpers')\n * export const askHuman = kernel.askExternalUser\n * ```\n */\nexport const createAdapterKernel = (helpersLabel: string): AdapterKernel => {\n const requireApiKey = (apiKey: string | undefined): string => {\n const resolved = apiKey ?? process.env.PUSHARY_API_KEY\n if (!resolved) {\n throw new Error(`Pushary: set PUSHARY_API_KEY or pass { apiKey } to ${helpersLabel}.`)\n }\n return resolved\n }\n\n const client = (config: PusharyAdapterConfig): PusharyServer =>\n createPusharyServer({\n apiKey: requireApiKey(config.apiKey),\n ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),\n })\n\n const requireExternalId = (externalId: string | undefined): string => {\n if (!externalId) {\n throw new Error(\n `Pushary: no end-user to ask. Pass { externalId } to ${helpersLabel}, or run ` +\n 'user-scoped auth so the framework supplies one.',\n )\n }\n return externalId\n }\n\n const createGate = (config: PusharyGateConfig): ApprovalGate => {\n const gateClient = createPusharyServer({\n apiKey: requireApiKey(config.apiKey),\n ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),\n })\n const usePolicy = config.policy ?? true\n\n /**\n * The verdict, or null if there is no usable one.\n *\n * Every failure is null rather than a throw. This route is entitled to the\n * Partner plan and is newer than the published adapters, so a caller who\n * cannot reach it — wrong plan, older deployment, a network blip — has to keep\n * working exactly as it did before policy existed, which is by asking a person.\n * Null is never allow, so the fail-safe direction is preserved.\n */\n const evaluate = async (\n ask: ApprovalAsk,\n facts: DecisionParameters | undefined,\n ): Promise<AuthorizationEvaluation | null> => {\n if (!usePolicy) return null\n try {\n return await gateClient.evaluateAuthorization({\n toolName: ask.toolName,\n toolTarget: ask.toolTarget,\n actor: ask.actor,\n environment: ask.environment,\n parameters: facts,\n externalId: ask.externalId,\n question: ask.question,\n ...(config.agentName ? { agentName: config.agentName } : {}),\n })\n } catch {\n return null\n }\n }\n\n return async (ask: ApprovalAsk): Promise<ApprovalDecision> => {\n // Derived once. The evaluation and the ask that may follow have to describe\n // the same action, and computing it twice is two chances for them not to.\n const facts = ask.parameters ?? deriveParameters(ask.input)\n const evaluation = await evaluate(ask, facts)\n if (evaluation?.verdict === 'allow') {\n return {\n approved: true,\n // Exactly the subject POST /authorize recorded, which is the whole of\n // what it was sent.\n ...(evaluation.authorizationId\n ? {\n authorization: {\n authorizationId: evaluation.authorizationId,\n toolName: ask.toolName,\n toolTarget: ask.toolTarget,\n actor: ask.actor,\n environment: ask.environment,\n externalId: ask.externalId,\n parameters: facts,\n },\n }\n : {}),\n }\n }\n if (evaluation?.verdict === 'deny') {\n return { approved: false, reason: deniedByPolicy(evaluation) }\n }\n\n const result = await gateClient.decisions.ask({\n question: ask.question,\n type: 'confirm',\n externalId: ask.externalId,\n ...(config.agentName ? { agentName: config.agentName } : {}),\n ...(config.expiresInSeconds ? { expiresInSeconds: config.expiresInSeconds } : {}),\n ...(config.timeoutMs !== undefined ? { timeoutMs: config.timeoutMs } : {}),\n ...(config.requireReachable ? { requireReachable: config.requireReachable } : {}),\n // The gated tool, recorded on the decision. The gate already knows it\n // and keyed on it below; sending it is what lets the decision be grouped\n // and matched later instead of surviving only as prose in the question.\n toolName: ask.toolName,\n // The rest of the subject only when the evaluation carried it and the\n // server accepted it. An evaluation that failed proves nothing about what\n // this ask would accept, and this path has to stay byte-for-byte the call\n // that worked before policy existed.\n ...(evaluation\n ? {\n toolTarget: ask.toolTarget,\n actor: ask.actor,\n environment: ask.environment,\n parameters: facts,\n }\n : {}),\n // Stable across replays, so a re-run of this call resolves to the same\n // decision instead of asking twice.\n idempotencyKey: deterministicKey([ask.sessionId, ask.callId, ask.toolName]),\n })\n\n if (result.approved) {\n return {\n approved: true,\n authorization: {\n authorizationId: result.decisionId,\n toolName: ask.toolName,\n externalId: ask.externalId,\n // Mirrors the ask above exactly. The decision recorded the rest of\n // the subject only when the evaluation carried it, so claiming it\n // here regardless would describe an action the record does not hold.\n ...(evaluation\n ? {\n toolTarget: ask.toolTarget,\n actor: ask.actor,\n environment: ask.environment,\n parameters: facts,\n }\n : {}),\n },\n }\n }\n return { approved: false, reason: result.answered ? DENIED_REFUSED : DENIED_UNANSWERED }\n }\n }\n\n const askExternalUser = (\n config: PusharyAdapterConfig,\n input: AskHumanInput,\n ): Promise<AskResult> =>\n client(config).decisions.ask({\n question: input.question,\n type: input.type,\n options: input.options,\n externalId: input.externalId,\n context: input.context,\n agentName: config.agentName,\n timeoutMs: config.timeoutMs,\n // The node/step name doubles as the action label. The default is not\n // sent: grouping every unnamed ask across every framework under one\n // bucket would mine into a suggestion to auto-approve \"ask-human\", which\n // spans unrelated questions.\n toolName: input.node,\n idempotencyKey: idempotencyKeyFor(input),\n })\n\n const createDurableDecision = async (\n config: PusharyAdapterConfig,\n input: AskHumanInput,\n ): Promise<CreatedDecision> => {\n const created = await client(config).decisions.create({\n question: input.question,\n type: input.type,\n options: input.options,\n externalId: input.externalId,\n context: input.context,\n callbackUrl: input.callbackUrl,\n agentName: config.agentName,\n toolName: input.node,\n idempotencyKey: idempotencyKeyFor(input),\n wait: false,\n })\n return {\n decisionId: created.decisionId,\n correlationId: created.decisionId,\n status: created.status,\n reachable: created.reachable,\n reachableChannels: created.reachableChannels,\n deviceCount: created.deviceCount,\n }\n }\n\n const connect = (config: PusharyAdapterConfig, externalId: string): Promise<EnrollResult> =>\n client(config).enroll(externalId)\n\n const protect = (config: PusharyGateConfig): Protect => {\n // One gate per configuration, not per action: it holds the client, so\n // building it here means a missing key throws where protect() is defined\n // rather than on the first refund somebody tries to issue.\n const gate = createGate(config)\n const permitClient = client(config)\n\n // The receipt is evidence about an action that has already happened. Failing\n // the caller because the evidence could not be written would turn a\n // successful refund into an error and invite them to issue it again, which\n // is the exact outcome this whole path exists to prevent. A permit left\n // `running` is the honest record, and it is queryable.\n const report = async (permitId: string, outcome: ExecutionOutcome, summary?: string) => {\n try {\n await permitClient.recordExecution({ permitId, outcome, summary })\n } catch {\n // Deliberately swallowed. See above.\n }\n }\n\n return async <TResult>(action: ProtectedAction<TResult>): Promise<ProtectResult<TResult>> => {\n const decision = await gate({\n toolName: action.action,\n toolTarget: action.target,\n callId: action.callId,\n sessionId: action.runId,\n question: action.question ?? defaultProtectQuestion(action.action, action.target),\n externalId: action.externalId,\n actor: action.actor,\n environment: action.environment,\n parameters: action.facts,\n })\n\n if (!decision.approved) return { ok: false, reason: decision.reason }\n if (!decision.authorization) return { ok: false, reason: UNBOUND_APPROVAL }\n\n // Spent before the side effect, never after. A permit consumed after a\n // successful run would leave the window this exists to close: the crash\n // between the two would lose the record that it ran, and the retry would\n // run it again.\n let consumed\n try {\n consumed = await permitClient.consumeAuthorization(decision.authorization)\n } catch {\n // The action was approved and we could not confirm it is still ours to\n // run. Not running is the only safe answer; a duplicate refund cannot be\n // taken back and an unissued one can be asked for again.\n return { ok: false, reason: UNCONFIRMED_PERMIT }\n }\n if (!consumed.consumed) return { ok: false, reason: refusedPermit(consumed) }\n\n const permitId = consumed.permit.permitId\n let result: TResult\n try {\n result = await action.run()\n } catch (error) {\n // Recorded and rethrown, not swallowed. An error from the side effect is\n // the caller's, and reporting it as `ok: false` would be\n // indistinguishable from a refusal to an agent reading the result.\n await report(permitId, 'failed', error instanceof Error ? error.message : 'unknown error')\n throw error\n }\n await report(permitId, 'succeeded')\n return { ok: true, result }\n }\n }\n\n return Object.freeze({\n client,\n askExternalUser,\n createDurableDecision,\n connect,\n createGate,\n protect,\n requireExternalId,\n })\n}\n\nexport { SIGNATURE_HEADER, verifyWebhookSignature, parseDecisionCallback } from './webhook'\nexport { deterministicKey, isApproved } from './util'\nexport type {\n AskResult,\n AuthorizationEvaluation,\n AuthorizationVerdict,\n DecisionParameters,\n DecisionType,\n EnrollResult,\n PusharyServer,\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/adapters.ts"],"sourcesContent":["// The shared kernel behind every Pushary framework adapter. Published as\n// `@pushary/server/adapters` so a new adapter is a thin binding — map the\n// framework's context onto these shapes, map the result back — instead of another\n// copy of the same ~150 lines. Nothing here imports a framework.\n\nimport { randomUUID } from 'node:crypto'\nimport { createPusharyServer, type PusharyServer } from './client'\nimport { decisionFingerprint, isApproved } from './util'\nimport { parseDecisionCallback, verifyWebhookSignature } from './webhook'\nimport type {\n AskResult,\n AuthorizationBinding,\n AuthorizationEvaluation,\n AuthorizationVerdict,\n ConsumeAuthorizationResult,\n DecisionParameters,\n DecisionSubject,\n DecisionType,\n EnrollResult,\n ExecutionOutcome,\n} from './types'\n\n/** Config every adapter accepts, whatever framework it binds. */\nexport interface PusharyAdapterConfig {\n /** Pushary API key (pk_xxx.sk_xxx). Defaults to `process.env.PUSHARY_API_KEY`. */\n readonly apiKey?: string\n /** Shown on the approval so the human knows which agent is asking. */\n readonly agentName?: string\n /** How long a blocking ask waits before returning (default 55s, serverless-safe). */\n readonly timeoutMs?: number\n /** Override the API base URL (tests / self-host). */\n readonly baseUrl?: string\n}\n\n/** One ask, as an adapter hands it to the kernel. */\nexport interface AskHumanInput extends DecisionSubject {\n /** Stable business operation ID for retries; required when parking a durable workflow. */\n readonly idempotencyKey?: string\n readonly question: string\n /** Your own stable id for the end-user who decides. */\n readonly externalId: string\n /** Stable node/step/tool name, used to build a re-run-safe idempotency key. */\n readonly node?: string\n readonly type?: DecisionType\n readonly options?: readonly string[]\n readonly context?: string\n /** Present on the durable path; Pushary POSTs the answer here. */\n readonly callbackUrl?: string\n readonly expiresInSeconds?: number\n readonly requireReachable?: boolean\n readonly placeholder?: string\n}\n\n/** A decision opened durably, before the framework parks its run. */\nexport interface CreatedDecision {\n readonly decisionId: string\n readonly correlationId: string\n readonly status: string\n readonly reachable?: boolean\n readonly reachableChannels?: number\n readonly deviceCount?: number\n}\n\n/** A verified, parsed answer callback, keyed by `correlationId`. */\nexport interface PusharyCallback {\n readonly correlationId: string\n readonly answer: string\n readonly value: string\n readonly approved: boolean\n readonly context?: string\n readonly answeredAt: string\n}\n\n/**\n * What the human is shown when the caller names no question.\n *\n * The action and its target, not a rendering of the facts: a protected action is\n * written by hand, so its name is already business language (\"refund.create\n * order_4471\") in a way a tool's raw input never is.\n */\nexport const defaultProtectQuestion = (action: string, target?: string): string =>\n `Approve ${action}${target ? ` ${target}` : ''}?`\n\n/**\n * Only an explicit operation key may replay an approval. Independent asks get\n * fresh keys even when their question and recipient are identical.\n */\nexport const idempotencyKeyFor = (input: AskHumanInput): string =>\n input.idempotencyKey ?? randomUUID()\n\nconst decisionSubject = (input: AskHumanInput) => ({\n toolTarget: input.toolTarget,\n actor: input.actor,\n environment: input.environment,\n parameters: input.parameters,\n presentation: input.presentation,\n approvalUrl: input.approvalUrl,\n expiresInSeconds: input.expiresInSeconds,\n requireReachable: input.requireReachable,\n placeholder: input.placeholder,\n callbackUrl: input.callbackUrl,\n})\n\n/** Fail-closed yes/no check for a confirm answer. */\nexport const isAffirmative = (answer: string | null | undefined): boolean =>\n isApproved({ status: 'answered', type: 'confirm', value: answer ?? null })\n\n/** Turn a decision outcome into an unambiguous instruction for the model. */\nexport const describeAnswer = (type: DecisionType, result: AskResult): string => {\n if (!result.answered) {\n return `No answer (status: ${result.status}). Treat this as NOT approved and do not proceed.`\n }\n if (type === 'confirm') {\n return result.approved\n ? 'The human approved. You may proceed.'\n : 'The human declined. Do not proceed.'\n }\n return `The human answered: ${result.value ?? ''}`\n}\n\n/**\n * Verify a callback signature and parse it, or return null. The `answer` is what\n * you feed back into the framework's resume seam.\n */\nexport const resolvePusharyCallback = (\n rawBody: string,\n signature: string | null | undefined,\n secret: string,\n): PusharyCallback | null => {\n if (!verifyWebhookSignature(rawBody, signature, secret)) return null\n const callback = parseDecisionCallback(rawBody)\n if (!callback) return null\n return {\n correlationId: callback.correlationId,\n answer: callback.answer,\n value: callback.value,\n approved: isAffirmative(callback.answer),\n context: callback.context,\n answeredAt: callback.answeredAt,\n }\n}\n\n/**\n * Connection-level options for a request-time approval gate. Everything that\n * varies per tool call lives on {@link ApprovalAsk} instead, so a gate holds one\n * client and no per-call state.\n */\nexport interface PusharyGateConfig {\n /** Pushary API key. Defaults to `process.env.PUSHARY_API_KEY`. */\n readonly apiKey?: string\n /** Shown on the approval so the human knows which agent is asking. */\n readonly agentName?: string\n /** How long the decision stays answerable. */\n readonly expiresInSeconds?: number\n /** How long to block waiting for an answer before failing closed. */\n readonly timeoutMs?: number\n /**\n * Refuse to open a decision nobody can receive, so an end-user with no connected\n * device is denied at request time instead of silently expiring.\n */\n readonly requireReachable?: boolean\n /**\n * Ask the site's policy before asking a person (default true). A rule that names\n * the action resolves it without paging anyone; everything else still asks.\n *\n * Set false to restore the always-ask gate: a site whose rules were written for\n * its own coding agents, and which happens to name a tool this gate also sits\n * on, would otherwise start resolving that tool automatically.\n */\n readonly policy?: boolean\n /** Override the API base URL (tests / self-host). */\n readonly baseUrl?: string\n}\n\n/**\n * One gated tool call, resolved down to what Pushary needs. The adapter owns the\n * mapping from its framework's context, so per-call resolvers stay typed against\n * the framework's own shapes rather than a lowest common denominator.\n */\nexport interface ApprovalAsk {\n /** The tool the model wants to run. */\n readonly toolName: string\n /** Unique per tool call. Keys the decision together with `sessionId`. */\n readonly callId: string\n /** Stable per run, session, or thread. */\n readonly sessionId: string\n /** What the human is shown. */\n readonly question: string\n /** The enrolled end-user who decides. */\n readonly externalId: string\n /** What the tool acts on, e.g. an order id. Narrows which rule governs. */\n readonly toolTarget?: string\n /** Whose authority the action claims, e.g. `user:u_44`. Not the approver. */\n readonly actor?: string\n /** Which deployment the action runs against, e.g. `production`. */\n readonly environment?: string\n /**\n * The tool's raw input. Bounded scalars are derived from it so a rule can decide\n * on the arguments and not only on the name; anything the bounds cannot carry\n * sends nothing, which escalates. Already rendered into the default question, so\n * passing it exposes nothing new.\n */\n readonly input?: unknown\n /** Explicit facts for policy, used instead of deriving them from `input`. */\n readonly parameters?: DecisionParameters\n readonly presentation?: DecisionSubject['presentation']\n readonly context?: string\n}\n\n/**\n * What a gate answers. Denials always carry a reason the model can read.\n *\n * An approval also carries the binding: which authorization settled it and the\n * exact subject it was recorded against. Carried rather than rebuilt, because\n * only the gate knows what it actually sent — the ask records the full subject\n * only when the evaluation succeeded — and a caller that rebuilds it would send\n * a subject the record does not hold and be refused for its own approval.\n * Absent only from a server too old to name the authorization.\n */\nexport type ApprovalDecision =\n | { readonly approved: true; readonly authorization?: AuthorizationBinding }\n | { readonly approved: false; readonly reason: string }\n\n/**\n * A request-time gate: ask the site's policy, ask a person when policy defers,\n * fail closed either way.\n */\nexport type ApprovalGate = (ask: ApprovalAsk) => Promise<ApprovalDecision>\n\nconst MAX_INPUT_CHARS = 300\n\nexport const DENIED_UNANSWERED =\n 'No answer from the approver, so this was denied. Do not retry the same action.'\nexport const DENIED_REFUSED = 'The approver denied this action.'\nexport const DENIED_BY_POLICY = 'Denied by policy. Do not retry the same action.'\n\nexport const UNBOUND_APPROVAL =\n '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.'\n\nexport const UNCONFIRMED_PERMIT =\n 'This was approved, but it could not be confirmed as still unspent, so it was not run. Ask again rather than retrying this one.'\n\nconst REFUSED_REPLAY =\n 'This authorization was already used. The action has run once and must not run again.'\n\n/**\n * A refused permit, as the model reads it.\n *\n * `already_consumed` is worded to stop the caller rather than to invite a retry,\n * because a retry is the failure: something already ran this action once, and\n * the agent reading this cannot see that from its own state.\n */\nexport const refusedPermit = (\n refused: Extract<ConsumeAuthorizationResult, { consumed: false }>,\n): string => (refused.refusal === 'already_consumed' ? REFUSED_REPLAY : `${refused.reason} Do not retry the same action.`)\n\n/**\n * A policy denial, as the model reads it. The server already words the reason\n * (\"Denied by policy rule refund.create.\"); the retry instruction is added here so\n * every denial this gate returns ends the same way, whoever settled it.\n */\nexport const deniedByPolicy = (evaluation: AuthorizationEvaluation): string =>\n evaluation.reason ? `${evaluation.reason} Do not retry the same action.` : DENIED_BY_POLICY\n\nconst PARAMETERS_MAX_KEYS = 32\nconst PARAMETER_KEY_MAX_LENGTH = 64\nconst PARAMETER_VALUE_MAX_LENGTH = 200\n\n/**\n * Bounded facts for policy, derived from a tool's own input.\n *\n * All or nothing, deliberately. The bounds mirror what the API accepts, so a\n * partial object is always representable — and that is exactly the danger: a rule\n * reading `amount >= 500` stops denying the moment `amount` goes missing, so\n * dropping one entry can only ever move a verdict toward allow. Sending nothing\n * instead leaves the rule's parameter absent, which escalates to a person.\n *\n * The same bounds also mean this never turns a working tool call into a rejected\n * one: an input this cannot carry is simply not sent.\n */\nexport const deriveParameters = (input: unknown): DecisionParameters | undefined => {\n if (input === null || typeof input !== 'object' || Array.isArray(input)) return undefined\n const entries = Object.entries(input as Record<string, unknown>)\n if (entries.length === 0 || entries.length > PARAMETERS_MAX_KEYS) return undefined\n\n const parameters: Record<string, string | number | boolean> = {}\n for (const [key, value] of entries) {\n if (key.length === 0 || key.length > PARAMETER_KEY_MAX_LENGTH) return undefined\n if (typeof value === 'boolean') {\n parameters[key] = value\n } else if (typeof value === 'number') {\n if (!Number.isFinite(value)) return undefined\n parameters[key] = value\n } else if (typeof value === 'string') {\n if (value.length > PARAMETER_VALUE_MAX_LENGTH) return undefined\n parameters[key] = value\n } else {\n return undefined\n }\n }\n return parameters\n}\n\n/**\n * The default question: the tool name plus a truncated view of its input, so the\n * human sees what they are approving without a wall of JSON on a lock screen.\n */\nexport const renderApprovalQuestion = (toolName: string, toolInput: unknown): string => {\n if (toolInput === undefined || toolInput === null) return `Approve ${toolName}?`\n const summary = summarise(toolInput)\n return summary === undefined ? `Approve ${toolName}?` : `Approve ${toolName}? ${summary}`\n}\n\nconst summarise = (toolInput: unknown): string | undefined => {\n let rendered: string | undefined\n if (typeof toolInput === 'string') {\n rendered = toolInput\n } else {\n try {\n // Returns undefined for a function or a symbol, and throws on a cycle. Neither\n // is a reason to fail the tool call: a gate that cannot render its input still\n // has to ask, so fall back to the bare tool name.\n rendered = JSON.stringify(toolInput)\n } catch {\n return undefined\n }\n }\n if (rendered === undefined) return undefined\n return rendered.length > MAX_INPUT_CHARS ? `${rendered.slice(0, MAX_INPUT_CHARS)}...` : rendered\n}\n\n\n/**\n * One protected action: what it is, who it belongs to, and what it does.\n *\n * The shape a developer writes once instead of threading authorize, escalate and\n * run through their business code by hand. Everything above `run` is the same\n * subject `authorize()` and the decision both read, so the rule that governs the\n * action and the sentence the human sees describe the one thing.\n */\nexport interface ProtectedAction<TResult> {\n /** What is being done, e.g. `refund.create`. A rule has to name this. */\n readonly action: string\n /** What it is done to, e.g. an order id. Narrows which rule governs. */\n readonly target?: string\n /** The end-user this action belongs to, and who decides if policy defers. */\n readonly externalId: string\n /** Whose authority the action claims, e.g. `user:u_44`. Not the approver. */\n readonly actor?: string\n /** Which deployment it runs against, e.g. `production`. */\n readonly environment?: string\n /**\n * The bounded facts a rule may decide on, e.g. `{ amount: 4800 }`. Scalars\n * only. Passed as given rather than derived, because a protected action is\n * written by hand and the author knows which of its arguments matter.\n */\n readonly facts?: DecisionParameters\n /** Unique per attempt. Keys the decision together with `runId`. */\n readonly callId: string\n /** Stable per run, session or thread. */\n readonly runId: string\n /** What the human is shown. Defaults to the action and its target. */\n readonly question?: string\n /**\n * The side effect. Called only after the action is authorized, and never on a\n * denial or an unanswered escalation.\n */\n readonly run: () => Promise<TResult>\n}\n\n/**\n * What a protected action did.\n *\n * A discriminated union rather than a thrown error, because \"policy said no\" is\n * an ordinary outcome an agent has to be able to read and report, not an\n * exception. An error thrown by `run` itself is NOT caught: that is the caller's\n * failure, about their side effect, and swallowing it here would turn a failed\n * refund into a silent success.\n */\nexport type ProtectResult<TResult> =\n | { readonly ok: true; readonly result: TResult }\n | { readonly ok: false; readonly reason: string }\n\n/** Protect one action. Build it once per configuration, call it per action. */\nexport type Protect = <TResult>(action: ProtectedAction<TResult>) => Promise<ProtectResult<TResult>>\n\n/**\n * The calls an adapter makes, bound to one framework's name so a missing key or a\n * missing end-user says which helpers to configure. Build one per adapter module.\n */\nexport interface AdapterKernel {\n /** The underlying client, for anything the kernel does not cover. */\n readonly client: (config: PusharyAdapterConfig) => PusharyServer\n /**\n * Blocking ask: create a decision and poll durably until the human answers or\n * the deadline passes. A fresh key is used unless the caller supplies an operation-specific idempotencyKey.\n */\n readonly askExternalUser: (\n config: PusharyAdapterConfig,\n input: AskHumanInput,\n ) => Promise<AskResult>\n /**\n * Durable create: open a decision with a `callbackUrl` and return at once, for\n * a framework that parks its run and resumes on the webhook.\n */\n readonly createDurableDecision: (\n config: PusharyAdapterConfig,\n input: AskHumanInput,\n ) => Promise<CreatedDecision>\n /** Connect one end-user's phone (keyless). Show them the returned link. */\n readonly connect: (config: PusharyAdapterConfig, externalId: string) => Promise<EnrollResult>\n /**\n * Build a request-time approval gate. The client is created here, so a missing\n * key throws where the gate is defined rather than on the first tool call.\n */\n readonly createGate: (config: PusharyGateConfig) => ApprovalGate\n /**\n * Wrap one action so authorization, escalation and execution are one call.\n *\n * The whole of it is `createGate` plus \"run it if the answer was yes\", which is\n * the point: there is no second policy engine, no durable runtime and no new\n * abstraction under this. An adapter binds it by mapping its framework's\n * context onto ProtectedAction, exactly as it already does for the gate.\n *\n * At most once. The decision is idempotent — a replay of the same\n * `runId` + `callId` + action resolves to the same approval instead of asking\n * twice — and the approval is then spent against a durable permit before the\n * side effect runs. A retry, a concurrent worker and a resumed run all reach\n * the same permit, and exactly one of them proceeds; the rest are refused with\n * a reason that tells the model to stop rather than try again.\n *\n * `run` is called only after the permit is spent, never before, so the failure\n * mode of a crash mid-action is a permit with no receipt — which is visible —\n * rather than an action that runs twice, which is not recoverable.\n */\n readonly protect: (config: PusharyGateConfig) => Protect\n /**\n * The end-user to ask, or a clear error naming these helpers. Adapters call this\n * after applying their own resolver and the framework's own default.\n */\n readonly requireExternalId: (externalId: string | undefined) => string\n}\n\n/**\n * Build the kernel for one adapter.\n *\n * `helpersLabel` names the adapter in the missing-key error, so the message points\n * at the helpers the caller is actually using (\"the LangGraph helpers\").\n *\n * ```ts\n * const kernel = createAdapterKernel('the Acme helpers')\n * export const askHuman = kernel.askExternalUser\n * ```\n */\nexport const createAdapterKernel = (helpersLabel: string): AdapterKernel => {\n const requireApiKey = (apiKey: string | undefined): string => {\n const resolved = apiKey ?? process.env.PUSHARY_API_KEY\n if (!resolved) {\n throw new Error(`Pushary: set PUSHARY_API_KEY or pass { apiKey } to ${helpersLabel}.`)\n }\n return resolved\n }\n\n const client = (config: PusharyAdapterConfig): PusharyServer =>\n createPusharyServer({\n apiKey: requireApiKey(config.apiKey),\n ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),\n })\n\n const requireExternalId = (externalId: string | undefined): string => {\n if (!externalId?.trim()) {\n throw new Error(\n `Pushary: no end-user to ask. Pass { externalId } to ${helpersLabel}, or run ` +\n 'user-scoped auth so the framework supplies one.',\n )\n }\n if (externalId.length > 256) throw new Error('Pushary: externalId must not exceed 256 characters.')\n return externalId\n }\n\n const createGate = (config: PusharyGateConfig): ApprovalGate => {\n const gateClient = createPusharyServer({\n apiKey: requireApiKey(config.apiKey),\n ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),\n })\n const usePolicy = config.policy ?? true\n\n /**\n * The verdict, or null if there is no usable one.\n *\n * Every failure is null rather than a throw. This route is entitled to the\n * Partner plan and is newer than the published adapters, so a caller who\n * cannot reach it — wrong plan, older deployment, a network blip — has to keep\n * working exactly as it did before policy existed, which is by asking a person.\n * Null is never allow, so the fail-safe direction is preserved.\n */\n const evaluate = async (\n ask: ApprovalAsk,\n facts: DecisionParameters | undefined,\n ): Promise<AuthorizationEvaluation | null> => {\n if (!usePolicy) return null\n try {\n return await gateClient.evaluateAuthorization({\n toolName: ask.toolName,\n toolTarget: ask.toolTarget,\n actor: ask.actor,\n environment: ask.environment,\n parameters: facts,\n externalId: ask.externalId,\n question: ask.question,\n ...(config.agentName ? { agentName: config.agentName } : {}),\n })\n } catch {\n return null\n }\n }\n\n return async (ask: ApprovalAsk): Promise<ApprovalDecision> => {\n if (!ask.callId?.trim()) throw new Error('Pushary: a stable tool call ID is required to resolve an approval.')\n requireExternalId(ask.externalId)\n // Derived once. The evaluation and the ask that may follow have to describe\n // the same action, and computing it twice is two chances for them not to.\n const facts = ask.parameters ?? deriveParameters(ask.input)\n const idempotencyKey = decisionFingerprint({ ...ask, parameters: facts })\n const evaluation = await evaluate(ask, facts)\n const includeSubject = evaluation !== null || !usePolicy\n if (evaluation?.verdict === 'allow') {\n return {\n approved: true,\n // Exactly the subject POST /authorize recorded, which is the whole of\n // what it was sent.\n ...(evaluation.authorizationId\n ? {\n authorization: {\n authorizationId: evaluation.authorizationId,\n toolName: ask.toolName,\n toolTarget: ask.toolTarget,\n actor: ask.actor,\n environment: ask.environment,\n externalId: ask.externalId,\n parameters: facts,\n },\n }\n : {}),\n }\n }\n if (evaluation?.verdict === 'deny') {\n return { approved: false, reason: deniedByPolicy(evaluation) }\n }\n\n const result = await gateClient.decisions.ask({\n question: ask.question,\n type: 'confirm',\n externalId: ask.externalId,\n context: ask.context,\n ...(config.agentName ? { agentName: config.agentName } : {}),\n ...(config.expiresInSeconds ? { expiresInSeconds: config.expiresInSeconds } : {}),\n ...(config.timeoutMs !== undefined ? { timeoutMs: config.timeoutMs } : {}),\n ...(config.requireReachable ? { requireReachable: config.requireReachable } : {}),\n // The gated tool, recorded on the decision. The gate already knows it\n // and keyed on it below; sending it is what lets the decision be grouped\n // and matched later instead of surviving only as prose in the question.\n toolName: ask.toolName,\n ...(includeSubject\n ? {\n presentation: ask.presentation,\n toolTarget: ask.toolTarget,\n actor: ask.actor,\n environment: ask.environment,\n parameters: facts,\n }\n : {}),\n // Stable across replays, so a re-run of this call resolves to the same\n // decision instead of asking twice.\n idempotencyKey,\n })\n\n if (result.approved) {\n return {\n approved: true,\n authorization: {\n authorizationId: result.decisionId,\n toolName: ask.toolName,\n externalId: ask.externalId,\n ...(includeSubject\n ? {\n toolTarget: ask.toolTarget,\n actor: ask.actor,\n environment: ask.environment,\n parameters: facts,\n }\n : {}),\n },\n }\n }\n return { approved: false, reason: result.answered ? DENIED_REFUSED : DENIED_UNANSWERED }\n }\n }\n\n const askExternalUser = (\n config: PusharyAdapterConfig,\n input: AskHumanInput,\n ): Promise<AskResult> =>\n client(config).decisions.ask({\n ...decisionSubject(input),\n question: input.question,\n type: input.type,\n options: input.options,\n externalId: requireExternalId(input.externalId),\n context: input.context,\n agentName: config.agentName,\n timeoutMs: config.timeoutMs,\n // The node/step name doubles as the action label. The default is not\n // sent: grouping every unnamed ask across every framework under one\n // bucket would mine into a suggestion to auto-approve \"ask-human\", which\n // spans unrelated questions.\n toolName: input.toolName ?? input.node,\n idempotencyKey: idempotencyKeyFor(input),\n })\n\n const createDurableDecision = async (\n config: PusharyAdapterConfig,\n input: AskHumanInput,\n ): Promise<CreatedDecision> => {\n if (!input.idempotencyKey?.trim()) {\n throw new Error('Pushary: durable decisions require an idempotencyKey tied to the run and step.')\n }\n const created = await client(config).decisions.create({\n ...decisionSubject(input),\n question: input.question,\n type: input.type,\n options: input.options,\n externalId: requireExternalId(input.externalId),\n context: input.context,\n callbackUrl: input.callbackUrl,\n agentName: config.agentName,\n toolName: input.toolName ?? input.node,\n idempotencyKey: idempotencyKeyFor(input),\n wait: false,\n })\n return {\n decisionId: created.decisionId,\n correlationId: created.decisionId,\n status: created.status,\n reachable: created.reachable,\n reachableChannels: created.reachableChannels,\n deviceCount: created.deviceCount,\n }\n }\n\n const connect = (config: PusharyAdapterConfig, externalId: string): Promise<EnrollResult> =>\n client(config).enroll(requireExternalId(externalId))\n\n const protect = (config: PusharyGateConfig): Protect => {\n // One gate per configuration, not per action: it holds the client, so\n // building it here means a missing key throws where protect() is defined\n // rather than on the first refund somebody tries to issue.\n const gate = createGate(config)\n const permitClient = client(config)\n\n // The receipt is evidence about an action that has already happened. Failing\n // the caller because the evidence could not be written would turn a\n // successful refund into an error and invite them to issue it again, which\n // is the exact outcome this whole path exists to prevent. A permit left\n // `running` is the honest record, and it is queryable.\n const report = async (permitId: string, outcome: ExecutionOutcome, summary?: string) => {\n try {\n await permitClient.recordExecution({ permitId, outcome, summary })\n } catch {\n // Deliberately swallowed. See above.\n }\n }\n\n return async <TResult>(action: ProtectedAction<TResult>): Promise<ProtectResult<TResult>> => {\n const decision = await gate({\n toolName: action.action,\n toolTarget: action.target,\n callId: action.callId,\n sessionId: action.runId,\n question: action.question ?? defaultProtectQuestion(action.action, action.target),\n externalId: action.externalId,\n actor: action.actor,\n environment: action.environment,\n parameters: action.facts,\n })\n\n if (!decision.approved) return { ok: false, reason: decision.reason }\n if (!decision.authorization) return { ok: false, reason: UNBOUND_APPROVAL }\n\n // Spent before the side effect, never after. A permit consumed after a\n // successful run would leave the window this exists to close: the crash\n // between the two would lose the record that it ran, and the retry would\n // run it again.\n let consumed\n try {\n consumed = await permitClient.consumeAuthorization(decision.authorization)\n } catch {\n // The action was approved and we could not confirm it is still ours to\n // run. Not running is the only safe answer; a duplicate refund cannot be\n // taken back and an unissued one can be asked for again.\n return { ok: false, reason: UNCONFIRMED_PERMIT }\n }\n if (!consumed.consumed) return { ok: false, reason: refusedPermit(consumed) }\n\n const permitId = consumed.permit.permitId\n let result: TResult\n try {\n result = await action.run()\n } catch (error) {\n // Recorded and rethrown, not swallowed. An error from the side effect is\n // the caller's, and reporting it as `ok: false` would be\n // indistinguishable from a refusal to an agent reading the result.\n await report(permitId, 'failed', error instanceof Error ? error.message : 'unknown error')\n throw error\n }\n await report(permitId, 'succeeded')\n return { ok: true, result }\n }\n }\n\n return Object.freeze({\n client,\n askExternalUser,\n createDurableDecision,\n connect,\n createGate,\n protect,\n requireExternalId,\n })\n}\n\nexport { SIGNATURE_HEADER, verifyWebhookSignature, parseDecisionCallback } from './webhook'\nexport { decisionFingerprint, deterministicKey, isApproved } from './util'\nexport type {\n AskResult,\n AuthorizationEvaluation,\n AuthorizationVerdict,\n DecisionParameters,\n DecisionType,\n EnrollResult,\n PusharyServer,\n}\n"],"mappings":"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","names":[]}
@@ -26,6 +26,25 @@ var PusharyApiError = class _PusharyApiError extends Error {
26
26
  // src/util.ts
27
27
  import { createHash } from "crypto";
28
28
  var deterministicKey = (parts) => createHash("sha256").update(parts.join("\0")).digest("hex").slice(0, 40);
29
+ var decisionFingerprint = (value) => {
30
+ const ancestors = /* @__PURE__ */ new Set();
31
+ const serialize = (item) => {
32
+ if (item === null || typeof item === "string" || typeof item === "boolean") return JSON.stringify(item);
33
+ if (typeof item === "number" && Number.isFinite(item)) return JSON.stringify(item);
34
+ if (typeof item !== "object" || ancestors.has(item)) {
35
+ throw new TypeError("Pushary: decision identity must contain finite, acyclic JSON values.");
36
+ }
37
+ if (!Array.isArray(item) && Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null) {
38
+ throw new TypeError("Pushary: decision identity must contain plain JSON objects.");
39
+ }
40
+ ancestors.add(item);
41
+ const entries = Object.entries(item).filter(([, entry]) => entry !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
42
+ const serialized = Array.isArray(item) ? `[${Array.from(item, serialize).join(",")}]` : `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${serialize(entry)}`).join(",")}}`;
43
+ ancestors.delete(item);
44
+ return serialized;
45
+ };
46
+ return createHash("sha256").update(serialize(value)).digest("hex");
47
+ };
29
48
  var AFFIRMATIVE = /* @__PURE__ */ new Set([
30
49
  "yes",
31
50
  "y",
@@ -67,11 +86,12 @@ var handleResponse = async (response) => {
67
86
  }
68
87
  return response.json();
69
88
  };
70
- var createRequest = (ctx) => async (method, path, body) => {
89
+ var createRequest = (ctx) => async (method, path, body, options) => {
71
90
  const isGet = method === "GET";
72
91
  const url = isGet && body ? buildUrl(ctx.baseUrl, path, body) : `${ctx.baseUrl}${path}`;
73
92
  const response = await fetch(url, {
74
93
  method,
94
+ signal: options?.signal ?? AbortSignal.timeout(ctx.timeoutMs ?? 65e3),
75
95
  headers: ctx.headers,
76
96
  body: !isGet && body ? JSON.stringify(body) : void 0
77
97
  });
@@ -132,64 +152,80 @@ var DEFAULT_ASK_TIMEOUT_MS = 55e3;
132
152
  var createDecisionsResource = (request) => {
133
153
  const ask = async (data) => {
134
154
  const idempotencyKey = data.idempotencyKey ?? randomUUID();
135
- const created = await request("POST", "/decisions", {
136
- question: data.question,
137
- type: data.type,
138
- options: data.options,
139
- externalId: data.externalId,
140
- email: data.email,
141
- agentName: data.agentName,
142
- context: data.context,
143
- placeholder: data.placeholder,
144
- toolName: data.toolName,
145
- toolTarget: data.toolTarget,
146
- actor: data.actor,
147
- environment: data.environment,
148
- parameters: data.parameters,
149
- // Its changes name parameters rather than carrying values, so what the
150
- // person sees is read from the same numbers a rule compares.
151
- presentation: data.presentation,
152
- approvalUrl: data.approvalUrl,
153
- expiresInSeconds: data.expiresInSeconds,
154
- poweredBy: data.poweredBy,
155
- callbackUrl: data.callbackUrl,
156
- requireReachable: data.requireReachable,
157
- // Read only by a key issued on your workspace's sandbox site, where the
158
- // simulator answers instead of a person. Ignored in production.
159
- sandboxOutcome: data.sandboxOutcome,
160
- idempotencyKey,
161
- wait: false
162
- });
163
- const type = created.type ?? data.type ?? "confirm";
164
- let status = created.status;
165
- let answered = created.answered;
166
- let value = created.value ?? null;
167
- const deadline = Date.now() + Math.max(0, data.timeoutMs ?? DEFAULT_ASK_TIMEOUT_MS);
168
- while (status === "pending" && Date.now() < deadline) {
169
- const waitSeconds = Math.max(
170
- 1,
171
- Math.min(POLL_WINDOW_SECONDS, Math.ceil((deadline - Date.now()) / 1e3))
172
- );
173
- const polled = await request("GET", `/decisions/${created.decisionId}`, {
174
- wait: waitSeconds
175
- });
176
- status = polled.status;
177
- answered = polled.answered;
178
- value = polled.value;
155
+ const timeoutMs = Math.max(0, data.timeoutMs ?? DEFAULT_ASK_TIMEOUT_MS);
156
+ const deadline = Date.now() + timeoutMs;
157
+ const controller = new AbortController();
158
+ const abort = () => controller.abort(data.signal?.reason);
159
+ if (data.signal?.aborted) abort();
160
+ else data.signal?.addEventListener("abort", abort, { once: true });
161
+ const timer = setTimeout(() => controller.abort(new DOMException("Pushary ask timed out", "TimeoutError")), timeoutMs || 65e3);
162
+ try {
163
+ const created = await request("POST", "/decisions", {
164
+ question: data.question,
165
+ type: data.type,
166
+ options: data.options,
167
+ externalId: data.externalId,
168
+ email: data.email,
169
+ agentName: data.agentName,
170
+ context: data.context,
171
+ placeholder: data.placeholder,
172
+ toolName: data.toolName,
173
+ toolTarget: data.toolTarget,
174
+ actor: data.actor,
175
+ environment: data.environment,
176
+ parameters: data.parameters,
177
+ // Its changes name parameters rather than carrying values, so what the
178
+ // person sees is read from the same numbers a rule compares.
179
+ presentation: data.presentation,
180
+ approvalUrl: data.approvalUrl,
181
+ expiresInSeconds: data.expiresInSeconds,
182
+ poweredBy: data.poweredBy,
183
+ callbackUrl: data.callbackUrl,
184
+ requireReachable: data.requireReachable,
185
+ // Read only by a key issued on your workspace's sandbox site, where the
186
+ // simulator answers instead of a person. Ignored in production.
187
+ sandboxOutcome: data.sandboxOutcome,
188
+ idempotencyKey,
189
+ wait: false
190
+ }, { signal: controller.signal });
191
+ const type = created.type ?? data.type ?? "confirm";
192
+ let status = created.status;
193
+ let answered = created.answered;
194
+ let value = created.value ?? null;
195
+ while (status === "pending" && Date.now() < deadline) {
196
+ const waitSeconds = Math.max(
197
+ 1,
198
+ Math.min(POLL_WINDOW_SECONDS, Math.ceil((deadline - Date.now()) / 1e3))
199
+ );
200
+ try {
201
+ const polled = await request("GET", `/decisions/${created.decisionId}`, {
202
+ wait: waitSeconds
203
+ }, { signal: controller.signal });
204
+ status = polled.status;
205
+ answered = polled.answered;
206
+ value = polled.value;
207
+ } catch (error) {
208
+ if (controller.signal.aborted && !data.signal?.aborted) break;
209
+ throw error;
210
+ }
211
+ }
212
+ return {
213
+ decisionId: created.decisionId,
214
+ status,
215
+ answered,
216
+ value,
217
+ type,
218
+ approved: isApproved({ status, type, value }),
219
+ // Surfaced from the create response so a caller can tell an unanswered
220
+ // decision that reached nobody (reachable:false) apart from a real decline.
221
+ reachable: created.reachable,
222
+ reachableChannels: created.reachableChannels,
223
+ deviceCount: created.deviceCount
224
+ };
225
+ } finally {
226
+ clearTimeout(timer);
227
+ data.signal?.removeEventListener("abort", abort);
179
228
  }
180
- return {
181
- decisionId: created.decisionId,
182
- status,
183
- answered,
184
- value,
185
- type,
186
- approved: isApproved({ status, type, value }),
187
- // Surfaced from the create response so a caller can tell an unanswered
188
- // decision that reached nobody (reachable:false) apart from a real decline.
189
- reachable: created.reachable,
190
- reachableChannels: created.reachableChannels,
191
- deviceCount: created.deviceCount
192
- };
193
229
  };
194
230
  return Object.freeze({
195
231
  create: (data) => request("POST", "/decisions", data),
@@ -319,6 +355,7 @@ var createContext = (config) => {
319
355
  validateApiKey(config.apiKey);
320
356
  return Object.freeze({
321
357
  baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
358
+ timeoutMs: config.requestTimeoutMs ?? 65e3,
322
359
  headers: Object.freeze({
323
360
  "Content-Type": "application/json",
324
361
  "Authorization": `Bearer ${config.apiKey}`
@@ -379,10 +416,11 @@ var parseDecisionCallback = (rawBody) => {
379
416
  export {
380
417
  PusharyApiError,
381
418
  deterministicKey,
419
+ decisionFingerprint,
382
420
  isApproved,
383
421
  createPusharyServer,
384
422
  SIGNATURE_HEADER,
385
423
  verifyWebhookSignature,
386
424
  parseDecisionCallback
387
425
  };
388
- //# sourceMappingURL=chunk-E6DWMKLZ.js.map
426
+ //# sourceMappingURL=chunk-AG6DI4RF.js.map