The stack
- Backend: Node.js + Express, MySQL via
mysql2/promise - Sessions:
express-sessionwithexpress-mysql-sessionstore — cookies areHttpOnly + SameSite=Lax + Secure(over HTTPS) - Frontend: React + Vite, react-router-dom for SPA routing
- Hosting: single AWS EC2 Ubuntu instance, nginx in front, PM2 managing the Node process, Let's Encrypt cert via DuckDNS
REST conventions
The API uses HTTP method semantics to convey intent. Each method has specific properties around safety, idempotency, and cacheability:
| Method | Used for | Safe | Idempotent |
|---|---|---|---|
| GET | Reading a resource or listing a collection | Yes (no side effects) | Yes |
| POST | Creating a new resource or invoking an action | No | No (creates a new row each time) |
| PUT | Updating fields on an existing resource | No | Yes (same body → same end state) |
| DELETE | Removing a resource | No | Yes (gone is gone) |
Idempotent means calling N times produces the same end-state as calling once — important so a network blip retry can't compound damage. Only POST is non-idempotent here (you really do create a new note each time).
Status codes
| Code | Meaning | When you'll see it |
|---|---|---|
| 200 | OK | Successful read or update; body has the resource |
| 201 | Created | POST that created a new resource; body has the new row |
| 400 | Bad Request | Validation failed (missing field, malformed input) |
| 401 | Unauthorized | No session cookie or session expired |
| 403 | Forbidden | Authenticated but lacking required role or ownership |
| 404 | Not Found | Resource doesn't exist or isn't yours (we return 404 either way to avoid leaking which IDs exist) |
| 409 | Conflict | e.g. registering an email that's already taken |
| 413 | Payload Too Large | File upload exceeded the per-tier size limit |
| 429 | Too Many Requests | Rate limit hit; RateLimit-* headers tell you when you can retry |
| 500 | Internal Server Error | Unexpected server-side bug; details are logged but never returned to the client |
Authentication and sessions
The API uses cookie-based session authentication. Successful POST /api/auth/login or POST /api/auth/register sets a cookie named hello.sid. That cookie is HttpOnly (so JavaScript can't read it, defending against XSS-based theft), SameSite=Lax (defending against most CSRF), and Secure (only sent over HTTPS).
Every request after login automatically carries the cookie. Browsers handle this; from fetch() include credentials: 'include'. The session itself lives in the sessions table in MySQL — destroying the row (via logout) immediately revokes the session.
On login the session ID is regenerated (defends against fixation), and login always runs a bcrypt comparison whether or not the email exists (defends against timing-based account enumeration).
Password reset uses one-hour, single-use tokens. Only their sha256 hash is stored — the plaintext only exists in the email sent to the user.
User roles
The users.role column is a four-tier hierarchy. Each tier inherits everything below it. The role is re-read from the database on every gated request — never cached in the session — so a demotion takes effect immediately.
| Role | Capabilities |
|---|---|
user | Default. Use the apps. Image uploads on task progress posts, capped at 10 MB. |
premium | + Video uploads on task progress posts, capped at 100 MB. |
admin | + Customer Service tool: search users, send manual password resets. |
super_admin | + Assign any role to any other user. Cannot change own role (backend blocks self-modification so a super admin can't accidentally lock everyone out). |
Rate limits
Three tiers of rate limiting, all keyed by client IP (resolved from the nginx X-Forwarded-For header):
- Global: 100 requests/minute/IP across all
/api/*routes - Auth mutations: 10 requests / 15 minutes / IP for
/api/auth/loginand/api/auth/register - Forgot-password: 5 requests / hour / IP for
/api/auth/forgot-password(limits email spam potential) - Admin routes: 30 requests/minute/IP for
/api/admin/*
Every response includes RateLimit-Policy, RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers so clients know their remaining budget without trial and error.
Endpoints
Messages: the original Hello World demo
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/messages | public | Returns the seeded messages from MySQL |
Auth
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/auth/register | public | Create an account and log in. Body: { email, password } |
| POST | /api/auth/login | public | Log into an existing account. Body: { email, password } |
| POST | /api/auth/logout | public | Destroy the current session |
| GET | /api/auth/me | public | Returns the current user (or null) including role |
| DELETE | /api/auth/me | required | Permanently delete the caller's account and all their data |
| POST | /api/auth/forgot-password | public | Send a reset email if the address exists. Always returns the same generic response so attackers can't enumerate accounts. |
| POST | /api/auth/reset-password | public | Consume a reset token and set a new password. Body: { token, password } |
Notes: QuickNotes
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/notes | required | List the caller's notes, newest-edited first |
| GET | /api/notes/:id | required | One note (404 if it belongs to another user) |
| POST | /api/notes | required | Create a new note. Body: { title, body } |
| PUT | /api/notes/:id | required | Update title and/or body |
| DELETE | /api/notes/:id | required | Delete the note |
Boards: MoodBoard
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/boards | required | List the caller's boards |
| POST | /api/boards | required | Create a new board. Body: { name } |
| GET | /api/boards/:token | public | Fetch one board + its images. Public so a share link works without login. Response includes can_edit:true when the caller is the owner. |
| PUT | /api/boards/:token | owner | Rename a board. Body: { name } |
| DELETE | /api/boards/:token | owner | Delete the board (cascades to images) |
| POST | /api/boards/:token/images | owner | Add an image URL. Body: { url }. Validated http/https only. |
| DELETE | /api/boards/:token/images/:imageId | owner | Remove an image |
Tasks: TaskTrackr
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/tasks | required | List the caller's tasks. Open tasks first, then by due date, then by recently updated. |
| GET | /api/tasks/:id | required | Fetch one task |
| POST | /api/tasks | required | Create a task. Body: { title, description, due_date, category } |
| PUT | /api/tasks/:id | required | Partial update; send only the fields that changed (used by auto-save) |
| DELETE | /api/tasks/:id | required | Delete the task. Cascades to task_updates rows AND unlinks any uploaded media files from disk. |
Task updates: progress posts
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/tasks/:taskId/updates | owner | List updates for a task, newest first |
| POST | /api/tasks/:taskId/updates | owner | Multipart form with optional body (text) and optional media (file). Image-only for free users (10 MB cap); image + video for premium+ (100 MB cap). At least one of body or media required. |
| DELETE | /api/tasks/:taskId/updates/:id | owner | Delete an update and unlink its media file |
| GET | /api/tasks/:taskId/updates/:id/media | owner | Stream the uploaded media bytes. Auth-checked on every request, so URLs are not capabilities. |
Payments: Stripe subscriptions
Subscription billing for the Premium tier, integrated with Stripe. The frontend uses Stripe Elements (Payment Element) so card details are sent directly from the browser to Stripe — they never touch this server, which keeps PCI compliance scope at SAQ-A. The role flip from user to premium happens in the webhook handler, not in the frontend, because the webhook is the only trustworthy "did the payment actually clear" signal.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/payments/config | required | Returns the Stripe publishable key (safe to expose) and a configured flag the UI uses to detect missing env vars. |
| GET | /api/payments/status | required | Returns { status, current_period_end, cancel_at_period_end, is_active } for the caller. status="none" if they have no record. |
| POST | /api/payments/subscribe | required | Creates a Stripe customer (if needed) and a subscription with payment_behavior=default_incomplete. Returns { client_secret } for the frontend Payment Element to confirm. 400 if you already have an active subscription. |
| POST | /api/payments/cancel | required | Sets cancel_at_period_end on the subscription. Premium access continues until the period ends; the webhook flips the role back to user when Stripe finally terminates. |
| POST | /api/payments/webhook | Stripe-signed | Stripe-only endpoint. Verifies the signature against STRIPE_WEBHOOK_SECRET, dedupes by event id (stripe_events table), and updates user role on customer.subscription.* events. Returns 5xx on processing failure so Stripe retries. |
The webhook endpoint is registered with express.raw() in server.js BEFORE the global express.json() middleware, because Stripe signs the raw request bytes — pre-parsed JSON would fail signature verification.
Admin: Customer Service
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/admin/users/search?q=... | admin+ | Substring search on user emails. Returns up to 50 users with role, created_at, last_login_at. Logged to admin_actions audit table. |
| POST | /api/admin/users/:id/send-password-reset | admin+ | Trigger a reset email to a target user. Goes through the same code path as self-service forgot-password. Logged. |
| PUT | /api/admin/users/:id/role | super_admin | Assign a role (user / premium / admin / super_admin) to another user. Self-modification blocked. Logged with from/to detail. |
Where this API diverges from textbook REST
Three honest concessions worth knowing about:
- PUT does partial updates, which is technically PATCH semantics. Strict REST says PUT replaces the whole resource. Our PUTs only modify fields present in the body, leaving others untouched. Pragmatic for our auto-save flows; not strictly conformant.
- DELETE on a non-existent resource returns 404 instead of 204. This breaks the strict "DELETE is idempotent" contract slightly. We picked 404 for honesty — if the client thought it existed and it doesn't, that's worth surfacing.
- No pagination. List endpoints return everything for the caller. Fine for personal-data scale (a user has a few dozen tasks); breaks at scale. The right pattern is
?cursor=...&limit=50with anext_cursorin the response. Easy to add when needed.
Other things a strict reviewer might call out: no API versioning (no /api/v1/ prefix), no formal OpenAPI/Swagger spec, no ETag-based optimistic locking for concurrent edits, no idempotency keys for retried POSTs. All reasonable to skip at class-project scope; all worth knowing exist.