@qwickapps/server 1.1.9 → 1.3.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 (240) hide show
  1. package/README.md +318 -0
  2. package/dist/core/control-panel.d.ts +7 -2
  3. package/dist/core/control-panel.d.ts.map +1 -1
  4. package/dist/core/control-panel.js +99 -60
  5. package/dist/core/control-panel.js.map +1 -1
  6. package/dist/core/gateway.d.ts +159 -79
  7. package/dist/core/gateway.d.ts.map +1 -1
  8. package/dist/core/gateway.js +683 -315
  9. package/dist/core/gateway.js.map +1 -1
  10. package/dist/core/index.d.ts +3 -1
  11. package/dist/core/index.d.ts.map +1 -1
  12. package/dist/core/index.js +2 -0
  13. package/dist/core/index.js.map +1 -1
  14. package/dist/core/plugin-registry.d.ts +271 -0
  15. package/dist/core/plugin-registry.d.ts.map +1 -0
  16. package/dist/core/plugin-registry.js +326 -0
  17. package/dist/core/plugin-registry.js.map +1 -0
  18. package/dist/core/types.d.ts +16 -33
  19. package/dist/core/types.d.ts.map +1 -1
  20. package/dist/index.d.ts +8 -5
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +15 -7
  23. package/dist/index.js.map +1 -1
  24. package/dist/plugins/auth/adapters/auth0-adapter.d.ts +14 -0
  25. package/dist/plugins/auth/adapters/auth0-adapter.d.ts.map +1 -0
  26. package/dist/plugins/auth/adapters/auth0-adapter.js +179 -0
  27. package/dist/plugins/auth/adapters/auth0-adapter.js.map +1 -0
  28. package/dist/plugins/auth/adapters/basic-adapter.d.ts +13 -0
  29. package/dist/plugins/auth/adapters/basic-adapter.d.ts.map +1 -0
  30. package/dist/plugins/auth/adapters/basic-adapter.js +51 -0
  31. package/dist/plugins/auth/adapters/basic-adapter.js.map +1 -0
  32. package/dist/plugins/auth/adapters/index.d.ts +9 -0
  33. package/dist/plugins/auth/adapters/index.d.ts.map +1 -0
  34. package/dist/plugins/auth/adapters/index.js +9 -0
  35. package/dist/plugins/auth/adapters/index.js.map +1 -0
  36. package/dist/plugins/auth/adapters/supabase-adapter.d.ts +13 -0
  37. package/dist/plugins/auth/adapters/supabase-adapter.d.ts.map +1 -0
  38. package/dist/plugins/auth/adapters/supabase-adapter.js +109 -0
  39. package/dist/plugins/auth/adapters/supabase-adapter.js.map +1 -0
  40. package/dist/plugins/auth/auth-plugin.d.ts +40 -0
  41. package/dist/plugins/auth/auth-plugin.d.ts.map +1 -0
  42. package/dist/plugins/auth/auth-plugin.js +255 -0
  43. package/dist/plugins/auth/auth-plugin.js.map +1 -0
  44. package/dist/plugins/auth/auth-plugin.test.d.ts +9 -0
  45. package/dist/plugins/auth/auth-plugin.test.d.ts.map +1 -0
  46. package/dist/plugins/auth/auth-plugin.test.js +147 -0
  47. package/dist/plugins/auth/auth-plugin.test.js.map +1 -0
  48. package/dist/plugins/auth/index.d.ts +12 -0
  49. package/dist/plugins/auth/index.d.ts.map +1 -0
  50. package/dist/plugins/auth/index.js +13 -0
  51. package/dist/plugins/auth/index.js.map +1 -0
  52. package/dist/plugins/auth/types.d.ts +148 -0
  53. package/dist/plugins/auth/types.d.ts.map +1 -0
  54. package/dist/plugins/auth/types.js +14 -0
  55. package/dist/plugins/auth/types.js.map +1 -0
  56. package/dist/plugins/bans/bans-plugin.d.ts +59 -0
  57. package/dist/plugins/bans/bans-plugin.d.ts.map +1 -0
  58. package/dist/plugins/bans/bans-plugin.js +428 -0
  59. package/dist/plugins/bans/bans-plugin.js.map +1 -0
  60. package/dist/plugins/bans/index.d.ts +9 -0
  61. package/dist/plugins/bans/index.d.ts.map +1 -0
  62. package/dist/plugins/bans/index.js +10 -0
  63. package/dist/plugins/bans/index.js.map +1 -0
  64. package/dist/plugins/bans/stores/index.d.ts +7 -0
  65. package/dist/plugins/bans/stores/index.d.ts.map +1 -0
  66. package/dist/plugins/bans/stores/index.js +7 -0
  67. package/dist/plugins/bans/stores/index.js.map +1 -0
  68. package/dist/plugins/bans/stores/postgres-store.d.ts +29 -0
  69. package/dist/plugins/bans/stores/postgres-store.d.ts.map +1 -0
  70. package/dist/plugins/bans/stores/postgres-store.js +132 -0
  71. package/dist/plugins/bans/stores/postgres-store.js.map +1 -0
  72. package/dist/plugins/bans/types.d.ts +128 -0
  73. package/dist/plugins/bans/types.d.ts.map +1 -0
  74. package/dist/plugins/bans/types.js +11 -0
  75. package/dist/plugins/bans/types.js.map +1 -0
  76. package/dist/plugins/cache-plugin.d.ts +14 -3
  77. package/dist/plugins/cache-plugin.d.ts.map +1 -1
  78. package/dist/plugins/cache-plugin.js +27 -7
  79. package/dist/plugins/cache-plugin.js.map +1 -1
  80. package/dist/plugins/cache-plugin.test.js +96 -32
  81. package/dist/plugins/cache-plugin.test.js.map +1 -1
  82. package/dist/plugins/config-plugin.d.ts +3 -2
  83. package/dist/plugins/config-plugin.d.ts.map +1 -1
  84. package/dist/plugins/config-plugin.js +17 -10
  85. package/dist/plugins/config-plugin.js.map +1 -1
  86. package/dist/plugins/diagnostics-plugin.d.ts +2 -2
  87. package/dist/plugins/diagnostics-plugin.d.ts.map +1 -1
  88. package/dist/plugins/diagnostics-plugin.js +17 -10
  89. package/dist/plugins/diagnostics-plugin.js.map +1 -1
  90. package/dist/plugins/entitlements/entitlements-plugin.d.ts +95 -0
  91. package/dist/plugins/entitlements/entitlements-plugin.d.ts.map +1 -0
  92. package/dist/plugins/entitlements/entitlements-plugin.js +707 -0
  93. package/dist/plugins/entitlements/entitlements-plugin.js.map +1 -0
  94. package/dist/plugins/entitlements/index.d.ts +12 -0
  95. package/dist/plugins/entitlements/index.d.ts.map +1 -0
  96. package/dist/plugins/entitlements/index.js +16 -0
  97. package/dist/plugins/entitlements/index.js.map +1 -0
  98. package/dist/plugins/entitlements/sources/index.d.ts +9 -0
  99. package/dist/plugins/entitlements/sources/index.d.ts.map +1 -0
  100. package/dist/plugins/entitlements/sources/index.js +9 -0
  101. package/dist/plugins/entitlements/sources/index.js.map +1 -0
  102. package/dist/plugins/entitlements/sources/postgres-source.d.ts +29 -0
  103. package/dist/plugins/entitlements/sources/postgres-source.d.ts.map +1 -0
  104. package/dist/plugins/entitlements/sources/postgres-source.js +169 -0
  105. package/dist/plugins/entitlements/sources/postgres-source.js.map +1 -0
  106. package/dist/plugins/entitlements/types.d.ts +232 -0
  107. package/dist/plugins/entitlements/types.d.ts.map +1 -0
  108. package/dist/plugins/entitlements/types.js +11 -0
  109. package/dist/plugins/entitlements/types.js.map +1 -0
  110. package/dist/plugins/frontend-app-plugin.d.ts +9 -3
  111. package/dist/plugins/frontend-app-plugin.d.ts.map +1 -1
  112. package/dist/plugins/frontend-app-plugin.js +14 -9
  113. package/dist/plugins/frontend-app-plugin.js.map +1 -1
  114. package/dist/plugins/health-plugin.d.ts +5 -2
  115. package/dist/plugins/health-plugin.d.ts.map +1 -1
  116. package/dist/plugins/health-plugin.js +20 -5
  117. package/dist/plugins/health-plugin.js.map +1 -1
  118. package/dist/plugins/index.d.ts +8 -2
  119. package/dist/plugins/index.d.ts.map +1 -1
  120. package/dist/plugins/index.js +8 -2
  121. package/dist/plugins/index.js.map +1 -1
  122. package/dist/plugins/logs-plugin.d.ts +3 -2
  123. package/dist/plugins/logs-plugin.d.ts.map +1 -1
  124. package/dist/plugins/logs-plugin.js +21 -12
  125. package/dist/plugins/logs-plugin.js.map +1 -1
  126. package/dist/plugins/postgres-plugin.d.ts +3 -3
  127. package/dist/plugins/postgres-plugin.d.ts.map +1 -1
  128. package/dist/plugins/postgres-plugin.js +9 -7
  129. package/dist/plugins/postgres-plugin.js.map +1 -1
  130. package/dist/plugins/postgres-plugin.test.js +47 -29
  131. package/dist/plugins/postgres-plugin.test.js.map +1 -1
  132. package/dist/plugins/users/index.d.ts +12 -0
  133. package/dist/plugins/users/index.d.ts.map +1 -0
  134. package/dist/plugins/users/index.js +13 -0
  135. package/dist/plugins/users/index.js.map +1 -0
  136. package/dist/plugins/users/stores/index.d.ts +7 -0
  137. package/dist/plugins/users/stores/index.d.ts.map +1 -0
  138. package/dist/plugins/users/stores/index.js +7 -0
  139. package/dist/plugins/users/stores/index.js.map +1 -0
  140. package/dist/plugins/users/stores/postgres-store.d.ts +28 -0
  141. package/dist/plugins/users/stores/postgres-store.d.ts.map +1 -0
  142. package/dist/plugins/users/stores/postgres-store.js +157 -0
  143. package/dist/plugins/users/stores/postgres-store.js.map +1 -0
  144. package/dist/plugins/users/types.d.ts +189 -0
  145. package/dist/plugins/users/types.d.ts.map +1 -0
  146. package/dist/plugins/users/types.js +12 -0
  147. package/dist/plugins/users/types.js.map +1 -0
  148. package/dist/plugins/users/users-plugin.d.ts +39 -0
  149. package/dist/plugins/users/users-plugin.d.ts.map +1 -0
  150. package/dist/plugins/users/users-plugin.js +242 -0
  151. package/dist/plugins/users/users-plugin.js.map +1 -0
  152. package/dist-ui/assets/index-Bsp2ntcw.js +465 -0
  153. package/dist-ui/assets/index-Bsp2ntcw.js.map +1 -0
  154. package/dist-ui/index.html +1 -1
  155. package/dist-ui-lib/api/controlPanelApi.d.ts +232 -0
  156. package/dist-ui-lib/components/ControlPanelApp.d.ts +61 -0
  157. package/dist-ui-lib/components/index.d.ts +18 -0
  158. package/dist-ui-lib/config/AppConfig.d.ts +7 -0
  159. package/dist-ui-lib/dashboard/DashboardWidgetRegistry.d.ts +62 -0
  160. package/dist-ui-lib/dashboard/DashboardWidgetRenderer.d.ts +8 -0
  161. package/dist-ui-lib/dashboard/PluginWidgetRenderer.d.ts +19 -0
  162. package/dist-ui-lib/dashboard/WidgetComponentRegistry.d.ts +44 -0
  163. package/dist-ui-lib/dashboard/builtInWidgets.d.ts +19 -0
  164. package/dist-ui-lib/dashboard/index.d.ts +13 -0
  165. package/dist-ui-lib/dashboard/widgets/ServiceHealthWidget.d.ts +12 -0
  166. package/dist-ui-lib/dashboard/widgets/index.d.ts +6 -0
  167. package/dist-ui-lib/index.js +6441 -0
  168. package/dist-ui-lib/index.js.map +1 -0
  169. package/dist-ui-lib/pages/ConfigPage.d.ts +1 -0
  170. package/dist-ui-lib/pages/DashboardPage.d.ts +1 -0
  171. package/dist-ui-lib/pages/DiagnosticsPage.d.ts +1 -0
  172. package/dist-ui-lib/pages/EntitlementsPage.d.ts +17 -0
  173. package/dist-ui-lib/pages/LogsPage.d.ts +1 -0
  174. package/dist-ui-lib/pages/NotFoundPage.d.ts +1 -0
  175. package/dist-ui-lib/pages/PluginPage.d.ts +15 -0
  176. package/dist-ui-lib/pages/SystemPage.d.ts +1 -0
  177. package/dist-ui-lib/pages/UsersPage.d.ts +22 -0
  178. package/package.json +18 -6
  179. package/src/core/control-panel.ts +122 -68
  180. package/src/core/gateway.ts +870 -399
  181. package/src/core/index.ts +21 -2
  182. package/src/core/plugin-registry.ts +653 -0
  183. package/src/core/types.ts +31 -37
  184. package/src/index.ts +118 -19
  185. package/src/plugins/auth/adapters/auth0-adapter.ts +214 -0
  186. package/src/plugins/auth/adapters/basic-adapter.ts +61 -0
  187. package/src/plugins/auth/adapters/index.ts +9 -0
  188. package/src/plugins/auth/adapters/supabase-adapter.ts +141 -0
  189. package/src/plugins/auth/auth-plugin.test.ts +176 -0
  190. package/src/plugins/auth/auth-plugin.ts +303 -0
  191. package/src/plugins/auth/index.ts +33 -0
  192. package/src/plugins/auth/types.ts +165 -0
  193. package/src/plugins/bans/bans-plugin.ts +485 -0
  194. package/src/plugins/bans/index.ts +31 -0
  195. package/src/plugins/bans/stores/index.ts +7 -0
  196. package/src/plugins/bans/stores/postgres-store.ts +195 -0
  197. package/src/plugins/bans/types.ts +141 -0
  198. package/src/plugins/cache-plugin.test.ts +105 -32
  199. package/src/plugins/cache-plugin.ts +40 -9
  200. package/src/plugins/config-plugin.ts +23 -12
  201. package/src/plugins/diagnostics-plugin.ts +22 -12
  202. package/src/plugins/entitlements/entitlements-plugin.ts +820 -0
  203. package/src/plugins/entitlements/index.ts +51 -0
  204. package/src/plugins/entitlements/sources/index.ts +9 -0
  205. package/src/plugins/entitlements/sources/postgres-source.ts +253 -0
  206. package/src/plugins/entitlements/types.ts +256 -0
  207. package/src/plugins/frontend-app-plugin.ts +24 -12
  208. package/src/plugins/health-plugin.ts +27 -7
  209. package/src/plugins/index.ts +106 -4
  210. package/src/plugins/logs-plugin.ts +28 -14
  211. package/src/plugins/postgres-plugin.test.ts +49 -29
  212. package/src/plugins/postgres-plugin.ts +11 -9
  213. package/src/plugins/users/index.ts +35 -0
  214. package/src/plugins/users/stores/index.ts +7 -0
  215. package/src/plugins/users/stores/postgres-store.ts +225 -0
  216. package/src/plugins/users/types.ts +209 -0
  217. package/src/plugins/users/users-plugin.ts +281 -0
  218. package/ui/src/App.tsx +185 -31
  219. package/ui/src/api/controlPanelApi.ts +354 -1
  220. package/ui/src/components/ControlPanelApp.tsx +209 -0
  221. package/ui/src/components/index.ts +62 -0
  222. package/ui/src/dashboard/DashboardWidgetRegistry.tsx +129 -0
  223. package/ui/src/dashboard/DashboardWidgetRenderer.tsx +34 -0
  224. package/ui/src/dashboard/PluginWidgetRenderer.tsx +115 -0
  225. package/ui/src/dashboard/WidgetComponentRegistry.tsx +116 -0
  226. package/ui/src/dashboard/builtInWidgets.tsx +29 -0
  227. package/ui/src/dashboard/index.ts +35 -0
  228. package/ui/src/dashboard/widgets/ServiceHealthWidget.tsx +140 -0
  229. package/ui/src/dashboard/widgets/index.ts +7 -0
  230. package/ui/src/pages/DashboardPage.tsx +28 -149
  231. package/ui/src/pages/EntitlementsPage.tsx +557 -0
  232. package/ui/src/pages/LogsPage.tsx +174 -8
  233. package/ui/src/pages/PluginPage.tsx +148 -0
  234. package/ui/src/pages/SystemPage.tsx +445 -0
  235. package/ui/src/pages/UsersPage.tsx +837 -0
  236. package/ui/tsconfig.lib.json +11 -0
  237. package/ui/vite.lib.config.ts +51 -0
  238. package/dist-ui/assets/index-CW1BviRn.js +0 -465
  239. package/dist-ui/assets/index-CW1BviRn.js.map +0 -1
  240. package/ui/src/pages/HealthPage.tsx +0 -204
@@ -0,0 +1,820 @@
1
+ /**
2
+ * Entitlements Plugin
3
+ *
4
+ * User entitlement management plugin for @qwickapps/server.
5
+ * Supports pluggable sources (PostgreSQL, Keap, etc.) with Redis caching.
6
+ *
7
+ * Entitlements are string-based tags (e.g., 'pro', 'enterprise', 'feature:analytics').
8
+ * Multiple sources can be combined - entitlements are merged from all sources.
9
+ *
10
+ * Copyright (c) 2025 QwickApps.com. All rights reserved.
11
+ */
12
+
13
+ import type { Request, Response, RequestHandler } from 'express';
14
+ import type { Plugin, PluginConfig, PluginRegistry } from '../../core/plugin-registry.js';
15
+ import type {
16
+ EntitlementsPluginConfig,
17
+ EntitlementSource,
18
+ EntitlementResult,
19
+ EntitlementDefinition,
20
+ CachedEntitlements,
21
+ EntitlementStats,
22
+ } from './types.js';
23
+ import type { AuthenticatedRequest } from '../auth/types.js';
24
+ import { getCache, type CacheInstance } from '../cache-plugin.js';
25
+
26
+ // Plugin state
27
+ let primarySource: EntitlementSource | null = null;
28
+ let additionalSources: EntitlementSource[] = [];
29
+ let pluginConfig: EntitlementsPluginConfig | null = null;
30
+ let cacheInstance: CacheInstance | null = null;
31
+ let cacheKeyPrefix = 'entitlements:';
32
+ let cacheTtl = 300;
33
+ let cacheMappingTtl = 600;
34
+ let cacheEnabled = true;
35
+ let cacheVersion = 1;
36
+
37
+ /**
38
+ * Create the Entitlements plugin
39
+ */
40
+ export function createEntitlementsPlugin(config: EntitlementsPluginConfig): Plugin {
41
+ const debug = config.debug || false;
42
+ // Routes are mounted under /api by the control panel, so don't include /api in prefix
43
+ const apiPrefix = config.api?.prefix || '/entitlements';
44
+ const apiEnabled = config.api?.enabled !== false;
45
+ const enableWriteApi = config.api?.enableWrite !== false;
46
+
47
+ function log(message: string, data?: Record<string, unknown>) {
48
+ if (debug) {
49
+ console.log(`[EntitlementsPlugin] ${message}`, data || '');
50
+ }
51
+ }
52
+
53
+ // Cache key helpers
54
+ const keys = {
55
+ entitlements: (email: string) => `${cacheKeyPrefix}user:${email.toLowerCase()}`,
56
+ mapping: (source: string, id: string) => `${cacheKeyPrefix}mapping:${source}:${id}`,
57
+ };
58
+
59
+ return {
60
+ id: 'entitlements',
61
+ name: 'Entitlements',
62
+ version: '1.0.0',
63
+
64
+ async onStart(_pluginConfig: PluginConfig, registry: PluginRegistry): Promise<void> {
65
+ log('Starting entitlements plugin');
66
+
67
+ // Initialize primary source
68
+ await config.source.initialize();
69
+ primarySource = config.source;
70
+ log('Primary source initialized', { source: config.source.name });
71
+
72
+ // Initialize additional sources
73
+ additionalSources = config.additionalSources || [];
74
+ for (const source of additionalSources) {
75
+ await source.initialize();
76
+ log('Additional source initialized', { source: source.name });
77
+ }
78
+
79
+ // Store config
80
+ pluginConfig = config;
81
+
82
+ // Setup caching if enabled
83
+ cacheEnabled = config.cache?.enabled !== false;
84
+ if (cacheEnabled) {
85
+ try {
86
+ const instanceName = config.cache?.instanceName || 'default';
87
+ cacheInstance = getCache(instanceName);
88
+ cacheKeyPrefix = config.cache?.keyPrefix || 'entitlements:';
89
+ cacheTtl = config.cache?.ttl || 300;
90
+ cacheMappingTtl = config.cache?.mappingTtl || cacheTtl * 2;
91
+ log('Cache configured', { instanceName, prefix: cacheKeyPrefix, ttl: cacheTtl });
92
+ } catch {
93
+ log('Cache not available, running without caching');
94
+ cacheEnabled = false;
95
+ cacheInstance = null;
96
+ }
97
+ }
98
+
99
+ // Register health check
100
+ registry.registerHealthCheck({
101
+ name: 'entitlements-source',
102
+ type: 'custom',
103
+ check: async () => {
104
+ try {
105
+ // Use source's isHealthy() method if available (avoids API calls)
106
+ // Otherwise just check that source is initialized
107
+ if (config.source.isHealthy) {
108
+ const healthy = await config.source.isHealthy();
109
+ return { healthy };
110
+ }
111
+ // Source is healthy if initialized (we got here means it started)
112
+ return { healthy: primarySource !== null };
113
+ } catch {
114
+ return { healthy: false };
115
+ }
116
+ },
117
+ });
118
+
119
+ // Add API routes if enabled
120
+ // IMPORTANT: Static paths must be registered BEFORE parameterized paths
121
+ // to prevent /:email from matching "available" and "status"
122
+ if (apiEnabled) {
123
+ // List available entitlements (static path - must be before /:email)
124
+ registry.addRoute({
125
+ method: 'get',
126
+ path: `${apiPrefix}/available`,
127
+ pluginId: 'entitlements',
128
+ handler: async (_req: Request, res: Response) => {
129
+ try {
130
+ const available = await getAvailableEntitlements();
131
+ res.json({ entitlements: available, total: available.length });
132
+ } catch (error) {
133
+ console.error('[EntitlementsPlugin] List available error:', error);
134
+ res.status(500).json({ error: 'Failed to list available entitlements' });
135
+ }
136
+ },
137
+ });
138
+
139
+ // Get entitlements plugin status (static path - must be before /:email)
140
+ registry.addRoute({
141
+ method: 'get',
142
+ path: `${apiPrefix}/status`,
143
+ pluginId: 'entitlements',
144
+ handler: async (_req: Request, res: Response) => {
145
+ try {
146
+ const sources = [
147
+ {
148
+ name: config.source.name,
149
+ description: config.source.description,
150
+ readonly: config.source.readonly ?? false,
151
+ primary: true,
152
+ },
153
+ ...additionalSources.map((s) => ({
154
+ name: s.name,
155
+ description: s.description,
156
+ readonly: s.readonly ?? false,
157
+ primary: false,
158
+ })),
159
+ ];
160
+
161
+ res.json({
162
+ readonly: config.source.readonly ?? false,
163
+ writeEnabled: enableWriteApi && !config.source.readonly,
164
+ cacheEnabled,
165
+ cacheTtl,
166
+ sources,
167
+ });
168
+ } catch (error) {
169
+ console.error('[EntitlementsPlugin] Status error:', error);
170
+ res.status(500).json({ error: 'Failed to get status' });
171
+ }
172
+ },
173
+ });
174
+
175
+ // Get entitlements statistics (static path - must be before /:email)
176
+ registry.addRoute({
177
+ method: 'get',
178
+ path: `${apiPrefix}/stats`,
179
+ pluginId: 'entitlements',
180
+ handler: async (_req: Request, res: Response) => {
181
+ try {
182
+ const stats = await getEntitlementStats();
183
+ res.json(stats);
184
+ } catch (error) {
185
+ console.error('[EntitlementsPlugin] Stats error:', error);
186
+ res.status(500).json({ error: 'Failed to get entitlement stats' });
187
+ }
188
+ },
189
+ });
190
+
191
+ // Invalidate cache for email (static prefix - must be before /:email)
192
+ registry.addRoute({
193
+ method: 'delete',
194
+ path: `${apiPrefix}/cache/:email`,
195
+ pluginId: 'entitlements',
196
+ handler: async (req: Request, res: Response) => {
197
+ try {
198
+ const email = decodeURIComponent(req.params.email);
199
+ await invalidateEntitlementCache(email);
200
+ log('Cache invalidated', { email });
201
+ res.status(204).send();
202
+ } catch (error) {
203
+ console.error('[EntitlementsPlugin] Invalidate cache error:', error);
204
+ res.status(500).json({ error: 'Failed to invalidate cache' });
205
+ }
206
+ },
207
+ });
208
+
209
+ // Get entitlements for email (parameterized - after static paths)
210
+ // Note: We guard against reserved paths that might accidentally match :email
211
+ const reservedPaths = ['stats', 'available', 'status', 'cache'];
212
+ registry.addRoute({
213
+ method: 'get',
214
+ path: `${apiPrefix}/:email`,
215
+ pluginId: 'entitlements',
216
+ handler: async (req: Request, res: Response) => {
217
+ try {
218
+ const email = decodeURIComponent(req.params.email);
219
+
220
+ // Skip reserved paths - they have their own handlers
221
+ if (reservedPaths.includes(email.toLowerCase())) {
222
+ return res.status(404).json({ error: 'Not found' });
223
+ }
224
+
225
+ const refresh = req.query.refresh === 'true';
226
+
227
+ const result = refresh
228
+ ? await refreshEntitlements(email)
229
+ : await getEntitlements(email);
230
+
231
+ res.json(result);
232
+ } catch (error) {
233
+ console.error('[EntitlementsPlugin] Get entitlements error:', error);
234
+ res.status(500).json({ error: 'Failed to get entitlements' });
235
+ }
236
+ },
237
+ });
238
+
239
+ // Check specific entitlement
240
+ registry.addRoute({
241
+ method: 'get',
242
+ path: `${apiPrefix}/:email/check/:entitlement`,
243
+ pluginId: 'entitlements',
244
+ handler: async (req: Request, res: Response) => {
245
+ try {
246
+ const email = decodeURIComponent(req.params.email);
247
+ const entitlement = decodeURIComponent(req.params.entitlement);
248
+
249
+ const has = await hasEntitlement(email, entitlement);
250
+ res.json({ email, entitlement, hasEntitlement: has });
251
+ } catch (error) {
252
+ console.error('[EntitlementsPlugin] Check entitlement error:', error);
253
+ res.status(500).json({ error: 'Failed to check entitlement' });
254
+ }
255
+ },
256
+ });
257
+
258
+ // Check multiple entitlements
259
+ registry.addRoute({
260
+ method: 'post',
261
+ path: `${apiPrefix}/:email/check`,
262
+ pluginId: 'entitlements',
263
+ handler: async (req: Request, res: Response) => {
264
+ try {
265
+ const email = decodeURIComponent(req.params.email);
266
+ const { entitlements: toCheck, mode = 'any' } = req.body;
267
+
268
+ if (!Array.isArray(toCheck) || toCheck.length === 0) {
269
+ return res.status(400).json({ error: 'entitlements array required' });
270
+ }
271
+
272
+ const result = await getEntitlements(email);
273
+ const has = toCheck.filter((e: string) => result.entitlements.includes(e));
274
+ const missing = toCheck.filter((e: string) => !result.entitlements.includes(e));
275
+
276
+ const passed = mode === 'all'
277
+ ? missing.length === 0
278
+ : has.length > 0;
279
+
280
+ res.json({
281
+ email,
282
+ mode,
283
+ passed,
284
+ has,
285
+ missing,
286
+ total: result.entitlements.length,
287
+ });
288
+ } catch (error) {
289
+ console.error('[EntitlementsPlugin] Check entitlements error:', error);
290
+ res.status(500).json({ error: 'Failed to check entitlements' });
291
+ }
292
+ },
293
+ });
294
+
295
+ // Refresh entitlements (bypass cache)
296
+ registry.addRoute({
297
+ method: 'post',
298
+ path: `${apiPrefix}/:email/refresh`,
299
+ pluginId: 'entitlements',
300
+ handler: async (req: Request, res: Response) => {
301
+ try {
302
+ const email = decodeURIComponent(req.params.email);
303
+ const result = await refreshEntitlements(email);
304
+ log('Entitlements refreshed', { email, count: result.entitlements.length });
305
+ res.json(result);
306
+ } catch (error) {
307
+ console.error('[EntitlementsPlugin] Refresh entitlements error:', error);
308
+ res.status(500).json({ error: 'Failed to refresh entitlements' });
309
+ }
310
+ },
311
+ });
312
+
313
+ // Write endpoints (grant/revoke) - only if enabled and source is writable
314
+ if (enableWriteApi && !config.source.readonly) {
315
+ // Grant entitlement
316
+ registry.addRoute({
317
+ method: 'post',
318
+ path: `${apiPrefix}/:email`,
319
+ pluginId: 'entitlements',
320
+ handler: async (req: Request, res: Response) => {
321
+ try {
322
+ const email = decodeURIComponent(req.params.email);
323
+ const { entitlement } = req.body;
324
+
325
+ if (!entitlement) {
326
+ return res.status(400).json({ error: 'entitlement required' });
327
+ }
328
+
329
+ const authReq = req as AuthenticatedRequest;
330
+ const grantedBy = authReq.auth?.user?.email || 'system';
331
+
332
+ await grantEntitlement(email, entitlement, grantedBy);
333
+ log('Entitlement granted', { email, entitlement, grantedBy });
334
+
335
+ res.status(201).json({ email, entitlement, granted: true });
336
+ } catch (error) {
337
+ console.error('[EntitlementsPlugin] Grant entitlement error:', error);
338
+ res.status(500).json({ error: 'Failed to grant entitlement' });
339
+ }
340
+ },
341
+ });
342
+
343
+ // Revoke entitlement
344
+ registry.addRoute({
345
+ method: 'delete',
346
+ path: `${apiPrefix}/:email/:entitlement`,
347
+ pluginId: 'entitlements',
348
+ handler: async (req: Request, res: Response) => {
349
+ try {
350
+ const email = decodeURIComponent(req.params.email);
351
+ const entitlement = decodeURIComponent(req.params.entitlement);
352
+
353
+ await revokeEntitlement(email, entitlement);
354
+ log('Entitlement revoked', { email, entitlement });
355
+
356
+ res.status(204).send();
357
+ } catch (error) {
358
+ console.error('[EntitlementsPlugin] Revoke entitlement error:', error);
359
+ res.status(500).json({ error: 'Failed to revoke entitlement' });
360
+ }
361
+ },
362
+ });
363
+ }
364
+ }
365
+
366
+ // Register UI menu item
367
+ registry.addMenuItem({
368
+ pluginId: 'entitlements',
369
+ id: 'entitlements:sidebar',
370
+ label: 'Entitlements',
371
+ icon: 'local_offer',
372
+ route: '/entitlements',
373
+ order: 35, // After Users (30)
374
+ });
375
+
376
+ log('Entitlements plugin started');
377
+ },
378
+
379
+ async onStop(): Promise<void> {
380
+ log('Stopping entitlements plugin');
381
+
382
+ // Shutdown sources
383
+ if (primarySource) {
384
+ await primarySource.shutdown();
385
+ }
386
+ for (const source of additionalSources) {
387
+ await source.shutdown();
388
+ }
389
+
390
+ primarySource = null;
391
+ additionalSources = [];
392
+ pluginConfig = null;
393
+ cacheInstance = null;
394
+
395
+ log('Entitlements plugin stopped');
396
+ },
397
+ };
398
+ }
399
+
400
+ // ========================================
401
+ // Helper Functions
402
+ // ========================================
403
+
404
+ /**
405
+ * Get the primary entitlement source
406
+ */
407
+ export function getEntitlementSource(): EntitlementSource | null {
408
+ return primarySource;
409
+ }
410
+
411
+ /**
412
+ * Check if the primary source is readonly
413
+ */
414
+ export function isSourceReadonly(): boolean {
415
+ return primarySource?.readonly ?? true;
416
+ }
417
+
418
+ /**
419
+ * Get entitlements for an email (cache-first)
420
+ */
421
+ export async function getEntitlements(email: string): Promise<EntitlementResult> {
422
+ if (!primarySource) {
423
+ throw new Error('Entitlements plugin not initialized');
424
+ }
425
+
426
+ const normalizedEmail = email.toLowerCase();
427
+ const cacheKey = `entitlements:user:${normalizedEmail}`;
428
+
429
+ // Try cache first
430
+ if (cacheEnabled && cacheInstance) {
431
+ try {
432
+ const cached = await cacheInstance.get<CachedEntitlements>(cacheKey);
433
+ if (cached && new Date(cached.expiresAt) > new Date()) {
434
+ // Call onFetch callback
435
+ if (pluginConfig?.callbacks?.onFetch) {
436
+ await pluginConfig.callbacks.onFetch(normalizedEmail, cached.entitlements, 'cache');
437
+ }
438
+
439
+ return {
440
+ identifier: normalizedEmail,
441
+ entitlements: cached.entitlements,
442
+ source: 'cache',
443
+ cachedAt: cached.cachedAt,
444
+ expiresAt: cached.expiresAt,
445
+ bySource: cached.bySource,
446
+ };
447
+ }
448
+ } catch (error) {
449
+ console.error('[EntitlementsPlugin] Cache get error:', error);
450
+ }
451
+ }
452
+
453
+ // Fetch from sources
454
+ return fetchFromSources(normalizedEmail);
455
+ }
456
+
457
+ /**
458
+ * Fetch entitlements from all sources and cache the result
459
+ */
460
+ async function fetchFromSources(email: string): Promise<EntitlementResult> {
461
+ if (!primarySource) {
462
+ throw new Error('Entitlements plugin not initialized');
463
+ }
464
+
465
+ const bySource: Record<string, string[]> = {};
466
+
467
+ // Fetch from primary source
468
+ const primaryEntitlements = await primarySource.getEntitlements(email);
469
+ bySource[primarySource.name] = primaryEntitlements;
470
+
471
+ // Fetch from additional sources in parallel
472
+ const additionalResults = await Promise.allSettled(
473
+ additionalSources.map(async (source) => {
474
+ const ents = await source.getEntitlements(email);
475
+ return { name: source.name, entitlements: ents };
476
+ })
477
+ );
478
+
479
+ for (const result of additionalResults) {
480
+ if (result.status === 'fulfilled') {
481
+ bySource[result.value.name] = result.value.entitlements;
482
+ } else {
483
+ console.error('[EntitlementsPlugin] Source fetch failed:', result.reason);
484
+ }
485
+ }
486
+
487
+ // Merge entitlements (union of all sources, deduplicated)
488
+ const allEntitlements = [...new Set(Object.values(bySource).flat())].sort();
489
+
490
+ const now = new Date();
491
+ const expiresAt = new Date(now.getTime() + cacheTtl * 1000);
492
+
493
+ const result: EntitlementResult = {
494
+ identifier: email,
495
+ entitlements: allEntitlements,
496
+ source: primarySource.name,
497
+ cachedAt: now.toISOString(),
498
+ expiresAt: expiresAt.toISOString(),
499
+ bySource,
500
+ };
501
+
502
+ // Cache the result
503
+ if (cacheEnabled && cacheInstance) {
504
+ try {
505
+ const cached: CachedEntitlements = {
506
+ email,
507
+ entitlements: allEntitlements,
508
+ bySource,
509
+ cachedAt: result.cachedAt!,
510
+ expiresAt: result.expiresAt!,
511
+ version: cacheVersion,
512
+ };
513
+ await cacheInstance.set(`entitlements:user:${email}`, cached, cacheTtl);
514
+ } catch (error) {
515
+ console.error('[EntitlementsPlugin] Cache set error:', error);
516
+ }
517
+ }
518
+
519
+ // Call onFetch callback
520
+ if (pluginConfig?.callbacks?.onFetch) {
521
+ await pluginConfig.callbacks.onFetch(email, allEntitlements, primarySource.name);
522
+ }
523
+
524
+ return result;
525
+ }
526
+
527
+ /**
528
+ * Refresh entitlements (bypass cache)
529
+ */
530
+ export async function refreshEntitlements(email: string): Promise<EntitlementResult> {
531
+ await invalidateEntitlementCache(email);
532
+ return fetchFromSources(email.toLowerCase());
533
+ }
534
+
535
+ /**
536
+ * Check if user has a specific entitlement
537
+ */
538
+ export async function hasEntitlement(email: string, entitlement: string): Promise<boolean> {
539
+ const result = await getEntitlements(email);
540
+ return result.entitlements.includes(entitlement);
541
+ }
542
+
543
+ /**
544
+ * Check if user has any of the specified entitlements
545
+ */
546
+ export async function hasAnyEntitlement(email: string, entitlements: string[]): Promise<boolean> {
547
+ const result = await getEntitlements(email);
548
+ return entitlements.some((e) => result.entitlements.includes(e));
549
+ }
550
+
551
+ /**
552
+ * Check if user has all of the specified entitlements
553
+ */
554
+ export async function hasAllEntitlements(email: string, entitlements: string[]): Promise<boolean> {
555
+ const result = await getEntitlements(email);
556
+ return entitlements.every((e) => result.entitlements.includes(e));
557
+ }
558
+
559
+ /**
560
+ * Grant an entitlement to a user
561
+ * @throws Error if source is read-only
562
+ */
563
+ export async function grantEntitlement(
564
+ email: string,
565
+ entitlement: string,
566
+ grantedBy?: string
567
+ ): Promise<void> {
568
+ if (!primarySource) {
569
+ throw new Error('Entitlements plugin not initialized');
570
+ }
571
+
572
+ if (primarySource.readonly || !primarySource.addEntitlement) {
573
+ throw new Error('Primary entitlement source is read-only');
574
+ }
575
+
576
+ await primarySource.addEntitlement(email, entitlement, grantedBy);
577
+
578
+ // Invalidate cache
579
+ await invalidateEntitlementCache(email);
580
+
581
+ // Call onGrant callback
582
+ if (pluginConfig?.callbacks?.onGrant) {
583
+ await pluginConfig.callbacks.onGrant(email, entitlement, grantedBy);
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Revoke an entitlement from a user
589
+ * @throws Error if source is read-only
590
+ */
591
+ export async function revokeEntitlement(email: string, entitlement: string): Promise<void> {
592
+ if (!primarySource) {
593
+ throw new Error('Entitlements plugin not initialized');
594
+ }
595
+
596
+ if (primarySource.readonly || !primarySource.removeEntitlement) {
597
+ throw new Error('Primary entitlement source is read-only');
598
+ }
599
+
600
+ await primarySource.removeEntitlement(email, entitlement);
601
+
602
+ // Invalidate cache
603
+ await invalidateEntitlementCache(email);
604
+
605
+ // Call onRevoke callback
606
+ if (pluginConfig?.callbacks?.onRevoke) {
607
+ await pluginConfig.callbacks.onRevoke(email, entitlement);
608
+ }
609
+ }
610
+
611
+ /**
612
+ * Set all entitlements for a user (replaces existing)
613
+ * Used by sync services to bulk-update user entitlements from external sources
614
+ * @throws Error if source is read-only or doesn't support setEntitlements
615
+ */
616
+ export async function setEntitlements(email: string, entitlements: string[]): Promise<void> {
617
+ if (!primarySource) {
618
+ throw new Error('Entitlements plugin not initialized');
619
+ }
620
+
621
+ if (primarySource.readonly) {
622
+ throw new Error('Primary entitlement source is read-only');
623
+ }
624
+
625
+ if (!primarySource.setEntitlements) {
626
+ throw new Error('Primary entitlement source does not support setEntitlements');
627
+ }
628
+
629
+ await primarySource.setEntitlements(email, entitlements);
630
+
631
+ // Invalidate cache
632
+ await invalidateEntitlementCache(email);
633
+ }
634
+
635
+ /**
636
+ * Get all available entitlement definitions
637
+ */
638
+ export async function getAvailableEntitlements(): Promise<EntitlementDefinition[]> {
639
+ if (!primarySource) {
640
+ throw new Error('Entitlements plugin not initialized');
641
+ }
642
+
643
+ const allDefinitions: EntitlementDefinition[] = [];
644
+
645
+ // Get from primary source
646
+ if (primarySource.getAllAvailable) {
647
+ const defs = await primarySource.getAllAvailable();
648
+ allDefinitions.push(...defs);
649
+ }
650
+
651
+ // Get from additional sources
652
+ for (const source of additionalSources) {
653
+ if (source.getAllAvailable) {
654
+ try {
655
+ const defs = await source.getAllAvailable();
656
+ // Add source prefix to avoid collisions
657
+ allDefinitions.push(
658
+ ...defs.map((d) => ({
659
+ ...d,
660
+ id: `${source.name}:${d.id}`,
661
+ category: d.category || source.name,
662
+ }))
663
+ );
664
+ } catch (error) {
665
+ console.error(`[EntitlementsPlugin] Failed to get available from ${source.name}:`, error);
666
+ }
667
+ }
668
+ }
669
+
670
+ return allDefinitions;
671
+ }
672
+
673
+ /**
674
+ * Get entitlement statistics from the primary source
675
+ */
676
+ export async function getEntitlementStats(): Promise<EntitlementStats> {
677
+ if (!primarySource) {
678
+ throw new Error('Entitlements plugin not initialized');
679
+ }
680
+
681
+ // If source has getStats method, use it
682
+ if (primarySource.getStats) {
683
+ return primarySource.getStats();
684
+ }
685
+
686
+ // Fallback: return zeros if source doesn't support stats
687
+ return {
688
+ usersWithEntitlements: 0,
689
+ totalEntitlements: 0,
690
+ };
691
+ }
692
+
693
+ /**
694
+ * Invalidate cache for an email
695
+ */
696
+ export async function invalidateEntitlementCache(email: string): Promise<void> {
697
+ if (!cacheEnabled || !cacheInstance) return;
698
+
699
+ const normalizedEmail = email.toLowerCase();
700
+ try {
701
+ await cacheInstance.delete(`entitlements:user:${normalizedEmail}`);
702
+ } catch (error) {
703
+ console.error('[EntitlementsPlugin] Cache delete error:', error);
704
+ }
705
+ }
706
+
707
+ /**
708
+ * Store a mapping from external ID to email (for webhook invalidation)
709
+ */
710
+ export async function storeExternalIdMapping(
711
+ source: string,
712
+ externalId: string,
713
+ email: string
714
+ ): Promise<void> {
715
+ if (!cacheEnabled || !cacheInstance) return;
716
+
717
+ try {
718
+ await cacheInstance.set(
719
+ `entitlements:mapping:${source}:${externalId}`,
720
+ email.toLowerCase(),
721
+ cacheMappingTtl
722
+ );
723
+ } catch (error) {
724
+ console.error('[EntitlementsPlugin] Store mapping error:', error);
725
+ }
726
+ }
727
+
728
+ /**
729
+ * Invalidate cache by external ID (for webhook handling)
730
+ */
731
+ export async function invalidateByExternalId(source: string, externalId: string): Promise<void> {
732
+ if (!cacheEnabled || !cacheInstance) return;
733
+
734
+ try {
735
+ const email = await cacheInstance.get<string>(`entitlements:mapping:${source}:${externalId}`);
736
+ if (email) {
737
+ await invalidateEntitlementCache(email);
738
+ }
739
+ } catch (error) {
740
+ console.error('[EntitlementsPlugin] Invalidate by external ID error:', error);
741
+ }
742
+ }
743
+
744
+ // ========================================
745
+ // Middleware Helpers
746
+ // ========================================
747
+
748
+ /**
749
+ * Express middleware to require a specific entitlement
750
+ */
751
+ export function requireEntitlement(entitlement: string): RequestHandler {
752
+ return async (req, res, next) => {
753
+ const authReq = req as AuthenticatedRequest;
754
+ const email = authReq.auth?.user?.email;
755
+
756
+ if (!email) {
757
+ return res.status(401).json({ error: 'Authentication required' });
758
+ }
759
+
760
+ const has = await hasEntitlement(email, entitlement);
761
+ if (!has) {
762
+ return res.status(403).json({
763
+ error: 'Insufficient entitlements',
764
+ required: entitlement,
765
+ });
766
+ }
767
+
768
+ next();
769
+ };
770
+ }
771
+
772
+ /**
773
+ * Express middleware to require any of the specified entitlements
774
+ */
775
+ export function requireAnyEntitlement(entitlements: string[]): RequestHandler {
776
+ return async (req, res, next) => {
777
+ const authReq = req as AuthenticatedRequest;
778
+ const email = authReq.auth?.user?.email;
779
+
780
+ if (!email) {
781
+ return res.status(401).json({ error: 'Authentication required' });
782
+ }
783
+
784
+ const has = await hasAnyEntitlement(email, entitlements);
785
+ if (!has) {
786
+ return res.status(403).json({
787
+ error: 'Insufficient entitlements',
788
+ required: entitlements,
789
+ mode: 'any',
790
+ });
791
+ }
792
+
793
+ next();
794
+ };
795
+ }
796
+
797
+ /**
798
+ * Express middleware to require all of the specified entitlements
799
+ */
800
+ export function requireAllEntitlements(entitlements: string[]): RequestHandler {
801
+ return async (req, res, next) => {
802
+ const authReq = req as AuthenticatedRequest;
803
+ const email = authReq.auth?.user?.email;
804
+
805
+ if (!email) {
806
+ return res.status(401).json({ error: 'Authentication required' });
807
+ }
808
+
809
+ const has = await hasAllEntitlements(email, entitlements);
810
+ if (!has) {
811
+ return res.status(403).json({
812
+ error: 'Insufficient entitlements',
813
+ required: entitlements,
814
+ mode: 'all',
815
+ });
816
+ }
817
+
818
+ next();
819
+ };
820
+ }