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, '
') + '
'; }
+ 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 '
' + inline(node[1]) + '
'; } + if (node[0] === 'ul') { + var items = ''; + for (var i = 0; i < node[1].length; i++) { items += 'This deployment has not published a 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('
') + '
Depending on this deployment’s configuration, CoreScope may process:
' + - '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) + '' : '') +
- '
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 += 'Loading…
Loading…