Rebuild API Server on the PilotVault structure

Mirror PilotVault's API Server layout and add the superadmin console,
plugin system, runtime PocketBase settings, and user/organization
management. The car domain (cars, service records, parts, sharing) is
carried over unchanged apart from the auth switch.

Layout: main.go -> cmd/server/main.go; module carcontrol/api ->
drivervault/apiserver. internal/api is split by concern (auth, users,
orgs, settings, plugins, status, health, respond).

Auth: replace the server-minted HS256 JWT and the sessions collection
with a PocketBase token proxy. /api/auth/login relays PocketBase's
{token, record}, and every protected request re-resolves that token
against PocketBase, so a role change or deletion takes effect at once
instead of waiting out a token. AUTH_SECRET is obsolete and internal/auth
is gone. Per-device session listing/revocation goes with it: PocketBase
tokens are stateless. Changing a password rotates the user's token key,
which invalidates every token already issued.

Roles: add superadmin alongside user/admin, plus an organizations
collection and users.organization. Admins are scoped to their own
organization; superadmins span all of them. Guards prevent changing your
own role, deleting your own account, an admin touching a superadmin, and
deleting an organization that still has members.

Plugins: new internal/plugins package with one contract over two kinds --
builtin (compiled in) and external (any HTTP service, registered at
runtime with no rebuild). State persists to plugins.json; secrets are
masked on read and preserved when saved back at the mask.

PocketBase settings: /api/admin/pb-config applies a new connection at
runtime and persists it to .env. It deliberately does not require a
working service account, so a wrong or unreachable connection can still
be fixed from the panel.

Panel: rebuilt as the superadmin console -- login gate, status, users,
organizations, PocketBase, plugins, and the endpoint reference.

Clients: update the Web App and Phone App for the PocketBase token shape,
the move of user management to /api/users ({users}/{user} envelopes, with
password resets folded into PATCH), and the removal of sessions. Both now
mirror the server's real guards rather than the old last-admin rule, and
parse PocketBase's field-level error shape.

Config: modern POCKETBASE_*/API_ADDR names with legacy PB_*/PORT
fallbacks, so existing .env files keep working. Also fixes /api/status
probing the Web App on 8090 instead of DriverVault's 5173.

Run scripts/setup-pocketbase.mjs to add the organizations collection and
grow users.role; every client must log in once more.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-16 22:29:45 +02:00
co-authored by Claude Opus 4.8
parent 7d55f0a4cd
commit ae6ed4ac1e
56 changed files with 4474 additions and 1475 deletions
+208 -86
View File
@@ -1,24 +1,99 @@
# Car Control — API Server
# DriverVault — 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.
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.
## Data model (from `Car Service.xlsx`)
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
│ └── dist/ # built panel, embedded via go:embed
├── config/config.go # env + .env load, .env write-back
├── 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 (none yet)
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 (was: one spreadsheet sheet) | name, make, model, year, registration, vin, `currentKm`, `serviceIntervalDays` (365), `serviceIntervalKm` (15000), `oilSpec`, `transmissionOilSpec`, `differentialOilSpec`, `brakeFluidSpec`, `coolantSpec`, `owner` |
| `cars` | one per car | 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 |
| `parts` | per-car parts catalog | 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 |
| `organizations` | tenants | name (unique) |
| `users` | login + profile (built-in auth collection) | name, email, avatar, `role` (user \| admin \| superadmin), `organization`, bio, theme, locale, date_format, font_size, deletion_requested_at |
**Spreadsheet formulas**, reproduced by the API on read:
**Spreadsheet formulas** (from the original `Car Service.xlsx`), reproduced by
the API on read:
```
Next Service Date = service date + serviceIntervalDays (Excel: =A+365)
@@ -27,40 +102,19 @@ 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 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
```
# 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
@@ -70,14 +124,18 @@ 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)
# 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}
# 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}
# superadmin
GET /api/admin/pb-config PUT /api/admin/pb-config
POST /api/admin/pb-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
# cars + sharing
GET /api/cars POST /api/cars
@@ -103,51 +161,81 @@ annotated with an `access` field. `GET /api/service-records?car={id}` and
> get blanked. (The phone's odometer quick-edit sends the whole car for this
> reason.)
## Layout
## 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)
```
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
```
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. DriverVault ships no built-in connectors yet; the external kind is the
place to start.
## Configuration
Copy `.env.example` to `.env` and fill in:
Copy `.env.example` to `.env` and fill in. Summary:
```
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
```
| 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:5173` | probed by `/api/status` |
| `AUTH_USERS_COLLECTION` | `users` | PocketBase auth collection |
| `PLUGINS_FILE` | `plugins.json` | plugin state store |
`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.
`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 # promote/demote
node scripts/backfill-car-owners.mjs # one-off: assign owner to legacy cars
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 `PB_URL` / `PB_ADMIN_EMAIL` / `PB_ADMIN_PASSWORD` from the
environment (or `.env`).
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 —
@@ -157,23 +245,36 @@ environment (or `.env`).
## First-time setup
1. **Create the PocketBase collections** (idempotent):
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:PB_URL="http://10.2.1.10:8027"
$env:PB_ADMIN_EMAIL="you@example.com"
$env:PB_ADMIN_PASSWORD="secret"
$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. **Run the server** — `go run .`, or build and run the binary (below).
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:
3. **(Optional) Seed from the spreadsheet** with the server running (see scripts).
```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 .
go build -o bin/api-server.exe ./cmd/server
```
The deployment runs the **prebuilt binary** `bin/api-server.exe` (not `go run`),
@@ -188,3 +289,24 @@ Start-Process -FilePath ".\bin\api-server.exe" -WorkingDirectory "." `
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.