Express backend for the song/tab management app. Handles authentication, song data, guitar tabs, and associated YouTube videos — all scoped to the authenticated user.
Users log in with Google OAuth. Once authenticated, they can:
- Store songs with a title and artist
- Attach guitar tab text to each song, with an optional auto-scroll speed
- Attach up to 5 YouTube videos per song
- Search their library by title or artist
- Delete songs (cascades to tabs and videos)
All data is private: every query is filtered by the authenticated user's ID.
| Layer | Technology |
|---|---|
| Runtime | Node.js 20, Express 5 |
| Database | PostgreSQL, Sequelize ORM |
| Auth | Google OAuth 2.0, JWT (HttpOnly cookie) |
| Process manager | forever (production) |
| Tests | Jest, Supertest |
| Dev server | nodemon |
No PostgreSQL installation required. The app uses SQLite automatically when NODE_ENV is not production.
# 1. Install dependencies
npm install
# 2. Create a .env file with the two required vars (see Environment variables below)
# 3. Seed the local SQLite database
npm run seed
# 4. Start the dev server (auto-restarts on file changes)
npm run devThe server starts on port 3001 by default. The SQLite database is stored in dev.sqlite (gitignored) and persists between restarts. To reset it to the seed data, run npm run seed again.
Create a .env file in the project root. For local development only two variables are required:
| Variable | Required locally | Description |
|---|---|---|
GOOGLE_CLIENT_ID |
Yes | OAuth client ID from Google Cloud Console |
GOOGLE_CLIENT_SECRET |
Yes | OAuth client secret from Google Cloud Console |
JWT_SECRET |
Yes | Secret used to sign session JWTs — any string works locally |
PORT |
No | HTTP port (defaults to 3001) |
NODE_ENV |
No | Set to production to use PostgreSQL and enable the Secure cookie flag |
DB_NAME |
Production only | PostgreSQL database name |
DB_USER |
Production only | PostgreSQL user |
DB_PASSWORD |
Production only | PostgreSQL password |
DB_HOST |
Production only | Database host |
DB_PORT |
Production only | Database port |
seeds/data.json contains the seed dataset committed to the repo. Anyone who clones the project can run npm run seed to get a working local database.
The app uses four tables:
users
idUUID, primary keygoogle_idstring — Google's identifier for the useremailstringnamestring
songs
idUUID, primary keyuser_idUUID, foreign key → userstitlestringartiststring
tabs
idUUID, primary keysong_idUUID, foreign key → songstexttext — the guitar tab contentscroll_speedinteger (nullable) — auto-scroll speed in the frontend
videos
idUUID, primary keysong_idUUID, foreign key → songsurlstring — YouTube URL (normalized to embed format on response)video_typestring
npm test # run all tests once
npm run test:watch # watch mode
npm run test:coverage # with coverage reportTests use Jest and Supertest and run against a real in-memory SQLite database. No running PostgreSQL instance is required.
Browser Backend Google
| | |
|-- Google login --------->| |
| |-- verify token ---------->|
| |<-- user payload ----------|
| | |
| | look up or create user in DB
| | |
|<-- Set-Cookie: session_jwt (HttpOnly, SameSite=Strict, Secure in prod)
|<-- JSON: { name, email, user_id }
| |
| (subsequent requests) |
|-- Cookie sent automatically (browser handles this)
| |
| | verify JWT from cookie
| | attach decoded token to req.token
| | proceed to route handler
Why HttpOnly cookies? The session_jwt cookie cannot be read by JavaScript, which prevents token theft via XSS attacks. SameSite=Strict blocks the cookie from being sent in cross-site requests, mitigating CSRF. In production, Secure ensures the cookie is only sent over HTTPS.
Token expiry: JWTs expire after 7 days. After expiry the server returns 401, which the frontend handles by returning the user to the login screen.
All endpoints except auth require a valid session_jwt cookie. The server returns 401 if the token is missing, invalid, or expired.
Exchange a Google OAuth credential for a session cookie.
Request body:
{ "token": "<google-id-token>" }Response (on success):
- Sets
Set-Cookie: session_jwt=...; HttpOnly; SameSite=Strict - Body:
{
"name": "Jane Smith",
"email": "jane@example.com",
"user_id": "uuid-here"
}Clears the session cookie.
Response: 204 No Content
List the authenticated user's songs.
Query params:
| Param | Type | Default | Description |
|---|---|---|---|
limit |
number | 10 | Max results to return |
offset |
number | 0 | Pagination offset |
query |
string | — | Case-insensitive search against title and artist |
Response:
[
{ "id": "uuid", "title": "Blackbird", "artist": "Beatles" }
]Create a new song with a tab and optional videos.
Request body:
{
"title": "Blackbird",
"artist": "Beatles",
"tab_text": "Am G C ...",
"scroll_speed": 20,
"videos": [
{ "url": "https://youtu.be/...", "video_type": "tutorial" }
]
}title, artist, and tab_text are required. videos is optional (max 5). Each video requires url and video_type.
Response: 201 Created
{
"song": { "id": "uuid", "title": "Blackbird", "artist": "Beatles" },
"tab": { "id": "uuid", "text": "Am G C ...", "scroll_speed": 20 },
"videos": [{ "id": "uuid", "video_type": "tutorial", "url": "https://www.youtube.com/embed/..." }]
}Update a song's title, artist, tab text, scroll speed, or videos.
All body fields are optional. Videos are replaced in full when provided — include existing video IDs to keep them, omit to delete them.
Request body:
{
"title": "Let It Be",
"tab_text": "C G Am F ...",
"scroll_speed": 30,
"videos": [
{ "id": "existing-uuid", "url": "https://youtu.be/...", "video_type": "tutorial" },
{ "url": "https://youtu.be/...", "video_type": "cover" }
]
}Response: 200 OK — same shape as POST /api/songs.
Delete a song and all associated tabs and videos.
Response: 204 No Content
Get the tab for a song.
Response:
{ "id": "uuid", "text": "Am G C ...", "scroll_speed": 20 }Get videos for a song. YouTube URLs are normalized to embed format.
Response:
[
{ "id": "uuid", "video_type": "tutorial", "url": "https://www.youtube.com/embed/..." }
]All error responses follow this shape:
{
"status": "error",
"message": "Description of the error",
"timestamp": "2026-01-01T00:00:00.000Z"
}| Status | Meaning |
|---|---|
400 |
Bad request — missing or invalid fields |
401 |
Unauthorized — missing, invalid, or expired session |
404 |
Song not found (or belongs to a different user) |
500 |
Internal server error |
Pushes to master trigger a GitHub Actions pipeline:
push to master
│
▼
[test job]
npm ci
npm test
│
▼ (on success)
[deploy job]
SSH into EC2
git pull
npm ci
write .env from secrets
forever stopall
forever start app.js
| Name | Type | Description |
|---|---|---|
EC2_HOST |
Secret | Public IP or hostname of the EC2 instance |
EC2_USER |
Secret | SSH user (e.g. ec2-user) |
EC2_SSH_KEY |
Secret | Private SSH key for the EC2 instance |
DB_NAME |
Secret | Production database name |
DB_USER |
Secret | Production database user |
DB_PASSWORD |
Secret | Production database password |
DB_HOST |
Secret | Production database host |
DB_PORT |
Secret | Production database port |
GOOGLE_CLIENT_ID |
Secret | Google OAuth client ID |
GOOGLE_CLIENT_SECRET |
Secret | Google OAuth client secret |
JWT_SECRET |
Secret | Production JWT signing secret (different from local) |
BACKEND_PORT |
Variable | Port the server listens on (e.g. 3001) |