Files
OpenNoodl/dev-docs/tasks/phase-6-uba-system/UBA-001-FOUNDATION.md

51 KiB

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

# 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

# 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

// 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<UBASchema> {
    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

// 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

// FieldWrapper.tsx
interface FieldWrapperProps {
  field: BaseField;
  error?: string;
  warning?: string;
  children: React.ReactNode;
}

export function FieldWrapper({ field, error, warning, children }: FieldWrapperProps) {
  return (
    <div className={css['field-wrapper']} data-field-id={field.id}>
      <label className={css['field-label']}>
        {field.name}
        {field.required && <span className={css['required']}>*</span>}
      </label>
      
      {field.description && (
        <p className={css['field-description']}>{field.description}</p>
      )}
      
      {children}
      
      {error && (
        <p className={css['field-error']}>{error}</p>
      )}
      
      {warning && (
        <p className={css['field-warning']}>{warning}</p>
      )}
      
      {field.ui?.help_link && (
        <a href={field.ui.help_link} target="_blank" className={css['help-link']}>
          Learn more 
        </a>
      )}
    </div>
  );
}

// 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 (
    <FieldWrapper field={field} error={error}>
      <input
        type="text"
        value={value ?? field.default ?? ''}
        onChange={(e) => onChange(e.target.value)}
        onBlur={onBlur}
        placeholder={field.placeholder}
        disabled={disabled}
        className={css['text-input']}
        maxLength={field.validation?.max_length}
      />
    </FieldWrapper>
  );
}

// SecretField.tsx
export function SecretField({ field, value, onChange, error, disabled }: SecretFieldProps) {
  const [visible, setVisible] = useState(false);
  
  return (
    <FieldWrapper field={field} error={error}>
      <div className={css['secret-input-wrapper']}>
        <input
          type={visible ? 'text' : 'password'}
          value={value ?? ''}
          onChange={(e) => onChange(e.target.value)}
          placeholder={field.placeholder ?? '••••••••••••'}
          disabled={disabled}
          className={css['text-input']}
          autoComplete="off"
        />
        <button
          type="button"
          onClick={() => setVisible(!visible)}
          className={css['visibility-toggle']}
          title={visible ? 'Hide' : 'Show'}
        >
          {visible ? <IconEyeOff /> : <IconEye />}
        </button>
      </div>
    </FieldWrapper>
  );
}

// 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 <StringField field={field} value={value} onChange={onChange} error={error} disabled={disabled} />;
    case 'text':
      return <TextField field={field} value={value} onChange={onChange} error={error} disabled={disabled} />;
    case 'number':
      return <NumberField field={field} value={value} onChange={onChange} error={error} disabled={disabled} />;
    case 'boolean':
      return <BooleanField field={field} value={value} onChange={onChange} disabled={disabled} />;
    case 'secret':
      return <SecretField field={field} value={value} onChange={onChange} error={error} disabled={disabled} />;
    case 'url':
      return <UrlField field={field} value={value} onChange={onChange} error={error} disabled={disabled} />;
    case 'select':
      return <SelectField field={field} value={value} onChange={onChange} error={error} disabled={disabled} />;
    case 'multi_select':
      return <MultiSelectField field={field} value={value} onChange={onChange} error={error} disabled={disabled} />;
    default:
      // Fallback for unknown types
      return <StringField field={field as any} value={value} onChange={onChange} error={error} disabled={disabled} />;
  }
}

Styling Guidelines

// 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

describe('Field Renderers', () => {
  describe('StringField', () => {
    it('renders with value', () => {
      render(<StringField field={mockField} value="test" onChange={jest.fn()} />);
      expect(screen.getByDisplayValue('test')).toBeInTheDocument();
    });
    
    it('shows placeholder when empty', () => {
      const field = { ...mockField, placeholder: 'Enter text...' };
      render(<StringField field={field} value="" onChange={jest.fn()} />);
      expect(screen.getByPlaceholderText('Enter text...')).toBeInTheDocument();
    });
    
    it('calls onChange when typed', () => {
      const onChange = jest.fn();
      render(<StringField field={mockField} value="" onChange={onChange} />);
      fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new' } });
      expect(onChange).toHaveBeenCalledWith('new');
    });
    
    it('shows error message', () => {
      render(<StringField field={mockField} value="" onChange={jest.fn()} error="Required" />);
      expect(screen.getByText('Required')).toBeInTheDocument();
    });
  });
  
  describe('SecretField', () => {
    it('masks value by default', () => {
      render(<SecretField field={mockField} value="secret123" onChange={jest.fn()} />);
      expect(screen.getByDisplayValue('secret123').getAttribute('type')).toBe('password');
    });
    
    it('toggles visibility', () => {
      render(<SecretField field={mockField} value="secret123" onChange={jest.fn()} />);
      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

// ConfigPanel.tsx
interface ConfigPanelProps {
  schema: UBASchema;
  initialValues?: Record<string, any>;
  onSave: (values: Record<string, any>) => Promise<void>;
  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<string | null>(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 (
    <div className={css['config-panel']}>
      <ConfigHeader
        backend={schema.backend}
        isDirty={isDirty}
        saving={saving}
        onSave={handleSave}
        onReset={handleReset}
        disabled={disabled}
      />
      
      {saveError && (
        <div className={css['save-error']}>
          <IconAlert /> {saveError}
        </div>
      )}
      
      <div className={css['section-tabs']}>
        {visibleSections.map(section => (
          <button
            key={section.id}
            className={cn(css['tab'], activeSection === section.id && css['active'])}
            onClick={() => setActiveSection(section.id)}
          >
            {section.icon && <Icon name={section.icon} />}
            {section.name}
            {hasErrors(errors, section.id) && <span className={css['error-badge']} />}
          </button>
        ))}
      </div>
      
      <div className={css['section-content']}>
        {visibleSections.map(section => (
          <ConfigSection
            key={section.id}
            section={section}
            values={values}
            errors={errors}
            onChange={setValue}
            visible={activeSection === section.id}
            disabled={disabled || saving}
          />
        ))}
      </div>
    </div>
  );
}

// useConfigForm.ts
export function useConfigForm(schema: UBASchema, initialValues?: Record<string, any>) {
  const [values, setValues] = useState<Record<string, any>>(() => 
    buildInitialValues(schema, initialValues)
  );
  const [errors, setErrors] = useState<Record<string, string>>({});
  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<string, any>): Record<string, any> {
  const values: Record<string, any> = {};
  
  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

// ConfigStorage.ts
import { ProjectModel } from '@noodl-models/projectmodel';
import { SecretManager } from './SecretManager';

interface StoredConfig {
  values: Record<string, any>;
  encryptedSecrets: Record<string, string>;
  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<string, any>, secretPaths: string[]): Promise<void> {
    // Separate secrets from regular values
    const { values, secrets } = this.separateSecrets(config, secretPaths);
    
    // Encrypt secrets
    const encryptedSecrets: Record<string, string> = {};
    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<Record<string, any> | null> {
    const stored = this.project.getMetaData(`uba.${backendId}`) as StoredConfig | undefined;
    
    if (!stored) {
      return null;
    }
    
    // Decrypt secrets
    const decryptedSecrets: Record<string, string> = {};
    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<string, any> | 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<string, any>, secretPaths: string[]): {
    values: Record<string, any>;
    secrets: Record<string, string>;
  } {
    const values = JSON.parse(JSON.stringify(config));
    const secrets: Record<string, string> = {};
    
    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<string> {
    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<string> {
    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

// 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<DiscoveryResult> {
    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<DiscoveryResult | null>(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 (
    <Dialog onClose={onClose} title="Add UBA Backend">
      <div className={css['form']}>
        <label>Backend URL</label>
        <input
          type="url"
          value={url}
          onChange={(e) => setUrl(e.target.value)}
          placeholder="https://my-backend.example.com"
        />
        <p className={css['hint']}>
          Will look for schema at: <code>/.well-known/nodegx-schema.yaml</code>
        </p>
        
        <label>Authentication</label>
        <select value={authType} onChange={(e) => setAuthType(e.target.value as any)}>
          <option value="none">None</option>
          <option value="bearer">Bearer Token</option>
          <option value="api_key">API Key</option>
        </select>
        
        {authType !== 'none' && (
          <input
            type="password"
            value={authToken}
            onChange={(e) => setAuthToken(e.target.value)}
            placeholder={authType === 'bearer' ? 'Bearer token' : 'API key'}
          />
        )}
        
        <Button onClick={handleDiscover} disabled={!url || discovering}>
          {discovering ? 'Detecting...' : 'Detect Schema'}
        </Button>
        
        {result && !result.success && (
          <div className={css['error']}>
            <IconAlert /> {result.error}
          </div>
        )}
        
        {result?.success && result.schema && (
          <div className={css['schema-preview']}>
            <div className={css['backend-info']}>
              {result.schema.backend.icon && <Icon name={result.schema.backend.icon} />}
              <div>
                <h3>{result.schema.backend.name}</h3>
                <p>{result.schema.backend.description}</p>
                <p className={css['version']}>v{result.schema.backend.version}</p>
              </div>
              <span className={css['health-badge']} data-status={result.healthStatus}>
                {result.healthStatus}
              </span>
            </div>
            
            <div className={css['sections-preview']}>
              <h4>Configuration Sections:</h4>
              <ul>
                {result.schema.sections.map(section => (
                  <li key={section.id}>
                    {section.icon && <Icon name={section.icon} size="small" />}
                    {section.name} ({section.fields.length} fields)
                  </li>
                ))}
              </ul>
            </div>
            
            {result.schema.debug?.enabled && (
              <p className={css['debug-available']}>
                <IconBug /> Debug streaming available
              </p>
            )}
            
            {result.warnings?.length > 0 && (
              <div className={css['warnings']}>
                {result.warnings.map((warning, i) => (
                  <p key={i}><IconWarning /> {warning}</p>
                ))}
              </div>
            )}
          </div>
        )}
      </div>
      
      <div className={css['actions']}>
        <Button variant="secondary" onClick={onClose}>Cancel</Button>
        <Button 
          variant="primary" 
          onClick={handleAdd}
          disabled={!result?.success}
        >
          Add Backend
        </Button>
      </div>
    </Dialog>
  );
}

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

// ConfigPush.ts
interface ConfigPushResult {
  success: boolean;
  applied_at?: string;
  warnings?: ConfigWarning[];
  errors?: ConfigError[];
  runtime_values?: Record<string, any>;
}

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<string, any>, metadata: ConfigMetadata): Promise<ConfigPushResult> {
    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<string, string> {
    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<string, any> = {};
  private listeners: Set<() => void> = new Set();
  
  update(values: Record<string, any>) {
    this.values = { ...this.values, ...values };
    this.notifyListeners();
  }
  
  get(key: string): any {
    return this.values[key];
  }
  
  getAll(): Record<string, any> {
    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)

{
  "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