Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand Down
23 changes: 22 additions & 1 deletion apps/server/server/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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();
Expand All@@ -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];
Expand Down
65 changes: 2 additions & 63 deletions packages/plugins/plugin-hono-server/src/hono-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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';
Expand DownExpand Up@@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/plugin-hono-server/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,4 +2,5 @@

export * from './hono-plugin';
export * from './adapter';
export * from './pattern-matcher';

90 changes: 34 additions & 56 deletions packages/plugins/plugin-hono-server/src/pattern-matcher.test.ts
Original file line numberDiff line numberDiff line change
@@ -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', () => {
Expand DownExpand Up@@ -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']);
});
});
88 changes: 88 additions & 0 deletions packages/plugins/plugin-hono-server/src/pattern-matcher.ts
Original file line numberDiff line numberDiff line change
@@ -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('*'));
}