Task 1 is the Unleash stack and ends with a human step — the Portainer deploy and the token generation cannot be automated from here. Nothing else blocks on it: an unset UNLEASH_URL means every flag is False, which is what local development and CI get, so the whole suite runs without a flag server existing. Self-review caught three defects in the plan itself. get_supplementary_data takes (db, urn), not (urn), and the test DataFrame was minimised to the point where the endpoint would have failed for reasons unrelated to flags — both now copy the known-good shape from test_school_details.py. The proxy test needs the node jest environment, since NextRequest wants Fetch API globals jsdom does not provide. And the e2e off-state check hardcoded a URN, so a 404 page would have satisfied it without proving anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
1107 lines
39 KiB
Markdown
1107 lines
39 KiB
Markdown
# Feature Flags (Unleash) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Let a merged feature reach production without becoming visible, and be switched on later from a UI.
|
||
|
||
**Architecture:** A self-hosted Unleash instance in its own Portainer stack holds flag state. FastAPI is the only service with an Unleash SDK; it declares every flag in a code registry, evaluates them fail-closed, and exposes them at an internal-only `/api/flags`. Next reads that endpoint server-side. No webhook — time-based ISR already propagates a flip within five minutes on school pages.
|
||
|
||
**Tech Stack:** Unleash OSS (Apache-2.0), `UnleashClient` (Python), FastAPI, Next.js 15 App Router, Playwright, pytest, Jest.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-08-23-feature-flags-design.md`
|
||
|
||
## Global Constraints
|
||
|
||
- **Every flag defaults to `False`.** There is no per-flag default field. A flag that defaults on is a kill switch, which this design does not offer.
|
||
- **One string per flag**, used unchanged as the registry key, the Unleash flag name, and the JSON key in `/api/flags`. snake_case, matching the API's existing convention. No case transformation anywhere.
|
||
- **Fail closed.** Unleash unreachable, unconfigured, or erroring ⇒ every flag is `False`. Never raise out of a flag evaluation.
|
||
- **`/api/flags` must never be publicly reachable.** It names unreleased features.
|
||
- **An API field that is off is absent, not `null`,** and withheld at the source. `/api/schools/` is public and unauthenticated (precedent: commit `c9a1892`).
|
||
- **Never flag content on a `force-static` page.** `app/admissions/page.tsx` never revalidates.
|
||
- **Reading flags in a route pins that route to a 300s ISR floor** — Next uses the lowest `revalidate` among a route's fetches.
|
||
- Backend tests run via: `uv run --quiet --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q`
|
||
- Never push to `main`. Branch, PR, let checks pass.
|
||
- Do not start a local server to test the application; it does not work.
|
||
|
||
## File Structure
|
||
|
||
| File | Responsibility |
|
||
|---|---|
|
||
| `docker-compose.portainer.unleash.yml` (new) | The Unleash stack: server + its own Postgres. Owned by neither app stack. |
|
||
| `backend/flags.py` (new) | The whole flag layer: registry, client lifecycle, `is_enabled`, `all_flags`. One module, ~90 lines. |
|
||
| `backend/config.py` (modify) | Four `unleash_*` settings. |
|
||
| `backend/app.py` (modify) | `/api/flags` endpoint; `flags.init()` in `lifespan`; the `admission_distance` gate. |
|
||
| `backend/tests/test_flags.py` (new) | Registry shape, fail-closed evaluation, staleness tripwire, endpoint. |
|
||
| `nextjs-app/lib/flags.ts` (new) | `getFlags()` — one server-side fetch, typed, never throws. |
|
||
| `nextjs-app/app/api/[...path]/route.ts` (modify) | Internal-only denylist so the public proxy will not forward `/api/flags`. |
|
||
| `nextjs-app/__tests__/lib/flags.test.ts` (new) | `getFlags` fallback behaviour. |
|
||
| `nextjs-app/__tests__/api/proxyDenylist.test.ts` (new) | The proxy 404s internal paths. |
|
||
| `e2e/tests/journeys.spec.ts` (modify) | Flag-aware journeys; the explicit-gate rule from spec §7. |
|
||
| `requirements.txt` (modify) | `UnleashClient`. |
|
||
| `docker-compose.yml`, `docker-compose.portainer.yml`, `docker-compose.portainer.staging.yml` (modify) | `UNLEASH_*` env for the backend service. |
|
||
|
||
---
|
||
|
||
### Task 1: The Unleash stack
|
||
|
||
**Files:**
|
||
- Create: `docker-compose.portainer.unleash.yml`
|
||
- Modify: `docs/DEPLOY.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing.
|
||
- Produces: a reachable Unleash server, and two client tokens — one scoped to the `development` environment, one to `production` — for Task 2's `UNLEASH_API_TOKEN`.
|
||
|
||
> **This task ends with a human step.** The stack must be deployed in Portainer and the tokens generated by hand; neither can be automated from here. Every later task works without it — flags simply evaluate `False` when `UNLEASH_URL` is unset, which is the correct local and CI behaviour.
|
||
|
||
- [ ] **Step 1: Write the stack file**
|
||
|
||
```yaml
|
||
# Portainer Stack Definition for School Compare — UNLEASH (feature flags)
|
||
#
|
||
# Deploy as a *separate* Portainer stack ("schoolcompare-unleash"), alongside
|
||
# the production and staging stacks. It deliberately belongs to neither: a
|
||
# staging redeploy must not be able to disturb production's flag state.
|
||
#
|
||
# One instance serves both environments. Open-source Unleash ships with
|
||
# `development` and `production` environments and environment-scoped client
|
||
# tokens, so the same flag holds independent state in each.
|
||
#
|
||
# Portainer environment variables (set in Portainer UI -> Stack -> Environment):
|
||
# UNLEASH_DB_PASSWORD — PostgreSQL password for the Unleash database
|
||
# UNLEASH_ADMIN_PASSWORD — initial admin password for the Unleash UI
|
||
# UNLEASH_IP — macvlan IP for the Unleash server (default 10.0.1.152)
|
||
|
||
services:
|
||
|
||
unleash_db:
|
||
container_name: sc_unleash_postgres
|
||
image: postgres:16-alpine
|
||
environment:
|
||
POSTGRES_USER: unleash
|
||
POSTGRES_PASSWORD: ${UNLEASH_DB_PASSWORD}
|
||
POSTGRES_DB: unleash
|
||
volumes:
|
||
- unleash_postgres_data:/var/lib/postgresql/data
|
||
networks:
|
||
- unleash
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "pg_isready -U unleash"]
|
||
interval: 10s
|
||
timeout: 5s
|
||
retries: 5
|
||
start_period: 10s
|
||
restart: unless-stopped
|
||
|
||
unleash:
|
||
container_name: sc_unleash
|
||
image: unleashorg/unleash-server:6
|
||
environment:
|
||
DATABASE_URL: postgres://unleash:${UNLEASH_DB_PASSWORD}@unleash_db:5432/unleash
|
||
DATABASE_SSL: "false"
|
||
INIT_ADMIN_API_TOKENS: ""
|
||
UNLEASH_DEFAULT_ADMIN_PASSWORD: ${UNLEASH_ADMIN_PASSWORD}
|
||
depends_on:
|
||
unleash_db:
|
||
condition: service_healthy
|
||
networks:
|
||
unleash: {}
|
||
macvlan:
|
||
ipv4_address: ${UNLEASH_IP:-10.0.1.152}
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "wget -qO- http://localhost:4242/health || exit 1"]
|
||
interval: 30s
|
||
timeout: 10s
|
||
retries: 3
|
||
start_period: 30s
|
||
restart: unless-stopped
|
||
|
||
networks:
|
||
unleash:
|
||
driver: bridge
|
||
macvlan:
|
||
external: true
|
||
name: macvlan
|
||
|
||
volumes:
|
||
unleash_postgres_data:
|
||
```
|
||
|
||
- [ ] **Step 2: Document the runbook**
|
||
|
||
Append to `docs/DEPLOY.md`:
|
||
|
||
```markdown
|
||
## Feature flags (Unleash)
|
||
|
||
Flag state lives in a self-hosted Unleash instance, deployed as its own
|
||
Portainer stack from `docker-compose.portainer.unleash.yml`. It is separate
|
||
from the application stacks on purpose — redeploying staging must not be able
|
||
to disturb production's flags.
|
||
|
||
### First-time setup
|
||
|
||
1. Deploy the stack in Portainer. Set `UNLEASH_DB_PASSWORD`,
|
||
`UNLEASH_ADMIN_PASSWORD` and (optionally) `UNLEASH_IP`.
|
||
2. Log in to the UI at `http://<UNLEASH_IP>:4242` as `admin`.
|
||
3. Create one **client** API token per environment:
|
||
- `schoolcompare-staging`, environment **development**
|
||
- `schoolcompare-prod`, environment **production**
|
||
Client tokens, not admin tokens — the backend only reads.
|
||
4. Put each token in the matching Portainer stack's `UNLEASH_API_TOKEN`
|
||
variable, and set `UNLEASH_URL` to `http://<UNLEASH_IP>:4242/api`.
|
||
5. Redeploy the application stacks.
|
||
|
||
### Turning a feature on
|
||
|
||
Flags are declared in `backend/flags.py` and appear in the Unleash UI on the
|
||
first evaluation after the backend starts. Toggle the flag in the environment
|
||
you want. A flip reaches school pages within about five minutes and place pages
|
||
within the hour — Next's ISR does the propagating, there is no webhook.
|
||
|
||
If `UNLEASH_URL` is unset, every flag evaluates to `False`. That is the correct
|
||
behaviour for local development and CI, and means nothing here is required to
|
||
run the tests.
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add docker-compose.portainer.unleash.yml docs/DEPLOY.md
|
||
git commit -m "feat(flags): add the Unleash stack and its runbook"
|
||
```
|
||
|
||
- [ ] **Step 4: Hand off the manual step**
|
||
|
||
Tell the human: the stack file is ready, and Steps 1–5 of the runbook are theirs to run. Note that the remaining tasks do not block on it.
|
||
|
||
---
|
||
|
||
### Task 2: The flag registry and client
|
||
|
||
**Files:**
|
||
- Create: `backend/flags.py`
|
||
- Create: `backend/tests/test_flags.py`
|
||
- Modify: `backend/config.py`
|
||
- Modify: `requirements.txt`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `settings` from `backend/config.py`.
|
||
- Produces:
|
||
- `backend.flags.Flag` — frozen dataclass with `name: str`, `description: str`, `added: date`
|
||
- `backend.flags.REGISTRY: dict[str, Flag]`
|
||
- `backend.flags.MAX_FLAG_AGE_DAYS: int` (= 90)
|
||
- `backend.flags.init() -> None`
|
||
- `backend.flags.is_enabled(name: str) -> bool`
|
||
- `backend.flags.all_flags() -> dict[str, bool]`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `backend/tests/test_flags.py`:
|
||
|
||
```python
|
||
"""Tests for the feature flag layer (spec 2026-08-23).
|
||
|
||
None of these need a running Unleash. That is the point: an unset UNLEASH_URL
|
||
means every flag is False, which is what local development and CI get.
|
||
"""
|
||
|
||
from datetime import date, timedelta
|
||
|
||
import pytest
|
||
|
||
from backend import flags
|
||
|
||
|
||
def test_every_declared_flag_is_keyed_by_its_own_name():
|
||
# One string is the registry key, the Unleash flag name and the JSON key.
|
||
# A mismatch here would mean the UI toggles a flag the code never reads.
|
||
for key, flag in flags.REGISTRY.items():
|
||
assert key == flag.name
|
||
|
||
|
||
def test_flag_names_are_snake_case():
|
||
# Matches the API's existing convention (admission_distance,
|
||
# rwm_expected_pct) so no case transformation exists to get wrong.
|
||
for name in flags.REGISTRY:
|
||
assert name == name.lower()
|
||
assert "-" not in name and " " not in name
|
||
|
||
|
||
def test_an_unconfigured_client_evaluates_every_flag_false(monkeypatch):
|
||
monkeypatch.setattr(flags, "_client", None)
|
||
for name in flags.REGISTRY:
|
||
assert flags.is_enabled(name) is False
|
||
|
||
|
||
def test_an_undeclared_flag_is_false_rather_than_an_error(monkeypatch):
|
||
# A typo'd flag name must not raise in a request path. It is logged as an
|
||
# error, because an undeclared flag is always a bug.
|
||
monkeypatch.setattr(flags, "_client", None)
|
||
assert flags.is_enabled("no_such_flag") is False
|
||
|
||
|
||
def test_an_exploding_client_is_false_rather_than_a_500(monkeypatch):
|
||
class Boom:
|
||
def is_enabled(self, *a, **kw):
|
||
raise RuntimeError("unleash is on fire")
|
||
|
||
monkeypatch.setattr(flags, "_client", Boom())
|
||
name = next(iter(flags.REGISTRY))
|
||
assert flags.is_enabled(name) is False
|
||
|
||
|
||
def test_all_flags_reports_every_declared_flag(monkeypatch):
|
||
monkeypatch.setattr(flags, "_client", None)
|
||
assert set(flags.all_flags()) == set(flags.REGISTRY)
|
||
assert all(v is False for v in flags.all_flags().values())
|
||
|
||
|
||
def test_a_flag_older_than_the_limit_fails_this_test():
|
||
"""A tripwire, not an assertion about correctness.
|
||
|
||
Flags are temporary scaffolding and the failure mode of every flag system
|
||
is accumulation. This fails on the day a flag turns 90, on whatever PR
|
||
happens to be open — which is the point: someone has to decide.
|
||
|
||
To fix: delete the flag and the branches that read it, or, if it genuinely
|
||
still needs to exist, move its `added` date and say why in the commit.
|
||
"""
|
||
stale = [
|
||
f.name for f in flags.REGISTRY.values()
|
||
if date.today() - f.added > timedelta(days=flags.MAX_FLAG_AGE_DAYS)
|
||
]
|
||
assert not stale, (
|
||
f"Flags older than {flags.MAX_FLAG_AGE_DAYS} days: {stale}. "
|
||
"Remove the flag and the code branches it guards, or move its `added` "
|
||
"date deliberately."
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 2: Run the tests to verify they fail**
|
||
|
||
Run: `uv run --quiet --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_flags.py -q`
|
||
|
||
Expected: FAIL — `ModuleNotFoundError: No module named 'backend.flags'`
|
||
|
||
- [ ] **Step 3: Add the dependency**
|
||
|
||
Append to `requirements.txt`:
|
||
|
||
```
|
||
UnleashClient==6.0.1
|
||
```
|
||
|
||
- [ ] **Step 4: Add the settings**
|
||
|
||
In `backend/config.py`, inside `class Settings`, after the Typesense block:
|
||
|
||
```python
|
||
# Feature flags (Unleash). An empty unleash_url disables flags entirely and
|
||
# every flag evaluates False — the correct behaviour for local development
|
||
# and CI, and the reason no test here needs a running Unleash.
|
||
unleash_url: str = ""
|
||
unleash_api_token: str = ""
|
||
unleash_app_name: str = "schoolcompare-backend"
|
||
# On a named volume, so a restart during an Unleash outage keeps
|
||
# last-known state instead of reverting a released feature to dark.
|
||
unleash_cache_directory: str = "/app/.unleash"
|
||
```
|
||
|
||
- [ ] **Step 5: Write the module**
|
||
|
||
Create `backend/flags.py`:
|
||
|
||
```python
|
||
"""Feature flags: what can be switched, and what is switched right now.
|
||
|
||
Ship-dark, not a kill switch. Flags let work merge and deploy without becoming
|
||
visible; they are expected to flip about monthly, by a person, deliberately.
|
||
Nothing here does percentage rollouts or user targeting — the site has no user
|
||
identity to target.
|
||
|
||
Unleash holds the state. It does not hold the list. REGISTRY below is that
|
||
list, and it exists for three reasons: the SDK evaluates an unknown flag to
|
||
False, so without a registry that is an *undeclared* False, indistinguishable
|
||
from a typo; /api/flags needs a key set to return when Unleash is unreachable;
|
||
and a flag in the UI but not in the registry is orphaned and should be visibly
|
||
so rather than quietly authoritative.
|
||
|
||
Every flag defaults to False. There is no per-flag default, because a flag that
|
||
defaults on is a kill switch, and this is not one.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from datetime import date
|
||
|
||
from .config import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# A flag is temporary scaffolding. See test_a_flag_older_than_the_limit.
|
||
MAX_FLAG_AGE_DAYS = 90
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Flag:
|
||
# One string: the registry key, the Unleash flag name, and the JSON key in
|
||
# /api/flags. snake_case, matching the API's existing convention. No case
|
||
# transformation anywhere, so there is no mapping layer to get wrong.
|
||
name: str
|
||
description: str # one line: what turning this on reveals
|
||
added: date # for the staleness tripwire
|
||
|
||
|
||
REGISTRY: dict[str, Flag] = {
|
||
f.name: f for f in (
|
||
Flag(
|
||
name="admission_distance",
|
||
description=(
|
||
"The last-distance-offered figure on the Admissions tile and "
|
||
"the 'How far away are you?' section on school pages."
|
||
),
|
||
added=date(2026, 8, 23),
|
||
),
|
||
)
|
||
}
|
||
|
||
|
||
_client = None
|
||
|
||
|
||
def init() -> None:
|
||
"""Start the Unleash client, or log why flags are all off.
|
||
|
||
Called once from the app lifespan. Never raises: a flag system that can
|
||
stop the API from booting is worse than one that is switched off.
|
||
"""
|
||
global _client
|
||
if not settings.unleash_url or not settings.unleash_api_token:
|
||
logger.warning(
|
||
"Unleash is not configured (UNLEASH_URL / UNLEASH_API_TOKEN); "
|
||
"every feature flag evaluates to False.")
|
||
return
|
||
|
||
try:
|
||
from UnleashClient import UnleashClient
|
||
|
||
_client = UnleashClient(
|
||
url=settings.unleash_url,
|
||
app_name=settings.unleash_app_name,
|
||
custom_headers={"Authorization": settings.unleash_api_token},
|
||
cache_directory=settings.unleash_cache_directory,
|
||
refresh_interval=15,
|
||
)
|
||
_client.initialize_client()
|
||
logger.info("Unleash client initialised against %s", settings.unleash_url)
|
||
except Exception:
|
||
# Fail closed and keep serving. The SDK also evaluates everything False
|
||
# until its first successful sync, so this is the same direction.
|
||
_client = None
|
||
logger.exception("Unleash client failed to start; flags are all False.")
|
||
|
||
|
||
def is_enabled(name: str) -> bool:
|
||
"""Whether `name` is on. False for anything unknown, unreachable or broken."""
|
||
if name not in REGISTRY:
|
||
logger.error(
|
||
"undeclared feature flag %r was evaluated; returning False. "
|
||
"Add it to backend/flags.py REGISTRY or fix the name.", name)
|
||
return False
|
||
if _client is None:
|
||
return False
|
||
try:
|
||
return bool(_client.is_enabled(
|
||
name, fallback_function=lambda feature_name, context: False))
|
||
except Exception:
|
||
logger.exception("flag %r failed to evaluate; returning False", name)
|
||
return False
|
||
|
||
|
||
def all_flags() -> dict[str, bool]:
|
||
"""Every declared flag and its current value. Serves /api/flags."""
|
||
return {name: is_enabled(name) for name in REGISTRY}
|
||
```
|
||
|
||
- [ ] **Step 6: Run the tests to verify they pass**
|
||
|
||
Run: `uv run --quiet --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_flags.py -q`
|
||
|
||
Expected: PASS, 7 tests.
|
||
|
||
- [ ] **Step 7: Run the whole backend suite**
|
||
|
||
Run: `uv run --quiet --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q`
|
||
|
||
Expected: all pass. Nothing else imports `flags` yet.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add backend/flags.py backend/tests/test_flags.py backend/config.py requirements.txt
|
||
git commit -m "feat(flags): the registry and a fail-closed Unleash client"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: `/api/flags`, and keeping it off the public internet
|
||
|
||
**Files:**
|
||
- Modify: `backend/app.py`
|
||
- Modify: `nextjs-app/app/api/[...path]/route.ts`
|
||
- Modify: `backend/tests/test_flags.py`
|
||
- Create: `nextjs-app/__tests__/api/proxyDenylist.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `backend.flags.init`, `backend.flags.all_flags`.
|
||
- Produces: `GET /api/flags` → `{"<flag_name>": bool, ...}`, reachable only from inside the Docker network.
|
||
|
||
> The endpoint and its exposure control ship together on purpose. The moment
|
||
> `/api/flags` exists, the public proxy at `app/api/[...path]/route.ts` will
|
||
> forward it, publishing the name and state of every unreleased feature.
|
||
|
||
- [ ] **Step 1: Write the failing backend test**
|
||
|
||
Append to `backend/tests/test_flags.py`:
|
||
|
||
```python
|
||
def _client():
|
||
from fastapi.testclient import TestClient
|
||
from backend import app as app_module
|
||
return TestClient(app_module.app, raise_server_exceptions=False)
|
||
|
||
|
||
def test_the_flags_endpoint_lists_every_declared_flag(monkeypatch):
|
||
monkeypatch.setattr(flags, "_client", None)
|
||
body = _client().get("/api/flags").json()
|
||
assert set(body) == set(flags.REGISTRY)
|
||
|
||
|
||
def test_the_flags_endpoint_answers_false_when_unleash_is_unreachable(monkeypatch):
|
||
# The endpoint must still answer. A frontend that cannot read flags renders
|
||
# everything dark, which is right; one that gets a 500 renders nothing.
|
||
monkeypatch.setattr(flags, "_client", None)
|
||
res = _client().get("/api/flags")
|
||
assert res.status_code == 200
|
||
assert all(v is False for v in res.json().values())
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to verify it fails**
|
||
|
||
Run: `uv run --quiet --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_flags.py -q`
|
||
|
||
Expected: FAIL — the endpoint 404s, so `set(body)` is `{"detail"}`.
|
||
|
||
- [ ] **Step 3: Add the import and the lifespan call**
|
||
|
||
In `backend/app.py`, add to the existing relative imports (after `from .data_loader import get_data_info as get_db_info`):
|
||
|
||
```python
|
||
from . import flags
|
||
```
|
||
|
||
Inside `async def lifespan(app: FastAPI):`, immediately after the `print("Loading school data from marts...")` line:
|
||
|
||
```python
|
||
flags.init()
|
||
```
|
||
|
||
- [ ] **Step 4: Add the endpoint**
|
||
|
||
In `backend/app.py`, directly above `@app.get("/api/data-info")`:
|
||
|
||
```python
|
||
@app.get("/api/flags")
|
||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||
async def get_feature_flags(request: Request):
|
||
"""Every declared flag and its current value.
|
||
|
||
Internal only. The Next proxy denies this path, because the response names
|
||
every unreleased feature the codebase knows about — which is exactly what
|
||
shipping dark is meant to keep quiet.
|
||
"""
|
||
return flags.all_flags()
|
||
```
|
||
|
||
- [ ] **Step 5: Run the backend tests to verify they pass**
|
||
|
||
Run: `uv run --quiet --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q`
|
||
|
||
Expected: all pass.
|
||
|
||
- [ ] **Step 6: Write the failing proxy test**
|
||
|
||
Create `nextjs-app/__tests__/api/proxyDenylist.test.ts`:
|
||
|
||
```typescript
|
||
/**
|
||
* The /api/* proxy is public. Anything it forwards is on the internet.
|
||
*
|
||
* @jest-environment node
|
||
*/
|
||
// The docblock above is load-bearing. jest.config.js sets jsdom globally, and
|
||
// NextRequest/NextResponse need the Web Fetch API globals that only the node
|
||
// environment provides — under jsdom this suite fails on import, not on an
|
||
// assertion.
|
||
import { NextRequest } from 'next/server';
|
||
import { GET } from '@/app/api/[...path]/route';
|
||
|
||
function request(path: string) {
|
||
return new NextRequest(`http://localhost:3000/api/${path}`);
|
||
}
|
||
|
||
describe('public API proxy', () => {
|
||
it('refuses to forward internal-only paths', async () => {
|
||
// /api/flags names every unreleased feature and its state. Forwarding it
|
||
// publishes the thing shipping dark exists to keep quiet.
|
||
const res = await GET(request('flags'), { params: Promise.resolve({ path: ['flags'] }) });
|
||
expect(res.status).toBe(404);
|
||
});
|
||
|
||
it('does not deny a path that merely starts with the same letters', async () => {
|
||
// A prefix match would take /api/flagship down with /api/flags.
|
||
const res = await GET(
|
||
request('flagship'), { params: Promise.resolve({ path: ['flagship'] }) });
|
||
expect(res.status).not.toBe(404);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 7: Run it to verify it fails**
|
||
|
||
Run: `cd nextjs-app && npx jest __tests__/api/proxyDenylist.test.ts`
|
||
|
||
Expected: FAIL — the first case returns 502 (no backend running in the test env), not 404.
|
||
|
||
- [ ] **Step 8: Add the denylist**
|
||
|
||
In `nextjs-app/app/api/[...path]/route.ts`, after the `STRIPPED_RESPONSE_HEADERS` constant:
|
||
|
||
```typescript
|
||
/*
|
||
* API paths this public proxy must not forward.
|
||
*
|
||
* Matched on the first segment, exactly — a prefix match would take
|
||
* /api/flagship down with /api/flags.
|
||
*
|
||
* `flags` is here because GET /api/flags names every unreleased feature the
|
||
* codebase knows about, along with whether it is on. Publishing that defeats
|
||
* the point of shipping dark. Next reads it server-side via FASTAPI_URL, on
|
||
* the Docker network, which never transits this route.
|
||
*
|
||
* Anything else internal-only belongs here too.
|
||
*/
|
||
const INTERNAL_ONLY_SEGMENTS = new Set(['flags']);
|
||
```
|
||
|
||
And at the top of `handler`, immediately after `const { path } = await ctx.params;`:
|
||
|
||
```typescript
|
||
if (INTERNAL_ONLY_SEGMENTS.has(path[0])) {
|
||
return NextResponse.json({ detail: 'Not Found' }, { status: 404 });
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 9: Run the frontend tests**
|
||
|
||
Run: `cd nextjs-app && npx jest && npx tsc --noEmit`
|
||
|
||
Expected: all pass, typecheck clean.
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add backend/app.py backend/tests/test_flags.py 'nextjs-app/app/api/[...path]/route.ts' nextjs-app/__tests__/api/proxyDenylist.test.ts
|
||
git commit -m "feat(flags): serve /api/flags, and keep the public proxy off it"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Reading flags from Next
|
||
|
||
**Files:**
|
||
- Create: `nextjs-app/lib/flags.ts`
|
||
- Create: `nextjs-app/__tests__/lib/flags.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `GET /api/flags`.
|
||
- Produces: `getFlags(): Promise<Flags>` where `type Flags = Record<string, boolean>`, and `FLAGS_REVALIDATE = 300`.
|
||
|
||
> This ships with no consumer. The first flag needs none — the backend
|
||
> withholds the field and the frontend follows (Task 5). It is built anyway
|
||
> because "UI elements on existing pages" is one of the three surfaces this
|
||
> capability is for, and a flag layer that cannot gate a UI element is
|
||
> incomplete. Keep it minimal; do not add helpers nothing calls.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `nextjs-app/__tests__/lib/flags.test.ts`:
|
||
|
||
```typescript
|
||
import { getFlags } from '@/lib/flags';
|
||
|
||
describe('getFlags', () => {
|
||
afterEach(() => { jest.restoreAllMocks(); });
|
||
|
||
it('returns the flags the API reports', async () => {
|
||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ admission_distance: true }),
|
||
} as Response);
|
||
await expect(getFlags()).resolves.toEqual({ admission_distance: true });
|
||
});
|
||
|
||
it('returns no flags rather than throwing when the API is down', async () => {
|
||
// A page that cannot read flags must render everything dark, not 500.
|
||
// Fail-closed is the same direction as the backend's default.
|
||
jest.spyOn(global, 'fetch').mockRejectedValue(new Error('ECONNREFUSED'));
|
||
await expect(getFlags()).resolves.toEqual({});
|
||
});
|
||
|
||
it('returns no flags rather than throwing on a non-200', async () => {
|
||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||
ok: false, status: 503,
|
||
} as Response);
|
||
await expect(getFlags()).resolves.toEqual({});
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to verify it fails**
|
||
|
||
Run: `cd nextjs-app && npx jest __tests__/lib/flags.test.ts`
|
||
|
||
Expected: FAIL — `Cannot find module '@/lib/flags'`.
|
||
|
||
- [ ] **Step 3: Write the module**
|
||
|
||
Create `nextjs-app/lib/flags.ts`:
|
||
|
||
```typescript
|
||
/**
|
||
* Reading feature flags.
|
||
*
|
||
* Server-side only. No flag value reaches the browser bundle, and there is no
|
||
* Unleash dependency in package.json — the SDK lives in FastAPI, which already
|
||
* owns every other piece of data this app renders.
|
||
*
|
||
* Flags are declared in backend/flags.py. A purely front-end flag still has to
|
||
* be declared there; it is a flat data edit, and the return is that one list
|
||
* answers "what flags exist" for the whole system.
|
||
*/
|
||
|
||
export type Flags = Record<string, boolean>;
|
||
|
||
/*
|
||
* Reading flags pins the calling route to this ISR floor: Next uses the LOWEST
|
||
* revalidate among a route's fetches to set the whole route's revalidation
|
||
* frequency. 300s matches what /school/[slug] already sits at, so a page that
|
||
* reads flags is no more dynamic than a school page already is.
|
||
*
|
||
* It is also what makes a flip propagate without a webhook: five minutes on
|
||
* school pages, an hour on place pages, against flags that flip monthly.
|
||
*/
|
||
export const FLAGS_REVALIDATE = 300;
|
||
|
||
const API = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL
|
||
|| 'http://localhost:8000/api';
|
||
|
||
/** Every flag and its value. Never throws: an unreadable flag is a dark one. */
|
||
export async function getFlags(): Promise<Flags> {
|
||
try {
|
||
const res = await fetch(`${API}/flags`, {
|
||
next: { revalidate: FLAGS_REVALIDATE },
|
||
});
|
||
if (!res.ok) return {};
|
||
return await res.json();
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests to verify they pass**
|
||
|
||
Run: `cd nextjs-app && npx jest __tests__/lib/flags.test.ts && npx tsc --noEmit`
|
||
|
||
Expected: PASS, 3 tests, typecheck clean.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add nextjs-app/lib/flags.ts nextjs-app/__tests__/lib/flags.test.ts
|
||
git commit -m "feat(flags): server-side getFlags for the frontend"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Put `admission_distance` behind the flag
|
||
|
||
**Files:**
|
||
- Modify: `backend/app.py:809`
|
||
- Modify: `backend/tests/test_flags.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `backend.flags.is_enabled`.
|
||
- Produces: `/api/schools/{urn}` omits `admission_distance` unless the flag is on.
|
||
|
||
> One gate, at the source. The frontend needs no change: `DistanceSection`
|
||
> already returns `null` when `admissionDistance?.distance_m == null`, and
|
||
> `PrimarySchoolSections`/`SecondarySchoolSections` already condition the
|
||
> admissions block on `(admissions || admissionDistance)`. Only 57 local
|
||
> authorities publish cut-offs, so the off-path is the commonest path on the
|
||
> site and is well covered already.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Append to `backend/tests/test_flags.py`:
|
||
|
||
```python
|
||
def _school_payload(monkeypatch, *, flag_on: bool):
|
||
"""Fetch one school's payload with the distance flag forced on or off.
|
||
|
||
The DataFrame shape is copied from test_school_details.py rather than
|
||
minimised: the endpoint reads a wide set of GIAS columns, and a trimmed
|
||
frame fails for reasons that have nothing to do with flags.
|
||
"""
|
||
import numpy as np
|
||
import pandas as pd
|
||
from fastapi.testclient import TestClient
|
||
from backend import app as app_module
|
||
|
||
df = pd.DataFrame([{
|
||
"urn": 150275,
|
||
"school_name": "West London Performing Arts Academy",
|
||
"phase": "Secondary",
|
||
"school_type": "Special post 16 institution",
|
||
"trust_name": None,
|
||
"religious_denomination": "Does not apply",
|
||
"gender": None,
|
||
"age_range": "16-25",
|
||
"admissions_policy": None,
|
||
"capacity": np.nan,
|
||
"gias_total_pupils": np.nan,
|
||
"headteacher_name": None,
|
||
"website": None,
|
||
"ofsted_grade": np.nan,
|
||
"local_authority": "Ealing",
|
||
"address": "268 Northfield Avenue, London, W5 4UB",
|
||
"postcode": "W5 4UB",
|
||
"latitude": 51.4986,
|
||
"longitude": -0.3148,
|
||
"year": np.nan,
|
||
"total_pupils": np.nan,
|
||
"eligible_pupils": np.nan,
|
||
"rwm_expected_pct": np.nan,
|
||
}])
|
||
|
||
monkeypatch.setattr(app_module, "load_school_data", lambda: df)
|
||
# Two arguments: get_supplementary_data(db, urn). See backend/app.py:763.
|
||
monkeypatch.setattr(
|
||
app_module, "get_supplementary_data",
|
||
lambda db, urn: {"admission_distance": {"distance_m": 772.49,
|
||
"year": 2024}})
|
||
monkeypatch.setattr(flags, "is_enabled", lambda name: flag_on)
|
||
|
||
client = TestClient(app_module.app, raise_server_exceptions=False)
|
||
res = client.get("/api/schools/150275")
|
||
assert res.status_code == 200, res.text
|
||
return res.json()
|
||
|
||
|
||
def test_the_distance_field_is_absent_when_the_flag_is_off(monkeypatch):
|
||
"""Absent, not null, and withheld at the source.
|
||
|
||
/api/schools/ is public and unauthenticated. Leaving a withheld field in
|
||
the payload while declining to render it hands the record to anyone who
|
||
opens the network tab — the reasoning already recorded in c9a1892.
|
||
"""
|
||
body = _school_payload(monkeypatch, flag_on=False)
|
||
assert "admission_distance" not in body
|
||
|
||
|
||
def test_the_distance_field_is_present_when_the_flag_is_on(monkeypatch):
|
||
body = _school_payload(monkeypatch, flag_on=True)
|
||
assert body["admission_distance"]["distance_m"] == 772.49
|
||
```
|
||
|
||
- [ ] **Step 2: Run them to verify they fail**
|
||
|
||
Run: `uv run --quiet --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_flags.py -q`
|
||
|
||
Expected: FAIL on `test_the_distance_field_is_absent_when_the_flag_is_off` — the key is present with value `None`.
|
||
|
||
- [ ] **Step 3: Gate the field**
|
||
|
||
In `backend/app.py`, replace line 809:
|
||
|
||
```python
|
||
"admission_distance": supplementary.get("admission_distance"),
|
||
```
|
||
|
||
with:
|
||
|
||
```python
|
||
# Behind a flag, and withheld at the source rather than rendered-but-
|
||
# hidden: this endpoint is public and unauthenticated, so a field left
|
||
# in the payload is a published field. The key is absent, not null —
|
||
# null would state that this school has no cut-off, which is a
|
||
# different claim from "we are not publishing cut-offs".
|
||
**({"admission_distance": supplementary.get("admission_distance")}
|
||
if flags.is_enabled("admission_distance") else {}),
|
||
```
|
||
|
||
- [ ] **Step 4: Run the backend suite**
|
||
|
||
Run: `uv run --quiet --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q`
|
||
|
||
Expected: all pass.
|
||
|
||
- [ ] **Step 5: Check the frontend type tolerates an absent key**
|
||
|
||
Run: `cd nextjs-app && npx tsc --noEmit`
|
||
|
||
`lib/types.ts:364` declares `admission_distance: SchoolAdmissionDistance | null`. An absent key is `undefined`, which that type does not admit. Change it to:
|
||
|
||
```typescript
|
||
/**
|
||
* Absent — not null — when the admission_distance flag is off. Null means
|
||
* "this school has no published cut-off"; absent means "cut-offs are not
|
||
* being published at all". They are different claims and the type says so.
|
||
*/
|
||
admission_distance?: SchoolAdmissionDistance | null;
|
||
```
|
||
|
||
`app/school/[slug]/page.tsx` already reads it as `admission_distance ?? null`, so no call site changes.
|
||
|
||
- [ ] **Step 6: Run the frontend suite**
|
||
|
||
Run: `cd nextjs-app && npx jest && npx tsc --noEmit && npm run build`
|
||
|
||
Expected: all pass, build green.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add backend/app.py backend/tests/test_flags.py nextjs-app/lib/types.ts
|
||
git commit -m "feat(flags): ship last-distance-offered dark behind a flag"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Flag-aware E2E journeys
|
||
|
||
**Files:**
|
||
- Modify: `e2e/tests/journeys.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `GET /api/flags` (reachable from Playwright, which runs against the frontend's own origin — note that the *proxy* denies it, so these read it through the same denial and must handle that; see Step 1).
|
||
|
||
- [ ] **Step 1: Write the journeys**
|
||
|
||
The existing distance journeys at `e2e/tests/journeys.spec.ts:1214–1310` use
|
||
`schoolWithCutoff()`, which returns `null` and skips when no school has a
|
||
figure. With the flag off they would skip silently and the suite would go
|
||
green — a flag accidentally off in staging would look like a pass.
|
||
|
||
`/api/flags` is denied by the public proxy, so Playwright cannot read it
|
||
directly. The flag's observable effect is what the tests assert on instead:
|
||
whether the field appears in a school payload at all.
|
||
|
||
Insert directly above the `schoolWithCutoff` helper:
|
||
|
||
```typescript
|
||
/**
|
||
* Whether the last-distance-offered feature is switched on here.
|
||
*
|
||
* Read from the data rather than from /api/flags, which the public proxy
|
||
* denies on purpose — the endpoint names unreleased features. The observable
|
||
* effect is the field's presence: the flag is off iff no candidate school
|
||
* carries an `admission_distance` key at all.
|
||
*
|
||
* The distinction that matters: `admission_distance: null` means this school
|
||
* has no published cut-off, and the key being ABSENT means cut-offs are not
|
||
* being published at all.
|
||
*/
|
||
async function distanceFeatureIsOn(page: Page): Promise<boolean> {
|
||
for (const urn of CUTOFF_CANDIDATE_URNS) {
|
||
const res = await page.request.get(`/api/schools/${urn}`);
|
||
if (!res.ok()) continue;
|
||
if ('admission_distance' in (await res.json())) return true;
|
||
}
|
||
return false;
|
||
}
|
||
```
|
||
|
||
Then add, directly after the `schoolWithCutoff` helper:
|
||
|
||
```typescript
|
||
test('when the distance feature is on, a school with a cut-off is findable', async ({ page }) => {
|
||
/*
|
||
* The gate that stops the other distance journeys passing vacuously.
|
||
*
|
||
* They all skip when schoolWithCutoff() finds nothing, which is right when
|
||
* the feature is off — but it means a feature that is *supposed* to be on
|
||
* and is silently broken shows up as a green run full of skips. This test
|
||
* fails in that case.
|
||
*/
|
||
test.skip(!(await distanceFeatureIsOn(page)),
|
||
'the admission_distance flag is off in this environment');
|
||
|
||
expect(await schoolWithCutoff(page),
|
||
'the distance feature is on, but no candidate school has a cut-off — '
|
||
+ 'the flag is on and the data or the query behind it is broken')
|
||
.not.toBeNull();
|
||
});
|
||
|
||
test('with the distance feature off, the section is absent rather than empty', async ({ page }) => {
|
||
// Shipping dark means the page renders as it did before the feature existed,
|
||
// not as a feature with its content removed.
|
||
test.skip(await distanceFeatureIsOn(page),
|
||
'the admission_distance flag is on in this environment');
|
||
|
||
// A school that exists, found rather than hardcoded — a 404 page would
|
||
// satisfy the absent-heading assertion without proving anything.
|
||
//
|
||
// A plain loop, not Array.find: find's predicate is synchronous, so an async
|
||
// one returns a Promise, every Promise is truthy, and it would always hand
|
||
// back the first URN whether or not that school exists.
|
||
let urn: number | null = null;
|
||
for (const candidate of CUTOFF_CANDIDATE_URNS) {
|
||
if ((await page.request.get(`/api/schools/${candidate}`)).ok()) {
|
||
urn = candidate;
|
||
break;
|
||
}
|
||
}
|
||
expect(urn, 'no candidate school resolves in this environment').not.toBeNull();
|
||
|
||
await page.goto(`/school/${urn}`);
|
||
await expect(page.locator('h1')).toBeVisible();
|
||
|
||
await expect(page.getByRole('heading', { name: /How far away are you\?/ }))
|
||
.toHaveCount(0);
|
||
});
|
||
|
||
test('/api/flags is not reachable from the public internet', async ({ page }) => {
|
||
// It names every unreleased feature and whether it is on. Next reads it
|
||
// server-side over the Docker network; the public proxy must deny it.
|
||
const res = await page.request.get('/api/flags');
|
||
expect(res.status()).toBe(404);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the spec compiles and the tests are collected**
|
||
|
||
Run: `cd e2e && npx playwright test --list`
|
||
|
||
Expected: the three new titles appear, total count rises by 3.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add e2e/tests/journeys.spec.ts
|
||
git commit -m "test(e2e): make the distance journeys fail loudly, not skip quietly"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Wire the environment variables
|
||
|
||
**Files:**
|
||
- Modify: `docker-compose.yml`
|
||
- Modify: `docker-compose.portainer.yml`
|
||
- Modify: `docker-compose.portainer.staging.yml`
|
||
|
||
**Interfaces:**
|
||
- Consumes: the tokens from Task 1.
|
||
- Produces: a backend that can reach Unleash in each environment.
|
||
|
||
- [ ] **Step 1: Add the variables to all three stacks**
|
||
|
||
In each file, in the **backend** service's `environment` block, after the
|
||
`TYPESENSE_API_KEY` line, add:
|
||
|
||
```yaml
|
||
UNLEASH_URL: ${UNLEASH_URL:-}
|
||
UNLEASH_API_TOKEN: ${UNLEASH_API_TOKEN:-}
|
||
```
|
||
|
||
Unset by default on purpose: an environment without them has every flag off,
|
||
which is the correct dark state rather than a failure.
|
||
|
||
- [ ] **Step 2: Add the cache volume to the two Portainer stacks**
|
||
|
||
The SDK's disk cache must survive a container restart, or a restart during an
|
||
Unleash outage reverts a released feature to dark.
|
||
|
||
In `docker-compose.portainer.yml` and `docker-compose.portainer.staging.yml`,
|
||
add to the backend service:
|
||
|
||
```yaml
|
||
volumes:
|
||
- unleash_cache:/app/.unleash
|
||
```
|
||
|
||
(If the backend service already has a `volumes:` block, add the one line to it
|
||
rather than a second block.)
|
||
|
||
And to each file's top-level `volumes:` block:
|
||
|
||
```yaml
|
||
unleash_cache:
|
||
```
|
||
|
||
- [ ] **Step 3: Document the Portainer variables**
|
||
|
||
In `docker-compose.portainer.staging.yml`, add to the header comment list:
|
||
|
||
```
|
||
# UNLEASH_URL — http://<unleash-ip>:4242/api (empty = all flags off)
|
||
# UNLEASH_API_TOKEN — Unleash *client* token, environment: development
|
||
```
|
||
|
||
And the same in `docker-compose.portainer.yml` with `environment: production`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add docker-compose.yml docker-compose.portainer.yml docker-compose.portainer.staging.yml
|
||
git commit -m "chore(flags): wire UNLEASH_URL and the SDK cache volume into the stacks"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Open the PR
|
||
|
||
- [ ] **Step 1: Run everything**
|
||
|
||
```bash
|
||
uv run --quiet --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q
|
||
cd nextjs-app && npx jest && npx tsc --noEmit && npm run build
|
||
cd ../e2e && npx playwright test --list
|
||
```
|
||
|
||
Expected: backend green, frontend green, typecheck clean, build green, e2e collects.
|
||
|
||
- [ ] **Step 2: Open the PR against `main`**
|
||
|
||
Body must state: the flag defaults off, so merging changes nothing visible in
|
||
any environment until someone toggles it in Unleash; and that Task 1's manual
|
||
Portainer steps are a prerequisite for the flag to be togglable at all.
|
||
|
||
## Notes for the executor
|
||
|
||
**Nothing here needs a running Unleash.** Every test passes with `UNLEASH_URL`
|
||
unset, which is also what CI gets. Task 1's manual steps only decide whether
|
||
the flag can be *turned on*, not whether the code works.
|
||
|
||
**The staleness test will fail on 2026-11-21** if `admission_distance` is still
|
||
declared. That is intended — see the docstring. It fails on whatever PR is open
|
||
at the time, which is the mechanism, not a bug in it.
|
||
|
||
**Do not add a webhook.** An earlier draft of the spec had one; §4 records why
|
||
it was removed and why time-based revalidation is enough.
|