@bolloon/bolloon-agent 0.4.29 → 0.4.31
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/agent-gateway.js +5 -0
- package/dist/agents/agent-manifest-protocol.js +29 -0
- package/dist/agents/contacts/chain.js +579 -0
- package/dist/agents/contacts/cli.js +158 -0
- package/dist/agents/contacts/consent.js +124 -0
- package/dist/agents/contacts/grant-payload.js +45 -0
- package/dist/agents/contacts/grants.js +451 -0
- package/dist/agents/contacts/policy.js +264 -0
- package/dist/agents/contacts/preview-types.js +6 -0
- package/dist/agents/contacts/providers.js +341 -0
- package/dist/agents/contacts/store.js +326 -0
- package/dist/agents/contacts/tools.js +154 -0
- package/dist/agents/contacts/types.js +148 -0
- package/dist/agents/economic-policy.js +7 -0
- package/dist/agents/gateway-network.js +6 -0
- package/dist/agents/local-signer.js +59 -0
- package/dist/agents/network-pulse.js +450 -0
- package/dist/agents/pi-sdk.js +10 -0
- package/dist/agents/task-contract.js +322 -0
- package/dist/agents/task-inbox.js +267 -0
- package/dist/agents/task-transport.js +280 -0
- package/dist/agents/x402/transaction-store.js +8 -1
- package/dist/cli/commands/agent.js +230 -0
- package/dist/cli/commands/index.js +61 -0
- package/dist/cli/commands/mcp.js +98 -0
- package/dist/cli/commands/network.js +334 -0
- package/dist/cli/commands/payment.js +125 -0
- package/dist/cli/commands/tasks.js +1236 -0
- package/dist/cli/commands/trade.js +229 -0
- package/dist/cli/commands/wallet.js +450 -0
- package/dist/cli/mcp/bridge.js +105 -0
- package/dist/cli/mcp/server.js +185 -0
- package/dist/cli/mcp/tools.js +612 -0
- package/dist/cli/protocol-envelope.js +346 -0
- package/dist/cli-entry.js +141 -17
- package/dist/electron-build/agents/agent-gateway.js +5 -0
- package/dist/electron-build/agents/agent-gateway.js.map +1 -1
- package/dist/electron-build/agents/agent-manifest-protocol.js +62 -0
- package/dist/electron-build/agents/agent-manifest-protocol.js.map +1 -1
- package/dist/electron-build/agents/contacts/chain.js +619 -0
- package/dist/electron-build/agents/contacts/chain.js.map +1 -0
- package/dist/electron-build/agents/contacts/consent.js +163 -0
- package/dist/electron-build/agents/contacts/consent.js.map +1 -0
- package/dist/electron-build/agents/contacts/grant-payload.js +52 -0
- package/dist/electron-build/agents/contacts/grant-payload.js.map +1 -0
- package/dist/electron-build/agents/contacts/grants.js +500 -0
- package/dist/electron-build/agents/contacts/grants.js.map +1 -0
- package/dist/electron-build/agents/contacts/policy.js +271 -0
- package/dist/electron-build/agents/contacts/policy.js.map +1 -0
- package/dist/electron-build/agents/contacts/preview-types.js +8 -0
- package/dist/electron-build/agents/contacts/preview-types.js.map +1 -0
- package/dist/electron-build/agents/contacts/providers.js +384 -0
- package/dist/electron-build/agents/contacts/providers.js.map +1 -0
- package/dist/electron-build/agents/contacts/store.js +370 -0
- package/dist/electron-build/agents/contacts/store.js.map +1 -0
- package/dist/electron-build/agents/contacts/tools.js +159 -0
- package/dist/electron-build/agents/contacts/tools.js.map +1 -0
- package/dist/electron-build/agents/contacts/types.js +166 -0
- package/dist/electron-build/agents/contacts/types.js.map +1 -0
- package/dist/electron-build/agents/economic-policy.js +7 -0
- package/dist/electron-build/agents/economic-policy.js.map +1 -1
- package/dist/electron-build/agents/external-events.js.map +1 -1
- package/dist/electron-build/agents/gateway-network.js +6 -0
- package/dist/electron-build/agents/gateway-network.js.map +1 -1
- package/dist/electron-build/agents/goal-store.js.map +1 -1
- package/dist/electron-build/agents/network-pulse.js +504 -0
- package/dist/electron-build/agents/network-pulse.js.map +1 -0
- package/dist/electron-build/agents/pi-sdk.js +10 -0
- package/dist/electron-build/agents/pi-sdk.js.map +1 -1
- package/dist/electron-build/agents/x402/transaction-store.js +8 -1
- package/dist/electron-build/agents/x402/transaction-store.js.map +1 -1
- package/dist/electron-build/security/tool-gate.js +4 -0
- package/dist/electron-build/security/tool-gate.js.map +1 -1
- package/dist/index.js +30 -0
- package/dist/security/tool-gate.js +4 -0
- package/dist/web/mobile-contacts.js +329 -0
- package/dist/web/mobile-core.js +481 -15
- package/dist/web/mobile.html +32 -0
- package/dist/web/mobile.js +139 -0
- package/dist/web/routes-contacts.js +420 -0
- package/dist/web/server.js +34 -0
- package/dist/web/task-frame-server.js +211 -0
- package/package.json +1 -1
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* contacts/grants.ts — 持久化联系方式能力授权 (2026-09-19, Phase 0/1/4/8)
|
|
3
|
+
*
|
|
4
|
+
* 与 consents.json 的分工:
|
|
5
|
+
* consents.json = **一次性**批准请求 (某个 requestId 的这一次发送)
|
|
6
|
+
* grants.json = **长期**能力授权 (以后 Agent 能不能自动用这个能力)
|
|
7
|
+
*
|
|
8
|
+
* 授权等级 (内部四级 / 用户只看三个选项):
|
|
9
|
+
* none 不允许 Agent 使用
|
|
10
|
+
* task_once 只允许当前任务 ← 用户选"仅本次任务"
|
|
11
|
+
* persistent 以后自动使用 ← 用户选"长期使用"(推荐默认)
|
|
12
|
+
* full_contact_access 联系方式能力完全授权 ← 用户选"完全授权"
|
|
13
|
+
*
|
|
14
|
+
* "完全访问" = **只对 phone.contact / email.contact 完全授权**, 不等于绕过系统边界:
|
|
15
|
+
* 仍然保留 单收件人 / 频率 / 任务关联 / requestId 幂等 / provider 检查 / 证据 / 撤销 / Harness 拦截 / 禁止群发。
|
|
16
|
+
* 支付·转账·签署·法律承诺·密钥明文 这些**永不被联系方式 Grant 放行** (见 FORBIDDEN_CATEGORIES)。
|
|
17
|
+
*
|
|
18
|
+
* 手机→桌面同步: Grant 由**手机设备私钥签名**, 桌面用登记过的设备公钥验签才接受;
|
|
19
|
+
* 规则: 撤销优先于授权 / 低版本不覆盖高版本 / 桌面不能自铸手机授权。
|
|
20
|
+
*/
|
|
21
|
+
import * as crypto from 'crypto';
|
|
22
|
+
import * as fsp from 'fs/promises';
|
|
23
|
+
import * as path from 'path';
|
|
24
|
+
// 2026-09-19: 类型/规范载荷/预设全部从纯模块复用 —— 手机端 (WebCrypto) 与桌面端 (Node) 签的必须是同一个字节串
|
|
25
|
+
export { canonicalGrantPayload, canonicalRevocationPayload, presetForChoice, GRANT_LEVEL_LABEL, GRANT_SIGNED_FIELDS, } from './grant-payload.js';
|
|
26
|
+
import { canonicalGrantPayload, canonicalRevocationPayload, presetForChoice, GRANT_LEVEL_LABEL } from './grant-payload.js';
|
|
27
|
+
/**
|
|
28
|
+
* 联系方式 Grant **永远**不能放行的内容类别 (与"完全授权"无关 —— 它们属于另一类高风险能力:
|
|
29
|
+
* 凭证 / 资金指令 / 合同承诺)。注意: 卡号与身份证号属于"敏感但可在完全授权下发送"。
|
|
30
|
+
*/
|
|
31
|
+
export const FORBIDDEN_CATEGORIES = ['api_key', 'password', 'bank_account', 'payment_instruction', 'contract_commitment'];
|
|
32
|
+
export function generateDeviceKeyPair() {
|
|
33
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
34
|
+
return {
|
|
35
|
+
deviceId: `dev-${crypto.randomBytes(4).toString('hex')}`,
|
|
36
|
+
publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
|
|
37
|
+
privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function grantPayloadHash(g) {
|
|
41
|
+
return crypto.createHash('sha256').update(canonicalGrantPayload(g)).digest('hex');
|
|
42
|
+
}
|
|
43
|
+
export function signGrant(g, privateKeyPem, deviceId) {
|
|
44
|
+
const payload = canonicalGrantPayload(g);
|
|
45
|
+
const sig = crypto.sign(null, Buffer.from(payload, 'utf8'), crypto.createPrivateKey(privateKeyPem)).toString('base64');
|
|
46
|
+
return { deviceId, alg: 'ed25519', payloadHash: grantPayloadHash(g), sig };
|
|
47
|
+
}
|
|
48
|
+
export function verifyGrantSignature(g, pub) {
|
|
49
|
+
if (!g.signature)
|
|
50
|
+
return { ok: false, reason: 'unsigned' };
|
|
51
|
+
if (!pub)
|
|
52
|
+
return { ok: false, reason: 'unknown_device' };
|
|
53
|
+
if (pub.deviceId !== g.signature.deviceId)
|
|
54
|
+
return { ok: false, reason: 'device_mismatch' };
|
|
55
|
+
if (g.signature.payloadHash !== grantPayloadHash(g))
|
|
56
|
+
return { ok: false, reason: 'payload_tampered' };
|
|
57
|
+
try {
|
|
58
|
+
const ok = crypto.verify(null, Buffer.from(canonicalGrantPayload(g), 'utf8'), crypto.createPublicKey(pub.publicKeyPem), Buffer.from(g.signature.sig, 'base64'));
|
|
59
|
+
return ok ? { ok: true } : { ok: false, reason: 'bad_signature' };
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
return { ok: false, reason: `verify_error:${err?.message || err}` };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** 内容类别 → 敏感类别名 (与 types.scanSensitive 对齐) */
|
|
66
|
+
export function categoriesOf(findings) {
|
|
67
|
+
return [...new Set(findings.map((f) => f.kind))];
|
|
68
|
+
}
|
|
69
|
+
export function evaluateGrant(grant, input, now = Date.now()) {
|
|
70
|
+
const base = { level: 'none', needsHumanApproval: false, authorizationMode: 'one_time', approvalSkipped: false, policyDecision: '' };
|
|
71
|
+
// 永不放行的类别 → 直接拒绝, 与有没有 Grant 无关
|
|
72
|
+
if (input.hasForbiddenContent) {
|
|
73
|
+
return { ...base, ok: false, block: 'forbidden_content_category',
|
|
74
|
+
reason: '内容含密码/密钥/银行账号/卡号/支付指令/合同承诺 —— 这些永不由联系方式授权放行 (属另一类高风险能力)' };
|
|
75
|
+
}
|
|
76
|
+
if (!grant) {
|
|
77
|
+
return { ...base, ok: false, block: 'grant_missing', needsHumanApproval: true,
|
|
78
|
+
reason: '没有长期联系方式授权 → 退回一次性人工批准 (批准一次不等于长期授权)', policyDecision: 'grant_missing→one_time_consent' };
|
|
79
|
+
}
|
|
80
|
+
const mode = grant.level === 'full_contact_access' ? 'full_contact_access' : grant.level === 'persistent' ? 'persistent' : 'one_time';
|
|
81
|
+
if (grant.status === 'revoked') {
|
|
82
|
+
return { ...base, level: grant.level, authorizationMode: mode, ok: false, block: 'grant_revoked',
|
|
83
|
+
reason: `授权 ${grant.grantId} 已被撤销 (${grant.revokedAt || ''})`, policyDecision: `grant_revoked:${grant.grantId}` };
|
|
84
|
+
}
|
|
85
|
+
if (grant.status === 'expired') {
|
|
86
|
+
return { ...base, level: grant.level, authorizationMode: mode, ok: false, block: 'grant_expired', reason: `授权 ${grant.grantId} 已过期`, policyDecision: `grant_expired:${grant.grantId}` };
|
|
87
|
+
}
|
|
88
|
+
if (grant.status === 'suspended') {
|
|
89
|
+
return { ...base, level: grant.level, authorizationMode: mode, ok: false, block: 'grant_suspended', needsHumanApproval: true, reason: `授权 ${grant.grantId} 处于暂停状态`, policyDecision: `grant_suspended:${grant.grantId}` };
|
|
90
|
+
}
|
|
91
|
+
if (grant.level === 'none') {
|
|
92
|
+
return { ...base, ok: false, block: 'grant_missing', needsHumanApproval: true, reason: '授权等级为 none (不允许 Agent 使用)', policyDecision: 'grant_none' };
|
|
93
|
+
}
|
|
94
|
+
// 设备可信: 同步来的 Grant 必须来自已登记设备
|
|
95
|
+
if (grant.signature && input.deviceTrusted && !input.deviceTrusted(grant.signature.deviceId)) {
|
|
96
|
+
return { ...base, level: grant.level, authorizationMode: mode, ok: false, block: 'grant_device_untrusted',
|
|
97
|
+
reason: `授权来自未登记设备 ${grant.signature.deviceId} (桌面不认未签名/未知设备的授权)`, policyDecision: `grant_device_untrusted:${grant.signature.deviceId}` };
|
|
98
|
+
}
|
|
99
|
+
const channelsOk = grant.channels === 'both' || grant.channels === input.channel;
|
|
100
|
+
if (!channelsOk) {
|
|
101
|
+
return { ...base, level: grant.level, authorizationMode: mode, ok: false, block: 'grant_channel_denied',
|
|
102
|
+
reason: `授权 ${grant.grantId} 只覆盖 ${grant.channels}, 不含 ${input.channel}`, policyDecision: `grant_channel_denied:${grant.channels}` };
|
|
103
|
+
}
|
|
104
|
+
if (grant.contactScope === 'verified_contacts' && !input.contactVerified) {
|
|
105
|
+
return { ...base, level: grant.level, authorizationMode: mode, ok: false, block: 'grant_recipient_denied',
|
|
106
|
+
reason: '授权范围是 verified_contacts, 但该联系方式未验证', policyDecision: 'grant_recipient_denied:unverified' };
|
|
107
|
+
}
|
|
108
|
+
// 明确点名的联系人 (经用户授权过) 永远在范围内
|
|
109
|
+
if (!input.taskBoundToGoal && grant.contactScope === 'all_contacts' && !input.contactVerified) {
|
|
110
|
+
return { ...base, level: grant.level, authorizationMode: mode, ok: false, block: 'grant_recipient_denied',
|
|
111
|
+
reason: 'all_contacts 也只覆盖**已验证**联系人 (未验证一律不发)', policyDecision: 'grant_recipient_denied:unverified' };
|
|
112
|
+
}
|
|
113
|
+
const taskScopeOk = grant.taskScope === 'all_future_goals' ||
|
|
114
|
+
(grant.taskScope === 'current_goal' && !!input.goalId && !!input.taskBoundToGoal) ||
|
|
115
|
+
(grant.taskScope === 'matching_goals' && !!input.goalId && !!(input.goalMatchesGrant ? input.goalMatchesGrant(grant, input.goalId) : input.taskBoundToGoal));
|
|
116
|
+
if (!taskScopeOk) {
|
|
117
|
+
return { ...base, level: grant.level, authorizationMode: mode, ok: false, block: 'grant_task_denied',
|
|
118
|
+
reason: `授权 ${grant.grantId} 的任务范围是 ${grant.taskScope}${input.goalId ? `, 当前任务 ${input.goalId} 不在范围内` : ' 且未指定任务'}`, policyDecision: `grant_task_denied:${grant.taskScope}` };
|
|
119
|
+
}
|
|
120
|
+
const sensitive = input.sensitiveCategories || [];
|
|
121
|
+
if (sensitive.length > 0 && (grant.contentScope !== 'sensitive' || !grant.sensitiveContentAllowed)) {
|
|
122
|
+
return { ...base, level: grant.level, authorizationMode: mode, ok: false, block: 'grant_sensitive_content_denied', needsHumanApproval: true,
|
|
123
|
+
reason: `内容敏感 (${sensitive.join(', ')}) 但授权 ${grant.grantId} 的内容范围是 ${grant.contentScope} → 需要人工批准`, policyDecision: `grant_sensitive_content_denied:${sensitive.join('|')}` };
|
|
124
|
+
}
|
|
125
|
+
const autoOk = grant.autoSend && grant.level !== 'task_once';
|
|
126
|
+
return {
|
|
127
|
+
ok: true,
|
|
128
|
+
grant,
|
|
129
|
+
level: grant.level,
|
|
130
|
+
needsHumanApproval: !autoOk,
|
|
131
|
+
authorizationMode: mode,
|
|
132
|
+
approvalSkipped: autoOk,
|
|
133
|
+
policyDecision: `allowed:${grant.grantId}:v${grant.grantVersion}:${mode}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
export class GrantStore {
|
|
137
|
+
dir;
|
|
138
|
+
grants = [];
|
|
139
|
+
devices = [];
|
|
140
|
+
loaded = false;
|
|
141
|
+
/** 读盘失败时置位 —— 之后一切自动发送都必须 fail-closed */
|
|
142
|
+
corrupt = false;
|
|
143
|
+
/** 上次读到的 mtime —— 别的进程 (CLI / web server / 手机同步) 改过盘就要重读 */
|
|
144
|
+
mtimeMs = 0;
|
|
145
|
+
constructor(dir) { this.dir = dir; }
|
|
146
|
+
grantsFile() { return path.join(this.dir, 'grants.json'); }
|
|
147
|
+
devicesFile() { return path.join(this.dir, 'devices.json'); }
|
|
148
|
+
async atomic(file, data, mode) {
|
|
149
|
+
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
150
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now().toString(36)}`;
|
|
151
|
+
await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
|
|
152
|
+
if (mode) {
|
|
153
|
+
try {
|
|
154
|
+
await fsp.chmod(tmp, mode);
|
|
155
|
+
}
|
|
156
|
+
catch { /* 平台限制 */ }
|
|
157
|
+
}
|
|
158
|
+
await fsp.rename(tmp, file);
|
|
159
|
+
}
|
|
160
|
+
async load() {
|
|
161
|
+
if (this.loaded)
|
|
162
|
+
return;
|
|
163
|
+
try {
|
|
164
|
+
const raw = JSON.parse(await fsp.readFile(this.grantsFile(), 'utf8'));
|
|
165
|
+
this.grants = Array.isArray(raw) ? raw : [];
|
|
166
|
+
this.corrupt = false;
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
if (err?.code === 'ENOENT') {
|
|
170
|
+
this.grants = [];
|
|
171
|
+
this.corrupt = false;
|
|
172
|
+
}
|
|
173
|
+
// 损坏 → 明确置位, 绝不静默当成"没有授权"(那会变成"重新弹批准")或"已授权"
|
|
174
|
+
else {
|
|
175
|
+
this.grants = [];
|
|
176
|
+
this.corrupt = true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const dev = JSON.parse(await fsp.readFile(this.devicesFile(), 'utf8'));
|
|
181
|
+
this.devices = Array.isArray(dev) ? dev : [];
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
this.devices = [];
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
this.mtimeMs = (await fsp.stat(this.grantsFile())).mtimeMs;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
this.mtimeMs = 0;
|
|
191
|
+
}
|
|
192
|
+
this.loaded = true;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* 2026-09-19 (真跑抓到): 长驻进程 (web server) 会缓存 Grant 列表 →
|
|
196
|
+
* 别的进程撤销/授权后, 它还在用旧事实 —— 对"撤销必须立即失效"是硬伤。
|
|
197
|
+
* 每次读/写前按 mtime 判断要不要重读 (stat 很便宜)。
|
|
198
|
+
*/
|
|
199
|
+
async refreshIfStale() {
|
|
200
|
+
try {
|
|
201
|
+
const st = await fsp.stat(this.grantsFile());
|
|
202
|
+
if (st.mtimeMs !== this.mtimeMs) {
|
|
203
|
+
this.loaded = false;
|
|
204
|
+
this.corrupt = false;
|
|
205
|
+
await this.load();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch { /* 文件还不存在 (或刚被删) → 保持现状 */ }
|
|
209
|
+
}
|
|
210
|
+
async persist() {
|
|
211
|
+
await this.atomic(this.grantsFile(), this.grants.slice(-200), 0o600);
|
|
212
|
+
await this.atomic(this.devicesFile(), this.devices.slice(-50), 0o600);
|
|
213
|
+
try {
|
|
214
|
+
this.mtimeMs = (await fsp.stat(this.grantsFile())).mtimeMs;
|
|
215
|
+
}
|
|
216
|
+
catch { /* 记不上下次 stat 再判 */ }
|
|
217
|
+
}
|
|
218
|
+
// ── 设备 ──────────────────────────────────────────────────────────────────
|
|
219
|
+
async registerDevice(deviceId, publicKeyPem, label) {
|
|
220
|
+
await this.load();
|
|
221
|
+
await this.refreshIfStale();
|
|
222
|
+
const i = this.devices.findIndex((d) => d.deviceId === deviceId);
|
|
223
|
+
const entry = { deviceId, publicKeyPem, label, registeredAt: new Date().toISOString() };
|
|
224
|
+
if (i >= 0)
|
|
225
|
+
this.devices[i] = entry;
|
|
226
|
+
else
|
|
227
|
+
this.devices.push(entry);
|
|
228
|
+
await this.persist();
|
|
229
|
+
return entry;
|
|
230
|
+
}
|
|
231
|
+
deviceTrusted(deviceId) { return this.devices.some((d) => d.deviceId === deviceId); }
|
|
232
|
+
getDevice(deviceId) { return this.devices.find((d) => d.deviceId === deviceId) || null; }
|
|
233
|
+
async listDevices() { await this.load(); return [...this.devices]; }
|
|
234
|
+
// ── Grant ─────────────────────────────────────────────────────────────────
|
|
235
|
+
async list() { await this.load(); await this.refreshIfStale(); return [...this.grants]; }
|
|
236
|
+
/** 当前生效的最高等级 Grant (撤销/暂停/过期的不算) */
|
|
237
|
+
async activeFor(ownerDid, now = Date.now()) {
|
|
238
|
+
await this.load();
|
|
239
|
+
await this.refreshIfStale();
|
|
240
|
+
if (this.corrupt)
|
|
241
|
+
return null;
|
|
242
|
+
const rank = { none: 0, task_once: 1, persistent: 2, full_contact_access: 3 };
|
|
243
|
+
const live = this.grants
|
|
244
|
+
.filter((g) => g.ownerDid === ownerDid && g.status === 'active' && (!g.revokedAt))
|
|
245
|
+
.sort((a, b) => (rank[b.level] - rank[a.level]) || (b.grantVersion - a.grantVersion));
|
|
246
|
+
return live[0] || null;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* 最高的**管辖** Grant。
|
|
250
|
+
* 有 active 的 → 按等级+版本取最高 (授权优先)
|
|
251
|
+
* 一个 active 都没有 → 取最近一条失效授权, 用来给出精确原因 (revoked/suspended/expired)
|
|
252
|
+
*
|
|
253
|
+
* 2026-09-19 真跑抓到: 早先"按等级排全部 (含已撤销)"实现会让一条**已撤销的高等级授权**
|
|
254
|
+
* 盖住后建的**有效**低等级授权 → 明明有长期授权却判成 grant_revoked (用户看到"已撤销"却没撤销过)。
|
|
255
|
+
*/
|
|
256
|
+
async latestFor(ownerDid) {
|
|
257
|
+
await this.load();
|
|
258
|
+
await this.refreshIfStale();
|
|
259
|
+
if (this.corrupt)
|
|
260
|
+
return null;
|
|
261
|
+
const rank = { none: 0, task_once: 1, persistent: 2, full_contact_access: 3 };
|
|
262
|
+
const mine = this.grants.filter((g) => g.ownerDid === ownerDid);
|
|
263
|
+
if (!mine.length)
|
|
264
|
+
return null;
|
|
265
|
+
const live = mine.filter((g) => g.status === 'active' && !g.revokedAt);
|
|
266
|
+
if (live.length)
|
|
267
|
+
return live.sort((a, b) => (rank[b.level] - rank[a.level]) || (b.grantVersion - a.grantVersion))[0];
|
|
268
|
+
const at = (g) => Date.parse(g.revokedAt || g.suspendedAt || g.grantedAt || '') || 0;
|
|
269
|
+
return [...mine].sort((a, b) => at(b) - at(a) || b.grantVersion - a.grantVersion)[0];
|
|
270
|
+
}
|
|
271
|
+
async get(grantId) {
|
|
272
|
+
await this.load();
|
|
273
|
+
await this.refreshIfStale();
|
|
274
|
+
return this.grants.find((g) => g.grantId === grantId) || null;
|
|
275
|
+
}
|
|
276
|
+
async create(input) {
|
|
277
|
+
await this.load();
|
|
278
|
+
const preset = presetForChoice(input.choice || 'persistent');
|
|
279
|
+
const now = new Date().toISOString();
|
|
280
|
+
const grant = {
|
|
281
|
+
grantId: `gr-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`,
|
|
282
|
+
identityId: input.identityId,
|
|
283
|
+
ownerDid: input.ownerDid,
|
|
284
|
+
level: input.level || preset.level,
|
|
285
|
+
channels: input.channels || preset.channels,
|
|
286
|
+
contactScope: input.contactScope || preset.contactScope,
|
|
287
|
+
taskScope: input.taskScope || preset.taskScope,
|
|
288
|
+
contentScope: input.contentScope || preset.contentScope,
|
|
289
|
+
replyWakeAllowed: preset.replyWakeAllowed,
|
|
290
|
+
autoSend: input.level && input.level !== 'task_once' ? true : preset.autoSend,
|
|
291
|
+
sensitiveContentAllowed: input.contentScope === 'sensitive' || preset.sensitiveContentAllowed,
|
|
292
|
+
newRecipientAllowed: preset.newRecipientAllowed,
|
|
293
|
+
grantedAt: now,
|
|
294
|
+
grantedBy: input.grantedBy,
|
|
295
|
+
grantedVia: input.grantedVia,
|
|
296
|
+
deviceIds: input.deviceIds || [],
|
|
297
|
+
status: 'active',
|
|
298
|
+
grantVersion: 1,
|
|
299
|
+
signature: input.signature,
|
|
300
|
+
};
|
|
301
|
+
this.grants.push(grant);
|
|
302
|
+
await this.persist();
|
|
303
|
+
return grant;
|
|
304
|
+
}
|
|
305
|
+
/** 暂停 / 恢复 / 撤销 (撤销是终态: 版本 +1, 不允许重新启用同一 grantId) */
|
|
306
|
+
async transition(grantId, action, opts = {}) {
|
|
307
|
+
await this.load();
|
|
308
|
+
await this.refreshIfStale();
|
|
309
|
+
const g = this.grants.find((x) => x.grantId === grantId);
|
|
310
|
+
if (!g)
|
|
311
|
+
return { ok: false, error: 'grant_not_found' };
|
|
312
|
+
if (g.status === 'revoked')
|
|
313
|
+
return { ok: false, error: 'grant_already_revoked' };
|
|
314
|
+
const now = new Date().toISOString();
|
|
315
|
+
if (action === 'pause') {
|
|
316
|
+
g.status = 'suspended';
|
|
317
|
+
g.suspendedAt = now;
|
|
318
|
+
}
|
|
319
|
+
if (action === 'resume') {
|
|
320
|
+
if (g.status !== 'suspended')
|
|
321
|
+
return { ok: false, error: `grant 状态 ${g.status}, 不能恢复` };
|
|
322
|
+
g.status = 'active';
|
|
323
|
+
g.suspendedAt = undefined;
|
|
324
|
+
}
|
|
325
|
+
if (action === 'revoke') {
|
|
326
|
+
g.status = 'revoked';
|
|
327
|
+
g.revokedAt = now;
|
|
328
|
+
g.revokedBy = opts.by || 'user';
|
|
329
|
+
g.revokedReason = opts.reason;
|
|
330
|
+
}
|
|
331
|
+
g.grantVersion += 1;
|
|
332
|
+
await this.persist();
|
|
333
|
+
return { ok: true, grant: g };
|
|
334
|
+
}
|
|
335
|
+
async revokeAll(by, reason) {
|
|
336
|
+
await this.load();
|
|
337
|
+
const now = new Date().toISOString();
|
|
338
|
+
const out = [];
|
|
339
|
+
for (const g of this.grants) {
|
|
340
|
+
if (g.status === 'revoked')
|
|
341
|
+
continue;
|
|
342
|
+
g.status = 'revoked';
|
|
343
|
+
g.revokedAt = now;
|
|
344
|
+
g.revokedBy = by;
|
|
345
|
+
g.revokedReason = reason;
|
|
346
|
+
g.grantVersion += 1;
|
|
347
|
+
out.push(g);
|
|
348
|
+
}
|
|
349
|
+
await this.persist();
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
async markUsed(grantId) {
|
|
353
|
+
await this.load();
|
|
354
|
+
const g = this.grants.find((x) => x.grantId === grantId);
|
|
355
|
+
if (!g)
|
|
356
|
+
return;
|
|
357
|
+
g.lastUsedAt = new Date().toISOString();
|
|
358
|
+
await this.persist();
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* 接受手机同步来的 (已签名) Grant。
|
|
362
|
+
* 规则: ① 设备必须已登记 ② 签名必须验过 ③ 撤销优先 (本地已撤销且版本 >= 来的版本 → 拒绝)
|
|
363
|
+
* ④ 低版本不覆盖高版本 ⑤ 同版本内容不同 → 冲突, 不覆盖
|
|
364
|
+
*/
|
|
365
|
+
async applySignedSync(incoming, opts = {}) {
|
|
366
|
+
await this.load();
|
|
367
|
+
await this.refreshIfStale();
|
|
368
|
+
const requireSig = opts.requireSignature !== false;
|
|
369
|
+
if (requireSig) {
|
|
370
|
+
const dev = this.getDevice(incoming.signature?.deviceId || '');
|
|
371
|
+
if (!dev)
|
|
372
|
+
return { ok: false, code: 'grant_device_untrusted', reason: `设备 ${incoming.signature?.deviceId || '(无)'} 未登记, 拒绝接受该授权` };
|
|
373
|
+
const v = verifyGrantSignature(incoming, dev);
|
|
374
|
+
if (!v.ok)
|
|
375
|
+
return { ok: false, code: 'grant_device_untrusted', reason: `签名校验失败: ${v.reason}` };
|
|
376
|
+
}
|
|
377
|
+
const local = this.grants.find((g) => g.grantId === incoming.grantId);
|
|
378
|
+
if (!local) {
|
|
379
|
+
this.grants.push({ ...incoming });
|
|
380
|
+
await this.persist();
|
|
381
|
+
return { ok: true, grant: incoming };
|
|
382
|
+
}
|
|
383
|
+
if (local.status === 'revoked' && incoming.status !== 'revoked' && incoming.grantVersion <= local.grantVersion) {
|
|
384
|
+
return { ok: false, code: 'grant_version_conflict', reason: `本地 ${local.grantId} 已撤销 (v${local.grantVersion}) → 撤销优先于授权, 拒绝旧版复活` };
|
|
385
|
+
}
|
|
386
|
+
if (incoming.grantVersion < local.grantVersion) {
|
|
387
|
+
return { ok: false, code: 'grant_version_conflict', reason: `来的版本 v${incoming.grantVersion} 低于本地 v${local.grantVersion}, 拒绝覆盖` };
|
|
388
|
+
}
|
|
389
|
+
if (incoming.grantVersion === local.grantVersion && canonicalGrantPayload(incoming) !== canonicalGrantPayload(local)) {
|
|
390
|
+
return { ok: false, code: 'grant_version_conflict', reason: `同版本 v${incoming.grantVersion} 内容不一致, 拒绝覆盖 (撤销优先)` };
|
|
391
|
+
}
|
|
392
|
+
const i = this.grants.indexOf(local);
|
|
393
|
+
this.grants[i] = { ...incoming };
|
|
394
|
+
await this.persist();
|
|
395
|
+
return { ok: true, grant: this.grants[i] };
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* 手机撤销 → 桌面收到后立即失效 (撤销事件可以带更高的版本)。
|
|
399
|
+
* 带签名时**先验签**: 撤销也必须来自已登记设备 (否则本地进程能冒充手机收回/伪造撤销)。
|
|
400
|
+
*/
|
|
401
|
+
async applyRevocation(grantId, opts = {}) {
|
|
402
|
+
await this.load();
|
|
403
|
+
await this.refreshIfStale();
|
|
404
|
+
const g = this.grants.find((x) => x.grantId === grantId);
|
|
405
|
+
if (!g)
|
|
406
|
+
return { ok: false, code: 'grant_missing', reason: `本地没有 ${grantId}` };
|
|
407
|
+
if (opts.signature) {
|
|
408
|
+
const dev = this.getDevice(opts.signature.deviceId);
|
|
409
|
+
if (!dev)
|
|
410
|
+
return { ok: false, code: 'grant_device_untrusted', reason: `撤销来自未登记设备 ${opts.signature.deviceId}` };
|
|
411
|
+
const payload = canonicalRevocationPayload({
|
|
412
|
+
grantId, grantVersion: opts.version ?? g.grantVersion, revokedAt: opts.revokedAt || new Date().toISOString(), by: opts.by || 'mobile',
|
|
413
|
+
});
|
|
414
|
+
const hash = crypto.createHash('sha256').update(payload).digest('hex');
|
|
415
|
+
if (opts.signature.payloadHash !== hash)
|
|
416
|
+
return { ok: false, code: 'grant_device_untrusted', reason: '撤销载荷 hash 不匹配 (被改过)' };
|
|
417
|
+
const ok = (() => { try {
|
|
418
|
+
return crypto.verify(null, Buffer.from(payload, 'utf8'), crypto.createPublicKey(dev.publicKeyPem), Buffer.from(opts.signature.sig, 'base64'));
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
return false;
|
|
422
|
+
} })();
|
|
423
|
+
if (!ok)
|
|
424
|
+
return { ok: false, code: 'grant_device_untrusted', reason: '撤销签名校验失败' };
|
|
425
|
+
}
|
|
426
|
+
if (g.status === 'revoked')
|
|
427
|
+
return { ok: true, grant: g };
|
|
428
|
+
g.status = 'revoked';
|
|
429
|
+
g.revokedAt = new Date().toISOString();
|
|
430
|
+
g.revokedBy = opts.by || 'mobile';
|
|
431
|
+
g.revokedReason = opts.reason || '手机端撤销';
|
|
432
|
+
g.grantVersion = Math.max(g.grantVersion + 1, opts.version || 0);
|
|
433
|
+
await this.persist();
|
|
434
|
+
return { ok: true, grant: g };
|
|
435
|
+
}
|
|
436
|
+
/** 用户可见摘要 (CLI/Web/手机同一份事实) */
|
|
437
|
+
async summary(ownerDid) {
|
|
438
|
+
await this.load();
|
|
439
|
+
const label = GRANT_LEVEL_LABEL;
|
|
440
|
+
const mine = this.grants.filter((g) => g.ownerDid === ownerDid);
|
|
441
|
+
const active = await this.activeFor(ownerDid);
|
|
442
|
+
return {
|
|
443
|
+
grants: mine.map((g) => ({
|
|
444
|
+
grantId: g.grantId, level: g.level, userLabel: label[g.level], channels: g.channels, contactScope: g.contactScope,
|
|
445
|
+
taskScope: g.taskScope, contentScope: g.contentScope, status: g.status, grantVersion: g.grantVersion,
|
|
446
|
+
signed: !!g.signature, deviceId: g.signature?.deviceId, grantedAt: g.grantedAt, grantedVia: g.grantedVia, lastUsedAt: g.lastUsedAt,
|
|
447
|
+
})),
|
|
448
|
+
effective: active ? `${label[active.level]} (${active.grantId} v${active.grantVersion})` : '无长期授权 (每次联系都需要人工批准)',
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
}
|