@bolloon/bolloon-agent 0.4.33 → 0.5.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/agents/contacts/chain.js +27 -7
- package/dist/agents/execution-supervisor.js +493 -35
- package/dist/agents/external-events.js +35 -6
- package/dist/agents/gateway-group.js +100 -12
- package/dist/agents/goal-criteria.js +10 -3
- package/dist/agents/goal-flywheel/block-executor.js +346 -0
- package/dist/agents/goal-flywheel/continuation-decision.js +592 -0
- package/dist/agents/goal-flywheel/goal-change.js +754 -0
- package/dist/agents/goal-flywheel/index.js +26 -0
- package/dist/agents/goal-flywheel/memory-layers.js +227 -0
- package/dist/agents/goal-flywheel/run-closure.js +809 -0
- package/dist/agents/goal-flywheel/skill-candidate.js +628 -0
- package/dist/agents/goal-flywheel/types.js +329 -0
- package/dist/agents/goal-flywheel/wiring/change.js +288 -0
- package/dist/agents/goal-flywheel/wiring/closure.js +697 -0
- package/dist/agents/goal-flywheel/wiring/continuation.js +432 -0
- package/dist/agents/goal-flywheel/wiring/contract.js +270 -0
- package/dist/agents/goal-flywheel/wiring/index.js +26 -0
- package/dist/agents/goal-flywheel/wiring/monitor.js +254 -0
- package/dist/agents/goal-flywheel/wiring/seams.js +592 -0
- package/dist/agents/goal-flywheel/work-contract.js +478 -0
- package/dist/agents/goal-flywheel/work-monitor.js +488 -0
- package/dist/agents/goal-flywheel-wiring.js +1870 -0
- package/dist/agents/goal-state-reducer.js +406 -0
- package/dist/agents/goal-store.js +35 -1
- package/dist/agents/mobile-flywheel-view.js +144 -0
- package/dist/agents/mobile-task-actions-verify.js +74 -0
- package/dist/agents/mobile-task-actions.js +178 -0
- package/dist/agents/mobile-task-views.js +199 -0
- package/dist/agents/network-pulse.js +304 -18
- package/dist/agents/pi-sdk.js +75 -20
- package/dist/agents/run-store.js +18 -5
- package/dist/agents/skill-readiness.js +52 -10
- package/dist/agents/subagent-manager.js +209 -3
- package/dist/agents/task/task-runner.js +9 -3
- package/dist/agents/task-group.js +8 -51
- package/dist/agents/task-public-text.js +89 -0
- package/dist/cli/commands/index.js +1 -0
- package/dist/cli/commands/tasks.js +350 -2
- package/dist/cli/identity-command.js +177 -0
- package/dist/cli/setup-wizard.js +71 -0
- package/dist/cli/update-commands.js +150 -24
- package/dist/cli-entry.js +12 -2
- package/dist/electron-build/agents/contacts/chain.js +26 -6
- package/dist/electron-build/agents/contacts/chain.js.map +1 -1
- package/dist/electron-build/agents/execution-supervisor.js +492 -34
- package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
- package/dist/electron-build/agents/external-events.js +34 -5
- package/dist/electron-build/agents/external-events.js.map +1 -1
- package/dist/electron-build/agents/goal-criteria.js +10 -3
- package/dist/electron-build/agents/goal-criteria.js.map +1 -1
- package/dist/electron-build/agents/goal-flywheel/continuation-decision.js +598 -0
- package/dist/electron-build/agents/goal-flywheel/continuation-decision.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/goal-change.js +773 -0
- package/dist/electron-build/agents/goal-flywheel/goal-change.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/memory-layers.js +269 -0
- package/dist/electron-build/agents/goal-flywheel/memory-layers.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/run-closure.js +818 -0
- package/dist/electron-build/agents/goal-flywheel/run-closure.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/skill-candidate.js +642 -0
- package/dist/electron-build/agents/goal-flywheel/skill-candidate.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/types.js +333 -0
- package/dist/electron-build/agents/goal-flywheel/types.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/change.js +294 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/change.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/closure.js +742 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/closure.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js +444 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/contract.js +279 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/contract.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/index.js +111 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/index.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js +264 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/seams.js +612 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/seams.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/work-contract.js +488 -0
- package/dist/electron-build/agents/goal-flywheel/work-contract.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/work-monitor.js +501 -0
- package/dist/electron-build/agents/goal-flywheel/work-monitor.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel-wiring.js +1962 -0
- package/dist/electron-build/agents/goal-flywheel-wiring.js.map +1 -0
- package/dist/electron-build/agents/goal-state-reducer.js +414 -0
- package/dist/electron-build/agents/goal-state-reducer.js.map +1 -0
- package/dist/electron-build/agents/goal-store.js +35 -1
- package/dist/electron-build/agents/goal-store.js.map +1 -1
- package/dist/electron-build/agents/network-pulse.js +307 -19
- package/dist/electron-build/agents/network-pulse.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk.js +74 -19
- package/dist/electron-build/agents/pi-sdk.js.map +1 -1
- package/dist/electron-build/agents/run-store.js +20 -5
- package/dist/electron-build/agents/run-store.js.map +1 -1
- package/dist/electron-build/agents/skill-readiness.js +52 -9
- package/dist/electron-build/agents/skill-readiness.js.map +1 -1
- package/dist/electron-build/agents/task/task-runner.js +8 -2
- package/dist/electron-build/agents/task/task-runner.js.map +1 -1
- package/dist/electron-build/orbitdb/cid-database.js +83 -17
- package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
- package/dist/electron-build/orbitdb/ipfs-node.js +31 -2
- package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
- package/dist/electron-build/utils/dual-source.js +471 -0
- package/dist/electron-build/utils/dual-source.js.map +1 -0
- package/dist/electron-build/utils/update-manager.js +331 -57
- package/dist/electron-build/utils/update-manager.js.map +1 -1
- package/dist/electron-build/utils/update-state.js +16 -1
- package/dist/electron-build/utils/update-state.js.map +1 -1
- package/dist/electron-build/utils/version-info.js +82 -2
- package/dist/electron-build/utils/version-info.js.map +1 -1
- package/dist/index.js +103 -12
- package/dist/ios/chain-explorer.js +617 -0
- package/dist/ios/explorer.html +163 -0
- package/dist/ios/index.html +96 -0
- package/dist/ios/mobile-agent.js +2 -2
- package/dist/ios/mobile-contacts.js +329 -0
- package/dist/ios/mobile-core.js +1379 -226
- package/dist/ios/mobile-privacy.js +185 -0
- package/dist/ios/mobile.css +19 -0
- package/dist/ios/mobile.html +96 -0
- package/dist/ios/mobile.js +706 -7
- package/dist/ios/routes-chain-index.js +108 -0
- package/dist/ios/routes-contacts.js +420 -0
- package/dist/ios/routes-onchain-trade.js +99 -0
- package/dist/ios/routes-x402-info.js +1 -0
- package/dist/ios/server.js +762 -0
- package/dist/ios/task-frame-server.js +211 -0
- package/dist/orbitdb/cid-database.js +81 -16
- package/dist/orbitdb/ipfs-node.js +31 -2
- package/dist/utils/dual-source.js +426 -0
- package/dist/utils/update-cli.js +16 -21
- package/dist/utils/update-health.js +35 -5
- package/dist/utils/update-manager.js +329 -57
- package/dist/utils/update-state.js +16 -1
- package/dist/utils/version-info.js +75 -1
- package/dist/web/mobile-contacts.js +44 -11
- package/dist/web/mobile-core.js +1197 -513
- package/dist/web/mobile-tasks.js +339 -0
- package/dist/web/mobile.css +19 -0
- package/dist/web/mobile.html +64 -0
- package/dist/web/mobile.js +531 -1
- package/dist/web/routes-mobile-tasks.js +353 -0
- package/dist/web/server.js +163 -4
- package/package.json +3 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* routes-chain-index.ts — 链上索引的**只读**HTTP 路由 (P5, ③)
|
|
3
|
+
* =========================================================================
|
|
4
|
+
* 刻意独立成文件, **不碰 src/web/server.ts**: 挂载方法见文件末尾注释。
|
|
5
|
+
* 本模块只暴露**读**接口 (读已落盘索引), 全都不发 RPC、不写盘、不签名:
|
|
6
|
+
*
|
|
7
|
+
* GET /api/chain/index/status
|
|
8
|
+
* → 当前索引高度 (lastSyncedBlock) / 最后同步时间 / head / 确认数门槛 / 页大小
|
|
9
|
+
* GET /api/chain/index/stats
|
|
10
|
+
* → 全量统计: tasks / created / proofSubmitted / released / refunded / disputed / expired
|
|
11
|
+
* + byFinality (observed|confirmed|finalized) + suspects (被回退的条数)
|
|
12
|
+
* GET /api/chain/index/timeline?taskKey=0x…(64hex)
|
|
13
|
+
* → 该 escrow 的事件时间线 (含块号/块哈希/logIndex/解码 args/确认数/finality/suspect)
|
|
14
|
+
* GET /api/chain/index/events?blockNumber=..&logIndex=..&limit=..
|
|
15
|
+
* → 按 cursor 增量拉取 (严格大于 cursor 的 (blockNumber, logIndex)); 无 cursor = 从头
|
|
16
|
+
*
|
|
17
|
+
* 挂载 (在 src/web/server.ts 里加两行即可, 本文件不改它):
|
|
18
|
+
* import { registerChainIndexRoutes } from './routes-chain-index.js';
|
|
19
|
+
* registerChainIndexRoutes(app); // app: express.Express (或等价 router 宿主)
|
|
20
|
+
*
|
|
21
|
+
* 挂载点可选前缀: registerChainIndexRoutes(app, { basePath: '/api/chain/index' })
|
|
22
|
+
* 读 SQLite/文件直接读 ~/.bolloon/chain/index.json; 换 HOME 用 opts.home (测试隔离)。
|
|
23
|
+
* 若还想在网页上触发同步: registerChainIndexRoutes(app, { allowSync: true })
|
|
24
|
+
* → 多一个 POST /api/chain/index/sync (body: { fromBlock?: number })。默认关 —— 写操作不默认开放。
|
|
25
|
+
*/
|
|
26
|
+
import { getIndexStatus, getIndexStats, getEscrowTimeline, fetchIndexSince, } from '../agents/chain/chain-index-query.js';
|
|
27
|
+
const isTaskKey = (v) => typeof v === 'string' && /^0x[0-9a-fA-F]{64}$/.test(v);
|
|
28
|
+
export function registerChainIndexRoutes(app, opts = {}) {
|
|
29
|
+
const base = opts.basePath || '/api/chain/index';
|
|
30
|
+
const home = opts.home;
|
|
31
|
+
// ① 当前索引高度 + 最后同步时间
|
|
32
|
+
app.get(`${base}/status`, (_req, res) => {
|
|
33
|
+
try {
|
|
34
|
+
res.json({ ok: true, status: getIndexStatus({ home }) });
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
res.status(500).json({ ok: false, error: String(e?.message || e) });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
// ② 全量统计
|
|
41
|
+
app.get(`${base}/stats`, (_req, res) => {
|
|
42
|
+
try {
|
|
43
|
+
res.json({ ok: true, stats: getIndexStats({ home }) });
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
res.status(500).json({ ok: false, error: String(e?.message || e) });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
// ③ 按 taskKey 查 escrow 时间线
|
|
50
|
+
app.get(`${base}/timeline`, (req, res) => {
|
|
51
|
+
const taskKey = String(req.query?.taskKey || '');
|
|
52
|
+
if (!isTaskKey(taskKey)) {
|
|
53
|
+
return res.status(400).json({ ok: false, error: 'taskKey 必须是 0x + 64 hex' });
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const tl = getEscrowTimeline(taskKey, { home });
|
|
57
|
+
res.json({ ok: true, timeline: tl });
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
res.status(500).json({ ok: false, error: String(e?.message || e) });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
// ④ 按 cursor 增量拉取
|
|
64
|
+
app.get(`${base}/events`, (req, res) => {
|
|
65
|
+
const q = req.query || {};
|
|
66
|
+
const hasCursor = q.blockNumber !== undefined && q.blockNumber !== '';
|
|
67
|
+
let cursor = null;
|
|
68
|
+
if (hasCursor) {
|
|
69
|
+
const bn = Number(q.blockNumber);
|
|
70
|
+
const li = q.logIndex === undefined || q.logIndex === '' ? 0 : Number(q.logIndex);
|
|
71
|
+
if (!Number.isInteger(bn) || bn < 0 || !Number.isInteger(li) || li < 0) {
|
|
72
|
+
return res.status(400).json({ ok: false, error: 'cursor 非法: 需要 blockNumber>=0, logIndex>=0' });
|
|
73
|
+
}
|
|
74
|
+
cursor = { blockNumber: bn, logIndex: li };
|
|
75
|
+
}
|
|
76
|
+
const limit = q.limit === undefined || q.limit === '' ? 200 : Number(q.limit);
|
|
77
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
78
|
+
return res.status(400).json({ ok: false, error: 'limit 必须 >= 1 的整数' });
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
res.json({ ok: true, page: fetchIndexSince(cursor, { home, limit }) });
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
res.status(500).json({ ok: false, error: String(e?.message || e) });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
// 可选: 触发一次同步 (显式 opt-in)
|
|
88
|
+
if (opts.allowSync) {
|
|
89
|
+
app.post(`${base}/sync`, async (req, res) => {
|
|
90
|
+
if (!opts.sync)
|
|
91
|
+
return res.status(501).json({ ok: false, error: '未注入 sync 实现' });
|
|
92
|
+
const fromBlockRaw = req.body?.fromBlock;
|
|
93
|
+
let fromBlock;
|
|
94
|
+
if (fromBlockRaw !== undefined && fromBlockRaw !== null && fromBlockRaw !== '') {
|
|
95
|
+
const n = Number(fromBlockRaw);
|
|
96
|
+
if (!Number.isInteger(n) || n < 0)
|
|
97
|
+
return res.status(400).json({ ok: false, error: 'fromBlock 必须 >= 0 的整数' });
|
|
98
|
+
fromBlock = n;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
res.json({ ok: true, result: await opts.sync(fromBlock) });
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
res.status(500).json({ ok: false, error: String(e?.message || e) });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* routes-contacts.ts — 联系方式 / 授权 / 配对 的桌面端 API (2026-09-19, Phase 5/6)
|
|
3
|
+
*
|
|
4
|
+
* 全部返回**脱敏**数据: 完整手机号/邮箱永不出现在响应里。
|
|
5
|
+
* 与手机端的分工 (leo 冻结):
|
|
6
|
+
* 手机负责 输入 + OTP 确认 + 生物识别授权 + 展示待发内容 + 批准高风险联系
|
|
7
|
+
* 桌面负责 长期 Goal/Run、执行、恢复、持久化状态、证据
|
|
8
|
+
* 同步只传: identityId / contactId / verificationStatus / capability / consentScope / provider /
|
|
9
|
+
* secretRef 的关联状态 —— **不传**明文、不传密钥。
|
|
10
|
+
*/
|
|
11
|
+
import * as crypto from 'crypto';
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import { ContactChain } from '../agents/contacts/chain.js';
|
|
16
|
+
const PAIRING_TTL_MS = 5 * 60_000;
|
|
17
|
+
/** 明显的明文联系方式/密钥 —— 同步载荷里出现就直接拒绝 (防止"顺手同步了明文") */
|
|
18
|
+
export function looksLikePlaintextSecret(value) {
|
|
19
|
+
const walk = (v, pathStr) => {
|
|
20
|
+
if (v == null)
|
|
21
|
+
return null;
|
|
22
|
+
if (typeof v === 'string') {
|
|
23
|
+
const s = v.trim();
|
|
24
|
+
if (/\b(sk-[A-Za-z0-9]{12,}|AKIA[0-9A-Z]{12,}|ghp_[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{20,})\b/.test(s))
|
|
25
|
+
return `${pathStr}: 疑似密钥明文`;
|
|
26
|
+
if (/\b(password|passwd|smtp_pass|api_secret|refresh_token|client_secret)\b/i.test(pathStr) && s.length > 0)
|
|
27
|
+
return `${pathStr}: 疑似秘密字段`;
|
|
28
|
+
// 明文邮箱/手机号: 只有在 key 明确表示"值"时才拦 (displayValue 是脱敏的, 不会匹配)
|
|
29
|
+
if (/[^@\s]+@[^@\s]+\.[^@\s]+/.test(s) && !/^\*|@\*/.test(s) && s.includes('*') === false)
|
|
30
|
+
return `${pathStr}: 疑似邮箱明文`;
|
|
31
|
+
if (/^\+?\d{8,15}$/.test(s.replace(/[^\d+]/g, '')) && /value|phone|number|to$/i.test(pathStr))
|
|
32
|
+
return `${pathStr}: 疑似手机号明文`;
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(v)) {
|
|
36
|
+
for (let i = 0; i < v.length; i++) {
|
|
37
|
+
const r = walk(v[i], `${pathStr}[${i}]`);
|
|
38
|
+
if (r)
|
|
39
|
+
return r;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (typeof v === 'object') {
|
|
44
|
+
for (const [k, vv] of Object.entries(v)) {
|
|
45
|
+
const r = walk(vv, pathStr ? `${pathStr}.${k}` : k);
|
|
46
|
+
if (r)
|
|
47
|
+
return r;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
return walk(value, '');
|
|
54
|
+
}
|
|
55
|
+
export function registerContactRoutes(app, opts = {}) {
|
|
56
|
+
const pairs = opts.pairs || new Map();
|
|
57
|
+
const ownerDidFromDisk = () => {
|
|
58
|
+
try {
|
|
59
|
+
const f = path.join(process.env.HOME || os.homedir(), '.bolloon', 'identity', 'user.json');
|
|
60
|
+
const j = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
61
|
+
if (j?.did)
|
|
62
|
+
return String(j.did);
|
|
63
|
+
}
|
|
64
|
+
catch { /* 还没有用户身份 → 用本地占位 */ }
|
|
65
|
+
return globalThis.__bolloonUserDid || 'did:bolln:local';
|
|
66
|
+
};
|
|
67
|
+
const chainFor = (ownerDid) => opts.chain || new ContactChain({
|
|
68
|
+
home: opts.home,
|
|
69
|
+
ownerDid: ownerDid || opts.ownerDid || ownerDidFromDisk(),
|
|
70
|
+
displayName: '本机用户',
|
|
71
|
+
});
|
|
72
|
+
const chainOf = (req) => chainFor(String(req?.body?.ownerDid || opts.ownerDid || ''));
|
|
73
|
+
// ── 列表 (脱敏) ────────────────────────────────────────────────────────────
|
|
74
|
+
app.get('/api/contacts', async (req, res) => {
|
|
75
|
+
try {
|
|
76
|
+
const c = chainOf(req);
|
|
77
|
+
res.json({
|
|
78
|
+
ok: true,
|
|
79
|
+
contacts: await c.listAuthorized(),
|
|
80
|
+
channels: c.channels(),
|
|
81
|
+
approvals: (await c.consents.list('pending')).map((a) => ({
|
|
82
|
+
consentId: a.consentId, contactId: a.contactId, contactName: a.contactName, channel: a.channel,
|
|
83
|
+
provider: a.provider, reallySent: a.reallySent, subject: a.subject, bodyPreview: a.bodyPreview,
|
|
84
|
+
goalId: a.goalId, reason: a.reason, createdAt: a.createdAt, expiresAt: a.expiresAt,
|
|
85
|
+
})),
|
|
86
|
+
waiting: (await c.store.readLedger({ limit: 200 })).filter((l) => l.activity === 'contact.sent').slice(-10),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
// ── 绑定 (Phase 2/3): 输入 → 规范化 → 隐私说明 → 发验证码 → pending ─────────
|
|
94
|
+
app.post('/api/contacts/bind', async (req, res) => {
|
|
95
|
+
try {
|
|
96
|
+
const c = chainOf(req);
|
|
97
|
+
const kind = String(req.body?.kind || '');
|
|
98
|
+
if (kind !== 'phone' && kind !== 'email')
|
|
99
|
+
return res.status(400).json({ ok: false, error: 'kind 必须是 phone 或 email' });
|
|
100
|
+
const r = await c.bind({
|
|
101
|
+
kind,
|
|
102
|
+
value: String(req.body?.value || ''),
|
|
103
|
+
region: req.body?.region ? String(req.body.region) : undefined,
|
|
104
|
+
provider: req.body?.provider ? String(req.body.provider) : undefined,
|
|
105
|
+
secretRef: req.body?.secretRef ? String(req.body.secretRef) : undefined,
|
|
106
|
+
aliases: Array.isArray(req.body?.aliases) ? req.body.aliases.map(String) : undefined,
|
|
107
|
+
note: req.body?.note ? String(req.body.note) : undefined,
|
|
108
|
+
source: req.body?.source === 'agent_card' ? 'agent_card' : 'user_input',
|
|
109
|
+
});
|
|
110
|
+
if (!r.ok)
|
|
111
|
+
return res.status(400).json({ ok: false, error: r.error });
|
|
112
|
+
res.json({
|
|
113
|
+
ok: true,
|
|
114
|
+
contactId: r.contact.contactId,
|
|
115
|
+
displayValue: r.contact.displayValue,
|
|
116
|
+
verificationStatus: r.contact.verificationStatus,
|
|
117
|
+
channelLabel: r.channelLabel,
|
|
118
|
+
challengeId: r.challengeId,
|
|
119
|
+
// 只有本地落盘通道才回显验证码 (真外发通道不回显, 也不会出现在响应里)
|
|
120
|
+
otpForLocalSink: r.otpForLocalSink,
|
|
121
|
+
privacy: '完整手机号/邮箱不会进入 prompt / Run / Git; 外部只看到 已验证/可联系/不可联系/需要重新授权',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
app.post('/api/contacts/verify', async (req, res) => {
|
|
129
|
+
try {
|
|
130
|
+
const c = chainOf(req);
|
|
131
|
+
const r = await c.verify({ contactId: String(req.body?.contactId || ''), challengeId: String(req.body?.challengeId || ''), code: String(req.body?.code || '') });
|
|
132
|
+
if (!r.ok)
|
|
133
|
+
return res.status(400).json({ ok: false, error: r.error });
|
|
134
|
+
res.json({ ok: true, contactId: r.contact.contactId, displayValue: r.contact.displayValue, verificationStatus: r.contact.verificationStatus, capabilities: r.contact.capabilities });
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
app.post('/api/contacts/:id/revoke', async (req, res) => {
|
|
141
|
+
try {
|
|
142
|
+
const c = chainOf(req);
|
|
143
|
+
const r = await c.revoke({ contactId: String(req.params.id), by: String(req.body?.by || 'user'), reason: req.body?.reason ? String(req.body.reason) : undefined });
|
|
144
|
+
if (!r.ok)
|
|
145
|
+
return res.status(404).json({ ok: false, error: r.error });
|
|
146
|
+
res.json({ ok: true, evidenceRef: r.evidenceRef, affectedGoals: r.affectedGoals });
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
// ── 任务级授权 (把联系方式绑定到某个长期任务) ───────────────────────────────
|
|
153
|
+
app.post('/api/contacts/:id/authorize', async (req, res) => {
|
|
154
|
+
try {
|
|
155
|
+
const c = chainOf(req);
|
|
156
|
+
const r = await c.authorizeForTask({ contactId: String(req.params.id), goalId: String(req.body?.goalId || ''), by: String(req.body?.by || 'user') });
|
|
157
|
+
if (!r.ok)
|
|
158
|
+
return res.status(400).json({ ok: false, error: r.error });
|
|
159
|
+
res.json({ ok: true, contactId: r.contact.contactId, consentScope: r.contact.consentScope });
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
// ── 预览 (不发送) ──────────────────────────────────────────────────────────
|
|
166
|
+
app.post('/api/contacts/preview', async (req, res) => {
|
|
167
|
+
try {
|
|
168
|
+
const c = chainOf(req);
|
|
169
|
+
const r = await c.preview({ contactId: String(req.body?.contactId || ''), goalId: req.body?.goalId ? String(req.body.goalId) : undefined, subject: req.body?.subject ? String(req.body.subject) : undefined, body: String(req.body?.body || '') });
|
|
170
|
+
res.json({ ok: true, allowed: r.decision.allowed, requiresApproval: r.decision.requiresApproval, blockKind: r.decision.blockKind, reallySent: r.decision.reallySent, preview: r.text });
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
// ── 发送 (走 policy; 需要批准就返回 awaiting_approval) ───────────────────────
|
|
177
|
+
app.post('/api/contacts/send', async (req, res) => {
|
|
178
|
+
try {
|
|
179
|
+
const c = chainOf(req);
|
|
180
|
+
const r = await c.send({
|
|
181
|
+
contactId: String(req.body?.contactId || ''),
|
|
182
|
+
goalId: req.body?.goalId ? String(req.body.goalId) : undefined,
|
|
183
|
+
runId: req.body?.runId ? String(req.body.runId) : undefined,
|
|
184
|
+
subject: req.body?.subject ? String(req.body.subject) : undefined,
|
|
185
|
+
body: String(req.body?.body || ''),
|
|
186
|
+
requestId: req.body?.requestId ? String(req.body.requestId) : undefined,
|
|
187
|
+
replyExpected: req.body?.replyExpected !== false,
|
|
188
|
+
});
|
|
189
|
+
const code = r.status === 'denied' ? 409 : r.status === 'failed' ? 502 : 200;
|
|
190
|
+
res.status(code).json({ ok: r.status === 'sent' || r.status === 'awaiting_reply', ...r });
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
// ── 批准 / 拒绝 (人工授权) ─────────────────────────────────────────────────
|
|
197
|
+
app.post('/api/contacts/approvals/:id/approve', async (req, res) => {
|
|
198
|
+
try {
|
|
199
|
+
const c = chainOf(req);
|
|
200
|
+
const r = await c.approveAndSend(String(req.params.id), { by: String(req.body?.by || 'user'), via: 'web', body: req.body?.body ? String(req.body.body) : undefined });
|
|
201
|
+
const code = r.ok ? 200 : (String(r.error || '').includes('not_found') ? 404 : 400);
|
|
202
|
+
res.status(code).json({ ok: r.ok, error: r.error, status: r.status, requestId: r.requestId });
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
app.post('/api/contacts/approvals/:id/reject', async (req, res) => {
|
|
209
|
+
try {
|
|
210
|
+
const c = chainOf(req);
|
|
211
|
+
const r = await c.consents.reject(String(req.params.id), { by: String(req.body?.by || 'user'), reason: req.body?.reason ? String(req.body.reason) : undefined });
|
|
212
|
+
const code = r.ok ? 200 : (String(r.error || '').includes('not_found') ? 404 : 400);
|
|
213
|
+
const cons = r.consent;
|
|
214
|
+
if (cons) {
|
|
215
|
+
await c.store.appendLedger({
|
|
216
|
+
ts: new Date().toISOString(), activity: 'contact.rejected', contactId: cons.contactId, goalId: cons.goalId,
|
|
217
|
+
detail: `人工拒绝 (by ${String(req.body?.by || 'user')}${req.body?.reason ? `, ${req.body.reason}` : ''}) · consent=${cons.consentId}`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
res.status(code).json({ ok: r.ok, error: r.error });
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
// ── 回复入口 (通道回调 / 手机转投): 来源校验不过就不唤醒、不入证据 ──────────────
|
|
227
|
+
app.post('/api/contacts/reply', async (req, res) => {
|
|
228
|
+
try {
|
|
229
|
+
const c = chainOf(req);
|
|
230
|
+
const r = await c.reply({
|
|
231
|
+
requestId: req.body?.requestId ? String(req.body.requestId) : undefined,
|
|
232
|
+
threadToken: req.body?.threadToken ? String(req.body.threadToken) : undefined,
|
|
233
|
+
from: String(req.body?.from || ''),
|
|
234
|
+
body: String(req.body?.body || ''),
|
|
235
|
+
eventId: req.body?.eventId ? String(req.body.eventId) : undefined,
|
|
236
|
+
});
|
|
237
|
+
const code = r.ok ? 200 : (r.reason === 'source_untrusted' ? 403 : 404);
|
|
238
|
+
res.status(code).json({ ok: r.ok, woke: r.woke, goalId: r.goalId, reason: r.reason, evidenceRef: r.evidenceRef });
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
// ── 手机—桌面配对 (Phase 5): 只同步 capability, 不同步明文/密钥 ───────────────
|
|
245
|
+
app.post('/api/contacts/pairing/challenge', async (_req, res) => {
|
|
246
|
+
const now = Date.now();
|
|
247
|
+
const pairingId = `pair-${now.toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
|
|
248
|
+
const p = { pairingId, code: String(crypto.randomInt(0, 1_000_000)).padStart(6, '0'), createdAt: now, expiresAt: now + PAIRING_TTL_MS };
|
|
249
|
+
pairs.set(pairingId, p);
|
|
250
|
+
res.json({ ok: true, pairingId, code: p.code, expiresAt: p.expiresAt, note: '手机扫码/深链确认后调 /confirm; 挑战 5 分钟有效且一次性' });
|
|
251
|
+
});
|
|
252
|
+
app.post('/api/contacts/pairing/confirm', async (req, res) => {
|
|
253
|
+
try {
|
|
254
|
+
const pairingId = String(req.body?.pairingId || '');
|
|
255
|
+
const p = pairs.get(pairingId);
|
|
256
|
+
if (!p)
|
|
257
|
+
return res.status(404).json({ ok: false, error: 'pairing_not_found' });
|
|
258
|
+
if (p.usedAt)
|
|
259
|
+
return res.status(409).json({ ok: false, error: 'pairing_already_used' });
|
|
260
|
+
if (p.expiresAt < Date.now())
|
|
261
|
+
return res.status(410).json({ ok: false, error: 'pairing_expired' });
|
|
262
|
+
if (String(req.body?.code || '') !== p.code)
|
|
263
|
+
return res.status(401).json({ ok: false, error: 'pairing_code_mismatch' });
|
|
264
|
+
// 明文字段直接拒绝 —— 同步只允许传 capability 视图
|
|
265
|
+
const incoming = req.body?.contacts;
|
|
266
|
+
const leak = looksLikePlaintextSecret(incoming);
|
|
267
|
+
if (leak)
|
|
268
|
+
return res.status(400).json({ ok: false, error: `同步载荷含明文/秘密, 已拒绝: ${leak}` });
|
|
269
|
+
p.usedAt = Date.now();
|
|
270
|
+
p.deviceDid = req.body?.deviceDid ? String(req.body.deviceDid) : undefined;
|
|
271
|
+
pairs.set(pairingId, p);
|
|
272
|
+
const accepted = [];
|
|
273
|
+
const rejected = [];
|
|
274
|
+
const c = chainOf(req);
|
|
275
|
+
const allowedKeys = ['contactId', 'kind', 'displayValue', 'verificationStatus', 'capabilities', 'consentScope', 'provider', 'secretRef', 'state'];
|
|
276
|
+
for (const item of Array.isArray(incoming) ? incoming : []) {
|
|
277
|
+
const clean = {};
|
|
278
|
+
for (const k of allowedKeys)
|
|
279
|
+
if (item && k in item)
|
|
280
|
+
clean[k] = item[k];
|
|
281
|
+
const known = clean.contactId ? await c.store.getContact(String(clean.contactId)) : null;
|
|
282
|
+
if (!known)
|
|
283
|
+
rejected.push({ contactId: clean.contactId, reason: '桌面端不认识这个 contactId (需要在桌面端绑定/接收能力)' });
|
|
284
|
+
else
|
|
285
|
+
accepted.push({ contactId: known.contactId, displayValue: known.displayValue, verificationStatus: known.verificationStatus, parity: 'capability-only' });
|
|
286
|
+
}
|
|
287
|
+
res.json({
|
|
288
|
+
ok: true, deviceDid: p.deviceDid, accepted, rejected,
|
|
289
|
+
note: '两端只对齐 contactId/状态/capability; 明文与密钥各自留在本机',
|
|
290
|
+
evidence: await c.store.appendLedger({
|
|
291
|
+
ts: new Date().toISOString(), activity: 'contact.discovered',
|
|
292
|
+
detail: `手机—桌面配对完成 (deviceDid=${p.deviceDid || '未提供'}), 接受 ${accepted.length} 项能力, 拒绝 ${rejected.length} 项`,
|
|
293
|
+
}),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
// ── 持久能力授权 (Phase 3/4/8/10): CLI / Web / 手机 读同一份 Grant 事实 ──────
|
|
301
|
+
app.get('/api/contacts/grants', async (req, res) => {
|
|
302
|
+
try {
|
|
303
|
+
const c = chainOf(req);
|
|
304
|
+
const summary = await c.grantsSummary();
|
|
305
|
+
res.json({
|
|
306
|
+
ok: true,
|
|
307
|
+
...summary,
|
|
308
|
+
choices: [
|
|
309
|
+
{ id: 'task_once', label: '仅本次任务' },
|
|
310
|
+
{ id: 'persistent', label: '长期使用 (推荐)' },
|
|
311
|
+
{ id: 'full_contact_access', label: '完全授权联系方式能力' },
|
|
312
|
+
],
|
|
313
|
+
willNotGet: ['读取全部邮箱', '读取通讯录', '群发消息', '自动支付/转账', '代表你签署合同', '拿到手机号/邮箱明文'],
|
|
314
|
+
forbiddenAlways: ['凭证 (密钥/密码)', '银行账号', '支付/转账指令', '合同承诺'],
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
app.post('/api/contacts/grants', async (req, res) => {
|
|
322
|
+
try {
|
|
323
|
+
const c = chainOf(req);
|
|
324
|
+
const choice = ['task_once', 'persistent', 'full_contact_access'].includes(String(req.body?.choice)) ? String(req.body.choice) : 'persistent';
|
|
325
|
+
const r = await c.authorize({
|
|
326
|
+
choice,
|
|
327
|
+
grantedBy: String(req.body?.by || 'user'),
|
|
328
|
+
grantedVia: req.body?.via === 'mobile' ? 'mobile' : req.body?.via === 'onboard' ? 'onboard' : 'web',
|
|
329
|
+
});
|
|
330
|
+
res.json({ ok: r.ok, userLabel: r.userLabel, grant: r.grant });
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
app.post('/api/contacts/grants/:id/:action', async (req, res) => {
|
|
337
|
+
try {
|
|
338
|
+
const c = chainOf(req);
|
|
339
|
+
const id = String(req.params.id);
|
|
340
|
+
const action = String(req.params.action);
|
|
341
|
+
const by = String(req.body?.by || 'user');
|
|
342
|
+
if (action === 'pause') {
|
|
343
|
+
const r = await c.pauseGrant(id, { by });
|
|
344
|
+
return res.status(r.ok ? 200 : 400).json({ ok: r.ok, error: r.error, grant: r.grant });
|
|
345
|
+
}
|
|
346
|
+
if (action === 'resume') {
|
|
347
|
+
const r = await c.resumeGrant(id, { by });
|
|
348
|
+
return res.status(r.ok ? 200 : 400).json({ ok: r.ok, error: r.error, grant: r.grant });
|
|
349
|
+
}
|
|
350
|
+
if (action === 'revoke') {
|
|
351
|
+
const r = await c.revokeGrant(id, { by, reason: req.body?.reason ? String(req.body.reason) : undefined });
|
|
352
|
+
return res.status(r.ok ? 200 : 400).json({ ok: r.ok, error: r.error, affectedGoals: r.affectedGoals });
|
|
353
|
+
}
|
|
354
|
+
res.status(400).json({ ok: false, error: `未知动作 ${action}` });
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
app.post('/api/contacts/grants/revoke-all', async (req, res) => {
|
|
361
|
+
try {
|
|
362
|
+
const c = chainOf(req);
|
|
363
|
+
const r = await c.revokeAllGrants({ by: String(req.body?.by || 'user'), reason: req.body?.reason ? String(req.body.reason) : undefined });
|
|
364
|
+
res.json({ ok: true, ...r });
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
/** 登记手机设备公钥 (配对阶段; 之后只接受它签名的授权) */
|
|
371
|
+
app.post('/api/contacts/devices', async (req, res) => {
|
|
372
|
+
try {
|
|
373
|
+
const c = chainOf(req);
|
|
374
|
+
const deviceId = String(req.body?.deviceId || '');
|
|
375
|
+
const publicKeyPem = String(req.body?.publicKeyPem || '');
|
|
376
|
+
if (!deviceId || !/BEGIN PUBLIC KEY/.test(publicKeyPem))
|
|
377
|
+
return res.status(400).json({ ok: false, error: 'deviceId/publicKeyPem 必填 (PEM 公钥)' });
|
|
378
|
+
const d = await c.registerDevice(deviceId, publicKeyPem, req.body?.label ? String(req.body.label) : undefined);
|
|
379
|
+
res.json({ ok: true, device: { deviceId: d.deviceId, label: d.label, registeredAt: d.registeredAt } });
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
/** 手机同步签名 Grant (Phase 4): 验签 → 版本冲突 → 撤销优先 */
|
|
386
|
+
app.post('/api/contacts/grants/sync', async (req, res) => {
|
|
387
|
+
try {
|
|
388
|
+
const c = chainOf(req);
|
|
389
|
+
const incoming = req.body?.grant;
|
|
390
|
+
if (!incoming?.grantId || !incoming?.signature)
|
|
391
|
+
return res.status(400).json({ ok: false, error: '需要带签名的 grant (grantId + signature)' });
|
|
392
|
+
const r = await c.syncGrant(incoming);
|
|
393
|
+
return res.status(r.ok ? 200 : 409).json({ ok: r.ok, code: r.code, reason: r.reason, grantId: r.grant?.grantId, grantVersion: r.grant?.grantVersion });
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
/** 手机撤销 → 桌面立即失效 */
|
|
400
|
+
app.post('/api/contacts/grants/revoke-sync', async (req, res) => {
|
|
401
|
+
try {
|
|
402
|
+
const c = chainOf(req);
|
|
403
|
+
const r = await c.syncRevocation(String(req.body?.grantId || ''), {
|
|
404
|
+
by: String(req.body?.by || 'mobile'),
|
|
405
|
+
reason: req.body?.reason ? String(req.body.reason) : undefined,
|
|
406
|
+
version: Number(req.body?.version) || undefined,
|
|
407
|
+
revokedAt: req.body?.revokedAt ? String(req.body.revokedAt) : undefined,
|
|
408
|
+
signature: req.body?.signature, // 手机签名 (有则验签, 未登记设备一律拒)
|
|
409
|
+
});
|
|
410
|
+
res.status(r.ok ? 200 : 400).json({ ok: r.ok, error: r.reason, grant: r.grant });
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
// ── 隐藏守卫自检 (验收/测试直接用) ──────────────────────────────────────────
|
|
417
|
+
app.get('/api/contacts/_selfcheck', (_req, res) => {
|
|
418
|
+
res.json({ ok: true, plaintextGuard: looksLikePlaintextSecret({ contacts: [{ value: '+8613812345678' }] }) });
|
|
419
|
+
});
|
|
420
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* routes-onchain-trade.ts — 链上任务交易闭环的**只读**HTTP 路由 (P4, ②③)
|
|
3
|
+
* =========================================================================
|
|
4
|
+
* 刻意独立成文件, **不碰 src/web/server.ts** (另一个并行任务在动它)。
|
|
5
|
+
* 只暴露**读**接口 —— 全都不发交易、不签名、不改链、不写盘:
|
|
6
|
+
*
|
|
7
|
+
* GET /api/chain/trade/state?taskKey=0x…(64hex) | taskId=…
|
|
8
|
+
* → 该任务在 chain-state.json 里的全部链上记录 (方法/状态/确认数/是否可疑/结果哈希)
|
|
9
|
+
* GET /api/chain/trade/recovery?taskKey=… | taskId=…&pendingResultDigest=sha256:…
|
|
10
|
+
* → ★ 重启恢复决策: nextAction / mustNotRepay / verified / 为什么 (纯读盘, 不联网)
|
|
11
|
+
* GET /api/chain/trade/budget?amount=0.02&taskBudget=0.05
|
|
12
|
+
* → M1 预算硬约束预检 (单任务 0.05 / 单次 0.02): 放行 / 哪一层拦下
|
|
13
|
+
*
|
|
14
|
+
* 挂载 (在 src/web/server.ts 里加两行即可, 本文件不改它):
|
|
15
|
+
* import { registerOnchainTradeRoutes } from './routes-onchain-trade.js';
|
|
16
|
+
* registerOnchainTradeRoutes(app); // app: express.Express
|
|
17
|
+
*
|
|
18
|
+
* 挂载点可选: registerOnchainTradeRoutes(app, { basePath: '/api/chain/trade' })
|
|
19
|
+
* 换 HOME 用 opts.home (测试隔离 / 多 profile)。**没有**任何写接口 ——
|
|
20
|
+
* 链上写操作必须走 CLI/任务链路 (那里有签名放行闸 + 审计), 不通过 HTTP 裸开。
|
|
21
|
+
*/
|
|
22
|
+
import * as os from 'os';
|
|
23
|
+
import { recoverOnchainTrade, checkOnchainAmount, onchainTaskKey, } from '../agents/chain/onchain-trade.js';
|
|
24
|
+
import { loadChainState, chainStatePath } from '../agents/chain/chain-state-store.js';
|
|
25
|
+
const isTaskKey = (v) => typeof v === 'string' && /^0x[0-9a-fA-F]{64}$/.test(v);
|
|
26
|
+
function homeOf(opts) {
|
|
27
|
+
return opts.home || process.env.HOME || os.homedir();
|
|
28
|
+
}
|
|
29
|
+
export function registerOnchainTradeRoutes(app, opts = {}) {
|
|
30
|
+
const base = opts.basePath || '/api/chain/trade';
|
|
31
|
+
// ① 链上记录 (按 taskKey / taskId 过滤; 不过滤 = 全部)
|
|
32
|
+
app.get(`${base}/state`, (req, res) => {
|
|
33
|
+
try {
|
|
34
|
+
const home = homeOf(opts);
|
|
35
|
+
const taskKey = String(req.query?.taskKey || '');
|
|
36
|
+
const taskId = String(req.query?.taskId || '');
|
|
37
|
+
if (taskKey && !isTaskKey(taskKey))
|
|
38
|
+
return res.status(400).json({ ok: false, error: 'taskKey 必须是 0x + 64 hex' });
|
|
39
|
+
const want = (taskKey || (taskId ? onchainTaskKey(taskId) : '')).toLowerCase();
|
|
40
|
+
const all = Object.values(loadChainState(home).records);
|
|
41
|
+
const records = want ? all.filter((r) => String(r.taskKey).toLowerCase() === want) : all;
|
|
42
|
+
res.json({
|
|
43
|
+
ok: true,
|
|
44
|
+
statePath: chainStatePath(home),
|
|
45
|
+
taskKey: want || null,
|
|
46
|
+
count: records.length,
|
|
47
|
+
records,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
res.status(500).json({ ok: false, error: String(e?.message || e) });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
// ② 重启恢复决策 (纯读盘; 不联网 → 不会把"读不到 RPC"当成"没付款")
|
|
55
|
+
app.get(`${base}/recovery`, (req, res) => {
|
|
56
|
+
try {
|
|
57
|
+
const home = homeOf(opts);
|
|
58
|
+
const q = req.query || {};
|
|
59
|
+
const taskKey = String(q.taskKey || '');
|
|
60
|
+
const taskId = String(q.taskId || '');
|
|
61
|
+
if (taskKey && !isTaskKey(taskKey))
|
|
62
|
+
return res.status(400).json({ ok: false, error: 'taskKey 必须是 0x + 64 hex' });
|
|
63
|
+
if (!taskKey && !taskId)
|
|
64
|
+
return res.status(400).json({ ok: false, error: '要 taskKey 或 taskId' });
|
|
65
|
+
const recovery = recoverOnchainTrade({
|
|
66
|
+
home,
|
|
67
|
+
...(taskKey ? { taskKey } : {}),
|
|
68
|
+
...(taskId ? { taskId } : {}),
|
|
69
|
+
...(q.pendingResultDigest ? { pendingResultDigest: String(q.pendingResultDigest) } : {}),
|
|
70
|
+
});
|
|
71
|
+
res.json({ ok: true, recovery });
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
res.status(500).json({ ok: false, error: String(e?.message || e) });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
// ③ M1 预算硬约束预检 (付款前必须能看得到; 纯函数, 不碰钱)
|
|
78
|
+
app.get(`${base}/budget`, (req, res) => {
|
|
79
|
+
try {
|
|
80
|
+
const q = req.query || {};
|
|
81
|
+
const amount = Number(q.amount);
|
|
82
|
+
if (!Number.isFinite(amount) || amount <= 0)
|
|
83
|
+
return res.status(400).json({ ok: false, error: 'amount 必须是正数 (USDC)' });
|
|
84
|
+
const decimals = Number.isFinite(Number(q.decimals)) ? Number(q.decimals) : 6;
|
|
85
|
+
const atomic = BigInt(Math.round(amount * 10 ** decimals));
|
|
86
|
+
const d = checkOnchainAmount({
|
|
87
|
+
amountAtomic: atomic, decimals,
|
|
88
|
+
budget: {
|
|
89
|
+
...(q.taskBudget !== undefined ? { taskBudget: Number(q.taskBudget) } : {}),
|
|
90
|
+
...(q.perPurchase !== undefined ? { perPurchase: Number(q.perPurchase) } : {}),
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
res.json({ ok: true, allowed: d.ok, layer: d.layer ?? null, reason: d.reason, amountUsdc: d.amountUsdc, budget: d.budget, why: d.why });
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
res.status(500).json({ ok: false, error: String(e?.message || e) });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -107,6 +107,7 @@ export function registerX402InfoRoutes(app) {
|
|
|
107
107
|
const pay = await checkAndSettlePayment({
|
|
108
108
|
paymentHeader: String(paymentHeader),
|
|
109
109
|
requirements,
|
|
110
|
+
expectedItemId: stored.item.id, // 凭据必须绑定这条资源 (防跨资源复用)
|
|
110
111
|
});
|
|
111
112
|
if (!pay.ok) {
|
|
112
113
|
return res.status(402).json({ ...requirements, error: pay.error });
|