Skip to content
Open
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
224 changes: 120 additions & 104 deletions Realism/app/api/jobs/create/route.ts
Original file line number Diff line number Diff line change
@@ -1,104 +1,120 @@
import { NextRequest, NextResponse } from 'next/server'
import { validateSession } from '@/lib/auth'
import { classifyGoal } from '@/lib/classifier'
import { createJob } from '@/lib/jobs'
import { updateJob } from '@/lib/redis'
import { sapiomCreateSpendRule, sapiomScheduleJob } from '@/lib/sapiom'
import type { JobCadence } from '@/types'

export async function POST(req: NextRequest) {
const token = req.cookies.get('realism-session')?.value
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const userId = await validateSession(token)
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

let body: { goal?: string; budget?: number; cadence?: JobCadence }
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid request body.' }, { status: 400 })
}

const { goal, budget, cadence } = body

if (!goal || typeof goal !== 'string' || goal.trim().length === 0) {
return NextResponse.json({ error: 'Goal is required.' }, { status: 400 })
}
if (goal.trim().length > 500) {
return NextResponse.json({ error: 'Goal must be 500 characters or fewer.' }, { status: 400 })
}
if (budget === undefined || typeof budget !== 'number' || budget < 0.25 || budget > 10) {
return NextResponse.json({ error: 'Budget must be between $0.25 and $10.00.' }, { status: 400 })
}
if (cadence !== undefined && cadence !== 'daily' && cadence !== 'weekly') {
return NextResponse.json({ error: 'Cadence must be daily or weekly.' }, { status: 400 })
}

let type: 'one-shot' | 'persistent'
try {
type = await classifyGoal(goal.trim())
} catch {
type = 'one-shot'
}

if (type === 'persistent' && !cadence) {
return NextResponse.json(
{ error: 'This goal looks like it needs a schedule. Choose daily or weekly.', needsCadence: true },
{ status: 400 }
)
}

const jobId = crypto.randomUUID()

let spendRuleId: string | undefined
try {
const spendRule = await sapiomCreateSpendRule(jobId, budget)
spendRuleId = spendRule.id
} catch (err) {
console.warn('[jobs/create] Spending rule creation failed (non-fatal):', err)
}

try {
const job = await createJob({
id: jobId,
userId,
goal: goal.trim(),
budget,
type,
cadence,
spendRuleId,
})

if (type === 'persistent' && cadence) {
const webhookUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/jobs/webhook`

try {
const schedule = await sapiomScheduleJob(jobId, cadence, webhookUrl)
await updateJob(jobId, { qstashScheduleId: schedule.scheduleId })
console.log(`[jobs/create] QStash schedule created: ${schedule.scheduleId}`)
} catch (err) {
console.warn('[jobs/create] QStash schedule creation failed (non-fatal):', err)
}

// Trigger immediate first run after a 150ms delay so the scheduleId
// write has time to persist before the webhook reads the job.
setTimeout(() => {
fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobId }),
}).catch(() => {})
}, 150)
}

return NextResponse.json({ jobId: job.id, type: job.type })
} catch (err) {
console.error('[jobs/create] Failed to create job:', err)
return NextResponse.json({ error: 'Failed to create job.' }, { status: 500 })
}
}
import { NextRequest, NextResponse } from 'next/server'
import { validateSession } from '@/lib/auth'
import { classifyGoal } from '@/lib/classifier'
import { createJob } from '@/lib/jobs'
import { updateJob } from '@/lib/redis'
import { sapiomCreateSpendRule, sapiomScheduleJob } from '@/lib/sapiom'
import type { JobCadence } from '@/types'

export async function POST(req: NextRequest) {
const token = req.cookies.get('realism-session')?.value
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const userId = await validateSession(token)
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

let body: { goal?: string; budget?: number; cadence?: JobCadence }
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid request body.' }, { status: 400 })
}

const { goal, budget, cadence } = body

if (!goal || typeof goal !== 'string' || goal.trim().length === 0) {
return NextResponse.json({ error: 'Goal is required.' }, { status: 400 })
}
if (goal.trim().length > 500) {
return NextResponse.json({ error: 'Goal must be 500 characters or fewer.' }, { status: 400 })
}
if (budget === undefined || typeof budget !== 'number' || budget < 0.25 || budget > 10) {
return NextResponse.json({ error: 'Budget must be between $0.25 and $10.00.' }, { status: 400 })
}
if (cadence !== undefined && cadence !== 'daily' && cadence !== 'weekly') {
return NextResponse.json({ error: 'Cadence must be daily or weekly.' }, { status: 400 })
}

let type: 'one-shot' | 'persistent'
try {
type = await classifyGoal(goal.trim())
} catch {
type = 'one-shot'
}

if (type === 'persistent' && !cadence) {
return NextResponse.json(
{ error: 'This goal looks like it needs a schedule. Choose daily or weekly.', needsCadence: true },
{ status: 400 }
)
}

const jobId = crypto.randomUUID()

let spendRuleId: string | undefined
try {
const spendRule = await sapiomCreateSpendRule(jobId, budget)
spendRuleId = spendRule.id
} catch (err) {
console.error('[jobs/create] Spending rule creation failed:', err)

// SECURITY FIX: Budget-Control Bypass Prevention.
// Failure to create a spending rule must be treated as a FATAL error (Fail Closed).
// We cannot proceed and create an unbounded job if governance boundaries fail to attach.
return NextResponse.json(
{ error: 'Failed to establish budget controls. Job creation aborted to prevent unbounded spend.' },
{ status: 502 }
)
}

// Double check to strictly ensure the spendRuleId was populated
if (!spendRuleId) {
return NextResponse.json(
{ error: 'Budget controls missing.' },
{ status: 500 }
)
}

try {
const job = await createJob({
id: jobId,
userId,
goal: goal.trim(),
budget,
type,
cadence,
spendRuleId,
})

if (type === 'persistent' && cadence) {
const webhookUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/jobs/webhook`

try {
const schedule = await sapiomScheduleJob(jobId, cadence, webhookUrl)
await updateJob(jobId, { qstashScheduleId: schedule.scheduleId })
console.log(`[jobs/create] QStash schedule created: ${schedule.scheduleId}`)
} catch (err) {
console.warn('[jobs/create] QStash schedule creation failed (non-fatal):', err)
}

setTimeout(() => {
// NOTE: In a complete fix, the manual webhook trigger should also use QStash SDK to include the proper signature
// since we enforced signature checks in the webhook route previously.
fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobId }),
}).catch(() => {})
}, 150)
}

return NextResponse.json({ jobId: job.id, type: job.type })
} catch (err) {
console.error('[jobs/create] Failed to create job:', err)
return NextResponse.json({ error: 'Failed to create job.' }, { status: 500 })
}
}
Loading