Penumbra Tech
[API Guide]

Every endpoint, documented.

A reference for every endpoint this site exposes, plus the REST principles the API follows. Every route lives under /api on https://penumbra-tech.com.

Contents

The stack

  • Backend: Node.js + Express, MySQL via mysql2/promise
  • Sessions: express-session with express-mysql-session store — cookies are HttpOnly + 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:

MethodUsed forSafeIdempotent
GETReading a resource or listing a collectionYes (no side effects)Yes
POSTCreating a new resource or invoking an actionNoNo (creates a new row each time)
PUTUpdating fields on an existing resourceNoYes (same body → same end state)
DELETERemoving a resourceNoYes (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

CodeMeaningWhen you'll see it
200OKSuccessful read or update; body has the resource
201CreatedPOST that created a new resource; body has the new row
400Bad RequestValidation failed (missing field, malformed input)
401UnauthorizedNo session cookie or session expired
403ForbiddenAuthenticated but lacking required role or ownership
404Not FoundResource doesn't exist or isn't yours (we return 404 either way to avoid leaking which IDs exist)
409Conflicte.g. registering an email that's already taken
413Payload Too LargeFile upload exceeded the per-tier size limit
429Too Many RequestsRate limit hit; RateLimit-* headers tell you when you can retry
500Internal Server ErrorUnexpected 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.

RoleCapabilities
userDefault. 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/login and /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

MethodPathAuthDescription
GET/api/messagespublicReturns the seeded messages from MySQL

Auth

MethodPathAuthDescription
POST/api/auth/registerpublicCreate an account and log in. Body: { email, password }
POST/api/auth/loginpublicLog into an existing account. Body: { email, password }
POST/api/auth/logoutpublicDestroy the current session
GET/api/auth/mepublicReturns the current user (or null) including role
DELETE/api/auth/merequiredPermanently delete the caller's account and all their data
POST/api/auth/forgot-passwordpublicSend a reset email if the address exists. Always returns the same generic response so attackers can't enumerate accounts.
POST/api/auth/reset-passwordpublicConsume a reset token and set a new password. Body: { token, password }

Notes: QuickNotes

MethodPathAuthDescription
GET/api/notesrequiredList the caller's notes, newest-edited first
GET/api/notes/:idrequiredOne note (404 if it belongs to another user)
POST/api/notesrequiredCreate a new note. Body: { title, body }
PUT/api/notes/:idrequiredUpdate title and/or body
DELETE/api/notes/:idrequiredDelete the note

Boards: MoodBoard

MethodPathAuthDescription
GET/api/boardsrequiredList the caller's boards
POST/api/boardsrequiredCreate a new board. Body: { name }
GET/api/boards/:tokenpublicFetch 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/:tokenownerRename a board. Body: { name }
DELETE/api/boards/:tokenownerDelete the board (cascades to images)
POST/api/boards/:token/imagesownerAdd an image URL. Body: { url }. Validated http/https only.
DELETE/api/boards/:token/images/:imageIdownerRemove an image

Tasks: TaskTrackr

MethodPathAuthDescription
GET/api/tasksrequiredList the caller's tasks. Open tasks first, then by due date, then by recently updated.
GET/api/tasks/:idrequiredFetch one task
POST/api/tasksrequiredCreate a task. Body: { title, description, due_date, category }
PUT/api/tasks/:idrequiredPartial update; send only the fields that changed (used by auto-save)
DELETE/api/tasks/:idrequiredDelete the task. Cascades to task_updates rows AND unlinks any uploaded media files from disk.

Task updates: progress posts

MethodPathAuthDescription
GET/api/tasks/:taskId/updatesownerList updates for a task, newest first
POST/api/tasks/:taskId/updatesownerMultipart 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/:idownerDelete an update and unlink its media file
GET/api/tasks/:taskId/updates/:id/mediaownerStream 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.

MethodPathAuthDescription
GET/api/payments/configrequiredReturns the Stripe publishable key (safe to expose) and a configured flag the UI uses to detect missing env vars.
GET/api/payments/statusrequiredReturns { status, current_period_end, cancel_at_period_end, is_active } for the caller. status="none" if they have no record.
POST/api/payments/subscriberequiredCreates 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/cancelrequiredSets 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/webhookStripe-signedStripe-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

MethodPathAuthDescription
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-resetadmin+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/rolesuper_adminAssign 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=50 with a next_cursor in 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.