slice 1e-1f: tRPC router (auth + market.snapshot) + node:http server
auth.signup/login/logout/me with signed HMAC session cookies + scrypt hashing; market.snapshot mega-endpoint (quote+candles+sector). node:http server mounts tRPC at /api/trpc + /health + background drain loop. Live-verified: real NVDA 194.97/65 candles/Technology served after stale-while-revalidate drain. 36 tests green.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
// Investor Flow — tRPC context + auth/session (DESIGN.md §2.2 auth/session seam).
|
||||
// Signed session cookies (HMAC), scrypt password hashing (design specified argon2id;
|
||||
// adapted to built-in scrypt — reversible, OWASP-approved).
|
||||
import { randomUUID, randomBytes, scryptSync, timingSafeEqual, createHmac } from 'node:crypto';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { CacheRepository } from '../cache/CacheRepository.ts';
|
||||
|
||||
export const SESSION_COOKIE = 'iflow_session';
|
||||
const SESSION_SECRET = process.env.IFLOW_SESSION_SECRET ?? 'dev-secret-change-me';
|
||||
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
export interface Context {
|
||||
db: DatabaseSync;
|
||||
cache: CacheRepository;
|
||||
resHeaders: Headers; // mutable; procedures append Set-Cookie here (applied to Response by the fetch adapter)
|
||||
userId: string | null; // resolved from the session cookie; null = unauthenticated
|
||||
}
|
||||
export interface CreateContextOpts { req: Request; resHeaders: Headers; info: unknown; }
|
||||
|
||||
// ----- signed session cookie (token.mac) -----
|
||||
function sign(token: string): string { return `${token}.${createHmac('sha256', SESSION_SECRET).update(token).digest('hex')}`; }
|
||||
function unsign(signed: string): string | null {
|
||||
const idx = signed.lastIndexOf('.');
|
||||
if (idx <= 0) return null;
|
||||
const token = signed.slice(0, idx);
|
||||
const mac = signed.slice(idx + 1);
|
||||
const expected = createHmac('sha256', SESSION_SECRET).update(token).digest('hex');
|
||||
const a = Buffer.from(mac); const b = Buffer.from(expected);
|
||||
return a.length === b.length && timingSafeEqual(a, b) ? token : null;
|
||||
}
|
||||
|
||||
export function sessionCookie(sessionId: string): string {
|
||||
// HttpOnly + SameSite=Lax. Secure omitted for slice-1 local http; add behind TLS in deployment slice.
|
||||
return `${SESSION_COOKIE}=${sign(sessionId)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${SESSION_TTL_MS / 1000}`;
|
||||
}
|
||||
export function clearCookie(): string { return `${SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`; }
|
||||
|
||||
export function parseCookies(req: Request): Record<string, string> {
|
||||
const header = req.headers.get('cookie') ?? '';
|
||||
const out: Record<string, string> = {};
|
||||
for (const part of header.split(';')) {
|
||||
const eq = part.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const k = part.slice(0, eq).trim(); const v = part.slice(eq + 1).trim();
|
||||
if (k) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createSession(database: DatabaseSync, userId: string): { sessionId: string; cookie: string } {
|
||||
const sessionId = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const expires = new Date(Date.now() + SESSION_TTL_MS).toISOString();
|
||||
database.prepare('INSERT INTO sessions (id,user_id,expires_at,created_at) VALUES (?,?,?,?)').run(sessionId, userId, expires, now);
|
||||
return { sessionId, cookie: sessionCookie(sessionId) };
|
||||
}
|
||||
|
||||
export function resolveSessionUserId(database: DatabaseSync, req: Request): string | null {
|
||||
const signed = parseCookies(req)[SESSION_COOKIE];
|
||||
if (!signed) return null;
|
||||
const token = unsign(signed);
|
||||
if (!token) return null;
|
||||
const row = database.prepare('SELECT user_id, expires_at FROM sessions WHERE id=?').get(token) as { user_id: string; expires_at: string } | undefined;
|
||||
if (!row) return null;
|
||||
if (Date.parse(row.expires_at) < Date.now()) return null;
|
||||
return row.user_id;
|
||||
}
|
||||
|
||||
// ----- password hashing (scrypt) -----
|
||||
export function hashPassword(pw: string): string {
|
||||
const salt = randomBytes(16);
|
||||
const hash = scryptSync(pw, salt, 64);
|
||||
return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`;
|
||||
}
|
||||
export function verifyPassword(pw: string, stored: string): boolean {
|
||||
const parts = stored.split('$');
|
||||
if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
|
||||
const salt = Buffer.from(parts[1], 'hex');
|
||||
const hash = Buffer.from(parts[2], 'hex');
|
||||
const test = scryptSync(pw, salt, 64);
|
||||
return hash.length === test.length && timingSafeEqual(hash, test);
|
||||
}
|
||||
|
||||
// ----- context factory: 1f wires real db+cache; tests inject in-memory -----
|
||||
export function makeCreateContext(opts: { db: DatabaseSync; cache: CacheRepository }) {
|
||||
return ({ req, resHeaders }: CreateContextOpts): Context => ({
|
||||
db: opts.db,
|
||||
cache: opts.cache,
|
||||
resHeaders,
|
||||
userId: resolveSessionUserId(opts.db, req),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user