183 lines
6.4 KiB
TypeScript
183 lines
6.4 KiB
TypeScript
/**
|
|
* 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<CredScoresDoc>(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<CredScoresDoc>(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<ContribGraphDoc>(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<Partial<CredConfigDoc>>();
|
|
const docId = configDocId(space);
|
|
ensureConfigDoc(space, ss());
|
|
|
|
ss().changeDoc<CredConfigDoc>(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 (auth required) ──
|
|
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);
|
|
|
|
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;
|
|
}
|