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
71 changes: 71 additions & 0 deletions packages/adapters/hono/src/hono.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,77 @@ describe('createHonoApp', () => {
});
});

describe('Auth via AuthPlugin service', () => {
it('uses kernel.getService("auth") when available', async () => {
const mockHandleRequest = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ user: { id: '1' } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const kernelWithAuth = {
...mockKernel,
getService: vi.fn().mockReturnValue({ handleRequest: mockHandleRequest }),
};
const app = createHonoApp({ kernel: kernelWithAuth });
const res = await app.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);
const json = await res.json();
expect(json.user.id).toBe('1');
expect(kernelWithAuth.getService).toHaveBeenCalledWith('auth');
expect(mockHandleRequest).toHaveBeenCalledWith(expect.any(Request));
expect(mockDispatcher.handleAuth).not.toHaveBeenCalled();
});

it('falls back to dispatcher.handleAuth when auth service is not available', async () => {
const kernelWithoutAuth = {
...mockKernel,
getService: vi.fn().mockReturnValue(null),
};
const app = createHonoApp({ kernel: kernelWithoutAuth });
const res = await app.request('/api/auth/login', { method: 'POST' });
expect(res.status).toBe(200);
expect(mockDispatcher.handleAuth).toHaveBeenCalled();
});

it('forwards GET requests to auth service', async () => {
const mockHandleRequest = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ session: { token: 'abc' } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const kernelWithAuth = {
...mockKernel,
getService: vi.fn().mockReturnValue({ handleRequest: mockHandleRequest }),
};
const app = createHonoApp({ kernel: kernelWithAuth });
const res = await app.request('/api/auth/get-session', { method: 'GET' });
expect(res.status).toBe(200);
const json = await res.json();
expect(json.session.token).toBe('abc');
expect(mockHandleRequest).toHaveBeenCalled();
});

it('returns error when auth service throws', async () => {
const mockHandleRequest = vi.fn().mockRejectedValue(new Error('Auth failed'));
const kernelWithAuth = {
...mockKernel,
getService: vi.fn().mockReturnValue({ handleRequest: mockHandleRequest }),
};
const app = createHonoApp({ kernel: kernelWithAuth });
const res = await app.request('/api/auth/sign-in/email', { method: 'POST' });
expect(res.status).toBe(500);
const json = await res.json();
expect(json.success).toBe(false);
expect(json.error.message).toBe('Auth failed');
});
});

describe('GraphQL Endpoint', () => {
it('POST /api/graphql calls handleGraphQL', async () => {
const app = createHonoApp({ kernel: mockKernel });
Expand Down
12 changes: 11 additions & 1 deletion packages/adapters/hono/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,17 @@ export function createHonoApp(options: ObjectStackHonoOptions) {
// --- 1. Auth ---
app.all(`${prefix}/auth/*`, async (c) => {
try {
// subpath from /api/auth/login -> login
// Try AuthPlugin service first (preferred path)
const authService = typeof options.kernel.getService === 'function'
? options.kernel.getService('auth')
: null;
Comment on lines +69 to +72
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 same AuthPlugin-detection logic (checking kernel.getService('auth') and handleRequest) is now duplicated across adapters. To reduce drift, consider extracting a small shared helper (e.g., in @objectstack/runtime or a local adapters util) that returns an auth handler or null, and reuse it here.

Copilot uses AI. Check for mistakes.

if (authService && typeof authService.handleRequest === 'function') {
const response = await authService.handleRequest(c.req.raw);
return response;
}

// Fallback to legacy dispatcher
const path = c.req.path.substring(c.req.path.indexOf('/auth/') + 6);
const body = await c.req.parseBody().catch(() => ({}));

Expand Down
36 changes: 36 additions & 0 deletions packages/adapters/nestjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,42 @@ export class ObjectStackController {
@All('auth/*')
async auth(@Req() req: any, @Res() res: any, @Body() body: any) {
try {
// Try AuthPlugin service first (preferred path)
const kernel = this.service.getKernel();
const authService = typeof kernel.getService === 'function'
? kernel.getService('auth')
: null;

if (authService && typeof authService.handleRequest === 'function') {
// Construct a Web standard Request from the Express/Fastify request
const protocol = req.protocol || 'http';
const host = req.get?.('host') || req.headers?.host || 'localhost';
const url = `${protocol}://${host}${req.originalUrl || req.url}`;
const headers = new Headers();
if (req.headers) {
Object.entries(req.headers).forEach(([k, v]) => {
if (typeof v === 'string') headers.set(k, v);
else if (Array.isArray(v)) headers.set(k, v.join(', '));
});
}
const init: RequestInit = { method: req.method, headers };
if (req.method !== 'GET' && req.method !== 'HEAD' && body) {
init.body = JSON.stringify(body);
if (!headers.has('content-type')) {
headers.set('content-type', 'application/json');
}
}
const webRequest = new Request(url, init);
const response = await authService.handleRequest(webRequest);

// Convert Web Response to Express/Fastify response
res.status(response.status);
response.headers.forEach((v: string, k: string) => res.setHeader(k, v));
const text = await response.text();
return res.send(text);
Comment on lines +137 to +141
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.

When piping the AuthPlugin Response back to res, using response.headers.forEach((v,k)=>res.setHeader(k,v)) plus await response.text() will (1) lose streaming/binary bodies and (2) mishandle multi-value headers like set-cookie (only one value survives / may be comma-joined depending on fetch impl). Consider forwarding response.body as a stream when available and ensure set-cookie is set as an array / appended per value.

Copilot uses AI. Check for mistakes.
}

// Fallback to legacy dispatcher
const path = req.params[0] || req.url.split('/auth/')[1]?.split('?')[0] || '';
const result = await this.service.dispatcher.handleAuth(path, req.method, body, { request: req, response: res });
return this.normalizeResponse(result, res);
Expand Down
103 changes: 103 additions & 0 deletions packages/adapters/nestjs/src/nestjs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,109 @@ describe('ObjectStackController', () => {
});
});

describe('auth() via AuthPlugin service', () => {
it('uses kernel.getService("auth") when available', async () => {
const mockHandleRequest = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ user: { id: '1' } }), {
status: 200,
headers: new Headers({ 'Content-Type': 'application/json' }),
}),
);
const kernelWithAuth = {
...createMockKernel(),
getService: vi.fn().mockReturnValue({ handleRequest: mockHandleRequest }),
};
const svc = new ObjectStackService(kernelWithAuth);
const ctrl = new ObjectStackController(svc);
const r = createMockRes();
const req = {
params: { 0: 'sign-in/email' },
url: '/api/auth/sign-in/email',
method: 'POST',
protocol: 'http',
get: (key: string) => key === 'host' ? 'localhost' : undefined,
headers: { 'content-type': 'application/json' },
originalUrl: '/api/auth/sign-in/email',
};

await ctrl.auth(req, r, { email: 'a@b.com', password: 'pass' });

expect(kernelWithAuth.getService).toHaveBeenCalledWith('auth');
expect(mockHandleRequest).toHaveBeenCalledWith(expect.any(Request));
expect(r._status).toBe(200);
});

it('falls back to dispatcher when auth service is not available', async () => {
const kernelWithoutAuth = {
...createMockKernel(),
getService: vi.fn().mockReturnValue(null),
};
const svc = new ObjectStackService(kernelWithoutAuth);
const ctrl = new ObjectStackController(svc);
const r = createMockRes();
const req = { params: { 0: 'login' }, url: '/api/auth/login', method: 'POST' };

await ctrl.auth(req, r, { email: 'a@b.com' });

expect(svc.dispatcher.handleAuth).toHaveBeenCalled();
});

it('forwards GET requests to auth service', async () => {
const mockHandleRequest = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ session: { token: 'abc' } }), {
status: 200,
headers: new Headers({ 'Content-Type': 'application/json' }),
}),
);
const kernelWithAuth = {
...createMockKernel(),
getService: vi.fn().mockReturnValue({ handleRequest: mockHandleRequest }),
};
const svc = new ObjectStackService(kernelWithAuth);
const ctrl = new ObjectStackController(svc);
const r = createMockRes();
const req = {
params: { 0: 'get-session' },
url: '/api/auth/get-session',
method: 'GET',
protocol: 'http',
get: (key: string) => key === 'host' ? 'localhost' : undefined,
headers: {},
originalUrl: '/api/auth/get-session',
};

await ctrl.auth(req, r, {});

expect(mockHandleRequest).toHaveBeenCalled();
expect(r._status).toBe(200);
});

it('returns error when auth service throws', async () => {
const mockHandleRequest = vi.fn().mockRejectedValue(new Error('Auth failed'));
const kernelWithAuth = {
...createMockKernel(),
getService: vi.fn().mockReturnValue({ handleRequest: mockHandleRequest }),
};
const svc = new ObjectStackService(kernelWithAuth);
const ctrl = new ObjectStackController(svc);
const r = createMockRes();
const req = {
params: { 0: 'sign-in/email' },
url: '/api/auth/sign-in/email',
method: 'POST',
protocol: 'http',
get: (key: string) => key === 'host' ? 'localhost' : undefined,
headers: {},
originalUrl: '/api/auth/sign-in/email',
};

await ctrl.auth(req, r, {});

expect(r._status).toBe(500);
expect(r._body.success).toBe(false);
});
});

describe('metadata()', () => {
it('dispatches to handleMetadata with extracted path', async () => {
const req = { params: { 0: '' }, url: '/api/meta/objects', method: 'GET' };
Expand Down
15 changes: 15 additions & 0 deletions packages/adapters/nextjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@ 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('auth')
: null;

if (authService && typeof authService.handleRequest === 'function') {
const response = await authService.handleRequest(req);
// Convert Web Response to NextResponse
const body = await response.text();
const headers: Record<string, string> = {};
response.headers.forEach((v: string, k: string) => { headers[k] = v; });
return new NextResponse(body, { status: response.status, headers });
Comment on lines +71 to +75
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.

Converting the Web Response to NextResponse via await response.text() and a Record<string,string> drops streaming bodies and cannot preserve multi-value headers (notably set-cookie, which auth flows commonly rely on). Prefer returning a NextResponse backed by response.body and forward headers using a Headers/HeadersInit that preserves duplicates.

Suggested change
// Convert Web Response to NextResponse
const body = await response.text();
const headers: Record<string, string> = {};
response.headers.forEach((v: string, k: string) => { headers[k] = v; });
return new NextResponse(body, { status: response.status, headers });
// Convert Web Response to NextResponse while preserving streaming body and multi-value headers
const headers = new Headers(response.headers);
return new NextResponse(response.body, {
status: response.status,
headers,
});

Copilot uses AI. Check for mistakes.
}

// Fallback to legacy dispatcher
const subPath = segments.slice(1).join('/');
const body = method === 'POST' ? await req.json().catch(() => ({})) : {};
const result = await dispatcher.handleAuth(subPath, method, body, { request: req });
Expand Down
65 changes: 65 additions & 0 deletions packages/adapters/nextjs/src/nextjs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,71 @@ describe('createRouteHandler', () => {
});
});

describe('Auth via AuthPlugin service', () => {
it('uses kernel.getService("auth") when available', async () => {
const mockHandleRequest = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ user: { id: '1' } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const kernelWithAuth = {
...mockKernel,
getService: vi.fn().mockReturnValue({ handleRequest: mockHandleRequest }),
};
const handler = createRouteHandler({ kernel: kernelWithAuth });
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(kernelWithAuth.getService).toHaveBeenCalledWith('auth');
expect(mockHandleRequest).toHaveBeenCalled();
expect(mockDispatcher.handleAuth).not.toHaveBeenCalled();
});

it('falls back to dispatcher.handleAuth when auth service is not available', async () => {
const kernelWithoutAuth = {
...mockKernel,
getService: vi.fn().mockReturnValue(null),
};
const handler = createRouteHandler({ kernel: kernelWithoutAuth });
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();
});

it('forwards GET requests to auth service', async () => {
const mockHandleRequest = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ session: { token: 'abc' } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const kernelWithAuth = {
...mockKernel,
getService: vi.fn().mockReturnValue({ handleRequest: mockHandleRequest }),
};
const handler = createRouteHandler({ kernel: kernelWithAuth });
const req = makeReq('http://localhost/api/auth/get-session', 'GET');
const res = await handler(req, { params: { objectstack: ['auth', 'get-session'] } });
expect(res.status).toBe(200);
expect(mockHandleRequest).toHaveBeenCalled();
});

it('returns error when auth service throws', async () => {
const mockHandleRequest = vi.fn().mockRejectedValue(new Error('Auth failed'));
const kernelWithAuth = {
...mockKernel,
getService: vi.fn().mockReturnValue({ handleRequest: mockHandleRequest }),
};
const handler = createRouteHandler({ kernel: kernelWithAuth });
const req = makeReq('http://localhost/api/auth/sign-in/email', 'POST', { email: 'a@b.com' });
const res = await handler(req, { params: { objectstack: ['auth', 'sign-in', 'email'] } });
expect(res.status).toBe(500);
expect(res.body.success).toBe(false);
});
});

describe('GraphQL Endpoint', () => {
it('POST graphql calls handleGraphQL', async () => {
const handler = createRouteHandler({ kernel: mockKernel });
Expand Down
Loading