From 11756627d4d7f97ce3e00b7c500d28d0676ee353 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 16:46:39 +0200 Subject: [PATCH 1/2] feat(cloud): include statistics in Cloud terms and ask local owners --- .changeset/cloud-statistics-terms.md | 9 ++ docs/adr/0008-ftw-cloud.md | 80 ++++++---- docs/protocol-cloud.md | 17 +++ go/cmd/ftw/fleet_ping.go | 3 +- go/internal/api/api_cloud.go | 21 +++ go/internal/api/api_cloud_test.go | 15 ++ go/internal/api/cloud_statistics.go | 15 +- go/internal/cloudlink/choice.go | 125 ++++++++++++++++ go/internal/cloudlink/choice_test.go | 138 ++++++++++++++++++ go/internal/cloudlink/conn.go | 4 +- go/internal/cloudlink/events.go | 3 + go/internal/cloudlink/grant.go | 10 +- go/internal/cloudlink/link.go | 100 ++++++++++++- go/internal/cloudlink/sender.go | 61 ++++---- go/protocol/cloudwire/allowlist.go | 2 +- go/protocol/cloudwire/messages.go | 1 + go/protocol/cloudwire/stats.go | 8 +- .../testdata/cloud-quic-vectors.json | 11 +- .../cloudwire/testdata/cloud-vectors.json | 40 +++-- go/protocol/cloudwire/vectors_test.go | 3 +- web/app-link-tab.test.mjs | 2 +- web/index.html | 7 +- web/lan-auth.js | 1 + web/settings/tabs/app.js | 9 ++ web/settings/tabs/fleet.js | 5 +- 25 files changed, 590 insertions(+), 100 deletions(-) create mode 100644 .changeset/cloud-statistics-terms.md create mode 100644 go/internal/cloudlink/choice.go create mode 100644 go/internal/cloudlink/choice_test.go diff --git a/.changeset/cloud-statistics-terms.md b/.changeset/cloud-statistics-terms.md new file mode 100644 index 000000000..6b09b3ca0 --- /dev/null +++ b/.changeset/cloud-statistics-terms.md @@ -0,0 +1,9 @@ +--- +"ftw": minor +--- + +Include project statistics in the updated FTW Cloud terms. Local-only users +get a separate question with Yes suggested; nothing starts before an answer. +Statistics-only sharing grants no app or assistant remote access. Turning +Cloud off ends remote access and remembers that choice across restarts. +Hourly collection still waits for the signed C8 review and service approval. diff --git a/docs/adr/0008-ftw-cloud.md b/docs/adr/0008-ftw-cloud.md index 5ea2c17a8..81ecdb1dc 100644 --- a/docs/adr/0008-ftw-cloud.md +++ b/docs/adr/0008-ftw-cloud.md @@ -154,33 +154,46 @@ cases. ## Statistics -**The start flow has two switches.** The first turns FTW Cloud on; the iPhone -app needs it, and it lists what leaves the house. The second turns statistics -on; it is off by default, and the app works the same either way. A change to -purpose or fields needs a new policy version and fresh consent. +**Product amendment, 17 October 2026.** Everyone who chooses FTW Cloud +contributes to project statistics. The updated Cloud terms name this scope; +there is no separate statistics opt-out within Cloud. Existing users must +accept the new terms before the approved policy can admit their home. +Owners who decline use the box locally without Cloud. + +Local-only users get a separate question, with Yes as the suggested action. +Nothing starts before an active answer. No leaves local control working. +This statistics-only link publishes no phone keys, pairing codes, Noise key, +read grant, live state or remote access. It reuses the signed upload path. +A policy or purpose change requires a new version and a fresh answer. +This product decision does not establish a lawful basis; C8 stays off until +that question and the DPIA have been reviewed and signed. - **Purpose.** To learn how homes with FTW use and shift energy, to improve FTW's planning and defaults, and to publish figures about homes as a group. Not for advertising, decisions about one home, or handing rows to anyone. -- **Fields.** Per closed hour, the site's energy totals in Wh, rounded to - 10 Wh, under the ledger's flow names; the price zone; which of battery, - solar and charger the site has; the policy version. Nothing finer than an - hour, and no names, addresses, IP addresses, serials, coordinates, VINs, - config or logs. +- **Fields.** Recorded closed-hour grid import/export, solar generation, + battery and vehicle charge/discharge; configured battery capacity, + battery/charger power limits and solar rating. Rounded to 10 Wh or W. + Missing values stay absent. No names, addresses, location, device ids, + prices, free text, configuration documents or logs. - **`statsId`.** 128 random bits drawn by the box at consent, derived from nothing else. A new consent draws a new one. -- **Retention.** Rows go 13 months after the hour they describe. Withdrawal or - a purge deletes them within 24 hours. Published aggregates stay, and the +- **Retention.** Rows go 13 months after the hour they describe. Leaving Cloud, stopping local sharing or + a purge deletes unpublished rows when Cloud receives the signed request. + An offline box sends it on reconnection. Published aggregates stay, and the consent screen says so. -- **Cell rules.** At least 20 homes per figure, nothing finer than a price - zone or an hour, and no home above 30 % of a cell's sum. Code applies the - rules; no person reads raw rows. +- **Cell rules.** At least 20 homes per figure, no per-home output, and no + home above 30 % of a cell's sum. Code applies the rules. No staff route + reads raw rows; full host administrators can read the database. - **Partners.** Homes under a partner's control stay out unless that partner's contract allows them in. Nova keeps B2B partner telemetry. -- **Law.** The service links `statsId` to the home so a purge can reach it, - which makes the rows personal data (CJEU C-413/23 P). Serving the owner's phones - is necessary for the service asked for and meets the exemption in LEK - 9 kap. 28 §; statistics are not, so they need consent. A lawyer confirms. +- **Law.** The service links `statsId` to the home for deletion, so these + rows are personal data. The required Cloud model needs its own documented + lawful-basis assessment. Writing it in terms does not establish contractual + necessity or make conditional consent freely given. Local sharing proposes + informed, separate consent. A legal review must settle both paths, other + household members and terminal-access rules before activation. The review + draft lives in `ftw-cloud/privacy/project-statistics-dpia.md`. ## Security and retention @@ -262,7 +275,9 @@ later step stands on it and ships on its own. | C7 Live Activity | Signed state in the extension view | A forged state shows "Open FTW" | | C8 Statistics | Ingest, backfill, cell rules | DPIA signed; withdrawal purges within 24 hours | -The statistics switch ships with the start flow in C4. The box records consent +The original separate Cloud statistics switch is replaced by the 17 October +product amendment above. The Cloud terms and the local question ship with the +start flow. The box records consent and `statsId` from then on. The October project policy below needs new consent and sends only later hours; it does not backfill the earlier gap. The lawyer also confirms that order: if the DPIA in C8 changes the rules, the app asks again. A public launch needs C2–C4 and the legal work. @@ -296,13 +311,26 @@ rules, the app asks again. A public launch needs C2–C4 and the legal work. ### Project statistics policy, October 2026 -`ftw-project-stats-2026-10-v1` adds configured battery capacity, flexible -power and solar ratings to the closed-hour energy scope. This needs fresh -consent; an earlier statistics choice sends none of these fields. The owner -can read the policy through `GET /api/cloud/stats-policy`, then accept its -exact id through the existing owner-only statistics API. Stats stay off by -default. Changing the policy rotates the random stats id and starts a new -consent window. Hours before that window never contribute. +`ftw-project-stats-2026-10-v2` covers configured capacities, flexible power, +solar rating and closed-hour energy. `ftw-cloud-2026-10-v2` includes that scope +for every Cloud home. An owner reads `GET /api/cloud/stats-policy`, then +accepts the Cloud terms through `POST /api/cloud/grant`. Core records the +scope, random stats id and start time in the same grant. An older Cloud +answer or saved statistics checkbox does not accept this version. + +A local owner can instead accept the statistics policy through +`POST /api/cloud/stats`, without granting readable state or remote access. +`DELETE /api/cloud/stats` stops that local contribution. Within Cloud, +statistics cannot be turned off alone; `DELETE /api/cloud` ends Cloud, +requests home deletion and remembers the off choice across restarts. +Phones can need pairing again after the home key is deleted. An app user +who declines its terms pauses Cloud on that phone and keeps its local keys. + +The daily anonymous report also waits for the current project choice. +Its saved config switch cannot bypass a missing answer or a local No. +A new policy rotates the stats id and starts a new reporting window. An +unset wall clock starts that window only once time is known; no earlier +hours are filled in. The public view counts installations reporting in the last 24 closed hours. It sums the recorded hours, which may be incomplete; it does not claim all diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index 27403fcec..50aa500f1 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -731,6 +731,7 @@ every call. | `purposes` | [text] | A subset of `cloud_purposes`: `serve` while the copy is on, and `stats` while the owner contributes to statistics. Empty while the copy is off or being withdrawn | | `statsId` | bstr, 16 B, optional | Random id for the separately accepted statistics policy; omitted when off | | `statsPolicy` | text, optional | Exact policy the owner accepted | +| `termsPolicy` | text, optional | Cloud terms accepted by the owner; the current Cloud policy includes statistics | | `statsSinceMs` | uint, optional | Consent start in UTC milliseconds; only later closed hours may contribute | | `controller` | text | The data controller: `sourceful` unless a partner pairing flow sets another. No such flow exists yet | @@ -1627,3 +1628,19 @@ constants, so `srcfl/ftw-cloud` need not spell them by hand. | Statistics rows kept | 13 months from the hour they describe | | Rule N clock lead allowed | 2 s | | Rule U drift margin, lifetime | 0.01 % of the time since learned; 24 hours | + + +### Cloud terms and local statistics, policy version 2 + +`ftw-cloud-2026-10-v2` includes `ftw-project-stats-2026-10-v2` for every Cloud +home. `Grant.termsPolicy` carries the owner-accepted terms. Once the operator +approves collection, Cloud admits only that bundled grant or a statistics-only +roster. Old terms are not silently upgraded; they need an owner answer. +Restoring a roster after restart applies the same rule. + +A statistics-only roster names the statistics policy as its terms policy, +contains the stats purpose alone and has no read scopes, devices, pending +codes, Noise key or browser subscriptions. It grants no remote access. Core +also rejects tunnels locally in that mode and emits no live or event data. +Collection stays off until the signed C8 review; the default operator setting +is empty. Withdrawal and retention continue while it is paused. diff --git a/go/cmd/ftw/fleet_ping.go b/go/cmd/ftw/fleet_ping.go index 99ed92a70..592ebed7e 100644 --- a/go/cmd/ftw/fleet_ping.go +++ b/go/cmd/ftw/fleet_ping.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/srcfl/ftw/go/internal/cloudlink" "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/drivers" "github.com/srcfl/ftw/go/internal/fleetping" @@ -76,7 +77,7 @@ func startFleetPing( installedAt := firstSeen(st, time.Now()) pinger, err := fleetping.New(fleetping.Options{ - Enabled: fleetPingEnabled(cfg, cfgMu), + Enabled: func() bool { return fleetPingEnabled(cfg, cfgMu)() && cloudlink.StatisticsChoice(st) }, Facts: func() fleetping.Facts { // Drivers can be installed without restarting FTW. Read the signed // catalogue at the same time as the config so tomorrow's report and diff --git a/go/internal/api/api_cloud.go b/go/internal/api/api_cloud.go index 7378aa964..6b9bb28a6 100644 --- a/go/internal/api/api_cloud.go +++ b/go/internal/api/api_cloud.go @@ -29,10 +29,12 @@ type CloudGrant interface { Revoke(by string) error Stats(cloudlink.StatsRequest) error StopStats(by string) error + End(by string) error } func (s *Server) cloudRoutes() { s.handle("GET /api/cloud", Read, s.handleCloudStatus) + s.handle("DELETE /api/cloud", Configure, s.handleCloudEnd, NoStepUp) s.handle("GET /api/cloud/preview", Read, s.handleCloudPreview) s.handle("GET /api/cloud/stats-policy", Read, s.handleCloudStatsPolicy) s.handle("POST /api/cloud/grant", Configure, s.handleCloudGrant, NoStepUp) @@ -204,6 +206,10 @@ func (s *Server) handleCloudStopStats(w http.ResponseWriter, r *http.Request) { return } if err := link.StopStats(cloudCaller(r)); err != nil { + if errors.Is(err, cloudlink.ErrCloudNeedsStatistics) { + writeAppLinkError(w, http.StatusConflict, err.Error()) + return + } writeAppLinkError(w, http.StatusInternalServerError, "could not turn statistics off") return } @@ -228,3 +234,18 @@ func (s *Server) CloudLoadpoints(now time.Time) (states []loadpoint.State, feedb s.decorateLoadpointsWithPlan(states) return states, s.controlFeedback(now), true } + +func (s *Server) handleCloudEnd(w http.ResponseWriter, r *http.Request) { + if !s.cloudGate(w, r, false) { + return + } + link, ok := s.cloudLink(w) + if !ok { + return + } + if err := link.End(cloudCaller(r)); err != nil { + writeAppLinkError(w, http.StatusInternalServerError, "could not stop FTW Cloud") + return + } + writeJSON(w, http.StatusOK, link.Status()) +} diff --git a/go/internal/api/api_cloud_test.go b/go/internal/api/api_cloud_test.go index 5f149a640..969be28e2 100644 --- a/go/internal/api/api_cloud_test.go +++ b/go/internal/api/api_cloud_test.go @@ -34,6 +34,8 @@ func (c *stubCloud) Grant(req cloudlink.GrantRequest) error { c.granted = append(c.granted, req) return c.err } +func (c *stubCloud) End(by string) error { return c.Revoke(by) } + func (c *stubCloud) Revoke(by string) error { c.revoked = append(c.revoked, by) return nil @@ -281,6 +283,7 @@ func TestAViewerReachesNoCloudRoute(t *testing.T) { {http.MethodGet, "/api/cloud/preview", "", s.handleCloudPreview}, {http.MethodPost, "/api/cloud/grant", grantBody, s.handleCloudGrant}, {http.MethodDelete, "/api/cloud/grant", "", s.handleCloudRevoke}, + {http.MethodDelete, "/api/cloud", "", s.handleCloudEnd}, {http.MethodPost, "/api/cloud/stats", statsBody, s.handleCloudStats}, {http.MethodDelete, "/api/cloud/stats", "", s.handleCloudStopStats}, } { @@ -386,3 +389,15 @@ func (k *cloudKV) SaveConfig(key, value string) error { k.m[key] = value return nil } + +func TestWholeCloudOffUsesTheExistingNarrowingAccessRule(t *testing.T) { + for _, req := range []*http.Request{sessionRequest(http.MethodDelete, "/api/cloud", ""), localRequest(http.MethodDelete, "/api/cloud", "")} { + c := &stubCloud{} + s := New(&Deps{Cloud: c}) + w := httptest.NewRecorder() + s.handleCloudEnd(w, req) + if w.Code != http.StatusOK || len(c.revoked) != 1 { + t.Fatal("whole Cloud off failed", w.Code) + } + } +} diff --git a/go/internal/api/cloud_statistics.go b/go/internal/api/cloud_statistics.go index fdf8c2246..97712bad2 100644 --- a/go/internal/api/cloud_statistics.go +++ b/go/internal/api/cloud_statistics.go @@ -7,11 +7,12 @@ import ( "time" "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/protocol" "github.com/srcfl/ftw/go/protocol/cloudwire" ) -// CloudStatistics contains only the fields named by the separate statistics -// consent. Configuration and ledger ids never cross this boundary. +// CloudStatistics contains only the fields named by the versioned project +// statistics terms. Configuration and ledger ids never cross this boundary. func (s *Server) CloudStatistics(ctx context.Context, now time.Time) (cloudwire.Stats, bool) { hour := now.UTC().Truncate(time.Hour).Add(-time.Hour) out := cloudwire.Stats{HourMS: hour.UnixMilli(), Metrics: map[string]int64{}} @@ -128,13 +129,17 @@ func (s *Server) handleCloudStatsPolicy(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, map[string]any{ "available": available, "policy": cloudwire.StatsPolicy, + "cloud_scopes": protocol.CloudScopes, + "cloud_policy": cloudwire.CloudTermsPolicy, + "cloud_includes_statistics": true, + "standalone": true, "purpose": "Publish group totals for FTW use and energy; improve planning. No ads or household decisions.", "fields": cloudwire.StatsMetricKeys, - "meaning": "One opted-in installation, configured flexible power and solar ratings, battery capacity, and recorded closed-hour energy. Configured limits do not prove physical control. Missing readings are unknown.", + "meaning": "One reporting installation, configured flexible power and solar ratings, battery capacity, and recorded closed-hour energy. Configured limits do not prove physical control. Missing readings are unknown.", "resolution": "Closed UTC hours, energy rounded to 10 Wh, capacities and limits rounded to 10 Wh or W. No names, addresses, device ids or location.", - "retention": "13 months. Switching statistics off deletes unpublished household rows; published group totals remain.", + "retention": "13 months. Leaving Cloud or stopping local sharing deletes unpublished household rows; published group totals remain.", "publication_minimum_homes": cloudwire.StatsMinimumHomes, "publication_max_home_share": 0.3, - "controller": "sourceful", "default": "off", "optional": true, + "controller": "sourceful", "local_default": "ask_yes", "local_optional": true, "cloud_required": true, }) } diff --git a/go/internal/cloudlink/choice.go b/go/internal/cloudlink/choice.go new file mode 100644 index 000000000..c5c1bf0ae --- /dev/null +++ b/go/internal/cloudlink/choice.go @@ -0,0 +1,125 @@ +package cloudlink + +import ( + "errors" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +var ErrCloudNeedsStatistics = errors.New("cloudlink: statistics are part of FTW Cloud; turn FTW Cloud off to stop contributing") + +// includeStatistics records the exact scope the owner read. Service approval +// still gates every report. The caller holds mu. +func (l *Link) includeStatistics(g *grantRecord, fresh bool) error { + if fresh || g.StatsPolicy != cloudwire.StatsPolicy || len(g.StatsID) != 16 { + id, err := randomBytes(statsIDBytes) + if err != nil { + return err + } + g.StatsID, g.StatsAtMs = id, 0 + if l.clockSynced() { + g.StatsAtMs = l.opts.Now().UnixMilli() + } + } + g.StatsPolicy = cloudwire.StatsPolicy + g.Purposes = withPurpose(g.Purposes, protocol.CloudPurposeStats, true) + return nil +} + +func (l *Link) enableLinkChoice() error { + if err := l.opts.KV.SaveConfig(disabledKey, "false"); err != nil { + return err + } + l.disabled = false + return nil +} + +// startStatisticsOnly reuses the signed uplink, without publishing any phone, +// pairing code, Noise key, live reading or grant for remote access. +func (l *Link) startStatisticsOnly(req StatsRequest) error { + if err := l.linkLocked(); err != nil { + return err + } + if l.link.Ending { + return ErrLinkEnding + } + id, err := randomBytes(cloudwire.GrantIDBytes) + if err != nil { + return err + } + key, err := randomBytes(cloudwire.GrantKeyBytes) + if err != nil { + return err + } + g := grantRecord{ID: id, GrantKey: key, KeyFp: l.link.KeyFp, Policy: req.Policy, By: req.By, Controller: cloudwire.ControllerSourceful, Scopes: []string{}, Purposes: []string{protocol.CloudPurposeStats}} + if l.clockSynced() { + g.AtMs = l.opts.Now().UnixMilli() + } + if err := l.includeStatistics(&g, true); err != nil { + return err + } + if err := l.enableLinkChoice(); err != nil { + return err + } + return l.storeLocked(g, "local statistics accepted") +} + +// End stops all Cloud functions, purges the home and remembers the choice so +// a paired phone or a restart cannot silently turn the link back on. +func (l *Link) End(by string) error { + l.mu.Lock() + defer l.mu.Unlock() + if err := l.opts.KV.SaveConfig(disabledKey, "true"); err != nil { + return err + } + l.disabled = true + if err := l.opts.KV.SaveConfig(statsDecisionKey, ""); err != nil { + return err + } + if l.link == nil { + return nil + } + rec := *l.link + rec.Ending = true + if err := saveJSON(l.opts.KV, linkKey, rec); err != nil { + return err + } + l.link = &rec + if l.grant != nil { + g := *l.grant + g.Revoked, g.By = true, by + if err := saveJSON(l.opts.KV, grantKey, g); err != nil { + return err + } + l.grant = &g + } + l.rosterDue = true + l.signal() + return nil +} + +// StatisticsChoice is the saved answer for both project reports. The daily +// report must not turn itself on before the owner answers the new question. +func StatisticsChoice(kv KV) bool { + if kv == nil { + return false + } + if disabled, _ := kv.LoadConfig(disabledKey); disabled == "true" { + return false + } + g, err := loadJSON[grantRecord](kv, grantKey) + return err == nil && g != nil && !g.Revoked && g.StatsPolicy == cloudwire.StatsPolicy && len(g.StatsID) == 16 +} + +// An unset clock cannot place a choice in UTC. Start its upload window only +// once the clock is known, never by filling earlier hours after NTP arrives. +func (l *Link) startStatisticsClock() error { + l.mu.Lock() + defer l.mu.Unlock() + if l.disabled || l.grant == nil || l.grant.Revoked || l.grant.StatsPolicy != cloudwire.StatsPolicy || l.grant.StatsAtMs != 0 || !l.clockSynced() { + return nil + } + g := *l.grant + g.StatsAtMs = l.opts.Now().UnixMilli() + return l.storeLocked(g, "statistics start clock set") +} diff --git a/go/internal/cloudlink/choice_test.go b/go/internal/cloudlink/choice_test.go new file mode 100644 index 000000000..e87fb5089 --- /dev/null +++ b/go/internal/cloudlink/choice_test.go @@ -0,0 +1,138 @@ +package cloudlink + +import ( + "errors" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" + "log/slog" + "testing" + "time" +) + +func TestCloudTermsIncludeStatisticsAndCannotOptOutSeparately(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + if err := h.link.Grant(GrantRequest{Scopes: []string{protocol.ScopeLiveRead}, Policy: cloudwire.CloudTermsPolicy, By: "owner"}); err != nil { + t.Fatal(err) + } + r := h.nextRoster(t) + if r.Grant.TermsPolicy != cloudwire.CloudTermsPolicy || r.Grant.StatsPolicy != cloudwire.StatsPolicy || len(r.Grant.StatsID) != 16 || r.Grant.StatsSinceMs <= 0 { + t.Fatal("Cloud did not include the accepted statistics scope") + } + if err := h.link.StopStats("owner"); !errors.Is(err, ErrCloudNeedsStatistics) { + t.Fatal("Cloud could opt out independently", err) + } + if !h.link.Status().CloudTermsAccepted || !h.link.Status().StatisticsActive { + t.Fatal("new terms not active") + } +} + +func TestLocalStatisticsPublishesNoRemoteAccess(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + if err := h.link.Stats(StatsRequest{Policy: cloudwire.StatsPolicy, By: "local owner"}); err != nil { + t.Fatal(err) + } + r := h.nextRoster(t) + if len(r.Devices) != 0 || len(r.Pending) != 0 || len(r.Noise) != 0 || len(r.WebPush) != 0 || len(r.Grant.Scopes) != 0 || len(r.Grant.Purposes) != 1 || r.Grant.Purposes[0] != protocol.CloudPurposeStats { + t.Fatal("statistics-only exposed Cloud access", r) + } + if h.link.TakesTun() || h.link.Home() != "" || h.link.SessionInfo() != nil || h.link.current().copyOn() { + t.Fatal("local statistics enabled Cloud functions") + } + if err := h.link.SendTun(cloudwire.Tun{}, false); !errors.Is(err, ErrNoUplink) { + t.Fatal("statistics-only queued a tunnel", err) + } + if !h.link.Status().StatisticsOnly || h.link.Status().State != "statistics" { + t.Fatal("local statistics labelled Cloud") + } +} + +func TestDecliningLocalStatisticsRecordsTheAnswerWithoutCreatingALink(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + if !h.link.Status().StatisticsDecisionRequired { + t.Fatal("an unanswered choice looked accepted") + } + if err := h.link.StopStats("local owner"); err != nil { + t.Fatal(err) + } + s := h.link.Status() + if s.StatisticsDecisionRequired || s.Link != "off" || s.StatisticsActive { + t.Fatal("decline did not stay local", s) + } +} + +func TestEndingCloudPreventsAutomaticRelinkFromPairedPhones(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t) + h.nextRoster(t) + if err := h.link.End("owner"); err != nil { + t.Fatal(err) + } + if h.link.TakesTun() || h.link.wantsLink() { + t.Fatal("disabled Cloud still wanted remote access") + } + h.link.RosterChanged() + disabled, ok := h.link.opts.KV.LoadConfig(disabledKey) + if !ok || disabled != "true" { + t.Fatal("off choice was not durable") + } +} + +func TestDailyReportRequiresCurrentAnswerAndHonoursDurableOff(t *testing.T) { + kv := &memKV{} + if StatisticsChoice(kv) { + t.Fatal("unanswered box may send") + } + g := grantRecord{StatsPolicy: "ftw-project-stats-2026-10-v1", StatsID: make([]byte, 16)} + if err := saveJSON(kv, grantKey, g); err != nil { + t.Fatal(err) + } + if StatisticsChoice(kv) { + t.Fatal("old choice may send new report") + } + g.StatsPolicy = cloudwire.StatsPolicy + if err := saveJSON(kv, grantKey, g); err != nil { + t.Fatal(err) + } + if !StatisticsChoice(kv) { + t.Fatal("current answer may not send") + } + // This flag is saved first, even if writing a later purge record fails. + if err := kv.SaveConfig(disabledKey, "true"); err != nil { + t.Fatal(err) + } + if StatisticsChoice(kv) { + t.Fatal("durable Cloud off did not stop daily report") + } +} + +func TestStatisticsClockStartsAfterNTPWithoutBackfill(t *testing.T) { + now := time.Date(2026, 10, 17, 13, 45, 0, 0, time.UTC) + synced := false + kv := &memKV{} + g := grantRecord{StatsPolicy: cloudwire.StatsPolicy, StatsID: make([]byte, 16)} + l := &Link{log: slog.Default(), opts: Options{KV: kv, Now: func() time.Time { return now }, ClockSynced: func() bool { return synced }}, grant: &g} + if err := l.startStatisticsClock(); err != nil { + t.Fatal(err) + } + if l.grant.StatsAtMs != 0 { + t.Fatal("an unknown clock started collection") + } + synced = true + if err := l.startStatisticsClock(); err != nil { + t.Fatal(err) + } + if l.grant.StatsAtMs != now.UnixMilli() { + t.Fatal("clock backfilled the earlier choice") + } + now = now.Add(time.Hour) + if err := l.startStatisticsClock(); err != nil { + t.Fatal(err) + } + if l.grant.StatsAtMs != now.Add(-time.Hour).UnixMilli() { + t.Fatal("clock moved on reconnect") + } +} diff --git a/go/internal/cloudlink/conn.go b/go/internal/cloudlink/conn.go index 66bc2e0e2..c31f29d82 100644 --- a/go/internal/cloudlink/conn.go +++ b/go/internal/cloudlink/conn.go @@ -438,7 +438,7 @@ func (l *Link) read(conn uplinkConn, snap grantSnapshot, events chan<- cloudwire if len(env.Sig) != 0 || cloudwire.Unmarshal(env.B, &t) != nil || len(t.D) > cloudwire.TunDataMax { return fmt.Errorf("%w: a tun the box cannot read", errService) } - if l.opts.Tunnel != nil { + if l.TakesTun() { l.opts.Tunnel.Deliver(t) } case cloudwire.MsgWatch, cloudwire.MsgResync: @@ -523,7 +523,7 @@ func (l *Link) SendTun(t cloudwire.Tun, bulk bool) error { q = l.tunBulk } switch { - case q == nil: + case q == nil || l.disabled || l.grant.statisticsOnly(): l.tunDropped++ return ErrNoUplink case err != nil || l.tunBudget.total(l.uptimeMs()) >= tunBudgetBytes: diff --git a/go/internal/cloudlink/events.go b/go/internal/cloudlink/events.go index aa7c742e1..30f2a722c 100644 --- a/go/internal/cloudlink/events.go +++ b/go/internal/cloudlink/events.go @@ -250,6 +250,9 @@ func (l *Link) Notice(kind, client string) bool { // sendNotices sends what waits, oldest first. func (w *writer) sendNotices(snap grantSnapshot) error { + if snap.g.statisticsOnly() { + return nil + } l := w.link l.mu.Lock() queued := l.notices diff --git a/go/internal/cloudlink/grant.go b/go/internal/cloudlink/grant.go index b0f300c0d..625ad0c8b 100644 --- a/go/internal/cloudlink/grant.go +++ b/go/internal/cloudlink/grant.go @@ -22,10 +22,12 @@ import ( // document, so a whole-document config write cannot reset them. A missing // record reads as off. const ( - linkKey = "cloud/link_v1" - grantKey = "cloud/grant_v1" - rosterKey = "cloud/roster_v1" - purgedKey = "cloud/purged_v1" + linkKey = "cloud/link_v1" + grantKey = "cloud/grant_v1" + rosterKey = "cloud/roster_v1" + purgedKey = "cloud/purged_v1" + disabledKey = "cloud/disabled_v1" + statsDecisionKey = "cloud/statistics_decision_v1" ) // linkRecord is the box's link to FTW Cloud: which cloud.key it holds. The diff --git a/go/internal/cloudlink/link.go b/go/internal/cloudlink/link.go index 759d0a12a..35623bf7b 100644 --- a/go/internal/cloudlink/link.go +++ b/go/internal/cloudlink/link.go @@ -248,6 +248,7 @@ type Link struct { home string link *linkRecord grant *grantRecord + disabled bool notice string connected bool serviceStatsPolicy string @@ -392,6 +393,8 @@ func (l *Link) load() error { l.mu.Lock() defer l.mu.Unlock() l.link, l.grant, l.key, l.pk, l.home, l.notice = nil, nil, nil, nil, "", "" + disabled, _ := l.opts.KV.LoadConfig(disabledKey) + l.disabled = disabled == "true" if r, err := loadJSON[receipt](l.opts.KV, purgedKey); err == nil && r != nil { l.purgedAt = r.AtMs } @@ -439,6 +442,12 @@ func (l *Link) load() error { l.log.Warn("FTW Cloud is off: cloud.key is not the key the link was made with") return nil } + if l.disabled { + link.Ending = true + if g != nil { + g.Revoked = true + } + } l.link, l.key, l.pk, l.home = link, key, pk, cloudwire.HomeID(pk) switch { case g == nil: @@ -484,6 +493,12 @@ func (l *Link) RosterChanged() { // page. A box that takes no tun cannot serve an agent, so its rows want no // link. A grant needs the link too, and makes it itself. func (l *Link) wantsLink() bool { + l.mu.Lock() + blocked := l.disabled || (l.grant != nil && l.grant.statisticsOnly()) + l.mu.Unlock() + if blocked { + return false + } if _, _, ok := l.opts.Roster.Pending(); ok { return true } @@ -593,7 +608,19 @@ func (l *Link) Grant(req GrantRequest) error { return ErrLinkEnding case l.grant != nil: g := *l.grant + fresh := g.Policy != req.Policy g.Scopes, g.Policy, g.By = scopes, req.Policy, req.By + g.Purposes = withPurpose(g.Purposes, protocol.CloudPurposeServe, true) + if req.Policy == cloudwire.CloudTermsPolicy { + if err := l.includeStatistics(&g, fresh); err != nil { + l.mu.Unlock() + return err + } + } + if err := l.enableLinkChoice(); err != nil { + l.mu.Unlock() + return err + } if err := saveJSON(l.opts.KV, grantKey, g); err != nil { l.mu.Unlock() return err @@ -627,6 +654,17 @@ func (l *Link) Grant(req GrantRequest) error { if l.clockSynced() { g.AtMs = time.Now().UnixMilli() } + if req.Policy == cloudwire.CloudTermsPolicy { + if err := l.includeStatistics(&g, true); err != nil { + l.mu.Unlock() + return err + } + } + if err := l.enableLinkChoice(); err != nil { + l.mu.Unlock() + return err + } + if err := saveJSON(l.opts.KV, grantKey, g); err != nil { l.mu.Unlock() return err @@ -646,6 +684,12 @@ func (l *Link) Grant(req GrantRequest) error { // record stays until the service's signed receipt arrives. The link, the // home id and the tunnel stay. func (l *Link) Revoke(by string) error { + l.mu.Lock() + whole := l.grant != nil && (l.grant.Policy == cloudwire.CloudTermsPolicy || l.grant.statisticsOnly()) + l.mu.Unlock() + if whole { + return l.End(by) + } l.mu.Lock() if l.grant == nil || l.grant.Revoked { l.mu.Unlock() @@ -692,7 +736,10 @@ func (l *Link) Stats(req StatsRequest) error { defer l.mu.Unlock() switch { case l.grant == nil: - return ErrNotGranted + if req.Policy != cloudwire.StatsPolicy { + return ErrNotGranted + } + return l.startStatisticsOnly(req) case l.grant.Revoked: return ErrPurgePending } @@ -718,8 +765,24 @@ func (l *Link) Stats(req StatsRequest) error { // StopStats removes the stats purpose and forgets the statsId. Turning it on // again draws a new one. func (l *Link) StopStats(by string) error { + l.mu.Lock() + if l.grant != nil && !l.grant.Revoked && l.grant.Policy == cloudwire.CloudTermsPolicy { + l.mu.Unlock() + return ErrCloudNeedsStatistics + } + only := l.grant != nil && l.grant.statisticsOnly() + l.mu.Unlock() + if only { + if err := l.End(by); err != nil { + return err + } + return l.opts.KV.SaveConfig(statsDecisionKey, cloudwire.StatsPolicy) + } l.mu.Lock() defer l.mu.Unlock() + if err := l.opts.KV.SaveConfig(statsDecisionKey, cloudwire.StatsPolicy); err != nil { + return err + } if l.grant == nil || l.grant.Revoked || (len(l.grant.StatsID) == 0 && !slices.Contains(l.grant.Purposes, protocol.CloudPurposeStats)) { return nil @@ -820,6 +883,10 @@ func (l *Link) clockSynced() bool { // Status is what GET /api/cloud shows. type Status struct { + CloudPolicy string `json:"cloudPolicy"` + CloudTermsAccepted bool `json:"cloudTermsAccepted"` + StatisticsOnly bool `json:"statisticsOnly"` + StatisticsDecisionRequired bool `json:"statisticsDecisionRequired"` // State is the readable copy's: off, on or revoking. State string `json:"state"` StatisticsPolicy string `json:"statisticsPolicy"` @@ -877,6 +944,14 @@ func (l *Link) Status() Status { Link: "off", TunBytesLast24h: l.tunBudget.total(l.uptimeMs()), TunBudgetBytes: tunBudgetBytes, TunDropped: l.tunDropped, } + s.CloudPolicy = cloudwire.CloudTermsPolicy + decision, _ := l.opts.KV.LoadConfig(statsDecisionKey) + s.StatisticsDecisionRequired = decision != cloudwire.StatsPolicy + if l.grant != nil && !l.grant.Revoked && l.grant.StatsPolicy == cloudwire.StatsPolicy && len(l.grant.StatsID) == 16 { + s.StatisticsDecisionRequired = false + } + s.StatisticsOnly = l.grant.statisticsOnly() + s.CloudTermsAccepted = l.grant != nil && !l.grant.Revoked && l.grant.Policy == cloudwire.CloudTermsPolicy s.StatisticsPolicy = cloudwire.StatsPolicy s.StatisticsReady = l.connected && l.serviceStatsPolicy == cloudwire.StatsPolicy if g := l.grant; g != nil { @@ -895,6 +970,9 @@ func (l *Link) Status() Status { if g := l.grant; g != nil { s.State, s.Scopes, s.Purposes = "on", g.Scopes, g.Purposes s.GrantedAtMs, s.GrantedBy, s.Policy = g.AtMs, g.By, g.Policy + if g.statisticsOnly() { + s.State = "statistics" + } if g.Revoked { s.State = "revoking" } @@ -907,7 +985,7 @@ func (l *Link) Status() Status { func (l *Link) Home() string { l.mu.Lock() defer l.mu.Unlock() - if l.link == nil || l.link.Ending { + if l.link == nil || l.link.Ending || l.disabled || l.grant.statisticsOnly() { return "" } return l.home @@ -919,7 +997,11 @@ func (l *Link) Available() bool { return len(l.opts.ServiceKeys) > 0 } // TakesTun reports whether the link carries the phones' own sessions as tun, // so a phone that knows the home id can reach the box without the relay. -func (l *Link) TakesTun() bool { return l.Available() && l.opts.Tunnel != nil } +func (l *Link) TakesTun() bool { + l.mu.Lock() + defer l.mu.Unlock() + return l.Available() && l.opts.Tunnel != nil && !l.disabled && !l.grant.statisticsOnly() +} // SessionInfo is what a phone needs, over its Noise session with the box, to // reach this home through FTW Cloud: the home id, the key that signs the @@ -930,6 +1012,9 @@ func (l *Link) TakesTun() bool { return l.Available() && l.opts.Tunnel != nil } // The tables name every driver and loadpoint the box knows now. One that // appears later has no name on a phone until its next session. func (l *Link) SessionInfo() *protocol.CloudSession { + if l.Home() == "" { + return nil + } snap := l.current() if snap.link == nil || snap.link.Ending || len(snap.pk) != cloudwire.PublicKeyBytes { return nil @@ -1000,11 +1085,16 @@ func (l *Link) current() grantSnapshot { } // copyOn reports whether the readable copy is on and not being withdrawn. -func (snap grantSnapshot) copyOn() bool { return snap.g != nil && !snap.g.Revoked } +func (g *grantRecord) statisticsOnly() bool { + return g != nil && !g.Revoked && slices.Contains(g.Purposes, protocol.CloudPurposeStats) && !slices.Contains(g.Purposes, protocol.CloudPurposeServe) +} +func (snap grantSnapshot) copyOn() bool { + return snap.g != nil && !snap.g.Revoked && slices.Contains(snap.g.Purposes, protocol.CloudPurposeServe) +} // grantID is the grant hello names: the readable copy's, or none. func (snap grantSnapshot) grantID() []byte { - if !snap.copyOn() { + if snap.g == nil || snap.g.Revoked { return nil } return snap.g.ID diff --git a/go/internal/cloudlink/sender.go b/go/internal/cloudlink/sender.go index e4124bf86..93bc57789 100644 --- a/go/internal/cloudlink/sender.go +++ b/go/internal/cloudlink/sender.go @@ -121,6 +121,9 @@ func (w *writer) run(ctx context.Context, events <-chan cloudwire.Envelope) erro var slot <-chan time.Time for { + if err := l.startStatisticsClock(); err != nil { + return err + } snap := l.current() if snap.link == nil || snap.home != connHome { return errors.New("cloudlink: the link changed under the connection") @@ -692,34 +695,36 @@ func (l *Link) rosterNumber(snap grantSnapshot) (uint64, cloudwire.Roster) { case g.Revoked: r.Grant.ID = g.ID default: - r.Grant = cloudwire.Grant{ID: g.ID, Scopes: g.Scopes, Purposes: g.Purposes, Controller: g.Controller, StatsID: g.StatsID, StatsPolicy: g.StatsPolicy, StatsSinceMs: g.StatsAtMs} - } - for _, d := range l.opts.Roster.Devices() { - var scopes []string - for _, s := range protocol.CloudScopes { - if slices.Contains(d.Scopes, s) { - scopes = append(scopes, s) + r.Grant = cloudwire.Grant{ID: g.ID, Scopes: g.Scopes, Purposes: g.Purposes, Controller: g.Controller, TermsPolicy: g.Policy, StatsID: g.StatsID, StatsPolicy: g.StatsPolicy, StatsSinceMs: g.StatsAtMs} + } + if !snap.g.statisticsOnly() { + for _, d := range l.opts.Roster.Devices() { + var scopes []string + for _, s := range protocol.CloudScopes { + if slices.Contains(d.Scopes, s) { + scopes = append(scopes, s) + } + } + r.Devices = append(r.Devices, cloudwire.Device{ID: d.ID, Key: d.Key, Role: d.Role, Scopes: scopes, + Epoch: d.Epoch, Tun: d.Tun, Agent: d.Agent}) + } + sort.Slice(r.Devices, func(a, b int) bool { return r.Devices[a].ID < r.Devices[b].ID }) + r.Noise = l.opts.Roster.Noise() + if l.opts.WebPush != nil { + var err error + r.WebPush, err = l.opts.WebPush.WebPushRows() + if err != nil { + l.log.Warn("could not prepare browser offline notices") + } + sort.Slice(r.WebPush, func(a, b int) bool { return r.WebPush[a].ID < r.WebPush[b].ID }) + } + if code, expires, ok := l.opts.Roster.Pending(); ok && snap.home != "" { + if left := time.Until(expires); left > 0 { + r.Pending = append(r.Pending, cloudwire.Pending{ + Hash: cloudwire.PairingHash(snap.home, code), + TTLS: int64((left + time.Second - 1) / time.Second), + }) } - } - r.Devices = append(r.Devices, cloudwire.Device{ID: d.ID, Key: d.Key, Role: d.Role, Scopes: scopes, - Epoch: d.Epoch, Tun: d.Tun, Agent: d.Agent}) - } - sort.Slice(r.Devices, func(a, b int) bool { return r.Devices[a].ID < r.Devices[b].ID }) - r.Noise = l.opts.Roster.Noise() - if l.opts.WebPush != nil { - var err error - r.WebPush, err = l.opts.WebPush.WebPushRows() - if err != nil { - l.log.Warn("could not prepare browser offline notices") - } - sort.Slice(r.WebPush, func(a, b int) bool { return r.WebPush[a].ID < r.WebPush[b].ID }) - } - if code, expires, ok := l.opts.Roster.Pending(); ok && snap.home != "" { - if left := time.Until(expires); left > 0 { - r.Pending = append(r.Pending, cloudwire.Pending{ - Hash: cloudwire.PairingHash(snap.home, code), - TTLS: int64((left + time.Second - 1) / time.Second), - }) } } // The number follows what the roster says, not how long a code has @@ -828,7 +833,7 @@ func (l *Link) Preview() Preview { func (l *Link) sample() []sentFrame { snap := l.current() if snap.g == nil { - snap.g = &grantRecord{GrantKey: make([]byte, cloudwire.GrantKeyBytes), Scopes: protocol.CloudScopes} + snap.g = &grantRecord{GrantKey: make([]byte, cloudwire.GrantKeyBytes), Scopes: protocol.CloudScopes, Purposes: []string{protocol.CloudPurposeServe}} } up := l.uptimeMs() var out []sentFrame diff --git a/go/protocol/cloudwire/allowlist.go b/go/protocol/cloudwire/allowlist.go index 3474f8f05..66befad80 100644 --- a/go/protocol/cloudwire/allowlist.go +++ b/go/protocol/cloudwire/allowlist.go @@ -48,7 +48,7 @@ var Fields = map[string][]string{ "Agent": {"scopes", "ops", "caps", "expiresAtMs", "principal"}, "Caps": {"holdS", "maxW", "perMin"}, "Principal": {"client", "clientId", "ticket"}, - "Grant": {"id", "scopes", "purposes", "controller", "statsId", "statsPolicy", "statsSinceMs"}, + "Grant": {"id", "scopes", "purposes", "controller", "termsPolicy", "statsId", "statsPolicy", "statsSinceMs"}, "Pending": {"hash", "ttlS"}, "WebPushRow": {"id", "dev", "epoch", "endpoint", "ct", "auth"}, diff --git a/go/protocol/cloudwire/messages.go b/go/protocol/cloudwire/messages.go index 196b1bc8f..80f403ba0 100644 --- a/go/protocol/cloudwire/messages.go +++ b/go/protocol/cloudwire/messages.go @@ -394,6 +394,7 @@ type Pending struct { // id, scopes or purposes. While a revoke waits for its receipt it keeps the // id and has neither scopes nor purposes. type Grant struct { + TermsPolicy string `cbor:"termsPolicy,omitempty"` ID []byte `cbor:"id"` Scopes []string `cbor:"scopes"` Purposes []string `cbor:"purposes"` diff --git a/go/protocol/cloudwire/stats.go b/go/protocol/cloudwire/stats.go index 651d09d4d..03daebf56 100644 --- a/go/protocol/cloudwire/stats.go +++ b/go/protocol/cloudwire/stats.go @@ -2,9 +2,11 @@ package cloudwire import "slices" -// StatsPolicy requires fresh consent: the earlier hourly-energy policy did -// not include configured equipment capacity or power limits. -const StatsPolicy = "ftw-project-stats-2026-10-v1" +// StatsPolicy names the scope included in the current Cloud terms, or +// accepted through the separate local statistics question. Earlier choices +// never accept this policy. Collection still requires service approval. +const StatsPolicy = "ftw-project-stats-2026-10-v2" +const CloudTermsPolicy = "ftw-cloud-2026-10-v2" const MsgStats = "stats" const StatsHourMS = int64(3_600_000) const StatsMinimumHomes = 20 diff --git a/go/protocol/cloudwire/testdata/cloud-quic-vectors.json b/go/protocol/cloudwire/testdata/cloud-quic-vectors.json index ce4e763bc..6ede8d105 100644 --- a/go/protocol/cloudwire/testdata/cloud-quic-vectors.json +++ b/go/protocol/cloudwire/testdata/cloud-quic-vectors.json @@ -250,17 +250,22 @@ }, { "name": "statistics service ready", - "envelope": "a36162588da563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d763161746868656c6c6f5f6f6b637369675840629d7e428ae3b7ed50f3e49a27f7281b2779d64373683bf3a6ed0037e6db4ce2c03211770b7d9649b1f2a957a040878174ae57c9f291ba57d3f0c0fc95476f33", + "envelope": "a36162588da563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d763261746868656c6c6f5f6f6b637369675840a838159a22c63e291c08d701b0ce0f7c0fd155c45fdcfa9b50e1253bf2fa312917ecd76379072c9bbaf3d62d1bbf95b93879177765f4b0adb73dd7ef0e2dd91e", "datagram": false }, { "name": "statistics closed hour", - "envelope": "a3616258b8a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a0001443861746573746174736373696758403363381c7f7d5291f1e2f27423b26c7d2a7b9f3cc81dbd094c0bb94af12ae73db6b83769403a3f4ac673975767468d7d03540c2f9d45ac345f2f7bb13c0d92ec", + "envelope": "a3616258b8a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a0001443861746573746174736373696758406147f18ed9f5927d3abf1599168322a021194369ad42d8b199abbd894040cc7e87c4fcff89e551c210863d2ed4f2bdeaddd6304f5fdf00188a5232b3246c23d6", + "datagram": false + }, + { + "name": "statistics only roster", + "envelope": "a36162590133aa637365711764626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a862696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573816573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326b7465726d73506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d805906764657669636573806770656e64696e678068757074696d654d731a00014ff069726f7374657253657107617466726f7374657263736967584018851c1cb544043d4e18b2609e5b2a3f6c321bd1ba04a136b2af103992876c90bc33c8d584840917a2d7a1fb0427b22df6deae7fad456c664e8e33f95654b4f7", "datagram": false }, { "name": "roster statistics consent", - "envelope": "a3616259010faa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a762696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106617466726f73746572637369675840479ea1acf685d509e1066c47d03668dde1ee42f5721afcd26a252fb1c1fe230436ff071cc1748b26547422e86491014b4f3fd4947d27af0d56b3de0f7857155c", + "envelope": "a36162590130aa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a862696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326b7465726d73506f6c696379746674772d636c6f75642d323032362d31302d76326c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106617466726f73746572637369675840a043311a41972afcec17e4aa0eae3251e985b503677b2802e5b520cd73acd472050cfe6f660ae85058d0dea532ff832759929c048f3738e4588794447552ded7", "datagram": false } ], diff --git a/go/protocol/cloudwire/testdata/cloud-vectors.json b/go/protocol/cloudwire/testdata/cloud-vectors.json index 7b6930d34..e74fdcae9 100644 --- a/go/protocol/cloudwire/testdata/cloud-vectors.json +++ b/go/protocol/cloudwire/testdata/cloud-vectors.json @@ -326,31 +326,41 @@ "name": "statistics service ready", "signer": "service", "t": "hello_ok", - "body": "a563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d7631", - "signedInputSha256": "6777900dc405724488293496cd480b243af7dd7852170a1db355d136e4269ab8", - "sig": "629d7e428ae3b7ed50f3e49a27f7281b2779d64373683bf3a6ed0037e6db4ce2c03211770b7d9649b1f2a957a040878174ae57c9f291ba57d3f0c0fc95476f33", - "envelope": "a36162588da563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d763161746868656c6c6f5f6f6b637369675840629d7e428ae3b7ed50f3e49a27f7281b2779d64373683bf3a6ed0037e6db4ce2c03211770b7d9649b1f2a957a040878174ae57c9f291ba57d3f0c0fc95476f33", - "frame": "0101000000e3a36162588da563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d763161746868656c6c6f5f6f6b637369675840629d7e428ae3b7ed50f3e49a27f7281b2779d64373683bf3a6ed0037e6db4ce2c03211770b7d9649b1f2a957a040878174ae57c9f291ba57d3f0c0fc95476f33" + "body": "a563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d7632", + "signedInputSha256": "03f2f4413566430718560c06ef743aba96dd6e268f4df9e5fcd6105505729284", + "sig": "a838159a22c63e291c08d701b0ce0f7c0fd155c45fdcfa9b50e1253bf2fa312917ecd76379072c9bbaf3d62d1bbf95b93879177765f4b0adb73dd7ef0e2dd91e", + "envelope": "a36162588da563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d763261746868656c6c6f5f6f6b637369675840a838159a22c63e291c08d701b0ce0f7c0fd155c45fdcfa9b50e1253bf2fa312917ecd76379072c9bbaf3d62d1bbf95b93879177765f4b0adb73dd7ef0e2dd91e", + "frame": "0101000000e3a36162588da563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d763261746868656c6c6f5f6f6b637369675840a838159a22c63e291c08d701b0ce0f7c0fd155c45fdcfa9b50e1253bf2fa312917ecd76379072c9bbaf3d62d1bbf95b93879177765f4b0adb73dd7ef0e2dd91e" }, { "name": "statistics closed hour", "signer": "box", "t": "stats", - "body": "a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a00014438", - "signedInputSha256": "0cd18bce4afac18cf16427e52e734fb06151f01adfedec4f58cae328a31e7127", - "sig": "3363381c7f7d5291f1e2f27423b26c7d2a7b9f3cc81dbd094c0bb94af12ae73db6b83769403a3f4ac673975767468d7d03540c2f9d45ac345f2f7bb13c0d92ec", - "envelope": "a3616258b8a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a0001443861746573746174736373696758403363381c7f7d5291f1e2f27423b26c7d2a7b9f3cc81dbd094c0bb94af12ae73db6b83769403a3f4ac673975767468d7d03540c2f9d45ac345f2f7bb13c0d92ec", - "frame": "01010000010ba3616258b8a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a0001443861746573746174736373696758403363381c7f7d5291f1e2f27423b26c7d2a7b9f3cc81dbd094c0bb94af12ae73db6b83769403a3f4ac673975767468d7d03540c2f9d45ac345f2f7bb13c0d92ec" + "body": "a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a00014438", + "signedInputSha256": "401bd24650ca3cb49fa76096e3b40d310c6e31d3952bf312e005b88937f81643", + "sig": "6147f18ed9f5927d3abf1599168322a021194369ad42d8b199abbd894040cc7e87c4fcff89e551c210863d2ed4f2bdeaddd6304f5fdf00188a5232b3246c23d6", + "envelope": "a3616258b8a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a0001443861746573746174736373696758406147f18ed9f5927d3abf1599168322a021194369ad42d8b199abbd894040cc7e87c4fcff89e551c210863d2ed4f2bdeaddd6304f5fdf00188a5232b3246c23d6", + "frame": "01010000010ba3616258b8a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a0001443861746573746174736373696758406147f18ed9f5927d3abf1599168322a021194369ad42d8b199abbd894040cc7e87c4fcff89e551c210863d2ed4f2bdeaddd6304f5fdf00188a5232b3246c23d6" + }, + { + "name": "statistics only roster", + "signer": "box", + "t": "roster", + "body": "aa637365711764626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a862696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573816573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326b7465726d73506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d805906764657669636573806770656e64696e678068757074696d654d731a00014ff069726f7374657253657107", + "signedInputSha256": "35b78f290505d453192680ada46310c9f2119b4f7dcdb3ca7d65d540fcc77075", + "sig": "18851c1cb544043d4e18b2609e5b2a3f6c321bd1ba04a136b2af103992876c90bc33c8d584840917a2d7a1fb0427b22df6deae7fad456c664e8e33f95654b4f7", + "envelope": "a36162590133aa637365711764626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a862696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573816573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326b7465726d73506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d805906764657669636573806770656e64696e678068757074696d654d731a00014ff069726f7374657253657107617466726f7374657263736967584018851c1cb544043d4e18b2609e5b2a3f6c321bd1ba04a136b2af103992876c90bc33c8d584840917a2d7a1fb0427b22df6deae7fad456c664e8e33f95654b4f7", + "frame": "010100000188a36162590133aa637365711764626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a862696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573816573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326b7465726d73506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d805906764657669636573806770656e64696e678068757074696d654d731a00014ff069726f7374657253657107617466726f7374657263736967584018851c1cb544043d4e18b2609e5b2a3f6c321bd1ba04a136b2af103992876c90bc33c8d584840917a2d7a1fb0427b22df6deae7fad456c664e8e33f95654b4f7" }, { "name": "roster statistics consent", "signer": "box", "t": "roster", - "body": "aa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a762696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106", - "signedInputSha256": "cb06814081a6ab56c1265fde264a4240e12fb3233cc410673a3399b0afdca884", - "sig": "479ea1acf685d509e1066c47d03668dde1ee42f5721afcd26a252fb1c1fe230436ff071cc1748b26547422e86491014b4f3fd4947d27af0d56b3de0f7857155c", - "envelope": "a3616259010faa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a762696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106617466726f73746572637369675840479ea1acf685d509e1066c47d03668dde1ee42f5721afcd26a252fb1c1fe230436ff071cc1748b26547422e86491014b4f3fd4947d27af0d56b3de0f7857155c", - "frame": "010100000164a3616259010faa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a762696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106617466726f73746572637369675840479ea1acf685d509e1066c47d03668dde1ee42f5721afcd26a252fb1c1fe230436ff071cc1748b26547422e86491014b4f3fd4947d27af0d56b3de0f7857155c" + "body": "aa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a862696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326b7465726d73506f6c696379746674772d636c6f75642d323032362d31302d76326c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106", + "signedInputSha256": "af8b572e3c64efa52a4c52514e9e1d31c3906f19376ab5ddc9c8c61c6c5c0312", + "sig": "a043311a41972afcec17e4aa0eae3251e985b503677b2802e5b520cd73acd472050cfe6f660ae85058d0dea532ff832759929c048f3738e4588794447552ded7", + "envelope": "a36162590130aa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a862696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326b7465726d73506f6c696379746674772d636c6f75642d323032362d31302d76326c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106617466726f73746572637369675840a043311a41972afcec17e4aa0eae3251e985b503677b2802e5b520cd73acd472050cfe6f660ae85058d0dea532ff832759929c048f3738e4588794447552ded7", + "frame": "010100000185a36162590130aa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a862696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326b7465726d73506f6c696379746674772d636c6f75642d323032362d31302d76326c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106617466726f73746572637369675840a043311a41972afcec17e4aa0eae3251e985b503677b2802e5b520cd73acd472050cfe6f660ae85058d0dea532ff832759929c048f3738e4588794447552ded7" } ] } diff --git a/go/protocol/cloudwire/vectors_test.go b/go/protocol/cloudwire/vectors_test.go index 67ffdd57b..56876c0e2 100644 --- a/go/protocol/cloudwire/vectors_test.go +++ b/go/protocol/cloudwire/vectors_test.go @@ -266,7 +266,8 @@ func vectorBodies(grantKey []byte, boxPK []byte) []struct { {"roster browser notice", "box", MsgRoster, Roster{Header: h(19, 82_000), RosterSeq: 5, TTLS: 86400, Devices: []Device{{ID: "browser", Key: bytes.Repeat([]byte{0x55}, 32), Role: "owner", Scopes: []string{protocol.ScopeEventsRead}, Epoch: 1}}, WebPush: []WebPushRow{{ID: "opaque-subscription", Device: "browser", Epoch: 1, Endpoint: "https://push.example/opaque", CT: bytes.Repeat([]byte{0x66}, 128), Auth: "vapid box-signed"}}}}, {"statistics service ready", "service", MsgHelloOK, HelloOK{BoxNonce: bytes.Repeat([]byte{0x33}, NonceBytes), EKM: bytes.Repeat([]byte{0x22}, EKMBytes), Have: map[string]string{}, RosterSeq: 4, StatsPolicy: StatsPolicy}}, {"statistics closed hour", "box", MsgStats, Stats{Header: h(20, 83_000), StatsID: bytes.Repeat([]byte{0x88}, 16), Policy: StatsPolicy, HourMS: wall/StatsHourMS*StatsHourMS - StatsHourMS, Metrics: map[string]int64{"flexible_power_w": 11000, "pv_generation_wh": 120}}}, - {"roster statistics consent", "box", MsgRoster, Roster{Header: h(21, 84_000), RosterSeq: 6, TTLS: 86400, Grant: Grant{ID: bytes.Repeat([]byte{0x44}, GrantIDBytes), Purposes: []string{protocol.CloudPurposeServe, protocol.CloudPurposeStats}, Controller: ControllerSourceful, StatsID: bytes.Repeat([]byte{0x88}, 16), StatsPolicy: StatsPolicy, StatsSinceMs: wall - 2*StatsHourMS}}}, + {"statistics only roster", "box", MsgRoster, Roster{Header: h(23, 86_000), RosterSeq: 7, TTLS: 86400, Grant: Grant{ID: bytes.Repeat([]byte{0x44}, GrantIDBytes), Scopes: []string{}, Purposes: []string{protocol.CloudPurposeStats}, Controller: ControllerSourceful, TermsPolicy: StatsPolicy, StatsID: bytes.Repeat([]byte{0x88}, 16), StatsPolicy: StatsPolicy, StatsSinceMs: wall - 2*StatsHourMS}}}, + {"roster statistics consent", "box", MsgRoster, Roster{Header: h(21, 84_000), RosterSeq: 6, TTLS: 86400, Grant: Grant{TermsPolicy: CloudTermsPolicy, ID: bytes.Repeat([]byte{0x44}, GrantIDBytes), Purposes: []string{protocol.CloudPurposeServe, protocol.CloudPurposeStats}, Controller: ControllerSourceful, StatsID: bytes.Repeat([]byte{0x88}, 16), StatsPolicy: StatsPolicy, StatsSinceMs: wall - 2*StatsHourMS}}}, } } diff --git a/web/app-link-tab.test.mjs b/web/app-link-tab.test.mjs index 8207506ac..86085d255 100644 --- a/web/app-link-tab.test.mjs +++ b/web/app-link-tab.test.mjs @@ -96,7 +96,7 @@ describe("the app tab", () => { const config = { app_link: { enabled: false } }; const html = appWithFleet.render({ config }); - assert.match(html, /Fleet statistics<\/legend>/); + assert.match(html, /Anonymous daily report<\/legend>/); assert.match(html, /data-checkbox-path="fleet_ping\.enabled"/); assert.equal(config.fleet_ping.enabled, true, "fleet sharing should still default on"); assert.doesNotMatch(html, /id="app-link-enabled"[^>]*checked/); diff --git a/web/index.html b/web/index.html index a954343f1..d32213249 100644 --- a/web/index.html +++ b/web/index.html @@ -989,7 +989,8 @@

Price bars (top of the chart)

- + + @@ -1002,9 +1003,9 @@

Price bars (top of the chart)

- + - + diff --git a/web/lan-auth.js b/web/lan-auth.js index c2569e694..14731bf5d 100644 --- a/web/lan-auth.js +++ b/web/lan-auth.js @@ -123,6 +123,7 @@ return r.json().then(function (body) { if (!r.ok) throw new Error((body && body.error) || "login failed"); finish(true); + if (typeof window.dispatchEvent === "function" && typeof window.Event === "function") window.dispatchEvent(new window.Event("ftw-owner-ready")); }); }).catch(function (err) { if (errEl) errEl.textContent = err.message || "login failed"; diff --git a/web/settings/tabs/app.js b/web/settings/tabs/app.js index eb0e95766..668236a00 100644 --- a/web/settings/tabs/app.js +++ b/web/settings/tabs/app.js @@ -362,6 +362,12 @@ // a pairing code still on screen, and the reverse. Every button here goes // through this, so the rule is not something a caller has to remember. function requestCode(opts, button, busyText, doneText) { + if(window.FTWStatistics && !window.FTWStatistics.cloudAccepted()) { + setStatus("Accept the Cloud terms above before pairing a phone. Statistics are included with Cloud."); + window.FTWStatistics.refresh(); + return; + } + var slot = document.getElementById("app-link-slot"); if (!button || !slot) return; @@ -439,9 +445,12 @@ if (toggle) toggle.addEventListener("change", function () { refreshStatus(ctx); }); refreshStatus(ctx); + var statistics=document.querySelector("[data-project-statistics-settings]"); + if(statistics && window.FTWStatistics)window.FTWStatistics.mount(statistics); }, 0); return ( + '
' + "
The FTW app" + '

This is not the Sourceful (Zap) app. The FTW app at ' + "app.ftw.energy talks to this box when you are away. On the same Wi-Fi you " + diff --git a/web/settings/tabs/fleet.js b/web/settings/tabs/fleet.js index ac486180c..4ed970d92 100644 --- a/web/settings/tabs/fleet.js +++ b/web/settings/tabs/fleet.js @@ -80,7 +80,7 @@ function whereLine(body) { var endpoint = (body && body.endpoint) || "nowhere"; var handling = body && body.ftw_relay - ? " The FTW relay adds the report to that day's totals, then drops it and does not save the source address. These are reports, not unique boxes." + ? " FTW Cloud adds the report to that day's totals, then drops it and does not save the source address. These are reports, not unique boxes." : " This box uses a custom collector. FTW cannot say what that service stores or whether it counts unique boxes."; if (body && body.enabled) { return "Sent to " + endpoint + @@ -181,7 +181,8 @@ setTimeout(refresh, 0); return ( - "

Fleet statistics" + + "
Anonymous daily report" + + '

The project statistics choice above controls whether this box contributes. This additional daily report describes versions and integrations. It does not count unique boxes.

' + '

When sharing is on, this box sends six things once a day: its ' + "FTW version, its release channel, the kinds of device it talks to, " + "roughly how much battery, its price zone, and roughly how old the " + From 94d4a3859924e607d33866923865932a2d1f13e4 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 16:49:57 +0200 Subject: [PATCH 2/2] test(web): cover explicit statistics choices and include the prompt --- web/project-statistics.js | 78 +++++++++++++++++++++++++++++++++ web/project-statistics.test.mjs | 61 ++++++++++++++++++++++++++ web/settings/tabs/app.js | 6 ++- 3 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 web/project-statistics.js create mode 100644 web/project-statistics.test.mjs diff --git a/web/project-statistics.js b/web/project-statistics.js new file mode 100644 index 000000000..04f19f88f --- /dev/null +++ b/web/project-statistics.js @@ -0,0 +1,78 @@ +// One owner choice: statistics come with Cloud; local use asks first. +(function (window, document) { + "use strict"; + var read = window.fetch.bind(window); + var details = "Recorded hourly energy, configured battery capacity, battery and charger power limits, and solar capacity help improve FTW and publish group totals. Values are rounded to 10 Wh or W. No names, addresses or device ids."; + var privacy = "Sourceful links reports to this box for deletion. They are not anonymous and stay for 13 months. Public values need at least 20 homes, with no home above 30% of a sum. Turning sharing off deletes unpublished reports when the box reaches the collector. Published totals remain."; + var status, policy, justAnswered=false; + function node(tag, text, parent) { + var el = document.createElement(tag); + if (text) el.textContent = text; + if (parent) parent.appendChild(el); + return el; + } + function say(parent, text) { var p=node("p",text,parent);p.className="hint";return p; } + function button(parent,text,run,primary) { + var b=node("button",text,parent);b.type="button";b.className=primary?"btn-send":"btn-ghost"; + b.addEventListener("click",function(){ + b.disabled=true; + Promise.resolve().then(run).then(refresh).catch(function(e){say(parent,e.message||"The choice could not be saved. Check the box status.");}).finally(function(){b.disabled=false;}); + }); + } + function write(method,path,body) { + return window.fetch(path,{method:method,headers:{"Content-Type":"application/json"},body:body?JSON.stringify(body):undefined}).then(function(r){return r.json().then(function(v){if(!r.ok)throw Error(v.error||"The box refused the choice.");justAnswered=true;return v;});}); + } + function paint(host,settings) { + host.textContent=""; + if (!status || !policy || (!settings && !status.statisticsDecisionRequired && !justAnswered)) {host.hidden=true;return;} + host.hidden=false; + if(!settings && !status.statisticsDecisionRequired && justAnswered) { + say(host,status.link==="ending"?"Stopping Cloud and deleting its stored copy. The box continues locally.":status.statisticsActive?"Statistics choice saved. Hourly reports wait for approval; change this in Settings → FTW app.":"Statistics sharing is off. The box continues locally.");return; + } + var panel=node("fieldset",null,host); + var cloud=status.link==="on" && !status.statisticsOnly; + node("legend",cloud?"FTW Cloud includes statistics":"Contribute to FTW statistics?",panel); + if(cloud) say(panel,"Everyone using FTW Cloud contributes to project statistics. Accept the updated Cloud terms to use the app and remote access. To stop contributing, turn FTW Cloud off and use the box locally."); + else say(panel,"For local FTW use, sharing is your choice. Yes is the suggested choice. Nothing starts until you answer, and saying no leaves local operation working. Statistics-only sharing gives no app, phone or assistant remote access."); + say(panel,details); + say(panel,privacy); + var link=node("a","Privacy and Cloud terms",panel);link.href="https://ftw.sourceful.energy/privacy#statistics";link.target="_blank";link.rel="noopener"; + if(!policy.available) say(panel,"Hourly collection is waiting for privacy review and activation. Your choice can be saved now; no hourly report leaves before that approval."); + var actions=node("div",null,panel);actions.className="app-link-actions"; + if(status.link==="ending") {say(panel,"Cloud is stopping and deleting its stored copy. Refresh after the box receives the deletion receipt.");return;} + if(cloud) { + if(status.cloudTermsAccepted) say(panel,"The current Cloud terms include statistics for this box."); + else button(actions,"Accept Cloud terms",function(){return write("POST","/api/cloud/grant",{scopes:policy.cloud_scopes,policy:policy.cloud_policy});},true); + button(actions,"Turn FTW Cloud off",function(){ + if(!window.confirm("Turn FTW Cloud off? The app and assistants lose remote access. The box keeps working locally. You may need to pair phones again if you turn Cloud back on."))return; + return write("DELETE","/api/cloud"); + },false); + } else if(status.statisticsOnly) { + say(panel,"Local statistics sharing is on. Remote Cloud functions are off."); + button(actions,"Stop sharing statistics",function(){return write("DELETE","/api/cloud/stats");},false); + } else { + button(actions,"Yes, contribute",function(){return write("POST","/api/cloud/stats",{policy:policy.policy});},true); + if(status.statisticsDecisionRequired) button(actions,"No, keep it local",function(){return write("DELETE","/api/cloud/stats");},false); + else say(panel,"Local statistics sharing is off."); + } + if(settings && !cloud) { + var more=node("details",null,panel);node("summary","Use FTW Cloud and the app",more); + say(more,"FTW Cloud adds app and assistant remote access and a readable state copy. Statistics are included for every Cloud user. Read the information above before accepting."); + button(more,"Accept Cloud terms and turn it on",function(){return write("POST","/api/cloud/grant",{scopes:policy.cloud_scopes,policy:policy.cloud_policy});},true); + } + } + function refresh() { + return Promise.all([read("/api/cloud"),read("/api/cloud/stats-policy")]).then(function(rs){if(rs.some(function(r){return !r.ok;}))return null;return Promise.all(rs.map(function(r){return r.json();}));}).then(function(v){ + if(!v)return; + status=v[0];policy=v[1]; + if(!policy.cloud_includes_statistics || !policy.standalone)return; + var prompt=document.getElementById("project-statistics-prompt"); + if(!prompt){prompt=node("section");prompt.id="project-statistics-prompt";prompt.setAttribute("aria-label","Project statistics choice");var main=document.querySelector("main")||document.body;main.insertBefore(prompt,main.firstChild);} + paint(prompt,false); + document.querySelectorAll("[data-project-statistics-settings]").forEach(function(host){paint(host,true);}); + }).catch(function(){}); + } + window.FTWStatistics={refresh:refresh,cloudAccepted:function(){return !!(status && status.cloudTermsAccepted);},mount:function(host){if(status&&policy)paint(host,true);refresh();}}; + window.addEventListener("ftw-owner-ready",refresh); + if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",refresh);else refresh(); +})(window,document); diff --git a/web/project-statistics.test.mjs b/web/project-statistics.test.mjs new file mode 100644 index 000000000..1bf529fd9 --- /dev/null +++ b/web/project-statistics.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {test} from 'node:test'; +import vm from 'node:vm'; +import {setImmediate} from 'node:timers/promises'; + +const source=readFileSync(new URL('./project-statistics.js',import.meta.url),'utf8'); +function page(cloud=false,authorised=true) { + class Element { + constructor(tag){this.tag=tag;this.children=[];this.events={};this.ownText='';} + appendChild(e){this.children.push(e);return e;} + insertBefore(e){this.children.unshift(e);} + set textContent(v){this.ownText=v;this.children=[];} + get textContent(){return this.ownText+this.children.map(e=>e.textContent).join(' ');} + setAttribute(){} + addEventListener(k,f){this.events[k]=f;} + } + const main=new Element('main'); + const all=()=>{const out=[];const visit=e=>{out.push(e);e.children.forEach(visit);};visit(main);return out;}; + let state={link:cloud?'on':'off',statisticsDecisionRequired:true,statisticsOnly:false}; + const writes=[]; + const policy={policy:'stats-current',cloud_policy:'cloud-current',cloud_scopes:['ftw.live.read'],available:false,cloud_includes_statistics:true,standalone:true}; + const window={addEventListener(){},confirm:()=>true,fetch:async(path,req)=>{ + if(req){writes.push({path,...req});state={...state,statisticsDecisionRequired:false,statisticsActive:req.method==='POST'};} + return {ok:authorised,json:async()=>path.endsWith('stats-policy')?policy:state}; + }}; + const document={readyState:'loading',addEventListener(){},createElement:t=>new Element(t),getElementById:id=>all().find(e=>e.id===id),querySelector:()=>main,querySelectorAll:()=>[]}; + vm.runInNewContext(source,{window,document,Promise,Error}); + return {window,writes,main,buttons:()=>all().filter(e=>e.tag==='button')}; +} + +test('local question sends nothing before a real Yes or No',async()=>{ + const p=page();await p.window.FTWStatistics.refresh(); + assert.equal(p.writes.length,0); + assert.deepEqual(p.buttons().map(e=>e.textContent),['Yes, contribute','No, keep it local']); + assert.equal(p.buttons()[0].className,'btn-send'); + p.buttons()[1].events.click();await setImmediate(); + assert.equal(p.writes.length,1);assert.equal(p.writes[0].method,'DELETE'); + assert.equal(p.writes[0].path,'/api/cloud/stats'); + assert.match(p.main.textContent,/sharing is off/); +}); + +test('local Yes grants only statistics, not Cloud access',async()=>{ + const p=page();await p.window.FTWStatistics.refresh();p.buttons()[0].events.click();await setImmediate(); + assert.equal(p.writes.length,1);assert.equal(p.writes[0].path,'/api/cloud/stats'); + assert.deepEqual(JSON.parse(p.writes[0].body),{policy:'stats-current'}); +}); + +test('Cloud has bundled terms and a whole-service off action',async()=>{ + const p=page(true);await p.window.FTWStatistics.refresh(); + assert.deepEqual(p.buttons().map(e=>e.textContent),['Accept Cloud terms','Turn FTW Cloud off']); + assert.match(p.main.textContent,/Everyone using FTW Cloud contributes/); + p.buttons()[0].events.click();await setImmediate(); + assert.equal(p.writes.length,1);assert.equal(p.writes[0].path,'/api/cloud/grant'); + assert.equal(JSON.parse(p.writes[0].body).policy,'cloud-current'); +}); + +test('an unauthenticated page neither asks nor writes on behalf of an owner',async()=>{ + const p=page(false,false);await p.window.FTWStatistics.refresh(); + assert.equal(p.buttons().length,0);assert.equal(p.writes.length,0); +}); diff --git a/web/settings/tabs/app.js b/web/settings/tabs/app.js index 668236a00..591af4e34 100644 --- a/web/settings/tabs/app.js +++ b/web/settings/tabs/app.js @@ -445,8 +445,10 @@ if (toggle) toggle.addEventListener("change", function () { refreshStatus(ctx); }); refreshStatus(ctx); - var statistics=document.querySelector("[data-project-statistics-settings]"); - if(statistics && window.FTWStatistics)window.FTWStatistics.mount(statistics); + if(window.FTWStatistics) { + var statistics=document.querySelector("[data-project-statistics-settings]"); + if(statistics)window.FTWStatistics.mount(statistics); + } }, 0); return (