Platform Access — Repository & Folder Conventions
Domain-first at the top, subdomain-first within. The app (access vs plt) is the top-level boundary. Inside each app, slice by subdomain (a vertical business capability), not by type.
1. Organizing principle
- The app (
accessvsplt) is the top-level boundary — the DDD bounded-context / code-organization line. It's a hard, permanent split in the codebase (different auth posture: pre-auth vs auth-required). It is not a URL or domain split — see §1.1. - Inside each app, slice by subdomain (a vertical business capability), not by type. A subdomain colocates its own pages, components, composables, stores, and routes. Do not create app-wide
components/,stores/,composables/buckets that mix unrelated subdomains. academic-enrollmentanddashboardare both subdomains — they simply live inside different apps.
This is the opposite of the type-first layout in the monolith and in the migration design docs (see §6).
1.1 Domain, URLs, and build topology
One domain, one URL structure — same as platform-frontend today. Both apps are served under plt.solidprofessor.com with the existing path layout: plt.solidprofessor.com/login, plt.solidprofessor.com/dashboard, etc. The access vs plt split is invisible in the URL.
Note: the access.solidprofessor.com / plt.solidprofessor.com "Domain" lines in the access.md / plt.md design docs are dev/staging labels, not the production topology.
Build topology: single Vite SPA with one router (one build, one deploy). This is simplest and closest to how platform-frontend behaves now. Keep the pre-auth/auth route groups lazy-loaded so an unauthenticated user at /login doesn't download the dashboard.
1.2 Phasing — plt first, access deferred
Start with plt (post-auth). The access app (pre-auth: login, signup, academic enrollment, onboarding) is deferred indefinitely. This means:
- Foundation work focuses on what
pltneeds immediately apps/access/scaffold exists but remains empty for now- Login/logout flows stay in the monolith (
platform-frontend) until access migration begins pltreads the existing monolith session cookie viapackages/shared-auth/— no auth changes required
This reduces scope and risk. When access migration begins, the patterns established in plt will inform the approach.
Why deferred? The timing for access migration depends on whether we proceed with the centralized auth effort. Migrating access now could mean significant rework if centralized auth moves forward — so we hold until that direction is clearer.
1.3 Incremental deployment strategy
Deploy each subdomain/page as it's completed — do not wait for a "big bang" cutover. When the Profile page is done, deploy it and route production traffic to it while other pages remain on the monolith.
Benefits:
- Reduced risk — smaller changesets mean easier rollback and debugging
- Faster feedback — real user traffic validates each piece before moving on
- Parallel progress — teams aren't blocked waiting for everything to be "ready"
Implementation:
- When a subdomain is complete and tested, create a ticket on the Core Platform board (John's team) to flip the routing rule from monolith → platform-access
- Routing rules are managed by Core Platform, not the plt team
- Rollback plan: Core Platform flips the routing rule back to monolith if issues arise
2. Repository tree
platform-access/ (one repo → eventually owned by Pod 4; npm workspaces)
├─ apps/
│ ├─ access/ (pre-auth bounded context)
│ │ └─ src/
│ │ ├─ subdomains/
│ │ │ ├─ auth/ (login, SAML, password recovery, activation)
│ │ │ ├─ signup/ (individual signup)
│ │ │ ├─ academic-enrollment/
│ │ │ ├─ onboarding/
│ │ │ └─ policy-consent/
│ │ ├─ pages/ (app infrastructure: NotFound, ServerError, Maintenance)
│ │ ├─ layouts/ (shared layouts: AuthLayout, CardLayout, EnrollmentLayout)
│ │ ├─ router/ (composes each subdomain's routes.ts + guards)
│ │ ├─ guards/ (enrollmentGate, resetPassword, authAlt, maintenance)
│ │ ├─ stores/ (app-wide ONLY: e.g. account subset for post-login redirect)
│ │ ├─ App.vue
│ │ └─ main.ts
│ │
│ └─ plt/ (post-auth bounded context)
│ └─ src/
│ ├─ subdomains/
│ │ ├─ dashboard/
│ │ ├─ my-learning/ (tabs: Overview, Achievements, Bookmarks & History)
│ │ ├─ profile/ (sub-pages: /profile/edit, /profile/membership-details, etc.)
│ │ ├─ certificates/ (public /certificates/:id — no auth required)
│ │ └─ feedback/
│ ├─ pages/ (app infrastructure: NotFound, ServerError, Maintenance)
│ ├─ layouts/ (shared layouts: DashboardLayout, DefaultLayout, BlankLayout)
│ ├─ router/
│ ├─ guards/ (auth, accountLocked, maintenance, myLearning)
│ ├─ stores/ (app-wide ONLY: e.g. user, account)
│ ├─ App.vue
│ └─ main.ts
│
└─ packages/ (shared across BOTH apps)
├─ shared-components/ (local layer over @solidprofessorhub/shared-components)
├─ cross-app/ (crossAppRedirect map — same-origin)
├─ stripe/ (useStripe(): access checkout + plt management)
└─ types/
3. Anatomy of a subdomain folder
A subdomain is self-contained: everything for that vertical slice lives together, and nothing from another subdomain bleeds in.
subdomains/academic-enrollment/
├─ pages/ EnrollmentIndex.vue, SchoolCode.vue, EnrollmentAccount.vue,
│ EnrollmentPayment.vue, EnrollmentComplete.vue
├─ components/ ACACard.vue, ACALogin.vue, BaseNavigation.vue, BaseNavigationStep.vue
├─ composables/ useAcademicEnrollment.ts
├─ stores/ academicEnrollment.ts
└─ routes.ts (this subdomain's route definitions, imported by src/router)
subdomains/dashboard/
├─ pages/ DashboardPage.vue (shell — layout + composition only)
├─ components/ DashboardWelcome.vue, ContinueLearning.vue, ThingsToDo.vue,
│ LiveTraining.vue, NewsAndResourcesBlock.vue, CareerDashboardBlock.vue,
│ AchievementsSummary.vue, QuickLinks.vue
├─ composables/ (subdomain-local composables)
├─ stores/ (subdomain-local stores)
└─ routes.ts
subdomains/profile/ (subdomain with multiple pages)
├─ pages/ ProfilePage.vue (/profile)
│ ProfileEditPage.vue (/profile/edit)
│ MembershipDetailsPage.vue (/profile/membership-details — billing & membership management)
│ UpdateInterestsGoalsPage.vue (/profile/update-interests-and-goals)
├─ components/ AvatarUpload.vue, ProfileForm.vue, MembershipCard.vue,
│ UpdatePaymentModal.vue, CancelMembershipModal.vue,
│ ReactivateMembershipModal.vue, OrdersHistory.vue
├─ composables/ useProfile.ts, useMembership.ts
├─ stores/ profile.ts, membership.ts
└─ routes.ts
Membership Details (/profile/membership-details) — this page handles all membership and billing management (no separate subscription subdomain):
- Current membership status, type, and plan details
- Next billing date and amount due
- Billing information (card last 4 digits, expiration)
- Update credit card via Stripe-integrated modal
- Cancel/reactivate membership flows
- Membership history (orders)
- Academic membership: extend/purchase membership buttons
subdomains/my-learning/ (tabs: Overview, Achievements, Bookmarks & History)
├─ pages/ MyLearningPage.vue (/my-learning, /my-learning?tab=achievements, etc.)
├─ components/ CourseCard.vue, ProgressIndicator.vue, AchievementBadge.vue,
│ CertificateCard.vue, TabNavigation.vue
├─ composables/ useMyLearning.ts
├─ stores/ myLearning.ts
└─ routes.ts
subdomains/certificates/ (public certificate view — no auth required)
├─ pages/ CertificatePublicPage.vue (/certificates/:id — shareable link)
├─ components/ CertificateDisplay.vue, CertificateActions.vue (LinkedIn, Print/Save)
├─ composables/ useCertificate.ts
└─ routes.ts (meta: { requiresAuth: false })
Guidelines:
- Each subdomain exports its own
routes.ts;src/routerimports and composes them (see §4). - Page components are thin: layout + composition. Push logic into composables and child components.
3.1 App-level concerns (pages, layouts, router)
Some things live outside subdomains because they span multiple subdomains or aren't business capabilities at all:
| Concern | Location | Why |
|---|---|---|
| Infrastructure pages (404, 500, Maintenance) | src/pages/ |
Not a business capability — app infrastructure |
| Shared layouts (DashboardLayout, AuthLayout) | src/layouts/ |
Used by multiple subdomains (Tier 2) |
| Single-use layout | subdomains/<name>/layouts/ |
Used by only one subdomain (Tier 1) |
| Router | src/router/ |
Composes all subdomain routes + global guards |
| Subdomain routes | subdomains/<name>/routes.ts |
Each subdomain owns its route definitions |
Layouts follow the 3-tier rule:
- If a layout is used by one subdomain → it can live in that subdomain's
layouts/folder - If a layout is used by multiple subdomains → it lives at
src/layouts/(Tier 2) - If a layout is used by both apps → it lives in
packages/(Tier 3)
Router is inherently app-level because it:
- Imports and composes routes from all subdomains
- Applies global navigation guards that span all subdomains
- Is the single orchestration point for the app's navigation
4. Pages & routing (post-Nuxt)
We are not using Nuxt. In a plain Vue 3 + Vite SPA there is no file-based routing, no auto-layouts, and no page middleware — a .vue file under pages/ is just a component until a router maps a path to it.
Mental model: a "page" is a component that a subdomain's routes.ts maps a path to, tagged with a layout and guard via route meta.
4.1 Routing — explicit, per subdomain
// subdomains/academic-enrollment/routes.ts
import type { RouteRecordRaw } from 'vue-router'
export const academicEnrollmentRoutes: RouteRecordRaw[] = [
{
path: '/academic-enrollment',
component: () => import('./pages/EnrollmentIndex.vue'),
meta: { layout: 'enrollment', guard: 'enrollmentGate' },
},
{
path: '/academic-enrollment/code',
component: () => import('./pages/SchoolCode.vue'),
meta: { layout: 'enrollment', guard: 'enrollmentGate' },
},
]
// apps/access/src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { authRoutes } from '@/subdomains/auth/routes'
import { academicEnrollmentRoutes } from '@/subdomains/academic-enrollment/routes'
import { onboardingRoutes } from '@/subdomains/onboarding/routes'
export const router = createRouter({
history: createWebHistory(),
routes: [
...authRoutes,
...academicEnrollmentRoutes,
...onboardingRoutes,
{ path: '/:pathMatch(.*)*', component: () => import('@/pages/NotFound.vue') },
],
})
4.2 Layouts — meta-driven
<!-- App.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import DashboardLayout from '@/layouts/DashboardLayout.vue'
import BlankLayout from '@/layouts/BlankLayout.vue'
const route = useRoute()
const layouts: Record<string, unknown> = { dashboard: DashboardLayout, blank: BlankLayout }
const layout = computed(() => layouts[route.meta.layout as string] ?? DashboardLayout)
</script>
<template>
<component :is="layout"><RouterView /></component>
</template>
4.3 Middleware → navigation guards
// apps/plt/src/router/guards.ts
router.beforeEach((to) => {
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return crossAppRedirect('/login') // → access app
}
if (to.meta.guard === 'enrollmentGate' && !enrollmentStore.canAccess(to)) {
return { path: '/academic-enrollment' }
}
})
4.5 Nuxt-isms that are gone — do not port
- No
useAsyncData/useFetchSSR hydration. Fetch client-side in a Pinia store action or inonMounted. - No auto-imports. Import Vue APIs, components, and composables explicitly.
- No
definePageMeta. Route meta lives inroutes.ts. process.client/process.server— delete these, don't port; it's all client.
5. The 3-tier "where does this live?" rule
Decide by who uses it. Start at the narrowest tier; promote only when a real second consumer appears — do not pre-share.
| Tier | Used by… | Lives in | Examples |
|---|---|---|---|
| 1 — Subdomain | one subdomain | apps/<app>/src/subdomains/<subdomain>/… |
ACACard.vue, useAcademicEnrollment |
| 2 — App | multiple subdomains in the same app | apps/<app>/src/{layouts,stores,guards} |
AuthLayout, account/user stores |
| 3 — Shared | both apps | packages/… |
GlobalHeader, AccountSwitcher, useStripe(), crossAppRedirect |
Note: Account switching is a UI component in the GlobalHeader user menu, not a subdomain with routes. It lives in packages/shared-components/ alongside GlobalHeader.
Promotion rule (gray-area components)
A component that is currently single-use but plausibly reusable starts in the app and is promoted to packages/ when the second consumer actually materializes — not before. Over-sharing balloons Foundation and blocks the app teams; under-sharing is cheap to fix later.
6. Translation note — design-doc paths are TYPE-FIRST; convert them
access.md and plt.md list Vue 3 target paths in a type-first layout inherited from the monolith. Do not take those paths literally — translate them to this repo's subdomain-first convention during migration.
| Design-doc target path (type-first) | Use instead (subdomain-first) |
|---|---|
components/auth/LoginForm.vue |
subdomains/auth/components/LoginForm.vue |
components/enrollment/ACACard.vue |
subdomains/academic-enrollment/components/ACACard.vue |
stores/academic-enrollment.ts |
subdomains/academic-enrollment/stores/academicEnrollment.ts |
components/dashboard/LiveTraining.vue |
subdomains/dashboard/components/LiveTraining.vue |
composables/useAcademicEnrollment.ts |
subdomains/academic-enrollment/composables/useAcademicEnrollment.ts |
9. Quick checklist for contributors
- New code belongs to a subdomain unless it's a layout, guard, app-wide store, or shared package.
- Colocate pages/components/composables/stores/routes inside the subdomain folder.
- Subdomain exports its own
routes.ts;src/routercomposes them. - Pages are wired via
routes.tswithmeta.layout+meta.guard; layout resolved inApp.vue; guards inbeforeEach. No Nuxt auto-magic. - Fetch data client-side (store action /
onMounted); nouseAsyncData; import everything explicitly. - Before creating a shared component, confirm a second real consumer exists — otherwise keep it app-local and promote later.
- Migrating from a design doc? Translate type-first paths → subdomain-first (see §6).
- Shared component conversion → Foundation. App-specific conversion → the app story.