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.mdandbackend-and-adhara.mdshould 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 (/pages → app/controllers/pages.py) and the
Visual Editor's image/video/audio uploads (/admin/editor/upload →
app/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():
GRAVITY_STORE_BACKENDenv var, if set, wins outright (local/gcs/s3/azure/r2/vercel_blob).- Otherwise auto-detect from whichever bucket/token env var is present — GCS → S3 → Azure → R2 → Vercel Blob → local fallback.
GRAVITY_SITE_PREFIXscopes 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
- Add the template under
views/<theme>/(copy an existing one as a starting point). - Add a controller function in the matching
app/controllers/*.py— pull any context it needs, calltheme_render('path/to.j2', request, **ctx). - Wire the URL in the matching
app/routes/*.py:router.get('/your-path')(ctrl.your_view)— addDepends(require_admin)/Depends(require_portal)as a controller parameter if it needs auth. - 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.