diff --git a/cmd/server/config.go b/cmd/server/config.go index 2a97bd5a..40b46362 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -5,12 +5,8 @@ import ( "fmt" "log" "math" - "net/url" "os" "path/filepath" - "regexp" - "sort" - "strconv" "strings" "sync" "sync/atomic" @@ -344,220 +340,15 @@ type CustomizerConfig struct { DisabledTabs []string `json:"disabledTabs"` } -// PrivacyConfig holds the operator-side fields for the privacy-notice page -// (#/privacy). Every field is plain text by contract: the frontend renders -// all values as TEXT, never markup (public/privacy.js), so operator config -// can never inject HTML. -// -// The software ships NO default legal text and NO stand-in operator -// identity. A privacy notice is a statement the operator makes about their -// own deployment; CoreScope cannot know the controller, the purposes, the -// retention rules or the lawful basis, and inventing any of them would be -// worse than publishing nothing, because a fabricated notice looks -// authoritative while being wrong. Enabling the page therefore requires -// supplying every field below. Enabled with anything missing is a -// configuration error (see Validate) and the notice is simply not -// published. -// -// None of this is legal advice, and publishing the page does not by itself -// make a deployment compliant. +// PrivacyConfig is the entire privacy feature's configuration: one opt-in +// switch. The notice itself is a FIXED document in public/privacy.js, not +// operator text, so the published wording cannot drift from the notice that +// was signed off. Nothing configured here reaches the page as content -- +// there is deliberately nothing to configure but whether to publish it. type PrivacyConfig struct { - // Enabled gates the whole feature: the injected nav link, the - // #/privacy page content, and the privacy field in /api/config/client. - // Enabling it makes every field marked REQUIRED below mandatory. + // Enabled publishes the #/privacy page and the nav links pointing at + // it. Default false: a deployment opts in explicitly. Enabled bool `json:"enabled"` - - // ControllerName is the data controller. REQUIRED — there is - // deliberately no fallback: a notice that cannot name who is - // responsible for the processing does not identify a controller at - // all, and a generic stand-in would misrepresent that as an answer. - ControllerName string `json:"controllerName,omitempty"` - // ContactEmail is the address for privacy questions and requests. - // REQUIRED: every remedy the page describes routes through it. - ContactEmail string `json:"contactEmail,omitempty"` - // EffectiveDate identifies the version of the notice. REQUIRED. - // Free text so operators can use their own date format. - EffectiveDate string `json:"effectiveDate,omitempty"` - - // PurposesText describes what the deployment processes data FOR. - // REQUIRED. - PurposesText string `json:"purposesText,omitempty"` - // LegalBasisType is the structured lawful basis. REQUIRED. Structured - // rather than inferred: guessing the basis by pattern-matching free - // text would be both fragile and presumptuous, and the value decides - // whether LegitimateInterestsText is mandatory. - LegalBasisType string `json:"legalBasisType,omitempty"` - // LegalBasisText is the operator's own description of the basis. - // REQUIRED. - LegalBasisText string `json:"legalBasisText,omitempty"` - // LegitimateInterestsText describes the specific interests relied on. - // REQUIRED only when LegalBasisType is "legitimate_interests" — - // naming the basis without describing the interests is exactly the - // "merely write legitimate interest" failure the notice must avoid. - LegitimateInterestsText string `json:"legitimateInterestsText,omitempty"` - - // RetentionText states how long data is kept, per category where they - // differ. REQUIRED: CoreScope has several independent retention knobs - // (packets, metrics, nodes, client-RX) that do not map one-to-one onto - // the categories the notice describes, so it cannot derive a sentence. - RetentionText string `json:"retentionText,omitempty"` - // RecipientsText names who can receive the data beyond site/API - // visitors (hosting, monitoring, other processors). REQUIRED. - RecipientsText string `json:"recipientsText,omitempty"` - // DataSourcesText describes where the data comes from. REQUIRED. - DataSourcesText string `json:"dataSourcesText,omitempty"` - // ThirdPartyServicesText lists external services the browser contacts - // (map/tile providers, CDNs, monitoring) and what they receive. - // REQUIRED — the deployment's own choice of providers, which CoreScope - // cannot enumerate for it. - ThirdPartyServicesText string `json:"thirdPartyServicesText,omitempty"` - // InternationalTransfersText states any transfers and safeguards, or - // explicitly that none apply. REQUIRED (an explicit "none" is a valid - // answer; silence is not). - InternationalTransfersText string `json:"internationalTransfersText,omitempty"` - // BrowserStorageText describes what this site stores in the visitor's - // browser. REQUIRED. - BrowserStorageText string `json:"browserStorageText,omitempty"` - // ServerLogsText describes server/proxy access logs, purpose and - // retention. REQUIRED — these live in the operator's infrastructure - // (reverse proxy, host), not in CoreScope. - ServerLogsText string `json:"serverLogsText,omitempty"` - - // RightsRequestText explains how to exercise data-protection rights - // and how requests are handled. REQUIRED. - RightsRequestText string `json:"rightsRequestText,omitempty"` - // SupervisoryAuthorityName is the complaint body. REQUIRED. - SupervisoryAuthorityName string `json:"supervisoryAuthorityName,omitempty"` - // SupervisoryAuthorityURL links to it. REQUIRED, http/https only. - SupervisoryAuthorityURL string `json:"supervisoryAuthorityUrl,omitempty"` - // AutomatedDecisionMakingText states whether automated - // decision-making/profiling with legal or similarly significant - // effects is used. REQUIRED (a plain "not used" is a valid answer). - AutomatedDecisionMakingText string `json:"automatedDecisionMakingText,omitempty"` - - // DPOName / DPOContact are OPTIONAL: most community deployments have - // no data protection officer, and the page simply omits the section - // when they are blank. - DPOName string `json:"dpoName,omitempty"` - DPOContact string `json:"dpoContact,omitempty"` -} - -// privacyEmailRe is a deliberately conservative address check. It proves -// shape, never deliverability: exactly one "@", no whitespace or control -// characters (CR/LF would enable header injection in a mailto:), no -// characters that would start or forge a mailto query ("?", "&", quotes, -// angle brackets), and a dotted domain. Anything it rejects is a -// configuration mistake worth surfacing loudly rather than rendering. -var privacyEmailRe = regexp.MustCompile(`^[^\s<>"'&?/\\,;:@]+@[^\s<>"'&?/\\,;:@]+\.[A-Za-z]{2,}$`) - -// privacyLegalBasisTypes are the GDPR Art. 6(1) lawful bases, as structured -// values. Operators pick one; the page prints their own LegalBasisText -// alongside it. "legitimate_interests" additionally requires -// LegitimateInterestsText. -var privacyLegalBasisTypes = map[string]bool{ - "consent": true, - "contract": true, - "legal_obligation": true, - "vital_interests": true, - "public_task": true, - "legitimate_interests": true, -} - -// PrivacyLegalBasisTypes returns the accepted legalBasisType values, sorted, -// for error messages and documentation. -func PrivacyLegalBasisTypes() []string { - out := make([]string, 0, len(privacyLegalBasisTypes)) - for k := range privacyLegalBasisTypes { - out = append(out, k) - } - sort.Strings(out) - return out -} - -// Validate reports the configuration errors that make an enabled privacy -// notice unpublishable. It returns nil when the notice is safe to publish, -// or when the feature is off (a disabled/absent block is not an error). -// -// Callers must refuse to publish the notice when this returns anything — -// see handleConfigClient. The page never falls back to invented defaults, -// and never to a stand-in controller name. -func (p *PrivacyConfig) Validate() []string { - if p == nil || !p.Enabled { - return nil - } - var errs []string - // Every required free-text field, in the order the page presents them. - required := []struct { - name, val, hint string - }{ - {"controllerName", p.ControllerName, "name the data controller; there is no default"}, - {"effectiveDate", p.EffectiveDate, "identify the version of this notice"}, - {"purposesText", p.PurposesText, "describe what the deployment processes data for"}, - {"legalBasisText", p.LegalBasisText, "state the lawful basis in your own words"}, - {"retentionText", p.RetentionText, "state the actual retention period or criteria"}, - {"recipientsText", p.RecipientsText, "describe who else receives the data"}, - {"dataSourcesText", p.DataSourcesText, "describe where the data comes from"}, - {"thirdPartyServicesText", p.ThirdPartyServicesText, "list external services and what they receive"}, - {"internationalTransfersText", p.InternationalTransfersText, "state transfers and safeguards, or that none apply"}, - {"browserStorageText", p.BrowserStorageText, "describe what is stored in the visitor's browser"}, - {"serverLogsText", p.ServerLogsText, "describe server/proxy access logs and their retention"}, - {"rightsRequestText", p.RightsRequestText, "explain how to exercise data-protection rights"}, - {"supervisoryAuthorityName", p.SupervisoryAuthorityName, "name the supervisory authority"}, - {"automatedDecisionMakingText", p.AutomatedDecisionMakingText, "state whether automated decision-making is used"}, - } - for _, r := range required { - if strings.TrimSpace(r.val) == "" { - errs = append(errs, "privacy."+r.name+" is required when privacy.enabled is true ("+r.hint+")") - } - } - - if strings.TrimSpace(p.ContactEmail) == "" { - errs = append(errs, "privacy.contactEmail is required when privacy.enabled is true") - } else if !privacyEmailRe.MatchString(strings.TrimSpace(p.ContactEmail)) { - errs = append(errs, "privacy.contactEmail is not a valid plain email address") - } - - if u := strings.TrimSpace(p.SupervisoryAuthorityURL); u == "" { - errs = append(errs, "privacy.supervisoryAuthorityUrl is required when privacy.enabled is true") - } else if !isSafeHTTPURL(u) { - errs = append(errs, "privacy.supervisoryAuthorityUrl must be an http(s) URL") - } - - switch bt := strings.TrimSpace(p.LegalBasisType); { - case bt == "": - errs = append(errs, "privacy.legalBasisType is required when privacy.enabled is true (one of: "+strings.Join(PrivacyLegalBasisTypes(), ", ")+")") - case !privacyLegalBasisTypes[bt]: - errs = append(errs, "privacy.legalBasisType "+strconv.Quote(bt)+" is not recognised (one of: "+strings.Join(PrivacyLegalBasisTypes(), ", ")+")") - case bt == "legitimate_interests" && strings.TrimSpace(p.LegitimateInterestsText) == "": - errs = append(errs, "privacy.legitimateInterestsText is required when privacy.legalBasisType is legitimate_interests (describe the specific interests, do not merely name the basis)") - } - - // The DPO block is optional as a whole, but a HALF-filled one is worse - // than none: naming a designated DPO the reader has no way to reach is - // an incomplete disclosure. The reverse is fine — a contact route - // without a name still tells the reader where to write. - if strings.TrimSpace(p.DPOName) != "" && strings.TrimSpace(p.DPOContact) == "" { - errs = append(errs, "privacy.dpoContact is required when privacy.dpoName is set (a named DPO must be reachable; leave both blank if none is designated)") - } - - return errs -} - -// isSafeHTTPURL accepts only absolute http/https URLs with a host and no -// control characters. Deliberately narrow: the value becomes an href, so -// javascript:, data: and similar schemes must never pass. -func isSafeHTTPURL(raw string) bool { - if raw == "" || strings.ContainsAny(raw, " \t\r\n<>\"") { - return false - } - u, err := url.Parse(raw) - if err != nil { - return false - } - if u.Scheme != "http" && u.Scheme != "https" { - return false - } - return u.Host != "" } // weakAPIKeys is the blocklist of known default/example API keys that must be rejected. @@ -819,38 +610,15 @@ func LoadConfig(baseDirs ...string) (*Config, error) { cfg.migrateDeprecatedConfig() cfg.applyListLimitsDefaults() applyCORSEnv(cfg) - cfg.logPrivacyConfigErrors() return cfg, nil } cfg.NormalizeTimestampConfig() cfg.migrateDeprecatedConfig() cfg.applyListLimitsDefaults() applyCORSEnv(cfg) - cfg.logPrivacyConfigErrors() return cfg, nil // defaults } -// logPrivacyConfigErrors surfaces an unpublishable privacy notice loudly at -// startup. It does NOT abort the process: CoreScope is a monitoring -// dashboard, and taking the whole mesh view down over a misconfigured -// optional page would be disproportionate. The notice is simply withheld -// (handleConfigClient re-checks Validate), so the failure mode is "no -// privacy page" — never a fabricated one — and the log says exactly which -// fields to fix. -func (c *Config) logPrivacyConfigErrors() { - if c == nil { - return - } - errs := c.Privacy.Validate() - if len(errs) == 0 { - return - } - log.Printf("[privacy] CONFIG ERROR: privacy.enabled is true but the notice cannot be published; the #/privacy page and its nav link stay OFF until this is fixed:") - for _, e := range errs { - log.Printf("[privacy] - %s", e) - } -} - func (c *Config) applyListLimitsDefaults() { if c.ListLimits == nil { c.ListLimits = &ListLimitsConfig{} diff --git a/cmd/server/privacy_config_test.go b/cmd/server/privacy_config_test.go index f90777c8..798d2412 100644 --- a/cmd/server/privacy_config_test.go +++ b/cmd/server/privacy_config_test.go @@ -10,43 +10,36 @@ import ( // The opt-in privacy-notice page (#/privacy) is driven entirely by the // `privacy` field on /api/config/client. Contract with the frontend // (public/roles.js + public/privacy.js): field PRESENT => feature on, -// render notice + inject nav link; field ABSENT => feature off. These -// tests pin both directions plus the disabled-but-configured case. - -// validPrivacy returns a fully-populated, publishable config. Tests blank -// ONE field at a time from this baseline, so a new required field -// automatically gains coverage in TestPrivacyEachRequiredFieldIsMandatory. -func validPrivacy() *PrivacyConfig { - return &PrivacyConfig{ - Enabled: true, - ControllerName: "Example Mesh Community", - ContactEmail: "privacy@example.org", - EffectiveDate: "2026-09-01", - PurposesText: "Operating and troubleshooting the community network.", - LegalBasisType: "public_task", - LegalBasisText: "Processing is necessary for our community task.", - RetentionText: "Packet data is deleted after 30 days.", - RecipientsText: "Website and API visitors; our hosting provider.", - DataSourcesText: "Observer nodes, radio packets and derived measurements.", - ThirdPartyServicesText: "Map tiles are loaded from a third-party provider.", - InternationalTransfersText: "No transfers outside the EU/EEA.", - BrowserStorageText: "Interface settings are stored in your browser.", - ServerLogsText: "Our proxy keeps access logs for 14 days.", - RightsRequestText: "Email us and we will assess your request.", - SupervisoryAuthorityName: "Datatilsynet", - SupervisoryAuthorityURL: "https://www.datatilsynet.dk", - AutomatedDecisionMakingText: "No automated decision-making is used.", - } +// render the notice + inject the nav link; field ABSENT => feature off. +// +// The notice itself is a FIXED document in public/privacy.js. The server +// therefore publishes a flag and nothing else: there is no operator text in +// the payload, and no config value can put wording on the page. These tests +// pin that emptiness as hard as they pin the on/off contract, because a +// field reappearing here is a field that could ship unreviewed text. + +// removedPrivacyFields are the operator-text keys the privacy block used to +// carry. None may ever come back: public/privacy.js has no renderer for +// them, so publishing one again would ship dead data to every browser and +// re-impose a required field an operator cannot influence the page with. +var removedPrivacyFields = []string{ + "controllerName", "contactEmail", "effectiveDate", "purposesText", + "legalBasisType", "legalBasisText", "legitimateInterestsText", + "retentionText", "recipientsText", "dataSourcesText", + "thirdPartyServicesText", "internationalTransfersText", + "browserStorageText", "serverLogsText", "automatedDecisionMakingText", + "rightsRequestText", "supervisoryAuthorityName", "supervisoryAuthorityUrl", + "dpoName", "dpoContact", "hiddenNamePrefixes", } -func TestConfigClientExposesPrivacyWhenEnabled(t *testing.T) { +func privacyBlock(t *testing.T, cfg *PrivacyConfig) (map[string]interface{}, bool) { + t.Helper() srv, router := setupTestServer(t) - srv.cfg.Privacy = validPrivacy() + srv.cfg.Privacy = cfg req := httptest.NewRequest("GET", "/api/config/client", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) - if w.Code != 200 { t.Fatalf("expected 200, got %d", w.Code) } @@ -54,450 +47,145 @@ func TestConfigClientExposesPrivacyWhenEnabled(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatalf("decode body: %v", err) } - pRaw, present := body["privacy"] + raw, present := body["privacy"] if !present { - t.Fatal("expected privacy in /api/config/client response when enabled") + return nil, false } - p, ok := pRaw.(map[string]interface{}) + p, ok := raw.(map[string]interface{}) if !ok { - t.Fatalf("expected privacy to be a JSON object, got %T: %+v", pRaw, pRaw) + t.Fatalf("expected privacy to be a JSON object, got %T: %+v", raw, raw) + } + return p, true +} + +func TestConfigClientExposesPrivacyWhenEnabled(t *testing.T) { + p, present := privacyBlock(t, &PrivacyConfig{Enabled: true}) + if !present { + t.Fatal("expected privacy to be published when enabled") } - // Explicit type assertions so a shape change (field renamed, value - // becoming a different type) fails loudly — mirrors the geoFilter - // contract tests in config_client_geofilter_test.go. if enabled, ok := p["enabled"].(bool); !ok || !enabled { t.Errorf("privacy[enabled] = %T(%v), want true", p["enabled"], p["enabled"]) } - wantStrings := map[string]string{ - "controllerName": "Example Mesh Community", - "contactEmail": "privacy@example.org", - "effectiveDate": "2026-09-01", - "legalBasisType": "public_task", - "retentionText": "Packet data is deleted after 30 days.", - "supervisoryAuthorityName": "Datatilsynet", - "supervisoryAuthorityUrl": "https://www.datatilsynet.dk", - "automatedDecisionMakingText": "No automated decision-making is used.", +} + +// The whole payload is one flag. Asserted by key count, not by a checklist, +// so a NEW field added later fails this too -- not just the removed ones. +func TestConfigClientPublishesOnlyTheEnabledFlag(t *testing.T) { + p, present := privacyBlock(t, &PrivacyConfig{Enabled: true}) + if !present { + t.Fatal("expected privacy to be published when enabled") } - for field, want := range wantStrings { - got, ok := p[field].(string) - if !ok { - t.Fatalf("privacy[%q] = %T(%v), want a string", field, p[field], p[field]) - } - if got != want { - t.Errorf("privacy[%q] = %q, want %q", field, got, want) + if len(p) != 1 { + keys := make([]string, 0, len(p)) + for k := range p { + keys = append(keys, k) } + t.Errorf("privacy payload must carry ONLY \"enabled\", got %d keys: %v", len(p), keys) } } -// A configured-but-disabled privacy section must behave exactly like an -// unconfigured one: the field is omitted entirely (not present-but-false), -// so the frontend's single presence check gates the whole feature. -func TestConfigClientOmitsPrivacyWhenDisabled(t *testing.T) { - srv, router := setupTestServer(t) - srv.cfg.Privacy = &PrivacyConfig{Enabled: false, ControllerName: "Example"} - - req := httptest.NewRequest("GET", "/api/config/client", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != 200 { - t.Fatalf("expected 200, got %d", w.Code) +func TestConfigClientOmitsAllRemovedPrivacyFields(t *testing.T) { + p, present := privacyBlock(t, &PrivacyConfig{Enabled: true}) + if !present { + t.Fatal("expected privacy to be published when enabled") } - var body map[string]interface{} - if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { - t.Fatalf("decode body: %v", err) + for _, f := range removedPrivacyFields { + if _, found := p[f]; found { + t.Errorf("privacy[%q] was removed with the operator-text model but is still published", f) + } } - if _, present := body["privacy"]; present { +} + +func TestConfigClientOmitsPrivacyWhenDisabled(t *testing.T) { + if _, present := privacyBlock(t, &PrivacyConfig{Enabled: false}); present { t.Error("expected privacy to be omitted from /api/config/client when disabled") } } func TestConfigClientOmitsPrivacyWhenUnconfigured(t *testing.T) { - srv, router := setupTestServer(t) - srv.cfg.Privacy = nil - - req := httptest.NewRequest("GET", "/api/config/client", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != 200 { - t.Fatalf("expected 200, got %d", w.Code) - } - var body map[string]interface{} - if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { - t.Fatalf("decode body: %v", err) - } - if _, present := body["privacy"]; present { + if _, present := privacyBlock(t, nil); present { t.Error("expected privacy to be omitted from /api/config/client when unconfigured") } } -// config.json parsing: the privacy section round-trips into Config with -// the documented json field names (see config.example.json). +// config.json parsing: the privacy section round-trips into Config with the +// documented json field name (see config.example.json). func TestPrivacyConfigParsing(t *testing.T) { - raw := `{ - "privacy": { - "enabled": true, - "controllerName": "Example Mesh Community", - "contactEmail": "privacy@example.org", - "retentionText": "Deleted after 30 days.", - "legalBasisText": "Legitimate interest." - } - }` - var cfg Config - if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + var c Config + if err := json.Unmarshal([]byte(`{"privacy":{"enabled":true}}`), &c); err != nil { t.Fatalf("unmarshal: %v", err) } - if cfg.Privacy == nil { - t.Fatal("expected cfg.Privacy to be set") - } - if !cfg.Privacy.Enabled { - t.Error("Enabled = false, want true") - } - if cfg.Privacy.ControllerName != "Example Mesh Community" { - t.Errorf("ControllerName = %q", cfg.Privacy.ControllerName) - } - if cfg.Privacy.ContactEmail != "privacy@example.org" { - t.Errorf("ContactEmail = %q", cfg.Privacy.ContactEmail) - } - if cfg.Privacy.RetentionText != "Deleted after 30 days." { - t.Errorf("RetentionText = %q", cfg.Privacy.RetentionText) - } - if cfg.Privacy.LegalBasisText != "Legitimate interest." { - t.Errorf("LegalBasisText = %q", cfg.Privacy.LegalBasisText) - } - // Omitted section must stay nil so the feature defaults off. - var empty Config - if err := json.Unmarshal([]byte(`{}`), &empty); err != nil { - t.Fatalf("unmarshal empty: %v", err) - } - if empty.Privacy != nil { - t.Error("expected nil Privacy for a config without the section") - } -} - -// ─── Required-field contract (review round: no invented legal text) ───────── - -// Enabling the notice makes contactEmail, retentionText and legalBasisText -// mandatory. CoreScope cannot infer a retention period, a lawful basis or a -// working contact, and must never invent them — so an incomplete block is a -// configuration error and the notice is withheld rather than published with -// made-up content. -// Every REQUIRED field, blanked one at a time from a known-good baseline. -// Driven by a table so adding a required field to PrivacyConfig without -// adding it here shows up as a gap rather than passing silently. -func TestPrivacyEachRequiredFieldIsMandatory(t *testing.T) { - if errs := validPrivacy().Validate(); len(errs) != 0 { - t.Fatalf("the baseline must validate, got %v", errs) - } - blank := map[string]func(*PrivacyConfig){ - "controllerName": func(p *PrivacyConfig) { p.ControllerName = "" }, - "contactEmail": func(p *PrivacyConfig) { p.ContactEmail = "" }, - "effectiveDate": func(p *PrivacyConfig) { p.EffectiveDate = "" }, - "purposesText": func(p *PrivacyConfig) { p.PurposesText = "" }, - "legalBasisType": func(p *PrivacyConfig) { p.LegalBasisType = "" }, - "legalBasisText": func(p *PrivacyConfig) { p.LegalBasisText = "" }, - "retentionText": func(p *PrivacyConfig) { p.RetentionText = "" }, - "recipientsText": func(p *PrivacyConfig) { p.RecipientsText = "" }, - "dataSourcesText": func(p *PrivacyConfig) { p.DataSourcesText = "" }, - "thirdPartyServicesText": func(p *PrivacyConfig) { p.ThirdPartyServicesText = "" }, - "internationalTransfersText": func(p *PrivacyConfig) { p.InternationalTransfersText = "" }, - "browserStorageText": func(p *PrivacyConfig) { p.BrowserStorageText = "" }, - "serverLogsText": func(p *PrivacyConfig) { p.ServerLogsText = "" }, - "rightsRequestText": func(p *PrivacyConfig) { p.RightsRequestText = "" }, - "supervisoryAuthorityName": func(p *PrivacyConfig) { p.SupervisoryAuthorityName = "" }, - "supervisoryAuthorityUrl": func(p *PrivacyConfig) { p.SupervisoryAuthorityURL = "" }, - "automatedDecisionMakingText": func(p *PrivacyConfig) { p.AutomatedDecisionMakingText = "" }, - } - for field, clear := range blank { - t.Run(field, func(t *testing.T) { - p := validPrivacy() - clear(p) - errs := p.Validate() - if len(errs) == 0 { - t.Fatalf("missing %s must be a validation error", field) - } - var named bool - for _, e := range errs { - if strings.Contains(e, field) { - named = true - } - } - if !named { - t.Errorf("the error for %s should name the field; got %v", field, errs) - } - }) - } - - // Whitespace-only is empty for every text field. - p := validPrivacy() - p.RetentionText = " \t " - if len(p.Validate()) == 0 { - t.Error("whitespace-only retentionText must not satisfy the requirement") - } - - // controllerName specifically has NO fallback anywhere in the stack. - p = validPrivacy() - p.ControllerName = " " - if len(p.Validate()) == 0 { - t.Error("controllerName must have no fallback — blank is a hard error") - } - - // DPO fields stay optional. - p = validPrivacy() - p.DPOName, p.DPOContact = "", "" - if errs := p.Validate(); len(errs) != 0 { - t.Errorf("DPO fields are optional, got %v", errs) - } - - // Disabled or absent is never an error. - if errs := (&PrivacyConfig{Enabled: false}).Validate(); len(errs) != 0 { - t.Errorf("a disabled block must not report errors, got %v", errs) - } - var nilCfg *PrivacyConfig - if errs := nilCfg.Validate(); len(errs) != 0 { - t.Errorf("an absent block must not report errors, got %v", errs) + if c.Privacy == nil { + t.Fatal("privacy section did not parse") } -} - -// legalBasisType is structured, not inferred from prose, and -// legitimate_interests additionally requires the specific interests. -func TestPrivacyLegalBasisTypeContract(t *testing.T) { - for _, bt := range PrivacyLegalBasisTypes() { - p := validPrivacy() - p.LegalBasisType = bt - if bt == "legitimate_interests" { - if errs := p.Validate(); len(errs) == 0 { - t.Error("legitimate_interests without legitimateInterestsText must fail") - } - p.LegitimateInterestsText = "We rely on X to keep the mesh operable; see our assessment." - } - if errs := p.Validate(); len(errs) != 0 { - t.Errorf("legalBasisType %q should validate, got %v", bt, errs) - } - } - - // Unrecognised values are rejected rather than passed through. - for _, bad := range []string{"legitimate interest", "LegitimateInterests", "art6f", "other"} { - p := validPrivacy() - p.LegalBasisType = bad - if errs := p.Validate(); len(errs) == 0 { - t.Errorf("legalBasisType %q should be rejected", bad) - } - } - - // legitimateInterestsText is NOT required for other bases. - p := validPrivacy() - p.LegalBasisType = "consent" - p.LegitimateInterestsText = "" - if errs := p.Validate(); len(errs) != 0 { - t.Errorf("legitimateInterestsText should only be required for legitimate_interests, got %v", errs) + if !c.Privacy.Enabled { + t.Error("privacy.enabled did not parse as true") } -} - -func TestPrivacySupervisoryAuthorityURLIsSafe(t *testing.T) { - // Trailing whitespace is trimmed (and the trimmed form is what ships to - // the browser -- see handleConfigClient), so it is a typo, not a threat. - good := []string{"https://www.datatilsynet.dk", "http://example.org/privacy", " https://example.org ", "https://example.org\n"} - for _, u := range good { - p := validPrivacy() - p.SupervisoryAuthorityURL = u - if errs := p.Validate(); len(errs) != 0 { - t.Errorf("%q should be accepted, got %v", u, errs) - } - } - bad := []string{ - "javascript:alert(1)", "data:text/html,", - "//example.org", "example.org", "ftp://example.org", - "https://exa mple.org", "https://exa\nmple.org", "", - } - for _, u := range bad { - p := validPrivacy() - p.SupervisoryAuthorityURL = u - if errs := p.Validate(); len(errs) == 0 { - t.Errorf("%q should be rejected as supervisoryAuthorityUrl", u) - } - } -} -func TestPrivacyValidateEmailShape(t *testing.T) { - base := func(email string) *PrivacyConfig { - p := validPrivacy() - p.ContactEmail = email - return p - } - good := []string{"privacy@example.org", "a.b+c@sub.example.co.uk", "x_y@example.io"} - for _, e := range good { - if errs := base(e).Validate(); len(errs) != 0 { - t.Errorf("%q should be accepted, got %v", e, errs) - } - } - // Conservative rejects: anything that could forge a mailto header/query, - // carry CR/LF, or is simply not an address. This proves shape only, never - // deliverability. - bad := []string{ - "", " ", "no-at-sign", "two@@example.org", "a@b", "a@example", - "a b@example.org", "a\r\nBcc:v@example.org", "a\n@example.org", - "a?subject=x@example.org", "a&cc=x@example.org", - "\"quoted\"@example.org", "", "Name ", - "a,b@example.org", "a;b@example.org", + // Omitted section stays nil so the feature defaults off. + var c2 Config + if err := json.Unmarshal([]byte(`{}`), &c2); err != nil { + t.Fatalf("unmarshal: %v", err) } - for _, e := range bad { - if errs := base(e).Validate(); len(errs) == 0 { - t.Errorf("%q should be rejected as a contactEmail", e) - } + if c2.Privacy != nil { + t.Errorf("an omitted privacy section must stay nil, got %+v", c2.Privacy) } } -// An enabled-but-invalid block must NOT reach the browser: the page would -// otherwise have to invent the missing text. -// An enabled-but-invalid block must NOT reach the browser: the page would -// otherwise have to invent the missing text, or name no controller at all. -// One subtest per required field, so every one of them is proven to gate -// publication end-to-end, not just in Validate(). -func TestConfigClientWithholdsPrivacyWhenInvalid(t *testing.T) { - blank := map[string]func(*PrivacyConfig){ - "controllerName": func(p *PrivacyConfig) { p.ControllerName = "" }, - "contactEmail": func(p *PrivacyConfig) { p.ContactEmail = "" }, - "effectiveDate": func(p *PrivacyConfig) { p.EffectiveDate = "" }, - "purposesText": func(p *PrivacyConfig) { p.PurposesText = "" }, - "legalBasisType": func(p *PrivacyConfig) { p.LegalBasisType = "" }, - "legalBasisText": func(p *PrivacyConfig) { p.LegalBasisText = "" }, - "retentionText": func(p *PrivacyConfig) { p.RetentionText = "" }, - "recipientsText": func(p *PrivacyConfig) { p.RecipientsText = "" }, - "dataSourcesText": func(p *PrivacyConfig) { p.DataSourcesText = "" }, - "thirdPartyServicesText": func(p *PrivacyConfig) { p.ThirdPartyServicesText = "" }, - "internationalTransfersText": func(p *PrivacyConfig) { p.InternationalTransfersText = "" }, - "browserStorageText": func(p *PrivacyConfig) { p.BrowserStorageText = "" }, - "serverLogsText": func(p *PrivacyConfig) { p.ServerLogsText = "" }, - "rightsRequestText": func(p *PrivacyConfig) { p.RightsRequestText = "" }, - "supervisoryAuthorityName": func(p *PrivacyConfig) { p.SupervisoryAuthorityName = "" }, - "supervisoryAuthorityUrl": func(p *PrivacyConfig) { p.SupervisoryAuthorityURL = "" }, - "automatedDecisionMakingText": func(p *PrivacyConfig) { p.AutomatedDecisionMakingText = "" }, - "malformed email": func(p *PrivacyConfig) { p.ContactEmail = "nope" }, - "unsafe authority url": func(p *PrivacyConfig) { p.SupervisoryAuthorityURL = "javascript:alert(1)" }, - "legitimate_interests without interests": func(p *PrivacyConfig) { - p.LegalBasisType = "legitimate_interests" - p.LegitimateInterestsText = "" - }, - "dpoName without dpoContact": func(p *PrivacyConfig) { - p.DPOName = "Jane Doe" - p.DPOContact = "" - }, - } - for name, clear := range blank { - t.Run(name, func(t *testing.T) { - srv, router := setupTestServer(t) - p := validPrivacy() - clear(p) - srv.cfg.Privacy = p - req := httptest.NewRequest("GET", "/api/config/client", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - if w.Code != 200 { - t.Fatalf("expected 200, got %d", w.Code) - } - var body map[string]interface{} - if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { - t.Fatalf("decode body: %v", err) - } - if _, present := body["privacy"]; present { - t.Error("an enabled-but-invalid privacy block must be withheld, not published") - } - }) +// A deployment upgrading in place still has the old operator-text keys in +// its config.json. They must be ignored -- not rejected, and above all never +// published, because there is no longer any renderer that would show them. +func TestPrivacyStaleConfigKeysAreIgnored(t *testing.T) { + raw := []byte(`{ + "enabled": true, + "controllerName": "STALE-controller", + "contactEmail": "stale@example.invalid", + "effectiveDate": "STALE-date", + "purposesText": "STALE-purposes", + "legalBasisType": "not_a_real_basis", + "legalBasisText": "STALE-basis", + "retentionText": "STALE-retention", + "recipientsText": "STALE-recipients", + "dataSourcesText": "STALE-sources", + "thirdPartyServicesText": "STALE-third-party", + "internationalTransfersText": "STALE-transfers", + "browserStorageText": "STALE-storage", + "serverLogsText": "STALE-logs", + "automatedDecisionMakingText": "STALE-automated", + "rightsRequestText": "STALE-rights", + "supervisoryAuthorityName": "STALE-authority", + "supervisoryAuthorityUrl": "javascript:alert(1)", + "dpoName": "STALE-dpo", + "dpoContact": "STALE-dpo-contact" + }`) + var p PrivacyConfig + if err := json.Unmarshal(raw, &p); err != nil { + t.Fatalf("a config.json written before the removal must still parse: %v", err) + } + if !p.Enabled { + t.Fatal("enabled must still parse from a pre-removal config") } -} -// The happy path: a complete config publishes every documented field, so the -// page and the nav link both light up. -func TestConfigClientPublishesCompletePrivacy(t *testing.T) { srv, router := setupTestServer(t) - p := validPrivacy() - p.LegalBasisType = "legitimate_interests" - p.LegitimateInterestsText = "Keeping the community mesh operable; see our assessment." - p.DPOName = "Jane Doe" - p.DPOContact = "dpo@example.org" - srv.cfg.Privacy = p - + srv.cfg.Privacy = &p req := httptest.NewRequest("GET", "/api/config/client", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) - var body map[string]interface{} - if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { - t.Fatalf("decode body: %v", err) - } - pc, ok := body["privacy"].(map[string]interface{}) - if !ok { - t.Fatalf("privacy missing from a complete config: %+v", body["privacy"]) + if w.Code != 200 { + t.Fatalf("a stale privacy config must not break the endpoint, got %d", w.Code) } - for _, f := range []string{ - "enabled", "controllerName", "contactEmail", "effectiveDate", "purposesText", - "legalBasisType", "legalBasisText", "legitimateInterestsText", "retentionText", - "recipientsText", "dataSourcesText", "thirdPartyServicesText", - "internationalTransfersText", "browserStorageText", "serverLogsText", - "rightsRequestText", "supervisoryAuthorityName", "supervisoryAuthorityUrl", - "automatedDecisionMakingText", "dpoName", "dpoContact", - } { - if _, present := pc[f]; !present { - t.Errorf("privacy[%q] missing from the published payload", f) + body := w.Body.String() + for _, leak := range []string{"STALE-", "stale@example.invalid", "javascript:alert(1)", "not_a_real_basis"} { + if strings.Contains(body, leak) { + t.Errorf("a stale removed key leaked into /api/config/client: %q", leak) } } } -// The page names the deployment's REAL hide prefixes instead of hardcoding a -// character, so the server has to publish the active list. -func TestConfigClientExposesActiveHiddenNamePrefixes(t *testing.T) { - srv, router := setupTestServer(t) - srv.cfg.Privacy = validPrivacy() - srv.cfg.SetHiddenNamePrefixes([]string{"##", " ", "zz"}) - - req := httptest.NewRequest("GET", "/api/config/client", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - var body map[string]interface{} - if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { - t.Fatalf("decode body: %v", err) - } - p, ok := body["privacy"].(map[string]interface{}) - if !ok { - t.Fatalf("privacy missing: %+v", body["privacy"]) - } - raw, ok := p["hiddenNamePrefixes"].([]interface{}) - if !ok { - t.Fatalf("hiddenNamePrefixes = %T(%v), want an array", p["hiddenNamePrefixes"], p["hiddenNamePrefixes"]) - } - got := make([]string, 0, len(raw)) - for _, v := range raw { - got = append(got, v.(string)) - } - want := []string{"##", "zz"} // whitespace-only entries are not enforced, so not advertised - if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { - t.Errorf("hiddenNamePrefixes = %v, want %v (blank entries dropped)", got, want) - } -} - -// No configured prefixes => the field is omitted entirely, so the page knows -// not to promise self-service hiding. -func TestConfigClientOmitsHiddenPrefixesWhenNoneConfigured(t *testing.T) { - srv, router := setupTestServer(t) - srv.cfg.Privacy = validPrivacy() - srv.cfg.SetHiddenNamePrefixes([]string{" "}) - - req := httptest.NewRequest("GET", "/api/config/client", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - var body map[string]interface{} - if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { - t.Fatalf("decode body: %v", err) - } - p := body["privacy"].(map[string]interface{}) - if v, present := p["hiddenNamePrefixes"]; present { - t.Errorf("hiddenNamePrefixes should be omitted when none are active, got %v", v) - } -} - // ActiveHiddenNamePrefixes must reflect what IsNameHidden actually enforces, // including after a SIGHUP-style replacement, and must not alias live config. +// The privacy page no longer consumes it, but the invariant it pins belongs +// to node hiding, not to the notice. func TestActiveHiddenNamePrefixesMatchesEnforcement(t *testing.T) { c := &Config{HiddenNamePrefixes: []string{"##", "", " "}} got := c.ActiveHiddenNamePrefixes() @@ -522,87 +210,3 @@ func TestActiveHiddenNamePrefixesMatchesEnforcement(t *testing.T) { t.Error("nil config must return nil") } } - -// The DPO block is optional, but not divisible: a named DPO the reader cannot -// reach is an incomplete disclosure, while a contact route without a name -// still says where to write. All four combinations are pinned here, at both -// the Validate() layer and the /api/config/client publishing gate, because a -// withheld privacy block also removes the page from the navigation. -func TestPrivacyDPONameRequiresContact(t *testing.T) { - cases := []struct { - name string - dpoName string - dpoContact string - wantValid bool - wantName bool // dpoName present in the published payload - wantContact bool // dpoContact present in the published payload - }{ - {"both blank", "", "", true, false, false}, - {"name without contact", "Jane Doe", "", false, false, false}, - {"contact without name", "", "dpo@example.org", true, false, true}, - {"both present", "Jane Doe", "dpo@example.org", true, true, true}, - // Trimming must match the rest of the contract: a whitespace-only - // contact is blank, not a contact route. - {"name with whitespace-only contact", "Jane Doe", " \t ", false, false, false}, - {"whitespace-only name is not a name", " ", "", true, false, false}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - // Layer 1: Validate() itself. - p := validPrivacy() - p.DPOName = tc.dpoName - p.DPOContact = tc.dpoContact - errs := p.Validate() - if tc.wantValid && len(errs) != 0 { - t.Fatalf("expected a valid config, got errors: %v", errs) - } - if !tc.wantValid { - if len(errs) == 0 { - t.Fatal("expected a validation error for a half-filled DPO block, got none") - } - var found bool - for _, e := range errs { - if strings.Contains(e, "privacy.dpoContact is required") { - found = true - } - } - if !found { - t.Errorf("expected the dpoContact error, got: %v", errs) - } - } - - // Layer 2: the end-to-end publishing gate. An invalid DPO block - // must withhold the WHOLE privacy payload, not just the section. - srv, router := setupTestServer(t) - srv.cfg.Privacy = p - req := httptest.NewRequest("GET", "/api/config/client", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - if w.Code != 200 { - t.Fatalf("expected 200, got %d", w.Code) - } - var body map[string]interface{} - if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { - t.Fatalf("decode body: %v", err) - } - raw, present := body["privacy"] - if !tc.wantValid { - if present { - t.Fatal("a half-filled DPO block must withhold the entire privacy payload") - } - return - } - pc, ok := raw.(map[string]interface{}) - if !ok { - t.Fatalf("privacy missing from a valid config: %+v", raw) - } - if _, got := pc["dpoName"]; got != tc.wantName { - t.Errorf("dpoName present = %v, want %v", got, tc.wantName) - } - if _, got := pc["dpoContact"]; got != tc.wantContact { - t.Errorf("dpoContact present = %v, want %v", got, tc.wantContact) - } - }) - } -} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index db0de9fb..442b009f 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -520,48 +520,15 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) { if s.cfg.Customizer != nil && s.cfg.Customizer.DisabledTabs != nil { disabledTabs = s.cfg.Customizer.DisabledTabs } - // #/privacy page content — only surfaced when the operator opted in - // (privacy.enabled) AND supplied every field the notice needs. An - // enabled-but-incomplete block is a configuration error (logged loudly - // at startup by logPrivacyConfigErrors) and is withheld here: the page - // must never fall back to invented retention/legal-basis text. Nil - // keeps the field out of the JSON entirely via omitempty, which the + // #/privacy page — published only when the operator opted in. The + // notice itself is a fixed document in public/privacy.js, so this + // carries the flag and nothing else; there is no operator text to send + // and therefore nothing that could put unreviewed wording on the page. + // Nil keeps the field out of the JSON entirely via omitempty, which the // frontend reads as "feature off". var privacy *PrivacyClientConfig - if s.cfg.Privacy != nil && s.cfg.Privacy.Enabled && len(s.cfg.Privacy.Validate()) == 0 { - // Publish TRIMMED values: Validate() checks the trimmed form, so - // sending the raw one could hand the browser a string its own - // safeUrl()/mailto guards then reject (e.g. a stray trailing - // newline in config.json). What was validated is what ships. - tr := strings.TrimSpace - privacy = &PrivacyClientConfig{ - Enabled: true, - ControllerName: tr(s.cfg.Privacy.ControllerName), - ContactEmail: tr(s.cfg.Privacy.ContactEmail), - EffectiveDate: tr(s.cfg.Privacy.EffectiveDate), - PurposesText: tr(s.cfg.Privacy.PurposesText), - LegalBasisType: tr(s.cfg.Privacy.LegalBasisType), - LegalBasisText: tr(s.cfg.Privacy.LegalBasisText), - LegitimateInterestsText: tr(s.cfg.Privacy.LegitimateInterestsText), - RetentionText: tr(s.cfg.Privacy.RetentionText), - RecipientsText: tr(s.cfg.Privacy.RecipientsText), - DataSourcesText: tr(s.cfg.Privacy.DataSourcesText), - ThirdPartyServicesText: tr(s.cfg.Privacy.ThirdPartyServicesText), - InternationalTransfersText: tr(s.cfg.Privacy.InternationalTransfersText), - BrowserStorageText: tr(s.cfg.Privacy.BrowserStorageText), - ServerLogsText: tr(s.cfg.Privacy.ServerLogsText), - RightsRequestText: tr(s.cfg.Privacy.RightsRequestText), - SupervisoryAuthorityName: tr(s.cfg.Privacy.SupervisoryAuthorityName), - SupervisoryAuthorityURL: tr(s.cfg.Privacy.SupervisoryAuthorityURL), - AutomatedDecisionMakingText: tr(s.cfg.Privacy.AutomatedDecisionMakingText), - DPOName: tr(s.cfg.Privacy.DPOName), - DPOContact: tr(s.cfg.Privacy.DPOContact), - // The ACTUAL active hide prefixes, so the page can name them - // instead of hardcoding a character the deployment may not - // use. Empty means the operator configured none, and the page - // must not promise self-service hiding. - HiddenNamePrefixes: s.cfg.ActiveHiddenNamePrefixes(), - } + if s.cfg.Privacy != nil && s.cfg.Privacy.Enabled { + privacy = &PrivacyClientConfig{Enabled: true} } writeJSON(w, ClientConfigResponse{ Roles: s.cfg.Roles, diff --git a/cmd/server/types.go b/cmd/server/types.go index 588f8b2a..c24b5341 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -1562,47 +1562,23 @@ type ClientConfigResponse struct { // nodePassesGeoFilter (public/app.js) and geo_filter.go. Omitted when // no geo_filter is configured. GeoFilter *GeoFilterConfig `json:"geoFilter,omitempty"` - // Privacy is the operator-configured privacy-notice content for the - // #/privacy page. Omitted entirely unless privacy.enabled is true AND - // the block passes PrivacyConfig.Validate() — the frontend treats - // "field absent" as "feature off" (no nav link injected). See - // PrivacyClientConfig below and PrivacyConfig (config.go). + // Privacy is the opt-in signal for the #/privacy page, not its content: + // the notice is a fixed document in public/privacy.js. Omitted entirely + // when privacy is unconfigured or privacy.enabled is false — the + // frontend treats "field absent" as "feature off" (no nav link + // injected). When enabled, the published block is exactly + // {"enabled":true}: no operator-configured text is ever sent to the + // frontend. See PrivacyClientConfig below and PrivacyConfig (config.go). Privacy *PrivacyClientConfig `json:"privacy,omitempty"` } -// PrivacyClientConfig is the browser-facing shape of the privacy notice. -// Separate from PrivacyConfig because the page needs one thing the operator -// does not type into the privacy block: the deployment's ACTIVE -// hiddenNamePrefixes, so the notice can name the real self-service hide -// prefix (or stay silent about it when none is configured) instead of -// hardcoding a character. +// PrivacyClientConfig is the privacy block of /api/config/client. It carries +// the opt-in flag and nothing else: the notice is a fixed document in +// public/privacy.js, so there is no operator content to ship. Keeping the +// payload empty is the point -- a field here would be a field that could put +// unreviewed text on the page. type PrivacyClientConfig struct { - Enabled bool `json:"enabled"` - ControllerName string `json:"controllerName"` - ContactEmail string `json:"contactEmail"` - EffectiveDate string `json:"effectiveDate"` - PurposesText string `json:"purposesText"` - LegalBasisType string `json:"legalBasisType"` - LegalBasisText string `json:"legalBasisText"` - LegitimateInterestsText string `json:"legitimateInterestsText,omitempty"` - RetentionText string `json:"retentionText"` - RecipientsText string `json:"recipientsText"` - DataSourcesText string `json:"dataSourcesText"` - ThirdPartyServicesText string `json:"thirdPartyServicesText"` - InternationalTransfersText string `json:"internationalTransfersText"` - BrowserStorageText string `json:"browserStorageText"` - ServerLogsText string `json:"serverLogsText"` - RightsRequestText string `json:"rightsRequestText"` - SupervisoryAuthorityName string `json:"supervisoryAuthorityName"` - SupervisoryAuthorityURL string `json:"supervisoryAuthorityUrl"` - AutomatedDecisionMakingText string `json:"automatedDecisionMakingText"` - // Optional: omitted entirely when the deployment has no DPO. - DPOName string `json:"dpoName,omitempty"` - DPOContact string `json:"dpoContact,omitempty"` - // HiddenNamePrefixes is the live Config.HiddenNamePrefixes list. Empty - // or absent means no self-service hiding is available on this - // deployment, and the page must not claim otherwise. - HiddenNamePrefixes []string `json:"hiddenNamePrefixes,omitempty"` + Enabled bool `json:"enabled"` } // CustomizerClientConfig is the operator-side customizer-modal knobs that diff --git a/config.example.json b/config.example.json index 1b6ff59b..327e32d9 100644 --- a/config.example.json +++ b/config.example.json @@ -401,45 +401,6 @@ }, "privacy": { "enabled": false, - "_comment": "Opt-in privacy-notice page at #/privacy. Default off. Setting enabled=true makes EVERY field below (except dpoName/dpoContact and legitimateInterestsText) REQUIRED: CoreScope ships no default legal text and no stand-in operator identity, because the controller, purposes, lawful basis, retention and recipients are facts only you know. If enabled=true and anything required is missing or malformed, the server logs a '[privacy] CONFIG ERROR' line naming each field at startup and does NOT publish the notice (page and nav link stay off) rather than showing an invented policy. All values render as plain TEXT (HTML-escaped) -- markup is not supported, by design. Blank lines in a text field become paragraphs. None of this is legal advice, and publishing the page does not by itself make a deployment compliant.", - "controllerName": "", - "_comment_controllerName": "REQUIRED. The data controller. There is deliberately NO fallback: a notice that cannot name who is responsible does not identify a controller at all.", - "contactEmail": "", - "_comment_contactEmail": "REQUIRED. Plain address only (no display name or angle brackets); validated conservatively for shape, never for deliverability.", - "effectiveDate": "", - "_comment_effectiveDate": "REQUIRED. Free text, e.g. '2026-09-01'. Identifies the version of this notice.", - "purposesText": "", - "_comment_purposesText": "REQUIRED. What this deployment processes data FOR, e.g. operating the community network, displaying coverage, troubleshooting routes, analysing network health.", - "legalBasisType": "", - "_comment_legalBasisType": "REQUIRED. Structured, one of: consent, contract, legal_obligation, vital_interests, public_task, legitimate_interests. Structured rather than parsed from prose -- guessing the basis from free text would be fragile and presumptuous.", - "legalBasisText": "", - "_comment_legalBasisText": "REQUIRED. Your own description of the basis.", - "legitimateInterestsText": "", - "_comment_legitimateInterestsText": "REQUIRED ONLY when legalBasisType is legitimate_interests: describe the SPECIFIC interests and refer to your assessment. Do not merely restate 'legitimate interest'.", - "retentionText": "", - "_comment_retentionText": "REQUIRED. Actual retention period or criteria, per category where they differ. CoreScope has several independent retention knobs (packets, metrics, nodes, client-RX) that do not map one-to-one onto the categories this notice describes, so it cannot derive this for you.", - "recipientsText": "", - "_comment_recipientsText": "REQUIRED. Who receives the data beyond website/API visitors: hosting, monitoring, other processors.", - "dataSourcesText": "", - "_comment_dataSourcesText": "REQUIRED. Where the data comes from -- typically participating MeshCore observer nodes and radio packets, plus, where enabled, mobile reception clients, and the measurements CoreScope derives from them.", - "thirdPartyServicesText": "", - "_comment_thirdPartyServicesText": "REQUIRED. External services the visitor's browser contacts (map/tile providers such as CARTO or Esri, CDNs, monitoring) and what they receive -- typically IP address, user agent and referrer.", - "internationalTransfersText": "", - "_comment_internationalTransfersText": "REQUIRED. Transfers outside your jurisdiction and any safeguards, OR an explicit statement that none apply. An explicit 'none' is a valid answer; silence is not.", - "browserStorageText": "", - "_comment_browserStorageText": "REQUIRED. What this site stores in the visitor's browser. This build uses localStorage for interface settings such as theme, favourites, filters, sort order, map/live view settings, panel positions and timestamp format, and -- if the visitor enters them -- channel keys and an API key. No analytics or advertising cookies are set by CoreScope itself.", - "serverLogsText": "", - "_comment_serverLogsText": "REQUIRED. Server/proxy access logs, their purpose and retention. These live in YOUR infrastructure (reverse proxy, host), not in CoreScope, so only you can describe them.", - "rightsRequestText": "", - "_comment_rightsRequestText": "REQUIRED. How to exercise data-protection rights and how you handle requests. The page already states that requests are assessed, not automatically granted.", - "supervisoryAuthorityName": "", - "_comment_supervisoryAuthorityName": "REQUIRED. The supervisory authority for complaints, e.g. 'Datatilsynet'.", - "supervisoryAuthorityUrl": "", - "_comment_supervisoryAuthorityUrl": "REQUIRED. http(s) URL only; other schemes are rejected.", - "automatedDecisionMakingText": "", - "_comment_automatedDecisionMakingText": "REQUIRED. Whether automated decision-making or profiling with legal or similarly significant effects is used. For a normal CoreScope deployment this is typically: 'This deployment does not use automated decision-making producing legal or similarly significant effects.'", - "dpoName": "", - "dpoContact": "", - "_comment_dpo": "OPTIONAL. Most community deployments have no data protection officer; leave BOTH blank and the page omits the section entirely. If dpoName is set, dpoContact is REQUIRED - a named DPO the reader cannot reach is an incomplete disclosure, and the whole privacy block is withheld until you add one. A contact without a name is allowed." + "_comment": "Opt-in privacy-notice page at #/privacy. Default off; set enabled=true to publish the page and the nav links that point at it. This flag is the ENTIRE privacy configuration -- the notice itself is a FIXED document in public/privacy.js and is deliberately not operator-editable, so the published wording cannot drift from the notice that was reviewed and signed off. Nothing you can put in config.json adds, removes or rewords a single line of it; changing the notice means editing that file. Older configs may still contain the removed text fields (controllerName, purposesText, retentionText and the rest) -- they are ignored, not rejected. None of this is legal advice, and publishing the page does not by itself make a deployment compliant." } } diff --git a/public/privacy.js b/public/privacy.js index 7bd3803d..8ce31f96 100644 --- a/public/privacy.js +++ b/public/privacy.js @@ -1,245 +1,158 @@ 'use strict'; -// Privacy — opt-in privacy-notice page (#/privacy). Content is driven by the -// operator's `privacy` config section, surfaced (only when enabled AND -// complete) through /api/config/client — see PrivacyConfig -// (cmd/server/config.go) and window.MC_PRIVACY (public/roles.js). +// Privacy — the opt-in #/privacy page. // -// This page ships NO default legal text and NO stand-in operator identity. -// The controller, the purposes, the lawful basis, retention, recipients and -// the rest are facts only the operator knows; the server refuses to publish -// the notice unless they are all configured (PrivacyConfig.Validate), so -// this file never has to invent them. Nothing here is legal advice, and -// rendering this page does not by itself make a deployment compliant. +// The notice is a FIXED document: DOC below is the whole of it, and it is +// the only content this page can ever show. Config decides WHETHER the page +// is published (privacy.enabled, see PrivacyConfig in cmd/server/config.go +// and window.MC_PRIVACY in public/roles.js) and nothing else. No operator +// value — current, stale or newly added — reaches the DOM, so the visible +// text cannot drift from the notice that was signed off. // -// SECURITY: every operator-supplied value is inserted as TEXT, never as -// markup. Values reach the DOM through txt() (escapeHtml) or, for the one -// href we emit, through mailtoHref()/safeUrl() + escapeHtml. Config fields -// are plain text by contract; this file enforces that rather than trusting -// it. +// SECURITY: DOC is code, never config. Every text run goes through +// escapeHtml, and the single link's href is a compile-time constant that is +// re-checked against SAFE_URL_RE at render time, so only an absolute +// http(s) URL can ever become an href. The renderer emits a closed set of +// tags (h2/p/ul/li/strong/br/a) and has no path that copies input into +// markup. (function () { - // Phosphor icons, not emoji — see issue #1648. New files start clean. - function phIcon(name) { - return ''; - } - - // txt() is the ONLY way operator config becomes page content. - function txt(v) { - return escapeHtml(String(v == null ? '' : v).trim()); - } - - // Operator text may legitimately contain blank-line-separated paragraphs. - // Split on the ESCAPED value so no markup can be assembled from config. - function paras(v) { - var s = txt(v); - if (!s) return ''; - return s.split(/\n\s*\n/).map(function (p) { - return '

' + p.replace(/\n/g, '
') + '

'; - }).join(''); - } - - // mailtoHref builds a mailto: URL that cannot be turned into a header or - // query injection by a malformed config value. The server already - // validates the address conservatively; this is the render-side belt: - // percent-encode everything, then put "@" back so the href stays readable. - // CR/LF -> %0D%0A, "?" -> %3F, "&" -> %26, quotes and spaces likewise, so - // no extra mailto header (?subject=, &cc=) can be smuggled in. - function mailtoHref(email) { - return 'mailto:' + encodeURIComponent(String(email == null ? '' : email).trim()).replace(/%40/g, '@'); + // Inline run constructors. The tuple shapes are the ONLY thing render() + // understands; anything else is dropped rather than emitted. + function t(s) { return ['t', s]; } // plain text + function b(s) { return ['b', s]; } // **bold** + function a(label, href) { return ['a', label, href]; } + var BR = ['br']; // markdown hard line break + + // Absolute http(s) only — belt-and-braces around a constant, so a future + // edit to DOC cannot smuggle javascript:/data: into an href. + var SAFE_URL_RE = /^https?:\/\/[^\s"'<>]+$/; + + // ── The notice. Authoritative text; do not reword, extend or trim. ── + var DOC = [ + ['h', "WHO WE ARE"], + ['p', [ + t("meshview.dk is a non-commercial community service that visualises the Danish "), + a("MeshCore", "https://meshcore.io"), + t(" LoRa mesh network. It runs the open-source CoreScope analyzer. The data controller is:"), + ]], + ['p', [ + b("The operator of meshview.dk"), + BR, + t("Contact: "), + b("kontakt@meshview.dk"), + ]], + ['h', "WHAT THIS SITE DOES"], + ['p', [ + t("Volunteer-run observer nodes listen to MeshCore radio traffic and forward the packets they hear to this site over MQTT. The site displays a live map and analysis of the network so that node operators and the community can see coverage, diagnose problems, and keep the mesh healthy."), + ]], + ['h', "WHAT DATA WE PROCESS"], + ['p', [ + t("All data originates from radio packets that MeshCore devices broadcast themselves:"), + ]], + ['ul', [ + [ + b("Node adverts"), + t(": node name, role, public key, and the GPS position the node is configured to advertise. Node names are chosen by their operators and may contain a personal handle or name; an advertised position may reveal where the operator lives."), + ], + [ + b("Packet metadata"), + t(": timestamps, packet types, routing paths, hop counts, and signal measurements (SNR/RSSI) as heard by observers."), + ], + [ + b("Node telemetry"), + t(": values a node chooses to broadcast, such as battery level and uptime."), + ], + [ + b("Public channel messages"), + t(": messages sent on well-known public channels (whose encryption keys are community knowledge) are decoded and shown, including the sender's node name and timestamp. Direct (private) messages are end-to-end encrypted and are never decrypted or displayed."), + ], + ]], + ['p', [ + b("Website visitors"), + t(": the site uses no analytics, tracking, or advertising cookies. [Our web server keeps standard technical logs, including IP addresses, for a short period for security and abuse prevention.]"), + ]], + ['h', "WHY, AND ON WHAT LEGAL BASIS"], + ['p', [ + t("We process this data under "), + b("legitimate interest"), + t(" (GDPR Art. 6(1)(f)): operating, mapping, and troubleshooting a community radio network — the same purpose for which node operators broadcast this information in the first place. The data shown is limited to what devices already transmit openly over the air, and an easy opt-out exists (below)."), + ]], + ['h', "HOW LONG WE KEEP IT"], + ['p', [ + t("Packet data, telemetry, and decoded public-channel messages are kept "), + b("indefinitely"), + t(", as a historical archive used for long-term network analysis (coverage trends, node health over time). We periodically review the archive and delete data that is no longer needed for that purpose. The node directory and map reflect the "), + b("current"), + t(" state of the network; nodes that stop advertising disappear from the live view, though their historical packets remain in the archive."), + ]], + ['h', "WHO CAN SEE IT, AND WHO WE SHARE IT WITH"], + ['p', [ + t("The site is publicly accessible, so anything displayed here can be seen by anyone. We do not sell data or share it with third parties, apart from the hosting provider that technically operates the server [hosted within the EU/EEA]."), + ]], + ['h', "A NOTE ON PUBLIC CHANNELS"], + ['p', [ + t("Public MeshCore channels are receivable and readable by anyone with a radio. Please do not send personal information over them — this site, like any other listener, will pick it up and keep it in the archive."), + ]], + ]; + + function inline(runs) { + var out = ''; + for (var i = 0; i < runs.length; i++) { + var r = runs[i]; + if (r[0] === 't') { out += escapeHtml(r[1]); } + else if (r[0] === 'b') { out += '' + escapeHtml(r[1]) + ''; } + else if (r[0] === 'br') { out += '
'; } + else if (r[0] === 'a') { + // An unsafe href is never emitted: the label degrades to plain text. + out += SAFE_URL_RE.test(r[2]) + ? '' + escapeHtml(r[1]) + '' + : escapeHtml(r[1]); + } + } + return out; } - // safeUrl returns the value only when it is an absolute http(s) URL, so a - // javascript:/data: value can never reach an href. The server validates - // this too (isSafeHTTPURL); belt-and-braces for stale caches. - function safeUrl(u) { - var s = String(u == null ? '' : u).trim(); - return /^https?:\/\/[^\s<>"]+$/i.test(s) ? s : ''; + function block(node) { + if (node[0] === 'h') { return '

' + escapeHtml(node[1]) + '

'; } + if (node[0] === 'p') { return '

' + inline(node[1]) + '

'; } + if (node[0] === 'ul') { + var items = ''; + for (var i = 0; i < node[1].length; i++) { items += '
  • ' + inline(node[1][i]) + '
  • '; } + return ''; + } + return ''; } - function section(icon, title, bodyHtml) { - return '

    ' + phIcon(icon) + ' ' + title + '

    ' + bodyHtml; + function renderNotice(container) { + var html = ''; + for (var i = 0; i < DOC.length; i++) { html += block(DOC[i]); } + container.innerHTML = '
    ' + html + '
    '; } + // Deployments that have not opted in show no notice at all — and, since + // the notice is not theirs, no heading or fragment of it either. function renderDisabled(container) { container.innerHTML = '
    ' + - '

    ' + phIcon('lock') + ' Privacy Notice

    ' + '

    This deployment has not published a privacy notice.

    ' + '

    Back to Home

    ' + '
    '; } - function render(container, cfg) { - // No fallbacks anywhere: the server does not publish the block unless - // every required field is present, so reaching render() means they are. - var controller = txt(cfg.controllerName); - var rawEmail = String(cfg.contactEmail || '').trim(); - var email = txt(rawEmail); - var emailHref = escapeHtml(mailtoHref(rawEmail)); - var contactInline = rawEmail ? '' + email + '' : email; - - var html = - '
    ' + - '

    ' + phIcon('lock') + ' Privacy Notice

    ' + - '

    ' + - 'Effective date: ' + txt(cfg.effectiveDate) + '
    ' + - 'Data controller: ' + controller + '
    ' + - 'Privacy contact: ' + contactInline + - '

    ' + - '

    This CoreScope deployment provides status information and analysis for a ' + - 'community-operated MeshCore radio network. It receives packet and reception data ' + - 'from participating observer nodes and makes selected information available through ' + - 'this website and its API.

    '; - - if (txt(cfg.dpoName) || txt(cfg.dpoContact)) { - html += section('user-circle', 'Data protection officer', - '

    ' + [txt(cfg.dpoName), txt(cfg.dpoContact)].filter(Boolean).join('
    ') + '

    '); - } - - // What data is processed. This list describes CoreScope's actual - // pipeline: radio traffic AND the reception/observer metadata CoreScope - // itself produces, plus derived analytics. Phrased as "may process, - // depending on configuration" because several categories are opt-in. - html += section('broadcast', 'What data this site processes', - '

    Depending on this deployment’s configuration, CoreScope may process:

    ' + - '
      ' + - '
    • Node information advertised over the radio network: public keys, node names, roles and advertised positions.
    • ' + - '
    • Packet metadata: timestamps, packet types, routing paths and hop information.
    • ' + - '
    • Reception metadata produced by observer nodes: which observer received a packet, and the associated SNR/RSSI measurements.
    • ' + - '
    • Observer identity, status and operational metrics.
    • ' + - '
    • Derived information generated by this site: distances, routes, coverage estimates, node health and network statistics.
    • ' + - '
    • Optional mobile client reception data, which may include the reception position reported by the client.
    • ' + - '
    • Encrypted group or channel packets, stored as packet records.
    • ' + - '
    • Message content from channels whose keys are known to this deployment — see below.
    • ' + - '
    ' + - '

    A node name or advertised position may identify or reveal information about its ' + - 'operator. Do not include personal information in node names, positions or channel ' + - 'messages unless you intend it to be publicly visible.

    '); - - html += section('target', 'Purpose of processing', paras(cfg.purposesText)); - - // Legal basis: the operator's structured choice plus their own wording. - // The software never asserts a basis and never infers one from prose. - var basisLabel = { - consent: 'Consent', - contract: 'Performance of a contract', - legal_obligation: 'Legal obligation', - vital_interests: 'Vital interests', - public_task: 'Public task', - legitimate_interests: 'Legitimate interests', - }[String(cfg.legalBasisType || '').trim()] || txt(cfg.legalBasisType); - var basisBody = '

    ' + escapeHtml(basisLabel) + '

    ' + paras(cfg.legalBasisText); - if (String(cfg.legalBasisType || '').trim() === 'legitimate_interests') { - basisBody += paras(cfg.legitimateInterestsText); - } - basisBody += '

    The lawful basis and its description are stated by the operator of this deployment.

    '; - html += section('scales', 'Legal basis', basisBody); - - html += section('arrow-down', 'Sources of the data', paras(cfg.dataSourcesText)); - html += section('users', 'Who can receive the data', paras(cfg.recipientsText)); - - html += section('clock', 'Retention', - paras(cfg.retentionText) + - '

    Different categories may have different retention rules. Moving a node ' + - 'to an inactive-node table, marking an observer inactive or hiding a node from ' + - 'dashboard and API views does not necessarily delete its historical packet or ' + - 'observation data.

    '); - - // Messages. Verified against the code: only group/channel payloads are - // ever decrypted, and only with keys this deployment holds. Direct - // message CONTENT is not decrypted -- but the surrounding metadata is - // still processed. Stored ciphertext is retained, so a packet that is - // unreadable today could be decoded later if a key becomes available. - html += section('chats', 'Channel and direct messages', - '

    Messages on channels whose keys are available to this deployment may be decoded ' + - 'and published. Encrypted group or channel packets are stored as received, so a ' + - 'packet that cannot be read today may become readable later if a corresponding key ' + - 'becomes available to this deployment.

    ' + - '

    Direct-message content is not decrypted by CoreScope. Packet, route and reception ' + - 'metadata associated with direct messages — such as timestamps, packet type, routing ' + - 'path and which observers received it — may nevertheless be stored and displayed.

    ' + - '

    Avoid transmitting personal or sensitive information on shared channels.

    '); - - html += section('browser', 'Storage in your browser', paras(cfg.browserStorageText)); - html += section('file-text', 'Server and proxy logs', paras(cfg.serverLogsText)); - html += section('globe', 'External services', paras(cfg.thirdPartyServicesText)); - html += section('airplane', 'International transfers', paras(cfg.internationalTransfersText)); - - // Self-service hiding: only offered when this deployment actually has - // hide prefixes configured, and always described as a visibility filter - // -- never as removal from the radio network or as deletion. - var prefixes = Array.isArray(cfg.hiddenNamePrefixes) ? cfg.hiddenNamePrefixes.filter(function (x) { - return typeof x === 'string' && x.trim() !== ''; - }) : []; - var hiddenBody; - if (prefixes.length) { - var rendered = prefixes.map(function (x) { - return '' + escapeHtml(x) + ''; - }).join(prefixes.length === 2 ? ' or ' : ', '); - hiddenBody = - '

    This deployment hides nodes whose name begins with ' + rendered + - ' from selected dashboard and API views.

    '; - } else { - hiddenBody = - '

    This deployment has no name-prefix hiding configured.

    '; - } - hiddenBody += - '

    Prefix hiding is a visibility filter on this site’s dashboard and API only. ' + - 'It does not remove a node from the radio network — other listeners still receive its ' + - 'transmissions — and it does not by itself delete stored packets, observations or ' + - 'derived analytics.

    '; - html += section('eye-slash', 'Hidden nodes', hiddenBody); - - // Rights. Deliberately conditional: requests are assessed, not - // automatically granted, and the page must not promise unconditional - // hiding or erasure. - var authorityUrl = safeUrl(cfg.supervisoryAuthorityUrl); - var authorityName = txt(cfg.supervisoryAuthorityName); - html += section('scroll', 'Your rights', - '

    Depending on the circumstances and applicable data-protection law, you may have ' + - 'rights to request access, correction, erasure, restriction, data portability, or to ' + - 'object to processing.

    ' + - '

    Requests are not automatically granted in every situation. The operator will assess ' + - 'each request under applicable law.

    ' + - paras(cfg.rightsRequestText) + - (rawEmail ? '

    Send privacy requests to ' + contactInline + '.

    ' : '') + - '

    If you are dissatisfied with the handling of your data, you may complain to:
    ' + - '' + authorityName + '' + - (authorityUrl ? '
    ' + escapeHtml(authorityUrl) + '' : '') + - '

    '); - - html += section('cpu', 'Automated decision-making', paras(cfg.automatedDecisionMakingText)); - - html += section('info', 'Changes to this notice', - '

    This notice may be updated when the deployment, its configuration or its ' + - 'data-processing practices change. The effective date shown at the top identifies the ' + - 'current version.

    '); - - html += '
    '; - container.innerHTML = html; - } - registerPage('privacy', { init: function (container) { - container.innerHTML = - '

    ' + phIcon('lock') + ' Privacy Notice

    ' + - '

    Loading…

    '; + container.innerHTML = '

    Loading…

    '; // Config arrives via roles.js's /api/config/client fetch. Gate on // MeshConfigReady so a direct deep-link to #/privacy renders after - // the config (and window.MC_PRIVACY) is actually there. + // window.MC_PRIVACY is actually there. var ready = (window.MeshConfigReady && typeof window.MeshConfigReady.then === 'function') ? window.MeshConfigReady : Promise.resolve(); return ready.then(function () { var cfg = window.MC_PRIVACY; - // Server-side gate already omits the section unless enabled AND - // complete; the explicit checks are belt-and-braces for stale - // caches. controllerName has no fallback, so its absence alone is - // enough to withhold the notice. - if (!cfg || cfg.enabled === false) { renderDisabled(container); return; } - if (!String(cfg.controllerName || '').trim()) { renderDisabled(container); return; } - render(container, cfg); + if (!cfg || !cfg.enabled) { renderDisabled(container); return; } + renderNotice(container); }).catch(function () { renderDisabled(container); }); }, destroy: function () {} diff --git a/test-privacy-page.js b/test-privacy-page.js index cf942643..dd69d059 100644 --- a/test-privacy-page.js +++ b/test-privacy-page.js @@ -45,36 +45,121 @@ assert(escMatch, 'could not extract escapeHtml from public/app.js'); // Mirrors validPrivacy() in cmd/server/privacy_config_test.go: the server // only ever publishes a block that passed Validate(), so the page's fixture // is a COMPLETE one. REQUIRED_FIELDS drives the "blank one at a time" tests. -const REQUIRED_FIELDS = [ - 'controllerName', 'contactEmail', 'effectiveDate', 'purposesText', - 'legalBasisType', 'legalBasisText', 'retentionText', 'recipientsText', - 'dataSourcesText', 'thirdPartyServicesText', 'internationalTransfersText', - 'browserStorageText', 'serverLogsText', 'rightsRequestText', - 'supervisoryAuthorityName', 'supervisoryAuthorityUrl', - 'automatedDecisionMakingText', -]; - -const VALID = { +// ─────────────────────── the authoritative notice ─────────────────────── +// +// This is the notice, verbatim, in the Markdown it was signed off in. It is +// the single source of truth for the golden test below: the rendered page, +// normalised back to visible text, must equal this EXACTLY -- so extra, +// missing or reworded text all fail, not just missing text. +// +// String.raw is load-bearing: a plain template literal would let JS eat the +// Markdown escapes (\. \@) and the trailing-backslash hard line break before +// the test ever parsed them, quietly weakening the comparison. +const AUTHORITATIVE = String.raw`## WHO WE ARE + +meshview\.dk is a non-commercial community service that visualises the Danish [MeshCore](https://meshcore.io) LoRa mesh network. It runs the open-source CoreScope analyzer. The data controller is: + +**The operator of meshview\.dk**\ +Contact: **kontakt\@meshview\.dk** + +## WHAT THIS SITE DOES + +Volunteer-run observer nodes listen to MeshCore radio traffic and forward the packets they hear to this site over MQTT. The site displays a live map and analysis of the network so that node operators and the community can see coverage, diagnose problems, and keep the mesh healthy. + +## WHAT DATA WE PROCESS + +All data originates from radio packets that MeshCore devices broadcast themselves: + +- **Node adverts**: node name, role, public key, and the GPS position the node is configured to advertise. Node names are chosen by their operators and may contain a personal handle or name; an advertised position may reveal where the operator lives. +- **Packet metadata**: timestamps, packet types, routing paths, hop counts, and signal measurements (SNR/RSSI) as heard by observers. +- **Node telemetry**: values a node chooses to broadcast, such as battery level and uptime. +- **Public channel messages**: messages sent on well-known public channels (whose encryption keys are community knowledge) are decoded and shown, including the sender's node name and timestamp. Direct (private) messages are end-to-end encrypted and are never decrypted or displayed. + +**Website visitors**: the site uses no analytics, tracking, or advertising cookies. [Our web server keeps standard technical logs, including IP addresses, for a short period for security and abuse prevention.] + +## WHY, AND ON WHAT LEGAL BASIS + +We process this data under **legitimate interest** (GDPR Art. 6(1)(f)): operating, mapping, and troubleshooting a community radio network — the same purpose for which node operators broadcast this information in the first place. The data shown is limited to what devices already transmit openly over the air, and an easy opt-out exists (below). + +## HOW LONG WE KEEP IT + +Packet data, telemetry, and decoded public-channel messages are kept **indefinitely**, as a historical archive used for long-term network analysis (coverage trends, node health over time). We periodically review the archive and delete data that is no longer needed for that purpose. The node directory and map reflect the **current** state of the network; nodes that stop advertising disappear from the live view, though their historical packets remain in the archive. + +## WHO CAN SEE IT, AND WHO WE SHARE IT WITH + +The site is publicly accessible, so anything displayed here can be seen by anyone. We do not sell data or share it with third parties, apart from the hosting provider that technically operates the server [hosted within the EU/EEA]. + +## A NOTE ON PUBLIC CHANNELS + +Public MeshCore channels are receivable and readable by anyone with a radio. Please do not send personal information over them — this site, like any other listener, will pick it up and keep it in the archive.`; + +// The page is opt-in only; there is no operator content to configure. +const ENABLED = { enabled: true }; + +// A pre-removal config.json: every operator-text key the model used to +// carry. None of it may reach the page. +const STALE_CONFIG = { enabled: true, - controllerName: 'Example Mesh Community', - contactEmail: 'privacy@example.org', - effectiveDate: '2026-09-01', - purposesText: 'Operating and troubleshooting the community network.', - legalBasisType: 'public_task', - legalBasisText: 'Processing is necessary for our community task.', - retentionText: 'Packet data is deleted after 30 days.', - recipientsText: 'Website and API visitors; our hosting provider.', - dataSourcesText: 'Observer nodes, radio packets and derived measurements.', - thirdPartyServicesText: 'Map tiles are loaded from a third-party provider.', - internationalTransfersText: 'No transfers outside the EU/EEA.', - browserStorageText: 'Interface settings are stored in your browser.', - serverLogsText: 'Our proxy keeps access logs for 14 days.', - rightsRequestText: 'Email us and we will assess your request.', - supervisoryAuthorityName: 'Datatilsynet', - supervisoryAuthorityUrl: 'https://www.datatilsynet.dk', - automatedDecisionMakingText: 'No automated decision-making is used.', + controllerName: 'STALE-controller', + contactEmail: 'stale@example.invalid', + effectiveDate: 'STALE-date', + purposesText: 'STALE-purposes', + legalBasisType: 'legitimate_interests', + legalBasisText: 'STALE-basis', + legitimateInterestsText: 'STALE-interests', + retentionText: 'STALE-retention', + recipientsText: 'STALE-recipients', + dataSourcesText: 'STALE-sources', + thirdPartyServicesText: 'STALE-third-party', + internationalTransfersText: 'STALE-transfers', + browserStorageText: 'STALE-storage', + serverLogsText: 'STALE-logs', + automatedDecisionMakingText: 'STALE-automated', + rightsRequestText: 'STALE-rights', + supervisoryAuthorityName: 'STALE-authority', + supervisoryAuthorityUrl: 'https://stale.example', + dpoName: 'STALE-dpo', + dpoContact: 'STALE-dpo-contact', + hiddenNamePrefixes: ['STALE-prefix'], }; -const withField = (k, v) => Object.assign({}, VALID, { [k]: v }); + +// One normaliser, applied to BOTH sides, so the comparison is about words +// and order -- never about indentation or how a line happens to wrap. +const normalize = (s) => s.split('\n').map((l) => l.trim()).filter(Boolean).join('\n'); + +// Markdown inline syntax -> the text a reader actually sees. +const inlineText = (s) => s + .replace(/\[([^\]]+)\]\([^)\s]+\)/g, '$1') // [label](url) -> label + .replace(/\*\*([\s\S]+?)\*\*/g, '$1') // **bold** -> bold + .replace(/\\([\s\S])/g, '$1'); // \. \@ -> . @ + +// The authoritative Markdown, reduced to the visible text it specifies. +function expectedVisibleText(md) { + const out = []; + md.trim().split(/\n\s*\n/).forEach((chunk) => { + chunk = chunk.replace(/^\n+|\n+$/g, ''); + if (chunk.startsWith('## ')) { out.push(chunk.slice(3).trim()); return; } + const lines = chunk.split('\n'); + if (lines.every((l) => l.trim().startsWith('- '))) { + lines.forEach((l) => out.push(inlineText(l.trim().slice(2)))); + return; + } + // A trailing backslash is a Markdown hard line break. + out.push(chunk.split(/\\\n/).map((part) => inlineText(part.replace(/\n/g, ' '))).join('\n')); + }); + return normalize(out.join('\n')); +} + +// Rendered markup -> the visible text. Block ends and
    become line +// breaks; everything else is tags, which carry no words. +function visibleText(html) { + return normalize(html + .replace(//g, '\n') + .replace(/<\/(h1|h2|h3|h4|p|li|div)>/g, '\n') + .replace(/<[^>]+>/g, '') + .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + .replace(/"/g, '"').replace(/'/g, "'")); +} // ───────────────────────── privacy.js render sandbox ───────────────────────── @@ -291,7 +376,7 @@ const sheetLinks = (doc) => doc.querySelectorAll('[data-bottom-nav-more-route]') }); await test('enabled:false renders the not-published state (stale-cache guard)', async () => { - const html = await renderWith({ enabled: false, operatorName: 'X' }); + const html = await renderWith({ enabled: false }); assert(html.includes('has not published a privacy notice'), 'expected disabled state'); }); @@ -302,10 +387,10 @@ const sheetLinks = (doc) => doc.querySelectorAll('[data-bottom-nav-more-route]') const container = { innerHTML: '' }; const p = pages.privacy.init(container); assert(container.innerHTML.includes('Loading'), 'should show a loading state while config is in flight'); - ctx.window.MC_PRIVACY = VALID; // config lands + ctx.window.MC_PRIVACY = ENABLED; // config lands settle({}); await p; - assert(container.innerHTML.includes('Example Mesh Community'), + assert(container.innerHTML.includes('WHO WE ARE'), 'must render the notice once config resolves, not the disabled state'); }); @@ -316,266 +401,173 @@ const sheetLinks = (doc) => doc.querySelectorAll('[data-bottom-nav-more-route]') assert(html.includes('has not published a privacy notice'), 'expected disabled state on config failure'); }); - // ─── content correctness: no invented legal text ────────────────────────── - - await test('renders every configured field', async () => { - const html = await renderWith(VALID); - assert(html.includes('Example Mesh Community'), 'controller name missing'); - assert(html.includes('href="mailto:privacy@example.org"'), 'mailto link missing'); - assert(html.includes('2026-09-01'), 'effective date missing'); - for (const f of REQUIRED_FIELDS) { - const v = VALID[f]; - if (f === 'legalBasisType') continue; // rendered as a friendly label - assert(html.includes(v), 'field ' + f + ' (' + v + ') missing from the page'); - } - assert(html.includes('Public task'), 'legalBasisType should render as a readable label'); - }); - - await test('each REQUIRED field blanked individually → notice withheld', async () => { - for (const f of REQUIRED_FIELDS) { - const html = await renderWith(withField(f, '')); - if (f === 'controllerName') { - assert(html.includes('has not published a privacy notice'), - 'blank controllerName must withhold the notice client-side too'); - } else { - // The server withholds these; the page must at minimum never - // invent a value for them. - assert(!html.includes('undefined') && !html.includes('null'), - 'blank ' + f + ' must not leak a placeholder into the page'); - } - } - }); - - await test('legitimateInterestsText is rendered for the legitimate_interests basis', async () => { - const cfg = Object.assign({}, VALID, { - legalBasisType: 'legitimate_interests', - legitimateInterestsText: 'Keeping the community mesh operable; see our assessment.', - }); - const html = await renderWith(cfg); - assert(html.includes('Legitimate interests'), 'basis label missing'); - assert(html.includes('Keeping the community mesh operable'), 'the specific interests must be shown'); - }); - - await test('optional DPO section appears only when configured', async () => { - const without = await renderWith(VALID); - assert(!without.includes('Data protection officer'), 'no DPO section when unconfigured'); - // Both fields together: the server refuses to publish a name without a - // contact route (PrivacyConfig.Validate), so that pairing is the only - // shape the page can actually receive with a name in it. - const withDpo = await renderWith(Object.assign({}, VALID, { - dpoName: 'Jane Doe', dpoContact: 'dpo@example.org', - })); - assert(withDpo.includes('Data protection officer') && withDpo.includes('Jane Doe') && - withDpo.includes('dpo@example.org'), 'DPO section should render when configured'); - }); - - await test('DPO contact alone still renders the section', async () => { - // A contact without a name is a valid published shape: it tells the - // reader where to write even though no individual is named. - const html = await renderWith(Object.assign({}, VALID, { dpoContact: 'dpo@example.org' })); - assert(html.includes('Data protection officer') && html.includes('dpo@example.org'), - 'a contact-only DPO block should still render'); - }); - - await test('ships NO default retention paragraph', async () => { - const html = await renderWith(VALID); - assert(!html.includes('historical archive'), - 'the old fabricated default retention paragraph must be gone'); - const bare = await renderWith({ enabled: true, contactEmail: 'a@b.co' }); - assert(!bare.includes('historical archive'), - 'a config missing retentionText must not fall back to invented text'); - }); - - await test('does NOT assert a legal basis on the operator behalf', async () => { - const html = await renderWith(VALID); - assert(!/processed under legitimate interest<\/strong>/.test(html), - 'the software must not hardcode legitimate interest'); - assert(html.includes('stated by the operator'), - 'the page must attribute the legal basis to the operator'); - }); - - await test('controllerName has NO fallback — blank withholds the notice', async () => { - for (const v of ['', ' ', undefined, null]) { - const html = await renderWith(withField('controllerName', v)); - assert(html.includes('has not published a privacy notice'), - 'blank controllerName (' + JSON.stringify(v) + ') must not render a notice'); + // ─── GOLDEN: the page is the notice, exactly ───────────────────────────── + + await test('GOLDEN: rendered visible text equals the authoritative notice, exactly', async () => { + const actual = visibleText(await renderWith(ENABLED)); + const expected = expectedVisibleText(AUTHORITATIVE); + if (actual !== expected) { + // Report the first divergent line so a reword is obvious, not a wall. + const a = actual.split('\n'), e = expected.split('\n'); + let i = 0; + while (i < Math.max(a.length, e.length) && a[i] === e[i]) i++; + assert.fail( + `visible text diverges at line ${i + 1} of ${e.length}\n` + + ` expected: ${JSON.stringify(e[i])}\n` + + ` actual : ${JSON.stringify(a[i])}\n` + + ` (lines: expected ${e.length}, actual ${a.length})`); } + assert.strictEqual(actual, expected); + }); + + await test('GOLDEN: no config value can add, remove or reword a single line', async () => { + // Same assertion, but driven by a full pre-removal config. Byte-identical + // output proves the page reads nothing but the enabled flag. + const clean = visibleText(await renderWith(ENABLED)); + const stale = visibleText(await renderWith(STALE_CONFIG)); + assert.strictEqual(stale, clean, 'a stale config changed the rendered notice'); + assert(!/STALE-/.test(stale), 'a stale config value reached the page'); + assert.strictEqual(stale, expectedVisibleText(AUTHORITATIVE)); + }); + + // ─── structure of the notice ────────────────────────────────────────────── + + await test('exactly 7 headings, in the authoritative order', async () => { + const html = await renderWith(ENABLED); + const headings = (html.match(/]*>[\s\S]*?<\/h[1-6]>/g) || []) + .map((h) => h.replace(/<[^>]*>/g, '').trim()); + assert.deepStrictEqual(headings, [ + 'WHO WE ARE', + 'WHAT THIS SITE DOES', + 'WHAT DATA WE PROCESS', + 'WHY, AND ON WHAT LEGAL BASIS', + 'HOW LONG WE KEEP IT', + 'WHO CAN SEE IT, AND WHO WE SHARE IT WITH', + 'A NOTE ON PUBLIC CHANNELS', + ]); + assert.strictEqual(headings.length, 7, 'expected exactly 7 headings'); + }); + + await test('exactly 4 data points, in order, each with its bold lead-in', async () => { + const html = await renderWith(ENABLED); + const items = (html.match(/
  • [\s\S]*?<\/li>/g) || []); + assert.strictEqual(items.length, 4, 'expected exactly 4 list items'); + const leads = items.map((li) => (li.match(/([^<]*)<\/strong>/) || [])[1]); + assert.deepStrictEqual(leads, + ['Node adverts', 'Packet metadata', 'Node telemetry', 'Public channel messages']); + assert.strictEqual((html.match(/
      /g) || []).length, 1, 'expected exactly one list'); + }); + + await test('the MeshCore link points at the authoritative URL and is safe', async () => { + const html = await renderWith(ENABLED); + const links = html.match(/]*>/g) || []; + assert.strictEqual(links.length, 1, 'the notice has exactly one link'); + assert(/href="https:\/\/meshcore\.io"/.test(html), 'MeshCore href is wrong or missing'); + assert(/>MeshCore<\/a>/.test(html), 'the link text must be MeshCore'); + assert(/rel="noopener noreferrer"/.test(html), 'external link needs rel=noopener noreferrer'); + assert(!/href="(?!https?:)/i.test(html), 'only http(s) hrefs may be emitted'); + assert(!/javascript:|data:/i.test(html), 'no javascript:/data: URL anywhere'); + }); + + await test('meshview.dk and kontakt@meshview.dk render without backslashes', async () => { + const text = visibleText(await renderWith(ENABLED)); + assert(!text.includes('\\'), 'a Markdown escape leaked into the visible text'); + assert(text.includes('meshview.dk is a non-commercial community service'), + 'meshview.dk must render as plain text in the opening sentence'); + assert(text.includes('The operator of meshview.dk'), 'controller line must render unescaped'); + assert(text.includes('Contact: kontakt@meshview.dk'), 'contact must render unescaped'); + assert(!text.includes('meshview\\.dk'), 'escaped domain leaked'); + assert(!text.includes('kontakt\\@'), 'escaped address leaked'); + // The contact is bold text, not a mailto link. + const html = await renderWith(ENABLED); + assert(html.includes('kontakt@meshview.dk'), 'contact must be bold text'); + assert(!/mailto:/.test(html), 'the notice specifies no mailto link'); + }); + + await test('both square-bracket passages survive verbatim, brackets included', async () => { + const text = visibleText(await renderWith(ENABLED)); + assert(text.includes('[Our web server keeps standard technical logs, including IP addresses, ' + + 'for a short period for security and abuse prevention.]'), + 'the server-log passage must keep its square brackets'); + assert(text.includes('[hosted within the EU/EEA]'), + 'the hosting passage must keep its square brackets'); + }); + + await test('no extra privacy sections: every removed section is gone', async () => { + const text = visibleText(await renderWith(STALE_CONFIG)); + [ + 'Privacy Notice', 'Effective date', 'Data controller', 'Privacy contact', + 'What data this site processes', 'Purpose of processing', 'Legal basis', + 'Sources of the data', 'Who can receive the data', 'Retention', + 'Channel and direct messages', 'Storage in your browser', + 'Server and proxy logs', 'External services', 'International transfers', + 'Hidden nodes', 'Your rights', 'Automated decision-making', + 'Changes to this notice', 'Data protection officer', 'Datatilsynet', + 'supervisory authority', 'CoreScope deployment provides status information', + 'not automatically granted', 'Send privacy requests to', + ].forEach((s) => assert(!text.includes(s), 'removed section still rendered: ' + s)); + // Structural: exactly the blocks the notice specifies, nothing spare. + const html = await renderWith(ENABLED); + assert.strictEqual((html.match(/

      /g) || []).length, 9, 'exactly 9 paragraphs'); + assert(!/

      \s*<\/p>/.test(html), 'empty paragraph left behind'); + assert(!/]*>\s*<\/h2>/.test(html), 'empty heading left behind'); + assert(!/

        \s*<\/ul>|
      • \s*<\/li>/.test(html), 'empty list left behind'); + assert(!/]*>\s*<\/div>/.test(html), 'empty wrapper left behind'); + assert(!/ { const src = fs.readFileSync('public/privacy.js', 'utf8'); - assert(!src.includes('The operator of this site'), - 'the generic operator fallback must be gone from the source entirely'); - assert(!/DEFAULT_OPERATOR/.test(src), 'no default-operator constant should remain'); - }); - - // ─── hidden-name prefixes: real list, or silence ────────────────────────── - - await test('names the ACTUAL configured hidden prefixes', async () => { - const html = await renderWith(Object.assign({}, VALID, { hiddenNamePrefixes: ['##'] })); - assert(html.includes('hides nodes whose name begins with'), 'the real prefix should be described'); - assert(html.includes('##'), 'the real configured prefix must be shown'); - }); - - await test('multiple prefixes are all listed', async () => { - const html = await renderWith(Object.assign({}, VALID, { hiddenNamePrefixes: ['##', 'zz'] })); - assert(html.includes('##') && html.includes('zz'), 'both prefixes must be shown'); + const reads = src.match(/cfg\.[A-Za-z_$][\w$]*/g) || []; + assert.deepStrictEqual([...new Set(reads)], ['cfg.enabled'], + 'privacy.js must read only cfg.enabled, got: ' + [...new Set(reads)].join(', ')); + assert(!/MC_PRIVACY\s*\.\s*[A-Za-z]/.test(src), 'no direct field read off MC_PRIVACY'); }); - await test('no configured prefixes → no self-service promise', async () => { - const html = await renderWith(VALID); - assert(!html.includes('hides nodes whose name begins with'), - 'must not describe prefix hiding when none is configured'); - assert(html.includes('no name-prefix hiding configured'), 'must say so explicitly'); - }); - - await test('does not hardcode the no-entry emoji as a guarantee', async () => { - const src = fs.readFileSync('public/privacy.js', 'utf8'); - assert(!src.includes('0x1F6AB'), 'the hardcoded hidden-prefix character must be gone'); - const html = await renderWith(VALID); - assert(!html.includes(String.fromCodePoint(0x1F6AB)), - 'no hardcoded prefix character should reach the page'); - }); - - await test('explains the real scope of hiding (dashboard/API, not the mesh; history may remain)', async () => { - const html = await renderWith(Object.assign({}, VALID, { hiddenNamePrefixes: ['##'] })); - assert(/dashboard and API/i.test(html), 'must scope hiding to this site, not the mesh'); - assert(/does not remove a node from the radio network/i.test(html), - 'must say the node stays on the radio network'); - assert(/other listeners still receive/i.test(html), 'must say others still receive it'); - assert(/does not by itself delete stored packets/i.test(html), - 'must be honest that recorded history can persist'); - }); - - // ─── escaping + mailto edge cases ───────────────────────────────────────── - - await test('config values are HTML-escaped (XSS)', async () => { - const html = await renderWith(Object.assign({}, VALID, { + await test('hostile config values cannot inject markup', async () => { + const html = await renderWith(Object.assign({}, STALE_CONFIG, { controllerName: '', - contactEmail: '">', - retentionText: 'bold', - legalBasisText: '', purposesText: '

        legitimate interest', 'hardcoded legitimate-interest claim'], - ['GDPR Art. 6(1)(f)', 'hardcoded article citation'], - // over-broad claim about public channels - ['receivable and readable by anyone with a radio', 'over-broad public-channel claim'], - // hardcoded hide character + unconditional promises - ['0x1F6AB', 'hardcoded hide-prefix character'], - ['it will be hidden or removed', 'unconditional hiding/removal promise'], - ['disappears from this site', 'unconditional disappearance promise'], - ]; - for (const [needle, why] of banned) { - assert(!src.includes(needle), 'removed phrasing reappeared (' + why + '): ' + needle); - } - const html = await renderWith(VALID); - for (const [needle, why] of banned) { - assert(!html.includes(needle), 'removed phrasing rendered (' + why + '): ' + needle); - } + ['WHO WE ARE', 'A NOTE ON PUBLIC CHANNELS', 'https://meshcore.io'].forEach((s) => + assert(src.includes(s), 'privacy.js must carry the notice itself: ' + s)); + const ex = JSON.parse(fs.readFileSync('config.example.json', 'utf8')); + assert.deepStrictEqual(Object.keys(ex.privacy), ['enabled', '_comment'], + 'config.example.json privacy block must be the opt-in flag plus its comment'); + ['cmd/server/config.go', 'cmd/server/types.go', 'cmd/server/routes.go'].forEach((f) => { + const go = fs.readFileSync(f, 'utf8'); + ['ControllerName', 'PurposesText', 'RetentionText', 'SupervisoryAuthorityName', 'DPOName'] + .forEach((g) => assert(!go.includes(g), f + ' still references removed privacy field ' + g)); + }); }); - await test('the page states the correct message semantics', async () => { - const html = await renderWith(VALID); - assert(/Direct-message content is not decrypted/i.test(html), - 'must say direct message CONTENT is not decrypted'); - assert(/metadata associated with direct messages/i.test(html), - 'must say direct-message METADATA is still processed'); - assert(/may become readable later if a corresponding key/i.test(html), - 'must say stored ciphertext may be decodable later'); - assert(/channels whose keys are available to this deployment/i.test(html), - 'channel decoding must be scoped to keys this deployment holds'); - }); - - await test('the page names both radio traffic and CoreScope-generated data', async () => { - const html = await renderWith(VALID); - assert(/Reception metadata produced by observer nodes/i.test(html), - 'must name observer-produced reception metadata'); - assert(/Derived information/i.test(html), 'must name derived analytics'); - assert(/Observer identity, status and operational metrics/i.test(html), - 'must name observer metadata'); - }); - - await test('rights section does not promise unconditional erasure', async () => { - const html = await renderWith(VALID); - assert(/not automatically granted/i.test(html), - 'must say requests are assessed, not automatically granted'); - assert(html.includes('Datatilsynet'), 'supervisory authority name missing'); - assert(html.includes('https://www.datatilsynet.dk'), 'supervisory authority URL missing'); - }); - - await test('a complete config renders the page AND enables the nav surfaces', async () => { - const html = await renderWith(VALID); - assert(!html.includes('has not published a privacy notice'), 'complete config must render the notice'); - assert(html.includes('Privacy Notice'), 'heading missing'); - // ...and the same config drives both dynamic nav surfaces. + await test('an enabled config renders the notice AND enables the nav surfaces', async () => { + const html = await renderWith(ENABLED); + assert(!html.includes('has not published a privacy notice'), 'enabled must render the notice'); + assert(html.includes('WHO WE ARE'), 'notice missing'); const d = bootNav('public/nav-drawer.js', { withConfigPromise: true }); const b = bootNav('public/bottom-nav.js', { withConfigPromise: true }); - d.settle({ privacy: VALID }); - b.settle({ privacy: VALID }); + d.settle({ privacy: ENABLED }); + b.settle({ privacy: ENABLED }); await d.tick(); await b.tick(); openMoreSheet(b.doc); - assert(drawerLinks(d.doc).includes('privacy'), 'drawer must show Privacy for a complete config'); - assert(sheetLinks(b.doc).includes('privacy'), 'More sheet must show Privacy for a complete config'); + assert(drawerLinks(d.doc).includes('privacy'), 'drawer must show Privacy when enabled'); + assert(sheetLinks(b.doc).includes('privacy'), 'More sheet must show Privacy when enabled'); }); // ─── navigation lifecycle: nav-drawer ───────────────────────────────────── @@ -584,7 +576,7 @@ const sheetLinks = (doc) => doc.querySelectorAll('[data-bottom-nav-more-route]') const h = bootNav('public/nav-drawer.js', { withConfigPromise: true }); assert(!drawerLinks(h.doc).includes('privacy'), 'no Privacy link before config — nothing is known yet'); - h.settle({ privacy: VALID }); + h.settle({ privacy: ENABLED }); await h.tick(); assert(drawerLinks(h.doc).includes('privacy'), 'the drawer must reconcile once config lands (this was the permanent-omission bug)'); @@ -592,7 +584,7 @@ const sheetLinks = (doc) => doc.querySelectorAll('[data-bottom-nav-more-route]') await test('drawer shows exactly one Privacy link (no duplicates on refresh)', async () => { const h = bootNav('public/nav-drawer.js', { withConfigPromise: true }); - h.settle({ privacy: VALID }); + h.settle({ privacy: ENABLED }); await h.tick(); await h.tick(); const n = drawerLinks(h.doc).filter((r) => r === 'privacy').length; assert.strictEqual(n, 1, 'expected exactly one Privacy link, got ' + n); @@ -640,7 +632,7 @@ const sheetLinks = (doc) => doc.querySelectorAll('[data-bottom-nav-more-route]') openMoreSheet(h.doc); // user is fast; config is not assert(h.doc.getElementById('bottomNavMoreSheet'), 'sheet should be built on open'); assert(!sheetLinks(h.doc).includes('privacy'), 'no Privacy link yet — config has not landed'); - h.settle({ privacy: VALID }); + h.settle({ privacy: ENABLED }); await h.tick(); assert(sheetLinks(h.doc).includes('privacy'), 'an already-open sheet must be reconciled once config lands (this was the race)'); @@ -648,7 +640,7 @@ const sheetLinks = (doc) => doc.querySelectorAll('[data-bottom-nav-more-route]') await test('More sheet opened AFTER config has the Privacy link immediately', async () => { const h = bootNav('public/bottom-nav.js', { withConfigPromise: true }); - h.settle({ privacy: VALID }); + h.settle({ privacy: ENABLED }); await h.tick(); openMoreSheet(h.doc); assert(sheetLinks(h.doc).includes('privacy'), 'sheet built post-config must include Privacy'); @@ -657,7 +649,7 @@ const sheetLinks = (doc) => doc.querySelectorAll('[data-bottom-nav-more-route]') await test('More sheet shows exactly one Privacy link (no duplicates on refresh)', async () => { const h = bootNav('public/bottom-nav.js', { withConfigPromise: true }); openMoreSheet(h.doc); - h.settle({ privacy: VALID }); + h.settle({ privacy: ENABLED }); await h.tick(); await h.tick(); const n = sheetLinks(h.doc).filter((r) => r === 'privacy').length; assert.strictEqual(n, 1, 'expected exactly one Privacy link, got ' + n); @@ -666,7 +658,7 @@ const sheetLinks = (doc) => doc.querySelectorAll('[data-bottom-nav-more-route]') await test('More sheet refresh keeps the separator and dark-mode button intact', async () => { const h = bootNav('public/bottom-nav.js', { withConfigPromise: true }); openMoreSheet(h.doc); - h.settle({ privacy: VALID }); + h.settle({ privacy: ENABLED }); await h.tick(); const sheet = h.doc.getElementById('bottomNavMoreSheet'); assert(sheet, 'sheet should exist'); @@ -690,7 +682,7 @@ const sheetLinks = (doc) => doc.querySelectorAll('[data-bottom-nav-more-route]') await test('More tab is active on #/privacy (after config)', async () => { const h = bootNav('public/bottom-nav.js', { withConfigPromise: true, hash: '#/privacy' }); - h.settle({ privacy: VALID }); + h.settle({ privacy: ENABLED }); await h.tick(); const moreTab = h.doc.querySelector('[data-bottom-nav-tab="more"]'); assert(moreTab, 'more tab should exist'); @@ -721,8 +713,8 @@ const sheetLinks = (doc) => doc.querySelectorAll('[data-bottom-nav-more-route]') for (const enabled of [true, false]) { const d = bootNav('public/nav-drawer.js', { withConfigPromise: true }); const b = bootNav('public/bottom-nav.js', { withConfigPromise: true }); - d.settle({ privacy: enabled ? VALID : null }); - b.settle({ privacy: enabled ? VALID : null }); + d.settle({ privacy: enabled ? ENABLED : null }); + b.settle({ privacy: enabled ? ENABLED : null }); await d.tick(); await b.tick(); openMoreSheet(b.doc); assert.strictEqual(