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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user