From 11a075ff175ab4f5ba0911d9494f2b672b89b857 Mon Sep 17 00:00:00 2001 From: Bruno Pavelja Date: Tue, 7 Apr 2026 18:42:32 +0200 Subject: [PATCH 1/6] docs: add user authentication system design spec Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-07-user-authentication-design.md | 364 ++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-07-user-authentication-design.md diff --git a/docs/superpowers/specs/2026-04-07-user-authentication-design.md b/docs/superpowers/specs/2026-04-07-user-authentication-design.md new file mode 100644 index 0000000..794d1b5 --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-user-authentication-design.md @@ -0,0 +1,364 @@ +# User Authentication System — Design Spec + +## Overview + +Replace the existing bearer-token authentication with a full user account system supporting registration, login, password recovery, role-based access, and daily scan limits. Two auth providers are supported: self-hosted (bcrypt + SQLite + JWT) and Firebase Auth. Only one provider is active at a time, configured via environment variable. + +--- + +## 1. Auth Provider Architecture + +### Configuration + +```env +AUTH_PROVIDER=local # "local" | "firebase" + +# Firebase-only settings +FIREBASE_PROJECT_ID= +FIREBASE_API_KEY= +FIREBASE_SERVICE_ACCOUNT_PATH= +``` + +### Provider Interface + +Both providers implement the same `AuthProvider` interface so the rest of the codebase is provider-agnostic: + +```typescript +interface AuthProvider { + register(email: string, password: string): Promise + login(email: string, password: string): Promise + logout(userId: string): Promise + verifyToken(token: string): Promise + forgotPassword(email: string): Promise + resetPassword(token: string, newPassword: string): Promise + changePassword(userId: string, oldPassword: string, newPassword: string): Promise +} + +interface AuthResult { + accessToken: string + refreshToken: string + user: { id: string; email: string; role: string } +} + +interface TokenPayload { + userId: string + email: string + role: string +} +``` + +### Local Provider + +- Password hashing: bcrypt (cost factor 12) +- Access tokens: JWT (short-lived, e.g. 15 min) +- Refresh tokens: opaque token stored in DB, rotated on use +- Password reset: random token with expiration, sent via existing SMTP config + +### Firebase Provider + +- Frontend uses Firebase JS SDK for login/signup UI +- Backend uses Firebase Admin SDK to verify ID tokens +- No password storage on our side +- Password reset handled by Firebase's built-in flow +- User profile/role synced to `user_profiles` table on first login + +--- + +## 2. Database Schema + +### `users` table (local provider only) + +```sql +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_login_at TEXT, + reset_token TEXT, + reset_token_expires_at TEXT, + refresh_token TEXT +); +``` + +### `user_profiles` table (both providers) + +Stores role, settings, and metadata regardless of provider: + +```sql +CREATE TABLE user_profiles ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + settings JSON NOT NULL DEFAULT '{}', + last_login_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +### `scans` table — modification + +Add `user_id` column to associate scans with users: + +```sql +ALTER TABLE scans ADD COLUMN user_id TEXT REFERENCES user_profiles(id); +``` + +Existing scans (pre-auth) will have `user_id = NULL`. Demo scans also have `user_id = NULL`. + +--- + +## 3. Roles + +Two roles: **user** and **superadmin**. + +| Capability | user | superadmin | +|---|---|---| +| Run scans | Yes | Yes | +| View own scan history | Yes | Yes | +| View all users' scans | No | Yes | +| View user list / admin panel | No | Yes | +| Delete own scans | Yes | Yes | +| Delete any scan | No | Yes | +| Change own settings | Yes | Yes | + +--- + +## 4. Superadmin Configuration + +Each column in the admin user list is individually toggleable: + +```env +SUPERADMIN_VIEW_EMAIL=true +SUPERADMIN_VIEW_LAST_LOGIN=true +SUPERADMIN_VIEW_DAILY_SCANS=true +SUPERADMIN_VIEW_SCANNED_PAGES=true +``` + +All default to `true`. The `GET /api/admin/users` endpoint respects these flags and omits disabled fields from the response. + +--- + +## 5. Superadmin Creation — CLI + +```bash +npx recon-web create-admin --email admin@example.com --password SecurePass123! +``` + +- Only works with `AUTH_PROVIDER=local` +- Creates user with `role=superadmin` in both `users` and `user_profiles` tables +- If email already exists, promotes to superadmin +- For Firebase: superadmin is assigned by updating `role` in `user_profiles` table directly or via a future `promote-admin` CLI command that takes a Firebase UID + +--- + +## 6. Daily Scan Limits + +### Configuration + +```env +DAILY_SCAN_LIMIT_GLOBAL=0 # 0 = unlimited +DAILY_SCAN_LIMIT_USER=0 # 0 = unlimited +``` + +### Enforcement + +Before each scan, the API checks: +1. Global daily count (all users combined) against `DAILY_SCAN_LIMIT_GLOBAL` +2. Current user's daily count against `DAILY_SCAN_LIMIT_USER` + +If either limit is reached, return `429 Too Many Requests`. + +Daily counts reset at midnight UTC. + +### Visibility + +**API responses** on scan endpoints include limit info: + +```json +{ + "scan_limits": { + "user_daily": { "used": 3, "limit": 10, "remaining": 7 }, + "global_daily": { "used": 45, "limit": 100, "remaining": 55 } + } +} +``` + +When limit is `0` (unlimited), that category is omitted from the response. + +**`GET /api/auth/me`** also returns current scan limits so the frontend can display them before a scan is triggered. + +**Frontend** displays a badge/indicator on the scan page (e.g. "7/10 scans remaining today"). Hidden when unlimited. + +--- + +## 7. Registration Control + +```env +REGISTRATION_OPEN=true # true = anyone can register, false = closed +``` + +When `false`, `POST /api/auth/register` returns `403 Forbidden`. Sign Up button is hidden on frontend. Only superadmin can create users (future: invite flow). + +--- + +## 8. Demo Scan + +```env +DEMO_SCAN_URL=https://example.com +``` + +- Uses existing scheduler infrastructure to run one scan per day against the configured URL +- Result stored as a scan with `user_id = NULL` (system scan) +- Publicly accessible at `GET /api/demo` without authentication +- Frontend "View Demo" button links to a read-only results page for this scan + +--- + +## 9. API Endpoints + +### Auth endpoints (public) + +``` +POST /api/auth/register — create account (if REGISTRATION_OPEN=true) +POST /api/auth/login — returns access + refresh tokens +POST /api/auth/refresh — exchange refresh token for new access token +POST /api/auth/forgot-password — sends reset email +POST /api/auth/reset-password — reset password with token +``` + +### Auth endpoints (authenticated) + +``` +GET /api/auth/me — current user profile, settings, scan limits +PUT /api/auth/me — update profile (email, settings) +POST /api/auth/change-password — change password (requires old password) +POST /api/auth/logout — invalidate refresh token +``` + +### Admin endpoints (superadmin only) + +``` +GET /api/admin/users — list users (fields filtered by env config) +DELETE /api/admin/users/:id — delete user +PUT /api/admin/users/:id — update user role +``` + +### Existing endpoints — modifications + +- `POST /api` and `POST /api/stream` — require auth, associate scan with user, return `scan_limits` in response +- `GET /api/history` — returns only current user's scans (superadmin can pass `?all=true`) +- `GET /api/demo` — new, public, no auth required + +### Public endpoints (no auth required) + +``` +GET /health +GET /api/handlers +GET /api/demo +GET /docs +``` + +--- + +## 10. Frontend Changes + +### Gear Icon Menu (replaces Settings nav link) + +Gear icon always visible in header. On click opens dropdown: + +**Not logged in:** +- Login +- Sign Up (if `REGISTRATION_OPEN=true`) +- Settings (theme only, localStorage) + +**Logged in:** +- Account Settings (email, password change) +- Settings (theme, preferences — saved to DB via `PUT /api/auth/me`) +- Admin Panel (superadmin only) +- Logout + +### Homepage (auth enabled, not logged in) + +- Hero section with product description +- "Sign Up" primary button +- "View Demo" secondary button → navigates to read-only demo results page +- No URL input visible + +### Homepage (logged in or auth disabled) + +- Normal URL input and scan functionality (current behavior) +- Scan limit indicator when limits are active + +### New Pages/Components + +- **Login page** — email + password form (already exists, needs update) +- **Sign Up page** — email + password + confirm password +- **Forgot Password page** — email input, sends reset link +- **Reset Password page** — new password form (accessed via email link) +- **Account Settings page** — change email, change password +- **Admin Panel page** — user list table with configurable columns, daily stats +- **Demo Results page** — read-only scan results view + +### Route Guards + +Protected routes redirect to `/login` when auth is enabled and user is not authenticated. Public routes: `/login`, `/signup`, `/forgot-password`, `/reset-password`, `/demo`. + +--- + +## 11. Environment Variables — Complete List + +```env +# Auth Provider +AUTH_PROVIDER=local # "local" | "firebase" + +# Firebase (when AUTH_PROVIDER=firebase) +FIREBASE_PROJECT_ID= +FIREBASE_API_KEY= +FIREBASE_SERVICE_ACCOUNT_PATH= + +# Registration +REGISTRATION_OPEN=true + +# Scan Limits (0 = unlimited) +DAILY_SCAN_LIMIT_GLOBAL=0 +DAILY_SCAN_LIMIT_USER=0 + +# Superadmin Panel Visibility +SUPERADMIN_VIEW_EMAIL=true +SUPERADMIN_VIEW_LAST_LOGIN=true +SUPERADMIN_VIEW_DAILY_SCANS=true +SUPERADMIN_VIEW_SCANNED_PAGES=true + +# Demo +DEMO_SCAN_URL=https://example.com + +# SMTP (existing, used for password reset emails) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +``` + +--- + +## 12. Security Considerations + +- Passwords hashed with bcrypt (cost 12) +- JWT access tokens short-lived (15 min) to limit exposure +- Refresh token rotation — old token invalidated on use +- Timing-safe comparison for token verification (existing pattern) +- Rate limiting on auth endpoints (existing Fastify rate-limit plugin) +- Password reset tokens expire after 1 hour +- Minimum password length: 8 characters +- CSRF protection not needed (API uses Authorization header, not cookies) + +--- + +## 13. Migration Path + +- Existing bearer token auth (`AUTH_TOKEN`) is removed +- If `AUTH_PROVIDER` is not set, authentication is disabled (backward compatible) +- Existing scans in DB get `user_id = NULL` — accessible to superadmin only +- `.env.example` updated with all new variables From a8393590d20499515efeb13d2b5bcb0b80be1b7a Mon Sep 17 00:00:00 2001 From: Bruno Pavelja Date: Tue, 7 Apr 2026 18:49:48 +0200 Subject: [PATCH 2/6] docs: add user authentication implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-07-user-authentication.md | 3196 +++++++++++++++++ 1 file changed, 3196 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-07-user-authentication.md diff --git a/docs/superpowers/plans/2026-04-07-user-authentication.md b/docs/superpowers/plans/2026-04-07-user-authentication.md new file mode 100644 index 0000000..8fdc201 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-user-authentication.md @@ -0,0 +1,3196 @@ +# User Authentication System — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace bearer-token auth with a full user account system (registration, login, password recovery, roles, scan limits, demo scan). + +**Architecture:** Pluggable auth provider (`local` or `firebase`) behind a shared `AuthProvider` interface. Local provider uses bcrypt + JWT + SQLite. All user data stored in `user_profiles` table (shared by both providers). Scan limits enforced at API level before each scan. Frontend uses React context for auth state with route guards. + +**Tech Stack:** Fastify 5, better-sqlite3, bcrypt, jsonwebtoken, Firebase Admin SDK (optional), React 18, React Router v6, TailwindCSS v4 + +--- + +## File Structure + +### Backend (packages/api) + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/auth/types.ts` | Create | AuthProvider interface, AuthResult, TokenPayload types | +| `src/auth/local-provider.ts` | Create | Local auth: bcrypt + JWT + refresh tokens | +| `src/auth/firebase-provider.ts` | Create | Firebase auth: token verification via Admin SDK | +| `src/auth/index.ts` | Rewrite | Provider factory + Fastify auth plugin using provider | +| `src/auth/auth-routes.ts` | Create | All `/api/auth/*` route handlers | +| `src/auth/admin-routes.ts` | Create | All `/api/admin/*` route handlers | +| `src/auth/scan-limits.ts` | Create | Scan limit checking + response helpers | +| `src/db/index.ts` | Modify | Add users, user_profiles tables + CRUD | +| `src/config.ts` | Modify | Add auth, limit, demo, superadmin env vars | +| `src/routes.ts` | Modify | Add user_id to scans, filter history by user, add demo endpoint | +| `src/scan.ts` | Modify | Accept userId, return scan_limits in results | +| `src/server.ts` | Modify | Register auth routes, admin routes | +| `src/scheduler/index.ts` | Modify | Add demo scan job | + +### Frontend (packages/web) + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/lib/api.ts` | Modify | Add auth API calls, scan_limits types | +| `src/hooks/use-auth.ts` | Rewrite | JWT-based auth context with user profile | +| `src/components/layout/Nav.tsx` | Rewrite | Gear icon dropdown replacing Settings link | +| `src/components/auth/RouteGuard.tsx` | Create | Redirect to login if not authenticated | +| `src/pages/Login.tsx` | Rewrite | Email + password login form | +| `src/pages/Signup.tsx` | Create | Registration form | +| `src/pages/ForgotPassword.tsx` | Create | Email input for password reset | +| `src/pages/ResetPassword.tsx` | Create | New password form (from email link) | +| `src/pages/AccountSettings.tsx` | Create | Change email, change password | +| `src/pages/AdminPanel.tsx` | Create | User list table with stats | +| `src/pages/Demo.tsx` | Create | Read-only demo scan results | +| `src/pages/Home.tsx` | Modify | Conditional: scan input vs sign-up/demo CTA | +| `src/pages/Settings.tsx` | Modify | Remove old auth config, connect to user profile | +| `src/App.tsx` | Modify | Add new routes + route guards | + +### CLI (packages/cli) + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/index.ts` | Modify | Add `create-admin` command | +| `src/admin.ts` | Create | Admin CLI logic (create superadmin in DB) | + +--- + +## Task 1: Install Backend Dependencies + +**Files:** +- Modify: `packages/api/package.json` + +- [ ] **Step 1: Install bcrypt, jsonwebtoken, and firebase-admin** + +```bash +cd packages/api && npm install bcryptjs jsonwebtoken && npm install -D @types/bcryptjs @types/jsonwebtoken +``` + +We use `bcryptjs` (pure JS, no native compilation needed) and `jsonwebtoken` for JWT. Firebase Admin SDK is an optional peer dependency — installed only when `AUTH_PROVIDER=firebase`. + +- [ ] **Step 2: Commit** + +```bash +git add packages/api/package.json packages/api/package-lock.json +git commit -m "feat(api): add bcryptjs and jsonwebtoken dependencies" +``` + +--- + +## Task 2: Auth Types + +**Files:** +- Create: `packages/api/src/auth/types.ts` + +- [ ] **Step 1: Create the AuthProvider interface and shared types** + +```typescript +// packages/api/src/auth/types.ts + +export interface AuthResult { + accessToken: string; + refreshToken: string; + user: AuthUser; +} + +export interface AuthUser { + id: string; + email: string; + role: 'user' | 'superadmin'; +} + +export interface TokenPayload { + userId: string; + email: string; + role: 'user' | 'superadmin'; +} + +export interface AuthProvider { + register(email: string, password: string): Promise; + login(email: string, password: string): Promise; + logout(userId: string): Promise; + verifyToken(token: string): Promise; + refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }>; + forgotPassword(email: string): Promise; + resetPassword(token: string, newPassword: string): Promise; + changePassword(userId: string, oldPassword: string, newPassword: string): Promise; +} + +export interface ScanLimits { + user_daily?: { used: number; limit: number; remaining: number }; + global_daily?: { used: number; limit: number; remaining: number }; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/api/src/auth/types.ts +git commit -m "feat(api): add auth provider interface and types" +``` + +--- + +## Task 3: Update Config + +**Files:** +- Modify: `packages/api/src/config.ts` + +- [ ] **Step 1: Add all new env vars to config** + +Replace the entire `config.ts` with: + +```typescript +// packages/api/src/config.ts +import { existsSync } from 'node:fs'; +import 'dotenv/config'; + +function detectChromePath(): string | undefined { + if (process.env.CHROME_PATH) return process.env.CHROME_PATH; + const candidates = [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + ]; + return candidates.find((p) => existsSync(p)); +} + +function envBool(key: string, defaultValue: boolean): boolean { + const val = process.env[key]; + if (val === undefined) return defaultValue; + return val === 'true'; +} + +function envInt(key: string, defaultValue: number): number { + const val = process.env[key]; + if (val === undefined) return defaultValue; + const parsed = parseInt(val, 10); + return Number.isNaN(parsed) ? defaultValue : parsed; +} + +export const config = { + port: envInt('PORT', 3000), + host: process.env.HOST || '0.0.0.0', + timeoutLimit: envInt('API_TIMEOUT_LIMIT', 30000), + corsOrigin: process.env.API_CORS_ORIGIN || '*', + chromePath: detectChromePath(), + staticDir: process.env.STATIC_DIR || undefined, + maxConcurrency: envInt('MAX_CONCURRENCY', 8), + dbPath: process.env.DB_PATH || './data/recon-web.db', + + // Auth + authProvider: (process.env.AUTH_PROVIDER || '') as '' | 'local' | 'firebase', + registrationOpen: envBool('REGISTRATION_OPEN', true), + jwtSecret: process.env.JWT_SECRET || '', + jwtExpiresIn: process.env.JWT_EXPIRES_IN || '15m', + refreshTokenExpiresInDays: envInt('REFRESH_TOKEN_EXPIRES_DAYS', 30), + + // Firebase (only when AUTH_PROVIDER=firebase) + firebaseProjectId: process.env.FIREBASE_PROJECT_ID || '', + firebaseApiKey: process.env.FIREBASE_API_KEY || '', + firebaseServiceAccountPath: process.env.FIREBASE_SERVICE_ACCOUNT_PATH || '', + + // Scan limits (0 = unlimited) + dailyScanLimitGlobal: envInt('DAILY_SCAN_LIMIT_GLOBAL', 0), + dailyScanLimitUser: envInt('DAILY_SCAN_LIMIT_USER', 0), + + // Superadmin panel visibility + superadminViewEmail: envBool('SUPERADMIN_VIEW_EMAIL', true), + superadminViewLastLogin: envBool('SUPERADMIN_VIEW_LAST_LOGIN', true), + superadminViewDailyScans: envBool('SUPERADMIN_VIEW_DAILY_SCANS', true), + superadminViewScannedPages: envBool('SUPERADMIN_VIEW_SCANNED_PAGES', true), + + // Demo + demoScanUrl: process.env.DEMO_SCAN_URL || 'https://example.com', + + apiKeys: { + GOOGLE_CLOUD_API_KEY: process.env.GOOGLE_CLOUD_API_KEY || '', + CLOUDMERSIVE_API_KEY: process.env.CLOUDMERSIVE_API_KEY || '', + BUILT_WITH_API_KEY: process.env.BUILT_WITH_API_KEY || '', + TRANCO_API_KEY: process.env.TRANCO_API_KEY || '', + TRANCO_USERNAME: process.env.TRANCO_USERNAME || '', + VIRUSTOTAL_API_KEY: process.env.VIRUSTOTAL_API_KEY || '', + ABUSEIPDB_API_KEY: process.env.ABUSEIPDB_API_KEY || '', + }, +} as const; + +/** Return only the API keys that have non-empty values. */ +export function getPopulatedApiKeys(): Record { + const keys: Record = {}; + for (const [k, v] of Object.entries(config.apiKeys)) { + if (v) keys[k] = v; + } + return keys; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/api/src/config.ts +git commit -m "feat(api): add auth, scan limit, and demo config vars" +``` + +--- + +## Task 4: Database Schema — Users & Profiles + +**Files:** +- Modify: `packages/api/src/db/index.ts` + +- [ ] **Step 1: Add users and user_profiles tables, user_id to scans, and CRUD functions** + +Add after the existing `CREATE TABLE` statements inside `initDb`: + +```sql +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_login_at TEXT, + reset_token TEXT, + reset_token_expires_at TEXT, + refresh_token TEXT +); + +CREATE TABLE IF NOT EXISTS user_profiles ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + settings JSON NOT NULL DEFAULT '{}', + last_login_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_user_profiles_email ON user_profiles(email); +``` + +Add migration for existing `scans` table — add `user_id` column if it doesn't exist: + +```typescript +// Inside initDb, after CREATE TABLE statements: +const hasUserId = db.prepare( + "SELECT COUNT(*) as cnt FROM pragma_table_info('scans') WHERE name = 'user_id'" +).get() as { cnt: number }; + +if (hasUserId.cnt === 0) { + db.exec('ALTER TABLE scans ADD COLUMN user_id TEXT'); + db.exec('CREATE INDEX IF NOT EXISTS idx_scans_user_id ON scans(user_id)'); +} +``` + +Add new CRUD functions for users and profiles: + +```typescript +// ── User CRUD ────────────────────────────────────────────────────────── +export interface User { + id: string; + email: string; + password_hash: string; + role: string; + created_at: string; + last_login_at: string | null; + reset_token: string | null; + reset_token_expires_at: string | null; + refresh_token: string | null; +} + +export interface UserProfile { + id: string; + email: string; + role: string; + settings: string; + last_login_at: string | null; + created_at: string; +} + +export function createUser( + db: BetterSqlite3.Database, + opts: { id: string; email: string; passwordHash: string; role?: string }, +): void { + db.prepare( + 'INSERT INTO users (id, email, password_hash, role) VALUES (?, ?, ?, ?)', + ).run(opts.id, opts.email, opts.passwordHash, opts.role ?? 'user'); +} + +export function getUserByEmail( + db: BetterSqlite3.Database, + email: string, +): User | undefined { + return db.prepare('SELECT * FROM users WHERE email = ?').get(email) as User | undefined; +} + +export function getUserById( + db: BetterSqlite3.Database, + id: string, +): User | undefined { + return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined; +} + +export function updateUserLastLogin(db: BetterSqlite3.Database, id: string): void { + db.prepare("UPDATE users SET last_login_at = datetime('now') WHERE id = ?").run(id); +} + +export function updateUserRefreshToken(db: BetterSqlite3.Database, id: string, token: string | null): void { + db.prepare('UPDATE users SET refresh_token = ? WHERE id = ?').run(token, id); +} + +export function updateUserResetToken( + db: BetterSqlite3.Database, + id: string, + token: string | null, + expiresAt: string | null, +): void { + db.prepare('UPDATE users SET reset_token = ?, reset_token_expires_at = ? WHERE id = ?').run(token, expiresAt, id); +} + +export function updateUserPassword(db: BetterSqlite3.Database, id: string, passwordHash: string): void { + db.prepare('UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires_at = NULL WHERE id = ?').run(passwordHash, id); +} + +export function updateUserRole(db: BetterSqlite3.Database, id: string, role: string): void { + db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id); + db.prepare('UPDATE user_profiles SET role = ? WHERE id = ?').run(role, id); +} + +export function deleteUser(db: BetterSqlite3.Database, id: string): boolean { + const info = db.prepare('DELETE FROM users WHERE id = ?').run(id); + db.prepare('DELETE FROM user_profiles WHERE id = ?').run(id); + return info.changes > 0; +} + +// ── Profile CRUD ─────────────────────────────────────────────────────── +export function createProfile( + db: BetterSqlite3.Database, + opts: { id: string; email: string; role?: string }, +): void { + db.prepare( + 'INSERT OR IGNORE INTO user_profiles (id, email, role) VALUES (?, ?, ?)', + ).run(opts.id, opts.email, opts.role ?? 'user'); +} + +export function getProfile(db: BetterSqlite3.Database, id: string): UserProfile | undefined { + return db.prepare('SELECT * FROM user_profiles WHERE id = ?').get(id) as UserProfile | undefined; +} + +export function updateProfileSettings(db: BetterSqlite3.Database, id: string, settings: unknown): void { + db.prepare('UPDATE user_profiles SET settings = ? WHERE id = ?').run(JSON.stringify(settings), id); +} + +export function updateProfileLastLogin(db: BetterSqlite3.Database, id: string): void { + db.prepare("UPDATE user_profiles SET last_login_at = datetime('now') WHERE id = ?").run(id); +} + +export function getAllProfiles(db: BetterSqlite3.Database): UserProfile[] { + return db.prepare('SELECT * FROM user_profiles ORDER BY created_at DESC').all() as UserProfile[]; +} + +// ── Scan count helpers ───────────────────────────────────────────────── +export function getDailyScanCountForUser(db: BetterSqlite3.Database, userId: string): number { + const row = db.prepare( + "SELECT COUNT(*) as cnt FROM scans WHERE user_id = ? AND created_at >= datetime('now', 'start of day')", + ).get(userId) as { cnt: number }; + return row.cnt; +} + +export function getDailyScanCountGlobal(db: BetterSqlite3.Database): number { + const row = db.prepare( + "SELECT COUNT(*) as cnt FROM scans WHERE created_at >= datetime('now', 'start of day')", + ).get() as { cnt: number }; + return row.cnt; +} + +export function getUserScannedPages(db: BetterSqlite3.Database, userId: string): string[] { + const rows = db.prepare( + "SELECT DISTINCT url FROM scans WHERE user_id = ? AND created_at >= datetime('now', 'start of day') ORDER BY created_at DESC", + ).all(userId) as Array<{ url: string }>; + return rows.map((r) => r.url); +} +``` + +- [ ] **Step 2: Update `createScan` to accept optional `userId`** + +Modify the `createScan` function: + +```typescript +export function createScan( + db: BetterSqlite3.Database, + opts: { id?: string; url: string; handlerCount: number; userId?: string }, +): string { + const id = opts.id ?? randomUUID(); + db.prepare( + 'INSERT INTO scans (id, url, handler_count, status, user_id) VALUES (?, ?, ?, ?, ?)', + ).run(id, opts.url, opts.handlerCount, 'running', opts.userId ?? null); + return id; +} +``` + +Also update `getScans` to support user filtering: + +```typescript +export function getScans( + db: BetterSqlite3.Database, + opts: { limit?: number; offset?: number; userId?: string; all?: boolean } = {}, +): Scan[] { + const limit = opts.limit ?? 20; + const offset = opts.offset ?? 0; + + if (opts.userId && !opts.all) { + return db + .prepare('SELECT * FROM scans WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?') + .all(opts.userId, limit, offset) as Scan[]; + } + + return db + .prepare('SELECT * FROM scans ORDER BY created_at DESC LIMIT ? OFFSET ?') + .all(limit, offset) as Scan[]; +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/api/src/db/index.ts +git commit -m "feat(api): add users, profiles tables and scan user_id column" +``` + +--- + +## Task 5: Scan Limits Module + +**Files:** +- Create: `packages/api/src/auth/scan-limits.ts` + +- [ ] **Step 1: Create scan limits checker** + +```typescript +// packages/api/src/auth/scan-limits.ts +import type BetterSqlite3 from 'better-sqlite3'; +import { config } from '../config.js'; +import { getDailyScanCountForUser, getDailyScanCountGlobal } from '../db/index.js'; +import type { ScanLimits } from './types.js'; + +export function checkScanLimits(db: BetterSqlite3.Database, userId: string): { + allowed: boolean; + limits: ScanLimits; + reason?: string; +} { + const limits: ScanLimits = {}; + + // Per-user limit + if (config.dailyScanLimitUser > 0) { + const used = getDailyScanCountForUser(db, userId); + const remaining = Math.max(0, config.dailyScanLimitUser - used); + limits.user_daily = { used, limit: config.dailyScanLimitUser, remaining }; + if (remaining === 0) { + return { allowed: false, limits, reason: 'Daily scan limit reached for your account' }; + } + } + + // Global limit + if (config.dailyScanLimitGlobal > 0) { + const used = getDailyScanCountGlobal(db); + const remaining = Math.max(0, config.dailyScanLimitGlobal - used); + limits.global_daily = { used, limit: config.dailyScanLimitGlobal, remaining }; + if (remaining === 0) { + return { allowed: false, limits, reason: 'Global daily scan limit reached' }; + } + } + + return { allowed: true, limits }; +} + +export function getScanLimits(db: BetterSqlite3.Database, userId: string): ScanLimits { + return checkScanLimits(db, userId).limits; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/api/src/auth/scan-limits.ts +git commit -m "feat(api): add scan limit checking module" +``` + +--- + +## Task 6: Local Auth Provider + +**Files:** +- Create: `packages/api/src/auth/local-provider.ts` + +- [ ] **Step 1: Implement local auth provider** + +```typescript +// packages/api/src/auth/local-provider.ts +import { randomUUID, randomBytes } from 'node:crypto'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import type BetterSqlite3 from 'better-sqlite3'; +import { config } from '../config.js'; +import { + createUser, getUserByEmail, getUserById, updateUserLastLogin, + updateUserRefreshToken, updateUserResetToken, updateUserPassword, + createProfile, updateProfileLastLogin, +} from '../db/index.js'; +import type { AuthProvider, AuthResult, TokenPayload } from './types.js'; + +const BCRYPT_ROUNDS = 12; +const MIN_PASSWORD_LENGTH = 8; + +function generateAccessToken(payload: TokenPayload): string { + return jwt.sign(payload, config.jwtSecret, { expiresIn: config.jwtExpiresIn }); +} + +function generateRefreshToken(): string { + return randomBytes(48).toString('hex'); +} + +export function createLocalProvider(db: BetterSqlite3.Database): AuthProvider { + if (!config.jwtSecret || config.jwtSecret.length < 32) { + throw new Error('JWT_SECRET must be set and at least 32 characters when using local auth'); + } + + return { + async register(email: string, password: string): Promise { + if (!config.registrationOpen) { + throw Object.assign(new Error('Registration is closed'), { statusCode: 403 }); + } + + if (password.length < MIN_PASSWORD_LENGTH) { + throw Object.assign( + new Error(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`), + { statusCode: 400 }, + ); + } + + const existing = getUserByEmail(db, email.toLowerCase()); + if (existing) { + throw Object.assign(new Error('Email already registered'), { statusCode: 409 }); + } + + const id = randomUUID(); + const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); + const normalizedEmail = email.toLowerCase(); + + createUser(db, { id, email: normalizedEmail, passwordHash }); + createProfile(db, { id, email: normalizedEmail }); + + const refreshToken = generateRefreshToken(); + updateUserRefreshToken(db, id, refreshToken); + updateUserLastLogin(db, id); + updateProfileLastLogin(db, id); + + const user = { id, email: normalizedEmail, role: 'user' as const }; + const accessToken = generateAccessToken({ userId: id, email: normalizedEmail, role: 'user' }); + + return { accessToken, refreshToken, user }; + }, + + async login(email: string, password: string): Promise { + const user = getUserByEmail(db, email.toLowerCase()); + if (!user) { + throw Object.assign(new Error('Invalid email or password'), { statusCode: 401 }); + } + + const valid = await bcrypt.compare(password, user.password_hash); + if (!valid) { + throw Object.assign(new Error('Invalid email or password'), { statusCode: 401 }); + } + + const refreshToken = generateRefreshToken(); + updateUserRefreshToken(db, user.id, refreshToken); + updateUserLastLogin(db, user.id); + updateProfileLastLogin(db, user.id); + + const role = user.role as 'user' | 'superadmin'; + const accessToken = generateAccessToken({ userId: user.id, email: user.email, role }); + + return { + accessToken, + refreshToken, + user: { id: user.id, email: user.email, role }, + }; + }, + + async logout(userId: string): Promise { + updateUserRefreshToken(db, userId, null); + }, + + async verifyToken(token: string): Promise { + try { + const decoded = jwt.verify(token, config.jwtSecret) as TokenPayload; + return decoded; + } catch { + throw Object.assign(new Error('Invalid or expired token'), { statusCode: 401 }); + } + }, + + async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> { + // Find user by refresh token + const user = db.prepare('SELECT * FROM users WHERE refresh_token = ?').get(refreshToken) as + | { id: string; email: string; role: string } + | undefined; + + if (!user) { + throw Object.assign(new Error('Invalid refresh token'), { statusCode: 401 }); + } + + const role = user.role as 'user' | 'superadmin'; + const newAccessToken = generateAccessToken({ userId: user.id, email: user.email, role }); + const newRefreshToken = generateRefreshToken(); + updateUserRefreshToken(db, user.id, newRefreshToken); + + return { accessToken: newAccessToken, refreshToken: newRefreshToken }; + }, + + async forgotPassword(email: string): Promise { + const user = getUserByEmail(db, email.toLowerCase()); + if (!user) return; // Don't reveal whether email exists + + const token = randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour + updateUserResetToken(db, user.id, token, expiresAt); + + // Send email via existing nodemailer infrastructure + try { + const { sendPasswordResetEmail } = await import('../notifications/email.js'); + await sendPasswordResetEmail(user.email, token); + } catch (err) { + console.error('Failed to send password reset email:', err); + // Don't throw — we don't want to reveal email existence + } + }, + + async resetPassword(token: string, newPassword: string): Promise { + if (newPassword.length < MIN_PASSWORD_LENGTH) { + throw Object.assign( + new Error(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`), + { statusCode: 400 }, + ); + } + + const user = db.prepare( + "SELECT * FROM users WHERE reset_token = ? AND reset_token_expires_at > datetime('now')", + ).get(token) as { id: string } | undefined; + + if (!user) { + throw Object.assign(new Error('Invalid or expired reset token'), { statusCode: 400 }); + } + + const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS); + updateUserPassword(db, user.id, passwordHash); + updateUserRefreshToken(db, user.id, null); // Invalidate sessions + }, + + async changePassword(userId: string, oldPassword: string, newPassword: string): Promise { + if (newPassword.length < MIN_PASSWORD_LENGTH) { + throw Object.assign( + new Error(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`), + { statusCode: 400 }, + ); + } + + const user = getUserById(db, userId); + if (!user) { + throw Object.assign(new Error('User not found'), { statusCode: 404 }); + } + + const valid = await bcrypt.compare(oldPassword, user.password_hash); + if (!valid) { + throw Object.assign(new Error('Current password is incorrect'), { statusCode: 401 }); + } + + const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS); + updateUserPassword(db, user.id, passwordHash); + }, + }; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/api/src/auth/local-provider.ts +git commit -m "feat(api): implement local auth provider with bcrypt + JWT" +``` + +--- + +## Task 7: Firebase Auth Provider + +**Files:** +- Create: `packages/api/src/auth/firebase-provider.ts` + +- [ ] **Step 1: Implement Firebase auth provider** + +```typescript +// packages/api/src/auth/firebase-provider.ts +import type BetterSqlite3 from 'better-sqlite3'; +import { config } from '../config.js'; +import { createProfile, getProfile, updateProfileLastLogin } from '../db/index.js'; +import type { AuthProvider, AuthResult, TokenPayload } from './types.js'; + +export function createFirebaseProvider(db: BetterSqlite3.Database): AuthProvider { + // Lazy-load firebase-admin — it's an optional dependency + let adminApp: any = null; + + async function getFirebaseAdmin() { + if (adminApp) return adminApp; + try { + const admin = await import('firebase-admin'); + if (!admin.default.apps.length) { + const initOptions: any = { projectId: config.firebaseProjectId }; + if (config.firebaseServiceAccountPath) { + const { readFileSync } = await import('node:fs'); + const serviceAccount = JSON.parse(readFileSync(config.firebaseServiceAccountPath, 'utf-8')); + initOptions.credential = admin.default.credential.cert(serviceAccount); + } + admin.default.initializeApp(initOptions); + } + adminApp = admin.default; + return adminApp; + } catch { + throw Object.assign( + new Error('firebase-admin is not installed. Run: npm install firebase-admin'), + { statusCode: 500 }, + ); + } + } + + // Firebase handles register/login/password on the client side. + // These server methods are stubs that throw — the frontend talks to Firebase directly. + const clientSideOnly = (method: string) => { + throw Object.assign( + new Error(`${method} is handled client-side by Firebase SDK`), + { statusCode: 400 }, + ); + }; + + return { + async register(): Promise { + return clientSideOnly('register') as never; + }, + + async login(): Promise { + return clientSideOnly('login') as never; + }, + + async logout(): Promise { + // Firebase manages sessions client-side; nothing to do server-side + }, + + async verifyToken(idToken: string): Promise { + const admin = await getFirebaseAdmin(); + const decoded = await admin.auth().verifyIdToken(idToken); + + // Ensure user_profiles row exists + let profile = getProfile(db, decoded.uid); + if (!profile) { + createProfile(db, { id: decoded.uid, email: decoded.email ?? '' }); + profile = getProfile(db, decoded.uid)!; + } + updateProfileLastLogin(db, decoded.uid); + + return { + userId: decoded.uid, + email: decoded.email ?? '', + role: (profile.role as 'user' | 'superadmin') ?? 'user', + }; + }, + + async refreshAccessToken(): Promise<{ accessToken: string; refreshToken: string }> { + return clientSideOnly('refreshAccessToken') as never; + }, + + async forgotPassword(): Promise { + return clientSideOnly('forgotPassword') as never; + }, + + async resetPassword(): Promise { + return clientSideOnly('resetPassword') as never; + }, + + async changePassword(): Promise { + return clientSideOnly('changePassword') as never; + }, + }; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/api/src/auth/firebase-provider.ts +git commit -m "feat(api): implement Firebase auth provider" +``` + +--- + +## Task 8: Password Reset Email + +**Files:** +- Modify: `packages/api/src/notifications/email.ts` + +- [ ] **Step 1: Read the existing email module to understand the pattern** + +Read `packages/api/src/notifications/email.ts` and add a `sendPasswordResetEmail` export that uses the same nodemailer transport. + +- [ ] **Step 2: Add sendPasswordResetEmail function** + +Add to the end of `packages/api/src/notifications/email.ts`: + +```typescript +export async function sendPasswordResetEmail(to: string, resetToken: string): Promise { + const host = process.env.SMTP_HOST; + const port = parseInt(process.env.SMTP_PORT || '587', 10); + const user = process.env.SMTP_USER; + const pass = process.env.SMTP_PASS; + + if (!host || !user || !pass) { + throw new Error('SMTP not configured — cannot send password reset email'); + } + + const nodemailer = await import('nodemailer'); + const transport = nodemailer.default.createTransport({ + host, + port, + secure: port === 465, + auth: { user, pass }, + }); + + const appUrl = process.env.APP_URL || 'http://localhost:3000'; + const resetUrl = `${appUrl}/reset-password?token=${resetToken}`; + + await transport.sendMail({ + from: user, + to, + subject: 'recon-web — Password Reset', + text: `You requested a password reset.\n\nClick this link to reset your password:\n${resetUrl}\n\nThis link expires in 1 hour.\n\nIf you didn't request this, ignore this email.`, + html: ` +

Password Reset

+

You requested a password reset for your recon-web account.

+

Reset Password

+

This link expires in 1 hour. If you didn't request this, ignore this email.

+ `, + }); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/api/src/notifications/email.ts +git commit -m "feat(api): add password reset email sender" +``` + +--- + +## Task 9: Rewrite Auth Plugin + +**Files:** +- Rewrite: `packages/api/src/auth/index.ts` + +- [ ] **Step 1: Rewrite the auth plugin as provider factory + Fastify hook** + +```typescript +// packages/api/src/auth/index.ts +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import fp from 'fastify-plugin'; +import { config } from '../config.js'; +import type { AuthProvider, TokenPayload } from './types.js'; + +declare module 'fastify' { + interface FastifyInstance { + authProvider: AuthProvider | null; + } + interface FastifyRequest { + user?: TokenPayload; + } +} + +const PUBLIC_PATHS = new Set(['/health', '/api/handlers', '/api/demo', '/docs']); +const AUTH_PATHS_PREFIX = '/api/auth/'; +const PUBLIC_AUTH_ACTIONS = new Set(['login', 'register', 'forgot-password', 'reset-password', 'refresh', 'config']); + +async function authPluginImpl(app: FastifyInstance): Promise { + const providerType = config.authProvider; + + if (!providerType) { + app.decorate('authProvider', null); + app.log.info('Auth disabled — no AUTH_PROVIDER set'); + return; + } + + let provider: AuthProvider; + + if (providerType === 'local') { + const { createLocalProvider } = await import('./local-provider.js'); + provider = createLocalProvider(app.db); + app.log.info('Auth enabled: local provider (bcrypt + JWT)'); + } else if (providerType === 'firebase') { + const { createFirebaseProvider } = await import('./firebase-provider.js'); + provider = createFirebaseProvider(app.db); + app.log.info('Auth enabled: Firebase provider'); + } else { + throw new Error(`Unknown AUTH_PROVIDER: ${providerType}. Use "local" or "firebase".`); + } + + app.decorate('authProvider', provider); + + app.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => { + const path = request.url.split('?')[0]; + + // Skip non-API paths (static files, SPA) + if (!path.startsWith('/api')) return; + + // Allow public endpoints + if (PUBLIC_PATHS.has(path)) return; + + // Allow public auth actions (login, register, etc.) + if (path.startsWith(AUTH_PATHS_PREFIX)) { + const action = path.slice(AUTH_PATHS_PREFIX.length); + if (PUBLIC_AUTH_ACTIONS.has(action)) return; + } + + // Require auth + const header = request.headers.authorization; + if (!header || !header.startsWith('Bearer ')) { + return reply.code(401).send({ error: 'Missing or malformed Authorization header' }); + } + + const token = header.slice(7); + try { + request.user = await provider.verifyToken(token); + } catch { + return reply.code(401).send({ error: 'Invalid or expired token' }); + } + }); +} + +export const authPlugin = fp(authPluginImpl, { + name: 'auth-plugin', + fastify: '5.x', +}); + +export type { AuthProvider, TokenPayload } from './types.js'; +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/api/src/auth/index.ts +git commit -m "feat(api): rewrite auth plugin with provider factory" +``` + +--- + +## Task 10: Auth Routes + +**Files:** +- Create: `packages/api/src/auth/auth-routes.ts` + +- [ ] **Step 1: Create auth route handlers** + +```typescript +// packages/api/src/auth/auth-routes.ts +import type { FastifyInstance } from 'fastify'; +import { config } from '../config.js'; +import { getProfile, updateProfileSettings } from '../db/index.js'; +import { getScanLimits } from './scan-limits.js'; + +export async function registerAuthRoutes(app: FastifyInstance): Promise { + const provider = app.authProvider; + if (!provider) return; + + // ── Public auth config (tells frontend what's available) ────── + app.get('/api/auth/config', { + schema: { description: 'Auth configuration for frontend', tags: ['auth'] }, + }, async () => { + return { + provider: config.authProvider, + registrationOpen: config.registrationOpen, + }; + }); + + // ── Register ────────────────────────────────────────────────── + app.post('/api/auth/register', { + schema: { + description: 'Create a new account', + tags: ['auth'], + body: { + type: 'object' as const, + required: ['email', 'password'], + properties: { + email: { type: 'string' as const, format: 'email' }, + password: { type: 'string' as const, minLength: 8 }, + }, + }, + }, + }, async (request, reply) => { + const { email, password } = request.body as { email: string; password: string }; + try { + const result = await provider.register(email, password); + return result; + } catch (err: any) { + return reply.code(err.statusCode ?? 500).send({ error: err.message }); + } + }); + + // ── Login ───────────────────────────────────────────────────── + app.post('/api/auth/login', { + schema: { + description: 'Log in with email and password', + tags: ['auth'], + body: { + type: 'object' as const, + required: ['email', 'password'], + properties: { + email: { type: 'string' as const }, + password: { type: 'string' as const }, + }, + }, + }, + }, async (request, reply) => { + const { email, password } = request.body as { email: string; password: string }; + try { + const result = await provider.login(email, password); + return result; + } catch (err: any) { + return reply.code(err.statusCode ?? 500).send({ error: err.message }); + } + }); + + // ── Refresh ─────────────────────────────────────────────────── + app.post('/api/auth/refresh', { + schema: { + description: 'Refresh access token', + tags: ['auth'], + body: { + type: 'object' as const, + required: ['refreshToken'], + properties: { + refreshToken: { type: 'string' as const }, + }, + }, + }, + }, async (request, reply) => { + const { refreshToken } = request.body as { refreshToken: string }; + try { + return await provider.refreshAccessToken(refreshToken); + } catch (err: any) { + return reply.code(err.statusCode ?? 500).send({ error: err.message }); + } + }); + + // ── Forgot password ────────────────────────────────────────── + app.post('/api/auth/forgot-password', { + schema: { + description: 'Send password reset email', + tags: ['auth'], + body: { + type: 'object' as const, + required: ['email'], + properties: { + email: { type: 'string' as const }, + }, + }, + }, + }, async (request) => { + const { email } = request.body as { email: string }; + await provider.forgotPassword(email); + return { message: 'If that email is registered, a reset link has been sent.' }; + }); + + // ── Reset password ─────────────────────────────────────────── + app.post('/api/auth/reset-password', { + schema: { + description: 'Reset password using token from email', + tags: ['auth'], + body: { + type: 'object' as const, + required: ['token', 'password'], + properties: { + token: { type: 'string' as const }, + password: { type: 'string' as const, minLength: 8 }, + }, + }, + }, + }, async (request, reply) => { + const { token, password } = request.body as { token: string; password: string }; + try { + await provider.resetPassword(token, password); + return { message: 'Password reset successfully' }; + } catch (err: any) { + return reply.code(err.statusCode ?? 500).send({ error: err.message }); + } + }); + + // ── Authenticated routes below ──────────────────────────────── + + // ── Get current user ────────────────────────────────────────── + app.get('/api/auth/me', { + schema: { description: 'Get current user profile and scan limits', tags: ['auth'] }, + }, async (request, reply) => { + if (!request.user) return reply.code(401).send({ error: 'Not authenticated' }); + + const profile = getProfile(app.db, request.user.userId); + if (!profile) return reply.code(404).send({ error: 'Profile not found' }); + + const scanLimits = getScanLimits(app.db, request.user.userId); + const settings = JSON.parse(profile.settings || '{}'); + + return { + id: profile.id, + email: profile.email, + role: profile.role, + settings, + scanLimits, + }; + }); + + // ── Update profile / settings ───────────────────────────────── + app.put('/api/auth/me', { + schema: { + description: 'Update current user profile and settings', + tags: ['auth'], + body: { + type: 'object' as const, + properties: { + settings: { type: 'object' as const }, + }, + }, + }, + }, async (request, reply) => { + if (!request.user) return reply.code(401).send({ error: 'Not authenticated' }); + + const { settings } = request.body as { settings?: Record }; + if (settings) { + updateProfileSettings(app.db, request.user.userId, settings); + } + + return { success: true }; + }); + + // ── Change password ─────────────────────────────────────────── + app.post('/api/auth/change-password', { + schema: { + description: 'Change password (requires current password)', + tags: ['auth'], + body: { + type: 'object' as const, + required: ['oldPassword', 'newPassword'], + properties: { + oldPassword: { type: 'string' as const }, + newPassword: { type: 'string' as const, minLength: 8 }, + }, + }, + }, + }, async (request, reply) => { + if (!request.user) return reply.code(401).send({ error: 'Not authenticated' }); + const { oldPassword, newPassword } = request.body as { oldPassword: string; newPassword: string }; + try { + await provider.changePassword(request.user.userId, oldPassword, newPassword); + return { message: 'Password changed successfully' }; + } catch (err: any) { + return reply.code(err.statusCode ?? 500).send({ error: err.message }); + } + }); + + // ── Logout ──────────────────────────────────────────────────── + app.post('/api/auth/logout', { + schema: { description: 'Log out (invalidate refresh token)', tags: ['auth'] }, + }, async (request, reply) => { + if (!request.user) return reply.code(401).send({ error: 'Not authenticated' }); + await provider.logout(request.user.userId); + return { success: true }; + }); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/api/src/auth/auth-routes.ts +git commit -m "feat(api): add auth route handlers" +``` + +--- + +## Task 11: Admin Routes + +**Files:** +- Create: `packages/api/src/auth/admin-routes.ts` + +- [ ] **Step 1: Create admin route handlers** + +```typescript +// packages/api/src/auth/admin-routes.ts +import type { FastifyInstance } from 'fastify'; +import { config } from '../config.js'; +import { + getAllProfiles, getDailyScanCountForUser, getUserScannedPages, + updateUserRole, deleteUser, +} from '../db/index.js'; + +export async function registerAdminRoutes(app: FastifyInstance): Promise { + if (!app.authProvider) return; + + // Superadmin guard + const requireSuperadmin = async (request: any, reply: any) => { + if (!request.user || request.user.role !== 'superadmin') { + return reply.code(403).send({ error: 'Superadmin access required' }); + } + }; + + // ── List users ──────────────────────────────────────────────── + app.get('/api/admin/users', { + schema: { description: 'List all users (superadmin only)', tags: ['admin'] }, + preHandler: requireSuperadmin, + }, async () => { + const profiles = getAllProfiles(app.db); + + return profiles.map((p) => { + const user: Record = { id: p.id, role: p.role, created_at: p.created_at }; + + if (config.superadminViewEmail) user.email = p.email; + if (config.superadminViewLastLogin) user.last_login_at = p.last_login_at; + if (config.superadminViewDailyScans) user.daily_scans = getDailyScanCountForUser(app.db, p.id); + if (config.superadminViewScannedPages) user.scanned_pages = getUserScannedPages(app.db, p.id); + + return user; + }); + }); + + // ── Update user role ────────────────────────────────────────── + app.put('/api/admin/users/:id', { + schema: { + description: 'Update user role (superadmin only)', + tags: ['admin'], + params: { type: 'object' as const, required: ['id'], properties: { id: { type: 'string' as const } } }, + body: { type: 'object' as const, required: ['role'], properties: { role: { type: 'string' as const, enum: ['user', 'superadmin'] } } }, + }, + preHandler: requireSuperadmin, + }, async (request) => { + const { id } = request.params as { id: string }; + const { role } = request.body as { role: string }; + updateUserRole(app.db, id, role); + return { success: true }; + }); + + // ── Delete user ─────────────────────────────────────────────── + app.delete('/api/admin/users/:id', { + schema: { + description: 'Delete a user (superadmin only)', + tags: ['admin'], + params: { type: 'object' as const, required: ['id'], properties: { id: { type: 'string' as const } } }, + }, + preHandler: requireSuperadmin, + }, async (request, reply) => { + const { id } = request.params as { id: string }; + + // Prevent self-deletion + if (request.user?.userId === id) { + return reply.code(400).send({ error: 'Cannot delete your own account' }); + } + + const deleted = deleteUser(app.db, id); + if (!deleted) return reply.code(404).send({ error: 'User not found' }); + return { success: true }; + }); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/api/src/auth/admin-routes.ts +git commit -m "feat(api): add admin route handlers for user management" +``` + +--- + +## Task 12: Update Routes — Scan Limits, User Scoping, Demo + +**Files:** +- Modify: `packages/api/src/routes.ts` + +- [ ] **Step 1: Add scan limit enforcement to scan routes** + +In `routes.ts`, add imports at top: + +```typescript +import { checkScanLimits } from './auth/scan-limits.js'; +``` + +Modify the `GET /api` handler to check limits and pass userId: + +```typescript +// Replace the existing GET /api handler body: +async (request, reply) => { + const { url } = request.query as { url: string }; + const apiKeys = getPopulatedApiKeys(); + const db = request.server.db; + const userId = request.user?.userId; + + // Check scan limits if auth is enabled and user is logged in + if (userId) { + const { allowed, limits, reason } = checkScanLimits(db, userId); + if (!allowed) { + return reply.code(429).send({ error: reason, scan_limits: limits }); + } + } + + const { scanId, results } = await executeScanDeduped({ + db, + url, + handlerOptions: { timeout: config.timeoutLimit, apiKeys, chromePath: config.chromePath }, + concurrency: config.maxConcurrency, + userId, + }); + + // Include scan limits in response + const scan_limits = userId ? checkScanLimits(db, userId).limits : undefined; + return { results, scanId, scan_limits }; +} +``` + +Apply the same pattern to the `GET /api/stream` handler — check limits before starting, include limits in the `scan_completed` event. + +- [ ] **Step 2: Update history route to scope by user** + +```typescript +// Replace GET /api/history handler: +async (request) => { + const { limit, offset } = request.query as { limit?: number; offset?: number }; + const db = request.server.db; + const userId = request.user?.userId; + const isAdmin = request.user?.role === 'superadmin'; + const all = (request.query as any).all === 'true' && isAdmin; + + return getScans(db, { limit, offset, userId, all: all || !userId }); +} +``` + +- [ ] **Step 3: Add demo endpoint** + +```typescript +// Add after history routes: +app.get('/api/demo', { + schema: { description: 'Get latest demo scan result (public)', tags: ['demo'] }, +}, async (request, reply) => { + const db = request.server.db; + // Demo scans have user_id = NULL and url = demo URL + const scan = db.prepare( + "SELECT * FROM scans WHERE user_id IS NULL AND url = ? ORDER BY created_at DESC LIMIT 1" + ).get(config.demoScanUrl) as any; + + if (!scan) { + return reply.code(404).send({ error: 'No demo scan available yet' }); + } + + return getScan(db, scan.id); +}); +``` + +- [ ] **Step 4: Commit** + +```bash +git add packages/api/src/routes.ts +git commit -m "feat(api): add scan limits, user-scoped history, demo endpoint" +``` + +--- + +## Task 13: Update Scan Module + +**Files:** +- Modify: `packages/api/src/scan.ts` + +- [ ] **Step 1: Add userId to ExecuteScanOptions and pass to createScan** + +Add `userId?: string` to the `ExecuteScanOptions` interface. + +Update the `executeScan` function's `createScan` call: + +```typescript +const scanId = createScan(db, { url, handlerCount: orderedHandlers.length, userId }); +``` + +Update `executeScanDeduped` to forward `userId`: + +```typescript +// No changes needed — it already passes the full opts to executeScan +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/api/src/scan.ts +git commit -m "feat(api): associate scans with user ID" +``` + +--- + +## Task 14: Update Server — Register New Routes + +**Files:** +- Modify: `packages/api/src/server.ts` + +- [ ] **Step 1: Register auth and admin routes** + +Add imports: + +```typescript +import { registerAuthRoutes } from './auth/auth-routes.js'; +import { registerAdminRoutes } from './auth/admin-routes.js'; +``` + +After `await app.register(authPlugin);` add: + +```typescript +// ── Auth & Admin routes ────────────────────────────────────── +await registerAuthRoutes(app); +await registerAdminRoutes(app); +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/api/src/server.ts +git commit -m "feat(api): register auth and admin routes in server" +``` + +--- + +## Task 15: Demo Scan Scheduler + +**Files:** +- Modify: `packages/api/src/scheduler/index.ts` + +- [ ] **Step 1: Add demo scan cron job** + +Inside `schedulerPluginImpl`, after the existing scheduler setup, add: + +```typescript +// ── Demo scan (runs daily at 00:30 UTC) ────────────────────────── +const demoUrl = config.demoScanUrl; +if (demoUrl && config.authProvider) { + app.log.info(`Demo scan scheduled for ${demoUrl}`); + + cron.schedule('30 0 * * *', async () => { + app.log.info(`Running demo scan for ${demoUrl}`); + try { + await executeScan({ + db: app.db, + url: demoUrl, + handlerOptions: { timeout: 30_000 }, + concurrency: 4, + // userId intentionally omitted — NULL marks it as a demo/system scan + }); + app.log.info('Demo scan completed'); + } catch (err) { + app.log.error(`Demo scan failed: ${String(err)}`); + } + }); +} +``` + +Add `config` import at top: + +```typescript +import { config } from '../config.js'; +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/api/src/scheduler/index.ts +git commit -m "feat(api): add daily demo scan to scheduler" +``` + +--- + +## Task 16: CLI — create-admin Command + +**Files:** +- Create: `packages/cli/src/admin.ts` +- Modify: `packages/cli/src/index.ts` + +- [ ] **Step 1: Create admin CLI module** + +```typescript +// packages/cli/src/admin.ts +import { randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; + +export async function createAdmin(email: string, password: string, dbPath: string): Promise { + if (password.length < 8) { + throw new Error('Password must be at least 8 characters'); + } + + const bcrypt = await import('bcryptjs'); + const Database = (await import('better-sqlite3')).default; + + const resolvedPath = resolve(dbPath); + const db = new Database(resolvedPath); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + + // Ensure tables exist + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_login_at TEXT, + reset_token TEXT, + reset_token_expires_at TEXT, + refresh_token TEXT + ); + CREATE TABLE IF NOT EXISTS user_profiles ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + settings JSON NOT NULL DEFAULT '{}', + last_login_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + + const normalizedEmail = email.toLowerCase(); + const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(normalizedEmail) as { id: string } | undefined; + + if (existing) { + // Promote existing user to superadmin + db.prepare("UPDATE users SET role = 'superadmin' WHERE id = ?").run(existing.id); + db.prepare("UPDATE user_profiles SET role = 'superadmin' WHERE id = ?").run(existing.id); + console.log(`User ${normalizedEmail} promoted to superadmin.`); + } else { + const id = randomUUID(); + const passwordHash = await bcrypt.hash(password, 12); + db.prepare('INSERT INTO users (id, email, password_hash, role) VALUES (?, ?, ?, ?)').run(id, normalizedEmail, passwordHash, 'superadmin'); + db.prepare("INSERT OR IGNORE INTO user_profiles (id, email, role) VALUES (?, ?, 'superadmin')").run(id, normalizedEmail); + console.log(`Superadmin created: ${normalizedEmail}`); + } + + db.close(); +} +``` + +- [ ] **Step 2: Add create-admin command to CLI** + +In `packages/cli/src/index.ts`, add after the scan command setup: + +```typescript +program + .command('create-admin') + .description('Create or promote a superadmin user (local auth only)') + .requiredOption('--email ', 'Admin email address') + .requiredOption('--password ', 'Admin password (min 8 characters)') + .option('--db ', 'Database path', './data/recon-web.db') + .action(async (options) => { + try { + const { createAdmin } = await import('./admin.js'); + await createAdmin(options.email, options.password, options.db); + } catch (err) { + console.error(`Error: ${(err as Error).message}`); + process.exit(1); + } + }); +``` + +- [ ] **Step 3: Install bcryptjs and better-sqlite3 as cli dependencies** + +```bash +cd packages/cli && npm install bcryptjs better-sqlite3 && npm install -D @types/bcryptjs @types/better-sqlite3 +``` + +- [ ] **Step 4: Commit** + +```bash +git add packages/cli/src/admin.ts packages/cli/src/index.ts packages/cli/package.json +git commit -m "feat(cli): add create-admin command" +``` + +--- + +## Task 17: Update .env.example + +**Files:** +- Modify: `.env.example` + +- [ ] **Step 1: Replace auth section and add new sections** + +Replace the `# ── Authentication ───` section and add new sections: + +```env +# ── Authentication ─────────────────────────────────────── +# Auth provider: "local" (bcrypt + JWT) or "firebase" +# Leave empty to disable authentication entirely +# AUTH_PROVIDER=local + +# JWT secret (required for local auth, minimum 32 characters) +# AUTH_PROVIDER=local requires this to be set +# JWT_SECRET=your-secret-key-at-least-32-characters-long + +# JWT access token expiration (default: 15m) +# JWT_EXPIRES_IN=15m + +# Refresh token expiration in days (default: 30) +# REFRESH_TOKEN_EXPIRES_DAYS=30 + +# Firebase (only when AUTH_PROVIDER=firebase) +# FIREBASE_PROJECT_ID= +# FIREBASE_API_KEY= +# FIREBASE_SERVICE_ACCOUNT_PATH= + +# Allow new user registration (default: true) +# REGISTRATION_OPEN=true + +# Application URL (used in password reset emails) +# APP_URL=http://localhost:3000 + +# ── Scan Limits ──────────────────────────────────────────── +# Daily scan limits (0 = unlimited) +# DAILY_SCAN_LIMIT_GLOBAL=0 +# DAILY_SCAN_LIMIT_USER=0 + +# ── Superadmin Panel ─────────────────────────────────────── +# Configure which columns are visible in admin user list +# SUPERADMIN_VIEW_EMAIL=true +# SUPERADMIN_VIEW_LAST_LOGIN=true +# SUPERADMIN_VIEW_DAILY_SCANS=true +# SUPERADMIN_VIEW_SCANNED_PAGES=true + +# ── Demo ─────────────────────────────────────────────────── +# URL for daily demo scan (shown to unauthenticated users) +# DEMO_SCAN_URL=https://example.com +``` + +Remove the old `AUTH_ENABLED` / `AUTH_TOKEN` lines. + +- [ ] **Step 2: Commit** + +```bash +git add .env.example +git commit -m "docs: update .env.example with new auth configuration" +``` + +--- + +## Task 18: Frontend — API Client Auth Functions + +**Files:** +- Modify: `packages/web/src/lib/api.ts` + +- [ ] **Step 1: Add auth API functions and scan_limits type** + +Add at the end of `packages/web/src/lib/api.ts`: + +```typescript +// ── Auth types ────────────────────────────────────────────────────── +export interface AuthUser { + id: string; + email: string; + role: 'user' | 'superadmin'; +} + +export interface AuthResponse { + accessToken: string; + refreshToken: string; + user: AuthUser; +} + +export interface UserProfile { + id: string; + email: string; + role: string; + settings: Record; + scanLimits: ScanLimitsInfo; +} + +export interface ScanLimitsInfo { + user_daily?: { used: number; limit: number; remaining: number }; + global_daily?: { used: number; limit: number; remaining: number }; +} + +export interface AuthConfig { + provider: string; + registrationOpen: boolean; +} + +// ── Auth API calls ────────────────────────────────────────────────── +const AUTH_BASE = import.meta.env.VITE_API_URL ?? "/api"; + +async function authRequest(path: string, init?: RequestInit): Promise { + const res = await fetch(`${AUTH_BASE}/auth${path}`, { + ...init, + headers: buildHeaders(init?.headers), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({ error: res.statusText })); + const err = new Error(body.error ?? `API ${res.status}`); + (err as any).status = res.status; + (err as any).scanLimits = body.scan_limits; + throw err; + } + + return res.json() as Promise; +} + +export function getAuthConfig(): Promise { + return authRequest('/config'); +} + +export function authRegister(email: string, password: string): Promise { + return authRequest('/register', { + method: 'POST', + body: JSON.stringify({ email, password }), + }); +} + +export function authLogin(email: string, password: string): Promise { + return authRequest('/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + }); +} + +export function authRefresh(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> { + return authRequest('/refresh', { + method: 'POST', + body: JSON.stringify({ refreshToken }), + }); +} + +export function authForgotPassword(email: string): Promise<{ message: string }> { + return authRequest('/forgot-password', { + method: 'POST', + body: JSON.stringify({ email }), + }); +} + +export function authResetPassword(token: string, password: string): Promise<{ message: string }> { + return authRequest('/reset-password', { + method: 'POST', + body: JSON.stringify({ token, password }), + }); +} + +export function authChangePassword(oldPassword: string, newPassword: string): Promise<{ message: string }> { + return authRequest('/change-password', { + method: 'POST', + body: JSON.stringify({ oldPassword, newPassword }), + }); +} + +export function getMe(): Promise { + return authRequest('/me'); +} + +export function updateMe(settings: Record): Promise<{ success: boolean }> { + return authRequest('/me', { + method: 'PUT', + body: JSON.stringify({ settings }), + }); +} + +export function authLogout(): Promise<{ success: boolean }> { + return authRequest('/logout', { method: 'POST' }); +} + +// ── Admin API ─────────────────────────────────────────────────────── +export interface AdminUser { + id: string; + role: string; + created_at: string; + email?: string; + last_login_at?: string | null; + daily_scans?: number; + scanned_pages?: string[]; +} + +export function getAdminUsers(): Promise { + return request('/admin/users'); +} + +export function deleteAdminUser(id: string): Promise<{ success: boolean }> { + return request('/admin/users/' + id, { method: 'DELETE' }); +} + +export function updateAdminUserRole(id: string, role: string): Promise<{ success: boolean }> { + return request('/admin/users/' + id, { + method: 'PUT', + body: JSON.stringify({ role }), + }); +} + +// ── Demo ──────────────────────────────────────────────────────────── +export function getDemoScan(): Promise { + return request('/demo'); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/web/src/lib/api.ts +git commit -m "feat(web): add auth, admin, and demo API client functions" +``` + +--- + +## Task 19: Frontend — Auth Hook Rewrite + +**Files:** +- Rewrite: `packages/web/src/hooks/use-auth.ts` + +- [ ] **Step 1: Rewrite auth hook for JWT-based auth** + +```typescript +// packages/web/src/hooks/use-auth.ts +import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'; +import { createElement } from 'react'; +import type { AuthUser, AuthConfig, ScanLimitsInfo } from '@/lib/api'; + +const ACCESS_TOKEN_KEY = 'recon-web-access-token'; +const REFRESH_TOKEN_KEY = 'recon-web-refresh-token'; +const USER_KEY = 'recon-web-user'; + +interface AuthContextValue { + user: AuthUser | null; + accessToken: string | null; + isAuthenticated: boolean; + authConfig: AuthConfig | null; + scanLimits: ScanLimitsInfo | null; + login: (accessToken: string, refreshToken: string, user: AuthUser) => void; + logout: () => void; + setAuthConfig: (config: AuthConfig) => void; + setScanLimits: (limits: ScanLimitsInfo) => void; +} + +const AuthContext = createContext(null); + +function getStored(key: string): T | null { + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +function setStored(key: string, value: unknown): void { + try { + if (value === null) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value)); + } + } catch { /* localStorage unavailable */ } +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const [accessToken, setAccessToken] = useState(() => { + try { return localStorage.getItem(ACCESS_TOKEN_KEY); } catch { return null; } + }); + const [user, setUser] = useState(() => getStored(USER_KEY)); + const [authConfig, setAuthConfig] = useState(null); + const [scanLimits, setScanLimits] = useState(null); + + const login = useCallback((at: string, rt: string, u: AuthUser) => { + setStored(ACCESS_TOKEN_KEY, at); + setStored(REFRESH_TOKEN_KEY, rt); + setStored(USER_KEY, u); + setAccessToken(at); + setUser(u); + }, []); + + const logout = useCallback(() => { + setStored(ACCESS_TOKEN_KEY, null); + setStored(REFRESH_TOKEN_KEY, null); + setStored(USER_KEY, null); + setAccessToken(null); + setUser(null); + setScanLimits(null); + }, []); + + const value: AuthContextValue = { + user, + accessToken, + isAuthenticated: !!accessToken && !!user, + authConfig, + login, + logout, + setAuthConfig, + scanLimits, + setScanLimits, + }; + + return createElement(AuthContext.Provider, { value }, children); +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within an AuthProvider'); + return ctx; +} +``` + +- [ ] **Step 2: Update buildHeaders in api.ts to use new token key** + +In `packages/web/src/lib/api.ts`, update `getStoredToken`: + +```typescript +function getStoredToken(): string | null { + try { + return localStorage.getItem("recon-web-access-token"); + } catch { + return null; + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/web/src/hooks/use-auth.ts packages/web/src/lib/api.ts +git commit -m "feat(web): rewrite auth hook for JWT-based authentication" +``` + +--- + +## Task 20: Frontend — Route Guard Component + +**Files:** +- Create: `packages/web/src/components/auth/RouteGuard.tsx` + +- [ ] **Step 1: Create route guard** + +```typescript +// packages/web/src/components/auth/RouteGuard.tsx +import { Navigate } from 'react-router-dom'; +import { useAuth } from '@/hooks/use-auth'; + +export function RequireAuth({ children }: { children: React.ReactNode }) { + const { isAuthenticated, authConfig } = useAuth(); + + // If auth is not configured (no provider), allow access + if (!authConfig || !authConfig.provider) { + return <>{children}; + } + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} + +export function RequireSuperadmin({ children }: { children: React.ReactNode }) { + const { user, authConfig } = useAuth(); + + if (!authConfig || !authConfig.provider) { + return <>{children}; + } + + if (!user || user.role !== 'superadmin') { + return ; + } + + return <>{children}; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/web/src/components/auth/RouteGuard.tsx +git commit -m "feat(web): add route guard components" +``` + +--- + +## Task 21: Frontend — Login Page Rewrite + +**Files:** +- Rewrite: `packages/web/src/pages/Login.tsx` + +- [ ] **Step 1: Rewrite login page for email+password** + +```typescript +// packages/web/src/pages/Login.tsx +import { useState, FormEvent } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { useAuth } from '@/hooks/use-auth'; +import { authLogin } from '@/lib/api'; +import { KeyRound } from 'lucide-react'; + +export default function Login() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const { login, authConfig } = useAuth(); + const navigate = useNavigate(); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const result = await authLogin(email, password); + login(result.accessToken, result.refreshToken, result.user); + navigate('/'); + } catch (err: any) { + setError(err.message || 'Login failed'); + } finally { + setLoading(false); + } + } + + return ( +
+
+ +

Log In

+

Enter your credentials to continue

+
+ +
+ { setEmail(e.target.value); if (error) setError(''); }} + placeholder="Email" + className="w-full rounded-xl border border-border bg-surface py-3 px-4 text-foreground placeholder:text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors" + autoFocus + required + /> + { setPassword(e.target.value); if (error) setError(''); }} + placeholder="Password" + className="w-full rounded-xl border border-border bg-surface py-3 px-4 text-foreground placeholder:text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors" + required + /> + {error &&

{error}

} + +
+ +
+ + Forgot password? + + {authConfig?.registrationOpen && ( +

+ Don't have an account?{' '} + + Sign up + +

+ )} +
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/web/src/pages/Login.tsx +git commit -m "feat(web): rewrite login page for email/password auth" +``` + +--- + +## Task 22: Frontend — Signup Page + +**Files:** +- Create: `packages/web/src/pages/Signup.tsx` + +- [ ] **Step 1: Create signup page** + +```typescript +// packages/web/src/pages/Signup.tsx +import { useState, FormEvent } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { useAuth } from '@/hooks/use-auth'; +import { authRegister } from '@/lib/api'; +import { UserPlus } from 'lucide-react'; + +export default function Signup() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const { login } = useAuth(); + const navigate = useNavigate(); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(''); + + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + if (password.length < 8) { + setError('Password must be at least 8 characters'); + return; + } + + setLoading(true); + try { + const result = await authRegister(email, password); + login(result.accessToken, result.refreshToken, result.user); + navigate('/'); + } catch (err: any) { + setError(err.message || 'Registration failed'); + } finally { + setLoading(false); + } + } + + return ( +
+
+ +

Create Account

+

Start scanning websites for free

+
+ +
+ { setEmail(e.target.value); if (error) setError(''); }} + placeholder="Email" + className="w-full rounded-xl border border-border bg-surface py-3 px-4 text-foreground placeholder:text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors" + autoFocus + required + /> + { setPassword(e.target.value); if (error) setError(''); }} + placeholder="Password (min 8 characters)" + className="w-full rounded-xl border border-border bg-surface py-3 px-4 text-foreground placeholder:text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors" + required + minLength={8} + /> + { setConfirmPassword(e.target.value); if (error) setError(''); }} + placeholder="Confirm password" + className="w-full rounded-xl border border-border bg-surface py-3 px-4 text-foreground placeholder:text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors" + required + /> + {error &&

{error}

} + +
+ +

+ Already have an account?{' '} + + Log in + +

+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/web/src/pages/Signup.tsx +git commit -m "feat(web): add signup page" +``` + +--- + +## Task 23: Frontend — Forgot Password & Reset Password Pages + +**Files:** +- Create: `packages/web/src/pages/ForgotPassword.tsx` +- Create: `packages/web/src/pages/ResetPassword.tsx` + +- [ ] **Step 1: Create forgot password page** + +```typescript +// packages/web/src/pages/ForgotPassword.tsx +import { useState, FormEvent } from 'react'; +import { Link } from 'react-router-dom'; +import { authForgotPassword } from '@/lib/api'; +import { Mail } from 'lucide-react'; + +export default function ForgotPassword() { + const [email, setEmail] = useState(''); + const [sent, setSent] = useState(false); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(''); + setLoading(true); + try { + await authForgotPassword(email); + setSent(true); + } catch (err: any) { + setError(err.message || 'Failed to send reset email'); + } finally { + setLoading(false); + } + } + + if (sent) { + return ( +
+ +

Check Your Email

+

+ If an account with that email exists, we've sent a password reset link. +

+ + Back to login + +
+ ); + } + + return ( +
+
+ +

Reset Password

+

Enter your email to receive a reset link

+
+ +
+ { setEmail(e.target.value); if (error) setError(''); }} + placeholder="Email" + className="w-full rounded-xl border border-border bg-surface py-3 px-4 text-foreground placeholder:text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors" + autoFocus + required + /> + {error &&

{error}

} + +
+ + + Back to login + +
+ ); +} +``` + +- [ ] **Step 2: Create reset password page** + +```typescript +// packages/web/src/pages/ResetPassword.tsx +import { useState, FormEvent } from 'react'; +import { useNavigate, useSearchParams, Link } from 'react-router-dom'; +import { authResetPassword } from '@/lib/api'; +import { Lock } from 'lucide-react'; + +export default function ResetPassword() { + const [searchParams] = useSearchParams(); + const token = searchParams.get('token') ?? ''; + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const navigate = useNavigate(); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(''); + + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + if (password.length < 8) { + setError('Password must be at least 8 characters'); + return; + } + + setLoading(true); + try { + await authResetPassword(token, password); + setSuccess(true); + setTimeout(() => navigate('/login'), 2000); + } catch (err: any) { + setError(err.message || 'Failed to reset password'); + } finally { + setLoading(false); + } + } + + if (!token) { + return ( +
+

Invalid Link

+

This password reset link is invalid or has expired.

+ + Request a new link + +
+ ); + } + + if (success) { + return ( +
+

Password Reset!

+

Redirecting to login...

+
+ ); + } + + return ( +
+
+ +

New Password

+

Enter your new password

+
+ +
+ { setPassword(e.target.value); if (error) setError(''); }} + placeholder="New password (min 8 characters)" + className="w-full rounded-xl border border-border bg-surface py-3 px-4 text-foreground placeholder:text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors" + autoFocus + required + minLength={8} + /> + { setConfirmPassword(e.target.value); if (error) setError(''); }} + placeholder="Confirm new password" + className="w-full rounded-xl border border-border bg-surface py-3 px-4 text-foreground placeholder:text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors" + required + /> + {error &&

{error}

} + +
+
+ ); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/web/src/pages/ForgotPassword.tsx packages/web/src/pages/ResetPassword.tsx +git commit -m "feat(web): add forgot password and reset password pages" +``` + +--- + +## Task 24: Frontend — Account Settings Page + +**Files:** +- Create: `packages/web/src/pages/AccountSettings.tsx` + +- [ ] **Step 1: Create account settings page** + +```typescript +// packages/web/src/pages/AccountSettings.tsx +import { useState, FormEvent } from 'react'; +import { useAuth } from '@/hooks/use-auth'; +import { authChangePassword } from '@/lib/api'; +import { User } from 'lucide-react'; + +export default function AccountSettings() { + const { user } = useAuth(); + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleChangePassword(e: FormEvent) { + e.preventDefault(); + setError(''); + setSuccess(''); + + if (newPassword !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + if (newPassword.length < 8) { + setError('Password must be at least 8 characters'); + return; + } + + setLoading(true); + try { + await authChangePassword(oldPassword, newPassword); + setSuccess('Password changed successfully'); + setOldPassword(''); + setNewPassword(''); + setConfirmPassword(''); + } catch (err: any) { + setError(err.message || 'Failed to change password'); + } finally { + setLoading(false); + } + } + + return ( +
+
+ +

Account Settings

+
+ +
+

Profile

+

+ Email: {user?.email} +

+

+ Role: {user?.role} +

+
+ +
+

Change Password

+
+ { setOldPassword(e.target.value); setError(''); setSuccess(''); }} + placeholder="Current password" + className="w-full rounded-xl border border-border bg-background py-3 px-4 text-foreground placeholder:text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors" + required + /> + { setNewPassword(e.target.value); setError(''); setSuccess(''); }} + placeholder="New password (min 8 characters)" + className="w-full rounded-xl border border-border bg-background py-3 px-4 text-foreground placeholder:text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors" + required + minLength={8} + /> + { setConfirmPassword(e.target.value); setError(''); setSuccess(''); }} + placeholder="Confirm new password" + className="w-full rounded-xl border border-border bg-background py-3 px-4 text-foreground placeholder:text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors" + required + /> + {error &&

{error}

} + {success &&

{success}

} + +
+
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/web/src/pages/AccountSettings.tsx +git commit -m "feat(web): add account settings page" +``` + +--- + +## Task 25: Frontend — Admin Panel Page + +**Files:** +- Create: `packages/web/src/pages/AdminPanel.tsx` + +- [ ] **Step 1: Create admin panel page** + +```typescript +// packages/web/src/pages/AdminPanel.tsx +import { useState, useEffect } from 'react'; +import { getAdminUsers, deleteAdminUser, updateAdminUserRole, type AdminUser } from '@/lib/api'; +import { Shield, Trash2 } from 'lucide-react'; + +export default function AdminPanel() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + async function loadUsers() { + try { + const data = await getAdminUsers(); + setUsers(data); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + } + + useEffect(() => { loadUsers(); }, []); + + async function handleDelete(id: string) { + if (!confirm('Are you sure you want to delete this user?')) return; + try { + await deleteAdminUser(id); + setUsers((prev) => prev.filter((u) => u.id !== id)); + } catch (err: any) { + setError(err.message); + } + } + + async function handleRoleToggle(user: AdminUser) { + const newRole = user.role === 'superadmin' ? 'user' : 'superadmin'; + try { + await updateAdminUserRole(user.id, newRole); + setUsers((prev) => prev.map((u) => u.id === user.id ? { ...u, role: newRole } : u)); + } catch (err: any) { + setError(err.message); + } + } + + if (loading) { + return ( +
Loading users...
+ ); + } + + return ( +
+
+ +

Admin Panel

+ {users.length} users +
+ + {error &&

{error}

} + +
+ + + + {users[0]?.email !== undefined && } + + {users[0]?.last_login_at !== undefined && } + {users[0]?.daily_scans !== undefined && } + {users[0]?.scanned_pages !== undefined && } + + + + + + {users.map((user) => ( + + {user.email !== undefined && } + + {user.last_login_at !== undefined && ( + + )} + {user.daily_scans !== undefined && } + {user.scanned_pages !== undefined && ( + + )} + + + + ))} + +
EmailRoleLast LoginToday's ScansScanned PagesJoinedActions
{user.email} + + {user.role} + + + {user.last_login_at ? new Date(user.last_login_at).toLocaleDateString() : 'Never'} + {user.daily_scans} + {user.scanned_pages?.join(', ') || '—'} + {new Date(user.created_at).toLocaleDateString()} + + +
+
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/web/src/pages/AdminPanel.tsx +git commit -m "feat(web): add admin panel page" +``` + +--- + +## Task 26: Frontend — Demo Page + +**Files:** +- Create: `packages/web/src/pages/Demo.tsx` + +- [ ] **Step 1: Create demo results page** + +```typescript +// packages/web/src/pages/Demo.tsx +import { useState, useEffect } from 'react'; +import { getDemoScan, type HistoricalScan } from '@/lib/api'; +import ResultGrid from '@/components/results/ResultGrid'; +import { Eye } from 'lucide-react'; + +export default function Demo() { + const [scan, setScan] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + getDemoScan() + .then(setScan) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( +
Loading demo scan...
+ ); + } + + if (error || !scan) { + return ( +
+ +

No Demo Available

+

A demo scan will appear here once the daily scan runs.

+
+ ); + } + + const results: Record = {}; + for (const r of scan.results) { + results[r.handler] = r.result; + } + + return ( +
+
+

Demo Scan

+

+ Read-only results for {scan.url} + {' · '} + {new Date(scan.created_at).toLocaleDateString()} +

+
+ +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/web/src/pages/Demo.tsx +git commit -m "feat(web): add read-only demo scan page" +``` + +--- + +## Task 27: Frontend — Nav Gear Menu + +**Files:** +- Rewrite: `packages/web/src/components/layout/Nav.tsx` + +- [ ] **Step 1: Replace Settings link with gear icon dropdown** + +Rewrite `Nav.tsx` — replace the `` with a gear dropdown. Keep the existing theme switcher and GitHub link. The gear dropdown logic: + +```typescript +// Add to imports: +import { useAuth } from '@/hooks/use-auth'; +import { Settings, LogOut, User, Shield as ShieldIcon } from 'lucide-react'; + +// Inside Nav component, add: +const { isAuthenticated, user, logout, authConfig } = useAuth(); +const navigate = useNavigate(); +const [showGear, setShowGear] = useState(false); +const gearRef = useRef(null); + +// Close gear dropdown on click outside (same pattern as theme) +useEffect(() => { + if (!showGear) return; + const handleClick = (e: MouseEvent) => { + if (gearRef.current && !gearRef.current.contains(e.target as Node)) { + setShowGear(false); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); +}, [showGear]); +``` + +Replace the Settings NavLink with: + +```tsx +{/* Gear menu */} +
+ + + {showGear && ( +
+ {isAuthenticated ? ( + <> + { navigate('/account'); setShowGear(false); }} + /> + { navigate('/settings'); setShowGear(false); }} + /> + {user?.role === 'superadmin' && ( + { navigate('/admin'); setShowGear(false); }} + /> + )} +
+ { logout(); navigate('/'); setShowGear(false); }} + /> + + ) : authConfig?.provider ? ( + <> + { navigate('/login'); setShowGear(false); }} + /> + {authConfig.registrationOpen && ( + { navigate('/signup'); setShowGear(false); }} + /> + )} +
+ { navigate('/settings'); setShowGear(false); }} + /> + + ) : ( + { navigate('/settings'); setShowGear(false); }} + /> + )} +
+ )} +
+``` + +Add helper component at bottom of file: + +```typescript +function GearItem({ icon: Icon, label, onClick }: { + icon: React.ComponentType<{ className?: string }>; + label: string; + onClick: () => void; +}) { + return ( + + ); +} +``` + +Add `useNavigate` import from `react-router-dom`. + +- [ ] **Step 2: Commit** + +```bash +git add packages/web/src/components/layout/Nav.tsx +git commit -m "feat(web): replace settings link with gear icon dropdown menu" +``` + +--- + +## Task 28: Frontend — Homepage Conditional Content + +**Files:** +- Modify: `packages/web/src/pages/Home.tsx` + +- [ ] **Step 1: Add auth-aware conditional rendering** + +Add imports at top: + +```typescript +import { useAuth } from '@/hooks/use-auth'; +import { Link } from 'react-router-dom'; +``` + +Inside the `Home` component, add: + +```typescript +const { isAuthenticated, authConfig, scanLimits } = useAuth(); +const authEnabled = !!authConfig?.provider; +``` + +Wrap the search form section with a conditional: + +```tsx +{/* Scan form — shown when logged in or auth disabled */} +{(!authEnabled || isAuthenticated) ? ( + <> + {/* Scan limit indicator */} + {scanLimits?.user_daily && ( +
+ + {scanLimits.user_daily.remaining}/{scanLimits.user_daily.limit} scans remaining today + +
+ )} + + {/* Existing search form */} +
+ ... +
+ +) : ( + /* Auth CTA — shown when auth is enabled but user is not logged in */ +
+
+ + Sign Up + + + View Demo + +
+
+)} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/web/src/pages/Home.tsx +git commit -m "feat(web): add auth-aware homepage with sign-up CTA and scan limits" +``` + +--- + +## Task 29: Frontend — Update App Router + +**Files:** +- Modify: `packages/web/src/App.tsx` + +- [ ] **Step 1: Add all new routes with guards** + +```typescript +// packages/web/src/App.tsx +import { Routes, Route } from "react-router-dom"; +import { useEffect } from "react"; +import Nav from "@/components/layout/Nav"; +import Footer from "@/components/layout/Footer"; +import Home from "@/pages/Home"; +import Results from "@/pages/Results"; +import HistoryResults from "@/pages/HistoryResults"; +import History from "@/pages/History"; +import Login from "@/pages/Login"; +import Signup from "@/pages/Signup"; +import ForgotPassword from "@/pages/ForgotPassword"; +import ResetPassword from "@/pages/ResetPassword"; +import AccountSettings from "@/pages/AccountSettings"; +import AdminPanel from "@/pages/AdminPanel"; +import Demo from "@/pages/Demo"; +import Settings from "@/pages/Settings"; +import Compare from "@/pages/Compare"; +import NotFound from "@/pages/NotFound"; +import { AuthProvider, useAuth } from "@/hooks/use-auth"; +import { ThemeProvider } from "@/hooks/use-theme"; +import { RequireAuth, RequireSuperadmin } from "@/components/auth/RouteGuard"; +import { getAuthConfig, getMe } from "@/lib/api"; + +function AppRoutes() { + const { setAuthConfig, isAuthenticated, setScanLimits } = useAuth(); + + // Fetch auth config on mount + useEffect(() => { + getAuthConfig() + .then(setAuthConfig) + .catch(() => { /* Auth not configured — that's fine */ }); + }, [setAuthConfig]); + + // Fetch user profile + scan limits when authenticated + useEffect(() => { + if (!isAuthenticated) return; + getMe() + .then((me) => { if (me.scanLimits) setScanLimits(me.scanLimits); }) + .catch(() => {}); + }, [isAuthenticated, setScanLimits]); + + return ( + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ); +} + +export default function App() { + return ( + + +
+
+
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/web/src/App.tsx +git commit -m "feat(web): add auth routes, guards, and auth config loading" +``` + +--- + +## Task 30: Frontend — Update Settings Page + +**Files:** +- Modify: `packages/web/src/pages/Settings.tsx` + +- [ ] **Step 1: Remove old auth config section** + +Remove the `authConfig` array and the `` call from Settings.tsx since auth is no longer configured via env display — it's a proper system now. + +- [ ] **Step 2: Commit** + +```bash +git add packages/web/src/pages/Settings.tsx +git commit -m "refactor(web): remove old bearer token auth config from settings" +``` + +--- + +## Task 31: Build Verification + +- [ ] **Step 1: Run TypeScript type checks for both packages** + +```bash +cd packages/api && npm run typecheck +cd ../web && npm run typecheck +``` + +Fix any type errors. + +- [ ] **Step 2: Run existing tests** + +```bash +cd packages/api && npm test +cd ../web && npm test +``` + +Fix any broken tests. + +- [ ] **Step 3: Test build** + +```bash +cd packages/api && npm run build +cd ../web && npm run build +``` + +- [ ] **Step 4: Commit any fixes** + +```bash +git add -A +git commit -m "fix: resolve type errors and test failures from auth implementation" +``` + +--- + +## Task 32: Manual Smoke Test + +- [ ] **Step 1: Start the API in dev mode with local auth** + +Create a test `.env` with: + +```env +AUTH_PROVIDER=local +JWT_SECRET=test-secret-key-at-least-32-characters-long +REGISTRATION_OPEN=true +DAILY_SCAN_LIMIT_USER=5 +DEMO_SCAN_URL=https://example.com +``` + +```bash +cd packages/api && npm run dev +``` + +- [ ] **Step 2: Create a superadmin via CLI** + +```bash +cd packages/cli && npx tsx src/index.ts create-admin --email admin@test.com --password TestPassword123 +``` + +- [ ] **Step 3: Test auth endpoints via curl** + +```bash +# Register +curl -X POST http://localhost:3000/api/auth/register \ + -H 'Content-Type: application/json' \ + -d '{"email":"user@test.com","password":"TestPassword123"}' + +# Login +curl -X POST http://localhost:3000/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"user@test.com","password":"TestPassword123"}' + +# Get profile (use token from login response) +curl http://localhost:3000/api/auth/me \ + -H 'Authorization: Bearer ' + +# Demo endpoint +curl http://localhost:3000/api/demo + +# Auth config +curl http://localhost:3000/api/auth/config +``` + +- [ ] **Step 4: Test frontend** + +```bash +cd packages/web && npm run dev +``` + +Open browser, verify: +1. Homepage shows Sign Up + View Demo buttons +2. Sign Up flow works +3. Login flow works +4. Scan limit badge shows after login +5. Gear menu shows correct items +6. Admin panel accessible for superadmin +7. Settings page no longer shows old auth section From 4b658ef07cb43a42bd75e645bd12f2716d0bfe15 Mon Sep 17 00:00:00 2001 From: Bruno Pavelja Date: Thu, 9 Apr 2026 00:31:42 +0200 Subject: [PATCH 3/6] feat: open-core architecture with pluggable server and component exports Remove authentication from the open-source codebase to prepare for an open-core business model. Auth, admin panel, and user management move to a separate proprietary repository. - Make buildServer() accept plugins and routes options for extensibility - Create API exports barrel (exports.ts) for external consumers - Create web component library exports (exports.ts) for pro frontend - Add userMenu slot prop to Nav for pro to inject auth UI - Remove all auth code: pages, hooks, guards, routes, config, DB tables - Remove auth dependencies (bcryptjs, jsonwebtoken) - Fix PageSpeed handler: add https:// prefix, use BEST-PRACTICES category - Redesign BuiltWith renderer with summary chips and detail modal - Improve QualityRenderer layout (one row per category) - Fix CSS @import order warning - Fix DELETE request Content-Type error - Clean .env.example (remove auth sections) --- .env.example | 27 +- .gitignore | 3 + docker-compose.remote.yml | 2 + docker-compose.yml | 2 + helm/recon-web/templates/configmap.yaml | 17 +- helm/recon-web/templates/secret.yaml | 10 +- helm/recon-web/values.yaml | 30 +- package-lock.json | 20 + packages/api/package.json | 6 + packages/api/src/auth/auth.test.ts | 168 -------- packages/api/src/auth/index.ts | 57 --- packages/api/src/config.ts | 17 + packages/api/src/db/db.test.ts | 3 +- packages/api/src/db/index.ts | 115 +++++- packages/api/src/exports.ts | 34 ++ packages/api/src/notifications/email.ts | 37 ++ packages/api/src/routes.ts | 33 +- packages/api/src/scan.test.ts | 3 +- packages/api/src/scan.ts | 32 +- packages/api/src/scheduler/index.ts | 46 +++ packages/api/src/server.ts | 66 ++- packages/cli/package.json | 10 +- packages/core/src/handlers/legacy-rank.ts | 4 +- packages/core/src/handlers/quality.ts | 14 +- packages/core/src/handlers/screenshot.ts | 2 +- packages/core/src/handlers/wordpress.ts | 107 ++++- packages/web/package.json | 4 + packages/web/src/App.tsx | 11 +- packages/web/src/components/layout/Footer.tsx | 6 +- packages/web/src/components/layout/Nav.tsx | 22 +- .../web/src/components/results/ResultGrid.tsx | 8 +- .../results/renderers/FeaturesRenderer.tsx | 274 +++++++++++-- .../results/renderers/QualityRenderer.tsx | 10 +- packages/web/src/exports.ts | 31 ++ packages/web/src/hooks/use-auth.ts | 49 --- packages/web/src/index.css | 2 +- packages/web/src/lib/api.ts | 39 +- packages/web/src/pages/Compare.tsx | 4 +- packages/web/src/pages/Demo.tsx | 81 ++++ packages/web/src/pages/History.tsx | 386 ++++++++++++++---- packages/web/src/pages/Home.tsx | 78 ++-- packages/web/src/pages/Login.tsx | 55 --- packages/web/src/pages/Results.tsx | 36 ++ packages/web/src/pages/Settings.tsx | 7 - 44 files changed, 1330 insertions(+), 638 deletions(-) delete mode 100644 packages/api/src/auth/auth.test.ts delete mode 100644 packages/api/src/auth/index.ts create mode 100644 packages/api/src/exports.ts create mode 100644 packages/web/src/exports.ts delete mode 100644 packages/web/src/hooks/use-auth.ts create mode 100644 packages/web/src/pages/Demo.tsx delete mode 100644 packages/web/src/pages/Login.tsx diff --git a/.env.example b/.env.example index 61f11d6..0efe8e1 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,12 @@ +# ── Docker Compose ───────────────────────────────────────── +# Ports exposed on the host (docker-compose.yml / docker-compose.remote.yml) +# API_PORT=3000 +# WEB_PORT=8080 + +# Override image registry/tag for docker-compose.remote.yml +# REGISTRY=ghcr.io/brunoafk/recon-web +# TAG=latest + # ── Server ──────────────────────────────────────────────── PORT=3000 HOST=0.0.0.0 @@ -9,9 +18,20 @@ API_TIMEOUT_LIMIT=30000 # CORS origin (use * for any, or specific domain) API_CORS_ORIGIN=* +# Rate limit: max requests per IP per time window (default: 100 / 10 minutes) +# RATE_LIMIT_MAX=100 +# RATE_LIMIT_WINDOW=10 minutes + # Maximum concurrent handlers per scan (default: 8) # MAX_CONCURRENCY=8 +# Maximum concurrent scans server-wide (default: 3) +# Extra scans queue and wait. Prevents memory exhaustion on small VMs. +# MAX_CONCURRENT_SCANS=3 + +# Enable Swagger API docs at /docs (default: false) +# SWAGGER_ENABLED=true + # Path to Chromium binary (auto-detected in Docker) # CHROME_PATH=/usr/bin/chromium @@ -22,10 +42,9 @@ API_CORS_ORIGIN=* # SQLite database path (auto-created) # DB_PATH=./data/recon-web.db -# ── Authentication ─────────────────────────────────────── -# Enable bearer token authentication for API endpoints -# AUTH_ENABLED=true -# AUTH_TOKEN=your-secret-token-here +# ── Demo ─────────────────────────────────────────────────── +# URL for demo scan (shown on /demo page) +# DEMO_SCAN_URL=https://example.com # ── Scheduled Scans ───────────────────────────────────── # Enable automated scheduled scanning diff --git a/.gitignore b/.gitignore index a5e5ace..ff14f1f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,9 @@ Thumbs.db @internal/ docs/superpowers/ +# Infrastructure +infra/ + # SQLite databases *.db *.db-journal diff --git a/docker-compose.remote.yml b/docker-compose.remote.yml index 8885755..d0b65b9 100644 --- a/docker-compose.remote.yml +++ b/docker-compose.remote.yml @@ -39,6 +39,8 @@ services: cli: image: ${REGISTRY:-ghcr.io/brunoafk/recon-web}/cli:${TAG:-latest} env_file: .env + volumes: + - scan-data:/app/data profiles: ["cli"] volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 7e4e79f..467d983 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,8 @@ services: context: . dockerfile: packages/cli/Dockerfile env_file: .env + volumes: + - scan-data:/app/data profiles: ["cli"] volumes: diff --git a/helm/recon-web/templates/configmap.yaml b/helm/recon-web/templates/configmap.yaml index 54c6617..5fe58d1 100644 --- a/helm/recon-web/templates/configmap.yaml +++ b/helm/recon-web/templates/configmap.yaml @@ -10,7 +10,22 @@ data: HOST: "0.0.0.0" PORT: "3000" MAX_CONCURRENCY: {{ .Values.api.env.MAX_CONCURRENCY | default "8" | quote }} - AUTH_ENABLED: {{ .Values.auth.enabled | quote }} + MAX_CONCURRENT_SCANS: {{ .Values.auth.maxConcurrentScans | default 3 | quote }} + {{- if .Values.auth.provider }} + AUTH_PROVIDER: {{ .Values.auth.provider | quote }} + REGISTRATION_OPEN: {{ .Values.auth.registrationOpen | quote }} + APP_URL: {{ .Values.auth.appUrl | quote }} + DAILY_SCAN_LIMIT_GLOBAL: {{ .Values.auth.scanLimits.global | default 0 | quote }} + DAILY_SCAN_LIMIT_USER: {{ .Values.auth.scanLimits.user | default 0 | quote }} + SUPERADMIN_VIEW_EMAIL: {{ .Values.auth.superadminView.email | default true | quote }} + SUPERADMIN_VIEW_LAST_LOGIN: {{ .Values.auth.superadminView.lastLogin | default true | quote }} + SUPERADMIN_VIEW_DAILY_SCANS: {{ .Values.auth.superadminView.dailyScans | default true | quote }} + SUPERADMIN_VIEW_SCANNED_PAGES: {{ .Values.auth.superadminView.scannedPages | default true | quote }} + DEMO_SCAN_URL: {{ .Values.auth.demoScanUrl | default "https://example.com" | quote }} + {{- if .Values.auth.firebase.projectId }} + FIREBASE_PROJECT_ID: {{ .Values.auth.firebase.projectId | quote }} + {{- end }} + {{- end }} {{- if .Values.scanner.enabled }} SCHEDULE_ENABLED: "true" SCHEDULE_CRON: {{ .Values.scanner.schedule | quote }} diff --git a/helm/recon-web/templates/secret.yaml b/helm/recon-web/templates/secret.yaml index db0d7dd..3b072fa 100644 --- a/helm/recon-web/templates/secret.yaml +++ b/helm/recon-web/templates/secret.yaml @@ -7,8 +7,14 @@ metadata: app.kubernetes.io/instance: {{ .Release.Name }} type: Opaque stringData: - {{- if .Values.auth.token }} - AUTH_TOKEN: {{ .Values.auth.token | quote }} + {{- if .Values.auth.jwtSecret }} + JWT_SECRET: {{ .Values.auth.jwtSecret | quote }} + {{- end }} + {{- if .Values.auth.setupToken }} + SETUP_TOKEN: {{ .Values.auth.setupToken | quote }} + {{- end }} + {{- if .Values.auth.firebase.apiKey }} + FIREBASE_API_KEY: {{ .Values.auth.firebase.apiKey | quote }} {{- end }} {{- range $key, $value := .Values.api.env }} {{ $key }}: {{ $value | quote }} diff --git a/helm/recon-web/values.yaml b/helm/recon-web/values.yaml index 7bddcc8..87df394 100644 --- a/helm/recon-web/values.yaml +++ b/helm/recon-web/values.yaml @@ -14,6 +14,7 @@ api: env: {} # GOOGLE_CLOUD_API_KEY: "" # MAX_CONCURRENCY: "8" + # MAX_CONCURRENT_SCANS: "3" web: image: @@ -58,8 +59,33 @@ notifications: notifyEmail: "" auth: - enabled: false - token: "" + # Provider: "local", "firebase", or "" (disabled) + provider: "" + # Required for local auth (min 32 chars) + jwtSecret: "" + # One-time token for creating first superadmin via API + setupToken: "" + registrationOpen: true + appUrl: "" + # Firebase (only when provider=firebase) + firebase: + projectId: "" + apiKey: "" + serviceAccountPath: "" + # Daily scan limits (0 = unlimited) + scanLimits: + global: 0 + user: 0 + # Max concurrent scans server-wide + maxConcurrentScans: 3 + # Superadmin panel column visibility + superadminView: + email: true + lastLogin: true + dailyScans: true + scannedPages: true + # Demo scan URL (shown to unauthenticated users) + demoScanUrl: "https://example.com" persistence: enabled: true diff --git a/package-lock.json b/package-lock.json index 4f49499..ad88d27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2969,6 +2969,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -3637,6 +3644,15 @@ "node": ">=10.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/better-sqlite3": { "version": "12.8.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", @@ -10280,6 +10296,8 @@ "version": "1.0.0", "dependencies": { "@recon-web/core": "*", + "bcryptjs": "^3.0.3", + "better-sqlite3": "^12.8.0", "commander": "^12.1.0", "ora": "^8.1.0", "picocolors": "^1.1.0" @@ -10288,6 +10306,8 @@ "recon-web": "dist/index.js" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.0.0", "typescript": "^5.6.0", "vitest": "^2.1.0" diff --git a/packages/api/package.json b/packages/api/package.json index b5c781d..2f800a8 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -3,6 +3,12 @@ "version": "1.0.0", "type": "module", "main": "dist/index.js", + "exports": { + ".": { + "import": "./dist/exports.js", + "types": "./dist/exports.d.ts" + } + }, "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", diff --git a/packages/api/src/auth/auth.test.ts b/packages/api/src/auth/auth.test.ts deleted file mode 100644 index c59edc2..0000000 --- a/packages/api/src/auth/auth.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { randomUUID } from 'node:crypto'; -import { unlinkSync } from 'node:fs'; -import type { FastifyInstance } from 'fastify'; -import { buildServer } from '../server.js'; - -let app: FastifyInstance; -let savedEnv: Record; -let testDbPath: string; - -const AUTH_VARS = ['AUTH_ENABLED', 'AUTH_TOKEN', 'DB_PATH']; - -beforeEach(() => { - // Save current env - savedEnv = {}; - for (const key of AUTH_VARS) { - savedEnv[key] = process.env[key]; - } - // Use unique DB per test to avoid SQLITE_BUSY - testDbPath = `/tmp/recon-web-auth-test-${randomUUID()}.db`; - process.env.DB_PATH = testDbPath; -}); - -afterEach(async () => { - if (app) { - await app.close(); - } - // Clean up test DB - try { unlinkSync(testDbPath); } catch {} - try { unlinkSync(testDbPath + '-wal'); } catch {} - try { unlinkSync(testDbPath + '-shm'); } catch {} - // Restore env - for (const key of AUTH_VARS) { - if (savedEnv[key] === undefined) { - delete process.env[key]; - } else { - process.env[key] = savedEnv[key]; - } - } -}); - -describe('Auth plugin', () => { - describe('auth disabled (default)', () => { - it('requests pass through without auth header', async () => { - delete process.env.AUTH_ENABLED; - delete process.env.AUTH_TOKEN; - app = await buildServer(); - await app.ready(); - - const res = await app.inject({ method: 'GET', url: '/health' }); - expect(res.statusCode).toBe(200); - }); - - it('non-public routes also pass through when auth is disabled', async () => { - delete process.env.AUTH_ENABLED; - delete process.env.AUTH_TOKEN; - app = await buildServer(); - await app.ready(); - - const res = await app.inject({ method: 'GET', url: '/api/handlers' }); - expect(res.statusCode).toBe(200); - }); - }); - - describe('auth enabled', () => { - const TEST_TOKEN = 'test-secret-token-12345'; - - it('request without token gets 401', async () => { - process.env.AUTH_ENABLED = 'true'; - process.env.AUTH_TOKEN = TEST_TOKEN; - app = await buildServer(); - await app.ready(); - - const res = await app.inject({ method: 'GET', url: '/api/dns?url=example.com' }); - expect(res.statusCode).toBe(401); - const body = JSON.parse(res.payload); - expect(body.error).toMatch(/[Mm]issing/); - }); - - it('request with wrong token gets 401', async () => { - process.env.AUTH_ENABLED = 'true'; - process.env.AUTH_TOKEN = TEST_TOKEN; - app = await buildServer(); - await app.ready(); - - const res = await app.inject({ - method: 'GET', - url: '/api/dns?url=example.com', - headers: { authorization: 'Bearer wrong-token' }, - }); - expect(res.statusCode).toBe(401); - const body = JSON.parse(res.payload); - expect(body.error).toMatch(/[Ii]nvalid/); - }); - - it('request with correct token passes', async () => { - process.env.AUTH_ENABLED = 'true'; - process.env.AUTH_TOKEN = TEST_TOKEN; - app = await buildServer(); - await app.ready(); - - const res = await app.inject({ - method: 'GET', - url: '/api/handlers', - headers: { authorization: `Bearer ${TEST_TOKEN}` }, - }); - expect(res.statusCode).toBe(200); - }); - - it('request with malformed auth header (no Bearer prefix) gets 401', async () => { - process.env.AUTH_ENABLED = 'true'; - process.env.AUTH_TOKEN = TEST_TOKEN; - app = await buildServer(); - await app.ready(); - - const res = await app.inject({ - method: 'GET', - url: '/api/dns?url=example.com', - headers: { authorization: TEST_TOKEN }, - }); - expect(res.statusCode).toBe(401); - }); - }); - - describe('public routes with auth enabled', () => { - const TEST_TOKEN = 'test-secret-token-12345'; - - it('/health passes through without token', async () => { - process.env.AUTH_ENABLED = 'true'; - process.env.AUTH_TOKEN = TEST_TOKEN; - app = await buildServer(); - await app.ready(); - - const res = await app.inject({ method: 'GET', url: '/health' }); - expect(res.statusCode).toBe(200); - }); - - it('/api/handlers passes through without token', async () => { - process.env.AUTH_ENABLED = 'true'; - process.env.AUTH_TOKEN = TEST_TOKEN; - app = await buildServer(); - await app.ready(); - - const res = await app.inject({ method: 'GET', url: '/api/handlers' }); - expect(res.statusCode).toBe(200); - }); - - it('frontend shell routes pass through without token', async () => { - process.env.AUTH_ENABLED = 'true'; - process.env.AUTH_TOKEN = TEST_TOKEN; - app = await buildServer(); - await app.ready(); - - const res = await app.inject({ method: 'GET', url: '/' }); - expect(res.statusCode).not.toBe(401); - }); - - it('/docs passes through without token', async () => { - process.env.AUTH_ENABLED = 'true'; - process.env.AUTH_TOKEN = TEST_TOKEN; - app = await buildServer(); - await app.ready(); - - const res = await app.inject({ method: 'GET', url: '/docs' }); - expect(res.statusCode).not.toBe(401); - }); - }); -}); diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts deleted file mode 100644 index 2c49a75..0000000 --- a/packages/api/src/auth/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { timingSafeEqual } from 'node:crypto'; -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import fp from 'fastify-plugin'; - -/** Public routes that skip authentication. */ -const PUBLIC_PATHS = new Set(['/health', '/api/handlers', '/docs']); - -async function authPluginImpl(app: FastifyInstance): Promise { - const enabled = process.env.AUTH_ENABLED === 'true'; - const token = process.env.AUTH_TOKEN ?? ''; - - if (!enabled) { - if (process.env.NODE_ENV === 'production') { - app.log.warn('Auth is DISABLED in production — API is publicly accessible. Set AUTH_ENABLED=true to secure it.'); - } else { - app.log.info('Auth plugin loaded but AUTH_ENABLED is not true — skipping'); - } - return; - } - - if (!token) { - app.log.warn('AUTH_ENABLED is true but AUTH_TOKEN is empty — all requests will be rejected'); - } else if (token.length < 32) { - app.log.warn(`AUTH_TOKEN is only ${token.length} characters — use at least 32 characters for security`); - } - - app.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => { - const path = request.url.split('?')[0]; - - if (!path.startsWith('/api')) { - return; - } - - // Allow public endpoints through - if (PUBLIC_PATHS.has(path)) { - return; - } - - const header = request.headers.authorization; - if (!header || !header.startsWith('Bearer ')) { - return reply.code(401).send({ error: 'Missing or malformed Authorization header' }); - } - - const provided = header.slice(7); - const a = Buffer.from(provided); - const b = Buffer.from(token); - const valid = a.length === b.length && timingSafeEqual(a, b); - if (!valid) { - return reply.code(401).send({ error: 'Invalid token' }); - } - }); -} - -export const authPlugin = fp(authPluginImpl, { - name: 'auth-plugin', - fastify: '5.x', -}); diff --git a/packages/api/src/config.ts b/packages/api/src/config.ts index 5272b18..5ac4302 100644 --- a/packages/api/src/config.ts +++ b/packages/api/src/config.ts @@ -13,6 +13,19 @@ function detectChromePath(): string | undefined { return candidates.find((p) => existsSync(p)); } +export function envBool(key: string, defaultValue: boolean): boolean { + const val = process.env[key]; + if (val === undefined || val === '') return defaultValue; + return val.toLowerCase() !== 'false' && val !== '0'; +} + +export function envInt(key: string, defaultValue: number): number { + const val = process.env[key]; + if (val === undefined || val === '') return defaultValue; + const parsed = parseInt(val, 10); + return isNaN(parsed) ? defaultValue : parsed; +} + export const config = { port: parseInt(process.env.PORT || '3000', 10), host: process.env.HOST || '0.0.0.0', @@ -21,8 +34,12 @@ export const config = { chromePath: detectChromePath(), staticDir: process.env.STATIC_DIR || undefined, maxConcurrency: parseInt(process.env.MAX_CONCURRENCY || '8', 10), + maxConcurrentScans: envInt('MAX_CONCURRENT_SCANS', 3), dbPath: process.env.DB_PATH || './data/recon-web.db', + // Demo + demoScanUrl: process.env.DEMO_SCAN_URL || 'https://example.com', + apiKeys: { GOOGLE_CLOUD_API_KEY: process.env.GOOGLE_CLOUD_API_KEY || '', CLOUDMERSIVE_API_KEY: process.env.CLOUDMERSIVE_API_KEY || '', diff --git a/packages/api/src/db/db.test.ts b/packages/api/src/db/db.test.ts index 0bd149e..8ced6b9 100644 --- a/packages/api/src/db/db.test.ts +++ b/packages/api/src/db/db.test.ts @@ -17,7 +17,8 @@ const CREATE_TABLES_SQL = ` created_at TEXT NOT NULL DEFAULT (datetime('now')), handler_count INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'running', - duration_ms INTEGER + duration_ms INTEGER, + user_id TEXT ); CREATE TABLE IF NOT EXISTS scan_results ( diff --git a/packages/api/src/db/index.ts b/packages/api/src/db/index.ts index ce018b3..f5d9ed0 100644 --- a/packages/api/src/db/index.ts +++ b/packages/api/src/db/index.ts @@ -12,6 +12,7 @@ export interface Scan { handler_count: number; status: string; duration_ms: number | null; + user_id: string | null; } export interface ScanWithResults extends Scan { @@ -26,8 +27,11 @@ export interface ScanResult { duration_ms: number | null; } +/** Callback for extending the database schema (e.g. adding user tables). */ +export type DbMigration = (db: BetterSqlite3.Database) => void; + // ── Init ──────────────────────────────────────────────────────────────── -export function initDb(dbPath: string): BetterSqlite3.Database { +export function initDb(dbPath: string, migrations?: DbMigration[]): BetterSqlite3.Database { mkdirSync(dirname(dbPath), { recursive: true }); const db = new Database(dbPath); @@ -41,7 +45,8 @@ export function initDb(dbPath: string): BetterSqlite3.Database { created_at TEXT NOT NULL DEFAULT (datetime('now')), handler_count INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'running', - duration_ms INTEGER + duration_ms INTEGER, + user_id TEXT ); CREATE TABLE IF NOT EXISTS scan_results ( @@ -55,8 +60,24 @@ export function initDb(dbPath: string): BetterSqlite3.Database { CREATE INDEX IF NOT EXISTS idx_scans_url ON scans(url); CREATE INDEX IF NOT EXISTS idx_scans_created ON scans(created_at DESC); CREATE INDEX IF NOT EXISTS idx_scan_results_scan_id ON scan_results(scan_id); + CREATE INDEX IF NOT EXISTS idx_scans_user_id ON scans(user_id); `); + // Migration: add user_id column to scans if missing (for existing databases) + const hasUserId = db.prepare( + "SELECT COUNT(*) as cnt FROM pragma_table_info('scans') WHERE name = 'user_id'" + ).get() as { cnt: number }; + + if (hasUserId.cnt === 0) { + db.exec('ALTER TABLE scans ADD COLUMN user_id TEXT'); + db.exec('CREATE INDEX IF NOT EXISTS idx_scans_user_id ON scans(user_id)'); + } + + // Run external migrations (e.g. auth tables from pro) + if (migrations) { + for (const m of migrations) m(db); + } + return db; } @@ -69,15 +90,15 @@ function safeJsonParse(raw: string, fallback: unknown = null): unknown { } } -// ── CRUD ──────────────────────────────────────────────────────────────── +// ── CRUD: Scans ────────────────────────────────────────────────────────── export function createScan( db: BetterSqlite3.Database, - opts: { id?: string; url: string; handlerCount: number }, + opts: { id?: string; url: string; handlerCount: number; userId?: string }, ): string { const id = opts.id ?? randomUUID(); db.prepare( - 'INSERT INTO scans (id, url, handler_count, status) VALUES (?, ?, ?, ?)', - ).run(id, opts.url, opts.handlerCount, 'running'); + 'INSERT INTO scans (id, url, handler_count, status, user_id) VALUES (?, ?, ?, ?, ?)', + ).run(id, opts.url, opts.handlerCount, 'running', opts.userId ?? null); return id; } @@ -106,12 +127,26 @@ export function saveScanResult( export function getScans( db: BetterSqlite3.Database, - opts: { limit?: number; offset?: number } = {}, + opts: { limit?: number; offset?: number; userId?: string; all?: boolean; search?: string } = {}, ): Scan[] { const limit = opts.limit ?? 20; const offset = opts.offset ?? 0; - return db - .prepare('SELECT * FROM scans ORDER BY created_at DESC LIMIT ? OFFSET ?') + const search = opts.search ? `%${opts.search}%` : null; + + if (opts.userId && !opts.all) { + if (search) { + return db.prepare('SELECT * FROM scans WHERE user_id = ? AND url LIKE ? ORDER BY created_at DESC LIMIT ? OFFSET ?') + .all(opts.userId, search, limit, offset) as Scan[]; + } + return db.prepare('SELECT * FROM scans WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?') + .all(opts.userId, limit, offset) as Scan[]; + } + + if (search) { + return db.prepare('SELECT * FROM scans WHERE url LIKE ? ORDER BY created_at DESC LIMIT ? OFFSET ?') + .all(search, limit, offset) as Scan[]; + } + return db.prepare('SELECT * FROM scans ORDER BY created_at DESC LIMIT ? OFFSET ?') .all(limit, offset) as Scan[]; } @@ -141,3 +176,65 @@ export function deleteScan( const info = db.prepare('DELETE FROM scans WHERE id = ?').run(id); return info.changes > 0; } + +export function getScanCount(db: BetterSqlite3.Database, search?: string): number { + if (search) { + const pattern = `%${search}%`; + const row = db.prepare('SELECT COUNT(*) as cnt FROM scans WHERE url LIKE ?').get(pattern) as { cnt: number }; + return row.cnt; + } + const row = db.prepare('SELECT COUNT(*) as cnt FROM scans').get() as { cnt: number }; + return row.cnt; +} + +/** + * Classify an error the same way the frontend does. + * Must stay in sync with packages/web/src/components/results/classify-error.ts + */ +function classifyError(error: string, errorCategory?: string): 'tool' | 'info' | 'site' { + if (errorCategory === 'tool' || errorCategory === 'info' || errorCategory === 'site') { + return errorCategory; + } + const lower = error.toLowerCase(); + if ( + lower.includes('api key') || lower.includes('api_key') || lower.includes('invalid url') || + lower.includes('typeerror') || lower.includes('econnrefused') || lower.includes('is required for') || + lower.includes('chromium') || lower.includes('chrome') || lower.includes('puppeteer') || + lower.includes('provide built') || lower.includes('provide google') || + lower.includes('provide cloud') || lower.includes('provide tranco') + ) return 'tool'; + if ( + lower.includes('not found') || lower.includes('no match') || lower.includes('not serve') || + lower.includes('no mail server') || lower.includes('no data found') || + lower.includes('no txt record') || lower.includes('never been archived') || + lower.includes('no matches found') + ) return 'info'; + return 'site'; +} + +export function getScanResultSummary( + db: BetterSqlite3.Database, + scanId: string, +): { ok: number; issues: number; info: number; skipped: number } { + const rows = db.prepare( + 'SELECT result FROM scan_results WHERE scan_id = ?' + ).all(scanId) as Array<{ result: string }>; + + let ok = 0, issues = 0, info = 0, skipped = 0; + for (const row of rows) { + try { + const parsed = JSON.parse(row.result); + if (parsed.skipped) { + skipped++; + } else if (parsed.error) { + const cat = classifyError(parsed.error, parsed.errorCategory); + if (cat === 'tool') skipped++; + else if (cat === 'info') info++; + else issues++; + } else { + ok++; + } + } catch { issues++; } + } + return { ok, issues, info, skipped }; +} diff --git a/packages/api/src/exports.ts b/packages/api/src/exports.ts new file mode 100644 index 0000000..900529c --- /dev/null +++ b/packages/api/src/exports.ts @@ -0,0 +1,34 @@ +// Public API for external consumers (e.g. recon-web-pro) + +export { buildServer } from './server.js'; +export type { BuildServerOptions } from './server.js'; + +export { registerRoutes } from './routes.js'; + +export { config, getPopulatedApiKeys, envBool, envInt } from './config.js'; + +export { initDb } from './db/index.js'; +export type { DbMigration, Scan, ScanWithResults, ScanResult } from './db/index.js'; +export { + createScan, + saveScanResult, + updateScanStatus, + getScans, + getScan, + deleteScan, + getScanCount, + getScanResultSummary, +} from './db/index.js'; + +export { executeScan, executeScanDeduped, getScanQueueStatus } from './scan.js'; +export type { + ScanEvent, + ScanProgressSnapshot, + ScanStartedEvent, + HandlerStartedEvent, + HandlerFinishedEvent, + ScanCompletedEvent, + ScanFailedEvent, +} from './scan.js'; + +export { schedulerPlugin } from './scheduler/index.js'; diff --git a/packages/api/src/notifications/email.ts b/packages/api/src/notifications/email.ts index 362c8c1..3777a3b 100644 --- a/packages/api/src/notifications/email.ts +++ b/packages/api/src/notifications/email.ts @@ -29,3 +29,40 @@ export async function sendEmail(message: string, config: EmailConfig): Promise { + const host = process.env.SMTP_HOST; + const port = parseInt(process.env.SMTP_PORT || '587', 10); + const user = process.env.SMTP_USER; + const pass = process.env.SMTP_PASS; + + if (!host || !user || !pass) { + throw new Error('SMTP not configured — cannot send password reset email'); + } + + const transporter = nodemailer.createTransport({ + host, + port, + secure: port === 465, + auth: { user, pass }, + }); + + const appUrl = process.env.APP_URL || 'http://localhost:3000'; + const resetUrl = `${appUrl}/reset-password?token=${resetToken}`; + + await transporter.sendMail({ + from: user, + to, + subject: 'recon-web — Password Reset', + text: `You requested a password reset.\n\nClick this link to reset your password:\n${resetUrl}\n\nThis link expires in 1 hour.\n\nIf you didn't request this, ignore this email.`, + html: ` +

Password Reset

+

You requested a password reset for your recon-web account.

+

Reset Password

+

This link expires in 1 hour. If you didn't request this, ignore this email.

+ `, + }); +} diff --git a/packages/api/src/routes.ts b/packages/api/src/routes.ts index 16f969c..debe72b 100644 --- a/packages/api/src/routes.ts +++ b/packages/api/src/routes.ts @@ -94,7 +94,7 @@ export async function registerRoutes(app: FastifyInstance): Promise { querystring: urlQuerySchema, }, preHandler: validateUrlHook, - }, async (request) => { + }, async (request, reply) => { const { url } = request.query as { url: string }; const apiKeys = getPopulatedApiKeys(); const db = request.server.db; @@ -175,8 +175,9 @@ export async function registerRoutes(app: FastifyInstance): Promise { }, }, async (request) => { const { limit, offset } = request.query as { limit?: number; offset?: number }; + const search = (request.query as any).search as string | undefined; const db = request.server.db; - return getScans(db, { limit, offset }); + return getScans(db, { limit, offset, search }); }); app.get('/api/history/:id', { @@ -294,6 +295,34 @@ export async function registerRoutes(app: FastifyInstance): Promise { return reply.header('Content-Type', 'text/html; charset=utf-8').send(html); }); + // ── Demo endpoint ─────────────────────────────────────────────── + app.get('/api/demo', { + schema: { description: 'Get latest demo scan result (public)', tags: ['demo'] }, + }, async (request, reply) => { + const db = request.server.db; + const demoUrl = process.env.DEMO_SCAN_URL || config.demoScanUrl; + // Try both raw URL and normalized version (scan stores normalized URL) + let scan = db.prepare( + "SELECT * FROM scans WHERE user_id IS NULL AND url = ? ORDER BY created_at DESC LIMIT 1" + ).get(demoUrl) as any; + if (!scan) { + try { + const normalized = normalizeUrl(demoUrl); + scan = db.prepare( + "SELECT * FROM scans WHERE user_id IS NULL AND url = ? ORDER BY created_at DESC LIMIT 1" + ).get(normalized) as any; + } catch { /* invalid URL, skip */ } + } + if (!scan) { + // Fallback: just get the latest system scan (user_id IS NULL) + scan = db.prepare( + "SELECT * FROM scans WHERE user_id IS NULL ORDER BY created_at DESC LIMIT 1" + ).get() as any; + } + if (!scan) return reply.code(404).send({ error: 'No demo scan available yet' }); + return getScan(db, scan.id); + }); + // ── Per-handler routes ────────────────────────────────────────── for (const name of getHandlerNames()) { const entry = registry[name]; diff --git a/packages/api/src/scan.test.ts b/packages/api/src/scan.test.ts index e098e14..1c7ee8f 100644 --- a/packages/api/src/scan.test.ts +++ b/packages/api/src/scan.test.ts @@ -25,7 +25,8 @@ describe('executeScan', () => { created_at TEXT NOT NULL DEFAULT (datetime('now')), handler_count INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'running', - duration_ms INTEGER + duration_ms INTEGER, + user_id TEXT ); CREATE TABLE scan_results ( id TEXT PRIMARY KEY, diff --git a/packages/api/src/scan.ts b/packages/api/src/scan.ts index 607abc0..83aaba3 100644 --- a/packages/api/src/scan.ts +++ b/packages/api/src/scan.ts @@ -70,6 +70,7 @@ interface ExecuteScanOptions { handlers?: string[]; onEvent?: (event: ScanEvent) => void | Promise; signal?: AbortSignal; + userId?: string; } function buildProgress(progress: ScanProgressSnapshot): ScanProgressSnapshot { @@ -162,6 +163,7 @@ export async function executeScan({ handlers, onEvent, signal, + userId, }: ExecuteScanOptions): Promise<{ scanId: string; results: Record; durationMs: number }> { // Pre-flight reachability check (skip when running a targeted subset of handlers) if (!handlers) { @@ -174,7 +176,7 @@ export async function executeScan({ ? allNames.filter((name: string) => name !== 'screenshot').concat('screenshot') : [...allNames]; - const scanId = createScan(db, { url, handlerCount: orderedHandlers.length }); + const scanId = createScan(db, { url, handlerCount: orderedHandlers.length, userId }); const startedAt = Date.now(); const progress: ScanProgressSnapshot = { total: orderedHandlers.length, @@ -277,14 +279,32 @@ export async function executeScan({ } } -// ── In-flight scan deduplication ───────────────────────────────────── +// ── Global concurrent scan limiter ─────────────────────────────────── +import { config } from './config.js'; + type ScanResult = { scanId: string; results: Record; durationMs: number }; + +const globalScanLimit = pLimit( + parseInt(process.env.MAX_CONCURRENT_SCANS || '', 10) || config.maxConcurrentScans || 3, +); + +/** Number of scans currently running or queued. */ +export function getScanQueueStatus(): { active: number; pending: number; limit: number } { + return { + active: globalScanLimit.activeCount, + pending: globalScanLimit.pendingCount, + limit: parseInt(process.env.MAX_CONCURRENT_SCANS || '', 10) || config.maxConcurrentScans || 3, + }; +} + +// ── In-flight scan deduplication ───────────────────────────────────── const inFlightScans = new Map>(); /** - * Wraps executeScan with in-flight deduplication. - * If a scan for the same URL is already running, reuses its Promise - * instead of starting a duplicate scan. + * Wraps executeScan with in-flight deduplication and global concurrency limit. + * - If a scan for the same URL is already running, reuses its Promise. + * - Otherwise, queues the scan through the global limiter so at most + * MAX_CONCURRENT_SCANS scans run simultaneously. */ export async function executeScanDeduped( opts: ExecuteScanOptions, @@ -293,7 +313,7 @@ export async function executeScanDeduped( const existing = inFlightScans.get(key); if (existing) return existing; - const promise = executeScan(opts).finally(() => inFlightScans.delete(key)); + const promise = globalScanLimit(() => executeScan(opts)).finally(() => inFlightScans.delete(key)); inFlightScans.set(key, promise); return promise; } diff --git a/packages/api/src/scheduler/index.ts b/packages/api/src/scheduler/index.ts index 84ba965..4586600 100644 --- a/packages/api/src/scheduler/index.ts +++ b/packages/api/src/scheduler/index.ts @@ -2,9 +2,50 @@ import type { FastifyInstance } from 'fastify'; import fp from 'fastify-plugin'; import cron from 'node-cron'; import { executeScan } from '../scan.js'; +import { config } from '../config.js'; import { detectChanges } from '@recon-web/core'; import { buildNotificationConfig, sendNotification } from '../notifications/index.js'; +async function runDemoScan(app: FastifyInstance): Promise { + const demoUrl = process.env.DEMO_SCAN_URL || config.demoScanUrl; + if (!demoUrl) return; + + app.log.info(`Running demo scan for ${demoUrl}`); + try { + await executeScan({ + db: app.db, + url: demoUrl, + handlerOptions: { timeout: 30_000 }, + concurrency: 4, + }); + app.log.info('Demo scan completed'); + } catch (err) { + app.log.error(`Demo scan failed: ${String(err)}`); + } +} + +async function setupDemoScan(app: FastifyInstance): Promise { + const demoUrl = process.env.DEMO_SCAN_URL || config.demoScanUrl; + if (!demoUrl) return; + + // Run immediately if no demo scan exists yet + const existing = app.db.prepare( + "SELECT id FROM scans WHERE user_id IS NULL AND url = ? LIMIT 1", + ).get(demoUrl); + + if (!existing) { + app.log.info('No demo scan found — running initial demo scan'); + // Run in background so it doesn't block server startup + runDemoScan(app).catch(() => {}); + } + + // Schedule daily refresh at 00:30 UTC + app.log.info(`Demo scan scheduled daily for ${demoUrl}`); + cron.schedule('30 0 * * *', () => { + runDemoScan(app).catch(() => {}); + }); +} + async function schedulerPluginImpl(app: FastifyInstance): Promise { const enabled = process.env.SCHEDULE_ENABLED === 'true'; const urlsRaw = process.env.SCHEDULE_URLS ?? ''; @@ -12,6 +53,7 @@ async function schedulerPluginImpl(app: FastifyInstance): Promise { if (!enabled) { app.log.info('Scheduler plugin loaded but SCHEDULE_ENABLED is not true — skipping'); + await setupDemoScan(app); return; } @@ -22,11 +64,13 @@ async function schedulerPluginImpl(app: FastifyInstance): Promise { if (urls.length === 0) { app.log.warn('Scheduler enabled but SCHEDULE_URLS is empty — nothing to scan'); + await setupDemoScan(app); return; } if (!cron.validate(cronExpr)) { app.log.error(`Invalid cron expression: ${cronExpr}`); + await setupDemoScan(app); return; } @@ -89,6 +133,8 @@ async function schedulerPluginImpl(app: FastifyInstance): Promise { task.stop(); app.log.info('Scheduler stopped'); }); + + await setupDemoScan(app); } export const schedulerPlugin = fp(schedulerPluginImpl, { diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index 111c697..c7b344f 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -8,10 +8,10 @@ import swagger from '@fastify/swagger'; import swaggerUi from '@fastify/swagger-ui'; import fastifyStatic from '@fastify/static'; import type BetterSqlite3 from 'better-sqlite3'; +import type { FastifyInstance } from 'fastify'; import { config } from './config.js'; import { registerRoutes } from './routes.js'; import { initDb } from './db/index.js'; -import { authPlugin } from './auth/index.js'; import { schedulerPlugin } from './scheduler/index.js'; declare module 'fastify' { @@ -20,9 +20,18 @@ declare module 'fastify' { } } +export interface BuildServerOptions { + /** Additional Fastify plugins to register before routes (e.g. auth) */ + plugins?: Array<(app: FastifyInstance) => Promise>; + /** Additional route registrations to run after core routes */ + routes?: Array<(app: FastifyInstance) => Promise>; + /** Override the static files directory */ + staticDir?: string; +} + const __dirname = dirname(fileURLToPath(import.meta.url)); -export async function buildServer() { +export async function buildServer(opts?: BuildServerOptions) { const app = Fastify({ logger: true, }); @@ -33,28 +42,32 @@ export async function buildServer() { }); await app.register(rateLimit, { - max: 100, - timeWindow: '10 minutes', + max: parseInt(process.env.RATE_LIMIT_MAX || '', 10) || 100, + timeWindow: process.env.RATE_LIMIT_WINDOW || '10 minutes', }); - await app.register(swagger, { - openapi: { - info: { - title: 'recon-web API', - description: 'REST API for running web reconnaissance and analysis handlers against any URL.', - version: '1.0.0', + if (process.env.SWAGGER_ENABLED === 'true') { + await app.register(swagger, { + openapi: { + info: { + title: 'recon-web API', + description: 'REST API for running web reconnaissance and analysis handlers against any URL.', + version: '1.0.0', + }, }, - }, - }); + }); - await app.register(swaggerUi, { - routePrefix: '/docs', - }); + await app.register(swaggerUi, { + routePrefix: '/docs', + }); + } // ── Static files (serve built frontend if available) ──────────── - const staticDir = config.staticDir - ? resolve(config.staticDir) - : resolve(process.cwd(), 'packages', 'web', 'dist'); + const staticDir = opts?.staticDir + ? resolve(opts.staticDir) + : config.staticDir + ? resolve(config.staticDir) + : resolve(process.cwd(), 'packages', 'web', 'dist'); if (existsSync(staticDir)) { await app.register(fastifyStatic, { @@ -66,16 +79,27 @@ export async function buildServer() { } // ── Database ──────────────────────────────────────────────────── - const db = initDb(config.dbPath); + const db = initDb(process.env.DB_PATH || config.dbPath); app.decorate('db', db); app.addHook('onClose', () => db.close()); - // ── Auth ─────────────────────────────────────────────────────── - await app.register(authPlugin); + // ── External plugins (e.g. auth) ─────────────────────────────── + if (opts?.plugins) { + for (const plugin of opts.plugins) { + await plugin(app); + } + } // ── Routes ────────────────────────────────────────────────────── await registerRoutes(app); + // ── External routes (e.g. auth routes, admin routes) ────────── + if (opts?.routes) { + for (const route of opts.routes) { + await route(app); + } + } + // ── Scheduler ───────────────────────────────────────────────── await app.register(schedulerPlugin); diff --git a/packages/cli/package.json b/packages/cli/package.json index 011b296..34bac9b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -13,13 +13,17 @@ }, "dependencies": { "@recon-web/core": "*", + "bcryptjs": "^3.0.3", + "better-sqlite3": "^12.8.0", "commander": "^12.1.0", - "picocolors": "^1.1.0", - "ora": "^8.1.0" + "ora": "^8.1.0", + "picocolors": "^1.1.0" }, "devDependencies": { - "typescript": "^5.6.0", + "@types/bcryptjs": "^2.4.6", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.0.0", + "typescript": "^5.6.0", "vitest": "^2.1.0" } } diff --git a/packages/core/src/handlers/legacy-rank.ts b/packages/core/src/handlers/legacy-rank.ts index 640e3b2..9eae46d 100644 --- a/packages/core/src/handlers/legacy-rank.ts +++ b/packages/core/src/handlers/legacy-rank.ts @@ -3,6 +3,7 @@ import * as unzipper from 'unzipper'; import csv from 'csv-parser'; import fs from 'fs'; import type { AnalysisHandler, HandlerResult } from '../types.js'; +import { normalizeUrl } from '../utils/url.js'; const FILE_URL = 'https://s3-us-west-1.amazonaws.com/umbrella-static/top-1m.csv.zip'; const TEMP_FILE_PATH = '/tmp/top-1m.csv'; @@ -17,7 +18,8 @@ export interface LegacyRankResult { export const legacyRankHandler: AnalysisHandler = async (url, options) => { let domain: string; try { - domain = new URL(url).hostname; + const targetUrl = normalizeUrl(url); + domain = new URL(targetUrl).hostname; } catch { return { error: 'Invalid URL', errorCode: 'INVALID_URL', errorCategory: 'tool' }; } diff --git a/packages/core/src/handlers/quality.ts b/packages/core/src/handlers/quality.ts index 8fc59ed..f6115e5 100644 --- a/packages/core/src/handlers/quality.ts +++ b/packages/core/src/handlers/quality.ts @@ -16,10 +16,11 @@ export const qualityHandler: AnalysisHandler = async (url, option } try { + const fullUrl = url.startsWith('http') ? url : `https://${url}`; const endpoint = `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?` + - `url=${encodeURIComponent(url)}&category=PERFORMANCE&category=ACCESSIBILITY` + - `&category=BEST_PRACTICES&category=SEO&category=PWA&strategy=mobile` + + `url=${encodeURIComponent(fullUrl)}&category=PERFORMANCE&category=ACCESSIBILITY` + + `&category=BEST-PRACTICES&category=SEO&strategy=mobile` + `&key=${apiKey}`; const response = await withRetry( @@ -27,6 +28,15 @@ export const qualityHandler: AnalysisHandler = async (url, option ); return { data: response.data as QualityResult }; } catch (error) { + const axiosErr = error as any; + if (axiosErr.response) { + const status = axiosErr.response.status; + const detail = axiosErr.response.data?.error; + const msg = detail + ? `${detail.message}${detail.status ? ` [${detail.status}]` : ''}${detail.errors?.length ? ` — ${detail.errors.map((e: any) => e.reason).join(', ')}` : ''}` + : JSON.stringify(axiosErr.response.data); + return { error: `PageSpeed API ${status}: ${msg}` }; + } return { error: (error as Error).message }; } }; diff --git a/packages/core/src/handlers/screenshot.ts b/packages/core/src/handlers/screenshot.ts index ec1034a..1e2d158 100644 --- a/packages/core/src/handlers/screenshot.ts +++ b/packages/core/src/handlers/screenshot.ts @@ -134,7 +134,7 @@ export const screenshotHandler: AnalysisHandler = async (url, ); return { data: { image: base64Screenshot } }; } catch { - return { error: (error as Error).message }; + return { error: `Screenshot unavailable: ${(error as Error).message}`, errorCategory: 'tool' }; } } finally { if (browser !== null) { diff --git a/packages/core/src/handlers/wordpress.ts b/packages/core/src/handlers/wordpress.ts index 30c19a9..cd895b1 100644 --- a/packages/core/src/handlers/wordpress.ts +++ b/packages/core/src/handlers/wordpress.ts @@ -45,7 +45,6 @@ async function fetchText(url: string, timeout: number): Promise { }); if (!res.ok) return null; const ct = res.headers.get('content-type') ?? ''; - // Only read text-like responses if (!ct.includes('text') && !ct.includes('json') && !ct.includes('xml')) return null; return await res.text(); } catch { @@ -53,9 +52,14 @@ async function fetchText(url: string, timeout: number): Promise { } } -async function isAccessible(url: string, timeout: number): Promise { +/** + * Detect if the server has a catch-all (returns 200 for any path). + * Probe a random nonsensical path — if it returns 200, the server + * has a catch-all and we cannot trust status codes for file checks. + */ +async function hasCatchAll(base: string, timeout: number): Promise { try { - const res = await fetch(url, { + const res = await fetch(`${base}/wp-content/recon-web-probe-${Date.now()}.php`, { method: 'HEAD', redirect: 'follow', signal: AbortSignal.timeout(timeout), @@ -66,6 +70,85 @@ async function isAccessible(url: string, timeout: number): Promise { } } +/** + * Check if a WordPress-specific path is genuinely accessible. + * + * When the server has a catch-all (returns 200 for everything), + * we require content-based proof for every path. When there's no + * catch-all, a 200 status with correct content-type is sufficient. + */ +async function isAccessible( + url: string, + path: string, + timeout: number, + catchAll: boolean, +): Promise { + try { + const res = await fetch(url, { + redirect: 'follow', + signal: AbortSignal.timeout(timeout), + headers: { 'User-Agent': 'recon-web/1.0' }, + }); + if (!res.ok) return false; + + const ct = res.headers.get('content-type') ?? ''; + + if (path === 'wp-login.php') { + if (!ct.includes('text/html')) return false; + const body = await res.text(); + // Real wp-login has a specific login form + return /loginform|user_login|wp-submit/i.test(body); + } + + if (path === 'xmlrpc.php') { + const body = await res.text(); + // Real xmlrpc.php responds with "XML-RPC server accepts POST requests only" + // or an XML fault response + return /XML-RPC server|/i.test(body) || (ct.includes('xml') && !ct.includes('html')); + } + + if (path === 'wp-json/wp/v2/users') { + if (!ct.includes('json')) return false; + const body = await res.text(); + // Real WP users endpoint returns a JSON array with user objects containing "slug" + return body.startsWith('[') && /\"slug\"/.test(body); + } + + if (path === 'readme.html') { + const body = await res.text(); + // Real WP readme.html has a very specific structure + return /wordpress\.org|WordPress\s+[\d.]+/i.test(body) && /

(); const regex = /wp-content\/plugins\/([a-zA-Z0-9_-]+)(?:\/[^"'?]*?\?ver=([0-9.]+))?/g; @@ -95,15 +178,12 @@ function extractThemes(html: string): WpTheme[] { } function extractWpVersion(html: string): string | null { - // meta generator tag const genMatch = html.match(/]+name=["']generator["'][^>]+content=["']WordPress\s+([0-9.]+)["']/i); if (genMatch) return genMatch[1]; - // wp-emoji script version const emojiMatch = html.match(/wp-emoji-release\.min\.js\?ver=([0-9.]+)/); if (emojiMatch) return emojiMatch[1]; - // wp-includes version hints const includesMatch = html.match(/wp-includes\/[^"']+\?ver=([0-9.]+)/); if (includesMatch) return includesMatch[1]; @@ -115,32 +195,33 @@ export const wordpressHandler: AnalysisHandler = async (url, op const normalized = normalizeUrl(url); const base = normalized.replace(/\/+$/, ''); - // Fetch homepage HTML const html = await fetchText(base, timeout); if (!html) { - return { data: { isWordPress: false, version: null, plugins: [], themes: [], exposedFiles: [], issues: [] } }; + return { error: 'WordPress not detected on this site.', errorCategory: 'info' }; } - // Detect WordPress + // Detect WordPress from homepage content const hasWpContent = /wp-content\//i.test(html); const hasWpIncludes = /wp-includes\//i.test(html); - const hasGenerator = /WordPress/i.test(html); + const hasGenerator = /]+name=["']generator["'][^>]+WordPress/i.test(html); if (!hasWpContent && !hasWpIncludes && !hasGenerator) { - return { data: { isWordPress: false, version: null, plugins: [], themes: [], exposedFiles: [], issues: [] } }; + return { error: 'WordPress not detected on this site.', errorCategory: 'info' }; } - // Extract data const version = extractWpVersion(html); const plugins = extractPlugins(html); const themes = extractThemes(html); + // Detect catch-all before checking exposed files + const catchAll = await hasCatchAll(base, timeout); + // Check exposed files in parallel const exposedChecks = await Promise.all( EXPOSED_PATHS.map(async (path) => ({ path, - accessible: await isAccessible(`${base}/${path}`, timeout), + accessible: await isAccessible(`${base}/${path}`, path, timeout, catchAll), })), ); const exposedFiles = exposedChecks.filter((f) => f.accessible); diff --git a/packages/web/package.json b/packages/web/package.json index af6922a..0543122 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -3,6 +3,10 @@ "version": "1.0.0", "type": "module", "private": true, + "exports": { + ".": "./src/exports.ts", + "./styles": "./src/index.css" + }, "scripts": { "dev": "vite", "build": "tsc -b && vite build", diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 98fd52a..ee453fe 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -5,34 +5,31 @@ import Home from "@/pages/Home"; import Results from "@/pages/Results"; import HistoryResults from "@/pages/HistoryResults"; import History from "@/pages/History"; -import Login from "@/pages/Login"; +import Demo from "@/pages/Demo"; import Settings from "@/pages/Settings"; import Compare from "@/pages/Compare"; import NotFound from "@/pages/NotFound"; -import { AuthProvider } from "@/hooks/use-auth"; import { ThemeProvider } from "@/hooks/use-theme"; export default function App() { return ( - -
+
- ); } diff --git a/packages/web/src/components/layout/Footer.tsx b/packages/web/src/components/layout/Footer.tsx index 67d094a..3c0795d 100644 --- a/packages/web/src/components/layout/Footer.tsx +++ b/packages/web/src/components/layout/Footer.tsx @@ -16,9 +16,13 @@ export default function Footer() { {/* Links */} - - - + {/* User menu slot — pro injects auth dropdown here */} + {userMenu}
diff --git a/packages/web/src/components/results/ResultGrid.tsx b/packages/web/src/components/results/ResultGrid.tsx index 642753a..3b1e2f7 100644 --- a/packages/web/src/components/results/ResultGrid.tsx +++ b/packages/web/src/components/results/ResultGrid.tsx @@ -29,7 +29,13 @@ import type { function isEmptyData(data: unknown): boolean { if (data === null || data === undefined) return true; if (Array.isArray(data)) return data.length === 0; - if (typeof data === "object") return Object.keys(data as object).length === 0; + if (typeof data === "object") { + const obj = data as Record; + const keys = Object.keys(obj); + if (keys.length === 0) return true; + // Check if all values are null/undefined/empty — e.g. { image: null } + return keys.every((k) => obj[k] === null || obj[k] === undefined || obj[k] === ""); + } return false; } diff --git a/packages/web/src/components/results/renderers/FeaturesRenderer.tsx b/packages/web/src/components/results/renderers/FeaturesRenderer.tsx index e162759..adf36c2 100644 --- a/packages/web/src/components/results/renderers/FeaturesRenderer.tsx +++ b/packages/web/src/components/results/renderers/FeaturesRenderer.tsx @@ -1,56 +1,248 @@ +import { useState, useMemo } from "react"; +import { Layers, Eye, EyeOff } from "lucide-react"; import type { RendererProps } from "./types"; -import { Chip, SectionLabel } from "./primitives"; +import Modal from "../../ui/Modal"; + +interface Category { + name: string; + live: number; + dead: number; + latest: number; + oldest: number; +} + +interface Group { + name: string; + live: number; + dead: number; + latest: number; + oldest: number; + categories: Category[]; +} + +interface FeaturesData { + domain: string; + first: number; + last: number; + groups: Group[]; +} + +function parseData(data: unknown): FeaturesData | null { + if (!data || typeof data !== "object") return null; + const d = data as any; + if (d.groups && Array.isArray(d.groups)) return d as FeaturesData; + return null; +} + +function formatDate(ts?: number): string | null { + if (!ts) return null; + const d = new Date(ts); + if (isNaN(d.getTime())) return null; + return d.toLocaleDateString("en-US", { month: "short", year: "numeric" }); +} export function FeaturesRenderer({ data }: RendererProps) { - if (!data || typeof data !== "object") { + const [showModal, setShowModal] = useState(false); + const parsed = parseData(data); + + if (!parsed) { return

No features data available.

; } - // BuiltWith can return various shapes — handle arrays and objects - const entries = Array.isArray(data) - ? data - : Object.entries(data).flatMap(([category, items]) => - Array.isArray(items) - ? items.map((item: unknown) => ({ - category, - name: typeof item === "string" ? item : (item as Record)?.Name ?? (item as Record)?.name ?? String(item), - })) - : [{ category, name: String(items) }], - ); - - if (entries.length === 0) { + const { groups } = parsed; + if (groups.length === 0) { return

No features detected.

; } - // Group by category if available - const grouped = new Map(); - for (const entry of entries) { - const cat = typeof entry === "object" && entry !== null && "category" in entry - ? String((entry as Record).category) - : "Other"; - const name = typeof entry === "object" && entry !== null && "name" in entry - ? String((entry as Record).name) - : String(entry); - const list = grouped.get(cat) ?? []; - list.push(name); - grouped.set(cat, list); - } + // Sort groups: active first, then by total count + const sorted = [...groups].sort((a, b) => { + if (a.live !== b.live) return b.live - a.live; + return (b.live + b.dead) - (a.live + a.dead); + }); + + const totalTech = groups.reduce((sum, g) => sum + g.live + g.dead, 0); + const activeTech = groups.reduce((sum, g) => sum + g.live, 0); + const preview = sorted.slice(0, 6); + + return ( +
+ {/* Compact summary chips */} +
+ {preview.map((g) => ( + 0 + ? "border-success/25 bg-success/8 text-success" + : "border-border/40 bg-surface-light/20 text-muted" + }`} + > + {g.name} + {g.live + g.dead} + + ))} + {sorted.length > 6 && ( + +{sorted.length - 6} more + )} +
+ + {/* Stats + View all */} +

+ {totalTech} technologies · {activeTech} active · {groups.length} categories +

+ + + {/* Detail modal */} + {showModal && ( + setShowModal(false)} + /> + )} +
+ ); +} + +function FeaturesModal({ + parsed, + sorted, + totalTech, + activeTech, + onClose, +}: { + parsed: FeaturesData; + sorted: Group[]; + totalTech: number; + activeTech: number; + onClose: () => void; +}) { + const [showInactive, setShowInactive] = useState(false); + + const filteredGroups = useMemo( + () => (showInactive ? sorted : sorted.filter((g) => g.live > 0)), + [sorted, showInactive], + ); + + const activeGroups = sorted.filter((g) => g.live > 0).length; return ( -
- {[...grouped.entries()].map(([category, names]) => ( -
- {category} -
- {names.slice(0, 12).map((name, i) => ( - - ))} - {names.length > 12 && ( - +{names.length - 12} more - )} -
+ + {/* Stats bar + toggle */} +
+
+ + + + +
+ +
+ + {/* Groups grid */} +
+ {filteredGroups.map((g) => ( + + ))} +
+ + {!showInactive && sorted.length > activeGroups && ( +

+ {sorted.length - activeGroups} inactive categories hidden +

+ )} +
+ ); +} + +function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) { + return ( +
+

{value}

+

{label}

+
+ ); +} + +function GroupCard({ group, showInactive }: { group: Group; showInactive: boolean }) { + const first = formatDate(group.oldest); + const last = formatDate(group.latest); + const hasActive = group.live > 0; + + const visibleCats = showInactive + ? group.categories + : group.categories.filter((c) => c.live > 0); + const hiddenCount = group.categories.length - visibleCats.length; + + return ( +
+ {/* Header row */} +
+
+ {hasActive && } +

{group.name}

+
+
+ {group.live > 0 && {group.live} active} + {group.dead > 0 && {group.dead} past} +
+
+ + {/* Date range */} + {first && last && ( +

{first} — {last}

+ )} + + {/* Categories */} + {visibleCats.length > 0 ? ( +
+ {visibleCats.map((cat) => ( + 0 + ? "border border-accent/25 bg-accent/8 text-foreground" + : "border border-border/20 bg-surface-light/20 text-muted/70" + }`} + > + {cat.name} + + ))} + {!showInactive && hiddenCount > 0 && ( + +{hiddenCount} inactive + )}
- ))} + ) : group.categories.length > 0 && !showInactive ? ( +

{group.categories.length} inactive categories

+ ) : ( +

No subcategories

+ )}
); } diff --git a/packages/web/src/components/results/renderers/QualityRenderer.tsx b/packages/web/src/components/results/renderers/QualityRenderer.tsx index 7bb7126..1cb50ad 100644 --- a/packages/web/src/components/results/renderers/QualityRenderer.tsx +++ b/packages/web/src/components/results/renderers/QualityRenderer.tsx @@ -26,17 +26,15 @@ export function QualityRenderer({ data }: RendererProps) { } return ( -
+
{Object.values(cats).map((cat) => { const score = Math.round((cat.score ?? 0) * 100); return ( -
- +
+ {cat.title} + {score} - - {cat.title} -
); })} diff --git a/packages/web/src/exports.ts b/packages/web/src/exports.ts new file mode 100644 index 0000000..9d2c031 --- /dev/null +++ b/packages/web/src/exports.ts @@ -0,0 +1,31 @@ +// Layout +export { default as Nav } from './components/layout/Nav'; +export { default as Footer } from './components/layout/Footer'; + +// Pages +export { default as Home } from './pages/Home'; +export { default as Results } from './pages/Results'; +export { default as History } from './pages/History'; +export { default as HistoryResults } from './pages/HistoryResults'; +export { default as Compare } from './pages/Compare'; +export { default as Demo } from './pages/Demo'; +export { default as Settings } from './pages/Settings'; +export { default as NotFound } from './pages/NotFound'; + +// Results components +export { default as ResultCard } from './components/results/ResultCard'; +export { default as ResultGrid } from './components/results/ResultGrid'; +export { rendererRegistry } from './components/results/renderers'; + +// Hooks +export { useScanAll, useScanHandler, useHandlers, useHistoricalScan, useLiveScan } from './hooks/use-scan'; +export { ThemeProvider, useTheme } from './hooks/use-theme'; +export { usePreferences } from './hooks/use-preferences'; +export type { GroupBy, SortBy, StatusOrder } from './hooks/use-preferences'; + +// API client (scan/history functions only) +export * from './lib/api'; + +// UI primitives +export { default as InfoModal } from './components/results/InfoModal'; +export { default as RawDataModal } from './components/results/RawDataModal'; diff --git a/packages/web/src/hooks/use-auth.ts b/packages/web/src/hooks/use-auth.ts deleted file mode 100644 index 21f710b..0000000 --- a/packages/web/src/hooks/use-auth.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; -import { createElement } from 'react'; - -const STORAGE_KEY = 'recon-web-token'; - -interface AuthContextValue { - token: string | null; - setToken: (token: string) => void; - clearToken: () => void; - isAuthenticated: boolean; -} - -const AuthContext = createContext(null); - -function getStoredToken(): string | null { - try { - return localStorage.getItem(STORAGE_KEY); - } catch { - return null; - } -} - -export function AuthProvider({ children }: { children: ReactNode }) { - const [token, setTokenState] = useState(getStoredToken); - - const setToken = useCallback((t: string) => { - localStorage.setItem(STORAGE_KEY, t); - setTokenState(t); - }, []); - - const clearToken = useCallback(() => { - localStorage.removeItem(STORAGE_KEY); - setTokenState(null); - }, []); - - return createElement( - AuthContext.Provider, - { value: { token, setToken, clearToken, isAuthenticated: !!token } }, - children, - ); -} - -export function useAuth(): AuthContextValue { - const ctx = useContext(AuthContext); - if (!ctx) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return ctx; -} diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 6cde192..2bb3ccf 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -1,5 +1,5 @@ -@import "tailwindcss"; @import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600;700&family=DM+Sans:wght@400;500;600;700&display=swap"); +@import "tailwindcss"; @theme { --color-background: #06080d; diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index 6e9bd48..865ab49 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -85,32 +85,24 @@ export type ScanStreamEvent = | ScanCompletedEvent | ScanFailedEvent; -function getStoredToken(): string | null { - try { - return localStorage.getItem("recon-web-token"); - } catch { - return null; - } -} - function buildHeaders(extra?: HeadersInit): Headers { const headers = new Headers(extra); if (!headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } - - const token = getStoredToken(); - if (token) { - headers.set("Authorization", `Bearer ${token}`); - } - return headers; } async function request(path: string, init?: RequestInit): Promise { + const headers = buildHeaders(init?.headers); + // Don't send Content-Type on requests without a body (GET, DELETE) + const method = init?.method?.toUpperCase() ?? 'GET'; + if (!init?.body && (method === 'GET' || method === 'DELETE')) { + headers.delete("Content-Type"); + } const res = await fetch(`${BASE_URL}${path}`, { ...init, - headers: buildHeaders(init?.headers), + headers, }); if (!res.ok) { @@ -171,10 +163,12 @@ export interface HistoryScanListItem { handler_count: number; status: string; duration_ms: number | null; + user_id?: string | null; + result_summary?: { ok: number; issues: number; info: number; skipped: number }; } -export function getHistory(limit = 50, offset = 0): Promise { - return request(`/history?limit=${limit}&offset=${offset}`); +export function getHistory(limit = 50, offset = 0, search = ''): Promise { + return request(`/history?limit=${limit}&offset=${offset}${search ? `&search=${encodeURIComponent(search)}` : ''}`); } export function deleteHistoryScan(scanId: string): Promise<{ success: true }> { @@ -183,11 +177,7 @@ export function deleteHistoryScan(scanId: string): Promise<{ success: true }> { /** Download or open a scan report. Tries PDF first, falls back to HTML in new tab. */ export async function downloadReport(scanId: string): Promise { - const headers = new Headers(); - const token = getStoredToken(); - if (token) headers.set('Authorization', `Bearer ${token}`); - - const res = await fetch(`${BASE_URL}/history/${scanId}/report?format=pdf`, { headers }); + const res = await fetch(`${BASE_URL}/history/${scanId}/report?format=pdf`); if (!res.ok) { // Fall back to HTML in new tab @@ -278,3 +268,8 @@ export async function streamScan( } } } + +// ── Demo ──────────────────────────────────────────────────────────── +export function getDemoScan(): Promise { + return request('/demo'); +} diff --git a/packages/web/src/pages/Compare.tsx b/packages/web/src/pages/Compare.tsx index 5b6393e..35460ef 100644 --- a/packages/web/src/pages/Compare.tsx +++ b/packages/web/src/pages/Compare.tsx @@ -305,7 +305,7 @@ export default function Compare() { {diff.fields.map((f, i) => ( - {f.field} + {f.field}
                                     {stringify(f.oldValue)}
@@ -325,7 +325,7 @@ export default function Compare() {
                         
{diff.fields.map((f, i) => (
-

{f.field}

+

{f.field}

A: diff --git a/packages/web/src/pages/Demo.tsx b/packages/web/src/pages/Demo.tsx new file mode 100644 index 0000000..7cf870a --- /dev/null +++ b/packages/web/src/pages/Demo.tsx @@ -0,0 +1,81 @@ +import { useState, useEffect } from "react"; +import { Eye } from "lucide-react"; +import ResultGrid from "@/components/results/ResultGrid"; +import { getDemoScan, type HistoricalScan, type HandlerResultData } from "@/lib/api"; +import { useHandlers } from "@/hooks/use-scan"; + +export default function Demo() { + const [scan, setScan] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const handlers = useHandlers(); + + useEffect(() => { + getDemoScan() + .then((data) => { + setScan(data); + setLoading(false); + }) + .catch(() => { + setError(true); + setLoading(false); + }); + }, []); + + if (loading) { + return ( +
+ Loading demo scan... +
+ ); + } + + if (error || !scan) { + return ( +
+
+ +
+

+ No Demo Available +

+

+ No demo scan has been configured. Ask your administrator to set one up. +

+
+ ); + } + + // Convert results array to Record keyed by handler + const results: Record = {}; + for (const r of scan.results) { + results[r.handler] = r.result; + } + + return ( +
+ {/* Header */} +
+
+ +

+ Demo Scan +

+
+
+ {scan.url} + · + {new Date(scan.created_at).toLocaleDateString()} +
+
+ + +
+ ); +} diff --git a/packages/web/src/pages/History.tsx b/packages/web/src/pages/History.tsx index 7f4e52c..5083026 100644 --- a/packages/web/src/pages/History.tsx +++ b/packages/web/src/pages/History.tsx @@ -1,47 +1,72 @@ -import { useState } from "react"; +import { useState, useCallback, useEffect } from "react"; import { Link, useNavigate } from "react-router-dom"; -import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Clock, Trash2, ExternalLink, Loader2, AlertTriangle, GitCompareArrows } from "lucide-react"; -import { deleteHistoryScan, getHistory } from "@/lib/api"; +import { Clock, Trash2, ExternalLink, Loader2, AlertTriangle, GitCompareArrows, Search, ChevronLeft, ChevronRight } from "lucide-react"; +import { deleteHistoryScan, getHistory, type HistoryScanListItem } from "@/lib/api"; +import Modal from "@/components/ui/Modal"; const PAGE_SIZE = 20; export default function History() { - const queryClient = useQueryClient(); const navigate = useNavigate(); - const [selected, setSelected] = useState>(new Set()); - - const { - data, - isLoading, - error, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - } = useInfiniteQuery({ - queryKey: ["history"], - queryFn: ({ pageParam = 0 }) => getHistory(PAGE_SIZE, pageParam), - getNextPageParam: (lastPage, allPages) => - lastPage.length === PAGE_SIZE ? allPages.flat().length : undefined, - initialPageParam: 0, - staleTime: 30_000, - }); - - const scans = data?.pages.flat() ?? []; - - const deleteMutation = useMutation({ - mutationFn: deleteHistoryScan, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["history"] }), - }); - - const toggleSelect = (id: string) => { - setSelected((prev) => { + + // Data + const [scans, setScans] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Search + const [search, setSearch] = useState(""); + + // Compare selection (max 2) + const [compareSelected, setCompareSelected] = useState>(new Set()); + + // Delete mode + const [deleteMode, setDeleteMode] = useState(false); + const [deleteSelected, setDeleteSelected] = useState>(new Set()); + const [deleting, setDeleting] = useState(false); + + // Confirm dialog + const [confirmDialog, setConfirmDialog] = useState<{ + title: string; + message: string; + onConfirm: () => void; + } | null>(null); + + // Fetch + const fetchScans = useCallback((p: number, q: string) => { + setLoading(true); + setError(null); + getHistory(PAGE_SIZE, p * PAGE_SIZE, q) + .then((data) => { + setScans(data); + setTotal(data.length < PAGE_SIZE ? p * PAGE_SIZE + data.length : (p + 2) * PAGE_SIZE); + setLoading(false); + }) + .catch((err) => { + setError(err instanceof Error ? err.message : "Failed to load history"); + setLoading(false); + }); + }, []); + + // Refetch on search/page change + useEffect(() => { + fetchScans(page, search); + }, [page, search, fetchScans]); + + // Filtered scans + // Search is server-side, scans are already filtered + const filteredScans = scans; + + // Compare + const toggleCompare = (id: string) => { + setCompareSelected((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { if (next.size >= 2) { - // Replace oldest selection const first = next.values().next().value!; next.delete(first); } @@ -51,74 +76,226 @@ export default function History() { }); }; - const canCompare = selected.size === 2; - const handleCompare = () => { - const [id1, id2] = Array.from(selected); + const [id1, id2] = Array.from(compareSelected); navigate(`/compare/${id1}/${id2}`); }; + // Delete + const toggleDeleteSelect = (id: string) => { + setDeleteSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const selectAllForDelete = () => { + if (deleteSelected.size === filteredScans.length) { + setDeleteSelected(new Set()); + } else { + setDeleteSelected(new Set(filteredScans.map((s) => s.id))); + } + }; + + async function executeDelete(ids: string[]) { + setDeleting(true); + try { + for (const id of ids) { + await deleteHistoryScan(id); + } + setDeleteSelected(new Set()); + setDeleteMode(false); + fetchScans(page, search); + } catch (err) { + alert(err instanceof Error ? err.message : "Delete failed"); + } finally { + setDeleting(false); + } + } + + function handleDeleteSelected() { + const count = deleteSelected.size; + if (count === 0) return; + setConfirmDialog({ + title: "Delete scans", + message: `Are you sure you want to delete ${count} scan${count > 1 ? "s" : ""}? This action cannot be undone.`, + onConfirm: () => { + setConfirmDialog(null); + executeDelete(Array.from(deleteSelected)); + }, + }); + } + + function handleDeleteAll() { + setConfirmDialog({ + title: "Delete all scans", + message: `Are you sure you want to delete all ${filteredScans.length} visible scans? This action cannot be undone.`, + onConfirm: () => { + setConfirmDialog(null); + executeDelete(filteredScans.map((s) => s.id)); + }, + }); + } + + function cancelDeleteMode() { + setDeleteMode(false); + setDeleteSelected(new Set()); + } + + const totalPages = Math.ceil(total / PAGE_SIZE); + return (
-
-

Scan History

-

- Past scans are stored locally in SQLite. Select two scans to compare. -

+ {/* Header */} +
+
+

+ Scan History +

+

+ {deleteMode + ? "Select scans to delete" + : "Select two scans to compare"} +

+
+ + {/* Action buttons */} +
+ {!deleteMode ? ( + + ) : ( + <> + + + + + + )} +
+
+ + {/* Search */} +
+ + { setSearch(e.target.value); setPage(0); }} + className="w-full rounded-xl border border-border/50 bg-surface pl-10 pr-4 py-2.5 text-sm text-foreground placeholder:text-muted focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/30 transition-colors" + />
- {isLoading && ( + {/* Loading */} + {loading && (
)} + {/* Error */} {error && (
-

- {error instanceof Error ? error.message : "Failed to load history"} -

+

{error}

)} - {!isLoading && scans.length === 0 && ( + {/* Empty */} + {!loading && scans.length === 0 && !error && (

No scans yet. Run a scan to see it here.

)} - {scans.length > 0 && ( + {/* Scan list */} + {filteredScans.length > 0 && (
- {scans.map((scan) => ( + {filteredScans.map((scan) => (
- + {/* Checkbox */} + {deleteMode ? ( + + ) : ( + + )} + {/* Content */}
-
+ {/* Actions (only in normal mode) */} + {!deleteMode && ( - -
+ )}
))} - {hasNextPage && ( -
- + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page + 1} +

+
+ + +
)}
)} + {/* Search empty state */} + {!loading && scans.length > 0 && filteredScans.length === 0 && ( +
+ No scans match "{search}" +
+ )} + {/* Sticky compare bar */} - {canCompare && ( + {!deleteMode && compareSelected.size === 2 && (
)} + + {/* Confirm dialog */} + {confirmDialog && ( + setConfirmDialog(null)} maxWidth="max-w-sm"> +

{confirmDialog.message}

+
+ + +
+
+ )}
); } diff --git a/packages/web/src/pages/Home.tsx b/packages/web/src/pages/Home.tsx index ed0d1a2..963b229 100644 --- a/packages/web/src/pages/Home.tsx +++ b/packages/web/src/pages/Home.tsx @@ -10,8 +10,6 @@ import { FileText, Database, Gauge, - BookOpen, - Github, Terminal, Copy, Check, @@ -93,58 +91,34 @@ export default function Home() {
{/* Search form */} -
-
- - { - setUrl(e.target.value); - if (error) setError(""); - }} - placeholder="example.com" - className="w-full rounded-2xl border border-border bg-surface py-5 pl-14 pr-28 text-base text-foreground placeholder:text-muted/50 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all" - autoFocus - /> - -
- {error &&

{error}

} -
- - {/* Quick links */} -
- - - API Docs - - | - - - GitHub - +
+
+
+ + { + setUrl(e.target.value); + if (error) setError(""); + }} + placeholder="example.com" + className="w-full rounded-2xl border border-border bg-surface py-5 pl-14 pr-28 text-base text-foreground placeholder:text-muted/50 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all" + autoFocus + /> + +
+ {error &&

{error}

} +
+
+ {/* Feature highlights */}
-
- -

Authentication

-

Enter your API token to continue

-
- -
- { - setTokenInput(e.target.value); - if (error) setError(""); - }} - placeholder="Bearer token..." - className="w-full rounded-xl border border-border bg-surface py-3 px-4 text-foreground placeholder:text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors mb-3" - autoFocus - /> - {error &&

{error}

} - -
-
- ); -} diff --git a/packages/web/src/pages/Results.tsx b/packages/web/src/pages/Results.tsx index bb9b8d6..d7ff770 100644 --- a/packages/web/src/pages/Results.tsx +++ b/packages/web/src/pages/Results.tsx @@ -60,6 +60,42 @@ export default function Results() { const completed = scan.progress.completed; const pct = total > 0 ? Math.round((completed / total) * 100) : 0; + // Rate limit (429) — show a clean, human-friendly page + const errorMsg = scan.error instanceof Error ? scan.error.message : ""; + const isRateLimited = scan.status === "failed" && errorMsg.includes("429"); + + if (isRateLimited) { + return ( +
+ + + Back + +
+
+ +
+

+ Daily scan limit reached +

+

+ You've used all your scans for today. + The limit resets at midnight UTC. +

+

+ {decodedUrl} — HTTP 429 +

+
+
+ ); + } + // If stale, treat as failed if (staleTimeout && !scan.scanId) { return ( diff --git a/packages/web/src/pages/Settings.tsx b/packages/web/src/pages/Settings.tsx index fe6463c..fd103b0 100644 --- a/packages/web/src/pages/Settings.tsx +++ b/packages/web/src/pages/Settings.tsx @@ -44,11 +44,6 @@ const apiKeysData: ApiKeyItem[] = [ { label: "AbuseIPDB", envVar: "ABUSEIPDB_API_KEY", description: "IP reputation and abuse reports", freeLimit: "1,000 req/day", signupUrl: "https://www.abuseipdb.com/pricing", infoDate: "2025-04" }, ]; -const authConfig: ConfigItem[] = [ - { label: "Auth Enabled", envVar: "AUTH_ENABLED", description: "Whether API authentication is required" }, - { label: "Auth Token", envVar: "AUTH_TOKEN", description: "Bearer token for API access" }, -]; - const GROUP_OPTIONS: { value: GroupBy; label: string; description: string }[] = [ { value: "none", label: "No grouping", description: "Cards displayed in flat masonry layout" }, { value: "category", label: "By Category", description: "Group cards by Security, DNS, Network, etc." }, @@ -198,8 +193,6 @@ export default function Settings() {

- - {/* API Keys — rich display */}

API Keys (optional)

From 956a0239f205bc4457bc50f3331d4fdac6f6e018 Mon Sep 17 00:00:00 2001 From: Bruno Pavelja Date: Thu, 9 Apr 2026 08:08:09 +0200 Subject: [PATCH 4/6] update vite and basic-ftp to patch security vulnerabilities --- docs/package-lock.json | 16 +++++----------- package-lock.json | 6 +++--- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 1feadb1..3ca78e0 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -1,13 +1,12 @@ { - "name": "docs", + "name": "@recon-web/docs", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "docs", + "name": "@recon-web/docs", "version": "1.0.0", - "license": "ISC", "dependencies": { "@astrojs/starlight": "^0.38.2", "astro": "^6.1.3", @@ -1966,7 +1965,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2053,7 +2051,6 @@ "resolved": "https://registry.npmjs.org/astro/-/astro-6.1.3.tgz", "integrity": "sha512-FUKbBYOdYYrRNZwDd9I5CVSfR6Nj9aZeNzcjcvh1FgHwR0uXawkYFR3HiGxmdmAB2m8fs0iIkDdsiUfwGeO8qA==", "license": "MIT", - "peer": true, "dependencies": { "@astrojs/compiler": "^3.0.1", "@astrojs/internal-helpers": "0.8.0", @@ -4937,7 +4934,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5390,7 +5386,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6064,11 +6059,10 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package-lock.json b/package-lock.json index ad88d27..656b09d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3636,9 +3636,9 @@ } }, "node_modules/basic-ftp": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", - "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.1.tgz", + "integrity": "sha512-0yaL8JdxTknKDILitVpfYfV2Ob6yb3udX/hK97M7I3jOeznBNxQPtVvTUtnhUkyHlxFWyr5Lvknmgzoc7jf+1Q==", "license": "MIT", "engines": { "node": ">=10.0.0" From c9ab6864dbc1449615670417bb70eaad2eda32aa Mon Sep 17 00:00:00 2001 From: Bruno Pavelja Date: Thu, 9 Apr 2026 08:19:31 +0200 Subject: [PATCH 5/6] add gitleaks pre-commit hook via husky for secret scanning --- .gitleaksignore | 7 +++++++ .husky/pre-commit | 3 +++ package-lock.json | 47 ++++++++++++++++++++++++++++++++++++----------- package.json | 6 +++++- 4 files changed, 51 insertions(+), 12 deletions(-) create mode 100644 .gitleaksignore create mode 100644 .husky/pre-commit diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..332a72b --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,7 @@ +3ba15210b191e8a325df35f9256b5c5c9d89ac05:@internal/AUTH-SETUP.md:curl-auth-header:85 +3ba15210b191e8a325df35f9256b5c5c9d89ac05:@internal/AUTH-SETUP.md:curl-auth-header:384 +abf03987e2574d92614c2af2717e69908552fff0:packages/api/src/auth/auth.test.ts:generic-api-key:60 +abf03987e2574d92614c2af2717e69908552fff0:packages/api/src/auth/auth.test.ts:generic-api-key:159 +346dd51d2d32252ab1710b30cab1000fb77e7728:docs/src/content/docs/guides/rest-api.mdx:curl-auth-header:122 +7bd2fa7b6e03b148493382b5b2ba84ab95eb9420:docs/src/content/docs/guides/rest-api.mdx:curl-auth-header:122 +a8393590d20499515efeb13d2b5bcb0b80be1b7a:docs/superpowers/plans/2026-04-07-user-authentication.md:generic-api-key:3143 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..b196a89 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,3 @@ +if command -v gitleaks &>/dev/null; then + gitleaks git --pre-commit --staged +fi diff --git a/package-lock.json b/package-lock.json index 656b09d..16ce241 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,9 @@ "workspaces": [ "packages/*" ], + "devDependencies": { + "husky": "^9.1.7" + }, "engines": { "node": ">=24" } @@ -107,6 +110,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -606,6 +610,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -654,6 +659,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -2921,8 +2927,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3126,6 +3131,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3137,6 +3143,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3405,7 +3412,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -3419,7 +3425,6 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -3762,6 +3767,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4580,15 +4586,15 @@ "version": "0.0.1367902", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz", "integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -4810,6 +4816,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -5681,6 +5688,22 @@ "node": ">= 14" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -6432,7 +6455,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7216,7 +7238,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7416,6 +7437,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7428,6 +7450,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -7441,8 +7464,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-masonry-css": { "version": "1.0.16", @@ -8701,6 +8723,7 @@ "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.1", @@ -8823,6 +8846,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -9510,6 +9534,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "bin": { "workerd": "bin/workerd" }, diff --git a/package.json b/package.json index f0a8c54..168ad41 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,10 @@ "test": "npm run test --workspaces --if-present", "lint": "npm run lint --workspaces --if-present", "typecheck": "npm run typecheck --workspaces --if-present", - "build:static": "npm run build -w @recon-web/web && npm run build -w @recon-web/static" + "build:static": "npm run build -w @recon-web/web && npm run build -w @recon-web/static", + "prepare": "husky" + }, + "devDependencies": { + "husky": "^9.1.7" } } From ef2867f8688a7aeaa2f83e436e3a5a33ac73928a Mon Sep 17 00:00:00 2001 From: Bruno Pavelja Date: Thu, 9 Apr 2026 08:29:52 +0200 Subject: [PATCH 6/6] skip husky in production builds --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 168ad41..8911091 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "lint": "npm run lint --workspaces --if-present", "typecheck": "npm run typecheck --workspaces --if-present", "build:static": "npm run build -w @recon-web/web && npm run build -w @recon-web/static", - "prepare": "husky" + "prepare": "husky || true" }, "devDependencies": { "husky": "^9.1.7"