mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
docs: nextjs,frontends,lua (15 files)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
148
frontends/nextjs/src/app/levels/LevelsClient.tsx
Normal file
148
frontends/nextjs/src/app/levels/LevelsClient.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
frontends/nextjs/src/app/levels/levels-data.ts
Normal file
57
frontends/nextjs/src/app/levels/levels-data.ts
Normal 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'],
|
||||
},
|
||||
]
|
||||
12
frontends/nextjs/src/app/levels/page.tsx
Normal file
12
frontends/nextjs/src/app/levels/page.tsx
Normal 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 />
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user