@objectstack/plugin-hono-server 4.0.2 → 4.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,6 +27,7 @@ vi.mock('./adapter', () => ({
27
27
  close: vi.fn(),
28
28
  getRawApp: vi.fn().mockReturnValue({
29
29
  get: vi.fn(),
30
+ use: vi.fn(),
30
31
  })
31
32
  };
32
33
  })
@@ -110,4 +111,126 @@ describe('HonoServerPlugin', () => {
110
111
  // Should register SPA fallback middleware
111
112
  expect(rawApp.get).toHaveBeenCalledWith('/*', expect.anything());
112
113
  });
114
+
115
+ describe('CORS wildcard pattern matching', () => {
116
+ beforeEach(() => {
117
+ vi.clearAllMocks();
118
+ });
119
+
120
+ it('should enable CORS middleware with wildcard subdomain patterns', async () => {
121
+ const plugin = new HonoServerPlugin({
122
+ cors: {
123
+ origins: ['https://*.objectui.org', 'https://*.objectstack.ai'],
124
+ credentials: true
125
+ }
126
+ });
127
+
128
+ await plugin.init(context as PluginContext);
129
+
130
+ const serverInstance = (HonoHttpServer as any).mock.instances[0];
131
+ const rawApp = serverInstance.getRawApp();
132
+
133
+ // CORS middleware should be registered
134
+ expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function));
135
+ });
136
+
137
+ it('should enable CORS middleware with port wildcard patterns', async () => {
138
+ const plugin = new HonoServerPlugin({
139
+ cors: {
140
+ origins: 'http://localhost:*',
141
+ }
142
+ });
143
+
144
+ await plugin.init(context as PluginContext);
145
+
146
+ const serverInstance = (HonoHttpServer as any).mock.instances[0];
147
+ const rawApp = serverInstance.getRawApp();
148
+
149
+ expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function));
150
+ });
151
+
152
+ it('should support comma-separated wildcard patterns', async () => {
153
+ const plugin = new HonoServerPlugin({
154
+ cors: {
155
+ origins: 'https://*.objectui.org,https://*.objectstack.ai',
156
+ }
157
+ });
158
+
159
+ await plugin.init(context as PluginContext);
160
+
161
+ const serverInstance = (HonoHttpServer as any).mock.instances[0];
162
+ const rawApp = serverInstance.getRawApp();
163
+
164
+ expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function));
165
+ });
166
+
167
+ it('should support exact origins without wildcards', async () => {
168
+ const plugin = new HonoServerPlugin({
169
+ cors: {
170
+ origins: ['https://app.example.com', 'https://api.example.com'],
171
+ }
172
+ });
173
+
174
+ await plugin.init(context as PluginContext);
175
+
176
+ const serverInstance = (HonoHttpServer as any).mock.instances[0];
177
+ const rawApp = serverInstance.getRawApp();
178
+
179
+ expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function));
180
+ });
181
+
182
+ it('should support CORS_ORIGIN environment variable with wildcards', async () => {
183
+ const originalEnv = process.env.CORS_ORIGIN;
184
+ process.env.CORS_ORIGIN = 'https://*.objectui.org,https://*.objectstack.ai';
185
+
186
+ const plugin = new HonoServerPlugin();
187
+ await plugin.init(context as PluginContext);
188
+
189
+ const serverInstance = (HonoHttpServer as any).mock.instances[0];
190
+ const rawApp = serverInstance.getRawApp();
191
+
192
+ expect(rawApp.use).toHaveBeenCalledWith('*', expect.any(Function));
193
+
194
+ // Restore environment
195
+ if (originalEnv !== undefined) {
196
+ process.env.CORS_ORIGIN = originalEnv;
197
+ } else {
198
+ delete process.env.CORS_ORIGIN;
199
+ }
200
+ });
201
+
202
+ it('should disable CORS when cors option is false', async () => {
203
+ const plugin = new HonoServerPlugin({
204
+ cors: false
205
+ });
206
+
207
+ await plugin.init(context as PluginContext);
208
+
209
+ const serverInstance = (HonoHttpServer as any).mock.instances[0];
210
+ const rawApp = serverInstance.getRawApp();
211
+
212
+ // CORS middleware should NOT be registered
213
+ expect(rawApp.use).not.toHaveBeenCalled();
214
+ });
215
+
216
+ it('should disable CORS when CORS_ENABLED env is false', async () => {
217
+ const originalEnv = process.env.CORS_ENABLED;
218
+ process.env.CORS_ENABLED = 'false';
219
+
220
+ const plugin = new HonoServerPlugin();
221
+ await plugin.init(context as PluginContext);
222
+
223
+ const serverInstance = (HonoHttpServer as any).mock.instances[0];
224
+ const rawApp = serverInstance.getRawApp();
225
+
226
+ expect(rawApp.use).not.toHaveBeenCalled();
227
+
228
+ // Restore environment
229
+ if (originalEnv !== undefined) {
230
+ process.env.CORS_ENABLED = originalEnv;
231
+ } else {
232
+ delete process.env.CORS_ENABLED;
233
+ }
234
+ });
235
+ });
113
236
  });
@@ -1,10 +1,11 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
- import { Plugin, PluginContext, IHttpServer } from '@objectstack/core';
3
+ import { Plugin, PluginContext, IHttpServer, IDataEngine } from '@objectstack/core';
4
4
  import {
5
5
  RestServerConfig,
6
6
  } from '@objectstack/spec/api';
7
- import { HonoHttpServer } from './adapter';
7
+ import { HonoHttpServer, HonoCorsOptions } from './adapter';
8
+ import { cors } from 'hono/cors';
8
9
  import { serveStatic } from '@hono/node-server/serve-static';
9
10
  import * as fs from 'fs';
10
11
  import * as path from 'path';
@@ -45,33 +46,97 @@ export interface HonoPluginOptions {
45
46
  * @default false
46
47
  */
47
48
  spaFallback?: boolean;
49
+
50
+ /**
51
+ * CORS configuration. Set to `false` to disable entirely.
52
+ * Enabled by default with origin '*'.
53
+ * Can also be controlled via environment variables:
54
+ * CORS_ENABLED, CORS_ORIGIN, CORS_CREDENTIALS, CORS_MAX_AGE
55
+ */
56
+ cors?: HonoCorsOptions | false;
48
57
  }
49
58
 
50
59
  /**
51
60
  * Hono Server Plugin
52
- *
61
+ *
53
62
  * Provides HTTP server capabilities using Hono framework.
54
63
  * Registers the IHttpServer service so other plugins can register routes.
55
- *
64
+ *
56
65
  * Route registration is handled by plugins:
57
66
  * - `@objectstack/rest` → CRUD, metadata, discovery, UI, batch
58
67
  * - `createDispatcherPlugin()` → auth, graphql, analytics, packages, etc.
59
68
  */
69
+ /**
70
+ * Check if an origin matches a pattern with wildcards.
71
+ * Supports patterns like:
72
+ * - "https://*.example.com" - matches any subdomain
73
+ * - "http://localhost:*" - matches any port
74
+ * - "https://*.objectui.org,https://*.objectstack.ai" - comma-separated patterns
75
+ *
76
+ * @param origin The origin to check (e.g., "https://app.example.com")
77
+ * @param pattern The pattern to match against (supports * wildcard)
78
+ * @returns true if origin matches the pattern
79
+ */
80
+ function matchOriginPattern(origin: string, pattern: string): boolean {
81
+ if (pattern === '*') return true;
82
+ if (pattern === origin) return true;
83
+
84
+ // Convert wildcard pattern to regex
85
+ // Escape special regex characters except *
86
+ const regexPattern = pattern
87
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special chars
88
+ .replace(/\*/g, '.*'); // Convert * to .*
89
+
90
+ const regex = new RegExp(`^${regexPattern}$`);
91
+ return regex.test(origin);
92
+ }
93
+
94
+ /**
95
+ * Create a CORS origin matcher function that supports wildcard patterns.
96
+ *
97
+ * @param patterns Single pattern, array of patterns, or comma-separated patterns
98
+ * @returns Function that returns the origin if it matches, or null/undefined
99
+ */
100
+ function createOriginMatcher(
101
+ patterns: string | string[]
102
+ ): (origin: string) => string | undefined | null {
103
+ // Normalize to array
104
+ let patternList: string[];
105
+ if (typeof patterns === 'string') {
106
+ // Handle comma-separated patterns
107
+ patternList = patterns.includes(',')
108
+ ? patterns.split(',').map(s => s.trim()).filter(Boolean)
109
+ : [patterns];
110
+ } else {
111
+ patternList = patterns;
112
+ }
113
+
114
+ // Return matcher function
115
+ return (requestOrigin: string) => {
116
+ for (const pattern of patternList) {
117
+ if (matchOriginPattern(requestOrigin, pattern)) {
118
+ return requestOrigin;
119
+ }
120
+ }
121
+ return null;
122
+ };
123
+ }
124
+
60
125
  export class HonoServerPlugin implements Plugin {
61
126
  name = 'com.objectstack.server.hono';
62
127
  type = 'server';
63
128
  version = '0.9.0';
64
-
129
+
65
130
  // Constants
66
131
  private static readonly DEFAULT_ENDPOINT_PRIORITY = 100;
67
132
  private static readonly CORE_ENDPOINT_PRIORITY = 950;
68
133
  private static readonly DISCOVERY_ENDPOINT_PRIORITY = 900;
69
-
134
+
70
135
  private options: HonoPluginOptions;
71
136
  private server: HonoHttpServer;
72
137
 
73
138
  constructor(options: HonoPluginOptions = {}) {
74
- this.options = {
139
+ this.options = {
75
140
  port: 3000,
76
141
  registerStandardEndpoints: true,
77
142
  useApiRegistry: true,
@@ -86,17 +151,76 @@ export class HonoServerPlugin implements Plugin {
86
151
  * Init phase - Setup HTTP server and register as service
87
152
  */
88
153
  init = async (ctx: PluginContext) => {
89
- ctx.logger.debug('Initializing Hono server plugin', {
154
+ ctx.logger.debug('Initializing Hono server plugin', {
90
155
  port: this.options.port,
91
- staticRoot: this.options.staticRoot
156
+ staticRoot: this.options.staticRoot
92
157
  });
93
-
158
+
94
159
  // Register HTTP server service as IHttpServer
95
160
  // Register as 'http.server' to match core requirements
96
161
  ctx.registerService('http.server', this.server);
97
162
  // Alias 'http-server' for backward compatibility
98
163
  ctx.registerService('http-server', this.server);
99
164
  ctx.logger.debug('HTTP server service registered', { serviceName: 'http.server' });
165
+
166
+ // ─── CORS Middleware ──────────────────────────────────────────────────
167
+ // Enabled by default. Controlled via options.cors or environment variables.
168
+ const corsDisabledByEnv = process.env.CORS_ENABLED === 'false';
169
+ if (this.options.cors !== false && !corsDisabledByEnv) {
170
+ const corsOpts = typeof this.options.cors === 'object' ? this.options.cors : {};
171
+ const enabled = corsOpts.enabled ?? true;
172
+
173
+ if (enabled) {
174
+ let configuredOrigin: string | string[];
175
+ if (corsOpts.origins) {
176
+ configuredOrigin = corsOpts.origins;
177
+ } else if (process.env.CORS_ORIGIN) {
178
+ const envOrigin = process.env.CORS_ORIGIN.trim();
179
+ configuredOrigin = envOrigin.includes(',') ? envOrigin.split(',').map(s => s.trim()) : envOrigin;
180
+ } else {
181
+ configuredOrigin = '*';
182
+ }
183
+
184
+ const credentials = corsOpts.credentials ?? (process.env.CORS_CREDENTIALS !== 'false');
185
+ const maxAge = corsOpts.maxAge ?? (process.env.CORS_MAX_AGE ? parseInt(process.env.CORS_MAX_AGE, 10) : 86400);
186
+
187
+ // Determine origin handler based on configuration
188
+ let origin: string | string[] | ((origin: string) => string | undefined | null);
189
+
190
+ // Check if patterns contain wildcards (*, subdomain patterns, port patterns)
191
+ const hasWildcard = (patterns: string | string[]): boolean => {
192
+ const list = Array.isArray(patterns) ? patterns : [patterns];
193
+ return list.some(p => p.includes('*'));
194
+ };
195
+
196
+ // When credentials is true, browsers reject wildcard '*' for Access-Control-Allow-Origin.
197
+ // For wildcard patterns (like "https://*.example.com"), always use a matcher function.
198
+ // For exact origins, we can pass them directly as string/array.
199
+ if (configuredOrigin === '*' && credentials) {
200
+ // Credentials mode with '*' - reflect the request origin
201
+ origin = (requestOrigin: string) => requestOrigin || '*';
202
+ } else if (hasWildcard(configuredOrigin)) {
203
+ // Wildcard patterns (including better-auth style patterns like "https://*.objectui.org")
204
+ // Use pattern matcher to support subdomain and port wildcards
205
+ origin = createOriginMatcher(configuredOrigin);
206
+ } else {
207
+ // Exact origin(s) - pass through as-is
208
+ origin = configuredOrigin;
209
+ }
210
+
211
+ const rawApp = this.server.getRawApp();
212
+ rawApp.use('*', cors({
213
+ origin: origin as any,
214
+ allowMethods: corsOpts.methods || ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'],
215
+ allowHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
216
+ exposeHeaders: [],
217
+ credentials,
218
+ maxAge,
219
+ }));
220
+
221
+ ctx.logger.debug('CORS middleware enabled', { origin: configuredOrigin, credentials });
222
+ }
223
+ }
100
224
  }
101
225
 
102
226
  /**
@@ -112,8 +236,8 @@ export class HonoServerPlugin implements Plugin {
112
236
  try {
113
237
  const rawKernel = ctx.getKernel() as any;
114
238
  if (rawKernel.plugins) {
115
- const loadedPlugins = rawKernel.plugins instanceof Map
116
- ? Array.from(rawKernel.plugins.values())
239
+ const loadedPlugins = rawKernel.plugins instanceof Map
240
+ ? Array.from(rawKernel.plugins.values())
117
241
  : Array.isArray(rawKernel.plugins) ? rawKernel.plugins : Object.values(rawKernel.plugins);
118
242
 
119
243
  for (const plugin of (loadedPlugins as any[])) {
@@ -123,10 +247,10 @@ export class HonoServerPlugin implements Plugin {
123
247
  // Derive base route from name: @org/console -> console
124
248
  const slug = plugin.slug || plugin.name.split('/').pop();
125
249
  const baseRoute = `/${slug}`;
126
-
127
- ctx.logger.debug(`Auto-mounting UI Plugin: ${plugin.name}`, {
128
- path: baseRoute,
129
- root: plugin.staticPath
250
+
251
+ ctx.logger.debug(`Auto-mounting UI Plugin: ${plugin.name}`, {
252
+ path: baseRoute,
253
+ root: plugin.staticPath
130
254
  });
131
255
 
132
256
  mounts.push({
@@ -161,7 +285,7 @@ export class HonoServerPlugin implements Plugin {
161
285
 
162
286
  if (mounts.length > 0) {
163
287
  const rawApp = this.server.getRawApp();
164
-
288
+
165
289
  for (const mount of mounts) {
166
290
  const mountRoot = path.resolve(process.cwd(), mount.root);
167
291
 
@@ -173,22 +297,22 @@ export class HonoServerPlugin implements Plugin {
173
297
  const mountPath = mount.path || '/';
174
298
  const normalizedPath = mountPath.startsWith('/') ? mountPath : `/${mountPath}`;
175
299
  const routePattern = normalizedPath === '/' ? '/*' : `${normalizedPath.replace(/\/$/, '')}/*`;
176
-
300
+
177
301
  // Routes to register: both /mount and /mount/*
178
302
  const routes = normalizedPath === '/' ? [routePattern] : [normalizedPath, routePattern];
179
303
 
180
- ctx.logger.debug('Mounting static files', {
181
- to: routes,
182
- from: mountRoot,
183
- rewrite: mount.rewrite,
184
- spa: mount.spa
304
+ ctx.logger.debug('Mounting static files', {
305
+ to: routes,
306
+ from: mountRoot,
307
+ rewrite: mount.rewrite,
308
+ spa: mount.spa
185
309
  });
186
310
 
187
311
  routes.forEach(route => {
188
312
  // 1. Serve Static Files
189
313
  rawApp.get(
190
- route,
191
- serveStatic({
314
+ route,
315
+ serveStatic({
192
316
  root: mount.root,
193
317
  rewriteRequestPath: (reqPath) => {
194
318
  if (mount.rewrite && normalizedPath !== '/') {
@@ -208,12 +332,12 @@ export class HonoServerPlugin implements Plugin {
208
332
  // Skip if API path check
209
333
  const config = this.options.restConfig || {};
210
334
  const basePath = config.api?.basePath || '/api';
211
-
335
+
212
336
  if (c.req.path.startsWith(basePath)) {
213
337
  return next();
214
338
  }
215
339
 
216
- return serveStatic({
340
+ return serveStatic({
217
341
  root: mount.root,
218
342
  rewriteRequestPath: () => 'index.html'
219
343
  })(c, next);
@@ -232,7 +356,7 @@ export class HonoServerPlugin implements Plugin {
232
356
 
233
357
  const port = this.options.port ?? 3000;
234
358
  ctx.logger.debug('Starting HTTP server', { port });
235
-
359
+
236
360
  await this.server.listen(port);
237
361
 
238
362
  const actualPort = this.server.getPort();
@@ -281,37 +405,41 @@ export class HonoServerPlugin implements Plugin {
281
405
 
282
406
  ctx.logger.info('Registered discovery endpoints', { prefix });
283
407
 
284
- // Basic CRUD data endpoints — delegate to kernel.broker when available
285
- const getBroker = () => (ctx.getKernel() as any).broker;
408
+ // Basic CRUD data endpoints — delegate to ObjectQL service directly
409
+ const getObjectQL = () => ctx.getService<IDataEngine>('objectql');
286
410
 
287
411
  // Create
288
412
  rawApp.post(`${prefix}/data/:object`, async (c: any) => {
289
- const broker = getBroker();
290
- if (!broker) return c.json({ error: 'Broker not available' }, 500);
413
+ const ql = getObjectQL();
414
+ if (!ql) return c.json({ error: 'Data service not available' }, 503);
291
415
  const object = c.req.param('object');
292
416
  const data = await c.req.json().catch(() => ({}));
293
- const result = await broker.call('data.create', { object, data }, {});
294
- return c.json(result);
417
+ const res = await ql.insert(object, data);
418
+ const record = { ...data, ...res };
419
+ return c.json({ object, id: record.id, record });
295
420
  });
296
421
 
297
422
  // Get by ID
298
423
  rawApp.get(`${prefix}/data/:object/:id`, async (c: any) => {
299
- const broker = getBroker();
300
- if (!broker) return c.json({ error: 'Broker not available' }, 500);
424
+ const ql = getObjectQL();
425
+ if (!ql) return c.json({ error: 'Data service not available' }, 503);
301
426
  const object = c.req.param('object');
302
427
  const id = c.req.param('id');
303
- const result = await broker.call('data.get', { object, id }, {});
304
- return result ? c.json(result) : c.json({ error: 'Not found' }, 404);
428
+ let all = await ql.find(object);
429
+ if (!all) all = [];
430
+ const match = all.find((i: any) => i.id === id);
431
+ return match ? c.json({ object, id, record: match }) : c.json({ error: 'Not found' }, 404);
305
432
  });
306
433
 
307
434
  // Find / List
308
435
  rawApp.get(`${prefix}/data/:object`, async (c: any) => {
309
- const broker = getBroker();
310
- if (!broker) return c.json({ error: 'Broker not available' }, 500);
436
+ const ql = getObjectQL();
437
+ if (!ql) return c.json({ error: 'Data service not available' }, 503);
311
438
  const object = c.req.param('object');
312
- const filters = c.req.query();
313
- const result = await broker.call('data.find', { object, filters }, {});
314
- return c.json(result);
439
+ let all = await ql.find(object);
440
+ if (!Array.isArray(all) && all && (all as any).value) all = (all as any).value;
441
+ if (!all) all = [];
442
+ return c.json({ object, records: all, total: all.length });
315
443
  });
316
444
 
317
445
  ctx.logger.debug('Registered standard CRUD data endpoints', { prefix });
@@ -0,0 +1,180 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ /**
4
+ * Check if an origin matches a pattern with wildcards.
5
+ * Supports patterns like:
6
+ * - "https://*.example.com" - matches any subdomain
7
+ * - "http://localhost:*" - matches any port
8
+ * - "https://*.objectui.org,https://*.objectstack.ai" - comma-separated patterns
9
+ *
10
+ * @param origin The origin to check (e.g., "https://app.example.com")
11
+ * @param pattern The pattern to match against (supports * wildcard)
12
+ * @returns true if origin matches the pattern
13
+ */
14
+ function matchOriginPattern(origin: string, pattern: string): boolean {
15
+ if (pattern === '*') return true;
16
+ if (pattern === origin) return true;
17
+
18
+ // Convert wildcard pattern to regex
19
+ // Escape special regex characters except *
20
+ const regexPattern = pattern
21
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special chars
22
+ .replace(/\*/g, '.*'); // Convert * to .*
23
+
24
+ const regex = new RegExp(`^${regexPattern}$`);
25
+ return regex.test(origin);
26
+ }
27
+
28
+ /**
29
+ * Create a CORS origin matcher function that supports wildcard patterns.
30
+ *
31
+ * @param patterns Single pattern, array of patterns, or comma-separated patterns
32
+ * @returns Function that returns the origin if it matches, or null/undefined
33
+ */
34
+ function createOriginMatcher(
35
+ patterns: string | string[]
36
+ ): (origin: string) => string | undefined | null {
37
+ // Normalize to array
38
+ let patternList: string[];
39
+ if (typeof patterns === 'string') {
40
+ // Handle comma-separated patterns
41
+ patternList = patterns.includes(',')
42
+ ? patterns.split(',').map(s => s.trim()).filter(Boolean)
43
+ : [patterns];
44
+ } else {
45
+ patternList = patterns;
46
+ }
47
+
48
+ // Return matcher function
49
+ return (requestOrigin: string) => {
50
+ for (const pattern of patternList) {
51
+ if (matchOriginPattern(requestOrigin, pattern)) {
52
+ return requestOrigin;
53
+ }
54
+ }
55
+ return null;
56
+ };
57
+ }
58
+
59
+ describe('matchOriginPattern', () => {
60
+ describe('exact matching', () => {
61
+ it('should match exact origin', () => {
62
+ expect(matchOriginPattern('https://app.example.com', 'https://app.example.com')).toBe(true);
63
+ });
64
+
65
+ it('should not match different origins', () => {
66
+ expect(matchOriginPattern('https://app.example.com', 'https://api.example.com')).toBe(false);
67
+ });
68
+
69
+ it('should match wildcard "*"', () => {
70
+ expect(matchOriginPattern('https://any.domain.com', '*')).toBe(true);
71
+ });
72
+ });
73
+
74
+ describe('subdomain wildcard matching', () => {
75
+ it('should match subdomain with wildcard pattern', () => {
76
+ expect(matchOriginPattern('https://app.objectui.org', 'https://*.objectui.org')).toBe(true);
77
+ expect(matchOriginPattern('https://api.objectui.org', 'https://*.objectui.org')).toBe(true);
78
+ expect(matchOriginPattern('https://studio.objectui.org', 'https://*.objectui.org')).toBe(true);
79
+ });
80
+
81
+ it('should match multi-level subdomains', () => {
82
+ expect(matchOriginPattern('https://app.dev.objectui.org', 'https://*.objectui.org')).toBe(true);
83
+ expect(matchOriginPattern('https://api.staging.objectui.org', 'https://*.objectui.org')).toBe(true);
84
+ });
85
+
86
+ it('should not match different domain', () => {
87
+ expect(matchOriginPattern('https://app.example.com', 'https://*.objectui.org')).toBe(false);
88
+ });
89
+
90
+ it('should not match different protocol', () => {
91
+ expect(matchOriginPattern('http://app.objectui.org', 'https://*.objectui.org')).toBe(false);
92
+ });
93
+ });
94
+
95
+ describe('port wildcard matching', () => {
96
+ it('should match localhost with any port', () => {
97
+ expect(matchOriginPattern('http://localhost:3000', 'http://localhost:*')).toBe(true);
98
+ expect(matchOriginPattern('http://localhost:8080', 'http://localhost:*')).toBe(true);
99
+ expect(matchOriginPattern('http://localhost:5173', 'http://localhost:*')).toBe(true);
100
+ });
101
+
102
+ it('should not match different host', () => {
103
+ expect(matchOriginPattern('http://example.com:3000', 'http://localhost:*')).toBe(false);
104
+ });
105
+ });
106
+
107
+ describe('multiple wildcard patterns', () => {
108
+ it('should match wildcard in multiple positions', () => {
109
+ expect(matchOriginPattern('https://app.objectui.org', 'https://*.objectui.*')).toBe(true);
110
+ expect(matchOriginPattern('https://api.objectui.com', 'https://*.objectui.*')).toBe(true);
111
+ });
112
+ });
113
+ });
114
+
115
+ describe('createOriginMatcher', () => {
116
+ describe('single pattern', () => {
117
+ it('should create matcher for single string pattern', () => {
118
+ const matcher = createOriginMatcher('https://*.objectui.org');
119
+
120
+ expect(matcher('https://app.objectui.org')).toBe('https://app.objectui.org');
121
+ expect(matcher('https://api.objectui.org')).toBe('https://api.objectui.org');
122
+ expect(matcher('https://example.com')).toBe(null);
123
+ });
124
+ });
125
+
126
+ describe('array of patterns', () => {
127
+ it('should create matcher for array of patterns', () => {
128
+ const matcher = createOriginMatcher([
129
+ 'https://*.objectui.org',
130
+ 'https://*.objectstack.ai'
131
+ ]);
132
+
133
+ expect(matcher('https://app.objectui.org')).toBe('https://app.objectui.org');
134
+ expect(matcher('https://api.objectstack.ai')).toBe('https://api.objectstack.ai');
135
+ expect(matcher('https://example.com')).toBe(null);
136
+ });
137
+ });
138
+
139
+ describe('comma-separated patterns', () => {
140
+ it('should parse comma-separated patterns', () => {
141
+ const matcher = createOriginMatcher('https://*.objectui.org,https://*.objectstack.ai');
142
+
143
+ expect(matcher('https://app.objectui.org')).toBe('https://app.objectui.org');
144
+ expect(matcher('https://api.objectstack.ai')).toBe('https://api.objectstack.ai');
145
+ expect(matcher('https://example.com')).toBe(null);
146
+ });
147
+
148
+ it('should handle whitespace in comma-separated patterns', () => {
149
+ const matcher = createOriginMatcher('https://*.objectui.org, https://*.objectstack.ai , http://localhost:*');
150
+
151
+ expect(matcher('https://app.objectui.org')).toBe('https://app.objectui.org');
152
+ expect(matcher('https://api.objectstack.ai')).toBe('https://api.objectstack.ai');
153
+ expect(matcher('http://localhost:3000')).toBe('http://localhost:3000');
154
+ expect(matcher('https://example.com')).toBe(null);
155
+ });
156
+ });
157
+
158
+ describe('mixed exact and wildcard patterns', () => {
159
+ it('should match both exact and wildcard patterns', () => {
160
+ const matcher = createOriginMatcher([
161
+ 'https://app.example.com',
162
+ 'https://*.objectui.org'
163
+ ]);
164
+
165
+ expect(matcher('https://app.example.com')).toBe('https://app.example.com');
166
+ expect(matcher('https://dev.objectui.org')).toBe('https://dev.objectui.org');
167
+ expect(matcher('https://other.com')).toBe(null);
168
+ });
169
+ });
170
+
171
+ describe('localhost patterns', () => {
172
+ it('should match localhost with port wildcard', () => {
173
+ const matcher = createOriginMatcher('http://localhost:*');
174
+
175
+ expect(matcher('http://localhost:3000')).toBe('http://localhost:3000');
176
+ expect(matcher('http://localhost:8080')).toBe('http://localhost:8080');
177
+ expect(matcher('http://127.0.0.1:3000')).toBe(null);
178
+ });
179
+ });
180
+ });