# 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 charging.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: ) -> { id, email, name, role } GET /api/identity (Authorization: ) -> + organization ``` Both `Authorization: Bearer ` and a raw `Authorization: ` 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. Creating one is self-service: any user who does not already belong to an organization may `POST /api/orgs`, and becomes that organization's **admin** and first member in the same request (if the promotion fails the new organization is rolled back, so it is never left with nobody able to administer it). A user who already belongs to one is refused — membership is a single relation, so creating a second would mean silently abandoning the first. A superadmin is the exception: they create organizations without joining them, since they already span every tenant. From there an admin manages **their own** organization — rename it, or delete it once they are its only member. Deleting it detaches and demotes them back to a plain `user` before the record is removed, so the organization is empty when it goes. A superadmin may rename or delete any organization, but still only once it has no members 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 | | `charging_sessions` | EV charging log, the same shape as the refuelling one (kWh/100km derived on read) | car, date, km, kwh, cost, `full_charge`, `missed_session`, location, 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 / drag lock / garage order / 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; POST /api/orgs is open to any user) 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) # vehicle providers — create a car from a manufacturer service; per-car provider tab GET /api/vehicle-providers GET /api/vehicle-providers/{provider}/vehicles POST /api/vehicle-providers/{provider}/import # cars + sharing (GET /api/cars returns the garage in the user's saved order, # which PATCH /api/me {carOrder} sets) GET /api/cars POST /api/cars GET /api/cars/{id} PATCH /api/cars/{id} DELETE /api/cars/{id} PUT /api/cars/{id}/view # which tabs + Information rows this car shows, and # the order of the tabs, the rows and the provider readings GET /api/cars/{id}/provider POST /api/cars/{id}/provider POST /api/cars/{id}/provider/sync 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}/charging-sessions GET /api/cars/{id}/charging-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/charging-sessions /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 | charging-sessions | 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 two exceptions are `owner` and the provider link (`provider`, > `provider_vehicle_id`), which `carPayload` deliberately leaves out so an > ordinary edit can neither reassign the car nor break its connected service. ### Vehicle providers `internal/api/vehicleproviders.go` turns a manufacturer-service plugin into a car you can create from your own account with that service, plus a per-car tab showing everything the service currently knows about it. Toyota (MyToyota) is the first provider; adding the next one means writing a `vehicleSource` adapter and appending it to `vehicleSources()` — no new endpoints and no Web App changes. Two properties shape the design: - **Credentials are always the caller's.** Every provider call resolves through the same global → org → user cascade as the integration settings, so a car shared with someone else shows them provider data only when that vehicle is on *their* manufacturer account. The owner's credentials are never borrowed. - **Upstream shapes are not modelled.** These are unofficial APIs. Rather than hard-coding field paths, the layer searches payloads by key name for the handful of readings worth promoting (odometer, fuel, battery, range) and flattens the rest to dotted key/value pairs, shipping the raw payload alongside. A renamed field costs one blank value instead of a broken page. `POST .../import` takes `{vehicleId, name?, include?}`, where `include` selects which groups to pull (`identity`, `fuelType`, `dates`, `odometer`). Omitting it means "everything available". `POST /api/cars/{id}/provider/sync` takes the same selection, and only ever moves the odometer forward — a reading that appears to go backwards is a stale provider, not a car driven in reverse. ## 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**). Enable state and global config persist to PocketBase, in the `app_settings` singleton — the same place the per-org and per-user layers of the cascade live. 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 | | `OCPP_REQUIRE_TLS` | `true` | reject chargers that did not connect over TLS | | `OCPP_PUBLIC_URL` | — | canonical `ws(s)://` base to point chargers at | | `PB_BOOTSTRAP` | `true` | run the on-boot schema create/reconcile (leave on across upgrades) | | `DRIVERVAULT_SUPERADMIN_EMAIL` / `_PASSWORD` / `_NAME` | — / — / `Administrator` | first `superadmin`, created on boot when absent | `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. The server keeps **no state on disk**: plugin settings, like everything else it owns, live in PocketBase. A `.env` in the working directory is read at startup as a local-development convenience, and the panel writes back to it when a superadmin retargets PocketBase or the Web App — but in Docker there is no volume behind it, so those two screens apply for the life of the container only. Set the environment variables to change them permanently; see [`Dockerfile`](Dockerfile) and [`../Docker`](../Docker). `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 "Name" # create an app login node scripts/set-role.mjs 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. > **Startup bootstrap:** the server also runs this same create/reconcile on boot > (`internal/bootstrap`, a Go mirror of `setup-pocketbase.mjs`) whenever a service > account is configured, so the Docker prod stack needs no manual setup step. It > is idempotent and gated by `PB_BOOTSTRAP` (default `true`; set to `false` to > skip). With `DRIVERVAULT_SUPERADMIN_EMAIL` + `DRIVERVAULT_SUPERADMIN_PASSWORD` > set it also creates the first `superadmin` user when absent. **Keep the two > schemas in sync:** a change to `DESIRED` in the script must be mirrored in > `internal/bootstrap/schema.go` (guarded by `TestSchemaConsistency`). ## 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.