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:
co-authored by
Claude Opus 4.8
parent
7d55f0a4cd
commit
ae6ed4ac1e
+208
-86
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user