@seasonkoh/webaz 0.1.29 → 0.1.30

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 +81 -16
  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 +709 -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 +253 -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 +13 -4
  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 +59 -36
  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 +59 -2
@@ -0,0 +1,156 @@
1
+ import { transition } from '../../layer0-foundation/L0-2-state-machine/engine.js';
2
+ import { toUnits, toDecimal } from '../../money.js';
3
+ import { applyWalletDelta, walletUnits } from '../../ledger.js';
4
+ export function initMutualCancelSchema(db) {
5
+ db.exec(`CREATE TABLE IF NOT EXISTS mutual_cancel_proposals (
6
+ id TEXT PRIMARY KEY,
7
+ order_id TEXT NOT NULL,
8
+ dispute_id TEXT NOT NULL,
9
+ proposed_by TEXT NOT NULL,
10
+ counterparty TEXT NOT NULL,
11
+ status TEXT NOT NULL DEFAULT 'pending', -- pending | accepted | declined | withdrawn
12
+ reason TEXT,
13
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
14
+ resolved_at TEXT,
15
+ resolved_by TEXT
16
+ )`);
17
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_mutual_cancel_order ON mutual_cancel_proposals(order_id, status)`);
18
+ }
19
+ /** 载入订单+活跃争议,并判定 caller 是否当事方 + 订单是否处于可协商取消的状态。fail-closed。 */
20
+ function loadCancellable(db, orderId, userId) {
21
+ const order = db.prepare('SELECT id, buyer_id, seller_id, status, payment_rail, total_amount, stake_backing, bid_stake_held FROM orders WHERE id = ?').get(orderId);
22
+ if (!order)
23
+ return { ok: false, error: '订单不存在', error_code: 'ORDER_NOT_FOUND' };
24
+ const role = userId === order.buyer_id ? 'buyer' : userId === order.seller_id ? 'seller' : null;
25
+ if (!role)
26
+ return { ok: false, error: '仅买卖双方可协商取消该订单', error_code: 'NOT_A_PARTY' };
27
+ if (order.status !== 'disputed')
28
+ return { ok: false, error: '仅争议中的订单可协商取消', error_code: 'ORDER_NOT_DISPUTED' };
29
+ const dispute = db.prepare("SELECT id, status FROM disputes WHERE order_id = ? AND status IN ('open','in_review') ORDER BY created_at DESC LIMIT 1").get(orderId);
30
+ if (!dispute)
31
+ return { ok: false, error: '该订单没有进行中的争议', error_code: 'NO_ACTIVE_DISPUTE' };
32
+ return { ok: true, order, dispute, role };
33
+ }
34
+ /** 当前 pending 提议(至多一条)。 */
35
+ function pendingProposal(db, orderId) {
36
+ return db.prepare("SELECT id, proposed_by, counterparty, status, reason, created_at FROM mutual_cancel_proposals WHERE order_id = ? AND status = 'pending' ORDER BY created_at DESC LIMIT 1").get(orderId);
37
+ }
38
+ /** 提议协商取消。proposalId 由路由用 generateId 生成后传入(域模块不自造 id)。 */
39
+ export function proposeMutualCancel(db, orderId, userId, reason, proposalId) {
40
+ const g = loadCancellable(db, orderId, userId);
41
+ if (!g.ok)
42
+ return g;
43
+ const existing = pendingProposal(db, orderId);
44
+ if (existing) {
45
+ if (existing.proposed_by === userId)
46
+ return { ok: false, error: '你已提议协商取消,正在等待对方确认', error_code: 'ALREADY_PROPOSED', proposal_id: existing.id };
47
+ return { ok: false, error: '对方已提议协商取消,请直接确认接受', error_code: 'COUNTERPARTY_ALREADY_PROPOSED', proposal_id: existing.id };
48
+ }
49
+ const counterparty = g.role === 'buyer' ? g.order.seller_id : g.order.buyer_id;
50
+ db.prepare("INSERT INTO mutual_cancel_proposals (id, order_id, dispute_id, proposed_by, counterparty, status, reason) VALUES (?,?,?,?,?,'pending',?)")
51
+ .run(proposalId, orderId, g.dispute.id, userId, counterparty, (reason || '').slice(0, 500) || null);
52
+ return { ok: true, proposal_id: proposalId, status: 'pending' };
53
+ }
54
+ /** 提议方撤回自己的 pending 提议。 */
55
+ export function withdrawMutualCancel(db, orderId, userId) {
56
+ const prop = pendingProposal(db, orderId);
57
+ if (!prop)
58
+ return { ok: false, error: '没有待处理的协商取消提议', error_code: 'NO_PENDING_PROPOSAL' };
59
+ if (prop.proposed_by !== userId)
60
+ return { ok: false, error: '只能撤回自己提出的提议', error_code: 'NOT_PROPOSER' };
61
+ db.prepare("UPDATE mutual_cancel_proposals SET status='withdrawn', resolved_at=datetime('now'), resolved_by=? WHERE id=?").run(userId, prop.id);
62
+ return { ok: true, status: 'withdrawn' };
63
+ }
64
+ /** 对方拒绝 pending 提议。 */
65
+ export function declineMutualCancel(db, orderId, userId) {
66
+ const g = loadCancellable(db, orderId, userId);
67
+ if (!g.ok)
68
+ return g;
69
+ const prop = pendingProposal(db, orderId);
70
+ if (!prop)
71
+ return { ok: false, error: '没有待处理的协商取消提议', error_code: 'NO_PENDING_PROPOSAL' };
72
+ if (prop.proposed_by === userId)
73
+ return { ok: false, error: '不能拒绝自己的提议(如需取消请撤回)', error_code: 'CANNOT_DECLINE_OWN' };
74
+ db.prepare("UPDATE mutual_cancel_proposals SET status='declined', resolved_at=datetime('now'), resolved_by=? WHERE id=?").run(userId, prop.id);
75
+ return { ok: true, status: 'declined' };
76
+ }
77
+ /**
78
+ * 资金+状态结算(tx-free core)——【必须由路由用 db.transaction 包裹】。
79
+ * · 直付(非托管):零资金,仅 transition→cancelled。
80
+ * · 托管:买家托管货款退回(escrowed→balance);卖家【本订单实际锁定】的质押返还(staked→balance)。无罚没/手续费/信誉。
81
+ * · 【money-path 铁律】买家退款 FAIL-CLOSED:实际 escrowed 必须 ≥ 本单全额,否则中止(不退/不关单/不结案),
82
+ * 绝不部分退款还静默收口。卖家质押返还取订单快照 stake_backing + bid_stake_held(不是商品名义 stake_amount ——
83
+ * 当前 escrow 模型 stake_backing 恒 0、不锁卖家 stake,bid_stake_held 仅中标单有),cap 到卖家真实 staked
84
+ * (best-effort,不 fail-closed,以免卖家侧账目瑕疵卡死买家退款)。两侧都绝不打负 / 凭空印钱。
85
+ * · 争议行同事务置 resolved(verdict='mutual_cancel'),防自动裁决二次结算。
86
+ */
87
+ function settleMutualCancel(db, order, disputeId) {
88
+ const sys = db.prepare("SELECT id FROM users WHERE id = 'sys_protocol'").get();
89
+ if (!sys)
90
+ return { ok: false, error: 'sys_protocol 用户不存在', error_code: 'SYS_MISSING' };
91
+ const nonCustodial = order.payment_rail === 'direct_p2p';
92
+ let detail;
93
+ if (nonCustodial) {
94
+ detail = { non_custodial: true, buyer_refund: 0, seller_stake_returned: 0, note: '非托管(直付)单:协议不持货款 → 零资金,仅关单' };
95
+ }
96
+ else {
97
+ // 买家 made-whole 不可妥协 —— FAIL-CLOSED:买家实际 escrowed 必须 ≥ 本单全额,否则【中止】(不退、不关单、
98
+ // 不结案,留人工核对账目),绝不"部分退款还静默收口"。足额时退【本单全额】(escrowed≥total 保证退后 escrowed≥0,不超退)。
99
+ const totalU = toUnits(order.total_amount);
100
+ if (walletUnits(db, order.buyer_id).escrowed < totalU)
101
+ return { ok: false, error: '买家托管余额不足以全额退款,协商取消已中止(账目待人工核对)', error_code: 'ESCROW_INSUFFICIENT' };
102
+ applyWalletDelta(db, order.buyer_id, { escrowed: -totalU, balance: totalU });
103
+ // 卖家质押返还 = 尽力而为的抵押物返还(与买家退款不同、【不】fail-closed,以免卖家侧账目瑕疵卡死买家的无责退款):
104
+ // 只退【本订单快照锁定】的质押(stake_backing+bid_stake_held,非商品名义值),再 cap 到卖家真实 staked → 绝不打负/印钱。
105
+ const lockedU = toUnits(Number(order.stake_backing || 0)) + toUnits(Number(order.bid_stake_held || 0));
106
+ const stakeReturnU = Math.max(0, Math.min(lockedU, walletUnits(db, order.seller_id).staked));
107
+ if (stakeReturnU > 0)
108
+ applyWalletDelta(db, order.seller_id, { staked: -stakeReturnU, balance: stakeReturnU });
109
+ detail = { non_custodial: false, buyer_refund: toDecimal(totalU), seller_stake_returned: toDecimal(stakeReturnU), note: '托管单:买家全额退款(fail-closed 已验足额)+ 卖家本单锁定质押返还(cap 实际 staked);无罚没/手续费/信誉' };
110
+ }
111
+ const tr = transition(db, order.id, 'cancelled', sys.id, [], '双方协商取消(无责裁定:买家退款,双方信誉不受影响)');
112
+ if (!tr.success)
113
+ return { ok: false, error: tr.error, error_code: 'TRANSITION_FAILED' };
114
+ db.prepare("UPDATE disputes SET status='resolved', verdict='mutual_cancel', verdict_reason=?, ruling_type='mutual_cancel', resolved_at=datetime('now') WHERE id=?")
115
+ .run('双方合意取消订单 —— 无责终结,不计入任一方信誉', disputeId);
116
+ return { ok: true, settlement: { ...detail, terminal: 'cancelled', dispute: 'resolved', fault: 'none', reputation_impact: 'none' } };
117
+ }
118
+ /**
119
+ * 对方确认接受 → 执行协商取消。【路由必须 db.transaction 包裹本函数】。
120
+ * 事务内重新校验(competition-safe):当事方 + order 仍 disputed + 争议仍 open|in_review + 存在 pending 提议 + 非自我确认。
121
+ */
122
+ export function acceptMutualCancel(db, orderId, userId) {
123
+ const g = loadCancellable(db, orderId, userId);
124
+ if (!g.ok)
125
+ return g;
126
+ const prop = pendingProposal(db, orderId);
127
+ if (!prop)
128
+ return { ok: false, error: '没有待确认的协商取消提议', error_code: 'NO_PENDING_PROPOSAL' };
129
+ if (prop.proposed_by === userId)
130
+ return { ok: false, error: '不能确认自己提出的取消,需由对方确认', error_code: 'CANNOT_ACCEPT_OWN' };
131
+ const settle = settleMutualCancel(db, g.order, g.dispute.id);
132
+ if (!settle.ok)
133
+ return settle;
134
+ db.prepare("UPDATE mutual_cancel_proposals SET status='accepted', resolved_at=datetime('now'), resolved_by=? WHERE id=?").run(userId, prop.id);
135
+ return { ok: true, status: 'accepted', settlement: settle.settlement };
136
+ }
137
+ /** UI 状态:当前提议 + 该 caller 可执行的动作。【party-gated】:非当事人一律 NOT_A_PARTY,绝不泄露提议/理由/发起方/存在性。 */
138
+ export function getMutualCancelState(db, orderId, userId) {
139
+ const order = db.prepare('SELECT buyer_id, seller_id, status FROM orders WHERE id = ?').get(orderId);
140
+ if (!order)
141
+ return { ok: false, error: '订单不存在', error_code: 'ORDER_NOT_FOUND' };
142
+ if (userId !== order.buyer_id && userId !== order.seller_id)
143
+ return { ok: false, error: '仅买卖双方可查看协商取消状态', error_code: 'NOT_A_PARTY' }; // 读也 party-gate:不向非当事人泄露提议/理由/发起方
144
+ const hasActiveDispute = !!db.prepare("SELECT 1 FROM disputes WHERE order_id = ? AND status IN ('open','in_review') LIMIT 1").get(orderId);
145
+ const eligible = order.status === 'disputed' && hasActiveDispute;
146
+ const prop = pendingProposal(db, orderId);
147
+ const proposal = prop ? { id: prop.id, proposed_by: prop.proposed_by, reason: prop.reason, created_at: prop.created_at, mine: prop.proposed_by === userId } : null;
148
+ return {
149
+ ok: true,
150
+ proposal,
151
+ can_propose: eligible && !prop,
152
+ can_accept: eligible && !!prop && prop.proposed_by !== userId,
153
+ can_decline: eligible && !!prop && prop.proposed_by !== userId,
154
+ can_withdraw: eligible && !!prop && prop.proposed_by === userId,
155
+ };
156
+ }
@@ -0,0 +1,94 @@
1
+ import { validateQrDataUri } from './direct-receive-account-qr.js';
2
+ import { MAX_INSTRUCTION_LEN, MAX_LABEL_LEN, MAX_METHOD_LEN, CURRENCY_RE } from './direct-receive-accounts.js';
3
+ /** PURE:校验 + 规范化文本字段(trim / 长度 / 币种)。长度超限=显式拒(非静默截断)。 */
4
+ export function normalizePlatformText(input) {
5
+ const instruction = String(input?.instruction ?? '').trim();
6
+ if (!instruction)
7
+ return { ok: false, reason: 'instruction required' };
8
+ if (instruction.length > MAX_INSTRUCTION_LEN)
9
+ return { ok: false, reason: `instruction must be ≤ ${MAX_INSTRUCTION_LEN} chars` };
10
+ const field = (v, max, name) => {
11
+ if (v == null)
12
+ return { ok: true, v: null };
13
+ const s = String(v).trim();
14
+ if (s.length > max)
15
+ return { ok: false, reason: `${name} must be ≤ ${max} chars` };
16
+ return { ok: true, v: s || null };
17
+ };
18
+ const L = field(input.label, MAX_LABEL_LEN, 'label');
19
+ if (!L.ok)
20
+ return L;
21
+ const M = field(input.method, MAX_METHOD_LEN, 'method');
22
+ if (!M.ok)
23
+ return M;
24
+ let currency = null;
25
+ if (input.currency != null && String(input.currency).trim()) {
26
+ currency = String(input.currency).trim().toUpperCase();
27
+ if (!CURRENCY_RE.test(currency))
28
+ return { ok: false, reason: 'currency must be a 2-8 char code (e.g. SGD, USDC)' };
29
+ }
30
+ return { ok: true, value: { instruction, label: L.v, method: M.v, currency } };
31
+ }
32
+ /** PURE:解析 qrDataUri 入参 → 'keep'(缺省不改) | null(清除) | 校验后的 data-uri | 错误。 */
33
+ export function resolveQrInput(input) {
34
+ if (!('qrDataUri' in input) || input.qrDataUri === undefined)
35
+ return { ok: true, qr: 'keep' };
36
+ const q = input.qrDataUri;
37
+ if (q == null || String(q).trim() === '')
38
+ return { ok: true, qr: null }; // 清除
39
+ const v = validateQrDataUri(q);
40
+ if (!v.ok)
41
+ return { ok: false, reason: v.reason };
42
+ return { ok: true, qr: String(q).trim() };
43
+ }
44
+ const COLS = 'id, label, method, currency, instruction, qr_data_uri, status';
45
+ /** 全部平台收款方式(默认仅 active;{ includeInactive:true } 取全部,含 qr_data_uri)。admin 用。 */
46
+ export function listPlatformAccounts(db, opts = {}) {
47
+ const where = opts.includeInactive ? '' : " WHERE status = 'active'";
48
+ return db.prepare(`SELECT ${COLS} FROM platform_receive_accounts${where} ORDER BY created_at ASC, id ASC`).all();
49
+ }
50
+ /** 仅 active(卖家侧看:含 instruction + qr_data_uri —— 平台公开收款明细,非披露门)。 */
51
+ export function listActivePlatformAccounts(db) {
52
+ return listPlatformAccounts(db, { includeInactive: false });
53
+ }
54
+ export function getPlatformAccount(db, id) {
55
+ return db.prepare(`SELECT ${COLS} FROM platform_receive_accounts WHERE id = ?`).get(id) ?? null;
56
+ }
57
+ /** 新增 active 平台收款方式。 */
58
+ export function addPlatformAccount(db, input, generateId) {
59
+ const t = normalizePlatformText(input);
60
+ if (!t.ok)
61
+ return t;
62
+ const q = resolveQrInput(input);
63
+ if (!q.ok)
64
+ return q;
65
+ const qr = q.qr === 'keep' ? null : q.qr; // 新增时无既有值,'keep' 视作无 qr
66
+ const id = generateId('pra');
67
+ db.prepare(`INSERT INTO platform_receive_accounts (id, label, method, currency, instruction, qr_data_uri, status) VALUES (?,?,?,?,?,?, 'active')`)
68
+ .run(id, t.value.label, t.value.method, t.value.currency, t.value.instruction, qr);
69
+ return { ok: true, account: { id, status: 'active', qr_data_uri: qr, ...t.value } };
70
+ }
71
+ /** 更新(文本总更;qr 仅当入参给了 qrDataUri 才动 —— 缺省保留既有)。 */
72
+ export function updatePlatformAccount(db, id, input) {
73
+ const t = normalizePlatformText(input);
74
+ if (!t.ok)
75
+ return t;
76
+ const q = resolveQrInput(input);
77
+ if (!q.ok)
78
+ return q;
79
+ const v = t.value;
80
+ let info;
81
+ if (q.qr === 'keep') {
82
+ info = db.prepare(`UPDATE platform_receive_accounts SET label=?, method=?, currency=?, instruction=?, updated_at=datetime('now') WHERE id=?`)
83
+ .run(v.label, v.method, v.currency, v.instruction, id);
84
+ }
85
+ else {
86
+ info = db.prepare(`UPDATE platform_receive_accounts SET label=?, method=?, currency=?, instruction=?, qr_data_uri=?, updated_at=datetime('now') WHERE id=?`)
87
+ .run(v.label, v.method, v.currency, v.instruction, q.qr, id);
88
+ }
89
+ return { ok: true, changed: info.changes > 0 };
90
+ }
91
+ /** 软停用(卖家不再看到)。 */
92
+ export function deactivatePlatformAccount(db, id) {
93
+ return db.prepare("UPDATE platform_receive_accounts SET status='inactive', updated_at=datetime('now') WHERE id=? AND status='active'").run(id).changes > 0;
94
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 争议详情【只读】admin 授权谓词 —— 与后台列表 /api/admin/disputes 的 requireArbitrationAdmin
3
+ * (= requireAdminPermission(req,res,'arbitration'))【等价】的纯布尔判定(不发响应,可在 guard 里与其他条件组合)。
4
+ *
5
+ * 单一真相源:server.ts 注册 disputes-read、以及 test-disputes-read-auth 都用它 → 后台列表与详情页授权一致,
6
+ * 不会出现"能进列表却进不了详情"(错挡)或"没 arbitration 权限却能直读详情"(错放)。
7
+ *
8
+ * 规则(镜像 server.ts 的 requireAdmin + hasAdminPermission,见 server.ts:3739/3765):
9
+ * 1. 必须是 admin:user.role === 'admin' 或 roles 数组含 'admin'(grant-role 只把 admin 加进 roles,不改主 role)。
10
+ * 2. 且具 arbitration 权限:root admin(admin_type 缺省视为 'root')隐式拥有 all;区域 admin 看 admin_permissions
11
+ * 是否含 'all' 或 'arbitration'。
12
+ * 注意:isRoot 用 admin_type ?? 'root' —— 非 admin 用户 admin_type 为 NULL 会被当 root,所以【必须】先过第 1 步 admin 门,
13
+ * 否则普通 buyer 会被误判为 root(与 server.ts 先 requireAdmin 再 hasAdminPermission 的顺序一致)。
14
+ */
15
+ export function isArbitrationReadAdmin(user) {
16
+ if (!user)
17
+ return false;
18
+ const roles = (() => { try {
19
+ return JSON.parse(user.roles || '[]');
20
+ }
21
+ catch {
22
+ return [];
23
+ } })();
24
+ const isAdmin = user.role === 'admin' || (Array.isArray(roles) && roles.includes('admin'));
25
+ if (!isAdmin)
26
+ return false;
27
+ const isRoot = (user.admin_type || 'root') === 'root';
28
+ if (isRoot)
29
+ return true;
30
+ const perms = (() => { try {
31
+ return JSON.parse(user.admin_permissions || '[]');
32
+ }
33
+ catch {
34
+ return [];
35
+ } })();
36
+ return Array.isArray(perms) && (perms.includes('all') || perms.includes('arbitration'));
37
+ }
@@ -0,0 +1,100 @@
1
+ const SYSTEM_ACCOUNT_IDS = new Set(['sys_protocol', 'usr_iaudit_001']);
2
+ const effectiveStatus = (row) => !row ? null : (row.status ?? 'active'); // legacy NULL = active
3
+ /** 仲裁资格:唯一看 active arbitrator_whitelist。role='arbitrator' 不再是旁路。 */
4
+ export function isEligibleArbitrator(db, userId) {
5
+ const st = effectiveStatus(db.prepare('SELECT status FROM arbitrator_whitelist WHERE user_id = ?').get(userId));
6
+ if (st === null)
7
+ return { ok: false, reason: '非仲裁员 — 需 active arbitrator_whitelist(role 不再作旁路)' };
8
+ if (st === 'active')
9
+ return { ok: true, via: 'whitelist' };
10
+ return { ok: false, reason: st === 'suspended' ? '仲裁员资格已暂停' : '仲裁员资格已撤销' };
11
+ }
12
+ /** 目标必须是可授权的真实人类:非系统/内部号、非 system 角色、已注册 Passkey。 */
13
+ function assertGrantableHuman(db, userId) {
14
+ if (SYSTEM_ACCOUNT_IDS.has(userId))
15
+ return { ok: false, error_code: 'NOT_HUMAN', error: '系统/内部账号不可授权为仲裁员' };
16
+ const u = db.prepare('SELECT id, role, roles FROM users WHERE id = ?').get(userId);
17
+ if (!u)
18
+ return { ok: false, error_code: 'USER_NOT_FOUND', error: '用户不存在' };
19
+ let roles = [];
20
+ try {
21
+ roles = JSON.parse(u.roles || '[]');
22
+ }
23
+ catch { }
24
+ // 拒 system 与 agent(即便有 Passkey):agent 是程序化行为体,仲裁是真人铁律节点。
25
+ if (u.role === 'system' || u.role === 'agent' || roles.includes('system') || roles.includes('agent')) {
26
+ return { ok: false, error_code: 'NOT_HUMAN', error: 'system/agent 账号不可授权为仲裁员' };
27
+ }
28
+ const hasPk = db.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?').get(userId).n > 0;
29
+ if (!hasPk)
30
+ return { ok: false, error_code: 'PASSKEY_REQUIRED', error: '目标需先注册 Passkey(仲裁需真人);合成/agent 账号无 Passkey 故被拒' };
31
+ return null;
32
+ }
33
+ /** 授权为 active 仲裁员。revoked 终态不可复活。 */
34
+ /** 授权核心【无事务包裹】—— 调用方(approve / governance activate)可在自己的 db.transaction 内与状态翻转
35
+ * 同事务调用,失败时一起回滚(P2-2:grant 与申请/治理状态翻转原子)。只写不开事务;失败不写。 */
36
+ export function grantArbitratorTx(db, p) {
37
+ const bad = assertGrantableHuman(db, p.userId);
38
+ if (bad)
39
+ return bad;
40
+ const st = effectiveStatus(db.prepare('SELECT status FROM arbitrator_whitelist WHERE user_id = ?').get(p.userId));
41
+ if (st === 'revoked')
42
+ return { ok: false, error_code: 'REVOKED_TERMINAL', error: '该用户已被永久撤销仲裁员资格,不可重新授权' };
43
+ if (st === null) {
44
+ db.prepare("INSERT INTO arbitrator_whitelist (user_id, note, is_system, granted_by, status) VALUES (?,?,0,?, 'active')").run(p.userId, p.note ?? '管理员直接授权', p.grantedBy);
45
+ }
46
+ else {
47
+ db.prepare("UPDATE arbitrator_whitelist SET status='active', granted_by=?, note=?, suspended_at=NULL WHERE user_id=? AND status != 'revoked'").run(p.grantedBy, p.note ?? '管理员直接授权', p.userId);
48
+ }
49
+ return { ok: true };
50
+ }
51
+ /** 独立授权(direct grant):把核心包进自己的事务。approve/governance 请改用 grantArbitratorTx 与状态翻转同事务。 */
52
+ export function grantArbitrator(db, p) {
53
+ let out = { ok: true };
54
+ db.transaction(() => { out = grantArbitratorTx(db, p); })();
55
+ return out;
56
+ }
57
+ /** 暂停(可逆)。revoked 不可暂停。 */
58
+ export function suspendArbitrator(db, p) {
59
+ const st = effectiveStatus(db.prepare('SELECT status FROM arbitrator_whitelist WHERE user_id = ?').get(p.userId));
60
+ if (st === null)
61
+ return { ok: false, error_code: 'NOT_ARBITRATOR', error: '该用户不是仲裁员' };
62
+ if (st === 'revoked')
63
+ return { ok: false, error_code: 'REVOKED_TERMINAL', error: '已永久撤销,不可暂停' };
64
+ db.prepare("UPDATE arbitrator_whitelist SET status='suspended', suspended_at=datetime('now') WHERE user_id=? AND status != 'revoked'").run(p.userId);
65
+ return { ok: true };
66
+ }
67
+ /** 复用(暂停→active)。revoked 终态不可复活。 */
68
+ export function reinstateArbitrator(db, p) {
69
+ const st = effectiveStatus(db.prepare('SELECT status FROM arbitrator_whitelist WHERE user_id = ?').get(p.userId));
70
+ if (st === null)
71
+ return { ok: false, error_code: 'NOT_ARBITRATOR', error: '该用户不是仲裁员' };
72
+ if (st === 'revoked')
73
+ return { ok: false, error_code: 'REVOKED_TERMINAL', error: '已永久撤销,不可复用' };
74
+ if (st === 'active')
75
+ return { ok: false, error_code: 'ALREADY_ACTIVE', error: '仲裁员已是 active' };
76
+ db.prepare("UPDATE arbitrator_whitelist SET status='active', suspended_at=NULL WHERE user_id=? AND status='suspended'").run(p.userId);
77
+ return { ok: true };
78
+ }
79
+ /** 撤销(终态,不可逆)。active/suspended 均可撤销;已撤销幂等。 */
80
+ export function revokeArbitrator(db, p) {
81
+ const st = effectiveStatus(db.prepare('SELECT status FROM arbitrator_whitelist WHERE user_id = ?').get(p.userId));
82
+ if (st === null)
83
+ return { ok: false, error_code: 'NOT_ARBITRATOR', error: '该用户不是仲裁员' };
84
+ if (st === 'revoked')
85
+ return { ok: true }; // 幂等:已终态
86
+ db.prepare("UPDATE arbitrator_whitelist SET status='revoked', revoked_at=datetime('now') WHERE user_id=?").run(p.userId);
87
+ return { ok: true };
88
+ }
89
+ /** 名册(admin 只读)。status NULL 归一为 active 便于展示。 */
90
+ export function listArbitrators(db) {
91
+ const rows = db.prepare(`SELECT user_id, status, added_at, suspended_at, revoked_at, granted_by, is_system, note
92
+ FROM arbitrator_whitelist ORDER BY added_at DESC`).all();
93
+ return rows.map(r => ({ ...r, status: (r.status ?? 'active') }));
94
+ }
95
+ /** COI:当前用户是否为本案当事方(买家/卖家/物流/发起人/被诉人)→ 不得仲裁。 */
96
+ export function arbitratorHasConflict(db, orderId, initiatorId, defendantId, userId) {
97
+ const o = db.prepare('SELECT buyer_id, seller_id, logistics_id FROM orders WHERE id = ?').get(orderId);
98
+ const parties = [o?.buyer_id, o?.seller_id, o?.logistics_id, initiatorId, defendantId];
99
+ return parties.some(p => p != null && p === userId);
100
+ }
@@ -39,6 +39,21 @@ export const CONTRACT_CHANGES = [
39
39
  { contract_version: 6, date: '2026-06-27', surface: 'entity', kind: 'added', summary: '§① order lifecycle gains two Direct Pay Rail 1 (non-custodial off-protocol payment) states: direct_pay_window (seller fee-stake locked, payment method shown, awaiting buyer off-protocol payment) and direct_expired_unconfirmed (payment window timed out without buyer confirmation — order is NOT silently closed; buyer retains a dispute/confirm window). Additive — existing order states/transitions unchanged; escrow orders never enter these states; agents may ignore unless they place direct_p2p orders.' },
40
40
  { contract_version: 7, date: '2026-06-27', surface: 'capability', kind: 'added', summary: '§② capability matrix RESERVES a new "direct_pay" action scope for the future /api/direct-pay/* surface (Direct Pay Rail 1). SCAFFOLD ONLY: this revision ships the D1/D2 disclosure helper + append-only ack model and a Passkey RISK-guard helper for later wiring — NO production route is gated yet, and the current order-action paths (mark_paid / cancel / confirm / confirm-in-person) do NOT yet enforce the Passkey or the two-disclosure-ack gate. The classifier maps /api/direct-pay/* → direct_pay so that surface is RISK-scoped the moment routes are added. Additive — no existing action changed.', migration: 'No behavioural change yet — do NOT assume Direct Pay order actions are Passkey-gated or disclosure-gated at this revision, and do NOT assume agents are blocked from them. Real enforcement (Passkey + two-disclosure-ack on mark_paid/confirm/confirm-in-person) is wired in a later PR together with the create route, ack endpoints, and UI.' },
41
41
  { contract_version: 8, date: '2026-07-02', surface: 'capability', kind: 'added', summary: 'Direct Pay Rail 1 (non-custodial) buyer account selection: POST /api/orders now accepts an OPTIONAL direct_receive_account_id — when a seller offers multiple receive accounts (GET /api/direct-receive/selectable-accounts, metadata-only), the buyer picks one and the create route snapshots THAT account. Omitted → unchanged dual-read fallback to the seller\'s single legacy payment instruction (no behaviour change for sellers with only that). The direct_p2p order gains a direct_pay_account_snapshot (non-sensitive metadata {account_id,method,currency,label,qr_ref} frozen at create); the raw instruction text stays in the existing disclosure-gated snapshot. New buyer read GET /api/orders/:id/direct-pay-qr serves the snapshotted receive-QR image bytes, gated to the order buyer AND both D1/D2 disclosure acks (non-enumerating 404 otherwise). Additive — this create-body field + order-metadata snapshot are NOT part of the §②/§① fingerprint projection, so registered here by hand; no existing action or order state changed.', migration: 'Agents placing direct_p2p orders MAY pass direct_receive_account_id (from selectable-accounts) to choose how they pay; omitting it preserves the prior single-instruction behaviour. The raw payment instruction and the QR image remain revealed only after both disclosure acks.' },
42
+ { contract_version: 9, date: '2026-07-03', surface: 'entity', kind: 'added', summary: '§① order lifecycle gains a Direct Pay Rail 1 (non-custodial) negotiation state payment_query + its transitions (accepted→payment_query, payment_query→{accepted,cancelled,disputed}, disputed→payment_query withdraw). NOW LIVE & ENFORCED (staged PR-A state-machine → PR-B routes + timing → PR-C UI, all shipped on this same v9 surface — the wiring needed no dictionary change): for direct_p2p, seller non-receipt → payment_query = bilateral negotiation (争议) that PRECEDES 举证仲裁, not arbitration-on-first-report. The legacy accepted→disputed "未收到货款" button is RETIRED for direct_p2p (seller now uses report_nonpayment → payment_query). Flow: payment_query→disputed = evidence-required escalation (举证仲裁); confirm-received resume (payment_query→accepted); buyer/seller cancel; a 7-day buyer-recourse window after seller request-cancel; withdraw-before-ruling (disputed→payment_query) — RESTRICTED (route + DTO fail-closed) to disputes that were themselves escalated from payment_query, so fulfilment disputes (delivered→disputed, 货损/货不对版) are NOT withdrawable. Additive — existing states/transitions unchanged; escrow never uses these transitions (route guard enforces direct_p2p-only entry).', migration: 'direct_p2p non-receipt now uses the payment_query negotiation stage — seller 未收到货款 opens bilateral negotiation, NOT immediate arbitration. Legacy accepted→disputed is retired for direct_p2p (escrow-rail disputes unchanged). Agents/integrators: treat payment_query as a live order state; disputed→payment_query withdraw is available only for payment_query-escalated disputes (server returns NOT_PAYMENT_QUERY_DISPUTE otherwise) and can_withdraw_payment_query_dispute on the order DTO signals when it is offered.' },
43
+ { contract_version: 10, date: '2026-07-03', surface: 'capability', kind: 'added', summary: 'Mutual cancel (协商取消): a no-fault, both-parties-consent way to terminate a DISPUTED order, alongside arbitration. New party-only endpoints POST /api/orders/:id/mutual-cancel/{propose,accept,decline,withdraw} + GET /api/orders/:id/mutual-cancel (current proposal + the caller\'s available actions). Handshake: either party (buyer/seller) proposes; the counterparty accepts → executed atomically (db.transaction). Outcome is NEUTRAL: the order goes to the existing terminal state \'cancelled\' (no new state or transition edge — reuses disputed→cancelled, executed by the protocol on consent), the active dispute is resolved in the SAME tx (verdict=mutual_cancel) so timeout auto-judge cannot double-settle, the buyer is made whole (escrow-rail → full escrow refund + seller stake returned; direct_p2p → zero funds, protocol never held them), and NEITHER party takes any reputation/fault/fee (no dispute_loss_count, no arbitration fee). Because it adds neither an order state/edge (§①) nor a named capability scope (§②), the §②/§① fingerprint is UNCHANGED — registered here by hand. Enforcement: these writes carry NO per-action Passkey / human-presence gate (unlike withdraw / arbitrate / direct-pay actions) — the safeguard is the two-party consent handshake itself (a single compromised session cannot unilaterally cancel, because the counterparty must independently accept). They classify under the generic default-deny \'write\' scope: session-authenticated parties and \'*\' agents may call them; narrow-scope declared agents are default-denied (an order-unwinding money action is not a delegated narrow scope).', migration: 'Agents/integrators: a disputed order can now reach \'cancelled\' via bilateral mutual-cancel consent (not only via arbitration ruling or timeout). Poll GET /api/orders/:id/mutual-cancel for a pending proposal and the caller\'s can_{propose,accept,decline,withdraw} flags. No existing action, order state, or transition changed. Escrow refund on accept touches ONLY funds actually locked for the order: buyer made-whole is FAIL-CLOSED — the buyer\'s real escrowed must be ≥ the order total or the accept aborts with ESCROW_INSUFFICIENT (no partial refund, no close, dispute stays open); seller stake return is best-effort from the order snapshot stake_backing+bid_stake_held (capped to real staked, never the product\'s nominal stake_amount). GET .../mutual-cancel is party-gated (non-party → 403; no proposal/reason leak).' },
44
+ { contract_version: 11, date: '2026-07-03', surface: 'entity', kind: 'added', summary: '§① order lifecycle: the disputed→payment_query edge gains \'system\' as an allowed actor (was buyer/seller only). This backs a new arbitrator action "dismiss arbitration → back to negotiation" (ruling=dismiss_to_negotiation on POST /api/disputes/:id/arbitrate): an arbitrator may decline to rule winner/loser and instead return a Direct Pay (direct_p2p) payment-query-originated dispute to bilateral negotiation. It is NOT a merit ruling — no refund/liability/settlement, no arbitration fee, no reputation change, no escrow movement. Effect mirrors the party pq_withdraw close: order disputed→payment_query (executed by sys_protocol under the arbitrator\'s live-Passkey authorization + active-whitelist + COI/assignment gates), active dispute→dismissed (ruling_type=dismiss_to_negotiation), payment_query_deadline rebuilt, payment_query_cancel_deadline cleared, both parties notified. RESTRICTED (route + domain fail-closed) to direct_p2p disputes whose most recent entry into disputed was from payment_query — fulfilment disputes (delivered→disputed) and escrow disputes are rejected (NOT_PAYMENT_QUERY_DISPUTE / ARBITRATION_DISMISS_NOT_ALLOWED), so escrow funds are never touched. Additive — no state added, no existing edge/actor removed; the §① entity fingerprint changes only because allowed_roles on this one edge grew.', migration: 'Agents/integrators arbitrating direct_p2p payment-query disputes now have a fifth ruling option dismiss_to_negotiation that returns the order to payment_query instead of ruling. The order DTO / dispute detail expose can_dismiss_to_negotiation to signal when it is offered. Server returns NOT_PAYMENT_QUERY_DISPUTE / ARBITRATION_DISMISS_NOT_ALLOWED for fulfilment/escrow disputes. Like all arbitrate rulings it requires a purpose=\'arbitrate\' WebAuthn gate token bound to the dispute_id.' },
45
+ { contract_version: 12, date: '2026-07-04', surface: 'entity', kind: 'added', summary: '§① order lifecycle gains an accepted→cancelled edge, allowed actor \'system\' ONLY. This backs the Direct Pay Rail 1 (non-custodial) post-payment cancel-refund handshake: after the buyer has paid off-protocol (mark_paid → accepted) and BEFORE the seller ships, the buyer may request cancellation. Because the protocol never held the buyer\'s money it cannot refund — the refund happens off-protocol and the protocol only orchestrates a three-step handshake via new party-only endpoints POST /api/orders/:id/cancel-refund/{request,decline,mark-refunded,withdraw,confirm} + GET /api/orders/:id/cancel-refund (party-gated state + caller\'s available actions): buyer requests (reason, ≤3 requests/order) → seller either declines (fulfilment continues) or marks refunded (optional refund reference) → buyer confirms the refund arrived (RISK: live-Passkey gate token, purpose=direct_pay_order_action, action=cancel_refund_confirm) → the protocol executes accepted→cancelled atomically (sys actor, db.transaction: request row CAS + transition + stock restore). NEUTRAL outcome: zero funds moved, no fee (fee AR accrues only at completed), no reputation/fault for either side. Buyer withdraw is allowed only BEFORE the seller marks refunded (afterwards the buyer must confirm or open a dispute — otherwise a refunded seller could be left with fulfilment still owed). The handshake does NOT block shipping: if the seller ships while a request is open the handshake goes stale fail-closed (mark-refunded/confirm reject with ORDER_NOT_ACCEPTED). Seller silence past the respond deadline (direct_pay.cancel_refund_respond_days, default 5) lazily expires the request — no auto-cancel (that would presume the refund happened); the buyer may re-request or escalate via the existing accepted→disputed evidence path. Escrow orders are rejected (NOT_DIRECT_PAY) — escrow refunds keep their own semantics; the new edge is system-only so no party can drive accepted→cancelled directly on ANY rail.', migration: 'Agents/integrators: a direct_p2p order in accepted can now reach \'cancelled\' via the bilateral cancel-refund handshake. Poll GET /api/orders/:id/cancel-refund for the open request + can_{request,respond,confirm,withdraw} flags. The confirm step requires a purpose=direct_pay_order_action WebAuthn gate token with purpose_data {order_id, action:\'cancel_refund_confirm\'} — agents cannot perform it (Passkey hard requirement). No existing state/edge/actor changed; escrow orders and all non-handshake paths are unaffected.' },
46
+ { contract_version: 13, date: '2026-07-04', surface: 'entity', kind: 'added', summary: 'Direct Pay Rail 1: the per-order direct_pay_account_snapshot metadata gains OPTIONAL payable_* fields frozen at order create — payable_usdc (the order total in the platform display unit) and, when the chosen receive account is denominated in a supported non-USD fiat, payable_approx / payable_currency / payable_rate / payable_asof / payable_stale (a DISPLAY-ONLY reference conversion from the cached FX snapshot). Purpose: the buyer pay UI, the mark_paid timeline note (which now records 付款参考 + 应付 amount) and the seller reconciliation all reference ONE stable figure instead of re-converting with a drifting live rate. This is explicitly NOT a settlement quote: the rate may be stale (flagged), the authoritative amount is whatever the seller payment instruction states, and conversion failure simply omits payable_approx (order creation is never blocked). Additive — orders created before this revision lack the fields and clients fall back to live conversion / USDC-only display. Like v8, this snapshot is NOT part of the §②/§① fingerprint, so registered here by hand.', migration: 'Integrators reading direct_pay_account_snapshot may use payable_* when present; treat payable_approx as an at-create reference hint (payable_stale marks a cached/fallback rate), never as a settlement amount. Orders without the fields: convert client-side or show the USDC figure.' },
47
+ { contract_version: 14, date: '2026-07-04', surface: 'capability', kind: 'changed', summary: 'Direct Pay Rail 1 disclosure acks (POST /api/direct-pay/disclosure-acks): (a) the endpoint now ALSO accepts stage:"both" — a single live-Passkey ceremony (gate token purpose_data.stage="both") records BOTH pre_select + pre_confirm ack rows at once, cutting the buyer\'s first-order Passkey ceremonies from 3 to 2 (the two disclosure SCREENS are still shown and confirmed; only the human-presence proof is consolidated; the two append-only ack rows and requireBothDisclosuresAcked evidence model are unchanged; the separate mark_paid RISK Passkey is unchanged). Existing single-stage POSTs (stage:"pre_select"|"pre_confirm") keep working. (b) The D1/D2 disclosure TEXT was reworded for accuracy ("non-guaranteed / no refund CAPABILITY / seller reputation penalty only" instead of "no refund") and its notice_version bumped d1.v1→d1.v2, d2.v1→d2.v2 — so GET /api/direct-pay/disclosure-acks/:orderId now returns the new text/versions and, for orders acked under v1, acked:{pre_select|pre_confirm}=false until re-acked under v2. recordDisclosureAck changed from INSERT-OR-IGNORE to an upsert so a re-ack under a new version actually refreshes the row (prior behavior would have silently no-op\'d and permanently blocked such orders). Not part of the §②/§① fingerprint (disclosure payload/versioning), registered here by hand.', migration: 'Buyers/integrators: the disclosure-ack POST may send stage:"both" with a purpose_data.stage="both" gate token to satisfy both disclosures in one ceremony; single-stage calls remain valid. After this revision, previously-recorded v1 acks read as un-acked (acked:false) until re-confirmed under d1.v2/d2.v2 — re-acking now works (upsert). No order state/edge/fund path changed; requireBothDisclosuresAcked still hard-gates mark_paid/confirm/confirm-in-person.' },
48
+ { contract_version: 15, date: '2026-07-04', surface: 'capability', kind: 'added', summary: 'Direct Pay Rail 1 post-delivery RETURNS: direct_p2p orders can now use the standard return flow (POST /api/orders/:id/return-request on a completed order within return_days, seller decide, optional pickup logistics, negotiation messages) — the previous hard block (DIRECT_PAY_NO_REFUND) is REMOVED. Only the REFUND EXECUTION step differs from escrow: because the protocol never held the buyer\'s money, seller acceptance moves the return to a new await_refund status (off-protocol refund owed) instead of an escrow wallet transfer, then two new party-only endpoints complete the handshake — POST /api/return-requests/:id/mark-refunded (seller declares the off-protocol refund, optional refund_reference) → refund_marked, and POST /api/return-requests/:id/confirm-refund (buyer confirms the refund arrived; RISK: live-Passkey gate token purpose=direct_pay_order_action, purpose_data {order_id, action:"return_refund_confirm"}) → refunded (terminal). ZERO funds moved, ZERO automatic stock restore (post-outbound goods require offline inspection before manual restock — deliberate anti-oversell rule), platform fee receivable NOT auto-reversed (admin adjustment path). Escalation: refund_marked → buyer may escalate to dispute anytime (declaration ≠ receipt); await_refund → escalatable after direct_pay.return_refund_respond_days (default 5); the existing rejected / pending≥7d escalations still apply. Order status is NOT changed by a return (same as escrow returns). Full-amount confirmed returns decrement the product\'s best-effort completion_count (authoritative genuine-sale predicate already excludes direct_p2p). Escrow returns are COMPLETELY unchanged. return_requests rows gain refund_reference + await_refund_since columns and the two new statuses await_refund | refund_marked. Not part of the §②/§① fingerprint (no order state/edge/scope change), registered here by hand.', migration: 'Agents/integrators: a completed direct_p2p order now accepts return requests like escrow orders; DIRECT_PAY_NO_REFUND is no longer returned. Track the two new return_request statuses await_refund and refund_marked; the buyer confirm step requires a purpose=direct_pay_order_action WebAuthn gate token with purpose_data {order_id, action:"return_refund_confirm"} — agents cannot perform it (Passkey hard requirement). Do NOT expect stock to be restored or any wallet movement on a direct_p2p return.' },
49
+ { contract_version: 16, date: '2026-07-04', surface: 'entity', kind: 'added', summary: 'Manual order-acceptance mode (accept_mode): sellers may require MANUAL acceptance before the buyer pays — per-product products.accept_mode overriding a store-wide users.store_accept_mode (effective mode is SNAPSHOT onto the order at create; later setting changes never affect in-flight orders; unset = auto = prior behavior on both rails). §① order lifecycle gains state pending_accept (manual direct_p2p orders land here at create instead of direct_pay_window) + 3 edges: created→pending_accept (system, at create), pending_accept→direct_pay_window (seller accepts — ONLY now does the payment window clock start and the payment instruction/QR become readable; a STATUS gate in the shared redaction projector hides the payment target during pending_accept even if D1/D2 are already acked), pending_accept→cancelled (seller decline / buyer withdraw / system timeout after direct_pay.accept_window_hours, default 24h — a NO-FAULT cancel: nobody has paid, zero funds move, stock restored via the pre-ship restock whitelist, both parties notified). New party-only endpoints POST /api/orders/:id/pending-accept/{accept,decline,cancel} + seller setting POST /api/seller/accept-mode. ESCROW rail deliberately does NOT use pending_accept: escrow is already a conditional intermediate state (funds locked at order, seller must accept within 24h or the buyer is auto-refunded in full) — accept_mode=auto on escrow simply auto-accepts at paid (formalizing the auto_accept Skill), manual/unset keeps todays flow. pending_accept counts toward the per-buyer open-order cap and the sellers in-flight fee estimate (griefing guards). Rationale: a non-custodial rail has no authorization-hold primitive — sequencing (accept before pay) is the only honest payment risk control.', migration: 'Agents placing direct_p2p orders: the create response may now return status pending_accept — do NOT expect payment instructions until the order reaches direct_pay_window (the payment-target fields stay redacted and the QR endpoint 404s during pending_accept). Track the new state + 3 edges from the lifecycle contract. Escrow orders and all orders on sellers/products without accept_mode=manual behave exactly as before.' },
50
+ { contract_version: 17, date: '2026-07-04', surface: 'capability', kind: 'added', summary: 'Region-based shipping-fee templates: sellers may predefine per-region shipping fees + estimated delivery time — per-product products.shipping_template overriding a store-wide users.store_shipping_template (JSON entries {region (uppercase code or "*" wildcard = rest of world), fee (same unit as the product price), est_days (display-only)}). When an EFFECTIVE template exists for a product, POST /api/orders REQUIRES a new body field ship_to_region (else 400 SHIP_REGION_REQUIRED); a covered region adds the template fee into total_amount BEFORE any money/gate logic on BOTH rails (escrow lock, dp per-tx cap and platform fee all see the fee-inclusive total — same convention as the insurance premium; no separate shipping settlement subject in v1) and snapshots orders.{ship_to_region, shipping_fee, shipping_est_days} (later template edits never affect in-flight orders); an uncovered region REJECTS order creation with 409 SHIP_REGION_NOT_COVERED (a quote-on-accept handshake for uncovered regions ships in a later revision). Products/sellers WITHOUT a template behave exactly as before (ship_to_region optional, no fee). New endpoints: POST /api/seller/shipping-template (seller: store default and/or per-product override; null/[] clears) and public GET /api/products/:id/shipping-options (effective template + region_required flag — buyers can see coverage/fee/ETA before ordering). est_days is display-only and does NOT feed the fulfilment-deadline clocks. Payment timing is UNCHANGED on both rails. Not part of the §②/§① fingerprint (create-body field + non-fingerprinted endpoints), registered by hand.', migration: 'Agents placing orders: call GET /api/products/:id/shipping-options first — if region_required, include ship_to_region (uppercase region code) in POST /api/orders and expect the returned total to include the template shipping fee; handle 400 SHIP_REGION_REQUIRED and 409 SHIP_REGION_NOT_COVERED. Orders on products without a template are unaffected.' },
51
+ { contract_version: 18, date: '2026-07-04', surface: 'capability', kind: 'added', summary: 'Shipping quote-on-accept handshake (direct_p2p only): when a product has an effective shipping template, the buyer region is NOT covered, and the seller has opted in (products.shipping_quote_ok ?? users.store_shipping_quote_ok, default OFF — settable via POST /api/seller/shipping-template {store_quote_ok | product_id+quote_ok}; surfaced as quote_outside_template on GET /api/products/:id/shipping-options), POST /api/orders no longer 409s: the order is created into pending_accept with shipping_quote_required=1 (REGARDLESS of accept_mode) and ZERO shipping fee in the total. Flow: seller POST /api/orders/:id/pending-accept/quote {shipping_fee, est_days?, note?} — the quote may only ADD a shipping-fee line (goods price untouchable) and goods+fee must fit the per-tx cap SNAPSHOT frozen at create (409 QUOTE_EXCEEDS_CAP — shipping cannot smuggle past the small-amount rail boundary); re-quoting before confirmation is allowed and each quote resets the response window (direct_pay.quote_confirm_hours, default 48h, same pending_accept_deadline → the expiry sweep covers both quote silence and confirm silence). Buyer POST .../confirm-quote → atomically (one db.transaction) total_amount += fee (integer base-units math), shipping_fee/est_days snapshotted, payable_* reference conversion REBUILT at the new total, payment window clock starts, order → direct_pay_window. Buyer decline = existing /cancel; seller withdraw = existing /decline (both no-fault + restock, nobody has paid). Plain /accept on a quote order is rejected (409 QUOTE_REQUIRED) so the fee step cannot be bypassed. AML monitor re-runs after the total change (fail-soft). Escrow-rail quote remains unavailable (create still 409 SHIP_REGION_NOT_COVERED; requires the pay-after-accept money path — separate revision). No new order state or edge (rides pending_accept + the existing pending_accept→direct_pay_window system edge); registered by hand.', migration: 'Agents on direct_p2p: create may return status pending_accept with shipping_quote_required=true even for auto-accept products — wait for the seller quote, then read shipping_quote_fee and call confirm-quote to consent to the NEW total (total_amount changes on confirmation; the D1/D2 + mark_paid gates are unchanged and payment info stays hidden until direct_pay_window). Do not expect /accept to work on quote orders (409 QUOTE_REQUIRED).' },
52
+ { contract_version: 19, date: '2026-07-05', surface: 'entity', kind: 'added', summary: '§① order lifecycle gains a disputed→confirmed edge, allowed actor \'buyer\' ONLY: the buyer may WITHDRAW a fulfilment dispute and confirm receipt (撤诉并确认收货) — e.g. a "package never arrived" parcel later turns up at a pickup point. Closes the gap where a delivered-origin dispute could only end via arbitration or mutual-cancel: both consensual closures now exist (seller-side = mutual-cancel full refund; buyer-side = this edge). New order action POST /api/orders/:id/action {action:"dispute_withdraw_confirm"} — RESTRICTED (route + DTO fail-closed, same pattern as pq_withdraw) to disputes whose most recent entry into disputed was from delivered (fulfilment disputes; payment_query-originated disputes keep their own pq_withdraw negotiation return), AND caller = buyer = dispute initiator, AND the dispute is still open/in_review (post-ruling → DISPUTE_ALREADY_RULED). Executed ATOMICALLY in one db.transaction: active dispute → dismissed (NO fault, NO reputation change, NO arbitration fee for either side) + disputed→confirmed→completed + settleOrder — the identical settlement chain to a normal confirm (escrow: funds release to seller; direct_p2p: platform fee accrues). On direct_p2p this is a RISK action: both D1/D2 disclosure acks + a live-Passkey gate token (purpose=direct_pay_order_action, purpose_data {order_id, action:"dispute_withdraw_confirm"}) are required — agents cannot perform it. The order DTO exposes can_confirm_receipt_close_dispute to signal when it is offered.', migration: 'Agents/integrators: a disputed order can now reach confirmed/completed via buyer dispute-withdrawal — treat disputed as non-terminal toward completion as well as cancellation. Offer the action only when can_confirm_receipt_close_dispute is true; server returns NOT_FULFILMENT_DISPUTE / NOT_DISPUTE_INITIATOR / DISPUTE_ALREADY_RULED otherwise. On direct_p2p a Passkey gate token is required (HUMAN_PRESENCE_REQUIRED without it). No existing state/edge/actor changed.' },
53
+ { contract_version: 20, date: '2026-07-05', surface: 'capability', kind: 'added', summary: 'Cross-border trade-terms skeleton (S0 of the cross-border series): (a) products gain OPTIONAL customs/logistics evidence fields on POST /api/products + PUT update — package_size, origin_country, country_of_origin, customs_description, hs_code (validated lightly: region codes uppercased ≤8 chars, hs_code 4-12 digits/dots, 400 INVALID_HS_CODE otherwise; display/evidence only, ZERO pricing logic). (b) every NEW order freezes a trade_terms snapshot at create (both rails): shipping ruling (source template|quote_pending|quote|none + region/fee/est_days; the quote slot is updated once when the buyer confirms a manual quote), fulfilment promises (handling_hours/estimated_days/return_days/return_condition/warranty_days), the customs/logistics fields above, seller declarations (free-text ship_regions + reserved structured slots), and the effective accept_mode — seller setting changes NEVER affect in-flight orders, and disputes get a written record of the terms at purchase time. GET /api/orders/:id returns it parsed as trade_terms (null for pre-S0 orders / bad JSON — consumers MUST tolerate null); the raw column is never serialized (list endpoint strips it). Reserved (columns exist, NOT accepted by any API yet — deliberately no fake switches): sale_regions / tax_lines / import_duty_terms — these activate in later revisions together with their enforcement gates (sellable-region gate; tax lines into totals with DDP/DDU declarations). Snapshot writes are fail-soft and are NOT part of any money path. Not part of the §②/§① fingerprint; registered by hand.', migration: 'Integrators: treat order.trade_terms as optional (null on older orders). Sellers MAY declare customs evidence fields on listings — they surface in order snapshots and buyer-facing detail; they do not change pricing, shipping fees or deadlines. Do NOT send sale_regions/tax_lines/import_duty_terms yet — they are rejected/ignored until the gates ship.' },
54
+ { contract_version: 21, date: '2026-07-05', surface: 'capability', kind: 'added', summary: 'Sellable regions (S1 of the cross-border series): sellers may now declare WHERE they sell, independent of shipping fees. POST /api/seller/shipping-template accepts store_sale_regions and/or {product_id, sale_regions} — rule shape {mode:"all"|"list", include?:[..], exclude?:[..]} (uppercase region codes; null clears; product overrides store; strict validation, 400 BAD_SALE_REGIONS). GET /api/seller/shipping-settings returns the parsed store rule. ENFORCEMENT: POST /api/orders now runs a sellable-region gate BEFORE the shipping gate, on BOTH rails, with the destination-capability taxonomy: 409 REGION_NOT_FOR_SALE (merchant will — a hard reject, deliberately NOT eligible for the quote-on-accept path; not selling is not a pricing problem), 409 PRODUCT_RESTRICTED (platform compliance overlay via the admin protocol param trade.platform_region_blocklist — merchants cannot widen it), 400 SHIP_REGION_REQUIRED (a rule exists but the buyer sent no ship_to_region — fail-closed). Products/stores without a rule behave exactly as before. The order trade_terms snapshot (v20) now carries the effective rule in declarations.sale_regions_rule. The legacy free-text products.ship_regions remains display-only and does NOT feed this gate. Not part of the §②/§① fingerprint; registered by hand.', migration: 'Agents placing orders: be ready for 409 REGION_NOT_FOR_SALE / PRODUCT_RESTRICTED and 400 SHIP_REGION_REQUIRED on POST /api/orders even for products without shipping templates. There is no quote fallback for REGION_NOT_FOR_SALE — pick a different destination or product. Sellers: declare rules via POST /api/seller/shipping-template; buyer-facing pre-checkout display of sellable regions ships in a later revision (S5).' },
55
+ { contract_version: 22, date: '2026-07-06', surface: 'capability', kind: 'added', summary: 'Free-shipping threshold as a MARKETING rule (S2 of the cross-border series; deliberately NOT a shipping-template field — templates stay pure cost/coverage structure, and under the future logistics-provider model the waiver is necessarily a merchant subsidy, so the rule lives where it will not need to move). Sellers set per-product products.free_shipping_threshold ?? store users.store_free_shipping_threshold (product overrides store) via POST /api/seller/shipping-template ({store_free_shipping_threshold} | {product_id, free_shipping_threshold}; positive finite, 400 BAD_FREE_SHIPPING_THRESHOLD; null clears). At order create, on BOTH rails, when the post-coupon goods subtotal (excluding insurance/donation) reaches the effective threshold AND the template fee for the destination is positive, the shipping fee is waived to 0 — comparison in integer base units, boundary >=. The manual quote-on-accept path is exempt (a human-priced quote is authoritative). The order trade_terms snapshot gains shipping.free_threshold_applied so disputes can distinguish waived-by-promotion from no-fee-configured. GET /api/seller/shipping-settings returns store_free_shipping_threshold. Additive; not part of the §②/§① fingerprint; registered by hand.', migration: 'Integrators: create totals may carry 0 shipping fee for covered regions when the goods subtotal reaches the seller\'s marketing threshold; read trade_terms.shipping.free_threshold_applied to distinguish. Shipping template entries are UNCHANGED (no promo fields).' },
56
+ { contract_version: 23, date: '2026-07-06', surface: 'capability', kind: 'added', summary: 'Cross-border tax & import-responsibility DECLARATION + DISCLOSURE layer (S3). Sellers declare, the platform does NOT compute/collect/remit tax (seller-of-record posture; see docs/COMPLIANCE-CROSS-BORDER-TAX.INTERNAL.md — non-custodial does NOT by itself exempt the platform from deemed-supplier/marketplace-facilitator status, so cross-border into high-enforcement regimes is counsel-gated via the S1 trade.platform_region_blocklist, not opened here). Two seller-declared fields, per-product ?? store, set via POST /api/seller/shipping-template: (1) import_duty_terms enum {ddu|ddp|null} — who bears destination import duty/tax (DDU = buyer pays on arrival; DDP = seller included it); 400 BAD_IMPORT_DUTY_TERMS. (2) tax_lines — an array of {region (uppercase code or *), label, rate_pct?, note?, kind:"included"} declaring taxes ALREADY INCLUDED in the price (e.g. SG GST 9%); kind is restricted to "included" and kind:"added" (tax added into the order total) is explicitly REJECTED — the money-path fold is a deliberately deferred future revision, the platform does not collect tax; 400 BAD_TAX_LINES. GET /api/products/:id/shipping-options now also returns import_duty_terms and tax_included_lines for pre-purchase buyer disclosure — tax_included_lines is FILTERED by an optional ?ship_to_region query param (destination match + wildcard \'*\' lines; no param = only \'*\' universal lines), so buyers are never shown taxes that do not apply to their destination; import_duty_terms is a single per-order flag (not region-scoped); GET /api/seller/shipping-settings returns store_import_duty_terms + store_tax_lines. The order trade_terms snapshot declarations.{import_duty_terms,tax_lines} slots (reserved in S0/contract v20) now fill from these columns. No order state/edge/money change; totals are unaffected (included-tax and DDP/DDU never alter total_amount). Additive; not part of the §②/§① fingerprint; registered by hand.', migration: 'Agents/integrators: read order.trade_terms.declarations.{import_duty_terms,tax_lines} and GET shipping-options?ship_to_region=XX {import_duty_terms,tax_included_lines} for a listing/order\'s declared tax posture (pass ship_to_region to get destination-applicable included-tax lines; omitting it returns only \'*\' universal lines). These are seller declarations only — the platform neither computes nor collects tax, and order totals are NOT changed by them (DDU import charges are paid by the buyer to the carrier at the border; DDP and included-tax are already inside the seller\'s price). Do NOT send tax_lines with kind:"added" (rejected).' },
42
57
  ];
43
58
  export function buildChangeFeed() {
44
59
  return {
@@ -1,9 +1,11 @@
1
1
  import { requireBothDisclosuresAcked } from '../direct-pay-disclosures.js';
2
- /** 买家自视角:未 both-acked 的 direct_p2p 订单,删收款目标(instruction 原文 + 账号快照 qr_ref)。就地改 o。 */
2
+ /** 买家自视角:未 both-acked 的 direct_p2p 订单,删收款目标(instruction 原文 + 账号快照 qr_ref)。就地改 o。
3
+ * 手动接单(v16):pending_accept 阶段【状态门】无条件遮蔽 —— 卖家还没确认能发货,买家不该拿到任何收款信息
4
+ * (哪怕已 ack 披露;时序门=非托管唯一付款风控,接单后才起表付款窗)。 */
3
5
  export function redactUnackedDirectPayTarget(db, o, userId) {
4
6
  if (o.payment_rail !== 'direct_p2p' || o.buyer_id !== userId)
5
7
  return;
6
- if (requireBothDisclosuresAcked(db, o.id).ok)
8
+ if (o.status !== 'pending_accept' && requireBothDisclosuresAcked(db, o.id).ok)
7
9
  return;
8
10
  delete o.direct_pay_instruction_snapshot;
9
11
  if (o.direct_pay_account_snapshot != null) {
@@ -22,3 +24,19 @@ export function stripDirectPayPaymentTarget(o) {
22
24
  delete o.direct_pay_instruction_snapshot;
23
25
  delete o.direct_pay_account_snapshot;
24
26
  }
27
+ /**
28
+ * 【唯一入口】按查看者投影收款目标(#218 审计发现 6):三类查看者一次分派,route 不再手工按序组合两个原语 ——
29
+ * 曾经的组合错法每种都真实发生过:列表只调 redact(→ logistics 第三方拿到收款目标)、详情只调 redact(→ 仲裁员拿到)。
30
+ * - 卖家(o.seller_id===viewerId):收款方,instruction 是他自填的 → 不动。
31
+ * - 买家:ack 门(redactUnackedDirectPayTarget —— 未 both-acked 删 instruction+剥 qr_ref,留非敏感元数据)。
32
+ * - 其他一切查看者(logistics/仲裁员/admin/任何第三方):无条件剥离(stripDirectPayPaymentTarget)。
33
+ * escrow 单不含收款目标语义,原语各自 no-op/删空,安全。所有返回 orders 整行的 reader 必须调本函数
34
+ * (reader-guard 的 GATE 认的就是本函数名);两个原语仍导出,仅作本函数的构件,route 层不应直接使用。
35
+ */
36
+ export function projectDirectPayTargetForViewer(db, o, viewerId) {
37
+ if (o.seller_id === viewerId)
38
+ return;
39
+ if (o.buyer_id === viewerId)
40
+ return redactUnackedDirectPayTarget(db, o, viewerId);
41
+ stripDirectPayPaymentTarget(o);
42
+ }
@@ -37,7 +37,8 @@ export function createHumanPresence(db, getProtocolParam) {
37
37
  // ─── Agent 治理铁律:人工铁律节点 ───
38
38
  // 关键节点(verifier 投票 / arbitrator 仲裁 / agent_revoke / delete_passkey / identity_claim)必须真实
39
39
  // 人工参与,agent 代操作被拦截。实现:要求 webauthn_gate_token(一次性 · 60s)+ 协议参数开关。
40
- // is_system fixture 旁路只对 vote/arbitrate 生效(有白名单表);其余无豁免。
40
+ // is_system fixture 旁路【只对 vote 生效】。PR-C:arbitrate 旁路已移除 —— HTTP 人类仲裁路由所有真人仲裁员都必须
41
+ // 现场 Passkey(consumeGateToken)。sys_protocol 自动裁决走 engine arbitrateDispute(role=system,不经此函数),不受影响。
41
42
  function requireHumanPresence(userId, purpose, token, paramKey, validate = () => true) {
42
43
  const enabled = Number(getProtocolParam(paramKey, 1)) === 1;
43
44
  if (!enabled)
@@ -47,11 +48,6 @@ export function createHumanPresence(db, getProtocolParam) {
47
48
  if (wl?.is_system === 1)
48
49
  return { ok: true };
49
50
  }
50
- else if (purpose === 'arbitrate') {
51
- const wl = db.prepare('SELECT is_system FROM arbitrator_whitelist WHERE user_id = ?').get(userId);
52
- if (wl?.is_system === 1)
53
- return { ok: true };
54
- }
55
51
  const result = consumeGateToken(userId, token, purpose, validate);
56
52
  if (!result.ok) {
57
53
  return { ok: false, error_code: 'HUMAN_PRESENCE_REQUIRED', reason: result.reason || '此操作需真实人工 WebAuthn 验证', required_when_enabled: true };
@@ -117,7 +117,7 @@ async function renderMyAdvanced(app) {
117
117
  }
118
118
 
119
119
  async function renderProfile(app) {
120
- app.innerHTML = shell(loading$(), 'me')
120
+ if (state.apiKey && !state.user) { const me = await GET('/me'); if (!me.error) state.user = me } app.innerHTML = shell(loading$(), 'me') // 自愈(修"切角色/改密后假登出"):switchRole/addRole/set-password 置空 state.user 后绕过 render() 路由直调本函数,shell 曾画成未登录(header 变登录钮/底栏错乱,不自愈) → 先权威重取再画
121
121
  const [data, blocklist] = await Promise.all([
122
122
  GET('/profile'),
123
123
  GET('/blocklist/me').catch(() => ({ blocked: [] })),
@@ -131,12 +131,12 @@ async function renderProfile(app) {
131
131
  const allRoles = [...SELF_SERVE_ROLES, ...APPLY_ROLES]
132
132
  const roleLabels = { buyer: t('买家'), seller: t('卖家'), logistics: t('物流'), arbitrator: t('仲裁员'), verifier: t('审核员'), admin: t('管理员') }
133
133
  const roleIcons = { buyer: '🛍️', seller: '🏪', logistics: '🚚', arbitrator: '⚖️', verifier: '🔍', admin: '🛡' }
134
- const addable = allRoles.filter(r => !roles.includes(r))
134
+ if (!state.canArbitrate) { try { const _as = await GET('/arbitrator/status'); state.canArbitrate = !!(_as && _as.can_arbitrate) } catch {} } const addable = allRoles.filter(r => !roles.includes(r) && !(r === 'arbitrator' && state.canArbitrate)) // #249-P3:仲裁资格=白名单非角色,roles 判不出 → 现场查;已持资格不再被邀请"申请仲裁员"
135
135
  // 受信角色:admin / verifier / logistics / arbitrator — 隐藏交易/社交相关 UI
136
136
  const TRUSTED_ROLES = ['admin', 'verifier', 'logistics', 'arbitrator']
137
137
  const isTrustedRole = TRUSTED_ROLES.includes(data.role) || roles.some(r => ['admin','verifier'].includes(r))
138
- // 受信角色显示的"已有角色" chip 只展示受信角色(隐藏 buyer/seller,即使账户有遗留多角色)
139
- const visibleRoles = isTrustedRole ? roles.filter(r => TRUSTED_ROLES.includes(r)) : roles
138
+ const identityLocked = ['admin', 'verifier'].includes(data.role) || roles.some(r => ['admin', 'verifier'].includes(r)) // 角色 chip 过滤:身份锁只看 admin/verifier(与服务端 switch-role 守卫同谓词)。此前 arbitrator/logistics active 也触发过滤 → legacy 仲裁员角色下 buyer/seller chip 被藏切不回买家(梦想者1号案);服务端本就允许该切换,前后端对齐
139
+ const visibleRoles = identityLocked ? roles.filter(r => TRUSTED_ROLES.includes(r)) : roles
140
140
 
141
141
  app.innerHTML = shell(`
142
142
  ${mySubTabsHTML('settings')}
@@ -204,7 +204,7 @@ async function renderProfile(app) {
204
204
  ${roleIcons[r]} ${roleLabels[r]}
205
205
  ${r === data.role ? `<span style="font-size:11px;color:#3b82f6">${t('● 激活')}</span>` : ''}
206
206
  </button>
207
- `).join('')}
207
+ `).join('')}${state.canArbitrate ? `<span title="${t('仲裁资格是白名单能力,叠加在你的买卖身份上,不是可切换角色')}" style="display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border-radius:10px;font-size:14px;border:2px dashed #c4b5fd;background:#f5f3ff;color:#6b21a8">⚖️ ${t('仲裁员资格(白名单)')}</span>` : ''}
208
208
  </div>
209
209
 
210
210
  ${roles.some(r => ['admin','verifier'].includes(r)) ? `
@@ -227,8 +227,8 @@ async function renderProfile(app) {
227
227
  <div style="font-size:13px;color:#6b7280;margin:12px 0 6px">${t('需通过申请获得')}</div>
228
228
  <div style="display:flex;flex-wrap:wrap;gap:8px">
229
229
  ${addable.filter(r => APPLY_ROLES.includes(r)).map(r => {
230
- if (r === 'verifier') {
231
- return `<button onclick="navigate('#apply-verifier')" style="
230
+ if (r === 'verifier' || r === 'arbitrator') { // 走查批次3:仲裁员早已不是"角色"(资格=白名单+个案COI,#220),灰死"联系管理员"chip 是旧模型残留 → 与审核员一致跳真实申请流程(不切角色,保留买卖身份)
231
+ return `<button onclick="navigate('${r === 'verifier' ? '#apply-verifier' : '#apply-arbitrator'}')" style="
232
232
  display:flex;align-items:center;gap:6px;padding:8px 14px;border-radius:10px;font-size:14px;cursor:pointer;
233
233
  background:#eef2ff;border:2px solid #6366f1;color:#4338ca
234
234
  ">${roleIcons[r]} 📥 ${t('申请')} ${roleLabels[r]}</button>`
@@ -438,7 +438,7 @@ async function renderProfile(app) {
438
438
  ${t('提现等敏感操作可要求设备指纹 / Face ID 二次确认。私钥不离开你的手机,手机丢失也不会泄露。')}
439
439
  </div>
440
440
  <div id="passkey-list" style="font-size:12px;color:#6b7280">${t('加载中…')}</div>
441
- <a onclick="navigate('#agents')" style="display:block;margin-top:12px;font-size:12px;color:#4f46e5;cursor:pointer">🔌 ${t('已连接的 Agent')} →</a>
441
+ <a onclick="navigate('#agents')" style="display:block;margin-top:12px;font-size:12px;color:#4f46e5;cursor:pointer">🔌 ${t('已连接的 Agent')} →</a><a onclick="navigate('#agent-approvals')" style="display:block;margin-top:8px;font-size:12px;color:#4f46e5;cursor:pointer">🔔 ${t('Agent 授权请求')}<span id="aa-pending-badge"></span> →</a>
442
442
  </div>
443
443
  </div>
444
444
  </div>
@@ -552,7 +552,7 @@ async function renderProfile(app) {
552
552
  })()
553
553
 
554
554
  // 加载 Passkey 列表
555
- refreshPasskeyList()
555
+ refreshPasskeyList(); if (window.hydrateAgentApprovalsBadge) hydrateAgentApprovalsBadge()
556
556
  }
557
557
 
558
558
  async function refreshPasskeyList() {