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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Fixed
- **CORS wildcard patterns in `@objectstack/hono` adapter (follow-up to PR #1177)** — `createHonoApp()` was the third CORS code path that still treated wildcard origins (e.g. `https://*.objectui.org`) as literal strings when passing them to Hono's `cors()` middleware. Because `apps/server` routes all non-OPTIONS requests through this adapter on Vercel, the browser would see a successful preflight (handled by the Vercel short-circuit) followed by a POST/GET response with no `Access-Control-Allow-Origin` header, blocking every real request. The adapter now imports `hasWildcardPattern` / `createOriginMatcher` from `@objectstack/plugin-hono-server` and uses the same matcher-function branch as `plugin-hono-server`, so all three Hono-based CORS paths share a single source of truth. (`packages/adapters/hono/src/index.ts`)
- **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
Expand Down
3 changes: 3 additions & 0 deletions packages/adapters/hono/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,9 @@
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@objectstack/plugin-hono-server": "workspace:*"
},
"peerDependencies": {
"@objectstack/runtime": "workspace:^",
"hono": "^4.12.8"
Expand Down
80 changes: 79 additions & 1 deletion packages/adapters/hono/src/hono.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import { Hono } from 'hono';

// Mock dispatcher instance accessible across tests
Expand DownExpand Up@@ -947,4 +947,82 @@ describe('createHonoApp', () => {
expect(json.success).toBe(true);
});
});

describe('CORS wildcard origin patterns', () => {
const ORIG_CORS_ORIGIN = process.env.CORS_ORIGIN;
const ORIG_CORS_CREDENTIALS = process.env.CORS_CREDENTIALS;

beforeEach(() => {
delete process.env.CORS_ORIGIN;
delete process.env.CORS_CREDENTIALS;
});

afterAll(() => {
if (ORIG_CORS_ORIGIN === undefined) delete process.env.CORS_ORIGIN;
else process.env.CORS_ORIGIN = ORIG_CORS_ORIGIN;
if (ORIG_CORS_CREDENTIALS === undefined) delete process.env.CORS_CREDENTIALS;
else process.env.CORS_CREDENTIALS = ORIG_CORS_CREDENTIALS;
});

it('matches subdomain wildcard (https://*.example.com) for real subdomains', async () => {
process.env.CORS_ORIGIN = 'https://*.example.com';
const app = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });

const res = await app.request('/api/v1/meta', {
method: 'GET',
headers: { Origin: 'https://app.example.com' },
});
expect(res.headers.get('access-control-allow-origin')).toBe('https://app.example.com');
});

it('matches port wildcard (http://localhost:*) for any localhost port', async () => {
process.env.CORS_ORIGIN = 'http://localhost:*';
const app = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });

const res = await app.request('/api/v1/meta', {
method: 'GET',
headers: { Origin: 'http://localhost:5173' },
});
expect(res.headers.get('access-control-allow-origin')).toBe('http://localhost:5173');
});

it('matches the correct pattern from a comma-separated wildcard list', async () => {
process.env.CORS_ORIGIN =
'https://*.objectui.org,https://*.objectstack.ai,http://localhost:*';
const app = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });

const res = await app.request('/api/v1/meta', {
method: 'GET',
headers: { Origin: 'https://studio.objectstack.ai' },
});
expect(res.headers.get('access-control-allow-origin')).toBe('https://studio.objectstack.ai');
});

it('rejects origins that do not match any wildcard pattern', async () => {
process.env.CORS_ORIGIN = 'https://*.example.com';
const app = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });

const res = await app.request('/api/v1/meta', {
method: 'GET',
headers: { Origin: 'https://evil.com' },
});
// Hono's cors() returns no allow-origin header when the matcher rejects
expect(res.headers.get('access-control-allow-origin')).toBeNull();
});

it('responds to preflight OPTIONS with matched wildcard origin', async () => {
process.env.CORS_ORIGIN = 'https://*.objectui.org';
const app = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });

const res = await app.request('/api/v1/meta', {
method: 'OPTIONS',
headers: {
Origin: 'https://app.objectui.org',
'Access-Control-Request-Method': 'POST',
},
});
expect(res.status).toBe(204);
expect(res.headers.get('access-control-allow-origin')).toBe('https://app.objectui.org');
});
});
});
18 changes: 16 additions & 2 deletions packages/adapters/hono/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { type ObjectKernel, HttpDispatcher, HttpDispatcherResult } from '@objectstack/runtime';
import { createOriginMatcher, hasWildcardPattern } from '@objectstack/plugin-hono-server';

export interface ObjectStackHonoCorsOptions {
/** Enable or disable CORS. Defaults to true. */
Expand DownExpand Up@@ -96,11 +97,24 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
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.
// For wildcard patterns (like "https://*.example.com" or "http://localhost:*") we must
// use a matcher function — Hono's cors() middleware does exact-string matching only and
// treats '*' in patterns as a literal character, so passing wildcard strings straight
// through would silently drop the Access-Control-Allow-Origin header on every real
// request (preflight can still succeed via apps/server's short-circuit, but the
// subsequent POST/GET would be blocked by the browser).
//
// This mirrors `plugin-hono-server`'s CORS wiring and uses the shared pattern matcher
// from `@objectstack/plugin-hono-server` so all Hono-based code paths stay in sync.
let origin: string | string[] | ((origin: string) => string | undefined | null);
if (credentials && configuredOrigin === '*') {
if (configuredOrigin === '*' && credentials) {
// Credentials mode with '*' — reflect the request origin
origin = (requestOrigin: string) => requestOrigin || '*';
} else if (hasWildcardPattern(configuredOrigin)) {
// Wildcard patterns (e.g., "https://*.objectui.org", "http://localhost:*")
origin = createOriginMatcher(configuredOrigin);
} else {
// Exact origin(s) — pass through as-is
origin = configuredOrigin;
}

Expand Down
4 changes: 4 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.