UI: warm daylight design system (Tailwind v4 @theme palette, gh-* component classes, watercolor grain, Zen Maru Gothic + Klee One fonts), animated SSR-safe GhibliBackground (drifting clouds, meadow hills, soot sprites), and a full reskin of navbar, connect button, dapp page, loan cards, settings modal, and readme. Fixes the bg-white-on-dark loan-card inconsistency. Web3/business logic untouched. Docs: converted docs/ into an Obsidian vault (frontmatter, [[wikilinks]], callouts, Home MOC, folders Architecture/Operations/Audits) and added a full-project audit note (Project Audit 2026-06). Redacted a real leaked Schedy key value from the security audit example (rotate it at Schedy). Also commits the previously-untracked server layer: app/api (cron + tasks routes) and lib (redis, ssrf-guard, task-store). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
92 lines
2.6 KiB
TypeScript
92 lines
2.6 KiB
TypeScript
import { ensureRedis, redis } from './redis';
|
|
|
|
export interface Task {
|
|
id: string;
|
|
url: string;
|
|
executeAt: number; // epoch seconds
|
|
headers: Record<string, string>;
|
|
payload: any;
|
|
retries: number;
|
|
retryInterval: number; // milliseconds
|
|
createdAt: number;
|
|
}
|
|
|
|
const TASK_PREFIX = 'mortgagefi:task:';
|
|
const TASK_ZSET = 'mortgagefi:tasks:by:time';
|
|
const RATE_LIMIT_PREFIX = 'mortgagefi:ratelimit:';
|
|
|
|
export async function saveTask(task: Task): Promise<void> {
|
|
const r = ensureRedis();
|
|
const pipe = r.pipeline();
|
|
pipe.set(`${TASK_PREFIX}${task.id}`, JSON.stringify(task));
|
|
pipe.zadd(TASK_ZSET, { score: task.executeAt, member: task.id });
|
|
await pipe.exec();
|
|
}
|
|
|
|
export async function getTask(id: string): Promise<Task | null> {
|
|
const r = ensureRedis();
|
|
const data = await r.get<string>(`${TASK_PREFIX}${id}`);
|
|
if (!data) return null;
|
|
try {
|
|
return JSON.parse(data) as Task;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function deleteTask(id: string): Promise<void> {
|
|
const r = ensureRedis();
|
|
const pipe = r.pipeline();
|
|
pipe.del(`${TASK_PREFIX}${id}`);
|
|
pipe.zrem(TASK_ZSET, id);
|
|
await pipe.exec();
|
|
}
|
|
|
|
export async function listDueTasks(before: number): Promise<Task[]> {
|
|
const r = ensureRedis();
|
|
const ids = await r.zrange<string[]>(TASK_ZSET, 0, before, { byScore: true });
|
|
if (!ids || ids.length === 0) return [];
|
|
|
|
const tasks: Task[] = [];
|
|
for (const id of ids) {
|
|
const t = await getTask(id);
|
|
if (t) tasks.push(t);
|
|
}
|
|
return tasks;
|
|
}
|
|
|
|
export async function listAllTasks(): Promise<Task[]> {
|
|
const r = ensureRedis();
|
|
const keys = await r.keys(`${TASK_PREFIX}*`);
|
|
if (!keys || keys.length === 0) return [];
|
|
|
|
const tasks: Task[] = [];
|
|
for (const key of keys) {
|
|
const data = await r.get<string>(key);
|
|
if (data) {
|
|
try {
|
|
tasks.push(JSON.parse(data) as Task);
|
|
} catch { /* ignore malformed */ }
|
|
}
|
|
}
|
|
return tasks.sort((a, b) => a.executeAt - b.executeAt);
|
|
}
|
|
|
|
// Simple per-IP rate limiter using Redis
|
|
export async function checkRateLimit(ip: string, maxRequests: number, windowSeconds: number): Promise<boolean> {
|
|
if (!redis) return true; // if redis not configured, allow (dev mode)
|
|
const key = `${RATE_LIMIT_PREFIX}${ip}`;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const windowStart = now - windowSeconds;
|
|
|
|
const pipe = redis.pipeline();
|
|
pipe.zremrangebyscore(key, 0, windowStart);
|
|
pipe.zcard(key);
|
|
pipe.zadd(key, { score: now, member: `${now}:${Math.random()}` });
|
|
pipe.expire(key, windowSeconds + 1);
|
|
|
|
const results = await pipe.exec();
|
|
const count = (results?.[1] as number) || 0;
|
|
return count < maxRequests;
|
|
}
|