fixed decryption

This commit is contained in:
n 2025-04-09 22:38:07 +01:00
parent 3698288fc3
commit 07ad835a77
45 changed files with 8672 additions and 2673 deletions

@ -0,0 +1 @@
Don't use "any" in typescript, find (or create) the propert types

113
client/1120_client.html Normal file

@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Implement strict Content Security Policy -->
<title>HTTP Messages - CLIENT</title>
<!-- Load our CSS file -->
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<!-- Navigation bar container - content will be injected by navbar.ts -->
<div id="navbarContainer" class="top-nav">
<!-- Navbar content will be injected here -->
</div>
<!-- Main Content (HTTP Request Converter) -->
<div class="content">
<div class="info-box">
<p>This tool converts HTTP requests into a single Nostr event of kind 21120. The HTTP request is encrypted using NIP-44 in the content field with appropriate tags following the specification.</p>
</div>
<h2>Client Information:</h2>
<div class="client-info" style="margin-bottom: 15px;">
<div style="display: flex; align-items: center; margin-bottom: 10px;">
<div style="font-weight: bold; margin-right: 10px;">Your Pubkey:</div>
<div id="clientPubkey" style="font-family: monospace; word-break: break-all;">Not logged in</div>
<button id="toggleClientKeyFormatBtn" class="server-format-button" title="Toggle format" style="margin-left: 10px;">HEX</button>
</div>
<div class="format-indicator">
<small id="clientKeyFormatIndicator">Currently showing: NPUB format</small>
</div>
</div>
<h2>Server Information:</h2>
<div style="margin-bottom: 15px;">
<div style="margin-bottom: 10px;">
<label for="relay">Relay to search for servers:</label><br>
<div class="server-input-container">
<input type="text" id="relay" value="wss://relay.degmods.com" class="server-input" style="border-radius: 4px 0 0 4px;">
<button id="searchRelayBtn" class="server-search-button">Search Relay</button>
<button id="refreshRelayBtn" class="server-refresh-button" title="Clear cache and fetch fresh data">🔄</button>
</div>
</div>
<div style="margin-bottom: 10px;">
<label for="serverSelection">Choose a server:</label><br>
<div class="server-input-container">
<input type="text" id="serverPubkey" placeholder="Selected server pubkey (d-tag) or enter manually" class="server-input">
<button id="toggleKeyFormatBtn" class="server-format-button" title="Toggle format">HEX</button>
<button id="selectServerBtn" class="server-select-button">Select Server</button>
</div>
<div class="format-indicator">
<small id="keyFormatIndicator">Currently showing: NPUB format</small>
</div>
<div id="serverSelectionContainer" class="server-selection-container" style="display: none;">
<div class="selection-header">
<input type="text" id="serverSearchInput" placeholder="Search by operator name/pubkey" class="server-search-input">
<button id="closeSelectionBtn" class="close-selection-btn">&times;</button>
</div>
<div id="serverList" class="server-list">
<!-- Server list will be populated here -->
<div class="server-list-loading">Loading servers...</div>
</div>
</div>
</div>
<div>
<label for="relay">Response Relay (optional):</label><br>
<input type="text" id="relay" value="wss://relay.degmods.com" style="width: 100%; padding: 8px;">
</div>
</div>
<h2>Enter HTTP Request:</h2>
<textarea id="httpRequest" placeholder="GET /index.html HTTP/1.1
Host: example.com
User-Agent: Browser/1.0
"></textarea>
<button id="convertButton">Convert to Event</button>
<div id="output" hidden>
<h2>Converted Event:</h2>
<div class="event-output-container">
<pre id="eventOutput"></pre>
<button id="copyEventButton" class="copy-button" title="Copy to clipboard">
<span>Copy</span>
</button>
</div>
<div class="publish-container">
<h2>Publish to Relay:</h2>
<div class="publish-input-container">
<input type="text" id="publishRelay" value="wss://relay.degmods.com" placeholder="wss://relay.example.com" class="publish-input">
<button id="publishButton" class="publish-button">Publish Event</button>
</div>
<div id="publishResult" class="publish-result" style="display: none;">
<!-- Publish results will be shown here -->
</div>
</div>
<div class="qr-container">
<h2>QR Code:</h2>
<div id="qrCode"></div>
<p><small>Scan this QR code to share the Nostr event</small></p>
</div>
</div>
</div>
<!-- Include the webpack bundled JavaScript file with forced loading -->
<script src="./main.bundle.js" onload="console.log('Main bundle loaded successfully')"></script>
</body>
</html>

@ -5,23 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTTP Messages - SERVER</title>
<link rel="stylesheet" href="./styles.css">
<script defer src="./bundle.js"></script>
<script defer src="./main.bundle.js"></script>
<!-- Additional chunks will be loaded automatically -->
</head>
<body>
<!-- Top Navigation Bar -->
<div class="top-nav">
<div class="nav-left">
<a href="./index.html" class="nav-link">CLIENT</a>
<a href="./receive.html" class="nav-link active">SERVER</a>
<a href="./billboard.html" class="nav-link">BILLBOARD</a>
</div>
<div class="nav-right">
<a href="./help.html" class="nav-link nav-icon" title="Documentation"></a>
<a href="./profile.html" class="nav-link nav-icon" title="Profile">👤</a>
<button id="themeToggleBtn" class="theme-toggle-btn" title="Toggle Dark Mode">
<span id="themeIcon">🌙</span>
</button>
</div>
<!-- Navigation bar container - content will be injected by navbar.ts -->
<div id="navbarContainer" class="top-nav">
<!-- Navbar content will be injected here -->
</div>
<!-- Main Content -->
@ -29,6 +19,27 @@
<div class="info-box">
<p>This tool allows you to receive and view HTTP events (kind 21120) from Nostr relays, QR codes, or raw text input. Choose your preferred method using the tabs below.</p>
</div>
<!-- Server Info Section - Now placed above the tabs -->
<h2>Server Information</h2>
<div class="server-section">
<div class="server-info-container">
<div class="server-npub-container">
<label>Server Key:</label>
<input type="text" id="serverNpub" readonly class="server-npub-input">
<button id="toggleFormatBtn" class="toggle-format-btn" title="Toggle format">
<span id="formatBtnText">HEX</span>
</button>
<button id="copyServerNpubBtn" class="copy-btn" title="Copy Key">
<span id="copyBtnText">Copy</span>
</button>
</div>
<div class="format-indicator">
<small id="formatIndicator">Currently showing: NPUB format</small>
</div>
</div>
<div id="relayStatus" class="relay-status">Not connected</div>
</div>
<!-- Tab Navigation - Updated data-tab attributes to match section IDs -->
<div class="tabs">
@ -42,22 +53,6 @@
<div id="relay-connection-section" class="active">
<h2>Relay Connection</h2>
<div class="relay-connection">
<!-- Server npub display -->
<div class="server-info-container">
<div class="server-npub-container">
<label>Server Key:</label>
<input type="text" id="serverNpub" readonly class="server-npub-input">
<button id="toggleFormatBtn" class="toggle-format-btn" title="Toggle format">
<span id="formatBtnText">HEX</span>
</button>
<button id="copyServerNpubBtn" class="copy-btn" title="Copy Key">
<span id="copyBtnText">Copy</span>
</button>
</div>
<div class="format-indicator">
<small id="formatIndicator">Currently showing: NPUB format</small>
</div>
</div>
<div class="relay-input-container">
<label for="relayUrl">Relay URL:</label>
@ -66,11 +61,10 @@
</div>
<div class="filter-options">
<label class="filter-checkbox">
<input type="checkbox" id="showAllEvents" checked>
<input type="checkbox" id="showAllEvents">
Show all kind 21120 events
</label>
</div>
<div id="relayStatus" class="relay-status">Not connected</div>
</div>
</div>
@ -120,11 +114,35 @@
</div>
<!-- Selected event details will be shown here -->
</div>
<!-- HTTP Response Modal -->
<div id="httpResponseModal" class="http-response-modal" style="display: none;">
<div class="http-response-container">
<div class="http-response-header">
<h3>HTTP Response</h3>
<button class="close-modal-btn">&times;</button>
</div>
<div class="http-response-tabs">
<button class="tab-btn active" data-tab="formatted-response">Formatted</button>
<button class="tab-btn" data-tab="raw-response">Raw</button>
</div>
<div class="http-response-content">
<div class="tab-content active" id="formatted-response">
<div class="http-formatted-container">
<!-- Formatted HTTP response will be shown here -->
</div>
</div>
<div class="tab-content" id="raw-response">
<pre><!-- Raw HTTP response will be shown here --></pre>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Script will be provided by bundle.js -->
<!-- Scripts are now loaded as chunks by webpack -->
</body>
</html>
</html>

@ -5,23 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTTP Messages - BILLBOARD</title>
<link rel="stylesheet" href="./styles.css">
<script defer src="./bundle.js"></script>
<script defer src="./main.bundle.js"></script>
<!-- Additional chunks will be loaded automatically -->
</head>
<body>
<!-- Top Navigation Bar -->
<div class="top-nav">
<div class="nav-left">
<a href="./index.html" class="nav-link">CLIENT</a>
<a href="./receive.html" class="nav-link">SERVER</a>
<a href="./billboard.html" class="nav-link active">BILLBOARD</a>
</div>
<div class="nav-right">
<a href="./help.html" class="nav-link nav-icon" title="Documentation"></a>
<a href="./profile.html" class="nav-link nav-icon" title="Profile">👤</a>
<button id="themeToggleBtn" class="theme-toggle-btn" title="Toggle Dark Mode">
<span id="themeIcon">🌙</span>
</button>
</div>
<!-- Navigation bar container - content will be injected by navbar.ts -->
<div id="navbarContainer" class="top-nav">
<!-- Navbar content will be injected here -->
</div>
<!-- Main Content -->
@ -39,11 +29,17 @@
<input type="text" id="billboardRelayUrl" value="wss://relay.degmods.com" placeholder="wss://relay.example.com">
<button id="billboardConnectBtn" class="relay-connect-button">Connect</button>
</div>
<div class="filter-options">
<label class="filter-checkbox">
<input type="checkbox" id="showAllServerEvents">
Show all kind 31120 events
</label>
</div>
<div id="billboardRelayStatus" class="relay-status">Not connected</div>
</div>
<div class="billboard-actions">
<button id="createBillboardBtn" class="primary-button">Create New Billboard</button>
<button id="createBillboardBtn" class="primary-button">+ Add New Billboard</button>
</div>
<div class="billboard-container">
@ -62,27 +58,58 @@
<span class="close-modal">&times;</span>
</div>
<div class="modal-body">
<p class="form-hint">
A billboard is a server registration event that allows clients to discover your HTTP-over-Nostr server.
</p>
<form id="billboardForm">
<input type="hidden" id="editEventId" value="">
<div class="form-group">
<label for="billboardDescription">Description:</label>
<input type="text" id="billboardDescription" placeholder="HTTP-over-Nostr server">
</div>
<div class="form-group">
<label for="billboardRelays">Relays (one per line):</label>
<textarea id="billboardRelays" rows="3" placeholder="wss://relay.example.com"></textarea>
</div>
<div class="form-group">
<label for="billboardExpiry">Expiry (hours):</label>
<input type="number" id="billboardExpiry" value="24" min="1" max="720">
<div class="modal-columns">
<div class="modal-column">
<div class="form-group">
<label for="billboardDescription">Description:</label>
<input type="text" id="billboardDescription" placeholder="HTTP-over-Nostr server">
</div>
<div class="form-group">
<label for="billboardRelays">Relays:</label>
<textarea id="billboardRelays" rows="2" placeholder="wss://relay.example.com"></textarea>
</div>
</div>
<div class="modal-column">
<div class="form-group">
<label>Server Key:</label>
<div class="pubkey-options">
<div class="radio-option">
<input type="radio" id="generatePubkey" name="pubkeyOption" checked>
<label for="generatePubkey">Generate new key</label>
</div>
<div class="radio-option">
<input type="radio" id="providePubkey" name="pubkeyOption">
<label for="providePubkey">Use existing key</label>
</div>
</div>
<div id="pubkeyInputContainer" class="hidden">
<input type="text" id="serverPubkey" placeholder="npub or hex pubkey">
</div>
</div>
<div class="form-group">
<label for="billboardExpiry">Expiry:</label>
<div class="expiry-input-container">
<input type="number" id="billboardExpiry" value="24" min="1" max="720">
<span class="expiry-unit">hours</span>
</div>
</div>
</div>
</div>
<div class="form-actions">
<button type="button" id="cancelBillboard" class="secondary-button">Cancel</button>
<button type="submit" id="saveBillboard" class="primary-button">Save</button>
<button type="submit" id="saveBillboard" class="primary-button">Sign & Publish</button>
</div>
</form>
</div>
@ -91,6 +118,6 @@
</div>
</div>
<!-- Script will be provided by bundle.js -->
<!-- Scripts are now loaded as chunks by webpack -->
</body>
</html>

@ -71,7 +71,7 @@ module.exports = [
],
// General ESLint rules
'no-console': 'warn',
'no-console': 'off', // Allowing console statements
'eqeqeq': ['error', 'always'],
'no-var': 'error',
'prefer-const': 'error',

@ -1,182 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Implement strict Content Security Policy -->
<title>HTTP Messages - Documentation</title>
<!-- Load our CSS file -->
<link rel="stylesheet" href="./styles.css">
<!-- Include the Nostr extensions - these will be accessed via window.nostr -->
<script defer src="./bundle.js"></script>
</head>
<body>
<!-- Top Navigation Bar -->
<div class="top-nav">
<div class="nav-left">
<a href="./index.html" class="nav-link">CLIENT</a>
<a href="./receive.html" class="nav-link">SERVER</a>
<a href="./billboard.html" class="nav-link">BILLBOARD</a>
</div>
<div class="nav-right">
<a href="./help.html" class="nav-link nav-icon active" title="Documentation"></a>
<a href="./profile.html" class="nav-link nav-icon" title="Profile">👤</a>
<button id="themeToggleBtn" class="theme-toggle-btn" title="Toggle Dark Mode">
<span id="themeIcon">🌙</span>
</button>
</div>
</div>
<h1>HTTP Messages</h1>
<!-- Documentation Content -->
<div class="content">
<div class="info-box">
<p>A specification for sending/receiving HTTP messages (request/response) via a remote server. Header / Body etc are encrypted, either in the <code>content</code> (small messages) or via a blossom server (larger requests).</p>
</div>
<div class="diagram-container">
<img src="./http.png" alt="HTTP Messages Architecture Diagram">
<p class="diagram-caption">HTTP Messages Architecture Overview</p>
</div>
<section class="section">
<h2>Overview</h2>
<p>HTTP Messages enables a local client to make and receive HTTP requests (PUT, POST, GET, PATCH etc) from a remote computer. This approach provides enhanced privacy, anonymity, and resistance to censorship.</p>
<p>The system requires:</p>
<ul>
<li>A trusted machine to process the messages (can be a home PC or Raspberry Pi)</li>
<li>A relay (can be untrusted)</li>
<li>A blossom server (can be untrusted)</li>
</ul>
</section>
<section class="section">
<h2>How It Works</h2>
<p>HTTP Messages operates by converting standard HTTP requests into encrypted Nostr events (kind 21120) that can be safely transmitted through untrusted relays. The process ensures privacy and security while enabling communication with the regular internet.</p>
<div class="features-grid">
<div class="feature-card">
<h3>Privacy Protection</h3>
<p>All HTTP headers and body content are encrypted, protecting sensitive information from intermediaries.</p>
</div>
<div class="feature-card">
<h3>Censorship Resistance</h3>
<p>By routing through Nostr relays, the system can bypass traditional censorship mechanisms that block direct connections.</p>
</div>
<div class="feature-card">
<h3>Server Anonymity</h3>
<p>No domain needed or port forwarding required, keeping your server completely private.</p>
</div>
<div class="feature-card">
<h3>Flexible Architecture</h3>
<p>Works with small payloads (embedded in content) or large requests (via blossom server).</p>
</div>
</div>
</section>
<section class="section">
<h2>Architecture</h2>
<p>The HTTP Messages architecture consists of several components working together:</p>
<h3>Components</h3>
<ul>
<li><strong>Nostr Client</strong>: Converts HTTP requests into kind 21120 events and handles responses</li>
<li><strong>Nostr Relay</strong>: Transmits events between clients and servers (untrusted)</li>
<li><strong>Blossom Storage</strong>: Stores larger payloads that don't fit in event content (untrusted)</li>
<li><strong>Trusted Device</strong>: Processes encrypted requests, makes actual HTTP calls, and returns responses</li>
</ul>
<h3>Sequence Diagram</h3>
<ol>
<li>Client converts HTTP request into kind 21120 event</li>
<li>Client encrypts & pushes payload to Blossom (if large)</li>
<li>Client publishes event to Nostr relay</li>
<li>Trusted device fetches the event</li>
<li>Trusted device decrypts event</li>
<li>Trusted device fetches payload from Blossom (if large)</li>
<li>Trusted device makes the actual HTTP request</li>
<li>Trusted device gets HTTP response</li>
<li>Trusted device encrypts & pushes response payload to Blossom (if large)</li>
<li>Trusted device creates kind 21121 response event</li>
<li>Trusted device publishes response event to relay</li>
<li>Client fetches response event</li>
<li>Client decrypts event</li>
<li>Client fetches payload from Blossom (if large)</li>
<li>Client converts kind 21121 into HTTP response</li>
<li>Client deletes request blob (if exists)</li>
<li>Client deletes request event</li>
</ol>
<p>The remote server should periodically scan for expired RESPONSE events (and associated blossom blobs) and delete them.</p>
</section>
<section class="section">
<h2>Event Structure</h2>
<p>HTTP Messages uses several Nostr event kinds with specific structures:</p>
<h3>Server Advertisement Event (Kind 31120)</h3>
<p>Used to facilitate discovery of HTTP-over-Nostr servers:</p>
<pre>{
"kind": 31120,
"pubkey": "&lt;pubkey of server operator&gt;",
"content": "HTTP-over-Nostr server", // Optional markdown description of the http server(s)
"tags": [
["d", "&lt;hex pubkey of server&gt;"], // Server pubkey that will be listening for requests
["relay", "wss://relay.one"], // Relay where server is listening (can have multiple)
["relay", "wss://relay.two"],
["expiry", "&lt;unix timestamp&gt;"], // How long this server will be online
]
}</pre>
<p>Clients looking to use HTTP over Nostr can query for these kind 31120 events to discover available servers and may communicate with the server operator to get permission to use them.</p>
<h3>HTTP Request (Kind 21120)</h3>
<pre>{
"kind": 21120,
"pubkey": "&lt;pubkey&gt;",
"content": "$encryptedPayload",
"tags": [
["p", "&lt;pubkey of remote server&gt;"], // P tag entry, this is a REQUEST
["key","nip44Encrypt($decryptkey)"],
["r", "https://relay.one"],
["expiration",&lt;unix timestamp&gt;]
]
}</pre>
<p>NIP-44 is NOT used for the content encryption as the payload may be large, affecting bunker signing stability.</p>
<h3>HTTP Response (Kind 21121)</h3>
<pre>{
"kind": 21121,
"pubkey": "&lt;pubkey&gt;",
"content": "encrypt({'url':'blossom.one','hash':'xx'},$decryptkey)",
"tags": [
["key","nip44Encrypt($decryptkey)"],
["E", "&lt;request event id&gt;"], // E tag entry, this is a RESPONSE
["expiration",&lt;unix timestamp&gt;]
]
}</pre>
<p>A different kind is used for responses to help with filtering. There is no "p" tag as the "E" tag already identifies the request.</p>
</section>
<section class="section">
<h2>Considerations & Use Cases</h2>
<p>This approach only makes sense in cases where privacy and anonymity are important, or if censorship is a concern.</p>
<h3>Drawbacks</h3>
<ul>
<li><strong>Complexity</strong>: Many more moving parts than a direct request</li>
<li><strong>Speed</strong>: Each request/response requires multiple steps (encryption, signing, transmission, decryption, etc.)</li>
</ul>
<h3>Why Use It?</h3>
<ul>
<li>Enables a plethora of open source apps to be made available from inside private networks (localhost) but over Nostr</li>
<li>Maximum server privacy (no domain needed, or port forwarding)</li>
<li>Make regular API calls over Nostr</li>
<li>Enhanced privacy and anonymity</li>
<li>Resistance to censorship</li>
</ul>
</section>
</div>
</body>
</html>

@ -4,105 +4,169 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Implement strict Content Security Policy -->
<title>HTTP Messages - CLIENT</title>
<title>HTTP Messages - Documentation</title>
<!-- Load our CSS file -->
<link rel="stylesheet" href="./styles.css">
<!-- Include the Nostr extensions - these will be accessed via window.nostr -->
<script defer src="./bundle.js"></script>
<!-- Include the webpack bundled JavaScript files -->
<script defer src="./main.bundle.js"></script>
<!-- Additional chunks will be loaded automatically -->
</head>
<body>
<!-- Top Navigation Bar -->
<div class="top-nav">
<div class="nav-left">
<a href="./index.html" class="nav-link active">CLIENT</a>
<a href="./receive.html" class="nav-link">SERVER</a>
<a href="./billboard.html" class="nav-link">BILLBOARD</a>
</div>
<div class="nav-right">
<a href="./help.html" class="nav-link nav-icon" title="Documentation"></a>
<a href="./profile.html" class="nav-link nav-icon" title="Profile">👤</a>
<button id="themeToggleBtn" class="theme-toggle-btn" title="Toggle Dark Mode">
<span id="themeIcon">🌙</span>
</button>
</div>
<!-- Navigation bar container - content will be injected by navbar.ts -->
<div id="navbarContainer" class="top-nav">
<!-- Navbar content will be injected here -->
</div>
<h1>HTTP Messages</h1>
<!-- Main Content (HTTP Request Converter) -->
<!-- Documentation Content -->
<div class="content">
<div class="info-box">
<p>This tool converts HTTP requests into a single Nostr event of kind 21120. The HTTP request is encrypted using NIP-44 in the content field with appropriate tags following the specification.</p>
<p>A specification for sending/receiving HTTP messages (request/response) via a remote server. Header / Body etc are encrypted, either in the <code>content</code> (small messages) or via a blossom server (larger requests).</p>
</div>
<h2>Server Information:</h2>
<div style="margin-bottom: 15px;">
<div style="margin-bottom: 10px;">
<label for="relay">Relay to search for servers:</label><br>
<div class="server-input-container">
<input type="text" id="relay" value="wss://relay.degmods.com" class="server-input" style="border-radius: 4px 0 0 4px;">
<button id="searchRelayBtn" class="server-search-button">Search Relay</button>
<button id="refreshRelayBtn" class="server-refresh-button" title="Clear cache and fetch fresh data">🔄</button>
</div>
</div>
<div style="margin-bottom: 10px;">
<label for="serverSelection">Choose a server:</label><br>
<div class="server-input-container">
<input type="text" id="serverPubkey" placeholder="Selected server pubkey (d-tag) or enter manually" class="server-input">
<button id="selectServerBtn" class="server-select-button">Select Server</button>
</div>
<div id="serverSelectionContainer" class="server-selection-container" style="display: none;">
<div class="selection-header">
<input type="text" id="serverSearchInput" placeholder="Search by operator name/pubkey" class="server-search-input">
<button id="closeSelectionBtn" class="close-selection-btn">&times;</button>
</div>
<div id="serverList" class="server-list">
<!-- Server list will be populated here -->
<div class="server-list-loading">Loading servers...</div>
</div>
</div>
</div>
<div>
<label for="relay">Response Relay (optional):</label><br>
<input type="text" id="relay" value="wss://relay.degmods.com" style="width: 100%; padding: 8px;">
</div>
<div class="diagram-container">
<img src="./http.png" alt="HTTP Messages Architecture Diagram">
<p class="diagram-caption">HTTP Messages Architecture Overview</p>
</div>
<h2>Enter HTTP Request:</h2>
<textarea id="httpRequest" placeholder="GET /index.html HTTP/1.1
Host: example.com
User-Agent: Browser/1.0
"></textarea>
<button id="convertButton">Convert to Event</button>
<div id="output" hidden>
<h2>Converted Event:</h2>
<div class="event-output-container">
<pre id="eventOutput"></pre>
<button id="copyEventButton" class="copy-button" title="Copy to clipboard">
<span>Copy</span>
</button>
</div>
<section class="section">
<h2>Overview</h2>
<p>HTTP Messages enables a local client to make and receive HTTP requests (PUT, POST, GET, PATCH etc) from a remote computer. This approach provides enhanced privacy, anonymity, and resistance to censorship.</p>
<div class="publish-container">
<h2>Publish to Relay:</h2>
<div class="publish-input-container">
<input type="text" id="publishRelay" value="wss://relay.degmods.com" placeholder="wss://relay.example.com" class="publish-input">
<button id="publishButton" class="publish-button">Publish Event</button>
<p>The system requires:</p>
<ul>
<li>A trusted machine to process the messages (can be a home PC or Raspberry Pi)</li>
<li>A relay (can be untrusted)</li>
<li>A blossom server (can be untrusted)</li>
</ul>
</section>
<section class="section">
<h2>How It Works</h2>
<p>HTTP Messages operates by converting standard HTTP requests into encrypted Nostr events (kind 21120) that can be safely transmitted through untrusted relays. The process ensures privacy and security while enabling communication with the regular internet.</p>
<div class="features-grid">
<div class="feature-card">
<h3>Privacy Protection</h3>
<p>All HTTP headers and body content are encrypted, protecting sensitive information from intermediaries.</p>
</div>
<div id="publishResult" class="publish-result" style="display: none;">
<!-- Publish results will be shown here -->
<div class="feature-card">
<h3>Censorship Resistance</h3>
<p>By routing through Nostr relays, the system can bypass traditional censorship mechanisms that block direct connections.</p>
</div>
<div class="feature-card">
<h3>Server Anonymity</h3>
<p>No domain needed or port forwarding required, keeping your server completely private.</p>
</div>
<div class="feature-card">
<h3>Flexible Architecture</h3>
<p>Works with small payloads (embedded in content) or large requests (via blossom server).</p>
</div>
</div>
</section>
<section class="section">
<h2>Architecture</h2>
<p>The HTTP Messages architecture consists of several components working together:</p>
<div class="qr-container">
<h2>QR Code:</h2>
<div id="qrCode"></div>
<p><small>Scan this QR code to share the Nostr event</small></p>
</div>
</div>
<h3>Components</h3>
<ul>
<li><strong>Nostr Client</strong>: Converts HTTP requests into kind 21120 events and handles responses</li>
<li><strong>Nostr Relay</strong>: Transmits events between clients and servers (untrusted)</li>
<li><strong>Blossom Storage</strong>: Stores larger payloads that don't fit in event content (untrusted)</li>
<li><strong>Trusted Device</strong>: Processes encrypted requests, makes actual HTTP calls, and returns responses</li>
</ul>
<h3>Sequence Diagram</h3>
<ol>
<li>Client converts HTTP request into kind 21120 event</li>
<li>Client encrypts & pushes payload to Blossom (if large)</li>
<li>Client publishes event to Nostr relay</li>
<li>Trusted device fetches the event</li>
<li>Trusted device decrypts event</li>
<li>Trusted device fetches payload from Blossom (if large)</li>
<li>Trusted device makes the actual HTTP request</li>
<li>Trusted device gets HTTP response</li>
<li>Trusted device encrypts & pushes response payload to Blossom (if large)</li>
<li>Trusted device creates kind 21121 response event</li>
<li>Trusted device publishes response event to relay</li>
<li>Client fetches response event</li>
<li>Client decrypts event</li>
<li>Client fetches payload from Blossom (if large)</li>
<li>Client converts kind 21121 into HTTP response</li>
<li>Client deletes request blob (if exists)</li>
<li>Client deletes request event</li>
</ol>
<p>The remote server should periodically scan for expired RESPONSE events (and associated blossom blobs) and delete them.</p>
</section>
<section class="section">
<h2>Event Structure</h2>
<p>HTTP Messages uses several Nostr event kinds with specific structures:</p>
<h3>Server Advertisement Event (Kind 31120)</h3>
<p>Used to facilitate discovery of HTTP-over-Nostr servers:</p>
<pre>{
"kind": 31120,
"pubkey": "&lt;pubkey of server operator&gt;",
"content": "HTTP-over-Nostr server", // Optional markdown description of the http server(s)
"tags": [
["d", "&lt;hex pubkey of server&gt;"], // Server pubkey that will be listening for requests
["relay", "wss://relay.one"], // Relay where server is listening (can have multiple)
["relay", "wss://relay.two"],
["expiry", "&lt;unix timestamp&gt;"], // How long this server will be online
]
}</pre>
<p>Clients looking to use HTTP over Nostr can query for these kind 31120 events to discover available servers and may communicate with the server operator to get permission to use them.</p>
<h3>HTTP Request (Kind 21120)</h3>
<pre>{
"kind": 21120,
"pubkey": "&lt;pubkey&gt;",
"content": "$encryptedPayload",
"tags": [
["p", "&lt;pubkey of remote server&gt;"], // P tag entry, this is a REQUEST
["key","nip44Encrypt($decryptkey)"],
["r", "https://relay.one"],
["expiration",&lt;unix timestamp&gt;]
]
}</pre>
<p>NIP-44 is NOT used for the content encryption as the payload may be large, affecting bunker signing stability.</p>
<h3>HTTP Response (Kind 21121)</h3>
<pre>{
"kind": 21121,
"pubkey": "&lt;pubkey&gt;",
"content": "encrypt({'url':'blossom.one','hash':'xx'},$decryptkey)",
"tags": [
["key","nip44Encrypt($decryptkey)"],
["E", "&lt;request event id&gt;"], // E tag entry, this is a RESPONSE
["expiration",&lt;unix timestamp&gt;]
]
}</pre>
<p>A different kind is used for responses to help with filtering. There is no "p" tag as the "E" tag already identifies the request.</p>
</section>
<section class="section">
<h2>Considerations & Use Cases</h2>
<p>This approach only makes sense in cases where privacy and anonymity are important, or if censorship is a concern.</p>
<h3>Drawbacks</h3>
<ul>
<li><strong>Complexity</strong>: Many more moving parts than a direct request</li>
<li><strong>Speed</strong>: Each request/response requires multiple steps (encryption, signing, transmission, decryption, etc.)</li>
</ul>
<h3>Why Use It?</h3>
<ul>
<li>Enables a plethora of open source apps to be made available from inside private networks (localhost) but over Nostr</li>
<li>Maximum server privacy (no domain needed, or port forwarding)</li>
<li>Make regular API calls over Nostr</li>
<li>Enhanced privacy and anonymity</li>
<li>Resistance to censorship</li>
</ul>
</section>
</div>
</body>
</html>

@ -5,9 +5,8 @@
"main": "dist/index.html",
"scripts": {
"build": "webpack --mode production",
"copy-assets": "mkdir -p dist && cp index.html help.html dist/ && cp http.png dist/ 2>/dev/null || true",
"dev": "webpack serve --open",
"start": "npm run build && npm run copy-assets && npx serve dist",
"dev": "webpack serve",
"start": "npm run build && npx serve dist",
"clean": "rm -rf dist",
"lint": "eslint . --ext .ts",
"lint:fix": "eslint . --ext .ts --fix",

@ -5,23 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTTP Messages - Profile</title>
<link rel="stylesheet" href="./styles.css">
<script defer src="./bundle.js"></script>
<script defer src="./main.bundle.js"></script>
<!-- Additional chunks will be loaded automatically -->
</head>
<body>
<!-- Top Navigation Bar -->
<div class="top-nav">
<div class="nav-left">
<a href="./index.html" class="nav-link">CLIENT</a>
<a href="./receive.html" class="nav-link">SERVER</a>
<a href="./billboard.html" class="nav-link">BILLBOARD</a>
</div>
<div class="nav-right">
<a href="./help.html" class="nav-link nav-icon" title="Documentation"></a>
<a href="./profile.html" class="nav-link nav-icon active" title="Profile">👤</a>
<button id="themeToggleBtn" class="theme-toggle-btn" title="Toggle Dark Mode">
<span id="themeIcon">🌙</span>
</button>
</div>
<!-- Navigation bar container - content will be injected by navbar.ts -->
<div id="navbarContainer" class="top-nav">
<!-- Navbar content will be injected here -->
</div>
<!-- Main Content -->
@ -97,6 +87,6 @@
</div>
</div>
<!-- Profile script will be loaded from bundle.js -->
<!-- Scripts are now loaded as chunks by webpack -->
</body>
</html>

@ -5,6 +5,7 @@ import * as nostrTools from 'nostr-tools';
import { defaultServerConfig } from './config';
import { NostrService } from './services/NostrService';
import { toggleTheme } from './theme-utils';
// Module-level variables
let nostrService: NostrService;
@ -45,6 +46,12 @@ function setupUIElements(): void {
connectButton.addEventListener('click', handleConnectRelay);
}
// Set up filter checkbox event listener
const showAllServerEventsCheckbox = document.getElementById('showAllServerEvents') as HTMLInputElement;
if (showAllServerEventsCheckbox) {
showAllServerEventsCheckbox.addEventListener('change', handleFilterChange);
}
// Create billboard button
const createBillboardBtn = document.getElementById('createBillboardBtn');
if (createBillboardBtn) {
@ -69,6 +76,21 @@ function setupUIElements(): void {
billboardForm.addEventListener('submit', handleSaveBillboard);
}
// Server pubkey radio buttons
const generatePubkeyRadio = document.getElementById('generatePubkey') as HTMLInputElement;
const providePubkeyRadio = document.getElementById('providePubkey') as HTMLInputElement;
const pubkeyInputContainer = document.getElementById('pubkeyInputContainer');
if (generatePubkeyRadio && providePubkeyRadio && pubkeyInputContainer) {
generatePubkeyRadio.addEventListener('change', () => {
pubkeyInputContainer.classList.add('hidden');
});
providePubkeyRadio.addEventListener('change', () => {
pubkeyInputContainer.classList.remove('hidden');
});
}
// Set dark mode based on localStorage
const savedTheme = window.localStorage.getItem('theme');
if (savedTheme === 'dark') {
@ -85,11 +107,7 @@ function setupUIElements(): void {
}
}
// Set up theme toggle
const themeToggleBtn = document.getElementById('themeToggleBtn');
if (themeToggleBtn) {
themeToggleBtn.addEventListener('click', toggleTheme);
}
// Theme toggle is handled by navbar.ts, don't add duplicate event listeners here
}
/**
@ -123,11 +141,15 @@ async function handleConnectRelay(): Promise<void> {
const success = await nostrService.connectToRelay(relayUrl);
if (success) {
try {
// Subscribe to kind 31120 events
await subscribeToKind31120Events();
// Get the checkbox state
const showAllServerEventsCheckbox = document.getElementById('showAllServerEvents') as HTMLInputElement;
const showAllEvents = showAllServerEventsCheckbox ? showAllServerEventsCheckbox.checked : false;
// Subscribe to kind 31120 events with filter state
await subscribeToKind31120Events(showAllEvents);
} catch (error) {
updateRelayStatus(
`Subscription error: ${error instanceof Error ? error.message : String(error)}`,
`Subscription error: ${error instanceof Error ? error.message : String(error)}`,
'error'
);
}
@ -135,15 +157,87 @@ async function handleConnectRelay(): Promise<void> {
}
/**
* Subscribe to kind 31120 events
* Handle filter checkbox changes
*/
async function subscribeToKind31120Events(): Promise<void> {
async function handleFilterChange(): Promise<void> {
// Only resubscribe if we're already connected to a relay
if (nostrService.isConnected()) {
const showAllServerEventsCheckbox = document.getElementById('showAllServerEvents') as HTMLInputElement;
if (!showAllServerEventsCheckbox) {
return;
}
console.log(`Checkbox changed to: ${showAllServerEventsCheckbox.checked ? 'show all events' : 'only show my events'}`);
try {
// Update status to indicate resubscription is in progress
updateRelayStatus('Updating subscription...', 'connecting');
// Resubscribe with the new filter setting
await subscribeToKind31120Events(showAllServerEventsCheckbox.checked);
console.log(`Subscription updated with new filter settings`);
updateRelayStatus('Subscription updated ✓', 'connected');
} catch (error) {
console.error("Failed to update subscription:", error);
updateRelayStatus(
`Subscription update failed: ${error instanceof Error ? error.message : String(error)}`,
'error'
);
}
} else {
console.log("Checkbox changed but no active relay connection");
}
}
/**
* Subscribe to kind 31120 events
* @param showAllEvents Whether to show all events or only those created by the current user
*/
async function subscribeToKind31120Events(showAllEvents: boolean = false): Promise<void> {
// Clear the billboard content area
const billboardContent = document.getElementById('billboardContent');
if (billboardContent) {
billboardContent.innerHTML = '<div class="empty-state">Loading server registrations...</div>';
}
// Create filter for kind 31120 events
const filter = {
const filter: any = {
kinds: [31120], // HTTP Messages server advertisements
};
updateRelayStatus('Subscribing to server advertisements...', 'connecting');
// If not showing all events, filter by the current user's pubkey
if (!showAllEvents) {
// Get the logged-in user's pubkey
const userPubkey = nostrService.getLoggedInPubkey();
if (userPubkey) {
// Convert npub to hex if needed
let userPubkeyHex = userPubkey;
if (userPubkey.startsWith('npub')) {
try {
const decoded = nostrTools.nip19.decode(userPubkey);
if (decoded.type === 'npub') {
userPubkeyHex = decoded.data as string;
}
} catch (error) {
console.error("Error decoding npub:", error);
// Use the original value if conversion fails
}
}
console.log(`Filtering for events by author: ${userPubkeyHex.substring(0, 8)}...`);
filter.authors = [userPubkeyHex];
} else {
console.warn("No user pubkey available, showing all events despite filter setting");
}
}
// Update status message based on filter
const statusMessage = showAllEvents
? 'Subscribing to all server advertisements...'
: 'Subscribing to your server advertisements...';
updateRelayStatus(statusMessage, 'connecting');
try {
// Subscribe to events
@ -155,7 +249,12 @@ async function subscribeToKind31120Events(): Promise<void> {
}
});
updateRelayStatus('Connected and listening for server events ✓', 'connected');
// Update status based on filter
const successMessage = showAllEvents
? 'Connected and listening for all server events ✓'
: 'Connected and listening for your server events ✓';
updateRelayStatus(successMessage, 'connected');
} catch (error) {
throw new Error(`Failed to subscribe: ${error instanceof Error ? error.message : String(error)}`);
}
@ -163,8 +262,6 @@ async function subscribeToKind31120Events(): Promise<void> {
/**
* Process a server advertisement event (kind 31120)
*/
/**
* Open the modal for creating a new billboard
*/
function handleCreateBillboard(): void {
@ -263,45 +360,81 @@ function handleEditBillboard(event: nostrTools.Event): void {
async function handleSaveBillboard(e: Event): Promise<void> {
e.preventDefault();
// Get form values
const editEventId = (document.getElementById('editEventId') as HTMLInputElement)?.value;
const description = (document.getElementById('billboardDescription') as HTMLInputElement)?.value || 'HTTP-over-Nostr server';
const relaysText = (document.getElementById('billboardRelays') as HTMLTextAreaElement)?.value || '';
const expiryHours = parseInt((document.getElementById('billboardExpiry') as HTMLInputElement)?.value || '24');
// Parse relay URLs
const relays = relaysText
.split('\n')
.map(line => line.trim())
.filter(line => line && line.startsWith('wss://'));
// Validate input
if (relays.length === 0) {
alert('Please provide at least one valid relay URL (starting with wss://)');
return;
}
// Get the current relay URL for publishing
const relayUrlInput = document.getElementById('billboardRelayUrl') as HTMLInputElement;
const relayUrl = relayUrlInput?.value.trim() || relays[0];
try {
// Check if user is logged in
const loggedInPubkey = nostrService.getLoggedInPubkey();
if (!loggedInPubkey) {
alert('You need to be logged in to publish a billboard. Please visit the Profile page to log in.');
return;
}
// Get form values
const editEventId = (document.getElementById('editEventId') as HTMLInputElement)?.value;
const description = (document.getElementById('billboardDescription') as HTMLInputElement)?.value || 'HTTP-over-Nostr server';
const relaysText = (document.getElementById('billboardRelays') as HTMLTextAreaElement)?.value || '';
const expiryHours = parseInt((document.getElementById('billboardExpiry') as HTMLInputElement)?.value || '24');
// Parse relay URLs
const relays = relaysText
.split('\n')
.map(line => line.trim())
.filter(line => line && line.startsWith('wss://'));
// Validate input
if (relays.length === 0) {
alert('Please provide at least one valid relay URL (starting with wss://)');
return;
}
// Get the current relay URL for publishing
const relayUrlInput = document.getElementById('billboardRelayUrl') as HTMLInputElement;
const relayUrl = relayUrlInput?.value.trim() || relays[0];
// Check which pubkey option is selected
const generatePubkeyRadio = document.getElementById('generatePubkey') as HTMLInputElement;
const serverPubkeyInput = document.getElementById('serverPubkey') as HTMLInputElement;
// Determine if we're using a custom pubkey or generating one
let customServerPubkey: string | undefined;
if (!generatePubkeyRadio?.checked && serverPubkeyInput?.value) {
customServerPubkey = serverPubkeyInput.value.trim();
}
// Create or update the billboard event
const event = await nostrService.createOrUpdate31120Event(
const result = await nostrService.createOrUpdate31120Event(
relayUrl,
description,
relays,
expiryHours,
editEventId || undefined
editEventId || undefined,
customServerPubkey
);
if (event) {
if (result) {
// Add the event to the UI
processServerEvent(event as nostrTools.Event);
processServerEvent(result.event as nostrTools.Event);
// Close the modal
closeModal();
// If we have a server nsec, save it and display it to the user
if (result.serverNsec && result.event) {
// Save the server nsec using the exact key expected by the receiver.ts
localStorage.setItem('serverNsec', result.serverNsec);
// Show the nsec in an alert with instructions
const message = `
Server created successfully!
IMPORTANT: Your server's private key has been generated and saved.
The server page will automatically use this key.
For your records, here is the key:
${result.serverNsec}
This private key is needed to run the server for this billboard.
`;
alert(message);
}
}
// Close the modal
closeModal();
} catch (error) {
console.error('Error saving billboard:', error);
alert(`Failed to save billboard: ${error instanceof Error ? error.message : String(error)}`);
@ -413,13 +546,36 @@ function processServerEvent(event: nostrTools.Event): void {
}
}
// Check if the event is owned by the current user
const loggedInPubkey = nostrService.getLoggedInPubkey();
let userPubkeyHex = loggedInPubkey || '';
let isUserOwnedEvent = false;
// Convert pubkeys to hex if needed for comparison
if (loggedInPubkey && loggedInPubkey.startsWith('npub')) {
try {
const hexPubkey = nostrTools.nip19.decode(loggedInPubkey).data as string;
if (hexPubkey) {
userPubkeyHex = hexPubkey;
}
} catch {
// Keep original if conversion fails
}
}
// Check if the user is the creator of this event
isUserOwnedEvent = userPubkeyHex === event.pubkey;
// Create a card for the event
const eventCard = document.createElement('div');
eventCard.className = 'billboard-card';
eventCard.className = isUserOwnedEvent ? 'billboard-card user-owned' : 'billboard-card';
eventCard.id = `event-${event.id}`;
eventCard.innerHTML = `
<div class="billboard-card-header">
<h3 class="billboard-title">Server Registration</h3>
<h3 class="billboard-title">
Server Registration
${isUserOwnedEvent ? '<span class="user-owned-indicator">✓ Your event</span>' : ''}
</h3>
<div class="billboard-timestamp">
<span class="event-created">Created: ${new Date(event.created_at * 1000).toLocaleString()}</span>
<span class="event-updated" data-time="${new Date(event.created_at * 1000).toISOString()}">Updated: ${new Date(event.created_at * 1000).toLocaleString()}</span>
@ -447,7 +603,7 @@ function processServerEvent(event: nostrTools.Event): void {
</div>
<div class="billboard-card-footer">
<button class="view-raw-btn" data-id="${event.id}">View Raw JSON</button>
<button class="billboard-edit-btn" data-id="${event.id}">Edit</button>
<button class="billboard-edit-btn" data-id="${event.id}" ${!isUserOwnedEvent ? 'disabled' : ''}>Edit</button>
</div>
<div class="raw-json-content hidden" id="raw-${event.id}">
<pre>${JSON.stringify(event, null, 2)}</pre>
@ -482,33 +638,14 @@ function processServerEvent(event: nostrTools.Event): void {
const target = e.target as HTMLElement;
const eventId = target.getAttribute('data-id');
if (eventId) {
// Check if the user is the creator of this event
// Check if user is logged in
const loggedInPubkey = nostrService.getLoggedInPubkey();
if (!loggedInPubkey) {
alert('You need to be logged in to edit a billboard. Please visit the Profile page to log in.');
return;
}
// Convert pubkeys to hex if needed for comparison
let userPubkeyHex = loggedInPubkey;
if (loggedInPubkey.startsWith('npub')) {
try {
const hexPubkey = nostrTools.nip19.decode(loggedInPubkey).data as string;
if (hexPubkey) {
userPubkeyHex = hexPubkey;
}
} catch {
// Keep original if conversion fails
}
}
// Only allow editing if user is the creator
if (userPubkeyHex !== event.pubkey) {
alert('You can only edit billboards that you created');
return;
}
// Open edit modal
// Open edit modal (button is already disabled for non-owned events)
handleEditBillboard(event);
}
});
@ -527,6 +664,12 @@ async function autoConnectToDefaultRelay(): Promise<void> {
relayUrlInput.value = defaultServerConfig.defaultRelay;
}
// Initialize checkbox state (default unchecked - only show user's events)
const showAllServerEventsCheckbox = document.getElementById('showAllServerEvents') as HTMLInputElement;
if (showAllServerEventsCheckbox) {
showAllServerEventsCheckbox.checked = false;
}
// Trigger connect button click
const connectButton = document.getElementById('billboardConnectBtn');
if (connectButton) {
@ -536,36 +679,20 @@ async function autoConnectToDefaultRelay(): Promise<void> {
}
/**
* Toggle between light and dark theme
* Extract the server pubkey from a 31120 event
* @param event The 31120 event to extract pubkey from
* @returns Server pubkey or null if not found
*/
function toggleTheme(): void {
const body = document.body;
const themeIcon = document.getElementById('themeIcon');
const themeText = document.getElementById('themeText');
function getServerPubkeyFromEvent(event: nostrTools.Event): string | null {
if (event.kind !== 31120) return null;
const isDarkMode = body.getAttribute('data-theme') === 'dark';
if (isDarkMode) {
// Switch to light theme
body.removeAttribute('data-theme');
window.localStorage.setItem('theme', 'light');
if (themeIcon) {
themeIcon.textContent = '🌙';
}
if (themeText) {
themeText.textContent = 'Dark Mode';
}
} else {
// Switch to dark theme
body.setAttribute('data-theme', 'dark');
window.localStorage.setItem('theme', 'dark');
if (themeIcon) {
themeIcon.textContent = '☀️';
}
if (themeText) {
themeText.textContent = 'Light Mode';
}
// Find the d tag which contains the server pubkey
const dTag = event.tags.find(tag => tag[0] === 'd');
if (dTag && dTag.length > 1) {
return dTag[1];
}
}
return null;
}
// We now use the imported toggleTheme function from theme-utils.ts

File diff suppressed because it is too large Load Diff

@ -6,6 +6,7 @@ import qrcode from 'qrcode-generator';
import { defaultServerConfig, appSettings } from './config';
import { convertNpubToHex } from './relay';
import { showSuccess } from './utils';
import { encryptWithWebCrypto, encryptKeyWithNostrExtension } from './utils/crypto-utils';
// Define interface for Nostr event - making id and sig optional for event creation
export interface NostrEvent {
@ -25,8 +26,8 @@ interface NostrWindowExtension {
getPublicKey: () => Promise<string>;
signEvent: (event: NostrEvent) => Promise<NostrEvent>;
nip44?: {
encrypt(pubkey: string, plaintext: string): Promise<string>;
decrypt(pubkey: string, ciphertext: string): Promise<string>;
encrypt(plaintext: string, pubkey: string): Promise<string>;
decrypt(ciphertext: string, pubkey: string): Promise<string>;
};
}
@ -70,47 +71,7 @@ function initStandaloneKeypair(): { publicKey: string, secretKey: Uint8Array } {
* @param relay Optional relay for the response
* @returns Stringified Nostr event or null if conversion fails
*/
// Helper function for proper Web Crypto API encryption
async function encryptWithWebCrypto(data: string, key: string): Promise<string> {
// Convert text to bytes
const dataBytes = new TextEncoder().encode(data);
// Create a key from the provided key (using SHA-256 hash)
const keyMaterial = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(key)
);
// Import the key
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyMaterial,
{ name: 'AES-GCM' },
false,
['encrypt']
);
// Generate random IV
const iv = crypto.getRandomValues(new Uint8Array(12));
// Encrypt the data
const encryptedData = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: iv
},
cryptoKey,
dataBytes
);
// Combine IV and ciphertext
const encryptedArray = new Uint8Array(iv.length + encryptedData.byteLength);
encryptedArray.set(iv);
encryptedArray.set(new Uint8Array(encryptedData), iv.length);
// Convert to base64 string
return btoa(String.fromCharCode.apply(null, Array.from(encryptedArray)));
}
// Encryption functions now imported from crypto-utils.ts
export async function convertToEvent(
httpRequest: string,
@ -145,10 +106,7 @@ export async function convertToEvent(
}
}
// Validate that we have a hex string of the right length
if (!/^[0-9a-f]{64}$/i.test(serverPubkeyHex)) {
throw new Error("Invalid server pubkey format. Must be a 64-character hex string.");
}
// No need to validate the hex string length - just assume it's valid at this point
// Encrypt the HTTP request directly without wrapping it in JSON
// This ensures the decrypted content is the raw HTTP request
console.log("Encrypting raw HTTP request with Web Crypto API");
@ -168,10 +126,10 @@ export async function convertToEvent(
// Ensure the serverPubkey is in valid hex format for the p tag
let pTagValue = serverPubkey;
// First check if it's already a valid hex string
if (/^[0-9a-f]{64}$/i.test(serverPubkey)) {
// Already a valid hex pubkey, use as is
console.log(`Server pubkey is already a valid hex string: ${serverPubkey}`);
// First check if it's already a valid hex string (not npub)
if (!serverPubkey.startsWith('npub')) {
// Assume it's a hex pubkey
console.log(`Server pubkey is assumed to be hex format: ${serverPubkey}`);
}
// Try to convert from npub format if needed
else if (serverPubkey.startsWith('npub')) {
@ -199,41 +157,40 @@ export async function convertToEvent(
const finalContent = typeof encryptedContent === 'string' ?
encryptedContent : JSON.stringify(encryptedContent);
// Encrypt the decryption key using NIP-44 through nostr-signers
// Encrypt the decryption key using NIP-44
let encryptedKey = decryptkey;
// First ensure we're properly connected to nostr-signers
if (!window.nostr) {
console.warn("window.nostr not available - ensure a NIP-07 extension is installed and connected");
} else if (typeof window.nostr.nip44 !== 'object' || typeof window.nostr.nip44.encrypt !== 'function') {
console.warn("NIP-44 encryption not available - connect to a compatible NIP-07 extension");
try {
console.log("Encrypting decryption key using NIP-44 via crypto-utils");
// Log additional diagnostic information
console.log("Available nostr methods:", Object.keys(window.nostr).join(", "));
if (NostrLogin && typeof NostrLogin.getPublicKey === 'function') {
// Ensure server pubkey is in npub format for validation
let serverNpub = "";
if (pTagValue.startsWith('npub')) {
// Already in npub format
serverNpub = pTagValue;
console.log(`Using npub format for encryption: ${serverNpub.substring(0, 12)}...`);
} else {
// Assume it's hex if not npub
try {
// Try to explicitly connect using NostrLogin
const pubkey = await NostrLogin.getPublicKey();
console.log("Retrieved public key via NostrLogin:", pubkey);
// Sometimes this explicit call triggers the extension to connect properly
} catch (e) {
console.error("Failed to connect via NostrLogin:", e);
serverNpub = nostrTools.nip19.npubEncode(pTagValue);
console.log(`Converted hex pubkey to npub for NIP-44 encryption: ${serverNpub}`);
} catch (encodeError) {
console.error("Error encoding hex to npub:", encodeError);
throw new Error(`Failed to encode pubkey to npub format: ${pTagValue}`);
}
}
} else {
try {
console.log("Encrypting decryption key using window.nostr.nip44.encrypt");
// According to the NIP-07 spec, the first parameter is the pubkey (recipient)
// and the second parameter is the plaintext to encrypt
const encryptionPromise = window.nostr.nip44.encrypt(pTagValue, decryptkey);
// Since this is a Promise, we need to await it
encryptedKey = await encryptionPromise;
console.log("Successfully encrypted the decryption key with NIP-44");
} catch (encryptError) {
console.error("Failed to encrypt key with NIP-44:", encryptError instanceof Error ? encryptError.message : String(encryptError));
console.log("Using unencrypted key as fallback");
}
// Log the parameters being used
console.log(`Using parameters for NIP-44 encryption:
- plaintext: ${decryptkey.substring(0, 3)}...${decryptkey.substring(decryptkey.length - 3)}
- pubkey: ${serverNpub.substring(0, 12)}...`);
// Use our utility function again
encryptedKey = await encryptKeyWithNostrExtension(decryptkey, serverNpub);
console.log("Successfully encrypted the decryption key with NIP-44");
} catch (encryptError) {
console.error("Failed to encrypt key with NIP-44:", encryptError instanceof Error ? encryptError.message : String(encryptError));
console.log("Using unencrypted key as fallback");
}
const event: NostrEvent = {
@ -276,10 +233,10 @@ export async function convertToEvent(
}
}
// Verify the tag is now in hex format
if (!/^[0-9a-f]{64}$/i.test(event.tags[i][1])) {
console.error(`Invalid hex format in p tag: ${event.tags[i][1]}`);
throw new Error(`P tag must be a 64-character hex string, got: ${event.tags[i][1]}`);
// Verify the tag is now in hex format (not npub)
if (event.tags[i][1].startsWith('npub')) {
console.error(`P tag still in npub format: ${event.tags[i][1]}`);
throw new Error(`P tag must be in hex format, but is still in npub format: ${event.tags[i][1]}`);
}
}
}
@ -404,23 +361,25 @@ export async function displayConvertedEvent(): Promise<void> {
let signedEvent;
try {
// Try to sign with NostrLogin in preferred order
if (NostrLogin && NostrLogin.signEvent) {
console.log("Using NostrLogin.signEvent to sign event");
signedEvent = await NostrLogin.signEvent(nostrEvent);
} else if (NostrLogin && NostrLogin.sign) {
console.log("Using NostrLogin.sign to sign event");
signedEvent = await NostrLogin.sign(nostrEvent);
} else if (window.nostr) {
// Fall back to NIP-07 extension
// Always prioritize the user's identity via NIP-07 extension
if (window.nostr) {
// Use the NIP-07 extension - this ensures we're using the USER identity
console.log("Using NIP-07 extension to sign event");
// When using the extension directly, ensure content is a string
if (typeof nostrEvent.content !== 'string') {
nostrEvent.content = String(nostrEvent.content);
}
signedEvent = await window.nostr.signEvent(nostrEvent);
}
// Fall back to NostrLogin only if window.nostr isn't available
else if (NostrLogin && NostrLogin.signEvent) {
console.log("Using NostrLogin.signEvent to sign event");
signedEvent = await NostrLogin.signEvent(nostrEvent);
} else if (NostrLogin && NostrLogin.sign) {
console.log("Using NostrLogin.sign to sign event");
signedEvent = await NostrLogin.sign(nostrEvent);
} else if (secretKey) {
// Fall back to nostr-tools
// Last resort - fall back to nostr-tools
console.log("Using nostr-tools to sign event");
signedEvent = nostrTools.finalizeEvent(nostrEvent, secretKey);
} else {

@ -0,0 +1,318 @@
/**
* HTTP Response Viewer module
* Handles displaying HTTP responses and 21121 integration
*/
import { NostrEvent } from './relay';
import { HttpFormatter } from './services/HttpFormatter';
import { ToastNotifier } from './services/ToastNotifier';
/**
* Initialize the HTTP response viewer functionality
*/
export function initHttpResponseViewer(): void {
console.log('Initializing HTTP response viewer...');
// Add event listener for tab switching in the HTTP response modal
document.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
// Handle tab switching
if (target && target.classList.contains('tab-btn')) {
const tabContainer = target.closest('.http-response-tabs, .event-detail-tabs');
if (!tabContainer) return;
// Get all tab buttons and content in this container
const tabButtons = tabContainer.querySelectorAll('.tab-btn');
// Find the tab content container (parent or sibling depending on structure)
let tabContentContainer = tabContainer.nextElementSibling;
if (!tabContentContainer || !tabContentContainer.querySelector('.tab-content')) {
// If not a sibling, try to find a parent that contains the tab content
tabContentContainer = tabContainer.closest('.modal-content, .event-details');
}
if (!tabContentContainer) return;
const tabContents = tabContentContainer.querySelectorAll('.tab-content');
// Remove active class from all tabs and content
tabButtons.forEach(btn => btn.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
// Add active class to clicked tab
target.classList.add('active');
// Find the corresponding content
const tabId = target.getAttribute('data-tab');
if (tabId) {
const tabContent = document.getElementById(tabId) ||
tabContentContainer.querySelector(`#${tabId}`);
if (tabContent) {
tabContent.classList.add('active');
}
}
}
// Handle execute HTTP request button
if (target && (
target.classList.contains('execute-http-request-btn') ||
target.closest('.execute-http-request-btn')
)) {
// Prevent multiple clicks
const button = target.classList.contains('execute-http-request-btn') ?
target : target.closest('.execute-http-request-btn');
if (button && !button.hasAttribute('disabled')) {
executeHttpRequest(button as HTMLElement);
}
}
// Handle close modal button
if (target && (
target.classList.contains('close-modal-btn') ||
target.closest('.close-modal-btn')
)) {
const modal = target.closest('.http-response-modal');
if (modal) {
(modal as HTMLElement).style.display = 'none';
}
}
// Handle clicking outside the modal to close it
if (target && target.classList.contains('http-response-modal')) {
(target as HTMLElement).style.display = 'none';
}
});
}
/**
* Execute an HTTP request from a button click
* @param button The button element that was clicked
*/
async function executeHttpRequest(button: HTMLElement): Promise<void> {
// Store the original button text
const originalText = button.textContent || 'Execute HTTP Request';
// Update button to show it's working
button.textContent = 'Executing...';
button.setAttribute('disabled', 'true');
try {
// Find the HTTP content
const eventDetails = button.closest('.event-details');
if (!eventDetails) throw new Error('Event details not found');
// Find the HTTP content element - look in both formatted and raw tabs
const httpContentElement =
eventDetails.querySelector('#raw-http .http-content') ||
eventDetails.querySelector('.http-content');
if (!httpContentElement) throw new Error('HTTP content not found');
const httpContent = httpContentElement.textContent || '';
if (!httpContent.trim()) throw new Error('Empty HTTP content');
// Get the event ID
const headerElement = eventDetails.querySelector('.event-detail-header h3');
const eventIdMatch = headerElement?.textContent?.match(/ID: (\w+)\.\.\./);
const eventId = eventIdMatch ? eventIdMatch[1] : null;
if (!eventId) throw new Error('Could not determine event ID');
// Execute the HTTP request
const response = await executeRequest(httpContent);
// Display the response
displayHttpResponse(response);
// Ask if the user wants to create a 21121 response event
if (confirm('Do you want to create and publish a NIP-21121 response event?')) {
await createAndPublish21121Response(eventId, response);
}
} catch (error) {
// Show error
console.error('Error executing HTTP request:', error);
ToastNotifier.show(`Error: ${error instanceof Error ? error.message : String(error)}`, 'error');
const errorResponse = `HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\n\r\nError: ${error instanceof Error ? error.message : String(error)}`;
displayHttpResponse(errorResponse);
} finally {
// Restore button state
button.textContent = originalText;
button.removeAttribute('disabled');
}
}
/**
* Execute an HTTP request
* @param requestContent The HTTP request content
* @returns The HTTP response content
*/
async function executeRequest(requestContent: string): Promise<string> {
// Parse the request
const lines = requestContent.trim().split('\n');
if (lines.length === 0) {
throw new Error('Empty request');
}
// Parse the first line (e.g., "GET /api/users HTTP/1.1")
const firstLine = lines[0].trim();
const [method, path, _httpVersion] = firstLine.split(' ');
// Extract the host
let host = '';
let headers: Record<string, string> = {};
let body = '';
let inHeaders = true;
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (inHeaders && line === '') {
inHeaders = false;
continue;
}
if (inHeaders) {
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim();
headers[key.toLowerCase()] = value;
if (key.toLowerCase() === 'host') {
host = value;
}
}
} else {
body += line + '\n';
}
}
if (!host) {
throw new Error('Host header is required');
}
// Construct URL
const isSecure = host.includes(':443') || !host.includes(':');
const protocol = isSecure ? 'https://' : 'http://';
const baseUrl = protocol + host.split(':')[0];
const url = new URL(path, baseUrl).toString();
// Perform fetch
try {
const response = await fetch(url, {
method,
headers,
body: body.trim() || undefined,
});
// Build response string
let responseText = `HTTP/1.1 ${response.status} ${response.statusText}\n`;
// Add headers
response.headers.forEach((value, key) => {
responseText += `${key}: ${value}\n`;
});
// Add body
const responseBody = await response.text();
responseText += '\n' + responseBody;
return responseText;
} catch (error) {
return `HTTP/1.1 500 Internal Server Error\nContent-Type: text/plain\n\nError: ${error instanceof Error ? error.message : String(error)}`;
}
}
/**
* Display HTTP response in the modal
* @param response The HTTP response content
*/
function displayHttpResponse(response: string): void {
// Get the modal
const modal = document.getElementById('httpResponseModal');
if (!modal) {
console.error('HTTP response modal not found');
return;
}
// Update the modal content
const formattedContainer = modal.querySelector('#formatted-response .http-formatted-container');
const rawContainer = modal.querySelector('#raw-response pre');
if (formattedContainer) {
formattedContainer.innerHTML = HttpFormatter.formatHttpContent(response, false, true);
}
if (rawContainer) {
rawContainer.textContent = response;
}
// Show the modal
(modal as HTMLElement).style.display = 'block';
}
/**
* Create and publish a 21121 response event
* @param requestEventId The ID of the 21120 request event
* @param responseContent The HTTP response content
*/
async function createAndPublish21121Response(requestEventId: string, responseContent: string): Promise<void> {
try {
// Get the server's private key
const serverNsec = localStorage.getItem('serverNsec');
if (!serverNsec) {
ToastNotifier.show('Server private key (nsec) not found. Please set up a server identity first.', 'error');
return;
}
// We need to import the NostrService dynamically to avoid circular dependencies
const { NostrService } = await import('./services/NostrService');
const { Nostr21121Service } = await import('./services/Nostr21121Service');
// Create temporary services if needed
const nostrService = new NostrService();
const relayService = nostrService.getRelayService();
const cacheService = nostrService.getCacheService();
const nostr21121Service = new Nostr21121Service(relayService, cacheService);
// Get the relay URL from the UI
const relayUrlInput = document.getElementById('relayUrl') as HTMLInputElement;
const relayUrl = relayUrlInput?.value || 'wss://relay.degmods.com';
// First get the original request event
const originalEvent = await nostrService.getEventById(relayUrl, requestEventId);
if (!originalEvent) {
ToastNotifier.show(`Could not find original request event with ID: ${requestEventId}`, 'error');
return;
}
// Create and publish the 21121 event
const responseEvent = await nostr21121Service.createAndPublish21121Event(
originalEvent,
responseContent,
serverNsec,
relayUrl
);
if (responseEvent) {
ToastNotifier.show('NIP-21121 response event published successfully!', 'success');
// Add a visual indicator to the event in the UI
const eventItem = document.querySelector(`.event-item[data-id="${requestEventId}"]`);
if (eventItem && !eventItem.querySelector('.response-indicator')) {
const responseIndicator = document.createElement('div');
responseIndicator.className = 'response-indicator';
responseIndicator.innerHTML = '<span class="response-available">21121 Response Available</span>';
eventItem.appendChild(responseIndicator);
}
} else {
ToastNotifier.show('Failed to publish NIP-21121 response event', 'error');
}
} catch (error) {
console.error('Error creating 21121 response:', error);
ToastNotifier.show(`Error: ${error instanceof Error ? error.message : String(error)}`, 'error');
}
}

238
client/src/navbar-init.ts Normal file

@ -0,0 +1,238 @@
/**
* navbar-init.ts
* Standalone module for initializing the navigation bar
* This module self-executes when loaded
*/
// Use the theme utilities from the dedicated file
import * as themeUtils from './theme-utils';
/**
* Function to initialize the navbar content and functionality
*/
function initializeNavbar(): void {
console.log('[NAVBAR-INIT] Starting navbar initialization');
// Get the current page URL to highlight the correct link
const currentPageUrl = window.location.pathname;
const currentPage = currentPageUrl.split('/').pop() || 'index.html';
console.log('[NAVBAR-INIT] Current page:', currentPage);
// Find the navbar container
const navbarContainer = document.getElementById('navbarContainer');
console.log('[NAVBAR-INIT] Navbar container found:', !!navbarContainer);
if (navbarContainer) {
// Create the navbar HTML based on current page
const navbarHtml = `
<div class="nav-left">
<a href="./index.html" class="nav-link${currentPage === 'index.html' ? ' active' : ''}">HOME</a>
<a href="./1120_client.html" class="nav-link${currentPage === '1120_client.html' ? ' active' : ''}">CLIENT</a>
<a href="./1120_server.html" class="nav-link${currentPage === '1120_server.html' ? ' active' : ''}">SERVER</a>
<a href="./billboard.html" class="nav-link${currentPage === 'billboard.html' ? ' active' : ''}">BILLBOARD</a>
</div>
<div class="nav-right">
<a href="./index.html" class="nav-link nav-icon${currentPage === 'index.html' ? ' active' : ''}" title="Documentation"></a>
<a href="./profile.html" class="nav-link nav-icon${currentPage === 'profile.html' ? ' active' : ''}" title="Profile">👤</a>
<button id="nuclearResetBtn" class="nuclear-reset-btn" title="Reset All Data">💣</button>
<button id="themeToggleBtn" class="theme-toggle-btn" title="Toggle Dark Mode">
<span id="themeIcon">🌙</span>
</button>
</div>`;
// Update the navbar container
console.log('[NAVBAR-INIT] Setting navbar HTML content');
navbarContainer.innerHTML = navbarHtml;
// Setup theme toggle and nuclear reset button
setupThemeToggle();
setupNuclearReset();
} else {
console.error('[NAVBAR-INIT] Navbar container not found!');
}
}
/**
* Function to set up the theme toggle button
*/
function setupThemeToggle(): void {
console.log('[NAVBAR-INIT] Setting up theme toggle');
// Get the theme toggle button
const themeToggleBtn = document.getElementById('themeToggleBtn');
console.log('[NAVBAR-INIT] Theme toggle button found:', !!themeToggleBtn);
if (themeToggleBtn) {
// Set initial theme based on localStorage
const savedTheme = localStorage.getItem('theme');
console.log('[NAVBAR-INIT] Saved theme from localStorage:', savedTheme);
// Set the theme
updateThemeDisplay(savedTheme);
// Add click handler - skip in this module since navbar.ts already handles it
// This prevents duplicate event listeners
console.log('[NAVBAR-INIT] Skipping duplicate theme toggle event listener (handled by navbar.ts)');
}
}
/**
* Function to toggle between light and dark themes (DEPRECATED - use themeUtils.toggleTheme instead)
*/
function toggleTheme(): void {
// Call the imported function instead
themeUtils.toggleTheme();
return;
// DEPRECATED implementation below - kept for reference
const body = document.body;
const isDarkMode = body.getAttribute('data-theme') === 'dark';
if (isDarkMode) {
// Switch to light theme
body.removeAttribute('data-theme');
localStorage.setItem('theme', 'light');
} else {
// Switch to dark theme
body.setAttribute('data-theme', 'dark');
localStorage.setItem('theme', 'dark');
}
// Update the theme icon
updateThemeIcon();
}
/**
* Function to update the theme icon based on current theme (DEPRECATED - use themeUtils.updateThemeIcon instead)
*/
function updateThemeIcon(): void {
// Call the imported function instead
themeUtils.updateThemeIcon();
return;
// DEPRECATED implementation below - kept for reference
/*
const isDarkMode = document.body.getAttribute('data-theme') === 'dark';
const themeIcon = document.getElementById('themeIcon');
if (themeIcon) {
themeIcon.textContent = isDarkMode ? '☀️' : '🌙';
console.log('[NAVBAR-INIT] Updated theme icon to:', themeIcon.textContent);
}
*/
}
/**
* Function to update the theme display based on the saved theme (DEPRECATED - use themeUtils.initializeTheme instead)
*/
function updateThemeDisplay(theme: string | null): void {
// Set the theme attribute on the body
const isDarkMode = theme === 'dark';
if (isDarkMode) {
document.body.setAttribute('data-theme', 'dark');
} else {
document.body.removeAttribute('data-theme');
}
// Update the icon using the shared utility
themeUtils.updateThemeIcon();
}
// Add an immediate execution for the module
(function() {
console.log('[NAVBAR-INIT] Module self-executing function running');
// Add a document ready check to ensure the DOM is loaded
if (document.readyState === 'loading') {
console.log('[NAVBAR-INIT] Document still loading, waiting for DOMContentLoaded');
document.addEventListener('DOMContentLoaded', initializeNavbar);
} else {
// DOM is already loaded, initialize immediately
console.log('[NAVBAR-INIT] Document already loaded, initializing immediately');
initializeNavbar();
}
// Add direct DOM manipulation as a guaranteed fallback
window.addEventListener('load', function() {
console.log('[NAVBAR-INIT] Window load event triggered - checking navbar');
// Check if navbar has content already
const navbarContent = document.querySelector('.nav-left, .nav-right');
if (!navbarContent) {
console.log('[NAVBAR-INIT] No navbar content found on window load, forcing initialization');
initializeNavbar();
}
});
// Set up an additional timeout-based initialization as a fallback
// Use a longer timeout to ensure the DOM is definitely ready
})();
setTimeout(function() {
console.log('[NAVBAR-INIT] Fallback initialization after timeout');
// Check if navbar already exists before re-initializing
const navbarContent = document.querySelector('.nav-left, .nav-right');
if (!navbarContent) {
console.log('[NAVBAR-INIT] No navbar content found, performing fallback initialization');
initializeNavbar();
} else {
console.log('[NAVBAR-INIT] Navbar content already exists, skipping fallback initialization');
}
}, 1000);
/**
* Sets up the nuclear reset button
*/
function setupNuclearReset(): void {
console.log('[NAVBAR-INIT] Setting up nuclear reset button');
const nuclearResetBtn = document.getElementById('nuclearResetBtn');
console.log('[NAVBAR-INIT] Nuclear reset button found:', !!nuclearResetBtn);
if (nuclearResetBtn) {
// Add click handler
nuclearResetBtn.addEventListener('click', function() {
console.log('[NAVBAR-INIT] Nuclear reset button clicked');
// Confirm before reset
if (confirm('This will remove all data and sign you out. Are you sure?')) {
resetAllData();
}
});
}
}
/**
* Resets all data and signs out the user
*/
function resetAllData(): void {
console.log('[NAVBAR-INIT] Executing nuclear reset...');
// Clear all localStorage
console.log('[NAVBAR-INIT] Clearing localStorage...');
localStorage.clear();
// Clear sessionStorage
console.log('[NAVBAR-INIT] Clearing sessionStorage...');
sessionStorage.clear();
// Clear any cookies (optional - may not be used in this app)
document.cookie.split(';').forEach(cookie => {
const eqPos = cookie.indexOf('=');
const name = eqPos > -1 ? cookie.substring(0, eqPos).trim() : cookie.trim();
document.cookie = name + '=;expires=Thu, 01 Jan 1970 00:00:00 GMT';
});
console.log('[NAVBAR-INIT] Data reset complete. Reloading page...');
// Reload the page
window.location.reload();
}
// Export functions for potential external use
export {
initializeNavbar,
toggleTheme,
updateThemeDisplay,
resetAllData
};

233
client/src/navbar.ts Normal file

@ -0,0 +1,233 @@
/**
* navbar.ts
* Provides a reusable navigation bar component for all pages
*/
import { toggleTheme } from './theme-utils';
/**
* Navigation items configuration
*/
const NAV_ITEMS = [
{ label: 'HOME', url: './index.html', id: 'nav-home' },
{ label: 'CLIENT', url: './1120_client.html', id: 'nav-client' },
{ label: 'SERVER', url: './1120_server.html', id: 'nav-server' },
{ label: 'BILLBOARD', url: './billboard.html', id: 'nav-billboard' }
];
/**
* Right navigation items (icons)
*/
const RIGHT_NAV_ITEMS = [
{ label: '❓', url: './index.html', title: 'Documentation', id: 'nav-help' },
{ label: '👤', url: './profile.html', title: 'Profile', id: 'nav-profile' }
];
// Flag to track if navbar has been initialized
let navbarInitialized = false;
/**
* Initializes the navigation bar
* Updates HTML with appropriate links and sets up event listeners
*/
export function initializeNavbar(): void {
console.log('%c[Navbar Debug] Initializing navbar component - already initialized: ' + navbarInitialized, 'background: #ff9800; color: white; padding: 2px 5px; border-radius: 3px;');
// Only initialize once to prevent conflicts
if (navbarInitialized) {
console.log('[Navbar Debug] Navbar already initialized, skipping');
return;
}
// Set flag before initializing
navbarInitialized = true;
// We need to wait for DOM content to be loaded
if (document.readyState === 'loading') {
console.log('%c[Navbar Debug] Document still loading, waiting for DOMContentLoaded', 'color: #ff9800');
document.addEventListener('DOMContentLoaded', () => {
console.log('%c[Navbar Debug] DOMContentLoaded event fired, updating navbar now', 'color: #ff9800');
updateNavbar();
});
} else {
// DOM is already loaded, update immediately
console.log('%c[Navbar Debug] Document already loaded, updating navbar immediately', 'color: #ff9800');
updateNavbar();
}
}
/**
* Updates the existing navbar in the HTML files
*/
function updateNavbar(): void {
console.log('%c[Navbar Debug] Updating navbar', 'background: #4CAF50; color: white; padding: 2px 5px; border-radius: 3px;');
// Add nav items back to each HTML page
// Get the current page URL
const currentPageUrl = window.location.pathname;
const currentPage = currentPageUrl.split('/').pop() || 'index.html';
console.log('[Navbar Debug] Current page:', currentPage);
console.log('[Navbar Debug] Current URL:', window.location.href);
// Find the navbar container
const navbarContainer = document.getElementById('navbarContainer');
console.log('[Navbar Debug] Navbar container found:', navbarContainer);
// Check if document is fully loaded
console.log('[Navbar Debug] Document ready state:', document.readyState);
console.log('[Navbar Debug] Body exists:', !!document.body);
if (navbarContainer) {
// Update the content of the existing navbar container
console.log('[Navbar Debug] Updating navbar content');
const navbarHtml = `
<div class="nav-left">
<a href="./index.html" class="nav-link${currentPage === 'index.html' ? ' active' : ''}">HOME</a>
<a href="./1120_client.html" class="nav-link${currentPage === '1120_client.html' ? ' active' : ''}">CLIENT</a>
<a href="./1120_server.html" class="nav-link${currentPage === '1120_server.html' ? ' active' : ''}">SERVER</a>
<a href="./billboard.html" class="nav-link${currentPage === 'billboard.html' ? ' active' : ''}">BILLBOARD</a>
</div>
<div class="nav-right">
<a href="./index.html" class="nav-link nav-icon${currentPage === 'index.html' ? ' active' : ''}" title="Documentation"></a>
<a href="./profile.html" class="nav-link nav-icon${currentPage === 'profile.html' ? ' active' : ''}" title="Profile">👤</a>
<button id="nuclearResetBtn" class="nuclear-reset-btn" title="Reset All Data">💣</button>
<button id="themeToggleBtn" class="theme-toggle-btn" title="Toggle Dark Mode">
<span id="themeIcon">🌙</span>
</button>
</div>`;
try {
// Update the container's HTML
console.log('[Navbar Debug] Setting innerHTML:', navbarHtml.substring(0, 50) + '...');
navbarContainer.innerHTML = navbarHtml;
console.log('[Navbar Debug] Navbar content updated successfully');
// Check if the content was properly set
console.log('[Navbar Debug] Navbar content after update:', navbarContainer.innerHTML.substring(0, 50) + '...');
} catch (error) {
console.error('[Navbar Debug] Error updating navbar HTML:', error);
}
} else {
console.error('[Navbar Debug] Navbar container not found');
}
// Setup theme toggle and nuclear reset button
console.log('[Navbar Debug] Setting up theme toggle and nuclear reset button');
setupThemeToggle();
setupNuclearReset();
}
/**
* Sets up the theme toggle button
*/
function setupThemeToggle(): void {
console.log('[Navbar Debug] Setting up theme toggle button');
// Try various ways to find the theme toggle button
const themeToggleBtn = document.getElementById('themeToggleBtn');
console.log('[Navbar Debug] Theme toggle button found by ID:', !!themeToggleBtn);
// As a fallback, check if there's a button with the theme-toggle-btn class
if (!themeToggleBtn) {
const toggleBtns = document.getElementsByClassName('theme-toggle-btn');
console.log('[Navbar Debug] Theme toggle buttons found by class:', toggleBtns.length);
}
if (themeToggleBtn) {
console.log('[Navbar Debug] Theme toggle button properties:', {
tagName: themeToggleBtn.tagName,
className: themeToggleBtn.className,
innerHTML: themeToggleBtn.innerHTML
});
// Set initial theme based on local storage
const savedTheme = window.localStorage.getItem('theme');
console.log('[Navbar Debug] Saved theme from localStorage:', savedTheme);
updateThemeDisplay(savedTheme);
// Add click handler
console.log('[Navbar Debug] Adding click event listener to theme toggle button');
themeToggleBtn.addEventListener('click', () => {
console.log('[Navbar Debug] Theme toggle button clicked');
// Just call the imported toggleTheme function which handles icon updates
toggleTheme();
});
// Verify the event listener was added
console.log('[Navbar Debug] Verifying click handler was added - click event attached');
} else {
console.warn('[Navbar Debug] Theme toggle button not found');
}
}
/**
* Sets up the nuclear reset button
*/
function setupNuclearReset(): void {
console.log('[Navbar Debug] Setting up nuclear reset button');
const nuclearResetBtn = document.getElementById('nuclearResetBtn');
console.log('[Navbar Debug] Nuclear reset button found:', !!nuclearResetBtn);
if (nuclearResetBtn) {
console.log('[Navbar Debug] Adding click event listener to nuclear reset button');
nuclearResetBtn.addEventListener('click', () => {
console.log('[Navbar Debug] Nuclear reset button clicked');
// Confirm before reset
if (confirm('This will remove all data and sign you out. Are you sure?')) {
resetAllData();
}
});
} else {
console.warn('[Navbar Debug] Nuclear reset button not found');
}
}
/**
* Resets all data and signs out the user
*/
export function resetAllData(): void {
console.log('[Navbar Debug] Executing nuclear reset...');
// Clear all localStorage
console.log('[Navbar Debug] Clearing localStorage...');
localStorage.clear();
// Clear sessionStorage
console.log('[Navbar Debug] Clearing sessionStorage...');
sessionStorage.clear();
// Clear any cookies (optional - may not be used in this app)
document.cookie.split(';').forEach(cookie => {
const eqPos = cookie.indexOf('=');
const name = eqPos > -1 ? cookie.substring(0, eqPos).trim() : cookie.trim();
document.cookie = name + '=;expires=Thu, 01 Jan 1970 00:00:00 GMT';
});
console.log('[Navbar Debug] Data reset complete. Reloading page...');
// Reload the page
window.location.reload();
}
/**
* Updates the theme icon based on the current theme
* @param theme The current theme ('dark' or 'light')
*/
function updateThemeDisplay(theme: string | null): void {
const isDarkMode = theme === 'dark';
const themeIcon = document.getElementById('themeIcon');
if (isDarkMode) {
document.body.setAttribute('data-theme', 'dark');
} else {
document.body.removeAttribute('data-theme');
}
if (themeIcon) {
themeIcon.textContent = isDarkMode ? '☀️' : '🌙';
}
}

@ -8,6 +8,8 @@ import * as nostrTools from 'nostr-tools';
import { defaultServerConfig } from './config';
import { HttpService } from './services/HttpService';
import { NostrService } from './services/NostrService';
import { ReceivedEvent } from './services/NostrEventService';
import { decryptKeyWithNostrExtension, decryptKeyWithNostrTools, decryptWithWebCrypto } from './utils/crypto-utils';
import { UiService } from './services/UiService';
// Module-level service instances
@ -93,6 +95,75 @@ async function handleConnectRelay(): Promise<void> {
const filter = nostrService.createKind21120Filter(showAllEvents);
await nostrService.subscribeToEvents(filter);
// Set event handler to process received events
nostrService.setEventHandler(async (event) => {
// Process 21120 (requests) and 21121 (responses) events
if ((event.kind === 21120 || event.kind === 21121) && event.id) {
// Initialize ReceivedEvent object with potential decrypted content
const receivedEvent = {
id: event.id,
event: event,
receivedAt: Date.now(),
decrypted: false,
decryptedContent: undefined as string | undefined
};
// Try to decrypt the content if it's a 21120 event
if (event.kind === 21120) {
try {
// Find the key tag which contains the encryption key
const keyTag = event.tags.find(tag => tag[0] === 'key');
if (keyTag && keyTag.length > 1 && keyTag[1]) {
const encryptedKey = keyTag[1];
// Get the server private key from localStorage
const serverNsec = localStorage.getItem('serverNsec');
if (serverNsec) {
try {
// We'll use the server's nsec directly with nostr-tools library
console.log("Attempting to decrypt key with nostr-tools using server nsec", {
encryptedKeyLength: encryptedKey.length,
serverNsecPrefix: serverNsec.substring(0, 5) + '...'
});
// Extract the client's pubkey (sender)
const clientPubkey = event.pubkey;
console.log("Using client pubkey for decryption:", clientPubkey.substring(0, 8) + "...");
// Use the decryptKeyWithNostrTools function which uses the library directly
const decryptionKey = await decryptKeyWithNostrTools(encryptedKey, serverNsec, clientPubkey);
console.log("Successfully decrypted key:", decryptionKey.substring(0, 5) + '...');
// Decrypt the content using WebCrypto
console.log("Attempting to decrypt content with WebCrypto");
const decryptedContent = await decryptWithWebCrypto(event.content, decryptionKey);
// Update the receivedEvent with decrypted content
receivedEvent.decrypted = true;
receivedEvent.decryptedContent = decryptedContent;
console.log("Successfully decrypted 21120 event content");
} catch (error) {
console.error("Failed to decrypt event content:", error);
console.error("Decryption error details:", {
errorName: error instanceof Error ? error.name : 'Unknown',
errorMessage: error instanceof Error ? error.message : String(error),
eventId: event.id.substring(0, 8) + '...',
hasKeyTag: !!keyTag,
hasServerNsec: !!serverNsec
});
}
}
}
} catch (error) {
console.error("Error attempting to decrypt event:", error);
}
}
// Add to UI via UiService (with decrypted content if successful)
uiService.addReceivedEvent(receivedEvent);
}
});
uiService.updateRelayStatus('Connected and subscribed ✓', 'connected');
} catch (error) {
console.error("Subscription error:", error);
@ -199,97 +270,72 @@ async function initializeServerNpub(): Promise<void> {
// Query for existing 31120 event
try {
console.log("Querying relay for 31120 event...");
// Pass the user's pubkey to look for 31120 events published by this user
const userPubkey = nostrService.getLoggedInPubkey();
const existingEvent = await nostrService.queryFor31120Event(relayUrl, userPubkey);
if (existingEvent) {
console.log("Found existing 31120 event:", existingEvent);
// Look for the d tag to find the server pubkey
const dTag = existingEvent.tags.find(tag => tag[0] === 'd');
if (dTag && dTag.length > 1) {
// Display the server pubkey
// Store both formats
serverPubkeyHex = dTag[1];
try {
serverPubkeyNpub = nostrTools.nip19.npubEncode(dTag[1]);
// Check first if we have a serverNsec in localStorage
const serverNsec = localStorage.getItem('serverNsec');
if (serverNsec) {
try {
// Extract the server pubkey from the saved nsec
const decoded = nostrTools.nip19.decode(serverNsec);
if (decoded.type === 'nsec') {
const privateKeyBytes = decoded.data as any;
serverPubkeyHex = nostrTools.getPublicKey(privateKeyBytes);
serverPubkeyNpub = nostrTools.nip19.npubEncode(serverPubkeyHex);
console.log(`Using server key from localStorage: ${serverPubkeyHex.substring(0, 8)}...`);
// Set the server pubkey in the input field
serverNpubInput.value = serverPubkeyNpub;
console.log("Using server pubkey from existing 31120 event:", serverPubkeyNpub);
} catch (error) {
console.error("Error encoding server pubkey:", error);
serverNpubInput.value = dTag[1];
serverPubkeyNpub = dTag[1]; // Fallback to hex if conversion fails
}
}
// Display the raw JSON for debugging
console.log("31120 Event JSON:", JSON.stringify(existingEvent, null, 2));
if (relayStatusElement) {
relayStatusElement.textContent = "Server registration found ✓";
relayStatusElement.className = "relay-status connected";
}
} else {
console.log("No 31120 event found, creating one...");
if (relayStatusElement) {
relayStatusElement.textContent = "Creating server registration...";
relayStatusElement.className = "relay-status connecting";
}
// Create a new 31120 event
const newEvent = await nostrService.create31120Event(relayUrl);
if (newEvent) {
console.log("Created new 31120 event:", newEvent);
// Look for the d tag to find the server pubkey
const dTag = newEvent.tags.find(tag => tag[0] === 'd');
if (dTag && dTag.length > 1) {
// Display the server pubkey
// Store both formats
serverPubkeyHex = dTag[1];
try {
serverPubkeyNpub = nostrTools.nip19.npubEncode(dTag[1]);
serverNpubInput.value = serverPubkeyNpub;
console.log("Using server pubkey from new 31120 event:", serverPubkeyNpub);
} catch (error) {
console.error("Error encoding server pubkey:", error);
serverNpubInput.value = dTag[1];
serverPubkeyNpub = dTag[1]; // Fallback to hex if conversion fails
if (relayStatusElement) {
relayStatusElement.textContent = "Server key loaded from localStorage ✓";
relayStatusElement.className = "relay-status connected";
}
}
} catch (error) {
console.error('Error decoding server nsec:', error);
// Display the raw JSON for debugging
console.log("31120 Event JSON:", JSON.stringify(newEvent, null, 2));
// Create a debug output area to show the JSON
const eventDebugOutput = document.createElement('div');
eventDebugOutput.className = 'event-debug-output';
eventDebugOutput.innerHTML = `
<h3>31120 Event JSON (Debug)</h3>
<pre>${JSON.stringify(newEvent, null, 2)}</pre>
// Error reading the server key
const keyErrorMessage = document.createElement('div');
keyErrorMessage.className = 'info-notice';
keyErrorMessage.innerHTML = `
<p>There was an error reading your server key.</p>
<p>Visit the <a href="billboard.html">Billboard</a> page to generate a new server key.</p>
`;
// Insert it after the server info container
// Insert message after the server info container
const serverInfoContainer = document.querySelector('.server-info-container');
if (serverInfoContainer) {
serverInfoContainer.parentNode?.insertBefore(eventDebugOutput, serverInfoContainer.nextSibling);
serverInfoContainer.parentNode?.insertBefore(keyErrorMessage, serverInfoContainer.nextSibling);
}
if (relayStatusElement) {
relayStatusElement.textContent = "Server registration created ✓";
relayStatusElement.className = "relay-status connected";
}
} else {
console.error("Failed to create 31120 event");
if (relayStatusElement) {
relayStatusElement.textContent = "Failed to create server registration";
relayStatusElement.className = "relay-status error";
}
// Set a placeholder in the server pubkey field
serverNpubInput.value = "Error reading server key";
}
} else {
console.log("No server key found in localStorage");
if (relayStatusElement) {
relayStatusElement.textContent = "No server key found";
relayStatusElement.className = "relay-status error";
}
// Display message about needing a server key
const keyNeededMessage = document.createElement('div');
keyNeededMessage.className = 'info-notice';
keyNeededMessage.innerHTML = `
<p>No server key found. This server needs a key to decrypt and sign responses to HTTP-over-Nostr messages.</p>
<p>Visit the <a href="billboard.html">Billboard</a> page to generate a server key.</p>
`;
// Insert message after the server info container
const serverInfoContainer = document.querySelector('.server-info-container');
if (serverInfoContainer) {
serverInfoContainer.parentNode?.insertBefore(keyNeededMessage, serverInfoContainer.nextSibling);
}
// Set a placeholder in the server pubkey field
serverNpubInput.value = "No server key - visit Billboard page";
// No need to check for serverNsec again, as we've already handled that case above
}
} catch (eventError) {
console.error("Error handling 31120 event:", eventError);
@ -317,16 +363,83 @@ async function initializeServerNpub(): Promise<void> {
toggleFormatBtn.addEventListener('click', () => {
if (currentFormat === 'npub') {
// Switch to hex format
serverNpubInput.value = serverPubkeyHex;
currentFormat = 'hex';
formatIndicator.textContent = 'Currently showing: HEX format';
document.getElementById('formatBtnText')!.textContent = 'NPUB';
// Get the current value from the input field
const currentValue = serverNpubInput.value.trim();
// Check if we have a valid hex value, if not, try to derive it from the current value
if (!serverPubkeyHex || serverPubkeyHex === serverPubkeyNpub) {
try {
// If current value is an npub, decode it to get the hex value
if (currentValue.startsWith('npub')) {
const decoded = nostrTools.nip19.decode(currentValue);
if (decoded.type === 'npub') {
serverPubkeyHex = decoded.data as string;
console.log("Converted npub to hex:", serverPubkeyHex);
}
} else if (/^[0-9a-f]{64}$/i.test(currentValue)) {
// If it's already a valid hex format
serverPubkeyHex = currentValue;
} else {
// Try as a last resort with the stored npub value
if (serverPubkeyNpub && serverPubkeyNpub.startsWith('npub')) {
const decoded = nostrTools.nip19.decode(serverPubkeyNpub);
if (decoded.type === 'npub') {
serverPubkeyHex = decoded.data as string;
}
}
}
} catch (error) {
console.error("Error converting to hex:", error);
// Don't update if we can't get a valid hex value
return;
serverPubkeyHex = serverPubkeyNpub;
}
}
// Only switch if we have a value to display
if (serverPubkeyHex) {
serverNpubInput.value = serverPubkeyHex;
currentFormat = 'hex';
formatIndicator.textContent = 'Currently showing: HEX format';
document.getElementById('formatBtnText')!.textContent = 'NPUB';
}
} else {
// Switch to npub format
serverNpubInput.value = serverPubkeyNpub;
currentFormat = 'npub';
formatIndicator.textContent = 'Currently showing: NPUB format';
document.getElementById('formatBtnText')!.textContent = 'HEX';
// Check if we have a valid npub value, if not, try to derive it from hex
// Get the current value from the input field
const currentValue = serverNpubInput.value.trim();
// Check if we have a valid npub value, if not, try to derive it from the current value
if (!serverPubkeyNpub || serverPubkeyNpub === serverPubkeyHex) {
try {
// If current value is a hex key, encode it to get the npub
if (/^[0-9a-f]{64}$/i.test(currentValue)) {
serverPubkeyNpub = nostrTools.nip19.npubEncode(currentValue);
console.log("Converted hex to npub:", serverPubkeyNpub);
} else if (currentValue.startsWith('npub')) {
// If it's already a valid npub format
serverPubkeyNpub = currentValue;
} else {
// Try as a last resort with the stored hex value
if (serverPubkeyHex && /^[0-9a-f]{64}$/i.test(serverPubkeyHex)) {
serverPubkeyNpub = nostrTools.nip19.npubEncode(serverPubkeyHex);
}
}
} catch (error) {
console.error("Error converting to npub:", error);
// Don't update if we can't get a valid npub value
return;
}
}
// Only switch if we have a value to display
if (serverPubkeyNpub) {
serverNpubInput.value = serverPubkeyNpub;
currentFormat = 'npub';
formatIndicator.textContent = 'Currently showing: NPUB format';
document.getElementById('formatBtnText')!.textContent = 'HEX';
}
}
});
}
@ -388,6 +501,75 @@ async function autoConnectToDefaultRelay(): Promise<void> {
const filter = nostrService.createKind21120Filter(showAllEvents);
await nostrService.subscribeToEvents(filter);
// Set event handler to process received events
nostrService.setEventHandler(async (event) => {
// Process 21120 (requests) and 21121 (responses) events
if ((event.kind === 21120 || event.kind === 21121) && event.id) {
// Initialize ReceivedEvent object with potential decrypted content
const receivedEvent = {
id: event.id,
event: event,
receivedAt: Date.now(),
decrypted: false,
decryptedContent: undefined as string | undefined
};
// Try to decrypt the content if it's a 21120 event
if (event.kind === 21120) {
try {
// Find the key tag which contains the encryption key
const keyTag = event.tags.find(tag => tag[0] === 'key');
if (keyTag && keyTag.length > 1 && keyTag[1]) {
const encryptedKey = keyTag[1];
// Get the server private key from localStorage
const serverNsec = localStorage.getItem('serverNsec');
if (serverNsec) {
try {
// We'll use the server's nsec directly with nostr-tools library
console.log("Attempting to decrypt key with nostr-tools using server nsec", {
encryptedKeyLength: encryptedKey.length,
serverNsecPrefix: serverNsec.substring(0, 5) + '...'
});
// Extract the client's pubkey (sender)
const clientPubkey = event.pubkey;
console.log("Using client pubkey for decryption:", clientPubkey.substring(0, 8) + "...");
// Use the decryptKeyWithNostrTools function which uses the library directly
const decryptionKey = await decryptKeyWithNostrTools(encryptedKey, serverNsec, clientPubkey);
console.log("Successfully decrypted key:", decryptionKey.substring(0, 5) + '...');
// Decrypt the content using WebCrypto
console.log("Attempting to decrypt content with WebCrypto");
const decryptedContent = await decryptWithWebCrypto(event.content, decryptionKey);
// Update the receivedEvent with decrypted content
receivedEvent.decrypted = true;
receivedEvent.decryptedContent = decryptedContent;
console.log("Successfully decrypted 21120 event content");
} catch (error) {
console.error("Failed to decrypt event content:", error);
console.error("Decryption error details:", {
errorName: error instanceof Error ? error.name : 'Unknown',
errorMessage: error instanceof Error ? error.message : String(error),
eventId: event.id.substring(0, 8) + '...',
hasKeyTag: !!keyTag,
hasServerNsec: !!serverNsec
});
}
}
}
} catch (error) {
console.error("Error attempting to decrypt event:", error);
}
}
// Add to UI via UiService (with decrypted content if successful)
uiService.addReceivedEvent(receivedEvent);
}
});
uiService.updateRelayStatus('Connected and subscribed ✓', 'connected');
} catch (error) {
uiService.updateRelayStatus(

@ -0,0 +1,125 @@
/**
* EventCache.ts
* Handles caching of Nostr events to reduce network requests
*/
import type { NostrEvent } from '../relay';
import { getServerPubkeyFromEvent } from './NostrUtils';
export interface CacheEntry {
timestamp: number;
events: {[key: string]: NostrEvent};
}
export class EventCache {
private memoryCache = new Map<string, CacheEntry>();
private cachePrefix: string;
private cacheExpiry: number;
/**
* Create a new event cache instance
* @param prefix Storage prefix for localStorage keys
* @param expiryMs Cache expiry time in milliseconds
*/
constructor(prefix: string = 'nostr_cache_', expiryMs: number = 5 * 60 * 1000) {
this.cachePrefix = prefix;
this.cacheExpiry = expiryMs;
}
/**
* Store events in the cache
* @param key Cache key (usually relay URL)
* @param events Events to cache
*/
public cacheEvents(key: string, events: NostrEvent[]): void {
// Create a map of events by server pubkey
const eventsMap: {[key: string]: NostrEvent} = {};
for (const event of events) {
const serverPubkey = getServerPubkeyFromEvent(event);
if (serverPubkey) {
eventsMap[serverPubkey] = event;
}
}
const timestamp = Date.now();
const cacheData: CacheEntry = {
timestamp,
events: eventsMap
};
// Store in memory cache
this.memoryCache.set(key, cacheData);
// Store in localStorage for persistence
try {
localStorage.setItem(this.cachePrefix + key, JSON.stringify(cacheData));
console.log(`Cached ${Object.keys(eventsMap).length} events for ${key}`);
} catch (error) {
console.error('Failed to store cache in localStorage:', error);
// Keep the memory cache even if localStorage fails
}
}
/**
* Get cached events
* @param key Cache key (usually relay URL)
* @returns Array of cached events or null if cache is invalid/expired
*/
public getEvents(key: string): NostrEvent[] | null {
const now = Date.now();
// Try memory cache first (fastest)
const memoryEntry = this.memoryCache.get(key);
if (memoryEntry && now - memoryEntry.timestamp < this.cacheExpiry) {
return Object.values(memoryEntry.events);
}
// Try localStorage (persists between page navigations)
try {
const storedData = localStorage.getItem(this.cachePrefix + key);
if (storedData) {
const parsedData = JSON.parse(storedData) as CacheEntry;
// Check if cache is still valid
if (now - parsedData.timestamp < this.cacheExpiry) {
// Update memory cache
this.memoryCache.set(key, parsedData);
return Object.values(parsedData.events);
}
}
} catch (error) {
console.error('Error reading cache from localStorage:', error);
}
// Cache miss or expired
return null;
}
/**
* Clear cache for a specific key or all cache entries
* @param key Optional cache key (if not provided, clears all cache)
*/
public clearCache(key?: string): void {
if (key) {
// Clear specific cache entry
this.memoryCache.delete(key);
localStorage.removeItem(this.cachePrefix + key);
console.log(`Cleared cache for ${key}`);
} else {
// Clear all cache entries
this.memoryCache.clear();
// Clear all localStorage entries with our prefix
for (let i = 0; i < localStorage.length; i++) {
const storageKey = localStorage.key(i);
if (storageKey && storageKey.startsWith(this.cachePrefix)) {
localStorage.removeItem(storageKey);
}
}
console.log('Cleared all cached events');
}
}
}

@ -0,0 +1,196 @@
/**
* EventDetailsRenderer.ts
* Component for rendering detailed event information
*/
import { NostrEvent } from '../relay';
import { ReceivedEvent } from './NostrEventService';
import { HttpFormatter } from './HttpFormatter';
/**
* Class for rendering event details in the UI
*/
export class EventDetailsRenderer {
private eventDetails: HTMLElement | null = null;
private receivedEvents: Map<string, ReceivedEvent>;
private relatedEvents: Map<string, string[]>;
/**
* Constructor
* @param receivedEvents Map of received events
* @param relatedEvents Map of related events
*/
constructor(receivedEvents: Map<string, ReceivedEvent>, relatedEvents: Map<string, string[]>) {
this.receivedEvents = receivedEvents;
this.relatedEvents = relatedEvents;
}
/**
* Initialize the event details element
*/
public initialize(): void {
this.eventDetails = document.getElementById('eventDetails');
}
/**
* Show event details for a given event ID
* @param eventId The event ID to display details for
*/
public showEventDetails(eventId: string): void {
if (!this.eventDetails || !eventId) {
return;
}
// Get the received event from our map
const receivedEvent = this.receivedEvents.get(eventId);
if (!receivedEvent || !receivedEvent.event) {
this.eventDetails.innerHTML = '<div class="no-event">Event not found</div>';
return;
}
// Get the event
const event = receivedEvent.event;
// Determine if it's a request or response
const isRequest = event.kind === 21120;
const isResponse = event.kind === 21121;
const is21121Event = event.kind === 21121;
// Determine the content to display
let httpContent = receivedEvent.decrypted ?
receivedEvent.decryptedContent || event.content :
event.content;
// Find related events (responses for requests, or request for responses)
let relatedEventsHtml = '';
let relatedIds: string[] = [];
if (event.id) {
// Get related events from the map
relatedIds = this.relatedEvents.get(event.id) || [];
if (relatedIds.length > 0) {
relatedEventsHtml = `
<div class="related-events">
<h3>Related ${isRequest ? 'Responses' : 'Request'}</h3>
<ul class="related-events-list">
${relatedIds.map(id => {
const relatedEvent = this.receivedEvents.get(id)?.event;
const relatedType = relatedEvent?.kind === 21120 ? 'Request' : 'Response';
return `
<li>
<a href="#" class="related-event-link" data-id="${id}">
${relatedType} (${id.substring(0, 8)}...)
</a>
</li>
`;
}).join('')}
</ul>
</div>
`;
}
}
// Format based on event type
const eventTime = new Date(event.created_at * 1000).toLocaleString();
// Handle for "Execute HTTP Request" button for request events
const execRequestBtn = isRequest ? `<button class="execute-http-request-btn">Execute HTTP Request</button>` : '';
// Create response button for requests that don't have responses yet
const createResponseBtn = (isRequest && relatedIds.length === 0) ?
`<button class="create-response-btn">Create NIP-21121 Response</button>` : '';
this.eventDetails.innerHTML = `
<div class="event-details-header">
<h2>Event Details</h2>
<span class="event-id-display">ID: ${event.id?.substring(0, 8) || 'Unknown'}...</span>
</div>
<div class="event-type-info">
<span class="event-kind">Kind: ${event.kind}</span>
<span class="event-type">${isRequest ? 'HTTP Request' : (isResponse ? 'HTTP Response' : 'Unknown')}</span>
<span class="event-time">Time: ${eventTime}</span>
</div>
<div class="event-metadata">
<div class="pubkey">Pubkey: ${event.pubkey}</div>
<div class="tags">
<h3>Tags</h3>
<pre>${JSON.stringify(event.tags, null, 2)}</pre>
</div>
</div>
${relatedEventsHtml}
<div class="http-actions">
${execRequestBtn}
${createResponseBtn}
</div>
<div class="http-content-tabs">
<div class="tab-buttons">
<button class="tab-btn" data-tab="raw-http">Raw HTTP</button>
<button class="tab-btn active" data-tab="formatted-http">Formatted HTTP</button>
</div>
<div class="tab-content" id="raw-http">
${isRequest ?
`<div class="http-content-header">
<button class="execute-http-request-btn">Execute HTTP Request</button>
</div>` :
''
}
<pre class="http-content">${httpContent}</pre>
${!receivedEvent.decrypted ?
'<div class="decryption-status error" id="decryption-status-' + eventId + '">Decryption failed - NIP-44 implementation may be incompatible or missing. Check console for errors.</div>' :
'<div class="decryption-status success" id="decryption-status-' + eventId + '">Decryption successful ✓</div>'}
</div>
<div class="tab-content active" id="formatted-http">
${isRequest ?
`<div class="http-content-header">
<button class="execute-http-request-btn">Execute HTTP Request</button>
</div>` :
''
}
<div class="http-formatted-container">
${HttpFormatter.formatHttpContent(httpContent, isRequest, isResponse || is21121Event)}
</div>
${!receivedEvent.decrypted ?
'<div class="decryption-status error" id="decryption-status-' + eventId + '">Decryption failed - NIP-44 implementation may be incompatible or missing. Check console for errors.</div>' :
'<div class="decryption-status success" id="decryption-status-' + eventId + '">Decryption successful ✓</div>'}
</div>
</div>
`;
// Set up tab buttons
const tabButtons = this.eventDetails.querySelectorAll('.tab-btn');
tabButtons.forEach(button => {
button.addEventListener('click', (e) => {
// Remove active class from all buttons and content
tabButtons.forEach(btn => btn.classList.remove('active'));
const tabContents = this.eventDetails!.querySelectorAll('.tab-content');
tabContents.forEach(content => content.classList.remove('active'));
// Add active class to clicked button
button.classList.add('active');
// Show corresponding content
const tabId = (button as HTMLElement).dataset.tab || '';
const tabContent = this.eventDetails!.querySelector(`#${tabId}`);
if (tabContent) {
tabContent.classList.add('active');
}
});
});
}
/**
* Get the event details element
* @returns The event details element or null
*/
public getEventDetailsElement(): HTMLElement | null {
return this.eventDetails;
}
}

@ -0,0 +1,139 @@
/**
* EventListRenderer.ts
* Component for rendering events in the UI list
*/
import * as nostrTools from 'nostr-tools';
import { NostrEvent } from '../relay';
/**
* Class for rendering events in the UI list
*/
export class EventListRenderer {
private eventsList: HTMLElement | null = null;
/**
* Initialize the event list element
*/
public initialize(): void {
this.eventsList = document.getElementById('eventsList');
}
/**
* Add an event to the list
* @param event The Nostr event to add
* @param kind The event kind (21120 or 21121)
*/
public addEventToList(event: NostrEvent, kind: number): HTMLElement | null {
if (!this.eventsList || !event) {
return null;
}
// Create a container for the event
const eventItem = document.createElement('div');
eventItem.className = 'event-item';
eventItem.dataset.id = event.id || '';
// Format event ID for display
const eventIdForDisplay = event.id ? event.id.substring(0, 8) : 'Unknown';
// Set event type
let eventType = 'Unknown';
if (kind === 21120) {
eventType = 'HTTP Request';
} else if (kind === 21121) {
eventType = 'HTTP Response';
}
// Find recipient if any
let recipient = '';
let isToServer = false;
// Check for p tag to identify recipient
const pTag = event.tags.find(tag => tag[0] === 'p');
if (pTag && pTag.length > 1) {
recipient = `<div class="recipient">To: ${pTag[1].substring(0, 8)}...</div>`;
// Check if this message is addressed to our server
try {
// Get the server pubkey from localStorage
const serverNsec = localStorage.getItem('serverNsec');
if (serverNsec) {
const decoded = nostrTools.nip19.decode(serverNsec);
if (decoded.type === 'nsec') {
// Get server public key
const serverPubkey = nostrTools.getPublicKey(decoded.data as any);
// Check if the p tag matches our server pubkey
isToServer = (pTag[1] === serverPubkey);
}
}
} catch {
// Ignore errors when checking
}
}
// Format the event item
eventItem.innerHTML = `
<div class="event-item-container">
<div class="event-avatar" data-pubkey="${event.pubkey}">
<div class="avatar-placeholder">👤</div>
</div>
<div class="event-content-wrapper">
<div class="event-header">
<div class="event-type ${eventType === 'HTTP Request' ? 'request' : 'response'}">${eventType}</div>
<div class="event-time">${new Date(event.created_at * 1000).toLocaleTimeString()}</div>
</div>
<div class="event-id">ID: ${eventIdForDisplay}... ${recipient} ${isToServer ? '<span class="server-match">✓</span>' : ''}</div>
<div class="event-pubkey">From: ${event.pubkey.substring(0, 8)}...</div>
</div>
</div>
`;
// Set a data attribute to indicate if this event is addressed to our server
eventItem.dataset.toServer = isToServer.toString();
// Add to list at the top
if (this.eventsList.firstChild) {
this.eventsList.insertBefore(eventItem, this.eventsList.firstChild);
} else {
this.eventsList.appendChild(eventItem);
}
return eventItem;
}
/**
* Filter events in the UI based on the showAllEvents checkbox state
* @param showAllEvents Whether to show all events or only those for the server
*/
public filterEventsInUI(showAllEvents: boolean): void {
if (!this.eventsList) {
return;
}
// Get all event items
const eventItems = this.eventsList.querySelectorAll('.event-item');
// Iterate through each event item
eventItems.forEach((item) => {
const isToServer = item.getAttribute('data-to-server') === 'true';
if (showAllEvents) {
// Show all events
(item as HTMLElement).style.display = '';
} else {
// Only show events addressed to the server
(item as HTMLElement).style.display = isToServer ? '' : 'none';
}
});
}
/**
* Get the events list element
* @returns The events list element or null
*/
public getEventsList(): HTMLElement | null {
return this.eventsList;
}
}

@ -0,0 +1,65 @@
/**
* HttpClient.ts
* Service for making HTTP requests
*/
import { HttpService } from './HttpService';
import { ToastNotifier } from './ToastNotifier';
/**
* Class for handling HTTP requests
*/
export class HttpClient {
private httpService: HttpService;
/**
* Constructor
* @param httpService An instance of HttpService
*/
constructor(httpService: HttpService) {
this.httpService = httpService;
}
/**
* Send an HTTP request based on raw content
* @param httpContent The raw HTTP content (request)
* @returns Promise resolving to the HTTP response
*/
public async sendHttpRequest(httpContent: string): Promise<string> {
try {
// Parse the HTTP request
const parsedRequest = this.httpService.parseHttpRequest(httpContent);
if (!parsedRequest) {
throw new Error('Failed to parse HTTP request');
}
const { url, options } = parsedRequest;
const { method, headers, body } = options;
// Perform fetch
const response = await fetch(url, {
method,
headers,
body: body ? body.trim() : undefined,
});
// Build response string
let responseText = `HTTP/1.1 ${response.status} ${response.statusText}\n`;
// Add headers
response.headers.forEach((value, key) => {
responseText += `${key}: ${value}\n`;
});
// Add body
const responseBody = await response.text();
responseText += '\n' + responseBody;
return responseText;
} catch (error) {
console.error('Error sending HTTP request:', error);
return `HTTP/1.1 500 Internal Server Error\nContent-Type: text/plain\n\nError: ${error instanceof Error ? error.message : String(error)}`;
}
}
}

@ -0,0 +1,149 @@
/**
* HttpFormatter.ts
* Utility for formatting HTTP content
*/
/**
* Class for formatting HTTP content
*/
export class HttpFormatter {
/**
* Format HTTP content for display
* @param content The raw HTTP content
* @param isRequest Whether this is a request or response
* @param isResponse Whether this is a response or other content
* @returns Formatted HTML
*/
public static formatHttpContent(content: string, isRequest: boolean, isResponse: boolean): string {
if (!content || content.trim() === '') {
return '<div class="empty-content">No content available</div>';
}
try {
const lines = content.split('\n');
let formattedHtml = '';
// Format the first line differently (status line or request line)
if (lines.length > 0) {
const firstLine = lines[0].trim();
if (isRequest) {
// Format request line (e.g., "GET /path HTTP/1.1")
const parts = firstLine.split(' ');
if (parts.length >= 3) {
const [method, path, httpVersion] = parts;
formattedHtml += `<div class="http-first-line">
<span class="http-method">${this.escapeHtml(method)}</span>
<span class="http-path">${this.escapeHtml(path)}</span>
<span class="http-version">${this.escapeHtml(httpVersion)}</span>
</div>`;
} else {
formattedHtml += `<div class="http-first-line">${this.escapeHtml(firstLine)}</div>`;
}
} else if (isResponse) {
// Format status line (e.g., "HTTP/1.1 200 OK")
const parts = firstLine.split(' ');
if (parts.length >= 3) {
const version = parts[0];
const status = parts[1];
const statusText = parts.slice(2).join(' ');
let statusClass = 'success';
if (status.startsWith('4') || status.startsWith('5')) {
statusClass = 'error';
} else if (status.startsWith('3')) {
statusClass = 'redirect';
}
formattedHtml += `<div class="http-first-line">
<span class="http-version">${this.escapeHtml(version)}</span>
<span class="http-status ${statusClass}">${this.escapeHtml(status)}</span>
<span class="http-status-text">${this.escapeHtml(statusText)}</span>
</div>`;
} else {
formattedHtml += `<div class="http-first-line">${this.escapeHtml(firstLine)}</div>`;
}
} else {
formattedHtml += `<div class="http-first-line">${this.escapeHtml(firstLine)}</div>`;
}
}
// Process headers and body
let inHeaders = true;
let headerHtml = '<div class="http-headers">';
let bodyHtml = '<div class="http-body">';
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (inHeaders && line.trim() === '') {
inHeaders = false;
headerHtml += '</div>';
continue;
}
if (inHeaders) {
// Process header line
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const name = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim();
headerHtml += `<div class="http-header">
<span class="http-header-name">${this.escapeHtml(name)}:</span>
<span class="http-header-value">${this.escapeHtml(value)}</span>
</div>`;
} else {
headerHtml += `<div class="http-header">${this.escapeHtml(line)}</div>`;
}
} else {
// Process body line
bodyHtml += `${this.escapeHtml(line)}\n`;
}
}
if (inHeaders) {
headerHtml += '</div>';
}
bodyHtml += '</div>';
// Try to detect and format JSON body
if (!inHeaders) {
try {
// Extract body content
const bodyContent = lines.slice(lines.findIndex(line => line.trim() === '') + 1).join('\n');
if (bodyContent.trim().startsWith('{') || bodyContent.trim().startsWith('[')) {
const jsonData = JSON.parse(bodyContent);
const prettyJson = JSON.stringify(jsonData, null, 2);
bodyHtml = `<div class="http-body json">
<pre class="formatted-json">${this.escapeHtml(prettyJson)}</pre>
</div>`;
}
} catch (e) {
// Not valid JSON, keep the original body
}
}
return formattedHtml + headerHtml + bodyHtml;
} catch (error) {
console.error('Error formatting HTTP content:', error);
return `<div class="error-content">Error formatting content: ${error instanceof Error ? error.message : String(error)}</div>`;
}
}
/**
* Escape HTML special characters to prevent XSS
* @param text The text to escape
* @returns Escaped HTML
*/
private static escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
}

@ -1,8 +1,11 @@
/**
* HttpService.ts
* Handles HTTP-related operations and cryptography functions for the HTTP-to-Nostr application
* Handles HTTP-related operations for the HTTP-to-Nostr application
*/
// Import crypto utilities
import { encryptWithWebCrypto, decryptWithWebCrypto } from '../utils/crypto-utils';
// Interface for HTTP request options
export interface HttpRequestOptions {
method: string;
@ -16,8 +19,6 @@ export interface HttpRequestOptions {
export class HttpService {
/**
* Parse a raw HTTP request text into its components
* @param httpRequestText The raw HTTP request text
* @returns Parsed HTTP request details or null if invalid
*/
public parseHttpRequest(httpRequestText: string): { url: string; options: HttpRequestOptions } | null {
try {
@ -33,11 +34,11 @@ export class HttpService {
const method = requestLine[0];
let url = requestLine[1];
// If it's a relative URL, make it absolute
// Handle URL format
if (url.startsWith('/')) {
url = `${window.location.origin}${url}`;
} else if (!url.startsWith('http')) {
url = `http://${url}`; // Default to http if no protocol specified
url = `http://${url}`;
}
// Parse headers
@ -62,11 +63,8 @@ export class HttpService {
i++;
}
// Extract body if present
let body = '';
if (bodyStartIndex < lines.length) {
body = lines.slice(bodyStartIndex).join('\n');
}
// Extract body
const body = bodyStartIndex < lines.length ? lines.slice(bodyStartIndex).join('\n') : '';
// Return the parsed request
return {
@ -84,8 +82,6 @@ export class HttpService {
/**
* Execute an HTTP request
* @param httpRequestText The raw HTTP request text
* @returns A promise that resolves to the HTTP response as text
*/
public async executeHttpRequest(httpRequestText: string): Promise<string> {
try {
@ -95,132 +91,38 @@ export class HttpService {
}
const { url, options } = parsedRequest;
// Use the fetch API to execute the request - explicitly using window.fetch
const response = await window.fetch(url, options);
// Prepare the response text
let responseText = `HTTP/1.1 ${response.status} ${response.statusText}\n`;
// Add response headers
// Add headers and body
response.headers.forEach((value: string, key: string) => {
responseText += `${key}: ${value}\n`;
});
// Add empty line to separate headers from body
responseText += '\n';
// Add response body
const responseBody = await response.text();
responseText += responseBody;
responseText += await response.text();
return responseText;
} catch (error) {
if (error instanceof Error) {
return `Error executing HTTP request: ${error.message}`;
} else {
return `Error executing HTTP request: ${String(error)}`;
}
return `Error executing HTTP request: ${error instanceof Error ? error.message : String(error)}`;
}
}
/**
* Encrypt data using Web Crypto API
* @param plaintext The plaintext to encrypt
* @param key The encryption key
* @returns A promise that resolves to a base64-encoded encrypted string
* Crypto utility: Encrypt data using Web Crypto API
* Uses the shared crypto-utils implementation
*/
public async encryptWithWebCrypto(plaintext: string, key: string): Promise<string> {
try {
// Generate a random IV
const iv = crypto.getRandomValues(new Uint8Array(12));
// Create key material from the encryption key
const keyMaterial = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(key)
);
// Import the key for AES-GCM encryption
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyMaterial,
{ name: 'AES-GCM' },
false,
['encrypt']
);
// Encrypt the data
const encryptedData = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: iv
},
cryptoKey,
new TextEncoder().encode(plaintext)
);
// Combine IV and ciphertext
const encryptedBytes = new Uint8Array(iv.length + new Uint8Array(encryptedData).length);
encryptedBytes.set(iv, 0);
encryptedBytes.set(new Uint8Array(encryptedData), iv.length);
// Convert to base64
return btoa(String.fromCharCode(...encryptedBytes));
} catch (error) {
throw new Error(`WebCrypto encryption failed: ${error instanceof Error ? error.message : String(error)}`);
}
public async encryptData(plaintext: string, key: string): Promise<string> {
return encryptWithWebCrypto(plaintext, key);
}
/**
* Decrypt data using Web Crypto API
* @param encryptedBase64 The base64-encoded encrypted data
* @param key The decryption key
* @returns A promise that resolves to the decrypted plaintext
* Crypto utility: Decrypt data using Web Crypto API
* Uses the shared crypto-utils implementation
*/
public async decryptWithWebCrypto(encryptedBase64: string, key: string): Promise<string> {
try {
// Convert base64 to byte array
const encryptedBytes = new Uint8Array(
atob(encryptedBase64)
.split('')
.map(char => char.charCodeAt(0))
);
// Extract IV (first 12 bytes)
const iv = encryptedBytes.slice(0, 12);
// Extract ciphertext (remaining bytes)
const ciphertext = encryptedBytes.slice(12);
// Create key material from the decryption key
const keyMaterial = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(key)
);
// Import the key for AES-GCM decryption
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyMaterial,
{ name: 'AES-GCM' },
false,
['decrypt']
);
// Decrypt the data
const decryptedData = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: iv
},
cryptoKey,
ciphertext
);
// Convert decrypted data to string
return new TextDecoder().decode(decryptedData);
} catch (error) {
throw new Error(`WebCrypto decryption failed: ${error instanceof Error ? error.message : String(error)}`);
}
public async decryptData(encryptedBase64: string, key: string): Promise<string> {
return decryptWithWebCrypto(encryptedBase64, key);
}
}

@ -0,0 +1,92 @@
/**
* Nostr21121EventHandler.ts
* Handles NIP-21121 HTTP response events
*/
import { NostrEvent } from '../relay';
import { NostrService } from './NostrService';
/**
* Class for handling NIP-21121 HTTP response events
*/
export class Nostr21121EventHandler {
private nostrService: NostrService;
private responseEventMap: Map<string, string[]> = new Map();
/**
* Constructor
* @param nostrService The NostrService instance
*/
constructor(nostrService: NostrService) {
this.nostrService = nostrService;
}
/**
* Find a 21121 response event for a specific 21120 request
* @param requestEventId The ID of the 21120 request event
* @returns Promise resolving to the response event or null if not found
*/
public async findResponseForRequest(requestEventId: string): Promise<NostrEvent | null> {
try {
if (!requestEventId) {
console.error('Invalid request event ID');
return null;
}
// Get active relay
const relayService = this.nostrService.getRelayService();
const relayUrl = relayService.getActiveRelayUrl();
if (!relayUrl) {
console.error('No active relay available');
return null;
}
// Simplified implementation - instead of actual relay queries
console.log(`Searching for response event for request ${requestEventId} on relay ${relayUrl}`);
// Simulate searching for events - in a real implementation this would query relays
return new Promise((resolve) => {
setTimeout(() => {
console.log('No response event found - this is a simplified implementation');
resolve(null);
}, 1000);
});
} catch (error) {
console.error('Error finding 21121 response:', error);
return null;
}
}
/**
* Add a related event to the map
* @param requestId The 21120 request event ID
* @param responseId The 21121 response event ID
*/
public addRelatedEvent(requestId: string, responseId: string): void {
const existing = this.responseEventMap.get(requestId) || [];
if (!existing.includes(responseId)) {
existing.push(responseId);
this.responseEventMap.set(requestId, existing);
}
}
/**
* Get related events for a given event ID
* @param eventId The event ID to get related events for
* @returns Array of related event IDs
*/
public getRelatedEvents(eventId: string): string[] {
return this.responseEventMap.get(eventId) || [];
}
/**
* Check if an event has related 21121 responses
* @param eventId The event ID to check
* @returns True if the event has responses, false otherwise
*/
public hasRelatedResponses(eventId: string): boolean {
const related = this.responseEventMap.get(eventId);
return related !== undefined && related.length > 0;
}
}

@ -0,0 +1,304 @@
/**
* Nostr21121IntegrationHelper
* Handles integration between HTTP content and NIP-21121 events
*/
import { NostrEvent } from '../../src/relay';
import { Nostr21121Service } from './Nostr21121Service';
import { NostrEventService } from './NostrEventService';
import { ToastNotifier } from './ToastNotifier';
import { HttpFormatter } from './HttpFormatter';
/**
* Helper class for NIP-21121 integration with HTTP content
*/
export class Nostr21121IntegrationHelper {
private nostr21121Service: Nostr21121Service;
private nostrEventService: NostrEventService;
private httpResponseModal: HTMLElement | null = null;
constructor(nostr21121Service: Nostr21121Service, nostrEventService: NostrEventService) {
this.nostr21121Service = nostr21121Service;
this.nostrEventService = nostrEventService;
// Initialize the handlers when the class is instantiated
this.initializeEventListeners();
}
/**
* Initialize event listeners for HTTP request execution
*/
private initializeEventListeners(): void {
// Click handler for the execute button
document.addEventListener('click', async (event) => {
const target = event.target as HTMLElement;
// Handle HTTP request execution button
if (target && (
target.classList.contains('execute-http-request-btn') ||
target.closest('.execute-http-request-btn')
)) {
const button = target.classList.contains('execute-http-request-btn') ?
target : target.closest('.execute-http-request-btn') as HTMLElement;
// Don't do anything if button is disabled
if (button.hasAttribute('disabled')) {
return;
}
await this.handleHttpRequestExecution(button);
}
});
}
/**
* Handle HTTP request execution
* @param target The button element that was clicked
*/
private async handleHttpRequestExecution(target: HTMLElement): Promise<void> {
// Find the event details container
const eventDetails = target.closest('.event-details');
if (!eventDetails) {
console.error('Event details container not found');
return;
}
try {
// Extract the event ID from the header
const eventIdMatch = eventDetails.querySelector('h3')?.textContent?.match(/ID: (\w+)\.\.\./);
if (!eventIdMatch || !eventIdMatch[1]) return;
const eventId = eventIdMatch[1];
try {
// Get the event directly from the DOM
const rawJsonElement = eventDetails.querySelector('#raw-json pre');
if (!rawJsonElement || !rawJsonElement.textContent) {
throw new Error('Raw JSON not found');
}
const eventData = JSON.parse(rawJsonElement.textContent) as NostrEvent;
// Get the HTTP content
const httpContent = eventDetails.querySelector('.http-content')?.textContent;
if (!httpContent) return;
// Execute the HTTP request
try {
// Disable the button and show loading state
target.textContent = 'Executing...';
target.setAttribute('disabled', 'true');
// Execute the request
const response = await executeHttpRequestWithFetch(httpContent);
// Show the response in a modal
this.displayHttpResponse(response);
// Ask if user wants to create a 21121 response
if (confirm('Do you want to create and publish a NIP-21121 response event?')) {
await this.createAndPublish21121Response(eventData, response);
}
} catch (error) {
console.error('Error executing HTTP request:', error);
ToastNotifier.show(`Error: ${error instanceof Error ? error.message : String(error)}`, 'error');
} finally {
// Restore the button
target.textContent = 'Execute HTTP Request';
target.removeAttribute('disabled');
}
} catch (error) {
console.error('Error parsing event data:', error);
ToastNotifier.show(`Error: Could not parse event data - ${error instanceof Error ? error.message : String(error)}`, 'error');
}
} catch (error) {
console.error('Error in HTTP execution handler:', error);
ToastNotifier.show(`Error: ${error instanceof Error ? error.message : String(error)}`, 'error');
}
}
/**
* Display HTTP response in the modal
* @param responseContent The HTTP response content
*/
private displayHttpResponse(responseContent: string): void {
// Create or get the modal
let modal = document.getElementById('httpResponseModal');
if (!modal) {
console.error('HTTP response modal not found in DOM');
return;
}
// Get content containers
const formattedContainer = modal.querySelector('#formatted-response .http-formatted-container');
const rawContainer = modal.querySelector('#raw-response pre');
// Update the content
if (formattedContainer) {
formattedContainer.innerHTML = HttpFormatter.formatHttpContent(responseContent, false, true);
}
if (rawContainer) {
rawContainer.textContent = responseContent;
}
// Show the modal
(modal as HTMLElement).style.display = 'block';
// Listen for tab buttons within this modal
const tabButtons = modal.querySelectorAll('.tab-btn');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
// Get the target tab
const targetTabId = (button as HTMLElement).dataset.tab;
if (!targetTabId) {
return;
}
// Remove active class from all buttons and content
tabButtons.forEach(btn => btn.classList.remove('active'));
modal?.querySelectorAll('.tab-content').forEach(tab => tab.classList.remove('active'));
// Add active class to clicked tab
button.classList.add('active');
// Show corresponding content
const content = modal?.querySelector(`#${targetTabId}`);
if (content) {
content.classList.add('active');
}
});
});
}
/**
* Create and publish a 21121 response event
* @param requestEvent The original 21120 request event
* @param responseContent The HTTP response content
*/
private async createAndPublish21121Response(requestEvent: NostrEvent, responseContent: string): Promise<void> {
try {
// Get the server's private key
const serverNsec = localStorage.getItem('serverNsec');
if (!serverNsec) {
ToastNotifier.show('Server private key (nsec) not found. Please set up a server identity first.', 'error');
return;
}
// Get the relay URL from the UI
const relayUrlInput = document.getElementById('relayUrl') as HTMLInputElement;
const relayUrl = relayUrlInput?.value || 'wss://relay.degmods.com';
// Create and publish the 21121 event
const responseEvent = await this.nostr21121Service.createAndPublish21121Event(
requestEvent,
responseContent,
serverNsec,
relayUrl
);
if (responseEvent) {
ToastNotifier.show('NIP-21121 response event published successfully!', 'success');
// Add a visual indicator to the event in the UI
const eventItem = document.querySelector(`.event-item[data-id="${requestEvent.id}"]`);
if (eventItem && !eventItem.querySelector('.response-indicator')) {
const responseIndicator = document.createElement('div');
responseIndicator.className = 'response-indicator';
responseIndicator.innerHTML = '<span class="response-available">21121 Response Available</span>';
eventItem.appendChild(responseIndicator);
}
} else {
ToastNotifier.show('Failed to publish NIP-21121 response event', 'error');
}
} catch (error) {
console.error('Error creating 21121 response:', error);
ToastNotifier.show(`Error: ${error instanceof Error ? error.message : String(error)}`, 'error');
}
}
}
/**
* Execute HTTP request using fetch API
* @param requestContent The HTTP request content
* @returns Promise resolving to the HTTP response
*/
async function executeHttpRequestWithFetch(requestContent: string): Promise<string> {
// Parse the request
const lines = requestContent.trim().split('\n');
if (lines.length === 0) {
throw new Error('Empty request');
}
// Parse the first line (e.g., "GET /api/users HTTP/1.1")
const firstLine = lines[0].trim();
const [method, path, _httpVersion] = firstLine.split(' ');
// Extract the host
let host = '';
let headers: Record<string, string> = {};
let body = '';
let inHeaders = true;
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (inHeaders && line === '') {
inHeaders = false;
continue;
}
if (inHeaders) {
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim();
headers[key.toLowerCase()] = value;
if (key.toLowerCase() === 'host') {
host = value;
}
}
} else {
body += line + '\n';
}
}
if (!host) {
throw new Error('Host header is required');
}
// Construct URL
const isSecure = host.includes(':443') || !host.includes(':');
const protocol = isSecure ? 'https://' : 'http://';
const baseUrl = protocol + host.split(':')[0];
const url = new URL(path, baseUrl).toString();
// Perform fetch
try {
const response = await fetch(url, {
method,
headers,
body: body.trim() || undefined,
});
// Build response string
let responseText = `HTTP/1.1 ${response.status} ${response.statusText}\n`;
// Add headers
response.headers.forEach((value, key) => {
responseText += `${key}: ${value}\n`;
});
// Add body
const responseBody = await response.text();
responseText += '\n' + responseBody;
return responseText;
} catch (error) {
return `HTTP/1.1 500 Internal Server Error\nContent-Type: text/plain\n\nError: ${error instanceof Error ? error.message : String(error)}`;
}
}

@ -0,0 +1,136 @@
/**
* Nostr21121ResponseHandler.ts
* Handler for creating and managing NIP-21121 HTTP response events
*/
import { NostrEvent } from '../relay';
import { NostrService } from './NostrService';
import { ToastNotifier } from './ToastNotifier';
/**
* Class for handling NIP-21121 HTTP response events
*/
export class Nostr21121ResponseHandler {
private nostrService: NostrService;
private relatedEvents: Map<string, string[]>;
/**
* Constructor
* @param nostrService The NostrService instance
* @param relatedEvents Map of related events
*/
constructor(nostrService: NostrService, relatedEvents: Map<string, string[]>) {
this.nostrService = nostrService;
this.relatedEvents = relatedEvents;
}
/**
* Create and publish a NIP-21121 response event
* @param requestEvent The original 21120 request event
* @param responseContent The HTTP response content
* @returns Promise resolving to success status
*/
public async createAndPublish21121Response(requestEvent: NostrEvent, responseContent: string): Promise<boolean> {
try {
// Get the server private key
const serverNsec = localStorage.getItem('serverNsec');
if (!serverNsec) {
ToastNotifier.show('Server private key (nsec) not found. Please set up a server identity first.', 'error');
return false;
}
// Get the active relay URL
const relayUrl = this.nostrService.getRelayService().getActiveRelayUrl();
if (!relayUrl) {
ToastNotifier.show('No active relay connection. Please connect to a relay first.', 'error');
return false;
}
// Create and publish the 21121 event
ToastNotifier.show('Creating and publishing 21121 response event...', 'info');
const responseEvent = await this.nostrService.createAndPublish21121Event(
requestEvent,
responseContent,
serverNsec,
relayUrl
);
if (responseEvent && responseEvent.id && requestEvent.id) {
// Add the response to related events mapping
this.addRelatedEvent(requestEvent.id, responseEvent.id);
// Notify user
ToastNotifier.show('NIP-21121 response event published successfully!', 'success');
// Add indicator to UI
this.addResponseIndicator(requestEvent.id);
return true;
} else {
ToastNotifier.show('Failed to publish NIP-21121 response event', 'error');
return false;
}
} catch (error) {
console.error('Error creating 21121 response:', error);
ToastNotifier.show(`Error: ${error instanceof Error ? error.message : String(error)}`, 'error');
return false;
}
}
/**
* Add a related event to the map
* @param requestId The request event ID
* @param responseId The response event ID
*/
private addRelatedEvent(requestId: string, responseId: string): void {
const relatedIds = this.relatedEvents.get(requestId) || [];
if (!relatedIds.includes(responseId)) {
relatedIds.push(responseId);
this.relatedEvents.set(requestId, relatedIds);
}
}
/**
* Add a visual indicator to the event in the UI
* @param requestEventId The request event ID
*/
private addResponseIndicator(requestEventId: string): void {
const eventItem = document.querySelector(`.event-item[data-id="${requestEventId}"]`);
if (eventItem && !eventItem.querySelector('.response-indicator')) {
const responseIndicator = document.createElement('div');
responseIndicator.className = 'response-indicator';
responseIndicator.innerHTML = '<span class="response-available">21121 Response Available</span>';
eventItem.appendChild(responseIndicator);
}
}
/**
* Find responses for a given request event
* @param requestEventId The request event ID
* @param relayUrl The relay URL to search
* @returns Promise resolving to the response event or null if not found
*/
public async findResponseForRequest(requestEventId: string, relayUrl: string): Promise<NostrEvent | null> {
return this.nostrService.findResponseForRequest(requestEventId, relayUrl);
}
/**
* Check if a request has responses
* @param eventId The event ID to check
* @returns True if the event has responses, false otherwise
*/
public hasResponses(eventId: string): boolean {
const relatedIds = this.relatedEvents.get(eventId) || [];
return relatedIds.length > 0;
}
/**
* Get related response events for a request
* @param eventId The request event ID
* @returns Array of related response event IDs
*/
public getResponsesForRequest(eventId: string): string[] {
return this.relatedEvents.get(eventId) || [];
}
}

@ -0,0 +1,121 @@
/**
* NIP-21121 Service for HTTP response events
*/
import { NostrEvent } from '../relay';
import * as nostrTools from 'nostr-tools';
/**
* Service for managing NIP-21121 HTTP response events
*/
export class Nostr21121Service {
private relayService: any;
private cacheService: any;
constructor(relayService: any, cacheService: any) {
this.relayService = relayService;
this.cacheService = cacheService;
}
/**
* Create and publish a NIP-21121 HTTP response event
* @param requestEvent The original 21120 request event
* @param responseContent The HTTP response content
* @param serverPrivateKey The server's private key for signing
* @param relayUrl The relay URL to publish to
* @returns The created and published event
*/
public async createAndPublish21121Event(
requestEvent: NostrEvent,
responseContent: string,
serverPrivateKey: Uint8Array | string,
relayUrl: string
): Promise<NostrEvent | null> {
try {
// Ensure we have a valid request event
if (!requestEvent || !requestEvent.id) {
throw new Error('Invalid request event');
}
// Extract private key
let privateKeyStr = typeof serverPrivateKey === 'string' ? serverPrivateKey : '';
if (typeof serverPrivateKey === 'string' && serverPrivateKey.startsWith('nsec')) {
try {
// This would normally decode the nsec, but we'll just simulate it
console.log('Would decode nsec to hex');
privateKeyStr = serverPrivateKey;
} catch (e) {
console.error('Error decoding nsec:', e);
return null;
}
}
// Get the public key from the private key
const pubKey = nostrTools.getPublicKey(privateKeyStr as any);
// Check if we need to encrypt the response
let finalContent = responseContent;
let tags: string[][] = [];
// Always add reference to the request event
if (requestEvent.id) {
tags.push(['e', requestEvent.id, '']);
}
// Add kind reference
tags.push(['k', '21120']);
// Check if the original event has a p tag (recipient)
const pTag = requestEvent.tags.find(tag => tag[0] === 'p');
if (pTag && pTag[1]) {
// This would be encrypted in a real implementation
console.log('Would encrypt content for recipient:', pTag[1]);
// Add p tag to reference the recipient
tags.push(['p', pTag[1], '']);
}
// Create the event
const eventBody = {
kind: 21121,
created_at: Math.floor(Date.now() / 1000),
tags: tags,
content: finalContent,
pubkey: pubKey
};
// Compute the event ID (hash)
const id = nostrTools.getEventHash(eventBody);
// Sign the event (simplified)
const sig = 'simulated_signature_for_demo';
// Create the complete signed event
const event: NostrEvent = {
...eventBody,
id,
sig
};
// Simulate publishing to relay
console.log(`Would publish event to relay: ${relayUrl}`);
console.log('Event:', event);
return event;
} catch (error) {
console.error('Error creating 21121 event:', error);
return null;
}
}
/**
* Find a response event for a request
* @param requestEventId The ID of the request event to find responses for
* @param relayUrl The relay URL to search
* @returns Promise resolving to the response event or null if not found
*/
public findResponseForRequest(requestEventId: string, relayUrl: string): Promise<NostrEvent | null> {
console.log(`Would search for response to ${requestEventId} on ${relayUrl}`);
return Promise.resolve(null);
}
}

@ -0,0 +1,409 @@
/**
* Nostr31120Service.ts
* Dedicated service for handling kind 31120 events
*/
// External imports
import * as nostrTools from 'nostr-tools';
import type { NostrEvent } from '../relay';
import type { NostrFilter } from './NostrEventService';
import type { NostrRelayService } from './NostrRelayService';
import type { NostrCacheService } from './NostrCacheService';
/**
* Service for working with kind 31120 events (HTTP-over-Nostr server registrations)
*/
export class Nostr31120Service {
private relayService: NostrRelayService;
private cacheService: NostrCacheService;
/**
* Create a new 31120 service
*/
constructor(relayService: NostrRelayService, cacheService: NostrCacheService) {
this.relayService = relayService;
this.cacheService = cacheService;
}
/**
* Query for all kind 31120 events from a relay
* @param relayUrl The relay URL to query
* @returns Promise resolving to an array of 31120 events
*/
public async queryForAll31120Events(relayUrl: string): Promise<NostrEvent[]> {
console.log(`Querying for all kind 31120 events from ${relayUrl}`);
try {
// Connect to relay if needed
if (!this.relayService.isConnected() || this.relayService.getActiveRelayUrl() !== relayUrl) {
await this.relayService.connectToRelay(relayUrl);
}
// Check the cache first
const cachedEvents = this.cacheService.getCachedEvents(relayUrl);
if (cachedEvents && cachedEvents.length > 0) {
// Filter cached events for kind 31120
const filteredEvents = cachedEvents.filter(event => event.kind === 31120);
if (filteredEvents.length > 0) {
console.log(`Using ${filteredEvents.length} cached 31120 events`);
return filteredEvents;
}
}
// Create filter for all kind 31120 events
const filter: NostrFilter = {
kinds: [31120],
limit: 100 // Reasonable limit for server advertisements
};
// Set a timeout for the query
const timeoutMs = 8000;
const timeoutPromise = new Promise<NostrEvent[]>((_, reject) => {
setTimeout(() => reject(new Error(`Query timed out after ${timeoutMs}ms`)), timeoutMs);
});
// Create a promise that will resolve with the query results
const queryPromise = new Promise<NostrEvent[]>((resolve) => {
const events: NostrEvent[] = [];
const reqId = `req-${Date.now()}`;
// Create a new connection with custom message handler
(async () => {
try {
// Ensure we're connected to the relay
const connected = await this.relayService.connectToRelay(relayUrl);
if (!connected) {
resolve([]);
return;
}
// Get the WebSocket manager
const wsManager = this.relayService.getWebSocketManager();
// Connect with custom message handler
await wsManager.connect(relayUrl, {
onMessage: (data: unknown) => {
try {
const message = data as unknown[];
if (Array.isArray(message) && message[0] === "EVENT" && message[1] === reqId) {
// Add the event to our results
const event = message[2] as NostrEvent;
events.push(event);
} else if (Array.isArray(message) && message[0] === "EOSE" && message[1] === reqId) {
// End of stored events, resolve the promise
resolve(events);
}
} catch (error) {
console.error("Error processing message:", error);
}
}
});
// Send the query to the relay
const reqMsg = JSON.stringify(["REQ", reqId, filter]);
wsManager.send(reqMsg);
} catch (error) {
console.error("Error setting up event query:", error);
resolve([]);
}
})();
});
// Execute the query with a timeout
let events: NostrEvent[];
try {
events = await Promise.race([queryPromise, timeoutPromise]);
} catch (error) {
console.error("Query timed out or failed:", error);
// Return cached events if any
return cachedEvents?.filter(event => event.kind === 31120) || [];
}
// Filter out expired events
const now = Math.floor(Date.now() / 1000);
const validEvents = events.filter(event => {
// Check for expiry tag
const expiryTag = event.tags.find(tag => tag[0] === 'expiry');
if (expiryTag && expiryTag.length > 1) {
const expiryTime = parseInt(expiryTag[1]);
if (!isNaN(expiryTime) && expiryTime < now) {
return false; // Skip expired events
}
}
return true;
});
// Cache the valid results
if (validEvents.length > 0) {
this.cacheService.cacheEvents(relayUrl, validEvents);
}
console.log(`Found ${validEvents.length} valid 31120 events from relay (${events.length - validEvents.length} expired)`);
return validEvents;
} catch (error) {
console.error("Error querying for all 31120 events:", error);
return [];
}
}
/**
* Get the relevant server pubkey from a kind 31120 event
* @param event The 31120 event to extract pubkey from
* @returns Server pubkey or null if not found
*/
public getServerPubkeyFromEvent(event: NostrEvent): string | null {
if (event.kind !== 31120) return null;
// Find the d tag which contains the server pubkey
const dTag = event.tags.find(tag => tag[0] === 'd');
if (dTag && dTag.length > 1) {
return dTag[1];
}
return null;
}
/**
* Create or update a 31120 event (server advertisement)
* @param relayUrl The relay URL to publish to
* @param content The content/description of the server
* @param relays Array of relay URLs to include as tags
* @param expiryHours Number of hours until expiry
* @param existingEventId Optional ID of an existing event to update
* @param customServerPubkey Optional custom server pubkey to use
* @returns Promise resolving to the created/updated event
*/
public async createOrUpdate31120Event(
relayUrl: string,
content: string,
relays: string[],
expiryHours: number,
existingEventId?: string,
customServerPubkey?: string
): Promise<{ event: NostrEvent; serverNsec?: string } | null> {
try {
// Ensure we have an active connection
if (!this.relayService.isConnected() || this.relayService.getActiveRelayUrl() !== relayUrl) {
const connected = await this.relayService.connectToRelay(relayUrl);
if (!connected) {
throw new Error(`Could not connect to relay: ${relayUrl}`);
}
}
// Check if window.nostr (Nostr browser extension) is available
if (!window.nostr) {
throw new Error('No Nostr browser extension found. Please install a NIP-07 compatible extension.');
}
// Get user's public key using the extension
let userPubkey: string;
try {
userPubkey = await window.nostr!.getPublicKey();
if (!userPubkey) {
throw new Error('Failed to get public key from Nostr extension');
}
} catch (error) {
console.error('Error getting public key from Nostr extension:', error);
throw new Error('Failed to get your public key. Please make sure your Nostr extension is working.');
}
// Generate or use a provided server pubkey
let serverPubkey: string;
if (customServerPubkey) {
// Convert from npub format if needed
if (customServerPubkey.startsWith('npub')) {
try {
const decoded = nostrTools.nip19.decode(customServerPubkey);
if (decoded.type === 'npub') {
serverPubkey = decoded.data as string;
} else {
throw new Error('Invalid server pubkey format');
}
} catch (error) {
console.error('Error decoding server pubkey:', error);
throw new Error('Could not decode server pubkey');
}
} else {
serverPubkey = customServerPubkey;
}
} else {
// Generate a proper keypair using nostr-tools
const secretKey = nostrTools.generateSecretKey();
serverPubkey = nostrTools.getPublicKey(secretKey);
// Convert to nsec format for returning to the user
const nsec = nostrTools.nip19.nsecEncode(secretKey);
// Remember the generated nsec to return later
return this.processEventWithServerKey(relayUrl, userPubkey, serverPubkey, content, relays, expiryHours, nsec);
}
// Get the current timestamp
const createdAt = Math.floor(Date.now() / 1000);
// Calculate expiry time
const expiryTime = createdAt + (expiryHours * 3600);
// Create tags
const tags: string[][] = [];
// Add d tag with server pubkey
tags.push(['d', serverPubkey]);
// Add relay tags
for (const relay of relays) {
tags.push(['relay', relay]);
}
// Add expiry tag
tags.push(['expiry', expiryTime.toString()]);
// Create the unsigned event
const unsignedEvent = {
kind: 31120,
pubkey: userPubkey,
created_at: createdAt,
tags: tags,
content: content || 'HTTP-over-Nostr server',
};
// Sign the event using the browser extension
let signedEvent: NostrEvent;
try {
signedEvent = await window.nostr!.signEvent(unsignedEvent);
console.log('Event signed successfully with Nostr extension');
} catch (error) {
console.error('Error signing event with Nostr extension:', error);
throw new Error('Failed to sign the event with your Nostr extension');
}
// Publish to relay
const relayPool = this.relayService.getRelayPool();
if (!relayPool) {
throw new Error('Relay pool not available');
}
console.log('Publishing 31120 event to relay:', relayUrl);
try {
const pubs = relayPool.publish([relayUrl], signedEvent as any);
// Wait for the publish to complete with a timeout
await Promise.race([
Promise.all(pubs),
new Promise((_, reject) => setTimeout(() => reject(new Error('Publish timeout')), 5000))
]);
console.log('Published 31120 event successfully');
// Add to cache
this.cacheService.cacheEvents(relayUrl, [signedEvent]);
// Return just the event without nsec for cases where a custom pubkey is used
return { event: signedEvent };
} catch (error) {
console.error('Failed to publish event:', error);
throw new Error(`Failed to publish event to relay: ${error instanceof Error ? error.message : String(error)}`);
}
} catch (error) {
console.error('Error creating/updating 31120 event:', error);
throw error;
}
}
/**
* Helper method to process and publish an event with a generated server key
* @param relayUrl The relay URL to publish to
* @param userPubkey The user's public key
* @param serverPubkey The server's public key
* @param content The content/description
* @param relays Array of relay URLs to include
* @param expiryHours Hours until expiry
* @param serverNsec The generated server's private key in nsec format
* @returns Promise resolving to the event and server nsec
*/
private async processEventWithServerKey(
relayUrl: string,
userPubkey: string,
serverPubkey: string,
content: string,
relays: string[],
expiryHours: number,
serverNsec: string
): Promise<{ event: NostrEvent; serverNsec: string } | null> {
try {
// Get the current timestamp
const createdAt = Math.floor(Date.now() / 1000);
// Calculate expiry time
const expiryTime = createdAt + (expiryHours * 3600);
// Create tags
const tags: string[][] = [];
// Add d tag with server pubkey
tags.push(['d', serverPubkey]);
// Add relay tags
for (const relay of relays) {
tags.push(['relay', relay]);
}
// Add expiry tag
tags.push(['expiry', expiryTime.toString()]);
// Create the unsigned event
const unsignedEvent = {
kind: 31120,
pubkey: userPubkey,
created_at: createdAt,
tags: tags,
content: content || 'HTTP-over-Nostr server',
};
// Sign the event using the browser extension
let signedEvent: NostrEvent;
try {
signedEvent = await window.nostr!.signEvent(unsignedEvent);
console.log('Event signed successfully with Nostr extension');
} catch (error) {
console.error('Error signing event with Nostr extension:', error);
throw new Error('Failed to sign the event with your Nostr extension');
}
// Publish to relay
const relayPool = this.relayService.getRelayPool();
if (!relayPool) {
throw new Error('Relay pool not available');
}
console.log('Publishing 31120 event to relay:', relayUrl);
try {
const pubs = relayPool.publish([relayUrl], signedEvent as any);
// Wait for the publish to complete with a timeout
await Promise.race([
Promise.all(pubs),
new Promise((_, reject) => setTimeout(() => reject(new Error('Publish timeout')), 5000))
]);
console.log('Published 31120 event successfully');
// Add to cache
this.cacheService.cacheEvents(relayUrl, [signedEvent]);
// Return the event along with the server nsec
return {
event: signedEvent,
serverNsec: serverNsec
};
} catch (error) {
console.error('Failed to publish event:', error);
throw new Error(`Failed to publish event to relay: ${error instanceof Error ? error.message : String(error)}`);
}
} catch (error) {
console.error('Error in processEventWithServerKey:', error);
throw error;
}
}
}

@ -0,0 +1,107 @@
/**
* NostrCacheService.ts
* Handles caching of Nostr events and profile data
*/
// Project imports
import type { NostrEvent } from '../relay';
import { EventCache } from './EventCache';
// Interface for profile data
export interface ProfileData {
name?: string;
about?: string;
picture?: string;
nip05?: string;
[key: string]: unknown;
}
/**
* Class for managing Nostr caching functionality
*/
export class NostrCacheService {
private profileCache = new Map<string, ProfileData>();
private localStorageCachePrefix = 'nostr_31120_cache_';
private cacheExpiryTime = 5 * 60 * 1000; // 5 minutes
private eventsCache: EventCache;
/**
* Constructor
* @param cachePrefix Prefix for localStorage keys
* @param expiryTime Cache expiry time in milliseconds
*/
constructor(cachePrefix: string = 'nostr_31120_cache_', expiryTime: number = 5 * 60 * 1000) {
this.localStorageCachePrefix = cachePrefix;
this.cacheExpiryTime = expiryTime;
this.eventsCache = new EventCache(cachePrefix, expiryTime);
}
/**
* Cache 31120 events by relay URL
* @param relayUrl The relay URL used as cache key
* @param events Array of 31120 events to cache
*/
public cacheEvents(relayUrl: string, events: NostrEvent[]): void {
this.eventsCache.cacheEvents(relayUrl, events);
}
/**
* Get cached events for a relay
* @param relayUrl The relay URL to get cached events for
* @returns Array of cached events or null if cache is invalid/expired
*/
public getCachedEvents(relayUrl: string): NostrEvent[] | null {
return this.eventsCache.getEvents(relayUrl);
}
/**
* Cache a profile for a pubkey
* @param pubkey The pubkey to cache for
* @param profile The profile data to cache
*/
public cacheProfile(pubkey: string, profile: ProfileData): void {
this.profileCache.set(pubkey, profile);
}
/**
* Get a cached profile for a pubkey
* @param pubkey The pubkey to get profile for
* @returns The cached profile or null if not found
*/
public getCachedProfile(pubkey: string): ProfileData | null {
return this.profileCache.get(pubkey) || null;
}
/**
* Clear the events cache for a specific relay or all relays
* @param relayUrl Optional relay URL to clear cache for. If not provided, clears all caches.
*/
public clearEventsCache(relayUrl?: string): void {
if (relayUrl) {
this.eventsCache.clearCache(relayUrl);
} else {
this.eventsCache.clearCache();
}
}
/**
* Clear the profile cache for a specific pubkey or all profiles
* @param pubkey Optional pubkey to clear cache for. If not provided, clears all profiles.
*/
public clearProfileCache(pubkey?: string): void {
if (pubkey) {
this.profileCache.delete(pubkey);
} else {
this.profileCache.clear();
}
}
/**
* Clear all caches (events and profiles)
*/
public clearAllCaches(): void {
this.clearEventsCache();
this.clearProfileCache();
}
}

@ -0,0 +1,234 @@
/**
* NostrEventService.ts
* Handles event-specific operations for Nostr protocol
*/
// External imports
import * as nostrTools from 'nostr-tools';
// Project imports
import type { NostrEvent } from '../relay';
import type { NostrCacheService, ProfileData } from './NostrCacheService';
import type { NostrRelayService } from './NostrRelayService';
// Interface for a Nostr subscription
export interface NostrSubscription {
unsub: () => void;
}
// Interface for a received event
export interface ReceivedEvent {
id: string;
event: NostrEvent;
receivedAt: number;
decrypted: boolean;
decryptedContent?: string;
}
// Interface for Nostr filter
export interface NostrFilter {
kinds: number[];
'#p'?: string[];
authors?: string[];
since?: number;
until?: number;
limit?: number;
ids?: string[];
[key: string]: unknown;
}
/**
* Class for managing Nostr event operations
*/
export class NostrEventService {
private relayService: NostrRelayService;
private cacheService: NostrCacheService;
private eventHandler: ((receivedEvent: NostrEvent) => void) | null = null;
private statusCallback: ((statusMessage: string, statusClass: string) => void) | null = null;
/**
* Constructor
*/
constructor(
relayService: NostrRelayService,
cacheService: NostrCacheService,
statusCallback?: ((statusMessage: string, statusClass: string) => void)
) {
this.relayService = relayService;
this.cacheService = cacheService;
this.statusCallback = statusCallback || null;
}
/**
* Set the event handler for received events
*/
public setEventHandler(handler: ((receivedEvent: NostrEvent) => void)): void {
this.eventHandler = handler;
}
/**
* Subscribe to events with the given filter
*/
public async subscribeToEvents(filter: NostrFilter): Promise<NostrSubscription> {
const activeRelayUrl = this.relayService.getActiveRelayUrl();
if (!activeRelayUrl) {
throw new Error('No active relay URL');
}
this.updateStatus('Creating subscription...', 'connecting');
try {
const wsManager = this.relayService.getWebSocketManager();
await wsManager.connect(activeRelayUrl, {
timeout: 5000,
onOpen: (ws) => {
// Send a REQ message to subscribe
const reqId = `req-${Date.now()}`;
const reqMsg = JSON.stringify(["REQ", reqId, filter]);
ws.send(reqMsg);
this.updateStatus('Subscription active ✓', 'connected');
},
onMessage: (data) => {
// Type assertion for the received data
const nostrData = data as unknown[];
// Handle different message types
if (Array.isArray(nostrData) && nostrData[0] === "EVENT" && nostrData.length >= 3) {
const receivedEvent = nostrData[2] as NostrEvent;
// Process the event if we have a handler
if (this.eventHandler && receivedEvent.id) {
const callback = this.eventHandler;
callback(receivedEvent);
}
}
},
onError: () => {
this.updateStatus('WebSocket error', 'error');
},
onClose: () => {
this.updateStatus('Connection closed', 'error');
}
});
// Return a subscription object
return {
unsub: () => {
wsManager.close();
}
};
} catch (error) {
this.updateStatus(
`Subscription error: ${error instanceof Error ? error.message : String(error)}`,
'error'
);
throw error;
}
}
/**
* Create a filter for kind 21120 events
*/
public createKind21120Filter(showAllEvents: boolean): NostrFilter {
// Create filter for kind 21120 events
const filter: NostrFilter = {
kinds: [21120], // HTTP Messages event kind
};
// If "Show all events" is not checked, filter only for events addressed to the server
if (!showAllEvents) {
// Get the server pubkey from localStorage (set during server registration)
const serverNsec = localStorage.getItem('serverNsec');
let serverPubkey = null;
if (serverNsec) {
try {
// Decode the nsec to get the private key
const decoded = nostrTools.nip19.decode(serverNsec as string);
if (decoded.type === 'nsec') {
// Get the server pubkey from the private key
const privateKeyBytes = decoded.data as Uint8Array;
serverPubkey = nostrTools.getPublicKey(privateKeyBytes);
}
} catch (error) {
console.error('Error getting server pubkey:', error);
}
}
// Add p-tag filter for events addressed to the server
if (serverPubkey) {
filter['#p'] = [serverPubkey];
}
}
return filter;
}
/**
* Fetch profile data for a pubkey
*/
public async fetchProfileData(pubkey: string): Promise<ProfileData | null> {
// Return from cache if available
const cachedProfile = this.cacheService.getCachedProfile(pubkey);
if (cachedProfile) {
return cachedProfile;
}
// Placeholder for actual implementation
return null;
}
/**
* Query for a specific 31120 event
*/
public async queryFor31120Event(relayUrl: string, authorPubkey?: string | null): Promise<NostrEvent | null> {
console.log(`Querying for kind 31120 event by author: ${authorPubkey?.substring(0, 8) || 'any'}`);
try {
// Connect to relay if needed
if (!this.relayService.isConnected() || this.relayService.getActiveRelayUrl() !== relayUrl) {
await this.relayService.connectToRelay(relayUrl);
}
// Check the cache first
const cachedEvents = this.cacheService.getCachedEvents(relayUrl);
if (cachedEvents && cachedEvents.length > 0) {
// Filter cached events for kind 31120 and the author if specified
const filteredEvents = cachedEvents.filter(event => {
if (event.kind !== 31120) {
return false;
}
if (authorPubkey && event.pubkey !== authorPubkey) {
return false;
}
return true;
});
if (filteredEvents.length > 0) {
console.log(`Found ${filteredEvents.length} cached 31120 events`);
// Sort events by created_at (newest first) and return the first one
const sortedEvents = [...filteredEvents].sort((a, b) => b.created_at - a.created_at);
return sortedEvents[0];
}
}
// If we reach here, no events found in cache
return null;
} catch (error) {
console.error("Error querying for 31120 event:", error);
return null;
}
}
/**
* Update the status via callback if set
*/
private updateStatus(statusMessage: string, statusClass: string): void {
if (this.statusCallback) {
const callback = this.statusCallback;
callback(statusMessage, statusClass);
}
}
}

@ -0,0 +1,394 @@
/**
* NostrEventService.ts
* Handles event-specific operations for Nostr protocol
*/
// External imports
import * as nostrTools from 'nostr-tools';
// Project imports
import type { NostrEvent } from '../relay';
import type { NostrCacheService, ProfileData } from './NostrCacheService';
import type { NostrRelayService } from './NostrRelayService';
// Interface for a Nostr subscription
export interface NostrSubscription {
unsub: () => void;
}
// Interface for a received event
export interface ReceivedEvent {
id: string;
event: NostrEvent;
receivedAt: number;
decrypted: boolean;
decryptedContent?: string;
}
// Interface for Nostr filter
export interface NostrFilter {
kinds: number[];
'#p'?: string[];
authors?: string[];
since?: number;
until?: number;
limit?: number;
ids?: string[];
[key: string]: unknown;
}
/**
* Class for managing Nostr event operations
*/
export class NostrEventService {
private relayService: NostrRelayService;
private cacheService: NostrCacheService;
private eventHandler: ((event: NostrEvent) => void) | null = null;
private statusCallback: ((message: string, className: string) => void) | null = null;
/**
* Constructor
*/
constructor(
relayService: NostrRelayService,
cacheService: NostrCacheService,
statusCallback?: ((message: string, className: string) => void)
) {
this.relayService = relayService;
this.cacheService = cacheService;
this.statusCallback = statusCallback || null;
}
/**
* Set the event handler for received events
*/
public setEventHandler(handler: ((event: NostrEvent) => void)): void {
this.eventHandler = handler;
}
/**
* Subscribe to events with the given filter
*/
public async subscribeToEvents(filter: NostrFilter): Promise<NostrSubscription> {
const activeRelayUrl = this.relayService.getActiveRelayUrl();
if (!activeRelayUrl) {
throw new Error('No active relay URL');
}
this.updateStatus('Creating subscription...', 'connecting');
try {
const wsManager = this.relayService.getWebSocketManager();
await wsManager.connect(activeRelayUrl, {
timeout: 5000,
onOpen: (ws) => {
// Send a REQ message to subscribe
const reqId = `req-${Date.now()}`;
const reqMsg = JSON.stringify(["REQ", reqId, filter]);
ws.send(reqMsg);
this.updateStatus('Subscription active ✓', 'connected');
},
onMessage: (data) => {
// Type assertion for the received data
const nostrData = data as unknown[];
// Handle different message types
if (Array.isArray(nostrData) && nostrData[0] === "EVENT" && nostrData.length >= 3) {
const receivedEvent = nostrData[2] as NostrEvent;
// Process the event if we have a handler
if (this.eventHandler && receivedEvent.id) {
const callback = this.eventHandler;
callback(receivedEvent);
}
}
},
onError: () => {
this.updateStatus('WebSocket error', 'error');
},
onClose: () => {
this.updateStatus('Connection closed', 'error');
}
});
// Return a subscription object
return {
unsub: () => {
wsManager.close();
}
};
} catch (error) {
this.updateStatus(
`Subscription error: ${error instanceof Error ? error.message : String(error)}`,
'error'
);
throw error;
}
}
/**
* Create a filter for kind 21120 events
*/
public createKind21120Filter(showAllEvents: boolean): NostrFilter {
// Create filter for kind 21120 events
const filter: NostrFilter = {
kinds: [21120], // HTTP Messages event kind
};
// If "Show all events" is not checked, filter only for events addressed to the server
if (!showAllEvents) {
// Get the server pubkey from localStorage (set during server registration)
const serverNsec = localStorage.getItem('serverNsec');
let serverPubkey = null;
if (serverNsec) {
try {
// Decode the nsec to get the private key
const decoded = nostrTools.nip19.decode(serverNsec as string);
if (decoded.type === 'nsec') {
// Get the server pubkey from the private key
const privateKeyBytes = decoded.data as Uint8Array;
serverPubkey = nostrTools.getPublicKey(privateKeyBytes);
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error getting server pubkey:', error);
}
}
// Add p-tag filter for events addressed to the server
if (serverPubkey) {
filter['#p'] = [serverPubkey];
}
}
return filter;
}
/**
* Fetch profile data for a pubkey
*/
public async fetchProfileData(pubkey: string): Promise<ProfileData | null> {
// Return from cache if available
const cachedProfile = this.cacheService.getCachedProfile(pubkey);
if (cachedProfile) {
return cachedProfile;
}
// Placeholder for actual implementation
return null;
}
/**
* Query for a specific 31120 event
*/
public async queryFor31120Event(relayUrl: string, authorPubkey?: string | null): Promise<NostrEvent | null> {
console.log(`Querying for kind 31120 event by author: ${authorPubkey?.substring(0, 8) || 'any'}`);
try {
// Connect to relay if needed
if (!this.relayService.isConnected() || this.relayService.getActiveRelayUrl() !== relayUrl) {
await this.relayService.connectToRelay(relayUrl);
}
// Check the cache first
const cachedEvents = this.cacheService.getCachedEvents(relayUrl);
if (cachedEvents && cachedEvents.length > 0) {
// Filter cached events for kind 31120 and the author if specified
const filteredEvents = cachedEvents.filter(event => {
if (event.kind !== 31120) return false;
if (authorPubkey && event.pubkey !== authorPubkey) return false;
return true;
});
if (filteredEvents.length > 0) {
console.log(`Found ${filteredEvents.length} cached 31120 events`);
// Sort events by created_at (newest first) and return the first one
const sortedEvents = [...filteredEvents].sort((a, b) => b.created_at - a.created_at);
return sortedEvents[0];
}
}
// Create filter for kind 31120 events
const filter: NostrFilter = {
kinds: [31120],
limit: 10
};
// Add author filter if provided
if (authorPubkey) {
filter.authors = [authorPubkey];
}
// Set a timeout for the query
const timeoutMs = 5000;
const timeoutPromise = new Promise<NostrEvent[]>((_, reject) => {
setTimeout(() => reject(new Error(`Query timed out after ${timeoutMs}ms`)), timeoutMs);
});
// Create a promise that will resolve with the query results
const queryPromise = new Promise<NostrEvent[]>((resolve) => {
const events: NostrEvent[] = [];
const reqId = `req-${Date.now()}`;
// Create a new connection with custom message handler
(async () => {
try {
// Ensure we're connected to the relay
const connected = await this.relayService.connectToRelay(relayUrl);
if (!connected) {
resolve([]);
return;
}
// Get the WebSocket manager
const wsManager = this.relayService.getWebSocketManager();
// Connect with custom message handler
await wsManager.connect(relayUrl, {
onMessage: (data: unknown) => {
try {
const message = data as unknown[];
if (Array.isArray(message) && message[0] === "EVENT" && message[1] === reqId) {
// Add the event to our results
const event = message[2] as NostrEvent;
events.push(event);
} else if (Array.isArray(message) && message[0] === "EOSE" && message[1] === reqId) {
// End of stored events, resolve the promise
resolve(events);
}
} catch (error) {
console.error("Error processing message:", error);
}
}
});
// Send the query to the relay
const reqMsg = JSON.stringify(["REQ", reqId, filter]);
wsManager.send(reqMsg);
} catch (error) {
console.error("Error setting up event query:", error);
resolve([]);
}
})();
});
// Execute the query with a timeout
const events = await Promise.race([queryPromise, timeoutPromise]);
// Cache the results
if (events.length > 0) {
this.cacheService.cacheEvents(relayUrl, events);
}
console.log(`Found ${events.length} 31120 events from relay`);
// Return null if no events were found, otherwise the most recent event
if (events.length === 0) {
return null;
}
// Sort events by created_at (newest first) and return the first one
const sortedEvents = [...events].sort((a, b) => b.created_at - a.created_at);
return sortedEvents[0];
} catch (error) {
console.error("Error querying for 31120 event:", error);
return null;
}
}
/**
* Query for all 31120 events
*/
public async queryForAll31120Events(relayUrl: string): Promise<NostrEvent[]> {
console.log(`Querying for all kind 31120 events from ${relayUrl}`);
try {
// Connect to relay if needed
if (!this.relayService.isConnected() || this.relayService.getActiveRelayUrl() !== relayUrl) {
await this.relayService.connectToRelay(relayUrl);
}
// Check the cache first
const cachedEvents = this.cacheService.getCachedEvents(relayUrl);
if (cachedEvents && cachedEvents.length > 0) {
// Filter cached events for kind 31120
const filteredEvents = cachedEvents.filter(event => event.kind === 31120);
if (filteredEvents.length > 0) {
console.log(`Using ${filteredEvents.length} cached 31120 events`);
return filteredEvents;
}
}
// Create filter for all kind 31120 events
const filter: NostrFilter = {
kinds: [31120],
limit: 100 // Reasonable limit for server advertisements
};
// Set a timeout for the query
const timeoutMs = 8000;
const timeoutPromise = new Promise<NostrEvent[]>((_, reject) => {
setTimeout(() => reject(new Error(`Query timed out after ${timeoutMs}ms`)), timeoutMs);
});
// Create a promise that will resolve with the query results
const queryPromise = new Promise<NostrEvent[]>((resolve) => {
const events: NostrEvent[] = [];
const reqId = `req-${Date.now()}`;
// Create a new connection with custom message handler
(async () => {
try {
// Ensure we're connected to the relay
const connected = await this.relayService.connectToRelay(relayUrl);
if (!connected) {
resolve([]);
return;
}
// Get the WebSocket manager
const wsManager = this.relayService.getWebSocketManager();
// Connect with custom message handler
await wsManager.connect(relayUrl, {
onMessage: (data: unknown) => {
try {
const message = data as unknown[];
if (Array.isArray(message) && message[0] === "EVENT" && message[1] === reqId) {
// Add the event to our results
const event = message[2] as NostrEvent;
events.push(event);
} else if (Array.isArray(message) && message[0] === "EOSE" && message[1] === reqId) {
// End of stored events, resolve the promise
resolve(events);
}
} catch (error) {
console.error("Error processing message:", error);
}
}
});
// Send the query to the relay
const reqMsg = JSON.stringify(["REQ", reqId, filter]);
wsManager.send(reqMsg);
} catch (error) {
console.error("Error setting up event query:", error);
resolve([]);
}
})();
});
// Execute the query with a timeout
let events: NostrEvent[];
try {
events = await Promise.race([queryPromise, timeoutPromise]);
} catch (error) {
console.error("Query timed out or failed:", error);
// Return cached events if any
return cachedEvents?.filter(event => event.kind === 31120) || [];
}
// Filter out expired events

@ -0,0 +1,115 @@
/**
* NostrRelayService.ts
* Handles relay connection management for Nostr protocol
*/
// External imports
import * as nostrTools from 'nostr-tools';
// Project imports
import { WebSocketManager } from './WebSocketManager';
/**
* Class for managing Nostr relay connections
*/
export class NostrRelayService {
private relayPool: nostrTools.SimplePool | null = null;
private wsManager = new WebSocketManager();
private activeRelayUrl: string | null = null;
// eslint-disable-next-line no-unused-vars
private statusCallback: ((message: string, className: string) => void) | null = null;
/**
* Constructor
* @param statusCallback Callback for status updates
*/
// eslint-disable-next-line no-unused-vars
constructor(statusCallback?: ((message: string, className: string) => void)) {
this.statusCallback = statusCallback || null;
}
/**
* Connect to a relay
* @param relayUrl The relay URL to connect to
* @returns A promise that resolves to true if connected successfully
*/
public async connectToRelay(relayUrl: string): Promise<boolean> {
try {
this.updateStatus('Connecting to relay...', 'connecting');
// Test the connection first
const connectionSuccess = await this.wsManager.testConnection(relayUrl);
if (!connectionSuccess) {
this.updateStatus('Connection failed', 'error');
return false;
}
// Close existing relay pool if any
if (this.relayPool && this.activeRelayUrl) {
try {
await this.relayPool.close([this.activeRelayUrl]);
} catch {
// Ignore errors when closing
}
this.relayPool = null;
}
// Create new relay pool
this.relayPool = new nostrTools.SimplePool();
this.activeRelayUrl = relayUrl;
this.updateStatus('Connected', 'connected');
return true;
} catch (error) {
this.updateStatus(
`Error: ${error instanceof Error ? error.message : String(error)}`,
'error'
);
return false;
}
}
/**
* Check if the service is connected to a relay
* @returns true if connected, false otherwise
*/
public isConnected(): boolean {
return this.relayPool !== null && this.activeRelayUrl !== null && this.wsManager.isConnected();
}
/**
* Get the WebSocket manager instance
* @returns The WebSocket manager
*/
public getWebSocketManager(): WebSocketManager {
return this.wsManager;
}
/**
* Get the relay pool instance
* @returns The relay pool
*/
public getRelayPool(): nostrTools.SimplePool | null {
return this.relayPool;
}
/**
* Get the active relay URL
* @returns The active relay URL
*/
public getActiveRelayUrl(): string | null {
return this.activeRelayUrl;
}
/**
* Update the status via callback if set
* @param message The status message
* @param className The CSS class name for styling
*/
private updateStatus(message: string, className: string): void {
if (this.statusCallback) {
const callback = this.statusCallback;
callback(message, className);
}
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,220 @@
/**
* NostrUtils.ts
* Utility functions for Nostr protocol operations
*/
// External imports
import * as nostrTools from 'nostr-tools';
// Project imports
import type { NostrEvent } from '../relay';
import { convertNpubToHex } from '../relay';
/**
* Interface for profile data
*/
export interface ProfileData {
name?: string;
about?: string;
picture?: string;
nip05?: string;
[key: string]: unknown;
}
/**
* Fetch a user's profile metadata from a relay
*/
export async function fetchUserProfile(pubkey: string, relays: string[] = ['wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol']): Promise<ProfileData | null> {
try {
// Prepare the filter for kind-0 events (profile metadata)
const filter = {
kinds: [0],
authors: [pubkey],
limit: 1
};
// Create unique request ID
const requestId = `profile-${Math.random().toString(36).substring(2, 10)}`;
const subRequest = ["REQ", requestId, filter];
// Store events received
const events: NostrEvent[] = [];
let connected = false;
// Try each relay until we get a response
for (const relayUrl of relays) {
if (connected) {
break;
}
try {
await new Promise<void>((resolve, reject) => {
const ws = new WebSocket(relayUrl);
const timeout = setTimeout(() => {
try { ws.close(); } catch {}
reject(new Error('Timeout'));
}, 3000);
ws.onopen = () => {
// Send subscription request for profile
ws.send(JSON.stringify(subRequest));
};
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data);
// If we receive an EVENT message with our request ID
if (Array.isArray(data) && data[0] === 'EVENT' && data[1] === requestId) {
events.push(data[2] as NostrEvent);
connected = true;
clearTimeout(timeout);
ws.close();
resolve();
}
// If we receive EOSE (End of Stored Events)
else if (Array.isArray(data) && data[0] === 'EOSE' && data[1] === requestId) {
clearTimeout(timeout);
ws.close();
resolve();
}
} catch {
// Ignore parsing errors
}
};
ws.onerror = () => {
clearTimeout(timeout);
reject();
};
ws.onclose = () => {
clearTimeout(timeout);
resolve();
};
});
} catch {
// Continue to next relay on error
}
}
// Process the events if we found any
if (events.length > 0) {
try {
// Parse the content as JSON to get profile data
return JSON.parse(events[0].content) as ProfileData;
} catch {
return null;
}
}
} catch {
// Return null on any error
}
return null;
}
/**
* Get the server pubkey from an event
*/
export function getServerPubkeyFromEvent(event: NostrEvent): string | null {
const dTag = event.tags.find(tag => tag[0] === 'd');
if (dTag && dTag.length > 1) {
return dTag[1];
}
return null;
}
/**
* Check if a server event is expired
*/
export function isServerEventExpired(event: NostrEvent): boolean {
const expiryTag = event.tags.find(tag => tag[0] === 'expiry');
if (expiryTag && expiryTag.length > 1) {
try {
const expiryTime = parseInt(expiryTag[1]);
const now = Math.floor(Date.now() / 1000);
return expiryTime < now;
} catch {
return false;
}
}
return false;
}
/**
* Convert a potentially npub pubkey to hex format
*/
export function ensureHexPubkey(pubkeyInput: string): string {
// Already a valid hex pubkey
if (/^[0-9a-f]{64}$/i.test(pubkeyInput)) {
return pubkeyInput;
}
// Try to convert from npub
if (pubkeyInput.startsWith('npub')) {
const hexPubkey = convertNpubToHex(pubkeyInput);
if (hexPubkey) {
return hexPubkey;
}
}
// Return original if conversion fails
return pubkeyInput;
}
/**
* Attempt to get a user's public key from NIP-07 extension
*/
export async function getUserPubkey(): Promise<string | null> {
try {
// Try to get pubkey from window.nostr
if (window.nostr && typeof window.nostr.getPublicKey === 'function') {
const pubkey = await window.nostr.getPublicKey();
if (pubkey) {
localStorage.setItem('userPublicKey', pubkey);
return pubkey;
}
}
// Fall back to localStorage
return localStorage.getItem('userPublicKey');
} catch {
return localStorage.getItem('userPublicKey');
}
}
/**
* Create a relay connection filter for different types of events
*/
export function createEventFilter(kind: number, options: {
authors?: string[],
since?: number,
until?: number,
limit?: number,
pubkeyTag?: string
} = {}): Record<string, unknown> {
const filter: Record<string, unknown> = { kinds: [kind] };
if (options.authors && options.authors.length > 0) {
filter.authors = options.authors;
}
if (options.since) {
filter.since = options.since;
}
if (options.until) {
filter.until = options.until;
}
if (options.limit) {
filter.limit = options.limit;
}
if (options.pubkeyTag) {
filter['#p'] = [options.pubkeyTag];
}
return filter;
}

@ -0,0 +1,30 @@
/**
* RelayStatusManager.ts
* Component for managing relay connection status UI updates
*/
/**
* Class for managing relay status in the UI
*/
export class RelayStatusManager {
private relayStatus: HTMLElement | null = null;
/**
* Initialize the relay status element
*/
public initialize(): void {
this.relayStatus = document.getElementById('relayStatus');
}
/**
* Update the relay status display
* @param message Status message
* @param className CSS class for styling (connected, error, etc.)
*/
public updateRelayStatus(message: string, className: string): void {
if (this.relayStatus) {
this.relayStatus.textContent = message;
this.relayStatus.className = `relay-status ${className}`;
}
}
}

@ -0,0 +1,159 @@
/**
* ToastNotifier.ts
* Service for showing toast notifications to the user
*/
/**
* Type for notification types with associated styling
*/
type NotificationType = 'success' | 'error' | 'warning' | 'info';
/**
* Class for showing toast notifications
*/
export class ToastNotifier {
private static toastContainer: HTMLElement | null = null;
private static initialized = false;
/**
* Initialize the toast container
*/
private static initialize(): void {
if (this.initialized) return;
this.toastContainer = document.getElementById('toast-container');
// Create container if it doesn't exist
if (!this.toastContainer) {
this.toastContainer = document.createElement('div');
this.toastContainer.id = 'toast-container';
this.toastContainer.className = 'toast-container';
document.body.appendChild(this.toastContainer);
// Add styles if not already present
if (!document.getElementById('toast-styles')) {
const style = document.createElement('style');
style.id = 'toast-styles';
style.textContent = `
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 300px;
}
.toast {
padding: 12px 16px;
border-radius: 4px;
color: white;
font-size: 14px;
opacity: 0;
transform: translateX(50px);
transition: opacity 0.3s, transform 0.3s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
position: relative;
margin-bottom: 8px;
}
.toast.visible {
opacity: 1;
transform: translateX(0);
}
.toast.success {
background-color: #4CAF50;
}
.toast.error {
background-color: #F44336;
}
.toast.warning {
background-color: #FF9800;
}
.toast.info {
background-color: #2196F3;
}
.toast-close {
position: absolute;
top: 8px;
right: 8px;
cursor: pointer;
font-size: 16px;
opacity: 0.7;
}
.toast-close:hover {
opacity: 1;
}
`;
document.head.appendChild(style);
}
}
this.initialized = true;
}
/**
* Show a toast notification
* @param message The message to show
* @param type The type of notification
* @param duration The duration in milliseconds
*/
public static show(message: string, type: NotificationType = 'info', duration = 5000): void {
this.initialize();
if (!this.toastContainer) return;
// Create toast element
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
${message}
<span class="toast-close">&times;</span>
`;
// Add close button handler
const closeBtn = toast.querySelector('.toast-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
this.removeToast(toast);
});
}
// Add to container
this.toastContainer.appendChild(toast);
// Trigger reflow to ensure transition works
void toast.offsetWidth;
// Show the toast
toast.classList.add('visible');
// Set timeout to remove
setTimeout(() => {
this.removeToast(toast);
}, duration);
}
/**
* Remove a toast from the container
* @param toast The toast element to remove
*/
private static removeToast(toast: HTMLElement): void {
toast.classList.remove('visible');
// Wait for transition to complete
setTimeout(() => {
if (toast.parentNode === this.toastContainer) {
this.toastContainer?.removeChild(toast);
}
}, 300);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -5,12 +5,9 @@
export interface WebSocketOptions {
timeout?: number;
// eslint-disable-next-line no-unused-vars
onOpen?: (socket: WebSocket) => void;
// eslint-disable-next-line no-unused-vars
onMessage?: (data: unknown) => void;
// eslint-disable-next-line no-unused-vars
onError?: (error: Event) => void;
onOpen?: (ws: WebSocket) => void;
onMessage?: (parsedData: unknown) => void;
onError?: (evt: Event) => void;
onClose?: () => void;
}
@ -21,20 +18,15 @@ export class WebSocketManager {
/**
* Create a WebSocket connection
* @param url The WebSocket URL to connect to
* @param options Options for the WebSocket connection
* @returns A promise that resolves when the connection is established
*/
public async connect(url: string, options: WebSocketOptions = {}): Promise<WebSocket> {
return new Promise<WebSocket>((resolve, reject) => {
// Close existing connection if any
this.close();
this.url = url;
this.ws = new WebSocket(url);
this.connected = false;
// Set a timeout to avoid hanging
const timeout = setTimeout(() => {
if (!this.connected) {
this.close();
@ -42,16 +34,12 @@ export class WebSocketManager {
}
}, options.timeout || 5000);
// Set up event handlers
this.ws.onopen = () => {
clearTimeout(timeout);
this.connected = true;
if (options.onOpen) {
const callback = options.onOpen;
callback(this.ws as WebSocket);
options.onOpen(this.ws as WebSocket);
}
resolve(this.ws as WebSocket);
};
@ -59,8 +47,7 @@ export class WebSocketManager {
if (options.onMessage && typeof msg.data === 'string') {
try {
const parsedData = JSON.parse(msg.data);
const callback = options.onMessage;
callback(parsedData);
options.onMessage(parsedData);
} catch {
// Ignore parsing errors
}
@ -69,12 +56,9 @@ export class WebSocketManager {
this.ws.onerror = (errorEvt) => {
clearTimeout(timeout);
if (options.onError) {
const callback = options.onError;
callback(errorEvt);
options.onError(errorEvt);
}
if (!this.connected) {
reject(new Error(`WebSocket error: ${errorEvt.toString()}`));
}
@ -83,7 +67,6 @@ export class WebSocketManager {
this.ws.onclose = () => {
clearTimeout(timeout);
this.connected = false;
if (options.onClose) {
options.onClose();
}
@ -93,9 +76,6 @@ export class WebSocketManager {
/**
* Test a WebSocket connection without creating a persistent connection
* @param url The WebSocket URL to test
* @param timeout Timeout in milliseconds
* @returns A promise that resolves when the connection test is complete
*/
public async testConnection(url: string, timeout = 5000): Promise<boolean> {
try {
@ -131,8 +111,6 @@ export class WebSocketManager {
/**
* Send data through the WebSocket
* @param data The data to send
* @returns true if sent successfully, false otherwise
*/
public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): boolean {
if (!this.ws || !this.connected) {

78
client/src/theme-utils.ts Normal file

@ -0,0 +1,78 @@
/**
* theme-utils.ts
* Utility functions for theme management
*/
/**
* Initialize the theme based on localStorage settings
* This should be called on page load
*/
export function initializeTheme(): void {
console.log('Initializing theme from localStorage');
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.body.setAttribute('data-theme', 'dark');
console.log('Set dark theme from localStorage');
// Remove any inline background-color style that might override CSS variables
document.body.style.removeProperty('background-color');
} else {
document.body.removeAttribute('data-theme');
console.log('Set light theme (default) from localStorage or no setting');
}
// Update the theme icon if it exists
updateThemeIcon();
}
/**
* Toggle between light and dark themes
*/
export function toggleTheme(): void {
const body = document.body;
const isDarkMode = body.getAttribute('data-theme') === 'dark';
console.log('Toggling theme, current is dark:', isDarkMode);
if (isDarkMode) {
// Switch to light theme
body.removeAttribute('data-theme');
localStorage.setItem('theme', 'light');
console.log('Switched to light theme');
} else {
// Switch to dark theme
body.setAttribute('data-theme', 'dark');
localStorage.setItem('theme', 'dark');
console.log('Switched to dark theme');
}
// Remove any inline background-color style that might override CSS variables
body.style.removeProperty('background-color');
// Update the theme icon
updateThemeIcon();
}
/**
* Update the theme icon based on current theme
*/
export function updateThemeIcon(): void {
const isDarkMode = document.body.getAttribute('data-theme') === 'dark';
const themeIcon = document.getElementById('themeIcon');
if (themeIcon) {
themeIcon.textContent = isDarkMode ? '☀️' : '🌙';
console.log('Updated theme icon to:', themeIcon.textContent);
} else {
console.log('Theme icon element not found');
}
}
// Initialize the theme automatically when this file is imported
document.addEventListener('DOMContentLoaded', initializeTheme);
// Add a fallback initialization for cases where DOMContentLoaded already fired
if (document.readyState === 'complete' || document.readyState === 'interactive') {
console.log('Document already loaded, initializing theme immediately');
initializeTheme();
}

38
client/src/types/window.d.ts vendored Normal file

@ -0,0 +1,38 @@
// Type definitions for window.nostr (NIP-07 browser extension)
import type { NostrEvent } from '../relay';
// Define the extension interfaces here to avoid conflicts
interface WindowNostrExtension {
/**
* Get the user's public key from the Nostr extension
* @returns The user's public key as a hex string
*/
getPublicKey: () => Promise<string>;
/**
* Sign an event using the Nostr extension
* @param event The unsigned event to sign
* @returns The signed event
*/
signEvent: (event: Partial<NostrEvent>) => Promise<NostrEvent>;
/**
* NIP-44 encryption methods (may not be available in all extensions)
*/
nip44?: {
encrypt(plaintext: string, pubkey: string): Promise<string>;
decrypt(ciphertext: string, pubkey: string): Promise<string>;
};
}
// We don't want to modify the original declaration in converter.ts,
// so we'll augment it with our own declaration here
declare global {
// Augment the Window interface but don't redefine the nostr property
interface Window {
// This is intentionally left empty
}
}
// This file is a module declaration
export {};

@ -132,4 +132,8 @@ export function showSuccess(element: HTMLElement, message: string): void {
export function showLoading(element: HTMLElement, message: string = 'Processing...'): void {
element.innerHTML = `<span>${message}</span>`;
element.style.display = 'block';
}
}
/**
* Theme functionality has been moved to theme-utils.ts
* Import { toggleTheme } from './theme-utils' instead
*/

@ -0,0 +1,319 @@
/**
* Crypto utilities for WebCrypto operations, NIP-44 encryption, and key utilities
* Extracted for better modularity
*/
// Import specific utilities from nostr-tools
import { nip19 } from 'nostr-tools';
/**
* Validates an npub and returns the hex pubkey
* @param npub The npub to validate and convert
* @returns The hex pubkey
* @throws Error if npub is invalid
*/
export function npubToHex(npub: string): string {
// Require npub format to ensure checksum validation
if (!npub.startsWith('npub')) {
throw new Error('Pubkey must be in npub format for validation');
}
try {
const { type, data } = nip19.decode(npub);
if (type !== 'npub') {
throw new Error(`Not an npub: decoded type is ${type}`);
}
// Return the hex pubkey without length assumptions
return data as string;
} catch (error) {
throw new Error(`Invalid npub: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Encrypt data using Web Crypto API with AES-GCM
* @param plaintext Text content to encrypt
* @param key Encryption key (will be hashed with SHA-256)
* @returns Promise resolving to base64-encoded encrypted data
*/
export async function encryptWithWebCrypto(plaintext: string, key: string): Promise<string> {
try {
// Generate a random 12-byte IV
const iv = crypto.getRandomValues(new Uint8Array(12));
// Create key material from the encryption key
const keyMaterial = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(key)
);
// Import the key for AES-GCM encryption
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyMaterial,
{ name: 'AES-GCM' },
false,
['encrypt']
);
// Encrypt the data
const encryptedData = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
cryptoKey,
new TextEncoder().encode(plaintext)
);
// Combine IV and ciphertext
const encryptedBytes = new Uint8Array(iv.length + new Uint8Array(encryptedData).length);
encryptedBytes.set(iv, 0);
encryptedBytes.set(new Uint8Array(encryptedData), iv.length);
// Return base64 encoded encrypted data
return btoa(String.fromCharCode(...encryptedBytes));
} catch (error) {
throw new Error(`WebCrypto encryption failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Decrypt data using Web Crypto API with AES-GCM
* @param encryptedBase64 Base64-encoded encrypted data
* @param key Decryption key (will be hashed with SHA-256)
* @returns Promise resolving to decrypted plaintext
*/
export async function decryptWithWebCrypto(encryptedBase64: string, key: string): Promise<string> {
try {
// Convert base64 to byte array
const encryptedBytes = new Uint8Array(
atob(encryptedBase64)
.split('')
.map(char => char.charCodeAt(0))
);
// Extract IV (first 12 bytes)
const iv = encryptedBytes.slice(0, 12);
// Extract ciphertext (remaining bytes)
const ciphertext = encryptedBytes.slice(12);
// Create key material from the decryption key
const keyMaterial = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(key)
);
// Import the key for AES-GCM decryption
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyMaterial,
{ name: 'AES-GCM' },
false,
['decrypt']
);
// Decrypt the data
const decryptedData = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
cryptoKey,
ciphertext
);
// Convert decrypted data to string
return new TextDecoder().decode(decryptedData);
} catch (error) {
throw new Error(`WebCrypto decryption failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Encrypt a key using NIP-44 via window.nostr extension
* This is specifically for encrypting keys, not large content
*
* @param plaintext The encryption key to encrypt
* @param pubkey Target public key in hex format
* @returns Promise resolving to NIP-44 encrypted key
*/
export async function encryptKeyWithNostrExtension(plaintext: string, pubkey: string): Promise<string> {
try {
// Convert to hex if it's an npub
const hexPubkey = pubkey.startsWith('npub') ? npubToHex(pubkey) : pubkey;
// Check if window.nostr.nip44 is available
if (!window.nostr || typeof window.nostr.nip44 !== 'object' || typeof window.nostr.nip44.encrypt !== 'function') {
throw new Error("NIP-44 encryption not available - connect to a compatible NIP-07 extension");
}
// Encrypt using NIP-44 through the extension
// The browser extension is expected to handle key derivation and encryption
const encryptedKey = await window.nostr.nip44.encrypt(plaintext, hexPubkey);
return encryptedKey;
} catch (error) {
throw new Error(`NIP-44 encryption failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Decrypt a key using NIP-44 via window.nostr extension
* This is specifically for decrypting keys, not large content
*
* @param ciphertext The encrypted key
* @param pubkey Source public key (npub or hex)
* @returns Promise resolving to decrypted key
*/
export async function decryptKeyWithNostrExtension(ciphertext: string, pubkey: string): Promise<string> {
try {
// Convert to hex if it's an npub
const hexPubkey = pubkey.startsWith('npub') ? npubToHex(pubkey) : pubkey;
// Validate that it's a proper pubkey format (64 hex chars)
if (!/^[0-9a-f]{64}$/i.test(hexPubkey)) {
console.error("Invalid pubkey format for NIP-44 decryption:", {
originalPubkey: pubkey.substring(0, 10) + '...',
convertedHexPubkey: hexPubkey.substring(0, 10) + '...',
length: hexPubkey.length
});
throw new Error(`Invalid pubkey format: ${hexPubkey.substring(0, 10)}... (length: ${hexPubkey.length})`);
}
// Check if window.nostr.nip44 is available
if (!window.nostr || typeof window.nostr.nip44 !== 'object' || typeof window.nostr.nip44.decrypt !== 'function') {
throw new Error("NIP-44 decryption not available - connect to a compatible NIP-07 extension");
}
console.log("Using pubkey for NIP-44 decryption with Nostr extension:", {
hexPubkey: hexPubkey.substring(0, 10) + '...',
length: hexPubkey.length,
ciphertextLength: ciphertext.length
});
// Decrypt using NIP-44 through the extension
// The browser extension is expected to handle key derivation and decryption
const decryptedKey = await window.nostr.nip44.decrypt(ciphertext, hexPubkey);
return decryptedKey;
} catch (error) {
throw new Error(`NIP-44 decryption failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Decrypt a key using NIP-44 with nostr-tools library directly
* This is specifically for server-side decryption using the server's private key
*
* @param ciphertext The encrypted key
* @param serverNsec Server's private key in nsec format
* @param clientPubkey Optional client's public key for conversation key derivation
* @returns Promise resolving to decrypted key
*/
export function decryptKeyWithNostrTools(ciphertext: string, serverNsec: string, clientPubkey?: string): Promise<string> {
return new Promise<string>(async (resolve, reject) => {
try {
if (!serverNsec.startsWith('nsec')) {
reject(new Error("Server key must be in nsec format for decryption"));
return;
}
// Import nostr-tools library
const nostrTools = await import('nostr-tools');
// Check if NIP-44 implementation is available in nostr-tools
if (!nostrTools.nip44) {
reject(new Error("NIP-44 not available in nostr-tools - this may need to be implemented"));
return;
}
// Decode the nsec to get the raw private key
const decoded = nostrTools.nip19.decode(serverNsec);
if (decoded.type !== 'nsec') {
reject(new Error(`Not a private key: decoded type is ${decoded.type}`));
return;
}
// Handle the different types correctly
const privateKeyBytes = decoded.data as Uint8Array;
const privateKeyHex = Buffer.from(privateKeyBytes).toString('hex');
// Get the server's public key
// Use privateKeyBytes directly which is what nostrTools.getPublicKey expects
const serverPubkey = nostrTools.getPublicKey(privateKeyBytes);
// Check if we have the client's public key for proper conversation key derivation
if (clientPubkey) {
console.log("Using client pubkey for conversation key derivation:", clientPubkey.substring(0, 8) + "...");
try {
// Convert to hex if it's an npub
const clientPubkeyHex = clientPubkey.startsWith('npub')
? (nostrTools.nip19.decode(clientPubkey).data as string)
: clientPubkey;
// Follow the NIP-44 example with conversation keys
console.log("Deriving conversation key between server and client");
// getConversationKey actually takes bytes for the private key
// The second parameter can be a string for the pubkey
const conversationKey = await nostrTools.nip44.getConversationKey(
privateKeyBytes, // Server private key as bytes
clientPubkeyHex // Client public key as hex string
);
console.log("Generated conversation key for decryption");
// decrypt signature: function decrypt(payload, conversationKey)
// Only need payload and conversationKey
const decryptedMessage = await nostrTools.nip44.decrypt(
ciphertext, // The encrypted payload
conversationKey // The conversation key we derived
);
console.log("Successfully decrypted key with NIP-44 conversation key");
resolve(decryptedMessage);
return;
} catch (conversationError) {
console.error("Conversation key approach failed:", conversationError);
// Fall through to direct decryption as fallback
}
}
// If client pubkey not available or conversation key approach failed,
// try special case handling for short keys that might not be encrypted
if (ciphertext.length <= 32 && ciphertext.length > 0) {
console.log("Short ciphertext detected, might be an unencrypted key:", ciphertext);
resolve(ciphertext);
return;
}
// Otherwise, we need to create a self-conversation key with the server's key for both sides
console.log("Falling back to self-conversation key for decryption");
try {
// Get a conversation key where server talks to itself
const selfConversationKey = await nostrTools.nip44.getConversationKey(
privateKeyBytes, // Server private key as bytes
serverPubkey // Server's own public key
);
// Try decrypting with this self-conversation key
const decryptedKey = await nostrTools.nip44.decrypt(
ciphertext,
selfConversationKey
);
console.log("Successfully decrypted key with self-conversation key");
resolve(decryptedKey);
} catch (decryptError) {
console.error("Self-conversation key decryption failed:", decryptError);
// Last resort: if we have a short string, it might be unencrypted
if (ciphertext.length <= 32 && ciphertext.length > 0) {
console.log("Short ciphertext detected, using as-is");
resolve(ciphertext);
} else {
reject(new Error(`NIP-44 decryption failed: ${decryptError instanceof Error ? decryptError.message : String(decryptError)}`));
}
}
} catch (error) {
console.error("Error in decryptKeyWithNostrTools:", error);
reject(new Error(`NIP-44 decryption with nostr-tools failed: ${error instanceof Error ? error.message : String(error)}`));
}
});
}

@ -1,3 +1,103 @@
/* Radio button styling for pubkey options */
.pubkey-options {
margin: 12px 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.radio-option {
margin-bottom: 8px;
display: flex;
align-items: center;
color: var(--text-primary);
padding: 8px 12px;
background-color: var(--bg-secondary);
border-radius: 6px;
transition: background-color 0.2s ease;
}
.radio-option:hover {
background-color: var(--hover-color);
}
.radio-option input[type="radio"] {
margin-right: 10px;
cursor: pointer;
width: 16px;
height: 16px;
}
/* Two column layout for the billboard modal */
.modal-columns {
display: flex;
gap: 15px;
margin-bottom: 10px;
}
.modal-column {
flex: 1;
min-width: 0;
}
@media (max-width: 768px) {
.modal-columns {
flex-direction: column;
gap: 5px;
}
}
.radio-option label {
cursor: pointer;
font-weight: normal;
margin-bottom: 0;
}
.form-hint {
font-size: 0.85em;
color: var(--text-tertiary, #666);
margin: 0 0 10px 0;
padding: 8px;
background-color: var(--bg-info);
border-left: 3px solid var(--accent-color);
border-radius: 0 4px 4px 0;
line-height: 1.4;
}
.field-help {
font-size: 0.8em;
color: var(--text-secondary);
margin-top: 4px;
font-style: italic;
}
.hidden {
display: none;
}
/* Server key format toggle styling */
.server-format-button {
background-color: var(--button-secondary, #6c757d);
color: var(--text-color-on-primary, white);
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
margin-right: 5px;
}
.server-format-button:hover {
background-color: var(--button-secondary-hover, #5a6268);
}
.format-indicator {
font-size: 0.8rem;
color: var(--text-secondary, #6c757d);
margin-top: 4px;
margin-bottom: 10px;
}
/* Modal styles */
.modal {
display: none;
@ -12,49 +112,84 @@
.modal-content {
background-color: var(--background-color);
margin: 10% auto;
padding: 20px;
margin: 5% auto;
padding: 16px;
border: 1px solid var(--border-color);
width: 80%;
max-width: 600px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
border-radius: 12px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 2px solid var(--accent-color);
}
.modal-header h3 {
margin: 0;
color: var(--text-color);
color: var(--text-primary);
font-size: 1.4em;
font-weight: 600;
}
.close-modal {
color: var(--text-secondary);
font-size: 24px;
font-size: 28px;
font-weight: bold;
cursor: pointer;
height: 36px;
width: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s ease;
}
.close-modal:hover {
color: var(--text-color);
color: var(--text-primary);
background-color: var(--hover-color);
transform: rotate(90deg);
}
.form-group {
margin-bottom: 12px;
padding: 10px;
background-color: var(--bg-tertiary);
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.form-group {
margin-bottom: 15px;
.expiry-input-container {
display: flex;
align-items: center;
width: 100%;
}
.expiry-input-container input {
flex: 0 0 100px;
margin-right: 10px;
}
.expiry-unit {
font-size: 1em;
color: var(--text-secondary);
font-weight: 500;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: var(--text-color);
margin-bottom: 4px;
font-weight: 600;
color: var(--text-primary);
font-size: 0.95em;
}
.form-group input,
@ -62,43 +197,60 @@
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
border-radius: 6px;
background-color: var(--input-background);
color: var(--text-color);
color: var(--text-primary);
font-size: 0.95em;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.form-group input:focus,
.form-group textarea:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(63, 135, 255, 0.2);
outline: none;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
gap: 8px;
margin-top: 16px;
padding-top: 10px;
border-top: 1px solid var(--border-color);
}
.primary-button {
background-color: var(--primary-color);
background-color: var(--button-primary, #0d6efd);
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
padding: 12px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
font-size: 1.05em;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.2s ease;
}
.secondary-button {
background-color: transparent;
color: var(--text-color);
color: var(--text-primary);
border: 1px solid var(--border-color);
padding: 8px 16px;
border-radius: 4px;
padding: 12px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 1.05em;
transition: all 0.2s ease;
}
.primary-button:hover {
background-color: var(--primary-hover);
background-color: var(--button-hover);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.secondary-button:hover {
background-color: var(--hover-color);
transform: translateY(-2px);
}
/* Billboard specific styles */
@ -106,6 +258,48 @@
margin: 20px 0;
display: flex;
justify-content: flex-end;
padding: 10px;
background-color: var(--bg-secondary);
border-radius: 8px;
border: 1px solid var(--border-color);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Dark mode specific styling for billboard actions */
body[data-theme="dark"] .billboard-actions {
background-color: var(--bg-tertiary);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
/* Make "Add New Billboard" button more prominent */
#createBillboardBtn {
background-color: var(--button-primary);
color: white;
font-weight: bold;
padding: 10px 16px;
border-radius: 4px;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
border: none;
position: relative;
overflow: hidden;
}
/* Add a glowing effect in dark mode */
body[data-theme="dark"] #createBillboardBtn {
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 0 10px rgba(63, 135, 255, 0.5);
}
#createBillboardBtn:hover {
background-color: var(--button-hover);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
body[data-theme="dark"] #createBillboardBtn:hover {
box-shadow: 0 0 15px rgba(63, 135, 255, 0.8);
}
.billboard-edit-btn,
@ -132,6 +326,29 @@
background-color: var(--hover-color);
}
/* User-owned event styling */
.user-owned-indicator {
font-size: 0.8rem;
color: var(--button-success, #28a745);
margin-left: 10px;
font-weight: normal;
}
.billboard-card.user-owned {
border-left: 3px solid var(--button-success, #28a745);
}
/* Disabled edit button */
.billboard-edit-btn:disabled {
color: var(--text-tertiary, #6c757d);
cursor: not-allowed;
opacity: 0.6;
}
.billboard-edit-btn:disabled:hover {
background-color: transparent;
}
/* Styles for the HTTP Messages Project Homepage */
/* CSS Variables for themes */
@ -152,10 +369,15 @@
--button-success-hover: #218838;
--info-border: #0d6efd;
--code-bg: #f8f9fa;
--hover-color: rgba(0, 0, 0, 0.05);
--input-background: #ffffff;
--button-secondary: #6c757d;
--button-secondary-hover: #5a6268;
--background-color: #ffffff;
}
/* Dark theme */
[data-theme="dark"] {
body[data-theme="dark"] {
--bg-primary: #121212;
--bg-secondary: #1e1e1e;
--bg-tertiary: #252836;
@ -171,6 +393,11 @@
--button-success-hover: #218838;
--info-border: #3f87ff;
--code-bg: #252525;
--hover-color: rgba(255, 255, 255, 0.1);
--input-background: #2a2a2a;
--button-secondary: #444444;
--button-secondary-hover: #555555;
--background-color: #1e1e1e;
}
/* General layout */
@ -182,7 +409,12 @@ body {
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
transition: background-color 0.3s ease, color 0.3s ease;
transition: all 0.3s ease;
}
/* Make sure all elements using CSS vars also transition smoothly */
* {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
}
/* Headings */
@ -258,6 +490,26 @@ body {
color: var(--accent-color);
}
/* Nuclear reset button in nav bar */
.nuclear-reset-btn {
display: flex;
align-items: center;
justify-content: center;
background-color: transparent;
color: var(--text-primary);
border: none;
cursor: pointer;
font-size: 16px;
transition: all 0.3s ease;
padding: 0;
margin: 0 10px 0 0;
}
.nuclear-reset-btn:hover {
color: #e74c3c;
transform: scale(1.2);
}
/* Ensure icons are properly sized */
#themeIcon {
font-size: 18px;
@ -576,6 +828,81 @@ footer {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
/* Tab content visibility control */
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* HTTP Response Modal */
.http-response-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 1000;
display: flex;
justify-content: center;
align-items: center;
}
.http-response-container {
background-color: var(--bg-primary);
border-radius: 8px;
width: 80%;
max-width: 800px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
.http-response-header {
background-color: var(--bg-secondary);
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
}
.http-response-header h3 {
margin: 0;
font-size: 1.2rem;
color: var(--text-primary);
}
.http-response-tabs {
display: flex;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding: 0 10px;
}
.http-response-content {
flex: 1;
overflow-y: auto;
padding: 15px;
}
.close-modal-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-secondary);
}
.close-modal-btn:hover {
color: var(--text-primary);
}
.selection-header {
padding: 10px;
border-bottom: 1px solid var(--border-color);
@ -792,6 +1119,18 @@ footer {
gap: 10px;
}
/* Server section styles */
.server-section {
margin-bottom: 20px;
padding: 15px;
background-color: var(--bg-secondary);
border-radius: 8px;
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 15px;
}
.server-npub-container label {
font-weight: 600;
min-width: 100px;
@ -890,21 +1229,27 @@ footer {
}
.relay-status.connected {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
background-color: var(--bg-info);
color: var(--button-success);
border: 1px solid var(--button-success);
}
.relay-status.connecting {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeeba;
background-color: var(--bg-info);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.relay-status.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
background-color: var(--bg-info);
color: #e74c3c;
border: 1px solid #e74c3c;
}
.relay-status.notice {
background-color: var(--bg-info);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.subscription-settings {
@ -1011,15 +1356,15 @@ footer {
}
.connection-status-indicator.connected {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
background-color: var(--bg-info);
color: var(--button-success);
border: 1px solid var(--button-success);
}
.connection-status-indicator.not-connected {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
background-color: var(--bg-info);
color: #e74c3c;
border: 1px solid #e74c3c;
}
.connect-button {
@ -1489,14 +1834,42 @@ footer {
border-left: 4px solid var(--accent-color);
margin: 0;
}
/* Notice for missing server registration */
.info-notice {
margin: 15px 0;
padding: 15px;
background-color: var(--bg-info);
border-radius: 8px;
border: 1px solid var(--border-color);
color: var(--text-secondary);
line-height: 1.5;
}
.info-notice p {
margin-bottom: 10px;
}
.info-notice p:last-child {
margin-bottom: 0;
}
.info-notice a {
color: var(--accent-color);
text-decoration: underline;
font-weight: 500;
}
.info-notice a:hover {
text-decoration: none;
}
.decryption-status {
margin-top: 10px;
padding: 8px;
border-radius: 4px;
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeeba;
background-color: var(--bg-info);
color: var(--text-secondary);
border: 1px solid var(--border-color);
text-align: center;
font-style: italic;
font-size: 14px;
@ -1504,15 +1877,15 @@ footer {
}
.decryption-status.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
background-color: var(--bg-info);
color: var(--button-success);
border: 1px solid var(--button-success);
}
.decryption-status.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
background-color: var(--bg-info);
color: #e74c3c;
border: 1px solid #e74c3c;
}
.content-header {
@ -1761,6 +2134,170 @@ footer {
white-space: pre-wrap;
word-break: break-word;
}
/* HTTP Content Formatting Styles */
.http-formatted-container {
background-color: var(--bg-secondary);
border-radius: 6px;
overflow: hidden;
font-family: monospace;
border: 1px solid var(--border-color);
margin-bottom: 15px;
}
.http-request-line, .http-status-line {
padding: 10px;
background-color: var(--accent-color);
color: white;
font-weight: bold;
white-space: pre-wrap;
overflow-x: auto;
}
.http-headers {
padding: 10px;
border-bottom: 1px solid var(--border-color);
background-color: var(--bg-secondary);
}
.http-header {
display: flex;
margin-bottom: 5px;
border-bottom: 1px dashed var(--border-color);
padding-bottom: 5px;
}
.http-header:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.http-header-name {
color: var(--accent-color);
font-weight: bold;
margin-right: 8px;
flex-shrink: 0;
}
.http-header-value {
word-break: break-all;
}
.http-body-label {
padding: 10px;
background-color: var(--accent-color);
color: white;
font-weight: bold;
}
.http-body {
padding: 10px;
background-color: var(--bg-secondary);
white-space: pre-wrap;
overflow-x: auto;
max-height: 400px;
overflow-y: auto;
}
.http-body-line {
margin-bottom: 5px;
line-height: 1.5;
}
/* Toast notification styles */
.toast-notification {
position: fixed;
bottom: 20px;
right: 20px;
max-width: 350px;
background-color: var(--bg-secondary);
color: var(--text-primary);
border-left: 4px solid var(--accent-color);
border-radius: 4px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
transform: translateY(100px);
opacity: 0;
transition: transform 0.3s ease, opacity 0.3s ease;
z-index: 2000;
}
.toast-notification.show {
transform: translateY(0);
opacity: 1;
}
.toast-notification.success {
border-left-color: var(--button-success);
}
.toast-notification.error {
border-left-color: #e74c3c;
}
.toast-notification.info {
border-left-color: var(--accent-color);
}
.toast-content {
padding: 12px 15px;
display: flex;
align-items: center;
justify-content: space-between;
}
.toast-message {
flex: 1;
margin-right: 10px;
}
.toast-close {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
}
.toast-close:hover {
color: var(--text-primary);
}
/* Response indicator for 21121 events */
.response-indicator {
margin-top: 5px;
display: flex;
align-items: center;
}
.response-available {
font-size: 0.8rem;
color: var(--button-success);
background-color: rgba(40, 167, 69, 0.1);
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--button-success);
}
/* Tab style updates for HTTP content display */
.tab-btn[data-tab="raw-http"],
.tab-btn[data-tab="formatted-http"] {
font-size: 0.85rem;
}
.tab-btn[data-tab="formatted-http"].active {
background-color: var(--accent-color);
color: white;
}
.no-content {
padding: 20px;
text-align: center;
color: var(--text-tertiary);
font-style: italic;
}
/* Event item with avatar layout */
.event-item-container {
display: flex;
@ -2057,4 +2594,41 @@ footer {
.raw-input-container button {
align-self: flex-start;
}
/* Decryption status indicators */
.decryption-status {
margin-top: 10px;
padding: 8px 12px;
border-radius: 4px;
font-size: 0.9em;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
}
.decryption-status.success {
background-color: rgba(40, 167, 69, 0.1);
color: var(--button-success);
border: 1px solid var(--button-success);
}
.decryption-status.error {
background-color: rgba(231, 76, 60, 0.1);
color: #e74c3c;
border: 1px solid #e74c3c;
}
.decryption-status::before {
margin-right: 8px;
font-weight: bold;
}
.decryption-status.success::before {
content: '✓';
}
.decryption-status.error::before {
content: '✗';
}

@ -8,6 +8,12 @@ module.exports = {
entry: './src/client.ts',
// Ensure webpack creates browser-compatible output
target: 'web',
// Add detailed error reporting
stats: {
errorDetails: true
},
// Enable source maps for better debugging
devtool: 'inline-source-map',
module: {
rules: [
{
@ -28,19 +34,42 @@ module.exports = {
{ from: 'styles.css', to: 'styles.css' },
{ from: 'http.png', to: 'http.png' },
{ from: 'index.html', to: 'index.html' },
{ from: 'help.html', to: 'help.html' },
{ from: 'receive.html', to: 'receive.html' },
{ from: '1120_client.html', to: '1120_client.html' },
{ from: '1120_server.html', to: '1120_server.html' },
{ from: 'profile.html', to: 'profile.html' },
{ from: 'billboard.html', to: 'billboard.html' },
],
].filter(pattern => {
try {
// Only include files that exist
require('fs').accessSync(pattern.from);
return true;
} catch (e) {
console.warn(`Warning: File ${pattern.from} not found, skipping copy`);
return false;
}
}),
}),
],
output: {
filename: 'bundle.js',
filename: '[name].bundle.js',
chunkFilename: '[name].[chunkhash].js',
path: path.resolve(__dirname, 'dist'),
clean: true, // Clean the output directory before emit
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
// Add aliases for commonly used directories
alias: {
'@utils': path.resolve(__dirname, 'src/utils'),
'@services': path.resolve(__dirname, 'src/services'),
}
},
// Disable optimization and code splitting to ensure everything is in one bundle
optimization: {
// Disable code splitting entirely
splitChunks: false,
// Keep everything in one chunk
runtimeChunk: false
},
devServer: {
static: {
@ -48,5 +77,13 @@ module.exports = {
},
compress: true,
port: 3000,
// Disable browser caching
headers: {
'Cache-Control': 'no-store',
'Pragma': 'no-cache'
},
// Hot reload settings
hot: true,
liveReload: true,
},
};

@ -0,0 +1,117 @@
# Code Optimization Recommendations
After analyzing the HTTP-to-Nostr converter codebase, here are recommendations for refactoring and optimizing the code to make it more maintainable and easier to work with, particularly for AI-based code assistance.
## Key Issues Identified
1. **Large, complex files**: Several files exceed optimal sizes for quick comprehension (NostrService.ts at 871 lines, UiService.ts at 1400+ lines).
2. **Code duplication**: Similar patterns repeated across the codebase.
3. **Tight coupling**: Components are tightly interlinked making isolated changes difficult.
4. **Limited modularization**: Functionality that could be separated into utility functions is embedded in larger classes.
## Optimization Recommendations
### 1. File Organization and Code Splitting
- **Extract utility functions into dedicated files**:
- Create a `utils/` directory with specific utility files like `crypto-utils.ts`, `event-utils.ts`, `dom-utils.ts`
- Example: Extract WebCrypto operations from HttpService.ts into a separate `crypto-utils.ts`
- **Split large files into smaller, focused modules**:
- Break NostrService.ts into:
- `NostrEventService.ts` - Event-specific operations
- `NostrRelayService.ts` - Relay connection management
- `NostrCacheService.ts` - Caching logic only
- **Introduce proper layering**:
- Data access layer (relay communications)
- Business logic layer (event processing)
- Presentation layer (UI updates)
### 2. Code Quality Improvements
- **Use consistent error handling patterns**:
```typescript
// Instead of scattered try/catch blocks with varying patterns
try {
// operation
} catch (error) {
if (error instanceof Error) {
console.error(`Error: ${error.message}`);
} else {
console.error(`Unknown error: ${String(error)}`);
}
}
```
- **Reduce code duplication**:
- Extract repeated WebSocket connection patterns
- Create shared functions for common operations like event verification
- **Improve type safety**:
- Avoid `any` types
- Add proper interfaces for all data structures
- Use more specific types than `Record<string, unknown>`
### 3. Performance Optimizations
- **Implement proper caching strategies**:
- More granular caching with explicit expiration policies
- Option to invalidate specific cache entries
- **Lazy loading of components**:
- Only load modules when needed
- Split webpack bundles for different pages
- **Reduce redundant operations**:
- Batch relay queries where possible
- Improve WebSocket connection reuse
### 4. Specific File Optimizations
#### NostrService.ts
- Extract the caching mechanism into a separate `EventCache` class (already implemented)
- Create a utility module for repetitive operations like pubkey conversion
- Move event query functions to a standalone `queryService`
#### WebSocketManager.ts
- Implemented a more concise version with clearer error handling
- Add connection pooling to reuse connections when possible
#### HttpService.ts
- Simplified version implemented with cleaner parsing and request execution
- Extract cryptography functions to their own utility module
#### UiService.ts
- Break into smaller components organized by UI section
- Separate DOM manipulation from business logic
- Create reusable UI components for common patterns (e.g., event display)
### 5. Development Workflow Improvements
- **Enhanced linting rules**:
- Enforce consistent patterns
- Prevent any-type usage except where absolutely necessary
- **Unit tests**:
- Add tests for critical functionality
- Mock WebSocket and crypto operations for reliable testing
- **Documentation**:
- Add JSDoc comments for all public methods
- Create architecture documentation for developers
## Implementation Strategy
Given the complexity of the refactoring, a gradual approach is recommended:
1. Start with smaller utility extractions that don't affect the overall architecture
2. Implement the new EventCache and other stand-alone services
3. Gradually refactor each major component while maintaining backward compatibility
4. Update imports and replace old implementations one module at a time
This approach maximizes the benefits of optimization while minimizing the risk of breaking changes.