@seasonkoh/webaz 0.1.27 → 0.1.28
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/README.md +1 -1
- package/dist/layer1-agent/L1-1-mcp-server/network-mode.js +69 -0
- package/dist/layer1-agent/L1-1-mcp-server/server.js +234 -54
- package/dist/pwa/public/app-account.js +977 -0
- package/dist/pwa/public/app-admin.js +608 -0
- package/dist/pwa/public/app-agents.js +63 -0
- package/dist/pwa/public/app-ai.js +2162 -0
- package/dist/pwa/public/app-contribution.js +836 -0
- package/dist/pwa/public/app-discover.js +1296 -0
- package/dist/pwa/public/app-listings.js +226 -0
- package/dist/pwa/public/app-profile.js +1692 -0
- package/dist/pwa/public/app-seller.js +199 -0
- package/dist/pwa/public/app-shop.js +1145 -0
- package/dist/pwa/public/app.js +9956 -18841
- package/dist/pwa/public/i18n.js +16 -0
- package/dist/pwa/public/index.html +10 -0
- package/dist/pwa/public/openapi.json +85 -1
- package/dist/pwa/routes/agent-grants.js +255 -0
- package/dist/pwa/routes/webauthn.js +6 -1
- package/dist/pwa/server-schema.js +9 -0
- package/dist/pwa/server.js +157 -1559
- package/dist/runtime/agent-grant-scopes.js +128 -0
- package/dist/runtime/agent-grant-verifier.js +67 -0
- package/dist/runtime/agent-pairing.js +60 -0
- package/dist/runtime/apply-webaz-runtime-schema.js +15 -0
- package/dist/runtime/webaz-schema-helpers.js +1848 -0
- package/package.json +11 -2
package/dist/pwa/public/i18n.js
CHANGED
|
@@ -4,6 +4,22 @@
|
|
|
4
4
|
window._lang = localStorage.getItem('webaz_lang') || 'zh'
|
|
5
5
|
|
|
6
6
|
const _EN = {
|
|
7
|
+
// ── RFC-020 Connected agents (app-agents.js) ─────────────────
|
|
8
|
+
'🔌 已连接的 Agent': '🔌 Connected agents',
|
|
9
|
+
'尚无已连接的 Agent': 'No connected agents yet',
|
|
10
|
+
'AI agent 通过 webaz_pair 配对、经你 Passkey 批准后出现在这里': 'Agents appear here after pairing via webaz_pair and your Passkey approval',
|
|
11
|
+
'这些是你授权给 AI agent 的委托凭证(作用域受限、短期、可随时撤销)。它们不是你的账号或密钥,永远无法动用资金、投票或改密钥。': 'These are scoped, short-lived, revocable delegation grants you authorized for AI agents. They are not your account or keys, and can never move funds, vote, or change keys.',
|
|
12
|
+
'有效': 'Active',
|
|
13
|
+
'最近使用': 'Last used',
|
|
14
|
+
'次调用': 'calls',
|
|
15
|
+
'从未使用': 'Never used',
|
|
16
|
+
'有效期至': 'Expires',
|
|
17
|
+
'未命名 Agent': 'Unnamed agent',
|
|
18
|
+
'仅安全只读权限': 'Safe read-only scopes',
|
|
19
|
+
'撤销访问': 'Revoke access',
|
|
20
|
+
'确认撤销此 Agent 的访问权限?该凭证将立即失效。': "Revoke this agent's access? The credential is invalidated immediately.",
|
|
21
|
+
'已撤销该 Agent 的访问': 'Agent access revoked',
|
|
22
|
+
'已连接的 Agent': 'Connected agents',
|
|
7
23
|
// ── General ──────────────────────────────────────────────────
|
|
8
24
|
'← 返回': '← Back',
|
|
9
25
|
'登录': 'Login',
|
|
@@ -43,6 +43,16 @@
|
|
|
43
43
|
</main>
|
|
44
44
|
</noscript>
|
|
45
45
|
<script src="/i18n.js"></script>
|
|
46
|
+
<script src="/app-admin.js"></script>
|
|
47
|
+
<script src="/app-contribution.js"></script>
|
|
48
|
+
<script src="/app-ai.js"></script>
|
|
49
|
+
<script src="/app-discover.js"></script>
|
|
50
|
+
<script src="/app-profile.js"></script>
|
|
51
|
+
<script src="/app-account.js"></script>
|
|
52
|
+
<script src="/app-shop.js"></script>
|
|
53
|
+
<script src="/app-listings.js"></script>
|
|
54
|
+
<script src="/app-seller.js"></script>
|
|
55
|
+
<script src="/app-agents.js"></script>
|
|
46
56
|
<script src="/app.js"></script>
|
|
47
57
|
</body>
|
|
48
58
|
</html>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "WebAZ Protocol API",
|
|
5
5
|
"version": "0.4.14",
|
|
6
|
-
"description": "Auto-generated endpoint inventory (
|
|
6
|
+
"description": "Auto-generated endpoint inventory (673 endpoints, 17 with full schema). See docs/ for design context."
|
|
7
7
|
},
|
|
8
8
|
"servers": [
|
|
9
9
|
{
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
"type": "apiKey",
|
|
22
22
|
"in": "header",
|
|
23
23
|
"name": "X-WebAuthn-Token"
|
|
24
|
+
},
|
|
25
|
+
"grantBearer": {
|
|
26
|
+
"type": "http",
|
|
27
|
+
"scheme": "bearer",
|
|
28
|
+
"bearerFormat": "gtk_ delegation-grant (RFC-020, safe scope)"
|
|
24
29
|
}
|
|
25
30
|
}
|
|
26
31
|
},
|
|
@@ -2086,6 +2091,85 @@
|
|
|
2086
2091
|
}
|
|
2087
2092
|
}
|
|
2088
2093
|
},
|
|
2094
|
+
"/api/agent-grants": {
|
|
2095
|
+
"get": {
|
|
2096
|
+
"summary": "\"Connected agents\" UI shows so a human can spot stale/unused or busy agents before revoking.",
|
|
2097
|
+
"tags": [],
|
|
2098
|
+
"security": [
|
|
2099
|
+
{
|
|
2100
|
+
"bearerAuth": []
|
|
2101
|
+
}
|
|
2102
|
+
]
|
|
2103
|
+
},
|
|
2104
|
+
"post": {
|
|
2105
|
+
"summary": "── Issue a grant (human-authenticated). Safe scopes only; risk/never-delegable rejected. ──",
|
|
2106
|
+
"tags": [],
|
|
2107
|
+
"security": [
|
|
2108
|
+
{
|
|
2109
|
+
"bearerAuth": []
|
|
2110
|
+
}
|
|
2111
|
+
]
|
|
2112
|
+
}
|
|
2113
|
+
},
|
|
2114
|
+
"/api/agent-grants/:grant_id/revoke": {
|
|
2115
|
+
"post": {
|
|
2116
|
+
"summary": "── Revoke (online, one-click). ──",
|
|
2117
|
+
"tags": [],
|
|
2118
|
+
"security": [
|
|
2119
|
+
{
|
|
2120
|
+
"bearerAuth": []
|
|
2121
|
+
}
|
|
2122
|
+
]
|
|
2123
|
+
}
|
|
2124
|
+
},
|
|
2125
|
+
"/api/agent-grants/pair/:pairing_id/retrieve": {
|
|
2126
|
+
"post": {
|
|
2127
|
+
"summary": "(pair 4) Agent retrieves the credential ONCE via PKCE verifier — UNAUTHENTICATED (PKCE-gated).",
|
|
2128
|
+
"tags": [],
|
|
2129
|
+
"security": []
|
|
2130
|
+
}
|
|
2131
|
+
},
|
|
2132
|
+
"/api/agent-grants/pair/:user_code": {
|
|
2133
|
+
"get": {
|
|
2134
|
+
"summary": "(pair 2) Human reviews the server-generated consent — human-authenticated.",
|
|
2135
|
+
"tags": [],
|
|
2136
|
+
"security": [
|
|
2137
|
+
{
|
|
2138
|
+
"bearerAuth": []
|
|
2139
|
+
}
|
|
2140
|
+
]
|
|
2141
|
+
}
|
|
2142
|
+
},
|
|
2143
|
+
"/api/agent-grants/pair/:user_code/approve": {
|
|
2144
|
+
"post": {
|
|
2145
|
+
"summary": "(pair 3) Human approves — human-authenticated. Issues the grant (token_hash filled at retrieve).",
|
|
2146
|
+
"tags": [],
|
|
2147
|
+
"security": [
|
|
2148
|
+
{
|
|
2149
|
+
"bearerAuth": []
|
|
2150
|
+
}
|
|
2151
|
+
]
|
|
2152
|
+
}
|
|
2153
|
+
},
|
|
2154
|
+
"/api/agent-grants/pair/start": {
|
|
2155
|
+
"post": {
|
|
2156
|
+
"summary": "(pair 1) Agent starts a pairing — UNAUTHENTICATED (agent has no credential yet). Safe scopes only.",
|
|
2157
|
+
"tags": [],
|
|
2158
|
+
"security": []
|
|
2159
|
+
}
|
|
2160
|
+
},
|
|
2161
|
+
"/api/agent-grants/whoami": {
|
|
2162
|
+
"get": {
|
|
2163
|
+
"summary": "end-to-end on a brand-new read-only endpoint that touches NO existing route and NO money path.",
|
|
2164
|
+
"tags": [],
|
|
2165
|
+
"security": [
|
|
2166
|
+
{
|
|
2167
|
+
"grantBearer": []
|
|
2168
|
+
}
|
|
2169
|
+
],
|
|
2170
|
+
"x-webaz-grant-scope": "read_public"
|
|
2171
|
+
}
|
|
2172
|
+
},
|
|
2089
2173
|
"/api/agent/acp-feed": {
|
|
2090
2174
|
"get": {
|
|
2091
2175
|
"summary": "GET /api/agent/acp-feed",
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js';
|
|
3
|
+
import { initAgentDelegationGrantsSchema, initAgentPairingSchema, initAgentGrantAuthLogSchema } from '../../runtime/webaz-schema-helpers.js';
|
|
4
|
+
import { validateRequestedCapabilities, clampTtlSeconds, grantIsActive } from '../../runtime/agent-grant-scopes.js';
|
|
5
|
+
import { generateUserCode, verifyPkceS256, clampPairingTtlSeconds, pairingApprovable, pairingRetrievable } from '../../runtime/agent-pairing.js';
|
|
6
|
+
import { verifyGrantToken } from '../../runtime/agent-grant-verifier.js';
|
|
7
|
+
// Bounds on a pairing request (anti-bloat for the anonymous start endpoint).
|
|
8
|
+
const MAX_CAPABILITIES = 12;
|
|
9
|
+
const MAX_CONSTRAINTS_JSON = 2000;
|
|
10
|
+
function safeParseCaps(json) {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(String(json));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Server-generated consent view for a pairing — canonical scope labels only, no secrets. */
|
|
19
|
+
function consentView(p) {
|
|
20
|
+
return {
|
|
21
|
+
pairing_id: p.pairing_id,
|
|
22
|
+
agent_label: p.agent_label || null,
|
|
23
|
+
reason: p.reason || null, // agent-supplied free text (display only)
|
|
24
|
+
capabilities: safeParseCaps(p.capabilities), // server-validated safe scopes
|
|
25
|
+
status: p.status,
|
|
26
|
+
expires_at: p.expires_at,
|
|
27
|
+
notice: 'Approving issues a scoped, short-lived, revocable delegation grant — NOT your api_key, NOT your funds. Safe (read/draft) scopes only; it can never move money, vote, arbitrate, or change keys.',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function registerAgentGrantsRoutes(app, deps) {
|
|
31
|
+
const { db, auth, generateId, rateLimitOk } = deps;
|
|
32
|
+
// PWA runtime self-init (MCP gets the tables via applyWebazRuntimeSchema). Idempotent.
|
|
33
|
+
initAgentDelegationGrantsSchema(db);
|
|
34
|
+
initAgentPairingSchema(db);
|
|
35
|
+
initAgentGrantAuthLogSchema(db);
|
|
36
|
+
// ─────────────────────────── RFC-020 PR-C2a: opt-in grant-scope enforcement ───────────────────────────
|
|
37
|
+
// EXPLICIT, per-route, per-SAFE-scope. NOT global auth — a gtk_* token is accepted ONLY by routes that
|
|
38
|
+
// deliberately mount requireAgentGrantScope(scope); auth()/api_key is untouched and never accepts gtk_*.
|
|
39
|
+
// Risk / never-delegable scopes can never pass (the verifier hard-fails non-safe required scopes).
|
|
40
|
+
const requireAgentGrantScope = (scope) => async (req, res, next) => {
|
|
41
|
+
// Anti-abuse: throttle the grant-consumption path BEFORE any DB work (parity with pair/start).
|
|
42
|
+
// Bounds both anonymous probing and valid-grant spam, and caps audit-log growth.
|
|
43
|
+
if (!rateLimitOk(`agent_grant:${req.ip || 'anon'}`, 30, 60_000)) {
|
|
44
|
+
return void res.status(429).json({ error: 'too_many_grant_requests', error_code: 'GRANT_RATE_LIMITED', retry_after_s: 60 });
|
|
45
|
+
}
|
|
46
|
+
const bearer = (req.header('authorization') || '').replace(/^Bearer\s+/i, '');
|
|
47
|
+
const presentedGrant = bearer.startsWith('gtk_'); // a request that presents no grant bearer isn't "grant-authorized"
|
|
48
|
+
const r = await verifyGrantToken(bearer, scope);
|
|
49
|
+
// Append-only audit (RFC-020 §3.7 + invariant: every grant-authorized request is audited). Only audit
|
|
50
|
+
// requests that actually presented a grant bearer — a no-token request is pure noise (and an unauth
|
|
51
|
+
// bloat vector), not a grant-authorized request.
|
|
52
|
+
let audited = false;
|
|
53
|
+
if (presentedGrant) {
|
|
54
|
+
try {
|
|
55
|
+
await dbRun('INSERT INTO agent_grant_auth_log (grant_id, human_id, capability, outcome, error_code) VALUES (?,?,?,?,?)', [r.ok ? r.principal.grant_id : (r.grant_id ?? null), r.ok ? r.principal.human_id : (r.human_id ?? null), scope, r.ok ? 'allow' : 'deny', r.ok ? null : r.error_code]);
|
|
56
|
+
audited = true;
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
console.error('[agent-grant] audit write failed:', e.message);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Deny path: return the denial regardless of audit (no access is granted, so nothing to fail closed on).
|
|
63
|
+
if (!r.ok)
|
|
64
|
+
return void res.status(r.status).json({ error: r.error, error_code: r.error_code });
|
|
65
|
+
// Success path: FAIL CLOSED if the authorization could not be audited — a grant-authorized request
|
|
66
|
+
// must never proceed unaudited (RFC-020 invariant). Better to deny (503, retryable) than act unaccountably.
|
|
67
|
+
if (!audited)
|
|
68
|
+
return void res.status(503).json({ error: 'authorization audit unavailable; refusing to proceed unaudited', error_code: 'GRANT_AUDIT_FAILED' });
|
|
69
|
+
req.agentGrant = r.principal;
|
|
70
|
+
next();
|
|
71
|
+
};
|
|
72
|
+
// Vertical slice (zero-risk): grant principal introspection. Proves the verifier + opt-in middleware
|
|
73
|
+
// end-to-end on a brand-new read-only endpoint that touches NO existing route and NO money path.
|
|
74
|
+
app.get('/api/agent-grants/whoami', requireAgentGrantScope('read_public'), (req, res) => {
|
|
75
|
+
const p = req.agentGrant;
|
|
76
|
+
res.json({ grant: p, note: 'Authorized via delegation grant (safe scope read_public). This is a grant principal, not a human session.' });
|
|
77
|
+
});
|
|
78
|
+
// ─────────────────────────── RFC-020 PR-C1: pairing (device-flow + PKCE) ───────────────────────────
|
|
79
|
+
// C1 = pairing + credential delivery ONLY. No grant is consumed by any tool here (that is PR-C2).
|
|
80
|
+
// (pair 1) Agent starts a pairing — UNAUTHENTICATED (agent has no credential yet). Safe scopes only.
|
|
81
|
+
app.post('/api/agent-grants/pair/start', async (req, res) => {
|
|
82
|
+
// Rate-limit the anonymous write FIRST (anti-bloat: no DB row unless under the cap).
|
|
83
|
+
if (!rateLimitOk(`agent_pair_start:${req.ip || 'anon'}`, 10, 60_000)) {
|
|
84
|
+
return void res.status(429).json({ error: 'too_many_pairing_starts', retry_after_s: 60 });
|
|
85
|
+
}
|
|
86
|
+
const body = (req.body || {});
|
|
87
|
+
const codeChallenge = typeof body.code_challenge === 'string' ? body.code_challenge : '';
|
|
88
|
+
if (!codeChallenge || codeChallenge.length < 32 || codeChallenge.length > 256)
|
|
89
|
+
return void res.status(400).json({ error: 'code_challenge required (PKCE S256)' });
|
|
90
|
+
const caps = Array.isArray(body.capabilities) ? body.capabilities : [];
|
|
91
|
+
if (caps.length > MAX_CAPABILITIES)
|
|
92
|
+
return void res.status(400).json({ error: 'too_many_capabilities', max: MAX_CAPABILITIES });
|
|
93
|
+
const v = validateRequestedCapabilities(caps);
|
|
94
|
+
if (!v.ok)
|
|
95
|
+
return void res.status(403).json({ error: 'pairing_rejected', rejected: v.rejected }); // risk + never-delegable hard-reject
|
|
96
|
+
const pairingId = generateId('par');
|
|
97
|
+
const userCode = generateUserCode();
|
|
98
|
+
const ttl = clampPairingTtlSeconds(body.ttl_seconds);
|
|
99
|
+
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
|
|
100
|
+
const label = typeof body.agent_label === 'string' ? body.agent_label.slice(0, 120) : null;
|
|
101
|
+
const reason = typeof body.reason === 'string' ? body.reason.slice(0, 280) : null; // agent free text only
|
|
102
|
+
const pubkey = typeof body.agent_pubkey === 'string' ? body.agent_pubkey.slice(0, 1000) : null; // RESERVED (PoP), not verified in C1
|
|
103
|
+
const capsJson = JSON.stringify(v.safe.map(c => ({ capability: c, constraints: (caps.find(x => x?.capability === c)?.constraints) || {} })));
|
|
104
|
+
if (capsJson.length > MAX_CONSTRAINTS_JSON)
|
|
105
|
+
return void res.status(400).json({ error: 'capabilities_too_large', max_bytes: MAX_CONSTRAINTS_JSON });
|
|
106
|
+
await dbRun('INSERT INTO agent_pairing_sessions (pairing_id, user_code, code_challenge, agent_label, agent_pubkey, reason, capabilities, status, expires_at) VALUES (?,?,?,?,?,?,?,?,?)', [pairingId, userCode, codeChallenge, label, pubkey, reason, capsJson, 'pending', expiresAt]);
|
|
107
|
+
res.status(201).json({
|
|
108
|
+
pairing_id: pairingId,
|
|
109
|
+
user_code: userCode,
|
|
110
|
+
approve_url: `/#pair?code=${userCode}`,
|
|
111
|
+
expires_at: expiresAt,
|
|
112
|
+
note: 'Ask the human to open approve_url at webaz.xyz (logged in) and approve. Then retrieve the credential with the PKCE verifier.',
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
// (pair 2) Human reviews the server-generated consent — human-authenticated.
|
|
116
|
+
app.get('/api/agent-grants/pair/:user_code', async (req, res) => {
|
|
117
|
+
const user = auth(req, res);
|
|
118
|
+
if (!user)
|
|
119
|
+
return;
|
|
120
|
+
const p = await dbOne('SELECT * FROM agent_pairing_sessions WHERE user_code = ?', [req.params.user_code]);
|
|
121
|
+
if (!p)
|
|
122
|
+
return void res.status(404).json({ error: 'pairing_not_found' });
|
|
123
|
+
if (!pairingApprovable(p, new Date().toISOString()))
|
|
124
|
+
return void res.status(409).json({ error: 'pairing_not_pending_or_expired', status: p.status });
|
|
125
|
+
res.json({ consent: consentView(p) });
|
|
126
|
+
});
|
|
127
|
+
// (pair 3) Human approves — human-authenticated. Issues the grant (token_hash filled at retrieve).
|
|
128
|
+
app.post('/api/agent-grants/pair/:user_code/approve', async (req, res) => {
|
|
129
|
+
const user = auth(req, res);
|
|
130
|
+
if (!user)
|
|
131
|
+
return;
|
|
132
|
+
const now = new Date().toISOString();
|
|
133
|
+
const p = await dbOne('SELECT * FROM agent_pairing_sessions WHERE user_code = ?', [req.params.user_code]);
|
|
134
|
+
if (!p)
|
|
135
|
+
return void res.status(404).json({ error: 'pairing_not_found' });
|
|
136
|
+
if (!pairingApprovable(p, now))
|
|
137
|
+
return void res.status(409).json({ error: 'pairing_not_pending_or_expired', status: p.status });
|
|
138
|
+
// Re-validate scopes at approval time (defense in depth) — must still be safe-only.
|
|
139
|
+
const caps = safeParseCaps(p.capabilities);
|
|
140
|
+
const v = validateRequestedCapabilities(caps);
|
|
141
|
+
if (!v.ok)
|
|
142
|
+
return void res.status(403).json({ error: 'pairing_rejected', rejected: v.rejected });
|
|
143
|
+
const grantId = generateId('grt');
|
|
144
|
+
const expiresAt = new Date(Date.now() + clampTtlSeconds(undefined) * 1000).toISOString();
|
|
145
|
+
// Grant created WITHOUT a token (token_hash NULL) — the bearer is minted only at retrieval.
|
|
146
|
+
await dbRun('INSERT INTO agent_delegation_grants (grant_id, human_id, agent_label, capabilities, token_hash, human_confirm_required, status, expires_at) VALUES (?,?,?,?,?,?,?,?)', [grantId, user.id, p.agent_label || null, JSON.stringify(caps), null, 0, 'active', expiresAt]);
|
|
147
|
+
await dbRun("UPDATE agent_pairing_sessions SET status='approved', human_id=?, grant_id=?, approved_at=? WHERE user_code=? AND status='pending'", [user.id, grantId, now, req.params.user_code]);
|
|
148
|
+
res.json({ success: true, grant_id: grantId, capabilities: caps });
|
|
149
|
+
});
|
|
150
|
+
// (pair 4) Agent retrieves the credential ONCE via PKCE verifier — UNAUTHENTICATED (PKCE-gated).
|
|
151
|
+
app.post('/api/agent-grants/pair/:pairing_id/retrieve', async (req, res) => {
|
|
152
|
+
const now = new Date().toISOString();
|
|
153
|
+
const verifier = typeof req.body?.code_verifier === 'string' ? req.body.code_verifier : '';
|
|
154
|
+
const p = await dbOne('SELECT * FROM agent_pairing_sessions WHERE pairing_id = ?', [req.params.pairing_id]);
|
|
155
|
+
if (!p)
|
|
156
|
+
return void res.status(404).json({ error: 'pairing_not_found' });
|
|
157
|
+
if (p.status === 'consumed' || p.consumed_at)
|
|
158
|
+
return void res.status(409).json({ error: 'pairing_already_consumed' });
|
|
159
|
+
if (!pairingRetrievable(p, now))
|
|
160
|
+
return void res.status(409).json({ error: 'pairing_not_approved_or_expired', status: p.status });
|
|
161
|
+
if (!verifyPkceS256(verifier, String(p.code_challenge)))
|
|
162
|
+
return void res.status(403).json({ error: 'pkce_mismatch' });
|
|
163
|
+
// Confirm the issued grant is still active (could have been revoked between approve and retrieve).
|
|
164
|
+
const grant = await dbOne('SELECT grant_id, status, capabilities, expires_at FROM agent_delegation_grants WHERE grant_id = ?', [String(p.grant_id)]);
|
|
165
|
+
if (!grant || grant.status !== 'active')
|
|
166
|
+
return void res.status(409).json({ error: 'grant_inactive' });
|
|
167
|
+
// Mint the bearer ONCE here; persist only its SHA-256 hash. Raw bearer is returned a single time.
|
|
168
|
+
const token = `gtk_${randomBytes(32).toString('hex')}`;
|
|
169
|
+
const tokenHash = createHash('sha256').update(token).digest('hex');
|
|
170
|
+
// One-time consume: only succeeds if still approved+unconsumed (guards against retrieval races/reuse).
|
|
171
|
+
const consumed = await dbRun("UPDATE agent_pairing_sessions SET status='consumed', consumed_at=? WHERE pairing_id=? AND status='approved' AND consumed_at IS NULL", [now, req.params.pairing_id]);
|
|
172
|
+
if (!consumed || consumed.changes !== 1)
|
|
173
|
+
return void res.status(409).json({ error: 'pairing_already_consumed' });
|
|
174
|
+
await dbRun('UPDATE agent_delegation_grants SET token_hash=? WHERE grant_id=?', [tokenHash, grant.grant_id]);
|
|
175
|
+
res.json({
|
|
176
|
+
grant_id: grant.grant_id,
|
|
177
|
+
token, // shown ONCE — agent stores it locally; server keeps only the hash
|
|
178
|
+
token_note: 'Shown once. Store in your OS secret store; the server keeps only a hash and cannot reissue it.',
|
|
179
|
+
capabilities: safeParseCaps(grant.capabilities),
|
|
180
|
+
expires_at: grant.expires_at,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
// ── Issue a grant (human-authenticated). Safe scopes only; risk/never-delegable rejected. ──
|
|
184
|
+
app.post('/api/agent-grants', async (req, res) => {
|
|
185
|
+
const user = auth(req, res);
|
|
186
|
+
if (!user)
|
|
187
|
+
return;
|
|
188
|
+
const body = (req.body || {});
|
|
189
|
+
const caps = Array.isArray(body.capabilities) ? body.capabilities : [];
|
|
190
|
+
const v = validateRequestedCapabilities(caps);
|
|
191
|
+
if (!v.ok) {
|
|
192
|
+
// Fail-closed: any risk / never-delegable / unknown scope rejects the whole request.
|
|
193
|
+
return void res.status(403).json({ error: 'grant_rejected', rejected: v.rejected });
|
|
194
|
+
}
|
|
195
|
+
const ttl = clampTtlSeconds(body.ttl_seconds);
|
|
196
|
+
const grantId = generateId('grt');
|
|
197
|
+
const token = `gtk_${randomBytes(32).toString('hex')}`; // bearer — shown once
|
|
198
|
+
const tokenHash = createHash('sha256').update(token).digest('hex');
|
|
199
|
+
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
|
|
200
|
+
const label = typeof body.agent_label === 'string' ? body.agent_label.slice(0, 120) : null;
|
|
201
|
+
const capsJson = JSON.stringify(v.safe.map(c => ({
|
|
202
|
+
capability: c,
|
|
203
|
+
constraints: (caps.find(x => x?.capability === c)?.constraints) || {},
|
|
204
|
+
})));
|
|
205
|
+
await dbRun('INSERT INTO agent_delegation_grants (grant_id, human_id, agent_label, capabilities, token_hash, human_confirm_required, status, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [grantId, user.id, label, capsJson, tokenHash, 0, 'active', expiresAt]);
|
|
206
|
+
res.status(201).json({
|
|
207
|
+
grant_id: grantId,
|
|
208
|
+
token,
|
|
209
|
+
token_note: 'Shown once — store securely. The server keeps only a hash; it cannot show this again.',
|
|
210
|
+
capabilities: JSON.parse(capsJson),
|
|
211
|
+
expires_at: expiresAt,
|
|
212
|
+
note: 'Bearer-first grant for safe scopes only. Risk scopes are not delegable until their route has a live-Passkey gate; PoP binding is required before any risk scope or longer-lived delegation (RFC-020).',
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
// ── Read: the human's connected agents (no secrets) + recent-use from the audit log (PR-D). ──
|
|
216
|
+
// last_used_at / use_count come from agent_grant_auth_log (RFC-020 §3.7) — the data the
|
|
217
|
+
// "Connected agents" UI shows so a human can spot stale/unused or busy agents before revoking.
|
|
218
|
+
app.get('/api/agent-grants', async (req, res) => {
|
|
219
|
+
const user = auth(req, res);
|
|
220
|
+
if (!user)
|
|
221
|
+
return;
|
|
222
|
+
const rows = await dbAll(`SELECT g.grant_id, g.agent_label, g.capabilities, g.status, g.created_at, g.expires_at, g.revoked_at, g.revoked_reason,
|
|
223
|
+
MAX(CASE WHEN l.outcome = 'allow' THEN l.ts END) AS last_used_at,
|
|
224
|
+
COUNT(CASE WHEN l.outcome = 'allow' THEN 1 END) AS use_count
|
|
225
|
+
FROM agent_delegation_grants g
|
|
226
|
+
LEFT JOIN agent_grant_auth_log l ON l.grant_id = g.grant_id
|
|
227
|
+
WHERE g.human_id = ?
|
|
228
|
+
GROUP BY g.grant_id
|
|
229
|
+
ORDER BY g.created_at DESC`, [user.id]);
|
|
230
|
+
const now = new Date().toISOString();
|
|
231
|
+
res.json({
|
|
232
|
+
grants: rows.map(g => ({
|
|
233
|
+
...g,
|
|
234
|
+
capabilities: safeParseCaps(g.capabilities),
|
|
235
|
+
use_count: Number(g.use_count) || 0,
|
|
236
|
+
active: grantIsActive(g, now),
|
|
237
|
+
})),
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
// ── Revoke (online, one-click). ──
|
|
241
|
+
app.post('/api/agent-grants/:grant_id/revoke', async (req, res) => {
|
|
242
|
+
const user = auth(req, res);
|
|
243
|
+
if (!user)
|
|
244
|
+
return;
|
|
245
|
+
const grantId = req.params.grant_id;
|
|
246
|
+
const g = await dbOne('SELECT grant_id, status FROM agent_delegation_grants WHERE grant_id = ? AND human_id = ?', [grantId, user.id]);
|
|
247
|
+
if (!g)
|
|
248
|
+
return void res.status(404).json({ error: 'grant_not_found' });
|
|
249
|
+
if (g.status === 'revoked')
|
|
250
|
+
return void res.json({ success: true, already_revoked: true, grant_id: grantId });
|
|
251
|
+
const reason = typeof req.body?.reason === 'string' ? req.body.reason.slice(0, 200) : null;
|
|
252
|
+
await dbRun("UPDATE agent_delegation_grants SET status = 'revoked', revoked_at = ?, revoked_reason = ? WHERE grant_id = ? AND human_id = ?", [new Date().toISOString(), reason, grantId, user.id]);
|
|
253
|
+
res.json({ success: true, grant_id: grantId });
|
|
254
|
+
});
|
|
255
|
+
}
|
|
@@ -3,8 +3,9 @@ import { randomBytes } from 'node:crypto';
|
|
|
3
3
|
// RFC-004 体验补:绑定 Passkey 后,追溯补发此前"已受理但无锚点跳过"的建设信誉。
|
|
4
4
|
import { grantPendingAnchorCredits } from '../../layer2-business/L2-8-feedback/build-feedback-engine.js';
|
|
5
5
|
import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
|
|
6
|
+
import { registerAgentGrantsRoutes } from './agent-grants.js'; // RFC-020 PR-B — Passkey-domain delegation grants (keeps server.ts untouched)
|
|
6
7
|
export function registerWebauthnRoutes(app, deps) {
|
|
7
|
-
const { db, auth, generateId, rpId, rpName, origin, challengeTtlMs, gateTtlMs, invalidateAgentRiskCacheForUser, requireHumanPresence } = deps;
|
|
8
|
+
const { db, auth, generateId, rateLimitOk, rpId, rpName, origin, challengeTtlMs, gateTtlMs, invalidateAgentRiskCacheForUser, requireHumanPresence } = deps;
|
|
8
9
|
// 1. 注册:start — 生成 challenge + 选项
|
|
9
10
|
app.post('/api/webauthn/register/start', async (req, res) => {
|
|
10
11
|
const user = auth(req, res);
|
|
@@ -192,4 +193,8 @@ export function registerWebauthnRoutes(app, deps) {
|
|
|
192
193
|
await dbRun('UPDATE users SET webauthn_required_for_withdraw = ? WHERE id = ?', [required, user.id]);
|
|
193
194
|
res.json({ success: true, required_for_withdraw: !!required });
|
|
194
195
|
});
|
|
196
|
+
// RFC-020 PR-B — agent delegation grants (issue/read/revoke). Co-registered with the
|
|
197
|
+
// Passkey security routes so the money-dense server.ts stays untouched. Safe scopes
|
|
198
|
+
// only; risk scopes default-hard-reject. Reuses db/auth/generateId from WebauthnDeps.
|
|
199
|
+
registerAgentGrantsRoutes(app, { db, auth, generateId, rateLimitOk });
|
|
195
200
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compatibility re-export.
|
|
3
|
+
*
|
|
4
|
+
* The pure idempotent schema helpers were relocated to a neutral top-level
|
|
5
|
+
* module (src/runtime/webaz-schema-helpers.ts) so the PWA boot path and the MCP
|
|
6
|
+
* runtime schema composition root can share ONE source. This file keeps the
|
|
7
|
+
* historical `./server-schema.js` import path working for src/pwa/server.ts.
|
|
8
|
+
*/
|
|
9
|
+
export * from '../runtime/webaz-schema-helpers.js';
|