Skip to content

Multi-space backend: serve N spaces from one instance - #10

Merged
michelangelomo merged 9 commits into
mainfrom
feat/multi-space
Apr 21, 2026
Merged

Multi-space backend: serve N spaces from one instance#10
michelangelomo merged 9 commits into
mainfrom
feat/multi-space

Conversation

@michelangelomo

Copy link
Copy Markdown
Member

Summary

Refactor the backend from single-tenant to multi-tenant so one instance can serve both Pescara and the new L'Aquila space without duplicating the deploy. Each space gets its own API key, Telegram chat/thread, and SpaceAPI metadata.

  • URL shape: /s/{slug}/{status,toggle,stats,spaceapi.json,ui}. The bare legacy paths (/status, /toggle, /stats, /spaceapi.json, /ui) are kept as aliases for DEFAULT_SPACE_SLUG=pescara so
    the deployed ESP32 button and the sede.olografix.org-pinned MCP server keep working with zero changes.
  • Config: a config/spaces.yaml (loaded at boot, upserted into the DB on slug) describes every space — API key, Telegram target, SpaceAPI fields. Secrets support $ENV_VAR interpolation. See
    backend/deploy/spaces.example.yaml.
  • Upgrade path: if spaces.yaml is missing, the app synthesises a single space from the legacy API_KEY + TELEGRAM_* env vars and backfills any sede_statuses rows with space_id = 0 onto it —
    existing prod comes up untouched.
  • DB: adds Space model + SpaceID on SedeStatus with composite index (space_id, timestamp). All status/stats queries are scoped per-space.
  • Auth: per-space bcrypt-hashed key; keys are not interchangeable between spaces.
  • Telegram: Dispatcher.Send(chatID, threadID, msg) — one bot client, per-call target.

michelangelomo and others added 9 commits April 21, 2026 18:11
Scaffolding for the upcoming multi-space refactor. Defaults are
config/spaces.yaml and "pescara" so existing single-space deploys
keep the same external behaviour once the rest of the refactor lands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Parses the YAML file whose path is in SpacesConfigPath, resolves
$ENV_VAR references in api_key fields, and validates required fields,
unique slugs, and lat/lon range. Exposes a LegacySpaceFromConfig
helper that synthesises a single-space definition from the legacy
API_KEY + TELEGRAM_* env vars with the previously-hardcoded Metro
Olografix SpaceAPI metadata, for the zero-config upgrade path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Introduces a Space entity (slug, name, SpaceAPI metadata, bcrypted API
key, per-space Telegram target) and a SpaceID foreign-key column on
sede_statuses with a composite (space_id, timestamp) index.

Repository methods are now scoped: GetLatestStatus, CreateStatus,
GetStatistics and GetWeeklyStats all take (or carry) a space_id; added
GetSpaceBySlug, ListSpaces, UpsertSpace (OnConflict upsert keyed on
slug), and BackfillDefaultSpaceID for the legacy-row migration.

Handlers currently pass a placeholder tempDefaultSpaceID (1). App
bootstrap will replace that with the real default-space ID in a
follow-up commit; router/middleware will inject the resolved space
from the URL one commit after that.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Send now takes chatID/threadID arguments so one bot client can notify
multiple spaces. Empty token or chatID == 0 are treated as no-ops so
callers don't branch on "telegram not configured".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
NewApp now reads spaces.yaml, bcrypts each per-space API key, upserts
every entry into the spaces table, and builds an in-memory slug->Space
map for hot-path lookups. When the YAML file is missing, a single
space is synthesised from the legacy API_KEY + TELEGRAM_* env vars so
existing single-space deployments upgrade with no config changes.
Legacy sede_statuses rows (space_id = 0) are backfilled onto the
default space. Handlers now resolve the default space via
a.defaultSpace.ID; the per-slug resolver arrives in the next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Every handler now reads the resolved *Space from the request context.
Two router middlewares feed it: resolveDefaultSpace for the legacy
bare routes (/status, /stats, /spaceapi.json, /toggle) and
resolveSpaceFromPath for the new /s/:slug/* group. authMiddleware
bcrypt-checks X-API-KEY against the space's stored hash, so one
space's key cannot unlock another's toggle. SpaceAPI fields are built
from the space row; toggleStatus sends Telegram to the space's
chat/thread and prefixes the message with the space name.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… path

- ui/index.html derives the stats URL from location.pathname so
  /s/{slug}/ui fetches /s/{slug}/stats while legacy /ui still hits
  /stats.
- deploy/docker-compose.yaml mounts ./config read-only so operators
  can drop a spaces.yaml alongside the DB volume.
- deploy/spaces.example.yaml shows the two-space shape with $VAR
  interpolation for secrets.
- README documents the new /s/{slug}/... surface and the legacy alias.
- .gitignore keeps real spaces.yaml out of the repo; only the example
  is committed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GetWeeklyStats left result as a nil slice when no rows matched, which
json.Marshal encodes as null. The UI calls data.find(...) and breaks
on null. Initialise the slice so the response is always [] and add a
regression test that asserts the raw JSON body.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- docker-compose.dev.yaml builds from the local Dockerfile, mounts
  the UI and deploy/spaces.example.yaml into /app/config, and sets
  dummy per-space API keys so the whole surface can be exercised
  with docker compose -f docker-compose.dev.yaml up --build.
- Bump Dockerfile base to golang:1.24 — go.mod now requires 1.24.0.
- Add L'Aquila as a third example space in spaces.example.yaml.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@codecov

codecov Bot commented Apr 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.84615% with 66 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
backend/internal/app/app.go 74.57% 8 Missing and 7 partials ⚠️
backend/internal/app/router.go 68.18% 11 Missing and 3 partials ⚠️
backend/internal/app/handlers.go 72.72% 8 Missing and 4 partials ⚠️
backend/internal/notification/telegram.go 47.05% 9 Missing ⚠️
backend/internal/database/database.go 87.27% 5 Missing and 2 partials ⚠️
backend/cmd/server/cmd/root.go 0.00% 6 Missing ⚠️
backend/internal/config/spaces.go 96.38% 2 Missing and 1 partial ⚠️
Files with missing lines Coverage Δ
backend/internal/config/config.go 100.00% <100.00%> (ø)
backend/internal/config/spaces.go 96.38% <96.38%> (ø)
backend/cmd/server/cmd/root.go 0.00% <0.00%> (ø)
backend/internal/database/database.go 86.82% <87.27%> (-0.87%) ⬇️
backend/internal/notification/telegram.go 44.44% <47.05%> (-19.20%) ⬇️
backend/internal/app/handlers.go 56.72% <72.72%> (-7.19%) ⬇️
backend/internal/app/router.go 72.52% <68.18%> (-6.72%) ⬇️
backend/internal/app/app.go 63.82% <74.57%> (+14.99%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@michelangelomo
michelangelomo merged commit 95ea6bf into main Apr 21, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant