Skip to content

Commit 5d99cb1

Browse files
committed
feat: config boot overlay + complete env reference (config-in-DB done)
- ConfigService.ApplyBootOverlay: DB > env > default applied at boot for all boot-consumed keys (rate limits, brute-force, webhook, storage, SMTP, quota, Stripe, log level); set-only semantics, legacy QUOTA_WARNING_THRESHOLD float preserved; read errors never wipe env values - observability.log_level now actually drives the JSON slog handler (newLogger level param) - .env.example: config-in-DB doctrine header, bootstrap vars marked permanent, missing catalog env vars added (DOMAIN_* x13, SESSION_COOKIE_DOMAIN, BF_*, RETENTION_*, LOG_LEVEL, branding x5, STRIPE_LIVEMODE, PAY2S_PARTNER_NAME, MAX_RELEASE_SIGN_SIZE_MB, QUOTA_WARNING_THRESHOLD_BPS), duplicate RELEASE_KEY_ENCRYPTION_KEY removed - guardrail tests: env_example_test.go pins .env.example <-> catalog parity both ways; config_overlay_test.go pins overlay semantics
1 parent 6d6e73c commit 5d99cb1

8 files changed

Lines changed: 534 additions & 19 deletions

File tree

‎.env.example‎

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,29 @@
1-
# ─── Server ───────────────────────────────────────
1+
# ─────────────────────────────────────────────────────────────────────
2+
# HiTechCloud — environment configuration
3+
#
4+
# CONFIG-IN-DB DOCTRINE: platform configuration lives in the settings
5+
# table (admin dashboard → Settings, or GET/PUT /api/v1/admin/config).
6+
# Precedence is: DB value > env var > built-in default. Everything in
7+
# this file except the eight BOOTSTRAP variables below is only a
8+
# FALLBACK for installs that predate config-in-DB — prefer the
9+
# dashboard. When an env var carries a value that the database does
10+
# not override, the server logs one deprecation line per variable at
11+
# boot. Secret values are sealed at rest (SECRET_ENCRYPTION_KEY) and
12+
# are never returned by any API.
13+
#
14+
# BOOTSTRAP (stay in the environment forever — key material and
15+
# infrastructure addresses needed BEFORE the database is reachable,
16+
# deliberately absent from the settings catalog):
17+
# PORT, DATABASE_URL, JWT_SECRET, LICENSE_SIGNING_KEY,
18+
# SECRET_ENCRYPTION_KEY, RELEASE_KEY_ENCRYPTION_KEY,
19+
# REFERRAL_HASH_SALT, REDIS_URL
20+
# ─────────────────────────────────────────────────────────────────────
21+
22+
# ─── Server ───────────────────────────────────────────────
223
PORT=9000
24+
# catalog: app.environment (restart-scoped)
325
ENVIRONMENT=development # development | staging | production
26+
# catalog: app.base_url (restart-scoped)
427
BASE_URL=http://localhost:9000
528

629
# ─── Database ─────────────────────────────────────
@@ -32,8 +55,9 @@ RELEASE_KEY_ENCRYPTION_KEY=
3255
STRIPE_SECRET_KEY=
3356
# Optional: set explicitly when running multiple instances behind a
3457
# shared Stripe webhook endpoint configuration.
35-
STRIPE_WEBHOOK_SECRET=
36-
58+
STRIPE_WEBHOOK_SECRET=# Optional: trust live-mode events. Unset = derived from the secret
59+
# key prefix (sk_live_ → live, sk_test_ → test).
60+
STRIPE_LIVEMODE=
3761
# ─── Payments (Vietnamese gateways, one-off VND) ─
3862
# Pay2S (bank transfer / Napas 247 QR): https://docs.pay2s.vn
3963
# A gateway is active only when ALL of its credentials are set. Leave
@@ -42,6 +66,8 @@ STRIPE_WEBHOOK_SECRET=
4266
# Bank accounts: repeated "bankId|accountNumber|accountName|bankName"
4367
# separated by commas — these are the accounts buyers transfer to.
4468
PAY2S_PARTNER_CODE=
69+
# Display name shown to the buyer at Pay2S checkout.
70+
PAY2S_PARTNER_NAME=HiTechCloud
4571
PAY2S_ACCESS_KEY=
4672
PAY2S_SECRET_KEY=
4773
PAY2S_BANK_ACCOUNTS=
@@ -87,8 +113,10 @@ RATE_LIMIT_AUTH=60
87113
# Login codes per IP per HOUR. Deliberately tight: this endpoint mails
88114
# an address the caller supplies, so a loose budget lets someone use
89115
# your server (and your sending reputation) to spam third parties.
90-
RATE_LIMIT_OTP_SEND=30
91-
116+
RATE_LIMIT_OTP_SEND=30# Brute-force guard on /license/*: failed license keys per IP before a
117+
# lockout, and the lockout duration in seconds.
118+
BF_MAX_FAILS=5
119+
BF_LOCKOUT_SECONDS=30
92120
# ─── Webhooks ────────────────────────────────────
93121
WEBHOOK_MAX_ATTEMPTS=5
94122
WEBHOOK_RETRY_INTERVAL=30s
@@ -101,6 +129,12 @@ WEBHOOK_HTTP_TIMEOUT=10s
101129
WEBHOOK_ALLOW_PRIVATE=false
102130

103131
# ─── Quotas ──────────────────────────────────────
132+
# catalog: app.quota_warning_threshold — quota usage (basis points,
133+
# 10000 = 100%) that triggers the quota-warning email. This bps form
134+
# is canonical and wins at boot.
135+
QUOTA_WARNING_THRESHOLD_BPS=8000
136+
# Legacy float fraction form (0.8 = 80%), read only when the bps form
137+
# is unset. Kept for installs that predate config-in-DB.
104138
QUOTA_WARNING_THRESHOLD=0.8
105139

106140
# ─── Admin Bootstrap ─────────────────────────────
@@ -130,11 +164,50 @@ STORAGE_DOWNLOAD_TTL=10m
130164
# Lifetime of artifact URLs inside public update feeds (Sparkle/Velopack/Tauri).
131165
# Users may click Install long after the feed was fetched.
132166
STORAGE_FEED_URL_TTL=24h
167+
# Largest artifact (MB) the server will sign in-memory (Ed25519).
168+
MAX_RELEASE_SIGN_SIZE_MB=500
133169

134-
# Master key that encrypts secrets at rest: release-signing keys, email
135-
# provider credentials and license keys. Required when release storage
136-
# is enabled; recommended in any production install.
137-
# Generate with: openssl rand -hex 32
138-
# Keep it safe and stable: losing or changing it makes existing signing
139-
# keys undecryptable, and signing then fails.
140-
RELEASE_KEY_ENCRYPTION_KEY=
170+
# ─── Domains / surfaces (the 5-domain split + extras) ───
171+
# catalog: domain.* — hostname map for surface routing. When unset the
172+
# install is single-host (no enforcement). domain.base derives the
173+
# standard set: payments./dashboard./merchant./customer./verify.
174+
# DOMAIN_BASE=example.com
175+
# Per-host overrides (win over the derived names) + optional extras:
176+
# DOMAIN_APEX=example.com
177+
# DOMAIN_PAYMENTS=payments.example.com
178+
# DOMAIN_DASHBOARD=dashboard.example.com
179+
# DOMAIN_MERCHANT=merchant.example.com
180+
# DOMAIN_CUSTOMER=customer.example.com
181+
# DOMAIN_VERIFY=verify.example.com
182+
# DOMAIN_HOOKS=hooks.example.com
183+
# DOMAIN_DOCS=docs.example.com
184+
# DOMAIN_STATUS=status.example.com
185+
# DOMAIN_GO=go.example.com
186+
# DOMAIN_AUTH=auth.example.com
187+
# DOMAIN_CDN=cdn.example.com
188+
# catalog: session_cookie_domain — lets the session cookie roam the
189+
# site's subdomains (e.g. example.com). Unset = host-only cookies.
190+
# SESSION_COOKIE_DOMAIN=example.com
191+
192+
# ─── Data retention (days; plan §92) ────────────────────
193+
# catalog: retention.* — batched pruning of non-financial rows.
194+
# Financial records (orders, invoices, refunds, audit) are never pruned.
195+
RETENTION_NOTIFICATIONS_DAYS=90
196+
RETENTION_PROCESSED_EVENTS_DAYS=30
197+
RETENTION_WEBHOOK_DELIVERIES_DAYS=90
198+
RETENTION_AUDIT_LOGS_DAYS=365
199+
200+
# ─── Observability ─────────────────────────────────────────
201+
# catalog: observability.log_level — slog level of the JSON stdout
202+
# stream: debug | info | warn | error.
203+
LOG_LEVEL=info
204+
205+
# ─── Branding ────────────────────────────────────────────
206+
# catalog: site_name / timezone / brand_color / language / logo_url.
207+
# NOTE: the "Powered by Keygate" attribution notice is a license
208+
# obligation (AGPL v3 Section 7(b)) and is NOT configurable here.
209+
SITE_NAME=HiTechCloud
210+
TIMEZONE=UTC
211+
BRAND_COLOR=#2563eb
212+
LANGUAGE=en
213+
LOGO_URL=

‎cmd/server/log_redaction_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ func TestInternalErrorReachesTheConfiguredLogger(t *testing.T) {
284284
t.Cleanup(func() { slog.SetDefault(prev) })
285285

286286
var buf bytes.Buffer
287-
newLogger(&buf) // as main does, minus the os.Stdout
287+
newLogger(&buf, "info") // as main does, minus the os.Stdout
288288

289289
gin.SetMode(gin.TestMode)
290290
r := gin.New()

‎cmd/server/main.go‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,17 @@ func main() {
258258
}
259259
defer db.Close()
260260

261-
logger := newLogger(os.Stdout)
261+
// Config-in-DB boot overlay (plan §67 evolution): the settings
262+
// table wins over the environment for every catalog key that
263+
// configures a boot-time consumer (rate limits, webhook, storage,
264+
// SMTP, quota, Stripe…). Must run before the services below are
265+
// constructed. Live-scoped keys (payment gateway credentials,
266+
// domains, session cookie domain, retention…) re-read per use.
267+
cfgsvc := service.NewConfigService(db)
268+
cfgsvc.ApplyBootOverlay(context.Background(), cfg)
269+
270+
logger := newLogger(os.Stdout, cfg.LogLevel)
271+
cfgsvc.LogEnvDeprecations(logger)
262272

263273
// Optional Redis/Valkey backend for rate limiting shared across
264274
// instances. Falls back to in-memory when REDIS_URL is unset or
@@ -442,9 +452,8 @@ func main() {
442452
// Platform configuration lives in the settings table (config-in-DB):
443453
// DB value > env var > catalog default. Env is bootstrap-only —
444454
// see config.BootstrapEnvVars. The service caches non-secret values
445-
// for 30s and drops the cache on every admin write.
446-
cfgsvc := service.NewConfigService(db)
447-
cfgsvc.LogEnvDeprecations(logger)
455+
// for 30s and drops the cache on every admin write. (cfgsvc was
456+
// built at boot-overlay time above.)
448457
// Vietnamese payment gateways (plan §25 provider abstraction):
449458
// one-off VND payments via Pay2S (bank transfer / Napas 247 QR),
450459
// ZaloPay and payOS (VietQR). Registered unconditionally —
@@ -1856,8 +1865,19 @@ func isFrontendAsset(clean string) bool {
18561865
// rather than through log. A log.Fatalf here would still exit, but it
18571866
// would announce the server refusing to start as an INFO line, and a
18581867
// deployment that pages on level=ERROR would never hear about it.
1859-
func newLogger(w io.Writer) *slog.Logger {
1860-
logger := slog.New(slog.NewJSONHandler(w, nil))
1868+
func newLogger(w io.Writer, level string) *slog.Logger {
1869+
var lv slog.Level
1870+
switch strings.ToLower(strings.TrimSpace(level)) {
1871+
case "debug":
1872+
lv = slog.LevelDebug
1873+
case "warn", "warning":
1874+
lv = slog.LevelWarn
1875+
case "error":
1876+
lv = slog.LevelError
1877+
default:
1878+
lv = slog.LevelInfo
1879+
}
1880+
logger := slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{Level: lv}))
18611881
slog.SetDefault(logger)
18621882
return logger
18631883
}

‎docs/ARCHITECTURE.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,20 @@ values are masked in every response).
116116
`JWT_SECRET`, `LICENSE_SIGNING_KEY`, `SECRET_ENCRYPTION_KEY`,
117117
`RELEASE_KEY_ENCRYPTION_KEY`, `REFERRAL_HASH_SALT`, `REDIS_URL`.
118118

119+
**Boot overlay vs live seams.** At boot, `ConfigService.ApplyBootOverlay`
120+
copies effective values onto the runtime `config.Config` for every key with a
121+
boot-time consumer (rate limits, brute-force, webhook, storage, SMTP, quota,
122+
Stripe, `observability.log_level`), so the settings table wins over the
123+
environment without any code reading env directly. Keys marked
124+
restart-required apply at the next boot; the live-scoped keys (payment gateway
125+
credentials, domains, `session_cookie_domain`, retention, branding, SMTP
126+
delivery) are re-read per use and take effect immediately. A key explicit in
127+
neither the database nor the environment leaves the env-loaded value alone.
128+
129+
`.env.example` is the complete environment reference: every catalog env var
130+
and bootstrap var is documented there, and `internal/config/env_example_test.go`
131+
fails the build if the two ever drift apart.
132+
119133
## Email queue
120134

121135
Outbound mail is queued into `email_queue` (`store.EnqueueEmail`), processed in

‎internal/config/config.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ type Config struct {
5656
WebhookAllowPrivate bool
5757
QuotaWarningThreshold float64
5858

59+
// LogLevel is the slog level for the JSON stdout handler
60+
// (debug | info | warn | error). Catalog key observability.log_level;
61+
// config-in-DB wins at boot.
62+
LogLevel string
63+
5964
SMTPHost string
6065
SMTPPort string
6166
SMTPUsername string
@@ -237,6 +242,7 @@ func Load() (*Config, error) {
237242
cfg.WebhookHTTPTimeout = envOr("WEBHOOK_HTTP_TIMEOUT", "10s")
238243
cfg.WebhookAllowPrivate = strings.EqualFold(os.Getenv("WEBHOOK_ALLOW_PRIVATE"), "true")
239244
cfg.QuotaWarningThreshold = envFloatOr("QUOTA_WARNING_THRESHOLD", 0.8)
245+
cfg.LogLevel = envOr("LOG_LEVEL", "info")
240246

241247
if admins := os.Getenv("ADMIN_EMAILS"); admins != "" {
242248
for e := range strings.SplitSeq(admins, ",") {
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package config
2+
3+
import (
4+
"os"
5+
"regexp"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// The .env.example file is the operator's complete environment
11+
// reference. These tests pin it to the catalog so a new catalog entry
12+
// can never ship without documenting its env fallback — and so a typo
13+
// in .env.example cannot introduce a variable nothing reads.
14+
15+
// envExamplePath is the repo-root .env.example relative to this package.
16+
const envExamplePath = "../../.env.example"
17+
18+
// envExampleLine matches an assignment line, commented or not:
19+
// "# NAME=value" and "NAME=value" both count as documented.
20+
var envExampleLine = regexp.MustCompile(`(?m)^\s*#?\s*([A-Z][A-Z0-9_]*)=`)
21+
22+
// envExampleActiveLine matches uncommented assignments only.
23+
var envExampleActiveLine = regexp.MustCompile(`(?m)^([A-Z][A-Z0-9_]*)=`)
24+
25+
func loadEnvExample(t *testing.T) string {
26+
t.Helper()
27+
b, err := os.ReadFile(envExamplePath)
28+
if err != nil {
29+
t.Fatalf("read %s: %v", envExamplePath, err)
30+
}
31+
return string(b)
32+
}
33+
34+
// legacyEnvVars are variables documented in .env.example that no
35+
// catalog entry or bootstrap entry claims. Keep this list tiny and
36+
// justified: every entry is a compatibility shim.
37+
var legacyEnvVars = map[string]string{
38+
// Float-fraction quota threshold, superseded by the bps form of
39+
// app.quota_warning_threshold. Read by config.Load only.
40+
"QUOTA_WARNING_THRESHOLD": "legacy float form of app.quota_warning_threshold",
41+
}
42+
43+
// TestEnvExampleDocumentsEveryCatalogEnvVar fails when a catalog entry
44+
// or bootstrap variable is missing from .env.example.
45+
func TestEnvExampleDocumentsEveryCatalogEnvVar(t *testing.T) {
46+
body := loadEnvExample(t)
47+
documented := map[string]bool{}
48+
for _, m := range envExampleLine.FindAllStringSubmatch(body, -1) {
49+
documented[m[1]] = true
50+
}
51+
52+
for _, e := range Catalog() {
53+
if e.EnvVar == "" {
54+
continue
55+
}
56+
if !documented[e.EnvVar] {
57+
t.Errorf("catalog key %s: env fallback %s is not documented in .env.example", e.Key, e.EnvVar)
58+
}
59+
}
60+
for _, b := range BootstrapEnvVars {
61+
if !documented[b] {
62+
t.Errorf("bootstrap env var %s is not documented in .env.example", b)
63+
}
64+
}
65+
}
66+
67+
// TestEnvExampleHasNoUnknownVariables fails when .env.example
68+
// documents a variable nothing reads.
69+
func TestEnvExampleHasNoUnknownVariables(t *testing.T) {
70+
body := loadEnvExample(t)
71+
known := map[string]bool{}
72+
for _, e := range Catalog() {
73+
if e.EnvVar != "" {
74+
known[e.EnvVar] = true
75+
}
76+
}
77+
for _, b := range BootstrapEnvVars {
78+
known[b] = true
79+
}
80+
for v := range legacyEnvVars {
81+
known[v] = true
82+
}
83+
84+
seen := map[string]bool{}
85+
for _, m := range envExampleActiveLine.FindAllStringSubmatch(body, -1) {
86+
name := m[1]
87+
if seen[name] {
88+
continue // first wins; duplicates are not this test's problem
89+
}
90+
seen[name] = true
91+
if !known[name] {
92+
t.Errorf(".env.example sets %s= but no catalog entry, bootstrap entry or legacy shim reads it", name)
93+
}
94+
}
95+
}
96+
97+
// TestLegacyEnvVarsAreJustified keeps the shim list honest.
98+
func TestLegacyEnvVarsAreJustified(t *testing.T) {
99+
for name, why := range legacyEnvVars {
100+
if strings.TrimSpace(why) == "" {
101+
t.Errorf("legacy env var %s has no justification", name)
102+
}
103+
}
104+
}

0 commit comments

Comments
 (0)