also looking at this
LSU's own coaching surface, and what auditing the site turned up
#12
86 files
+3943
-1190
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -4,20 +4,24 @@ const { section = '' } = defineProps<Props>() | ||
| 4 | 4 | </script> |
| 5 | 5 | <script server> |
| 6 | 6 | import coaching from '../../config/coaching' |
| 7 | const coachingOrigin = new URL(coaching.origin).origin | |
| 8 | if (new URL(coachingOrigin).protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(new URL(coachingOrigin).hostname)) throw new Error('HQ_ORIGIN must use HTTPS') | |
| 9 | if (!/^[a-z0-9][a-z0-9-]{0,139}$/.test(coaching.brand)) throw new Error('HQ_BRAND_SLUG must be an organization slug') | |
| 10 | const coachingSection = ['plans', 'calendar', 'feedback', 'progress', 'library', 'health', 'messages', 'account'].includes(section) ? section : '' | |
| 11 | const portalUrl = `${coachingOrigin}/brands/${coaching.brand}` | |
| 12 | const elementUrl = `${coachingOrigin}/assets/elements/hq-coaching.js` | |
| 7 | import { coachingBrand, coachingOrigin, coachingSection, portalElementUrl, portalUrl } from '../functions/coaching-source' | |
| 8 | ||
| 9 | /* Origin, slug and section are all checked in coaching-source, which is also | |
| 10 | what the module pages build their links from, so the embed and the link | |
| 11 | beside it cannot disagree about where the portal is. */ | |
| 12 | const portalBrand = coachingBrand() | |
| 13 | const portalApi = coachingOrigin() | |
| 14 | const portalNamedSection = coachingSection(section) | |
| 15 | const portalHref = portalUrl(portalNamedSection) | |
| 16 | const portalElement = portalElementUrl() | |
| 13 | 17 | </script> |
| 14 | 18 | <script client> |
| 15 | 19 | import { publicSubscriptionReceipt } from '../functions/coaching-return' |
| 16 | 20 | const coachingSearch = useSearchParams() |
| 17 | 21 | const coachingSubscription = memo(() => publicSubscriptionReceipt(coachingSearch.get('hq_subscription'))) |
| 18 | 22 | </script> |
| 19 | <script type="module" src=""></script> | |
| 20 | <hq-coaching brand="" api="" label="Last Soul Ultra coaching" section="" :subscription="coachingSubscription"> | |
| 23 | <script type="module" src=""></script> | |
| 24 | <hq-coaching brand="" api="" label="Last Soul Ultra coaching" section="" :subscription="coachingSubscription"> | |
| 21 | 25 | <p>Training plans, monthly subscriptions and feedback, powered by HQ.</p> |
| 22 | <a class="btn" href="">Open your training</a> | |
| 26 | <a class="btn" href="">Open your training</a> | |
| 23 | 27 | </hq-coaching> |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import type { CoachingBrand } from './coaching-brand' |
| 2 | 2 | import coaching from '../../config/coaching' |
| 3 | import { coachingModule } from '../data/coaching' | |
| 3 | 4 | import { parseBrand } from './coaching-brand' |
| 4 | 5 | |
| 5 | 6 | /** |
| @@ -32,11 +33,55 @@ const cache = new Map<string, Entry>() | ||
| 32 | 33 | /** One in-flight request per brand, so a cold cache does not stampede. */ |
| 33 | 34 | const inFlight = new Map<string, Promise<CoachingBrand | null>>() |
| 34 | 35 | |
| 36 | /** | |
| 37 | * The configured HQ origin, checked. | |
| 38 | * | |
| 39 | * An HQ reached over plain HTTP would carry a runner's training session in | |
| 40 | * clear, and a misconfigured HQ_ORIGIN is a deployment mistake that should | |
| 41 | * stop the build rather than quietly ship. Loopback is exempt so the local | |
| 42 | * preview stack works. | |
| 43 | */ | |
| 44 | export function coachingOrigin(value = coaching.origin): string { | |
| 45 | const url = new URL(value) | |
| 46 | if (url.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(url.hostname)) | |
| 47 | throw new Error('HQ_ORIGIN must use HTTPS') | |
| 48 | return url.origin | |
| 49 | } | |
| 50 | ||
| 51 | /** The configured brand slug, checked against HQ's own slug shape. */ | |
| 52 | export function coachingBrand(value = coaching.brand): string { | |
| 53 | if (!/^[a-z0-9][a-z0-9-]{0,139}$/.test(value)) | |
| 54 | throw new Error('HQ_BRAND_SLUG must be an organization slug') | |
| 55 | return value | |
| 56 | } | |
| 57 | ||
| 58 | /** | |
| 59 | * A section name HQ will accept, or ''. The registry is the list: a section | |
| 60 | * this site does not serve has no business being passed through to HQ, and | |
| 61 | * keeping a second copy of the eight names here is how the two drift. | |
| 62 | */ | |
| 63 | export function coachingSection(value: unknown): string { | |
| 64 | return typeof value === 'string' && coachingModule(value) ? value : '' | |
| 65 | } | |
| 66 | ||
| 35 | 67 | /** The endpoint, built from config rather than from anything a visitor sends. */ |
| 36 | 68 | export function brandEndpoint(origin = coaching.origin, slug = coaching.brand): string { |
| 37 | 69 | return new URL(`/api/brands/${encodeURIComponent(slug)}`, origin).toString() |
| 38 | 70 | } |
| 39 | 71 | |
| 72 | /** Where the portal lives, for a link that works when the frame does not. */ | |
| 73 | export function portalUrl(section: unknown = '', origin = coaching.origin, slug = coaching.brand): string { | |
| 74 | const url = new URL(`/brands/${encodeURIComponent(coachingBrand(slug))}`, coachingOrigin(origin)) | |
| 75 | const named = coachingSection(section) | |
| 76 | if (named) url.searchParams.set('section', named) | |
| 77 | return url.toString() | |
| 78 | } | |
| 79 | ||
| 80 | /** The custom element HQ generates for embedding its portal. */ | |
| 81 | export function portalElementUrl(origin = coaching.origin): string { | |
| 82 | return `${coachingOrigin(origin)}/assets/elements/hq-coaching.js` | |
| 83 | } | |
| 84 | ||
| 40 | 85 | async function request(slug: string, origin: string): Promise<CoachingBrand | null> { |
| 41 | 86 | try { |
| 42 | 87 | const response = await fetch(brandEndpoint(origin, slug), { |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from 'bun:test' |
| 2 | 2 | import { coachingModule, coachingModules, enabledModules, indexableModulePaths, modulePath } from '../resources/data/coaching' |
| 3 | 3 | import { formatPrice, formatTrial, parseBrand } from '../resources/functions/coaching-brand' |
| 4 | import { coachingBrand, coachingOrigin, coachingSection, portalElementUrl, portalUrl } from '../resources/functions/coaching-source' | |
| 4 | 5 | |
| 5 | 6 | describe('the coaching module registry', () => { |
| 6 | 7 | it('uses each HQ module key once, as its own URL segment', () => { |
| @@ -158,3 +159,39 @@ describe('printing a coach\'s price', () => { | ||
| 158 | 159 | expect(formatTrial({ ...base, trialDays: 14 })).toBe('14 days free first') |
| 159 | 160 | }) |
| 160 | 161 | }) |
| 162 | ||
| 163 | describe('where the portal is', () => { | |
| 164 | it('insists on HTTPS anywhere but loopback', () => { | |
| 165 | expect(coachingOrigin('https://hq.training')).toBe('https://hq.training') | |
| 166 | expect(coachingOrigin('http://localhost:3940')).toBe('http://localhost:3940') | |
| 167 | expect(coachingOrigin('http://127.0.0.1:3940')).toBe('http://127.0.0.1:3940') | |
| 168 | expect(() => coachingOrigin('http://hq.training')).toThrow('HQ_ORIGIN must use HTTPS') | |
| 169 | // A host that merely contains "localhost" is not loopback. | |
| 170 | expect(() => coachingOrigin('http://localhost.attacker.test')).toThrow() | |
| 171 | }) | |
| 172 | ||
| 173 | it('refuses a brand slug that is not one', () => { | |
| 174 | expect(coachingBrand('last-soul-ultra')).toBe('last-soul-ultra') | |
| 175 | for (const value of ['', '-leading', 'Upper', 'has space', '../org', 'a'.repeat(141)]) | |
| 176 | expect(() => coachingBrand(value)).toThrow('HQ_BRAND_SLUG must be an organization slug') | |
| 177 | }) | |
| 178 | ||
| 179 | it('passes through only a section this site actually serves', () => { | |
| 180 | expect(coachingSection('calendar')).toBe('calendar') | |
| 181 | for (const value of ['', 'whiteboard', 'moderation', 42, null, undefined, ['plans']]) | |
| 182 | expect(coachingSection(value)).toBe('') | |
| 183 | }) | |
| 184 | ||
| 185 | it('links to the same section the embed is showing', () => { | |
| 186 | expect(portalUrl('calendar', 'https://hq.training', 'last-soul-ultra')) | |
| 187 | .toBe('https://hq.training/brands/last-soul-ultra?section=calendar') | |
| 188 | expect(portalUrl('', 'https://hq.training', 'last-soul-ultra')) | |
| 189 | .toBe('https://hq.training/brands/last-soul-ultra') | |
| 190 | expect(portalUrl('nonsense', 'https://hq.training', 'last-soul-ultra')) | |
| 191 | .toBe('https://hq.training/brands/last-soul-ultra') | |
| 192 | }) | |
| 193 | ||
| 194 | it('loads the element from the same origin it frames', () => { | |
| 195 | expect(portalElementUrl('https://hq.training')).toBe('https://hq.training/assets/elements/hq-coaching.js') | |
| 196 | }) | |
| 197 | }) | |