@objectstack/plugin-auth 3.2.6 → 3.2.8

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.
@@ -410,4 +410,349 @@ describe('AuthManager', () => {
410
410
  );
411
411
  });
412
412
  });
413
+
414
+ describe('trustedOrigins passthrough', () => {
415
+ it('should forward trustedOrigins to betterAuth when provided', () => {
416
+ let capturedConfig: any;
417
+ (betterAuth as any).mockImplementation((config: any) => {
418
+ capturedConfig = config;
419
+ return { handler: vi.fn(), api: {} };
420
+ });
421
+
422
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
423
+ const manager = new AuthManager({
424
+ secret: 'test-secret-at-least-32-chars-long',
425
+ baseUrl: 'http://localhost:3000',
426
+ trustedOrigins: ['https://*.objectos.app', 'http://localhost:*'],
427
+ });
428
+ manager.getAuthInstance();
429
+ warnSpy.mockRestore();
430
+
431
+ expect(capturedConfig.trustedOrigins).toEqual([
432
+ 'https://*.objectos.app',
433
+ 'http://localhost:*',
434
+ ]);
435
+ });
436
+
437
+ it('should NOT include trustedOrigins key when not provided', () => {
438
+ let capturedConfig: any;
439
+ (betterAuth as any).mockImplementation((config: any) => {
440
+ capturedConfig = config;
441
+ return { handler: vi.fn(), api: {} };
442
+ });
443
+
444
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
445
+ const manager = new AuthManager({
446
+ secret: 'test-secret-at-least-32-chars-long',
447
+ baseUrl: 'http://localhost:3000',
448
+ });
449
+ manager.getAuthInstance();
450
+ warnSpy.mockRestore();
451
+
452
+ expect(capturedConfig).not.toHaveProperty('trustedOrigins');
453
+ });
454
+
455
+ it('should NOT include trustedOrigins key when array is empty', () => {
456
+ let capturedConfig: any;
457
+ (betterAuth as any).mockImplementation((config: any) => {
458
+ capturedConfig = config;
459
+ return { handler: vi.fn(), api: {} };
460
+ });
461
+
462
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
463
+ const manager = new AuthManager({
464
+ secret: 'test-secret-at-least-32-chars-long',
465
+ baseUrl: 'http://localhost:3000',
466
+ trustedOrigins: [],
467
+ });
468
+ manager.getAuthInstance();
469
+ warnSpy.mockRestore();
470
+
471
+ expect(capturedConfig).not.toHaveProperty('trustedOrigins');
472
+ });
473
+ });
474
+
475
+ describe('setRuntimeBaseUrl', () => {
476
+ it('should update baseURL before auth instance is created', () => {
477
+ let capturedConfig: any;
478
+ (betterAuth as any).mockImplementation((config: any) => {
479
+ capturedConfig = config;
480
+ return { handler: vi.fn(), api: {} };
481
+ });
482
+
483
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
484
+ const manager = new AuthManager({
485
+ secret: 'test-secret-at-least-32-chars-long',
486
+ baseUrl: 'http://localhost:3000',
487
+ });
488
+
489
+ manager.setRuntimeBaseUrl('http://localhost:3002');
490
+ manager.getAuthInstance();
491
+ warnSpy.mockRestore();
492
+
493
+ expect(capturedConfig.baseURL).toBe('http://localhost:3002');
494
+ });
495
+
496
+ it('should be a no-op and warn when called after auth instance is created', () => {
497
+ let capturedConfig: any;
498
+ (betterAuth as any).mockImplementation((config: any) => {
499
+ capturedConfig = config;
500
+ return { handler: vi.fn(), api: {} };
501
+ });
502
+
503
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
504
+ const manager = new AuthManager({
505
+ secret: 'test-secret-at-least-32-chars-long',
506
+ baseUrl: 'http://localhost:3000',
507
+ });
508
+
509
+ // Force auth instance creation
510
+ manager.getAuthInstance();
511
+ expect(capturedConfig.baseURL).toBe('http://localhost:3000');
512
+
513
+ // Now try to change — should warn and not affect the already-created instance
514
+ manager.setRuntimeBaseUrl('http://localhost:4000');
515
+
516
+ expect(warnSpy).toHaveBeenCalledWith(
517
+ expect.stringContaining('setRuntimeBaseUrl() called after the auth instance was already created'),
518
+ );
519
+ warnSpy.mockRestore();
520
+ });
521
+
522
+ it('should override the default fallback (localhost:3000) when no baseUrl was configured', () => {
523
+ let capturedConfig: any;
524
+ (betterAuth as any).mockImplementation((config: any) => {
525
+ capturedConfig = config;
526
+ return { handler: vi.fn(), api: {} };
527
+ });
528
+
529
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
530
+ const manager = new AuthManager({
531
+ secret: 'test-secret-at-least-32-chars-long',
532
+ });
533
+
534
+ manager.setRuntimeBaseUrl('http://localhost:3002');
535
+ manager.getAuthInstance();
536
+ warnSpy.mockRestore();
537
+
538
+ expect(capturedConfig.baseURL).toBe('http://localhost:3002');
539
+ });
540
+ });
541
+
542
+ describe('socialProviders passthrough', () => {
543
+ it('should forward socialProviders to betterAuth when provided', () => {
544
+ let capturedConfig: any;
545
+ (betterAuth as any).mockImplementation((config: any) => {
546
+ capturedConfig = config;
547
+ return { handler: vi.fn(), api: {} };
548
+ });
549
+
550
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
551
+ const manager = new AuthManager({
552
+ secret: 'test-secret-at-least-32-chars-long',
553
+ baseUrl: 'http://localhost:3000',
554
+ socialProviders: {
555
+ google: { clientId: 'gid', clientSecret: 'gsecret' },
556
+ github: { clientId: 'ghid', clientSecret: 'ghsecret' },
557
+ },
558
+ });
559
+ manager.getAuthInstance();
560
+ warnSpy.mockRestore();
561
+
562
+ expect(capturedConfig.socialProviders).toEqual({
563
+ google: { clientId: 'gid', clientSecret: 'gsecret' },
564
+ github: { clientId: 'ghid', clientSecret: 'ghsecret' },
565
+ });
566
+ });
567
+
568
+ it('should NOT include socialProviders when not provided', () => {
569
+ let capturedConfig: any;
570
+ (betterAuth as any).mockImplementation((config: any) => {
571
+ capturedConfig = config;
572
+ return { handler: vi.fn(), api: {} };
573
+ });
574
+
575
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
576
+ const manager = new AuthManager({
577
+ secret: 'test-secret-at-least-32-chars-long',
578
+ baseUrl: 'http://localhost:3000',
579
+ });
580
+ manager.getAuthInstance();
581
+ warnSpy.mockRestore();
582
+
583
+ expect(capturedConfig).not.toHaveProperty('socialProviders');
584
+ });
585
+ });
586
+
587
+ describe('emailAndPassword passthrough', () => {
588
+ it('should default emailAndPassword to enabled: true', () => {
589
+ let capturedConfig: any;
590
+ (betterAuth as any).mockImplementation((config: any) => {
591
+ capturedConfig = config;
592
+ return { handler: vi.fn(), api: {} };
593
+ });
594
+
595
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
596
+ const manager = new AuthManager({
597
+ secret: 'test-secret-at-least-32-chars-long',
598
+ baseUrl: 'http://localhost:3000',
599
+ });
600
+ manager.getAuthInstance();
601
+ warnSpy.mockRestore();
602
+
603
+ expect(capturedConfig.emailAndPassword.enabled).toBe(true);
604
+ });
605
+
606
+ it('should forward extended emailAndPassword options', () => {
607
+ let capturedConfig: any;
608
+ (betterAuth as any).mockImplementation((config: any) => {
609
+ capturedConfig = config;
610
+ return { handler: vi.fn(), api: {} };
611
+ });
612
+
613
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
614
+ const manager = new AuthManager({
615
+ secret: 'test-secret-at-least-32-chars-long',
616
+ baseUrl: 'http://localhost:3000',
617
+ emailAndPassword: {
618
+ enabled: true,
619
+ minPasswordLength: 12,
620
+ maxPasswordLength: 64,
621
+ requireEmailVerification: true,
622
+ autoSignIn: false,
623
+ revokeSessionsOnPasswordReset: true,
624
+ },
625
+ });
626
+ manager.getAuthInstance();
627
+ warnSpy.mockRestore();
628
+
629
+ expect(capturedConfig.emailAndPassword).toEqual({
630
+ enabled: true,
631
+ minPasswordLength: 12,
632
+ maxPasswordLength: 64,
633
+ requireEmailVerification: true,
634
+ autoSignIn: false,
635
+ revokeSessionsOnPasswordReset: true,
636
+ });
637
+ });
638
+ });
639
+
640
+ describe('emailVerification passthrough', () => {
641
+ it('should forward emailVerification when provided', () => {
642
+ let capturedConfig: any;
643
+ (betterAuth as any).mockImplementation((config: any) => {
644
+ capturedConfig = config;
645
+ return { handler: vi.fn(), api: {} };
646
+ });
647
+
648
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
649
+ const manager = new AuthManager({
650
+ secret: 'test-secret-at-least-32-chars-long',
651
+ baseUrl: 'http://localhost:3000',
652
+ emailVerification: {
653
+ sendOnSignUp: true,
654
+ expiresIn: 1800,
655
+ },
656
+ });
657
+ manager.getAuthInstance();
658
+ warnSpy.mockRestore();
659
+
660
+ expect(capturedConfig.emailVerification).toEqual({
661
+ sendOnSignUp: true,
662
+ expiresIn: 1800,
663
+ });
664
+ });
665
+
666
+ it('should NOT include emailVerification when not provided', () => {
667
+ let capturedConfig: any;
668
+ (betterAuth as any).mockImplementation((config: any) => {
669
+ capturedConfig = config;
670
+ return { handler: vi.fn(), api: {} };
671
+ });
672
+
673
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
674
+ const manager = new AuthManager({
675
+ secret: 'test-secret-at-least-32-chars-long',
676
+ baseUrl: 'http://localhost:3000',
677
+ });
678
+ manager.getAuthInstance();
679
+ warnSpy.mockRestore();
680
+
681
+ expect(capturedConfig).not.toHaveProperty('emailVerification');
682
+ });
683
+ });
684
+
685
+ describe('advanced options passthrough', () => {
686
+ it('should forward crossSubDomainCookies when provided', () => {
687
+ let capturedConfig: any;
688
+ (betterAuth as any).mockImplementation((config: any) => {
689
+ capturedConfig = config;
690
+ return { handler: vi.fn(), api: {} };
691
+ });
692
+
693
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
694
+ const manager = new AuthManager({
695
+ secret: 'test-secret-at-least-32-chars-long',
696
+ baseUrl: 'http://localhost:3000',
697
+ advanced: {
698
+ crossSubDomainCookies: {
699
+ enabled: true,
700
+ domain: '.objectos.app',
701
+ },
702
+ useSecureCookies: true,
703
+ },
704
+ });
705
+ manager.getAuthInstance();
706
+ warnSpy.mockRestore();
707
+
708
+ expect(capturedConfig.advanced).toEqual({
709
+ crossSubDomainCookies: {
710
+ enabled: true,
711
+ domain: '.objectos.app',
712
+ },
713
+ useSecureCookies: true,
714
+ });
715
+ });
716
+
717
+ it('should forward cookiePrefix and disableCSRFCheck', () => {
718
+ let capturedConfig: any;
719
+ (betterAuth as any).mockImplementation((config: any) => {
720
+ capturedConfig = config;
721
+ return { handler: vi.fn(), api: {} };
722
+ });
723
+
724
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
725
+ const manager = new AuthManager({
726
+ secret: 'test-secret-at-least-32-chars-long',
727
+ baseUrl: 'http://localhost:3000',
728
+ advanced: {
729
+ disableCSRFCheck: true,
730
+ cookiePrefix: 'objectos',
731
+ },
732
+ });
733
+ manager.getAuthInstance();
734
+ warnSpy.mockRestore();
735
+
736
+ expect(capturedConfig.advanced.disableCSRFCheck).toBe(true);
737
+ expect(capturedConfig.advanced.cookiePrefix).toBe('objectos');
738
+ });
739
+
740
+ it('should NOT include advanced when not provided', () => {
741
+ let capturedConfig: any;
742
+ (betterAuth as any).mockImplementation((config: any) => {
743
+ capturedConfig = config;
744
+ return { handler: vi.fn(), api: {} };
745
+ });
746
+
747
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
748
+ const manager = new AuthManager({
749
+ secret: 'test-secret-at-least-32-chars-long',
750
+ baseUrl: 'http://localhost:3000',
751
+ });
752
+ manager.getAuthInstance();
753
+ warnSpy.mockRestore();
754
+
755
+ expect(capturedConfig).not.toHaveProperty('advanced');
756
+ });
757
+ });
413
758
  });
@@ -106,11 +106,42 @@ export class AuthManager {
106
106
  ...AUTH_VERIFICATION_CONFIG,
107
107
  },
108
108
 
109
- // Email configuration
109
+ // Social / OAuth providers
110
+ ...(this.config.socialProviders ? { socialProviders: this.config.socialProviders as any } : {}),
111
+
112
+ // Email and password configuration
110
113
  emailAndPassword: {
111
- enabled: true,
114
+ enabled: this.config.emailAndPassword?.enabled ?? true,
115
+ ...(this.config.emailAndPassword?.disableSignUp != null
116
+ ? { disableSignUp: this.config.emailAndPassword.disableSignUp } : {}),
117
+ ...(this.config.emailAndPassword?.requireEmailVerification != null
118
+ ? { requireEmailVerification: this.config.emailAndPassword.requireEmailVerification } : {}),
119
+ ...(this.config.emailAndPassword?.minPasswordLength != null
120
+ ? { minPasswordLength: this.config.emailAndPassword.minPasswordLength } : {}),
121
+ ...(this.config.emailAndPassword?.maxPasswordLength != null
122
+ ? { maxPasswordLength: this.config.emailAndPassword.maxPasswordLength } : {}),
123
+ ...(this.config.emailAndPassword?.resetPasswordTokenExpiresIn != null
124
+ ? { resetPasswordTokenExpiresIn: this.config.emailAndPassword.resetPasswordTokenExpiresIn } : {}),
125
+ ...(this.config.emailAndPassword?.autoSignIn != null
126
+ ? { autoSignIn: this.config.emailAndPassword.autoSignIn } : {}),
127
+ ...(this.config.emailAndPassword?.revokeSessionsOnPasswordReset != null
128
+ ? { revokeSessionsOnPasswordReset: this.config.emailAndPassword.revokeSessionsOnPasswordReset } : {}),
112
129
  },
113
130
 
131
+ // Email verification
132
+ ...(this.config.emailVerification ? {
133
+ emailVerification: {
134
+ ...(this.config.emailVerification.sendOnSignUp != null
135
+ ? { sendOnSignUp: this.config.emailVerification.sendOnSignUp } : {}),
136
+ ...(this.config.emailVerification.sendOnSignIn != null
137
+ ? { sendOnSignIn: this.config.emailVerification.sendOnSignIn } : {}),
138
+ ...(this.config.emailVerification.autoSignInAfterVerification != null
139
+ ? { autoSignInAfterVerification: this.config.emailVerification.autoSignInAfterVerification } : {}),
140
+ ...(this.config.emailVerification.expiresIn != null
141
+ ? { expiresIn: this.config.emailVerification.expiresIn } : {}),
142
+ },
143
+ } : {}),
144
+
114
145
  // Session configuration
115
146
  session: {
116
147
  ...AUTH_SESSION_CONFIG,
@@ -120,6 +151,23 @@ export class AuthManager {
120
151
 
121
152
  // better-auth plugins — registered based on AuthPluginConfig flags
122
153
  plugins: this.buildPluginList(),
154
+
155
+ // Trusted origins for CSRF protection (supports wildcards like "https://*.example.com")
156
+ ...(this.config.trustedOrigins?.length ? { trustedOrigins: this.config.trustedOrigins } : {}),
157
+
158
+ // Advanced options (cross-subdomain cookies, secure cookies, CSRF, etc.)
159
+ ...(this.config.advanced ? {
160
+ advanced: {
161
+ ...(this.config.advanced.crossSubDomainCookies
162
+ ? { crossSubDomainCookies: this.config.advanced.crossSubDomainCookies } : {}),
163
+ ...(this.config.advanced.useSecureCookies != null
164
+ ? { useSecureCookies: this.config.advanced.useSecureCookies } : {}),
165
+ ...(this.config.advanced.disableCSRFCheck != null
166
+ ? { disableCSRFCheck: this.config.advanced.disableCSRFCheck } : {}),
167
+ ...(this.config.advanced.cookiePrefix != null
168
+ ? { cookiePrefix: this.config.advanced.cookiePrefix } : {}),
169
+ },
170
+ } : {}),
123
171
  };
124
172
 
125
173
  return betterAuth(betterAuthConfig);
@@ -224,6 +272,27 @@ export class AuthManager {
224
272
  return envSecret;
225
273
  }
226
274
 
275
+ /**
276
+ * Update the base URL at runtime.
277
+ *
278
+ * This **must** be called before the first request triggers lazy
279
+ * initialisation of the better-auth instance — typically from a
280
+ * `kernel:ready` hook where the actual server port is known.
281
+ *
282
+ * If the auth instance has already been created this is a no-op and
283
+ * a warning is emitted.
284
+ */
285
+ setRuntimeBaseUrl(url: string): void {
286
+ if (this.auth) {
287
+ console.warn(
288
+ '[AuthManager] setRuntimeBaseUrl() called after the auth instance was already created — ignoring. ' +
289
+ 'Ensure this method is called before the first request.',
290
+ );
291
+ return;
292
+ }
293
+ this.config = { ...this.config, baseUrl: url };
294
+ }
295
+
227
296
  /**
228
297
  * Get the underlying better-auth instance
229
298
  * Useful for advanced use cases
@@ -265,6 +265,95 @@ describe('AuthPlugin', () => {
265
265
  // Should NOT throw
266
266
  });
267
267
 
268
+ it('should auto-detect baseUrl from http-server port when port differs', async () => {
269
+ const mockRawApp = { all: vi.fn() };
270
+ const mockHttpServer = {
271
+ post: vi.fn(), get: vi.fn(), put: vi.fn(), delete: vi.fn(),
272
+ patch: vi.fn(), use: vi.fn(),
273
+ getRawApp: vi.fn(() => mockRawApp),
274
+ getPort: vi.fn(() => 3002),
275
+ };
276
+
277
+ mockContext.getService = vi.fn((name: string) => {
278
+ if (name === 'http-server') return mockHttpServer;
279
+ throw new Error(`Service not found: ${name}`);
280
+ });
281
+
282
+ // AuthPlugin configured with default port 3000, but server will be on 3002
283
+ const registeredAuthManager = (mockContext.registerService as any).mock.calls[0][1];
284
+ const setRuntimeSpy = vi.spyOn(registeredAuthManager, 'setRuntimeBaseUrl');
285
+
286
+ await authPlugin.start(mockContext);
287
+ await hookCapture.trigger('kernel:ready');
288
+
289
+ expect(setRuntimeSpy).toHaveBeenCalledWith('http://localhost:3002');
290
+ expect(mockContext.logger.info).toHaveBeenCalledWith(
291
+ expect.stringContaining('Auth baseUrl auto-updated to http://localhost:3002'),
292
+ );
293
+ });
294
+
295
+ it('should NOT update baseUrl when port matches configured value', async () => {
296
+ const localHookCapture = createHookCapture();
297
+ const localPlugin = new AuthPlugin({
298
+ secret: 'test-secret-at-least-32-chars-long',
299
+ baseUrl: 'http://localhost:3000',
300
+ });
301
+ mockContext.hook = localHookCapture.hookFn;
302
+ await localPlugin.init(mockContext);
303
+
304
+ const mockRawApp = { all: vi.fn() };
305
+ const mockHttpServer = {
306
+ post: vi.fn(), get: vi.fn(), put: vi.fn(), delete: vi.fn(),
307
+ patch: vi.fn(), use: vi.fn(),
308
+ getRawApp: vi.fn(() => mockRawApp),
309
+ getPort: vi.fn(() => 3000),
310
+ };
311
+
312
+ mockContext.getService = vi.fn((name: string) => {
313
+ if (name === 'http-server') return mockHttpServer;
314
+ throw new Error(`Service not found: ${name}`);
315
+ });
316
+
317
+ const registeredAuthManager = (mockContext.registerService as any).mock.calls.at(-1)[1];
318
+ const setRuntimeSpy = vi.spyOn(registeredAuthManager, 'setRuntimeBaseUrl');
319
+
320
+ await localPlugin.start(mockContext);
321
+ await localHookCapture.trigger('kernel:ready');
322
+
323
+ expect(setRuntimeSpy).not.toHaveBeenCalled();
324
+ });
325
+
326
+ it('should auto-detect baseUrl when no baseUrl configured (uses default fallback)', async () => {
327
+ const localHookCapture = createHookCapture();
328
+ // No baseUrl — defaults to http://localhost:3000 internally
329
+ const localPlugin = new AuthPlugin({
330
+ secret: 'test-secret-at-least-32-chars-long',
331
+ });
332
+ mockContext.hook = localHookCapture.hookFn;
333
+ await localPlugin.init(mockContext);
334
+
335
+ const mockRawApp = { all: vi.fn() };
336
+ const mockHttpServer = {
337
+ post: vi.fn(), get: vi.fn(), put: vi.fn(), delete: vi.fn(),
338
+ patch: vi.fn(), use: vi.fn(),
339
+ getRawApp: vi.fn(() => mockRawApp),
340
+ getPort: vi.fn(() => 3002),
341
+ };
342
+
343
+ mockContext.getService = vi.fn((name: string) => {
344
+ if (name === 'http-server') return mockHttpServer;
345
+ throw new Error(`Service not found: ${name}`);
346
+ });
347
+
348
+ const registeredAuthManager = (mockContext.registerService as any).mock.calls.at(-1)[1];
349
+ const setRuntimeSpy = vi.spyOn(registeredAuthManager, 'setRuntimeBaseUrl');
350
+
351
+ await localPlugin.start(mockContext);
352
+ await localHookCapture.trigger('kernel:ready');
353
+
354
+ expect(setRuntimeSpy).toHaveBeenCalledWith('http://localhost:3002');
355
+ });
356
+
268
357
  it('should throw error if auth not initialized', async () => {
269
358
  const uninitializedPlugin = new AuthPlugin({
270
359
  secret: 'test-secret',
@@ -3,6 +3,12 @@
3
3
  import { Plugin, PluginContext, IHttpServer } from '@objectstack/core';
4
4
  import { AuthConfig } from '@objectstack/spec/system';
5
5
  import { AuthManager } from './auth-manager.js';
6
+ import {
7
+ SysUser, SysSession, SysAccount, SysVerification,
8
+ SysOrganization, SysMember, SysInvitation,
9
+ SysTeam, SysTeamMember,
10
+ SysApiKey, SysTwoFactor,
11
+ } from './objects/index.js';
6
12
 
7
13
  /**
8
14
  * Auth Plugin Options
@@ -43,6 +49,7 @@ export interface AuthPluginOptions extends Partial<AuthConfig> {
43
49
  *
44
50
  * This plugin registers:
45
51
  * - `auth` service (auth manager instance) — always
52
+ * - `app.com.objectstack.system` service (system object definitions) — always
46
53
  * - HTTP routes for authentication endpoints — only when HTTP server is available
47
54
  *
48
55
  * Integrates with better-auth library to provide comprehensive
@@ -88,7 +95,23 @@ export class AuthPlugin implements Plugin {
88
95
 
89
96
  // Register auth service
90
97
  ctx.registerService('auth', this.authManager);
91
-
98
+
99
+ // Register system objects as an app service so ObjectQLPlugin
100
+ // auto-discovers them via the `app.*` convention.
101
+ ctx.registerService('app.com.objectstack.system', {
102
+ id: 'com.objectstack.system',
103
+ name: 'System',
104
+ version: '1.0.0',
105
+ type: 'plugin',
106
+ namespace: 'sys',
107
+ objects: [
108
+ SysUser, SysSession, SysAccount, SysVerification,
109
+ SysOrganization, SysMember, SysInvitation,
110
+ SysTeam, SysTeamMember,
111
+ SysApiKey, SysTwoFactor,
112
+ ],
113
+ });
114
+
92
115
  ctx.logger.info('Auth Plugin initialized successfully');
93
116
  }
94
117
 
@@ -114,6 +137,27 @@ export class AuthPlugin implements Plugin {
114
137
  }
115
138
 
116
139
  if (httpServer) {
140
+ // Auto-detect the actual server URL when no explicit baseUrl was
141
+ // configured, or when the configured baseUrl uses a different port
142
+ // than the running server (e.g. port 3000 configured but 3002 bound).
143
+ // getPort() is optional on IHttpServer; duck-type check for it.
144
+ const serverWithPort = httpServer as IHttpServer & { getPort?: () => number };
145
+ if (this.authManager && typeof serverWithPort.getPort === 'function') {
146
+ const actualPort = serverWithPort.getPort();
147
+ if (actualPort) {
148
+ const configuredUrl = this.options.baseUrl || 'http://localhost:3000';
149
+ const configuredOrigin = new URL(configuredUrl).origin;
150
+ const actualUrl = `http://localhost:${actualPort}`;
151
+
152
+ if (configuredOrigin !== actualUrl) {
153
+ this.authManager.setRuntimeBaseUrl(actualUrl);
154
+ ctx.logger.info(
155
+ `Auth baseUrl auto-updated to ${actualUrl} (configured: ${configuredUrl})`,
156
+ );
157
+ }
158
+ }
159
+ }
160
+
117
161
  // Route registration errors should propagate (server misconfiguration)
118
162
  this.registerAuthRoutes(httpServer, ctx);
119
163
  ctx.logger.info(`Auth routes registered at ${this.options.basePath}`);