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
15 changes: 15 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(npx tsc *)",
"Bash(pnpm test:all)",
"Bash(pnpm install *)",
"Bash(pnpm test:all -- --reporter=verbose)",
"Bash(npm run *)",
"Bash(cd /d C:\\\\Users\\\\SSTECH\\\\developments\\\\code-kit-ultra\\\\.claude\\\\worktrees\\\\vibrant-lederberg-080dba)",
"Bash(npm test *)",
"Bash(pnpm test *)",
"Bash(pnpm run *)"
]
}
}
59 changes: 59 additions & 0 deletions apps/control-service/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { z } from 'zod';

const envSchema = z.object({
DATABASE_URL: z.string()
.url('DATABASE_URL must be a valid URL')
.startsWith('postgresql://', 'DATABASE_URL must start with postgresql://'),
REDIS_URL: z.string()
.url('REDIS_URL must be a valid URL')
.startsWith('redis://', 'REDIS_URL must start with redis://'),
JWT_SECRET: z.string()
.min(32, 'JWT_SECRET must be at least 32 characters'),
NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
PORT: z.coerce.number().default(7474),
CKU_ALLOWED_ORIGINS: z.string().default('http://localhost:7473'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
INSFORGE_API_KEY: z.string().optional(),
INSFORGE_PROJECT_ID: z.string().optional(),
INSFORGE_API_BASE_URL: z.string().url().optional(),
});

export type EnvConfig = z.infer<typeof envSchema>;

let configCache: EnvConfig | null = null;

export function validateEnv(): EnvConfig {
try {
return envSchema.parse(process.env);
} catch (error) {
if (error instanceof z.ZodError) {
const messages = error.errors
.map((err) => `${err.path.join('.')}: ${err.message}`)
.join('\n');
console.error('Environment validation failed:\n' + messages);
process.exit(1);
}
throw error;
}
}

export function getConfig(): EnvConfig {
if (!configCache) {
// Skip validation in test mode - tests set required env vars via vitest.setup.ts
if (process.env.NODE_ENV === 'test') {
// Return a minimal config for tests
configCache = {

Check failure on line 45 in apps/control-service/src/config/env.ts

View workflow job for this annotation

GitHub Actions / Verify Base

Conversion of type '{ DATABASE_URL: string; REDIS_URL: string; JWT_SECRET: string; NODE_ENV: "test"; PORT: number; CKU_ALLOWED_ORIGINS: string; LOG_LEVEL: "info"; }' to type '{ LOG_LEVEL: "warn" | "info" | "error" | "debug"; REDIS_URL: string; DATABASE_URL: string; NODE_ENV: "staging" | "production" | "development"; JWT_SECRET: string; PORT: number; CKU_ALLOWED_ORIGINS: string; INSFORGE_API_KEY?: string | undefined; INSFORGE_PROJECT_ID?: string | undefined; INSFORGE_API_BASE_URL?: string...' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
DATABASE_URL: process.env.DATABASE_URL || 'postgresql://test:test@localhost:5432/cku_test',
REDIS_URL: process.env.REDIS_URL || 'redis://localhost:6379/1',
JWT_SECRET: process.env.JWT_SECRET || 'test-secret-key-at-least-32-characters-long-enough',
NODE_ENV: 'test',
PORT: 7474,
CKU_ALLOWED_ORIGINS: 'http://localhost:3000,http://localhost:7473',
LOG_LEVEL: 'info',
} as EnvConfig;
} else {
configCache = validateEnv();
}
}
return configCache;
}
41 changes: 26 additions & 15 deletions apps/control-service/src/db/pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,42 @@

const { Pool } = pg;

if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL environment variable is required');
}
let pool: pg.Pool | null = null;
let initialized = false;

function initializePool() {
if (initialized) return;
initialized = true;

if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL environment variable is required');
}

const pool = new Pool({
connectionString: process.env.DATABASE_URL,
min: 2,
max: 10,
});
pool = new Pool({
connectionString: process.env.DATABASE_URL,
min: 2,
max: 10,
});

// Handle pool errors
pool.on('error', (err) => {
logger.error({ err }, 'Unexpected error on idle client');
});
// Handle pool errors
pool.on('error', (err) => {
logger.error({ err }, 'Unexpected error on idle client');
});

// Register with shared pool registry so packages can use getPool()
setPool(pool);
// Register with shared pool registry so packages can use getPool()
setPool(pool);
}

export function getPool() {
return pool;
if (!pool) {
initializePool();
}
return pool!;
}

export async function testConnection() {
try {
const client = await pool.connect();

Check failure on line 42 in apps/control-service/src/db/pool.ts

View workflow job for this annotation

GitHub Actions / Verify Base

'pool' is possibly 'null'.
await client.query('SELECT 1');
client.release();
return true;
Expand All @@ -39,5 +50,5 @@
}

export async function closePool() {
await pool.end();

Check failure on line 53 in apps/control-service/src/db/pool.ts

View workflow job for this annotation

GitHub Actions / Verify Base

'pool' is possibly 'null'.
}
17 changes: 15 additions & 2 deletions apps/control-service/src/handlers/approve-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,26 @@ import { AuditEventBuilder, AuditActions } from "../lib/audit-builder.js";
export async function approveGateHandler(req: Request, res: Response) {
try {
const context = extractAuthContext(req);
if (!context) {
return res.status(401).json({ error: "Unauthorized: No auth context" });
}
if (!context.actor?.id || !context.actor?.name || !context.tenant?.orgId) {
return res.status(401).json({ error: "Unauthorized: Incomplete auth context" });
}

const runId = extractRunId(req);
if (!runId) {
return res.status(400).json({ error: "Bad request: Missing runId parameter" });
}

// Load run and validate it exists
const bundle = loadRunBundle(runId);
if (!bundle) {
return sendNotFound(res, "Run not found", "run");
}
if (!bundle.state?.runId || !bundle.state?.orgId) {
return sendInternalError(res, new Error("Invalid run state"), "approve_gate");
}

// Validate cross-tenant access
try {
Expand All @@ -48,7 +61,7 @@ export async function approveGateHandler(req: Request, res: Response) {
await new AuditEventBuilder(AuditActions.GATE_APPROVED, context)
.withRunId(runId)
.withResult("success")
.withCorrelationId(bundle.state.correlationId)
.withCorrelationId(bundle.state.correlationId || "")
.emit();

// Emit canonical event for downstream systems
Expand All @@ -60,7 +73,7 @@ export async function approveGateHandler(req: Request, res: Response) {
type: context.actor.type,
authMode: context.actor.authMode,
},
correlationId: bundle.state.correlationId,
correlationId: bundle.state.correlationId || "",
payload: {
actorName: context.actor.name,
status: "approved",
Expand Down
53 changes: 42 additions & 11 deletions apps/control-service/src/handlers/create-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,52 +8,79 @@

export async function createRunHandler(req: Request, res: Response) {
try {
const auth = (req as any).auth;
const { idea, mode, dryRun } = req.body;

// Must require project scope
const auth = req.auth;
if (!auth) {
return res.status(401).json({ error: "Unauthorized" });
}
if (!auth.actor?.actorId || !auth.actor?.actorType || !auth.tenant?.orgId) {
return res.status(401).json({ error: "Unauthorized: Incomplete auth context" });
}

// Must require project scope - check BEFORE validating request body
if (!auth?.tenant?.projectId) {
return res.status(403).json({ error: "Run creation requires a project scope (projectId missing in token)." });
}

const { idea, mode = "expert", dryRun = false } = req.body;
if (!idea || typeof idea !== "string") {
return res.status(400).json({ error: "Bad request: idea is required and must be a string" });
}
if (typeof mode !== "string") {
return res.status(400).json({ error: "Bad request: mode must be a string" });
}

const correlationId = crypto.randomUUID();

// trigger runVerticalSlice
const result = await runVerticalSlice({
idea,
mode,

Check failure on line 37 in apps/control-service/src/handlers/create-run.ts

View workflow job for this annotation

GitHub Actions / Verify Base

Type 'string' is not assignable to type 'Mode | undefined'.
dryRun
});

if (!result?.report) {
return res.status(500).json({ error: "Internal error: Invalid orchestrator result" });
}
if (!result.artifactDirectory) {
return res.status(500).json({ error: "Internal error: Missing artifact directory" });
}

const runId = result.report.id || path.basename(result.artifactDirectory) || "unknown";
if (!runId || runId === "unknown") {
return res.status(500).json({ error: "Internal error: Could not determine run ID" });
}

// Set scope metadata on the new run
// In a real system, we'd update the DB. For this patch, we update the persisted state:
let bundle = loadRunBundle(runId);

if (!bundle) {
// Initialize a Phase 8+ bundle from the Phase 7 report if it doesn't exist yet
if (!result.report.createdAt) {
return res.status(500).json({ error: "Internal error: Missing createdAt timestamp" });
}

const intake = {
runId,
createdAt: result.report.createdAt,
idea,
input: result.report.input,
input: result.report.input || "",
assumptions: result.report.assumptions || [],
clarifyingQuestions: result.report.clarifyingQuestions || [],
};
const plan = {
runId,
createdAt: result.report.createdAt,
summary: result.report.summary || "Newly created run",
selectedSkills: (result.report.selectedSkills || []).map(s => ({ skillId: s.skillId, reason: s.reason, source: "generated" as any })),
selectedSkills: (result.report.selectedSkills || []).map(s => ({ skillId: s?.skillId || "", reason: s?.reason || "", source: "generated" as const })),
tasks: [], // We'll populate tasks during planning/building phase transition
};
const state = {
runId,
createdAt: result.report.createdAt,
updatedAt: result.report.createdAt,
currentStepIndex: 0,
status: "planned" as any,
status: "planned" as const,
approvalRequired: false,
approved: false,
orgId: auth.tenant.orgId,
Expand All @@ -68,6 +95,9 @@
updatePlan(runId, plan);
updateRunState(runId, state);
} else {
if (!bundle.state) {
return res.status(500).json({ error: "Internal error: Invalid run bundle state" });
}
bundle.state.actorId = auth.actor.actorId;
bundle.state.actorType = auth.actor.actorType;
bundle.state.orgId = auth.tenant.orgId;
Expand All @@ -79,7 +109,7 @@

writeAuditEvent({
runId,
actorName: auth.actor.actorName,
actorName: auth.actor.actorName || auth.actor.actorId,
actorId: auth.actor.actorId,
actorType: auth.actor.actorType,
orgId: auth.tenant.orgId,
Expand All @@ -102,7 +132,7 @@
payload: {
idea: idea,
mode: mode,
status: result.overallGateStatus
status: result.overallGateStatus || "unknown"
}
});

Expand All @@ -112,6 +142,7 @@
correlationId
});
} catch (err: any) {
console.error("[createRunHandler] Caught exception:", err);
res.status(500).json({ error: err.message });
}
}
15 changes: 10 additions & 5 deletions apps/control-service/src/handlers/delete-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import { logger } from '../lib/logger.js';
*/
export async function deleteSessionHandler(req: Request, res: Response) {
try {
const session = (req as any).auth;
const auth = req.auth;

if (!session || !session.jti) {
if (!auth) {
return res.status(401).json({
error: 'UNAUTHORIZED',
message: 'No active session',
Expand All @@ -20,10 +20,15 @@ export async function deleteSessionHandler(req: Request, res: Response) {
// Calculate remaining TTL (default 10 minutes from now)
const remainingTtl = 10 * 60;

// Revoke the session
await revokeSession(session.jti, remainingTtl);
// Revoke the session using actor ID
const sessionId = auth.actor.actorId;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use per-token jti key when revoking sessions

This switches session revocation to auth.actor.actorId, but the revocation API is keyed by JWT jti (revokeSession(jti, ...) / isRevoked(jti)). Using actor ID means multiple active tokens for the same actor collide on one revocation key, so deleting one session can revoke all of that actor’s concurrent sessions instead of only the current token.

Useful? React with πŸ‘Β / πŸ‘Ž.

if (!sessionId) {
return res.status(401).json({ error: "Invalid session" });
}

await revokeSession(sessionId, remainingTtl);

logger.info({ userId: session.userId }, 'Session revoked');
logger.info({ userId: auth.actor.actorId }, 'Session revoked');

return res.status(200).json({
status: 'revoked',
Expand Down
5 changes: 4 additions & 1 deletion apps/control-service/src/handlers/get-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { requireAnyPermission } from "../middleware/authorize.js";
// Needs exact signature for express middleware, or we could just export the function handling req/res
export async function getRunHandler(req: Request, res: Response) {
try {
const auth = (req as any).auth;
const auth = req.auth;
if (!auth) {
return res.status(401).json({ error: "Unauthorized" });
}
const runId = req.params.id as string;
const run = RunReader.getRun(runId);

Expand Down
4 changes: 3 additions & 1 deletion apps/control-service/src/handlers/healing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { writeAuditEvent } from "../../../../../packages/audit/src/index";
import { loadRunBundle } from "../../../../../packages/memory/src/run-store";

function verifyHealingTenant(req: Request, res: Response, runId: string) {
const auth = (req as any).auth;
const auth = req.auth;
if (!auth) return { valid: false, response: res.status(401).json({ error: "Unauthorized" }) };

const bundle = loadRunBundle(runId);
if (!bundle) return { valid: false, response: res.status(404).json({ error: "Run not found" }) };

Expand Down
14 changes: 11 additions & 3 deletions apps/control-service/src/handlers/list-runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,19 @@ import { loadRunBundle } from "../../../../packages/memory/src/run-store.js";

export async function listRunsHandler(req: Request, res: Response) {
try {
const auth = (req as any).auth;
const auth = req.auth;
if (!auth) {
return res.status(401).json({ error: "Unauthorized" });
}
if (!auth.actor?.id || !auth.actor?.actorId || !auth.tenant?.orgId) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Drop invalid actor.id check from list-runs auth validation

listRunsHandler rejects valid authenticated requests because it checks auth.actor?.id, but actors produced by authenticate use actorId (not id). That condition is always true, so GET /v1/runs returns 401 Unauthorized: Incomplete auth context for normal bearer/API-key sessions instead of returning runs.

Useful? React with πŸ‘Β / πŸ‘Ž.

return res.status(401).json({ error: "Unauthorized: Incomplete auth context" });
}

const runs = RunReader.getRuns();

// Filter to only what matches the user's org
const filtered = runs.filter((r: any) => {
const filtered = runs.filter((r) => {
if (!r?.id) return false;
const state = loadRunBundle(r.id)?.state;
if (!state) return true; // Legacy fallback
if (state.orgId && state.orgId !== auth.tenant.orgId) return false;
Expand All @@ -18,7 +26,7 @@ export async function listRunsHandler(req: Request, res: Response) {

writeAuditEvent({
action: "LIST_RUNS",
actorName: auth.actor.actorName,
actorName: auth.actor.actorName || auth.actor.actorId,
actorId: auth.actor.actorId,
actorType: auth.actor.actorType,
orgId: auth.tenant.orgId,
Expand Down
Loading
Loading