MEAN stack project — Module Seven: authentication and security.
Module Four connected the site to MongoDB. Module Five pulled the database access
out of the website and into a separate Express application, app_api, published at
/api. Module Six added an Angular single-page application, app_admin, that lets
staff add and edit trips, and extended the API with the write endpoints it needs.
This module secures it. Until now anyone who knew the admin URL could add, edit, or delete a trip. There is now a login form on the admin site, users are stored with a salted PBKDF2 hash, and the write endpoints reject any request that does not carry a valid JSON Web Token.
The rule is read is public, write is guarded. The GET endpoints stay open because the customer-facing website consumes them and its visitors are anonymous — locking the whole API down would have broken the public site.
The API now has three clients: the Express website, the Angular admin SPA, and any external caller such as Postman. All three read the same data; two of them can write it, and only with a token.
There are three pieces:
| Piece | Folder | URL |
|---|---|---|
| Customer website (Express + Handlebars) | app_server |
http://localhost:3000 |
| REST API (Express + Mongoose) | app_api |
http://localhost:3000/api |
| Admin SPA (Angular) | app_admin |
http://localhost:4200 |
- Node.js 18 or newer (this project uses the built-in
fetch, added in Node 18) - MongoDB running locally on the default port (27017)
- An internet connection for the two
npm installsteps, and for the Bootstrap CDN the admin SPA loads inapp_admin/src/index.html - Angular CLI, for the admin SPA:
npm install -g @angular/cli. Optional — everyngcommand below also works asnpx ng, which uses the copy of the CLI thatnpm installalready put inapp_admin/node_modules.
Install dependencies:
npm installCreate the environment file. .env is gitignored — a signing key in version control
is a signing key everybody has — so a fresh clone from GitHub has to make its own:
copy .env.example .envOn macOS or Linux that is cp .env.example .env. If you are working from
travlr.zip, skip this step — a working .env is already in the archive so the
project runs as-is.
Seed the trips, then the mock admin account:
npm run seednpm run seed:userStart the Express site and API:
npm startThen, in a second terminal, start the Angular admin SPA:
cd app_admin && npm install && npx ng servenpx ng serve works whether or not the Angular CLI is installed globally. With a
global CLI, plain ng serve is equivalent.
Both servers must be running for the admin console to work: the SPA at http://localhost:4200 makes its API calls to the Express server on port 3000.
Sign in at http://localhost:4200/login with the seeded account:
| Field | Value |
|---|---|
admin@travlr.com |
|
| Password | Password123 |
Mock credentials for a local database — nothing real is behind them.
To use a different database server, set MONGODB_URI or DB_HOST before starting.
app_server no longer requires mongoose anywhere — its only imports are express,
its own controllers, and the static data file used by the home and news pages. Every
Mongoose call in the project now lives under app_api/.
| File | Purpose |
|---|---|
app_api/controllers/trips.js |
Mongoose data access for the trips resource. |
app_api/routes/index.js |
Route table for the API; mounted at /api by app.js. |
The models folder moved from app_server to app_api, taking the database
connection, the schema, and the seed script with it:
| From | To |
|---|---|
app_server/models/db.js |
app_api/models/db.js |
app_server/models/travlr.js |
app_api/models/travlr.js |
app_server/models/seed.js |
app_api/models/seed.js |
data/trips.json stays in the project's upper-level /data folder where it was
created. app_server/data/travlr.js (static copy for the home and news pages) also
stays where it was.
| File | Change |
|---|---|
app.js |
Requires ./app_api/models/db, and wires apiRouter up to the /api path. |
app_server/controllers/travel.js |
Rewritten as a presentation-only controller that fetches GET /api/trips. |
app_server/views/travel.hbs |
Trip image and title HREFs retargeted at /api/trips/{{code}}. |
app_server/routes/travel.js |
Removed GET /travel/json, now superseded by GET /api/trips. |
package.json |
seed script points at the relocated app_api/models/seed.js. |
| File | Change |
|---|---|
.env / .env.example |
new — holds JWT_SECRET; .env is gitignored |
app_api/models/user.js |
new — user schema, setPassword, validPassword, generateJwt |
app_api/models/seed-user.js |
new — creates the mock admin account |
app_api/config/passport.js |
new — Passport local strategy |
app_api/controllers/authentication.js |
new — register and login |
app_api/models/db.js |
Registers the user model alongside the trip model |
app_api/controllers/trips.js |
getUser() helper wraps the three write handlers |
app_api/routes/index.js |
/register and /login; auth middleware on POST, PUT, DELETE |
app.js |
dotenv first, passport.initialize(), UnauthorizedError → 401 JSON |
package.json |
+ jsonwebtoken, passport, passport-local, express-jwt, dotenv; new seed:user script |
| File | Change |
|---|---|
models/user.ts, models/authresponse.ts |
new — types for the auth flow |
services/storage.service.ts |
new — BROWSER_STORAGE injection token |
services/authentication.service.ts |
new — login, register, token handling |
guards/auth.guard.ts |
new — keeps anonymous users off the write screens |
login/, register/ |
new — the HTML login form and account creation |
services/trip-data.service.ts |
Attaches Authorization: Bearer to the three write calls |
app.routes.ts |
/login, /register; canActivate on /add-trip and /edit-trip |
app.component.* |
Navbar shows the signed-in user and a Log out button |
trip-listing/, trip-card/ |
Add / Edit / Delete render only when signed in; Delete button added |
add-trip/, edit-trip/ |
A 401 now reports an expired session rather than a generic failure |
assets/css/styles.css |
Styles for the navbar auth controls and the logged-out prompts |
Base URL: http://localhost:3000/api
| Method | Endpoint | Description | Auth | Success |
|---|---|---|---|---|
| POST | /api/register |
Create an account | public | 200 + { token } |
| POST | /api/login |
Exchange credentials for a token | public | 200 + { token } |
| GET | /api/trips |
All trips | public | 200 + JSON array |
| GET | /api/trips/:tripCode |
One trip, located by its unique code |
public | 200 + JSON array of one |
| POST | /api/trips |
Add a trip | token | 201 + the stored trip |
| PUT | /api/trips/:tripCode |
Update that trip | token | 201 + the updated trip |
| DELETE | /api/trips/:tripCode |
Remove that trip | token | 200 + confirmation |
The protected endpoints expect the token in an Authorization header:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Bearer is the scheme name the JWT spec uses and the exact word express-jwt looks
for — one space, correct capitalization, or the middleware will not find the token
and will answer 401 as though none had been sent.
Routes follow a single, predictable convention so a client can guess a URL correctly:
- Resources are plural, lowercase nouns —
/trips. - A single member is addressed by its business key as a named parameter —
/trips/:tripCode, e.g./api/trips/GALR210214. - The HTTP verb carries the action. No verbs appear in paths — there is no
/getTripsor/tripFind. - Every route for a path is grouped with
router.route(), so addingPOST,PUT, orDELETEin a later module is a one-line change per verb.
:tripCode is matched against the code field rather than Mongo's _id because
code is the identifier the business actually uses, it is human-readable, and the
schema places a unique index on it — so the lookup is a single indexed query.
| Code | When |
|---|---|
200 OK |
The request succeeded; the body is the requested JSON. An empty collection returns 200 with [] — the resource exists, it just has no members yet. |
201 Created |
A POST or PUT succeeded; the body is the saved trip. |
400 Bad Request |
The submitted trip failed schema validation, or its code duplicates an existing trip. Also returned by /register and /login when a required field is missing. |
401 Unauthorized |
No token, a malformed token, a bad signature, an expired token, or a token naming an account that no longer exists. Also returned by /login on bad credentials. |
404 Not Found |
No trip carries the requested code. |
500 Internal Server Error |
The query failed — most often because MongoDB is unreachable. |
Error responses share one body shape, so a client only has to read one field:
{ "message": "Trip not found" }Start MongoDB, run npm run seed and npm run seed:user, then npm start.
| # | Request URL | Expected | Verifies |
|---|---|---|---|
| 1 | GET http://localhost:3000/api/trips |
200, JSON array of 6 trips |
Model.find({}) — FIND with an empty filter returns the whole collection |
| 2 | GET http://localhost:3000/api/trips/GALR210214 |
200, JSON array of 1 trip |
Model.find({ code }) — FIND with a filter returns a single trip |
| 3 | GET http://localhost:3000/api/trips/ZZZZ999999 |
404 + { "message": "Trip not found" } |
A code with no match is handled, not crashed |
Send these with no Authorization header. All three succeeded before Module
Seven; all three are now rejected before the controller ever runs.
| # | Method and URL | Expected |
|---|---|---|
| 4 | POST http://localhost:3000/api/trips |
401 + { "message": "Unauthorized: a valid token is required for this request." } |
| 5 | PUT http://localhost:3000/api/trips/GALR210214 |
401, same body |
| 6 | DELETE http://localhost:3000/api/trips/GALR210214 |
401, same body |
Body tab → raw → JSON.
| # | Method and URL | Body | Expected |
|---|---|---|---|
| 7 | POST /api/login |
{ "email": "admin@travlr.com", "password": "wrong" } |
401 + { "message": "Incorrect email or password." } |
| 8 | POST /api/login |
{ "email": "nobody@travlr.com", "password": "Password123" } |
401, identical message |
| 9 | POST /api/login |
{ "email": "admin@travlr.com" } |
400 + both-fields-required message |
| 10 | POST /api/login |
{ "email": "admin@travlr.com", "password": "Password123" } |
200 + { "token": "eyJhbGci..." } |
| 11 | POST /api/register |
{ "name": "Dup", "email": "admin@travlr.com", "password": "Password123" } |
400 + already-exists message |
| 12 | POST /api/register |
{ "name": "X", "email": "x@travlr.com" } |
400 + all-fields-required message |
| 13 | POST /api/register |
a fresh name/email/password | 200 + a usable token |
Requests 7 and 8 returning the same message is deliberate. Saying which half of the credential pair was wrong would let a caller enumerate valid accounts.
Copy the token from request 10. Paste the payload segment (between the two dots) into any base64 decoder to see what a JWT actually carries:
{ "_id": "6a81eb8d...", "email": "admin@travlr.com",
"name": "Travlr Admin", "exp": 1787504175, "iat": 1786899375 }Readable, because a JWT is signed, not encrypted. Which is exactly why the payload holds nothing but identity claims.
On the Authorization tab choose Bearer Token and paste a broken one.
| # | Token | Expected | Verifies |
|---|---|---|---|
| 14 | A real token with the last three characters changed | 401 |
The signature check catches tampering even though the payload is still valid JSON naming a real admin |
| 15 | not-a-jwt |
401 |
A malformed token is rejected, not crashed on |
Same requests as 4–6, but with the good token from request 10 on the
Authorization tab. Body: code, name, length, start, resort,
perPerson, image, description.
| # | Method and URL | Expected | Verifies |
|---|---|---|---|
| 16 | POST /api/trips |
201 + the stored trip |
Create works when authorized |
| 17 | POST the same body again |
400 + duplicate-code message |
The unique index on code is still enforced |
| 18 | PUT /api/trips/<code> |
201 + the updated trip |
findOneAndUpdate saves the changes |
| 19 | PUT with a code that does not exist |
404 |
A missing record is reported, not created |
| 20 | DELETE /api/trips/<code> |
200 + confirmation |
The trip is removed |
| 21 | DELETE the same code again |
404 |
Deleting something already gone is handled |
17, 19, and 21 matter: the security layer did not swallow the existing error handling. A valid token gets a caller in; it does not excuse them from validation.
To see the 500 path, stop MongoDB and re-run request 1.
The GET endpoints can still be checked straight from the browser address bar. The
write endpoints need Postman — a browser address bar can only issue GET, and it
cannot attach an Authorization header.
- Individual trip returns JSON — request 2 returns the trip whose
codematches. - Collection returns JSON — request 1 returns all trips with all eight schema fields on every element.
- All four verbs exercised — GET (1–3), POST (16–17), PUT (18–19), DELETE (20–21).
- User-class endpoints verified —
/loginon requests 7–10,/registeron 11–13. - Authentication tested with mock data — the
admin@travlr.comaccount created bynpm run seed:user, exercised on both the success path (10) and the failure paths (7–9). - Endpoints are actually secured — requests 4–6 prove the writes reject an anonymous caller; 14–15 prove they reject a forged one.
- Errors return correct HTTP status codes —
400on 9, 11, 12, 17;401on 4–8, 14, 15;404on 3, 19, 21; stopping MongoDB covers500.
With the server running, open http://localhost:3000/travel. The page renders the
same six trips as before — but the data now arrives over HTTP from /api/trips
rather than from a database call inside the page controller.
To prove the website is genuinely reading from the API, watch the terminal while
loading /travel. Morgan logs two requests: the page request and the internal API
call it makes.
GET /api/trips 200 12.418 ms - 4181
GET /travel 200 31.006 ms - 8022
Clicking any trip image now navigates to /api/trips/<code> and displays the raw JSON
for that single trip. This is not how a production site would link its images — it is
a quick way to demonstrate that the filtered API call works end to end from the
rendered page.
The controller also handles two failure conditions: a response that is not an array (the API returned an error body) shows "API lookup error", and an empty array shows "No trips exist in the database".
The admin console lives in app_admin and talks to the same /api endpoints the
public site uses. It is a single-page application: the Angular router swaps
components in and out of <router-outlet> without ever reloading the page.
| Piece | File | Job |
|---|---|---|
| Trip listing | app_admin/src/app/trip-listing/ |
Fetches all trips through the data service and lays out one card per trip |
| Trip card | app_admin/src/app/trip-card/ |
Renders a single trip and its Edit / Delete buttons |
| Add trip | app_admin/src/app/add-trip/ |
Reactive form that POSTs a new trip |
| Edit trip | app_admin/src/app/edit-trip/ |
Loads one trip, pre-fills the form, and PUTs the changes |
| Login | app_admin/src/app/login/ |
The HTML login form; POSTs to /api/login |
| Register | app_admin/src/app/register/ |
Creates an account through /api/register |
| Trip data service | app_admin/src/app/services/trip-data.service.ts |
Every HTTP call to the API, in one place |
| Auth service | app_admin/src/app/services/authentication.service.ts |
Login, register, and everything token-related |
| Storage token | app_admin/src/app/services/storage.service.ts |
Injects localStorage instead of reaching for the global |
| Auth guard | app_admin/src/app/guards/auth.guard.ts |
Keeps anonymous users off the write screens |
| Trip model | app_admin/src/app/models/trip.ts |
TypeScript interface matching the Mongoose schema |
| User models | app_admin/src/app/models/user.ts, authresponse.ts |
Types for the auth flow |
The card rendering started out inside the trip listing. Pulling it into its own
trip-card component means the listing only decides which trips to show while
the card decides how one is drawn, so adding a list view later means swapping a
selector instead of rewriting the listing.
TripDataService is the same idea applied to data access. The components never
call HttpClient themselves, so if the API moves or an endpoint changes, only
that one file changes.
| Path | Component | Guard |
|---|---|---|
/ |
Trip listing | — |
/login |
Login form | — |
/register |
Register form | — |
/add-trip |
Add trip form | authGuard |
/edit-trip |
Edit trip form | authGuard |
The listing stays open because GET /api/trips is public. Hitting /add-trip
while logged out redirects to /login?returnUrl=%2Fadd-trip.
The edit screen needs to know which trip to load. The card stashes the trip code
in localStorage before routing, and the edit component reads it back in
ngOnInit.
The SPA runs on port 4200 and the API on port 3000. Browsers treat those as
different origins and block the calls unless the API opts in, so app.js sets
Access-Control-Allow-Origin, -Headers, and -Methods on /api. The
-Methods header matters: without it a browser only allows GET and POST, so PUT
and DELETE would fail even though the routes exist.
MongoDB stores start as a full ISO timestamp, but <input type="date"> only
accepts yyyy-MM-dd. The edit component trims the timestamp before patching it
into the form, so the date box populates correctly instead of rendering empty
with a console warning.
login form ──POST /api/login──▶ passport local strategy
│ validPassword() re-hashes with the
│ stored salt and compares
▼
generateJwt() ──▶ { token }
│
localStorage ◀───── saveToken() ────────────────────┘
│
└─▶ TripDataService adds Authorization: Bearer <token>
│
▼
express-jwt verifies the signature ──▶ req.auth
│
▼
getUser() confirms the account still exists
│
▼
the controller finally runs
| File | Responsibility |
|---|---|
app_api/models/user.js |
Schema + setPassword, validPassword, generateJwt |
app_api/config/passport.js |
Local strategy — are these credentials good? |
app_api/controllers/authentication.js |
/register and /login responses |
app_api/routes/index.js |
Which endpoints require a token |
app.js |
dotenv, passport.initialize(), the 401 error handler |
Splitting it this way means an unguarded write route is visible at a glance in the
route table — it is the line without auth on it. If the check lived inside each
controller function, auditing the same thing would mean opening five files.
The user schema has no password field. setPassword() generates a 16-byte
random salt and runs the plaintext through 1000 rounds of PBKDF2-SHA512, storing
only the salt and the hex hash. validPassword() re-hashes the submitted password
with the same salt and compares; the stored hash is never reversed.
The per-user salt is what stops two admins who both chose Password123 from
getting byte-identical hashes, which is what a rainbow table attacks.
Node's built-in crypto is used rather than bcrypt, so installing the project
needs no native build step.
generateJwt() signs { _id, email, name, exp } with JWT_SECRET. Tokens last
seven days.
A JWT is signed, not encrypted. The payload is base64 and anyone holding the token can read it, so nothing secret goes in it — no salt, no hash, no password. What the signature guarantees is that the payload has not been altered, because only the server knows the secret.
express-jwt is pinned to algorithms: ['HS256']. That is not boilerplate: it
blocks the alg: none attack, where a caller submits an unsigned token whose
header claims no algorithm and asks the library to trust it.
express-jwt proves the signature is valid. The getUser() helper in
app_api/controllers/trips.js proves the account named by the token still exists.
Those are different facts — a token lives seven days, so an account deleted on day
two would otherwise keep writing until day seven.
auth.guard.ts is a usability feature, not a security control. All it does is
read localStorage, and the user owns their localStorage.
This was tested directly: a hand-built token with a real-looking payload and a
garbage signature, pasted into localStorage, walked straight past the guard and
made the navbar read "Signed in as Totally Legit" — and POST /api/trips with
that same token came back 401. The guard's actual job is to stop an honest
user from filling out a long form and only discovering it was pointless when Save
returns an error. The server is what protects the data.
JWT_SECRET lives in .env, which is gitignored — a signing key in version
control is a signing key everybody has. .env.example is committed so a fresh
clone can see which variables the app expects.
require('dotenv').config() is the first line of app.js. JWT_SECRET is read at
require-time when the express-jwt middleware is constructed, so loading dotenv any
later leaves the secret undefined and every token check fails.
- The token is in
localStorage, so a refresh does not log you out. The cost is that any JavaScript on the page can read it, so an XSS hole exposes the token. An HttpOnly cookie is safer against XSS but brings CSRF along and does not work cleanly across the two origins this project uses. Named here rather than hidden. - Logout is client-side only. The API keeps no session, so discarding the token
is the whole operation. The token itself stays technically valid until
exppasses — which is why the expiry is seven days and not seven months. - HTTP, not HTTPS. The password crosses the network in the request body. Fine on localhost; a real deployment has to be TLS.
Defined in app_api/models/user.js.
| Field | Type | Validation |
|---|---|---|
name |
String | required, max 100 chars |
email |
String | required, unique, indexed, lowercased, must match an email pattern |
salt |
String | required — 16 random bytes, hex |
hash |
String | required — PBKDF2-SHA512, 1000 rounds, 64 bytes, hex |
timestamps: true adds createdAt and updatedAt.
Defined in app_api/models/travlr.js.
| Field | Type | Validation |
|---|---|---|
code |
String | required, unique, indexed, uppercase, max 20 chars |
name |
String | required, indexed, max 100 chars |
length |
String | required |
start |
Date | required |
resort |
String | required |
perPerson |
String | required, must match a currency format |
image |
String | required |
description |
String | required |
timestamps: true adds createdAt and updatedAt so records can be audited.
In MongoDB Compass, connect to mongodb://127.0.0.1:27017 and open the travlr
database, trips collection. Confirm 6 documents with the fields above, and check
the Indexes tab for the unique index on code.
Then open the users collection. The single document should have name, email,
salt, hash, createdAt, and updatedAt — and no password field anywhere.
That absence is the point of the whole hashing exercise.
From the command line:
mongoshuse travlr
db.trips.countDocuments()
db.trips.find().pretty()
db.users.find().pretty()
Cannot find module 'http-errors' — run npm install first.
Could not connect to MongoDB — start the MongoDB service, then re-run
npm run seed.
API returns 200 with [] — the connection works but the collection is empty.
Run npm run seed.
Travel page says "No trips exist in the database" — same cause: the API returned a
valid but empty array. Run npm run seed.
Travel page says "API lookup error" — the API returned an error body instead of an
array. Check GET http://localhost:3000/api/trips in Postman and look at the terminal
for the underlying Mongoose error.
secretOrPrivateKey must have a value, or every write returns 401 — .env is
missing or JWT_SECRET is empty. Run copy .env.example .env and restart the server.
Login returns 401 with correct credentials — the users collection is empty. Run
npm run seed:user.
A write returns 401 from the SPA even though the navbar says you are signed in —
the token expired. Log out and back in. The forms report this as "Your session has
expired".
Everything worked, then every write started returning 401 after a restart — check
that JWT_SECRET did not change. Tokens are signed with it, so a new secret
invalidates every token already issued.
ng is not recognized — the Angular CLI is not installed globally. Use
npx ng serve instead, or run npm install -g @angular/cli.
Unexpected "var" in JSON during ng build — esbuild walks up the directory tree
looking for a package.json and found a malformed one in a parent folder. Move the
project somewhere with a clean path (not inside a temp directory) and rebuild. This is
an environment problem, not a project one.
The admin pages render unstyled — Bootstrap is loaded from a CDN in
app_admin/src/index.html, so the SPA needs an internet connection the first time a
browser loads it.
The zip contains no node_modules, no build output, and no absolute paths, so it is
portable. What the target machine has to supply:
| Needs | Why |
|---|---|
| Node.js 18+ | bin/www and the seed scripts |
MongoDB on 127.0.0.1:27017 |
Nothing is bundled; the database has to exist locally |
| Internet, once | Two npm install runs and the Bootstrap CDN |
Everything else travels with the archive, including .env, so no secret has to be
regenerated. Both package-lock.json files are current, so the installs are
reproducible.
Full sequence on a clean machine:
npm installnpm run seed && npm run seed:usernpm startcd app_admin && npm install && npx ng serveTo point at a MongoDB somewhere other than localhost, set MONGODB_URI or DB_HOST
before starting — no code change needed.
The one thing that is genuinely hard-coded is the API base URL,
http://localhost:3000/api, in app_admin/src/app/services/trip-data.service.ts and
authentication.service.ts. That is fine as long as both servers run on the same
machine, which is how this project is meant to be graded. Serving the API from a
different host would mean changing those two lines — properly, it belongs in an
Angular environment file, which is a refactor for a later module.
travlr/
├── .env JWT_SECRET (gitignored)
├── .env.example committed template
├── app.js dotenv, DB, passport, /api, 401 handler
├── bin/www
├── data/trips.json seed data (6 sample trips)
├── app_api/ REST API — owns all database access
│ ├── config/passport.js local strategy: are these credentials good?
│ ├── controllers/
│ │ ├── trips.js Mongoose logic + getUser() on the writes
│ │ └── authentication.js /register and /login
│ ├── models/
│ │ ├── db.js Mongoose connection; registers both models
│ │ ├── travlr.js trip schema + model
│ │ ├── user.js user schema, hashing, generateJwt
│ │ ├── seed.js loads data/trips.json into MongoDB
│ │ └── seed-user.js creates the mock admin account
│ └── routes/index.js route table — where auth is applied
├── app_admin/ Angular admin SPA (runs on port 4200)
│ └── src/app/
│ ├── trip-listing/ fetches and lays out the trip cards
│ ├── trip-card/ renders one trip + Edit / Delete
│ ├── add-trip/ reactive form, POSTs a new trip
│ ├── edit-trip/ loads a trip, PUTs the changes
│ ├── login/ the HTML login form
│ ├── register/ account creation
│ ├── guards/auth.guard.ts keeps anonymous users off the write screens
│ ├── services/ trip-data, authentication, storage token
│ ├── models/ trip.ts, user.ts, authresponse.ts
│ ├── app.routes.ts '', login, register, add-trip, edit-trip
│ └── app.config.ts provideRouter + provideHttpClient
├── app_server/ website — no database access
│ ├── controllers/ travel.js fetches from the API
│ ├── data/travlr.js static copy for home/news pages
│ ├── routes/
│ └── views/
└── public/
I built the front end three ways here. The original /public folder was static HTML with all six trips typed into travel.html, so changing a price meant editing markup. Handlebars fixed that: /travel hits a route, the controller gets the data, and res.render('travel', { trips }) pours it into a template that works the same for six trips or six hundred. The page arrives finished, which is fast, but every click is a full reload.
The Angular admin works the opposite way. It loads once and the router swaps components in and out of <router-outlet> without reloading. Deleting a trip refreshes nothing: the card emits an event, the listing re-fetches, only the list redraws. It feels like a desktop app, but far more logic lives in the browser, and I had to track state in a way Handlebars never required.
MongoDB made sense once I built the API. A trip document already looks like what the API returns, so it goes into Mongo, back through Mongoose, out through res.json(), and into a Trip object in Angular with no conversion anywhere. The catch is that Mongo would happily store a trip with no price. That is what Mongoose is for: the schema in app_api/models/travlr.js holds the real rules, including a unique index on code.
I used "JavaScript" and "JSON" interchangeably for too long. JavaScript is a language that runs. JSON borrowed its object-literal syntax and dropped everything else. It is data sitting still.
That matters because JSON is the only thing crossing boundaries here. Inside Node I have Mongoose documents with methods like generateJwt(). res.json() flattens that into text, it travels as a string, and Angular's HttpClient parses it back into an object. My Trip interface and my Mongoose schema describe the same JSON shape in two languages, which is why the field names match.
The biggest refactor was Module 5. My website controller queried MongoDB and rendered the page in the same function, so building the API meant writing the same Model.find({}) twice. I moved the Mongoose code into app_api/controllers/trips.js, rewrote the website controller to fetch /api/trips, and deleted the duplicate GET /travel/json route. The payoff came in Module 6, when I built the SPA and wrote zero new database queries.
I also pulled the trip card out of trip-listing.component.html into its own TripCardComponent. Adding Edit and Delete in Module 7 meant changing one file, and every card got them. Same with TripDataService: when the API started requiring a token I wrote authHeaders() once and touched three methods. No component knows a token exists.
A method is the verb, an endpoint is the noun. GET /api/trips and DELETE /api/trips/GALR210214 sit on related URLs but are different operations, which is why I have no /getTrips route.
I tested all seven endpoints in Postman before wiring up the SPA, so when something broke later I knew the API was fine. Security made that harder. Testing a PUT now meant calling /api/login first, copying the token, and pasting it into an Authorization: Bearer header with exactly one space. I collected a lot of 401s over formatting.
The real shift was testing failures on purpose. A passing request with a good token proves nothing by itself, so I ran every write endpoint three ways: no token, a garbage token, and a valid one. The first two have to return 401, and the GETs still have to return 200 without a token, since the customer site reads those anonymously. The check is layered too: express-jwt proves the signature is valid but not that the account still exists, so every write controller also runs getUser().
Coming in, I could build a page. I could not build an application.
What I have now is the ability to follow the whole path: a click in an Angular component, through a service, over HTTP, into an Express route, past middleware, through a controller, into Mongoose, into MongoDB, and back. Those layers used to be black boxes. I can also explain why passwords are salted and hashed rather than encrypted, and what a JWT signature actually protects.
The less glamorous skill was handling the gap between a tutorial and reality. The course materials use an older Angular with NgModules; I built on Angular 19 with standalone components. express-jwt v8 puts the payload on req.auth, not req.payload like most examples online. Figuring out where the docs and the instructions disagreed felt more like real work than anything else I did. The bugs helped too: my home route was silently shadowed by public/index.html until I set index: false on express.static.
Next I want to learn Jasmine and Karma so I am not testing by hand, and I want to deploy something, because there is a real gap between localhost and a server. Mostly I am glad I have something I can show someone. It has an API, a validated database, two front ends, and a login that works.