409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
/**
|
|
* Intent API routes — Hono sub-router for intent CRUD, solver, and settlement.
|
|
*
|
|
* All routes are relative to the module mount point (/:space/rtime/).
|
|
*/
|
|
|
|
import { Hono } from 'hono';
|
|
import * as Automerge from '@automerge/automerge';
|
|
import { verifyToken, extractToken } from '../../server/auth';
|
|
import { burnTokensEscrow, reverseBurn } from '../../server/token-service';
|
|
import type { SyncServer } from '../../server/local-first/sync-server';
|
|
import {
|
|
intentsDocId, solverResultsDocId,
|
|
skillCurvesDocId, reputationDocId,
|
|
intentsSchema, solverResultsSchema,
|
|
skillCurvesSchema, reputationSchema,
|
|
} from './schemas-intent';
|
|
import type {
|
|
IntentsDoc, SolverResultsDoc,
|
|
SkillCurvesDoc, ReputationDoc,
|
|
Intent, IntentStatus,
|
|
} from './schemas-intent';
|
|
import type { Skill } from './schemas';
|
|
import { getSkillPrice, calculateIntentCost, getAllSkillPrices, getSkillCurveConfig } from './skill-curve';
|
|
import { getMemberSkillReputation, getMemberOverallReputation } from './reputation';
|
|
import { solve } from './solver';
|
|
import { settleResult, isReadyForSettlement, updateSkillCurves } from './settlement';
|
|
|
|
const VALID_SKILLS: Skill[] = ['facilitation', 'design', 'tech', 'outreach', 'logistics'];
|
|
|
|
/**
|
|
* Create the intent routes sub-router.
|
|
* Requires a SyncServer reference (passed from mod.ts onInit).
|
|
*/
|
|
export function createIntentRoutes(getSyncServer: () => SyncServer | null): Hono {
|
|
const routes = new Hono();
|
|
|
|
// ── Helpers ──
|
|
|
|
function syncServer(): SyncServer {
|
|
const ss = getSyncServer();
|
|
if (!ss) throw new Error('SyncServer not initialized');
|
|
return ss;
|
|
}
|
|
|
|
function ensureIntentsDoc(space: string): IntentsDoc {
|
|
const docId = intentsDocId(space);
|
|
let doc = syncServer().getDoc<IntentsDoc>(docId);
|
|
if (!doc) {
|
|
doc = Automerge.change(Automerge.init<IntentsDoc>(), 'init intents', (d) => {
|
|
const init = intentsSchema.init();
|
|
Object.assign(d, init);
|
|
d.meta.spaceSlug = space;
|
|
});
|
|
syncServer().setDoc(docId, doc);
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
function ensureSolverResultsDoc(space: string): SolverResultsDoc {
|
|
const docId = solverResultsDocId(space);
|
|
let doc = syncServer().getDoc<SolverResultsDoc>(docId);
|
|
if (!doc) {
|
|
doc = Automerge.change(Automerge.init<SolverResultsDoc>(), 'init solver results', (d) => {
|
|
const init = solverResultsSchema.init();
|
|
Object.assign(d, init);
|
|
d.meta.spaceSlug = space;
|
|
});
|
|
syncServer().setDoc(docId, doc);
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
function ensureSkillCurvesDoc(space: string): SkillCurvesDoc {
|
|
const docId = skillCurvesDocId(space);
|
|
let doc = syncServer().getDoc<SkillCurvesDoc>(docId);
|
|
if (!doc) {
|
|
doc = Automerge.change(Automerge.init<SkillCurvesDoc>(), 'init skill curves', (d) => {
|
|
const init = skillCurvesSchema.init();
|
|
Object.assign(d, init);
|
|
d.meta.spaceSlug = space;
|
|
});
|
|
syncServer().setDoc(docId, doc);
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
function ensureReputationDoc(space: string): ReputationDoc {
|
|
const docId = reputationDocId(space);
|
|
let doc = syncServer().getDoc<ReputationDoc>(docId);
|
|
if (!doc) {
|
|
doc = Automerge.change(Automerge.init<ReputationDoc>(), 'init reputation', (d) => {
|
|
const init = reputationSchema.init();
|
|
Object.assign(d, init);
|
|
d.meta.spaceSlug = space;
|
|
});
|
|
syncServer().setDoc(docId, doc);
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
async function requireAuth(headers: Headers): Promise<{ did: string; username: string } | null> {
|
|
const token = extractToken(headers);
|
|
if (!token) return null;
|
|
try {
|
|
const claims = await verifyToken(token);
|
|
return { did: claims.sub, username: (claims as any).username || claims.sub };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Intent CRUD ──
|
|
|
|
// POST /api/intent — Create a new intent
|
|
routes.post('/api/intent', async (c) => {
|
|
const auth = await requireAuth(c.req.raw.headers);
|
|
if (!auth) return c.json({ error: 'Authentication required' }, 401);
|
|
|
|
const space = c.req.param('space') || 'demo';
|
|
const body = await c.req.json();
|
|
const { type, skill, hours, description, minReputation, maxPrice, preferredMembers, expiresAt } = body;
|
|
|
|
// Validate
|
|
if (!type || !['offer', 'need'].includes(type)) {
|
|
return c.json({ error: 'type must be "offer" or "need"' }, 400);
|
|
}
|
|
if (!skill || !VALID_SKILLS.includes(skill)) {
|
|
return c.json({ error: `skill must be one of: ${VALID_SKILLS.join(', ')}` }, 400);
|
|
}
|
|
if (!hours || hours < 1 || hours > 100) {
|
|
return c.json({ error: 'hours must be between 1 and 100' }, 400);
|
|
}
|
|
|
|
ensureIntentsDoc(space);
|
|
ensureSkillCurvesDoc(space);
|
|
|
|
const id = crypto.randomUUID();
|
|
const now = Date.now();
|
|
let escrowTxId: string | undefined;
|
|
|
|
// For need intents: lock tokens in escrow
|
|
if (type === 'need') {
|
|
const curvesDoc = syncServer().getDoc<SkillCurvesDoc>(skillCurvesDocId(space))!;
|
|
const cost = calculateIntentCost(skill, hours, curvesDoc);
|
|
|
|
const offRampId = `intent-${id}`;
|
|
const success = burnTokensEscrow(
|
|
'cusdc',
|
|
auth.did,
|
|
auth.username,
|
|
cost,
|
|
offRampId,
|
|
`Intent escrow: ${hours}h ${skill} @ ${getSkillPrice(skill, curvesDoc)} tokens/h`,
|
|
);
|
|
|
|
if (!success) {
|
|
return c.json({ error: 'Insufficient balance for escrow' }, 402);
|
|
}
|
|
escrowTxId = offRampId;
|
|
}
|
|
|
|
// Write intent
|
|
syncServer().changeDoc<IntentsDoc>(intentsDocId(space), 'create intent', (d) => {
|
|
d.intents[id] = {
|
|
id,
|
|
memberId: auth.did,
|
|
memberName: auth.username,
|
|
type,
|
|
skill,
|
|
hours: Math.max(1, Math.min(100, hours)),
|
|
description: description || '',
|
|
status: 'active',
|
|
createdAt: now,
|
|
...(minReputation != null ? { minReputation } : {}),
|
|
...(maxPrice != null ? { maxPrice } : {}),
|
|
...(preferredMembers?.length ? { preferredMembers } : {}),
|
|
...(escrowTxId ? { escrowTxId } : {}),
|
|
...(expiresAt ? { expiresAt } : {}),
|
|
} as any;
|
|
});
|
|
|
|
// Update skill curves
|
|
updateSkillCurves(space, syncServer());
|
|
|
|
const doc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
|
|
return c.json(doc.intents[id], 201);
|
|
});
|
|
|
|
// PATCH /api/intent/:id — Update or withdraw an intent
|
|
routes.patch('/api/intent/:id', async (c) => {
|
|
const auth = await requireAuth(c.req.raw.headers);
|
|
if (!auth) return c.json({ error: 'Authentication required' }, 401);
|
|
|
|
const space = c.req.param('space') || 'demo';
|
|
const id = c.req.param('id');
|
|
const body = await c.req.json();
|
|
|
|
ensureIntentsDoc(space);
|
|
const doc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
|
|
const intent = doc.intents[id];
|
|
|
|
if (!intent) return c.json({ error: 'Intent not found' }, 404);
|
|
if (intent.memberId !== auth.did) return c.json({ error: 'Not your intent' }, 403);
|
|
if (intent.status !== 'active') return c.json({ error: `Cannot modify intent in status: ${intent.status}` }, 400);
|
|
|
|
// Handle withdrawal
|
|
if (body.status === 'withdrawn') {
|
|
// Reverse escrow if need intent
|
|
if (intent.escrowTxId) {
|
|
reverseBurn('cusdc', intent.escrowTxId);
|
|
}
|
|
|
|
syncServer().changeDoc<IntentsDoc>(intentsDocId(space), 'withdraw intent', (d) => {
|
|
d.intents[id].status = 'withdrawn';
|
|
});
|
|
|
|
updateSkillCurves(space, syncServer());
|
|
return c.json({ ok: true, status: 'withdrawn' });
|
|
}
|
|
|
|
// Handle field updates
|
|
syncServer().changeDoc<IntentsDoc>(intentsDocId(space), 'update intent', (d) => {
|
|
if (body.description !== undefined) d.intents[id].description = body.description;
|
|
if (body.minReputation !== undefined) d.intents[id].minReputation = body.minReputation;
|
|
if (body.maxPrice !== undefined) d.intents[id].maxPrice = body.maxPrice;
|
|
if (body.preferredMembers !== undefined) d.intents[id].preferredMembers = body.preferredMembers;
|
|
});
|
|
|
|
const updated = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
|
|
return c.json(updated.intents[id]);
|
|
});
|
|
|
|
// GET /api/intents — List active intents for space
|
|
routes.get('/api/intents', (c) => {
|
|
const space = c.req.param('space') || 'demo';
|
|
ensureIntentsDoc(space);
|
|
const doc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
|
|
const intents = Object.values(doc.intents);
|
|
return c.json({ intents });
|
|
});
|
|
|
|
// GET /api/intents/:memberId — List member's intents
|
|
routes.get('/api/intents/:memberId', (c) => {
|
|
const space = c.req.param('space') || 'demo';
|
|
const memberId = c.req.param('memberId');
|
|
ensureIntentsDoc(space);
|
|
const doc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
|
|
const intents = Object.values(doc.intents).filter(i => i.memberId === memberId);
|
|
return c.json({ intents });
|
|
});
|
|
|
|
// ── Solver ──
|
|
|
|
// GET /api/solver-results — Get current solver recommendations
|
|
routes.get('/api/solver-results', (c) => {
|
|
const space = c.req.param('space') || 'demo';
|
|
ensureSolverResultsDoc(space);
|
|
const doc = syncServer().getDoc<SolverResultsDoc>(solverResultsDocId(space))!;
|
|
const results = Object.values(doc.results)
|
|
.filter(r => r.status === 'proposed' || r.status === 'accepted');
|
|
return c.json({ results });
|
|
});
|
|
|
|
// POST /api/solver/run — Manual solver trigger
|
|
routes.post('/api/solver/run', async (c) => {
|
|
const auth = await requireAuth(c.req.raw.headers);
|
|
if (!auth) return c.json({ error: 'Authentication required' }, 401);
|
|
|
|
const space = c.req.param('space') || 'demo';
|
|
const resultCount = runSolver(space);
|
|
|
|
return c.json({ ok: true, resultsGenerated: resultCount });
|
|
});
|
|
|
|
// POST /api/solver-results/:id/accept — Member accepts a recommendation
|
|
routes.post('/api/solver-results/:id/accept', async (c) => {
|
|
const auth = await requireAuth(c.req.raw.headers);
|
|
if (!auth) return c.json({ error: 'Authentication required' }, 401);
|
|
|
|
const space = c.req.param('space') || 'demo';
|
|
const resultId = c.req.param('id');
|
|
|
|
ensureSolverResultsDoc(space);
|
|
const doc = syncServer().getDoc<SolverResultsDoc>(solverResultsDocId(space))!;
|
|
const result = doc.results[resultId];
|
|
|
|
if (!result) return c.json({ error: 'Result not found' }, 404);
|
|
if (!result.members.includes(auth.did)) return c.json({ error: 'Not a participant' }, 403);
|
|
if (result.status !== 'proposed' && result.status !== 'accepted') {
|
|
return c.json({ error: `Cannot accept result in status: ${result.status}` }, 400);
|
|
}
|
|
|
|
syncServer().changeDoc<SolverResultsDoc>(solverResultsDocId(space), 'accept result', (d) => {
|
|
d.results[resultId].acceptances[auth.did] = true;
|
|
});
|
|
|
|
// Check if all accepted → auto-settle
|
|
const updated = syncServer().getDoc<SolverResultsDoc>(solverResultsDocId(space))!;
|
|
const updatedResult = updated.results[resultId];
|
|
|
|
if (isReadyForSettlement(updatedResult)) {
|
|
// Mark as accepted first
|
|
syncServer().changeDoc<SolverResultsDoc>(solverResultsDocId(space), 'mark accepted', (d) => {
|
|
d.results[resultId].status = 'accepted';
|
|
});
|
|
|
|
const outcome = await settleResult(resultId, space, syncServer());
|
|
return c.json({ ok: true, settled: outcome.success, outcome });
|
|
}
|
|
|
|
return c.json({
|
|
ok: true,
|
|
settled: false,
|
|
acceptances: updatedResult.acceptances,
|
|
remaining: updatedResult.members.filter(m => !updatedResult.acceptances[m]),
|
|
});
|
|
});
|
|
|
|
// POST /api/solver-results/:id/reject — Member rejects a recommendation
|
|
routes.post('/api/solver-results/:id/reject', async (c) => {
|
|
const auth = await requireAuth(c.req.raw.headers);
|
|
if (!auth) return c.json({ error: 'Authentication required' }, 401);
|
|
|
|
const space = c.req.param('space') || 'demo';
|
|
const resultId = c.req.param('id');
|
|
|
|
ensureSolverResultsDoc(space);
|
|
const doc = syncServer().getDoc<SolverResultsDoc>(solverResultsDocId(space))!;
|
|
const result = doc.results[resultId];
|
|
|
|
if (!result) return c.json({ error: 'Result not found' }, 404);
|
|
if (!result.members.includes(auth.did)) return c.json({ error: 'Not a participant' }, 403);
|
|
|
|
syncServer().changeDoc<SolverResultsDoc>(solverResultsDocId(space), 'reject result', (d) => {
|
|
d.results[resultId].status = 'rejected';
|
|
});
|
|
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// ── Skill Curves ──
|
|
|
|
// GET /api/skill-curves — Get current skill prices
|
|
routes.get('/api/skill-curves', (c) => {
|
|
const space = c.req.param('space') || 'demo';
|
|
ensureSkillCurvesDoc(space);
|
|
const doc = syncServer().getDoc<SkillCurvesDoc>(skillCurvesDocId(space))!;
|
|
const prices = getAllSkillPrices(doc);
|
|
return c.json({ curves: Object.values(doc.curves), prices, config: getSkillCurveConfig() });
|
|
});
|
|
|
|
// ── Reputation ──
|
|
|
|
// GET /api/reputation/:memberId — Get member reputation
|
|
routes.get('/api/reputation/:memberId', (c) => {
|
|
const space = c.req.param('space') || 'demo';
|
|
const memberId = c.req.param('memberId');
|
|
ensureReputationDoc(space);
|
|
const doc = syncServer().getDoc<ReputationDoc>(reputationDocId(space))!;
|
|
|
|
const entries = Object.values(doc.entries).filter(e => e.memberId === memberId);
|
|
const overall = getMemberOverallReputation(memberId, doc);
|
|
|
|
return c.json({ memberId, overall, skills: entries });
|
|
});
|
|
|
|
// ── Internal: Run solver ──
|
|
|
|
function runSolver(space: string): number {
|
|
ensureIntentsDoc(space);
|
|
ensureSolverResultsDoc(space);
|
|
ensureSkillCurvesDoc(space);
|
|
ensureReputationDoc(space);
|
|
|
|
const intentsDoc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
|
|
const reputationDoc = syncServer().getDoc<ReputationDoc>(reputationDocId(space))!;
|
|
const skillCurvesDoc = syncServer().getDoc<SkillCurvesDoc>(skillCurvesDocId(space))!;
|
|
|
|
const candidates = solve(intentsDoc, reputationDoc, skillCurvesDoc);
|
|
|
|
if (candidates.length === 0) return 0;
|
|
|
|
// Clear old proposed results and write new ones
|
|
syncServer().changeDoc<SolverResultsDoc>(solverResultsDocId(space), 'solver: write results', (d) => {
|
|
// Remove stale proposed results
|
|
for (const [id, result] of Object.entries(d.results)) {
|
|
if (result.status === 'proposed') delete d.results[id];
|
|
}
|
|
|
|
// Add new results
|
|
const now = Date.now();
|
|
for (const candidate of candidates) {
|
|
const id = crypto.randomUUID();
|
|
d.results[id] = {
|
|
id,
|
|
...candidate,
|
|
createdAt: now,
|
|
} as any;
|
|
}
|
|
});
|
|
|
|
console.log(`[rTime] Solver produced ${candidates.length} results for space "${space}"`);
|
|
return candidates.length;
|
|
}
|
|
|
|
return routes;
|
|
}
|