Files
DriverVault/API Server/README.md
T
tajniak81andClaude Opus 4.8 1e76c2b7f9 Refresh docs and fix Docker builds for current layout
READMEs: correct the auth model (PocketBase token relay, not JWT/sessions),
document the full feature set (technical checks, fuel, maintenance, documents,
reminders, attachments, integrations, OCPP charging control), the shipping
built-in connectors (toyota, anker-solix), and the current endpoint surface.

Docker: build against the current repo layout — Go 1.26, cmd/server entry
point, Web App source under web/. Add the missing Web App Dockerfile (Go BFF)
and .dockerignore, drop the obsolete AUTH_SECRET, modernise CORS var naming,
and standardise on drivervault-* naming.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 11:05:46 +02:00

358 lines
17 KiB
Markdown

# DriverVault — API Server
The central API Server for the DriverVault project. Written in Go (standard
library only, module `drivervault/apiserver`). 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 a superuser and all collection access
rules are left null, so data is only reachable through this server.
It also serves the **superadmin web panel** at the server root (`/`).
## Layout
```
cmd/server/main.go # entry point
internal/
├── api/ # HTTP handlers + router (server.go)
│ ├── auth.go # PocketBase token proxy + role gates
│ ├── users.go # user management (role/org scoped)
│ ├── orgs.go # organization management
│ ├── settings.go # runtime PocketBase connection (pb-config)
│ ├── plugins.go # plugin management endpoints
│ ├── status.go # upstream health probes
│ ├── health.go respond.go panel.go
│ ├── cars.go records.go services.go parts.go shares.go me.go
│ ├── technical.go fuel.go maintenance.go documents.go reminders.go
│ ├── attachments.go # one optional file per record (shared handlers)
│ ├── integrations*.go # per-user Toyota / Anker Solix settings + OCPP control
│ └── dist/ # built panel, embedded via go:embed
├── config/config.go # env + .env load, .env write-back
├── models/models.go # domain types + derived-field computation
├── ocpp/ # OCPP 1.6J Central System (Anker Solix charging control)
├── pb/client.go # PocketBase superuser client (runtime-retargetable)
└── plugins/ # plugin system — see plugins/README.md
├── plugin.go manager.go external.go doc.go
└── builtin/ # built-in connectors: toyota, ankersolix
panel/ # Vue 3 + Tailwind panel source
scripts/ # Node/Python maintenance scripts
bin/api-server.exe # prebuilt binary the deployment runs
```
## Auth & access control
Authentication is **PocketBase's own**. `POST /api/auth/login` is proxied to the
PocketBase users collection and the client keeps the token PocketBase minted;
this server does not issue its own JWT. Every protected request re-resolves that
token against PocketBase (`auth-refresh`), so a role change or a deletion takes
effect **immediately** rather than lingering until a token expires.
```
POST /api/auth/login { "email": "...", "password": "..." } -> PocketBase { token, record }
GET /api/auth/me (Authorization: <token>) -> { id, email, name, role }
GET /api/identity (Authorization: <token>) -> + organization
```
Both `Authorization: Bearer <token>` and a raw `Authorization: <token>` are
accepted (PocketBase's own SDKs send the latter).
### Roles
`users.role` is `user` | `admin` | `superadmin` (empty is treated as `user`).
| Role | Can |
|---|---|
| **user** | their own cars, service records, parts, profile |
| **admin** | the above, plus manage users **within their own organization** |
| **superadmin** | everything, across all organizations, plus the PocketBase connection and plugins |
Guards worth knowing: an admin cannot create or edit a superadmin, cannot move
users between organizations, and nobody can change their own role or delete
their own account. An organization cannot be deleted while it still has members.
### Organizations
`organizations` is the tenant collection; `users.organization` is the
membership. A superadmin spans all organizations; an admin is scoped by the
server to their own. Users may have no organization at all.
### 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.
## Data model
| Collection | Purpose | Key fields |
|---|---|---|
| `cars` | one per car | name, make, model, year, registration, `registrationCountry`, vin, `fuelType`, `buildDate`, `firstRegistrationDate`, `currentKm`, `serviceIntervalDays` (365), `serviceIntervalKm` (15000), `technicalCheckIntervalDays` (365), `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 |
| `technical_checks` | roadworthiness inspections (przegląd techniczny / MOT / TÜV) | car, date, `result` (passed \| failed), cost, station, `valid_until`, notes |
| `parts` | per-car parts catalog | car, name, part_number, category, notes |
| `fuel_entries` | refuelling log (efficiency derived on read) | car, date, km, liters, cost, `full_tank`, `missed_fill`, station, notes |
| `maintenance_entries` | workshop visits & repairs (outside routine service) | car, date, km, type, status, workshop, parts_used, labor_cost, parts_cost, invoice_number, warranty_until, notes |
| `car_documents` | paperwork (insurance, registration, road tax, …) | car, type, title, provider, reference, issue_date, expiry_date, cost, notes |
| `reminders` | date/odometer reminders (some auto-derived) | car, title, type, due_date, due_km, repeat_days, repeat_km, done, done_at, notes |
| `car_shares` | grants another user access to a car | car, user, `permission` (read \| write) |
| `organizations` | tenants | name (unique) |
| `control_audit` | OCPP control-command audit trail | user, charger, action, result, timestamp |
| `users` | login + profile (built-in auth collection) | name, email, avatar, `role` (user \| admin \| superadmin), `organization`, bio, theme, locale, date_format, currency, font_size, deletion_requested_at |
Every record collection except `car_shares` / `organizations` / `control_audit`
carries **one optional file attachment**, served only through the API Server
(`GET /api/{records}/{id}/file`) — never a public PocketBase URL.
**Spreadsheet formulas** (from the original `Car Service.xlsx`), 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`.
## Endpoints
```
# public
GET /healthz
GET /api/health
GET /api/status # health of PocketBase + Web App, probed server-side
POST /api/auth/login
GET /api/auth/validate
# identity
GET /api/auth/me
GET /api/identity
# current user (profile / appearance / avatar / data / account lifecycle)
GET /api/me PATCH /api/me DELETE /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
# users + organizations (admin or superadmin; org writes are superadmin-only)
GET /api/users POST /api/users
PATCH /api/users/{id} DELETE /api/users/{id}
GET /api/orgs POST /api/orgs
PATCH /api/orgs/{id} DELETE /api/orgs/{id}
# superadmin — connection + plugin management
GET /api/admin/pb-config PUT /api/admin/pb-config POST /api/admin/pb-config/test
GET /api/admin/webapp-config PUT /api/admin/webapp-config POST /api/admin/webapp-config/test
GET /api/admin/plugins POST /api/admin/plugins
GET /api/admin/plugins/{name} PUT /api/admin/plugins/{name}
DELETE /api/admin/plugins/{name} POST /api/admin/plugins/{name}/health
# integrations (per-user plugin settings; superadmin → org admin → user cascade)
GET /api/integrations/toyota PUT /api/integrations/toyota POST /api/integrations/toyota/health
GET /api/integrations/toyota/vehicles
GET /api/integrations/anker-solix PUT /api/integrations/anker-solix POST /api/integrations/anker-solix/health
GET /api/integrations/anker-solix/chargers
# Anker Solix OCPP charging control (own/proxy mode + a live CSMS session)
GET /api/integrations/anker-solix/chargers/{sn}/control
POST /api/integrations/anker-solix/chargers/{sn}/control/token
DELETE /api/integrations/anker-solix/chargers/{sn}/control/token
POST /api/integrations/anker-solix/chargers/{sn}/{action}
GET /ocpp/{serial} # charger dials in here (OCPP Basic auth, not bearer)
# 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}/technical-checks
GET /api/cars/{id}/parts GET /api/cars/{id}/fuel-entries GET /api/cars/{id}/fuel-stats
GET /api/cars/{id}/maintenance GET /api/cars/{id}/documents GET /api/cars/{id}/reminders
GET /api/cars/{id}/shares POST /api/cars/{id}/shares DELETE /api/cars/{id}/shares/{userId}
# per-record collections — each is GET(list) POST / GET PATCH DELETE {id}
/api/service-records /api/technical-checks /api/parts
/api/fuel-entries /api/maintenance /api/car-documents
/api/reminders (+ POST /api/reminders/{id}/complete)
# attachments — one optional file per record, on every collection that takes one.
# {records} = car-documents | service-records | technical-checks | maintenance | fuel-entries | parts
POST /api/{records}/{id}/file GET /api/{records}/{id}/file DELETE /api/{records}/{id}/file
```
`GET /api/cars` returns the caller's owned cars plus any shared with them, each
annotated with an `access` field. The per-record list endpoints also accept a
`?car={id}` filter (e.g. `GET /api/service-records?car={id}`).
> **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.)
## The panel (`/`)
A Vue 3 + Tailwind app (source in `panel/`, built into `internal/api/dist` and
embedded at compile time). It is the superadmin console:
- **Overview** — live health of the API Server, PocketBase and the Web App.
- **Users / Organizations** — full management, scoped to the caller's role.
- **PocketBase** — retarget the database connection at runtime; test before
saving. Applied immediately **and** persisted to `.env`, so it survives a
restart. This is the escape hatch when the configured PocketBase is wrong or
unreachable — the settings endpoints deliberately do **not** require a working
service account.
- **Plugins** — enable/disable/configure integrations, run health checks,
register external plugins.
- **API** — the endpoint reference.
Log in with any DriverVault account; the sections you see depend on your role.
```powershell
cd panel
npm install
npm run dev # localhost:5174, proxies /api to localhost:8080
npm run build # -> ../internal/api/dist (then rebuild the Go binary)
```
Editing panel source alone does nothing to the served panel — run `npm run build`
and then rebuild the Go binary, since `dist` is embedded.
## Plugins
An extension system for integrating third-party services, managed by a
superadmin. Two kinds share one contract: **built-in** (Go, compiled in) and
**external** (any HTTP service, registered at runtime, **no rebuild**). State
persists to `plugins.json`.
See **[`internal/plugins/README.md`](internal/plugins/README.md)** for the full
guide. Two built-in connectors ship today — **Toyota Connected** (`toyota`,
read-only MyToyota vehicle data) and the **Anker Solix** V1 EV charger
(`anker-solix`) — and any number of external HTTP plugins can be registered at
runtime with no rebuild.
### Integrations & charging control
Beyond the superadmin plugin registry, the built-in connectors are exposed
per-user through `/api/integrations/*` under a **superadmin → org admin → user**
cascade (each layer supplies defaults the next can override). For Anker Solix
chargers the server additionally runs an **OCPP 1.6J Central System**
(`internal/ocpp`): when the owner sets a control mode of own/proxy, the charger
dials back in at `GET /ocpp/{serial}` (authenticated with OCPP Basic auth using a
per-charger control token, not a bearer token) and the owner can start/stop and
set charge limits, with every command rate-limited and written to a
`control_audit` trail.
## Configuration
Copy `.env.example` to `.env` and fill in. Summary:
| Variable | Default | Purpose |
|---|---|---|
| `API_ADDR` | `:8080` | listen address (bare port accepted) |
| `POCKETBASE_URL` | `http://10.2.1.10:8027` | PocketBase base URL |
| `POCKETBASE_ADMIN_EMAIL` / `_PASSWORD` | — | superuser service account |
| `CORS_ALLOW_ORIGINS` | `*` | comma-separated browser origins |
| `WEBAPP_URL` | `http://localhost:8090` | probed by `/api/status` |
| `AUTH_USERS_COLLECTION` | `users` | PocketBase auth collection |
| `PLUGINS_FILE` | `plugins.json` | plugin state store |
`PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD`, `PORT` and `CORS_ORIGINS` are
still honoured for older deployments; the modern names win when both are set.
`CORS_ALLOW_ORIGINS` only matters for **browser** clients (the web app). Native
mobile apps are not subject to CORS.
The service account is **optional at startup**: without it the server still runs
and a superadmin can log in and configure it from the panel, while management
endpoints return 503.
## Scripts (`scripts/`)
```powershell
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|superadmin
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 `POCKETBASE_URL` / `POCKETBASE_ADMIN_EMAIL` /
`POCKETBASE_ADMIN_PASSWORD` (or the legacy `PB_*` names) from the environment.
> **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 — safe to re-run on an
existing deployment; it adds `organizations`, `users.organization`, and grows
`users.role` to include `superadmin`):
```powershell
$env:POCKETBASE_URL="http://10.2.1.10:8027"
$env:POCKETBASE_ADMIN_EMAIL="you@example.com"
$env:POCKETBASE_ADMIN_PASSWORD="secret"
node scripts/setup-pocketbase.mjs
```
2. **Mint the first superadmin.** The panel's management screens need one, and
only a superadmin can promote another — so the first has to come from the
script:
```powershell
node scripts/create-user.mjs admin@example.com "a-long-password" "Admin"
node scripts/set-role.mjs admin@example.com superadmin
```
3. **Run the server** — `go run ./cmd/server`, or build and run the binary.
4. **Open the panel** at `http://localhost:8080/` and sign in.
5. **(Optional) Seed from the spreadsheet** with the server running (see scripts).
## Build & run
```powershell
go build -o bin/api-server.exe ./cmd/server
```
The deployment runs the **prebuilt binary** `bin/api-server.exe` (not `go run`),
started detached so it survives the shell:
```powershell
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.
## Migrating from the JWT build
Earlier builds minted their own HS256 JWT and tracked a `sessions` collection
for "active devices" / remote logout. That is gone — the server now relays
PocketBase tokens. Consequences:
- **`AUTH_SECRET` is obsolete** and ignored.
- **Login returns PocketBase's envelope** — `{token, record}`, not `{token, user}`.
- **`GET|DELETE /api/sessions*` are gone.** PocketBase tokens are stateless, so
there is nothing to revoke per-device. To lock every device out of an account,
change its password: PocketBase rotates the user's token key, which
invalidates every token already issued.
- **User management moved** from `/api/admin/users*` to `/api/users*`, and the
separate password-reset endpoint folded into `PATCH /api/users/{id}`
(`{"password": "..."}`). Responses are enveloped: `{users}` / `{user}`.
- **Existing tokens are invalid** — every client must log in once more.
- The `sessions` collection is left in PocketBase rather than dropped; delete it
by hand if you want it gone.
The Web App and Phone App in this repo are already updated for all of the above.