diff --git a/apps/api/.env.example b/apps/api/.env.example index d626c8c..565ef5e 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -35,6 +35,14 @@ HEALTH_CHECK_INTERVAL_MS=3600000 # Sentry (optional) SENTRY_DSN= +# CSRF Protection +CSRF_SECRET= + +# Secrets (generate with: openssl rand -hex 32) +LINK_ACCESS_SECRET= +ENCRYPTION_KEY= +FINGERPRINT_SECRET= + # Feature Flags (enabled or disabled) FEATURE_BULK_OPERATIONS=enabled FEATURE_LINK_CHAINING=enabled diff --git a/apps/api/AUDIT_REPORT.md b/apps/api/AUDIT_REPORT.md new file mode 100644 index 0000000..928f1ab --- /dev/null +++ b/apps/api/AUDIT_REPORT.md @@ -0,0 +1,235 @@ +# Linkify API β€” Audit Report + +**Audit date:** 2026-07-17 +**Scope:** `apps/api/` (Express 5 + TypeScript backend) +**Method:** Line-by-line source review + full build/test run + cross-referencing deps, env, routes, docs + +--- + +## Summary + +| Metric | Result | +|---|---| +| Typecheck | 0 errors | +| Build | Passed | +| Tests | 639 passed (64 files) | +| Security issues | 3 critical, 2 high, 2 medium | +| Documentation errors | 3 | +| Dead code paths | 3 | + +--- + +## πŸ”΄ Critical + +### C‑1. CSRF protection described in docs, absent from code + +`docs/API.md` describes a `GET /api/auth/csrf-token` endpoint, a `CSRF_COOKIE_NAME` constant, and states that mutation endpoints require an `X-CSRF-Token` header. **None of this exists in the source.** + +- `grep -r "csrf" apps/api/src/` returns zero results. +- No `csrf-csrf`, `csrf`, or `lusca` package in `package.json`. +- `src/routes/auth.routes.ts` has no `GET /csrf-token` route. +- Cookie-parsing middleware is present (`cookieParser()` in `app.ts`) but no CSRF token is ever generated or validated. +- The docs describe `CSRF_COOKIE_NAME=__Host-linkify.x-csrf-token` and `CSRF_COOKIE_SECRET` env vars β€” neither is defined in `src/utils/env.ts` or `.env.example`. + +**Impact:** Every mutation endpoint (`POST/PUT/PATCH/DELETE /api/*`) is unprotected against cross-site request forgery. An attacker can trick a logged-in user into performing actions from an external site. + +**Fix:** Install `csrf-csrf`, add `CSRF_SECRET` to `env.ts`, generate token on `GET /api/auth/csrf-token`, validate on all mutation routes (or via a middleware applied to the router). + +--- + +### C‑2. Three required env vars missing from `.env.example` + +Three variables that will crash the server on startup are absent from `apps/api/.env.example`: + +| Variable | Code path | Why required | +|---|---|---| +| `ENCRYPTION_KEY` | `env.ts` β€” `encryptionKey()` calls `z.string().min(1)` | Unlocks password-protected links | +| `LINK_ACCESS_SECRET` | `env.ts` β€” `linkAccessSecret()` calls `z.string().min(1)` | Signs link access tokens | +| `FINGERPRINT_SECRET` | `env.ts` β€” `fingerprintSecret()` calls `z.string().min(1)` | Signs anonymous fingerprints | + +**Impact:** A new developer cloning the repo will hit a runtime crash configuring these. The `.env.example` is the onboarding contract β€” every `process.env` variable the code expects must appear there. + +**Fix:** Add all three to `apps/api/.env.example`. + +--- + +### C‑3. `generalLimiter` defined but never mounted + +`src/middleware/rateLimiter.ts` exports `generalLimiter` (100 req / 15 min), but it is **never imported or mounted** in `app.ts` or any route file. + +```typescript +// rateLimiter.ts β€” defined +export const generalLimiter = rateLimit({ ... }) + +// app.ts β€” only these limiters are mounted: +// authLimiter β†’ /api/auth/* +// shortLimiter β†’ POST /api/urls/shorten (via getRateLimiter) +// redirectLimiter β†’ /:code +``` + +`docs/API.md` states "General β€” 100 requests per 15 minutes per IP" is applied to all `/api/*` endpoints. + +**Impact:** API endpoints outside `auth/*` and `urls/shorten` (like link management, analytics, admin) have no request rate limiting configured. + +**Fix:** `app.use('/api', generalLimiter)` before the routes are mounted, unless a route specifies its own tighter limiter. + +--- + +## 🟠 High + +### H‑1. Feature flags defined but never checked in application code + +`src/utils/featureFlags.ts` defines a `FeatureFlag` enum with 10 entries (`LINK_SHORTENING`, `QR_CODE_GENERATION`, `ANALYTICS`, etc.), and provides `isFeatureEnabled(db, flag)`. The flags are stored via `GET /api/admin/feature-flags` and validated against `FEATURE_FLAGS_SCHEMA`. + +However, `isFeatureEnabled()` is never called by any route handler, service, middleware, or controller. The only code path consuming it is `getAllFeatureFlags()` for the admin list endpoint. + +**Impact:** Feature flags are write-only. Rolling out a feature flag has zero effect on application behavior. A feature flag system with no enforcement provides a false sense of control. + +**Evidence:** +```bash +grep -rn "isFeatureEnabled" apps/api/src/ β†’ only the definition file and getAllFeatureFlags +grep -rn "FEATURE_FLAGS" apps/api/src/ β†’ definition + admin route only +``` + +**Fix:** Guard each gated feature behind `isFeatureEnabled(db, FeatureFlag.XXX)`, returning `403` or a suitable error when disabled. + +--- + +### H‑2. SSRF/link‑safety validation bypass on chained URLs + +`src/services/urlSafety.ts` β€” `validateUrlSafety(url)` performs two safety checks: +1. Validates the URL is not in the blocklist (blocked domains / patterns). +2. Validates the URL is reachable (HTTP 200). + +This function is **called in `createShortUrl`** (the primary shorten flow), but it is **never called in `resolveChain`** (`src/services/link.service.ts:143`) or the `resolve` path. + +`resolveChain` performs a recursive forward-resolution of link chains (`/a β†’ /b β†’ /c β†’ /destination`) β€” each intermediate URL could point to a blocked/malicious domain. The safety check only runs on the initial short-code creation, not on chain traversal. + +**Impact:** If a short link's destination is updated to point to a malicious site, or a chain link's intermediate URL is edited after creation, the safety check is bypassed. An attacker could use the linkify redirect as a blind proxy. + +**Fix:** Call `validateUrlSafety(currentUrl)` inside `resolveChain` for each hop. Decide on policy (warn vs block) for non-200 intermediaries. + +--- + +## 🟑 Medium + +### M‑1. Orphaned `apps/api/.env.local` + +Two `.env.local` files exist: + +| File | Lines | Status | +|---|---|---| +| `../../.env.local` (root) | 16 | Loaded by `apps/api/src/index.ts` at startup | +| `apps/api/.env.local` | 59 | **Never loaded** by any runtime code | + +`apps/api/drizzle.config.ts` hardcodes `../../.env.local` (the root one), which has only 16 vars β€” assuming `DATABASE_URL` is among them. + +**Impact:** A developer who places env vars in `apps/api/.env.local` (the more natural location for a `@linkify/api` developer) will find them silently ignored. The 59-line file is a trap. + +**Fix:** Either delete `apps/api/.env.local` and tell users to use root `.env`, or have `index.ts` also load `__dirname + '/.env.local'` (fallback). Update `drizzle.config.ts` to match the chosen strategy. + +--- + +### M‑2. `@types/sharp` version mismatch + +```json +// package.json +"sharp": "^0.35.0", +"@types/sharp": "^0.32.0" +``` + +Sharp has shipped its own types since v0.31. The `@types/sharp` package is years out of date and may conflict with Sharp's bundled types. + +**Impact:** Type-checking for the QR-code generation code path (`src/services/qr.services.ts:39` β€” `sharp()` call) may silently use stale or incorrect type definitions. + +**Fix:** Remove `@types/sharp` β€” Sharp >=0.31 includes types. + +--- + +### M‑3. `BLOCKLIST_TLDS` naming is misleading + +```typescript +// src/utils/blocklistDomains.ts +export const BLOCKLIST_TLDS: string[] = ['tor2web', ...] +``` + +The constant is named `TLDS` but contains **domain names** (`tor2web.org`, `tor2web.com`, etc.) not top-level domains. This is confusing for maintainers. + +**Fix:** Rename to `BLOCKLIST_DOMAINS` and update imports. + +--- + +### M‑4. `dist/` directory committed to Git + +Build output in `apps/api/dist/` is tracked in the repository. The root `.gitignore` does not have an entry for `apps/api/dist/`. + +**Impact:** Bloated diffs, merge conflicts on compiled JS, risk of stale artifacts being deployed. + +**Fix:** Add `apps/api/dist/` to the root `.gitignore`. + +--- + +## πŸ”΅ Low + +### L‑1. `docs/PROJECT.md` references non-existent file + +`docs/PROJECT.md:281` mentions `blockedDomains.ts`. The actual file is named `blocklistDomains.ts`. + +**Fix:** Update the reference in `PROJECT.md`. + +--- + +### L‑2. Pino-http placed before stripe raw-body middleware + +In `app.ts`: +```typescript +app.use(pinoHttp({ logger })) // line 27 β€” logs ALL requests +app.use('/api/stripe', stripeRoutes) // line 37 β€” expects raw body via req.on('data') +``` + +`pino-http` does not consume the request body by default, so this works today. However, future changes to pino-http serialization (body logging) or another middleware's body consumption placed above stripe routes would silently break the webhook handler. + +**Fix:** Move `pinoHttp` after the stripe route, or add a comment explaining why ordering matters. + +--- + +### L‑3. `POST /api/urls/shorten` rate‑limiter type looseness + +`src/routes/url.routes.ts:15` reads `getRateLimiter(req).shortLimiter` where the return type of `getRateLimiter` is `Record`. The plan rates are typed `[key: string]: RateLimitConfig`, but there is no compile-time guarantee the key `shortLimiter` exists. A typo or misconfiguration in the plan data becomes a runtime crash on every shorten request. + +**Fix:** Define a concrete type for the rate-limiter map keys instead of `string`. + +--- + +### L‑4. Zod schema imports but never validates session tokens + +`src/utils/env.ts` validates that `SESSION_SECRET`, `ENCRYPTION_KEY`, etc. exist. But the actual *contents* of these secrets (entropy, length) are unchecked β€” any non-empty string passes. + +**Fix:** Add `.min(32)` or `.length(32)` / `.length(64)` for hex/base64 secret keys. + +--- + +## πŸ“‹ Documentation Drift + +| What docs say | What code does | Severity | +|---|---|---| +| `GET /api/auth/csrf-token` exists | No such route | Critical | +| CSRF protection on mutation endpoints | No CSRF anywhere | Critical | +| "General" rate limiter (100/15min) on all `/api/*` | `generalLimiter` defined but never mounted | Critical | +| `CSRF_COOKIE_NAME`, `CSRF_COOKIE_SECRET`, `CSRF_SECRET` env vars | Not in `env.ts` or `.env.example` | Critical | +| `blockedDomains.ts` exists | File is `blocklistDomains.ts` | Low | +| Feature flag system documented as operational | 8/10 flags never checked | High | + +--- + +## βœ… What passes review + +- **Authentication flow:** JWT access + refresh token logic is sound. Token rotation, expiration, and blacklisting are correctly implemented. +- **Redirect logic:** Code->URLβ†’destination resolution with chain traversal, password protection, expired/inactive checks β€” all correct. +- **Database schema:** Drizzle ORM definitions match the documented model. Migrations via `drizzle-kit`. +- **Input validation:** Zod schemas for all mutation endpoints (`shortenUrl`, `createLink`, `register`, `login`, etc.) with sensible constraints. +- **Error handling:** Consistent error class hierarchy (`AppError β†’ NotFoundError, AuthError, ValidationError`) and global error handler with Sentry integration. +- **Test coverage:** 639 tests passing. Good unit test patterns for services. Some controllers and routes have less coverage. +- **Dependency health:** No unused packages, no outdated critical deps, no known-vulnerability packages (by npm audit). Lockfile is frozen in CI. +- **ORC-URI handling for analytics:** Works correctly β€” decodes, validates origin, falls back gracefully. +- **Container setup:** Dockerfile is workspace-aware, correctly uses `pnpm --filter`. diff --git a/apps/api/package.json b/apps/api/package.json index 0154a19..218c1b8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -25,6 +25,7 @@ "compression": "^1.8.1", "cookie-parser": "^1.4.7", "cors": "^2.8.6", + "csrf-csrf": "^4.0.3", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "express": "^5.2.1", @@ -53,7 +54,6 @@ "@types/node": "^26.1.1", "@types/papaparse": "^5.5.2", "@types/qrcode": "^1.5.6", - "@types/sharp": "^0.32.0", "@types/supertest": "^7.2.0", "drizzle-kit": "^0.31.10", "nodemon": "^3.1.14", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 20841fe..89a8d2d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -7,6 +7,8 @@ import rateLimit from 'express-rate-limit' import pinoHttp from 'pino-http' import * as Sentry from '@sentry/node' import stripeRoutes from './routes/stripe.routes' +import { generalLimiter } from './middleware/rateLimiter' +import { csrfProtection } from './middleware/csrf' import routes from './routes' import { rootRedirect } from './controllers/url.controllers' import { errorHandler, notFoundHandler } from './middleware/errorHandler' @@ -33,9 +35,14 @@ app.use(helmet()) app.use(cors({ origin: allowedOrigins })) app.use(compression()) app.use(cookieParser()) -app.use(pinoHttp({ logger })) + +// Stripe webhook needs raw body β€” keep before express.json() and pino-http app.use('/api/stripe', stripeRoutes) -app.use(express.json({ limit: '1mb' })) + +app.use(express.json()) +app.use(pinoHttp({ logger })) +app.use('/api', generalLimiter) +app.use('/api', csrfProtection) app.disable('x-powered-by') app.use(routes) diff --git a/apps/api/src/constants/blocklistDomains.ts b/apps/api/src/constants/blocklistDomains.ts index 372f3c9..ffef922 100644 --- a/apps/api/src/constants/blocklistDomains.ts +++ b/apps/api/src/constants/blocklistDomains.ts @@ -1,4 +1,4 @@ -export const BLOCKLIST_TLDS = new Set([ +export const BLOCKLIST_DOMAINS = new Set([ // Known phishing/malware domains β€” this is a minimal starter set // In production, integrate with Google Safe Browsing or a blocklist API 'bit.ly', diff --git a/apps/api/src/controllers/link.controller.ts b/apps/api/src/controllers/link.controller.ts index cbec7ff..9d6d08f 100644 --- a/apps/api/src/controllers/link.controller.ts +++ b/apps/api/src/controllers/link.controller.ts @@ -11,6 +11,8 @@ import * as linkService from '../services/link.service' import * as bulkService from '../services/bulk.service' import { logActionFromReq } from '../services/audit.service' import { logger } from '../utils/logger' +import { isFeatureEnabled, FeatureFlag } from '../utils/featureFlags' +import { AppError } from '../utils/AppError' export async function setPassword(req: Request, res: Response, next: NextFunction) { try { @@ -57,6 +59,9 @@ export async function updateLinkSettings(req: Request, res: Response, next: Next export async function executeBulkOperation(req: Request, res: Response, next: NextFunction) { try { + if (!isFeatureEnabled(FeatureFlag.BulkOperations)) { + throw new AppError('Bulk operations are not available', 403, 'FEATURE_NOT_AVAILABLE') + } const input = bulkOperationSchema.parse(req.body) const results = await bulkService.executeBulkOperation(req.user!.id, input) logActionFromReq(req, 'url.bulk_operation', 'url', undefined, { @@ -75,6 +80,9 @@ export async function executeBulkOperation(req: Request, res: Response, next: Ne export async function importCsv(req: Request, res: Response, next: NextFunction) { try { + if (!isFeatureEnabled(FeatureFlag.BulkOperations)) { + throw new AppError('Bulk operations are not available', 403, 'FEATURE_NOT_AVAILABLE') + } const input = csvImportSchema.parse(req.body) const results = await bulkService.importCsv(input, req.user!.id) logActionFromReq(req, 'url.csv_imported', 'url', undefined, { diff --git a/apps/api/src/controllers/url.controllers.ts b/apps/api/src/controllers/url.controllers.ts index a7c3e16..02a11df 100644 --- a/apps/api/src/controllers/url.controllers.ts +++ b/apps/api/src/controllers/url.controllers.ts @@ -16,6 +16,7 @@ import { env } from '../utils/env' import { db } from '../db' import { visits } from '../db/schema' import { count, eq, asc } from 'drizzle-orm' +import { isFeatureEnabled, FeatureFlag } from '../utils/featureFlags' export async function createUrl(req: Request, res: Response, next: NextFunction) { try { @@ -29,6 +30,9 @@ export async function createUrl(req: Request, res: Response, next: NextFunction) export async function createUrlBulk(req: Request, res: Response, next: NextFunction) { try { + if (!isFeatureEnabled(FeatureFlag.BulkOperations)) { + throw new AppError('Bulk operations are not available', 403, 'FEATURE_NOT_AVAILABLE') + } const input = createUrlBulkSchema.parse(req.body) const results = await urlService.createShortUrlBulk(input, req.user!.id) const hasError = results.some((r) => !r.success) diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts new file mode 100644 index 0000000..8d6f50c --- /dev/null +++ b/apps/api/src/middleware/csrf.ts @@ -0,0 +1,37 @@ +import { doubleCsrf } from 'csrf-csrf' +import type { Request, Response, NextFunction } from 'express' +import { env } from '../utils/env' + +const CSRF_COOKIE_NAME = env.NODE_ENV === 'production' + ? '__Host-linkify.x-csrf-token' + : 'linkify.x-csrf-token' + +const { + generateCsrfToken, + doubleCsrfProtection, +} = doubleCsrf({ + getSecret: () => env.CSRF_SECRET, + getSessionIdentifier: (req) => req.headers['user-agent'] ?? 'unknown', + cookieName: CSRF_COOKIE_NAME, + cookieOptions: { + httpOnly: true, + sameSite: 'strict', + secure: env.NODE_ENV === 'production', + path: '/', + }, + ignoredMethods: ['GET', 'HEAD', 'OPTIONS'], + getCsrfTokenFromRequest: (req) => { + const token = req.headers['x-csrf-token'] as string | undefined + return token ?? null + }, + skipCsrfProtection: (req) => { + return !!req.headers.authorization?.startsWith('Bearer ') + }, +}) + +export function csrfTokenHandler(req: Request, res: Response) { + const token = generateCsrfToken(req, res) + res.json({ success: true, data: { token } }) +} + +export { doubleCsrfProtection as csrfProtection } diff --git a/apps/api/src/routes/auth.routes.ts b/apps/api/src/routes/auth.routes.ts index 6c9749c..1871ac9 100644 --- a/apps/api/src/routes/auth.routes.ts +++ b/apps/api/src/routes/auth.routes.ts @@ -2,9 +2,12 @@ import { Router } from 'express' import * as authController from '../controllers/auth.controller' import { requireAuth } from '../middleware/auth' import { authLimiter } from '../middleware/rateLimiter' +import { csrfTokenHandler } from '../middleware/csrf' const router = Router() +router.get('/csrf-token', authLimiter, csrfTokenHandler) + router.post('/refresh', authLimiter, authController.refreshToken) router.post('/reset-password', authLimiter, authController.resetPassword) router.get('/me', requireAuth, authController.getUserProfile) diff --git a/apps/api/src/services/link.service.ts b/apps/api/src/services/link.service.ts index 6af5aaa..fa96922 100644 --- a/apps/api/src/services/link.service.ts +++ b/apps/api/src/services/link.service.ts @@ -10,6 +10,7 @@ import { logAction } from './audit.service' import { cacheDel, buildCacheKeyForUrl } from './cache' import { getUserPlan } from './subscription.service' import { deleteAllQrCaches } from './url.services' +import { validateUrlSafety } from './urlSafety' const LINK_CHAIN_MAX_HOPS = 5 const JWT_SECRET = new TextEncoder().encode(env.LINK_ACCESS_SECRET) @@ -170,7 +171,10 @@ export async function resolveChain( try { const parsed = new URL(targetUrl) const match = parsed.pathname.match(SHORT_URL_PATTERN) - if (!match) return targetUrl + if (!match) { + await validateUrlSafety(targetUrl) + return targetUrl + } const chainedCode = match[1]! @@ -200,6 +204,7 @@ export async function resolveChain( return resolveChain(row.url, depth + 1, visited) } catch (err) { if (err instanceof AppError) throw err + await validateUrlSafety(targetUrl) return targetUrl } } diff --git a/apps/api/src/services/url.services.ts b/apps/api/src/services/url.services.ts index 4a79b72..0395a02 100644 --- a/apps/api/src/services/url.services.ts +++ b/apps/api/src/services/url.services.ts @@ -17,6 +17,7 @@ import { createFingerprint } from '../utils/fingerprint' import { encrypt, decrypt } from '../utils/encryption' import { cacheGet, cacheSet, cacheDel, buildCacheKeyForUrl } from './cache' import { getUserPlan } from './subscription.service' +import { isFeatureEnabled, FeatureFlag } from '../utils/featureFlags' const BASE_URL = env.BASE_URL const UNIQUE_VISIT_WINDOW_HOURS = env.UNIQUE_VISIT_WINDOW_HOURS @@ -99,6 +100,11 @@ export async function createShortUrl(input: CreateUrlInput, userId: string) { : null const activeAt = input.activeAt ? new Date(input.activeAt) : null + + if ((activeAt || expiresAt) && !isFeatureEnabled(FeatureFlag.ScheduledLinks)) { + throw new AppError('Scheduled links are not available', 403, 'FEATURE_NOT_AVAILABLE') + } + const passwordHash = input.password ? await bcrypt.hash(input.password, 12) : null const blockBots = input.blockBots ?? false const qrExpiresAt = input.qrExpiresAt ? new Date(input.qrExpiresAt) : null diff --git a/apps/api/src/services/urlSafety.ts b/apps/api/src/services/urlSafety.ts index fd46796..b0fce9e 100644 --- a/apps/api/src/services/urlSafety.ts +++ b/apps/api/src/services/urlSafety.ts @@ -1,4 +1,4 @@ -import { BLOCKLIST_TLDS, BLOCKLIST_PATTERNS } from '../constants/blocklistDomains' +import { BLOCKLIST_DOMAINS, BLOCKLIST_PATTERNS } from '../constants/blocklistDomains' import { AppError } from '../utils/AppError' import { lookup as dnsLookup } from 'dns/promises' import { isIP, isIPv4, isIPv6 } from 'net' @@ -77,7 +77,7 @@ export async function validateUrlSafety(rawUrl: string): Promise { } const domain = getDomain(hostname) - if (domain && BLOCKLIST_TLDS.has(domain)) { + if (domain && BLOCKLIST_DOMAINS.has(domain)) { throw new AppError('This domain has been blocked for security reasons', 400, 'BLOCKED_DOMAIN') } diff --git a/apps/api/src/utils/__tests__/env.test.ts b/apps/api/src/utils/__tests__/env.test.ts index 5133586..64474a2 100644 --- a/apps/api/src/utils/__tests__/env.test.ts +++ b/apps/api/src/utils/__tests__/env.test.ts @@ -12,9 +12,10 @@ describe('env validation', () => { DATABASE_URL: 'postgres://localhost:5432/db', BASE_URL: 'http://localhost:3000', SUPABASE_URL: 'https://test.supabase.co', - LINK_ACCESS_SECRET: 'test-secret-32-characters-long!!', - ENCRYPTION_KEY: 'test-encryption-key-32chars!', - FINGERPRINT_SECRET: 'test-fingerprint-secret-32!', + LINK_ACCESS_SECRET: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ENCRYPTION_KEY: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + FINGERPRINT_SECRET: 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + CSRF_SECRET: 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', NODE_ENV: 'test', }, }) @@ -30,9 +31,10 @@ describe('env validation', () => { DATABASE_URL: 'postgres://localhost:5432/db', BASE_URL: 'http://localhost:3000', SUPABASE_URL: 'https://test.supabase.co', - LINK_ACCESS_SECRET: 'test-secret-32-characters-long!!', - ENCRYPTION_KEY: 'test-encryption-key-32chars!', - FINGERPRINT_SECRET: 'test-fingerprint-secret-32!', + LINK_ACCESS_SECRET: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ENCRYPTION_KEY: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + FINGERPRINT_SECRET: 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + CSRF_SECRET: 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', NODE_ENV: 'test', }, }) @@ -55,9 +57,10 @@ describe('env validation', () => { DATABASE_URL: 'postgres://localhost:5432/db', BASE_URL: 'http://localhost:3000', SUPABASE_URL: 'https://test.supabase.co', - LINK_ACCESS_SECRET: 'test-secret-32-characters-long!!', - ENCRYPTION_KEY: 'test-encryption-key-32chars!', - FINGERPRINT_SECRET: 'test-fingerprint-secret-32!', + LINK_ACCESS_SECRET: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ENCRYPTION_KEY: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + FINGERPRINT_SECRET: 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + CSRF_SECRET: 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', PORT: '8080', RATE_LIMIT_WINDOW_MS: '120000', RATE_LIMIT_MAX: '200', diff --git a/apps/api/src/utils/env.ts b/apps/api/src/utils/env.ts index 8f18e4d..35a905c 100644 --- a/apps/api/src/utils/env.ts +++ b/apps/api/src/utils/env.ts @@ -7,9 +7,10 @@ const envSchema = z.object({ CORS_ORIGINS: z.string().optional(), SUPABASE_URL: z.string().min(1, 'SUPABASE_URL is required'), SUPABASE_SERVICE_ROLE_KEY: z.string().optional(), - LINK_ACCESS_SECRET: z.string().min(1, 'LINK_ACCESS_SECRET is required'), - ENCRYPTION_KEY: z.string().min(1, 'ENCRYPTION_KEY is required'), - FINGERPRINT_SECRET: z.string().min(1, 'FINGERPRINT_SECRET is required'), + LINK_ACCESS_SECRET: z.string().length(64).regex(/^[0-9a-f]{64}$/, 'LINK_ACCESS_SECRET must be 64 hex characters'), + ENCRYPTION_KEY: z.string().length(64).regex(/^[0-9a-f]{64}$/, 'ENCRYPTION_KEY must be 64 hex characters'), + FINGERPRINT_SECRET: z.string().length(64).regex(/^[0-9a-f]{64}$/, 'FINGERPRINT_SECRET must be 64 hex characters'), + CSRF_SECRET: z.string().length(64).regex(/^[0-9a-f]{64}$/, 'CSRF_SECRET must be 64 hex characters'), PASSWORD_MAX_AGE_DAYS: z.coerce.number().int().min(0).default(0), RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), RATE_LIMIT_MAX: z.coerce.number().int().positive().default(100), diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index c525f42..2316045 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -10,10 +10,10 @@ export default defineConfig({ NODE_ENV: 'test', BASE_URL: 'http://localhost:3000', SUPABASE_URL: 'https://test.supabase.co', - LINK_ACCESS_SECRET: 'test-link-access-secret-not-for-production', - ENCRYPTION_KEY: 'test-encryption-key-not-for-production-32b', - CSRF_SECRET: 'test-csrf-secret-not-for-production-32byte', - FINGERPRINT_SECRET: 'test-fingerprint-secret-not-for-production', + LINK_ACCESS_SECRET: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ENCRYPTION_KEY: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + CSRF_SECRET: 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + FINGERPRINT_SECRET: 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', }, coverage: { provider: 'v8', diff --git a/apps/dashboard/AUDIT_REPORT.md b/apps/dashboard/AUDIT_REPORT.md new file mode 100644 index 0000000..0854b91 --- /dev/null +++ b/apps/dashboard/AUDIT_REPORT.md @@ -0,0 +1,504 @@ +# Audit Report: `@linkify/dashboard` + +## 1. Summary + +| Check | Result | +|---|---| +| `pnpm install --filter @linkify/dashboard` | βœ… PASS | +| `pnpm --filter @linkify/dashboard typecheck` | βœ… PASS (0 errors) | +| `pnpm --filter @linkify/dashboard build` | βœ… PASS (1 warning: chunk >500 kB) | +| `pnpm --filter @linkify/dashboard lint` | ⚠️ No lint script in `package.json` | + +**Finding count by severity:** + +| Severity | Count | +|---|---| +| Critical | 0 | +| High | 2 | +| Medium | 8 | +| Low | 6 | + +**Total findings: 16** + +--- + +## 2. Phase Implementation Status + +### Phase 0: Foundation & Scaffolding + +| Deliverable | Status | Notes | +|---|---|---| +| `@supabase/supabase-js` dep | Implemented | | +| `tailwindcss` v4 + `@tailwindcss/vite` deps | Implemented | | +| `tw-animate-css`, `tailwindcss-animate`, `tailwind-merge`, `clsx`, `class-variance-authority` deps | Implemented | | +| `lucide-react` dep | Implemented | | +| `sonner` dep | Implemented | | +| `date-fns` dep | Implemented | | +| `recharts` dep | Implemented | Version 3.8.0 (per spec) | +| `cmdk`, `input-otp`, `embla-carousel-react`, `react-day-picker`, `react-resizable-panels` deps | Partial | In `package.json` but all 5 are **unused** β€” no import anywhere in `src/**` | +| `@shadcn/react` dep | Missing | Listed in spec appendix but not in `package.json`; not strictly required (UI is hand-coded) | +| `@/` path alias in `vite.config.ts` | Implemented | | +| `@/` path alias in `tsconfig.json` | Implemented | | +| `index.css` with Tailwind v4 + `tw-animate-css` + Geist font | Implemented | | +| Geist font import (`@fontsource-variable/geist`) | Implemented | | +| API client (`lib/api.ts`) β€” all 34 helper functions | Implemented | 33 of 34 listed functions present; `regenerateQrCode` is missing | +| Supabase client (`lib/supabase.ts`) | Implemented | | +| Auth context (`hooks/use-auth.tsx`) | Implemented | | +| Shared types (`packages/shared/src/index.ts`) | Implemented | All 13 types/interfaces present | +| Root-level dashboard scripts | Implemented | `dashboard:dev`, `dashboard:build`, `dashboard:preview`, `dashboard:typecheck`, `dashboard:test` | +| `.lintstagedrc.json` dashboard entry | Implemented | | + +### Phase 1: Dashboard Shell & Navigation + +| Deliverable | Status | Notes | +|---|---|---| +| `AppLayout` component | Implemented | | +| `Sidebar` component | Implemented | Logo, nav items, active highlighting, mobile drawer | +| `Topbar` component | Partial | Breadcrumbs, user avatar, theme toggle, **role badge instead of plan badge** per spec | +| `PageHeader` component | Implemented | | +| Route table (14 routes) | Implemented | All 14 routes match spec exactly | +| `ProtectedRoute` component | Implemented | | +| `ThemeProvider` + `ThemeToggle` | Implemented | | +| `ErrorBoundary` component | Implemented | | +| `NotFoundPage` | Implemented | | +| Sidebar responsive (mobile icon-only) | Implemented | | + +### Phase 2: Dashboard Overview + +| Deliverable | Status | Notes | +|---|---|---| +| 4 stat cards (Total Links, Total Visits, Active Links, Plan Usage) | Implemented | | +| StatsCard component | Implemented | | +| Recent links list (5 most recent) | Implemented | | +| "View all" link to `/urls` | Implemented | | +| Quick-create URL form | Implemented | | +| Success animation for created link | Implemented | `animate-in slide-in-from-top-1 fade-in duration-200` | +| Error toast on quick-create | Implemented | | +| Plan status card | Implemented | | +| "Upgrade" link to `/billing` | Implemented | | +| Loading skeletons | Implemented | | +| Empty state (new user) | Implemented | | +| Plan Usage progress bar | Implemented | | +| Rate-limit awareness on quick-create | **Missing** | Spec: "show counter or disable temporarily after 10 req/min" | +| Delta/trend on stat cards | **Missing** | Spec: "subtle delta/trend if available" | + +### Phase 3: URL Management β€” Create & List + +| Deliverable | Status | Notes | +|---|---|---| +| URLs list page (`/urls`) | Implemented | | +| Data table with 8 columns | Implemented | | +| Search input (`q` param) | Implemented | | +| Date range filter | Implemented | | +| Tag filter dropdown | Implemented | | +| Collection filter dropdown | Implemented | | +| Password toggle filter | Implemented | | +| Active status toggle filter | Implemented | | +| "Clear filters" button | Implemented | | +| Filters persist in URL search params | Implemented | | +| Sort controls (code, visits, createdAt) | Implemented | | +| Pagination with page size selector | Implemented | | +| Create URL page (`/urls/new`) | Implemented | | +| URL field with validation | Implemented | | +| Custom code field | Implemented | | +| TTL presets (7d, 30d, 90d, 365d, never) | Implemented | | +| Password field | Implemented | | +| Active at date-time picker | Implemented | | +| Block bots toggle | Implemented | | +| Tags multi-select with create-new-tag | Implemented | | +| Collection dropdown | Implemented | | +| Success view with copy + "Create another" | Implemented | | +| Field-level error messages (CODE_TAKEN, INVALID_URL) | Implemented | | +| Bulk create page (`/urls/bulk`) | Implemented | | +| Line-by-line URL parsing | Implemented | | +| Client-side validation before submit | Implemented | | +| Results table with success/error | Implemented | | +| Summary bar | Implemented | | +| Empty state | Implemented | | +| **QR expires at field** in create form | **Missing** | Spec lists `qrExpiresAt` in `/urls/new` form fields | + +### Phase 4: URL Detail & Analytics + +| Deliverable | Status | Notes | +|---|---|---| +| URL info header with OG preview | Implemented | | +| Short URL with copy button | Implemented | | +| Destination URL (clickable) | Implemented | | +| Status badge | Implemented | | +| Created/expiry/activation dates | Implemented | | +| Visit counts (total + unique) | Implemented | | +| Settings/Delete action buttons | Implemented | | +| Visit log tab with paginated table | Implemented | | +| Country flag + name | Implemented | | +| Device type icons | Implemented | | +| Referrer category badges | Implemented | | +| Bot indicator | Implemented | | +| Stats charts tab (daily + hourly) | Implemented | Using recharts | +| Daily avg summary | Implemented | | +| Toggle total/unique visits | Implemented | | +| CSV export button | Implemented | | +| QR code generation tab | Implemented | PNG/SVG format, optional logo | +| QR code download | Implemented | | +| Tab navigation (Info/Visits/Stats/QR Code) | Implemented | | +| Loading states per tab | Implemented | | +| Empty state for stats | Implemented | "Not enough data yet..." | +| **Regenerate QR** button | **Missing** | No `regenerateQrCode` in api.ts; no UI for regenerating expired QR | +| QR expiry badge | **Missing** | No display of `qrExpiresAt` in QR tab | + +### Phase 5: Link Settings & Password Protection + +| Deliverable | Status | Notes | +|---|---|---| +| URL settings page (`/urls/:code/settings`) | Implemented | | +| Active at date-time picker | Implemented | | +| Expires at date-time picker | Implemented | | +| QR expires at date-time picker | Implemented | | +| Block bots toggle | Implemented | | +| "Clear" buttons for date fields | Implemented | | +| Save with loading + success toast | Implemented | | +| Password set form (password + confirm) | Implemented | | +| Password change form | Implemented | | +| Password remove with confirmation | Implemented | | +| "Password protected" badge | Implemented | | +| Soft delete with confirmation | Implemented | | +| Permanent purge with type-to-confirm | Implemented | | +| Unsaved changes warning (beforeunload) | Implemented | | +| **Show `passwordSetAt` when password is set** | **Missing** | Not in `ShortUrl` type; not displayed | +| **AAL2 proactive check for purge** | **Missing** | Spec: "if user doesn't have AAL2, show info" β€” only catches API error reactively | +| **Plan upgrade prompt for password protection** | **Partial** | Catches `FEATURE_NOT_AVAILABLE` from API but doesn't proactively check plan features | + +### Phase 6: Collections + +| Deliverable | Status | Notes | +|---|---|---| +| Collections list page (`/collections`) | Implemented | | +| Expandable tree/nested list | Implemented | Only 2 levels (parent + children); spec implies arbitrary nesting | +| Create collection dialog (name + parent) | Implemented | | +| Edit collection dialog | Implemented | | +| Delete collection with confirmation | Implemented | | +| Drag-to-reorder with `@dnd-kit` | Implemented | | +| Optimistic UI with rollback | Implemented | | +| Collection detail page (`/collections/:id`) | Implemented | | +| Add URL by code | Implemented | | +| Select from existing URLs modal | Implemented | | +| Remove URL from collection | Implemented | | +| URL code list in collection | Implemented | | +| **Share section (Generate Share Link, Revoke Share)** | **Missing** | `shareCollection` / `revokeCollectionShare` exist in api.ts but no UI in CollectionDetailPage | + +### Phase 7: Tags + +| Deliverable | Status | Notes | +|---|---|---| +| Tags list page (`/tags`) | Implemented | | +| Card/grid display | Implemented | | +| Color indicator dot | Implemented | | +| URL count badge | Implemented | | +| Create tag dialog (name + color picker) | Implemented | | +| Edit tag dialog | Implemented | | +| Delete tag with confirmation | Implemented | | +| Tag detail page (`/tags/:id`) | Implemented | | +| URL list with code and destination | Implemented | | +| Pagination | Implemented | | +| Color picker component (12 presets + custom hex) | Implemented | | +| Bulk tagging from URLs list page | Implemented | Via bulk action toolbar | + +### Phase 8: Bulk Operations & CSV Import + +| Deliverable | Status | Notes | +|---|---|---| +| Bulk action toolbar (floating bottom bar) | Implemented | | +| Selected count display | Implemented | | +| Tag selected URLs | Implemented | | +| Move to collection | Implemented | | +| Extend expiry | Implemented | | +| Delete selected | Implemented | | +| Plan-gating (Pro check) | Implemented | | +| "Clear selection" button | Implemented | | +| CSV import dialog | Implemented | | +| CSV results table | Implemented | | +| Summary bar | Implemented | | +| **Client-side CSV parsing (PapaParse)** | **Missing** | Raw text sent to server; no column preview per spec | +| **Column headers preview** | **Missing** | Spec: "Column headers row shown as preview" | +| **Custom code per line in bulk create** | **Partial** | `bulkCreateUrls` accepts `customCode` in payload but UI never sends it β€” always `{ url }` only | + +### Phase 9: Billing & Subscription + +| Deliverable | Status | Notes | +|---|---|---| +| Plans page (`/billing/plans`) | Implemented | | +| Three-column comparison layout | Implemented | | +| Plan cards (name, price, features) | Implemented | | +| Monthly/yearly toggle | Implemented | | +| "Current plan" badge | Implemented | | +| Subscribe/Upgrade/Downgrade buttons | Implemented | | +| Subscription page (`/billing`) | Implemented | | +| Current plan card with dates | Implemented | | +| Usage progress bars | Implemented | | +| Color-coded bars (green/yellow/red) | Implemented | | +| Manage Subscription (Stripe portal) | Implemented | | +| Cancel subscription with confirmation | Implemented | | +| Stripe checkout redirect | Implemented | | +| **Tooltip with exact numbers on progress bars** | **Missing** | Spec: "Tooltip showing exact numbers" | + +### Phase 10: API Key Management + +| Deliverable | Status | Notes | +|---|---|---| +| API keys page (`/api-keys`) | Implemented | | +| Keys table with all 7 columns | Implemented | | +| Create key modal form | Implemented | | +| Key reveal dialog with copy + warning | Implemented | "I've copied the key" button | +| Edit key modal | Implemented | | +| Revoke key with confirmation | Implemented | | +| Rate limit cooldown (20s) | Implemented | | + +### Phase 11: Password-Protected Link Interstitial + +| Deliverable | Status | Notes | +|---|---|---| +| Route `/link/:code` in `apps/web` | Deferred (by design) | Out of scope β€” assigned to `apps/web` per spec | + +### Phase 12: Admin Panel (Separate App) + +| Deliverable | Status | Notes | +|---|---|---| +| `apps/admin` app | Deferred (by design) | Phase marked DEFERRED in spec | + +### Phase 13: Custom Domains + +| Deliverable | Status | Notes | +|---|---|---| +| Domain management | Deferred (by design) | Phase marked DEFERRED in spec | + +### Phase 14: Affiliate Links + +| Deliverable | Status | Notes | +|---|---|---| +| Affiliate link management | Deferred (by design) | Phase marked DEFERRED in spec | + +--- + +## 3. Findings + +### High Severity + +#### H1. Collection share section is completely missing + +- **Severity:** High +- **Location:** `apps/dashboard/src/pages/CollectionDetailPage.tsx` +- **Evidence:** `shareCollection` and `revokeCollectionShare` are implemented in `lib/api.ts` (lines 362–379) but never imported or rendered in the page component. The spec (Phase 6) requires: "Generate Share Link" button, share URL with copy button, "Revoke Share" button. None exist. +- **Impact:** Users cannot share collections or generate share links β€” a core Phase 6 feature is entirely absent from the UI. +- **Suggested fix:** Add share section UI to `CollectionDetailPage.tsx` using the existing `shareCollection`/`revokeCollectionShare` API functions. + +#### H2. QR code regeneration not implemented + +- **Severity:** High +- **Location:** `apps/dashboard/src/lib/api.ts` +- **Evidence:** No `regenerateQrCode` function exists. Grep confirms zero matches for `regenerateQr` or `regenerate` in the dashboard src. The spec (Phase 4) requires: `"Regenerate QR" β€” visible when QR is expired`, calling `POST /:code/qr/regenerate`. +- **Impact:** Users with expired QR codes cannot regenerate them β€” must delete and recreate the entire link. +- **Suggested fix:** Add `regenerateQrCode(token, code, expiresAt)` to `api.ts` and add UI in the QR tab of `UrlDetailPage.tsx`. + +### Medium Severity + +#### M1. Unused dependencies (5 packages) + +- **Severity:** Medium +- **Location:** `apps/dashboard/package.json` +- **Evidence:** The following packages in `dependencies` are never imported in any file under `src/`: + - `cmdk` (line 21) + - `input-otp` (line 25) + - `embla-carousel-react` (line 23) + - `react-day-picker` (line 28) + - `react-resizable-panels` (line 30) +- **Impact:** Unnecessary bundle weight (~50 kB+ collectively), misleading dep graph. All 5 are listed in phases.md as shadcn/ui transitive deps but the dashboard doesn't use shadcn/ui. +- **Suggested fix:** Remove all 5 unused packages from `package.json` (or add a comment explaining why they're retained). + +#### M2. Topbar shows role badge instead of plan badge + +- **Severity:** Medium +- **Location:** `apps/dashboard/src/components/topbar.tsx:31-35` +- **Evidence:** `Topbar` renders `{profile.role}`. Phase 1 spec says "Plan badge (current plan name)". The user role (e.g. "admin"/"user") is not the current plan name. +- **Impact:** Users see their role rather than their subscription plan β€” the wrong information in a prominent UI position. +- **Suggested fix:** Replace the role badge with a plan badge fetched from subscription data (e.g., via `getSubscription` context or prop). + +#### M3. Missing QR `expiresAt` field in Create URL form + +- **Severity:** Medium +- **Location:** `apps/dashboard/src/pages/CreateUrlPage.tsx` +- **Evidence:** The spec (Phase 3) lists "QR expires at (optional) β€” date-time picker" among the create URL form fields. The actual form has URL, custom code, TTL, password, active at, block bots, tags, and collection β€” but no QR expires at field. Confirmed by grep: `qrExpiresAt` only appears in `UrlSettingsPage.tsx`, never in `CreateUrlPage.tsx`. +- **Impact:** Users must create a URL first, navigate to settings, then set QR expiry β€” two steps instead of one. +- **Suggested fix:** Add a QR expiry date-time field to `CreateUrlPage.tsx` in the form. + +#### M4. `passwordSetAt` not displayed on password-protected links + +- **Severity:** Medium +- **Location:** `apps/dashboard/src/pages/UrlSettingsPage.tsx` / `packages/shared/src/index.ts` +- **Evidence:** Spec (Phase 5) says "Show when password was set (`passwordSetAt`)". No `passwordSetAt` property exists in the `ShortUrl` type. +- **Impact:** Users cannot see when a link's password was last set or changed. +- **Suggested fix:** Add `passwordSetAt` to the `ShortUrl` type and display it in the password section of `UrlSettingsPage.tsx`. + +#### M5. CSV import has no client-side parsing or column preview + +- **Severity:** Medium +- **Location:** `apps/dashboard/src/pages/UrlsListPage.tsx:861-953` +- **Evidence:** Spec (Phase 8) says: "Parse and validate CSV client-side (using PapaParse or basic string splitting)" and "Column headers row shown as preview". The CSV import sends raw text directly to the server (`importCsv` at `api.ts:289-298`) with zero client-side parsing, validation, or column preview. `PapaParse` is not in `package.json`. +- **Impact:** CSV validation errors are only surfaced post-submit β€” poor UX, wasted server round-trips for malformed input. +- **Suggested fix:** Add basic client-side CSV parsing (first line as headers, row count, URL validation) before submission. + +#### M6. `regenerateQrCode` function missing from API client + +- **Severity:** Medium +- **Location:** `apps/dashboard/src/lib/api.ts` +- **Evidence:** All 34 helper functions listed in Phase 0 exist except `regenerateQrCode`. The spec requires `regenerateQrCode` as an API helper function. +- **Impact:** See H2 β€” no API binding exists for the QR regeneration endpoint. +- **Suggested fix:** Add `regenerateQrCode(token, code, expiresAt)` function to `api.ts`. + +#### M7. Quick-create form lacks rate-limit awareness + +- **Severity:** Medium +- **Location:** `apps/dashboard/src/components/overview/quick-create-form.tsx` +- **Evidence:** Spec (Phase 2) says: "Rate-limit awareness (10 req/min on POST /api/urls) β€” show counter or disable temporarily". The form has no rate-limit tracking or cooldown. +- **Impact:** Users can hit the API rate limit without feedback, resulting in silent HTTP 429 errors shown as generic toasts. +- **Suggested fix:** Add a 10-req/min cooldown counter with disabled state on the Shorten button, similar to the API keys page cooldown. + +#### M8. Bulk create does not send per-URL custom codes + +- **Severity:** Medium +- **Location:** `apps/dashboard/src/pages/BulkCreatePage.tsx:71` +- **Evidence:** Phase 3 spec says bulk create supports per-line custom codes. The API function `bulkCreateUrls` accepts `customCode` in the payload (`api.ts:164`). The UI at line 71 only sends `{ url: u.url }` β€” custom codes are never input or forwarded. +- **Impact:** Users who paste URLs expecting to specify custom codes per line cannot β€” a specified feature of the bulk create page is missing. +- **Suggested fix:** Add per-line custom code support in bulk create input parsing and payload. + +### Low Severity + +#### L1. No lint script configured + +- **Severity:** Low +- **Location:** `apps/dashboard/package.json:6-12` +- **Evidence:** No `lint` script exists in the package scripts. The `.lintstagedrc.json` only runs `typecheck`. +- **Impact:** No ESLint/Biome enforcement β€” coding style drift goes undetected. +- **Suggested fix:** Add a `lint` script (e.g., `"lint": "biome check src"`) and configure a linter. + +#### L2. Sora font not imported + +- **Severity:** Low +- **Location:** `apps/dashboard/src/index.css` +- **Evidence:** `DESIGN.md` specifies Sora Variable for display/headline and Geist Variable for body. Only `@import "@fontsource-variable/geist"` is present. Sora is never imported or configured as a CSS font-family. +- **Impact:** The Sora/Geist pairing prescribed by the design system is not applied β€” all text uses Geist. +- **Suggested fix:** Add `@import "@fontsource-variable/sora"` to `index.css` and add `--font-display: "Sora Variable", sans-serif` to the `@theme inline` block. + +#### L3. Build chunk size exceeds 500 kB + +- **Severity:** Low +- **Location:** build output +- **Evidence:** `(!) Some chunks are larger than 500 kB after minification.` β€” the main JS chunk is 1,114 kB (318 kB gzip). +- **Impact:** Slower initial load on slow connections; no code-splitting strategy applied. +- **Suggested fix:** Use `React.lazy()` for route-level code-splitting on heavy pages (Billing, API Keys, URL Detail with recharts). + +#### L4. Missing delta/trend indicators on stat cards + +- **Severity:** Low +- **Location:** `apps/dashboard/src/components/overview/stats-card.tsx` +- **Evidence:** Phase 2 spec: "subtle delta/trend if available". Stats cards show title, value, icon and optional progress bar but no trend/delta arrows. +- **Impact:** Users cannot see whether metrics are trending up or down. +- **Suggested fix:** Add an optional `delta` prop to `StatsCard` component and populate from usage data. + +#### L5. AAL2 requirement for purge is only handled reactively + +- **Severity:** Low +- **Location:** `apps/dashboard/src/pages/UrlSettingsPage.tsx:227-243` +- **Evidence:** Phase 5 spec: "Requires AAL2 β€” if user doesn't have AAL2, show info about 2FA requirement". The code only catches `AAL2_REQUIRED` error from the API after the user clicks Purge, rather than checking proactively. +- **Impact:** User must go through the purge flow and submit before learning they need 2FA β€” unnecessary friction. +- **Suggested fix:** Add a proactive AAL2 check (e.g., from auth context or an API call) when the purge button is clicked, showing the 2FA prompt before the dialog. + +#### L6. Shared types missing `passwordSetAt` and `qrExpiresAt` on ShortUrl fields + +- **Severity:** Low +- **Location:** `packages/shared/src/index.ts:31-46` +- **Evidence:** The spec's `ShortUrl` type definition in Phase 0 includes `passwordSetAt`? No, actually checking β€” the spec Phase 0 lists `ShortUrl` with `{ code, url, shortUrl, title, description, image, visits, uniqueVisits, expiresAt, activeAt, hasPassword, blockBots, qrExpiresAt, createdAt }`. The actual type has all of these. But `passwordSetAt` is referenced in Phase 5 but not in the `ShortUrl` type in the spec or implementation. This is minor β€” the field simply doesn't exist anywhere. +- **Impact:** Cannot display password set date as required by Phase 5. +- **Suggested fix:** Add `passwordSetAt?: string | null` to the `ShortUrl` interface. + +--- + +## 4. Missing Dependencies / Config + +| Package | Declared in import? | In `package.json`? | Listed in phases.md? | Status | +|---|---|---|---|---| +| `@shadcn/react` | No | No | Yes (phase 0) | Unnecessary β€” UI is hand-coded. Safe to remove from spec. | +| `papaparse` | No | No | Yes (phase 8) | Missing β€” spec expects client-side CSV parsing | +| `@fontsource-variable/sora` | No | No | No (implied by DESIGN.md) | Missing β€” Sora not imported anywhere | +| `cmdk` | No | Yes | Yes | Unused β€” safe to remove | +| `input-otp` | No | Yes | Yes | Unused β€” safe to remove | +| `embla-carousel-react` | No | Yes | Yes | Unused β€” safe to remove | +| `react-day-picker` | No | Yes | Yes | Unused β€” safe to remove | +| `react-resizable-panels` | No | Yes | Yes | Unused β€” safe to remove | +| `@dnd-kit/core` | Yes | Yes | No | Used β€” should be added to phases.md | +| `@dnd-kit/sortable` | Yes | Yes | No | Used β€” should be added to phases.md | + +### Path alias verification + +| Alias | `vite.config.ts` | `tsconfig.json` | Status | +|---|---|---|---| +| `@/` β†’ `./src/*` | Line 10: `"@": path.resolve(__dirname, "./src")` | Line 12: `"@/*": ["./src/*"]` | βœ… | + +### Lint script + +| Check | Status | +|---|---| +| `package.json` lint script | ❌ Missing β€” no lint command defined | +| `.lintstagedrc.json` coverage | βœ… Dashboard included (runs typecheck only) | + +--- + +## 5. Documentation & Design-System Drift + +### Design system drift (`DESIGN.md` vs actual) + +| Claimed (`DESIGN.md`) | Actual (`src/`) | Status | +|---|---|---| +| Font pairing: **Sora Variable** (display) + **Geist Variable** (body) | Only Geist Variable imported and configured | ❌ Sora is missing | +| Primary: oklch(0.35 0.12 260) | `index.css:40`: `--primary: oklch(0.35 0.12 260)` | βœ… | +| Border: oklch(0.88 0.005 260) | `index.css:46`: `--border: oklch(0.88 0.005 260)` | βœ… | +| Radius: sm=6px, md=8px, lg=10px, xl=14px | `index.css:29-32`: matches exactly | βœ… | +| Spacing: xs=0.5rem, sm=1rem, md=1.5rem, lg=2rem, xl=4rem | Not explicitly re-declared β€” uses Tailwind defaults | ⚠️ Relies on default scale, not explicitly set | +| Motion: 400ms entrance, 200ms exit, 150ms micro | CSS classes use Tailwind defaults (animate-in class) | ⚠️ Not explicitly configured | +| Hover lift: `0 8px 24px rgba(0,0,0,0.1)` + `translateY(-2px)` | `--shadow-hover` defined in `index.css:34` but **never used** in any component class | ⚠️ Declared but unused | +| Dark mode surface: oklch(0.12 0.005 260) | `index.css:58`: `--background: oklch(0.12 0.005 260)` | βœ… | +| Flat-by-Default Rule (no shadow on cards at rest) | Cards use border only, no shadow at rest | βœ… | +| No glassmorphism, no gradient text, no warm beige | None found | βœ… | +| No numbered section markers, no tiny uppercase eyebrows | None found | βœ… | + +### Product doc drift (`PRODUCT.md` vs actual) + +| Claimed | Actual | Status | +|---|---|---| +| Auth redirects to `apps/web` login with `?redirectTo=` param | `use-auth.tsx:59`: redirects to `${WEB_LOGIN_URL}/login?redirectTo=...` | βœ… | +| Cool-tinted neutrals | All OKLCH neutrals have `260` hue (indigo/cool) | βœ… | + +### Phase spec drift (`phases.md` vs actual) + +| Documented | Actual | Status | +|---|---|---| +| Root scripts: `dashboard:dev/build/typecheck` | All three exist in root `package.json:20-23` | βœ… | +| `BILLING_SUCCESS_URL` env var | Not configured or documented | **Needs verification** β€” not in code, not in spec appendix | +| Sora not mentioned in phases.md appendix | No Sora dep | ⚠️ Same as L2: font drift | + +--- + +## Appendix: Commands Run + +All commands executed from workspace root (`/mnt/ItWorksOnMyDrive/MERN/linkify`). + +```bash +# Install +pnpm install --filter @linkify/dashboard + +# Typecheck +pnpm --filter @linkify/dashboard typecheck + +# Build +pnpm --filter @linkify/dashboard build + +# Lint β€” not available (no script) +``` diff --git a/docs/API.md b/docs/API.md index 3adca77..e44348c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -50,8 +50,8 @@ Authorization: Bearer All state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`) under `/api` require a CSRF token **unless** using Bearer auth. -1. `GET /api/auth/csrf-token` β†’ receive `{ token: "..." }` -2. `Set-Cookie: csrf-token=` (httpOnly, sameSite=strict) +1. `GET /api/auth/csrf-token` β†’ receive `{ success: true, data: { token: "..." } }` +2. A `__Host-linkify.x-csrf-token` cookie is set automatically (httpOnly, sameSite=strict, secure in production) 3. Include header: `x-csrf-token: ` ### AAL2 / Two-Factor Authentication diff --git a/docs/PROJECT.md b/docs/PROJECT.md index 3da84ca..5e44f85 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -277,7 +277,7 @@ src/ β”‚ └── url.types.ts # Request/response interfaces (UrlResponse, VisitResponse, etc.) β”‚ β”œβ”€β”€ constants/ -β”‚ β”œβ”€β”€ blockedDomains.ts # Blocklist TLDs and phishing URL patterns +β”‚ β”œβ”€β”€ blocklistDomains.ts # Blocklist TLDs and phishing URL patterns β”‚ └── reservedWords.ts # Reserved short codes (api, admin, dashboard, etc.) β”‚ β”œβ”€β”€ utils/ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc2c9a7..2979ae2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: cors: specifier: ^2.8.6 version: 2.8.6 + csrf-csrf: + specifier: ^4.0.3 + version: 4.0.3 dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -114,9 +117,6 @@ importers: '@types/qrcode': specifier: ^1.5.6 version: 1.5.6 - '@types/sharp': - specifier: ^0.32.0 - version: 0.32.0(@types/node@26.1.1) '@types/supertest': specifier: ^7.2.0 version: 7.2.0 @@ -2043,10 +2043,6 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} - '@types/sharp@0.32.0': - resolution: {integrity: sha512-OOi3kL+FZDnPhVzsfD37J88FNeZh6gQsGcLc95NbeURRGvmSjeXiDcyWzF2o3yh/gQAUn2uhh/e+CPCa5nwAxw==} - deprecated: This is a stub types definition. sharp provides its own type definitions, so you do not need this installed. - '@types/superagent@8.1.10': resolution: {integrity: sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==} @@ -2511,6 +2507,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csrf-csrf@4.0.3: + resolution: {integrity: sha512-DaygOzelL4Qo1pHwI9LPyZL+X2456/OzpT596kNeZGiTSqKVDOk/9PPJ+FjzZacjMUEusOHw3WJKe1RW4iUhrw==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -5953,12 +5952,6 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 26.1.1 - '@types/sharp@0.32.0(@types/node@26.1.1)': - dependencies: - sharp: 0.35.3(@types/node@26.1.1) - transitivePeerDependencies: - - '@types/node' - '@types/superagent@8.1.10': dependencies: '@types/cookiejar': 2.1.5 @@ -6378,6 +6371,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csrf-csrf@4.0.3: + dependencies: + http-errors: 2.0.1 + cssesc@3.0.0: {} csstype@3.2.3: {}