From 2c0aa265c0cc2a00a44adda697f10036c9939ba3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 05:19:22 +0000 Subject: [PATCH] =?UTF-8?q?fix(sportySync):=20drive=20sync=20via=20GitHub?= =?UTF-8?q?=20Actions=20cron=20=E2=80=94=20SWA=20managed=20functions=20ign?= =?UTF-8?q?ore=20timer=20triggers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Azure Static Web Apps managed functions run HTTP triggers only; the app.timer('sportySyncTimer', ...) trigger never registered in production, so the sporty.no calendar sync never ran. - Remove the dead timer trigger; document the platform limitation (#270) - Add .github/workflows/sporty-sync.yml cron (04/11/14/22 UTC, daysBack=7) - Accept X-Api-Key (SPORTY_SYNC_API_KEY) machine auth on POST /api/sporty-sync alongside the existing X-Supabase-Token JWT for manual kicks - Update CLAUDE.md, README.md, CHANGELOG.md; bump version to 1.5.17 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FwvkU2RBksppoWttDEcfB1 --- .github/workflows/sporty-sync.yml | 58 +++++++++++++++++++++++++++++++ CHANGELOG.md | 3 ++ CLAUDE.md | 5 ++- README.md | 12 +++++-- app/api/sportySync.js | 54 +++++++++++++++++----------- app/package.json | 2 +- 6 files changed, 108 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/sporty-sync.yml diff --git a/.github/workflows/sporty-sync.yml b/.github/workflows/sporty-sync.yml new file mode 100644 index 0000000..e0e3059 --- /dev/null +++ b/.github/workflows/sporty-sync.yml @@ -0,0 +1,58 @@ +name: Sporty sync + +# Azure Static Web Apps managed functions only run HTTP triggers — the timer +# trigger in app/api/sportySync.js never fires in production. This scheduled +# workflow drives the sync externally by POSTing to /api/sporty-sync. +# +# Required repository secrets: +# SPORTY_SYNC_URL — full endpoint URL, e.g. https://workout.umulig.org/api/sporty-sync +# SPORTY_SYNC_API_KEY — same value as the SPORTY_SYNC_API_KEY app setting in Azure +on: + schedule: + # 04:00, 11:00, 14:00 and 22:00 UTC daily. 22:00 UTC = midnight Oslo + # (CEST/UTC+2) — captures the next day's sessions while Sporty still + # returns them as "tomorrow". GitHub cron is best-effort and may be + # delayed under load; the 7-day lookback makes each run self-healing. + - cron: '0 4,11,14,22 * * *' + workflow_dispatch: + inputs: + daysBack: + description: 'Days to look back (self-heal window)' + required: false + default: '7' + +# Don't pile up overlapping syncs if a run is slow. +concurrency: + group: sporty-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 5 + name: Trigger sporty.no sync + steps: + - name: POST /api/sporty-sync + env: + SPORTY_SYNC_URL: ${{ secrets.SPORTY_SYNC_URL }} + SPORTY_SYNC_API_KEY: ${{ secrets.SPORTY_SYNC_API_KEY }} + DAYS_BACK: ${{ github.event.inputs.daysBack || '7' }} + run: | + set -euo pipefail + if [ -z "${SPORTY_SYNC_URL:-}" ] || [ -z "${SPORTY_SYNC_API_KEY:-}" ]; then + echo "::error::SPORTY_SYNC_URL and SPORTY_SYNC_API_KEY repository secrets must be set" + exit 1 + fi + http=$(curl -sS -o response.json -w '%{http_code}' \ + --retry 3 --retry-delay 5 --retry-all-errors --max-time 120 \ + -X POST "$SPORTY_SYNC_URL" \ + -H "Content-Type: application/json" \ + -H "X-Api-Key: $SPORTY_SYNC_API_KEY" \ + --data "{\"daysBack\": ${DAYS_BACK}}") + echo "HTTP $http" + cat response.json || true + echo + if [ "$http" != "200" ]; then + echo "::error::sporty.no sync failed with HTTP $http" + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 5381f2f..b441761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,10 @@ All notable changes to Workout Lens are documented here. ## [Unreleased] +## [1.5.17] — 2026-06-25 + ### Developer / Infrastructure +- **Fix sporty.no sync never running — timer trigger unsupported on SWA managed functions** — the automatic calendar sync was implemented as an Azure Functions timer trigger (`app.timer('sportySyncTimer', ...)`) in `sportySync.js`. Azure Static Web Apps **managed functions run HTTP triggers only** — timer/cron triggers are silently ignored and never register, so the scheduled sync never fired in production (the secondary `AZURE_FUNCTIONS_ENVIRONMENT === 'Production'` guard was moot). Fix: removed the dead timer and drive the sync externally with a new GitHub Actions cron workflow (`.github/workflows/sporty-sync.yml`) that `POST`s to `/api/sporty-sync` at 04:00, 11:00, 14:00 and 22:00 UTC with a 7-day self-healing lookback. The `POST /api/sporty-sync` endpoint now accepts machine auth (`X-Api-Key: `) in addition to the existing Supabase JWT (`X-Supabase-Token`) for manual kicks. **Setup: add `SPORTY_SYNC_URL` and `SPORTY_SYNC_API_KEY` as GitHub Actions repo secrets.** Documented as pitfall #270. - **Fix sporty sync writes blocked by missing User-Agent** — Supabase rejects POST and DELETE requests from the `sb_secret` service role key when no `User-Agent` header is present (treats the request as a browser). Azure Functions' built-in `fetch` sends no User-Agent, so the cleanup DELETE and upsert POST in `sportySync.js` were silently failing after each sync — the cleanup wiped future rows, then the upsert failed to re-insert them, leaving `gym_calendar` empty from June 6 onwards. Added `User-Agent: WorkoutLens/1.0 sporty-sync (Azure Functions)` to both requests. A post-deploy manual backfill is needed to restore June data. ## [1.5.16] — 2026-05-19 diff --git a/CLAUDE.md b/CLAUDE.md index 4ebe317..fcae8ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ Skeleton dark-mode tokens must be added to `[data-theme="g100"]` in `carbon-toke **Azure Functions entry:** `app/api/index.js` must import every new function file — Azure v4 only loads what `main` references. API files must use raw `fetch` to Supabase REST — never `import { createClient } from '@supabase/supabase-js'`. -**Sporty sync:** `sportySync.js` timer triggers 04:00, 11:00, 14:00 UTC. Timer guarded by `AZURE_FUNCTIONS_ENVIRONMENT === 'Production'` — skipped in local dev. +**Sporty sync:** SWA managed functions run **HTTP triggers only** — Azure Functions timer triggers never fire in production (see pitfall #270). The sync is driven by a GitHub Actions cron workflow (`.github/workflows/sporty-sync.yml`) that `POST`s to `/api/sporty-sync` at 04:00, 11:00, 14:00, 22:00 UTC with `{"daysBack":7}`. The endpoint accepts either `X-Api-Key: ` (automation) or `X-Supabase-Token: ` (manual kick from a signed-in user). Do NOT re-add an `app.timer(...)` — it is dead code on this platform. **Recs cache:** Bump `RECS_PROMPT_VERSION` in both `prompts.js` AND `recsCacheCleanup.js` whenever the recommendation prompt or model changes. A CI test (`recsVersion.test.js`) fails if they drift. @@ -184,3 +184,6 @@ Default `GRANT ALL` gives anon TRUNCATE (bypasses RLS). **Only grant what PostgR ### #268 — Supabase blocks sb_secret writes without User-Agent Supabase treats POST/DELETE with an `sb_secret` service role key and no `User-Agent` as a browser request and returns 403 "Forbidden use of secret API key in browser". Azure Functions' built-in `fetch` sends no User-Agent by default. **Always add `'User-Agent': 'WorkoutLens/1.0 sporty-sync (Azure Functions)'` to every write request (POST, DELETE, PATCH) that uses the service role key.** GET requests are unaffected. + +### #270 — SWA managed functions ignore timer triggers +Azure Static Web Apps **managed** functions (`api_location: "app/api"` in `ci.yml`) run **HTTP triggers only** — `app.timer(...)` and every other non-HTTP trigger is silently dropped, never registers, and never fires. This is why the sporty.no sync never ran: it was an `app.timer('sportySyncTimer', ...)`. **Fix: drive scheduled work from outside** — a GitHub Actions cron workflow (`.github/workflows/sporty-sync.yml`) that `POST`s to the HTTP endpoint. Never schedule recurring work with `app.timer` on this platform; the only escape hatch is "bring your own Functions app". [Docs](https://learn.microsoft.com/azure/static-web-apps/apis-functions#constraints). diff --git a/README.md b/README.md index 681183e..c4772f2 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ app/ api/ index.js # Entry point — imports all Azure Functions claude.js # Azure Function — proxies requests to Anthropic API - sportySync.js # Azure Function — timer (04:00+11:00 UTC) + HTTP trigger for sporty.no sync + sportySync.js # Azure Function — HTTP triggers for sporty.no sync (driven by GitHub Actions cron; SWA managed functions can't run timers) sportyUtils.js # Pure utility — normalizeName() (no Azure SDK dep; unit-tested) host.json # Azure Functions runtime config package.json # API dependencies @@ -161,6 +161,10 @@ Hosted on **Azure Static Web Apps** — every push to `master` triggers a build Live URL: `https://workout.umulig.org` +### Scheduled sporty.no sync + +Azure SWA managed functions run **HTTP triggers only** — Azure Functions timer/cron triggers are silently ignored ([docs](https://learn.microsoft.com/azure/static-web-apps/apis-functions#constraints)). The sporty.no calendar sync is therefore driven by a GitHub Actions cron workflow (`.github/workflows/sporty-sync.yml`) that `POST`s to `/api/sporty-sync` at 04:00, 11:00, 14:00 and 22:00 UTC with a 7-day self-healing lookback. It authenticates with the `SPORTY_SYNC_API_KEY` secret. Run it on demand via **Actions → Sporty sync → Run workflow**. + ### Required secrets (GitHub Actions) | Secret | Purpose | @@ -168,6 +172,8 @@ Live URL: `https://workout.umulig.org` | `VITE_SUPABASE_URL` | Injected into frontend bundle via `env:` block on the build step | | `VITE_SUPABASE_ANON_KEY` | Injected into frontend bundle via `env:` block on the build step | | `AZURE_STATIC_WEB_APPS_API_TOKEN_` | Azure deploy token (the exact name is generated by Azure when you create the SWA resource; find it in the deployment workflow Azure downloads to your repo) | +| `SPORTY_SYNC_URL` | Full sync endpoint URL (e.g. `https://workout.umulig.org/api/sporty-sync`) — used by the `Sporty sync` cron workflow | +| `SPORTY_SYNC_API_KEY` | Machine auth for the `Sporty sync` cron workflow — must match the `SPORTY_SYNC_API_KEY` Azure app setting | ### Required app settings (Azure SWA) @@ -176,8 +182,8 @@ Live URL: `https://workout.umulig.org` | `ANTHROPIC_API_KEY` | Used by the Claude proxy function — never exposed to browser | | `SUPABASE_URL` | Used by the Claude proxy (JWT verification) and sporty.no sync function | | `VITE_SUPABASE_ANON_KEY` | Used by the Claude proxy to verify Supabase JWTs — same value as the GitHub Actions secret | -| `SUPABASE_SERVICE_ROLE_KEY` | Used by the sporty.no sync function (bypasses RLS — timer has no auth user) | -| `SPORTY_SYNC_API_KEY` | Required `x-api-key` header for `GET /api/sporty-health` (external monitoring check) — `POST /api/sporty-sync` now uses Supabase JWT auth | +| `SUPABASE_SERVICE_ROLE_KEY` | Used by the sporty.no sync function (bypasses RLS — sync has no auth user) | +| `SPORTY_SYNC_API_KEY` | Required `X-Api-Key` header for `GET /api/sporty-health` (monitoring) **and** the scheduled `POST /api/sporty-sync` (GitHub Actions cron). `POST /api/sporty-sync` also accepts a Supabase JWT (`X-Supabase-Token`) for manual kicks from a signed-in user. | > **Note:** The frontend is built in the GitHub Actions runner (not by Oryx inside Azure SWA's Docker container). Oryx strips `VITE_*` env vars before spawning Vite, so they would never reach the bundle if built there. The workflow pre-builds `app/dist/` and the Azure SWA action uploads it directly via `app_location: "app/dist"`. Do not revert this. diff --git a/app/api/sportySync.js b/app/api/sportySync.js index 5e36f41..c128e3b 100644 --- a/app/api/sportySync.js +++ b/app/api/sportySync.js @@ -149,18 +149,15 @@ async function syncGymCalendar(context, { shiftDays = 0, daysBack = 0 } = {}) { return { ok: true, upserted: rows.length }; } -// ── Timer trigger: 22:00, 04:00, 11:00, and 14:00 UTC daily ────────── +// ── No timer trigger ────────────────────────────────────────────────── +// Azure Static Web Apps managed functions support HTTP triggers ONLY — timer +// (cron) triggers are silently ignored and never register. The scheduled sync +// is therefore driven externally by a GitHub Actions cron workflow +// (.github/workflows/sporty-sync.yml) that POSTs to /api/sporty-sync at +// 04:00, 11:00, 14:00 and 22:00 UTC with {"daysBack": 7}. // 22:00 UTC = midnight Oslo (CEST/UTC+2) — captures next day's sessions while -// Sporty still returns them as "tomorrow". Later runs keep the schedule fresh. -// Skipped locally — SWA CLI only supports HTTP triggers. -if (process.env.AZURE_FUNCTIONS_ENVIRONMENT === 'Production') { - app.timer('sportySyncTimer', { - schedule: '0 4,11,14,22 * * *', - handler: async (myTimer, context) => { - await syncGymCalendar(context, { daysBack: 7 }); - }, - }); -} +// Sporty still returns them as "tomorrow". +// Docs: https://learn.microsoft.com/azure/static-web-apps/apis-functions#constraints // ── HTTP trigger: health check ──────────────────────────────────────── // GET /api/sporty-health → returns most-recent gym_calendar row + count @@ -228,23 +225,38 @@ app.http('sportySyncHealth', { }, }); -// ── HTTP trigger: manual kick + optional backfill ───────────────────── +// ── HTTP trigger: scheduled sync (cron) + manual kick + optional backfill ── // POST /api/sporty-sync → sync today +// POST /api/sporty-sync {"daysBack":7} → self-healing 7-day lookback (cron default) // POST /api/sporty-sync {"shiftDays":-7} → duplicate current data 7 days back -// Requires header: X-Supabase-Token: -// (Azure SWA hijacks the Authorization header — never use it for app JWTs) +// +// Two auth paths are accepted: +// 1. Automation (GitHub Actions cron): header X-Api-Key: +// SWA managed functions only run HTTP triggers — no timer trigger ever fires +// in production (see .github/workflows/sporty-sync.yml), so an external +// scheduler drives the sync via this endpoint. +// 2. Manual kick from a signed-in user: header X-Supabase-Token: +// (Azure SWA hijacks the Authorization header — never use it for app JWTs) app.http('sportySyncHttp', { methods: ['POST'], route: 'sporty-sync', authLevel: 'anonymous', handler: async (request, context) => { - const token = request.headers.get('x-supabase-token'); - const userId = await verifySupabaseJwt( - token, - process.env.SUPABASE_URL, - process.env.SUPABASE_ANON_KEY, - ); - if (!userId) { + const apiKey = request.headers.get('x-api-key'); + const expectedKey = process.env.SPORTY_SYNC_API_KEY; + let authorized = Boolean(expectedKey && apiKey === expectedKey); + + if (!authorized) { + const token = request.headers.get('x-supabase-token'); + const userId = await verifySupabaseJwt( + token, + process.env.SUPABASE_URL, + process.env.SUPABASE_ANON_KEY, + ); + authorized = Boolean(userId); + } + + if (!authorized) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, diff --git a/app/package.json b/app/package.json index cc727d3..96d5088 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "workout-lens", "private": true, - "version": "1.5.16", + "version": "1.5.17", "author": "Christopher Rotnes", "license": "MIT", "repository": {