Skip to main content

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:

SurfaceSizeLifetimeUse for
PopupConstrained (~800×600 max)Closes when focus leavesQuick actions: "submit this page" — not primary browsing
Side panelFull height, ~360–500 px widePinnable; persists across tabsAmbient browse — read alongside a web page
New tab pageFull viewportReplaces Chrome's new-tabDestination browse — "my start page"
Options pageOpens in a new tabOn demandSettings, 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.name is the one place the project name is hardcoded. See CONFIG.md §5.
  • host_permissions lists the API origin explicitly. <all_urls> is not needed and raises Web Store review bar.
  • No remote <script> tags. CSP is strict.
  • activeTab permission grants access to the currently focused tab only when the user triggers the action — used by the submit flow to read tab.url and tab.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 (not setInterval) for periodic work — alarms wake the worker if suspended.
  • Don't rely on message responses over long-running operations. Use chrome.storage.onChanged or 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.

KeyShapePurpose
auth:accessTokenstringCurrent JWT
auth:refreshTokenstringCurrent 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:densityRecord<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:

  1. User opens newtab for the first time → worker checks storage.auth:* → absent → UI renders "Sign in."
  2. User submits login form → UI sends auth.login message → worker POSTs /auth/login → stores tokens + user → responds success.
  3. UI re-renders as authenticated; fetches catalog + default feed (messages to worker).
  4. 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.required via chrome.storage.onChanged and UI shows the login form.

8. Catalog + meta caching

On worker cold start:

  1. Read catalog:cache from storage. If missing, set catalog:loading.
  2. Issue GET /catalog with If-None-Match: <etag> if present.
  3. 304 → reuse cache, update fetchedAt.
  4. 200 → overwrite cache with new body + etag.
  5. 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)

  1. User browses to a page they want to submit, clicks the extension action in the Chrome toolbar.
  2. Popup opens, grabs tab.url and tab.title via chrome.tabs.query({ active: true, currentWindow: true }).
  3. Popup pre-fills a form: title, url, optional description, optional tags, optional group.
  4. Submit button → UI sends content.submit message → worker POSTs /content/submit → renders the envelope's success/error inline.
  5. On success, popup shows "Pending — thanks!" and closes after 2 seconds.

See features/content-submit.md for the server-side contract.


10. Development workflow

  1. pnpm install in extension/.
  2. pnpm dev runs the bundler in watch mode, outputting to extension/dist/.
  3. In Chrome: chrome://extensions, enable Developer Mode, click "Load unpacked," select extension/dist/.
  4. Chrome assigns a random extension ID for unpacked loads. Copy it into the API's CORS_ALLOWED_ORIGINS dev value.
  5. 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.html arrives from the API, it was curated server-side, but still pass it through DOMPurify before injecting.
  • Validate all chrome.runtime.onMessage payloads inside the worker. A compromised extension surface could send garbage; the worker does not trust its siblings.
  • Never store tokens in localStorage or a cookiechrome.storage.local only.
  • Content scripts (if ever added) never see tokens. They communicate with the worker; the worker attaches tokens.

12. Publishing

When ready to publish:

  1. Set APP_NAME / APP_DESCRIPTION / icons to final values.
  2. Update manifest.json version.
  3. Build the production bundle: pnpm build.
  4. Zip dist/.
  5. Submit via Chrome Web Store developer console. Expect 1–5 business-day review.
  6. Publish, capture the stable extension ID.
  7. Update API's CORS_ALLOWED_ORIGINS in production to include chrome-extension://<stable-id>.