From 43f5021ccbc601f6f0f8dc80adb25c67877be61e Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Thu, 22 Jan 2026 02:00:16 +0000 Subject: [PATCH] stuff --- docs/CODE_REVIEW_FINDINGS.md | 375 +++++++++ gameengine/src/app/service_based_app.cpp | 26 +- .../impl/graphics/bgfx_shader_compiler.cpp | 26 +- .../impl/shader/shader_pipeline_validator.cpp | 39 +- mojo/examples/snake/conanfile.txt | 9 + packagerepo/backend/Dockerfile | 4 +- packagerepo/frontend/Dockerfile | 3 + packagerepo/frontend/src/app/admin/page.jsx | 32 +- packagerepo/frontend/src/utils/api.js | 15 +- packagerepo/schema.json | 726 +++++++++++++++++- 10 files changed, 1205 insertions(+), 50 deletions(-) create mode 100644 docs/CODE_REVIEW_FINDINGS.md create mode 100644 mojo/examples/snake/conanfile.txt diff --git a/docs/CODE_REVIEW_FINDINGS.md b/docs/CODE_REVIEW_FINDINGS.md new file mode 100644 index 000000000..81fe1c5e4 --- /dev/null +++ b/docs/CODE_REVIEW_FINDINGS.md @@ -0,0 +1,375 @@ +# Code Review Findings Report - MetaBuilder Codebase + +**Date**: 2026-01-22 +**Reviewer**: Automated Code Review (Claude Opus 4.5) +**Scope**: Critical C++ components (Game Engine, DBAL, HTTP Daemon) +**Completion Promise**: All issues fixed, good logging all round + +--- + +## Executive Summary + +| Severity | Count | Status | +|----------|-------|--------| +| CRITICAL | 1 | Requires immediate fix | +| HIGH | 3 | Requires fix before production | +| MEDIUM | 2 | Should be addressed | +| LOW | 4 | Informational/improvements | + +--- + +## CRITICAL Issues + +### [CRIT-001] Plain-text Password Comparison in verify_credential.hpp + +**File**: `dbal/production/src/entities/credential/crud/verify_credential.hpp:18` +**Category**: SECURITY +**Impact**: Authentication bypass, credential theft + +**Description**: +The credential verification function compares passwords directly without hashing: + +```cpp +if (!credential || credential->passwordHash != password) { + return Error::unauthorized("Invalid credentials"); +} +``` + +**Issue**: Despite the field being named `passwordHash`, it's compared directly to the input `password`, suggesting either: +1. Passwords are stored in plain text (catastrophic security issue) +2. The caller is expected to hash before calling, but the API name doesn't indicate this + +**Required Fix**: +```cpp +// Use constant-time comparison with proper hashing +#include +#include + +inline bool secureCompare(const std::string& a, const std::string& b) { + if (a.size() != b.size()) return false; + volatile int result = 0; + for (size_t i = 0; i < a.size(); ++i) { + result |= static_cast(a[i]) ^ static_cast(b[i]); + } + return result == 0; +} + +inline Result verify(InMemoryStore& store, const std::string& username, const std::string& password) { + if (username.empty() || password.empty()) { + return Error::validationError("username and password are required"); + } + + auto* credential = helpers::getCredential(store, username); + if (!credential) { + // Perform dummy hash to prevent timing attacks + computeHash(password, "dummy_salt"); + return Error::unauthorized("Invalid credentials"); + } + + // Hash the input password and compare + const std::string inputHash = computeHash(password, credential->salt); + if (!secureCompare(inputHash, credential->passwordHash)) { + return Error::unauthorized("Invalid credentials"); + } + + return Result(true); +} +``` + +--- + +## HIGH Issues + +### [HIGH-001] Missing Path Traversal Prevention + +**File**: `dbal/production/src/daemon/server/validation_internal/validate_request_path.hpp` +**Category**: SECURITY +**Impact**: Directory traversal attacks + +**Description**: +The path validation checks for null bytes and length but does NOT check for path traversal sequences: + +```cpp +inline bool validate_request_path(const std::string& path, HttpResponse& error_response) { + // Check for null bytes in path (CVE pattern) + if (path.find('\0') != std::string::npos) { ... } + + // Validate path length + if (path.length() > MAX_PATH_LENGTH) { ... } + + return true; // MISSING: Path traversal check! +} +``` + +**Required Fix**: +```cpp +inline bool validate_request_path(const std::string& path, HttpResponse& error_response) { + // Existing checks... + + // Check for path traversal sequences + if (path.find("..") != std::string::npos) { + error_response.status_code = 400; + error_response.status_text = "Bad Request"; + error_response.body = R"({"error":"Path traversal detected"})"; + return false; + } + + // Check for encoded traversal attempts + if (path.find("%2e%2e") != std::string::npos || + path.find("%2E%2E") != std::string::npos || + path.find("..%2f") != std::string::npos || + path.find("..%5c") != std::string::npos) { + error_response.status_code = 400; + error_response.status_text = "Bad Request"; + error_response.body = R"({"error":"Encoded path traversal detected"})"; + return false; + } + + return true; +} +``` + +--- + +### [HIGH-002] Username Validation Minimum Length Not Enforced + +**File**: `dbal/production/src/validation/entity/user_validation.hpp:25-28` +**Category**: SECURITY/VALIDATION +**Impact**: Potential for weak usernames (single character) + +**Description**: +```cpp +inline bool isValidUsername(const std::string& username) { + if (username.empty() || username.length() > 50) return false; // No minimum length! + static const std::regex username_pattern(R"([a-zA-Z0-9_-]+)"); + return std::regex_match(username, username_pattern); +} +``` + +**Required Fix**: +```cpp +inline bool isValidUsername(const std::string& username) { + if (username.length() < 3 || username.length() > 50) return false; // Enforce 3-50 chars + static const std::regex username_pattern(R"([a-zA-Z0-9_-]+)"); + return std::regex_match(username, username_pattern); +} +``` + +--- + +### [HIGH-003] Credential Password Validation Too Weak + +**File**: `dbal/production/src/validation/entity/credential_validation.hpp:11-18` +**Category**: SECURITY +**Impact**: Allows passwords that are only whitespace or single characters + +**Description**: +```cpp +inline bool isValidCredentialPassword(const std::string& hash) { + if (hash.empty()) { + return false; + } + return std::any_of(hash.begin(), hash.end(), [](unsigned char c) { + return !std::isspace(c); // Only checks for at least one non-whitespace + }); +} +``` + +**Required Fix**: +```cpp +inline bool isValidCredentialPassword(const std::string& password) { + // Minimum length + if (password.length() < 8) { + return false; + } + // Maximum length (prevent DoS on hashing) + if (password.length() > 128) { + return false; + } + // At least one non-whitespace character + return std::any_of(password.begin(), password.end(), [](unsigned char c) { + return !std::isspace(c); + }); +} +``` + +--- + +## MEDIUM Issues + +### [MED-001] Regex Pattern Compilation on Every Call + +**File**: `dbal/production/src/validation/entity/user_validation.hpp:18` +**Category**: PERFORMANCE +**Impact**: Unnecessary regex compilation overhead + +**Description**: +While `static` is used for regex patterns, the `std::regex_match` call is still relatively expensive for a hot validation path. + +**Recommended**: Consider caching compiled regex at module level or using a simpler character-class validation. + +--- + +### [MED-002] Missing HTTP Method Validation + +**File**: `dbal/production/src/daemon/server/parsing/parse_request_line.hpp:23-39` +**Category**: SECURITY +**Impact**: Potential for unexpected HTTP method handling + +**Description**: +The request line parser accepts any string as the HTTP method without validating against known methods. + +**Recommended Fix**: +```cpp +inline bool is_valid_http_method(const std::string& method) { + static const std::unordered_set valid_methods = { + "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS" + }; + return valid_methods.count(method) > 0; +} + +inline bool parse_request_line(...) { + // ... existing parsing ... + + if (!is_valid_http_method(request.method)) { + error_response.status_code = 405; + error_response.status_text = "Method Not Allowed"; + error_response.body = R"({"error":"Unknown HTTP method"})"; + return false; + } + + return true; +} +``` + +--- + +## LOW Issues (Informational) + +### [LOW-001] Excellent Security Limits Configuration +**File**: `dbal/production/src/daemon/http/server/security_limits.hpp` +**Status**: GOOD +**Note**: Well-configured security limits: +- 64KB max request size (prevents buffer overflow) +- 100 max headers (prevents header bomb) +- 8KB max header size +- 2KB max path length +- 10MB max body size +- 1000 max concurrent connections + +### [LOW-002] Excellent Null Byte and CRLF Injection Prevention +**File**: `dbal/production/src/daemon/server/validation_internal/validate_header.hpp` +**Status**: GOOD +**Note**: Proper validation for: +- Null byte injection in headers and paths +- CRLF injection (HTTP Response Splitting) prevention +- Header count and size limits + +### [LOW-003] SQL Injection Prevention Confirmed +**File**: `dbal/production/src/adapters/sql/sql_adapter.hpp` +**Status**: GOOD +**Note**: Proper parameterized queries using `placeholder(N)` pattern with dialect-aware syntax. + +### [LOW-004] Excellent Game Engine Initialization Order +**File**: `gameengine/src/services/impl/graphics/bgfx_graphics_backend.cpp:612-622` +**Status**: GOOD - CRITICAL FIX ALREADY APPLIED +**Note**: The "prime frame" pattern ensures bgfx render thread synchronization before resource creation: +```cpp +if (logger_) { + logger_->Trace("BgfxGraphicsBackend", "Initialize", + "Priming bgfx with initial frame before resource creation"); +} +const uint32_t frameNumber = bgfx::frame(); +frameCount_ = frameNumber + 1; +``` + +--- + +## Logging Quality Assessment + +### Game Engine Logging: EXCELLENT +- Comprehensive trace logging throughout initialization +- Error messages include context (renderer type, platform info) +- Fallback renderer logging with recommendations +- Shader compilation logging with binary analysis + +### DBAL Logging: NEEDS IMPROVEMENT +- Missing structured logging in credential operations +- No audit trail for security-sensitive operations +- HTTP daemon could log more connection metadata + +### Recommended Logging Improvements: + +```cpp +// In verify_credential.hpp +inline Result verify(InMemoryStore& store, const std::string& username, const std::string& password, ILogger* logger = nullptr) { + if (logger) { + logger->Info("Credential verification attempt for user: " + username); + } + + // ... verification logic ... + + if (!credential) { + if (logger) { + logger->Warn("Credential verification failed: user not found - " + username); + } + return Error::unauthorized("Invalid credentials"); + } + + // ... hash comparison ... + + if (logger) { + logger->Info("Credential verification succeeded for user: " + username); + } + return Result(true); +} +``` + +--- + +## Summary of Required Actions + +### Immediate (Before Next Release) +1. **[CRIT-001]** Fix plain-text password comparison - implement proper hashing +2. **[HIGH-001]** Add path traversal prevention to `validate_request_path.hpp` +3. **[HIGH-002]** Add minimum length (3 chars) to username validation +4. **[HIGH-003]** Strengthen password validation requirements + +### Near-Term (Within 2 Weeks) +5. **[MED-001]** Optimize regex usage in hot paths +6. **[MED-002]** Add HTTP method whitelist validation +7. Add structured logging to DBAL credential operations +8. Add audit trail for security-sensitive operations + +### Verification Steps +After fixes are applied: +1. Run `npm run test:e2e` to verify no regressions +2. Run static analysis: `clang-tidy` on modified files +3. Run security tests for path traversal and credential operations +4. Verify logging output for credential operations + +--- + +## Files Reviewed + +| File | Status | Issues Found | +|------|--------|--------------| +| `gameengine/CMakeLists.txt` | ✅ GOOD | None | +| `gameengine/src/app/service_based_app.cpp` | ✅ GOOD | None | +| `gameengine/src/services/impl/graphics/bgfx_graphics_backend.cpp` | ✅ GOOD | Excellent init order fix | +| `gameengine/src/services/impl/graphics/bgfx_shader_compiler.cpp` | ✅ GOOD | Integer uniform fix applied | +| `gameengine/src/services/impl/shader/shader_pipeline_validator.cpp` | ✅ GOOD | None | +| `dbal/production/src/daemon/http/server/security_limits.hpp` | ✅ GOOD | None | +| `dbal/production/src/daemon/server/validation_internal/validate_request_path.hpp` | ⚠️ HIGH | Missing path traversal check | +| `dbal/production/src/daemon/server/validation_internal/validate_header.hpp` | ✅ GOOD | None | +| `dbal/production/src/entities/credential/crud/verify_credential.hpp` | 🔴 CRIT | Plain-text password comparison | +| `dbal/production/src/entities/credential/crud/set_credential.hpp` | ✅ GOOD | Uses validation | +| `dbal/production/src/validation/entity/user_validation.hpp` | ⚠️ HIGH | Missing min length | +| `dbal/production/src/validation/entity/credential_validation.hpp` | ⚠️ HIGH | Weak validation | +| `dbal/production/src/adapters/sql/sql_adapter.hpp` | ✅ GOOD | Proper parameterization | +| `dbal/production/src/daemon/server/parsing/parse_request_line.hpp` | ⚠️ MED | No method whitelist | + +--- + +**Report Generated**: 2026-01-22 +**Review Status**: In Progress - Fixes Required diff --git a/gameengine/src/app/service_based_app.cpp b/gameengine/src/app/service_based_app.cpp index 2a477c3b8..f041f8ece 100644 --- a/gameengine/src/app/service_based_app.cpp +++ b/gameengine/src/app/service_based_app.cpp @@ -310,17 +310,8 @@ void ServiceBasedApp::RegisterServices() { registry_.GetService(), registry_.GetService()); - registry_.RegisterService( - registry_.GetService(), - registry_.GetService(), - registry_.GetService(), - registry_.GetService(), - registry_.GetService(), - registry_.GetService(), - registry_.GetService(), - registry_.GetService(), - registry_.GetService(), - registry_.GetService()); + // NOTE: FrameWorkflowService registration moved below after all dependencies are registered + // (IPhysicsService, ISceneService, IRenderCoordinatorService) // Physics bridge services registry_.RegisterService( @@ -376,6 +367,19 @@ void ServiceBasedApp::RegisterServices() { registry_.GetService(), registry_.GetService()); + // Frame workflow service (registered after all dependencies: physics, scene, render coordinator) + registry_.RegisterService( + registry_.GetService(), + registry_.GetService(), + registry_.GetService(), + registry_.GetService(), + registry_.GetService(), + registry_.GetService(), + registry_.GetService(), + registry_.GetService(), + registry_.GetService(), + registry_.GetService()); + // Application loop service registry_.RegisterService( registry_.GetService(), diff --git a/gameengine/src/services/impl/graphics/bgfx_shader_compiler.cpp b/gameengine/src/services/impl/graphics/bgfx_shader_compiler.cpp index f309f512d..f20efd221 100644 --- a/gameengine/src/services/impl/graphics/bgfx_shader_compiler.cpp +++ b/gameengine/src/services/impl/graphics/bgfx_shader_compiler.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -473,8 +474,10 @@ bgfx::ShaderHandle BgfxShaderCompiler::CompileShader( "falling back to temp-file compilation for " + label); } // Fallback to temp-file + pipelineCompiler_/executable flow - std::string tempInputPath = "/tmp/" + label + (isVertex ? ".vert.glsl" : ".frag.glsl"); - std::string tempOutputPath = "/tmp/" + label + (isVertex ? ".vert.bin" : ".frag.bin"); + // Use std::filesystem::temp_directory_path() for cross-platform compatibility + const std::filesystem::path tempDir = std::filesystem::temp_directory_path(); + const std::string tempInputPath = (tempDir / (label + (isVertex ? ".vert.glsl" : ".frag.glsl"))).string(); + const std::string tempOutputPath = (tempDir / (label + (isVertex ? ".vert.bin" : ".frag.bin"))).string(); { std::ofstream ofs(tempInputPath); ofs << source; @@ -485,10 +488,15 @@ bgfx::ShaderHandle BgfxShaderCompiler::CompileShader( std::vector args = {"--type", isVertex ? "vertex" : "fragment", "--profile", "spirv"}; ok = pipelineCompiler_->Compile(tempInputPath, tempOutputPath, args); } else { - std::string cmd = "./src/bgfx_tools/shaderc/shaderc -f " + tempInputPath + " -o " + tempOutputPath; - if (logger_) logger_->Trace("BgfxShaderCompiler", "CompileShaderCmd", cmd); - int rc = std::system(cmd.c_str()); - ok = (rc == 0); + // Security: Avoid std::system() which is vulnerable to command injection. + // Pipeline compiler service must be available for fallback compilation. + if (logger_) { + logger_->Error("BgfxShaderCompiler: Pipeline compiler service not available for " + label + + " - cannot compile shader without in-memory compilation support"); + } + // Cleanup temp input file before returning + std::filesystem::remove(tempInputPath); + return BGFX_INVALID_HANDLE; } if (!ok) { @@ -514,9 +522,9 @@ bgfx::ShaderHandle BgfxShaderCompiler::CompileShader( if (logger_) logger_->Error("BgfxShaderCompiler: Failed to read compiled shader data"); return BGFX_INVALID_HANDLE; } - // cleanup temp files - remove(tempInputPath.c_str()); - remove(tempOutputPath.c_str()); + // cleanup temp files using std::filesystem for cross-platform compatibility + std::filesystem::remove(tempInputPath); + std::filesystem::remove(tempOutputPath); } if (!shaderInfo.has_value()) { diff --git a/gameengine/src/services/impl/shader/shader_pipeline_validator.cpp b/gameengine/src/services/impl/shader/shader_pipeline_validator.cpp index 034720a3e..a48932d8c 100644 --- a/gameengine/src/services/impl/shader/shader_pipeline_validator.cpp +++ b/gameengine/src/services/impl/shader/shader_pipeline_validator.cpp @@ -8,8 +8,18 @@ std::vector ShaderPipelineValidator::ExtractShaderInputs(const std::string& glslSource) const { std::vector inputs; - // Match: layout (location = N) in type name; (handles compact syntax too) - std::regex pattern(R"(layout\s*\(\s*location\s*=\s*(\d+)\s*\)\s*in\s+(\w+)\s+(\w+)\s*;)"); + // Improved pattern to handle: + // - Optional qualifiers (flat, smooth, noperspective) + // - Multiple layout params separated by commas + // - Array types like vec3[N] + // - Whitespace variations + // Pattern: layout([...] location = N [...]) [qualifier] in type name [array]?; + std::regex pattern( + R"(layout\s*\([^)]*location\s*=\s*(\d+)[^)]*\)\s*)" // layout(... location=N ...) + R"((?:flat\s+|smooth\s+|noperspective\s+)?)" // optional interpolation qualifier + R"(in\s+(\w+)\s+(\w+))" // in type name + R"((?:\s*\[\s*\d+\s*\])?\s*;)" // optional array index, semicolon + ); std::sregex_iterator begin(glslSource.begin(), glslSource.end(), pattern); std::sregex_iterator end; @@ -21,8 +31,6 @@ ShaderPipelineValidator::ExtractShaderInputs(const std::string& glslSource) cons size_t size = GetGlslTypeSize(type); inputs.emplace_back(location, type, name, size); - - // Trace logging disabled to avoid API mismatch } return inputs; @@ -32,8 +40,18 @@ std::vector ShaderPipelineValidator::ExtractShaderOutputs(const std::string& glslSource) const { std::vector outputs; - // Match: layout (location = N) out type name; (handles compact syntax too) - std::regex pattern(R"(layout\s*\(\s*location\s*=\s*(\d+)\s*\)\s*out\s+(\w+)\s+(\w+)\s*;)"); + // Improved pattern to handle: + // - Optional qualifiers (flat, smooth, noperspective) + // - Multiple layout params separated by commas + // - Array types like vec4[N] + // - Whitespace variations + // Pattern: layout([...] location = N [...]) [qualifier] out type name [array]?; + std::regex pattern( + R"(layout\s*\([^)]*location\s*=\s*(\d+)[^)]*\)\s*)" // layout(... location=N ...) + R"((?:flat\s+|smooth\s+|noperspective\s+)?)" // optional interpolation qualifier + R"(out\s+(\w+)\s+(\w+))" // out type name + R"((?:\s*\[\s*\d+\s*\])?\s*;)" // optional array index, semicolon + ); std::sregex_iterator begin(glslSource.begin(), glslSource.end(), pattern); std::sregex_iterator end; @@ -180,8 +198,13 @@ ShaderPipelineValidator::ValidateSpirvRequirements( ValidationResult result; // Check that all in/out variables have layout(location=N) - std::regex inOutPattern(R"(\b(in|out)\s+\w+\s+\w+\s*;)"); - std::regex layoutPattern(R"(layout\s*\()"); + // Improved patterns to handle qualifiers and array syntax + std::regex inOutPattern( + R"(\b(?:flat\s+|smooth\s+|noperspective\s+)?)" // optional interpolation + R"((in|out)\s+\w+\s+\w+)" // in/out type name + R"((?:\s*\[\s*\d+\s*\])?\s*;)" // optional array, semicolon + ); + std::regex layoutPattern(R"(layout\s*\([^)]*location\s*=)"); size_t lineNum = 1; std::istringstream stream(glslSource); diff --git a/mojo/examples/snake/conanfile.txt b/mojo/examples/snake/conanfile.txt new file mode 100644 index 000000000..b302a0dde --- /dev/null +++ b/mojo/examples/snake/conanfile.txt @@ -0,0 +1,9 @@ +[requires] +sdl/3.2.20 + +[generators] +CMakeDeps +CMakeToolchain + +[options] +sdl/*:shared=True diff --git a/packagerepo/backend/Dockerfile b/packagerepo/backend/Dockerfile index 9aadf5ead..67327eb1d 100644 --- a/packagerepo/backend/Dockerfile +++ b/packagerepo/backend/Dockerfile @@ -9,8 +9,8 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy application files COPY backend/app.py backend/auth.py backend/config_db.py backend/auth_sqlalchemy.py backend/config_db_sqlalchemy.py backend/models.py backend/rocksdb_store.py ./ -# Copy schema from parent directory -COPY schema.json /app/schema.json +# Copy schema to where app.py expects it (parent of /app = /) +COPY schema.json /schema.json # Create data directory RUN mkdir -p /data/blobs /data/meta diff --git a/packagerepo/frontend/Dockerfile b/packagerepo/frontend/Dockerfile index 8603029c2..8931f728b 100644 --- a/packagerepo/frontend/Dockerfile +++ b/packagerepo/frontend/Dockerfile @@ -15,6 +15,9 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 +# Set backend URL for Next.js rewrites (Docker internal network) +ARG BACKEND_URL=http://backend:5000 +ENV BACKEND_URL=${BACKEND_URL} # Ensure public directory exists (Next.js may not create it if no static assets) RUN mkdir -p public diff --git a/packagerepo/frontend/src/app/admin/page.jsx b/packagerepo/frontend/src/app/admin/page.jsx index 1e25c31e1..15ed814ae 100644 --- a/packagerepo/frontend/src/app/admin/page.jsx +++ b/packagerepo/frontend/src/app/admin/page.jsx @@ -6,6 +6,20 @@ import styles from './page.module.scss'; import { getOperationLabel, getOperationDescription, getOperationCategory, getCategoryColor } from '../../utils/operations'; import { getApiUrl } from '../../utils/api'; +// Helper to safely parse JSON - handles both already-parsed objects and JSON strings +function safeParseJson(value, fallback = []) { + if (value === null || value === undefined) return fallback; + if (typeof value === 'object') return value; // Already parsed + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch { + return fallback; + } + } + return fallback; +} + export default function AdminPage() { const router = useRouter(); const [user, setUser] = useState(null); @@ -189,7 +203,7 @@ export default function AdminPage() { <>
Protocols:{' '} - {JSON.parse(config.capabilities.protocols || '[]').map((p, i) => ( + {safeParseJson(config.capabilities.protocols).map((p, i) => ( {p} @@ -197,7 +211,7 @@ export default function AdminPage() {
Storage:{' '} - {JSON.parse(config.capabilities.storage || '[]').map((s, i) => ( + {safeParseJson(config.capabilities.storage).map((s, i) => ( {s} @@ -205,7 +219,7 @@ export default function AdminPage() {
Features:{' '} - {JSON.parse(config.capabilities.features || '[]').map((f, i) => ( + {safeParseJson(config.capabilities.features).map((f, i) => ( {f} @@ -265,7 +279,7 @@ export default function AdminPage() { {field.name} {field.type} {field.optional ? '✓' : '✗'} - {JSON.parse(field.normalizations || '[]').join(', ') || 'none'} + {safeParseJson(field.normalizations).join(', ') || 'none'} ))} @@ -411,7 +425,7 @@ export default function AdminPage() {
{config.api_routes && config.api_routes.length > 0 ? ( config.api_routes.map((route, i) => { - const pipeline = JSON.parse(route.pipeline || '[]'); + const pipeline = safeParseJson(route.pipeline); const isExpanded = expandedRoute === i; return ( @@ -426,7 +440,7 @@ export default function AdminPage() { {' '} {route.path} {' • '} - {JSON.parse(route.tags || '[]').map((tag, j) => ( + {safeParseJson(route.tags).map((tag, j) => ( {tag} @@ -568,7 +582,7 @@ export default function AdminPage() { {config.auth_scopes.map((scope, i) => ( {scope.name} - {JSON.parse(scope.actions || '[]').join(', ')} + {safeParseJson(scope.actions).join(', ')}
{JSON.stringify({
-                        conditions: JSON.parse(policy.conditions || '{}'),
-                        requirements: JSON.parse(policy.requirements || '{}')
+                        conditions: safeParseJson(policy.conditions, {}),
+                        requirements: safeParseJson(policy.requirements, {})
                       }, null, 2)}
diff --git a/packagerepo/frontend/src/utils/api.js b/packagerepo/frontend/src/utils/api.js index 76906f6f4..04605c9e0 100644 --- a/packagerepo/frontend/src/utils/api.js +++ b/packagerepo/frontend/src/utils/api.js @@ -33,7 +33,14 @@ export function getApiUrl() { // For client-side, try to infer from current location if (typeof window !== 'undefined') { const { protocol, hostname, port } = window.location; - + + // If running on localhost with a custom port (e.g., 3003 from Docker), + // use relative URLs to go through Next.js rewrites/proxy + if ((hostname === 'localhost' || hostname === '127.0.0.1') && port && port !== '3000') { + // Docker compose or similar setup - use Next.js proxy + return ''; + } + // If running on a deployed domain (not localhost), try intelligent defaults if (hostname !== 'localhost' && hostname !== '127.0.0.1') { // Pattern 1: Frontend on custom port (e.g., :3000) - try backend on :5000 @@ -42,14 +49,14 @@ export function getApiUrl() { if (port && port !== '80' && port !== '443') { return `${protocol}//${hostname}:5000`; } - + // Pattern 2: Same origin with Next.js rewrites // Return empty string to use relative URLs, which will be handled by Next.js rewrites // This works when backend routes are proxied through Next.js return ''; } - - // For localhost development, backend is typically on port 5000 + + // For localhost development on port 3000, backend is typically on port 5000 return 'http://localhost:5000'; } diff --git a/packagerepo/schema.json b/packagerepo/schema.json index 70a0ef802..e501cc7ac 100644 --- a/packagerepo/schema.json +++ b/packagerepo/schema.json @@ -1,11 +1,11 @@ { - "schema_version": "1.0", + "schema_version": "1.1", "type_id": "acme.declarative_repo_type", - "description": "Mega schema: declarative repository type with closed-world ops, routes, storage, indexing, caching, replication, GC, invariants, and static validation.", + "description": "Declarative package repository supporting generic artifacts, Docker/OCI images, Conan packages, and OS packages with dependencies, signing, and metadata.", "capabilities": { - "protocols": ["http"], + "protocols": ["http", "oci"], "storage": ["blob", "kv", "index"], - "features": ["proxy", "virtual", "replication", "gc", "audit", "immutability"] + "features": ["proxy", "virtual", "replication", "gc", "audit", "immutability", "signing", "dependencies", "oci"] }, "entities": { @@ -28,6 +28,70 @@ { "field": "digest", "regex": "^(sha256:)?[a-f0-9]{64}$", "when_present": true } ] }, + "manifest": { + "description": "OCI/Docker manifest for container images", + "fields": { + "namespace": { "type": "string", "normalize": ["trim", "lower"] }, + "name": { "type": "string", "normalize": ["trim", "lower", "replace:_:-"] }, + "reference": { "type": "string", "normalize": ["trim"] }, + "media_type": { "type": "string" }, + "digest": { "type": "string", "normalize": ["trim", "lower"] } + }, + "primary_key": ["namespace", "name", "reference"], + "constraints": [ + { "field": "namespace", "regex": "^[a-z0-9][a-z0-9._-]{0,127}$" }, + { "field": "name", "regex": "^[a-z0-9][a-z0-9._/-]{0,255}$" }, + { "field": "reference", "regex": "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$|^sha256:[a-f0-9]{64}$" }, + { "field": "media_type", "regex": "^application/vnd\\.(oci|docker)\\..*$", "when_present": true }, + { "field": "digest", "regex": "^sha256:[a-f0-9]{64}$", "when_present": true } + ] + }, + "layer": { + "description": "OCI/Docker image layer blob", + "fields": { + "digest": { "type": "string", "normalize": ["trim", "lower"] }, + "media_type": { "type": "string" }, + "size": { "type": "integer" } + }, + "primary_key": ["digest"], + "constraints": [ + { "field": "digest", "regex": "^sha256:[a-f0-9]{64}$" } + ] + }, + "dependency": { + "description": "Package dependency relationship", + "fields": { + "namespace": { "type": "string", "normalize": ["trim", "lower"] }, + "name": { "type": "string", "normalize": ["trim", "lower"] }, + "version": { "type": "string", "normalize": ["trim"] }, + "variant": { "type": "string", "optional": true, "normalize": ["trim", "lower"] }, + "dep_namespace": { "type": "string", "normalize": ["trim", "lower"] }, + "dep_name": { "type": "string", "normalize": ["trim", "lower"] }, + "dep_version_spec": { "type": "string" }, + "dep_kind": { "type": "string", "optional": true } + }, + "primary_key": ["namespace", "name", "version", "variant", "dep_namespace", "dep_name"], + "constraints": [ + { "field": "dep_kind", "regex": "^(build|runtime|test|dev|optional)$", "when_present": true } + ] + }, + "signature": { + "description": "Cryptographic signature for an artifact", + "fields": { + "namespace": { "type": "string", "normalize": ["trim", "lower"] }, + "name": { "type": "string", "normalize": ["trim", "lower"] }, + "version": { "type": "string", "normalize": ["trim"] }, + "variant": { "type": "string", "optional": true, "normalize": ["trim", "lower"] }, + "key_id": { "type": "string" }, + "algo": { "type": "string" }, + "signature": { "type": "string" } + }, + "primary_key": ["namespace", "name", "version", "variant", "key_id"], + "constraints": [ + { "field": "algo", "regex": "^(ed25519|rsa-sha256|ecdsa-p256)$" }, + { "field": "key_id", "regex": "^[a-zA-Z0-9:_-]{1,128}$" } + ] + }, "versioning": { "scheme": "semver", "ordering": "semver", @@ -63,12 +127,32 @@ "artifact_meta": { "store": "meta", "key_template": "artifact/{namespace}/{name}/{version}/{variant?}", - "schema": "ArtifactMetaV1" + "schema": "ArtifactMetaV2" }, "tag_map": { "store": "meta", "key_template": "tag/{namespace}/{name}/{tag}", "schema": "TagMapV1" + }, + "manifest": { + "store": "meta", + "key_template": "manifest/{namespace}/{name}/{reference}", + "schema": "ManifestV1" + }, + "manifest_by_digest": { + "store": "meta", + "key_template": "manifest_digest/{namespace}/{name}/{digest}", + "schema": "ManifestV1" + }, + "dependency_graph": { + "store": "meta", + "key_template": "deps/{namespace}/{name}/{version}/{variant?}", + "schema": "DependencyGraphV1" + }, + "signature": { + "store": "meta", + "key_template": "sig/{namespace}/{name}/{version}/{variant?}/{key_id}", + "schema": "SignatureV1" } }, "schemas": { @@ -94,6 +178,93 @@ } } }, + "ArtifactMetaV2": { + "type": "object", + "required": ["namespace", "name", "version", "blob_digest", "created_at"], + "properties": { + "namespace": { "type": "string" }, + "name": { "type": "string" }, + "version": { "type": "string" }, + "variant": { "type": "string" }, + "blob_digest": { "type": "string" }, + "blob_size": { "type": "integer" }, + "created_at": { "type": "string" }, + "created_by": { "type": "string" }, + "labels": { "type": "object" }, + "integrity": { + "type": "object", + "properties": { + "algo": { "type": "string" }, + "digest": { "type": "string" } + } + }, + "package_info": { + "type": "object", + "properties": { + "description": { "type": "string" }, + "license": { "type": "string" }, + "homepage": { "type": "string" }, + "repository": { "type": "string" }, + "authors": { "type": "array", "items": { "type": "string" } }, + "keywords": { "type": "array", "items": { "type": "string" } }, + "package_type": { "type": "string", "enum": ["generic", "conan", "docker", "os", "npm", "pip"] } + } + }, + "dependencies": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "version_spec"], + "properties": { + "namespace": { "type": "string" }, + "name": { "type": "string" }, + "version_spec": { "type": "string" }, + "kind": { "type": "string", "enum": ["build", "runtime", "test", "dev", "optional"] } + } + } + }, + "signatures": { + "type": "array", + "items": { + "type": "object", + "required": ["key_id", "algo", "signature"], + "properties": { + "key_id": { "type": "string" }, + "algo": { "type": "string" }, + "signature": { "type": "string" }, + "signed_at": { "type": "string" } + } + } + } + } + }, + "ManifestV1": { + "type": "object", + "description": "OCI/Docker image manifest", + "required": ["namespace", "name", "reference", "media_type", "config_digest", "layers"], + "properties": { + "namespace": { "type": "string" }, + "name": { "type": "string" }, + "reference": { "type": "string" }, + "media_type": { "type": "string" }, + "config_digest": { "type": "string" }, + "config_size": { "type": "integer" }, + "layers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "digest": { "type": "string" }, + "size": { "type": "integer" }, + "media_type": { "type": "string" } + } + } + }, + "annotations": { "type": "object" }, + "created_at": { "type": "string" }, + "total_size": { "type": "integer" } + } + }, "TagMapV1": { "type": "object", "required": ["namespace", "name", "tag", "target_key", "updated_at"], @@ -105,6 +276,32 @@ "updated_at": { "type": "string" }, "updated_by": { "type": "string" } } + }, + "DependencyGraphV1": { + "type": "object", + "description": "Pre-computed dependency graph for a package", + "required": ["namespace", "name", "version"], + "properties": { + "namespace": { "type": "string" }, + "name": { "type": "string" }, + "version": { "type": "string" }, + "variant": { "type": "string" }, + "direct_deps": { "type": "array", "items": { "type": "string" } }, + "transitive_deps": { "type": "array", "items": { "type": "string" } }, + "computed_at": { "type": "string" } + } + }, + "SignatureV1": { + "type": "object", + "description": "Cryptographic signature for a package artifact", + "required": ["key_id", "algo", "signature"], + "properties": { + "key_id": { "type": "string" }, + "algo": { "type": "string" }, + "signature": { "type": "string" }, + "signed_at": { "type": "string" }, + "signed_by": { "type": "string" } + } } } }, @@ -223,7 +420,13 @@ "respond.redirect", "respond.error", "time.now_iso8601", - "string.format" + "string.format", + "sig.verify", + "sig.sign", + "oci.parse_manifest", + "oci.validate_manifest", + "deps.resolve", + "deps.graph" ], "limits": { "max_pipeline_ops": 128, @@ -676,6 +879,476 @@ } } ] + }, + + { + "id": "oci_push_blob", + "method": "PUT", + "path": "/v2/{namespace}/{name}/blobs/uploads/{upload_id}", + "tags": ["write_path", "oci"], + "description": "Upload a blob (layer or config) for OCI/Docker images", + "pipeline": [ + { "op": "auth.require_scopes", "args": { "scopes": ["write"] } }, + { "op": "parse.path", "args": { "entity": "layer" } }, + { "op": "parse.query", "args": { "params": ["digest"] } }, + + { "op": "txn.begin", "args": { "isolation": "serializable" } }, + { + "op": "blob.put", + "args": { + "store": "primary", + "from": "request.body", + "out": "computed_digest", + "out_size": "blob_size" + } + }, + { + "op": "blob.verify_digest", + "args": { "digest": "{digest}", "computed": "$computed_digest" } + }, + { "op": "txn.commit", "args": {} }, + + { + "op": "respond.json", + "args": { + "status": 201, + "headers": { + "Location": "/v2/{namespace}/{name}/blobs/{digest}", + "Docker-Content-Digest": "{digest}" + }, + "body": {} + } + } + ] + }, + + { + "id": "oci_get_blob", + "method": "GET", + "path": "/v2/{namespace}/{name}/blobs/{digest}", + "tags": ["public", "oci"], + "description": "Fetch a blob by digest for OCI/Docker images", + "pipeline": [ + { "op": "auth.require_scopes", "args": { "scopes": ["read"] } }, + { "op": "parse.path", "args": { "entity": "layer" } }, + + { + "op": "blob.get", + "args": { + "store": "primary", + "digest": "{digest}", + "out": "blob" + } + }, + { + "op": "respond.error", + "args": { + "when": { "is_null": "$blob" }, + "status": 404, + "code": "BLOB_UNKNOWN", + "message": "Blob not found" + } + }, + { + "op": "respond.bytes", + "args": { + "status": 200, + "headers": { + "Content-Type": "application/octet-stream", + "Docker-Content-Digest": "{digest}" + }, + "body": "$blob" + } + } + ] + }, + + { + "id": "oci_push_manifest", + "method": "PUT", + "path": "/v2/{namespace}/{name}/manifests/{reference}", + "tags": ["write_path", "oci"], + "description": "Push an OCI/Docker image manifest", + "pipeline": [ + { "op": "auth.require_scopes", "args": { "scopes": ["write"] } }, + { "op": "parse.path", "args": { "entity": "manifest" } }, + { "op": "parse.json", "args": { "out": "manifest_body" } }, + { "op": "oci.parse_manifest", "args": { "manifest": "$manifest_body", "out": "parsed" } }, + { "op": "oci.validate_manifest", "args": { "manifest": "$parsed" } }, + + { "op": "txn.begin", "args": { "isolation": "serializable" } }, + { + "op": "blob.put", + "args": { + "store": "primary", + "from": "request.body", + "out": "manifest_digest", + "out_size": "manifest_size" + } + }, + { "op": "time.now_iso8601", "args": { "out": "now" } }, + { + "op": "kv.put", + "args": { + "doc": "manifest", + "key": "manifest/{namespace}/{name}/{reference}", + "value": { + "namespace": "{namespace}", + "name": "{name}", + "reference": "{reference}", + "media_type": "$parsed.media_type", + "config_digest": "$parsed.config.digest", + "layers": "$parsed.layers", + "created_at": "$now", + "total_size": "$parsed.total_size" + } + } + }, + { + "op": "kv.put", + "args": { + "doc": "manifest_by_digest", + "key": "manifest_digest/{namespace}/{name}/$manifest_digest", + "value": { + "namespace": "{namespace}", + "name": "{name}", + "reference": "{reference}", + "media_type": "$parsed.media_type", + "config_digest": "$parsed.config.digest", + "layers": "$parsed.layers", + "created_at": "$now" + } + } + }, + { + "op": "emit.event", + "args": { + "type": "manifest.pushed", + "payload": { + "namespace": "{namespace}", + "name": "{name}", + "reference": "{reference}", + "digest": "$manifest_digest", + "at": "$now", + "by": "{principal.sub}" + } + } + }, + { "op": "txn.commit", "args": {} }, + + { + "op": "respond.json", + "args": { + "status": 201, + "headers": { + "Location": "/v2/{namespace}/{name}/manifests/$manifest_digest", + "Docker-Content-Digest": "$manifest_digest" + }, + "body": {} + } + } + ] + }, + + { + "id": "oci_get_manifest", + "method": "GET", + "path": "/v2/{namespace}/{name}/manifests/{reference}", + "tags": ["public", "oci"], + "description": "Fetch an OCI/Docker image manifest by tag or digest", + "pipeline": [ + { "op": "auth.require_scopes", "args": { "scopes": ["read"] } }, + { "op": "parse.path", "args": { "entity": "manifest" } }, + + { + "op": "kv.get", + "args": { + "doc": "manifest", + "key": "manifest/{namespace}/{name}/{reference}", + "out": "manifest_meta" + } + }, + { + "op": "respond.error", + "args": { + "when": { "is_null": "$manifest_meta" }, + "status": 404, + "code": "MANIFEST_UNKNOWN", + "message": "Manifest not found" + } + }, + { + "op": "blob.get", + "args": { + "store": "primary", + "digest": "$manifest_meta.config_digest", + "out": "manifest_blob" + } + }, + { + "op": "respond.bytes", + "args": { + "status": 200, + "headers": { + "Content-Type": "$manifest_meta.media_type", + "Docker-Content-Digest": "$manifest_meta.config_digest" + }, + "body": "$manifest_blob" + } + } + ] + }, + + { + "id": "publish_with_dependencies", + "method": "PUT", + "path": "/v1/{namespace}/{name}/{version}/{variant}/package", + "tags": ["write_path"], + "description": "Publish a package with metadata and dependencies", + "pipeline": [ + { "op": "auth.require_scopes", "args": { "scopes": ["write"] } }, + { "op": "parse.path", "args": { "entity": "artifact" } }, + { "op": "normalize.entity", "args": { "entity": "artifact" } }, + { "op": "validate.entity", "args": { "entity": "artifact" } }, + { "op": "parse.json", "args": { "out": "pkg" } }, + { + "op": "validate.json_schema", + "args": { + "schema": { + "type": "object", + "required": ["blob_digest"], + "properties": { + "blob_digest": { "type": "string" }, + "description": { "type": "string" }, + "license": { "type": "string" }, + "homepage": { "type": "string" }, + "authors": { "type": "array", "items": { "type": "string" } }, + "keywords": { "type": "array", "items": { "type": "string" } }, + "package_type": { "type": "string" }, + "dependencies": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "version_spec"], + "properties": { + "namespace": { "type": "string" }, + "name": { "type": "string" }, + "version_spec": { "type": "string" }, + "kind": { "type": "string" } + } + } + } + } + }, + "value": "$pkg" + } + }, + + { "op": "txn.begin", "args": { "isolation": "serializable" } }, + { "op": "time.now_iso8601", "args": { "out": "now" } }, + + { + "op": "kv.cas_put", + "args": { + "doc": "artifact_meta", + "key": "artifact/{namespace}/{name}/{version}/{variant}", + "if_absent": true, + "value": { + "namespace": "{namespace}", + "name": "{name}", + "version": "{version}", + "variant": "{variant}", + "blob_digest": "$pkg.blob_digest", + "created_at": "$now", + "created_by": "{principal.sub}", + "package_info": { + "description": "$pkg.description", + "license": "$pkg.license", + "homepage": "$pkg.homepage", + "authors": "$pkg.authors", + "keywords": "$pkg.keywords", + "package_type": "$pkg.package_type" + }, + "dependencies": "$pkg.dependencies" + } + } + }, + { + "op": "index.upsert", + "args": { + "index": "artifact_versions", + "key": { "namespace": "{namespace}", "name": "{name}" }, + "value": { + "namespace": "{namespace}", + "name": "{name}", + "version": "{version}", + "variant": "{variant}", + "blob_digest": "$pkg.blob_digest" + } + } + }, + { + "op": "emit.event", + "args": { + "type": "artifact.published", + "payload": { + "namespace": "{namespace}", + "name": "{name}", + "version": "{version}", + "variant": "{variant}", + "blob_digest": "$pkg.blob_digest", + "at": "$now", + "by": "{principal.sub}", + "has_dependencies": true + } + } + }, + { "op": "txn.commit", "args": {} }, + + { + "op": "respond.json", + "args": { + "status": 201, + "body": { "ok": true, "digest": "$pkg.blob_digest" } + } + } + ] + }, + + { + "id": "get_dependencies", + "method": "GET", + "path": "/v1/{namespace}/{name}/{version}/{variant}/dependencies", + "tags": ["public"], + "description": "Get dependencies for a package", + "pipeline": [ + { "op": "auth.require_scopes", "args": { "scopes": ["read"] } }, + { "op": "parse.path", "args": { "entity": "artifact" } }, + { "op": "normalize.entity", "args": { "entity": "artifact" } }, + + { + "op": "kv.get", + "args": { + "doc": "artifact_meta", + "key": "artifact/{namespace}/{name}/{version}/{variant}", + "out": "meta" + } + }, + { + "op": "respond.error", + "args": { + "when": { "is_null": "$meta" }, + "status": 404, + "code": "NOT_FOUND", + "message": "Package not found" + } + }, + { + "op": "respond.json", + "args": { + "status": 200, + "body": { + "namespace": "{namespace}", + "name": "{name}", + "version": "{version}", + "variant": "{variant}", + "dependencies": "$meta.dependencies" + } + } + } + ] + }, + + { + "id": "add_signature", + "method": "POST", + "path": "/v1/{namespace}/{name}/{version}/{variant}/signatures", + "tags": ["write_path"], + "description": "Add a cryptographic signature to a package", + "pipeline": [ + { "op": "auth.require_scopes", "args": { "scopes": ["write"] } }, + { "op": "parse.path", "args": { "entity": "artifact" } }, + { "op": "normalize.entity", "args": { "entity": "artifact" } }, + { "op": "parse.json", "args": { "out": "sig_data" } }, + { + "op": "validate.json_schema", + "args": { + "schema": { + "type": "object", + "required": ["key_id", "algo", "signature"], + "properties": { + "key_id": { "type": "string" }, + "algo": { "type": "string", "enum": ["ed25519", "rsa-sha256", "ecdsa-p256"] }, + "signature": { "type": "string" } + } + }, + "value": "$sig_data" + } + }, + + { + "op": "kv.get", + "args": { + "doc": "artifact_meta", + "key": "artifact/{namespace}/{name}/{version}/{variant}", + "out": "meta" + } + }, + { + "op": "respond.error", + "args": { + "when": { "is_null": "$meta" }, + "status": 404, + "code": "NOT_FOUND", + "message": "Package not found" + } + }, + + { + "op": "sig.verify", + "args": { + "key_id": "$sig_data.key_id", + "algo": "$sig_data.algo", + "signature": "$sig_data.signature", + "data_digest": "$meta.blob_digest" + } + }, + + { "op": "txn.begin", "args": { "isolation": "serializable" } }, + { "op": "time.now_iso8601", "args": { "out": "now" } }, + { + "op": "kv.put", + "args": { + "doc": "signature", + "key": "sig/{namespace}/{name}/{version}/{variant}/$sig_data.key_id", + "value": { + "key_id": "$sig_data.key_id", + "algo": "$sig_data.algo", + "signature": "$sig_data.signature", + "signed_at": "$now", + "signed_by": "{principal.sub}" + } + } + }, + { + "op": "emit.event", + "args": { + "type": "signature.added", + "payload": { + "namespace": "{namespace}", + "name": "{name}", + "version": "{version}", + "variant": "{variant}", + "key_id": "$sig_data.key_id", + "at": "$now", + "by": "{principal.sub}" + } + } + }, + { "op": "txn.commit", "args": {} }, + + { "op": "respond.json", "args": { "status": 201, "body": { "ok": true } } } + ] } ] }, @@ -709,6 +1382,41 @@ "name": "tag.updated", "durable": true, "schema": { "type": "object" } + }, + { + "name": "manifest.pushed", + "durable": true, + "description": "Emitted when an OCI/Docker manifest is pushed", + "schema": { + "type": "object", + "required": ["namespace", "name", "reference", "digest", "at"], + "properties": { + "namespace": { "type": "string" }, + "name": { "type": "string" }, + "reference": { "type": "string" }, + "digest": { "type": "string" }, + "at": { "type": "string" }, + "by": { "type": "string" } + } + } + }, + { + "name": "signature.added", + "durable": true, + "description": "Emitted when a signature is added to a package", + "schema": { + "type": "object", + "required": ["namespace", "name", "version", "key_id", "at"], + "properties": { + "namespace": { "type": "string" }, + "name": { "type": "string" }, + "version": { "type": "string" }, + "variant": { "type": "string" }, + "key_id": { "type": "string" }, + "at": { "type": "string" }, + "by": { "type": "string" } + } + } } ] }, @@ -839,7 +1547,11 @@ "features": { "mutable_tags": true, "allow_overwrite_artifacts": false, - "proxy_enabled": true + "proxy_enabled": true, + "gc_enabled": true, + "signing_enabled": true, + "oci_enabled": true, + "dependencies_enabled": true }, "validation": {