Car Control — API Server
The central API Server for the Car Control project. Written in Go (standard
library only, module carcontrol/api). It is the single gateway between all
clients (web app, phone app, Home Assistant plugin, ESP32 device) and the
PocketBase database — clients never talk to PocketBase directly. The API
Server authenticates to PocketBase as superuser and all collection access rules
are left null, so data is only reachable through this server.
Data model (from Car Service.xlsx)
| Collection | Purpose | Key fields |
|---|---|---|
cars |
one per car (was: one spreadsheet sheet) | name, make, model, year, registration, vin, currentKm, serviceIntervalDays (365), serviceIntervalKm (15000), oilSpec, transmissionOilSpec, differentialOilSpec, brakeFluidSpec, coolantSpec, owner |
service_records |
the service log | car, date, km, changed_oil, changed_engine_air_filter, changed_cabin_air_filter, notes |
parts |
per-car parts catalog (cols M/N) | car, name, part_number, category |
car_shares |
grants another user access to a car | car, user, permission (read | write) |
sessions |
active login sessions (device/IP/expiry/revoked) | user, label, ip, user_agent, expires, revoked |
users |
login + profile (built-in auth collection) | name, email, avatar, role (user | admin), bio, theme, locale, date_format, font_size, deletion_requested_at |
Spreadsheet formulas, reproduced by the API on read:
Next Service Date = service date + serviceIntervalDays (Excel: =A+365)
Next Service Km = service km + serviceIntervalKm (Excel: =B+15000)
These come back on each service record as nextServiceDate / nextServiceKm.
Auth & access control
All endpoints except /api/health and /api/auth/login require a bearer token.
Login verifies credentials against the PocketBase users collection, then the
API Server issues its own HS256 JWT (valid 7 days).
- Sessions — each login also creates a
sessionsrecord (device label, IP, user-agent, expiry) whose id is embedded as the JWTjti.withAuthrejects any token whose session is missing or revoked, which powers the "active sessions" list and remote logout in Settings. (Any JWT minted before sessions were introduced has nojtiand is treated as revoked.) - Per-user car ownership + sharing — cars are not a global list.
cars.ownermarks ownership andcar_sharesgrants other usersreadorwriteaccess. Every car/service/part handler is gated byrequireCarAccess:- read — view the car, its service records and parts.
- write — edit the car and full service/part CRUD.
- owner only — delete the car and manage its shares.
- Admin role —
users.role(user|admin), embedded in the JWT and re-checked from PocketBase on each admin call (so demotion is immediate). Admins manage users under/api/admin/*. Guards prevent deleting your own account or removing/demoting the last admin.
POST /api/auth/login { "email": "...", "password": "..." } -> { token, user }
GET /api/auth/me (Authorization: Bearer <token>) -> { id, email, name, role }
Endpoints
GET /api/health
POST /api/auth/login
GET /api/auth/me
# current user (profile / appearance / avatar / data / account lifecycle)
GET /api/me PATCH /api/me
POST /api/me/password
POST /api/me/avatar GET /api/me/avatar DELETE /api/me/avatar
POST /api/me/verify/request
GET /api/me/export POST /api/me/import
POST /api/me/delete POST /api/me/delete/cancel DELETE /api/me
# active sessions
GET /api/sessions
DELETE /api/sessions/{id} DELETE /api/sessions (revoke all others)
# admin (admin role required)
GET /api/admin/users POST /api/admin/users
PATCH /api/admin/users/{id} POST /api/admin/users/{id}/password
DELETE /api/admin/users/{id}
# cars + sharing
GET /api/cars POST /api/cars
GET /api/cars/{id} PATCH /api/cars/{id} DELETE /api/cars/{id}
GET /api/cars/{id}/service-records
GET /api/cars/{id}/parts
GET /api/cars/{id}/shares POST /api/cars/{id}/shares
DELETE /api/cars/{id}/shares/{userId}
# service records + parts
GET /api/service-records POST /api/service-records
GET /api/service-records/{id} PATCH /api/service-records/{id} DELETE /api/service-records/{id}
GET /api/parts POST /api/parts
GET /api/parts/{id} PATCH /api/parts/{id} DELETE /api/parts/{id}
GET /api/cars returns the caller's owned cars plus any shared with them, each
annotated with an access field. GET /api/service-records?car={id} and
GET /api/parts?car={id} filter by car.
Gotcha:
updateCarrewrites all car columns from the payload, so aPATCH /api/cars/{id}must send the full car object — omitted spec fields get blanked. (The phone's odometer quick-edit sends the whole car for this reason.)
Layout
main.go
internal/
├── api/ # HTTP handlers + router (server.go)
│ ├── auth.go services.go records.go parts.go cars.go
│ ├── me.go sessions.go shares.go admin.go
├── auth/jwt.go # HS256 JWT mint/verify
├── config/config.go
├── models/models.go
└── pb/client.go # PocketBase superuser client
scripts/ # Node/Python maintenance scripts (see below)
bin/api-server.exe # prebuilt binary the deployment runs
Configuration
Copy .env.example to .env and fill in:
PORT=8080
PB_URL=http://10.2.1.10:8027
PB_ADMIN_EMAIL=...
PB_ADMIN_PASSWORD=...
AUTH_SECRET=<long random value>
CORS_ORIGINS=http://localhost:5173
CORS_ORIGINS only matters for browser clients (the web app). Native mobile
apps are not subject to CORS. Set AUTH_SECRET to a long random value — the
server warns and falls back to an insecure dev secret if it is unset.
Scripts (scripts/)
node scripts/setup-pocketbase.mjs # create/reconcile collections (idempotent)
node scripts/create-user.mjs <email> <pw> "Name" # create an app login
node scripts/set-role.mjs <email> user|admin # promote/demote
node scripts/backfill-car-owners.mjs # one-off: assign owner to legacy cars
python scripts/seed_from_excel.py "C:/Users/jania/Desktop/Car Service.xlsx"
The Node scripts read PB_URL / PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD from the
environment (or .env).
PocketBase note: collections created by the setup script do not get automatic
created/updatedautodate fields in this PocketBase version — sorting oncreatedfails unless an explicitF.autodate(...)is added. When adding a new car spec field, extendDESIRED.carsinsetup-pocketbase.mjs, add it tomodels.Car+ the record mapping inrecords.go, then rebuild.
First-time setup
-
Create the PocketBase collections (idempotent):
$env:PB_URL="http://10.2.1.10:8027" $env:PB_ADMIN_EMAIL="you@example.com" $env:PB_ADMIN_PASSWORD="secret" node scripts/setup-pocketbase.mjs -
Run the server —
go run ., or build and run the binary (below). -
(Optional) Seed from the spreadsheet with the server running (see scripts).
Build & run
go build -o bin/api-server.exe .
The deployment runs the prebuilt binary bin/api-server.exe (not go run),
started detached so it survives the shell:
Start-Process -FilePath ".\bin\api-server.exe" -WorkingDirectory "." `
-WindowStyle Hidden -RedirectStandardOutput api-server.out.log `
-RedirectStandardError api-server.err.log
Go's log package writes to stderr, so check api-server.err.log for
request logs and errors. After editing any Go source, rebuild and restart the
process — editing source alone does nothing until the binary is rebuilt.