Skip to content

Commit 1f4dabd

Browse files
authored
Merge pull request #1152 from objectstack-ai/claude/add-better-auth-cors-support
feat: add wildcard CORS origin pattern matching for better-auth compatibility
2 parents 2ce4fb8 + 1ef7843 commit 1f4dabd

4 files changed

Lines changed: 447 additions & 4 deletions

File tree

packages/plugins/plugin-hono-server/README.md

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ HTTP Server adapter for ObjectStack using Hono.
88
- **Fast**: Built on Hono, a high-performance web framework.
99
- **Full Protocol Support**: Automatically provides all ObjectStack Runtime endpoints (Auth, Data, Metadata, etc.).
1010
- **Middleware**: Supports standard Hono middleware.
11+
- **Wildcard CORS**: Supports wildcard patterns in CORS origins (compatible with better-auth).
1112

1213
## Usage
1314

@@ -18,7 +19,7 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
1819
const kernel = new ObjectKernel();
1920

2021
// Register the server plugin
21-
kernel.use(new HonoServerPlugin({
22+
kernel.use(new HonoServerPlugin({
2223
port: 3000,
2324
restConfig: {
2425
api: {
@@ -30,6 +31,75 @@ kernel.use(new HonoServerPlugin({
3031
await kernel.start();
3132
```
3233

34+
## CORS Configuration
35+
36+
The Hono server plugin supports flexible CORS configuration with wildcard pattern matching.
37+
38+
### Basic CORS
39+
40+
```typescript
41+
kernel.use(new HonoServerPlugin({
42+
port: 3000,
43+
cors: {
44+
origins: ['https://app.example.com'],
45+
credentials: true
46+
}
47+
}));
48+
```
49+
50+
### Wildcard Patterns (better-auth compatible)
51+
52+
```typescript
53+
// Subdomain wildcards
54+
kernel.use(new HonoServerPlugin({
55+
cors: {
56+
origins: ['https://*.objectui.org', 'https://*.objectstack.ai'],
57+
credentials: true
58+
}
59+
}));
60+
61+
// Port wildcards (useful for development)
62+
kernel.use(new HonoServerPlugin({
63+
cors: {
64+
origins: 'http://localhost:*'
65+
}
66+
}));
67+
68+
// Comma-separated patterns
69+
kernel.use(new HonoServerPlugin({
70+
cors: {
71+
origins: 'https://*.objectui.org,https://*.objectstack.ai,http://localhost:*'
72+
}
73+
}));
74+
```
75+
76+
### Environment Variables
77+
78+
CORS can also be configured via environment variables:
79+
80+
```bash
81+
# Single origin
82+
CORS_ORIGIN=https://app.example.com
83+
84+
# Wildcard patterns (comma-separated)
85+
CORS_ORIGIN=https://*.objectui.org,https://*.objectstack.ai
86+
87+
# Disable CORS
88+
CORS_ENABLED=false
89+
90+
# Additional options
91+
CORS_CREDENTIALS=true
92+
CORS_MAX_AGE=86400
93+
```
94+
95+
### Disable CORS
96+
97+
```typescript
98+
kernel.use(new HonoServerPlugin({
99+
cors: false // Completely disable CORS
100+
}));
101+
```
102+
33103
## Architecture
34104

35105
This plugin wraps `@objectstack/hono` to provide a turnkey HTTP server solution for the Runtime. It binds the standard `HttpDispatcher` to a Hono application and starts listening on the configured port.

packages/plugins/plugin-hono-server/src/hono-plugin.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,126 @@ describe('HonoServerPlugin', () => {
111111
// Should register SPA fallback middleware
112112
expect(rawApp.get).toHaveBeenCalledWith('/*', expect.anything());
113113
});
114+
115+
describe('CORS wildcard pattern matching', () => {
116+
beforeEach(() => {
117+
vi.clearAllMocks();
118+
});
119+
120+
it('should enable CORS middleware with wildcard subdomain patterns', async () => {
121+
const plugin = new HonoServerPlugin({
122+
cors: {
123+
origins: ['https://*.objectui.org', 'https://*.objectstack.ai'],
124+
credentials: true
125+
}
126+
});
127+
128+
await plugin.init(context as PluginContext);
129+
130+
const serverInstance = (HonoHttpServer as any).mock.instances[0];
131+
const rawApp = serverInstance.getRawApp();
132+
133+
// CORS middleware should be registered
134+
expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function));
135+
});
136+
137+
it('should enable CORS middleware with port wildcard patterns', async () => {
138+
const plugin = new HonoServerPlugin({
139+
cors: {
140+
origins: 'http://localhost:*',
141+
}
142+
});
143+
144+
await plugin.init(context as PluginContext);
145+
146+
const serverInstance = (HonoHttpServer as any).mock.instances[0];
147+
const rawApp = serverInstance.getRawApp();
148+
149+
expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function));
150+
});
151+
152+
it('should support comma-separated wildcard patterns', async () => {
153+
const plugin = new HonoServerPlugin({
154+
cors: {
155+
origins: 'https://*.objectui.org,https://*.objectstack.ai',
156+
}
157+
});
158+
159+
await plugin.init(context as PluginContext);
160+
161+
const serverInstance = (HonoHttpServer as any).mock.instances[0];
162+
const rawApp = serverInstance.getRawApp();
163+
164+
expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function));
165+
});
166+
167+
it('should support exact origins without wildcards', async () => {
168+
const plugin = new HonoServerPlugin({
169+
cors: {
170+
origins: ['https://app.example.com', 'https://api.example.com'],
171+
}
172+
});
173+
174+
await plugin.init(context as PluginContext);
175+
176+
const serverInstance = (HonoHttpServer as any).mock.instances[0];
177+
const rawApp = serverInstance.getRawApp();
178+
179+
expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function));
180+
});
181+
182+
it('should support CORS_ORIGIN environment variable with wildcards', async () => {
183+
const originalEnv = process.env.CORS_ORIGIN;
184+
process.env.CORS_ORIGIN = 'https://*.objectui.org,https://*.objectstack.ai';
185+
186+
const plugin = new HonoServerPlugin();
187+
await plugin.init(context as PluginContext);
188+
189+
const serverInstance = (HonoHttpServer as any).mock.instances[0];
190+
const rawApp = serverInstance.getRawApp();
191+
192+
expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function));
193+
194+
// Restore environment
195+
if (originalEnv !== undefined) {
196+
process.env.CORS_ORIGIN = originalEnv;
197+
} else {
198+
delete process.env.CORS_ORIGIN;
199+
}
200+
});
201+
202+
it('should disable CORS when cors option is false', async () => {
203+
const plugin = new HonoServerPlugin({
204+
cors: false
205+
});
206+
207+
await plugin.init(context as PluginContext);
208+
209+
const serverInstance = (HonoHttpServer as any).mock.instances[0];
210+
const rawApp = serverInstance.getRawApp();
211+
212+
// CORS middleware should NOT be registered
213+
expect(rawApp.use).not.toHaveBeenCalled();
214+
});
215+
216+
it('should disable CORS when CORS_ENABLED env is false', async () => {
217+
const originalEnv = process.env.CORS_ENABLED;
218+
process.env.CORS_ENABLED = 'false';
219+
220+
const plugin = new HonoServerPlugin();
221+
await plugin.init(context as PluginContext);
222+
223+
const serverInstance = (HonoHttpServer as any).mock.instances[0];
224+
const rawApp = serverInstance.getRawApp();
225+
226+
expect(rawApp.use).not.toHaveBeenCalled();
227+
228+
// Restore environment
229+
if (originalEnv !== undefined) {
230+
process.env.CORS_ENABLED = originalEnv;
231+
} else {
232+
delete process.env.CORS_ENABLED;
233+
}
234+
});
235+
});
114236
});

packages/plugins/plugin-hono-server/src/hono-plugin.ts

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,62 @@ export interface HonoPluginOptions {
6666
* - `@objectstack/rest` → CRUD, metadata, discovery, UI, batch
6767
* - `createDispatcherPlugin()` → auth, graphql, analytics, packages, etc.
6868
*/
69+
/**
70+
* Check if an origin matches a pattern with wildcards.
71+
* Supports patterns like:
72+
* - "https://*.example.com" - matches any subdomain
73+
* - "http://localhost:*" - matches any port
74+
* - "https://*.objectui.org,https://*.objectstack.ai" - comma-separated patterns
75+
*
76+
* @param origin The origin to check (e.g., "https://app.example.com")
77+
* @param pattern The pattern to match against (supports * wildcard)
78+
* @returns true if origin matches the pattern
79+
*/
80+
function matchOriginPattern(origin: string, pattern: string): boolean {
81+
if (pattern === '*') return true;
82+
if (pattern === origin) return true;
83+
84+
// Convert wildcard pattern to regex
85+
// Escape special regex characters except *
86+
const regexPattern = pattern
87+
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special chars
88+
.replace(/\*/g, '.*'); // Convert * to .*
89+
90+
const regex = new RegExp(`^${regexPattern}$`);
91+
return regex.test(origin);
92+
}
93+
94+
/**
95+
* Create a CORS origin matcher function that supports wildcard patterns.
96+
*
97+
* @param patterns Single pattern, array of patterns, or comma-separated patterns
98+
* @returns Function that returns the origin if it matches, or null/undefined
99+
*/
100+
function createOriginMatcher(
101+
patterns: string | string[]
102+
): (origin: string) => string | undefined | null {
103+
// Normalize to array
104+
let patternList: string[];
105+
if (typeof patterns === 'string') {
106+
// Handle comma-separated patterns
107+
patternList = patterns.includes(',')
108+
? patterns.split(',').map(s => s.trim()).filter(Boolean)
109+
: [patterns];
110+
} else {
111+
patternList = patterns;
112+
}
113+
114+
// Return matcher function
115+
return (requestOrigin: string) => {
116+
for (const pattern of patternList) {
117+
if (matchOriginPattern(requestOrigin, pattern)) {
118+
return requestOrigin;
119+
}
120+
}
121+
return null;
122+
};
123+
}
124+
69125
export class HonoServerPlugin implements Plugin {
70126
name = 'com.objectstack.server.hono';
71127
type = 'server';
@@ -128,12 +184,27 @@ export class HonoServerPlugin implements Plugin {
128184
const credentials = corsOpts.credentials ?? (process.env.CORS_CREDENTIALS !== 'false');
129185
const maxAge = corsOpts.maxAge ?? (process.env.CORS_MAX_AGE ? parseInt(process.env.CORS_MAX_AGE, 10) : 86400);
130186

131-
// When credentials is true, browsers reject wildcard '*' for Access-Control-Allow-Origin.
132-
// Use a function to reflect the request's Origin header instead.
187+
// Determine origin handler based on configuration
133188
let origin: string | string[] | ((origin: string) => string | undefined | null);
134-
if (credentials && configuredOrigin === '*') {
189+
190+
// Check if patterns contain wildcards (*, subdomain patterns, port patterns)
191+
const hasWildcard = (patterns: string | string[]): boolean => {
192+
const list = Array.isArray(patterns) ? patterns : [patterns];
193+
return list.some(p => p.includes('*'));
194+
};
195+
196+
// When credentials is true, browsers reject wildcard '*' for Access-Control-Allow-Origin.
197+
// For wildcard patterns (like "https://*.example.com"), always use a matcher function.
198+
// For exact origins, we can pass them directly as string/array.
199+
if (configuredOrigin === '*' && credentials) {
200+
// Credentials mode with '*' - reflect the request origin
135201
origin = (requestOrigin: string) => requestOrigin || '*';
202+
} else if (hasWildcard(configuredOrigin)) {
203+
// Wildcard patterns (including better-auth style patterns like "https://*.objectui.org")
204+
// Use pattern matcher to support subdomain and port wildcards
205+
origin = createOriginMatcher(configuredOrigin);
136206
} else {
207+
// Exact origin(s) - pass through as-is
137208
origin = configuredOrigin;
138209
}
139210

0 commit comments

Comments
 (0)