@seasonkoh/webaz 0.1.28 → 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.
- package/README.md +3 -2
- package/README.zh-CN.md +7 -6
- package/dist/bond-refund-blockers.js +50 -0
- package/dist/bond-slash.js +100 -0
- package/dist/bond-terms.js +31 -0
- package/dist/currency.js +16 -0
- package/dist/deposit-rails.js +52 -0
- package/dist/direct-pay-aml-monitor.js +67 -0
- package/dist/direct-pay-aml-review.js +40 -0
- package/dist/direct-pay-base-bond-entry.js +5 -0
- package/dist/direct-pay-bond-rail-clearance.js +99 -0
- package/dist/direct-pay-cancel-refund.js +160 -0
- package/dist/direct-pay-compliance-ingress.js +145 -0
- package/dist/direct-pay-controls.js +136 -0
- package/dist/direct-pay-create.js +231 -0
- package/dist/direct-pay-deferral-quota.js +43 -0
- package/dist/direct-pay-disclosures.js +50 -0
- package/dist/direct-pay-eligibility.js +46 -0
- package/dist/direct-pay-fee-ar.js +241 -0
- package/dist/direct-pay-fee-prepay-request.js +80 -0
- package/dist/direct-pay-launch-readiness.js +108 -0
- package/dist/direct-pay-launch-summary.js +68 -0
- package/dist/direct-pay-ledger.js +94 -0
- package/dist/direct-pay-returns.js +104 -0
- package/dist/direct-pay-stock.js +9 -0
- package/dist/direct-receive-account-qr.js +77 -0
- package/dist/direct-receive-accounts.js +82 -0
- package/dist/direct-receive-deferral.js +171 -0
- package/dist/direct-receive-deposits.js +365 -0
- package/dist/direct-receive-payment-instruction.js +26 -0
- package/dist/free-shipping.js +37 -0
- package/dist/fx-rates.js +78 -0
- package/dist/layer0-foundation/L0-1-database/schema.js +777 -1
- package/dist/layer0-foundation/L0-2-state-machine/engine.js +1 -0
- package/dist/layer0-foundation/L0-2-state-machine/genuine-sale.js +15 -5
- package/dist/layer0-foundation/L0-2-state-machine/transitions.js +114 -0
- package/dist/layer0-foundation/L0-5-manifest/manifest.js +1 -1
- package/dist/layer1-agent/L1-1-mcp-server/cli.js +64 -0
- package/dist/layer1-agent/L1-1-mcp-server/network-mode.js +11 -0
- package/dist/layer1-agent/L1-1-mcp-server/server.js +99 -32
- package/dist/layer2-business/L2-6-notifications/notification-engine.js +110 -41
- package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +133 -31
- package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +16 -4
- package/dist/layer3-trust/L3-1-dispute-engine/mutual-cancel.js +156 -0
- package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +4 -4
- package/dist/ledger.js +12 -3
- package/dist/mcp.js +23 -4
- package/dist/merchant-bond-domain.js +64 -0
- package/dist/merchant-bond-exposure.js +78 -0
- package/dist/merchant-bond-watcher.js +26 -0
- package/dist/payment-rails.js +29 -0
- package/dist/platform-receive-accounts.js +94 -0
- package/dist/product-verification.js +93 -0
- package/dist/pwa/acp-feed.js +3 -2
- package/dist/pwa/arbitration-read-admin.js +37 -0
- package/dist/pwa/arbitrator-lifecycle.js +100 -0
- package/dist/pwa/contract-fingerprint.js +18 -0
- package/dist/pwa/direct-pay-guards.js +20 -0
- package/dist/pwa/direct-pay-order-redaction.js +42 -0
- package/dist/pwa/endpoint-actions.js +3 -0
- package/dist/pwa/human-presence.js +2 -6
- package/dist/pwa/public/app-account.js +9 -9
- package/dist/pwa/public/app-admin-disputes.js +55 -0
- package/dist/pwa/public/app-agent-appeal.js +90 -0
- package/dist/pwa/public/app-agent-approvals.js +93 -0
- package/dist/pwa/public/app-agent-pair.js +127 -0
- package/dist/pwa/public/app-ai.js +10 -10
- package/dist/pwa/public/app-arbitrator-admin.js +87 -0
- package/dist/pwa/public/app-arbitrator-entry.js +9 -0
- package/dist/pwa/public/app-bond-deferral-ui.js +9 -0
- package/dist/pwa/public/app-bond-refund-ui.js +66 -0
- package/dist/pwa/public/app-bond-slash-ui.js +74 -0
- package/dist/pwa/public/app-bond-terms-ui.js +23 -0
- package/dist/pwa/public/app-bond-ui.js +108 -0
- package/dist/pwa/public/app-chat-poll.js +29 -0
- package/dist/pwa/public/app-contribution-hub.js +23 -0
- package/dist/pwa/public/app-create-kinds.js +17 -0
- package/dist/pwa/public/app-direct-pay-accounts.js +141 -0
- package/dist/pwa/public/app-direct-pay-buyer.js +72 -0
- package/dist/pwa/public/app-direct-pay-cancel-refund.js +72 -0
- package/dist/pwa/public/app-direct-pay-compliance.js +67 -0
- package/dist/pwa/public/app-direct-pay-copy.js +12 -0
- package/dist/pwa/public/app-direct-pay-deferral-admin.js +72 -0
- package/dist/pwa/public/app-direct-pay-deferral.js +61 -0
- package/dist/pwa/public/app-direct-pay-fee-center.js +33 -0
- package/dist/pwa/public/app-direct-pay-fee-history.js +39 -0
- package/dist/pwa/public/app-direct-pay-fee-ops.js +112 -0
- package/dist/pwa/public/app-direct-pay-fee-request.js +81 -0
- package/dist/pwa/public/app-direct-pay-fee-requests-admin.js +70 -0
- package/dist/pwa/public/app-direct-pay-memo.js +14 -0
- package/dist/pwa/public/app-direct-pay-negotiation.js +35 -0
- package/dist/pwa/public/app-direct-pay-pay.js +15 -0
- package/dist/pwa/public/app-direct-pay-paymodal.js +32 -0
- package/dist/pwa/public/app-direct-pay-product-verify.js +103 -0
- package/dist/pwa/public/app-direct-pay-readiness.js +38 -0
- package/dist/pwa/public/app-direct-pay-reconcile.js +19 -0
- package/dist/pwa/public/app-direct-pay-returns.js +56 -0
- package/dist/pwa/public/app-direct-pay-reveal.js +82 -0
- package/dist/pwa/public/app-direct-pay-sales-report.js +70 -0
- package/dist/pwa/public/app-direct-pay-store-verify.js +100 -0
- package/dist/pwa/public/app-direct-pay.js +226 -0
- package/dist/pwa/public/app-discover.js +20 -20
- package/dist/pwa/public/app-dispute-close-ui.js +38 -0
- package/dist/pwa/public/app-external-links.js +32 -0
- package/dist/pwa/public/app-free-shipping-ui.js +29 -0
- package/dist/pwa/public/app-gmv-rail-split.js +12 -0
- package/dist/pwa/public/app-listing-commerce-ui.js +72 -0
- package/dist/pwa/public/app-listings.js +4 -4
- package/dist/pwa/public/app-mutual-cancel.js +54 -0
- package/dist/pwa/public/app-notif-templates-orders.js +43 -0
- package/dist/pwa/public/app-notif-templates.js +22 -0
- package/dist/pwa/public/app-order-accept-ui.js +158 -0
- package/dist/pwa/public/app-order-errors.js +50 -0
- package/dist/pwa/public/app-order-labels.js +19 -0
- package/dist/pwa/public/app-order-rail-filter.js +26 -0
- package/dist/pwa/public/app-platform-receive-accounts.js +140 -0
- package/dist/pwa/public/app-poll-governor.js +22 -0
- package/dist/pwa/public/app-prelaunch-waz.js +39 -0
- package/dist/pwa/public/app-price.js +55 -0
- package/dist/pwa/public/app-product-media.js +15 -0
- package/dist/pwa/public/app-profile.js +10 -10
- package/dist/pwa/public/app-purchase-terms-ui.js +68 -0
- package/dist/pwa/public/app-sale-regions-ui.js +38 -0
- package/dist/pwa/public/app-seller.js +5 -5
- package/dist/pwa/public/app-shop.js +19 -19
- package/dist/pwa/public/app-trade-tax-ui.js +33 -0
- package/dist/pwa/public/app.js +264 -295
- package/dist/pwa/public/i18n.js +1068 -197
- package/dist/pwa/public/index.html +58 -0
- package/dist/pwa/public/openapi.json +1845 -653
- package/dist/pwa/public/style.css +3 -0
- package/dist/pwa/routes/admin-analytics.js +4 -2
- package/dist/pwa/routes/admin-direct-receive-deposits.js +533 -0
- package/dist/pwa/routes/admin-protocol-params.js +16 -0
- package/dist/pwa/routes/admin-reports.js +31 -5
- package/dist/pwa/routes/agent-governance.js +1 -1
- package/dist/pwa/routes/agent-grants.js +253 -32
- package/dist/pwa/routes/analytics.js +2 -0
- package/dist/pwa/routes/arbitrator.js +67 -14
- package/dist/pwa/routes/bond-seller.js +162 -0
- package/dist/pwa/routes/buyer-feeds.js +2 -1
- package/dist/pwa/routes/chat.js +6 -1
- package/dist/pwa/routes/dashboards.js +2 -1
- package/dist/pwa/routes/direct-pay-availability.js +196 -0
- package/dist/pwa/routes/direct-pay-cancel-refund.js +111 -0
- package/dist/pwa/routes/direct-pay-disclosure-acks.js +79 -0
- package/dist/pwa/routes/direct-pay-pending-accept.js +222 -0
- package/dist/pwa/routes/direct-pay-returns.js +74 -0
- package/dist/pwa/routes/direct-pay-timeouts.js +248 -0
- package/dist/pwa/routes/direct-receive-accounts.js +155 -0
- package/dist/pwa/routes/direct-receive-payment-instructions.js +45 -0
- package/dist/pwa/routes/disputes-read.js +20 -6
- package/dist/pwa/routes/disputes-write.js +91 -33
- package/dist/pwa/routes/external-anchors.js +4 -2
- package/dist/pwa/routes/fee-prepay-requests.js +66 -0
- package/dist/pwa/routes/fx.js +12 -0
- package/dist/pwa/routes/governance-onboarding.js +46 -8
- package/dist/pwa/routes/leaderboard.js +47 -9
- package/dist/pwa/routes/listings.js +2 -2
- package/dist/pwa/routes/logistics.js +6 -2
- package/dist/pwa/routes/manifests.js +38 -0
- package/dist/pwa/routes/me-data.js +5 -2
- package/dist/pwa/routes/mutual-cancel.js +62 -0
- package/dist/pwa/routes/orders-action.js +297 -16
- package/dist/pwa/routes/orders-create.js +21 -6
- package/dist/pwa/routes/orders-read.js +106 -8
- package/dist/pwa/routes/p2p-products.js +2 -2
- package/dist/pwa/routes/platform-receive-accounts.js +111 -0
- package/dist/pwa/routes/products-create.js +15 -4
- package/dist/pwa/routes/products-links.js +34 -0
- package/dist/pwa/routes/products-list.js +2 -1
- package/dist/pwa/routes/products-update.js +37 -3
- package/dist/pwa/routes/profile-identity.js +4 -2
- package/dist/pwa/routes/promoter.js +3 -0
- package/dist/pwa/routes/referral.js +4 -0
- package/dist/pwa/routes/returns.js +60 -4
- package/dist/pwa/routes/rewards-apply.js +10 -5
- package/dist/pwa/routes/rewards-clearing-mature.js +96 -0
- package/dist/pwa/routes/rewards-escrow-expire.js +6 -2
- package/dist/pwa/routes/seller-directpay-report.js +110 -0
- package/dist/pwa/routes/seller-quota.js +5 -1
- package/dist/pwa/routes/shipping-templates.js +219 -0
- package/dist/pwa/routes/shops.js +2 -1
- package/dist/pwa/routes/snf.js +4 -1
- package/dist/pwa/routes/url-claim.js +2 -2
- package/dist/pwa/routes/users-public.js +8 -0
- package/dist/pwa/routes/wallet-read.js +3 -0
- package/dist/pwa/routes/webauthn.js +3 -3
- package/dist/pwa/server.js +112 -132
- package/dist/runtime/agent-grant-scopes.js +69 -1
- package/dist/runtime/webaz-schema-helpers.js +161 -3
- package/dist/sale-regions.js +116 -0
- package/dist/shipping-templates.js +119 -0
- package/dist/store-verification.js +77 -0
- package/dist/trade-tax.js +99 -0
- package/dist/trade-terms.js +76 -0
- package/dist/version.js +1 -1
- package/package.json +128 -2
package/dist/pwa/acp-feed.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { displayCurrency } from '../currency.js'; // agent-facing 币种统一 WAZ,遗留 'DCP' 读时归一化(绝不外泄 DCP)
|
|
1
2
|
const BASE = 'https://webaz.xyz';
|
|
2
3
|
// ACP availability 枚举(spec):in_stock | out_of_stock | pre_order | backorder | unknown
|
|
3
4
|
function availability(stock) {
|
|
@@ -55,7 +56,7 @@ export function buildAcpProductFeed(db, opts = {}) {
|
|
|
55
56
|
description: plainText(p.description, 5000), // spec: plain text, max 5000
|
|
56
57
|
url: `${BASE}/#order-product/${p.id}`, // 商品详情页(SPA hash,200)
|
|
57
58
|
// price: spec = Number + ISO 4217 code。WAZ 非 ISO 4217 → 见 feed 级 _disclosures.currency
|
|
58
|
-
price: { amount: Number(p.price), currency: p.currency
|
|
59
|
+
price: { amount: Number(p.price), currency: displayCurrency(p.currency) },
|
|
59
60
|
availability: availability(p.stock),
|
|
60
61
|
is_digital: p.product_type === 'digital',
|
|
61
62
|
seller_name: (p.seller_name || p.seller_id).slice(0, 70),
|
|
@@ -106,7 +107,7 @@ export function buildAcpProductFeed(db, opts = {}) {
|
|
|
106
107
|
},
|
|
107
108
|
_disclosures: {
|
|
108
109
|
phase: 'pre-launch',
|
|
109
|
-
currency: "Each item's price.currency is the protocol's SIMULATED pre-launch internal unit (WAZ
|
|
110
|
+
currency: "Each item's price.currency is the protocol's SIMULATED pre-launch internal unit, always emitted as WAZ (legacy internal-code rows are normalized to WAZ on read), NOT an ISO 4217 fiat currency. No real money is involved.",
|
|
110
111
|
checkout: 'is_eligible_checkout is false for every item: ACP checkout is not yet wired. Products are DISCOVERABLE via ACP, not yet PURCHASABLE via ACP. Native WebAZ checkout + escrow + state machine govern real purchases (see RFC-015).',
|
|
111
112
|
},
|
|
112
113
|
product_count: products.length,
|
|
@@ -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
|
+
}
|
|
@@ -36,6 +36,24 @@ export const CONTRACT_CHANGES = [
|
|
|
36
36
|
{ contract_version: 3, date: '2026-06-09', surface: 'entity', kind: 'changed', summary: '§① order lifecycle: corrected declined_nofault state meaning text — it is NOT terminal (transitions declined_nofault→completed on settlement). Dropped the contradictory "(terminal)" label that conflicted with the auto-derived terminal:false. Semantics/state-machine unchanged; text-only clarification for agents reading the entity dictionary.' },
|
|
37
37
|
{ contract_version: 4, date: '2026-06-09', surface: 'capability', kind: 'changed', summary: '§② capability matrix: POST /api/reviews/:type/:id/claim now requires the new "review_claim" action scope instead of being SAFE (unscoped). The review-claim path locks a 5 WAZ stake (escrow), so it belongs under default-deny accountability like other value writes. GET reviews endpoints stay open.', migration: 'A declared agent that calls review claim must add the "review_claim" scope to its api_key (or hold a Passkey, or declare "*"). Passkey-bound humans and "*" agents are unaffected; GET reviews unchanged.' },
|
|
38
38
|
{ contract_version: 5, date: '2026-06-24', surface: 'integration', kind: 'added', summary: '§④ integration-contract entry (/.well-known/webaz-integration.json) gains an agent_quickstart block: a 60-second stranger-agent cold-start with discrete, machine-parseable fields (canonical_start_url, public_readonly_entrypoints, anonymous_allowed_actions, authenticated_required_actions, safe_next_actions, proposal_flow, contribution_boundary). Additive — no existing field changed; agents may ignore. NB: this surface is NOT covered by the §②/§① fingerprint, so it is registered here by hand.' },
|
|
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
|
+
{ 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
|
+
{ 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).' },
|
|
39
57
|
];
|
|
40
58
|
export function buildChangeFeed() {
|
|
41
59
|
return {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 直付 / 直接收款 RISK 门:
|
|
3
|
+
* ① 无 Passkey credential → 硬拒(agent 路径:agent 不可能持有 Passkey assertion → 永远过不了)。
|
|
4
|
+
* ② 需一次性真人 WebAuthn gate token(purpose-bound, 60s)。
|
|
5
|
+
* 两步都过才返回 ok。无自助批准路径。
|
|
6
|
+
*/
|
|
7
|
+
export function requireDirectPayHumanPasskey(deps, args) {
|
|
8
|
+
const { db, consumeGateToken } = deps;
|
|
9
|
+
const { userId, webauthnToken, purpose, validate } = args;
|
|
10
|
+
// ① agent 硬拒:没有 Passkey 凭证的身份(agent)绝不能发起/批准直付
|
|
11
|
+
const hasPk = db.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?').get(userId).n > 0;
|
|
12
|
+
if (!hasPk) {
|
|
13
|
+
return { ok: false, error_code: 'PASSKEY_REQUIRED_FOR_DIRECT_PAY', reason: '直付/直接收款是 RISK 操作:需绑定 Passkey 并真人二次确认,agent 不可代操作' };
|
|
14
|
+
}
|
|
15
|
+
// ② 一次性 purpose-bound 真人 WebAuthn gate token
|
|
16
|
+
const gate = consumeGateToken(userId, webauthnToken, purpose, validate ?? (() => true));
|
|
17
|
+
if (!gate.ok)
|
|
18
|
+
return { ok: false, error_code: 'HUMAN_PRESENCE_REQUIRED', reason: gate.reason || '需真实人工 WebAuthn 验证' };
|
|
19
|
+
return { ok: true };
|
|
20
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { requireBothDisclosuresAcked } from '../direct-pay-disclosures.js';
|
|
2
|
+
/** 买家自视角:未 both-acked 的 direct_p2p 订单,删收款目标(instruction 原文 + 账号快照 qr_ref)。就地改 o。
|
|
3
|
+
* 手动接单(v16):pending_accept 阶段【状态门】无条件遮蔽 —— 卖家还没确认能发货,买家不该拿到任何收款信息
|
|
4
|
+
* (哪怕已 ack 披露;时序门=非托管唯一付款风控,接单后才起表付款窗)。 */
|
|
5
|
+
export function redactUnackedDirectPayTarget(db, o, userId) {
|
|
6
|
+
if (o.payment_rail !== 'direct_p2p' || o.buyer_id !== userId)
|
|
7
|
+
return;
|
|
8
|
+
if (o.status !== 'pending_accept' && requireBothDisclosuresAcked(db, o.id).ok)
|
|
9
|
+
return;
|
|
10
|
+
delete o.direct_pay_instruction_snapshot;
|
|
11
|
+
if (o.direct_pay_account_snapshot != null) {
|
|
12
|
+
try {
|
|
13
|
+
const s = JSON.parse(o.direct_pay_account_snapshot);
|
|
14
|
+
delete s.qr_ref;
|
|
15
|
+
o.direct_pay_account_snapshot = JSON.stringify(s);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
delete o.direct_pay_account_snapshot;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** 无条件删除收款目标(instruction 快照 + 整个账号快照)。用于非买家第三方 reader,他们绝不该看到收款目标。就地改 o。 */
|
|
23
|
+
export function stripDirectPayPaymentTarget(o) {
|
|
24
|
+
delete o.direct_pay_instruction_snapshot;
|
|
25
|
+
delete o.direct_pay_account_snapshot;
|
|
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
|
+
}
|
|
@@ -31,6 +31,9 @@ export const WRITE_RULES = [
|
|
|
31
31
|
{ method: 'POST', re: /^\/api\/skill-market\/[^/]+\/purchase/, action: 'purchase' },
|
|
32
32
|
{ method: 'POST', re: /^\/api\/secondhand\/[^/]+\/order/, action: 'buy_secondhand' },
|
|
33
33
|
{ method: 'POST', re: /^\/api\/group-buys\/[^/]+\/join/, action: 'group_buy_join' },
|
|
34
|
+
// Direct Pay (Rail 1) = RISK scope:为【未来】/api/direct-pay/* 写面【保留】'direct_pay' 分类(该面尚无路由)。
|
|
35
|
+
// ⚠️ SCAFFOLD:Passkey/两次披露门当前是 helper,【尚未】接到任何 handler;真实 enforcement 随 create-route/UI/ack 端点在后续 PR 落地。
|
|
36
|
+
{ method: 'WRITE', re: /^\/api\/direct-pay\//, action: 'direct_pay' },
|
|
34
37
|
// Codex #98 P1:review claim 锁 5 WAZ stake(扣 balance + escrowed)—— 资金写,绝不能落 SAFE,纳入 default-deny 问责门。
|
|
35
38
|
// 只命中 .../:type/:id/claim;其余 reviews 写无规则 → 落通用 'write'(仍 default-deny),GET reviews 由 endpointToAction(GET) 返回 null。
|
|
36
39
|
{ method: 'POST', re: /^\/api\/reviews\/[^/]+\/[^/]+\/claim$/, action: 'review_claim' },
|
|
@@ -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
|
|
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
|
-
//
|
|
139
|
-
const visibleRoles =
|
|
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() {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// admin 争议查看/管理 —— 可【区分】(状态 / 直付-托管 / 进度 / 是否已指派 / 如何结案 / 紧急度)+ 可【管理】(状态过滤 + 钻取查看)。
|
|
2
|
+
// admin 只读监督(后端 disputes-read 允许 admin 查详情);裁定/驳回等【动作】仍须 active 白名单仲裁员(COI+指派)。中文 t(),英文 i18n.js。
|
|
3
|
+
window.renderAdminDisputes = async function (app, opts = {}) {
|
|
4
|
+
if (!state.user) { renderLogin(); return }
|
|
5
|
+
if (!isAdmin()) { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限管理员')}</div>`, 'admin'); return }
|
|
6
|
+
app.innerHTML = shell(loading$(), 'admin')
|
|
7
|
+
const status = opts.status || ''
|
|
8
|
+
const data = await GET('/admin/disputes' + (status ? `?status=${encodeURIComponent(status)}` : ''))
|
|
9
|
+
if (data.error) { app.innerHTML = shell(alert$('error', data.error), 'admin'); return }
|
|
10
|
+
const c = data.counts || {}
|
|
11
|
+
const STATUS_META = { open: ['#fef3c7', '#92400e', t('待响应')], in_review: ['#fde68a', '#b45309', t('仲裁中')], resolved: ['#dcfce7', '#166534', t('已裁定')], dismissed: ['#e5e7eb', '#374151', t('已驳回')] }
|
|
12
|
+
const tabs = [['', t('全部'), data.total || 0], ['open', t('待响应'), c.open || 0], ['in_review', t('仲裁中'), c.in_review || 0], ['resolved', t('已裁定'), c.resolved || 0], ['dismissed', t('已驳回'), c.dismissed || 0]]
|
|
13
|
+
const tabHtml = tabs.map(([val, label, n]) => `<button class="btn btn-sm" style="width:auto;font-size:12px;background:${status === val ? '#4f46e5' : '#fff'};color:${status === val ? '#fff' : '#374151'};border:1px solid ${status === val ? '#4f46e5' : '#e5e7eb'}" onclick="renderAdminDisputes(document.getElementById('app'),{status:'${val}'})">${label} ${n}</button>`).join('')
|
|
14
|
+
const railBadge = (rail) => rail === 'direct_p2p'
|
|
15
|
+
? `<span style="font-size:10px;background:#eff6ff;color:#1e40af;padding:1px 7px;border-radius:99px">${t('直付')}</span>`
|
|
16
|
+
: `<span style="font-size:10px;background:#f0fdf4;color:#166534;padding:1px 7px;border-radius:99px">${t('托管')}</span>`
|
|
17
|
+
const urgencyChip = (d) => {
|
|
18
|
+
if (d.status !== 'open' && d.status !== 'in_review') return ''
|
|
19
|
+
const dl = d.status === 'open' ? d.respond_deadline : d.arbitrate_deadline
|
|
20
|
+
if (!dl) return ''
|
|
21
|
+
const overdue = new Date(dl) < new Date()
|
|
22
|
+
return `<span style="font-size:10px;background:${overdue ? '#fee2e2' : '#fff7ed'};color:${overdue ? '#991b1b' : '#9a3412'};padding:1px 7px;border-radius:99px">${overdue ? '⏰ ' + t('已超时') : '⏳ ' + fmtTime(dl)}</span>`
|
|
23
|
+
}
|
|
24
|
+
const assignChip = (d) => {
|
|
25
|
+
let n = 0; try { n = (JSON.parse(d.assigned_arbitrators || '[]') || []).length } catch {}
|
|
26
|
+
return `<span style="font-size:10px;background:${n ? '#ede9fe' : '#f3f4f6'};color:${n ? '#5b21b6' : '#9ca3af'};padding:1px 7px;border-radius:99px">${n ? '⚖ ' + t('已指派') : t('未指派')}</span>`
|
|
27
|
+
}
|
|
28
|
+
const verdictChip = (d) => (d.status === 'resolved' || d.status === 'dismissed') && d.ruling_type
|
|
29
|
+
? `<span style="font-size:10px;background:#f5f3ff;color:#6d28d9;padding:1px 7px;border-radius:99px">${(window.dpRulingLabel && window.dpRulingLabel(d.ruling_type)) || t(d.ruling_type)}</span>` : ''
|
|
30
|
+
const list = (data.disputes || []).map(d => {
|
|
31
|
+
const [bg, fg, lbl] = STATUS_META[d.status] || ['#e5e7eb', '#374151', d.status]
|
|
32
|
+
return `<div class="card" style="margin-bottom:10px;font-size:13px;cursor:pointer" onclick="navigate('#dispute/${escHtml(d.id)}')">
|
|
33
|
+
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
|
|
34
|
+
<div style="flex:1;min-width:0">
|
|
35
|
+
<div style="font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(d.product_title || '—')}</div>
|
|
36
|
+
<div style="font-size:12px;color:#6b7280;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml((d.reason || '').slice(0, 60))}</div>
|
|
37
|
+
<div style="font-size:11px;color:#6b7280;margin-top:6px">${t('原告')}: ${escHtml(d.initiator_name || '—')} → ${t('被告')}: ${escHtml(d.defendant_name || '—')}</div>
|
|
38
|
+
<div style="display:flex;gap:5px;flex-wrap:wrap;margin-top:6px">${railBadge(d.payment_rail)}${assignChip(d)}${urgencyChip(d)}${verdictChip(d)}</div>
|
|
39
|
+
<div style="font-size:11px;color:#9ca3af;margin-top:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(d.id)} · ${fmtTime(d.created_at)}</div>
|
|
40
|
+
</div>
|
|
41
|
+
<div style="display:flex;flex-direction:column;gap:6px;align-items:flex-end;flex-shrink:0">
|
|
42
|
+
<span style="background:${bg};color:${fg};padding:2px 8px;border-radius:8px;font-size:11px;white-space:nowrap">${lbl}</span>
|
|
43
|
+
${d.total_amount ? `<div style="font-size:12px;color:#374151;white-space:nowrap">${d.total_amount} WAZ</div>` : ''}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>`
|
|
47
|
+
}).join('') || `<div class="empty"><div class="empty-icon">⚖️</div><div class="empty-text">${t('暂无争议')}</div></div>`
|
|
48
|
+
app.innerHTML = shell(`
|
|
49
|
+
<h1 class="page-title">⚖️ ${t('争议查看')}</h1>
|
|
50
|
+
<div style="margin-bottom:12px"><button class="btn btn-outline btn-sm" style="width:auto" onclick="navigate('#admin')">${t('返回概览')}</button></div>
|
|
51
|
+
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px">${tabHtml}</div>
|
|
52
|
+
<div style="font-size:11px;color:#9ca3af;margin-bottom:8px">${t('点卡片查看争议详情(只读监督;裁定须仲裁员)')}</div>
|
|
53
|
+
${list}
|
|
54
|
+
`, 'admin')
|
|
55
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// 被封用户申诉页 + admin strike 审批页(Tina 案补齐 UI)。UI ONLY —— 申诉/裁决边界全在后端;
|
|
2
|
+
// 被封状态下只调两个封禁豁免端点(GET /api/me/agents、POST .../strikes/:id/appeal)。
|
|
3
|
+
;(function () {
|
|
4
|
+
const SEV = () => ({ warning: t('警告'), suspend_7d: t('暂停 7 天'), permanent: t('永久封禁') })
|
|
5
|
+
const APL = () => ({ none: t('未申诉'), pending: t('申诉审核中'), approved: t('申诉通过(已解封)'), denied: t('申诉被驳回') })
|
|
6
|
+
|
|
7
|
+
// ── 被封用户:极简申诉页(登录检测到 AGENT_BLOCKED 时进入;key 已在 state.apiKey)──
|
|
8
|
+
window.renderAgentBlockedAppeal = async (blockMsg) => {
|
|
9
|
+
const app = document.getElementById('app')
|
|
10
|
+
app.innerHTML = `<div style="max-width:560px;margin:40px auto;padding:0 16px">
|
|
11
|
+
<h1 style="font-size:18px;font-weight:700;margin-bottom:8px">🚫 ${t('账号处于暂停期')}</h1>
|
|
12
|
+
<div class="alert alert-error" style="margin-bottom:12px">${escHtml(blockMsg || '')}</div>
|
|
13
|
+
<div id="appeal-box">${loading$()}</div></div>`
|
|
14
|
+
const r = await GET('/me/agents').catch(() => null)
|
|
15
|
+
const box = document.getElementById('appeal-box')
|
|
16
|
+
if (!r || !Array.isArray(r.items)) { box.innerHTML = `<div class="alert alert-error">${t('加载失败,请重试(确认 key 完整)')}</div>`; return }
|
|
17
|
+
const strikes = r.items.flatMap(a => (a.recent_strikes || []))
|
|
18
|
+
const active = strikes.find(s => ['suspend_7d', 'permanent'].includes(s.severity) && s.appeal_status !== 'approved')
|
|
19
|
+
if (!active) { box.innerHTML = `<div class="alert alert-info">${t('未发现生效中的封禁,请重新登录试试')}</div>`; return }
|
|
20
|
+
const S = SEV(); const A = APL()
|
|
21
|
+
box.innerHTML = `
|
|
22
|
+
<div class="card" style="font-size:13px">
|
|
23
|
+
<div><strong>${S[active.severity] || active.severity}</strong> · ${escHtml(active.reason_code)}${active.reason_detail ? `(${escHtml(active.reason_detail)})` : ''}</div>
|
|
24
|
+
<div style="color:#6b7280;margin-top:4px">${t('签发')}:${fmtTime(active.issued_at)}${active.expires_at ? ` · ${t('到期自动解除')}:${fmtTime(active.expires_at)}` : ''}</div>
|
|
25
|
+
<div style="margin-top:4px">${t('申诉状态')}:<strong>${A[active.appeal_status] || active.appeal_status}</strong></div>
|
|
26
|
+
</div>
|
|
27
|
+
${active.appeal_status === 'none' ? `
|
|
28
|
+
<textarea class="form-control" id="appeal-reason" rows="3" maxlength="500" placeholder="${t('申诉理由(≥10 字;说明误触发原因,如页面挂机轮询/误操作)')}" style="margin:10px 0;font-size:13px"></textarea>
|
|
29
|
+
<button class="btn btn-primary" style="width:100%" onclick="agentAppealSubmit(${active.id})">${t('提交申诉(等待管理员审核)')}</button>`
|
|
30
|
+
: active.appeal_status === 'pending' ? `<div class="alert alert-info" style="margin-top:10px">${t('申诉已提交,等待管理员审核;审核通过后重新登录即可。')}</div>`
|
|
31
|
+
: active.appeal_status === 'denied' ? `<div class="alert alert-error" style="margin-top:10px">${t('申诉被驳回;封禁将按期自动解除。')}</div>` : ''}
|
|
32
|
+
<button class="btn btn-outline btn-sm" style="width:100%;margin-top:10px" onclick="state.apiKey=null;location.reload()">${t('退出(换个账号登录)')}</button>`
|
|
33
|
+
}
|
|
34
|
+
window.agentAppealSubmit = async (strikeId) => {
|
|
35
|
+
const reason = (document.getElementById('appeal-reason')?.value || '').trim()
|
|
36
|
+
if (reason.length < 10) return void toast$(t('申诉理由 ≥10 字'), 'error')
|
|
37
|
+
const r = await POST(`/me/agents/strikes/${strikeId}/appeal`, { reason })
|
|
38
|
+
if (r.error) return void toast$(r.error, 'error')
|
|
39
|
+
toast$(t('申诉已提交'), 'success'); window.renderAgentBlockedAppeal('')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── admin:#admin/agent-strikes 待审队列 + 裁决 + 主动 issue ──
|
|
43
|
+
window.renderAdminAgentStrikes = function (app) {
|
|
44
|
+
if (!state.user) { renderLogin(); return }
|
|
45
|
+
if (typeof isAdmin === 'function' && !isAdmin()) { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限管理员')}</div>`, 'admin'); return }
|
|
46
|
+
if ((state.user.admin_type || 'root') !== 'root') { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限根管理员')}</div>`, 'admin'); return }
|
|
47
|
+
app.innerHTML = shell(`
|
|
48
|
+
<h1 class="page-title">🚦 ${t('Agent 封禁与申诉')}</h1>
|
|
49
|
+
<div style="font-size:12px;color:#6b7280;margin-bottom:10px">${t('申诉批准=立即解封(60 秒缓存内生效);驳回=按期自动解除。真人(绑 Passkey)已豁免速率封禁,此页主要处理 agent 与历史误封。')}</div>
|
|
50
|
+
<div id="strike-adm-box">${loading$()}</div>
|
|
51
|
+
<h2 style="font-size:14px;font-weight:700;margin:16px 0 8px">${t('主动签发 strike(慎用)')}</h2>
|
|
52
|
+
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px">
|
|
53
|
+
<input class="form-control" id="stk-key" placeholder="api_key" style="flex:2;min-width:160px;font-size:12px">
|
|
54
|
+
<input class="form-control" id="stk-reason" placeholder="${t('reason_code(如 spam / fake_shipment)')}" style="flex:1;min-width:120px;font-size:12px">
|
|
55
|
+
<select class="form-control" id="stk-sev" style="width:auto;font-size:12px"><option value="warning">warning</option><option value="suspend_7d">suspend_7d</option><option value="permanent">permanent</option></select>
|
|
56
|
+
<button class="btn btn-outline btn-sm" style="width:auto;font-size:11px" onclick="adminStrikeIssue()">${t('签发')}</button>
|
|
57
|
+
</div>`, 'admin')
|
|
58
|
+
window.adminStrikesHydrate()
|
|
59
|
+
}
|
|
60
|
+
window.adminStrikesHydrate = async () => {
|
|
61
|
+
const box = document.getElementById('strike-adm-box'); if (!box) return
|
|
62
|
+
const r = await GET('/admin/agent-strikes/pending').catch(() => null)
|
|
63
|
+
const items = (r && r.items) || []
|
|
64
|
+
const S = SEV()
|
|
65
|
+
box.innerHTML = items.length === 0 ? `<div class="alert alert-info">${t('暂无待审申诉')}</div>` : items.map(i => `
|
|
66
|
+
<div class="card" style="font-size:12px">
|
|
67
|
+
<div style="display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap">
|
|
68
|
+
<div><strong>@${escHtml(i.handle || i.user_id)}</strong> · ${S[i.severity] || i.severity} · ${escHtml(i.reason_code)}</div>
|
|
69
|
+
<div style="color:#6b7280">${fmtTime(i.issued_at)}</div>
|
|
70
|
+
</div>
|
|
71
|
+
${i.reason_detail ? `<div style="color:#6b7280;margin-top:4px">${t('签发详情')}:${escHtml(i.reason_detail)}</div>` : ''}
|
|
72
|
+
<div style="margin-top:6px;padding:8px;background:#f9fafb;border-radius:6px">💬 ${t('申诉理由')}:${escHtml(i.appeal_reason || '-')}</div>
|
|
73
|
+
<div style="display:flex;gap:8px;margin-top:8px">
|
|
74
|
+
<button class="btn btn-primary btn-sm" style="width:auto;font-size:11px" onclick="adminStrikeDecide(${i.id},'approved')">${t('批准(立即解封)')}</button>
|
|
75
|
+
<button class="btn btn-outline btn-sm" style="width:auto;font-size:11px;color:#dc2626;border-color:#fecaca" onclick="adminStrikeDecide(${i.id},'denied')">${t('驳回')}</button>
|
|
76
|
+
</div>
|
|
77
|
+
</div>`).join('')
|
|
78
|
+
}
|
|
79
|
+
window.adminStrikeDecide = async (id, decision) => {
|
|
80
|
+
const r = await POST(`/admin/agent-strikes/${id}/decide`, { decision })
|
|
81
|
+
if (r.error) return void toast$(r.error, 'error')
|
|
82
|
+
toast$(decision === 'approved' ? t('已批准,60 秒内解封') : t('已驳回'), 'success'); window.adminStrikesHydrate()
|
|
83
|
+
}
|
|
84
|
+
window.adminStrikeIssue = async () => {
|
|
85
|
+
if (typeof confirmModal === 'function' && !(await confirmModal(t('确认签发 strike?suspend_7d/permanent 将立即阻断该 key 的所有访问。'), t('签发'), { danger: true }))) return
|
|
86
|
+
const r = await POST('/admin/agent-strikes/issue', { api_key: (document.getElementById('stk-key')?.value || '').trim(), reason_code: (document.getElementById('stk-reason')?.value || '').trim(), severity: document.getElementById('stk-sev')?.value })
|
|
87
|
+
if (r.error) return void toast$(r.error, 'error')
|
|
88
|
+
toast$(t('已签发'), 'success')
|
|
89
|
+
}
|
|
90
|
+
})()
|