diff --git a/cmd/siftd/main.go b/cmd/siftd/main.go index ad5558e..2f7cf11 100644 --- a/cmd/siftd/main.go +++ b/cmd/siftd/main.go @@ -27,17 +27,17 @@ const ( ) type config struct { - ListenAddr string - PostgresDSN string - RegistryPath string - OutputDir string - SyncInterval time.Duration - SyncTimeout time.Duration - RetentionWindow time.Duration - SyncOnStart bool - ZitadelIssuer string - ZitadelAudience string - WSAllowedOrigins []string + ListenAddr string + PostgresDSN string + RegistryPath string + OutputDir string + SyncInterval time.Duration + SyncTimeout time.Duration + RetentionWindow time.Duration + SyncOnStart bool + ZitadelIssuer string + ZitadelAudience string + AllowedOrigins []string } func main() { @@ -68,11 +68,11 @@ func run(ctx context.Context) error { } api, err := hosted.New(hosted.Options{ - Store: store, - Validator: validator, - OutputDir: cfg.OutputDir, - AllowedWebSocketOrigins: cfg.WSAllowedOrigins, - Now: func() time.Time { return time.Now().UTC() }, + Store: store, + Validator: validator, + OutputDir: cfg.OutputDir, + AllowedBrowserOrigins: cfg.AllowedOrigins, + Now: func() time.Time { return time.Now().UTC() }, }) if err != nil { return err @@ -190,18 +190,23 @@ func loadConfigFromEnv() (config, error) { return config{}, err } + allowedOrigins := parseCSVEnv("SIFTD_ALLOWED_ORIGINS") + if len(allowedOrigins) == 0 { + allowedOrigins = parseCSVEnv("SIFTD_WS_ALLOWED_ORIGINS") + } + cfg := config{ - ListenAddr: envOrDefault("SIFTD_ADDR", defaultListenAddr), - PostgresDSN: strings.TrimSpace(os.Getenv("SIFTD_POSTGRES_DSN")), - RegistryPath: envOrDefault("SIFTD_REGISTRY", defaultRegistryPath), - OutputDir: envOrDefault("SIFTD_OUTPUT_DIR", defaultOutputDir), - SyncInterval: syncInterval, - SyncTimeout: syncTimeout, - RetentionWindow: retentionWindow, - SyncOnStart: syncOnStart, - ZitadelIssuer: strings.TrimSpace(os.Getenv("SIFTD_ZITADEL_ISSUER")), - ZitadelAudience: strings.TrimSpace(os.Getenv("SIFTD_ZITADEL_AUDIENCE")), - WSAllowedOrigins: parseCSVEnv("SIFTD_WS_ALLOWED_ORIGINS"), + ListenAddr: envOrDefault("SIFTD_ADDR", defaultListenAddr), + PostgresDSN: strings.TrimSpace(os.Getenv("SIFTD_POSTGRES_DSN")), + RegistryPath: envOrDefault("SIFTD_REGISTRY", defaultRegistryPath), + OutputDir: envOrDefault("SIFTD_OUTPUT_DIR", defaultOutputDir), + SyncInterval: syncInterval, + SyncTimeout: syncTimeout, + RetentionWindow: retentionWindow, + SyncOnStart: syncOnStart, + ZitadelIssuer: strings.TrimSpace(os.Getenv("SIFTD_ZITADEL_ISSUER")), + ZitadelAudience: strings.TrimSpace(os.Getenv("SIFTD_ZITADEL_AUDIENCE")), + AllowedOrigins: allowedOrigins, } if cfg.PostgresDSN == "" { diff --git a/cmd/siftd/main_test.go b/cmd/siftd/main_test.go index 7c5898c..675a7bb 100644 --- a/cmd/siftd/main_test.go +++ b/cmd/siftd/main_test.go @@ -10,7 +10,7 @@ func TestLoadConfigFromEnv(t *testing.T) { t.Setenv("SIFTD_SYNC_TIMEOUT", "30s") t.Setenv("SIFTD_RETENTION", "720h") t.Setenv("SIFTD_SYNC_ON_START", "false") - t.Setenv("SIFTD_WS_ALLOWED_ORIGINS", "https://sift.local, https://console.sift.local") + t.Setenv("SIFTD_ALLOWED_ORIGINS", "https://sift.local, https://console.sift.local") cfg, err := loadConfigFromEnv() if err != nil { @@ -29,11 +29,28 @@ func TestLoadConfigFromEnv(t *testing.T) { if cfg.RetentionWindow.String() != "720h0m0s" { t.Fatalf("unexpected retention window: %s", cfg.RetentionWindow) } - if len(cfg.WSAllowedOrigins) != 2 { - t.Fatalf("unexpected ws allowed origins count: %d", len(cfg.WSAllowedOrigins)) + if len(cfg.AllowedOrigins) != 2 { + t.Fatalf("unexpected allowed origins count: %d", len(cfg.AllowedOrigins)) } - if cfg.WSAllowedOrigins[0] != "https://sift.local" { - t.Fatalf("unexpected first ws allowed origin: %s", cfg.WSAllowedOrigins[0]) + if cfg.AllowedOrigins[0] != "https://sift.local" { + t.Fatalf("unexpected first allowed origin: %s", cfg.AllowedOrigins[0]) + } +} + +func TestLoadConfigFromEnvFallsBackToLegacyWSOrigins(t *testing.T) { + t.Setenv("SIFTD_POSTGRES_DSN", "postgres://user:pass@localhost:5432/sift?sslmode=disable") + t.Setenv("SIFTD_ZITADEL_ISSUER", "https://auth.example.com") + t.Setenv("SIFTD_ZITADEL_AUDIENCE", "audience") + t.Setenv("SIFTD_ALLOWED_ORIGINS", "") + t.Setenv("SIFTD_WS_ALLOWED_ORIGINS", "https://legacy.sift.local") + + cfg, err := loadConfigFromEnv() + if err != nil { + t.Fatalf("loadConfigFromEnv returned error: %v", err) + } + + if len(cfg.AllowedOrigins) != 1 || cfg.AllowedOrigins[0] != "https://legacy.sift.local" { + t.Fatalf("unexpected legacy fallback origins: %#v", cfg.AllowedOrigins) } } diff --git a/docs/contracts/openapi.yaml b/docs/contracts/openapi.yaml index 63108ff..d830503 100644 --- a/docs/contracts/openapi.yaml +++ b/docs/contracts/openapi.yaml @@ -145,6 +145,10 @@ paths: Stream messages are advisory. Clients should re-fetch canonical records over REST when full event truth is required. + + Non-browser clients may authenticate with `Authorization: Bearer `. + Browser clients should use `Sec-WebSocket-Protocol: sift.v1, bearer.` + so the server can validate the bearer token without exposing it in the URL. responses: "101": description: Switching protocols to WebSocket diff --git a/docs/runbooks/siftd.md b/docs/runbooks/siftd.md index 9e04090..2730180 100644 --- a/docs/runbooks/siftd.md +++ b/docs/runbooks/siftd.md @@ -44,7 +44,7 @@ Before deployment, have all of the following: - reachable Postgres DSN for the shared Sift database; - reachable Zitadel issuer URL; - Zitadel audience for the API application; -- one allowed browser origin if WebSocket is used from a browser UI; +- one allowed browser origin if REST or WebSocket is used from a browser UI; - repo checkout path `/srv/sift/current`. ## Expected Host Layout @@ -107,13 +107,14 @@ Recommended variables: - `SIFTD_SYNC_TIMEOUT=4m` - `SIFTD_RETENTION=720h` - `SIFTD_SYNC_ON_START=true` -- `SIFTD_WS_ALLOWED_ORIGINS=https://console.example.com` +- `SIFTD_ALLOWED_ORIGINS=https://skill7.dev` Operational notes: - `SIFTD_RETENTION=720h` means `30d`. - `SIFTD_SYNC_TIMEOUT` must stay below `SIFTD_SYNC_INTERVAL`. -- if `SIFTD_WS_ALLOWED_ORIGINS` is set, browser clients must send one of those origins; +- if `SIFTD_ALLOWED_ORIGINS` is set, browser REST requests and browser WebSocket clients must send one of those origins; +- `SIFTD_WS_ALLOWED_ORIGINS` is still accepted as a legacy fallback alias for older deployments; - keep `SIFTD_ADDR` bound to localhost unless TLS termination is handled directly in-process. ## Start and Verify @@ -205,6 +206,14 @@ If `/readyz` stays degraded: If browser WebSocket connection fails: -- confirm the client uses `Authorization: Bearer `; -- confirm request `Origin` matches `SIFTD_WS_ALLOWED_ORIGINS`; +- confirm browser clients send `Sec-WebSocket-Protocol: sift.v1, bearer.`; +- confirm non-browser clients use `Authorization: Bearer ` if they do not support subprotocol auth; +- confirm request `Origin` matches `SIFTD_ALLOWED_ORIGINS`; - confirm the reverse proxy forwards the WebSocket upgrade headers unchanged. +- scrub or disable logging of `Sec-WebSocket-Protocol` in the reverse proxy, because `bearer.` can otherwise land in access/proxy logs. + +If browser REST requests fail with CORS errors: + +- confirm `SIFTD_ALLOWED_ORIGINS` includes the browser origin exactly; +- confirm the browser is calling the public HTTPS hostname rather than the pod or cluster IP; +- confirm the reverse proxy preserves the `Origin` header unchanged. diff --git a/internal/hosted/server.go b/internal/hosted/server.go index 966eddf..d63b5fc 100644 --- a/internal/hosted/server.go +++ b/internal/hosted/server.go @@ -21,8 +21,10 @@ import ( ) const ( - defaultListLimit = 20 - maxListLimit = 100 + defaultListLimit = 20 + maxListLimit = 100 + webSocketProtocol = "sift.v1" + webSocketBearerPrefix = "bearer." ) type EventStore interface { @@ -35,6 +37,7 @@ type Options struct { Store EventStore Validator zitadel.Validator OutputDir string + AllowedBrowserOrigins []string AllowedWebSocketOrigins []string Now func() time.Time } @@ -55,7 +58,7 @@ type Server struct { clients map[*wsClient]struct{} upgrader websocket.Upgrader - allowedWebSocketOrigins map[string]struct{} + allowedBrowserOrigins map[string]struct{} } type wsClient struct { @@ -101,25 +104,28 @@ func New(options Options) (*Server, error) { now = func() time.Time { return time.Now().UTC() } } - allowedOrigins := make(map[string]struct{}, len(options.AllowedWebSocketOrigins)) - for _, rawOrigin := range options.AllowedWebSocketOrigins { + allowedOrigins := make(map[string]struct{}, len(options.AllowedBrowserOrigins)+len(options.AllowedWebSocketOrigins)) + for _, rawOrigin := range append(options.AllowedBrowserOrigins, options.AllowedWebSocketOrigins...) { origin, err := normalizeOrigin(rawOrigin) if err != nil { - return nil, fmt.Errorf("invalid websocket allowed origin %q: %w", rawOrigin, err) + return nil, fmt.Errorf("invalid allowed origin %q: %w", rawOrigin, err) } allowedOrigins[origin] = struct{}{} } server := &Server{ - store: options.Store, - validator: options.Validator, - outputDir: outputDir, - now: now, - clients: make(map[*wsClient]struct{}), - allowedWebSocketOrigins: allowedOrigins, + store: options.Store, + validator: options.Validator, + outputDir: outputDir, + now: now, + clients: make(map[*wsClient]struct{}), + allowedBrowserOrigins: allowedOrigins, } server.upgrader = websocket.Upgrader{ CheckOrigin: server.checkWebSocketOrigin, + Subprotocols: []string{ + webSocketProtocol, + }, } return server, nil @@ -146,28 +152,33 @@ func normalizeOrigin(raw string) (string, error) { } func (s *Server) checkWebSocketOrigin(r *http.Request) bool { - originHeader := strings.TrimSpace(r.Header.Get("Origin")) + _, ok := s.allowedOrigin(r.Header.Get("Origin"), r.Host) + return ok +} + +func (s *Server) allowedOrigin(rawOrigin, requestHost string) (string, bool) { + originHeader := strings.TrimSpace(rawOrigin) if originHeader == "" { // If an explicit allowlist is configured, require Origin header presence. - return len(s.allowedWebSocketOrigins) == 0 + return "", len(s.allowedBrowserOrigins) == 0 } origin, err := normalizeOrigin(originHeader) if err != nil { - return false + return "", false } - if len(s.allowedWebSocketOrigins) > 0 { - _, ok := s.allowedWebSocketOrigins[origin] - return ok + if len(s.allowedBrowserOrigins) > 0 { + _, ok := s.allowedBrowserOrigins[origin] + return origin, ok } originURL, err := url.Parse(origin) if err != nil { - return false + return "", false } - return strings.EqualFold(originURL.Host, r.Host) + return origin, strings.EqualFold(originURL.Host, requestHost) } func (s *Server) Handler() http.Handler { @@ -178,7 +189,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/v1/events/", s.requireAuth(s.handleGetEvent)) mux.HandleFunc("/v1/digests/", s.requireAuth(s.handleGetDigest)) mux.HandleFunc("/v1/ws", s.requireAuth(s.handleWebSocket)) - return mux + return s.withCORS(mux) } func (s *Server) MarkSyncSuccess(runID string, at time.Time) { @@ -411,6 +422,63 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { } } +func (s *Server) withCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !isRESTAPIPath(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + + applyCORSVaryHeaders(w.Header()) + + origin, ok := s.allowedOrigin(r.Header.Get("Origin"), r.Host) + if strings.TrimSpace(r.Header.Get("Origin")) != "" && !ok { + writeJSONError(w, http.StatusForbidden, "origin not allowed") + return + } + + if ok && origin != "" { + applyCORSHeaders(w.Header(), origin) + } + + if isPreflightRequest(r) { + if !ok || origin == "" { + writeJSONError(w, http.StatusForbidden, "origin not allowed") + return + } + if !strings.EqualFold(strings.TrimSpace(r.Header.Get("Access-Control-Request-Method")), http.MethodGet) { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} + +func isRESTAPIPath(path string) bool { + return path == "/v1/events" || strings.HasPrefix(path, "/v1/events/") || strings.HasPrefix(path, "/v1/digests/") +} + +func isPreflightRequest(r *http.Request) bool { + return r.Method == http.MethodOptions && strings.TrimSpace(r.Header.Get("Access-Control-Request-Method")) != "" +} + +func applyCORSHeaders(header http.Header, origin string) { + header.Set("Access-Control-Allow-Origin", origin) + header.Set("Access-Control-Allow-Methods", "GET, OPTIONS") + header.Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + header.Set("Access-Control-Max-Age", "600") +} + +func applyCORSVaryHeaders(header http.Header) { + header.Add("Vary", "Origin") + header.Add("Vary", "Access-Control-Request-Method") + header.Add("Vary", "Access-Control-Request-Headers") +} + func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token, err := tokenFromRequest(r) @@ -432,10 +500,27 @@ func tokenFromRequest(r *http.Request) (string, error) { if token, err := zitadel.ExtractBearerToken(r.Header.Get("Authorization")); err == nil { return token, nil } + if websocket.IsWebSocketUpgrade(r) { + if token, ok := tokenFromWebSocketSubprotocols(r); ok { + return token, nil + } + } return "", fmt.Errorf("missing bearer token") } +func tokenFromWebSocketSubprotocols(r *http.Request) (string, bool) { + for _, protocol := range websocket.Subprotocols(r) { + if strings.HasPrefix(protocol, webSocketBearerPrefix) { + token := strings.TrimPrefix(protocol, webSocketBearerPrefix) + if token != "" { + return token, true + } + } + } + return "", false +} + func (s *Server) registerClient(client *wsClient) { s.wsMu.Lock() defer s.wsMu.Unlock() diff --git a/internal/hosted/server_test.go b/internal/hosted/server_test.go index 5aae1ca..61c7609 100644 --- a/internal/hosted/server_test.go +++ b/internal/hosted/server_test.go @@ -179,6 +179,167 @@ func TestListEventsFiltersAndLimit(t *testing.T) { } } +func TestListEventsAddsCORSHeadersForAllowedOrigin(t *testing.T) { + t.Parallel() + + srv, err := New(Options{ + Store: &fakeStore{ + events: []event.Record{{EventID: "evt_btc", Category: "crypto", Title: "BTC event"}}, + }, + Validator: fakeValidator{ + validToken: "token123", + }, + OutputDir: "output", + AllowedBrowserOrigins: []string{"https://skill7.dev"}, + Now: fixedNow, + }) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/v1/events", nil) + req.Header.Set("Authorization", "Bearer token123") + req.Header.Set("Origin", "https://skill7.dev") + recorder := httptest.NewRecorder() + srv.Handler().ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", recorder.Code, recorder.Body.String()) + } + if recorder.Header().Get("Access-Control-Allow-Origin") != "https://skill7.dev" { + t.Fatalf("unexpected allow origin header: %q", recorder.Header().Get("Access-Control-Allow-Origin")) + } +} + +func TestRESTAddsVaryHeadersWithoutOrigin(t *testing.T) { + t.Parallel() + + srv, err := New(Options{ + Store: &fakeStore{ + events: []event.Record{{EventID: "evt_btc", Category: "crypto", Title: "BTC event"}}, + }, + Validator: fakeValidator{ + validToken: "token123", + }, + OutputDir: "output", + Now: fixedNow, + }) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/v1/events", nil) + req.Header.Set("Authorization", "Bearer token123") + recorder := httptest.NewRecorder() + srv.Handler().ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", recorder.Code, recorder.Body.String()) + } + if recorder.Header().Get("Access-Control-Allow-Origin") != "" { + t.Fatalf("unexpected allow origin header: %q", recorder.Header().Get("Access-Control-Allow-Origin")) + } + + vary := recorder.Header().Values("Vary") + for _, expected := range []string{"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"} { + if !containsString(vary, expected) { + t.Fatalf("expected Vary to contain %q, got %q", expected, vary) + } + } +} + +func TestListEventsAllowsSameOriginFallbackWhenAllowlistEmpty(t *testing.T) { + t.Parallel() + + srv, err := New(Options{ + Store: &fakeStore{ + events: []event.Record{{EventID: "evt_btc", Category: "crypto", Title: "BTC event"}}, + }, + Validator: fakeValidator{ + validToken: "token123", + }, + OutputDir: "output", + Now: fixedNow, + }) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "https://api.sift.local/v1/events", nil) + req.Header.Set("Authorization", "Bearer token123") + req.Header.Set("Origin", "https://api.sift.local") + recorder := httptest.NewRecorder() + srv.Handler().ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", recorder.Code, recorder.Body.String()) + } + if recorder.Header().Get("Access-Control-Allow-Origin") != "https://api.sift.local" { + t.Fatalf("unexpected allow origin header: %q", recorder.Header().Get("Access-Control-Allow-Origin")) + } +} + +func TestRESTPreflightAllowsConfiguredOrigin(t *testing.T) { + t.Parallel() + + srv, err := New(Options{ + Store: &fakeStore{}, + Validator: fakeValidator{}, + OutputDir: "output", + AllowedBrowserOrigins: []string{ + "https://skill7.dev", + }, + Now: fixedNow, + }) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + req := httptest.NewRequest(http.MethodOptions, "/v1/events", nil) + req.Header.Set("Origin", "https://skill7.dev") + req.Header.Set("Access-Control-Request-Method", http.MethodGet) + req.Header.Set("Access-Control-Request-Headers", "Authorization") + recorder := httptest.NewRecorder() + srv.Handler().ServeHTTP(recorder, req) + + if recorder.Code != http.StatusNoContent { + t.Fatalf("unexpected status: %d body=%s", recorder.Code, recorder.Body.String()) + } + if recorder.Header().Get("Access-Control-Allow-Origin") != "https://skill7.dev" { + t.Fatalf("unexpected allow origin header: %q", recorder.Header().Get("Access-Control-Allow-Origin")) + } + if !strings.Contains(recorder.Header().Get("Access-Control-Allow-Headers"), "Authorization") { + t.Fatalf("unexpected allow headers: %q", recorder.Header().Get("Access-Control-Allow-Headers")) + } +} + +func TestRESTPreflightRejectsDisallowedOrigin(t *testing.T) { + t.Parallel() + + srv, err := New(Options{ + Store: &fakeStore{}, + Validator: fakeValidator{}, + OutputDir: "output", + AllowedBrowserOrigins: []string{ + "https://skill7.dev", + }, + Now: fixedNow, + }) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + req := httptest.NewRequest(http.MethodOptions, "/v1/events", nil) + req.Header.Set("Origin", "https://evil.example") + req.Header.Set("Access-Control-Request-Method", http.MethodGet) + recorder := httptest.NewRecorder() + srv.Handler().ServeHTTP(recorder, req) + + if recorder.Code != http.StatusForbidden { + t.Fatalf("unexpected status: %d body=%s", recorder.Code, recorder.Body.String()) + } +} + func TestGetEventNotFound(t *testing.T) { t.Parallel() @@ -484,6 +645,65 @@ func TestWebSocketAllowsConfiguredOrigin(t *testing.T) { } } +func TestWebSocketAllowsSubprotocolTokenAuthentication(t *testing.T) { + t.Parallel() + + srv, err := New(Options{ + Store: &fakeStore{}, + Validator: fakeValidator{ + validToken: "token123", + }, + OutputDir: "output", + AllowedBrowserOrigins: []string{"https://skill7.dev"}, + Now: fixedNow, + }) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + httpServer := httptest.NewServer(srv.Handler()) + defer httpServer.Close() + + wsURL, err := url.Parse(httpServer.URL) + if err != nil { + t.Fatalf("parse test server url: %v", err) + } + wsURL.Scheme = "ws" + wsURL.Path = "/v1/ws" + + header := http.Header{} + header.Set("Origin", "https://skill7.dev") + + dialer := websocket.Dialer{ + Subprotocols: []string{ + webSocketProtocol, + webSocketBearerPrefix + "token123", + }, + } + conn, _, err := dialer.Dial(wsURL.String(), header) + if err != nil { + t.Fatalf("dial websocket with subprotocol token: %v", err) + } + defer conn.Close() + + if conn.Subprotocol() != webSocketProtocol { + t.Fatalf("unexpected negotiated subprotocol: %q", conn.Subprotocol()) + } + + _, message, err := conn.ReadMessage() + if err != nil { + t.Fatalf("read connected message: %v", err) + } + + var connected streamEnvelope + if err := json.Unmarshal(message, &connected); err != nil { + t.Fatalf("decode connected message: %v", err) + } + if connected.Type != "connected" { + t.Fatalf("unexpected connected message type: %s", connected.Type) + } +} + func TestWebSocketRejectsQueryTokenAuthentication(t *testing.T) { t.Parallel() @@ -567,3 +787,12 @@ func TestWebSocketRejectsMissingOriginWhenAllowlistConfigured(t *testing.T) { func fixedNow() time.Time { return time.Date(2026, 3, 8, 10, 0, 0, 0, time.UTC) } + +func containsString(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} diff --git a/ops/env/siftd.env.example b/ops/env/siftd.env.example index 43c0847..84f389d 100644 --- a/ops/env/siftd.env.example +++ b/ops/env/siftd.env.example @@ -6,6 +6,6 @@ SIFTD_SYNC_INTERVAL=5m SIFTD_SYNC_TIMEOUT=4m SIFTD_RETENTION=720h SIFTD_SYNC_ON_START=true +SIFTD_ALLOWED_ORIGINS=https://skill7.dev SIFTD_ZITADEL_ISSUER=https://auth.example.com SIFTD_ZITADEL_AUDIENCE=sift-api -SIFTD_WS_ALLOWED_ORIGINS=https://console.example.com