Plan: Public Access to Turing Test Assessment via Nonce
Implement the following plan:
Plan: Public Access to Turing Test Assessment via Nonce
Context
We want to allow unauthenticated users to take the Turing Test assessment via a "magic link" with ?public=true. Instead of Azure AD login, the user is identified by a nonce stored in localStorage (per-browser identity). This enables sharing the assessment publicly (e.g., embedding, demos, external audiences) without requiring course enrollment or login.
Approach
1. Guest user records in the users table
Add a nullable, unique guestNonce column to the users table. When a request arrives with an X-Guest-Nonce header, the API looks up or creates a guest user record with that nonce.
- Schema change:
packages/db/src/schema/users.ts— addguestNonce: text("guest_nonce").unique() - Migration: Run
bun run db:generate(never hand-write) - Guest users get:
email: "guest-<nonce-prefix>@guest",name: null,role: "student",isAdmin: false
2. Guest auth middleware
Add a guestAuthMiddleware to apps/api/src/middleware/auth.ts that:
- Checks for an
X-Guest-Nonceheader - If present, looks up the user by
guestNoncecolumn - If no user found, creates one (upsert pattern)
- Sets
c.set("user", ...)just likeauthMiddleware - If no nonce header, falls through to 401 (or could chain with regular auth)
Also create a combined authOrGuestMiddleware that tries session cookie first, then falls back to nonce header. This replaces authMiddleware on the public-eligible assessment routes.
3. API route changes
Modify routes that the public assessment flow touches. Only allow guest auth for assessments that have visibility: "public":
-
apps/api/src/routes/assessments.ts:GET /:slug— useauthOrGuestMiddleware. After loading the assessment, verifyvisibility === "public"if user is a guest (reject with 403 otherwise).POST /:slug/start— same patternGET /:slug/attempts/:attemptId— same patternGET /:slug/attempts/:attemptId/evaluation— same pattern
-
apps/api/src/routes/assessment-chat.ts:POST /— useauthOrGuestMiddleware. The attempt lookup already ensures ownership viauserId.
4. Frontend: nonce utility
Create apps/web/lib/guest-nonce.ts:
getGuestNonce(): string— reads fromlocalStoragekeyguest-nonce, or generates a UUID v4 and stores itgetGuestHeaders(): Record<string, string>— returns{ "X-Guest-Nonce": nonce }for API calls
5. Frontend: assessment detail page (/assessments/[slug])
apps/web/app/assessments/[slug]/page.tsx:
- Accept
searchParamsprop - If
?public=true:- Skip
auth.api.getSession()and the login redirect - Don't call
serverApiFetch(can't forward a nonce from server component — no localStorage) - Instead, render a new
AssessmentDetailPublicclient component that:- Fetches assessment data client-side using nonce header
- Renders the same UI as
AssessmentDetailClient
- Skip
- If no
?public=true: existing auth flow unchanged
6. Frontend: assessment chat page (/assessments/[slug]/[attemptId])
apps/web/app/assessments/[slug]/[attemptId]/page.tsx:
- Same pattern: if
?public=true, render a client component that fetches data with nonce header - Create
AssessmentChatPublicwrapper that loads attempt data client-side, then rendersAssessmentChatClient
7. Frontend: client components send nonce
Modify AssessmentDetailClient (or the public wrapper) and AssessmentChatClient:
- When in public mode, include
X-Guest-Nonceheader on allfetch()calls instead ofcredentials: "include" - Pass an
isPublicprop or use a context/hook to know which auth mode to use - Simplest: create a
useApiFetchhook or a small helper that adds the right auth (cookie vs nonce) based on mode
8. Navigation: keep ?public=true across pages
When navigating from assessment detail to the chat page (after starting an attempt), append ?public=true to the URL so the chat page also skips login.
Files to modify
| File | Change |
|---|---|
packages/db/src/schema/users.ts | Add guestNonce column |
apps/api/src/middleware/auth.ts | Add guestAuthMiddleware and authOrGuestMiddleware |
apps/api/src/routes/assessments.ts | Switch to authOrGuestMiddleware on relevant routes |
apps/api/src/routes/assessment-chat.ts | Switch to authOrGuestMiddleware |
apps/web/lib/guest-nonce.ts | New: nonce generation/retrieval utility |
apps/web/app/assessments/[slug]/page.tsx | Handle ?public=true with client-side data fetching |
apps/web/app/assessments/[slug]/client.tsx | Support nonce-based fetch (or create public wrapper) |
apps/web/app/assessments/[slug]/[attemptId]/page.tsx | Handle ?public=true |
apps/web/app/assessments/[slug]/[attemptId]/client.tsx | Support nonce-based fetch |
Security considerations
- Only assessments with
visibility: "public"accept guest nonce auth - Guest users are scoped — they can only see their own attempts (same userId filter)
- Nonce provides per-browser identity but is not cryptographically secure (acceptable for public assessments)
- Guest users cannot access any other routes (preparations, admin, etc.)
Verification
- Run
bun run db:generateandbun run db:migratefor the schema change - Start dev server (
bun run dev) - Visit
/assessments/turing-test?public=truein an incognito window (no login) - Verify: assessment details load, can start an attempt, can chat, receives evaluation
- Verify: refreshing the page preserves the session (same nonce from localStorage)
- Verify: visiting
/assessments/turing-testwithout?public=truestill requires login - Run
bun run tscandbun run lintto check for type errors
If you need specific details from before exiting plan mode (like exact code snippets, error messages, or content you generated), read the full transcript at: ~/.claude/projects/-Users-challen-www-usingandunderstanding-ai/584fcfd5-cf46-4d9b-bf85-e48f98bd05ee.jsonl
I'll implement this plan step by step. Let me start by reading the key files I need to understand.
Let me start by reading all the key files I need to understand.
Now let me read the remaining files I need.
Now let me also check the assessment evaluation component and the server-api helper.
Good. Now I have a clear picture. Let me start implementing.
Task 1: Add guestNonce column to users schema
Now generate the migration:
Local database isn't running, that's fine — the migration file is generated. Let me continue with the implementation.
Task 2: Add guest auth middleware
Now add the guest auth middleware and the combined middleware at the end of the file:
Task 3: Update API routes for guest auth
I need to check what getAssessment returns to know if visibility is available, and also check if there's a isGuestUser helper I can add.
Good, assessment.visibility is available. Now I need a helper to detect if a user is a guest. The simplest approach: check if the email matches the guest pattern. Let me update the assessments routes.
Now update GET /:slug to use authOrGuestMiddleware with visibility check:
Now update POST /:slug/start:
Now update GET /:slug/attempts/:attemptId:
Now update GET /:slug/attempts/:attemptId/evaluation:
Now update assessment-chat.ts:
Task 4: Create frontend guest nonce utility
Task 5: Update frontend assessment pages for public mode
First, update the assessment detail page to handle ?public=true:
Now create the AssessmentDetailPublic client component:
Now I need to update AssessmentDetailClient to accept an isPublic prop and use guest headers when in public mode:
Now update the handleStart to use guest headers when in public mode, and the navigation to preserve ?public=true:
Now update the attempt links in the past attempts section to also preserve ?public=true:
Also update the back link to preserve public mode:
Now update the chat page ([attemptId]/page.tsx):
Now create the AssessmentChatPublic component:
Now update AssessmentChatClient to accept isPublic and use guest headers when in public mode:
Now I need to update AssessmentChatClient to support isPublic. The key changes are:
- Add
isPublicprop - Use guest headers instead of
credentials: "include"for fetch calls - Skip timer preference save for guest users
- Update back link to preserve
?public=true - Pass
isPublictoAssessmentEvaluation
Now update the timer preference save to skip for public mode:
Now update the chat fetch to use guest headers:
Update the dependency array of handleSend to include isPublic:
Now update the back link to preserve ?public=true:
Now update the AssessmentEvaluation component to support public mode. It also makes fetch calls with credentials: "include":
Now update the AssessmentEvaluation component:
Update the useEffect dependency to include isPublic:
Task 6: Run type checks and lint
TypeScript checks pass. Now lint:
The authMiddleware import is now unused in assessments.ts. Let me fix that:
Also check that assessment-chat.ts doesn't still import authMiddleware:
Good. Now re-run lint:
Unused router in public-client.tsx. Let me remove it:
All clean. Let me also run tsc once more to confirm everything is still good after the lint fixes:
All passing. Let me also run the format check to make sure everything is properly formatted:
Good. Now let me verify the final state of lint and tsc are still clean:
Everything passes. Let me mark the final task complete.
All tasks are complete. Here's a summary of what was implemented:
Summary of changes
Database
packages/db/src/schema/users.ts— Added nullable uniqueguestNoncecolumn to theuserstablepackages/db/drizzle/0014_flawless_shiver_man.sql— Auto-generated migration
API middleware
apps/api/src/middleware/auth.ts— AddedresolveGuestUser()function that looks up or creates guest users by nonce, andauthOrGuestMiddlewarethat tries session cookie first, then falls back toX-Guest-Nonceheader
API routes
apps/api/src/routes/assessments.ts— SwitchedGET /:slug,POST /:slug/start,GET /:slug/attempts/:attemptId, andGET /:slug/attempts/:attemptId/evaluationfromauthMiddlewaretoauthOrGuestMiddleware. Guest users are rejected with 403 if the assessment isn'tvisibility: "public"apps/api/src/routes/assessment-chat.ts— SwitchedPOST /toauthOrGuestMiddleware
Frontend utilities
apps/web/lib/guest-nonce.ts— New file withgetGuestNonce()(reads/generates UUID in localStorage) andgetGuestHeaders()(returnsX-Guest-Nonceheader)
Frontend pages
apps/web/app/assessments/[slug]/page.tsx— Accepts?public=truesearchParam; rendersAssessmentDetailPublic(client-side fetch with nonce) instead of server-side auth flowapps/web/app/assessments/[slug]/public-client.tsx— New client component that fetches assessment data with guest headers and rendersAssessmentDetailClientapps/web/app/assessments/[slug]/client.tsx— AddedisPublicprop; uses guest headers instead of cookies when public; preserves?public=truein navigation links; hides "back to all assessments" link for public usersapps/web/app/assessments/[slug]/[attemptId]/page.tsx— Same?public=truepattern; rendersAssessmentChatPublicfor public modeapps/web/app/assessments/[slug]/[attemptId]/public-client.tsx— New client component for public chat pageapps/web/app/assessments/[slug]/[attemptId]/client.tsx— AddedisPublicprop; uses guest headers for chat fetch; skips preference saving for guests; preserves?public=truein back linkapps/web/app/assessments/[slug]/[attemptId]/components/assessment-evaluation.tsx— AddedisPublicprop; uses guest headers for evaluation fetch
Not sure what cause this, but public seems to be working! Amazing. [02:20:16.642] WARN (assessment-evaluator/54025): Failed to parse evaluator response, using fallback
@repo/api:dev: raw: ""
@repo/api:dev: err: {
@repo/api:dev: "type": "SyntaxError",
@repo/api:dev: "message": "JSON Parse error: Unexpected EOF",
@repo/api:dev: "stack":
@repo/api:dev: SyntaxError: JSON Parse error: Unexpected EOF
@repo/api:dev: at <parse> (:0)
@repo/api:dev: at parse (unknown)
@repo/api:dev: at parseEvaluatorResponse (/www/usingandunderstanding.ai/apps/api/src/services/assessment-evaluator.ts:281:25)
@repo/api:dev: at <anonymous> (/www/usingandunderstanding.ai/apps/api/src/routes/assessment-chat.ts:215:16)
@repo/api:dev: at async dispatch (/www/usingandunderstanding.ai/node_modules/.bun/hono@4.11.7/node_modules/hono/dist/compose.js:22:23)
@repo/api:dev: at async <anonymous> (/www/usingandunderstanding.ai/node_modules/.bun/hono@4.11.7/node_modules/hono/dist/validator/validator.js:81:18)
@repo/api:dev: at async dispatch (/www/usingandunderstanding.ai/node_modules/.bun/hono@4.11.7/node_modules/hono/dist/compose.js:22:23)
@repo/api:dev: at async <anonymous> (/www/usingandunderstanding.ai/apps/api/src/middleware/auth.ts:351:9)
@repo/api:dev: at async dispatch (/www/usingandunderstanding.ai/node_modules/.bun/hono@4.11.7/node_modules/hono/dist/compose.js:22:23)
@repo/api:dev: at async cors2 (/www/usingandunderstanding.ai/node_modules/.bun/hono@4.11.7/node_modules/hono/dist/middleware/cors/index.js:79:11)
@repo/api:dev: at async dispatch (/www/usingandunderstanding.ai/node_modules/.bun/hono@4.11.7/node_modules/hono/dist/compose.js:22:23)
@repo/api:dev: at async <anonymous> (/www/usingandunderstanding.ai/apps/api/src/middleware/request-logger.ts:8:9)
@repo/api:dev: at async dispatch (/www/usingandunderstanding.ai/node_modules/.bun/hono@4.11.7/node_modules/hono/dist/compose.js:22:23)
@repo/api:dev: at async <anonymous> (/www/usingandunderstanding.ai/node_modules/.bun/hono@4.11.7/node_modules/hono/dist/hono-base.js:301:31)
@repo/api:dev: at processTicksAndRejections (native:7:39)
@repo/api:dev: }
That warning is unrelated to the public access changes — it's the evaluator's existing fallback path when chatJSON returns an empty response (the catch block at assessment-chat.ts:213-216 catches the JSON parse failure and falls back to the default state). This can happen intermittently when the LLM returns an empty or malformed response. The assessment should still continue working since the fallback state keeps things moving.
OK, if this is expected, can we silence the log noise?
Also check the parseEvaluatorResponse warning that was in the stack trace:
Downgraded both log messages from warn to debug — they'll still be recorded but won't show in normal log output.
Your task
First, run git status, git diff, and git log --oneline -10 to understand the current state.
Then add and commit all modified files with a descriptive message. Break the changes into multiple commits if appropriate. If there are untracked files, ask if they should be added or added to the appropriate .gitignore file. The user may also want to take over at this point, so provide an abort option as well. Always check changes for unnecessary debugging statements or logging that might have been accidentally included. Prompt the user if you find any, and offer to remove each one by one. Do not use interactive git staging (git add -p) as it does not work.
I'll check the current git state and then commit the changes.
Now let me check if there are any debugging statements or console.logs in the new files:
Perfect! The changes look clean. Let me organize these into logical commits:
- Database migration - Add guest nonce support
- API changes - Add guest auth middleware and update routes
- Frontend changes - Add public assessment access
- Logging cleanup - Reduce log noise from evaluator fallbacks
Let me present the plan:
Commit Plan
I'll create the following commits:
-
Add guestNonce column to users table for guest authentication
packages/db/src/schema/users.tspackages/db/drizzle/0014_flawless_shiver_man.sqlpackages/db/drizzle/meta/0014_snapshot.jsonpackages/db/drizzle/meta/_journal.json
-
Add guest auth middleware and update API routes for public assessments
apps/api/src/middleware/auth.tsapps/api/src/routes/assessments.tsapps/api/src/routes/assessment-chat.ts
-
Add public assessment access via ?public=true query parameter
apps/web/lib/guest-nonce.ts(new)apps/web/app/assessments/[slug]/page.tsxapps/web/app/assessments/[slug]/client.tsxapps/web/app/assessments/[slug]/public-client.tsx(new)apps/web/app/assessments/[slug]/[attemptId]/page.tsxapps/web/app/assessments/[slug]/[attemptId]/client.tsxapps/web/app/assessments/[slug]/[attemptId]/public-client.tsx(new)apps/web/app/assessments/[slug]/[attemptId]/components/assessment-evaluation.tsx
-
Reduce evaluator fallback log noise from warn to debug
apps/api/src/services/assessment-evaluator.ts
Does this look good? Would you like me to:
- Proceed with these commits?
- Combine some commits?
- Split commits differently?
- Abort and let you handle the commits manually?
Proceed.
All four commits are clean. Working tree is clean.
OK! Now, let's support another query parameter &embed=true. When set, embed should hide all page chrome, including the top bar and footer.
Good, the codebase already has a CSS-based pattern for hiding chrome (used for print pages). Let me write the plan.