diff --git a/apps/extension/src/dev/chrome-stubs.ts b/apps/extension/src/dev/chrome-stubs.ts index e76fdfca..381d9d4b 100644 --- a/apps/extension/src/dev/chrome-stubs.ts +++ b/apps/extension/src/dev/chrome-stubs.ts @@ -750,6 +750,13 @@ function createChromeStubs() { cookies: { getAll: async () => [{ name: 'session', value: 'mock-session' }], }, + permissions: { + // Dev stub: pretend the optional LinkedIn host permission is always + // granted so the side-panel permission gate (ensureLinkedInHostPermission) + // passes in dev mode and e2e without a real Chrome prompt. + contains: async () => true, + request: async () => true, + }, sidePanel: { setPanelBehavior: () => {}, }, diff --git a/apps/extension/src/lib/shell/facades/profile-sync.facade.ts b/apps/extension/src/lib/shell/facades/profile-sync.facade.ts index a00dfa19..a6fdc222 100644 --- a/apps/extension/src/lib/shell/facades/profile-sync.facade.ts +++ b/apps/extension/src/lib/shell/facades/profile-sync.facade.ts @@ -12,6 +12,50 @@ export type LinkedInProfilePreviewResult = | { extracted: true; profile: CanonicalCandidateProfileDraft } | { extracted: false; errorCode: string; errorMessage: string }; +const LINKEDIN_HOST_PERMISSION: chrome.permissions.Permissions = { + origins: ['https://www.linkedin.com/*'], +}; + +interface ChromePermissionsLike { + contains(permissions: chrome.permissions.Permissions): Promise; + request(permissions: chrome.permissions.Permissions): Promise; +} + +function getChromePermissions(): ChromePermissionsLike | undefined { + if (typeof chrome === 'undefined') { + return undefined; + } + const permissions = chrome.permissions as ChromePermissionsLike | undefined; + if (!permissions?.contains || !permissions.request) { + return undefined; + } + return permissions; +} + +/** + * Ensures the optional LinkedIn host permission is granted before the side + * panel asks the service worker to extract the active LinkedIn tab. + * + * `chrome.permissions.request()` MUST run in a UI context (side panel) during a + * user gesture — it cannot run in the service worker (MV3). Returns false when + * the API is unavailable or the user denies the prompt. + * See `src/models/linkedin-import.model.md`. + */ +export async function ensureLinkedInHostPermission(): Promise { + const permissions = getChromePermissions(); + if (!permissions) { + return false; + } + if (await permissions.contains(LINKEDIN_HOST_PERMISSION)) { + return true; + } + try { + return await permissions.request(LINKEDIN_HOST_PERMISSION); + } catch { + return false; + } +} + export async function verifyProfilePage( url: string, fields: ProfileSyncField[] diff --git a/apps/extension/src/lib/shell/profile-extractors/linkedin.extractor.ts b/apps/extension/src/lib/shell/profile-extractors/linkedin.extractor.ts index 0d1bda98..47741e9c 100644 --- a/apps/extension/src/lib/shell/profile-extractors/linkedin.extractor.ts +++ b/apps/extension/src/lib/shell/profile-extractors/linkedin.extractor.ts @@ -32,7 +32,6 @@ interface ChromeLike { }; permissions?: { contains(permissions: chrome.permissions.Permissions): Promise; - request?(permissions: chrome.permissions.Permissions): Promise; }; scripting?: { executeScript( @@ -215,6 +214,22 @@ export class LinkedInProfileExtractor implements PlatformProfileExtractor { now: number, tabId?: number ): Promise> { + // Permission gate runs BEFORE resolveTab: without the LinkedIn host + // permission, chrome.tabs.query returns tab.url === undefined and the URL + // classification would emit a misleading profile_not_found. The origin is + // requested from the side panel (user gesture) before this bridge call; + // see src/models/linkedin-import.model.md. + const scriptingReady = await this.ensureExtractionPermission(); + if (!scriptingReady) { + return err( + createProfileExtractorError( + 'permission_required', + 'LinkedIn profile import requires the LinkedIn host permission. Grant it from the MissionPulse side panel before importing.', + now + ) + ); + } + const tab = await this.resolveTab(tabId); if (!tab?.id || !tab.url) { return err( @@ -240,13 +255,13 @@ export class LinkedInProfileExtractor implements PlatformProfileExtractor { ); } - const scriptingReady = await this.ensureExtractionPermission(); - if (!scriptingReady || !this.chromeApi.scripting?.executeScript || !this.chromeApi.tabs) { + if (!this.chromeApi.scripting?.executeScript) { return err( createProfileExtractorError( 'permission_required', - 'LinkedIn profile import requires activeTab and scripting permissions.', - now + 'LinkedIn profile import requires the scripting permission.', + now, + { url: tab.url } ) ); } @@ -336,16 +351,12 @@ export class LinkedInProfileExtractor implements PlatformProfileExtractor { return false; } - const hasLinkedInOrigin = await this.chromeApi.permissions.contains({ + // Contains-only: the LinkedIn host permission is requested from the side + // panel (user gesture) before the bridge call. The service worker cannot + // call chrome.permissions.request (MV3). See src/models/linkedin-import.model.md. + return this.chromeApi.permissions.contains({ origins: ['https://www.linkedin.com/*'], }); - if (hasLinkedInOrigin) { - return true; - } - - return ( - this.chromeApi.permissions.request?.({ origins: ['https://www.linkedin.com/*'] }) ?? false - ); } private async resolveTab(tabId?: number): Promise { diff --git a/apps/extension/src/models/linkedin-import.model.md b/apps/extension/src/models/linkedin-import.model.md new file mode 100644 index 00000000..9a676fd5 --- /dev/null +++ b/apps/extension/src/models/linkedin-import.model.md @@ -0,0 +1,117 @@ +# LinkedIn Import Model + +Source of truth for the LinkedIn profile import flow: extracting the active +LinkedIn profile tab into a canonical draft and merging it into the user's +profile. The flow crosses two Chrome contexts (side panel → service worker) via +the typed bridge, and is gated by an **optional** LinkedIn host permission +requested in-context from the side panel. + +The LLM never decides a transition. It may later enrich extracted content +inside a dedicated AI worker; the model decides when extraction/merge run and +how errors surface. **Le LLM produit des signaux ; le modèle décide.** + +## Why a model + +`chrome.tabs.query` only populates `tab.url` when the extension holds the +`tabs` permission, a matching host permission, or an active `activeTab` grant. +`activeTab` is revoked as soon as the user navigates inside the tab, so a +profile opened then interacted with no longer exposes its URL. LinkedIn is +declared in `optional_host_permissions` (privacy-first: not requested at +install), so the host permission must be requested from a UI context with a +user gesture before the service worker can read the tab URL, read LinkedIn +cookies, or inject the extraction script. + +`chrome.permissions.request()` may only be called from a UI context (popup, +side panel, options page) during a user gesture — never from the service +worker. Requesting it from the SW (the old flow) always fails. + +## Contexts + +- **Side panel** (`src/ui/pages/CvPage.svelte`) — owns the user gesture (click + on "Importer LinkedIn") and the permission request. +- **Service worker** (`src/background/index.ts`) — owns extraction + (`LinkedInProfileExtractor`) and persistence (merge into `UserProfile`). +- **Bridge** (`src/lib/shell/messaging/bridge.ts`) — typed messages between the + two; the side panel never touches `chrome.cookies`/`chrome.scripting`/IndexedDB + directly. + +## States (side panel, `handleLinkedInImport`) + +``` +idle ──CLICK─────────────────► checking-permission +checking-permission ──PERMISSION_GRANTED──► extracting +checking-permission ──PERMISSION_DENIED───► idle (toast: "Autorisation LinkedIn refusée.") +extracting ──EXTRACT_OK(profile)──► merging +extracting ──EXTRACT_ERR(code,msg)──► idle (toast: msg, typée par code) +merging ──MERGE_OK──► idle (toast: "Expériences LinkedIn importées avec succès.") +merging ──MERGE_ERR(msg)──► idle (toast: msg) +``` + +`isImporting === true` for `checking-permission | extracting | merging` (button +disabled, reentrancy blocked). + +## Events + +- `CLICK` — user clicks "Importer LinkedIn". +- `PERMISSION_GRANTED | PERMISSION_DENIED` — result of + `ensureLinkedInHostPermission()` (facade). +- `EXTRACT_OK(profile) | EXTRACT_ERR(code, message)` — result of + `importLinkedInProfile()` (bridge `IMPORT_LINKEDIN_PROFILE`). +- `MERGE_OK | MERGE_ERR(message)` — result of `syncLinkedInProfileImport(profile)` + (bridge `SYNC_LINKEDIN_PROFILE_IMPORT` → `PROFILE_UPDATED`). + +## Effects (shell) + +- **Enter `checking-permission`**: `ensureLinkedInHostPermission()` runs in the + side panel context: + 1. `chrome.permissions.contains({ origins: ['https://www.linkedin.com/*'] })`. + 2. If false, `chrome.permissions.request({ origins: ['https://www.linkedin.com/*'] })` + (user gesture active). Returns `true`/`false`. + - Documented UI exception (same category as clipboard write): permitted + because it is the Chrome-recommended pattern for optional host permissions. +- **Enter `extracting`**: `importLinkedInProfile()` sends + `IMPORT_LINKEDIN_PROFILE` → SW `LinkedInProfileExtractor.extractProfile(now)`. +- **Enter `merging`**: `syncLinkedInProfileImport(profile)` → SW + `mergeCandidateProfileIntoUserProfile` → emits `PROFILE_UPDATED`. + +## Extractor (service worker) — ordered checks + +``` +ensureExtractionPermission() // contains-only; NO request from the SW + └ missing ⇒ permission_required +resolveTab(active, currentWindow) + └ !tab?.id || !tab.url ⇒ profile_not_found ("Open a LinkedIn profile tab…") +classifyLinkedInUrl(tab.url) + └ login ⇒ session_required | checkpoint ⇒ rate_limited_or_blocked | non-/in/ ⇒ profile_not_found +detectSession(cookies li_at) + └ absent ⇒ session_required +scripting.executeScript(extractLinkedInProfileFromDom) + └ blockedReason ⇒ rate_limited_or_blocked | empty ⇒ dom_changed | else ⇒ canonical draft +``` + +The permission check runs **before** `resolveTab`: without the LinkedIn host +permission, `tab.url` is `undefined` and the URL classification would produce a +misleading `profile_not_found`. With the permission granted (by the side panel +gate), `tab.url` is readable for LinkedIn tabs. + +## Invariants + +1. `chrome.permissions.request()` is called **only** from the side panel during + a `CLICK` gesture, never from the service worker. +2. The extractor validates the LinkedIn host permission **before** reading + `tab.url`. +3. If `tab.url` is `undefined` after the permission is granted, the active tab + is not a LinkedIn tab → `profile_not_found` is correct. +4. `isImporting` blocks reentrancy for the whole `checking-permission → +extracting → merging` sequence. +5. Bridge errors are surfaced as typed toasts; the UI never crashes on + null/unknown bridge responses (facade graceful handling). +6. The LLM never decides a transition. It may only enrich extracted content in + a dedicated AI worker. **Le LLM produit des signaux ; le modèle décide.** + +## Non-goals + +- Do not add the `tabs` permission (too broad; install-time warning). +- Do not move LinkedIn to required `host_permissions` (breaks the + minimal-permission, privacy-first posture; in-context request is the pattern). +- Do not request the LinkedIn origin from the service worker. diff --git a/apps/extension/src/ui/pages/CvPage.svelte b/apps/extension/src/ui/pages/CvPage.svelte index f9771504..477d13aa 100644 --- a/apps/extension/src/ui/pages/CvPage.svelte +++ b/apps/extension/src/ui/pages/CvPage.svelte @@ -6,6 +6,7 @@ getCvSyncTargets, } from '$lib/shell/facades/cv-experience.facade'; import { + ensureLinkedInHostPermission, importLinkedInProfile, syncLinkedInProfileImport, } from '$lib/shell/facades/profile-sync.facade'; @@ -34,6 +35,11 @@ } isImporting = true; try { + const granted = await ensureLinkedInHostPermission(); + if (!granted) { + showToast('Autorisation LinkedIn refusée.', 'error'); + return; + } const extracted = await importLinkedInProfile(); if (!extracted.imported) { showToast(extracted.errorMessage, 'error'); diff --git a/apps/extension/tests/unit/facades/profile-sync-facade.test.ts b/apps/extension/tests/unit/facades/profile-sync-facade.test.ts index e83fb013..1a55fdd5 100644 --- a/apps/extension/tests/unit/facades/profile-sync-facade.test.ts +++ b/apps/extension/tests/unit/facades/profile-sync-facade.test.ts @@ -1,9 +1,10 @@ /** * @vitest-environment jsdom */ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CanonicalCandidateProfileDraft } from '../../../src/lib/core/profile-extractors/types'; import { + ensureLinkedInHostPermission, importLinkedInProfile, previewLinkedInProfile, syncLinkedInProfileImport, @@ -75,3 +76,46 @@ describe('profile-sync facade — graceful handling of null/unknown bridge respo }); }); }); + +describe('profile-sync facade — ensureLinkedInHostPermission (side-panel permission gate)', () => { + const linkedinOrigin = { origins: ['https://www.linkedin.com/*'] }; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns true without requesting when the LinkedIn origin is already granted', async () => { + const contains = vi.fn(async () => true); + const request = vi.fn(async () => true); + vi.stubGlobal('chrome', { permissions: { contains, request } }); + + await expect(ensureLinkedInHostPermission()).resolves.toBe(true); + expect(contains).toHaveBeenCalledWith(linkedinOrigin); + expect(request).not.toHaveBeenCalled(); + }); + + it('requests the LinkedIn origin when not yet granted and returns true on accept', async () => { + const contains = vi.fn(async () => false); + const request = vi.fn(async () => true); + vi.stubGlobal('chrome', { permissions: { contains, request } }); + + await expect(ensureLinkedInHostPermission()).resolves.toBe(true); + expect(contains).toHaveBeenCalledWith(linkedinOrigin); + expect(request).toHaveBeenCalledWith(linkedinOrigin); + }); + + it('returns false when the user denies the LinkedIn origin prompt', async () => { + const contains = vi.fn(async () => false); + const request = vi.fn(async () => false); + vi.stubGlobal('chrome', { permissions: { contains, request } }); + + await expect(ensureLinkedInHostPermission()).resolves.toBe(false); + expect(request).toHaveBeenCalledWith(linkedinOrigin); + }); + + it('returns false when chrome.permissions is unavailable (permissions API missing)', async () => { + vi.stubGlobal('chrome', { runtime: {} }); + + await expect(ensureLinkedInHostPermission()).resolves.toBe(false); + }); +}); diff --git a/apps/extension/tests/unit/profile-extractors/linkedin-extractor.test.ts b/apps/extension/tests/unit/profile-extractors/linkedin-extractor.test.ts index eff7a29f..534093ac 100644 --- a/apps/extension/tests/unit/profile-extractors/linkedin-extractor.test.ts +++ b/apps/extension/tests/unit/profile-extractors/linkedin-extractor.test.ts @@ -90,22 +90,23 @@ describe('LinkedInProfileExtractor', () => { } }); - it('requests the optional LinkedIn origin permission when it is not granted yet', async () => { - const requestedPermissions: chrome.permissions.Permissions[] = []; + it('returns permission_required when the LinkedIn origin is not granted and never calls request from the service worker', async () => { + const request = vi.fn(async () => true); const extractor = new LinkedInProfileExtractor( createChromeDouble({ + // scripting/activeTab (declared permissions) are contained; the LinkedIn + // origin (optional_host_permissions) is not. contains: async (permissions) => Boolean(permissions.permissions?.length), - request: async (permissions) => { - requestedPermissions.push(permissions); - return true; - }, + request, }) ); const result = await extractor.extractProfile(1779436800000); - expect(result.ok).toBe(true); - expect(requestedPermissions).toEqual([{ origins: ['https://www.linkedin.com/*'] }]); + expect(result.ok).toBe(false); + expect(extractorCode(result)).toBe('permission_required'); + // MV3 invariant: chrome.permissions.request cannot run in the service worker. + expect(request).not.toHaveBeenCalled(); }); it('returns profile_not_found when the active tab is not a LinkedIn profile', async () => { @@ -121,13 +122,12 @@ describe('LinkedInProfileExtractor', () => { expect(extractorCode(result)).toBe('profile_not_found'); }); - it('does not request LinkedIn origin permission before validating the active profile tab', async () => { - const request = vi.fn(async () => true); + it('returns profile_not_found when the active tab url is undefined despite permission being granted', async () => { + // With the LinkedIn host permission granted, tab.url is populated for + // LinkedIn tabs but stays undefined for non-LinkedIn tabs (no host match). const extractor = new LinkedInProfileExtractor( createChromeDouble({ - contains: async (permissions) => Boolean(permissions.permissions?.length), - request, - query: async () => [{ id: 9, url: 'https://www.linkedin.com/feed/' } as chrome.tabs.Tab], + query: async () => [{ id: 9 } as chrome.tabs.Tab], }) ); @@ -135,7 +135,6 @@ describe('LinkedInProfileExtractor', () => { expect(result.ok).toBe(false); expect(extractorCode(result)).toBe('profile_not_found'); - expect(request).not.toHaveBeenCalled(); }); it('returns permission_required when scripting or activeTab is missing', async () => { @@ -151,20 +150,6 @@ describe('LinkedInProfileExtractor', () => { expect(extractorCode(result)).toBe('permission_required'); }); - it('returns permission_required when the optional LinkedIn origin permission is refused', async () => { - const extractor = new LinkedInProfileExtractor( - createChromeDouble({ - contains: async (permissions) => Boolean(permissions.permissions?.length), - request: async () => false, - }) - ); - - const result = await extractor.extractProfile(1779436800000); - - expect(result.ok).toBe(false); - expect(extractorCode(result)).toBe('permission_required'); - }); - it('returns session_required when LinkedIn redirects to login', async () => { const extractor = new LinkedInProfileExtractor( createChromeDouble({