# 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: ) -> { 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. ### 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 "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. ## 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.