All Sessions

Plan: Public Access to Turing Test Assessment via Nonce

February 7, 20262:10 AM2:29 AM(19 min)7 turns
Model: claude-opus-4-6, claude-sonnet-4-5-20250929
Edit32Bash19Read18TaskUpdate12TaskCreate6Write6Grep3Glob1EnterPlanMode1Task1ExitPlanMode1
2:10 AM

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 — add guestNonce: 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-Nonce header
  • If present, looks up the user by guestNonce column
  • If no user found, creates one (upsert pattern)
  • Sets c.set("user", ...) just like authMiddleware
  • 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 — use authOrGuestMiddleware. After loading the assessment, verify visibility === "public" if user is a guest (reject with 403 otherwise).
    • POST /:slug/start — same pattern
    • GET /:slug/attempts/:attemptId — same pattern
    • GET /:slug/attempts/:attemptId/evaluation — same pattern
  • apps/api/src/routes/assessment-chat.ts:

    • POST / — use authOrGuestMiddleware. The attempt lookup already ensures ownership via userId.

4. Frontend: nonce utility

Create apps/web/lib/guest-nonce.ts:

  • getGuestNonce(): string — reads from localStorage key guest-nonce, or generates a UUID v4 and stores it
  • getGuestHeaders(): 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 searchParams prop
  • 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 AssessmentDetailPublic client component that:
      • Fetches assessment data client-side using nonce header
      • Renders the same UI as AssessmentDetailClient
  • 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 AssessmentChatPublic wrapper that loads attempt data client-side, then renders AssessmentChatClient

7. Frontend: client components send nonce

Modify AssessmentDetailClient (or the public wrapper) and AssessmentChatClient:

  • When in public mode, include X-Guest-Nonce header on all fetch() calls instead of credentials: "include"
  • Pass an isPublic prop or use a context/hook to know which auth mode to use
  • Simplest: create a useApiFetch hook 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

FileChange
packages/db/src/schema/users.tsAdd guestNonce column
apps/api/src/middleware/auth.tsAdd guestAuthMiddleware and authOrGuestMiddleware
apps/api/src/routes/assessments.tsSwitch to authOrGuestMiddleware on relevant routes
apps/api/src/routes/assessment-chat.tsSwitch to authOrGuestMiddleware
apps/web/lib/guest-nonce.tsNew: nonce generation/retrieval utility
apps/web/app/assessments/[slug]/page.tsxHandle ?public=true with client-side data fetching
apps/web/app/assessments/[slug]/client.tsxSupport nonce-based fetch (or create public wrapper)
apps/web/app/assessments/[slug]/[attemptId]/page.tsxHandle ?public=true
apps/web/app/assessments/[slug]/[attemptId]/client.tsxSupport 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

  1. Run bun run db:generate and bun run db:migrate for the schema change
  2. Start dev server (bun run dev)
  3. Visit /assessments/turing-test?public=true in an incognito window (no login)
  4. Verify: assessment details load, can start an attempt, can chat, receives evaluation
  5. Verify: refreshing the page preserves the session (same nonce from localStorage)
  6. Verify: visiting /assessments/turing-test without ?public=true still requires login
  7. Run bun run tsc and bun run lint to 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:

  1. Add isPublic prop
  2. Use guest headers instead of credentials: "include" for fetch calls
  3. Skip timer preference save for guest users
  4. Update back link to preserve ?public=true
  5. Pass isPublic to AssessmentEvaluation

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 unique guestNonce column to the users table
  • packages/db/drizzle/0014_flawless_shiver_man.sql — Auto-generated migration

API middleware

  • apps/api/src/middleware/auth.ts — Added resolveGuestUser() function that looks up or creates guest users by nonce, and authOrGuestMiddleware that tries session cookie first, then falls back to X-Guest-Nonce header

API routes

  • apps/api/src/routes/assessments.ts — Switched GET /:slug, POST /:slug/start, GET /:slug/attempts/:attemptId, and GET /:slug/attempts/:attemptId/evaluation from authMiddleware to authOrGuestMiddleware. Guest users are rejected with 403 if the assessment isn't visibility: "public"
  • apps/api/src/routes/assessment-chat.ts — Switched POST / to authOrGuestMiddleware

Frontend utilities

  • apps/web/lib/guest-nonce.ts — New file with getGuestNonce() (reads/generates UUID in localStorage) and getGuestHeaders() (returns X-Guest-Nonce header)

Frontend pages

  • apps/web/app/assessments/[slug]/page.tsx — Accepts ?public=true searchParam; renders AssessmentDetailPublic (client-side fetch with nonce) instead of server-side auth flow
  • apps/web/app/assessments/[slug]/public-client.tsx — New client component that fetches assessment data with guest headers and renders AssessmentDetailClient
  • apps/web/app/assessments/[slug]/client.tsx — Added isPublic prop; uses guest headers instead of cookies when public; preserves ?public=true in navigation links; hides "back to all assessments" link for public users
  • apps/web/app/assessments/[slug]/[attemptId]/page.tsx — Same ?public=true pattern; renders AssessmentChatPublic for public mode
  • apps/web/app/assessments/[slug]/[attemptId]/public-client.tsx — New client component for public chat page
  • apps/web/app/assessments/[slug]/[attemptId]/client.tsx — Added isPublic prop; uses guest headers for chat fetch; skips preference saving for guests; preserves ?public=true in back link
  • apps/web/app/assessments/[slug]/[attemptId]/components/assessment-evaluation.tsx — Added isPublic prop; uses guest headers for evaluation fetch
Edit29Read15TaskUpdate12Bash10TaskCreate6Write5Grep2Glob1
2:19 AM
2:21 AM

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.

2:22 AM

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.

Edit3Read2Grep1
2:24 AM

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:

  1. Database migration - Add guest nonce support
  2. API changes - Add guest auth middleware and update routes
  3. Frontend changes - Add public assessment access
  4. Logging cleanup - Reduce log noise from evaluator fallbacks

Let me present the plan:

Commit Plan

I'll create the following commits:

  1. Add guestNonce column to users table for guest authentication

    • packages/db/src/schema/users.ts
    • packages/db/drizzle/0014_flawless_shiver_man.sql
    • packages/db/drizzle/meta/0014_snapshot.json
    • packages/db/drizzle/meta/_journal.json
  2. Add guest auth middleware and update API routes for public assessments

    • apps/api/src/middleware/auth.ts
    • apps/api/src/routes/assessments.ts
    • apps/api/src/routes/assessment-chat.ts
  3. Add public assessment access via ?public=true query parameter

    • apps/web/lib/guest-nonce.ts (new)
    • apps/web/app/assessments/[slug]/page.tsx
    • apps/web/app/assessments/[slug]/client.tsx
    • apps/web/app/assessments/[slug]/public-client.tsx (new)
    • apps/web/app/assessments/[slug]/[attemptId]/page.tsx
    • apps/web/app/assessments/[slug]/[attemptId]/client.tsx
    • apps/web/app/assessments/[slug]/[attemptId]/public-client.tsx (new)
    • apps/web/app/assessments/[slug]/[attemptId]/components/assessment-evaluation.tsx
  4. 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?
Bash4
2:25 AM

Proceed.

All four commits are clean. Working tree is clean.

Bash5
2:27 AM

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.

EnterPlanMode1Task1Read1Write1ExitPlanMode1