rspace-online/modules/rcred/routes.ts

186 lines
6.5 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 ──
// 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;
}