diff --git a/examples/minimal-auth/README.md b/examples/minimal-auth/README.md index fa4fb27a..9a752bb3 100644 --- a/examples/minimal-auth/README.md +++ b/examples/minimal-auth/README.md @@ -67,6 +67,14 @@ This will: 4. Get the current session 5. Test password reset flow +### 5. Test Dynamic Discovery (Optional) + +```bash +pnpm tsx src/test-discovery.ts +``` + +This test demonstrates how the auth service automatically appears in the API discovery response when `plugin-auth` is registered. Before the plugin is registered, `discovery.services.auth.status` is "unavailable". After registration, it becomes "available" with the proper route information. + ## Usage ### Using the ObjectStack Client @@ -122,12 +130,59 @@ curl http://localhost:3000/api/v1/auth/get-session \ ``` minimal-auth/ ├── src/ -│ ├── server.ts # Server setup with AuthPlugin -│ └── test-auth.ts # Authentication flow test +│ ├── server.ts # Server setup with AuthPlugin +│ ├── test-auth.ts # Authentication flow test +│ └── test-discovery.ts # Discovery API test (dynamic service detection) ├── package.json └── README.md ``` +## Dynamic Service Discovery + +ObjectStack features a **dynamic service discovery** system that automatically reflects which plugins are registered. This is particularly useful for clients that need to adapt their UI or behavior based on available services. + +**Discovery Response Without Auth Plugin:** +```json +{ + "services": { + "auth": { + "enabled": false, + "status": "unavailable", + "message": "Install plugin-auth to enable" + } + } +} +``` + +**Discovery Response With Auth Plugin:** +```json +{ + "services": { + "auth": { + "enabled": true, + "status": "available", + "route": "/api/v1/auth", + "provider": "plugin-auth" + } + }, + "endpoints": { + "auth": "/api/v1/auth" + } +} +``` + +Clients can use this to check service availability: +```typescript +const discovery = await client.getDiscovery(); +if (discovery.services.auth?.enabled) { + // Auth is available - show login UI + await client.auth.login({ ... }); +} else { + // Auth not available - hide login UI + console.log(discovery.services.auth?.message); +} +``` + ## Advanced Configuration See `src/server.ts` for examples of enabling advanced features: diff --git a/examples/minimal-auth/src/test-discovery.ts b/examples/minimal-auth/src/test-discovery.ts new file mode 100644 index 00000000..21d23e54 --- /dev/null +++ b/examples/minimal-auth/src/test-discovery.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Discovery Test + * + * This test verifies that the discovery endpoint correctly reflects + * the availability of the auth service when plugin-auth is registered. + */ + +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQL } from '@objectstack/objectql'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import { AuthPlugin } from '@objectstack/plugin-auth'; + +async function testDiscovery() { + console.log('🧪 Testing Discovery with Auth Plugin...\n'); + + // 1. Create ObjectQL instance with in-memory driver + const objectql = new ObjectQL(); + await objectql.registerDriver(new InMemoryDriver()); + + // 2. Create kernel + const kernel = new ObjectKernel(); + + // 3. Register ObjectQL plugin (which provides protocol service) + await kernel.use(new ObjectQLPlugin(objectql)); + + // 4. Get discovery BEFORE auth is registered + console.log('📋 Discovery BEFORE auth plugin:'); + let protocol = kernel.getService('protocol'); + let discovery = await protocol.getDiscovery(); + console.log(' - Auth service enabled:', discovery.services?.auth?.enabled); + console.log(' - Auth service status:', discovery.services?.auth?.status); + console.log(' - Auth endpoint:', discovery.endpoints?.auth || 'undefined'); + console.log(''); + + // 5. Register auth plugin + await kernel.use(new AuthPlugin({ + secret: 'test-secret-min-32-characters-long-for-jwt-signing', + baseUrl: 'http://localhost:3000', + })); + + // 6. Get discovery AFTER auth is registered + console.log('📋 Discovery AFTER auth plugin:'); + protocol = kernel.getService('protocol'); + discovery = await protocol.getDiscovery(); + console.log(' - Auth service enabled:', discovery.services?.auth?.enabled); + console.log(' - Auth service status:', discovery.services?.auth?.status); + console.log(' - Auth service route:', discovery.services?.auth?.route); + console.log(' - Auth service provider:', discovery.services?.auth?.provider); + console.log(' - Auth endpoint:', discovery.endpoints?.auth); + console.log(''); + + // 7. Verify the results + if (discovery.services?.auth?.enabled && discovery.services?.auth?.status === 'available') { + console.log('✅ SUCCESS: Auth service is correctly shown as available!'); + console.log('✅ Discovery endpoint is working correctly!'); + } else { + console.log('❌ FAILED: Auth service should be available but is not!'); + console.log(' Actual status:', discovery.services?.auth); + process.exit(1); + } + + // 8. Clean up + await kernel.shutdown(); + console.log('\n🎉 All tests passed!'); +} + +testDiscovery().catch((error) => { + console.error('❌ Error:', error); + process.exit(1); +}); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index f6cd7026..42672861 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -61,7 +61,10 @@ export class ObjectQLPlugin implements Plugin { }); // Register Protocol Implementation - const protocolShim = new ObjectStackProtocolImplementation(this.ql); + const protocolShim = new ObjectStackProtocolImplementation( + this.ql, + () => ctx.getServices ? ctx.getServices() : new Map() + ); ctx.registerService('protocol', protocolShim); ctx.logger.info('Protocol service registered'); diff --git a/packages/objectql/src/protocol-discovery.test.ts b/packages/objectql/src/protocol-discovery.test.ts new file mode 100644 index 00000000..dc1f2e15 --- /dev/null +++ b/packages/objectql/src/protocol-discovery.test.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { ObjectQL } from './engine.js'; + +describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => { + let protocol: ObjectStackProtocolImplementation; + let engine: ObjectQL; + + beforeEach(() => { + engine = new ObjectQL(); + }); + + it('should return unavailable auth service when no services registered', async () => { + // Create protocol without service registry + protocol = new ObjectStackProtocolImplementation(engine); + + const discovery = await protocol.getDiscovery(); + + expect(discovery.services.auth).toBeDefined(); + expect(discovery.services.auth.enabled).toBe(false); + expect(discovery.services.auth.status).toBe('unavailable'); + expect(discovery.services.auth.message).toContain('plugin-auth'); + expect(discovery.capabilities.workflow).toBe(false); + }); + + it('should return available auth service when auth is registered', async () => { + // Mock service registry with auth service + const mockServices = new Map(); + mockServices.set('auth', { /* mock auth service */ }); + + protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); + + const discovery = await protocol.getDiscovery(); + + expect(discovery.services.auth).toBeDefined(); + expect(discovery.services.auth.enabled).toBe(true); + expect(discovery.services.auth.status).toBe('available'); + expect(discovery.services.auth.route).toBe('/api/v1/auth'); + expect(discovery.services.auth.provider).toBe('plugin-auth'); + expect(discovery.endpoints.auth).toBe('/api/v1/auth'); + }); + + it('should return available workflow when automation service is registered', async () => { + const mockServices = new Map(); + mockServices.set('automation', { /* mock automation service */ }); + + protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); + + const discovery = await protocol.getDiscovery(); + + expect(discovery.services.automation).toBeDefined(); + expect(discovery.services.automation.enabled).toBe(true); + expect(discovery.services.automation.status).toBe('available'); + expect(discovery.capabilities.workflow).toBe(true); + }); + + it('should return multiple available services when registered', async () => { + const mockServices = new Map(); + mockServices.set('auth', {}); + mockServices.set('realtime', {}); + mockServices.set('ai', {}); + + protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); + + const discovery = await protocol.getDiscovery(); + + // Check auth + expect(discovery.services.auth.enabled).toBe(true); + expect(discovery.services.auth.status).toBe('available'); + + // Check realtime + expect(discovery.services.realtime.enabled).toBe(true); + expect(discovery.services.realtime.status).toBe('available'); + expect(discovery.capabilities.websockets).toBe(true); + + // Check AI + expect(discovery.services.ai.enabled).toBe(true); + expect(discovery.services.ai.status).toBe('available'); + expect(discovery.capabilities.ai).toBe(true); + + // Endpoints should include available services + expect(discovery.endpoints.auth).toBe('/api/v1/auth'); + expect(discovery.endpoints.realtime).toBe('/api/v1/realtime'); + expect(discovery.endpoints.ai).toBe('/api/v1/ai'); + }); + + it('should always show core services as available', async () => { + protocol = new ObjectStackProtocolImplementation(engine); + + const discovery = await protocol.getDiscovery(); + + // Core services should always be available + expect(discovery.services.metadata.enabled).toBe(true); + expect(discovery.services.metadata.status).toBe('degraded'); + expect(discovery.services.data.enabled).toBe(true); + expect(discovery.services.data.status).toBe('available'); + expect(discovery.services.analytics.enabled).toBe(true); + expect(discovery.services.analytics.status).toBe('available'); + + // Core capabilities + expect(discovery.capabilities.analytics).toBe(true); + }); + + it('should map file-storage service to storage endpoint', async () => { + const mockServices = new Map(); + mockServices.set('file-storage', {}); + + protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); + + const discovery = await protocol.getDiscovery(); + + expect(discovery.services['file-storage'].enabled).toBe(true); + expect(discovery.services['file-storage'].status).toBe('available'); + expect(discovery.endpoints.storage).toBe('/api/v1/storage'); + expect(discovery.capabilities.files).toBe(true); + }); + + it('should handle workflow capability from either automation or workflow service', async () => { + // Test with workflow service + const mockServicesWithWorkflow = new Map(); + mockServicesWithWorkflow.set('workflow', {}); + + protocol = new ObjectStackProtocolImplementation(engine, () => mockServicesWithWorkflow); + let discovery = await protocol.getDiscovery(); + expect(discovery.capabilities.workflow).toBe(true); + + // Test with automation service + const mockServicesWithAutomation = new Map(); + mockServicesWithAutomation.set('automation', {}); + + protocol = new ObjectStackProtocolImplementation(engine, () => mockServicesWithAutomation); + discovery = await protocol.getDiscovery(); + expect(discovery.capabilities.workflow).toBe(true); + }); +}); diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index a9bef36b..37bcb83f 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -8,7 +8,7 @@ import type { UpdateManyDataRequest, DeleteManyDataRequest } from '@objectstack/spec/api'; -import type { MetadataCacheRequest, MetadataCacheResponse } from '@objectstack/spec/api'; +import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo } from '@objectstack/spec/api'; // We import SchemaRegistry directly since this class lives in the same package import { SchemaRegistry } from './registry.js'; @@ -27,56 +27,103 @@ function simpleHash(str: string): string { return Math.abs(hash).toString(16); } +/** + * Service Configuration for Discovery + * Maps service names to their routes and plugin providers + */ +const SERVICE_CONFIG: Record = { + auth: { route: '/api/v1/auth', plugin: 'plugin-auth' }, + automation: { route: '/api/v1/automation', plugin: 'plugin-automation', capability: 'workflow' }, + cache: { route: '/api/v1/cache', plugin: 'plugin-redis' }, + queue: { route: '/api/v1/queue', plugin: 'plugin-bullmq' }, + job: { route: '/api/v1/jobs', plugin: 'job-scheduler' }, + ui: { route: '/api/v1/ui', plugin: 'ui-plugin' }, + workflow: { route: '/api/v1/workflow', plugin: 'plugin-workflow', capability: 'workflow' }, + realtime: { route: '/api/v1/realtime', plugin: 'plugin-realtime', capability: 'websockets' }, + notification: { route: '/api/v1/notifications', plugin: 'plugin-notifications', capability: 'notifications' }, + ai: { route: '/api/v1/ai', plugin: 'plugin-ai', capability: 'ai' }, + i18n: { route: '/api/v1/i18n', plugin: 'plugin-i18n', capability: 'i18n' }, + graphql: { route: '/graphql', plugin: 'plugin-graphql', capability: 'graphql' }, + 'file-storage': { route: '/api/v1/storage', plugin: 'plugin-storage', capability: 'files' }, + search: { route: '/api/v1/search', plugin: 'plugin-search', capability: 'search' }, +}; + export class ObjectStackProtocolImplementation implements ObjectStackProtocol { private engine: IDataEngine; + private getServicesRegistry?: () => Map; - constructor(engine: IDataEngine) { + constructor(engine: IDataEngine, getServicesRegistry?: () => Map) { this.engine = engine; + this.getServicesRegistry = getServicesRegistry; } async getDiscovery() { + // Get registered services from kernel if available + const registeredServices = this.getServicesRegistry ? this.getServicesRegistry() : new Map(); + + // Build dynamic service info with proper typing + const services: Record = { + // --- Kernel-provided (objectql is an example kernel implementation) --- + metadata: { enabled: true, status: 'degraded' as const, route: '/api/meta', provider: 'objectql', message: 'In-memory registry only; DB persistence not yet implemented' }, + data: { enabled: true, status: 'available' as const, route: '/api/data', provider: 'objectql' }, + analytics: { enabled: true, status: 'available' as const, route: '/api/analytics', provider: 'objectql' }, + }; + + // Check which services are actually registered + for (const [serviceName, config] of Object.entries(SERVICE_CONFIG)) { + if (registeredServices.has(serviceName)) { + // Service is registered and available + services[serviceName] = { + enabled: true, + status: 'available' as const, + route: config.route, + provider: config.plugin, + }; + } else { + // Service is not registered + services[serviceName] = { + enabled: false, + status: 'unavailable' as const, + message: `Install ${config.plugin} to enable`, + }; + } + } + + // Build capabilities based on available services + const capabilities = { + graphql: registeredServices.has('graphql'), + search: registeredServices.has('search'), + websockets: registeredServices.has('realtime'), + files: registeredServices.has('file-storage'), + analytics: true, // Always available via objectql + ai: registeredServices.has('ai'), + workflow: registeredServices.has('workflow') || registeredServices.has('automation'), + notifications: registeredServices.has('notification'), + i18n: registeredServices.has('i18n'), + }; + + // Build endpoints (only include available services) + const endpoints: Record = { + data: '/api/data', + metadata: '/api/meta', + analytics: '/api/analytics', + }; + + // Add routes for available plugin services + for (const [serviceName, config] of Object.entries(SERVICE_CONFIG)) { + if (registeredServices.has(serviceName)) { + // Map service name to endpoint key (some services use different names) + const endpointKey = serviceName === 'file-storage' ? 'storage' : serviceName; + endpoints[endpointKey] = config.route; + } + } + return { version: '1.0', apiName: 'ObjectStack API', - capabilities: { - graphql: false, - search: false, - websockets: false, - files: false, - analytics: true, - ai: false, - workflow: false, - notifications: false, - i18n: false, - }, - endpoints: { - data: '/api/data', - metadata: '/api/meta', - analytics: '/api/analytics', - }, - services: { - // --- Kernel-provided (objectql is an example kernel implementation) --- - metadata: { enabled: true, status: 'degraded' as const, route: '/api/meta', provider: 'objectql', message: 'In-memory registry only; DB persistence not yet implemented' }, - data: { enabled: true, status: 'available' as const, route: '/api/data', provider: 'objectql' }, - analytics: { enabled: true, status: 'available' as const, route: '/api/analytics', provider: 'objectql' }, - // --- Plugin-provided (kernel does NOT handle these) --- - auth: { enabled: false, status: 'unavailable' as const, message: 'Install an auth plugin (e.g. plugin-auth) to enable' }, - automation: { enabled: false, status: 'unavailable' as const, message: 'Install an automation plugin (e.g. plugin-automation) to enable' }, - // --- Core infrastructure (plugin-provided) --- - cache: { enabled: false, status: 'unavailable' as const, message: 'Install a cache plugin (e.g. plugin-redis) to enable' }, - queue: { enabled: false, status: 'unavailable' as const, message: 'Install a queue plugin (e.g. plugin-bullmq) to enable' }, - job: { enabled: false, status: 'unavailable' as const, message: 'Install a job scheduler plugin to enable' }, - // --- Optional services (all plugin-provided) --- - ui: { enabled: false, status: 'unavailable' as const, message: 'Install a UI plugin to enable' }, - workflow: { enabled: false, status: 'unavailable' as const, message: 'Install a workflow plugin to enable' }, - realtime: { enabled: false, status: 'unavailable' as const, message: 'Install a realtime plugin to enable' }, - notification: { enabled: false, status: 'unavailable' as const, message: 'Install a notification plugin to enable' }, - ai: { enabled: false, status: 'unavailable' as const, message: 'Install an AI plugin to enable' }, - i18n: { enabled: false, status: 'unavailable' as const, message: 'Install an i18n plugin to enable' }, - graphql: { enabled: false, status: 'unavailable' as const, message: 'Install a GraphQL plugin to enable' }, - 'file-storage': { enabled: false, status: 'unavailable' as const, message: 'Install a file-storage plugin to enable' }, - search: { enabled: false, status: 'unavailable' as const, message: 'Install a search plugin to enable' }, - }, + capabilities, + endpoints, + services, }; } diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index b5cb67f9..361cd5ba 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { ViewSchema } from '../ui/view.zod'; -import { ApiCapabilitiesSchema, ApiRoutesSchema } from './discovery.zod'; +import { ApiCapabilitiesSchema, ApiRoutesSchema, ServiceInfoSchema } from './discovery.zod'; import { BatchUpdateRequestSchema, BatchUpdateResponseSchema, BatchOptionsSchema } from './batch.zod'; import { MetadataCacheRequestSchema, MetadataCacheResponseSchema } from './http-cache.zod'; import { QuerySchema } from '../data/query.zod'; @@ -95,6 +95,7 @@ export const GetDiscoveryResponseSchema = z.object({ apiName: z.string().describe('API name'), capabilities: ApiCapabilitiesSchema.optional().describe('Supported features/capabilities'), endpoints: ApiRoutesSchema.optional().describe('Available endpoint paths'), + services: z.record(z.string(), ServiceInfoSchema).optional().describe('Per-service availability map'), }); /**