Customer Portal
Auth, onboarding, courses, community, events, meetings, certificates, resources, and memberships — local JSON by default, real Adhara when configured.
/portal/* is a full customer-facing area — authentication, onboarding, a course/lesson
system, community, events, meetings, certificates, downloadable resources, and memberships.
It isn’t block-driven content, but it follows its own consistency rule worth knowing before
you touch it.
The provider-interface pattern
Every portal capability is defined once, in the PortalProvider interface
(src/services/portal/types.ts), and implemented twice:
src/services/portal/local*.ts— local JSON.src/lib/adharaPortal.ts— real Adhara, raw HTTP, confirmed endpoints, no SDK.
A page or API route calls getPortalProvider() from src/lib/portal.ts and never imports
either implementation directly, so it works unmodified against both.
If you add a portal capability, add it to the interface first, then implement it in both places. An interface method with only one implementation type-checks fine and then throws at runtime for whichever backend happens to be active — that’s the one sharp edge in this otherwise-clean pattern.
Backend resolution
GRAVITY_PORTAL_BACKEND=local|adhara forces it; unset auto-detects adhara once both
ADHARA_API_KEY and ADHARA_WORKSPACE are set — see Overview for how this
fits the shared toggle resolver.
Session/auth guard
verifyPortalSession (src/lib/portalAuth.ts) is the one piece of portal logic that isn’t
per-provider — it handles the auth precedence order across both backends. Read its docstring
before changing how a session is resolved or invalidated.
Local dev bypass
GRAVITY_DEV_PORTAL=1 opens /portal without a real customer login — it auto-provisions a
fixed local dev customer (dev@local.test). This is separate from GRAVITY_DEV_ADMIN (which
bypasses the admin dashboard, not the customer portal). Never set it in production.
The sample course
A full 10-lesson “Building With AI” course ships as sample data — the exact same content
that’s referenced from /get-started’s “Prefer a guided walkthrough?” section. It covers
installing an AI coding agent, cloning the repo, enough Git to be dangerous, running it
locally, making a first AI-assisted change, both visual editors, and shipping it live. Seed
it with:
npm run courses -- import docs/gravity/examples/portal/courses/building-with-ai.json
It’s sample data, not part of the live site by default — import it once and it’s there for good, on your own machine or anyone else’s copy of this repo. Working through it doubles as a tour of the portal itself.
Meetings (/portal/meetings)
Read-only: a list + detail view of meetings (calls, sessions, seminars, master classes) the customer is on the attendee roster for, or that their membership tier has tier-gated access to (e.g. a master class recording published to a whole tier) — visibility is entirely server-side, nothing to filter again here. Detail view shows notes, highlights (key takeaways / action items / goals), a linked transcript, and a recording link when present. No registration/RSVP concept, unlike events — meetings are host-scheduled, not something a customer opts into from the portal.
Not SDK-wrapped, by design, matching every other portal capability — Adhara’s real Node
SDK does have a MeetingsResource (client.meetings), but it only covers the admin API
(/api/v1/meetings/*, API-key authenticated). The portal-facing API this page needs
(/api/v1/portal/{workspace}/meetings/*) is customer-session-scoped (a Bearer token from
verifyPortalSession, not an API key) — a different auth model the SDK’s HttpClient isn’t
built around, which is exactly why adharaPortal.ts uses raw fetch() for every portal
capability already (see “The provider-interface pattern” above). local*.ts’s
implementation is always empty, not fake demo data — same “capability split, not a content
split” reasoning as Scheduling: a meeting is inherently host-scheduled with
a real attendee roster, not something a site owner would plausibly hand-author as local JSON.
Not built yet
Portal OAuth/social login isn’t implemented in this port — see FAQ & Roadmap.