Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +54 to +55
- name: Check unsafe type casts
run: bun check:casts:strict
- name: Check types
Expand Down
1 change: 1 addition & 0 deletions lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/routes/alltrails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
20 changes: 10 additions & 10 deletions packages/api/src/routes/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -94,7 +94,7 @@ export const authRoutes = new Elysia({ prefix: '/auth' })
},
)

// Register
// public-route: pre-authentication account creation
.post(
'/register',
async ({ body }) => {
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -227,7 +227,7 @@ export const authRoutes = new Elysia({ prefix: '/auth' })
},
)

// Resend verification
// public-route: pre-authentication email flow
.post(
'/resend-verification',
async ({ body }) => {
Expand Down Expand Up @@ -270,7 +270,7 @@ export const authRoutes = new Elysia({ prefix: '/auth' })
},
)

// Forgot password
// public-route: pre-authentication email flow
.post(
'/forgot-password',
async ({ body }) => {
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -364,7 +364,7 @@ export const authRoutes = new Elysia({ prefix: '/auth' })
},
)

// Refresh token
// public-route: refresh token is the credential
.post(
'/refresh',
async ({ body }) => {
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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 }) => {
Expand Down
5 changes: 5 additions & 0 deletions scripts/check-all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'),
Expand Down
2 changes: 1 addition & 1 deletion scripts/lint/no-duplicate-guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
176 changes: 176 additions & 0 deletions scripts/lint/no-unauth-routes.ts
Original file line number Diff line number Diff line change
@@ -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: <reason> comment above the route.',
);
process.exit(1);
Loading