Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions examples/minimal-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README claims src/test-discovery.ts shows discovery “before the plugin is registered” and then “after registration”, but with ObjectKernel you can’t add plugins after bootstrap(), and without bootstrap() the protocol service won’t exist. Once the script is adjusted (e.g. two kernels / LiteKernel), this section should be updated to reflect the actual steps needed to run the discovery demo successfully.

Suggested change
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.
This test runs API discovery twice: first against a kernel **without** `plugin-auth` registered, and then against a kernel **with** `plugin-auth` registered. In the first case, `discovery.services.auth.status` is `"unavailable"`. In the second case, it becomes `"available"` with the proper route information.

Copilot uses AI. Check for mistakes.

## Usage

### Using the ObjectStack Client
Expand Down Expand Up @@ -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:
Expand Down
73 changes: 73 additions & 0 deletions examples/minimal-auth/src/test-discovery.ts
Original file line number Diff line number Diff line change
@@ -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',
}));
Comment on lines +23 to +42
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This script won’t behave as described with ObjectKernel: kernel.use(...) only registers plugins and ObjectQLPlugin doesn’t register the protocol service until kernel.bootstrap() runs, so kernel.getService('protocol') will throw here. Additionally, after calling bootstrap(), you can’t use() additional plugins, and AuthPlugin also depends on com.objectstack.server.hono (so bootstrap would fail without registering HonoServerPlugin). To demonstrate before/after discovery, consider using two separate kernels (one without AuthPlugin, one with it) and calling bootstrap() for each, or use LiteKernel/skipSystemValidation to allow running without required services.

Copilot uses AI. Check for mistakes.

// 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);
});
5 changes: 4 additions & 1 deletion packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
137 changes: 137 additions & 0 deletions packages/objectql/src/protocol-discovery.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>();
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<string, any>();
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<string, any>();
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<string, any>();
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);
});
Comment on lines +106 to +118
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dynamic endpoint key mapping is tested for file-storage -> storage, but there’s no test covering the analogous notification -> notifications route-key mismatch (which is easy to regress and breaks client routing). Adding a test case for notifications would lock in spec/client compatibility.

Copilot uses AI. Check for mistakes.

it('should handle workflow capability from either automation or workflow service', async () => {
// Test with workflow service
const mockServicesWithWorkflow = new Map<string, any>();
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<string, any>();
mockServicesWithAutomation.set('automation', {});

protocol = new ObjectStackProtocolImplementation(engine, () => mockServicesWithAutomation);
discovery = await protocol.getDiscovery();
expect(discovery.capabilities.workflow).toBe(true);
});
});
Loading
Loading