@seasonkoh/webaz 0.1.30 → 0.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -308,22 +308,21 @@ export async function handlePair(args) {
308
308
  };
309
309
  }
310
310
  if (action === 'verify') {
311
- // Consume the stored grant against a SAFE grant-gated route (read_public). The SERVER is authoritative:
312
- // it re-checks active/expiry/revoked/subject-suspension/scope + audits on EVERY call. We resolve the
313
- // bearer from the secret store and attach it; the raw token is never printed. Safe scopes only — no
314
- // business tool and no risk scope is wired to grants here.
311
+ // Ask the server for the FULL grant (all authorized scopes/bundle/expiry/status), not just one capability.
312
+ // The SERVER is authoritative: it re-checks active/expiry/revoked/subject-suspension + audits on EVERY call.
313
+ // We resolve the bearer from the secret store and attach it; the raw token is never printed.
315
314
  const cred = resolveGrantCredential();
316
315
  if (!cred)
317
316
  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/whoami', { method: 'GET', apiKey: cred.token });
317
+ const resp = await apiCall('/api/agent-grants/verify', { method: 'GET', apiKey: cred.token });
319
318
  if (resp.error) {
320
319
  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
320
  }
322
321
  return {
323
322
  status: 'active',
324
- grant: resp.grant, // SERVER-AUTHORITATIVE principal (grant_id/human_id/agent_label/capability)
323
+ grant: resp.grant, // SERVER-AUTHORITATIVE principal: grant_id/human_id/agent_label + ALL scopes[]/permission_bundle/expiry/status
325
324
  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/scope + audited). `grant` is authoritative; `local_cache` is advisory. Safe scopes only; no business tool or risk scope consumes this grant.',
325
+ 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
326
  };
328
327
  }
329
328
  if (action === 'request') {
@@ -654,7 +653,7 @@ Skipping is allowed but agent then carries price/stock-race risk itself.`,
654
653
  // was ~1336 chars, now ~650 chars
655
654
  description: `⚠️ **"list" = PUBLISH** (verb), NOT "list out". Seller-only catalog publish + manage.
656
655
 
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). Requires seller api_key. On create: system auto-suggests stake ~15% of price (buyer protection).
656
+ 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
657
 
659
658
  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
659
 
@@ -671,7 +670,7 @@ Actions: create (title/description/price) | mine | update (product_id + changed
671
670
  product_id: { type: 'string', description: 'Product ID (required for update/delist/relist/trash/delete)' },
672
671
  title: { type: 'string', description: 'Product name (required for create; optional for update)' },
673
672
  description: { type: 'string', description: 'Product description (required for create; optional for update)' },
674
- price: { type: 'number', description: 'Price in WAZ (required for create; optional for update)' },
673
+ 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
674
  stock: { type: 'number', description: 'Stock, default 1' },
676
675
  category: { type: 'string', description: 'Category (optional)' },
677
676
  specs: {
@@ -724,9 +723,14 @@ Actions: create (title/description/price) | mine | update (product_id + changed
724
723
  {
725
724
  name: 'webaz_place_order',
726
725
  // was ~1117 chars, now ~580 chars
727
- description: `Buyer places order. Buyer api_key required. Funds auto-enter protocol escrow.
726
+ description: `Buyer places order. Buyer api_key required.
728
727
 
729
- Order deadlines (absolute ISO timestamps in response):
728
+ Payment rail (chosen here, at purchase — default: escrow):
729
+ - **escrow** (default, legacy): funds auto-enter protocol escrow; the amount is the WAZ/protocol-unit price. The deadlines/auto-judge below apply.
730
+ - **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).
731
+ - onchain_full_stake / psp: PLANNED, not enabled → passing them returns PAYMENT_RAIL_DISABLED.
732
+
733
+ Order deadlines (escrow rail; absolute ISO timestamps in response):
730
734
  - accept T+48h | ship T+120h (72h after accept)
731
735
  - pickup T+168h (48h after ship) | delivery T+336h (7d after pickup)
732
736
  - confirm T+408h (72h after delivery)
@@ -743,6 +747,8 @@ Options:
743
747
  api_key: { type: 'string', description: "Buyer's api_key (or omit and set the WEBAZ_API_KEY env var)" },
744
748
  product_id: { type: 'string', description: 'Product ID to buy (from webaz_search)' },
745
749
  quantity: { type: 'number', description: 'Quantity, default 1' },
750
+ 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.' },
751
+ 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
752
  shipping_address: { type: 'string', description: 'Shipping address' },
747
753
  notes: { type: 'string', description: 'Note to seller (optional)' },
748
754
  session_token: {
@@ -1584,7 +1590,7 @@ Enums: **category** phone/computer/appliance/furniture/clothing/book/toy/sports/
1584
1590
  description: { type: 'string', description: 'Description (≤1000 chars, optional)' },
1585
1591
  category: { type: 'string', enum: ['phone', 'computer', 'appliance', 'furniture', 'clothing', 'book', 'toy', 'sports', 'other'], description: 'Category (required for publish)' },
1586
1592
  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: 'Price in WAZ 0-100000 (required for publish)' },
1593
+ 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
1594
  negotiable: { type: 'boolean', description: 'Negotiable flag (optional)' },
1589
1595
  images: { type: 'array', items: { type: 'string' }, description: 'Images dataURL/URL array, ≥1 ≤9 (required for publish)' },
1590
1596
  region: { type: 'string', description: 'Region (≤40 chars, optional)' },
@@ -2442,12 +2448,43 @@ async function handleVerifyPrice(args) {
2442
2448
  next: `调用 webaz_place_order 时传入 session_token="${token}" 确保以此价格成交`,
2443
2449
  };
2444
2450
  }
2445
- async function handleListProduct(args) {
2451
+ export async function handleListProduct(args) {
2446
2452
  // Wave 3 audit P0: 加 action 分发 — agent 卖家能完整管理目录(不止 create)
2447
2453
  const action = args.action || 'create';
2448
2454
  const apiKey = resolveMcpApiKey(args);
2449
- if (!apiKey)
2450
- return { error: 'api_key required' };
2455
+ if (!apiKey) {
2456
+ // No human api_key fall back to a delegation grant (Catalog Agent). A grant can READ the catalog
2457
+ // (action="mine", seller_products_read) and create DRAFTS (action="create"|"draft", seller_product_draft
2458
+ // → status=warehouse, NOT public). Publishing (active) + all other writes stay human/api_key only.
2459
+ // Missing scope → structured PERMISSION_REQUIRED (agent can request it, then retry).
2460
+ if (!isNetworkMode())
2461
+ return { error: 'a delegation grant requires NETWORK mode (grants live on webaz.xyz)', error_code: 'GRANT_REQUIRES_NETWORK' };
2462
+ const cred = resolveGrantCredential();
2463
+ if (!cred)
2464
+ 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' };
2465
+ if (action === 'mine') {
2466
+ const r = await apiCall('/api/agent/seller/products', { method: 'GET', apiKey: cred.token });
2467
+ if (r.error_code === 'PERMISSION_REQUIRED')
2468
+ 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.' };
2469
+ if (r.error)
2470
+ return r;
2471
+ 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).' };
2472
+ }
2473
+ if (action === 'create' || action === 'draft') {
2474
+ 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'];
2475
+ const body = {};
2476
+ for (const k of createFields)
2477
+ if (args[k] !== undefined)
2478
+ body[k] = args[k];
2479
+ const r = await apiCall('/api/agent/seller/products', { method: 'POST', apiKey: cred.token, body });
2480
+ if (r.error_code === 'PERMISSION_REQUIRED')
2481
+ 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.' };
2482
+ if (r.error)
2483
+ return r;
2484
+ 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.' };
2485
+ }
2486
+ 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.` };
2487
+ }
2451
2488
  // RFC-003 P2b: NETWORK 模式 — 卖家目录管理全部转发生产端点(单一真相源)
2452
2489
  if (toolBackend('webaz_list_product') === 'network') {
2453
2490
  const pid = args.product_id;
@@ -2610,9 +2647,20 @@ async function handleListProduct(args) {
2610
2647
  ...(extraResult ? { extra_fields_applied: !('error' in extraResult), extra_result: extraResult } : {}),
2611
2648
  };
2612
2649
  }
2613
- async function handlePlaceOrder(args) {
2650
+ export async function handlePlaceOrder(args) {
2651
+ // Payment rail (PWA-aligned). Only escrow (legacy) + direct_p2p (live, non-custodial) are enabled; the
2652
+ // other declared rails (onchain_full_stake / psp) are PLANNED placeholders — refuse them here, never
2653
+ // silently downgrade. The MCP does NOT judge direct-pay eligibility: it forwards payment_rail +
2654
+ // direct_receive_account_id to the SAME /api/orders route, which runs all the PWA gates.
2655
+ const rail = args.payment_rail;
2656
+ if (rail != null && rail !== 'escrow' && rail !== 'direct_p2p') {
2657
+ 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' };
2658
+ }
2659
+ if (rail === 'direct_p2p' && !isNetworkMode()) {
2660
+ 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' };
2661
+ }
2614
2662
  // RFC-003 P2: network 模式转发到生产 POST /api/orders(前置,绕过本地 db)。
2615
- // 生产端做完整鉴权/库存/session/spend-cap/结算。
2663
+ // 生产端做完整鉴权/库存/session/spend-cap/结算 + direct-pay gate(createDirectPayResponse)。
2616
2664
  if (toolBackend('webaz_place_order') === 'network') {
2617
2665
  const body = { product_id: args.product_id, quantity: Number(args.quantity ?? 1) };
2618
2666
  if (args.session_token != null)
@@ -2623,6 +2671,10 @@ async function handlePlaceOrder(args) {
2623
2671
  body.shipping_address = args.shipping_address;
2624
2672
  if (args.donation_pct != null)
2625
2673
  body.donation_pct = args.donation_pct;
2674
+ if (args.payment_rail != null)
2675
+ body.payment_rail = args.payment_rail; // escrow | direct_p2p (route forks + gates)
2676
+ if (args.direct_receive_account_id != null)
2677
+ body.direct_receive_account_id = args.direct_receive_account_id; // seller receiving acct (direct_p2p)
2626
2678
  return apiCall('/api/orders', { method: 'POST', apiKey: resolveMcpApiKey(args), body });
2627
2679
  }
2628
2680
  const auth = requireAuth(db, resolveMcpApiKey(args));
@@ -3,7 +3,7 @@
3
3
  "info": {
4
4
  "title": "WebAZ Protocol API",
5
5
  "version": "0.4.14",
6
- "description": "Auto-generated endpoint inventory (776 endpoints, 17 with full schema). See docs/ for design context."
6
+ "description": "Auto-generated endpoint inventory (777 endpoints, 17 with full schema). See docs/ for design context."
7
7
  },
8
8
  "servers": [
9
9
  {
@@ -2774,6 +2774,16 @@
2774
2774
  }
2775
2775
  ],
2776
2776
  "x-webaz-grant-scope": "seller_products_read"
2777
+ },
2778
+ "post": {
2779
+ "summary": "POST /api/agent/seller/products",
2780
+ "tags": [],
2781
+ "security": [
2782
+ {
2783
+ "grantBearer": []
2784
+ }
2785
+ ],
2786
+ "x-webaz-grant-scope": "seller_product_draft"
2777
2787
  }
2778
2788
  },
2779
2789
  "/api/agent/verifiability": {
@@ -32,7 +32,7 @@ function consentView(p) {
32
32
  };
33
33
  }
34
34
  export function registerAgentGrantsRoutes(app, deps) {
35
- const { db, auth, generateId, rateLimitOk, requireHumanPresence } = deps;
35
+ const { db, auth, generateId, rateLimitOk, requireHumanPresence, createProductDraftHandler } = deps;
36
36
  // PWA runtime self-init (MCP gets the tables via applyWebazRuntimeSchema). Idempotent.
37
37
  initAgentDelegationGrantsSchema(db);
38
38
  initAgentPairingSchema(db);
@@ -86,7 +86,9 @@ export function registerAgentGrantsRoutes(app, deps) {
86
86
  error: `this action needs the "${scope}" permission, which your grant does not carry`,
87
87
  error_code: 'PERMISSION_REQUIRED',
88
88
  required_scope: scope,
89
+ missing_scopes: [scope],
89
90
  approval_url: '/#agent-approvals',
91
+ retry_after_approval: true,
90
92
  request_permission: { method: 'POST', endpoint: '/api/agent-grants/permission-requests', body: { scopes: [scope] } },
91
93
  note: 'Ask the human to approve at approval_url; on approval your existing grant is expanded — then retry this request.',
92
94
  });
@@ -115,6 +117,32 @@ export function registerAgentGrantsRoutes(app, deps) {
115
117
  const rows = await dbAll("SELECT id, title, status, price, currency, stock, category, created_at, updated_at FROM products WHERE seller_id = ? AND status != 'deleted' ORDER BY created_at DESC LIMIT 200", [p.human_id]);
116
118
  res.json({ seller_id: p.human_id, agent_label: p.agent_label, count: rows.length, products: rows, note: 'Seller-owned catalog read via delegation grant (safe scope seller_products_read). Read-only; no money/commission fields.' });
117
119
  });
120
+ // POST create a DRAFT product via a delegation grant (Catalog Agent, safe scope seller_product_draft). The
121
+ // draft is FORCED to status='warehouse' (not public/sellable). PUBLISHING STAYS HUMAN-ONLY — the human
122
+ // flips warehouse→active in the existing 我的商品→仓库 UI (publish is never delegated to a grant, matching
123
+ // the taxonomy). Reuses the SAME product-create validation as the human POST /api/products
124
+ // (createProductDraftHandler) so the agent path can't drift. Audited by the middleware. Lightweight signal:
125
+ // a notification tells the human a draft is waiting to review + publish.
126
+ if (createProductDraftHandler) {
127
+ app.post('/api/agent/seller/products', requireAgentGrantScope('seller_product_draft'), async (req, res) => {
128
+ const p = req.agentGrant;
129
+ const human = await dbOne('SELECT id, role FROM users WHERE id = ?', [p.human_id]);
130
+ if (!human || human.role !== 'seller')
131
+ return void res.status(403).json({ error: 'the grant owner is not a seller — product drafts need a seller account', error_code: 'NOT_A_SELLER' });
132
+ await createProductDraftHandler(req, res, human, {
133
+ forceStatus: 'warehouse',
134
+ skipExternalLinkEffects: true, // a SAFE draft grant must never trigger wallet debit / verify_tasks / auto-verified links (source_url stays inert metadata)
135
+ onCreated: async (productId) => {
136
+ try {
137
+ await dbRun("INSERT INTO notifications (id, user_id, type, title, body) VALUES (?,?,?,?,?)", [generateId('ntf'), p.human_id, 'agent_product_draft', 'AI 助手起草了商品草稿', `${p.agent_label || 'An agent'} 起草了商品草稿 ${productId},请到「我的商品 → 仓库」审核并发布。`]);
138
+ }
139
+ catch (e) {
140
+ console.error('[agent-grant draft notify]', e.message);
141
+ }
142
+ },
143
+ });
144
+ });
145
+ }
118
146
  // helpers for the permission-request flow ----------------------------------------------------------------
119
147
  const parseCapList = (json) => { try {
120
148
  const a = JSON.parse(json);
@@ -149,7 +177,7 @@ export function registerAgentGrantsRoutes(app, deps) {
149
177
  // Fail closed: a grant-authorized read is audited or it does not proceed (parity with requireAgentGrantScope).
150
178
  if (!(await auditGrant(g.grant_id, g.human_id, 'grant:verify', 'allow')))
151
179
  return void res.status(503).json({ error: 'authorization audit unavailable; refusing to proceed unaudited', error_code: 'GRANT_AUDIT_FAILED' });
152
- res.json({ grant: { grant_id: full.grant_id, agent_label: full.agent_label, scopes: scopeNames(full.capabilities), permission_bundle: full.permission_bundle, expires_at: full.expires_at, status: full.status }, note: 'Full grant principal — scopes/bundle/expiry/status. Not a human session; never authorizes risk/never-delegable actions.' });
180
+ res.json({ grant: { grant_id: full.grant_id, human_id: full.human_id, agent_label: full.agent_label, scopes: scopeNames(full.capabilities), permission_bundle: full.permission_bundle, expires_at: full.expires_at, status: full.status }, note: 'Full grant principal — all authorized scopes/bundle/expiry/status. Not a human session; never authorizes risk/never-delegable actions.' });
153
181
  });
154
182
  // POST create a permission request — the AGENT (holding its current grant) asks for MORE scope / a bundle.
155
183
  // Bound to (human_id, grant_id) from the grant bearer. Rate-limited. Safe-only: risk/never-delegable are
@@ -1,12 +1,12 @@
1
1
  import { dbOne, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
2
- export function registerProductsCreateRoutes(app, deps) {
3
- const { db, auth, generateId, checkSellerCanList, getStakeDiscount, VALID_PRODUCT_TYPES, parsePlatformUrl, makeCommitmentHash, makeDescriptionHash, makePriceHash } = deps;
4
- app.post('/api/products', async (req, res) => {
5
- const user = auth(req, res);
6
- if (!user)
7
- return;
8
- if (user.role !== 'seller')
9
- return void res.json({ error: '仅卖家可上架商品' });
2
+ /**
3
+ * The SINGLE source of product-create validation + insert. res-coupled but auth-agnostic: the caller resolves
4
+ * `user` (the human api_key route, OR the delegation-grant draft route) and may force `warehouse` status. Both
5
+ * the human `POST /api/products` and the grant-gated draft route MUST go through this — no parallel copy.
6
+ */
7
+ export function makeCreateProductHandler(deps) {
8
+ const { db, generateId, checkSellerCanList, getStakeDiscount, VALID_PRODUCT_TYPES, parsePlatformUrl, makeCommitmentHash, makeDescriptionHash, makePriceHash } = deps;
9
+ return async function createProductHandler(req, res, user, opts = {}) {
10
10
  // 里程碑 3-D:1h 上架限速(防 spam 批量上架)
11
11
  const LISTING_RATE_LIMIT = 5;
12
12
  const recentListings = (await dbOne(`
@@ -55,7 +55,7 @@ export function registerProductsCreateRoutes(app, deps) {
55
55
  return void res.json({ error: 'commission_rate 必须在 1% - 50% 之间(小数 0.01-0.50)' });
56
56
  }
57
57
  // 上架前检查:同一卖家不能重复关联相同外部链接
58
- if (source_url) {
58
+ if (source_url && !opts.skipExternalLinkEffects) {
59
59
  const sameSellerDupe = (await dbOne(`
60
60
  SELECT COUNT(*) as n FROM product_external_links pel
61
61
  JOIN products p ON pel.product_id = p.id
@@ -94,14 +94,14 @@ export function registerProductsCreateRoutes(app, deps) {
94
94
  ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'WAZ',?,?,?,?,?,?)`, [
95
95
  id, user.id, title, description, priceNum, Number(stock), category, stakeAmount,
96
96
  specsJson, brand ?? null, model ?? null,
97
- source_url ?? null, source_price ? Number(source_price) : null, source_price ? now : null,
97
+ opts.skipExternalLinkEffects ? null : (source_url ?? null), opts.skipExternalLinkEffects ? null : (source_price ? Number(source_price) : null), opts.skipExternalLinkEffects ? null : (source_price ? now : null),
98
98
  weight_kg ? Number(weight_kg) : null, ship_regions, Number(handling_hours), estJson, fragile ? 1 : 0,
99
99
  Number(return_days), return_condition, Number(warranty_days),
100
100
  Math.max(0, Math.floor(Number(low_stock_threshold) || 0)), auto_delist_on_zero ? 1 : 0,
101
101
  makeCommitmentHash(pFields), makeDescriptionHash({ title, description, specs: specsJson }),
102
102
  makePriceHash(priceNum, now), now,
103
103
  commissionRateNum, product_type, imagesJsonForInsert,
104
- _tx(package_size, 40), _cc(origin_country), _cc(country_of_origin), _tx(customs_description, 120), _hs, create_status === 'warehouse' ? 'warehouse' : 'active'
104
+ _tx(package_size, 40), _cc(origin_country), _cc(country_of_origin), _tx(customs_description, 120), _hs, (opts.forceStatus === 'warehouse' || create_status === 'warehouse') ? 'warehouse' : 'active'
105
105
  ]);
106
106
  // M7.2.6:免质押上架 — 不再扣 stake;首单成交时 settleOrder 自动从订单 escrow 锁定
107
107
  // M7.2-6: 上架时同步入 aliases(卖家已勾选的 candidates)
@@ -128,7 +128,7 @@ export function registerProductsCreateRoutes(app, deps) {
128
128
  }
129
129
  // 来源链接:冲突检测
130
130
  let linkConflict = null;
131
- if (source_url) {
131
+ if (source_url && !opts.skipExternalLinkEffects) {
132
132
  // 另一家卖家已认领此链接(verified=1)
133
133
  const otherClaim = await dbOne(`
134
134
  SELECT pel.product_id FROM product_external_links pel
@@ -183,7 +183,7 @@ export function registerProductsCreateRoutes(app, deps) {
183
183
  // 额外链接:同步冲突检查(最多 5 个)
184
184
  const additionalLinks = req.body.additional_links;
185
185
  const blockedLinks = [];
186
- if (Array.isArray(additionalLinks) && additionalLinks.length > 0) {
186
+ if (!opts.skipExternalLinkEffects && Array.isArray(additionalLinks) && additionalLinks.length > 0) {
187
187
  for (const extraUrl of additionalLinks.slice(0, 5)) {
188
188
  if (typeof extraUrl !== 'string' || !extraUrl.startsWith('http'))
189
189
  continue;
@@ -220,13 +220,33 @@ export function registerProductsCreateRoutes(app, deps) {
220
220
  catch { }
221
221
  }
222
222
  }
223
+ if (opts.onCreated) {
224
+ try {
225
+ await opts.onCreated(id);
226
+ }
227
+ catch (e) {
228
+ console.error('[products-create onCreated]', e.message);
229
+ }
230
+ }
223
231
  res.json({
224
232
  success: true,
225
233
  product_id: id,
234
+ status: (opts.forceStatus === 'warehouse' || create_status === 'warehouse') ? 'warehouse' : 'active',
226
235
  stake_locked: 0, // 免质押上架(M7.2.6 方案 3)
227
236
  stake_deferred: stakeAmount, // 首单成交时自动锁定(trusted+ 跳过)
228
237
  ...(linkConflict ? { link_conflict: linkConflict } : {}),
229
238
  ...(blockedLinks.length > 0 ? { blocked_links: blockedLinks } : {}),
230
239
  });
240
+ };
241
+ }
242
+ export function registerProductsCreateRoutes(app, deps) {
243
+ const createProductHandler = makeCreateProductHandler(deps);
244
+ app.post('/api/products', async (req, res) => {
245
+ const user = deps.auth(req, res);
246
+ if (!user)
247
+ return;
248
+ if (user.role !== 'seller')
249
+ return void res.json({ error: '仅卖家可上架商品' });
250
+ await createProductHandler(req, res, user);
231
251
  });
232
252
  }
@@ -196,5 +196,5 @@ export function registerWebauthnRoutes(app, deps) {
196
196
  // RFC-020 PR-B — agent delegation grants (issue/read/revoke). Co-registered with the
197
197
  // Passkey security routes so the money-dense server.ts stays untouched. Safe scopes
198
198
  // only; risk scopes default-hard-reject. Reuses db/auth/generateId from WebauthnDeps.
199
- registerAgentGrantsRoutes(app, { db, auth, generateId, rateLimitOk, requireHumanPresence });
199
+ registerAgentGrantsRoutes(app, { db, auth, generateId, rateLimitOk, requireHumanPresence, createProductDraftHandler: deps.createProductDraftHandler });
200
200
  }
@@ -283,7 +283,7 @@ import { registerProductsCrudRoutes } from './routes/products-crud.js';
283
283
  // Products PUT update (#1013 Phase 93) — 1 endpoint (123 行)
284
284
  import { registerProductsUpdateRoutes } from './routes/products-update.js';
285
285
  // Products POST create (#1013 Phase 94) — 1 endpoint (232 行)
286
- import { registerProductsCreateRoutes } from './routes/products-create.js';
286
+ import { registerProductsCreateRoutes, makeCreateProductHandler } from './routes/products-create.js';
287
287
  // Products GET list (#1013 Phase 95) — 1 endpoint (399 行)
288
288
  import { registerProductsListRoutes } from './routes/products-list.js';
289
289
  // P2P 商品 (#1013 Phase 96) — 5 endpoints
@@ -4091,8 +4091,8 @@ app.use((req, res, next) => {
4091
4091
  if (req.method === 'GET' && /^\/api\/(info|leaderboard|payment-methods|governance|openapi)/.test(req.path))
4092
4092
  return next();
4093
4093
  const apiKey = req.headers.authorization?.replace('Bearer ', '');
4094
- if (!apiKey)
4095
- return next(); // 无 key 走原 IP 速率
4094
+ if (!apiKey || apiKey.startsWith('gtk_'))
4095
+ return next(); // 无 key→IP 速率;gtk_* 委托凭证(RFC-020)是独立自守 auth 通道(requireAgentGrantScope/resolveActiveGrantByBearer 管 safe-scope+active/expiry/revoked+audit),非 api_key,不进下面 agent 声明/风控/strike;否则合法 safe-scope grant 写(如 POST /api/agent-grants/permission-requests)被误判 AGENT_SCOPE_UNDECLARED。gtk_* 只命中 grant-aware 路由,别处 auth() 自然 401。
4096
4096
  // 2026-05-23 P1 fix:被封禁用户仍可走以下路径(看自己 agent 状态 + 申诉)
4097
4097
  // - GET /api/me/agents 系列(看强 / 申诉前必查)
4098
4098
  // - POST /api/me/agents/strikes/:id/appeal(申诉权不能被封禁阻断)
@@ -7870,7 +7870,7 @@ registerWebauthnRoutes(app, {
7870
7870
  origin: WEBAUTHN_ORIGIN,
7871
7871
  challengeTtlMs: WEBAUTHN_CHALLENGE_TTL_MS,
7872
7872
  gateTtlMs: WEBAUTHN_GATE_TTL_MS,
7873
- invalidateAgentRiskCacheForUser,
7873
+ invalidateAgentRiskCacheForUser, createProductDraftHandler: makeCreateProductHandler({ db, auth, generateId, checkSellerCanList, getStakeDiscount, VALID_PRODUCT_TYPES, parsePlatformUrl, makeCommitmentHash, makeDescriptionHash, makePriceHash }), // RFC-020 PR-4: grant-gated warehouse draft reuses the human create logic
7874
7874
  requireHumanPresence, // #1044 — DELETE passkey 自身需 token
7875
7875
  });
7876
7876
  // consumeGateToken / requireHumanPresence 已抽出到 ./human-presence.ts(PR-F0,behavior-zero,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seasonkoh/webaz",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
4
4
  "description": "[PRE-LAUNCH] Agent-native decentralized commerce protocol. Humans and AI agents trade on the same protocol via MCP tools. ⚠️ Repository currently private until W8 public launch — GitHub links may return 404. See https://webaz.xyz for status.",
5
5
  "main": "dist/mcp.js",
6
6
  "bin": {
@@ -88,6 +88,8 @@
88
88
  "test:agent-permission-requests": "tsx scripts/test-agent-permission-requests.ts",
89
89
  "test:agent-permission-consume": "tsx scripts/test-agent-permission-consume.ts",
90
90
  "test:agent-approvals-ui": "tsx scripts/test-agent-approvals-ui.ts",
91
+ "test:mcp-place-order-rail": "tsx scripts/test-mcp-place-order-rail.ts",
92
+ "test:agent-product-draft": "tsx scripts/test-agent-product-draft.ts",
91
93
  "test:agent-grant-verify": "tsx scripts/test-agent-grant-verify.ts",
92
94
  "test:agent-grant-mcp-consume": "tsx scripts/test-agent-grant-mcp-consume.ts",
93
95
  "test:connected-agents-read": "tsx scripts/test-connected-agents-read.ts",