Skip to content

Repository files navigation

tesserae-api

Public JSON API server for Tesserae widgets, served at https://api.tesserae.ink.

v1 ships a single endpoint: a channel-aware version check. The structure is laid out so further endpoints (widget catalog, almanac, space events, and later multi-user features) slot in as new routers without a rewrite.

License: AGPL-3.0-or-later.

Architecture

[ Internet ]
     |
     v 443/tcp
[ Caddy (systemd) ]  auto-TLS, per-IP rate limit, access logs with no IP / no User-Agent
     |
     | reverse proxy -> 127.0.0.1:8000
     v
[ tesserae-api container (docker compose) ]
     |-- FastAPI + uvicorn (:8000)
     |-- /data volume  <- version_cache.json
     |-- GeoLite2 mmdb  (baked into the image at build time)
     |
     v  postgresql (container network)
[ postgres container ]  aggregate stats, named volume, 127.0.0.1:5432 (SSH tunnel only)

[ systemd timer: tesserae-api-poll.timer ]  every 15 min
     |
     v
[ docker exec tesserae-api python -m scripts.poll_github ]
     -> refreshes /data/version_cache.json from GitHub

The application never calls GitHub on the request path. A systemd timer polls GitHub every 15 minutes and writes version_cache.json and firmware_cache.json; requests are served entirely from those caches. If GitHub is unreachable at poll time the previous cache is left untouched, so the API keeps serving the last known good value.

Layout

src/tesserae_api/
  main.py                  FastAPI app factory
  config.py                settings (paths, repo slug, GitHub token)
  routes/version.py        GET /version/latest
  routes/firmware.py       GET /firmware/{kind}/latest
  cache/github_releases.py version polling, cache read/write, channel resolution
  cache/firmware.py        firmware release polling by descriptor asset, resolution
  stats/collector.py       aggregate writes (SQLAlchemy: SQLite dev, Postgres prod)
  stats/geo.py             MaxMind GeoLite2 lookup
scripts/
  poll_github.py           run by the systemd timer (version + firmware)
  dump_stats.py            maintainer's stats reader

Endpoint contract

GET /version/latest

Query parameters:

Param Required Notes
channel no stable (default), main, or edge.
current no Caller's running version. Stable: SemVer (0.69.18). Main: <ver>+<sha> or bare main. Edge: a pre-release tag.
install no Client-generated UUID v4, stored opaquely against the stats record. Omit for no cross-day dedup.

Response headers: Cache-Control: public, max-age=300 and Access-Control-Allow-Origin: *.

stable / edge (edge resolves to the latest prerelease: true release):

{
  "channel": "stable",
  "current": "0.69.18",
  "latest": {
    "version": "0.69.19",
    "released_at": "2026-07-08T09:15:00Z",
    "url": "https://github.com/dmellok/tesserae/releases/tag/v0.69.19",
    "notes_headline": "Some fix"
  },
  "is_current": false,
  "versions_behind": 1
}

main:

{
  "channel": "main",
  "current": "0.69.18+abc1234",
  "latest": {
    "sha": "def5678",
    "committed_at": "2026-07-08T10:00:00Z",
    "url": "https://github.com/dmellok/tesserae/commit/def5678",
    "message_headline": "Commit subject line"
  },
  "is_current": false,
  "commits_behind": 5
}

When current is omitted (or unparseable), is_current and versions_behind / commits_behind are null. If the cache has not been populated yet the endpoint returns 503 with Cache-Control: no-store and a Retry-After header.

GET /firmware/{kind}/latest

Latest published firmware for a device kind, so a Tesserae server can show an "update available" badge. The source of truth is the newest published (non-draft, non-prerelease) release of the firmware repo (dmellok/tesserae-device-firmware) that carries a descriptor-<kind>.json asset; if the newest release does not cover the kind, older releases are walked until one does. Kinds are therefore discovered from the release assets, not a config file. Releases are polled and cached to disk (authenticated with a GitHub token).

Query parameter: current (optional) is the caller's running firmware version, recorded for the version distribution; it does not change the response. Response header: Cache-Control: public, max-age=300.

{
  "latest": {
    "version": "1.6.0",
    "released_at": "2026-07-22T21:37:48Z",
    "url": "https://github.com/dmellok/tesserae-device-firmware/releases/tag/v1.6.0",
    "notes_headline": "Safe Wi-Fi OTA for E1004",
    "assets": [
      { "name": "descriptor-seeed_reterminal_e1004.json", "url": "https://.../descriptor-seeed_reterminal_e1004.json", "size": 611, "content_type": "application/json" }
    ],
    "descriptor_url": "https://.../descriptor-seeed_reterminal_e1004.json"
  }
}

version is the release tag with any leading v stripped (plain semver). descriptor_url is the matching descriptor asset (for one-click import). An unknown kind, or one no release covers, returns 404 with an empty body (the client treats any non-2xx as "no data"), so 404 is the normal "nothing to report" signal, never a 500. Adding a new device kind is a release with its descriptor-<kind>.json asset, no code or config change here.

Telemetry is aggregate only: counts per (day, kind, reported version, coarse country) in firmware_check_stats. The caller IP is used for the country lookup then discarded; no IP, no install id, and no per-request row is retained.

POST /widgets/install

Records one widget-install event, called server-to-server by an app backend when a user installs a widget from the marketplace. JSON body:

{ "widget": "spotify", "install": "<uuid>", "version": "0.93.0" }

widget is required (a catalog id); install is the app-level install UUID (opaque, dedupe key); version is the app's Tesserae version (optional). Missing widget returns 400. On success the endpoint returns 204 No Content with Cache-Control: no-store. The write is best-effort and never fails the response. Same privacy posture as the other endpoints: coarse geo from the IP, then the IP is discarded; no IP or User-Agent is stored.

GET /widgets/installs

Unique install counts per widget for the Browse UI (Cache-Control: public, max-age=300):

{ "counts": { "spotify": 42, "weather_now": 130 } }

Counts are COUNT(DISTINCT install_uuid) grouped by widget (rows with a NULL install UUID are excluded from the distinct count). ?widget=<id> returns { "widget": "<id>", "count": <int> }.

POST /heartbeat

Called about once a day by an app backend (server-to-server) to report an install's presence and environment. JSON body (all fields optional):

{
  "install": "<uuid>", "version": "0.94.2", "channel": "stable",
  "os": "linux", "arch": "arm64", "py": "3.12", "deploy": "docker",
  "transport": "rest", "devices": "2-3", "companion": "2-3",
  "device_kinds": [
    {"kind": "pimoroni_inky_4", "fw_version": "1.3.1"},
    {"kind": "waveshare_spectra6_13", "fw_version": "2.0.0"}
  ],
  "ha": true
}

device_kinds accepts either objects {"kind", "fw_version"} or bare slug strings (in which case the firmware version is unknown). fw_version is a bare semver (one optional leading v is tolerated); non-semver values are stored as unknown. The latest firmware per (install, day, kind) wins on re-ping, and feeds the per-kind firmware map.

Returns 204 No Content with Cache-Control: no-store. Two privacy properties are specific to the heartbeat:

  • Only a day is stored, never a timestamp. Heartbeat timing cannot become a sub-daily presence trace.
  • Idempotent per (install, day). Multiple pings on the same day upsert into one row (the descriptive columns reflect the latest ping), so restarts do not inflate counts.

Enum fields are coerced to their allowed sets rather than rejected: an unrecognised value becomes other (os/arch/py) or unknown (channel/deploy/transport/devices/companion). devices and companion share the bucket vocabulary {0, 1, 2-3, 4-9, 10+} (companion is a bucketed count of paired companion apps). device_kinds keeps only ^[a-z0-9_-]{1,64}$ slugs, deduped and capped at 32. Same geo posture as the other endpoints: the IP is used for a coarse country/region lookup then discarded. Install-level heartbeat rows are not exposed publicly; the maintainer reads aggregates via scripts/dump_stats.py.

Interactive OpenAPI docs are served at /docs.

Aggregate stats collected

One row per served request. Storage is a SQLAlchemy URL: a local SQLite file in development, PostgreSQL in production (the postgres service in docker-compose). Set TESSERAE_DATABASE_URL to switch; unset defaults to sqlite:///data/stats.db.

CREATE TABLE hits (
  ts TIMESTAMPTZ NOT NULL,    -- DATETIME under SQLite
  install_uuid TEXT,          -- client-generated UUID, NULL if the client omitted it
  country TEXT,               -- coarse geo from MaxMind, IP discarded after lookup
  region TEXT,
  channel TEXT,               -- version endpoint: requested channel
  kind TEXT,                  -- firmware endpoint: device kind
  current_version TEXT
);

Both update-check endpoints record into hits. /version/latest sets channel; /firmware/{kind}/latest sets kind. Both honour the same optional install UUID contract.

Widget installs use a separate widget_installs table (ts, widget_id, install_uuid, tesserae_version, country, region), written by POST /widgets/install and counted by GET /widgets/installs. The hits table is untouched by the widget path.

Heartbeats use heartbeats (keyed UNIQUE(day, install_uuid), upserted) and heartbeat_kinds (UNIQUE(day, install_uuid, kind), with a fw_version column for the reported firmware version per kind). These store a day (a Date), never a timestamp, and are written by POST /heartbeat. The hits and widget_installs tables are untouched by the heartbeat path.

Firmware checks (GET /firmware/{kind}/latest) write only to firmware_check_stats, an aggregate-only counter keyed UNIQUE(day, kind, version, country) with a count column. There is no per-request row, no IP, and no install id: the endpoint upserts a count and stores a coarse country only.

What is deliberately not collected, ever:

  • No IP addresses. The caller IP is used only for the GeoLite2 lookup and is discarded before the DB write.
  • No User-Agent strings.
  • Caddy access logs record only method, path, status, size, and duration. No IP, no User-Agent.

install_uuid is generated by the client on first install and stored in its own config, so it is stable across requests and lets unique installs be deduped across days for retention analysis. If a user resets their widget config they get a new UUID and count as a new install. That is expected.

How to read stats

Summary reader (talks to whatever TESSERAE_DATABASE_URL points at):

# On the VPS (uses the container's Postgres config):
docker exec tesserae-api python -m scripts.dump_stats
# Or against an explicit URL:
python -m scripts.dump_stats --database-url postgresql+psycopg://tesserae:PW@localhost:5432/tesserae

Prints: total unique installs, unique installs by country, version distribution, channel distribution, retention (installs seen within the last 7 / 30 / 90 days), and new installs in the last 7 days (first-seen UUIDs).

Remote / ODBC access

Postgres listens only on the VPS loopback and is never exposed publicly. Reach it from your machine over an SSH tunnel, then point psql or any ODBC/BI tool at localhost:5432:

ssh -N -L 5432:127.0.0.1:5432 deploy@api.tesserae.ink   # leave running
psql "host=localhost port=5432 dbname=tesserae user=tesserae"

See BOOTSTRAP.md for the ODBC DSN details. The password is in /opt/tesserae-api/.env on the VPS.

Local development

uv sync
uv run ruff format --check .
uv run ruff check .
uv run pytest -q
# Run the server locally (writes to ./data):
uv run uvicorn tesserae_api.main:app --reload
uv run python -m scripts.poll_github   # populate ./data/version_cache.json

Deployment

See BOOTSTRAP.md for first-time VPS setup. docker-compose.yml is copied to /opt/tesserae-api/ and Caddyfile to /etc/caddy/Caddyfile on the server.

CI/CD

Three GitHub Actions workflows:

  • ci.yml - on every push / PR: ruff format --check, ruff check, pytest.
  • release.yml - on a v* tag: build the image, bake in a fresh GeoLite2 mmdb, push to ghcr.io/dmellok/tesserae-api:<tag> and :latest, SSH-deploy to the VPS, then smoke-test https://api.tesserae.ink/version/latest. The workflow fails if the smoke test does not return 200.
  • geoip-refresh.yml - Sundays 06:00 UTC: rebuild :latest with a fresh mmdb and redeploy, keeping GeoIP data current without a code release.

Deploy runs automatically on tag. To require manual approval instead, add a GitHub Environment named production with a required reviewer and set environment: production on the deploy job in release.yml.

Trigger a manual redeploy

# On the VPS, as the deploy user:
cd /opt/tesserae-api
docker compose pull && docker compose up -d

Or re-run the release.yml deploy job from the GitHub Actions tab. A weekly image refresh also runs automatically.

Rotate secrets

Secrets live in the repo's Settings -> Secrets and variables -> Actions:

  • DEPLOY_SSH_KEY - deploy user's private SSH key. To rotate: generate a new keypair, add the new public key to ~deploy/.ssh/authorized_keys on the VPS, update the secret, confirm a deploy succeeds, then remove the old public key from the VPS.
  • DEPLOY_HOST, DEPLOY_USER - VPS host / deploy user.
  • MAXMIND_LICENSE_KEY - MaxMind license key for the GeoLite2 download. To rotate: create a new key in the MaxMind account, update the secret, then revoke the old key. The next release or weekly refresh picks it up.

For the GHCR login the deploy user uses on the VPS, generate a new GitHub PAT with read:packages and re-run docker login ghcr.io as shown in BOOTSTRAP.md.

The database password is not a GitHub secret; it lives in /opt/tesserae-api/.env on the VPS as POSTGRES_PASSWORD. To rotate it, update the password in Postgres (ALTER ROLE tesserae WITH PASSWORD ...), edit the .env, and docker compose up -d to restart the API with the new value.

Image visibility

The published ghcr.io/dmellok/tesserae-api image is public, so any Tesserae install can pull it without authentication and docker compose pull works unattended. To run your own copy of the API, follow BOOTSTRAP.md against your own VPS and point your widgets at your own host.

If you prefer to keep the image private (repo public, image private), the deploy user needs a GHCR login on the VPS. See BOOTSTRAP.md step 6.

About

Public JSON API server for Tesserae widgets (api.tesserae.ink)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages