@seasonkoh/webaz 0.1.24 → 0.1.25
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 +2 -0
- package/dist/layer0-foundation/L0-1-database/db-backends/pg-backend.js +51 -0
- package/dist/layer0-foundation/L0-1-database/db-backends/sql-dialect-datetime.js +437 -0
- package/dist/layer0-foundation/L0-1-database/db-backends/sql-placeholders.js +98 -0
- package/dist/layer0-foundation/L0-1-database/db.js +65 -0
- package/dist/layer0-foundation/L0-2-state-machine/order-chain.js +13 -11
- package/dist/layer0-foundation/L0-2-state-machine/transitions.js +1 -1
- package/dist/layer0-foundation/L0-5-manifest/manifest.js +13 -11
- package/dist/layer1-agent/L1-1-mcp-server/server.js +165 -64
- package/dist/layer1-agent/L1-2-external-anchor/anchor-engine.js +14 -12
- package/dist/layer2-business/L2-6-notifications/notification-engine.js +8 -5
- package/dist/layer2-business/L2-7-snf/snf-engine.js +16 -14
- package/dist/layer2-business/L2-8-feedback/build-feedback-engine.js +18 -10
- package/dist/layer2-business/L2-9-contribution/build-reputation-engine.js +37 -23
- package/dist/layer2-business/L2-9-contribution/build-task-agent-metadata-store.js +173 -0
- package/dist/layer2-business/L2-9-contribution/build-task-participation.js +47 -0
- package/dist/layer2-business/L2-9-contribution/build-task-read.js +222 -0
- package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +10 -2
- package/dist/layer2-business/L2-9-contribution/canonical-contribution-target.js +16 -0
- package/dist/layer2-business/L2-9-contribution/contribution-display-envelope.js +40 -0
- package/dist/layer2-business/L2-9-contribution/contribution-score-contract.js +36 -0
- package/dist/layer2-business/L2-9-contribution/contribution-score-evidence.js +61 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/canonical.js +60 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/github-credential.schema.js +140 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/github-fetch-adapter.js +437 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/self-consistency.js +38 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/verifier.js +231 -0
- package/dist/layer2-business/L2-9-contribution/github-credential-ingestion-engine.js +145 -0
- package/dist/layer2-business/L2-9-contribution/github-credential-store.js +115 -0
- package/dist/layer2-business/L2-9-contribution/identity-binding-engine.js +134 -0
- package/dist/layer2-business/L2-9-contribution/identity-binding-store.js +101 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-challenge-engine.js +126 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-challenge-store.js +30 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-engine.js +109 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-fact-precondition.js +22 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-proof-verifier.js +97 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-read.js +59 -0
- package/dist/layer2-business/L2-9-contribution/task-proposal-store.js +129 -0
- package/dist/layer2-business/L2-notes/note-photo-storage.js +4 -2
- package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +17 -15
- package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +11 -8
- package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +9 -8
- package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +11 -8
- package/dist/layer4-economics/L4-4-skill-market/skill-listing-engine.js +22 -16
- package/dist/pwa/acp-feed.js +13 -1
- package/dist/pwa/contract-fingerprint.js +2 -0
- package/dist/pwa/endpoint-actions.js +5 -1
- package/dist/pwa/goal-index.js +8 -8
- package/dist/pwa/human-presence.js +62 -0
- package/dist/pwa/public/app.js +575 -68
- package/dist/pwa/public/i18n.js +29 -20
- package/dist/pwa/public/index.html +1 -0
- package/dist/pwa/public/openapi.json +2 -2
- package/dist/pwa/rate-limit.js +22 -0
- package/dist/pwa/routes/account-deletion.js +15 -13
- package/dist/pwa/routes/addresses.js +10 -9
- package/dist/pwa/routes/admin-admins.js +13 -14
- package/dist/pwa/routes/admin-analytics.js +109 -69
- package/dist/pwa/routes/admin-catalog.js +13 -11
- package/dist/pwa/routes/admin-editor-picks.js +15 -10
- package/dist/pwa/routes/admin-events.js +5 -3
- package/dist/pwa/routes/admin-health.js +2 -1
- package/dist/pwa/routes/admin-moderation.js +26 -29
- package/dist/pwa/routes/admin-ops.js +22 -21
- package/dist/pwa/routes/admin-protocol-params.js +16 -19
- package/dist/pwa/routes/admin-reports.js +23 -21
- package/dist/pwa/routes/admin-tokenomics.js +26 -25
- package/dist/pwa/routes/admin-users-lifecycle.js +37 -40
- package/dist/pwa/routes/admin-users-query.js +54 -53
- package/dist/pwa/routes/admin-verifier-flow.js +82 -41
- package/dist/pwa/routes/admin-verifier-whitelist.js +55 -27
- package/dist/pwa/routes/admin-wallet-ops.js +7 -5
- package/dist/pwa/routes/agent-buy.js +46 -22
- package/dist/pwa/routes/agent-governance.js +52 -56
- package/dist/pwa/routes/ai.js +7 -5
- package/dist/pwa/routes/analytics.js +43 -41
- package/dist/pwa/routes/anchors.js +19 -20
- package/dist/pwa/routes/announcements.js +13 -13
- package/dist/pwa/routes/arbitrator.js +97 -31
- package/dist/pwa/routes/auction.js +153 -114
- package/dist/pwa/routes/auth-login.js +6 -4
- package/dist/pwa/routes/auth-read.js +11 -9
- package/dist/pwa/routes/auth-register.js +35 -20
- package/dist/pwa/routes/auth-sessions.js +12 -11
- package/dist/pwa/routes/blocklist.js +16 -15
- package/dist/pwa/routes/build-feedback.js +10 -9
- package/dist/pwa/routes/build-reputation.js +6 -2
- package/dist/pwa/routes/build-tasks.js +45 -13
- package/dist/pwa/routes/buyer-feeds.js +27 -25
- package/dist/pwa/routes/cart.js +16 -15
- package/dist/pwa/routes/charity.js +212 -150
- package/dist/pwa/routes/chat.js +42 -43
- package/dist/pwa/routes/checkin-tasks.js +10 -9
- package/dist/pwa/routes/checkout-helpers.js +12 -10
- package/dist/pwa/routes/claim-initiators.js +34 -14
- package/dist/pwa/routes/claim-verify.js +86 -53
- package/dist/pwa/routes/claim-voting.js +43 -18
- package/dist/pwa/routes/contribution-identity.js +147 -0
- package/dist/pwa/routes/contribution-score.js +19 -0
- package/dist/pwa/routes/coupons.js +19 -16
- package/dist/pwa/routes/dashboards.js +18 -16
- package/dist/pwa/routes/dispute-cases.js +25 -24
- package/dist/pwa/routes/disputes-read.js +45 -51
- package/dist/pwa/routes/disputes-write.js +124 -61
- package/dist/pwa/routes/evidence.js +9 -9
- package/dist/pwa/routes/external-anchors.js +13 -12
- package/dist/pwa/routes/feedback.js +29 -33
- package/dist/pwa/routes/flash-sales.js +18 -16
- package/dist/pwa/routes/follows.js +25 -24
- package/dist/pwa/routes/governance-auto-deactivate.js +21 -9
- package/dist/pwa/routes/governance-onboarding.js +70 -59
- package/dist/pwa/routes/group-buys.js +22 -22
- package/dist/pwa/routes/growth.js +33 -30
- package/dist/pwa/routes/import-product.js +12 -10
- package/dist/pwa/routes/kyc.js +9 -8
- package/dist/pwa/routes/leaderboard.js +20 -18
- package/dist/pwa/routes/listings.js +23 -22
- package/dist/pwa/routes/logistics.js +10 -8
- package/dist/pwa/routes/manifests.js +27 -27
- package/dist/pwa/routes/me-data.js +23 -21
- package/dist/pwa/routes/notifications.js +7 -6
- package/dist/pwa/routes/offers.js +30 -12
- package/dist/pwa/routes/orders-action.js +33 -17
- package/dist/pwa/routes/orders-create.js +75 -20
- package/dist/pwa/routes/orders-read.js +21 -20
- package/dist/pwa/routes/p2p-products.js +30 -18
- package/dist/pwa/routes/payments-governance.js +61 -56
- package/dist/pwa/routes/peers.js +9 -8
- package/dist/pwa/routes/pin-receipts.js +13 -13
- package/dist/pwa/routes/products-aliases.js +12 -10
- package/dist/pwa/routes/products-claims.js +36 -17
- package/dist/pwa/routes/products-create.js +53 -38
- package/dist/pwa/routes/products-crud.js +17 -16
- package/dist/pwa/routes/products-links.js +49 -26
- package/dist/pwa/routes/products-list.js +6 -4
- package/dist/pwa/routes/products-meta.js +40 -39
- package/dist/pwa/routes/products-update.js +19 -5
- package/dist/pwa/routes/profile-credentials.js +14 -16
- package/dist/pwa/routes/profile-identity.js +14 -13
- package/dist/pwa/routes/profile-location.js +7 -6
- package/dist/pwa/routes/profile-placement.js +19 -17
- package/dist/pwa/routes/profile-prefs.js +11 -11
- package/dist/pwa/routes/promoter.js +55 -49
- package/dist/pwa/routes/public-build-tasks.js +19 -0
- package/dist/pwa/routes/public-utils.js +108 -46
- package/dist/pwa/routes/push.js +16 -15
- package/dist/pwa/routes/ratings.js +30 -30
- package/dist/pwa/routes/recover-key.js +13 -12
- package/dist/pwa/routes/referral.js +37 -32
- package/dist/pwa/routes/reputation.js +3 -2
- package/dist/pwa/routes/returns.js +76 -73
- package/dist/pwa/routes/reviews.js +41 -18
- package/dist/pwa/routes/rewards-apply.js +16 -15
- package/dist/pwa/routes/rewards-auto-downgrade.js +9 -7
- package/dist/pwa/routes/rewards-escrow-expire.js +7 -5
- package/dist/pwa/routes/rfqs.js +163 -85
- package/dist/pwa/routes/search.js +16 -14
- package/dist/pwa/routes/secondhand.js +25 -22
- package/dist/pwa/routes/seller-quota.js +24 -26
- package/dist/pwa/routes/share-redirects.js +59 -55
- package/dist/pwa/routes/shareables-interactions.js +34 -35
- package/dist/pwa/routes/shareables.js +55 -51
- package/dist/pwa/routes/shop-referral.js +57 -0
- package/dist/pwa/routes/shops.js +20 -18
- package/dist/pwa/routes/signaling.js +10 -9
- package/dist/pwa/routes/skill-market.js +16 -16
- package/dist/pwa/routes/skills.js +15 -14
- package/dist/pwa/routes/snf.js +14 -13
- package/dist/pwa/routes/tags.js +10 -9
- package/dist/pwa/routes/task-proposals.js +45 -0
- package/dist/pwa/routes/trial.js +69 -51
- package/dist/pwa/routes/trusted-kpi.js +20 -18
- package/dist/pwa/routes/url-claim.js +67 -28
- package/dist/pwa/routes/users-public.js +62 -60
- package/dist/pwa/routes/variants.js +12 -13
- package/dist/pwa/routes/verifier-user.js +61 -21
- package/dist/pwa/routes/verify-tasks.js +49 -25
- package/dist/pwa/routes/waitlist.js +16 -15
- package/dist/pwa/routes/wallet-read.js +74 -36
- package/dist/pwa/routes/wallet-write.js +12 -9
- package/dist/pwa/routes/webauthn.js +25 -26
- package/dist/pwa/routes/webhooks.js +26 -26
- package/dist/pwa/routes/welcome.js +45 -50
- package/dist/pwa/routes/wishlist-qa.js +29 -32
- package/dist/pwa/server.js +237 -81
- package/dist/version.js +1 -1
- package/package.json +47 -2
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js';
|
|
2
|
+
// RFC-016 Phase 1 — 仅端点纯校验读/列表/公开查询/读回 + 单语句标记/字段写 + 写后通知 → async seam。
|
|
3
|
+
// 全部保持同步(Phase 3 再用 pg tx/行锁):
|
|
4
|
+
// - 模块级 helper(settleClaimTask 三路径结算 / distributePool / checkAndApplyOutlierStrike /
|
|
5
|
+
// notifyEligibleVerifiers / isEligibleClaimVerifier / activeClaimTaskCountForVerifier /
|
|
6
|
+
// processClaimTaskQueue)——settleClaimTask 是裸(非 db.transaction)多写结算序列,
|
|
7
|
+
// 由 vote 端点与 cron 调用,必须整体同步;
|
|
8
|
+
// - claim 发起锁押序列(INSERT task + 锁 stake + has_pending_claim);
|
|
9
|
+
// - vote 共识序列(票数 guard + INSERT vote + 收齐重数 + seal),seal 后同步调 settleClaimTask。
|
|
1
10
|
// ─── 域常量 ───────────────────────────────────────────────
|
|
2
11
|
export const CLAIM_STAKE_DEFAULT = 10; // 买家发起质押 10 WAZ
|
|
3
12
|
export const CLAIM_DEADLINE_HOURS = 48; // 接单 + 投票截止
|
|
@@ -279,11 +288,11 @@ export function processClaimTaskQueue(db, generateId) {
|
|
|
279
288
|
export function registerClaimVerifyRoutes(app, deps) {
|
|
280
289
|
const { db, auth, generateId, requireHumanPresence } = deps;
|
|
281
290
|
// 买家发起 claim 验证任务(绑定 paid 及之后的订单)
|
|
282
|
-
app.post('/api/orders/:id/claim-verification', (req, res) => {
|
|
291
|
+
app.post('/api/orders/:id/claim-verification', async (req, res) => {
|
|
283
292
|
const user = auth(req, res);
|
|
284
293
|
if (!user)
|
|
285
294
|
return;
|
|
286
|
-
const order =
|
|
295
|
+
const order = await dbOne('SELECT * FROM orders WHERE id = ?', [req.params.id]);
|
|
287
296
|
if (!order)
|
|
288
297
|
return void res.status(404).json({ error: '订单不存在' });
|
|
289
298
|
if (order.buyer_id !== user.id)
|
|
@@ -292,7 +301,7 @@ export function registerClaimVerifyRoutes(app, deps) {
|
|
|
292
301
|
if (blockedStatuses.has(order.status)) {
|
|
293
302
|
return void res.status(400).json({ error: `当前订单状态(${order.status})不可发起验证` });
|
|
294
303
|
}
|
|
295
|
-
const existing =
|
|
304
|
+
const existing = await dbOne('SELECT id FROM claim_verification_tasks WHERE order_id = ?', [req.params.id]);
|
|
296
305
|
if (existing)
|
|
297
306
|
return void res.status(409).json({ error: '该订单已存在验证任务(不可撤销)', task_id: existing.id });
|
|
298
307
|
const claim_target = String(req.body?.claim_target || '').trim();
|
|
@@ -304,7 +313,7 @@ export function registerClaimVerifyRoutes(app, deps) {
|
|
|
304
313
|
return void res.status(400).json({ error: 'claim_text 长度需 6-500 字' });
|
|
305
314
|
}
|
|
306
315
|
const evidence_uri = req.body?.evidence_uri ? String(req.body.evidence_uri).trim().slice(0, 500) : null;
|
|
307
|
-
const wallet =
|
|
316
|
+
const wallet = await dbOne('SELECT balance FROM wallets WHERE user_id = ?', [user.id]);
|
|
308
317
|
const stake = CLAIM_STAKE_DEFAULT;
|
|
309
318
|
if (!wallet || wallet.balance < stake) {
|
|
310
319
|
return void res.status(400).json({ error: `余额不足:发起需锁 ${stake} WAZ,当前余额 ${wallet?.balance ?? 0} WAZ` });
|
|
@@ -312,17 +321,43 @@ export function registerClaimVerifyRoutes(app, deps) {
|
|
|
312
321
|
const id = generateId('cvt');
|
|
313
322
|
const deadline = new Date(Date.now() + CLAIM_DEADLINE_HOURS * 3600_000).toISOString();
|
|
314
323
|
const sellerId = order.seller_id;
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
.
|
|
320
|
-
|
|
321
|
-
|
|
324
|
+
// Codex #237 P1:原为裸多写序列(await 预检后直接 3 连写,无 db.transaction、无余额守卫)。
|
|
325
|
+
// 包进 db.transaction + tx 内重检无重复 task + 余额守卫扣押 + 订单 flag CAS;任一失败回滚全部。
|
|
326
|
+
const BLOCKED = ['created', 'cancelled', 'completed', 'refunded'];
|
|
327
|
+
try {
|
|
328
|
+
db.transaction(() => {
|
|
329
|
+
const dup = db.prepare('SELECT id FROM claim_verification_tasks WHERE order_id = ?').get(req.params.id);
|
|
330
|
+
if (dup)
|
|
331
|
+
throw new Error('CLAIM_EXISTS');
|
|
332
|
+
db.prepare(`INSERT INTO claim_verification_tasks
|
|
333
|
+
(id, order_id, buyer_id, seller_id, product_id, claim_target, claim_text, evidence_uri, stake_buyer, deadline_at, status)
|
|
334
|
+
VALUES (?,?,?,?,?,?,?,?,?,?, 'open')`).run(id, req.params.id, user.id, sellerId, order.product_id, claim_target, claim_text, evidence_uri, stake, deadline);
|
|
335
|
+
const d = db.prepare('UPDATE wallets SET balance = balance - ?, escrowed = escrowed + ? WHERE user_id = ? AND balance >= ?')
|
|
336
|
+
.run(stake, stake, user.id, stake);
|
|
337
|
+
if (d.changes !== 1)
|
|
338
|
+
throw new Error('CLAIM_INSUFFICIENT_BALANCE');
|
|
339
|
+
const o = db.prepare(`UPDATE orders SET has_pending_claim = 1 WHERE id = ? AND (has_pending_claim IS NULL OR has_pending_claim != 1) AND status NOT IN ('created','cancelled','completed','refunded')`).run(req.params.id);
|
|
340
|
+
if (o.changes !== 1)
|
|
341
|
+
throw new Error('CLAIM_ORDER_BLOCKED');
|
|
342
|
+
})();
|
|
343
|
+
}
|
|
344
|
+
catch (e) {
|
|
345
|
+
const m = e.message;
|
|
346
|
+
if (m === 'CLAIM_EXISTS')
|
|
347
|
+
return void res.status(409).json({ error: '该订单已存在验证任务(不可撤销)' });
|
|
348
|
+
if (m === 'CLAIM_INSUFFICIENT_BALANCE')
|
|
349
|
+
return void res.status(400).json({ error: `余额不足:发起需锁 ${stake} WAZ` });
|
|
350
|
+
if (m === 'CLAIM_ORDER_BLOCKED')
|
|
351
|
+
return void res.status(400).json({ error: `当前订单状态不可发起验证(${BLOCKED.join('/')} 或已挂验证)` });
|
|
352
|
+
throw e;
|
|
353
|
+
}
|
|
354
|
+
const productTitle = (await dbOne('SELECT title FROM products WHERE id = ?', [order.product_id]))?.title || '—';
|
|
322
355
|
const claimLabel = CLAIM_TARGET_LABEL_ZH[claim_target] || claim_target;
|
|
323
356
|
try {
|
|
324
|
-
|
|
325
|
-
|
|
357
|
+
await dbRun(`INSERT INTO notifications (id, user_id, type, title, body, order_id) VALUES (?,?,?,?,?,?)`, [generateId('ntf'), sellerId, 'claim_new',
|
|
358
|
+
`⚠️ 买家发起验证:${claimLabel}`,
|
|
359
|
+
`订单「${productTitle}」 — 48h 内提交证据可延期至 verifier 共识结案`,
|
|
360
|
+
req.params.id]);
|
|
326
361
|
}
|
|
327
362
|
catch (e) {
|
|
328
363
|
console.error('[V2 notify seller]', e.message);
|
|
@@ -341,25 +376,23 @@ export function registerClaimVerifyRoutes(app, deps) {
|
|
|
341
376
|
res.json({ success: true, task_id: id, deadline_at: deadline, stake_locked: stake });
|
|
342
377
|
});
|
|
343
378
|
// 通过 order_id 查关联 task
|
|
344
|
-
app.get('/api/orders/:id/claim-task', (req, res) => {
|
|
379
|
+
app.get('/api/orders/:id/claim-task', async (req, res) => {
|
|
345
380
|
const user = auth(req, res);
|
|
346
381
|
if (!user)
|
|
347
382
|
return;
|
|
348
|
-
const task =
|
|
349
|
-
.get(req.params.id);
|
|
383
|
+
const task = await dbOne('SELECT * FROM claim_verification_tasks WHERE order_id = ?', [req.params.id]);
|
|
350
384
|
if (!task)
|
|
351
385
|
return void res.json({ task: null });
|
|
352
|
-
const hasVoted =
|
|
353
|
-
.get(task.id, user.id);
|
|
386
|
+
const hasVoted = await dbOne('SELECT id FROM claim_verification_votes WHERE task_id = ? AND verifier_id = ?', [task.id, user.id]);
|
|
354
387
|
const isParty = task.buyer_id === user.id || task.seller_id === user.id;
|
|
355
388
|
const elig = isEligibleClaimVerifier(db, user.id);
|
|
356
389
|
if (!isParty && !hasVoted && !elig.ok)
|
|
357
390
|
return void res.json({ task: null, visibility: 'restricted' });
|
|
358
|
-
const votes =
|
|
391
|
+
const votes = await dbAll(`SELECT verifier_id, vote, voted_at FROM claim_verification_votes WHERE task_id = ? ORDER BY voted_at ASC`, [task.id]);
|
|
359
392
|
res.json({ task, votes, votes_needed: CLAIM_VERIFIERS_NEEDED });
|
|
360
393
|
});
|
|
361
394
|
// 列出可接的 open 任务
|
|
362
|
-
app.get('/api/claim-tasks/available', (req, res) => {
|
|
395
|
+
app.get('/api/claim-tasks/available', async (req, res) => {
|
|
363
396
|
const user = auth(req, res);
|
|
364
397
|
if (!user)
|
|
365
398
|
return;
|
|
@@ -370,7 +403,7 @@ export function registerClaimVerifyRoutes(app, deps) {
|
|
|
370
403
|
if (active >= CLAIM_VERIFIER_MAX_ACTIVE) {
|
|
371
404
|
return void res.status(429).json({ error: `已有 ${active} 个进行中任务(上限 ${CLAIM_VERIFIER_MAX_ACTIVE}),请先完成`, active });
|
|
372
405
|
}
|
|
373
|
-
const rows =
|
|
406
|
+
const rows = await dbAll(`
|
|
374
407
|
SELECT cvt.id, cvt.order_id, cvt.product_id, cvt.claim_target, cvt.claim_text,
|
|
375
408
|
cvt.evidence_uri, cvt.seller_evidence_uri, cvt.deadline_at, cvt.created_at,
|
|
376
409
|
(SELECT COUNT(*) FROM claim_verification_votes WHERE task_id = cvt.id AND vote != 'abstain') as votes_count,
|
|
@@ -383,11 +416,11 @@ export function registerClaimVerifyRoutes(app, deps) {
|
|
|
383
416
|
AND (SELECT COUNT(*) FROM claim_verification_votes WHERE task_id = cvt.id AND vote != 'abstain') < ?
|
|
384
417
|
ORDER BY cvt.created_at ASC
|
|
385
418
|
LIMIT 50
|
|
386
|
-
|
|
419
|
+
`, [user.id, user.id, user.id, CLAIM_VERIFIERS_NEEDED]);
|
|
387
420
|
res.json({ eligible: true, via: elig.via, active, max_active: CLAIM_VERIFIER_MAX_ACTIVE, tasks: rows });
|
|
388
421
|
});
|
|
389
422
|
// verifier 投票 — 铁律 §4
|
|
390
|
-
app.post('/api/claim-tasks/:id/vote', (req, res) => {
|
|
423
|
+
app.post('/api/claim-tasks/:id/vote', async (req, res) => {
|
|
391
424
|
const user = auth(req, res);
|
|
392
425
|
if (!user)
|
|
393
426
|
return;
|
|
@@ -401,7 +434,7 @@ export function registerClaimVerifyRoutes(app, deps) {
|
|
|
401
434
|
});
|
|
402
435
|
if (!hpCheck.ok)
|
|
403
436
|
return void res.status(412).json({ error: hpCheck.reason, error_code: hpCheck.error_code });
|
|
404
|
-
const task =
|
|
437
|
+
const task = await dbOne('SELECT * FROM claim_verification_tasks WHERE id = ?', [req.params.id]);
|
|
405
438
|
if (!task)
|
|
406
439
|
return void res.status(404).json({ error: '任务不存在' });
|
|
407
440
|
if (task.status !== 'open')
|
|
@@ -416,8 +449,7 @@ export function registerClaimVerifyRoutes(app, deps) {
|
|
|
416
449
|
}
|
|
417
450
|
const evidence_uri = req.body?.evidence_uri ? String(req.body.evidence_uri).trim().slice(0, 500) : null;
|
|
418
451
|
const note = req.body?.note ? String(req.body.note).trim().slice(0, 500) : null;
|
|
419
|
-
const dup =
|
|
420
|
-
.get(req.params.id, user.id);
|
|
452
|
+
const dup = await dbOne('SELECT id FROM claim_verification_votes WHERE task_id = ? AND verifier_id = ?', [req.params.id, user.id]);
|
|
421
453
|
if (dup)
|
|
422
454
|
return void res.status(409).json({ error: '已投过票' });
|
|
423
455
|
const votesNow = db.prepare(`SELECT COUNT(*) as n FROM claim_verification_votes WHERE task_id = ? AND vote != 'abstain'`)
|
|
@@ -452,42 +484,42 @@ export function registerClaimVerifyRoutes(app, deps) {
|
|
|
452
484
|
});
|
|
453
485
|
});
|
|
454
486
|
// 我相关的任务(必须在 /:id 之前注册,否则被 /:id 截获)
|
|
455
|
-
app.get('/api/claim-tasks/mine', (req, res) => {
|
|
487
|
+
app.get('/api/claim-tasks/mine', async (req, res) => {
|
|
456
488
|
const user = auth(req, res);
|
|
457
489
|
if (!user)
|
|
458
490
|
return;
|
|
459
|
-
const asBuyer =
|
|
460
|
-
FROM claim_verification_tasks WHERE buyer_id = ? ORDER BY created_at DESC LIMIT 50
|
|
461
|
-
const asSeller =
|
|
462
|
-
FROM claim_verification_tasks WHERE seller_id = ? ORDER BY created_at DESC LIMIT 50
|
|
463
|
-
const asVerifier =
|
|
491
|
+
const asBuyer = await dbAll(`SELECT id, order_id, product_id, claim_target, status, deadline_at, created_at
|
|
492
|
+
FROM claim_verification_tasks WHERE buyer_id = ? ORDER BY created_at DESC LIMIT 50`, [user.id]);
|
|
493
|
+
const asSeller = await dbAll(`SELECT id, order_id, product_id, claim_target, status, deadline_at, created_at
|
|
494
|
+
FROM claim_verification_tasks WHERE seller_id = ? ORDER BY created_at DESC LIMIT 50`, [user.id]);
|
|
495
|
+
const asVerifier = await dbAll(`
|
|
464
496
|
SELECT cvt.id, cvt.order_id, cvt.product_id, cvt.claim_target, cvt.status, cvt.deadline_at, cvt.created_at,
|
|
465
497
|
cvv.vote, cvv.voted_at
|
|
466
498
|
FROM claim_verification_votes cvv
|
|
467
499
|
JOIN claim_verification_tasks cvt ON cvt.id = cvv.task_id
|
|
468
500
|
WHERE cvv.verifier_id = ?
|
|
469
501
|
ORDER BY cvv.voted_at DESC
|
|
470
|
-
LIMIT 50
|
|
502
|
+
LIMIT 50`, [user.id]);
|
|
471
503
|
res.json({ as_buyer: asBuyer, as_seller: asSeller, as_verifier: asVerifier });
|
|
472
504
|
});
|
|
473
505
|
// 通知偏好
|
|
474
|
-
app.post('/api/me/notify-claim-tasks', (req, res) => {
|
|
506
|
+
app.post('/api/me/notify-claim-tasks', async (req, res) => {
|
|
475
507
|
const user = auth(req, res);
|
|
476
508
|
if (!user)
|
|
477
509
|
return;
|
|
478
510
|
const enabled = req.body?.enabled === false ? 0 : 1;
|
|
479
|
-
|
|
511
|
+
await dbRun('UPDATE users SET notify_claim_tasks = ? WHERE id = ?', [enabled, user.id]);
|
|
480
512
|
res.json({ success: true, notify_claim_tasks: enabled });
|
|
481
513
|
});
|
|
482
|
-
app.get('/api/me/notify-claim-tasks', (req, res) => {
|
|
514
|
+
app.get('/api/me/notify-claim-tasks', async (req, res) => {
|
|
483
515
|
const user = auth(req, res);
|
|
484
516
|
if (!user)
|
|
485
517
|
return;
|
|
486
|
-
const row =
|
|
518
|
+
const row = await dbOne('SELECT COALESCE(notify_claim_tasks, 1) as enabled FROM users WHERE id = ?', [user.id]);
|
|
487
519
|
res.json({ notify_claim_tasks: row?.enabled ?? 1 });
|
|
488
520
|
});
|
|
489
521
|
// 公开 #claims 广场(无 auth — 透明性是验证声明信任的前提)
|
|
490
|
-
app.get('/api/claims/public', (req, res) => {
|
|
522
|
+
app.get('/api/claims/public', async (req, res) => {
|
|
491
523
|
const status = String(req.query.status || 'open');
|
|
492
524
|
const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 30));
|
|
493
525
|
let where;
|
|
@@ -508,7 +540,7 @@ export function registerClaimVerifyRoutes(app, deps) {
|
|
|
508
540
|
where = `1=1`;
|
|
509
541
|
orderBy = `cvt.created_at DESC`;
|
|
510
542
|
}
|
|
511
|
-
const rows =
|
|
543
|
+
const rows = await dbAll(`
|
|
512
544
|
SELECT cvt.id, cvt.order_id, cvt.product_id, cvt.claim_target,
|
|
513
545
|
SUBSTR(cvt.claim_text, 1, 140) as claim_excerpt,
|
|
514
546
|
cvt.evidence_uri IS NOT NULL as has_buyer_evidence,
|
|
@@ -522,7 +554,7 @@ export function registerClaimVerifyRoutes(app, deps) {
|
|
|
522
554
|
WHERE ${where}
|
|
523
555
|
ORDER BY ${orderBy}
|
|
524
556
|
LIMIT ?
|
|
525
|
-
|
|
557
|
+
`, [limit]);
|
|
526
558
|
const items = rows.map(r => {
|
|
527
559
|
let firstImage = null;
|
|
528
560
|
try {
|
|
@@ -553,33 +585,30 @@ export function registerClaimVerifyRoutes(app, deps) {
|
|
|
553
585
|
res.json({ items, votes_needed: CLAIM_VERIFIERS_NEEDED });
|
|
554
586
|
});
|
|
555
587
|
// 任务详情
|
|
556
|
-
app.get('/api/claim-tasks/:id', (req, res) => {
|
|
588
|
+
app.get('/api/claim-tasks/:id', async (req, res) => {
|
|
557
589
|
const user = auth(req, res);
|
|
558
590
|
if (!user)
|
|
559
591
|
return;
|
|
560
|
-
const task =
|
|
561
|
-
.get(req.params.id);
|
|
592
|
+
const task = await dbOne('SELECT * FROM claim_verification_tasks WHERE id = ?', [req.params.id]);
|
|
562
593
|
if (!task)
|
|
563
594
|
return void res.status(404).json({ error: '任务不存在' });
|
|
564
|
-
const hasVoted =
|
|
565
|
-
.get(req.params.id, user.id);
|
|
595
|
+
const hasVoted = await dbOne('SELECT id FROM claim_verification_votes WHERE task_id = ? AND verifier_id = ?', [req.params.id, user.id]);
|
|
566
596
|
const isParty = task.buyer_id === user.id || task.seller_id === user.id;
|
|
567
597
|
const elig = isEligibleClaimVerifier(db, user.id);
|
|
568
598
|
const canRead = isParty || !!hasVoted || elig.ok;
|
|
569
599
|
if (!canRead) {
|
|
570
600
|
return void res.status(403).json({ error: '仅当事人或已投票 / 资格内 verifier 可见任务详情' });
|
|
571
601
|
}
|
|
572
|
-
const votes =
|
|
573
|
-
FROM claim_verification_votes WHERE task_id = ? ORDER BY voted_at ASC
|
|
602
|
+
const votes = await dbAll(`SELECT id, verifier_id, vote, evidence_uri, note, voted_at
|
|
603
|
+
FROM claim_verification_votes WHERE task_id = ? ORDER BY voted_at ASC`, [req.params.id]);
|
|
574
604
|
res.json({ task, votes, votes_needed: CLAIM_VERIFIERS_NEEDED });
|
|
575
605
|
});
|
|
576
606
|
// 卖家提交证据 → 延期 24h;状态保持 open
|
|
577
|
-
app.post('/api/claim-tasks/:id/seller-evidence', (req, res) => {
|
|
607
|
+
app.post('/api/claim-tasks/:id/seller-evidence', async (req, res) => {
|
|
578
608
|
const user = auth(req, res);
|
|
579
609
|
if (!user)
|
|
580
610
|
return;
|
|
581
|
-
const task =
|
|
582
|
-
.get(req.params.id);
|
|
611
|
+
const task = await dbOne('SELECT * FROM claim_verification_tasks WHERE id = ?', [req.params.id]);
|
|
583
612
|
if (!task)
|
|
584
613
|
return void res.status(404).json({ error: '任务不存在' });
|
|
585
614
|
if (task.seller_id !== user.id)
|
|
@@ -595,11 +624,15 @@ export function registerClaimVerifyRoutes(app, deps) {
|
|
|
595
624
|
const oldDeadline = new Date(String(task.deadline_at)).getTime();
|
|
596
625
|
const newCandidate = Date.now() + CLAIM_SELLER_EXTENSION_HOURS * 3600_000;
|
|
597
626
|
const newDeadline = new Date(Math.max(oldDeadline, newCandidate)).toISOString();
|
|
598
|
-
|
|
627
|
+
// Codex #237 P2:await 预检与写之间 task 可能被结算/并发提证;status='open' + seller_evidence_at IS NULL
|
|
628
|
+
// 守卫保证只在仍 open 且未提交过时写,changes===0 → 409。
|
|
629
|
+
const ev = await dbRun(`UPDATE claim_verification_tasks
|
|
599
630
|
SET seller_evidence_uri = ?, seller_evidence_at = datetime('now'), deadline_at = ?
|
|
600
|
-
WHERE id =
|
|
631
|
+
WHERE id = ? AND status = 'open' AND seller_evidence_at IS NULL`, [evidence_uri, newDeadline, req.params.id]);
|
|
632
|
+
if (ev.changes === 0)
|
|
633
|
+
return void res.status(409).json({ error: '任务状态已变更或已提交过证据(请刷新)' });
|
|
601
634
|
try {
|
|
602
|
-
const productTitle =
|
|
635
|
+
const productTitle = (await dbOne('SELECT title FROM products WHERE id = ?', [task.product_id]))?.title || '—';
|
|
603
636
|
const claimLabel = CLAIM_TARGET_LABEL_ZH[String(task.claim_target)] || String(task.claim_target);
|
|
604
637
|
notifyEligibleVerifiers(db, generateId, {
|
|
605
638
|
taskId: String(task.id), productTitle, claimTargetLabel: claimLabel,
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { dbOne, dbAll } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
|
|
1
2
|
export function registerClaimVotingRoutes(app, deps) {
|
|
3
|
+
// 只读站点走 RFC-016 异步 seam;db 保留:vote 是"投票→封顶→结算"裁决资金路径,
|
|
4
|
+
// dup 门 + INSERT vote + 计票 + seal-CAS 必须原子(db.transaction);settle(发还/没收质押)
|
|
5
|
+
// 在 tx 提交后只对真正 seal 的那一票触发,防并发双封顶/双结算。Phase 3 迁 pg 行锁。
|
|
2
6
|
const { db, auth, isEligibleClaimVerifier, generateId, settleProductClaim, settleGenericClaim, PRODUCT_CLAIM_VERIFIERS_NEEDED, REVIEW_VERIFIERS_NEEDED } = deps;
|
|
3
7
|
const wire = (cfg) => {
|
|
4
8
|
const { vertical, taskTable, voteTable, taskAlias: a, partyIdCol, votePrefix, votesNeeded } = cfg;
|
|
5
9
|
// GET /api/<vertical>-claims/available
|
|
6
|
-
app.get(`/api/${vertical}-claims/available`, (req, res) => {
|
|
10
|
+
app.get(`/api/${vertical}-claims/available`, async (req, res) => {
|
|
7
11
|
const user = auth(req, res);
|
|
8
12
|
if (!user)
|
|
9
13
|
return;
|
|
@@ -22,18 +26,18 @@ export function registerClaimVotingRoutes(app, deps) {
|
|
|
22
26
|
AND (SELECT COUNT(*) FROM ${voteTable} WHERE claim_id = ${a}.id) < ${votesNeeded}
|
|
23
27
|
ORDER BY ${a}.created_at ASC LIMIT 50
|
|
24
28
|
`;
|
|
25
|
-
const rows =
|
|
29
|
+
const rows = await dbAll(sql, [user.id, user.id, user.id]);
|
|
26
30
|
res.json({ items: rows, eligible: true });
|
|
27
31
|
});
|
|
28
32
|
// POST /api/<vertical>-claims/:id/vote
|
|
29
|
-
app.post(`/api/${vertical}-claims/:id/vote`, (req, res) => {
|
|
33
|
+
app.post(`/api/${vertical}-claims/:id/vote`, async (req, res) => {
|
|
30
34
|
const user = auth(req, res);
|
|
31
35
|
if (!user)
|
|
32
36
|
return;
|
|
33
37
|
const elig = isEligibleClaimVerifier(user.id);
|
|
34
38
|
if (!elig.ok)
|
|
35
39
|
return void res.status(403).json({ error: elig.reason });
|
|
36
|
-
const claim =
|
|
40
|
+
const claim = await dbOne(`SELECT * FROM ${taskTable} WHERE id = ?`, [req.params.id]);
|
|
37
41
|
if (!claim)
|
|
38
42
|
return void res.status(404).json({ error: '声明不存在' });
|
|
39
43
|
if (claim.status !== 'open')
|
|
@@ -45,29 +49,50 @@ export function registerClaimVotingRoutes(app, deps) {
|
|
|
45
49
|
if (!['upheld', 'dismissed', 'insufficient'].includes(vote)) {
|
|
46
50
|
return void res.status(400).json({ error: `vote 须为 upheld / dismissed / insufficient` });
|
|
47
51
|
}
|
|
48
|
-
const dup = db.prepare(`SELECT id FROM ${voteTable} WHERE claim_id = ? AND verifier_id = ?`).get(req.params.id, user.id);
|
|
49
|
-
if (dup)
|
|
50
|
-
return void res.status(409).json({ error: '已投过票' });
|
|
51
|
-
const votesNow = db.prepare(`SELECT COUNT(*) as n FROM ${voteTable} WHERE claim_id = ?`).get(req.params.id).n;
|
|
52
|
-
if (votesNow >= votesNeeded)
|
|
53
|
-
return void res.status(409).json({ error: '已收齐共识票数' });
|
|
54
52
|
const evidence_uri = req.body?.evidence_uri ? String(req.body.evidence_uri).trim().slice(0, 500) : null;
|
|
55
53
|
const note = req.body?.note ? String(req.body.note).trim().slice(0, 500) : null;
|
|
56
54
|
const voteId = generateId(votePrefix);
|
|
55
|
+
// 裁决原子段:权威重检(状态/dup/票数)→ INSERT vote → 重计票 → 达标则 CAS 封顶。
|
|
56
|
+
// 返回 { after, didSeal };didSeal 仅对真正把 open→sealed 翻过去的那一票为 true。
|
|
57
|
+
let txOut;
|
|
57
58
|
try {
|
|
58
|
-
db.
|
|
59
|
-
.
|
|
59
|
+
txOut = db.transaction(() => {
|
|
60
|
+
const cur = db.prepare(`SELECT status FROM ${taskTable} WHERE id = ?`).get(req.params.id);
|
|
61
|
+
if (!cur || cur.status !== 'open')
|
|
62
|
+
throw new Error('VOTE_CLOSED');
|
|
63
|
+
if (db.prepare(`SELECT id FROM ${voteTable} WHERE claim_id = ? AND verifier_id = ?`).get(req.params.id, user.id))
|
|
64
|
+
throw new Error('VOTE_DUP');
|
|
65
|
+
const now = db.prepare(`SELECT COUNT(*) as n FROM ${voteTable} WHERE claim_id = ?`).get(req.params.id).n;
|
|
66
|
+
if (now >= votesNeeded)
|
|
67
|
+
throw new Error('VOTE_FULL');
|
|
68
|
+
db.prepare(`INSERT INTO ${voteTable} (id, claim_id, verifier_id, vote, evidence_uri, note) VALUES (?,?,?,?,?,?)`)
|
|
69
|
+
.run(voteId, req.params.id, user.id, vote, evidence_uri, note);
|
|
70
|
+
const after = db.prepare(`SELECT COUNT(*) as n FROM ${voteTable} WHERE claim_id = ?`).get(req.params.id).n;
|
|
71
|
+
let didSeal = false;
|
|
72
|
+
if (after >= votesNeeded) {
|
|
73
|
+
const seal = db.prepare(`UPDATE ${taskTable} SET status = 'sealed' WHERE id = ? AND status = 'open'`).run(req.params.id);
|
|
74
|
+
didSeal = seal.changes === 1;
|
|
75
|
+
}
|
|
76
|
+
return { after, didSeal };
|
|
77
|
+
})();
|
|
60
78
|
}
|
|
61
|
-
catch {
|
|
62
|
-
|
|
79
|
+
catch (e) {
|
|
80
|
+
const msg = e.message;
|
|
81
|
+
if (msg === 'VOTE_CLOSED')
|
|
82
|
+
return void res.status(400).json({ error: '该声明已结案,不接受投票' });
|
|
83
|
+
if (msg === 'VOTE_DUP')
|
|
84
|
+
return void res.status(409).json({ error: '已投过票' });
|
|
85
|
+
if (msg === 'VOTE_FULL')
|
|
86
|
+
return void res.status(409).json({ error: '已收齐共识票数' });
|
|
87
|
+
console.error('[claim-voting tx]', msg);
|
|
88
|
+
return void res.status(500).json({ error: '投票失败,请重试' });
|
|
63
89
|
}
|
|
64
|
-
|
|
90
|
+
// settle(发还/没收质押)在事务提交后只对真正 seal 的那一票触发(它自身另起事务)。
|
|
65
91
|
let settlement = null;
|
|
66
|
-
if (
|
|
67
|
-
db.prepare(`UPDATE ${taskTable} SET status = 'sealed' WHERE id = ? AND status = 'open'`).run(req.params.id);
|
|
92
|
+
if (txOut.didSeal) {
|
|
68
93
|
settlement = cfg.useProductSettle ? settleProductClaim(req.params.id) : settleGenericClaim(taskTable, voteTable, req.params.id);
|
|
69
94
|
}
|
|
70
|
-
res.json({ success: true, votes_collected: after, sealed:
|
|
95
|
+
res.json({ success: true, votes_collected: txOut.after, sealed: txOut.didSeal, settlement });
|
|
71
96
|
});
|
|
72
97
|
};
|
|
73
98
|
// 5 个垂类配置
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { issueGithubIdentityClaimChallenge, getIssuedChallengeForVerification, } from '../../layer2-business/L2-9-contribution/identity-claim-challenge-engine.js';
|
|
3
|
+
import { verifyGithubGistProof } from '../../layer2-business/L2-9-contribution/identity-claim-proof-verifier.js';
|
|
4
|
+
import { claimGithubIdentity } from '../../layer2-business/L2-9-contribution/identity-claim-engine.js';
|
|
5
|
+
import { getMyGithubIdentitySurface } from '../../layer2-business/L2-9-contribution/identity-claim-read.js';
|
|
6
|
+
import { withUncommittedValueBoundary } from '../../layer2-business/L2-9-contribution/contribution-display-envelope.js';
|
|
7
|
+
// ── strict request bodies (unknown/sensitive keys → rejected; nothing trusts a caller field) ──
|
|
8
|
+
const ChallengeBody = z.strictObject({
|
|
9
|
+
source_event_key: z.string().min(1),
|
|
10
|
+
github_actor_id: z.string().min(1),
|
|
11
|
+
});
|
|
12
|
+
const CompleteBody = z.strictObject({
|
|
13
|
+
source_event_key: z.string().min(1),
|
|
14
|
+
github_actor_id: z.string().min(1),
|
|
15
|
+
challenge_id: z.string().min(1),
|
|
16
|
+
gist_id: z.string().min(1),
|
|
17
|
+
webauthn_token: z.string().min(1), // one-time WebAuthn gate token id (purpose 'identity_claim')
|
|
18
|
+
});
|
|
19
|
+
const PARAM_KEY = 'require_human_presence_for_identity_claim';
|
|
20
|
+
export function registerContributionIdentityRoutes(app, deps) {
|
|
21
|
+
const { auth, requireHumanPresence, errorRes, getGithubReadToken } = deps;
|
|
22
|
+
// ── 1) issue a publication challenge ─────────────────────────────────────────────────────────
|
|
23
|
+
app.post('/api/contribution-identity/github/claim-challenge', async (req, res) => {
|
|
24
|
+
const user = auth(req, res);
|
|
25
|
+
if (!user)
|
|
26
|
+
return;
|
|
27
|
+
const parsed = ChallengeBody.safeParse(req.body ?? {});
|
|
28
|
+
if (!parsed.success)
|
|
29
|
+
return void errorRes(res, 400, 'INVALID_REQUEST', '请求参数无效');
|
|
30
|
+
// accountId is ALWAYS the session user (never the body).
|
|
31
|
+
const r = await issueGithubIdentityClaimChallenge({
|
|
32
|
+
accountId: user.id,
|
|
33
|
+
githubActorId: parsed.data.github_actor_id,
|
|
34
|
+
sourceEventKey: parsed.data.source_event_key,
|
|
35
|
+
});
|
|
36
|
+
if (r.ok && r.status === 'issued') {
|
|
37
|
+
return void res.json({ status: 'issued', challenge_id: r.challenge_id, expires_at: r.expires_at, proof_marker: r.proof_marker });
|
|
38
|
+
}
|
|
39
|
+
if (r.ok && r.status === 'already_bound_self') {
|
|
40
|
+
return void res.json({ status: 'already_bound_self', github_actor_id: r.github_actor_id });
|
|
41
|
+
}
|
|
42
|
+
// refused — map to a status without leaking internals.
|
|
43
|
+
switch (r.reason) {
|
|
44
|
+
case 'invalid_request': return void errorRes(res, 400, 'INVALID_REQUEST', '请求参数无效');
|
|
45
|
+
case 'fact_not_found': return void errorRes(res, 404, 'FACT_NOT_CLAIMABLE', '没有可认领的、经凭证背书的 GitHub 贡献记录');
|
|
46
|
+
case 'actor_mismatch': return void errorRes(res, 403, 'ACTOR_MISMATCH', '该贡献记录的执行者与所声明的 GitHub 身份不符');
|
|
47
|
+
case 'already_bound_other': return void errorRes(res, 409, 'ALREADY_BOUND', '该 GitHub 身份已被其他账号认领');
|
|
48
|
+
case 'backend_unsupported': return void errorRes(res, 503, 'BACKEND_UNSUPPORTED', '当前后端暂不支持身份认领');
|
|
49
|
+
case 'db_busy': return void errorRes(res, 503, 'DB_BUSY', '系统繁忙,请稍后重试');
|
|
50
|
+
default: return void errorRes(res, 500, 'INTERNAL', '内部错误');
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
// ── 2) complete the claim (human gate → re-fetch gist proof → atomic consume+bind) ────────────
|
|
54
|
+
app.post('/api/contribution-identity/github/claim-complete', async (req, res) => {
|
|
55
|
+
const user = auth(req, res);
|
|
56
|
+
if (!user)
|
|
57
|
+
return;
|
|
58
|
+
const parsed = CompleteBody.safeParse(req.body ?? {});
|
|
59
|
+
if (!parsed.success)
|
|
60
|
+
return void errorRes(res, 400, 'INVALID_REQUEST', '请求参数无效');
|
|
61
|
+
const { source_event_key, github_actor_id, challenge_id, gist_id, webauthn_token } = parsed.data;
|
|
62
|
+
const userId = user.id;
|
|
63
|
+
// Server-config precondition FIRST — don't burn the one-time human gate token if the server can't
|
|
64
|
+
// perform the authenticated GitHub read (fail closed; issuing a challenge never needs a token).
|
|
65
|
+
const githubToken = getGithubReadToken();
|
|
66
|
+
if (!githubToken)
|
|
67
|
+
return void errorRes(res, 503, 'GITHUB_READ_NOT_CONFIGURED', '身份认领暂不可用');
|
|
68
|
+
// ① human presence — the gate token must be bound (purpose_data) to THIS exact claim tuple, so a
|
|
69
|
+
// token minted for one claim cannot complete another, and an agent cannot replay it.
|
|
70
|
+
const hp = requireHumanPresence(userId, 'identity_claim', webauthn_token, PARAM_KEY, (data) => {
|
|
71
|
+
const d = data;
|
|
72
|
+
return !!d && d.github_actor_id === github_actor_id && d.source_event_key === source_event_key && d.challenge_id === challenge_id;
|
|
73
|
+
});
|
|
74
|
+
if (!hp.ok)
|
|
75
|
+
return void errorRes(res, 412, hp.error_code || 'HUMAN_PRESENCE_REQUIRED', hp.reason || '此操作需真实人工 WebAuthn 验证');
|
|
76
|
+
// ② confirm the challenge is issued/owned/unexpired and fetch the stored nonce_hash (read-only;
|
|
77
|
+
// BEFORE the network call so a bad challenge never triggers a GitHub fetch). Not consumed here.
|
|
78
|
+
const look = getIssuedChallengeForVerification({ challengeId: challenge_id, accountId: userId, githubActorId: github_actor_id, sourceEventKey: source_event_key });
|
|
79
|
+
if (!look.ok) {
|
|
80
|
+
switch (look.reason) {
|
|
81
|
+
case 'challenge_not_found': return void errorRes(res, 404, 'CHALLENGE_NOT_FOUND', '认领挑战不存在或不属于当前账号');
|
|
82
|
+
case 'challenge_expired': return void errorRes(res, 410, 'CHALLENGE_EXPIRED', '认领挑战已过期,请重新发起');
|
|
83
|
+
case 'challenge_already_used': return void errorRes(res, 409, 'CHALLENGE_ALREADY_USED', '认领挑战已被使用');
|
|
84
|
+
case 'backend_unsupported': return void errorRes(res, 503, 'BACKEND_UNSUPPORTED', '当前后端暂不支持身份认领');
|
|
85
|
+
default: return void errorRes(res, 400, 'INVALID_REQUEST', '请求参数无效');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// ③ WebAZ re-fetches the gist itself (trusted token from config; NEVER the body) and verifies
|
|
89
|
+
// owner.id == actor + marker + sha256(nonce) == stored nonce_hash. A failure here does NOT consume
|
|
90
|
+
// the challenge (F2 is not called) — the user can fix the gist and retry.
|
|
91
|
+
const proof = await verifyGithubGistProof({
|
|
92
|
+
gistId: gist_id,
|
|
93
|
+
githubActorId: github_actor_id,
|
|
94
|
+
challengeId: challenge_id,
|
|
95
|
+
expectedNonceHash: look.nonceHash,
|
|
96
|
+
token: githubToken,
|
|
97
|
+
});
|
|
98
|
+
if (!proof.ok) {
|
|
99
|
+
// Surface only the typed outcome (verifier guarantees its reasons are token-free; we don't echo them).
|
|
100
|
+
const code = proof.outcome === 'rate_limited' ? 429
|
|
101
|
+
: proof.outcome === 'timeout' || proof.outcome === 'upstream_unavailable' ? 502
|
|
102
|
+
: proof.outcome === 'not_found' ? 404
|
|
103
|
+
: proof.outcome === 'invalid_request' ? 400
|
|
104
|
+
: 422;
|
|
105
|
+
return void errorRes(res, code, 'PROOF_REJECTED', '未能验证 GitHub 公开发布凭证', { proof_outcome: proof.outcome });
|
|
106
|
+
}
|
|
107
|
+
// ④ atomic consume(CAS) + bind — proofVerified:true; accountId is the session user.
|
|
108
|
+
const claim = await claimGithubIdentity({
|
|
109
|
+
accountId: userId,
|
|
110
|
+
githubActorId: github_actor_id,
|
|
111
|
+
sourceEventKey: source_event_key,
|
|
112
|
+
challengeId: challenge_id,
|
|
113
|
+
proofVerified: true,
|
|
114
|
+
});
|
|
115
|
+
if (claim.ok) {
|
|
116
|
+
return void res.json({ status: claim.status, github_actor_id: claim.github_actor_id, challenge_id: claim.challenge_id });
|
|
117
|
+
}
|
|
118
|
+
switch (claim.reason) {
|
|
119
|
+
case 'already_bound_other': return void errorRes(res, 409, 'ALREADY_BOUND', '该 GitHub 身份已被其他账号认领');
|
|
120
|
+
case 'challenge_already_used': return void errorRes(res, 409, 'CHALLENGE_ALREADY_USED', '认领挑战已被使用');
|
|
121
|
+
case 'challenge_expired': return void errorRes(res, 410, 'CHALLENGE_EXPIRED', '认领挑战已过期,请重新发起');
|
|
122
|
+
case 'challenge_not_found': return void errorRes(res, 404, 'CHALLENGE_NOT_FOUND', '认领挑战不存在或不属于当前账号');
|
|
123
|
+
case 'fact_not_found': return void errorRes(res, 404, 'FACT_NOT_CLAIMABLE', '没有可认领的、经凭证背书的 GitHub 贡献记录');
|
|
124
|
+
case 'actor_mismatch': return void errorRes(res, 403, 'ACTOR_MISMATCH', '该贡献记录的执行者与所声明的 GitHub 身份不符');
|
|
125
|
+
case 'backend_unsupported': return void errorRes(res, 503, 'BACKEND_UNSUPPORTED', '当前后端暂不支持身份认领');
|
|
126
|
+
case 'db_busy': return void errorRes(res, 503, 'DB_BUSY', '系统繁忙,请稍后重试');
|
|
127
|
+
default: return void errorRes(res, 500, 'INTERNAL', '内部错误'); // proof_not_verified / invariant_violation
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
// ── 3) READ-ONLY: the caller's OWN bindings + attributable facts (PR-F4) ──────────────────────
|
|
131
|
+
// No query/body input is read — accountId is ALWAYS the session user, so a caller cannot ask about
|
|
132
|
+
// another account or github_actor_id. Returns no other account's id, no token/nonce/nonce_hash.
|
|
133
|
+
// PR-5A: the response is wrapped in the uncommitted-value boundary (RFC-017 I-12 / §7) so this
|
|
134
|
+
// metering/display surface can never read as a payout promise — facts + attribution only.
|
|
135
|
+
app.get('/api/contribution-identity/github/me', async (req, res) => {
|
|
136
|
+
const user = auth(req, res);
|
|
137
|
+
if (!user)
|
|
138
|
+
return;
|
|
139
|
+
try {
|
|
140
|
+
const surface = await getMyGithubIdentitySurface(user.id);
|
|
141
|
+
res.json(withUncommittedValueBoundary(surface));
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return void errorRes(res, 500, 'INTERNAL', '内部错误'); // never leak a stack / query
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { collectContributionScoreEvidence } from '../../layer2-business/L2-9-contribution/contribution-score-evidence.js';
|
|
2
|
+
import { withUncommittedValueBoundary } from '../../layer2-business/L2-9-contribution/contribution-display-envelope.js';
|
|
3
|
+
export function registerContributionScoreRoutes(app, deps) {
|
|
4
|
+
const { auth, errorRes } = deps;
|
|
5
|
+
// READ-ONLY self-view of contribution-score EVIDENCE (not a score). No query/body input — accountId is
|
|
6
|
+
// always the session user. Output is component evidence wrapped in the uncommitted-value boundary.
|
|
7
|
+
app.get('/api/contribution-score/evidence/me', async (req, res) => {
|
|
8
|
+
const user = auth(req, res);
|
|
9
|
+
if (!user)
|
|
10
|
+
return;
|
|
11
|
+
try {
|
|
12
|
+
const components = await collectContributionScoreEvidence(user.id);
|
|
13
|
+
res.json(withUncommittedValueBoundary({ evidence_version: 'v1', components }));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return void errorRes(res, 500, 'INTERNAL', '内部错误'); // never leak a stack / query
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|