返回日报条目

资料节译

用 TypeScript 安全处理 AI 代理退款:签名、幂等与限额

阅读原文

AI 助手开始代表用户调用企业系统:“退掉那件蓝色夹克”“把我的预约改到周四”。读取订单无伤大雅。退款会转移资金,这才是有意思的工程问题。

以下是我会优先考虑的四种故障模式,以及我为每种情况使用的 TypeScript。示例使用 allowfy,这是一个小型开源软件包(零依赖,适用于 Web Request/Response),但这些模式适用于任何基于 webhook 的集成。

你的退款端点会暴露在互联网上。每个请求都必须证明它来自你的网关,而且是近期发出的。

方案:Allowfy-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">。在解析任何 JSON 之前,针对原始请求体进行验证,使用恒定时间比较和时间窗口:

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 });

签名载荷中的时间戳可以阻止重放:攻击者即使截获了有效请求,也无法在十分钟后再次发送它。

不要让智能体的请求直接触发退款。把流程拆开:

报价:计算出精确条款(这个订单、这个金额、这种方式)。不要做任何改动。

执行:按照已批准的条款办理。

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
});

如果批准后条款发生变化(例如价格更新、订单不同),原批准便不再适用,需要重新批准。该规则由网关负责执行,而你的 quote 会把条款明确下来。

典型情况:你的系统发出了退款,随后连接中断,网关还没收到回复。简单重试会导致重复退款。

两条规则可以解决这个问题:

  1. 每次执行都带有一个稳定的操作 ID(也会作为Idempotency-Key发送)。每个 ID 的操作最多执行一次,重试时返回相同结果。
  2. 任何不明确的答复(5xx、超时)都意味着结果未知,绝不能说“失败”。网关会使用同一 ID 重试,最后一次尝试后由人工处理。
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` };
  },
},

还要注意传递给支付服务提供商的幂等键:这一保证必须贯穿整个调用链。

refuse()用于有意给出“否”的答复(已在店内退货):没有任何变更,客户会看到你的消息。其他任何抛出的错误都会变成 500,这意味着“结果未知,请重试”。

按单次请求设置限额(“退款金额不超过 100 美元时,只需客户批准”)很容易绕过。限额需要按时间计算,并按客户统计,涵盖所有智能体:

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

有两个实现细节很重要:

**跨智能体计数。**切换助手不能重置计数器。

**按客户串行处理。**两个同时到达的请求不能都钻过限额。在 Postgres 中,使用以客户为键、作用域限定在事务内的 advisory lock 即可:SELECT pg_advisory_xact_lock(hashtextextended('limit:' || $1, 0))。

在 Supabase(Deno)上,整个处理程序只需一次调用:

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

在 Express 中,应在任何 JSON 请求体解析器之前挂载它(签名覆盖原始请求体):

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

关于网关这一侧(带签名密钥的智能体身份、DPoP 绑定令牌、客户登录、规则、审批、签名账本、紧急停止),我正在通过 Allowfy 构建。本文较长、非代码的版本发布在 Allowfy 博客。如果你想看看 AI 智能体如今如何看待你自己的域名,Allowfy Check 免费且开源。

有问题,或者有我遗漏的故障模式吗?欢迎在评论中告诉我。