/** * 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(docId); if (!doc) { doc = Automerge.change(Automerge.init(), '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(docId); if (!doc) { doc = Automerge.change(Automerge.init(), '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(docId); if (!doc) { doc = Automerge.change(Automerge.init(), '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(docId); if (!doc) { doc = Automerge.change(Automerge.init(), '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(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(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(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(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(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(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(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(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(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(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(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(solverResultsDocId(space), 'accept result', (d) => { d.results[resultId].acceptances[auth.did] = true; }); // Check if all accepted → auto-settle const updated = syncServer().getDoc(solverResultsDocId(space))!; const updatedResult = updated.results[resultId]; if (isReadyForSettlement(updatedResult)) { // Mark as accepted first syncServer().changeDoc(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(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(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(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(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(intentsDocId(space))!; const reputationDoc = syncServer().getDoc(reputationDocId(space))!; const skillCurvesDoc = syncServer().getDoc(skillCurvesDocId(space))!; const candidates = solve(intentsDoc, reputationDoc, skillCurvesDoc); if (candidates.length === 0) return 0; // Clear old proposed results and write new ones syncServer().changeDoc(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; }