Skip to content
Merged
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,7 @@ the ecosystem for enterprise workloads.
| Client SDK (TypeScript) | ✅ | `@objectstack/client` |
| React Hooks | ✅ | `@objectstack/client-react` |
| Authentication (better-auth) | ✅ | `@objectstack/plugin-auth` |
| Auth in MSW/Mock Mode | ✅ | `@objectstack/plugin-auth` + `@objectstack/runtime` |
| RBAC / RLS / FLS Security | ✅ | `@objectstack/plugin-security` |
| CLI (16 commands) | ✅ | `@objectstack/cli` |
| Dev Mode Plugin | ✅ | `@objectstack/plugin-dev` |
Expand Down
74 changes: 73 additions & 1 deletion content/docs/guides/authentication.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@ Complete guide to implementing authentication in ObjectStack applications using
7. [Client Integration](#client-integration)
8. [API Reference](#api-reference)
9. [Best Practices](#best-practices)
10. [MSW/Mock Mode](#mswmock-mode)

---

Expand DownExpand Up@@ -89,7 +90,7 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server';

const kernel = new ObjectKernel({
plugins: [
// HTTP server (required for auth routes)
// HTTP server (optional — auth works without it in MSW/mock mode)
new HonoServerPlugin({
port: 3000,
}),
Expand All@@ -107,6 +108,10 @@ await kernel.start();

That's it! Your authentication endpoints are now available at `/api/v1/auth/*`.

> **MSW/Mock Mode:** AuthPlugin does **not** require an HTTP server. When HonoServerPlugin
> is absent, the plugin gracefully skips route registration and still registers the `auth`
> service. See [MSW/Mock Mode](#mswmock-mode) below for details.

### 3. ObjectQL Data Persistence

The plugin automatically uses ObjectQL for data persistence. No additional database configuration is required - it works with your existing ObjectQL setup.
Expand DownExpand Up@@ -586,6 +591,73 @@ better-auth internally uses model names like `user` and `session`. The ObjectQL

---

## MSW/Mock Mode

AuthPlugin is designed to work in **both** server and MSW/mock (browser-only) environments. This means you can develop and test authentication flows without running a real HTTP server.

### How It Works

- **Server mode** (HonoServerPlugin active): AuthPlugin registers HTTP routes at `/api/v1/auth/*` and forwards all requests to better-auth.
- **MSW/mock mode** (no HTTP server): AuthPlugin gracefully skips route registration but still registers the `auth` service. The `HttpDispatcher` provides mock fallback responses for core auth endpoints.

### Minimal Configuration for Mock Mode

```typescript
import { ObjectKernel, DriverPlugin, AppPlugin } from '@objectstack/runtime';
import { ObjectQLPlugin } from '@objectstack/objectql';
import { InMemoryDriver } from '@objectstack/driver-memory';
import { AuthPlugin } from '@objectstack/plugin-auth';

const kernel = new ObjectKernel();

await kernel.use(new ObjectQLPlugin());
await kernel.use(new DriverPlugin(new InMemoryDriver(), 'memory'));

// AuthPlugin works without HonoServerPlugin — no HTTP server needed
await kernel.use(new AuthPlugin({
secret: 'INSECURE_DEV_ONLY_mock_secret_do_not_use_in_production',
baseUrl: 'http://localhost:5173',
}));

await kernel.bootstrap();
```

> ⚠️ **Warning:** The secret above is for **local development only**. In production, always use a strong random secret from an environment variable (`process.env.AUTH_SECRET`).

### Mock Fallback Endpoints

When no auth service handler is registered and the legacy broker login is unavailable, `HttpDispatcher.handleAuth()` automatically provides mock responses for:

| Endpoint | Method | Description |
|:---|:---:|:---|
| `sign-up/email` | POST | Returns mock user + session |
| `sign-in/email` | POST | Returns mock user + session |
| `login` | POST | Legacy login — returns mock user + session |
| `register` | POST | Alias for sign-up |
| `get-session` | GET | Returns `{ session: null, user: null }` |
| `sign-out` | POST | Returns `{ success: true }` |

This ensures that registration and sign-in flows do not return 404 errors in MSW/browser-only environments.
Comment on lines +627 to +640

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

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

Docs say mock fallback happens "when no auth service handler or broker is available", but the implementation falls back for most auth paths whenever the auth service handler is missing (even if a broker exists). Please align this wording with the actual fallback conditions (e.g., auth service missing and legacy broker login unavailable/unsupported).

Copilot uses AI. Check for mistakes.

> **Note:** In server mode with AuthPlugin loaded, the auth service handler takes priority and the mock fallback is never reached. The mock fallback only activates when AuthPlugin is not loaded (e.g. browser-only Studio builds where `better-auth` is unavailable).

### Studio Kernel Factory

The Studio app runs in the browser, where the Node-only `better-auth` library cannot be bundled. Instead of loading `AuthPlugin` directly, Studio relies on `HttpDispatcher`'s built-in mock fallback to handle auth endpoints in MSW mode:

```typescript
// apps/studio/src/mocks/createKernel.ts
// No AuthPlugin needed — HttpDispatcher provides mock auth endpoints automatically
const kernel = new ObjectKernel();
await kernel.use(new ObjectQLPlugin());
await kernel.use(new DriverPlugin(driver, 'memory'));
// ...
await kernel.use(new MSWPlugin({ /* ... */ }));
await kernel.bootstrap();
```

---

## Next Steps

- See [Security Guide](/docs/guides/security) for authorization and permissions
Expand Down
29 changes: 28 additions & 1 deletion packages/plugins/plugin-auth/src/auth-plugin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@ describe('AuthPlugin', () => {
expect(authPlugin.name).toBe('com.objectstack.auth');
expect(authPlugin.type).toBe('standard');
expect(authPlugin.version).toBe('1.0.0');
expect(authPlugin.dependencies).toContain('com.objectstack.server.hono');
expect(authPlugin.dependencies).toEqual([]);
});
});

Expand DownExpand Up@@ -149,6 +149,33 @@ describe('AuthPlugin', () => {
expect(mockContext.getService).not.toHaveBeenCalledWith('http-server');
});

it('should gracefully skip routes when http-server is not available', async () => {
mockContext.getService = vi.fn(() => null);

await authPlugin.start(mockContext);

expect(mockContext.getService).toHaveBeenCalledWith('http-server');
expect(mockContext.logger.warn).toHaveBeenCalledWith(
expect.stringContaining('No HTTP server available')
);
// Should NOT throw — auth service is still registered from init()
});

it('should gracefully handle http-server getService throwing', async () => {
mockContext.getService = vi.fn(() => {
throw new Error('Service not found: http-server');
});

await authPlugin.start(mockContext);

expect(mockContext.logger.warn).toHaveBeenCalledWith(
expect.stringContaining('No HTTP server available')
);
// Auth service should still be registered from init()
expect(mockContext.registerService).toHaveBeenCalledWith('auth', expect.anything());
// Should NOT throw
});

it('should throw error if auth not initialized', async () => {
const uninitializedPlugin = new AuthPlugin({
secret: 'test-secret',
Expand Down
33 changes: 24 additions & 9 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,13 @@ export interface AuthPluginOptions extends Partial<AuthConfig> {
*
* Provides authentication and identity services for ObjectStack applications.
*
* **Dual-Mode Operation:**
* - **Server mode** (HonoServerPlugin active): Registers HTTP routes at basePath,
* forwarding all auth requests to better-auth's universal handler.
* - **MSW/Mock mode** (no HTTP server): Gracefully skips route registration but
* still registers the `auth` service, allowing HttpDispatcher.handleAuth() to
* simulate auth flows (sign-up, sign-in, etc.) for development and testing.
*
* Features:
* - Session management
* - User registration/login
Expand All@@ -35,8 +42,8 @@ export interface AuthPluginOptions extends Partial<AuthConfig> {
* - 2FA, passkeys, magic links
*
* This plugin registers:
* - `auth` service (auth manager instance)
* - HTTP routes for authentication endpoints
* - `auth` service (auth manager instance) — always
* - HTTP routes for authentication endpoints — only when HTTP server is available
*
* Integrates with better-auth library to provide comprehensive
* authentication capabilities including email/password, OAuth, 2FA,
Expand All@@ -46,7 +53,7 @@ export class AuthPlugin implements Plugin {
name = 'com.objectstack.auth';
type = 'standard';
version = '1.0.0';
dependencies= ['com.objectstack.server.hono']; // Requires HTTP server
dependencies: string[] = []; // HTTP server is optional; routes are registered only when available

private options: AuthPluginOptions;
private authManager: AuthManager | null = null;
Expand DownExpand Up@@ -92,16 +99,24 @@ export class AuthPlugin implements Plugin {
throw new Error('Auth manager not initialized');
}

// Register HTTP routes if enabled
// Register HTTP routes if enabled and HTTP server is available
if (this.options.registerRoutes) {
let httpServer: IHttpServer | null = null;
try {
const httpServer = ctx.getService<IHttpServer>('http-server');
httpServer = ctx.getService<IHttpServer>('http-server');
} catch {
// Service not found — expected in MSW/mock mode
}

if (httpServer) {
// Route registration errors should propagate (server misconfiguration)
this.registerAuthRoutes(httpServer, ctx);
ctx.logger.info(`Auth routes registered at ${this.options.basePath}`);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
ctx.logger.error('Failed to register auth routes:', err);
throw err;
} else {
ctx.logger.warn(
'No HTTP server available — auth routes not registered. ' +
'Auth service is still available for MSW/mock environments via HttpDispatcher.'
);
}
Comment on lines 103 to 120

CopilotAIMar 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 try/catch around route registration currently swallows all errors (including registerAuthRoutes throwing because the server isn't Hono or wildcard routing fails). That can leave server mode running without auth routes and only a warn log. Consider only catching the "http-server service missing" case and rethrowing unexpected registration errors.

Copilot uses AI. Check for mistakes.
}

Expand Down
53 changes: 53 additions & 0 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -305,6 +305,59 @@ describe('HttpDispatcher', () => {
});
});

describe('handleAuth mock fallback (MSW/test mode)', () => {
beforeEach(() => {
// No auth service, no broker — simulates MSW/mock mode
(kernel as any).getService = vi.fn().mockResolvedValue(null);
(kernel as any).services = new Map();
(kernel as any).broker = null;
});

it('should mock sign-up/email endpoint', async () => {
const result = await dispatcher.handleAuth('/sign-up/email', 'POST', { email: 'test@example.com', name: 'Test' }, { request: {} });
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(200);
expect(result.response?.body.user).toBeDefined();
expect(result.response?.body.user.email).toBe('test@example.com');
expect(result.response?.body.session).toBeDefined();
});

it('should mock sign-in/email endpoint', async () => {
const result = await dispatcher.handleAuth('/sign-in/email', 'POST', { email: 'test@example.com' }, { request: {} });
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(200);
expect(result.response?.body.user).toBeDefined();
expect(result.response?.body.session).toBeDefined();
});

it('should mock get-session endpoint', async () => {
const result = await dispatcher.handleAuth('/get-session', 'GET', {}, { request: {} });
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(200);
expect(result.response?.body).toEqual({ session: null, user: null });
});

it('should mock sign-out endpoint', async () => {
const result = await dispatcher.handleAuth('/sign-out', 'POST', {}, { request: {} });
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(200);
expect(result.response?.body).toEqual({ success: true });
});

it('should mock login fallback when broker unavailable', async () => {
const result = await dispatcher.handleAuth('/login', 'POST', { email: 'test@example.com' }, { request: {} });
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(200);
expect(result.response?.body.user).toBeDefined();
expect(result.response?.body.session).toBeDefined();
});

it('should return unhandled for unknown auth path in mock mode', async () => {
const result = await dispatcher.handleAuth('/unknown', 'GET', {}, { request: {} });
expect(result.handled).toBe(false);
});
});

describe('handleStorage with async service', () => {
it('should resolve storage service from Promise', async () => {
const mockStorage = {
Expand Down
90 changes: 85 additions & 5 deletions packages/runtime/src/http-dispatcher.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,18 @@
import { ObjectKernel, getEnv } from '@objectstack/core';
import { CoreServiceName } from '@objectstack/spec/system';

/** Browser-safe UUID generator — prefers Web Crypto, falls back to RFC 4122 v4 */
function randomUUID(): string {
if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') {
return globalThis.crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}

export interface HttpProtocolContext {
request: any;
response?: any;
Expand DownExpand Up@@ -179,12 +191,80 @@ export class HttpDispatcher {
return { handled: true, result: response };
}

// 2. Legacy Login
// 2. Legacy Login via broker
const normalizedPath = path.replace(/^\/+/, '');
if (normalizedPath === 'login' && method.toUpperCase() === 'POST') {
const broker = this.ensureBroker();
const data = await broker.call('auth.login', body, { request: context.request });
return { handled: true, response: { status: 200, body: data } };
try {
const broker = this.ensureBroker();
const data = await broker.call('auth.login', body, { request: context.request });
return { handled: true, response: { status: 200, body: data } };
} catch (error: any) {
// Only fall through to mock when the broker is truly unavailable
// (ensureBroker throws statusCode 500 when kernel.broker is null)
const statusCode = error?.statusCode ?? error?.status;
if (statusCode !== 500 || !error?.message?.includes('Broker not available')) {
throw error;
}
}
}

// 3. Mock fallback for MSW/test environments when no auth service is registered
return this.mockAuthFallback(normalizedPath, method, body);
}

/**
* Provides mock auth responses for core better-auth endpoints when
* AuthPlugin is not loaded (e.g. MSW/browser-only environments).
* This ensures registration/sign-in flows do not 404 in mock mode.
*/
private mockAuthFallback(path: string, method: string, body: any): HttpDispatcherResult {
const m = method.toUpperCase();
const MOCK_SESSION_EXPIRY_MS = 86_400_000; // 24 hours

// POST sign-up/email
if ((path === 'sign-up/email' || path === 'register') && m === 'POST') {
const id = `mock_${randomUUID()}`;
return {
handled: true,
response: {
status: 200,
body: {
user: { id, name: body?.name || 'Mock User', email: body?.email || 'mock@test.local', emailVerified: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() },
},
},
};
}

// POST sign-in/email or login
if ((path === 'sign-in/email' || path === 'login') && m === 'POST') {
const id = `mock_${randomUUID()}`;
return {
handled: true,
response: {
status: 200,
body: {
user: { id, name: 'Mock User', email: body?.email || 'mock@test.local', emailVerified: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() },
},
},
};
}

// GET get-session
if (path === 'get-session' && m === 'GET') {
return {
handled: true,
response: { status: 200, body: { session: null, user: null } },
};
}

// POST sign-out
if (path === 'sign-out' && m === 'POST') {
return {
handled: true,
response: { status: 200, body: { success: true } },
};
}

return { handled: false };
Expand DownExpand Up@@ -486,7 +566,7 @@ export class HttpDispatcher {
* GET /labels/:object/:locale → getFieldLabels (both from path)
* GET /labels/:object?locale=xx → getFieldLabels (locale from query)
*/
async handleI18n(path: string, method: string, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
async handleI18n(path: string, method: string, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
const i18nService = await this.getService(CoreServiceName.enum.i18n);
if (!i18nService) return { handled: true, response: this.error('i18n service not available', 501) };

Expand Down