@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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +15 -0
- package/dist/index.d.mts +1410 -91
- package/dist/index.d.ts +1410 -91
- package/dist/index.js +149 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +148 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -7
- package/src/auth-manager.test.ts +129 -4
- package/src/auth-manager.ts +83 -2
- package/src/auth-plugin.test.ts +12 -5
- package/src/auth-plugin.ts +24 -2
- package/src/objects/index.ts +1 -0
- package/src/objects/sys-user-preference.object.ts +82 -0
package/src/auth-manager.test.ts
CHANGED
|
@@ -434,7 +434,7 @@ describe('AuthManager', () => {
|
|
|
434
434
|
]);
|
|
435
435
|
});
|
|
436
436
|
|
|
437
|
-
it('should
|
|
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).
|
|
452
|
+
expect(capturedConfig.trustedOrigins).toEqual(['http://localhost:*']);
|
|
453
453
|
});
|
|
454
454
|
|
|
455
|
-
it('should
|
|
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).
|
|
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
|
});
|
package/src/auth-manager.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
}
|
package/src/auth-plugin.test.ts
CHANGED
|
@@ -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 = {
|
package/src/auth-plugin.ts
CHANGED
|
@@ -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.
|
package/src/objects/index.ts
CHANGED
|
@@ -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
|
+
});
|