@seasonkoh/webaz 0.1.30 → 0.1.32

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 (33) hide show
  1. package/README.md +3 -1
  2. package/dist/layer0-foundation/L0-2-state-machine/engine.js +1 -1
  3. package/dist/layer1-agent/L1-1-mcp-server/network-mode.js +4 -0
  4. package/dist/layer1-agent/L1-1-mcp-server/server.js +202 -19
  5. package/dist/layer2-business/L2-6-notifications/notification-engine.js +96 -0
  6. package/dist/layer3-trust/L3-1-dispute-engine/decline-contest-resolve.js +110 -0
  7. package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +109 -0
  8. package/dist/pwa/agent-order-minimal-view.js +29 -0
  9. package/dist/pwa/order-action-exec.js +158 -0
  10. package/dist/pwa/order-action-request.js +57 -0
  11. package/dist/pwa/public/app-admin-disputes.js +1 -1
  12. package/dist/pwa/public/app-agent-approvals-order.js +18 -0
  13. package/dist/pwa/public/app-agent-approvals.js +6 -7
  14. package/dist/pwa/public/app-agent-pair.js +2 -2
  15. package/dist/pwa/public/app-arbitrator-admin.js +1 -1
  16. package/dist/pwa/public/app-decline-contest-ruling.js +53 -0
  17. package/dist/pwa/public/app-decline-contest-ui.js +44 -0
  18. package/dist/pwa/public/app-grant-duration.js +20 -0
  19. package/dist/pwa/public/app.js +9 -9
  20. package/dist/pwa/public/i18n.js +24 -1
  21. package/dist/pwa/public/index.html +4 -0
  22. package/dist/pwa/public/openapi.json +78 -23
  23. package/dist/pwa/routes/admin-reports.js +40 -3
  24. package/dist/pwa/routes/agent-grants.js +147 -14
  25. package/dist/pwa/routes/disputes-read.js +17 -1
  26. package/dist/pwa/routes/disputes-write.js +42 -73
  27. package/dist/pwa/routes/orders-action.js +46 -3
  28. package/dist/pwa/routes/products-create.js +33 -13
  29. package/dist/pwa/routes/webauthn.js +1 -1
  30. package/dist/pwa/server.js +7 -7
  31. package/dist/runtime/agent-grant-scopes.js +26 -8
  32. package/dist/runtime/webaz-schema-helpers.js +28 -0
  33. package/package.json +15 -2
@@ -1,9 +1,13 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js';
3
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';
4
+ import { validateRequestedCapabilities, grantIsActive, resolveBundle, durationAllowedForScopes, suggestedDurationForScopes, allowedDurationsForScopes, 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
+ import { minimalSellerOrderView, MINIMAL_ORDER_COLUMNS } from '../agent-order-minimal-view.js'; // RFC-021 §6a 最小化订单读投影
8
+ import { createOrderActionRequest } from '../order-action-request.js'; // RFC-021 PR2 order-action 请求 domain(sync tx 在 domain 层,不增 route seam)
9
+ import { approveAndExecuteOrderAction } from '../order-action-exec.js'; // RFC-021 PR3 approve→执行(CAS approved + 执行 + executed_at CAS,domain 层)
10
+ import { notifyTransition } from '../../layer2-business/L2-6-notifications/notification-engine.js'; // 执行后通知买卖双方
7
11
  // Bounds on a pairing request (anti-bloat for the anonymous start endpoint).
8
12
  const MAX_CAPABILITIES = 12;
9
13
  const MAX_CONSTRAINTS_JSON = 2000;
@@ -28,11 +32,14 @@ function consentView(p) {
28
32
  capabilities: safeParseCaps(p.capabilities), // server-validated safe scopes
29
33
  status: p.status,
30
34
  expires_at: p.expires_at,
31
- 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.',
35
+ // Duration-choice: agent's suggested lifetime + the durations the human may pick (safe scopes up to 30d).
36
+ suggested_duration: p.grant_duration || suggestedDurationForScopes(safeParseCaps(p.capabilities).map(c => String(c?.capability || '')).filter(Boolean)),
37
+ allowed_durations: allowedDurationsForScopes(safeParseCaps(p.capabilities).map(c => String(c?.capability || '')).filter(Boolean)),
38
+ notice: 'Approving issues a scoped, 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. You choose how long it lasts.',
32
39
  };
33
40
  }
34
41
  export function registerAgentGrantsRoutes(app, deps) {
35
- const { db, auth, generateId, rateLimitOk, requireHumanPresence } = deps;
42
+ const { db, auth, generateId, rateLimitOk, requireHumanPresence, createProductDraftHandler } = deps;
36
43
  // PWA runtime self-init (MCP gets the tables via applyWebazRuntimeSchema). Idempotent.
37
44
  initAgentDelegationGrantsSchema(db);
38
45
  initAgentPairingSchema(db);
@@ -86,7 +93,9 @@ export function registerAgentGrantsRoutes(app, deps) {
86
93
  error: `this action needs the "${scope}" permission, which your grant does not carry`,
87
94
  error_code: 'PERMISSION_REQUIRED',
88
95
  required_scope: scope,
96
+ missing_scopes: [scope],
89
97
  approval_url: '/#agent-approvals',
98
+ retry_after_approval: true,
90
99
  request_permission: { method: 'POST', endpoint: '/api/agent-grants/permission-requests', body: { scopes: [scope] } },
91
100
  note: 'Ask the human to approve at approval_url; on approval your existing grant is expanded — then retry this request.',
92
101
  });
@@ -115,6 +124,72 @@ export function registerAgentGrantsRoutes(app, deps) {
115
124
  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]);
116
125
  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.' });
117
126
  });
127
+ // RFC-021 §6a — 最小化订单读(safe scope seller_orders_read_minimal)。仅读该 agent 之人(卖家)的订单;
128
+ // ALLOWLIST 投影(minimalSellerOrderView)只产出 6 字段,SELECT 只取非 PII 列(MINIMAL_ORDER_COLUMNS) ——
129
+ // 买家地址/联系/gift_recipient 连取都不取(I6)。纯只读,无任何执行(order_action_request 在 PR2/PR3 才有提交/执行)。
130
+ app.get('/api/agent/orders', requireAgentGrantScope('seller_orders_read_minimal'), async (req, res) => {
131
+ const p = req.agentGrant;
132
+ const rows = await dbAll(`SELECT ${MINIMAL_ORDER_COLUMNS.join(', ')} FROM orders WHERE seller_id = ? ORDER BY created_at DESC LIMIT 200`, [p.human_id]);
133
+ res.json({ seller_id: p.human_id, agent_label: p.agent_label, count: rows.length, orders: rows.map(o => minimalSellerOrderView(o, db)), note: 'RFC-021 minimal order read (safe scope seller_orders_read_minimal). No buyer address/contact; no execution.' });
134
+ });
135
+ app.get('/api/agent/orders/:id', requireAgentGrantScope('seller_orders_read_minimal'), async (req, res) => {
136
+ const p = req.agentGrant;
137
+ const o = await dbOne(`SELECT ${MINIMAL_ORDER_COLUMNS.join(', ')} FROM orders WHERE id = ? AND seller_id = ?`, [req.params.id, p.human_id]);
138
+ if (!o)
139
+ return void res.status(404).json({ error: '订单不存在或不属于你', error_code: 'ORDER_NOT_FOUND' });
140
+ res.json({ order: minimalSellerOrderView(o, db) });
141
+ });
142
+ // RFC-021 PR2 — order-action 请求提交(safe scope order_action_request)。SUBMIT-only:写 pending,【绝不执行】。
143
+ // D2 拒 decline;归属校验 seller 本人;ship 须带 tracking+evidence_ref(I4 提交侧);地址永不入参/入 audit(I6);
144
+ // 同 (order_id,action) 双 pending 被唯一索引拒。执行(accept/ship)在 PR3 经人 Passkey 批准后由服务端跑。
145
+ app.post('/api/agent/orders/:orderId/action-request', requireAgentGrantScope('order_action_request'), async (req, res) => {
146
+ const p = req.agentGrant;
147
+ const b = (req.body ?? {});
148
+ const orderId = String(req.params.orderId);
149
+ const r = createOrderActionRequest(db, { orderId, action: String(b.action ?? ''), rawParams: b.action_params, grantId: p.grant_id, humanId: p.human_id, agentLabel: p.agent_label, generateId });
150
+ if (!r.ok)
151
+ return void res.status(r.http || 400).json({ error: r.error, error_code: r.error_code });
152
+ res.json({ success: true, request_id: r.request_id, order_id: orderId, action: b.action, params_hash: r.params_hash, approval_url: '/#agent-approvals', note: 'Pending human Passkey approval. NOT executed — execution (accept/ship) lands in RFC-021 PR3.' });
153
+ });
154
+ // POST create a DRAFT product via a delegation grant (Catalog Agent, safe scope seller_product_draft). The
155
+ // draft is FORCED to status='warehouse' (not public/sellable). PUBLISHING STAYS HUMAN-ONLY — the human
156
+ // flips warehouse→active in the existing 我的商品→仓库 UI (publish is never delegated to a grant, matching
157
+ // the taxonomy). Reuses the SAME product-create validation as the human POST /api/products
158
+ // (createProductDraftHandler) so the agent path can't drift. Audited by the middleware. Lightweight signal:
159
+ // a notification tells the human a draft is waiting to review + publish.
160
+ if (createProductDraftHandler) {
161
+ app.post('/api/agent/seller/products', requireAgentGrantScope('seller_product_draft'), async (req, res) => {
162
+ const p = req.agentGrant;
163
+ const human = await dbOne('SELECT id, role FROM users WHERE id = ?', [p.human_id]);
164
+ if (!human || human.role !== 'seller')
165
+ 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' });
166
+ // Accurate error codes for the GRANT path WITHOUT touching the shared handler / human path: the create
167
+ // handler emits legacy validation errors as `200 + {error}` (no success). For a grant caller that's
168
+ // misleading, so translate exactly those to `400 VALIDATION_ERROR`. Explicit res.status(400/429/500…)
169
+ // and the success ({product_id}) response pass through unchanged.
170
+ const origJson = res.json.bind(res);
171
+ res.json = (body) => {
172
+ const b = body;
173
+ if (res.statusCode === 200 && b && typeof b === 'object' && b.error && !b.success) {
174
+ res.status(400);
175
+ return origJson({ error: b.error, error_code: 'VALIDATION_ERROR' });
176
+ }
177
+ return origJson(body);
178
+ };
179
+ await createProductDraftHandler(req, res, human, {
180
+ forceStatus: 'warehouse',
181
+ skipExternalLinkEffects: true, // a SAFE draft grant must never trigger wallet debit / verify_tasks / auto-verified links (source_url stays inert metadata)
182
+ onCreated: async (productId) => {
183
+ try {
184
+ 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},请到「我的商品 → 仓库」审核并发布。`]);
185
+ }
186
+ catch (e) {
187
+ console.error('[agent-grant draft notify]', e.message);
188
+ }
189
+ },
190
+ });
191
+ });
192
+ }
118
193
  // helpers for the permission-request flow ----------------------------------------------------------------
119
194
  const parseCapList = (json) => { try {
120
195
  const a = JSON.parse(json);
@@ -149,7 +224,7 @@ export function registerAgentGrantsRoutes(app, deps) {
149
224
  // Fail closed: a grant-authorized read is audited or it does not proceed (parity with requireAgentGrantScope).
150
225
  if (!(await auditGrant(g.grant_id, g.human_id, 'grant:verify', 'allow')))
151
226
  return void res.status(503).json({ error: 'authorization audit unavailable; refusing to proceed unaudited', error_code: 'GRANT_AUDIT_FAILED' });
152
- res.json({ grant: { grant_id: full.grant_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 — scopes/bundle/expiry/status. Not a human session; never authorizes risk/never-delegable actions.' });
227
+ 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.' });
153
228
  });
154
229
  // POST create a permission request — the AGENT (holding its current grant) asks for MORE scope / a bundle.
155
230
  // Bound to (human_id, grant_id) from the grant bearer. Rate-limited. Safe-only: risk/never-delegable are
@@ -183,8 +258,21 @@ export function registerAgentGrantsRoutes(app, deps) {
183
258
  const user = auth(req, res);
184
259
  if (!user)
185
260
  return;
186
- 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()]);
187
- res.json({ requests: rows.map(r => ({ ...r, requested_scopes: scopeNames(String(r.requested_scopes)), human_summary: bundleSummary(r.permission_bundle) })) });
261
+ const rows = await dbAll("SELECT id, agent_label, requested_scopes, permission_bundle, reason, task_context, risk_level, duration, created_at, expires_at, kind, order_id, order_action, params_hash, action_params 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()]);
262
+ // order_action:额外返回 kind/order_id/order_action/params_hash/action_params(action_params PR2 sanitize,
263
+ // 只含 tracking/evidence_ref,【无地址/PII】)。前端据此绑 Passkey purpose_data {request_id, order_id, action, params_hash}。
264
+ res.json({ requests: rows.map(r => {
265
+ const base = { ...r, requested_scopes: scopeNames(String(r.requested_scopes)), human_summary: bundleSummary(r.permission_bundle) };
266
+ if (r.kind === 'order_action') {
267
+ try {
268
+ base.action_params = r.action_params ? JSON.parse(String(r.action_params)) : {};
269
+ }
270
+ catch {
271
+ base.action_params = {};
272
+ }
273
+ }
274
+ return base;
275
+ }) });
188
276
  });
189
277
  // GET list the requests THIS grant created — GRANT-authed (the agent, via webaz_pair), so an agent can poll
190
278
  // its own request status (pending/approved/rejected/expired) without hitting the target surface. Bound to
@@ -210,11 +298,34 @@ export function registerAgentGrantsRoutes(app, deps) {
210
298
  if (!user)
211
299
  return;
212
300
  const now = new Date().toISOString();
213
- 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]);
301
+ const r = await dbOne('SELECT human_id, grant_id, requested_scopes, permission_bundle, duration, status, expires_at, kind, order_id, order_action, params_hash FROM agent_permission_requests WHERE id = ?', [req.params.id]);
214
302
  if (!r)
215
303
  return void res.status(404).json({ error: 'permission_request_not_found' });
216
304
  if (r.human_id !== user.id)
217
305
  return void res.status(403).json({ error: 'not your permission request' });
306
+ // RFC-021 PR2:order-action 请求分流。Passkey 绑三元组 (order_id, action, params_hash)(对齐 admin fallback 严格绑定);
307
+ // 过期/pending 判定【原子在 domain 的 CAS 里】(P1-b,含 expires_at > now),不做两步式预检查。
308
+ // 批到 approved 就【停】—— 绝不执行、不改订单、不碰钱路(execute 在 PR3)。
309
+ if ((r.kind ?? 'scope_grant') === 'order_action') {
310
+ const hp = requireHumanPresence(user.id, 'agent_permission_approve', (req.body || {}).webauthn_token, 'require_human_presence_for_agent_permission_approve', (data) => { const d = data; return d != null && typeof d === 'object' && d.request_id === req.params.id && d.order_id === r.order_id && d.action === r.order_action && d.params_hash === r.params_hash; });
311
+ if (!hp.ok)
312
+ return void res.status(412).json({ error: hp.reason, error_code: hp.error_code });
313
+ // PR3:CAS→approved 后【由服务端在 seller 本人授权下执行】accept/ship(strictTracking=true,守卫全在执行器内)。
314
+ // 执行成功写 executed_at(I5 幂等);执行失败请求保持 approved 可重试。执行器对 agent-bearer 不可达(/approve 需人类 session)。
315
+ const ar = approveAndExecuteOrderAction(db, req.params.id, user.id, r.grant_id, now, generateId);
316
+ if (!ar.ok)
317
+ return void res.status(ar.http || 409).json({ error: ar.error, error_code: ar.error_code });
318
+ // 执行成功后通知买卖双方(事务外;通知失败不回滚已完成的状态跃迁)
319
+ if (!ar.already_executed && ar.order_status) {
320
+ try {
321
+ const fromS = ar.order_status === 'accepted' ? 'paid' : 'accepted';
322
+ notifyTransition(db, r.order_id, fromS, ar.order_status);
323
+ }
324
+ catch { /* */ }
325
+ }
326
+ return void res.json({ success: true, kind: 'order_action', status: 'executed', order_id: r.order_id, action: r.order_action, order_status: ar.order_status, already_executed: ar.already_executed || false });
327
+ }
328
+ // scope_grant:保留既有两步(下方还有 grant-active 复检 + 原子 tx;非 order-action)。
218
329
  if (r.status !== 'pending' || r.expires_at <= now)
219
330
  return void res.status(409).json({ error: 'permission_request_not_pending', status: r.status });
220
331
  // Live Passkey, bound to THIS request (a token minted for request A can't approve B).
@@ -236,9 +347,17 @@ export function registerAgentGrantsRoutes(app, deps) {
236
347
  const grant = await dbOne('SELECT grant_id, capabilities, status, expires_at, revoked_at FROM agent_delegation_grants WHERE grant_id = ?', [r.grant_id]);
237
348
  if (!grant || !grantIsActive(grant, now))
238
349
  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)' });
239
- // Union new scopes into the grant; set bundle; extend expiry (never shorten). 'once' short 1h window.
350
+ // Duration: the HUMAN's submitted value is authoritative. If body.duration is PRESENT but not allowed →
351
+ // 400 (no silent fallback; nothing claimed/expanded below). Only when ABSENT do we fall back to the
352
+ // agent's requested duration. Checked BEFORE the tx so an invalid value never claims/extends anything.
353
+ const bodyDur = (req.body || {}).duration;
354
+ if (bodyDur !== undefined && bodyDur !== null && !durationAllowedForScopes(reqScopes, bodyDur)) {
355
+ return void res.status(400).json({ error: 'invalid grant duration', error_code: 'INVALID_GRANT_DURATION', allowed_durations: allowedDurationsForScopes(reqScopes) });
356
+ }
357
+ // Union new scopes into the grant; set bundle; extend expiry (never shorten).
240
358
  const union = [...new Set([...scopeNames(grant.capabilities), ...reqScopes])].map(s => ({ capability: s, constraints: {} }));
241
- const secs = durationToSeconds(r.duration) || 3600;
359
+ const effDuration = (bodyDur !== undefined && bodyDur !== null) ? bodyDur : r.duration;
360
+ const secs = durationToSeconds(effDuration) || 3600;
242
361
  const newExpiry = new Date(Date.now() + secs * 1000).toISOString();
243
362
  const expiresAt = newExpiry > grant.expires_at ? newExpiry : grant.expires_at;
244
363
  // ATOMIC (RFC-020 invariant: a grant expansion is audited-or-it-does-not-happen; parity with arbitrator
@@ -266,7 +385,7 @@ export function registerAgentGrantsRoutes(app, deps) {
266
385
  }
267
386
  if (outcome === 'not_pending')
268
387
  return void res.status(409).json({ error: 'permission_request_not_pending' });
269
- res.json({ success: true, grant_id: grant.grant_id, scopes: union.map(u => u.capability), permission_bundle: r.permission_bundle, expires_at: expiresAt });
388
+ res.json({ success: true, grant_id: grant.grant_id, scopes: union.map(u => u.capability), permission_bundle: r.permission_bundle, duration: effDuration, expires_at: expiresAt });
270
389
  });
271
390
  // POST reject — human-authed. Terminal 'rejected'; nothing is granted.
272
391
  app.post('/api/agent-grants/permission-requests/:id/reject', async (req, res) => {
@@ -313,13 +432,17 @@ export function registerAgentGrantsRoutes(app, deps) {
313
432
  const capsJson = JSON.stringify(v.safe.map(c => ({ capability: c, constraints: (caps.find(x => x?.capability === c)?.constraints) || {} })));
314
433
  if (capsJson.length > MAX_CONSTRAINTS_JSON)
315
434
  return void res.status(400).json({ error: 'capabilities_too_large', max_bytes: MAX_CONSTRAINTS_JSON });
316
- 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]);
435
+ // Agent SUGGESTS a grant lifetime; the human accepts or overrides it at approve. Safe-scope only up to 30d.
436
+ const reqDuration = durationAllowedForScopes(v.safe, body.duration) ? body.duration : suggestedDurationForScopes(v.safe);
437
+ await dbRun('INSERT INTO agent_pairing_sessions (pairing_id, user_code, code_challenge, agent_label, agent_pubkey, reason, capabilities, grant_duration, status, expires_at) VALUES (?,?,?,?,?,?,?,?,?,?)', [pairingId, userCode, codeChallenge, label, pubkey, reason, capsJson, reqDuration, 'pending', expiresAt]);
317
438
  res.status(201).json({
318
439
  pairing_id: pairingId,
319
440
  user_code: userCode,
320
441
  approve_url: `/#pair?code=${userCode}`,
321
442
  expires_at: expiresAt,
322
- note: 'Ask the human to open approve_url at webaz.xyz (logged in) and approve. Then retrieve the credential with the PKCE verifier.',
443
+ suggested_duration: reqDuration,
444
+ allowed_durations: allowedDurationsForScopes(v.safe),
445
+ note: 'Ask the human to open approve_url at webaz.xyz (logged in) and approve. The human picks the grant duration (your suggested_duration is pre-selected; they can change it). Then retrieve the credential with the PKCE verifier.',
323
446
  });
324
447
  });
325
448
  // (pair 2) Human reviews the server-generated consent — human-authenticated.
@@ -362,7 +485,17 @@ export function registerAgentGrantsRoutes(app, deps) {
362
485
  if (!v.ok)
363
486
  return void res.status(403).json({ error: 'pairing_rejected', rejected: v.rejected });
364
487
  const grantId = generateId('grt');
365
- const expiresAt = new Date(Date.now() + clampTtlSeconds(undefined) * 1000).toISOString();
488
+ // Duration: the HUMAN's submitted value is authoritative. If body.duration is PRESENT but not in the
489
+ // safe-scope matrix → 400 (no silent fallback; nothing claimed/issued below). Only when ABSENT do we
490
+ // fall back to the agent's suggestion (grant_duration) then the safe default.
491
+ const bodyDur = (req.body || {}).duration;
492
+ if (bodyDur !== undefined && bodyDur !== null && !durationAllowedForScopes(v.safe, bodyDur)) {
493
+ return void res.status(400).json({ error: 'invalid grant duration', error_code: 'INVALID_GRANT_DURATION', allowed_durations: allowedDurationsForScopes(v.safe) });
494
+ }
495
+ const chosenDuration = (bodyDur !== undefined && bodyDur !== null)
496
+ ? bodyDur
497
+ : (durationAllowedForScopes(v.safe, p.grant_duration) ? p.grant_duration : suggestedDurationForScopes(v.safe));
498
+ const expiresAt = new Date(Date.now() + (durationToSeconds(chosenDuration) || 3600) * 1000).toISOString();
366
499
  // 先【CAS 抢占】pending 配对(唯一赢家),再插 grant —— 竞态下输家 changes!==1 直接 409、不插任何 grant,
367
500
  // 杜绝"插了 grant 但配对更新失败"留下 token_hash NULL 的 orphan grant(污染连接记录)。
368
501
  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]);
@@ -370,7 +503,7 @@ export function registerAgentGrantsRoutes(app, deps) {
370
503
  return void res.status(409).json({ error: 'pairing_not_pending_or_expired' });
371
504
  // Grant created WITHOUT a token (token_hash NULL) — the bearer is minted only at retrieval.
372
505
  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]);
373
- res.json({ success: true, grant_id: grantId, capabilities: caps });
506
+ res.json({ success: true, grant_id: grantId, capabilities: caps, duration: chosenDuration, expires_at: expiresAt });
374
507
  });
375
508
  // (pair 3b) Human rejects — human-authenticated. Terminal 'rejected' → agent's retrieve fails clearly (no silent lingering).
376
509
  // 拒绝是保护性动作,无需 Passkey(不签发任何凭证)。幂等:仅 pending 可拒。
@@ -1,4 +1,5 @@
1
1
  import { dbOne, dbAll } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
2
+ import { getRecentResolvedDisputes } from '../../layer3-trust/L3-1-dispute-engine/dispute-engine.js'; // 历史裁决(已结)供仲裁员"已结"tab
2
3
  export function registerDisputesReadRoutes(app, deps) {
3
4
  const { db, auth, errorRes, getOpenDisputes, getDisputeDetails, getEvidenceRequests, listEvidenceFiles, isEligibleArbitrator, isArbitrationAdmin } = deps;
4
5
  // 仲裁员:查看所有开放争议
@@ -9,7 +10,22 @@ export function registerDisputesReadRoutes(app, deps) {
9
10
  const elig = isEligibleArbitrator(user.id);
10
11
  if (!elig.ok)
11
12
  return void errorRes(res, 403, 'NOT_ARBITRATOR', elig.reason || '仅限仲裁员访问');
12
- res.json(await getOpenDisputes(db));
13
+ // 待裁(open+in_review)+ 近期已结(resolved/dismissed)—— 前端"待裁/已结"sub-tab 客户端切分;
14
+ // getOpenDisputes 语义不变(MCP 复用),历史走 getRecentResolvedDisputes(LIMIT 50)。
15
+ const open = await getOpenDisputes(db);
16
+ const resolved = await getRecentResolvedDisputes(db, 50);
17
+ res.json([...open, ...resolved]);
18
+ });
19
+ // PR2 统一仲裁台待办角标:仲裁员待处理案数(open+in_review,含并入的 decline_contest)。轻量 count,喂前端 badge。
20
+ // 非仲裁员返回 0(不报错,前端 badge 拉取对所有人无害)。
21
+ app.get('/api/arbitrator/pending-count', async (req, res) => {
22
+ const user = auth(req, res);
23
+ if (!user)
24
+ return;
25
+ if (!isEligibleArbitrator(user.id).ok)
26
+ return void res.json({ count: 0 });
27
+ const row = await dbOne("SELECT COUNT(*) n FROM disputes WHERE status IN ('open','in_review')");
28
+ res.json({ count: row?.n ?? 0 });
13
29
  });
14
30
  // A2 同类判例推荐
15
31
  app.get('/api/disputes/:id/similar-cases', async (req, res) => {
@@ -1,14 +1,14 @@
1
1
  import express from 'express';
2
- // RFC-007 stage 5:客观拒单仲裁翻案直接复用 L0 状态机/结算(纯 db 函数,无副作用)
3
- import { transition, settleFault, settleDeclinedNoFault } from '../../layer0-foundation/L0-2-state-machine/engine.js';
4
2
  // RFC-014 PR5 — 争议后佣金/基金 clawback 走整数 base-units + 绝对值落库。
5
3
  import { toUnits, toDecimal, mulRate } from '../../money.js';
6
4
  import { applyWalletDelta } from '../../ledger.js';
7
5
  // RFC-016 Phase 1 — 纯只读端点/校验读/SNF 分发读/标记写 → async seam;arbitrate 仲裁核心(原子领取 +
8
6
  // 2 settlement db.transaction + reputation/strike/publish)与 tx 内 appendAuditLog 保持同步(Phase 3 迁 pg)。
9
- import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js';
7
+ import { dbOne, dbRun } from '../../layer0-foundation/L0-1-database/db.js';
10
8
  import { arbitratorHasConflict } from '../arbitrator-lifecycle.js'; // PR-B COI 硬门
11
9
  import { dismissDisputeToNegotiation } from '../../layer3-trust/L3-1-dispute-engine/dispute-engine.js'; // 仲裁员驳回仲裁,退回协商(非胜负裁决)
10
+ import { resolveDeclineContestDispute } from '../../layer3-trust/L3-1-dispute-engine/decline-contest-resolve.js'; // PR3 拒单举证唯一裁决器
11
+ import { notifyDeclineContestResolved } from '../../layer2-business/L2-6-notifications/notification-engine.js'; // 裁决落定通知买卖双方
12
12
  export function registerDisputesWriteRoutes(app, deps) {
13
13
  const { db, auth, generateId, detectFraud, errorRes, isEligibleArbitrator, requireHumanPresence, getDisputeDetails, respondToDispute, arbitrateDispute, addPartyEvidence, requestEvidence, markEvidenceExpiry, uploadEvidence, EVIDENCE_MAX_BYTES, EVIDENCE_ALLOWED_MIME, appendOrderEvent, FUND_BASE_RATE, settleCommission, depositToFund, calculatePv, recordDisputeReputation, issueAgentStrike, publishDisputeCase, logAdminAction, snfSend, getProtocolParam, notifyTransition } = deps;
14
14
  // PR-E:案件级仲裁操作(裁定 / 补证)统一的【原子领取或校验指派】。assigned 为空 → CAS 首位领取;
@@ -34,72 +34,10 @@ export function registerDisputesWriteRoutes(app, deps) {
34
34
  }
35
35
  return assigned.includes(userId) ? { ok: true } : { ok: false, error_code: 'NOT_ASSIGNED_ARBITRATOR' };
36
36
  };
37
- // ── RFC-007 stage 5:客观拒单【临时判责】的仲裁翻案 ─────────────────────────────
38
- // 卖家 contest_decline 后,订单 = fault_seller + decline_objective_pending=1 + decline_contested=1(未结算)。
39
- // 仲裁员(真实人工 + WebAuthn)裁决:uphold → declined_nofault(免责全退+退质押);reject → 违约结算。
40
- const SYS = 'sys_protocol';
41
- // 仲裁员待办:列出所有被举证的临时判责拒单
42
- app.get('/api/admin/decline-contests', async (req, res) => {
43
- const user = auth(req, res);
44
- if (!user)
45
- return;
46
- const elig = isEligibleArbitrator(user.id);
47
- if (!elig.ok)
48
- return void errorRes(res, 403, 'NOT_ARBITRATOR', elig.reason || '仅限仲裁员');
49
- const rows = await dbAll(`
50
- SELECT id AS order_id, buyer_id, seller_id, product_id, total_amount, decline_reason_code, declined_at, decline_contest_deadline
51
- FROM orders
52
- WHERE status = 'fault_seller' AND COALESCE(decline_objective_pending,0)=1 AND COALESCE(decline_contested,0)=1 AND settled_fault_at IS NULL
53
- ORDER BY declined_at ASC
54
- `);
55
- res.json({ contests: rows });
56
- });
57
- // 仲裁员裁决
58
- app.post('/api/admin/decline-contests/:orderId/resolve', async (req, res) => {
59
- const user = auth(req, res);
60
- if (!user)
61
- return;
62
- const elig = isEligibleArbitrator(user.id);
63
- if (!elig.ok)
64
- return void errorRes(res, 403, 'NOT_ARBITRATOR', elig.reason || '仅限仲裁员');
65
- // 铁律:仲裁需真实人工 WebAuthn
66
- const hp = requireHumanPresence(user.id, 'arbitrate', req.body?.webauthn_token, 'require_human_presence_for_arbitrate');
67
- if (!hp.ok)
68
- return void errorRes(res, 412, hp.error_code || 'HUMAN_PRESENCE_REQUIRED', hp.reason || '此操作需真实人工 WebAuthn 验证');
69
- const { decision, reason } = req.body ?? {};
70
- if (!['uphold', 'reject'].includes(decision))
71
- return void errorRes(res, 400, 'BAD_DECISION', "decision 必须为 'uphold'(认定无责) 或 'reject'(驳回,判违约)");
72
- if (!reason || !String(reason).trim())
73
- return void errorRes(res, 400, 'REASON_REQUIRED', '请提供裁决理由');
74
- const order = await dbOne('SELECT * FROM orders WHERE id = ?', [req.params.orderId]);
75
- if (!order)
76
- return void res.status(404).json({ error: '订单不存在' });
77
- if (order.status !== 'fault_seller' || Number(order.decline_objective_pending) !== 1 || Number(order.decline_contested) !== 1 || order.settled_fault_at) {
78
- return void errorRes(res, 400, 'NOT_CONTESTED_PROVISIONAL', '本订单不是【已举证·待裁决】的临时判责拒单');
79
- }
80
- try {
81
- appendOrderEvent(db, { orderId: req.params.orderId, eventType: 'transition', fromStatus: 'fault_seller', toStatus: 'fault_seller', actorId: user.id, actorRole: 'arbitrator', extra: { action: 'decline_contest_ruling', decision, reason } });
82
- }
83
- catch (e) {
84
- console.warn('[order-chain] decline ruling event:', e.message);
85
- }
86
- if (decision === 'uphold') {
87
- // 认定客观无责 → declined_nofault:全退买家 + 退卖家质押,零罚没
88
- const r1 = transition(db, req.params.orderId, 'declined_nofault', user.id, [], `客观拒单仲裁维持(无责):${reason}`);
89
- if (!r1.success)
90
- return void res.json({ error: r1.error });
91
- settleDeclinedNoFault(db, req.params.orderId);
92
- transition(db, req.params.orderId, 'completed', SYS, [], '无责拒单结算完成');
93
- logAdminAction(user.id, 'decline_contest_uphold', 'order', req.params.orderId, { reason });
94
- return void res.json({ success: true, outcome: 'declined_nofault', note: '裁决:客观无责。买家已全额退款,卖家质押已退回,无罚没。' });
95
- }
96
- // reject → 违约结算(终结临时判责)
97
- settleFault(db, req.params.orderId, 'fault_seller');
98
- transition(db, req.params.orderId, 'completed', SYS, [], `客观拒单仲裁驳回 → 违约结算:${reason}`);
99
- db.prepare('UPDATE orders SET decline_objective_pending = 0, decline_contested = 0 WHERE id = ?').run(req.params.orderId);
100
- logAdminAction(user.id, 'decline_contest_reject', 'order', req.params.orderId, { reason });
101
- res.json({ success: true, outcome: 'fault_seller', note: '裁决:驳回。按违约结算,买家已全额退款,卖家质押按规则罚没。' });
102
- });
37
+ // RFC-007 客观拒单举证仲裁已并入统一 disputes 仲裁台(PR1-3,#279-#281):dispute_type='decline_contest'。
38
+ // 旧订单级 GET /api/admin/decline-contests 列表 + POST .../:orderId/resolve 裁决端点【已物理移除】(PR4)。
39
+ // 现统一走:仲裁员 POST /api/disputes/:id/arbitrate(ruling=decline_no_fault_upheld|decline_fault_confirmed);
40
+ // admin 兜底 POST /api/admin/disputes/:id/decline-contest-resolve;两者都经唯一 resolveDeclineContestDispute。
103
41
  // 被诉方反驳
104
42
  app.post('/api/disputes/:id/respond', async (req, res) => {
105
43
  const user = auth(req, res);
@@ -132,9 +70,15 @@ export function registerDisputesWriteRoutes(app, deps) {
132
70
  const elig = isEligibleArbitrator(user.id);
133
71
  if (!elig.ok)
134
72
  return void errorRes(res, 403, 'NOT_ARBITRATOR', elig.reason || '仅限仲裁员');
135
- // 2026-05-23 Agent 治理铁律:仲裁需真实人工
73
+ // 2026-05-23 Agent 治理铁律:仲裁需真实人工。
74
+ // decline_contest 两选裁决 = 钱路/状态闭环(退款/罚没/completed)→ 与 admin fallback 一致 fail-closed:
75
+ // token 必须【显式绑定】本案 dispute_id + 本结果 decision,绝不允许 null/未绑定的泛化 arbitrate token 跨案裁决。
76
+ // 通用争议维持既有(d==null 允许);resolver 只对两选 ruling 执行,故钱路仅在严格绑定时可达。
77
+ const _dcRuling = req.body?.ruling === 'decline_no_fault_upheld' || req.body?.ruling === 'decline_fault_confirmed';
136
78
  const hpCheck = requireHumanPresence(user.id, 'arbitrate', req.body?.webauthn_token, 'require_human_presence_for_arbitrate', (data) => {
137
79
  const d = data;
80
+ if (_dcRuling)
81
+ return d != null && typeof d === 'object' && d.dispute_id === req.params.id && d.decision === req.body?.ruling;
138
82
  return d == null || d.dispute_id === req.params.id;
139
83
  });
140
84
  if (!hpCheck.ok)
@@ -142,6 +86,34 @@ export function registerDisputesWriteRoutes(app, deps) {
142
86
  const { ruling, reason, refund_amount, liable_party_id, liability_parties } = req.body;
143
87
  if (!ruling || !reason)
144
88
  return void res.json({ error: '请提供裁定结果(ruling)和理由(reason)' });
89
+ // P1-1:先读 dispute,再按 dispute_type 选 ruling allowlist —— 否则 decline_contest 的专用枚举会被通用校验提前 400。
90
+ const dispute = await getDisputeDetails(db, req.params.id);
91
+ if (!dispute)
92
+ return void res.status(404).json({ error: '争议不存在' });
93
+ // PR3:decline_contest → 唯一 domain resolver(两选;dispute CAS + COI + assignment + 终态 completed + 结算 + 审计,单事务失败全回滚)。
94
+ if (dispute.dispute_type === 'decline_contest') {
95
+ const dcAllow = ['decline_no_fault_upheld', 'decline_fault_confirmed'];
96
+ if (!dcAllow.includes(ruling))
97
+ return void errorRes(res, 400, 'BAD_DECISION', `拒单举证仲裁的 ruling 必须是 ${dcAllow.join(' / ')} 之一`);
98
+ try {
99
+ const r = resolveDeclineContestDispute(db, req.params.id, user.id, ruling, reason, 'arbitrator');
100
+ try {
101
+ notifyDeclineContestResolved(db, r.orderId, r.decision);
102
+ }
103
+ catch (e) {
104
+ console.warn('[notify] dc-resolve:', e.message);
105
+ }
106
+ try {
107
+ logAdminAction(user.id, `decline_contest_${r.decision}`, 'order', r.orderId, { source: 'arbitrator', dispute_id: req.params.id, reason });
108
+ }
109
+ catch { /* */ }
110
+ return void res.json({ success: true, outcome: r.decision, order_status: 'completed' });
111
+ }
112
+ catch (e) {
113
+ const err = e;
114
+ return void errorRes(res, err.http || 400, err.code || 'DC_RESOLVE_FAILED', err.message || '裁决失败');
115
+ }
116
+ }
145
117
  const validRulings = ['refund_buyer', 'release_seller', 'partial_refund', 'liability_split', 'dismiss_to_negotiation'];
146
118
  if (!validRulings.includes(ruling)) {
147
119
  return void res.json({ error: `ruling 必须是 ${validRulings.join(' / ')} 之一` });
@@ -156,9 +128,6 @@ export function registerDisputesWriteRoutes(app, deps) {
156
128
  }
157
129
  }
158
130
  }
159
- const dispute = await getDisputeDetails(db, req.params.id);
160
- if (!dispute)
161
- return void res.status(404).json({ error: '争议不存在' });
162
131
  // PR-B COI 硬门:当事方(买家/卖家/物流/发起人/被诉人)不得仲裁本案。在领取/裁定前拦截。
163
132
  if (arbitratorHasConflict(db, dispute.order_id, dispute.initiator_id ?? null, dispute.defendant_id ?? null, user.id)) {
164
133
  return void res.status(403).json({ error: '你是本案当事方,不可仲裁(利益冲突)', error_code: 'ARBITRATOR_CONFLICT_OF_INTEREST' });
@@ -1,11 +1,12 @@
1
1
  import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js';
2
- import { createNotification } from '../../layer2-business/L2-6-notifications/notification-engine.js';
2
+ import { createNotification, notifyDeclineContestCase } from '../../layer2-business/L2-6-notifications/notification-engine.js';
3
+ import { executeSellerOrderAction } from '../order-action-exec.js'; // RFC-021 PR3 共享执行器(api_key 路径 strictTracking=false)
3
4
  import { releaseFeeStake } from '../../direct-pay-ledger.js'; // Rail1 直付:取消/超时释放任何遗留模拟质押(AR 订单无 stake → no-op)
4
5
  import { restorePreShipDirectPayStock } from '../../direct-pay-stock.js'; // D3 库存回补唯一入口(pre-ship 放行;已出库拒绝)
5
6
  import { requireBothDisclosuresAcked } from '../../direct-pay-disclosures.js'; // PR-4e: D1/D2 披露契约门
6
7
  import { requireDirectPayHumanPasskey } from '../direct-pay-guards.js'; // PR-4e: 现场真人 Passkey/gate-token 门
7
8
  export function registerOrdersActionRoutes(app, deps) {
8
- const { db, auth, isTrustedRole, generateId, transition, notifyTransition, settleOrder, settleFault, detectFraud, createDispute, checkTimeouts, recordViolationReputation, broadcastSystemEvent, consumeGateToken } = deps;
9
+ const { db, auth, isTrustedRole, generateId, transition, notifyTransition, settleOrder, settleFault, detectFraud, createDispute, createDeclineContestDispute, checkTimeouts, recordViolationReputation, broadcastSystemEvent, consumeGateToken } = deps;
9
10
  // PR-4e: direct_p2p 风险动作门 —— ① D1/D2 两次披露都 ack(缺则 DISCLOSURE_NOT_ACKED);② 现场真人 Passkey + 一次性
10
11
  // WebAuthn gate token(purpose 固定 direct_pay_order_action,order+action 走 purpose_data + validate)。
11
12
  // 纯前置门:返回 ok 才允许后续写入。【先 disclosure(只读)再 Passkey(消费 token)】→ 缺 ack 不浪费 token。
@@ -378,8 +379,22 @@ export function registerOrdersActionRoutes(app, deps) {
378
379
  evIds.push(eid);
379
380
  }
380
381
  db.prepare("UPDATE orders SET decline_contested = 1 WHERE id = ?").run(req.params.id);
382
+ // 统一仲裁台:并入 disputes(不改 order.status)。幂等——重复举证不会建第二条。失败不阻断举证本身
383
+ // (标志位已置;backfill 脚本可事后补建),但记录以便排查。
384
+ const dc = createDeclineContestDispute(db, req.params.id);
385
+ if (!dc.success)
386
+ console.error(`[contest_decline] createDeclineContestDispute failed for ${req.params.id}: ${dc.error}`);
387
+ // PR2:通知全体 active 仲裁员 + admin 有新案(去重,只对新建行发;既有行不重复轰炸)。
388
+ else if (!dc.existing && dc.disputeId) {
389
+ try {
390
+ notifyDeclineContestCase(db, req.params.id, dc.disputeId);
391
+ }
392
+ catch (e) {
393
+ console.error(`[contest_decline] notify failed: ${e.message}`);
394
+ }
395
+ }
381
396
  return void res.json({
382
- success: true, outcome: 'contested', evidence_ids: evIds,
397
+ success: true, outcome: 'contested', evidence_ids: evIds, dispute_id: dc.disputeId,
383
398
  note: '已就客观无责拒单发起人工仲裁举证。自动终结已暂停,等待仲裁员裁决:维持→免责全退+退质押,驳回→违约结算。',
384
399
  });
385
400
  }
@@ -456,6 +471,21 @@ export function registerOrdersActionRoutes(app, deps) {
456
471
  catch { }
457
472
  return void res.json({ success: true, status: 'completed', dispute_dismissed: true });
458
473
  }
474
+ // RFC-021 PR3:accept/ship 经【共享执行器】(与 Passkey-approve 路径同一真相源;守卫全内置)。
475
+ // api_key 路径 = seller 本人直发 → strictTracking=false(保持现状:无单号/占位单号发货不破坏;tracking 仍为 hint)。
476
+ // evidence_description 现状语义保留(缺则执行器内 transition 因证据空而拒);logistics 绑定已在上方完成。
477
+ if (action === 'accept' || action === 'ship') {
478
+ const exec = executeSellerOrderAction(db, { orderId: req.params.id, action, actorId: user.id, nowIso: new Date().toISOString(), strictTracking: false, tracking: req.body?.tracking, evidenceDescription: evidence_description, generateId, path: 'api_key' });
479
+ if (!exec.ok)
480
+ return void res.status(exec.http || 409).json({ error: exec.error, error_code: exec.error_code });
481
+ try {
482
+ notifyTransition(db, req.params.id, exec.fromStatus, exec.toStatus);
483
+ }
484
+ catch (e) {
485
+ console.warn('[order-action notify]', e.message);
486
+ }
487
+ return void res.json({ success: true, status: exec.toStatus });
488
+ }
459
489
  const actionMap = {
460
490
  accept: 'accepted', ship: 'shipped', pickup: 'picked_up',
461
491
  transit: 'in_transit', deliver: 'delivered', confirm: 'confirmed', dispute: 'disputed',
@@ -530,6 +560,19 @@ export function registerOrdersActionRoutes(app, deps) {
530
560
  const result = transition(db, req.params.id, toStatus, user.id, evidenceIds, notes);
531
561
  if (!result.success)
532
562
  return void res.json({ error: result.error });
563
+ // P1-c(b):direct_p2p 首次进 accepted(mark_paid / confirm_received)才生成 ship_deadline —— direct-pay-create 建单不设该列,
564
+ // 缺 ship_deadline 会被共享执行器 SLA【fail-closed】卡住发货。WHERE ship_deadline IS NULL 兜死:仅 null→值 那一次写,
565
+ // 绝不覆盖已有值(守 I3:deadline 绝对列不重写)。判责钟从付款确认(now)起。
566
+ if (toStatus === 'accepted' && order.payment_rail === 'direct_p2p') {
567
+ let sh = 72;
568
+ try {
569
+ const p = await dbOne("SELECT value FROM protocol_params WHERE key = 'direct_pay.ship_window_hours'");
570
+ if (p)
571
+ sh = Math.max(1, Number(p.value) || 72);
572
+ }
573
+ catch { /* 用默认 72h */ }
574
+ await dbRun("UPDATE orders SET ship_deadline = datetime('now', ?) WHERE id = ? AND ship_deadline IS NULL", [`+${sh} hours`, req.params.id]);
575
+ }
533
576
  notifyTransition(db, req.params.id, fromStatus, toStatus);
534
577
  // 审计项 B(N2):买家标记付款 → 通知卖家核款发货(direct_pay_window→accepted 不在 notifyTransition RULES,
535
578
  // 此前卖家全程收不到"已付款"信号只能手动刷)。notes 即 D1/D2/E 的权威对账串(参考号+应付+多单预警),直接复用。