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
This commit is contained in:
2026-05-25 18:13:44 -04:00
parent 20dc5bf785
commit b03096f19d
30 changed files with 1474 additions and 5 deletions

View File

@@ -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<SpamCheckResult | null> {
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<SpamCheckResult | null> {
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;
}
});

View File

@@ -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();

View File

@@ -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<typeof createApiClient>;

View File

@@ -0,0 +1,34 @@
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
export class Cache<T = unknown> {
private store = new Map<string, CacheEntry<T>>();
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<unknown>(5 * 60 * 1000);

View File

@@ -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,
};
}

View File

@@ -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<ExtensionSettings> {
const result = await chrome.storage.sync.get(STORAGE_KEY);
return { ...DEFAULT_SETTINGS, ...result[STORAGE_KEY] } as ExtensionSettings;
}
export async function updateSettings(
partial: Partial<ExtensionSettings>,
): Promise<ExtensionSettings> {
const current = await getSettings();
const updated = { ...current, ...partial };
await chrome.storage.sync.set({ [STORAGE_KEY]: updated });
return updated;
}

View File

@@ -0,0 +1,167 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ShieldAI Settings</title>
<style>
:root {
--primary: #6366f1;
--bg: #0f172a;
--surface: #1e293b;
--text: #f8fafc;
--muted: #94a3b8;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
max-width: 600px;
margin: 0 auto;
padding: 32px 16px;
}
h1 {
font-size: 24px;
margin-bottom: 24px;
}
.card {
background: var(--surface);
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
}
.card h2 {
font-size: 16px;
margin-bottom: 16px;
color: var(--muted);
}
.field {
margin-bottom: 16px;
}
.field:last-child { margin-bottom: 0; }
label {
display: block;
font-size: 13px;
color: var(--muted);
margin-bottom: 6px;
}
input[type="text"],
input[type="url"],
select {
width: 100%;
padding: 10px 12px;
background: var(--bg);
border: 1px solid #334155;
border-radius: 8px;
color: var(--text);
font-size: 14px;
outline: none;
}
input:focus { border-color: var(--primary); }
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
}
.toggle-row label { margin-bottom: 0; }
.toggle {
width: 44px;
height: 24px;
background: #334155;
border-radius: 12px;
cursor: pointer;
position: relative;
transition: background 0.2s;
}
.toggle.active { background: var(--primary); }
.toggle::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle.active::after { transform: translateX(20px); }
.btn {
padding: 10px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
background: var(--primary);
color: white;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.status {
margin-top: 12px;
padding: 10px;
border-radius: 8px;
font-size: 13px;
display: none;
}
.status.success { display: block; background: #052e16; color: #22c55e; }
.status.error { display: block; background: #450a0a; color: #ef4444; }
.status.info { display: block; background: #1e1b4b; color: #818cf8; }
</style>
</head>
<body>
<h1>ShieldAI Settings</h1>
<div class="card">
<h2>API Configuration</h2>
<div class="field">
<label for="apiUrl">API URL</label>
<input type="url" id="apiUrl" placeholder="https://api.shieldai.com/api/trpc" />
</div>
<div class="field">
<label for="apiKey">API Key</label>
<input type="text" id="apiKey" placeholder="Enter your API key" />
</div>
</div>
<div class="card">
<h2>Account Linking</h2>
<div class="field">
<div id="authStatus" style="font-size:13px;color:var(--muted);margin-bottom:12px;">
Loading auth status...
</div>
<button class="btn" id="linkDeviceBtn">Link Device</button>
<div id="linkStatus" class="status"></div>
</div>
</div>
<div class="card">
<h2>Notifications</h2>
<div class="toggle-row">
<label>Enable notifications</label>
<div class="toggle" id="notificationsToggle"></div>
</div>
</div>
<div class="card">
<h2>Phishing Detection</h2>
<div class="toggle-row">
<label>Enable phishing detection</label>
<div class="toggle active" id="phishingToggle"></div>
</div>
<div class="toggle-row" style="border-top:1px solid #334155;margin-top:8px;padding-top:8px;">
<label>Auto-report phishing</label>
<div class="toggle" id="autoReportToggle"></div>
</div>
</div>
<div class="card">
<button class="btn" id="saveBtn">Save Settings</button>
<div id="saveStatus" class="status"></div>
</div>
<script type="module" src="./options.ts"></script>
</body>
</html>

View File

@@ -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<ReturnType<typeof getSettings>>;
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();

View File

@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ShieldAI</title>
<style>
:root {
--primary: #6366f1;
--danger: #ef4444;
--success: #22c55e;
--bg: #0f172a;
--surface: #1e293b;
--text: #f8fafc;
--muted: #94a3b8;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
width: 360px;
padding: 16px;
}
.header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.logo {
width: 32px;
height: 32px;
background: var(--primary);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 16px;
}
.status-badge {
margin-left: auto;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
}
.status-badge.linked { background: var(--success); color: #052e16; }
.status-badge.unlinked { background: var(--danger); color: #450a0a; }
.section {
background: var(--surface);
border-radius: 10px;
padding: 12px;
margin-bottom: 12px;
}
.section h3 {
font-size: 13px;
color: var(--muted);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn {
display: block;
width: 100%;
padding: 10px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
background: var(--primary);
color: white;
margin-bottom: 8px;
}
.btn:last-child { margin-bottom: 0; }
.btn-outline {
background: transparent;
border: 1px solid var(--primary);
color: var(--primary);
}
.detection {
padding: 8px 0;
border-bottom: 1px solid rgba(255,255,255,0.06);
font-size: 12px;
}
.detection:last-child { border-bottom: none; }
.detection .label {
color: var(--muted);
font-size: 11px;
}
.detection.spam { border-left: 3px solid var(--danger); padding-left: 8px; }
.detection.phishing { border-left: 3px solid #eab308; padding-left: 8px; }
.empty-state {
color: var(--muted);
font-size: 12px;
text-align: center;
padding: 16px;
}
</style>
</head>
<body>
<div class="header">
<div class="logo">S</div>
<span style="font-weight:600;font-size:15px;">ShieldAI</span>
<span id="authBadge" class="status-badge unlinked">Unlinked</span>
</div>
<div class="section">
<h3>Quick Actions</h3>
<button class="btn" id="checkNumberBtn">Check Phone Number</button>
<button class="btn btn-outline" id="reportSpamBtn">Report Spam</button>
</div>
<div class="section">
<h3>Recent Detections</h3>
<div id="detections">
<div class="empty-state">No recent detections</div>
</div>
</div>
<div class="section" style="text-align:center;">
<a href="#" id="openOptions" style="color:var(--primary);font-size:12px;text-decoration:none;">Settings</a>
</div>
<script type="module" src="./popup.ts"></script>
</body>
</html>

View File

@@ -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 `<div class="detection ${typeClass}">
<div class="label">${detection.type.replace("_", " ")} · ${date}</div>
<div>${detection.value}</div>
</div>`;
}
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();

View File

@@ -0,0 +1,44 @@
export interface PhishingResult {
isPhishing: boolean;
confidence: number;
reasons: string[];
}
export interface SpamCheckResult {
isSpam: boolean;
score: number;
category: string;
details?: Record<string, unknown>;
}
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;
}

View File

@@ -0,0 +1,23 @@
import type { AuthStatus, LinkDeviceResult, ReportResult, SpamCheckResult } from "./index";
export interface TrpcClient {
spamshield: {
checkNumber: {
query: (input: { phoneNumber: string }) => Promise<SpamCheckResult>;
};
classifySMS: {
query: (input: { text: string }) => Promise<SpamCheckResult>;
};
};
extension: {
getAuthStatus: {
query: (input?: { apiKey?: string }) => Promise<AuthStatus>;
};
linkDevice: {
mutate: (input: { extensionId: string; deviceName?: string }) => Promise<LinkDeviceResult>;
};
reportPhishing: {
mutate: (input: { url: string; source?: string }) => Promise<ReportResult>;
};
};
}