Skip to content

Commit a8ab01c

Browse files
committed
feat(docker): improve local/Docker mode UX
- Hide social login buttons when OAuth providers aren't configured - Fix per-user data isolation in local mode (session guard prioritizes real Better Auth sessions over synthetic loopback fallback) - Expose isLocalMode and ollamaAvailable in GET /api/v1/setup/status - Make Ollama optional via Docker Compose profile (--profile ollama) - Show contextual messages for Ollama in provider list based on state - Centralize setup status fetching in shared cached service
1 parent acd3284 commit a8ab01c

File tree

16 files changed

+712
-70
lines changed

16 files changed

+712
-70
lines changed

docker/docker-compose.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ services:
3030
- DATABASE_URL=${DATABASE_URL:-postgresql://manifest:manifest@postgres:5432/manifest}
3131
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set in .env}
3232
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3001}
33+
- MANIFEST_MODE=local
34+
- OLLAMA_HOST=http://ollama:11434
3335
- SEED_DATA=false
3436
- NODE_ENV=production
3537
- AUTO_MIGRATE=true
@@ -87,6 +89,22 @@ services:
8789
networks:
8890
- internal
8991

92+
# Optional: run local LLMs via Ollama.
93+
# Start with: docker compose --profile ollama up -d
94+
ollama:
95+
image: ollama/ollama:latest
96+
profiles: ["ollama"]
97+
volumes:
98+
- ollama_data:/root/.ollama
99+
healthcheck:
100+
test: ["CMD-SHELL", "ollama list || exit 1"]
101+
interval: 30s
102+
timeout: 5s
103+
start_period: 30s
104+
retries: 3
105+
networks:
106+
- internal
107+
90108
networks:
91109
internal:
92110
driver: bridge
@@ -96,3 +114,4 @@ networks:
96114

97115
volumes:
98116
pgdata:
117+
ollama_data:

packages/backend/src/auth/session.guard.spec.ts

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,10 @@ describe('SessionGuard', () => {
8686
expect(request['authMethod']).toBe('session');
8787
});
8888

89-
it('returns true even when no session found', async () => {
89+
it('returns true even when no session found (non-local mode)', async () => {
9090
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
9191
(auth.api.getSession as jest.Mock).mockResolvedValue(null);
92-
const { context, request } = createMockContext({});
92+
const { context, request } = createMockContext({ ip: '203.0.113.1' });
9393

9494
const result = await guard.canActivate(context);
9595

@@ -98,15 +98,146 @@ describe('SessionGuard', () => {
9898
expect(request['authMethod']).toBeUndefined();
9999
});
100100

101-
it('returns true and leaves user undefined when getSession throws', async () => {
101+
it('returns true and leaves user undefined when getSession throws (non-local mode)', async () => {
102102
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
103103
(auth.api.getSession as jest.Mock).mockRejectedValue(new Error('DB connection lost'));
104-
const { context, request } = createMockContext({});
104+
const { context, request } = createMockContext({ ip: '203.0.113.1' });
105105

106106
const result = await guard.canActivate(context);
107107

108108
expect(result).toBe(true);
109109
expect(request['user']).toBeUndefined();
110110
expect(request['authMethod']).toBeUndefined();
111111
});
112+
113+
describe('cloud mode (default) — no loopback fallback', () => {
114+
const originalEnv = process.env['MANIFEST_MODE'];
115+
116+
beforeEach(() => {
117+
delete process.env['MANIFEST_MODE'];
118+
});
119+
120+
afterEach(() => {
121+
if (originalEnv === undefined) delete process.env['MANIFEST_MODE'];
122+
else process.env['MANIFEST_MODE'] = originalEnv;
123+
});
124+
125+
it('does NOT inject synthetic user for loopback IP without session', async () => {
126+
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
127+
(auth.api.getSession as jest.Mock).mockResolvedValue(null);
128+
const { context, request } = createMockContext({ ip: '127.0.0.1' });
129+
130+
await guard.canActivate(context);
131+
132+
expect(request['user']).toBeUndefined();
133+
expect(request['authMethod']).toBeUndefined();
134+
});
135+
136+
it('does NOT inject synthetic user for loopback IP when getSession throws', async () => {
137+
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
138+
(auth.api.getSession as jest.Mock).mockRejectedValue(new Error('DB error'));
139+
const { context, request } = createMockContext({ ip: '127.0.0.1' });
140+
141+
await guard.canActivate(context);
142+
143+
expect(request['user']).toBeUndefined();
144+
expect(request['authMethod']).toBeUndefined();
145+
});
146+
147+
it('attaches real user when Better Auth session is valid (loopback IP)', async () => {
148+
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
149+
const mockSession = {
150+
user: { id: 'cloud-user-1', name: 'Cloud User', email: 'cloud@example.com' },
151+
session: { id: 'session-cloud' },
152+
};
153+
(auth.api.getSession as jest.Mock).mockResolvedValue(mockSession);
154+
const { context, request } = createMockContext({ ip: '127.0.0.1' });
155+
156+
await guard.canActivate(context);
157+
158+
expect(request['user']).toEqual(mockSession.user);
159+
expect(request['authMethod']).toBe('session');
160+
});
161+
162+
it('does NOT inject synthetic user when MANIFEST_MODE is "cloud"', async () => {
163+
process.env['MANIFEST_MODE'] = 'cloud';
164+
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
165+
(auth.api.getSession as jest.Mock).mockResolvedValue(null);
166+
const { context, request } = createMockContext({ ip: '127.0.0.1' });
167+
168+
await guard.canActivate(context);
169+
170+
expect(request['user']).toBeUndefined();
171+
expect(request['authMethod']).toBeUndefined();
172+
});
173+
});
174+
175+
describe('local mode loopback fallback', () => {
176+
const originalEnv = process.env['MANIFEST_MODE'];
177+
178+
beforeEach(() => {
179+
process.env['MANIFEST_MODE'] = 'local';
180+
});
181+
182+
afterEach(() => {
183+
if (originalEnv === undefined) delete process.env['MANIFEST_MODE'];
184+
else process.env['MANIFEST_MODE'] = originalEnv;
185+
});
186+
187+
it('uses real session when Better Auth session exists (preserves per-user isolation)', async () => {
188+
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
189+
const mockSession = {
190+
user: { id: 'real-user-1', name: 'Real User', email: 'real@test.com' },
191+
session: { id: 'session-1' },
192+
};
193+
(auth.api.getSession as jest.Mock).mockResolvedValue(mockSession);
194+
const { context, request } = createMockContext({ ip: '127.0.0.1' });
195+
196+
await guard.canActivate(context);
197+
198+
expect(request['user']).toEqual(mockSession.user);
199+
expect(request['authMethod']).toBe('session');
200+
});
201+
202+
it('falls back to synthetic local user when no session on loopback', async () => {
203+
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
204+
(auth.api.getSession as jest.Mock).mockResolvedValue(null);
205+
const { context, request } = createMockContext({ ip: '127.0.0.1' });
206+
207+
await guard.canActivate(context);
208+
209+
expect(request['user']).toEqual({
210+
id: 'local',
211+
name: 'Local User',
212+
email: 'local@localhost',
213+
});
214+
expect(request['authMethod']).toBe('session');
215+
});
216+
217+
it('falls back to synthetic local user when getSession throws on loopback', async () => {
218+
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
219+
(auth.api.getSession as jest.Mock).mockRejectedValue(new Error('DB error'));
220+
const { context, request } = createMockContext({ ip: '127.0.0.1' });
221+
222+
await guard.canActivate(context);
223+
224+
expect(request['user']).toEqual({
225+
id: 'local',
226+
name: 'Local User',
227+
email: 'local@localhost',
228+
});
229+
expect(request['authMethod']).toBe('session');
230+
});
231+
232+
it('does not apply loopback fallback for non-loopback IPs', async () => {
233+
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
234+
(auth.api.getSession as jest.Mock).mockResolvedValue(null);
235+
const { context, request } = createMockContext({ ip: '203.0.113.1' });
236+
237+
await guard.canActivate(context);
238+
239+
expect(request['user']).toBeUndefined();
240+
expect(request['authMethod']).toBeUndefined();
241+
});
242+
});
112243
});

packages/backend/src/auth/session.guard.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Request } from 'express';
44
import { fromNodeHeaders } from 'better-auth/node';
55
import { auth } from './auth.instance';
66
import { IS_PUBLIC_KEY } from '../common/decorators/public.decorator';
7+
import { isLoopbackIp } from '../common/utils/local-ip';
78

89
@Injectable()
910
export class SessionGuard implements CanActivate {
@@ -23,7 +24,7 @@ export class SessionGuard implements CanActivate {
2324
// Let API-key authenticated requests be handled by ApiKeyGuard
2425
if (request.headers['x-api-key']) return true;
2526

26-
// In local mode, Better Auth is not initialized
27+
// In local mode without Better Auth, skip session lookup
2728
if (!auth) return true;
2829

2930
try {
@@ -35,11 +36,24 @@ export class SessionGuard implements CanActivate {
3536
(request as Request & { user: unknown }).user = session.user;
3637
(request as Request & { session: unknown }).session = session.session;
3738
(request as Request & { authMethod: string }).authMethod = 'session';
39+
return true;
3840
}
3941
} catch (err) {
4042
this.logger.warn(`Session lookup failed: ${(err as Error).message}`);
4143
}
4244

45+
// In local mode, fall back to a synthetic user for loopback requests
46+
// without a session (e.g. curl, programmatic access)
47+
if (process.env['MANIFEST_MODE'] === 'local' && request.ip && isLoopbackIp(request.ip)) {
48+
(request as Request & { user: unknown }).user = {
49+
id: 'local',
50+
name: 'Local User',
51+
email: 'local@localhost',
52+
};
53+
(request as Request & { authMethod: string }).authMethod = 'session';
54+
return true;
55+
}
56+
4357
// Always pass — let ApiKeyGuard handle unauthenticated requests
4458
return true;
4559
}

packages/backend/src/setup/setup.controller.spec.ts

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,16 @@ describe('SetupController', () => {
1010
let controller: SetupController;
1111
let mockNeedsSetup: jest.Mock;
1212
let mockCreateFirstAdmin: jest.Mock;
13+
let mockGetEnabledSocialProviders: jest.Mock;
14+
let mockIsLocalMode: jest.Mock;
15+
let mockIsOllamaAvailable: jest.Mock;
1316

1417
beforeEach(async () => {
1518
mockNeedsSetup = jest.fn();
1619
mockCreateFirstAdmin = jest.fn();
20+
mockGetEnabledSocialProviders = jest.fn().mockReturnValue([]);
21+
mockIsLocalMode = jest.fn().mockReturnValue(false);
22+
mockIsOllamaAvailable = jest.fn().mockResolvedValue(false);
1723

1824
const module: TestingModule = await Test.createTestingModule({
1925
controllers: [SetupController],
@@ -23,6 +29,9 @@ describe('SetupController', () => {
2329
useValue: {
2430
needsSetup: mockNeedsSetup,
2531
createFirstAdmin: mockCreateFirstAdmin,
32+
getEnabledSocialProviders: mockGetEnabledSocialProviders,
33+
isLocalMode: mockIsLocalMode,
34+
isOllamaAvailable: mockIsOllamaAvailable,
2635
},
2736
},
2837
],
@@ -32,16 +41,72 @@ describe('SetupController', () => {
3241
});
3342

3443
describe('getStatus', () => {
35-
it('returns needsSetup=true when service says so', async () => {
44+
it('returns needsSetup=true with empty socialProviders in cloud mode', async () => {
3645
mockNeedsSetup.mockResolvedValue(true);
3746
const result = await controller.getStatus();
38-
expect(result).toEqual({ needsSetup: true });
47+
expect(result).toEqual({
48+
needsSetup: true,
49+
socialProviders: [],
50+
isLocalMode: false,
51+
ollamaAvailable: false,
52+
});
3953
});
4054

41-
it('returns needsSetup=false when an admin already exists', async () => {
55+
it('returns needsSetup=false with empty socialProviders in cloud mode', async () => {
4256
mockNeedsSetup.mockResolvedValue(false);
4357
const result = await controller.getStatus();
44-
expect(result).toEqual({ needsSetup: false });
58+
expect(result).toEqual({
59+
needsSetup: false,
60+
socialProviders: [],
61+
isLocalMode: false,
62+
ollamaAvailable: false,
63+
});
64+
});
65+
66+
it('includes enabled social providers in the response', async () => {
67+
mockNeedsSetup.mockResolvedValue(false);
68+
mockGetEnabledSocialProviders.mockReturnValue(['google', 'github']);
69+
const result = await controller.getStatus();
70+
expect(result).toEqual({
71+
needsSetup: false,
72+
socialProviders: ['google', 'github'],
73+
isLocalMode: false,
74+
ollamaAvailable: false,
75+
});
76+
});
77+
78+
it('returns isLocalMode=true when in local mode', async () => {
79+
mockNeedsSetup.mockResolvedValue(false);
80+
mockIsLocalMode.mockReturnValue(true);
81+
mockIsOllamaAvailable.mockResolvedValue(false);
82+
const result = await controller.getStatus();
83+
expect(result).toEqual({
84+
needsSetup: false,
85+
socialProviders: [],
86+
isLocalMode: true,
87+
ollamaAvailable: false,
88+
});
89+
});
90+
91+
it('returns ollamaAvailable=true when in local mode and Ollama is reachable', async () => {
92+
mockNeedsSetup.mockResolvedValue(false);
93+
mockIsLocalMode.mockReturnValue(true);
94+
mockIsOllamaAvailable.mockResolvedValue(true);
95+
const result = await controller.getStatus();
96+
expect(result).toEqual({
97+
needsSetup: false,
98+
socialProviders: [],
99+
isLocalMode: true,
100+
ollamaAvailable: true,
101+
});
102+
});
103+
104+
it('skips Ollama check in cloud mode (always false)', async () => {
105+
mockNeedsSetup.mockResolvedValue(false);
106+
mockIsLocalMode.mockReturnValue(false);
107+
const result = await controller.getStatus();
108+
expect(result.ollamaAvailable).toBe(false);
109+
expect(mockIsOllamaAvailable).not.toHaveBeenCalled();
45110
});
46111
});
47112

packages/backend/src/setup/setup.controller.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,19 @@ export class SetupController {
99

1010
@Public()
1111
@Get('status')
12-
async getStatus(): Promise<{ needsSetup: boolean }> {
13-
return { needsSetup: await this.setupService.needsSetup() };
12+
async getStatus(): Promise<{
13+
needsSetup: boolean;
14+
socialProviders: string[];
15+
isLocalMode: boolean;
16+
ollamaAvailable: boolean;
17+
}> {
18+
const isLocal = this.setupService.isLocalMode();
19+
return {
20+
needsSetup: await this.setupService.needsSetup(),
21+
socialProviders: this.setupService.getEnabledSocialProviders(),
22+
isLocalMode: isLocal,
23+
ollamaAvailable: isLocal ? await this.setupService.isOllamaAvailable() : false,
24+
};
1425
}
1526

1627
@Public()

0 commit comments

Comments
 (0)