Repository navigation
Platform Specification
Snapshot of
docs/platform/spec.mdat commit707b4a0, preserved before the docs were compacted. History only: the current specs are in the repository.
On hold (2026-10-29, ADR 0007). Platform development is halted until further notice. New app and firmware work does not take the platform into account. The existing code, the API contract and the app ↔ platform protocol stay in place unchanged.
Status: draft (2026-10-19), PL0 implemented, PL1 and PL2 implemented (API) · Decisions: ADR 0003, ADR 0004
The ReactPod platform is the online part of ReactPod: accounts, clubs and teams, sharing of programs, presets and sets, and the training results of athletes. The Config App stays the editor and the only thing that talks to pods; the platform stores, shares and analyses.
Everything a client may do on the platform goes through one documented API. The Config App, the Python tools, third-party apps and AI clients (via MCP) are all clients of that API and get exactly the rights of the user they act for.
Program format and block semantics: programs.md. Pod API: config-api.md. App: spec.md.
| # | Feature |
|---|---|
| G1 | Accounts without passwords: email magic link and passkeys |
| G2 | Clubs (organisations) and teams with roles, invitations, managed athletes |
| G3 | Programs: publish from the app, versions, fork with attribution, search |
| G4 | Presets: named parameter sets for a program (difficulty, timing, colours) |
| G5 | Sets: a menu with its programs, shareable and installable to a pod via the app |
| G6 | Training sessions and results per athlete, statistics, team leaderboards |
| G7 | Pod sets registered to a user or a club (what firmware, last sync) |
| G8 | AI clients connect over MCP with the user's rights; writes become drafts |
| G9 | GDPR: EU hosting, data export, account deletion, minors protected by default |
- Payments, subscriptions, paid tiers.
- Social feed, chat, comments on programs (only ratings and reports).
- Remote control of pods over the internet. Pods never talk to the platform directly; the app is always in between.
- Public athlete profiles. Results are never public by default.
- Training plans and webhooks (designed here, built in a later phase, §14).
| Persona | Typical use |
|---|---|
| Hobby user | Downloads programs and presets, shares own programs, tracks own times |
| Coach | Runs sessions with a team, picks presets, looks at athlete progress |
| Club admin | Creates the club, teams, invites coaches, manages athletes and pod sets |
| Athlete (adult) | Own account, sees own results, may join several teams |
| Managed athlete | Usually a minor: profile without login, owned by the club, results entered by the coach's app |
| Maker / developer | Uses the API with a personal access token, writes tools |
| AI client | Claude or another agent, acting for one user with a scoped token (§7) |
Roles are per organisation (club); a user can have different roles in different clubs. A personal space (no club) is an organisation of one, so every resource has exactly one owner organisation.
| Role | Can |
|---|---|
| owner | everything in the club, delete the club, transfer ownership |
| admin | members, teams, athletes, pod sets, club content |
| coach | sessions and results for the teams they coach, presets, club programs (draft + publish to club) |
| athlete | own results, club content readable |
| viewer | club content readable (e.g. parents, assistants) |
| platform admin | moderation, abuse handling (not part of a club) |
Team-level: a membership can be limited to teams (coach of team U12 only).
Every shareable resource (program, preset, set) has a visibility:
private (owner) · team · org (club) · public (everyone, also without login).
Sessions and results know only private, team and org; leaderboards inside a club can be
switched on per team.
-
Sign up / sign in by email: one mail with a 6-digit code and a link into the app
(
#/login?token=…); both work once and for 15 minutes, a code for 5 attempts. The code helps when the link opens in another browser than the installed app. Always answers 202, also for unknown addresses. Rate limits: 5 requests per address and 20 per IP per hour. -
Session tokens: opaque bearer tokens (30 days), stored hashed, sent as
Authorization: Bearer …. No cookies: the app runs on another origin than the API. -
Passkeys (WebAuthn): postponed until the app's own domain is decided (decision P13); a
passkey is bound to the domain, so registering them on
runningjoke.github.iowould break with a move. Several passkeys per user (phone, laptop). - Sessions: list of signed-in devices, sign out one or all.
- Profile: display name, avatar (optional), language (en / de), units.
- Data export (JSON + programs as .zip) and account deletion (results of managed athletes stay with the club; own content can be transferred or deleted).
- No passwords are stored anywhere.
- Create a club (name, country, optional logo). The creator is the owner.
- Invite by email or by link (role and teams preset, expiry, max uses).
- Teams inside a club (e.g. "U12", "First team"), members per team.
- Managed athletes: profiles without login (name or nickname, birth year, team). A managed athlete can later be claimed by a user account (invitation from the club); the results move with it.
- Leave a club, remove members, transfer ownership. A club without an owner is not possible.
- Publish from the app: the program JSON exactly as on the pod (programs.md), plus title, description, tags, number of pods, difficulty, language, licence.
- The platform validates with the same rules and issue codes as the app (shared package, §5.3). Invalid programs cannot be published, only saved as draft.
-
Revisions: every save creates a numbered revision with the file text exactly as on the pod
(
sha256of the text). Revisions are never changed; publishing marks one draft as the published version readers get. Presets and sets point to a revision, so they never change under their users. Readers see published revisions, editors also drafts. - Fork: copy into the own space with a link to the source revision (attribution shown).
-
Includes: only the shipped library parts (
/prog/lib/*) may be included; the platform serves them itself. Other includes are flattened in the app first ("convert to editable copy"), the API answersinclude_not_shipped(decision P17). - Who edits: coaches and above of the owning club (team-limited coaches: team items of their teams); private items only their author. Deleting hides a program; its revisions stay for the presets and sets that use them.
- Search and browse: text (title, description), tags (any of), pod count, club; sort by new, rating or title; offset paging. Anonymous callers find public published programs only.
- Rating (1–5) and report (abuse, broken, licence). Reported content can be hidden by platform admins.
- Licence: CC BY 4.0 by default for public content; the author can choose CC0.
A preset is a named, reusable configuration of a program revision: the same game with other
numbers. Programs already have variables (initial integer values, programs.md);
a preset overrides some of them.
{ "name": "Sprint – U12 easy",
"program": { "id": "…", "revision": 3 },
"variables": { "MAX_LOOPS": 8, "TIMEOUT_MS": 2500 },
"target": { "pods": 4, "ageGroup": "U12", "level": "easy" },
"notes": "Warm-up, 2 rounds" }- Only variables that exist in the program (with its library includes) can be overridden.
-
GET /presets/{id}/programrenders the program file with the changedvariables, named after the preset; the app pushes it like any local program (app spec §9). No firmware change needed. - Presets made by AI clients are drafts (visible to their creator only) until a person sets
status: ready. Draft presets cannot go into sets. - Presets belong to a user or a club and have a visibility like programs.
- Naming: the app spec (§7.2) uses "preset" for a library include. To avoid two meanings, that app term is renamed to "library part" when the app integrates the platform (PL2).
A set is what a pod set runs: a menu (order, menu LED patterns) plus the programs, the same
layout as the .zip export of the app and firmware/data/. On the platform a set item points to
a program revision or a preset.
- Publish the app's local menu as a set; install a set into the app's workspace, then push.
-
GET /sets/{id}/filesreturns the pod files:/programs.json, every program (presets applied, under a path made from the preset name), and the library parts they include. JSON, not a .zip: the app zips it with its existing set export. Entries the caller can no longer read are skipped and listed. - Club sets: "Hall A standard set" shared with all coaches of the club.
- A pod set is registered from the app with its
setId, name and firmware version (frominfo/status, config-api §5). Owner: a user or a club. - The platform shows which set and firmware a pod set had at its last sync. It never controls pods; OTA files stay on Pages (ADR 0002).
flowchart LR
POD["Pod set (central)"] -- "results / RESULT lines" --> APP["Config App (offline queue)"]
APP -- "POST /v1/orgs/{orgId}/training/upload" --> API["Platform API"]
API --> DB[("Postgres")]
COACH["Coach / athlete / AI client"] -- "stats, leaderboards" --> API
The pod side is epic #37: BLE stays on
during the game, and a communicate block sends a report with everything recorded since the
last one (#41), tagged with the
athlete the app set (#42). The app
stores reports per athlete (#47) and
uploads them (#48).
-
Training session: one athlete on one pod set running one program, from the moment the app
assigns the athlete until it changes the athlete or the program. It holds the athlete, the pod
set id (
setId), the program's pod path, and optionally the platform revision / preset it came from (the app knows them from its install cache) with the content hash, start time and notes. -
Report (the upload unit, P21): one per
communicateblock, as the firmware sends it: pod report id, cycle,startMs/endMs, single values{ t, metric, pod, ms }, the firmware's stats per metric and the reported variables. Reports followed by arepeatare markeddiscarded: they are kept, but not counted. - Offline queue: the app keeps reports in IndexedDB and uploads when online. Each session and report carries an id the app generates (UUID); a repeated upload stores nothing twice (P22). The pod's report id is not unique: it starts again after a reboot.
- Statistics from the single values (discarded reports excluded): count, best, average, worst per metric, and a weekly trend, per athlete and program / preset. Team leaderboards: best value per athlete of a team, only inside the club and only when the team has them switched on.
- Who sees results: coaches and above see the results of the athletes they see (team-limited coaches: their teams). An athlete sees the results of their own (claimed) profile, viewers see none. Leaderboards are visible to every member of the team.
- Corrections: a coach can delete a report or a whole session (audit logged). Deleting an athlete
deletes their results (P24). The app passes its deletions on: a linked athlete or an uploaded
session deleted on the phone is deleted on the platform too, queued like the uploads when the
phone is offline or signed out (
DELETE .../athletes/{id},DELETE .../training/sessions/{id}). The queue runs on sign-in and at app start, whether or not auto upload is on (P26). -
Self-delete (P24): the user who claimed an athlete profile deletes it (
DELETE /v1/orgs/{orgId}/me/athletes/{athleteId}; anyone else gets 404). Everything that names the athlete goes with it: training sessions with their reports and values, invitations and links to the profile. Coaches'DELETE .../athletes/{athleteId}removes the same set. The team ids sit on the profile itself, so nothing else is left. The user's club membership stays. - Reports that a pod still holds for a deleted athlete are dismissed on the phone (they are not uploaded, and the platform never sees them).
- Idempotency: the body ids (P22) are the only dedupe key; there is no
Idempotency-Keyheader. -
Firmware dependency: without the
reportsfeature (#41) there are no live results; the API needs no change for it, it only receives fewer reports.
An ordered list of presets per training day / week, assigned to a team, with completion tracked from the sessions.
New revision of a forked program, invitation, plan assigned. Delivered by email and, later, as signed webhooks (outbox table, §5.1) for third-party integrations.
erDiagram
USER ||--o{ CREDENTIAL : "passkeys"
USER ||--o{ MEMBERSHIP : has
ORGANIZATION ||--o{ MEMBERSHIP : has
ORGANIZATION ||--o{ TEAM : has
TEAM ||--o{ TEAM_MEMBER : has
ORGANIZATION ||--o{ ATHLETE : owns
USER |o--o| ATHLETE : "claims"
ORGANIZATION ||--o{ PROGRAM : owns
PROGRAM ||--o{ PROGRAM_REVISION : has
PROGRAM_REVISION |o--o{ PROGRAM : "forked from"
PROGRAM_REVISION ||--o{ PRESET : configures
ORGANIZATION ||--o{ SET : owns
SET ||--o{ SET_ITEM : has
SET_ITEM }o--|| PROGRAM_REVISION : "or preset"
ORGANIZATION ||--o{ POD_SET : owns
TRAINING_SESSION }o--|| ATHLETE : "for"
TRAINING_SESSION }o--o| PRESET : uses
TRAINING_SESSION }o--o| PROGRAM_REVISION : runs
TRAINING_SESSION ||--o{ REPORT : has
REPORT ||--o{ REPORT_VALUE : has
USER ||--o{ ACCESS_TOKEN : has
ACCESS_TOKEN }o--o| API_CLIENT : "issued to"
| Entity | Key fields | Notes |
|---|---|---|
| User | id, email, displayName, locale, createdAt | email verified by magic link |
| Credential | userId, webauthnId, publicKey, signCount, label | passkeys |
| Organization | id, name, kind personal | club, country |
personal = space of one user |
| Membership | orgId, userId, role, teamIds? | §2.2 |
| Team | id, orgId, name, leaderboard (bool) | |
| Athlete | id, orgId, userId?, name, birthYear?, teamIds, consentAt, consentBy | managed when userId is null; consent confirmed by the coach who adds the profile (P23) |
| Program | id, orgId, title, visibility, forkOf (revision id), licence | |
| ProgramRevision | id, programId, n, json, programFormat, sha256, createdBy, status draft | published
|
immutable once published |
| Preset | id, orgId, revisionId, name, variables, target, visibility | |
| Set / SetItem | id, orgId, name, visibility / setId, index, revisionId | presetId, menuLeds | |
| PodSet | id, orgId, setId, name, firmware, lastSeenAt | |
| TrainingSession | id, orgId, clientId, athleteId, setId?, programPath, revisionId?, presetId?, contentSha256?, startedAt, notes |
clientId = the app's UUID, unique per club |
| Report | id, sessionId, clientId, podReportId?, cycle?, startMs?, endMs?, recordedAt, stats, vars, discarded | one per communicate block (#41) |
| ReportValue | reportId, t, metric, pod, ms | many rows; partitioned by month if large |
| AccessToken | id, userId, clientId?, scopes, kind session | pat | oauth | agent, expiresAt |
stored hashed |
| ApiClient | id, name, redirectUris, kind first-party | third-party | ai
|
OAuth clients, incl. MCP dynamic registration |
| AuditLog | at, actor (user + token kind), action, resource, orgId | every write; every agent call |
| Report | resource, reason, by, state | moderation |
Ids are UUIDv7. Every row carries its orgId; authorization (§5.1) always filters by it, and
Postgres row-level security is a second fence.
Details and alternatives: ADR 0004.
The platform is a modular monolith with a hexagonal (ports and adapters) structure. All business rules live in one application core of use cases. Clients reach it through thin front doors that only translate protocols. Nothing below the front doors knows whether a call came from the app, a script or an AI client.
flowchart TB
subgraph Clients
APP["Config App (PWA / native)"]
TOOLS["tools/*.py, third-party apps"]
AI["AI clients (Claude, agents)"]
end
subgraph "Front doors (adapters in)"
REST["REST /v1 (OpenAPI 3.1)"]
MCP["MCP server /mcp (Streamable HTTP)"]
HOOKS["Webhooks out (later)"]
end
AUTH["Auth: OAuth 2.1 tokens, scopes, rate limits, audit"]
subgraph Core["Application core"]
UC["Use cases: publishProgram, forkProgram, createPreset,<br/>recordSession, getAthleteStats, inviteMember, …"]
POL["Policies (role × visibility × scope)"]
DOM["Domain: program model + validation (shared package)"]
end
subgraph "Ports → adapters out"
DB[("Postgres")]
OBJ["Object storage"]
MAIL["Mailer"]
WA["WebAuthn"]
OUT["Outbox / events"]
end
APP --> REST
TOOLS --> REST
AI --> MCP
REST --> AUTH
MCP --> AUTH
AUTH --> UC
UC --> POL
UC --> DOM
UC --> DB
UC --> OBJ
UC --> MAIL
UC --> WA
UC --> OUT
OUT --> HOOKS
Modules inside the core (each with its own folder, tables and use cases): identity
(users, credentials, tokens), orgs (clubs, teams, memberships, athletes), catalog
(programs, revisions, presets, sets), training (sessions, attempts, stats), devices
(pod sets), moderation. Modules call each other only through their public use cases.
Each use case is defined once:
export const createPreset = useCase({
name: "create_preset", // MCP tool name; REST: POST /v1/presets
description: "Create a named parameter set for a program revision.",
scope: "presets:write",
input: z.object({ revisionId: z.string().uuid(), name: z.string().max(60),
variables: z.record(z.number().int()), visibility: Visibility }),
output: Preset,
agent: { mode: "draft" }, // §7.3: AI calls create drafts
run: async (ctx, input) => { … }, // ctx = actor, org, scopes, ports
});From this one definition the build generates:
- the REST route and its part of
spec/platform-api.yaml(OpenAPI 3.1), - the MCP tool (name, description, JSON schema of input and output),
- the typed TypeScript client used by the app (
app/src/platform/).
Adding a use case therefore makes it available to the app and to AI clients at once, with the same validation and the same authorization. This is what keeps attaching AI clients easy.
The program types, block definitions, limits, formatting and the validator (port of
tools/validate_programs.py) live in packages/program-model/ (@reactpod/program-model,
TypeScript source, no build step). The app and the platform depend on it with
"file:../packages/program-model"; Vite and esbuild compile it. The platform rejects the same
programs as the app, with the same issue codes. spec/program.schema.json (JSON Schema 2020-12,
generated from the block definitions, x-programFormat = PROGRAM_FORMAT) describes the structure
for other languages and editors (ADR 0003); the
validator stays the authority for references, reachability and pod limits.
flowchart TB
UI["UI (React)"] --> P["pod/ (PodClient, sync)"]
UI --> S["store/ (Storage interface)"]
UI --> PL["platform/ (typed API client, auth, offline queue)"]
S --> IDB["IndexedDB"]
PL --> API["Platform API /v1"]
- New module
app/src/platform/next topod/: login (magic link, passkey), generated client, offline queue for results, cache of downloaded programs / presets / sets. - The app stays offline-first and fully usable without an account (as today). The platform is an add-on: publish, install, sync results.
- The app checks
GET /v1/meta(API version, features) like it checksinfoon a pod: a missing feature means the older behaviour.
| Area | Endpoints | Scope |
|---|---|---|
| Auth |
POST /auth/login (202), POST /auth/verify (token or email + code → session token), POST /auth/logout; later OAuth: /oauth/authorize, /oauth/token, /oauth/register, /.well-known/oauth-authorization-server
|
– |
| Me |
GET/PATCH/DELETE /me (GET lists the athlete profiles the user claimed, with their athleteId), GET /me/sessions, DELETE /me/sessions/{sessionId}, GET /me/export
|
profile |
| Clubs |
GET/POST /orgs, GET/PATCH/DELETE /orgs/{orgId}, POST /orgs/{orgId}/leave, POST /orgs/{orgId}/transfer
|
org:read, org:write
|
| Members |
GET /orgs/{orgId}/members, PATCH/DELETE /orgs/{orgId}/members/{userId}
|
org:* |
| Invitations |
GET/POST /orgs/{orgId}/invites, DELETE /orgs/{orgId}/invites/{inviteId}, POST /invites/accept (also claims an athlete profile) |
org:* |
| Teams |
GET/POST /orgs/{orgId}/teams, PATCH/DELETE /orgs/{orgId}/teams/{teamId}
|
org:* |
| Athletes |
GET/POST /orgs/{orgId}/athletes (?teamId=), PATCH/DELETE /orgs/{orgId}/athletes/{athleteId}
|
athletes:* |
| Audit |
GET /orgs/{orgId}/audit?cursor=&limit= (admins) |
audit:read |
| Programs |
GET /programs?q=&tags=&pods=&orgId=&sort=&offset=&limit=, POST /programs (draft), GET/PATCH/DELETE /programs/{programId} (?revision=n), GET/POST /programs/{programId}/revisions, POST /programs/{programId}/revisions/{n}/publish, POST /programs/{programId}/fork, PUT /programs/{programId}/rating, POST /programs/validate
|
programs:* |
| Presets |
GET/POST /presets (?programId=&orgId=), GET/PATCH/DELETE /presets/{presetId}, GET /presets/{presetId}/program (rendered file) |
programs:* |
| Sets |
GET/POST /sets, GET/PATCH/DELETE /sets/{setId}, GET /sets/{setId}/files (pod files as JSON) |
programs:* |
| Pod sets |
GET/POST /orgs/{org}/pod-sets, PATCH /pod-sets/{id}
|
devices:* |
| Training |
POST /orgs/{orgId}/training/upload (sessions with reports, idempotent by the app's ids), GET /orgs/{orgId}/training/sessions?athleteId=&teamId=&from=&to=&cursor=, GET/DELETE /orgs/{orgId}/training/sessions/{sessionId}, PATCH/DELETE /orgs/{orgId}/training/reports/{reportId}
|
results:* |
| Stats |
GET /orgs/{orgId}/athletes/{athleteId}/stats?programPath=&revisionId=&presetId=&from=&to=, GET /orgs/{orgId}/teams/{teamId}/leaderboard?metric=&programPath=&presetId=&from=
|
results:read |
| Moderation |
POST /reports; admin GET/PATCH /reports/{id} later (PL5) |
programs:read |
| Tokens |
GET/POST/DELETE /tokens (personal access tokens) |
tokens |
| Meta |
GET /meta (version, features, limits) |
– |
Conventions:
- JSON, UTF-8; errors as
application/problem+jsonwith a stablecode(program validation issues reuse the app's issue codes). - Cursor pagination (
?cursor=&limit=),ETag+If-Matchon updates (no lost writes). - Breaking changes only with
/v2; additive changes are announced inGET /meta. - CORS only for the app origins (Pages, localhost, Capacitor schemes).
- The platform runs an MCP server at
https://<api>/mcp(Streamable HTTP transport). - Authorization follows the MCP authorization spec: OAuth 2.1 with PKCE, authorization server metadata and dynamic client registration. A user adds the URL in Claude (or any MCP client), signs in with magic link / passkey, picks the club and approves the scopes.
- Scripts and local agents can use a personal access token (scoped, expiring) instead.
- The MCP tools are generated from the use cases (§5.2), so the AI sees the same operations, input schemas and errors as the app.
| Tool | Use case | Scope |
|---|---|---|
search_programs, get_program, list_revisions
|
find and read programs |
programs:read (public without) |
validate_program |
run the shared validator, return issues | – |
create_program, save_revision, fork_program
|
save drafts (never published, never public) | programs:write |
list_presets, get_preset, render_preset, create_preset (draft) |
presets | programs:* |
list_sets, get_set, export_set, create_set (draft) |
sets | programs:* |
list_training_sessions, get_training_session
|
sessions of an athlete / team in a time range, with reports | results:read |
get_athlete_stats |
bests, averages, trend | results:read |
get_team_leaderboard |
ranking for a metric and program / preset | results:read |
list_teams, list_athletes
|
club structure | org:read |
Resources: reactpod://spec/program.schema.json (the JSON Schema) and
reactpod://spec/block-types.json (block definitions, ports, defaults, issue codes from the shared
package). Not exposed: publish_revision, update_*, delete_*, ratings, reports. With these an AI can write a valid program from a description,
validate it and leave it as a draft for the coach.
Prompts (MCP prompts, optional): "design a warm-up for N pods", "analyse the last 4 weeks of team X".
- The AI acts as the user who authorized it: never more rights than that user, further
limited by the token's scopes. Tokens carry
kind: agent. -
Drafts only: AI writes create drafts. Publishing, making something public, deleting,
inviting people and editing athletes need a human confirmation in the app (use cases are
marked
agent: { mode: "draft" | "deny" | "allow" }). - Minors: personal data of managed athletes (names, birth year) is pseudonymised in agent responses ("Athlete A3") unless a club admin allows real names for agents.
- Rate limits per token, stricter for agent tokens; every agent call is in the audit log and visible to the user ("connected apps" list, revoke at any time).
An assistant inside the app uses the same MCP server (via the Claude API with an MCP connector), so there is only one AI integration to build and secure.
| Topic | Rule |
|---|---|
| Hosting | EU only (database, storage, backups, mail provider with EU processing) |
| Data minimisation | email, display name; athletes: name or nickname, optional birth year. No addresses, no health data beyond reaction times |
| Minors | managed by the club; the coach who adds a profile confirms the consent (of the parents for minors), recorded with date and user (P23). Not visible outside the club, pseudonymised for agents |
| Athlete data | the club owns managed profiles and their results; a claimed profile is still the club's, the athlete can see and export it. Deleting a profile deletes its results (P24) |
| Export / delete | self-service (§3.1), the export includes the results of claimed profiles; coaches can delete their athletes with all results; an athlete can delete their own claimed profile with all its results and links (P24) |
| Retention | results kept until deleted; inactive accounts reminded after 24 months, then deleted |
| Tokens | opaque, stored as SHA-256, revocable, 30-day sessions; sign-in links and codes single use, 15 min |
| Transport | HTTPS only, HSTS, CORS restricted (§6) |
| Abuse | rate limits, reports, platform admin moderation, audit log |
| Legal | imprint, privacy policy, data processing agreement for clubs (before PL3 goes live) |
| Part | Choice | Why |
|---|---|---|
| Language | TypeScript (Node LTS) | same as the app, shares the program model |
| HTTP | Hono | small, typed, runs on Node and edge if needed |
| Schemas | zod + OpenAPI generation | one definition for validation, REST, MCP and client |
| MCP |
@modelcontextprotocol/sdk (Streamable HTTP) |
reference implementation |
| Database | Postgres 17. Staging: container on the VPS (below). Production: same, or managed EU Postgres via DATABASE_URL
|
relational data, RLS, JSONB for program JSON |
| ORM / migrations | Drizzle | typed, SQL-near, plain migration files |
| Auth | own module: email code + link, opaque bearer tokens; later SimpleWebAuthn (passkeys) and an OAuth 2.1 server | full control over scopes and agent tokens |
nodemailer over SMTP (MAIL_URL); printed to the log when unset |
any EU provider | |
| Database in tests / dev | PGlite (embedded Postgres, DATABASE_URL=pglite:…) |
no database server needed, real Postgres SQL |
| Storage | S3-compatible object storage (EU) | avatars, set archives |
| transactional provider with EU processing | magic links, invitations | |
| Bundle | esbuild → one dist/main.js (+ dist-migrate/migrate.js), no node_modules in the image |
small image, fast start |
| Deploy | Hetzner VPS (EU): Docker Compose with API, Postgres and Caddy (HTTPS), deployed by CI over SSH (platform/deploy) | cheap, EU, no lock-in |
| CI / release |
.github/workflows/platform.yml: tests, image ghcr.io/runningjoke/reactpod-platform, staging deploy; tags platform-vX.Y.Z
|
like firmware and app (ADR 0002) |
platform/
├── src/
│ ├── main.ts node entry: config → ports → HTTP server
│ ├── config.ts environment (PORT, PUBLIC_URL, DATABASE_URL, CORS_ORIGINS, SHIPPED_DATA_DIR)
│ ├── core/ useCase.ts (defineUseCase, execute), context.ts (Ctx, Actor), ports.ts,
│ │ errors.ts (AppError → problem+json), registry.ts (all use cases)
│ ├── modules/ one folder per module: useCases.ts, later policies.ts, schema.ts (tables)
│ │ ├── meta/ get_meta, validate_program (PL0)
│ │ ├── identity/ sign-in, sessions, profile, export, deletion (PL1)
│ │ ├── orgs/ clubs, members, teams, invitations, athletes, policies (PL1)
│ │ ├── audit/ audit log (PL1)
│ │ ├── catalog/ programs, revisions, presets, sets, ratings, reports, access rules (PL2)
│ │ └── training/ training sessions, reports, values, statistics, leaderboards (PL3)
│ ├── adapters/ db.ts (postgres.js + Drizzle), migrate.ts, library.ts (shipped /prog/lib)
│ ├── http/app.ts REST front door generated from the registry, /mcp, /healthz, /readyz, /v1/openapi.json
│ ├── mcp/server.ts MCP front door: one tool per use case with agent ≠ deny
│ └── openapi.ts OpenAPI 3.1 from the registry
├── scripts/gen-openapi.ts writes spec/platform-api.yaml (--check in CI)
├── test/ REST, MCP (real /mcp route with the SDK client), OpenAPI
├── migrations/ Drizzle (first tables in PL1)
├── deploy/ compose.yaml, Caddyfile, .env.example, README (Hetzner)
├── compose.dev.yaml local Postgres
└── Dockerfile built from the repo root
packages/
└── program-model/ program types, block definitions, limits, validator, formatting
spec/
├── platform-api.yaml generated by platform (npm run openapi), checked in
└── program.schema.json generated by packages/program-model (npm run schema), checked in
| Risk | Mitigation |
|---|---|
| Personal data of minors | club-managed, pseudonymised for agents, no public results, EU hosting, DPA |
| Results need firmware work | API accepts summaries now; /fw-request for per-attempt results over BLE |
| Moderation effort for public content | reports + hide; public publishing can start invite-only |
| MCP spec still changes | MCP door is a thin adapter; REST stays the stable contract |
| Running our own auth | small surface (no passwords), well-known libraries, security review before PL1 goes live |
| Hosting cost | modular monolith in one container + managed Postgres; scale later |
| # | Topic | Decision |
|---|---|---|
| P1 | Backend | own TypeScript API service on managed EU Postgres |
| P2 | Login | email magic link + passkeys, no passwords |
| P3 | Training results | core feature (G6) |
| P4 | Architecture | modular monolith, hexagonal core, use cases defined once (ADR 0004) |
| P5 | Client contract | REST /v1 with OpenAPI 3.1 for apps and tools |
| P6 | AI clients | MCP server generated from the same use cases, OAuth 2.1, agent tokens |
| P7 | AI writes | drafts only; publish, delete, share, people data need a human |
| P8 | Preset | named variable overrides of a program revision; app's "preset" (include) becomes "library part" |
| P9 | Licence | CC BY 4.0 default for public content, CC0 optional |
| P10 | Tenancy | every resource has one owner organisation; personal space = organisation of one |
| P11 | Hosting (staging) | Hetzner VPS in the EU, Docker Compose (API, Postgres, Caddy), deployed by CI over SSH |
| P12 | Shared model |
packages/program-model as TypeScript source via file: dependency, no root workspace; JSON Schema in PL2 |
| P13 | Passkeys | postponed until the own domain is decided; email code + link only |
| P14 | Sessions | opaque bearer tokens, hashed in the database, 30 days; no cookies, no JWT (instant revocation) |
| P15 | Test database | PGlite in memory per test file; production Postgres 17 |
| P17 | Includes | only shipped library parts; other includes are flattened in the app before saving |
| P18 | AI writes | programs: draft revisions (publishing is deny); presets and sets: status: draft, visible to the creator until a person sets ready; AI clients cannot set public
|
| P19 | Set export | JSON map of pod files (/programs.json + programs + library parts); the app zips |
| P20 | Scopes |
programs:read / programs:write cover programs, presets and sets; session tokens always carry all scopes |
| P16 | Privacy defaults | non-members get 404 for a club; e-mail addresses only for admins, never for AI clients; athletes pseudonymised for AI clients unless the club allows real names; audit details hold ids, not names |
| P21 | Results | the upload unit is the firmware's report (#41), grouped into training sessions; statistics come from the single values |
| P22 | Idempotency | sessions and reports carry a UUID made by the app, unique per club; a repeated upload stores nothing twice (the pod's report id restarts after a reboot). Re-uploading may only change discarded
|
| P23 | Consent | adding an athlete requires consent: true; the platform records when and by whom |
| P24 | Deletion | deleting an athlete or a club deletes its sessions, reports, values, invitations and links; the claiming user may delete their own profile (/me/athletes/{id}, 404 for everyone else) |
| P25 | Scopes |
results:read / results:write for training sessions, reports and statistics; AI clients may read (pseudonymised) but not upload or delete |
| P26 | Deletion queue | queued platform deletions run on sign-in and at app start, independent of the auto-upload setting; uploads still follow it |
-
Result granularity from the firmware→ reports over BLE during the game (#37, #41), P21. - Public leaderboards across clubs (opt-in) – wanted at all?
-
Includes on publish→ P17. - Domain name for app + API (same site helps cookies and passkeys).
- Who moderates public content, and is public publishing open from the start?
- Paid tiers later (e.g. large clubs)? Influences quotas in
GET /meta.
Like the app phases: the user approves each phase.
| Phase | Content | Done when | Status |
|---|---|---|---|
| PL0 | Skeleton: platform/, CI, packages/program-model extracted from the app (app tests still green), use-case helper, OpenAPI + MCP generation, deploy pipeline to a staging host |
GET /v1/meta deployed; app builds against the package |
implemented; staging deploy waits for the server + secrets (deploy) |
| PL1 | Accounts (email code + link; passkeys later, P13), clubs, teams, invitations, managed athletes, audit log | sign up, create club, invite coach, add athletes from the app | API implemented and tested (REST + MCP); app screens later; user test pending |
| PL2 | Programs, revisions, forks, presets, sets, search, ratings, reports, spec/program.schema.json; app: login, publish, install, apply preset |
a program made in the app can be published, found and installed on another phone | API implemented and tested (REST + MCP, Postgres 17); app integration later; user test pending |
| PL3 | Training sessions and reports (upload from the app's offline queue), stats, team leaderboards; pod side: epic #37 | a session on real pods appears in the athlete's stats | API implemented and tested (REST + MCP); app upload with #47 / #48; #38 done (ADR 0005 accepted), #41 done; real pods wait for #39 (live BLE), #42 → #45–#47; user test pending |
| PL4 | OAuth for third-party / AI clients, agent tokens, guard rails (the MCP door exists since PL0) | Claude connects, writes a valid program draft and analyses a team's sessions | – |
| PL5 | Training plans, webhooks, moderation tooling, data export polish | – | – |
For users and coaches
Repository and workflow
Firmware
- Firmware Overview
- Firmware Architecture
- Firmware Link Protocol
- Firmware Config API
- Firmware Programs
- Firmware Testing
- Firmware Timing
- Firmware Host Syntax Check
Config App
Community platform (on hold)
Decisions (ADRs)
- Decisions
- ADR 0001 Repo Layout and Workflow
- ADR 0002 Versions and Firmware Delivery
- ADR 0003 Community Platform
- ADR 0004 Platform Architecture
- ADR 0005 Live Results over BLE
- ADR 0006 No OTA
- ADR 0007 Platform on Hold
Archive