Back to all projects

Water My Plants

React · Vite · Express · PostgreSQL · Knex · JWT · Vercel

Reviving a 2021 team project whose host had shut down. The hard part was not the migration. It was reading the original code carefully enough to notice that the API had never checked who was calling it.

Live demoCode

Problem

Water My Plants was a 2021 team project split across two repositories: a Create React App frontend on Netlify and an Express and Knex API on Heroku. Heroku retired its free tier in November 2022 and took the API and its database with it. The frontend stayed up and could no longer log anyone in.

Reviving it meant deciding what reviving it meant, and reading the code answered that question differently than I expected. The deployment was not the reason it did not work. It had not fully worked while it was hosted either.

The authentication middleware had been written and was never applied to a single route, so the entire API was public. GET /api/users returned every row of the users table, bcrypt hashes included, to any caller with no token at all. The plants table had no owner column, so every account shared one global plant list. Several handlers referenced identifiers that existed nowhere in the project, left behind from an unrelated scaffold. The seed inserted a plaintext password, so the account it created could never log in. The frontend deleted plants from local state without ever calling the API, so they came back on the next refresh.

Constraints

One project, one deployment. Two repositories with two hosts and a hardcoded cross-origin URL between them is what made the original fragile enough that a single provider's pricing change ended it.

It is a public demo where anyone can sign up with any username. That makes isolation between accounts the property that decides whether the thing is safe to leave running, not a feature to add later.

The API runs as a serverless function and the database scales to zero when idle. There is no long-lived process to hold state in, and the first request after a quiet spell pays a wake-up cost that the interface has to account for.

The 2021 client sends its token as a bare Authorization header value rather than a Bearer credential. Rewriting the API is fine; silently breaking a client that behaves that way is not.

Approach

Audit before rewriting. I read the original front to back and wrote down every defect as a separate line rather than starting fresh, because a rewrite that does not know what was broken tends to reproduce it. That list is in the repository's README, which is the honest place for it.

Consolidate the two halves into one project so the frontend and API share an origin. The old client had its Heroku URL hardcoded in four separate files; a same-origin /api makes that entire category of bug unrepresentable.

Push ownership down to the data layer instead of enforcing it in handlers. A route that forgets a check is a bug waiting to be written, so the plants model simply does not offer an unscoped query. There is no 'find all plants' function to call by accident.

Implementation

Express 5 and Knex 3 against Postgres, with a Vite and React 19 frontend, deployed as a single Vercel project. The serverless entry point exports the Express app directly, since an Express app is already a request handler; vercel.json routes /api/* to it and sends everything else to index.html so client-side routing survives a hard refresh.

In development the Vite server proxies /api to the local API, so the browser talks to a same-origin /api exactly as it does in production. No CORS configuration, no environment-specific base URL, and no class of bug that only appears in one of the two.

The schema carries the rules the application used to be trusted with. Plants have a user_id foreign key with cascading delete and an index; usernames are unique at the database level, so a duplicate registration surfaces as a Postgres unique violation translated to a 409 rather than a race the application layer has to win.

Every plant route mounts the auth gate at the router level rather than per handler, so a route added later cannot be added unprotected. The gate verifies the token, checks it against a revocation list, and confirms the account still exists, since a token outliving its user is otherwise perfectly valid.

The users model selects an explicit list of public columns, which is what keeps password hashes out of responses structurally rather than by remembering to delete a key. The single function that does return the hash is named for it and documented as unsafe to serialize.

Plant validation rebuilds a clean object from only the fields it checked, so a caller cannot smuggle a user_id through the request body and reassign someone else's plant. Logging out writes the still-valid token to a revocation table and opportunistically prunes rows that have passed their own expiry, so the table cannot grow forever.

On the client, one axios instance attaches the token on the way out and, on the way back, clears the session and returns to the login screen on a 401, so a revoked or expired token cannot leave a half-authenticated interface on screen.

Challenges

Logging out and immediately back in locked the account. Tokens were fully determined by the user and the issued-at claim, which has second resolution, so a second login inside the same second produced a byte-identical token, one the revocation list had just recorded. The account stayed locked out until the clock ticked over. Every token now carries a random unique identifier, which is a one-line fix for a bug that only exists inside a one-second window and would have been miserable to diagnose from a user report.

Deleting your own account bounced you to a sign-in form for an account that no longer existed. Clearing the session re-rendered the profile route under its auth guard, whose redirect ran afterward and sent you to /login. Account deletion is terminal, so it now leaves through a hard navigation to the homepage rather than a client-side transition that races the guard.

The production database connection was quietly on course to stop verifying certificates. node-postgres is moving `sslmode=require` toward libpq's meaning, where it asks for encryption without validating the certificate at all. The connection string looked correct and would have kept looking correct while the guarantee underneath it changed, so verification is now set explicitly in the Knex config rather than inferred from a URL.

The database-free unit tests were not database-free. The integration suite was being picked up by the default test run, so the fast checks silently required a live Postgres, which is exactly the kind of thing that makes people stop running tests locally.

Solution

The two suites are now genuinely separate. 26 unit tests cover routing, request validation, the auth gate, and token issuance with no database at all. 18 integration tests run against real Postgres, truncating between cases, and cover registration, login, the full plant lifecycle, profile updates, logout revocation, and cross-user isolation.

The tests that matter most are the ones asserting the negatives: that one account cannot list or modify another's plants, that a user_id in the request body is ignored, that a password is never stored or returned in plaintext, that a stack trace never reaches the client in production, and that logging out and back in within the same second works. Each of those corresponds to something that was actually wrong, which is a better source of test cases than imagining what might break.

Outcome

The app works again, as one deployment instead of two, and it works correctly in ways the original did not: accounts only ever see their own plants, deleting an account takes its plants with it, and deleting a plant deletes it.

The rebuild is documented as an audit rather than a release. The README lists every defect that was found and fixed, in the security, broken-code, and cleanup categories, so the interesting part of the project is legible without cloning it.

The stack moved forward with it (React 17 to 19, Create React App to Vite, Express 4 to 5, React Router 5 to 7, Jest to Vitest), but the upgrade is the least significant thing that happened here.

What I Learned

Middleware that is written but never mounted is worse than middleware that does not exist. The file was there, correctly implemented, imported in the router, and applied to nothing. It read as a project with authentication, which is precisely why nobody noticed it did not have any. Security review has to follow what actually executes, not what the file tree implies.

The most reliable place to enforce a rule is the layer that cannot be skipped. Ownership checks in route handlers depend on every current and future handler remembering; a foreign key and a model with no unscoped query do not depend on anyone remembering anything. I would rather make the mistake unrepresentable than catch it in review.

Reading old work carefully is a real technique, not just diligence. Nearly everything worth fixing here was invisible from the outside. The app looked like it was down for hosting reasons, and if I had migrated it without reading it, it would have come back up and still been wrong.