Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}
108 changes: 74 additions & 34 deletions extension/background.js
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,42 @@
const API_URL = 'https://api.tryhavril.com';

async function getConfig() {
const session = await new Promise((r) =>
chrome.storage.session.get(['token', 'refreshToken'], r),
);
const [session, local] = await Promise.all([
new Promise((r) => chrome.storage.session.get(['token'], r)),
new Promise((r) => chrome.storage.local.get(['refreshToken'], r)),
]);
return {
token: session.token || '',
refreshToken: local.refreshToken,
serverUrl: API_URL,
};
}

async function apiFetch(path, options = {}) {
async function clearSession() {
await chrome.storage.session.remove(['token']);
await chrome.storage.local.remove(['refreshToken']);
await chrome.storage.sync.remove(['userName', 'userEmail', 'userAvatar']);
}

function authError() {
const e = new Error('Session expired — please sign in again');
e.code = 'auth_failed';
return e;
}

function notAuthError() {
const e = new Error('Not signed in — open the Havril extension to sign in');
e.code = 'not_authenticated';
return e;
}

async function apiFetch(path, options = {}, _retried = false) {
const { token, serverUrl } = await getConfig();

if (!token) {
throw new Error(
'Havril token not set — open the extension popup to configure it',
);
if (_retried) throw notAuthError();
await rotateAccessToken();
return apiFetch(path, options, true);
}

const response = await fetch(`${serverUrl}${path}`, {
Expand All @@ -33,9 +53,13 @@ async function apiFetch(path, options = {}) {

if (!response.ok) {
const body = await response.json().catch(() => ({}));
if (body.code === 'expired_token') {
if (body.code === 'expired_token' && !_retried) {
await rotateAccessToken();
return apiFetch(path, options);
return apiFetch(path, options, true);
}
if (response.status === 401) {
await clearSession();
throw authError();
}
throw new Error(body.error || `HTTP ${response.status}`);
}
Expand All @@ -55,24 +79,38 @@ async function submitConversation(conversation, sourceModel) {
});
}

let inflightRefresh = null;

async function rotateAccessToken() {
const { refreshToken, serverUrl } = await getConfig();
if (!refreshToken) {
throw new Error('Not logged in please open the extension to log in');
}
if (inflightRefresh) return inflightRefresh;

inflightRefresh = (async () => {
try {
const { refreshToken, serverUrl } = await getConfig();
if (!refreshToken) {
await clearSession();
throw notAuthError();
}

const response = await fetch(`${serverUrl}${`/v1/auth/refresh`}`, {
method: 'POST',
body: JSON.stringify({ refresh_token: refreshToken }),
});
const response = await fetch(`${serverUrl}/v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});

if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${response.status}`);
}
if (!response.ok) {
await clearSession();
throw authError();
}

const { token } = await response.json();
await chrome.storage.session.set({ token });
const { token } = await response.json();
await chrome.storage.session.set({ token });
} finally {
inflightRefresh = null;
}
})();

return inflightRefresh;
}

// ── OAuth tab handling ────────────────────────────────────────────────────────
Expand All @@ -93,8 +131,13 @@ chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
const userAvatar = url.searchParams.get('avatar') || '';
if (!token) return;

await chrome.storage.session.set({ token, refreshToken });
await chrome.storage.sync.set({ userName, userEmail, userAvatar });
await chrome.storage.session.set({ token });
await chrome.storage.local.set({ refreshToken });
await chrome.storage.sync.set({
userName,
userEmail,
userAvatar,
});

await chrome.storage.session.remove(['authTabId']);
chrome.tabs.remove(tabId);
Expand Down Expand Up @@ -126,8 +169,8 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
case 'LIST_MEMORIES': {
return apiFetch('/v1/memory');
}
case 'GET_CONFIG': {
return getConfig();
case 'GENERATE_MCP_TOKEN': {
return apiFetch('/v1/mcp/token', { method: 'POST' });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edge case: GENERATE_MCP_TOKEN silently fails if the service worker is restarted mid-call

This is a general concern for the new message-passing architecture, but it's most user-visible here. If the Manifest V3 service worker is suspended between the popup sending GENERATE_MCP_TOKEN and apiFetch completing, chrome.runtime.sendMessage can reject with "Could not establish connection". The popup's catch block handles it (shows "Error"), but the user has no indication of why — and retrying will likely work.

Minor, but worth noting: apiFetch('/v1/mcp/token', { method: 'POST' }) sends a POST with an empty body. If the server expects a JSON body (even {}), this could 400. The other POST call (submitConversation) always includes a body. Confirm the /v1/mcp/token endpoint accepts an empty-body POST.

}
case 'START_OAUTH': {
const { provider } = message.payload;
Expand All @@ -138,12 +181,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
return { started: true };
}
case 'LOGOUT': {
await chrome.storage.session.remove(['token', 'refreshToken']);
await chrome.storage.sync.remove([
'userName',
'userEmail',
'userAvatar',
]);
await clearSession();
return { ok: true };
}
default:
Expand All @@ -153,7 +191,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {

handle()
.then((data) => sendResponse({ ok: true, data }))
.catch((err) => sendResponse({ ok: false, error: err.message }));
.catch((err) =>
sendResponse({ ok: false, error: err.message, code: err.code }),
);

return true;
});
Loading
Loading