@seasonkoh/webaz 0.1.30 → 0.1.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/layer0-foundation/L0-2-state-machine/engine.js +1 -1
- package/dist/layer1-agent/L1-1-mcp-server/network-mode.js +4 -0
- package/dist/layer1-agent/L1-1-mcp-server/server.js +202 -19
- package/dist/layer2-business/L2-6-notifications/notification-engine.js +96 -0
- package/dist/layer3-trust/L3-1-dispute-engine/decline-contest-resolve.js +110 -0
- package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +109 -0
- package/dist/pwa/agent-order-minimal-view.js +29 -0
- package/dist/pwa/order-action-exec.js +158 -0
- package/dist/pwa/order-action-request.js +57 -0
- package/dist/pwa/public/app-admin-disputes.js +1 -1
- package/dist/pwa/public/app-agent-approvals-order.js +18 -0
- package/dist/pwa/public/app-agent-approvals.js +6 -7
- package/dist/pwa/public/app-agent-pair.js +2 -2
- package/dist/pwa/public/app-arbitrator-admin.js +1 -1
- package/dist/pwa/public/app-decline-contest-ruling.js +53 -0
- package/dist/pwa/public/app-decline-contest-ui.js +44 -0
- package/dist/pwa/public/app-grant-duration.js +20 -0
- package/dist/pwa/public/app.js +9 -9
- package/dist/pwa/public/i18n.js +24 -1
- package/dist/pwa/public/index.html +4 -0
- package/dist/pwa/public/openapi.json +78 -23
- package/dist/pwa/routes/admin-reports.js +40 -3
- package/dist/pwa/routes/agent-grants.js +147 -14
- package/dist/pwa/routes/disputes-read.js +17 -1
- package/dist/pwa/routes/disputes-write.js +42 -73
- package/dist/pwa/routes/orders-action.js +46 -3
- package/dist/pwa/routes/products-create.js +33 -13
- package/dist/pwa/routes/webauthn.js +1 -1
- package/dist/pwa/server.js +7 -7
- package/dist/runtime/agent-grant-scopes.js +26 -8
- package/dist/runtime/webaz-schema-helpers.js +28 -0
- package/package.json +15 -2
package/README.md
CHANGED
|
@@ -47,9 +47,11 @@ Open the PWA, register a local account, and walk a full order → ship → confi
|
|
|
47
47
|
WebAZ ships an **MCP server**. MCP is an open standard, so it works with **any MCP-capable client** — Claude Desktop, Claude Code, Codex, Cursor, or your own agent. Add the server to your client's MCP config:
|
|
48
48
|
|
|
49
49
|
```json
|
|
50
|
-
{ "mcpServers": { "webaz": { "command": "npx", "args": ["-y", "@seasonkoh/webaz"] } } }
|
|
50
|
+
{ "mcpServers": { "webaz": { "command": "npx", "args": ["-y", "@seasonkoh/webaz@latest"] } } }
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
+
> **Updating:** MCP clients don't auto-update servers, and `npx` caches by version — so a client restart alone may keep running an older cached build. To pick up a new release: clear the cache (`rm -rf ~/.npm/_npx`) and restart the client. To pin a specific version instead of tracking latest, use `@seasonkoh/webaz@0.1.32`.
|
|
54
|
+
|
|
53
55
|
- 🟢 **Network read-only (default, zero-config):** with no `WEBAZ_API_KEY`, public reads (`webaz_search` / leaderboard / price history / browse) hit the **live shared network** at [webaz.xyz](https://webaz.xyz) — nothing local. Transactional tools (register / order / list / fulfill) need a key.
|
|
54
56
|
- 🟢 **Network (full):** register at [webaz.xyz](https://webaz.xyz) (invite + Passkey = an accountable human), copy your `api_key`, and set it as `env.WEBAZ_API_KEY` → the agent can transact on the **live shared network**. (`webaz_register` never self-creates an account — accounts require a real human + Passkey, by protocol.)
|
|
55
57
|
- 🟡 **Sandbox (explicit, dev/demo):** set `WEBAZ_MODE=sandbox` → a **private local SQLite playground, isolated from the live network** — safe to try the whole order flow offline. (Opt-in; the default with no key is Network read-only, not sandbox.)
|
|
@@ -473,7 +473,7 @@ function findActiveDeadlineTransition(order, now) {
|
|
|
473
473
|
return null;
|
|
474
474
|
}
|
|
475
475
|
/** 获取当前有效的截止时间 */
|
|
476
|
-
function getActiveDeadline(order, db) {
|
|
476
|
+
export function getActiveDeadline(order, db) {
|
|
477
477
|
// QA 轮 7 P1:旧表 picked_up 状态没 deadline → agent 不知道下一步多久前要做完
|
|
478
478
|
// 修:picked_up 状态视为"已揽收,等运输/投递",下一个 deadline 是 delivery_deadline
|
|
479
479
|
// QA 轮 7 P1(另一条):disputed 状态下没读 dispute_cases 的 arbitrate_deadline → agent 不知道仲裁还有多久
|
|
@@ -34,6 +34,10 @@ export const NETWORK_TOOLS = new Set([
|
|
|
34
34
|
'webaz_get_status',
|
|
35
35
|
'webaz_feedback',
|
|
36
36
|
'webaz_contribute',
|
|
37
|
+
// RFC-021 fulfillment-agent grant-wired order tools (read minimal + submit action-request).
|
|
38
|
+
// Grant-only (no api_key path); grants live on webaz.xyz so they must be network-reachable.
|
|
39
|
+
'webaz_get_agent_order',
|
|
40
|
+
'webaz_order_action_request',
|
|
37
41
|
// Batch 1(只读 + 低危自身写):走 webaz.xyz Bearer api_key。
|
|
38
42
|
'webaz_notifications',
|
|
39
43
|
'webaz_nearby',
|
|
@@ -258,6 +258,7 @@ export async function handlePair(args) {
|
|
|
258
258
|
capabilities: caps,
|
|
259
259
|
agent_label: typeof args.agent_label === 'string' ? args.agent_label : undefined,
|
|
260
260
|
reason: typeof args.reason === 'string' ? args.reason : undefined, // free-text reason only
|
|
261
|
+
duration: typeof args.duration === 'string' ? args.duration : undefined, // SUGGESTED grant lifetime; the human picks/overrides at approve
|
|
261
262
|
},
|
|
262
263
|
});
|
|
263
264
|
if (resp.error)
|
|
@@ -269,7 +270,9 @@ export async function handlePair(args) {
|
|
|
269
270
|
user_code: resp.user_code,
|
|
270
271
|
approve_url: `${WEBAZ_API_URL}${String(resp.approve_url || '')}`,
|
|
271
272
|
expires_at: resp.expires_at,
|
|
272
|
-
|
|
273
|
+
suggested_duration: resp.suggested_duration,
|
|
274
|
+
allowed_durations: resp.allowed_durations,
|
|
275
|
+
next: `Ask the human to open approve_url (logged in at webaz.xyz) and approve — they choose the grant duration (your suggested_duration is pre-selected; allowed: ${Array.isArray(resp.allowed_durations) ? resp.allowed_durations.join('/') : 'once/1h/24h/7d/30d'}). Then call webaz_pair again with action="complete".`,
|
|
273
276
|
capabilities_requested: caps.map(c => c.capability),
|
|
274
277
|
note: 'Safe scopes only. The credential is delivered once on completion and stored in your OS secret store — it is never shown here.',
|
|
275
278
|
};
|
|
@@ -308,22 +311,21 @@ export async function handlePair(args) {
|
|
|
308
311
|
};
|
|
309
312
|
}
|
|
310
313
|
if (action === 'verify') {
|
|
311
|
-
//
|
|
312
|
-
// it re-checks active/expiry/revoked/subject-suspension
|
|
313
|
-
// bearer from the secret store and attach it; the raw token is never printed.
|
|
314
|
-
// business tool and no risk scope is wired to grants here.
|
|
314
|
+
// Ask the server for the FULL grant (all authorized scopes/bundle/expiry/status), not just one capability.
|
|
315
|
+
// The SERVER is authoritative: it re-checks active/expiry/revoked/subject-suspension + audits on EVERY call.
|
|
316
|
+
// We resolve the bearer from the secret store and attach it; the raw token is never printed.
|
|
315
317
|
const cred = resolveGrantCredential();
|
|
316
318
|
if (!cred)
|
|
317
319
|
return { status: 'not_paired', error_code: 'NO_GRANT_CREDENTIAL', hint: 'No stored grant. Run webaz_pair action="start", have the human approve, then action="complete".' };
|
|
318
|
-
const resp = await apiCall('/api/agent-grants/
|
|
320
|
+
const resp = await apiCall('/api/agent-grants/verify', { method: 'GET', apiKey: cred.token });
|
|
319
321
|
if (resp.error) {
|
|
320
322
|
return { status: 'grant_invalid', grant_id: cred.grant_id, error: resp.error, error_code: resp.error_code, hint: 'Grant is no longer valid (revoked / expired / suspended). Re-pair with webaz_pair action="start".' };
|
|
321
323
|
}
|
|
322
324
|
return {
|
|
323
325
|
status: 'active',
|
|
324
|
-
grant: resp.grant, // SERVER-AUTHORITATIVE principal
|
|
326
|
+
grant: resp.grant, // SERVER-AUTHORITATIVE principal: grant_id/human_id/agent_label + ALL scopes[]/permission_bundle/expiry/status
|
|
325
327
|
local_cache: { capabilities: cred.capabilities, expires_at: cred.expires_at }, // advisory only, may be stale — NOT authoritative
|
|
326
|
-
note: 'Grant verified LIVE by the server (per-call: active/expiry/revoked/subject-suspension
|
|
328
|
+
note: 'Grant verified LIVE by the server (per-call: active/expiry/revoked/subject-suspension + audited). `grant.scopes` is the authoritative full scope list; `local_cache` is advisory. Safe scopes only.',
|
|
327
329
|
};
|
|
328
330
|
}
|
|
329
331
|
if (action === 'request') {
|
|
@@ -549,7 +551,7 @@ NOTE: this consumes the grant only on safe read paths; no business tool and no r
|
|
|
549
551
|
capabilities: { type: 'array', items: { type: 'string' }, description: 'action="start": requested SAFE scopes (default: read_public, search). Non-safe scopes are rejected.' },
|
|
550
552
|
bundle: { type: 'string', description: 'action="request": a permission bundle key (e.g. "catalog_agent") — a named all-safe scope set the human approves as one thing.' },
|
|
551
553
|
scopes: { type: 'array', items: { type: 'string' }, description: 'action="request": individual SAFE scopes to request (alternative to bundle).' },
|
|
552
|
-
duration: { type: 'string', enum: ['
|
|
554
|
+
duration: { type: 'string', enum: ['1h', '24h', '7d', '30d'], description: 'SUGGESTED grant lifetime for action="start" (initial pairing) or action="request" (expansion). Safe scopes may be long-term (up to 30d); the human PICKS/overrides it at approve time and their choice wins. (No single-use "once" yet.)' },
|
|
553
555
|
agent_label: { type: 'string', description: 'Human-friendly name for this agent (shown in the consent screen)' },
|
|
554
556
|
reason: { type: 'string', description: 'Free-text reason shown to the human (you cannot relabel the scopes)' },
|
|
555
557
|
},
|
|
@@ -654,7 +656,7 @@ Skipping is allowed but agent then carries price/stock-race risk itself.`,
|
|
|
654
656
|
// was ~1336 chars, now ~650 chars
|
|
655
657
|
description: `⚠️ **"list" = PUBLISH** (verb), NOT "list out". Seller-only catalog publish + manage.
|
|
656
658
|
|
|
657
|
-
USE THIS when seller wants to publish / update / delist / relist / trash / delete own product, OR view own listings (action=mine). NOT for browsing marketplace — use webaz_search (anyone, no auth).
|
|
659
|
+
USE THIS when seller wants to publish / update / delist / relist / trash / delete own product, OR view own listings (action=mine). NOT for browsing marketplace — use webaz_search (anyone, no auth). A paired delegation grant (webaz_pair — Catalog Agent) can **read** (action=mine, seller_products_read) and **create DRAFTS** (action=create, seller_product_draft → the product lands in your warehouse as status=warehouse, NOT public); **publishing (going active), updating, delisting and deleting stay human/api_key only** — a grant can never publish. A grant that lacks the scope gets a structured PERMISSION_REQUIRED (request it via webaz_pair action="request", then retry). On create: system auto-suggests stake ~15% of price (buyer protection).
|
|
658
660
|
|
|
659
661
|
Fill agent_summary fields completely (brand/return/handling/warranty) — helps buyer agents compare. For "exclusive-price vs external-link" listing → PWA Web only (link claim needs crowd-verify).
|
|
660
662
|
|
|
@@ -671,7 +673,7 @@ Actions: create (title/description/price) | mine | update (product_id + changed
|
|
|
671
673
|
product_id: { type: 'string', description: 'Product ID (required for update/delist/relist/trash/delete)' },
|
|
672
674
|
title: { type: 'string', description: 'Product name (required for create; optional for update)' },
|
|
673
675
|
description: { type: 'string', description: 'Product description (required for create; optional for update)' },
|
|
674
|
-
price: { type: 'number', description: '
|
|
676
|
+
price: { type: 'number', description: 'Listing amount = the protocol/base amount (waz_usdc_rate 1.0). No currency is set at listing time. On the direct_p2p rail the actual payable DISPLAY follows the seller\'s declared receiving-account currency/instruction — USDC only when that account is USDC/USD, otherwise the seller\'s currency ("以收款说明为准"); WebAZ does not custody, route, validate, or restrict the payment method/currency. USDC is only the base/reference amount when applicable. Legacy escrow uses WAZ. Required for create; optional for update.' },
|
|
675
677
|
stock: { type: 'number', description: 'Stock, default 1' },
|
|
676
678
|
category: { type: 'string', description: 'Category (optional)' },
|
|
677
679
|
specs: {
|
|
@@ -724,9 +726,14 @@ Actions: create (title/description/price) | mine | update (product_id + changed
|
|
|
724
726
|
{
|
|
725
727
|
name: 'webaz_place_order',
|
|
726
728
|
// was ~1117 chars, now ~580 chars
|
|
727
|
-
description: `Buyer places order. Buyer api_key required.
|
|
729
|
+
description: `Buyer places order. Buyer api_key required.
|
|
730
|
+
|
|
731
|
+
Payment rail (chosen here, at purchase — default: escrow):
|
|
732
|
+
- **escrow** (default, legacy): funds auto-enter protocol escrow; the amount is the WAZ/protocol-unit price. The deadlines/auto-judge below apply.
|
|
733
|
+
- **direct_p2p** (live, non-custodial direct pay): buyer pays the seller directly OFF-platform via the seller's chosen receiving account. The order amount is the protocol/base amount; the actual payable DISPLAY follows that account's declared currency/instruction (USDC only when the account is USDC/USD — otherwise the seller's currency, "以收款说明为准"). WebAZ does NOT custody, route, validate, or restrict the method/currency. Pass \`direct_receive_account_id\` (seller's receiving account). Eligibility — product/store verification, per-tx caps, valid account — is enforced by the SERVER; you do not decide it. Ineligible → the route's own gate error (e.g. DIRECT_PAY_PRODUCT_NOT_VERIFIED → use escrow).
|
|
734
|
+
- onchain_full_stake / psp: PLANNED, not enabled → passing them returns PAYMENT_RAIL_DISABLED.
|
|
728
735
|
|
|
729
|
-
Order deadlines (absolute ISO timestamps in response):
|
|
736
|
+
Order deadlines (escrow rail; absolute ISO timestamps in response):
|
|
730
737
|
- accept T+48h | ship T+120h (72h after accept)
|
|
731
738
|
- pickup T+168h (48h after ship) | delivery T+336h (7d after pickup)
|
|
732
739
|
- confirm T+408h (72h after delivery)
|
|
@@ -743,6 +750,8 @@ Options:
|
|
|
743
750
|
api_key: { type: 'string', description: "Buyer's api_key (or omit and set the WEBAZ_API_KEY env var)" },
|
|
744
751
|
product_id: { type: 'string', description: 'Product ID to buy (from webaz_search)' },
|
|
745
752
|
quantity: { type: 'number', description: 'Quantity, default 1' },
|
|
753
|
+
payment_rail: { type: 'string', enum: ['escrow', 'direct_p2p'], description: 'Payment rail (default: escrow). "direct_p2p" = live non-custodial direct pay, off-platform; the payable display follows the seller\'s receiving-account currency/instruction (USDC only as the base/reference amount, WebAZ doesn\'t custody/route/validate/restrict currency) — requires direct_receive_account_id; the server runs the direct-pay eligibility gates (you do not). Other rails (onchain_full_stake / psp) are planned but disabled → PAYMENT_RAIL_DISABLED.' },
|
|
754
|
+
direct_receive_account_id: { type: 'string', description: 'For payment_rail="direct_p2p": the seller\'s direct-receive account id to pay. Ignored on escrow.' },
|
|
746
755
|
shipping_address: { type: 'string', description: 'Shipping address' },
|
|
747
756
|
notes: { type: 'string', description: 'Note to seller (optional)' },
|
|
748
757
|
session_token: {
|
|
@@ -1584,7 +1593,7 @@ Enums: **category** phone/computer/appliance/furniture/clothing/book/toy/sports/
|
|
|
1584
1593
|
description: { type: 'string', description: 'Description (≤1000 chars, optional)' },
|
|
1585
1594
|
category: { type: 'string', enum: ['phone', 'computer', 'appliance', 'furniture', 'clothing', 'book', 'toy', 'sports', 'other'], description: 'Category (required for publish)' },
|
|
1586
1595
|
condition_grade: { type: 'string', enum: ['brand_new', 'like_new', 'lightly_used', 'well_used', 'heavily_used'], description: 'Condition grade (required for publish)' },
|
|
1587
|
-
price: { type: 'number', description: '
|
|
1596
|
+
price: { type: 'number', description: 'Listing amount 0-100000 = protocol/base amount (waz_usdc_rate 1.0). On direct_p2p the payable display follows the seller\'s receiving-account currency/instruction (USDC only as the base/reference; WebAZ doesn\'t custody/route/validate/restrict currency); WAZ on legacy escrow. Rail chosen at purchase. Required for publish.' },
|
|
1588
1597
|
negotiable: { type: 'boolean', description: 'Negotiable flag (optional)' },
|
|
1589
1598
|
images: { type: 'array', items: { type: 'string' }, description: 'Images dataURL/URL array, ≥1 ≤9 (required for publish)' },
|
|
1590
1599
|
region: { type: 'string', description: 'Region (≤40 chars, optional)' },
|
|
@@ -1708,6 +1717,39 @@ Coordinates + records only — NO merge/reward; acceptance (done) = human mainta
|
|
|
1708
1717
|
},
|
|
1709
1718
|
},
|
|
1710
1719
|
},
|
|
1720
|
+
{
|
|
1721
|
+
name: 'webaz_get_agent_order',
|
|
1722
|
+
description: `Grant-wired MINIMAL order read for a fulfillment agent (safe scope seller_orders_read_minimal). Reads the seller's OWN orders via a paired delegation grant (webaz_pair — fulfillment_agent bundle), NOT an api_key. Returns a minimal projection only — order_id / status / next_actor / deadline / amount / item_ref — with NO buyer address / contact / PII and no execution.
|
|
1723
|
+
|
|
1724
|
+
- order_id given → that one order; omitted → the seller's order list.
|
|
1725
|
+
- No grant paired → GRANT_REQUIRED (run webaz_pair action="start"). Missing scope → structured PERMISSION_REQUIRED (request via webaz_pair action="request" bundle="fulfillment_agent", have the human approve, then retry).
|
|
1726
|
+
- To ACT on an order (accept/ship), use webaz_order_action_request (submit → human Passkey approves → server executes).`,
|
|
1727
|
+
inputSchema: {
|
|
1728
|
+
type: 'object',
|
|
1729
|
+
properties: {
|
|
1730
|
+
order_id: { type: 'string', description: "Order ID. Omit to list the seller's orders (minimal projection)." },
|
|
1731
|
+
},
|
|
1732
|
+
required: [],
|
|
1733
|
+
},
|
|
1734
|
+
},
|
|
1735
|
+
{
|
|
1736
|
+
name: 'webaz_order_action_request',
|
|
1737
|
+
description: `Grant-wired order-action SUBMIT for a fulfillment agent (safe scope order_action_request). Submits an accept/ship request into the seller's HUMAN approval queue — it does NOT execute. The order action runs only after the human approves with their Passkey. Uses a paired delegation grant (webaz_pair — fulfillment_agent), NOT an api_key.
|
|
1738
|
+
|
|
1739
|
+
- action="accept" (a paid order) | action="ship" (an accepted order; action_params needs { tracking, evidence_ref }).
|
|
1740
|
+
- Returns request_id + approval_url — tell the human to open it and approve with their Passkey; only then does the server execute the action.
|
|
1741
|
+
- decline is NOT delegable — the backend returns DECLINE_NOT_DELEGATED; a human must decline in the PWA.
|
|
1742
|
+
- No grant → GRANT_REQUIRED. Missing scope → structured PERMISSION_REQUIRED (request via webaz_pair action="request" bundle="fulfillment_agent"). No buyer address/PII in params.`,
|
|
1743
|
+
inputSchema: {
|
|
1744
|
+
type: 'object',
|
|
1745
|
+
properties: {
|
|
1746
|
+
order_id: { type: 'string', description: 'Order ID' },
|
|
1747
|
+
action: { type: 'string', enum: ['accept', 'ship'], description: 'accept a paid order, or ship an accepted order' },
|
|
1748
|
+
action_params: { type: 'object', description: 'ship needs { tracking, evidence_ref }; accept needs none. Never put buyer address/PII here.' },
|
|
1749
|
+
},
|
|
1750
|
+
required: ['order_id', 'action'],
|
|
1751
|
+
},
|
|
1752
|
+
},
|
|
1711
1753
|
];
|
|
1712
1754
|
// ─── 工具处理函数 ─────────────────────────────────────────────
|
|
1713
1755
|
// RFC-004: webaz_feedback — agent-native "use → build" 反馈(双模;仅 NETWORK 能送达)
|
|
@@ -1886,8 +1928,45 @@ export async function handleContribute(args) {
|
|
|
1886
1928
|
}
|
|
1887
1929
|
return { error: 'unknown action: ' + action };
|
|
1888
1930
|
}
|
|
1931
|
+
/** Minimal semver "a < b" for plain x.y.z release strings (our versions carry no pre-release tags).
|
|
1932
|
+
* NUMERIC per-part — must not string-compare (else '0.1.9' > '0.1.10' is wrong). */
|
|
1933
|
+
export function isOlderVersion(a, b) {
|
|
1934
|
+
const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);
|
|
1935
|
+
const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);
|
|
1936
|
+
for (let i = 0; i < 3; i++) {
|
|
1937
|
+
const x = pa[i] || 0, y = pb[i] || 0;
|
|
1938
|
+
if (x !== y)
|
|
1939
|
+
return x < y;
|
|
1940
|
+
}
|
|
1941
|
+
return false;
|
|
1942
|
+
}
|
|
1943
|
+
/** Best-effort "is a newer MCP published?" nudge for webaz_info. npm is the source of truth (the live server
|
|
1944
|
+
* version can LAG a publish), so we ask the registry directly. Network-gated + short timeout + never throws —
|
|
1945
|
+
* a missing/slow check must never break webaz_info. */
|
|
1946
|
+
async function checkMcpVersion() {
|
|
1947
|
+
const current = SOFTWARE_VERSION;
|
|
1948
|
+
if (!isNetworkMode())
|
|
1949
|
+
return { mcp_version: current, update_check: 'skipped (sandbox mode — no external calls)' };
|
|
1950
|
+
try {
|
|
1951
|
+
const r = await fetch('https://registry.npmjs.org/@seasonkoh/webaz/latest', { signal: AbortSignal.timeout(2500) });
|
|
1952
|
+
if (!r.ok)
|
|
1953
|
+
return { mcp_version: current, update_check: `unavailable (npm ${r.status})` };
|
|
1954
|
+
const latest = String((await r.json()).version || '');
|
|
1955
|
+
const stale = !!latest && isOlderVersion(current, latest);
|
|
1956
|
+
return {
|
|
1957
|
+
mcp_version: current,
|
|
1958
|
+
latest_version: latest || undefined,
|
|
1959
|
+
update_available: stale,
|
|
1960
|
+
...(stale ? { update_note: `A newer WebAZ MCP is published (${latest} > your ${current}). Update your connector to "@seasonkoh/webaz@latest" (or @${latest}), then FULLY restart the client (Cmd+Q) and open a NEW conversation — a conversation's MCP tool set is fixed at connect time, so an in-place refresh will NOT pick up new tools like webaz_pair.` } : {}),
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
catch (e) {
|
|
1964
|
+
return { mcp_version: current, update_check: `unavailable (${e.message})` };
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1889
1967
|
async function handleInfo() {
|
|
1890
1968
|
const summary = getManifestSummary();
|
|
1969
|
+
const mcp = await checkMcpVersion();
|
|
1891
1970
|
// RFC-003 Batch 0:NETWORK 模式下,best-effort 拉 webaz.xyz 的 live 协议状态,
|
|
1892
1971
|
// 让带 key 的 agent 拿到【真网络】数字,而非只看本机本地 live_stats(下方仍保留并标注为本地)。
|
|
1893
1972
|
let network_live = null;
|
|
@@ -1938,6 +2017,7 @@ async function handleInfo() {
|
|
|
1938
2017
|
})();
|
|
1939
2018
|
return {
|
|
1940
2019
|
...summary,
|
|
2020
|
+
mcp, // client MCP version + best-effort npm update check (mcp_version / latest_version / update_available / update_note)
|
|
1941
2021
|
// 诚实化披露 — 排在 stats / quick_start 之前,让 agent 先建立正确预期。
|
|
1942
2022
|
// 第三方尽调 agent 抓到过的破绽:live_stats 是本地库数字、引导词推 agent 拉新。
|
|
1943
2023
|
// 此处主动披露真实状态(pre-launch / 数据来源),不抗辩;agent 自己判断如何处理。
|
|
@@ -2442,12 +2522,94 @@ async function handleVerifyPrice(args) {
|
|
|
2442
2522
|
next: `调用 webaz_place_order 时传入 session_token="${token}" 确保以此价格成交`,
|
|
2443
2523
|
};
|
|
2444
2524
|
}
|
|
2445
|
-
|
|
2525
|
+
// RFC-021 fulfillment-agent grant-wired order tools. PURE WRAPPERS over already-live endpoints —
|
|
2526
|
+
// no backend/executor/projection/money change. Grant-only (these endpoints require a gtk_ grant
|
|
2527
|
+
// bearer via requireAgentGrantScope; an api_key does not resolve them). Replicates the
|
|
2528
|
+
// resolveGrantCredential → apiCall pattern (samizdat of handleListProduct's grant path).
|
|
2529
|
+
export async function handleGetAgentOrder(args) {
|
|
2530
|
+
// Wraps GET /api/agent/orders(/:id) (safe scope seller_orders_read_minimal). Passes the minimal
|
|
2531
|
+
// projection through UNCHANGED — no field reshaped/added, nothing persisted; PII never enters here.
|
|
2532
|
+
if (!isNetworkMode())
|
|
2533
|
+
return { error: 'a delegation grant requires NETWORK mode (grants live on webaz.xyz)', error_code: 'GRANT_REQUIRES_NETWORK' };
|
|
2534
|
+
const cred = resolveGrantCredential();
|
|
2535
|
+
if (!cred)
|
|
2536
|
+
return { error: 'a delegation grant is required — run webaz_pair action="start", have the human approve the fulfillment_agent bundle, then retry.', error_code: 'GRANT_REQUIRED' };
|
|
2537
|
+
const orderId = (typeof args.order_id === 'string' && args.order_id) ? args.order_id : '';
|
|
2538
|
+
const path = orderId ? `/api/agent/orders/${encodeURIComponent(orderId)}` : '/api/agent/orders';
|
|
2539
|
+
const r = await apiCall(path, { method: 'GET', apiKey: cred.token });
|
|
2540
|
+
if (r.error_code === 'PERMISSION_REQUIRED')
|
|
2541
|
+
return { ...r, retry_after_approval: true, hint: 'Your grant lacks seller_orders_read_minimal. Run webaz_pair action="request" bundle="fulfillment_agent", have the human approve, then retry.' };
|
|
2542
|
+
return r;
|
|
2543
|
+
}
|
|
2544
|
+
export async function handleOrderActionRequest(args) {
|
|
2545
|
+
// Wraps POST /api/agent/orders/:id/action-request (safe scope order_action_request). SUBMIT-ONLY:
|
|
2546
|
+
// writes a pending request to the human approval queue; NEVER executes (execution needs a human
|
|
2547
|
+
// Passkey approval). decline is not delegable — the backend returns DECLINE_NOT_DELEGATED and we
|
|
2548
|
+
// pass it through untouched. Client-side forward-allowlist keeps PII off the wire (see below).
|
|
2549
|
+
if (!isNetworkMode())
|
|
2550
|
+
return { error: 'a delegation grant requires NETWORK mode (grants live on webaz.xyz)', error_code: 'GRANT_REQUIRES_NETWORK' };
|
|
2551
|
+
const cred = resolveGrantCredential();
|
|
2552
|
+
if (!cred)
|
|
2553
|
+
return { error: 'a delegation grant is required — run webaz_pair action="start", have the human approve the fulfillment_agent bundle, then retry.', error_code: 'GRANT_REQUIRED' };
|
|
2554
|
+
const orderId = (typeof args.order_id === 'string' && args.order_id) ? args.order_id : '';
|
|
2555
|
+
if (!orderId)
|
|
2556
|
+
return { error: 'order_id is required', error_code: 'ORDER_ID_REQUIRED' };
|
|
2557
|
+
const body = { action: args.action };
|
|
2558
|
+
// Client-side forward-allowlist (defense-in-depth): only `ship` carries params, and only
|
|
2559
|
+
// {tracking, evidence_ref} — so PII (address/recipient/phone) NEVER leaves the agent, not even
|
|
2560
|
+
// in the outbound request body. `accept` forwards no params. Canonical allowlist = the backend's
|
|
2561
|
+
// sanitizeOrderActionParams (order-action-request.ts) — keep these two fields in sync.
|
|
2562
|
+
if (args.action === 'ship' && args.action_params && typeof args.action_params === 'object') {
|
|
2563
|
+
const p = args.action_params;
|
|
2564
|
+
const clean = {};
|
|
2565
|
+
if (p.tracking != null)
|
|
2566
|
+
clean.tracking = p.tracking;
|
|
2567
|
+
if (p.evidence_ref != null)
|
|
2568
|
+
clean.evidence_ref = p.evidence_ref;
|
|
2569
|
+
body.action_params = clean;
|
|
2570
|
+
}
|
|
2571
|
+
const r = await apiCall(`/api/agent/orders/${encodeURIComponent(orderId)}/action-request`, { method: 'POST', apiKey: cred.token, body });
|
|
2572
|
+
if (r.error_code === 'PERMISSION_REQUIRED')
|
|
2573
|
+
return { ...r, retry_after_approval: true, hint: 'Your grant lacks order_action_request. Run webaz_pair action="request" bundle="fulfillment_agent", have the human approve, then retry.' };
|
|
2574
|
+
return r;
|
|
2575
|
+
}
|
|
2576
|
+
export async function handleListProduct(args) {
|
|
2446
2577
|
// Wave 3 audit P0: 加 action 分发 — agent 卖家能完整管理目录(不止 create)
|
|
2447
2578
|
const action = args.action || 'create';
|
|
2448
2579
|
const apiKey = resolveMcpApiKey(args);
|
|
2449
|
-
if (!apiKey)
|
|
2450
|
-
|
|
2580
|
+
if (!apiKey) {
|
|
2581
|
+
// No human api_key — fall back to a delegation grant (Catalog Agent). A grant can READ the catalog
|
|
2582
|
+
// (action="mine", seller_products_read) and create DRAFTS (action="create"|"draft", seller_product_draft
|
|
2583
|
+
// → status=warehouse, NOT public). Publishing (active) + all other writes stay human/api_key only.
|
|
2584
|
+
// Missing scope → structured PERMISSION_REQUIRED (agent can request it, then retry).
|
|
2585
|
+
if (!isNetworkMode())
|
|
2586
|
+
return { error: 'a delegation grant requires NETWORK mode (grants live on webaz.xyz)', error_code: 'GRANT_REQUIRES_NETWORK' };
|
|
2587
|
+
const cred = resolveGrantCredential();
|
|
2588
|
+
if (!cred)
|
|
2589
|
+
return { error: 'api_key required — or pair a delegation grant: webaz_pair action="start", have the human approve the catalog_agent bundle, then retry.', error_code: 'AUTH_REQUIRED' };
|
|
2590
|
+
if (action === 'mine') {
|
|
2591
|
+
const r = await apiCall('/api/agent/seller/products', { method: 'GET', apiKey: cred.token });
|
|
2592
|
+
if (r.error_code === 'PERMISSION_REQUIRED')
|
|
2593
|
+
return { ...r, retry_after_approval: true, hint: 'Your grant lacks seller_products_read. Run webaz_pair action="request" bundle="catalog_agent", have the human approve, then retry.' };
|
|
2594
|
+
if (r.error)
|
|
2595
|
+
return r;
|
|
2596
|
+
return { found: r.count, products: r.products, seller_id: r.seller_id, via: 'delegation_grant', note: 'Read via your delegation grant (safe scope seller_products_read). Creating a DRAFT: action="create" (goes to your warehouse for you to review + publish).' };
|
|
2597
|
+
}
|
|
2598
|
+
if (action === 'create' || action === 'draft') {
|
|
2599
|
+
const createFields = ['title', 'description', 'price', 'stock', 'category', 'specs', 'brand', 'model', 'source_url', 'source_price', 'external_title', 'weight_kg', 'ship_regions', 'handling_hours', 'estimated_days', 'fragile', 'return_days', 'return_condition', 'warranty_days', 'commission_rate', 'product_type', 'aliases', 'image_hashes', 'additional_links'];
|
|
2600
|
+
const body = {};
|
|
2601
|
+
for (const k of createFields)
|
|
2602
|
+
if (args[k] !== undefined)
|
|
2603
|
+
body[k] = args[k];
|
|
2604
|
+
const r = await apiCall('/api/agent/seller/products', { method: 'POST', apiKey: cred.token, body });
|
|
2605
|
+
if (r.error_code === 'PERMISSION_REQUIRED')
|
|
2606
|
+
return { ...r, retry_after_approval: true, hint: 'Your grant lacks seller_product_draft. Run webaz_pair action="request" bundle="catalog_agent", have the human approve, then retry.' };
|
|
2607
|
+
if (r.error)
|
|
2608
|
+
return r;
|
|
2609
|
+
return { ...r, via: 'delegation_grant', note: 'Created as a DRAFT (status=warehouse — NOT public/sellable). Publishing stays with the human: they review it in 我的商品 → 仓库 and publish (a notification was sent). A grant can never publish.' };
|
|
2610
|
+
}
|
|
2611
|
+
return { error_code: 'GRANT_WRITE_NOT_ENABLED', note: `action="${action}" is not available via a delegation grant — a grant can READ (action="mine") and create DRAFTS (action="create" → warehouse). Publishing, updating, delisting, and deleting stay human/api_key only. Provide a seller api_key for those.` };
|
|
2612
|
+
}
|
|
2451
2613
|
// RFC-003 P2b: NETWORK 模式 — 卖家目录管理全部转发生产端点(单一真相源)
|
|
2452
2614
|
if (toolBackend('webaz_list_product') === 'network') {
|
|
2453
2615
|
const pid = args.product_id;
|
|
@@ -2610,9 +2772,20 @@ async function handleListProduct(args) {
|
|
|
2610
2772
|
...(extraResult ? { extra_fields_applied: !('error' in extraResult), extra_result: extraResult } : {}),
|
|
2611
2773
|
};
|
|
2612
2774
|
}
|
|
2613
|
-
async function handlePlaceOrder(args) {
|
|
2775
|
+
export async function handlePlaceOrder(args) {
|
|
2776
|
+
// Payment rail (PWA-aligned). Only escrow (legacy) + direct_p2p (live, non-custodial) are enabled; the
|
|
2777
|
+
// other declared rails (onchain_full_stake / psp) are PLANNED placeholders — refuse them here, never
|
|
2778
|
+
// silently downgrade. The MCP does NOT judge direct-pay eligibility: it forwards payment_rail +
|
|
2779
|
+
// direct_receive_account_id to the SAME /api/orders route, which runs all the PWA gates.
|
|
2780
|
+
const rail = args.payment_rail;
|
|
2781
|
+
if (rail != null && rail !== 'escrow' && rail !== 'direct_p2p') {
|
|
2782
|
+
return { error: `payment rail "${String(rail)}" is planned but not enabled — only "direct_p2p" (live, non-custodial direct pay) and "escrow" (legacy) are available.`, error_code: 'PAYMENT_RAIL_DISABLED' };
|
|
2783
|
+
}
|
|
2784
|
+
if (rail === 'direct_p2p' && !isNetworkMode()) {
|
|
2785
|
+
return { error: 'direct_p2p (direct pay) requires NETWORK mode — it settles against a real seller receiving account on webaz.xyz.', error_code: 'DIRECT_PAY_REQUIRES_NETWORK' };
|
|
2786
|
+
}
|
|
2614
2787
|
// RFC-003 P2: network 模式转发到生产 POST /api/orders(前置,绕过本地 db)。
|
|
2615
|
-
// 生产端做完整鉴权/库存/session/spend-cap
|
|
2788
|
+
// 生产端做完整鉴权/库存/session/spend-cap/结算 + direct-pay gate(createDirectPayResponse)。
|
|
2616
2789
|
if (toolBackend('webaz_place_order') === 'network') {
|
|
2617
2790
|
const body = { product_id: args.product_id, quantity: Number(args.quantity ?? 1) };
|
|
2618
2791
|
if (args.session_token != null)
|
|
@@ -2623,6 +2796,10 @@ async function handlePlaceOrder(args) {
|
|
|
2623
2796
|
body.shipping_address = args.shipping_address;
|
|
2624
2797
|
if (args.donation_pct != null)
|
|
2625
2798
|
body.donation_pct = args.donation_pct;
|
|
2799
|
+
if (args.payment_rail != null)
|
|
2800
|
+
body.payment_rail = args.payment_rail; // escrow | direct_p2p (route forks + gates)
|
|
2801
|
+
if (args.direct_receive_account_id != null)
|
|
2802
|
+
body.direct_receive_account_id = args.direct_receive_account_id; // seller receiving acct (direct_p2p)
|
|
2626
2803
|
return apiCall('/api/orders', { method: 'POST', apiKey: resolveMcpApiKey(args), body });
|
|
2627
2804
|
}
|
|
2628
2805
|
const auth = requireAuth(db, resolveMcpApiKey(args));
|
|
@@ -5225,6 +5402,12 @@ export async function startMCPServer() {
|
|
|
5225
5402
|
case 'webaz_get_status':
|
|
5226
5403
|
result = await handleGetStatus(args);
|
|
5227
5404
|
break;
|
|
5405
|
+
case 'webaz_get_agent_order':
|
|
5406
|
+
result = await handleGetAgentOrder(args);
|
|
5407
|
+
break;
|
|
5408
|
+
case 'webaz_order_action_request':
|
|
5409
|
+
result = await handleOrderActionRequest(args);
|
|
5410
|
+
break;
|
|
5228
5411
|
case 'webaz_feedback':
|
|
5229
5412
|
result = await handleFeedback(args);
|
|
5230
5413
|
break;
|
|
@@ -275,6 +275,102 @@ opts) {
|
|
|
275
275
|
pushCallback?.(userId, notif);
|
|
276
276
|
return notif;
|
|
277
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* 仲裁 admin 收件人:root(admin_type='root' → 隐含 ['all'])或 admin_permissions 含 'all'/'arbitration' 的 admin。
|
|
280
|
+
* 与争议读写授权边界(requireAdminPermission(...,'arbitration'))一致 —— content/support 等无 arbitration 权限的
|
|
281
|
+
* admin【不】收到争议通知。复刻 hasAdminPermission 逻辑,不 import server 层。列缺失(fresh-DB)→ 空集。
|
|
282
|
+
*/
|
|
283
|
+
export function resolveArbitrationAdminIds(db) {
|
|
284
|
+
const ids = [];
|
|
285
|
+
try {
|
|
286
|
+
const rows = db.prepare("SELECT id, admin_type, admin_permissions FROM users WHERE role = 'admin'").all();
|
|
287
|
+
for (const r of rows) {
|
|
288
|
+
if (r.admin_type === 'root') {
|
|
289
|
+
ids.push(r.id);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
let perms = [];
|
|
293
|
+
try {
|
|
294
|
+
perms = JSON.parse(r.admin_permissions || '[]');
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
perms = [];
|
|
298
|
+
}
|
|
299
|
+
if (Array.isArray(perms) && (perms.includes('all') || perms.includes('arbitration')))
|
|
300
|
+
ids.push(r.id);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch { /* fresh-DB 无 admin_type/permissions 列 → 空集 */ }
|
|
304
|
+
return ids;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* 统一仲裁台:一笔卖家客观拒单举证并入 disputes 后,通知全体 active 仲裁员 + 仲裁 admin(管理面)有新案待处理。
|
|
308
|
+
* 幂等去重:按 (order_id, type='arb:decline_contest_new', user_id) 存在性探测 —— 新建行、backfill 补建、
|
|
309
|
+
* 重复调用皆只对每个收件人发一次,不重复轰炸(镜像 scanDeadlineReminders 的 (order_id,type) 去重)。
|
|
310
|
+
* 收件人为 active arbitrator_whitelist(唯一仲裁能力源)+ users.role='admin'(监督/兜底)。
|
|
311
|
+
* 同步 better-sqlite3(engine 层);调用方(contest 路由 / backfill 脚本)传入同一个 db 句柄。
|
|
312
|
+
*/
|
|
313
|
+
export function notifyDeclineContestCase(db, orderId, disputeId) {
|
|
314
|
+
const TYPE = 'arb:decline_contest_new';
|
|
315
|
+
const ids = new Set();
|
|
316
|
+
try {
|
|
317
|
+
db.prepare("SELECT user_id AS id FROM arbitrator_whitelist WHERE status IS NULL OR status = 'active'").all().forEach(r => ids.add(r.id));
|
|
318
|
+
}
|
|
319
|
+
catch { /* fresh-DB 无表 */ }
|
|
320
|
+
resolveArbitrationAdminIds(db).forEach(id => ids.add(id)); // 仅仲裁 admin(root / all / arbitration),不含 content/support
|
|
321
|
+
let notified = 0, skipped = 0;
|
|
322
|
+
for (const uid of ids) {
|
|
323
|
+
const exists = db.prepare('SELECT 1 FROM notifications WHERE order_id = ? AND type = ? AND user_id = ? LIMIT 1').get(orderId, TYPE, uid);
|
|
324
|
+
if (exists) {
|
|
325
|
+
skipped++;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
createNotification(db, uid, orderId, TYPE, '新的拒单举证仲裁待处理', '一笔卖家客观拒单举证已进入统一仲裁台,等待仲裁员裁决。', { templateKey: 'arb_decline_contest_new', params: { order_id: orderId, dispute_id: disputeId } });
|
|
329
|
+
notified++;
|
|
330
|
+
}
|
|
331
|
+
return { notified, skipped };
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* 四段式超时升级:仲裁窗口过后仍无裁决 → 通知 active 仲裁员 + 仲裁 admin 尽快处理(admin 可 override)。
|
|
335
|
+
* 与 [[notifyDeclineContestCase]] 同一收件人 + 同一 (order_id, type, user_id) 去重键,type='arb:decline_contest_escalated' 只发一次。
|
|
336
|
+
*/
|
|
337
|
+
export function notifyDeclineContestEscalated(db, orderId) {
|
|
338
|
+
const TYPE = 'arb:decline_contest_escalated';
|
|
339
|
+
const ids = new Set();
|
|
340
|
+
try {
|
|
341
|
+
db.prepare("SELECT user_id AS id FROM arbitrator_whitelist WHERE status IS NULL OR status = 'active'").all().forEach(r => ids.add(r.id));
|
|
342
|
+
}
|
|
343
|
+
catch { /* */ }
|
|
344
|
+
resolveArbitrationAdminIds(db).forEach(id => ids.add(id));
|
|
345
|
+
let notified = 0, skipped = 0;
|
|
346
|
+
for (const uid of ids) {
|
|
347
|
+
if (db.prepare('SELECT 1 FROM notifications WHERE order_id = ? AND type = ? AND user_id = ? LIMIT 1').get(orderId, TYPE, uid)) {
|
|
348
|
+
skipped++;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
createNotification(db, uid, orderId, TYPE, '拒单举证仲裁已超时,请尽快裁决', '一笔拒单举证仲裁已过仲裁窗口仍未裁决,已进入 admin 兜底窗口 —— 请仲裁员或管理员尽快处理,否则将自动判卖家违约。', { templateKey: 'arb_decline_contest_escalated', params: { order_id: orderId } });
|
|
352
|
+
notified++;
|
|
353
|
+
}
|
|
354
|
+
return { notified, skipped };
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* 裁决落定后通知买卖双方结果(维持无责 / 驳回判违约)。非去重(每案只落定一次);买卖各一条。
|
|
358
|
+
*/
|
|
359
|
+
export function notifyDeclineContestResolved(db, orderId, decision) {
|
|
360
|
+
const o = db.prepare('SELECT buyer_id, seller_id FROM orders WHERE id = ?').get(orderId);
|
|
361
|
+
if (!o)
|
|
362
|
+
return;
|
|
363
|
+
const upheld = decision === 'decline_no_fault_upheld';
|
|
364
|
+
const title = upheld ? '拒单举证仲裁:维持无责' : '拒单举证仲裁:驳回,判卖家违约';
|
|
365
|
+
const body = upheld
|
|
366
|
+
? '仲裁认定卖家客观无责:买家已全额退款,卖家质押已退回,无罚没。订单已结。'
|
|
367
|
+
: '仲裁驳回卖家举证,判卖家违约:买家已退款,卖家质押按违约处置。订单已结。';
|
|
368
|
+
for (const uid of [o.buyer_id, o.seller_id]) {
|
|
369
|
+
if (!uid)
|
|
370
|
+
continue;
|
|
371
|
+
createNotification(db, uid, orderId, 'arb:decline_contest_resolved', title, body, { templateKey: 'arb_decline_contest_resolved', params: { order_id: orderId, decision } });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
278
374
|
// ─── 查询 ─────────────────────────────────────────────────────
|
|
279
375
|
// RFC-016 Phase 1:纯读 → 异步 seam。db 参数保留(签名兼容),内部走 dbAll/dbOne(同实例,setSeamDb)。
|
|
280
376
|
// 调用点全部已确认不在 db.transaction 内(notifications.ts:43/60/61 + mcp server.ts:2770/2771)。
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { transition, settleFault, settleDeclinedNoFault } from '../../layer0-foundation/L0-2-state-machine/engine.js';
|
|
2
|
+
import { arbitratorHasConflict } from '../../pwa/arbitrator-lifecycle.js';
|
|
3
|
+
const SYS = 'sys_protocol'; // 终态转移执行者(role=system);真实裁决人记在 dispute
|
|
4
|
+
export class DcResolveError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
http;
|
|
7
|
+
constructor(code, message, http = 400) { super(message); this.code = code; this.http = http; }
|
|
8
|
+
}
|
|
9
|
+
function appendAudit(existing, entry) {
|
|
10
|
+
let log = [];
|
|
11
|
+
try {
|
|
12
|
+
const p = JSON.parse(existing || '[]');
|
|
13
|
+
if (Array.isArray(p))
|
|
14
|
+
log = p;
|
|
15
|
+
}
|
|
16
|
+
catch { /* 破损 → 重置为空,不丢新条目 */ }
|
|
17
|
+
log.push(entry);
|
|
18
|
+
return JSON.stringify(log);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 唯一裁决器。decision 对 timeout_auto 会被强制为 decline_fault_confirmed(硬兜底判卖家违约)。
|
|
22
|
+
* @throws DcResolveError(.http 供路由映射状态码);事务整体回滚。
|
|
23
|
+
*/
|
|
24
|
+
export function resolveDeclineContestDispute(db, disputeId, actorId, decision, reason, source) {
|
|
25
|
+
if (decision !== 'decline_no_fault_upheld' && decision !== 'decline_fault_confirmed') {
|
|
26
|
+
throw new DcResolveError('BAD_DECISION', "decision 必须为 'decline_no_fault_upheld'(维持无责) 或 'decline_fault_confirmed'(驳回判违约)", 400);
|
|
27
|
+
}
|
|
28
|
+
if (!reason || !String(reason).trim())
|
|
29
|
+
throw new DcResolveError('REASON_REQUIRED', '必须提供裁决理由', 400);
|
|
30
|
+
const run = db.transaction(() => {
|
|
31
|
+
const dispute = db.prepare('SELECT id, order_id, initiator_id, defendant_id, status, dispute_type, arbitrate_deadline, assigned_arbitrators, audit_log FROM disputes WHERE id = ?').get(disputeId);
|
|
32
|
+
if (!dispute)
|
|
33
|
+
throw new DcResolveError('DISPUTE_NOT_FOUND', '争议不存在', 404);
|
|
34
|
+
if (dispute.dispute_type !== 'decline_contest')
|
|
35
|
+
throw new DcResolveError('NOT_DECLINE_CONTEST', '本争议不是拒单举证仲裁', 400);
|
|
36
|
+
if (dispute.status !== 'open' && dispute.status !== 'in_review')
|
|
37
|
+
throw new DcResolveError('ALREADY_RULED', '本案已裁决', 409);
|
|
38
|
+
const order = db.prepare('SELECT id, buyer_id, seller_id, status, decline_objective_pending, decline_contested, settled_fault_at FROM orders WHERE id = ?').get(dispute.order_id);
|
|
39
|
+
if (!order)
|
|
40
|
+
throw new DcResolveError('ORDER_NOT_FOUND', '订单不存在', 404);
|
|
41
|
+
if (order.status !== 'fault_seller' || Number(order.decline_objective_pending) !== 1 || Number(order.decline_contested) !== 1 || order.settled_fault_at) {
|
|
42
|
+
throw new DcResolveError('ORDER_NOT_RESOLVABLE', '订单不是可裁决的【已举证客观拒单临时判责】状态', 409);
|
|
43
|
+
}
|
|
44
|
+
// ── ② 按 source 授权 ──
|
|
45
|
+
let effectiveDecision = decision;
|
|
46
|
+
const audit = { at: new Date().toISOString(), source, actor: actorId, decision };
|
|
47
|
+
if (source === 'arbitrator') {
|
|
48
|
+
if (arbitratorHasConflict(db, order.id, dispute.initiator_id, dispute.defendant_id, actorId))
|
|
49
|
+
throw new DcResolveError('ARBITRATOR_CONFLICT_OF_INTEREST', '你是本案当事方,不可仲裁(利益冲突)', 403);
|
|
50
|
+
// assignment CAS:首个仲裁员抢占;已分配他人 → 拒
|
|
51
|
+
let assigned = [];
|
|
52
|
+
try {
|
|
53
|
+
assigned = JSON.parse(dispute.assigned_arbitrators || '[]');
|
|
54
|
+
}
|
|
55
|
+
catch { /* */ }
|
|
56
|
+
if (assigned.length === 0) {
|
|
57
|
+
const claim = db.prepare("UPDATE disputes SET assigned_arbitrators = ? WHERE id = ? AND (assigned_arbitrators IS NULL OR assigned_arbitrators = '[]')").run(JSON.stringify([actorId]), disputeId);
|
|
58
|
+
if (claim.changes === 0) {
|
|
59
|
+
const fresh = db.prepare('SELECT assigned_arbitrators FROM disputes WHERE id = ?').get(disputeId);
|
|
60
|
+
try {
|
|
61
|
+
assigned = JSON.parse(fresh?.assigned_arbitrators || '[]');
|
|
62
|
+
}
|
|
63
|
+
catch { /* */ }
|
|
64
|
+
}
|
|
65
|
+
else
|
|
66
|
+
assigned = [actorId];
|
|
67
|
+
}
|
|
68
|
+
if (!assigned.includes(actorId))
|
|
69
|
+
throw new DcResolveError('NOT_ASSIGNED_ARBITRATOR', '本案已分配给其他仲裁员', 409);
|
|
70
|
+
}
|
|
71
|
+
else if (source === 'admin_fallback') {
|
|
72
|
+
if (arbitratorHasConflict(db, order.id, dispute.initiator_id, dispute.defendant_id, actorId))
|
|
73
|
+
throw new DcResolveError('ARBITRATOR_CONFLICT_OF_INTEREST', '你是本案当事方,不可裁决(利益冲突)', 403);
|
|
74
|
+
// §3 仲裁员优先:仅仲裁窗口(arbitrate_deadline)过后 admin 才可 override
|
|
75
|
+
if (!dispute.arbitrate_deadline || new Date().toISOString() <= dispute.arbitrate_deadline)
|
|
76
|
+
throw new DcResolveError('FALLBACK_TOO_EARLY', '仲裁窗口未过,admin 兜底裁决尚不可用(仲裁员优先)', 409);
|
|
77
|
+
audit.resolved_by_admin_override = actorId; // 不占用 assigned_arbitrators,以 override 记录
|
|
78
|
+
}
|
|
79
|
+
else { // timeout_auto
|
|
80
|
+
effectiveDecision = 'decline_fault_confirmed'; // 硬兜底:一律判卖家违约(卖家担客观无责举证责任)
|
|
81
|
+
audit.decision = effectiveDecision;
|
|
82
|
+
audit.auto_resolved_by_timeout = true;
|
|
83
|
+
}
|
|
84
|
+
// ── ① dispute CAS:抢占裁决权(并发第二人 → 0 行 → throw,不结算)──
|
|
85
|
+
const cas = db.prepare("UPDATE disputes SET status='resolved', ruling_type=?, verdict_reason=?, resolved_at=datetime('now'), audit_log=? WHERE id=? AND status IN ('open','in_review')")
|
|
86
|
+
.run(effectiveDecision, reason, appendAudit(dispute.audit_log, audit), disputeId);
|
|
87
|
+
if (cas.changes === 0)
|
|
88
|
+
throw new DcResolveError('ALREADY_RULED', '本案已被裁决(并发抢占)', 409);
|
|
89
|
+
// ── ③④ 终态转移 + 结算(用 SYS 执行状态机移动;每步失败即 throw 回滚)──
|
|
90
|
+
const orderId = order.id;
|
|
91
|
+
if (effectiveDecision === 'decline_no_fault_upheld') {
|
|
92
|
+
const t1 = transition(db, orderId, 'declined_nofault', SYS, [], `拒单举证仲裁维持无责:${reason}`);
|
|
93
|
+
if (!t1.success)
|
|
94
|
+
throw new DcResolveError('TRANSITION_FAILED', `转 declined_nofault 失败:${t1.error}`, 500);
|
|
95
|
+
settleDeclinedNoFault(db, orderId); // 全退买家 + 退卖家质押 + 回补库存(settled_fault_at 幂等)
|
|
96
|
+
const t2 = transition(db, orderId, 'completed', SYS, [], '客观无责裁定结算完成');
|
|
97
|
+
if (!t2.success)
|
|
98
|
+
throw new DcResolveError('TRANSITION_FAILED', `转 completed 失败:${t2.error}`, 500);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
settleFault(db, orderId, 'fault_seller'); // 退款买家 + 罚没卖家质押(settled_fault_at 幂等)
|
|
102
|
+
const t = transition(db, orderId, 'completed', SYS, [], `拒单举证仲裁驳回·判卖家违约:${reason}`);
|
|
103
|
+
if (!t.success)
|
|
104
|
+
throw new DcResolveError('TRANSITION_FAILED', `转 completed 失败:${t.error}`, 500);
|
|
105
|
+
}
|
|
106
|
+
db.prepare('UPDATE orders SET decline_objective_pending=0, decline_contested=0 WHERE id=?').run(orderId);
|
|
107
|
+
return { orderId, decision: effectiveDecision, source, buyerId: order.buyer_id, sellerId: order.seller_id };
|
|
108
|
+
});
|
|
109
|
+
return run();
|
|
110
|
+
}
|