package config import ( "encoding/base64" "fmt" "os" "strconv" "strings" "sync" "time" "unicode" ) // maxAppIdLen caps the app id length. Long enough for UUIDs (36), ULIDs (26), // Expo project ids (36), and reasonable slug prefixes; short enough that a // hostile or typo'd value can't DoS our path-building or map-keying paths. const maxAppIdLen = 64 // reservedAppIds are names that collide with top-level HTTP routes. Gorilla // mux resolves static routes before pattern routes, so a config with an app // id of "dashboard" would never receive traffic on /{APP_ID}/…, routes would // route to the dashboard static handler instead. Rejecting these at boot // surfaces the misconfiguration before it becomes a silent outage. var reservedAppIds = map[string]struct{}{ "api": {}, "assets": {}, "auth": {}, "dashboard": {}, "hc": {}, "manifest": {}, "metrics": {}, } // KeysMode identifies how an app's signing key pair is stored. type KeysMode string const ( KeysModeLocal KeysMode = "local" KeysModeAWSSM KeysMode = "aws-secrets-manager" KeysModeEnvironment KeysMode = "environment" KeysModeDatabase KeysMode = "database" ) // KeysConfig is a tagged union: exactly one set of fields must be populated, // matched by Mode. Validated at LoadConfig time, downstream code assumes a // valid config and does not re-check invariants. type KeysConfig struct { Mode KeysMode `json:"mode"` // mode=local PublicPath string `json:"publicPath,omitempty"` PrivatePath string `json:"privatePath,omitempty"` // mode=aws-secrets-manager PublicSecretId string `json:"publicSecretId,omitempty"` PrivateSecretId string `json:"privateSecretId,omitempty"` // mode=environment PublicB64 string `json:"publicB64,omitempty"` PrivateB64 string `json:"privateB64,omitempty"` // mode=database SealedPublicKey string `json:"sealedPublicKey,omitempty"` SealedPrivateKey string `json:"sealedPrivateKey,omitempty"` } // AppConfig is the resolved configuration for one app. In stateless mode it // is built from the flat env vars; in control-plane mode it is hydrated from // a database row. Each app has its own identity (id, accessToken) and signing // key pair. Name is optional and used purely as a display label in the // dashboard, it does not participate in request routing, which always goes // by Id. type AppConfig struct { Id string `json:"id"` Name string `json:"name,omitempty"` GitURL string `json:"gitUrl,omitempty"` AccessToken string `json:"accessToken,omitempty"` Keys KeysConfig `json:"keys"` CreatedAt time.Duration `json:"createTime,omitempty"` } // AppDescriptor is the public-safe view of an AppConfig (no token, no keys) // intended for dashboard listings and anything else that needs to enumerate // apps without touching secrets. type AppDescriptor struct { Id string `json:"id"` Name string `json:"name,omitempty"` } var ( appsByIdMu sync.RWMutex appsById map[string]*AppConfig ) // LoadAppsFromFlatEnv resolves the single-app stateless config from the flat // env vars (EXPO_APP_ID, EXPO_ACCESS_TOKEN, KEYS_STORAGE_TYPE and its mode- // specific siblings), validates it, and caches the result in memory. This // mirrors the v1 env layout unchanged, so a v1 install upgrades to v2 with // zero config changes. Multi-app deployments are served by the control plane // (DB mode), which loads apps from the database and never calls this. // // Must be called once from wire.go (stateless branch) before any handler // resolves an app. Returns a non-nil error on any structural or semantic // issue; callers are expected to log.Fatal on error. func LoadAppsFromFlatEnv() error { apps, source, err := ReadAppsFromFlatEnv() if err != nil { return err } if len(apps) == 0 { return fmt.Errorf("%s: must declare at least one app", source) } index := make(map[string]*AppConfig, len(apps)) for i := range apps { app := &apps[i] if err := validateApp(app, i); err != nil { return fmt.Errorf("%s: %w", source, err) } if _, dup := index[app.Id]; dup { return fmt.Errorf("%s: duplicate app id %q", source, app.Id) } index[app.Id] = app } appsByIdMu.Lock() appsById = index appsByIdMu.Unlock() return nil } // ReadAppsFromFlatEnv returns the parsed (but not yet validated) single-app // config plus a human-readable source tag used for error messages. It reads // the legacy v1 variable names verbatim to preserve upgrade-in-place. Returned // as a slice so callers (LoadAppsFromFlatEnv, the infra→DB migration) share one // iteration shape whether the config carries one app (stateless) or many // (control plane). func ReadAppsFromFlatEnv() ([]AppConfig, string, error) { if appId := strings.TrimSpace(os.Getenv("EXPO_APP_ID")); appId != "" { return []AppConfig{parseFlatEnvApp(appId)}, "flat env (EXPO_APP_ID)", nil } return nil, "", fmt.Errorf("no app config found: set EXPO_APP_ID + EXPO_ACCESS_TOKEN + key vars (multi-app deployments are managed by the control plane / DB mode)") } // LegacyFallbackAppId names the app that manifest and asset requests arriving // without an expo-app-id header belong to, or "" when no such app exists. // // v1 clients predate the header. It is baked into Expo.plist / // AndroidManifest.xml at build time, so an already-installed v1 binary can // never start sending it without a store release, and an OTA server that // requires a store release to keep serving OTA updates has defeated its own // purpose. EXPO_APP_ID is the Expo project id v1 already identified the deploy // by, and the infra→DB migration carries it over verbatim as the apps row key, // so it names the legacy app whether the deploy stayed stateless or grew into a // control plane. A control plane created fresh leaves it unset and gets "" back, // which keeps the header mandatory exactly where no legacy client can exist. // // Adding more apps later does not make this ambiguous: their clients are v2 and // send the header, so a header-less request still belongs to the legacy app. func LegacyFallbackAppId() string { if skip, _ := strconv.ParseBool(GetEnv("SKIP_LEGACY_APP_ID_FALLBACK")); skip { return "" } return strings.TrimSpace(GetEnv("EXPO_APP_ID")) } // parseFlatEnvApp reads the v1 single-app env vars and returns an AppConfig. // Validation is deferred to validateApp so the error path is uniform with the // JSON case. func parseFlatEnvApp(appId string) AppConfig { app := AppConfig{ Id: appId, AccessToken: os.Getenv("EXPO_ACCESS_TOKEN"), } switch os.Getenv("KEYS_STORAGE_TYPE") { case "local", "": // "local" is the default when unset, matching v1 DefaultEnvValues. app.Keys = KeysConfig{ Mode: KeysModeLocal, PublicPath: os.Getenv("PUBLIC_LOCAL_EXPO_KEY_PATH"), PrivatePath: os.Getenv("PRIVATE_LOCAL_EXPO_KEY_PATH"), } case "aws-secrets-manager": app.Keys = KeysConfig{ Mode: KeysModeAWSSM, PublicSecretId: os.Getenv("AWSSM_EXPO_PUBLIC_KEY_SECRET_ID"), PrivateSecretId: os.Getenv("AWSSM_EXPO_PRIVATE_KEY_SECRET_ID"), } case "environment": app.Keys = KeysConfig{ Mode: KeysModeEnvironment, PublicB64: os.Getenv("PUBLIC_EXPO_KEY_B64"), PrivateB64: os.Getenv("PRIVATE_EXPO_KEY_B64"), } default: // Leave Mode empty; validateApp will surface a clear error naming // the invalid KEYS_STORAGE_TYPE in the boot log. } return app } func validateApp(app *AppConfig, index int) error { prefix := fmt.Sprintf("apps[%d]", index) if err := ValidateAppId(app.Id, prefix+".id"); err != nil { return err } if app.AccessToken == "" { return fmt.Errorf("%s.accessToken is required", prefix) } return ValidateKeys(&app.Keys, prefix+".keys") } // ValidateAppId centralizes every rule an app id must satisfy before it can // be used as a path segment, map key, or route parameter. Kept together so // the constraints stay in sync with isValidAppID / validateSegment. func ValidateAppId(id, fieldPath string) error { if id == "" { return fmt.Errorf("%s is required", fieldPath) } if len(id) > maxAppIdLen { return fmt.Errorf("%s %q exceeds max length %d", fieldPath, id, maxAppIdLen) } // Reserved filesystem names, match validateSegment / isValidAppID so // every id-validation path agrees. "." and ".." would resolve to the // bucket root (or its parent) when interpolated into {appId}/{branch}/… // on the local backend. if id == "." || id == ".." { return fmt.Errorf("%s %q is reserved", fieldPath, id) } if _, reserved := reservedAppIds[strings.ToLower(id)]; reserved { return fmt.Errorf("%s %q collides with a top-level route name", fieldPath, id) } for _, r := range id { if r == '/' || r == '\\' { return fmt.Errorf("%s %q must not contain path separators", fieldPath, id) } if unicode.IsSpace(r) { return fmt.Errorf("%s %q must not contain whitespace", fieldPath, id) } if unicode.IsControl(r) { return fmt.Errorf("%s %q must not contain control characters", fieldPath, id) } // Only ASCII alphanumerics plus `-` / `_` / `.` are safe across // filesystems, URL paths and S3/GCS key rules. Unicode lookalikes // (e.g. U+2215 ∕, fullwidth slash U+FF0F /) would bypass the // separator check above while still tripping up downstream consumers. if r > unicode.MaxASCII { return fmt.Errorf("%s %q must be ASCII", fieldPath, id) } if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' && r != '.' { return fmt.Errorf("%s %q contains invalid character %q", fieldPath, id, r) } } return nil } func ValidateKeys(k *KeysConfig, prefix string) error { // No config.IsDBMode() check here since to avoid circular depdency isDBMode := os.Getenv("DB_URL") != "" allowedModes := []string{string(KeysModeLocal), string(KeysModeAWSSM), string(KeysModeEnvironment)} if isDBMode { allowedModes = append(allowedModes, string(KeysModeDatabase)) } if k.Mode == "" { return fmt.Errorf("%s.mode is required (%s)", prefix, strings.Join(allowedModes, "|")) } hasLocal := k.PublicPath != "" || k.PrivatePath != "" hasAWSSM := k.PublicSecretId != "" || k.PrivateSecretId != "" hasEnv := k.PublicB64 != "" || k.PrivateB64 != "" switch k.Mode { case KeysModeLocal: if k.PublicPath == "" || k.PrivatePath == "" { return fmt.Errorf("%s: mode=local requires publicPath and privatePath", prefix) } if hasAWSSM || hasEnv { return fmt.Errorf("%s: mode=local must not set aws-sm or b64 fields", prefix) } case KeysModeAWSSM: if k.PublicSecretId == "" || k.PrivateSecretId == "" { return fmt.Errorf("%s: mode=aws-secrets-manager requires publicSecretId and privateSecretId", prefix) } if hasLocal || hasEnv { return fmt.Errorf("%s: mode=aws-secrets-manager must not set local or b64 fields", prefix) } case KeysModeEnvironment: if k.PublicB64 == "" || k.PrivateB64 == "" { return fmt.Errorf("%s: mode=environment requires publicB64 and privateB64", prefix) } if hasLocal || hasAWSSM { return fmt.Errorf("%s: mode=environment must not set local or aws-sm fields", prefix) } if err := validatePEMKeyB64(k.PublicB64, prefix+".publicB64"); err != nil { return err } if err := validatePEMKeyB64(k.PrivateB64, prefix+".privateB64"); err != nil { return err } case KeysModeDatabase: if !isDBMode { return fmt.Errorf("%s: mode=database is not allowed when DB_URL is not set", prefix) } if hasLocal || hasAWSSM { return fmt.Errorf("%s: mode=database must not set local or aws-sm fields", prefix) } default: return fmt.Errorf("%s.mode=%q is invalid (expected %s)", prefix, k.Mode, strings.Join(allowedModes, "|")) } return nil } // validatePEMKeyB64 fails fast when a mode=environment key is structurally // broken: not base64, or base64 that decodes to something that is clearly // not a PEM-encoded key. Catches two common operator mistakes (double- // encoded input, pasting raw PEM into a b64 field) at boot instead of at // first manifest sign, where the symptom is an opaque 500. func validatePEMKeyB64(b64, fieldPath string) error { decoded, err := base64.StdEncoding.DecodeString(b64) if err != nil { return fmt.Errorf("%s: invalid base64: %w", fieldPath, err) } if !strings.Contains(string(decoded), "-----BEGIN ") { return fmt.Errorf("%s: decoded value is not a PEM key (missing BEGIN marker)", fieldPath) } return nil } // ListAppIds returns the configured app ids in an unspecified order. Meant // for read-only introspection (e.g. the dashboard /api/settings endpoint); // never expose tokens or keys from the same path. func ListAppIds() []string { appsByIdMu.RLock() defer appsByIdMu.RUnlock() ids := make([]string, 0, len(appsById)) for id := range appsById { ids = append(ids, id) } return ids } // ListApps returns a public-safe descriptor for each configured app. Same // contract as ListAppIds (unspecified order, snapshot-at-call-time) but // includes the optional display name so clients like the dashboard can show // a human-readable label instead of the raw UUID. func ListApps() []AppDescriptor { appsByIdMu.RLock() defer appsByIdMu.RUnlock() out := make([]AppDescriptor, 0, len(appsById)) for _, app := range appsById { out = append(out, AppDescriptor{Id: app.Id, Name: app.Name}) } return out } // GetAppConfig returns the resolved configuration for the given app id. // Returns an error when the id is unknown so callers can return a clear 404 // instead of silently serving a different app's content. func GetAppConfig(appId string) (*AppConfig, error) { appsByIdMu.RLock() defer appsByIdMu.RUnlock() app, ok := appsById[appId] if !ok { return nil, fmt.Errorf("unknown app id %q", appId) } return app, nil } // ResetAppsForTest clears the loaded apps cache. Intended for tests that // need to reinitialize the config between runs. Unexported-equivalent // naming keeps it out of "normal" autocompletion. func ResetAppsForTest() { appsByIdMu.Lock() appsById = nil appsByIdMu.Unlock() } // SetAppsForTest replaces the in-memory registry with the given apps. Test- // only: lets tests exercise the multi-app resolution paths (dashboard tenant // isolation, per-app routing) directly, without going through the flat-env // loader, which is single-app. Production multi-app lives in the control // plane (DB mode); this shortcut just seeds the same registry the bucket // store reads. func SetAppsForTest(apps []AppConfig) { index := make(map[string]*AppConfig, len(apps)) for i := range apps { app := &apps[i] index[app.Id] = app } appsByIdMu.Lock() appsById = index appsByIdMu.Unlock() }