feat(ui): Ghibli/Miyazaki reskin + Obsidian docs vault + project audit

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>
This commit is contained in:
Siavash Sameni
2026-06-14 08:13:53 +04:00
parent cf76322008
commit 6ae581ab2e
25 changed files with 4245 additions and 369 deletions

91
lib/task-store.ts Normal file
View File

@@ -0,0 +1,91 @@
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;
}