Files
descrybe/apps/api/cmd/api/main.go
T
greeneclipseandClaude Fable 5 d03c2a5c57 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>
2026-08-18 01:12:23 +02:00

117 lines
3.9 KiB
Go

package main
import (
"context"
"log"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/descrybe/descrybe-v2/apps/api/internal/db"
"github.com/descrybe/descrybe-v2/apps/api/internal/httpapi"
"github.com/descrybe/descrybe-v2/apps/api/internal/logredact"
)
func main() {
log.SetOutput(logredact.Writer(os.Stderr))
slog.SetDefault(slog.New(logredact.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})))
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
if cfg.InsecureLocalProductionActive() {
slog.Warn("ALLOW_INSECURE_LOCAL_PRODUCTION=1 with loopback WEB_ORIGIN — not for public deploy",
"web_origin", cfg.WebOrigin,
"http_addr", cfg.HTTPAddr,
)
}
if cfg.ShouldWarnRateLimits() {
slog.Warn(cfg.RateLimitWarningMessage(),
"rate_limit_replicas", cfg.RateLimitReplicas,
"rate_limit_multi_replica", cfg.RateLimitMultiReplica,
"rate_limit_backend", cfg.RateLimitBackend,
"rate_limit_backend_requested", cfg.RateLimitBackendRequested,
)
}
ctx := context.Background()
pool, err := db.NewPool(ctx, cfg.DatabaseURL, db.PoolOptions{
MaxConns: int32(cfg.DBMaxConns),
MinConns: int32(cfg.DBMinConns),
MaxConnLifetime: cfg.DBMaxConnLifetime,
MaxConnLifetimeJitter: cfg.DBMaxConnLifetimeJitter,
MaxConnIdleTime: cfg.DBMaxConnIdleTime,
HealthCheckPeriod: cfg.DBHealthCheckPeriod,
StatementTimeout: cfg.DBStatementTimeout,
})
if err != nil {
log.Fatalf("db: %v", err)
}
defer pool.Close()
sessions := auth.NewSessionManager(pool, cfg.SessionCookieName, cfg.SessionCookieParentDomain(), cfg.CookieSecure(), cfg.SessionIdleHours)
srv := httpapi.NewServer(cfg, pool, sessions)
runCtx, runCancel := context.WithCancel(context.Background())
defer runCancel()
// Lightweight AI fallback poller so FAQ-miss tickets drain without a separate worker.
if srv.Support != nil {
go srv.Support.RunAutoJobsLoop(runCtx, 2*time.Second, 3)
}
httpServer := newHTTPServer(cfg.HTTPAddr, srv.Router())
ln, err := net.Listen("tcp", cfg.HTTPAddr)
if err != nil {
log.Fatalf("listen: %v", err)
}
slog.Info("api_listening", "addr", cfg.HTTPAddr, "maintenance", cfg.MaintenanceMode, "read_only", cfg.ReadOnlyMode)
go func() {
if err := httpServer.Serve(ln); err != nil && err != http.ErrServerClosed {
log.Fatalf("serve: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
runCancel()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = httpServer.Shutdown(shutdownCtx)
}
// newHTTPServer configures net/http timeouts for the API listener.
//
// Tradeoff — WriteTimeout vs long sync/export:
// WriteTimeout bounds the whole ServeHTTP + response write. Feed export
// streams and sync-style handlers can run for many minutes; a short
// WriteTimeout aborts them mid-stream (clients see truncated/hanging
// responses). We use a long WriteTimeout ceiling instead of 0 (unlimited)
// so wedged handlers still release connections eventually. Finer per-route
// deadlines belong on request contexts / middleware for normal JSON APIs.
// Leaving WriteTimeout unset (0) would never reclaim a stuck writer.
func newHTTPServer(addr string, handler http.Handler) *http.Server {
return &http.Server{
Addr: addr,
Handler: handler,
// Headers-only Slowloris guard (independent of ReadTimeout).
ReadHeaderTimeout: 10 * time.Second,
// Full request read (headers + body). Above typical API JSON uploads.
ReadTimeout: 60 * time.Second,
// Long ceiling so streaming exports/sync can finish; see comment above.
WriteTimeout: 15 * time.Minute,
// Close keep-alive connections idle between requests.
IdleTimeout: 120 * time.Second,
}
}