A high-level map of the system, written for a senior engineer joining the codebase. Pair this with lib/db/src/schema/index.ts (data shape), lib/api-spec/openapi.yaml (contract), and threat_model.md (security posture).
┌─────────────────────────┐ ┌──────────────────────────┐
│ Glimpse mobile (Expo) │ HTTPS │ API server (Express 5) │
│ artifacts/glimpse │────────▶│ artifacts/api-server │
│ - Clerk @clerk/expo │ │ - Clerk @clerk/express │
│ - React Query hooks │ │ - Zod validation │
│ (generated) │ │ - Drizzle ORM │
└─────────────────────────┘ └──────────┬───────────────┘
│
┌──────────────────────────────────┼──────────────────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ Postgres │ │ Clerk │ │ Replit App │
│ (Drizzle schema) │ │ (auth provider) │ │ Storage (GCS) │
└──────────────────┘ └─────────────────┘ └──────────────────┘
▲
┌──────────────────┐ │
│ Google Places │◀─── server-proxied via /api/places/* │
│ (server only) │ │
└──────────────────┘ signed URL uploads
The mobile client never talks directly to Postgres, Clerk's secret API, Google Places, or Storage's signing endpoints. Every protected resource flows through the Express API.
Single source of truth: lib/api-spec/openapi.yaml (OpenAPI 3.1).
lib/api-spec/openapi.yaml
│
┌─────────────────────┴──────────────────────┐
│ orval codegen │
▼ ▼
lib/api-client-react/ lib/api-zod/
(React Query hooks) (Zod schemas)
│ │
▼ ▼
consumed broadly by available to artifacts/api-server
artifacts/glimpse (currently used only by health.ts;
(typed hooks per endpoint) other routes use handwritten Zod)
The OpenAPI spec is the source of truth for the client hooks, which are consumed across the mobile app. The matching server-side Zod schemas exist in @workspace/api-zod but most route handlers today use handwritten Zod inline. Adopting the generated schemas across all routes is a planned cleanup, not a blocker for first release.
To change the contract, edit openapi.yaml, run pnpm --filter @workspace/api-spec run codegen, then pnpm run typecheck to surface every place that needs updating.
- Identity provider: Clerk. Each user is a Clerk user; the app database stores a corresponding row in
userskeyed on the Clerkuser_id. - Session transport: Clerk JWT short-lived sessions held by
@clerk/expo. The mobile client attaches the session via the standard Clerk handshake — for HTTP it sends a bearer token in theAuthorizationheader. There is no Express session cookie store and noSESSION_SECRETconsumed at runtime. - Server validation:
clerkMiddlewarefrom@clerk/expressruns early in the stack and populatesreq.auth. TherequireAuthmiddleware (artifacts/api-server/src/middlewares/requireAuth.ts) gates every protected route on a non-emptyreq.auth.userId. Storage routes (POST /storage/uploads/*,/storage/objects/*) are gated by an inlinerequireAuthblock inroutes/index.tsbeforestorageRoutermounts; only/storage/public-objects/*and/api/healthzremain public. - CORS + authorizedParties: both layers read from the same allowlist (
ALLOWED_ORIGINSenv var, falling back toREPLIT_DOMAINS) so the same origin policy is enforced twice — at CORS and again inside Clerk's session validator. In production, the server refuses to start if neither is set. - Production Clerk frontend proxy: when the production Clerk instance is fronted by our own domain, requests under
/api/__clerk/*are forwarded byclerkProxyMiddlewareto Clerk's frontend API. This is howEXPO_PUBLIC_CLERK_PROXY_URLis wired end-to-end. - 18+ gate: enforced at profile-setup (
app/profile-setup.tsx), not at signup. Onboarding is mandatory before any product action so the gate is functionally pre-use.
Defined in lib/db/src/schema/index.ts. Tables:
| Table | Purpose | Notes |
|---|---|---|
users |
Clerk-keyed profile + onboarding flags | id = Clerk user_id; cascades to all child rows |
profile_photos |
Ordered photo list per user (max 6, app-enforced) | references App Storage key + public URL |
presence_buckets |
Coarse (bucketLat, bucketLng, seenAt) rows used for matching |
indexed for bucket-time lookups; 72h expiry |
glimpses |
Author + place + descriptors + visibility/expiry timestamps | exact lat/lng stored; never returned to others |
glimpse_responses |
"this might be me" submissions | unique per (glimpseId, responderId) |
connections |
Mutual-confirm result; gates chat | |
chat_messages |
1:1 messages tied to a connection | |
blocks |
Two-way block enforcement | |
reports |
Server-validated abuse reports | text gated by isInappropriate before persist |
events |
In-house analytics ingestion | no third-party analytics SDK |
Enums: glimpse_status (pending/visible/matched/expired), response_status (pending/confirmed/declined), report_reason (harassment/spam/fake/inappropriate/other), subscription_status (free/active/past_due/canceled — schema only, no purchase flow ships in 1.0).
The product promise is "I saw someone, near here, recently — show me Glimpses about people who were also here." That promise is implemented as three layers:
- Presence ingestion — the client periodically posts to
/api/presencewith the user's current(lat, lng). The server bucketizes to(floor(lat*100), floor(lng*100))(≈1 km grid) and writes a row with a 72hexpiresAt. - Posting gate — when the user tries to drop a Glimpse at a place, the server confirms the user has a presence row in the 3×3 neighborhood of the target bucket (
NEIGHBOR_BUCKET_RADIUS = 1) and within ±2 hours of the event time (PRESENCE_WINDOW_SECONDS = 7200), via the SQL helperspresenceInNeighborhood/presenceWithinTimeOf(artifacts/api-server/src/lib/locationValidation.ts). The client runs an advisory, spatial-only pre-flight —checkPresencePreflight()from@workspace/location-validation, used incomponents/DropPanel.tsx— to surface friendly UX hints. The constants are shared, but the time check is server-only; the client check is never authoritative. - Visibility delay — a Glimpse becomes discoverable to other users 1 hour after its
eventTime(VISIBLE_DELAY_MS = 60 * 60 * 1000inroutes/glimpses.ts). Note: earlier product copy andreplit.mdhistorical notes mention 4 hours — the current code is 1 hour. Reconcile before App Review messaging is finalized. - Discovery match —
/api/glimpses/discoverjoins the viewer's recent presence rows against authored Glimpses using the same neighborhood radius and ±2h window, requiringvisibleAfter <= nowand the author not blocked.
Privacy guarantees that must hold:
- No exact coords are ever returned to another user. Only a place name + display label.
- Times are fuzzed in the UI to "this morning", "earlier today", etc.
- Presence is purged at 72h by
expiresAtfiltering (and indexed for batch cleanup). - Coordinates are fuzzed ~30m before bucketization for additional privacy on sensitive locations.
- Sensitive locations auto-fallback to broader labels.
- Nominatim is rate-limited when used as the reverse-geocoding fallback.
A drops Glimpse ──▶ visibleAfter passes ──▶ B sees it in Discover
│
▼
B responds ("might be me")
│
▼
A reviews response → confirm | decline
│
confirm │ decline
│ │
▼ ▼
connection row created response.status = declined
│ (no further action possible)
▼
chat unlocked for both
Chat is gated server-side on the existence of a connections row binding the two userIds. Block on either side immediately filters the connection out of every list endpoint.
- Provider: Replit App Storage, GCS-compatible.
- Upload flow: client requests a signed URL from
POST /api/storage/uploads/request-url(rate-limited). ClientPUTs bytes directly to the signed URL. Client then callsPOST /api/me/photoswith the resultingobjectPathto bind it to their profile. The bind step normalizes the path, verifies it points under the private object dir, parses the uploader user id out of the path, and rejects if it doesn't match the caller (routes/photos.ts).PUT /api/me/photosis reorder-only — it does not bind new photos. - Serving:
/api/storage/public-objects/*filePath— for assets underPUBLIC_OBJECT_SEARCH_PATHS/api/storage/objects/*path— for private user content; access control enforced before streaming
- Cap: 6 photos per user, enforced at the API.
- Cleanup:
DELETE /api/me/photos/:photoIdremoves the DB row and compacts the remainingpositionvalues. It does NOT delete the underlying object in storage — orphan objects accumulate. Worth wiring up object deletion as a P1 cleanup; not a TestFlight blocker.
| Service | Surface | Secret(s) |
|---|---|---|
| Clerk | Auth identity, sessions, password resets, account deletion | CLERK_SECRET_KEY (server), EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY (mobile) |
| Postgres | Primary data store | DATABASE_URL (server + build) |
| Replit Storage | Profile photos, public assets | PRIVATE_OBJECT_DIR, PUBLIC_OBJECT_SEARCH_PATHS (read by app code); DEFAULT_OBJECT_STORAGE_BUCKET_ID (Replit-provisioned bucket metadata, used by GCS client) |
| Google Places | Autocomplete + place details (server-proxied only) | GOOGLE_PLACES_API_KEY (server) |
| OSM/Nominatim | Reverse geocoding fallback | none (rate-limited per Nominatim policy) |
| Expo / EAS | Mobile build pipeline + OTA distribution | EAS account; secrets configured per build profile |
No Stripe, RevenueCat, Segment, Mixpanel, Amplitude, Sentry, Datadog, or other paid SaaS dependency ships in 1.0.
- Server uses Pino via
pino-http. Request logs include method, path (no querystring), and request id. - Never use
console.login server code —req.logfor request scope,loggerfromsrc/lib/logger.tsfor module scope. - 500 responses are normalized to
{error, code:"internal"}so internals never leak. - No external APM is wired in. Production logs are read via the Replit deployment logs UI.
| Artifact | Build | Runtime |
|---|---|---|
api-server |
esbuild → dist/index.mjs (single ESM bundle) |
Node 20+, listens on $PORT, served on /api |
glimpse |
EAS Build (iOS / Android) | Distributed via TestFlight + Play Internal Testing |
mockup-sandbox |
Vite (dev only) | Not deployed; used as an in-Replit canvas tool |
The Replit deployment runtime hosts only api-server. The mobile client is built and shipped through Expo / EAS.
The repo uses drizzle-kit push (not versioned migrations) to keep parity with scripts/post-merge.sh. Process:
- Edit
lib/db/src/schema/index.ts - Run
pnpm --filter @workspace/db run pushlocally — confirm any destructive prompts manually - Production: the deployment build step runs
pnpm --filter @workspace/db run pushagainst the deployment'sDATABASE_URLbefore bundling the server. The build will fail loudly if push errors.
If/when the project grows to need versioned migrations:
- Switch
lib/db/package.jsonscripts todrizzle-kit generate+drizzle-kit migrate - Update
scripts/post-merge.shto runmigrateinstead ofpush - Update
artifacts/api-server/.replit-artifact/artifact.tomlbuild args accordingly - Commit the generated
lib/db/migrations/directory