The Oregon State University EECS Capstone application: browse and propose capstone projects, run them through a review workflow, and manage shared inventory.
This README covers how to run and develop the app. Known issues and the roadmap
live in GitHub Issues. For
the full, exhaustive feature list, see PRD.md. For implementation
quirks and gotchas, see docs/QUIRKS.md. For how work moves
through the repo and the gates it passes, see CONTRIBUTING.md;
for the rules that bind an agent on every turn, AGENTS.md.
Both live in GitHub Issues rather than in this file, so that what is outstanding has one home that can be assigned, closed and linked from a PR.
For the exhaustive list of what is already built, see PRD.md.
New to the repo? docs/ONBOARDING.md is day one: the
accounts to ask for, a wizard that runs the steps below and every test suite
once (bash scripts/onboard.sh), what Claude Code does here, and a first week.
npm install
docker compose up -d
npm run db:migrate # nothing applies migrations on boot
npm run devTo stop the database and storage:
docker compose downThe stack publishes Postgres on 5432 and RustFS on 9000/9001. If another
project already holds those host ports, the containers still start but silently
publish nothing, and the app talks to the other project's services instead:
Postgres fails with password authentication failed, while S3 may quietly
accept writes, because the default rustfsadmin credentials are the same.
Give this stack its own host ports in .env (docker compose reads .env, and
not .env.local), then mirror them in .env.local:
# .env, read by docker compose
POSTGRES_PORT=5433
STORAGE_PORT=9100
STORAGE_CONSOLE_PORT=9101# .env.local, read by the app and the scripts
DATABASE_URL="postgresql://postgres:postgres@localhost:5433/eecs_capstone"
S3_ENDPOINT=http://localhost:9100
VITE_STORAGE_PUBLIC_BASE=http://localhost:9100/cs-capstoneThen docker compose up -d --force-recreate. Confirm the ports actually bound
with docker compose ps and check the publishers column is not empty.
To build for production:
npm run buildThis is a TanStack Start app (React SSR) with TanStack Router (file-based routes
in src/routes), TanStack Query, Drizzle ORM on PostgreSQL, Better Auth, and
S3-compatible object storage (RustFS locally). UI is shadcn/ui + Radix.
A few conventions worth knowing before you contribute:
- The project workflow state machine (
src/lib/project-workflow.ts) and visibility rules (src/lib/project-visibility.ts) are pure modules. Keep business logic there, not in routes. - A project's proposer is
proposer_idwhen an account exists andproposer_emailotherwise. Staff cannot retype a linked address directly; the edit form routes that through a re-assign modal (seedocs/QUIRKS.md). - Every project/comment/inventory mutation is one server function in
src/server/, each enforcing its own gate and wrapping writes in a transaction. The companion*As(viewer, ...)helpers next to eachcreateServerFnlet integration tests exercise business logic directly, without the HTTP layer. - Forms with more than two fields use TanStack Form
with Zod validators shared with the server. Server-thrown
ZodErroris mapped back to field-level errors viasrc/lib/apply-server-errors.ts. - Full-text search uses a Postgres generated
tsvectorcolumn with a GIN index. To change field weights, drop and re-add the column in a new migration (seedocs/QUIRKS.md). - Interest-based recommendations use pgvector. Projects are embedded only on
publish and re-embedded when a published project's indexed text changes; see
src/server/_internal/project-embeddings.ts, the single writer of every vector.npm run embeddings:backfillis the safety net. - All filter/search state lives in URL search params so links are shareable.
This project uses Better Auth backed by Drizzle + Postgres. Identity lives in the
user, session, account, and verification tables (generated by Better
Auth's CLI into src/db/auth-schema.ts and re-exported from src/db/schema.ts).
-
Copy
.env.exampleto.env.localand fill in values. -
Generate a Better Auth secret if you don't have one:
npx -y @better-auth/cli secret
-
Register a GitHub OAuth App at https://github.com/settings/developers with callback
http://localhost:3000/api/auth/callback/github, then put the credentials into.env.local. -
Start Postgres and run the dev server:
docker compose up -d npm run dev
-
Seed your dev database (safe to re-run):
npm run db:seed:dev
In production, seed an admin user (configured via environment variables).
Keep at least two
adminusers in production. The self-action guard prevents a sole admin from accidentally demoting or banning themselves into a one-way trap. Usenpm run db:seed:adminor a directdb:studioedit to bootstrap the second admin.
src/db/auth-schema.ts is hand-maintained. Do not run @better-auth/cli generate against it: that package lags the library (the CLI is stuck on 1.4.x
while we run better-auth 1.6.x), and its output silently drops the
timezone-aware timestamps, the session/account/verification indexes, and the
role NOT NULL default that this file carries. Running it would produce a
destructive migration.
To add or change a Better Auth additionalField:
-
Add the field to
user.additionalFieldsinsrc/lib/auth.ts(this is what Better Auth reads at runtime). -
Add the matching column to the
usertable insrc/db/auth-schema.tsby hand, with the correct type and a DB default (so existing rows backfill). -
Generate and apply the migration, reviewing the SQL to confirm it only adds your column:
npm run db:generate npm run db:migrate
If the @better-auth/cli package ever catches up to the installed better-auth
version, this file could return to CLI generation; until then, edit it directly.
EMAIL_TRANSPORT selects the sender behind the EmailSender interface in
src/lib/email/sender.ts:
console(the default, and what local development uses): every email below is written to the server's stderr instead of being sent, review notices included.ses: real outbound mail through AWS SES v2 (src/lib/email/ses-sender.ts), which additionally requiresEMAIL_FROMto be a verified sender identity.EMAIL_REPLY_TOis optional: set it and every message carries thatReply-To, leave it blank and the header is omitted.
The app sends four emails, all through src/lib/email/templates.ts:
| Trigger | Recipient | |
|---|---|---|
| Verify your email | Sign-up | The new account |
| Reset your password | Forgot-password form | The account |
| New project submitted | A project moves to submitted |
EMAIL_REVIEW_INBOX |
| Approved / Changes requested | Staff review a project | The proposer |
Everything else the app notifies about is in-app only, a row in notifications
rendered by the bell, and never reaches an inbox. Staff can skip either review
email per action from the transition dialog.
Production runs ses and has done since task definition revision 22: the domain
identity verifies with DKIM SUCCESS, the account has production access (so the
sandbox recipient restriction no longer applies), and mail sends From
noreply@capstone.eecs.oregonstate.edu.
EMAIL_FROM must align with the verified identity or DKIM fails, which is why
both it and var.domain_name derive from the same variable in infra/ecs.tf.
It is also not optional under ses: getEmailSender() runs at module scope in
src/lib/auth.ts, so a missing EMAIL_FROM throws during import and stops the
app booting rather than merely stopping its email. Terraform always writes the
two into the same task definition revision, which is why the transport must
never be flipped by hand in the ECS console. EMAIL_REPLY_TO is not
DKIM-aligned and so carries an ordinary OSU mailbox,
eecs-capstone@oregonstate.edu.
Images live in an S3-compatible bucket (RustFS locally, AWS S3 in production).
docker compose up -d rustfs
npm run storage:init # idempotentProduction note: configure the bucket as public-read at the bucket policy level on
AWS, or run with S3_ENDPOINT set to your CDN base. Set
VITE_STORAGE_PUBLIC_BASE to the customer-facing URL prefix.
The project form can request per-field improvement suggestions, backed by AWS
Bedrock. Set BEDROCK_MODEL_ID (and the relevant AWS credentials) to configure
the model; the default is OpenAI GPT-5.6 Luna.
The call goes to the OpenAI-compatible Responses API on the bedrock-mantle
endpoint, signed with SigV4 from the ambient AWS credentials, so no separate
Bedrock API key is needed. BEDROCK_REASONING_EFFORT tunes how much the model
thinks before answering. Embeddings still use bedrock-runtime.
This project uses Vitest.
npm run testThe auth and server surfaces have integration tests that hit the docker-compose Postgres:
npm run test:integrationEach test starts from a TRUNCATEd database, so they share a single fork and run
serially. They read the schema as it exists, so run npm run db:migrate after
pulling a migration or every one of them fails on the missing column.
Accessibility is checked separately, with Playwright and axe against the running
app (the config starts npm run dev itself, or reuses one already listening on
port 3000):
npm run test:accessibilityPull requests run only the scans tagged @smoke (npm run test:accessibility:smoke);
the whole suite runs from the dispatch-only Full Accessibility workflow.
TODO (future): integration tests currently run against the same database as dev and TRUNCATE every table before each test, which wipes dev data. Point them at a dedicated
eecs_capstone_testdatabase via a separateTEST_DATABASE_URL. See the Drizzle section ofdocs/QUIRKS.mdfor details.
This project uses Ultracite (Biome under the hood).
npm run lint
npm run format
npm run checkAlways run npm run check after finishing work and fix any issues before
committing.
Add components using the latest version of shadcn:
npx shadcn@latest add buttonProduction runs on AWS, provisioned with Terraform (infra/) and deployed via a
one-click GitHub Actions workflow (.github/workflows/deploy.yml):
- Compute: a single arm64 ECS Fargate task running the app's multi-stage Docker image.
- Ingress: CloudFront is the only public entry point, serving HTTPS at
capstone.eecs.oregonstate.edu(var.domain_name). Two DNS records live in the OSU-managedeecs.oregonstate.eduzone rather than in this Terraform configuration: one validating the ACM certificate, and one pointing the hostname at the CloudFront distribution. That is why the certificate is a read-onlydatalookup ininfra/cloudfront.tfinstead of a managed resource. CloudFront reaches an internal Application Load Balancer through a VPC origin, so the ALB itself has no public IP. - Data: a private RDS Postgres instance and a private S3 bucket for uploaded images, the latter served through its own CloudFront distribution via Origin Access Control.
- Images/build: ECR holds built images; the deploy workflow builds natively on an arm64 GitHub-hosted runner (matching the Fargate architecture), pushes to ECR, runs migrations as a one-off ECS task, then rolls the service.
- Secrets/config: app credentials come from the ECS task role (no static AWS keys); secrets live in Secrets Manager, non-secret config in the task definition and SSM.
See DEPLOYMENT.md for the full operational runbook
(first-time setup through teardown) and infra/README.md
for the Terraform specifics.