feat(#26): Nostr identity + trust engine

- New nostr_identities DB table (pubkey, trust_score, tier, interaction_count, sats_absorbed_today, last_seen)
- nullable nostr_pubkey FK on sessions + jobs tables; schema pushed
- TrustService: getTier, getOrCreate, recordSuccess/Failure, HMAC token (issue/verify)
- Soft score decay (lazy, on read) when identity absent > N days
- POST /api/identity/challenge + POST /api/identity/verify (NIP-01 sig verification)
- GET /api/identity/me — look up trust profile by X-Nostr-Token
- POST /api/sessions + POST /api/jobs accept optional nostr_token; bind pubkey to row
- GET /sessions/:id + GET /jobs/:id include trust_tier in response
- recordSuccess/Failure called after session request + job work completes
- X-Nostr-Token added to CORS allowedHeaders + exposedHeaders
- TIMMY_TOKEN_SECRET set as persistent shared env var
This commit is contained in:
Replit Agent
2026-03-19 15:59:14 +00:00
parent fa0ebc6b5c
commit 9b778351e4
12 changed files with 581 additions and 6 deletions

View File

@@ -0,0 +1,182 @@
import { Router, type Request, type Response } from "express";
import { randomBytes } from "crypto";
import { verifyEvent, validateEvent } from "nostr-tools";
import { trustService } from "../lib/trust.js";
import { makeLogger } from "../lib/logger.js";
const logger = makeLogger("identity");
const router = Router();
// ── In-memory nonce store (TTL = 5 minutes) ───────────────────────────────────
// Nonces are single-use: consumed on first successful verify.
const NONCE_TTL_MS = 5 * 60 * 1000;
interface ChallengeEntry {
nonce: string;
expiresAt: number;
}
const challenges = new Map<string, ChallengeEntry>();
// Cleanup stale nonces periodically
setInterval(() => {
const now = Date.now();
for (const [key, entry] of challenges) {
if (now > entry.expiresAt) challenges.delete(key);
}
}, 60_000);
// ── POST /identity/challenge ──────────────────────────────────────────────────
// Returns a time-limited nonce the client must sign with their Nostr key.
router.post("/identity/challenge", (_req: Request, res: Response) => {
const nonce = randomBytes(32).toString("hex");
const expiresAt = Date.now() + NONCE_TTL_MS;
challenges.set(nonce, { nonce, expiresAt });
res.json({
nonce,
expiresAt: new Date(expiresAt).toISOString(),
});
});
// ── POST /identity/verify ─────────────────────────────────────────────────────
// Accepts a NIP-01 signed event whose `content` field contains the nonce.
// Verifies the signature, consumes the nonce, upserts the identity row,
// and returns a signed `nostr_token` valid for 24 h.
//
// Body: { event: NostrEvent }
// event.pubkey — 64-char hex pubkey
// event.content — the nonce returned by /identity/challenge
// event.kind — any (27235 recommended per NIP-98, but not enforced)
router.post("/identity/verify", async (req: Request, res: Response) => {
const { event } = req.body as { event?: unknown };
if (!event || typeof event !== "object") {
res.status(400).json({ error: "Body must include 'event' (Nostr signed event)" });
return;
}
// ── Validate event structure ──────────────────────────────────────────────
const ev = event as Record<string, unknown>;
const pubkey = ev["pubkey"];
const content = ev["content"];
if (typeof pubkey !== "string" || !/^[0-9a-f]{64}$/.test(pubkey)) {
res.status(400).json({ error: "event.pubkey must be a 64-char hex string" });
return;
}
if (typeof content !== "string" || content.trim().length === 0) {
res.status(400).json({ error: "event.content must be the nonce string" });
return;
}
const nonce = content.trim();
// ── Check nonce ──────────────────────────────────────────────────────────
const entry = challenges.get(nonce);
if (!entry) {
res.status(401).json({ error: "Nonce not found or already consumed. Request a new challenge." });
return;
}
if (Date.now() > entry.expiresAt) {
challenges.delete(nonce);
res.status(401).json({ error: "Nonce expired. Request a new challenge." });
return;
}
// ── Verify Nostr signature ────────────────────────────────────────────────
if (!validateEvent(ev as Parameters<typeof validateEvent>[0])) {
res.status(401).json({ error: "Invalid Nostr event structure" });
return;
}
let valid = false;
try {
valid = verifyEvent(ev as Parameters<typeof verifyEvent>[0]);
} catch {
valid = false;
}
if (!valid) {
res.status(401).json({ error: "Nostr signature verification failed" });
return;
}
// ── Consume nonce (single-use) ────────────────────────────────────────────
challenges.delete(nonce);
// ── Upsert identity & issue token ────────────────────────────────────────
try {
const identity = await trustService.getOrCreate(pubkey);
const token = trustService.issueToken(pubkey);
const tierInfo = await trustService.getIdentityWithDecay(pubkey);
logger.info("identity verified", {
pubkey: pubkey.slice(0, 8),
tier: identity.tier,
score: identity.trustScore,
});
res.json({
pubkey,
nostr_token: token,
trust: {
tier: tierInfo?.tier ?? identity.tier,
score: tierInfo?.trustScore ?? identity.trustScore,
interactionCount: identity.interactionCount,
memberSince: identity.createdAt.toISOString(),
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to upsert identity";
logger.error("identity verify failed", { error: message });
res.status(500).json({ error: message });
}
});
// ── GET /identity/me ──────────────────────────────────────────────────────────
// Look up the trust profile for a verified nostr_token.
router.get("/identity/me", async (req: Request, res: Response) => {
const raw = req.headers["x-nostr-token"];
const token = typeof raw === "string" ? raw.trim() : null;
if (!token) {
res.status(401).json({ error: "Missing X-Nostr-Token header" });
return;
}
const parsed = trustService.verifyToken(token);
if (!parsed) {
res.status(401).json({ error: "Invalid or expired nostr_token" });
return;
}
try {
const identity = await trustService.getIdentityWithDecay(parsed.pubkey);
if (!identity) {
res.status(404).json({ error: "Identity not found" });
return;
}
res.json({
pubkey: identity.pubkey,
trust: {
tier: identity.tier,
score: identity.trustScore,
interactionCount: identity.interactionCount,
satsAbsorbedToday: identity.satsAbsorbedToday,
memberSince: identity.createdAt.toISOString(),
lastSeen: identity.lastSeen.toISOString(),
},
});
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch identity" });
}
});
export default router;

View File

@@ -10,6 +10,7 @@ import uiRouter from "./ui.js";
import nodeDiagnosticsRouter from "./node-diagnostics.js";
import metricsRouter from "./metrics.js";
import worldRouter from "./world.js";
import identityRouter from "./identity.js";
const router: IRouter = Router();
@@ -18,6 +19,7 @@ router.use(metricsRouter);
router.use(jobsRouter);
router.use(bootstrapRouter);
router.use(sessionsRouter);
router.use(identityRouter);
router.use(demoRouter);
router.use(testkitRouter);
router.use(uiRouter);

View File

@@ -11,6 +11,7 @@ import { eventBus } from "../lib/event-bus.js";
import { streamRegistry } from "../lib/stream-registry.js";
import { makeLogger } from "../lib/logger.js";
import { latencyHistogram } from "../lib/histogram.js";
import { trustService } from "../lib/trust.js";
const logger = makeLogger("jobs");
@@ -151,6 +152,12 @@ async function runWorkInBackground(jobId: string, request: string, workAmountSat
refundState,
});
eventBus.publish({ type: "job:completed", jobId, result: workResult.result });
// Trust scoring — fire and forget
const completedJob = await getJobById(jobId);
if (completedJob?.nostrPubkey) {
void trustService.recordSuccess(completedJob.nostrPubkey, actualAmountSats);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Execution error";
streamRegistry.end(jobId);
@@ -239,6 +246,17 @@ async function advanceJob(job: Job): Promise<Job | null> {
// ── POST /jobs ────────────────────────────────────────────────────────────────
// ── Resolve Nostr pubkey from token header or body ────────────────────────────
function resolveNostrPubkey(req: Request): string | null {
const header = req.headers["x-nostr-token"];
const bodyToken = req.body?.nostr_token;
const raw = typeof header === "string" ? header : (typeof bodyToken === "string" ? bodyToken : null);
if (!raw) return null;
const parsed = trustService.verifyToken(raw.trim());
return parsed?.pubkey ?? null;
}
router.post("/jobs", jobsLimiter, async (req: Request, res: Response) => {
const parseResult = CreateJobBody.safeParse(req.body);
if (!parseResult.success) {
@@ -251,6 +269,9 @@ router.post("/jobs", jobsLimiter, async (req: Request, res: Response) => {
}
const { request } = parseResult.data;
// Optionally bind a Nostr identity
const nostrPubkey = resolveNostrPubkey(req);
try {
const evalFee = pricingService.calculateEvalFeeSats();
const jobId = randomUUID();
@@ -260,7 +281,14 @@ router.post("/jobs", jobsLimiter, async (req: Request, res: Response) => {
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, createdAt });
await tx.insert(jobs).values({
id: jobId,
request,
state: "awaiting_eval_payment",
evalAmountSats: evalFee,
createdAt,
...(nostrPubkey ? { nostrPubkey } : {}),
});
await tx.insert(invoices).values({
id: invoiceId,
jobId,
@@ -273,11 +301,22 @@ router.post("/jobs", jobsLimiter, async (req: Request, res: Response) => {
await tx.update(jobs).set({ evalInvoiceId: invoiceId, updatedAt: new Date() }).where(eq(jobs.id, jobId));
});
logger.info("job created", { jobId, evalAmountSats: evalFee, stubMode: lnbitsService.stubMode });
logger.info("job created", {
jobId,
evalAmountSats: evalFee,
stubMode: lnbitsService.stubMode,
nostrPubkey: nostrPubkey?.slice(0, 8),
});
const trust = nostrPubkey
? await trustService.getIdentityWithDecay(nostrPubkey)
: null;
res.status(201).json({
jobId,
createdAt: createdAt.toISOString(),
...(nostrPubkey ? { nostrPubkey } : {}),
...(trust ? { trust_tier: trust.tier } : {}),
evalInvoice: {
paymentRequest: lnbitsInvoice.paymentRequest,
amountSats: evalFee,
@@ -305,11 +344,17 @@ router.get("/jobs/:id", async (req: Request, res: Response) => {
const advanced = await advanceJob(job);
if (advanced) job = advanced;
const trustTier = job.nostrPubkey
? await trustService.getTier(job.nostrPubkey)
: undefined;
const base = {
jobId: job.id,
state: job.state,
createdAt: job.createdAt.toISOString(),
completedAt: job.state === "complete" ? job.updatedAt.toISOString() : null,
...(job.nostrPubkey ? { nostrPubkey: job.nostrPubkey } : {}),
...(trustTier ? { trust_tier: trustTier } : {}),
};
switch (job.state) {

View File

@@ -8,6 +8,7 @@ import { eventBus } from "../lib/event-bus.js";
import { agentService } from "../lib/agent.js";
import { pricingService } from "../lib/pricing.js";
import { getBtcPriceUsd, usdToSats } from "../lib/btc-oracle.js";
import { trustService } from "../lib/trust.js";
const router = Router();
@@ -41,13 +42,15 @@ function extractMacaroon(req: Request): string | null {
return null;
}
function sessionView(session: Session, includeInvoice = false) {
function sessionView(session: Session, includeInvoice = false, trustTier?: string) {
const base = {
sessionId: session.id,
state: session.state,
balanceSats: session.balanceSats,
expiresAt: session.expiresAt?.toISOString() ?? null,
minimumBalanceSats: MIN_BALANCE_SATS,
...(session.nostrPubkey ? { nostrPubkey: session.nostrPubkey } : {}),
...(trustTier ? { trust_tier: trustTier } : {}),
...(session.macaroon && (session.state === "active" || session.state === "paused")
? { macaroon: session.macaroon }
: {}),
@@ -133,6 +136,17 @@ async function advanceTopup(session: Session): Promise<Session> {
return updated[0] ?? session;
}
// ── Resolve Nostr pubkey from token header or body ────────────────────────────
function resolveNostrPubkey(req: Request): string | null {
const header = req.headers["x-nostr-token"];
const bodyToken = req.body?.nostr_token;
const raw = typeof header === "string" ? header : (typeof bodyToken === "string" ? bodyToken : null);
if (!raw) return null;
const parsed = trustService.verifyToken(raw.trim());
return parsed?.pubkey ?? null;
}
// ── POST /sessions ─────────────────────────────────────────────────────────────
router.post("/sessions", sessionsLimiter, async (req: Request, res: Response) => {
@@ -146,6 +160,9 @@ router.post("/sessions", sessionsLimiter, async (req: Request, res: Response) =>
return;
}
// Optionally bind a Nostr identity
const nostrPubkey = resolveNostrPubkey(req);
try {
const sessionId = randomUUID();
const invoice = await lnbitsService.createInvoice(amountSats, `Session deposit ${sessionId}`);
@@ -159,11 +176,18 @@ router.post("/sessions", sessionsLimiter, async (req: Request, res: Response) =>
depositPaymentRequest: invoice.paymentRequest,
depositPaid: false,
expiresAt: new Date(Date.now() + EXPIRY_MS),
...(nostrPubkey ? { nostrPubkey } : {}),
});
const trust = nostrPubkey
? await trustService.getIdentityWithDecay(nostrPubkey)
: null;
res.status(201).json({
sessionId,
state: "awaiting_payment",
...(nostrPubkey ? { nostrPubkey } : {}),
...(trust ? { trust_tier: trust.tier } : {}),
invoice: {
paymentRequest: invoice.paymentRequest,
amountSats,
@@ -203,7 +227,11 @@ router.get("/sessions/:id", async (req: Request, res: Response) => {
session = await advanceTopup(session);
}
res.json(sessionView(session, true));
const trustTier = session.nostrPubkey
? await trustService.getTier(session.nostrPubkey)
: undefined;
res.json(sessionView(session, true, trustTier));
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch session" });
}
@@ -345,6 +373,15 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => {
.where(eq(sessions.id, id));
});
// ── Trust scoring ────────────────────────────────────────────────────────
if (session.nostrPubkey) {
if (finalState === "complete") {
void trustService.recordSuccess(session.nostrPubkey, debitedSats);
} else if (finalState === "rejected") {
void trustService.recordFailure(session.nostrPubkey, reason ?? "rejected");
}
}
res.json({
requestId,
state: finalState,