diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 7901ce003f..cbf483abef 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -51,6 +51,8 @@ jobs: run: bun scripts/format/sort-package-json.ts --check - name: Custom lint rules (typeof guards, raw regex, process.env) run: bun lint:custom + - name: Check for unauthenticated routes + run: bun scripts/lint/no-unauth-routes.ts - name: Check unsafe type casts run: bun check:casts:strict - name: Check types diff --git a/lefthook.yml b/lefthook.yml index 821ba9eadc..97c1d3aa56 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -21,6 +21,7 @@ pre-push: bun scripts/lint/no-circular-deps.ts && bun scripts/lint/no-duplicate-deps.ts && bun scripts/lint/no-duplicate-guards.ts && + bun scripts/lint/no-unauth-routes.ts && bun scripts/format/sort-package-json.ts --check && bun check:casts:strict fail_text: "Pre-push checks failed! Run `bun check:all` for the full picture." diff --git a/package.json b/package.json index ff3c96f1b3..1d449b88e8 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "ios": "cd apps/expo && bun ios", "lefthook": "lefthook install", "lint": "biome check --write", - "lint:custom": "bun run scripts/lint/no-raw-typeof.ts && bun run scripts/lint/no-raw-regex.ts && bun run packages/env/scripts/no-raw-process-env.ts && bun run scripts/lint/no-duplicate-guards.ts", + "lint:custom": "bun run scripts/lint/no-raw-typeof.ts && bun run scripts/lint/no-raw-regex.ts && bun run packages/env/scripts/no-raw-process-env.ts && bun run scripts/lint/no-duplicate-guards.ts && bun run scripts/lint/no-unauth-routes.ts", "lint:strict": "biome check && bun run lint:custom", "lint-unsafe": "biome check --write --unsafe", "mcp": "bun run --cwd packages/mcp dev", diff --git a/packages/api/src/routes/alltrails.ts b/packages/api/src/routes/alltrails.ts index 8fe7e4537f..fde8640f5a 100644 --- a/packages/api/src/routes/alltrails.ts +++ b/packages/api/src/routes/alltrails.ts @@ -15,6 +15,7 @@ function extractOgTag(html: string, property: string): string | null { return match?.[1] ?? null; } +// public-route: link-preview proxy; fetches OG metadata from AllTrails, no user data involved export const alltrailsRoutes = new Elysia({ prefix: '/alltrails' }).post( '/preview', async ({ body }) => { diff --git a/packages/api/src/routes/auth/index.ts b/packages/api/src/routes/auth/index.ts index 6b03a6b41f..8154129aa1 100644 --- a/packages/api/src/routes/auth/index.ts +++ b/packages/api/src/routes/auth/index.ts @@ -48,7 +48,7 @@ const { passwordHash: _pw, ...userWithoutPassword } = getTableColumns(users); export const authRoutes = new Elysia({ prefix: '/auth' }) .use(authPlugin) - // Login + // public-route: credentials are the auth mechanism .post( '/login', async ({ body }) => { @@ -94,7 +94,7 @@ export const authRoutes = new Elysia({ prefix: '/auth' }) }, ) - // Register + // public-route: pre-authentication account creation .post( '/register', async ({ body }) => { @@ -155,7 +155,7 @@ export const authRoutes = new Elysia({ prefix: '/auth' }) }, ) - // Verify email + // public-route: OTP code is the credential; no prior session needed .post( '/verify-email', async ({ body }) => { @@ -227,7 +227,7 @@ export const authRoutes = new Elysia({ prefix: '/auth' }) }, ) - // Resend verification + // public-route: pre-authentication email flow .post( '/resend-verification', async ({ body }) => { @@ -270,7 +270,7 @@ export const authRoutes = new Elysia({ prefix: '/auth' }) }, ) - // Forgot password + // public-route: pre-authentication email flow .post( '/forgot-password', async ({ body }) => { @@ -312,7 +312,7 @@ export const authRoutes = new Elysia({ prefix: '/auth' }) }, ) - // Reset password + // public-route: OTP code is the credential; no prior session needed .post( '/reset-password', async ({ body }) => { @@ -364,7 +364,7 @@ export const authRoutes = new Elysia({ prefix: '/auth' }) }, ) - // Refresh token + // public-route: refresh token is the credential .post( '/refresh', async ({ body }) => { @@ -421,7 +421,7 @@ export const authRoutes = new Elysia({ prefix: '/auth' }) }, ) - // Logout + // public-route: refresh token is the credential; no JWT required .post( '/logout', async ({ body }) => { @@ -494,7 +494,7 @@ export const authRoutes = new Elysia({ prefix: '/auth' }) }, ) - // Apple sign-in + // public-route: Apple identity token is the credential .post( '/apple', async ({ body }) => { @@ -563,7 +563,7 @@ export const authRoutes = new Elysia({ prefix: '/auth' }) }, ) - // Google sign-in + // public-route: Google ID token is the credential .post( '/google', async ({ body }) => { diff --git a/scripts/check-all.ts b/scripts/check-all.ts index 6829f1a093..8bba7e6e46 100644 --- a/scripts/check-all.ts +++ b/scripts/check-all.ts @@ -9,6 +9,7 @@ // - scripts/lint/no-circular-deps.ts // - scripts/lint/no-duplicate-deps.ts (skipped if file doesn't exist) // - scripts/lint/no-duplicate-guards.ts +// - scripts/lint/no-unauth-routes.ts // - packages/checks/src/check-magic-strings.ts // - packages/checks/src/check-type-casts.ts --strict // - scripts/lint/check-react-doctor.ts @@ -77,6 +78,10 @@ const ALL_CHECKS: CheckDef[] = [ name: 'no-duplicate-guards', script: join(ROOT, 'scripts', 'lint', 'no-duplicate-guards.ts'), }, + { + name: 'no-unauth-routes', + script: join(ROOT, 'scripts', 'lint', 'no-unauth-routes.ts'), + }, { name: 'check-type-casts', script: join(ROOT, 'packages', 'checks', 'src', 'check-type-casts.ts'), diff --git a/scripts/lint/no-duplicate-guards.ts b/scripts/lint/no-duplicate-guards.ts index 3c9f365d48..6bd250c505 100644 --- a/scripts/lint/no-duplicate-guards.ts +++ b/scripts/lint/no-duplicate-guards.ts @@ -92,7 +92,7 @@ function isTargetFile(name: string): boolean { } function isExcluded(relPath: string): boolean { - return EXCLUDED_ROOTS.some((p) => relPath === p || relPath.startsWith(p + '/')); + return EXCLUDED_ROOTS.some((p) => relPath === p || relPath.startsWith(`${p}/`)); } function walkDir(dir: string, relPath: string, violations: Violation[]): void { diff --git a/scripts/lint/no-unauth-routes.ts b/scripts/lint/no-unauth-routes.ts new file mode 100644 index 0000000000..520c768dcc --- /dev/null +++ b/scripts/lint/no-unauth-routes.ts @@ -0,0 +1,176 @@ +#!/usr/bin/env bun +// +// no-unauth-routes.ts — flags Elysia route handlers that are missing an auth macro. +// +// PackRat's API uses three auth macros in route options: +// isAuthenticated: true — requires a valid user JWT +// isAdmin: true — requires ADMIN role +// isValidApiKey: true — requires X-API-Key (cron / ETL routes) +// +// Any route definition (.get/.post/.put/.patch/.delete/.all) that omits all +// three is either accidentally public or needs a // public-route: annotation +// to explicitly declare it intentional. +// +// Annotation placement: +// // public-route: reason +// .post('/login', handler, { body: LoginSchema }) +// +// Or inline on the options object's closing line. +// +// Exit code: +// 0 — all routes protected or annotated +// 1 — violations found + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROOT = join(import.meta.dir, '..', '..'); +const ROUTES_ROOT = join(ROOT, 'packages', 'api', 'src', 'routes'); + +const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'build', '__tests__', 'admin']); + +// Matches the start of an Elysia route definition: .get('/path' .post(`/path` etc. +// Requiring the path to start with '/' distinguishes from URLSearchParams.get('key') etc. +const ROUTE_START = /\.(get|post|put|patch|delete|all)\s*\(\s*['"`]\//g; + +// Auth macros that indicate the route is protected. +const AUTH_MACRO = + /\bisAuthenticated\s*:\s*true\b|\bisAdmin\s*:\s*true\b|\bisValidApiKey\s*:\s*true\b/; + +// Explicit opt-out annotation for intentionally public routes. +const PUBLIC_ANNOTATION = /\/\/\s*public-route:/; + +interface Violation { + file: string; + line: number; + method: string; + path: string; +} + +function collectFiles(dir: string, out: string[]): void { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const entry of entries) { + if (EXCLUDED_DIRS.has(entry)) continue; + const full = join(dir, entry); + let isDir = false; + try { + isDir = statSync(full).isDirectory(); + } catch { + continue; + } + if (isDir) { + collectFiles(full, out); + } else if (/\.ts$/.test(entry) && !/\.(test|spec|d)\.ts$/.test(entry)) { + out.push(full); + } + } +} + +function extractRoutePath(content: string, afterOffset: number): string { + // Extract the string literal that is the first argument of the route call. + const slice = content.slice(afterOffset, afterOffset + 200); + const m = slice.match(/^['"`]([^'"`\n]*)/); + return m ? m[1] : '?'; +} + +function checkFile(filePath: string): Violation[] { + let content: string; + try { + content = readFileSync(filePath, 'utf8'); + } catch { + return []; + } + + const violations: Violation[] = []; + const lines = content.split('\n'); + const relPath = filePath.replace(`${ROOT}/`, ''); + + // Reset and scan for all route starts. + ROUTE_START.lastIndex = 0; + + for (;;) { + const match = ROUTE_START.exec(content); + if (match === null) break; + const method = match[1] ?? 'get'; + const callStart = match.index; // position of the '.' + const parenOpen = content.indexOf('(', callStart + 1); // opening paren of the call + + // Walk forward tracking paren depth to find the end of the full route call. + let depth = 0; + let callEnd = -1; + for (let i = parenOpen; i < content.length; i++) { + const ch = content[i]; + if (ch === '(') depth++; + else if (ch === ')') { + depth--; + if (depth === 0) { + callEnd = i; + break; + } + } + } + if (callEnd === -1) continue; // malformed — skip + + const span = content.slice(callStart, callEnd + 1); + + // Protected if any auth macro appears within the call. + if (AUTH_MACRO.test(span)) continue; + + // Also OK if a // public-route: annotation appears anywhere in the span. + if (PUBLIC_ANNOTATION.test(span)) continue; + + // Also check the run of comment lines immediately preceding the route call. + const callLine = content.slice(0, callStart).split('\n').length - 1; + let annotated = false; + for (let j = callLine - 1; j >= 0 && j >= callLine - 10; j--) { + const prev = lines[j] ?? ''; + if (PUBLIC_ANNOTATION.test(prev)) { + annotated = true; + break; + } + if (!/^\s*(\/\/|\*)/.test(prev)) break; // stop at non-comment + } + if (annotated) continue; + + const routePath = extractRoutePath(content, parenOpen + 1); + violations.push({ file: relPath, line: callLine + 1, method, path: routePath }); + } + + return violations; +} + +const files: string[] = []; +collectFiles(ROUTES_ROOT, files); + +const allViolations: Violation[] = []; +for (const f of files.sort()) { + allViolations.push(...checkFile(f)); +} + +if (allViolations.length === 0) { + console.log('✓ All routes are protected or explicitly annotated as public.'); + process.exit(0); +} + +console.log( + `Found ${allViolations.length} route(s) with no auth macro and no // public-route: annotation:\n`, +); + +let lastFile = ''; +for (const v of allViolations) { + if (v.file !== lastFile) { + console.log(` ${v.file}`); + lastFile = v.file; + } + console.log(` line ${v.line}: .${v.method}('${v.path}')`); +} + +console.log( + '\nFix: add isAuthenticated/isAdmin/isValidApiKey to the route options, or add a // public-route: comment above the route.', +); +process.exit(1);