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:
@@ -1,10 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "browser-ext",
|
"name": "@shieldai/browser-ext",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "echo 'browser-ext build placeholder'",
|
"dev": "vite build --watch",
|
||||||
"dev": "echo 'browser-ext dev placeholder'",
|
"build": "vite build",
|
||||||
"test": "echo 'browser-ext test placeholder'"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
browser-ext/public/icons/icon-128.png
Normal file
BIN
browser-ext/public/icons/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 228 B |
BIN
browser-ext/public/icons/icon-16.png
Normal file
BIN
browser-ext/public/icons/icon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 B |
BIN
browser-ext/public/icons/icon-48.png
Normal file
BIN
browser-ext/public/icons/icon-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 127 B |
44
browser-ext/public/manifest.json
Normal file
44
browser-ext/public/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
80
browser-ext/scripts/generate-icons.mjs
Normal file
80
browser-ext/scripts/generate-icons.mjs
Normal 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`);
|
||||||
|
}
|
||||||
98
browser-ext/src/background/index.ts
Normal file
98
browser-ext/src/background/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
35
browser-ext/src/content/index.ts
Normal file
35
browser-ext/src/content/index.ts
Normal 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();
|
||||||
17
browser-ext/src/lib/api-client.ts
Normal file
17
browser-ext/src/lib/api-client.ts
Normal 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>;
|
||||||
34
browser-ext/src/lib/cache.ts
Normal file
34
browser-ext/src/lib/cache.ts
Normal 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);
|
||||||
50
browser-ext/src/lib/phishing-detector.ts
Normal file
50
browser-ext/src/lib/phishing-detector.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
25
browser-ext/src/lib/settings.ts
Normal file
25
browser-ext/src/lib/settings.ts
Normal 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;
|
||||||
|
}
|
||||||
167
browser-ext/src/options/options.html
Normal file
167
browser-ext/src/options/options.html
Normal 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>
|
||||||
107
browser-ext/src/options/options.ts
Normal file
107
browser-ext/src/options/options.ts
Normal 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();
|
||||||
129
browser-ext/src/popup/popup.html
Normal file
129
browser-ext/src/popup/popup.html
Normal 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>
|
||||||
82
browser-ext/src/popup/popup.ts
Normal file
82
browser-ext/src/popup/popup.ts
Normal 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();
|
||||||
44
browser-ext/src/types/index.ts
Normal file
44
browser-ext/src/types/index.ts
Normal 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;
|
||||||
|
}
|
||||||
23
browser-ext/src/types/trpc.ts
Normal file
23
browser-ext/src/types/trpc.ts
Normal 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>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
90
browser-ext/tests/api-client.test.ts
Normal file
90
browser-ext/tests/api-client.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
53
browser-ext/tests/cache.test.ts
Normal file
53
browser-ext/tests/cache.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
48
browser-ext/tests/phishing-detector.test.ts
Normal file
48
browser-ext/tests/phishing-detector.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
browser-ext/tests/setup.ts
Normal file
40
browser-ext/tests/setup.ts
Normal 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
15
browser-ext/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
24
browser-ext/vite.config.ts
Normal file
24
browser-ext/vite.config.ts
Normal 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",
|
||||||
|
});
|
||||||
9
browser-ext/vitest.config.ts
Normal file
9
browser-ext/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./tests/setup.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -9,8 +9,11 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
"build": "pnpm --filter web build",
|
"build": "pnpm --filter web build",
|
||||||
|
"build:ext": "pnpm --filter @shieldai/browser-ext build",
|
||||||
"test": "pnpm --filter web test",
|
"test": "pnpm --filter web test",
|
||||||
|
"test:ext": "pnpm --filter @shieldai/browser-ext test",
|
||||||
"lint": "pnpm --filter web lint",
|
"lint": "pnpm --filter web lint",
|
||||||
|
"lint:ext": "pnpm --filter @shieldai/browser-ext lint",
|
||||||
"db:migrate": "pnpm --filter web db:migrate",
|
"db:migrate": "pnpm --filter web db:migrate",
|
||||||
"db:seed": "pnpm --filter web db:seed"
|
"db:seed": "pnpm --filter web db:seed"
|
||||||
},
|
},
|
||||||
|
|||||||
166
pnpm-lock.yaml
generated
166
pnpm-lock.yaml
generated
@@ -24,7 +24,30 @@ importers:
|
|||||||
specifier: ^4.1.5
|
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))
|
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:
|
web:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1824,6 +1847,9 @@ packages:
|
|||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
|
'@types/chrome@0.0.280':
|
||||||
|
resolution: {integrity: sha512-AotSmZrL9bcZDDmSI1D9dE7PGbhOur5L0cKxXd7IqbVizQWCY4gcvupPUVsQ4FfDj3V2tt/iOpomT9EY0s+w1g==}
|
||||||
|
|
||||||
'@types/deep-eql@4.0.2':
|
'@types/deep-eql@4.0.2':
|
||||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
@@ -1833,6 +1859,15 @@ packages:
|
|||||||
'@types/estree@1.0.9':
|
'@types/estree@1.0.9':
|
||||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
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':
|
'@types/hast@3.0.4':
|
||||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||||
|
|
||||||
@@ -2301,6 +2336,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
|
resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
copy-anything@4.0.5:
|
||||||
|
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
core-util-is@1.0.3:
|
core-util-is@1.0.3:
|
||||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||||
|
|
||||||
@@ -3066,6 +3105,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
|
resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
|
||||||
engines: {node: '>=12.13'}
|
engines: {node: '>=12.13'}
|
||||||
|
|
||||||
|
is-what@5.5.0:
|
||||||
|
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
is-wsl@3.1.1:
|
is-wsl@3.1.1:
|
||||||
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
|
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@@ -4097,6 +4140,10 @@ packages:
|
|||||||
stubs@3.0.0:
|
stubs@3.0.0:
|
||||||
resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==}
|
resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==}
|
||||||
|
|
||||||
|
superjson@2.2.6:
|
||||||
|
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
supports-color@10.2.2:
|
supports-color@10.2.2:
|
||||||
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
|
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -4434,6 +4481,46 @@ packages:
|
|||||||
'@testing-library/jest-dom':
|
'@testing-library/jest-dom':
|
||||||
optional: true
|
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:
|
vite@7.3.3:
|
||||||
resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==}
|
resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -6138,12 +6225,25 @@ snapshots:
|
|||||||
'@types/deep-eql': 4.0.2
|
'@types/deep-eql': 4.0.2
|
||||||
assertion-error: 2.0.1
|
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/deep-eql@4.0.2': {}
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@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':
|
'@types/hast@3.0.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
@@ -6264,6 +6364,14 @@ snapshots:
|
|||||||
chai: 6.2.2
|
chai: 6.2.2
|
||||||
tinyrainbow: 3.1.0
|
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))':
|
'@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:
|
dependencies:
|
||||||
'@vitest/spy': 4.1.7
|
'@vitest/spy': 4.1.7
|
||||||
@@ -6645,6 +6753,10 @@ snapshots:
|
|||||||
|
|
||||||
cookie@1.0.2: {}
|
cookie@1.0.2: {}
|
||||||
|
|
||||||
|
copy-anything@4.0.5:
|
||||||
|
dependencies:
|
||||||
|
is-what: 5.5.0
|
||||||
|
|
||||||
core-util-is@1.0.3: {}
|
core-util-is@1.0.3: {}
|
||||||
|
|
||||||
cosmiconfig@9.0.1(typescript@5.9.3):
|
cosmiconfig@9.0.1(typescript@5.9.3):
|
||||||
@@ -7439,6 +7551,8 @@ snapshots:
|
|||||||
|
|
||||||
is-what@4.1.16: {}
|
is-what@4.1.16: {}
|
||||||
|
|
||||||
|
is-what@5.5.0: {}
|
||||||
|
|
||||||
is-wsl@3.1.1:
|
is-wsl@3.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-inside-container: 1.0.0
|
is-inside-container: 1.0.0
|
||||||
@@ -8626,6 +8740,10 @@ snapshots:
|
|||||||
stubs@3.0.0:
|
stubs@3.0.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
superjson@2.2.6:
|
||||||
|
dependencies:
|
||||||
|
copy-anything: 4.0.5
|
||||||
|
|
||||||
supports-color@10.2.2: {}
|
supports-color@10.2.2: {}
|
||||||
|
|
||||||
supports-color@7.2.0:
|
supports-color@7.2.0:
|
||||||
@@ -8966,6 +9084,22 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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):
|
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:
|
dependencies:
|
||||||
esbuild: 0.27.7
|
esbuild: 0.27.7
|
||||||
@@ -8986,6 +9120,36 @@ snapshots:
|
|||||||
optionalDependencies:
|
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)
|
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)):
|
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:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.7
|
'@vitest/expect': 4.1.7
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { removebrokersRouter } from "./routers/removebrokers";
|
|||||||
import { correlationRouter } from "./routers/correlation";
|
import { correlationRouter } from "./routers/correlation";
|
||||||
import { reportsRouter } from "./routers/reports";
|
import { reportsRouter } from "./routers/reports";
|
||||||
import { schedulerRouter } from "./routers/scheduler";
|
import { schedulerRouter } from "./routers/scheduler";
|
||||||
|
import { extensionRouter } from "./routers/extension";
|
||||||
import { createTRPCRouter } from "./utils";
|
import { createTRPCRouter } from "./utils";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
@@ -25,6 +26,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
correlation: correlationRouter,
|
correlation: correlationRouter,
|
||||||
reports: reportsRouter,
|
reports: reportsRouter,
|
||||||
scheduler: schedulerRouter,
|
scheduler: schedulerRouter,
|
||||||
|
extension: extensionRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
55
web/src/server/api/routers/extension.ts
Normal file
55
web/src/server/api/routers/extension.ts
Normal 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 };
|
||||||
|
}),
|
||||||
|
});
|
||||||
15
web/src/server/api/schemas/extension.ts
Normal file
15
web/src/server/api/schemas/extension.ts
Normal 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()),
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user