1106 lines
39 KiB
Markdown
1106 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.
|