Auto-derive session cookie domain; AI prompt page = formula builder hub

Session (no env needed):
- SESSION_COOKIE_DOMAIN env removed. Config.SessionCookieParentDomain()
  derives the cookie Domain from WEB_ORIGIN + PUBLIC_API_URL, which the
  API already requires: sibling hosts of one parent (descrybe.io +
  api.descrybe.io) share the parent domain so SvelteKit SSR (/admin
  gate, user switching) receives the session cookie; localhost, IPs,
  same-host, and unrelated hosts stay host-only. Deploying the new build
  is the whole fix — nothing to configure.

AI generation prompt page:
- Each section now embeds its formula editor next to the per-language
  prompt instructions: Title = full title formula builder (preview,
  elements, separator, variable selector, custom variables), Description
  = description formula sections editor (type + instructions + export
  id, drag reorder), Meta = meta title / meta description formula
  fields. One Save writes categories.prompt + title_template +
  description_template together; Assign copies all three to the
  selected categories.
- New $lib/categories/formula-variables.ts loads every usable field for
  the builder: custom variables (/api/variables), company attributes
  (/api/attributes — attribute_key, name, unit, example), and standard
  fields (/api/standard-fields). Used by both the prompt page and the
  title-formula page (which previously ignored attributes).

Verified locally: svelte-check clean for changed files, unit tests pass,
and the full save contract exercised over HTTP as the page does it
(login → load variables/attributes/standard-fields → PATCH prompt +
title-formula + description-formula → round-trip read), then the test
category restored via repair-category-prompts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 01:12:23 +02:00
co-authored by Claude Fable 5
parent 9a839d6d13
commit d03c2a5c57
7 changed files with 657 additions and 62 deletions
+63 -5
View File
@@ -36,7 +36,6 @@ type Config struct {
// (e.g. redis/postgres) so boot can warn that memory was forced.
RateLimitBackendRequested string
SessionCookieName string
SessionCookieDomain string
SessionSecure bool
CSRFCookieName string
PublicAPIURL string
@@ -142,10 +141,6 @@ func Load() (Config, error) {
RateLimitMultiReplica: getenvBool("RATE_LIMIT_MULTI_REPLICA", false),
RateLimitBackend: "memory",
SessionCookieName: getenv("SESSION_COOKIE_NAME", "descrybe_session"),
// Empty = host-only cookie (localhost). Set to the parent domain
// (e.g. descrybe.io) when web + api run on sibling subdomains so
// SvelteKit SSR (descrybe.io) receives the session cookie too.
SessionCookieDomain: getenv("SESSION_COOKIE_DOMAIN", ""),
// Default Secure=true when APP_ENV is production|prod so cookies are HTTPS-only
// even if SESSION_SECURE is unset; explicit false still fails closed in validate.
SessionSecure: getenvBool("SESSION_SECURE", isProductionEnvValue(appEnv)),
@@ -251,6 +246,69 @@ func (c Config) InsecureLocalProductionActive() bool {
return c.IsProduction() && c.AllowInsecureLocalProduction && isLoopbackWebOriginHost(c.WebOrigin)
}
// SessionCookieParentDomain derives the session cookie Domain attribute from
// WEB_ORIGIN and PUBLIC_API_URL — no extra env needed. When web and API run on
// sibling hosts of one parent domain (descrybe.io + api.descrybe.io), the
// shared parent is returned so the browser also sends the session cookie to
// the web host; SvelteKit SSR gates (/admin) forward it to /api/auth/me and
// would otherwise always see 401. Same hostname (localhost dev, single-host
// deploys), IPs, or unrelated hosts → "" (host-only cookie, old behavior).
func (c Config) SessionCookieParentDomain() string {
web := hostnameOfURL(c.WebOrigin)
api := hostnameOfURL(c.PublicAPIURL)
if web == "" || api == "" || web == api {
return ""
}
if net.ParseIP(web) != nil || net.ParseIP(api) != nil ||
!strings.Contains(web, ".") || !strings.Contains(api, ".") {
return ""
}
// Direct parent/child: one host is the other's registrable parent.
if strings.HasSuffix(api, "."+web) {
return web
}
if strings.HasSuffix(web, "."+api) {
return api
}
// Sibling subdomains (app.x.y + api.x.y): share the deepest common suffix,
// but only when it has at least two labels (never a bare TLD).
if suffix := commonDotSuffix(web, api); strings.Count(suffix, ".") >= 1 {
return suffix
}
return ""
}
func hostnameOfURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" {
return ""
}
return strings.ToLower(u.Hostname())
}
// commonDotSuffix returns the longest label-aligned common suffix of two hostnames
// ("app.descrybe.io", "api.descrybe.io" → "descrybe.io"); "" when nothing matches.
func commonDotSuffix(a, b string) string {
la := strings.Split(a, ".")
lb := strings.Split(b, ".")
n := 0
for n < len(la) && n < len(lb) {
if la[len(la)-1-n] != lb[len(lb)-1-n] {
break
}
n++
}
// Never return one of the full hostnames itself (that is the parent/child case).
if n == 0 || n == len(la) || n == len(lb) {
return ""
}
return strings.Join(la[len(la)-n:], ".")
}
// ShouldWarnRateLimits reports whether operators opted into multi-replica rate-limit
// awareness or requested an unsupported shared backend.
func (c Config) ShouldWarnRateLimits() bool {
+28
View File
@@ -528,3 +528,31 @@ func TestValidateProcessingPollInterval(t *testing.T) {
t.Fatal("expected PROCESSING_POLL_INTERVAL > 0")
}
}
func TestSessionCookieParentDomain(t *testing.T) {
t.Parallel()
cases := []struct {
name string
web string
api string
want string
}{
{name: "prod_split", web: "https://descrybe.io", api: "https://api.descrybe.io", want: "descrybe.io"},
{name: "prod_split_reversed", web: "https://app.descrybe.io", api: "https://descrybe.io", want: "descrybe.io"},
{name: "sibling_subdomains", web: "https://app.descrybe.io", api: "https://api.descrybe.io", want: "descrybe.io"},
{name: "localhost_ports", web: "http://localhost:28472", api: "http://localhost:28471", want: ""},
{name: "loopback_ip", web: "http://127.0.0.1:28472", api: "http://127.0.0.1:28471", want: ""},
{name: "same_host", web: "https://descrybe.io", api: "https://descrybe.io", want: ""},
{name: "unrelated_hosts", web: "https://descrybe.io", api: "https://example.com", want: ""},
{name: "empty_api", web: "https://descrybe.io", api: "", want: ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
c := Config{WebOrigin: tc.web, PublicAPIURL: tc.api}
if got := c.SessionCookieParentDomain(); got != tc.want {
t.Fatalf("web=%q api=%q got %q want %q", tc.web, tc.api, got, tc.want)
}
})
}
}