Nostr-handson

From CompleteNoobs
Jump to navigation Jump to search
Please Select a Licence from the LICENCE_HEADERS page
And place at top of your page
If no Licence is Selected/Appended, Default will be CC0

Default Licence IF there is no Licence placed below this notice! When you edit this page, you agree to release your contribution under the CC0 Licence

LICENCE: More information about the cc0 licence can be found here:
https://creativecommons.org/share-your-work/public-domain/cc0

The person who associated a work with this deed has dedicated the work to the public domain by waiving all of his or her rights to the work worldwide under copyright law, including all related and neighboring rights, to the extent allowed by law.

You can copy, modify, distribute and perform the work, even for commercial purposes, all without asking permission.

Licence:

Statement of Purpose

The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work").

Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others.

For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights.

1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following:

   the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work;
   moral rights retained by the original author(s) and/or performer(s);
   publicity and privacy rights pertaining to a person's image or likeness depicted in a Work;
   rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below;
   rights protecting the extraction, dissemination, use and reuse of data in a Work;
   database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and
   other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof.

2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose.

3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose.

4. Limitations and Disclaimers.

   No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document.
   Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law.
   Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work.
   Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work.

LICENCE: When you edit this page, you agree to release your contribution under the MIT Licence

LICENCE

Copyright <YEAR> <COPYRIGHT HOLDER>

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.


Nostr Hands-On — Send Your First Notes and DMs Through Your Own Relays

From CompleteNoobs

Stage 2 of the Nostr learning path. Stage 1 (nostr-relay-with-whitelist) got you two private write-whitelisted relays running. This stage gets you actually using Nostr — sending public notes, sending encrypted DMs, subscribing to events, watching the raw protocol — using a single static HTML page that you can read, modify, and break.

We deliberately skip Primal / nostrudel / Damus etc. for stage 2. Real clients hide the protocol behind a UX, and the noob ends up "using Nostr" without ever seeing what Nostr is. Instead, you'll use nostr-handson.html from this folder — a one-file, no-install learning tool. After 30 minutes with it, you'll understand more about Nostr than most people who've used a client for months.

End result of stage 2: you can publish a signed Nostr event from your laptop, see it land on your private relay, query it back over the wire, send an encrypted DM between two of your whitelisted identities, and read every JSON message that flowed through the browser's developer console.

nostr-handson.html code

nostr-handson.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Nostr Hands-On — completenoobs</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
  --bg: #0d0d0d;
  --surface: #1a1a1a;
  --surface2: #252525;
  --accent: #f5a623;
  --green: #5cb85c;
  --blue: #5bc0de;
  --purple: #9b59b6;
  --red: #d9534f;
  --text: #e0e0e0;
  --subtext: #b0b0b0;
  --muted: #707070;
  --border: #333;
}
* { box-sizing: border-box; }
body {
  background: var(--bg);
  color: var(--text);
  font-family: 'IBM Plex Sans', system-ui, sans-serif;
  margin: 0;
  padding: 20px;
  max-width: 1100px;
  margin-left: auto; margin-right: auto;
}
header {
  border-bottom: 2px solid var(--accent);
  padding-bottom: 10px;
  margin-bottom: 20px;
}
header h1 { margin: 0; color: var(--accent); font-family: 'IBM Plex Mono', monospace; }
header p { color: var(--subtext); margin: 6px 0 0; font-size: 0.9rem; }

section {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 14px 16px;
  margin-bottom: 14px;
}
section h2 {
  margin: 0 0 8px;
  color: var(--accent);
  font-size: 0.95rem;
  font-family: 'IBM Plex Mono', monospace;
}
.help {
  color: var(--subtext);
  font-size: 0.85rem;
  margin-bottom: 10px;
  line-height: 1.4;
}

input, textarea, button, select {
  background: var(--surface2);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 6px 10px;
  font-family: 'IBM Plex Mono', monospace;
  font-size: 0.85rem;
}
input:focus, textarea:focus { border-color: var(--accent); outline: none; }
button { cursor: pointer; }
button:hover { border-color: var(--accent); }
button.primary { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; }
button.primary:hover { filter: brightness(1.1); }
button.danger { color: var(--red); border-color: var(--red); }
button:disabled { opacity: 0.4; cursor: not-allowed; }

.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
.row > input { flex: 1; min-width: 200px; }

.warn {
  background: rgba(217,83,79,0.08);
  border: 1px solid var(--red);
  color: var(--red);
  padding: 8px 12px;
  border-radius: 4px;
  font-size: 0.82rem;
  margin-bottom: 10px;
  line-height: 1.4;
}
.tip {
  background: rgba(245,166,35,0.07);
  border: 1px solid rgba(245,166,35,0.3);
  color: var(--subtext);
  padding: 6px 10px;
  border-radius: 4px;
  font-size: 0.78rem;
  margin-top: 8px;
  line-height: 1.4;
}

.tab-buttons { display: flex; gap: 4px; margin-bottom: 10px; }
.tab-buttons button {
  border-bottom: 2px solid transparent;
  border-radius: 4px 4px 0 0;
}
.tab-buttons button.active {
  border-bottom-color: var(--accent);
  color: var(--accent);
}
.tab-pane { display: none; }
.tab-pane.active { display: block; }

#relay-list { display: flex; flex-direction: column; gap: 6px; }
.relay-row {
  display: flex; align-items: center; gap: 8px;
  background: var(--surface2);
  padding: 6px 10px;
  border-radius: 4px;
  flex-wrap: wrap;
}
.relay-row .url { flex: 1; font-family: 'IBM Plex Mono', monospace; font-size: 0.82rem; min-width: 200px; }
.relay-row .status {
  font-size: 0.72rem;
  font-family: 'IBM Plex Mono', monospace;
  padding: 2px 6px;
  border-radius: 3px;
  min-width: 88px;
  text-align: center;
}
.status.disconnected { background: #444; color: var(--muted); }
.status.connecting { background: var(--purple); color: white; }
.status.open { background: var(--green); color: white; }
.status.error { background: var(--red); color: white; }
.relay-row .msgs { font-size: 0.72rem; color: var(--muted); min-width: 60px; }

textarea { width: 100%; min-height: 70px; resize: vertical; }

#event-log {
  max-height: 600px;
  overflow-y: auto;
  font-family: 'IBM Plex Mono', monospace;
  font-size: 0.78rem;
}
.log-entry {
  padding: 6px 8px;
  margin-bottom: 5px;
  border-radius: 4px;
  border-left: 3px solid;
}
.log-entry.in { background: rgba(91,192,222,0.05); border-left-color: var(--blue); }
.log-entry.out { background: rgba(245,166,35,0.05); border-left-color: var(--accent); }
.log-entry.system { background: rgba(112,112,112,0.08); border-left-color: var(--muted); }
.log-entry.error { background: rgba(217,83,79,0.05); border-left-color: var(--red); }
.log-entry .head {
  color: var(--subtext);
  font-size: 0.72rem;
  margin-bottom: 3px;
}
.log-entry .summary { color: var(--text); margin-bottom: 4px; word-break: break-word; }
.log-entry pre {
  margin: 0;
  white-space: pre-wrap;
  word-break: break-all;
  background: var(--bg);
  padding: 6px;
  border-radius: 3px;
  color: var(--subtext);
  max-height: 240px;
  overflow-y: auto;
  cursor: pointer;
  font-size: 0.74rem;
}
.log-entry pre.collapsed { max-height: 38px; overflow: hidden; }
.log-entry .relay-tag {
  display: inline-block;
  background: var(--surface);
  padding: 1px 6px;
  border-radius: 3px;
  font-size: 0.7rem;
  color: var(--accent);
  margin-right: 4px;
}
.identity-display {
  font-family: 'IBM Plex Mono', monospace;
  font-size: 0.82rem;
  word-break: break-all;
  background: var(--surface2);
  padding: 8px 10px;
  border-radius: 4px;
}
.identity-display strong { color: var(--accent); display: inline-block; min-width: 110px; }
.muted { color: var(--muted); }
small.help-inline { color: var(--muted); font-size: 0.75rem; margin-left: 4px; }
</style>
</head>
<body>
<header>
  <h1>Nostr Hands-On</h1>
  <p>A bare-metal Nostr learning page. One file, no install, no client opinions, no follow lists, no profile rendering. Every WebSocket message is logged in section 5 <strong>and mirrored to the browser console</strong> (open DevTools / F12). Read the source — it <em>is</em> the tutorial.</p>
</header>

<section>
  <h2>1. Identity — load a private key</h2>
  <div class="help">Paste your <code>nsec1...</code> or 64-char hex private key. The key stays in this tab's memory only — it's never sent to any server, written to disk, or kept after you close the tab.</div>
  <div class="warn"><strong>⚠ Use a disposable key.</strong> The privkey lives in plain JS memory while this tab is open. Browser extensions, page bugs, or future you forgetting which tab is which can all expose it. For real Nostr usage, use a NIP-07 extension (nos2x / Alby) — see the wiki.</div>
  <div class="row">
    <input id="nsec-input" type="password" placeholder="nsec1... or 64-char hex" autocomplete="off">
    <button id="load-key" class="primary">Load Key</button>
    <button id="forget-key">Forget</button>
  </div>
  <div id="identity-out" class="identity-display muted">No key loaded.</div>
</section>

<section>
  <h2>2. Relays — open WebSockets</h2>
  <div class="help">Pick which relays to use. Click <strong>Connect</strong> on each one. Status flips disconnected → connecting → open. Read-only relays don't need your key. Write relays need your pubkey on the relay's whitelist (for your private relays, that means you set them up correctly in <code>config.toml</code>).</div>
  <div id="relay-list"></div>
  <div class="row" style="margin-top:10px;">
    <input id="new-relay" placeholder="wss://your-relay.example.com" autocomplete="off">
    <button id="add-relay">Add relay</button>
  </div>
  <div class="tip">A "Connect" failure is almost always: bad URL, relay offline, or your network blocking outbound 443. Check the system log entry for the WebSocket error.</div>
</section>

<section>
  <h2>3. Compose — sign and publish events</h2>
  <div class="help">Build a Nostr event, sign it with your loaded key, and broadcast to every <em>open</em> relay. The signed event is logged in section 5 — check the JSON to see <code>id</code> (sha256 of canonical fields), <code>sig</code> (schnorr), <code>tags</code>, etc.</div>
  <div class="tab-buttons">
    <button class="tab-btn active" data-tab="note">Public Note (kind 1)</button>
    <button class="tab-btn" data-tab="dm">DM (kind 4 / NIP-04)</button>
  </div>
  <div class="tab-pane active" data-pane="note">
    <textarea id="note-content" placeholder="What's on your mind? (kind 1 = a public note. The whole world can read this on any relay you publish to.)"></textarea>
    <div class="row" style="margin-top:8px;">
      <button id="send-note" class="primary">Sign & Publish</button>
      <small class="help-inline">Goes to every relay currently in the open state.</small>
    </div>
  </div>
  <div class="tab-pane" data-pane="dm">
    <input id="dm-recipient" placeholder="Recipient npub1... or 64-char hex" autocomplete="off">
    <textarea id="dm-content" placeholder="Encrypted message body" style="margin-top:6px;"></textarea>
    <div class="row" style="margin-top:8px;">
      <button id="send-dm" class="primary">Encrypt, Sign & Publish</button>
      <small class="help-inline">NIP-04: end-to-end encrypted. Relays see the ciphertext + the recipient's pubkey, not the body.</small>
    </div>
    <div class="tip">NIP-04 is the original DM scheme — old, deprecated for new clients (NIP-17 is the modern one), but transparent and supported everywhere. Good for learning. <strong>Do not</strong> use it as your main DM transport long-term.</div>
  </div>
</section>

<section>
  <h2>4. Subscribe — ask relays for events</h2>
  <div class="help">A subscription is a <code>REQ</code> message with one or more filter objects. Relays send matching stored events, then <code>EOSE</code> (end of stored events), then keep streaming new matches in real time until you <code>CLOSE</code>.</div>
  <div class="row">
    <button class="sub-preset" data-preset="my-events">My events</button>
    <button class="sub-preset" data-preset="my-dms">DMs to me</button>
    <button class="sub-preset" data-preset="lookup-author">Lookup author…</button>
    <button class="sub-preset" data-preset="firehose">Firehose (latest 20 kind 1)</button>
    <button id="cancel-subs" class="danger">Cancel all subs</button>
  </div>
  <div id="active-subs" class="muted" style="margin-top: 8px; font-size: 0.78rem;"></div>
  <div class="tip">Filters are JSON predicates: <code>{kinds:[1], authors:[hex]}</code>, <code>{kinds:[4], "#p":[my-hex]}</code>, etc. Relays AND the conditions inside a filter, OR across multiple filters in one REQ.</div>
</section>

<section>
  <h2>5. Event Log — every WebSocket message in/out</h2>
  <div class="help">Click any JSON block to expand/collapse. Browser console (F12) gets the same data — useful for searching with Ctrl+F. Outgoing in <span style="color:var(--accent)">orange</span>, incoming in <span style="color:var(--blue)">blue</span>, system in grey, errors in red.</div>
  <div class="row">
    <button id="clear-log">Clear log</button>
    <label style="font-size:0.82rem;"><input type="checkbox" id="show-raw" checked> Show raw JSON</label>
  </div>
  <div id="event-log"></div>
</section>

<script type="module">
// ============================================================================
// Imports — pure browser ESM via esm.sh, no install, no build step.
// ============================================================================
//
// nostr-tools gives us: schnorr signing, schnorr verification, sha256 event-id,
// bech32 (npub/nsec) encode/decode, and NIP-04 ECDH+AES encryption.
// We deliberately use raw WebSockets (not the nostr-tools Relay class) so the
// REQ/EVENT/EOSE/OK/NOTICE/CLOSE wire dance is fully visible.
//
// Pinned to a specific 2.x version so this file keeps working when nostr-tools
// 3.x ships with breaking changes.
import {
  finalizeEvent,    // adds id + sig to an unsigned event
  getPublicKey,     // priv (Uint8Array) -> hex pubkey
  verifyEvent,      // validates id + sig
  nip04,            // .encrypt(priv, theirPub, plain) / .decrypt(priv, theirPub, cipher)
  nip19             // .npubEncode / .nsecEncode / .decode
} from 'https://esm.sh/nostr-tools@2.7.2';

// ============================================================================
// State
// ============================================================================

const state = {
  privkey: null,        // Uint8Array(32) — null until a key is loaded
  pubkey: null,         // hex string (64 chars)
  npub: null,           // bech32 npub
  relays: new Map(),    // url -> { ws, status, msgs }
  subs:   new Map()     // subId -> { filters }
};

const DEFAULT_RELAYS = [
  'wss://nostr.hive-book.com',
  'wss://nostr.v4call.com',
  'wss://relay.damus.io',
  'wss://nos.lol'
];

// ============================================================================
// Tiny helpers
// ============================================================================

function hexToBytes(hex) {
  if (hex.length % 2) throw new Error('hex string odd length');
  const out = new Uint8Array(hex.length / 2);
  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i*2, i*2+2), 16);
  return out;
}
function shortKey(hex)   { return hex ? hex.slice(0, 8) + '…' + hex.slice(-4) : ''; }
function shortNpub(npub) { return npub ? npub.slice(0, 12) + '…' + npub.slice(-4) : ''; }
function nowSec()        { return Math.floor(Date.now() / 1000); }
function timeStr() {
  const d = new Date();
  return d.toTimeString().slice(0,8) + '.' + String(d.getMilliseconds()).padStart(3,'0');
}
function escapeHtml(s) {
  return String(s).replace(/[&<>"']/g, c => ({
    '&':'&','<':'<','>':'>','"':'"',"'":'''
  }[c]));
}

// ============================================================================
// Logging — every WS in/out shows up here AND in console.log
// ============================================================================

const logEl = document.getElementById('event-log');

function addLog({ type, relay, summary, raw }) {
  const showRaw = document.getElementById('show-raw').checked;
  const div = document.createElement('div');
  div.className = 'log-entry ' + type;
  let html = `<div class="head">${timeStr()} `;
  if (relay)   html += `<span class="relay-tag">${escapeHtml(relay)}</span> `;
  html += `<span class="muted">${type.toUpperCase()}</span></div>`;
  if (summary) html += `<div class="summary">${escapeHtml(summary)}</div>`;
  if (raw && showRaw) {
    let pretty;
    try {
      const obj = (typeof raw === 'string') ? JSON.parse(raw) : raw;
      pretty = JSON.stringify(obj, null, 2);
    } catch { pretty = String(raw); }
    html += `<pre class="collapsed">${escapeHtml(pretty)}
`;
 }
 div.innerHTML = html;
 logEl.prepend(div);
 const pre = div.querySelector('pre');
 if (pre) pre.addEventListener('click', () => pre.classList.toggle('collapsed'));
 // mirror to browser console for grep / Ctrl+F
 console.log(`[${type}]`, relay || '-', summary || , raw ?? );

}

// ============================================================================ // Identity // ============================================================================

const idOut = document.getElementById('identity-out');

document.getElementById('load-key').addEventListener('click', () => {

 const raw = document.getElementById('nsec-input').value.trim();
 if (!raw) return;
 try {
   let priv;
   if (raw.startsWith('nsec1')) {
     const decoded = nip19.decode(raw);
     if (decoded.type !== 'nsec') throw new Error('not an nsec');
     priv = decoded.data;          // Uint8Array(32)
   } else if (/^[0-9a-f]{64}$/i.test(raw)) {
     priv = hexToBytes(raw.toLowerCase());
   } else {
     throw new Error('paste an nsec1... or 64-char hex');
   }
   state.privkey = priv;
   state.pubkey  = getPublicKey(priv);
   state.npub    = nip19.npubEncode(state.pubkey);
   idOut.classList.remove('muted');
   idOut.innerHTML = `
npub ${escapeHtml(state.npub)}
hex pubkey ${escapeHtml(state.pubkey)}
   `;
   addLog({ type:'system', summary:`Loaded identity ${shortNpub(state.npub)}`, raw:{ npub: state.npub, pubkey: state.pubkey } });
   document.getElementById('nsec-input').value = ;
 } catch (e) {
   addLog({ type:'error', summary:`Failed to load key: ${e.message}`, raw:null });
   alert('Bad key: ' + e.message);
 }

});

document.getElementById('forget-key').addEventListener('click', () => {

 state.privkey = null;
 state.pubkey  = null;
 state.npub    = null;
 idOut.classList.add('muted');
 idOut.textContent = 'No key loaded.';
 addLog({ type:'system', summary:'Identity forgotten (privkey wiped from memory).', raw:null });

});

// ============================================================================ // Relays // ============================================================================

const relayListEl = document.getElementById('relay-list');

function renderRelayRow(url) {

 const r = state.relays.get(url) || { ws:null, status:'disconnected', msgs:0 };
 let row = relayListEl.querySelector(`[data-url="${CSS.escape(url)}"]`);
 if (!row) {
   row = document.createElement('div');
   row.className = 'relay-row';
   row.dataset.url = url;
   row.innerHTML = `
     ${escapeHtml(url)}
     disconnected
     
     <button class="connect-btn">Connect</button>
     <button class="disconnect-btn" style="display:none;">Disconnect</button>
     <button class="remove-btn" title="Remove">×</button>
   `;
   relayListEl.appendChild(row);
   row.querySelector('.connect-btn').addEventListener('click', () => connectRelay(url));
   row.querySelector('.disconnect-btn').addEventListener('click', () => disconnectRelay(url));
   row.querySelector('.remove-btn').addEventListener('click', () => {
     disconnectRelay(url);
     state.relays.delete(url);
     row.remove();
   });
 }
 const statusEl = row.querySelector('.status');
 statusEl.className = 'status ' + r.status;
 statusEl.textContent = r.status;
 row.querySelector('.msgs').textContent = r.msgs ? `${r.msgs} msgs` : ;
 const open = (r.status === 'open' || r.status === 'connecting');
 row.querySelector('.connect-btn').style.display    = open ? 'none' : ;
 row.querySelector('.disconnect-btn').style.display = open ?  : 'none';

}

function connectRelay(url) {

 let r = state.relays.get(url);
 if (!r) { r = { ws:null, status:'disconnected', msgs:0 }; state.relays.set(url, r); }
 if (r.ws && (r.ws.readyState === WebSocket.CONNECTING || r.ws.readyState === WebSocket.OPEN)) return;
 r.status = 'connecting';
 renderRelayRow(url);
 addLog({ type:'system', relay:url, summary:'Connecting…', raw:null });
 let ws;
 try {
   ws = new WebSocket(url);
 } catch (e) {
   r.status = 'error';
   addLog({ type:'error', relay:url, summary:e.message, raw:null });
   renderRelayRow(url);
   return;
 }
 r.ws = ws;
 ws.addEventListener('open', () => {
   r.status = 'open';
   addLog({ type:'system', relay:url, summary:'WebSocket open.', raw:null });
   renderRelayRow(url);
   // Re-attach existing subs to the newly opened relay
   for (const [subId, sub] of state.subs) {
     const msg = ['REQ', subId, ...sub.filters];
     ws.send(JSON.stringify(msg));
     addLog({ type:'out', relay:url, summary:`REQ ${subId} (re-attached)`, raw:msg });
   }
 });
 ws.addEventListener('message', (ev) => {
   r.msgs++;
   renderRelayRow(url);
   let parsed;
   try { parsed = JSON.parse(ev.data); }
   catch { addLog({ type:'in', relay:url, summary:'non-JSON message', raw:ev.data }); return; }
   handleRelayMessage(url, parsed);
 });
 ws.addEventListener('close', () => {
   r.status = 'disconnected';
   addLog({ type:'system', relay:url, summary:'WebSocket closed.', raw:null });
   renderRelayRow(url);
 });
 ws.addEventListener('error', () => {
   r.status = 'error';
   addLog({ type:'error', relay:url, summary:'WebSocket error (DNS / TLS / blocked port?)', raw:null });
   renderRelayRow(url);
 });

}

function disconnectRelay(url) {

 const r = state.relays.get(url);
 if (r && r.ws) { try { r.ws.close(); } catch {} }

}

function handleRelayMessage(url, msg) {

 if (!Array.isArray(msg)) {
   addLog({ type:'in', relay:url, summary:'unknown shape', raw:msg });
   return;
 }
 const verb = msg[0];
 if (verb === 'EVENT') {
   const [, subId, ev] = msg;
   let summary = `EVENT sub=${subId} kind=${ev.kind} from=${shortKey(ev.pubkey)}`;
   // Auto-decrypt kind 4 if I can
   if (ev.kind === 4 && state.privkey) {
     const myHex = state.pubkey;
     const iAmRecipient = ev.tags.some(t => t[0] === 'p' && t[1] === myHex);
     const iAmSender    = ev.pubkey === myHex;
     if (iAmRecipient || iAmSender) {
       const counterparty = iAmRecipient ? ev.pubkey : (ev.tags.find(t => t[0] === 'p') || [])[1];
       if (counterparty) {
         nip04.decrypt(state.privkey, counterparty, ev.content)
           .then(plain => {
             const tag = iAmRecipient ? `from ${shortKey(ev.pubkey)}` : `to ${shortKey(counterparty)}`;
             addLog({ type:'in', relay:url, summary:`DM (decrypted) ${tag}: ${plain}`, raw:ev });
           })
           .catch(e => addLog({ type:'in', relay:url, summary:`${summary} — decrypt failed: ${e.message}`, raw:ev }));
         return;
       }
     }
   }
   if (ev.kind === 1) summary += ` content="${(ev.content || ).slice(0, 80).replace(/\n/g,' ')}"`;
   addLog({ type:'in', relay:url, summary, raw:ev });
 } else if (verb === 'EOSE') {
   addLog({ type:'in', relay:url, summary:`EOSE sub=${msg[1]} (end of stored events; live updates continue)`, raw:msg });
 } else if (verb === 'OK') {
   const [, eventId, accepted, reason] = msg;
   addLog({ type:'in', relay:url, summary:`OK ${shortKey(eventId)} accepted=${accepted}${reason ? ' reason="'+reason+'"' : }`, raw:msg });
 } else if (verb === 'NOTICE') {
   addLog({ type:'in', relay:url, summary:`NOTICE: ${msg[1]}`, raw:msg });
 } else if (verb === 'CLOSED') {
   addLog({ type:'in', relay:url, summary:`CLOSED sub=${msg[1]}${msg[2] ? ' reason="'+msg[2]+'"' : }`, raw:msg });
 } else {
   addLog({ type:'in', relay:url, summary:`unknown verb ${verb}`, raw:msg });
 }

}

document.getElementById('add-relay').addEventListener('click', () => {

 const url = document.getElementById('new-relay').value.trim();
 if (!url) return;
 if (!/^wss?:\/\//.test(url)) { alert('URL must start with wss:// or ws://'); return; }
 if (!state.relays.has(url)) state.relays.set(url, { ws:null, status:'disconnected', msgs:0 });
 renderRelayRow(url);
 document.getElementById('new-relay').value = ;

});

DEFAULT_RELAYS.forEach(url => {

 state.relays.set(url, { ws:null, status:'disconnected', msgs:0 });
 renderRelayRow(url);

});

// ============================================================================ // Tabs // ============================================================================

document.querySelectorAll('.tab-btn').forEach(btn => {

 btn.addEventListener('click', () => {
   document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
   document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
   btn.classList.add('active');
   document.querySelector(`[data-pane="${btn.dataset.tab}"]`).classList.add('active');
 });

});

// ============================================================================ // Compose: public note (kind 1) // ============================================================================

document.getElementById('send-note').addEventListener('click', () => {

 if (!state.privkey) return alert('Load your key in section 1 first.');
 const content = document.getElementById('note-content').value;
 if (!content.trim()) return alert('Note is empty.');
 // Build the unsigned event. Per NIP-01, an event has these fields:
 //   pubkey, created_at, kind, tags, content
 // (id and sig are added by finalizeEvent.)
 const unsigned = {
   kind: 1,
   created_at: nowSec(),
   tags: [],
   content
 };
 // finalizeEvent does:
 //   1. set pubkey from priv
 //   2. compute id = sha256( JSON([0, pubkey, created_at, kind, tags, content]) )
 //   3. sign id with schnorr (BIP-340) using priv
 //   4. attach id + sig to the event
 const signed = finalizeEvent(unsigned, state.privkey);
 if (!verifyEvent(signed)) {
   addLog({ type:'error', summary:'verifyEvent failed — refusing to publish (this should never happen)', raw:signed });
   return;
 }
 publishEvent(signed);
 document.getElementById('note-content').value = ;

});

// ============================================================================ // Compose: DM (kind 4 NIP-04) // ============================================================================

document.getElementById('send-dm').addEventListener('click', async () => {

 if (!state.privkey) return alert('Load your key in section 1 first.');
 const recipRaw = document.getElementById('dm-recipient').value.trim();
 const body     = document.getElementById('dm-content').value;
 if (!recipRaw || !body.trim()) return alert('Need both recipient and message.');
 let recipHex;
 try {
   if (recipRaw.startsWith('npub1')) {
     const dec = nip19.decode(recipRaw);
     if (dec.type !== 'npub') throw new Error('not an npub');
     recipHex = dec.data;
   } else if (/^[0-9a-f]{64}$/i.test(recipRaw)) {
     recipHex = recipRaw.toLowerCase();
   } else throw new Error('paste an npub1... or hex pubkey');
 } catch (e) {
   return alert('Bad recipient: ' + e.message);
 }
 // NIP-04: ECDH(my-priv, their-pub) -> shared secret -> AES-CBC encrypt(body)
 // -> content = base64(ciphertext) + "?iv=" + base64(iv)
 let ciphertext;
 try {
   ciphertext = await nip04.encrypt(state.privkey, recipHex, body);
 } catch (e) {
   addLog({ type:'error', summary:`NIP-04 encrypt failed: ${e.message}`, raw:{ recipient: recipHex, error: e.message } });
   alert('Encrypt failed: ' + e.message + '\n\nUsually means a bad recipient pubkey.');
   return;
 }
 const unsigned = {
   kind: 4,
   created_at: nowSec(),
   tags: 'p', recipHex,   // 'p' tag tells relays who the recipient is (visible plaintext!)
   content: ciphertext
 };
 let signed;
 try {
   signed = finalizeEvent(unsigned, state.privkey);
 } catch (e) {
   addLog({ type:'error', summary:`finalizeEvent failed: ${e.message}`, raw:unsigned });
   return;
 }
 if (!verifyEvent(signed)) {
   addLog({ type:'error', summary:'verifyEvent failed — refusing to publish', raw:signed });
   return;
 }
 publishEvent(signed);
 document.getElementById('dm-content').value = ;

});

function publishEvent(signed) {

 const msg = ['EVENT', signed];
 let sent = 0;
 for (const [url, r] of state.relays) {
   if (r.ws && r.ws.readyState === WebSocket.OPEN) {
     r.ws.send(JSON.stringify(msg));
     sent++;
     addLog({ type:'out', relay:url, summary:`EVENT kind=${signed.kind} id=${shortKey(signed.id)}`, raw:msg });
   }
 }
 if (!sent) addLog({ type:'error', summary:'No open relays — nothing was published. Connect a relay in section 2.', raw:signed });

}

// ============================================================================ // Subscribe presets // ============================================================================

let subCounter = 0; function nextSubId() { return `sub-${++subCounter}`; }

function publishSub(filters) {

 const subId = nextSubId();
 state.subs.set(subId, { filters });
 const msg = ['REQ', subId, ...filters];
 let sent = 0;
 for (const [url, r] of state.relays) {
   if (r.ws && r.ws.readyState === WebSocket.OPEN) {
     r.ws.send(JSON.stringify(msg));
     addLog({ type:'out', relay:url, summary:`REQ ${subId}`, raw:msg });
     sent++;
   }
 }
 if (!sent) addLog({ type:'error', summary:'No open relays — REQ not sent.', raw:msg });
 renderActiveSubs();

}

document.querySelectorAll('.sub-preset').forEach(btn => {

 btn.addEventListener('click', () => {
   const preset = btn.dataset.preset;
   if (preset === 'my-events') {
     if (!state.pubkey) return alert('Load key first.');
     publishSub([{ kinds:[0,1,3,4,7], authors:[state.pubkey], limit:50 }]);
   } else if (preset === 'my-dms') {
     if (!state.pubkey) return alert('Load key first.');
     publishSub([{ kinds:[4], '#p':[state.pubkey], limit:50 }]);
   } else if (preset === 'lookup-author') {
     const raw = prompt('Author npub or 64-char hex:');
     if (!raw) return;
     let hex;
     try {
       if (raw.startsWith('npub1')) {
         const d = nip19.decode(raw.trim());
         if (d.type !== 'npub') throw new Error('not an npub');
         hex = d.data;
       } else if (/^[0-9a-f]{64}$/i.test(raw.trim())) {
         hex = raw.trim().toLowerCase();
       } else throw new Error('bad input');
     } catch (e) { return alert(e.message); }
     publishSub([{ kinds:[0,1], authors:[hex], limit:30 }]);
   } else if (preset === 'firehose') {
     publishSub([{ kinds:[1], limit:20 }]);
   }
 });

});

document.getElementById('cancel-subs').addEventListener('click', () => {

 for (const [subId] of state.subs) {
   const msg = ['CLOSE', subId];
   for (const [url, r] of state.relays) {
     if (r.ws && r.ws.readyState === WebSocket.OPEN) {
       r.ws.send(JSON.stringify(msg));
       addLog({ type:'out', relay:url, summary:`CLOSE ${subId}`, raw:msg });
     }
   }
 }
 state.subs.clear();
 renderActiveSubs();

});

function renderActiveSubs() {

 const el = document.getElementById('active-subs');
 if (!state.subs.size) { el.textContent = 'No active subscriptions.'; return; }
 el.textContent = `Active subs: ${Array.from(state.subs.keys()).join(', ')}`;

} renderActiveSubs();

// ============================================================================ // Misc // ============================================================================

document.getElementById('clear-log').addEventListener('click', () => { logEl.innerHTML = ; });

addLog({ type:'system', summary:'Page loaded. Open the browser console (F12) to see all WebSocket traffic mirrored there.', raw:null }); </script> </body> </html>

Why a hands-on page beats a real client for learning

  • Real clients pick relays for you, sign for you, decrypt for you, render for you. Magic = mystery = doesn't stick.
  • This page does nothing automatically. You connect each relay manually. You build each event manually. You pick filters manually. You see every signed JSON object before it leaves and after it arrives.
  • The whole thing is one HTML file with all logic in plain ES modules. view-source: in the browser is the tutorial. Read the comments inline as you click through the lessons.
  • When you do graduate to a real client, you'll know exactly what each setting in "Relays" / "DM" / "Notifications" maps to in the protocol.

Contents

Prerequisites

  • Two relays from stage 1 workingwss://nostr.hive-book.com and wss://nostr.v4call.com (or your equivalents).
  • Three nsec keys generated and whitelisted on both relays. Stage 1 covered this. The keys can be from nostr-gen.html, your normal Nostr client, or nak key generate.
  • nak installed on your laptop (Stage 1 → Step 14 → Option 1C). Used in lessons 4 + 11.
  • A modern browser with developer tools — Brave, Firefox, Chrome, Safari all work. We'll reference Brave in screenshots since that's CompleteNoobs' default.
  • nostr-handson.html — sitting in this folder next to this wiki.

Step 0: Open the page (no install)

Three ways to load nostr-handson.html; pick whichever you prefer:

Option A — file:// (simplest)
Just double-click the file in your file manager, or drag it into a browser tab. Address bar will show file:///path/to/nostr-handson.html. WebSocket-to-internet works fine from file://; no special server needed.
Option B — python3 -m http.server (if you prefer http://)
From the folder containing the file:
python3 -m http.server 8000
Then browse to http://localhost:8000/nostr-handson.html.
Option C — Host it on your relay box (clean URL, accessible from any device)
Drop the file into your relay's web root and serve via Caddy. Out of scope for this lesson; do it later if you want.

The page imports its crypto + bech32 helpers from esm.sh on first load — that's a CDN fetch of nostr-tools. After that the page is fully functional even offline (until cache expires). Open the browser console (F12) before you click anything.

Lesson 1: Load a key

  1. In Section 1, paste an nsec1... from one of your three whitelisted keys. Use a disposable / learning key — see the warning in the page.
  2. Click Load Key.
  3. The page derives + displays your npub and your hex pubkey.
  4. Open your browser console — there's a system log line: [system] - Loaded identity npub1abc…xyz4 {npub: ..., pubkey: ...}.
What just happened
  • nip19.decode("nsec1...") turned your bech32 nsec into raw bytes.
  • getPublicKey(privBytes) ran a single secp256k1 scalar multiplication to derive your pubkey. Same math underlies BTC, ETH, Hive — Nostr just uses the result differently.
  • nip19.npubEncode(pubkeyHex) wrapped the pubkey in bech32 to get your npub.
  • The privkey is now sitting in state.privkey in browser memory. Closing the tab forgets it.
💡 Wisdom
An npub and a hex pubkey are the same key, just two encodings. Relays use hex internally. Clients display npub. Always 64 hex chars / always starts npub1. If you only see one form, you can convert any time with nak decode npub1....

Lesson 2: Connect a relay

  1. In Section 2 you'll see four pre-filled relays — your two privates plus relay.damus.io and nos.lol (popular publics).
  2. Click Connect on wss://nostr.hive-book.com (or whichever of yours).
  3. Status flips: disconnectedconnectingopen (green).
  4. Console gets one [system] entry per state change.
What just happened
  • new WebSocket("wss://nostr.hive-book.com") opened a TCP/TLS/WebSocket-upgrade chain to the relay.
  • The browser dev tools' Network tab → filter WS shows the connection. Click it → Messages sub-tab shows every frame in/out. Useful as a second view alongside section 5.
💡 Wisdom
A Nostr connection is just a WebSocket. Same plumbing as a chat room, a stock ticker, or a multiplayer game. Nothing magical at the transport layer. The protocol on top of it (REQ / EVENT / EOSE / OK / NOTICE / CLOSED) is what's "Nostr-y".

Lesson 3: Publish your first public note

  1. Section 3 → "Public Note (kind 1)" tab is selected by default.
  2. Type something memorable: hello world from nostr-handson.
  3. Click Sign & Publish.
  4. Watch sections 5 + the console:
  • [out] EVENT kind=1 id=abc123… with the full JSON.
  • Then a [in] OK abc123… accepted=true from each connected relay.
What just happened — the most important JSON in Nostr
Click the JSON block in the log to expand. You'll see something like:
{
  "kind": 1,
  "created_at": 1746554000,
  "tags": [],
  "content": "hello world from nostr-handson",
  "pubkey": "abc123…",      // hex pubkey, derived from your nsec
  "id":     "deadbeef…",   // sha256 of [0,pubkey,created_at,kind,tags,content]
  "sig":    "f00ba12…"     // schnorr signature of id, by your privkey
}
The id and sig are the whole proof. Anyone in the world holding only this JSON can verify it actually came from your pubkey and hasn't been tampered with — without ever talking to you, the relay, or anything.
💡 Wisdom
A Nostr event is just signed JSON. That's it. Every "post" / "DM" / "reaction" / "follow list" / "long-form article" / "zap receipt" / etc. is the same shape: a JSON object with kind picking the type, tags attaching metadata, and content being the body. Hundreds of kinds defined across NIPs, all the same fundamental wrapper.

Lesson 4: Verify on the server with nak

You don't have to trust the page — go look at the relay directly.

nak req -k 1 -a YOUR_HEX_PUBKEY wss://nostr.hive-book.com

(Use the hex pubkey from section 1 of the page, not the npub.)

You should see your note printed. nak hits the WebSocket, sends a REQ with kinds:[1], authors:[your-hex], prints every EVENT, then exits on EOSE.

💡 Wisdom
Whenever a client claims something is missing, query the relay directly. If nak finds the event but your client doesn't show it, the bug is in the client's relay config or filter, not in your publish. This single habit will save you days of debugging later.

Lesson 5: Subscribe to your own feed

  1. Section 4 → click My events.
  2. Watch the log:
  • [out] REQ sub-1 with filter {kinds:[0,1,3,4,7], authors:[your-hex], limit:50}.
  • [in] EVENT sub=sub-1 kind=1 ... — your note from lesson 3 comes back.
  • [in] EOSE sub=sub-1 — "you've now seen all stored events; live updates continue."
  1. Now post another note from section 3. It'll arrive immediately on the same subscription as a live event.
What just happened
A Nostr subscription is a long-lived REQ. Stored events flow first, then EOSE marks the catch-up boundary, then the relay streams new matches in real time until you CLOSE.
💡 Wisdom
Filters AND across fields, OR across filter objects. {kinds:[1], authors:[X]} means "kind 1 AND author X". Adding a second filter to the same REQ means "match either filter". This is how clients build complex feeds (your follows + your replies + your zaps) in one subscription.

Lesson 6: Open a second identity in a second window

You want to send a message between two of your three whitelisted accounts. Two ways to have two identities at once:

Option A — Brave private window
File → New Private Window. Open nostr-handson.html there. Load identity B's nsec.
Option B — Two browsers
Brave for identity A, Firefox for identity B (or any other combination).

You should now have two browser windows, each showing the page, each with a different identity loaded. Treat them as Alice (account A) and Bob (account B).

  1. In Bob's window, connect wss://nostr.v4call.com (Bob's home relay) AND wss://nostr.hive-book.com (so Bob can read what Alice writes).
  2. In Alice's window, do the symmetrical: connect both relays.
💡 Wisdom
The single biggest source of "Nostr is broken" frustration is no shared relay between sender and recipient. Get this dual-relay setup working in both windows and you've eliminated 90% of all multi-account Nostr issues.

Lesson 7: Have a public-note conversation

  1. Alice's window: section 4 → Lookup author… → paste Bob's npub. A REQ goes out for kinds 0+1 from Bob.
  2. Bob's window: same in reverse. Look up Alice's npub.
  3. Now Alice posts: "Hi Bob, can you see this?" Section 5 in Bob's window shows the incoming EVENT within a second.
  4. Bob replies (still kind 1): "Loud and clear, Alice."
  5. Alice's incoming subscription picks it up.
💡 Wisdom
This is the entire feed mechanism in Nostr. A real client wraps it in nicer UX, threading, mute lists, follow lists, but the wire protocol is exactly what you're seeing. There is no central server, no API key, no rate limit beyond what each relay chooses.

Lesson 8: Send an encrypted DM (NIP-04)

  1. Alice's window → section 3 → DM (kind 4 / NIP-04) tab.
  2. Recipient: paste Bob's npub.
  3. Body: "this is a secret".
  4. Click Encrypt, Sign & Publish.
  5. Watch the outgoing event in section 5. Notice content is base64 ciphertext like 4QwT...?iv=Lk9P..., not your plaintext. Notice tags: "p", "bob-hex" — that's how the relay knows who to deliver to.
  6. In Bob's window: section 4 → DMs to me.
  7. Bob's subscription matches kind 4 with #p tag = Bob's hex. The relay sends Alice's encrypted event. Bob's page auto-decrypts because Bob's nsec is loaded. The log shows: [in] DM (decrypted) from abc12345…wxyz: this is a secret.
What just happened
  • Encryption (NIP-04 spec):
  • shared = ECDH(Alice-priv, Bob-pub) = secp256k1 point's X coordinate (32 bytes).
  • iv = 16 random bytes.
  • ciphertext = AES-256-CBC(shared, iv, plaintext).
  • content field = base64(ciphertext) + "?iv=" + base64(iv).
  • Decryption is the same with Bob-priv + Alice-pub — ECDH gives the same shared secret in either direction. That's the magic of Diffie-Hellman.
  • The relay sees: sender pubkey, recipient pubkey (from p-tag), timestamp, ciphertext. It does NOT see the body.
⚠ Wisdom — NIP-04 limits
  • Metadata (who-talks-to-whom + when + how often) is plaintext on the relay. A relay operator running a logger can build the social graph.
  • NIP-44 is a newer encryption (ChaCha20 + HKDF + HMAC). Better authenticated encryption. Most clients support it.
  • NIP-17 wraps NIP-44 in "gift wraps" (kind 1059) that hide sender + recipient on the relay. Modern best practice. Not implemented in this page yet — too complex for stage 2.
  • For real privacy use a client that supports NIP-17. This page is for learning, not for OPSEC.

Lesson 9: Read the raw JSON

Open the browser console (F12 → Console tab). Filter / search for [in] or [out]:

  • An outgoing kind 1 has the canonical structure from lesson 3.
  • An outgoing kind 4 is the same shape but: kind=4, content=ciphertext, tags="p", recipient.
  • An incoming EVENT is a 3-tuple: ["EVENT", subId, eventObject].
  • An incoming OK is a 4-tuple: ["OK", eventId, accepted (bool), reason (string)]. accepted=false with a clear reason is how relays reject (e.g. "blocked: pubkey not authorized to publish").
  • A NOTICE is a server-side gripe: ["NOTICE", "could not parse command"]. You saw this in stage 1 lesson 14.
Try this in the console
After connecting a relay, type:
["EVENT", "fakeId"] ← intentionally malformed
Then send it manually:
state.relays.get('wss://nostr.hive-book.com').ws.send(JSON.stringify(["EVENT","fakeId"]))
You'll get a NOTICE back from the relay rejecting the malformed message. Your page literally bypasses no security to do this — you're just speaking the protocol directly.
💡 Wisdom
Once you can build and parse these JSON shapes by hand, you can write a Nostr client. That's the whole protocol. Everything else is UX.

Lesson 10: Watch the firehose (a public relay)

  1. Make sure wss://relay.damus.io is connected (it's pre-filled).
  2. Section 4 → Firehose button. This sends {kinds:[1], limit:20} with no author filter.
  3. Watch the log fill with kind-1 notes from random Nostr users worldwide.
  4. Notice each one has a different pubkey. Each one is independently signed.
💡 Wisdom
A public relay is just a private relay without the whitelist. Same protocol, same code, same nostr-rs-relay binary on the other end (or its peers). Yours is private only because of three lines in config.toml.

Lesson 11: Server-side view (Docker logs + nak)

SSH into your relay box. In one terminal:

cd /opt/nostr-relay && docker compose logs -f nostr-relay

In another terminal (or back on your laptop):

nak req -k 4 -a YOUR_HEX_PUBKEY wss://nostr.hive-book.com

Now from the page, send another note. Watch the docker logs — the relay's perspective: a connection arrives, an EVENT is received, it's persisted, an OK goes back. Send a kind 4 — same flow, but the relay only sees ciphertext.

Try this with a non-whitelisted key (load a fresh disposable nsec, try to publish): the relay logs the rejection, the page receives OK accepted=false reason="blocked: ...".

💡 Wisdom
Relays are accountable infrastructure. You can audit exactly what you accepted and rejected. This is part of why people run their own — you control the policy and you can prove you're enforcing it.

Lesson 12: Break things on purpose

Healthy thing to do — surface error paths so you'll recognise them later.

  1. Disconnect a relay, then publish. Page shows [error] No open relays — nothing was published.
  2. Publish without a key loaded. Page alerts. (Try this in the console too: clear state.privkey and click Send.)
  3. Subscribe with a malformed filter. From the console: state.relays.get('wss://nostr.hive-book.com').ws.send(JSON.stringify(["REQ","junk",{"foo":"bar"}])) — the relay either ignores or NOTICEs.
  4. Send a DM to yourself. Auto-decrypts. Useful for confirming the encrypt/decrypt round-trip works locally without involving a second account.
  5. Refresh the page mid-session. Privkey is forgotten. Relays disconnect. Section 5 clears. Nothing persists. Re-paste nsec and reconnect. (This is by design — keys never persist.)
  6. Cancel all subs, then post. The post still goes out (publishing is independent of subscriptions). Subs only affect reading.
💡 Wisdom
Most "Nostr won't work" reports are one of: no key loaded, no relay open, sender/recipient relay mismatch, or a stale subscription. Once you've intentionally hit each of these in a controlled setting, you'll diagnose them in seconds in the wild.

What this page does NOT do

Stage 2 deliberately stops where the protocol ends and where client UX begins. The page does NOT:

  • Render profiles (kind 0 metadata) — you only see "from <hex>".
  • Build threaded reply trees (NIP-10 e/p tag markers).
  • Render reactions (kind 7) as little hearts.
  • Auto-discover who's on which relay (NIP-65 outbox model).
  • Show notifications, mentions, or unread counts.
  • Handle long-form articles (kind 30023) with markdown.
  • Send or receive zaps (NIP-57 Lightning).
  • Use modern DMs (NIP-17 gift-wrapped). NIP-04 only.
  • Maintain mute lists, follow lists, or any persistence between page loads.
  • Use a NIP-07 browser extension for safer key handling.

Each of these is a layer that real clients add on top of the protocol. Once you're comfortable with what's happening here, every one of them makes sense as a small specific addition rather than as opaque magic.

Wisdom for the Nostr Noob

Use disposable keys for this page
The privkey lives in browser JS memory. Acceptable for learning, not for valuable identities. For real use → NIP-07 extension (nos2x / Alby) or a remote signer (NIP-46 / Nsec.app).
No shared relay = no message delivered
If sender's writes and recipient's reads don't overlap, you're shouting into the void. There is no central postman. This is THE concept.
The console is your second feed
Section 5 is a curated UI; the browser console (F12) has every byte mirrored to console.log(). Use Ctrl+F to search across hundreds of events. Filter by [in] / [out] / [system] / [error].
Read the page source
All logic is in one <script type="module"> block at the bottom of the file. ~600 lines, well-commented. Edit it. Add a button. Add a kind. The page is yours — break it, learn from breaking it.
nak is the second tool you'll always reach for
When the page says "I sent it", confirm with nak req -k 1 -a HEX wss://.... When the page says "nothing came back", confirm with the same command. The two tools cross-check each other and remove all "but the client said..." doubt.
Kinds are the schema
0 = profile, 1 = note, 3 = follow list, 4 = old DM, 5 = delete request, 6 = repost, 7 = reaction, 9735 = zap receipt, 10002 = relay list, 30023 = long-form article, 1059 = NIP-17 gift wrap, ... hundreds of NIPs. The full list lives at github.com/nostr-protocol/nips. Surprisingly readable.
Time stamps are seconds, not milliseconds
Nostr's created_at is Unix seconds (Math.floor(Date.now()/1000)). Putting milliseconds will make events look ~50 years in the future and many relays drop them.
"Delete" is fuzzy
NIP-09 has a "request deletion" event. Relays may or may not honour it. Other relays still have copies. Treat anything you publish as permanent.

Where to go after this

In rough order of "what to learn next":

  1. NIP-65 outbox model — publish a relay-list event (kind 10002) declaring your read + write relays. This is the spec for "where can people find me." Most modern clients use it. Add a "publish my relay list" button to nostr-handson.html as a first hack.
  2. Profile metadata (kind 0) — set name, about, picture. Same shape as kind 1, but content is JSON. Replaceable: only the latest event survives per pubkey.
  3. NIP-05 verification — map username@yourdomain.com to your pubkey via a /.well-known/nostr.json on your domain. Stage 1's relays could host this trivially.
  4. NIP-07 (browser extension signing) — install nos2x or Alby. Modify nostr-handson.html so it calls window.nostr.signEvent() instead of holding the privkey. This is the right pattern for any real key.
  5. NIP-44 + NIP-17 — modern DMs. NIP-44 = better symmetric encryption. NIP-17 = wrap NIP-44 in gift wraps so the relay can't see who-talked-to-whom.
  6. Zaps (NIP-57) — Lightning tipping over Nostr. You'll need a Lightning wallet first.
  7. A real client — Primal, nostrudel, Damus, Amethyst, Iris, Coracle, Snort, Habla. Pick one. Notice that under the hood it's doing exactly what your page does, just with prettier UX.
  8. v4call's planned Nostr layer — see V4call and the project's NOSTR-DESIGN-NOTES.md. Your private relays are the natural infrastructure for that, with one of your 3 keys being your v4call server's identity.

You now have a Nostr identity you understand, a relay you control, and a tool that exposes the entire protocol. That's a stronger foundation than 99% of Nostr users start with. 🌿