# Phase 6A: UBA Foundation ## Schema Parser, Basic Fields, and Core Infrastructure **Phase:** 6A of 6 **Duration:** 3 weeks (15 working days) **Priority:** CRITICAL (blocks all other UBA phases) **Status:** NOT STARTED --- ## Overview Phase 6A establishes the core infrastructure for the Universal Backend Adapter system. This includes the schema specification, YAML parser, basic field type renderers, configuration storage, and backend communication protocol. By the end of this phase, a developer should be able to: 1. Write a minimal UBA schema 2. Add their backend to Nodegx 3. See a configuration panel with basic fields 4. Save configuration 5. Have that configuration sent to their backend ### Success Vision ``` Developer creates: User sees in Nodegx: ┌─────────────────────────┐ ┌─────────────────────────┐ │ schema_version: "1.0" │ │ My Backend [Save]│ │ backend: │ ───► ├─────────────────────────┤ │ name: "My Backend" │ │ API Key * │ │ sections: │ │ [•••••••••••••••] │ │ - id: "auth" │ │ │ │ fields: │ │ Environment │ │ - id: "api_key" │ │ [Production ▼] │ │ type: "secret" │ │ │ │ - id: "env" │ │ Enable Debug │ │ type: "select" │ │ [✓] │ └─────────────────────────┘ └─────────────────────────┘ ``` --- ## Goals 1. **Define schema specification v1.0** - Complete, documented, versioned 2. **Build schema parser** - YAML parsing with validation 3. **Implement basic field renderers** - 8 core field types 4. **Create config panel shell** - Generic panel that renders any schema 5. **Implement config storage** - Save/load with encryption for secrets 6. **Build backend discovery** - Fetch schemas from URLs 7. **Implement config push** - Send configuration to backends --- ## Prerequisites - React 19 migration complete (Phase 1) ✅ - Backend Services panel exists (BYOB) ✅ - Core UI components available ✅ --- ## Task Breakdown ### UBA-001: Schema Specification v1.0 **Effort:** 3 days **Assignee:** TBD **Branch:** `feature/uba-001-schema-spec` #### Description Define and document the complete YAML schema specification. This is the contract between backends and Nodegx - it must be thorough, unambiguous, and versioned. #### Deliverables 1. **Schema Specification Document** - Complete YAML structure definition - All field types (basic + complex + special) - Validation rules - Conditional visibility syntax - Debug schema format 2. **JSON Schema for Validation** - Machine-readable schema definition - Used by parser for validation - Enables IDE autocomplete for schema authors 3. **Version Compatibility Rules** - How schema versions are compared - Backward compatibility guarantees - Deprecation process #### Files to Create ``` docs/ └── uba/ ├── schema-specification-v1.md # Human-readable spec ├── schema-changelog.md # Version history └── migration-guide.md # For schema authors packages/noodl-editor/src/editor/src/models/UBA/ └── schema-v1.json # JSON Schema ``` #### Specification Outline ```yaml # Schema Structure (to be fully specified) schema_version: "1.0" # Required, semver backend: # Required id: string # Unique identifier name: string # Display name description?: string # Optional description version: string # Backend version (semver) icon?: string # Icon name or URL homepage?: string # Documentation URL endpoints: # Required config: string # Config endpoint path health?: string # Health check path debug_stream?: string # Debug WebSocket/SSE path auth?: # Optional type: "none" | "bearer" | "api_key" | "basic" header?: string # Header name capabilities?: # Optional feature flags hot_reload?: boolean debug?: boolean batch_config?: boolean sections: # Required, array - id: string # Section identifier name: string # Display name description?: string icon?: string collapsed?: boolean # Initial state visible_when?: # Conditional visibility fields: Field[] # Array of fields debug?: # Optional debug schema enabled: boolean event_schema: DebugField[] ``` #### Acceptance Criteria - [ ] All field types documented with examples - [ ] Validation rules clearly specified - [ ] JSON Schema validates example schemas correctly - [ ] At least 3 example schemas pass validation - [ ] Documentation reviewed by team #### Testing ```bash # Validate example schemas against JSON Schema npm run test:uba-schema ``` --- ### UBA-002: Schema Parser **Effort:** 4 days **Assignee:** TBD **Branch:** `feature/uba-002-schema-parser` **Depends On:** UBA-001 #### Description Build a robust YAML parser that reads schema files, validates them against the JSON Schema, and produces typed TypeScript objects for use by the UI. #### Deliverables 1. **YAML Parser** - Parse YAML to JavaScript objects - Handle syntax errors gracefully - Support both string and file input 2. **Schema Validator** - Validate against JSON Schema - Detailed error messages with line numbers - Warning vs error distinction 3. **Type Definitions** - Complete TypeScript types for schema - Discriminated unions for field types - Strict typing throughout 4. **Version Handler** - Detect schema version - Apply compatibility transforms - Warn about deprecated features #### Files to Create ``` packages/noodl-editor/src/editor/src/models/UBA/ ├── types.ts # TypeScript type definitions ├── SchemaParser.ts # Main parser class ├── SchemaValidator.ts # Validation logic ├── SchemaVersionHandler.ts # Version compatibility └── errors.ts # Custom error classes ``` #### Implementation ```typescript // types.ts export interface UBASchema { schema_version: string; backend: BackendMetadata; sections: Section[]; debug?: DebugSchema; } export interface BackendMetadata { id: string; name: string; description?: string; version: string; icon?: string; homepage?: string; endpoints: Endpoints; auth?: AuthConfig; capabilities?: Capabilities; } export interface Section { id: string; name: string; description?: string; icon?: string; collapsed?: boolean; visible_when?: Condition; fields: Field[]; } export type Field = | StringField | TextField | NumberField | BooleanField | SecretField | UrlField | SelectField | MultiSelectField // ... more field types export interface StringField { type: 'string'; id: string; name: string; description?: string; placeholder?: string; default?: string; required?: boolean; validation?: StringValidation; visible_when?: Condition; depends_on?: Dependency; ui?: UIHints; } // SchemaParser.ts import yaml from 'js-yaml'; import Ajv from 'ajv'; import schemaV1 from './schema-v1.json'; export class SchemaParser { private validator: Ajv; constructor() { this.validator = new Ajv({ allErrors: true }); this.validator.addSchema(schemaV1, 'uba-v1'); } /** * Parse YAML string into validated schema object */ parse(yamlString: string): ParseResult { try { // Parse YAML const parsed = yaml.load(yamlString); // Validate against JSON Schema const valid = this.validator.validate('uba-v1', parsed); if (!valid) { return { success: false, errors: this.formatErrors(this.validator.errors) }; } // Apply version transforms if needed const schema = this.applyVersionTransforms(parsed as UBASchema); return { success: true, data: schema, warnings: this.collectWarnings(schema) }; } catch (error) { if (error instanceof yaml.YAMLException) { return { success: false, errors: [{ path: '', message: error.message, line: error.mark?.line, column: error.mark?.column }] }; } throw error; } } /** * Format AJV errors into human-readable messages */ private formatErrors(errors: Ajv.ErrorObject[]): SchemaError[] { return errors.map(err => ({ path: err.instancePath, message: this.humanizeError(err), keyword: err.keyword, params: err.params })); } private humanizeError(error: Ajv.ErrorObject): string { switch (error.keyword) { case 'required': return `Missing required field: ${error.params.missingProperty}`; case 'enum': return `Invalid value. Expected one of: ${error.params.allowedValues.join(', ')}`; case 'type': return `Expected ${error.params.type}, got ${typeof error.data}`; default: return error.message || 'Validation error'; } } } ``` #### Acceptance Criteria - [ ] Parses valid YAML schemas correctly - [ ] Returns detailed errors for invalid schemas - [ ] Handles YAML syntax errors gracefully - [ ] TypeScript types match schema spec exactly - [ ] Version detection works correctly - [ ] 100% test coverage for parser #### Test Cases ```typescript // SchemaParser.test.ts describe('SchemaParser', () => { describe('valid schemas', () => { it('parses minimal schema', () => { const schema = ` schema_version: "1.0" backend: id: test name: Test Backend version: "1.0.0" endpoints: config: /config sections: [] `; const result = parser.parse(schema); expect(result.success).toBe(true); expect(result.data.backend.name).toBe('Test Backend'); }); it('parses schema with all field types', () => { /* ... */ }); it('parses schema with nested objects', () => { /* ... */ }); it('parses schema with conditions', () => { /* ... */ }); }); describe('invalid schemas', () => { it('reports missing required fields', () => { const schema = ` schema_version: "1.0" backend: name: Test `; const result = parser.parse(schema); expect(result.success).toBe(false); expect(result.errors[0].message).toContain('id'); }); it('reports invalid field type', () => { /* ... */ }); it('reports YAML syntax errors with line numbers', () => { /* ... */ }); }); describe('version handling', () => { it('detects schema version', () => { /* ... */ }); it('warns about deprecated features', () => { /* ... */ }); }); }); ``` --- ### UBA-003: Basic Field Renderers **Effort:** 5 days **Assignee:** TBD **Branch:** `feature/uba-003-basic-fields` **Depends On:** UBA-002 #### Description Implement React components that render basic field types. Each renderer receives field configuration and produces appropriate UI with validation. #### Field Types to Implement | Type | Component | Notes | |------|-----------|-------| | `string` | `StringField` | Single-line text input | | `text` | `TextField` | Multi-line textarea | | `number` | `NumberField` | Number input with min/max/step | | `boolean` | `BooleanField` | Toggle switch | | `secret` | `SecretField` | Masked input, show/hide toggle | | `url` | `UrlField` | URL input with validation | | `select` | `SelectField` | Dropdown single-select | | `multi_select` | `MultiSelectField` | Multi-select with tags | #### Files to Create ``` packages/noodl-editor/src/editor/src/views/UBA/ ├── fields/ │ ├── index.ts # Barrel export │ ├── FieldRenderer.tsx # Factory component │ ├── FieldWrapper.tsx # Common wrapper (label, description, error) │ ├── StringField.tsx │ ├── TextField.tsx │ ├── NumberField.tsx │ ├── BooleanField.tsx │ ├── SecretField.tsx │ ├── UrlField.tsx │ ├── SelectField.tsx │ └── MultiSelectField.tsx ├── fields.module.scss # Shared field styles └── hooks/ └── useFieldValidation.ts # Validation hook ``` #### Implementation Pattern ```typescript // FieldWrapper.tsx interface FieldWrapperProps { field: BaseField; error?: string; warning?: string; children: React.ReactNode; } export function FieldWrapper({ field, error, warning, children }: FieldWrapperProps) { return (
{field.description && (

{field.description}

)} {children} {error && (

{error}

)} {warning && (

{warning}

)} {field.ui?.help_link && ( Learn more ↗ )}
); } // StringField.tsx interface StringFieldProps { field: StringField; value: string; onChange: (value: string) => void; onBlur?: () => void; error?: string; disabled?: boolean; } export function StringField({ field, value, onChange, onBlur, error, disabled }: StringFieldProps) { return ( onChange(e.target.value)} onBlur={onBlur} placeholder={field.placeholder} disabled={disabled} className={css['text-input']} maxLength={field.validation?.max_length} /> ); } // SecretField.tsx export function SecretField({ field, value, onChange, error, disabled }: SecretFieldProps) { const [visible, setVisible] = useState(false); return (
onChange(e.target.value)} placeholder={field.placeholder ?? '••••••••••••'} disabled={disabled} className={css['text-input']} autoComplete="off" />
); } // FieldRenderer.tsx - Factory component interface FieldRendererProps { field: Field; value: any; onChange: (value: any) => void; error?: string; disabled?: boolean; } export function FieldRenderer({ field, value, onChange, error, disabled }: FieldRendererProps) { switch (field.type) { case 'string': return ; case 'text': return ; case 'number': return ; case 'boolean': return ; case 'secret': return ; case 'url': return ; case 'select': return ; case 'multi_select': return ; default: // Fallback for unknown types return ; } } ``` #### Styling Guidelines ```scss // fields.module.scss .field-wrapper { margin-bottom: var(--spacing-md); } .field-label { display: block; font-weight: 500; margin-bottom: var(--spacing-xs); color: var(--color-text-primary); } .required { color: var(--color-danger); margin-left: 2px; } .field-description { font-size: var(--font-size-sm); color: var(--color-text-secondary); margin-bottom: var(--spacing-xs); } .text-input { width: 100%; padding: var(--spacing-sm); border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-bg-input); color: var(--color-text-primary); &:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 2px var(--color-primary-alpha); } &:disabled { opacity: 0.6; cursor: not-allowed; } &.has-error { border-color: var(--color-danger); } } .field-error { font-size: var(--font-size-sm); color: var(--color-danger); margin-top: var(--spacing-xs); } .field-warning { font-size: var(--font-size-sm); color: var(--color-warning); margin-top: var(--spacing-xs); } ``` #### Acceptance Criteria - [ ] All 8 field types render correctly - [ ] Default values applied - [ ] Placeholder text shown - [ ] Required indicator displayed - [ ] Description text rendered - [ ] Error state styling works - [ ] Disabled state works - [ ] Secret field masks/unmasks - [ ] Select field shows options - [ ] Multi-select allows multiple selections - [ ] Consistent styling across all fields #### Test Cases ```typescript describe('Field Renderers', () => { describe('StringField', () => { it('renders with value', () => { render(); expect(screen.getByDisplayValue('test')).toBeInTheDocument(); }); it('shows placeholder when empty', () => { const field = { ...mockField, placeholder: 'Enter text...' }; render(); expect(screen.getByPlaceholderText('Enter text...')).toBeInTheDocument(); }); it('calls onChange when typed', () => { const onChange = jest.fn(); render(); fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new' } }); expect(onChange).toHaveBeenCalledWith('new'); }); it('shows error message', () => { render(); expect(screen.getByText('Required')).toBeInTheDocument(); }); }); describe('SecretField', () => { it('masks value by default', () => { render(); expect(screen.getByDisplayValue('secret123').getAttribute('type')).toBe('password'); }); it('toggles visibility', () => { render(); fireEvent.click(screen.getByTitle('Show')); expect(screen.getByDisplayValue('secret123').getAttribute('type')).toBe('text'); }); }); // ... more tests for each field type }); ``` --- ### UBA-004: Config Panel Shell **Effort:** 4 days **Assignee:** TBD **Branch:** `feature/uba-004-config-panel` **Depends On:** UBA-003 #### Description Build the main configuration panel component that reads a schema and renders sections with fields. This is the primary UI users interact with. #### Deliverables 1. **ConfigPanel Component** - Tab navigation for sections - Section rendering with fields - Form state management - Save/Reset functionality - Loading states - Error display 2. **Section Components** - Section header with icon - Collapsible sections - Field grid layout 3. **Form State Hook** - Track field values - Track dirty state - Track validation errors - Handle nested objects #### Files to Create ``` packages/noodl-editor/src/editor/src/views/UBA/ ├── ConfigPanel.tsx ├── ConfigPanel.module.scss ├── ConfigSection.tsx ├── ConfigSection.module.scss ├── ConfigHeader.tsx ├── hooks/ │ ├── useConfigForm.ts │ └── useConfigValidation.ts └── context/ └── ConfigContext.tsx ``` #### Implementation ```typescript // ConfigPanel.tsx interface ConfigPanelProps { schema: UBASchema; initialValues?: Record; onSave: (values: Record) => Promise; onReset?: () => void; disabled?: boolean; } export function ConfigPanel({ schema, initialValues, onSave, onReset, disabled }: ConfigPanelProps) { const [activeSection, setActiveSection] = useState(schema.sections[0]?.id); const { values, errors, isDirty, setValue, setFieldError, reset } = useConfigForm(schema, initialValues); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const handleSave = async () => { // Validate all fields const validationErrors = validateConfig(schema, values); if (Object.keys(validationErrors).length > 0) { Object.entries(validationErrors).forEach(([field, error]) => { setFieldError(field, error); }); return; } setSaving(true); setSaveError(null); try { await onSave(values); } catch (error) { setSaveError(error.message); } finally { setSaving(false); } }; const handleReset = () => { reset(); onReset?.(); }; // Filter visible sections const visibleSections = schema.sections.filter(section => evaluateCondition(section.visible_when, values) ); return (
{saveError && (
{saveError}
)}
{visibleSections.map(section => ( ))}
{visibleSections.map(section => ( ))}
); } // useConfigForm.ts export function useConfigForm(schema: UBASchema, initialValues?: Record) { const [values, setValues] = useState>(() => buildInitialValues(schema, initialValues) ); const [errors, setErrors] = useState>({}); const [initialSnapshot] = useState(() => JSON.stringify(values)); const isDirty = useMemo(() => JSON.stringify(values) !== initialSnapshot, [values, initialSnapshot] ); const setValue = useCallback((path: string, value: any) => { setValues(prev => setNestedValue(prev, path, value)); // Clear error when value changes setErrors(prev => { const next = { ...prev }; delete next[path]; return next; }); }, []); const setFieldError = useCallback((path: string, error: string) => { setErrors(prev => ({ ...prev, [path]: error })); }, []); const reset = useCallback(() => { setValues(buildInitialValues(schema, initialValues)); setErrors({}); }, [schema, initialValues]); return { values, errors, isDirty, setValue, setFieldError, reset }; } // Helper: Build initial values from schema defaults function buildInitialValues(schema: UBASchema, provided?: Record): Record { const values: Record = {}; for (const section of schema.sections) { for (const field of section.fields) { const path = `${section.id}.${field.id}`; const providedValue = getNestedValue(provided, path); if (providedValue !== undefined) { setNestedValue(values, path, providedValue); } else if (field.default !== undefined) { setNestedValue(values, path, field.default); } } } return values; } ``` #### UI Mockup ``` ┌─────────────────────────────────────────────────────────┐ │ Erleah AI Agent [Reset] [Save] │ │ agent.example.com • v1.0.0 • ● Connected │ ├─────────────────────────────────────────────────────────┤ │ [Data Sources] [Vector DB] [Tools●] [LLM] [Prompts] │ ├─────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ 🗄️ Data Sources │ │ │ │ Configure where your data comes from │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ Primary Backend * │ │ ┌─────────────────────────────────────────────────┐ │ │ │ Directus (conference.directus.app) ▼ │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ Attendee Collection * │ │ ┌─────────────────────────────────────────────────┐ │ │ │ attendees ▼ │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ ▼ Field Mappings │ │ ┌─────────────────────────────────────────────────┐ │ │ │ Map your Directus fields to agent expectations │ │ │ │ ... (field mapping UI would go here) │ │ │ └─────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────┘ ``` #### Acceptance Criteria - [ ] Section tabs display and switch correctly - [ ] Fields render based on schema - [ ] Form values tracked correctly - [ ] Dirty state detected - [ ] Save button enabled only when dirty - [ ] Reset restores initial values - [ ] Loading state during save - [ ] Error display on save failure - [ ] Nested field paths work (section.field) - [ ] Keyboard navigation works --- ### UBA-005: Config Storage **Effort:** 3 days **Assignee:** TBD **Branch:** `feature/uba-005-config-storage` **Depends On:** UBA-004 #### Description Implement storage for backend configurations within Nodegx projects. Secrets must be encrypted. Configurations should support environment-specific values. #### Deliverables 1. **Config Storage Manager** - Save config to project metadata - Load config from project - Encrypt/decrypt secrets 2. **Secret Manager** - AES encryption for secrets - Key derivation from project key - Never expose secrets in logs/exports 3. **Environment Support** - Development vs Production configs - Environment variable substitution #### Files to Create ``` packages/noodl-editor/src/editor/src/models/UBA/ ├── ConfigStorage.ts ├── SecretManager.ts └── EnvironmentConfig.ts ``` #### Implementation ```typescript // ConfigStorage.ts import { ProjectModel } from '@noodl-models/projectmodel'; import { SecretManager } from './SecretManager'; interface StoredConfig { values: Record; encryptedSecrets: Record; environment: string; lastUpdated: string; } export class ConfigStorage { private secretManager: SecretManager; constructor(private project: ProjectModel) { this.secretManager = new SecretManager(project); } /** * Save configuration for a backend */ async save(backendId: string, config: Record, secretPaths: string[]): Promise { // Separate secrets from regular values const { values, secrets } = this.separateSecrets(config, secretPaths); // Encrypt secrets const encryptedSecrets: Record = {}; for (const [path, value] of Object.entries(secrets)) { encryptedSecrets[path] = await this.secretManager.encrypt(value); } // Store in project metadata const stored: StoredConfig = { values, encryptedSecrets, environment: this.getEnvironment(), lastUpdated: new Date().toISOString() }; this.project.setMetaData(`uba.${backendId}`, stored); await this.project.save(); } /** * Load configuration for a backend */ async load(backendId: string): Promise | null> { const stored = this.project.getMetaData(`uba.${backendId}`) as StoredConfig | undefined; if (!stored) { return null; } // Decrypt secrets const decryptedSecrets: Record = {}; for (const [path, encrypted] of Object.entries(stored.encryptedSecrets)) { try { decryptedSecrets[path] = await this.secretManager.decrypt(encrypted); } catch (error) { console.error(`Failed to decrypt secret at ${path}:`, error); decryptedSecrets[path] = ''; // Empty on failure } } // Merge values with decrypted secrets return this.mergeSecrets(stored.values, decryptedSecrets); } /** * Export config without secrets (for sharing) */ exportWithoutSecrets(backendId: string): Record | null { const stored = this.project.getMetaData(`uba.${backendId}`) as StoredConfig | undefined; if (!stored) { return null; } // Return values only, secrets become empty strings const config = { ...stored.values }; for (const path of Object.keys(stored.encryptedSecrets)) { setNestedValue(config, path, ''); } return config; } private separateSecrets(config: Record, secretPaths: string[]): { values: Record; secrets: Record; } { const values = JSON.parse(JSON.stringify(config)); const secrets: Record = {}; for (const path of secretPaths) { const value = getNestedValue(config, path); if (value) { secrets[path] = value; setNestedValue(values, path, '[ENCRYPTED]'); } } return { values, secrets }; } } // SecretManager.ts import * as crypto from 'crypto'; export class SecretManager { private key: Buffer; constructor(private project: ProjectModel) { // Derive key from project ID this.key = this.deriveKey(project.id); } private deriveKey(projectId: string): Buffer { // Use PBKDF2 to derive a key from project ID return crypto.pbkdf2Sync( projectId, 'nodegx-uba-salt', // Static salt (could be improved) 100000, 32, 'sha256' ); } async encrypt(plaintext: string): Promise { const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv('aes-256-gcm', this.key, iv); let encrypted = cipher.update(plaintext, 'utf8', 'base64'); encrypted += cipher.final('base64'); const authTag = cipher.getAuthTag(); // Format: iv:authTag:ciphertext return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`; } async decrypt(encrypted: string): Promise { const [ivBase64, authTagBase64, ciphertext] = encrypted.split(':'); const iv = Buffer.from(ivBase64, 'base64'); const authTag = Buffer.from(authTagBase64, 'base64'); const decipher = crypto.createDecipheriv('aes-256-gcm', this.key, iv); decipher.setAuthTag(authTag); let decrypted = decipher.update(ciphertext, 'base64', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; } } ``` #### Acceptance Criteria - [ ] Config saves to project metadata - [ ] Config loads correctly - [ ] Secrets are encrypted at rest - [ ] Decryption works with correct key - [ ] Export excludes secrets - [ ] Environment support works - [ ] Handles missing/corrupted data gracefully --- ### UBA-006: Backend Discovery **Effort:** 3 days **Assignee:** TBD **Branch:** `feature/uba-006-backend-discovery` **Depends On:** UBA-002 #### Description Implement the ability to fetch and cache schemas from backend URLs. This is how users add new UBA backends to their projects. #### Deliverables 1. **Backend Discovery** - Fetch schema from `.well-known` URL - Handle authentication - Validate fetched schema 2. **Schema Cache** - Cache schemas to avoid repeated fetches - Cache invalidation on backend version change - Offline support (use cached schema) 3. **Add Backend Dialog** - URL input - Auth configuration - Schema preview - Add confirmation #### Files to Create ``` packages/noodl-editor/src/editor/src/models/UBA/ ├── BackendDiscovery.ts ├── SchemaCache.ts └── BackendConnection.ts packages/noodl-editor/src/editor/src/views/UBA/ ├── AddBackendDialog.tsx └── AddBackendDialog.module.scss ``` #### Implementation ```typescript // BackendDiscovery.ts export class BackendDiscovery { private parser: SchemaParser; private cache: SchemaCache; constructor() { this.parser = new SchemaParser(); this.cache = new SchemaCache(); } /** * Discover and validate a backend's schema */ async discover(url: string, auth?: AuthConfig): Promise { const schemaUrl = this.buildSchemaUrl(url); try { // Fetch schema const response = await fetch(schemaUrl, { headers: this.buildHeaders(auth) }); if (!response.ok) { return { success: false, error: `Failed to fetch schema: ${response.status} ${response.statusText}` }; } const yamlText = await response.text(); // Parse and validate const parseResult = this.parser.parse(yamlText); if (!parseResult.success) { return { success: false, error: 'Invalid schema', validationErrors: parseResult.errors }; } const schema = parseResult.data; // Cache the schema await this.cache.set(url, schema, yamlText); // Test health endpoint if available let healthStatus: 'healthy' | 'unhealthy' | 'unknown' = 'unknown'; if (schema.backend.endpoints.health) { healthStatus = await this.checkHealth(url, schema.backend.endpoints.health, auth); } return { success: true, schema, healthStatus, warnings: parseResult.warnings }; } catch (error) { // Try cache for offline support const cached = await this.cache.get(url); if (cached) { return { success: true, schema: cached.schema, healthStatus: 'unknown', fromCache: true, warnings: ['Using cached schema (backend unreachable)'] }; } return { success: false, error: `Connection failed: ${error.message}` }; } } private buildSchemaUrl(baseUrl: string): string { const url = new URL(baseUrl); url.pathname = '/.well-known/nodegx-schema.yaml'; return url.toString(); } private buildHeaders(auth?: AuthConfig): HeadersInit { const headers: HeadersInit = { 'Accept': 'application/x-yaml, text/yaml, text/plain' }; if (auth) { switch (auth.type) { case 'bearer': headers['Authorization'] = `Bearer ${auth.token}`; break; case 'api_key': headers[auth.header || 'X-API-Key'] = auth.token; break; case 'basic': headers['Authorization'] = `Basic ${btoa(`${auth.username}:${auth.password}`)}`; break; } } return headers; } private async checkHealth(baseUrl: string, healthPath: string, auth?: AuthConfig): Promise<'healthy' | 'unhealthy'> { try { const url = new URL(healthPath, baseUrl); const response = await fetch(url.toString(), { headers: this.buildHeaders(auth) }); if (response.ok) { const data = await response.json(); return data.status === 'healthy' ? 'healthy' : 'unhealthy'; } return 'unhealthy'; } catch { return 'unhealthy'; } } } // AddBackendDialog.tsx export function AddBackendDialog({ onClose, onAdd }: AddBackendDialogProps) { const [url, setUrl] = useState(''); const [authType, setAuthType] = useState<'none' | 'bearer' | 'api_key'>('none'); const [authToken, setAuthToken] = useState(''); const [discovering, setDiscovering] = useState(false); const [result, setResult] = useState(null); const handleDiscover = async () => { setDiscovering(true); setResult(null); const discovery = new BackendDiscovery(); const result = await discovery.discover(url, authType !== 'none' ? { type: authType, token: authToken } : undefined ); setResult(result); setDiscovering(false); }; const handleAdd = () => { if (result?.success && result.schema) { onAdd({ url, schema: result.schema, auth: authType !== 'none' ? { type: authType, token: authToken } : undefined }); onClose(); } }; return (
setUrl(e.target.value)} placeholder="https://my-backend.example.com" />

Will look for schema at: /.well-known/nodegx-schema.yaml

{authType !== 'none' && ( setAuthToken(e.target.value)} placeholder={authType === 'bearer' ? 'Bearer token' : 'API key'} /> )} {result && !result.success && (
{result.error}
)} {result?.success && result.schema && (
{result.schema.backend.icon && }

{result.schema.backend.name}

{result.schema.backend.description}

v{result.schema.backend.version}

{result.healthStatus}

Configuration Sections:

    {result.schema.sections.map(section => (
  • {section.icon && } {section.name} ({section.fields.length} fields)
  • ))}
{result.schema.debug?.enabled && (

Debug streaming available

)} {result.warnings?.length > 0 && (
{result.warnings.map((warning, i) => (

{warning}

))}
)}
)}
); } ``` #### Acceptance Criteria - [ ] Fetches schema from correct URL - [ ] Handles authentication correctly - [ ] Validates fetched schema - [ ] Caches schema for offline use - [ ] Shows schema preview before adding - [ ] Health check works - [ ] Error messages are helpful - [ ] Works with various URL formats --- ### UBA-007: Config Push **Effort:** 2 days **Assignee:** TBD **Branch:** `feature/uba-007-config-push` **Depends On:** UBA-005, UBA-006 #### Description Implement sending configuration to backends when users save. Handle responses including warnings and runtime values. #### Deliverables 1. **Config Push Service** - POST config to backend - Handle authentication - Process response (success, warnings, errors) 2. **Runtime Values Integration** - Receive runtime values from backend - Inject into prompt variables - Display in UI 3. **Hot Reload Support** - Detect if backend supports hot reload - Push config changes without restart #### Files to Create ``` packages/noodl-editor/src/editor/src/models/UBA/ ├── ConfigPush.ts └── RuntimeValues.ts ``` #### Implementation ```typescript // ConfigPush.ts interface ConfigPushResult { success: boolean; applied_at?: string; warnings?: ConfigWarning[]; errors?: ConfigError[]; runtime_values?: Record; } interface ConfigWarning { field: string; message: string; } interface ConfigError { field: string; message: string; code: string; } export class ConfigPush { constructor( private backendUrl: string, private auth?: AuthConfig ) {} /** * Push configuration to backend */ async push(schema: UBASchema, config: Record, metadata: ConfigMetadata): Promise { const endpoint = new URL(schema.backend.endpoints.config, this.backendUrl); const body = { config, metadata: { project_id: metadata.projectId, project_name: metadata.projectName, environment: metadata.environment, nodegx_version: metadata.nodegxVersion } }; try { const response = await fetch(endpoint.toString(), { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.buildAuthHeaders() }, body: JSON.stringify(body) }); const data = await response.json(); if (!response.ok) { return { success: false, errors: data.errors || [{ field: '', message: data.message || 'Configuration rejected by backend', code: 'REJECTED' }] }; } return { success: true, applied_at: data.applied_at, warnings: data.warnings, runtime_values: data.runtime_values }; } catch (error) { return { success: false, errors: [{ field: '', message: `Failed to push config: ${error.message}`, code: 'CONNECTION_ERROR' }] }; } } private buildAuthHeaders(): Record { if (!this.auth) return {}; switch (this.auth.type) { case 'bearer': return { 'Authorization': `Bearer ${this.auth.token}` }; case 'api_key': return { [this.auth.header || 'X-API-Key']: this.auth.token }; default: return {}; } } } // RuntimeValues.ts export class RuntimeValues { private values: Record = {}; private listeners: Set<() => void> = new Set(); update(values: Record) { this.values = { ...this.values, ...values }; this.notifyListeners(); } get(key: string): any { return this.values[key]; } getAll(): Record { return { ...this.values }; } subscribe(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } private notifyListeners() { this.listeners.forEach(listener => listener()); } } ``` #### Acceptance Criteria - [ ] Config POSTs to correct endpoint - [ ] Authentication headers included - [ ] Success response handled - [ ] Warnings displayed to user - [ ] Errors displayed with field context - [ ] Runtime values stored and accessible - [ ] Hot reload triggers correctly - [ ] Connection errors handled gracefully --- ## Phase 6A Checklist ### Pre-Development - [ ] Review and approve schema specification - [ ] Set up branch structure - [ ] Create Storybook stories for field components ### UBA-001: Schema Specification - [ ] Write complete specification document - [ ] Create JSON Schema - [ ] Write example schemas - [ ] Review with team ### UBA-002: Schema Parser - [ ] Implement YAML parser - [ ] Implement JSON Schema validation - [ ] Create TypeScript types - [ ] Add version handling - [ ] Write unit tests - [ ] Test with example schemas ### UBA-003: Basic Field Renderers - [ ] Implement StringField - [ ] Implement TextField - [ ] Implement NumberField - [ ] Implement BooleanField - [ ] Implement SecretField - [ ] Implement UrlField - [ ] Implement SelectField - [ ] Implement MultiSelectField - [ ] Create FieldRenderer factory - [ ] Style all fields consistently - [ ] Write component tests - [ ] Add Storybook stories ### UBA-004: Config Panel Shell - [ ] Implement ConfigPanel - [ ] Implement ConfigSection - [ ] Implement ConfigHeader - [ ] Create useConfigForm hook - [ ] Add validation integration - [ ] Test tab navigation - [ ] Test form state ### UBA-005: Config Storage - [ ] Implement ConfigStorage - [ ] Implement SecretManager - [ ] Add encryption/decryption - [ ] Test save/load cycle - [ ] Test export without secrets ### UBA-006: Backend Discovery - [ ] Implement BackendDiscovery - [ ] Implement SchemaCache - [ ] Create AddBackendDialog - [ ] Test schema fetching - [ ] Test caching - [ ] Test error handling ### UBA-007: Config Push - [ ] Implement ConfigPush - [ ] Implement RuntimeValues - [ ] Test push flow - [ ] Test warning handling - [ ] Test error handling ### Integration - [ ] End-to-end test with mock backend - [ ] Test with real backend (if available) - [ ] Performance testing - [ ] Accessibility review ### Documentation - [ ] API documentation - [ ] Usage examples - [ ] Troubleshooting guide --- ## Success Criteria ### Functional - [ ] Can add a UBA backend by URL - [ ] Schema is fetched and validated - [ ] Config panel renders all basic field types - [ ] Configuration saves to project - [ ] Configuration pushes to backend - [ ] Secrets are encrypted at rest ### Performance - [ ] Schema parsing < 100ms - [ ] Panel renders < 500ms - [ ] No memory leaks ### Quality - [ ] Test coverage > 80% - [ ] No TypeScript errors - [ ] Passes linting - [ ] Accessible (keyboard navigation, screen readers) --- ## Dependencies ### External Packages (to add) ```json { "js-yaml": "^4.1.0", "ajv": "^8.12.0" } ``` ### Internal Dependencies - `@noodl-models/projectmodel` - Project metadata storage - `@noodl-core-ui` - UI components - Backend Services system - Backend list integration --- ## Risk Mitigation | Risk | Mitigation | |------|------------| | Schema spec too complex | Start minimal, add features in 6B | | Encryption issues | Use well-tested crypto library | | YAML parsing errors | Comprehensive error handling | | Backend unreachable | Offline mode with cached schema | --- ## Notes - Keep field renderers simple in this phase; complex types come in 6B - Focus on correctness over features - Document all decisions for future reference - Create reusable patterns for later phases