@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.
- package/README.md +318 -0
- package/dist/core/control-panel.d.ts +7 -2
- package/dist/core/control-panel.d.ts.map +1 -1
- package/dist/core/control-panel.js +99 -60
- package/dist/core/control-panel.js.map +1 -1
- package/dist/core/gateway.d.ts +159 -79
- package/dist/core/gateway.d.ts.map +1 -1
- package/dist/core/gateway.js +683 -315
- package/dist/core/gateway.js.map +1 -1
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/plugin-registry.d.ts +271 -0
- package/dist/core/plugin-registry.d.ts.map +1 -0
- package/dist/core/plugin-registry.js +326 -0
- package/dist/core/plugin-registry.js.map +1 -0
- package/dist/core/types.d.ts +16 -33
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -7
- package/dist/index.js.map +1 -1
- package/dist/plugins/auth/adapters/auth0-adapter.d.ts +14 -0
- package/dist/plugins/auth/adapters/auth0-adapter.d.ts.map +1 -0
- package/dist/plugins/auth/adapters/auth0-adapter.js +179 -0
- package/dist/plugins/auth/adapters/auth0-adapter.js.map +1 -0
- package/dist/plugins/auth/adapters/basic-adapter.d.ts +13 -0
- package/dist/plugins/auth/adapters/basic-adapter.d.ts.map +1 -0
- package/dist/plugins/auth/adapters/basic-adapter.js +51 -0
- package/dist/plugins/auth/adapters/basic-adapter.js.map +1 -0
- package/dist/plugins/auth/adapters/index.d.ts +9 -0
- package/dist/plugins/auth/adapters/index.d.ts.map +1 -0
- package/dist/plugins/auth/adapters/index.js +9 -0
- package/dist/plugins/auth/adapters/index.js.map +1 -0
- package/dist/plugins/auth/adapters/supabase-adapter.d.ts +13 -0
- package/dist/plugins/auth/adapters/supabase-adapter.d.ts.map +1 -0
- package/dist/plugins/auth/adapters/supabase-adapter.js +109 -0
- package/dist/plugins/auth/adapters/supabase-adapter.js.map +1 -0
- package/dist/plugins/auth/auth-plugin.d.ts +40 -0
- package/dist/plugins/auth/auth-plugin.d.ts.map +1 -0
- package/dist/plugins/auth/auth-plugin.js +255 -0
- package/dist/plugins/auth/auth-plugin.js.map +1 -0
- package/dist/plugins/auth/auth-plugin.test.d.ts +9 -0
- package/dist/plugins/auth/auth-plugin.test.d.ts.map +1 -0
- package/dist/plugins/auth/auth-plugin.test.js +147 -0
- package/dist/plugins/auth/auth-plugin.test.js.map +1 -0
- package/dist/plugins/auth/index.d.ts +12 -0
- package/dist/plugins/auth/index.d.ts.map +1 -0
- package/dist/plugins/auth/index.js +13 -0
- package/dist/plugins/auth/index.js.map +1 -0
- package/dist/plugins/auth/types.d.ts +148 -0
- package/dist/plugins/auth/types.d.ts.map +1 -0
- package/dist/plugins/auth/types.js +14 -0
- package/dist/plugins/auth/types.js.map +1 -0
- package/dist/plugins/bans/bans-plugin.d.ts +59 -0
- package/dist/plugins/bans/bans-plugin.d.ts.map +1 -0
- package/dist/plugins/bans/bans-plugin.js +428 -0
- package/dist/plugins/bans/bans-plugin.js.map +1 -0
- package/dist/plugins/bans/index.d.ts +9 -0
- package/dist/plugins/bans/index.d.ts.map +1 -0
- package/dist/plugins/bans/index.js +10 -0
- package/dist/plugins/bans/index.js.map +1 -0
- package/dist/plugins/bans/stores/index.d.ts +7 -0
- package/dist/plugins/bans/stores/index.d.ts.map +1 -0
- package/dist/plugins/bans/stores/index.js +7 -0
- package/dist/plugins/bans/stores/index.js.map +1 -0
- package/dist/plugins/bans/stores/postgres-store.d.ts +29 -0
- package/dist/plugins/bans/stores/postgres-store.d.ts.map +1 -0
- package/dist/plugins/bans/stores/postgres-store.js +132 -0
- package/dist/plugins/bans/stores/postgres-store.js.map +1 -0
- package/dist/plugins/bans/types.d.ts +128 -0
- package/dist/plugins/bans/types.d.ts.map +1 -0
- package/dist/plugins/bans/types.js +11 -0
- package/dist/plugins/bans/types.js.map +1 -0
- package/dist/plugins/cache-plugin.d.ts +14 -3
- package/dist/plugins/cache-plugin.d.ts.map +1 -1
- package/dist/plugins/cache-plugin.js +27 -7
- package/dist/plugins/cache-plugin.js.map +1 -1
- package/dist/plugins/cache-plugin.test.js +96 -32
- package/dist/plugins/cache-plugin.test.js.map +1 -1
- package/dist/plugins/config-plugin.d.ts +3 -2
- package/dist/plugins/config-plugin.d.ts.map +1 -1
- package/dist/plugins/config-plugin.js +17 -10
- package/dist/plugins/config-plugin.js.map +1 -1
- package/dist/plugins/diagnostics-plugin.d.ts +2 -2
- package/dist/plugins/diagnostics-plugin.d.ts.map +1 -1
- package/dist/plugins/diagnostics-plugin.js +17 -10
- package/dist/plugins/diagnostics-plugin.js.map +1 -1
- package/dist/plugins/entitlements/entitlements-plugin.d.ts +95 -0
- package/dist/plugins/entitlements/entitlements-plugin.d.ts.map +1 -0
- package/dist/plugins/entitlements/entitlements-plugin.js +707 -0
- package/dist/plugins/entitlements/entitlements-plugin.js.map +1 -0
- package/dist/plugins/entitlements/index.d.ts +12 -0
- package/dist/plugins/entitlements/index.d.ts.map +1 -0
- package/dist/plugins/entitlements/index.js +16 -0
- package/dist/plugins/entitlements/index.js.map +1 -0
- package/dist/plugins/entitlements/sources/index.d.ts +9 -0
- package/dist/plugins/entitlements/sources/index.d.ts.map +1 -0
- package/dist/plugins/entitlements/sources/index.js +9 -0
- package/dist/plugins/entitlements/sources/index.js.map +1 -0
- package/dist/plugins/entitlements/sources/postgres-source.d.ts +29 -0
- package/dist/plugins/entitlements/sources/postgres-source.d.ts.map +1 -0
- package/dist/plugins/entitlements/sources/postgres-source.js +169 -0
- package/dist/plugins/entitlements/sources/postgres-source.js.map +1 -0
- package/dist/plugins/entitlements/types.d.ts +232 -0
- package/dist/plugins/entitlements/types.d.ts.map +1 -0
- package/dist/plugins/entitlements/types.js +11 -0
- package/dist/plugins/entitlements/types.js.map +1 -0
- package/dist/plugins/frontend-app-plugin.d.ts +9 -3
- package/dist/plugins/frontend-app-plugin.d.ts.map +1 -1
- package/dist/plugins/frontend-app-plugin.js +14 -9
- package/dist/plugins/frontend-app-plugin.js.map +1 -1
- package/dist/plugins/health-plugin.d.ts +5 -2
- package/dist/plugins/health-plugin.d.ts.map +1 -1
- package/dist/plugins/health-plugin.js +20 -5
- package/dist/plugins/health-plugin.js.map +1 -1
- package/dist/plugins/index.d.ts +8 -2
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +8 -2
- package/dist/plugins/index.js.map +1 -1
- package/dist/plugins/logs-plugin.d.ts +3 -2
- package/dist/plugins/logs-plugin.d.ts.map +1 -1
- package/dist/plugins/logs-plugin.js +21 -12
- package/dist/plugins/logs-plugin.js.map +1 -1
- package/dist/plugins/postgres-plugin.d.ts +3 -3
- package/dist/plugins/postgres-plugin.d.ts.map +1 -1
- package/dist/plugins/postgres-plugin.js +9 -7
- package/dist/plugins/postgres-plugin.js.map +1 -1
- package/dist/plugins/postgres-plugin.test.js +47 -29
- package/dist/plugins/postgres-plugin.test.js.map +1 -1
- package/dist/plugins/users/index.d.ts +12 -0
- package/dist/plugins/users/index.d.ts.map +1 -0
- package/dist/plugins/users/index.js +13 -0
- package/dist/plugins/users/index.js.map +1 -0
- package/dist/plugins/users/stores/index.d.ts +7 -0
- package/dist/plugins/users/stores/index.d.ts.map +1 -0
- package/dist/plugins/users/stores/index.js +7 -0
- package/dist/plugins/users/stores/index.js.map +1 -0
- package/dist/plugins/users/stores/postgres-store.d.ts +28 -0
- package/dist/plugins/users/stores/postgres-store.d.ts.map +1 -0
- package/dist/plugins/users/stores/postgres-store.js +157 -0
- package/dist/plugins/users/stores/postgres-store.js.map +1 -0
- package/dist/plugins/users/types.d.ts +189 -0
- package/dist/plugins/users/types.d.ts.map +1 -0
- package/dist/plugins/users/types.js +12 -0
- package/dist/plugins/users/types.js.map +1 -0
- package/dist/plugins/users/users-plugin.d.ts +39 -0
- package/dist/plugins/users/users-plugin.d.ts.map +1 -0
- package/dist/plugins/users/users-plugin.js +242 -0
- package/dist/plugins/users/users-plugin.js.map +1 -0
- package/dist-ui/assets/index-Bsp2ntcw.js +465 -0
- package/dist-ui/assets/index-Bsp2ntcw.js.map +1 -0
- package/dist-ui/index.html +1 -1
- package/dist-ui-lib/api/controlPanelApi.d.ts +232 -0
- package/dist-ui-lib/components/ControlPanelApp.d.ts +61 -0
- package/dist-ui-lib/components/index.d.ts +18 -0
- package/dist-ui-lib/config/AppConfig.d.ts +7 -0
- package/dist-ui-lib/dashboard/DashboardWidgetRegistry.d.ts +62 -0
- package/dist-ui-lib/dashboard/DashboardWidgetRenderer.d.ts +8 -0
- package/dist-ui-lib/dashboard/PluginWidgetRenderer.d.ts +19 -0
- package/dist-ui-lib/dashboard/WidgetComponentRegistry.d.ts +44 -0
- package/dist-ui-lib/dashboard/builtInWidgets.d.ts +19 -0
- package/dist-ui-lib/dashboard/index.d.ts +13 -0
- package/dist-ui-lib/dashboard/widgets/ServiceHealthWidget.d.ts +12 -0
- package/dist-ui-lib/dashboard/widgets/index.d.ts +6 -0
- package/dist-ui-lib/index.js +6441 -0
- package/dist-ui-lib/index.js.map +1 -0
- package/dist-ui-lib/pages/ConfigPage.d.ts +1 -0
- package/dist-ui-lib/pages/DashboardPage.d.ts +1 -0
- package/dist-ui-lib/pages/DiagnosticsPage.d.ts +1 -0
- package/dist-ui-lib/pages/EntitlementsPage.d.ts +17 -0
- package/dist-ui-lib/pages/LogsPage.d.ts +1 -0
- package/dist-ui-lib/pages/NotFoundPage.d.ts +1 -0
- package/dist-ui-lib/pages/PluginPage.d.ts +15 -0
- package/dist-ui-lib/pages/SystemPage.d.ts +1 -0
- package/dist-ui-lib/pages/UsersPage.d.ts +22 -0
- package/package.json +18 -6
- package/src/core/control-panel.ts +122 -68
- package/src/core/gateway.ts +870 -399
- package/src/core/index.ts +21 -2
- package/src/core/plugin-registry.ts +653 -0
- package/src/core/types.ts +31 -37
- package/src/index.ts +118 -19
- package/src/plugins/auth/adapters/auth0-adapter.ts +214 -0
- package/src/plugins/auth/adapters/basic-adapter.ts +61 -0
- package/src/plugins/auth/adapters/index.ts +9 -0
- package/src/plugins/auth/adapters/supabase-adapter.ts +141 -0
- package/src/plugins/auth/auth-plugin.test.ts +176 -0
- package/src/plugins/auth/auth-plugin.ts +303 -0
- package/src/plugins/auth/index.ts +33 -0
- package/src/plugins/auth/types.ts +165 -0
- package/src/plugins/bans/bans-plugin.ts +485 -0
- package/src/plugins/bans/index.ts +31 -0
- package/src/plugins/bans/stores/index.ts +7 -0
- package/src/plugins/bans/stores/postgres-store.ts +195 -0
- package/src/plugins/bans/types.ts +141 -0
- package/src/plugins/cache-plugin.test.ts +105 -32
- package/src/plugins/cache-plugin.ts +40 -9
- package/src/plugins/config-plugin.ts +23 -12
- package/src/plugins/diagnostics-plugin.ts +22 -12
- package/src/plugins/entitlements/entitlements-plugin.ts +820 -0
- package/src/plugins/entitlements/index.ts +51 -0
- package/src/plugins/entitlements/sources/index.ts +9 -0
- package/src/plugins/entitlements/sources/postgres-source.ts +253 -0
- package/src/plugins/entitlements/types.ts +256 -0
- package/src/plugins/frontend-app-plugin.ts +24 -12
- package/src/plugins/health-plugin.ts +27 -7
- package/src/plugins/index.ts +106 -4
- package/src/plugins/logs-plugin.ts +28 -14
- package/src/plugins/postgres-plugin.test.ts +49 -29
- package/src/plugins/postgres-plugin.ts +11 -9
- package/src/plugins/users/index.ts +35 -0
- package/src/plugins/users/stores/index.ts +7 -0
- package/src/plugins/users/stores/postgres-store.ts +225 -0
- package/src/plugins/users/types.ts +209 -0
- package/src/plugins/users/users-plugin.ts +281 -0
- package/ui/src/App.tsx +185 -31
- package/ui/src/api/controlPanelApi.ts +354 -1
- package/ui/src/components/ControlPanelApp.tsx +209 -0
- package/ui/src/components/index.ts +62 -0
- package/ui/src/dashboard/DashboardWidgetRegistry.tsx +129 -0
- package/ui/src/dashboard/DashboardWidgetRenderer.tsx +34 -0
- package/ui/src/dashboard/PluginWidgetRenderer.tsx +115 -0
- package/ui/src/dashboard/WidgetComponentRegistry.tsx +116 -0
- package/ui/src/dashboard/builtInWidgets.tsx +29 -0
- package/ui/src/dashboard/index.ts +35 -0
- package/ui/src/dashboard/widgets/ServiceHealthWidget.tsx +140 -0
- package/ui/src/dashboard/widgets/index.ts +7 -0
- package/ui/src/pages/DashboardPage.tsx +28 -149
- package/ui/src/pages/EntitlementsPage.tsx +557 -0
- package/ui/src/pages/LogsPage.tsx +174 -8
- package/ui/src/pages/PluginPage.tsx +148 -0
- package/ui/src/pages/SystemPage.tsx +445 -0
- package/ui/src/pages/UsersPage.tsx +837 -0
- package/ui/tsconfig.lib.json +11 -0
- package/ui/vite.lib.config.ts +51 -0
- package/dist-ui/assets/index-CW1BviRn.js +0 -465
- package/dist-ui/assets/index-CW1BviRn.js.map +0 -1
- 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
|
+
}
|