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.