R-498: the app page's first steps name the household's address, not wiki.DOMAIN

AppInfo.WithAddress fills the catalog placeholder (<label>.DOMAIN -> the
effective subdomain + customer domain, bare DOMAIN -> the domain) on a
copy; appDetailHandler applies it. Pinned per catalog template (both
languages) and on the rendered page.

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-05 21:49:10 +02:00
parent 28a5203d01
commit 05a447ef02
5 changed files with 158 additions and 1 deletions
+1
View File
@@ -92,6 +92,7 @@
| `CheckPlacement` + `PlacementMismatchMessage` | controller/internal/backup/offbox_placement.go | `(*RecoveryManifest, liveDrive, liveNS) PlacementCheck` / `(stack, PlacementCheck) string` | Comparing where a backup SAYS the data lived against where a restore is about to write | Pure and total — nil/empty/blank manifest all give the same honest "not known, no mismatch". **An UNKNOWN is never a mismatch** (refusing on an absence strands every pre-field unit). Compares the DRIVE only (the namespace root is derived from it), Cleaned, so a trailing slash is not a difference. The message names BOTH values on purpose |
| `RecordedUnitForStack` + `RecordedAddress` | controller/internal/backup/offbox_placement.go | `(stack) (RecordedPlacement, RecordedAddress, bool)` | Reading back the address + data folder a backup recorded, for a reinstall prefill | Local file reads over every readable namespace root — **no network, no restic, no restore**; it exists for the NOT-INSTALLED case where `GetStackHDDPath` is `""`. **`RecordedAddress.Known()` requires BOTH halves:** an absent `SUBDOMAIN` makes the live deploy path fall back to the CATALOG default (`stacks/deploy.go:88-90`), and offering that back as "what your backup says" is a fabricated fact |
| **`Metadata.For(lang)` + `LocalizeStacks` / `LocalizeStackPtr` / `Stack.MetaFor` (R-560, v0.257.0)** | controller/internal/stacks/metadata_i18n.go | `(lang string) Metadata`; `([]Stack, lang) []Stack`; `(*Stack, lang) *Stack` | THE only way a page may reach catalog COPY — the app description, tagline, use cases, first steps, prerequisites, deploy-field labels and descriptions, optional-config text, integration labels, data-path labels and the initial-credentials note | Reading `stack.Meta.<copy>` off the manager renders HUNGARIAN to an English household, with no error and nothing wrong-looking on the page — `TestNoDirectMetaCopyReadOnPages` keeps the named, reasoned allow-list of every direct read in `internal/web`. **`For("hu")` is the parsed struct with `I18n` cleared** and nothing else (pinned over all 53 real catalog files), so a translation cannot change the Hungarian product. **Fallback is FIELD BY FIELD** — absent or blank English shows Hungarian, which is what makes a half-translated app shippable. **Lists replace WHOLE; every other list is matched by its own key** (`env_var`, option `value`, `match_group`, `target`, `path`) — position matching mistranslates silently the first time a field is inserted. **It never writes through:** the metadata is shared by concurrent requests, so an in-place merge leaks one household's language into another's page |
| `AppInfo.WithAddress` (R-498) | controller/internal/stacks/appinfo_address.go | `(subdomain, domain string) AppInfo` | Filling the catalog copy's `<label>.DOMAIN` / bare `DOMAIN` placeholder with the household's real address (effective subdomain + customer domain) | Returns a COPY (the metadata is shared); `\bDOMAIN\b` leaves `SUBDOMAIN`/`MAIL_DOMAIN` alone; no domain → unchanged. Apply AFTER `LocalizeStackPtr`. The deploy page still fills its post-deploy steps in JS (`deploy.html`), `knownLoginLine` its own line |
| `Metadata.HasDeployField` | controller/internal/stacks/metadata.go | `(envVar string) bool` | "Does this app have somewhere to PUT a recorded value?" | **13 of 53 templates declare `HDD_PATH`; 40 do not** (measured 2026-08-21). For the 40 a recorded placement is a FACT TO STATE, never a value to write into a field that does not exist |
| `redirectTier2` | controller/internal/web/tier2_config_handler.go | `(w, r, name, flash, flashErr)` | Tier2 page flash redirects | Same convention |
| `validStackName` | controller/internal/web/validate.go | `(name string) bool` | Any stack name from a request | Single-segment, no `/ \ ..` — blocks path traversal into stacks/userdata |
@@ -0,0 +1,51 @@
package stacks
import "regexp"
// reDomainPlaceholder matches the catalog's address placeholder in app copy: a bare `DOMAIN` or a
// `<label>.DOMAIN` (wiki.DOMAIN, https://paste.DOMAIN/…). `\b` keeps SUBDOMAIN and MAIL_DOMAIN out —
// the letter or underscore before them is a word character, so no boundary falls inside them.
var reDomainPlaceholder = regexp.MustCompile(`(?:([a-z0-9-]+)\.)?\bDOMAIN\b`)
// WithAddress returns a copy of the app copy with the catalog's `DOMAIN` placeholder filled in
// (R-498): `<label>.DOMAIN` becomes `<subdomain>.<domain>` — the app's EFFECTIVE subdomain (the one
// the household chose at install), or the catalog label when none is known — and a bare `DOMAIN`
// becomes `<domain>`. With no domain configured the copy is returned unchanged: a placeholder is
// better than an address that is wrong. The receiver is not modified (slices are copied), because the
// stack's metadata is shared with every other page. Pinned by TestR498_* (stacks and web).
func (ai AppInfo) WithAddress(subdomain, domain string) AppInfo {
if domain == "" {
return ai
}
fill := func(s string) string {
return reDomainPlaceholder.ReplaceAllStringFunc(s, func(m string) string {
sub := reDomainPlaceholder.FindStringSubmatch(m)
if sub[1] == "" {
return domain
}
label := sub[1]
if subdomain != "" {
label = subdomain
}
return label + "." + domain
})
}
list := func(in []string) []string {
if in == nil {
return nil
}
out := make([]string, len(in))
for i, s := range in {
out[i] = fill(s)
}
return out
}
out := ai
out.Tagline = fill(ai.Tagline)
out.UseCases = list(ai.UseCases)
out.FirstSteps = list(ai.FirstSteps)
out.Prerequisites = list(ai.Prerequisites)
out.DefaultCreds = fill(ai.DefaultCreds)
out.AddPeople = fill(ai.AddPeople)
return out
}
@@ -0,0 +1,83 @@
package stacks
import (
"os"
"path/filepath"
"reflect"
"regexp"
"testing"
)
// R-498: the catalog's `<label>.DOMAIN` becomes the household's address; SUBDOMAIN / MAIL_DOMAIN are
// not placeholders and stay; the receiver is not mutated; no domain → unchanged.
func TestR498_WithAddressFillsThePlaceholder(t *testing.T) {
in := AppInfo{
FirstSteps: []string{"Nyisd meg a wiki.DOMAIN címet a böngészőben", "Lásd https://paste.DOMAIN/admin"},
Prerequisites: []string{"SUBDOMAIN és MAIL_DOMAIN marad", "csak DOMAIN"},
DefaultCreds: "admin@DOMAIN",
}
keep := AppInfo{FirstSteps: append([]string(nil), in.FirstSteps...), Prerequisites: append([]string(nil), in.Prerequisites...), DefaultCreds: in.DefaultCreds}
got := in.WithAddress("konyvtar", "example.hu")
want := []string{"Nyisd meg a konyvtar.example.hu címet a böngészőben", "Lásd https://konyvtar.example.hu/admin"}
if !reflect.DeepEqual(got.FirstSteps, want) {
t.Errorf("FirstSteps = %q, want %q", got.FirstSteps, want)
}
if got.Prerequisites[0] != "SUBDOMAIN és MAIL_DOMAIN marad" || got.Prerequisites[1] != "csak example.hu" {
t.Errorf("Prerequisites = %q", got.Prerequisites)
}
if got.DefaultCreds != "admin@example.hu" {
t.Errorf("DefaultCreds = %q", got.DefaultCreds)
}
if !reflect.DeepEqual(in.FirstSteps, keep.FirstSteps) || !reflect.DeepEqual(in.Prerequisites, keep.Prerequisites) {
t.Error("WithAddress mutated the shared metadata")
}
// No effective subdomain known: the catalog label stays.
if g := in.WithAddress("", "example.hu").FirstSteps[0]; g != "Nyisd meg a wiki.example.hu címet a böngészőben" {
t.Errorf("label fallback = %q", g)
}
// No domain: unchanged.
if g := in.WithAddress("wiki", "").FirstSteps[0]; g != in.FirstSteps[0] {
t.Errorf("no domain must leave the copy unchanged, got %q", g)
}
}
// R-498, per template: every catalog template's app copy, in both languages, renders with no literal
// DOMAIN left. Reads the sibling catalog clone; skipped when it is not checked out next to this repo.
func TestR498_EveryCatalogTemplateRendersAnAddress(t *testing.T) {
root := filepath.Join("..", "..", "..", "..", "app-catalog-felhom.eu", "templates")
entries, err := os.ReadDir(root)
if err != nil {
t.Skipf("catalog clone not found at %s: %v", root, err)
}
literal := regexp.MustCompile(`\bDOMAIN\b`)
checked, withPlaceholder := 0, 0
for _, e := range entries {
dir := filepath.Join(root, e.Name())
if _, err := os.Stat(filepath.Join(dir, ".felhom.yml")); err != nil {
continue
}
st := Stack{Name: e.Name(), Meta: LoadMetadata(dir)}
for _, lang := range []string{"hu", "en"} {
ai := LocalizeStack(st, lang).Meta.AppInfo
before := append(append(append([]string{ai.Tagline, ai.DefaultCreds, ai.AddPeople}, ai.UseCases...), ai.FirstSteps...), ai.Prerequisites...)
for _, s := range before {
if literal.MatchString(s) {
withPlaceholder++
break
}
}
out := ai.WithAddress(st.Meta.Subdomain, "example.hu")
for _, s := range append(append(append([]string{out.Tagline, out.DefaultCreds, out.AddPeople}, out.UseCases...), out.FirstSteps...), out.Prerequisites...) {
if literal.MatchString(s) {
t.Errorf("%s (%s): a literal DOMAIN is left: %q", e.Name(), lang, s)
}
}
checked++
}
}
if checked == 0 || withPlaceholder == 0 {
t.Fatalf("the sweep proved nothing: %d renders checked, %d carried the placeholder", checked, withPlaceholder)
}
t.Logf("%d template renders checked, %d carried the placeholder", checked, withPlaceholder)
}
@@ -91,3 +91,24 @@ func TestAutoUpdateLine_PageSaysItOnce(t *testing.T) {
t.Error("the line must go once the app no longer runs what the automatic step installed")
}
}
// R-498: the app page's „Első lépések" shows the household's address, never the catalog's wiki.DOMAIN.
func TestR498_AppPageFirstStepsShowTheRealAddress(t *testing.T) {
s, _ := credsHarness(t)
sd := filepath.Join(s.cfg.Paths.StacksDir, "crafty")
meta := "display_name: Crafty\nslug: crafty\nsubdomain: wiki\napp_info:\n first_steps:\n - 'Nyisd meg a wiki.DOMAIN címet a böngészőben'\n"
if err := os.WriteFile(filepath.Join(sd, ".felhom.yml"), []byte(meta), 0o644); err != nil {
t.Fatal(err)
}
_ = s.stackMgr.ScanStacks()
s.loadTemplates()
rr := httptest.NewRecorder()
s.appDetailHandler(rr, httptest.NewRequest("GET", "/apps/crafty", nil), "crafty")
page := html.UnescapeString(rr.Body.String())
if !strings.Contains(page, "Nyisd meg a wiki.example.hu c") {
t.Fatalf("the first step must name the real address wiki.example.hu (status %d)", rr.Code)
}
if strings.Contains(page, "wiki.DOMAIN") {
t.Error("the literal wiki.DOMAIN reached the page")
}
}
+2 -1
View File
@@ -768,7 +768,8 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
data := s.baseData("stacks", found.Meta.DisplayName)
data["Stack"] = found
data["Meta"] = found.Meta
data["AppInfo"] = found.Meta.AppInfo
// R-498: the catalog copy says `wiki.DOMAIN`; the household needs its own address.
data["AppInfo"] = found.Meta.AppInfo.WithAddress(effectiveSubdomain, s.cfg.Customer.Domain)
// v0.279.0 (decision 45): the default-login card only while that login is in effect.
data["KnownLoginLine"] = s.knownLoginLine(s.langFor(r), &found.Meta, found.AppConfig, found.Deployed)
data["DefaultLoginReplaced"] = defaultLoginReplaced(&found.Meta, found.AppConfig, found.Deployed)