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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
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": {
|
||||
"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
166
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
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