@qwickapps/server 1.3.1 → 1.4.0

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.
Files changed (149) hide show
  1. package/README.md +157 -0
  2. package/dist/core/control-panel.d.ts.map +1 -1
  3. package/dist/core/control-panel.js +114 -0
  4. package/dist/core/control-panel.js.map +1 -1
  5. package/dist/core/types.d.ts +19 -0
  6. package/dist/core/types.d.ts.map +1 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +4 -2
  10. package/dist/index.js.map +1 -1
  11. package/dist/plugins/auth/adapter-wrapper.d.ts +47 -0
  12. package/dist/plugins/auth/adapter-wrapper.d.ts.map +1 -0
  13. package/dist/plugins/auth/adapter-wrapper.js +166 -0
  14. package/dist/plugins/auth/adapter-wrapper.js.map +1 -0
  15. package/dist/plugins/auth/adapter-wrapper.test.d.ts +7 -0
  16. package/dist/plugins/auth/adapter-wrapper.test.d.ts.map +1 -0
  17. package/dist/plugins/auth/adapter-wrapper.test.js +303 -0
  18. package/dist/plugins/auth/adapter-wrapper.test.js.map +1 -0
  19. package/dist/plugins/auth/config-store.d.ts +11 -0
  20. package/dist/plugins/auth/config-store.d.ts.map +1 -0
  21. package/dist/plugins/auth/config-store.js +232 -0
  22. package/dist/plugins/auth/config-store.js.map +1 -0
  23. package/dist/plugins/auth/config-store.test.d.ts +7 -0
  24. package/dist/plugins/auth/config-store.test.d.ts.map +1 -0
  25. package/dist/plugins/auth/config-store.test.js +299 -0
  26. package/dist/plugins/auth/config-store.test.js.map +1 -0
  27. package/dist/plugins/auth/env-config.d.ts +51 -1
  28. package/dist/plugins/auth/env-config.d.ts.map +1 -1
  29. package/dist/plugins/auth/env-config.js +640 -7
  30. package/dist/plugins/auth/env-config.js.map +1 -1
  31. package/dist/plugins/auth/index.d.ts +6 -2
  32. package/dist/plugins/auth/index.d.ts.map +1 -1
  33. package/dist/plugins/auth/index.js +5 -1
  34. package/dist/plugins/auth/index.js.map +1 -1
  35. package/dist/plugins/auth/types.d.ts +106 -0
  36. package/dist/plugins/auth/types.d.ts.map +1 -1
  37. package/dist/plugins/index.d.ts +4 -2
  38. package/dist/plugins/index.d.ts.map +1 -1
  39. package/dist/plugins/index.js +3 -1
  40. package/dist/plugins/index.js.map +1 -1
  41. package/dist/plugins/rate-limit/__tests__/rate-limit-plugin.test.d.ts +7 -0
  42. package/dist/plugins/rate-limit/__tests__/rate-limit-plugin.test.d.ts.map +1 -0
  43. package/dist/plugins/rate-limit/__tests__/rate-limit-plugin.test.js +220 -0
  44. package/dist/plugins/rate-limit/__tests__/rate-limit-plugin.test.js.map +1 -0
  45. package/dist/plugins/rate-limit/cleanup.d.ts +40 -0
  46. package/dist/plugins/rate-limit/cleanup.d.ts.map +1 -0
  47. package/dist/plugins/rate-limit/cleanup.js +72 -0
  48. package/dist/plugins/rate-limit/cleanup.js.map +1 -0
  49. package/dist/plugins/rate-limit/env-config.d.ts +91 -0
  50. package/dist/plugins/rate-limit/env-config.d.ts.map +1 -0
  51. package/dist/plugins/rate-limit/env-config.js +318 -0
  52. package/dist/plugins/rate-limit/env-config.js.map +1 -0
  53. package/dist/plugins/rate-limit/index.d.ts +76 -0
  54. package/dist/plugins/rate-limit/index.d.ts.map +1 -0
  55. package/dist/plugins/rate-limit/index.js +79 -0
  56. package/dist/plugins/rate-limit/index.js.map +1 -0
  57. package/dist/plugins/rate-limit/middleware.d.ts +40 -0
  58. package/dist/plugins/rate-limit/middleware.d.ts.map +1 -0
  59. package/dist/plugins/rate-limit/middleware.js +169 -0
  60. package/dist/plugins/rate-limit/middleware.js.map +1 -0
  61. package/dist/plugins/rate-limit/rate-limit-plugin.d.ts +44 -0
  62. package/dist/plugins/rate-limit/rate-limit-plugin.d.ts.map +1 -0
  63. package/dist/plugins/rate-limit/rate-limit-plugin.js +354 -0
  64. package/dist/plugins/rate-limit/rate-limit-plugin.js.map +1 -0
  65. package/dist/plugins/rate-limit/rate-limit-service.d.ts +110 -0
  66. package/dist/plugins/rate-limit/rate-limit-service.d.ts.map +1 -0
  67. package/dist/plugins/rate-limit/rate-limit-service.js +172 -0
  68. package/dist/plugins/rate-limit/rate-limit-service.js.map +1 -0
  69. package/dist/plugins/rate-limit/stores/cache-store.d.ts +33 -0
  70. package/dist/plugins/rate-limit/stores/cache-store.d.ts.map +1 -0
  71. package/dist/plugins/rate-limit/stores/cache-store.js +225 -0
  72. package/dist/plugins/rate-limit/stores/cache-store.js.map +1 -0
  73. package/dist/plugins/rate-limit/stores/index.d.ts +8 -0
  74. package/dist/plugins/rate-limit/stores/index.d.ts.map +1 -0
  75. package/dist/plugins/rate-limit/stores/index.js +8 -0
  76. package/dist/plugins/rate-limit/stores/index.js.map +1 -0
  77. package/dist/plugins/rate-limit/stores/postgres-store.d.ts +34 -0
  78. package/dist/plugins/rate-limit/stores/postgres-store.d.ts.map +1 -0
  79. package/dist/plugins/rate-limit/stores/postgres-store.js +320 -0
  80. package/dist/plugins/rate-limit/stores/postgres-store.js.map +1 -0
  81. package/dist/plugins/rate-limit/strategies/fixed-window.d.ts +21 -0
  82. package/dist/plugins/rate-limit/strategies/fixed-window.d.ts.map +1 -0
  83. package/dist/plugins/rate-limit/strategies/fixed-window.js +97 -0
  84. package/dist/plugins/rate-limit/strategies/fixed-window.js.map +1 -0
  85. package/dist/plugins/rate-limit/strategies/index.d.ts +14 -0
  86. package/dist/plugins/rate-limit/strategies/index.d.ts.map +1 -0
  87. package/dist/plugins/rate-limit/strategies/index.js +27 -0
  88. package/dist/plugins/rate-limit/strategies/index.js.map +1 -0
  89. package/dist/plugins/rate-limit/strategies/sliding-window.d.ts +22 -0
  90. package/dist/plugins/rate-limit/strategies/sliding-window.d.ts.map +1 -0
  91. package/dist/plugins/rate-limit/strategies/sliding-window.js +122 -0
  92. package/dist/plugins/rate-limit/strategies/sliding-window.js.map +1 -0
  93. package/dist/plugins/rate-limit/strategies/token-bucket.d.ts +28 -0
  94. package/dist/plugins/rate-limit/strategies/token-bucket.d.ts.map +1 -0
  95. package/dist/plugins/rate-limit/strategies/token-bucket.js +121 -0
  96. package/dist/plugins/rate-limit/strategies/token-bucket.js.map +1 -0
  97. package/dist/plugins/rate-limit/types.d.ts +265 -0
  98. package/dist/plugins/rate-limit/types.d.ts.map +1 -0
  99. package/dist/plugins/rate-limit/types.js +9 -0
  100. package/dist/plugins/rate-limit/types.js.map +1 -0
  101. package/dist-ui/assets/index-D7DoZ9rL.js +478 -0
  102. package/dist-ui/assets/index-D7DoZ9rL.js.map +1 -0
  103. package/dist-ui/index.html +1 -1
  104. package/dist-ui-lib/api/controlPanelApi.d.ts +141 -0
  105. package/dist-ui-lib/dashboard/widgets/AuthStatusWidget.d.ts +9 -0
  106. package/dist-ui-lib/dashboard/widgets/IntegrationStatusWidget.d.ts +9 -0
  107. package/dist-ui-lib/dashboard/widgets/index.d.ts +2 -0
  108. package/dist-ui-lib/index.js +3332 -2343
  109. package/dist-ui-lib/index.js.map +1 -1
  110. package/dist-ui-lib/pages/IntegrationsPage.d.ts +1 -0
  111. package/dist-ui-lib/pages/RateLimitPage.d.ts +1 -0
  112. package/package.json +1 -1
  113. package/src/core/control-panel.ts +128 -0
  114. package/src/core/types.ts +17 -0
  115. package/src/index.ts +38 -0
  116. package/src/plugins/auth/adapter-wrapper.test.ts +395 -0
  117. package/src/plugins/auth/adapter-wrapper.ts +205 -0
  118. package/src/plugins/auth/config-store.test.ts +417 -0
  119. package/src/plugins/auth/config-store.ts +305 -0
  120. package/src/plugins/auth/env-config.ts +714 -7
  121. package/src/plugins/auth/index.ts +22 -1
  122. package/src/plugins/auth/types.ts +138 -0
  123. package/src/plugins/index.ts +49 -0
  124. package/src/plugins/rate-limit/__tests__/rate-limit-plugin.test.ts +259 -0
  125. package/src/plugins/rate-limit/cleanup.ts +117 -0
  126. package/src/plugins/rate-limit/env-config.ts +400 -0
  127. package/src/plugins/rate-limit/index.ts +128 -0
  128. package/src/plugins/rate-limit/middleware.ts +212 -0
  129. package/src/plugins/rate-limit/rate-limit-plugin.ts +400 -0
  130. package/src/plugins/rate-limit/rate-limit-service.ts +228 -0
  131. package/src/plugins/rate-limit/stores/cache-store.ts +261 -0
  132. package/src/plugins/rate-limit/stores/index.ts +8 -0
  133. package/src/plugins/rate-limit/stores/postgres-store.ts +402 -0
  134. package/src/plugins/rate-limit/strategies/fixed-window.ts +116 -0
  135. package/src/plugins/rate-limit/strategies/index.ts +30 -0
  136. package/src/plugins/rate-limit/strategies/sliding-window.ts +157 -0
  137. package/src/plugins/rate-limit/strategies/token-bucket.ts +154 -0
  138. package/src/plugins/rate-limit/types.ts +338 -0
  139. package/ui/src/App.tsx +32 -14
  140. package/ui/src/api/controlPanelApi.ts +226 -0
  141. package/ui/src/dashboard/builtInWidgets.tsx +5 -1
  142. package/ui/src/dashboard/widgets/AuthStatusWidget.tsx +143 -0
  143. package/ui/src/dashboard/widgets/IntegrationStatusWidget.tsx +135 -0
  144. package/ui/src/dashboard/widgets/index.ts +2 -0
  145. package/ui/src/pages/AuthPage.tsx +986 -142
  146. package/ui/src/pages/IntegrationsPage.tsx +288 -0
  147. package/ui/src/pages/RateLimitPage.tsx +292 -0
  148. package/dist-ui/assets/index-BY8OxNgO.js +0 -465
  149. package/dist-ui/assets/index-BY8OxNgO.js.map +0 -1
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Auth Adapter Wrapper
3
+ *
4
+ * Wraps an auth adapter to enable hot-reload without server restart.
5
+ * All method calls are delegated to the underlying adapter, which can
6
+ * be swapped at runtime via setAdapter().
7
+ *
8
+ * Copyright (c) 2025 QwickApps.com. All rights reserved.
9
+ */
10
+
11
+ import type { Request, Response, RequestHandler } from 'express';
12
+ import type { AuthAdapter, AuthenticatedUser } from './types.js';
13
+
14
+ /**
15
+ * Extended adapter interface with hot-reload capabilities
16
+ */
17
+ export interface AdapterWrapper extends AuthAdapter {
18
+ /**
19
+ * Replace the underlying adapter (for hot-reload)
20
+ * Calls shutdown() on the old adapter before swapping
21
+ */
22
+ setAdapter(adapter: AuthAdapter): Promise<void>;
23
+
24
+ /**
25
+ * Get information about the current adapter
26
+ */
27
+ getAdapterInfo(): { name: string; initialized: boolean };
28
+
29
+ /**
30
+ * Check if an adapter is currently set
31
+ */
32
+ hasAdapter(): boolean;
33
+ }
34
+
35
+ /**
36
+ * Create a no-op adapter for when auth is disabled
37
+ */
38
+ function createNoopAdapter(): AuthAdapter {
39
+ return {
40
+ name: 'none',
41
+ initialize: () => ((_req, _res, next) => next()) as RequestHandler,
42
+ isAuthenticated: () => false,
43
+ getUser: () => null,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Create an adapter wrapper for hot-reload support
49
+ *
50
+ * @param initialAdapter Optional initial adapter (defaults to no-op)
51
+ * @returns AdapterWrapper that delegates to the underlying adapter
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * const wrapper = createAdapterWrapper(supertokensAdapter(config));
56
+ *
57
+ * // Later, swap the adapter without restart:
58
+ * await wrapper.setAdapter(auth0Adapter(newConfig));
59
+ * ```
60
+ */
61
+ export function createAdapterWrapper(initialAdapter?: AuthAdapter): AdapterWrapper {
62
+ let currentAdapter: AuthAdapter = initialAdapter || createNoopAdapter();
63
+ let isInitialized = false;
64
+ let isSwapping = false; // Prevent concurrent adapter swaps
65
+
66
+ return {
67
+ // Delegate name to current adapter
68
+ get name(): string {
69
+ return currentAdapter.name;
70
+ },
71
+
72
+ /**
73
+ * Initialize returns the middleware
74
+ * Note: This is called once at startup. The middleware itself
75
+ * will delegate to currentAdapter, which can be swapped.
76
+ */
77
+ initialize(): RequestHandler | RequestHandler[] {
78
+ isInitialized = true;
79
+
80
+ // Get the initial middleware
81
+ const initialMiddleware = currentAdapter.initialize();
82
+
83
+ // Return a wrapper middleware that delegates to current adapter's middleware
84
+ // This allows hot-reload to work - new adapter's middleware will be used
85
+ // Note: For simplicity, we return the initial middleware. Full hot-reload
86
+ // of middleware would require more complex Express route manipulation.
87
+ // The key hot-reload capability is in isAuthenticated/getUser which ARE
88
+ // delegated dynamically.
89
+ return initialMiddleware;
90
+ },
91
+
92
+ /**
93
+ * Delegate isAuthenticated to current adapter
94
+ */
95
+ isAuthenticated(req: Request): boolean {
96
+ return currentAdapter.isAuthenticated(req);
97
+ },
98
+
99
+ /**
100
+ * Delegate getUser to current adapter
101
+ */
102
+ getUser(req: Request): AuthenticatedUser | null | Promise<AuthenticatedUser | null> {
103
+ return currentAdapter.getUser(req);
104
+ },
105
+
106
+ /**
107
+ * Delegate hasRoles to current adapter (if available)
108
+ */
109
+ hasRoles(req: Request, roles: string[]): boolean {
110
+ if (currentAdapter.hasRoles) {
111
+ return currentAdapter.hasRoles(req, roles);
112
+ }
113
+ return false;
114
+ },
115
+
116
+ /**
117
+ * Delegate getAccessToken to current adapter (if available)
118
+ */
119
+ getAccessToken(req: Request): string | null {
120
+ if (currentAdapter.getAccessToken) {
121
+ return currentAdapter.getAccessToken(req);
122
+ }
123
+ return null;
124
+ },
125
+
126
+ /**
127
+ * Delegate onUnauthorized to current adapter (if available)
128
+ */
129
+ onUnauthorized(req: Request, res: Response): void {
130
+ if (currentAdapter.onUnauthorized) {
131
+ currentAdapter.onUnauthorized(req, res);
132
+ } else {
133
+ res.status(401).json({
134
+ error: 'Unauthorized',
135
+ message: 'Authentication required',
136
+ });
137
+ }
138
+ },
139
+
140
+ /**
141
+ * Shutdown the current adapter
142
+ */
143
+ async shutdown(): Promise<void> {
144
+ if (currentAdapter.shutdown) {
145
+ await currentAdapter.shutdown();
146
+ }
147
+ isInitialized = false;
148
+ },
149
+
150
+ /**
151
+ * Hot-reload: Replace the underlying adapter
152
+ * @throws Error if another adapter swap is already in progress
153
+ */
154
+ async setAdapter(adapter: AuthAdapter): Promise<void> {
155
+ // Prevent concurrent adapter swaps
156
+ if (isSwapping) {
157
+ throw new Error('Adapter swap already in progress');
158
+ }
159
+ isSwapping = true;
160
+
161
+ try {
162
+ const oldAdapter = currentAdapter;
163
+
164
+ // Shutdown old adapter
165
+ if (oldAdapter.shutdown) {
166
+ try {
167
+ await oldAdapter.shutdown();
168
+ } catch (err) {
169
+ console.error('[AdapterWrapper] Error shutting down old adapter:', err);
170
+ }
171
+ }
172
+
173
+ // Set new adapter
174
+ currentAdapter = adapter;
175
+
176
+ // Initialize new adapter if we're already running
177
+ if (isInitialized) {
178
+ // Note: We initialize the new adapter but can't easily swap Express middleware
179
+ // The new adapter's isAuthenticated/getUser will be used immediately
180
+ // Full middleware hot-reload would require server restart
181
+ currentAdapter.initialize();
182
+ }
183
+ } finally {
184
+ isSwapping = false;
185
+ }
186
+ },
187
+
188
+ /**
189
+ * Get info about the current adapter
190
+ */
191
+ getAdapterInfo(): { name: string; initialized: boolean } {
192
+ return {
193
+ name: currentAdapter.name,
194
+ initialized: isInitialized,
195
+ };
196
+ },
197
+
198
+ /**
199
+ * Check if a real adapter is set (not the no-op)
200
+ */
201
+ hasAdapter(): boolean {
202
+ return currentAdapter.name !== 'none';
203
+ },
204
+ };
205
+ }
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Auth Config Store Tests
3
+ *
4
+ * Unit tests for PostgreSQL-backed auth configuration store.
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+ import { postgresAuthConfigStore } from './config-store.js';
9
+ import type { RuntimeAuthConfig } from './types.js';
10
+
11
+ // Mock pg pool
12
+ interface MockPoolClient {
13
+ query: ReturnType<typeof vi.fn>;
14
+ on: ReturnType<typeof vi.fn>;
15
+ release: ReturnType<typeof vi.fn>;
16
+ }
17
+
18
+ interface MockPool {
19
+ query: ReturnType<typeof vi.fn>;
20
+ connect: ReturnType<typeof vi.fn>;
21
+ on: ReturnType<typeof vi.fn>;
22
+ }
23
+
24
+ function createMockPool(): MockPool {
25
+ const mockClient: MockPoolClient = {
26
+ query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
27
+ on: vi.fn(),
28
+ release: vi.fn(),
29
+ };
30
+
31
+ return {
32
+ query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
33
+ connect: vi.fn().mockResolvedValue(mockClient),
34
+ on: vi.fn(),
35
+ };
36
+ }
37
+
38
+ describe('postgresAuthConfigStore', () => {
39
+ let mockPool: MockPool;
40
+
41
+ beforeEach(() => {
42
+ mockPool = createMockPool();
43
+ });
44
+
45
+ afterEach(() => {
46
+ vi.clearAllMocks();
47
+ });
48
+
49
+ describe('identifier validation', () => {
50
+ it('should reject invalid table names', () => {
51
+ expect(() =>
52
+ postgresAuthConfigStore({
53
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
54
+ tableName: 'DROP TABLE users;--',
55
+ })
56
+ ).toThrow('Invalid table name');
57
+ });
58
+
59
+ it('should reject table names with spaces', () => {
60
+ expect(() =>
61
+ postgresAuthConfigStore({
62
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
63
+ tableName: 'my table',
64
+ })
65
+ ).toThrow('Invalid table name');
66
+ });
67
+
68
+ it('should reject table names starting with numbers', () => {
69
+ expect(() =>
70
+ postgresAuthConfigStore({
71
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
72
+ tableName: '123table',
73
+ })
74
+ ).toThrow('Invalid table name');
75
+ });
76
+
77
+ it('should reject table names longer than 63 characters', () => {
78
+ const longName = 'a'.repeat(64);
79
+ expect(() =>
80
+ postgresAuthConfigStore({
81
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
82
+ tableName: longName,
83
+ })
84
+ ).toThrow('Invalid table name');
85
+ });
86
+
87
+ it('should accept valid table names', () => {
88
+ expect(() =>
89
+ postgresAuthConfigStore({
90
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
91
+ tableName: 'auth_config',
92
+ })
93
+ ).not.toThrow();
94
+ });
95
+
96
+ it('should accept table names with underscores', () => {
97
+ expect(() =>
98
+ postgresAuthConfigStore({
99
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
100
+ tableName: '_my_auth_config_table',
101
+ })
102
+ ).not.toThrow();
103
+ });
104
+
105
+ it('should reject invalid schema names', () => {
106
+ expect(() =>
107
+ postgresAuthConfigStore({
108
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
109
+ schema: 'public; DROP TABLE users;--',
110
+ })
111
+ ).toThrow('Invalid schema name');
112
+ });
113
+
114
+ it('should reject invalid notify channel names', () => {
115
+ expect(() =>
116
+ postgresAuthConfigStore({
117
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
118
+ notifyChannel: 'channel-with-dashes',
119
+ })
120
+ ).toThrow('Invalid notify channel');
121
+ });
122
+ });
123
+
124
+ describe('initialize', () => {
125
+ it('should create table if autoCreateTable is true', async () => {
126
+ const store = postgresAuthConfigStore({
127
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
128
+ autoCreateTable: true,
129
+ enableNotify: false,
130
+ });
131
+
132
+ await store.initialize();
133
+
134
+ expect(mockPool.query).toHaveBeenCalledWith(
135
+ expect.stringContaining('CREATE TABLE IF NOT EXISTS')
136
+ );
137
+ });
138
+
139
+ it('should not create table if autoCreateTable is false', async () => {
140
+ const store = postgresAuthConfigStore({
141
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
142
+ autoCreateTable: false,
143
+ enableNotify: false,
144
+ });
145
+
146
+ await store.initialize();
147
+
148
+ expect(mockPool.query).not.toHaveBeenCalledWith(
149
+ expect.stringContaining('CREATE TABLE IF NOT EXISTS')
150
+ );
151
+ });
152
+
153
+ it('should use custom table name', async () => {
154
+ const store = postgresAuthConfigStore({
155
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
156
+ tableName: 'custom_auth_config',
157
+ autoCreateTable: true,
158
+ enableNotify: false,
159
+ });
160
+
161
+ await store.initialize();
162
+
163
+ expect(mockPool.query).toHaveBeenCalledWith(
164
+ expect.stringContaining('"public"."custom_auth_config"')
165
+ );
166
+ });
167
+
168
+ it('should use custom schema name', async () => {
169
+ const store = postgresAuthConfigStore({
170
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
171
+ schema: 'myschema',
172
+ autoCreateTable: true,
173
+ enableNotify: false,
174
+ });
175
+
176
+ await store.initialize();
177
+
178
+ expect(mockPool.query).toHaveBeenCalledWith(
179
+ expect.stringContaining('"myschema"."auth_config"')
180
+ );
181
+ });
182
+ });
183
+
184
+ describe('load', () => {
185
+ it('should return null when no config exists', async () => {
186
+ mockPool.query.mockResolvedValueOnce({ rows: [], rowCount: 0 });
187
+
188
+ const store = postgresAuthConfigStore({
189
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
190
+ autoCreateTable: false,
191
+ enableNotify: false,
192
+ });
193
+
194
+ const result = await store.load();
195
+
196
+ expect(result).toBeNull();
197
+ });
198
+
199
+ it('should return config when it exists', async () => {
200
+ const mockConfig = {
201
+ adapter: 'basic',
202
+ config: { basic: { username: 'admin', password: 'secret' } },
203
+ settings: {},
204
+ updated_at: new Date('2025-01-15T12:00:00Z'),
205
+ updated_by: 'admin@example.com',
206
+ };
207
+
208
+ mockPool.query.mockResolvedValueOnce({ rows: [mockConfig], rowCount: 1 });
209
+
210
+ const store = postgresAuthConfigStore({
211
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
212
+ autoCreateTable: false,
213
+ enableNotify: false,
214
+ });
215
+
216
+ const result = await store.load();
217
+
218
+ expect(result).toEqual({
219
+ adapter: 'basic',
220
+ config: { basic: { username: 'admin', password: 'secret' } },
221
+ settings: {},
222
+ updatedAt: '2025-01-15T12:00:00.000Z',
223
+ updatedBy: 'admin@example.com',
224
+ });
225
+ });
226
+ });
227
+
228
+ describe('save', () => {
229
+ it('should upsert config to database', async () => {
230
+ const store = postgresAuthConfigStore({
231
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
232
+ autoCreateTable: false,
233
+ enableNotify: false,
234
+ });
235
+
236
+ const config: RuntimeAuthConfig = {
237
+ adapter: 'basic',
238
+ config: { basic: { username: 'admin', password: 'secret', realm: 'Test' } },
239
+ settings: {},
240
+ updatedAt: new Date().toISOString(),
241
+ updatedBy: 'admin@example.com',
242
+ };
243
+
244
+ await store.save(config);
245
+
246
+ expect(mockPool.query).toHaveBeenCalledWith(
247
+ expect.stringContaining('INSERT INTO'),
248
+ expect.arrayContaining(['basic', expect.any(String), expect.any(String), 'admin@example.com'])
249
+ );
250
+ });
251
+
252
+ it('should send NOTIFY when enableNotify is true', async () => {
253
+ const store = postgresAuthConfigStore({
254
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
255
+ autoCreateTable: false,
256
+ enableNotify: true,
257
+ });
258
+
259
+ const config: RuntimeAuthConfig = {
260
+ adapter: 'basic',
261
+ config: { basic: { username: 'admin', password: 'secret', realm: 'Test' } },
262
+ settings: {},
263
+ updatedAt: new Date().toISOString(),
264
+ };
265
+
266
+ await store.save(config);
267
+
268
+ expect(mockPool.query).toHaveBeenCalledWith('NOTIFY auth_config_changed');
269
+ });
270
+
271
+ it('should not send NOTIFY when enableNotify is false', async () => {
272
+ const store = postgresAuthConfigStore({
273
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
274
+ autoCreateTable: false,
275
+ enableNotify: false,
276
+ });
277
+
278
+ const config: RuntimeAuthConfig = {
279
+ adapter: 'basic',
280
+ config: { basic: { username: 'admin', password: 'secret', realm: 'Test' } },
281
+ settings: {},
282
+ updatedAt: new Date().toISOString(),
283
+ };
284
+
285
+ await store.save(config);
286
+
287
+ expect(mockPool.query).not.toHaveBeenCalledWith('NOTIFY auth_config_changed');
288
+ });
289
+ });
290
+
291
+ describe('delete', () => {
292
+ it('should delete config from database', async () => {
293
+ mockPool.query.mockResolvedValueOnce({ rows: [], rowCount: 1 });
294
+
295
+ const store = postgresAuthConfigStore({
296
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
297
+ autoCreateTable: false,
298
+ enableNotify: false,
299
+ });
300
+
301
+ const result = await store.delete();
302
+
303
+ expect(mockPool.query).toHaveBeenCalledWith(
304
+ expect.stringContaining('DELETE FROM')
305
+ );
306
+ expect(result).toBe(true);
307
+ });
308
+
309
+ it('should return false when no config to delete', async () => {
310
+ mockPool.query.mockResolvedValueOnce({ rows: [], rowCount: 0 });
311
+
312
+ const store = postgresAuthConfigStore({
313
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
314
+ autoCreateTable: false,
315
+ enableNotify: false,
316
+ });
317
+
318
+ const result = await store.delete();
319
+
320
+ expect(result).toBe(false);
321
+ });
322
+
323
+ it('should send NOTIFY after delete when enableNotify is true', async () => {
324
+ mockPool.query.mockResolvedValueOnce({ rows: [], rowCount: 1 });
325
+
326
+ const store = postgresAuthConfigStore({
327
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
328
+ autoCreateTable: false,
329
+ enableNotify: true,
330
+ });
331
+
332
+ await store.delete();
333
+
334
+ expect(mockPool.query).toHaveBeenCalledWith('NOTIFY auth_config_changed');
335
+ });
336
+ });
337
+
338
+ describe('onChange', () => {
339
+ it('should register listener and return unsubscribe function', () => {
340
+ const store = postgresAuthConfigStore({
341
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
342
+ autoCreateTable: false,
343
+ enableNotify: false,
344
+ });
345
+
346
+ const listener = vi.fn();
347
+ const unsubscribe = store.onChange(listener);
348
+
349
+ expect(typeof unsubscribe).toBe('function');
350
+ });
351
+
352
+ it('should unsubscribe listener when called', () => {
353
+ const store = postgresAuthConfigStore({
354
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
355
+ autoCreateTable: false,
356
+ enableNotify: false,
357
+ });
358
+
359
+ const listener = vi.fn();
360
+ const unsubscribe = store.onChange(listener);
361
+
362
+ // Unsubscribe
363
+ unsubscribe();
364
+
365
+ // Listener should not be called anymore
366
+ // (We can't easily test this without exposing internals, but the function exists)
367
+ expect(unsubscribe).toBeDefined();
368
+ });
369
+ });
370
+
371
+ describe('lazy pool initialization', () => {
372
+ it('should accept a function that returns the pool', async () => {
373
+ const poolFn = vi.fn().mockReturnValue(mockPool);
374
+
375
+ const store = postgresAuthConfigStore({
376
+ pool: poolFn as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
377
+ autoCreateTable: false,
378
+ enableNotify: false,
379
+ });
380
+
381
+ mockPool.query.mockResolvedValueOnce({ rows: [], rowCount: 0 });
382
+ await store.load();
383
+
384
+ expect(poolFn).toHaveBeenCalled();
385
+ });
386
+
387
+ it('should throw if pool is invalid', async () => {
388
+ const store = postgresAuthConfigStore({
389
+ pool: (() => ({})) as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
390
+ autoCreateTable: false,
391
+ enableNotify: false,
392
+ });
393
+
394
+ await expect(store.load()).rejects.toThrow('Invalid pool');
395
+ });
396
+ });
397
+
398
+ describe('shutdown', () => {
399
+ it('should release listener client and clear listeners', async () => {
400
+ const store = postgresAuthConfigStore({
401
+ pool: mockPool as unknown as Parameters<typeof postgresAuthConfigStore>[0]['pool'],
402
+ autoCreateTable: false,
403
+ enableNotify: false,
404
+ });
405
+
406
+ // Add a listener
407
+ const listener = vi.fn();
408
+ store.onChange(listener);
409
+
410
+ // Shutdown
411
+ await store.shutdown();
412
+
413
+ // Should complete without error
414
+ expect(true).toBe(true);
415
+ });
416
+ });
417
+ });