From d5e608cab698139a41d9c61016ed204fb068c0b6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 28 Oct 2026 13:40:42 +0000 Subject: [PATCH] feat(desktop): expand native surfaces and harden security for 1.6.0 Add device-Bearer APIs for network, search, jobs, opportunities, and saved posts; wire Tauri commands and native Desktop UI for LinkedIn JTBD parity without cloning brand or chrome. Tighten CSP, Permissions-Policy, Desktop CSP, and introduce a desktopRead rate-limit tier. Co-authored-by: Matthew --- CHANGELOG.md | 36 +- apps/desktop/package.json | 2 +- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/src/main.rs | 149 +++- apps/desktop/src-tauri/tauri.conf.json | 4 +- apps/desktop/src/app.tsx | 726 ++++++++++++++++-- apps/desktop/src/styles.css | 61 ++ apps/web/app/api/desktop/jobs/route.ts | 50 ++ .../app/api/desktop/network/connect/route.ts | 54 ++ .../web/app/api/desktop/network/route.test.ts | 96 +++ apps/web/app/api/desktop/network/route.ts | 34 + .../app/api/desktop/opportunities/route.ts | 52 ++ apps/web/app/api/desktop/saved/route.ts | 70 ++ apps/web/app/api/desktop/search/route.test.ts | 126 +++ apps/web/app/api/desktop/search/route.ts | 60 ++ .../security/content-security-policy.test.ts | 2 + .../lib/security/content-security-policy.ts | 2 + apps/web/app/lib/security/rate-limit.ts | 2 + apps/web/next.config.ts | 4 +- apps/web/package.json | 2 +- docs/AUTHENTICATED_APP.md | 4 +- docs/IA_COMPARISON.md | 26 +- docs/IMPLEMENTATION_MATRIX.md | 42 +- docs/SECURITY.md | 21 +- docs/releases/v1.6.0.md | 27 + package.json | 2 +- 26 files changed, 1552 insertions(+), 104 deletions(-) create mode 100644 apps/web/app/api/desktop/jobs/route.ts create mode 100644 apps/web/app/api/desktop/network/connect/route.ts create mode 100644 apps/web/app/api/desktop/network/route.test.ts create mode 100644 apps/web/app/api/desktop/network/route.ts create mode 100644 apps/web/app/api/desktop/opportunities/route.ts create mode 100644 apps/web/app/api/desktop/saved/route.ts create mode 100644 apps/web/app/api/desktop/search/route.test.ts create mode 100644 apps/web/app/api/desktop/search/route.ts create mode 100644 docs/releases/v1.6.0.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 65e6f30..73d78ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,37 @@ All notable changes to VouchNet are documented here. This project follows ## Unreleased -### Saved posts and notification inbox +## 1.6.0 - 2026-10-28 -- Added saved posts as a real vertical slice: migration `0040_saved_posts`, `/api/saved`, feed Save - controls, and a private `/saved` page that re-checks visibility and block rules on every read. -- Added mark-all-as-read for member notifications at `/api/notifications/read-all`. -- Updated the implementation matrix and authenticated-app docs so messaging and saved content are - no longer described as missing stubs. +### Desktop expansion + +- Native Desktop Network: contacts overview, pending invitations (accept/decline), and mutual-context + recommendations with Connect actions via `/api/desktop/network` and `/api/desktop/network/connect`. +- Native Desktop Jobs and Organizations directories backed by `/api/desktop/jobs` with in-app search. +- Native Opportunities rail (`/api/desktop/opportunities`) with remote filter and brief cards. +- Native Saved posts (`/api/desktop/saved`) with unsave, plus command-palette people/jobs/company + search through `/api/desktop/search`. +- Desktop version bumped to **1.6.0** (Windows NSIS/MSI when the release workflow publishes). + +### Security hardening + +- Production CSP adds `upgrade-insecure-requests` (Experience `srcDoc` iframes remain allowed). +- Permissions-Policy denies payment, USB, Bluetooth, and Topics APIs; API routes emit `nosniff`. +- New `desktopRead` rate-limit tier (60/min) on Desktop search/jobs/opportunities; connection and + saved writes reuse `socialWrite` with device Bearer auth. +- Desktop Tauri CSP tightened (`frame-ancestors 'none'`, `worker-src 'none'`, `script-src 'self'`, + `upgrade-insecure-requests`). + +### LinkedIn parity documentation + +- Expanded `docs/IA_COMPARISON.md` with a JTBD side-by-side matrix (feed, network, messaging, + notifications, jobs, companies, saved, opportunities, desktop). +- Implementation matrix Desktop row updated for the authenticated native surfaces. + +### Earlier unreleased notes folded in + +- Saved posts vertical slice (`0040_saved_posts`, `/api/saved`, `/saved`) and notifications + mark-all-read shipped on main ahead of this Desktop expansion. ## 1.5.0 - 2026-10-28 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a59bf86..3e3a1d2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@vouchnet/desktop", "private": true, - "version": "1.5.0", + "version": "1.6.0", "type": "module", "scripts": { "dev": "vite", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 9b45e9c..69618c1 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vouchnet-desktop" -version = "1.5.0" +version = "1.6.0" description = "The installed VouchNet client" authors = ["VouchNet"] edition = "2021" diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index a657ce8..976e1bc 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -245,7 +245,37 @@ async fn authenticated_post( return Err("Your desktop session needs to be reconnected.".into()); } if !response.status().is_success() { - return Err("VouchNet could not send that message.".into()); + return Err("VouchNet could not complete that request.".into()); + } + response + .json() + .await + .map_err(|_| "VouchNet returned an invalid response.".into()) +} +async fn authenticated_delete( + state: &DesktopState, + path: &str, + payload: serde_json::Value, +) -> Result { + let token = access(state).await?; + let response = reqwest::Client::new() + .delete(format!("{}{}", base_url(), path)) + .bearer_auth(token) + .json(&payload) + .send() + .await + .map_err(|_| { + "VouchNet is temporarily unavailable. Check your connection and retry.".to_string() + })?; + if response.status().as_u16() == 401 { + *state + .access_token + .lock() + .map_err(|_| "Desktop session lock failed.")? = None; + return Err("Your desktop session needs to be reconnected.".into()); + } + if !response.status().is_success() { + return Err("VouchNet could not complete that request.".into()); } response .json() @@ -374,6 +404,115 @@ async fn desktop_mark_activity_read( .await } #[tauri::command] +async fn desktop_network(state: State<'_, DesktopState>) -> Result { + authenticated_get(&state, "/api/desktop/network").await +} +#[tauri::command] +async fn desktop_connect( + state: State<'_, DesktopState>, + payload: serde_json::Value, +) -> Result { + authenticated_post(&state, "/api/desktop/network/connect", payload).await +} +fn desktop_query_path(base: &str, pairs: &[(&str, &str)]) -> Result { + let mut url = Url::parse(&format!("https://vouchnet.local{base}")).map_err(|e| e.to_string())?; + { + let mut query = url.query_pairs_mut(); + for (key, value) in pairs { + query.append_pair(key, value); + } + } + Ok(format!("{}{}", url.path(), url.query().map(|q| format!("?{q}")).unwrap_or_default())) +} +#[tauri::command] +async fn desktop_search( + state: State<'_, DesktopState>, + query: String, +) -> Result { + let trimmed = query.trim(); + if trimmed.chars().count() > 80 { + return Err("Search query is too long.".into()); + } + let path = desktop_query_path("/api/desktop/search", &[("q", trimmed)])?; + authenticated_get(&state, &path).await +} +#[tauri::command] +async fn desktop_jobs( + state: State<'_, DesktopState>, + query: Option, +) -> Result { + let trimmed = query.unwrap_or_default(); + let trimmed = trimmed.trim(); + if trimmed.is_empty() { + return authenticated_get(&state, "/api/desktop/jobs").await; + } + if trimmed.chars().count() > 80 { + return Err("Search query is too long.".into()); + } + let path = desktop_query_path("/api/desktop/jobs", &[("q", trimmed)])?; + authenticated_get(&state, &path).await +} +#[tauri::command] +async fn desktop_opportunities( + state: State<'_, DesktopState>, + query: Option, + remote_only: Option, +) -> Result { + let mut pairs: Vec<(&str, String)> = Vec::new(); + if let Some(value) = query { + let trimmed = value.trim().to_string(); + if !trimmed.is_empty() { + if trimmed.chars().count() > 80 { + return Err("Search query is too long.".into()); + } + pairs.push(("q", trimmed)); + } + } + if remote_only.unwrap_or(false) { + pairs.push(("remote", "1".into())); + } + if pairs.is_empty() { + return authenticated_get(&state, "/api/desktop/opportunities").await; + } + let borrowed: Vec<(&str, &str)> = pairs.iter().map(|(k, v)| (*k, v.as_str())).collect(); + let path = desktop_query_path("/api/desktop/opportunities", &borrowed)?; + authenticated_get(&state, &path).await +} +#[tauri::command] +async fn desktop_saved(state: State<'_, DesktopState>) -> Result { + authenticated_get(&state, "/api/desktop/saved").await +} +#[tauri::command] +async fn desktop_save_post( + state: State<'_, DesktopState>, + post_id: String, +) -> Result { + if !is_uuid(&post_id) { + return Err("Invalid post.".into()); + } + authenticated_post( + &state, + "/api/desktop/saved", + serde_json::json!({ "postId": post_id }), + ) + .await +} +#[tauri::command] +async fn desktop_unsave_post( + state: State<'_, DesktopState>, + post_id: String, +) -> Result { + if !is_uuid(&post_id) { + return Err("Invalid post.".into()); + } + authenticated_delete( + &state, + "/api/desktop/saved", + serde_json::json!({ "postId": post_id }), + ) + .await +} +#[tauri::command] fn desktop_sign_out(state: State) -> Result<(), String> { *state .access_token @@ -398,6 +537,14 @@ fn main() { desktop_send_message, desktop_activity, desktop_mark_activity_read, + desktop_network, + desktop_connect, + desktop_search, + desktop_jobs, + desktop_opportunities, + desktop_saved, + desktop_save_post, + desktop_unsave_post, desktop_sign_out ]) .run(tauri::generate_context!()) diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 46e1980..cd2460f 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "VouchNet", - "version": "1.5.0", + "version": "1.6.0", "identifier": "dev.vouchnet.desktop", "build": { "beforeDevCommand": "pnpm dev", @@ -25,7 +25,7 @@ } ], "security": { - "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; connect-src 'self' https://vouchnet.dev; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'" + "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; connect-src 'self' https://vouchnet.dev; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'; frame-ancestors 'none'; worker-src 'none'; script-src 'self'; upgrade-insecure-requests" } }, "bundle": { diff --git a/apps/desktop/src/app.tsx b/apps/desktop/src/app.tsx index d606f76..a5d9f97 100644 --- a/apps/desktop/src/app.tsx +++ b/apps/desktop/src/app.tsx @@ -92,7 +92,92 @@ type ActivityItem = { readAt: string | null; createdAt: string; }; -type View = 'home' | 'network' | 'messages' | 'activity' | 'jobs' | 'organizations' | 'discover'; +type NetworkInvitation = { + connectionId: string; + firstName: string; + lastName: string; + headline: string | null; + slug: string; +}; +type NetworkPerson = { + userId: string; + slug: string; + firstName: string; + lastName: string; + headline: string | null; + mutualContacts: number; + score: number; + reasons: string[]; +}; +type NetworkPayload = { + overview: { + contactCount: number; + followingCount: number; + pendingReceivedCount: number; + pendingReceived: NetworkInvitation[]; + }; + recommendations: NetworkPerson[]; +}; +type DirectoryJob = DiscoveryJob & { + summary?: string; + workplaceType?: string; + employmentType?: string; + sourceStatus?: string; +}; +type DirectoryOrganization = { + slug: string; + name: string; + tagline: string | null; + headquarters?: string | null; + verificationStatus?: string; + technologies: string[]; + openRoleCount?: number; +}; +type OpportunityCard = { + id: string; + slug: string; + type: string; + title: string; + summary: string; + lookingFor: string; + location: string | null; + remote: boolean; + tags: string[]; + budgetMin: number | null; + budgetMax: number | null; + budgetCurrency: string; + proposalCount: number; + poster: { slug: string; name: string; headline: string | null }; + project: { slug: string; name: string } | null; + createdAt: string; +}; +type SavedPost = { + id: string; + authorName: string; + authorSlug: string; + authorHeadline: string | null; + bodyMarkdown: string; + category: string; + peerSignal: number; + commentCount: number; + createdAt: string; + savedAt: string; +}; +type SearchResults = { + query: string; + people: Array<{ + userId: string; + slug: string; + fullName: string; + headline: string | null; + location: string | null; + isPlus: boolean; + }>; + jobs: DiscoveryJob[]; + organizations: DirectoryOrganization[]; +}; +type View = + 'home' | 'network' | 'messages' | 'activity' | 'jobs' | 'organizations' | 'discover' | 'saved'; type AppStatus = 'connecting' | 'signed-out' | 'ready' | 'error'; const views: Array<{ id: View; label: string; icon: string; shortcut: string }> = [ @@ -102,7 +187,8 @@ const views: Array<{ id: View; label: string; icon: string; shortcut: string }> { id: 'activity', label: 'Activity', icon: '◌', shortcut: 'A' }, { id: 'jobs', label: 'Jobs', icon: '▣', shortcut: 'J' }, { id: 'organizations', label: 'Organizations', icon: '◇', shortcut: 'O' }, - { id: 'discover', label: 'Discover', icon: '◈', shortcut: 'D' }, + { id: 'discover', label: 'Opportunities', icon: '◈', shortcut: 'D' }, + { id: 'saved', label: 'Saved', icon: '☆', shortcut: 'S' }, ]; function verifiedDateLabel(iso: string) { @@ -145,21 +231,19 @@ function formatSalary(job: DiscoveryJob) { } return format(job.salaryMin ?? job.salaryMax ?? 0); } -function webRoute(view: View) { - switch (view) { - case 'network': - return '/mynetwork'; - case 'activity': - return '/notifications'; - case 'organizations': - return '/search?type=companies'; - case 'discover': - return '/discover'; - case 'home': - return '/feed'; - default: - return `/${view}`; - } +function emptySearch(): SearchResults { + return { query: '', people: [], jobs: [], organizations: [] }; +} +function emptyNetwork(): NetworkPayload { + return { + overview: { + contactCount: 0, + followingCount: 0, + pendingReceivedCount: 0, + pendingReceived: [], + }, + recommendations: [], + }; } function emptyDiscovery(): FeedDiscovery { return { @@ -179,6 +263,12 @@ export function App() { const [selectedConversationId, setSelectedConversationId] = useState(null); const [messages, setMessages] = useState([]); const [activity, setActivity] = useState([]); + const [network, setNetwork] = useState(emptyNetwork()); + const [directoryJobs, setDirectoryJobs] = useState([]); + const [directoryOrganizations, setDirectoryOrganizations] = useState([]); + const [opportunities, setOpportunities] = useState([]); + const [savedPosts, setSavedPosts] = useState([]); + const [searchResults, setSearchResults] = useState(emptySearch()); const [composer, setComposer] = useState(''); const [attachmentUrl, setAttachmentUrl] = useState(''); const [attachments, setAttachments] = useState([]); @@ -187,8 +277,16 @@ export function App() { const [commandOpen, setCommandOpen] = useState(false); const [commandQuery, setCommandQuery] = useState(''); const [threadQuery, setThreadQuery] = useState(''); + const [jobsQuery, setJobsQuery] = useState(''); + const [opportunityQuery, setOpportunityQuery] = useState(''); + const [remoteOnly, setRemoteOnly] = useState(false); const [messageLoading, setMessageLoading] = useState(false); const [activityLoading, setActivityLoading] = useState(false); + const [networkLoading, setNetworkLoading] = useState(false); + const [jobsLoading, setJobsLoading] = useState(false); + const [opportunitiesLoading, setOpportunitiesLoading] = useState(false); + const [savedLoading, setSavedLoading] = useState(false); + const [searchLoading, setSearchLoading] = useState(false); const [sending, setSending] = useState(false); const knownUnreadRef = useRef(0); const selectedConversation = useMemo( @@ -219,6 +317,7 @@ export function App() { { id: 'profile', label: 'Open my profile', icon: '◉', path: `/vouch/${profile?.slug ?? ''}` }, { id: 'games', label: 'Play today’s puzzle', icon: '▦', path: '/games' }, { id: 'settings', label: 'Account settings', icon: '⚙', path: '/settings/account' }, + { id: 'web-search', label: 'Open web search', icon: '⌕', path: '/search' }, ].filter((item) => item.label.toLowerCase().includes(query)); return { navigation, extras }; }, [commandQuery, profile?.slug]); @@ -263,6 +362,8 @@ export function App() { setDiscovery(feedPayload.discovery ?? emptyDiscovery()); setConversations(conversationPayload.conversations); setActivity(activityPayload.notifications); + setDirectoryJobs(feedPayload.discovery?.jobs ?? []); + setDirectoryOrganizations(feedPayload.discovery?.organizations ?? []); setSelectedConversationId( (current) => current ?? conversationPayload.conversations[0]?.id ?? null, ); @@ -349,6 +450,71 @@ export function App() { ) .finally(() => setActivityLoading(false)); }, [status, view]); + useEffect(() => { + if (view !== 'network' || status !== 'ready') return; + setNetworkLoading(true); + void invoke('desktop_network') + .then((payload) => setNetwork(payload)) + .catch((error: unknown) => + setNotice(error instanceof Error ? error.message : 'Network could not be loaded.'), + ) + .finally(() => setNetworkLoading(false)); + }, [status, view]); + useEffect(() => { + if ((view !== 'jobs' && view !== 'organizations') || status !== 'ready') return; + setJobsLoading(true); + void invoke<{ jobs: DirectoryJob[]; organizations: DirectoryOrganization[] }>('desktop_jobs', { + query: jobsQuery.trim() || null, + }) + .then((payload) => { + setDirectoryJobs(payload.jobs); + setDirectoryOrganizations(payload.organizations); + }) + .catch((error: unknown) => + setNotice(error instanceof Error ? error.message : 'Jobs directory could not be loaded.'), + ) + .finally(() => setJobsLoading(false)); + }, [jobsQuery, status, view]); + useEffect(() => { + if (view !== 'discover' || status !== 'ready') return; + setOpportunitiesLoading(true); + void invoke<{ opportunities: OpportunityCard[] }>('desktop_opportunities', { + query: opportunityQuery.trim() || null, + remoteOnly, + }) + .then((payload) => setOpportunities(payload.opportunities)) + .catch((error: unknown) => + setNotice(error instanceof Error ? error.message : 'Opportunities could not be loaded.'), + ) + .finally(() => setOpportunitiesLoading(false)); + }, [opportunityQuery, remoteOnly, status, view]); + useEffect(() => { + if (view !== 'saved' || status !== 'ready') return; + setSavedLoading(true); + void invoke<{ posts: SavedPost[] }>('desktop_saved') + .then((payload) => setSavedPosts(payload.posts)) + .catch((error: unknown) => + setNotice(error instanceof Error ? error.message : 'Saved posts could not be loaded.'), + ) + .finally(() => setSavedLoading(false)); + }, [status, view]); + useEffect(() => { + if (!commandOpen || status !== 'ready') return; + const query = commandQuery.trim(); + if (query.length < 2) { + setSearchResults(emptySearch()); + setSearchLoading(false); + return; + } + setSearchLoading(true); + const timer = window.setTimeout(() => { + void invoke('desktop_search', { query }) + .then((payload) => setSearchResults(payload)) + .catch(() => setSearchResults(emptySearch())) + .finally(() => setSearchLoading(false)); + }, 250); + return () => window.clearTimeout(timer); + }, [commandOpen, commandQuery, status]); useEffect(() => { if (status !== 'ready') return; const timer = window.setInterval(() => { @@ -397,9 +563,54 @@ export function App() { setConversations([]); setMessages([]); setActivity([]); + setNetwork(emptyNetwork()); + setDirectoryJobs([]); + setDirectoryOrganizations([]); + setOpportunities([]); + setSavedPosts([]); + setSearchResults(emptySearch()); knownUnreadRef.current = 0; setStatus('signed-out'); } + async function connectWith(userId: string) { + try { + await invoke('desktop_connect', { payload: { targetUserId: userId } }); + setNetwork((current) => ({ + ...current, + recommendations: current.recommendations.filter((person) => person.userId !== userId), + })); + setNotice('Connection request sent.'); + } catch (error) { + setNotice(error instanceof Error ? error.message : 'Connection request failed.'); + } + } + async function respondInvitation(connectionId: string, action: 'accept' | 'decline') { + try { + await invoke('desktop_connect', { payload: { connectionId, action } }); + setNetwork((current) => ({ + ...current, + overview: { + ...current.overview, + pendingReceived: current.overview.pendingReceived.filter( + (item) => item.connectionId !== connectionId, + ), + pendingReceivedCount: Math.max(0, current.overview.pendingReceivedCount - 1), + contactCount: + action === 'accept' ? current.overview.contactCount + 1 : current.overview.contactCount, + }, + })); + } catch (error) { + setNotice(error instanceof Error ? error.message : 'Invitation could not be updated.'); + } + } + async function unsavePost(postId: string) { + try { + await invoke('desktop_unsave_post', { postId }); + setSavedPosts((current) => current.filter((post) => post.id !== postId)); + } catch (error) { + setNotice(error instanceof Error ? error.message : 'Could not remove that saved post.'); + } + } async function sendMessage() { if (!selectedConversationId || sending || (!composer.trim() && !attachments.length)) return; setSending(true); @@ -675,21 +886,50 @@ export function App() { /> ) : view === 'jobs' ? ( void openWeb('/jobs')} onOpenJob={(slug) => void openWeb(`/jobs/${slug}`)} + onQueryChange={setJobsQuery} /> ) : view === 'organizations' ? ( void openWeb('/companies')} onOpenOrganization={(slug) => void openWeb(`/company/${slug}`)} + onQueryChange={setJobsQuery} + /> + ) : view === 'network' ? ( + void respondInvitation(id, 'accept')} + onConnect={(userId) => void connectWith(userId)} + onDecline={(id) => void respondInvitation(id, 'decline')} + onOpenAll={() => void openWeb('/mynetwork')} + onOpenProfile={(slug) => void openWeb(`/vouch/${slug}`)} + /> + ) : view === 'discover' ? ( + void openWeb('/opportunities')} + onOpenOpportunity={(slug) => void openWeb(`/opportunities/${slug}`)} + onQueryChange={setOpportunityQuery} + onRemoteOnlyChange={setRemoteOnly} /> ) : ( - item.id === view)?.label ?? 'VouchNet'} - view={view} - onOpen={() => void openWeb(webRoute(view))} + void openWeb('/saved')} + onOpenPost={(id) => void openWeb(`/feed?post=${id}`)} + onUnsave={(id) => void unsavePost(id)} /> )} @@ -806,6 +1046,66 @@ export function App() { ↗ ))} + {commandQuery.trim().length >= 2 ? ( + <> +

+ {searchLoading + ? 'Searching VouchNet…' + : `Results for “${searchResults.query || commandQuery.trim()}”`} +

+ {searchResults.people.map((person) => ( + + ))} + {searchResults.jobs.map((job) => ( + + ))} + {searchResults.organizations.map((organization) => ( + + ))} + {!searchLoading && + searchResults.people.length + + searchResults.jobs.length + + searchResults.organizations.length === + 0 ? ( +

+ No people, jobs, or companies matched that query. +

+ ) : null} + + ) : null}

Esc to close · letter keys jump between spaces

@@ -1324,12 +1624,18 @@ function ActivityView({ function JobsView({ jobs, + loading, + query, onBrowse, onOpenJob, + onQueryChange, }: { - jobs: DiscoveryJob[]; + jobs: DirectoryJob[]; + loading: boolean; + query: string; onBrowse: () => void; onOpenJob: (slug: string) => void; + onQueryChange: (value: string) => void; }) { return (
@@ -1342,11 +1648,26 @@ function JobsView({ Browse all jobs ↗ - {jobs.length === 0 ? ( +
+ ⌕{' '} + onQueryChange(event.target.value)} + placeholder="Search roles, companies, or skills" + value={query} + /> +
+ {loading ? ( +
+ + + +
+ ) : jobs.length === 0 ? (
▣ -

No curated roles right now

-

Open the full jobs directory for salary-forward listings as they are reviewed.

+

No roles matched

+

Try another skill, company, or open the full salary-forward jobs directory.

) : (
@@ -1377,12 +1698,18 @@ function JobsView({ function OrganizationsView({ organizations, + loading, + query, onBrowse, onOpenOrganization, + onQueryChange, }: { - organizations: DiscoveryOrganization[]; + organizations: DirectoryOrganization[]; + loading: boolean; + query: string; onBrowse: () => void; onOpenOrganization: (slug: string) => void; + onQueryChange: (value: string) => void; }) { return (
@@ -1395,7 +1722,22 @@ function OrganizationsView({ Browse companies ↗
- {organizations.length === 0 ? ( +
+ ⌕{' '} + onQueryChange(event.target.value)} + placeholder="Search companies or technologies" + value={query} + /> +
+ {loading ? ( +
+ + + +
+ ) : organizations.length === 0 ? (
◇

Directory quiet for now

@@ -1406,8 +1748,11 @@ function OrganizationsView({ {organizations.map((organization) => (

- {organization.openRoleCount} open role - {organization.openRoleCount === 1 ? '' : 's'} + {organization.verificationStatus === 'DOMAIN_VERIFIED' + ? 'Domain verified' + : organization.openRoleCount !== undefined + ? `${organization.openRoleCount} open role${organization.openRoleCount === 1 ? '' : 's'}` + : 'Source reviewed'}

{organization.name}

{organization.tagline ?? 'Source-reviewed organization on VouchNet.'}

@@ -1429,37 +1774,296 @@ function OrganizationsView({ ); } -function NativeSurface({ title, view, onOpen }: { title: string; view: View; onOpen: () => void }) { - const content: Record< - 'network' | 'discover', - { eyebrow: string; heading: string; body: string; action: string } - > = { - network: { - eyebrow: 'Professional graph', - heading: 'Build a network with context.', - body: 'Discover people through shared work, clear professional details, and explanations—not noisy suggestions.', - action: 'Explore your network', - }, - discover: { - eyebrow: 'Build in public', - heading: 'See who is shipping useful work.', - body: 'People building, project momentum, and open opportunities ranked from recorded activity—with reasons attached.', - action: 'Open Discover', - }, - }; - const item = content[view as 'network' | 'discover']; +function NetworkView({ + network, + loading, + onAccept, + onDecline, + onConnect, + onOpenProfile, + onOpenAll, +}: { + network: NetworkPayload; + loading: boolean; + onAccept: (connectionId: string) => void; + onDecline: (connectionId: string) => void; + onConnect: (userId: string) => void; + onOpenProfile: (slug: string) => void; + onOpenAll: () => void; +}) { return ( -
-
{views.find((entry) => entry.id === view)?.icon}
-

{item.eyebrow}

-

{item.heading}

-

{item.body}

- - - {title} opens in the full VouchNet workspace so desktop and web stay on one account. - +
+
+
+

Professional graph

+

Build a network with context.

+
+ +
+
+
+ {network.overview.contactCount} + Contacts +
+
+ {network.overview.followingCount} + Following +
+
+ {network.overview.pendingReceivedCount} + Invitations +
+
+ {loading ? ( +
+ + + +
+ ) : ( + <> + {network.overview.pendingReceived.length ? ( +
+

Pending invitations

+
+ {network.overview.pendingReceived.map((invitation) => ( +
+

Wants to connect

+

+ {invitation.firstName} {invitation.lastName} +

+

{invitation.headline ?? 'VouchNet member'}

+
+ + + +
+
+ ))} +
+
+ ) : null} +
+

People you may know

+ {network.recommendations.length === 0 ? ( +
+ ◎ +

No recommendations right now

+

As your contacts grow, mutual-context suggestions appear here.

+
+ ) : ( +
+ {network.recommendations.map((person) => ( +
+

+ {person.mutualContacts} mutual contact + {person.mutualContacts === 1 ? '' : 's'} +

+

+ {person.firstName} {person.lastName} +

+

{person.headline ?? 'VouchNet member'}

+
+ + +
+
+ ))} +
+ )} +
+ + )} +
+ ); +} + +function OpportunitiesView({ + opportunities, + loading, + query, + remoteOnly, + onBrowse, + onOpenOpportunity, + onQueryChange, + onRemoteOnlyChange, +}: { + opportunities: OpportunityCard[]; + loading: boolean; + query: string; + remoteOnly: boolean; + onBrowse: () => void; + onOpenOpportunity: (slug: string) => void; + onQueryChange: (value: string) => void; + onRemoteOnlyChange: (value: boolean) => void; +}) { + return ( +
+
+
+

Build in public

+

Open work worth proposing on.

+
+ +
+
+
+ ⌕{' '} + onQueryChange(event.target.value)} + placeholder="Search briefs, skills, or posters" + value={query} + /> +
+ +
+ {loading ? ( +
+ + + +
+ ) : opportunities.length === 0 ? ( +
+ ◈ +

No open opportunities matched

+

Try clearing filters or post a brief from the web workspace.

+
+ ) : ( +
+ {opportunities.map((opportunity) => ( +
+

+ {opportunity.type.replaceAll('_', ' ')} · {opportunity.proposalCount} proposal + {opportunity.proposalCount === 1 ? '' : 's'} +

+

{opportunity.title}

+

{opportunity.summary}

+ + {opportunity.poster.name} + {opportunity.remote + ? ' · Remote' + : opportunity.location + ? ` · ${opportunity.location}` + : ''} + + {opportunity.tags.length ? ( +
+ {opportunity.tags.slice(0, 4).map((tag) => ( + {tag} + ))} +
+ ) : null} + +
+ ))} +
+ )} +
+ ); +} + +function SavedView({ + posts, + loading, + onOpenPost, + onUnsave, + onOpenAll, +}: { + posts: SavedPost[]; + loading: boolean; + onOpenPost: (id: string) => void; + onUnsave: (id: string) => void; + onOpenAll: () => void; +}) { + return ( +
+
+
+

Your reading list

+

Posts worth returning to.

+
+ +
+ {loading ? ( +
+ + + +
+ ) : posts.length === 0 ? ( +
+ ☆ +

Nothing saved yet

+

Save posts from the feed on web or desktop when you want to revisit them later.

+
+ ) : ( +
+ {posts.map((post) => ( +
+
+ {initials(post.authorName)} +
+ {post.authorName} + + {post.authorHeadline ?? 'VouchNet member'} · saved {formatDate(post.savedAt)} + +
+ +
+

{post.bodyMarkdown}

+
+ + ⌁ {post.peerSignal} verified signal · {post.commentCount} comments + + +
+
+ ))} +
+ )}
); } diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index beff0ee..41cb754 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -734,6 +734,67 @@ textarea:focus-visible { display: grid; gap: 16px; } +.directory-search { + max-width: 420px; +} +.network-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} +.network-stats article { + border: 1px solid var(--vn-line); + border-radius: var(--vn-radius-surface); + background: var(--vn-surface); + padding: 14px 16px; +} +.network-stats strong { + display: block; + font-size: 1.35rem; + letter-spacing: -0.04em; +} +.network-stats span { + color: var(--vn-muted); + font-size: 0.68rem; +} +.network-section { + display: grid; + gap: 12px; +} +.network-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} +.network-actions button { + min-height: 30px; + padding: 0 12px; + border: 1px solid #4d6ea8; + border-radius: 8px; + color: #d7e5ff; + background: #173055; + font-size: 0.68rem; + font-weight: 700; +} +.network-actions .button-quiet { + border-color: var(--vn-line); + background: transparent; + color: var(--vn-muted); +} +.opportunity-filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} +.remote-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--vn-muted); + font-size: 0.72rem; +} .surface-toolbar { display: flex; align-items: flex-end; diff --git a/apps/web/app/api/desktop/jobs/route.ts b/apps/web/app/api/desktop/jobs/route.ts new file mode 100644 index 0000000..4d405a6 --- /dev/null +++ b/apps/web/app/api/desktop/jobs/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { desktopActorFromBearer } from '../../../lib/desktop-auth'; +import { listPublicJobs, listPublicOrganizations } from '../../../lib/directory'; +import { enforceRateLimit } from '../../../lib/security/rate-limit'; +import { rateLimitResponse } from '../../../lib/security/rate-limit-response'; + +/** + * Authenticated desktop jobs directory. Uses the same public job records as the web directory so + * salary transparency and source status stay identical across clients. + */ +export async function GET(request: NextRequest) { + try { + const actor = await desktopActorFromBearer(request.headers.get('authorization')); + if (actor === null) return NextResponse.json({ error: 'UNAUTHENTICATED' }, { status: 401 }); + const rateLimit = await enforceRateLimit(request, 'desktopRead', actor.userId); + if (!rateLimit.allowed) return rateLimitResponse(rateLimit); + const query = (request.nextUrl.searchParams.get('q') ?? '').trim().slice(0, 80); + const [jobs, organizations] = await Promise.all([ + listPublicJobs(query || undefined, 30), + listPublicOrganizations(query || undefined, 24), + ]); + return NextResponse.json({ + jobs: jobs.map((job) => ({ + slug: job.slug, + title: job.title, + summary: job.summary, + organizationName: job.organizationName, + organizationSlug: job.organizationSlug, + location: job.location, + workplaceType: job.workplaceType, + employmentType: job.employmentType, + salaryMin: job.salaryMin, + salaryMax: job.salaryMax, + salaryCurrency: job.salaryCurrency, + skillTags: job.skillTags, + sourceStatus: job.sourceStatus, + })), + organizations: organizations.map((organization) => ({ + slug: organization.slug, + name: organization.name, + tagline: organization.tagline, + headquarters: organization.headquarters, + verificationStatus: organization.verificationStatus, + technologies: organization.technologies, + })), + }); + } catch { + return NextResponse.json({ error: 'JOBS_UNAVAILABLE' }, { status: 503 }); + } +} diff --git a/apps/web/app/api/desktop/network/connect/route.ts b/apps/web/app/api/desktop/network/connect/route.ts new file mode 100644 index 0000000..7a3e4a3 --- /dev/null +++ b/apps/web/app/api/desktop/network/connect/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { desktopActorFromBearer } from '../../../../lib/desktop-auth'; +import { + ConnectionCooldownError, + requestConnection, + respondToConnection, +} from '../../../../lib/social'; +import { enforceRateLimit } from '../../../../lib/security/rate-limit'; +import { rateLimitResponse } from '../../../../lib/security/rate-limit-response'; + +const requestSchema = z.object({ targetUserId: z.string().uuid() }).strict(); +const responseSchema = z + .object({ + connectionId: z.string().uuid(), + action: z.enum(['accept', 'decline']), + }) + .strict(); + +/** + * Device Bearer tokens replace cookie CSRF for desktop connection writes. Rate limits still apply. + */ +export async function POST(request: NextRequest) { + try { + const actor = await desktopActorFromBearer(request.headers.get('authorization')); + if (actor === null) return NextResponse.json({ error: 'UNAUTHENTICATED' }, { status: 401 }); + const rateLimit = await enforceRateLimit(request, 'socialWrite', actor.userId); + if (!rateLimit.allowed) return rateLimitResponse(rateLimit); + const body: unknown = await request.json(); + if (typeof body === 'object' && body !== null && 'targetUserId' in body) { + await requestConnection(actor.userId, requestSchema.parse(body).targetUserId); + return NextResponse.json({ state: 'PENDING' }, { status: 201 }); + } + const data = responseSchema.parse(body); + await respondToConnection( + actor.userId, + data.connectionId, + data.action === 'accept' ? 'ACCEPTED' : 'DECLINED', + ); + return NextResponse.json({ state: data.action === 'accept' ? 'ACCEPTED' : 'DECLINED' }); + } catch (error) { + if (error instanceof ConnectionCooldownError) + return NextResponse.json( + { error: 'CONNECTION_COOLDOWN', resetsAt: error.resetsAt.toISOString() }, + { status: 429 }, + ); + if (error instanceof z.ZodError) + return NextResponse.json({ error: 'INVALID_CONNECTION' }, { status: 400 }); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'CONNECTION_FAILED' }, + { status: 400 }, + ); + } +} diff --git a/apps/web/app/api/desktop/network/route.test.ts b/apps/web/app/api/desktop/network/route.test.ts new file mode 100644 index 0000000..65ff6d5 --- /dev/null +++ b/apps/web/app/api/desktop/network/route.test.ts @@ -0,0 +1,96 @@ +import { NextRequest } from 'next/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GET } from './route'; + +const state = vi.hoisted(() => ({ + actor: { userId: 'user-1' } as { userId: string } | null, + fail: false, +})); + +vi.mock('../../../lib/desktop-auth', () => ({ + desktopActorFromBearer: async () => state.actor, +})); + +vi.mock('../../../lib/social', () => ({ + getNetworkOverview: async () => { + if (state.fail) throw new Error('DATABASE_UNAVAILABLE'); + return { + contactCount: 3, + followingCount: 5, + pendingReceivedCount: 1, + pendingReceived: [ + { + connectionId: '22222222-2222-2222-2222-222222222222', + firstName: 'Ada', + lastName: 'Lovelace', + headline: 'Computing pioneer', + slug: 'ada', + }, + ], + }; + }, +})); + +vi.mock('../../../lib/discovery', () => ({ + discoverPeople: async () => [ + { + user_id: '33333333-3333-3333-3333-333333333333', + slug: 'grace', + first_name: 'Grace', + last_name: 'Hopper', + headline: 'Compiler author', + mutual_contacts: 2, + score: 0.91, + reasons: ['MUTUAL_CONTACTS'], + }, + ], +})); + +describe('desktop network', () => { + beforeEach(() => { + state.actor = { userId: 'user-1' }; + state.fail = false; + }); + + it('requires a desktop device bearer token', async () => { + state.actor = null; + const response = await GET( + new NextRequest('https://vouchnet.dev/api/desktop/network', { + headers: { authorization: 'Bearer missing' }, + }), + ); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'UNAUTHENTICATED' }); + }); + + it('returns overview and recommendations for the device owner', async () => { + const response = await GET( + new NextRequest('https://vouchnet.dev/api/desktop/network', { + headers: { authorization: 'Bearer desktop-token' }, + }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + overview: { contactCount: 3, pendingReceivedCount: 1 }, + recommendations: [ + { + slug: 'grace', + firstName: 'Grace', + lastName: 'Hopper', + mutualContacts: 2, + }, + ], + }); + }); + + it('hides database failures behind a desktop-safe unavailable response', async () => { + state.fail = true; + const response = await GET( + new NextRequest('https://vouchnet.dev/api/desktop/network', { + headers: { authorization: 'Bearer desktop-token' }, + }), + ); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: 'NETWORK_UNAVAILABLE' }); + }); +}); diff --git a/apps/web/app/api/desktop/network/route.ts b/apps/web/app/api/desktop/network/route.ts new file mode 100644 index 0000000..bd2af92 --- /dev/null +++ b/apps/web/app/api/desktop/network/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { desktopActorFromBearer } from '../../../lib/desktop-auth'; +import { discoverPeople } from '../../../lib/discovery'; +import { getNetworkOverview } from '../../../lib/social'; + +/** + * Desktop network uses the device Bearer token so the Tauri client can load contacts, + * pending invitations, and people recommendations without a browser session cookie. + */ +export async function GET(request: NextRequest) { + try { + const actor = await desktopActorFromBearer(request.headers.get('authorization')); + if (actor === null) return NextResponse.json({ error: 'UNAUTHENTICATED' }, { status: 401 }); + const [overview, recommendations] = await Promise.all([ + getNetworkOverview(actor.userId), + discoverPeople(actor.userId), + ]); + return NextResponse.json({ + overview, + recommendations: recommendations.slice(0, 24).map((person) => ({ + userId: person.user_id, + slug: person.slug, + firstName: person.first_name, + lastName: person.last_name, + headline: person.headline, + mutualContacts: person.mutual_contacts, + score: person.score, + reasons: person.reasons, + })), + }); + } catch { + return NextResponse.json({ error: 'NETWORK_UNAVAILABLE' }, { status: 503 }); + } +} diff --git a/apps/web/app/api/desktop/opportunities/route.ts b/apps/web/app/api/desktop/opportunities/route.ts new file mode 100644 index 0000000..79e523f --- /dev/null +++ b/apps/web/app/api/desktop/opportunities/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { desktopActorFromBearer } from '../../../lib/desktop-auth'; +import { listOpportunities } from '../../../lib/opportunities'; +import { enforceRateLimit } from '../../../lib/security/rate-limit'; +import { rateLimitResponse } from '../../../lib/security/rate-limit-response'; + +/** + * Desktop opportunities surface for build-in-public work requests. Visibility rules match the web + * opportunities list for the signed-in device owner. + */ +export async function GET(request: NextRequest) { + try { + const actor = await desktopActorFromBearer(request.headers.get('authorization')); + if (actor === null) return NextResponse.json({ error: 'UNAUTHENTICATED' }, { status: 401 }); + const rateLimit = await enforceRateLimit(request, 'desktopRead', actor.userId); + if (!rateLimit.allowed) return rateLimitResponse(rateLimit); + const query = (request.nextUrl.searchParams.get('q') ?? '').trim().slice(0, 80); + const remoteOnly = request.nextUrl.searchParams.get('remote') === '1'; + const opportunities = await listOpportunities({ + viewerId: actor.userId, + q: query.length >= 2 ? query : null, + remoteOnly, + limit: 40, + }); + return NextResponse.json({ + opportunities: opportunities.map((opportunity) => ({ + id: opportunity.id, + slug: opportunity.slug, + type: opportunity.type, + title: opportunity.title, + summary: opportunity.summary, + lookingFor: opportunity.lookingFor, + location: opportunity.location, + remote: opportunity.remote, + tags: opportunity.tags, + budgetMin: opportunity.budgetMin, + budgetMax: opportunity.budgetMax, + budgetCurrency: opportunity.budgetCurrency, + deadline: opportunity.deadline, + proposalCount: opportunity.proposalCount, + poster: opportunity.poster, + project: opportunity.project, + createdAt: + opportunity.createdAt instanceof Date + ? opportunity.createdAt.toISOString() + : String(opportunity.createdAt), + })), + }); + } catch { + return NextResponse.json({ error: 'OPPORTUNITIES_UNAVAILABLE' }, { status: 503 }); + } +} diff --git a/apps/web/app/api/desktop/saved/route.ts b/apps/web/app/api/desktop/saved/route.ts new file mode 100644 index 0000000..3ddb9fd --- /dev/null +++ b/apps/web/app/api/desktop/saved/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { desktopActorFromBearer } from '../../../lib/desktop-auth'; +import { listSavedPosts, savePost, unsavePost } from '../../../lib/saved-posts'; +import { enforceRateLimit } from '../../../lib/security/rate-limit'; +import { rateLimitResponse } from '../../../lib/security/rate-limit-response'; + +const saveSchema = z.object({ postId: z.string().uuid() }).strict(); + +/** + * Desktop saved-posts inbox. Reads re-check visibility and block rules; writes use the device + * Bearer token instead of cookie CSRF. + */ +export async function GET(request: NextRequest) { + try { + const actor = await desktopActorFromBearer(request.headers.get('authorization')); + if (actor === null) return NextResponse.json({ error: 'UNAUTHENTICATED' }, { status: 401 }); + const posts = await listSavedPosts(actor.userId); + return NextResponse.json({ + posts: posts.map((post) => ({ + id: post.id, + authorName: post.authorName, + authorSlug: post.authorSlug, + authorHeadline: post.authorHeadline, + bodyMarkdown: post.bodyMarkdown, + category: post.category, + peerSignal: post.peerSignal, + commentCount: post.commentCount, + createdAt: post.createdAt.toISOString(), + savedAt: post.savedAt.toISOString(), + })), + }); + } catch { + return NextResponse.json({ error: 'SAVED_UNAVAILABLE' }, { status: 503 }); + } +} + +export async function POST(request: NextRequest) { + try { + const actor = await desktopActorFromBearer(request.headers.get('authorization')); + if (actor === null) return NextResponse.json({ error: 'UNAUTHENTICATED' }, { status: 401 }); + const rateLimit = await enforceRateLimit(request, 'socialWrite', actor.userId); + if (!rateLimit.allowed) return rateLimitResponse(rateLimit); + const body = saveSchema.parse(await request.json()); + const result = await savePost(actor.userId, body.postId); + return NextResponse.json({ saved: true, result }); + } catch (error) { + if (error instanceof z.ZodError) + return NextResponse.json({ error: 'INVALID_SAVE' }, { status: 400 }); + if (error instanceof Error && error.message === 'POST_NOT_FOUND') + return NextResponse.json({ error: 'POST_NOT_FOUND' }, { status: 404 }); + return NextResponse.json({ error: 'SAVE_FAILED' }, { status: 400 }); + } +} + +export async function DELETE(request: NextRequest) { + try { + const actor = await desktopActorFromBearer(request.headers.get('authorization')); + if (actor === null) return NextResponse.json({ error: 'UNAUTHENTICATED' }, { status: 401 }); + const rateLimit = await enforceRateLimit(request, 'socialWrite', actor.userId); + if (!rateLimit.allowed) return rateLimitResponse(rateLimit); + const body = saveSchema.parse(await request.json()); + await unsavePost(actor.userId, body.postId); + return NextResponse.json({ saved: false }); + } catch (error) { + if (error instanceof z.ZodError) + return NextResponse.json({ error: 'INVALID_SAVE' }, { status: 400 }); + return NextResponse.json({ error: 'UNSAVE_FAILED' }, { status: 400 }); + } +} diff --git a/apps/web/app/api/desktop/search/route.test.ts b/apps/web/app/api/desktop/search/route.test.ts new file mode 100644 index 0000000..a2b3d51 --- /dev/null +++ b/apps/web/app/api/desktop/search/route.test.ts @@ -0,0 +1,126 @@ +import { NextRequest } from 'next/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GET } from './route'; + +const state = vi.hoisted(() => ({ + actor: { userId: 'user-1' } as { userId: string } | null, + allowed: true as boolean, +})); + +vi.mock('../../../lib/desktop-auth', () => ({ + desktopActorFromBearer: async () => state.actor, +})); + +vi.mock('../../../lib/security/rate-limit', () => ({ + enforceRateLimit: async () => + state.allowed ? { allowed: true as const } : { allowed: false as const, retryAfterSeconds: 30 }, +})); + +vi.mock('../../../lib/security/rate-limit-response', () => ({ + rateLimitResponse: () => + new Response(JSON.stringify({ error: 'RATE_LIMITED' }), { + status: 429, + headers: { 'content-type': 'application/json' }, + }), +})); + +vi.mock('../../../lib/people', () => ({ + searchPeople: async () => [ + { + userId: '11111111-1111-1111-1111-111111111111', + slug: 'ada', + firstName: 'Ada', + lastName: 'Lovelace', + headline: 'Analyst', + location: 'London', + isPlus: true, + }, + ], +})); + +vi.mock('../../../lib/directory', () => ({ + listPublicJobs: async () => [ + { + slug: 'staff-engineer', + title: 'Staff Engineer', + summary: 'Build trust graphs', + location: 'Remote', + workplaceType: 'REMOTE', + employmentType: 'FULL_TIME', + salaryMin: 180000, + salaryMax: 220000, + salaryCurrency: 'USD', + skillTags: ['TypeScript'], + sourceUrl: 'https://example.com', + sourceCheckedAt: new Date('2026-10-28T12:00:00.000Z'), + sourceStatus: 'SOURCE_LIVE', + organizationName: 'VouchNet Labs', + organizationSlug: 'vouchnet-labs', + }, + ], + listPublicOrganizations: async () => [ + { + slug: 'vouchnet-labs', + name: 'VouchNet Labs', + tagline: 'Signal over noise', + headquarters: 'Remote', + verificationStatus: 'DOMAIN_VERIFIED', + technologies: ['TypeScript'], + }, + ], +})); + +describe('desktop search', () => { + beforeEach(() => { + state.actor = { userId: 'user-1' }; + state.allowed = true; + }); + + it('requires a desktop device bearer token', async () => { + state.actor = null; + const response = await GET( + new NextRequest('https://vouchnet.dev/api/desktop/search?q=ada', { + headers: { authorization: 'Bearer missing' }, + }), + ); + expect(response.status).toBe(401); + }); + + it('returns empty collections for short queries without calling search backends', async () => { + const response = await GET( + new NextRequest('https://vouchnet.dev/api/desktop/search?q=a', { + headers: { authorization: 'Bearer desktop-token' }, + }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + query: 'a', + people: [], + jobs: [], + organizations: [], + }); + }); + + it('returns people, jobs, and organizations for a valid query', async () => { + const response = await GET( + new NextRequest('https://vouchnet.dev/api/desktop/search?q=ada', { + headers: { authorization: 'Bearer desktop-token' }, + }), + ); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.people[0].fullName).toBe('Ada Lovelace'); + expect(body.jobs[0].slug).toBe('staff-engineer'); + expect(body.organizations[0].slug).toBe('vouchnet-labs'); + }); + + it('enforces the desktop read rate limit', async () => { + state.allowed = false; + const response = await GET( + new NextRequest('https://vouchnet.dev/api/desktop/search?q=ada', { + headers: { authorization: 'Bearer desktop-token' }, + }), + ); + expect(response.status).toBe(429); + }); +}); diff --git a/apps/web/app/api/desktop/search/route.ts b/apps/web/app/api/desktop/search/route.ts new file mode 100644 index 0000000..8e4f0bc --- /dev/null +++ b/apps/web/app/api/desktop/search/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { desktopActorFromBearer } from '../../../lib/desktop-auth'; +import { listPublicJobs, listPublicOrganizations } from '../../../lib/directory'; +import { searchPeople } from '../../../lib/people'; +import { enforceRateLimit } from '../../../lib/security/rate-limit'; +import { rateLimitResponse } from '../../../lib/security/rate-limit-response'; + +/** + * Unified desktop search across people, jobs, and organizations. Query length is bounded so a + * device token cannot fan out expensive ILIKE scans without a rate-limit gate. + */ +export async function GET(request: NextRequest) { + try { + const actor = await desktopActorFromBearer(request.headers.get('authorization')); + if (actor === null) return NextResponse.json({ error: 'UNAUTHENTICATED' }, { status: 401 }); + const rateLimit = await enforceRateLimit(request, 'desktopRead', actor.userId); + if (!rateLimit.allowed) return rateLimitResponse(rateLimit); + const query = (request.nextUrl.searchParams.get('q') ?? '').trim().slice(0, 80); + if (query.length < 2) { + return NextResponse.json({ query, people: [], jobs: [], organizations: [] }); + } + const [people, jobs, organizations] = await Promise.all([ + searchPeople(actor.userId, query), + listPublicJobs(query, 12), + listPublicOrganizations(query, 12), + ]); + return NextResponse.json({ + query, + people: people.map((person) => ({ + userId: person.userId, + slug: person.slug, + fullName: `${person.firstName} ${person.lastName}`.trim(), + headline: person.headline, + location: person.location, + isPlus: person.isPlus, + })), + jobs: jobs.map((job) => ({ + slug: job.slug, + title: job.title, + organizationName: job.organizationName, + organizationSlug: job.organizationSlug, + location: job.location, + salaryMin: job.salaryMin, + salaryMax: job.salaryMax, + salaryCurrency: job.salaryCurrency, + skillTags: job.skillTags, + })), + organizations: organizations.map((organization) => ({ + slug: organization.slug, + name: organization.name, + tagline: organization.tagline, + headquarters: organization.headquarters, + verificationStatus: organization.verificationStatus, + technologies: organization.technologies, + })), + }); + } catch { + return NextResponse.json({ error: 'SEARCH_UNAVAILABLE' }, { status: 503 }); + } +} diff --git a/apps/web/app/lib/security/content-security-policy.test.ts b/apps/web/app/lib/security/content-security-policy.test.ts index 021ec0f..b488f52 100644 --- a/apps/web/app/lib/security/content-security-policy.test.ts +++ b/apps/web/app/lib/security/content-security-policy.test.ts @@ -7,6 +7,8 @@ describe('createContentSecurityPolicy', () => { expect(policy).toContain("script-src 'self' 'nonce-c2VjdXJlLW5vbmNl' 'strict-dynamic'"); expect(policy).toContain("object-src 'none'"); expect(policy).toContain("frame-ancestors 'none'"); + expect(policy).toContain('upgrade-insecure-requests'); + expect(policy).not.toContain("frame-src 'none'"); }); it('rejects nonce values that could inject a policy directive', () => { diff --git a/apps/web/app/lib/security/content-security-policy.ts b/apps/web/app/lib/security/content-security-policy.ts index 4aa1ab5..6c6f0d3 100644 --- a/apps/web/app/lib/security/content-security-policy.ts +++ b/apps/web/app/lib/security/content-security-policy.ts @@ -10,6 +10,7 @@ export function createContentSecurityPolicy(nonce: string): string { "default-src 'self'", "base-uri 'self'", "frame-ancestors 'none'", + // Experiences render in opaque-origin srcDoc iframes; do not set frame-src 'none'. "form-action 'self'", "img-src 'self' data: blob: https:", "object-src 'none'", @@ -20,5 +21,6 @@ export function createContentSecurityPolicy(nonce: string): string { `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://static.cloudflareinsights.com`, "style-src 'self' 'unsafe-inline'", "connect-src 'self' https://cloudflareinsights.com", + 'upgrade-insecure-requests', ].join('; '); } diff --git a/apps/web/app/lib/security/rate-limit.ts b/apps/web/app/lib/security/rate-limit.ts index 1b39a6c..41dff61 100644 --- a/apps/web/app/lib/security/rate-limit.ts +++ b/apps/web/app/lib/security/rate-limit.ts @@ -9,6 +9,8 @@ export const rateLimitTiers = { auth: { maxRequests: 5, windowSeconds: 60 }, generalApi: { maxRequests: 100, windowSeconds: 60 }, socialWrite: { maxRequests: 20, windowSeconds: 60 }, + /** Desktop Bearer reads (search/jobs/opportunities) — tighter than general API. */ + desktopRead: { maxRequests: 60, windowSeconds: 60 }, } as const; export type RateLimitTier = keyof typeof rateLimitTiers; diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index e8b3e31..7c7d1bc 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -27,7 +27,8 @@ const nextConfig: NextConfig = { { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', - value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()', + value: + 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), bluetooth=(), interest-cohort=(), browsing-topics=()', }, { key: 'Cross-Origin-Opener-Policy', value: 'same-origin' }, { key: 'Cross-Origin-Resource-Policy', value: 'same-origin' }, @@ -43,6 +44,7 @@ const nextConfig: NextConfig = { headers: [ { key: 'Cache-Control', value: 'no-store, max-age=0' }, { key: 'X-Robots-Tag', value: 'noindex, nofollow, noarchive' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, ], }, ], diff --git a/apps/web/package.json b/apps/web/package.json index 5bb0ef1..b18e5eb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@nexus/web", "private": true, - "version": "1.5.0", + "version": "1.6.0", "scripts": { "dev": "next dev", "build": "next build", diff --git a/docs/AUTHENTICATED_APP.md b/docs/AUTHENTICATED_APP.md index 03408bc..4444995 100644 --- a/docs/AUTHENTICATED_APP.md +++ b/docs/AUTHENTICATED_APP.md @@ -10,7 +10,9 @@ Implemented product routes include `/home`, `/network`, `/network/discover`, `/s Messaging is a real vertical slice: conversation persistence, membership authorization, delivery and seen markers, and the Desktop device-token routes. Saved posts are private to the member who -saved them and re-check visibility and block rules on every read. +saved them and re-check visibility and block rules on every read. Desktop 1.6 also exposes native +Bearer routes for network, search, jobs, opportunities, and saved content so the Windows client +does not depend on browser session cookies for those surfaces. The avatar menu links to the real profile, projects, saved items, settings, and the real POST logout route. The protected shell includes responsive primary navigation, an accessible keyboard skip link, diff --git a/docs/IA_COMPARISON.md b/docs/IA_COMPARISON.md index 0aa84c2..daa1c6c 100644 --- a/docs/IA_COMPARISON.md +++ b/docs/IA_COMPARISON.md @@ -43,9 +43,33 @@ area make people work, and what VouchNet does instead: | AI features surface as the product ("AI can write this") | One rule: humans participate; AI is an explicitly authorized tool via scoped credentials | | Settings model scattered across dozens of pages | Privacy and visibility live under `/settings`, public surface documented in `sitemap.xml` | | Profile chrome crowded by ads and "People also viewed" modules | Same stacked-card profile layout (banner, overlapping avatar, section cards: About, Languages, Experience, Services, Badges, Trust graph) — minus the ads and upsells | +| Desktop / mobile apps centered on feed scroll + ads | Windows Desktop 1.6 signal desk: native Home, Network, Messages, Activity, Jobs, Organizations, Opportunities, Saved, and command-palette search over one account | +| Jobs behind Premium upsell walls; salary often hidden | Public salary-forward `/jobs` + Desktop jobs directory; source URL remains the application authority | +| “My Network” as gamified invitation spam | Network invitations + mutual-context recommendations with reasons; connection cooldowns and rate limits | +| Saved items buried; engagement bait to return | First-class `/saved` + Desktop Saved rail; visibility/block rules re-checked on every read | +| Messaging as a browser afterthought with notification fatigue | Web + Desktop inbox on the same device-authorized session; optional Windows unread notifications | + +## Side-by-side product surface (LinkedIn JTBD → VouchNet) + +This is a jobs-to-be-done parity map, not a UI clone. VouchNet keeps original branding, IA, and +trust semantics. + +| Job to be done | LinkedIn surface | VouchNet surface | Parity | +| -------------------------------------- | --------------------------------- | ----------------------------------------------------------- | -------- | +| Publish and read professional updates | Feed / posts | `/feed`, Desktop Home | Yes | +| Find people and reconnect | My Network / search | `/mynetwork`, `/search`, Desktop Network + Ctrl+K search | Yes | +| Message contacts | Messaging | `/messaging`, Desktop Messages | Yes | +| Track invitations and signals | Notifications | `/notifications`, Desktop Activity | Yes | +| Browse roles with compensation context | Jobs (often gated / vague pay) | `/jobs`, Desktop Jobs (salary-forward, source-reviewed) | Yes | +| Research employers | Company pages | `/company/`, Desktop Organizations | Yes | +| Save content for later | Saved | `/saved`, Desktop Saved | Yes | +| Find freelance / project work | Services / Open to work fragments | `/opportunities`, Desktop Opportunities | Yes | +| Prove credibility | Endorsements / Premium badges | Signed vouches, identity verification, DOMAIN_VERIFIED orgs | Stronger | +| Keep a desktop workspace | LinkedIn Desktop / mobile apps | VouchNet Desktop (Tauri, PKCE device auth) | Yes | Deliberate non-goals: no reaction inflation, no follower leaderboards, no infinite-scroll-bait -layout, no promoted content. Anything that exists only to raise a metric is out of scope. +layout, no promoted content, no LinkedIn brand or API mimicry. Anything that exists only to raise +a metric is out of scope. ## Rules we enforce diff --git a/docs/IMPLEMENTATION_MATRIX.md b/docs/IMPLEMENTATION_MATRIX.md index 24e6630..036e722 100644 --- a/docs/IMPLEMENTATION_MATRIX.md +++ b/docs/IMPLEMENTATION_MATRIX.md @@ -4,27 +4,27 @@ This is a reality audit of the current VouchNet vertical slices. A feature is on when its database, service, authorization, API, UI, error/empty states, and tests form a usable path. Prefer this matrix over older Phase 5 notes when they disagree. -| Feature | Database | Service / API | UI | Tests | Status | -| ----------------------------------- | -------------------- | ------------------------------------ | ------------------------------------------ | -------------- | ------- | -| Email/password identity | complete | register/login/verify/reset | login/signup/verify | partial | PARTIAL | -| Server sessions | complete | cookie sessions + idle expiration | settings sessions | partial | PARTIAL | -| MFA | complete (`0028`) | enroll/verify/disable | settings security | partial | PARTIAL | -| Professional profiles | complete | public read + owner edit | `/in`, `/vouch`, onboarding | partial | PARTIAL | -| Follow / contacts / network | complete | follow, connections, discover | network + discovery | unit only | PARTIAL | -| People search | complete | PostgreSQL search | `/search` | none | PARTIAL | -| Public organizations + jobs | complete | directory + employer intake | `/company`, `/jobs`, `/jobs/post` | partial | PARTIAL | -| Native job applications | complete (`0020`) | apply + candidate pipeline | apply + `/org/jobs/.../candidates` | partial | PARTIAL | -| Posts / feed / reactions | complete | feed, reactions, comments | `/feed` | partial | PARTIAL | -| Saved posts | complete (`0040`) | `/api/saved` | feed Save control + `/saved` | API unit | PARTIAL | -| Messaging | complete (`0026`) | browser + desktop device routes | `/messaging`, Desktop inbox | partial | PARTIAL | -| Notifications | complete | member notifications + mark-all-read | `/notifications` | partial | PARTIAL | -| Projects / Discover / opportunities | complete (`0029`) | build-in-public surfaces | `/projects`, `/discover`, `/opportunities` | partial | PARTIAL | -| Desktop client | device auth (`0027`) | feed, messages, activity | Tauri Windows client | package build | PARTIAL | -| Volunteer moderation | complete (`0024`) | apply/report/queue | moderation + admin | none | PARTIAL | -| Prepaid API credits | complete (`0029`) | Stripe Checkout + metering | account + developer center | inventory test | PARTIAL | -| Privacy / visibility settings | complete | settings APIs | `/settings/visibility` | partial | PARTIAL | -| MCP gateway / approval protocol | contracts only | not publicly live | developer docs only | unit contracts | MISSING | -| Audit / trust runtime | partial schema | partial contracts | admin/error telemetry only | partial | PARTIAL | +| Feature | Database | Service / API | UI | Tests | Status | +| ----------------------------------- | -------------------- | --------------------------------------------------------------------- | ------------------------------------------ | ------------------------ | ------- | +| Email/password identity | complete | register/login/verify/reset | login/signup/verify | partial | PARTIAL | +| Server sessions | complete | cookie sessions + idle expiration | settings sessions | partial | PARTIAL | +| MFA | complete (`0028`) | enroll/verify/disable | settings security | partial | PARTIAL | +| Professional profiles | complete | public read + owner edit | `/in`, `/vouch`, onboarding | partial | PARTIAL | +| Follow / contacts / network | complete | follow, connections, discover | network + discovery | unit only | PARTIAL | +| People search | complete | PostgreSQL search | `/search` | none | PARTIAL | +| Public organizations + jobs | complete | directory + employer intake | `/company`, `/jobs`, `/jobs/post` | partial | PARTIAL | +| Native job applications | complete (`0020`) | apply + candidate pipeline | apply + `/org/jobs/.../candidates` | partial | PARTIAL | +| Posts / feed / reactions | complete | feed, reactions, comments | `/feed` | partial | PARTIAL | +| Saved posts | complete (`0040`) | `/api/saved` | feed Save control + `/saved` | API unit | PARTIAL | +| Messaging | complete (`0026`) | browser + desktop device routes | `/messaging`, Desktop inbox | partial | PARTIAL | +| Notifications | complete | member notifications + mark-all-read | `/notifications` | partial | PARTIAL | +| Projects / Discover / opportunities | complete (`0029`) | build-in-public surfaces | `/projects`, `/discover`, `/opportunities` | partial | PARTIAL | +| Desktop client | device auth (`0027`) | feed, messages, activity, network, search, jobs, opportunities, saved | Tauri Windows client (1.6) | package build + API unit | PARTIAL | +| Volunteer moderation | complete (`0024`) | apply/report/queue | moderation + admin | none | PARTIAL | +| Prepaid API credits | complete (`0029`) | Stripe Checkout + metering | account + developer center | inventory test | PARTIAL | +| Privacy / visibility settings | complete | settings APIs | `/settings/visibility` | partial | PARTIAL | +| MCP gateway / approval protocol | contracts only | not publicly live | developer docs only | unit contracts | MISSING | +| Audit / trust runtime | partial schema | partial contracts | admin/error telemetry only | partial | PARTIAL | `MISSING` means the product still withholds a fake UI. Partial means a usable path exists but tests, edge coverage, or secondary clients remain incomplete. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 29b9747..e3f8fc4 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -27,10 +27,16 @@ used to report a suspected account, authorization, credential, or data-exposure - The page response receives a new cryptographic CSP nonce and server-generated request ID from Next.js Proxy. `strict-dynamic` limits executable scripts to VouchNet scripts carrying that nonce; Cloudflare Web Analytics is the only currently permitted third-party script/connect - origin. HSTS, `nosniff`, anti-framing, cross-origin isolation, referrer, and permissions headers - are applied to every response. + origin. Production CSP also sets `upgrade-insecure-requests` while leaving Experience `srcDoc` + iframes available (do not set `frame-src 'none'`). HSTS, `nosniff`, anti-framing, cross-origin + isolation, referrer, and permissions headers (including payment/usb/bluetooth/topics denial) are + applied to every response. - API responses are marked `no-store` and `noindex` to reduce accidental browser/proxy retention - and search indexing of authenticated or operational responses. + and search indexing of authenticated or operational responses. API routes also emit + `X-Content-Type-Options: nosniff`. +- The installed Desktop client enforces its own CSP (`default-src 'self'`, no workers/frames, + connect only to `https://vouchnet.dev`) and stores refresh credentials in Windows Credential + Manager rather than app local storage. - Cookie-authenticated mutations compare the browser `Origin` with the canonical `APP_URL` when it is configured. This is important on Netlify, where an internal deployment hostname can differ from the public domain. A request from any other origin is rejected. @@ -43,9 +49,12 @@ used to report a suspected account, authorization, credential, or data-exposure a client component import path that crosses into them. Browser-safe rules and DTO types are kept separately from database-backed services. - High-risk authentication/OAuth operations use a Redis-backed atomic sliding window (5 per minute - per hashed request subject). Social writes use 20 per minute. Production fails closed with a 503 - if Redis is unavailable; development permits local work without Redis. A rejected quota returns - a JSON error envelope, HTTP 429, and `Retry-After`. + per hashed request subject). Social writes use 20 per minute. Desktop Bearer reads + (search/jobs/opportunities) use 60 per minute under the `desktopRead` tier. Production fails + closed with a 503 if Redis is unavailable; development permits local work without Redis. A + rejected quota returns a JSON error envelope, HTTP 429, and `Retry-After`. Desktop connection and + saved-post writes reuse the social-write tier and authenticate with the device Bearer token + instead of cookie CSRF. - The implementation stores only a hash of the actor-or-anonymous subject, IP address, and a bounded user-agent portion in the Redis key. It does not collect TLS JA3/JA4 fingerprints because those are not reliably exposed to a Netlify application runtime. Cloudflare is the appropriate diff --git a/docs/releases/v1.6.0.md b/docs/releases/v1.6.0.md new file mode 100644 index 0000000..6112839 --- /dev/null +++ b/docs/releases/v1.6.0.md @@ -0,0 +1,27 @@ +# VouchNet 1.6.0 — Desktop expansion + +Ship date: 2026-10-28 (America/New_York). + +## Highlights + +- **Native Network** on Desktop: contacts, invitations, and mutual-context recommendations. +- **Jobs / Organizations** load from authenticated Desktop directory APIs with search. +- **Opportunities** and **Saved** join the rail; Ctrl+K searches people, jobs, and companies. +- **Security**: CSP upgrade-insecure-requests, tighter Desktop CSP, `desktopRead` rate limits, + expanded Permissions-Policy denials. + +## Desktop API surface + +| Route | Purpose | +| ------------------------------------ | ----------------------------- | +| `GET /api/desktop/network` | Overview + recommendations | +| `POST /api/desktop/network/connect` | Request / accept / decline | +| `GET /api/desktop/search?q=` | People + jobs + organizations | +| `GET /api/desktop/jobs` | Salary-forward directory | +| `GET /api/desktop/opportunities` | Open work briefs | +| `GET/POST/DELETE /api/desktop/saved` | Saved posts | + +## Installers + +Windows NSIS/MSI and SHA-256 checksums publish through the GitHub Release workflow for tag +`v1.6.0` when that tag is pushed. diff --git a/package.json b/package.json index 9b34735..cde3ecf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nexus", "private": true, - "version": "1.5.0", + "version": "1.6.0", "packageManager": "pnpm@11.19.0", "scripts": { "dev": "pnpm --filter @nexus/web dev",