Security hardening: forgeable sessions, free-tier login failure, and 9 other fixes#1
Conversation
TextEncoder().encode(undefined) yields a zero-length key, and jose will both sign and verify HS256 with it. A deploy that forgot to set the secret would therefore accept forged session cookies for any user_id, with no error anywhere — the app just looks like it works. Require a secret of at least 32 characters and throw otherwise, so a misconfigured deploy 500s instead of running wide open. Resolve the key outside verifySession's try block so a config error surfaces as a 500 rather than being swallowed into a 401. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bcryptjs is pure JavaScript and a cost-10 hash burns ~74ms of CPU. The Workers Free plan caps CPU at 10ms per request, so login and register hard-failed with "exceeded resource limits" on any fork deployed to the free tier. The app worked only because our own account is on Paid. Move to PBKDF2-HMAC-SHA256 through crypto.subtle, which runs natively. No password hash is both OWASP-grade and under 10ms — being slow is the whole point of one. We choose free-tier deployability over hash strength and run 50k iterations (~6ms), roughly 12x below OWASP's guidance. The trade-off is documented at the constant. Iterations are stored per-hash, so the factor can be raised later without invalidating existing hashes. Legacy bcrypt hashes still verify and are transparently re-hashed to PBKDF2 on the account's next successful login. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Session JWTs were stateless, so change-password rehashed the secret but left every already-issued cookie valid for its full 30-day life. The one action a user takes when they think they have been compromised did not actually lock the attacker out. The existing test missed this because it checked the old *password* stopped working, not the old *session*. Add users.token_version, embed it in the JWT, and compare it against the stored value on every authenticated request. change-password increments it, revoking prior sessions, and re-issues a cookie for the calling device so changing your password doesn't sign you out of it. This costs one D1 read per authenticated request. That is the price of being able to revoke a session at all. verifySession is replaced by authenticate() rather than extended, so the compiler flags any call site still doing a signature-only check. The test bootstrap now reads the whole migrations directory instead of naming files, so a new migration can't land without the schema tracking it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An unknown email returned before any hashing ran, so a miss answered ~6ms faster than a real account with a wrong password. That difference is stable enough to enumerate which addresses are registered. Verify against a dummy hash on the unknown-email path so both cost the same work, and cover it with a timing test that would catch the early return coming back. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
login, register and change-password accepted unlimited attempts from a single IP. PBKDF2 makes each guess cost ~6ms, which slows a brute-force run down but does not stop one. Add Cloudflare's rate-limit binding at 10 attempts/minute/IP, keyed on CF-Connecting-IP (set by the edge, not spoofable by the client). An unknown IP shares one bucket, so it is throttled rather than exempt. The binding is treated as optional: if it is missing the endpoints still work and log a warning, because rate limiting is a production defence and not a correctness property. Note for the tests: getPlatformProxy hands back a real limiter once wrangler.jsonc declares one, so the suite was throttling itself. The default test env drops it and the dedicated test injects a stub. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
zod's .url() only runs `new URL()`, which accepts javascript: and data:. Both reach an <a href>: job.url in the drawer header, and contact.linkedin — which had no URL validation at all, just a length cap. A stored "javascript:…" would run on click. This is self-XSS in a single-user tool rather than a way in from outside, but it is a five-second fix and stops being self-inflicted the moment anything is shared. Guard it on both sides. safeExternalUrl() gates the scheme on write, and the drawer runs the same check on render, because rows written before this landed may already contain one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Registration checked for an existing email and then inserted. Two concurrent signups for one address both passed the check, and the loser hit the UNIQUE constraint, which fell through to onError as a 500. Drop the pre-check and let the constraint be the single source of truth, mapping the violation to the 409 that was always intended. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The funnel query filtered archived = 0 but the response-rate, average days-to-interview and weekly-applications queries did not. Archiving a job removed it from the funnel while it kept dragging on the response rate, so two numbers on the same dashboard disagreed about which jobs existed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The drawer's status dropdown called PATCH /jobs/:id, which updates the status column but never touches sort_order. The card therefore kept the ordering value it had in the column it left and landed at an arbitrary slot in the one it joined — sometimes colliding with a card already there. Send it through PATCH /jobs/:id/move, which pins the card at a requested index and renumbers the destination column, exactly as drag-and-drop does. The drawer's onChange now takes every job the server touched rather than a single one, since a move rewrites a whole column. Both callers merged by id already, so the change is confined to the signature. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/auth was public only because it was mounted above the line that applied requireAuth to everything. The boundary was positional and invisible: a route added one line too high would have shipped unauthenticated, and nothing would have said so. Mount the auth routes on publicApi and everything else on protectedApi, so a route's protection follows from the router it is attached to. One ordering dependency survives — publicApi must be mounted first, since protectedApi's wildcard middleware also matches /api/auth/* and only stops there because the public handler already returned. Getting that wrong 401s every request including login, so it fails loudly rather than quietly opening a hole. A test pins both halves of the invariant. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The app served no CSP, so a stored javascript: URL had a script context to run in, and index.html pulled Space Grotesk from fonts.googleapis.com — which leaked every visitor's IP and User-Agent to Google and blocked first paint on a server we don't control. Add public/_headers with a CSP plus nosniff, Referrer-Policy, HSTS, X-Frame-Options and Permissions-Policy. script-src is 'self' with no 'unsafe-inline', which is the directive that closes the XSS class for good. style-src does need 'unsafe-inline' because dnd-kit writes drag transforms into inline style attributes. Ship the latin subset of the variable woff2 from public/fonts so font-src can stay 'self' and the app makes no third-party request at all. Verified against `wrangler dev`: the headers are served, the font loads, and register/login/jobs/stats all still work under the policy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every 400 from the jobs routes returned "Company and title are required", so rejecting a javascript: URL told the user to fill in a company they had already filled in. Surface the failing field's own message instead, with wording written for the fields people actually hit. Found while smoke-testing the URL validation against wrangler dev. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
typecheck and tests both pass on a tree that fails to bundle, so a broken Vite build could reach main and only surface at deploy time. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The docs described bcrypt hashing, a stateless session, and a merely "recommended" 32-char secret — all three now contradict the code, which is the kind of thing that burns someone forking the repo. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 45 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (9)
📝 WalkthroughWalkthroughAuthentication is hardened with PBKDF2, token-version session invalidation, JWT secret checks, timing mitigation, and credential rate limiting. API validation, statistics, job movement, frontend links, security headers, font loading, CI builds, tests, and documentation are also updated. ChangesApplication hardening and consistency
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant AuthRoutes
participant Password
participant Database
Client->>AuthRoutes: Submit login credentials
AuthRoutes->>Database: Load password hash and token version
AuthRoutes->>Password: Verify password or dummy credentials
Password-->>AuthRoutes: Return verification result
AuthRoutes->>Database: Store upgraded hash when applicable
AuthRoutes-->>Client: Set tracker_auth JWT cookie
Client->>AuthRoutes: Send authenticated request
AuthRoutes->>Database: Compare JWT tv with users.token_version
Database-->>AuthRoutes: Return session validity
AuthRoutes-->>Client: Return protected response or 401
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
🧹 Nitpick comments (1)
src/pages/Board.tsx (1)
138-140: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winUse
filterand spread to merge jobs instead ofmapto avoid dropping new records.When the backend
/moveendpoint returns the renumbered destination column, it may contain jobs that were added by another client and don't yet exist in the localjobsstate. The currentjs.map(...)logic only updates existing records and will silently drop these new jobs.Align this with the robust merge pattern already used in
onDragEnd:
src/pages/Board.tsx#L138-L140: Replace themaplogic with afilterand spread approach to append any newly discovered jobs.src/pages/TableView.tsx#L157-L157: Apply the samefilterand spread merge logic here.♻️ Proposed refactor for merging jobs
For both files, replace the
onChangelogic with the following:- onChange={(updated) => - setJobs((js) => js.map((x) => updated.find((u) => u.id === x.id) ?? x)) - } + onChange={(updated) => { + const ids = new Set(updated.map((u) => u.id)); + setJobs((js) => [...js.filter((j) => !ids.has(j.id)), ...updated]); + }}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/pages/Board.tsx` around lines 138 - 140, Update the onChange merge logic in src/pages/Board.tsx lines 138-140 and src/pages/TableView.tsx line 157 to use the established filter-and-spread pattern from onDragEnd: update matching existing jobs and append returned jobs whose IDs are not already present, preserving newly discovered records from the backend.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/ARCHITECTURE.md`:
- Line 91: Update the “Logout” step in the architecture flow to state that it
clears the auth cookie in the response, rather than implying server-side session
deletion or JWT revocation. Keep the description concise and consistent with the
behavior of clearSession.
In `@public/_headers`:
- Around line 1-7: Apply the security headers defined in public/_headers to
Worker-generated /api/* responses by adding them in the Worker response path,
reusing the same policy values for CSP and related headers. Ensure all relevant
Worker responses receive the shared headers rather than limiting the policy to
static assets.
In `@worker/api.test.ts`:
- Around line 352-371: Update the “excludes archived jobs from every stat” test
to include an active job with a response, then assert the non-zero responseRate
expected when the archived job is excluded from the denominator. Add archived
interview/offer fixtures if needed to verify the remaining statistics also
exclude archived jobs.
- Around line 114-130: Update the registration flow exercised by the weak
JWT_SECRET test to validate session configuration before inserting the user,
ensuring invalid secrets return 500 without committing an account. Extend the
test around the registration request to verify no user row remains for each
weak-secret case, while preserving the existing 500 response assertion.
In `@worker/lib/password.ts`:
- Around line 13-25: The password hashing default in ITERATIONS is too weak for
production. Change the production default to the stated secure PBKDF2 guidance,
and make the 50,000-iteration free-tier compromise an explicit deployment opt-in
through the existing configuration mechanism; ensure hashing and verification
continue using the iteration count stored in each hash.
- Around line 77-84: Update dummyVerify and the login verification flow to use
one fixed verification budget across missing users, PBKDF2 hashes, and legacy $2
bcrypt hashes. Ensure bcrypt records do not perform an additional longer
bcrypt.compare path; route them through the same established dummy verification
timing while preserving the false result for unknown users.
In `@worker/routes/auth.ts`:
- Around line 76-80: Update both password update flows, including the
legacy-login upgrade near isLegacyHash and the password-change flow, to use
compare-and-set updates: add the verified hash snapshot as an additional
password_hash predicate and bind it accordingly. Check the update result for
zero affected rows and handle that as a concurrent modification rather than
reporting success.
- Around line 34-51: Validate the JWT signing configuration at the start of the
registration handler, before hashPassword and the users INSERT, and return the
existing configuration error response when invalid. Ensure createSession
receives only validated configuration so failed registrations do not persist an
account; keep the UNIQUE constraint handling unchanged.
---
Nitpick comments:
In `@src/pages/Board.tsx`:
- Around line 138-140: Update the onChange merge logic in src/pages/Board.tsx
lines 138-140 and src/pages/TableView.tsx line 157 to use the established
filter-and-spread pattern from onDragEnd: update matching existing jobs and
append returned jobs whose IDs are not already present, preserving newly
discovered records from the backend.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 94555490-b77f-4050-9d2b-f7851c857101
⛔ Files ignored due to path filters (1)
public/fonts/space-grotesk-var.woff2is excluded by!**/*.woff2
📒 Files selected for processing (25)
.github/workflows/ci.ymlCHANGELOG.mdREADME.mddocs/ARCHITECTURE.mddocs/CONFIG.mddocs/SETUP.mdindex.htmlmigrations/0003_token_version.sqlpublic/_headersshared/types.tssrc/components/JobDrawer.tsxsrc/index.csssrc/pages/Board.tsxsrc/pages/TableView.tsxworker/api.test.tsworker/env.d.tsworker/index.tsworker/lib/auth.tsworker/lib/password.tsworker/lib/ratelimit.tsworker/routes/auth.tsworker/routes/items.tsworker/routes/jobs.tsworker/routes/stats.tswrangler.jsonc
| /* | ||
| Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none' | ||
| X-Content-Type-Options: nosniff | ||
| Referrer-Policy: strict-origin-when-cross-origin | ||
| X-Frame-Options: DENY | ||
| Strict-Transport-Security: max-age=31536000; includeSubDomains | ||
| Permissions-Policy: geolocation=(), microphone=(), camera=(), interest-cohort=() |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Apply these security headers to Worker-generated responses too.
public/_headers only covers static asset responses; /api/* is handled by Worker code, so those responses won't inherit this CSP/header policy. Add the shared headers in the Worker response path as well, or document that this policy is static-assets only.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@public/_headers` around lines 1 - 7, Apply the security headers defined in
public/_headers to Worker-generated /api/* responses by adding them in the
Worker response path, reusing the same policy values for CSP and related
headers. Ensure all relevant Worker responses receive the shared headers rather
than limiting the policy to static assets.
| // 50k is a deliberate compromise, not an oversight. The Workers Free plan caps | ||
| // CPU at 10ms per request, and no password hash is both OWASP-grade and that | ||
| // cheap — slowness is the point of a password hash. Measured cost per login: | ||
| // | ||
| // bcrypt cost-10 (previous) 74ms — 7x over the free cap, login 1102s | ||
| // PBKDF2 600k (OWASP 2024) 72ms — same problem | ||
| // PBKDF2 50k 7ms — fits, at ~12x below OWASP guidance | ||
| // | ||
| // We optimise for "anyone can fork this and deploy it for free", and accept the | ||
| // weaker factor. The iteration count is stored inside each hash, so raising it | ||
| // later costs nothing: verification reads the stored value, and any account can | ||
| // be re-hashed on its next successful login. | ||
| const ITERATIONS = 50_000; |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Do not make the below-guidance work factor the production default.
At 50,000 iterations, a stolen password database is approximately 12× cheaper to attack than the stated guidance. Rate limiting cannot mitigate offline cracking. Use a secure default and make the free-tier compromise an explicit deployment opt-in.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@worker/lib/password.ts` around lines 13 - 25, The password hashing default in
ITERATIONS is too weak for production. Change the production default to the
stated secure PBKDF2 guidance, and make the 50,000-iteration free-tier
compromise an explicit deployment opt-in through the existing configuration
mechanism; ensure hashing and verification continue using the iteration count
stored in each hash.
| // A precomputed hash of an unguessable value. Verifying against it lets login | ||
| // spend the same CPU on an unknown email as on a real one, so response time | ||
| // stops revealing which addresses are registered. | ||
| let dummyHash: string | undefined; | ||
| export async function dummyVerify(password: string): Promise<false> { | ||
| dummyHash ??= await hashPassword(crypto.randomUUID()); | ||
| await verifyPassword(password, dummyHash); | ||
| return false; |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- worker/lib/password.ts (outline) ---'
ast-grep outline worker/lib/password.ts --view expanded || true
echo
echo '--- worker/lib/password.ts lines 1-220 ---'
cat -n worker/lib/password.ts | sed -n '1,220p'
echo
echo '--- search for bcrypt/legacy password references ---'
rg -n --hidden -S 'bcrypt|legacy|dummyVerify|verifyPassword|hashPassword|PBKDF2|PBKDF2|pbkdf2' worker lib . || trueRepository: Patel230/tracker
Length of output: 14135
🏁 Script executed:
#!/bin/bash
set -euo pipefail
cat -n worker/lib/password.ts | sed -n '1,180p'Repository: Patel230/tracker
Length of output: 4483
Equalize the bcrypt fallback path too. Missing users already hit the PBKDF2 dummy hash, but accounts that still have $2 hashes spend far longer in bcrypt.compare, so login timing can reveal which users have not been migrated. Use one fixed verification budget for missing, PBKDF2, and bcrypt records.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@worker/lib/password.ts` around lines 77 - 84, Update dummyVerify and the
login verification flow to use one fixed verification budget across missing
users, PBKDF2 hashes, and legacy $2 bcrypt hashes. Ensure bcrypt records do not
perform an additional longer bcrypt.compare path; route them through the same
established dummy verification timing while preserving the false result for
unknown users.
| // Upgrade the account off bcrypt the first time it logs in successfully. | ||
| if (isLegacyHash(user.password_hash)) { | ||
| await c.env.DB.prepare("UPDATE users SET password_hash = ? WHERE id = ?") | ||
| .bind(await hashPassword(password), user.id) | ||
| .run(); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Make password writes conditional on the hash that was verified.
Both flows verify a snapshot and then update unconditionally. A legacy login can restore an old password over a concurrent password change, while two password-change requests can both succeed. Add AND password_hash = ? to each update and handle a missing returned row as a concurrent modification.
Proposed compare-and-set updates
-UPDATE users SET password_hash = ? WHERE id = ?
+UPDATE users SET password_hash = ? WHERE id = ? AND password_hash = ?-UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ? RETURNING token_version
+UPDATE users
+SET password_hash = ?, token_version = token_version + 1
+WHERE id = ? AND password_hash = ?
+RETURNING token_versionAlso applies to: 109-127
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@worker/routes/auth.ts` around lines 76 - 80, Update both password update
flows, including the legacy-login upgrade near isLegacyHash and the
password-change flow, to use compare-and-set updates: add the verified hash
snapshot as an additional password_hash predicate and bind it accordingly. Check
the update result for zero affected rows and handle that as a concurrent
modification rather than reporting success.
…eaders CodeRabbit caught a real bug I had worked around instead of fixing. - Registration hashed the password and INSERTed the user, and only then threw on a bad JWT_SECRET when signing the session. That left an orphan account behind, so the retry returned 409 for an account nobody could log into. My own test comment described this and routed around it with distinct emails. Validate the session config in middleware, before any handler can write, and assert no row survives. - The archived-stats test asserted responseRate === 0, which is true with or without the fix when nothing has responded — a vacuous assertion. Give it a responded job so the denominator actually matters: 1/1 with the fix, 1/2 without. - public/_headers only decorates static assets, so /api/* responses carried no headers at all. Add them in the Worker path. - PBKDF2_ITERATIONS is now configurable. The 50k default stays, because free-tier deployability is the point, but a Paid deployment can run OWASP's 600k and existing hashes upgrade on next login instead of needing a reset. - ARCHITECTURE.md overstated logout: it clears the cookie, it does not revoke the JWT. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deep review of the full codebase, then one commit per fix. Test count goes from 10 to 20; typecheck, tests and build all pass, and the whole flow was smoke-tested against
wrangler dev.Two changes here will take production down if shipped in the wrong order:
wrangler secret put JWT_SECRET— must be ≥32 chars (the current one may be shorter; the app now refuses to sign sessions below that floor)npm run db:migrate:remote— addsusers.token_version, whichauthenticate()reads on every requestnpm run deployMigration before deploy, or every authenticated request 500s on the missing column.
The two that actually mattered
A missing
JWT_SECRETlet anyone forge any session.TextEncoder().encode(undefined)returns a zero-length key, andjosewill happily sign and verify HS256 with it. A fork deployed without the secret set would have accepted a forged cookie for anyuser_id— no error, no crash, app looks fine. Now fails closed.Login was broken on the Workers Free plan.
bcryptjsis pure JS and costs ~74ms CPU; Free caps CPU at 10ms/request, so login and register hard-fail with a 1102 there. It only worked for us because our account is Paid — i.e. it was broken for exactly the students meant to fork it.Worth stating plainly: switching to PBKDF2 does not magically fix this. No secure password hash fits in 10ms — being slow is the point of one (bcrypt-10: 74ms, PBKDF2 600k/OWASP: 72ms, PBKDF2 100k: 13ms). We deliberately chose free-tier deployability over hash strength and run 50k iterations (~6ms), ~12x below OWASP guidance. The trade-off is documented at the constant, and iterations are stored per-hash so the factor can be raised later without invalidating anything.
Everything else
token_versionin the JWT, bumped on change, checked per request. The old test passed because it checked the old password failed, not the old session.javascript:/data:URLs reached an<a href>.url()only runsnew URL(), which accepts both.contact.linkedinhad no URL validation at all. Guarded on write and render./api/statsdisagreed with itselfarchived, so archiving a job removed it from the funnel while it still dragged on the response rate.PATCH /jobs/:id, which never touchessort_order. Now routes through/move./authwas public only because it sat aboveapi.use("*", requireAuth). Now split into explicit public/protected routers.npm run build.🤖 Generated with Claude Code
Summary by CodeRabbit
Security
Bug Fixes
Documentation
Chores