@seasonkoh/webaz 0.1.29 → 0.1.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.
Files changed (126) hide show
  1. package/dist/bond-refund-blockers.js +50 -0
  2. package/dist/bond-slash.js +100 -0
  3. package/dist/bond-terms.js +31 -0
  4. package/dist/direct-pay-bond-rail-clearance.js +10 -5
  5. package/dist/direct-pay-cancel-refund.js +160 -0
  6. package/dist/direct-pay-create.js +78 -9
  7. package/dist/direct-pay-disclosures.js +17 -11
  8. package/dist/direct-pay-fee-ar.js +2 -2
  9. package/dist/direct-pay-fee-prepay-request.js +80 -0
  10. package/dist/direct-pay-returns.js +104 -0
  11. package/dist/direct-pay-stock.js +9 -0
  12. package/dist/direct-receive-deferral.js +30 -1
  13. package/dist/direct-receive-deposits.js +122 -17
  14. package/dist/free-shipping.js +37 -0
  15. package/dist/fx-rates.js +7 -0
  16. package/dist/layer0-foundation/L0-1-database/schema.js +246 -0
  17. package/dist/layer0-foundation/L0-2-state-machine/engine.js +1 -0
  18. package/dist/layer0-foundation/L0-2-state-machine/transitions.js +73 -0
  19. package/dist/layer1-agent/L1-1-mcp-server/server.js +150 -33
  20. package/dist/layer2-business/L2-6-notifications/notification-engine.js +110 -41
  21. package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +133 -31
  22. package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +16 -4
  23. package/dist/layer3-trust/L3-1-dispute-engine/mutual-cancel.js +156 -0
  24. package/dist/platform-receive-accounts.js +94 -0
  25. package/dist/pwa/arbitration-read-admin.js +37 -0
  26. package/dist/pwa/arbitrator-lifecycle.js +100 -0
  27. package/dist/pwa/contract-fingerprint.js +15 -0
  28. package/dist/pwa/direct-pay-order-redaction.js +20 -2
  29. package/dist/pwa/human-presence.js +2 -6
  30. package/dist/pwa/public/app-account.js +9 -9
  31. package/dist/pwa/public/app-admin-disputes.js +55 -0
  32. package/dist/pwa/public/app-agent-appeal.js +90 -0
  33. package/dist/pwa/public/app-agent-approvals.js +93 -0
  34. package/dist/pwa/public/app-agent-pair.js +127 -0
  35. package/dist/pwa/public/app-arbitrator-admin.js +87 -0
  36. package/dist/pwa/public/app-arbitrator-entry.js +9 -0
  37. package/dist/pwa/public/app-bond-deferral-ui.js +9 -0
  38. package/dist/pwa/public/app-bond-refund-ui.js +66 -0
  39. package/dist/pwa/public/app-bond-slash-ui.js +74 -0
  40. package/dist/pwa/public/app-bond-terms-ui.js +23 -0
  41. package/dist/pwa/public/app-bond-ui.js +108 -0
  42. package/dist/pwa/public/app-chat-poll.js +6 -6
  43. package/dist/pwa/public/app-contribution-hub.js +23 -0
  44. package/dist/pwa/public/app-direct-pay-accounts.js +1 -1
  45. package/dist/pwa/public/app-direct-pay-buyer.js +1 -1
  46. package/dist/pwa/public/app-direct-pay-cancel-refund.js +72 -0
  47. package/dist/pwa/public/app-direct-pay-copy.js +12 -0
  48. package/dist/pwa/public/app-direct-pay-fee-history.js +39 -0
  49. package/dist/pwa/public/app-direct-pay-fee-ops.js +1 -1
  50. package/dist/pwa/public/app-direct-pay-fee-request.js +81 -0
  51. package/dist/pwa/public/app-direct-pay-fee-requests-admin.js +70 -0
  52. package/dist/pwa/public/app-direct-pay-memo.js +14 -0
  53. package/dist/pwa/public/app-direct-pay-negotiation.js +35 -0
  54. package/dist/pwa/public/app-direct-pay-pay.js +15 -0
  55. package/dist/pwa/public/app-direct-pay-paymodal.js +32 -0
  56. package/dist/pwa/public/app-direct-pay-reconcile.js +19 -0
  57. package/dist/pwa/public/app-direct-pay-returns.js +56 -0
  58. package/dist/pwa/public/app-direct-pay-reveal.js +82 -0
  59. package/dist/pwa/public/app-direct-pay-sales-report.js +70 -0
  60. package/dist/pwa/public/app-direct-pay.js +36 -37
  61. package/dist/pwa/public/app-dispute-close-ui.js +38 -0
  62. package/dist/pwa/public/app-free-shipping-ui.js +29 -0
  63. package/dist/pwa/public/app-gmv-rail-split.js +12 -0
  64. package/dist/pwa/public/app-listing-commerce-ui.js +72 -0
  65. package/dist/pwa/public/app-mutual-cancel.js +54 -0
  66. package/dist/pwa/public/app-notif-templates-orders.js +43 -0
  67. package/dist/pwa/public/app-notif-templates.js +22 -0
  68. package/dist/pwa/public/app-order-accept-ui.js +158 -0
  69. package/dist/pwa/public/app-order-errors.js +50 -0
  70. package/dist/pwa/public/app-order-labels.js +19 -0
  71. package/dist/pwa/public/app-order-rail-filter.js +26 -0
  72. package/dist/pwa/public/app-platform-receive-accounts.js +140 -0
  73. package/dist/pwa/public/app-poll-governor.js +22 -0
  74. package/dist/pwa/public/app-profile.js +4 -4
  75. package/dist/pwa/public/app-purchase-terms-ui.js +68 -0
  76. package/dist/pwa/public/app-sale-regions-ui.js +38 -0
  77. package/dist/pwa/public/app-seller.js +1 -1
  78. package/dist/pwa/public/app-trade-tax-ui.js +33 -0
  79. package/dist/pwa/public/app.js +133 -160
  80. package/dist/pwa/public/i18n.js +675 -5
  81. package/dist/pwa/public/index.html +41 -0
  82. package/dist/pwa/public/openapi.json +719 -11
  83. package/dist/pwa/public/style.css +3 -0
  84. package/dist/pwa/routes/admin-direct-receive-deposits.js +215 -3
  85. package/dist/pwa/routes/admin-protocol-params.js +16 -0
  86. package/dist/pwa/routes/admin-reports.js +31 -5
  87. package/dist/pwa/routes/agent-governance.js +1 -1
  88. package/dist/pwa/routes/agent-grants.js +281 -32
  89. package/dist/pwa/routes/analytics.js +2 -0
  90. package/dist/pwa/routes/arbitrator.js +67 -14
  91. package/dist/pwa/routes/bond-seller.js +162 -0
  92. package/dist/pwa/routes/direct-pay-cancel-refund.js +111 -0
  93. package/dist/pwa/routes/direct-pay-disclosure-acks.js +9 -4
  94. package/dist/pwa/routes/direct-pay-pending-accept.js +222 -0
  95. package/dist/pwa/routes/direct-pay-returns.js +74 -0
  96. package/dist/pwa/routes/direct-pay-timeouts.js +186 -9
  97. package/dist/pwa/routes/disputes-read.js +20 -6
  98. package/dist/pwa/routes/disputes-write.js +91 -33
  99. package/dist/pwa/routes/external-anchors.js +4 -2
  100. package/dist/pwa/routes/fee-prepay-requests.js +66 -0
  101. package/dist/pwa/routes/governance-onboarding.js +46 -8
  102. package/dist/pwa/routes/logistics.js +4 -4
  103. package/dist/pwa/routes/me-data.js +2 -2
  104. package/dist/pwa/routes/mutual-cancel.js +62 -0
  105. package/dist/pwa/routes/orders-action.js +182 -9
  106. package/dist/pwa/routes/orders-create.js +18 -7
  107. package/dist/pwa/routes/orders-read.js +76 -14
  108. package/dist/pwa/routes/platform-receive-accounts.js +111 -0
  109. package/dist/pwa/routes/products-create.js +45 -16
  110. package/dist/pwa/routes/products-update.js +15 -1
  111. package/dist/pwa/routes/profile-identity.js +4 -2
  112. package/dist/pwa/routes/returns.js +39 -8
  113. package/dist/pwa/routes/seller-directpay-report.js +110 -0
  114. package/dist/pwa/routes/seller-quota.js +5 -1
  115. package/dist/pwa/routes/shipping-templates.js +219 -0
  116. package/dist/pwa/routes/snf.js +4 -1
  117. package/dist/pwa/routes/webauthn.js +3 -3
  118. package/dist/pwa/server.js +63 -40
  119. package/dist/runtime/agent-grant-scopes.js +69 -1
  120. package/dist/runtime/webaz-schema-helpers.js +57 -3
  121. package/dist/sale-regions.js +116 -0
  122. package/dist/shipping-templates.js +119 -0
  123. package/dist/trade-tax.js +99 -0
  124. package/dist/trade-terms.js +76 -0
  125. package/dist/version.js +1 -1
  126. package/package.json +61 -2
@@ -1,12 +1,16 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
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';
3
+ import { initAgentDelegationGrantsSchema, initAgentPairingSchema, initAgentGrantAuthLogSchema, initAgentPermissionRequestsSchema } from '../../runtime/webaz-schema-helpers.js';
4
+ import { validateRequestedCapabilities, clampTtlSeconds, grantIsActive, resolveBundle, durationAllowedForScopes, suggestedDurationForScopes, durationToSeconds, riskLevelForScopes } from '../../runtime/agent-grant-scopes.js';
5
5
  import { generateUserCode, verifyPkceS256, clampPairingTtlSeconds, pairingApprovable, pairingRetrievable } from '../../runtime/agent-pairing.js';
6
6
  import { verifyGrantToken } from '../../runtime/agent-grant-verifier.js';
7
7
  // Bounds on a pairing request (anti-bloat for the anonymous start endpoint).
8
8
  const MAX_CAPABILITIES = 12;
9
9
  const MAX_CONSTRAINTS_JSON = 2000;
10
+ // Thrown inside the approve transaction when the grant is no longer active (revoked/expired in the race
11
+ // window) — rolls the whole tx back so the request claim + expansion + audit are all-or-nothing.
12
+ class GrantInactiveError extends Error {
13
+ }
10
14
  function safeParseCaps(json) {
11
15
  try {
12
16
  return JSON.parse(String(json));
@@ -28,11 +32,23 @@ function consentView(p) {
28
32
  };
29
33
  }
30
34
  export function registerAgentGrantsRoutes(app, deps) {
31
- const { db, auth, generateId, rateLimitOk } = deps;
35
+ const { db, auth, generateId, rateLimitOk, requireHumanPresence, createProductDraftHandler } = deps;
32
36
  // PWA runtime self-init (MCP gets the tables via applyWebazRuntimeSchema). Idempotent.
33
37
  initAgentDelegationGrantsSchema(db);
34
38
  initAgentPairingSchema(db);
35
39
  initAgentGrantAuthLogSchema(db);
40
+ initAgentPermissionRequestsSchema(db);
41
+ // Resolve the ACTIVE grant behind a gtk_ bearer (no scope check) — used to bind a permission request to
42
+ // (grant_id, human_id). Returns null on missing/expired/revoked. token_hash lookup mirrors the verifier.
43
+ async function resolveActiveGrantByBearer(req) {
44
+ const bearer = (req.header('authorization') || '').replace(/^Bearer\s+/i, '');
45
+ if (!bearer.startsWith('gtk_'))
46
+ return null;
47
+ const g = await dbOne('SELECT grant_id, human_id, agent_label, capabilities, status, expires_at, revoked_at FROM agent_delegation_grants WHERE token_hash = ?', [createHash('sha256').update(bearer).digest('hex')]);
48
+ if (!g || !grantIsActive(g, new Date().toISOString()))
49
+ return null;
50
+ return { grant_id: g.grant_id, human_id: g.human_id, agent_label: g.agent_label, capabilities: g.capabilities };
51
+ }
36
52
  // ─────────────────────────── RFC-020 PR-C2a: opt-in grant-scope enforcement ───────────────────────────
37
53
  // EXPLICIT, per-route, per-SAFE-scope. NOT global auth — a gtk_* token is accepted ONLY by routes that
38
54
  // deliberately mount requireAgentGrantScope(scope); auth()/api_key is untouched and never accepts gtk_*.
@@ -60,8 +76,25 @@ export function registerAgentGrantsRoutes(app, deps) {
60
76
  }
61
77
  }
62
78
  // Deny path: return the denial regardless of audit (no access is granted, so nothing to fail closed on).
63
- if (!r.ok)
79
+ if (!r.ok) {
80
+ // Structured permission_required (RFC-020): the agent IS validly connected but its grant simply lacks
81
+ // this SAFE scope. Instead of a bare 403, hand it the exact next step — ask the human to expand the
82
+ // grant (approval_url + the create-request call), then retry this same request. Other grant failures
83
+ // (no/expired/revoked/suspended grant) stay plain: those aren't "request more", they must re-pair.
84
+ if (r.error_code === 'SCOPE_NOT_GRANTED') {
85
+ return void res.status(403).json({
86
+ error: `this action needs the "${scope}" permission, which your grant does not carry`,
87
+ error_code: 'PERMISSION_REQUIRED',
88
+ required_scope: scope,
89
+ missing_scopes: [scope],
90
+ approval_url: '/#agent-approvals',
91
+ retry_after_approval: true,
92
+ request_permission: { method: 'POST', endpoint: '/api/agent-grants/permission-requests', body: { scopes: [scope] } },
93
+ note: 'Ask the human to approve at approval_url; on approval your existing grant is expanded — then retry this request.',
94
+ });
95
+ }
64
96
  return void res.status(r.status).json({ error: r.error, error_code: r.error_code });
97
+ }
65
98
  // Success path: FAIL CLOSED if the authorization could not be audited — a grant-authorized request
66
99
  // must never proceed unaudited (RFC-020 invariant). Better to deny (503, retryable) than act unaccountably.
67
100
  if (!audited)
@@ -75,6 +108,211 @@ export function registerAgentGrantsRoutes(app, deps) {
75
108
  const p = req.agentGrant;
76
109
  res.json({ grant: p, note: 'Authorized via delegation grant (safe scope read_public). This is a grant principal, not a human session.' });
77
110
  });
111
+ // First REAL grant-consumed seller surface (Catalog Agent): read the grant human's OWN catalog. Read-only,
112
+ // money fields (commission/stake) excluded. A grant that lacks seller_products_read → structured
113
+ // permission_required (see requireAgentGrantScope) so the agent can request → human approves → retry.
114
+ // The consumption (allow AND the permission_required deny) is audited by the middleware.
115
+ app.get('/api/agent/seller/products', requireAgentGrantScope('seller_products_read'), async (req, res) => {
116
+ const p = req.agentGrant;
117
+ const rows = await dbAll("SELECT id, title, status, price, currency, stock, category, created_at, updated_at FROM products WHERE seller_id = ? AND status != 'deleted' ORDER BY created_at DESC LIMIT 200", [p.human_id]);
118
+ res.json({ seller_id: p.human_id, agent_label: p.agent_label, count: rows.length, products: rows, note: 'Seller-owned catalog read via delegation grant (safe scope seller_products_read). Read-only; no money/commission fields.' });
119
+ });
120
+ // POST create a DRAFT product via a delegation grant (Catalog Agent, safe scope seller_product_draft). The
121
+ // draft is FORCED to status='warehouse' (not public/sellable). PUBLISHING STAYS HUMAN-ONLY — the human
122
+ // flips warehouse→active in the existing 我的商品→仓库 UI (publish is never delegated to a grant, matching
123
+ // the taxonomy). Reuses the SAME product-create validation as the human POST /api/products
124
+ // (createProductDraftHandler) so the agent path can't drift. Audited by the middleware. Lightweight signal:
125
+ // a notification tells the human a draft is waiting to review + publish.
126
+ if (createProductDraftHandler) {
127
+ app.post('/api/agent/seller/products', requireAgentGrantScope('seller_product_draft'), async (req, res) => {
128
+ const p = req.agentGrant;
129
+ const human = await dbOne('SELECT id, role FROM users WHERE id = ?', [p.human_id]);
130
+ if (!human || human.role !== 'seller')
131
+ return void res.status(403).json({ error: 'the grant owner is not a seller — product drafts need a seller account', error_code: 'NOT_A_SELLER' });
132
+ await createProductDraftHandler(req, res, human, {
133
+ forceStatus: 'warehouse',
134
+ skipExternalLinkEffects: true, // a SAFE draft grant must never trigger wallet debit / verify_tasks / auto-verified links (source_url stays inert metadata)
135
+ onCreated: async (productId) => {
136
+ try {
137
+ await dbRun("INSERT INTO notifications (id, user_id, type, title, body) VALUES (?,?,?,?,?)", [generateId('ntf'), p.human_id, 'agent_product_draft', 'AI 助手起草了商品草稿', `${p.agent_label || 'An agent'} 起草了商品草稿 ${productId},请到「我的商品 → 仓库」审核并发布。`]);
138
+ }
139
+ catch (e) {
140
+ console.error('[agent-grant draft notify]', e.message);
141
+ }
142
+ },
143
+ });
144
+ });
145
+ }
146
+ // helpers for the permission-request flow ----------------------------------------------------------------
147
+ const parseCapList = (json) => { try {
148
+ const a = JSON.parse(json);
149
+ return Array.isArray(a) ? a : [];
150
+ }
151
+ catch {
152
+ return [];
153
+ } };
154
+ const scopeNames = (json) => parseCapList(json).map(c => (typeof c === 'string' ? c : c?.capability)).filter((s) => typeof s === 'string');
155
+ // Returns true iff the audit row was durably written. Callers on the grant-authorized SUCCESS path MUST
156
+ // fail closed when this is false (RFC-020 invariant: a grant-authorized action is audited or it does not
157
+ // happen) — parity with the requireAgentGrantScope middleware above.
158
+ const auditGrant = async (grantId, humanId, cap, outcome, errorCode) => {
159
+ try {
160
+ await dbRun('INSERT INTO agent_grant_auth_log (grant_id, human_id, capability, outcome, error_code) VALUES (?,?,?,?,?)', [grantId, humanId, cap, outcome, errorCode ?? null]);
161
+ return true;
162
+ }
163
+ catch (e) {
164
+ console.error('[agent-grant] audit write failed:', e.message);
165
+ return false;
166
+ }
167
+ };
168
+ const bundleSummary = (key) => { const b = key ? resolveBundle(key) : null; return b ? b.human_summary : null; };
169
+ // GET verify — grant-authed. Returns the FULL grant (scopes, bundle, expiry, status), not just read_public.
170
+ // Audited (acceptance #8: every grant use logs). Never returns the raw token/api_key.
171
+ app.get('/api/agent-grants/verify', async (req, res) => {
172
+ const g = await resolveActiveGrantByBearer(req);
173
+ if (!g) {
174
+ return void res.status(401).json({ error: 'active delegation grant required', error_code: 'GRANT_REQUIRED' });
175
+ }
176
+ const full = await dbOne('SELECT grant_id, human_id, agent_label, capabilities, status, expires_at, permission_bundle FROM agent_delegation_grants WHERE grant_id = ?', [g.grant_id]);
177
+ // Fail closed: a grant-authorized read is audited or it does not proceed (parity with requireAgentGrantScope).
178
+ if (!(await auditGrant(g.grant_id, g.human_id, 'grant:verify', 'allow')))
179
+ return void res.status(503).json({ error: 'authorization audit unavailable; refusing to proceed unaudited', error_code: 'GRANT_AUDIT_FAILED' });
180
+ res.json({ grant: { grant_id: full.grant_id, human_id: full.human_id, agent_label: full.agent_label, scopes: scopeNames(full.capabilities), permission_bundle: full.permission_bundle, expires_at: full.expires_at, status: full.status }, note: 'Full grant principal — all authorized scopes/bundle/expiry/status. Not a human session; never authorizes risk/never-delegable actions.' });
181
+ });
182
+ // POST create a permission request — the AGENT (holding its current grant) asks for MORE scope / a bundle.
183
+ // Bound to (human_id, grant_id) from the grant bearer. Rate-limited. Safe-only: risk/never-delegable are
184
+ // NOT grantable (they need a per-action live Passkey, not a persistent grant) → structured reject.
185
+ app.post('/api/agent-grants/permission-requests', async (req, res) => {
186
+ if (!rateLimitOk(`agent_perm_req:${req.ip || 'anon'}`, 20, 60_000))
187
+ return void res.status(429).json({ error: 'too_many_permission_requests', retry_after_s: 60 });
188
+ const g = await resolveActiveGrantByBearer(req);
189
+ if (!g)
190
+ return void res.status(401).json({ error: 'an active delegation grant is required to request more permissions (pair first with webaz_pair)', error_code: 'GRANT_REQUIRED' });
191
+ const body = (req.body || {});
192
+ const bundleKey = typeof body.bundle === 'string' ? body.bundle : null;
193
+ const bundle = bundleKey ? resolveBundle(bundleKey) : null;
194
+ if (bundleKey && !bundle)
195
+ return void res.status(400).json({ error: 'unknown permission bundle', error_code: 'UNKNOWN_BUNDLE' });
196
+ const scopes = bundle ? [...bundle.scopes] : (Array.isArray(body.scopes) ? body.scopes.filter((s) => typeof s === 'string') : []);
197
+ if (scopes.length === 0)
198
+ return void res.status(400).json({ error: 'bundle or scopes required', error_code: 'NO_SCOPES' });
199
+ const v = validateRequestedCapabilities(scopes.map(s => ({ capability: s })));
200
+ if (!v.ok)
201
+ return void res.status(403).json({ error: 'permission_not_grantable', error_code: 'PERMISSION_NOT_GRANTABLE', rejected: v.rejected, note: 'Only safe (read/draft) scopes can be granted. Risk actions (order/publish/refund/…) require the human to act with a live Passkey — they are never delegated to a persistent grant.' });
202
+ const risk = riskLevelForScopes(scopes);
203
+ const duration = durationAllowedForScopes(scopes, body.duration) ? body.duration : suggestedDurationForScopes(scopes);
204
+ const id = generateId('apr');
205
+ const reqTtlIso = new Date(Date.now() + 7 * 86400_000).toISOString(); // request auto-expires in 7d if unanswered
206
+ await dbRun('INSERT INTO agent_permission_requests (id, human_id, grant_id, agent_label, requested_scopes, permission_bundle, reason, task_context, risk_level, duration, status, expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)', [id, g.human_id, g.grant_id, g.agent_label, JSON.stringify(scopes), bundleKey, typeof body.reason === 'string' ? body.reason.slice(0, 280) : null, typeof body.task_context === 'string' ? body.task_context.slice(0, 500) : null, risk, duration, 'pending', reqTtlIso]);
207
+ res.status(201).json({ approval_id: id, approval_url: '/#agent-approvals', status: 'pending', risk_level: risk, requested_scopes: scopes, permission_bundle: bundleKey, human_summary: bundleSummary(bundleKey), suggested_duration: duration, note: 'Ask the human to open approve_url (logged in) and approve. On approval your existing grant is expanded; then retry.' });
208
+ });
209
+ // GET list this human's PENDING permission requests (for #agent-approvals). Human-authed.
210
+ app.get('/api/agent-grants/permission-requests', async (req, res) => {
211
+ const user = auth(req, res);
212
+ if (!user)
213
+ return;
214
+ const rows = await dbAll("SELECT id, agent_label, requested_scopes, permission_bundle, reason, task_context, risk_level, duration, created_at, expires_at FROM agent_permission_requests WHERE human_id = ? AND status = 'pending' AND expires_at > ? ORDER BY created_at DESC LIMIT 100", [user.id, new Date().toISOString()]);
215
+ res.json({ requests: rows.map(r => ({ ...r, requested_scopes: scopeNames(String(r.requested_scopes)), human_summary: bundleSummary(r.permission_bundle) })) });
216
+ });
217
+ // GET list the requests THIS grant created — GRANT-authed (the agent, via webaz_pair), so an agent can poll
218
+ // its own request status (pending/approved/rejected/expired) without hitting the target surface. Bound to
219
+ // grant_id: an agent sees ONLY its own requests, never the human's other agents'. Audited (fail-closed).
220
+ app.get('/api/agent-grants/my-permission-requests', async (req, res) => {
221
+ if (!rateLimitOk(`agent_perm_list:${req.ip || 'anon'}`, 30, 60_000))
222
+ return void res.status(429).json({ error: 'too_many_requests', error_code: 'GRANT_RATE_LIMITED', retry_after_s: 60 });
223
+ const g = await resolveActiveGrantByBearer(req);
224
+ if (!g)
225
+ return void res.status(401).json({ error: 'an active delegation grant is required (pair first with webaz_pair)', error_code: 'GRANT_REQUIRED' });
226
+ const rows = await dbAll('SELECT id, requested_scopes, permission_bundle, risk_level, duration, status, created_at, expires_at, approved_at FROM agent_permission_requests WHERE grant_id = ? ORDER BY created_at DESC LIMIT 50', [g.grant_id]);
227
+ if (!(await auditGrant(g.grant_id, g.human_id, 'grant:list_requests', 'allow')))
228
+ return void res.status(503).json({ error: 'authorization audit unavailable; refusing to proceed unaudited', error_code: 'GRANT_AUDIT_FAILED' });
229
+ res.json({ requests: rows.map(r => ({ ...r, requested_scopes: scopeNames(String(r.requested_scopes)), human_summary: bundleSummary(r.permission_bundle) })) });
230
+ });
231
+ // RFC-020: expanding an agent grant is a privilege escalation (like initial pairing) — a stolen web session
232
+ // must NOT widen an agent from read_public to a long-term bundle. So a LIVE Passkey bound to this request_id
233
+ // is required. Grant-active is checked BEFORE claiming the request, and the expand is guarded + reversible,
234
+ // so a mid-flight expire/revoke can never strand a phantom 'approved'.
235
+ // POST approve — human-authed + live Passkey; expands the bound grant (union scopes + bundle + extend expiry). Audited.
236
+ app.post('/api/agent-grants/permission-requests/:id/approve', async (req, res) => {
237
+ const user = auth(req, res);
238
+ if (!user)
239
+ return;
240
+ const now = new Date().toISOString();
241
+ const r = await dbOne('SELECT human_id, grant_id, requested_scopes, permission_bundle, duration, status, expires_at FROM agent_permission_requests WHERE id = ?', [req.params.id]);
242
+ if (!r)
243
+ return void res.status(404).json({ error: 'permission_request_not_found' });
244
+ if (r.human_id !== user.id)
245
+ return void res.status(403).json({ error: 'not your permission request' });
246
+ if (r.status !== 'pending' || r.expires_at <= now)
247
+ return void res.status(409).json({ error: 'permission_request_not_pending', status: r.status });
248
+ // Live Passkey, bound to THIS request (a token minted for request A can't approve B).
249
+ const hp = requireHumanPresence(user.id, 'agent_permission_approve', (req.body || {}).webauthn_token, 'require_human_presence_for_agent_permission_approve', (data) => { try {
250
+ return typeof data === 'object' && data !== null && data.request_id === req.params.id;
251
+ }
252
+ catch {
253
+ return false;
254
+ } });
255
+ if (!hp.ok)
256
+ return void res.status(412).json({ error: hp.reason, error_code: hp.error_code });
257
+ const reqScopes = scopeNames(r.requested_scopes);
258
+ // Defense in depth: re-validate safe-only + duration allowed at approval time.
259
+ if (!validateRequestedCapabilities(reqScopes.map(s => ({ capability: s }))).ok)
260
+ return void res.status(403).json({ error: 'permission_not_grantable', error_code: 'PERMISSION_NOT_GRANTABLE' });
261
+ if (!durationAllowedForScopes(reqScopes, r.duration))
262
+ return void res.status(403).json({ error: 'duration_not_allowed_for_risk', error_code: 'DURATION_NOT_ALLOWED' });
263
+ // (P2) Verify the grant is ACTIVE *before* claiming the request — never leave a phantom 'approved'.
264
+ const grant = await dbOne('SELECT grant_id, capabilities, status, expires_at, revoked_at FROM agent_delegation_grants WHERE grant_id = ?', [r.grant_id]);
265
+ if (!grant || !grantIsActive(grant, now))
266
+ return void res.status(409).json({ error: 'grant_inactive', error_code: 'GRANT_INACTIVE', note: 'the agent grant expired or was revoked; re-pair (this request stays pending)' });
267
+ // Union new scopes into the grant; set bundle; extend expiry (never shorten). 'once' → short 1h window.
268
+ const union = [...new Set([...scopeNames(grant.capabilities), ...reqScopes])].map(s => ({ capability: s, constraints: {} }));
269
+ const secs = durationToSeconds(r.duration) || 3600;
270
+ const newExpiry = new Date(Date.now() + secs * 1000).toISOString();
271
+ const expiresAt = newExpiry > grant.expires_at ? newExpiry : grant.expires_at;
272
+ // ATOMIC (RFC-020 invariant: a grant expansion is audited-or-it-does-not-happen; parity with arbitrator
273
+ // approve). CAS-claim the request + guarded-expand the grant + write the audit row in ONE sync
274
+ // db.transaction. If the audit INSERT throws, OR the grant was revoked in the race window (guarded
275
+ // WHERE → 0 rows → GrantInactiveError), the WHOLE tx rolls back: scopes unchanged, request stays pending.
276
+ let outcome;
277
+ try {
278
+ outcome = db.transaction(() => {
279
+ const claimed = db.prepare("UPDATE agent_permission_requests SET status='approved', approved_at=? WHERE id=? AND status='pending'").run(now, req.params.id);
280
+ if (claimed.changes !== 1)
281
+ return 'not_pending';
282
+ const expanded = db.prepare("UPDATE agent_delegation_grants SET capabilities=?, permission_bundle=COALESCE(?, permission_bundle), expires_at=? WHERE grant_id=? AND status='active' AND revoked_at IS NULL").run(JSON.stringify(union), r.permission_bundle, expiresAt, grant.grant_id);
283
+ if (expanded.changes !== 1)
284
+ throw new GrantInactiveError();
285
+ db.prepare('INSERT INTO agent_grant_auth_log (grant_id, human_id, capability, outcome, error_code) VALUES (?,?,?,?,?)').run(grant.grant_id, user.id, `permission_request:approve:${r.permission_bundle || reqScopes.join(',')}`, 'allow', null);
286
+ return 'expanded';
287
+ })();
288
+ }
289
+ catch (e) {
290
+ if (e instanceof GrantInactiveError)
291
+ return void res.status(409).json({ error: 'grant_inactive', error_code: 'GRANT_INACTIVE', note: 'the agent grant was revoked while approving; request stays pending' });
292
+ console.error('[agent-grant] approve tx failed (audit unavailable?):', e.message);
293
+ return void res.status(503).json({ error: 'authorization audit unavailable; refusing to expand unaudited', error_code: 'GRANT_AUDIT_FAILED' });
294
+ }
295
+ if (outcome === 'not_pending')
296
+ return void res.status(409).json({ error: 'permission_request_not_pending' });
297
+ res.json({ success: true, grant_id: grant.grant_id, scopes: union.map(u => u.capability), permission_bundle: r.permission_bundle, expires_at: expiresAt });
298
+ });
299
+ // POST reject — human-authed. Terminal 'rejected'; nothing is granted.
300
+ app.post('/api/agent-grants/permission-requests/:id/reject', async (req, res) => {
301
+ const user = auth(req, res);
302
+ if (!user)
303
+ return;
304
+ const r = await dbOne('SELECT human_id, status FROM agent_permission_requests WHERE id = ?', [req.params.id]);
305
+ if (!r)
306
+ return void res.status(404).json({ error: 'permission_request_not_found' });
307
+ if (r.human_id !== user.id)
308
+ return void res.status(403).json({ error: 'not your permission request' });
309
+ if (r.status !== 'pending')
310
+ return void res.status(409).json({ error: 'permission_request_not_pending', status: r.status });
311
+ const rj = await dbRun("UPDATE agent_permission_requests SET status='rejected' WHERE id=? AND status='pending'", [req.params.id]);
312
+ if (!rj || rj.changes !== 1)
313
+ return void res.status(409).json({ error: 'permission_request_not_pending' });
314
+ res.json({ success: true, status: 'rejected' });
315
+ });
78
316
  // ─────────────────────────── RFC-020 PR-C1: pairing (device-flow + PKCE) ───────────────────────────
79
317
  // C1 = pairing + credential delivery ONLY. No grant is consumed by any tool here (that is PR-C2).
80
318
  // (pair 1) Agent starts a pairing — UNAUTHENTICATED (agent has no credential yet). Safe scopes only.
@@ -135,6 +373,17 @@ export function registerAgentGrantsRoutes(app, deps) {
135
373
  return void res.status(404).json({ error: 'pairing_not_found' });
136
374
  if (!pairingApprovable(p, now))
137
375
  return void res.status(409).json({ error: 'pairing_not_pending_or_expired', status: p.status });
376
+ // 人工在场 gate:批准 = 真人 Passkey/WebAuthn 确认(默认必需,param 可关)。挡"账号被静默批准"。
377
+ // 注:这挡的是"是不是真人在批",不挡 illicit-consent(人被骗批错 agent)—— 那由前端强制口令核对 + safe-scope 兜底。
378
+ // token 必须【绑定这个配对码】(purpose_data.user_code === :user_code)—— 防"为批 A 拿到的 token 被首次提交去批 B"(与 delete_passkey 绑 credential_id 同法)。
379
+ const hp = requireHumanPresence(user.id, 'agent_pair_approve', (req.body || {}).webauthn_token, 'require_human_presence_for_agent_pair_approve', (data) => { try {
380
+ return typeof data === 'object' && data !== null && data.user_code === req.params.user_code;
381
+ }
382
+ catch {
383
+ return false;
384
+ } });
385
+ if (!hp.ok)
386
+ return void res.status(412).json({ error: hp.reason, error_code: hp.error_code });
138
387
  // Re-validate scopes at approval time (defense in depth) — must still be safe-only.
139
388
  const caps = safeParseCaps(p.capabilities);
140
389
  const v = validateRequestedCapabilities(caps);
@@ -142,11 +391,31 @@ export function registerAgentGrantsRoutes(app, deps) {
142
391
  return void res.status(403).json({ error: 'pairing_rejected', rejected: v.rejected });
143
392
  const grantId = generateId('grt');
144
393
  const expiresAt = new Date(Date.now() + clampTtlSeconds(undefined) * 1000).toISOString();
394
+ // 先【CAS 抢占】pending 配对(唯一赢家),再插 grant —— 竞态下输家 changes!==1 直接 409、不插任何 grant,
395
+ // 杜绝"插了 grant 但配对更新失败"留下 token_hash NULL 的 orphan grant(污染连接记录)。
396
+ const claimed = 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]);
397
+ if (!claimed || claimed.changes !== 1)
398
+ return void res.status(409).json({ error: 'pairing_not_pending_or_expired' });
145
399
  // Grant created WITHOUT a token (token_hash NULL) — the bearer is minted only at retrieval.
146
400
  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
401
  res.json({ success: true, grant_id: grantId, capabilities: caps });
149
402
  });
403
+ // (pair 3b) Human rejects — human-authenticated. Terminal 'rejected' → agent's retrieve fails clearly (no silent lingering).
404
+ // 拒绝是保护性动作,无需 Passkey(不签发任何凭证)。幂等:仅 pending 可拒。
405
+ app.post('/api/agent-grants/pair/:user_code/reject', async (req, res) => {
406
+ const user = auth(req, res);
407
+ if (!user)
408
+ return;
409
+ const p = await dbOne('SELECT status FROM agent_pairing_sessions WHERE user_code = ?', [req.params.user_code]);
410
+ if (!p)
411
+ return void res.status(404).json({ error: 'pairing_not_found' });
412
+ if (p.status !== 'pending')
413
+ return void res.status(409).json({ error: 'pairing_not_pending', status: p.status });
414
+ const r = await dbRun("UPDATE agent_pairing_sessions SET status='rejected', human_id=? WHERE user_code=? AND status='pending'", [user.id, req.params.user_code]);
415
+ if (!r || r.changes !== 1)
416
+ return void res.status(409).json({ error: 'pairing_not_pending' });
417
+ res.json({ success: true, status: 'rejected' });
418
+ });
150
419
  // (pair 4) Agent retrieves the credential ONCE via PKCE verifier — UNAUTHENTICATED (PKCE-gated).
151
420
  app.post('/api/agent-grants/pair/:pairing_id/retrieve', async (req, res) => {
152
421
  const now = new Date().toISOString();
@@ -180,36 +449,16 @@ export function registerAgentGrantsRoutes(app, deps) {
180
449
  expires_at: grant.expires_at,
181
450
  });
182
451
  });
183
- // ── Issue a grant (human-authenticated). Safe scopes only; risk/never-delegable rejected. ──
184
- app.post('/api/agent-grants', async (req, res) => {
452
+ // ── Direct grant issuance is DISABLED single blessed path is the Passkey pairing flow. ──
453
+ // 旧的"仅登录即直接 mint 原文 bearer"入口会旁路 #pair 的真人 Passkey 批准,削弱"human Passkey-approves
454
+ // agent delegation"的安全叙事。零消费方(前端/MCP/测试均不用),故降级为不可用,统一走 pairing。
455
+ app.post('/api/agent-grants', (req, res) => {
185
456
  const user = auth(req, res);
186
457
  if (!user)
187
458
  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).',
459
+ return void res.status(410).json({
460
+ error: 'USE_PAIRING_FLOW', error_code: 'USE_PAIRING_FLOW',
461
+ note: 'Direct grant issuance is disabled. Start pairing with webaz_pair (action=start); the human approves at /#pair with a Passkey, then the agent retrieves the credential with its PKCE verifier. No endpoint mints a bearer without a live Passkey ceremony bound to the pairing.',
213
462
  });
214
463
  });
215
464
  // ── Read: the human's connected agents (no secrets) + recent-use from the audit log (PR-D). ──
@@ -141,6 +141,8 @@ export function registerAnalyticsRoutes(app, deps) {
141
141
  SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_orders,
142
142
  SUM(CASE WHEN status IN ('paid','accepted','shipped','picked_up','in_transit','delivered','confirmed') THEN 1 ELSE 0 END) as in_progress_orders,
143
143
  COALESCE(SUM(CASE WHEN status = 'completed' THEN total_amount ELSE 0 END), 0) as gmv,
144
+ COALESCE(SUM(CASE WHEN status = 'completed' AND COALESCE(payment_rail,'escrow') = 'escrow' THEN total_amount ELSE 0 END), 0) as gmv_escrow,
145
+ COALESCE(SUM(CASE WHEN status = 'completed' AND payment_rail = 'direct_p2p' THEN total_amount ELSE 0 END), 0) as gmv_direct_pay,
144
146
  COALESCE(AVG(CASE WHEN status = 'completed' THEN total_amount END), 0) as aov
145
147
  FROM orders WHERE seller_id = ? AND created_at > datetime('now', '-' || ? || ' days')
146
148
  `, [user.id, windowDays]));
@@ -1,8 +1,42 @@
1
1
  import { dbOne, dbAll } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
2
+ import { grantArbitrator, grantArbitratorTx, suspendArbitrator, reinstateArbitrator, revokeArbitrator, listArbitrators } from '../arbitrator-lifecycle.js'; // PR-B/PR-C.2 生产仲裁员生命周期域逻辑
2
3
  export function registerArbitratorRoutes(app, deps) {
3
4
  // 只读/单写站点走 RFC-016 异步 seam;db 保留:apply/withdraw/approve/reject 是
4
5
  // stake 资金路径,状态翻转 + 钱包扣/退必须原子(db.transaction + CAS),Phase 3 迁 pg 行锁。
5
- const { db, generateId, auth, requireArbitrationAdmin, checkArbitratorEligibility, getArbitratorState, errorRes, logAdminAction, ARB_STAKE_REQUIRED, ARB_APP_REJECT_COOLDOWN_DAYS, } = deps;
6
+ const { db, generateId, auth, requireArbitrationAdmin, checkArbitratorEligibility, getArbitratorState, errorRes, logAdminAction, consumeGateToken, ARB_STAKE_REQUIRED, ARB_APP_REJECT_COOLDOWN_DAYS, } = deps;
7
+ // ── PR-B:生产仲裁员生命周期(grant/suspend/reinstate/revoke)。门(四端点共用):ROOT/admin auth +
8
+ // 现场真人 Passkey(purpose 绑动作 + purpose_data.user_id 绑目标,杜绝跨动作/跨目标复用)+ admin_audit_log(含失败留痕)。
9
+ // 域逻辑(active-only 授权源 / revoked 终态 / 拒非人类)在 arbitrator-lifecycle.ts;此处只做 auth + Passkey + 审计。
10
+ const arbLifecycle = (action, purpose, fn) => (req, res) => {
11
+ const admin = requireArbitrationAdmin(req, res);
12
+ if (!admin)
13
+ return;
14
+ const userId = String((action === 'grant' ? req.body?.user_id : req.params.user_id) || '');
15
+ const note = (req.body?.note ?? null);
16
+ if (!userId)
17
+ return void errorRes(res, 400, 'MISSING_USER_ID', '缺少目标 user_id');
18
+ const gate = consumeGateToken(admin.id, req.body?.webauthn_token, purpose, (d) => d?.user_id === userId);
19
+ if (!gate.ok) {
20
+ logAdminAction(admin.id, `arbitrator.${action}`, 'user', userId, { ok: false, gate: gate.reason }); // ROOT 尝试也留痕(purpose 不符/无 token)
21
+ return void errorRes(res, 412, 'HUMAN_PRESENCE_REQUIRED', gate.reason || '此操作需现场真人 Passkey 确认');
22
+ }
23
+ const r = fn(userId, admin.id, note);
24
+ logAdminAction(admin.id, `arbitrator.${action}`, 'user', userId, { ok: r.ok, error_code: r.error_code, note });
25
+ if (!r.ok)
26
+ return void errorRes(res, 409, r.error_code || 'ARB_MUTATION_FAILED', r.error || '操作失败');
27
+ res.json({ success: true, user_id: userId, action });
28
+ };
29
+ app.post('/api/admin/arbitrators/grant', arbLifecycle('grant', 'arbitrator_grant', (userId, adminId, note) => grantArbitrator(db, { userId, grantedBy: adminId, note })));
30
+ app.post('/api/admin/arbitrators/:user_id/suspend', arbLifecycle('suspend', 'arbitrator_suspend', (userId, _a, note) => suspendArbitrator(db, { userId, note })));
31
+ app.post('/api/admin/arbitrators/:user_id/reinstate', arbLifecycle('reinstate', 'arbitrator_reinstate', (userId, _a, note) => reinstateArbitrator(db, { userId, note })));
32
+ app.post('/api/admin/arbitrators/:user_id/revoke', arbLifecycle('revoke', 'arbitrator_revoke', (userId, _a, note) => revokeArbitrator(db, { userId, note })));
33
+ // 名册(admin 只读,无需 Passkey)。含 active/suspended/revoked 全量 + 状态。
34
+ app.get('/api/admin/arbitrators', (req, res) => {
35
+ const admin = requireArbitrationAdmin(req, res);
36
+ if (!admin)
37
+ return;
38
+ res.json({ arbitrators: listArbitrators(db) });
39
+ });
6
40
  app.get('/api/arbitrator/eligibility', (req, res) => {
7
41
  const user = auth(req, res);
8
42
  if (!user)
@@ -123,30 +157,49 @@ export function registerArbitratorRoutes(app, deps) {
123
157
  const admin = requireArbitrationAdmin(req, res);
124
158
  if (!admin)
125
159
  return;
126
- const { note } = req.body;
160
+ const note = (req.body?.note ?? null);
127
161
  const appRow = await dbOne("SELECT id, user_id, status FROM arbitrator_applications WHERE id = ?", [req.params.id]);
128
162
  if (!appRow)
129
163
  return void res.json({ error: '申请不存在' });
130
164
  if (appRow.status !== 'pending')
131
165
  return void res.json({ error: '该申请不在待审状态' });
132
- // 原子段:CAS 翻转 pending→approved + 入白名单仅在本请求真翻转时(防并发双批准)
166
+ // PR-B:批准 = 现场真人 Passkey(purpose 绑目标 user_id)+ 走【统一】grantArbitrator(含 revoked-terminal +
167
+ // 拒 system/agent/无 Passkey)。绝不 INSERT OR REPLACE 白名单(那会复活被撤销用户)。审计成功与失败。
168
+ const gate = consumeGateToken(admin.id, req.body?.webauthn_token, 'arbitrator_grant', (d) => d?.user_id === appRow.user_id);
169
+ if (!gate.ok) {
170
+ logAdminAction(admin.id, 'arbitrator.approve', 'user', appRow.user_id, { ok: false, gate: gate.reason });
171
+ return void errorRes(res, 412, 'HUMAN_PRESENCE_REQUIRED', gate.reason || '此操作需现场真人 Passkey 确认');
172
+ }
173
+ // PR-C.2 原子:grant 白名单 + 翻转申请状态在【同一事务】。任一失败一起回滚 —— 杜绝"grant 成功但申请已被并发撤回/拒绝"。
174
+ let gErr = null;
175
+ let raced = false;
133
176
  try {
134
177
  db.transaction(() => {
135
- const cas = db.prepare("UPDATE arbitrator_applications SET status='approved', reviewed_at=datetime('now'), reviewed_by=?, decision_note=? WHERE id=? AND status='pending'")
136
- .run(admin.id, note || null, appRow.id);
137
- if (cas.changes === 0)
138
- throw new Error('ARB_RACE');
139
- db.prepare(`INSERT OR REPLACE INTO arbitrator_whitelist (user_id, note, is_system, granted_by, stake_amount) VALUES (?,?,0,?,?)`)
140
- .run(appRow.user_id, note || '外部仲裁员批准', admin.id, ARB_STAKE_REQUIRED);
178
+ const g = grantArbitratorTx(db, { userId: appRow.user_id, grantedBy: admin.id, note: note ?? '外部申请批准' });
179
+ if (!g.ok) {
180
+ gErr = g.error_code || 'GRANT_FAILED';
181
+ throw new Error('ARB_ABORT');
182
+ } // 回滚(此时未写 CAS)
183
+ const cas = db.prepare("UPDATE arbitrator_applications SET status='approved', reviewed_at=datetime('now'), reviewed_by=?, decision_note=? WHERE id=? AND status='pending'").run(admin.id, note, appRow.id);
184
+ if (cas.changes === 0) {
185
+ raced = true;
186
+ throw new Error('ARB_ABORT');
187
+ } // 并发已改状态 → 回滚 grant
141
188
  })();
142
189
  }
143
190
  catch (e) {
144
- if (e.message === 'ARB_RACE')
145
- return void res.json({ error: '该申请不在待审状态' });
146
- console.error('[arbitrator approve tx]', e.message);
147
- return void res.status(500).json({ error: '批准失败,请重试' });
191
+ if (e.message !== 'ARB_ABORT')
192
+ throw e;
193
+ }
194
+ if (raced) {
195
+ logAdminAction(admin.id, 'arbitrator.approve', 'user', appRow.user_id, { ok: false, raced: true });
196
+ return void res.json({ error: '该申请不在待审状态' });
197
+ }
198
+ if (gErr) {
199
+ logAdminAction(admin.id, 'arbitrator.approve', 'user', appRow.user_id, { ok: false, error_code: gErr });
200
+ return void errorRes(res, 409, gErr, '批准失败');
148
201
  }
149
- logAdminAction(admin.id, 'approve_arbitrator', 'user', appRow.user_id, { note });
202
+ logAdminAction(admin.id, 'arbitrator.approve', 'user', appRow.user_id, { ok: true });
150
203
  res.json({ success: true });
151
204
  });
152
205
  app.post('/api/admin/arbitrator-applications/:id/reject', async (req, res) => {