docs: add package-sources documentation and update copilot-instructions for 6-level permissions

This commit is contained in:
2025-12-30 00:36:30 +00:00
parent e59841bbff
commit 021762bd50
10 changed files with 396 additions and 28 deletions

View File

@@ -4,10 +4,11 @@
MetaBuilder is a **data-driven, multi-tenant platform** with 95% functionality in JSON/Lua, not TypeScript. The system combines:
- **5-Level Permission System**: Public → User → Admin → God → Supergod access hierarchies
- **6-Level Permission System**: Public → User → Moderator → Admin → God → Supergod access hierarchies
- **DBAL (Database Abstraction Layer)**: TypeScript SDK + C++ daemon, language-agnostic via YAML contracts
- **Declarative Components**: Render complex UIs from JSON configuration using `RenderComponent`
- **Package System**: Self-contained modules in `/packages/{name}/seed/` with metadata, components, scripts
- **Multi-Source Package Repos**: Support for local and remote package registries via `PackageSourceManager`
- **Multi-Tenancy**: All data queries filter by `tenantId`; each tenant has isolated configurations
## 0-kickstart Operating Rules
@@ -56,7 +57,7 @@ Each package auto-loads on init:
```
packages/{name}/
├── seed/
│ ├── metadata.json # Package info, exports, dependencies
│ ├── metadata.json # Package info, exports, dependencies, minLevel
│ ├── components.json # Component definitions
│ ├── scripts/ # Lua scripts organized by function
│ └── index.ts # Exports packageSeed object
@@ -65,6 +66,22 @@ packages/{name}/
```
Loaded by `initializePackageSystem()``buildPackageRegistry()``exportAllPackagesForSeed()`
### 3a. Multi-Source Package Repositories
Packages can come from multiple sources:
```typescript
import { createPackageSourceManager, LocalPackageSource, RemotePackageSource } from '@/lib/packages/package-glue'
const manager = createPackageSourceManager({
enableRemote: true,
remoteUrl: 'https://registry.metabuilder.dev/api/v1',
conflictResolution: 'priority' // or 'latest-version', 'local-first', 'remote-first'
})
const packages = await manager.fetchMergedIndex()
const pkg = await manager.loadPackage('dashboard')
```
See: `docs/packages/package-sources.md`, `package-glue/sources/`
### 4. Database Helpers Pattern
Always use `Database` class methods, never raw Prisma:
```typescript

View File

@@ -0,0 +1,180 @@
# Package Source System
The MetaBuilder package system supports loading packages from multiple sources:
- **Local packages**: Packages bundled with the application in `/packages/`
- **Remote packages**: Packages from a remote registry API
- **Git packages**: Packages from Git repositories (future support)
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ PackageSourceManager │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ LocalSource │ │ RemoteSource │ │ GitSource │ │
│ │ (priority 0)│ │ (priority 10│ │ (priority 20)│ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └────────────┬────┴────────┬────────┘ │
│ ▼ ▼ │
│ ┌─────────────────────────┐ │
│ │ Merged Package Index │ │
│ │ (conflict resolution) │ │
│ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## Configuration
### Environment Variables
- `NEXT_PUBLIC_ENABLE_REMOTE_PACKAGES=true` - Enable remote package sources
- `PACKAGE_REGISTRY_AUTH_TOKEN=xxx` - Authentication token for remote registry
### Programmatic Configuration
```typescript
import {
createPackageSourceManager,
LocalPackageSource,
RemotePackageSource
} from '@/lib/packages/package-glue'
// Simple setup with local only
const manager = createPackageSourceManager()
// With remote source
const managerWithRemote = createPackageSourceManager({
enableRemote: true,
remoteUrl: 'https://registry.metabuilder.dev/api/v1',
remoteAuthToken: 'your-token',
conflictResolution: 'priority'
})
// Custom configuration
const customManager = new PackageSourceManager({
conflictResolution: 'latest-version'
})
customManager.addSource(new LocalPackageSource())
customManager.addSource(new RemotePackageSource({
id: 'custom-registry',
name: 'Custom Registry',
type: 'remote',
url: 'https://custom.registry.com/api',
priority: 5,
enabled: true,
authToken: 'token'
}))
```
## Conflict Resolution
When the same package exists in multiple sources, the system resolves conflicts using one of these strategies:
| Strategy | Description |
|----------|-------------|
| `priority` | Lower priority number wins (default) |
| `latest-version` | Highest semver version wins |
| `local-first` | Always prefer local packages |
| `remote-first` | Always prefer remote packages |
## Package Source Interface
All package sources implement:
```typescript
interface PackageSource {
getConfig(): PackageSourceConfig
fetchIndex(): Promise<PackageIndexEntry[]>
loadPackage(packageId: string): Promise<PackageData | null>
hasPackage(packageId: string): Promise<boolean>
getVersions(packageId: string): Promise<string[]>
}
```
## API Endpoints
### GET /api/packages/index
Returns the local package index:
```json
{
"packages": [
{
"packageId": "dashboard",
"name": "Dashboard",
"version": "1.0.0",
"minLevel": 2,
"dependencies": ["ui_core"]
}
]
}
```
### Remote Registry API (Expected Format)
The remote registry should implement:
```
GET /api/v1/packages - List all packages
GET /api/v1/packages/:id - Get package details
GET /api/v1/packages/:id/versions - List versions
GET /api/v1/packages/search?q=query - Search packages
```
## Example Usage
```typescript
// Get merged package list
const packages = await manager.fetchMergedIndex()
// Load a specific package
const dashboard = await manager.loadPackage('dashboard')
// Check package availability
const exists = await manager.hasPackage('some-package')
// Get all versions across sources
const versions = await manager.getAllVersions('dashboard')
// Map { 'local' => ['1.0.0'], 'remote' => ['1.0.0', '1.1.0', '2.0.0'] }
// Load from specific source
const remotePkg = await manager.loadPackageFromSource('dashboard', 'remote')
```
## Permission Integration
Packages specify a `minLevel` that integrates with the 6-level permission system:
| Level | Name | Example Packages |
|-------|------|------------------|
| 1 | Public | ui_pages, landing components |
| 2 | User | dashboard, data_table |
| 3 | Moderator | forum_forge moderation tools |
| 4 | Admin | admin_dialog, system settings |
| 5 | God | advanced configuration |
| 6 | Supergod | ui_level6, tenant management |
```typescript
import { getAccessiblePackages } from '@/lib/packages/package-glue'
// Filter packages by user level
const userPackages = getAccessiblePackages(registry, userLevel)
```
## Caching
- Local source: Caches index and packages in memory
- Remote source: 5-minute cache with stale-while-revalidate
- Manager: Caches merged index until `clearAllCaches()` called
## Future Enhancements
1. **Git source**: Clone packages from Git repos
2. **S3 source**: Load from S3 buckets
3. **Package publishing**: Push local packages to registry
4. **Version pinning**: Lock package versions
5. **Dependency resolution**: Auto-resolve compatible versions

View File

@@ -5,6 +5,7 @@ import type { LuaScript } from '@/lib/level-types'
import { executeLuaScriptWithProfile } from '@/lib/lua/execute-lua-script-with-profile'
import type { LuaExecutionResult } from '@/lib/lua-engine'
import { securityScanner, type SecurityScanResult } from '@/lib/security-scanner'
import type { JsonValue } from '@/types/utility-types'
interface UseLuaEditorLogicProps {
scripts: LuaScript[]
@@ -19,7 +20,7 @@ export const useLuaEditorLogic = ({ scripts, onScriptsChange }: UseLuaEditorLogi
scripts.length > 0 ? scripts[0].id : null
)
const [testOutput, setTestOutput] = useState<LuaExecutionResult | null>(null)
const [testInputs, setTestInputs] = useState<Record<string, any>>({})
const [testInputs, setTestInputs] = useState<Record<string, JsonValue>>({})
const [isExecuting, setIsExecuting] = useState(false)
const [isFullscreen, setIsFullscreen] = useState(false)
const [showSnippetLibrary, setShowSnippetLibrary] = useState(false)
@@ -37,7 +38,7 @@ export const useLuaEditorLogic = ({ scripts, onScriptsChange }: UseLuaEditorLogi
useEffect(() => {
if (!currentScript) return
const inputs: Record<string, any> = {}
const inputs: Record<string, JsonValue> = {}
currentScript.parameters.forEach(param => {
inputs[param.name] = param.type === 'number' ? 0 : param.type === 'boolean' ? false : ''
})
@@ -86,7 +87,7 @@ export const useLuaEditorLogic = ({ scripts, onScriptsChange }: UseLuaEditorLogi
handleUpdateScript({
parameters: currentScript.parameters.map((p, i) => (i === index ? { ...p, ...updates } : p)),
})
const handleTestInputChange = (paramName: string, value: any) =>
const handleTestInputChange = (paramName: string, value: JsonValue) =>
setTestInputs({ ...testInputs, [paramName]: value })
const executeScript = async () => {
@@ -94,7 +95,7 @@ export const useLuaEditorLogic = ({ scripts, onScriptsChange }: UseLuaEditorLogi
setIsExecuting(true)
setTestOutput(null)
try {
const contextData: any = {}
const contextData: Record<string, JsonValue> = {}
currentScript.parameters.forEach(param => {
contextData[param.name] = testInputs[param.name]
})
@@ -103,7 +104,7 @@ export const useLuaEditorLogic = ({ scripts, onScriptsChange }: UseLuaEditorLogi
{
data: contextData,
user: { username: 'test_user', role: 'god' },
log: (...args: any[]) => console.log('[Lua]', ...args),
log: (...args: JsonValue[]) => console.log('[Lua]', ...args),
},
currentScript
)

View File

@@ -8,16 +8,19 @@ import { Label } from '@/components/ui'
import type { FieldSchema, SchemaConfig } from '@/lib/schema-types'
import { findModel, getFieldLabel, getHelpText, getModelLabel } from '@/lib/schema-utils'
import { getRecordsKey } from '@/lib/schema-utils'
import type { JsonValue } from '@/types/utility-types'
interface FieldRendererProps {
field: FieldSchema
value: any
onChange: (value: any) => void
value: JsonValue
onChange: (value: JsonValue) => void
error?: string
schema: SchemaConfig
currentApp: string
}
type RelatedRecord = Record<string, JsonValue> & { id: string }
export function FieldRenderer({
field,
value,
@@ -32,7 +35,7 @@ export function FieldRenderer({
const relatedRecordsKey = field.relatedModel
? getRecordsKey(currentApp, field.relatedModel)
: 'dummy'
const [relatedModelRecords] = useKV<any[]>(relatedRecordsKey, [])
const [relatedModelRecords] = useKV<RelatedRecord[]>(relatedRecordsKey, [])
const relatedModel =
field.type === 'relation' && field.relatedModel
@@ -158,7 +161,7 @@ export function FieldRenderer({
<SelectValue placeholder={`Select ${label}`} />
</SelectTrigger>
<SelectContent>
{relatedModelRecords.map((record: any) => (
{relatedModelRecords.map(record => (
<SelectItem key={record.id} value={record.id}>
{record[displayField] || record.id}
</SelectItem>

View File

@@ -5,13 +5,14 @@ import { CssClassBuilder } from '@/components/CssClassBuilder'
import { Button, Separator } from '@/components/ui'
import { componentCatalog } from '@/lib/component-catalog'
import { Database, DropdownConfig } from '@/lib/database'
import type { ComponentInstance } from '@/lib/types/builder-types'
import type { ComponentInstance, ComponentProps } from '@/lib/types/builder-types'
import type { JsonValue } from '@/types/utility-types'
import { PropertyPanels } from './components/PropertyPanels'
interface PropertyInspectorProps {
component: ComponentInstance | null
onUpdate: (id: string, props: any) => void
onUpdate: (id: string, props: ComponentProps) => void
onDelete: (id: string) => void
onCodeEdit: () => void
}
@@ -45,7 +46,7 @@ export function PropertyInspector({
const componentDef = componentCatalog.find(c => c.type === component.type)
const handlePropChange = (propName: string, value: any) => {
const handlePropChange = (propName: string, value: JsonValue) => {
onUpdate(component.id, {
...component.props,
[propName]: value,
@@ -90,7 +91,11 @@ export function PropertyInspector({
<CssClassBuilder
open={cssBuilderOpen}
onClose={() => setCssBuilderOpen(false)}
initialValue={component.props[cssBuilderPropName] || ''}
initialValue={
typeof component.props[cssBuilderPropName] === 'string'
? component.props[cssBuilderPropName]
: ''
}
onSave={handleCssClassSave}
/>
</div>

View File

@@ -12,11 +12,12 @@ import {
} from '@/components/ui'
import type { DropdownConfig } from '@/lib/database'
import type { PropDefinition } from '@/lib/types/builder-types'
import type { JsonValue } from '@/types/utility-types'
interface FieldTypesProps {
propDef: PropDefinition
value: any
onChange: (value: any) => void
value: JsonValue
onChange: (value: JsonValue) => void
dynamicDropdown?: DropdownConfig | null
onOpenCssBuilder?: () => void
}
@@ -69,9 +70,15 @@ export function FieldTypes({
</SelectContent>
</Select>
)
case 'select':
case 'select': {
const selectValue =
typeof value === 'string'
? value
: typeof propDef.defaultValue === 'string'
? propDef.defaultValue
: ''
return (
<Select value={value || propDef.defaultValue} onValueChange={val => onChange(val)}>
<Select value={selectValue} onValueChange={val => onChange(val)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -84,6 +91,7 @@ export function FieldTypes({
</SelectContent>
</Select>
)
}
case 'dynamic-select':
return (
<Select value={value || ''} onValueChange={val => onChange(val)}>

View File

@@ -3,6 +3,7 @@ import { Code, PaintBrush } from '@phosphor-icons/react'
import { Button, ScrollArea, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import type { DropdownConfig } from '@/lib/database'
import type { ComponentDefinition, ComponentInstance } from '@/lib/types/builder-types'
import type { JsonValue } from '@/types/utility-types'
import { FieldTypes } from './FieldTypes'
@@ -10,7 +11,7 @@ interface PropertyPanelsProps {
component: ComponentInstance
componentDef?: ComponentDefinition
dynamicDropdowns: DropdownConfig[]
onPropChange: (propName: string, value: any) => void
onPropChange: (propName: string, value: JsonValue) => void
onCodeEdit: () => void
onOpenCssBuilder: (propName: string) => void
}

View File

@@ -9,15 +9,15 @@ import { TextField } from './functions/text-field'
* This is a convenience wrapper. Prefer importing individual functions.
*/
export class TabsUtils {
static SchemaTabs(...args: any[]) {
return SchemaTabs(...(args as any))
static SchemaTabs(...args: Parameters<typeof SchemaTabs>) {
return SchemaTabs(...args)
}
static FieldCard(...args: any[]) {
return FieldCard(...(args as any))
static FieldCard(...args: Parameters<typeof FieldCard>) {
return FieldCard(...args)
}
static TextField(...args: any[]) {
return TextField(...(args as any))
static TextField(...args: Parameters<typeof TextField>) {
return TextField(...args)
}
}

View File

@@ -3,6 +3,7 @@ import { toast } from 'sonner'
import type { LuaScript, Workflow, WorkflowNode } from '@/lib/level-types'
import type { WorkflowExecutionResult } from '@/lib/workflow/engine/workflow-engine'
import { createWorkflowEngine } from '@/lib/workflow/engine/workflow-engine'
import type { JsonValue } from '@/types/utility-types'
import type { WorkflowActionHandlers, WorkflowStateSetters } from './types'
@@ -89,9 +90,9 @@ export const createActionHandlers = ({
setTestOutput(null)
try {
let parsedData: any
let parsedData: JsonValue
try {
parsedData = JSON.parse(testData)
parsedData = JSON.parse(testData) as JsonValue
} catch {
parsedData = testData
}

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, vi } from 'vitest'
import {
DEFAULT_PACKAGE_REPO_CONFIG,
DEVELOPMENT_PACKAGE_REPO_CONFIG,
PRODUCTION_PACKAGE_REPO_CONFIG,
getPackageRepoConfig,
validatePackageRepoConfig,
} from '../package-repo-config'
import type { PackageRepoConfig } from '../package-repo-config'
describe('package-repo-config', () => {
describe('DEFAULT_PACKAGE_REPO_CONFIG', () => {
it('should have priority conflict resolution', () => {
expect(DEFAULT_PACKAGE_REPO_CONFIG.conflictResolution).toBe('priority')
})
it('should have one local source', () => {
expect(DEFAULT_PACKAGE_REPO_CONFIG.sources).toHaveLength(1)
expect(DEFAULT_PACKAGE_REPO_CONFIG.sources[0].type).toBe('local')
})
it('should have local source enabled', () => {
expect(DEFAULT_PACKAGE_REPO_CONFIG.sources[0].enabled).toBe(true)
})
})
describe('DEVELOPMENT_PACKAGE_REPO_CONFIG', () => {
it('should use local-first conflict resolution', () => {
expect(DEVELOPMENT_PACKAGE_REPO_CONFIG.conflictResolution).toBe('local-first')
})
it('should have two sources', () => {
expect(DEVELOPMENT_PACKAGE_REPO_CONFIG.sources).toHaveLength(2)
})
it('should have local source enabled', () => {
const local = DEVELOPMENT_PACKAGE_REPO_CONFIG.sources.find((s) => s.id === 'local')
expect(local?.enabled).toBe(true)
})
it('should have staging source disabled by default', () => {
const staging = DEVELOPMENT_PACKAGE_REPO_CONFIG.sources.find((s) => s.id === 'staging')
expect(staging?.enabled).toBe(false)
})
})
describe('PRODUCTION_PACKAGE_REPO_CONFIG', () => {
it('should use latest-version conflict resolution', () => {
expect(PRODUCTION_PACKAGE_REPO_CONFIG.conflictResolution).toBe('latest-version')
})
it('should have local with higher priority than remote', () => {
const local = PRODUCTION_PACKAGE_REPO_CONFIG.sources.find((s) => s.id === 'local')
const production = PRODUCTION_PACKAGE_REPO_CONFIG.sources.find((s) => s.id === 'production')
expect(local?.priority).toBeGreaterThan(production?.priority ?? 0)
})
})
describe('validatePackageRepoConfig', () => {
it.each([
{
name: 'valid config',
config: DEFAULT_PACKAGE_REPO_CONFIG,
expectedErrors: 0,
},
{
name: 'empty sources',
config: { conflictResolution: 'priority', sources: [] } as PackageRepoConfig,
expectedErrors: 2, // No sources + no enabled sources
},
{
name: 'duplicate IDs',
config: {
conflictResolution: 'priority',
sources: [
{ id: 'dup', name: 'Dup 1', type: 'local', url: '/a', priority: 0, enabled: true },
{ id: 'dup', name: 'Dup 2', type: 'local', url: '/b', priority: 1, enabled: true },
],
} as PackageRepoConfig,
expectedErrors: 1,
},
{
name: 'remote with non-http URL',
config: {
conflictResolution: 'priority',
sources: [
{ id: 'bad', name: 'Bad', type: 'remote', url: '/local/path', priority: 0, enabled: true },
],
} as PackageRepoConfig,
expectedErrors: 1,
},
{
name: 'all sources disabled',
config: {
conflictResolution: 'priority',
sources: [
{ id: 'off1', name: 'Off 1', type: 'local', url: '/a', priority: 0, enabled: false },
{ id: 'off2', name: 'Off 2', type: 'remote', url: 'https://x', priority: 1, enabled: false },
],
} as PackageRepoConfig,
expectedErrors: 1,
},
])('should validate $name', ({ config, expectedErrors }) => {
const errors = validatePackageRepoConfig(config)
expect(errors).toHaveLength(expectedErrors)
})
it('should catch missing source ID', () => {
const config: PackageRepoConfig = {
conflictResolution: 'priority',
sources: [
{ id: '', name: 'No ID', type: 'local', url: '/test', priority: 0, enabled: true },
],
}
const errors = validatePackageRepoConfig(config)
expect(errors.some((e) => e.includes('ID'))).toBe(true)
})
it('should catch missing URL', () => {
const config: PackageRepoConfig = {
conflictResolution: 'priority',
sources: [
{ id: 'no-url', name: 'No URL', type: 'local', url: '', priority: 0, enabled: true },
],
}
const errors = validatePackageRepoConfig(config)
expect(errors.some((e) => e.includes('URL'))).toBe(true)
})
})
describe('getPackageRepoConfig', () => {
// Skip environment-dependent tests in this context
// These would need proper mocking setup for process.env
it('should return a valid config', () => {
const config = getPackageRepoConfig()
const errors = validatePackageRepoConfig(config)
expect(errors).toHaveLength(0)
})
it('should have at least one source', () => {
const config = getPackageRepoConfig()
expect(config.sources.length).toBeGreaterThan(0)
})
it('should have at least one enabled source', () => {
const config = getPackageRepoConfig()
const enabledSources = config.sources.filter((s) => s.enabled)
expect(enabledSources.length).toBeGreaterThan(0)
})
})
})