npm package: @kya-os/provider-registry
Description: Single source of truth for provider definitions and provider-type mapping
Latest version: 0.1.7

# @kya-os/provider-registry

Single source of truth for provider definitions and provider-type mapping used across the MCP-I framework.

## Overview

This package centralizes provider metadata, provider-type detection, and authentication routing logic. It eliminates hardcoded provider lists scattered across the codebase and provides a consistent, testable interface for provider management.

## Features

- **Single Source of Truth**: Centralized provider definitions
- **Type Safety**: Full TypeScript support with Zod validation
- **Dependency Injection**: Interface-based design for testability
- **Runtime Configuration**: Load custom providers from JSON/config
- **Default Providers**: Pre-populated with common OAuth providers
- **oauthProviderId Uniqueness**: Enforced uniqueness for OAuth provider IDs
- **Seal Capability**: Lock registry in production to prevent modifications
- **Consistent Fallback Behavior**: Documented, predictable behavior for unknown providers

## Installation

```bash
pnpm add @kya-os/provider-registry
```

## Usage

### Basic Usage (Default Registry)

```typescript
import { defaultProviderRegistry } from '@kya-os/provider-registry';

// Check if a provider is OAuth
if (defaultProviderRegistry.isOAuthProvider('github')) {
  // Handle OAuth flow
}

// Map auth mode to consent provider type
const providerType = defaultProviderRegistry.mapAuthModeToConsentProvider(
  'credentials',
  undefined
);
// Returns: 'credential'
```

### Dependency Injection (Testing)

```typescript
import { ProviderRegistry, IProviderRegistry, createDefaultProviderRegistry } from '@kya-os/provider-registry';

// Create isolated registry for tests
const testRegistry = createDefaultProviderRegistry();

// Or create with custom providers
const customRegistry = new ProviderRegistry([
  {
    id: 'test-provider',
    displayName: 'Test Provider',
    authType: 'oauth2',
  },
]);

// Inject into your class
class MyAgent {
  constructor(private providerRegistry: IProviderRegistry = defaultProviderRegistry) {}
  
  handleAuth(provider: string) {
    if (this.providerRegistry.isOAuthProvider(provider)) {
      // OAuth flow
    }
  }
}
```

### Registering Custom Providers

```typescript
import { defaultProviderRegistry } from '@kya-os/provider-registry';

// Register a custom credential provider (e.g., for enterprise auth)
defaultProviderRegistry.registerProvider({
  id: 'my-company-auth',
  displayName: 'My Company Login',
  authType: 'password',
  ui: {
    description: 'Sign in with My Company credentials',
  },
});

// Register a custom OAuth provider
defaultProviderRegistry.registerProvider({
  id: 'enterprise-sso',
  displayName: 'Enterprise SSO',
  authType: 'oauth2',
  oauthProviderId: 'enterprise-sso',
  defaultScopes: ['openid', 'profile', 'email'],
});
```

### Loading from Configuration

```typescript
import { defaultProviderRegistry } from '@kya-os/provider-registry';

// Load from JSON config
const config = {
  providers: [
    {
      id: 'my-provider',
      displayName: 'My Provider',
      authType: 'oauth2',
    },
  ],
};

defaultProviderRegistry.loadFromConfig(config);
```

### Environment Variable Configuration

```typescript
// Load from environment variable (JSON string)
const envConfig = process.env.MCP_PROVIDER_REGISTRY;
if (envConfig) {
  const config = JSON.parse(envConfig);
  defaultProviderRegistry.loadFromConfig(config);
}

// Seal registry to prevent further modifications in production
defaultProviderRegistry.seal();
```

### Finding Providers by OAuth Provider ID

```typescript
import { defaultProviderRegistry } from '@kya-os/provider-registry';

// Find provider by oauthProviderId (useful when IDs differ)
defaultProviderRegistry.registerProvider({
  id: 'custom-oauth',
  authType: 'oauth2',
  oauthProviderId: 'my-custom-oauth-id',
});

const provider = defaultProviderRegistry.findByOauthProviderId('my-custom-oauth-id');
// Returns: { id: 'custom-oauth', ... }
```

## API Reference

### `IProviderRegistry` Interface

```typescript
interface IProviderRegistry {
  getProvider(id: string): ProviderDefinition | undefined;
  findByOauthProviderId(oauthProviderId: string): ProviderDefinition | undefined;
  isKnownProvider(id: string): boolean;
  isOAuthProvider(id: string): boolean;
  isCredentialProvider(id: string): boolean;
  mapAuthModeToConsentProvider(authMode?: AuthMode | string, oauthProviderId?: string): ConsentProviderType;
  mapProviderToConsentProvider(providerId: string): ConsentProviderType;
  determineProviderTypeFromAuthMode(authMode?: string, oauthIdentityProvider?: string): ConsentProviderType; // @deprecated
  registerProvider(def: ProviderDefinition, opts?: { overwrite?: boolean }): void;
  listProviders(): ProviderDefinition[];
  loadFromConfig(config: unknown): void;
  seal(): void;
  isSealed(): boolean;
}
```

### `ProviderDefinition` Type

```typescript
interface ProviderDefinition {
  id: string;                    // Unique identifier (e.g., 'github', 'my-company-auth')
  displayName?: string;          // Human-friendly name
  authType: ProviderAuthType;    // 'oauth2', 'password', 'verifiable_credential', etc.
  oauthProviderId?: string;      // OAuth provider ID (must be unique across registry)
  defaultScopes?: string[];      // Default OAuth scopes
  oauthConfig?: OAuthProviderConfig;     // OAuth endpoint configuration
  credentialConfig?: CredentialProviderConfig; // Credential flow configuration
  ui?: {
    icon?: string;
    description?: string;
  };
  metadata?: Record<string, unknown>;    // Custom metadata (secret names, not secrets!)
}
```

### `OAuthProviderConfig` Type

Configuration for OAuth provider flows:

```typescript
interface OAuthProviderConfig {
  authorizationEndpoint: string;   // OAuth authorization URL
  tokenEndpoint: string;           // OAuth token URL
  userInfoEndpoint?: string;       // User info endpoint
  defaultScopes?: string[];        // Default scopes to request
  supportsPKCE?: boolean;          // Whether PKCE is supported
  requiresClientSecret?: boolean;  // Whether client secret is required
  tokenEndpointAuthMethod?: 'client_secret_post' | 'client_secret_basic';
  responseType?: string;           // OAuth response type (default: 'code')
  grantType?: string;              // OAuth grant type (default: 'authorization_code')
  customParams?: Record<string, string>; // Custom OAuth params (audience, etc.)
  authUrlTemplate?: string;        // URL template with {placeholders}
}
```

### `CredentialProviderConfig` Type

Configuration for credential/password authentication:

```typescript
interface CredentialProviderConfig {
  authEndpoint: string;            // Endpoint to POST credentials
  httpMethod?: 'POST' | 'PUT';     // HTTP method
  contentType?: 'application/json' | 'application/x-www-form-urlencoded';
  requestBodyTemplate?: {
    identityField?: string;        // Field name for email/username
    passwordField?: string;        // Field name for password
    additionalFields?: Record<string, string>;
  };
  responseFields?: {
    sessionTokenPath?: string;     // JSON path to session token
    userIdPath?: string;           // JSON path to user ID
    userEmailPath?: string;        // JSON path to user email
    userDisplayNamePath?: string;  // JSON path to display name
    expiresInPath?: string;        // JSON path to token expiry
  };
  successCheck?: {
    path?: string;                 // JSON path to check for success
    expectedValue?: string | boolean;
  };
  useCookieSession?: boolean;      // Whether to use cookies for session
  cookieNames?: string;            // Cookie names to extract
  customHeaders?: Record<string, string>;
  requiresCsrf?: boolean;          // Whether CSRF token is required
}
```

## Mapping Semantics

### `mapAuthModeToConsentProvider(authMode?, oauthProviderId?)`

Deterministic precedence rules:

1. **Explicit authMode (non-oauth)** → Direct mapping
   - `'credentials'`, `'password'` → `'credential'`
   - `'magic-link'` → `'magic_link'`
   - `'otp'` → `'otp'`
   - `'verifiable_credential'`, `'idv'`, `'mdl'` → `'verifiable_credential'`
   - `'consent-only'`, `'none'`, `''` → `'none'`

2. **If authMode === 'oauth' or oauthProviderId present** → Consult registry
   - Known provider → Map based on provider's authType
   - **Unknown provider → `'oauth2'` (with warning log)**

3. **Fallback** → `'none'`

### `mapProviderToConsentProvider(providerId)`

- Known provider → Map based on provider's authType
- **Unknown provider → `'none'` (with warning log)**

### Fallback Behavior Rationale

The different fallbacks are intentional:

- **`mapAuthModeToConsentProvider` with unknown oauthProviderId → `'oauth2'`**: The presence of an oauthProviderId strongly implies an OAuth flow, even if the provider isn't registered.

- **`mapProviderToConsentProvider` with unknown providerId → `'none'`**: Direct provider lookup should fail safely without assumptions.

Both log warnings to help identify configuration issues.

## Default Providers

The registry comes pre-populated with common providers:

**OAuth Providers**: GitHub, Google, Microsoft, Discord, Slack, Apple, LinkedIn, Twitter, Facebook, Okta, Auth0

**Credential Provider**: `credentials` (generic placeholder)

**Note**: Customer-specific providers (like HardwareWorld) should NOT be in default providers. Register them via:
- `registerProvider()` at runtime
- `loadFromConfig()` with custom configuration
- `MCP_PROVIDER_REGISTRY` environment variable

## Security: Secrets Handling

> ⚠️ **NEVER store actual secrets in provider definitions.** Store only secret references (names).

Provider definitions may contain endpoint URLs and configuration, but **client secrets, API keys, and other sensitive credentials must NOT be stored in the registry**.

### The Correct Approach

Store **secret names** in `metadata` and read actual secrets from secure storage at runtime:

```typescript
// ✅ CORRECT: Store secret name, not secret value
defaultProviderRegistry.registerProvider({
  id: 'enterprise-oauth',
  displayName: 'Enterprise SSO',
  authType: 'oauth2',
  oauthConfig: {
    authorizationEndpoint: 'https://sso.example.com/oauth/authorize',
    tokenEndpoint: 'https://sso.example.com/oauth/token',
  },
  metadata: {
    clientSecretName: 'ENTERPRISE_SSO_CLIENT_SECRET', // Name, not value!
    clientId: 'my-app-client-id', // Client ID is often public, OK to store
  },
});

// At runtime, read secret from secure storage
async function getProviderSecret(providerId: string): Promise<string> {
  const provider = defaultProviderRegistry.getProvider(providerId);
  const secretName = provider?.metadata?.clientSecretName as string;
  
  // Read from Cloudflare Secrets, AWS Secrets Manager, vault, etc.
  return env[secretName] || await secretsManager.getSecret(secretName);
}
```

### What Can Be Stored in Registry

| Field | OK to Store | Notes |
|-------|-------------|-------|
| `authorizationEndpoint` | ✅ Yes | Public URL |
| `tokenEndpoint` | ✅ Yes | Public URL |
| `clientId` | ✅ Usually | Often public (check your provider) |
| `clientSecret` | ❌ **NO** | Store as `metadata.clientSecretName` |
| `apiKey` | ❌ **NO** | Store as `metadata.apiKeyName` |
| `defaultScopes` | ✅ Yes | Configuration, not secret |

### Credential Provider Security

For password/credential flows, the `credentialConfig` describes **how** to authenticate, not **with what credentials**:

```typescript
// ✅ CORRECT: Describe flow structure, not credentials
defaultProviderRegistry.registerProvider({
  id: 'legacy-api',
  authType: 'password',
  credentialConfig: {
    authEndpoint: 'https://api.example.com/auth/login',
    contentType: 'application/json',
    requestBodyTemplate: {
      identityField: 'email',     // Field name, not value
      passwordField: 'password',  // Field name, not value
    },
    responseFields: {
      sessionTokenPath: 'data.token',
    },
  },
  metadata: {
    // If the API requires static headers like API keys:
    apiKeyHeaderName: 'X-API-Key',
    apiKeySecretName: 'LEGACY_API_KEY', // Read from env/secrets at runtime
  },
});
```

## Production Best Practices

### 1. Load Custom Providers Early

```typescript
import { defaultProviderRegistry } from '@kya-os/provider-registry';

// In your server initialization
async function initializeRegistry() {
  // Load from config/environment
  if (process.env.MCP_PROVIDER_REGISTRY) {
    defaultProviderRegistry.loadFromConfig(
      JSON.parse(process.env.MCP_PROVIDER_REGISTRY)
    );
  }
  
  // Seal to prevent runtime modifications
  defaultProviderRegistry.seal();
}
```

### 2. Use Dependency Injection for Testing

```typescript
import { createDefaultProviderRegistry, IProviderRegistry } from '@kya-os/provider-registry';

class MyService {
  constructor(private registry: IProviderRegistry = defaultProviderRegistry) {}
}

// In tests
const testRegistry = createDefaultProviderRegistry();
testRegistry.registerProvider({ id: 'mock', authType: 'oauth2' });
const service = new MyService(testRegistry);
```

### 3. Custom Logger for Metrics

```typescript
import { createDefaultProviderRegistry, RegistryLogger } from '@kya-os/provider-registry';

const metricsLogger: RegistryLogger = {
  warn(message, context) {
    myMetricsClient.increment('provider_registry.warning', {
      message,
      ...context,
    });
    console.warn(message, context);
  },
};

const registry = createDefaultProviderRegistry(metricsLogger);
```

### 4. Registry Lifecycle & Multi-tenant Deployments

**Lifecycle expectations for the singleton:**

1. **Initialization**: The `defaultProviderRegistry` is lazily created on first access (no module-load side effects).

2. **Configuration**: Load project-specific providers early in your application startup:
   ```typescript
   // At server/worker startup
   if (env.MCP_PROVIDER_REGISTRY) {
     defaultProviderRegistry.loadFromConfig(JSON.parse(env.MCP_PROVIDER_REGISTRY));
   }
   ```

3. **Sealing**: Call `seal()` after configuration to prevent accidental runtime modifications:
   ```typescript
   defaultProviderRegistry.seal();
   // After this, registerProvider() and loadFromConfig() will throw
   ```

4. **Runtime**: Use the sealed registry for lookups throughout the application lifecycle.

**Multi-tenant deployments:**

For multi-tenant scenarios where different projects need different providers, **prefer per-project registry instances** instead of mutating the global singleton:

```typescript
import { ProviderRegistry, defaultProviders } from '@kya-os/provider-registry';

function createProjectRegistry(projectConfig: ProviderConfig): ProviderRegistry {
  // Start with default providers
  const registry = new ProviderRegistry(defaultProviders);
  
  // Add project-specific providers
  registry.loadFromConfig(projectConfig);
  
  // Seal and return
  registry.seal();
  return registry;
}

// Each tenant gets their own isolated registry
const tenant1Registry = createProjectRegistry(tenant1Config);
const tenant2Registry = createProjectRegistry(tenant2Config);
```

**Testing:**

Use `resetDefaultProviderRegistryForTests()` in test setup to ensure isolation:

```typescript
import { resetDefaultProviderRegistryForTests } from '@kya-os/provider-registry';

beforeEach(() => {
  resetDefaultProviderRegistryForTests();
});
```

## Integration Examples

### Cloudflare Agent

```typescript
import { defaultProviderRegistry, IProviderRegistry } from '@kya-os/provider-registry';

export class MCPICloudflareAgent {
  constructor(
    private providerRegistry: IProviderRegistry = defaultProviderRegistry
  ) {}

  async handleOAuthRequired(error: OAuthRequiredError) {
    const provider = error.provider?.toLowerCase() || '';
    
    if (this.providerRegistry.isCredentialProvider(provider)) {
      // Build credential consent URL
    } else if (this.providerRegistry.isOAuthProvider(provider)) {
      // Build OAuth URL
    }
  }
}
```

### Consent UI

```typescript
import { defaultProviderRegistry } from '@kya-os/provider-registry';

const providerType = defaultProviderRegistry.mapAuthModeToConsentProvider(
  authMode,
  oauthIdentity?.provider
);

const provider = defaultProviderRegistry.getProvider(providerId);
if (provider?.ui?.icon) {
  // Render provider icon
}
```

## Testing

```typescript
import { ProviderRegistry, createDefaultProviderRegistry } from '@kya-os/provider-registry';

describe('MyComponent', () => {
  it('should handle OAuth providers', () => {
    const registry = new ProviderRegistry([
      { id: 'github', displayName: 'GitHub', authType: 'oauth2' },
    ]);
    
    expect(registry.isOAuthProvider('github')).toBe(true);
  });

  it('should use isolated registry', () => {
    const registry = createDefaultProviderRegistry();
    registry.registerProvider({ id: 'custom', authType: 'password' });
    
    // Won't affect other tests or global singleton
    expect(registry.isCredentialProvider('custom')).toBe(true);
  });
});
```

## Migration Guide

### Replacing Hardcoded Lists

**Before:**
```typescript
const KNOWN_OAUTH_PROVIDERS = ['github', 'google', 'microsoft'];
const isOAuth = KNOWN_OAUTH_PROVIDERS.includes(provider);
```

**After:**
```typescript
import { defaultProviderRegistry } from '@kya-os/provider-registry';
const isOAuth = defaultProviderRegistry.isOAuthProvider(provider);
```

### Replacing Provider Type Detection

**Before:**
```typescript
function getProviderType(authMode?: string, oauthProvider?: string) {
  if (authMode === 'credentials') return 'credential';
  if (oauthProvider) return 'oauth2';
  return 'none';
}
```

**After:**
```typescript
import { defaultProviderRegistry } from '@kya-os/provider-registry';
const providerType = defaultProviderRegistry.mapAuthModeToConsentProvider(
  authMode,
  oauthProvider
);
```

### Using findByOauthProviderId

**Before:**
```typescript
// Manual lookup with potential mismatch
const provider = registry.getProvider(oauthIdentityProvider);
```

**After:**
```typescript
// Proper lookup using oauthProviderId index
const provider = registry.findByOauthProviderId(oauthIdentityProvider);
```

## License

MIT