跳到正文
DEV Community· awab alomran·· 2 小時前AI 評分59

TypeScript 教學:以簽名、冪等機制與限額處理 AI 智能體退款

Handling AI agent refunds without paying twice: signatures, idempotency and limits in TypeScript

AI 導讀

作者以 TypeScript 示範 AI 智能體退款處理,透過簽名驗證、報價與執行分離、冪等操作及按客戶累計的限額,應對偽造請求、重複退款與限額繞過。簽名須在解析 JSON 前驗證原始請求本文,並設時間窗口;執行則以同一 operation ID 重試,付款提供方也要使用冪等鍵。

正文

AI assistants are starting to call businesses' systems on behalf of their users: "return the blue jacket", "move my appointment to Thursday". Reading an order is harmless. A refund moves money, and that's where the interesting engineering is.

Here are the four failure modes I'd design for first, with the TypeScript I use for each. The examples use allowfy, a small open-source package (zero dependencies, Web Request/Response), but the patterns apply to any webhook-based integration.

1. Forged or replayed requests

Your refund endpoint will be reachable from the internet. Every request must prove it came from your gateway, and be recent.

The scheme: Allowfy-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">. Verify over the raw body, before any JSON parsing, with a constant-time comparison and a time window:

import { verifySignature } from "allowfy";

const raw = await request.text(); // raw, not request.json()
const ok = await verifySignature(process.env.ALLOWFY_WEBHOOK_SECRET!, raw, request.headers.get("allowfy-signature"));
if (!ok) return new Response("bad signature", { status: 401 });

The timestamp inside the signed payload is what stops replays: an attacker who captured a valid request can't send it again ten minutes later.

2. "Quote" and "execute" must be different calls

Never let an agent's request go straight to the refund. Split it:

  • quote: compute the exact terms (this order, this amount, this method). Change nothing.
  • the customer approves those exact terms; your rules decide if your team must review too;
  • execute: do it, with the approved terms.
import { createHandler, refuse } from "allowfy";

export const POST = createHandler({
  secret: process.env.ALLOWFY_WEBHOOK_SECRET!,
  quote: {
    async "orders.return"({ order_id }, { customerRef }) {
      const o = await db.order(order_id, customerRef);
      if (!o) refuse("not_found", "Order not found", 404);
      if (o.status !== "paid") refuse("not_returnable", "This order can't be returned");
      return {
        terms: { order_id: o.id, refund_minor: o.totalMinor, currency: o.currency },
        summary: `Return ${o.item} for a ${o.totalMinor / 100} ${o.currency} refund`,
        amountMinor: o.totalMinor, // lets amount rules apply
        currency: o.currency,
      };
    },
  },
  // execute: below
});

If the terms change after approval (a price update, a different order), the approval no longer applies and a new one is needed. That rule lives in the gateway, but your quote is what makes the terms explicit.

3. The refund that ran, but the answer got lost

The classic: your system issues the refund, then the connection drops before the gateway hears back. A naive retry refunds twice.

Two rules fix it:

  1. Every execution carries a stable operation ID (also sent as Idempotency-Key). Do the work at most once per ID, and answer the same outcome on a retry.
  2. Anything that isn't a clear answer (5xx, timeout) means unknown, never "failed". The gateway retries with the same ID, and after the last attempt a person settles it.
execute: {
  async "orders.return"(terms, { operationId }) {
    // A unique index on returns.operation_id turns a retry into a no-op.
    const inserted = await db.returns.insertIfAbsent({ operation_id: operationId, order_id: terms.order_id, refund_minor: terms.refund_minor });
    if (inserted) await payments.refund(terms.order_id, terms.refund_minor, { idempotencyKey: operationId });
    return { summary: `Order ${terms.order_id} returned; refund on its way` };
  },
},

Note the idempotency key passed on to the payment provider too: the guarantee has to hold all the way down.

refuse() is for deliberate "no" answers (already returned in store): nothing changed, and the customer sees your message. Any other thrown error becomes a 500, which means "unknown, retry".

4. Splitting: $300 as three $99 refunds

A per-request limit ("refunds up to $100 need only the customer's approval") is trivial to get around. Limits need a time dimension, per customer, across all agents:

{
  "action": "orders.return",
  "decision": "user_approval",
  "conditions": { "maxAmountMinor": 10000, "maxTotalMinor": 15000, "maxCount": 3, "windowDays": 7 },
  "otherwise": "business_review"
}

Two implementation details matter:

  • Count across agents. Switching assistants must not reset the counter.
  • Serialize per customer. Two simultaneous requests must not both slip under the limit. A transaction-scoped advisory lock keyed on the customer does it in Postgres: SELECT pg_advisory_xact_lock(hashtextextended('limit:' || $1, 0)).

Wiring it up

On Supabase (Deno), the whole handler is one call:

import { createHandler } from "npm:allowfy";
Deno.serve(createHandler({ secret: Deno.env.get("ALLOWFY_WEBHOOK_SECRET")!, authenticate, read, quote, execute }));

On Express, mount it before any JSON body parser (the signature covers the raw body):

import { createHandler, toNodeListener } from "allowfy";
app.post("/allowfy", toNodeListener(createHandler({ secret, authenticate, read, quote, execute })));

Checklist

  • [ ] Signatures verified on the raw body, with a time window
  • [ ] Quote and execute are separate; quote changes nothing
  • [ ] Execute is idempotent per operation ID, all the way to the payment provider
  • [ ] Unknown outcomes are never reported as failed
  • [ ] Limits per customer over time, across agents, serialized
  • [ ] Every step recorded somewhere you can export

The gateway side of this (agent identity with signed keys, DPoP-bound tokens, customer sign-in, rules, approvals, a signed ledger, an emergency stop) is what I'm building at Allowfy. The longer, non-code version of this post is on the Allowfy blog. And if you want to see how an AI agent sees your own domain today, Allowfy Check is free and open source.

Questions, or failure modes I missed? I'd like to hear them in the comments.

來源:DEV Community · dev.to