fix(backend): harden Phase A endpoints — Zod validation, feature flag gate, state filtering

3 bugs fixed in recent Phase A code:

1. POST /api/context-message: Add Zod schema validation, feature flag gate
   (ai_context_messages.enabled), and safe body parsing. Previously had no
   validation and unsafe 'as' cast that could null-ptr on missing body.

2. GET /api/timers/availability: Filter out dismissed/completed/fired timers.
   Previously included inactive timers in occupied intervals, causing the
   endpoint to report less free time than actually available.

3. agent-actions/routes.ts: Import PRODUCT_ID from product-config.ts instead
   of hardcoding 'chronomind' string. Ensures consistency if product identity
   changes.

Also: Add EXTRACTION_SERVICE_URL + PLATFORM_SERVICE_URL to .env.example.

All 219 backend tests pass. No breaking changes.
This commit is contained in:
saravanakumardb1 2026-03-31 23:56:35 -07:00
parent c0e576e15c
commit ea5adcc6ca
4 changed files with 30 additions and 16 deletions

View File

@ -16,3 +16,7 @@ FIELD_ENCRYPT_KEY_PROVIDER=memory
FIELD_ENCRYPT_KEY=
FIELD_ENCRYPT_MEK_NAME=chronomind-mek
AZURE_KEYVAULT_URL=
# Platform + extraction services (optional — for AI context messages)
PLATFORM_SERVICE_URL=http://localhost:4003
EXTRACTION_SERVICE_URL=http://localhost:4005

View File

@ -19,8 +19,7 @@ import {
BatchApproveSchema,
type AgentActionDoc,
} from './types.js';
const PRODUCT_ID = 'chronomind';
import { PRODUCT_ID } from '../../lib/product-config.js';
export async function agentActionRoutes(app: FastifyInstance) {
// ── Feature flag gate ─────────────────────────────────────

View File

@ -233,13 +233,17 @@ export async function timerRoutes(app: FastifyInstance) {
}
// Fetch all timers in the window (active or upcoming)
const { items: timers } = await repo.listTimers(auth.sub, PRODUCT_ID, {
const { items: allTimers } = await repo.listTimers(auth.sub, PRODUCT_ID, {
limit: 100,
offset: 0,
sortBy: 'targetTime',
sortOrder: 'asc',
});
// Exclude timers that no longer occupy time slots
const INACTIVE_STATES = new Set(['dismissed', 'completed', 'fired']);
const timers = allTimers.filter(t => !INACTIVE_STATES.has(t.state));
// Build occupied intervals from timers that overlap the window
const occupied: Array<{ start: number; end: number }> = [];
for (const t of timers) {

View File

@ -17,8 +17,9 @@ import { initCosmosIfNeeded } from './lib/cosmos-init.js';
import { initDatastore } from './lib/datastore.js';
import { initEncryption } from './lib/field-encrypt.js';
import { config } from './lib/config.js';
import { getAllFlags } from './lib/feature-flags.js';
import { generateContextMessage } from './lib/ai-context.js';
import { getAllFlags, isFeatureEnabled } from './lib/feature-flags.js';
import { generateContextMessage, type ContextMessageInput } from './lib/ai-context.js';
import { z } from 'zod';
import { getBufferedEvents, flushEvents } from './lib/telemetry.js';
import { PRODUCT_ID, productConfig } from './lib/product-config.js';
@ -58,19 +59,25 @@ await app.register(webhookRoutes, { prefix: '/api' });
await app.register(agentActionRoutes, { prefix: '/api' });
// ── Phase A.4: Context-aware AI messages ─────────────────────────
app.post('/api/context-message', async req => {
const body = req.body as {
timerLabel: string;
category?: string;
urgency?: string;
minutesBefore: number;
timeOfDay?: string;
recentTimerLabels?: string[];
};
if (!body.timerLabel || typeof body.minutesBefore !== 'number') {
const ContextMessageSchema = z.object({
timerLabel: z.string().min(1).max(500),
category: z.string().max(128).optional(),
urgency: z.string().max(64).optional(),
minutesBefore: z.number().min(0).max(10080),
timeOfDay: z.string().max(64).optional(),
recentTimerLabels: z.array(z.string().max(500)).max(10).optional(),
});
app.post('/api/context-message', async (req, reply) => {
if (!isFeatureEnabled('ai_context_messages.enabled')) {
return { message: 'Get ready!', source: 'generic' as const };
}
return generateContextMessage(body);
const parsed = ContextMessageSchema.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { message: 'Get ready!', source: 'generic' as const };
}
return generateContextMessage(parsed.data as ContextMessageInput);
});
// ── Bootstrap (no auth) ──────────────────────────────────────────