From b03096f19d133390ee5d9b71ab315093de9adaf9 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 25 May 2026 18:13:44 -0400 Subject: [PATCH] feat(browser-ext): move browser extension to browser-ext/ and update API client to tRPC - Create browser-ext/ with full extension code (MV3 manifest, background service worker, content script, popup, options page) - Add tRPC API client that communicates with unified monolith endpoints - Implement cache, settings, and phishing detection utilities - Create extension tRPC router in web app (getAuthStatus, linkDevice, reportPhishing) - Configure Vite build with manifest V3 support - Write unit tests for cache, phishing detector, and API client - All 20 tests passing, TypeScript lint clean --- browser-ext/package.json | 24 ++- browser-ext/public/icons/icon-128.png | Bin 0 -> 228 bytes browser-ext/public/icons/icon-16.png | Bin 0 -> 80 bytes browser-ext/public/icons/icon-48.png | Bin 0 -> 127 bytes browser-ext/public/manifest.json | 44 ++++++ browser-ext/scripts/generate-icons.mjs | 80 ++++++++++ browser-ext/src/background/index.ts | 98 ++++++++++++ browser-ext/src/content/index.ts | 35 ++++ browser-ext/src/lib/api-client.ts | 17 ++ browser-ext/src/lib/cache.ts | 34 ++++ browser-ext/src/lib/phishing-detector.ts | 50 ++++++ browser-ext/src/lib/settings.ts | 25 +++ browser-ext/src/options/options.html | 167 ++++++++++++++++++++ browser-ext/src/options/options.ts | 107 +++++++++++++ browser-ext/src/popup/popup.html | 129 +++++++++++++++ browser-ext/src/popup/popup.ts | 82 ++++++++++ browser-ext/src/types/index.ts | 44 ++++++ browser-ext/src/types/trpc.ts | 23 +++ browser-ext/tests/api-client.test.ts | 90 +++++++++++ browser-ext/tests/cache.test.ts | 53 +++++++ browser-ext/tests/phishing-detector.test.ts | 48 ++++++ browser-ext/tests/setup.ts | 40 +++++ browser-ext/tsconfig.json | 15 ++ browser-ext/vite.config.ts | 24 +++ browser-ext/vitest.config.ts | 9 ++ package.json | 3 + pnpm-lock.yaml | 166 ++++++++++++++++++- web/src/server/api/root.ts | 2 + web/src/server/api/routers/extension.ts | 55 +++++++ web/src/server/api/schemas/extension.ts | 15 ++ 30 files changed, 1474 insertions(+), 5 deletions(-) create mode 100644 browser-ext/public/icons/icon-128.png create mode 100644 browser-ext/public/icons/icon-16.png create mode 100644 browser-ext/public/icons/icon-48.png create mode 100644 browser-ext/public/manifest.json create mode 100644 browser-ext/scripts/generate-icons.mjs create mode 100644 browser-ext/src/background/index.ts create mode 100644 browser-ext/src/content/index.ts create mode 100644 browser-ext/src/lib/api-client.ts create mode 100644 browser-ext/src/lib/cache.ts create mode 100644 browser-ext/src/lib/phishing-detector.ts create mode 100644 browser-ext/src/lib/settings.ts create mode 100644 browser-ext/src/options/options.html create mode 100644 browser-ext/src/options/options.ts create mode 100644 browser-ext/src/popup/popup.html create mode 100644 browser-ext/src/popup/popup.ts create mode 100644 browser-ext/src/types/index.ts create mode 100644 browser-ext/src/types/trpc.ts create mode 100644 browser-ext/tests/api-client.test.ts create mode 100644 browser-ext/tests/cache.test.ts create mode 100644 browser-ext/tests/phishing-detector.test.ts create mode 100644 browser-ext/tests/setup.ts create mode 100644 browser-ext/tsconfig.json create mode 100644 browser-ext/vite.config.ts create mode 100644 browser-ext/vitest.config.ts create mode 100644 web/src/server/api/routers/extension.ts create mode 100644 web/src/server/api/schemas/extension.ts diff --git a/browser-ext/package.json b/browser-ext/package.json index ca791db..33da0b5 100644 --- a/browser-ext/package.json +++ b/browser-ext/package.json @@ -1,10 +1,26 @@ { - "name": "browser-ext", + "name": "@shieldai/browser-ext", "version": "0.1.0", "private": true, + "type": "module", "scripts": { - "build": "echo 'browser-ext build placeholder'", - "dev": "echo 'browser-ext dev placeholder'", - "test": "echo 'browser-ext test placeholder'" + "dev": "vite build --watch", + "build": "vite build", + "build:chrome": "vite build --mode chrome", + "build:firefox": "vite build --mode firefox", + "test": "vitest run", + "test:watch": "vitest", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@trpc/client": "^10.45.2", + "@trpc/server": "^10.45.2", + "superjson": "^2.2.1" + }, + "devDependencies": { + "@types/chrome": "^0.0.280", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^4.1.5" } } diff --git a/browser-ext/public/icons/icon-128.png b/browser-ext/public/icons/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..9680fe09e9827aa13019a34638c270ad0c641eca GIT binary patch literal 228 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7uRSt36#DLn`LHy=2ID$Uwy9;2qJ< z-^ENBn#>aYvU?|P+g|>^P{nf+7;RdA`|n2?(ZAE@7!-ZrOJX)=f3G06==eJU2HTwD z4KD<(SzP+BDLPcG=wrC}xP)VZ+!AGnFC5w~`3GJ+3h%m?WEK0P>*8#m#ock1RkQbB zdiqXe@701`7i1TkOS_5hEdEmF{6eU_@7}xgT?c@gewfDInYw8EpBZ{@fV$>=`}RWI Yr)A>9eO^&rKxZ>}y85}Sb4q9e0I^ literal 0 HcmV?d00001 diff --git a/browser-ext/public/icons/icon-48.png b/browser-ext/public/icons/icon-48.png new file mode 100644 index 0000000000000000000000000000000000000000..c1958d7fc0bdc4bdda5f81fa508ca76812bad311 GIT binary patch literal 127 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTCH%}MGkcv6U2@"], + "js": ["content.js"], + "run_at": "document_end" + } + ], + "action": { + "default_popup": "src/popup/popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + "options_ui": { + "page": "src/options/options.html", + "open_in_tab": true + }, + "icons": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } +} diff --git a/browser-ext/scripts/generate-icons.mjs b/browser-ext/scripts/generate-icons.mjs new file mode 100644 index 0000000..66cddb4 --- /dev/null +++ b/browser-ext/scripts/generate-icons.mjs @@ -0,0 +1,80 @@ +import { writeFileSync, mkdirSync } from "fs"; +import { resolve } from "path"; +import { deflateSync } from "zlib"; + +const __dirname = import.meta.dirname; +const iconsDir = resolve(__dirname, "../public/icons"); +mkdirSync(iconsDir, { recursive: true }); + +function crc32(buf) { + let crc = 0xffffffff; + const table = new Int32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[i] = c; + } + for (let i = 0; i < buf.length; i++) crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const typeB = Buffer.from(type, "ascii"); + const crcData = Buffer.concat([typeB, data]); + const crcV = Buffer.alloc(4); + crcV.writeUInt32BE(crc32(crcData)); + return Buffer.concat([len, typeB, data, crcV]); +} + +function createPNG(size) { + const raw = Buffer.alloc(size * size * 4); + const cx = size / 2; + const cy = size / 2; + const r = size * 0.42; + + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const dx = x - cx; + const dy = y - cy; + const dist = Math.sqrt(dx * dx + dy * dy); + const i = (y * size + x) * 4; + if (dist <= r) { + raw[i] = 99; + raw[i + 1] = 102; + raw[i + 2] = 241; + raw[i + 3] = 255; + } else { + raw[i] = 0; + raw[i + 1] = 0; + raw[i + 2] = 0; + raw[i + 3] = 0; + } + } + } + + const filter = Buffer.alloc(size * size + size); + for (let y = 0; y < size; y++) { + filter[y * (size * 4 + 1)] = 0; + raw.copy(filter, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4); + } + + const compressed = deflateSync(filter); + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr[8] = 8; + ihdr[9] = 6; + ihdr[10] = 0; + ihdr[11] = 0; + ihdr[12] = 0; + + const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + return Buffer.concat([sig, chunk("IHDR", ihdr), chunk("IDAT", compressed), chunk("IEND", Buffer.alloc(0))]); +} + +for (const size of [16, 48, 128]) { + writeFileSync(resolve(iconsDir, `icon-${size}.png`), createPNG(size)); + console.log(`Created icon-${size}.png`); +} diff --git a/browser-ext/src/background/index.ts b/browser-ext/src/background/index.ts new file mode 100644 index 0000000..7af664e --- /dev/null +++ b/browser-ext/src/background/index.ts @@ -0,0 +1,98 @@ +import { createApiClient } from "../lib/api-client"; +import { getSettings } from "../lib/settings"; +import { spamCheckCache } from "../lib/cache"; +import type { SpamCheckResult } from "../types"; + +async function getClient() { + const settings = await getSettings(); + return createApiClient(settings.apiUrl, settings.apiKey ?? undefined); +} + +chrome.runtime.onInstalled.addListener(async (details) => { + if (details.reason === "install") { + const extensionId = chrome.runtime.id; + try { + const client = await getClient(); + await client.extension.linkDevice.mutate({ + extensionId, + deviceName: "ShieldAI Browser Extension", + }); + } catch { + console.warn("[ShieldAI] Device linking failed (will retry on auth)"); + } + } +}); + +const detectionHistory: Array<{ + type: "spam_call" | "spam_sms" | "phishing"; + value: string; + timestamp: number; +}> = []; + +function addDetection( + type: "spam_call" | "spam_sms" | "phishing", + value: string, +) { + detectionHistory.unshift({ type, value, timestamp: Date.now() }); + if (detectionHistory.length > 50) detectionHistory.pop(); + + chrome.storage.local.set({ + "shieldai:detections": detectionHistory.slice(0, 20), + }); +} + +export async function checkPhoneNumber(phoneNumber: string): Promise { + const cacheKey = `checkNumber:${phoneNumber}`; + const cached = spamCheckCache.get(cacheKey); + if (cached) return cached as SpamCheckResult; + + try { + const client = await getClient(); + const result = await client.spamshield.checkNumber.query({ phoneNumber }); + spamCheckCache.set(cacheKey, result); + addDetection("spam_call", phoneNumber); + return result; + } catch (err) { + console.error("[ShieldAI] Failed to check number:", err); + return null; + } +} + +export async function classifySMS(text: string): Promise { + const cacheKey = `classifySMS:${text.slice(0, 100)}`; + const cached = spamCheckCache.get(cacheKey); + if (cached) return cached as SpamCheckResult; + + try { + const client = await getClient(); + const result = await client.spamshield.classifySMS.query({ text }); + spamCheckCache.set(cacheKey, result); + addDetection("spam_sms", text.slice(0, 200)); + return result; + } catch (err) { + console.error("[ShieldAI] Failed to classify SMS:", err); + return null; + } +} + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + switch (message.type) { + case "CHECK_NUMBER": + checkPhoneNumber(message.phoneNumber).then(sendResponse); + return true; + case "CLASSIFY_SMS": + classifySMS(message.text).then(sendResponse); + return true; + case "GET_DETECTIONS": + chrome.storage.local.get("shieldai:detections").then((result) => { + sendResponse(result["shieldai:detections"] ?? []); + }); + return true; + case "GET_AUTH_STATUS": + getClient() + .then((client) => client.extension.getAuthStatus.query()) + .then(sendResponse) + .catch(() => sendResponse({ linked: false })); + return true; + } +}); diff --git a/browser-ext/src/content/index.ts b/browser-ext/src/content/index.ts new file mode 100644 index 0000000..c55dcc8 --- /dev/null +++ b/browser-ext/src/content/index.ts @@ -0,0 +1,35 @@ +import { checkUrlForPhishing } from "../lib/phishing-detector"; +import { getSettings } from "../lib/settings"; +import { createApiClient } from "../lib/api-client"; + +async function checkCurrentPage() { + const settings = await getSettings(); + if (!settings.phishingDetectionEnabled) return; + + const url = window.location.href; + const result = checkUrlForPhishing(url); + + if (result.isPhishing) { + console.warn("[ShieldAI] Phishing detected:", result); + + if (settings.autoReportPhishing) { + try { + const client = createApiClient(settings.apiUrl, settings.apiKey ?? undefined); + await client.extension.reportPhishing.mutate({ + url, + source: "content-script", + }); + } catch (err) { + console.error("[ShieldAI] Failed to report phishing:", err); + } + } + + chrome.runtime.sendMessage({ + type: "PHISHING_DETECTED", + url, + result, + }); + } +} + +checkCurrentPage(); diff --git a/browser-ext/src/lib/api-client.ts b/browser-ext/src/lib/api-client.ts new file mode 100644 index 0000000..7cf363f --- /dev/null +++ b/browser-ext/src/lib/api-client.ts @@ -0,0 +1,17 @@ +import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; +import type { TrpcClient } from "../types/trpc"; + +export function createApiClient(url: string, apiKey?: string) { + const raw = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url, + headers: apiKey ? { "x-api-key": apiKey } : undefined, + }), + ], + }); + + return raw as unknown as TrpcClient; +} + +export type ApiClient = ReturnType; diff --git a/browser-ext/src/lib/cache.ts b/browser-ext/src/lib/cache.ts new file mode 100644 index 0000000..45fdcb9 --- /dev/null +++ b/browser-ext/src/lib/cache.ts @@ -0,0 +1,34 @@ +interface CacheEntry { + data: T; + expiresAt: number; +} + +export class Cache { + private store = new Map>(); + + constructor(private ttlMs: number = 5 * 60 * 1000) {} + + get(key: string): T | null { + const entry = this.store.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return null; + } + return entry.data; + } + + set(key: string, data: T): void { + this.store.set(key, { data, expiresAt: Date.now() + this.ttlMs }); + } + + delete(key: string): void { + this.store.delete(key); + } + + clear(): void { + this.store.clear(); + } +} + +export const spamCheckCache = new Cache(5 * 60 * 1000); diff --git a/browser-ext/src/lib/phishing-detector.ts b/browser-ext/src/lib/phishing-detector.ts new file mode 100644 index 0000000..8dd4350 --- /dev/null +++ b/browser-ext/src/lib/phishing-detector.ts @@ -0,0 +1,50 @@ +import type { PhishingResult } from "../types"; + +const SUSPICIOUS_PATTERNS = [ + /login[.-]?[a-z]+\.(com|org|net)/i, + /secure[.-]?[a-z]+\.(com|org|net)/i, + /account[.-]?[a-z]+\.(com|org|net)/i, + /verify[.-]?[a-z]+\.(com|org|net)/i, + /update[.-]?[a-z]+\.(com|org|net)/i, + /banking[.-]?[a-z]+\.(com|org|net)/i, + /paypal[.-]?[a-z]+\.(com|org|net)/i, +]; + +const KNOWN_PHISHING_DOMAINS = [ + "shieldai-secure.com", + "shieldai-verify.com", + "shield-ai-login.com", +]; + +export function checkUrlForPhishing(url: string): PhishingResult { + const reasons: string[] = []; + const lowerUrl = url.toLowerCase(); + + try { + const parsed = new URL(lowerUrl); + + for (const domain of KNOWN_PHISHING_DOMAINS) { + if (parsed.hostname === domain || parsed.hostname.endsWith("." + domain)) { + reasons.push(`Known phishing domain: ${parsed.hostname}`); + } + } + + for (const pattern of SUSPICIOUS_PATTERNS) { + if (pattern.test(lowerUrl)) { + reasons.push(`Suspicious URL pattern matched: ${pattern}`); + } + } + + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + reasons.push(`Non-standard protocol: ${parsed.protocol}`); + } + } catch { + reasons.push("Invalid URL format"); + } + + return { + isPhishing: reasons.length > 0, + confidence: reasons.length > 2 ? 0.9 : reasons.length > 0 ? 0.6 : 0, + reasons, + }; +} diff --git a/browser-ext/src/lib/settings.ts b/browser-ext/src/lib/settings.ts new file mode 100644 index 0000000..4c98481 --- /dev/null +++ b/browser-ext/src/lib/settings.ts @@ -0,0 +1,25 @@ +import type { ExtensionSettings } from "../types"; + +const DEFAULT_SETTINGS: ExtensionSettings = { + apiKey: null, + apiUrl: "https://api.shieldai.com/api/trpc", + notificationsEnabled: true, + phishingDetectionEnabled: true, + autoReportPhishing: false, +}; + +const STORAGE_KEY = "shieldai:settings"; + +export async function getSettings(): Promise { + const result = await chrome.storage.sync.get(STORAGE_KEY); + return { ...DEFAULT_SETTINGS, ...result[STORAGE_KEY] } as ExtensionSettings; +} + +export async function updateSettings( + partial: Partial, +): Promise { + const current = await getSettings(); + const updated = { ...current, ...partial }; + await chrome.storage.sync.set({ [STORAGE_KEY]: updated }); + return updated; +} diff --git a/browser-ext/src/options/options.html b/browser-ext/src/options/options.html new file mode 100644 index 0000000..1b4de3d --- /dev/null +++ b/browser-ext/src/options/options.html @@ -0,0 +1,167 @@ + + + + + + ShieldAI Settings + + + +

ShieldAI Settings

+ +
+

API Configuration

+
+ + +
+
+ + +
+
+ +
+

Account Linking

+
+
+ Loading auth status... +
+ +
+
+
+ +
+

Notifications

+
+ +
+
+
+ +
+

Phishing Detection

+
+ +
+
+
+ +
+
+
+ +
+ +
+
+ + + + diff --git a/browser-ext/src/options/options.ts b/browser-ext/src/options/options.ts new file mode 100644 index 0000000..2a7d5ee --- /dev/null +++ b/browser-ext/src/options/options.ts @@ -0,0 +1,107 @@ +import { getSettings, updateSettings } from "../lib/settings"; +import { createApiClient } from "../lib/api-client"; + +const apiUrlInput = document.getElementById("apiUrl") as HTMLInputElement; +const apiKeyInput = document.getElementById("apiKey") as HTMLInputElement; +const authStatusEl = document.getElementById("authStatus")!; +const linkDeviceBtn = document.getElementById("linkDeviceBtn") as HTMLButtonElement; +const linkStatusEl = document.getElementById("linkStatus")!; +const notificationsToggle = document.getElementById("notificationsToggle")!; +const phishingToggle = document.getElementById("phishingToggle")!; +const autoReportToggle = document.getElementById("autoReportToggle")!; +const saveBtn = document.getElementById("saveBtn") as HTMLButtonElement; +const saveStatusEl = document.getElementById("saveStatus")!; + +let currentSettings!: Awaited>; + +function setToggle(el: HTMLElement, on: boolean) { + el.classList.toggle("active", on); +} + +function applySettings(settings: typeof currentSettings) { + currentSettings = settings; + apiUrlInput.value = settings.apiUrl; + apiKeyInput.value = settings.apiKey ?? ""; + setToggle(notificationsToggle, settings.notificationsEnabled); + setToggle(phishingToggle, settings.phishingDetectionEnabled); + setToggle(autoReportToggle, settings.autoReportPhishing); +} + +getSettings().then(applySettings); + +async function updateAuthStatusDisplay() { + try { + const client = createApiClient( + currentSettings.apiUrl, + currentSettings.apiKey ?? undefined, + ); + const status = await client.extension.getAuthStatus.query(); + if (status.linked) { + authStatusEl.textContent = "✅ Account linked" + ("email" in status && status.email ? ` (${status.email})` : ""); + } else { + authStatusEl.textContent = "❌ Not linked — enter an API key or log in"; + } + } catch { + authStatusEl.textContent = "❌ Not linked — API unreachable"; + } +} + +linkDeviceBtn.addEventListener("click", async () => { + const extensionId = chrome.runtime.id; + linkStatusEl.className = "status info"; + linkStatusEl.textContent = "Linking device..."; + linkStatusEl.style.display = "block"; + + try { + const client = createApiClient( + currentSettings.apiUrl, + currentSettings.apiKey ?? undefined, + ); + const result = await client.extension.linkDevice.mutate({ + extensionId, + deviceName: "ShieldAI Browser Extension", + }); + linkStatusEl.className = "status success"; + linkStatusEl.textContent = `Device linked! (ID: ${result.deviceId.slice(0, 8)}...)`; + updateAuthStatusDisplay(); + } catch (err) { + linkStatusEl.className = "status error"; + linkStatusEl.textContent = "Failed to link device. Check your API key and URL."; + } +}); + +saveBtn.addEventListener("click", async () => { + saveBtn.disabled = true; + saveBtn.textContent = "Saving..."; + + const updated = await updateSettings({ + apiUrl: apiUrlInput.value, + apiKey: apiKeyInput.value || null, + notificationsEnabled: notificationsToggle.classList.contains("active"), + phishingDetectionEnabled: phishingToggle.classList.contains("active"), + autoReportPhishing: autoReportToggle.classList.contains("active"), + }); + applySettings(updated); + + saveStatusEl.className = "status success"; + saveStatusEl.textContent = "Settings saved!"; + saveStatusEl.style.display = "block"; + saveBtn.disabled = false; + saveBtn.textContent = "Save Settings"; + + updateAuthStatusDisplay(); +}); + +notificationsToggle.addEventListener("click", () => { + notificationsToggle.classList.toggle("active"); +}); + +phishingToggle.addEventListener("click", () => { + phishingToggle.classList.toggle("active"); +}); + +autoReportToggle.addEventListener("click", () => { + autoReportToggle.classList.toggle("active"); +}); + +updateAuthStatusDisplay(); diff --git a/browser-ext/src/popup/popup.html b/browser-ext/src/popup/popup.html new file mode 100644 index 0000000..ffcb408 --- /dev/null +++ b/browser-ext/src/popup/popup.html @@ -0,0 +1,129 @@ + + + + + + ShieldAI + + + +
+ + ShieldAI + Unlinked +
+ +
+

Quick Actions

+ + +
+ +
+

Recent Detections

+
+
No recent detections
+
+
+ +
+ Settings +
+ + + + diff --git a/browser-ext/src/popup/popup.ts b/browser-ext/src/popup/popup.ts new file mode 100644 index 0000000..c11cf40 --- /dev/null +++ b/browser-ext/src/popup/popup.ts @@ -0,0 +1,82 @@ +const authBadge = document.getElementById("authBadge")!; +const detectionsEl = document.getElementById("detections")!; +const checkNumberBtn = document.getElementById("checkNumberBtn")!; +const reportSpamBtn = document.getElementById("reportSpamBtn")!; +const openOptionsLink = document.getElementById("openOptions")!; + +function showDetection(detection: { + type: string; + value: string; + timestamp: number; +}) { + const date = new Date(detection.timestamp).toLocaleTimeString(); + const typeClass = detection.type === "phishing" ? "phishing" : "spam"; + return `
+
${detection.type.replace("_", " ")} · ${date}
+
${detection.value}
+
`; +} + +async function updateAuthStatus() { + try { + const status = await chrome.runtime.sendMessage({ type: "GET_AUTH_STATUS" }); + if (status?.linked) { + authBadge.textContent = "Linked"; + authBadge.className = "status-badge linked"; + } else { + authBadge.textContent = "Unlinked"; + authBadge.className = "status-badge unlinked"; + } + } catch { + authBadge.textContent = "Unlinked"; + authBadge.className = "status-badge unlinked"; + } +} + +async function updateDetections() { + try { + const detections = await chrome.runtime.sendMessage({ type: "GET_DETECTIONS" }); + if (detections && detections.length > 0) { + detectionsEl.innerHTML = detections.map(showDetection).join(""); + } + } catch { + // Background may not be ready + } +} + +checkNumberBtn.addEventListener("click", () => { + const phoneNumber = prompt("Enter phone number to check (e.g. +1234567890):"); + if (phoneNumber) { + chrome.runtime.sendMessage( + { type: "CHECK_NUMBER", phoneNumber }, + (result) => { + if (result) { + alert( + result.isSpam + ? `⚠️ Spam detected (score: ${result.score})` + : "✅ No spam detected", + ); + updateDetections(); + } + }, + ); + } +}); + +reportSpamBtn.addEventListener("click", () => { + const phoneNumber = prompt("Enter phone number to report as spam:"); + if (phoneNumber) { + chrome.runtime.sendMessage( + { type: "CHECK_NUMBER", phoneNumber }, + () => updateDetections(), + ); + } +}); + +openOptionsLink.addEventListener("click", (e) => { + e.preventDefault(); + chrome.runtime.openOptionsPage(); +}); + +updateAuthStatus(); +updateDetections(); diff --git a/browser-ext/src/types/index.ts b/browser-ext/src/types/index.ts new file mode 100644 index 0000000..5ad439a --- /dev/null +++ b/browser-ext/src/types/index.ts @@ -0,0 +1,44 @@ +export interface PhishingResult { + isPhishing: boolean; + confidence: number; + reasons: string[]; +} + +export interface SpamCheckResult { + isSpam: boolean; + score: number; + category: string; + details?: Record; +} + +export interface ExtensionSettings { + apiKey: string | null; + apiUrl: string; + notificationsEnabled: boolean; + phishingDetectionEnabled: boolean; + autoReportPhishing: boolean; +} + +export interface DetectionRecord { + id: string; + type: "spam_call" | "spam_sms" | "phishing"; + value: string; + timestamp: number; + isSpam: boolean; + confidence: number; +} + +export type AuthStatus = + | { linked: false } + | { linked: true; userId: string; email?: string } + | { linked: true; apiKey: string }; + +export interface LinkDeviceResult { + linked: boolean; + deviceId: string; +} + +export interface ReportResult { + reported: boolean; + url: string; +} diff --git a/browser-ext/src/types/trpc.ts b/browser-ext/src/types/trpc.ts new file mode 100644 index 0000000..a64c107 --- /dev/null +++ b/browser-ext/src/types/trpc.ts @@ -0,0 +1,23 @@ +import type { AuthStatus, LinkDeviceResult, ReportResult, SpamCheckResult } from "./index"; + +export interface TrpcClient { + spamshield: { + checkNumber: { + query: (input: { phoneNumber: string }) => Promise; + }; + classifySMS: { + query: (input: { text: string }) => Promise; + }; + }; + extension: { + getAuthStatus: { + query: (input?: { apiKey?: string }) => Promise; + }; + linkDevice: { + mutate: (input: { extensionId: string; deviceName?: string }) => Promise; + }; + reportPhishing: { + mutate: (input: { url: string; source?: string }) => Promise; + }; + }; +} diff --git a/browser-ext/tests/api-client.test.ts b/browser-ext/tests/api-client.test.ts new file mode 100644 index 0000000..08e27c7 --- /dev/null +++ b/browser-ext/tests/api-client.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createApiClient } from "../src/lib/api-client"; + +vi.mock("@trpc/client", () => ({ + createTRPCProxyClient: vi.fn(() => ({ + spamshield: { + checkNumber: { + query: vi.fn().mockResolvedValue({ + isSpam: true, + score: 0.85, + category: "scam", + }), + }, + classifySMS: { + query: vi.fn().mockResolvedValue({ + isSpam: false, + score: 0.1, + category: "legitimate", + }), + }, + }, + extension: { + getAuthStatus: { + query: vi.fn().mockResolvedValue({ linked: false }), + }, + linkDevice: { + mutate: vi.fn().mockResolvedValue({ + linked: true, + deviceId: "dev-123", + }), + }, + reportPhishing: { + mutate: vi.fn().mockResolvedValue({ + reported: true, + url: "https://phishing.com", + }), + }, + }, + })), + httpBatchLink: vi.fn(), +})); + +describe("createApiClient", () => { + let client: ReturnType; + + beforeEach(() => { + client = createApiClient("https://api.shieldai.com/api/trpc", "test-key"); + }); + + it("should create a client", () => { + expect(client).toBeDefined(); + }); + + it("should check phone number reputation", async () => { + const result = await client.spamshield.checkNumber.query({ + phoneNumber: "+1234567890", + }); + expect(result).toBeDefined(); + expect(result.isSpam).toBe(true); + expect(result.score).toBe(0.85); + }); + + it("should classify SMS text", async () => { + const result = await client.spamshield.classifySMS.query({ + text: "Hello, this is a test message", + }); + expect(result).toBeDefined(); + expect(result.isSpam).toBe(false); + }); + + it("should get auth status", async () => { + const result = await client.extension.getAuthStatus.query(); + expect(result).toEqual({ linked: false }); + }); + + it("should link device", async () => { + const result = await client.extension.linkDevice.mutate({ + extensionId: "ext-123", + }); + expect(result.linked).toBe(true); + expect(result.deviceId).toBe("dev-123"); + }); + + it("should report phishing", async () => { + const result = await client.extension.reportPhishing.mutate({ + url: "https://phishing.com", + }); + expect(result.reported).toBe(true); + }); +}); diff --git a/browser-ext/tests/cache.test.ts b/browser-ext/tests/cache.test.ts new file mode 100644 index 0000000..1f8d7de --- /dev/null +++ b/browser-ext/tests/cache.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Cache } from "../src/lib/cache"; + +describe("Cache", () => { + let cache: Cache; + + beforeEach(() => { + vi.useFakeTimers(); + cache = new Cache(1000); + }); + + it("should store and retrieve values", () => { + cache.set("key1", "value1"); + expect(cache.get("key1")).toBe("value1"); + }); + + it("should return null for missing keys", () => { + expect(cache.get("nonexistent")).toBeNull(); + }); + + it("should expire entries after TTL", () => { + cache.set("key1", "value1"); + vi.advanceTimersByTime(1500); + expect(cache.get("key1")).toBeNull(); + }); + + it("should not expire entries before TTL", () => { + cache.set("key1", "value1"); + vi.advanceTimersByTime(500); + expect(cache.get("key1")).toBe("value1"); + }); + + it("should delete entries", () => { + cache.set("key1", "value1"); + cache.delete("key1"); + expect(cache.get("key1")).toBeNull(); + }); + + it("should clear all entries", () => { + cache.set("key1", "value1"); + cache.set("key2", "value2"); + cache.clear(); + expect(cache.get("key1")).toBeNull(); + expect(cache.get("key2")).toBeNull(); + }); + + it("should not return expired entries when they are cleaned up on get", () => { + cache.set("key1", "value1"); + vi.advanceTimersByTime(1001); + const result = cache.get("key1"); + expect(result).toBeNull(); + }); +}); diff --git a/browser-ext/tests/phishing-detector.test.ts b/browser-ext/tests/phishing-detector.test.ts new file mode 100644 index 0000000..18005de --- /dev/null +++ b/browser-ext/tests/phishing-detector.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { checkUrlForPhishing } from "../src/lib/phishing-detector"; + +describe("checkUrlForPhishing", () => { + it("should return isPhishing: false for a normal URL", () => { + const result = checkUrlForPhishing("https://www.google.com"); + expect(result.isPhishing).toBe(false); + expect(result.confidence).toBe(0); + expect(result.reasons).toHaveLength(0); + }); + + it("should detect known phishing domains", () => { + const result = checkUrlForPhishing("https://shieldai-secure.com/login"); + expect(result.isPhishing).toBe(true); + expect(result.reasons).toContain("Known phishing domain: shieldai-secure.com"); + }); + + it("should detect suspicious URL patterns", () => { + const result = checkUrlForPhishing("https://login-secure.example.com"); + expect(result.isPhishing).toBe(true); + expect(result.reasons.length).toBeGreaterThan(0); + }); + + it("should detect phishing with multiple signals for high confidence", () => { + const result = checkUrlForPhishing( + "https://shieldai-verify.com/account-update/verify", + ); + expect(result.isPhishing).toBe(true); + expect(result.confidence).toBeGreaterThan(0.5); + expect(result.reasons.length).toBeGreaterThanOrEqual(1); + }); + + it("should handle invalid URLs gracefully", () => { + const result = checkUrlForPhishing("not-a-valid-url"); + expect(result.isPhishing).toBe(true); + expect(result.reasons).toContain("Invalid URL format"); + }); + + it("should detect phishing with subdomain of known domain", () => { + const result = checkUrlForPhishing("https://login.shieldai-secure.com"); + expect(result.isPhishing).toBe(true); + }); + + it("should not flag legitimate shieldai.com URLs", () => { + const result = checkUrlForPhishing("https://api.shieldai.com/api/trpc"); + expect(result.isPhishing).toBe(false); + }); +}); diff --git a/browser-ext/tests/setup.ts b/browser-ext/tests/setup.ts new file mode 100644 index 0000000..5978f68 --- /dev/null +++ b/browser-ext/tests/setup.ts @@ -0,0 +1,40 @@ +import { vi } from "vitest"; + +const mockStorage: Record = {}; + +vi.stubGlobal("chrome", { + runtime: { + id: "test-extension-id", + onInstalled: { + addListener: vi.fn(), + }, + onMessage: { + addListener: vi.fn(), + }, + sendMessage: vi.fn(), + openOptionsPage: vi.fn(), + }, + storage: { + sync: { + get: vi.fn((keys: string | string[] | Record) => { + if (typeof keys === "string") { + return Promise.resolve({ [keys]: mockStorage[keys] ?? null }); + } + if (Array.isArray(keys)) { + const result: Record = {}; + for (const key of keys) result[key] = mockStorage[key] ?? null; + return Promise.resolve(result); + } + return Promise.resolve({}); + }), + set: vi.fn((items: Record) => { + Object.assign(mockStorage, items); + return Promise.resolve(); + }), + }, + local: { + get: vi.fn(), + set: vi.fn(), + }, + }, +}); diff --git a/browser-ext/tsconfig.json b/browser-ext/tsconfig.json new file mode 100644 index 0000000..3a4604f --- /dev/null +++ b/browser-ext/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "strict": true, + "noEmit": true, + "isolatedModules": true, + "jsx": "preserve", + "types": ["vite/client", "chrome"] + }, + "include": ["src", "vite.config.ts", "tests"] +} diff --git a/browser-ext/vite.config.ts b/browser-ext/vite.config.ts new file mode 100644 index 0000000..99e2aec --- /dev/null +++ b/browser-ext/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "vite"; +import { resolve } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); + +export default defineConfig({ + build: { + outDir: "dist", + emptyOutDir: true, + rollupOptions: { + input: { + background: resolve(__dirname, "src/background/index.ts"), + content: resolve(__dirname, "src/content/index.ts"), + popup: resolve(__dirname, "src/popup/popup.html"), + options: resolve(__dirname, "src/options/options.html"), + }, + output: { + entryFileNames: "[name].js", + }, + }, + }, + publicDir: "public", +}); diff --git a/browser-ext/vitest.config.ts b/browser-ext/vitest.config.ts new file mode 100644 index 0000000..68b5d05 --- /dev/null +++ b/browser-ext/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./tests/setup.ts"], + }, +}); diff --git a/package.json b/package.json index 146473f..7d72545 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,11 @@ "scripts": { "dev": "pnpm --filter web dev", "build": "pnpm --filter web build", + "build:ext": "pnpm --filter @shieldai/browser-ext build", "test": "pnpm --filter web test", + "test:ext": "pnpm --filter @shieldai/browser-ext test", "lint": "pnpm --filter web lint", + "lint:ext": "pnpm --filter @shieldai/browser-ext lint", "db:migrate": "pnpm --filter web db:migrate", "db:seed": "pnpm --filter web db:seed" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de8d79b..19d7119 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,7 +24,30 @@ importers: specifier: ^4.1.5 version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)) - browser-ext: {} + browser-ext: + dependencies: + '@trpc/client': + specifier: ^10.45.2 + version: 10.45.4(@trpc/server@10.45.4) + '@trpc/server': + specifier: ^10.45.2 + version: 10.45.4 + superjson: + specifier: ^2.2.1 + version: 2.2.6 + devDependencies: + '@types/chrome': + specifier: ^0.0.280 + version: 0.0.280 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3) + vitest: + specifier: ^4.1.5 + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)) web: dependencies: @@ -1824,6 +1847,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/chrome@0.0.280': + resolution: {integrity: sha512-AotSmZrL9bcZDDmSI1D9dE7PGbhOur5L0cKxXd7IqbVizQWCY4gcvupPUVsQ4FfDj3V2tt/iOpomT9EY0s+w1g==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1833,6 +1859,15 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/filesystem@0.0.36': + resolution: {integrity: sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==} + + '@types/filewriter@0.0.33': + resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==} + + '@types/har-format@1.2.16': + resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -2301,6 +2336,10 @@ packages: resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} engines: {node: '>=18'} + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3066,6 +3105,10 @@ packages: resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} engines: {node: '>=12.13'} + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + is-wsl@3.1.1: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} @@ -4097,6 +4140,10 @@ packages: stubs@3.0.0: resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -4434,6 +4481,46 @@ packages: '@testing-library/jest-dom': optional: true + vite@6.4.2: + resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vite@7.3.3: resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6138,12 +6225,25 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/chrome@0.0.280': + dependencies: + '@types/filesystem': 0.0.36 + '@types/har-format': 1.2.16 + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} '@types/estree@1.0.9': {} + '@types/filesystem@0.0.36': + dependencies: + '@types/filewriter': 0.0.33 + + '@types/filewriter@0.0.33': {} + + '@types/har-format@1.2.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -6264,6 +6364,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@4.1.7(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3))': + dependencies: + '@vitest/spy': 4.1.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3) + '@vitest/mocker@4.1.7(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3))': dependencies: '@vitest/spy': 4.1.7 @@ -6645,6 +6753,10 @@ snapshots: cookie@1.0.2: {} + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + core-util-is@1.0.3: {} cosmiconfig@9.0.1(typescript@5.9.3): @@ -7439,6 +7551,8 @@ snapshots: is-what@4.1.16: {} + is-what@5.5.0: {} + is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 @@ -8626,6 +8740,10 @@ snapshots: stubs@3.0.0: optional: true + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + supports-color@10.2.2: {} supports-color@7.2.0: @@ -8966,6 +9084,22 @@ snapshots: transitivePeerDependencies: - supports-color + vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.9.1 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + terser: 5.48.0 + tsx: 4.22.3 + vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3): dependencies: esbuild: 0.27.7 @@ -8986,6 +9120,36 @@ snapshots: optionalDependencies: vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3) + vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)): + dependencies: + '@vitest/expect': 4.1.7 + '@vitest/mocker': 4.1.7(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)) + '@vitest/pretty-format': 4.1.7 + '@vitest/runner': 4.1.7 + '@vitest/snapshot': 4.1.7 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 25.9.1 + '@vitest/coverage-v8': 4.1.7(vitest@4.1.7) + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)): dependencies: '@vitest/expect': 4.1.7 diff --git a/web/src/server/api/root.ts b/web/src/server/api/root.ts index 2d5132a..0be27e3 100644 --- a/web/src/server/api/root.ts +++ b/web/src/server/api/root.ts @@ -10,6 +10,7 @@ import { removebrokersRouter } from "./routers/removebrokers"; import { correlationRouter } from "./routers/correlation"; import { reportsRouter } from "./routers/reports"; import { schedulerRouter } from "./routers/scheduler"; +import { extensionRouter } from "./routers/extension"; import { createTRPCRouter } from "./utils"; export const appRouter = createTRPCRouter({ @@ -25,6 +26,7 @@ export const appRouter = createTRPCRouter({ correlation: correlationRouter, reports: reportsRouter, scheduler: schedulerRouter, + extension: extensionRouter, }); export type AppRouter = typeof appRouter; diff --git a/web/src/server/api/routers/extension.ts b/web/src/server/api/routers/extension.ts new file mode 100644 index 0000000..e4d1e5e --- /dev/null +++ b/web/src/server/api/routers/extension.ts @@ -0,0 +1,55 @@ +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import { wrap } from "@typeschema/valibot"; +import { createTRPCRouter, publicProcedure } from "../utils"; +import { GetAuthStatusSchema, LinkDeviceSchema, ReportPhishingSchema } from "../schemas/extension"; +import { db } from "~/server/db"; +import { deviceTokens } from "~/server/db/schema/auth"; + +export const extensionRouter = createTRPCRouter({ + getAuthStatus: publicProcedure.input(wrap(GetAuthStatusSchema)).query(async ({ ctx }) => { + if (ctx.user) { + return { linked: true, userId: ctx.user.id, email: ctx.user.email }; + } + if (ctx.apiKey) { + return { linked: true, apiKey: ctx.apiKey }; + } + return { linked: false }; + }), + + linkDevice: publicProcedure.input(wrap(LinkDeviceSchema)).mutation(async ({ ctx, input }) => { + if (!ctx.user) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required to link device" }); + } + + const existing = await db.query.deviceTokens.findFirst({ + where: eq(deviceTokens.token, input.extensionId), + }); + + if (existing) { + await db + .update(deviceTokens) + .set({ lastUsedAt: new Date(), appName: input.deviceName ?? null }) + .where(eq(deviceTokens.id, existing.id)); + return { linked: true, deviceId: existing.id }; + } + + const [newDevice] = await db + .insert(deviceTokens) + .values({ + userId: ctx.user.id, + deviceType: "desktop", + platform: "web", + token: input.extensionId, + appName: input.deviceName ?? "ShieldAI Browser Extension", + }) + .returning(); + + return { linked: true, deviceId: newDevice.id }; + }), + + reportPhishing: publicProcedure.input(wrap(ReportPhishingSchema)).mutation(async ({ input }) => { + console.log(`[Phishing Report] URL: ${input.url}, Source: ${input.source ?? "unknown"}`); + return { reported: true, url: input.url }; + }), +}); diff --git a/web/src/server/api/schemas/extension.ts b/web/src/server/api/schemas/extension.ts new file mode 100644 index 0000000..e112410 --- /dev/null +++ b/web/src/server/api/schemas/extension.ts @@ -0,0 +1,15 @@ +import { object, string, optional } from "valibot"; + +export const GetAuthStatusSchema = object({ + apiKey: optional(string()), +}); + +export const LinkDeviceSchema = object({ + extensionId: string(), + deviceName: optional(string()), +}); + +export const ReportPhishingSchema = object({ + url: string(), + source: optional(string()), +});