feat: AM-3..AM-6 strip SWIFTLY_BEARER_TOKEN env var, delete superseded script, refresh docs

AM-3: SWIFTLY_BEARER_TOKEN removed from .env.example, .env.test (local),
docker-compose.yml service env, and Settings (backend/app/config.py).
The scraper docstring is updated to reflect the auto-mint path.

AM-4: scripts/refresh_swiftly_token.py (commit ccfb38a, seleniumbase
click-through capture) deleted; superseded by swiftly_auth.py.

AM-5: docs refreshed.
- spec status header → "Implemented 2026-05-06" with live-verification
  evidence
- HANDOFF.md TL;DR + caveats #2/#3 collapsed; replaced with the
  auto-mint failure-modes caveat; "Suggested next move" rewritten
  pointing to Phase 5 orchestration; file-map and last-updated touched
- ORIENTATION.md env-var section updated (no bearer var) + footer

AM-6 verification gate (run 2026-05-06):
- pytest -q tests/ → 92/92 green (88 prior + 4 new swiftly_auth)
- POST /api/admin/scrape → status=success, items_scraped=10928 in 44s
- grocery_item rows: 9980 (after dedup-by external_id)
- ingredient_grocery_match rows: 29779 (matcher post-hook populated)
- Container env confirmed clean of SWIFTLY_BEARER_TOKEN

The system now scrapes, matches, and generates plans without any
operator-managed credential. Live JWT lifecycle: Firebase REST anon
signUp → cache for ~55min → re-mint as needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 15:47:15 -07:00
co-authored by Claude Opus 4.7
parent dfd79a9d08
commit 95b8e0c1b5
8 changed files with 38 additions and 347 deletions
+21 -40
View File
@@ -2,7 +2,7 @@
You are taking over a project in mid-flight. Read `docs/ORIENTATION.md` first for the high-level. This file is the deep dive: what's real, what's stubbed, where the bodies are buried, and what to do next.
Date of handoff: 2026-05-06. Last commit before handoff: Swiftly auto-mint design spec (post-Phase 9).
Date of handoff: 2026-05-06. Last commit before handoff: Swiftly token auto-mint shipped (AM-1..AM-6).
---
@@ -12,7 +12,7 @@ The project completed a **recovery pass** (R1+R2+R3-0) from 2026-05-04 to 2026-0
The project's reason to exist is now real and verified end-to-end. 88/88 pytest tests pass.
**Top remaining toil:** `SWIFTLY_BEARER_TOKEN` expires hourly, requiring manual devtools capture. A complete redesign is specced in `docs/specs/2026-05-06-swiftly-token-auto-mint.md` (Path B: pure-HTTP token minting via Firebase REST, validated on 2026-05-06). That should ship first — see "Suggested next move".
**Operator toil eliminated.** Swiftly bearer JWTs are now auto-minted via Firebase REST anon-signUp (`backend/app/services/swiftly_auth.py`); the `SWIFTLY_BEARER_TOKEN` env var is gone. Cache hit ratio in steady state is ~99% (one mint per ~hour). See `docs/specs/2026-05-06-swiftly-token-auto-mint.md` (status: Implemented).
---
@@ -27,7 +27,7 @@ The project's reason to exist is now real and verified end-to-end. 88/88 pytest
- Lucky California ingestion (`backend/app/scraper/lucky_ca_scraper.py`) is a `requests`-based Swiftly JSON API client. **Not Playwright** — that path was deleted. Discovers 17 categories from `https://luckysupermarkets.com/categories`, fetches each from `prod.swiftlyapi.net/search/api/v1/products/categories?cat=…&store=757&limit=10000`. Bearer scoping: token only ever attached to `prod.swiftlyapi.net` requests, never to the public categories page.
- Approval flow (`backend/app/services/approval.py` + meals router): per-voter `URLSafeTimedSerializer` tokens, TTL, single-use enforced in `consume_token`, GET renders an HTMLResponse vote page, POST records the vote and applies the rule (any deny → item denied; all approve → item approved; otherwise pending).
- Email backend (`backend/app/services/email.py`): Protocol + `ConsoleEmailBackend` (writes JSONL to `backend/var/email_outbox.jsonl`) + `SendGridEmailBackend` stub that raises `NotImplementedError`. Selected via `EMAIL_BACKEND` env (default `console`).
- 401 from Swiftly raises `SwiftlyAuthError` carrying the verbatim message `"SWIFTLY_BEARER_TOKEN expired — request a fresh token from the user (capture from luckysupermarkets.com network tab on a /search/api/v1 request)"`. The bg runner catches it and writes `ScrapeLog.error_message` so it surfaces via the admin logs endpoint.
- 401 from Swiftly is now rare (we always send a freshly minted JWT). When it does happen, `SwiftlyAuthError` carries a message pointing at the auto-mint spec; mint failures upstream surface as `SwiftlyAuthMintError`. Either lands verbatim in `ScrapeLog.error_message` via the bg runner.
- Thin Phase 4: ingredient + recipe CRUD endpoints with admin gating; NeverSuggest CRUD (covers both ingredient blocklist and recipe blocklist via the existing schema); ingredient↔grocery_item match layer (rapidfuzz top-3 ranking with confidence threshold 0.75, manual override via /api/admin/ingredients/{id}/matches and /api/admin/ingredient-matches/{id}); 50 canonical ingredients seeded with aliases enriching pre-existing rows from migration 0002; 30 starter recipes spanning chicken/beef/turkey/pork/fish/vegetarian with varied cuisines, all under 45 min for 28/30. Match job runs after each successful scrape; matcher failures don't flip the scrape to FAILED.
- Phase 9: meal-plan generation. POST /api/admin/meal-plans/generate runs the full filter→score→set-select pipeline against seeded recipes and produces a persisted MealPlan with up to 3 MealPlanItem dinners. Regenerate endpoint accepts relaxed constraint overrides (`relax_time_max_minutes`, `relax_calorie_pct`, `relax_max_meal_cost`) and deletes any prior plan for the same `(family, week_start_date)` before re-running. Per-meal cost matched against ingredient_grocery_match using the top-confidence grocery row. 88 tests green.
@@ -87,31 +87,29 @@ The project's reason to exist is now real and verified end-to-end. 88/88 pytest
1. **Bootstrap login hatch.** `app/api/auth.py` login: when no `family_profile` row exists, it signs the literal string `"bootstrap"` instead of a UUID. Anyone with `SESSION_PASSWORD` gets a session even with zero data in the DB. Acceptable for self-hosted on a trusted network. Replace with a proper first-run setup gate before exposing the system beyond the LAN/VPN. The decision is documented in `.agent/context.md` under "Decisions".
2. **`SWIFTLY_BEARER_TOKEN` expires hourly — and there's a designed-but-not-yet-built fix.** Today the env var holds a Firebase anonymous-auth JWT for `swiftly-lu-prod` that expires hourly; on 401 the user manually captures a fresh one from devtools. **The replacement is fully designed in `docs/specs/2026-05-06-swiftly-token-auto-mint.md`** (~2-3 hours of work). The discovery: `https://luckysupermarkets.com/config.json` is publicly readable and exposes `firebaseApiKey`; with that, anonymous Firebase signUp via REST mints fresh tokens in <1 second. Verified end-to-end on 2026-05-06. **Do this redesign first** — it eliminates the only operator-toil step in the system. The `scripts/refresh_swiftly_token.py` (commit `ccfb38a`) seleniumbase approach is superseded; leave it for historical context but don't extend it.
2. **Auto-mint failure modes.** `swiftly_auth.get_token()` can fail in three ways: (a) `luckysupermarkets.com/config.json` becomes non-public; (b) Lucky disables anonymous Firebase auth on the `swiftly-lu-prod` project (signUp returns 400); (c) Google adds anti-abuse fingerprinting that the REST headers can't satisfy. All three surface as `SwiftlyAuthMintError` with the upstream status/body in the message and land verbatim in `ScrapeLog.error_message`. The fallback is to revive the manual-capture flow; the historical `scripts/refresh_swiftly_token.py` (commit `ccfb38a`) is in git history if you ever need it.
3. **`.env.example` ships a real (expiring) token.** Per user authorization. If it's already expired by the time you read this, that's expected — surface the refresh request to the user, OR (preferably) ship the auto-mint redesign per caveat #2 and remove the env var entirely.
3. **`ScrapeStatus` enum reuses `STARTED` for the queued state.** R1-C didn't add a `QUEUED` value because that would have churned the Postgres enum type. Cosmetic. If you change it, add a migration.
4. **`ScrapeStatus` enum reuses `STARTED` for the queued state.** R1-C didn't add a `QUEUED` value because that would have churned the Postgres enum type. Cosmetic. If you change it, add a migration.
4. **Pytest's transactional `db` fixture rolls back at teardown.** Background tasks open their own `SessionLocal()` and don't see uncommitted data. `test_swiftly_api.py::test_background_runner_writes_failed_with_token_message` is the example of how to test bg-task behavior — use a separate non-fixture session, commit, run, verify, clean up explicitly.
5. **Pytest's transactional `db` fixture rolls back at teardown.** Background tasks open their own `SessionLocal()` and don't see uncommitted data. `test_swiftly_api.py::test_background_runner_writes_failed_with_token_message` is the example of how to test bg-task behavior — use a separate non-fixture session, commit, run, verify, clean up explicitly.
5. **`alembic downgrade base` in 0001 preserves `alembic_version` table.** Don't change this to `DROP SCHEMA public CASCADE` — that would also drop `alembic_version` and break the alembic state machine on the next upgrade.
6. **`alembic downgrade base` in 0001 preserves `alembic_version` table.** Don't change this to `DROP SCHEMA public CASCADE` — that would also drop `alembic_version` and break the alembic state machine on the next upgrade.
6. **Login bootstrap aside, `family_profile` is currently empty in any fresh DB.** Phase 9 must either seed it during the first-run flow or assume the admin manually created the row. Either way, document it.
7. **Login bootstrap aside, `family_profile` is currently empty in any fresh DB.** Phase 9 must either seed it during the first-run flow or assume the admin manually created the row. Either way, document it.
7. **Routes use `@router.get("")` (no trailing slash).** FastAPI's `redirect_slashes=True` (the default) will 307-redirect `/api/profile/` to `/api/profile`. Tests assert canonical paths (no slash). The frontend client matches.
8. **Routes use `@router.get("")` (no trailing slash).** FastAPI's `redirect_slashes=True` (the default) will 307-redirect `/api/profile/` to `/api/profile`. Tests assert canonical paths (no slash). The frontend client matches.
8. **30 starter recipes seeded.** Migration 0007 loads them; enough to exercise Phase 9 against real data. Bulk ingestion source still deferred.
9. **30 starter recipes seeded.** Migration 0007 loads them; enough to exercise Phase 9 against real data. Bulk ingestion source still deferred.
9. **Frontend doesn't have a login UI.** Until you build one, the family-facing flows can't actually be exercised by a real user — only by tests. The Dashboard/Pantry/etc. pages assume the cookie is already set.
10. **Frontend doesn't have a login UI.** Until you build one, the family-facing flows can't actually be exercised by a real user — only by tests. The Dashboard/Pantry/etc. pages assume the cookie is already set.
10. `regenerate.exclude_recipe_ids` accepted by the API for forward compat but not yet honored by the orchestrator — only NeverSuggest blocklist applies. ~30-line follow-up.
11. `regenerate.exclude_recipe_ids` accepted by the API for forward compat but not yet honored by the orchestrator — only NeverSuggest blocklist applies. ~30-line follow-up.
11. `GET /api/meal-plans/{id}` returns persisted items but with `score=0`, `components={}`, and zeroed debug — those are only available in the immediate `generate` response. Acceptable for the email-approval flow which uses the generate response directly. To persist them, add columns to MealPlanItem.
12. `GET /api/meal-plans/{id}` returns persisted items but with `score=0`, `components={}`, and zeroed debug — those are only available in the immediate `generate` response. Acceptable for the email-approval flow which uses the generate response directly. To persist them, add columns to MealPlanItem.
12. `family_profile.calorie_target` is treated as per-serving by the planner filter (matches spec §2.1 wording). The family-setup UI/API should clarify per-serving vs per-day to avoid confusion. Test families use ~500 cal/serving for a 4-person household.
13. `family_profile.calorie_target` is treated as per-serving by the planner filter (matches spec §2.1 wording). The family-setup UI/API should clarify per-serving vs per-day to avoid confusion. Test families use ~500 cal/serving for a 4-person household.
14. Cost estimation treats `qty` as dimensionless (no unit conversion). Produces a biased-but-monotonic ranking signal; sufficient for current use, revisit if real-dollar accuracy is needed (`docs/specs/2026-05-05-meal-planner-algorithm-design.md` §7).
13. Cost estimation treats `qty` as dimensionless (no unit conversion). Produces a biased-but-monotonic ranking signal; sufficient for current use, revisit if real-dollar accuracy is needed (`docs/specs/2026-05-05-meal-planner-algorithm-design.md` §7).
---
@@ -147,25 +145,9 @@ docker compose --env-file .env.test exec backend \
## Suggested next move
**Swiftly token auto-mint first.** Phases 4 (thin slice) and 9 are both shipped — the project's reason to exist is real. The remaining operator toil is the hourly bearer-token refresh. Fully designed in `docs/specs/2026-05-06-swiftly-token-auto-mint.md`: ~2-3 hours, no new deps, replaces `SWIFTLY_BEARER_TOKEN` env var with a process-local cache that mints fresh JWTs from Firebase via REST. Eliminates manual capture entirely.
Swiftly auto-mint shipped (AM-1..AM-6). Phases 4 (thin slice) and 9 are both real and verified end-to-end. The system can now scrape → match → generate without operator toil.
### Pre-broken-down task list (start here)
A fresh agent should pick these up in order. Halt at the AM-2 boundary to verify a real-world live scrape before stripping the env var.
- [ ] **AM-1: `swiftly_auth.py` + unit tests.** New module `backend/app/services/swiftly_auth.py` with `get_token()`, `mint_anonymous_token()`, process-local cache `(token, exp)`, new `SwiftlyAuthMintError`. Tests at `backend/tests/test_swiftly_auth.py`: 4 unit tests covering mint, cache hit, near-expiry re-mint, non-200 → error. ~80 lines, ~1 hr.
- [ ] **AM-2: Wire into `lucky_ca_scraper.py`.** Replace `settings.SWIFTLY_BEARER_TOKEN` lookup with `get_token()`. Live-verify via `scripts/spike_swiftly_ingest.py --confirm-live`. **Halt here** for user confirmation before AM-3. ~30 min.
- [ ] **AM-3: Remove `SWIFTLY_BEARER_TOKEN` env var.** Drop from `.env.example`, `.env.test`, `docker-compose.yml`, `app/config.py` Settings, `.github/workflows/ci.yml`. Optional: add `SWIFTLY_FIREBASE_CONFIG_URL` (default `https://luckysupermarkets.com/config.json`). ~15 min.
- [ ] **AM-4: Delete superseded seleniumbase script.** `rm scripts/refresh_swiftly_token.py`; commit `ccfb38a` stays in history for context. ~5 min.
- [ ] **AM-5: Refresh docs.** `docs/HANDOFF.md` (remove caveats #2/#3, mark spec implemented), `docs/ORIENTATION.md` env-var section + footer, `docs/specs/2026-05-06-swiftly-token-auto-mint.md` status header → "Implemented". ~15 min.
- [ ] **AM-6: Verification gate.** Full `pytest -q tests/` (expect 92+ green: 88 prior + 4 new); live scrape end-to-end via `POST /api/admin/scrape` returning success with item count > 0; matcher confirms `ingredient_grocery_match` rows populated. ~20 min.
**Verified prerequisites** (already validated 2026-05-06):
- `https://luckysupermarkets.com/config.json` is publicly readable; `firebaseApiKey = AIzaSyCnG97lkCEUvVTcRdSEJ6looOPQgX0WE2U`
- `POST identitytoolkit.googleapis.com/v1/accounts:signUp?key=<API_KEY>` with `Origin: https://luckysupermarkets.com` + `Referer: https://luckysupermarkets.com/` returns a valid JWT (`iss=https://securetoken.google.com/swiftly-lu-prod`, `aud=swiftly-lu-prod`, `provider=anonymous`, 3600s TTL)
- Swiftly API accepts the minted JWT (verified: 400 "Category is required" on a malformed test, NOT 401)
### After AM-6, in priority order
### Priority order
1. **Phase 5 — meal-planner orchestration.** Chain scrape → generate → email → vote → finalize on a weekly cadence. APScheduler container with `--workers 1` was the original plan. All the parts exist (scrape, generate, email-stub, approval round-trip); nothing chains them.
2. **Phase 6 — SendGrid.** Replace the `ConsoleEmailBackend` JSONL stub with real SendGrid. Templates: meal proposal, T-24h reminder, confirmation, denial. `from_email`/`reply_to` config still needs adding to Settings.
@@ -182,7 +164,8 @@ Brainstorm with the user before committing to non-trivial scope. Use the `superp
| ID | Subject | Priority |
|---|---|---|
| AM-1..AM-6 | Swiftly token auto-mint (see Suggested next move above) | **Top — start here** |
| Phase 5 | Weekly cadence orchestration (scrape → generate → email → vote → finalize) | High |
| Phase 6 | Replace `ConsoleEmailBackend` stub with real SendGrid | High |
| #8 | `ScrapeStatus` enum could use a distinct `QUEUED` value | Cosmetic |
Other tasks in the recovery session were closed. See `.agent/phase-summaries/` for the detailed write-ups of each phase (R1A test harness, R1B+D auth+paths, R1C async scrape, R2A live scrape, R2B email approval, R3-0 Swiftly ingestion).
@@ -234,9 +217,7 @@ backend/tests/fixtures/lucky_ca/ categories.html, category_meat_seafood.
scripts/
├── send_test_approval.py email round-trip prover
├── spike_lucky_scrape.py R2-A archived
── spike_swiftly_ingest.py R3-0 live ingest prover
└── refresh_swiftly_token.py SUPERSEDED — seleniumbase token capture
(replaced by docs/specs/2026-05-06-swiftly-token-auto-mint.md)
── spike_swiftly_ingest.py live ingest prover (auto-minted JWT)
docs/specs/
├── 2026-05-05-meal-planner-algorithm-design.md Phase 9 + thin Phase 4 design
@@ -251,4 +232,4 @@ docs/specs/
Trust the tests. Trust the live runs. Don't trust prose claims that something is "complete" without running the verification gate yourself. The recovery happened because the prior agent did the latter without the former.
Last updated: 2026-05-06 — Phase 9 shipped; auto-mint design + AM-1..AM-6 task list embedded for fresh agent handoff.
Last updated: 2026-05-06 — Swiftly auto-mint shipped (AM-1..AM-6); operator toil eliminated. Next pickup: Phase 5 weekly orchestration.
+4 -7
View File
@@ -95,10 +95,7 @@ SENDGRID_API_KEY=... # only when EMAIL_BACKEND=sendgrid (R3-C)
LUCKY_STORE_ID=757 # Lucky California — San Pablo
SWIFTLY_API_BASE=https://prod.swiftlyapi.net
SWIFTLY_CATEGORIES_URL=https://luckysupermarkets.com/categories
SWIFTLY_BEARER_TOKEN=... # Firebase anon JWT, expires hourly
# → scheduled for removal: see
# docs/specs/2026-05-06-swiftly-token-auto-mint.md
# (auto-mint via Firebase REST, no manual capture)
# (no bearer-token env var — minted on demand by app/services/swiftly_auth.py)
# Other
LUCKY_CA_URL=https://luckysupermarkets.com
@@ -106,7 +103,7 @@ AI_IMAGE_ENABLED=false
LOG_LEVEL=INFO
```
`.env.example` carries a literal expiring bearer token — rotate before any real run. On expiry, the next scrape's `ScrapeLog.error_message` reads `SWIFTLY_BEARER_TOKEN expired — request a fresh token from the user (capture from luckysupermarkets.com network tab on a /search/api/v1 request)`. Fix: capture a fresh `Authorization: Bearer …` from devtools, update env, restart backend, re-trigger.
The Swiftly bearer JWT is auto-minted at request time via Firebase REST anon-signUp (`backend/app/services/swiftly_auth.py`, spec `docs/specs/2026-05-06-swiftly-token-auto-mint.md`). Process-local cache; ~99% cache-hit rate in steady state. A mint failure surfaces as `SwiftlyAuthMintError` and lands verbatim in `ScrapeLog.error_message`.
---
@@ -148,10 +145,10 @@ A `.env.test` template lives in the repo root (gitignored) for local stack runs.
- `docs/implementation-plan.md` — original phased plan.
- `docs/RUNNING.md` — local dev workflow.
- `docs/specs/2026-05-05-meal-planner-algorithm-design.md` — Phase 9 + thin Phase 4 design.
- `docs/specs/2026-05-06-swiftly-token-auto-mint.md`next-up redesign that eliminates the only operator-toil step.
- `docs/specs/2026-05-06-swiftly-token-auto-mint.md`Swiftly JWT auto-mint design (Implemented 2026-05-06).
- `.agent/plan.md`, `.agent/context.md`, `.agent/phase-summaries/` — recovery decisions and per-phase summaries from the R1+R2+R3-0 work.
- `Review/reviewconcensus.md` — the adversarial review that drove the recovery.
---
Last updated: 2026-05-06 — Phase 9 shipped (88/88 pytest green); Swiftly token auto-mint designed (`docs/specs/2026-05-06-swiftly-token-auto-mint.md`) and broken into AM-1..AM-6 in `docs/HANDOFF.md` for fresh-agent handoff.
Last updated: 2026-05-06 — Swiftly auto-mint shipped (AM-1..AM-6); 92/92 pytest green; live scrape verified end-to-end with no `SWIFTLY_BEARER_TOKEN` env var. Next pickup: Phase 5 weekly orchestration.
@@ -1,7 +1,7 @@
# Swiftly Token Auto-Mint — Design Spec
Date: 2026-05-06
Status: **Designed, not yet implemented.** The current scraper still reads a static `SWIFTLY_BEARER_TOKEN` from the env. This spec replaces that with an automatic, HTTP-only refresh path.
Status: **Implemented 2026-05-06.** Live-verified end-to-end: 260 products fetched from `Product/meat_seafood` on a fresh-mint scrape with `SWIFTLY_BEARER_TOKEN` removed from the env. Module: `backend/app/services/swiftly_auth.py`; wiring: `backend/app/scraper/lucky_ca_scraper.py`. Original design follows for context.
---