forked from pds.ls/pdsls
this repo has no description

Compare changes

Choose any two refs to compare.

+1503 -1
+111
DID_MONITOR_README.md
··· 1 + # DID Monitor - AT Protocol DID Document Monitoring Service 2 + 3 + A monitoring service for AT Protocol DID documents, transformed from the PDSls AT Protocol Explorer. This service helps users monitor their `did:plc` documents for unauthorized changes and receive alerts within the 72-hour window when PLC operations can still be nullified. 4 + 5 + ## Features 6 + 7 + - **DID Registration**: Register your `did:plc` identifier for monitoring 8 + - **Handle Autocomplete**: Reuses PDSls autocomplete functionality with user avatars 9 + - **Daily Monitoring**: Automatically checks registered DIDs once daily for changes 10 + - **Email Alerts**: Sends notifications when changes are detected within 72 hours 11 + - **Dashboard**: View registered DIDs, monitoring status, and alert history 12 + - **Local Storage**: Data stored in browser localStorage (no server required) 13 + 14 + ## How It Works 15 + 16 + 1. **Register Your DID**: Enter your AT Protocol handle or `did:plc` identifier 17 + 2. **Daily Checks**: The service fetches the PLC audit log from `https://plc.directory/{did}/log/audit` 18 + 3. **Change Detection**: Compares the latest operation with the last known state 19 + 4. **Alert System**: Sends browser notifications and email alerts for recent changes 20 + 5. **72-Hour Window**: Gives you time to nullify unauthorized PLC operations 21 + 22 + ## Usage 23 + 24 + ### Registration 25 + 26 + 1. Go to the "Register DID" tab 27 + 2. Enter your handle (e.g., `alice.bsky.social`) or DID 28 + 3. Use the autocomplete to select your identity 29 + 4. Enter your email address for notifications 30 + 5. Click "Register for Monitoring" 31 + 32 + ### Dashboard 33 + 34 + - View all registered DIDs and their monitoring status 35 + - Check when each DID was last monitored 36 + - Manually trigger checks for specific DIDs 37 + - Start/stop the monitoring service 38 + - View alert history 39 + 40 + ## Development 41 + 42 + ### Start the Development Server 43 + 44 + ```bash 45 + pnpm start 46 + ``` 47 + 48 + ### Build for Production 49 + 50 + ```bash 51 + pnpm build 52 + ``` 53 + 54 + ## Technical Details 55 + 56 + ### Architecture 57 + 58 + - **Frontend**: SolidJS with TypeScript 59 + - **Storage**: Browser localStorage (no backend required) 60 + - **PLC API**: Fetches from `https://plc.directory/{did}/log/audit` 61 + - **Monitoring**: Client-side scheduler with 24-hour intervals 62 + 63 + ### Key Components 64 + 65 + - `MonitoringView`: Main tabbed interface 66 + - `RegistrationForm`: DID registration with autocomplete 67 + - `MonitoringDashboard`: Status and management interface 68 + - `DidInput`: Reused autocomplete component from PDSls 69 + - `MonitoringScheduler`: Daily check scheduling 70 + - `PlcMonitoringService`: PLC directory API integration 71 + - `EmailNotificationService`: Alert system 72 + 73 + ### Data Flow 74 + 75 + 1. User registers DID with email 76 + 2. Scheduler runs daily checks 77 + 3. Fetches latest PLC operation 78 + 4. Compares with stored state 79 + 5. Sends alerts for changes within 72 hours 80 + 6. Updates stored state 81 + 82 + ## Limitations 83 + 84 + - Only supports `did:plc` identifiers 85 + - Uses browser notifications (email integration would require backend) 86 + - Data stored locally (no sync across devices) 87 + - Requires browser to be open for monitoring (in production, would need background service) 88 + 89 + ## Production Considerations 90 + 91 + For a production deployment, consider: 92 + 93 + - Backend database for persistent storage 94 + - Real email service integration (SendGrid, AWS SES) 95 + - Background worker service for monitoring 96 + - User authentication and account management 97 + - Push notification service for mobile 98 + - Proper error handling and retry logic 99 + 100 + ## Original PDSls Features 101 + 102 + The original AT Protocol Explorer features are still available at `/explorer`: 103 + 104 + - PDS exploration and browsing 105 + - Record viewing and editing 106 + - Jetstream/Firehose monitoring 107 + - AT Protocol debugging tools 108 + 109 + ## License 110 + 111 + Licensed under 0BSD (same as original PDSls project).
+150
src/components/did-input.tsx
··· 1 + // DID/Handle input component with autocomplete - reused from search.tsx 2 + import { Client, CredentialManager } from "@atcute/client"; 3 + import { createResource, createSignal, For, Show, JSX } from "solid-js"; 4 + import { createDebouncedValue } from "../utils/hooks/debounced"; 5 + import { resolveHandle } from "../utils/api"; 6 + 7 + interface DidInputProps { 8 + onDidResolved: (did: string, handle?: string) => void; 9 + placeholder?: string; 10 + class?: string; 11 + } 12 + 13 + export const DidInput = (props: DidInputProps) => { 14 + let inputRef!: HTMLInputElement; 15 + const rpc = new Client({ 16 + handler: new CredentialManager({ service: "https://public.api.bsky.app" }), 17 + }); 18 + 19 + const fetchTypeahead = async (input: string) => { 20 + if (!input.length) return []; 21 + const res = await rpc.get("app.bsky.actor.searchActorsTypeahead", { 22 + params: { q: input, limit: 8 }, 23 + }); 24 + if (res.ok) { 25 + return res.data.actors; 26 + } 27 + return []; 28 + }; 29 + 30 + const [input, setInput] = createSignal<string>(""); 31 + const [selectedDid, setSelectedDid] = createSignal<string>(""); 32 + const [selectedHandle, setSelectedHandle] = createSignal<string>(""); 33 + const [search] = createResource(createDebouncedValue(input, 250), fetchTypeahead); 34 + 35 + const processInput = async (inputValue: string) => { 36 + inputValue = inputValue.trim().replace(/^@/, ""); 37 + if (!inputValue.length) return; 38 + 39 + try { 40 + // If it's already a DID 41 + if (inputValue.startsWith('did:')) { 42 + setSelectedDid(inputValue); 43 + setSelectedHandle(""); 44 + props.onDidResolved(inputValue); 45 + return; 46 + } 47 + 48 + // If we have search results, use the first one 49 + if (search()?.length) { 50 + const actor = search()![0]; 51 + setSelectedDid(actor.did); 52 + setSelectedHandle(actor.handle); 53 + props.onDidResolved(actor.did, actor.handle); 54 + return; 55 + } 56 + 57 + // Try to resolve as handle 58 + const resolvedDid = await resolveHandle(inputValue); 59 + setSelectedDid(resolvedDid); 60 + setSelectedHandle(inputValue); 61 + props.onDidResolved(resolvedDid, inputValue); 62 + } catch (error) { 63 + console.error("Failed to resolve DID/handle:", error); 64 + throw new Error(`Could not resolve "${inputValue}". Please check the handle or DID.`); 65 + } 66 + }; 67 + 68 + const selectActor = (actor: any) => { 69 + setInput(actor.handle); 70 + setSelectedDid(actor.did); 71 + setSelectedHandle(actor.handle); 72 + props.onDidResolved(actor.did, actor.handle); 73 + }; 74 + 75 + const clearInput = () => { 76 + setInput(""); 77 + setSelectedDid(""); 78 + setSelectedHandle(""); 79 + inputRef.focus(); 80 + }; 81 + 82 + return ( 83 + <div class="relative w-full"> 84 + <div class="dark:bg-dark-100 dark:shadow-dark-800 flex items-center gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-white px-3 py-2 shadow-xs focus-within:outline-[1.5px] focus-within:outline-blue-600 dark:border-neutral-700 dark:focus-within:outline-blue-400"> 85 + <input 86 + type="text" 87 + spellcheck={false} 88 + placeholder={props.placeholder || "Enter handle (user.bsky.social) or DID"} 89 + ref={inputRef} 90 + class={`grow select-none placeholder:text-sm focus:outline-none ${props.class || ""}`} 91 + value={input()} 92 + onInput={(e) => setInput(e.currentTarget.value)} 93 + onKeyDown={(e) => { 94 + if (e.key === "Enter") { 95 + e.preventDefault(); 96 + processInput(input()).catch(err => { 97 + alert(err.message); 98 + }); 99 + } 100 + }} 101 + /> 102 + <Show when={input()}> 103 + <button 104 + type="button" 105 + class="flex items-center rounded-lg p-0.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-600 dark:active:bg-neutral-500" 106 + onClick={clearInput} 107 + > 108 + <span class="iconify lucide--x text-lg"></span> 109 + </button> 110 + </Show> 111 + </div> 112 + 113 + {/* Search results dropdown */} 114 + <Show when={search()?.length && input() && !selectedDid()}> 115 + <div class="dark:bg-dark-300 dark:shadow-dark-800 absolute z-30 mt-1 flex w-full flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-1 shadow-md transition-opacity duration-200 dark:border-neutral-700"> 116 + <For each={search()}> 117 + {(actor) => ( 118 + <button 119 + type="button" 120 + class="flex items-center gap-3 rounded-lg p-2 text-left hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 121 + onClick={() => selectActor(actor)} 122 + > 123 + <img 124 + src={actor.avatar?.replace("img/avatar/", "img/avatar_thumbnail/")} 125 + class="size-8 rounded-full" 126 + alt={`${actor.handle} avatar`} 127 + /> 128 + <div class="flex flex-col"> 129 + <span class="font-medium">{actor.handle}</span> 130 + <span class="text-xs text-neutral-600 dark:text-neutral-400">{actor.displayName || actor.did}</span> 131 + </div> 132 + </button> 133 + )} 134 + </For> 135 + </div> 136 + </Show> 137 + 138 + {/* Selected DID display */} 139 + <Show when={selectedDid()}> 140 + <div class="mt-2 rounded-lg bg-green-50 border border-green-200 p-2 text-sm dark:bg-green-900/20 dark:border-green-800"> 141 + <div class="font-medium text-green-800 dark:text-green-200">โœ“ Resolved DID:</div> 142 + <div class="text-green-700 dark:text-green-300 font-mono text-xs break-all">{selectedDid()}</div> 143 + <Show when={selectedHandle()}> 144 + <div class="text-green-600 dark:text-green-400">Handle: {selectedHandle()}</div> 145 + </Show> 146 + </div> 147 + </Show> 148 + </div> 149 + ); 150 + };
+235
src/components/monitoring-dashboard.tsx
··· 1 + // Dashboard for monitoring service 2 + import { createSignal, onMount, For, Show } from "solid-js"; 3 + import { MonitoringDatabase } from "../utils/monitoring-db"; 4 + import { MonitoringScheduler } from "../utils/monitoring-scheduler"; 5 + import { EmailNotificationService } from "../utils/email-service"; 6 + import { MonitoredDid } from "../types/monitoring"; 7 + 8 + export const MonitoringDashboard = () => { 9 + const [monitoredDids, setMonitoredDids] = createSignal<MonitoredDid[]>([]); 10 + const [isMonitoringActive, setIsMonitoringActive] = createSignal<boolean>(false); 11 + const [stats, setStats] = createSignal<any>({}); 12 + 13 + const refreshData = () => { 14 + setMonitoredDids(MonitoringDatabase.getMonitoredDids()); 15 + setIsMonitoringActive(MonitoringScheduler.isActive()); 16 + setStats(MonitoringScheduler.getStatus()); 17 + }; 18 + 19 + onMount(() => { 20 + refreshData(); 21 + 22 + // Refresh data every 30 seconds 23 + const interval = setInterval(refreshData, 30000); 24 + 25 + return () => clearInterval(interval); 26 + }); 27 + 28 + const toggleMonitoring = () => { 29 + if (isMonitoringActive()) { 30 + MonitoringScheduler.stop(); 31 + } else { 32 + MonitoringScheduler.start(); 33 + } 34 + refreshData(); 35 + }; 36 + 37 + const runManualCheck = async (did: string) => { 38 + try { 39 + await MonitoringScheduler.checkDidManually(did); 40 + refreshData(); 41 + alert("Manual check completed. Check console for details."); 42 + } catch (error: any) { 43 + alert(`Check failed: ${error.message}`); 44 + } 45 + }; 46 + 47 + const removeRegistration = (id: string) => { 48 + if (confirm("Are you sure you want to remove this DID from monitoring?")) { 49 + MonitoringDatabase.removeDidRecord(id); 50 + refreshData(); 51 + } 52 + }; 53 + 54 + const testNotification = async () => { 55 + try { 56 + await EmailNotificationService.sendTestAlert("test@example.com"); 57 + alert("Test notification sent! Check your browser notifications."); 58 + } catch (error: any) { 59 + alert(`Test failed: ${error.message}`); 60 + } 61 + }; 62 + 63 + const formatTimestamp = (timestamp?: string) => { 64 + if (!timestamp) return "Never"; 65 + return new Date(timestamp).toLocaleString(); 66 + }; 67 + 68 + return ( 69 + <div class="max-w-6xl mx-auto p-6 space-y-6"> 70 + {/* Header */} 71 + <div class="bg-white dark:bg-dark-100 rounded-lg shadow-md p-6"> 72 + <h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-2"> 73 + DID Monitoring Dashboard 74 + </h1> 75 + <p class="text-neutral-600 dark:text-neutral-400"> 76 + Monitor your AT Protocol DID documents for unauthorized changes 77 + </p> 78 + </div> 79 + 80 + {/* Status Cards */} 81 + <div class="grid grid-cols-1 md:grid-cols-3 gap-6"> 82 + <div class="bg-white dark:bg-dark-100 rounded-lg shadow-md p-6"> 83 + <h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2"> 84 + Monitoring Status 85 + </h3> 86 + <div class="flex items-center gap-2 mb-3"> 87 + <div class={`w-3 h-3 rounded-full ${isMonitoringActive() ? 'bg-green-500' : 'bg-red-500'}`}></div> 88 + <span class={isMonitoringActive() ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}> 89 + {isMonitoringActive() ? 'Active' : 'Inactive'} 90 + </span> 91 + </div> 92 + <button 93 + onClick={toggleMonitoring} 94 + class={`px-4 py-2 rounded-lg font-medium transition-colors ${ 95 + isMonitoringActive() 96 + ? 'bg-red-600 hover:bg-red-700 text-white' 97 + : 'bg-green-600 hover:bg-green-700 text-white' 98 + }`} 99 + > 100 + {isMonitoringActive() ? 'Stop Monitoring' : 'Start Monitoring'} 101 + </button> 102 + </div> 103 + 104 + <div class="bg-white dark:bg-dark-100 rounded-lg shadow-md p-6"> 105 + <h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2"> 106 + Registered DIDs 107 + </h3> 108 + <div class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-1"> 109 + {stats().totalRegistered || 0} 110 + </div> 111 + <div class="text-sm text-neutral-600 dark:text-neutral-400"> 112 + {stats().activeMonitored || 0} active 113 + </div> 114 + </div> 115 + 116 + <div class="bg-white dark:bg-dark-100 rounded-lg shadow-md p-6"> 117 + <h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2"> 118 + Last Check 119 + </h3> 120 + <div class="text-sm text-neutral-600 dark:text-neutral-400"> 121 + {formatTimestamp(stats().lastCheckRun)} 122 + </div> 123 + <div class="mt-2"> 124 + <button 125 + onClick={testNotification} 126 + class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors" 127 + > 128 + Test Notifications 129 + </button> 130 + </div> 131 + </div> 132 + </div> 133 + 134 + {/* Registered DIDs Table */} 135 + <div class="bg-white dark:bg-dark-100 rounded-lg shadow-md p-6"> 136 + <div class="flex justify-between items-center mb-4"> 137 + <h2 class="text-xl font-semibold text-neutral-900 dark:text-neutral-100"> 138 + Registered DIDs 139 + </h2> 140 + <button 141 + onClick={refreshData} 142 + class="px-3 py-1 bg-neutral-600 hover:bg-neutral-700 text-white text-sm rounded-lg transition-colors" 143 + > 144 + Refresh 145 + </button> 146 + </div> 147 + 148 + <Show when={monitoredDids().length === 0}> 149 + <div class="text-center py-8 text-neutral-500 dark:text-neutral-400"> 150 + No DIDs registered for monitoring yet. 151 + </div> 152 + </Show> 153 + 154 + <Show when={monitoredDids().length > 0}> 155 + <div class="overflow-x-auto"> 156 + <table class="w-full text-sm"> 157 + <thead> 158 + <tr class="border-b border-neutral-200 dark:border-neutral-700"> 159 + <th class="text-left py-3 px-2">Handle/DID</th> 160 + <th class="text-left py-3 px-2">Email</th> 161 + <th class="text-left py-3 px-2">Registered</th> 162 + <th class="text-left py-3 px-2">Last Checked</th> 163 + <th class="text-left py-3 px-2">Status</th> 164 + <th class="text-left py-3 px-2">Actions</th> 165 + </tr> 166 + </thead> 167 + <tbody> 168 + <For each={monitoredDids()}> 169 + {(did) => ( 170 + <tr class="border-b border-neutral-100 dark:border-neutral-800"> 171 + <td class="py-3 px-2"> 172 + <div> 173 + <div class="font-medium"> 174 + {did.handle || 'Unknown Handle'} 175 + </div> 176 + <div class="text-xs text-neutral-500 dark:text-neutral-400 font-mono"> 177 + {did.did} 178 + </div> 179 + </div> 180 + </td> 181 + <td class="py-3 px-2 text-neutral-600 dark:text-neutral-400"> 182 + {did.email} 183 + </td> 184 + <td class="py-3 px-2 text-neutral-600 dark:text-neutral-400"> 185 + {formatTimestamp(did.registeredAt)} 186 + </td> 187 + <td class="py-3 px-2 text-neutral-600 dark:text-neutral-400"> 188 + {formatTimestamp(did.lastChecked)} 189 + </td> 190 + <td class="py-3 px-2"> 191 + <span class={`px-2 py-1 rounded-full text-xs ${ 192 + did.isActive 193 + ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400' 194 + : 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400' 195 + }`}> 196 + {did.isActive ? 'Active' : 'Inactive'} 197 + </span> 198 + </td> 199 + <td class="py-3 px-2"> 200 + <div class="flex gap-2"> 201 + <button 202 + onClick={() => runManualCheck(did.did)} 203 + class="px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded transition-colors" 204 + > 205 + Check Now 206 + </button> 207 + <button 208 + onClick={() => removeRegistration(did.id)} 209 + class="px-2 py-1 bg-red-600 hover:bg-red-700 text-white text-xs rounded transition-colors" 210 + > 211 + Remove 212 + </button> 213 + </div> 214 + </td> 215 + </tr> 216 + )} 217 + </For> 218 + </tbody> 219 + </table> 220 + </div> 221 + </Show> 222 + </div> 223 + 224 + {/* Alert Log */} 225 + <div class="bg-white dark:bg-dark-100 rounded-lg shadow-md p-6"> 226 + <h2 class="text-xl font-semibold text-neutral-900 dark:text-neutral-100 mb-4"> 227 + Recent Alerts 228 + </h2> 229 + <div class="text-sm text-neutral-600 dark:text-neutral-400"> 230 + Alert history will appear here when changes are detected. 231 + </div> 232 + </div> 233 + </div> 234 + ); 235 + };
+109
src/components/monitoring-navbar.tsx
··· 1 + // Simple navbar for DID monitoring service 2 + import { A, useLocation } from "@solidjs/router"; 3 + import { createSignal, Show } from "solid-js"; 4 + 5 + export const MonitoringNavbar = () => { 6 + const location = useLocation(); 7 + const [showMobileMenu, setShowMobileMenu] = createSignal(false); 8 + 9 + return ( 10 + <nav class="bg-white dark:bg-dark-100 border-b border-neutral-200 dark:border-neutral-700"> 11 + <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> 12 + <div class="flex justify-between h-16"> 13 + <div class="flex items-center"> 14 + {/* Logo/Title */} 15 + <A href="/" class="flex items-center space-x-2"> 16 + <span class="iconify lucide--shield-check text-2xl text-blue-600"></span> 17 + <span class="text-xl font-bold text-neutral-900 dark:text-neutral-100"> 18 + DID Monitor 19 + </span> 20 + </A> 21 + </div> 22 + 23 + {/* Desktop Navigation */} 24 + <div class="hidden md:flex items-center space-x-8"> 25 + <A 26 + href="/" 27 + class={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${ 28 + location.pathname === "/" || location.pathname === "/monitor" 29 + ? "text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-900/20" 30 + : "text-neutral-700 hover:text-neutral-900 dark:text-neutral-300 dark:hover:text-neutral-100" 31 + }`} 32 + > 33 + Monitor 34 + </A> 35 + <A 36 + href="/explorer" 37 + class={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${ 38 + location.pathname === "/explorer" 39 + ? "text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-900/20" 40 + : "text-neutral-700 hover:text-neutral-900 dark:text-neutral-300 dark:hover:text-neutral-100" 41 + }`} 42 + > 43 + Explorer 44 + </A> 45 + <A 46 + href="/settings" 47 + class={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${ 48 + location.pathname === "/settings" 49 + ? "text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-900/20" 50 + : "text-neutral-700 hover:text-neutral-900 dark:text-neutral-300 dark:hover:text-neutral-100" 51 + }`} 52 + > 53 + Settings 54 + </A> 55 + </div> 56 + 57 + {/* Mobile menu button */} 58 + <div class="md:hidden flex items-center"> 59 + <button 60 + onClick={() => setShowMobileMenu(!showMobileMenu())} 61 + class="p-2 rounded-md text-neutral-700 hover:text-neutral-900 dark:text-neutral-300 dark:hover:text-neutral-100" 62 + > 63 + <span class={`iconify ${showMobileMenu() ? 'lucide--x' : 'lucide--menu'} text-xl`}></span> 64 + </button> 65 + </div> 66 + </div> 67 + 68 + {/* Mobile Navigation */} 69 + <Show when={showMobileMenu()}> 70 + <div class="md:hidden pb-4 space-y-2"> 71 + <A 72 + href="/" 73 + class={`block px-3 py-2 rounded-md text-base font-medium transition-colors ${ 74 + location.pathname === "/" || location.pathname === "/monitor" 75 + ? "text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-900/20" 76 + : "text-neutral-700 hover:text-neutral-900 dark:text-neutral-300 dark:hover:text-neutral-100" 77 + }`} 78 + onClick={() => setShowMobileMenu(false)} 79 + > 80 + Monitor 81 + </A> 82 + <A 83 + href="/explorer" 84 + class={`block px-3 py-2 rounded-md text-base font-medium transition-colors ${ 85 + location.pathname === "/explorer" 86 + ? "text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-900/20" 87 + : "text-neutral-700 hover:text-neutral-900 dark:text-neutral-300 dark:hover:text-neutral-100" 88 + }`} 89 + onClick={() => setShowMobileMenu(false)} 90 + > 91 + Explorer 92 + </A> 93 + <A 94 + href="/settings" 95 + class={`block px-3 py-2 rounded-md text-base font-medium transition-colors ${ 96 + location.pathname === "/settings" 97 + ? "text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-900/20" 98 + : "text-neutral-700 hover:text-neutral-900 dark:text-neutral-300 dark:hover:text-neutral-100" 99 + }`} 100 + onClick={() => setShowMobileMenu(false)} 101 + > 102 + Settings 103 + </A> 104 + </div> 105 + </Show> 106 + </div> 107 + </nav> 108 + ); 109 + };
+163
src/components/registration-form.tsx
··· 1 + // Registration form for DID monitoring service 2 + import { createSignal, Show } from "solid-js"; 3 + import { DidInput } from "./did-input"; 4 + import { MonitoringDatabase } from "../utils/monitoring-db"; 5 + import { MonitoringScheduler } from "../utils/monitoring-scheduler"; 6 + 7 + export const RegistrationForm = () => { 8 + const [selectedDid, setSelectedDid] = createSignal<string>(""); 9 + const [selectedHandle, setSelectedHandle] = createSignal<string>(""); 10 + const [email, setEmail] = createSignal<string>(""); 11 + const [isSubmitting, setIsSubmitting] = createSignal<boolean>(false); 12 + const [message, setMessage] = createSignal<string>(""); 13 + const [messageType, setMessageType] = createSignal<"success" | "error" | "">(""); 14 + 15 + const handleDidResolved = (did: string, handle?: string) => { 16 + setSelectedDid(did); 17 + setSelectedHandle(handle || ""); 18 + setMessage(""); 19 + setMessageType(""); 20 + }; 21 + 22 + const validateEmail = (email: string): boolean => { 23 + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 24 + return emailRegex.test(email); 25 + }; 26 + 27 + const handleSubmit = async (e: Event) => { 28 + e.preventDefault(); 29 + 30 + if (!selectedDid()) { 31 + setMessage("Please enter and resolve a valid handle or DID"); 32 + setMessageType("error"); 33 + return; 34 + } 35 + 36 + if (!selectedDid().startsWith('did:plc:')) { 37 + setMessage("Currently only did:plc identifiers are supported for monitoring"); 38 + setMessageType("error"); 39 + return; 40 + } 41 + 42 + if (!email() || !validateEmail(email())) { 43 + setMessage("Please enter a valid email address"); 44 + setMessageType("error"); 45 + return; 46 + } 47 + 48 + setIsSubmitting(true); 49 + setMessage(""); 50 + 51 + try { 52 + // Register the DID for monitoring 53 + const registration = await MonitoringDatabase.registerDid( 54 + selectedDid(), 55 + selectedHandle(), 56 + email() 57 + ); 58 + 59 + setMessage(`Successfully registered ${selectedHandle() || selectedDid()} for monitoring. You will receive email alerts if any changes are detected within 72 hours.`); 60 + setMessageType("success"); 61 + 62 + // Start monitoring service if not already running 63 + if (!MonitoringScheduler.isActive()) { 64 + MonitoringScheduler.start(); 65 + } 66 + 67 + // Clear form 68 + setSelectedDid(""); 69 + setSelectedHandle(""); 70 + setEmail(""); 71 + 72 + } catch (error: any) { 73 + console.error("Registration failed:", error); 74 + setMessage(error.message || "Registration failed. Please try again."); 75 + setMessageType("error"); 76 + } finally { 77 + setIsSubmitting(false); 78 + } 79 + }; 80 + 81 + return ( 82 + <div class="max-w-2xl mx-auto p-6 bg-white dark:bg-dark-100 rounded-lg shadow-md"> 83 + <div class="mb-6"> 84 + <h2 class="text-2xl font-bold text-neutral-900 dark:text-neutral-100 mb-2"> 85 + Register for DID Monitoring 86 + </h2> 87 + <p class="text-neutral-600 dark:text-neutral-400"> 88 + Monitor your did:plc document for unauthorized changes. Get notified within the 72-hour window when operations can still be nullified. 89 + </p> 90 + </div> 91 + 92 + <form onSubmit={handleSubmit} class="space-y-6"> 93 + {/* DID/Handle Input */} 94 + <div> 95 + <label class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2"> 96 + AT Protocol Handle or DID 97 + </label> 98 + <DidInput 99 + onDidResolved={handleDidResolved} 100 + placeholder="Enter your handle (e.g., alice.bsky.social) or did:plc:..." 101 + class="w-full" 102 + /> 103 + <p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400"> 104 + Start typing your handle to see autocomplete suggestions with avatars 105 + </p> 106 + </div> 107 + 108 + {/* Email Input */} 109 + <div> 110 + <label for="email" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2"> 111 + Email Address for Alerts 112 + </label> 113 + <input 114 + type="email" 115 + id="email" 116 + value={email()} 117 + onInput={(e) => setEmail(e.currentTarget.value)} 118 + placeholder="your.email@example.com" 119 + class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-dark-200 dark:text-neutral-100" 120 + required 121 + /> 122 + <p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400"> 123 + You'll receive email notifications if changes are detected within 72 hours 124 + </p> 125 + </div> 126 + 127 + {/* Submit Button */} 128 + <div> 129 + <button 130 + type="submit" 131 + disabled={isSubmitting() || !selectedDid() || !email()} 132 + class="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-neutral-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors duration-200" 133 + > 134 + {isSubmitting() ? "Registering..." : "Register for Monitoring"} 135 + </button> 136 + </div> 137 + 138 + {/* Message Display */} 139 + <Show when={message()}> 140 + <div class={`p-4 rounded-lg ${ 141 + messageType() === "success" 142 + ? "bg-green-50 border border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200" 143 + : "bg-red-50 border border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200" 144 + }`}> 145 + {message()} 146 + </div> 147 + </Show> 148 + </form> 149 + 150 + {/* Information Section */} 151 + <div class="mt-8 p-4 bg-neutral-50 dark:bg-dark-200 rounded-lg"> 152 + <h3 class="font-medium text-neutral-900 dark:text-neutral-100 mb-2">How it works:</h3> 153 + <ul class="text-sm text-neutral-600 dark:text-neutral-400 space-y-1"> 154 + <li>โ€ข Your DID document is checked once daily for changes</li> 155 + <li>โ€ข If changes are detected within 72 hours, you'll receive an email alert</li> 156 + <li>โ€ข This gives you time to nullify unauthorized PLC operations</li> 157 + <li>โ€ข Currently supports did:plc identifiers only</li> 158 + <li>โ€ข Data is stored locally in your browser</li> 159 + </ul> 160 + </div> 161 + </div> 162 + ); 163 + };
+4 -1
src/index.tsx
··· 11 11 import { RepoView } from "./views/repo.tsx"; 12 12 import { Settings } from "./views/settings.tsx"; 13 13 import { StreamView } from "./views/stream.tsx"; 14 + import { MonitoringView } from "./views/monitoring.tsx"; 14 15 15 16 render( 16 17 () => ( 17 18 <Router root={Layout}> 18 - <Route path="/" component={Home} /> 19 + <Route path="/" component={MonitoringView} /> 20 + <Route path="/monitor" component={MonitoringView} /> 21 + <Route path="/explorer" component={Home} /> 19 22 <Route path={["/jetstream", "/firehose"]} component={StreamView} /> 20 23 <Route path="/settings" component={Settings} /> 21 24 <Route path="/:pds" component={PdsView} />
+89
src/monitoring-layout.tsx
··· 1 + // Layout specifically for the DID monitoring service 2 + import { Meta, MetaProvider } from "@solidjs/meta"; 3 + import { RouteSectionProps, useLocation } from "@solidjs/router"; 4 + import { createEffect, createSignal, ErrorBoundary, onMount, Show, Suspense } from "solid-js"; 5 + import { MonitoringNavbar } from "./components/monitoring-navbar"; 6 + import { themeEvent, ThemeSelection } from "./components/theme"; 7 + 8 + export const [notif, setNotif] = createSignal<{ 9 + show: boolean; 10 + icon?: string; 11 + text?: string; 12 + }>({ show: false }); 13 + 14 + const MonitoringLayout = (props: RouteSectionProps<unknown>) => { 15 + const location = useLocation(); 16 + let timeout: number; 17 + 18 + createEffect(() => { 19 + if (notif().show) { 20 + clearTimeout(timeout); 21 + timeout = setTimeout(() => setNotif({ show: false }), 3000); 22 + } 23 + }); 24 + 25 + onMount(() => { 26 + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", themeEvent); 27 + }); 28 + 29 + return ( 30 + <div class="min-h-screen bg-neutral-50 dark:bg-dark-300 text-neutral-900 dark:text-neutral-200"> 31 + <MetaProvider> 32 + <Meta name="description" content="AT Protocol DID Document Monitoring Service" /> 33 + <Meta name="viewport" content="width=device-width, initial-scale=1" /> 34 + <Show when={location.pathname !== "/"}> 35 + <Meta name="robots" content="noindex, nofollow" /> 36 + </Show> 37 + </MetaProvider> 38 + 39 + <MonitoringNavbar /> 40 + 41 + <main class="pb-8"> 42 + <ErrorBoundary 43 + fallback={(err) => ( 44 + <div class="max-w-2xl mx-auto p-6"> 45 + <div class="bg-red-50 border border-red-200 rounded-lg p-4 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200"> 46 + <h3 class="font-medium mb-2">Application Error</h3> 47 + <p class="text-sm break-words">{err.message}</p> 48 + </div> 49 + </div> 50 + )} 51 + > 52 + <Suspense 53 + fallback={ 54 + <div class="flex justify-center items-center py-16"> 55 + <span class="iconify lucide--loader-circle animate-spin text-2xl text-blue-600"></span> 56 + </div> 57 + } 58 + > 59 + {props.children} 60 + </Suspense> 61 + </ErrorBoundary> 62 + </main> 63 + 64 + {/* Global notifications */} 65 + <Show when={notif().show}> 66 + <div class="fixed bottom-6 right-6 z-50"> 67 + <div class="bg-white dark:bg-dark-100 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg p-4 max-w-sm"> 68 + <div class="flex items-center justify-between"> 69 + <div class="flex items-center space-x-2"> 70 + <Show when={notif().icon}> 71 + <span class={`iconify ${notif().icon} text-lg`}></span> 72 + </Show> 73 + <span class="text-sm">{notif().text}</span> 74 + </div> 75 + <button 76 + onClick={() => setNotif({ show: false })} 77 + class="ml-4 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300" 78 + > 79 + <span class="iconify lucide--x text-sm"></span> 80 + </button> 81 + </div> 82 + </div> 83 + </div> 84 + </Show> 85 + </div> 86 + ); 87 + }; 88 + 89 + export { MonitoringLayout };
+43
src/types/monitoring.ts
··· 1 + // Types for the DID monitoring service 2 + 3 + export interface MonitoredDid { 4 + id: string; 5 + did: string; 6 + handle?: string; 7 + email: string; 8 + registeredAt: string; 9 + lastChecked?: string; 10 + lastOperationCid?: string; 11 + isActive: boolean; 12 + } 13 + 14 + export interface PlcOperation { 15 + did: string; 16 + operation: { 17 + sig: string; 18 + prev: string | null; 19 + type: string; 20 + services?: Record<string, { type: string; endpoint: string }>; 21 + alsoKnownAs?: string[]; 22 + rotationKeys?: string[]; 23 + verificationMethods?: Record<string, string>; 24 + }; 25 + cid: string; 26 + nullified: boolean; 27 + createdAt: string; 28 + } 29 + 30 + export interface EmailAlert { 31 + id: string; 32 + didRecord: MonitoredDid; 33 + operation: PlcOperation; 34 + sentAt: string; 35 + alertType: 'recent_change' | 'unauthorized_change'; 36 + } 37 + 38 + export interface MonitoringStats { 39 + totalRegistered: number; 40 + activeMonitored: number; 41 + lastCheckRun?: string; 42 + alertsSentLast24h: number; 43 + }
+132
src/utils/email-service.ts
··· 1 + // Email notification service for DID monitoring alerts 2 + import { MonitoredDid, PlcOperation } from '../types/monitoring'; 3 + import { PlcMonitoringService } from './plc-monitoring'; 4 + 5 + export class EmailNotificationService { 6 + /** 7 + * Send an email alert about a DID change 8 + * In a real implementation, this would integrate with an email service like SendGrid, AWS SES, etc. 9 + * For now, this will show a browser notification and log the alert 10 + */ 11 + static async sendChangeAlert(didRecord: MonitoredDid, operation: PlcOperation): Promise<void> { 12 + const subject = `DID Change Alert: ${didRecord.handle || didRecord.did}`; 13 + const changes = PlcMonitoringService.formatOperationForAlert(operation); 14 + const timeAgo = this.formatTimeAgo(operation.createdAt); 15 + 16 + const message = ` 17 + ๐Ÿšจ DID Document Change Detected 18 + 19 + DID: ${didRecord.did} 20 + Handle: ${didRecord.handle || 'Unknown'} 21 + Time: ${timeAgo} 22 + 23 + Changes detected: 24 + ${changes} 25 + 26 + Operation CID: ${operation.cid} 27 + Nullified: ${operation.nullified ? 'Yes' : 'No'} 28 + 29 + This change occurred within the 72-hour window for PLC operations. 30 + If this change was not authorized by you, please take immediate action. 31 + 32 + --- 33 + DID Monitor Service 34 + `.trim(); 35 + 36 + // In a real implementation, send actual email here 37 + console.log(`Email Alert for ${didRecord.email}:`); 38 + console.log(`Subject: ${subject}`); 39 + console.log(`Message:\n${message}`); 40 + 41 + // Show browser notification if permissions are granted 42 + this.showBrowserNotification(subject, didRecord); 43 + 44 + // Store alert details for the dashboard 45 + this.logAlert(didRecord, operation, message); 46 + } 47 + 48 + /** 49 + * Show a browser notification 50 + */ 51 + private static async showBrowserNotification(title: string, didRecord: MonitoredDid): Promise<void> { 52 + if ('Notification' in window) { 53 + let permission = Notification.permission; 54 + 55 + if (permission === 'default') { 56 + permission = await Notification.requestPermission(); 57 + } 58 + 59 + if (permission === 'granted') { 60 + new Notification(title, { 61 + body: `DID change detected for ${didRecord.handle || didRecord.did}`, 62 + icon: '/favicon.png', 63 + tag: `did-monitor-${didRecord.id}` 64 + }); 65 + } 66 + } 67 + } 68 + 69 + /** 70 + * Log alert details for dashboard display 71 + */ 72 + private static logAlert(didRecord: MonitoredDid, operation: PlcOperation, message: string): void { 73 + const alertLog = JSON.parse(localStorage.getItem('did_monitor_alert_log') || '[]'); 74 + 75 + alertLog.push({ 76 + id: crypto.randomUUID(), 77 + timestamp: new Date().toISOString(), 78 + didRecord, 79 + operation, 80 + message, 81 + type: 'change_alert' 82 + }); 83 + 84 + // Keep only last 100 alerts 85 + if (alertLog.length > 100) { 86 + alertLog.splice(0, alertLog.length - 100); 87 + } 88 + 89 + localStorage.setItem('did_monitor_alert_log', JSON.stringify(alertLog)); 90 + } 91 + 92 + /** 93 + * Format timestamp as human-readable "time ago" 94 + */ 95 + private static formatTimeAgo(timestamp: string): string { 96 + const now = new Date(); 97 + const then = new Date(timestamp); 98 + const diffMs = now.getTime() - then.getTime(); 99 + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); 100 + const diffMinutes = Math.floor(diffMs / (1000 * 60)); 101 + 102 + if (diffHours < 1) { 103 + return diffMinutes === 1 ? '1 minute ago' : `${diffMinutes} minutes ago`; 104 + } else if (diffHours < 24) { 105 + return diffHours === 1 ? '1 hour ago' : `${diffHours} hours ago`; 106 + } else { 107 + const diffDays = Math.floor(diffHours / 24); 108 + return diffDays === 1 ? '1 day ago' : `${diffDays} days ago`; 109 + } 110 + } 111 + 112 + /** 113 + * Get all alert logs for dashboard display 114 + */ 115 + static getAlertLogs(): any[] { 116 + return JSON.parse(localStorage.getItem('did_monitor_alert_log') || '[]'); 117 + } 118 + 119 + /** 120 + * Send a test email (for development) 121 + */ 122 + static async sendTestAlert(email: string): Promise<void> { 123 + console.log(`Test email sent to: ${email}`); 124 + 125 + if ('Notification' in window && Notification.permission === 'granted') { 126 + new Notification('DID Monitor Test', { 127 + body: 'Test notification from DID Monitor service', 128 + icon: '/favicon.png' 129 + }); 130 + } 131 + } 132 + }
+112
src/utils/monitoring-db.ts
··· 1 + // Simple local storage-based database for the monitoring service 2 + // In a production environment, this would be replaced with a proper database 3 + 4 + import { MonitoredDid, PlcOperation, EmailAlert } from '../types/monitoring'; 5 + 6 + const STORAGE_KEYS = { 7 + MONITORED_DIDS: 'did_monitor_registered_dids', 8 + EMAIL_ALERTS: 'did_monitor_alerts', 9 + STATS: 'did_monitor_stats' 10 + }; 11 + 12 + export class MonitoringDatabase { 13 + // DID Management 14 + static async registerDid(did: string, handle: string | undefined, email: string): Promise<MonitoredDid> { 15 + const dids = this.getMonitoredDids(); 16 + 17 + // Check if already registered 18 + const existing = dids.find(d => d.did === did); 19 + if (existing) { 20 + throw new Error('DID is already registered for monitoring'); 21 + } 22 + 23 + const newRecord: MonitoredDid = { 24 + id: crypto.randomUUID(), 25 + did, 26 + handle, 27 + email, 28 + registeredAt: new Date().toISOString(), 29 + isActive: true 30 + }; 31 + 32 + dids.push(newRecord); 33 + localStorage.setItem(STORAGE_KEYS.MONITORED_DIDS, JSON.stringify(dids)); 34 + 35 + return newRecord; 36 + } 37 + 38 + static getMonitoredDids(): MonitoredDid[] { 39 + const stored = localStorage.getItem(STORAGE_KEYS.MONITORED_DIDS); 40 + return stored ? JSON.parse(stored) : []; 41 + } 42 + 43 + static updateDidRecord(id: string, updates: Partial<MonitoredDid>): MonitoredDid | null { 44 + const dids = this.getMonitoredDids(); 45 + const index = dids.findIndex(d => d.id === id); 46 + 47 + if (index === -1) return null; 48 + 49 + dids[index] = { ...dids[index], ...updates }; 50 + localStorage.setItem(STORAGE_KEYS.MONITORED_DIDS, JSON.stringify(dids)); 51 + 52 + return dids[index]; 53 + } 54 + 55 + static removeDidRecord(id: string): boolean { 56 + const dids = this.getMonitoredDids(); 57 + const filtered = dids.filter(d => d.id !== id); 58 + 59 + if (filtered.length === dids.length) return false; 60 + 61 + localStorage.setItem(STORAGE_KEYS.MONITORED_DIDS, JSON.stringify(filtered)); 62 + return true; 63 + } 64 + 65 + // Alert Management 66 + static saveEmailAlert(didRecord: MonitoredDid, operation: PlcOperation, alertType: 'recent_change' | 'unauthorized_change'): EmailAlert { 67 + const alerts = this.getEmailAlerts(); 68 + 69 + const alert: EmailAlert = { 70 + id: crypto.randomUUID(), 71 + didRecord, 72 + operation, 73 + sentAt: new Date().toISOString(), 74 + alertType 75 + }; 76 + 77 + alerts.push(alert); 78 + localStorage.setItem(STORAGE_KEYS.EMAIL_ALERTS, JSON.stringify(alerts)); 79 + 80 + return alert; 81 + } 82 + 83 + static getEmailAlerts(): EmailAlert[] { 84 + const stored = localStorage.getItem(STORAGE_KEYS.EMAIL_ALERTS); 85 + return stored ? JSON.parse(stored) : []; 86 + } 87 + 88 + // Statistics 89 + static getStats() { 90 + const dids = this.getMonitoredDids(); 91 + const alerts = this.getEmailAlerts(); 92 + const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000); 93 + 94 + return { 95 + totalRegistered: dids.length, 96 + activeMonitored: dids.filter(d => d.isActive).length, 97 + lastCheckRun: dids.reduce((latest, did) => { 98 + if (!did.lastChecked) return latest; 99 + if (!latest || did.lastChecked > latest) return did.lastChecked; 100 + return latest; 101 + }, undefined as string | undefined), 102 + alertsSentLast24h: alerts.filter(a => new Date(a.sentAt) > last24h).length 103 + }; 104 + } 105 + 106 + // Clear all data (for development/testing) 107 + static clearAll(): void { 108 + localStorage.removeItem(STORAGE_KEYS.MONITORED_DIDS); 109 + localStorage.removeItem(STORAGE_KEYS.EMAIL_ALERTS); 110 + localStorage.removeItem(STORAGE_KEYS.STATS); 111 + } 112 + }
+187
src/utils/monitoring-scheduler.ts
··· 1 + // Monitoring scheduler and worker service 2 + import { MonitoringDatabase } from './monitoring-db'; 3 + import { PlcMonitoringService } from './plc-monitoring'; 4 + import { EmailNotificationService } from './email-service'; 5 + import { MonitoredDid } from '../types/monitoring'; 6 + 7 + export class MonitoringScheduler { 8 + private static checkInterval: number | null = null; 9 + private static isRunning = false; 10 + 11 + /** 12 + * Start the monitoring service with daily checks 13 + */ 14 + static start(): void { 15 + if (this.isRunning) { 16 + console.log('Monitoring service is already running'); 17 + return; 18 + } 19 + 20 + this.isRunning = true; 21 + console.log('Starting DID monitoring service...'); 22 + 23 + // Run initial check 24 + this.runMonitoringCheck(); 25 + 26 + // Schedule checks every 24 hours (86400000 ms) 27 + // For development/demo, you might want to use a shorter interval like 5 minutes (300000 ms) 28 + const intervalMs = 24 * 60 * 60 * 1000; // 24 hours 29 + // const intervalMs = 5 * 60 * 1000; // 5 minutes for testing 30 + 31 + this.checkInterval = window.setInterval(() => { 32 + this.runMonitoringCheck(); 33 + }, intervalMs); 34 + 35 + console.log(`Monitoring scheduled to run every ${intervalMs / 1000 / 60} minutes`); 36 + } 37 + 38 + /** 39 + * Stop the monitoring service 40 + */ 41 + static stop(): void { 42 + if (this.checkInterval !== null) { 43 + clearInterval(this.checkInterval); 44 + this.checkInterval = null; 45 + } 46 + this.isRunning = false; 47 + console.log('DID monitoring service stopped'); 48 + } 49 + 50 + /** 51 + * Check if the monitoring service is currently running 52 + */ 53 + static isActive(): boolean { 54 + return this.isRunning; 55 + } 56 + 57 + /** 58 + * Run a single monitoring check for all registered DIDs 59 + */ 60 + static async runMonitoringCheck(): Promise<void> { 61 + console.log('Running DID monitoring check...'); 62 + 63 + const monitoredDids = MonitoringDatabase.getMonitoredDids(); 64 + const activeDids = monitoredDids.filter(did => did.isActive); 65 + 66 + if (activeDids.length === 0) { 67 + console.log('No active DIDs to monitor'); 68 + return; 69 + } 70 + 71 + console.log(`Checking ${activeDids.length} registered DIDs for changes...`); 72 + 73 + const results = { 74 + checked: 0, 75 + errors: 0, 76 + alertsSent: 0 77 + }; 78 + 79 + // Check each DID for changes 80 + for (const didRecord of activeDids) { 81 + try { 82 + await this.checkSingleDid(didRecord); 83 + results.checked++; 84 + } catch (error) { 85 + console.error(`Error checking DID ${didRecord.did}:`, error); 86 + results.errors++; 87 + } 88 + } 89 + 90 + console.log(`Monitoring check complete:`, results); 91 + } 92 + 93 + /** 94 + * Check a single DID for recent changes 95 + */ 96 + private static async checkSingleDid(didRecord: MonitoredDid): Promise<void> { 97 + // Only check did:plc identifiers 98 + if (!didRecord.did.startsWith('did:plc:')) { 99 + console.log(`Skipping non-PLC DID: ${didRecord.did}`); 100 + return; 101 + } 102 + 103 + try { 104 + // Get the latest operation 105 + const latestOperation = await PlcMonitoringService.getLatestOperation(didRecord.did); 106 + 107 + if (!latestOperation) { 108 + console.log(`No operations found for DID: ${didRecord.did}`); 109 + this.updateDidCheckTime(didRecord); 110 + return; 111 + } 112 + 113 + // Check if this is a new operation since our last check 114 + const isNewOperation = !didRecord.lastOperationCid || 115 + didRecord.lastOperationCid !== latestOperation.cid; 116 + 117 + if (isNewOperation) { 118 + console.log(`New operation detected for DID: ${didRecord.did}`); 119 + 120 + // Check if the operation is within the 72-hour window 121 + const { hasChanges, timeSinceChange } = await PlcMonitoringService.hasRecentChanges(didRecord.did, 72); 122 + 123 + if (hasChanges) { 124 + console.log(`Recent change detected for ${didRecord.did} (${timeSinceChange?.toFixed(1)} hours ago)`); 125 + 126 + // Send alert 127 + await EmailNotificationService.sendChangeAlert(didRecord, latestOperation); 128 + 129 + // Update database with alert info 130 + MonitoringDatabase.saveEmailAlert(didRecord, latestOperation, 'recent_change'); 131 + } 132 + 133 + // Update the DID record with the latest operation CID 134 + MonitoringDatabase.updateDidRecord(didRecord.id, { 135 + lastOperationCid: latestOperation.cid, 136 + lastChecked: new Date().toISOString() 137 + }); 138 + } else { 139 + // No new operations, just update check time 140 + this.updateDidCheckTime(didRecord); 141 + } 142 + 143 + } catch (error) { 144 + console.error(`Failed to check DID ${didRecord.did}:`, error); 145 + 146 + // Still update the check time to avoid repeatedly failing on the same DID 147 + this.updateDidCheckTime(didRecord); 148 + throw error; 149 + } 150 + } 151 + 152 + /** 153 + * Update the last checked time for a DID record 154 + */ 155 + private static updateDidCheckTime(didRecord: MonitoredDid): void { 156 + MonitoringDatabase.updateDidRecord(didRecord.id, { 157 + lastChecked: new Date().toISOString() 158 + }); 159 + } 160 + 161 + /** 162 + * Run a manual check for a specific DID (for testing) 163 + */ 164 + static async checkDidManually(did: string): Promise<void> { 165 + const didRecord = MonitoringDatabase.getMonitoredDids().find(d => d.did === did); 166 + 167 + if (!didRecord) { 168 + throw new Error(`DID ${did} is not registered for monitoring`); 169 + } 170 + 171 + console.log(`Running manual check for DID: ${did}`); 172 + await this.checkSingleDid(didRecord); 173 + } 174 + 175 + /** 176 + * Get monitoring service status 177 + */ 178 + static getStatus() { 179 + const stats = MonitoringDatabase.getStats(); 180 + 181 + return { 182 + isRunning: this.isRunning, 183 + ...stats, 184 + nextCheckIn: this.checkInterval ? '24 hours' : 'Not scheduled' 185 + }; 186 + } 187 + }
+119
src/utils/plc-monitoring.ts
··· 1 + // PLC Directory monitoring service 2 + import { PlcOperation } from '../types/monitoring'; 3 + 4 + export class PlcMonitoringService { 5 + private static readonly PLC_DIRECTORY_BASE = 'https://plc.directory'; 6 + 7 + /** 8 + * Fetch the audit log for a specific DID from PLC directory 9 + */ 10 + static async fetchPlcAuditLog(did: string): Promise<PlcOperation[]> { 11 + if (!did.startsWith('did:plc:')) { 12 + throw new Error('Only did:plc identifiers are supported for monitoring'); 13 + } 14 + 15 + const url = `${this.PLC_DIRECTORY_BASE}/${did}/log/audit`; 16 + 17 + try { 18 + const response = await fetch(url); 19 + 20 + if (!response.ok) { 21 + throw new Error(`Failed to fetch PLC audit log: ${response.status} ${response.statusText}`); 22 + } 23 + 24 + const operations: PlcOperation[] = await response.json(); 25 + return operations; 26 + } catch (error) { 27 + console.error(`Error fetching PLC audit log for ${did}:`, error); 28 + throw error; 29 + } 30 + } 31 + 32 + /** 33 + * Get the most recent operation from the audit log 34 + */ 35 + static async getLatestOperation(did: string): Promise<PlcOperation | null> { 36 + const operations = await this.fetchPlcAuditLog(did); 37 + 38 + if (operations.length === 0) return null; 39 + 40 + // Sort by createdAt descending to get the most recent 41 + const sorted = operations.sort((a, b) => 42 + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 43 + ); 44 + 45 + return sorted[0]; 46 + } 47 + 48 + /** 49 + * Check if there have been any changes within the specified time window 50 + */ 51 + static async hasRecentChanges(did: string, hoursWindow: number = 72): Promise<{ 52 + hasChanges: boolean; 53 + latestOperation?: PlcOperation; 54 + timeSinceChange?: number; // hours 55 + }> { 56 + const latestOperation = await this.getLatestOperation(did); 57 + 58 + if (!latestOperation) { 59 + return { hasChanges: false }; 60 + } 61 + 62 + const operationTime = new Date(latestOperation.createdAt).getTime(); 63 + const now = Date.now(); 64 + const hoursAgo = (now - operationTime) / (1000 * 60 * 60); 65 + 66 + const hasChanges = hoursAgo <= hoursWindow; 67 + 68 + return { 69 + hasChanges, 70 + latestOperation, 71 + timeSinceChange: hoursAgo 72 + }; 73 + } 74 + 75 + /** 76 + * Check if an operation represents a potentially unauthorized change 77 + * This is a simple heuristic - in practice you'd want more sophisticated detection 78 + */ 79 + static isUnauthorizedChange(operation: PlcOperation): boolean { 80 + // For now, we'll consider any change within 72 hours as potentially unauthorized 81 + // In practice, you might want to check for: 82 + // - Changes to rotation keys 83 + // - Changes to verification methods 84 + // - PDS endpoint changes 85 + // - Handle changes 86 + 87 + const hoursAgo = (Date.now() - new Date(operation.createdAt).getTime()) / (1000 * 60 * 60); 88 + return hoursAgo <= 72; 89 + } 90 + 91 + /** 92 + * Format operation details for email alerts 93 + */ 94 + static formatOperationForAlert(operation: PlcOperation): string { 95 + const changes: string[] = []; 96 + 97 + if (operation.operation.services) { 98 + const pdsService = operation.operation.services.atproto_pds; 99 + if (pdsService) { 100 + changes.push(`PDS endpoint: ${pdsService.endpoint}`); 101 + } 102 + } 103 + 104 + if (operation.operation.alsoKnownAs?.length) { 105 + changes.push(`Handle: ${operation.operation.alsoKnownAs[0]?.replace('at://', '')}`); 106 + } 107 + 108 + if (operation.operation.rotationKeys?.length) { 109 + changes.push(`Rotation keys: ${operation.operation.rotationKeys.length} keys`); 110 + } 111 + 112 + if (operation.operation.verificationMethods) { 113 + const methods = Object.keys(operation.operation.verificationMethods); 114 + changes.push(`Verification methods: ${methods.join(', ')}`); 115 + } 116 + 117 + return changes.length > 0 ? changes.join('\n') : 'Unknown changes detected'; 118 + } 119 + }
+49
src/views/monitoring.tsx
··· 1 + // Main monitoring view that combines registration and dashboard 2 + import { createSignal, Show } from "solid-js"; 3 + import { RegistrationForm } from "../components/registration-form"; 4 + import { MonitoringDashboard } from "../components/monitoring-dashboard"; 5 + 6 + export const MonitoringView = () => { 7 + const [activeTab, setActiveTab] = createSignal<"register" | "dashboard">("register"); 8 + 9 + return ( 10 + <div class="w-full max-w-none"> 11 + <div class="max-w-7xl mx-auto py-8 px-4"> 12 + {/* Tab Navigation */} 13 + <div class="mb-8"> 14 + <nav class="flex space-x-8" aria-label="Tabs"> 15 + <button 16 + onClick={() => setActiveTab("register")} 17 + class={`py-2 px-1 border-b-2 font-medium text-sm ${ 18 + activeTab() === "register" 19 + ? "border-blue-500 text-blue-600 dark:text-blue-400" 20 + : "border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300 dark:text-neutral-400 dark:hover:text-neutral-300" 21 + }`} 22 + > 23 + Register DID 24 + </button> 25 + <button 26 + onClick={() => setActiveTab("dashboard")} 27 + class={`py-2 px-1 border-b-2 font-medium text-sm ${ 28 + activeTab() === "dashboard" 29 + ? "border-blue-500 text-blue-600 dark:text-blue-400" 30 + : "border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300 dark:text-neutral-400 dark:hover:text-neutral-300" 31 + }`} 32 + > 33 + Dashboard 34 + </button> 35 + </nav> 36 + </div> 37 + 38 + {/* Tab Content */} 39 + <Show when={activeTab() === "register"}> 40 + <RegistrationForm /> 41 + </Show> 42 + 43 + <Show when={activeTab() === "dashboard"}> 44 + <MonitoringDashboard /> 45 + </Show> 46 + </div> 47 + </div> 48 + ); 49 + };