Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions api/routes/aiControls.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import express from 'express';
import { getRuntimeEnvironment } from '../middleware/auth.js';
import {
getAIKillSwitch,
getAIKillSwitchHistory,
isOperatorTokenValid,
updateAIKillSwitch,
validateKillSwitchUpdate,
} from '../services/aiKillSwitch.js';

export const router = express.Router();

function sendUnavailable(res, error) {
console.error(
'[ai-controls] Runtime control unavailable:',
error instanceof Error ? error.message : error
);
return res
.status(503)
.json({ error: 'ai_control_unavailable', message: 'AI runtime controls are unavailable.' });
}

router.get('/', async (_req, res) => {
try {
getRuntimeEnvironment();
const state = await getAIKillSwitch();
res.set('Cache-Control', 'no-store');
return res.json({ success: true, data: state });
} catch (error) {
return sendUnavailable(res, error);
}
});

router.get('/audit', async (_req, res) => {
try {
getRuntimeEnvironment();
const history = await getAIKillSwitchHistory();
res.set('Cache-Control', 'no-store');
return res.json({ success: true, data: history });
} catch (error) {
return sendUnavailable(res, error);
}
});

router.put('/', async (req, res) => {
const authorization = req.headers.authorization;
const token =
typeof authorization === 'string' && authorization.startsWith('Bearer ')
? authorization.slice('Bearer '.length)
: '';
if (!isOperatorTokenValid(token)) {
return res
.status(403)
.json({ error: 'forbidden', message: 'A configured operator token is required.' });
}

let update;
try {
update = validateKillSwitchUpdate(req.body);
getRuntimeEnvironment();
} catch (error) {
if (error?.status === 400)
return res.status(400).json({ error: 'invalid_payload', message: error.message });
return res.status(503).json({
error: 'unsupported_environment',
message: 'AI runtime control is unsupported in this environment.',
});
}

try {
const state = await updateAIKillSwitch(update.enabled, 'configured-operator', update.reason);
res.set('Cache-Control', 'no-store');
return res.json({ success: true, data: state });
} catch (error) {
return sendUnavailable(res, error);
}
});
13 changes: 13 additions & 0 deletions api/routes/behavior.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,22 @@ import {
clearPersonalizationData,
} from '../../src/lib/personalizationEngine.js';
import { requireSelfOrAdmin } from '../middleware/auth.js';
import { getAIKillSwitch } from '../services/aiKillSwitch.js';

export const router = express.Router();

router.use(async (_req, res, next) => {
try {
const { enabled } = await getAIKillSwitch();
if (!enabled) {
return res.status(503).json({ error: 'ai_disabled', message: 'AI-assisted behavior features are temporarily disabled.' });
}
return next();
} catch {
return res.status(503).json({ error: 'ai_control_unavailable', message: 'AI features are unavailable until runtime controls can be verified.' });
}
});

router.use((req, res, next) => {
const userId = req.headers['x-user-id'] || req.query.userId || req.user?.id;
if (!userId && req.path !== '/settings') {
Expand Down
4 changes: 4 additions & 0 deletions api/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { router as notificationSummariesRouter } from './routes/notificationSumm
import { router as gasPredictionRouter } from './routes/gasPrediction.js';
import { router as analyticsRouter } from './routes/analytics.js';
import { router as migrationRouter } from './routes/migration.js';
import { router as aiControlsRouter } from './routes/aiControls.js';

export const app = express();
export const server = createServer(app);
Expand All @@ -25,6 +26,8 @@ app.use('/api/health', healthRouter);
app.use(apiVersioningMiddleware);
app.use(idempotencyMiddleware);
app.use(rateLimiter);
// Public read-only status; mutation authorization is enforced by the route itself.
app.use('/api/v1/ai-controls', aiControlsRouter);

getRuntimeEnvironment();

Expand Down Expand Up @@ -56,6 +59,7 @@ app.get('/api/docs', (req, res) => {
'/api/v1/transactions': 'GET - Query transactions (query params: accountId, limit)',
'/api/v1/liquidity': 'GET - Liquidity predictions and metrics',
'/api/v1/behavior': 'Behavior prediction, suggestions, personalization',
'/api/v1/ai-controls': 'GET/PUT - Global AI panel kill switch (operator token required for writes)',
'/api/v1/behavior/predict/intent': 'POST - Predict user intent',
'/api/v1/behavior/predict/next-action': 'POST - Predict next user action',
'/api/v1/behavior/profile': 'GET - Get behavior profile',
Expand Down
161 changes: 161 additions & 0 deletions api/services/aiKillSwitch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { timingSafeEqual } from 'node:crypto';

const STATE_KEY = 'stellar-dashboard:ai-kill-switch';
const AUDIT_LIMIT = 100;

class MemoryKillSwitchStore {
constructor() {
this.state = { enabled: true, updatedAt: null, updatedBy: null, reason: null };
this.audit = [];
}

async read() {
return { ...this.state };
}

async update(enabled, actor, reason) {
const now = new Date().toISOString();
const entry = { enabled, previousEnabled: this.state.enabled, updatedAt: now, actor, reason };
this.state = { enabled, updatedAt: now, updatedBy: actor, reason };
this.audit.unshift(entry);
this.audit.length = Math.min(this.audit.length, AUDIT_LIMIT);
return { ...this.state };
}

async history() {
return this.audit.map((entry) => ({ ...entry }));
}
}

class RedisKillSwitchStore {
constructor(redis) {
this.redis = redis;
}

async read() {
const raw = await this.redis.get(STATE_KEY);
return raw
? JSON.parse(raw)
: { enabled: true, updatedAt: null, updatedBy: null, reason: null };
}

async update(enabled, actor, reason) {
const script = `
local old = redis.call('GET', KEYS[1])
local previous = true
if old then previous = cjson.decode(old).enabled end
local state = cjson.encode({enabled=ARGV[1] == 'true', updatedAt=ARGV[2], updatedBy=ARGV[3], reason=ARGV[4]})
local audit = cjson.encode({enabled=ARGV[1] == 'true', previousEnabled=previous, updatedAt=ARGV[2], actor=ARGV[3], reason=ARGV[4]})
redis.call('SET', KEYS[1], state)
redis.call('LPUSH', KEYS[2], audit)
redis.call('LTRIM', KEYS[2], 0, ${AUDIT_LIMIT - 1})
return state
`;
const raw = await this.redis.eval(
script,
2,
STATE_KEY,
`${STATE_KEY}:audit`,
String(enabled),
new Date().toISOString(),
actor,
reason || ''
);
return JSON.parse(raw);
}

async history() {
const rows = await this.redis.lrange(`${STATE_KEY}:audit`, 0, AUDIT_LIMIT - 1);
return rows.map((row) => JSON.parse(row));
}
}

let storePromise;
let injectedStore;

async function getStore() {
if (injectedStore) return injectedStore;
if (storePromise) return storePromise;
storePromise = (async () => {
const mode = (process.env.AI_KILL_SWITCH_STORE || '').toLowerCase();
const redisUrl = process.env.REDIS_URL;
if (mode && mode !== 'memory' && mode !== 'redis') {
throw new Error('AI_KILL_SWITCH_STORE must be either redis or memory');
}
if (mode === 'memory' && process.env.NODE_ENV === 'production') {
throw new Error('Process-local AI kill switch storage is unsupported in production');
}
if (mode === 'memory' || (!redisUrl && process.env.NODE_ENV !== 'production')) {
return new MemoryKillSwitchStore();
}
if (!redisUrl) {
throw new Error('AI kill switch requires REDIS_URL in production');
}
const { default: Redis } = await import('ioredis');
const redis = new Redis(redisUrl, { maxRetriesPerRequest: 1, lazyConnect: true });
await redis.connect();
await redis.ping();
return new RedisKillSwitchStore(redis);
})().catch((error) => {
storePromise = undefined;
throw error;
});
return storePromise;
}

export function validateKillSwitchUpdate(body) {
if (
!body ||
typeof body !== 'object' ||
Array.isArray(body) ||
typeof body.enabled !== 'boolean'
) {
const error = new Error('enabled must be a boolean');
error.status = 400;
throw error;
}
const reason = body.reason === undefined ? '' : body.reason;
const containsControlCharacter =
typeof reason === 'string' &&
Array.from(reason).some(
(character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127
);
if (typeof reason !== 'string' || reason.length > 240 || containsControlCharacter) {
const error = new Error('reason must be a string of at most 240 printable characters');
error.status = 400;
throw error;
}
return { enabled: body.enabled, reason: reason.trim() };
}

export function isOperatorTokenValid(token, expected = process.env.AI_KILL_SWITCH_OPERATOR_TOKEN) {
if (typeof token !== 'string' || typeof expected !== 'string' || expected.length < 32)
return false;
const supplied = Buffer.from(token);
const configured = Buffer.from(expected);
return supplied.length === configured.length && timingSafeEqual(supplied, configured);
}

export async function getAIKillSwitch() {
return (await getStore()).read();
}

export async function updateAIKillSwitch(enabled, actor, reason = '') {
return (await getStore()).update(enabled, actor, reason);
}

export async function getAIKillSwitchHistory() {
return (await getStore()).history();
}

export function setAIKillSwitchStoreForTests(store) {
injectedStore = store;
storePromise = undefined;
}

export function resetAIKillSwitchStoreForTests() {
injectedStore = undefined;
storePromise = undefined;
}

export { MemoryKillSwitchStore };
6 changes: 5 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ services:
command: npm run dev -- --host
environment:
- NODE_ENV=development
- VITE_API_URL=${VITE_API_URL:-http://localhost:4000}
- VITE_API_URL=${VITE_API_URL:-http://api:4000}
depends_on:
api:
condition: service_healthy
Expand All @@ -59,6 +59,8 @@ services:
- NODE_ENV=${NODE_ENV:-development}
- PORT=4000
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- AI_KILL_SWITCH_STORE=${AI_KILL_SWITCH_STORE:-redis}
- AI_KILL_SWITCH_OPERATOR_TOKEN=${AI_KILL_SWITCH_OPERATOR_TOKEN:-}
depends_on:
redis:
condition: service_healthy
Expand All @@ -82,6 +84,8 @@ services:
- CANARY=true
- PORT=4000
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- AI_KILL_SWITCH_STORE=${AI_KILL_SWITCH_STORE:-redis}
- AI_KILL_SWITCH_OPERATOR_TOKEN=${AI_KILL_SWITCH_OPERATOR_TOKEN:-}
depends_on:
redis:
condition: service_healthy
Expand Down
31 changes: 26 additions & 5 deletions docs/features/feature-flag-lifecycle.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
# Feature Flag Lifecycle Management

The lifecycle API below manages **session-local experimental flags**. It is not the operator kill switch and must not be used as a production-wide safety control.

Dashboard modules can gate experimental UI through a shared lifecycle service:

`src/lib/featureFlagLifecycle.ts` + `src/components/dashboard/FeatureFlags.tsx`

## Lifecycle

| Status | Meaning |
| --- | --- |
| `draft` | Created but not yet serving traffic |
| `active` | Eligible for evaluation in configured environments |
| Status | Meaning |
| --------- | --------------------------------------------------------------- |
| `draft` | Created but not yet serving traffic |
| `active` | Eligible for evaluation in configured environments |
| `expired` | Past `expiresAt` or manually expired; evaluate returns disabled |
| `retired` | Terminal state; cannot be re-activated |
| `retired` | Terminal state; cannot be re-activated |

Flows: **create → activate → evaluate → expire → retire**, with append-only **audit history**.

Expand All @@ -37,6 +39,25 @@ Flows: **create → activate → evaluate → expire → retire**, with append-o
4. Prefer `expire()` for temporary shutoff and `retire()` for permanent removal.
5. Existing stub `FeatureFlags` tab now manages flags via this service; no storage migration is required (in-memory for the session).

## Global AI panel kill switch (runtime control)

The Feature Flags screen also exposes a separate server-authoritative switch at `/api/v1/ai-controls`. `GET` reads current status; `PUT` accepts `{ "enabled": false, "reason": "incident response" }`. A configured operator bearer token is required to write. The UI holds the token in memory only, clears it after a successful change, and never persists it. The endpoint also exposes a read-only bounded audit list at `/api/v1/ai-controls/audit`.

### Compatibility and deployment

- Current API environments are `development`, `test`, and `production`. Other values (including `staging`) return an unsupported-environment error; define and validate an environment mapping before using a new deployment tier.
- Development/test may use process-local memory when Redis is not configured. That mode is neither durable nor shared across API instances and is unsuitable for production.
- Production requires shared Redis configured with `REDIS_URL` and `AI_KILL_SWITCH_STORE=redis`. The control state and bounded audit history are updated atomically in Redis. If Redis is absent or unavailable, reads and writes return `503`; the browser fails closed and suppresses AI panels. There is intentionally no process-local fallback.
- Set `AI_KILL_SWITCH_OPERATOR_TOKEN` on the API service to a randomly generated secret of at least 32 characters. Configure it through your secret manager/container environment, not source control, browser storage, or Vite's `VITE_*` variables. No operator token means no authorized mutation.
- Compose's API and web containers use the API service proxy so status and operator requests are same-origin. For standalone deployments, route `/api/` to the API server and preserve `Authorization`; deploy a shared Redis service before enabling the production control.

### Behavior and security

- AI panel state is polled every 10 seconds and refreshed when a browser tab becomes visible. Initial state and fetch/storage failures are fail-closed; the UI does not treat missing status as enabled.
- Dashboard AI routes/widgets and AI behavior endpoints are gated. Hiding a panel is not an authorization substitute; other AI backend integrations should also check the authoritative state before being considered covered by the global switch.
- The write credential is compared in constant time, never returned by the API, and not written to local storage. Audit records include transition, timestamp, fixed operator label, and optional reason (max 240 printable characters); do not put secrets or personal data in the reason.
- Existing local feature flags and their in-memory audit log are unchanged. No data migration is needed; the runtime control persists independently in Redis. Roll back by setting `enabled` to `true` with the operator credential.

## Example

```ts
Expand Down
12 changes: 12 additions & 0 deletions nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ server {
add_header Content-Type text/plain;
}

# Keep the runtime kill switch same-origin and available without rebuilding the UI.
location /api/ {
proxy_pass http://api:4000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 2s;
proxy_read_timeout 10s;
}

# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
Expand Down
Loading
Loading