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/data/coaching.tsadded+118-0
Changes to resources/data/coaching.ts
@@ -0,0 +1,118 @@
1/**
2 * The coaching modules, in LSU's own words.
3 *
4 * HQ publishes eight composable training modules and a navigation list for the
5 * brand. This site does not simply print HQ's labels: a runner arriving from
6 * /format should read the same voice here as everywhere else, so the label,
7 * the page heading and the one-line summary are LSU's. HQ remains the only
8 * source of the data inside each panel, and of whether a module is enabled at
9 * all - `enabledModules()` intersects this list with the published brand.
10 *
11 * `scope` is the part worth keeping straight:
12 * 'public' - readable without an HQ account. The storefront.
13 * 'athlete' - needs a signed-in HQ subscriber. Noindex, and said plainly on
14 * the page rather than discovered after a click.
15 * 'private' - as athlete, and additionally account-scoped inside HQ: the
16 * athlete's own health metrics and messages, which a brand never
17 * sees. LSU must never imply it can read them.
18 */
19
20export type ModuleScope = 'public' | 'athlete' | 'private'
21
22export interface CoachingModule {
23 /** HQ's module key. Also this site's URL segment, under /coaching/. */
24 slug: string
25 /** Nav word. LSU's, not HQ's. */
26 label: string
27 /** Page heading. */
28 title: string
29 /** One line, in the site's voice, saying what the panel actually does. */
30 summary: string
31 scope: ModuleScope
32}
33
34export const coachingModules: CoachingModule[] = [
35 {
36 slug: 'plans',
37 label: 'Tiers',
38 title: 'What is open',
39 summary: 'The tiers a coach has published, what each one covers and what it costs. Nothing here is set by this site.',
40 scope: 'public',
41 },
42 {
43 slug: 'calendar',
44 label: 'The week',
45 title: 'Your week',
46 summary: 'The sessions your method has given you, sized to the days you have. Log the sets, the time and how hard it was.',
47 scope: 'athlete',
48 },
49 {
50 slug: 'feedback',
51 label: 'Say how it felt',
52 title: 'Say how it felt',
53 summary: 'Record a voice message after a session. You read the transcript back and send it yourself; next week moves inside the method.',
54 scope: 'athlete',
55 },
56 {
57 slug: 'progress',
58 label: 'Progress',
59 title: 'What you have done',
60 summary: 'Sessions given against sessions finished, across every tier you are paying for.',
61 scope: 'athlete',
62 },
63 {
64 slug: 'library',
65 label: 'The method',
66 title: 'The method you are in',
67 summary: 'The coach behind your tier and the published summary of how they train people. The private methodology stays with them.',
68 scope: 'athlete',
69 },
70 {
71 slug: 'health',
72 label: 'Health',
73 title: 'Your health data',
74 summary: 'Sleep, heart rate and the rest, read from your own account. This site never sees it and neither does the race.',
75 scope: 'private',
76 },
77 {
78 slug: 'messages',
79 label: 'Messages',
80 title: 'Your messages',
81 summary: 'Your own inbox, read from your own account. Not a channel to the race, and not something the race can read.',
82 scope: 'private',
83 },
84 {
85 slug: 'account',
86 label: 'Account',
87 title: 'Billing and renewal',
88 summary: 'Cancel, resume a renewal, or open billing. Payment is handled where your account lives, never on this site.',
89 scope: 'athlete',
90 },
91]
92
93const bySlug = new Map(coachingModules.map(module => [module.slug, module]))
94
95export function coachingModule(slug: string): CoachingModule | undefined {
96 return bySlug.get(slug)
97}
98
99/** The URL this site serves a module at. */
100export function modulePath(slug: string): string {
101 return `/coaching/${slug}`
102}
103
104/**
105 * The modules to show, in this site's order, limited to what the published
106 * brand enables. An unreachable brand means the storefront only: linking a
107 * runner to a panel that cannot load is worse than not offering it.
108 */
109export function enabledModules(published: readonly string[] | undefined): CoachingModule[] {
110 if (!published || published.length === 0)
111 return coachingModules.filter(module => module.scope === 'public')
112 return coachingModules.filter(module => published.includes(module.slug))
113}
114
115/** Only `public` modules may be indexed; the rest need an account to show anything. */
116export function indexableModulePaths(): string[] {
117 return coachingModules.filter(module => module.scope === 'public').map(module => modulePath(module.slug))
118}
tests/coaching-surface.test.tsadded+61-0
Changes to tests/coaching-surface.test.ts
@@ -0,0 +1,61 @@
1import { describe, expect, it } from 'bun:test'
2import { coachingModule, coachingModules, enabledModules, indexableModulePaths, modulePath } from '../resources/data/coaching'
3
4describe('the coaching module registry', () => {
5 it('uses each HQ module key once, as its own URL segment', () => {
6 const slugs = coachingModules.map(module => module.slug)
7 expect(new Set(slugs).size).toBe(slugs.length)
8 for (const slug of slugs) {
9 expect(slug).toMatch(/^[a-z]+$/)
10 expect(modulePath(slug)).toBe(`/coaching/${slug}`)
11 }
12 })
13
14 it('covers the eight modules HQ publishes, and nothing invented', () => {
15 expect(coachingModules.map(module => module.slug).sort()).toEqual(
16 ['account', 'calendar', 'feedback', 'health', 'library', 'messages', 'plans', 'progress'],
17 )
18 })
19
20 it('shows only the storefront when no brand is published', () => {
21 // An unreachable or draft brand must not advertise panels that cannot load.
22 expect(enabledModules(undefined).map(module => module.slug)).toEqual(['plans'])
23 expect(enabledModules([]).map(module => module.slug)).toEqual(['plans'])
24 })
25
26 it('keeps this site\'s order rather than the order the brand happens to list', () => {
27 expect(enabledModules(['account', 'plans', 'calendar']).map(module => module.slug))
28 .toEqual(['plans', 'calendar', 'account'])
29 })
30
31 it('ignores a module the brand names that this site does not serve', () => {
32 expect(enabledModules(['plans', 'whiteboard']).map(module => module.slug)).toEqual(['plans'])
33 })
34
35 it('indexes only what a visitor without an account can actually read', () => {
36 expect(indexableModulePaths()).toEqual(['/coaching/plans'])
37 for (const module of coachingModules) {
38 if (module.scope !== 'public')
39 expect(indexableModulePaths()).not.toContain(modulePath(module.slug))
40 }
41 })
42
43 it('marks health and messages as account-private, because a brand never sees them', () => {
44 expect(coachingModule('health')?.scope).toBe('private')
45 expect(coachingModule('messages')?.scope).toBe('private')
46 })
47
48 it('says what each panel does in a full sentence', () => {
49 for (const module of coachingModules) {
50 expect(module.summary.length).toBeGreaterThan(40)
51 expect(module.summary.endsWith('.')).toBe(true)
52 // The site's one hard typographic rule.
53 expect(module.summary).not.toContain('—')
54 expect(module.title).not.toContain('—')
55 }
56 })
57
58 it('does not know about a module that is not registered', () => {
59 expect(coachingModule('nope')).toBeUndefined()
60 })
61})