# Knead Time

[Knead Time](<https://kneadtime.pizza>) is a static pizza-dough calculator on GitHub Pages. Its step reminders cannot come from the page — iOS wakes a web app's service worker only for a server-sent push — so this is the one server it has: a small FastAPI service that holds a phone's push subscription and the times of its hands-on steps, and sends a Web Push notification when each comes due. The source and the image live in the [knead-time](<https://github.com/JanWelker/knead-time/tree/main/push>) repository, so like Flowscape it deploys as plain manifests around our own image, per [Conventions → Official upstream sources only](<https://homelab-apps.wlkr.ch/conventions/#2-official-upstream-sources-only>).

## At a glance

|  |  |
| --- | --- |
| URL | [kneadtime.k8s.wlkr.ch](<https://kneadtime.k8s.wlkr.ch/v1/vapid>), an API under `/v1`; the app that calls it is [kneadtime.pizza](<https://kneadtime.pizza>) |
| Authentication | None. Browsers on kneadtime.pizza call it directly; CORS admits that origin only |
| Storage | None on the pod |
| Database | CloudNativePG `Cluster` `kneadtime-db` |
| Dials | The browsers' push services, over HTTPS |
| Files | [`kneadtime/`](<https://github.com/JanWelker/homelab-apps/tree/main/kneadtime>) |

## State

| State | Where | Rebuildable? |
| --- | --- | --- |
| Push subscriptions and the reminders queued for them | PostgreSQL, via CloudNativePG | Only by every phone tapping **Remind me** again; a lost database means one missed bake, not a lost account |
| The VAPID private key | OpenBao `kv/kneadtime/config`, through an ExternalSecret | No — rotating it retires every subscription taken with the old key |

## Configuration

| Setting | Why |
| --- | --- |
| Plain manifests, no chart | One process, one port, one database; the [service README](<https://github.com/JanWelker/knead-time/blob/main/push/README.md>) lists the environment and that is all there is |
| The namespace enforces `restricted` | The image runs as uid 65532, writes nothing and needs no capability; CloudNativePG's pods declare restricted-compliant contexts of their own |
| One replica, the default `RollingUpdate` | The dispatcher that sends due reminders runs inside the process and claims due rows with `FOR UPDATE SKIP LOCKED`, so the two copies that overlap during a rollout never send the same reminder; nothing on the pod needs a `Recreate` gap |
| A `Cluster`, not SQLite on a PVC | [Conventions → PostgreSQL is always a CloudNativePG Cluster](<https://homelab-apps.wlkr.ch/conventions/#3-postgresql-is-always-a-cloudnativepg-cluster>); `DATABASE_URL` is the `uri` key the operator writes to `kneadtime-db-app` |
| `VAPID_PRIVATE_KEY` from OpenBao | A P-256 key in PEM, generated by `make bao-secrets` because a `Password` generator cannot produce one, which is why [rule 7](<https://homelab-apps.wlkr.ch/conventions/#7-real-secrets-come-from-openbao>)'s `PushSecret` path does not apply; the service derives the public half at startup and serves it, so the static site carries no key |
| `VAPID_SUBJECT` is the site URL | The contact a push service may use; a URL rather than a mailbox keeps an address out of a manifest |
| `ALLOWED_ORIGINS` is the site | Only pages loaded from `kneadtime.pizza` may call the API from a browser; PR previews of the app live under the same origin |
| `FORWARDED_ALLOW_IPS` is the pod CIDR | Envoy is the only thing that reaches the pod, and it appends the client's address to `X-Forwarded-For`; uvicorn trusts that header from the pod network only, so the service's per-address rate limit ([`ratelimit.py`](<https://github.com/JanWelker/knead-time/blob/main/push/kneadtime_push/ratelimit.py>)) counts the phone, not the Gateway |
| `HTTPRoute` targets the app's own Service | Nobody logs in — browsers call the API on behalf of a phone that already gave its subscription. The Dependency-Track shape, not the Flowscape one |
| `HTTPRoute` matches `/v1` only | The app serves its API, its probes and `/metrics` on one port; the browser calls only `/v1/vapid` and `/v1/schedules` (`src/lib/push/api.ts` in the app repository), and the counters describe usage that is nobody else's business |
| `revisionHistoryLimit: 2` | Superseded ReplicaSets keep their Trivy config-audit reports until garbage collection; the default keeps ten |

### Network policy

The rules in `kneadtime/networkpolicy.yaml`, and why each is there. The shape is [Conventions → Ship a `CiliumNetworkPolicy`](<https://homelab-apps.wlkr.ch/conventions/#5-ship-a-ciliumnetworkpolicy>).

| Rule | Why |
| --- | --- |
| Ingress from the namespace, from `host` and `remote-node`; egress to the namespace | The rules every policy carries: [What every policy contains](<https://homelab.wlkr.ch/platform/security-policies/#what-every-policy-contains>) |
| Ingress on 8080 from `ingress` | The Gateway reaches the pod directly; there is no outpost to skip |
| Ingress from Prometheus on 8080 and 9187, `GET /metrics` only | The app's counters and the database's exporter |
| Ingress from `cnpg-system` on 8000 | The operator reads the instance manager's status |
| Egress to the four push-service names on 443 | Apple, Google, Mozilla and Microsoft are where subscriptions live. The service refuses any other host at request time, so this list and the code's allow-list are one decision taken twice |
| The instance pods reach `kube-apiserver` | The CloudNativePG instance manager reports its status there. The object store for WAL and base backups is the platform's `cnpg-backups` policy, see [CloudNativePG → Backups](<https://homelab.wlkr.ch/platform/cloudnative-pg/#backups>) |

### Secrets

`kv/kneadtime/config` holds one key, `vapid-private-key`, written by `make bao-secrets` in the platform repository. Overwriting it is gated behind a typed confirmation there: the push services answer 401 or 403 for every subscription taken with the old key, the service deletes them, and every phone has to opt in again.

## Usage

Nothing to operate by hand. A phone with the app on its Home Screen taps **Remind me on this device** in the plan's menu; the receipt notification that arrives at once is the end-to-end check.

```bash
curl -s https://kneadtime.k8s.wlkr.ch/v1/vapid     # the public key, from outside the LAN
```

## Health check

```bash
kubectl -n kneadtime get pods,cluster,externalsecret,httproute
kubectl -n kneadtime port-forward svc/kneadtime 8080:80 &
curl -s localhost:8080/metrics | grep kneadtime_push
```

`kneadtime_push_sent_total` moves when a reminder goes out; `gone_total` counts subscriptions the push services refused.

## Pitfalls

- **The image has to be public on GHCR.** The cluster has no pull secret for `ghcr.io/janwelker`; a version published while the package was private stays `ImagePullBackOff` until the package's visibility is changed.
- **`readyz` is the database.** A pod that is up but not ready is a database that is not reachable; check the `Cluster` before the pod.
- **Nothing here sends on its own schedule.** A reminder that did not arrive is either a subscription the push service retired (`gone_total` rose, the phone has to opt in again) or a phone whose Home Screen icon was removed, which drops the subscription on the device side.
