[search] Decode npub authors to hex — the Author filter matched nothing (npubs)

The Author field (and the author: token) passed values through as-typed, but the
matcher compares against video.pubkey — HEX. So entering an npub (the only format
users actually have) silently returned zero results; only raw hex worked.

New normalizeAuthorValues (searchFilters.js, injectable decoder for tests):
npub1* decodes via safeDecodeNpub to hex, hex passes through lowercased, and
undecodable values are dropped — surfaced as parse errors on the author: token
path. Applied in BOTH the parser and the filter modal's applyFilters, so the
Author panel field and the token syntax now genuinely filter by creator.

Tests: +2 in tests/search-filters.test.mjs (decode/passthrough/drop with an
injected decoder; parser hex passthrough + garbage error). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-02 23:12:20 -04:00
1 parent a6a6d4b0f7
commit 9afbc0cbbb
3 files changed
+82 -4

No files matched your search

+36 -1
View File
@@ -1,3 +1,32 @@
import { safeDecodeNpub } from "../utils/nostrHelpers.js";
const HEX64 = /^[0-9a-f]{64}$/;
// Authors arrive as npubs (what users actually have) or hex, but videos carry
// HEX pubkeys — without decoding, npub authors silently matched nothing.
// Undecodable values are dropped (returned in `dropped` for error surfaces).
export function normalizeAuthorValues(values, { decodeNpub = safeDecodeNpub } = {}) {
const normalized = [];
const dropped = [];
for (const raw of Array.isArray(values) ? values : []) {
const value = typeof raw === "string" ? raw.trim() : "";
if (!value) continue;
if (HEX64.test(value.toLowerCase())) {
normalized.push(value.toLowerCase());
continue;
}
if (value.toLowerCase().startsWith("npub1")) {
const hex = typeof decodeNpub === "function" ? decodeNpub(value) : null;
if (hex && HEX64.test(hex.toLowerCase())) {
normalized.push(hex.toLowerCase());
continue;
}
}
dropped.push(value);
}
return { normalized, dropped };
}
const DATE_FORMAT_OPTIONS = { const DATE_FORMAT_OPTIONS = {
year: "numeric", year: "numeric",
month: "2-digit", month: "2-digit",
@@ -176,7 +205,13 @@ export function parseFilterQuery(inputString = "") {
errors.push({ token, message: "Author value is empty." }); errors.push({ token, message: "Author value is empty." });
break; break;
} }
filters.authorPubkeys.push(...values); {
const { normalized, dropped } = normalizeAuthorValues(values);
filters.authorPubkeys.push(...normalized);
for (const value of dropped) {
errors.push({ token, message: `Could not decode author "${value}".` });
}
}
break; break;
} }
case "tag": { case "tag": {
+4 -3
View File
@@ -9,7 +9,7 @@ import {
buildSearchHashFromState, buildSearchHashFromState,
resetSearchFilters, resetSearchFilters,
} from "../../search/searchFilterState.js"; } from "../../search/searchFilterState.js";
import { DEFAULT_FILTERS } from "../../search/searchFilters.js"; import { DEFAULT_FILTERS, normalizeAuthorValues } from "../../search/searchFilters.js";
import { setHashView } from "../../hashView.js"; import { setHashView } from "../../hashView.js";
import { ALLOW_NSFW_CONTENT } from "../../config.js"; import { ALLOW_NSFW_CONTENT } from "../../config.js";
@@ -349,8 +349,9 @@ function applyFilters() {
filters.sort = sortSelect.value; filters.sort = sortSelect.value;
// Author // Author
const authors = authorInput.value.split(/[, ]+/).map(s => s.trim()).filter(Boolean); const rawAuthors = authorInput.value.split(/[, ]+/).map(s => s.trim()).filter(Boolean);
filters.authorPubkeys = authors; // Decode npubs to hex — videos carry hex pubkeys, so raw npubs matched nothing.
filters.authorPubkeys = normalizeAuthorValues(rawAuthors).normalized;
// Tags // Tags
const selectedTags = new Set(); const selectedTags = new Set();
+42
View File
@@ -116,3 +116,45 @@ test("sort: trending weights views by recency", () => {
); );
assert.deepEqual(ranked, ["fresh-riser", "old-hit"]); assert.deepEqual(ranked, ["fresh-riser", "old-hit"]);
}); });
// Author values arrive as npubs but videos carry hex pubkeys — without decoding,
// npub authors silently matched nothing (the "wired but wrong" filter).
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-search-author-npub
// given: "author values as npub / hex / garbage"
// when: "normalizeAuthorValues runs (with an injected decoder)"
// then: "npubs decode to hex, hex passes through lowercased, garbage is dropped with errors"
// observable_outcomes:
// - "npub -> decoded hex; HEX passthrough lowercased; undecodable -> dropped"
// - "parser author: token emits decode errors for garbage"
// determinism_controls:
// - "injected decodeNpub stub (no window.NostrTools dependency)"
// anti_cheat_rationale:
// prevents: ["hard-coded return value"]
// relaxation:
// did_relax_any_assertion: false
test("normalizeAuthorValues decodes npubs, passes hex, drops garbage", async () => {
const { normalizeAuthorValues } = await import("../js/search/searchFilters.js");
const HEX = "a".repeat(64);
const decodeNpub = (v) => (v === "npub1good" ? HEX : null);
const { normalized, dropped } = normalizeAuthorValues(
["npub1good", "B".repeat(64), "npub1bad", "garbage"],
{ decodeNpub },
);
assert.deepEqual(normalized, [HEX, "b".repeat(64)]);
assert.deepEqual(dropped, ["npub1bad", "garbage"]);
});
test("author: token with hex passes into filters; garbage surfaces as an error", () => {
const HEX = "c".repeat(64);
const good = parseFilterQuery(`author:${HEX}`);
assert.deepEqual(good.filters.authorPubkeys, [HEX]);
const bad = parseFilterQuery("author:not-a-key");
assert.equal(bad.filters.authorPubkeys.length, 0);
assert.ok(bad.errors.some((e) => /decode author/i.test(e.message)));
});