Chrome Extension
The extension is the primary client. It's a Manifest V3 extension with a background service worker, one or more user-facing surfaces (side panel and/or new tab page), a toolbar action for submissions, and an options page for settings.
The extension does not exist yet. This doc describes the architecture it will follow.
1. Surfaces and when to use each
Chrome MV3 gives four places a UI can live. Each has different constraints:
| Surface | Size | Lifetime | Use for |
|---|---|---|---|
| Popup | Constrained (~800×600 max) | Closes when focus leaves | Quick actions: "submit this page" — not primary browsing |
| Side panel | Full height, ~360–500 px wide | Pinnable; persists across tabs | Ambient browse — read alongside a web page |
| New tab page | Full viewport | Replaces Chrome's new-tab | Destination browse — "my start page" |
| Options page | Opens in a new tab | On demand | Settings, auth, advanced controls |
Recommended starting scope for v1:
- New tab page as the main browse UI (feed, filters, detail view).
- Toolbar action (popup OR direct action) for submit-this-page.
- Options page for login/logout, primary-color override, keyboard shortcuts.
- Side panel deferred to v2.
2. Directory layout (future extension/)
extension/
├── manifest.json MV3 manifest
├── package.json Build tooling
├── src/
│ ├── sw/ Service worker (background)
│ │ ├── index.ts Entry
│ │ ├── api-client.ts Wraps fetch + auth refresh
│ │ ├── storage.ts Typed wrapper over chrome.storage.local
│ │ ├── catalog-cache.ts Catalog fetch + ETag revalidation
│ │ └── messages.ts chrome.runtime message router
│ ├── newtab/ New tab page
│ │ ├── index.html
│ │ ├── index.tsx App root
│ │ ├── routes/ Internal routing
│ │ ├── components/
│ │ └── hooks/ useCatalog, useAuth, useContentSearch, etc.
│ ├── popup/ Toolbar popup
│ │ ├── index.html
│ │ └── index.tsx Submit-this-page UI
│ ├── options/
│ │ ├── index.html
│ │ └── index.tsx
│ ├── shared/ Types + utilities shared across surfaces
│ │ ├── api-types.ts Mirrors envelope + DTOs
│ │ └── config.ts Reads /meta once per session
│ └── assets/ Icons, logo fallback
└── public/ Static files (copied into dist at build time)
UI framework choice is deferred — React + Vite or Svelte are both fine. Pick one when the extension gets started.
3. Manifest
Minimum manifest.json (filled in once names and IDs are locked):
{
"manifest_version": 3,
"name": "<APP_NAME>", // only static string; see CONFIG.md §5
"version": "0.1.0",
"description": "<APP_DESCRIPTION>",
"action": {
"default_popup": "popup/index.html",
"default_icon": { "16": "assets/icon-16.png", "32": "assets/icon-32.png" }
},
"chrome_url_overrides": { "newtab": "newtab/index.html" },
"options_page": "options/index.html",
"background": { "service_worker": "sw/index.js", "type": "module" },
"permissions": ["storage", "activeTab"],
"host_permissions": ["https://<API_DOMAIN>/*"],
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self';"
}
}
Notes:
manifest.nameis the one place the project name is hardcoded. See CONFIG.md §5.host_permissionslists the API origin explicitly.<all_urls>is not needed and raises Web Store review bar.- No remote
<script>tags. CSP is strict. activeTabpermission grants access to the currently focused tab only when the user triggers the action — used by the submit flow to readtab.urlandtab.title.
4. Service worker lifecycle
MV3 service workers are not persistent. They spin up on events, run, and suspend. Rules:
- Treat the worker as stateless. Don't cache anything in module-level variables; it won't survive suspension.
- Every outbound API call reads the access token fresh from
chrome.storage.local. - Use
chrome.alarms(notsetInterval) for periodic work — alarms wake the worker if suspended. - Don't rely on message responses over long-running operations. Use
chrome.storage.onChangedor sendMessage from the worker back to UI surfaces instead of resolving cross-surface promises that may outlive the worker.
5. chrome.storage.local keys
One typed wrapper module (sw/storage.ts) owns all keys. Never read/write them scattered.
| Key | Shape | Purpose |
|---|---|---|
auth:accessToken | string | Current JWT |
auth:refreshToken | string | Current refresh token |
auth:user | { id, email, username, permissions } | Cached user info |
meta:cache | { data, etag, fetchedAt } | /meta response cache |
catalog:cache | { data, etag, fetchedAt } | /catalog response cache |
prefs:density | Record<string, 'compact'|'standard'|'magazine'> | Per-context view density |
prefs:theme | 'system'|'light'|'dark' | Theme override |
Wrapper reads/writes via typed functions: storage.getAccessToken() rather than chrome.storage.local.get('auth:accessToken').
Clearing on logout
storage.clearAuth() removes auth:* keys but keeps preferences.
6. Messaging between surfaces
UI surfaces (newtab, popup, options) don't talk to the API directly. They message the service worker, which owns API access.
Why: keeps auth tokens out of content scripts (which the newtab is not, but this is a defensive posture), and centralizes cache/retry logic.
Pattern:
// UI surface
const result = await chrome.runtime.sendMessage({
type: 'content.search',
payload: { q: 'gaussian splats', tags: ['3d'] }
});
// Service worker — sw/messages.ts
chrome.runtime.onMessage.addListener((msg, _sender, send) => {
(async () => {
switch (msg.type) {
case 'content.search': send(await api.searchContent(msg.payload)); break;
case 'content.submit': send(await api.submitContent(msg.payload)); break;
case 'auth.login': send(await api.login(msg.payload)); break;
// ...
}
})();
return true; // keep message channel open for async send
});
Keep the set of message types small (~a dozen). Prefer coarse-grained "content.search" over fine-grained "content.search.byTag" so the worker can evolve the implementation.
7. Auth flow
Summarized from AUTH.md §2. In extension terms:
- User opens newtab for the first time → worker checks
storage.auth:*→ absent → UI renders "Sign in." - User submits login form → UI sends
auth.loginmessage → worker POSTs/auth/login→ stores tokens + user → responds success. - UI re-renders as authenticated; fetches catalog + default feed (messages to worker).
- Any API call that returns 401 triggers the worker's refresh dance (see features/tokens.md). Transparent to the UI unless refresh also fails; then worker emits
auth.requiredviachrome.storage.onChangedand UI shows the login form.
8. Catalog + meta caching
On worker cold start:
- Read
catalog:cachefrom storage. If missing, setcatalog:loading. - Issue
GET /catalogwithIf-None-Match: <etag>if present. - 304 → reuse cache, update
fetchedAt. - 200 → overwrite cache with new body + etag.
- Schedule a
chrome.alarms.create('catalog.revalidate', { periodInMinutes: 30 })— the alarm handler re-runs this flow so the extension picks up taxonomy edits without requiring a cold start.
/meta follows the same pattern; revalidate less often (once a day via alarm).
9. Submit flow (toolbar action)
- User browses to a page they want to submit, clicks the extension action in the Chrome toolbar.
- Popup opens, grabs
tab.urlandtab.titleviachrome.tabs.query({ active: true, currentWindow: true }). - Popup pre-fills a form: title, url, optional description, optional tags, optional group.
- Submit button → UI sends
content.submitmessage → worker POSTs/content/submit→ renders the envelope's success/error inline. - On success, popup shows "Pending — thanks!" and closes after 2 seconds.
See features/content-submit.md for the server-side contract.
10. Development workflow
pnpm installinextension/.pnpm devruns the bundler in watch mode, outputting toextension/dist/.- In Chrome:
chrome://extensions, enable Developer Mode, click "Load unpacked," selectextension/dist/. - Chrome assigns a random extension ID for unpacked loads. Copy it into the API's
CORS_ALLOWED_ORIGINSdev value. - Open a new tab to see the main UI; click the action for the submit popup.
Hot reload
Extension hot-reload is fiddly. Vite's @crxjs/vite-plugin handles it well; use that or a similar plugin. Without one, you'll manually click "Reload" on the extension card after each code change.
Debugging
- Service worker logs:
chrome://extensions→ Extension details → "Inspect views: service worker." - New tab logs: regular DevTools in the new tab.
- Popup logs: right-click the action icon → "Inspect popup."
11. Security notes specific to the extension
- No
eval, no remote scripts. MV3 CSP forbids both. - Sanitize any HTML from the API. If
content.embed.htmlarrives from the API, it was curated server-side, but still pass it through DOMPurify before injecting. - Validate all
chrome.runtime.onMessagepayloads inside the worker. A compromised extension surface could send garbage; the worker does not trust its siblings. - Never store tokens in
localStorageor a cookie —chrome.storage.localonly. - Content scripts (if ever added) never see tokens. They communicate with the worker; the worker attaches tokens.
12. Publishing
When ready to publish:
- Set
APP_NAME/APP_DESCRIPTION/ icons to final values. - Update
manifest.jsonversion. - Build the production bundle:
pnpm build. - Zip
dist/. - Submit via Chrome Web Store developer console. Expect 1–5 business-day review.
- Publish, capture the stable extension ID.
- Update API's
CORS_ALLOWED_ORIGINSin production to includechrome-extension://<stable-id>.