From dc92b2dc8ca6658a6d3612c33d997f544e0c645e Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:08:35 +0000 Subject: [PATCH 1/2] feat: add better-auth wildcard CORS support to Hono plugin - Add matchOriginPattern() and createOriginMatcher() functions to support wildcard patterns - Support subdomain wildcards (e.g., https://*.objectui.org) - Support port wildcards (e.g., http://localhost:*) - Support comma-separated patterns for better-auth compatibility - Add comprehensive unit tests for pattern matching - Update CORS middleware to use pattern matching for wildcard origins - Maintain backward compatibility with exact origin matching Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/a7ac1ca8-a54d-49a5-8469-ec453eb41b8f Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/hono-plugin.test.ts | 123 ++++++++++++ .../plugin-hono-server/src/hono-plugin.ts | 77 +++++++- .../src/pattern-matcher.test.ts | 180 ++++++++++++++++++ 3 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 packages/plugins/plugin-hono-server/src/pattern-matcher.test.ts diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts index 8c2b2d69f..9f2955422 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts @@ -27,6 +27,7 @@ vi.mock('./adapter', () => ({ close: vi.fn(), getRawApp: vi.fn().mockReturnValue({ get: vi.fn(), + use: vi.fn(), }) }; }) @@ -110,4 +111,126 @@ describe('HonoServerPlugin', () => { // Should register SPA fallback middleware expect(rawApp.get).toHaveBeenCalledWith('/*', expect.anything()); }); + + describe('CORS wildcard pattern matching', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should enable CORS middleware with wildcard subdomain patterns', async () => { + const plugin = new HonoServerPlugin({ + cors: { + origins: ['https://*.objectui.org', 'https://*.objectstack.ai'], + credentials: true + } + }); + + await plugin.init(context as PluginContext); + + const serverInstance = (HonoHttpServer as any).mock.instances[0]; + const rawApp = serverInstance.getRawApp(); + + // CORS middleware should be registered + expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function)); + }); + + it('should enable CORS middleware with port wildcard patterns', async () => { + const plugin = new HonoServerPlugin({ + cors: { + origins: 'http://localhost:*', + } + }); + + await plugin.init(context as PluginContext); + + const serverInstance = (HonoHttpServer as any).mock.instances[0]; + const rawApp = serverInstance.getRawApp(); + + expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function)); + }); + + it('should support comma-separated wildcard patterns', async () => { + const plugin = new HonoServerPlugin({ + cors: { + origins: 'https://*.objectui.org,https://*.objectstack.ai', + } + }); + + await plugin.init(context as PluginContext); + + const serverInstance = (HonoHttpServer as any).mock.instances[0]; + const rawApp = serverInstance.getRawApp(); + + expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function)); + }); + + it('should support exact origins without wildcards', async () => { + const plugin = new HonoServerPlugin({ + cors: { + origins: ['https://app.example.com', 'https://api.example.com'], + } + }); + + await plugin.init(context as PluginContext); + + const serverInstance = (HonoHttpServer as any).mock.instances[0]; + const rawApp = serverInstance.getRawApp(); + + expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function)); + }); + + it('should support CORS_ORIGIN environment variable with wildcards', async () => { + const originalEnv = process.env.CORS_ORIGIN; + process.env.CORS_ORIGIN = 'https://*.objectui.org,https://*.objectstack.ai'; + + const plugin = new HonoServerPlugin(); + await plugin.init(context as PluginContext); + + const serverInstance = (HonoHttpServer as any).mock.instances[0]; + const rawApp = serverInstance.getRawApp(); + + expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function)); + + // Restore environment + if (originalEnv !== undefined) { + process.env.CORS_ORIGIN = originalEnv; + } else { + delete process.env.CORS_ORIGIN; + } + }); + + it('should disable CORS when cors option is false', async () => { + const plugin = new HonoServerPlugin({ + cors: false + }); + + await plugin.init(context as PluginContext); + + const serverInstance = (HonoHttpServer as any).mock.instances[0]; + const rawApp = serverInstance.getRawApp(); + + // CORS middleware should NOT be registered + expect(rawApp.use).not.toHaveBeenCalled(); + }); + + it('should disable CORS when CORS_ENABLED env is false', async () => { + const originalEnv = process.env.CORS_ENABLED; + process.env.CORS_ENABLED = 'false'; + + const plugin = new HonoServerPlugin(); + await plugin.init(context as PluginContext); + + const serverInstance = (HonoHttpServer as any).mock.instances[0]; + const rawApp = serverInstance.getRawApp(); + + expect(rawApp.use).not.toHaveBeenCalled(); + + // Restore environment + if (originalEnv !== undefined) { + process.env.CORS_ENABLED = originalEnv; + } else { + delete process.env.CORS_ENABLED; + } + }); + }); }); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index e3870ab2c..2d1dae9d0 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -66,6 +66,62 @@ export interface HonoPluginOptions { * - `@objectstack/rest` → CRUD, metadata, discovery, UI, batch * - `createDispatcherPlugin()` → auth, graphql, analytics, packages, etc. */ +/** + * Check if an origin matches a pattern with wildcards. + * Supports patterns like: + * - "https://*.example.com" - matches any subdomain + * - "http://localhost:*" - matches any port + * - "https://*.objectui.org,https://*.objectstack.ai" - comma-separated patterns + * + * @param origin The origin to check (e.g., "https://app.example.com") + * @param pattern The pattern to match against (supports * wildcard) + * @returns true if origin matches the pattern + */ +function matchOriginPattern(origin: string, pattern: string): boolean { + if (pattern === '*') return true; + if (pattern === origin) return true; + + // Convert wildcard pattern to regex + // Escape special regex characters except * + const regexPattern = pattern + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special chars + .replace(/\*/g, '.*'); // Convert * to .* + + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(origin); +} + +/** + * Create a CORS origin matcher function that supports wildcard patterns. + * + * @param patterns Single pattern, array of patterns, or comma-separated patterns + * @returns Function that returns the origin if it matches, or null/undefined + */ +function createOriginMatcher( + patterns: string | string[] +): (origin: string) => string | undefined | null { + // Normalize to array + let patternList: string[]; + if (typeof patterns === 'string') { + // Handle comma-separated patterns + patternList = patterns.includes(',') + ? patterns.split(',').map(s => s.trim()).filter(Boolean) + : [patterns]; + } else { + patternList = patterns; + } + + // Return matcher function + return (requestOrigin: string) => { + for (const pattern of patternList) { + if (matchOriginPattern(requestOrigin, pattern)) { + return requestOrigin; + } + } + return null; + }; +} + export class HonoServerPlugin implements Plugin { name = 'com.objectstack.server.hono'; type = 'server'; @@ -128,12 +184,27 @@ export class HonoServerPlugin implements Plugin { const credentials = corsOpts.credentials ?? (process.env.CORS_CREDENTIALS !== 'false'); const maxAge = corsOpts.maxAge ?? (process.env.CORS_MAX_AGE ? parseInt(process.env.CORS_MAX_AGE, 10) : 86400); - // When credentials is true, browsers reject wildcard '*' for Access-Control-Allow-Origin. - // Use a function to reflect the request's Origin header instead. + // Determine origin handler based on configuration let origin: string | string[] | ((origin: string) => string | undefined | null); - if (credentials && configuredOrigin === '*') { + + // Check if patterns contain wildcards (*, subdomain patterns, port patterns) + const hasWildcard = (patterns: string | string[]): boolean => { + const list = Array.isArray(patterns) ? patterns : [patterns]; + return list.some(p => p.includes('*')); + }; + + // When credentials is true, browsers reject wildcard '*' for Access-Control-Allow-Origin. + // For wildcard patterns (like "https://*.example.com"), always use a matcher function. + // For exact origins, we can pass them directly as string/array. + if (configuredOrigin === '*' && credentials) { + // Credentials mode with '*' - reflect the request origin origin = (requestOrigin: string) => requestOrigin || '*'; + } else if (hasWildcard(configuredOrigin)) { + // Wildcard patterns (including better-auth style patterns like "https://*.objectui.org") + // Use pattern matcher to support subdomain and port wildcards + origin = createOriginMatcher(configuredOrigin); } else { + // Exact origin(s) - pass through as-is origin = configuredOrigin; } diff --git a/packages/plugins/plugin-hono-server/src/pattern-matcher.test.ts b/packages/plugins/plugin-hono-server/src/pattern-matcher.test.ts new file mode 100644 index 000000000..8eb08537c --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/pattern-matcher.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect } from 'vitest'; + +/** + * Check if an origin matches a pattern with wildcards. + * Supports patterns like: + * - "https://*.example.com" - matches any subdomain + * - "http://localhost:*" - matches any port + * - "https://*.objectui.org,https://*.objectstack.ai" - comma-separated patterns + * + * @param origin The origin to check (e.g., "https://app.example.com") + * @param pattern The pattern to match against (supports * wildcard) + * @returns true if origin matches the pattern + */ +function matchOriginPattern(origin: string, pattern: string): boolean { + if (pattern === '*') return true; + if (pattern === origin) return true; + + // Convert wildcard pattern to regex + // Escape special regex characters except * + const regexPattern = pattern + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special chars + .replace(/\*/g, '.*'); // Convert * to .* + + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(origin); +} + +/** + * Create a CORS origin matcher function that supports wildcard patterns. + * + * @param patterns Single pattern, array of patterns, or comma-separated patterns + * @returns Function that returns the origin if it matches, or null/undefined + */ +function createOriginMatcher( + patterns: string | string[] +): (origin: string) => string | undefined | null { + // Normalize to array + let patternList: string[]; + if (typeof patterns === 'string') { + // Handle comma-separated patterns + patternList = patterns.includes(',') + ? patterns.split(',').map(s => s.trim()).filter(Boolean) + : [patterns]; + } else { + patternList = patterns; + } + + // Return matcher function + return (requestOrigin: string) => { + for (const pattern of patternList) { + if (matchOriginPattern(requestOrigin, pattern)) { + return requestOrigin; + } + } + return null; + }; +} + +describe('matchOriginPattern', () => { + describe('exact matching', () => { + it('should match exact origin', () => { + expect(matchOriginPattern('https://app.example.com', 'https://app.example.com')).toBe(true); + }); + + it('should not match different origins', () => { + expect(matchOriginPattern('https://app.example.com', 'https://api.example.com')).toBe(false); + }); + + it('should match wildcard "*"', () => { + expect(matchOriginPattern('https://any.domain.com', '*')).toBe(true); + }); + }); + + describe('subdomain wildcard matching', () => { + it('should match subdomain with wildcard pattern', () => { + expect(matchOriginPattern('https://app.objectui.org', 'https://*.objectui.org')).toBe(true); + expect(matchOriginPattern('https://api.objectui.org', 'https://*.objectui.org')).toBe(true); + expect(matchOriginPattern('https://studio.objectui.org', 'https://*.objectui.org')).toBe(true); + }); + + it('should match multi-level subdomains', () => { + expect(matchOriginPattern('https://app.dev.objectui.org', 'https://*.objectui.org')).toBe(true); + expect(matchOriginPattern('https://api.staging.objectui.org', 'https://*.objectui.org')).toBe(true); + }); + + it('should not match different domain', () => { + expect(matchOriginPattern('https://app.example.com', 'https://*.objectui.org')).toBe(false); + }); + + it('should not match different protocol', () => { + expect(matchOriginPattern('http://app.objectui.org', 'https://*.objectui.org')).toBe(false); + }); + }); + + describe('port wildcard matching', () => { + it('should match localhost with any port', () => { + expect(matchOriginPattern('http://localhost:3000', 'http://localhost:*')).toBe(true); + expect(matchOriginPattern('http://localhost:8080', 'http://localhost:*')).toBe(true); + expect(matchOriginPattern('http://localhost:5173', 'http://localhost:*')).toBe(true); + }); + + it('should not match different host', () => { + expect(matchOriginPattern('http://example.com:3000', 'http://localhost:*')).toBe(false); + }); + }); + + describe('multiple wildcard patterns', () => { + it('should match wildcard in multiple positions', () => { + expect(matchOriginPattern('https://app.objectui.org', 'https://*.objectui.*')).toBe(true); + expect(matchOriginPattern('https://api.objectui.com', 'https://*.objectui.*')).toBe(true); + }); + }); +}); + +describe('createOriginMatcher', () => { + describe('single pattern', () => { + it('should create matcher for single string pattern', () => { + const matcher = createOriginMatcher('https://*.objectui.org'); + + expect(matcher('https://app.objectui.org')).toBe('https://app.objectui.org'); + expect(matcher('https://api.objectui.org')).toBe('https://api.objectui.org'); + expect(matcher('https://example.com')).toBe(null); + }); + }); + + describe('array of patterns', () => { + it('should create matcher for array of patterns', () => { + const matcher = createOriginMatcher([ + 'https://*.objectui.org', + 'https://*.objectstack.ai' + ]); + + expect(matcher('https://app.objectui.org')).toBe('https://app.objectui.org'); + expect(matcher('https://api.objectstack.ai')).toBe('https://api.objectstack.ai'); + expect(matcher('https://example.com')).toBe(null); + }); + }); + + describe('comma-separated patterns', () => { + it('should parse comma-separated patterns', () => { + const matcher = createOriginMatcher('https://*.objectui.org,https://*.objectstack.ai'); + + expect(matcher('https://app.objectui.org')).toBe('https://app.objectui.org'); + expect(matcher('https://api.objectstack.ai')).toBe('https://api.objectstack.ai'); + expect(matcher('https://example.com')).toBe(null); + }); + + it('should handle whitespace in comma-separated patterns', () => { + const matcher = createOriginMatcher('https://*.objectui.org, https://*.objectstack.ai , http://localhost:*'); + + expect(matcher('https://app.objectui.org')).toBe('https://app.objectui.org'); + expect(matcher('https://api.objectstack.ai')).toBe('https://api.objectstack.ai'); + expect(matcher('http://localhost:3000')).toBe('http://localhost:3000'); + expect(matcher('https://example.com')).toBe(null); + }); + }); + + describe('mixed exact and wildcard patterns', () => { + it('should match both exact and wildcard patterns', () => { + const matcher = createOriginMatcher([ + 'https://app.example.com', + 'https://*.objectui.org' + ]); + + expect(matcher('https://app.example.com')).toBe('https://app.example.com'); + expect(matcher('https://dev.objectui.org')).toBe('https://dev.objectui.org'); + expect(matcher('https://other.com')).toBe(null); + }); + }); + + describe('localhost patterns', () => { + it('should match localhost with port wildcard', () => { + const matcher = createOriginMatcher('http://localhost:*'); + + expect(matcher('http://localhost:3000')).toBe('http://localhost:3000'); + expect(matcher('http://localhost:8080')).toBe('http://localhost:8080'); + expect(matcher('http://127.0.0.1:3000')).toBe(null); + }); + }); +}); From 1ef7843cef5606cb6976d9c10e081b12bd6986a4 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:09:11 +0000 Subject: [PATCH 2/2] docs: add CORS wildcard pattern documentation to Hono plugin README Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/a7ac1ca8-a54d-49a5-8469-ec453eb41b8f Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/plugins/plugin-hono-server/README.md | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-hono-server/README.md b/packages/plugins/plugin-hono-server/README.md index b192b7d6d..b4dadb21c 100644 --- a/packages/plugins/plugin-hono-server/README.md +++ b/packages/plugins/plugin-hono-server/README.md @@ -8,6 +8,7 @@ HTTP Server adapter for ObjectStack using Hono. - **Fast**: Built on Hono, a high-performance web framework. - **Full Protocol Support**: Automatically provides all ObjectStack Runtime endpoints (Auth, Data, Metadata, etc.). - **Middleware**: Supports standard Hono middleware. +- **Wildcard CORS**: Supports wildcard patterns in CORS origins (compatible with better-auth). ## Usage @@ -18,7 +19,7 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; const kernel = new ObjectKernel(); // Register the server plugin -kernel.use(new HonoServerPlugin({ +kernel.use(new HonoServerPlugin({ port: 3000, restConfig: { api: { @@ -30,6 +31,75 @@ kernel.use(new HonoServerPlugin({ await kernel.start(); ``` +## CORS Configuration + +The Hono server plugin supports flexible CORS configuration with wildcard pattern matching. + +### Basic CORS + +```typescript +kernel.use(new HonoServerPlugin({ + port: 3000, + cors: { + origins: ['https://app.example.com'], + credentials: true + } +})); +``` + +### Wildcard Patterns (better-auth compatible) + +```typescript +// Subdomain wildcards +kernel.use(new HonoServerPlugin({ + cors: { + origins: ['https://*.objectui.org', 'https://*.objectstack.ai'], + credentials: true + } +})); + +// Port wildcards (useful for development) +kernel.use(new HonoServerPlugin({ + cors: { + origins: 'http://localhost:*' + } +})); + +// Comma-separated patterns +kernel.use(new HonoServerPlugin({ + cors: { + origins: 'https://*.objectui.org,https://*.objectstack.ai,http://localhost:*' + } +})); +``` + +### Environment Variables + +CORS can also be configured via environment variables: + +```bash +# Single origin +CORS_ORIGIN=https://app.example.com + +# Wildcard patterns (comma-separated) +CORS_ORIGIN=https://*.objectui.org,https://*.objectstack.ai + +# Disable CORS +CORS_ENABLED=false + +# Additional options +CORS_CREDENTIALS=true +CORS_MAX_AGE=86400 +``` + +### Disable CORS + +```typescript +kernel.use(new HonoServerPlugin({ + cors: false // Completely disable CORS +})); +``` + ## Architecture 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.