/** * rCred API routes — contribution recognition endpoints. */ import { Hono } from 'hono'; import * as Automerge from '@automerge/automerge'; import { verifyToken, extractToken } from '../../server/auth'; import { resolveCallerRole } from '../../server/spaces'; import type { SyncServer } from '../../server/local-first/sync-server'; import { getTokenDoc, getAllBalances, getAllTransfers } from '../../server/token-service'; import type { CredConfigDoc, CredScoresDoc, ContribGraphDoc } from './schemas'; import { scoresDocId, graphDocId, configDocId, configSchema, DEFAULT_WEIGHTS } from './schemas'; import { recomputeSpace, ensureConfigDoc } from './grain-engine'; export function createCredRoutes(getSyncServer: () => SyncServer | null) { const routes = new Hono(); function ss(): SyncServer { const s = getSyncServer(); if (!s) throw new Error('SyncServer not initialized'); return s; } // ── GET /api/scores — leaderboard ── routes.get('/api/scores', (c) => { const space = c.req.param('space') || c.req.query('space') || ''; if (!space) return c.json({ error: 'space required' }, 400); const doc = ss().getDoc(scoresDocId(space)); if (!doc || !doc.scores) return c.json({ scores: [], totalCred: 0, computedAt: 0 }); const scores = Object.values(doc.scores) .sort((a, b) => b.cred - a.cred) .map((s, i) => ({ rank: i + 1, did: s.did, label: s.label, cred: s.cred, grainLifetime: s.grainLifetime, topModule: Object.entries(s.breakdown).sort(([, a], [, b]) => b - a)[0]?.[0] || '', lastActive: s.lastActive, })); return c.json({ scores, totalCred: doc.totalCred, computedAt: doc.computedAt, epochId: doc.epochId, }); }); // ── GET /api/scores/:did — single contributor detail ── routes.get('/api/scores/:did', (c) => { const space = c.req.param('space') || c.req.query('space') || ''; const did = c.req.param('did'); if (!space || !did) return c.json({ error: 'space and did required' }, 400); const doc = ss().getDoc(scoresDocId(space)); if (!doc?.scores) return c.json({ error: 'No scores found' }, 404); const score = doc.scores[did]; if (!score) return c.json({ error: 'Contributor not found' }, 404); return c.json(score); }); // ── GET /api/graph — contribution graph ── routes.get('/api/graph', (c) => { const space = c.req.param('space') || c.req.query('space') || ''; if (!space) return c.json({ error: 'space required' }, 400); const doc = ss().getDoc(graphDocId(space)); if (!doc) return c.json({ nodes: {}, edges: {}, stats: null }); return c.json({ nodes: doc.nodes, edges: doc.edges, lastBuiltAt: doc.lastBuiltAt, stats: doc.stats, }); }); // ── GET /api/config — current weights + grain config ── routes.get('/api/config', (c) => { const space = c.req.param('space') || c.req.query('space') || ''; if (!space) return c.json({ error: 'space required' }, 400); const config = ensureConfigDoc(space, ss()); return c.json({ weights: config.weights, grainPerEpoch: config.grainPerEpoch, epochLengthDays: config.epochLengthDays, slowFraction: config.slowFraction, fastFraction: config.fastFraction, dampingFactor: config.dampingFactor, lookbackDays: config.lookbackDays, enabled: config.enabled, lastEpochAt: config.lastEpochAt, }); }); // ── PUT /api/config — update weights (auth required) ── routes.put('/api/config', async (c) => { const space = c.req.param('space') || c.req.query('space') || ''; if (!space) return c.json({ error: 'space required' }, 400); const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: 'Auth required' }, 401); const claims = await verifyToken(token); if (!claims) return c.json({ error: 'Invalid token' }, 401); const resolved = await resolveCallerRole(space, claims); if (!resolved || (resolved.role !== 'admin' && !resolved.isOwner)) { return c.json({ error: 'Admin access required' }, 403); } const body = await c.req.json>(); const docId = configDocId(space); ensureConfigDoc(space, ss()); ss().changeDoc(docId, 'Update rcred config', (d) => { if (body.weights) { for (const [key, val] of Object.entries(body.weights)) { if (d.weights[key] && typeof val.weight === 'number') { d.weights[key].weight = Math.max(0, Math.min(10, val.weight)); } } } if (typeof body.grainPerEpoch === 'number') d.grainPerEpoch = body.grainPerEpoch; if (typeof body.epochLengthDays === 'number') d.epochLengthDays = body.epochLengthDays; if (typeof body.dampingFactor === 'number') d.dampingFactor = body.dampingFactor; if (typeof body.lookbackDays === 'number') d.lookbackDays = body.lookbackDays; if (typeof body.enabled === 'boolean') d.enabled = body.enabled; }); return c.json({ ok: true }); }); // ── POST /api/recompute — trigger immediate recompute ── // Demo space: open access. Other spaces: require member+ auth. routes.post('/api/recompute', async (c) => { const space = c.req.param('space') || c.req.query('space') || ''; if (!space) return c.json({ error: 'space required' }, 400); if (space !== 'demo') { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: 'Auth required' }, 401); const claims = await verifyToken(token); if (!claims) return c.json({ error: 'Invalid token' }, 401); const resolved = await resolveCallerRole(space, claims); if (!resolved || resolved.role === 'viewer') { return c.json({ error: 'Membership required' }, 403); } } const result = recomputeSpace(space, ss()); return c.json(result); }); // ── GET /api/grain/balances — grain balances from token-service ── routes.get('/api/grain/balances', (c) => { const space = c.req.param('space') || c.req.query('space') || ''; if (!space) return c.json({ error: 'space required' }, 400); const tokenId = `grain-${space}`; const tokenDoc = getTokenDoc(tokenId); if (!tokenDoc) return c.json({ balances: {} }); const balances = getAllBalances(tokenDoc); return c.json({ tokenId, balances }); }); // ── GET /api/grain/history — grain ledger entries ── routes.get('/api/grain/history', (c) => { const space = c.req.param('space') || c.req.query('space') || ''; if (!space) return c.json({ error: 'space required' }, 400); const tokenId = `grain-${space}`; const entries = getAllTransfers().filter(e => e.tokenId === tokenId); return c.json({ entries: entries.slice(-100) }); }); return routes; }