NestJS API server for EngVocab. It powers authentication (email + Google), spaced-repetition vocabulary practice, curated word collections, and full TOEIC practice/mock-test taking for the web client.
- NestJS 11 (Express, Helmet, class-validator/global
ValidationPipe) - PostgreSQL 16 + Prisma 7
- JWT access tokens + rotating, hashed, revocable refresh sessions (HttpOnly cookie)
- Google Sign-In (ID token verification via
google-auth-library) @nestjs/throttlerrate limiting on auth endpoints- Supabase Postgres (production DB) + Supabase Storage (TOEIC audio/image media)
- Docker Compose for local PostgreSQL
- Jest + Supertest for unit and e2e tests
- GitHub Actions CI
- Node.js 22
- pnpm (via Corepack)
- Docker (for local PostgreSQL)
Install dependencies:
pnpm installCreate local env:
cp .env.example .envStart PostgreSQL:
docker compose up -dGenerate the Prisma client and apply migrations:
pnpm prisma generate
pnpm prisma migrate deployStart the API:
pnpm start:devThe server runs on http://localhost:3001 by default.
Google login and Supabase Storage are optional locally: leave GOOGLE_CLIENT_ID
empty to disable Google login, and leave SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY
empty to skip TOEIC media signing/upload (the server logs a warning and continues).
Generate the current catalog from the authoring JSON files:
pnpm toeic:catalog -- --source /path/to/toeic --out /tmp/toeic-catalogPreview every object that would be uploaded to the existing public toeic
bucket:
pnpm toeic:publish -- --source /path/to/toeic --dry-runPublish the catalog, its part documents, media files, and grading-index.json:
pnpm toeic:publish -- --source /path/to/toeicThe script uploads part documents and media before catalog.json, so the
manifest only becomes visible after its referenced files are present. It does
not create the bucket.
| Name | Required | Default | Purpose |
|---|---|---|---|
DATABASE_URL |
Yes | - | PostgreSQL connection string |
JWT_SECRET |
Yes | - | Secret used to sign access tokens. Must be at least 32 characters |
PORT |
No | 3001 |
API server port |
CORS_ORIGIN |
No | http://localhost:3000 |
Allowed frontend origin |
GOOGLE_CLIENT_ID |
No | - | Google OAuth web client ID. Must match the web client's NEXT_PUBLIC_GOOGLE_CLIENT_ID. When empty, POST /auth/google is disabled |
ACCESS_TOKEN_TTL_SECONDS |
No | 900 |
Access token lifetime in seconds |
BCRYPT_SALT_ROUNDS |
No | 10 |
Password hashing cost |
REFRESH_TOKEN_TTL_DAYS |
No | 30 |
Refresh token lifetime in days |
REFRESH_TOKEN_COOKIE_NAME |
No | engvocab.refreshToken |
HttpOnly refresh token cookie name. Must stay engvocab.refreshToken to match the web client's BFF |
REFRESH_TOKEN_COOKIE_SECURE |
No | true in production, otherwise false |
Whether the refresh cookie requires HTTPS |
REFRESH_TOKEN_COOKIE_SAME_SITE |
No | none when secure, otherwise lax |
Refresh cookie SameSite policy: lax, none, or strict |
AUTH_RATE_LIMIT_LIMIT |
No | 10 |
Request limit for auth endpoints |
AUTH_RATE_LIMIT_TTL_SECONDS |
No | 60 |
Rate limit window for auth endpoints |
SUPABASE_URL |
No | - | Supabase project URL. When empty, TOEIC media signing/upload is disabled |
SUPABASE_SERVICE_ROLE_KEY |
No | - | Supabase service role key used for Storage |
TOEIC_STORAGE_BUCKET |
No | toeic-media |
Supabase Storage bucket for TOEIC audio/images |
TOEIC_SIGNED_URL_TTL_SECONDS |
No | 900 |
Lifetime of signed TOEIC media URLs |
TOEIC_GRADING_INDEX_URL |
Runtime API only | - | Public URL of the current grading-index.json in Storage |
OTEL_ENABLED |
No | false |
Enables backend OpenTelemetry export. When true, the endpoint and headers below are required |
OTEL_EXPORTER_OTLP_ENDPOINT |
When OpenTelemetry is enabled | - | Grafana Cloud OTLP base endpoint, without /v1/metrics or /v1/traces |
OTEL_EXPORTER_OTLP_HEADERS |
When OpenTelemetry is enabled | - | OTLP authentication headers copied from Grafana, for example Authorization=Basic%20<base64(instance-id:token)> |
OTEL_SERVICE_NAME |
No | engvocab-server |
Service name sent with telemetry |
NODE_ENV is also read: when it equals production, the refresh cookie defaults
to Secure unless REFRESH_TOKEN_COOKIE_SECURE overrides it.
| Module | Path | Responsibility |
|---|---|---|
| App | src/app |
Root module, GET /health |
| Auth | src/auth |
Register/login/Google/refresh/logout, JWT guard, admin guard, refresh sessions |
| Users | src/users |
User lookups (used by auth + admin guard) |
| Vocab | src/vocab |
User vocabulary + definitions, SRS review, stats |
| Collections | src/collections |
User + system word collections, catalog import |
| Tests | src/tests |
TOEIC test catalog, full runs (practice/mock), part practice, media signing |
| Admin | src/admin |
Admin-only TOEIC content management (groups, questions, media uploads) |
| Prisma | src/prisma |
Prisma client wrapper |
| Config | src/config |
Typed env loading + validation |
All endpoints except /health are JSON. Vocabulary, collection, test, and admin
endpoints require Authorization: Bearer <accessToken>. Auth refresh tokens are
stored in an HttpOnly cookie scoped to /auth.
Health:
GET /health
Auth (rate-limited; register/login/google/refresh set the refresh cookie, logout clears it):
POST /auth/registerPOST /auth/loginPOST /auth/googlePOST /auth/refreshPOST /auth/logoutGET /auth/me(JWT)
Vocabulary (JWT):
GET /vocab— query:collectionId(required),search,limit(1-500),offsetGET /vocab/stats— query:collectionId(required)GET /vocab/review/due— query:collectionId(required),limit(1-1000),offsetGET /vocab/:idPOST /vocabPATCH /vocab/:idPATCH /vocab/:id/reviewDELETE /vocab/definitions/:idDELETE /vocab/:id
Collections (JWT):
GET /collectionsPOST /collectionsGET /collections/:idPOST /collections/:id/import— body:targetCollectionId?,catalogDefinitionIds?PATCH /collections/:idDELETE /collections/:id
Tests (JWT):
GET /tests/yearsGET /tests— query:year(default2026)GET /tests/part-practice/partsPOST /tests/part-practice/runsGET /tests/part-practice/runs/:sessionIdPOST /tests/part-practice/runs/:sessionId/answersDELETE /tests/part-practice/:partNumber/historyPOST /tests/runs— create a full TOEIC run (practice or mock-test mode)GET /tests/runs/:sessionIdPOST /tests/runs/:sessionId/expand-partsPOST /tests/runs/:sessionId/answersPATCH /tests/runs/:sessionId/finishDELETE /tests/:testId/practice-historyPOST /tests/:testId/parts/:partNumber/refresh-media
Admin (JWT + ADMIN role, under /admin/tests):
GET /admin/testsGET /admin/tests/:testId/rawPATCH /admin/tests/groups/:groupIdPOST /admin/tests/groups/:groupId/audio— multipartaudio, max 5 MBDELETE /admin/tests/groups/:groupId/audioPOST /admin/tests/groups/:groupId/image— multipartimage, max 5 MBDELETE /admin/tests/groups/:groupId/imagePATCH /admin/tests/questions/:questionId
The Next.js web client proxies auth through a same-origin BFF (/api/auth/*)
that extracts the upstream refresh cookie and re-sets it on the web origin, so
the backend refresh cookie is consumed by the BFF rather than the browser
directly. Keep REFRESH_TOKEN_COOKIE_NAME as engvocab.refreshToken because
that is the upstream cookie name the BFF expects, and set CORS_ORIGIN to the
exact web origin (the browser calls the API directly with a Bearer token for
non-auth data). For direct cross-domain cookie usage, use
REFRESH_TOKEN_COOKIE_SECURE=true and REFRESH_TOKEN_COOKIE_SAME_SITE=none.
The AdminGuard requires the authenticated user's role to be ADMIN. There is
no public endpoint that grants admin; set it directly on the user row, for example
against Supabase/Postgres:
UPDATE users SET role = 'ADMIN' WHERE email = 'you@example.com';package.json also defines an admin:grant script (ts-node scripts/grant-admin.ts),
but the script file is not currently committed, so grant admin via SQL until it is added.
TOEIC audio and images are stored in Supabase Storage (TOEIC_STORAGE_BUCKET).
The TestsStorageService creates signed URLs (TTL TOEIC_SIGNED_URL_TTL_SECONDS)
for playback, and the admin upload endpoints write/delete objects. If Supabase is
not configured, media fields are returned without signed URLs and admin uploads fail
fast with a clear error.
pnpm test # unit tests (Jest)
pnpm test:e2e # e2e tests (Supertest, needs DATABASE_URL)
pnpm test:cov # unit tests with coverage
pnpm build
pnpm lint
pnpm format.github/workflows/ci.yml runs on pull requests and pushes to main. It starts a
Postgres 16 service, installs with pnpm install --frozen-lockfile, generates the
Prisma client, runs migrate deploy, then runs unit tests, e2e tests, and the build.
Recommended production architecture:
Vercel web client -> Railway NestJS API -> Supabase Postgres
Supabase Storage (TOEIC media)Use Supabase as the production PostgreSQL database. Apply Prisma migrations before pointing production traffic at the API:
pnpm prisma migrate deployThen import the Oxford catalog data once. The catalog lives separately from user vocabulary; importing a collection into a user's vocabulary skips words the user already has.
Use a production-only connection string in Railway. Do not commit production
connection strings or .env.production.local.
With Supabase Session Pooler, Railway may need sslmode=no-verify if Node.js
reports a self-signed certificate chain:
DATABASE_URL="postgres://prisma.<project-ref>:<password>@<region>.pooler.supabase.com:5432/postgres?sslmode=no-verify"sslmode=no-verify still uses TLS, but skips certificate-chain verification.
Prefer a verified CA setup later if this service handles sensitive production
traffic.
Create the TOEIC_STORAGE_BUCKET bucket in Supabase Storage and upload the TOEIC
audio/image assets to the paths the admin endpoints expect.
Set these variables in the Railway service:
NODE_ENV=production
DATABASE_URL=<supabase-postgres-url>
JWT_SECRET=<64-plus-character-secret>
ACCESS_TOKEN_TTL_SECONDS=900
REFRESH_TOKEN_TTL_DAYS=30
CORS_ORIGIN=https://<vercel-web-domain>
REFRESH_TOKEN_COOKIE_NAME=engvocab.refreshToken
REFRESH_TOKEN_COOKIE_SECURE=true
REFRESH_TOKEN_COOKIE_SAME_SITE=none
AUTH_RATE_LIMIT_LIMIT=10
AUTH_RATE_LIMIT_TTL_SECONDS=60
BCRYPT_SALT_ROUNDS=10
GOOGLE_CLIENT_ID=<google-oauth-client-id>
SUPABASE_URL=<supabase-project-url>
SUPABASE_SERVICE_ROLE_KEY=<supabase-service-role-key>
TOEIC_STORAGE_BUCKET=toeic-media
TOEIC_SIGNED_URL_TTL_SECONDS=900
TOEIC_GRADING_INDEX_URL=https://<project-ref>.supabase.co/storage/v1/object/public/toeic/grading-index.json
OTEL_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=<grafana-cloud-otlp-base-endpoint>
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic%20<base64(otlp-instance-id:access-policy-token)>
OTEL_SERVICE_NAME=engvocab-serverDo not set PORT manually on Railway unless needed; Railway provides it.
The root path / may return 404. Use /health or auth endpoints for smoke
tests.
The API exports backend HTTP metrics and sampled traces directly to Grafana Cloud
when OTEL_ENABLED=true. Keep it disabled in local development, test, and CI.
- In Grafana Cloud, create an access-policy token with
metrics:writeandtraces:write, then use the OpenTelemetry setup page to obtain its endpoint and instance ID. - Put the four
OTEL_*variables above in Railway. The endpoint must be the base OTLP endpoint: the server appends/v1/metricsand/v1/tracesitself. The Grafana-providedBasic%20form is supported and decoded before the request is sent. Never commit the token or header value. - Create an EngVocab API Production dashboard. Useful PromQL panels are:
# Request rate, excluding health checks
sum(rate(engvocab_http_server_requests_total{http_route!="/health"}[5m]))
# Server-error rate
sum(rate(engvocab_http_server_requests_total{http_response_status_class="5xx",http_route!="/health"}[5m]))
/ sum(rate(engvocab_http_server_requests_total{http_route!="/health"}[5m]))
# API p95 latency
histogram_quantile(0.95, sum by (le) (
rate(engvocab_http_server_request_duration_seconds_bucket{http_route!="/health"}[5m])
))
Use http_route, http_request_method, and http_response_status_class to
break down the panels by route, method, and status family. Route labels use the
Nest/Express template (for example /vocab/:id), never raw IDs or query
strings. Request bodies, users, authorization headers, and other PII are not
sent as metric labels.
Create two Grafana-managed alert rules and send both to the owner email contact:
- 5xx rate exceeds 2% for 5 minutes, only while there are at least 20 requests in that period.
- p95 latency exceeds 1 second for 10 minutes, only while there are at least 20 requests in that period.
Traces use parent-based 10% root sampling. Use Grafana Explore with
service.name=engvocab-server to inspect a sampled slow or failed request.
Railway's request and container metrics remain useful as a complementary view of
platform and network time.
Set the API URL:
API=https://<railway-api-domain>Login with a missing user should return 401, not 500:
curl -i -X POST "$API/auth/login" \
-H "Content-Type: application/json" \
-d '{"email":"notfound@example.com","password":"test123456"}'Register, refresh, and logout should work with the HttpOnly refresh cookie:
curl -i -c cookies.txt -b cookies.txt \
-X POST "$API/auth/register" \
-H "Content-Type: application/json" \
-d '{"email":"prodtest@example.com","password":"test123456","name":"Prod Test"}'
curl -i -c cookies.txt -b cookies.txt \
-X POST "$API/auth/refresh"
curl -i -c cookies.txt -b cookies.txt \
-X POST "$API/auth/logout"Build the API image (Node 22, multi-stage, runs prisma generate + nest build):
docker build -t engvocab-server .Run the API container against the local PostgreSQL container:
docker run --rm -p 3001:3001 \
--env-file .env \
--add-host=host.docker.internal:host-gateway \
engvocab-serverWhen running from Docker, use a DATABASE_URL that the container can reach, for example:
DATABASE_URL="postgresql://engvocab:engvocab@host.docker.internal:5433/engvocab"Local PostgreSQL runs on port 5433 to avoid conflicts with a system PostgreSQL on 5432.
Prisma schema:
prisma/schema.prismaKey models:
User— email/password or Google (googleSub),role(USER/ADMIN)RefreshSession— hashed, revocable refresh tokensVocabWord+VocabWordDefinition— user vocabulary with SRS fields (level,wrongCount,lastReview,nextReview) and soft delete (deletedAt), scoped to aWordCollectionWordCollection—SYSTEMorUSERkind, optional owner, CEFR level, default/public flagsCatalogWord+CatalogDefinition— shared Oxford catalog, separate from user vocabularyCollectionCatalogItem— joins catalog words into collectionsToeicTest->ToeicTestPart->ToeicQuestionGroup->ToeicQuestion— TOEIC content treeToeicRun+ToeicRunGroup+ToeicRunQuestion— full test runs (PRACTICE/MOCK_TEST)ToeicPartPracticeRun+ groups + questions — per-part practice sessions
Create a migration after schema changes:
pnpm prisma migrate dev --name <migration_name>Apply existing migrations:
pnpm prisma migrate deploy