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
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:
+8
-1
@@ -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`
|
||||
|
||||
@@ -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)), ".")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user