Skip to content
76 changes: 76 additions & 0 deletions internal/dev_server/sdk/client_context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package sdk

import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"strings"

"github.com/gorilla/mux"
"github.com/pkg/errors"
)

// maxClientContextBytes caps how much of a POST body is read while looking for the
// evaluation context. Real contexts are orders of magnitude smaller.
const maxClientContextBytes = 1 << 20

// contextPathVar is the mux variable holding the base64url-encoded context that GET
// variants of the client-side FDv2 endpoints carry in their path.
const contextPathVar = "context"

// ValidateClientContext checks that the evaluation context a client-side SDK sends with
// an FDv2 request is well-formed JSON: base64url-encoded in the path for GET, or as the
// raw request body for POST.
//
// The dev server does not support targeting. It serves the same variation of a flag no
// matter who is evaluating, so the context is never used and is not checked against the
// rules for a valid context. See
// https://launchdarkly.com/docs/guides/flags/ldcli-dev-server-reference.
func ValidateClientContext(handler http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if err := validateClientContext(writer, request); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
handler.ServeHTTP(writer, request)
})
}

func validateClientContext(writer http.ResponseWriter, request *http.Request) error {
if encoded, ok := mux.Vars(request)[contextPathVar]; ok {
decoded, err := decodeBase64Context(encoded)
if err != nil {
return errors.Wrap(err, "context in path is not valid base64")
}
return validateContextJSON(decoded)
}

body, err := io.ReadAll(http.MaxBytesReader(writer, request.Body, maxClientContextBytes))
if err != nil {
return errors.Wrap(err, "unable to read context from request body")
}
// The body is consumed here but the handlers downstream don't need the context, so
// hand back a replayable copy rather than an exhausted reader.
request.Body = io.NopCloser(bytes.NewReader(body))
return validateContextJSON(body)
}

// decodeBase64Context decodes the context segment of a GET request path. Client-side
// SDKs base64url-encode the context and strip the padding, but standard base64 and
// explicit padding are accepted too so that no SDK's encoding choice can lock it out.
func decodeBase64Context(encoded string) ([]byte, error) {
normalized := strings.NewReplacer("-", "+", "_", "/").Replace(encoded)
if remainder := len(normalized) % 4; remainder != 0 {
normalized += strings.Repeat("=", 4-remainder)
}
return base64.StdEncoding.DecodeString(normalized)
}

func validateContextJSON(data []byte) error {
if !json.Valid(data) {
return errors.New("evaluation context is not valid JSON")
}
return nil
}
65 changes: 65 additions & 0 deletions internal/dev_server/sdk/client_flag_eval.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package sdk

import (
"net/http"

"github.com/launchdarkly/go-sdk-common/v3/ldreason"
"github.com/launchdarkly/go-sdk-common/v3/ldvalue"
"github.com/launchdarkly/go-server-sdk/v7/subsystems"
"github.com/launchdarkly/ldcli/internal/dev_server/model"
)

// flagEvalKind is the object kind for client-side FDv2 put-object events. Note the
// hyphen: the SDK-side contract spells this "flag-eval", not "flag_eval".
const flagEvalKind = subsystems.ObjectKind("flag-eval")

// clientFlagEval is the object carried by a put-object event of kind flag-eval: a
// flag result the server already evaluated, rather than the flag configuration that
// fdv2ServerObjects sends.
//
// Compared with the FDv1 client-side representation (clientFlag), this drops the
// payload version — the put-object envelope carries it — and gains samplingRatio
// and trackEvents. The flag key lives on the envelope too, so it is absent here.
type clientFlagEval struct {
FlagVersion int `json:"flagVersion,omitempty"`
Value ldvalue.Value `json:"value"`
Variation int `json:"variation"`
TrackEvents bool `json:"trackEvents"`
// Reason is only populated when the SDK asked for evaluation reasons.
Reason *ldreason.EvaluationReason `json:"reason,omitempty"`
// SamplingRatio is part of the protocol but the dev server never samples, so it
// stays unset — SDKs treat an absent ratio as 1.
SamplingRatio *int `json:"samplingRatio,omitempty"`
}

// fdv2ClientObjectsForRequest builds the client-side encoder for a request, honouring
// the withReasons query parameter.
func fdv2ClientObjectsForRequest(r *http.Request) fdv2ObjectEncoder {
return fdv2ClientObjects(r.URL.Query().Get("withReasons") == "true")
}

// fdv2ClientObjects encodes flags for client-side SDKs as pre-evaluated results.
//
// The dev server does not evaluate targeting: it serves one variation per flag no
// matter who is evaluating. That variation is the only one it exposes, so it is
// always variation 0 and always the fallthrough for an on flag, which is what the
// server-side representation reports as well.
func fdv2ClientObjects(withReasons bool) fdv2ObjectEncoder {
var reason *ldreason.EvaluationReason
if withReasons {
fallthroughReason := ldreason.NewEvalReasonFallthrough()
reason = &fallthroughReason
}
return fdv2ObjectEncoder{
kind: flagEvalKind,
encode: func(_ string, flagState model.FlagState) any {
return clientFlagEval{
FlagVersion: flagState.Version,
Value: flagState.Value,
Variation: 0,
TrackEvents: flagState.TrackEvents,
Reason: reason,
}
},
}
}
13 changes: 13 additions & 0 deletions internal/dev_server/sdk/cors.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ func CorsHeadersForMethods(methods ...string) func(http.Handler) http.Handler {
)
}

// ClientFdv2CorsHeaders is the CORS configuration for the client-side FDv2 endpoints.
// Unlike the FDv1 client-side routes these accept POST rather than REPORT, and they
// accept the credential on the Authorization header as well as the auth query
// parameter.
var ClientFdv2CorsHeaders = handlers.CORS(
handlers.AllowedOrigins([]string{"*"}),
handlers.AllowedMethods([]string{"GET", "POST"}),
handlers.AllowCredentials(),
handlers.ExposedHeaders([]string{"Date"}),
handlers.AllowedHeaders([]string{"Authorization", "Cache-Control", "Content-Type", "Content-Length", "Accept-Encoding", "X-LaunchDarkly-Event-Schema", "X-LaunchDarkly-User-Agent", "X-LaunchDarkly-Payload-ID", "X-LaunchDarkly-Wrapper", "X-LaunchDarkly-Tags"}),
handlers.MaxAge(300),
)

var EventsCorsHeaders = handlers.CORS(
handlers.AllowedOrigins([]string{"*"}),
handlers.AllowedMethods([]string{"POST"}),
Expand Down
13 changes: 13 additions & 0 deletions internal/dev_server/sdk/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,17 @@
// ✅ /sdk/evalx/{envId}/users/{contextBase64} GET clientsdk. Alternate name for /sdk/evalx/{envId}/contexts/{contextBase64} used by older SDKs
// ✅ /sdk/evalx/{envId}/users REPORT clientsdk. Alternate name for /sdk/evalx/{envId}/contexts used by older SDKs
// ✅ /sdk/goals/{envId} GET clientsdk. Provides goals data used by JS SDK
//
// The routes above are the FDv1 protocol. FDv2 replaces them with the routes below, which
// carry the same flag data as a stream of protocol events. FDv2 also unifies the browser and
// mobile client-side endpoints, so a single pair of routes replaces the four FDv1 client-side
// route families (/eval/{envId}, /meval, /sdk/evalx/{envId} and /msdk/evalx). Delta transfers
// are not supported: a client whose ?basis is stale receives a full payload.
//
// ✅ /sdk/poll GET sdk. Polling endpoint for server-side SDKs
// ✅ /sdk/stream GET stream. SSE stream for server-side SDKs
// ✅ /sdk/poll/eval/{contextBase64} GET clientsdk. Polling endpoint returning evaluation results for a context
// ✅ /sdk/poll/eval POST clientsdk. Same as above, but the request body is the evaluation context JSON object (not in base64)
// ✅ /sdk/stream/eval/{contextBase64} GET clientstream. SSE stream of evaluation results for a context
// ✅ /sdk/stream/eval POST clientstream. Same as above, but the request body is the evaluation context JSON object (not in base64)
package sdk
Loading
Loading