NowHere: Nearby snaps on NestJS microservices
Nearby snaps — NestJS, NATS, Expo. Local compose demo.
Expo client walkthrough — nearby map, snap detail, capture, profile
What it is
This is a local Compose demo / portfolio system, not a hosted product. There is no production URL.
NowHere is a location-based social app you run on your machine. Open the map, see photo snaps other people posted near you, drop your own. The recording at the top is the Expo client: nearby map, snap detail, capture, and profile. Each snap has a caption, a tag, a GeoJSON point, and an expiry (author setting, default 1 day). You can post at most one snap per area per day (author newSnapDistance, default 1 km). Lost/found snaps can be marked found and reopened. Bookmarks and reports live in MySQL; hiding a snap for yourself is local MMKV only.
Selectable tags in the client:
| Tag | Meaning |
|---|---|
SOCIAL | A nearby moment |
INTERESTING | Flagged as worth a look |
HIDDEN_GEM | A local spot that is easy to miss |
FINDINGS | Something was found here |
LOST | Something was lost here |
PROMOTION | A nearby offer or pop-up |
HTTP from the phone always hits one gateway. The gateway never opens MySQL, Mongo, or S3 itself — it calls domain services over NATS. Socket.IO is the exception: the client opens a websocket straight to the snaps process.
Source:
- Backend: AliSaleemHasan/NowHere-backend
- Mobile: AliSaleemHasan/NowHere-frontend
Topology
Client HTTP goes to the gateway on :3005. Socket.IO goes around the gateway, to snaps on :3000. Kubernetes ingress is written the same way (/ → gateway, /socket.io → snaps).
Figure 1. Client HTTP hits the gateway; Socket.IO hits snaps; services share NATS; each domain owns its store.
Local ports (dev compose publishes all of these):
| Process | Port | Store |
|---|---|---|
| API gateway | 3005 | none |
| Snaps (HTTP + Socket.IO) | 3000 | MongoDB |
| Users | 3001 | MySQL Users_Info |
| Storage | 3002 | MinIO/S3/GCS + Redis |
| Authentication | 3004 | MySQL Users_Credentials |
| NATS | 4222 (monitor 8222) | JetStream file store |
| Redis | 6379 | signed download URL cache |
| MinIO | 9000 / console 9001 | object store |
| MySQL | 3307 → 3306 | auth + users |
| MongoDB | 27017 | snaps |
| Mailhog (dev) | 1025 / UI 8025 | password-reset mail |
Production-like compose publishes only gateway :3005 and snaps :3000. Auth, users, and storage stay on the internal network.
The five services
Nx 21 + pnpm workspaces. Apps under apps/, shared code under libs/contracts (Zod schemas + NATS subject constants) and libs/nowhere-common (bootstrap, filters, NATS helpers, geo, health, storage-key rules).
Figure 2. Controllers, stores, and NATS families.
1. API gateway (apps/gateway, :3005)
The only public HTTP API. Nest HTTP app that calls natsRequest(...) for domain work. Default RPC timeout 8 s (login/signup and account delete 15 s).
- Helmet always on. CORS required in production (
CORS_ORIGINcomma-separated). Credentials enabled. GatewayAuthGuard: verifies the access JWT locally withACCESS_SECRET. Injectsrequest.user. Does not call auth over NATS on every request.GET /auth/me: returns that decoded user. No NATS hop.DataResponseInterceptor: wraps HTTP success as{ success: true, data }except/healthand/docs.HttpExceptionFilter:Content-Type: application/problem+json(RFC 9457 fieldstype,title,status,detail,instance, plustimestamp).- Throttling: 60 req / 60s globally; login/signup 5 / 60s; forgot/reset 5 / 15 min.
/healthis skipped. - Admin routes (
RoleGuard): list all users/snaps/files, delete all snaps. - Swagger at
/docswhenENABLE_SWAGGER=true.
2. Authentication (apps/authentication, :3004)
Owns credentials (uuid, unique email, bcrypt hash, isActive, role ADMIN | USER, lastLoginAt, lockout fields) and password_reset_tokens.
NATS subjects:
auth.validateUser
auth.signup
auth.refreshToken
auth.changePassword
auth.forgotPassword
auth.resetPassword
auth.deleteCredentials
auth.deactivateUser
Passwords are bcrypt (genSalt() default 10 rounds). Tokens are HS256 with ACCESS_SECRET / REFRESH_SECRET. Access default 15m, refresh default 7d. Refresh is stateless: the refresh JWT is verified and a new pair is issued. There is no refresh-token store.
After 5 failed logins the account locks for 15 minutes. Password policy: min 8, one upper, one lower, one number, one symbol. Forgot-password always returns 202 (no email enumeration). Reset tokens are 32 random bytes, stored as HMAC-SHA256, TTL 1 hour, single-use. Dev compose sends mail through Mailhog.
On signup (and on admin seed), auth publishes JetStream event auth.user.credentials.created.
3. Users (apps/users, :3001)
Owns users, settings, snap_seen, snap_bookmark, snap_report. Settings defaults on the entity: maxDistance 10 000 m, newSnapDistance 1 000 m, snapDisappearTime 1 day. Presets the client can pick: 1/5/10/25 km, 250/500/1000/2000 m, 1/3/7 days. If the settings RPC fails, nearby falls back to 5 km.
NATS subjects include profile, settings, seen, bookmarks, reports, DSGVO export, and purge:
users.getSettings
users.updateSettings
users.updateProfile
users.getUserById
users.setUserPhoto
users.setSeenSnap
users.notSeenSnaps
users.addBookmark
users.removeBookmark
users.listBookmarks
users.createReport
users.exportUser
users.purgeUser
A JetStream durable consumer users-auth-credentials on stream AUTH_EVENTS creates the profile row when credentials are created. Explicit ack, max_deliver: 5, backoff 2s / 10s / 30s / 60s.
4. Snaps (apps/snaps, :3000)
Mongo document: description (max 280), snaps (1–4 object keys), _userId, GeoJSON Point [lng, lat], tag, status, expiresAt, optional idempotencyKey, lost/found resolution. Indexes: location: 2dsphere, { createdAt: -1, location: '2dsphere' }, { expiresAt: 1 }, unique sparse { _userId, idempotencyKey }.
NATS subjects:
snaps.findNear
snaps.findSeen
snaps.findOne
snaps.findAll
snaps.findByTags
snaps.findByUser
snaps.create
snaps.deleteOne
snaps.deleteAll
snaps.deleteByUserId
snaps.markFound
snaps.reopen
GET /snaps/near/:lng/:lat is unseen nearby snaps. GET /snaps/seen/:lng/:lat is the seen list. Both take optional tags. Opening GET /snaps/:id marks the snap seen. GET /snaps/me lists the author’s snaps (includeExpired defaults true).
A TTL job runs every 15 minutes, batch 100: delete object keys then the Mongo docs.
Socket.IO lives here, not on the gateway. Handshake JWT from auth.token or Authorization. Clients emit locationChange with [lng, lat]. On create, handleNewSnap walks the in-memory map, keeps sockets within 100 km Haversine, and emits snap-added.
5. Storage (apps/storage, :3002)
storage.uploadPhoto
storage.getSignedUrl
storage.getSignedUrls
storage.getPresignedUpload
storage.listFiles
storage.deleteFiles
STORAGE_PROVIDER in {gcp,gcs,google} binds GCSStorageStrategy. Anything else (default aws) binds S3StorageStrategy with forcePathStyle: true, so MinIO and R2-compatible endpoints work by setting AWS_ENDPOINT_URL. There is no separate MinIO class.
Download URLs are cached in Redis. storage.service.ts falls back to CACHE_TTL = 86340 seconds when the env var is unset. CacheModule itself defaults to 3600 if the env is missing at module init — k8s example sets CACHE_TTL: '86340' so they agree. Presigned PUT URLs expire in 3600 s.
Auth fan-out (the JetStream path)
Signup is request-reply plus a durable event, because the credentials row and the profile row live in different MySQL databases.
Expo Gateway :3005 Auth :3004 NATS JetStream Users :3001
│ │ │ │ │
│ POST /auth/signup │ │ │ │
│ │ NATS auth.signup │ │ │
│ │─────────────────────────►│ bcrypt.hash │ │
│ │ │ INSERT credentials │ │
│ │ │ publish ──────────────────►│ AUTH_EVENTS │
│ │ │ auth.user.credentials.created │
│ │ │ sign HS256 access+refresh │ durable consumer │
│ { user, tokens } │◄─────────────────────────│ │ users-auth-credentials │
│ │ │ │─────────────────────────►│ INSERT users
│ GET /auth/me │ (no NATS — JWT only) │ │ │
│◄────────────────────│ │ │ │
If the users consumer is down, JetStream keeps the message. The client can already hold tokens; the profile appears when the consumer catches up. The Expo client retries settings 404 after signup for that reason.
Account delete is the reverse orchestration on the gateway: deactivate auth → snaps.deleteByUserId → users.purgeUser → auth.deleteCredentials. A later-step failure returns 500 ACCOUNT_DELETE_INCOMPLETE.
Snap media: presign, PUT, then metadata
Camera photos do not go through Nest. The client asks the gateway for a PUT URL, uploads bytes to MinIO/S3/GCS, then posts keys.
Figure 3.
POST /storage/presigned-upload → NATS storage.getPresignedUpload → client PUT → POST /snaps with object keys.
Gateway rules:
- Batch limit 4 (
MAX_UPLOAD_BATCH/MAX_SNAP_IMAGES). - Content type allow-list: jpeg / png / webp.
- Object key is owned:
snaps/{date}/{userId}/{ts-rand}.ext. The gateway builds the key; the client cannot pick an arbitrary path. POST /snapsthenassertSnapImageKeysso you cannot attach someone else’s key.- Optional
idempotencyKey(UUID). Same user + key returns the existing snap. The Expo client does not send one; the compose smoke test does.
Create also enforces “already posted in this area today”: a $near query with newSnapDistance in the disappear window → 403 SNAP_ALREADY_IN_AREA.
Profile photos are a different path. PUT /users/image streams the file through the gateway (5 MiB FileInterceptor) and over NATS as a buffer to storage.uploadPhoto, key profile/{userId}.
Nearby query vs live fan-out
Three distances, three jobs:
| Mechanism | Default | Where |
|---|---|---|
| HTTP nearby query | user settings.maxDistance (10 km), or 5 km if settings fetch fails | Mongo $geoWithin + $centerSphere (radians), fallback $near + $maxDistance |
| Duplicate-post guard | newSnapDistance 1 km | Mongo $near |
| Socket.IO fan-out | hardcoded 100 km | in-memory Haversine over connected sockets |
The 100 km figure is not the map query radius. The map query is a few kilometres. The socket walk is a coarse “is this client roughly around this snap?” filter so distant sockets do not get snap-added. Clients that were offline pick the snap up on the next GET /snaps/near.
Haversine lives in libs/nowhere-common/src/utils/geo.ts (haversineMeters, earth radius 6 371 000 m). The Socket.IO gateway filters a Map.
Figure 4. After Mongo insert, snaps walks connected sockets and emits
snap-added inside 100 km.
// apps/snaps/src/snaps/gateway.ts — in-memory, single process
findNearbyUsers(lat: number, lng: number, radiusKm: number) {
const radiusInMeters = radiusKm * 1000;
return Array.from(this.usersLocationMap.values()).filter((user) => {
return (
haversineMeters(lat, lng, user.coordinates[1], user.coordinates[0])
<= radiusInMeters
);
});
}
The comment above that map:
Does not survive process restart or a second snaps replica — sticky sessions or a shared store is a later phase.
Kubernetes currently sets replicas: 1 for every app, which matches that.
NATS: request-reply for RPC, JetStream for one event
Figure 5. Left — gateway RPC. Right — stream
AUTH_EVENTS.
Request-reply (NATS Core client.send / @MessagePattern) is how the gateway answers HTTP, and how snaps talks to users (settings, seen) and storage (signed URLs, deletes).
JetStream stream AUTH_EVENTS, subjects auth.user.>, file storage, max age 7 days. One publisher (auth), one durable consumer (users). Snap create does not publish a JetStream event.
Shared contract package so frontend TypeScript and backend Nest do not drift on payload shape: Zod schemas in libs/contracts.
Storage wiring
Figure 6. Storage is selected at
StorageModule boot. SecretManagerModule in nowhere-common is optional library code; apps currently boot from ConfigModule / .env.
// apps/storage/src/storage.module.ts
useFactory: (configService: ConfigService) => {
if (isGcpStorageProvider(configService.get('STORAGE_PROVIDER'))) {
return new GCSStorageStrategy(configService);
}
return new S3StorageStrategy(configService);
}
Gateway HTTP the phone actually calls
REST from Expo goes to EXPO_PUBLIC_GATEWAY_URL with a domain prefix (/auth, /users, /snaps, /storage). Socket URL is separate: EXPO_PUBLIC_SNAPS_SOCKET_URL.
| Method | Path | Auth | Backend hop |
|---|---|---|---|
POST | /auth/login, /auth/signup | public (throttled) | auth.validateUser / auth.signup + JetStream |
POST | /auth/forgot-password | public | auth.forgotPassword (always 202) |
POST | /auth/reset-password | public | auth.resetPassword |
GET | /auth/refresh | refresh JWT | auth.refreshToken |
GET | /auth/me | access JWT | none |
GET/PUT | /users/settings | access JWT | settings RPC |
PATCH | /users/me | access JWT | users.updateProfile |
GET | /users/me/export | access JWT | DSGVO JSON (object keys, not signed URLs) |
DELETE | /users/me | access JWT + password | gateway orchestrator |
GET/PUT/DELETE | /users/me/bookmarks[/:snapId] | access JWT | bookmark RPCs |
PUT | /users/image | access JWT, multipart | users.setUserPhoto |
GET | /snaps/near/:lng/:lat | access JWT | snaps.findNear |
GET | /snaps/seen/:lng/:lat | access JWT | snaps.findSeen |
GET | /snaps/me | access JWT | snaps.findByUser |
GET | /snaps/:id | access JWT | snaps.findOne + signed URLs |
POST | /snaps | access JWT | snaps.create |
DELETE | /snaps/:id | owner or admin | snaps.deleteOne |
POST | /snaps/:id/found, /reopen, /report | access JWT | resolution / report |
POST | /storage/presigned-upload | access JWT | storage.getPresignedUpload |
GET | /health | public | Terminus |
Success: { success: true, data }. Errors: problem+json. The Expo HttpClient accepts both, unwraps the envelope, and maps problem details onto ApiError.
Mobile client (Expo 53)
React Native 0.79, Expo Router 5, NativeWind. Feature folders: features/auth, features/snaps, features/users, features/map. Language toggle (DE/EN) is device-only, stored in MMKV — not a gateway setting.
Figure 7. Screens → TanStack Query / Zustand → HttpClient + Socket.IO. Map rendering is MapLibre (OpenFreeMap liberty style);
ReactNativeMapsStrategy sits behind the same interface.
Screens (expo-router):
| Route | Job |
|---|---|
onboarding, location-consent | First-run + location |
(tabs)/index | Map of nearby snaps |
(tabs)/add-snap | Tab press → camera modal (or login) |
(snaps)/snaps-capture | expo-camera |
(snaps)/snap-inputs | Description + tag, then presign/PUT/create |
(snaps)/[id] | Snap detail + signed images |
(auth)/login, signup, forgot-password, reset-password | Credentials |
(tabs)/profile, settings, my-snaps, saved | Profile, settings, own snaps, bookmarks |
(legal)/privacy | Privacy copy |
Session. Access and refresh tokens live in Expo SecureStore. useAuthSession hydrates from SecureStore, then GET /auth/me. 401 logs out. A network error keeps the local session so a cold start offline does not bounce you to login.
MMKV persists Zustand slices that are not secrets: last location, onboarding flag, user profile cache, snap draft, hidden-snap ids, locale.
Nearby UI is a map, not a list. Markers are colored by tag; a bottom sheet filters tags and “new nearby” vs “already opened.”
Live sockets. useSnapSocket connects with the access token, emits locationChange whenever lng/lat change, and upserts on snap-added. Location is not continuously watched (watchPositionAsync is unused).
Uploads. Camera capture uses JPEG quality 0.85. Presigned PUT URLs that come back as minio-local / localhost are rewritten to the phone’s LAN host so a device can hit Docker MinIO.
HTTP. Shared HttpClient with single-flight refresh, retry unless 4xx, Accept: application/json, application/problem+json.
Kubernetes and compose
k8s/apps.yaml + infra.yaml + ingress.yaml:
- Five Deployments, replicas: 1, non-root uid 1000,
/healthprobes. - Gateway Service is a LoadBalancer on 3005. Everything else ClusterIP.
- Ingress:
/socket.io→ snaps:3000,/→ gateway:3005. - Secrets are gitignored;
k8s/secrets.example/holdschange_me_*placeholders.
Tests
Backend: Jest unit specs per service (auth bcrypt/JWT/lockout/reset, gateway signup→presign→create→near with mocked NATS, snaps geo query shape and 100 km emit, TTL, found/reopen, storage cache hit/miss, bookmarks/reports/export/purge) plus a local compose smoke (pnpm test:e2e:smoke) that hits a running gateway.
Frontend: Jest + Testing Library for ApiError, presign/create snap, MapLibre vs RN Maps, snap details, camera, tags, bookmarks, export/delete, geo helpers.
CI runs lint, typecheck, unit tests, and (backend) a no-push docker build of the gateway image. It does not start compose.
Why these choices
| Decision | Choice | What it costs |
|---|---|---|
| Inter-service RPC | NATS request-reply, subjects in contracts | A broker to operate; worth it once there are five processes |
| Snap photos | Presigned PUT | Two-step client protocol; key-ownership checks on create |
| Profile photos | Multipart through gateway | Simpler; it is the bandwidth path snaps avoid |
| Nearby query | Mongo 2dsphere + $geoWithin | Flexible documents/tags; not PostGIS |
| Live updates | Socket.IO in-process | Fast to write; one snaps replica |
| Cross-DB signup | JetStream AUTH_EVENTS | Profile can lag tokens; consumer must stay up |
| Gateway auth | Local HS256 verify | Auth service is not on the hot path for /me; rotating keys is a shared secret |
| Passwords | bcrypt | Fine for this app |
| Errors | problem+json | Mobile can parse detail / status uniformly |
| Map | MapLibre strategy | OSM tiles; RN Maps remains a one-line swap |
Run it locally
# backend
git clone https://github.com/AliSaleemHasan/NowHere-backend.git
cd NowHere-backend
pnpm install
docker compose -f docker-compose.dev.yml up --build
curl http://localhost:3005/health # gateway
curl http://localhost:3000/health # snaps (Socket.IO lives here too)
pnpm test
# with compose up:
pnpm test:e2e:smoke
# mobile
git clone https://github.com/AliSaleemHasan/NowHere-frontend.git
cd NowHere-frontend
npm install
# point the phone at your LAN, not localhost
# EXPO_PUBLIC_GATEWAY_URL=http://<lan-ip>:3005
# EXPO_PUBLIC_SNAPS_SOCKET_URL=http://<lan-ip>:3000
npx expo start