diff --git a/.changeset/mcp-setup-calls.md b/.changeset/mcp-setup-calls.md new file mode 100644 index 000000000..27a74822a --- /dev/null +++ b/.changeset/mcp-setup-calls.md @@ -0,0 +1,7 @@ +--- +"ftw": minor +--- + +Owners can grant an assistant setup access as a separate choice. MCP reads the box's complete setup schema, resumes a draft, edits one field or list entry, validates and saves the exact draft the user confirms. The box retains its Settings checks, reports pending restarts and preserves a save receipt. A separate confirmed call can restart to apply the exact saved setup, with a live grant and revision check before the restart. Saving does not prove physical measurement or control. + +Every draft write and final Settings transaction rechecks the live grant and request deadline after preparation. Revoked access and ended requests leave both settings and commit receipts unchanged. diff --git a/VISION.md b/VISION.md index 7f2080c5a..e58ca404f 100644 --- a/VISION.md +++ b/VISION.md @@ -10,6 +10,15 @@ primitives an agent or another automation needs in order to work with the home's energy system. Home Assistant, MQTT and a database the owner runs for their own analysis sit on top. Core does not absorb those tools. +An owner must be able to set up the full system through an authorized +assistant, starting with a new box. The conversation should ask only for +missing facts and choices, keep an unfinished draft on the box, and separate +saved settings from measured and verified operation. The owner must also be +able to share a revocable grant with another person's own assistant through +a code. Everyday status and history must stay direct, without a setup +interview. These are requirements; the [agent-led setup proposal](docs/adr/0011-agent-led-setup.md) +names the delivery and evidence still needed. + This is the product direction set by Fredrik. It guides design and review; it does not claim that every outcome below has shipped. The [roadmap](docs/roadmap.md) names the work and evidence needed to reach it. diff --git a/contract/registry.yaml b/contract/registry.yaml index 0ae5357af..b8c70b460 100644 --- a/contract/registry.yaml +++ b/contract/registry.yaml @@ -142,6 +142,7 @@ scopes: - { name: ftw.logs.read, desc: Log excerpts the box redacts } - { name: ftw.config.read, desc: Configuration the box redacts } - { name: ftw.diag.run, desc: Full technical diagnostics and read API } + - { name: ftw.setup.write, desc: Save confirmed system setup and restart to apply it after user confirmation } - { name: ftw.dispatch.hold, desc: Hold energy assets for a capped time } - { name: ftw.goals.request, desc: Ask an owner to change a durable goal } # Reserved: no op asks for an update yet, and no role but owner carries it. @@ -173,10 +174,10 @@ roles: # is the release check, which reads a health summary and nothing else. agent: label: Assistant - scopes: [ftw.live.read, ftw.history.read, ftw.plan.read, ftw.health.read, ftw.events.read, ftw.logs.read, ftw.config.read, ftw.diag.run, ftw.dispatch.hold, ftw.goals.request] + scopes: [ftw.live.read, ftw.history.read, ftw.plan.read, ftw.health.read, ftw.events.read, ftw.logs.read, ftw.config.read, ftw.diag.run, ftw.setup.write, ftw.dispatch.hold, ftw.goals.request] support: label: Sourceful support - scopes: [ftw.live.read, ftw.history.read, ftw.plan.read, ftw.health.read, ftw.events.read, ftw.logs.read, ftw.config.read, ftw.diag.run, ftw.dispatch.hold, ftw.goals.request] + scopes: [ftw.live.read, ftw.history.read, ftw.plan.read, ftw.health.read, ftw.events.read, ftw.logs.read, ftw.config.read, ftw.diag.run, ftw.setup.write, ftw.dispatch.hold, ftw.goals.request] release: label: Release check scopes: [ftw.health.read] @@ -510,3 +511,14 @@ charger_car_fields: - { name: carCorrected, kind: bool, unit: null, desc: A better rule replaced the car the box first named } - { name: carCorrectedFrom, kind: text, unit: null, desc: The car the box named before that } - { name: choose, kind: text_list, unit: null, desc: While asking the cars a tap may pick. At most four } + +# Agent setup calls use the box's typed draft service. They do not carry an +# HTTP path or grant general configuration or membership authority. +agent_calls: + - { msg: setup.schema, const: MsgSetupSchema, scope: ftw.diag.run, desc: Read this build's complete setup schema } + - { msg: setup.status, const: MsgSetupStatus, scope: ftw.diag.run, desc: Resume the masked setup draft } + - { msg: setup.edit, const: MsgSetupEdit, scope: ftw.setup.write, desc: Patch the exact current setup draft } + - { msg: setup.reset, const: MsgSetupReset, scope: ftw.setup.write, desc: Start a new draft from current settings } + - { msg: setup.validate, const: MsgSetupValidate, scope: ftw.setup.write, desc: Check the exact setup draft without applying it } + - { msg: setup.commit, const: MsgSetupCommit, scope: ftw.setup.write, desc: Save the exact checked and confirmed draft } + - { msg: setup.restart, const: MsgSetupRestart, scope: ftw.setup.write, desc: Restart to apply the exact saved setup after user confirmation } diff --git a/docs/agents.md b/docs/agents.md index 78689ef0d..b6def1c45 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -1,6 +1,7 @@ # Agents and support access -Proposed. Nothing here has shipped. This document is normative: "must" is a +This document defines the agent contract. Some operations remain proposed; +the connected box reports what its build supports. The contract is normative: "must" is a requirement on the box, FTW Cloud or the app. It extends the app protocol ([srcfl/ftw-webapp docs/protocol.md](https://github.com/srcfl/ftw-webapp/blob/main/docs/protocol.md)) and the [FTW Cloud protocol](protocol-cloud.md). Names in `code` that are @@ -27,10 +28,11 @@ act. Each check runs again on every call, so a revoke or an expiry bites at the next call whatever FTW Cloud does. **What FTW Cloud sees.** It holds the agent's key, so it can read every -answer an agent gets. Answers to agents therefore name nothing: sources, -chargers and cars go by opaque ids the box cuts with a key of its own, logs -and configuration are redacted on the box, and an agent has no `sub` stream, -whose source table carries names. +answer an agent gets. Typed routine reads use opaque source, charger and car ids and redact logs +and configuration. Full technical diagnostics preserve names, addresses, +source code, exact measurements and exports, including backups that can hold +saved credentials. The owner-approved diagnostic scope covers those reads +without extra endpoint grants. An agent has no `sub` stream. ## Principals @@ -75,16 +77,18 @@ these: |---|---| | `ftw.logs.read` | Read a log excerpt that the box redacts before it leaves | | `ftw.config.read` | Read the configuration, redacted on the box | -| `ftw.diag.run` | Run checks that change nothing | +| `ftw.diag.run` | Read full technical diagnostics and exports; backups can contain saved credentials | +| `ftw.setup.write` | Read and edit full setup drafts, save the exact confirmed draft and restart to apply it after confirmation | | `ftw.dispatch.hold` | Hold a charger for a capped time; the hold ends unless renewed | | `ftw.goals.request` | Ask for a durable change, which waits for an owner | | `ftw.update.request` | Reserved: no op uses it yet | The roles `agent` and `support` carry at most `ftw.live.read`, `ftw.history.read`, `ftw.plan.read`, `ftw.health.read`, `ftw.events.read`, -`ftw.logs.read`, `ftw.config.read`, `ftw.diag.run`, `ftw.dispatch.hold` and +`ftw.logs.read`, `ftw.config.read`, `ftw.diag.run`, `ftw.setup.write`, `ftw.dispatch.hold` and `ftw.goals.request`. A grant names a subset. Agents read by default; an owner -ticks control to add `ftw.dispatch.hold` or `ftw.goals.request`. +ticks control to add `ftw.dispatch.hold` or `ftw.goals.request`. Setup has its +own choice, off by default. Control alone never adds setup access. ## What an agent may send @@ -412,6 +416,56 @@ The registry's `reads` table names each read and the scope it needs. lines. `beforeMs` and `afterMs` together pick the lines between them; an `afterMs` at or past `beforeMs` gets `E_UNKNOWN_OP {field: "beforeMs"}`. +## Technical reads and setup + +`api.read` carries `APIRead` from `go/protocol/agentapi.go` and requires +`ftw.diag.run`. Start with `/api/diagnostics/catalog` to get this build's +routes, source links and bug-report workflow. It carries GET reads only. +Large exports use byte pages and a digest to detect a changed answer. +Typed routine reads keep the redaction described below. + +The registry's `agent_calls` table defines seven setup messages: + +| Message | Right | JSON request | +|---|---|---| +| `setup.schema` | `ftw.diag.run` | `{}`; returns full typed schema, driver catalogue and price zones | +| `setup.status` | `ftw.diag.run` | `{}`; returns masked draft, revisions, digest and save receipt | +| `setup.edit` | `ftw.setup.write` | `{revision, patch}` or `{revision, operations}` | +| `setup.reset` | `ftw.setup.write` | `{revision}`; starts a new draft from current settings; does not restart Core | +| `setup.validate` | `ftw.setup.write` | `{revision, base_revision, sha256}` | +| `setup.commit` | `ftw.setup.write` | `{revision, base_revision, sha256, confirmed}` | +| `setup.restart` | `ftw.setup.write` | `{saved_revision, sha256, confirmed}`; only the exact saved setup | + +Each request is `SetupCall {json: bytes}` from `go/protocol/setup.go`, at most +10 KiB within one bulk frame. Replies use `api.head/chunk/end`. A merge patch +replaces whole arrays. JSON Patch `add`, `replace` and `remove` can edit one +entry without resending a large list. Indexes refer only to the exact draft +revision; a concurrent edit conflicts. Core rejects unknown config fields. + +A read never discovers equipment or applies settings. The agent asks for +missing facts in small groups, accepts unknown answers, validates the exact +draft and shows changes and restart needs before asking the user to confirm. +Commit binds both revisions and the digest, checks the live grant again at +the final save, then uses the normal Settings transaction and applier. The +receipt commits with Settings; retrying that exact save returns the receipt. +Replies distinguish saved, applied, restart pending and hardware verification. +A later draft or setup save keeps earlier restart needs until a real process +restart. A confirmed `setup.restart` checks the saved receipt, live revision +and live grant again just before signalling the existing graceful restart. +A lost reply leaves the outcome unknown; read setup status and the receipt +before retrying the same operation. A restart request never proves application. + +Setup reads use the existing full diagnostic grant because they retain names, +addresses and connection details while masking credentials. They do not widen +an old redacted `ftw.config.read` grant. Replies to setup writes carry the same +masked draft under the owner's new, explicit setup grant. + +Setup keeps the agent's identity. It opens no general HTTP writes, access +grants, Cloud consent, house-password changes or direct hardware commands. +Saved equipment settings can affect operation under Core's safety checks. +Physical measurement and control need separate evidence. First-run claiming, +the guided interview and sharing codes follow [ADR 0011](adr/0011-agent-led-setup.md). + ## Redaction on the box `logs.get` and `config.get` build their answer on the box. Nothing leaves it diff --git a/docs/roadmap.md b/docs/roadmap.md index 775808e0c..c308a5a49 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -25,7 +25,7 @@ of Core; new solver work belongs in Energyplan. |---|---|---| | Remote clients, 17 October 2026 | [Core #1687](https://github.com/srcfl/ftw/pull/1687), [Cloud #28](https://github.com/srcfl/ftw-cloud/pull/28), [web #95](https://github.com/srcfl/ftw-webapp/pull/95) and [iOS #83](https://github.com/srcfl/ftw-app/pull/83) converge on the Cloud tunnel. Core validates each command; browser offline notices remain box-encrypted. Core [v0.147.0-beta.1](https://github.com/srcfl/ftw/releases/tag/v0.147.0-beta.1) and the web client are published; iOS 0.4.8 build 13 is uploaded to App Store Connect. Cloud runs in Stockholm and the Frankfurt relay is stopped, with disk and address kept for rollback. Escrow remains separate. The production Cloud test paired a simulated box, confirmed 50 commands and resumed five times with no relay dial. | Update older boxes and pair older clients again. Confirm physical-site behaviour separately; the Cloud test used no hardware. | | Mixed equipment | Separate SolarEdge legacy and Pixii drivers exist. Their [published evidence](https://github.com/srcfl/device-drivers/blob/7e5946555245dee2150d46d1b7278aaec5ebe242/drivers/lua/solaredge_legacy.lua) and [Pixii metadata](https://github.com/srcfl/device-drivers/blob/7e5946555245dee2150d46d1b7278aaec5ebe242/drivers/lua/pixii.lua) still say experimental; the legacy header and declared curtail capability also need to agree. | Reconcile known field runs with the catalog, then verify the named inverter + battery + charger combination. Distinguish measured telemetry, verified commands and unverified control. Metadata is not evidence that no user has ever run the hardware. | -| Setup and usable power | Discovery, fingerprinting and per-device settings exist. [Planner battery limits](../go/cmd/ftw/main.go) use configured limits or a 0.5C estimate, then an aggregate fuse cap. | A guided commissioning result and measured usable-power learning are still missing from the reviewed setup path. Read verified limits first; learn response within them. A capacity-derived estimate is not a learned power limit. | +| Setup and usable power | Discovery, fingerprinting and per-device settings exist. [Planner battery limits](../go/cmd/ftw/main.go) use configured limits or a 0.5C estimate, then an aggregate fuse cap. | Complete the [agent-led setup](adr/0011-agent-led-setup.md) path from a new box, including resumable drafts, explicit setup rights, shared access by code and verified commissioning. A guided commissioning result and measured usable-power learning are still missing from the reviewed setup path. Read verified limits first; learn response within them. A capacity-derived estimate is not a learned power limit. | | Forecasts and defaults | [PV learning](../optimizer/native/README.md) needs no panel geometry or rating. [Cold-load selection](../go/cmd/ftw/forecast_tracking.go) keeps the site prior until learning, and [load/net-risk tests](../go/cmd/ftw/forecast_load_risk_test.go) cover uncertainty. Charge and discharge efficiency are included. The [minimum arbitrage spread](../go/internal/config/config.go) defaults to zero. | Verify first-day and learned performance on held-out site periods. Keep the separate opt-in wear-cost requirement open; a minimum arbitrage spread is not a general wear model. Align all persisted defaults and worker support before exposing such a setting. Do not rebuild cold-start support already delivered in #1204. | | Daily charging | [The webapp panel](https://github.com/srcfl/ftw-webapp/blob/ff7af033fa3fcdeb38882e3ff365e8d6d7aba75a/src/views/EvPanel.svelte) already saves SoC on slider release, changes schedules without a Save button and supports Charge now. | [Now](https://github.com/srcfl/ftw-webapp/blob/ff7af033fa3fcdeb38882e3ff365e8d6d7aba75a/src/views/Now.svelte) normally opens that panel after a charger tap or notification link. Bring the relevant SoC action directly into the post-plug-in entry experience. Complete the goal → plan → delivered-energy flow on real chargers, including offline cars and restarts. | | Notifications | Core and the webapp implement subscription, charging connection/completion/interruption events and device alerts. See the [shared push catalogue](../contract/push-catalogue.yaml) and [rule defaults](../go/internal/notifications/service.go). | There is no dedicated predicted-missed-departure event in that catalogue. Add an actionable goal-risk notification and make activation clear during charging setup, with user consent. Verify delivery while the app is closed; an interrupted-session alert alone does not cover a future shortfall. | diff --git a/go/cmd/ftw/app_link_agent_setup.go b/go/cmd/ftw/app_link_agent_setup.go new file mode 100644 index 000000000..268b235c9 --- /dev/null +++ b/go/cmd/ftw/app_link_agent_setup.go @@ -0,0 +1,20 @@ +package main + +import ( + "net/http" + + "github.com/srcfl/ftw/go/protocol" +) + +// ServeAgentSetup forwards the typed setup entry through the wrapper used +// by real app sessions. The bound API keeps the agent's live authority. +func (l *lateAPI) ServeAgentSetup(w http.ResponseWriter, r *http.Request, message string, call protocol.SetupCall, authorize func() error) { + srv := l.srv.Load() + if srv == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error":"the box is still starting"}`)) + return + } + srv.ServeAgentSetup(w, r, message, call, authorize) +} diff --git a/go/cmd/ftw/app_link_agent_setup_test.go b/go/cmd/ftw/app_link_agent_setup_test.go new file mode 100644 index 000000000..5c7e16164 --- /dev/null +++ b/go/cmd/ftw/app_link_agent_setup_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/srcfl/ftw/go/internal/api" + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/protocol" +) + +type setupGateway interface { + ServeAgentSetup(http.ResponseWriter, *http.Request, string, protocol.SetupCall, func() error) +} + +var _ setupGateway = (*lateAPI)(nil) + +func TestProductionGatewayForwardsTypedSetupAndLiveAuthority(t *testing.T) { + gateway := &lateAPI{} + var production setupGateway = gateway + r := httptest.NewRequest(http.MethodGet, "/api/setup/schema", nil) + r = r.WithContext(apiauth.WithCaller(r.Context(), apiauth.Caller{Kind: apiauth.KindAgent, Role: apiauth.RoleAgent, Subject: "agent:test", Scopes: apiauth.NewScopeSet(protocol.ScopeDiagRun)})) + call := protocol.SetupCall{JSON: []byte(`{}`)} + checks := 0 + authorize := func() error { checks++; return nil } + starting := httptest.NewRecorder() + production.ServeAgentSetup(starting, r, protocol.MsgSetupSchema, call, authorize) + if starting.Code != http.StatusServiceUnavailable { + t.Fatalf("before bind: %d", starting.Code) + } + gateway.bind(api.New(&api.Deps{})) + answer := httptest.NewRecorder() + production.ServeAgentSetup(answer, r, protocol.MsgSetupSchema, call, authorize) + var doc struct { + Schema map[string]any `json:"schema"` + } + if answer.Code != http.StatusOK || json.Unmarshal(answer.Body.Bytes(), &doc) != nil || len(doc.Schema) == 0 || checks == 0 { + t.Fatalf("typed setup did not reach API: %d %s checks=%d", answer.Code, answer.Body.String(), checks) + } + revoked := httptest.NewRecorder() + production.ServeAgentSetup(revoked, r, protocol.MsgSetupSchema, call, func() error { return errors.New("revoked") }) + if revoked.Code != http.StatusForbidden { + t.Fatalf("live authority lost: %d", revoked.Code) + } +} diff --git a/go/cmd/ftw/beta4_audit_test.go b/go/cmd/ftw/beta4_audit_test.go index 1ec17df89..c0f2e4c2d 100644 --- a/go/cmd/ftw/beta4_audit_test.go +++ b/go/cmd/ftw/beta4_audit_test.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "os" + "os/exec" "path/filepath" "testing" "time" @@ -57,6 +58,16 @@ func TestBeta4AuditPlannerAndControlKeepEachKnownBatteryLimit(t *testing.T) { // fixture with current Configure: that would stop testing an upgrade. func TestBeta4AuditUpgradePreservesForecastLearning(t *testing.T) { t.Setenv("TZ", "UTC") + // localzone deliberately captures the process zone once. The historical + // fixture needs UTC even when another API test has already read that zone. + if os.Getenv("FTW_BETA3_FORECAST_FIXTURE") != "1" { + cmd := exec.Command(os.Args[0], "-test.run=^TestBeta4AuditUpgradePreservesForecastLearning$", "-test.count=1") + cmd.Env = append(os.Environ(), "FTW_BETA3_FORECAST_FIXTURE=1") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("UTC fixture subprocess: %v\n%s", err, output) + } + return + } raw, err := os.ReadFile("testdata/beta3-forecast-binding.json") if err != nil { t.Fatal(err) diff --git a/go/internal/api/api.go b/go/internal/api/api.go index ed2c327f5..42a06f614 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -130,7 +130,7 @@ type Deps struct { SelfTune *selftune.Coordinator DtS float64 // control interval seconds (for model τ / age displays) SaveConfig func(path string, c *config.Config) error // injection for testability - SaveConfigWithValues func(path string, c *config.Config, values map[string]string, reviewedJSON []byte) error + SaveConfigWithValues func(path string, c *config.Config, values map[string]string, reviewedJSON []byte, beforeSave func() error) error // ConfigApplier is main.go's config-applied callback — the same // closure the configreload watcher runs (registry reload with SoC // bounds, capacities, inverter groups, fuse and mpc/loadmodel @@ -260,10 +260,11 @@ type Deps struct { // Server wraps the http.ServeMux and adds shared middleware (logging, // no-cache headers on static assets). type Server struct { - configWriteMu sync.Mutex // Covers persistence and apply for every config writer. - deps *Deps - mux *http.ServeMux - setup *onboarding.Service + configWriteMu sync.Mutex // Covers persistence and apply for every config writer. + deps *Deps + mux *http.ServeMux + setup *onboarding.Service + setupRestartRevision int64 // Protected by configWriteMu; a lost reply must not queue another restart. // dailyCache memoizes per-local-day energy totals keyed by "YYYY-MM-DD". // Past days are immutable once the day ends, so we only ever recompute @@ -332,8 +333,8 @@ func New(deps *Deps) *Server { } s.setup = onboarding.NewWithSecrets(deps.State, s.preserveSetupSecrets, filepath.Dir(deps.ConfigPath)) if deps.SaveConfigWithValues == nil && deps.State != nil { - deps.SaveConfigWithValues = func(path string, cfg *config.Config, values map[string]string, reviewedJSON []byte) error { - return config.SaveStoredReviewedWithValues(deps.State, path, cfg, values, reviewedJSON) + deps.SaveConfigWithValues = func(path string, cfg *config.Config, values map[string]string, reviewedJSON []byte, beforeSave func() error) error { + return config.SaveStoredReviewedWithCheck(deps.State, path, cfg, values, reviewedJSON, beforeSave) } } if deps.Registry != nil { @@ -1726,6 +1727,11 @@ func (s *Server) handlePostConfigWith(w http.ResponseWriter, r *http.Request, re // Persist atomically (Password has yaml:"-" so it won't appear in YAML) if err := save(s.deps.ConfigPath, &newCfg); err != nil { + var denied *setupAccessError + if errors.As(err, &denied) { + writeJSON(w, denied.status, map[string]string{"error": denied.Error()}) + return + } status := http.StatusInternalServerError if errors.Is(err, config.ErrReviewedConfigChanged) { status = http.StatusConflict @@ -1984,6 +1990,10 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) { // drivers/ directory, parsed from each .lua file's DRIVER metadata. // Used by the Settings UI to offer an "Add from catalog" dropdown. func (s *Server) handleDriversCatalog(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, s.driverCatalogReply()) +} + +func (s *Server) driverCatalogReply() map[string]any { // Default is next to the config file; overridable via -drivers for // deployments (Docker) where drivers live in the image, not the data volume. dir := s.deps.DriverDir @@ -2001,8 +2011,7 @@ func (s *Server) handleDriversCatalog(w http.ResponseWriter, r *http.Request) { drivers.CatalogSource{Dir: dir, Source: "bundled"}, ) if err != nil { - writeJSON(w, 200, map[string]any{"path": dir, "entries": []any{}, "error": err.Error()}) - return + return map[string]any{"path": dir, "entries": []any{}, "error": err.Error()} } if s.deps.DriverRepository != nil { entries = s.deps.DriverRepository.EnrichCatalog(entries) @@ -2041,7 +2050,7 @@ func (s *Server) handleDriversCatalog(w http.ResponseWriter, r *http.Request) { entries[i].Chosen = s.deps.DriverRepository.Chosen(entries[i].Path, entries[i].Version) } } - writeJSON(w, 200, map[string]any{"path": dir, "entries": entries}) + return map[string]any{"path": dir, "entries": entries} } // POST /api/drivers/{name}/restart — stop + re-add the driver so it @@ -2636,6 +2645,10 @@ func (s *Server) handlePrices(w http.ResponseWriter, r *http.Request) { // // Response: {"zones": [{code, country, currency, name}]} func (s *Server) handlePriceZones(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, 200, map[string]any{"zones": priceZoneItems()}) +} + +func priceZoneItems() []map[string]string { all := prices.Zones() items := make([]map[string]string, 0, len(all)) for _, z := range all { @@ -2646,7 +2659,7 @@ func (s *Server) handlePriceZones(w http.ResponseWriter, _ *http.Request) { "name": z.Name(), }) } - writeJSON(w, 200, map[string]any{"zones": items}) + return items } // ---- /api/forecast ---- diff --git a/go/internal/api/api_agent_setup.go b/go/internal/api/api_agent_setup.go new file mode 100644 index 000000000..5c3b634b3 --- /dev/null +++ b/go/internal/api/api_agent_setup.go @@ -0,0 +1,91 @@ +package api + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/protocol" +) + +type setupAuthorityKey struct{} + +type setupAccessError struct { + status int + message string +} + +func (e *setupAccessError) Error() string { return e.message } + +func setupWriteCheck(r *http.Request) func() error { + return func() error { + if r.Context().Err() != nil { + return &setupAccessError{http.StatusRequestTimeout, "Setup request ended. Read the current status before retrying."} + } + if check, ok := r.Context().Value(setupAuthorityKey{}).(func() error); ok { + if err := check(); err != nil { + return &setupAccessError{http.StatusForbidden, "Setup access ended. Read the current status after connecting again."} + } + } + // The live authority check may itself wait for another operation. + if r.Context().Err() != nil { + return &setupAccessError{http.StatusRequestTimeout, "Setup request ended. Read the current status before retrying."} + } + return nil + } +} + +func setupAuthority(w http.ResponseWriter, r *http.Request) bool { + if err := setupWriteCheck(r)(); err != nil { + denied := err.(*setupAccessError) + writeJSON(w, denied.status, map[string]string{"error": denied.Error()}) + return false + } + return true +} + +// ServeAgentSetup is a typed entry to the same draft service as Settings. +// The caller stays an agent. Its setup grant never opens general API writes, +// grants, Cloud consent, password changes, hardware commands or discovery. +func (s *Server) ServeAgentSetup(w http.ResponseWriter, r *http.Request, message string, call protocol.SetupCall, authorize func() error) { + caller, named := apiauth.FromRequest(r) + need, known := protocol.RegistryAgentCalls[message] + if !known || !named || caller.Kind != apiauth.KindAgent || !apiauth.AgentRole(caller.Role) || !caller.Scopes.Has(need) || authorize == nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "This setup operation is not granted."}) + return + } + r = r.Clone(context.WithValue(r.Context(), setupAuthorityKey{}, authorize)) + if !setupAuthority(w, r) { + return + } + var path string + var handle http.HandlerFunc + switch message { + case protocol.MsgSetupSchema: + path, handle = "/api/setup/schema", s.handleSetupSchema + case protocol.MsgSetupStatus: + path, handle = "/api/setup/draft", s.handleSetupDraft + case protocol.MsgSetupEdit: + path, handle = "/api/setup/draft", s.handleSetupEdit + case protocol.MsgSetupReset: + path, handle = "/api/setup/reset", s.handleSetupReset + case protocol.MsgSetupValidate: + path, handle = "/api/setup/validate", s.handleSetupValidate + case protocol.MsgSetupCommit: + path, handle = "/api/setup/commit", s.handleSetupCommit + case protocol.MsgSetupRestart: + path, handle = "/api/setup/restart", s.handleSetupRestart + } + r.URL = &url.URL{Path: path} + r.RequestURI = path + r.Method = http.MethodPost + if message == protocol.MsgSetupStatus || message == protocol.MsgSetupSchema { + r.Method = http.MethodGet + } + r.Body = io.NopCloser(bytes.NewReader(call.JSON)) + r.ContentLength = int64(len(call.JSON)) + handle(w, r) +} diff --git a/go/internal/api/api_agent_setup_test.go b/go/internal/api/api_agent_setup_test.go new file mode 100644 index 000000000..2ba28a5e6 --- /dev/null +++ b/go/internal/api/api_agent_setup_test.go @@ -0,0 +1,213 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "github.com/srcfl/ftw/go/internal/config" + "net/http" + "net/http/httptest" + "testing" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/protocol" +) + +func agentSetupCall(t *testing.T, s *Server, message string, body any, scopes []string, guard func() error) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + r := agentReadRequest("/api/setup/draft", scopes...) + s.ServeAgentSetup(w, r, message, protocol.SetupCall{JSON: raw}, guard) + var result map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatal(err, w.Body.String()) + } + return w, result +} + +func TestAgentSetupUsesItsOwnGrantAndDurableSettings(t *testing.T) { + s, cfg := setupTestServer(t) + ok := func() error { return nil } + w, _ := agentSetupCall(t, s, protocol.MsgSetupEdit, map[string]any{"revision": 0, "patch": map[string]any{"site": map[string]any{"name": "Denied"}}}, []string{protocol.ScopeDiagRun}, ok) + if w.Code != http.StatusForbidden || cfg.Site.Name != "Before" { + t.Fatal(w.Body.String()) + } + w, draft := agentSetupCall(t, s, protocol.MsgSetupEdit, map[string]any{"revision": 0, "patch": map[string]any{"site": map[string]any{"name": "Assistant setup"}}}, []string{protocol.ScopeSetupWrite}, ok) + if w.Code != http.StatusOK || cfg.Site.Name != "Before" { + t.Fatal(w.Body.String()) + } + check := map[string]any{"revision": draft["revision"], "base_revision": draft["base_revision"], "sha256": draft["sha256"], "confirmed": true} + w, result := agentSetupCall(t, s, protocol.MsgSetupCommit, check, []string{protocol.ScopeSetupWrite}, ok) + if w.Code != http.StatusOK || cfg.Site.Name != "Assistant setup" || result["saved"] != true || result["hardware_verified"] != false { + t.Fatal(w.Body.String()) + } + w, retry := agentSetupCall(t, s, protocol.MsgSetupCommit, check, []string{protocol.ScopeSetupWrite}, ok) + if w.Code != http.StatusOK || retry["commit"] == nil || retry["live_revision"] != result["live_revision"] { + t.Fatal(w.Body.String()) + } +} + +func TestAgentSetupRechecksAuthorityAtTheFinalSave(t *testing.T) { + s, cfg := setupTestServer(t) + ok := func() error { return nil } + w, draft := agentSetupCall(t, s, protocol.MsgSetupEdit, map[string]any{"revision": 0, "patch": map[string]any{"site": map[string]any{"name": "Revoked"}}}, []string{protocol.ScopeSetupWrite}, ok) + if w.Code != http.StatusOK { + t.Fatal(w.Body.String()) + } + checks := 0 + w, _ = agentSetupCall(t, s, protocol.MsgSetupCommit, map[string]any{"revision": draft["revision"], "base_revision": draft["base_revision"], "sha256": draft["sha256"], "confirmed": true}, []string{protocol.ScopeSetupWrite}, func() error { + checks++ + if checks == 2 { + return errors.New("grant revoked") + } + return nil + }) + if w.Code < 400 || cfg.Site.Name != "Before" || checks != 2 { + t.Fatal("revoked setup reached Settings", w.Body.String()) + } + if receipt, err := s.setup.Receipt(); err != nil || receipt != nil { + t.Fatal("revoked save left a receipt", err) + } +} + +func TestSetupGatewayKeepsTheAgentIdentityAndRejectsGeneralAPIWrites(t *testing.T) { + s, _ := setupTestServer(t) + r := agentReadRequest("/api/setup/draft", protocol.ScopeSetupWrite) + w := httptest.NewRecorder() + s.ServeAgentSetup(w, r, "api.req", protocol.SetupCall{}, func() error { return nil }) + if w.Code != http.StatusForbidden { + t.Fatal(w.Body.String()) + } + caller, _ := apiauth.FromRequest(r) + if caller.Kind != apiauth.KindAgent || caller.Role != apiauth.RoleAgent { + t.Fatal("setup promoted the caller") + } +} + +func TestNarrowSetupReadIncludesTheMetadataItNeeds(t *testing.T) { + s, _ := setupTestServer(t) + w, result := agentSetupCall(t, s, protocol.MsgSetupSchema, map[string]any{}, []string{protocol.ScopeDiagRun}, func() error { return nil }) + if w.Code != http.StatusOK || result["driver_catalog"] == nil || result["runtime_timezone"] == "" { + t.Fatal(w.Body.String()) + } + zones, ok := result["price_zones"].([]any) + if !ok || len(zones) == 0 { + t.Fatal("setup did not include its price choices") + } + found := false + for _, item := range zones { + if item.(map[string]any)["code"] == "SE3" { + found = true + } + } + if !found { + t.Fatal("setup's price choices differ from the normal API") + } +} + +func TestSetupReadsDoNotBroadenAnExistingRedactedConfigGrant(t *testing.T) { + s, _ := setupTestServer(t) + for _, message := range []string{protocol.MsgSetupSchema, protocol.MsgSetupStatus} { + w, _ := agentSetupCall(t, s, message, map[string]any{}, []string{protocol.ScopeConfigRead}, func() error { return nil }) + if w.Code != http.StatusForbidden { + t.Fatal("old redacted grant gained full setup data", message, w.Body.String()) + } + w, result := agentSetupCall(t, s, message, map[string]any{}, []string{protocol.ScopeDiagRun}, func() error { return nil }) + if w.Code != http.StatusOK { + t.Fatal(w.Body.String()) + } + if message == protocol.MsgSetupStatus && result["config"].(map[string]any)["site"].(map[string]any)["name"] != "Before" { + t.Fatal("technical setup lost the device names it needs", result) + } + } +} + +func TestAgentSetupRechecksEachDraftWriteAfterPreparation(t *testing.T) { + for _, action := range []string{"edit", "operations", "reset", "validate"} { + t.Run(action, func(t *testing.T) { + s, _ := setupTestServer(t) + w, before := setupCall(t, s, http.MethodPost, "/api/setup/draft", map[string]any{"revision": 0, + "patch": map[string]any{"planner": map[string]any{"engine": "python", "soc_min": 0, "soc_min_pct": 15}}}) + if w.Code != http.StatusOK { + t.Fatal(w.Body.String()) + } + message := protocol.MsgSetupEdit + body := map[string]any{"revision": before["revision"], "patch": map[string]any{"site": map[string]any{"name": "Not authorized"}}} + switch action { + case "operations": + body = map[string]any{"revision": before["revision"], "operations": []any{map[string]any{"op": "replace", "path": "/site/name", "value": "Not authorized"}}} + case "reset": + message, body = protocol.MsgSetupReset, map[string]any{"revision": before["revision"]} + case "validate": + message, body = protocol.MsgSetupValidate, setupIntegrityCheck(before) + } + checks := 0 + w, _ = agentSetupCall(t, s, message, body, []string{protocol.ScopeSetupWrite}, func() error { + checks++ + if checks == 3 { + return errors.New("grant revoked during preparation") + } + return nil + }) + if w.Code != http.StatusForbidden || checks != 3 { + t.Fatal("draft write missed its final authority check", checks, w.Code, w.Body.String()) + } + w, after := setupCall(t, s, http.MethodGet, "/api/setup/draft", nil) + if w.Code != http.StatusOK || after["sha256"] != before["sha256"] || after["revision"] != before["revision"] { + t.Fatal("revoked operation changed the durable draft", w.Body.String()) + } + }) + } +} + +func TestAgentSetupCommitChecksRevocationAndDeadlineImmediatelyBeforeSQL(t *testing.T) { + for _, outcome := range []string{"revoked", "request ended"} { + t.Run(outcome, func(t *testing.T) { + s, cfg := setupTestServer(t) + oldRevision := cfg.Revision + w, draft := setupCall(t, s, http.MethodPost, "/api/setup/draft", map[string]any{"revision": 0, "patch": map[string]any{"site": map[string]any{"name": "Not authorized"}}}) + if w.Code != http.StatusOK { + t.Fatal(w.Body.String()) + } + r := agentReadRequest("/api/setup/draft", protocol.ScopeSetupWrite) + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + r = r.WithContext(ctx) + raw, err := json.Marshal(setupIntegrityCheck(draft)) + if err != nil { + t.Fatal(err) + } + w = httptest.NewRecorder() + checks := 0 + s.ServeAgentSetup(w, r, protocol.MsgSetupCommit, protocol.SetupCall{JSON: raw}, func() error { + checks++ + if checks == 3 { + if outcome == "request ended" { + cancel() + return nil + } + return errors.New("grant revoked after Settings preparation") + } + return nil + }) + want := http.StatusForbidden + if outcome == "request ended" { + want = http.StatusRequestTimeout + } + if w.Code != want || checks != 3 || cfg.Revision != oldRevision || cfg.Site.Name != "Before" { + t.Fatal("ended authority reached live settings", checks, w.Code, w.Body.String()) + } + loaded, err := config.Load(s.deps.ConfigPath) + if err != nil || loaded.Revision != oldRevision || loaded.Site.Name != "Before" { + t.Fatal("ended authority reached SQL", err) + } + if receipt, err := s.setup.Receipt(); err != nil || receipt != nil { + t.Fatal("refused save left a success receipt", err) + } + }) + } +} diff --git a/go/internal/api/api_agents.go b/go/internal/api/api_agents.go index b2cdba8c5..bf2e9eff2 100644 --- a/go/internal/api/api_agents.go +++ b/go/internal/api/api_agents.go @@ -202,6 +202,7 @@ type agentsRegistry struct { AgentOps map[string]string `json:"agentOps"` // WriteScopes change something, now or once an owner says yes. WriteScopes []string `json:"writeScopes"` + CallScopes []string `json:"callScopes"` // AgentArgs are each op's arguments, in the registry's order, with the // label an owner reads. AgentArgs map[string][]agentArgView `json:"agentArgs"` @@ -217,13 +218,21 @@ type agentArgView struct { } func registryForPage() agentsRegistry { + calls := []string{} + for _, scope := range protocol.RegistryAgentCalls { + if slices.Contains(protocol.WriteScopes, scope) { + calls = append(calls, scope) + } + } + slices.Sort(calls) + calls = slices.Compact(calls) args := map[string][]agentArgView{} for op, list := range protocol.RegistryAgentArgs { for _, a := range list { args[op] = append(args[op], agentArgView{Name: a.Name, Label: a.Label, Kind: a.Kind, Unit: a.Unit, Cap: a.Cap}) } } - return agentsRegistry{AgentOps: protocol.RegistryAgentOps, WriteScopes: protocol.WriteScopes, AgentArgs: args} + return agentsRegistry{AgentOps: protocol.RegistryAgentOps, WriteScopes: protocol.WriteScopes, CallScopes: calls, AgentArgs: args} } func (s *Server) handleAgentsList(w http.ResponseWriter, r *http.Request) { diff --git a/go/internal/api/api_restart.go b/go/internal/api/api_restart.go index 86084fe05..95c8c41ea 100644 --- a/go/internal/api/api_restart.go +++ b/go/internal/api/api_restart.go @@ -27,15 +27,25 @@ func (s *Server) handleRestart(w http.ResponseWriter, r *http.Request) { return } writeJSON(w, 202, map[string]any{"status": "restarting"}) + s.queueRestart(nil, nil) +} +func (s *Server) queueRestart(trigger func(context.Context) error, done func(error)) { + if trigger == nil { + trigger = s.deps.Restart + } go func() { time.Sleep(200 * time.Millisecond) // Don't reuse r.Context() — it's canceled the instant the // response is flushed. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - if err := s.deps.Restart(ctx); err != nil { + err := trigger(ctx) + if err != nil { slog.Warn("restart trigger failed", "err", err) } + if done != nil { + done(err) + } }() } diff --git a/go/internal/api/api_setup.go b/go/internal/api/api_setup.go index 5d5a49251..dfd4e145f 100644 --- a/go/internal/api/api_setup.go +++ b/go/internal/api/api_setup.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/localzone" "github.com/srcfl/ftw/go/internal/onboarding" ) @@ -61,16 +62,19 @@ func (s *Server) setupRoutes() { s.handle("POST /api/setup/reset", Configure, s.handleSetupReset) s.handle("POST /api/setup/validate", Configure, s.handleSetupValidate) s.handle("POST /api/setup/commit", Configure, s.handleSetupCommit) + s.handle("POST /api/setup/restart", Configure, s.handleSetupRestart) } func (s *Server) handleSetupSchema(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ + "driver_catalog": s.driverCatalogReply(), "price_zones": priceZoneItems(), "runtime_timezone": localzone.Name(), "schema": onboarding.Schema(), "drivers": "/api/drivers/catalog", "price_areas": "/api/prices/zones", "workflow": []string{ "Read the current draft. Ask only for missing facts and choices.", "Discover equipment only when the user asks; reading a draft does not scan.", "Patch the current draft revision. Omitted fields and saved credentials remain on the box.", "Arrays replace the whole array. Read and retain all existing entries before editing one.", + "For large lists use JSON Patch add/replace/remove operations against this exact revision; an index never refers to a later draft.", "Validate the exact draft, show its changes and assumptions, then ask the user to confirm before commit.", "Validation can normalize fields into a new draft revision and checksum. Review that returned configuration and its warnings; an earlier confirmation cannot save the changed draft.", "Saving configuration does not prove hardware operation. Check fresh measurements and bounded control tests separately.", @@ -101,6 +105,10 @@ func (s *Server) setupLive() (*config.Config, error) { func (s *Server) setupError(w http.ResponseWriter, err error) { status := http.StatusUnprocessableEntity + var denied *setupAccessError + if errors.As(err, &denied) { + status = denied.status + } if errors.Is(err, onboarding.ErrConflict) { status = http.StatusConflict } @@ -180,8 +188,9 @@ func (s *Server) writeSetupDraft(w http.ResponseWriter, live *config.Config, d o } type setupEdit struct { - Revision int64 `json:"revision"` - Patch json.RawMessage `json:"patch"` + Revision int64 `json:"revision"` + Patch json.RawMessage `json:"patch"` + Operations json.RawMessage `json:"operations"` } func setupJSON(w http.ResponseWriter, r *http.Request, v any) bool { @@ -209,7 +218,19 @@ func (s *Server) handleSetupEdit(w http.ResponseWriter, r *http.Request) { s.setupError(w, err) return } - d, err := s.setup.Edit(live, q.Revision, q.Patch) + if !setupAuthority(w, r) { + return + } + var d onboarding.Draft + if len(q.Operations) > 0 && len(q.Patch) > 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "send either patch or operations"}) + return + } + if len(q.Operations) > 0 { + d, err = s.setup.EditOperationsWithCheck(live, q.Revision, q.Operations, setupWriteCheck(r)) + } else { + d, err = s.setup.EditWithCheck(live, q.Revision, q.Patch, setupWriteCheck(r)) + } if err != nil { s.setupError(w, err) return @@ -229,7 +250,10 @@ func (s *Server) handleSetupReset(w http.ResponseWriter, r *http.Request) { s.setupError(w, err) return } - d, err := s.setup.Restart(live, q.Revision) + if !setupAuthority(w, r) { + return + } + d, err := s.setup.RestartWithCheck(live, q.Revision, setupWriteCheck(r)) if err != nil { s.setupError(w, err) return @@ -258,7 +282,10 @@ func (s *Server) handleSetupValidate(w http.ResponseWriter, r *http.Request) { s.setupError(w, onboarding.ErrConflict) return } - d, err := s.setup.Validate(live, q.Revision, q.SHA256) + if !setupAuthority(w, r) { + return + } + d, err := s.setup.ValidateWithCheck(live, q.Revision, q.SHA256, setupWriteCheck(r)) if err != nil { s.setupError(w, err) return @@ -326,7 +353,10 @@ func (s *Server) handleSetupCommit(w http.ResponseWriter, r *http.Request) { return err } s.handlePostConfigWith(w, request, true, func(path string, cfg *config.Config) error { - return s.deps.SaveConfigWithValues(path, cfg, map[string]string{onboarding.CommitKey: string(record)}, raw) + if err := setupWriteCheck(request)(); err != nil { + return err + } + return s.deps.SaveConfigWithValues(path, cfg, map[string]string{onboarding.CommitKey: string(record)}, raw, setupWriteCheck(request)) }, func(cfg *config.Config, _ []string) { s.writeSetupCommit(w, cfg.Revision, &receipt) }) diff --git a/go/internal/api/api_setup_integrity_test.go b/go/internal/api/api_setup_integrity_test.go index bb3048924..6b76a2fe2 100644 --- a/go/internal/api/api_setup_integrity_test.go +++ b/go/internal/api/api_setup_integrity_test.go @@ -201,9 +201,9 @@ func TestSetupFinalSaveRefusesLateMutationBeforeAnySQLWrite(t *testing.T) { if w.Code != http.StatusOK { t.Fatal(w.Body.String()) } - s.deps.SaveConfigWithValues = func(path string, candidate *config.Config, values map[string]string, reviewedJSON []byte) error { + s.deps.SaveConfigWithValues = func(path string, candidate *config.Config, values map[string]string, reviewedJSON []byte, beforeSave func() error) error { candidate.Site.Name = "Not reviewed" - return config.SaveStoredReviewedWithValues(s.deps.State, path, candidate, values, reviewedJSON) + return config.SaveStoredReviewedWithCheck(s.deps.State, path, candidate, values, reviewedJSON, beforeSave) } w, _ = setupCall(t, s, http.MethodPost, "/api/setup/commit", setupIntegrityCheck(draft)) if w.Code != http.StatusConflict || cfg.Revision != beforeRevision || cfg.Site.Name != "Before" { diff --git a/go/internal/api/api_setup_restart.go b/go/internal/api/api_setup_restart.go new file mode 100644 index 000000000..95370fe4c --- /dev/null +++ b/go/internal/api/api_setup_restart.go @@ -0,0 +1,85 @@ +package api + +import ( + "context" + "net/http" + + "github.com/srcfl/ftw/go/internal/onboarding" +) + +// A setup grant may complete its saved configuration. It does not open the +// general restart route, and cannot restart unrelated Settings changes. +func (s *Server) handleSetupRestart(w http.ResponseWriter, r *http.Request) { + var q struct { + SavedRevision int64 `json:"saved_revision"` + SHA256 string `json:"sha256"` + Confirmed bool `json:"confirmed"` + } + if !setupJSON(w, r, &q) { + return + } + if !q.Confirmed { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ask the user to confirm the restart and its brief interruption first."}) + return + } + s.configWriteMu.Lock() + defer s.configWriteMu.Unlock() + live, err := s.setupLive() + if err != nil { + s.setupError(w, err) + return + } + receipt, err := s.setup.Receipt() + if err != nil { + s.setupError(w, err) + return + } + if receipt == nil || receipt.SavedRevision != q.SavedRevision || receipt.SHA256 != q.SHA256 || live.Revision != q.SavedRevision { + s.setupError(w, onboarding.ErrConflict) + return + } + if !setupAuthority(w, r) { + return + } + if !setupRestartPending(receipt) { + writeJSON(w, http.StatusOK, map[string]any{"restart_requested": false, "restart_pending": false, "saved_revision": q.SavedRevision, "hardware_verified": false}) + return + } + if s.deps.Restart == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Restart is unavailable here. The setup is saved and still needs a process restart."}) + return + } + if s.setupRestartRevision != q.SavedRevision { + s.setupRestartRevision = q.SavedRevision + s.queueRestart(func(ctx context.Context) error { + s.configWriteMu.Lock() + defer s.configWriteMu.Unlock() + if authorize, ok := r.Context().Value(setupAuthorityKey{}).(func() error); ok { + if err := authorize(); err != nil { + return err + } + } + current, err := s.setupLive() + if err != nil { + return err + } + receipt, err := s.setup.Receipt() + if err != nil { + return err + } + if current.Revision != q.SavedRevision || receipt == nil || receipt.SavedRevision != q.SavedRevision || receipt.SHA256 != q.SHA256 { + return onboarding.ErrConflict + } + return s.deps.Restart(ctx) + }, func(err error) { + if err != nil { + s.configWriteMu.Lock() + if s.setupRestartRevision == q.SavedRevision { + s.setupRestartRevision = 0 + } + s.configWriteMu.Unlock() + } + }) + } + writeJSON(w, http.StatusAccepted, map[string]any{"restart_requested": true, "restart_pending": true, "saved_revision": q.SavedRevision, "hardware_verified": false, "next_step": "The connection will pause. Reconnect and read setup_status to verify that the saved configuration is applied; a restart request alone does not prove it."}) +} diff --git a/go/internal/api/api_setup_restart_test.go b/go/internal/api/api_setup_restart_test.go new file mode 100644 index 000000000..e1669df33 --- /dev/null +++ b/go/internal/api/api_setup_restart_test.go @@ -0,0 +1,125 @@ +package api + +import ( + "context" + "errors" + "net/http" + "sync/atomic" + "testing" + "time" + + "github.com/srcfl/ftw/go/protocol" +) + +func savedSetupForRestart(t *testing.T, s *Server) map[string]any { + t.Helper() + w, draft := setupCall(t, s, http.MethodPost, "/api/setup/draft", map[string]any{"revision": 0, "patch": map[string]any{"api": map[string]any{"port": 8899}}}) + if w.Code != http.StatusOK { + t.Fatal(w.Body.String()) + } + w, result := setupCall(t, s, http.MethodPost, "/api/setup/commit", map[string]any{"revision": draft["revision"], "base_revision": draft["base_revision"], "sha256": draft["sha256"], "confirmed": true}) + if w.Code != http.StatusOK || result["restart_required"] != true { + t.Fatal(w.Body.String()) + } + receipt := result["commit"].(map[string]any) + return map[string]any{"saved_revision": receipt["saved_revision"], "sha256": receipt["sha256"], "confirmed": true} +} + +func TestAgentSetupRestartRequiresTheConfirmedSavedReceipt(t *testing.T) { + s, _ := setupTestServer(t) + q := savedSetupForRestart(t, s) + var count atomic.Int32 + done := make(chan struct{}, 2) + s.deps.Restart = func(context.Context) error { count.Add(1); done <- struct{}{}; return nil } + ok := func() error { return nil } + q["confirmed"] = false + w, _ := agentSetupCall(t, s, protocol.MsgSetupRestart, q, []string{protocol.ScopeSetupWrite}, ok) + if w.Code != http.StatusBadRequest { + t.Fatal(w.Body.String()) + } + q["confirmed"] = true + w, _ = agentSetupCall(t, s, protocol.MsgSetupRestart, q, []string{protocol.ScopeDiagRun}, ok) + if w.Code != http.StatusForbidden { + t.Fatal(w.Body.String()) + } + sha := q["sha256"] + q["sha256"] = "different" + w, _ = agentSetupCall(t, s, protocol.MsgSetupRestart, q, []string{protocol.ScopeSetupWrite}, ok) + if w.Code != http.StatusConflict { + t.Fatal(w.Body.String()) + } + q["sha256"] = sha + w, _ = agentSetupCall(t, s, protocol.MsgSetupRestart, q, []string{protocol.ScopeSetupWrite}, ok) + if w.Code != http.StatusAccepted { + t.Fatal(w.Body.String()) + } + w, _ = agentSetupCall(t, s, protocol.MsgSetupRestart, q, []string{protocol.ScopeSetupWrite}, ok) + if w.Code != http.StatusAccepted { + t.Fatal("lost reply cannot be retried", w.Body.String()) + } + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("restart was not triggered") + } + if count.Load() != 1 { + t.Fatal("queued duplicate restarts") + } +} + +func TestAgentSetupRestartRejectsRevocationAndSupersededSettings(t *testing.T) { + s, cfg := setupTestServer(t) + q := savedSetupForRestart(t, s) + s.deps.Restart = func(context.Context) error { t.Error("refused setup restarted"); return nil } + checks := 0 + w, _ := agentSetupCall(t, s, protocol.MsgSetupRestart, q, []string{protocol.ScopeSetupWrite}, func() error { + checks++ + if checks == 2 { + return errors.New("revoked") + } + return nil + }) + if w.Code != http.StatusForbidden || checks != 2 { + t.Fatal(w.Body.String()) + } + s.deps.CfgMu.Lock() + cfg.Revision++ + s.deps.CfgMu.Unlock() + w, _ = agentSetupCall(t, s, protocol.MsgSetupRestart, q, []string{protocol.ScopeSetupWrite}, func() error { return nil }) + if w.Code != http.StatusConflict { + t.Fatal(w.Body.String()) + } +} + +func TestSetupRestartDoesNotClaimARequestWasApplied(t *testing.T) { + s, _ := setupTestServer(t) + q := savedSetupForRestart(t, s) + w, _ := setupCall(t, s, http.MethodPost, "/api/setup/restart", q) + if w.Code != http.StatusServiceUnavailable { + t.Fatal(w.Body.String()) + } + s.deps.Restart = func(context.Context) error { return errors.New("service unavailable") } + w, result := setupCall(t, s, http.MethodPost, "/api/setup/restart", q) + if w.Code != http.StatusAccepted || result["restart_pending"] != true || result["hardware_verified"] != false { + t.Fatal(w.Body.String()) + } + // A failed trigger releases the retry guard; it never clears the receipt. + deadline := time.After(2 * time.Second) + for { + s.configWriteMu.Lock() + queued := s.setupRestartRevision + s.configWriteMu.Unlock() + if queued == 0 { + break + } + select { + case <-deadline: + t.Fatal("restart failure prevents retry") + case <-time.After(time.Millisecond): + } + } + w, status := setupCall(t, s, http.MethodGet, "/api/setup/draft", nil) + if w.Code != http.StatusOK || status["restart_pending"] != true { + t.Fatal(w.Body.String()) + } +} diff --git a/go/internal/api/api_setup_restart_window_test.go b/go/internal/api/api_setup_restart_window_test.go new file mode 100644 index 000000000..35e3da605 --- /dev/null +++ b/go/internal/api/api_setup_restart_window_test.go @@ -0,0 +1,65 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "sync/atomic" + "testing" + "time" + + "github.com/srcfl/ftw/go/protocol" +) + +func TestQueuedSetupRestartRechecksBeforeEffect(t *testing.T) { + for _, change := range []string{"settings", "grant"} { + t.Run(change, func(t *testing.T) { + s, cfg := setupTestServer(t) + q := savedSetupForRestart(t, s) + var count atomic.Int32 + var revoked atomic.Bool + s.deps.Restart = func(context.Context) error { count.Add(1); return nil } + guard := func() error { + if revoked.Load() { + return errors.New("revoked") + } + return nil + } + w, _ := agentSetupCall(t, s, protocol.MsgSetupRestart, q, []string{protocol.ScopeSetupWrite}, guard) + if w.Code != http.StatusAccepted { + t.Fatal(w.Code, w.Body.String()) + } + if change == "grant" { + revoked.Store(true) + } else { + next := *cfg + next.Site.Name = "Saved after restart was queued" + raw, err := json.Marshal(next) + if err != nil { + t.Fatal(err) + } + if code := postConfig(t, s, string(raw)); code != http.StatusOK { + t.Fatal(code) + } + } + deadline := time.After(2 * time.Second) + for { + s.configWriteMu.Lock() + queued := s.setupRestartRevision + s.configWriteMu.Unlock() + if queued == 0 { + break + } + select { + case <-deadline: + t.Fatal("refused restart did not release its retry guard") + case <-time.After(time.Millisecond): + } + } + if count.Load() != 0 { + t.Errorf("queued setup restart ran after %s changed before effect", change) + } + }) + } +} diff --git a/go/internal/api/api_setup_test.go b/go/internal/api/api_setup_test.go index 8e72502a9..5ee666030 100644 --- a/go/internal/api/api_setup_test.go +++ b/go/internal/api/api_setup_test.go @@ -129,7 +129,9 @@ func TestSetupFailedSaveLeavesLiveAndStoredConfigUntouched(t *testing.T) { if w.Code != http.StatusOK { t.Fatal(w.Body.String()) } - s.deps.SaveConfigWithValues = func(string, *config.Config, map[string]string, []byte) error { return http.ErrHandlerTimeout } + s.deps.SaveConfigWithValues = func(string, *config.Config, map[string]string, []byte, func() error) error { + return http.ErrHandlerTimeout + } w, _ = setupCall(t, s, http.MethodPost, "/api/setup/commit", map[string]any{"revision": draft["revision"], "base_revision": draft["base_revision"], "sha256": draft["sha256"], "confirmed": true}) if w.Code != http.StatusInternalServerError || cfg.Site.Name != "Before" { t.Fatal("failed commit changed live settings") diff --git a/go/internal/apiauth/contract_gen.go b/go/internal/apiauth/contract_gen.go index 94a6f8b23..d36294ab9 100644 --- a/go/internal/apiauth/contract_gen.go +++ b/go/internal/apiauth/contract_gen.go @@ -33,6 +33,7 @@ var RoleScopes = map[string][]string{ "ftw.logs.read", "ftw.config.read", "ftw.diag.run", + "ftw.setup.write", "ftw.dispatch.hold", "ftw.goals.request", }, @@ -50,6 +51,7 @@ var RoleScopes = map[string][]string{ "ftw.logs.read", "ftw.config.read", "ftw.diag.run", + "ftw.setup.write", "ftw.dispatch.hold", "ftw.goals.request", "ftw.update.request", @@ -68,6 +70,7 @@ var RoleScopes = map[string][]string{ "ftw.logs.read", "ftw.config.read", "ftw.diag.run", + "ftw.setup.write", "ftw.dispatch.hold", "ftw.goals.request", }, diff --git a/go/internal/appproto/agents.go b/go/internal/appproto/agents.go index fddb94646..1ac321b40 100644 --- a/go/internal/appproto/agents.go +++ b/go/internal/appproto/agents.go @@ -269,11 +269,15 @@ func (h *Handler) dispatchAgent(ctx context.Context, env Envelope) error { return h.sendError(env.ID, *refusal) } need, read := agentReads[env.T] - if !read && !agentOther[env.T] { + callScope, setup := protocol.RegistryAgentCalls[env.T] + if setup { + need = callScope + } + if !read && !setup && !agentOther[env.T] { h.audit(env.By, env.T, "", ErrScopeDenied) return h.refuse(env.ID, ErrScopeDenied, map[string]any{"t": env.T}) } - if read { + if read || setup { if !ScopesForEnrolment(grant).Has(need) { h.audit(env.By, env.T, "", ErrScopeDenied) return h.refuse(env.ID, ErrScopeDenied, map[string]any{"t": env.T, "needScope": need, "role": grant.Role}) @@ -283,7 +287,12 @@ func (h *Handler) dispatchAgent(ctx context.Context, env Envelope) error { return h.sendError(env.ID, ErrorBody{Code: protocol.ErrRateLimited, Retryable: ErrorRetryable[protocol.ErrRateLimited], RetryAfterMs: &wait}) } - h.auditRead(env.By, env.T) + if read { + h.auditRead(env.By, env.T) + } + } + if setup { + return h.onSetupCall(env) } switch env.T { diff --git a/go/internal/appproto/gencontract/gencontract.go b/go/internal/appproto/gencontract/gencontract.go index de1d8d4a8..2075b7d06 100644 --- a/go/internal/appproto/gencontract/gencontract.go +++ b/go/internal/appproto/gencontract/gencontract.go @@ -80,6 +80,12 @@ type Registry struct { Scope string `yaml:"scope"` Desc string `yaml:"desc"` } `yaml:"reads"` + AgentCalls []struct { + Msg string `yaml:"msg"` + Const string `yaml:"const"` + Scope string `yaml:"scope"` + Desc string `yaml:"desc"` + } `yaml:"agent_calls"` Resolutions map[string]struct { TileSpanMs int64 `yaml:"tile_span_ms"` RetentionDays int `yaml:"retention_days"` @@ -134,6 +140,7 @@ func Generate(raw []byte) ([]byte, error) { writeOps(&b, reg) writeAgentArgs(&b, reg) writeReads(&b, reg) + writeAgentCalls(&b, reg) writeErrors(&b, reg) writeSourceStates(&b, reg) writeResolutions(&b, reg) @@ -143,6 +150,17 @@ func Generate(raw []byte) ([]byte, error) { return format.Source(b.Bytes()) } +func writeAgentCalls(b *bytes.Buffer, reg Registry) { + for _, call := range reg.AgentCalls { + fmt.Fprintf(b, "const %s = %q\n\n", call.Const, call.Msg) + } + b.WriteString("// RegistryAgentCalls maps typed setup calls to their required grants.\nvar RegistryAgentCalls = map[string]string{\n") + for _, call := range reg.AgentCalls { + fmt.Fprintf(b, "\t// %s.\n\t%q: %q,\n", call.Desc, call.Msg, call.Scope) + } + b.WriteString("}\n\n") +} + // GenerateRoles renders the role table into package apiauth. // // Roles live there and not beside the other constants because three packages diff --git a/go/internal/appproto/passthrough.go b/go/internal/appproto/passthrough.go index 79fab9039..6987c7d4e 100644 --- a/go/internal/appproto/passthrough.go +++ b/go/internal/appproto/passthrough.go @@ -480,6 +480,7 @@ type apiWriter struct { recheck func() *ErrorBody header http.Header + status int buf []byte seq uint32 written int64 @@ -523,6 +524,7 @@ func (w *apiWriter) WriteHeader(status int) { } w.wroteHead = true + w.status = status w.sendErr = w.h.sendBulk(MsgAPIHead, &w.id, APIHeadMsg{ Status: status, Headers: carriedHeaders(w.header), diff --git a/go/internal/appproto/setup.go b/go/internal/appproto/setup.go new file mode 100644 index 000000000..4d3cf734e --- /dev/null +++ b/go/internal/appproto/setup.go @@ -0,0 +1,85 @@ +package appproto + +import ( + "context" + "net/http" + "sync" + + "github.com/srcfl/ftw/go/protocol" +) + +type setupGateway interface { + ServeAgentSetup(http.ResponseWriter, *http.Request, string, protocol.SetupCall, func() error) +} + +func (h *Handler) onSetupCall(env Envelope) error { + if env.ID == nil { + return nil + } + call, err := protocol.DecodeSetupCall(env.B) + if err != nil { + return h.refuse(env.ID, ErrUnknownOp, map[string]any{"t": env.T}) + } + gateway, available := h.cfg.API.(setupGateway) + if !available { + return h.refuse(env.ID, ErrUnavailable, map[string]any{"subsystem": "setup"}) + } + if !h.apiBusy.CompareAndSwap(false, true) { + return h.refuse(env.ID, ErrUnavailable, map[string]any{"reason": "busy"}) + } + id := *env.ID + go func() { + var once sync.Once + free := func() { once.Do(func() { h.apiBusy.Store(false) }) } + defer free() + ctx, cancel := context.WithTimeout(h.ctx, protocol.AgentAPIReadTimeout) + defer cancel() + authority := func() *ErrorBody { + if _, refusal, _ := h.agentGate(); refusal != nil { + return refusal + } + caller, ok := h.liveCaller() + if !ok || !caller.Scopes.Has(protocol.RegistryAgentCalls[env.T]) { + return &ErrorBody{Code: ErrScopeDenied, Args: map[string]any{"needScope": protocol.RegistryAgentCalls[env.T]}} + } + return nil + } + check := func() *ErrorBody { + if ctx.Err() != nil { + return &ErrorBody{Code: ErrUnavailable} + } + return authority() + } + if refusal := check(); refusal != nil { + _ = h.sendError(&id, *refusal) + return + } + caller, _ := h.liveCaller() + req := buildAPIRequest(ctx, caller, APIReq{Method: APIGet, Path: "/api/setup/draft"}) + writer := &apiWriter{h: h, id: id, ctx: ctx, max: protocol.AgentAPIMaxBytes, free: free, header: http.Header{}, buf: make([]byte, 0, APIChunkBytes), recheck: check} + func() { + defer func() { + if recover() != nil { + writer.panicked = true + } + }() + gateway.ServeAgentSetup(writer, req, env.T, call, func() error { + // An accepted restart rechecks the live grant after the reply. + // That check must survive this short request's completion. + if refusal := authority(); refusal != nil { + return protocol.Refuse(refusal.Code, refusal.Args) + } + return nil + }) + }() + result := "ok" + if writer.status >= 400 || writer.panicked { + result = "refused" + } + h.audit(env.By, env.T, "", result) + if err := writer.finish(); err != nil { + h.log.Warn("setup reply ended", "err", err) + } + }() + return nil +} diff --git a/go/internal/appproto/setup_test.go b/go/internal/appproto/setup_test.go new file mode 100644 index 000000000..50915d7c4 --- /dev/null +++ b/go/internal/appproto/setup_test.go @@ -0,0 +1,98 @@ +package appproto + +import ( + "net/http" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/protocol" +) + +type setupTestGateway struct { + stubAPI + call func(http.ResponseWriter, *http.Request, string, protocol.SetupCall, func() error) +} + +func (g *setupTestGateway) ServeAgentSetup(w http.ResponseWriter, r *http.Request, message string, call protocol.SetupCall, guard func() error) { + g.call(w, r, message, call, guard) +} + +func TestSetupCallUsesTheAgentsOwnNoiseSessionAndGrant(t *testing.T) { + for _, scope := range []string{protocol.ScopeDiagRun, protocol.ScopeSetupWrite} { + t.Run(scope, func(t *testing.T) { + r := newAgentRig(t) + row, _ := r.grant(apiauth.RoleAgent, []string{scope}, nil, protocol.Caps{}, time.Hour, protocol.Principal{Client: "ChatGPT"}) + h, rec := r.agentSession(row) + t.Cleanup(h.Close) + called := make(chan struct{}, 1) + h.cfg.API = &setupTestGateway{call: func(w http.ResponseWriter, req *http.Request, message string, call protocol.SetupCall, guard func() error) { + called <- struct{}{} + caller, ok := apiauth.FromRequest(req) + if !ok || caller.Kind != apiauth.KindAgent || caller.Role != apiauth.RoleAgent || !caller.Scopes.Has(protocol.ScopeSetupWrite) { + t.Error("caller identity changed") + } + if message != protocol.MsgSetupEdit || string(call.JSON) != `{"revision":0,"patch":{"site":{"name":"Home"}}}` || guard() != nil { + t.Error("request changed") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"revision":1,"saved":false}`)) + }} + deliver(t, h, protocol.MsgSetupEdit, ptrU32(1), protocol.SetupCall{JSON: []byte(`{"revision":0,"patch":{"site":{"name":"Home"}}}`)}) + if scope == protocol.ScopeDiagRun { + if got := body[ErrorBody](t, waitFor(t, rec, MsgError)); got.Code != ErrScopeDenied { + t.Fatal(got) + } + select { + case <-called: + t.Fatal("read grant reached a write") + default: + } + return + } + end := body[APIEnd](t, waitFor(t, rec, MsgAPIEnd)) + if end.Truncated || string(apiChunks(rec)) != `{"revision":1,"saved":false}` { + t.Fatal(end, string(apiChunks(rec))) + } + }) + } +} + +func TestRevocationReachesTheSetupSaveGuard(t *testing.T) { + r := newAgentRig(t) + row, _ := r.grant(apiauth.RoleAgent, []string{protocol.ScopeSetupWrite}, nil, protocol.Caps{}, time.Hour, protocol.Principal{}) + h, rec := r.agentSession(row) + t.Cleanup(h.Close) + started, release, checked := make(chan struct{}), make(chan struct{}), make(chan error, 1) + h.cfg.API = &setupTestGateway{call: func(w http.ResponseWriter, _ *http.Request, _ string, _ protocol.SetupCall, guard func() error) { + close(started) + <-release + checked <- guard() + _, _ = w.Write([]byte(`{"must_not_leave":true}`)) + }} + deliver(t, h, protocol.MsgSetupCommit, ptrU32(1), protocol.SetupCall{JSON: []byte(`{}`)}) + select { + case <-started: + case <-time.After(2 * time.Second): + close(release) + t.Fatal("setup not called") + } + if err := r.access.RevokeAgent(row.ID); err != nil { + t.Fatal(err) + } + close(release) + select { + case err := <-checked: + if err == nil { + t.Fatal("revocation did not reach save guard") + } + case <-time.After(2 * time.Second): + t.Fatal("guard not checked") + } + if got := body[ErrorBody](t, waitFor(t, rec, MsgError)); got.Code != ErrGrantRevoked { + t.Fatal(got) + } + if rec.has(MsgAPIHead) || len(apiChunks(rec)) > 0 { + t.Fatal("revoked setup returned an answer") + } +} diff --git a/go/internal/config/storage.go b/go/internal/config/storage.go index 61a34fa6f..501b03310 100644 --- a/go/internal/config/storage.go +++ b/go/internal/config/storage.go @@ -238,7 +238,7 @@ func SaveStored(st *state.Store, path string, cfg *Config) error { // SaveStoredWithValues commits a settings change and its companion records // in the same SQLite transaction, for example a setup commit receipt. func SaveStoredWithValues(st *state.Store, path string, cfg *Config, values map[string]string) error { - return saveStoredValues(st, path, cfg, "", values, nil) + return saveStoredValues(st, path, cfg, "", values, nil, nil) } // DropRetiredSettings rewrites stored settings that still carry settings of a @@ -267,7 +267,7 @@ func DropRetiredSettings(st *state.Store, path string, cfg *Config) ([]string, e } func saveStored(st *state.Store, path string, cfg *Config, sourceHash string) error { - return saveStoredValues(st, path, cfg, sourceHash, nil, nil) + return saveStoredValues(st, path, cfg, sourceHash, nil, nil, nil) } // ErrReviewedConfigChanged means a save would differ from the reviewed document. @@ -276,13 +276,19 @@ var ErrReviewedConfigChanged = errors.New("configuration changed after review; v // SaveStoredReviewedWithValues checks the portable document after all save-time // normalization, before committing either settings or companion records. func SaveStoredReviewedWithValues(st *state.Store, path string, cfg *Config, values map[string]string, reviewedJSON []byte) error { + return SaveStoredReviewedWithCheck(st, path, cfg, values, reviewedJSON, nil) +} + +// SaveStoredReviewedWithCheck checks authority after all save preparation, +// immediately before committing the reviewed settings and companion records. +func SaveStoredReviewedWithCheck(st *state.Store, path string, cfg *Config, values map[string]string, reviewedJSON []byte, beforeSave func() error) error { if len(reviewedJSON) == 0 { return errors.New("reviewed configuration is required") } - return saveStoredValues(st, path, cfg, "", values, reviewedJSON) + return saveStoredValues(st, path, cfg, "", values, reviewedJSON, beforeSave) } -func saveStoredValues(st *state.Store, path string, cfg *Config, sourceHash string, values map[string]string, reviewedJSON []byte) error { +func saveStoredValues(st *state.Store, path string, cfg *Config, sourceHash string, values map[string]string, reviewedJSON []byte, beforeSave func() error) error { if cfg.ConfigDatabase == "" { return errors.New("settings database is not initialized") } @@ -360,6 +366,11 @@ func saveStoredValues(st *state.Store, path string, cfg *Config, sourceHash stri } credentials[key] = value } + if beforeSave != nil { + if err := beforeSave(); err != nil { + return err + } + } revision, err := st.SaveConfiguration(raw, cfg.Revision, credentials) if err != nil { return err diff --git a/go/internal/onboarding/draft.go b/go/internal/onboarding/draft.go index bcae89dbb..70091e66c 100644 --- a/go/internal/onboarding/draft.go +++ b/go/internal/onboarding/draft.go @@ -114,6 +114,11 @@ func initial(live *config.Config) (Draft, error) { // the whole array; the client must read it before editing an entry. // Partial drafts may fail Config.Validate while still having valid types. func (s *Service) Edit(live *config.Config, expected int64, patch json.RawMessage) (Draft, error) { + return s.EditWithCheck(live, expected, patch, nil) +} + +// EditWithCheck rechecks the caller after preparation, before the durable write. +func (s *Service) EditWithCheck(live *config.Config, expected int64, patch json.RawMessage, beforeSave func() error) (Draft, error) { s.mu.Lock() defer s.mu.Unlock() d, found, err := s.current() @@ -186,6 +191,11 @@ func (s *Service) Edit(live *config.Config, expected int64, patch json.RawMessag if err != nil || len(raw) > MaxDraftBytes { return Draft{}, errors.New("setup draft is too large") } + if beforeSave != nil { + if err := beforeSave(); err != nil { + return Draft{}, err + } + } if err := s.store.SaveConfig(draftKey, string(raw)); err != nil { return Draft{}, fmt.Errorf("save setup draft: %w", err) } @@ -223,6 +233,11 @@ func keepDraftCredentialOwners(cfg, previous *config.Config) error { // Validate saves any normalization as a new draft for the user to review. // An invalid partial draft stays unchanged; reading never calls this method. func (s *Service) Validate(live *config.Config, revision int64, sha string) (Draft, error) { + return s.ValidateWithCheck(live, revision, sha, nil) +} + +// ValidateWithCheck rechecks the caller after preparation, before the durable write. +func (s *Service) ValidateWithCheck(live *config.Config, revision int64, sha string, beforeSave func() error) (Draft, error) { s.mu.Lock() defer s.mu.Unlock() if live == nil { @@ -255,6 +270,11 @@ func (s *Service) Validate(live *config.Config, revision int64, sha string) (Dra if err != nil || len(raw) > MaxDraftBytes { return Draft{}, errors.New("setup draft is too large") } + if beforeSave != nil { + if err := beforeSave(); err != nil { + return Draft{}, err + } + } if err := s.store.SaveConfig(draftKey, string(raw)); err != nil { return Draft{}, fmt.Errorf("save checked setup draft: %w", err) } @@ -319,6 +339,11 @@ func (s *Service) candidateLocked(liveRevision, revision int64, sha string) (Dra // Restart abandons a stale draft only when the caller names its current // revision. It stores a new draft from live settings, never an empty config. func (s *Service) Restart(live *config.Config, expected int64) (Draft, error) { + return s.RestartWithCheck(live, expected, nil) +} + +// RestartWithCheck rechecks the caller after preparation, before the durable write. +func (s *Service) RestartWithCheck(live *config.Config, expected int64, beforeSave func() error) (Draft, error) { s.mu.Lock() defer s.mu.Unlock() old, found, err := s.current() @@ -337,6 +362,11 @@ func (s *Service) Restart(live *config.Config, expected int64) (Draft, error) { if err != nil || len(raw) > MaxDraftBytes { return Draft{}, errors.New("setup draft is too large") } + if beforeSave != nil { + if err := beforeSave(); err != nil { + return Draft{}, err + } + } if err := s.store.SaveConfig(draftKey, string(raw)); err != nil { return Draft{}, err } diff --git a/go/internal/onboarding/operations.go b/go/internal/onboarding/operations.go new file mode 100644 index 000000000..370bdc395 --- /dev/null +++ b/go/internal/onboarding/operations.go @@ -0,0 +1,173 @@ +package onboarding + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/srcfl/ftw/go/internal/config" +) + +// EditOperations applies JSON Patch add/replace/remove operations to a +// named draft revision. Editing a list entry does not resend its siblings. +func (s *Service) EditOperations(live *config.Config, expected int64, raw json.RawMessage) (Draft, error) { + return s.EditOperationsWithCheck(live, expected, raw, nil) +} + +func (s *Service) EditOperationsWithCheck(live *config.Config, expected int64, raw json.RawMessage, beforeSave func() error) (Draft, error) { + d, err := s.Read(live) + if err != nil { + return Draft{}, err + } + if d.Revision != expected { + return Draft{}, ErrConflict + } + if err := rejectDuplicates(raw); err != nil { + return Draft{}, err + } + var operations []struct { + Op string `json:"op"` + Path string `json:"path"` + Value json.RawMessage `json:"value"` + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&operations); err != nil || len(operations) == 0 || len(operations) > 100 { + return Draft{}, errors.New("send 1..100 setup add, replace or remove operations") + } + var original, next any + dec = json.NewDecoder(bytes.NewReader(d.Config)) + dec.UseNumber() + if err := dec.Decode(&original); err != nil { + return Draft{}, err + } + dec = json.NewDecoder(bytes.NewReader(d.Config)) + dec.UseNumber() + if err := dec.Decode(&next); err != nil { + return Draft{}, err + } + for _, op := range operations { + if op.Op != "add" && op.Op != "replace" && op.Op != "remove" { + return Draft{}, errors.New("setup supports only add, replace and remove") + } + if !strings.HasPrefix(op.Path, "/") || len(op.Path) > 1024 { + return Draft{}, errors.New("use a JSON pointer within the setup configuration") + } + parts := strings.Split(op.Path[1:], "/") + for i, part := range parts { + for j := 0; j < len(part); j++ { + if part[j] == '~' { + if j+1 >= len(part) || (part[j+1] != '0' && part[j+1] != '1') { + return Draft{}, errors.New("invalid JSON pointer escape") + } + j++ + } + } + parts[i] = strings.ReplaceAll(strings.ReplaceAll(part, "~1", "/"), "~0", "~") + } + var value any + if op.Op == "remove" { + if len(op.Value) > 0 { + return Draft{}, errors.New("remove does not take a value") + } + } else { + dec = json.NewDecoder(bytes.NewReader(op.Value)) + dec.UseNumber() + if len(op.Value) == 0 || dec.Decode(&value) != nil { + return Draft{}, errors.New("add and replace require a JSON value") + } + } + next, err = editPointer(next, parts, op.Op, value) + if err != nil { + return Draft{}, err + } + } + patch, err := json.Marshal(mergeDifference(original, next)) + if err != nil { + return Draft{}, err + } + // Edit performs the final revision check, type validation, secret + // preservation and one durable write. Any concurrent edit conflicts. + return s.EditWithCheck(live, expected, patch, beforeSave) +} + +func editPointer(node any, parts []string, op string, value any) (any, error) { + if len(parts) == 0 { + return value, nil + } + key, last := parts[0], len(parts) == 1 + switch object := node.(type) { + case map[string]any: + old, exists := object[key] + if last { + if op != "add" && !exists { + return nil, fmt.Errorf("setup field %q is absent", key) + } + if op == "remove" { + delete(object, key) + } else { + object[key] = value + } + return object, nil + } + if !exists { + return nil, fmt.Errorf("setup field %q is absent", key) + } + next, err := editPointer(old, parts[1:], op, value) + if err != nil { + return nil, err + } + object[key] = next + return object, nil + case []any: + index, err := strconv.Atoi(key) + if key == "-" && last && op == "add" { + index, err = len(object), nil + } + if err != nil || index < 0 || index > len(object) || (index == len(object) && (!last || op != "add")) || key != "-" && strconv.Itoa(index) != key { + return nil, errors.New("setup list index is absent or invalid") + } + if last { + switch op { + case "add": + object = append(object, nil) + copy(object[index+1:], object[index:]) + object[index] = value + case "replace": + object[index] = value + case "remove": + object = append(object[:index], object[index+1:]...) + } + return object, nil + } + next, err := editPointer(object[index], parts[1:], op, value) + if err != nil { + return nil, err + } + object[index] = next + return object, nil + default: + return nil, errors.New("setup pointer does not name an object or list") + } +} + +func mergeDifference(before, after any) any { + next, object := after.(map[string]any) + if !object { + return after + } + old, _ := before.(map[string]any) + patch := map[string]any{} + for key := range old { + if _, exists := next[key]; !exists { + patch[key] = nil + } + } + for key, value := range next { + patch[key] = mergeDifference(old[key], value) + } + return patch +} diff --git a/go/internal/onboarding/operations_test.go b/go/internal/onboarding/operations_test.go new file mode 100644 index 000000000..037cad13f --- /dev/null +++ b/go/internal/onboarding/operations_test.go @@ -0,0 +1,59 @@ +package onboarding + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/srcfl/ftw/go/internal/config" +) + +func TestEntryEditPreservesALargeListAndItsCredentials(t *testing.T) { + live := testLive(t) + live.Drivers = []config.Driver{ + {Name: "Meter", Lua: "drivers/meter.lua", IsSiteMeter: true, Config: map[string]any{"password": strings.Repeat("a", 20<<10)}}, + {Name: "Battery", Lua: "drivers/battery.lua", Config: map[string]any{"password": "keep-battery-secret"}}, + } + s := New(draftStore(t)) + d, err := s.EditOperations(live, 0, json.RawMessage(`[{"op":"replace","path":"/drivers/1/name","value":"Garage battery"}]`)) + if err != nil { + t.Fatal(err) + } + var cfg config.Config + if err := json.Unmarshal(d.Config, &cfg); err != nil { + t.Fatal(err) + } + if len(cfg.Drivers) != 2 || cfg.Drivers[0].Config["password"] != live.Drivers[0].Config["password"] || cfg.Drivers[1].Name != "Garage battery" || cfg.Drivers[1].Config["password"] != "keep-battery-secret" { + t.Fatal("entry edit removed a sibling or credential") + } + if _, err := s.EditOperations(live, 0, json.RawMessage(`[{"op":"remove","path":"/drivers/0"}]`)); err != ErrConflict { + t.Fatal("stale list edit did not conflict", err) + } +} + +func TestEntryEditsRejectInvalidPointersAndApplyAllOrNothing(t *testing.T) { + live := testLive(t) + s := New(draftStore(t)) + for _, ops := range []string{ + `[{"op":"replace","path":"/missing","value":1}]`, + `[{"op":"remove","path":"/drivers/99"}]`, + `[{"op":"replace","path":"/site/name","value":"Changed"},{"op":"move","path":"/site/id"}]`, + `[{"op":"replace","path":"/site/name","value":"Changed"},{"op":"replace","path":"/site/no_such_field","value":1}]`, + `[{"op":"replace","path":"/site/~5","value":1}]`, + } { + if _, err := s.EditOperations(live, 0, json.RawMessage(ops)); err == nil { + t.Fatal("invalid edit accepted", ops) + } + d, err := s.Read(live) + if err != nil || d.Revision != 0 { + t.Fatal("failed operation changed the draft", err) + } + } + d, err := s.EditOperations(live, 0, json.RawMessage(`[{"op":"replace","path":"/drivers","value":[]},{"op":"add","path":"/drivers/-","value":{"name":"New meter","lua":"drivers/new.lua","config":{"host":"192.168.1.8"}}},{"op":"remove","path":"/drivers/0"}]`)) + if err != nil { + t.Fatal(err) + } + if d.Revision != 1 { + t.Fatal(d.Revision) + } +} diff --git a/go/internal/onboarding/schema.go b/go/internal/onboarding/schema.go index bfb96aa59..b9be17710 100644 --- a/go/internal/onboarding/schema.go +++ b/go/internal/onboarding/schema.go @@ -45,8 +45,10 @@ func schemaFor(t reflect.Type) map[string]any { } return map[string]any{"type": "object", "properties": properties, "additionalProperties": false} case reflect.Map: - return map[string]any{"type": "object", "additionalProperties": schemaFor(t.Elem())} - case reflect.Slice, reflect.Array: + return map[string]any{"type": []string{"object", "null"}, "additionalProperties": schemaFor(t.Elem())} + case reflect.Slice: + return map[string]any{"type": []string{"array", "null"}, "items": schemaFor(t.Elem())} + case reflect.Array: return map[string]any{"type": "array", "items": schemaFor(t.Elem())} case reflect.String: return map[string]any{"type": "string"} diff --git a/go/protocol/contract_gen.go b/go/protocol/contract_gen.go index 549cf6760..0dee9b66f 100644 --- a/go/protocol/contract_gen.go +++ b/go/protocol/contract_gen.go @@ -185,6 +185,8 @@ const ( ScopeConfigRead = "ftw.config.read" // ScopeDiagRun — Full technical diagnostics and read API. ScopeDiagRun = "ftw.diag.run" + // ScopeSetupWrite — Save confirmed system setup and restart to apply it after user confirmation. + ScopeSetupWrite = "ftw.setup.write" // ScopeDispatchHold — Hold energy assets for a capped time. ScopeDispatchHold = "ftw.dispatch.hold" // ScopeGoalsRequest — Ask an owner to change a durable goal. @@ -203,6 +205,7 @@ var WriteScopes = []string{ ScopeDispatchWrite, ScopeModeWrite, ScopeMembersWrite, + ScopeSetupWrite, ScopeDispatchHold, ScopeGoalsRequest, ScopeUpdateRequest, @@ -380,6 +383,38 @@ var RegistryReads = map[string]string{ "api.read": "ftw.diag.run", } +const MsgSetupSchema = "setup.schema" + +const MsgSetupStatus = "setup.status" + +const MsgSetupEdit = "setup.edit" + +const MsgSetupReset = "setup.reset" + +const MsgSetupValidate = "setup.validate" + +const MsgSetupCommit = "setup.commit" + +const MsgSetupRestart = "setup.restart" + +// RegistryAgentCalls maps typed setup calls to their required grants. +var RegistryAgentCalls = map[string]string{ + // Read this build's complete setup schema. + "setup.schema": "ftw.diag.run", + // Resume the masked setup draft. + "setup.status": "ftw.diag.run", + // Patch the exact current setup draft. + "setup.edit": "ftw.setup.write", + // Start a new draft from current settings. + "setup.reset": "ftw.setup.write", + // Check the exact setup draft without applying it. + "setup.validate": "ftw.setup.write", + // Save the exact checked and confirmed draft. + "setup.commit": "ftw.setup.write", + // Restart to apply the exact saved setup after user confirmation. + "setup.restart": "ftw.setup.write", +} + // Error codes. The box sends the code and machine-readable args; the app // owns every word of prose, in every language it ships. const ( diff --git a/go/protocol/setup.go b/go/protocol/setup.go new file mode 100644 index 000000000..93d7244b1 --- /dev/null +++ b/go/protocol/setup.go @@ -0,0 +1,27 @@ +package protocol + +import ( + "encoding/json" + "errors" +) + +// SetupCall carries a bounded JSON request to a named setup operation. It +// cannot choose an HTTP route, method, headers or another user's identity. +type SetupCall struct { + JSON []byte `cbor:"json,omitempty"` +} + +// A patch fits one padded bulk envelope. JSON Patch operations edit a list +// entry without resending siblings; the box holds the complete draft. +const SetupRequestBytes = 10 << 10 + +func DecodeSetupCall(raw []byte) (SetupCall, error) { + var call SetupCall + if err := strictDec.Unmarshal(raw, &call); err != nil { + return call, err + } + if len(call.JSON) > SetupRequestBytes || len(call.JSON) > 0 && !json.Valid(call.JSON) { + return call, errors.New("invalid setup request") + } + return call, nil +} diff --git a/go/protocol/setup_test.go b/go/protocol/setup_test.go new file mode 100644 index 000000000..ff245238b --- /dev/null +++ b/go/protocol/setup_test.go @@ -0,0 +1,37 @@ +package protocol + +import ( + "encoding/hex" + "strings" + "testing" +) + +func TestSetupCallGoldenVector(t *testing.T) { + const vector = "a1646a736f6e4e7b227265766973696f6e223a317d" + raw, err := Marshal(SetupCall{JSON: []byte(`{"revision":1}`)}) + if err != nil || hex.EncodeToString(raw) != vector { + t.Fatal(err, hex.EncodeToString(raw)) + } + golden, _ := hex.DecodeString(vector) + call, err := DecodeSetupCall(golden) + if err != nil || string(call.JSON) != `{"revision":1}` { + t.Fatal(err, string(call.JSON)) + } +} + +func TestSetupBoundaryRejectsExtraAuthorityAndOversizedPatches(t *testing.T) { + for _, v := range []any{ + map[string]any{"json": []byte(`{}`), "path": "/api/agents"}, + SetupCall{JSON: []byte(`{"patch":"` + strings.Repeat("x", SetupRequestBytes) + `"}`)}, + SetupCall{JSON: []byte(`{"broken"`)}, + } { + raw, _ := Marshal(v) + if _, err := DecodeSetupCall(raw); err == nil { + t.Fatal("invalid setup boundary accepted") + } + } + raw, _ := Marshal(SetupCall{JSON: []byte(`{"patch":{"site":{"name":"Home"}}}`)}) + if _, err := DecodeSetupCall(raw); err != nil { + t.Fatal(err) + } +} diff --git a/web/index.html b/web/index.html index 3b3d7ab53..e2ab3ccf8 100644 --- a/web/index.html +++ b/web/index.html @@ -1009,7 +1009,7 @@

Price bars (top of the chart)

- + diff --git a/web/settings/tabs/access-setup-flow.test.mjs b/web/settings/tabs/access-setup-flow.test.mjs new file mode 100644 index 000000000..4f2bea78d --- /dev/null +++ b/web/settings/tabs/access-setup-flow.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import vm from 'node:vm'; + +const source = readFileSync(new URL('./access.js', import.meta.url), 'utf8'); +const setup = 'ftw.setup.write'; + +function rig(renew = false) { + function element(tag) { + const e = { tag, children: [], listeners: {}, checked: false, value: '', + setAttribute(k, v) { this[k] = v; }, + addEventListener(k, v) { this.listeners[k] = v; }, + appendChild(child) { this.children.push(child); if (tag === 'select' && (!this.value || child.selected)) this.value = child.value; return child; }, + all() { return this.children.flatMap(c => [c, ...c.all()]); }, + querySelectorAll(q) { return this.all().filter(n => { + if (q.startsWith('[id^=')) return n.id?.startsWith(q.slice(6, -2)); + const m = /^(input|select)\[name="(\w+)"\]$/.exec(q); + return m && n.tag === m[1] && n.name === m[2]; + }); }, + querySelector(q) { return this.querySelectorAll(q)[0] || null; }, + submit() { this.listeners.submit({ preventDefault() {} }); }, + click() { this.listeners.click?.(); }, + }; + let text = ''; + Object.defineProperty(e, 'textContent', { get() { return text; }, set(v) { text = v; this.children = []; } }); + return e; + } + const roots = Object.fromEntries(['access-ready', 'access-code', 'access-code-form', 'access-lookup', 'access-note', 'access-offer', 'access-rows', 'access-rows-section', 'access-requests', 'access-requests-section'].map(id => [id, Object.assign(element('div'), { id })])); + const all = () => Object.values(roots).flatMap(e => [e, ...e.all()]); + const posts = []; + const row = { id: 'agent1', role: 'agent', scopes: ['ftw.config.read', setup], ops: [], caps: {}, epoch: 1, expiresAtMs: Date.now() + 86400000, principal: { client: 'ChatGPT' } }; + const registry = { agentOps: {}, writeScopes: [setup], callScopes: [setup] }; + const answer = body => Promise.resolve({ ok: true, status: 200, json: async () => body }); + const context = vm.createContext({ window: {}, document: { + createElement: element, getElementById: id => all().find(e => e.id === id) || null, + }, fetch: (path, opts) => { + if (opts?.method === 'POST') posts.push({ path, body: JSON.parse(opts.body) }); + if (path === '/api/auth/status') return answer({ lan_auth: true, configured: true }); + if (path === '/api/agents' && !opts) return answer({ agents: renew ? [row] : [], requests: [], registry, box: { widen: true, offers: true, clock: 'ok', nowMs: Date.now(), lookup: '/lookup', longestMs: { agent: 31536000000 } } }); + if (path.startsWith('/lookup?')) return answer({ offer: 'closed-fixture', status: 'pending' }); + if (path === '/api/agents/offer') return answer({ ...row, code: 'TESTCODE', client: 'ChatGPT', ttlS: 2592000, expMs: Date.now() + 600000 }); + return answer(row); + }, setTimeout() {}, confirm: () => true, console }); + vm.runInContext(source, context); + context.window.FTWSettings.tabs.access.after({ navigateTab() {} }); + return { roots, posts, all }; +} +const settle = async () => { for (let n = 0; n < 12; n++) await Promise.resolve(); }; + +for (const allow of [false, true]) test(`new offer sends setup only when selected (${allow})`, async () => { + const r = rig(); await settle(); + r.roots['access-code'].value = 'TESTCODE'; + r.roots['access-code-form'].submit(); await settle(); + const form = r.roots['access-offer'].all().find(e => e.tag === 'form'); + assert.ok(form, 'the real lookup must draw the offer form'); + const choice = form.querySelector('input[name="call"]'); + assert.equal(choice.checked, false); + choice.checked = allow; + form.submit(); await settle(); + const sent = r.posts.find(p => p.path === '/api/agents'); + assert.ok(sent, 'the real submit must send the grant'); + assert.equal(sent.body.scopes.includes(setup), allow); +}); +for (const keep of [false, true]) test(`renewal preserves the setup choice (${keep})`, async () => { + const r = rig(true); await settle(); + r.all().find(e => e.tag === 'button' && e.textContent === 'Renew or narrow').click(); + const form = r.roots['access-rows'].all().find(e => e.tag === 'form'); + const choice = form.querySelector('input[name="call"]'); + assert.equal(choice.checked, true); + choice.checked = keep; + form.submit(); await settle(); + const sent = r.posts.find(p => p.path === '/api/agents/agent1/widen'); + assert.ok(sent); + assert.equal(sent.body.scopes.includes(setup), keep); +}); diff --git a/web/settings/tabs/access.js b/web/settings/tabs/access.js index 1b0fa458e..db8a4b38a 100644 --- a/web/settings/tabs/access.js +++ b/web/settings/tabs/access.js @@ -34,6 +34,7 @@ "ftw.logs.read": "Read the box's logs, with names and secrets taken out", "ftw.config.read": "Read the settings, with names and secrets taken out", "ftw.diag.run": "Read full technical diagnostics, source, logs and backups; backups can contain saved credentials", + "ftw.setup.write": "Save the exact setup draft you confirm and restart to apply it when you confirm", "ftw.dispatch.hold": "Act at once, for a few minutes at a time, within these limits", "ftw.goals.request": "Ask for lasting changes, each waiting for your yes here", }; @@ -109,7 +110,7 @@ function splitGrant(scopes, ops, registry) { var write = (registry && registry.writeScopes) || []; var agentOps = (registry && registry.agentOps) || {}; - var reads = [], groups = []; + var reads = [], groups = [], calls = []; // In the page's order, which runs from now to the past to the box itself; // a scope the page has no words for goes last. var order = Object.keys(SCOPE_WORDS); @@ -119,13 +120,17 @@ reads.push(scope); return; } + if (((registry && registry.callScopes) || []).indexOf(scope) >= 0) { + calls.push(scope); + return; + } var opOrder = Object.keys(OP_WORDS); var opRank = function (op) { var i = opOrder.indexOf(op); return i < 0 ? opOrder.length : i; }; var under = (ops || []).filter(function (op) { return agentOps[op] === scope; }) .sort(function (a, b) { return opRank(a) - opRank(b); }); if (under.length) groups.push({ scope: scope, ops: under }); }); - return { reads: reads, groups: groups }; + return { reads: reads, groups: groups, calls: calls }; } // An op runs at once, under the grant's caps, when one of its arguments is @@ -149,6 +154,9 @@ var agentOps = (registry && registry.agentOps) || {}; var ops = sortedUnique(choice.ops || []); var scopes = (choice.reads || []).slice(); + (choice.calls || []).forEach(function (scope) { + if (((registry && registry.callScopes) || []).indexOf(scope) >= 0) scopes.push(scope); + }); ops.forEach(function (op) { if (agentOps[op]) scopes.push(agentOps[op]); }); scopes = sortedUnique(scopes); var caps = { holdS: 0, maxW: 0, perMin: perMin || 0 }; @@ -593,6 +601,10 @@ split.reads.forEach(function (scope) { form.appendChild(checkRow("read", scope, scopeWords(scope), true)); }); + split.calls.forEach(function (scope) { + form.appendChild(checkRow("call", scope, scopeWords(scope), !!current)); + form.appendChild(el("p", { className: "access-quiet", text: "Off until you choose it. This includes equipment names and connection settings. Review and confirm the exact draft before it saves, and confirm any restart separately. Saved equipment settings can affect operation; Core keeps its safety checks." })); + }); if (split.groups.length) { var controlOn = !!current; @@ -628,8 +640,9 @@ } function readChoice(form, current) { - var reads = [], ops = []; + var reads = [], ops = [], calls = []; form.querySelectorAll('input[name="read"]').forEach(function (b) { if (b.checked) reads.push(b.value); }); + form.querySelectorAll('input[name="call"]').forEach(function (b) { if (b.checked) calls.push(b.value); }); var control = form.querySelector('[id^="access-control-"]'); if (!control || control.checked) { form.querySelectorAll('input[name="op"]').forEach(function (b) { if (b.checked) ops.push(b.value); }); @@ -639,7 +652,7 @@ var power = document.getElementById("access-power-" + suffix); var length = form.querySelector('select[name="length"]'); return { - reads: reads, ops: ops, + reads: reads, ops: ops, calls: calls, holdMin: hold ? hold.value : 0, maxKW: power ? power.value : 0, length: length ? length.value : "", }; @@ -712,7 +725,7 @@ card.addEventListener("submit", function (e) { e.preventDefault(); var c = readChoice(card, null); - var grant = chosenGrant({ reads: c.reads, ops: c.ops, holdMin: c.holdMin, maxKW: c.maxKW, ttlS: c.length }, + var grant = chosenGrant({ reads: c.reads, ops: c.ops, calls: c.calls, holdMin: c.holdMin, maxKW: c.maxKW, ttlS: c.length }, form.perMin, registry); var why = choiceProblem(grant, v.caps); if (why) { @@ -778,7 +791,7 @@ ])); var ended = row.expiresAtMs && row.expiresAtMs <= now; item.appendChild(el("p", { className: "access-quiet", text: [ - split.groups.length ? "Reads and controls" : "Reads", + split.calls.length ? (split.groups.length ? "Reads, setup and control" : "Reads and setup") : (split.groups.length ? "Reads and controls" : "Reads"), ended ? "ran out " + whenText(row.expiresAtMs) : "until " + whenText(row.expiresAtMs), agoText(row.lastSeenMs, now), row.approvedBy === "lan" ? "let in on this page" : "let in from a phone", @@ -786,6 +799,7 @@ var list = el("ul", { className: "access-may" }); split.reads.forEach(function (s) { list.appendChild(el("li", { text: scopeWords(s) })); }); + split.calls.forEach(function (s) { list.appendChild(el("li", { text: scopeWords(s) })); }); split.groups.forEach(function (g) { g.ops.forEach(function (op) { list.appendChild(el("li", { text: opWords(op) })); }); }); @@ -841,7 +855,7 @@ e.preventDefault(); var c = readChoice(form, row); var ttlS = c.length === "keep" ? Math.floor((row.expiresAtMs - boxNow()) / 1000) : Number(c.length); - var grant = chosenGrant({ reads: c.reads, ops: c.ops, holdMin: c.holdMin, maxKW: c.maxKW, ttlS: ttlS }, + var grant = chosenGrant({ reads: c.reads, ops: c.ops, calls: c.calls, holdMin: c.holdMin, maxKW: c.maxKW, ttlS: ttlS }, f.perMin, registry); var why = choiceProblem(grant, { holdS: (row.caps || {}).holdS || 0, maxW: (row.caps || {}).maxW || 0 }); if (why) { diff --git a/web/settings/tabs/access.test.mjs b/web/settings/tabs/access.test.mjs index b9e15d372..255223aa1 100644 --- a/web/settings/tabs/access.test.mjs +++ b/web/settings/tabs/access.test.mjs @@ -30,7 +30,8 @@ const registry = { "car.goal.set": "ftw.goals.request", "car.soc.set": "ftw.goals.request", }, - writeScopes: ["ftw.dispatch.write", "ftw.mode.write", "ftw.dispatch.hold", "ftw.goals.request"], + writeScopes: ["ftw.dispatch.write", "ftw.mode.write", "ftw.dispatch.hold", "ftw.goals.request", "ftw.setup.write"], + callScopes: ["ftw.setup.write"], agentArgs: { "loadpoint.hold": [ { name: "id", label: "Charger", kind: "loadpoint" }, @@ -131,6 +132,15 @@ describe("names from outside", () => { }); describe("what the owner grants", () => { + it("keeps setup separate from reads and only grants the explicit choice", () => { + const split = P.splitGrant(["ftw.config.read", "ftw.setup.write"], [], registry); + assert.deepEqual(split.reads, ["ftw.config.read"]); + assert.deepEqual(split.groups, []); + assert.deepEqual(split.calls, ["ftw.setup.write"]); + const base = { reads: split.reads, ops: [], ttlS: 86400 }; + assert.deepEqual(P.chosenGrant(base, 0, registry).scopes, ["ftw.config.read"]); + assert.deepEqual(P.chosenGrant({ ...base, calls: split.calls }, 0, registry).scopes, ["ftw.config.read", "ftw.setup.write"]); + }); const offer = { scopes: ["ftw.config.read", "ftw.dispatch.hold", "ftw.goals.request", "ftw.live.read", "ftw.plan.read"], ops: ["car.goal.set", "loadpoint.hold", "site.mode.set"],