@objectstack/plugin-auth 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.
@@ -434,7 +434,7 @@ describe('AuthManager', () => {
434
434
  ]);
435
435
  });
436
436
 
437
- it('should NOT include trustedOrigins key when not provided', () => {
437
+ it('should default to localhost wildcard when trustedOrigins not provided', () => {
438
438
  let capturedConfig: any;
439
439
  (betterAuth as any).mockImplementation((config: any) => {
440
440
  capturedConfig = config;
@@ -449,10 +449,10 @@ describe('AuthManager', () => {
449
449
  manager.getAuthInstance();
450
450
  warnSpy.mockRestore();
451
451
 
452
- expect(capturedConfig).not.toHaveProperty('trustedOrigins');
452
+ expect(capturedConfig.trustedOrigins).toEqual(['http://localhost:*']);
453
453
  });
454
454
 
455
- it('should NOT include trustedOrigins key when array is empty', () => {
455
+ it('should default to localhost wildcard when trustedOrigins array is empty', () => {
456
456
  let capturedConfig: any;
457
457
  (betterAuth as any).mockImplementation((config: any) => {
458
458
  capturedConfig = config;
@@ -468,7 +468,7 @@ describe('AuthManager', () => {
468
468
  manager.getAuthInstance();
469
469
  warnSpy.mockRestore();
470
470
 
471
- expect(capturedConfig).not.toHaveProperty('trustedOrigins');
471
+ expect(capturedConfig.trustedOrigins).toEqual(['http://localhost:*']);
472
472
  });
473
473
  });
474
474
 
@@ -755,4 +755,129 @@ describe('AuthManager', () => {
755
755
  expect(capturedConfig).not.toHaveProperty('advanced');
756
756
  });
757
757
  });
758
+
759
+ describe('getPublicConfig', () => {
760
+ it('should return safe public configuration', () => {
761
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
762
+ const manager = new AuthManager({
763
+ secret: 'test-secret-at-least-32-chars-long',
764
+ baseUrl: 'http://localhost:3000',
765
+ socialProviders: {
766
+ google: {
767
+ clientId: 'google-client-id',
768
+ clientSecret: 'google-client-secret',
769
+ enabled: true,
770
+ },
771
+ github: {
772
+ clientId: 'github-client-id',
773
+ clientSecret: 'github-client-secret',
774
+ },
775
+ },
776
+ emailAndPassword: {
777
+ enabled: true,
778
+ disableSignUp: false,
779
+ requireEmailVerification: true,
780
+ },
781
+ plugins: {
782
+ twoFactor: true,
783
+ organization: true,
784
+ },
785
+ });
786
+ warnSpy.mockRestore();
787
+
788
+ const config = manager.getPublicConfig();
789
+
790
+ // Should include social providers without secrets
791
+ expect(config.socialProviders).toHaveLength(2);
792
+ expect(config.socialProviders[0]).toEqual({
793
+ id: 'google',
794
+ name: 'Google',
795
+ enabled: true,
796
+ });
797
+ expect(config.socialProviders[1]).toEqual({
798
+ id: 'github',
799
+ name: 'GitHub',
800
+ enabled: true,
801
+ });
802
+
803
+ // Should NOT include sensitive data
804
+ expect(config).not.toHaveProperty('secret');
805
+ expect(config.socialProviders[0]).not.toHaveProperty('clientSecret');
806
+ expect(config.socialProviders[0]).not.toHaveProperty('clientId');
807
+
808
+ // Should include email/password config
809
+ expect(config.emailPassword).toEqual({
810
+ enabled: true,
811
+ disableSignUp: false,
812
+ requireEmailVerification: true,
813
+ });
814
+
815
+ // Should include features
816
+ expect(config.features).toEqual({
817
+ twoFactor: true,
818
+ passkeys: false,
819
+ magicLink: false,
820
+ organization: true,
821
+ });
822
+ });
823
+
824
+ it('should filter out disabled providers', () => {
825
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
826
+ const manager = new AuthManager({
827
+ secret: 'test-secret-at-least-32-chars-long',
828
+ socialProviders: {
829
+ google: {
830
+ clientId: 'google-client-id',
831
+ clientSecret: 'google-client-secret',
832
+ enabled: true,
833
+ },
834
+ github: {
835
+ clientId: 'github-client-id',
836
+ clientSecret: 'github-client-secret',
837
+ enabled: false,
838
+ },
839
+ },
840
+ });
841
+ warnSpy.mockRestore();
842
+
843
+ const config = manager.getPublicConfig();
844
+
845
+ expect(config.socialProviders).toHaveLength(1);
846
+ expect(config.socialProviders[0].id).toBe('google');
847
+ });
848
+
849
+ it('should default email/password to enabled', () => {
850
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
851
+ const manager = new AuthManager({
852
+ secret: 'test-secret-at-least-32-chars-long',
853
+ });
854
+ warnSpy.mockRestore();
855
+
856
+ const config = manager.getPublicConfig();
857
+
858
+ expect(config.emailPassword.enabled).toBe(true);
859
+ });
860
+
861
+ it('should handle unknown provider names', () => {
862
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
863
+ const manager = new AuthManager({
864
+ secret: 'test-secret-at-least-32-chars-long',
865
+ socialProviders: {
866
+ customProvider: {
867
+ clientId: 'custom-client-id',
868
+ clientSecret: 'custom-client-secret',
869
+ },
870
+ },
871
+ });
872
+ warnSpy.mockRestore();
873
+
874
+ const config = manager.getPublicConfig();
875
+
876
+ expect(config.socialProviders[0]).toEqual({
877
+ id: 'customProvider',
878
+ name: 'CustomProvider',
879
+ enabled: true,
880
+ });
881
+ });
882
+ });
758
883
  });
@@ -5,7 +5,11 @@ import type { Auth, BetterAuthOptions } from 'better-auth';
5
5
  import { organization } from 'better-auth/plugins/organization';
6
6
  import { twoFactor } from 'better-auth/plugins/two-factor';
7
7
  import { magicLink } from 'better-auth/plugins/magic-link';
8
- import type { AuthConfig } from '@objectstack/spec/system';
8
+ import type {
9
+ AuthConfig,
10
+ EmailAndPasswordConfig,
11
+ AuthPluginConfig,
12
+ } from '@objectstack/spec/system';
9
13
  import type { IDataEngine } from '@objectstack/core';
10
14
  import { createObjectQLAdapterFactory } from './objectql-adapter.js';
11
15
  import {
@@ -153,7 +157,23 @@ export class AuthManager {
153
157
  plugins: this.buildPluginList(),
154
158
 
155
159
  // Trusted origins for CSRF protection (supports wildcards like "https://*.example.com")
156
- ...(this.config.trustedOrigins?.length ? { trustedOrigins: this.config.trustedOrigins } : {}),
160
+ // Auto-includes origins from CORS_ORIGIN env var so CORS and CSRF stay in sync.
161
+ ...(() => {
162
+ const origins: string[] = [...(this.config.trustedOrigins || [])];
163
+ // Sync with CORS_ORIGIN env var (comma-separated)
164
+ const corsOrigin = process.env.CORS_ORIGIN;
165
+ if (corsOrigin && corsOrigin !== '*') {
166
+ corsOrigin.split(',').map(s => s.trim()).filter(Boolean).forEach(o => {
167
+ if (!origins.includes(o)) origins.push(o);
168
+ });
169
+ }
170
+ // When CORS allows all origins (default) and no explicit trustedOrigins,
171
+ // trust all localhost ports in development for convenience.
172
+ if (!origins.length && (!corsOrigin || corsOrigin === '*')) {
173
+ origins.push('http://localhost:*');
174
+ }
175
+ return origins.length ? { trustedOrigins: origins } : {};
176
+ })(),
157
177
 
158
178
  // Advanced options (cross-subdomain cookies, secure cookies, CSRF, etc.)
159
179
  ...(this.config.advanced ? {
@@ -335,4 +355,65 @@ export class AuthManager {
335
355
  get api() {
336
356
  return this.getOrCreateAuth().api;
337
357
  }
358
+
359
+ /**
360
+ * Get public authentication configuration
361
+ * Returns safe, non-sensitive configuration that can be exposed to the frontend
362
+ *
363
+ * This allows the frontend to discover:
364
+ * - Which social/OAuth providers are available
365
+ * - Whether email/password login is enabled
366
+ * - Which advanced features are enabled (2FA, magic links, etc.)
367
+ */
368
+ getPublicConfig() {
369
+ // Extract social providers info (without sensitive data)
370
+ const socialProviders = [];
371
+ if (this.config.socialProviders) {
372
+ for (const [id, providerConfig] of Object.entries(this.config.socialProviders)) {
373
+ if (providerConfig.enabled !== false) {
374
+ // Map provider ID to friendly name
375
+ const nameMap: Record<string, string> = {
376
+ google: 'Google',
377
+ github: 'GitHub',
378
+ microsoft: 'Microsoft',
379
+ apple: 'Apple',
380
+ facebook: 'Facebook',
381
+ twitter: 'Twitter',
382
+ discord: 'Discord',
383
+ gitlab: 'GitLab',
384
+ linkedin: 'LinkedIn',
385
+ };
386
+
387
+ socialProviders.push({
388
+ id,
389
+ name: nameMap[id] || id.charAt(0).toUpperCase() + id.slice(1),
390
+ enabled: true,
391
+ });
392
+ }
393
+ }
394
+ }
395
+
396
+ // Extract email/password config (safe fields only)
397
+ const emailPasswordConfig: Partial<EmailAndPasswordConfig> = this.config.emailAndPassword ?? {};
398
+ const emailPassword = {
399
+ enabled: emailPasswordConfig.enabled !== false, // Default to true
400
+ disableSignUp: emailPasswordConfig.disableSignUp ?? false,
401
+ requireEmailVerification: emailPasswordConfig.requireEmailVerification ?? false,
402
+ };
403
+
404
+ // Extract enabled features
405
+ const pluginConfig: Partial<AuthPluginConfig> = this.config.plugins ?? {};
406
+ const features = {
407
+ twoFactor: pluginConfig.twoFactor ?? false,
408
+ passkeys: pluginConfig.passkeys ?? false,
409
+ magicLink: pluginConfig.magicLink ?? false,
410
+ organization: pluginConfig.organization ?? false,
411
+ };
412
+
413
+ return {
414
+ emailPassword,
415
+ socialProviders,
416
+ features,
417
+ };
418
+ }
338
419
  }
@@ -24,7 +24,11 @@ describe('AuthPlugin', () => {
24
24
  beforeEach(() => {
25
25
  mockContext = {
26
26
  registerService: vi.fn(),
27
- getService: vi.fn(),
27
+ getService: vi.fn((name: string) => {
28
+ if (name === 'manifest') return { register: vi.fn() };
29
+ if (name === 'data') return undefined;
30
+ return undefined;
31
+ }),
28
32
  getServices: vi.fn(() => new Map()),
29
33
  hook: vi.fn(),
30
34
  trigger: vi.fn(),
@@ -47,7 +51,7 @@ describe('AuthPlugin', () => {
47
51
  expect(authPlugin.name).toBe('com.objectstack.auth');
48
52
  expect(authPlugin.type).toBe('standard');
49
53
  expect(authPlugin.version).toBe('1.0.0');
50
- expect(authPlugin.dependencies).toEqual([]);
54
+ expect(authPlugin.dependencies).toEqual(['com.objectstack.engine.objectql']);
51
55
  });
52
56
  });
53
57
 
@@ -133,6 +137,7 @@ describe('AuthPlugin', () => {
133
137
  it('should register routes with HTTP server on kernel:ready', async () => {
134
138
  const mockRawApp = {
135
139
  all: vi.fn(),
140
+ get: vi.fn(),
136
141
  };
137
142
 
138
143
  const mockHttpServer = {
@@ -169,6 +174,7 @@ describe('AuthPlugin', () => {
169
174
  it('should log via ctx.logger when better-auth returns a 500 response', async () => {
170
175
  const mockRawApp = {
171
176
  all: vi.fn(),
177
+ get: vi.fn(),
172
178
  };
173
179
 
174
180
  const mockHttpServer = {
@@ -266,7 +272,7 @@ describe('AuthPlugin', () => {
266
272
  });
267
273
 
268
274
  it('should auto-detect baseUrl from http-server port when port differs', async () => {
269
- const mockRawApp = { all: vi.fn() };
275
+ const mockRawApp = { all: vi.fn(), get: vi.fn() };
270
276
  const mockHttpServer = {
271
277
  post: vi.fn(), get: vi.fn(), put: vi.fn(), delete: vi.fn(),
272
278
  patch: vi.fn(), use: vi.fn(),
@@ -302,7 +308,7 @@ describe('AuthPlugin', () => {
302
308
  (mockContext.registerService as any).mockClear();
303
309
  await localPlugin.init(mockContext);
304
310
 
305
- const mockRawApp = { all: vi.fn() };
311
+ const mockRawApp = { all: vi.fn(), get: vi.fn() };
306
312
  const mockHttpServer = {
307
313
  post: vi.fn(), get: vi.fn(), put: vi.fn(), delete: vi.fn(),
308
314
  patch: vi.fn(), use: vi.fn(),
@@ -334,7 +340,7 @@ describe('AuthPlugin', () => {
334
340
  (mockContext.registerService as any).mockClear();
335
341
  await localPlugin.init(mockContext);
336
342
 
337
- const mockRawApp = { all: vi.fn() };
343
+ const mockRawApp = { all: vi.fn(), get: vi.fn() };
338
344
  const mockHttpServer = {
339
345
  post: vi.fn(), get: vi.fn(), put: vi.fn(), delete: vi.fn(),
340
346
  patch: vi.fn(), use: vi.fn(),
@@ -396,6 +402,7 @@ describe('AuthPlugin', () => {
396
402
 
397
403
  const mockRawApp = {
398
404
  all: vi.fn(),
405
+ get: vi.fn(),
399
406
  };
400
407
 
401
408
  const mockHttpServer = {
@@ -7,7 +7,7 @@ import {
7
7
  SysUser, SysSession, SysAccount, SysVerification,
8
8
  SysOrganization, SysMember, SysInvitation,
9
9
  SysTeam, SysTeamMember,
10
- SysApiKey, SysTwoFactor,
10
+ SysApiKey, SysTwoFactor, SysUserPreference,
11
11
  } from './objects/index.js';
12
12
 
13
13
  /**
@@ -107,7 +107,7 @@ export class AuthPlugin implements Plugin {
107
107
  SysUser, SysSession, SysAccount, SysVerification,
108
108
  SysOrganization, SysMember, SysInvitation,
109
109
  SysTeam, SysTeamMember,
110
- SysApiKey, SysTwoFactor,
110
+ SysApiKey, SysTwoFactor, SysUserPreference,
111
111
  ],
112
112
  });
113
113
 
@@ -245,6 +245,28 @@ export class AuthPlugin implements Plugin {
245
245
 
246
246
  const rawApp = (httpServer as any).getRawApp();
247
247
 
248
+ // Register auth config endpoint - public endpoint for frontend discovery
249
+ rawApp.get(`${basePath}/config`, async (c: any) => {
250
+ try {
251
+ const config = this.authManager!.getPublicConfig();
252
+ return c.json({
253
+ success: true,
254
+ data: config,
255
+ });
256
+ } catch (error) {
257
+ const err = error instanceof Error ? error : new Error(String(error));
258
+ ctx.logger.error('Auth config error:', err);
259
+
260
+ return c.json({
261
+ success: false,
262
+ error: {
263
+ code: 'auth_config_error',
264
+ message: err.message,
265
+ },
266
+ }, 500);
267
+ }
268
+ });
269
+
248
270
  // Register wildcard route to forward all auth requests to better-auth.
249
271
  // better-auth is configured with basePath matching our route prefix, so we
250
272
  // forward the original request directly — no path rewriting needed.
@@ -27,6 +27,7 @@ export { SysTeamMember } from './sys-team-member.object.js';
27
27
  // ── Additional Auth Objects ────────────────────────────────────────────────
28
28
  export { SysApiKey } from './sys-api-key.object.js';
29
29
  export { SysTwoFactor } from './sys-two-factor.object.js';
30
+ export { SysUserPreference } from './sys-user-preference.object.js';
30
31
 
31
32
  // ── Backward Compatibility (deprecated) ────────────────────────────────────
32
33
  /** @deprecated Use `SysUser` instead */
@@ -0,0 +1,82 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { ObjectSchema, Field } from '@objectstack/spec/data';
4
+
5
+ /**
6
+ * sys_user_preference — System User Preference Object
7
+ *
8
+ * Per-user key-value preferences for storing UI state, settings, and personalization.
9
+ * Supports the User Preferences layer in the Config Resolution hierarchy
10
+ * (Runtime > User Preferences > Tenant > Env).
11
+ *
12
+ * Common use cases:
13
+ * - UI preferences: theme, locale, timezone, sidebar state
14
+ * - Feature flags: plugin.ai.auto_save, plugin.dev.debug_mode
15
+ * - User-specific settings: default_view, notifications_enabled
16
+ *
17
+ * @namespace sys
18
+ */
19
+ export const SysUserPreference = ObjectSchema.create({
20
+ namespace: 'sys',
21
+ name: 'user_preference',
22
+ label: 'User Preference',
23
+ pluralLabel: 'User Preferences',
24
+ icon: 'settings',
25
+ isSystem: true,
26
+ description: 'Per-user key-value preferences (theme, locale, etc.)',
27
+ titleFormat: '{key}',
28
+ compactLayout: ['user_id', 'key'],
29
+
30
+ fields: {
31
+ id: Field.text({
32
+ label: 'Preference ID',
33
+ required: true,
34
+ readonly: true,
35
+ }),
36
+
37
+ created_at: Field.datetime({
38
+ label: 'Created At',
39
+ defaultValue: 'NOW()',
40
+ readonly: true,
41
+ }),
42
+
43
+ updated_at: Field.datetime({
44
+ label: 'Updated At',
45
+ defaultValue: 'NOW()',
46
+ readonly: true,
47
+ }),
48
+
49
+ user_id: Field.text({
50
+ label: 'User ID',
51
+ required: true,
52
+ maxLength: 255,
53
+ description: 'Owner user of this preference',
54
+ }),
55
+
56
+ key: Field.text({
57
+ label: 'Key',
58
+ required: true,
59
+ maxLength: 255,
60
+ description: 'Preference key (e.g., theme, locale, plugin.ai.auto_save)',
61
+ }),
62
+
63
+ value: Field.json({
64
+ label: 'Value',
65
+ description: 'Preference value (any JSON-serializable type)',
66
+ }),
67
+ },
68
+
69
+ indexes: [
70
+ { fields: ['user_id', 'key'], unique: true },
71
+ { fields: ['user_id'], unique: false },
72
+ ],
73
+
74
+ enable: {
75
+ trackHistory: false,
76
+ searchable: false,
77
+ apiEnabled: true,
78
+ apiMethods: ['get', 'list', 'create', 'update', 'delete'],
79
+ trash: false,
80
+ mru: false,
81
+ },
82
+ });