Compare commits

..

3 Commits

Author SHA1 Message Date
Lars Nolden f4b5926e1f finish local dev setup
check / flake-check (push) Successful in 35s
deploy / deploy (push) Successful in 39s
2026-07-10 15:59:03 +02:00
Lars Nolden 1047968946 add requirements file 2026-07-09 16:07:16 +02:00
Lars Nolden 2ce68cb098 Wire schema v1 into a manual Supabase migration and vendor the real stack
deploy / deploy (push) Successful in 5m57s
check / flake-check (push) Failing after 35m52s
- Move db/schema.sql to supabase/migrations/ as the first supabase CLI
  migration (manual `db push` only, no automated runner); re-add the
  gebos_ingest role + grants there since init.sql never re-runs
- Add gebos-postgres-passwords oneshot on db-host: syncs role passwords
  from sops (LoadCredential, journal-safe), makes supabase_admin
  SUPERUSER and hands the auth schema to supabase_auth_admin to match
  the upstream supabase/postgres image; add pg_hba rule for 10.0.0.0/8
- Vendor the official docker-compose (studio/kong/auth/rest/meta only,
  external Postgres, loopback Studio with no Kong dashboard route) plus
  kong.yml (trimmed) and kong-entrypoint.sh (verbatim); tested: compose
  config, Kong config parse, migration applied on TimescaleDB pg17
- Document decisions as ADRs 0001-0004 (migrations, passwords, vendored
  stack, JWT API keys incl. verify/mint procedure)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 22:33:50 +02:00
22 changed files with 1789 additions and 129 deletions
@@ -0,0 +1,51 @@
# ADR-0001: Database schema changes are manual Supabase CLI migrations
Date: 2026-07-08
Status: Accepted
## Context
Schema v1 (`db/schema.sql`, ~600 lines) was committed but wired to nothing.
The only SQL that ran automatically was `nix/supabase/init.sql` via
`services.postgresql.initialScript` — which executes **only at first initdb**,
so on the already-initialized db-host cluster neither it nor any schema change
would ever apply again. A delivery mechanism was needed.
Options considered:
1. **Automated runner** — a systemd oneshot on db-host applying migrations on
every deploy (dbmate/atlas or plain psql with a tracking table).
2. **Manual Supabase CLI migrations** — plain-SQL files in
`supabase/migrations/`, applied by a human with `supabase db push`.
3. **Extending initialScript** — rejected outright: never re-runs on an
existing cluster.
## Decision
Option 2. Schema changes are rare, deliberate, admin-level operations; a
human applies them on purpose, and no automation exists to break or to apply
a half-reviewed migration as a side effect of a deploy.
* Migrations live in `supabase/migrations/<timestamp>_<name>.sql`; schema v1
moved there as the first one. `supabase/config.toml` is the minimal CLI
project marker.
* Applied via SSH tunnel to db-host as the `postgres` superuser
(see `supabase/README.md` for the exact commands). The CLI records applied
files in `supabase_migrations.schema_migrations`, so pushes are incremental
and re-running is safe. Individual migrations need **not** be idempotent.
* Layering rule: `nix/supabase/init.sql` holds only cluster bootstrap
(Supabase schemas, admin/API roles, extensions needing
`shared_preload_libraries`); everything else — including the `gebos_ingest`
role and its grants, which are coupled to application tables and must reach
existing clusters — lives in migrations. Passwords live in neither
(see ADR-0002).
## Consequences
* Deploys (`deploy-rs`) never touch the schema; a deploy and a migration are
two separate, independently reversible acts.
* A human must remember to push after merging a migration. Accepted for a
pilot with one operator.
* The local `nix run .#dev` Postgres has no TimescaleDB yet, so the v1
migration's `create_hypertable` fails there until the dev stack gains the
extension.
@@ -0,0 +1,61 @@
# ADR-0002: Postgres role passwords are synced by a one-shot systemd unit
Date: 2026-07-08
Status: Accepted
## Context
The Supabase services on app-host and the ingester on mqtt-ingest connect to
db-host over TCP and need password auth, but every login role
(`supabase_admin`, `supabase_auth_admin`, `authenticator`, `gebos_ingest`)
was created passwordless.
NixOS deliberately offers **no declarative option** for Postgres passwords:
anything the configuration references lands world-readable in `/nix/store`,
and `initialScript` — the only built-in hook that could set one — runs solely
at first initdb. Passwords are data, not configuration.
Two supporting gaps surfaced at the same time:
* NixOS's default `pg_hba.conf` covers only local sockets and loopback, so
connections from 10.0.0.0/8 were rejected before password auth even
started, firewall rule notwithstanding.
* The upstream `supabase/postgres` image ships role *attributes* our vanilla
Postgres lacked: `supabase_admin` is a SUPERUSER there (Studio/postgres-meta
connect as it), and `supabase_auth_admin` owns the `auth` schema (GoTrue
runs its own migrations in it at startup).
## Decision
A oneshot unit, `gebos-postgres-passwords` (`nix/modules/gebos-postgres.nix`),
runs on db-host after `postgresql.service` on every boot/deploy:
* Runs as the `postgres` OS user (local peer auth); reads the sops-rendered
env file via systemd `LoadCredential`, so the root-owned 0400 file needs no
permission widening.
* Idempotently syncs: `postgres``postgres_admin_password`;
`supabase_admin` / `supabase_auth_admin` / `authenticator` ← the shared
`supabase_postgres_password` (mirroring upstream's single
`POSTGRES_PASSWORD` convention); `gebos_ingest`
`ingester_postgres_password`.
* Enforces the two upstream attributes: `supabase_admin SUPERUSER` and
`ALTER SCHEMA auth OWNER TO supabase_auth_admin`.
* Guards every role for existence (tolerates a cluster the schema-v1
migration hasn't reached) and discards query output
(`--output=/dev/null`) so passwords never reach the journal.
* db-host enables the `supabase` and `ingester` secret toggles solely to get
those keys into its env file; `.sops.yaml`'s single key group already
permits this.
* `pg_hba` gains `host all all 10.0.0.0/8 scram-sha-256`.
## Consequences
* No password ever exists in git, the nix store, or SQL files; rotation is
`sops nix/secrets/secrets.yaml` + deploy + `systemctl restart
gebos-postgres-passwords` (the unit does not auto-rerun on secret-content
changes alone).
* The three Supabase roles share one password by design — per-role passwords
would diverge from the vendored compose, which interpolates a single
`${POSTGRES_PASSWORD}` everywhere.
* db-host can decrypt the Supabase JWT/API-key secrets it doesn't strictly
need; acceptable until `.sops.yaml` moves to per-prefix rules (noted there).
@@ -0,0 +1,66 @@
# ADR-0003: Vendor a trimmed Supabase compose stack against external Postgres
Date: 2026-07-08
Status: Accepted
## Context
The Supabase stack on app-host was a placeholder skeleton. The official
self-hosting bundle assumes its own bundled `db` container and ships nine+
services (realtime, storage, imgproxy, edge functions, supavisor pooler,
Logflare analytics, …), most of which the UVI pilot does not use. Meanwhile
our Postgres lives on a separate NixOS host (db-host) with TimescaleDB.
Kong deserves a note, since "why is it even there" came up: Kong is the
single front door for `api.gebos.online` (Caddy → Kong on loopback :8000).
It routes paths to internal services (`/auth/v1` → GoTrue, `/rest/v1`
PostgREST) and enforces the perimeter (key-auth + ACLs), so unauthenticated
internet traffic never reaches the backends. Replacing it would mean
re-implementing routing/auth/CORS in Caddy by hand instead of inheriting
Supabase's tested config.
## Decision
Vendor the upstream `docker/docker-compose.yml` (and `kong.yml` +
`kong-entrypoint.sh`) into `nix/supabase/`, keeping only
**studio, kong, auth (GoTrue), rest (PostgREST), meta (postgres-meta)**, with
these deviations — each also documented in the files' header comments:
1. **No `db` service.** Everything connects to
`${POSTGRES_HOST}:${POSTGRES_PORT}` (db-host); `depends_on: db` dropped.
2. **No realtime / storage / imgproxy / functions / supavisor / analytics.**
Re-vendor from upstream when actually needed.
3. **Studio binds to loopback only, and Kong's catch-all dashboard route is
removed.** Upstream fronts Studio with Kong basic-auth on `/`; we expose
no route to it at all. Access is `ssh -L 3000:127.0.0.1:3000` — possession
of the SSH key is the credential. `supabase_dashboard_password` is
therefore currently unused. The public API surface is exactly
`/auth/v1`, `/rest/v1`, `/graphql/v1` and two `.well-known` endpoints.
4. **meta connects as `supabase_admin`, not `postgres`.** Upstream's
`postgres` role *is* its cluster superuser; on db-host `postgres` keeps a
separate admin password, so `supabase_admin` (made SUPERUSER by ADR-0002)
fills that role with the shared service password.
5. **`kong-entrypoint.sh` is vendored byte-identical.** Kong's declarative
YAML cannot read env vars, so the entrypoint substitutes `$VARS` (the API
keys) into the config at container start; keeping it unmodified makes
re-vendoring a plain copy.
Variable layering (the answer to "where does `POSTGRES_HOST` come from"):
* non-secret deployment config (`POSTGRES_HOST/PORT/DB`, `STUDIO_BIND`) —
systemd unit `environment` in `gebos-supabase.nix`, fed by module options;
* secrets (`POSTGRES_PASSWORD`, `JWT_SECRET`, `ANON_KEY`,
`SERVICE_ROLE_KEY`) — sops-rendered `EnvironmentFile`;
* gebos constants (public URLs, org name) — hardcoded in the vendored
compose.
## Consequences
* Deliberately small attack surface and dependency set; Studio reachable only
through SSH.
* Upstream updates are a re-copy plus re-applying the five listed deviations;
all drift is concentrated in `docker-compose.yml` and `kong.yml` headers.
* GoTrue has **no SMTP configured yet** — magic-link login cannot send email
until the `SMTP_*` secrets are added (TODO marked in the compose file).
* `anon`/`authenticated` still hold zero grants; PostgREST serves nothing
until the RLS/grants migration lands (future ADR).
+103
View File
@@ -0,0 +1,103 @@
# ADR-0004: Legacy JWT-based API keys, one signing secret
Date: 2026-07-08
Status: Accepted
## Context
Supabase's API tier knows two credentials, and they are easy to confuse
because in the legacy scheme **both are JWTs signed with the same
`JWT_SECRET`**:
| | `apikey` header | `Authorization: Bearer …` header |
|---|---|---|
| identifies | the application | a logged-in person |
| minted by | a human, once, at project setup | GoTrue, at every login |
| payload | `{"role":"anon"}` / `{"role":"service_role"}` | `{"role":"authenticated","sub":"<user uuid>",…}` |
| lifetime | ~10 years | 1 h (`GOTRUE_JWT_EXP`), refreshed |
| checked by | Kong: exact string match against its consumer list | PostgREST: signature verification, then `SET ROLE` on the claim |
The `role` claim is what binds tokens to the database: PostgREST executes
each request as the Postgres role the (verified) `Authorization` JWT names —
`anon` for pre-login traffic, `authenticated` for sessions (with `auth.uid()`
= the `sub` claim, feeding RLS), `service_role` for server-side admin work.
`service_role` exists because some work legitimately spans all tenants
(admin invites, cross-apartment aggregation jobs, backfills); it carries
`BYPASSRLS` and its key must never leave the server side, while the anon key
is public by design (it ships in the frontend bundle).
Supabase is migrating to opaque `sb_publishable_*`/`sb_secret_*` keys that
separate the two concepts; the vendored `kong-entrypoint.sh` contains the
translation shim for that scheme.
## Decision
Stay on the **legacy scheme** for the pilot: `supabase_anon_key` and
`supabase_service_role_key` are long-lived HS256 JWTs signed with
`supabase_jwt_secret`. The opaque-key shim stays dormant (its env vars are
empty, which switches the Kong entrypoint to plain apikey pass-through).
This has a non-obvious integrity requirement: **the three secrets form one
cryptographic family.** The API keys are only valid if they were signed with
the exact `JWT_SECRET` stored alongside them. They are minted offline by us —
no service issues them — and rotating `JWT_SECRET` silently invalidates both
API keys *and* every active user session; all three must always be rotated
together.
## Verifying / minting the keys
The keys currently in `nix/secrets/secrets.yaml` must be checked once against
the stored secret (they predate this ADR). From the repo root, with sops
access — `openssl` is not in the dev shell, so wrap in
`nix shell nixpkgs#openssl` if needed:
```sh
SECRET=$(sops -d --extract '["supabase_jwt_secret"]' nix/secrets/secrets.yaml)
verify() { # verify <jwt> — checks HS256 signature against $SECRET
local hp=${1%.*} sig=${1##*.}
local expect=$(printf '%s' "$hp" \
| openssl dgst -sha256 -hmac "$SECRET" -binary \
| basenc --base64url -w0 | tr -d '=')
if [ -n "$expect" ] && [ "$sig" = "$expect" ]; then
echo "valid ($(printf '%s' "${hp#*.}" | tr '_-' '/+' | base64 -d 2>/dev/null))"
else
echo "INVALID SIGNATURE"
fi
}
verify "$(sops -d --extract '["supabase_anon_key"]' nix/secrets/secrets.yaml)"
verify "$(sops -d --extract '["supabase_service_role_key"]' nix/secrets/secrets.yaml)"
```
Each should print `valid` with a payload naming the right role. If either
prints `INVALID SIGNATURE`, re-mint and store both:
```sh
mint() { # mint <role> — 10-year HS256 JWT signed with $SECRET
local iat=$(date +%s) b64='basenc --base64url -w0'
local h=$(printf '{"alg":"HS256","typ":"JWT"}' | $b64 | tr -d '=')
local p=$(printf '{"role":"%s","iss":"supabase","iat":%s,"exp":%s}' \
"$1" "$iat" $((iat + 315360000)) | $b64 | tr -d '=')
local s=$(printf '%s.%s' "$h" "$p" \
| openssl dgst -sha256 -hmac "$SECRET" -binary | $b64 | tr -d '=')
printf '%s.%s.%s\n' "$h" "$p" "$s"
}
mint anon # → sops set as supabase_anon_key
mint service_role # → sops set as supabase_service_role_key
```
(Equivalently: the generator on Supabase's self-hosting docs page produces
the same thing — paste in the stored `JWT_SECRET`, don't let it invent a new
one.)
## Consequences
* Matches what supabase-js sends by default; no client-side configuration
beyond URL + anon key.
* One secret to protect (`JWT_SECRET`) — and one blast radius: leak it and an
attacker can mint `service_role` tokens; rotate it and keys + sessions die
together.
* Moving to opaque keys later is config-only: fill the four `sb_*`/asymmetric
env vars and the already-vendored Kong entrypoint starts translating.
+12
View File
@@ -0,0 +1,12 @@
# Architecture Decision Records
Numbered, immutable-once-accepted records of the significant decisions in
this repo. Supersede by adding a new ADR and flipping the old one's status to
`Superseded by ADR-NNNN`, not by editing history.
| # | Title | Status |
|---|-------|--------|
| [0001](0001-manual-supabase-cli-migrations.md) | Database schema changes are manual Supabase CLI migrations | Accepted |
| [0002](0002-role-passwords-via-oneshot-unit.md) | Postgres role passwords are synced by a one-shot systemd unit | Accepted |
| [0003](0003-vendored-supabase-compose-subset.md) | Vendor a trimmed Supabase compose stack against external Postgres | Accepted |
| [0004](0004-legacy-jwt-api-keys.md) | Legacy JWT-based API keys, one signing secret | Accepted |
+189
View File
@@ -0,0 +1,189 @@
# UVI Anforderungen (Pilot)
> Anforderungsdokument. Source of Truth = diese Notion-Seite. Tickets in Linear referenzieren die hier vergebenen IDs.
>
> **Methodische Grundlage:** UBA-Leitfaden „Verständliche monatliche Heizinformation als Schlüssel zur Verbrauchsreduktion" (CLIMATE CHANGE 69/2021), Umweltbundesamt 2021.
>
---
# 1. Goal State
Ein Mieter kann sich jederzeit in das Dashboard seiner Wohneinheit einloggen und seine monatliche Heizinformation einsehen, gestaltet **vollständig nach dem UBA-Leitfaden**. Die Heizinformation umfasst sechs Module:
1. **Verbrauchsentwicklung** für Heizung und Warmwasser mit 13-Monats-Verlauf, Tendenz-Indikator gegenüber Vorjahresmonat und Witterungs-Kontext (Heizgradtage)
2. **In-House-Vergleich** des spezifischen Verbrauchs (kWh/m² Gebäudenutzfläche) mit anderen Wohneinheiten desselben Hauses
3. **Bandtacho-Einordnung** des Hauses in die Gebäudeeffizienzklassen A⁺ bis H
4. **Spartipp des Monats** für Heizen (in der Heizperiode) bzw. Warmwasser (außerhalb)
5. **Kostenschätzung** für den aktuellen Monat und Jahr-zu-Datum
6. **CO2-Emissionen** des Haushalts mit alltagsnahem Vergleichswert
Die Heizinformation erfüllt § 6a Abs. 2 HeizkostenV. Der gesetzlich geforderte Durchschnittsnutzer-Vergleich (§ 6a Abs. 2 Nr. 3) wird durch die Kombination aus Modul 2 und Modul 3 abgedeckt — wie vom UBA-Leitfaden empfohlen.
## In Scope
- Alle sechs Module gemäß UBA-Leitfaden
- Mieter-Dashboard mit Magic-Link-Login pro Wohneinheit
- Datenempfang von Gateways
- Read-Only Admin-Dashboard, gruppiert nach Gebäudeadresse
- Unterscheidung Heizperiode / außerhalb Heizperiode
- Integration externer Referenzdaten: DWD Heizgradtage, GEG-Anlagen 9 und 10
## Out of Scope (für UVI)
- Jährliche Heizkostenabrechnung (eigenes Epic)
- Vermieter-/Hausverwaltungs-Sicht
- Witterungsbereinigung der Verbräuche (gehört zur Jahresabrechnung)
- Self-Service-UI für Geräte-zu-Wohneinheit-Zuordnung (post-Pilot)
- API-Keys pro Wohneinheit (Future)
- Optionale UBA-Leitfaden-Erweiterungen: Lage der Wohnung, energetischer Zustand, Pro-Kopf-Vergleichswerte, tägliche Verbrauchsinformationen, Gamification
- „Energieanalyse aus dem Verbrauch" (DIN TS 12831-1, DIN V 18599)
## Phasenmodell
| Phase | Geräte-Zuordnung | Admin-Dashboard | Mieter-Dashboard |
| --- | --- | --- | --- |
| **Pilot** | Manuell direkt in der Datenbank | Read-Only Übersicht aller Wohneinheiten, gruppiert nach Gebäudeadresse | Vollständig (alle 6 Module) |
| **Post-Pilot** | UI/Mechanismus *(TBD)* | Erweitert um Zuordnungs-Funktionen | Vollständig + ggf. optionale Erweiterungen |
---
# 2. Anforderungen
## 2.1 Compliance — § 6a HeizkostenV
| ID | Anforderung | Quelle | Erfüllung |
| --- | --- | --- | --- |
| UVI-COMP-01 | Verbrauch des Mieters im letzten Monat in kWh wird bereitgestellt | § 6a Abs. 2 Nr. 1 | Modul 1 |
| UVI-COMP-02 | Vergleich mit dem Verbrauch des Vormonats desselben Mieters | § 6a Abs. 2 Nr. 2 | Modul 1 (visuell im 13-Monats-Verlauf) |
| UVI-COMP-03 | Vergleich mit dem entsprechenden Monat des Vorjahres desselben Mieters | § 6a Abs. 2 Nr. 2 | Modul 1 (Tendenz-Indikator + Witterungskontext) |
| UVI-COMP-04 | Vergleich mit Durchschnittsnutzer derselben Nutzerkategorie | § 6a Abs. 2 Nr. 3 | Modul 2 (In-House) + Modul 3 (Bandtacho) |
| UVI-COMP-05 | Bereitstellung mindestens monatlich | § 6a Abs. 1 Nr. 2 | — |
## 2.2 Funktional
Spalte „Modul" referenziert die Module des UBA-Leitfadens (16).
| ID | Anforderung | Modul |
| --- | --- | --- |
| UVI-FUN-01 | Empfang von Verbrauchsdaten von Gateways | — |
| UVI-FUN-02 | *(Platzhalter)* Geräte-zu-Wohneinheit-Zuordnung — Pilot manuell in DB; Mechanismus post-Pilot | — |
| UVI-FUN-03 | Mieter-Login pro Wohneinheit (Magic Link) | — |
| UVI-FUN-04 | Anzeige des aktuellen Monatsverbrauchs in kWh, getrennt für Heizung und Warmwasser. **Anteil ist nicht nach verbrauchs-/flächenabhängigem Schlüssel gewichtet** (Unterschied zur Jahresabrechnung). | 1 |
| UVI-FUN-05 | Verlaufsdiagramm der monatlichen Verbräuche der letzten 13 Monate, getrennt für Heizung und Warmwasser; Vorjahresmonat zusätzlich beziffert | 1 |
| UVI-FUN-06 | Tendenz-Indikator zum Vorjahresmonat: gesunken (grüner Pfeil), gestiegen (roter Pfeil), gleich (grauer Pfeil). „Gleich" wenn auf Zehnerstelle gerundete kWh identisch sind. | 1 |
| UVI-FUN-07 | Witterungs-Kontext als Fußnote: Heizgradtage-Differenz Vorjahresmonat ↔ aktueller Monat in % | 1 |
| UVI-FUN-08 | In-House-Vergleich: Sparpotenzial-Pfeil zeigt Spektrum der spezifischen Gesamtverbräuche (Heizung+Warmwasser) aller Wohneinheiten des Hauses in kWh/m² Gebäudenutzfläche | 2 |
| UVI-FUN-09 | Bei kleinen Häusern (≤ 4 Wohneinheiten) wird zur Wahrung der Anonymität statt des Einzelwerts der Mittelwert der drei sparsamsten Haushalte ausgewiesen | 2 |
| UVI-FUN-10 | Bandtacho-Einordnung des Hauses in Effizienzklassen A⁺ bis H, basierend auf monatlichen Schwellenwerten (Tabelle 1 UBA-Leitfaden, abgeleitet aus Anlage 10 GEG) | 3 |
| UVI-FUN-11 | Anzeige eines Spartipps des Monats (Heizen/Lüften in der Heizperiode, Warmwasser außerhalb), inkl. Link zu weiterführenden Beratungsangeboten | 4 |
| UVI-FUN-12 | Kostenschätzung für aktuellen Monat und Jahr-zu-Datum, berechnet aus Endenergieverbrauch × aktueller Energiepreis pro Brennstoffart | 5 |
| UVI-FUN-13 | Anzeige der monatlichen CO2-Emissionen in kg, berechnet mit Emissionsfaktor aus Anlage 9 GEG; ergänzt um alltagsnahen Vergleichswert | 6 |
| UVI-FUN-14 | Außerhalb der Heizperiode: Bandtacho entfällt; Sparpotenzial-Pfeil zeigt nur Warmwasser-Spektrum (rot→blau); Heizungs-Verlaufsdiagramm bleibt sichtbar mit aktuellem Wert 0 kWh | — |
| UVI-FUN-15 | Fehlende Daten werden im UI klar als „nicht verfügbar" gekennzeichnet (keine stille 0) | — |
| UVI-FUN-16 | Verbrauchsdaten der letzten 13 Monate sind im Dashboard einsehbar | — |
| UVI-FUN-17 | Admin-Dashboard zeigt UVI-Daten aller Wohneinheiten als Read-Only-Übersicht, gruppiert nach Gebäudeadresse | — |
| UVI-FUN-18 | Admin-Login getrennt vom Mieter-Login (Magic Link) | — |
## 2.3 Nicht-funktional
| ID | Anforderung |
| --- | --- |
| UVI-NFR-01 | Datenresolution: mindestens monatlich |
| UVI-NFR-02 | DSGVO-konforme Speicherung und Verarbeitung |
| UVI-NFR-03 | Strikte Mandantentrennung: kein wohneinheitsübergreifender Datenzugriff für Mieter |
| UVI-NFR-04 | Datenübertragung von Gateway zum System verschlüsselt (TLS) |
| UVI-NFR-05 | Daten der UVI sind spätestens am 5. Werktag des Folgemonats im Dashboard sichtbar |
| UVI-NFR-06 | Verfügbarkeit Dashboard: *(zu definieren Vorschlag: 99 % im Monatsmittel)* |
| UVI-NFR-07 | Admin-Authentifizierung getrennt vom Mieter-Login; Admin-Zugriff protokolliert |
| UVI-NFR-08 | Authentifizierung erfolgt für Mieter und Admin **ausschließlich via Magic Link** (passwortlos) |
| UVI-NFR-09 | Anonymitätsschutz im In-House-Vergleich (siehe UVI-FUN-09) |
## 2.4 Daten / Domäne
| ID | Anforderung |
| --- | --- |
| UVI-DAT-01 | Entität *Wohneinheit* mit eindeutiger ID und **Wohnfläche in m²** |
| UVI-DAT-02 | *(Platzhalter)* Entität *Gateway/Sensor* und Verknüpfung zu Wohneinheit — Granularität noch offen |
| UVI-DAT-03 | Entität *Messwert* mit Zeitstempel, Wert in kWh, Typ (Heizung/Warmwasser), Geräte-Referenz |
| UVI-DAT-04 | Entität *Mieter* mit Zuordnung zu Wohneinheit; Nutzungs-Zeiträume zur Erkennung von Mieterwechseln |
| UVI-DAT-05 | Entität *Gebäude* mit Adresse, **Brennstoffart**, optional Faktor Wohnfläche → Gebäudenutzfläche (Default 1,2) |
| UVI-DAT-06 | Aggregate werden **on-the-fly aus Roh-Messwerten berechnet**; keine Persistierung von Aggregaten |
| UVI-DAT-07 | Referenzdaten: **Bandtacho-Schwellenwerte** der Gebäudeeffizienzklassen aus Anlage 10 GEG, monatlich aufgeteilt gemäß Tabelle 1 UBA-Leitfaden |
| UVI-DAT-08 | Referenzdaten: **CO2-Emissionsfaktoren** pro Brennstoffart aus Anlage 9 GEG |
| UVI-DAT-09 | Referenzdaten: **Energiepreise** pro Brennstoffart, regelmäßig aktualisierbar |
| UVI-DAT-10 | Referenzdaten: **Heizgradtage** pro Monat aus DWD Open Data nach VDI 3807 |
| UVI-DAT-11 | **Spartipps-Bibliothek**: kategorisiert nach Heizen / Warmwasser, mit optionalen Links |
## 2.5 Methodische Festlegungen
| ID | Anforderung |
| --- | --- |
| UVI-MET-01 | Die UVI folgt dem **UBA-Leitfaden** (CLIMATE CHANGE 69/2021) als kanonische Vorlage. Abweichungen werden explizit begründet. |
| UVI-MET-02 | Der Durchschnittsnutzer-Vergleich gemäß § 6a Abs. 2 Nr. 3 wird durch **Kombination aus In-House-Vergleich (Modul 2) und Bandtacho-Einordnung (Modul 3)** erfüllt. Eine externe Vergleichswertquelle (z.B. BMWK-Bekanntmachung) wird **nicht** zusätzlich verwendet. |
| UVI-MET-03 | Spezifische Verbräuche werden auf die **Gebäudenutzfläche** bezogen (= Wohnfläche × 1,2, falls keine direkte Angabe vorliegt). |
---
# 3. Mapping Notion ↔ Tickets
- Anforderungen (UVI-COMP-*, UVI-FUN-*, UVI-NFR-*, UVI-DAT-*, UVI-MET-*) bleiben Single Source of Truth in Notion**.
- User Stories werden in Linear angelegt; Stories enthalten in der Beschreibung die `Erfüllt:`-Zeile als Backlink in Notion.
- Definition of Done eines Tickets = die Akzeptanzkriterien der Story.
---
# 4. Offene Punkte (zu klären)
- **Geräte-zu-Wohneinheit-Zuordnung (post-Pilot):** Granularität, Pflege, UI vs. Bulk-Import, Validierung. Pilot manuell in DB.
- **Energiepreis-Pflege (Modul 5):** Wer pflegt die Preise, in welcher Frequenz, aus welcher Quelle?
- **Mieter-Mail-Erfassung:** Wer hinterlegt die E-Mail-Adresse des Mieters?
- **Spartipps-Quelle:** Eigene Kuratierung vs. Verlinken auf [verbraucherzentrale.de](http://verbraucherzentrale.de) / co2online?
- **Datenaufbewahrung:** Wie lange werden Roh-Messwerte gespeichert?
- **Wetterstationszuordnung pro Gebäude:** Automatisch nach Postleitzahl oder manuell?
- **Verfügbarkeits-SLA:** Konkreter Wert für UVI-NFR-06.
- **Sprache/Mehrsprachigkeit:** Nur DE oder auch EN?
- **Brennstoff-Mix bei Fernwärme:** § 6a Abs. 3 — gehört zur Jahresabrechnung, könnte aber Datenmodell beeinflussen.
## Geklärt seit Beginn
- ✅ Authentifizierung: Magic Link (passwortlos) für Mieter und Admin
- ✅ Aggregation: on-the-fly, keine Persistierung von Aggregaten
- ✅ Methodik: Vollständig nach UBA-Leitfaden — alle 6 Module im Pilot
- ✅ Durchschnittsnutzer-Vergleich: über In-House + Bandtacho (nicht über externe Vergleichswert-Datenbank)
---
# Anhang A — Methodische Grundlage
## A.1 Warum UBA-Leitfaden statt BMWK-Bekanntmachung?
Eine frühere Version dieses Dokuments hatte die BMWK-Bekanntmachung „Regeln für Energieverbrauchswerte im Wohngebäudebestand" (BAnz AT 16.04.2021 B1) als Quelle vorgesehen. Diese Entscheidung wurde nach Auswertung des UBA-Leitfadens revidiert.
Begründung:
1. **Zweck der Quellen unterscheidet sich:** Die BMWK-Bekanntmachung ist für **Energieausweise nach GEG** konzipiert. Der UBA-Leitfaden adressiert exakt unseren Use Case: **monatliche Heizinformation** nach § 6a HeizkostenV.
2. **Praktische Argumentation des UBA-Leitfadens (S. 23):** Ein gebäudeübergreifender Vergleich anonymisierter Verbräuche setzt voraus, dass die Gebäude einen ähnlichen energetischen Standard haben. Diese Information liegt Messdienstleistern typischerweise nicht vor.
3. **Der Leitfaden liefert konkrete Vergleichswerte** (Tabelle 1, S. 26) und ein vollständiges Gestaltungsmuster.
4. **Methodischer Ansatz:** § 6a Abs. 2 Nr. 3 wird erfüllt durch In-House-Vergleich (Modul 2) + Bandtacho (Modul 3).
## A.2 Edge Case: Mieterwechsel
Bei einem Mieterwechsel ist der Vergleich zum Vormonat oder Vorjahresmonat nach § 6a Abs. 2 Nr. 2 nicht möglich. In diesem Fall bleibt nur Modul 2 als verpflichtende Information. Datenmodell: Nutzungs-Zeiträume pro Wohneinheit müssen erfasst sein (UVI-DAT-04).
## A.3 Sanktion bei Nicht-Erfüllung
§ 7 HeizkostenV: Wird die UVI nicht oder unvollständig mitgeteilt, hat der Mieter ein Kürzungsrecht von **3 %** auf den Kostenanteil.
## A.4 Quellen
- **UBA-Leitfaden:** „Verständliche monatliche Heizinformation als Schlüssel zur Verbrauchsreduktion" (CLIMATE CHANGE 69/2021), Umweltbundesamt 2021, Brischke et al. (ifeu)
- **HeizkostenV § 6a** — Inhalte der UVI
- **HeizkostenV § 7** — Kürzungsrecht
- **EU-Richtlinie 2012/27/EU** Anhang VIIa, in der Fassung der RL 2018/2002 (EED)
- **GEG Anlage 9** — CO2-Emissionsfaktoren
- **GEG Anlage 10** — Schwellenwerte der Gebäudeeffizienzklassen
- **VDI 3807** — Heizgradtage und Verbrauchskennwerte (Backup-Referenz)
- **DWD Open Data** — [opendata.dwd.de/.../hdd_3807/recent/](http://opendata.dwd.de/.../hdd_3807/recent/)
- **UBA-CO2-Rechner** — [uba.co2-rechner.de](http://uba.co2-rechner.de)
+1 -1
View File
@@ -7,7 +7,7 @@ import { createClient } from "@supabase/supabase-js";
// and VITE_SUPABASE_ANON_KEY set at `nix build .#frontend` time. // and VITE_SUPABASE_ANON_KEY set at `nix build .#frontend` time.
const DEV_SUPABASE_URL = "http://127.0.0.1:8000"; const DEV_SUPABASE_URL = "http://127.0.0.1:8000";
const DEV_SUPABASE_ANON_KEY = const DEV_SUPABASE_ANON_KEY =
"dev-anon-key-placeholder-regenerate-with-supabase-self-host-script"; "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc1MTMyODAwMCwiZXhwIjoyMDgyNzU4NDAwfQ.M6U-nZq9dySoUJEfWHsiB0qabhATWobGaTM1LF86HHU";
const SUPABASE_URL = const SUPABASE_URL =
import.meta.env.VITE_SUPABASE_URL ?? import.meta.env.VITE_SUPABASE_URL ??
+189 -9
View File
@@ -15,17 +15,25 @@ let
pgPassword = "dev"; pgPassword = "dev";
pgHost = "127.0.0.1"; pgHost = "127.0.0.1";
pgPort = 5432; pgPort = 5432;
# Unix socket dir, bind-mounted into the compose network's `db` proxy
# (see composeDevOverride below). Relative to the process-compose cwd,
# like services-flake's ./data/db data dir.
pgSocketDir = "./data/db-socket";
mqttHost = "127.0.0.1"; mqttHost = "127.0.0.1";
mqttPort = 1883; mqttPort = 1883;
# Pre-generated dev JWT pair, signed with `jwt_secret = "dev-jwt-secret-32chars-minimum-xxxxx"`. # Pre-generated dev JWT pair, signed HS256 with jwtSecret. Regenerate
# Anyone with this token can read/write the local dev stack only — not prod. # with any JWT tool if jwtSecret ever changes; payload is
# {"role":"<anon|service_role>","iss":"supabase-dev","iat":1751328000,"exp":2082758400}.
# Anyone with these tokens can read/write the local dev stack only — not prod.
jwtSecret = "dev-jwt-secret-32chars-minimum-xxxxx"; jwtSecret = "dev-jwt-secret-32chars-minimum-xxxxx";
anonKey = "dev-anon-key-placeholder-regenerate-with-supabase-self-host-script"; anonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc1MTMyODAwMCwiZXhwIjoyMDgyNzU4NDAwfQ.M6U-nZq9dySoUJEfWHsiB0qabhATWobGaTM1LF86HHU";
serviceRoleKey = "dev-service-role-key-placeholder-regenerate-with-supabase-self-host-script"; serviceRoleKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UtZGV2IiwiaWF0IjoxNzUxMzI4MDAwLCJleHAiOjIwODI3NTg0MDB9.dNeWNshQ9e1pIjxTDYseURlbSPPArVIKa9OuTRZyIH8";
supabaseUrl = "http://127.0.0.1:8000"; supabaseUrl = "http://127.0.0.1:8000";
frontendPort = 5173; # vite default
}; };
ingesterEnv = { ingesterEnv = {
@@ -45,6 +53,133 @@ let
listener ${toString dev.mqttPort} ${dev.mqttHost} listener ${toString dev.mqttPort} ${dev.mqttHost}
allow_anonymous true allow_anonymous true
''; '';
# ---------------------------------------------------------------------------
# Supabase — the vendored prod compose bundle (nix/supabase/), unchanged,
# plus this dev overlay.
#
# The overlay's job is wiring the containers to the services-flake postgres.
# Bridge→host TCP is a dead end on a typical NixOS dev machine (the host
# firewall's INPUT chain rejects it), so instead a socat `db` service
# forwards TCP 5432 inside the compose network onto the host postgres unix
# socket, bind-mounted from pgSocketDir. Conveniently that restores the
# upstream compose convention of a service literally named `db`, so the
# vendored file works with just POSTGRES_HOST=db. Socket connections match
# pg_hba's `local … trust`, so the dev password is never actually checked.
# ---------------------------------------------------------------------------
composeDevOverride = pkgs.writeText "docker-compose.dev-override.yml" ''
services:
db:
container_name: supabase-dev-db
# Unpinned minor: dev-only TCPunix-socket shim, nothing depends on
# socat internals.
image: alpine/socat:1.8.0.3
restart: unless-stopped
command: TCP-LISTEN:${toString dev.pgPort},fork,reuseaddr UNIX-CONNECT:/host-postgres/.s.PGSQL.${toString dev.pgPort}
volumes:
- ''${PG_SOCKET_DIR:?set by gebos-dev-supabase-compose}:/host-postgres
studio:
environment:
SUPABASE_PUBLIC_URL: ${dev.supabaseUrl}
auth:
environment:
API_EXTERNAL_URL: ${dev.supabaseUrl}
GOTRUE_SITE_URL: http://localhost:${toString dev.frontendPort}
GOTRUE_JWT_ISSUER: ${dev.supabaseUrl}
# No SMTP locally, so magic-link invites can't send. Allow plain
# self-signup with auto-confirm instead; prod keeps signup disabled.
GOTRUE_DISABLE_SIGNUP: "false"
GOTRUE_MAILER_AUTOCONFIRM: "true"
'';
# One wrapper for `up`/`down` so both resolve the socket-dir bind mount the
# same way (compose needs an absolute path).
supabaseCompose = pkgs.writeShellApplication {
name = "gebos-dev-supabase-compose";
runtimeInputs = [ pkgs.docker pkgs.coreutils ];
text = ''
PG_SOCKET_DIR="$(readlink -f ${dev.pgSocketDir})"
export PG_SOCKET_DIR
exec docker compose \
--project-name gebos-supabase-dev \
-f nix/supabase/docker-compose.yml \
-f ${composeDevOverride} \
"$@"
'';
};
supabaseEnv = {
POSTGRES_HOST = "db"; # the socat proxy above
POSTGRES_PORT = toString dev.pgPort;
POSTGRES_DB = dev.pgDB;
POSTGRES_PASSWORD = dev.pgPassword; # unchecked — see overlay comment
JWT_SECRET = dev.jwtSecret;
ANON_KEY = dev.anonKey;
SERVICE_ROLE_KEY = dev.serviceRoleKey;
STUDIO_BIND = "127.0.0.1";
};
# Everything prod splits across nix/supabase/init.sql (first initdb),
# the gebos-postgres-passwords oneshot, and a manual `supabase db push`,
# collapsed into one idempotent script that runs on every stack start.
dbMigrate = pkgs.writeShellApplication {
name = "gebos-dev-db-migrate";
runtimeInputs = [ pkgs.supabase-cli pkgs.postgresql_17 ];
text = ''
export PGHOST=${dev.pgHost} PGPORT=${toString dev.pgPort} PGDATABASE=${dev.pgDB} PGSSLMODE=disable
# 1. Cluster bootstrap: Supabase roles/schemas the compose services
# expect (mirrors nix/supabase/init.sql, but idempotent because the
# dev data dir may predate this script). Password 'dev' everywhere.
psql -v ON_ERROR_STOP=1 --quiet <<'SQL'
DO $$ BEGIN CREATE ROLE anon NOLOGIN NOINHERIT; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE ROLE authenticated NOLOGIN NOINHERIT; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE ROLE service_role NOLOGIN NOINHERIT BYPASSRLS; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE ROLE authenticator LOGIN NOINHERIT; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
GRANT anon, authenticated, service_role TO authenticator;
DO $$ BEGIN CREATE ROLE supabase_admin LOGIN CREATEROLE CREATEDB BYPASSRLS; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE ROLE supabase_auth_admin LOGIN CREATEROLE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- On db-host the NixOS postgresql module creates the `postgres`
-- superuser; the services-flake cluster's superuser is the OS user
-- instead, and GoTrue's own migrations GRANT to `postgres` by name.
DO $$ BEGIN CREATE ROLE postgres LOGIN SUPERUSER; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
ALTER ROLE postgres PASSWORD 'dev';
ALTER ROLE supabase_admin SUPERUSER PASSWORD 'dev';
ALTER ROLE supabase_auth_admin PASSWORD 'dev';
ALTER ROLE authenticator PASSWORD 'dev';
-- GoTrue owns `auth` and runs its own migrations there on startup. Its
-- migrator creates tables in the role's default schema, so without the
-- search_path it lands in `public` and dies on PG15+'s revoked CREATE
-- (upstream's supabase/postgres image sets the same role search_path).
CREATE SCHEMA IF NOT EXISTS auth;
ALTER SCHEMA auth OWNER TO supabase_auth_admin;
ALTER ROLE supabase_auth_admin SET search_path TO auth, extensions;
-- The upstream supabase/postgres image keeps shared extensions in an
-- `extensions` schema on the search path; the services assume that.
CREATE SCHEMA IF NOT EXISTS extensions;
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions;
GRANT USAGE ON SCHEMA extensions TO anon, authenticated, service_role, authenticator, supabase_auth_admin;
ALTER DATABASE gebos SET search_path TO "$user", public, extensions;
SQL
# 2. Schema migrations same mechanism as prod (supabase/README.md),
# non-interactive, as the local superuser ($USER).
supabase db push --yes --db-url "postgres://$USER@${dev.pgHost}:${toString dev.pgPort}/${dev.pgDB}"
# 3. The schema-v1 migration created gebos_ingest; in prod its password
# comes from the gebos-postgres-passwords oneshot.
psql -v ON_ERROR_STOP=1 --quiet -c "ALTER ROLE ${dev.pgUser} PASSWORD '${dev.pgPassword}'"
# 4. Fixture data idempotent, dev/tests only (supabase/README.md).
psql -v ON_ERROR_STOP=1 --quiet -f supabase/seed.sql
'';
};
in in
{ {
imports = [ services-flake.processComposeModules.default ]; imports = [ services-flake.processComposeModules.default ];
@@ -53,7 +188,17 @@ in
postgres."db" = { postgres."db" = {
enable = true; enable = true;
port = dev.pgPort; port = dev.pgPort;
# TCP stays loopback-only; the compose containers come in through the
# unix socket instead (see composeDevOverride).
socketDir = dev.pgSocketDir;
initialDatabases = [{ name = dev.pgDB; }]; initialDatabases = [{ name = dev.pgDB; }];
# The schema-v1 migration calls create_hypertable; without the
# extension `supabase db push` rolls back entirely on the dev db.
# The apache variant lacks only TSL features (compression, CAggs
# policies) — hypertables are enough here, and it keeps dev free
# of unfree packages.
extensions = extensions: [ extensions.timescaledb-apache ];
settings.shared_preload_libraries = "timescaledb";
}; };
}; };
@@ -67,20 +212,55 @@ in
}; };
}; };
# Oneshot: bootstrap roles/schemas, apply migrations, seed. Idempotent,
# so it simply re-runs on every stack start.
db-migrate = {
command = "${dbMigrate}/bin/gebos-dev-db-migrate";
depends_on."db".condition = "process_healthy";
};
# Auth (GoTrue) + REST (PostgREST) + Studio + postgres-meta behind Kong
# on ${dev.supabaseUrl} — the vendored prod bundle, see composeDevOverride.
# Needs a running docker daemon on the dev machine.
supabase = {
command = "${supabaseCompose}/bin/gebos-dev-supabase-compose up --remove-orphans";
environment = supabaseEnv;
# `restart: unless-stopped` containers outlive a hard-killed
# process-compose; an explicit `down` covers the clean path.
shutdown.command = "${supabaseCompose}/bin/gebos-dev-supabase-compose down --remove-orphans";
depends_on = {
# GoTrue's own migrations need the auth schema + roles from db-migrate.
"db".condition = "process_healthy";
"db-migrate".condition = "process_completed_successfully";
};
readiness_probe = {
# Kong → key-auth → GoTrue → Postgres: healthy = the whole API path works.
exec.command = "${pkgs.curl}/bin/curl -fsS -H 'apikey: ${dev.anonKey}' ${dev.supabaseUrl}/auth/v1/health";
initial_delay_seconds = 5;
period_seconds = 5;
failure_threshold = 60; # first start pulls the images
};
};
ingester = { ingester = {
command = "${pkgs.go}/bin/go run ./ingester/cmd/gebos-ingester"; # -C: the go module root is ingester/, not the repo root.
command = "${pkgs.go}/bin/go run -C ingester ./cmd/gebos-ingester";
environment = ingesterEnv; environment = ingesterEnv;
depends_on = { depends_on = {
"db".condition = "process_healthy"; # db-migrate (implies db healthy) creates the gebos_ingest role and
# the telemetry tables.
"db-migrate".condition = "process_completed_successfully";
"broker".condition = "process_started"; "broker".condition = "process_started";
}; };
}; };
frontend = { frontend = {
command = "${pkgs.nodejs_24}/bin/npm --prefix frontend install && ${pkgs.nodejs_24}/bin/npm --prefix frontend run dev"; command = "${pkgs.nodejs_24}/bin/npm --prefix frontend install && ${pkgs.nodejs_24}/bin/npm --prefix frontend run dev";
environment = frontendEnv; environment = frontendEnv;
}; };
# TODO: supabase compose (point POSTGRES_HOST at the services-flake db)
# TODO: kong (use the vendored kong.yml from nix/supabase/) # No caddy locally: prod's Caddy config is generated by the gebos-caddy
# TODO: caddy (mirror prod routes locally, no TLS) # NixOS module, so a hand-mirrored dev caddyfile wouldn't test it — the
# frontend talks to Kong directly (dev.supabaseUrl).
}; };
} }
+5
View File
@@ -8,6 +8,11 @@
networking.hostName = "db-host"; networking.hostName = "db-host";
services.gebos.secrets.postgresAdmin = true; services.gebos.secrets.postgresAdmin = true;
# The gebos-postgres-passwords oneshot (gebos-postgres.nix) also syncs the
# shared Supabase service-role password and the ingester's password, so
# db-host needs those keys rendered into its env file too.
services.gebos.secrets.supabase = true;
services.gebos.secrets.ingester = true;
services.gebos.postgres = { services.gebos.postgres = {
enable = true; enable = true;
+91 -1
View File
@@ -37,8 +37,98 @@ in
# supabase_vault — TODO: package or vendor # supabase_vault — TODO: package or vendor
]; ];
# Init script creates Supabase's `auth`, `storage`, `_analytics`, `_supavisor` # Init script creates Supabase's `auth`, `storage`, `_analytics`, `_supavisor`
# schemas, the gebos_ingest role (BYPASSRLS), and telemetry hypertables. # schemas and the Supabase admin/API roles. It only runs at first initdb;
# everything else arrives via manual supabase migrations (see
# supabase/README.md) and the password oneshot below.
initialScript = ../supabase/init.sql; initialScript = ../supabase/init.sql;
# The firewall limits 5432 to 10.0.0.0/8, but NixOS's default pg_hba
# only covers local sockets and loopback — without this line every
# connection from app-host / mqtt-ingest is rejected before password
# auth even starts.
authentication = ''
host all all 10.0.0.0/8 scram-sha-256
'';
};
# Role passwords are data, not configuration: NixOS has no declarative
# option for them (anything reachable from the config lands world-readable
# in /nix/store, and initialScript only runs at first initdb). This
# oneshot re-applies them from the sops-rendered env file on every boot /
# deploy, idempotently. It also enforces two attributes the Supabase
# services expect because the upstream supabase/postgres image ships them:
# supabase_admin is SUPERUSER (Studio/postgres-meta connect as it) and
# supabase_auth_admin owns the auth schema (GoTrue runs its own migrations
# there on startup).
#
# After rotating a password in secrets.yaml, re-run with
# `systemctl restart gebos-postgres-passwords` (deploys re-run it only
# when the unit definition itself changed).
systemd.services.gebos-postgres-passwords =
lib.mkIf config.services.gebos.secrets.postgresAdmin {
description = "Gebos sync Postgres role passwords from sops secrets";
after = [ "postgresql.service" ];
requires = [ "postgresql.service" ];
wantedBy = [ "multi-user.target" ];
path = [ config.services.postgresql.package ];
serviceConfig = {
Type = "oneshot";
User = "postgres";
# LoadCredential hands the root-owned 0400 env file to the postgres
# user without widening its permissions.
LoadCredential = "gebos-env:${config.services.gebos.secrets.envFile}";
};
script = ''
set -euo pipefail
set -a; . "$CREDENTIALS_DIRECTORY/gebos-env"; set +a
# All three come from the gebos-env template; the toggles in
# db-host.nix (postgresAdmin + supabase + ingester) must be on.
# --output=/dev/null: the set_config() SELECTs would otherwise echo
# the passwords into the journal.
psql -v ON_ERROR_STOP=1 --output=/dev/null \
-v admin_pw="''${POSTGRES_ADMIN_PASSWORD:?}" \
-v supabase_pw="''${POSTGRES_PASSWORD:?}" \
-v ingest_pw="''${GEBOS_POSTGRES_PASSWORD:?}" \
--dbname postgres <<'SQL'
-- psql :'var' interpolation does not reach inside DO bodies, so
-- stash the values in session GUCs first.
SELECT set_config('gebos.supabase_pw', :'supabase_pw', false);
SELECT set_config('gebos.ingest_pw', :'ingest_pw', false);
ALTER ROLE postgres PASSWORD :'admin_pw';
DO $do$
DECLARE r text;
BEGIN
-- One shared password for the roles the compose services log in
-- as, mirroring upstream's POSTGRES_PASSWORD convention.
FOREACH r IN ARRAY ARRAY['supabase_admin','supabase_auth_admin','authenticator'] LOOP
IF EXISTS (SELECT FROM pg_roles WHERE rolname = r) THEN
EXECUTE format('ALTER ROLE %I PASSWORD %L',
r, current_setting('gebos.supabase_pw'));
END IF;
END LOOP;
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_admin') THEN
EXECUTE 'ALTER ROLE supabase_admin SUPERUSER';
END IF;
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_auth_admin')
AND EXISTS (SELECT FROM pg_namespace WHERE nspname = 'auth') THEN
EXECUTE 'ALTER SCHEMA auth OWNER TO supabase_auth_admin';
END IF;
-- Created by the schema-v1 migration, so tolerate its absence on
-- a cluster the migration has not reached yet.
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'gebos_ingest') THEN
EXECUTE format('ALTER ROLE gebos_ingest PASSWORD %L',
current_setting('gebos.ingest_pw'));
END IF;
END
$do$;
SQL
'';
}; };
}; };
} }
+16 -2
View File
@@ -30,10 +30,16 @@ in
"d /var/lib/gebos-supabase 0750 root root - -" "d /var/lib/gebos-supabase 0750 root root - -"
]; ];
# Copy the vendored compose file into place at activation time so changes # Copy the vendored compose bundle into place at activation time so
# to nix/supabase/docker-compose.yml are deployed atomically. # changes under nix/supabase/ are deployed atomically. Kong's declarative
# config is mounted from here by the compose file (relative ./kong.yml
# resolves against WorkingDirectory).
environment.etc."gebos/supabase/docker-compose.yml".source = environment.etc."gebos/supabase/docker-compose.yml".source =
"${composeDir}/docker-compose.yml"; "${composeDir}/docker-compose.yml";
environment.etc."gebos/supabase/kong.yml".source =
"${composeDir}/kong.yml";
environment.etc."gebos/supabase/kong-entrypoint.sh".source =
"${composeDir}/kong-entrypoint.sh";
systemd.services.gebos-supabase = { systemd.services.gebos-supabase = {
description = "Gebos Supabase compose stack"; description = "Gebos Supabase compose stack";
@@ -41,6 +47,14 @@ in
wants = [ "docker.service" "network-online.target" ]; wants = [ "docker.service" "network-online.target" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
# `docker compose up -d` only recreates containers whose definition
# changed, but the unit must re-run for it to notice at all.
restartTriggers = [
"${composeDir}/docker-compose.yml"
"${composeDir}/kong.yml"
"${composeDir}/kong-entrypoint.sh"
];
environment = { environment = {
POSTGRES_HOST = cfg.postgresHost; POSTGRES_HOST = cfg.postgresHost;
POSTGRES_PORT = toString cfg.postgresPort; POSTGRES_PORT = toString cfg.postgresPort;
+4 -4
View File
@@ -17,8 +17,8 @@
# Supabase compose stack (consumed on app-host by gebos-supabase.service) # Supabase compose stack (consumed on app-host by gebos-supabase.service)
supabase_postgres_password: ENC[AES256_GCM,data:OrjkfiumbB3uMD0tuDkgzaiLDgnt20wYOP9tBAyVwVUXQJxLiDypJoUb4wTY,iv:b4/Lep0Jzb6UtAhyOCpcetXgppul+V5O43zokMtpM2M=,tag:3fvi5xpverfSG+xc/4/HHg==,type:str] supabase_postgres_password: ENC[AES256_GCM,data:OrjkfiumbB3uMD0tuDkgzaiLDgnt20wYOP9tBAyVwVUXQJxLiDypJoUb4wTY,iv:b4/Lep0Jzb6UtAhyOCpcetXgppul+V5O43zokMtpM2M=,tag:3fvi5xpverfSG+xc/4/HHg==,type:str]
supabase_jwt_secret: ENC[AES256_GCM,data:csu0Rj/DM07DCtYFieIe6eEca+eUPR6KGfQBqa6cqRe7bX4=,iv:OKYFa7Y1bbEAKh1ueM7K8JDLldrTJL/r83AGTtMx/IU=,tag:y6OGVofm4cUFPc0BBmkY+A==,type:str] supabase_jwt_secret: ENC[AES256_GCM,data:csu0Rj/DM07DCtYFieIe6eEca+eUPR6KGfQBqa6cqRe7bX4=,iv:OKYFa7Y1bbEAKh1ueM7K8JDLldrTJL/r83AGTtMx/IU=,tag:y6OGVofm4cUFPc0BBmkY+A==,type:str]
supabase_anon_key: ENC[AES256_GCM,data:ReE4g5pNUHqi9M9ejnR88XUjwgaibcgZRM5GCMl+Ds7pzKUY442nqEtc2xVO2vQ=,iv:+lp/a/JshLZfdkxm4uQrgOKxBFmIduLtu3aUAhWrKlE=,tag:ft9swD2SeFJ/jMyL07kmGQ==,type:str] supabase_anon_key: ENC[AES256_GCM,data:piGlqQFvF2pVeL/IedL+/PS+5WO2k90zQooNTk9Q9RPUWtkyIYkZxe5kU8OqmharPO6RJbm6Ez5ly2Gk5NyzodQiEUW58wyCK+iXkduUThvtihQS1fh63ijuIPLwzZAdAjRDTY6vYrWWBnUpRELo5A0QCY4SF7KZUkZhCFN0KUHie2PHHzOPVb0koaacqc+K7A2L6Q/Ay0miLG7MXKNDkHsXdXDh9fhP6A==,iv:kq9wti3d1tugHj2p9BPKG42ZIRvvtvU6eGZhh9Ywq1w=,tag:qBMJbFJ1v95uUDWQ9kLb7g==,type:str]
supabase_service_role_key: ENC[AES256_GCM,data:QZQbvJ6rkyoHIpxIvjVDFwxeZ4oIb7Bt2h2vgtW8/sp6swSxtW/oAeVfLkdeduir+2BC5mFAhA==,iv:/F5F3MRC5Yebn68g45F1sj4d18+60JZupneHa3ipExs=,tag:fuzDuSj2npxKQxP2Pz+cjQ==,type:str] supabase_service_role_key: ENC[AES256_GCM,data:jGIHQVni8Cc7trwl89mQzN2p2g3ry3mNHxYtXHBcmSkpHp7AjsuypxS3W4yEdq96nNLCkbFeFdw+0X4vC8JIhHhl0NHbVb1eKOM/k9fL7q/nEWGqJGdMxoOytF/2L/NjQgzIFfK2L+YDZYUPaf6Ve0FKJ/awnRi8Csrof73PY5rtBrgrndbobYHEBlmzjv25l28kT3lwD4kaKW6ThnxUFmSAUFMwUd9maAdWm+47Vb1G4rTN,iv:N+XbGLnWqiDUfRl53KrFkY37kyyLCuU5sSU9aNHBYXI=,tag:qu9koOkum0m0F83/OgGC2g==,type:str]
supabase_dashboard_password: ENC[AES256_GCM,data:6KHJCPJ66dW9n/uzb3mpGIYqGZABHeEew1H5kx3I8kqgABqXcnHi6w==,iv:87NBfRoNOwtEKgGC0CacY/0xyVE5DQJpuN0X+7j8dfQ=,tag:TcMmIS2GgUlBXlH0elC7Rg==,type:str] supabase_dashboard_password: ENC[AES256_GCM,data:6KHJCPJ66dW9n/uzb3mpGIYqGZABHeEew1H5kx3I8kqgABqXcnHi6w==,iv:87NBfRoNOwtEKgGC0CacY/0xyVE5DQJpuN0X+7j8dfQ=,tag:TcMmIS2GgUlBXlH0elC7Rg==,type:str]
# Ingester (consumed on mqtt-ingest by gebos-ingester.service) # Ingester (consumed on mqtt-ingest by gebos-ingester.service)
ingester_postgres_password: ENC[AES256_GCM,data:6jy1G6zG4r0z3c9B6s7jPUtvn/Nr/J2HQN23ch6AGFigiVbefY3hCw==,iv:X/PWQjTW/++MVGsi+hcxbkOJZQMOUH0a4mRH5CbOw4A=,tag:TBKFIdbXZ2YzDLbjdnsyxg==,type:str] ingester_postgres_password: ENC[AES256_GCM,data:6jy1G6zG4r0z3c9B6s7jPUtvn/Nr/J2HQN23ch6AGFigiVbefY3hCw==,iv:X/PWQjTW/++MVGsi+hcxbkOJZQMOUH0a4mRH5CbOw4A=,tag:TBKFIdbXZ2YzDLbjdnsyxg==,type:str]
@@ -71,6 +71,6 @@ sops:
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
recipient: age190gu75rf3ra89mhk27xe3tv87tad087altqhugjlhkerqwe2jfqsnu738d recipient: age190gu75rf3ra89mhk27xe3tv87tad087altqhugjlhkerqwe2jfqsnu738d
encrypted_regex: ^(supabase_|ingester_|postgres_admin_|emqx_|porkbun_) encrypted_regex: ^(supabase_|ingester_|postgres_admin_|emqx_|porkbun_)
lastmodified: "2026-07-02T13:11:42Z" lastmodified: "2026-07-08T20:32:18Z"
mac: ENC[AES256_GCM,data:eJRwD6eGsmca+Gv3TP7JLSoCXlH34boilkxxEqRkbz8vNsCpEANZufet4Oes8vpNubU8yx509nrGo3SoC+MihvRaw/hfWqYOlXhlB0j6RsQuuG3LRQFVLJ1m1Q4zw5PYEcB/JHc4ORSIDrMb6nlemBc1dsDItIqyxlOqFnesUec=,iv:EBmK7XZ9ZDQT68FiItivu3QglhX0LfvMgw6n6qUhXUM=,tag:IqvQ4CVOrnWpQ3cE/mC/2w==,type:str] mac: ENC[AES256_GCM,data:56m8mOIai7eRKBXwcvWa0SLH6pH2LkL0zrRQ3nbfqe2qqn2qiji5dh4fzVVHmAKnXyob9HaRgRGBTnoi0L357MX+rzefSb0RTDXItsHkYSURYDZnRegEtszPpE5tlff/AlxtLXjs43P6nErkE263gzoFD2EFBwVlH6aufcX3fRo=,iv:eeGYKLRJqDq/ODHeyb6Ucn/K/drFNEJzKZGuAMEGGAU=,tag:qNo4uY4PV7C4MsYThvol3w==,type:str]
version: 3.13.1 version: 3.13.1
+50 -24
View File
@@ -1,40 +1,66 @@
# Supabase compose stack # Supabase compose stack
Vendored from the official Supabase self-hosting bundle, with two deliberate Vendored from the official self-hosting bundle
changes: (`docker/docker-compose.yml` in supabase/supabase), with deliberate changes:
1. The bundled `db` service is removed. Every service that talks to Postgres 1. **No `db` service.** Every service reads `POSTGRES_HOST` / `POSTGRES_PORT`
reads `POSTGRES_HOST` / `POSTGRES_PORT` from the environment and connects to from the environment and connects to the external `db-host`. The systemd
the external `db-host`. The systemd unit (`nix/modules/gebos-supabase.nix`) unit (`nix/modules/gebos-supabase.nix`) injects these; secrets come from
injects these from `/etc/gebos/secrets.env`. the sops-rendered env file (see `gebos-secrets.nix`).
2. Studio is bound to `127.0.0.1` only. There is **no public route** to it. 2. **Only auth + rest + studio (+ kong, meta).** Realtime, storage, imgproxy,
To use it from your laptop: edge functions, supavisor and the Logflare analytics stack are not
vendored — re-add from upstream when actually needed.
3. **Studio is bound to `127.0.0.1` only, with no Kong dashboard route.**
Upstream protects Studio with Kong basic-auth on a catch-all `/` route; we
drop that route entirely (`kong.yml` here), so there is **no public route**
to Studio and the api.gebos.online surface is only `/auth/v1`, `/rest/v1`,
`/graphql/v1` and the two `.well-known` endpoints. To use Studio:
``` ```
ssh -L 3000:127.0.0.1:3000 app-host ssh -L 3000:127.0.0.1:3000 deploy@app.gebos.online
open http://localhost:3000 open http://localhost:3000
``` ```
This is intentional — Studio runs with the `service_role` JWT and bypasses Studio itself has no login in this topology — possession of the SSH key is
RLS. Putting it on the public internet behind only HTTP basic auth (the the credential. (`supabase_dashboard_password` in secrets.yaml is currently
default) is too thin. unused; it becomes relevant only if Studio ever gets fronted by basic
auth again.)
4. **meta connects as `supabase_admin`, not `postgres`.** In the upstream
image `postgres`/`supabase_admin` is the superuser; on db-host the
`postgres` superuser keeps its own password (`postgres_admin_password`),
and the `gebos-postgres-passwords` oneshot makes `supabase_admin`
SUPERUSER with the shared `POSTGRES_PASSWORD` to match upstream semantics.
## What's in here ## What's in here
- `docker-compose.yml` — the stack itself - `docker-compose.yml` — the stack itself
- `init.sql` — schemas, roles, and extensions Postgres needs before the - `init.sql` — Supabase schemas, roles, and extensions Postgres needs before
compose services come up. Loaded by `nix/modules/gebos-postgres.nix`. the compose services come up. Runs once at first initdb via
- `kong.yml` — Kong's declarative routing (to be vendored alongside the compose `nix/modules/gebos-postgres.nix`. Application schema lives in
file when we copy it in). `supabase/migrations/` (applied manually — see `supabase/README.md`).
- `kong.yml` — Kong's declarative routing, trimmed to the services we run
- `kong-entrypoint.sh` — upstream helper, verbatim: substitutes `$VARS` into
kong.yml (Kong has no native env interpolation) and builds the
request-transformer Lua expressions
## Passwords
No service role has a password in any SQL file. The
`gebos-postgres-passwords` oneshot on db-host (see `gebos-postgres.nix`)
syncs them from sops on every boot/deploy:
| role | secret |
|-----------------------------------------------------|--------|
| `postgres` | `postgres_admin_password` |
| `supabase_admin`, `supabase_auth_admin`, `authenticator` | `supabase_postgres_password` (= `POSTGRES_PASSWORD` in the compose env) |
| `gebos_ingest` | `ingester_postgres_password` |
## Updating the vendored compose ## Updating the vendored compose
When upstream Supabase ships a new compose layout, re-vendor by: When upstream ships a new layout, re-vendor by copying the upstream files and
re-applying the deviations listed at the top of `docker-compose.yml` and
1. Copy upstream `docker/docker-compose.yml` over `docker-compose.yml`. `kong.yml` (both carry the list in their header comments). Then commit,
2. Remove the `db:` service block. deploy, and smoke-test Studio through the SSH tunnel.
3. Replace every `db:5432` / `postgres:5432` reference with
`${POSTGRES_HOST}:${POSTGRES_PORT}`.
4. Bind Studio to `${STUDIO_BIND}:3000` instead of `0.0.0.0:3000`.
5. Commit, deploy, smoke-test through the SSH tunnel.
+179 -35
View File
@@ -1,65 +1,209 @@
# Gebos — vendored Supabase compose stack. # Gebos — vendored Supabase compose stack.
# #
# TODO: copy the full official compose from # Vendored from the official self-hosting bundle
# https://github.com/supabase/supabase/blob/master/docker/docker-compose.yml # https://github.com/supabase/supabase/blob/master/docker/docker-compose.yml
# and apply the three modifications described in ./README.md: # with the deliberate deviations documented in ./README.md:
# - drop the `db` service
# - replace `db:5432` / `postgres:5432` with ${POSTGRES_HOST}:${POSTGRES_PORT}
# - bind Studio to ${STUDIO_BIND}:3000 (default 127.0.0.1)
# #
# The skeleton below names the services we'll keep and the env vars each one # * no `db` service — Postgres runs on db-host; every service connects to
# reads, so reviewers can sanity-check the topology before the real compose # ${POSTGRES_HOST}:${POSTGRES_PORT} and the `depends_on: db` blocks are
# lands. # dropped
# * no realtime / storage / imgproxy / functions / supavisor / analytics —
# the pilot needs auth + rest + studio only; re-vendor from upstream when
# one of them becomes needed
# * Studio binds to ${STUDIO_BIND}:3000 (loopback; reach it via SSH tunnel)
# * meta connects as supabase_admin, not `postgres`: upstream's `postgres`
# is its cluster superuser, but on db-host that role carries a different
# password. gebos-postgres-passwords makes supabase_admin SUPERUSER to
# match the upstream image's semantics.
#
# Where variables come from (see nix/modules/gebos-supabase.nix):
# * POSTGRES_HOST / POSTGRES_PORT / POSTGRES_DB / STUDIO_BIND — systemd
# unit `environment` (non-secret deployment config, nix-managed)
# * POSTGRES_PASSWORD / JWT_SECRET / ANON_KEY / SERVICE_ROLE_KEY —
# sops-rendered EnvironmentFile (secrets)
# * gebos constants (public URLs, org name) — hardcoded right here
name: gebos-supabase
services: services:
kong:
image: kong:2.8.1 studio:
container_name: supabase-studio
image: supabase/studio:2026.07.07-sha-a6a04f2
restart: unless-stopped restart: unless-stopped
ports: ports:
- "127.0.0.1:8000:8000/tcp" # Caddy proxies api.gebos.online here # Never 0.0.0.0 — Studio performs no authentication of its own in this
# topology (the upstream basic-auth lives on Kong's dashboard route,
# which we do not expose). Access = SSH tunnel to app-host.
- ${STUDIO_BIND}:3000:3000/tcp
healthcheck:
test:
[
"CMD-SHELL",
"node -e \"fetch('http://localhost:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})\""
]
timeout: 10s
interval: 5s
retries: 3
start_period: 20s
environment:
HOSTNAME: "0.0.0.0"
STUDIO_PG_META_URL: http://meta:8080
POSTGRES_HOST: ${POSTGRES_HOST}
POSTGRES_PORT: ${POSTGRES_PORT}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_USER_READ_WRITE: supabase_admin
PGRST_DB_SCHEMAS: public
DEFAULT_ORGANIZATION_NAME: Gebos
DEFAULT_PROJECT_NAME: Gebos
SUPABASE_URL: http://kong:8000
SUPABASE_PUBLIC_URL: https://api.gebos.online
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
AUTH_JWT_SECRET: ${JWT_SECRET}
# No Logflare/analytics stack in this deployment.
ENABLED_FEATURES_LOGS_ALL: "false"
kong:
container_name: supabase-kong
image: kong/kong:3.9.1
restart: unless-stopped
ports:
- 127.0.0.1:8000:8000/tcp # Caddy proxies api.gebos.online here
healthcheck:
test: ["CMD", "kong", "health"]
interval: 5s
timeout: 5s
retries: 5
volumes:
# The entrypoint substitutes $ENV_VARS in temp.yml and writes the result
# to KONG_DECLARATIVE_CONFIG (Kong has no native env interpolation).
- ./kong.yml:/home/kong/temp.yml:ro
- ./kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro
environment: environment:
KONG_DATABASE: "off" KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml
volumes: KONG_ROUTER_FLAVOR: expressions
- ./kong.yml:/home/kong/kong.yml:ro # https://github.com/supabase/cli/issues/14
KONG_DNS_ORDER: LAST,A,CNAME
KONG_DNS_NOT_FOUND_TTL: 1
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,post-function
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
KONG_PROXY_ACCESS_LOG: /dev/stdout combined
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
# Opaque sb_* API keys — not used yet. Empty values make the entrypoint
# fall back to legacy apikey pass-through and strip the empty
# credentials from the declarative config.
SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY:-}
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-}
ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-}
SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-}
entrypoint: ["/bin/sh", "/home/kong/kong-entrypoint.sh"]
auth: auth:
image: supabase/gotrue:v2.158.1 container_name: supabase-auth
image: supabase/gotrue:v2.189.0
restart: unless-stopped restart: unless-stopped
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://localhost:9999/health"
]
timeout: 5s
interval: 5s
retries: 3
environment: environment:
GOTRUE_DB_DRIVER: postgres GOTRUE_API_HOST: 0.0.0.0
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} GOTRUE_API_PORT: 9999
GOTRUE_SITE_URL: https://app.gebos.online
GOTRUE_JWT_SECRET: ${JWT_SECRET}
GOTRUE_JWT_EXP: "3600"
API_EXTERNAL_URL: https://api.gebos.online API_EXTERNAL_URL: https://api.gebos.online
GOTRUE_DB_DRIVER: postgres
# GoTrue owns the auth schema and runs its own migrations in it on
# startup — supabase_auth_admin's ownership of that schema is enforced
# by the gebos-postgres-passwords oneshot on db-host.
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
GOTRUE_SITE_URL: https://app.gebos.online
GOTRUE_URI_ALLOW_LIST: ""
# Tenants are invited by an admin (magic link), never self-signed-up.
GOTRUE_DISABLE_SIGNUP: "true"
GOTRUE_JWT_ADMIN_ROLES: service_role
GOTRUE_JWT_AUD: authenticated
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
GOTRUE_JWT_EXP: "3600"
GOTRUE_JWT_SECRET: ${JWT_SECRET}
GOTRUE_JWT_ISSUER: https://api.gebos.online
GOTRUE_EXTERNAL_EMAIL_ENABLED: "true"
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "false"
GOTRUE_MAILER_AUTOCONFIRM: "false"
# TODO: no SMTP provider configured yet. Magic links (UVI-NFR-08,
# passwordless-only) cannot send until these are filled in — add the
# SMTP secrets to nix/secrets/secrets.yaml and wire them through
# gebos-secrets.nix like the other supabase keys.
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL:-}
GOTRUE_SMTP_HOST: ${SMTP_HOST:-}
GOTRUE_SMTP_PORT: ${SMTP_PORT:-587}
GOTRUE_SMTP_USER: ${SMTP_USER:-}
GOTRUE_SMTP_PASS: ${SMTP_PASS:-}
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME:-Gebos}
GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify
GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify
GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify
GOTRUE_EXTERNAL_PHONE_ENABLED: "false"
GOTRUE_SMS_AUTOCONFIRM: "false"
rest: rest:
image: postgrest/postgrest:v12.0.2 container_name: supabase-rest
image: postgrest/postgrest:v14.12
restart: unless-stopped restart: unless-stopped
healthcheck:
test: [ "CMD", "postgrest", "--ready" ]
interval: 5s
timeout: 5s
retries: 3
environment: environment:
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
PGRST_DB_SCHEMAS: public PGRST_DB_SCHEMAS: public
PGRST_JWT_SECRET: ${JWT_SECRET} PGRST_DB_MAX_ROWS: 1000
PGRST_DB_EXTRA_SEARCH_PATH: public
PGRST_DB_ANON_ROLE: anon PGRST_DB_ANON_ROLE: anon
studio: PGRST_ADMIN_SERVER_PORT: 3001
image: supabase/studio:20240326-5e5586d PGRST_ADMIN_SERVER_HOST: localhost
restart: unless-stopped
ports: PGRST_JWT_SECRET: ${JWT_SECRET}
- "${STUDIO_BIND}:3000:3000/tcp" # never bind to 0.0.0.0 PGRST_DB_USE_LEGACY_GUCS: "false"
environment: PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
STUDIO_PG_META_URL: http://meta:8080 PGRST_APP_SETTINGS_JWT_EXP: "3600"
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} command:
DEFAULT_ORGANIZATION_NAME: Gebos [
SUPABASE_URL: https://api.gebos.online "postgrest"
DASHBOARD_USERNAME: gebos ]
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
meta: meta:
image: supabase/postgres-meta:v0.83.2 container_name: supabase-meta
image: supabase/postgres-meta:v0.96.6
restart: unless-stopped restart: unless-stopped
environment: environment:
PG_META_PORT: 8080
PG_META_DB_HOST: ${POSTGRES_HOST} PG_META_DB_HOST: ${POSTGRES_HOST}
PG_META_DB_PORT: ${POSTGRES_PORT} PG_META_DB_PORT: ${POSTGRES_PORT}
PG_META_DB_NAME: ${POSTGRES_DB} PG_META_DB_NAME: ${POSTGRES_DB}
-31
View File
@@ -48,34 +48,3 @@ do $$ begin
create role supabase_auth_admin login createrole; create role supabase_auth_admin login createrole;
grant usage on schema auth to supabase_auth_admin; grant usage on schema auth to supabase_auth_admin;
exception when duplicate_object then null; end $$; exception when duplicate_object then null; end $$;
-- Ingester writes telemetry; bypasses RLS because at write time no user is
-- authenticated. Trust boundary is the broker ACL ("who can publish to what
-- tenant's topic"), not the database.
do $$ begin
create role gebos_ingest login bypassrls;
exception when duplicate_object then null; end $$;
-- Telemetry hypertable. Tenant lives on the row so standard RLS works.
create table if not exists public.telemetry (
ts timestamptz not null,
tenant_id uuid not null,
device_id uuid not null,
metric text not null,
value double precision,
payload jsonb
);
select create_hypertable('public.telemetry', 'ts', if_not_exists => true);
create index if not exists telemetry_tenant_device_ts_idx
on public.telemetry (tenant_id, device_id, ts desc);
alter table public.telemetry enable row level security;
create policy telemetry_tenant_isolation on public.telemetry
for select
using (tenant_id = current_setting('app.tenant_id', true)::uuid);
grant select on public.telemetry to authenticated;
grant insert on public.telemetry to gebos_ingest;
+49
View File
@@ -0,0 +1,49 @@
#!/bin/sh
# Custom entrypoint for Kong that builds Lua expressions for request-transformer
# and performs environment variable substitution in the declarative config.
# Build Lua expressions for translating opaque API keys to asymmetric JWTs.
# When opaque keys are not configured (empty env vars), expressions fall through
# to legacy-only behavior - just passing apikey as-is.
#
# Full expression logic (when opaque keys are configured):
# 1. If Authorization header exists and is NOT an sb_ key -> pass through (user session JWT)
# 2. If apikey matches secret key -> set service_role asymmetric JWT internal "API key"
# 3. If apikey matches publishable key -> set anon asymmetric JWT internal "API key"
# 4. Fallback: pass apikey as-is (legacy HS256 JWT)
if [ -n "$SUPABASE_SECRET_KEY" ] && [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
# Opaque keys configured -> full translation expressions
export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == '$SUPABASE_SECRET_KEY' and 'Bearer $SERVICE_ROLE_KEY_ASYMMETRIC') or (headers.apikey == '$SUPABASE_PUBLISHABLE_KEY' and 'Bearer $ANON_KEY_ASYMMETRIC') or headers.apikey)"
# Realtime WebSocket: reads from query_params.apikey (supabase-js sends apikey
# via query string), outputs to x-api-key header which Realtime checks first.
export LUA_RT_WS_EXPR="\$((query_params.apikey == '$SUPABASE_SECRET_KEY' and '$SERVICE_ROLE_KEY_ASYMMETRIC') or (query_params.apikey == '$SUPABASE_PUBLISHABLE_KEY' and '$ANON_KEY_ASYMMETRIC') or query_params.apikey)"
else
# Legacy API keys, not sb_ API keys -> pass apikey through unchanged
export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)"
export LUA_RT_WS_EXPR="\$(query_params.apikey)"
fi
# Substitute environment variables in the Kong declarative config.
# Uses awk instead of eval/echo to preserve YAML quoting (eval strips double
# quotes, breaking "Header: value" patterns that YAML parses as mappings).
awk '{
result = ""
rest = $0
while (match(rest, /\$[A-Za-z_][A-Za-z_0-9]*/)) {
varname = substr(rest, RSTART + 1, RLENGTH - 1)
if (varname in ENVIRON) {
result = result substr(rest, 1, RSTART - 1) ENVIRON[varname]
} else {
result = result substr(rest, 1, RSTART + RLENGTH - 1)
}
rest = substr(rest, RSTART + RLENGTH)
}
print result rest
}' /home/kong/temp.yml > "$KONG_DECLARATIVE_CONFIG"
# Remove empty key-auth credentials (unconfigured opaque keys)
sed -i '/^[[:space:]]*- key:[[:space:]]*$/d' "$KONG_DECLARATIVE_CONFIG"
exec /entrypoint.sh kong docker-start
+176 -18
View File
@@ -1,48 +1,206 @@
_format_version: "2.1" _format_version: '2.1'
_transform: true
# Declarative Kong config — vanilla Supabase routing, no surprises. # Vendored from the official bundle (docker/volumes/api/kong.yml), with the
# Lives at /home/kong/kong.yml inside the container. # routes for services we don't run removed: realtime, storage, functions,
# analytics, pg-meta (/pg/), MCP, and — deliberately — the catch-all
# dashboard route + DASHBOARD basic-auth consumer. Studio is loopback-only
# behind an SSH tunnel and must never be reachable via api.gebos.online.
#
# $VARS are substituted by kong-entrypoint.sh at container start; empty
# credential lines (unconfigured opaque keys) are stripped there too.
###
### Consumers / Users
###
consumers: consumers:
- username: anon - username: anon
keyauth_credentials: keyauth_credentials:
- key: ${ANON_KEY} - key: $SUPABASE_ANON_KEY
- key: $SUPABASE_PUBLISHABLE_KEY
- username: service_role - username: service_role
keyauth_credentials: keyauth_credentials:
- key: ${SERVICE_ROLE_KEY} - key: $SUPABASE_SERVICE_KEY
- key: $SUPABASE_SECRET_KEY
###
### Access Control List
###
acls:
- consumer: anon
group: anon
- consumer: service_role
group: admin
###
### API Routes
###
services: services:
## Open Auth routes
- name: auth-v1-open
_comment: 'Auth: /auth/v1/verify* -> http://auth:9999/verify*'
url: http://auth:9999/verify
routes:
- name: auth-v1-open
strip_path: true
paths:
- /auth/v1/verify
plugins:
- name: cors
- name: auth-v1-open-callback
_comment: 'Auth: /auth/v1/callback* -> http://auth:9999/callback*'
url: http://auth:9999/callback
routes:
- name: auth-v1-open-callback
strip_path: true
paths:
- /auth/v1/callback
plugins:
- name: cors
- name: auth-v1-open-authorize
_comment: 'Auth: /auth/v1/authorize* -> http://auth:9999/authorize*'
url: http://auth:9999/authorize
routes:
- name: auth-v1-open-authorize
strip_path: true
paths:
- /auth/v1/authorize
plugins:
- name: cors
- name: auth-v1-open-jwks
_comment: 'Auth: /auth/v1/.well-known/jwks.json -> http://auth:9999/.well-known/jwks.json'
url: http://auth:9999/.well-known/jwks.json
routes:
- name: auth-v1-open-jwks
strip_path: true
paths:
- /auth/v1/.well-known/jwks.json
plugins:
- name: cors
## Secure Auth routes
- name: auth-v1 - name: auth-v1
_comment: 'Auth: /auth/v1/* -> http://auth:9999/*'
url: http://auth:9999/ url: http://auth:9999/
routes: routes:
- name: auth-v1-route - name: auth-v1-all
strip_path: true strip_path: true
paths: [ /auth/v1/ ] paths:
- /auth/v1/
plugins: plugins:
- name: cors - name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
- name: rest-v1 ## OpenAPI root - admin only
- name: rest-v1-openapi
_comment: 'PostgREST OpenAPI root: /rest/v1/ -> <http://rest:3000/> (admin only). See <https://github.com/orgs/supabase/discussions/42949>'
url: http://rest:3000/ url: http://rest:3000/
routes: routes:
- name: rest-v1-route - name: rest-v1-openapi-root
strip_path: true strip_path: true
paths: [ /rest/v1/ ] expression: 'http.path == "/rest/v1/"'
plugins: plugins:
- name: cors - name: cors
- name: key-auth - name: key-auth
config: config:
hide_credentials: true hide_credentials: false
key_names: [ apikey ] - name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- name: rpc ## Secure PostgREST routes
url: http://rest:3000/rpc/ - name: rest-v1
_comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*'
url: http://rest:3000/
routes: routes:
- name: rpc-route - name: rest-v1-all
strip_path: true strip_path: true
paths: [ /rpc/ ] paths:
- /rest/v1/
plugins: plugins:
- name: cors - name: cors
- name: key-auth - name: key-auth
config: config:
hide_credentials: true hide_credentials: false
key_names: [ apikey ] - name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Secure GraphQL routes
- name: graphql-v1
_comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql'
url: http://rest:3000/rpc/graphql
routes:
- name: graphql-v1-all
strip_path: true
paths:
- /graphql/v1
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Content-Profile: graphql_public"
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## OAuth 2.0 Authorization Server Metadata (RFC 8414)
- name: well-known-oauth
_comment: 'Auth: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server'
url: http://auth:9999/.well-known/oauth-authorization-server
routes:
- name: well-known-oauth
strip_path: true
paths:
- /.well-known/oauth-authorization-server
plugins:
- name: cors
+1
View File
@@ -0,0 +1 @@
v2.109.1
+84
View File
@@ -0,0 +1,84 @@
# Database migrations
Plain-SQL migrations in `migrations/`, applied **manually** with the supabase
CLI (available in the dev shell). There is deliberately no automated runner:
schema changes are rare, deliberate, admin-level operations.
The CLI records what has been applied in
`supabase_migrations.schema_migrations` on the target database, so pushing is
incremental and re-running it is safe.
## Applying to prod (db-host)
Postgres on db-host only accepts connections from the private network, so go
through an SSH tunnel:
```sh
# terminal 1 — tunnel to db-host's loopback
ssh -L 15432:127.0.0.1:5432 deploy@db.gebos.online
# terminal 2 — from the repo root, inside `nix develop`
PGPASS=$(sops -d --extract '["postgres_admin_password"]' nix/secrets/secrets.yaml)
supabase db push --db-url "postgres://postgres:${PGPASS}@127.0.0.1:15432/postgres"
```
The `postgres` superuser password is set from the same sops secret by the
`gebos-postgres-passwords` oneshot on db-host (see
`nix/modules/gebos-postgres.nix`), so it only works after the first deploy of
that unit.
## Adding a migration
```sh
supabase migration new <short_name> # creates migrations/<timestamp>_<short_name>.sql
```
Write plain SQL. Conventions:
* Migrations are ordered and applied exactly once — they do NOT need to be
idempotent (the schema-v1 file's `CREATE TYPE`s aren't).
* Cluster bootstrap (Supabase schemas/roles, extensions that need
`shared_preload_libraries`) belongs in `nix/supabase/init.sql`, not here.
* Role passwords never go in migrations — they are synced from sops by the
`gebos-postgres-passwords` unit.
## Local dev
`nix run .#dev` applies everything automatically: its `db-migrate` process
bootstraps the Supabase roles/schemas, runs `supabase db push`, and loads
`seed.sql` on every start (all idempotent — see
`nix/dev/process-compose.nix`). To re-apply by hand against the running dev
Postgres, do it as the local superuser (your OS user — `gebos_ingest` is only
*created* by the migration and has no DDL rights). The CLI ignores `sslmode`
in the URL, so TLS must be disabled via the environment:
```sh
PGSSLMODE=disable supabase db push --db-url "postgres://$USER@127.0.0.1:5432/gebos"
```
(The dev postgres ships TimescaleDB — the Apache edition, which covers
hypertables; TSL-only features like compression are absent locally.)
## Seed / fixture data (dev and tests only)
`seed.sql` fills the local database with a self-consistent fixture world:
2 buildings (gas / district heating), 8 apartments, tenants incl. a
Mieterwechsel and a vacancy, gateways, sensors (incl. one unassigned), ~13
months of daily cumulative measurements with realistic seasonality, and
approximate reference data (thresholds, CO2 factors, prices, HDD). The header
comment in the file documents every scenario and the fixed-UUID namespaces.
Apply it after the migrations, with a superuser (the seed writes tables the
`gebos_ingest` role may not):
```sh
psql -h 127.0.0.1 -p 5432 -d gebos -f supabase/seed.sql
```
It is idempotent (fixed UUIDs + `ON CONFLICT DO NOTHING`, deterministic
measurement generation) — re-running on a later day only appends the new
days of measurements.
**Never apply it to prod**: the tenants and devices are fake, and the
regulatory numbers are development approximations, not verified values from
the legal sources. Verified reference data belongs in its own migration.
+4
View File
@@ -0,0 +1,4 @@
# Minimal supabase CLI project marker. Only `supabase db push --db-url ...`
# is used here (manual migrations against db-host); local `supabase start`
# is not — the local dev stack is `nix run .#dev` (process-compose).
project_id = "gebos"
@@ -1,7 +1,15 @@
-- ============================================================================ -- ============================================================================
-- GebOS Schema V1 -- GebOS Schema V1 (initial Supabase migration)
-- Target: Postgres 17 + TimescaleDB -- Target: Postgres 17 + TimescaleDB
-- --
-- Applied MANUALLY via `supabase db push` — see supabase/README.md. There is
-- deliberately no automated migration runner. The CLI records applied
-- migrations in supabase_migrations.schema_migrations, so re-running push is
-- safe; the file itself is NOT idempotent (CREATE TYPE has no IF NOT EXISTS).
--
-- Cluster-level bootstrap (Supabase schemas/roles, extensions requiring
-- shared_preload_libraries) lives in nix/supabase/init.sql, not here.
--
-- Scope: UVI pilot per § 6a HeizkostenV, methodology per UBA-Leitfaden -- Scope: UVI pilot per § 6a HeizkostenV, methodology per UBA-Leitfaden
-- CLIMATE CHANGE 69/2021 (all six modules). Annual billing is a separate -- CLIMATE CHANGE 69/2021 (all six modules). Annual billing is a separate
-- epic and deliberately not modeled here. -- epic and deliberately not modeled here.
@@ -598,3 +606,37 @@ CREATE TABLE saving_tip (
-- * Heating-period vs. off-season behavior (UVI-FUN-14) — pure display -- * Heating-period vs. off-season behavior (UVI-FUN-14) — pure display
-- logic; the data model is season-agnostic. -- logic; the data model is season-agnostic.
-- =========================================================================== -- ===========================================================================
-- ===========================================================================
-- 6. INGEST ROLE & GRANTS
--
-- gebos_ingest is the MQTT ingester's login role — gebos-specific, NOT a
-- Supabase role. It bypasses RLS because at write time no end user is
-- authenticated; the trust boundary is the broker ACL ("who may publish to
-- which tenant's topic"), not the database. Created here rather than in
-- init.sql because its grants are coupled to the tables above, and because
-- init.sql only runs at first initdb while migrations always apply.
--
-- Its password is set out-of-band by the gebos-postgres-passwords oneshot
-- on db-host (sops secret: ingester_postgres_password).
-- ===========================================================================
DO $$ BEGIN
CREATE ROLE gebos_ingest LOGIN BYPASSRLS;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
GRANT USAGE ON SCHEMA public TO gebos_ingest;
-- Lookups the ingest/parse pipeline performs: gateway by IMEI, sensor by
-- oms_id, parser configuration by device type.
GRANT SELECT ON gateway, sensor, device_type, value_parser TO gebos_ingest;
-- Frame log: INSERT on arrival; SELECT + UPDATE for the parse worker's
-- queue query and its parse_status transitions.
GRANT SELECT, INSERT, UPDATE ON received_payload TO gebos_ingest;
-- Measurements are append-only from the ingester's point of view — dedup is
-- INSERT .. ON CONFLICT DO NOTHING against the composite PK, which needs no
-- UPDATE privilege.
GRANT SELECT, INSERT ON measurement TO gebos_ingest;
+412
View File
@@ -0,0 +1,412 @@
-- ============================================================================
-- GebOS Dev/test fixture data (supabase/seed.sql)
--
-- DEV ONLY. Never apply to prod: it contains fake tenants and fake devices,
-- and the regulatory reference numbers (efficiency class thresholds, CO2
-- factors, prices, HDD) are plausible APPROXIMATIONS for development, not
-- verified values from the legal sources. Real reference data ships as its
-- own migration when the numbers have been verified.
--
-- Apply with psql against the local process-compose stack (see
-- supabase/README.md), AFTER the schema migration:
--
-- psql -h 127.0.0.1 -p 5432 -d gebos -f supabase/seed.sql
--
-- Idempotent by construction: fixture rows carry fixed UUIDs (so tests can
-- reference them) and every INSERT ends in ON CONFLICT DO NOTHING. The
-- measurement series is anchored at a fixed start date (2025-06-01) and
-- generated deterministically (md5-based jitter, no random()), so re-running
-- on a later day recomputes identical values for existing days (skipped via
-- the composite PK) and appends only the new days — cumulative meter totals
-- stay monotonic across re-runs.
--
-- UUID namespaces (first group encodes the table, last digits the row):
-- dd... weather_station b0... building a0... apartment
-- e0... app_user 0c... occupancy 9e... gateway
-- d1... device_type f0... value_parser 5e... sensor
-- 7a... received_payload 5a... saving_tip
--
-- Scenario coverage (what each fixture exists to exercise):
-- * Building 1 (Berlin, gas, 5 apartments) — normal in-house spectrum.
-- * Building 2 (Potsdam, district heating, 3 apartments) — small-building
-- anonymity rule (UVI-FUN-09), second fuel type, and NO weather station
-- (weather footnote must render "not available", UVI-FUN-15).
-- * Apartment 3 — Mieterwechsel on 2026-03-01: comparisons across that
-- date must be suppressed (both months must fall in ONE occupancy row).
-- * Apartment 5 — vacant since 2026-05-01 (occupancy ended, none current).
-- * Apartment 4 — real heat meter (energy + flow/return temperatures +
-- error flags), not a heat cost allocator.
-- * Apartment 1 — hot water metered as ENERGY; all others as VOLUME
-- (both measurement kinds must work, see measurement_kind comment).
-- * Sensor 5e..17 — provisioned but unassigned (apartment_id NULL) with
-- measurements: data stored, tenant-invisible until assigned.
-- * received_payload — one row per parse_status, incl. an unknown sender.
-- * Consumption profiles span thrifty (~45 kWh/m²a) to heavy (~190),
-- so the Bandtacho classes and the in-house comparison spread out.
--
-- app_user note: in prod, app_user.id must equal auth.users.id (GoTrue).
-- The dev stack has no GoTrue yet, so these users have no auth identity —
-- when it lands, create auth users with these fixed UUIDs to log in as them.
-- ============================================================================
BEGIN;
-- ---------------------------------------------------------------------------
-- Reference: weather station (assigned to building 1 only — building 2
-- deliberately has none)
-- ---------------------------------------------------------------------------
INSERT INTO weather_station (id, dwd_id, name, lat, lng) VALUES
('dd000000-0000-0000-0000-000000000001', '00433', 'Berlin-Tempelhof', 52.467500, 13.402100)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Buildings & apartments
-- ---------------------------------------------------------------------------
INSERT INTO building (id, street, house_number, postal_code, city, lat, lng,
fuel_type, weather_station_id, note) VALUES
('b0000000-0000-0000-0000-000000000001',
'Fichtestraße', '12', '10967', 'Berlin', 52.489200, 13.417100,
'natural_gas', 'dd000000-0000-0000-0000-000000000001',
'Schlüsselkasten am Hoftor, Code 4711. Heizungskeller: Zugang über Hof.'),
('b0000000-0000-0000-0000-000000000002',
'Gartenweg', '3', '14482', 'Potsdam', 52.390800, 13.115600,
'district_heating', NULL,
'Kleine Anlage, 3 WE. Übergabestation im Keller rechts.')
ON CONFLICT DO NOTHING;
INSERT INTO apartment (id, building_id, label, living_area_m2) VALUES
('a0000000-0000-0000-0000-000000000001', 'b0000000-0000-0000-0000-000000000001', 'EG links', 54.00),
('a0000000-0000-0000-0000-000000000002', 'b0000000-0000-0000-0000-000000000001', 'EG rechts', 72.00),
('a0000000-0000-0000-0000-000000000003', 'b0000000-0000-0000-0000-000000000001', '1. OG links', 85.00),
('a0000000-0000-0000-0000-000000000004', 'b0000000-0000-0000-0000-000000000001', '1. OG rechts', 96.00),
('a0000000-0000-0000-0000-000000000005', 'b0000000-0000-0000-0000-000000000001', '2. OG', 110.00),
('a0000000-0000-0000-0000-000000000006', 'b0000000-0000-0000-0000-000000000002', 'WE 01', 60.00),
('a0000000-0000-0000-0000-000000000007', 'b0000000-0000-0000-0000-000000000002', 'WE 02', 75.00),
('a0000000-0000-0000-0000-000000000008', 'b0000000-0000-0000-0000-000000000002', 'WE 03', 88.00)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Users & occupancies
-- ---------------------------------------------------------------------------
INSERT INTO app_user (id, role, display_name) VALUES
('e0000000-0000-0000-0000-000000000001', 'admin', 'Anna Admin'),
('e0000000-0000-0000-0000-000000000002', 'tenant', 'Max Mustermann'),
('e0000000-0000-0000-0000-000000000003', 'tenant', 'Erika Beispiel'),
('e0000000-0000-0000-0000-000000000004', 'tenant', 'Otto Vormieter'),
('e0000000-0000-0000-0000-000000000005', 'tenant', 'Nina Neumieterin'),
('e0000000-0000-0000-0000-000000000006', 'tenant', 'Karl Konstant'),
('e0000000-0000-0000-0000-000000000007', 'tenant', 'Ferdinand Fortgezogen'),
('e0000000-0000-0000-0000-000000000008', 'tenant', 'Gerda Gartenweg'),
('e0000000-0000-0000-0000-000000000009', 'tenant', 'Hans Hofmann'),
('e0000000-0000-0000-0000-00000000000a', 'tenant', 'Ines Iser')
ON CONFLICT DO NOTHING;
INSERT INTO occupancy (id, apartment_id, user_id, valid_from, valid_to) VALUES
-- building 1
('0c000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000002', '2023-09-01', NULL),
('0c000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000002', 'e0000000-0000-0000-0000-000000000003', '2024-01-01', NULL),
-- Mieterwechsel in apartment 3: comparisons across 2026-03-01 are forbidden
('0c000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000003', 'e0000000-0000-0000-0000-000000000004', '2022-05-01', '2026-03-01'),
('0c000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000003', 'e0000000-0000-0000-0000-000000000005', '2026-03-01', NULL),
('0c000000-0000-0000-0000-000000000005', 'a0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000006', '2021-11-01', NULL),
-- apartment 5 vacant since 2026-05-01
('0c000000-0000-0000-0000-000000000006', 'a0000000-0000-0000-0000-000000000005', 'e0000000-0000-0000-0000-000000000007', '2023-02-01', '2026-05-01'),
-- building 2
('0c000000-0000-0000-0000-000000000007', 'a0000000-0000-0000-0000-000000000006', 'e0000000-0000-0000-0000-000000000008', '2020-01-01', NULL),
('0c000000-0000-0000-0000-000000000008', 'a0000000-0000-0000-0000-000000000007', 'e0000000-0000-0000-0000-000000000009', '2024-07-01', NULL),
('0c000000-0000-0000-0000-000000000009', 'a0000000-0000-0000-0000-000000000008', 'e0000000-0000-0000-0000-00000000000a', '2023-03-15', NULL)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Gateways (two in building 1: broadcast radio means overlapping reception)
-- ---------------------------------------------------------------------------
INSERT INTO gateway (id, imei, imsi, building_id, label, installation_note) VALUES
('9e000000-0000-0000-0000-000000000001', '861234050000011', '901405000000011',
'b0000000-0000-0000-0000-000000000001', 'GW Keller',
'Im Heizungskeller, über der Tür zum Anschlussraum.'),
('9e000000-0000-0000-0000-000000000002', '861234050000012', '901405000000012',
'b0000000-0000-0000-0000-000000000001', 'GW Dach',
'Trockenboden, am Kaminschacht montiert.'),
('9e000000-0000-0000-0000-000000000003', '861234050000013', '901405000000013',
'b0000000-0000-0000-0000-000000000002', 'GW Keller',
'Neben der Fernwärme-Übergabestation.')
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Device types & value parsers
-- ---------------------------------------------------------------------------
INSERT INTO device_type (id, manufacturer, model, medium) VALUES
('d1000000-0000-0000-0000-000000000001', 'QDS', 'Q caloric 5.5', '08'), -- heat cost allocator
('d1000000-0000-0000-0000-000000000002', 'EFE', 'SensoStar U', '04'), -- heat meter
('d1000000-0000-0000-0000-000000000003', 'EFE', 'SensoStar E', '06'), -- warm-water heat meter
('d1000000-0000-0000-0000-000000000004', 'ZRI', 'Minomess', '06') -- warm-water volume meter
ON CONFLICT DO NOTHING;
INSERT INTO value_parser (id, device_type_id, json_path, parser_instructions, kind, unit) VALUES
('f0000000-0000-0000-0000-000000000001', 'd1000000-0000-0000-0000-000000000001',
'$.records[0].value', '{"scale": 1}', 'heating_energy', 'kWh'),
('f0000000-0000-0000-0000-000000000002', 'd1000000-0000-0000-0000-000000000002',
'$.records[?(@.type=="energy")].value', '{"scale": 1}', 'heating_energy', 'kWh'),
('f0000000-0000-0000-0000-000000000003', 'd1000000-0000-0000-0000-000000000002',
'$.records[?(@.type=="flow_temp")].value', '{"scale": 1}', 'flow_temperature', '°C'),
('f0000000-0000-0000-0000-000000000004', 'd1000000-0000-0000-0000-000000000002',
'$.records[?(@.type=="return_temp")].value', '{"scale": 1}', 'return_temperature', '°C'),
('f0000000-0000-0000-0000-000000000005', 'd1000000-0000-0000-0000-000000000002',
'$.status', NULL, 'error_flags', 'flags'),
('f0000000-0000-0000-0000-000000000006', 'd1000000-0000-0000-0000-000000000003',
'$.records[?(@.type=="energy")].value', '{"scale": 1}', 'hot_water_energy', 'kWh'),
('f0000000-0000-0000-0000-000000000007', 'd1000000-0000-0000-0000-000000000004',
'$.records[0].value', '{"scale": 0.001}', 'hot_water_volume', 'm3')
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Sensors
-- 0108: heating, apartments 18 (apartment 4 has the real heat meter)
-- 0916: hot water, apartments 18 (apartment 1 energy, the rest volume)
-- 17: provisioned but unassigned
-- ---------------------------------------------------------------------------
INSERT INTO sensor (id, oms_id, device_type_id, apartment_id, decrypt_key, installed_at, installation_note) VALUES
('5e000000-0000-0000-0000-000000000001', '71000001', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', '000102030405060708090a0b0c0d0e01', '2025-04-14', 'HKV Wohnzimmer, einziger Heizkörper.'),
('5e000000-0000-0000-0000-000000000002', '71000002', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000002', '000102030405060708090a0b0c0d0e02', '2025-04-14', 'HKV Wohnzimmer.'),
('5e000000-0000-0000-0000-000000000003', '71000003', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000003', '000102030405060708090a0b0c0d0e03', '2025-04-14', 'HKV Schlafzimmer.'),
('5e000000-0000-0000-0000-000000000004', '71000004', 'd1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000004', '000102030405060708090a0b0c0d0e04', '2025-04-15', 'Wärmemengenzähler im Flurschacht.'),
('5e000000-0000-0000-0000-000000000005', '71000005', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000005', '000102030405060708090a0b0c0d0e05', '2025-04-15', 'HKV Küche.'),
('5e000000-0000-0000-0000-000000000006', '71000006', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000006', '000102030405060708090a0b0c0d0e06', '2025-05-06', 'HKV Wohnzimmer.'),
('5e000000-0000-0000-0000-000000000007', '71000007', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000007', '000102030405060708090a0b0c0d0e07', '2025-05-06', 'HKV Wohnzimmer.'),
('5e000000-0000-0000-0000-000000000008', '71000008', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000008', '000102030405060708090a0b0c0d0e08', '2025-05-06', 'HKV Bad.'),
('5e000000-0000-0000-0000-000000000009', '71000009', 'd1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', '000102030405060708090a0b0c0d0e09', '2025-04-14', 'WW-Wärmezähler unter der Spüle.'),
('5e000000-0000-0000-0000-000000000010', '71000010', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000002', '000102030405060708090a0b0c0d0e10', '2025-04-14', 'WW-Zähler Bad, Vorwandinstallation.'),
('5e000000-0000-0000-0000-000000000011', '71000011', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000003', '000102030405060708090a0b0c0d0e11', '2025-04-14', 'WW-Zähler Bad.'),
('5e000000-0000-0000-0000-000000000012', '71000012', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000004', '000102030405060708090a0b0c0d0e12', '2025-04-15', 'WW-Zähler Küche.'),
('5e000000-0000-0000-0000-000000000013', '71000013', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000005', '000102030405060708090a0b0c0d0e13', '2025-04-15', 'WW-Zähler Bad.'),
('5e000000-0000-0000-0000-000000000014', '71000014', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000006', '000102030405060708090a0b0c0d0e14', '2025-05-06', 'WW-Zähler Bad.'),
('5e000000-0000-0000-0000-000000000015', '71000015', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000007', '000102030405060708090a0b0c0d0e15', '2025-05-06', 'WW-Zähler Bad.'),
('5e000000-0000-0000-0000-000000000016', '71000016', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000008', '000102030405060708090a0b0c0d0e16', '2025-05-06', 'WW-Zähler Bad.'),
-- provisioned but not yet assigned to an apartment (normal pilot state)
('5e000000-0000-0000-0000-000000000017', '71000017', 'd1000000-0000-0000-0000-000000000001', NULL, '000102030405060708090a0b0c0d0e17', '2025-04-15', 'Lager: noch nicht montiert / zugeordnet.')
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Raw frame log: one row per parse_status, including a frame from a meter
-- that is not ours (unknown_sensor — normal on a broadcast band).
-- ---------------------------------------------------------------------------
INSERT INTO received_payload (id, gateway_id, sensor_id, timestamp_sent, timestamp_received, raw_payload, parse_status) VALUES
('7a000000-0000-0000-0000-000000000001', '9e000000-0000-0000-0000-000000000001', '5e000000-0000-0000-0000-000000000001',
now() - interval '2 hours', now() - interval '2 hours',
'{"telegram": "2e44685071000001080c7a...", "decoded": {"manufacturer": "QDS", "serial": "71000001", "records": [{"type": "energy", "unit": "kWh", "value": 2841.7}]}}',
'parsed'),
('7a000000-0000-0000-0000-000000000002', '9e000000-0000-0000-0000-000000000002', '5e000000-0000-0000-0000-000000000002',
now() - interval '30 minutes', now() - interval '29 minutes',
'{"telegram": "2e44685071000002080c7a...", "decoded": {"manufacturer": "QDS", "serial": "71000002", "records": [{"type": "energy", "unit": "kWh", "value": 7404.2}]}}',
'pending'),
('7a000000-0000-0000-0000-000000000003', '9e000000-0000-0000-0000-000000000001', NULL,
now() - interval '1 hour', now() - interval '59 minutes',
'{"telegram": "2e44a51199887766060c7a...", "decoded": {"manufacturer": "LSE", "serial": "99887766", "records": [{"type": "volume", "unit": "m3", "value": 481.2}]}}',
'unknown_sensor'),
('7a000000-0000-0000-0000-000000000004', '9e000000-0000-0000-0000-000000000003', '5e000000-0000-0000-0000-000000000014',
now() - interval '3 hours', now() - interval '1 hour', -- buffered upload: sent ≪ received
'{"telegram": "1e44e5c871000014060c7a...", "decoded": null, "error": "decryption failed: wrong key length"}',
'error')
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Measurements: daily cumulative meter readings from 2025-06-01 to today.
--
-- Values are cumulative totals (meters broadcast counters, consumption is
-- computed by differencing at query time). Daily increment = annual total
-- × month weight ÷ days-in-month, ± 10% deterministic jitter (md5 of
-- sensor/kind/day — NOT random(), so re-runs are byte-identical). Heating
-- follows a seasonal curve; hot water is flat across the year.
--
-- Annual heating totals encode the per-m² profiles the Bandtacho needs:
-- apt1 45 kWh/m²a (thrifty) … apt4 140 … apt5 190 kWh/m²a (heavy).
-- ---------------------------------------------------------------------------
WITH weights (m, wt) AS (
VALUES (1, 0.170), (2, 0.140), (3, 0.120), (4, 0.080), (5, 0.040), (6, 0.020),
(7, 0.015), (8, 0.015), (9, 0.040), (10, 0.080), (11, 0.120), (12, 0.160)
),
profiles (sensor_id, kind, unit, annual, start_value, seasonal) AS (
VALUES
-- heating (kWh/yr = profile kWh/m²a × living area)
('5e000000-0000-0000-0000-000000000001'::uuid, 'heating_energy'::measurement_kind, 'kWh', 2430.0, 3210.0, true),
('5e000000-0000-0000-0000-000000000002'::uuid, 'heating_energy'::measurement_kind, 'kWh', 6120.0, 5480.0, true),
('5e000000-0000-0000-0000-000000000003'::uuid, 'heating_energy'::measurement_kind, 'kWh', 8500.0, 9120.0, true),
('5e000000-0000-0000-0000-000000000004'::uuid, 'heating_energy'::measurement_kind, 'kWh', 13440.0, 21930.0, true),
('5e000000-0000-0000-0000-000000000005'::uuid, 'heating_energy'::measurement_kind, 'kWh', 20900.0, 15040.0, true),
('5e000000-0000-0000-0000-000000000006'::uuid, 'heating_energy'::measurement_kind, 'kWh', 4200.0, 2660.0, true),
('5e000000-0000-0000-0000-000000000007'::uuid, 'heating_energy'::measurement_kind, 'kWh', 7125.0, 4310.0, true),
('5e000000-0000-0000-0000-000000000008'::uuid, 'heating_energy'::measurement_kind, 'kWh', 10560.0, 7750.0, true),
-- hot water: apartment 1 as energy, the rest as volume
('5e000000-0000-0000-0000-000000000009'::uuid, 'hot_water_energy'::measurement_kind, 'kWh', 1620.0, 1890.0, false),
('5e000000-0000-0000-0000-000000000010'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 28.8, 214.3, false),
('5e000000-0000-0000-0000-000000000011'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 34.0, 131.9, false),
('5e000000-0000-0000-0000-000000000012'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 38.4, 356.0, false),
('5e000000-0000-0000-0000-000000000013'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 44.0, 102.4, false),
('5e000000-0000-0000-0000-000000000014'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 24.0, 188.7, false),
('5e000000-0000-0000-0000-000000000015'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 30.0, 77.5, false),
('5e000000-0000-0000-0000-000000000016'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 35.2, 240.1, false),
-- unassigned sensor: measured and stored regardless
('5e000000-0000-0000-0000-000000000017'::uuid, 'heating_energy'::measurement_kind, 'kWh', 5000.0, 0.0, true)
),
days AS (
SELECT d::date AS d
FROM generate_series(date '2025-06-01', current_date, interval '1 day') AS d
),
daily AS (
SELECT p.sensor_id, p.kind, p.unit, dy.d, p.start_value,
p.annual
* CASE WHEN p.seasonal THEN w.wt ELSE 1.0 / 12 END
/ extract(day FROM date_trunc('month', dy.d) + interval '1 month - 1 day')
* (0.9 + 0.2 * ((('x' || substr(md5(p.sensor_id::text || p.kind::text || dy.d::text), 1, 8))::bit(32)::int & 1023) / 1023.0))
AS increment
FROM profiles p
CROSS JOIN days dy
JOIN weights w ON w.m = extract(month FROM dy.d)::int
)
INSERT INTO measurement (sensor_id, measured_at, kind, value, unit)
SELECT sensor_id,
-- per-sensor stable time-of-day, so series don't all tick in lockstep
d::timestamp + interval '6 hours'
+ make_interval(mins => (('x' || substr(md5(sensor_id::text), 1, 4))::bit(16)::int % 50)),
kind,
round((start_value + sum(increment) OVER (PARTITION BY sensor_id, kind ORDER BY d))::numeric, 3),
unit
FROM daily
ON CONFLICT DO NOTHING;
-- Diagnostics from the apartment-4 heat meter: flow/return temperatures
-- (seasonal: ~69/50 °C in January, ~44/32 °C in July) and an error register
-- that is 0 except for one flagged day.
WITH weights (m, wt) AS (
VALUES (1, 0.170), (2, 0.140), (3, 0.120), (4, 0.080), (5, 0.040), (6, 0.020),
(7, 0.015), (8, 0.015), (9, 0.040), (10, 0.080), (11, 0.120), (12, 0.160)
),
days AS (
SELECT d::date AS d
FROM generate_series(date '2025-06-01', current_date, interval '1 day') AS d
)
INSERT INTO measurement (sensor_id, measured_at, kind, value, unit)
SELECT '5e000000-0000-0000-0000-000000000004'::uuid,
dy.d::timestamp + interval '6 hours 15 minutes',
k.kind,
round((k.base + k.span * w.wt
+ 2 * ((('x' || substr(md5(k.kind::text || dy.d::text), 1, 8))::bit(32)::int & 255) / 255.0))::numeric, 1),
'°C'
FROM days dy
JOIN weights w ON w.m = extract(month FROM dy.d)::int
CROSS JOIN (VALUES ('flow_temperature'::measurement_kind, 42.0, 160.0),
('return_temperature'::measurement_kind, 30.0, 120.0)) AS k (kind, base, span)
ON CONFLICT DO NOTHING;
INSERT INTO measurement (sensor_id, measured_at, kind, value, unit) VALUES
('5e000000-0000-0000-0000-000000000004', date '2026-02-10' + interval '6 hours 15 minutes', 'error_flags', 0, 'flags'),
('5e000000-0000-0000-0000-000000000004', date '2026-02-11' + interval '6 hours 15 minutes', 'error_flags', 16, 'flags'), -- e.g. air in flow sensor
('5e000000-0000-0000-0000-000000000004', date '2026-02-12' + interval '6 hours 15 minutes', 'error_flags', 0, 'flags')
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Reference data — DEV APPROXIMATIONS, not verified legal values.
-- ---------------------------------------------------------------------------
-- Efficiency class thresholds: GEG Anlage 10 annual bounds split into monthly
-- bounds using the same seasonal weights as the heating fixtures. The real
-- UBA CC 69/2021 Tabelle 1 values differ — replace via a verified migration.
WITH classes (class, annual_max) AS (
VALUES ('A+', 30.0), ('A', 50.0), ('B', 75.0), ('C', 100.0),
('D', 130.0), ('E', 160.0), ('F', 200.0), ('G', 250.0)
),
weights (m, wt) AS (
VALUES (1, 0.170), (2, 0.140), (3, 0.120), (4, 0.080), (5, 0.040), (6, 0.020),
(7, 0.015), (8, 0.015), (9, 0.040), (10, 0.080), (11, 0.120), (12, 0.160)
)
INSERT INTO efficiency_class_threshold (class, month, max_kwh_per_m2, source)
SELECT c.class, w.m, round((c.annual_max * w.wt)::numeric, 3),
'DEV FIXTURE — approximation of UBA CC 69/2021 Tab. 1 / GEG Anl. 10'
FROM classes c CROSS JOIN weights w
UNION ALL
SELECT 'H', w.m, 999.999, -- open-ended class: sentinel, not a real bound
'DEV FIXTURE — approximation of UBA CC 69/2021 Tab. 1 / GEG Anl. 10'
FROM weights w
ON CONFLICT DO NOTHING;
-- CO2 factors: one superseded natural-gas row to exercise versioning.
INSERT INTO co2_emission_factor (fuel_type, g_co2_per_kwh, valid_from, valid_to, source) VALUES
('natural_gas', 202.00, '2021-01-01', '2023-12-31', 'DEV FIXTURE — approximates GEG Anlage 9'),
('natural_gas', 240.00, '2024-01-01', NULL, 'DEV FIXTURE — approximates GEG Anlage 9'),
('heating_oil', 310.00, '2024-01-01', NULL, 'DEV FIXTURE — approximates GEG Anlage 9'),
('district_heating', 180.00, '2024-01-01', NULL, 'DEV FIXTURE — approximates GEG Anlage 9'),
('heat_pump_electricity', 560.00, '2024-01-01', NULL, 'DEV FIXTURE — approximates GEG Anlage 9'),
('wood_pellets', 20.00, '2024-01-01', NULL, 'DEV FIXTURE — approximates GEG Anlage 9'),
('other', 300.00, '2024-01-01', NULL, 'DEV FIXTURE — placeholder')
ON CONFLICT DO NOTHING;
-- Energy prices: one superseded natural-gas row to exercise versioning.
INSERT INTO energy_price (fuel_type, ct_per_kwh, valid_from, valid_to, source, created_by) VALUES
('natural_gas', 11.200, '2024-01-01', '2025-12-31', 'DEV FIXTURE', 'e0000000-0000-0000-0000-000000000001'),
('natural_gas', 12.400, '2026-01-01', NULL, 'DEV FIXTURE', 'e0000000-0000-0000-0000-000000000001'),
('heating_oil', 10.900, '2026-01-01', NULL, 'DEV FIXTURE', 'e0000000-0000-0000-0000-000000000001'),
('district_heating', 15.800, '2026-01-01', NULL, 'DEV FIXTURE', 'e0000000-0000-0000-0000-000000000001'),
('heat_pump_electricity', 27.500, '2026-01-01', NULL, 'DEV FIXTURE', 'e0000000-0000-0000-0000-000000000001'),
('wood_pellets', 8.200, '2026-01-01', NULL, 'DEV FIXTURE', 'e0000000-0000-0000-0000-000000000001')
ON CONFLICT DO NOTHING;
-- Heating degree days: the 15 complete months before the current month.
-- The CURRENT month is deliberately absent — the freshest footnote renders
-- "not available" until the (future) monthly DWD import fills it, exactly
-- like prod. Deterministic per (year, month), so re-runs insert nothing new
-- for existing months and exactly one row when a month completes.
WITH base (m, hdd0) AS (
VALUES (1, 550), (2, 480), (3, 400), (4, 280), (5, 140), (6, 40),
(7, 15), (8, 20), (9, 110), (10, 280), (11, 420), (12, 520)
),
months AS (
SELECT (date_trunc('month', current_date) - make_interval(months => n))::date AS mon
FROM generate_series(1, 15) AS n
)
INSERT INTO heating_degree_days (station_id, year, month, hdd)
SELECT 'dd000000-0000-0000-0000-000000000001',
extract(year FROM mon)::smallint,
extract(month FROM mon)::smallint,
round((b.hdd0 * (0.90 + 0.2 * ((('x' || substr(md5(mon::text), 1, 8))::bit(32)::int & 255) / 255.0)))::numeric, 1)
FROM months
JOIN base b ON b.m = extract(month FROM mon)::int
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Savings tips (Module 4) — one retired tip to exercise active = false.
-- ---------------------------------------------------------------------------
INSERT INTO saving_tip (id, category, title, body, link_url, active) VALUES
('5a000000-0000-0000-0000-000000000001', 'heating', 'Stoßlüften statt Kipplüften',
'Mehrmals täglich 510 Minuten mit weit geöffnetem Fenster lüften, statt das Fenster dauerhaft zu kippen. So bleibt die Wärme in Wänden und Möbeln erhalten.',
'https://www.verbraucherzentrale.de/wissen/energie/heizen-und-warmwasser', true),
('5a000000-0000-0000-0000-000000000002', 'heating', 'Raumtemperatur um 1 °C senken',
'Ein Grad weniger spart rund 6 % Heizenergie. 20 °C im Wohnzimmer und 1718 °C im Schlafzimmer reichen in der Regel aus.',
'https://www.co2online.de/energie-sparen/heizenergie-sparen/', true),
('5a000000-0000-0000-0000-000000000003', 'heating', 'Heizkörper entlüften',
'Gluckert der Heizkörper oder wird er nur teilweise warm, ist Luft im System. Entlüften stellt die volle Heizleistung wieder her.',
NULL, true),
('5a000000-0000-0000-0000-000000000004', 'heating', 'Heizkörper nicht zustellen',
'Möbel und Vorhänge vor dem Heizkörper stauen die Wärme und erhöhen den Verbrauch.',
NULL, false), -- retired tip: must remain resolvable for past months
('5a000000-0000-0000-0000-000000000005', 'hot_water', 'Duschen statt Baden',
'Ein Vollbad benötigt etwa dreimal so viel warmes Wasser wie eine kurze Dusche.',
'https://www.verbraucherzentrale.de/wissen/energie/heizen-und-warmwasser', true),
('5a000000-0000-0000-0000-000000000006', 'hot_water', 'Sparduschkopf einbauen',
'Ein Sparduschkopf halbiert den Warmwasserverbrauch beim Duschen — bei gleichem Komfort.',
'https://www.co2online.de/energie-sparen/strom-sparen/', true),
('5a000000-0000-0000-0000-000000000007', 'hot_water', 'Warmwasser nicht laufen lassen',
'Beim Einseifen, Zähneputzen und Spülen das warme Wasser abstellen.',
NULL, true)
ON CONFLICT DO NOTHING;
COMMIT;