diff --git a/dbal/cpp/src/client.cpp b/dbal/cpp/src/client.cpp index d9b82e50d..237445328 100644 --- a/dbal/cpp/src/client.cpp +++ b/dbal/cpp/src/client.cpp @@ -1,4 +1,6 @@ #include "dbal/client.hpp" +#include "entities/lua_script/index.hpp" +#include "store/in_memory_store.hpp" #include #include #include @@ -7,32 +9,6 @@ namespace dbal { -// In-memory store for mock implementation -struct InMemoryStore { - std::map users; - std::map pages; - std::map page_slugs; // slug -> id mapping - std::map workflows; - std::map workflow_names; // name -> id mapping - std::map sessions; - std::map session_tokens; // token -> id mapping - std::map lua_scripts; - std::map lua_script_names; // name -> id mapping - std::map packages; - std::map package_keys; // name@version -> id mapping - int user_counter = 0; - int page_counter = 0; - int workflow_counter = 0; - int session_counter = 0; - int lua_script_counter = 0; - int package_counter = 0; -}; - -static InMemoryStore& getStore() { - static InMemoryStore store; - return store; -} - // Validation helpers static bool isValidEmail(const std::string& email) { static const std::regex email_pattern(R"([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})"); @@ -60,18 +36,6 @@ static bool isValidWorkflowTrigger(const std::string& trigger) { return std::find(allowed.begin(), allowed.end(), trigger) != allowed.end(); } -static bool isValidLuaScriptName(const std::string& name) { - return !name.empty() && name.length() <= 255; -} - -static bool isValidLuaScriptCode(const std::string& code) { - return !code.empty(); -} - -static bool isValidLuaTimeout(int timeout_ms) { - return timeout_ms >= 100 && timeout_ms <= 30000; -} - static bool isValidPackageName(const std::string& name) { return !name.empty() && name.length() <= 255; } @@ -85,12 +49,6 @@ static std::string packageKey(const std::string& name, const std::string& versio return name + "@" + version; } -static std::string generateId(const std::string& prefix, int counter) { - char buffer[64]; - snprintf(buffer, sizeof(buffer), "%s_%08d", prefix.c_str(), counter); - return std::string(buffer); -} - Client::Client(const ClientConfig& config) : config_(config) { // Validate configuration if (config.adapter.empty()) { @@ -128,7 +86,7 @@ Result Client::createUser(const CreateUserInput& input) { // Create user User user; - user.id = generateId("user", ++store.user_counter); + user.id = store.generateId("user", ++store.user_counter); user.username = input.username; user.email = input.email; user.role = input.role; @@ -335,7 +293,7 @@ Result Client::createPage(const CreatePageInput& input) { // Create page PageView page; - page.id = generateId("page", ++store.page_counter); + page.id = store.generateId("page", ++store.page_counter); page.slug = input.slug; page.title = input.title; page.description = input.description; @@ -525,7 +483,7 @@ Result Client::createWorkflow(const CreateWorkflowInput& input) { } Workflow workflow; - workflow.id = generateId("workflow", ++store.workflow_counter); + workflow.id = store.generateId("workflow", ++store.workflow_counter); workflow.name = input.name; workflow.description = input.description; workflow.trigger = input.trigger; @@ -700,7 +658,7 @@ Result Client::createSession(const CreateSessionInput& input) { } Session session; - session.id = generateId("session", ++store.session_counter); + session.id = store.generateId("session", ++store.session_counter); session.user_id = input.user_id; session.token = input.token; session.expires_at = input.expires_at; @@ -860,191 +818,23 @@ Result> Client::listSessions(const ListOptions& options) { } Result Client::createLuaScript(const CreateLuaScriptInput& input) { - if (!isValidLuaScriptName(input.name)) { - return Error::validationError("Lua script name must be 1-255 characters"); - } - if (!isValidLuaScriptCode(input.code)) { - return Error::validationError("Lua script code must be a non-empty string"); - } - if (!isValidLuaTimeout(input.timeout_ms)) { - return Error::validationError("Timeout must be between 100 and 30000 ms"); - } - if (input.created_by.empty()) { - return Error::validationError("created_by is required"); - } - - for (const auto& entry : input.allowed_globals) { - if (entry.empty()) { - return Error::validationError("allowed_globals must contain non-empty strings"); - } - } - - auto& store = getStore(); - if (store.lua_script_names.find(input.name) != store.lua_script_names.end()) { - return Error::conflict("Lua script name already exists: " + input.name); - } - - LuaScript script; - script.id = generateId("lua", ++store.lua_script_counter); - script.name = input.name; - script.description = input.description; - script.code = input.code; - script.is_sandboxed = input.is_sandboxed; - script.allowed_globals = input.allowed_globals; - script.timeout_ms = input.timeout_ms; - script.created_by = input.created_by; - script.created_at = std::chrono::system_clock::now(); - script.updated_at = script.created_at; - - store.lua_scripts[script.id] = script; - store.lua_script_names[script.name] = script.id; - - return Result(script); + return entities::lua_script::create(getStore(), input); } Result Client::getLuaScript(const std::string& id) { - if (id.empty()) { - return Error::validationError("Lua script ID cannot be empty"); - } - - auto& store = getStore(); - auto it = store.lua_scripts.find(id); - - if (it == store.lua_scripts.end()) { - return Error::notFound("Lua script not found: " + id); - } - - return Result(it->second); + return entities::lua_script::get(getStore(), id); } Result Client::updateLuaScript(const std::string& id, const UpdateLuaScriptInput& input) { - if (id.empty()) { - return Error::validationError("Lua script ID cannot be empty"); - } - - auto& store = getStore(); - auto it = store.lua_scripts.find(id); - - if (it == store.lua_scripts.end()) { - return Error::notFound("Lua script not found: " + id); - } - - LuaScript& script = it->second; - std::string old_name = script.name; - - if (input.name.has_value()) { - if (!isValidLuaScriptName(input.name.value())) { - return Error::validationError("Lua script name must be 1-255 characters"); - } - auto name_it = store.lua_script_names.find(input.name.value()); - if (name_it != store.lua_script_names.end() && name_it->second != id) { - return Error::conflict("Lua script name already exists: " + input.name.value()); - } - store.lua_script_names.erase(old_name); - store.lua_script_names[input.name.value()] = id; - script.name = input.name.value(); - } - - if (input.description.has_value()) { - script.description = input.description.value(); - } - - if (input.code.has_value()) { - if (!isValidLuaScriptCode(input.code.value())) { - return Error::validationError("Lua script code must be a non-empty string"); - } - script.code = input.code.value(); - } - - if (input.is_sandboxed.has_value()) { - script.is_sandboxed = input.is_sandboxed.value(); - } - - if (input.allowed_globals.has_value()) { - for (const auto& entry : input.allowed_globals.value()) { - if (entry.empty()) { - return Error::validationError("allowed_globals must contain non-empty strings"); - } - } - script.allowed_globals = input.allowed_globals.value(); - } - - if (input.timeout_ms.has_value()) { - if (!isValidLuaTimeout(input.timeout_ms.value())) { - return Error::validationError("Timeout must be between 100 and 30000 ms"); - } - script.timeout_ms = input.timeout_ms.value(); - } - - if (input.created_by.has_value()) { - if (input.created_by.value().empty()) { - return Error::validationError("created_by is required"); - } - script.created_by = input.created_by.value(); - } - - script.updated_at = std::chrono::system_clock::now(); - - return Result(script); + return entities::lua_script::update(getStore(), id, input); } Result Client::deleteLuaScript(const std::string& id) { - if (id.empty()) { - return Error::validationError("Lua script ID cannot be empty"); - } - - auto& store = getStore(); - auto it = store.lua_scripts.find(id); - - if (it == store.lua_scripts.end()) { - return Error::notFound("Lua script not found: " + id); - } - - store.lua_script_names.erase(it->second.name); - store.lua_scripts.erase(it); - - return Result(true); + return entities::lua_script::remove(getStore(), id); } Result> Client::listLuaScripts(const ListOptions& options) { - auto& store = getStore(); - std::vector scripts; - - for (const auto& [id, script] : store.lua_scripts) { - bool matches = true; - - if (options.filter.find("created_by") != options.filter.end()) { - if (script.created_by != options.filter.at("created_by")) matches = false; - } - - if (options.filter.find("is_sandboxed") != options.filter.end()) { - bool filter_sandboxed = options.filter.at("is_sandboxed") == "true"; - if (script.is_sandboxed != filter_sandboxed) matches = false; - } - - if (matches) { - scripts.push_back(script); - } - } - - if (options.sort.find("name") != options.sort.end()) { - std::sort(scripts.begin(), scripts.end(), [](const LuaScript& a, const LuaScript& b) { - return a.name < b.name; - }); - } else if (options.sort.find("created_at") != options.sort.end()) { - std::sort(scripts.begin(), scripts.end(), [](const LuaScript& a, const LuaScript& b) { - return a.created_at < b.created_at; - }); - } - - int start = (options.page - 1) * options.limit; - int end = std::min(start + options.limit, static_cast(scripts.size())); - - if (start < static_cast(scripts.size())) { - return Result>(std::vector(scripts.begin() + start, scripts.begin() + end)); - } - - return Result>(std::vector()); + return entities::lua_script::list(getStore(), options); } Result Client::createPackage(const CreatePackageInput& input) { @@ -1065,7 +855,7 @@ Result Client::createPackage(const CreatePackageInput& input) { } Package package; - package.id = generateId("package", ++store.package_counter); + package.id = store.generateId("package", ++store.package_counter); package.name = input.name; package.version = input.version; package.description = input.description; diff --git a/dbal/docs/CVE_ANALYSIS_2025_12.md b/dbal/docs/CVE_ANALYSIS_2025_12.md index fcac0d0e1..4a9518a66 100644 --- a/dbal/docs/CVE_ANALYSIS_2025_12.md +++ b/dbal/docs/CVE_ANALYSIS_2025_12.md @@ -1124,18 +1124,36 @@ private deepEquals(a: any, b: any): boolean { - Could allow subtle bypass of list removal - DoS via circular reference (though TypeScript type system helps) -**Recommendation**: +**๐Ÿฐ Fort Knox Remediation**: ```typescript import { isDeepStrictEqual } from 'util' -private deepEquals(a: any, b: any): boolean { +/** + * Secure deep equality check with protections + */ +function secureDeepEquals(a: unknown, b: unknown, maxDepth = 10): boolean { + // Guard against stack overflow from deeply nested structures + function checkDepth(obj: unknown, depth: number): void { + if (depth > maxDepth) { + throw DBALError.validationError('Object nesting exceeds maximum depth') + } + if (obj !== null && typeof obj === 'object') { + for (const value of Object.values(obj)) { + checkDepth(value, depth + 1) + } + } + } + + checkDepth(a, 0) + checkDepth(b, 0) + return isDeepStrictEqual(a, b) } ``` --- -#### DBAL-2025-009: Quota Bypass via Concurrent Requests (MEDIUM) +#### DBAL-2025-009: Quota Bypass via Concurrent Requests (MEDIUM โ†’ HIGH) **Location**: [kv-store.ts](../ts/src/core/kv-store.ts#L101-L140) ```typescript @@ -1159,10 +1177,194 @@ async set(key: string, value: StorableValue, context: TenantContext, ttl?: numbe - Concurrent requests can exceed quota by writing simultaneously - Pattern: CWE-362 (Concurrent Execution) -**Recommendation**: -- Use atomic quota reservation before write -- Implement distributed locking for multi-node deployments -- Consider Redis-based quota with Lua scripts for atomicity +**๐Ÿฐ Fort Knox Remediation**: +```typescript +import { createClient, RedisClientType } from 'redis' + +/** + * Fort Knox Quota Manager + * Atomic quota operations using Redis Lua scripts + */ +class AtomicQuotaManager { + private redis: RedisClientType + + // Lua script for atomic quota check-and-reserve + // Returns: 1 = success, 0 = quota exceeded, -1 = error + private static readonly RESERVE_QUOTA_SCRIPT = ` + local key = KEYS[1] + local limit = tonumber(ARGV[1]) + local requested = tonumber(ARGV[2]) + local ttl = tonumber(ARGV[3]) or 300 + + -- Get current usage (atomic read) + local current = tonumber(redis.call('GET', key) or '0') + + -- Check if request would exceed quota + if current + requested > limit then + return 0 -- Quota exceeded + end + + -- Reserve quota atomically + local new_total = redis.call('INCRBY', key, requested) + + -- Set TTL on first use + if current == 0 then + redis.call('EXPIRE', key, ttl) + end + + -- Double-check we didn't exceed (handles race at limit) + if tonumber(new_total) > limit then + -- Rollback + redis.call('DECRBY', key, requested) + return 0 + end + + return 1 -- Success + ` + + private static readonly RELEASE_QUOTA_SCRIPT = ` + local key = KEYS[1] + local amount = tonumber(ARGV[1]) + + local current = tonumber(redis.call('GET', key) or '0') + if current < amount then + redis.call('SET', key, '0') + return 0 + end + + redis.call('DECRBY', key, amount) + return 1 + ` + + private reserveScriptSha: string | null = null + private releaseScriptSha: string | null = null + + async initialize(): Promise { + // Pre-load Lua scripts for efficiency + this.reserveScriptSha = await this.redis.scriptLoad( + AtomicQuotaManager.RESERVE_QUOTA_SCRIPT + ) + this.releaseScriptSha = await this.redis.scriptLoad( + AtomicQuotaManager.RELEASE_QUOTA_SCRIPT + ) + } + + /** + * Attempt to reserve quota atomically + * @returns true if reservation successful, false if quota exceeded + */ + async reserveQuota( + tenantId: string, + quotaType: 'storage' | 'records' | 'operations', + amount: number, + limit: number + ): Promise { + const key = `quota:${tenantId}:${quotaType}` + + const result = await this.redis.evalSha( + this.reserveScriptSha!, + { keys: [key], arguments: [String(limit), String(amount), '86400'] } + ) + + if (result === 0) { + // Log quota exceeded for monitoring + console.warn(JSON.stringify({ + event: 'QUOTA_EXCEEDED', + tenantId, + quotaType, + requested: amount, + limit, + timestamp: new Date().toISOString() + })) + return false + } + + return true + } + + /** + * Release reserved quota (on delete or failure rollback) + */ + async releaseQuota( + tenantId: string, + quotaType: 'storage' | 'records' | 'operations', + amount: number + ): Promise { + const key = `quota:${tenantId}:${quotaType}` + + await this.redis.evalSha( + this.releaseScriptSha!, + { keys: [key], arguments: [String(amount)] } + ) + } + + /** + * Get current quota usage + */ + async getUsage(tenantId: string, quotaType: string): Promise { + const key = `quota:${tenantId}:${quotaType}` + const value = await this.redis.get(key) + return parseInt(value || '0', 10) + } +} + +/** + * Fort Knox KV Store with atomic quotas + */ +class SecureKVStore implements KVStore { + private quotaManager: AtomicQuotaManager + + async set(key: string, value: StorableValue, context: TenantContext, ttl?: number): Promise { + const sizeBytes = this.calculateSize(value) + + // ATOMIC: Reserve quota before writing + const quotaReserved = await this.quotaManager.reserveQuota( + context.identity.tenantId, + 'storage', + sizeBytes, + context.quota.maxDataSizeBytes || Infinity + ) + + if (!quotaReserved) { + throw DBALError.quotaExceeded('Storage quota exceeded') + } + + try { + // Perform the write + await this.doSet(key, value, context, ttl) + } catch (error) { + // CRITICAL: Release quota on failure + await this.quotaManager.releaseQuota( + context.identity.tenantId, + 'storage', + sizeBytes + ) + throw error + } + } +} +``` + +**In-Memory Alternative** (for single-node deployments): +```typescript +import { Mutex } from 'async-mutex' + +class InMemoryAtomicQuota { + private mutex = new Mutex() + private usage = new Map() + + async reserveQuota(key: string, amount: number, limit: number): Promise { + return await this.mutex.runExclusive(() => { + const current = this.usage.get(key) || 0 + if (current + amount > limit) { + return false + } + this.usage.set(key, current + amount) + return true + }) + } +} +``` --- diff --git a/dbal/ts/src/core/entities/lua-script/create-lua-script.ts b/dbal/ts/src/core/entities/lua-script/create-lua-script.ts index 289e121a4..d3a82076e 100644 --- a/dbal/ts/src/core/entities/lua-script/create-lua-script.ts +++ b/dbal/ts/src/core/entities/lua-script/create-lua-script.ts @@ -2,38 +2,32 @@ * @file create-lua-script.ts * @description Create Lua script operation */ -import type { LuaScript, CreateLuaScriptInput, Result } from '../types'; -import type { InMemoryStore } from '../store/in-memory-store'; -import { validateLuaSyntax } from '../validation/lua-script-validation'; +import type { DBALAdapter } from '../../../adapters/adapter' +import type { LuaScript } from '../../types' +import { DBALError } from '../../errors' +import { validateLuaScriptCreate } from '../../validation' /** * Create a new Lua script in the store */ export async function createLuaScript( - store: InMemoryStore, - input: CreateLuaScriptInput -): Promise> { - if (!input.name || input.name.length > 100) { - return { success: false, error: { code: 'VALIDATION_ERROR', message: 'Name required (max 100)' } }; - } - if (!input.code) { - return { success: false, error: { code: 'VALIDATION_ERROR', message: 'Code required' } }; - } - if (!validateLuaSyntax(input.code)) { - return { success: false, error: { code: 'VALIDATION_ERROR', message: 'Invalid Lua syntax' } }; + adapter: DBALAdapter, + data: Omit +): Promise { + const validationErrors = validateLuaScriptCreate(data) + if (validationErrors.length > 0) { + throw DBALError.validationError( + 'Invalid Lua script data', + validationErrors.map(error => ({ field: 'luaScript', error })) + ) } - const script: LuaScript = { - id: store.generateId('lua_script'), - name: input.name, - description: input.description ?? '', - code: input.code, - category: input.category ?? 'general', - isActive: input.isActive ?? true, - createdAt: new Date(), - updatedAt: new Date(), - }; - - store.luaScripts.set(script.id, script); - return { success: true, data: script }; + try { + return adapter.create('LuaScript', data) as Promise + } catch (error) { + if (error instanceof DBALError && error.code === 409) { + throw DBALError.conflict(`Lua script with name '${data.name}' already exists`) + } + throw error + } } diff --git a/frontends/nextjs/src/app/levels/LevelsClient.tsx b/frontends/nextjs/src/app/levels/LevelsClient.tsx new file mode 100644 index 000000000..6a8f75ec8 --- /dev/null +++ b/frontends/nextjs/src/app/levels/LevelsClient.tsx @@ -0,0 +1,148 @@ +'use client' + +import { useMemo, useState } from 'react' + +import { + Alert, + Box, + Button, + Chip, + Container, + Divider, + Grid, + Paper, + Stack, + Typography, +} from '@mui/material' + +import { PERMISSION_LEVELS, type PermissionLevel } from './levels-data' + +const highlightColor = (level: PermissionLevel) => { + if (level.id === 5) return 'warning.main' + if (level.id === 4) return 'primary.main' + return 'divider' +} + +export default function LevelsClient() { + const [selectedLevelId, setSelectedLevelId] = useState(PERMISSION_LEVELS[0].id) + const [note, setNote] = useState('') + + const selectedLevel = useMemo( + () => PERMISSION_LEVELS.find((level) => level.id === selectedLevelId) ?? PERMISSION_LEVELS[0], + [selectedLevelId] + ) + + const nextLevel = useMemo( + () => PERMISSION_LEVELS.find((level) => level.id === selectedLevelId + 1) ?? null, + [selectedLevelId] + ) + + const handleSelect = (levelId: number) => { + setSelectedLevelId(levelId) + setNote(`Selected ${PERMISSION_LEVELS.find((l) => l.id === levelId)?.title ?? 'unknown'} privileges.`) + } + + const handlePromote = () => { + if (!nextLevel) { + setNote('You already command the cosmos. No further promotions available.') + return + } + setSelectedLevelId(nextLevel.id) + setNote(`Upgraded to ${nextLevel.title}.`) + } + + return ( + + + + + The Five Permission Levels + + + Level up through Guest, Regular User, Moderator, God, and Super God to unlock the right + controls for your role. + + + + + {PERMISSION_LEVELS.map((level) => ( + + handleSelect(level.id)} + sx={{ + border: (theme) => `2px solid ${selectedLevel.id === level.id ? theme.palette.primary.main : theme.palette.divider}`, + p: 3, + cursor: 'pointer', + position: 'relative', + '&:hover': { + borderColor: 'primary.main', + }, + }} + elevation={selectedLevel.id === level.id ? 6 : 1} + > + + + + Level {level.id} ยท {level.title} + + {level.tagline} + + + {level.description} + + + {level.capabilities.slice(0, 3).map((capability) => ( + + ))} + + + + ))} + + + `1px dashed ${theme.palette.divider}`, bgcolor: 'background.paper' }}> + + + Selected level details + + + + {selectedLevel.description} + + + {selectedLevel.capabilities.map((capability) => ( + + ))} + + + + + Next move + + {nextLevel ? ( + + Promote into {nextLevel.title} to unlock {nextLevel.capabilities.length} controls. + + ) : ( + + Super God reigns supreme. You already own every privilege. + + )} + + + + + {note && {note}} + + + + + ) +} diff --git a/frontends/nextjs/src/app/levels/levels-data.ts b/frontends/nextjs/src/app/levels/levels-data.ts new file mode 100644 index 000000000..654c05f01 --- /dev/null +++ b/frontends/nextjs/src/app/levels/levels-data.ts @@ -0,0 +1,57 @@ +export type PermissionLevel = { + id: number + key: string + title: string + description: string + badge: string + capabilities: string[] + tagline: string +} + +export const PERMISSION_LEVELS: PermissionLevel[] = [ + { + id: 1, + key: 'guest', + title: 'Guest', + badge: '๐Ÿ‘๏ธ', + description: 'Browse the public landing pages and marketing content with read-only access.', + tagline: 'View-only browsing with zero privileges.', + capabilities: ['Access front page', 'Read public articles', 'View news feed'], + }, + { + id: 2, + key: 'regular', + title: 'Regular User', + badge: '๐Ÿง‘โ€๐Ÿ’ป', + description: 'Interact with your profile, store preferences, and explore configurable dashboards.', + tagline: 'Personalized space for regular contributors and team members.', + capabilities: ['Edit personal settings', 'Update profile', 'Launch saved dashboards', 'Submit tickets'], + }, + { + id: 3, + key: 'moderator', + title: 'Moderator', + badge: '๐Ÿ›ก๏ธ', + description: 'Keep the community healthy, triage issues, and enforce conduct policies.', + tagline: 'Guardrails for the wider user base.', + capabilities: ['Moderate discussions', 'Resolve user flags', 'Review reports', 'Approve content'], + }, + { + id: 4, + key: 'god', + title: 'God', + badge: '๐Ÿง™โ€โ™‚๏ธ', + description: 'Design workflows, compose pages, and orchestrate the system architecture.', + tagline: 'Blueprint builder with editing rights.', + capabilities: ['Edit the front page', 'Author workflows', 'Define multi-tenant settings', 'Seed packages'], + }, + { + id: 5, + key: 'supergod', + title: 'Super God', + badge: '๐Ÿ‘‘', + description: 'Full sovereignty over the universe: transfer rights, manage infrastructure, and override controls.', + tagline: 'Ultimate authority for system-level changes.', + capabilities: ['Assign god roles', 'Transfer front page rights', 'Burn/restore tenants', 'Run security audits'], + }, +] diff --git a/frontends/nextjs/src/app/levels/page.tsx b/frontends/nextjs/src/app/levels/page.tsx new file mode 100644 index 000000000..1c4df2e51 --- /dev/null +++ b/frontends/nextjs/src/app/levels/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' + +import LevelsClient from './LevelsClient' + +export const metadata: Metadata = { + title: 'Permission Levels', + description: 'Explore the five permission tiers that govern MetaBuilder.', +} + +export default function LevelsPage() { + return +} diff --git a/frontends/nextjs/src/lib/db/lua-scripts/add-lua-script.test.ts b/frontends/nextjs/src/lib/db/lua-scripts/add-lua-script.test.ts index 22893a13f..958fccab9 100644 --- a/frontends/nextjs/src/lib/db/lua-scripts/add-lua-script.test.ts +++ b/frontends/nextjs/src/lib/db/lua-scripts/add-lua-script.test.ts @@ -23,15 +23,34 @@ describe('addLuaScript', () => { name: 'script with parameters', script: { id: 'ls2', name: 'Calc', code: 'return a + b', parameters: [{ name: 'a' }, { name: 'b' }], returnType: 'number' }, }, + { + name: 'script with sandbox profile', + script: { + id: 'ls3', + name: 'Sandboxed', + code: 'return math.sqrt(9)', + parameters: [], + isSandboxed: true, + allowedGlobals: ['math'], + timeoutMs: 2500, + }, + }, ])('should add $name', async ({ script }) => { mockCreate.mockResolvedValue(undefined) await addLuaScript(script as any) + const payload = mockCreate.mock.calls[0]?.[1] as Record + expect(mockCreate).toHaveBeenCalledWith('LuaScript', expect.objectContaining({ id: script.id, name: script.name, code: script.code, })) + + if (script.allowedGlobals) { + expect(payload.allowedGlobals).toContain('math') + expect(payload.timeoutMs).toBe(script.timeoutMs) + } }) }) diff --git a/frontends/nextjs/src/lib/db/lua-scripts/add-lua-script.ts b/frontends/nextjs/src/lib/db/lua-scripts/add-lua-script.ts index 800bec9ce..28ee08a34 100644 --- a/frontends/nextjs/src/lib/db/lua-scripts/add-lua-script.ts +++ b/frontends/nextjs/src/lib/db/lua-scripts/add-lua-script.ts @@ -1,17 +1,11 @@ import { getAdapter } from '../dbal-client' import type { LuaScript } from '../../types/level-types' +import { serializeLuaScript } from './serialize-lua-script' /** * Add a Lua script */ export async function addLuaScript(script: LuaScript): Promise { const adapter = getAdapter() - await adapter.create('LuaScript', { - id: script.id, - name: script.name, - description: script.description, - code: script.code, - parameters: JSON.stringify(script.parameters), - returnType: script.returnType, - }) + await adapter.create('LuaScript', serializeLuaScript(script)) } diff --git a/frontends/nextjs/src/lib/db/lua-scripts/build-lua-script-update.ts b/frontends/nextjs/src/lib/db/lua-scripts/build-lua-script-update.ts new file mode 100644 index 000000000..9bbefa9a3 --- /dev/null +++ b/frontends/nextjs/src/lib/db/lua-scripts/build-lua-script-update.ts @@ -0,0 +1,19 @@ +import type { LuaScript } from '../../types/level-types' +import { normalizeAllowedGlobals } from '../../lua/functions/sandbox/normalize-allowed-globals' + +export function buildLuaScriptUpdate(updates: Partial): Record { + const data: Record = {} + + if (updates.name !== undefined) data.name = updates.name + if (updates.description !== undefined) data.description = updates.description + if (updates.code !== undefined) data.code = updates.code + if (updates.parameters !== undefined) data.parameters = JSON.stringify(updates.parameters) + if (updates.returnType !== undefined) data.returnType = updates.returnType + if (updates.isSandboxed !== undefined) data.isSandboxed = updates.isSandboxed + if (updates.allowedGlobals !== undefined) { + data.allowedGlobals = JSON.stringify(normalizeAllowedGlobals(updates.allowedGlobals)) + } + if (updates.timeoutMs !== undefined) data.timeoutMs = updates.timeoutMs + + return data +} diff --git a/frontends/nextjs/src/lib/db/lua-scripts/get-lua-scripts.test.ts b/frontends/nextjs/src/lib/db/lua-scripts/get-lua-scripts.test.ts index 8a9930e50..b7fe8b872 100644 --- a/frontends/nextjs/src/lib/db/lua-scripts/get-lua-scripts.test.ts +++ b/frontends/nextjs/src/lib/db/lua-scripts/get-lua-scripts.test.ts @@ -18,14 +18,35 @@ describe('getLuaScripts', () => { { name: 'empty', dbData: [], expectedLength: 0 }, { name: 'parsed scripts', - dbData: [{ id: 'ls1', name: 'Test', description: null, code: 'return 1', parameters: '[]', returnType: null }], + dbData: [ + { + id: 'ls1', + name: 'Test', + description: null, + code: 'return 1', + parameters: '[]', + returnType: null, + isSandboxed: true, + allowedGlobals: '["math","string"]', + timeoutMs: 3000, + }, + ], expectedLength: 1, + expectedFirst: { + id: 'ls1', + allowedGlobals: ['math', 'string'], + isSandboxed: true, + timeoutMs: 3000, + }, }, - ])('should return $name', async ({ dbData, expectedLength }) => { + ])('should return $name', async ({ dbData, expectedLength, expectedFirst }) => { mockList.mockResolvedValue({ data: dbData }) const result = await getLuaScripts() expect(result).toHaveLength(expectedLength) + if (expectedFirst) { + expect(result[0]).toEqual(expect.objectContaining(expectedFirst)) + } }) }) diff --git a/frontends/nextjs/src/lib/db/lua-scripts/get-lua-scripts.ts b/frontends/nextjs/src/lib/db/lua-scripts/get-lua-scripts.ts index 5efeb017f..7eff535f5 100644 --- a/frontends/nextjs/src/lib/db/lua-scripts/get-lua-scripts.ts +++ b/frontends/nextjs/src/lib/db/lua-scripts/get-lua-scripts.ts @@ -1,5 +1,6 @@ import { getAdapter } from '../dbal-client' import type { LuaScript } from '../../types/level-types' +import { deserializeLuaScript } from './deserialize-lua-script' /** * Get all Lua scripts @@ -7,12 +8,5 @@ import type { LuaScript } from '../../types/level-types' export async function getLuaScripts(): Promise { const adapter = getAdapter() const result = await adapter.list('LuaScript') - return (result.data as any[]).map((s) => ({ - id: s.id, - name: s.name, - description: s.description || undefined, - code: s.code, - parameters: JSON.parse(s.parameters), - returnType: s.returnType || undefined, - })) + return (result.data as Record[]).map(deserializeLuaScript) } diff --git a/frontends/nextjs/src/lib/db/lua-scripts/set-lua-scripts.test.ts b/frontends/nextjs/src/lib/db/lua-scripts/set-lua-scripts.test.ts index e407107fc..36f856558 100644 --- a/frontends/nextjs/src/lib/db/lua-scripts/set-lua-scripts.test.ts +++ b/frontends/nextjs/src/lib/db/lua-scripts/set-lua-scripts.test.ts @@ -23,7 +23,16 @@ describe('setLuaScripts', () => { mockDelete.mockResolvedValue(undefined) mockCreate.mockResolvedValue(undefined) - await setLuaScripts([{ id: 'new', name: 'New', code: 'return 1', parameters: [] }] as any) + await setLuaScripts([ + { + id: 'new', + name: 'New', + code: 'return 1', + parameters: [], + allowedGlobals: ['math'], + timeoutMs: 1500, + }, + ] as any) expect(mockDelete).toHaveBeenCalledTimes(1) expect(mockCreate).toHaveBeenCalledTimes(1) diff --git a/frontends/nextjs/src/lib/db/lua-scripts/set-lua-scripts.ts b/frontends/nextjs/src/lib/db/lua-scripts/set-lua-scripts.ts index f991d2109..a5e465295 100644 --- a/frontends/nextjs/src/lib/db/lua-scripts/set-lua-scripts.ts +++ b/frontends/nextjs/src/lib/db/lua-scripts/set-lua-scripts.ts @@ -1,5 +1,6 @@ import { getAdapter } from '../dbal-client' import type { LuaScript } from '../../types/level-types' +import { serializeLuaScript } from './serialize-lua-script' /** * Set all Lua scripts (replaces existing) @@ -15,13 +16,6 @@ export async function setLuaScripts(scripts: LuaScript[]): Promise { // Create new scripts for (const script of scripts) { - await adapter.create('LuaScript', { - id: script.id, - name: script.name, - description: script.description, - code: script.code, - parameters: JSON.stringify(script.parameters), - returnType: script.returnType, - }) + await adapter.create('LuaScript', serializeLuaScript(script)) } } diff --git a/frontends/nextjs/src/lib/db/lua-scripts/update-lua-script.test.ts b/frontends/nextjs/src/lib/db/lua-scripts/update-lua-script.test.ts index 5eb89e587..d2cda1d5b 100644 --- a/frontends/nextjs/src/lib/db/lua-scripts/update-lua-script.test.ts +++ b/frontends/nextjs/src/lib/db/lua-scripts/update-lua-script.test.ts @@ -17,6 +17,7 @@ describe('updateLuaScript', () => { it.each([ { id: 'ls1', updates: { name: 'New Name' } }, { id: 'ls2', updates: { code: 'return 2', description: 'Updated' } }, + { id: 'ls3', updates: { isSandboxed: false, allowedGlobals: ['math'], timeoutMs: 9000 } }, ])('should update $id', async ({ id, updates }) => { mockUpdate.mockResolvedValue(undefined) diff --git a/frontends/nextjs/src/lib/db/lua-scripts/update-lua-script.ts b/frontends/nextjs/src/lib/db/lua-scripts/update-lua-script.ts index 547432df6..9799847d2 100644 --- a/frontends/nextjs/src/lib/db/lua-scripts/update-lua-script.ts +++ b/frontends/nextjs/src/lib/db/lua-scripts/update-lua-script.ts @@ -1,17 +1,11 @@ import { getAdapter } from '../dbal-client' import type { LuaScript } from '../../types/level-types' +import { buildLuaScriptUpdate } from './build-lua-script-update' /** * Update a Lua script by ID */ export async function updateLuaScript(scriptId: string, updates: Partial): Promise { const adapter = getAdapter() - const data: Record = {} - if (updates.name !== undefined) data.name = updates.name - if (updates.description !== undefined) data.description = updates.description - if (updates.code !== undefined) data.code = updates.code - if (updates.parameters !== undefined) data.parameters = JSON.stringify(updates.parameters) - if (updates.returnType !== undefined) data.returnType = updates.returnType - - await adapter.update('LuaScript', scriptId, data) + await adapter.update('LuaScript', scriptId, buildLuaScriptUpdate(updates)) }