Skip to content

Development

Prerequisites

  • Python >= 3.12 and uv
  • task (go-task)
  • PostgreSQL at localhost:15432 — to run the service. The test suite needs no database; see Testing.

Environment Variables

Variable Default Description
DATABASE_URL postgresql+asyncpg://...host.docker.internal:15432/celine_webapp PostgreSQL async URL
DATABASE_ECHO false Log SQL statements
DIGITAL_TWIN_API_URL http://host.docker.internal:8002 Digital Twin service URL
NUDGING_API_URL http://host.docker.internal:8016 nudging-tool service URL
FLEXIBILITY_API_URL http://host.docker.internal:8017 flexibility-api service URL
REC_REGISTRY_URL http://host.docker.internal:8004 rec-registry service URL
ONBOARDING_API_URL http://host.docker.internal:8040 onboarding service URL — reached with the member's own token, for data sharing and for the terms gate's acceptances given at application
SMART_METER_API_URL — Optional smart meter API URL
NUDGING_INGEST_SCOPE nudging.ingest OAuth2 scope for nudging ingest calls
POLICY_VERSION 2024-01-01 Current terms version string
LEGAL_BASE_URL — The legal host. A community link the rec-registry leaves empty resolves to <LEGAL_BASE_URL>/<community>/<slot>/, and the terms gate asks for the community's own documents and versions (docs/features.md); unset, links come from the registry only and the gate uses POLICY_VERSION
JWT_HEADER_NAME x-auth-request-access-token Header carrying the bearer token
CORS_ORIGINS ["http://localhost:5173"] Allowed CORS origins
CELINE_OIDC_* (from celine-sdk defaults) OIDC settings — issuer, JWKS URI, audience
CELINE_ENV — (hardened) dev accepts the local defaults; see Deployment posture. ENVIRONMENT is read when it is empty

Deployment posture

The service follows celine.sdk.posture: only CELINE_ENV=dev relaxes. Unset, empty, staging, prod or a typo is hardened, and create_app() refuses to start — before the database is opened — while:

  • DATABASE_URL carries a local-stack password;
  • CELINE_OIDC_CLIENT_SECRET is empty or equal to CELINE_OIDC_CLIENT_ID (only checked when a client id is set; it is used for nudging reminders);
  • CELINE_OIDC_BASE_URL / CELINE_OIDC_JWKS_URI are left on the SDK's local Keycloak default.

In dev the same list is one startup warning. Outside dev /api/docs, /api/redoc and /api/openapi.json are not mounted (404) unless CELINE_PUBLIC_DOCS=true. task run exports CELINE_ENV=dev; CELINE_ENV=staging task run runs the same entry point hardened.

CELINE_OIDC_AUDIENCE is not required: with it unset the member token's audience is not checked (issuer, signature and expiry are). Requiring it needs the realm to put this service's audience into the member's token first.

celine.sdk.posture ships in celine-sdk 2.0.0, the floor in pyproject.toml.

Data sharing

Off by default. The section is hidden in the app and the routes answer 404 until the dataspace is deployed, because a sharing screen that cannot record a decision is worse than no screen.

Variable Default Description
DATA_SHARING_ENABLED false Master switch for the whole surface
DATA_SHARING_REVIEW_AFTER_DAYS 180 How long a decision stands before the member is asked to review it. 0 or less asks once and never again

The routes stay off unless DATA_SHARING_ENABLED is true and ONBOARDING_API_URL is set.

The dataspace is not reached from here. Onboarding resolves the member's credential, speaks to the connector and serves their history; this service forwards the member's own token to /api/me/data-sharing and passes the answer back. There is no identity-registry URL, no connector URL and no service account in this repository — a credential that never leaves the service that resolved it cannot leak from the one that did not need it.

Backend Setup

uv sync
uv run alembic upgrade head
task run
# Listens on http://localhost:8014

Taskfile Commands

Command Description
task setup uv sync
task test Run the test suite
task run Start dev server on port 8014
task debug Start with debugger
task migrate Apply all pending migrations
task migrate:create Create a new Alembic migration
task release Run semantic-release

Frontend

The participant frontend is in celine-frontend apps/webapp. See that repository for frontend setup instructions.

Calling the API locally

In local dev the BFF reads X-Auth-Request-Access-Token, the header oauth2-proxy injects in deployment. Authorization: Bearer <token> works as a fallback.

curl http://localhost:8014/api/me \
  -H "X-Auth-Request-Access-Token: <token>"

The token must be a real one from Keycloak. It is verified in full here — the signing key is resolved from the realm's JWKS by the token's kid, the signature is checked, and exp and iss are enforced. There is no unverified path and no development bypass, so a hand-assembled or alg: none token will be rejected.

A 401 accompanied by this log line is the usual result, and it means the token, not the network:

Failed to fetch signing key from …/protocol/openid-connect/certs:
  Unable to find a signing key that matches: "None"

CLI

celine-webapp-export-feedback   # Export user feedback data

Database Migrations

uv run alembic upgrade head
uv run alembic revision --autogenerate -m "add preference column"

Testing

task test              # the whole suite
task test -- -x -q     # arguments pass through to pytest

The suite needs nothing running — no PostgreSQL, no Keycloak, no upstream service. It runs on a clean checkout and in CI, and a test that needs a live service does not belong in it.

That is achieved at two seams, and both matter when you write a test:

  • Identity is real; only the JWKS fetch is faked. Tests sign genuine RS256 tokens with a throwaway key, and the service verifies them exactly as it does in production — signature, expiry, issuer. Nothing overrides the authentication dependency, so a test cannot accidentally prove that a token this service should reject is accepted.
  • The database is SQLite, one file per test. Production is PostgreSQL, so a schema change is not verified by a green run alone — exercise the migration separately.

What the suite does and does not tell you

This service is a backend-for-frontend: most of what it does is fan out to the Digital Twin, rec-registry, flexibility-api and nudging-tool through celine-sdk, then compose the results. Those four are replaced by fakes in tests/fakes.py, which reproduce the shape the SDK returned when they were written.

Onboarding, the fifth, is stubbed differently: celine.sdk.onboarding builds its own httpx.AsyncClient, so the mock_upstream_http fixture replaces the socket and lets the wrapper, the generated client and the schema conversion all run for real.

So a green suite says the composition logic is correct against that shape. tests/test_sdk_contract.py checks the fakes against the installed celine-sdk models, which catches drift in the package — but not that a deployed upstream serves what its own SDK describes. A celine-sdk version bump can still change this service's behaviour with no file here changing. Treat an SDK bump as a change to this service rather than as dependency maintenance.

Layout

File Covers
tests/test_auth_boundary.py what is accepted as an identity, and what is rejected
tests/test_overview_fanout.py /api/overview — aggregation, trend building, degradation
tests/test_gamification_fanout.py /api/gamification — season scoring and its fallback
tests/test_nudging_fanout.py /api/settings and /api/notifications
tests/test_sdk_contract.py that the fakes still match the installed celine-sdk models
tests/test_data_sharing.py the data-sharing surface, onboarding stubbed at the socket
tests/test_onboarding_dep.py that the onboarding client is built per request from the caller's token
tests/test_api.py, tests/test_forecast.py pure mapping and window functions
tests/test_feedback.py feedback filing and the manager check: realm role platform-admin vs the REC's own groups
tests/integration/test_real_tokens.py the same two levels with tokens a local Keycloak issued — opt-in, skipped by default
tests/fakes.py the four upstream fakes

If you add a field to a fake, assert it in test_sdk_contract.py in the same change. The fakes are written by hand from reading route code, so an invented attribute will reproduce whatever you expect of it — that is not a hypothetical, it produced a fully reproducible and entirely wrong bug report the day the fakes were written.

tests/integration/ is the one exception to "needs nothing running", and it is opt-in so the default run and CI stay hermetic. With the local stack's Keycloak up, it mints real oauth2_proxy tokens for the dev users and verifies them against the realm's real JWKS:

CELINE_WEBAPP_REAL_TOKENS=1 uv run pytest tests/integration

It refuses any issuer that is not *.localhost or loopback. The module docstring lists the users, aliases and environment variables it reads; the retired-realm-group case runs only when a token carrying one is passed in CELINE_WEBAPP_LEGACY_GROUP_TOKEN.

Tests marked xfail with strict=True are known defects, pinned deliberately, each with its reason in the marker. Fixing one turns the run red with XPASS(strict) — the signal to remove the marker in the same change. There are none at present.

Project Layout

src/celine/webapp/
  main.py                # FastAPI app factory
  settings.py            # Pydantic settings
  routes.py              # Router registration
  cli.py                 # CLI (celine-webapp-export-feedback)
  api/
    user.py              # /api/me, /api/terms/accept
    overview.py          # /api/overview
    weather.py           # /api/weather
    forecast.py          # /api/forecast
    community.py         # /api/community
    suggestions.py       # /api/suggestions, /api/commitments
    gamification.py      # /api/gamification
    co2_settings.py      # /api/settings/co2
    settings_routes.py   # /api/settings
    notifications.py     # /api/notifications, webpush
    feedback.py          # /api/feedback
    data_sharing.py      # /api/data-sharing
    meta.py              # /health
    deps.py              # FastAPI dependencies — every outbound client is resolved here
    schemas.py           # Pydantic schemas
  services/
    data_sharing.py      # Dataspace calls (identity registry, connector, provenance)
  db/
    models.py            # SQLAlchemy ORM models
    session.py           # Async session management
    user_settings.py     # User settings helpers
alembic/                 # Database migrations
tests/                   # See Testing above