ReviewOS

also looking at this

stacks/lastsoulultra

LSU's own coaching surface, and what auditing the site turned up

#12
Merged glennmichael123 wants to merge claude/last-soul-ultra-web-6e0718 into main
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.

resources/components/CoachingPortal.stxmodified+13-9
Changes to resources/components/CoachingPortal.stx
@@ -4,20 +4,24 @@ const { section = '' } = defineProps<Props>()
44</script>
55<script server>
66import coaching from '../../config/coaching'
7const coachingOrigin = new URL(coaching.origin).origin
8if (new URL(coachingOrigin).protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(new URL(coachingOrigin).hostname)) throw new Error('HQ_ORIGIN must use HTTPS')
9if (!/^[a-z0-9][a-z0-9-]{0,139}$/.test(coaching.brand)) throw new Error('HQ_BRAND_SLUG must be an organization slug')
10const coachingSection = ['plans', 'calendar', 'feedback', 'progress', 'library', 'health', 'messages', 'account'].includes(section) ? section : ''
11const portalUrl = `${coachingOrigin}/brands/${coaching.brand}`
12const elementUrl = `${coachingOrigin}/assets/elements/hq-coaching.js`
7import { 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. */
12const portalBrand = coachingBrand()
13const portalApi = coachingOrigin()
14const portalNamedSection = coachingSection(section)
15const portalHref = portalUrl(portalNamedSection)
16const portalElement = portalElementUrl()
1317</script>
1418<script client>
1519import { publicSubscriptionReceipt } from '../functions/coaching-return'
1620const coachingSearch = useSearchParams()
1721const coachingSubscription = memo(() => publicSubscriptionReceipt(coachingSearch.get('hq_subscription')))
1822</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">
2125 <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>
2327</hq-coaching>
resources/functions/coaching-source.tsmodified+45-0
Changes to resources/functions/coaching-source.ts
@@ -1,5 +1,6 @@
11import type { CoachingBrand } from './coaching-brand'
22import coaching from '../../config/coaching'
3import { coachingModule } from '../data/coaching'
34import { parseBrand } from './coaching-brand'
45
56/**
@@ -32,11 +33,55 @@ const cache = new Map<string, Entry>()
3233/** One in-flight request per brand, so a cold cache does not stampede. */
3334const inFlight = new Map<string, Promise<CoachingBrand | null>>()
3435
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 */
44export 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. */
52export 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 */
63export function coachingSection(value: unknown): string {
64 return typeof value === 'string' && coachingModule(value) ? value : ''
65}
66
3567/** The endpoint, built from config rather than from anything a visitor sends. */
3668export function brandEndpoint(origin = coaching.origin, slug = coaching.brand): string {
3769 return new URL(`/api/brands/${encodeURIComponent(slug)}`, origin).toString()
3870}
3971
72/** Where the portal lives, for a link that works when the frame does not. */
73export 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. */
81export function portalElementUrl(origin = coaching.origin): string {
82 return `${coachingOrigin(origin)}/assets/elements/hq-coaching.js`
83}
84
4085async function request(slug: string, origin: string): Promise<CoachingBrand | null> {
4186 try {
4287 const response = await fetch(brandEndpoint(origin, slug), {
tests/coaching-surface.test.tsmodified+37-0
Changes to tests/coaching-surface.test.ts
@@ -1,6 +1,7 @@
11import { describe, expect, it } from 'bun:test'
22import { coachingModule, coachingModules, enabledModules, indexableModulePaths, modulePath } from '../resources/data/coaching'
33import { formatPrice, formatTrial, parseBrand } from '../resources/functions/coaching-brand'
4import { coachingBrand, coachingOrigin, coachingSection, portalElementUrl, portalUrl } from '../resources/functions/coaching-source'
45
56describe('the coaching module registry', () => {
67 it('uses each HQ module key once, as its own URL segment', () => {
@@ -158,3 +159,39 @@ describe('printing a coach\'s price', () => {
158159 expect(formatTrial({ ...base, trialDays: 14 })).toBe('14 days free first')
159160 })
160161})
162
163describe('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})