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

@@ -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,