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 number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Fixed
- **Cross-origin auth tokens stripped in `@objectstack/hono` adapter (follow-up to PR #1178)** — `createHonoApp()` was not exposing `set-auth-token` via `Access-Control-Expose-Headers`, diverging from `plugin-hono-server`'s CORS wiring. On Vercel deployments (where all traffic flows through `createHonoApp()`), the browser stripped the header from every response, preventing the better-auth `bearer()` plugin from delivering rotated session tokens to cross-origin clients. Cross-origin sessions silently broke even after the wildcard fixes in #1177/#1178. The adapter now always includes `set-auth-token` in `exposeHeaders`, merged with any user-supplied values, mirroring the invariant established in commit `151dd19c`. (`packages/adapters/hono/src/index.ts`)
- **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`)

Expand Down
61 changes: 61 additions & 0 deletions packages/adapters/hono/src/hono.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1025,4 +1025,65 @@ describe('createHonoApp', () => {
expect(res.headers.get('access-control-allow-origin')).toBe('https://app.objectui.org');
});
});

describe('CORS expose-headers defaults', () => {
// `set-auth-token` must always be exposed so the better-auth bearer()
// plugin can deliver rotated session tokens to cross-origin clients.
// This mirrors plugin-hono-server's CORS wiring — all three Hono-based
// CORS sites must stay in lockstep on this default.
const ORIG_CORS_ORIGIN = process.env.CORS_ORIGIN;

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

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

it('always exposes set-auth-token by default', async () => {
const app = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });

const res = await app.request('/api/v1/meta', {
method: 'GET',
headers: { Origin: 'https://app.example.com' },
});
const exposed = res.headers.get('access-control-expose-headers') || '';
expect(exposed.toLowerCase()).toContain('set-auth-token');
});

it('merges user-supplied exposeHeaders with set-auth-token (does not replace)', async () => {
const app = createHonoApp({
kernel: mockKernel,
prefix: '/api/v1',
cors: { exposeHeaders: ['X-Custom-Header'] },
});

const res = await app.request('/api/v1/meta', {
method: 'GET',
headers: { Origin: 'https://app.example.com' },
});
const exposed = (res.headers.get('access-control-expose-headers') || '').toLowerCase();
expect(exposed).toContain('set-auth-token');
expect(exposed).toContain('x-custom-header');
});

it('does not duplicate set-auth-token when user also supplies it', async () => {
const app = createHonoApp({
kernel: mockKernel,
prefix: '/api/v1',
cors: { exposeHeaders: ['set-auth-token', 'X-Other'] },
});

const res = await app.request('/api/v1/meta', {
method: 'GET',
headers: { Origin: 'https://app.example.com' },
});
const exposed = (res.headers.get('access-control-expose-headers') || '').toLowerCase();
const occurrences = exposed.split(',').map(s => s.trim()).filter(s => s === 'set-auth-token');
expect(occurrences.length).toBe(1);
expect(exposed).toContain('x-other');
});
});
});
17 changes: 16 additions & 1 deletion packages/adapters/hono/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,26 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
origin = configuredOrigin;
}

// Always include `set-auth-token` in exposed headers so that the
// better-auth `bearer()` plugin (registered by plugin-auth) can
// deliver rotated session tokens to cross-origin clients. Without
// this, browsers strip the header from every response, the client
// never sees the new token, and cross-origin sessions silently
// break even when preflight and the actual request both succeed.
//
// This mirrors `plugin-hono-server`'s CORS wiring — all three
// Hono-based CORS sites must stay in lockstep on this default.
const defaultExposeHeaders = ['set-auth-token'];
const exposeHeaders = Array.from(new Set([
...defaultExposeHeaders,
...(corsOpts.exposeHeaders ?? []),
]));

app.use('*', cors({
origin: origin as any,
allowMethods: corsOpts.methods || ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'],
allowHeaders: corsOpts.allowHeaders || ['Content-Type', 'Authorization', 'X-Requested-With'],
exposeHeaders: corsOpts.exposeHeaders || [],
exposeHeaders,
credentials,
maxAge,
}));
Expand Down