diff --git a/artifacts/api-server/src/routes/identity.ts b/artifacts/api-server/src/routes/identity.ts index 842d7dd..d493f5b 100644 --- a/artifacts/api-server/src/routes/identity.ts +++ b/artifacts/api-server/src/routes/identity.ts @@ -2,7 +2,7 @@ import { Router, type Request, type Response } from "express"; import { randomBytes, randomUUID } from "crypto"; import { verifyEvent, validateEvent } from "nostr-tools"; import { db, nostrTrustVouches, nostrIdentities, timmyNostrEvents } from "@workspace/db"; -import { eq, count } from "drizzle-orm"; +import { eq, count, desc } from "drizzle-orm"; import { trustService } from "../lib/trust.js"; import { timmyIdentityService } from "../lib/timmy-identity.js"; import { makeLogger } from "../lib/logger.js"; @@ -406,4 +406,85 @@ router.get("/identity/me", async (req: Request, res: Response) => { } }); +// ── GET /identity/leaderboard ───────────────────────────────────────────────── +// Returns the top 20 verified Nostr identities sorted by trust score descending. +// Public endpoint — no authentication required. + +router.get("/identity/leaderboard", async (_req: Request, res: Response) => { + try { + const rows = await db + .select({ + pubkey: nostrIdentities.pubkey, + trustScore: nostrIdentities.trustScore, + tier: nostrIdentities.tier, + interactionCount: nostrIdentities.interactionCount, + memberSince: nostrIdentities.createdAt, + }) + .from(nostrIdentities) + .orderBy(desc(nostrIdentities.trustScore)) + .limit(20); + + res.json({ leaderboard: rows }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch leaderboard" }); + } +}); + +// ── POST /identity/me/decay ─────────────────────────────────────────────────── +// TEST-ONLY endpoint: triggers one synthetic decay cycle for the authenticated +// identity, decrementing trust_score by the configured DECAY_PER_DAY constant. +// Disabled in production (returns 404). Used by T37 in the testkit. + +const DECAY_PER_DAY_TEST = parseInt(process.env["TRUST_DECAY_PER_DAY"] ?? "1", 10) || 1; + +router.post("/identity/me/decay", async (req: Request, res: Response) => { + if (process.env["NODE_ENV"] === "production") { + res.status(404).json({ error: "Not found" }); + return; + } + + const raw = req.headers["x-nostr-token"]; + const token = typeof raw === "string" ? raw.trim() : null; + if (!token) { + res.status(401).json({ error: "Missing X-Nostr-Token header" }); + return; + } + + const parsed = trustService.verifyToken(token); + if (!parsed) { + res.status(401).json({ error: "Invalid or expired nostr_token" }); + return; + } + + try { + const identity = await trustService.getIdentity(parsed.pubkey); + if (!identity) { + res.status(404).json({ error: "Identity not found" }); + return; + } + + const newScore = Math.max(0, identity.trustScore - DECAY_PER_DAY_TEST); + + await db + .update(nostrIdentities) + .set({ trustScore: newScore, updatedAt: new Date() }) + .where(eq(nostrIdentities.pubkey, parsed.pubkey)); + + logger.info("test decay applied", { + pubkey: parsed.pubkey.slice(0, 8), + before: identity.trustScore, + after: newScore, + }); + + res.json({ + pubkey: parsed.pubkey, + trust_score_before: identity.trustScore, + trust_score_after: newScore, + decayed_by: identity.trustScore - newScore, + }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : "Failed to apply decay" }); + } +}); + export default router; diff --git a/artifacts/api-server/src/routes/testkit.ts b/artifacts/api-server/src/routes/testkit.ts index 677e1be..ce86cd1 100644 --- a/artifacts/api-server/src/routes/testkit.ts +++ b/artifacts/api-server/src/routes/testkit.ts @@ -29,6 +29,10 @@ const router = Router(); * Guarded on stubMode=true; polls until state=provisioning|ready (20 s timeout). * - T24 ADDED: costLedger completeness after job completion — 8 fields, honest-accounting * invariant (actualAmountSats ≤ workAmountSats), refundState enum check. + * - T36 EXTENDED: verify response shape (nostr_token + trust.tier), POST /jobs + GET /jobs/:id + * with valid token → nostrPubkey set, POST /sessions + GET /sessions/:id trust_tier. + * - T37 ADDED: POST /api/identity/me/decay (test-only endpoint) → trust_score decremented. + * - T38 ADDED: GET /api/identity/leaderboard → array sorted by trustScore desc. */ router.get("/testkit", (req: Request, res: Response) => { const proto = @@ -1042,26 +1046,92 @@ async function main() { // 3. Build and sign Nostr event (kind=27235, content=nonce) const event = finalizeEvent({ kind: 27235, content: nonce, tags: [], created_at: Math.floor(Date.now() / 1000) }, sk); - // 4. POST /identity/verify + // 4. POST /identity/verify — assert response shape: nostr_token + trust.tier const verRes = await request(BASE + '/api/identity/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' } }, JSON.stringify({ event })); if (verRes.status !== 200) { process.stderr.write('verify failed: ' + verRes.status + ' ' + verRes.body + '\n'); process.exit(1); } const verBody = JSON.parse(verRes.body); - const token = verBody.nostr_token; - const tier = verBody.trust && verBody.trust.tier; - const icount = verBody.trust && verBody.trust.interactionCount; + const token = verBody.nostr_token; + const tier = verBody.trust && verBody.trust.tier; + const icount = verBody.trust && verBody.trust.interactionCount; + const verifyHasToken = typeof token === 'string' && token.length > 0; + const verifyHasTier = typeof tier === 'string' && tier.length > 0; - // 5. GET /identity/me + // 5. GET /identity/me — assert trust fields + pubkey round-trip const meRes = await request(BASE + '/api/identity/me', { method: 'GET', headers: { 'X-Nostr-Token': token } }); if (meRes.status !== 200) { process.stderr.write('identity/me failed: ' + meRes.status + '\n'); process.exit(1); } - const meBody = JSON.parse(meRes.body); - const meTier = meBody.trust && meBody.trust.tier; + const meBody = JSON.parse(meRes.body); + const meTier = meBody.trust && meBody.trust.tier; const mePubkey = meBody.pubkey; + const meTrustFields = meBody.trust && + typeof meBody.trust.tier === 'string' && + typeof meBody.trust.score === 'number' && + typeof meBody.trust.interactionCount === 'number'; + + // 6. POST /jobs with valid Nostr token — assert nostrPubkey in response + let jobPubkey = null; + let jobId = null; + try { + const jobRes = await request(BASE + '/api/jobs', + { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Nostr-Token': token } }, + JSON.stringify({ request: 'T36 nostrPubkey probe' })); + if (jobRes.status === 201) { + const jobBody = JSON.parse(jobRes.body); + jobPubkey = jobBody.nostrPubkey || null; + jobId = jobBody.jobId || null; + } + } catch (_) { /* network error — leave null */ } + + // 7. GET /jobs/:id — assert nostrPubkey present if job was created + let getJobPubkey = null; + if (jobId) { + try { + const gjRes = await request(BASE + '/api/jobs/' + jobId, { method: 'GET', headers: {} }); + if (gjRes.status === 200) { + const gjBody = JSON.parse(gjRes.body); + getJobPubkey = gjBody.nostrPubkey || null; + } + } catch (_) { /* ignore */ } + } + + // 8. POST /sessions with valid Nostr token — assert nostrPubkey in response + let sessionPubkey = null; + let sessionId = null; + let sessionTier = null; + try { + const sessRes = await request(BASE + '/api/sessions', + { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Nostr-Token': token } }, + JSON.stringify({ amount_sats: 200 })); + if (sessRes.status === 201) { + const sessBody = JSON.parse(sessRes.body); + sessionPubkey = sessBody.nostrPubkey || null; + sessionId = sessBody.sessionId || null; + sessionTier = sessBody.trust_tier || null; + } + } catch (_) { /* network error — leave null */ } + + // 9. GET /sessions/:id — assert trust_tier present + let getSessionTier = null; + if (sessionId) { + try { + const gsRes = await request(BASE + '/api/sessions/' + sessionId, { method: 'GET', headers: {} }); + if (gsRes.status === 200) { + const gsBody = JSON.parse(gsRes.body); + getSessionTier = gsBody.trust_tier || null; + } + } catch (_) { /* ignore */ } + } // Print result as single JSON line - process.stdout.write(JSON.stringify({ pubkey, tier, icount, meTier, mePubkey }) + '\n'); + process.stdout.write(JSON.stringify({ + pubkey, tier, icount, meTier, mePubkey, + verifyHasToken, verifyHasTier, meTrustFields, + jobPubkey, getJobPubkey, + sessionPubkey, getSessionTier, + token, + }) + '\n'); } main().catch(err => { process.stderr.write(String(err) + '\n'); process.exit(1); }); NODESCRIPT @@ -1072,26 +1142,144 @@ NODESCRIPT T36_OUT=\$(node "\$T36_TMPFILE" "\$BASE" 2>/dev/null) || T36_EXIT=\$? rm -f "\$T36_TMPFILE" + # Stash the token for T37 (decay) if the node script succeeded + T36_TOKEN=\$(echo "\$T36_OUT" | jq -r '.token' 2>/dev/null || echo "") + if [[ \$T36_EXIT -ne 0 || -z "\$T36_OUT" ]]; then note SKIP "node script failed (nostr-tools unavailable or network error)" SKIP=\$((SKIP+1)) else - T36_TIER=\$(echo "\$T36_OUT" | jq -r '.tier' 2>/dev/null || echo "") - T36_ICOUNT=\$(echo "\$T36_OUT" | jq -r '.icount' 2>/dev/null || echo "") - T36_METIER=\$(echo "\$T36_OUT" | jq -r '.meTier' 2>/dev/null || echo "") - T36_PK=\$(echo "\$T36_OUT" | jq -r '.pubkey' 2>/dev/null || echo "") - T36_MEPK=\$(echo "\$T36_OUT" | jq -r '.mePubkey' 2>/dev/null || echo "") + T36_TIER=\$(echo "\$T36_OUT" | jq -r '.tier' 2>/dev/null || echo "") + T36_ICOUNT=\$(echo "\$T36_OUT" | jq -r '.icount' 2>/dev/null || echo "") + T36_METIER=\$(echo "\$T36_OUT" | jq -r '.meTier' 2>/dev/null || echo "") + T36_PK=\$(echo "\$T36_OUT" | jq -r '.pubkey' 2>/dev/null || echo "") + T36_MEPK=\$(echo "\$T36_OUT" | jq -r '.mePubkey' 2>/dev/null || echo "") + T36_VER_TOKEN=\$(echo "\$T36_OUT" | jq -r '.verifyHasToken' 2>/dev/null || echo "false") + T36_VER_TIER=\$(echo "\$T36_OUT" | jq -r '.verifyHasTier' 2>/dev/null || echo "false") + T36_ME_FIELDS=\$(echo "\$T36_OUT" | jq -r '.meTrustFields' 2>/dev/null || echo "false") + T36_JOB_PK=\$(echo "\$T36_OUT" | jq -r '.jobPubkey' 2>/dev/null || echo "") + T36_GJOB_PK=\$(echo "\$T36_OUT" | jq -r '.getJobPubkey' 2>/dev/null || echo "") + T36_SESS_PK=\$(echo "\$T36_OUT" | jq -r '.sessionPubkey' 2>/dev/null || echo "") + T36_GSESS_T=\$(echo "\$T36_OUT" | jq -r '.getSessionTier' 2>/dev/null || echo "") if [[ "\$T36_TIER" == "new" && "\$T36_ICOUNT" == "0" \\ - && "\$T36_METIER" == "new" && "\$T36_PK" == "\$T36_MEPK" && -n "\$T36_PK" ]]; then - note PASS "challenge→sign→verify OK: tier=\$T36_TIER icount=\$T36_ICOUNT identity/me pubkey matches" + && "\$T36_METIER" == "new" && "\$T36_PK" == "\$T36_MEPK" && -n "\$T36_PK" \\ + && "\$T36_VER_TOKEN" == "true" && "\$T36_VER_TIER" == "true" \\ + && "\$T36_ME_FIELDS" == "true" ]]; then + note PASS "challenge→sign→verify OK: tier=\$T36_TIER icount=\$T36_ICOUNT identity/me pubkey matches, response fields verified" PASS=\$((PASS+1)) else - note FAIL "tier=\$T36_TIER icount=\$T36_ICOUNT meTier=\$T36_METIER pkMatch=\$([[ \$T36_PK == \$T36_MEPK ]] && echo yes || echo no)" + note FAIL "tier=\$T36_TIER icount=\$T36_ICOUNT meTier=\$T36_METIER pkMatch=\$([[ \$T36_PK == \$T36_MEPK ]] && echo yes || echo no) verToken=\$T36_VER_TOKEN verTier=\$T36_VER_TIER meFields=\$T36_ME_FIELDS" + FAIL=\$((FAIL+1)) + fi + + # Sub-assertion: POST /jobs with valid Nostr token → nostrPubkey set + if [[ -n "\$T36_JOB_PK" && "\$T36_JOB_PK" != "null" && "\$T36_JOB_PK" == "\$T36_PK" ]]; then + note PASS "POST /jobs with token → nostrPubkey=\${T36_JOB_PK:0:8}... matches identity pubkey" + PASS=\$((PASS+1)) + elif [[ -z "\$T36_JOB_PK" || "\$T36_JOB_PK" == "null" ]]; then + note SKIP "POST /jobs did not return nostrPubkey (job creation may have failed in eval/payment gate)" + SKIP=\$((SKIP+1)) + else + note FAIL "POST /jobs nostrPubkey='\$T36_JOB_PK' does not match identity pubkey '\$T36_PK'" + FAIL=\$((FAIL+1)) + fi + + # Sub-assertion: GET /jobs/:id → nostrPubkey present + if [[ -n "\$T36_GJOB_PK" && "\$T36_GJOB_PK" != "null" && "\$T36_GJOB_PK" == "\$T36_PK" ]]; then + note PASS "GET /jobs/:id → nostrPubkey=\${T36_GJOB_PK:0:8}... matches" + PASS=\$((PASS+1)) + elif [[ -z "\$T36_GJOB_PK" || "\$T36_GJOB_PK" == "null" ]]; then + note SKIP "GET /jobs/:id nostrPubkey not present (skipped if job creation was skipped)" + SKIP=\$((SKIP+1)) + else + note FAIL "GET /jobs/:id nostrPubkey='\$T36_GJOB_PK' mismatch" + FAIL=\$((FAIL+1)) + fi + + # Sub-assertion: POST /sessions with valid Nostr token → nostrPubkey set + if [[ -n "\$T36_SESS_PK" && "\$T36_SESS_PK" != "null" && "\$T36_SESS_PK" == "\$T36_PK" ]]; then + note PASS "POST /sessions with token → nostrPubkey=\${T36_SESS_PK:0:8}... matches" + PASS=\$((PASS+1)) + elif [[ -z "\$T36_SESS_PK" || "\$T36_SESS_PK" == "null" ]]; then + note SKIP "POST /sessions did not return nostrPubkey" + SKIP=\$((SKIP+1)) + else + note FAIL "POST /sessions nostrPubkey='\$T36_SESS_PK' mismatch" + FAIL=\$((FAIL+1)) + fi + + # Sub-assertion: GET /sessions/:id → trust_tier present + if [[ -n "\$T36_GSESS_T" && "\$T36_GSESS_T" != "null" ]]; then + note PASS "GET /sessions/:id → trust_tier=\$T36_GSESS_T present" + PASS=\$((PASS+1)) + else + note SKIP "GET /sessions/:id trust_tier absent (session creation may have been skipped)" + SKIP=\$((SKIP+1)) + fi + fi +fi + +# --------------------------------------------------------------------------- +# T37 — POST /identity/me/decay (test mode) → trust_score decremented +# Uses T36_TOKEN if available; SKIPs if node script failed or endpoint absent. +# --------------------------------------------------------------------------- +sep "Test 37 — POST /identity/me/decay → trust_score decremented (test-only endpoint)" +if [[ -z "\$T36_TOKEN" || "\$T36_TOKEN" == "null" ]]; then + note SKIP "No T36_TOKEN available (T36 node script did not run)" + SKIP=\$((SKIP+1)) +else + T37_RES=\$(curl -s -w "\\n%{http_code}" -X POST "\$BASE/api/identity/me/decay" \\ + -H "X-Nostr-Token: \$T36_TOKEN") + T37_BODY=\$(body_of "\$T37_RES"); T37_CODE=\$(code_of "\$T37_RES") + if [[ "\$T37_CODE" == "404" ]]; then + note SKIP "Endpoint returned 404 — disabled in production or not yet deployed" + SKIP=\$((SKIP+1)) + else + T37_BEFORE=\$(echo "\$T37_BODY" | jq -r '.trust_score_before' 2>/dev/null || echo "") + T37_AFTER=\$(echo "\$T37_BODY" | jq -r '.trust_score_after' 2>/dev/null || echo "") + T37_DECAYED=\$(echo "\$T37_BODY" | jq -r '.decayed_by' 2>/dev/null || echo "") + # trust_score_before=0, after=0 (already at floor), decayed_by=0 is still valid + if [[ "\$T37_CODE" == "200" \\ + && "\$T37_BEFORE" =~ ^[0-9]+\$ \\ + && "\$T37_AFTER" =~ ^[0-9]+\$ \\ + && "\$T37_AFTER" -le "\$T37_BEFORE" ]]; then + note PASS "HTTP 200, trust_score_before=\$T37_BEFORE trust_score_after=\$T37_AFTER decayed_by=\$T37_DECAYED" + PASS=\$((PASS+1)) + else + note FAIL "code=\$T37_CODE before='\$T37_BEFORE' after='\$T37_AFTER' body=\$T37_BODY" FAIL=\$((FAIL+1)) fi fi fi +# --------------------------------------------------------------------------- +# T38 — GET /identity/leaderboard → array sorted by trust score +# --------------------------------------------------------------------------- +sep "Test 38 — GET /identity/leaderboard → sorted array" +T38_RES=\$(curl -s -w "\\n%{http_code}" "\$BASE/api/identity/leaderboard") +T38_BODY=\$(body_of "\$T38_RES"); T38_CODE=\$(code_of "\$T38_RES") +T38_IS_ARRAY=\$(echo "\$T38_BODY" | jq '.leaderboard | type == "array"' 2>/dev/null || echo "false") +T38_SORTED=false +if [[ "\$T38_IS_ARRAY" == "true" ]]; then + # Verify sorted: jq returns true if each element's trustScore >= next element's + T38_SORTED=\$(echo "\$T38_BODY" | jq ' + .leaderboard as \$a | + if (\$a | length) <= 1 then true + else + [range(\$a | length - 1)] | + all(. as \$i | \$a[\$i].trustScore >= \$a[\$i+1].trustScore) + end + ' 2>/dev/null || echo "false") +fi +if [[ "\$T38_CODE" == "200" && "\$T38_IS_ARRAY" == "true" && "\$T38_SORTED" == "true" ]]; then + T38_LEN=\$(echo "\$T38_BODY" | jq '.leaderboard | length' 2>/dev/null || echo "?") + note PASS "HTTP 200, leaderboard array length=\$T38_LEN, sorted by trustScore desc" + PASS=\$((PASS+1)) +else + note FAIL "code=\$T38_CODE isArray=\$T38_IS_ARRAY sorted=\$T38_SORTED body=\$T38_BODY" + FAIL=\$((FAIL+1)) +fi + # =========================================================================== # FUTURE STUBS — placeholders for upcoming tasks (do not affect PASS/FAIL) # ===========================================================================