All Sessions

Plan: Chrome Page Capture Extension + Profile Page

February 24, 20267:07 PM6:18 AM(11h 11m)7 turns
Model: claude-opus-4-6
Read21Write16TaskUpdate10Bash10Edit9Grep6TaskCreate5Glob1EnterPlanMode1Task1ExitPlanMode1
7:07 PM

Implement the following plan:

Plan: Chrome Page Capture Extension + Profile Page

Context

The course needs a way for instructors to capture web page content (URL + current DOM) for later processing. This requires: a Chrome extension to capture pages, a backend API to receive and store captures, and a way for instructors to get an auth token for the extension. Alongside this, we'll create a /profile page (which doesn't exist yet) where all users can see their info, set a preferred name, and see their Gravatar. Instructors additionally see their capture token on this page. A separate /capture page lets instructors browse captured pages.

Scope

1. Database Schema

New file: packages/db/src/schema/page-captures.ts

Two new tables:

  • captureTokens — one token per user (unique on userId), used by the extension for auth

    • id text PK (crypto.randomUUID)
    • userId text FK → users.id, unique, onDelete cascade
    • token text, unique (crypto.randomUUID)
    • createdAt timestamp
  • pageCaptures — stores captured page content

    • id text PK (crypto.randomUUID)
    • userId text FK → users.id, onDelete cascade
    • url text
    • title text (nullable)
    • content text (the DOM innerHTML)
    • metadata jsonb (nullable — browser info, timestamp, etc.)
    • createdAt timestamp
    • Indexes on userId and createdAt

Add preferredName column to users table in packages/db/src/schema/users.ts:

  • preferredName: text("preferred_name") — nullable, user-editable

Modify: packages/db/src/schema/index.ts — add export * from "./page-captures"

Then: bun run db:generate && bun run db:migrate

2. API Routes

2a. Profile route: apps/api/src/routes/profile.ts

  • GET /api/profile — returns current user's name, email, preferredName, role, gravatarUrl
  • PATCH /api/profile — updates preferredName (zod validated, max 50 chars)
  • Uses authMiddleware
  • Gravatar URL: compute from email using MD5 hash → https://www.gravatar.com/avatar/{hash}?d=mp&s=200

2b. Capture token management: apps/api/src/routes/admin/capture-tokens.ts

  • GET /api/admin/capture-tokens — get current user's token + captureUrl (or null)
  • POST /api/admin/capture-tokens — generate/regenerate token (delete old, insert new)
  • DELETE /api/admin/capture-tokens — revoke token
  • All gated with authMiddleware + requireRole("instructor", "staff")
  • captureUrl = ${API_URL}/api/captures?token=${token}

2c. Capture upload: apps/api/src/routes/captures.ts

  • POST /api/captures?token=<uuid> — token-based auth (no session needed)
  • Validates token against captureTokens table to identify user
  • Zod schema: { url: string().url(), title: string().optional(), content: string().min(1).max(10_000_000), metadata: record().optional() }
  • Rate limited: 10/min, 100/day (IP-based since no session)
  • Route-level CORS override (Access-Control-Allow-Origin: *) since extension sends from chrome-extension:// origin — safe because the token is the security boundary, not the origin

2d. Capture browsing: apps/api/src/routes/admin/captures.ts

  • GET /api/admin/captures — list captures (paginated, newest first)
  • GET /api/admin/captures/:id — get single capture with full content
  • Gated with authMiddleware + requireRole("instructor", "staff")

2e. Register routes in apps/api/src/index.ts

app.route("/api/profile", profileRoutes);
app.route("/api/captures", captureRoutes);
app.route("/api/admin/capture-tokens", adminCaptureTokensRouter);
app.route("/api/admin/captures", adminCapturesRouter);

3. Web App Pages

3a. Profile page: apps/web/app/profile/page.tsx + apps/web/app/profile/client.tsx

Server component (page.tsx):

  • Requires auth (redirect to /login?returnTo=/profile if not)
  • Fetches /api/profile via serverApiFetch
  • If instructor, also fetches /api/admin/capture-tokens
  • Passes data to client component

Client component (client.tsx):

  • User info section: Gravatar avatar (large), name, email, role badge
  • Preferred name: editable text input with save button, PATCHes to /api/profile
  • Gravatar note: small text explaining "Your photo comes from Gravatar" with link to gravatar.com
  • Capture token section (instructor-only): shows magic URL with copy button, generate/regenerate/revoke buttons
  • Extension instructions (instructor-only): brief setup steps

Styling: matches site patterns — border-border, bg-background, text-foreground, mono fonts, accent colors

3b. Capture browsing page: apps/web/app/capture/page.tsx + apps/web/app/capture/client.tsx

Server component (page.tsx):

  • Requires auth + instructor role
  • Fetches /api/admin/captures via serverApiFetch

Client component (client.tsx):

  • Table/list of captures: URL (linked), title, date, truncated content preview
  • Click to expand and see full captured content

3c. Navigation updates

apps/web/components/header.tsx — add capture to instructor nav items:

...(isInstructor ? [
  { href: "/roster", label: "roster" },
  { href: "/capture", label: "capture" },
] : []),

apps/web/components/user-menu.tsx — add Profile link above Sign out

4. Chrome Extension: tools/chrome-extension/

Manifest V3, standalone directory (not a Bun package).

Files:

  • manifest.json — permissions: activeTab, storage, scripting
  • popup.html + popup.js — Capture button, status display
  • options.html + options.js — text input for capture URL, saves to [local-hostname]
  • Icon PNGs (16, 48, 128)

Popup flow:

  1. Check [local-hostname] for captureUrl — if missing, show "Configure in options" message
  2. On "Capture" click: chrome.scripting.executeScript on active tab to grab { url, title, content: document.documentElement.innerHTML }
  3. POST to the captureUrl with JSON body
  4. Show success/error status

Styling: match site look — dark background, accent blue, mono font, minimal layout

5. Gravatar Utility

New file: apps/api/src/lib/gravatar.ts

import { createHash } from "crypto";
export function getGravatarUrl(email: string, size = 200): string {
  const hash = createHash("md5").update(email.trim().toLowerCase()).digest("hex");
  return `https://www.gravatar.com/avatar/${hash}?d=mp&s=${size}`;
}

d=mp = "mystery person" silhouette fallback for users without Gravatar accounts.

Files Summary

ActionFile
Createpackages/db/src/schema/page-captures.ts
Modifypackages/db/src/schema/users.ts (add preferredName)
Modifypackages/db/src/schema/index.ts (add export)
Createapps/api/src/lib/gravatar.ts
Createapps/api/src/routes/profile.ts
Createapps/api/src/routes/captures.ts
Createapps/api/src/routes/admin/capture-tokens.ts
Createapps/api/src/routes/admin/captures.ts
Modifyapps/api/src/index.ts (register 4 routes)
Createapps/web/app/profile/page.tsx
Createapps/web/app/profile/client.tsx
Createapps/web/app/capture/page.tsx
Createapps/web/app/capture/client.tsx
Modifyapps/web/components/header.tsx (add capture nav)
Modifyapps/web/components/user-menu.tsx (add profile link)
Createtools/chrome-extension/manifest.json
Createtools/chrome-extension/popup.html
Createtools/chrome-extension/popup.js
Createtools/chrome-extension/options.html
Createtools/chrome-extension/options.js

Existing Patterns to Reuse

  • serverApiFetch (apps/web/lib/server-api.ts) — server-side API calls with cookie forwarding
  • authMiddleware, requireRole (apps/api/src/middleware/auth.ts) — auth + role gating
  • rateLimit (apps/api/src/middleware/rate-limit.ts) — rate limiting
  • zValidator from @hono/zod-validator — request validation
  • Roster page pattern (apps/web/app/roster/page.tsx) — instructor-only page with serverApiFetch
  • Preferences route pattern (apps/api/src/routes/preferences.ts) — PATCH to update user fields
  • Avatar, AvatarImage, AvatarFallback (apps/web/components/ui/avatar.tsx) — avatar display

Verification

  1. Database: bun run db:generate produces migration, bun run db:migrate applies it
  2. Type check: bun run tsc passes
  3. Profile page: Log in, navigate to /profile, see name/email/Gravatar, edit preferred name, save
  4. Capture token: Log in as instructor, visit /profile, generate token, copy URL
  5. Capture API: curl -X POST "<captureUrl>" -H "Content-Type: application/json" -d '{"url":"https://example.com","content":"<html>test</html>"}' → 201
  6. CORS: curl -X OPTIONS "<captureUrl>" -H "Origin: chrome-extension://test" -H "Access-Control-Request-Method: POST" -v → returns allow headers
  7. Capture browsing: Visit /capture as instructor, see the test capture from step 5
  8. Extension: Load unpacked from tools/chrome-extension/, configure URL in options, capture a page, verify it appears on /capture

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/3ff2a7d8-46c0-4bc0-99a9-ef90615cc8c8.jsonl

I'll implement this plan step by step. Let me start by reading the existing files I need to understand and modify.

Let me read all the existing files I need to understand.

Now let me read the existing patterns I need to follow.

Now let me read the web app files I need to modify.

Now let me read the roster page pattern and server-api for reference.

Now let me look at an existing admin route for patterns and also check existing schema files.

Now let me look at an admin route for pattern reference.

Good, I have a solid understanding of all the patterns. Let me also check if there's an existing index import pattern for drizzle.

Good. Now I have all the context I need. Let me start implementing.

Step 1: Database Schema

Now generate and apply the migration.

Database schema is done.

Step 2: API Routes

First, the gravatar utility.

Now create the profile route.

Now the capture tokens admin route.

Now the capture upload route (public, token-based auth).

Now the admin captures browsing route.

Now register all routes in the API index.

Step 3: Web App Pages

Let me check the existing UI components available and auth patterns.

Let me check what UI components exist and how auth/login redirect pattern works.

Let me check the lib/auth-client for the web side and see what useSession provides.

Now let me check what the ExternalLink component looks like for reference.

Now let me check a client-side fetch pattern for the web app (how other pages do client-side API calls).

Now I have the full picture. Let me create the profile page (server component + client component).

Now the capture browsing page.

Now update the header and user menu navigation.

Now add Profile link to the user menu.

Step 4: Chrome Extension

Now let me generate simple icon PNGs. I'll create them as simple SVG-based data URIs converted to PNG.

Step 5: Verify Build

There's a TS error in captures.ts line 16. Let me check how zValidator is used elsewhere.

The error is at captures.ts line 16: z.record(z.unknown()). Some zod versions require the key type argument. Let me fix it.

All 18 packages pass TypeScript checks. Let me also run lint.

The only lint error is a pre-existing issue in markov-babbler.tsx (unused model variable) — not related to my changes. All my new code passes lint and tsc cleanly.

All tasks complete. Here's a summary of everything implemented:

Summary

Database (packages/db)

  • New file: schema/page-captures.tscaptureTokens and pageCaptures tables
  • Modified: schema/users.ts — added preferredName column
  • Modified: schema/index.ts — exports new schema
  • Migration generated and applied (0016_nebulous_whiplash.sql)

API Routes (apps/api)

  • src/lib/gravatar.ts — MD5-based Gravatar URL utility
  • src/routes/profile.tsGET/PATCH /api/profile (auth required)
  • src/routes/captures.tsPOST /api/captures?token=... (token auth, rate limited, CORS enabled)
  • src/routes/admin/capture-tokens.tsGET/POST/DELETE /api/admin/capture-tokens (instructor/staff)
  • src/routes/admin/captures.tsGET /api/admin/captures, GET /api/admin/captures/:id (instructor/staff)
  • Modified src/index.ts — registered all 4 new route groups

Web App (apps/web)

  • app/profile/page.tsx — server component, auth-gated, fetches profile + capture token
  • app/profile/client.tsx — Gravatar avatar, preferred name editing, capture token management with copy URL
  • app/capture/page.tsx — server component, auth + instructor gated
  • app/capture/client.tsx — expandable list of captures with lazy-loaded full content
  • Modified components/header.tsx — added /capture to instructor nav
  • Modified components/user-menu.tsx — added Profile link above Sign out

Chrome Extension (tools/chrome-extension)

  • manifest.json — Manifest V3 with activeTab, storage, scripting permissions
  • popup.html/js — capture button, shows page title/URL, posts to configured capture URL
  • options.html/js — text input for capture URL, saves to [local-hostname]
  • Icon PNGs — 16x16, 48x48, 128x128

Verification

  • bun run tsc — passes (18/18 packages)
  • bun run lint — only pre-existing error in markov-babbler.tsx (not from my changes)
Read19Write15TaskUpdate10Edit8Bash8TaskCreate5Grep5Glob1
7:15 PM

Run bun check and fix the errors, please! Thanks.

The only error is the pre-existing unused model variable in markov-babbler.tsx. Let me fix it.

model is never read — only setModel and modelRef are used. I'll prefix it with _.

All clean. bun run check passes — lint, tsc, and prettier all succeeded (36/36 tasks).

Bash2Read1Grep1Edit1
7:22 PM

What's the best way to distribute the Chrome plugin? Can it be installed directly from this website?

Chrome extensions can't be installed directly from a website for regular users — Chrome blocks this for security. Here are the realistic options:

1. Chrome Web Store (public or unlisted)

  • Unlisted listing is probably ideal — only people with the direct link can find it. No review queue for unlisted extensions. One-time $5 developer fee.
  • Installation is one click for anyone with the link.

2. Load unpacked (current approach)

  • Works fine for just you and a few staff. No publishing needed.
  • Requires enabling Developer Mode in chrome://extensions, then pointing to the tools/chrome-extension/ folder.
  • Downside: Chrome nags about developer mode extensions on every launch.

3. Self-host with enterprise policy

  • Organizations can force-install extensions via Chrome enterprise policy (managed devices). Illinois IT could theoretically do this, but it's heavy machinery for a small tool.

For your use case (instructors/staff only), I'd suggest:

  • Short term: Load unpacked. The setup instructions on /profile already cover this, and your instructor audience is small.
  • If it becomes annoying: Publish as an unlisted Chrome Web Store extension. You'd add a tools/chrome-extension/ zip build step and publish once. Then just share the Web Store link on the profile page instead of the "load unpacked" instructions.

Want me to set it up for Chrome Web Store publishing (adding a zip script and updating the instructions), or is load-unpacked fine for now?

7:25 PM

I think it's fine for now. But there's no way to just install with a link from the website?

No. Chrome removed that capability (called "inline installation") back in 2018. Even Chrome Web Store extensions can't be installed via a website link that triggers installation directly — the link just takes you to the Web Store page where you click "Add to Chrome."

The only ways to install outside the Chrome Web Store are:

  • Load unpacked (developer mode)
  • Enterprise policy (managed devices)
  • chrome://extensions drag-and-drop of a .crx file — but Chrome will disable it on next restart unless it's policy-installed

So for a small instructor audience, load unpacked is the practical path.

7:27 PM

Actually, I wonder if you could do this with a Greasemonkey extension?

That's a great idea. A userscript (Greasemonkey/Tampermonkey/Violentmonkey) would be much simpler to distribute:

  • Users install Tampermonkey once (from Chrome Web Store)
  • You host the .user.js script on the website
  • Navigating to a URL ending in .user.js triggers Tampermonkey's install prompt automatically
  • Updates can be automatic (Tampermonkey polls the @updateURL)

The script would just add a floating "Capture" button to every page. Click it, it POSTs the DOM to the capture URL (stored in GM_setValue). A settings prompt on first use asks for the capture URL.

The tradeoff is you lose the nice popup/options UI, but for "click to capture current page" it's arguably better — no extension to build, no icons, no manifest, works across browsers (Firefox/Greasemonkey too).

Want me to replace the Chrome extension with a userscript approach? I'd:

  1. Create a public/capture.user.js served from the website
  2. Add an install link on the /profile page (e.g., "Install Capture Script")
  3. Remove tools/chrome-extension/
7:28 PM

But how would you configure the URL? Or I guess we could create custom scripts for individual users? And I don't love the idea of a floating capture button. Is there any way to control these from the Greasemonkey menu?

Yes to both:

  1. Per-user scripts: The /profile page links to an API endpoint like /api/admin/capture-tokens/script.user.js that returns a personalized userscript with the token baked in. No configuration step needed.

  2. Menu commands: GM_registerMenuCommand("Capture This Page", callback) adds an item to the Tampermonkey/Greasemonkey dropdown menu. No floating button, no page injection. User clicks the Tampermonkey icon, clicks "Capture This Page", done.

So the flow becomes:

  • Instructor generates token on /profile
  • Clicks "Install Capture Script" (links to their personalized .user.js)
  • Tampermonkey detects the .user.js URL and prompts to install
  • On any page, Tampermonkey menu shows "Capture This Page"

Want me to go this route and replace the Chrome extension?

6:16 AM

Yes.

Let me explore the existing code to understand what needs to change.

Good, I have full context. Now let me plan the replacement approach.

Now let me write the updated plan.

EnterPlanMode1Task1Read1Write1ExitPlanMode1