diff --git a/EXIT: b/EXIT: deleted file mode 100644 index 8d246defa..000000000 --- a/EXIT: +++ /dev/null @@ -1,37 +0,0 @@ - -=================================================== -WaddleBot Beta Deployment Script -=================================================== - -[INFO] Tag: beta-1773154482 -[INFO] Method: helm -[INFO] Namespace: waddlebot -[INFO] Kube Context: dal2-beta - -=================================================== -Checking Prerequisites -=================================================== - -[SUCCESS] Docker found: Docker version 28.2.2, build 28.2.2-0ubuntu1 -[SUCCESS] kubectl found: installed -[SUCCESS] Helm found: v4.1.1+g5caf004 -[SUCCESS] WaddleBot project directory verified -[SUCCESS] Kubernetes context: dal2-beta -[INFO] Loaded NPM_TOKEN from ~/code/.gh-token -[SUCCESS] NPM_TOKEN configured - -=================================================== -Skipping Image Build (--skip-build flag set) -=================================================== - - -=================================================== -Deploying to Beta Cluster with Helm -=================================================== - -[INFO] Checking if namespace waddlebot exists... -[SUCCESS] Namespace waddlebot already exists -[INFO] Deploying WaddleBot to beta cluster... -level=WARN msg="upgrade failed" name=waddlebot error="resource Deployment/waddlebot/waddlebot-analytics-core not ready. status: Failed, message: Progress deadline exceeded" -Error: UPGRADE FAILED: resource Deployment/waddlebot/waddlebot-analytics-core not ready. status: Failed, message: Progress deadline exceeded -[ERROR] Helm deployment failed diff --git a/admin/hub_module/backend/package-lock.json b/admin/hub_module/backend/package-lock.json index fbc3425c2..f49838921 100644 --- a/admin/hub_module/backend/package-lock.json +++ b/admin/hub_module/backend/package-lock.json @@ -23,6 +23,7 @@ "multer": "^2.0.2", "nodemailer": "8.0.5", "pg": "^8.13.1", + "redis": "^4.7.0", "socket.io": "^4.8.1", "uuid": "^14.0.0", "xss": "^1.0.15" @@ -825,6 +826,65 @@ "node": ">=20.0.0" } }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, "node_modules/@simplewebauthn/server": { "version": "13.2.3", "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.3.tgz", @@ -1290,6 +1350,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2055,6 +2124,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3027,6 +3105,23 @@ "node": ">= 6" } }, + "node_modules/redis": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.1", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -3617,6 +3712,12 @@ "node": ">=0.4" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/admin/hub_module/backend/package.json b/admin/hub_module/backend/package.json index bdec2df22..b5b50f7d2 100644 --- a/admin/hub_module/backend/package.json +++ b/admin/hub_module/backend/package.json @@ -25,6 +25,7 @@ "multer": "^2.0.2", "nodemailer": "8.0.5", "pg": "^8.13.1", + "redis": "^4.7.0", "socket.io": "^4.8.1", "uuid": "^14.0.0", "xss": "^1.0.15", diff --git a/admin/hub_module/backend/src/config/redis.js b/admin/hub_module/backend/src/config/redis.js new file mode 100644 index 000000000..dc46c249a --- /dev/null +++ b/admin/hub_module/backend/src/config/redis.js @@ -0,0 +1,118 @@ +/** + * Redis Configuration + * A single lazily-connected shared client using node-redis (redis v4). + * + * The hub backend uses Redis only for lightweight pub/sub cache-invalidation + * signalling (e.g. the "feature_flags:reload" channel the Python router + * subscribes to). It is NOT a hard dependency: if REDIS_URL is unset the + * exported client is null, and if Redis is unreachable every operation fails + * soft (logged, never thrown). The app must run fine with Redis down or absent. + */ +import { createClient } from 'redis'; +import { logger } from '../utils/logger.js'; + +// REDIS_URL example (see docker-compose.yml): redis://hub:@infra-redis:6379/0 +const redisUrl = process.env.REDIS_URL || null; + +/** + * Build the shared client. Connection is deferred until first use so that + * importing this module never blocks startup or crashes when Redis is absent. + * Returns null when no REDIS_URL is configured. + */ +function buildClient() { + if (!redisUrl) { + logger.debug('Redis disabled: REDIS_URL is not set'); + return null; + } + + const c = createClient({ + url: redisUrl, + socket: { + connectTimeout: 5000, + // Give up after a handful of attempts so an absent Redis does not + // produce an endless reconnect/error loop in the logs. + reconnectStrategy: (retries) => (retries > 10 ? false : Math.min(retries * 200, 3000)), + }, + }); + + // An 'error' listener is mandatory: without one, node-redis emits on the + // process and an unhandled 'error' would crash the app. + c.on('error', (err) => logger.warn('Redis client error', { error: err.message })); + c.on('ready', () => logger.info('Redis client ready')); + c.on('end', () => logger.debug('Redis connection closed')); + + return c; +} + +// Single shared client instance (or null when Redis is not configured). +const client = buildClient(); + +// De-dupe concurrent connect attempts. +let connecting = null; + +/** + * Return a connected client, or null if Redis is unavailable/unconfigured. + * Never throws. + * @returns {Promise} + */ +export async function getRedisClient() { + if (!client) return null; + if (client.isOpen) return client; + + if (!connecting) { + connecting = client.connect().catch((err) => { + logger.warn('Redis connection failed', { error: err.message }); + return null; + }).finally(() => { + connecting = null; + }); + } + await connecting; + return client.isOpen ? client : null; +} + +/** + * Fire-and-forget publish. Returns true if the message was handed to Redis, + * false if Redis is unavailable. Never throws. + * @param {string} channel + * @param {string} message + * @returns {Promise} + */ +export async function publish(channel, message) { + const c = await getRedisClient(); + if (!c) return false; + await c.publish(channel, message); + return true; +} + +/** + * Check Redis connectivity. Never throws. + * @returns {Promise} + */ +export async function checkConnection() { + try { + const c = await getRedisClient(); + if (!c) return false; + const pong = await c.ping(); + return pong === 'PONG'; + } catch { + return false; + } +} + +/** + * Close the shared client (best-effort, for graceful shutdown). + */ +export async function closeRedis() { + if (client && client.isOpen) { + try { + await client.quit(); + logger.info('Redis client closed'); + } catch (err) { + logger.warn('Error closing Redis client', { error: err.message }); + } + } +} + +export { client }; +export default { getRedisClient, publish, checkConnection, closeRedis, client }; diff --git a/admin/hub_module/backend/src/controllers/featureFlagAdminController.js b/admin/hub_module/backend/src/controllers/featureFlagAdminController.js new file mode 100644 index 000000000..1d010ffe6 --- /dev/null +++ b/admin/hub_module/backend/src/controllers/featureFlagAdminController.js @@ -0,0 +1,317 @@ +/** + * Feature Flag Admin Controller - superadmin surface. + * + * Superadmins manage GLOBAL flags (community_id NULL) and view the full audit + * trail. All mutations force community_id NULL, run in a transaction with the + * append-only feature_flag_audit INSERT, then publish a reload message. + * + * Kept in a dedicated controller (mounted from routes/superadmin.js) to match + * the existing pattern where superadmin.js aggregates several focused + * controllers (userManagementController, analyticsController, etc.). + */ +import { query, transaction } from '../config/database.js'; +import { errors } from '../middleware/errorHandler.js'; +import { logger } from '../utils/logger.js'; +import { + normalizeFlagKey, + normalizePlatform, + normalizeRolloutPct, + actorFromRequest, + insertFlagAudit, + publishReload, +} from '../services/featureFlagService.js'; + +const SCHEMA_ERROR_CODES = ['42P01', '42703', '42883']; +function isSchemaError(err) { + return SCHEMA_ERROR_CODES.includes(err?.code); +} + +function serializeFlag(row) { + return { + id: row.id, + flag_key: row.flag_key, + community_id: row.community_id, + platform: row.platform, + is_enabled: row.is_enabled, + rollout_pct: row.rollout_pct, + description: row.description, + updated_by: row.updated_by, + created_at: row.created_at?.toISOString?.() || row.created_at, + updated_at: row.updated_at?.toISOString?.() || row.updated_at, + }; +} + +/** + * GET /api/v1/superadmin/feature-flags + * List all GLOBAL flags with a count of community overrides per flag_key. + */ +export async function listGlobalFlags(req, res, next) { + try { + const search = req.query.search || ''; + const params = []; + let where = 'WHERE g.community_id IS NULL'; + if (search) { + params.push(`%${search}%`); + where += ` AND (g.flag_key ILIKE $${params.length} OR g.description ILIKE $${params.length})`; + } + + const result = await query( + `SELECT g.*, + (SELECT COUNT(*) FROM feature_flags o + WHERE o.flag_key = g.flag_key AND o.community_id IS NOT NULL) AS override_count + FROM feature_flags g + ${where} + ORDER BY g.flag_key, g.platform NULLS FIRST`, + params + ); + + const flags = result.rows.map((row) => ({ + ...serializeFlag(row), + override_count: parseInt(row.override_count || 0, 10), + })); + + res.json({ success: true, flags }); + } catch (err) { + if (isSchemaError(err)) { + return res.json({ success: true, flags: [] }); + } + next(err); + } +} + +/** + * POST /api/v1/superadmin/feature-flags + * Create a GLOBAL flag (community_id NULL). + */ +export async function createGlobalFlag(req, res, next) { + try { + const keyCheck = normalizeFlagKey(req.body.flag_key); + if (keyCheck.error) return next(errors.badRequest(keyCheck.error)); + const platCheck = normalizePlatform(req.body.platform); + if (platCheck.error) return next(errors.badRequest(platCheck.error)); + const pctCheck = normalizeRolloutPct(req.body.rollout_pct); + if (pctCheck.error) return next(errors.badRequest(pctCheck.error)); + + const { flagKey } = keyCheck; + const { platform } = platCheck; + const { rolloutPct } = pctCheck; + const isEnabled = req.body.is_enabled === undefined ? false : Boolean(req.body.is_enabled); + const description = typeof req.body.description === 'string' ? req.body.description : null; + const actor = actorFromRequest(req); + + const existing = await query( + `SELECT id FROM feature_flags + WHERE flag_key = $1 AND community_id IS NULL + AND COALESCE(platform, '*') = COALESCE($2, '*')`, + [flagKey, platform] + ); + if (existing.rows.length > 0) { + return next(errors.conflict('A global flag with this key/platform already exists')); + } + + const created = await transaction(async (client) => { + const result = await client.query( + `INSERT INTO feature_flags + (flag_key, community_id, platform, is_enabled, rollout_pct, description, updated_by) + VALUES ($1, NULL, $2, $3, $4, $5, $6) + RETURNING *`, + [flagKey, platform, isEnabled, rolloutPct, description, actor] + ); + const row = result.rows[0]; + await insertFlagAudit(client, { + flagKey, + communityId: null, + platform, + action: 'created', + oldValue: null, + newValue: serializeFlag(row), + changedBy: actor, + }); + return row; + }); + + await publishReload(flagKey, null); + logger.audit('Global feature flag created', { adminId: req.user?.id, flagKey, platform }); + + res.status(201).json({ success: true, flag: serializeFlag(created) }); + } catch (err) { + next(err); + } +} + +/** + * PUT /api/v1/superadmin/feature-flags/:id + * Update a GLOBAL flag. The target row must be global (community_id NULL). + */ +export async function updateGlobalFlag(req, res, next) { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) return next(errors.badRequest('Invalid flag id')); + + const existing = await query('SELECT * FROM feature_flags WHERE id = $1', [id]); + if (existing.rows.length === 0) { + return next(errors.notFound('Feature flag not found')); + } + const current = existing.rows[0]; + if (current.community_id !== null) { + return next(errors.badRequest('This endpoint only manages global flags; use the community endpoint for overrides')); + } + + const updates = []; + const params = []; + let idx = 1; + + if (req.body.is_enabled !== undefined) { + updates.push(`is_enabled = $${idx++}`); + params.push(Boolean(req.body.is_enabled)); + } + if (req.body.rollout_pct !== undefined) { + const pctCheck = normalizeRolloutPct(req.body.rollout_pct); + if (pctCheck.error) return next(errors.badRequest(pctCheck.error)); + updates.push(`rollout_pct = $${idx++}`); + params.push(pctCheck.rolloutPct); + } + if (req.body.description !== undefined) { + updates.push(`description = $${idx++}`); + params.push(typeof req.body.description === 'string' ? req.body.description : null); + } + if (updates.length === 0) { + return next(errors.badRequest('No updates provided')); + } + + const actor = actorFromRequest(req); + updates.push(`updated_by = $${idx++}`); + params.push(actor); + updates.push('updated_at = NOW()'); + params.push(id); + + const updated = await transaction(async (client) => { + const result = await client.query( + `UPDATE feature_flags SET ${updates.join(', ')} WHERE id = $${idx} RETURNING *`, + params + ); + const row = result.rows[0]; + await insertFlagAudit(client, { + flagKey: row.flag_key, + communityId: null, + platform: row.platform, + action: 'updated', + oldValue: serializeFlag(current), + newValue: serializeFlag(row), + changedBy: actor, + }); + return row; + }); + + await publishReload(updated.flag_key, null); + logger.audit('Global feature flag updated', { adminId: req.user?.id, flagKey: updated.flag_key }); + + res.json({ success: true, flag: serializeFlag(updated) }); + } catch (err) { + next(err); + } +} + +/** + * DELETE /api/v1/superadmin/feature-flags/:id + * Delete a GLOBAL flag. The target row must be global (community_id NULL). + */ +export async function deleteGlobalFlag(req, res, next) { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) return next(errors.badRequest('Invalid flag id')); + + const existing = await query('SELECT * FROM feature_flags WHERE id = $1', [id]); + if (existing.rows.length === 0) { + return next(errors.notFound('Feature flag not found')); + } + const current = existing.rows[0]; + if (current.community_id !== null) { + return next(errors.badRequest('This endpoint only manages global flags')); + } + + const actor = actorFromRequest(req); + await transaction(async (client) => { + await client.query('DELETE FROM feature_flags WHERE id = $1', [id]); + await insertFlagAudit(client, { + flagKey: current.flag_key, + communityId: null, + platform: current.platform, + action: 'deleted', + oldValue: serializeFlag(current), + newValue: null, + changedBy: actor, + }); + }); + + await publishReload(current.flag_key, null); + logger.audit('Global feature flag deleted', { adminId: req.user?.id, flagKey: current.flag_key }); + + res.json({ success: true }); + } catch (err) { + next(err); + } +} + +/** + * GET /api/v1/superadmin/feature-flags/audit + * Paginated audit trail, optionally filtered by flag_key. + */ +export async function listAudit(req, res, next) { + try { + const page = Math.max(1, parseInt(req.query.page || '1', 10)); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit || '25', 10))); + const offset = (page - 1) * limit; + const flagKey = req.query.flag_key || ''; + + const params = []; + let where = 'WHERE 1=1'; + if (flagKey) { + params.push(flagKey); + where += ` AND flag_key = $${params.length}`; + } + + const countResult = await query(`SELECT COUNT(*) AS count FROM feature_flag_audit ${where}`, params); + const total = parseInt(countResult.rows[0]?.count || 0, 10); + + const result = await query( + `SELECT id, flag_key, community_id, platform, action, old_value, new_value, changed_by, changed_at + FROM feature_flag_audit + ${where} + ORDER BY changed_at DESC, id DESC + LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, + [...params, limit, offset] + ); + + const entries = result.rows.map((row) => ({ + id: row.id, + flag_key: row.flag_key, + community_id: row.community_id, + platform: row.platform, + action: row.action, + old_value: row.old_value, + new_value: row.new_value, + changed_by: row.changed_by, + changed_at: row.changed_at?.toISOString?.() || row.changed_at, + })); + + res.json({ + success: true, + entries, + pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, + }); + } catch (err) { + if (isSchemaError(err)) { + return res.json({ success: true, entries: [], pagination: { page: 1, limit: 25, total: 0, totalPages: 0 } }); + } + next(err); + } +} + +export default { + listGlobalFlags, + createGlobalFlag, + updateGlobalFlag, + deleteGlobalFlag, + listAudit, +}; diff --git a/admin/hub_module/backend/src/controllers/featureFlagController.js b/admin/hub_module/backend/src/controllers/featureFlagController.js new file mode 100644 index 000000000..75219845b --- /dev/null +++ b/admin/hub_module/backend/src/controllers/featureFlagController.js @@ -0,0 +1,275 @@ +/** + * Feature Flag Controller - community admin surface. + * + * Community admins manage feature-flag OVERRIDES for their OWN community only. + * A community admin must NEVER be able to create/update/delete a global row + * (community_id NULL) or another community's row: community_id is always forced + * from the :communityId URL param and every mutation re-checks ownership. + * + * Every mutation is wrapped in a transaction together with the append-only + * feature_flag_audit INSERT, then publishes a cache-invalidation message. + */ +import { query, transaction } from '../config/database.js'; +import { errors } from '../middleware/errorHandler.js'; +import { logger } from '../utils/logger.js'; +import { + normalizeFlagKey, + normalizePlatform, + normalizeRolloutPct, + actorFromRequest, + resolveEffectiveFlags, + insertFlagAudit, + publishReload, +} from '../services/featureFlagService.js'; + +/** + * PostgreSQL error codes for missing schema objects. If migration 068 has not + * been applied yet, list endpoints degrade to empty data instead of a 500. + */ +const SCHEMA_ERROR_CODES = ['42P01', '42703', '42883']; +function isSchemaError(err) { + return SCHEMA_ERROR_CODES.includes(err?.code); +} + +/** Serialize a feature_flags row for API responses. */ +function serializeFlag(row) { + return { + id: row.id, + flag_key: row.flag_key, + community_id: row.community_id, + platform: row.platform, + is_enabled: row.is_enabled, + rollout_pct: row.rollout_pct, + description: row.description, + updated_by: row.updated_by, + created_at: row.created_at?.toISOString?.() || row.created_at, + updated_at: row.updated_at?.toISOString?.() || row.updated_at, + }; +} + +/** + * GET /api/v1/admin/:communityId/feature-flags + * Merged view: every global flag (community_id NULL) plus this community's + * overrides. Effective state per (flag_key, platform) row is resolved with the + * router's exact specificity ranking (see resolveEffectiveFlags / + * libs/flask_core/flask_core/feature_flags.py _pick_most_specific): + * (community, platform) > (community, NULL) > (NULL, platform) > (NULL, NULL) — + * so the page always shows what the bot actually does, e.g. a community + * all-platform override wins over a platform-specific global row. + */ +export async function listCommunityFlags(req, res, next) { + try { + const communityId = parseInt(req.params.communityId, 10); + + const globalsResult = await query( + `SELECT * FROM feature_flags + WHERE community_id IS NULL + ORDER BY flag_key, platform NULLS FIRST`, + [] + ); + const overridesResult = await query( + `SELECT * FROM feature_flags + WHERE community_id = $1 + ORDER BY flag_key, platform NULLS FIRST`, + [communityId] + ); + + const flags = resolveEffectiveFlags(globalsResult.rows, overridesResult.rows, communityId); + + res.json({ success: true, flags }); + } catch (err) { + if (isSchemaError(err)) { + return res.json({ success: true, flags: [] }); + } + next(err); + } +} + +/** + * POST /api/v1/admin/:communityId/feature-flags + * Create a community-scoped override. community_id is forced from the URL. + */ +export async function createCommunityOverride(req, res, next) { + try { + const communityId = parseInt(req.params.communityId, 10); + + const keyCheck = normalizeFlagKey(req.body.flag_key); + if (keyCheck.error) return next(errors.badRequest(keyCheck.error)); + const platCheck = normalizePlatform(req.body.platform); + if (platCheck.error) return next(errors.badRequest(platCheck.error)); + const pctCheck = normalizeRolloutPct(req.body.rollout_pct); + if (pctCheck.error) return next(errors.badRequest(pctCheck.error)); + + const { flagKey } = keyCheck; + const { platform } = platCheck; + const { rolloutPct } = pctCheck; + const isEnabled = req.body.is_enabled === undefined ? false : Boolean(req.body.is_enabled); + const description = typeof req.body.description === 'string' ? req.body.description : null; + const actor = actorFromRequest(req); + + // Reject duplicates for this community + platform (unique per + // flag_key, COALESCE(community_id,-1), COALESCE(platform,'*')). + const existing = await query( + `SELECT id FROM feature_flags + WHERE flag_key = $1 AND community_id = $2 + AND COALESCE(platform, '*') = COALESCE($3, '*')`, + [flagKey, communityId, platform] + ); + if (existing.rows.length > 0) { + return next(errors.conflict('An override for this flag/platform already exists for this community')); + } + + const created = await transaction(async (client) => { + const result = await client.query( + `INSERT INTO feature_flags + (flag_key, community_id, platform, is_enabled, rollout_pct, description, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [flagKey, communityId, platform, isEnabled, rolloutPct, description, actor] + ); + const row = result.rows[0]; + await insertFlagAudit(client, { + flagKey, + communityId, + platform, + action: 'created', + oldValue: null, + newValue: serializeFlag(row), + changedBy: actor, + }); + return row; + }); + + await publishReload(flagKey, communityId); + logger.audit('Feature flag override created', { adminId: req.user?.id, communityId, flagKey, platform }); + + res.status(201).json({ success: true, flag: serializeFlag(created) }); + } catch (err) { + next(err); + } +} + +/** + * PUT /api/v1/admin/:communityId/feature-flags/:id + * Update a community-scoped override. Ownership is re-checked: the row must + * belong to THIS community (never a global row, never another community). + */ +export async function updateCommunityOverride(req, res, next) { + try { + const communityId = parseInt(req.params.communityId, 10); + const id = parseInt(req.params.id, 10); + if (isNaN(id)) return next(errors.badRequest('Invalid flag id')); + + const existing = await query('SELECT * FROM feature_flags WHERE id = $1', [id]); + if (existing.rows.length === 0) { + return next(errors.notFound('Feature flag override not found')); + } + const current = existing.rows[0]; + // Guard: a community admin may only touch their own community's overrides. + if (current.community_id !== communityId) { + return next(errors.forbidden('Cannot modify a flag outside this community')); + } + + const updates = []; + const params = []; + let idx = 1; + + if (req.body.is_enabled !== undefined) { + updates.push(`is_enabled = $${idx++}`); + params.push(Boolean(req.body.is_enabled)); + } + if (req.body.rollout_pct !== undefined) { + const pctCheck = normalizeRolloutPct(req.body.rollout_pct); + if (pctCheck.error) return next(errors.badRequest(pctCheck.error)); + updates.push(`rollout_pct = $${idx++}`); + params.push(pctCheck.rolloutPct); + } + if (req.body.description !== undefined) { + updates.push(`description = $${idx++}`); + params.push(typeof req.body.description === 'string' ? req.body.description : null); + } + if (updates.length === 0) { + return next(errors.badRequest('No updates provided')); + } + + const actor = actorFromRequest(req); + updates.push(`updated_by = $${idx++}`); + params.push(actor); + updates.push('updated_at = NOW()'); + params.push(id); + + const updated = await transaction(async (client) => { + const result = await client.query( + `UPDATE feature_flags SET ${updates.join(', ')} WHERE id = $${idx} RETURNING *`, + params + ); + const row = result.rows[0]; + await insertFlagAudit(client, { + flagKey: row.flag_key, + communityId, + platform: row.platform, + action: 'updated', + oldValue: serializeFlag(current), + newValue: serializeFlag(row), + changedBy: actor, + }); + return row; + }); + + await publishReload(updated.flag_key, communityId); + logger.audit('Feature flag override updated', { adminId: req.user?.id, communityId, flagKey: updated.flag_key }); + + res.json({ success: true, flag: serializeFlag(updated) }); + } catch (err) { + next(err); + } +} + +/** + * DELETE /api/v1/admin/:communityId/feature-flags/:id + * Remove a community-scoped override (reverts to the global default). + */ +export async function deleteCommunityOverride(req, res, next) { + try { + const communityId = parseInt(req.params.communityId, 10); + const id = parseInt(req.params.id, 10); + if (isNaN(id)) return next(errors.badRequest('Invalid flag id')); + + const existing = await query('SELECT * FROM feature_flags WHERE id = $1', [id]); + if (existing.rows.length === 0) { + return next(errors.notFound('Feature flag override not found')); + } + const current = existing.rows[0]; + if (current.community_id !== communityId) { + return next(errors.forbidden('Cannot delete a flag outside this community')); + } + + const actor = actorFromRequest(req); + await transaction(async (client) => { + await client.query('DELETE FROM feature_flags WHERE id = $1', [id]); + await insertFlagAudit(client, { + flagKey: current.flag_key, + communityId, + platform: current.platform, + action: 'deleted', + oldValue: serializeFlag(current), + newValue: null, + changedBy: actor, + }); + }); + + await publishReload(current.flag_key, communityId); + logger.audit('Feature flag override deleted', { adminId: req.user?.id, communityId, flagKey: current.flag_key }); + + res.json({ success: true }); + } catch (err) { + next(err); + } +} + +export default { + listCommunityFlags, + createCommunityOverride, + updateCommunityOverride, + deleteCommunityOverride, +}; diff --git a/admin/hub_module/backend/src/routes/featureFlags.js b/admin/hub_module/backend/src/routes/featureFlags.js new file mode 100644 index 000000000..c91af456f --- /dev/null +++ b/admin/hub_module/backend/src/routes/featureFlags.js @@ -0,0 +1,80 @@ +/** + * Feature Flag Routes - community admin access. + * + * Mounted under the same /admin prefix and protected exactly like the other + * community-admin routes (admin.js): requireAuth on the router, then + * requireCommunityAdmin per route (which enforces the :communityId scope). + * community_id is always taken from the URL param, never the body. + */ +import { Router } from 'express'; +import { body, param } from 'express-validator'; +import * as featureFlagController from '../controllers/featureFlagController.js'; +import { requireAuth, requireCommunityAdmin } from '../middleware/auth.js'; +import { validateRequest } from '../middleware/validation.js'; +import { PLATFORM_ALLOWLIST } from '../services/featureFlagService.js'; + +const router = Router(); + +router.use(requireAuth); + +// Validation chains for create/update override bodies. +const flagKeyValidator = body('flag_key') + .isString() + .bail() + .isLength({ min: 1, max: 100 }) + .withMessage('flag_key must be 1-100 characters') + .bail() + .matches(/^[a-z0-9_.-]+$/) + .withMessage('flag_key may only contain lowercase letters, digits, dot, dash and underscore'); + +const platformValidator = body('platform') + .optional({ nullable: true }) + .custom((v) => v === null || v === '' || v === 'all' || PLATFORM_ALLOWLIST.includes(v)) + .withMessage(`platform must be null/all or one of: ${PLATFORM_ALLOWLIST.join(', ')}`); + +const rolloutValidator = body('rollout_pct') + .optional() + .isInt({ min: 0, max: 100 }) + .withMessage('rollout_pct must be an integer between 0 and 100'); + +const enabledValidator = body('is_enabled').optional().isBoolean().withMessage('is_enabled must be a boolean'); +const descriptionValidator = body('description').optional({ nullable: true }).isString().isLength({ max: 5000 }); + +// List merged flag view for this community +router.get('/:communityId/feature-flags', requireCommunityAdmin, featureFlagController.listCommunityFlags); + +// Create a community-scoped override +router.post( + '/:communityId/feature-flags', + requireCommunityAdmin, + flagKeyValidator, + platformValidator, + rolloutValidator, + enabledValidator, + descriptionValidator, + validateRequest, + featureFlagController.createCommunityOverride +); + +// Update a community-scoped override +router.put( + '/:communityId/feature-flags/:id', + requireCommunityAdmin, + param('id').isInt({ min: 1 }), + rolloutValidator, + enabledValidator, + descriptionValidator, + validateRequest, + featureFlagController.updateCommunityOverride +); + +// Delete a community-scoped override +router.delete( + '/:communityId/feature-flags/:id', + requireCommunityAdmin, + param('id').isInt({ min: 1 }), + validateRequest, + featureFlagController.deleteCommunityOverride +); + +export default router; diff --git a/admin/hub_module/backend/src/routes/index.js b/admin/hub_module/backend/src/routes/index.js index e2dfef200..420a54591 100644 --- a/admin/hub_module/backend/src/routes/index.js +++ b/admin/hub_module/backend/src/routes/index.js @@ -31,6 +31,7 @@ import analyticsRoutes from './analytics.js'; import raffleCustomizationRoutes from './raffleCustomization.js'; import githubSyncRoutes from './githubSync.js'; import aiKnowledgeRoutes from './aiKnowledge.js'; +import featureFlagRoutes from './featureFlags.js'; const router = Router(); @@ -122,6 +123,9 @@ router.use('/', githubSyncRoutes); // AI knowledge base + ticket suggestion routes (admin) router.use('/admin', aiKnowledgeRoutes); +// Feature flag routes (community-admin — manage this community's flag overrides) +router.use('/admin', featureFlagRoutes); + // Interaction routes (hub channels, forums — admin + member) router.use('/admin', interactionAdminRoutes); router.use('/community', communityInteractionRouter); diff --git a/admin/hub_module/backend/src/routes/superadmin.js b/admin/hub_module/backend/src/routes/superadmin.js index 6d9f68db0..1f8b8a990 100644 --- a/admin/hub_module/backend/src/routes/superadmin.js +++ b/admin/hub_module/backend/src/routes/superadmin.js @@ -6,8 +6,11 @@ import * as superadminController from '../controllers/superadminController.js'; import * as analyticsController from '../controllers/analyticsController.js'; import PlatformConfigController from '../controllers/platformConfigController.js'; import * as userManagementController from '../controllers/userManagementController.js'; +import * as featureFlagAdminController from '../controllers/featureFlagAdminController.js'; import { requireAuth, requireSuperAdmin } from '../middleware/auth.js'; import { validators, validationRules, validateRequest } from '../middleware/validation.js'; +import { body } from 'express-validator'; +import { PLATFORM_ALLOWLIST } from '../services/featureFlagService.js'; const router = Router(); @@ -130,6 +133,31 @@ router.post('/users/:userId/password-reset', userManagementController.generatePa router.post('/users/:userId/analytics-consumer-role', userManagementController.assignAnalyticsConsumerRole); router.get('/users/:userId/deletion-request', userManagementController.getUserDeletionRequest); +// Feature flag management (global flags + audit trail) +// NOTE: /audit is declared before /:id so it is not swallowed by the param route. +router.get('/feature-flags', featureFlagAdminController.listGlobalFlags); +router.get('/feature-flags/audit', featureFlagAdminController.listAudit); +router.post('/feature-flags', + body('flag_key').isString().bail().isLength({ min: 1, max: 100 }) + .matches(/^[a-z0-9_.-]+$/).withMessage('flag_key may only contain lowercase letters, digits, dot, dash and underscore'), + body('platform').optional({ nullable: true }) + .custom((v) => v === null || v === '' || v === 'all' || PLATFORM_ALLOWLIST.includes(v)) + .withMessage(`platform must be null/all or one of: ${PLATFORM_ALLOWLIST.join(', ')}`), + body('rollout_pct').optional().isInt({ min: 0, max: 100 }), + body('is_enabled').optional().isBoolean(), + body('description').optional({ nullable: true }).isString().isLength({ max: 5000 }), + validateRequest, + featureFlagAdminController.createGlobalFlag +); +router.put('/feature-flags/:id', + body('rollout_pct').optional().isInt({ min: 0, max: 100 }), + body('is_enabled').optional().isBoolean(), + body('description').optional({ nullable: true }).isString().isLength({ max: 5000 }), + validateRequest, + featureFlagAdminController.updateGlobalFlag +); +router.delete('/feature-flags/:id', featureFlagAdminController.deleteGlobalFlag); + // Tenant management router.get('/tenants', superadminController.listTenants); router.post('/tenants', superadminController.createTenant); diff --git a/admin/hub_module/backend/src/services/featureFlagService.js b/admin/hub_module/backend/src/services/featureFlagService.js new file mode 100644 index 000000000..f58bb6ac6 --- /dev/null +++ b/admin/hub_module/backend/src/services/featureFlagService.js @@ -0,0 +1,297 @@ +/** + * Feature Flag Service - shared helpers for the feature-flag admin surface. + * + * Covers three concerns shared by the community-scoped controller + * (featureFlagController.js) and the superadmin global controller + * (featureFlagAdminController.js): + * 1. Input validation constants (flag key format, platform allowlist). + * 2. Append-only audit-trail inserts (feature_flag_audit). + * 3. Cache-invalidation publish to the "feature_flags:reload" Redis channel. + */ +import { logger } from '../utils/logger.js'; +import { publish } from '../config/redis.js'; + +/** + * Allowed platform values for a flag scope. `null` (all platforms) is handled + * separately by the callers and is always valid. + * Mirrors the bot's supported platform set. + */ +export const PLATFORM_ALLOWLIST = [ + 'twitch', + 'discord', + 'slack', + 'youtube', + 'kick', + 'teams', + 'mattermost', + 'googlechat', +]; + +/** flag_key format per the schema contract: lowercase, digits, dot, dash, underscore. */ +export const FLAG_KEY_REGEX = /^[a-z0-9_.-]+$/; + +/** + * Normalize/validate a platform value coming from a request body. + * Returns { platform: string|null } on success or { error: string } on failure. + */ +export function normalizePlatform(value) { + if (value === undefined || value === null || value === '' || value === 'all') { + return { platform: null }; + } + if (typeof value !== 'string' || !PLATFORM_ALLOWLIST.includes(value)) { + return { error: `platform must be null/all or one of: ${PLATFORM_ALLOWLIST.join(', ')}` }; + } + return { platform: value }; +} + +/** + * Validate a rollout percentage. Returns { rolloutPct: number } or { error }. + * Accepts undefined → defaults to 100. + */ +export function normalizeRolloutPct(value) { + if (value === undefined || value === null || value === '') { + return { rolloutPct: 100 }; + } + const n = Number(value); + if (!Number.isInteger(n) || n < 0 || n > 100) { + return { error: 'rollout_pct must be an integer between 0 and 100' }; + } + return { rolloutPct: n }; +} + +/** Validate a flag_key. Returns { flagKey } or { error }. */ +export function normalizeFlagKey(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > 100) { + return { error: 'flag_key is required and must be at most 100 characters' }; + } + if (!FLAG_KEY_REGEX.test(value)) { + return { error: 'flag_key may only contain lowercase letters, digits, dot, dash and underscore' }; + } + return { flagKey: value }; +} + +/** + * Build the "actor" string stored in updated_by / changed_by (VARCHAR 255). + */ +export function actorFromRequest(req) { + return req.user?.email || req.user?.username || (req.user?.id ? String(req.user.id) : 'unknown'); +} + +/** + * Pick the highest-specificity feature_flags row for a + * (community_id, platform) lookup. + * + * EXACT port of the router's resolution + * (libs/flask_core/flask_core/feature_flags.py, _pick_most_specific): + * community specificity dominates platform specificity, so a community-scoped + * row always beats a global row even when the global row is platform-specific. + * Ranking (first match wins): + * 1. (community_id, platform) score 22 + * 2. (community_id, NULL) score 21 + * 3. (NULL, platform) score 12 + * 4. (NULL, NULL) score 11 + * + * @param {Array} rows - feature_flags rows (community_id/platform may be null) + * @param {number|null} communityId - lookup community scope + * @param {string|null} platform - lookup platform scope + * @returns {Object|null} the winning row, or null when nothing matches + */ +export function pickMostSpecific(rows, communityId, platform) { + let best = null; + let bestScore = -1; + for (const row of rows) { + const commSpecific = row.community_id !== null && row.community_id === communityId; + const commGlobal = row.community_id === null; + if (!commSpecific && !commGlobal) continue; + + const platSpecific = row.platform !== null && platform !== null && row.platform === platform; + const platGlobal = row.platform === null; + if (!platSpecific && !platGlobal) continue; + + // Community rank weighted above platform rank so it always dominates. + const score = (commSpecific ? 2 : 1) * 10 + (platSpecific ? 2 : 1); + if (score > bestScore) { + bestScore = score; + best = row; + } + } + return best; +} + +/** Scope label for a winning row, e.g. 'community-all' or 'global-platform'. */ +function scopeOf(row) { + if (!row) return null; + const comm = row.community_id !== null ? 'community' : 'global'; + const plat = row.platform !== null ? 'platform' : 'all'; + return `${comm}-${plat}`; +} + +function toIso(value) { + return value?.toISOString?.() || value || null; +} + +/** + * Build the community admin's merged flag view. + * + * For each displayed (flag_key, platform) combination present in either the + * global rows or this community's overrides, the effective state is resolved + * with the router's exact specificity ranking (pickMostSpecific above): + * community override at that platform → community override at platform NULL + * → global at that platform → global at platform NULL. Both effective_enabled + * and effective_rollout_pct come from the single winning row, so the admin + * page always mirrors actual bot behavior (e.g. a community all-platform + * override beats a platform-specific global row). + * + * Pure function — no DB access — so it is directly unit-testable. + * + * @param {Array} globalRows - feature_flags rows with community_id NULL + * @param {Array} overrideRows - feature_flags rows for this community + * @param {number} communityId + * @returns {Array} display rows + */ +export function resolveEffectiveFlags(globalRows, overrideRows, communityId) { + const byKey = new Map(); + for (const row of [...globalRows, ...overrideRows]) { + if (!byKey.has(row.flag_key)) byKey.set(row.flag_key, []); + byKey.get(row.flag_key).push(row); + } + + const flags = []; + const sortedKeys = [...byKey.keys()].sort(); + for (const flagKey of sortedKeys) { + const rows = byKey.get(flagKey); + + // Display one row per distinct platform scope (null = all platforms first). + const platformSet = new Map(); + for (const row of rows) platformSet.set(row.platform ?? '*', row.platform ?? null); + const platforms = [...platformSet.values()].sort((a, b) => { + if (a === null) return -1; + if (b === null) return 1; + return a < b ? -1 : a > b ? 1 : 0; + }); + + for (const platform of platforms) { + // Winner across ALL candidate rows — mirrors the router's runtime lookup. + const winner = pickMostSpecific(rows, communityId, platform); + // What the router would resolve if this community had no overrides. + const globalWinner = pickMostSpecific( + rows.filter((r) => r.community_id === null), + null, + platform + ); + // Exact-key rows (used for edit/revert actions and metadata). + const exactOverride = rows.find( + (r) => r.community_id === communityId && (r.platform ?? null) === platform + ) || null; + const exactGlobal = rows.find( + (r) => r.community_id === null && (r.platform ?? null) === platform + ) || null; + + const winnerIsOverride = Boolean(winner && winner.community_id !== null); + const meta = exactOverride || exactGlobal || winner || {}; + + flags.push({ + flag_key: flagKey, + platform, + description: exactGlobal?.description ?? winner?.description ?? null, + // Which row actually won (matches bot behavior) — drives the badge. + is_override: winnerIsOverride, + winning_scope: scopeOf(winner), + winning_override_id: winnerIsOverride ? winner.id : null, + // Exact override at this (flag_key, platform), if any — the target for + // edit/revert actions (may differ from the winning row). + override_id: exactOverride ? exactOverride.id : null, + effective_enabled: winner ? winner.is_enabled : null, + effective_rollout_pct: winner ? winner.rollout_pct : null, + global_enabled: globalWinner ? globalWinner.is_enabled : null, + global_rollout_pct: globalWinner ? globalWinner.rollout_pct : null, + updated_by: meta.updated_by ?? null, + updated_at: toIso(meta.updated_at), + }); + } + } + return flags; +} + +/** + * Insert an append-only audit row. MUST be called inside the same transaction + * as the mutation it records (pass the transaction client). + * + * @param {import('pg').PoolClient} client - transaction client + * @param {Object} entry + * @param {string} entry.flagKey + * @param {number|null} entry.communityId + * @param {string|null} entry.platform + * @param {'created'|'updated'|'deleted'} entry.action + * @param {Object|null} entry.oldValue + * @param {Object|null} entry.newValue + * @param {string} entry.changedBy + */ +export async function insertFlagAudit(client, { flagKey, communityId, platform, action, oldValue, newValue, changedBy }) { + await client.query( + `INSERT INTO feature_flag_audit + (flag_key, community_id, platform, action, old_value, new_value, changed_by) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + flagKey, + communityId ?? null, + platform ?? null, + action, + oldValue === undefined || oldValue === null ? null : JSON.stringify(oldValue), + newValue === undefined || newValue === null ? null : JSON.stringify(newValue), + changedBy, + ] + ); +} + +/** + * Publish a cache-invalidation message so the runtime services drop their + * cached copy of a flag. + * + * CACHE-INVALIDATION CONTRACT: publish JSON {"flag_key": "...", "community_id": } + * to the Redis channel "feature_flags:reload". The Python router subscribes to + * this channel and invalidates its cached flag decisions. + * + * Fire-and-forget: this never throws into the request path. If Redis is + * unavailable the failure is logged at warn level and the mutation still + * succeeds (runtime caches simply expire on their own TTL). + */ +export const FEATURE_FLAG_RELOAD_CHANNEL = 'feature_flags:reload'; + +export async function publishReload(flagKey, communityId) { + const payload = { flag_key: flagKey, community_id: communityId ?? null }; + try { + const delivered = await publish(FEATURE_FLAG_RELOAD_CHANNEL, JSON.stringify(payload)); + if (delivered) { + logger.debug('Published feature_flags reload', { + channel: FEATURE_FLAG_RELOAD_CHANNEL, + payload, + }); + } else { + logger.warn('feature_flags reload not published — Redis unavailable', { + channel: FEATURE_FLAG_RELOAD_CHANNEL, + payload, + }); + } + } catch (err) { + logger.warn('feature_flags reload publish failed', { + channel: FEATURE_FLAG_RELOAD_CHANNEL, + payload, + error: err.message, + }); + } +} + +export default { + PLATFORM_ALLOWLIST, + FLAG_KEY_REGEX, + FEATURE_FLAG_RELOAD_CHANNEL, + normalizePlatform, + normalizeRolloutPct, + normalizeFlagKey, + actorFromRequest, + pickMostSpecific, + resolveEffectiveFlags, + insertFlagAudit, + publishReload, +}; diff --git a/admin/hub_module/frontend/src/App.jsx b/admin/hub_module/frontend/src/App.jsx index 96d972e11..bd57cc9ed 100644 --- a/admin/hub_module/frontend/src/App.jsx +++ b/admin/hub_module/frontend/src/App.jsx @@ -90,6 +90,7 @@ import AdminInventory from './pages/admin/AdminInventory'; import AdminRconServers from './pages/admin/AdminRconServers'; import AdminCommunityTokens from './pages/admin/AdminCommunityTokens'; import AdminCommands from './pages/admin/AdminCommands'; +import AdminFeatureFlags from './pages/admin/AdminFeatureFlags'; import AdminPlatformSettings from './pages/admin/AdminPlatformSettings'; import AdminLfgConfig from './pages/admin/AdminLfgConfig'; import AdminClipConfig from './pages/admin/AdminClipConfig'; @@ -136,6 +137,7 @@ import SuperAdminVendorRequests from './pages/superadmin/SuperAdminVendorRequest import SuperAdminUsers from './pages/superadmin/SuperAdminUsers'; import SuperAdminAnalytics from './pages/superadmin/SuperAdminAnalytics'; import SuperAdminTenants from './pages/superadmin/SuperAdminTenants'; +import SuperAdminFeatureFlags from './pages/superadmin/SuperAdminFeatureFlags'; // Tenant admin pages import TenantDashboard from './pages/tenant/TenantDashboard'; @@ -338,6 +340,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> @@ -389,6 +392,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/admin/hub_module/frontend/src/layouts/AdminLayout.jsx b/admin/hub_module/frontend/src/layouts/AdminLayout.jsx index 56b863e75..3fce7f53b 100644 --- a/admin/hub_module/frontend/src/layouts/AdminLayout.jsx +++ b/admin/hub_module/frontend/src/layouts/AdminLayout.jsx @@ -41,6 +41,7 @@ import { ClipboardDocumentListIcon, UserPlusIcon, AcademicCapIcon, + FlagIcon, } from '@heroicons/react/24/outline'; function AdminLayout() { @@ -160,6 +161,7 @@ function AdminLayout() { { to: `/admin/${communityId}/translation`, icon: LanguageIcon, label: 'Translation' }, { to: `/admin/${communityId}/support`, icon: TicketIcon, label: 'Support Tickets' }, { to: `/admin/${communityId}/tokens`, icon: Cog6ToothIcon, label: 'Tokens' }, + { to: `/admin/${communityId}/feature-flags`, icon: FlagIcon, label: 'Feature Flags' }, { to: `/admin/${communityId}/inventory`, icon: InboxStackIcon, label: 'Inventory' }, ], }, @@ -197,6 +199,7 @@ function AdminLayout() { { to: '/superadmin/modules', icon: BuildingStorefrontIcon, label: 'Module Registry' }, { to: '/superadmin/analytics', icon: ChartBarIcon, label: 'Analytics' }, { to: '/superadmin/platform-config', icon: Cog6ToothIcon, label: 'Platform Config' }, + { to: '/superadmin/feature-flags', icon: FlagIcon, label: 'Feature Flags' }, { to: '/superadmin/tenants', icon: ServerStackIcon, label: 'Tenants' }, ]; diff --git a/admin/hub_module/frontend/src/layouts/DashboardLayout.jsx b/admin/hub_module/frontend/src/layouts/DashboardLayout.jsx index 6c30590cb..234de2b26 100644 --- a/admin/hub_module/frontend/src/layouts/DashboardLayout.jsx +++ b/admin/hub_module/frontend/src/layouts/DashboardLayout.jsx @@ -20,6 +20,7 @@ import { TicketIcon, CubeIcon, CodeBracketIcon, + FlagIcon, } from '@heroicons/react/24/outline'; import { useMemo, useState } from 'react'; import GlobalBanner from '../components/GlobalBanner'; @@ -132,6 +133,7 @@ function DashboardLayout() { { name: 'Vendor Requests', href: '/superadmin/vendor-requests', icon: ShoppingCartIcon }, { name: 'Analytics', href: '/superadmin/analytics', icon: ChartBarIcon }, { name: 'Platform Config', href: '/superadmin/platform-config', icon: Cog6ToothIcon }, + { name: 'Feature Flags', href: '/superadmin/feature-flags', icon: FlagIcon }, ], }); } diff --git a/admin/hub_module/frontend/src/pages/admin/AdminFeatureFlags.jsx b/admin/hub_module/frontend/src/pages/admin/AdminFeatureFlags.jsx new file mode 100644 index 000000000..ad1791af0 --- /dev/null +++ b/admin/hub_module/frontend/src/pages/admin/AdminFeatureFlags.jsx @@ -0,0 +1,429 @@ +import { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { + FlagIcon, + XMarkIcon, + CheckIcon, + ExclamationTriangleIcon, + PencilSquareIcon, + PlusIcon, + ArrowUturnLeftIcon, +} from '@heroicons/react/24/outline'; +import { adminApi } from '../../services/api'; + +const PLATFORM_OPTIONS = [ + { value: '', label: 'All platforms' }, + { value: 'twitch', label: 'Twitch' }, + { value: 'discord', label: 'Discord' }, + { value: 'slack', label: 'Slack' }, + { value: 'youtube', label: 'YouTube' }, + { value: 'kick', label: 'Kick' }, + { value: 'teams', label: 'Teams' }, + { value: 'mattermost', label: 'Mattermost' }, + { value: 'googlechat', label: 'Google Chat' }, +]; + +function AdminFeatureFlags() { + const { communityId } = useParams(); + const [flags, setFlags] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + const [busy, setBusy] = useState({}); + const [editModal, setEditModal] = useState(null); // { mode: 'create'|'edit', flag } + + useEffect(() => { + loadFlags(); + }, [communityId]); + + const loadFlags = async () => { + try { + setLoading(true); + setError(null); + const res = await adminApi.getFeatureFlags(communityId); + setFlags(res.data.flags || []); + } catch (err) { + setError(err.response?.data?.error?.message || 'Failed to load feature flags'); + } finally { + setLoading(false); + } + }; + + const rowKey = (f) => `${f.flag_key}::${f.platform || '*'}`; + + const toggleEffective = async (flag) => { + const key = rowKey(flag); + try { + setBusy((b) => ({ ...b, [key]: true })); + if (flag.override_id) { + // An override exists at exactly this (flag, platform) — update it. + await adminApi.updateFeatureFlagOverride(communityId, flag.override_id, { + is_enabled: !flag.effective_enabled, + }); + } else { + // No exact override here — the effective state comes from a broader + // community override or a global row. Create a platform-exact override, + // the most specific scope, so it always wins the router's resolution. + await adminApi.createFeatureFlagOverride(communityId, { + flag_key: flag.flag_key, + platform: flag.platform || null, + is_enabled: !flag.effective_enabled, + rollout_pct: flag.effective_rollout_pct ?? 100, + description: flag.description || null, + }); + } + setMessage({ type: 'success', text: 'Flag updated for this community' }); + loadFlags(); + } catch (err) { + setError(err.response?.data?.error?.message || 'Failed to update flag'); + } finally { + setBusy((b) => ({ ...b, [key]: false })); + } + }; + + const revertOverride = async (flag) => { + if (!flag.override_id) return; + if (!confirm('Remove this community override? The next most specific flag (a broader community override or the global default) will take effect.')) return; + const key = rowKey(flag); + try { + setBusy((b) => ({ ...b, [key]: true })); + await adminApi.deleteFeatureFlagOverride(communityId, flag.override_id); + setMessage({ type: 'success', text: 'Override removed — the next most specific flag now applies' }); + loadFlags(); + } catch (err) { + setError(err.response?.data?.error?.message || 'Failed to remove override'); + } finally { + setBusy((b) => ({ ...b, [key]: false })); + } + }; + + const platformLabel = (p) => PLATFORM_OPTIONS.find((o) => o.value === (p || ''))?.label || p; + + return ( +
+
+
+

Feature Flags

+

+ Override global feature flags for this community. Overrides only affect this community. +

+
+ +
+ + {error && ( +
+
+ + {error} +
+ +
+ )} + + {message && ( +
+
+ + {message.text} +
+ +
+ )} + + {loading ? ( +
+
+
+ ) : flags.length === 0 ? ( +
+ +

No Feature Flags

+

+ No global feature flags are defined yet. New overrides you create will appear here. +

+
+ ) : ( +
+ + + + + + + + + + + + + {flags.map((flag) => { + const key = rowKey(flag); + return ( + + + + + + + + + ); + })} + +
FlagPlatformEffective StateRollout %SourceActions
+
+
+ +
+
+

{flag.flag_key}

+ {flag.description && ( +

{flag.description}

+ )} +
+
+
+ + {platformLabel(flag.platform)} + + + + {flag.effective_rollout_pct}% + {flag.is_override ? ( + + {/* Reflect the row that actually won the router's resolution: + a platform row can be governed by the community's + all-platform override. */} + {flag.winning_scope === 'community-all' && flag.platform + ? 'Override (all platforms)' + : 'Override'} + + ) : ( + + Global default + + )} + + {flag.override_id ? ( + <> + + + + ) : ( + + )} +
+
+ )} + + {editModal && ( + setEditModal(null)} + onSaved={(text) => { + setEditModal(null); + setMessage({ type: 'success', text }); + loadFlags(); + }} + onError={(text) => setError(text)} + /> + )} +
+ ); +} + +function OverrideModal({ communityId, mode, flag, onClose, onSaved, onError }) { + // In "edit" mode the override already exists. In "create" mode from a global + // row, flag_key/platform are seeded and locked. From the "New Override" + // button (flag === null) all fields are editable. + const editing = mode === 'edit'; + const seeded = Boolean(flag); + const [form, setForm] = useState({ + flag_key: flag?.flag_key || '', + platform: flag?.platform || '', + is_enabled: flag ? Boolean(flag.effective_enabled) : false, + rollout_pct: flag?.effective_rollout_pct ?? 100, + description: flag?.description || '', + }); + const [saving, setSaving] = useState(false); + const [formError, setFormError] = useState(null); + + const submit = async (e) => { + e.preventDefault(); + setFormError(null); + + if (!editing) { + if (!/^[a-z0-9_.-]+$/.test(form.flag_key) || form.flag_key.length === 0 || form.flag_key.length > 100) { + setFormError('flag_key may only contain lowercase letters, digits, dot, dash and underscore (max 100 chars)'); + return; + } + } + const pct = Number(form.rollout_pct); + if (!Number.isInteger(pct) || pct < 0 || pct > 100) { + setFormError('rollout_pct must be an integer between 0 and 100'); + return; + } + + try { + setSaving(true); + if (editing) { + await adminApi.updateFeatureFlagOverride(communityId, flag.override_id, { + is_enabled: form.is_enabled, + rollout_pct: pct, + description: form.description || null, + }); + onSaved('Override updated'); + } else { + await adminApi.createFeatureFlagOverride(communityId, { + flag_key: form.flag_key, + platform: form.platform || null, + is_enabled: form.is_enabled, + rollout_pct: pct, + description: form.description || null, + }); + onSaved('Override created'); + } + } catch (err) { + const msg = err.response?.data?.error?.message + || err.response?.data?.error?.details?.[0]?.msg + || 'Failed to save override'; + setFormError(msg); + onError?.(msg); + } finally { + setSaving(false); + } + }; + + return ( +
+
+
+

+ {editing ? 'Edit Override' : 'Create Override'} +

+ +
+
+
+ {formError && ( +
+ {formError} +
+ )} +
+ + setForm({ ...form, flag_key: e.target.value })} + disabled={editing || seeded} + className="w-full px-3 py-2 bg-navy-800 border border-navy-700 rounded-lg text-sky-100 font-mono text-sm focus:outline-none focus:border-gold-500 disabled:opacity-60" + placeholder="my_feature.key" + /> +
+
+ + +
+
+ + setForm({ ...form, rollout_pct: e.target.value })} + className="w-full px-3 py-2 bg-navy-800 border border-navy-700 rounded-lg text-sky-100 focus:outline-none focus:border-gold-500" + /> +
+
+ +