@@ -295,6 +297,21 @@
const applicantNpub = document
.getElementById("applicantNpub")
.value.trim();
+
+ // Anti-spam: the application identifier must be a valid npub.
+ const applicantPubHex =
+ typeof window.validateNpubHex === "function"
+ ? window.validateNpubHex(applicantNpub)
+ : null;
+ if (!applicantPubHex) {
+ log(
+ "Please enter a valid Nostr public key (npub1…) before submitting. Your application was not sent.",
+ "error"
+ );
+ document.getElementById("applicantNpub")?.focus();
+ return;
+ }
+
const contactMethod = document
.getElementById("contactMethod")
.value.trim();
@@ -353,6 +370,7 @@
**1. Applicant Information**
- **Nostr Public Key (npub):** ${applicantNpub}
+- **Pubkey (hex):** ${applicantPubHex}
- **Preferred Contact Method:** ${contactMethod || "N/A"}
- **Username or Alias:** ${username || "N/A"}
diff --git a/components/iframe_forms/iframe-bug-fix-form.html b/components/iframe_forms/iframe-bug-fix-form.html
index 69109d01..13704d9a 100644
--- a/components/iframe_forms/iframe-bug-fix-form.html
+++ b/components/iframe_forms/iframe-bug-fix-form.html
@@ -8,6 +8,8 @@
+
+
@@ -275,6 +277,22 @@
try {
// Section 1: User Information
const userNpub = document.getElementById("userNpub").value.trim();
+
+ // Anti-spam: npub is optional here, but if provided it must be a
+ // valid npub so the submission can't carry free-text garbage.
+ if (
+ userNpub &&
+ (typeof window.validateNpubHex !== "function" ||
+ !window.validateNpubHex(userNpub))
+ ) {
+ log(
+ "That Nostr public key isn't valid. Enter a valid npub1… or leave it blank. Your submission was not sent.",
+ "error"
+ );
+ document.getElementById("userNpub")?.focus();
+ return;
+ }
+
const roleNodes = document.querySelectorAll(
'input[name="userRole"]:checked'
);
diff --git a/components/iframe_forms/iframe-feedback-form.html b/components/iframe_forms/iframe-feedback-form.html
index 290acc90..5e568548 100644
--- a/components/iframe_forms/iframe-feedback-form.html
+++ b/components/iframe_forms/iframe-feedback-form.html
@@ -8,6 +8,8 @@
+
+
@@ -212,6 +214,22 @@
try {
// Section 1: User Information
const userNpub = document.getElementById("userNpub").value.trim();
+
+ // Anti-spam: npub is optional here, but if provided it must be a
+ // valid npub so the submission can't carry free-text garbage.
+ if (
+ userNpub &&
+ (typeof window.validateNpubHex !== "function" ||
+ !window.validateNpubHex(userNpub))
+ ) {
+ log(
+ "That Nostr public key isn't valid. Enter a valid npub1… or leave it blank. Your submission was not sent.",
+ "error"
+ );
+ document.getElementById("userNpub")?.focus();
+ return;
+ }
+
const roleNodes = document.querySelectorAll(
'input[name="userRole"]:checked'
);
diff --git a/components/iframe_forms/iframe-request-form.html b/components/iframe_forms/iframe-request-form.html
index ec291d14..8c76a281 100644
--- a/components/iframe_forms/iframe-request-form.html
+++ b/components/iframe_forms/iframe-request-form.html
@@ -8,6 +8,8 @@
+
+
@@ -235,6 +237,21 @@
try {
// Section 1: User Information
const userNpub = document.getElementById("userNpub").value.trim();
+
+ // Anti-spam: npub is optional here, but if provided it must be a
+ // valid npub so the submission can't carry free-text garbage.
+ if (
+ userNpub &&
+ (typeof window.validateNpubHex !== "function" ||
+ !window.validateNpubHex(userNpub))
+ ) {
+ log(
+ "That Nostr public key isn't valid. Enter a valid npub1… or leave it blank. Your submission was not sent.",
+ "error"
+ );
+ document.getElementById("userNpub")?.focus();
+ return;
+ }
const roleNodes = document.querySelectorAll(
'input[name="userRole"]:checked'
);
diff --git a/js/utils/validateNpub.js b/js/utils/validateNpub.js
new file mode 100644
index 00000000..c1ca970f
--- /dev/null
+++ b/js/utils/validateNpub.js
@@ -0,0 +1,60 @@
+// Shared, anti-spam npub (NIP-19) validation for the embed submission forms
+// (application / feature-request / bug-fix / feedback). A submission identifier
+// is only accepted if it decodes to a canonical 64-char hex pubkey, so the forms
+// stop publishing free-text garbage.
+//
+// Usable two ways:
+// - import { validateNpubHex } from "../utils/validateNpub.js" (tests / app)
+// - as a classic global in the iframe forms: window.validateNpubHex(value)
+// (the module assigns the global below and resolves nostr-tools at call time
+// from window.NostrTools, which the forms already load).
+
+/**
+ * Decode a user-entered npub and return its hex pubkey, or null if it isn't a
+ * well-formed npub.
+ *
+ * @param {string} value Raw input (may include surrounding whitespace).
+ * @param {{ nip19?: { decode: Function } }} [deps] Inject a nip19 implementation
+ * (defaults to the global NostrTools bundle the embed forms load).
+ * @returns {string|null} 64-char lowercase hex pubkey, or null when invalid.
+ */
+export function validateNpubHex(value, { nip19 } = {}) {
+ const decoder =
+ nip19 ||
+ (typeof globalThis !== "undefined" ? globalThis.NostrTools?.nip19 : null);
+
+ if (typeof value !== "string") {
+ return null;
+ }
+ const trimmed = value.trim();
+ if (!trimmed.startsWith("npub1")) {
+ return null;
+ }
+ if (!decoder || typeof decoder.decode !== "function") {
+ return null;
+ }
+
+ try {
+ const decoded = decoder.decode(trimmed);
+ if (
+ decoded &&
+ decoded.type === "npub" &&
+ typeof decoded.data === "string" &&
+ /^[0-9a-f]{64}$/.test(decoded.data)
+ ) {
+ return decoded.data;
+ }
+ } catch (error) {
+ return null;
+ }
+
+ return null;
+}
+
+if (typeof globalThis !== "undefined") {
+ // Expose to the iframe forms' classic inline scripts. Resolves NostrTools at
+ // call time, so load order relative to the nostr-tools bundle doesn't matter.
+ globalThis.validateNpubHex = (value) => validateNpubHex(value);
+}
+
+export default validateNpubHex;
diff --git a/tests/validate-npub.test.mjs b/tests/validate-npub.test.mjs
new file mode 100644
index 00000000..451d035e
--- /dev/null
+++ b/tests/validate-npub.test.mjs
@@ -0,0 +1,56 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import * as nostrTools from "nostr-tools";
+
+import { validateNpubHex } from "../js/utils/validateNpub.js";
+
+// Use the real nostr-tools nip19 so we exercise actual bech32 decoding, not a
+// mirror of the implementation. Generate a real key pair for the happy path.
+const nip19 = nostrTools.nip19;
+const secret = nostrTools.generateSecretKey();
+const hex = nostrTools.getPublicKey(secret);
+const npub = nip19.npubEncode(hex);
+
+test("accepts a well-formed npub and returns its lowercase hex pubkey", () => {
+ const result = validateNpubHex(npub, { nip19 });
+ assert.equal(result, hex);
+ assert.match(result, /^[0-9a-f]{64}$/);
+});
+
+test("tolerates surrounding whitespace", () => {
+ assert.equal(validateNpubHex(` ${npub}\n`, { nip19 }), hex);
+});
+
+test("rejects free-text spam that is not an npub", () => {
+ for (const junk of [
+ "",
+ " ",
+ "hello world",
+ "spammer@example.com",
+ "not-an-npub",
+ npub.replace("npub1", "note1"), // wrong NIP-19 prefix
+ ]) {
+ assert.equal(validateNpubHex(junk, { nip19 }), null, `should reject: ${junk}`);
+ }
+});
+
+test("rejects an npub with a corrupted checksum", () => {
+ // Flip the last character — the bech32 checksum no longer validates.
+ const tampered = npub.slice(0, -1) + (npub.endsWith("q") ? "p" : "q");
+ assert.equal(validateNpubHex(tampered, { nip19 }), null);
+});
+
+test("rejects a wrong NIP-19 entity (nprofile) even though it decodes", () => {
+ const nprofile = nip19.nprofileEncode({ pubkey: hex, relays: [] });
+ assert.equal(validateNpubHex(nprofile, { nip19 }), null);
+});
+
+test("rejects non-string input", () => {
+ for (const value of [null, undefined, 42, {}, [], true]) {
+ assert.equal(validateNpubHex(value, { nip19 }), null);
+ }
+});
+
+test("returns null (instead of throwing) when no decoder is available", () => {
+ assert.equal(validateNpubHex(npub, { nip19: null }), null);
+});
diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md
index 59ff1b02..997bbe50 100644
--- a/todo/TODO_2026-06-20_pre-launch.md
+++ b/todo/TODO_2026-06-20_pre-launch.md
@@ -414,11 +414,22 @@ toward freshness and looked identical. Gave each a structural identity:
Source still to pin: the loop issuing 30000/30002/30005/30015 per contact.
### 22. Anti-spam: validate npub on application / submission forms
-- [ ] Application/submission forms accept too much spam. Require the submitter's
- identifier to be a valid `npub` (NIP-19) — regex/decoder validation client-side
- so submission only succeeds for a well-formed npub (and normalize to hex for
- storage). Reject/disable submit otherwise with a clear inline error. Pairs with
- #23 (the submissions should become structured data, not DMs).
+- [x] **DONE 2026-06-24.** Added a shared, testable NIP-19 validator
+ (`js/utils/validateNpub.js`, exposes `window.validateNpubHex`) and wired it
+ into all four embed submission forms (`components/iframe_forms/`):
+ - **Application** (`applicantNpub`, required): submit is blocked unless the
+ npub decodes to a 64-char hex pubkey; the hex is also written into the
+ application body for canonical storage/whitelist matching.
+ - **Feature-request / bug-fix / feedback** (`userNpub`, optional): the npub
+ stays optional, but a non-empty value must be a valid npub or submit is
+ blocked with a clear inline error.
+ Validation decodes via the real nostr-tools `nip19` (checksum + prefix +
+ hex shape), so spam/free-text/`note1…`/tampered npubs are rejected.
+ Unit tests: `tests/validate-npub.test.mjs` (real nostr-tools). Pairs with #23
+ (submissions should become structured data, not DMs).
+- [ ] **VERIFY live**: open each form embed — application rejects a junk npub and
+ only sends with a valid npub1…; the optional forms send when the npub is
+ blank but reject a malformed one.
### 23. Admin submissions tab — structured submissions + approve/deny UI (SCALING)
- [ ] Replace the current DM-based submission flow with **custom structured data**