deesse 0.2.11 → 0.2.13

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.
@@ -2,38 +2,134 @@ import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
2
2
  import type { BetterAuthPlugin } from 'better-auth';
3
3
  import type { Plugin } from './plugin';
4
4
  import type { PageTree } from './page';
5
+ import type { AdminHeaderConfig } from '@deessejs/admin';
5
6
  import { admin } from 'better-auth/plugins';
6
7
 
7
- export type Config = {
8
+ /**
9
+ * User schema type - each application defines their own schema.
10
+ * Use generics to preserve Drizzle type safety.
11
+ */
12
+ export type Config<TSchema extends Record<string, unknown> = Record<string, never>> = {
8
13
  name?: string;
9
- database: PostgresJsDatabase;
14
+ database: PostgresJsDatabase<TSchema>;
10
15
  plugins?: Plugin[];
11
16
  pages?: PageTree[];
12
17
  secret: string;
13
- auth: {
14
- baseURL: string;
18
+ auth: AuthConfig;
19
+ admin?: {
20
+ header?: AdminHeaderConfig;
21
+ };
22
+ };
23
+
24
+ /**
25
+ * Auth configuration - all fields optional for partial override
26
+ */
27
+ export type AuthConfig = {
28
+ baseURL: string;
29
+ plugins?: BetterAuthPlugin[];
30
+ emailAndPassword?: {
31
+ enabled?: boolean;
32
+ allowSignUp?: boolean;
33
+ requireEmailVerification?: boolean;
15
34
  };
35
+ session?: {
36
+ maxAge?: number;
37
+ updateAge?: number;
38
+ };
39
+ trustedOrigins?: string[];
16
40
  };
17
41
 
18
42
  /**
19
43
  * Internal config type used at runtime - includes admin plugin
44
+ * Preserves the schema generic for type safety.
20
45
  */
21
- export type InternalConfig = Config & {
46
+ export type InternalConfig<TSchema extends Record<string, unknown> = Record<string, never>> = Config<TSchema> & {
22
47
  auth: {
23
48
  baseURL: string;
24
49
  plugins: BetterAuthPlugin[];
50
+ emailAndPassword: { enabled: boolean };
51
+ session: { maxAge: number };
52
+ trustedOrigins: string[];
25
53
  };
26
54
  };
27
55
 
28
- export function defineConfig(config: Config): InternalConfig {
29
- // Always include admin plugin - user cannot remove it
30
- const authPlugins: BetterAuthPlugin[] = [admin()];
56
+ /**
57
+ * Deep merge two objects, with source overriding target for nested objects.
58
+ * Arrays are concatenated, primitive overrides replace values.
59
+ */
60
+ function deepMerge<T extends Record<string, unknown>>(target: T, source: Partial<T>): T {
61
+ const result = { ...target };
62
+ for (const key of Object.keys(source) as (keyof T)[]) {
63
+ const targetValue = target[key];
64
+ const sourceValue = source[key];
65
+
66
+ if (
67
+ typeof targetValue === 'object' &&
68
+ targetValue !== null &&
69
+ !Array.isArray(targetValue) &&
70
+ typeof sourceValue === 'object' &&
71
+ sourceValue !== null &&
72
+ !Array.isArray(sourceValue)
73
+ ) {
74
+ result[key] = deepMerge(
75
+ targetValue as Record<string, unknown>,
76
+ sourceValue as Record<string, unknown>
77
+ ) as T[typeof key];
78
+ } else if (sourceValue !== undefined) {
79
+ result[key] = sourceValue as T[typeof key];
80
+ }
81
+ }
82
+ return result;
83
+ }
31
84
 
32
- return {
85
+ /**
86
+ * Global config storage for getDeesse() without arguments.
87
+ * Stored at module level to persist across HMR.
88
+ * Uses 'any' for the schema since it's a runtime singleton.
89
+ */
90
+ let globalConfig: InternalConfig<any> | undefined;
91
+
92
+ export function defineConfig<TSchema extends Record<string, unknown>>(
93
+ config: Config<TSchema>
94
+ ): InternalConfig<TSchema> {
95
+ // Default auth configuration - admin() is always first
96
+ const defaultAuth = {
97
+ plugins: [admin()] as BetterAuthPlugin[],
98
+ emailAndPassword: { enabled: true },
99
+ session: { maxAge: 60 * 60 * 24 * 7 }, // 7 days
100
+ trustedOrigins: [config.auth.baseURL],
101
+ };
102
+
103
+ // Merge user config with defaults
104
+ const mergedAuth = {
105
+ baseURL: config.auth.baseURL,
106
+ secret: config.secret,
107
+ plugins: [...defaultAuth.plugins, ...(config.auth.plugins || [])],
108
+ emailAndPassword: deepMerge(defaultAuth.emailAndPassword, config.auth.emailAndPassword || {}),
109
+ session: deepMerge(defaultAuth.session, config.auth.session || {}),
110
+ trustedOrigins: config.auth.trustedOrigins || defaultAuth.trustedOrigins,
111
+ };
112
+
113
+ const internalConfig: InternalConfig<TSchema> = {
33
114
  ...config,
34
- auth: {
35
- ...config.auth,
36
- plugins: authPlugins,
37
- },
38
- } as InternalConfig;
115
+ auth: mergedAuth,
116
+ } as InternalConfig<TSchema>;
117
+
118
+ // Store globally for getDeesse() without arguments
119
+ globalConfig = internalConfig;
120
+
121
+ return internalConfig;
122
+ }
123
+
124
+ /**
125
+ * Get the global config stored by defineConfig().
126
+ * Throws if defineConfig() has not been called.
127
+ */
128
+ export function getGlobalConfig<TSchema extends Record<string, unknown> = Record<string, never>>(): InternalConfig<TSchema> {
129
+ if (!globalConfig) {
130
+ throw new Error(
131
+ "Deesse config not defined. Call defineConfig() before accessing getDeesse() without arguments."
132
+ );
133
+ }
134
+ return globalConfig as InternalConfig<TSchema>;
39
135
  }
package/src/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  // @deessejs/deesse core package
2
2
 
3
- import type { InternalConfig } from "./config/define.js";
4
3
  import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
5
4
  import { createDeesse, type Deesse } from "./server.js";
5
+ import { getGlobalConfig, type InternalConfig } from "./config/define.js";
6
6
 
7
7
  export { defineConfig } from "./config/index.js";
8
8
  export type { Config, InternalConfig } from "./config/index.js";
@@ -14,14 +14,12 @@ export type { Page, Section, PageTree } from "./config/index.js";
14
14
  export { z } from "zod";
15
15
  export type { ZodSchema } from "zod";
16
16
 
17
+ export { sql } from "drizzle-orm";
18
+
17
19
  export type { Deesse } from "./server.js";
18
20
 
19
21
  export { createClient } from "./client.js";
20
- export type { DeesseClient, DeesseClientOptions } from "./client.js";
21
-
22
- export { isDatabaseEmpty, requireDatabaseNotEmpty, validateAdminEmail } from "./lib/admin.js";
23
- export type { EmailValidationOptions } from "./lib/admin.js";
24
- export { isPublicEmailDomain, isAllowedAdminEmail, getAllowedDomains, validateAdminEmailDomain, PUBLIC_EMAIL_DOMAINS } from "./lib/validation.js";
22
+ export type { DeesseClientOptions } from "./client.js";
25
23
 
26
24
  /**
27
25
  * Symbol-based global storage for Deesse instance.
@@ -52,6 +50,8 @@ function getGlobalCache(): GlobalDeesseCache {
52
50
  /**
53
51
  * Deep equality check for config comparison.
54
52
  * Required because config objects are recreated on HMR.
53
+ * Note: We do NOT compare database pools - the pool reference from $client
54
+ * may return new wrapper objects on each access, causing false positives.
55
55
  */
56
56
  function isConfigEqual(a: InternalConfig, b: InternalConfig): boolean {
57
57
  if (a.secret !== b.secret) return false;
@@ -71,35 +71,54 @@ function extractPool(db: PostgresJsDatabase): unknown {
71
71
  /**
72
72
  * Get the Deesse singleton instance.
73
73
  * Cached on global to persist across HMR.
74
+ *
75
+ * Can be called without arguments if defineConfig() was called first,
76
+ * or with a config for explicit instantiation.
74
77
  */
75
78
  export const getDeesse = async (
76
- config: InternalConfig
79
+ config?: InternalConfig
77
80
  ): Promise<Deesse> => {
81
+ const effectiveConfig = config ?? getGlobalConfig();
78
82
  const cache = getGlobalCache();
79
83
 
80
84
  // Case 1: Instance exists and config is semantically equal
81
- if (cache.instance && cache.config && isConfigEqual(cache.config, config)) {
85
+ if (cache.instance && cache.config && isConfigEqual(cache.config, effectiveConfig)) {
82
86
  return cache.instance;
83
87
  }
84
88
 
85
89
  // Case 2: Instance exists but config changed - hot reload
86
- // IMPORTANT: Don't close the pool! cache.instance still uses the old pool.
87
- // The new config's pool will be garbage collected when the HMR module reference is dropped.
88
90
  if (
89
91
  cache.instance &&
90
92
  cache.config &&
91
- !isConfigEqual(cache.config, config)
93
+ !isConfigEqual(cache.config, effectiveConfig)
92
94
  ) {
93
95
  console.info("[deesse] Config changed, performing hot reload...");
94
- cache.config = config;
95
- return cache.instance;
96
+
97
+ // Close the old pool before creating new instance (with 5s timeout)
98
+ if (cache.pool) {
99
+ const oldPool = cache.pool as { end?: () => Promise<void> };
100
+ if (typeof oldPool.end === "function") {
101
+ await Promise.race([
102
+ oldPool.end(),
103
+ new Promise((resolve) => setTimeout(() => resolve(undefined), 5000)),
104
+ ]).catch(console.error);
105
+ }
106
+ }
107
+
108
+ // Create new instance with new config
109
+ const instance = createDeesse(effectiveConfig);
110
+ cache.pool = extractPool(instance.database);
111
+ cache.instance = instance;
112
+ cache.config = effectiveConfig;
113
+
114
+ return instance;
96
115
  }
97
116
 
98
117
  // Case 3: No instance exists - create one
99
- const instance = createDeesse(config);
118
+ const instance = createDeesse(effectiveConfig);
100
119
  cache.pool = extractPool(instance.database);
101
120
  cache.instance = instance;
102
- cache.config = config;
121
+ cache.config = effectiveConfig;
103
122
 
104
123
  return instance;
105
124
  };
package/src/server.ts CHANGED
@@ -1,16 +1,11 @@
1
1
  import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
2
- import type { BetterAuthPlugin } from "better-auth";
2
+ import type { Auth } from "better-auth";
3
3
  import type { InternalConfig } from "./config/define";
4
4
  import { betterAuth } from "better-auth";
5
5
  import { drizzleAdapter } from "@better-auth/drizzle-adapter";
6
6
 
7
7
  export type Deesse = {
8
- auth: Awaited<ReturnType<typeof betterAuth<{
9
- database: ReturnType<typeof drizzleAdapter>;
10
- baseURL: string;
11
- secret: string;
12
- plugins: BetterAuthPlugin[];
13
- }>>>;
8
+ auth: Auth;
14
9
  database: PostgresJsDatabase;
15
10
  };
16
11
 
@@ -21,8 +16,10 @@ export function createDeesse(config: InternalConfig): Deesse {
21
16
  }),
22
17
  baseURL: config.auth.baseURL,
23
18
  secret: config.secret,
19
+ emailAndPassword: config.auth.emailAndPassword,
20
+ trustedOrigins: config.auth.trustedOrigins,
24
21
  plugins: config.auth.plugins,
25
- });
22
+ }) as Auth;
26
23
 
27
24
  return {
28
25
  auth,
package/tsconfig.json CHANGED
@@ -1,12 +1,12 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "compilerOptions": {
4
- "composite": false,
5
- "module": "ESNext",
6
- "moduleResolution": "bundler",
7
- "outDir": "./dist",
8
- "rootDir": "./src"
9
- },
10
- "include": ["src/**/*"],
11
- "exclude": ["node_modules", "dist"]
12
- }
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "composite": false,
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "outDir": "./dist",
8
+ "rootDir": "./src"
9
+ },
10
+ "include": ["src/**/*"],
11
+ "exclude": ["node_modules", "dist"]
12
+ }
package/src/lib/admin.ts DELETED
@@ -1,68 +0,0 @@
1
- import type { Auth } from "better-auth";
2
-
3
- /**
4
- * Check if the database has any users.
5
- * Returns true if the database is empty (no users).
6
- */
7
- export async function isDatabaseEmpty(auth: Auth): Promise<boolean> {
8
- try {
9
- const result = await (auth.api as any).listUsers({ limit: 1 });
10
- return !result?.users || result.users.length === 0;
11
- } catch {
12
- // If listUsers fails, assume not empty (safer default)
13
- return false;
14
- }
15
- }
16
-
17
- /**
18
- * Require that the database is NOT empty.
19
- * Throws if no users exist.
20
- */
21
- export async function requireDatabaseNotEmpty(auth: Auth): Promise<void> {
22
- if (await isDatabaseEmpty(auth)) {
23
- throw new Error(
24
- "Database is empty. Cannot proceed with this operation. " +
25
- "Use the First Admin Setup page to create the initial admin account."
26
- );
27
- }
28
- }
29
-
30
- export interface EmailValidationOptions {
31
- allowedDomains?: string[];
32
- blockedDomains?: string[];
33
- requireOrganization?: boolean;
34
- }
35
-
36
- /**
37
- * Validate an admin email against configured rules.
38
- */
39
- export function validateAdminEmail(
40
- email: string,
41
- options: EmailValidationOptions = {}
42
- ): { valid: boolean; error?: string } {
43
- const domain = email.split('@')[1]?.toLowerCase();
44
-
45
- if (!domain) {
46
- return { valid: false, error: "Invalid email format" };
47
- }
48
-
49
- // Check blocked domains
50
- if (options.blockedDomains?.includes(domain)) {
51
- return { valid: false, error: `Email domain ${domain} is blocked` };
52
- }
53
-
54
- // Check allowed domains (if specified)
55
- if (options.allowedDomains?.length && !options.allowedDomains.includes(domain)) {
56
- return { valid: false, error: `Email must be from: ${options.allowedDomains.join(', ')}` };
57
- }
58
-
59
- // Require organization (no public email domains)
60
- if (options.requireOrganization) {
61
- const PUBLIC_DOMAINS = ['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'icloud.com'];
62
- if (PUBLIC_DOMAINS.includes(domain)) {
63
- return { valid: false, error: "Personal email domains are not allowed. Use an organizational email." };
64
- }
65
- }
66
-
67
- return { valid: true };
68
- }
@@ -1,89 +0,0 @@
1
- /**
2
- * Email validation utilities for admin email enforcement
3
- */
4
-
5
- export const PUBLIC_EMAIL_DOMAINS = [
6
- 'gmail.com',
7
- 'yahoo.com',
8
- 'hotmail.com',
9
- 'outlook.com',
10
- 'icloud.com',
11
- 'mail.com',
12
- 'aol.com',
13
- 'protonmail.com',
14
- 'zoho.com',
15
- 'yandex.com',
16
- 'gmx.com',
17
- ] as const;
18
-
19
- export type PublicEmailDomain = (typeof PUBLIC_EMAIL_DOMAINS)[number];
20
-
21
- /**
22
- * Check if an email uses a public email domain
23
- */
24
- export function isPublicEmailDomain(email: string): boolean {
25
- const domain = email.split('@')[1]?.toLowerCase();
26
- return PUBLIC_EMAIL_DOMAINS.includes(domain as PublicEmailDomain);
27
- }
28
-
29
- /**
30
- * Get allowed domains from ADMIN_ALLOWED_DOMAINS environment variable.
31
- * Returns empty array if not configured (no restrictions).
32
- */
33
- export function getAllowedDomains(): string[] {
34
- const envValue = process.env['ADMIN_ALLOWED_DOMAINS'];
35
- if (!envValue) return [];
36
- return envValue
37
- .split(',')
38
- .map((d) => d.trim().toLowerCase())
39
- .filter(Boolean);
40
- }
41
-
42
- /**
43
- * Check if an email is from an allowed domain.
44
- * If no allowed domains are configured, all domains are allowed.
45
- */
46
- export function isAllowedAdminEmail(email: string): boolean {
47
- const allowed = getAllowedDomains();
48
- if (!allowed.length) return true; // No restriction configured
49
- const domain = email.split('@')[1]?.toLowerCase();
50
- return allowed.includes(domain);
51
- }
52
-
53
- /**
54
- * Validate admin email against organizational requirements.
55
- * Returns an error message if validation fails.
56
- */
57
- export function validateAdminEmailDomain(
58
- email: string
59
- ): { valid: true } | { valid: false; code: string; message: string; suggestion?: string } {
60
- // Check if email is from a public domain (warning level, not blocking)
61
- const isPublic = isPublicEmailDomain(email);
62
- const allowed = getAllowedDomains();
63
-
64
- // If allowed domains are configured, enforce them strictly
65
- if (allowed.length > 0) {
66
- const domain = email.split('@')[1]?.toLowerCase();
67
- if (!allowed.includes(domain)) {
68
- return {
69
- valid: false,
70
- code: 'INVALID_EMAIL_DOMAIN',
71
- message: `Admin email must be from an allowed domain. Allowed: ${allowed.join(', ')}`,
72
- suggestion: 'Set ADMIN_ALLOWED_DOMAINS environment variable to configure allowed email domains',
73
- };
74
- }
75
- }
76
-
77
- // If email is from a public domain, return warning info (but allow through)
78
- if (isPublic && allowed.length === 0) {
79
- const domain = email.split('@')[1]?.toLowerCase();
80
- return {
81
- valid: false,
82
- code: 'PUBLIC_EMAIL_DOMAIN',
83
- message: `${email} is a public email domain. Admin accounts should use organizational email addresses.`,
84
- suggestion: `Set ADMIN_ALLOWED_DOMAINS environment variable to restrict to organizational domains only (e.g., ADMIN_ALLOWED_DOMAINS=${domain})`,
85
- };
86
- }
87
-
88
- return { valid: true };
89
- }