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:
@@ -18,6 +18,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"express": "^5",
|
||||
"express-rate-limit": "^8.3.1",
|
||||
"nostr-tools": "^2.23.3",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -40,8 +40,8 @@ app.use(
|
||||
},
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "X-Session-Token"],
|
||||
exposedHeaders: ["X-Session-Token"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "X-Session-Token", "X-Nostr-Token"],
|
||||
exposedHeaders: ["X-Session-Token", "X-Nostr-Token"],
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
192
artifacts/api-server/src/lib/trust.ts
Normal file
192
artifacts/api-server/src/lib/trust.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { createHmac, randomBytes } from "crypto";
|
||||
import { db, nostrIdentities, type NostrIdentity, type TrustTier } from "@workspace/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { makeLogger } from "./logger.js";
|
||||
|
||||
const logger = makeLogger("trust");
|
||||
|
||||
// ── Env-var helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function envInt(name: string, fallback: number): number {
|
||||
const raw = parseInt(process.env[name] ?? "", 10);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
||||
}
|
||||
|
||||
// ── Tier score boundaries (inclusive lower bound) ─────────────────────────────
|
||||
// Override with TRUST_TIER_ESTABLISHED, TRUST_TIER_TRUSTED, TRUST_TIER_ELITE.
|
||||
|
||||
const TIER_ESTABLISHED = envInt("TRUST_TIER_ESTABLISHED", 10);
|
||||
const TIER_TRUSTED = envInt("TRUST_TIER_TRUSTED", 50);
|
||||
const TIER_ELITE = envInt("TRUST_TIER_ELITE", 200);
|
||||
|
||||
// Points per event
|
||||
const SCORE_PER_SUCCESS = envInt("TRUST_SCORE_PER_SUCCESS", 2);
|
||||
const SCORE_PER_FAILURE = envInt("TRUST_SCORE_PER_FAILURE", 5);
|
||||
|
||||
// Soft decay: points lost per day absent, applied lazily on read
|
||||
const DECAY_ABSENT_DAYS = envInt("TRUST_DECAY_ABSENT_DAYS", 30);
|
||||
const DECAY_PER_DAY = envInt("TRUST_DECAY_PER_DAY", 1);
|
||||
|
||||
// ── HMAC token for nostr_token auth ──────────────────────────────────────────
|
||||
// Token format: `{pubkey}:{expiry}:{hmac}`
|
||||
|
||||
const TOKEN_SECRET: string = (() => {
|
||||
const s = process.env["TIMMY_TOKEN_SECRET"];
|
||||
if (s && s.length >= 32) return s;
|
||||
const generated = randomBytes(32).toString("hex");
|
||||
logger.warn("TIMMY_TOKEN_SECRET not set — generated ephemeral secret (tokens expire on restart)");
|
||||
return generated;
|
||||
})();
|
||||
|
||||
const TOKEN_TTL_SECS = envInt("NOSTR_TOKEN_TTL_SECS", 86400); // 24 h
|
||||
|
||||
function signToken(pubkey: string, expiry: number): string {
|
||||
const payload = `${pubkey}:${expiry}`;
|
||||
const hmac = createHmac("sha256", TOKEN_SECRET).update(payload).digest("hex");
|
||||
return `${payload}:${hmac}`;
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): { pubkey: string; expiry: number } | null {
|
||||
const parts = token.split(":");
|
||||
if (parts.length !== 3) return null;
|
||||
const [pubkey, expiryStr, hmac] = parts as [string, string, string];
|
||||
const expiry = parseInt(expiryStr, 10);
|
||||
if (!Number.isFinite(expiry) || Date.now() / 1000 > expiry) return null;
|
||||
const expected = createHmac("sha256", TOKEN_SECRET)
|
||||
.update(`${pubkey}:${expiry}`)
|
||||
.digest("hex");
|
||||
if (expected !== hmac) return null;
|
||||
return { pubkey, expiry };
|
||||
}
|
||||
|
||||
export function issueToken(pubkey: string): string {
|
||||
const expiry = Math.floor(Date.now() / 1000) + TOKEN_TTL_SECS;
|
||||
return signToken(pubkey, expiry);
|
||||
}
|
||||
|
||||
// ── Trust score helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function computeTier(score: number): TrustTier {
|
||||
if (score >= TIER_ELITE) return "elite";
|
||||
if (score >= TIER_TRUSTED) return "trusted";
|
||||
if (score >= TIER_ESTABLISHED) return "established";
|
||||
return "new";
|
||||
}
|
||||
|
||||
function applyDecay(identity: NostrIdentity): number {
|
||||
const daysSeen =
|
||||
(Date.now() - identity.lastSeen.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (daysSeen < DECAY_ABSENT_DAYS) return identity.trustScore;
|
||||
const daysAbsent = Math.floor(daysSeen - DECAY_ABSENT_DAYS);
|
||||
return Math.max(0, identity.trustScore - daysAbsent * DECAY_PER_DAY);
|
||||
}
|
||||
|
||||
// ── TrustService ──────────────────────────────────────────────────────────────
|
||||
|
||||
export class TrustService {
|
||||
// Upsert a new pubkey with default values.
|
||||
async getOrCreate(pubkey: string): Promise<NostrIdentity> {
|
||||
const existing = await this.getIdentity(pubkey);
|
||||
if (existing) return existing;
|
||||
|
||||
const rows = await db
|
||||
.insert(nostrIdentities)
|
||||
.values({ pubkey })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
const row = rows[0];
|
||||
if (row) return row;
|
||||
|
||||
// Race: another request inserted first
|
||||
return (await this.getIdentity(pubkey))!;
|
||||
}
|
||||
|
||||
async getIdentity(pubkey: string): Promise<NostrIdentity | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(nostrIdentities)
|
||||
.where(eq(nostrIdentities.pubkey, pubkey))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// Returns the trust tier for a pubkey, or "new" if unknown.
|
||||
async getTier(pubkey: string): Promise<TrustTier> {
|
||||
const identity = await this.getIdentity(pubkey);
|
||||
if (!identity) return "new";
|
||||
const decayedScore = applyDecay(identity);
|
||||
return computeTier(decayedScore);
|
||||
}
|
||||
|
||||
// Returns full identity row with decayed score applied (does NOT persist decay).
|
||||
async getIdentityWithDecay(pubkey: string): Promise<(NostrIdentity & { tier: TrustTier }) | null> {
|
||||
const identity = await this.getIdentity(pubkey);
|
||||
if (!identity) return null;
|
||||
const score = applyDecay(identity);
|
||||
const tier = computeTier(score);
|
||||
return { ...identity, trustScore: score, tier };
|
||||
}
|
||||
|
||||
// Called after a successful (paid) interaction.
|
||||
async recordSuccess(pubkey: string, satsCost: number): Promise<void> {
|
||||
const identity = await this.getOrCreate(pubkey);
|
||||
const newScore = identity.trustScore + SCORE_PER_SUCCESS;
|
||||
const newTier = computeTier(newScore);
|
||||
|
||||
await db
|
||||
.update(nostrIdentities)
|
||||
.set({
|
||||
trustScore: newScore,
|
||||
tier: newTier,
|
||||
interactionCount: identity.interactionCount + 1,
|
||||
lastSeen: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(nostrIdentities.pubkey, pubkey));
|
||||
|
||||
logger.info("trust: success recorded", {
|
||||
pubkey: pubkey.slice(0, 8),
|
||||
newScore,
|
||||
newTier,
|
||||
satsCost,
|
||||
});
|
||||
}
|
||||
|
||||
// Called after a failed, rejected, or abusive interaction.
|
||||
async recordFailure(pubkey: string, reason: string): Promise<void> {
|
||||
const identity = await this.getOrCreate(pubkey);
|
||||
const newScore = Math.max(0, identity.trustScore - SCORE_PER_FAILURE);
|
||||
const newTier = computeTier(newScore);
|
||||
|
||||
await db
|
||||
.update(nostrIdentities)
|
||||
.set({
|
||||
trustScore: newScore,
|
||||
tier: newTier,
|
||||
interactionCount: identity.interactionCount + 1,
|
||||
lastSeen: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(nostrIdentities.pubkey, pubkey));
|
||||
|
||||
logger.info("trust: failure recorded", {
|
||||
pubkey: pubkey.slice(0, 8),
|
||||
newScore,
|
||||
newTier,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
// Issue a signed identity token for a verified pubkey.
|
||||
issueToken(pubkey: string): string {
|
||||
return issueToken(pubkey);
|
||||
}
|
||||
|
||||
// Verify and parse an X-Nostr-Token header value.
|
||||
verifyToken(token: string): { pubkey: string; expiry: number } | null {
|
||||
return verifyToken(token);
|
||||
}
|
||||
}
|
||||
|
||||
export const trustService = new TrustService();
|
||||
182
artifacts/api-server/src/routes/identity.ts
Normal file
182
artifacts/api-server/src/routes/identity.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from "./messages";
|
||||
export * from "./bootstrap-jobs";
|
||||
export * from "./world-events";
|
||||
export * from "./sessions";
|
||||
export * from "./nostr-identities";
|
||||
|
||||
@@ -36,6 +36,9 @@ export const jobs = pgTable("jobs", {
|
||||
actualOutputTokens: integer("actual_output_tokens"),
|
||||
actualCostUsd: real("actual_cost_usd"),
|
||||
|
||||
// Optional Nostr identity bound at job creation
|
||||
nostrPubkey: text("nostr_pubkey"),
|
||||
|
||||
// ── Post-work honest accounting & refund ─────────────────────────────────
|
||||
actualAmountSats: integer("actual_amount_sats"),
|
||||
refundAmountSats: integer("refund_amount_sats"),
|
||||
|
||||
39
lib/db/src/schema/nostr-identities.ts
Normal file
39
lib/db/src/schema/nostr-identities.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { pgTable, text, timestamp, integer } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// ── Trust tier labels ─────────────────────────────────────────────────────────
|
||||
// Boundaries are env-var overridable via TrustService.
|
||||
|
||||
export const TRUST_TIERS = ["new", "established", "trusted", "elite"] as const;
|
||||
export type TrustTier = (typeof TRUST_TIERS)[number];
|
||||
|
||||
// ── nostr_identities ──────────────────────────────────────────────────────────
|
||||
// One row per Nostr pubkey (64-char lowercase hex). Trust score drives pricing
|
||||
// decisions in the cost-routing layer (Task #27).
|
||||
|
||||
export const nostrIdentities = pgTable("nostr_identities", {
|
||||
pubkey: text("pubkey").primaryKey(),
|
||||
|
||||
trustScore: integer("trust_score").notNull().default(0),
|
||||
tier: text("tier").$type<TrustTier>().notNull().default("new"),
|
||||
interactionCount: integer("interaction_count").notNull().default(0),
|
||||
|
||||
// Rolling daily absorption budget (reset by TrustService on read)
|
||||
satsAbsorbedToday: integer("sats_absorbed_today").notNull().default(0),
|
||||
absorbedResetAt: timestamp("absorbed_reset_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
|
||||
lastSeen: timestamp("last_seen", { withTimezone: true }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const insertNostrIdentitySchema = createInsertSchema(nostrIdentities).omit({
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
|
||||
export type NostrIdentity = typeof nostrIdentities.$inferSelect;
|
||||
export type InsertNostrIdentity = z.infer<typeof insertNostrIdentitySchema>;
|
||||
@@ -43,6 +43,9 @@ export const sessions = pgTable("sessions", {
|
||||
// Auth token — issued once when session activates; required for requests
|
||||
macaroon: text("macaroon"),
|
||||
|
||||
// Optional Nostr identity bound at session creation
|
||||
nostrPubkey: text("nostr_pubkey"),
|
||||
|
||||
// TTL — refreshed on each successful request
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
|
||||
|
||||
70
pnpm-lock.yaml
generated
70
pnpm-lock.yaml
generated
@@ -193,6 +193,9 @@ importers:
|
||||
express-rate-limit:
|
||||
specifier: ^8.3.1
|
||||
version: 8.3.1(express@5.2.1)
|
||||
nostr-tools:
|
||||
specifier: ^2.23.3
|
||||
version: 2.23.3(typescript@5.9.3)
|
||||
ws:
|
||||
specifier: ^8.19.0
|
||||
version: 8.19.0
|
||||
@@ -670,6 +673,18 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@noble/ciphers@2.1.1':
|
||||
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@noble/curves@2.0.1':
|
||||
resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@noble/hashes@2.0.1':
|
||||
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1415,6 +1430,15 @@ packages:
|
||||
resolution: {integrity: sha512-ngJcHGoCHmpWgYtNy08vmzFfLdQEkMpvaCQqNPPMNKq0QEXOv89e/rn+TZJZgPnRlY7fDIoIhn9lNgr+azBW+w==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@scure/base@2.0.0':
|
||||
resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==}
|
||||
|
||||
'@scure/bip32@2.0.1':
|
||||
resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==}
|
||||
|
||||
'@scure/bip39@2.0.1':
|
||||
resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==}
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1':
|
||||
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
||||
|
||||
@@ -2532,6 +2556,17 @@ packages:
|
||||
node-releases@2.0.36:
|
||||
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
|
||||
|
||||
nostr-tools@2.23.3:
|
||||
resolution: {integrity: sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==}
|
||||
peerDependencies:
|
||||
typescript: '>=5.0.0'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
nostr-wasm@0.1.0:
|
||||
resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==}
|
||||
|
||||
npm-run-path@6.0.0:
|
||||
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3418,6 +3453,14 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@noble/ciphers@2.1.1': {}
|
||||
|
||||
'@noble/curves@2.0.1':
|
||||
dependencies:
|
||||
'@noble/hashes': 2.0.1
|
||||
|
||||
'@noble/hashes@2.0.1': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -4285,6 +4328,19 @@ snapshots:
|
||||
dependencies:
|
||||
'@scalar/openapi-types': 0.5.4
|
||||
|
||||
'@scure/base@2.0.0': {}
|
||||
|
||||
'@scure/bip32@2.0.1':
|
||||
dependencies:
|
||||
'@noble/curves': 2.0.1
|
||||
'@noble/hashes': 2.0.1
|
||||
'@scure/base': 2.0.0
|
||||
|
||||
'@scure/bip39@2.0.1':
|
||||
dependencies:
|
||||
'@noble/hashes': 2.0.1
|
||||
'@scure/base': 2.0.0
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1': {}
|
||||
|
||||
'@shikijs/engine-oniguruma@3.23.0':
|
||||
@@ -5345,6 +5401,20 @@ snapshots:
|
||||
|
||||
node-releases@2.0.36: {}
|
||||
|
||||
nostr-tools@2.23.3(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@noble/ciphers': 2.1.1
|
||||
'@noble/curves': 2.0.1
|
||||
'@noble/hashes': 2.0.1
|
||||
'@scure/base': 2.0.0
|
||||
'@scure/bip32': 2.0.1
|
||||
'@scure/bip39': 2.0.1
|
||||
nostr-wasm: 0.1.0
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
nostr-wasm@0.1.0: {}
|
||||
|
||||
npm-run-path@6.0.0:
|
||||
dependencies:
|
||||
path-key: 4.0.0
|
||||
|
||||
Reference in New Issue
Block a user