Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ PORT=8080
# Valid values: debug, info, warn, error
LOG_LEVEL=info

# Taxonomy service integration (optional; beta)
# TAXONOMY_SERVICE_URL is the internal URL Hub uses to call the standalone taxonomy service.
# TAXONOMY_SERVICE_TOKEN is sent by Hub as Authorization: Bearer <token> to the taxonomy service.
# HUB_INTERNAL_API_TOKEN protects Hub internal taxonomy APIs called by the taxonomy service.
# These values are server-side secrets/config only; do not expose them to browsers.
# TAXONOMY_SERVICE_URL=http://localhost:8000
# TAXONOMY_SERVICE_TOKEN=dev-taxonomy-service-token
# HUB_INTERNAL_API_TOKEN=dev-hub-internal-api-token

# Message publisher: event channel buffer size (optional). Default: 1024
MESSAGE_PUBLISHER_QUEUE_MAX_SIZE=16384

Expand Down
8 changes: 8 additions & 0 deletions charts/hub/templates/NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ Recommended production configuration
- PUBLIC_BASE_URL (ConfigMap/env): set this when Hub is exposed through ingress, TLS termination,
or a reverse-proxy path prefix so /openapi.yaml and /openapi.json advertise the correct public URL.
- hub-worker: keep values.worker.enabled=true unless another deployment processes the same River queues.

Beta taxonomy configuration
---------------------------
- TAXONOMY_SERVICE_URL (ConfigMap/env): internal URL for the standalone taxonomy service.
- TAXONOMY_SERVICE_TOKEN (Secret): token Hub sends to the standalone taxonomy service.
- HUB_INTERNAL_API_TOKEN (Secret): protects /internal/v1/taxonomy/* when taxonomy internals are enabled.
{{- if .Values.embeddings.enabled }}
- embeddings: provide values.embeddings.huggingFace.token or values.embeddings.huggingFace.existingSecret
only when using private or gated models.
Expand All @@ -28,6 +34,8 @@ Secret management options (choose one):
values.secrets.create=true and set:
values.secrets.stringData.DATABASE_URL
values.secrets.stringData.API_KEY
values.secrets.stringData.TAXONOMY_SERVICE_TOKEN
values.secrets.stringData.HUB_INTERNAL_API_TOKEN

2) Existing Secret:
values.secrets.existingSecret=<name>
Expand Down
6 changes: 6 additions & 0 deletions charts/hub/templates/secret.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,10 @@ stringData:
{{- with .Values.secrets.stringData.EMBEDDING_PROVIDER_API_KEY }}
EMBEDDING_PROVIDER_API_KEY: {{ . | quote }}
{{- end }}
{{- with .Values.secrets.stringData.TAXONOMY_SERVICE_TOKEN }}
TAXONOMY_SERVICE_TOKEN: {{ . | quote }}
{{- end }}
{{- with .Values.secrets.stringData.HUB_INTERNAL_API_TOKEN }}
HUB_INTERNAL_API_TOKEN: {{ . | quote }}
{{- end }}
{{- end }}
9 changes: 9 additions & 0 deletions charts/hub/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,9 @@ config:
# Optional: use with EMBEDDING_PROVIDER=openai to target a self-hosted OpenAI-compatible embeddings endpoint.
# Example: http://text-embeddings-inference.default.svc.cluster.local/v1
EMBEDDING_BASE_URL: ""
# Optional: internal URL Hub uses to start taxonomy generation jobs.
# Example: http://taxonomy.default.svc.cluster.local:8000
TAXONOMY_SERVICE_URL: ""

secrets:
create: true
Expand All @@ -330,6 +333,10 @@ secrets:
API_KEY: ""
# Optional: required only when EMBEDDING_PROVIDER is openai or google (AI Studio)
EMBEDDING_PROVIDER_API_KEY: ""
# Optional: required when Hub starts taxonomy generation jobs
TAXONOMY_SERVICE_TOKEN: ""
# Optional: required when Hub internal taxonomy APIs are enabled
HUB_INTERNAL_API_TOKEN: ""
externalSecrets:
enabled: false
# Name of existing SecretStore resource for AWS Secrets Manager
Expand All @@ -343,6 +350,8 @@ secrets:
- DATABASE_URL
- API_KEY
- EMBEDDING_PROVIDER_API_KEY
- TAXONOMY_SERVICE_TOKEN
- HUB_INTERNAL_API_TOKEN

extraEnv: []
extraEnvFrom: []
Expand Down
15 changes: 14 additions & 1 deletion cmd/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {
tenantDataHandler := handlers.NewTenantDataHandler(tenantDataService)

feedbackRecordsHandler := handlers.NewFeedbackRecordsHandler(feedbackRecordsService)
taxonomyInternalHandler := handlers.NewTaxonomyInternalHandler()
healthHandler := handlers.NewHealthHandler()

openapiHandler, err := handlers.NewOpenAPIHandler(handlers.ResolveOpenAPISpecPath(), cfg.Server.PublicBaseURL)
Expand All @@ -333,6 +334,7 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {

server := newHTTPServer(
cfg, healthHandler, openapiHandler, feedbackRecordsHandler, webhooksHandler, tenantDataHandler, searchHandler,
taxonomyInternalHandler,
meterProvider, tracerProvider,
)

Expand All @@ -348,7 +350,8 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {
}, nil
}

// newHTTPServer builds the HTTP server and muxes (no auth on /health or /openapi.*, API key on /v1/).
// newHTTPServer builds the HTTP server and muxes (no auth on /health or /openapi.*, API key on /v1/,
// internal taxonomy token on /internal/v1/taxonomy/ when configured).
// Handler chain: RequestID -> otelhttp(Logging(mux)) so access logs get trace_id/span_id from context.
func newHTTPServer(
cfg *config.Config,
Expand All @@ -358,6 +361,7 @@ func newHTTPServer(
webhooks *handlers.WebhooksHandler,
tenantData *handlers.TenantDataHandler,
search *handlers.SearchHandler,
taxonomyInternal *handlers.TaxonomyInternalHandler,
meterProvider *sdkmetric.MeterProvider,
tracerProvider *sdktrace.TracerProvider,
) *http.Server {
Expand Down Expand Up @@ -386,8 +390,17 @@ func newHTTPServer(
protected.HandleFunc("GET /v1/feedback-records/{id}/similar", search.SimilarFeedback)

protectedWithAuth := middleware.Auth(cfg.Server.HubAPIKey)(protected)

mux := http.NewServeMux()
mux.Handle("/v1/", protectedWithAuth)

if cfg.Taxonomy.HubInternalAPIToken != "" {
Comment thread
BhagyaAmarasinghe marked this conversation as resolved.
internalTaxonomy := http.NewServeMux()
internalTaxonomy.HandleFunc("GET /internal/v1/taxonomy/auth-check", taxonomyInternal.AuthCheck)
internalTaxonomyWithAuth := middleware.Auth(cfg.Taxonomy.HubInternalAPIToken)(internalTaxonomy)
mux.Handle("/internal/v1/taxonomy/", internalTaxonomyWithAuth)
}

mux.Handle("/", public)

otelOpts := []otelhttp.Option{
Expand Down
94 changes: 94 additions & 0 deletions cmd/api/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,90 @@ func TestNewHTTPServerKeepsTenantDataRoutesProtected(t *testing.T) {
}
}

func TestNewHTTPServerInternalTaxonomyRouteRequiresInternalToken(t *testing.T) {
server := newTestHTTPServer(t)

tests := []struct {
name string
authHeader string
wantStatus int
wantBodyMatch string
}{
{
name: "missing auth",
wantStatus: http.StatusUnauthorized,
},
{
name: "malformed auth",
authHeader: "Basic test-internal-token",
wantStatus: http.StatusUnauthorized,
},
{
name: "public API key rejected",
authHeader: "Bearer test-api-key",
wantStatus: http.StatusUnauthorized,
},
{
name: "wrong internal token rejected",
authHeader: "Bearer wrong-token",
wantStatus: http.StatusUnauthorized,
},
{
name: "internal token accepted",
authHeader: "Bearer test-internal-token",
wantStatus: http.StatusOK,
wantBodyMatch: "hub-taxonomy-internal",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := httptest.NewRecorder()

request := httptest.NewRequestWithContext(
Comment thread
BhagyaAmarasinghe marked this conversation as resolved.
context.Background(),
http.MethodGet,
"/internal/v1/taxonomy/auth-check",
http.NoBody,
)
if tt.authHeader != "" {
request.Header.Set("Authorization", tt.authHeader)
}

server.Handler.ServeHTTP(recorder, request)

if recorder.Code != tt.wantStatus {
t.Fatalf("GET /internal/v1/taxonomy/auth-check status = %d, want %d; body=%s",
recorder.Code, tt.wantStatus, recorder.Body.String())
}

if tt.wantBodyMatch != "" && !strings.Contains(recorder.Body.String(), tt.wantBodyMatch) {
t.Fatalf("GET /internal/v1/taxonomy/auth-check body = %s, want %q",
recorder.Body.String(), tt.wantBodyMatch)
}
})
}
}

func TestNewHTTPServerInternalTaxonomyRouteDisabledWithoutToken(t *testing.T) {
server := newTestHTTPServerWithConfig(t, "https://hub.example.com/base", config.TaxonomyConfig{})

recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet,
"/internal/v1/taxonomy/auth-check",
http.NoBody,
)

server.Handler.ServeHTTP(recorder, request)

if recorder.Code != http.StatusNotFound {
t.Fatalf("GET /internal/v1/taxonomy/auth-check status = %d, want %d",
recorder.Code, http.StatusNotFound)
}
}

func newTestHTTPServer(t *testing.T) *http.Server {
t.Helper()

Expand All @@ -267,11 +351,20 @@ func newTestHTTPServer(t *testing.T) *http.Server {
func newTestHTTPServerWithPublicBaseURL(t *testing.T, publicBaseURL string) *http.Server {
t.Helper()

return newTestHTTPServerWithConfig(t, publicBaseURL, config.TaxonomyConfig{
HubInternalAPIToken: "test-internal-token",
})
}

func newTestHTTPServerWithConfig(t *testing.T, publicBaseURL string, taxonomy config.TaxonomyConfig) *http.Server {
t.Helper()

cfg := &config.Config{
Server: config.ServerConfig{
Port: "0",
HubAPIKey: "test-api-key",
},
Taxonomy: taxonomy,
}

return newHTTPServer(
Expand All @@ -282,6 +375,7 @@ func newTestHTTPServerWithPublicBaseURL(t *testing.T, publicBaseURL string) *htt
handlers.NewWebhooksHandler(nil),
handlers.NewTenantDataHandler(nil),
handlers.NewSearchHandler(nil),
handlers.NewTaxonomyInternalHandler(),
nil,
nil,
)
Expand Down
23 changes: 23 additions & 0 deletions internal/api/handlers/taxonomy_internal_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package handlers

import (
"net/http"

"github.com/formbricks/hub/internal/api/response"
)

// TaxonomyInternalHandler hosts internal taxonomy service endpoints.
type TaxonomyInternalHandler struct{}

// NewTaxonomyInternalHandler creates a taxonomy internal handler.
func NewTaxonomyInternalHandler() *TaxonomyInternalHandler {
return &TaxonomyInternalHandler{}
}

// AuthCheck returns success after middleware.Auth enforces the internal Hub API token.
func (h *TaxonomyInternalHandler) AuthCheck(w http.ResponseWriter, _ *http.Request) {
response.RespondJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"service": "hub-taxonomy-internal",
})
}
18 changes: 18 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var (
ErrDatabaseMinConnsExceedsMax = errors.New("DATABASE_MIN_CONNS must not exceed DATABASE_MAX_CONNS")
ErrInvalidPublicBaseURL = errors.New("PUBLIC_BASE_URL must be an absolute http(s) URL without query or fragment")
ErrInvalidEmbeddingBaseURL = errors.New("EMBEDDING_BASE_URL must be an absolute http(s) URL without query or fragment")
ErrInvalidTaxonomyServiceURL = errors.New("TAXONOMY_SERVICE_URL must be an absolute http(s) URL without query or fragment")
)

// DefaultDatabaseURL is the default connection URL when DATABASE_URL is unset (local/test only).
Expand All @@ -44,6 +45,7 @@ type Config struct {
Webhook WebhookConfig
MessagePublisher MessagePublisherConfig
Embedding EmbeddingConfig
Taxonomy TaxonomyConfig
Observability ObservabilityConfig
}

Expand Down Expand Up @@ -124,6 +126,13 @@ type EmbeddingConfig struct {
GoogleCloudLocation string `env:"EMBEDDING_GOOGLE_CLOUD_LOCATION"`
}

// TaxonomyConfig holds Hub-to-taxonomy service settings.
type TaxonomyConfig struct {
ServiceURL string `env:"TAXONOMY_SERVICE_URL"`
ServiceToken string `env:"TAXONOMY_SERVICE_TOKEN"`
HubInternalAPIToken string `env:"HUB_INTERNAL_API_TOKEN"`
}

// ObservabilityConfig holds OpenTelemetry settings.
type ObservabilityConfig struct {
MetricsExporter string `env:"OTEL_METRICS_EXPORTER"`
Expand Down Expand Up @@ -331,6 +340,15 @@ func validate(cfg *Config) error {
cfg.Embedding.BaseURL = normalized
}

if cfg.Taxonomy.ServiceURL != "" {
normalized, err := normalizeHTTPBaseURL(cfg.Taxonomy.ServiceURL, ErrInvalidTaxonomyServiceURL)
if err != nil {
return err
}

cfg.Taxonomy.ServiceURL = normalized
}

return nil
}

Expand Down
51 changes: 51 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,57 @@ func TestLoad_EmbeddingBaseURLValidation(t *testing.T) {
}
}

func TestLoad_TaxonomyConfig(t *testing.T) {
t.Setenv("TAXONOMY_SERVICE_URL", "https://taxonomy.example.com/root/")
t.Setenv("TAXONOMY_SERVICE_TOKEN", "taxonomy-service-token")
t.Setenv("HUB_INTERNAL_API_TOKEN", "hub-internal-token")

cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}

if cfg.Taxonomy.ServiceURL != "https://taxonomy.example.com/root" {
t.Errorf("Taxonomy.ServiceURL = %q, want https://taxonomy.example.com/root", cfg.Taxonomy.ServiceURL)
}

if cfg.Taxonomy.ServiceToken != "taxonomy-service-token" {
t.Errorf("Taxonomy.ServiceToken = %q, want taxonomy-service-token", cfg.Taxonomy.ServiceToken)
}

if cfg.Taxonomy.HubInternalAPIToken != "hub-internal-token" {
t.Errorf("Taxonomy.HubInternalAPIToken = %q, want hub-internal-token", cfg.Taxonomy.HubInternalAPIToken)
}
}

func TestLoad_TaxonomyServiceURLValidation(t *testing.T) {
tests := []struct {
name string
value string
}{
{name: "rejects relative url", value: "/taxonomy"},
{name: "rejects unsupported scheme", value: "ftp://taxonomy.example.com"},
{name: "rejects query", value: "https://taxonomy.example.com?x=1"},
{name: "rejects fragment", value: "https://taxonomy.example.com#frag"},
{name: "rejects user info", value: "https://user:pass@taxonomy.example.com"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("TAXONOMY_SERVICE_URL", tt.value)

_, err := Load()
if err == nil {
t.Fatalf("Load() error = nil, want error")
}

if !errors.Is(err, ErrInvalidTaxonomyServiceURL) {
t.Fatalf("Load() error = %v, want %v", err, ErrInvalidTaxonomyServiceURL)
}
})
}
}

func TestLoad_PublicBaseURLValidation(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading
Loading