Plan: Chrome Page Capture Extension + Profile Page
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 authidtext PK (crypto.randomUUID)userIdtext FK → users.id, unique, onDelete cascadetokentext, unique (crypto.randomUUID)createdAttimestamp
-
pageCaptures— stores captured page contentidtext PK (crypto.randomUUID)userIdtext FK → users.id, onDelete cascadeurltexttitletext (nullable)contenttext (the DOM innerHTML)metadatajsonb (nullable — browser info, timestamp, etc.)createdAttimestamp- 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, gravatarUrlPATCH /api/profile— updatespreferredName(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
captureTokenstable 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 fromchrome-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=/profileif not) - Fetches
/api/profileviaserverApiFetch - 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/capturesviaserverApiFetch
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,scriptingpopup.html+popup.js— Capture button, status displayoptions.html+options.js— text input for capture URL, saves to[local-hostname]- Icon PNGs (16, 48, 128)
Popup flow:
- Check
[local-hostname]forcaptureUrl— if missing, show "Configure in options" message - On "Capture" click:
chrome.scripting.executeScripton active tab to grab{ url, title, content: document.documentElement.innerHTML } - POST to the captureUrl with JSON body
- 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
| Action | File |
|---|---|
| Create | packages/db/src/schema/page-captures.ts |
| Modify | packages/db/src/schema/users.ts (add preferredName) |
| Modify | packages/db/src/schema/index.ts (add export) |
| Create | apps/api/src/lib/gravatar.ts |
| Create | apps/api/src/routes/profile.ts |
| Create | apps/api/src/routes/captures.ts |
| Create | apps/api/src/routes/admin/capture-tokens.ts |
| Create | apps/api/src/routes/admin/captures.ts |
| Modify | apps/api/src/index.ts (register 4 routes) |
| Create | apps/web/app/profile/page.tsx |
| Create | apps/web/app/profile/client.tsx |
| Create | apps/web/app/capture/page.tsx |
| Create | apps/web/app/capture/client.tsx |
| Modify | apps/web/components/header.tsx (add capture nav) |
| Modify | apps/web/components/user-menu.tsx (add profile link) |
| Create | tools/chrome-extension/manifest.json |
| Create | tools/chrome-extension/popup.html |
| Create | tools/chrome-extension/popup.js |
| Create | tools/chrome-extension/options.html |
| Create | tools/chrome-extension/options.js |
Existing Patterns to Reuse
serverApiFetch(apps/web/lib/server-api.ts) — server-side API calls with cookie forwardingauthMiddleware,requireRole(apps/api/src/middleware/auth.ts) — auth + role gatingrateLimit(apps/api/src/middleware/rate-limit.ts) — rate limitingzValidatorfrom@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
- Database:
bun run db:generateproduces migration,bun run db:migrateapplies it - Type check:
bun run tscpasses - Profile page: Log in, navigate to
/profile, see name/email/Gravatar, edit preferred name, save - Capture token: Log in as instructor, visit
/profile, generate token, copy URL - Capture API:
curl -X POST "<captureUrl>" -H "Content-Type: application/json" -d '{"url":"https://example.com","content":"<html>test</html>"}'→ 201 - CORS:
curl -X OPTIONS "<captureUrl>" -H "Origin: chrome-extension://test" -H "Access-Control-Request-Method: POST" -v→ returns allow headers - Capture browsing: Visit
/captureas instructor, see the test capture from step 5 - 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.ts—captureTokensandpageCapturestables - Modified:
schema/users.ts— addedpreferredNamecolumn - 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 utilitysrc/routes/profile.ts—GET/PATCH /api/profile(auth required)src/routes/captures.ts—POST /api/captures?token=...(token auth, rate limited, CORS enabled)src/routes/admin/capture-tokens.ts—GET/POST/DELETE /api/admin/capture-tokens(instructor/staff)src/routes/admin/captures.ts—GET /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 tokenapp/profile/client.tsx— Gravatar avatar, preferred name editing, capture token management with copy URLapp/capture/page.tsx— server component, auth + instructor gatedapp/capture/client.tsx— expandable list of captures with lazy-loaded full content- Modified
components/header.tsx— added/captureto 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 permissionspopup.html/js— capture button, shows page title/URL, posts to configured capture URLoptions.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)
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).
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 thetools/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
/profilealready 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?
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://extensionsdrag-and-drop of a.crxfile — 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.
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.jsscript on the website - Navigating to a URL ending in
.user.jstriggers 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:
- Create a
public/capture.user.jsserved from the website - Add an install link on the
/profilepage (e.g., "Install Capture Script") - Remove
tools/chrome-extension/
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:
-
Per-user scripts: The
/profilepage links to an API endpoint like/api/admin/capture-tokens/script.user.jsthat returns a personalized userscript with the token baked in. No configuration step needed. -
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.jsURL and prompts to install - On any page, Tampermonkey menu shows "Capture This Page"
Want me to go this route and replace the Chrome extension?
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.