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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.pnpm-store/
**/__pycache__/
**/node_modules/
coverage/
Expand Down Expand Up @@ -54,3 +55,4 @@ dumps/**/*.dump.gpg
/apps/docs/docs/.obsidian/

.claude/worktrees
.claude/scheduled_tasks.lock
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ Without Traefik, use `http://localhost:<port>` directly. The `*.klicker.com` dom
- **Shared practice renderer in chat**: Do not wrap `StudentElement` in a chat-owned `<form>`; shared answer option buttons are regular buttons and can behave like implicit submit controls inside forms. Use an explicit chat submit button instead. (`apps/chat/src/components/student-practice-quiz-card.tsx`, `packages/shared-components/src/questions/`)
- **Chat shared-component i18n**: Shared practice components use `next-intl` hooks, so `apps/chat` must wrap its App Router tree in `NextIntlClientProvider` before rendering them. (`apps/chat/src/app/layout.tsx`, `packages/shared-components/src/StudentElement.tsx`)
- **Chat model reasoning effort changes**: Adding a reasoning effort requires updating chat validation, the GraphQL enum/schema, generated GraphQL artifacts, and the manage chatbot settings effort order; model-specific availability belongs in the chat and GraphQL model registries. (`apps/chat/src/lib/config/reasoning.ts`, `packages/graphql/src/schema/resource.ts`, `apps/frontend-manage/src/components/resources/chatbots/ChatbotDetails.tsx`)
- **Edge-safe util imports**: Next middleware and other edge-bundled code must import narrow util subpaths (for example `@klicker-uzh/util/auth`), not the util package root, because the root bundle includes Prisma/Node dependencies that break edge builds. (`packages/util/package.json`, `apps/chat/src/middleware.ts`)
- **PWA CHIPS with nookies**: `nookies@2.5.2` uses an older cookie serializer that does not emit `Partitioned`; when setting CHIPS cookies in PWA SSR code, append the attribute to the generated `Set-Cookie` header after `nookies.set`. (`apps/frontend-pwa/src/lib/getParticipantToken.ts`)

## Factory Skills (AI Assistance)

Expand Down
3 changes: 3 additions & 0 deletions apps/chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@klicker-uzh/prisma": "workspace:*",
"@klicker-uzh/shared-components": "workspace:*",
"@klicker-uzh/types": "workspace:*",
"@klicker-uzh/util": "workspace:*",
"@langfuse/otel": "4.6.1",
"@langfuse/tracing": "4.6.1",
"@modelcontextprotocol/sdk": "1.17.5",
Expand All @@ -31,6 +32,7 @@
"@radix-ui/react-tooltip": "1.2.7",
"@uzh-bf/design-system": "4.1.6",
"ai": "6.0.91",
"bcryptjs": "2.4.3",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"heic2any": "0.0.4",
Expand Down Expand Up @@ -59,6 +61,7 @@
"@tailwindcss/forms": "~0.5.10",
"@tailwindcss/postcss": "~4.1.11",
"@tailwindcss/typography": "~0.5.16",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.19.11",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
Expand Down
29 changes: 29 additions & 0 deletions apps/chat/scripts/mint-lti-jwt.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { signJWT } from '@klicker-uzh/util'

const sub = process.argv[2] || `lti-test-${Date.now()}`
const scope = process.argv[3] || 'LTI1.3'

const appSecret = process.env.APP_SECRET
const issuer = process.env.APP_ORIGIN_LTI
const missingEnv = [
['APP_SECRET', appSecret],
['APP_ORIGIN_LTI', issuer],
]
.filter(([, value]) => !value)
.map(([name]) => name)

if (missingEnv.length > 0) {
console.error(`Error: ${missingEnv.join(', ')} must be set`)
process.exit(1)
}

const jwt = await signJWT(
{ sub, email: `${sub}@example.invalid`, scope },
appSecret,
{
algorithm: 'HS256',
expiresIn: '5m',
issuer,
}
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
console.log(jwt)
22 changes: 21 additions & 1 deletion apps/chat/src/app/api/chatbots/[chatbotId]/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,7 @@ export async function POST(
if ('response' in authResult) {
return authResult.response
}
const { participantId, chatbot: authChatbot } = authResult
const { participantId, authMode, chatbot: authChatbot } = authResult

// check disclaimer acceptance
try {
Expand Down Expand Up @@ -1012,6 +1012,26 @@ export async function POST(
}
}

// Phase A: anonymous (LTI guest) users are locked to fallback models.
// This is the FINAL model override — runs after all account-mode model
// resolution branches so it cannot be silently overwritten by the
// `!chatbot.modelSelection` branch (the bug from the original branch).
// Phase B replaces this with reasoning-effort tier gating.
if (authMode === 'anonymous' && !selectedModelConfig.fallback) {
// Pick a fallback directly. `getAutomaticModelId` returns the *primary*
// when credits>0 even when called with `chatbot.allowedModelIds`, which
// would re-trip this branch and 503 anonymous users that have credits.
const guestFallback = modelRegistry.find((m) => m.fallback)
if (!guestFallback) {
return NextResponse.json(
{ error: 'No fallback model available for guest access' },
{ status: 503 }
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
selectedModel = guestFallback.id
selectedModelConfig = guestFallback
}

const allowedReasoningEfforts = getAllowedReasoningEffortsForModel(
selectedModelConfig,
chatbot.allowedReasoningEffortsByModel
Expand Down
61 changes: 34 additions & 27 deletions apps/chat/src/app/api/chatbots/[chatbotId]/credits/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export async function GET(
if ('response' in authResult) {
return authResult.response
}
const { participantId } = authResult
const { participantId, authMode } = authResult

const chatbotResult = await getChatbotOr404(chatbotId, {
courseId: true,
Expand All @@ -35,36 +35,43 @@ export async function GET(
chatbotId
)

const availableModels = getModelsForChatbot(
chatbotResult.chatbot,
credits
).map(
({
id,
name,
description,
fallback,
supportsReasoning,
supportsImageAttachments,
supportedReasoningEfforts,
}) => ({
id,
name,
description,
fallback,
supportsReasoning,
supportsImageAttachments,
allowedReasoningEfforts: supportedReasoningEfforts,
})
)
let availableModels = getModelsForChatbot(chatbotResult.chatbot, credits)

// Phase A: anonymous (LTI guest) restricted to fallback models only.
// Phase B replaces this with reasoning-effort tier gating so guests can
// use the flagship model at free effort levels.
if (authMode === 'anonymous') {
availableModels = availableModels.filter((m) => m.fallback)
}

const automaticModelId =
authMode === 'anonymous'
? (availableModels[0]?.id ?? null)
: getAutomaticModelId(credits, chatbotResult.chatbot.allowedModelIds)

return NextResponse.json({
...credits,
availableModels,
automaticModelId: getAutomaticModelId(
credits,
chatbotResult.chatbot.allowedModelIds
availableModels: availableModels.map(
({
id,
name,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
description,
fallback,
supportsReasoning,
supportsImageAttachments,
supportedReasoningEfforts,
}) => ({
id,
name,
description,
fallback,
supportsReasoning,
supportsImageAttachments,
allowedReasoningEfforts: supportedReasoningEfforts,
})
),
automaticModelId,
authMode,
})
} catch (error) {
console.error('Failed to fetch credits:', error)
Expand Down
201 changes: 201 additions & 0 deletions apps/chat/src/app/auth/lti/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import {
resolveLtiAuthDecision,
signChatGuestToken,
verifyLtiToken,
} from '@/src/lib/server/ltiGuest'
import { prisma } from '@klicker-uzh/prisma'
import {
LTI_PROBE_COOKIE_NAME,
cookieSecurityOptions,
cookiesAvailableViaLtiProbe,
} from '@klicker-uzh/util/auth'
import { jwtVerify } from 'jose'
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'

const LOG_PREFIX = '[chat:auth/lti]'

const querySchema = z.object({
jwt: z.string().min(1),
courseId: z.string().uuid(),
chatbotId: z.string().uuid(),
})

async function getParticipantTokenSub(
req: NextRequest
): Promise<string | null> {
const token = req.cookies.get('participant_token')?.value
if (!token) return null
const appSecret = process.env.APP_SECRET
if (!appSecret) return null
try {
const result = await jwtVerify(token, new TextEncoder().encode(appSecret))
return typeof result.payload.sub === 'string' &&
result.payload.sub.length > 0
? result.payload.sub
: null
} catch {
return null
}
}

function noLoginRedirect(req: NextRequest, chatbotId: string | null) {
const noLoginUrl = req.nextUrl.clone()
noLoginUrl.pathname = '/noLogin'
noLoginUrl.search = ''
noLoginUrl.searchParams.set('lti', '1')
if (chatbotId) {
noLoginUrl.searchParams.set('redirectTo', `/${chatbotId}`)
}
return NextResponse.redirect(noLoginUrl)
}

export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl

const queryResult = querySchema.safeParse({
jwt: searchParams.get('jwt'),
courseId: searchParams.get('courseId'),
chatbotId: searchParams.get('chatbotId'),
})

if (!queryResult.success) {
console.error(
LOG_PREFIX,
'Invalid query params:',
queryResult.error.flatten()
)
return NextResponse.json(
{
error: 'Missing or invalid query parameters (jwt, courseId, chatbotId)',
},
{ status: 400 }
)
}

const { jwt, courseId, chatbotId } = queryResult.data

let ltiPayload
try {
ltiPayload = await verifyLtiToken(jwt)
} catch (error) {
console.error(LOG_PREFIX, 'LTI JWT verification failed:', error)
return noLoginRedirect(req, chatbotId)
}

const [course, chatbot] = await Promise.all([
prisma.course.findUnique({ where: { id: courseId }, select: { id: true } }),
prisma.chatbot.findUnique({
where: { id: chatbotId },
select: { id: true, courseId: true },
}),
])

if (!course) {
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
}
if (!chatbot) {
return NextResponse.json({ error: 'Chatbot not found' }, { status: 404 })
}
if (chatbot.courseId !== courseId) {
console.error(LOG_PREFIX, 'Cross-course access blocked', {
chatbotCourseId: chatbot.courseId,
requestedCourseId: courseId,
chatbotId,
})
return NextResponse.json(
{ error: 'Chatbot not found in this course' },
{ status: 403 }
)
}

const participantTokenSub = await getParticipantTokenSub(req)

let decision
try {
decision = await resolveLtiAuthDecision({
ltiSub: ltiPayload.sub,
ltiScope: ltiPayload.scope,
courseId,
participantTokenSub,
})
} catch (error) {
console.error(LOG_PREFIX, 'resolveLtiAuthDecision failed:', error)
return NextResponse.json(
{ error: 'Failed to resolve auth decision' },
{ status: 500 }
)
}

console.info(LOG_PREFIX, 'auth resolved', {
mode: decision.mode,
chatbotId,
courseId,
})

// Probe whether third-party cookies survived the LMS iframe context.
// `apps/lti` sets `lti-token` with `secure; sameSite=none; domain=COOKIE_DOMAIN`;
// browsers blocking 3p cookies (Safari ITP, Brave, Firefox total cookie protection
// pre-141, Chrome with Tracking Protection) strip it before this request lands.
// Mirrors the PWA pattern in `getParticipantToken.ts`.
const cookiesAvailable = cookiesAvailableViaLtiProbe({
[LTI_PROBE_COOKIE_NAME]: req.cookies.get(LTI_PROBE_COOKIE_NAME)?.value,
})

const chatbotUrl = req.nextUrl.clone()
chatbotUrl.pathname = `/${chatbotId}`
chatbotUrl.search = ''

const isProduction =
process.env.NODE_ENV === 'production' &&
process.env.COOKIE_DOMAIN !== '127.0.0.1'

if (decision.mode === 'account') {
// Account branch: clear any stale `chat_participant_token` so the
// guest-first middleware order (verify chat-guest before participant) does
// not keep forcing `authMode='anonymous'` after this redirect.
const accountResponse = NextResponse.redirect(chatbotUrl)
accountResponse.cookies.set('chat_participant_token', '', {
httpOnly: true,
...cookieSecurityOptions({ isProduction }),
path: '/',
maxAge: 0,
})
return accountResponse
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// Guest path. Issue chat_participant_token; never override participant_token.
let chatGuestToken
try {
chatGuestToken = await signChatGuestToken(decision.participantId)
} catch (error) {
console.error(LOG_PREFIX, 'Failed to sign chat guest token:', error)
return NextResponse.json(
{ error: 'Failed to create guest session' },
{ status: 500 }
)
}

// sessionStorage fallback for browsers where CHIPS is not yet supported
// (pre-Safari 26.2, Firefox <141). Hand the token off via `?_t=` query so
// the client bootstrap (`useChatGuestTokenBootstrap`) can stuff it into
// sessionStorage and strip the URL parameter via `router.replace`.
if (!cookiesAvailable) {
chatbotUrl.searchParams.set('_t', chatGuestToken)
}

const response = NextResponse.redirect(chatbotUrl)

// Host-only cookie: no `domain` set → cookie never leaves the chat subdomain.
// Backend GraphQL on api.<domain> never sees this token even if leaked.
// `Partitioned` (CHIPS) lets modern browsers keep the cookie in third-party
// iframe contexts (Chrome 114+, Edge 114+, Firefox 141+, Safari 26.2+).
response.cookies.set('chat_participant_token', chatGuestToken, {
httpOnly: true,
...cookieSecurityOptions({ isProduction }),
path: '/',
maxAge: 60 * 60 * 24 * 14,
})

return response
}
Loading
Loading