Files
2026-07-06 08:50:52 +02:00

7.9 KiB

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 sessions record (device label, IP, user-agent, expiry) whose id is embedded as the JWT jti. withAuth rejects 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 no jti and is treated as revoked.)
  • Per-user car ownership + sharing — cars are not a global list. cars.owner marks ownership and car_shares grants other users read or write access. Every car/service/part handler is gated by requireCarAccess:
    • 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 roleusers.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: updateCar rewrites all car columns from the payload, so a PATCH /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/updated autodate fields in this PocketBase version — sorting on created fails unless an explicit F.autodate(...) is added. When adding a new car spec field, extend DESIRED.cars in setup-pocketbase.mjs, add it to models.Car + the record mapping in records.go, then rebuild.

First-time setup

  1. 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
    
  2. Run the servergo run ., or build and run the binary (below).

  3. (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.