diff --git a/CHANGELOG.md b/CHANGELOG.md index 00ea67c5bb..d9f10b50d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **CORS wildcard patterns on Vercel deployments** — `CORS_ORIGIN` values containing wildcard patterns (e.g. `https://*.objectui.org,https://*.objectstack.ai,http://localhost:*`) no longer cause browser CORS errors when `apps/server` is deployed to Vercel. The Vercel entrypoint's OPTIONS preflight short-circuit previously matched origins with a literal `Array.includes()`, treating `*` as a plain character and rejecting legitimate subdomains. It now shares the same pattern-matching logic as the Hono plugin's `cors()` middleware via new exports `createOriginMatcher` / `hasWildcardPattern` / `matchOriginPattern` / `normalizeOriginPatterns` from `@objectstack/plugin-hono-server`. (`apps/server/server/index.ts`, `packages/plugins/plugin-hono-server/src/pattern-matcher.ts`) + ### Added - **Claude Code integration (`CLAUDE.md`)** — Added root `CLAUDE.md` file so that [Claude Code](https://docs.anthropic.com/en/docs/claude-code) automatically loads the project's system prompt when launched in the repository. Content is synced with `.github/copilot-instructions.md` and includes build/test quick-reference commands, all prime directives, monorepo structure, protocol domains, coding patterns, and domain-specific prompt references. This complements the existing GitHub Copilot instructions and `skills/` directory. - **AI Skills documentation pages** — Added two new documentation pages covering the Skills System: diff --git a/apps/server/server/index.ts b/apps/server/server/index.ts index c6cba41b3b..71b3fe41a9 100644 --- a/apps/server/server/index.ts +++ b/apps/server/server/index.ts @@ -9,6 +9,7 @@ import { ObjectKernel } from '@objectstack/runtime'; import { createHonoApp } from '@objectstack/hono'; +import { createOriginMatcher, hasWildcardPattern } from '@objectstack/plugin-hono-server'; import { getRequestListener } from '@hono/node-server'; import type { Hono } from 'hono'; import stackConfig from '../objectstack.config'; @@ -102,8 +103,20 @@ function corsMaxAge(): number { * * - If `CORS_ORIGIN` is unset, reflects the request `Origin` (or `*` when * credentials are disabled and no `Origin` is sent). - * - If `CORS_ORIGIN` is a comma-separated list, matches against it. + * - If `CORS_ORIGIN` contains wildcard patterns (e.g. `https://*.example.com` + * or `http://localhost:*`), matches them against the request origin using + * the same rules as the Hono plugin's CORS middleware — see + * `@objectstack/plugin-hono-server`'s `createOriginMatcher`. + * - If `CORS_ORIGIN` is a comma-separated list of exact origins, matches + * against it directly. * - Returns `null` if the origin is disallowed. + * + * Keeping the wildcard semantics identical to the Hono plugin is critical: + * the Vercel handler short-circuits OPTIONS preflight responses here + * *before* the Hono app runs. If this function rejected a wildcard origin + * that the plugin itself would have accepted, the browser would see a + * missing `Access-Control-Allow-Origin` header and block every subsequent + * request. */ function resolveAllowOrigin(requestOrigin: string | null): string | null { const credentials = corsCredentials(); @@ -121,6 +134,14 @@ function resolveAllowOrigin(requestOrigin: string | null): string | null { return '*'; } + // Wildcard patterns (e.g. "https://*.objectui.org", "http://localhost:*") + // must be matched using the shared pattern matcher — plain Array.includes() + // treats '*' as a literal character and produces spurious CORS errors. + if (hasWildcardPattern(envOrigin)) { + if (!requestOrigin) return null; + return createOriginMatcher(envOrigin)(requestOrigin); + } + const allowed = envOrigin.includes(',') ? envOrigin.split(',').map((s: string) => s.trim()).filter(Boolean) : [envOrigin]; diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 652dc0dbdb..477c236ca8 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -9,6 +9,7 @@ import { cors } from 'hono/cors'; import { serveStatic } from '@hono/node-server/serve-static'; import * as fs from 'fs'; import * as path from 'path'; +import { createOriginMatcher, hasWildcardPattern } from './pattern-matcher'; export interface StaticMount { root: string; @@ -66,62 +67,6 @@ 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'; @@ -187,19 +132,13 @@ export class HonoServerPlugin implements Plugin { // Determine origin handler based on configuration let origin: string | string[] | ((origin: string) => string | undefined | null); - // 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)) { + } else if (hasWildcardPattern(configuredOrigin)) { // Wildcard patterns (including better-auth style patterns like "https://*.objectui.org") // Use pattern matcher to support subdomain and port wildcards origin = createOriginMatcher(configuredOrigin); diff --git a/packages/plugins/plugin-hono-server/src/index.ts b/packages/plugins/plugin-hono-server/src/index.ts index a2aa314b55..0c9d072862 100644 --- a/packages/plugins/plugin-hono-server/src/index.ts +++ b/packages/plugins/plugin-hono-server/src/index.ts @@ -2,4 +2,5 @@ export * from './hono-plugin'; export * from './adapter'; +export * from './pattern-matcher'; diff --git a/packages/plugins/plugin-hono-server/src/pattern-matcher.test.ts b/packages/plugins/plugin-hono-server/src/pattern-matcher.test.ts index 8eb08537ca..396cad179d 100644 --- a/packages/plugins/plugin-hono-server/src/pattern-matcher.test.ts +++ b/packages/plugins/plugin-hono-server/src/pattern-matcher.test.ts @@ -1,60 +1,5 @@ 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; - }; -} +import { matchOriginPattern, createOriginMatcher, hasWildcardPattern, normalizeOriginPatterns } from './pattern-matcher'; describe('matchOriginPattern', () => { describe('exact matching', () => { @@ -177,4 +122,37 @@ describe('createOriginMatcher', () => { expect(matcher('http://127.0.0.1:3000')).toBe(null); }); }); + + describe('empty origin handling', () => { + it('should return null for empty request origin', () => { + const matcher = createOriginMatcher('https://*.example.com'); + expect(matcher('')).toBe(null); + }); + }); +}); + +describe('hasWildcardPattern', () => { + it('detects wildcards in a single string', () => { + expect(hasWildcardPattern('https://*.example.com')).toBe(true); + expect(hasWildcardPattern('https://example.com')).toBe(false); + }); + + it('detects wildcards in an array', () => { + expect(hasWildcardPattern(['https://a.com', 'https://*.b.com'])).toBe(true); + expect(hasWildcardPattern(['https://a.com', 'https://b.com'])).toBe(false); + }); +}); + +describe('normalizeOriginPatterns', () => { + it('splits comma-separated strings and trims whitespace', () => { + expect(normalizeOriginPatterns('a, b , c')).toEqual(['a', 'b', 'c']); + }); + + it('passes arrays through after trimming', () => { + expect(normalizeOriginPatterns([' a ', 'b'])).toEqual(['a', 'b']); + }); + + it('drops empty entries', () => { + expect(normalizeOriginPatterns('a,,b')).toEqual(['a', 'b']); + }); }); diff --git a/packages/plugins/plugin-hono-server/src/pattern-matcher.ts b/packages/plugins/plugin-hono-server/src/pattern-matcher.ts new file mode 100644 index 0000000000..0c5d17412b --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/pattern-matcher.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * CORS origin pattern matching utilities. + * + * Supports the same wildcard syntax as better-auth's `trustedOrigins`: + * - `*` → matches any origin + * - `https://*.example.com` → matches any subdomain + * - `http://localhost:*` → matches any port + * - Comma-separated list of the above + * + * These helpers are shared between the Hono plugin's CORS middleware and + * consumers that need to apply CORS headers outside the Hono request + * pipeline (e.g., the Vercel serverless entrypoint's preflight + * short-circuit in `apps/server`). Keeping a single implementation + * ensures both paths stay consistent — divergence caused bug where + * wildcard `CORS_ORIGIN` values worked locally but produced browser + * CORS errors on Vercel. + */ + +/** + * Check if an origin matches a pattern with wildcards. + * + * @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 + */ +export function matchOriginPattern(origin: string, pattern: string): boolean { + if (pattern === '*') return true; + if (pattern === origin) return true; + + // Convert wildcard pattern to regex: + // 1. Escape regex special chars EXCEPT `*` + // 2. Replace `*` with `.*` + const regexPattern = pattern + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*'); + + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(origin); +} + +/** + * Normalize a single string / comma-separated string / array into a + * trimmed array of non-empty patterns. + */ +export function normalizeOriginPatterns(patterns: string | string[]): string[] { + if (Array.isArray(patterns)) { + return patterns.map(p => p.trim()).filter(Boolean); + } + return patterns.includes(',') + ? patterns.split(',').map(s => s.trim()).filter(Boolean) + : [patterns.trim()].filter(Boolean); +} + +/** + * Create a CORS origin matcher function that supports wildcard patterns. + * + * The returned function follows Hono's `cors({ origin })` contract: + * given the request's `Origin` header, it returns the origin to echo + * back in `Access-Control-Allow-Origin`, or `null` if the origin is not + * allowed. + * + * @param patterns Single pattern, array of patterns, or comma-separated patterns + */ +export function createOriginMatcher( + patterns: string | string[] +): (origin: string) => string | null { + const patternList = normalizeOriginPatterns(patterns); + + return (requestOrigin: string) => { + if (!requestOrigin) return null; + for (const pattern of patternList) { + if (matchOriginPattern(requestOrigin, pattern)) { + return requestOrigin; + } + } + return null; + }; +} + +/** + * True if any pattern in the given list contains a `*` wildcard. + */ +export function hasWildcardPattern(patterns: string | string[]): boolean { + const list = Array.isArray(patterns) ? patterns : [patterns]; + return list.some(p => p.includes('*')); +}