Skip to content

Latest commit

 

History

History
300 lines (246 loc) · 15 KB

File metadata and controls

300 lines (246 loc) · 15 KB

RP2 — Ross Projective

The backend and web portal for the online arm of the Ross Mathematics Program. The name plays on "projective Ross" being "on line": the real projective plane ℝℙ² is built from lines through the origin, so "on line" is literally what its points are.

Product context — the pilot's shape, staffing model, timeline, and application questions — lives in CLAUDE.md. This document is about how the code works and why it is the way it is.

What's in the box

A single-page React app, a Fastify HTTP API, and a SQLite database, delivered as a monorepo. The applicant portal is the priority-one deliverable: an applicant creates a passwordless account, fills a multi-section application with autosave, uploads a transcript, and submits it. An admin review flow is next.

┌────────────────────┐        ┌──────────────────────────────┐
│  React SPA (Vite)  │  ─────▶│  Fastify + better-sqlite3    │
│  served by Caddy   │  HTTPS │  Sessions in signed cookies  │
└────────────────────┘        └──────┬───────────────┬───────┘
                                     │               │
                              SQLite (WAL)      S3 (transcripts,
                              on EBS volume     aid docs, backups
                              + Litestream ──▶  via Litestream)
                                     │
                     ┌───────────────┴────────────┐
                     │ External integrations       │
                     │  - AWS SES (email)          │
                     │  - AWS S3 (uploads)         │
                     └────────────────────────────┘

Repo layout

pnpm workspace, three packages:

rp2/
├── backend/       # Fastify + Drizzle + better-sqlite3
│   ├── src/
│   │   ├── auth/                # magic-link + session helpers
│   │   ├── db/                  # schema, client, migrations runner
│   │   ├── integrations/        # email/{ses}, storage/{local,s3}
│   │   ├── routes/              # thin HTTP handlers
│   │   ├── services/            # business logic
│   │   └── server.ts
│   └── migrations/              # generated by drizzle-kit
├── frontend/      # React 18 + Vite + TanStack Router/Query
│   ├── src/
│   │   ├── api/                 # typed HTTP client
│   │   ├── components/          # PageFrame, UserMenu, …
│   │   ├── features/applicant/  # applicant-form UI
│   │   ├── routes/              # route trees (TanStack)
│   │   └── styles/              # tokens, fonts, index.css
│   └── public/fonts/cm/         # vendored Computer Modern .woff
├── shared/        # Zod schemas + questions.ts — imported by both sides
├── ops/           # Deploy configs (Caddy, systemd, Litestream, IAM)
│   └── DEPLOY.md  # Production runbook
└── planning/      # Source-of-truth product docs (gitignored)

Getting started

Requires Node 20+ and pnpm 9+ (corepack enable && corepack prepare pnpm@9 --activate).

pnpm install
cp backend/.env.example backend/.env
$EDITOR backend/.env                        # set SESSION_SECRET
pnpm --filter @rp2/backend db:migrate       # applies migrations to dev.sqlite

./scripts/backend.sh                        # in one terminal
./scripts/frontend.sh                       # in another

Visit http://localhost:5173. In dev, magic-link emails are printed to the backend terminal instead of sent through SES.

Technical decisions

Data — SQLite, additive migrations, and a hybrid response model

The database is SQLite in WAL mode, wrapped by Drizzle. For a pilot with at most low-thousands of applicants, Postgres would add operational overhead without providing value; SQLite gives us serializable transactions, no network hop, and a database that's a single file we can back up with Litestream continuously to S3. All queries are synchronous (better-sqlite3 is a synchronous binding), which is fine when the DB is on the same host and every query returns in microseconds.

Migrations are generated by drizzle-kit from backend/src/db/schema.ts and applied on boot. Migrations are additive: once a migration has been applied to any environment, it is never edited.

The applicant's answers live in two places:

  • application_response(application_id, question_key, value) — free-text and select-style answers, with value as JSON text. One row per answered question, keyed by a stable identifier from questions.ts. This is the source of truth for the applicant's form state, and it can absorb new prompts without a schema change.
  • Structured "denormalized" tablesapplicant_profile, application_availability, application_course_preference, application_file. These exist for two reasons: admins filter and sort on these fields constantly (grade, timezone, first choice course, weekly availability intersection), and some of them have meaning beyond a single application (an applicant_profile outlives the application if the student is admitted). On every response save, upsertResponses syncs the relevant denorm tables from the responses.

Some data has to live outside the responses table entirely: application_file because file blobs can't sit in a JSON column, and user.dob because it's per-person, not per-application (see the COPPA notes below).

Authentication — passwordless magic links, scanner-safe

The applicant pool is high-school students and their families. Password-based auth means password resets, "did I make an account?" support tickets, and credential-stuffing risk from breached password reuse. So auth is passwordless magic-link: enter your email, click a link, you're in.

Two subtleties matter.

Scanner-safe interstitial. Enterprise inbox scanners (Microsoft Defender Safe Links, Mimecast, Proofpoint URL Defense, Gmail hover previews) prefetch every URL in incoming mail. A naive "click-link-consume-token-and-log-you-in" design burns the token during that prefetch, and the human then gets a "link already used" error. Instead:

  1. The email link points at GET /api/auth/verify?token=…, which validates but does not consume the token, then 302s to an SPA page /auth/complete?token=….
  2. The SPA page shows "You're about to sign in as ada@…, [Continue]" and only on the button click does it POST to /api/auth/complete, which is the sole endpoint that actually consumes the token and mints a session.
  3. Scanners GET but don't POST. Human sees one extra click.

This is the same pattern Slack, Vercel, Notion, and Auth0 use. Implementation lives in backend/src/auth/magic-link.ts and frontend/src/routes/auth.tsx.

COPPA age gate. The Children's Online Privacy Protection Act imposes real compliance burden for users under 13. To avoid it, we prohibit under-13 sign-ups. Date of birth is collected at the interstitial — the first moment we know the user actually clicked their link — and stored on the user record. If the age is below 13 we consume the token (so it can't be retried with a different DOB from the same email) and delete the user row entirely so we retain no personal information about the rejected minor. Returning users skip the DOB prompt.

Sessions themselves are HttpOnly, Secure, SameSite=Lax signed cookies managed by @fastify/cookie. Session state lives in a session table on the server, not just in the cookie signature, so we can revoke.

Uploads — presigned PUTs, dev/prod parity through a driver interface

Applicant transcripts and financial-aid documents go direct to object storage via presigned PUT URLs — the app server never sees the file bytes, which matters when someone submits a 25 MB transcript on flaky hotel wifi.

There are two storage drivers behind a common interface:

  • local (backend/src/integrations/storage/local.ts) — used in dev. Presigned URL is /api/uploads/put?…&sig=<hmac>, backed by a real HMAC of (key, contentType, size, exp) with SESSION_SECRET. The client PUTs to the backend, which verifies the HMAC and writes the file to disk. Unauthenticated by design — the HMAC in the URL is the auth, matching S3 presigned-URL semantics.
  • s3 (backend/src/integrations/storage/s3.ts) — used in prod. Presigned URL is a real S3 URL. The client PUTs to S3. The backend never sees the bytes.

backend/src/integrations/storage/index.ts dispatches based on STORAGE_DRIVER at boot. The rest of the codebase just imports from ../integrations/storage/ and gets the right implementation. Result: the applicant-side upload code is identical in both environments, and swapping to S3 for production is a one-env-var change.

Credentials never appear in the codebase or in .env files in production — the EC2 instance carries an IAM role and the AWS SDK picks up short-lived credentials from IMDS.

Frontend — declarative questions, server state in the query cache, no form library

The application form has thirty-plus questions across six sections, and the same definition must render both the applicant's editor and the admin's review view. So questions are declarative, in shared/src/questions.ts:

export type Question =
  | (Base & { type: 'short_text'; maxLength?: number })
  | (Base & { type: 'long_text' })
  | (Base & { type: 'timezone' })
  | (Base & { type: 'single_select'; options: readonly Option[] })
  | (Base & { type: 'ranked'; options: readonly Option[] })
  | (Base & { type: 'availability_grid' })
  | (Base & { type: 'file_upload'; kind: 'transcript' | 'aid_doc'; accept: readonly string[] })
  | (Base & { type: 'signature' })
  // …

A single Field component dispatches by type and delegates to a small per-type renderer. Sections are described in the same file, and the applicant's left-rail navigation, progress dots, and submit-readiness check are all derived from it. Any prompt can be added, removed, or reworded without touching database schema or React components.

Server state (application, files, current user) lives in the TanStack Query cache. There is no global Redux-style store; the query cache is the store. Autosave is a mutation that optimistically updates the cache, so the "Draft saved · 2:14pm" indicator refreshes immediately even before the server acknowledges. Because the cache is keyed by URL, cross-tab consistency is a side effect of staleTime.

Autosave is on-blur rather than on-keystroke — one PATCH per field per edit. The form uses plain React state per section instead of a form library. Zod validation lives at the trust boundary (the HTTP layer), not during typing; if the server refuses a saved value we surface it, but at autosave scale that never happens.

Typography. Computer Modern serif and Computer Modern Sans, vendored under frontend/public/fonts/cm/ so they load regardless of CDN weather. The whole design is built around them: paper background, hairline rules, § marks in the accent green, and a small-caps sans face for chrome. A math program's admissions portal should look like it takes mathematics seriously.

Deploy — single instance, tsx at runtime, instance-role credentials

Production is one EC2 t4g.medium (2 vCPU ARM Graviton, 4 GB RAM), fronted by Caddy for automatic TLS, with the Node process supervised by systemd. SQLite sits on an EBS volume and Litestream ships WAL fragments continuously to S3. Applicant uploads go to a separate S3 prefix in the same bucket.

The backend runs from TypeScript source directly via tsx — no compilation step, so deploying an update is git pull && pnpm install && systemctl restart rp2. The typecheck runs on CI and locally with pnpm typecheck; the runtime never re-typechecks. Startup is under a second even on ARM.

Full deployment runbook — packages, service user, systemd unit, Caddy config, Litestream setup, and restore testing — is in ops/DEPLOY.md. The IAM policy that grants the instance role only what it needs (SES send in us-east-1, S3 access on the two buckets) is in ops/iam/rp2-instance-role.json.

Testing

pnpm typecheck runs tsc --noEmit across the workspace and is the first-line correctness check. Vitest is installed for unit testing but the suite is still small. The two flows that cannot afford to break — applicant submission and admin review — will get end-to-end Playwright coverage before the pilot opens.

Integration boundaries (SES, S3) are exercised in dev against a no-op SES transport and the local storage driver. In staging/prod we run against the real services with test-mode credentials.

Where to look for specific things

Question File
What questions does the form ask? shared/src/questions.ts
What's in the database? backend/src/db/schema.ts
How does auth work? backend/src/auth/magic-link.ts
Where does the file upload go? backend/src/integrations/storage/
What's the applicant UI structure? frontend/src/routes/apply-section.tsx + features/applicant/
How do I deploy? ops/DEPLOY.md
What's the product plan? CLAUDE.md

Conventions

  • TypeScript strict everywhere. No any without a comment explaining why.
  • Zod at every trust boundary — parse, don't validate.
  • Thin routes, fat services. Handlers do auth + parse + call service + serialize; business logic lives in backend/src/services/.
  • Migrations are additive. Never edit an applied migration.
  • UTC everywhere internally. Timezone conversion at the edges.
  • PII in the DB, not in logs. Structured logs with err/email redacted.
  • Youth safety first. Any feature that puts minors in contact with adults gets an explicit review checklist (moderation, recording, reporting path).

Contributing

The main branch is the current state of the pilot. Work in feature branches, keep commits focused and self-explanatory, and land through a PR once pnpm typecheck is clean. Before merging anything that touches auth, uploads, or the review flow, exercise the end-to-end path locally — the walkthrough is at the top of ops/DEPLOY.md.