Status: 🟢 Live — The Hub is the shipping product. Shell, nav spine, dashboard,
auth, multi-tenancy and the AI bar are all wired end-to-end and serve real traffic.
A few conditionally-gated surfaces and vestigial template scaffolding are flagged
under Dormant / dead-ends.
Section 1 overview page. This is the map for the whole Hub; each of the 5 command
centers has its own deep-dive page (linked below).
The Hub is GoBuild's FastAPI construction-management SaaS — one app a builder
"owns" instead of a stack of siloed tools. A single deployment serves three
audiences at once, split by route prefix and auth model, not by separate apps:
| Audience | Where they live | Auth model |
|---|---|---|
| Internal staff (owner / PM / office / field) | /portal/* — the server-rendered portal shell (Jinja2 + HTMX + Alpine) |
Cookie session (keystone_session JWT), role-gated |
| Clients (homeowners) | /client/{token}, /invoice/{token}, /proposal/{token} |
Shareable per-record token, no login (optional SMS OTP / PIN gate) |
| Prospects (public marketing) | /p/{slug}, custom domains, /book, /api/chat |
No auth — the org's live website + lead-capture surface |
The staff portal is the "wedge": it renders hub-owned data only, so it works
even without a live OpenProject backend (schedule items are read-only mirrors; daily
logs, estimates, invoices, costs are hub-owned — see ARCHITECTURE.md (repo docs/ARCHITECTURE.md)).
The public + client surfaces are token- or host-resolved, so a prospect browsing a
contractor's site or a homeowner opening a share link never touches a login screen.
How one app serves all three: a request's Host header is parsed by
TenantMiddleware (which org?), then a second middleware (host_site_router)
decides whether this host is a hub host (→ /portal back-office) or a partner
marketing host (→ rewrites / onto /p/{slug}, the public site). Client and
vendor surfaces are plain token routes (/client, /invoice, /proposal, /bid,
/sub, /po) that carry their own capability in the URL.
The left nav is deliberately just Home + 5 command centers — every legacy
standalone page was folded into a hub as a native pane (see
nav_consolidation_plan.md (repo docs/nav_consolidation_plan.md)). Rendered by the shared
app shell templates/app.html via the navtree() macro (app.html:94–137), which
drives both the desktop sidebar and the mobile drawer.
| Nav item | Route | Icon (lucide) | Wiki page | Visibility |
|---|---|---|---|---|
| ⌂ Home | /portal |
layout-dashboard |
(this section — Home dashboard) | Everyone |
| Jobs | /portal/jobs |
hard-hat |
hub-jobs | Everyone (only hub a field user sees) |
| Sales | /portal/leads |
target |
hub-sales | non-field + has_feature(org,'sales') |
| Marketing | /portal/marketing |
megaphone |
hub-marketing | non-field + has_feature(org,'marketing') |
| Reports & Finance | /portal/reports |
bar-chart-3 |
hub-reports-finance | non-field |
| Team & HR | /portal/team-hub |
users-round |
hub-team-hr | non-field + has_feature(org,'hr') |
Note the two routes that surprise: Sales → /portal/leads (not /portal/sales,
though the Sales command-center router is prefixed /portal/sales), and Team &
HR → /portal/team-hub (the classic /portal/team page still exists but is only
in the profile dropdown). field-role users see only Home + Jobs; everything
else is behind role != 'field' (app.html:111).
The top-right account menu (app.html:196–235) holds what used to clutter the nav:
/portal/account/portal/settings (owner-only)/portal/team (owner-only)/admin (owner-only)/portal/help/portal/logoutStaff on the desktop Hub reach the Field App at /field. On phone/tablet UAs a
dismissible banner (#fieldSwitch, app.html:248–272, gated on is_mobile(request))
offers "You're on a phone — open the Field App → /field". The Field App is the
field-worker PWA (time clock, daily logs, tasks, today view); it is a separate shell
from the office Hub.
#hub-tabbar)On small screens the sidebar collapses to a 5-slot bottom bar (app.html:281–306):
Home (/portal) · Jobs (/portal/jobs) · Keystone (raised center orb —
toggles the AI bar, not a link) · Messages (/portal/messages) · More
(opens the full nav drawer).
Route: GET /portal → dashboard() in app/portal/router.py:225.
The dashboard is role-adaptive. command_center.shape_for(role)
(app/services/command_center.py:29) maps the user's role to one of three shapes:
field (role field) → a light, money-free "today" view (dashboard_field.html) —ops (role pm) → the full command center minus money modules.business (roles owner, office, anything else) → the full command centerdashboard.html) — KPIs, activity feed, jobsite map, weather strip, getting-startedFirst-run behavior: a brand-new empty org is redirected to /portal/welcome
(router.py:245). The dashboard also drives the Getting Started checklist
(_getting_started, router.py:310) and a lazy HTMX weather strip
(/portal/briefing/weather). ?embed=1 renders a chrome-less version that the
Reports Hub's Overview tab iframes in — the canonical Reports Hub is at
/portal/reports, not here.
Status: 🟢 Live.
All tokens are JWTs signed with one key; a purpose claim keeps them from being
replayed across surfaces (app/auth/security.py). The session resolvers only accept
purpose in ("session","impersonation") (app/portal/deps.py:31).
| Path | Who | How | Code | Status |
|---|---|---|---|---|
| Staff email + password | Owner / PM / office / field | POST /portal/login → issues keystone_session cookie. Per-IP + per-account rate limits; user resolved within the request's org (find_user_in_org) so a pooled DB can't cross tenants. |
router.py:88; cookie in deps.py:22 |
🟢 |
| Field PIN | Field workers | 4-digit numpad on a device-bound cookie (keystone_device). Personal-unlock, shared-tablet roster, or email+PIN (any device). 5 fails → 15-min lockout → falls back to password. |
app/routers/pin.py |
🟢 |
| Client (homeowner) | Homeowners | No login — open /client/{token} (the job's client_token). Optional per-org gate: SMS OTP or a shared PIN (portal_otp), which mints a scoped verify cookie. |
app/routers/client_portal.py:62,94,110,136 |
🟢 |
| Impersonation ("Login as") | Support / control-plane | CP authorizes over the Admin API and hands the operator's browser a 90-s ticket; /_impersonate/consume swaps it for a 30-min impersonation cookie set on the tenant's own host. A persistent banner + audit trail ride along. |
app/routers/impersonation.py; deps.py:47–60 |
🟢 |
Related token types (same signing key, distinct purpose, cannot be used as a
login session): invite / set-password (create_invite_token, 7-day, powers
/portal/accept-invite) and device (create_device_token, marks a trusted PIN
device). Vendor/sub and public-record surfaces (/bid, /sub, /invoice,
/proposal) use opaque per-record tokens, not JWTs.
One Hub, many orgs — every table carries organization_id. The tenant is resolved
from the Host header:
TenantMiddleware (pure-ASGI, app/tenancy/middleware.py) parses the host to ascope["tenant_slug"] + a ContextVar.app.gobuild.ca (app/config.py:28). An org with slugacme lives at acme.app.gobuild.ca; tenant_slug_from_host strips the baseapp/tenancy/resolver.py:23). Reserved labels (app, www, api, ops, …)resolve_request_org returns the subdomain org, or — for a legacy single-tenantNone when ambiguous soresolver.py:66).deps.py:43–46), and a CSRF origin guard blocks cross-tenant cookie replaymain.py:361).This is a summary — provisioning, the control plane, the fleet/registry, and the
per-tenant credential model live in the Platform / Control-Plane section. See
also memory notes: Fleet topology, Credential wall-off model.
What it is: an always-on, contextual command bar — the in-app AI copilot. The
front-end floating orb ("Keystone") sends {page, entity, record_id, prompt}; the
backend lets the model pick exactly one action from that page's allowed list
(so it can't invent capabilities) and returns a small instruction the browser
applies: answer (talk back), fill (type into on-page fields), card (a
review card the user must Apply), or nav (go somewhere). Nothing commits without
the user.
Where the code is:
app/routers/ai_bar.py — prefix /portal/ai, endpoints POST /command,POST /ask-stream (NDJSON streaming), POST /apply. Gatedrequire_portal_role(pm, office) (ai_bar.py:31).app/services/ai_bar.py — the action registry (global + per-page tools),app/services/ai_assistant.py
ai_retrieval.py; 👍/👎 feed ai_feedback.py (the learning loop).ai_* service family: ai_assistant, ai_retrieval, ai_feedback,portal_ai, live_ai, plus vertical drafters (proposals, daily logs, client updates…).Where it appears: rendered shell-wide via
{% include "partials/keystone_cc_bar.html" %} at app.html:389 — on every
portal page that extends the app shell — plus the raised center button in the mobile
#hub-tabbar, and directly inside the full-screen command centers (e.g. Reports).
Status: 🟢 Live — the bar is wired to real handlers across Jobs, Sales,
Marketing, Reports, Team & HR, and per-record detail pages. Availability degrades
gracefully to a friendly message if the model is unreachable.
Naming caveat: the code calls it both "Keystone Bar" / "Keystone CC bar"
(classes.kcc-*) and, historically, "AI bar." The product-facing name is
Keystone. See the open questions about what it's
really for.
app/portal/templating.py: one shared Jinja2 instance;is_mobile, has_feature, DEMO_MODE, ASSET_V (CSS cache-bust)./portal/manifest.webmanifest,/portal/sw.js, /portal/offline). Network-first SW (cache gobuild-hub-v10);GET /healthz → {"status":"ok"} (liveness, leaks nothing);GET /readyz → SELECT 1 DB check. (There is no /health.)main.py:321–382).owner / pm / office / field (app/models/base.py:51);require_portal_role(*roles) always admits owner (can't lock yourself out).Flagged with evidence so the next reader doesn't mistake scaffolding for live wiring:
navgroup (app.html:37–47) and pillarapp.html:51–76) are defined but never called. Leftover from the oldhublink model (per the comment at app.html:77). ⚪ Dead template code.nav-prefs persistencerouter.py:866), the per-item pin button and .ks-starred-wrap blockapp.html:29–31,101–106) are wired but largely vestigial given the flat navapp.html:13 hides #ks-ai-bar in embed mode, but the actual.kcc-* classes and no element has id="ks-ai-bar". ⚪ No-op CSS.app/routers/demo.py (prefix /portal/demo) is alwayssettings.demo_mode and a portaldemo.py:73–75); the /portal/demo-assets mount and theDEMO_MODE "Pitch mode" button (app.html:128–136) are likewise gated. ⚪ Livetracking.py:23–28 still queries the oldDripSend table alongside EmailSend for open-pixel/click tokens. 🟡 Back-compatads_build.py, ads.py, permits.py,admin_api.py (none are empty routers). Out of scope here; run theplumbing-inspector skill for an exhaustive dead-code pass.docs/ARCHITECTURE.md) — data model, OpenProject contract, AI/comms stackdocs/nav_consolidation_plan.md) — how the nav became Home + 5 hubs