Task #3: MVP API — payment-gated jobs + demo endpoint

OpenAPI spec (lib/api-spec/openapi.yaml)
- Added POST /jobs, GET /jobs/{id}, GET /demo endpoints
- Added schemas: CreateJobRequest, CreateJobResponse, JobStatusResponse,
  InvoiceInfo, JobState, DemoResponse, ErrorResponse
- Ran codegen: generated CreateJobBody, GetJobResponse, RunDemoQueryParams etc.

Jobs router (artifacts/api-server/src/routes/jobs.ts)
- POST /jobs: validates body, creates LNbits eval invoice, inserts job +
  invoice in a DB transaction, returns { jobId, evalInvoice }
- GET /jobs/🆔 fetches job, calls advanceJob() helper, returns state-
  appropriate payload (eval/work invoice, reason, result, errorMessage)
- advanceJob() state machine:
  - awaiting_eval_payment: checks LNbits, atomically marks paid + advances
    state via optimistic WHERE state='awaiting_eval_payment'; runs
    AgentService.evaluateRequest, branches to awaiting_work_payment or rejected
  - awaiting_work_payment: same pattern for work invoice, runs
    AgentService.executeWork, advances to complete
  - Any agent/LNbits error transitions job to failed

Demo router (artifacts/api-server/src/routes/demo.ts)
- GET /demo?request=...: in-memory rate limiter (5 req/hour per IP)
- Explicit guard for missing request param (coerce.string() workaround)
- Calls AgentService.executeWork directly, returns { result }

Dev router (artifacts/api-server/src/routes/dev.ts)
- POST /dev/stub/pay/:paymentHash: marks stub invoice paid in-memory
- Only mounted when NODE_ENV !== 'production'

Route index updated to mount all three routers

replit.md: documented full curl flow with all 6 steps, demo endpoint,
and dev stub-pay trigger

End-to-end verified with curl:
- Full flow: create → eval pay → evaluating → work pay → executing → complete
- Error cases: 400 on missing body/param, 404 on unknown job
This commit is contained in:
alexpaynex
2026-03-18 15:31:26 +00:00
parent 9ec5e20a10
commit 4e8adbcb93
19 changed files with 1050 additions and 6 deletions

View File

@@ -0,0 +1,65 @@
import { Router, type Request, type Response } from "express";
import { RunDemoQueryParams } from "@workspace/api-zod";
import { agentService } from "../lib/agent.js";
const router = Router();
const RATE_LIMIT_MAX = 5;
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
const ipHits = new Map<string, { count: number; resetAt: number }>();
function checkRateLimit(ip: string): { allowed: boolean; resetAt: number } {
const now = Date.now();
const entry = ipHits.get(ip);
if (!entry || now >= entry.resetAt) {
ipHits.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return { allowed: true, resetAt: now + RATE_LIMIT_WINDOW_MS };
}
if (entry.count >= RATE_LIMIT_MAX) {
return { allowed: false, resetAt: entry.resetAt };
}
entry.count += 1;
return { allowed: true, resetAt: entry.resetAt };
}
router.get("/demo", async (req: Request, res: Response) => {
const ip =
(req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ??
req.socket.remoteAddress ??
"unknown";
const { allowed, resetAt } = checkRateLimit(ip);
if (!allowed) {
const secsUntilReset = Math.ceil((resetAt - Date.now()) / 1000);
res.status(429).json({
error: `Rate limit exceeded. Try again in ${secsUntilReset}s (5 requests per hour per IP).`,
});
return;
}
if (!req.query.request) {
res.status(400).json({ error: "Missing required query param: request" });
return;
}
const parseResult = RunDemoQueryParams.safeParse(req.query);
if (!parseResult.success) {
res.status(400).json({ error: "Invalid query param: request must be a non-empty string" });
return;
}
const { request } = parseResult.data;
try {
const { result } = await agentService.executeWork(request);
res.json({ result });
} catch (err) {
const message = err instanceof Error ? err.message : "Agent error";
res.status(500).json({ error: message });
}
});
export default router;

View File

@@ -0,0 +1,24 @@
/**
* Development-only routes. Not mounted in production.
*/
import { Router, type Request, type Response } from "express";
import { lnbitsService } from "../lib/lnbits.js";
const router = Router();
/**
* POST /dev/stub/pay/:paymentHash
* Marks a stub invoice as paid in the in-memory store.
* Only available when LNbitsService is in stub mode (i.e. no real LNbits creds).
*/
router.post("/dev/stub/pay/:paymentHash", (req: Request, res: Response) => {
const { paymentHash } = req.params as { paymentHash: string };
if (!lnbitsService.stubMode) {
res.status(400).json({ error: "Stub mode is not active. Real LNbits credentials are configured." });
return;
}
lnbitsService.stubMarkPaid(paymentHash);
res.json({ ok: true, paymentHash });
});
export default router;

View File

@@ -1,8 +1,17 @@
import { Router, type IRouter } from "express";
import healthRouter from "./health";
import healthRouter from "./health.js";
import jobsRouter from "./jobs.js";
import demoRouter from "./demo.js";
import devRouter from "./dev.js";
const router: IRouter = Router();
router.use(healthRouter);
router.use(jobsRouter);
router.use(demoRouter);
if (process.env.NODE_ENV !== "production") {
router.use(devRouter);
}
export default router;

View File

@@ -0,0 +1,242 @@
import { Router, type Request, type Response } from "express";
import { randomUUID } from "crypto";
import { db, jobs, invoices, type Job } from "@workspace/db";
import { eq, and } from "drizzle-orm";
import { CreateJobBody } from "@workspace/api-zod";
import { lnbitsService } from "../lib/lnbits.js";
import { agentService } from "../lib/agent.js";
import { pricingService } from "../lib/pricing.js";
const router = Router();
async function getJobById(id: string): Promise<Job | null> {
const rows = await db.select().from(jobs).where(eq(jobs.id, id)).limit(1);
return rows[0] ?? null;
}
async function getInvoiceById(id: string) {
const rows = await db.select().from(invoices).where(eq(invoices.id, id)).limit(1);
return rows[0] ?? null;
}
/**
* Checks whether the active invoice for a job has been paid and, if so,
* advances the state machine. Returns the refreshed job after any transitions.
*/
async function advanceJob(job: Job): Promise<Job | null> {
if (job.state === "awaiting_eval_payment" && job.evalInvoiceId) {
const evalInvoice = await getInvoiceById(job.evalInvoiceId);
if (!evalInvoice || evalInvoice.paid) return getJobById(job.id);
const isPaid = await lnbitsService.checkInvoicePaid(evalInvoice.paymentHash);
if (!isPaid) return job;
const advanced = await db.transaction(async (tx) => {
await tx
.update(invoices)
.set({ paid: true, paidAt: new Date() })
.where(eq(invoices.id, evalInvoice.id));
const updated = await tx
.update(jobs)
.set({ state: "evaluating", updatedAt: new Date() })
.where(and(eq(jobs.id, job.id), eq(jobs.state, "awaiting_eval_payment")))
.returning();
return updated.length > 0;
});
if (!advanced) return getJobById(job.id);
try {
const evalResult = await agentService.evaluateRequest(job.request);
if (evalResult.accepted) {
const workFee = pricingService.calculateWorkFeeSats(job.request);
const workInvoiceData = await lnbitsService.createInvoice(
workFee,
`Work fee for job ${job.id}`,
);
const workInvoiceId = randomUUID();
await db.transaction(async (tx) => {
await tx.insert(invoices).values({
id: workInvoiceId,
jobId: job.id,
paymentHash: workInvoiceData.paymentHash,
paymentRequest: workInvoiceData.paymentRequest,
amountSats: workFee,
type: "work",
paid: false,
});
await tx
.update(jobs)
.set({
state: "awaiting_work_payment",
workInvoiceId,
workAmountSats: workFee,
updatedAt: new Date(),
})
.where(eq(jobs.id, job.id));
});
} else {
await db
.update(jobs)
.set({ state: "rejected", rejectionReason: evalResult.reason, updatedAt: new Date() })
.where(eq(jobs.id, job.id));
}
} catch (err) {
const message = err instanceof Error ? err.message : "Evaluation error";
await db
.update(jobs)
.set({ state: "failed", errorMessage: message, updatedAt: new Date() })
.where(eq(jobs.id, job.id));
}
return getJobById(job.id);
}
if (job.state === "awaiting_work_payment" && job.workInvoiceId) {
const workInvoice = await getInvoiceById(job.workInvoiceId);
if (!workInvoice || workInvoice.paid) return getJobById(job.id);
const isPaid = await lnbitsService.checkInvoicePaid(workInvoice.paymentHash);
if (!isPaid) return job;
const advanced = await db.transaction(async (tx) => {
await tx
.update(invoices)
.set({ paid: true, paidAt: new Date() })
.where(eq(invoices.id, workInvoice.id));
const updated = await tx
.update(jobs)
.set({ state: "executing", updatedAt: new Date() })
.where(and(eq(jobs.id, job.id), eq(jobs.state, "awaiting_work_payment")))
.returning();
return updated.length > 0;
});
if (!advanced) return getJobById(job.id);
try {
const workResult = await agentService.executeWork(job.request);
await db
.update(jobs)
.set({ state: "complete", result: workResult.result, updatedAt: new Date() })
.where(eq(jobs.id, job.id));
} catch (err) {
const message = err instanceof Error ? err.message : "Execution error";
await db
.update(jobs)
.set({ state: "failed", errorMessage: message, updatedAt: new Date() })
.where(eq(jobs.id, job.id));
}
return getJobById(job.id);
}
return job;
}
router.post("/jobs", async (req: Request, res: Response) => {
const parseResult = CreateJobBody.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({ error: "Invalid request: 'request' string is required" });
return;
}
const { request } = parseResult.data;
try {
const evalFee = pricingService.calculateEvalFeeSats();
const jobId = randomUUID();
const invoiceId = randomUUID();
const lnbitsInvoice = await lnbitsService.createInvoice(
evalFee,
`Eval fee for job ${jobId}`,
);
await db.transaction(async (tx) => {
await tx.insert(jobs).values({
id: jobId,
request,
state: "awaiting_eval_payment",
evalAmountSats: evalFee,
});
await tx.insert(invoices).values({
id: invoiceId,
jobId,
paymentHash: lnbitsInvoice.paymentHash,
paymentRequest: lnbitsInvoice.paymentRequest,
amountSats: evalFee,
type: "eval",
paid: false,
});
await tx
.update(jobs)
.set({ evalInvoiceId: invoiceId, updatedAt: new Date() })
.where(eq(jobs.id, jobId));
});
res.status(201).json({
jobId,
evalInvoice: {
paymentRequest: lnbitsInvoice.paymentRequest,
amountSats: evalFee,
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create job";
res.status(500).json({ error: message });
}
});
router.get("/jobs/:id", async (req: Request, res: Response) => {
const { id } = req.params as { id: string };
try {
let job = await getJobById(id);
if (!job) {
res.status(404).json({ error: "Job not found" });
return;
}
const advanced = await advanceJob(job);
if (advanced) job = advanced;
const base = { jobId: job.id, state: job.state };
switch (job.state) {
case "awaiting_eval_payment": {
const inv = job.evalInvoiceId ? await getInvoiceById(job.evalInvoiceId) : null;
res.json({
...base,
...(inv ? { evalInvoice: { paymentRequest: inv.paymentRequest, amountSats: inv.amountSats } } : {}),
});
break;
}
case "awaiting_work_payment": {
const inv = job.workInvoiceId ? await getInvoiceById(job.workInvoiceId) : null;
res.json({
...base,
...(inv ? { workInvoice: { paymentRequest: inv.paymentRequest, amountSats: inv.amountSats } } : {}),
});
break;
}
case "rejected":
res.json({ ...base, reason: job.rejectionReason ?? undefined });
break;
case "complete":
res.json({ ...base, result: job.result ?? undefined });
break;
case "failed":
res.json({ ...base, errorMessage: job.errorMessage ?? undefined });
break;
default:
res.json(base);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to fetch job";
res.status(500).json({ error: message });
}
});
export default router;