- 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
183 lines
6.6 KiB
TypeScript
183 lines
6.6 KiB
TypeScript
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;
|