## What was built Full relay access control: relay_accounts table, RelayAccountService, trust hook, live policy enforcement, admin CRUD API, elite startup seed. ## DB schema (`lib/db/src/schema/relay-accounts.ts`) relay_accounts table: pubkey (PK, FK nostr_identities ON DELETE CASCADE), access_level (none/read/write), granted_by (text), granted_at, revoked_at, notes. Exported from lib/db/src/schema/index.ts. Pushed via pnpm run push. ## RelayAccountService (`artifacts/api-server/src/lib/relay-accounts.ts`) - getAccess(pubkey) → RelayAccessLevel (none if missing or revoked) - grant(pubkey, level, reason, grantedBy) — upsert; creates nostr_identity FK - revoke(pubkey, reason) — sets revokedAt, accessLevel→none, grantedBy→"manual-revoked" The "manual-revoked" marker prevents syncFromTrustTier from auto-reinstating. Only explicit admin grant() can restore access after revocation. - syncFromTrustTier(pubkey) — fetches tier from DB internally (no tier param to avoid caller drift). Respects: manual-revoked (skip), manual active (upgrade only), auto-tier (full sync). Never auto-reinstates revoked accounts. - seedElite(pubkey, notes) — upserts nostr_identities with tier="elite" + trustScore=200, then grants relay write access as a permanent manual grant. Called at startup for Timmy's own pubkey. - list(opts) — returns all accounts, filtered by activeOnly if requested. - Tier→access: new=none, established/trusted/elite=write (env-overridable) ## Trust hook (`artifacts/api-server/src/lib/trust.ts`) recordSuccess + recordFailure both call syncFromTrustTier(pubkey) after DB write. Fire-and-forget with catch (trust flow is never blocked by relay errors). ## Policy endpoint (`artifacts/api-server/src/routes/relay.ts`) evaluatePolicy() async: queries relay_accounts.getAccess(). write→accept, read/none/missing→reject. DB error→reject (fail-closed). ## Admin routes (`artifacts/api-server/src/routes/admin-relay.ts`) ADMIN_SECRET Bearer auth (localhost-only fallback in dev; error log in prod). GET /api/admin/relay/accounts — list all accounts POST /api/admin/relay/accounts/:pk/grant — grant (level + notes) POST /api/admin/relay/accounts/:pk/revoke — revoke (sets manual-revoked) pubkey validation: 64-char lowercase hex only. ## Startup seed (`artifacts/api-server/src/index.ts`) Resolves pubkey from TIMMY_NOSTR_PUBKEY env first, falls back to timmyIdentityService.pubkeyHex. Calls seedElite() — idempotent upsert. Sets nostr_identities.tier="elite" alongside relay write access. ## Smoke test results (all pass) Timmy accept ✓; unknown reject ✓; grant→accept ✓; revoke→manual-revoked ✓; revoked stays rejected ✓; TypeScript 0 errors.
211 lines
7.8 KiB
TypeScript
211 lines
7.8 KiB
TypeScript
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";
|
|
import { relayAccountService } from "./relay-accounts.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.
|
|
// Decay is applied first so long-absent identities start from their decayed
|
|
// baseline rather than the raw stored score.
|
|
async recordSuccess(pubkey: string, satsCost: number): Promise<void> {
|
|
const identity = await this.getOrCreate(pubkey);
|
|
const decayedBase = applyDecay(identity);
|
|
const newScore = decayedBase + 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),
|
|
decayedBase,
|
|
newScore,
|
|
newTier,
|
|
satsCost,
|
|
});
|
|
|
|
// Sync relay access whenever the tier may have changed
|
|
relayAccountService.syncFromTrustTier(pubkey).catch((err) =>
|
|
logger.warn("relay sync failed after success", { pubkey: pubkey.slice(0, 8), err }),
|
|
);
|
|
}
|
|
|
|
// Called after a failed, rejected, or abusive interaction.
|
|
// Decay is applied first so mutations always start from the current true baseline.
|
|
async recordFailure(pubkey: string, reason: string): Promise<void> {
|
|
const identity = await this.getOrCreate(pubkey);
|
|
const decayedBase = applyDecay(identity);
|
|
const newScore = Math.max(0, decayedBase - 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),
|
|
decayedBase,
|
|
newScore,
|
|
newTier,
|
|
reason,
|
|
});
|
|
|
|
// Sync relay access on tier change (may revoke write on repeated failures)
|
|
relayAccountService.syncFromTrustTier(pubkey).catch((err) =>
|
|
logger.warn("relay sync failed after failure", { pubkey: pubkey.slice(0, 8), err }),
|
|
);
|
|
}
|
|
|
|
// 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();
|