Skip to content

Latest commit

 

History

History
197 lines (148 loc) · 15.7 KB

File metadata and controls

197 lines (148 loc) · 15.7 KB

Glimpse — Technical Architecture

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).


1. Components

┌─────────────────────────┐         ┌──────────────────────────┐
│ 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.


2. Frontend ↔ backend contract

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.


3. Auth model

  • Identity provider: Clerk. Each user is a Clerk user; the app database stores a corresponding row in users keyed on the Clerk user_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 the Authorization header. There is no Express session cookie store and no SESSION_SECRET consumed at runtime.
  • Server validation: clerkMiddleware from @clerk/express runs early in the stack and populates req.auth. The requireAuth middleware (artifacts/api-server/src/middlewares/requireAuth.ts) gates every protected route on a non-empty req.auth.userId. Storage routes (POST /storage/uploads/*, /storage/objects/*) are gated by an inline requireAuth block in routes/index.ts before storageRouter mounts; only /storage/public-objects/* and /api/healthz remain public.
  • CORS + authorizedParties: both layers read from the same allowlist (ALLOWED_ORIGINS env var, falling back to REPLIT_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 by clerkProxyMiddleware to Clerk's frontend API. This is how EXPO_PUBLIC_CLERK_PROXY_URL is 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.

4. Data model overview

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).


5. Location & matching model

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:

  1. Presence ingestion — the client periodically posts to /api/presence with 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 72h expiresAt.
  2. 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 helpers presenceInNeighborhood / presenceWithinTimeOf (artifacts/api-server/src/lib/locationValidation.ts). The client runs an advisory, spatial-only pre-flight — checkPresencePreflight() from @workspace/location-validation, used in components/DropPanel.tsx — to surface friendly UX hints. The constants are shared, but the time check is server-only; the client check is never authoritative.
  3. Visibility delay — a Glimpse becomes discoverable to other users 1 hour after its eventTime (VISIBLE_DELAY_MS = 60 * 60 * 1000 in routes/glimpses.ts). Note: earlier product copy and replit.md historical notes mention 4 hours — the current code is 1 hour. Reconcile before App Review messaging is finalized.
  4. Discovery match/api/glimpses/discover joins the viewer's recent presence rows against authored Glimpses using the same neighborhood radius and ±2h window, requiring visibleAfter <= now and 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 expiresAt filtering (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.

6. Approval & chat model

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.


7. Storage model

  • Provider: Replit App Storage, GCS-compatible.
  • Upload flow: client requests a signed URL from POST /api/storage/uploads/request-url (rate-limited). Client PUTs bytes directly to the signed URL. Client then calls POST /api/me/photos with the resulting objectPath to 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/photos is reorder-only — it does not bind new photos.
  • Serving:
    • /api/storage/public-objects/*filePath — for assets under PUBLIC_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/:photoId removes the DB row and compacts the remaining position values. 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.

8. Third-party integrations

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.


9. Logging & observability

  • Server uses Pino via pino-http. Request logs include method, path (no querystring), and request id.
  • Never use console.log in server code — req.log for request scope, logger from src/lib/logger.ts for 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.

10. Build & runtime topology

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.


11. Schema-change workflow

The repo uses drizzle-kit push (not versioned migrations) to keep parity with scripts/post-merge.sh. Process:

  1. Edit lib/db/src/schema/index.ts
  2. Run pnpm --filter @workspace/db run push locally — confirm any destructive prompts manually
  3. Production: the deployment build step runs pnpm --filter @workspace/db run push against the deployment's DATABASE_URL before bundling the server. The build will fail loudly if push errors.

If/when the project grows to need versioned migrations:

  1. Switch lib/db/package.json scripts to drizzle-kit generate + drizzle-kit migrate
  2. Update scripts/post-merge.sh to run migrate instead of push
  3. Update artifacts/api-server/.replit-artifact/artifact.toml build args accordingly
  4. Commit the generated lib/db/migrations/ directory