Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/box-page-cars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"ftw": minor
---

The box's own page shows the owner's cars. The overview has a card for each car, plugged in or not, with its level, where the level came from and how old it is ("72% · From the car · 1 h ago", or "≈" once it is an estimate), its charge limit, its goal and the charger it is on. When a charger is in use and the box cannot tell which car it is, the charger's notice asks "Which car is on garage?" with one button per car and "None of these"; a car the box named itself can be corrected the same way. Settings has a Cars tab that lists each car with its VIN, its level sources and the tags a charger knows it by, and lets the owner rename a car, set its battery size, add one and remove one.

New routes carry the car ops for the page only: `POST /api/cars`, `PATCH` and `DELETE /api/cars/{id}`, and `POST /api/loadpoints/{id}/car`, the tap. A phone keeps sending the ops. `GET /api/cars` now leaves out each car's VIN, charge tags and sources for a request that has not proved itself as a protected one must, such as one through a public name without the API token, and says so with `redacted: true`.
5 changes: 5 additions & 0 deletions .changeset/persistent-lan-login.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": minor
---

Keep house-password browser sessions for 30 days across Core updates and restarts. Save only token digests in the database, and keep logout, expiry and password changes effective after a restart.
29 changes: 28 additions & 1 deletion docs/cars.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,22 @@ The Go types are in [`go/protocol/cars.go`](../go/protocol/cars.go).
| Read | Who | What |
|---|---|---|
| `GET /api/cars` with `ftw.cars.read` | The owner role | Every field |
| `GET /api/cars` without it | Viewers and watches | `id`, `name`, the level fields, `goal`, `surplusOnly` |
| `GET /api/cars` without it | Viewers, watches and agents | `id`, `name`, the level fields, `goal`, `surplusOnly`, and `redacted: true` |
| FTW Cloud's `car` class | Phones with `ftw.live.read` | Opaque `id`, the level fields, `goal`, `surplusOnly` |
| `cars.get` | Agents with `ftw.live.read` | The car class under the agent key ([agents.md](agents.md)) |

An agent asks for a car's goal or level with `car.goal.set` and
`car.soc.set` as `agent.request`, which waits for an owner, naming the car
by the id `cars.get` gave it. It never taps and never keeps the list.

On the box's own listener every device is the owner while the house
password is off. There `GET /api/cars` gives every field only to a request
that has proved itself as a protected one must: from a local address, from
the box itself, with the house password, or with `FTW_API_TOKEN` through a
public name. Any other request gets the shorter list with `redacted: true`,
so a box published under a public name does not hand out its owners' VINs
and charge tags.

`car.add`, `car.update` and `car.remove` need `ftw.cars.write`, which only
the owner role carries. They change no hardware, so they need no passkey
step-up. No car op waits for the dispatch gate: a stale meter must not stop
Expand Down Expand Up @@ -205,6 +213,21 @@ for what the op acts on, a level as a fraction from 0 to 1.
| `car.update` | `id`, the fields that change; `new_id` for a `boxMinted` car | Changes a car |
| `car.remove` | `id` | Removes a car and keeps its tombstone. A session on it becomes a visitor's |

The box's own page speaks HTTP, so it sends the same ops through routes
that hand the same arguments to the same checks and answer with the same
code and reason:

| Route | Op |
|---|---|
| `POST /api/cars`, the op's arguments as the body | `car.add` |
| `PATCH /api/cars/{id}`, the fields that change | `car.update` |
| `DELETE /api/cars/{id}` | `car.remove` |
| `POST /api/loadpoints/{id}/car` with `{session, car_id}` | `session.car.set` |

The first three are served only on the box's own page: a phone sends the ops,
with their id and expiry, and the session refuses these routes. The tap is
actuation, and a phone is told to send `session.car.set`.

`cmd.result` carries `car`, the car's id, level, goal and surplus choice as
the box holds them after the op, or `charger`, the charger's car after
`session.car.set`; `car.soc.set` also reads the level back in `observed`, as
Expand Down Expand Up @@ -232,6 +255,10 @@ by default: until someone taps, the car charges as a visitor.

## Where cars appear

- The box's own page shows each car on its overview, plugged in or not,
with its level, source and age, its goal and the charger it is on. A
charger's notice asks which car it is while the box asks, and Settings
has a Cars tab that keeps the list.
- `GET /api/cars` lists the cars, as above, and `removed`.
`GET /api/loadpoints` gives each charger `session`, `car_id`,
`car_identified_by`, `car_corrected`, `car_corrected_from` and `choose`.
Expand Down
38 changes: 29 additions & 9 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ type Server struct {

// New creates a new API server.
func New(deps *Deps) *Server {
restoreLANSessions(deps.State)
if deps.Version == "" {
deps.Version = "dev"
}
Expand Down Expand Up @@ -528,6 +529,15 @@ func (s *Server) routes() {
s.handle("GET /api/ev/providers", Read, s.handleEVProviders)
s.handle("GET /api/loadpoints", Read, s.handleLoadpoints)
s.handle("GET /api/cars", Read, s.handleCars)
// The box page keeps the owner's cars with the same ops as the owner's
// app (docs/cars.md). Local: a phone sends the ops as cmd frames, with
// their id and expiry, and never these routes through the passthrough.
s.handle("POST /api/cars", Local, s.handleCarAdd)
s.handle("PATCH /api/cars/{id}", Local, s.handleCarUpdate)
s.handle("DELETE /api/cars/{id}", Local, s.handleCarRemove)
// The one tap. It changes what the charger plans for, so it is
// actuation; a phone sends the op.
s.handle("POST /api/loadpoints/{id}/car", Actuate, s.handleSessionCarSet, Via(protocol.OpSessionCarSet))
// Via names the one field of this body the session can set. The target
// level and its deadline still have no command; the passthrough refuses
// the whole route either way.
Expand Down Expand Up @@ -3837,21 +3847,31 @@ type CarViews interface {

// handleCars is GET /api/cars: every car the box keeps, in the registry's
// names spelled in snake_case, and the ids an owner removed. A caller
// without ftw.cars.read (a viewer, a watch) sees each car's id, name, level,
// goal and surplus choice, and nothing that says which car it is.
// without ftw.cars.read (a viewer, a watch, an agent) sees each car's id,
// name, level, goal and surplus choice, and nothing that says which car it
// is, and the answer says so with redacted. So does a request off the LAN
// listener that has not proved itself as a protected one must: the LAN
// listener names every caller there an owner while the house password is
// off, and a box published under a public name would otherwise hand any
// visitor its owners' VINs and charge tags.
func (s *Server) handleCars(w http.ResponseWriter, r *http.Request) {
list, removed := []protocol.Car{}, []cars.Tombstone{}
if s.deps.Cars != nil {
list, removed = s.deps.Cars.Cars(), s.deps.Cars.Removed()
}
if caller, ok := apiauth.FromRequest(r); !ok || !caller.Scopes.Has(protocol.ScopeCarsRead) {
for i, c := range list {
list[i] = protocol.Car{ID: c.ID, Name: c.Name, SocPermille: c.SocPermille, SocSource: c.SocSource,
SocAgeS: c.SocAgeS, EnergySinceWh: c.EnergySinceWh, SocEstimate: c.SocEstimate,
ChargeLimitPermille: c.ChargeLimitPermille, Goal: c.Goal, SurplusOnly: c.SurplusOnly}
}
caller, ok := apiauth.FromRequest(r)
full := ok && caller.Scopes.Has(protocol.ScopeCarsRead) &&
(caller.Kind != apiauth.KindLAN || remoteProved(r, s.deps.MutationPolicy))
Comment on lines +3862 to +3864

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor the API token when LAN auth is enabled

When api.lan_auth is enabled and a client connects directly from a non-local address through a public hostname, even a valid FTW_API_TOKEN receives the redacted list. resolveLANSecret skips non-local clients, so decideLANCaller assigns the viewer scopes; this scope check then fails before remoteProved can recognize the valid token. This contradicts the documented public-token flow and prevents authenticated remote owners from seeing VINs, sources, and charge tags.

Useful? React with 👍 / 👎.

if full {
writeJSON(w, http.StatusOK, map[string]any{"cars": list, "removed": removed})
return
}
for i, c := range list {
list[i] = protocol.Car{ID: c.ID, Name: c.Name, SocPermille: c.SocPermille, SocSource: c.SocSource,
SocAgeS: c.SocAgeS, EnergySinceWh: c.EnergySinceWh, SocEstimate: c.SocEstimate,
ChargeLimitPermille: c.ChargeLimitPermille, Goal: c.Goal, SurplusOnly: c.SurplusOnly}
}
writeJSON(w, http.StatusOK, map[string]any{"cars": list, "removed": removed})
writeJSON(w, http.StatusOK, map[string]any{"cars": list, "removed": removed, "redacted": true})
}

// decorateLoadpointsWithCar shows each plugged-in charger's level as its
Expand Down
190 changes: 190 additions & 0 deletions go/internal/api/api_cars.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package api

import (
"errors"
"log/slog"
"net/http"

"github.com/srcfl/ftw/go/internal/apiauth"
"github.com/srcfl/ftw/go/internal/appproto"
"github.com/srcfl/ftw/go/internal/cars"
"github.com/srcfl/ftw/go/protocol"
)

// The car ops for the box's own page (docs/cars.md, Ops). A phone sends them
// as cmd frames; the page speaks HTTP, so each op has a route that hands the
// same arguments to the same runtime and answers with the same refusals.

// CarEdits is the part of the cars runtime that changes cars. It is apart
// from CarViews so a fake that only shows cars need not grow these.
type CarEdits interface {
Add(protocol.Car) (protocol.Car, error)
UpdateCar(id string, u cars.Update) (protocol.Car, error)
Remove(id string) error
SetSessionCar(lpID, sessionID, carID string) (protocol.ChargerCar, error)
}

// carEdits returns the runtime's edits, or answers 503 as the ops answer
// E_UNAVAILABLE {subsystem: cars} on a box without cars.
func (s *Server) carEdits(w http.ResponseWriter) (CarEdits, bool) {
edits, ok := s.deps.Cars.(CarEdits)
if !ok || edits == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "this box keeps no cars", "code": appproto.ErrUnavailable, "reason": "cars"})
return nil, false
}
return edits, true
}

// mayKeepCars refuses a caller without ftw.cars.write, which only the owner
// role carries. The LAN's own page has every scope.
func mayKeepCars(w http.ResponseWriter, r *http.Request) bool {
if caller, ok := apiauth.FromRequest(r); ok && caller.Scopes.Has(protocol.ScopeCarsWrite) {
return true
}
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only an owner keeps the list of cars"})
return false
}

// writeCarError answers a car op's error with the op's code and reason, so
// the page can say what went wrong in words.
func writeCarError(w http.ResponseWriter, op string, err error) {
code, reason, ok := appproto.CarRefusal(err)
if !ok {
// A save the box could not confirm: it may hold the change or not.
slog.Warn("car op not saved", "op", op, "err", err)
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "the box could not confirm the change was saved", "code": appproto.ErrUnavailable, "reason": "persistence_unconfirmed"})
return
}
status := http.StatusConflict
switch {
case code == appproto.ErrNotFound:
status = http.StatusNotFound
case errors.Is(err, cars.ErrInvalid):
status = http.StatusBadRequest
}
writeJSON(w, status, map[string]string{"error": err.Error(), "code": code, "reason": reason})
}

// carArgs reads a route's body as the op's arguments: the map a cmd frame
// carries, so the op's own checks read it.
func carArgs(w http.ResponseWriter, r *http.Request) (map[string]any, bool) {
var args map[string]any
if err := readJSON(r, &args); err != nil || args == nil {
msg := "body must be a JSON object"
if err != nil {
msg = err.Error()
}
writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg})
return nil, false
}
return args, true
}

// writeBadCarArg refuses one argument as the op does: E_UNKNOWN_OP naming
// it, with the reason the page reads as "check what you typed".
func writeBadCarArg(w http.ResponseWriter, arg string) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad argument: " + arg,
"code": appproto.ErrUnknownOp, "reason": "invalid", "arg": arg})
}

// POST /api/cars is car.add. The body is car.add's arguments: the id the
// page minted and the car's fields, in the names of GET /api/cars.
func (s *Server) handleCarAdd(w http.ResponseWriter, r *http.Request) {
if !mayKeepCars(w, r) {
return
}
edits, ok := s.carEdits(w)
if !ok {
return
}
args, ok := carArgs(w, r)
if !ok {
return
}
car, bad := appproto.CarAddArgs(args)
if bad != "" {
writeBadCarArg(w, bad)
return
}
added, err := edits.Add(car)
if err != nil {
writeCarError(w, protocol.OpCarAdd, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"car": added})
}

// PATCH /api/cars/{id} is car.update: the fields present change, the rest
// stay as they were. The car is the one the path names.
func (s *Server) handleCarUpdate(w http.ResponseWriter, r *http.Request) {
if !mayKeepCars(w, r) {
return
}
edits, ok := s.carEdits(w)
if !ok {
return
}
args, ok := carArgs(w, r)
if !ok {
return
}
id := r.PathValue("id")
args["id"] = id
u, bad := appproto.CarUpdateArgs(args)
if bad != "" {
writeBadCarArg(w, bad)
return
}
car, err := edits.UpdateCar(id, u)
if err != nil {
writeCarError(w, protocol.OpCarUpdate, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"car": car})
}

// DELETE /api/cars/{id} is car.remove. The box keeps the id as removed.
func (s *Server) handleCarRemove(w http.ResponseWriter, r *http.Request) {
if !mayKeepCars(w, r) {
return
}
edits, ok := s.carEdits(w)
if !ok {
return
}
if err := edits.Remove(r.PathValue("id")); err != nil {
writeCarError(w, protocol.OpCarRemove, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}

// POST /api/loadpoints/{id}/car is session.car.set, the one tap. The body
// names the plug-in it answers, {session, car_id}; an empty car_id makes the
// session a visitor's.
func (s *Server) handleSessionCarSet(w http.ResponseWriter, r *http.Request) {
edits, ok := s.carEdits(w)
if !ok {
return
}
var req struct {
Session string `json:"session"`
CarID *string `json:"car_id"`
}
if err := readJSON(r, &req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
// The same arguments the op requires: a tap names its plug-in, and says
// which car or none.
if req.Session == "" || req.CarID == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session and car_id required"})
return
}
held, err := edits.SetSessionCar(r.PathValue("id"), req.Session, *req.CarID)
if err != nil {
writeCarError(w, protocol.OpSessionCarSet, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"charger": held})
}
Loading
Loading