Gravity Gravity Docs

Gravity Framework — Architecture & Request Flow

How every request flows from the browser to a response, and which files handle it. Written for both humans and AI coding agents — if you're an agent working on this codebase, this doc plus getting-started.md and backend-and-adhara.md should be enough to orient before making a change.


Stack

Browser
  │
  ▼
Uvicorn, or Gunicorn supervising Uvicorn workers  ← scripts/entrypoint.sh picks the mode
  │                                                  (GRAVITY_SERVER_MODE; see deployment.md)
  ▼
FastAPI Application            ← asgi.py → app/main.py create_app()
  APIRouter matching
  │
  ├──► Controllers            ← app/controllers/*.py
  │       │
  │       ├──► Service layer  ← app/services/*.py, app/stores/*.py
  │       └──► Theme render   ← app/theme.py → views/<theme>/*.j2
  │
  └──► Static assets          ← public/ (CSS, JS, images), mounted last

Entry point: asgi.py imports create_app() from app/main.py and exposes the result as app. Every server mode points at the same asgi:app target — see scripts/entrypoint.sh and deployment.md.

There is deliberately no module-level app = FastAPI(). create_app() is a factory so tests/test_http_smoke.py can build fresh, differently-configured app instances (e.g. with GRAVITY_DEV_ADMIN=1 set or unset) without one shared instance leaking state between tests.


Directory Structure

gravity/
│
├── asgi.py                        ASGI entry — imports create_app(), exposes 'app'
├── api/index.py                    Vercel's function entry — re-exports asgi:app (Vercel
│                                    requires the file under api/; see vercel.json's rewrites)
├── gunicorn.conf.py                worker_class=uvicorn_worker.UvicornWorker, startup banner
├── Dockerfile                     python:3.12-slim, GRAVITY_ROOT=/gravity
├── docker-compose.yml             ports ${GRAVITY_HOST_PORT:-3001}:8000
├── Makefile                       make dev|build|up|down|logs|run-local|setup|deploy PLATFORM=x|push-repo
├── requirements.txt
│
├── app/                           FastAPI application layer
│   ├── main.py                    create_app() — factory, middleware, routers, static mount, error handlers
│   ├── deps.py                    require_admin / require_portal (FastAPI Depends()), the
│   │                              302-forcing redirect(), RedirectRequired, json_body/raw_body
│   ├── auth.py                    cookie name constants + dev-bypass flag checks — no
│   │                              route-guarding logic itself (that's deps.py); framework-free
│   ├── theme.py                   theme_render() / theme_admin_render() / plain_render(),
│   │                              the single Jinja2 Environment, ThemeFallbackLoader
│   ├── content.py                 Visual Editor page/section content API (see §6)
│   ├── theme_tokens.py            Theme Tweaks CSS-token override API (see §7)
│   ├── tenant.py                  Trusted-proxy multi-tenant seam (Adhara Pro embed) — contextvars-based
│   │
│   ├── controllers/               Pull context → call theme_render()/plain_render()
│   │   ├── main.py                home, about, features, University pages, checkout, forms — 27 routes
│   │   ├── blog.py, events.py     blog_list/blog_post/blog_new_post, events/event/event_confirmed
│   │   ├── scheduling.py          public booking pages (Adhara scheduling API)
│   │   ├── links.py               link-in-bio page
│   │   ├── pages.py               generic HTML page upload/manage
│   │   ├── editor.py              Visual Editor (inline + full builder) + floating edit button
│   │   ├── theme_tweaks.py        Theme Tweaks panel + floating "Theme" fab
│   │   ├── admin.py               Admin dashboard, blog/events/contacts/links management (47 routes)
│   │   └── portal.py, portal_auth.py, portal_oauth.py   customer portal (39 routes total)
│   │
│   ├── routes/                    URL → controller wiring, one APIRouter per group
│   │   ├── main.py, blog.py, events.py, scheduling.py, links.py, pages.py
│   │   ├── admin.py                /admin/*, gated by Depends(require_admin) per route
│   │   ├── api.py                 /api/message  /api/newsletter  /api/forms/submit
│   │   └── portal.py, portal_auth.py, portal_oauth.py   /portal/*, Depends(require_portal)
│   │
│   ├── messaging/                 JSON command dispatcher (see §3)
│   │   ├── parser.py              parse_message() — schema validation, auth check, dispatch
│   │   ├── handlers.py            MessageHandlers — one handle*() per command
│   │   ├── commands.py            Command string constants
│   │   ├── schema.py              JsonSchemaHelper — validates message shape
│   │   └── messages.py            JsonMessage — response envelope builder
│   │
│   ├── stores/                    Pluggable ContentStore abstraction (see §5)
│   │   ├── base.py                ContentStore ABC — read/write/describe/write_media/list/delete
│   │   ├── local.py, gcs.py, s3.py, azure.py, r2.py, vercel_blob.py   6 backends
│   │   └── __init__.py            get_store() — backend auto-selection from env vars
│   │
│   └── services/
│       ├── adhara.py              Adhara HTTP API + SDK client (blog/events/commerce/auth)
│       ├── blog_store.py          Local JSON ↔ Adhara blog storage (on top of ContentStore)
│       ├── event_store.py         Local JSON ↔ Adhara event storage
│       ├── contact_store.py       Contacts storage (ContentStore-backed; no Adhara variant)
│       ├── link_store.py          Link-in-bio page storage
│       ├── registration_store.py  Event registration storage
│       ├── scheduling_store.py    Scheduling/booking storage
│       ├── portal_*.py            Customer portal: auth, courses, community, resources, etc.
│       └── sanitize.py            HTML allowlist sanitizer for authored content
│
├── gravityApp/app/                Legacy modules — ONLY auth/session helpers remain:
│   ├── GravityConfiguration.py    Path/flag configuration (GRAVITY_ROOT-derived paths)
│   ├── JsonFile.py                Raw JSON file I/O utility (used by UserManagement)
│   ├── JsonWebToken.py            JWT encode/decode
│   └── UserManagement.py          Local user CRUD (fallback auth store — see §2)
│   (Made importable as bare top-level imports via a sys.path shim in app/main.py.
│    Every other content type — blog, events, contacts, links, page content, theme
│    tweaks — lives on app/stores' ContentStore abstraction instead.)
│
├── views/                         Jinja2 templates, one folder per theme
│   ├── gravity/                   Default theme (dark navy + gold) — also the fallback
│   │   ├── base.j2                Public base layout (nav, footer, floating edit button)
│   │   ├── blocks/                Visual Editor block partials (hero, feature-grid, ...)
│   │   ├── admin/editor/          Visual Editor admin UI (inline, builder, header)
│   │   ├── blog/ events/ ...
│   │   └── admin/                 Admin dashboard templates
│   ├── clarity/                   Notion-inspired light theme — same structure
│   ├── photo/                     Editorial/photography theme
│   ├── resonance/                 Music-artist theme, mic-reactive audio hero
│   ├── stripe/                    CSS + a homepage only — everything else falls back to gravity
│   ├── docs/                      This documentation site's own shell — NOT theme-prefixed
│   │                              (rendered via plain_render(), see the docs pipeline below)
│   └── pages/                     Theme-agnostic utility templates (HTML upload/manage)
│
├── public/                        Static assets (served at URL root /)
│   ├── themes/<theme>/main.css    Per-theme CSS design system (custom properties)
│   ├── css/docs.css                The docs site's own stylesheet (built once, not per-theme —
│   │                                deliberately NOT under public/docs/, which would collide
│   │                                with the /docs/{slug} catch-all route)
│   ├── js/                        main.js, editor.js/builder.js/inline.js, theme-tweaks.js
│   └── upload/                    Uploaded files (local backend only — see §5)
│
├── data/content/                  Local ContentStore backend's JSON documents (default)
├── auth/                          Mounted volume — local fallback token/user store
├── schema/                        jsonschema files (messages) + schema/blocks, schema/events
├── docs/gravity/                  This documentation — markdown, rendered by app/routes/docs.py
├── scripts/
│   ├── install.sh                 curl | bash one-shot local install/run
│   ├── install_tools_{macos,linux,windows}.{sh,ps1}   idempotent prerequisite installers
│   ├── push_repo.sh                make push-repo — create + push a GitHub/GitLab repo
│   ├── gravity_content.py / gravity_blog.py / gravity_events.py / gravity_theme.py
│   ├── deploy_{vercel,gcp,cloudflare,azure}.sh   4 deploy targets
│   ├── setup.sh                   Top-level "where do I start" wizard (make setup)
│   ├── lib/                       Shared shell libs: colors.sh, env_file.sh, storage_setup.sh
│   └── create_theme.sh / extract_theme.sh
│
├── models/                        Static, repo-committed seed JSON (about.json, features.json)
├── vercel.json / wrangler.jsonc / cloudflare/worker.js   Deploy configs
├── requirements.txt
└── .env.example

1. Public Page Request (GET)

A browser requests /blog.

Browser GET /blog
         │
         ▼
  FastAPI matches /blog → app/routes/blog.py's APIRouter → ctrl.blog_list
         │
         ▼
  app/controllers/blog.py :: blog_list(request)
    │
    ├── app/services/blog_store.py :: get_blog_store().list_all()
    │     → local backend: reads data/content/blog/*.json via app/stores
    │     → Adhara backend: GET https://api.adharaweb.com/api/v1/blog-posts (if configured)
    │
    └── theme_render('blog/blog.j2', request, blog_items=[...])
          │
          app/theme.py :: theme_render()
            merges the shared per-request context (theme, header, edit_bar, ...)
            └── Jinja renders '<theme>/blog/blog.j2'
                  extends <theme>/base.j2
                  → loads /themes/<theme>/main.css
                  → HTML string → HTMLResponse → Browser

Which backend serves the read is decided once per process by app/services/blog_store.py::get_blog_store() — local JSON by default, or Adhara if ADHARA_API_KEY + ADHARA_WORKSPACE_ID are set (or forced via GRAVITY_BLOG_BACKEND=local|adhara). Events (event_store.py) and page content (app/content.py) follow the identical pattern.

Two response-class gotchas every route in this codebase already routes around, worth knowing if you add a new one: FastAPI's default response class is JSON (a returned HTML string would get quote-wrapped), so every router sets default_response_class=HTMLResponse once at the top; and Starlette's RedirectResponse defaults to a 307 (preserves the HTTP method), which would turn a POST → redirect(GET-only page) flow into a 405 — every controller imports redirect() from app/deps.py instead of RedirectResponse directly, which forces 302.


2. Auth-Protected Page Request (GET)

A browser requests /admin/blog/manage.

  app/routes/admin.py:
    router.get('/blog/manage')(ctrl.blog_manage)
    — ctrl.blog_manage declares admin_ctx: dict = Depends(require_admin)
         │
         ▼
  app/deps.py :: require_admin(request) runs BEFORE the controller body
    │
    ├── dev_admin_enabled()?  (GRAVITY_DEV_ADMIN=1)
    │     → yes: skip the check entirely (LOCAL TESTING ONLY, never in production)
    │
    ├── trusted-proxy mode (app/tenant.py) authorized?
    │     → yes: skip the check (Adhara Pro embed seam, off unless GRAVITY_TRUST_PROXY=1)
    │
    ├── token = request.cookies.get('token')
    ├── app/services/adhara.py :: validate_token(token)
    │     → requires the Adhara SDK installed; returns False if it isn't
    │     → no token or invalid → raises RedirectRequired('/login')
    │           (caught by a single exception handler in app/main.py, turned into a 302)
    │
    └── valid → returns {'current_user', 'admin', 'sdk_available'} → becomes ctrl.blog_manage's admin_ctx argument

A FastAPI dependency can't replace the response directly — its return value only ever becomes the route's argument. RedirectRequired is how this codebase expresses "stop here, redirect instead": the dependency raises it, and one exception handler registered in app/main.py turns it into an actual RedirectResponse.

Two separate auth systems coexist, deliberately:

System Cookie Dependency Backing store Used for
Admin auth token require_admin (app/deps.py) Adhara SDK (validate_token), or the local fallback in gravityApp/app/UserManagement.py when nothing else applies /admin/*
Customer portal auth portal_session / portal_refresh require_portal (app/deps.py) app/services/portal_auth.py (Adhara's portal API) /portal/*

Admin access requires the Adhara SDK in real deployments — without it, validate_token() always returns False and every /admin/* route redirects to /login, which shows an SDK-required banner. GRAVITY_DEV_ADMIN=1 is the local escape hatch for testing the Visual Editor without an Adhara account; none of the four deploy scripts set it, so a normal deploy stays locked. gravityApp/app/UserManagement.py still exists as a local user-CRUD fallback (used by the admin "manage users" UI), but it is not the primary auth path.

require_portal additionally does a silent session refresh: an expired portal_session with a still-valid portal_refresh cookie gets quietly re-minted (new cookies set directly on the dependency's injected Response parameter) instead of interrupting the customer mid-task — see authentication.md for the full portal flow.


3. JSON Command API Request (POST /api/message)

The admin JavaScript POSTs a JSON command — the AJAX layer behind blog/event/contact edits and login/register.

Browser POST /api/message
Body: {"command": "contact_add", "contact_info": {"email": "...", ...}}
         │
         ▼
  app/routes/api.py → app/controllers/api.py :: message(body: dict = Depends(json_body))
    ├── token = request.cookies.get('token')
    └── app.messaging.parser.parse_message(body, {"token": token}, logger)
              │
              ▼
        app/messaging/parser.py :: parse_message()
          1. app/messaging/schema.py :: JsonSchemaHelper.validate() — shape check
          2. Auth check — only for AUTH_PROTECTED_COMMANDS
             (user_add_new, user_delete, user_edit): token must pass
             gravityApp/app/UserManagement.py :: validateToken()
          3. Dispatch by command string → app/messaging/handlers.py :: MessageHandlers.handle*()
              │
              ▼
        app/messaging/handlers.py :: handleContactAdd()
          ├── app.services.contact_store.add(contact_info)
          └── app/messaging/messages.py :: JsonMessage.createResponseMessage(...)
  │
  app/controllers/api.py :: message()
    └── returns the response dict as JSON, 201

Command Reference

See app/messaging/commands.py for the authoritative list. As of this writing: ajax_test, login, logout, user_register, user_add_new, user_delete, user_edit, user_edit_password, blog_new_post, blog_submit_edit, blog_submit_delete, event_submit_new, event_submit_edit, event_submit_delete, event_register, event_attendee_checkin, event_attendee_delete, event_attendee_edit, contact_add, contact_edit, contact_delete. Handlers live in app/messaging/handlers.py, one handle*() method per command, calling into app/services/*_store.py — never a legacy Gravity*.py module (all deleted except the four auth-related ones under gravityApp/app/).


4. Media Upload Request (POST)

Both the generic page-upload flow (/pagesapp/controllers/pages.py) and the Visual Editor's image/video/audio uploads (/admin/editor/uploadapp/controllers/editor.py::editor_upload) end up calling the active ContentStore's write_media():

Browser POST (multipart/form-data, a 'file' field)
         │
  Guard: extension not in allowlist (images, video, mp4/webm/mov/m4v,
         audio mp3/wav/m4a/ogg/aac) → 400
         │
  A sanitized filename (path-traversal-safe) is derived from the upload
         │
  app/stores :: get_store().write_media(filename, data, content_type)
    ├── local backend  → public/upload/<sha1[:10]>-<safe-filename>, URL /upload/...
    └── cloud backend  → uploaded to the configured bucket/container, a
                          (best-effort public) URL returned — see §5

5. The ContentStore Abstraction

Every piece of mutable, user-editable data in Gravity — blog posts, events, contacts, the link-in-bio page, page-builder content, theme token overrides, and uploaded media — goes through one small interface, app.stores.base.ContentStore (read / write / describe / write_media / list / delete), never a vendor SDK directly. The active backend is chosen once per process by app/stores/__init__.py::get_store():

  1. GRAVITY_STORE_BACKEND env var, if set, wins outright (local/gcs/s3/azure/r2/vercel_blob).
  2. Otherwise auto-detect from whichever bucket/token env var is present — GCS → S3 → Azure → R2 → Vercel Blob → local fallback.
  3. GRAVITY_SITE_PREFIX scopes any backend (local included) to a subfolder, so several Gravity instances can share one bucket/root directory.

This is what makes make deploy PLATFORM=x able to offer "set up persistent storage" for any of the four hosting targets and default to that platform's own native option (Vercel Blob / GCS / Cloudflare R2 / Azure Blob) — see scripts/lib/storage_setup.sh. Local dev needs none of this: with nothing configured, get_store() falls back to plain JSON files under data/content/.

app/content.py (Visual Editor page content), app/theme_tokens.py (Theme Tweaks), and every app/services/*_store.py module sit on top of this same interface — they only differ in the document names they read/write (content.draft.json, theme.draft.json, blog/<slug>.json, etc.), not in how storage works.


6. Visual Editor (page content)

app/content.py implements a block-based content system: pages are made of {id, type, fields} sections, validated against schema/blocks/<type>.json, stored as content.draft.json (working copy) and content.published.json (what the public site renders) — publishing is a copy from draft to published, computed fresh per request, never baked to a static file. Three ways to edit: inline click-to-edit (/admin/editor/inline/<page>), the full 3-pane drag-and-configure Builder (/admin/editor/build/<page>), or by asking an AI assistant to edit the JSON directly. See visual_editor_design.md for the full design and phase history.


7. Theme Tweaks (CSS token overrides)

app/theme_tokens.py mirrors the Visual Editor's exact draft/publish pattern (theme.draft.json / theme.published.json on the same ContentStore), but for CSS custom-property overrides — colors, shadows, opacity — layered on top of a theme's main.css rather than editing it. Gated behind GRAVITY_DEV_THEME_TWEAKS=1 (build-time/customization tool, not meant to stay on for a finished production site). Routes under /admin/theme/*; rendered via a <style> block injected right after the theme's own stylesheet <link>.


8. Adhara Integration

Two tiers, both degrading gracefully when unconfigured — see app/services/adhara.py and backend-and-adhara.md for the full picture (what each tier unlocks, and the honest tradeoffs of the alternatives):

Tier Requires Used for
HTTP API ADHARA_API_KEY + ADHARA_WORKSPACE/_ID Blog/event reads (when the Adhara backend is selected), newsletter, form submissions
SDK adhara package installed + ADHARA_WORKSPACE_ID Admin login/logout, checkout() product listing (Adhara Commerce), portal auth

app.services.adhara.sdk_available() is what controllers/templates check to show fallback UI (e.g. the checkout page's "SDK not installed" state) instead of crashing when the package isn't present.


9. Theme System

GRAVITY_TEMPLATE (default gravity) is read once at process start and picks which folder under views/ and public/themes/ is active for the whole process — there's no per-visitor theme switch, only a per-deployment one. A handful of shared values (theme, theme_css, header, edit_bar, …) get merged into every template's context by app/theme.py::_global_context(); <theme>/base.j2 loads /themes/{{ theme }}/main.css. A theme that ships CSS but no templates of its own automatically falls back to gravity's templates (see ThemeFallbackLoader in app/theme.py); one that has neither falls back to GRAVITY_DEFAULT_TEMPLATE. To add a theme: copy an existing theme's views/<name>/ and public/themes/<name>/, edit the :root {} tokens in the new main.css, set GRAVITY_TEMPLATE. No controller/route changes.

This documentation site (/docs/*) is the one deliberate exception — it always renders views/docs/*.j2 via plain_render() (no theme prefix) and loads its own public/css/docs.css, regardless of which GRAVITY_TEMPLATE the deployment has set. Reference docs read the same everywhere; only the marketing/product surface is meant to be themeable.


10. Route Map

Full detail lives in app/routes/*.py — this is the shape, not an exhaustive listing (the customer portal alone has ~30 routes under /portal/*, admin has 47 under /admin/*).

Prefix Router Auth Highlights
/, /about, /features, /get-started, /whats-next, /install-your-tools, /backend, /connect-adhara, /building-with-ai, … app/routes/main.py (27 routes) None Public marketing pages + the Gravity University series
/blog, /blog/<slug> app/routes/blog.py None Adhara or local-JSON backed
/events, /event/<slug> app/routes/events.py None Adhara or local-JSON backed
/schedule/<slug> app/routes/scheduling.py None Public booking pages (Adhara scheduling API)
/links app/routes/links.py None Link-in-bio page
/docs, /docs/<slug> app/routes/docs.py None This documentation site — see below
/admin/* app/routes/admin.py Depends(require_admin) Dashboard, blog/events/contacts/links management, /admin/editor/* (Visual Editor), /admin/theme/* (Theme Tweaks)
/api/message, /api/newsletter, /api/forms/submit app/routes/api.py Per-command (see §3) JSON command dispatcher, newsletter/form → Adhara
/portal/* app/routes/portal.py, portal_auth.py, portal_oauth.py Depends(require_portal) Customer dashboard, courses, community, resources, certificates

11. This Documentation Site

/docs is a small, self-contained pipeline, deliberately separate from the Visual Editor's content system — docs are files in this repo, not database-backed content someone edits through a UI:

Browser GET /docs/architecture
         │
  app/routes/docs.py → app/controllers/docs.py :: doc_page(request, slug='architecture')
    ├── DOCS_NAV (a plain Python list of sections/pages, defined in the controller)
    │     resolves 'architecture' → docs/gravity/architecture.md
    ├── markdown.markdown(text, extensions=['extra', 'codehilite', 'toc', 'admonition'])
    │     parsed fresh on every request — these are small files and traffic is low,
    │     so there's no caching layer to keep in sync with the source .md files
    └── plain_render('docs/page.j2', request, html=..., nav=DOCS_NAV, ...)
          extends views/docs/base.j2 (sidebar + content shell, not theme-prefixed)

The .md files under docs/gravity/ are the actual source of truth — readable directly by a human on GitHub/GitLab, by an AI agent that's cloned the repo, or rendered into this styled site. Adding a page: write a new .md file, add one entry to DOCS_NAV in app/controllers/docs.py.


12. Environment Variables

The canonical, actively-maintained list is the README's Environment Variables table and .env.example — both cover the storage backends, Adhara integration, and dev-only bypass flags. This doc won't duplicate that table (it goes stale the moment a new backend or flag is added); read those two instead.


Adding a New Route

  1. Add the template under views/<theme>/ (copy an existing one as a starting point).
  2. Add a controller function in the matching app/controllers/*.py — pull any context it needs, call theme_render('path/to.j2', request, **ctx).
  3. Wire the URL in the matching app/routes/*.py: router.get('/your-path')(ctrl.your_view) — add Depends(require_admin)/Depends(require_portal) as a controller parameter if it needs auth.
  4. Restart (make dev/make up/make run-local), visit the new path.

If the theme you're testing against doesn't have the new template yet, ThemeFallbackLoader (app/theme.py) serves gravity's copy instead of 404ing — so a partial theme is never broken, just unstyled-for-that-one-page until you add its version too.

The new page's title/description/Open Graph tags and /sitemap.xml entry are handled automatically — see AGENTS.md at the repo root for what that covers and the couple of things (a page-specific description, deciding whether it belongs in the sitemap) worth setting by hand.