config/apps.go
Rama main · réplica de lectura del repositorio original.
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()
}