R-415 fixed on hub main (no duplicate/nested/felhom.eu customer domain; R-138 option B); R-762 closed (9202 proven, catalog eec9a0d); R-905 opened (wger static in backups); decision sheet D10; 130 -> 129
gates / gates (push) Successful in 3m55s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-08 10:19:39 +02:00
parent 78121aa475
commit d9147b02de
18 changed files with 818 additions and 9 deletions
+8 -1
View File
@@ -1,9 +1,16 @@
## Unreleased (2026-10-08) — an alarm when a box never backs up off-site because its escrow is pending (R-243; `09` §3 decision 179); a deleted customer's audit rows go after 1 year (R-901; decision 181); the operator's older-recovery-package mail (R-304; decision 183) — ships with tomorrow's hub release
## Unreleased (2026-10-08) — an alarm when a box never backs up off-site because its escrow is pending (R-243; `09` §3 decision 179); a deleted customer's audit rows go after 1 year (R-901; decision 181); the operator's older-recovery-package mail (R-304; decision 183); no duplicate or nested customer domain (R-415, R-138 option B) — ships with tomorrow's hub release
**Operator action on deploy: none.** Expect ONE `offsite_escrow_pending` mail for **Tester 2** on the first sweep after
the deploy: its latest report (2026-10-04) says off-site ON, escrow `pending`, no successful run ever — the state the
operator believes it is in (decision 170).
- **R-415 / R-138 option B (operator ruling 2026-09-14, `01` §7 — every customer has their own domain):** the create and
edit paths refuse a domain that equals, contains or lies under another customer's, or is under `felhom.eu`, BEFORE
anything is generated or provisioned; the form re-renders with the submitted values and one sentence; a store error
refuses too. Label-boundary matching, case-insensitive, trailing dot ignored. `store.DomainConflict`; test
`TestR415_CreateAndEditRefuseConflictingDomain` (red-proved: without the create guard „b1 with example.hu was
created"). Live data read first (read-only DB copy, deleted): the four customers' domains are distinct and none is
under felhom.eu. The form's example and one old test fixture said `kovacs.felhom.eu` — corrected to `kovacs.hu`.
- **R-304 option C (decision 183):** new event type `recovery_older_package` (sent by the controller of the same day when
a household's code opens, or may open, an older sealed escrow package): in `allowedEventTypes` and
`notify.operatorOnlyEvents`, no household text. Test `TestR304_RecoveryOlderPackageIsAllowlistedAndOperatorOnly`
+47
View File
@@ -0,0 +1,47 @@
package store
import "strings"
// R-415 / R-138 option B (2026-10-08; operator ruling 2026-09-14, `01` §7: every customer has their OWN domain, never a
// name under felhom.eu). The hub enforced uniqueness on customer_id only, so two customers could be given the same
// domain — or one inside the other, a shared zone in all but name — silently. DomainConflict answers, for a domain about
// to be saved for customerID, which rule it breaks ("" = none):
//
// - "felhom.eu" — the domain is felhom.eu or a name under it;
// - "<other id>" — another customer's domain equals it, contains it, or lies under it.
//
// Matching is case-insensitive, ignores a trailing dot, and is on LABEL boundaries („notexample.hu" is not under
// „example.hu"). An empty domain conflicts with nothing. Pinned by TestR415_* (domain_conflict_test.go) and the
// handler test TestR415_CreateAndEditRefuseConflictingDomain.
func (s *Store) DomainConflict(customerID, domain string) (string, error) {
d := normDomain(domain)
if d == "" {
return "", nil
}
if d == "felhom.eu" || strings.HasSuffix(d, ".felhom.eu") {
return "felhom.eu", nil
}
rows, err := s.db.Query(`SELECT customer_id, domain FROM customer_configs WHERE customer_id != ? AND domain != ''`, customerID)
if err != nil {
return "", err
}
defer rows.Close()
for rows.Next() {
var id, other string
if err := rows.Scan(&id, &other); err != nil {
return "", err
}
o := normDomain(other)
if o == "" {
continue
}
if d == o || strings.HasSuffix(d, "."+o) || strings.HasSuffix(o, "."+d) {
return id, nil
}
}
return "", rows.Err()
}
func normDomain(d string) string {
return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(d)), ".")
}
+38
View File
@@ -741,6 +741,18 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
return
}
// R-415 / R-138 option B: a domain equal to, inside or containing another customer's, or under felhom.eu, is refused
// BEFORE anything is generated or provisioned.
if msg := s.domainConflictMessage(customerID, r.FormValue("domain")); msg != "" {
s.renderConfigForm(w, r, true, &store.CustomerConfig{
CustomerID: customerID,
CustomerName: r.FormValue("customer_name"),
Domain: r.FormValue("domain"),
Email: r.FormValue("email"),
}, nil, msg)
return
}
// Generate credentials.
//
// R-597: the Owner passphrase follows the language the operator is choosing ON THIS FORM, not
@@ -861,6 +873,13 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust
s.renderConfigForm(w, r, false, cfg, submitted, "Display Name and Domain are required.")
return
}
// R-415 / R-138 option B — the same guard on edit (a customer's own current domain never conflicts with itself).
if msg := s.domainConflictMessage(customerID, cfg.Domain); msg != "" {
var submitted map[string]interface{}
_ = json.Unmarshal([]byte(buildConfigJSON(r)), &submitted)
s.renderConfigForm(w, r, false, cfg, submitted, msg)
return
}
cfg.ConfigJSON = buildConfigJSON(r)
@@ -1769,3 +1788,22 @@ func (s *Server) handleGeoDisable(w http.ResponseWriter, r *http.Request, custom
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Geo-restriction removed from Cloudflare."})
}
// domainConflictMessage is the operator's sentence for a refused domain ("" = allowed). A store error refuses too
// (fail closed: the save can be retried; a duplicate domain cannot be undone once boxes use it).
func (s *Server) domainConflictMessage(customerID, domain string) string {
other, err := s.store.DomainConflict(customerID, domain)
if err != nil {
s.logger.Printf("[ERROR] domain check for %s failed: %v — save refused", customerID, err)
return "The domain could not be checked against the other customers — nothing was saved. Try again."
}
switch other {
case "":
return ""
case "felhom.eu":
return fmt.Sprintf("Domain %q is under felhom.eu — every customer has their own domain (01 §7). Nothing was saved.", strings.TrimSpace(domain))
default:
s.logger.Printf("[WARN] domain %q for %s refused: it overlaps customer %s's domain (R-415)", strings.TrimSpace(domain), customerID, other)
return fmt.Sprintf("Domain %q equals, contains or lies under the domain of customer %q — every customer has their own domain (01 §7). Nothing was saved.", strings.TrimSpace(domain), other)
}
}
+2 -2
View File
@@ -64,13 +64,13 @@ func TestConfigUpdate_DebugSurvivesRebuild_OffsiteUntouched(t *testing.T) {
const id = "cust-dbg"
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: id, CustomerName: "Kovács", Domain: "kovacs.felhom.eu", ConfigJSON: "{}",
CustomerID: id, CustomerName: "Kovács", Domain: "kovacs.hu", ConfigJSON: "{}",
}); err != nil {
t.Fatalf("seed customer: %v", err)
}
// 1) First save WITH offsite enabled → provisions + merges the descriptor. No debug yet.
const offsiteForm = "customer_name=Kov%C3%A1cs&domain=kovacs.felhom.eu&dr_tier=on&offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50"
const offsiteForm = "customer_name=Kov%C3%A1cs&domain=kovacs.hu&dr_tier=on&offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50"
w := httptest.NewRecorder()
s.handleConfigUpdate(w, postForm("/configs/"+id+"/edit", offsiteForm), id)
if w.Code != http.StatusSeeOther {
@@ -0,0 +1,62 @@
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// R-415 / R-138 option B: every customer has their own domain (`01` §7). The CONSEQUENCE asserted: a refused create
// stores nothing; an allowed one is stored; an edit keeping its own domain is allowed.
// RED-PROOF: delete the domainConflictMessage block in handleConfigCreate → „b" with „example.hu" is created → FAILS.
func TestR415_CreateAndEditRefuseConflictingDomain(t *testing.T) {
s, st := newTestServer(t)
post := func(id, domain string) *httptest.ResponseRecorder {
form := url.Values{"customer_id": {id}, "customer_name": {"T " + id}, "email": {"t@example.org"}, "domain": {domain}}
req := httptest.NewRequest(http.MethodPost, "/configs/new", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.handleConfigCreate(rr, req)
return rr
}
if rr := post("a", "example.hu"); rr.Code != http.StatusSeeOther {
t.Fatalf("create a: %d %s", rr.Code, rr.Body.String())
}
for _, c := range []struct{ id, domain string }{{"b1", "example.hu"}, {"b2", "x.EXAMPLE.hu."}, {"b3", "hu"}, {"b4", "t1.felhom.eu"}, {"b5", "felhom.eu"}} {
rr := post(c.id, c.domain)
if rr.Code == http.StatusSeeOther {
t.Errorf("%s with %q was created — it must be refused", c.id, c.domain)
}
if !strings.Contains(rr.Body.String(), "Nothing was saved") {
t.Errorf("%s with %q: the refusal must say nothing was saved; got %d", c.id, c.domain, rr.Code)
}
if got, _ := st.GetCustomerConfig(c.id); got != nil {
t.Errorf("%s with %q: a config was stored", c.id, c.domain)
}
}
for _, c := range []struct{ id, domain string }{{"c1", "example2.hu"}, {"c2", "notexample.hu"}} {
if rr := post(c.id, c.domain); rr.Code != http.StatusSeeOther {
t.Errorf("%s with %q must be allowed; got %d %s", c.id, c.domain, rr.Code, rr.Body.String())
}
}
// Edit: „a" keeping its own domain is allowed; „c1" moving under „a"'s is refused and keeps its old domain.
edit := func(id, domain string) *httptest.ResponseRecorder {
form := url.Values{"customer_name": {"T " + id}, "email": {"t@example.org"}, "domain": {domain}}
req := httptest.NewRequest(http.MethodPost, "/configs/"+id, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.handleConfigUpdate(rr, req, id)
return rr
}
if rr := edit("a", "example.hu"); strings.Contains(rr.Body.String(), "Nothing was saved") {
t.Errorf("editing a with its own domain must not conflict with itself: %s", rr.Body.String())
}
if rr := edit("c1", "shop.example.hu"); !strings.Contains(rr.Body.String(), "Nothing was saved") {
t.Errorf("moving c1 under a's domain must be refused; got %d", rr.Code)
}
if got, _ := st.GetCustomerConfig("c1"); got == nil || got.Domain != "example2.hu" {
t.Errorf("c1's domain must stay example2.hu after a refused edit; got %+v", got)
}
}
@@ -25,7 +25,7 @@
<label for="domain">Domain *</label>
<input type="text" id="domain" name="domain"
value="{{.Config.Domain}}"
placeholder="e.g. kovacs.felhom.eu"
placeholder="e.g. kovacs.hu (the customer's own domain)"
required>
</div>
<div class="form-group">