fix(config): 完成生产配置向 config.toml 的迁移

This commit is contained in:
2026-09-19 00:39:09 +08:00
parent 4599ea32b2
commit 045d598400
26 changed files with 908 additions and 254 deletions
+2 -17
View File
@@ -332,7 +332,7 @@ func TestRefreshCurrentReleaseHealthTransitionsAndClearsFailure(t *testing.T) {
t.Fatalf("refresh diagnostics=%v", refresh)
}
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("1234567890"), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("2801109426\n"), 0o644); err != nil {
t.Fatal(err)
}
backend.releaseStatusErr = nil
@@ -461,7 +461,7 @@ func TestCDNSupportsRangeHeadAndConditionalRequests(t *testing.T) {
t.Fatalf("Content-Range=%q", rr.Header().Get("Content-Range"))
}
etag := rr.Header().Get("ETag")
if etag != `"blake3-0000000000000000000000000000000000000000000000000000000000000000"` {
if etag != `"blake3-5d5c6cc8ca0afa7d71df9b0d764c7b7f9e60c8b082dbb9fc844b10442989357f"` {
t.Fatalf("ETag=%q", etag)
}
@@ -1802,21 +1802,6 @@ func TestDiscoverTreatsCatalogUnavailableAsNoRelease(t *testing.T) {
}
}
func TestLoadEnvFileDoesNotOverride(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ".env")
if err := os.WriteFile(path, []byte("BAT_API_LISTEN=:9999\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("BAT_API_LISTEN", ":1111")
if err := LoadEnvFile(path); err != nil {
t.Fatal(err)
}
if os.Getenv("BAT_API_LISTEN") != ":1111" {
t.Fatal(os.Getenv("BAT_API_LISTEN"))
}
}
func TestRefreshDiagnosticsAndReadyz(t *testing.T) {
root := fixtureRoot(t)
cfg := DefaultConfig()
+6 -102
View File
@@ -25,14 +25,16 @@ const (
DefaultAuthSkew = 5 * time.Minute
ClientPatchHost = "prod-clientpatch.bluearchiveyostar.com"
ServerInfoHost = "yostar-serverinfo.bluearchiveyostar.com"
EnvFileName = ".env"
ConfigFileName = "config.toml"
ConfigExampleName = "config.toml.example"
)
// Config holds bat-api process configuration.
//
// Values come from (highest wins): CLI flags > process environment > .env file
// > built-in defaults. Database-related keys are reserved for future API
// persistence and are loaded but not required for the resource CDN surface.
// Values come from (highest wins): CLI flags > process environment >
// config.toml > built-in defaults. Database-related keys are reserved for
// future API persistence and are loaded but not required for the resource CDN
// surface.
type Config struct {
// Listen is the HTTP listen address, e.g. ":18080" or "127.0.0.1:18080".
Listen string
@@ -54,8 +56,6 @@ type Config struct {
RPCTimeout time.Duration
// RefreshInterval periodically re-discovers the release through RPC. Zero disables it.
RefreshInterval time.Duration
// SkipEnvFile disables loading .env when true (BAT_API_SKIP_ENV_FILE=1).
SkipEnvFile bool
// AuthToken enables HTTP token authentication when non-empty.
AuthToken string
// AuthQueryParam is the optional query parameter accepted for token auth.
@@ -148,41 +148,6 @@ func (c *Config) Normalize() error {
return nil
}
// LoadEnvFile reads KEY=VALUE lines from path into the process environment
// without overriding variables that are already set.
func LoadEnvFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
value = strings.Trim(value, `"'`)
if key == "" {
continue
}
if _, exists := os.LookupEnv(key); exists {
continue
}
if err := os.Setenv(key, value); err != nil {
return err
}
}
return nil
}
// ApplyEnv overlays environment variables onto cfg.
func ApplyEnv(cfg *Config) {
if v := os.Getenv("BAT_API_LISTEN"); v != "" {
@@ -239,9 +204,6 @@ func ApplyEnv(cfg *Config) {
if v := os.Getenv("BAT_API_REDIS_PASSWORD"); v != "" {
cfg.RedisPassword = v
}
if v := os.Getenv("BAT_API_SKIP_ENV_FILE"); v != "" {
cfg.SkipEnvFile = parseBool(v, false)
}
if v := os.Getenv("BAT_API_AUTH_TOKEN"); v != "" {
cfg.AuthToken = v
}
@@ -313,61 +275,3 @@ func normalizePathList(paths []string) []string {
}
return out
}
// EnvTemplate is written next to the bat-api binary on first run when missing.
const EnvTemplate = `# bat-api configuration
# Priority: CLI flags > process environment > this file > built-in defaults.
# Resource auto-pull/sync is owned by the Rust bat daemon, not bat-api.
# bat-api only bootstraps/distributes already-published resources and exposes
# a small management surface for release inspection.
# HTTP listen address (host:port or :port)
BAT_API_LISTEN=:18080
# Public base URL used when rewriting AddressablesCatalogUrlRoot
BAT_API_PUBLIC_BASE_URL=http://127.0.0.1:18080
# Rust bat daemon RPC socket (primary discovery path)
# Prefer BAT_API_SOCKET; BAT_API_STATE_DIR only derives the default socket path.
BAT_API_STATE_DIR=/tmp/bat-pid
# BAT_API_SOCKET=/tmp/bat-pid/bat.sock
# Optional override of the published release root (fixtures / emergency only).
# Production obtains resource_root from daemon RPC (release.attestation +
# generation-bound resource.manifest).
# BAT_API_RESOURCE_ROOT=
# Optional server-info JSON for Addressables root rewrite
# BAT_API_SERVER_INFO_FILE=
# CDN safety
BAT_API_REQUIRE_INDEXED=true
BAT_API_VERIFY_SIZE=true
BAT_API_RPC_TIMEOUT=30s
# Periodically re-read bat.sock so bat-api follows Rust bat release switches.
# Set to 0 to disable in fixture-only local development.
BAT_API_REFRESH_INTERVAL=1m
# Player-facing HTTP controls.
# Prefer setting BAT_API_AUTH_TOKEN through a secret manager or process
# environment. Reverse proxies may inject Authorization: Bearer <token> or
# X-BAT-Token to authenticated upstream requests. Query token fallback uses
# BAT_API_AUTH_QUERY_PARAM and is supported for clients that cannot set headers.
# BAT_API_AUTH_TOKEN=
BAT_API_AUTH_QUERY_PARAM=bat_token
# BAT_API_AUTH_EXEMPT_PATHS=
BAT_API_TRUST_PROXY_HEADERS=false
BAT_API_ACCESS_LOG=false
# Zero disables in-process limiting; production may still enforce edge limits.
BAT_API_RATE_LIMIT_RPS=0
BAT_API_RATE_LIMIT_BURST=0
BAT_API_MAX_RESOURCE_LIMIT=1000
# Reserved for future API persistence (not required for resource CDN)
# BAT_API_DATABASE_URL=postgres://bat:@127.0.0.1:5432/bat?sslmode=disable
# BAT_API_DATABASE_PASSWORD=
# BAT_API_REDIS_URL=redis://127.0.0.1:6379/0
# BAT_API_REDIS_PASSWORD=
# Set to 1 to ignore this file entirely
# BAT_API_SKIP_ENV_FILE=0
`
+391
View File
@@ -0,0 +1,391 @@
package api
import (
"fmt"
"math"
"os"
"path/filepath"
"runtime"
"time"
"github.com/pelletier/go-toml/v2"
)
// ConfigTOMLTemplate is the only template generated by bat-api. Rust bat
// accepts the same file and ignores the [api] section.
const ConfigTOMLTemplate = `# BlueArchive Toolkit shared application configuration.
# Priority: CLI flags > process environment > config.toml > built-in defaults.
# This file is the application configuration. The application never reads .env.
#
# Rust bat consumes [runtime], [resource], [localized], [repository], [network]
# and [translation.worker]. Go bat-api consumes [api]. Each binary ignores the
# other application's section.
[runtime]
state_dir = '/tmp/bat-pid'
interval_seconds = 3600
error_retry_seconds = 60
quiet_up_to_date = false
output_format = 'human'
banner = true
progress = true
tail_lines = 200
[resource]
output_root = './bat-resources'
auto_discover = true
app_version = ''
connection_group = ''
launcher_version = '1.7.2'
platforms = ['windows', 'android']
snapshot_path = ''
dry_run = false
plan = false
force = false
audit_local = true
repair = true
[resource.server_info]
kind = 'none'
value = ''
[localized]
output_root = './bat-localized'
[repository]
import_repository = false
import_cas_root = ''
import_resource_repository_path = ''
[network]
curl_command = 'curl'
proxy = 'auto'
unzip_command = 'unzip'
zip_command = 'zip'
download_concurrency = 8
[translation.worker]
provider = 'mock'
fixture = ''
translation_memory_path = ''
glossary_path = ''
concurrency = 8
max_attempts = 3
lease_seconds = 300
retry_backoff_seconds = 5
max_tasks = ''
worker_id = ''
[api]
listen = ':18080'
public_base_url = 'http://127.0.0.1:18080'
state_dir = '/tmp/bat-pid'
socket_path = ''
resource_root = ''
server_info_file = ''
require_indexed = true
verify_size = true
rpc_timeout = '30s'
refresh_interval = '1m'
auth_token = ''
auth_query_param = 'bat_token'
auth_exempt_paths = []
trust_proxy_headers = false
access_log = false
rate_limit_rps = 0
rate_limit_burst = 0
max_resource_page_limit = 1000
# Reserved for a future API persistence layer.
database_url = ''
database_password = ''
redis_url = ''
redis_password = ''
`
// LoadConfigFromCurrentExe loads config.toml next to the running binary.
func LoadConfigFromCurrentExe(cfg *Config) error {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("locate current executable: %w", err)
}
return LoadConfigFromBinaryDir(filepath.Dir(exe), cfg)
}
// LoadConfigFromBinaryDir loads only config.toml from binaryDir. Missing
// config.toml causes config.toml.example to be created when possible; the
// example is never parsed as the active configuration.
func LoadConfigFromBinaryDir(binaryDir string, cfg *Config) error {
if cfg == nil {
return fmt.Errorf("config must not be nil")
}
configPath := filepath.Join(binaryDir, ConfigFileName)
examplePath := filepath.Join(binaryDir, ConfigExampleName)
info, err := os.Lstat(configPath)
if err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("stat %s: %w", configPath, err)
}
ensureConfigExample(examplePath)
return nil
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("%s must be a regular file", configPath)
}
if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
return fmt.Errorf("%s permissions must be 0600 or stricter (current %03o)", configPath, info.Mode().Perm())
}
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("read %s: %w", configPath, err)
}
if err := applyAPITOML(data, cfg); err != nil {
return fmt.Errorf("parse %s: %w", configPath, err)
}
return nil
}
func ensureConfigExample(path string) {
if _, err := os.Lstat(path); err == nil || !os.IsNotExist(err) {
return
}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return
}
if _, err := file.WriteString(ConfigTOMLTemplate); err != nil {
_ = file.Close()
return
}
_ = file.Close()
}
func applyAPITOML(data []byte, cfg *Config) error {
var document map[string]any
if err := toml.Unmarshal(data, &document); err != nil {
return err
}
rawAPI, exists := document["api"]
if !exists {
return nil
}
apiValues, ok := rawAPI.(map[string]any)
if !ok {
return fmt.Errorf("[api] must be a table")
}
for key, value := range apiValues {
if err := applyAPIValue(cfg, key, value); err != nil {
return err
}
}
return nil
}
func applyAPIValue(cfg *Config, key string, raw any) error {
field := fmt.Sprintf("[api].%s", key)
switch key {
case "listen":
value, err := tomlString(raw, field)
if err != nil {
return err
}
cfg.Listen = value
case "public_base_url":
value, err := tomlString(raw, field)
if err != nil {
return err
}
cfg.PublicBaseURL = value
case "state_dir":
value, err := tomlString(raw, field)
if err != nil {
return err
}
cfg.StateDir = value
case "socket_path":
value, err := tomlString(raw, field)
if err != nil {
return err
}
cfg.SocketPath = value
case "resource_root":
value, err := tomlString(raw, field)
if err != nil {
return err
}
cfg.ResourceRoot = value
case "server_info_file":
value, err := tomlString(raw, field)
if err != nil {
return err
}
cfg.ServerInfoFile = value
case "require_indexed":
value, err := tomlBool(raw, field)
if err != nil {
return err
}
cfg.RequireIndexed = value
case "verify_size":
value, err := tomlBool(raw, field)
if err != nil {
return err
}
cfg.VerifySize = value
case "rpc_timeout":
value, err := tomlDuration(raw, field)
if err != nil {
return err
}
cfg.RPCTimeout = value
case "refresh_interval":
value, err := tomlDuration(raw, field)
if err != nil {
return err
}
cfg.RefreshInterval = value
case "auth_token":
value, err := tomlString(raw, field)
if err != nil {
return err
}
cfg.AuthToken = value
case "auth_query_param":
value, err := tomlString(raw, field)
if err != nil {
return err
}
cfg.AuthQueryParam = value
case "auth_exempt_paths":
value, err := tomlStringArray(raw, field)
if err != nil {
return err
}
cfg.AuthExemptPaths = value
case "trust_proxy_headers":
value, err := tomlBool(raw, field)
if err != nil {
return err
}
cfg.TrustProxyHeaders = value
case "access_log":
value, err := tomlBool(raw, field)
if err != nil {
return err
}
cfg.AccessLog = value
case "rate_limit_rps":
value, err := tomlNonNegativeNumber(raw, field)
if err != nil {
return err
}
cfg.RateLimitRPS = value
case "rate_limit_burst":
value, err := tomlNonNegativeInt(raw, field)
if err != nil {
return err
}
cfg.RateLimitBurst = value
case "max_resource_page_limit":
value, err := tomlNonNegativeInt(raw, field)
if err != nil {
return err
}
cfg.MaxResourcePageLimit = value
case "database_url":
value, err := tomlString(raw, field)
if err != nil {
return err
}
cfg.DatabaseURL = value
case "database_password":
value, err := tomlString(raw, field)
if err != nil {
return err
}
cfg.DatabasePassword = value
case "redis_url":
value, err := tomlString(raw, field)
if err != nil {
return err
}
cfg.RedisURL = value
case "redis_password":
value, err := tomlString(raw, field)
if err != nil {
return err
}
cfg.RedisPassword = value
default:
return fmt.Errorf("unsupported [api] key %q", key)
}
return nil
}
func tomlString(raw any, field string) (string, error) {
value, ok := raw.(string)
if !ok {
return "", fmt.Errorf("%s must be a string (got %T)", field, raw)
}
return value, nil
}
func tomlBool(raw any, field string) (bool, error) {
value, ok := raw.(bool)
if !ok {
return false, fmt.Errorf("%s must be a boolean (got %T)", field, raw)
}
return value, nil
}
func tomlDuration(raw any, field string) (time.Duration, error) {
value, err := tomlString(raw, field)
if err != nil {
return 0, err
}
duration, err := time.ParseDuration(value)
if err != nil || duration < 0 {
return 0, fmt.Errorf("%s must be a non-negative duration", field)
}
return duration, nil
}
func tomlNonNegativeNumber(raw any, field string) (float64, error) {
var value float64
switch typed := raw.(type) {
case int64:
value = float64(typed)
case float64:
value = typed
default:
return 0, fmt.Errorf("%s must be a non-negative number (got %T)", field, raw)
}
if value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
return 0, fmt.Errorf("%s must be a non-negative number", field)
}
return value, nil
}
func tomlNonNegativeInt(raw any, field string) (int, error) {
value, ok := raw.(int64)
if !ok || value < 0 || int64(int(value)) != value {
return 0, fmt.Errorf("%s must be a non-negative integer (got %T)", field, raw)
}
return int(value), nil
}
func tomlStringArray(raw any, field string) ([]string, error) {
values, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("%s must be an array of strings (got %T)", field, raw)
}
items := make([]string, len(values))
for index, rawItem := range values {
item, ok := rawItem.(string)
if !ok {
return nil, fmt.Errorf("%s[%d] must be a string (got %T)", field, index, rawItem)
}
items[index] = item
}
return items, nil
}
+163
View File
@@ -0,0 +1,163 @@
package api
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
func writePrivateConfig(t *testing.T, dir, content string) {
t.Helper()
path := filepath.Join(dir, ConfigFileName)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
func TestLoadConfigFromBinaryDirFirstLaunchCreatesExampleOnly(t *testing.T) {
dir := t.TempDir()
cfg := DefaultConfig()
if err := LoadConfigFromBinaryDir(dir, &cfg); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, ConfigExampleName)); err != nil {
t.Fatalf("config.toml.example was not created: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, ".env")); !os.IsNotExist(err) {
t.Fatalf("unexpected .env after first launch, err=%v", err)
}
if cfg.Listen != DefaultListen || cfg.RPCTimeout != 30*time.Second {
t.Fatalf("first launch changed defaults: %+v", cfg)
}
}
func TestCheckedInBatAPIConfigExampleMatchesRuntimeTemplate(t *testing.T) {
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
path := filepath.Join(filepath.Dir(sourceFile), "..", "..", "cmd", "bat-api", ConfigExampleName)
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != ConfigTOMLTemplate {
t.Fatal("cmd/bat-api/config.toml.example differs from runtime template")
}
}
func TestLoadConfigFromBinaryDirDoesNotReadDotEnv(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("BAT_API_LISTEN=':19999'\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg := DefaultConfig()
if err := LoadConfigFromBinaryDir(dir, &cfg); err != nil {
t.Fatal(err)
}
if cfg.Listen != DefaultListen {
t.Fatalf("dotenv value was applied: %q", cfg.Listen)
}
if _, err := os.Stat(filepath.Join(dir, ConfigExampleName)); err != nil {
t.Fatalf("config.toml.example was not created: %v", err)
}
}
func TestLoadConfigFromBinaryDirAppliesAPISectionOnly(t *testing.T) {
dir := t.TempDir()
writePrivateConfig(t, dir, `
[runtime]
state_dir = '/rust-state'
this_key_belongs_to_rust = 'ignored-by-go'
[translation.worker]
provider = 'mock'
[api]
listen = '127.0.0.1:19080'
public_base_url = 'https://assets.example.test'
state_dir = '/api-state'
socket_path = '/api-state/bat.sock'
resource_root = '/srv/releases/current'
server_info_file = '/srv/server-info.json'
require_indexed = false
verify_size = false
rpc_timeout = '2s'
refresh_interval = '0s'
auth_token = 'secret'
auth_query_param = 'token'
auth_exempt_paths = ['/healthz', '/readyz']
trust_proxy_headers = true
access_log = true
rate_limit_rps = 12.5
rate_limit_burst = 30
max_resource_page_limit = 250
database_url = 'postgres://example'
database_password = 'db-secret'
redis_url = 'redis://example'
redis_password = 'redis-secret'
`)
cfg := DefaultConfig()
if err := LoadConfigFromBinaryDir(dir, &cfg); err != nil {
t.Fatal(err)
}
if cfg.Listen != "127.0.0.1:19080" ||
cfg.PublicBaseURL != "https://assets.example.test" ||
cfg.StateDir != "/api-state" ||
cfg.SocketPath != "/api-state/bat.sock" ||
cfg.ResourceRoot != "/srv/releases/current" ||
cfg.ServerInfoFile != "/srv/server-info.json" ||
cfg.RequireIndexed ||
cfg.VerifySize ||
cfg.RPCTimeout != 2*time.Second ||
cfg.RefreshInterval != 0 ||
cfg.AuthToken != "secret" ||
cfg.AuthQueryParam != "token" ||
cfg.TrustProxyHeaders != true ||
cfg.AccessLog != true ||
cfg.RateLimitRPS != 12.5 ||
cfg.RateLimitBurst != 30 ||
cfg.MaxResourcePageLimit != 250 ||
cfg.DatabaseURL != "postgres://example" ||
cfg.DatabasePassword != "db-secret" ||
cfg.RedisURL != "redis://example" ||
cfg.RedisPassword != "redis-secret" {
t.Fatalf("unexpected config: %+v", cfg)
}
if strings.Join(cfg.AuthExemptPaths, ",") != "/healthz,/readyz" {
t.Fatalf("auth exempt paths=%v", cfg.AuthExemptPaths)
}
}
func TestLoadConfigFromBinaryDirRejectsUnknownAPIKey(t *testing.T) {
dir := t.TempDir()
writePrivateConfig(t, dir, "[api]\nunknown_key = 'value'\n")
cfg := DefaultConfig()
err := LoadConfigFromBinaryDir(dir, &cfg)
if err == nil || !strings.Contains(err.Error(), "unsupported [api] key") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestConfigPrecedenceEnvironmentOverConfig(t *testing.T) {
dir := t.TempDir()
writePrivateConfig(t, dir, "[api]\nlisten = ':19080'\nauth_token = 'config-token'\n")
t.Setenv("BAT_API_LISTEN", ":19081")
t.Setenv("BAT_API_AUTH_TOKEN", "env-token")
cfg := DefaultConfig()
if err := LoadConfigFromBinaryDir(dir, &cfg); err != nil {
t.Fatal(err)
}
ApplyEnv(&cfg)
if cfg.Listen != ":19081" || cfg.AuthToken != "env-token" {
t.Fatalf("environment did not override config: %+v", cfg)
}
}
@@ -5,13 +5,18 @@
"url": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
"destination": "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
"bytes": 21,
"blake3": "0000000000000000000000000000000000000000000000000000000000000000"
"blake3": "5d5c6cc8ca0afa7d71df9b0d764c7b7f9e60c8b082dbb9fc844b10442989357f"
},
"https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash": {
"url": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash",
"destination": "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash",
"bytes": 10,
"blake3": "0000000000000000000000000000000000000000000000000000000000000000"
"bytes": 11,
"blake3": "dd3384e165b93bc64bf856fcf7d13883bb0a26c3d097de6622a8631b3941f6ef"
}
}
},
"destination_index": {
"prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
"prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"
},
"distribution_mapping_identity": "odm-v1-8f515f013cbe2ec7116fee902ea44d0179c435812cd29571361b91efc5ac33e6"
}