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
7 changes: 7 additions & 0 deletions apps/extension/src/dev/chrome-stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {},
},
Expand Down
44 changes: 44 additions & 0 deletions apps/extension/src/lib/shell/facades/profile-sync.facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;
request(permissions: chrome.permissions.Permissions): Promise<boolean>;
}

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<boolean> {
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[]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ interface ChromeLike {
};
permissions?: {
contains(permissions: chrome.permissions.Permissions): Promise<boolean>;
request?(permissions: chrome.permissions.Permissions): Promise<boolean>;
};
scripting?: {
executeScript(
Expand Down Expand Up @@ -215,6 +214,22 @@ export class LinkedInProfileExtractor implements PlatformProfileExtractor {
now: number,
tabId?: number
): Promise<Result<CanonicalCandidateProfileDraft, AppError>> {
// 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(
Expand All @@ -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 }
)
);
}
Expand Down Expand Up @@ -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<chrome.tabs.Tab | null> {
Expand Down
117 changes: 117 additions & 0 deletions apps/extension/src/models/linkedin-import.model.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions apps/extension/src/ui/pages/CvPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
getCvSyncTargets,
} from '$lib/shell/facades/cv-experience.facade';
import {
ensureLinkedInHostPermission,
importLinkedInProfile,
syncLinkedInProfileImport,
} from '$lib/shell/facades/profile-sync.facade';
Expand Down Expand Up @@ -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');
Expand Down
46 changes: 45 additions & 1 deletion apps/extension/tests/unit/facades/profile-sync-facade.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
});
});
Loading
Loading