docs: nextjs,frontends,lua (15 files)

This commit is contained in:
2025-12-26 01:19:24 +00:00
parent 82decb56e7
commit cc3b62717e
15 changed files with 539 additions and 291 deletions

View File

@@ -1,4 +1,6 @@
#include "dbal/client.hpp"
#include "entities/lua_script/index.hpp"
#include "store/in_memory_store.hpp"
#include <stdexcept>
#include <map>
#include <algorithm>
@@ -7,32 +9,6 @@
namespace dbal {
// In-memory store for mock implementation
struct InMemoryStore {
std::map<std::string, User> users;
std::map<std::string, PageView> pages;
std::map<std::string, std::string> page_slugs; // slug -> id mapping
std::map<std::string, Workflow> workflows;
std::map<std::string, std::string> workflow_names; // name -> id mapping
std::map<std::string, Session> sessions;
std::map<std::string, std::string> session_tokens; // token -> id mapping
std::map<std::string, LuaScript> lua_scripts;
std::map<std::string, std::string> lua_script_names; // name -> id mapping
std::map<std::string, Package> packages;
std::map<std::string, std::string> 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<User> 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<PageView> 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<Workflow> 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<Session> 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<std::vector<Session>> Client::listSessions(const ListOptions& options) {
}
Result<LuaScript> 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<LuaScript>(script);
return entities::lua_script::create(getStore(), input);
}
Result<LuaScript> 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<LuaScript>(it->second);
return entities::lua_script::get(getStore(), id);
}
Result<LuaScript> 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<LuaScript>(script);
return entities::lua_script::update(getStore(), id, input);
}
Result<bool> 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<bool>(true);
return entities::lua_script::remove(getStore(), id);
}
Result<std::vector<LuaScript>> Client::listLuaScripts(const ListOptions& options) {
auto& store = getStore();
std::vector<LuaScript> 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<int>(scripts.size()));
if (start < static_cast<int>(scripts.size())) {
return Result<std::vector<LuaScript>>(std::vector<LuaScript>(scripts.begin() + start, scripts.begin() + end));
}
return Result<std::vector<LuaScript>>(std::vector<LuaScript>());
return entities::lua_script::list(getStore(), options);
}
Result<Package> Client::createPackage(const CreatePackageInput& input) {
@@ -1065,7 +855,7 @@ Result<Package> 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;

View File

@@ -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<void> {
// 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<boolean> {
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<void> {
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<number> {
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<void> {
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<string, number>()
async reserveQuota(key: string, amount: number, limit: number): Promise<boolean> {
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
})
}
}
```
---

View File

@@ -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<Result<LuaScript>> {
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<LuaScript, 'id' | 'createdAt' | 'updatedAt'>
): Promise<LuaScript> {
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<LuaScript>
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict(`Lua script with name '${data.name}' already exists`)
}
throw error
}
}

View File

@@ -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 (
<Container maxWidth="lg" sx={{ py: 8 }}>
<Stack spacing={4}>
<Stack spacing={1}>
<Typography variant="h3" component="h1">
The Five Permission Levels
</Typography>
<Typography color="text.secondary">
Level up through Guest, Regular User, Moderator, God, and Super God to unlock the right
controls for your role.
</Typography>
</Stack>
<Grid container spacing={3}>
{PERMISSION_LEVELS.map((level) => (
<Grid item xs={12} md={6} lg={4} key={level.id}>
<Paper
onClick={() => 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}
>
<Box sx={{ position: 'absolute', top: 16, right: 16 }}>
<Chip label={level.badge} />
</Box>
<Typography variant="h6">Level {level.id} · {level.title}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{level.tagline}
</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
{level.description}
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
{level.capabilities.slice(0, 3).map((capability) => (
<Chip key={capability} label={capability} size="small" variant="outlined" />
))}
</Stack>
</Paper>
</Grid>
))}
</Grid>
<Paper sx={{ p: 4, border: (theme) => `1px dashed ${theme.palette.divider}`, bgcolor: 'background.paper' }}>
<Stack spacing={2}>
<Stack direction="row" alignItems="center" spacing={1}>
<Typography variant="h5">Selected level details</Typography>
<Chip label={selectedLevel.badge} size="small" color="secondary" />
</Stack>
<Typography variant="body1" color="text.secondary">
{selectedLevel.description}
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
{selectedLevel.capabilities.map((capability) => (
<Chip
key={capability}
label={capability}
size="small"
sx={{ borderColor: highlightColor(selectedLevel) }}
/>
))}
</Stack>
<Divider />
<Box>
<Typography variant="subtitle2" gutterBottom>
Next move
</Typography>
{nextLevel ? (
<Typography variant="body2" color="text.secondary">
Promote into <strong>{nextLevel.title}</strong> to unlock {nextLevel.capabilities.length} controls.
</Typography>
) : (
<Typography variant="body2" color="text.secondary">
Super God reigns supreme. You already own every privilege.
</Typography>
)}
</Box>
<Box>
<Button variant="contained" onClick={handlePromote}>
{nextLevel ? `Promote to ${nextLevel.title}` : 'Hold the crown'}
</Button>
</Box>
{note && <Alert severity="info">{note}</Alert>}
</Stack>
</Paper>
</Stack>
</Container>
)
}

View File

@@ -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'],
},
]

View File

@@ -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 <LevelsClient />
}

View File

@@ -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<string, unknown>
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)
}
})
})

View File

@@ -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<void> {
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))
}

View File

@@ -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<LuaScript>): Record<string, unknown> {
const data: Record<string, unknown> = {}
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
}

View File

@@ -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))
}
})
})

View File

@@ -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<LuaScript[]> {
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<string, unknown>[]).map(deserializeLuaScript)
}

View File

@@ -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)

View File

@@ -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<void> {
// 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))
}
}

View File

@@ -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)

View File

@@ -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<LuaScript>): Promise<void> {
const adapter = getAdapter()
const data: Record<string, unknown> = {}
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))
}