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.
| @@ -1,4 +1,11 @@ | ||
| 1 | 1 | import type { ServerConfig } from '@stacksjs/types' |
| 2 | import coaching from './coaching' | |
| 3 | ||
| 4 | /** | |
| 5 | * The HQ origin the coaching panel is framed from. Permissions-Policy needs | |
| 6 | * the exact origin, quoted, and it must agree with what CoachingPortal frames. | |
| 7 | */ | |
| 8 | const hqOrigin = new URL(coaching.origin).origin | |
| 2 | 9 | |
| 3 | 10 | /** |
| 4 | 11 | * **Server Configuration** |
| @@ -10,6 +17,27 @@ import type { ServerConfig } from '@stacksjs/types' | ||
| 10 | 17 | * Discord or GitHub Discussions. |
| 11 | 18 | */ |
| 12 | 19 | export default { |
| 20 | /** | |
| 21 | * **Response headers** | |
| 22 | * | |
| 23 | * `path` or `path/*` to the headers that path answers with. | |
| 24 | * | |
| 25 | * Recording a voice message is the whole point of the coaching feedback | |
| 26 | * module, and a cross-origin frame can only reach the microphone if the | |
| 27 | * page embedding it delegates that permission. Without this the browser | |
| 28 | * blocks `getUserMedia` inside HQ's frame and the feature fails quietly, | |
| 29 | * which is the worst way for it to fail: nothing errors, the button simply | |
| 30 | * never does anything. | |
| 31 | * | |
| 32 | * Only the two routes that need it. The hub embeds every section, including | |
| 33 | * feedback; the seven other module routes pin one section each and have no | |
| 34 | * use for a microphone. | |
| 35 | */ | |
| 36 | headers: { | |
| 37 | '/coaching': { 'Permissions-Policy': `microphone=(self "${hqOrigin}")` }, | |
| 38 | '/coaching/feedback': { 'Permissions-Policy': `microphone=(self "${hqOrigin}")` }, | |
| 39 | }, | |
| 40 | ||
| 13 | 41 | /** |
| 14 | 42 | * **API Proxy** |
| 15 | 43 | * |
| @@ -1,6 +1,23 @@ | ||
| 1 | 1 | /* eslint-disable no-console */ |
| 2 | 2 | import { resolve, sep } from 'node:path' |
| 3 | 3 | import { compressResponse } from '@stacksjs/stx/compression' |
| 4 | import server from '../config/server' | |
| 5 | ||
| 6 | /** | |
| 7 | * The same per-path headers the production server applies from | |
| 8 | * config/server.ts, matched the same way: `path/*` is a prefix, anything | |
| 9 | * else is exact. A preview that grants the microphone where production does | |
| 10 | * not is a preview that cannot find the bug, so this reads the one rule set | |
| 11 | * rather than keeping a second hard-coded origin beside it. | |
| 12 | */ | |
| 13 | function configuredHeaders(pathname: string): Record<string, string> { | |
| 14 | const matched: Record<string, string> = {} | |
| 15 | for (const [pattern, headers] of Object.entries(server.headers ?? {})) { | |
| 16 | const hit = pattern.endsWith('/*') ? pathname.startsWith(pattern.slice(0, -1)) : pathname === pattern | |
| 17 | if (hit) Object.assign(matched, headers) | |
| 18 | } | |
| 19 | return matched | |
| 20 | } | |
| 4 | 21 | |
| 5 | 22 | const root = resolve('dist') |
| 6 | 23 | Bun.serve({ hostname: '127.0.0.1', port: 3942, async fetch(request) { |
| @@ -15,6 +32,7 @@ Bun.serve({ hostname: '127.0.0.1', port: 3942, async fetch(request) { | ||
| 15 | 32 | let file = Bun.file(path) |
| 16 | 33 | let status = 200 |
| 17 | 34 | if (!await file.exists()) { file = Bun.file(resolve(root, '404.html')); status = 404 } |
| 18 | return compressResponse(request, new Response(file, { status, headers: { 'Cache-Control': 'no-store', 'Permissions-Policy': 'microphone=(self "http://localhost:3940")' } })) | |
| 35 | const headers = { 'Cache-Control': 'no-store', ...configuredHeaders(pathname.replace(/(.)\/$/, '$1')) } | |
| 36 | return compressResponse(request, new Response(file, { status, headers })) | |
| 19 | 37 | } }) |
| 20 | 38 | console.log('LSU preview listening at http://localhost:3942') |
| @@ -1,6 +1,8 @@ | ||
| 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 coaching from '../config/coaching' | |
| 5 | import server from '../config/server' | |
| 4 | 6 | import { coachingBrand, coachingOrigin, coachingSection, portalElementUrl, portalUrl } from '../resources/functions/coaching-source' |
| 5 | 7 | |
| 6 | 8 | describe('the coaching module registry', () => { |
| @@ -195,3 +197,32 @@ describe('where the portal is', () => { | ||
| 195 | 197 | expect(portalElementUrl('https://hq.training')).toBe('https://hq.training/assets/elements/hq-coaching.js') |
| 196 | 198 | }) |
| 197 | 199 | }) |
| 200 | ||
| 201 | describe('the microphone the feedback module needs', () => { | |
| 202 | /** Exactly how @stacksjs/server matches config.server.headers. */ | |
| 203 | function headersFor(pathname: string): Record<string, string> { | |
| 204 | const matched: Record<string, string> = {} | |
| 205 | for (const [pattern, headers] of Object.entries(server.headers ?? {})) { | |
| 206 | const hit = pattern.endsWith('/*') ? pathname.startsWith(pattern.slice(0, -1)) : pathname === pattern | |
| 207 | if (hit) Object.assign(matched, headers) | |
| 208 | } | |
| 209 | return matched | |
| 210 | } | |
| 211 | ||
| 212 | it('is delegated on the two routes that frame it', () => { | |
| 213 | for (const path of ['/coaching', '/coaching/feedback']) { | |
| 214 | expect(headersFor(path)['Permissions-Policy']).toBe(`microphone=(self "${new URL(coaching.origin).origin}")`) | |
| 215 | } | |
| 216 | }) | |
| 217 | ||
| 218 | it('names the origin the panel is actually framed from', () => { | |
| 219 | // A policy naming a different host than the iframe src grants nothing. | |
| 220 | const policy = headersFor('/coaching/feedback')['Permissions-Policy'] ?? '' | |
| 221 | expect(policy).toContain(new URL(portalUrl('feedback')).origin) | |
| 222 | }) | |
| 223 | ||
| 224 | it('is not handed to a route with no microphone in it', () => { | |
| 225 | for (const path of ['/', '/format', '/coaching/calendar', '/coaching/account']) | |
| 226 | expect(headersFor(path)['Permissions-Policy']).toBeUndefined() | |
| 227 | }) | |
| 228 | }) | |