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
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ This strategy ensures rapid iteration while maintaining a clear path to producti
| `z.unknown()` in extensibility fields | ✅ Justified | `properties`, `children`, `context`, `options`, `body` — inherently dynamic extensibility points |
| DashboardWidget discriminated union by type | 🔴 | Planned — chart/metric/pivot subtypes with type-specific required fields |
| CI lint rule rejecting new `z.any()` | 🔴 | Planned — eslint or custom lint rule to block `z.any()` additions |
| Dispatcher async `getService` bug fix | ✅ | All `getService`/`getObjectQLService` calls in `http-dispatcher.ts` now properly `await` async service factories. Covers `handleAnalytics`, `handleAuth`, `handleStorage`, `handleAutomation`, `handleMetadata`, `handleUi`, `handlePackages`. 20 new tests for async/sync/error scenarios. |
| Dispatcher async `getService` bug fix | ✅ | All `getService`/`getObjectQLService` calls in `http-dispatcher.ts` now properly `await` async service factories. Covers `handleAnalytics`, `handleAuth`, `handleStorage`, `handleAutomation`, `handleMetadata`, `handleUi`, `handlePackages`. All 7 framework adapters (Express, Fastify, Hono, Next.js, SvelteKit, NestJS, Nuxt) updated to use `getServiceAsync()` for auth service resolution. |

---

Expand Down
16 changes: 12 additions & 4 deletions packages/adapters/express/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,18 @@ export function createExpressRouter(options: ExpressAdapterOptions): Router {
const path = (req.params as any).path;
const method = req.method;

// Try AuthPlugin service first
const authService = typeof options.kernel.getService === 'function'
? options.kernel.getService<AuthService>('auth')
: null;
// Try AuthPlugin service first (prefer async to support factory-based services)
let authService: AuthService | null = null;
try {
if (typeof options.kernel.getServiceAsync === 'function') {
authService = await options.kernel.getServiceAsync<AuthService>('auth');
} else if (typeof options.kernel.getService === 'function') {
authService = options.kernel.getService<AuthService>('auth');
}
Comment on lines +87 to +91

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

The Express adapter changed auth resolution to prefer kernel.getServiceAsync(), but the adapter test suite doesn’t cover this branch. Add a test that supplies getServiceAsync('auth') and asserts it is used (and another that asserts fallthrough when getServiceAsync throws/returns null) to prevent regressions.

Copilot uses AI. Check for mistakes.
} catch {
// Service not registered — fall through to dispatcher
Comment on lines +92 to +93

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

This catch-all treats any error during auth service resolution as "service not registered" and falls back to the dispatcher. That can mask genuine auth factory/initialization failures. Consider only swallowing expected lookup errors (e.g. not-found / "is async - use await") and rethrow unexpected errors so failures surface as 500s.

Suggested change
} catch {
// Service not registered — fall through to dispatcher
} catch (err) {
const message =
err instanceof Error
? err.message
: typeof err === 'string'
? err
: String(err);
const isNotRegistered =
message.includes('not registered') ||
message.includes('not found') ||
message.includes('service "auth"');
const isAsyncHint =
message.includes('is async - use await') ||
message.includes('use getServiceAsync');
if (!isNotRegistered && !isAsyncHint) {
// Unexpected error during auth service resolution — surface as 500
throw err;
}
// Service not registered / not available — fall through to dispatcher

Copilot uses AI. Check for mistakes.
authService = null;
}

if (authService && typeof authService.handleRequest === 'function') {
const protocol = req.protocol || 'http';
Expand Down
16 changes: 12 additions & 4 deletions packages/adapters/fastify/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,18 @@ export async function objectStackPlugin(fastify: FastifyInstance, options: Fasti
const path = request.url.substring(`${prefix}/auth/`.length).split('?')[0];
const method = request.method;

// Try AuthPlugin service first
const authService = typeof options.kernel.getService === 'function'
? options.kernel.getService<AuthService>('auth')
: null;
// Try AuthPlugin service first (prefer async to support factory-based services)
let authService: AuthService | null = null;
try {
if (typeof options.kernel.getServiceAsync === 'function') {
authService = await options.kernel.getServiceAsync<AuthService>('auth');
} else if (typeof options.kernel.getService === 'function') {
authService = options.kernel.getService<AuthService>('auth');
}
Comment on lines +90 to +94

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

Fastify adapter tests exist, but they don’t cover the new auth resolution branch that prefers kernel.getServiceAsync(). Add a test that provides getServiceAsync('auth') and asserts it is used, plus a fallthrough test when getServiceAsync throws/returns null.

Copilot uses AI. Check for mistakes.
} catch {
// Service not registered — fall through to dispatcher
authService = null;
}
Comment on lines +95 to +98

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

This catch-all treats any error during auth service resolution as "service not registered" and falls back to the dispatcher, which can mask real auth factory/initialization failures. Consider only swallowing expected lookup errors (not found / "is async") and rethrow unexpected errors so failures surface correctly.

Copilot uses AI. Check for mistakes.

if (authService && typeof authService.handleRequest === 'function') {
const protocol = request.protocol || 'http';
Expand Down
37 changes: 37 additions & 0 deletions packages/adapters/hono/src/hono.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,43 @@ describe('createHonoApp', () => {
expect(res.status).toBe(200);
expect(mockDispatcher.handleAuth).toHaveBeenCalled();
});

it('uses kernel.getServiceAsync("auth") when available (async factory)', async () => {
const mockHandleRequest = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ user: { id: '2' } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const kernelWithAsyncAuth = {
...mockKernel,
getServiceAsync: vi.fn().mockResolvedValue({ handleRequest: mockHandleRequest }),
};
const authApp = createHonoApp({ kernel: kernelWithAsyncAuth });
const res = await authApp.request('/api/auth/sign-in/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'a@b.com', password: 'pass' }),
});
expect(res.status).toBe(200);
expect(kernelWithAsyncAuth.getServiceAsync).toHaveBeenCalledWith('auth');
expect(mockHandleRequest).toHaveBeenCalled();
});

it('falls back to dispatcher when getServiceAsync throws (async factory error)', async () => {
const kernelWithFailingAsync = {
...mockKernel,
getServiceAsync: vi.fn().mockRejectedValue(new Error("Service 'auth' not found")),
};
const authApp = createHonoApp({ kernel: kernelWithFailingAsync });
const res = await authApp.request('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'a@b.com' }),
});
expect(res.status).toBe(200);
expect(mockDispatcher.handleAuth).toHaveBeenCalled();
});
});

describe('GraphQL Endpoint', () => {
Expand Down
16 changes: 12 additions & 4 deletions packages/adapters/hono/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,18 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
const path = c.req.path.substring(`${prefix}/auth/`.length);
const method = c.req.method;

// Try AuthPlugin service first (preferred path)
const authService = typeof options.kernel.getService === 'function'
? options.kernel.getService<AuthService>('auth')
: null;
// Try AuthPlugin service first (prefer async to support factory-based services)
let authService: AuthService | null = null;
try {
if (typeof options.kernel.getServiceAsync === 'function') {
authService = await options.kernel.getServiceAsync<AuthService>('auth');
} else if (typeof options.kernel.getService === 'function') {
authService = options.kernel.getService<AuthService>('auth');
}
} catch {

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

This catch-all treats any error during auth service resolution as "service not registered" and falls back to the dispatcher. That can hide real auth factory/initialization failures. Consider only swallowing expected lookup errors (not found / "is async") and rethrow unexpected errors so failures surface as 500s.

Suggested change
} catch {
} catch (err: any) {
// Only treat known "service not registered / async lookup" errors as absence;
// rethrow unexpected errors so they surface as 500s via the outer handler.
const message =
err && typeof err.message === 'string' ? err.message.toLowerCase() : '';
const isLookupError =
err?.code === 'SERVICE_NOT_FOUND' ||
err?.code === 'SERVICE_NOT_REGISTERED' ||
err?.name === 'ServiceNotFoundError' ||
message.includes('service not found') ||
message.includes('service "auth" not found') ||
message.includes('service auth not found') ||
message.includes('service not registered') ||
message.includes('no auth service registered') ||
message.includes('service lookup is async') ||
message.includes('use getserviceasync') ||
message.includes('is async');
if (!isLookupError) {
throw err;
}

Copilot uses AI. Check for mistakes.
// Service not registered — fall through to dispatcher
authService = null;
}

if (authService && typeof authService.handleRequest === 'function') {
const response = await authService.handleRequest(c.req.raw);
Expand Down
16 changes: 12 additions & 4 deletions packages/adapters/nestjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,19 @@ export class ObjectStackController {
@All('auth/*')
async auth(@Req() req: any, @Res() res: any, @Body() body: any) {
try {
// Try AuthPlugin service first (preferred path)
// Try AuthPlugin service first (prefer async to support factory-based services)
const kernel = this.service.getKernel();
const authService = typeof kernel.getService === 'function'
? kernel.getService<AuthService>('auth')
: null;
let authService: AuthService | null = null;
try {
if (typeof kernel.getServiceAsync === 'function') {
authService = await kernel.getServiceAsync<AuthService>('auth');
} else if (typeof kernel.getService === 'function') {
authService = kernel.getService<AuthService>('auth');
}
Comment on lines +120 to +124

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

NestJS adapter has tests, but they don’t cover the new auth resolution branch that prefers kernel.getServiceAsync(). Add a test that supplies getServiceAsync('auth') and asserts it is called (and a fallthrough test when it throws/returns null) to prevent regressions.

Copilot uses AI. Check for mistakes.
} catch {
// Service not registered — fall through to legacy dispatcher
authService = null;
Comment on lines +125 to +127

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

This catch-all treats any error during auth service resolution as "service not registered" and falls back to the dispatcher, which can mask real auth factory/initialization failures. Consider only swallowing expected lookup errors (not found / "is async") and rethrow unexpected errors so failures surface correctly.

Suggested change
} catch {
// Service not registered — fall through to legacy dispatcher
authService = null;
} catch (error: any) {
const message = typeof error?.message === 'string' ? error.message : '';
const isNotRegistered =
message.includes('not registered') || message.includes('not found');
const isAsyncHint = message.includes('is async');
if (isNotRegistered || isAsyncHint) {
// Auth service not registered or only available asynchronously —
// fall through to legacy dispatcher.
authService = null;
} else {
// Unexpected error during auth service resolution — surface it.
throw error;
}

Copilot uses AI. Check for mistakes.
}

if (authService && typeof authService.handleRequest === 'function') {
// Construct a Web standard Request from the Express/Fastify request
Expand Down
16 changes: 12 additions & 4 deletions packages/adapters/nextjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,18 @@ export function createRouteHandler(options: NextAdapterOptions) {

// --- 1. Auth ---
if (segments[0] === 'auth') {
// Try AuthPlugin service first (preferred path)
const authService = typeof options.kernel.getService === 'function'
? options.kernel.getService<AuthService>('auth')
: null;
// Try AuthPlugin service first (prefer async to support factory-based services)
let authService: AuthService | null = null;
try {
if (typeof options.kernel.getServiceAsync === 'function') {
authService = await options.kernel.getServiceAsync<AuthService>('auth');
} else if (typeof options.kernel.getService === 'function') {
authService = options.kernel.getService<AuthService>('auth');
}
} catch {
// Service not registered — fall through to dispatcher
authService = null;
Comment on lines +79 to +81

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

This catch-all treats any error during auth service resolution as "service not registered" and falls back to the dispatcher. That fixes the async-factory sync-call case, but it can also mask genuine auth factory/initialization failures. Consider only swallowing expected lookup errors and rethrow unexpected errors so failures remain observable.

Suggested change
} catch {
// Service not registered — fall through to dispatcher
authService = null;
} catch (err) {
// Only treat expected "service not registered" lookup errors as absence.
const isLookupError =
err instanceof Error &&
typeof err.message === 'string' &&
/not\s+(registered|found)/i.test(err.message);
if (isLookupError) {
// Service not registered — fall through to dispatcher
authService = null;
} else {
// Unexpected error during auth service resolution: rethrow so it remains observable
throw err;
}

Copilot uses AI. Check for mistakes.
}

if (authService && typeof authService.handleRequest === 'function') {
const response = await authService.handleRequest(req);
Expand Down
31 changes: 31 additions & 0 deletions packages/adapters/nextjs/src/nextjs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,37 @@ describe('createRouteHandler', () => {
expect(res.status).toBe(500);
expect(res.body.success).toBe(false);
});

it('uses kernel.getServiceAsync("auth") when available (async factory)', async () => {
const mockHandleRequest = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ user: { id: '2' } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const kernelWithAsyncAuth = {
...mockKernel,
getServiceAsync: vi.fn().mockResolvedValue({ handleRequest: mockHandleRequest }),
};
const handler = createRouteHandler({ kernel: kernelWithAsyncAuth });
const req = makeReq('http://localhost/api/auth/sign-in/email', 'POST', { email: 'a@b.com', password: 'pass' });
const res = await handler(req, { params: { objectstack: ['auth', 'sign-in', 'email'] } });
expect(res.status).toBe(200);
expect(kernelWithAsyncAuth.getServiceAsync).toHaveBeenCalledWith('auth');
expect(mockHandleRequest).toHaveBeenCalled();
});

it('falls back to dispatcher when getServiceAsync throws', async () => {
const kernelWithFailingAsync = {
...mockKernel,
getServiceAsync: vi.fn().mockRejectedValue(new Error("Service 'auth' not found")),
};
const handler = createRouteHandler({ kernel: kernelWithFailingAsync });
const req = makeReq('http://localhost/api/auth/login', 'POST', { email: 'a@b.com' });
const res = await handler(req, { params: { objectstack: ['auth', 'login'] } });
expect(res.status).toBe(200);
expect(mockDispatcher.handleAuth).toHaveBeenCalled();
});
});

describe('GraphQL Endpoint', () => {
Expand Down
16 changes: 12 additions & 4 deletions packages/adapters/nuxt/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,18 @@ export function createH3Router(options: NuxtAdapterOptions): Router {
const path = urlPath.substring(`${prefix}/auth/`.length).split('?')[0];
const method = event.method;

// Try AuthPlugin service first
const authService = typeof options.kernel.getService === 'function'
? options.kernel.getService<AuthService>('auth')
: null;
// Try AuthPlugin service first (prefer async to support factory-based services)
let authService: AuthService | null = null;
try {
if (typeof options.kernel.getServiceAsync === 'function') {
authService = await options.kernel.getServiceAsync<AuthService>('auth');
} else if (typeof options.kernel.getService === 'function') {
authService = options.kernel.getService<AuthService>('auth');
}
Comment on lines +110 to +114

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

Nuxt adapter has tests, but they don’t cover the new auth resolution branch that prefers kernel.getServiceAsync(). Add a test that supplies getServiceAsync('auth') and asserts the auth handler is used, plus a fallthrough test when getServiceAsync throws/returns null.

Copilot uses AI. Check for mistakes.
} catch {
// Service not registered — fall through to dispatcher
authService = null;
Comment on lines +115 to +117

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

This catch-all treats any error during auth service resolution as "service not registered" and falls back to the dispatcher. That can mask genuine auth factory/initialization failures. Consider only swallowing expected lookup errors (not found / "is async") and rethrow unexpected errors so failures surface as 500s.

Suggested change
} catch {
// Service not registered — fall through to dispatcher
authService = null;
} catch (err: any) {
const message = typeof err?.message === 'string' ? err.message : '';
const lowerMessage = message.toLowerCase();
const isLookupError =
lowerMessage.includes('not registered') ||
lowerMessage.includes('not found') ||
lowerMessage.includes('is async');
if (isLookupError) {
// Service not registered or only available asynchronously — fall through to dispatcher
authService = null;
} else {
// Unexpected error — rethrow so it surfaces as a 500
throw err;
}

Copilot uses AI. Check for mistakes.
}

if (authService && typeof authService.handleRequest === 'function') {
const host = event.node.req.headers.host || 'localhost';
Expand Down
16 changes: 12 additions & 4 deletions packages/adapters/sveltekit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,18 @@ export function createRequestHandler(options: SvelteKitAdapterOptions) {
if (segments[0] === 'auth') {
const subPath = segments.slice(1).join('/');

// Try AuthPlugin service first
const authService = typeof options.kernel.getService === 'function'
? options.kernel.getService<AuthService>('auth')
: null;
// Try AuthPlugin service first (prefer async to support factory-based services)
let authService: AuthService | null = null;
try {
if (typeof options.kernel.getServiceAsync === 'function') {
authService = await options.kernel.getServiceAsync<AuthService>('auth');
} else if (typeof options.kernel.getService === 'function') {
authService = options.kernel.getService<AuthService>('auth');
}
} catch {
// Service not registered — fall through to dispatcher
authService = null;
Comment on lines +119 to +121

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

This catch-all treats any error during auth service resolution as "service not registered" and falls back to the dispatcher, which can mask real auth factory/initialization failures. Consider only swallowing expected lookup errors (not found / "is async") and rethrow unexpected errors so failures surface correctly.

Suggested change
} catch {
// Service not registered — fall through to dispatcher
authService = null;
} catch (error) {
// Only swallow expected lookup errors; rethrow unexpected failures
if (error instanceof Error) {
const message = error.message || '';
if (message.includes('not registered') || message.includes('is async')) {
// Service not registered or requires async access — fall through to dispatcher
authService = null;
} else {
throw error;
}
} else {
throw error;
}

Copilot uses AI. Check for mistakes.
}

if (authService && typeof authService.handleRequest === 'function') {
return await authService.handleRequest(request);
Expand Down
31 changes: 31 additions & 0 deletions packages/adapters/sveltekit/src/sveltekit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,37 @@ describe('createRequestHandler', () => {
expect(kernelWithAuth.getService).toHaveBeenCalledWith('auth');
expect(mockHandleRequest).toHaveBeenCalled();
});

it('uses kernel.getServiceAsync("auth") when available (async factory)', async () => {
const mockHandleRequest = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ user: { id: '2' } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const kernelWithAsyncAuth = {
...mockKernel,
getServiceAsync: vi.fn().mockResolvedValue({ handleRequest: mockHandleRequest }),
};
const authHandler = createRequestHandler({ kernel: kernelWithAsyncAuth });
const event = makeEvent('http://localhost/api/auth/sign-in/email', 'POST', { email: 'a@b.com' });
const res = await authHandler(event);
expect(res.status).toBe(200);
expect(kernelWithAsyncAuth.getServiceAsync).toHaveBeenCalledWith('auth');
expect(mockHandleRequest).toHaveBeenCalled();
});

it('falls back to dispatcher when getServiceAsync throws', async () => {
const kernelWithFailingAsync = {
...mockKernel,
getServiceAsync: vi.fn().mockRejectedValue(new Error("Service 'auth' not found")),
};
const authHandler = createRequestHandler({ kernel: kernelWithFailingAsync });
const event = makeEvent('http://localhost/api/auth/login', 'POST', { email: 'a@b.com' });
const res = await authHandler(event);
expect(res.status).toBe(200);
expect(mockDispatcher.handleAuth).toHaveBeenCalled();
});
});

describe('GraphQL', () => {
Expand Down
Loading