5 Commits
Author SHA1 Message Date
tajniak81andClaude Opus 5 3c34b708b9 A mode that does not work stops being on the menu
The control-mode picker offered all five paths to everyone, always. When one of
them is broken in a deployment — OCPP is, right now — there was nothing to do
about it: the superadmin could pick a different mode for the global layer, but
the option stayed in every organization's and every user's dropdown, waiting to
be chosen. The cascade could impose a mode. It could not withdraw one.

So each layer now carries a second, separate thing: a list of the modes it hides
from the layers below it. controlModesDisabled sits beside controlMode, on the
global layer as a plugin config field and on an organization as part of the same
pluginSettings blob its credentials already live in. A superadmin ticking Own
CSMS and Proxy CSMS takes both OCPP paths out of every picker underneath; an org
admin ticking Modbus takes it out of their own users'.

Three decisions are worth naming.

A hide-list governs the layers below, not the layer holding it. The superadmin
can keep running Proxy globally while hiding it from everyone else, which is
what you want while a mode is being repaired rather than retired: the operator
testing the fix is the one person who still needs to select it. The alternative,
a list that also invalidates its own layer's choice, would have made the panel
contradict itself — a mode chosen in one field and switched off in the one below
it.

But a hidden mode really is hidden, not merely absent from a dropdown. A user
who had picked Proxy last month stops resolving to Proxy the moment the
superadmin hides it, and falls back to monitoring only. Filtering the picker
alone would have left every existing charger on the broken path and quietly
disagreed with the list the operator had just filled in. Resolution now walks
the layers accumulating what each hides from the next, so a stored value only
takes effect if the layers above it still permit it.

And off is never hideable. It is what a charger falls back to and what an empty
cascade resolves to, so a layer that could take it away could leave the layer
below with a picker holding no valid choice at all. It is not among the
checkboxes in any of the three clients, and the parser drops it if it arrives
anyway.

The panel needed a field shape it did not have — several options, any number
chosen — so ConfigField grows a "multiselect" type, stored as the
comma-separated string that fits the flat map every other field already uses.
That is generic: any plugin can declare one now, and the PUT body is unchanged.
The phone's field specs grew the same way, a scopeOptions hook that narrows a
declared option list to what the server still offers, rather than teaching the
integration card about control modes specifically.

Both clients clamp a stored mode that has since been hidden back to off before
drawing the picker, so the box shows what will actually happen rather than a
choice that would be dropped on save.

Verified: Go tests pass, both frontends build, flutter analyze is clean, and the
panel's new checkbox field was rendered against the real stylesheet. The
end-to-end path — superadmin hides a mode, an org admin and then a user reload
and find it gone — has not been walked on a live stack; the panel is embedded in
the Go binary, so the remote deployment needs a rebuild before any of this is
visible there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 12:54:30 +02:00
tajniak81andClaude Opus 5 a7cab50e06 Apprise: a gateway to hand a message to, not a hundred protocols to carry
Apprise is a Python library that speaks 100+ notification services behind one URL
grammar — mailto://, tgram://, ntfy://, discord://. None of that is portable to a
server that takes no dependencies, and none of it needs to be: caronc/apprise-api
wraps the library in HTTP and is meant to run as a container beside us. So the
connector carries no notification protocols of its own. It posts a body to an
endpoint the operator runs and lets Apprise fan it out, which is also why adding
a service later costs nothing here.

Targets are addressed one of two ways and configKey is the switch. Stateful means
the URLs live on the Apprise server under a key, narrowed by a tag expression, and
recipients are then edited there — no credential for any downstream service is
ever held in DriverVault. Stateless means the URLs travel with the request, from a
secret config field, which is simpler for one destination and worse for ten. A
call that names its own key or urls takes that destination alone rather than
merging with the configured one: honouring a caller's URLs while still falling
back to the configured key would deliver the message somewhere nobody asked for.

baseUrl is Required, which no other connector's address is. Toyota, Anker and
Greencell leave everything blank at the global layer because the superadmin → org
→ user cascade exists to fill it in, and a blank there means "let the user
choose". There is no cascade behind this one — a notification gateway is
infrastructure the operator runs, not an account a driver owns — so nothing
further down can supply the address, and a blank is simply a plugin that cannot
work. Better to fail at enable than at the first notification nobody sees.

Three limits are choices rather than gaps. /add and /del are not implemented: the
Apprise config belongs to the operator, we post to it, and a connector that can
delete a notification config has a wider blast radius than one that can only send
through it. privacy=1 is forced on /json/urls rather than offered as a parameter,
so a target listing reads mailto://user:****@host and downstream tokens stay on
the Apprise side of the wire. Attachments are remote URLs the Apprise server
fetches; multipart upload is the API's own path for files and not ours.

Health follows the rule Greencell set. A reachable server whose config holds
nothing to notify is degraded, not down: the half we address works and the missing
half is the operator's config. Two cases earn their own line — a config key set
against a server running with stateful mode disabled can never resolve, and /status
answers 417 rather than 500 when Apprise finds a problem with itself, so that is a
parsed answer and not a transport failure. A proxy that strips our Accept header
gets the same codes back as plain text, which is read rather than called
unreadable; an HTML error page from something that is not Apprise is not, and a
test pins the difference.

Notifications needed a category of their own, and that is the one change outside
the plugin: the constant, the tab order in PluginsCard.vue, and the label in all
three panel languages. The cost is now written down in the plugins README beside
the Descriptor example, since the previous five categories predate anyone having
to add a sixth.

The plugin's tests run against an apprise-api stand-in built from that project's
views.py — both notify paths, the override rules, 204-as-empty against
424-as-failure, and every health branch. builtin_test.go is the other half: the
blank-import list in builtin.go is a silent failure mode, since a connector left
out of it compiles, passes its own tests, and never appears in the panel. What is
not covered is a live instance; there is no Docker on this machine, so the wire
contract comes from reading upstream's source rather than from running it, and a
smoke test against a real deployment is still worth doing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 22:43:07 +02:00
tajniak81andClaude Opus 5 e648634ce1 The plugin list, grouped by what a plugin actually is
Category has been in the plugin contract since it was written — apis-external,
drives-external, drives-local — and every builtin declared the same one, so it
grouped nothing. Two of the three talk to a wallbox and one talks to a car
manufacturer, and those are different questions an operator arrives with: the
Toyota card is where a driver's account gets linked, the Anker and Greencell
cards are where a charger's broker and credentials live. So vehicles and
chargers join the constants and the three builtins say which they are.

The panel groups on that field rather than on a list of names, which is what
keeps an external plugin from needing panel code. Tab order mirrors the
constants; a category with nothing in it gets no tab, and a single group hides
the bar entirely, so an install with one connector looks exactly as it did.
A category the panel does not recognise — or an empty one — falls to the
external-APIs tab rather than vanishing, because a plugin nobody can see is a
plugin nobody can disable. The selected tab falls back to the first group when
its own goes away, which is what removing the last external plugin does.

Registration still asks only for name, base URL and provider, so a plugin
registered at runtime lands under Other APIs until its manifest names a
category. That path already works and is the honest default: the panel is
guessing about a service it has never spoken to, and the service can say.

The header lockup is the other half. It was a copy of the Web App's mark rather
than the same mark, and copies drift — a 32px icon against 28, a 24px wordmark
against 21.6, "Driver" at text-strong instead of white, "Vault" a step lighter
than brand-400. The Web App's Logo.vue moves in verbatim, props included. The
one thing it cannot inherit is which variant to render: the Web App's rail is
always dark, while this panel flips with its own theme toggle, so on-dark is
bound to the theme and the hand-rolled bar fills that existed to survive that
flip are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 23:30:56 +02:00
tajniak81andClaude Opus 5 ee4ac441be Plugins: drop the plugins.json migration, and the volume it needed
The project has no public installs, so there is nothing to migrate from.
MigrateLegacyFile, the file-backed Store it read through, PLUGINS_FILE and
the legacy path threaded through the Server all go. What is left is one
store, PocketBase, and a plugins package that touches no filesystem at all.

That was the last thing keeping api_data alive, so the volume goes too. All
four compose files now declare exactly one volume, pb_data, and the
standalone API Server compose declares none - it talks to an external
PocketBase and has nothing of its own to keep. Backing up the stack is
backing up one path again.

Both images get simpler for it. The API Server image loses VOLUME /data and
the su-exec entrypoint that existed only to fix a mounted volume's
ownership, so it goes back to a plain USER app; its working directory is
now /app and holds nothing. The AIO image loses its second volume and
chowns only /pb/pb_data.

One consequence worth stating plainly, because it is a small regression
rather than a no-op. The panel's Settings -> PocketBase and Settings -> Web
App screens write .env in the working directory, which is now ephemeral. In
the multi-container stack that changes nothing: compose sets all five of
those keys as container environment, and loadDotEnv only applies a key that
is not already set, so the file could never win a restart there anyway. In
the AIO image it did win for POCKETBASE_ADMIN_EMAIL/_PASSWORD, which are
not in that container's environment - so a service account fixed from the
panel now lasts only until the container is recreated. Both READMEs say so.
Moving those two screens into the app_settings singleton would close it
properly; the PocketBase URL and credentials cannot follow, since they are
how the database is reached in the first place.

go build, go vet and go test ./... pass; the compose files parse and each
resolves to a single pb_data volume. Not verified: no Docker CLI here, so
neither image was built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:41:02 +02:00
tajniak81andClaude Opus 4.8 ae6ed4ac1e 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>
2026-07-16 22:29:45 +02:00