SP

Platform Access

Repository & Folder Conventions
Epic: SPPLT-18663 Status: Authoritative Audience: access + plt teams, Pod 4
Foundation Epic

Platform Access — Repository & Folder Conventions

Attach to: Foundation epic (SPPLT-18663) | When a design doc's target path conflicts with this document, this document wins (see §6).

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

1. Organizing principle

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:

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:

Implementation:

§2 Repository Tree

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 Subdomain Folder Anatomy

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):

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:

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:

Router is inherently app-level because it:

  1. Imports and composes routes from all subdomains
  2. Applies global navigation guards that span all subdomains
  3. Is the single orchestration point for the app's navigation
§4 Pages & Routing

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

§5 The 3-Tier Rule

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

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

9. Quick checklist for contributors