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

@@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 B

View File

@@ -0,0 +1,44 @@
{
"manifest_version": 3,
"name": "ShieldAI",
"version": "0.1.0",
"description": "AI-powered spam call, SMS, and phishing protection",
"permissions": [
"activeTab",
"storage",
"declarativeNetRequest",
"notifications"
],
"host_permissions": [
"https://api.shieldai.com/*",
"https://*.shieldai.com/*"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"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"
}
}

View File

@@ -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`);
}

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

View File

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

View File

@@ -0,0 +1,53 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Cache } from "../src/lib/cache";
describe("Cache", () => {
let cache: Cache<string>;
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();
});
});

View File

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

View File

@@ -0,0 +1,40 @@
import { vi } from "vitest";
const mockStorage: Record<string, unknown> = {};
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<string, unknown>) => {
if (typeof keys === "string") {
return Promise.resolve({ [keys]: mockStorage[keys] ?? null });
}
if (Array.isArray(keys)) {
const result: Record<string, unknown> = {};
for (const key of keys) result[key] = mockStorage[key] ?? null;
return Promise.resolve(result);
}
return Promise.resolve({});
}),
set: vi.fn((items: Record<string, unknown>) => {
Object.assign(mockStorage, items);
return Promise.resolve();
}),
},
local: {
get: vi.fn(),
set: vi.fn(),
},
},
});

15
browser-ext/tsconfig.json Normal file
View File

@@ -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"]
}

View File

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

View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./tests/setup.ts"],
},
});

View File

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

166
pnpm-lock.yaml generated
View File

@@ -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

View File

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

View File

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

View File

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