Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist/
node_modules/
65 changes: 65 additions & 0 deletions build.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import esbuild from "esbuild";
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { join } from "path";

const watch = process.argv.includes("--watch");
const targetArg = process.argv.find((a) => a.startsWith("--target="));
const targets = targetArg
? [targetArg.split("=")[1]]
: ["chrome", "firefox"];

const entryPoints = [
{ in: "src/background/service-worker.ts", out: "background/service-worker" },
{ in: "src/popup/popup.ts", out: "popup/popup" },
{ in: "src/options/options.ts", out: "options/options" },
];

function copyStaticAssets(target) {
const out = `dist/${target}`;
mkdirSync(`${out}/icons`, { recursive: true });
mkdirSync(`${out}/popup`, { recursive: true });
mkdirSync(`${out}/options`, { recursive: true });

copyFileSync("src/icons/icon-48.png", `${out}/icons/icon-48.png`);
copyFileSync("src/icons/icon-96.png", `${out}/icons/icon-96.png`);
copyFileSync("src/popup/popup.html", `${out}/popup/popup.html`);
copyFileSync("src/popup/popup.css", `${out}/popup/popup.css`);
copyFileSync("src/options/options.html", `${out}/options/options.html`);
copyFileSync("src/options/options.css", `${out}/options/options.css`);

const manifest = JSON.parse(
readFileSync(`manifests/manifest.${target}.json`, "utf8")
);
writeFileSync(join(out, "manifest.json"), JSON.stringify(manifest, null, 2));
}

async function build(target) {
const outdir = `dist/${target}`;
mkdirSync(outdir, { recursive: true });

const ctx = await esbuild.context({
entryPoints,
outdir,
bundle: true,
format: "esm",
target: "es2022",
platform: "browser",
sourcemap: watch ? "inline" : false,
minify: !watch,
});

copyStaticAssets(target);

if (watch) {
await ctx.watch();
console.log(`Watching ${target}...`);
} else {
await ctx.rebuild();
await ctx.dispose();
console.log(`Built ${target}`);
}
}

for (const target of targets) {
await build(target);
}
6 changes: 0 additions & 6 deletions firefox-extension/README.md

This file was deleted.

1 change: 0 additions & 1 deletion firefox-extension/ffextension.js

This file was deleted.

18 changes: 0 additions & 18 deletions firefox-extension/manifest.json

This file was deleted.

35 changes: 35 additions & 0 deletions manifests/manifest.chrome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"manifest_version": 3,
"name": "FindFirst",
"version": "1.0.0",
"description": "Save pages and links to FindFirst",
"icons": {
"48": "icons/icon-48.png",
"96": "icons/icon-96.png"
},
"action": {
"default_popup": "popup/popup.html",
"default_icon": {
"48": "icons/icon-48.png",
"96": "icons/icon-96.png"
}
},
"background": {
"service_worker": "background/service-worker.js",
"type": "module"
},
"options_ui": {
"page": "options/options.html",
"open_in_tab": true
},
"permissions": [
"activeTab",
"storage",
"cookies",
"contextMenus",
"notifications"
],
"host_permissions": [
"http://localhost:9000/*"
]
}
40 changes: 40 additions & 0 deletions manifests/manifest.firefox.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"manifest_version": 3,
"name": "FindFirst",
"version": "1.0.0",
"description": "Save pages and links to FindFirst",
"icons": {
"48": "icons/icon-48.png",
"96": "icons/icon-96.png"
},
"action": {
"default_popup": "popup/popup.html",
"default_icon": {
"48": "icons/icon-48.png",
"96": "icons/icon-96.png"
}
},
"background": {
"scripts": ["background/service-worker.js"]
},
"options_ui": {
"page": "options/options.html",
"open_in_tab": true
},
"permissions": [
"activeTab",
"storage",
"cookies",
"contextMenus",
"notifications"
],
"host_permissions": [
"http://localhost:9000/*"
],
"browser_specific_settings": {
"gecko": {
"id": "findfirst@findfirst.app",
"strict_min_version": "109.0"
}
}
}
2 changes: 2 additions & 0 deletions openspec/changes/browser-extension-core/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-23
91 changes: 91 additions & 0 deletions openspec/changes/browser-extension-core/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
## Context

FindFirst is a Spring Boot bookmarking platform with JWT-based auth (HttpOnly cookies) and a REST API. The browser extension repo currently has a Manifest V2 Firefox stub (no real functionality) and an empty Chrome directory. We are building real extensions from scratch with a shared source tree that targets both browsers.

## Goals / Non-Goals

**Goals:**
- One `src/` tree builds both Chrome (Manifest V3) and Firefox (Manifest V3) artifacts
- Users can sign in, save pages with tags, and configure their server URL from within the extension
- Works against any self-hosted FindFirst instance (configurable base URL)
- Minimal bundle — no heavy UI framework, no unnecessary runtime dependencies

**Non-Goals:**
- Syncing bookmarks back to the browser's native bookmark manager
- Offline read-later / full-page caching
- Supporting Manifest V2 (dropped in Chrome; aligning Firefox to V3)
- Multi-account (single server URL / single user session per extension install)
- Publishing to extension stores (out of scope for this change)

## Decisions

### 1. Shared source tree, browser-specific manifests

**Decision**: Single `src/` directory. Two manifest templates (`manifests/manifest.chrome.json`, `manifests/manifest.firefox.json`). Build script (`build.js`) outputs `dist/chrome/` and `dist/firefox/`.

**Rationale**: All logic is identical across browsers; only the manifest and a few API shim differences need to be browser-specific. Duplicating source would immediately diverge.

**Alternative considered**: Separate `chrome-extension/` and `firefox-extension/` directories with symlinks — rejected because symlinks are fragile in git on Windows, and there is no meaningful browser-specific logic in the source.

### 2. TypeScript — no UI framework

**Decision**: TypeScript for all source files (`src/**/*.ts`). Plain HTML/CSS for popup and options pages. No React, Vue, or Svelte.

**Rationale**: Extension popups are tiny (< 400px wide). The DOM surface is trivial (a handful of form controls). A framework adds > 30 KB to the bundle and complicates the build without any benefit at this scale. TypeScript is used to match the FindFirst frontend and to catch type errors across the shared API client and message-passing interface at compile time.

**Alternative considered**: Preact (3 KB) — kept as an option if complexity grows, but deferred for now.

### 3. esbuild for bundling

**Decision**: esbuild via a Node.js build script (`build.js`). Bundles TypeScript, copies HTML/CSS/icons, injects the right manifest.

**Rationale**: Zero-config, extremely fast, no Webpack boilerplate. esbuild has native TypeScript transpilation support — no separate transpile step needed. `tsc --noEmit` is run separately for type-checking only.

**Alternative considered**: Rollup — more plugin ecosystem but more configuration overhead; unnecessary here.

### 4. Auth via HttpOnly cookies (no token storage in extension)

**Decision**: The background service worker calls `POST /user/signin` with `Authorization: Basic base64(user:pass)`. The server sets an HttpOnly JWT cookie on the FindFirst origin. All subsequent `fetch()` calls from the background include `credentials: "include"`, so the browser cookie jar handles auth automatically. The extension stores only `{ serverUrl, username, isAuthenticated }` in `browser.storage.local` — never the password or JWT string.

**Rationale**: Matches the server's existing auth model exactly. Storing a JWT in `storage.local` would expose it to any extension JS; leaving it in the cookie jar keeps it HttpOnly and leverages the server's existing token refresh/expiry logic.

**Alternative considered**: Storing the JWT in `storage.local` and sending it as `Authorization: Bearer` — works, but requires the server to also accept Bearer tokens (it currently supports both cookie and header). Rejected because it unnecessarily duplicates the token outside the browser's secure cookie jar.

**Caveat**: `credentials: "include"` in a service worker fetch works only when the extension has `host_permissions` for the FindFirst server URL. This must be set at install time for the default URL, with dynamic permission requests if the user changes the server URL.

### 5. All API calls routed through the background service worker

**Decision**: Popup and options pages send messages to the background service worker (`browser.runtime.sendMessage`); the background makes all `fetch()` calls to the FindFirst API and replies with results.

**Rationale**: Centralises auth state management. Prevents CORS complexity in popup context. Service workers persist the cookie jar state correctly; popup contexts are ephemeral.

**Alternative considered**: Popup making direct fetch calls — simpler code path but creates duplicate auth-checking logic and complicates cookie handling in extension page contexts.

### 6. Manifest V3 for both browsers

**Decision**: Target Manifest V3 on both Chrome and Firefox.

**Rationale**: Chrome has removed V2 support. Firefox supports V3 and recommends it for new extensions. The main V2→V3 change is `background.scripts` → `background.service_worker`; Firefox additionally supports `background.scripts` in V3 for compatibility, but we'll use `service_worker` for alignment.

**Browser differences handled in build**: Firefox manifest includes `browser_specific_settings.gecko` with extension ID and minimum Firefox version (109+, which added MV3 GA support). Chrome manifest omits this.

## Risks / Trade-offs

| Risk | Mitigation |
|------|-----------|
| Service worker terminates between API calls, losing in-flight context | Keep all state in `browser.storage.local`; re-read on each message. Service workers wake on `browser.runtime.onMessage`. |
| `credentials: "include"` rejected by server CORS policy | FindFirst must return `Access-Control-Allow-Origin: <extension-origin>` and `Access-Control-Allow-Credentials: true`. This may require a server-side change — document as a deployment requirement. |
| User changes server URL mid-session | Clear auth state and prompt re-login on URL change in options page. |
| Firefox MV3 service worker support is newer | Require Firefox 109+. Document minimum browser versions. |
| esbuild doesn't type-check (transpiles only) | Run `tsc --noEmit` as a separate CI step using the project `tsconfig.json`; esbuild handles bundling, tsc handles type validation. |

## Migration Plan

1. Build produces `dist/chrome/` and `dist/firefox/` — these replace the old `chrome-extension/` and `firefox-extension/` stub directories
2. Old `firefox-extension/manifest.json` (V2) is deleted; its icons are moved to `src/icons/`
3. No user-facing migration (extension is not yet published; no existing users)

## Open Questions

- Does the FindFirst server need a CORS configuration change to allow requests from extension origins (`moz-extension://` / `chrome-extension://`)? → Needs server-side verification before this extension can be tested end-to-end.
- Should the context menu option appear on all pages or only on pages the user is actively viewing? → Current plan: all pages (saves the right-clicked link's href, or the tab URL if no link selected).
36 changes: 36 additions & 0 deletions openspec/changes/browser-extension-core/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
## Why

FindFirst has a web app and API for saving and searching bookmarks, but users must navigate to the app to save a page — breaking their browsing flow. Browser extensions give users a one-click way to save any page to FindFirst without leaving the tab.

## What Changes

- Introduce a shared-source extension codebase (single `src/` tree) that builds separate Chrome and Firefox artifacts
- Replace the existing Firefox Manifest V2 stub with a real Manifest V3 extension
- Implement the Chrome extension from scratch with Manifest V3
- Add a popup UI for quick-saving the current page with tags
- Add a context menu entry to save the current page or a selected link
- Add an options/settings page for configuring the FindFirst server URL
- Add authentication flow (sign-in form → JWT stored in extension storage)

## Capabilities

### New Capabilities

- `auth`: Sign in to a FindFirst instance from the extension; store JWT and server URL in extension storage; sign out; detect expired sessions
- `bookmark-save`: Save the current page (or a right-clicked link) to FindFirst with a title and optional tags; pre-fills title/URL from the active tab
- `tag-management`: Fetch the user's existing tags for autocomplete; create new tags inline during bookmark save
- `settings`: Options page to configure the FindFirst server base URL and view connection status
- `build-system`: Shared `src/` with an esbuild-based build that emits separate `dist/chrome/` and `dist/firefox/` artifacts; Vitest unit test setup

### Modified Capabilities

*(none — this is a greenfield build)*

## Impact

- **New files**: `src/` tree (background, popup, options, shared API client), `manifest.chrome.json`, `manifest.firefox.json`, `package.json`, `build.js`
- **Replaced**: `firefox-extension/` stub replaced by proper build output in `dist/firefox/`
- **New runtime dependencies**: none (TypeScript compiled to plain JS, no runtime framework)
- **New dev dependencies**: esbuild, vitest, web-ext, typescript, @types/chrome, @types/firefox-webext-browser
- **API surface**: consumes FindFirst REST API — `/user/signin`, `/api/bookmark`, `/api/tags`, `/api/tag`, `/api/search/text`
- **Permissions required**: `activeTab`, `storage`, `contextMenus`, `host_permissions` for the configured FindFirst host
50 changes: 50 additions & 0 deletions openspec/changes/browser-extension-core/specs/auth/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
## ADDED Requirements

### Requirement: User can sign in to a FindFirst instance
The extension SHALL provide a sign-in form that authenticates the user against a configured FindFirst server. On success, the extension SHALL mark the session as authenticated in `browser.storage.local` and store the username for display. Credentials SHALL NOT be persisted in storage.

#### Scenario: Successful sign-in
- **WHEN** the user submits valid username and password on the sign-in form
- **THEN** the background service worker calls `POST /user/signin` with `Authorization: Basic base64(user:pass)` and `credentials: "include"`
- **THEN** the server responds with 200 and sets the JWT cookie
- **THEN** the extension stores `{ isAuthenticated: true, username }` in `browser.storage.local`
- **THEN** the popup transitions to the bookmark-save view

#### Scenario: Invalid credentials
- **WHEN** the user submits incorrect username or password
- **THEN** the server responds with 401
- **THEN** the extension displays an error message on the sign-in form
- **THEN** no auth state is written to storage

#### Scenario: Server unreachable
- **WHEN** the user submits sign-in credentials and the server cannot be reached
- **THEN** the extension displays a connectivity error message
- **THEN** the user remains on the sign-in form

### Requirement: User can sign out
The extension SHALL provide a sign-out action that clears the authenticated session.

#### Scenario: Sign-out clears session
- **WHEN** the user clicks the sign-out button
- **THEN** the extension removes `isAuthenticated` and `username` from `browser.storage.local`
- **THEN** the popup returns to the sign-in view on next open

### Requirement: Unauthenticated users are redirected to sign-in
The extension SHALL detect when no authenticated session exists and present the sign-in form instead of the bookmark-save UI.

#### Scenario: Extension opened without a session
- **WHEN** the user clicks the extension icon and `isAuthenticated` is false or absent in storage
- **THEN** the popup displays the sign-in form

#### Scenario: Session expired mid-use
- **WHEN** an API call returns 401 during bookmark save
- **THEN** the extension clears auth state from storage
- **THEN** the popup displays the sign-in form with a message indicating the session expired

### Requirement: Auth state is checked before every API call
The background service worker SHALL verify auth state before executing any API request and surface a session-expired error if the server returns 401.

#### Scenario: 401 on API call
- **WHEN** any API call from the background service worker receives a 401 response
- **THEN** the background clears `isAuthenticated` from storage
- **THEN** the background returns an `{ error: "session_expired" }` message to the caller
Loading