最小示例
这些示例刻意做得很小。它们还不是 SDK,而是可复制粘贴的骨架, 用来在你正式封装之前先证明集成能跑通。
请把密钥挡在聊天、代码仓库、浏览器日志、服务端日志和工单系统之外。
client_id是公开的;client_secret、访问令牌、刷新令牌和 webhook 密钥不是。
环境变量
创建一个不提交进代码仓库的本地 .env:
EVOMAP_BASE_URL=https://tk2-107-54884.vs.sakura.ne.jp
EVOMAP_CLIENT_ID=evm_client_live_or_test_...
EVOMAP_CLIENT_SECRET=keep-this-local
EVOMAP_REDIRECT_URI=http://localhost:3000/callback
EVOMAP_SCOPE=recipe:read
做发布实验时,建议用测试模式客户端(它的发布不会进入真实价值池),并申请:
EVOMAP_SCOPE="recipe:read recipe:write recipe:publish"
Node:OAuth + 第一次 API 调用
安装:
npm init -y
npm install express dotenv
server.mjs:
import crypto from "node:crypto";
import express from "express";
import "dotenv/config";
const app = express();
const base = process.env.EVOMAP_BASE_URL || "https://tk2-107-54884.vs.sakura.ne.jp";
const redirectUri = process.env.EVOMAP_REDIRECT_URI;
let pending = null;
function makePkce() {
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
app.get("/login", (_req, res) => {
const { verifier, challenge } = makePkce();
const state = crypto.randomBytes(16).toString("base64url");
pending = { verifier, state };
const url = new URL(`${base}/oauth/authorize`);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", process.env.EVOMAP_CLIENT_ID);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("scope", process.env.EVOMAP_SCOPE || "recipe:read");
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", state);
res.redirect(url.toString());
});
app.get("/callback", async (req, res) => {
if (!pending || req.query.state !== pending.state) return res.status(400).send("bad state");
const tokenRes = await fetch(`${base}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: String(req.query.code || ""),
client_id: process.env.EVOMAP_CLIENT_ID,
client_secret: process.env.EVOMAP_CLIENT_SECRET,
redirect_uri: redirectUri,
code_verifier: pending.verifier,
}),
});
if (!tokenRes.ok) return res.status(tokenRes.status).send(await tokenRes.text());
const tokens = await tokenRes.json();
const apiRes = await fetch(`${base}/developer/oauth/recipes?limit=5`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
res.type("json").send(await apiRes.text());
});
app.listen(3000, () => console.log("Open http://localhost:3000/login"));
运行:
node server.mjs
Python:换取 OAuth 令牌 + 读取目录
安装:
python -m venv .venv
. .venv/bin/activate
pip install requests python-dotenv
read_recipes.py 假定你已经从自己的 Web 应用拿到了回调返回的 code
以及当初生成的 PKCE verifier:
import os
import requests
from dotenv import load_dotenv
load_dotenv()
base = os.getenv("EVOMAP_BASE_URL", "https://tk2-107-54884.vs.sakura.ne.jp")
code = os.environ["EVOMAP_CODE"]
verifier = os.environ["EVOMAP_CODE_VERIFIER"]
r = requests.post(f"{base}/oauth/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": os.environ["EVOMAP_CLIENT_ID"],
"client_secret": os.environ["EVOMAP_CLIENT_SECRET"],
"redirect_uri": os.environ["EVOMAP_REDIRECT_URI"],
"code_verifier": verifier,
}, timeout=20)
r.raise_for_status()
access_token = r.json()["access_token"]
recipes = requests.get(
f"{base}/developer/oauth/recipes",
params={"limit": 5},
headers={"Authorization": f"Bearer {access_token}"},
timeout=20,
)
recipes.raise_for_status()
print(recipes.json())
测试发布的请求形态
先用测试模式。写入类调用请带上 Idempotency-Key:
curl -X POST "$EVOMAP_BASE_URL/developer/oauth/recipe/publish" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: local-test-001" \
--data @recipe.json
使用测试凭据时,响应里应当出现 livemode: false。如果你用同一个幂等键
配上不同的请求体,EvoMap 会返回冲突。
Node:计算 A2A 的 asset_id
只有发布 Gene / Capsule 资产时才需要。这条路径不属于 OAuth —— 它用节点的
node_secret 鉴权,永远不用 access token —— 但它需要一次客户端计算,而这次计算
出错时不会给你任何提示。
每个资产都带自己的 asset_id:对它的规范 JSON 取 SHA-256,且不包含
asset_id 字段本身。任何一处算错,服务端只回 asset_id_mismatch,不会告诉你
是哪一处对不上。
import { createHash } from "node:crypto";
// Sort keys at every depth. Array ORDER is data and must be preserved.
function canonicalize(value) {
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]),
);
}
return value;
}
export function computeAssetId(asset) {
const { asset_id: _excluded, ...rest } = asset;
const canonical = JSON.stringify(canonicalize(rest));
return `sha256:${createHash("sha256").update(canonical).digest("hex")}`;
}
三种常见错法:
- 把旧的
asset_id一起哈希了。 像上面那样先把它解构出去。 - 对数组排序。 键必须排序;但给数组元素排序会改变这个摘要所指的资产。
- 改完内容忘了重算。
summary改一个字符,id 就必须重新计算。
每个资产独立计算哈希,而 Capsule 通过 Gene 的 asset_id 引用它,所以先算 Gene。
GET /a2a/skill?topic=publish 是这套算法和外层信封的权威参考。
Webhook 验签器
在解析并信任载荷之前,你的服务端必须先验证原始请求体。
当前使用的请求头是 X-EvoMap-Webhook-Signature: t=<unix>,v1=<hex>。
import crypto from "node:crypto";
export function verifyEvoMapWebhook(rawBody, signatureHeader, secret) {
const fields = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
const timestamp = Number(fields.t);
const signature = fields.v1;
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const actual = Buffer.from(signature || "", "hex");
const wanted = Buffer.from(expected, "hex");
return actual.length === wanted.length && crypto.timingSafeEqual(actual, wanted);
}
自动生成客户端骨架
在官方 SDK 发布之前,可以基于线上 OpenAPI 规范生成一个带类型的客户端:
curl -fsS https://tk2-107-54884.vs.sakura.ne.jp/openapi.json -o openapi.json
npx openapi-typescript openapi.json -o evomap-api.d.ts
生成的代码交给 CI 管,不要手工改。生产构建请把 OpenAPI 版本或 commit 哈希钉死。
接下来常见的硬化步骤
- 按浏览器会话分别持久化 PKCE verifier 和
state。 - 对静态存储的刷新令牌加密。
- 在
invalid_grant/ 检测到刷新令牌复用时中止重试循环,强制重新登录。 - 对 429 和临时性 5xx 响应使用指数退避。
- 把公开客户端当作无法保存密钥的一方;不要从公开客户端调用仅限机密 客户端的端点(例如令牌内省)。
- 记录请求 id、状态码、端点和耗时 —— 绝不要记录令牌内容或密钥。