my personal site
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(gymtracker): admin API auth and KV shape for production

- Treat Cloudflare Access as authenticated when either Cf-Access-Jwt-Assertion
or Cf-Access-Authenticated-User-Email is present (email OTP flows).
- Normalize KV ads payload as array or { ads: [...] }; safer legacy migration.
- Parse /api/admin/ads response as text and surface non-JSON responses.
- Document prod KV vs preview and API troubleshooting in ACCESS_SETUP.md.
- Prefer /api/* first in run_worker_first ordering (same coverage as before).

+66 -8
+4 -1
gymtracker/ACCESS_SETUP.md
··· 97 97 ## Troubleshooting 98 98 99 99 - **401 Unauthorized** on `/admin`: Access may not be protecting that path yet, or the JWT isn’t being sent. Confirm the Access application path matches `/admin` and `/api/admin`. 100 + - **Admin loads but ad list is empty, status shows Connected**: 101 + - In DevTools → **Network**, open the request to `/api/admin/ads`. You should see **200** and a JSON body like `{ "ads": [ ... ] }`. If the response is HTML or a login page, `/api/admin` is not covered by Access or the request is going to the wrong host (open admin only at `https://gymtracker.jackhannon.net/admin`). 102 + - **Production KV is separate from preview**: ads you create in `wrangler dev` use the **preview** KV namespace. Production uses the namespace id in `wrangler.jsonc` (`kv_namespaces.id`). To backfill production data, use `wrangler kv key put` against the **production** namespace or create ads again after signing in on prod. 100 103 - **CORS errors**: The Worker allows `gymtracker.jackhannon.net`. If you use another origin, add it to `ALLOWED_ORIGINS` in the Worker. 101 104 - **Public API blocked**: Ensure `/api/ads` is not covered by a “Require” policy, or add a Bypass policy for it. 102 105 ··· 108 111 npm run deploy 109 112 ``` 110 113 111 - The Worker serves `/admin` and `/api/admin/ads` and checks for the `Cf-Access-Jwt-Assertion` header. Cloudflare adds this header when the request has passed Access, so no API key is required. 114 + The Worker serves `/admin` and `/api/admin/ads` and treats a request as authenticated when Cloudflare adds **`Cf-Access-Jwt-Assertion`** or **`Cf-Access-Authenticated-User-Email`** (after a successful Access login). No API key is required for the admin UI when Access is configured correctly.
+26 -2
gymtracker/src/admin-html.ts
··· 1597 1597 document.getElementById('calendarWrap').classList.add('loading'); 1598 1598 try { 1599 1599 const res = await fetchWithRetry(API_URL, { credentials: 'include' }); 1600 - let data; 1601 - try { data = await res.json(); } catch (_) { data = {}; } 1600 + const contentType = (res.headers.get('content-type') || '').toLowerCase(); 1601 + const bodyText = await res.text(); 1602 + let data = {}; 1603 + let jsonParseOk = true; 1604 + if (bodyText) { 1605 + try { 1606 + data = JSON.parse(bodyText); 1607 + } catch (_) { 1608 + jsonParseOk = false; 1609 + data = {}; 1610 + } 1611 + } 1602 1612 if (res.status === 401) { 1603 1613 document.getElementById('adCards').classList.remove('loading'); 1604 1614 document.getElementById('calendarWrap').classList.remove('loading'); ··· 1610 1620 document.getElementById('calendarWrap').classList.remove('loading'); 1611 1621 setStatus(fetchStatus, 'Not connected', false); 1612 1622 showErrorBanner('Failed to load', (data.error || res.statusText || 'Unknown error')); 1623 + return; 1624 + } 1625 + const expectJson = contentType.includes('application/json'); 1626 + const looksLikeAdsApi = 1627 + data && typeof data === 'object' && 1628 + (Array.isArray(data.ads) || data.id != null || data.error !== undefined); 1629 + if (!jsonParseOk || (bodyText && !expectJson && !looksLikeAdsApi)) { 1630 + document.getElementById('adCards').classList.remove('loading'); 1631 + document.getElementById('calendarWrap').classList.remove('loading'); 1632 + setStatus(fetchStatus, 'Not connected', false); 1633 + showErrorBanner( 1634 + 'Ads API did not return JSON', 1635 + 'Open this page at <strong>https://gymtracker.jackhannon.net/admin</strong> (not another domain). In Zero Trust, include path <code>/api/admin</code> on the same Access app as <code>/admin</code>. Then hard-refresh.' 1636 + ); 1613 1637 return; 1614 1638 } 1615 1639 scheduledAds = parseAdsResponse(data);
+35 -4
gymtracker/src/index.ts
··· 225 225 return true; 226 226 } 227 227 228 + /** Normalize KV payload: stored as `AdConfig[]` or `{ ads: AdConfig[] }` (legacy / mistaken shape). */ 229 + function adsArrayFromKvJson(parsed: unknown): AdConfig[] { 230 + if (Array.isArray(parsed)) { 231 + return parsed as AdConfig[]; 232 + } 233 + if ( 234 + parsed && 235 + typeof parsed === "object" && 236 + "ads" in parsed && 237 + Array.isArray((parsed as { ads: unknown }).ads) 238 + ) { 239 + return (parsed as { ads: AdConfig[] }).ads; 240 + } 241 + return []; 242 + } 243 + 228 244 async function getAdsArray(kv: KVNamespace): Promise<AdConfig[]> { 229 245 const legacy = await kv.get(KV_KEY_LEGACY); 230 246 if (legacy) { 231 247 try { 232 - const migrated = [JSON.parse(legacy) as AdConfig]; 248 + const raw = JSON.parse(legacy) as unknown; 249 + let migrated = adsArrayFromKvJson(raw); 250 + if (migrated.length === 0 && raw && typeof raw === "object" && !Array.isArray(raw)) { 251 + migrated = [raw as AdConfig]; 252 + } 253 + if (migrated.length === 0) { 254 + return []; 255 + } 233 256 await kv.put(KV_KEY_ADS, JSON.stringify(migrated)); 234 257 await kv.delete(KV_KEY_LEGACY); 235 258 return migrated; ··· 241 264 const value = await kv.get(KV_KEY_ADS); 242 265 if (!value) return []; 243 266 try { 244 - return JSON.parse(value) as AdConfig[]; 267 + return adsArrayFromKvJson(JSON.parse(value) as unknown); 245 268 } catch (err) { 246 269 console.error("Failed to parse ads:", err); 247 270 return []; ··· 387 410 } 388 411 } 389 412 390 - /** True if request passed Cloudflare Access (JWT header present) or is localhost. */ 413 + /** 414 + * True if request passed Cloudflare Access or is local dev. 415 + * Cloudflare adds Cf-Access-Jwt-Assertion after a successful Access login; the user email 416 + * header is also present for authenticated requests (see Cloudflare Access docs). 417 + */ 391 418 function hasAccessAuth(request: Request): boolean { 392 - return isLocalRequest(request) || !!request.headers.get("Cf-Access-Jwt-Assertion"); 419 + if (isLocalRequest(request)) return true; 420 + return !!( 421 + request.headers.get("Cf-Access-Jwt-Assertion") || 422 + request.headers.get("Cf-Access-Authenticated-User-Email") 423 + ); 393 424 } 394 425 395 426 export default {
+1 -1
gymtracker/wrangler.jsonc
··· 4 4 "assets": { 5 5 "directory": "./public", 6 6 "binding": "ASSETS", 7 - "run_worker_first": ["/", "/admin*", "/ads*", "/api/*"] 7 + "run_worker_first": ["/api/*", "/", "/admin*", "/ads*"] 8 8 }, 9 9 "vars": { 10 10 "POSTHOG_PROJECT_ID": "352692",