Promote unstable → beta: storage providers (B2 + per-provider connections), upload/unlock fixes, per-event admin block list

Includes: Backblaze B2 as a first-class provider + provider-aware CORS helper; per-provider
storage connections (no clash) + sync; profile image/banner + B2/S3 upload fixes
(resolveConnection hex, mediaUploader routing-by-credentials, prepareUpload contract/meta
flatten); nsec re-unlock prompts (storage pane + upload modal) + stacked-modal focus fix;
NIP-71 mirror relay-truth (#34); per-event admin block list (#25); bulk orphan delete (#8);
mobile @media + profile-modal grid; #11b test un-quarantine. Tree set to unstable @ 6e85c2a3.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-30 16:20:16 -04:00
commit aef82601fd
47 files changed
+3479 -166

No files matched your search

+46
View File
@@ -412,3 +412,49 @@ test_integrity_note:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
---
## 2026-06-25 — todo-11b triage: un-quarantine nostr-boost-actions + admin-list-store
```yaml
test_integrity_note:
change_type: ["spec_correction", "refactor_tests"]
scenarios:
- id: SCN-repost-optional-relay
given: "A repost is built with no relay hint available (buildRepostEvent, nostrEventSchemas.js)"
when: "buildRepostEvent({ eventId, authorPubkey, targetKind }) is called without eventRelay"
then: "It returns a VALID repost (kind 16 for a non-kind-1 target, two-element ['e', id] tag, ['p', author], ['k', kind]) instead of throwing"
- id: SCN-community-blacklist-merge-batched
given: "An admin community-blacklist sources list references two curator kind-30000 lists, each with p-tag members"
when: "loadAdminState() runs and fetches curator lists via the BATCHED SubscriptionManager path"
then: "adminState.blacklist merges the curator members with the direct entry (deduped, guards respected)"
observable_outcomes:
- "buildRepostEvent output shape (kind/tags) with no relay — asserted exactly, stricter than the old throws-check"
- "adminState.blacklist === [direct, communityMemberOne, communityMemberTwo]"
determinism_controls:
- "pure builder (no IO) for repost; in-memory list registry + mocked pool.list AND getSubscriptionManager for the admin store"
anti_cheat_rationale:
prevents:
- "hard-coded return value"
- "over-mocking internal logic"
note: |
nostr-boost-actions: STALE SPEC. The test required buildRepostEvent to throw
/missing-event-relay/ when no relay hint is given, but a relay hint is a NIP-18
SHOULD (not MUST). The builder intentionally degrades to a two-element `e` tag so a
user can always repost. Replaced the throws-assertion with an EXACT assertion of the
valid output (kind 16 + e/p/k tags) — strictly stronger.
admin-list-store: STALE HARNESS, no production change, no assertion weakened. Three
fidelity fixes so the test exercises the real batched community-blacklist path:
(1) curator lists are now fetched via the batched SubscriptionManager (cold-start
relay-storm fix), which the harness didn't mock — added a getSubscriptionManager().list
mock serving the same registry; (2) curator coordinates now use REAL 64-char hex
pubkeys (the old mock-"hex" started with "npub", so parseCommunityBlacklistReferences'
isHexPubkey()/npub branch decoded them twice and the batched filter no longer matched);
(3) createListEvent now emits a `d` tag (real kind-30000 lists always have one;
selectNewestEventsForReferences matches curator events to references by their d tag).
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
+93 -3
View File
@@ -898,6 +898,7 @@
<span class="mb-2 block font-medium">Provider</span>
<select id="storageProvider" class="input">
<option value="cloudflare_r2" selected>Cloudflare R2</option>
<option value="backblaze_b2">Backblaze B2</option>
<option value="generic_s3">Custom S3</option>
</select>
</label>
@@ -907,6 +908,11 @@
Enter your Cloudflare R2 credentials. Your Account ID (from dashboard) is used to connect. For Public Access URL, use your custom domain or R2.dev subdomain. Ensure the token has "Object Read & Write" permissions.
</div>
<!-- B2 Helper -->
<div id="storageB2Helper" class="card border border-info/30 bg-info/10 p-3 text-xs text-info hidden">
Enter your Backblaze B2 Application Key. Set Region to your bucket's region (e.g. us-west-004) — bitvid derives the endpoint and the public URL (https://&lt;bucket&gt;.s3.&lt;region&gt;.backblazeb2.com). Your bucket must be Public for playback; override the Public Access URL if you serve files from a custom domain/CDN.
</div>
<!-- S3 Helper -->
<div id="storageS3Helper" class="card border border-info/30 bg-info/10 p-3 text-xs text-info hidden">
Enter your S3-compatible storage details. The Endpoint URL is your provider's API address (e.g., https://s3.amazonaws.com or your MinIO URL).
@@ -967,6 +973,9 @@
<button id="storageClearBtn" type="button" class="btn-ghost focus-ring text-status-danger">
Clear
</button>
<button id="storageCorsHelpBtn" type="button" class="btn-ghost focus-ring text-xs">
CORS setup help
</button>
</div>
<p id="storageFormStatus" class="text-sm" role="status" aria-live="polite"></p>
@@ -1052,9 +1061,18 @@
data-orientation="vertical"
></ul>
<div id="profileMyVideosOrphans" class="space-y-2 hidden">
<h4 class="text-sm font-semibold text-text">
Orphaned storage objects
</h4>
<div class="flex items-center justify-between gap-3">
<h4 class="text-sm font-semibold text-text">
Orphaned storage objects
</h4>
<button
id="profileMyVideosOrphanDeleteAll"
type="button"
class="btn-ghost focus-ring text-xs text-status-danger"
>
Delete all
</button>
</div>
<p class="text-2xs text-muted">
Files in your bucket that no current video references — safe to
remove to reclaim space.
@@ -1832,3 +1850,75 @@
</div>
</div>
</div>
<!-- Storage CORS setup helper (B2 / S3-compatible). Shown on demand from the
Storage pane; renders above the profile modal via modal-always-on-top. -->
<div
id="storageCorsModal"
class="bv-modal modal-always-on-top hidden items-start justify-center md:items-center"
role="dialog"
aria-modal="true"
aria-labelledby="storageCorsModalTitle"
aria-describedby="storageCorsModalIntro"
data-ds="new"
>
<div class="bv-modal-backdrop" data-storage-cors-dismiss></div>
<div class="modal-sheet max-h-modal-shell overflow-hidden flex flex-col w-full max-w-2xl" tabindex="-1">
<div class="modal-header">
<h2 id="storageCorsModalTitle" class="text-xl font-bold text-text">
Allow browser uploads (bucket CORS)
</h2>
<button
id="storageCorsModalClose"
class="modal-close"
type="button"
aria-label="Close CORS setup help"
data-storage-cors-dismiss
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="modal-body overflow-y-auto p-5 space-y-4 text-sm text-muted-strong">
<p id="storageCorsModalIntro">
Uploading from your browser uses an S3 <code>PUT</code>/<code>POST</code>,
which your bucket must allow via CORS.
</p>
<div>
<div class="mb-1 flex items-center justify-between gap-2">
<span class="font-medium text-text">CORS rules JSON</span>
<button id="storageCorsCopyJsonBtn" type="button" class="btn-ghost focus-ring text-xs">
Copy JSON
</button>
</div>
<pre class="card border border-default bg-surface-strong/40 p-3 overflow-x-auto text-xs leading-relaxed"><code id="storageCorsJson" class="whitespace-pre text-text"></code></pre>
<p class="mt-1 text-xs text-muted">
Origins are pre-filled from this site. Add your other bitvid domains, or use
<code>["*"]</code> to allow every origin.
</p>
</div>
<div>
<div class="mb-1 flex items-center justify-between gap-2">
<span id="storageCorsCmdLabel" class="font-medium text-text">Apply it</span>
<button id="storageCorsCopyCmdBtn" type="button" class="btn-ghost focus-ring text-xs">
Copy command
</button>
</div>
<pre class="card border border-default bg-surface-strong/40 p-3 overflow-x-auto text-xs leading-relaxed"><code id="storageCorsCli" class="whitespace-pre text-text"></code></pre>
</div>
<p id="storageCorsNotes" class="whitespace-pre-line text-xs text-muted"></p>
</div>
<div class="modal-footer flex items-center justify-end gap-3 border-t border-border/60 p-4">
<button id="storageCorsDoneBtn" type="button" class="btn focus-ring" data-storage-cors-dismiss>
Done
</button>
</div>
</div>
</div>
+40 -3
View File
@@ -1395,7 +1395,7 @@
overscroll-behavior: contain;
}
@media (max-width: calc(theme("screens.lg") - var(--breakpoint-edge-offset))) {
@media (max-width: calc(theme("screens.lg") - 0.02px)) {
.bv-modal {
align-items: stretch;
justify-content: stretch;
@@ -3130,7 +3130,7 @@
}
}
@media (max-width: calc(theme("screens.lg") - var(--breakpoint-edge-offset))) {
@media (max-width: calc(theme("screens.lg") - 0.02px)) {
.profile-modal__layout[data-mobile-view="menu"]
.profile-modal__pane-wrapper {
display: none;
@@ -3141,6 +3141,43 @@
}
}
/* Mobile: the profile tab nav (full-width menu view) becomes an app-grid — icon
tiles with the title centered below each icon, instead of the desktop vertical
list. Desktop (>=lg) keeps its vertical nav (the min-width:lg rule below wins
at the exact breakpoint via source order). NOTE: the @media condition must NOT
use a CSS `var()` — custom properties are invalid inside media conditions and
Chromium drops the whole block. `theme("screens.lg")` resolves to a literal at
build time, which is valid. */
@media (max-width: calc(theme("screens.lg") - 0.02px)) {
.profile-modal__nav {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-xs);
}
.profile-modal__nav > button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
text-align: center;
gap: var(--space-2xs);
width: 100%;
padding-block: var(--space-sm);
padding-inline: var(--space-2xs);
/* wrap labels at spaces, not mid-word */
overflow-wrap: break-word;
word-break: normal;
hyphens: none;
}
/* slightly larger glyph for the tile */
.profile-modal__nav > button svg {
width: var(--space-lg);
height: var(--space-lg);
}
}
@media (min-width: theme("screens.lg")) {
.profile-modal {
max-block-size: var(--modal-shell-max-height);
@@ -6085,7 +6122,7 @@
}
/* Mobile Active State */
@media (max-width: calc(theme("screens.lg") - var(--breakpoint-edge-offset))) {
@media (max-width: calc(theme("screens.lg") - 0.02px)) {
.card[data-mobile-active="true"] {
--video-card-lift-translate-y: var(--motion-video-card-hover-translate);
--video-card-backdrop-opacity: 0.425;
+99
View File
@@ -14,6 +14,10 @@ import {
readCachedAdminState,
} from "./adminListStore.js";
import { ensureNostrTools } from "./nostr/toolkit.js";
import {
normalizeEventId,
normalizeEventIdList,
} from "./adminEventBlacklistHelpers.js";
import { userLogger } from "./utils/logger.js";
const HEX_KEY_PATTERN = /^[0-9a-f]{64}$/i;
@@ -109,6 +113,8 @@ class AccessControl {
this.whitelistPubkeys = new Set();
this.blacklist = new Set();
this.blacklistPubkeys = new Set();
// Per-event (per-video) block list (#25): hex event ids hidden everywhere.
this.eventBlacklist = new Set();
this.whitelistEnabled = getWhitelistMode();
this.hasLoaded = false;
this.lastError = null;
@@ -118,6 +124,7 @@ class AccessControl {
this._whitelistListeners = new Set();
this._editorListeners = new Set();
this._blacklistListeners = new Set();
this._eventBlacklistListeners = new Set();
this._scheduleHydrateFromCache();
}
@@ -248,6 +255,9 @@ class AccessControl {
const editors = Array.isArray(state?.editors) ? state.editors : [];
const whitelist = Array.isArray(state?.whitelist) ? state.whitelist : [];
const blacklist = Array.isArray(state?.blacklist) ? state.blacklist : [];
const eventBlacklist = Array.isArray(state?.eventBlacklist)
? state.eventBlacklist
: [];
const previousEditors =
this.editors instanceof Set ? new Set(this.editors) : new Set();
@@ -255,6 +265,8 @@ class AccessControl {
this.whitelist instanceof Set ? new Set(this.whitelist) : new Set();
const previousBlacklist =
this.blacklist instanceof Set ? new Set(this.blacklist) : new Set();
const previousEventBlacklist =
this.eventBlacklist instanceof Set ? new Set(this.eventBlacklist) : new Set();
this.editors = new Set(
dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors])
@@ -282,6 +294,12 @@ class AccessControl {
this.blacklist = new Set(sanitizedBlacklist);
const blacklistChanged = !areSetsEqual(previousBlacklist, this.blacklist);
this.eventBlacklist = new Set(normalizeEventIdList(eventBlacklist));
const eventBlacklistChanged = !areSetsEqual(
previousEventBlacklist,
this.eventBlacklist,
);
const toolkitCandidate =
(typeof window !== "undefined" ? window?.NostrTools : null) ||
(typeof globalThis !== "undefined" ? globalThis?.NostrTools : null);
@@ -330,6 +348,9 @@ class AccessControl {
if (blacklistChanged) {
this._emitBlacklistChange(Array.from(this.blacklist));
}
if (eventBlacklistChanged) {
this._emitEventBlacklistChange(Array.from(this.eventBlacklist));
}
}
_emitWhitelistChange(whitelistValues) {
@@ -368,6 +389,24 @@ class AccessControl {
}
}
_emitEventBlacklistChange(eventValues) {
if (!this._eventBlacklistListeners.size) {
return;
}
const snapshot = Array.isArray(eventValues)
? [...eventValues]
: this.getEventBlacklist();
for (const listener of Array.from(this._eventBlacklistListeners)) {
try {
listener(snapshot);
} catch (error) {
userLogger.error("accessControl event-blacklist listener failed", error);
}
}
}
_emitEditorsChange(editorsValues) {
if (!this._editorListeners.size) {
return;
@@ -405,6 +444,7 @@ class AccessControl {
this.whitelistPubkeys.clear();
this.blacklist.clear();
this.blacklistPubkeys.clear();
this.eventBlacklist.clear();
}
throw error;
} finally {
@@ -542,6 +582,65 @@ class AccessControl {
};
}
// --- Per-event (per-video) admin block list (#25) ---
getEventBlacklist() {
return Array.from(this.eventBlacklist);
}
isEventBlacklisted(idOrPointer) {
const id = normalizeEventId(idOrPointer);
return Boolean(id) && this.eventBlacklist.has(id);
}
onEventBlacklistChange(listener) {
if (typeof listener !== "function") {
return () => {};
}
this._eventBlacklistListeners.add(listener);
return () => {
this._eventBlacklistListeners.delete(listener);
};
}
async _persistEventBlacklist(actorNpub, nextIds) {
try {
await persistAdminState(actorNpub, { eventBlacklist: nextIds });
await this.refresh();
return { ok: true };
} catch (error) {
return { ok: false, error: error?.code || "storage-error" };
}
}
async addToEventBlacklist(actorNpub, idOrPointer) {
if (!this.canEditAdminLists(actorNpub)) {
return { ok: false, error: "forbidden" };
}
const id = normalizeEventId(idOrPointer);
if (!id) {
return { ok: false, error: "invalid event id" };
}
return this._persistEventBlacklist(
actorNpub,
normalizeEventIdList([...this.getEventBlacklist(), id]),
);
}
async removeFromEventBlacklist(actorNpub, idOrPointer) {
if (!this.canEditAdminLists(actorNpub)) {
return { ok: false, error: "forbidden" };
}
const id = normalizeEventId(idOrPointer);
if (!id) {
return { ok: false, error: "invalid event id" };
}
return this._persistEventBlacklist(
actorNpub,
this.getEventBlacklist().filter((value) => value !== id),
);
}
async addModerator(requestorNpub, moderatorNpub) {
if (!this.isSuperAdmin(requestorNpub)) {
return { ok: false, error: "forbidden" };
+80
View File
@@ -0,0 +1,80 @@
// Helpers for the per-event (per-video) admin block list (#25). Entries are 64-char
// hex event ids carried as `e` tags on a kind-30000 NIP-51 list — not npubs.
import { buildAdminListEvent } from "./nostrEventSchemas.js";
export const HEX_EVENT_ID_REGEX = /^[0-9a-f]{64}$/i;
// Unsigned kind-30000 event for the per-event block list (`e` tags via its schema).
export function buildEventBlacklistEvent(actorHex, eventIds) {
return buildAdminListEvent("eventBlacklist", {
pubkey: actorHex,
created_at: Math.floor(Date.now() / 1000),
hexPubkeys: sanitizeEventIdList(eventIds),
});
}
// Deduped, lowercased hex event ids; anything malformed is dropped.
export function sanitizeEventIdList(values) {
const out = [];
const seen = new Set();
for (const value of Array.isArray(values) ? values : []) {
const id = typeof value === "string" ? value.trim().toLowerCase() : "";
if (HEX_EVENT_ID_REGEX.test(id) && !seen.has(id)) {
seen.add(id);
out.push(id);
}
}
return out;
}
export function extractEventIdsFromEvent(event) {
if (!event || !Array.isArray(event.tags)) {
return [];
}
return sanitizeEventIdList(
event.tags
.filter((tag) => Array.isArray(tag) && tag[0] === "e" && tag[1])
.map((tag) => tag[1]),
);
}
// Normalize a per-event block entry to a 64-char hex event id. Accepts a raw hex id or
// a NIP-19 nevent/note string (the ⋯ menu passes the video's hex id directly).
export function normalizeEventId(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (HEX_EVENT_ID_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
try {
const tools =
(typeof window !== "undefined" ? window?.NostrTools : null) ||
(typeof globalThis !== "undefined" ? globalThis?.NostrTools : null);
const decoded = tools?.nip19?.decode?.(trimmed);
if (decoded?.type === "nevent" && HEX_EVENT_ID_REGEX.test(decoded.data?.id || "")) {
return decoded.data.id.toLowerCase();
}
if (decoded?.type === "note" && HEX_EVENT_ID_REGEX.test(decoded.data || "")) {
return decoded.data.toLowerCase();
}
} catch (error) {
// not a decodable pointer
}
return "";
}
export function normalizeEventIdList(values) {
const out = [];
const seen = new Set();
for (const value of Array.isArray(values) ? values : []) {
const id = normalizeEventId(value);
if (id && !seen.has(id)) {
seen.add(id);
out.push(id);
}
}
return out;
}
+24 -24
View File
@@ -21,6 +21,7 @@ import {
NOTE_TYPES,
} from "./nostrEventSchemas.js";
import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js";
import { sanitizeEventIdList, extractEventIdsFromEvent, buildEventBlacklistEvent } from "./adminEventBlacklistHelpers.js";
import { publishEventToRelay } from "./nostrPublish.js";
import { fetchBatchedReferenceEvents } from "./adminListBatch.js";
@@ -224,6 +225,7 @@ function sanitizeAdminState(state = {}) {
editors: sanitizedEditors,
whitelist: sanitizedWhitelist,
blacklist: sanitizedBlacklist,
eventBlacklist: sanitizeEventIdList(state.eventBlacklist || []),
};
}
@@ -231,7 +233,8 @@ function hasAnyEntries(state = {}) {
return Boolean(
(Array.isArray(state.editors) && state.editors.length) ||
(Array.isArray(state.whitelist) && state.whitelist.length) ||
(Array.isArray(state.blacklist) && state.blacklist.length)
(Array.isArray(state.blacklist) && state.blacklist.length) ||
(Array.isArray(state.eventBlacklist) && state.eventBlacklist.length)
);
}
@@ -551,15 +554,9 @@ async function fetchLatestListEvent(filter, contextLabel = "admin-list") {
? filter["#d"][0]
: "";
// Note: fetchListIncrementally expects single kind, pubkey, dTag.
// The existing fetchLatestListEvent was a bit generic but admin lists are specific.
// If we can't extract specific identifiers, fall back to pool list (no incremental).
// fetchListIncrementally needs a single kind/pubkey/dTag (admin lists always have them).
if (!pubkey) {
devLogger.warn(`[adminListStore] Cannot use incremental fetch for ${contextLabel} without author.`);
// Fallback to old behavior if pubkey missing (e.g. search scenario? unlikely for admin lists)
// Actually admin lists always have specific d-tag and author (editor list, whitelist, blacklist, community lists)
// So we should be fine.
}
let events = [];
@@ -577,20 +574,14 @@ async function fetchLatestListEvent(filter, contextLabel = "admin-list") {
pubkey,
dTag,
relayUrls: relays,
// Force a full fetch: admin lists are replaceable events whose
// created_at doesn't advance, so a persisted lastSeen would gate
// since=lastSeen+1 and perpetually hide the current event across
// reloads. See tests/nostr/relayBatchFetcherReplaceable.test.mjs.
// Force a full fetch: admin lists are replaceable (see
// tests/nostr/relayBatchFetcherReplaceable.test.mjs).
since: 0,
}),
timeoutPromise,
]);
} else {
// Fallback for non-specific queries
const normalizedFilter = {
kinds: [kind],
limit: 50,
};
const normalizedFilter = { kinds: [kind], limit: 50 };
if (dTag) normalizedFilter["#d"] = [dTag];
if (filter.authors) normalizedFilter.authors = filter.authors;
events = await Promise.race([
@@ -639,17 +630,19 @@ async function loadNostrList(identifier) {
}
const event = await fetchLatestListEvent(filter, identifier);
return event ? extractNpubsFromEvent(event) : [];
const extractor = identifier === ADMIN_LIST_IDENTIFIERS.eventBlacklist ? extractEventIdsFromEvent : extractNpubsFromEvent;
return event ? extractor(event) : [];
}
async function loadNostrState() {
const [editors, whitelist, blacklist] = await Promise.all([
const [editors, whitelist, blacklist, eventBlacklist] = await Promise.all([
loadNostrList(ADMIN_LIST_IDENTIFIERS.editors),
loadNostrList(ADMIN_LIST_IDENTIFIERS.whitelist),
loadNostrList(ADMIN_LIST_IDENTIFIERS.blacklist),
loadNostrList(ADMIN_LIST_IDENTIFIERS.eventBlacklist),
]);
return sanitizeAdminState({ editors, whitelist, blacklist });
return sanitizeAdminState({ editors, whitelist, blacklist, eventBlacklist });
}
async function loadCommunityBlacklistEntries() {
@@ -689,9 +682,8 @@ async function loadCommunityBlacklistEntries() {
return [];
}
// Batch all curator blacklists into ONE REQ instead of one-per-curator — this
// was the cold-start kind-30000 relay storm. Best-effort: on failure we return
// nothing and the cached admin state (readCachedAdminState) carries over.
// Batch all curator blacklists into ONE REQ (was the cold-start kind-30000 storm).
// Best-effort: on failure the cached admin state carries over.
try {
const matched = await fetchBatchedReferenceEvents({
references,
@@ -709,7 +701,11 @@ async function loadCommunityBlacklistEntries() {
}
}
function buildListEvent(listKey, npubs, actorHex) {
function buildListEvent(listKey, entries, actorHex) {
if (listKey === "eventBlacklist") {
return buildEventBlacklistEvent(actorHex, entries); // `e`-tag list, hex event ids
}
const npubs = entries;
const hexPubkeys = Array.from(
new Set(npubs.map((npub) => {
try {
@@ -938,6 +934,9 @@ async function persistNostrState(actorNpub, updates = {}) {
);
}
if (Array.isArray(updates.eventBlacklist)) {
sanitizedUpdates.eventBlacklist = sanitizeEventIdList(updates.eventBlacklist);
}
const entries = Object.entries(sanitizedUpdates).filter(([, value]) =>
Array.isArray(value)
);
@@ -1026,6 +1025,7 @@ export async function loadAdminState() {
editors: baseEditors,
whitelist: baseWhitelist,
blacklist: [...baseBlacklist, ...communityEntries],
eventBlacklist: nostrState?.eventBlacklist || [],
});
}
} catch (error) {
+47
View File
@@ -1071,7 +1071,54 @@ class Application {
// Placeholder for session actor initialization if needed
}
// Merge the static operator list (_staticBlacklistedEventIds) with the dynamic admin
// event-blacklist (#25) into the live set every consumer reads. Reassign (not mutate)
// so snapshots taken by feed/grid/search paths pick up the new set.
_rebuildBlacklistedEventIds() {
const base =
this._staticBlacklistedEventIds instanceof Set
? this._staticBlacklistedEventIds
: new Set();
let adminIds = [];
try {
adminIds =
typeof accessControl.getEventBlacklist === "function"
? accessControl.getEventBlacklist()
: [];
} catch (error) {
adminIds = [];
}
this.blacklistedEventIds = new Set([...base, ...adminIds]);
return this.blacklistedEventIds;
}
_initAccessControlListeners() {
if (typeof accessControl.onEventBlacklistChange === "function") {
accessControl.onEventBlacklistChange(() => {
this._rebuildBlacklistedEventIds();
const refreshReason = "admin-event-blacklist-change";
this.refreshAllVideoGrids({
reason: refreshReason,
forceMainReload: true,
}).catch((error) => {
devLogger.warn(
"[app.init()] Failed to refresh video grids after admin event-blacklist change:",
error,
);
});
if (typeof this.refreshVisibleModerationUi === "function") {
try {
this.refreshVisibleModerationUi({ reason: refreshReason });
} catch (error) {
devLogger.warn(
"[app.init()] Failed to refresh moderation UI after event-blacklist change:",
error,
);
}
}
});
}
if (typeof accessControl.onBlacklistChange === "function") {
accessControl.onBlacklistChange(() => {
this._syncSessionActorBlacklist("blacklist-change");
+30 -1
View File
@@ -883,6 +883,13 @@ export class SignerManager {
async scheduleStoredRemoteSignerRestore() {
const stored = readStoredNip46Session();
// [nip46-diag] TEMP (todo #33): trace whether a NIP-46 session is restored on
// load — the "unknown user after refresh" symptom. Remove with the fix.
userLogger.info("[nip46-diag] restore: scheduleStoredRemoteSignerRestore", {
hasStored: !!stored,
storedPubkey: stored?.pubkey || stored?.userPubkey || null,
storedKeys: stored ? Object.keys(stored) : [],
});
if (!stored) {
return;
}
@@ -896,8 +903,15 @@ export class SignerManager {
silent: true,
validator: this.buildAccessControlValidator(),
});
userLogger.info("[nip46-diag] restore: succeeded", {
resolvedPubkey: this.pubkey || null,
});
} catch (err) {
devLogger.warn("Failed to restore remote signer", err);
// Surfaced via userLogger so it's visible on unstable (devLogger is gated).
userLogger.warn("[nip46-diag] restore: FAILED", {
message: err?.message || String(err),
code: err?.code || null,
});
}
};
@@ -905,6 +919,14 @@ export class SignerManager {
}
async disconnectRemoteSigner({ keepStored = false } = {}) {
// [nip46-diag] TEMP (todo #33): the single shared nip46Client is torn down here;
// logout/switch calling this is the suspected "logs out everyone" cause.
userLogger.info("[nip46-diag] disconnectRemoteSigner", {
keepStored,
hadClient: !!this.nip46Client,
pubkey: this.pubkey || null,
stack: new Error().stack?.split("\n").slice(2, 5).join(" <- "),
});
// Abort an in-flight nostrconnect:// handshake wait, if any.
if (typeof this.pendingHandshakeCancel === "function") {
try {
@@ -1016,6 +1038,13 @@ export class SignerManager {
logout() {
const previousPubkey = this.pubkey;
// [nip46-diag] TEMP (todo #33): trace logout scope — which pubkey + whether a
// shared remote-signer client is being torn down (affects other NIP-46 profiles).
userLogger.info("[nip46-diag] logout", {
previousPubkey: previousPubkey || null,
hadRemoteClient: !!this.nip46Client,
sessionActorSource: this.sessionActor?.source || null,
});
this.pubkey = null;
logoutSignerFromRegistry(previousPubkey);
const previousSessionActor = this.sessionActor;
+20
View File
@@ -56,6 +56,7 @@ export const NOTE_TYPES = Object.freeze({
ADMIN_MODERATION_LIST: "adminModerationList",
ADMIN_BLACKLIST: "adminBlacklist",
ADMIN_WHITELIST: "adminWhitelist",
ADMIN_EVENT_BLACKLIST: "adminEventBlacklist",
PROFILE_METADATA: "profileMetadata",
MUTE_LIST: "muteList",
DELETION: "deletion",
@@ -82,6 +83,7 @@ export const ADMIN_LIST_IDENTIFIERS = Object.freeze({
editors: "editors",
whitelist: "whitelist",
blacklist: "blacklist",
eventBlacklist: "event-blacklist",
communityBlacklistSources: ADMIN_COMMUNITY_BLACKLIST_SOURCES,
});
@@ -635,6 +637,21 @@ const BASE_SCHEMAS = {
appendTags: DEFAULT_APPEND_TAGS,
content: { format: "empty", description: "Content field unused." },
},
// Per-event (per-video) admin block list (#25): hides a SINGLE offending event
// without blocking its author. Same kind-30000 NIP-51 shape as the author lists,
// but the entries are EVENT ids carried as `e` tags (not `p` pubkey tags).
[NOTE_TYPES.ADMIN_EVENT_BLACKLIST]: {
type: NOTE_TYPES.ADMIN_EVENT_BLACKLIST,
label: "Admin event blacklist",
kind: 30000,
identifierTag: {
name: "d",
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.eventBlacklist}`,
},
participantTagName: "e",
appendTags: DEFAULT_APPEND_TAGS,
content: { format: "empty", description: "Content field unused." },
},
[NOTE_TYPES.PROFILE_METADATA]: {
type: NOTE_TYPES.PROFILE_METADATA,
label: "Profile metadata",
@@ -2977,6 +2994,9 @@ function resolveAdminNoteType(listKey) {
return NOTE_TYPES.ADMIN_WHITELIST;
case "blacklist":
return NOTE_TYPES.ADMIN_BLACKLIST;
case "eventBlacklist":
case "event-blacklist":
return NOTE_TYPES.ADMIN_EVENT_BLACKLIST;
default:
return null;
}
+17
View File
@@ -977,6 +977,12 @@ export default class AuthService {
const providerId = normalizeProviderId(normalizedOptions.providerId);
const normalizedActive = this.normalizeHexPubkey(getActiveProfilePubkey());
// [nip46-diag] TEMP (todo #33): trace profile switching — from/to + provider.
userLogger.info("[nip46-diag] switchProfile: begin", {
from: normalizedActive || null,
to: normalizedTarget,
providerId: providerId || null,
});
if (normalizedActive && normalizedActive === normalizedTarget) {
return { switched: false, reason: "already-active" };
}
@@ -993,6 +999,10 @@ export default class AuthService {
try {
requestResult = await this.requestLogin(requestOptions);
} catch (error) {
userLogger.warn("[nip46-diag] switchProfile: requestLogin FAILED", {
to: normalizedTarget,
message: error?.message || String(error),
});
throw error;
}
@@ -1001,6 +1011,13 @@ export default class AuthService {
(typeof requestResult?.pubkey === "string"
? requestResult.pubkey.trim()
: "");
// [nip46-diag] TEMP (todo #33): did the switch resolve the EXPECTED pubkey?
userLogger.info("[nip46-diag] switchProfile: requestLogin returned", {
to: normalizedTarget,
requestedPubkey: requestedPubkey || null,
matchesTarget: requestedPubkey === normalizedTarget,
hasDetail: !!requestResult?.detail,
});
let detail = requestResult?.detail || null;
+149 -3
View File
@@ -10,7 +10,11 @@
//
// Dependency-injected so it unit-tests without a live signer/relay pool.
import { buildNip71MirrorEvent } from "../nostr/nip71Mirror.js";
import {
buildNip71MirrorEvent,
NIP71_NORMAL_VIDEO_KIND,
NIP71_SHORT_VIDEO_KIND,
} from "../nostr/nip71Mirror.js";
import { nostrClient } from "../nostrClientFacade.js";
import { getActiveSigner } from "../nostr/index.js";
import {
@@ -34,6 +38,40 @@ export function createNip71MirrorService({
? nostrClient.getDeletePublishRelays()
: [],
getPool = () => nostrClient?.pool || null,
// L1 chokepoint (no direct pool.list — see scripts/check-direct-pool-access.mjs).
getSubscriptionManager = () =>
typeof nostrClient?.getSubscriptionManager === "function"
? nostrClient.getSubscriptionManager()
: null,
// Look up the author's existing mirror events (both kinds) for a video root, on
// the write relays where mirrors are published. Returns [{ kind, created_at }].
// Injected so the idempotency logic unit-tests without a live relay manager.
fetchExistingMirrors = async ({ pubkey, root }) => {
const sm = getSubscriptionManager();
const relays = getWriteRelays() || [];
if (!sm || typeof sm.list !== "function" || !relays.length) {
return [];
}
try {
const events = await sm.list({
filters: [
{
kinds: [NIP71_NORMAL_VIDEO_KIND, NIP71_SHORT_VIDEO_KIND],
authors: [pubkey],
"#d": [root],
},
],
relays,
});
return (Array.isArray(events) ? events : []).map((e) => ({
kind: e?.kind,
created_at: Number(e?.created_at) || 0,
}));
} catch (error) {
userLogger.warn("[nip71Mirror] existing-mirror lookup failed:", error);
return [];
}
},
publishEventToRelays = defaultPublishEventToRelays,
summarizePublishResults = defaultSummarize,
signEvent = async (template) => {
@@ -82,9 +120,37 @@ export function createNip71MirrorService({
}
const pubkey = str(video?.pubkey) || getActivePubkey();
const root = str(video?.videoRootId);
// Idempotency (#34): the mirror kind (34235 normal / 34236 short) is inferred
// from the video's dimensions, which aren't always present — so re-mirroring the
// same video could publish the OTHER kind, and since (kind,pubkey,d) is the
// addressable identity that produced a DUPLICATE in NIP-71 clients. Reuse the kind
// of any existing mirror so a re-publish REPLACES the same coordinate, and remember
// a stray of the other kind to clean up (self-heals duplicates created earlier).
let forcedShort = options.short; // explicit caller override always wins
let staleKind = null;
let reusedExistingKind = false;
if (forcedShort === undefined && pubkey && root) {
const existing = await fetchExistingMirrors({ pubkey, root });
if (Array.isArray(existing) && existing.length) {
const newest = existing.reduce((a, b) =>
(b.created_at || 0) > (a.created_at || 0) ? b : a,
);
forcedShort = newest.kind === NIP71_SHORT_VIDEO_KIND;
reusedExistingKind = true;
const otherKind = forcedShort
? NIP71_NORMAL_VIDEO_KIND
: NIP71_SHORT_VIDEO_KIND;
if (existing.some((e) => e.kind === otherKind)) {
staleKind = otherKind;
}
}
}
const built = buildMirrorEvent(
{ ...video, pubkey },
{ ...options, createdAt: Math.floor(now() / 1000) },
{ ...options, short: forcedShort, createdAt: Math.floor(now() / 1000) },
);
if (!built.ok) {
return { ok: false, reason: built.reason };
@@ -106,11 +172,37 @@ export function createNip71MirrorService({
const results = await publishEventToRelays(pool, relays, signed);
const { accepted, failed } = summarizePublishResults(results);
// Self-heal: if a duplicate of the OTHER kind already existed, tear it down via a
// NIP-09 delete so the video stops showing twice in NIP-71 clients.
let healedStaleKind = false;
if (staleKind != null && accepted.length > 0) {
try {
const del = await signEvent({
kind: 5,
pubkey,
created_at: Math.floor(now() / 1000),
content: "Removed duplicate NIP-71 mirror",
tags: [
["a", `${staleKind}:${pubkey}:${root}`],
["k", String(staleKind)],
],
});
const delResults = await publishEventToRelays(pool, relays, del);
healedStaleKind = summarizePublishResults(delResults).accepted.length > 0;
} catch (error) {
userLogger.warn("[nip71Mirror] Failed to clean up duplicate mirror:", error);
}
}
return {
ok: accepted.length > 0,
accepted: accepted.length,
total: accepted.length + failed.length,
event: signed,
kind: built.event.kind,
reusedExistingKind,
healedStaleKind,
};
}
@@ -186,7 +278,61 @@ export function createNip71MirrorService({
return { ok: publishedOk > 0, published: publishedOk, total };
}
return { isAvailable, canMirror, publish, remove };
// Relay-truth detection (#34): derive "is this mirrored?" from the actual
// published events instead of the device-local flag. `kinds.length > 1` flags a
// pre-existing cross-kind duplicate. (UI wiring is a follow-up — batch these.)
async function findMirror(video) {
const pubkey = str(video?.pubkey) || getActivePubkey();
const root = str(video?.videoRootId);
if (!pubkey || !root) {
return { mirrored: false, kinds: [], duplicate: false };
}
const existing = await fetchExistingMirrors({ pubkey, root });
const kinds = [
...new Set((Array.isArray(existing) ? existing : []).map((e) => e.kind).filter(Boolean)),
];
return { mirrored: kinds.length > 0, kinds, duplicate: kinds.length > 1 };
}
// Batched relay-truth detection for a list of videos (e.g. the My Videos grid):
// ONE query for all video roots instead of N. Returns Map<videoRootId,
// { mirrored, kinds, duplicate }>. Roots with no mirror are simply absent.
async function findMirrors(videos) {
const list = Array.isArray(videos) ? videos : [];
const pubkey = getActivePubkey();
const roots = [
...new Set(list.map((v) => str(v?.videoRootId)).filter(Boolean)),
];
const out = new Map();
if (!pubkey || !roots.length) {
return out;
}
// fetchExistingMirrors filters on a single #d; query each root but in one
// round via the shared lookup (the SubscriptionManager batches internally).
const results = await Promise.all(
roots.map(async (root) => {
const existing = await fetchExistingMirrors({ pubkey, root });
const kinds = [
...new Set(
(Array.isArray(existing) ? existing : [])
.map((e) => e.kind)
.filter(Boolean),
),
];
return [root, kinds];
}),
);
for (const [root, kinds] of results) {
out.set(root, {
mirrored: kinds.length > 0,
kinds,
duplicate: kinds.length > 1,
});
}
return out;
}
return { isAvailable, canMirror, publish, remove, findMirror, findMirrors };
}
export const nip71MirrorService = createNip71MirrorService();
+7 -2
View File
@@ -447,8 +447,13 @@ export class R2Service {
async resolveConnection(npub) {
if (!npub) return null;
// 1. Try StorageService
const pubkey = safeDecodeNpub(npub);
// 1. Try StorageService. Accept either a hex pubkey or an npub — some callers
// (e.g. ProfileEditController's image upload) pass hex, and safeDecodeNpub only
// decodes npub1… strings, so passing hex used to silently resolve no connection.
const trimmed = String(npub).trim();
const pubkey = /^[0-9a-f]{64}$/i.test(trimmed)
? trimmed.toLowerCase()
: safeDecodeNpub(trimmed);
if (pubkey && storageService) {
try {
const connections = await storageService.listConnections(pubkey);
+3
View File
@@ -3,8 +3,11 @@ import { ensureBucketCors, ensureBucketExists } from "../storage/s3-multipart.js
import {
buildS3PublicUrl,
buildS3UrlFromBase,
deriveB2Endpoint,
normalizeS3PublicBaseUrl,
} from "../storage/s3-url.js";
export { deriveB2Endpoint };
import { userLogger } from "../utils/logger.js";
function normalizeEndpoint(endpoint) {
+34 -1
View File
@@ -26,6 +26,30 @@ import {
const STATUS_VARIANTS = new Set(["info", "success", "error", "warning"]);
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
// A storageService.getConnection() result keeps bucket/region/endpoint/forcePathStyle
// under `.meta`; validateS3Connection reads them at the top level. Flatten so a saved
// connection (e.g. Backblaze B2) can be uploaded to without "S3 endpoint is required".
function flattenS3Settings(raw = {}) {
const meta = raw && typeof raw.meta === "object" && raw.meta ? raw.meta : {};
const pickBool = (a, b) =>
typeof a === "boolean" ? a : typeof b === "boolean" ? b : false;
return {
...raw,
endpoint: raw.endpoint || meta.endpoint || "",
bucket: raw.bucket || meta.bucket || "",
region: raw.region || meta.region || "auto",
accessKeyId: raw.accessKeyId || "",
secretAccessKey: raw.secretAccessKey || "",
forcePathStyle: pickBool(raw.forcePathStyle, meta.forcePathStyle),
publicBaseUrl:
raw.publicBaseUrl ||
meta.publicBaseUrl ||
raw.baseDomain ||
meta.baseDomain ||
"",
};
}
function normalizeInfoHash(value) {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
@@ -155,7 +179,16 @@ export class S3UploadService {
return prepared;
}
async prepareUpload(settings = {}, { createBucketIfMissing = true } = {}) {
async prepareUpload(settingsOrNpub = {}, options = {}) {
const { createBucketIfMissing = true, credentials } = options || {};
// Two call styles must both work:
// - direct: prepareUpload(settings, { createBucketIfMissing })
// - mediaUploader: prepareUpload(npub, { credentials }) ← like r2Service
// When credentials come from storageService.getConnection, the bucket/region/
// endpoint live under `.meta`, so flatten them to the top level that
// validateS3Connection reads. (npub is ignored when credentials are supplied.)
const raw = credentials || settingsOrNpub || {};
const settings = flattenS3Settings(raw);
const prepared = await this.verifyConnection({
settings,
createBucketIfMissing,
+21
View File
@@ -2,6 +2,7 @@ import { userLogger } from "../utils/logger.js";
import { clearLegacyR2Settings, loadLegacyR2Settings } from "../r2.js";
import { bytesToHex } from "../../vendor/crypto-helpers.bundle.min.js";
import { testS3Connection } from "../storage/r2-s3.js";
import { deriveB2Endpoint } from "../storage/s3-url.js";
const DB_NAME = "bitvid-storage";
const DB_VERSION = 1;
@@ -33,6 +34,7 @@ export const PROVIDERS = Object.freeze({
R2: "cloudflare_r2",
S3: "aws_s3",
GENERIC: "generic_s3",
B2: "backblaze_b2",
});
function resolveConnectionMeta(config) {
@@ -86,6 +88,16 @@ function resolveR2Endpoint(config) {
return `https://${accountId}.r2.cloudflarestorage.com`;
}
// Backblaze B2: prefer an explicit endpoint (lets a user override), otherwise derive
// the region-scoped S3 endpoint (https://s3.<region>.backblazeb2.com) from the region.
function resolveB2Endpoint(config) {
const explicit = resolveEndpoint(config);
if (explicit) {
return explicit;
}
return deriveB2Endpoint(resolveRegion(config));
}
function resolveForcePathStyle(config, fallback = false) {
const meta = resolveConnectionMeta(config);
if (typeof config.forcePathStyle === "boolean") {
@@ -142,6 +154,15 @@ const PROVIDER_TESTS = {
forcePathStyle: resolveForcePathStyle(config, true),
})
),
// B2 is addressed virtual-hosted-style; the public URL then derives to
// https://<bucket>.s3.<region>.backblazeb2.com (forcePathStyle false).
[PROVIDERS.B2]: (config) =>
testS3Connection(
buildS3TestConfig(config, {
endpoint: resolveB2Endpoint(config),
forcePathStyle: resolveForcePathStyle(config, false),
})
),
};
function hexToBytes(hex) {
+19
View File
@@ -17,6 +17,25 @@ function normalizeKey(key) {
return String(key || "").replace(/^\/+/, "").trim();
}
// Backblaze B2's S3-compatible endpoint is region-scoped:
// region "us-west-004" -> https://s3.us-west-004.backblazeb2.com
// Unlike generic S3, the user supplies a region (not a pasted endpoint) and bitvid
// derives the endpoint. Returns "" for a missing/placeholder region so callers can
// surface a "region required" error. B2 buckets are addressed virtual-hosted-style,
// so the public URL then derives to https://<bucket>.s3.<region>.backblazeb2.com via
// buildS3PublicUrl({ forcePathStyle: false }).
export function deriveB2Endpoint(region) {
const normalizedRegion = String(region || "").trim().toLowerCase();
if (!normalizedRegion || normalizedRegion === "auto") {
return "";
}
// Defend against a user pasting a full endpoint/host into the region box.
if (normalizedRegion.includes("/") || normalizedRegion.includes(".")) {
return "";
}
return `https://s3.${normalizedRegion}.backblazeb2.com`;
}
export function normalizeS3PublicBaseUrl(publicBaseUrl) {
const trimmed = String(publicBaseUrl || "").trim();
if (!trimmed) {
+38 -2
View File
@@ -543,6 +543,33 @@ export default class ApplicationBootstrap {
authService: app.authService,
requestAddProfileLogin: (options) =>
app.requestProfileAdditionLogin(options),
// Side-effect-free open of the login modal (no add-account callback). Used by
// the storage pane to route a locked persisted-nsec session to the existing
// unlock-saved-key (passphrase) flow. Returns true if the modal opened.
openLoginModal: (options = {}) => {
try {
if (typeof app.initializeLoginModalController === "function") {
app.initializeLoginModalController();
}
} catch (error) {
devLogger.warn(
"[Application] Failed to ensure login modal before open:",
error,
);
}
const controller = app.loginModalController;
if (controller && typeof controller.openModal === "function") {
try {
return controller.openModal(options) !== false;
} catch (error) {
devLogger.warn(
"[Application] Failed to open login modal:",
error,
);
}
}
return false;
},
describeLoginError: (error, fallbackMessage) =>
app.describeLoginError(error, fallbackMessage),
hashtagPreferences: app.hashtagPreferences,
@@ -1182,7 +1209,10 @@ export default class ApplicationBootstrap {
});
app.appChromeController.initialize();
app.blacklistedEventIds = new Set();
// Static, build-time per-event block list (operator config). Kept separate from
// the dynamic admin list (#25) so app._rebuildBlacklistedEventIds() can re-merge
// both whenever the published admin event-blacklist changes.
app._staticBlacklistedEventIds = new Set();
if (this.window?.NostrTools?.nip19?.decode) {
for (const neventStr of ADMIN_INITIAL_EVENT_BLACKLIST) {
if (!neventStr || neventStr.trim().length < 8) {
@@ -1191,7 +1221,7 @@ export default class ApplicationBootstrap {
try {
const decoded = this.window.NostrTools.nip19.decode(neventStr);
if (decoded?.type === "nevent" && decoded.data?.id) {
app.blacklistedEventIds.add(decoded.data.id);
app._staticBlacklistedEventIds.add(decoded.data.id);
}
} catch (err) {
devLogger.error(
@@ -1202,6 +1232,12 @@ export default class ApplicationBootstrap {
}
}
}
app.blacklistedEventIds = new Set(app._staticBlacklistedEventIds);
if (typeof app._rebuildBlacklistedEventIds === "function") {
// Fold in any already-loaded admin event-blacklist (accessControl may have
// hydrated from cache synchronously before this point).
app._rebuildBlacklistedEventIds();
}
app.unsubscribeFromPubkeyState = null;
app.unsubscribeFromCurrentUserState = null;
+54
View File
@@ -53,6 +53,7 @@ export class UploadModal {
eventTarget,
container,
onRequestStorageSettings,
onRequestUnlock,
} = {}) {
this.authService = authService || null;
this.r2Service = r2Service || null;
@@ -88,6 +89,9 @@ export class UploadModal {
eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
this.container = container || null;
this.onRequestStorageSettings = typeof onRequestStorageSettings === "function" ? onRequestStorageSettings : null;
// Opens the login modal's unlock-saved-key (passphrase) flow — used when a
// persisted nsec session is locked after a reload.
this.onRequestUnlock = typeof onRequestUnlock === "function" ? onRequestUnlock : null;
this.root = null;
this.isVisible = false;
@@ -813,6 +817,13 @@ export class UploadModal {
? signer.canSign()
: typeof signer?.signEvent === "function";
if (!canSign) {
// A persisted ("remember this key") nsec session restores the logged-in
// pubkey after a reload but NOT the in-memory signer (the key is passphrase-
// encrypted). Prompt the user to re-unlock their saved key instead of dead-
// ending — matches the storage pane's behavior (#36).
if (this.promptStoredNsecUnlock(pubkey)) {
return;
}
alert("No signer available to unlock storage.");
return;
}
@@ -844,6 +855,43 @@ export class UploadModal {
}
}
// Returns true if a locked persisted-nsec session was detected (for the active
// account) and the re-unlock (passphrase) flow was opened, so the caller can stop.
promptStoredNsecUnlock(pubkey) {
const client = this.authService?.nostrClient;
if (!client || typeof client.getStoredSessionActorMetadata !== "function") {
return false;
}
let meta = null;
try {
meta = client.getStoredSessionActorMetadata();
} catch (err) {
return false;
}
if (!meta || meta.hasEncryptedKey !== true || meta.source !== "nsec") {
return false;
}
const metaPubkey =
typeof meta.pubkey === "string" ? meta.pubkey.trim().toLowerCase() : "";
const activePubkey =
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
if (activePubkey && metaPubkey && metaPubkey !== activePubkey) {
return false;
}
if (typeof this.onRequestUnlock !== "function") {
return false;
}
this.showError(
"Your saved key is locked after reloading. Re-enter your passphrase to unlock it, then try the upload again.",
);
try {
this.onRequestUnlock();
} catch (err) {
userLogger.warn("[UploadModal] Failed to open saved-key unlock flow:", err);
}
return true;
}
updateLockUi() {
const locked = !this.isStorageUnlocked;
if (this.statusText?.storageLock) {
@@ -867,6 +915,9 @@ export class UploadModal {
if (provider === PROVIDERS.S3) {
return "Amazon S3";
}
if (provider === PROVIDERS.B2) {
return "Backblaze B2";
}
return "S3 Compatible";
}
@@ -881,6 +932,9 @@ export class UploadModal {
if (provider === PROVIDERS.R2 || provider === "cloudflare_r2") {
return "Uploads will target your Cloudflare R2 bucket.";
}
if (provider === PROVIDERS.B2) {
return "Uploads will target your Backblaze B2 bucket.";
}
return "Uploads will target your S3-compatible bucket.";
}
+15 -7
View File
@@ -111,7 +111,11 @@ export class MediaUploader {
async uploadThumbnail(file, { provider, credentials, onProgress } = {}) {
const pubkey = this.getCurrentPubkey();
const npub = this.safeEncodeNpub(pubkey);
const service = this.serviceFor(provider);
// Route by the credentials' own provider (see uploadVideo) to avoid wrong-service
// routing when the modal's provider state is stale.
const effectiveProvider =
credentials?.provider || credentials?.meta?.provider || provider;
const service = this.serviceFor(effectiveProvider);
const { settings, bucketEntry } = await service.prepareUpload(npub, {
credentials,
@@ -121,7 +125,7 @@ export class MediaUploader {
const cleanName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
const key = `${npub}/thumbnails/${timestamp}-${cleanName}`;
const baseDomain = bucketEntry.publicBaseUrl;
const url = this.publicUrlFor(provider, baseDomain, key);
const url = this.publicUrlFor(effectiveProvider, baseDomain, key);
await service.uploadFile({
file,
@@ -129,7 +133,7 @@ export class MediaUploader {
key,
accountId: settings.accountId,
endpoint: settings.endpoint,
provider: settings.provider || provider || "cloudflare_r2",
provider: settings.provider || effectiveProvider || "cloudflare_r2",
region: settings.region,
accessKeyId: settings.accessKeyId,
secretAccessKey: settings.secretAccessKey,
@@ -201,7 +205,11 @@ export class MediaUploader {
const pubkey = this.getCurrentPubkey();
const npub = this.safeEncodeNpub(pubkey);
const service = this.serviceFor(provider);
// Route by the credentials' OWN provider so a stale activeProvider can't send an
// R2 connection (accountId, no endpoint) through the S3 path — or vice-versa.
const effectiveProvider =
credentials?.provider || credentials?.meta?.provider || provider;
const service = this.serviceFor(effectiveProvider);
const { settings, bucketEntry } = await service.prepareUpload(npub, {
credentials,
@@ -209,7 +217,7 @@ export class MediaUploader {
const videoKey = buildR2Key(npub, file, identifier);
const baseDomain = bucketEntry.publicBaseUrl;
const videoPublicUrl = this.publicUrlFor(provider, baseDomain, videoKey);
const videoPublicUrl = this.publicUrlFor(effectiveProvider, baseDomain, videoKey);
emit(0, "Uploading video...");
const uploadPromise = service.uploadFile({
@@ -218,7 +226,7 @@ export class MediaUploader {
key: videoKey,
accountId: settings.accountId,
endpoint: settings.endpoint,
provider: settings.provider || provider || "cloudflare_r2",
provider: settings.provider || effectiveProvider || "cloudflare_r2",
region: settings.region,
accessKeyId: settings.accessKeyId,
secretAccessKey: settings.secretAccessKey,
@@ -239,7 +247,7 @@ export class MediaUploader {
publicBaseUrl: baseDomain,
key: videoKey,
});
const providerLabel = isR2Provider(provider) ? "r2" : "s3";
const providerLabel = isR2Provider(effectiveProvider) ? "r2" : "s3";
const storagePointer = buildStoragePointerValue({
provider: providerLabel,
prefix: storagePrefix,
+6
View File
@@ -282,6 +282,12 @@ export function createVideoMoreMenuPanel({
variant: "critical",
dataset: { author: metadata.pubkey },
});
appendMenuAction(doc, list, {
text: "Block this video",
action: "blacklist-event",
variant: "critical",
dataset: { eventId: metadata.id },
});
}
appendMenuAction(doc, list, {
+13
View File
@@ -0,0 +1,13 @@
// A focus trap (e.g. the profile modal's) must YIELD focus to any modal stacked on
// top of it — otherwise it yanks focus back and the stacked modal's fields (like the
// login modal's nsec passphrase/PIN input) can't be clicked or typed into.
//
// Returns true when the focus target lives inside a DIFFERENT open `.bv-modal` than the
// trapping modal — i.e. the trap should leave focus alone.
export function isInStackedModal(target, modalRoot) {
if (!target || typeof target.closest !== "function") {
return false;
}
const stacked = target.closest(".bv-modal:not(.hidden)");
return Boolean(stacked && stacked !== modalRoot);
}
+16
View File
@@ -55,6 +55,22 @@ export function initUploadModal({
app.profileController.show("storage");
}
},
// Open the login modal's unlock-saved-key (passphrase) flow when a persisted
// nsec session is locked after a reload.
onRequestUnlock: () => {
try {
if (typeof app?.initializeLoginModalController === "function") {
app.initializeLoginModalController();
}
const controller = app?.loginModalController;
if (controller && typeof controller.openModal === "function") {
return controller.openModal({}) !== false;
}
} catch (error) {
/* best-effort */
}
return false;
},
});
const boundSubmitHandler =
+56
View File
@@ -0,0 +1,56 @@
// Per-event (per-video) admin block action (#25) for the ⋯ menu. Editor-gated; adds
// the video's event id to the published admin event-blacklist and refreshes the grids.
// Extracted from moreMenuController so that large file stays under its size cap.
import { userLogger } from "../../utils/logger.js";
export async function handleBlacklistEventAction({
accessControl,
callbacks,
dataset = {},
currentVideo = null,
} = {}) {
const actorNpub = callbacks.getCurrentUserNpub?.();
if (!actorNpub) {
callbacks.showError?.("Please login as a moderator to block a video.");
return;
}
try {
await accessControl?.ensureReady?.();
} catch (error) {
userLogger.warn("Failed to refresh moderation state before event block:", error);
}
if (!accessControl?.canEditAdminLists?.(actorNpub)) {
callbacks.showError?.("Only moderators can block a video.");
return;
}
const eventId =
(typeof dataset.eventId === "string" && dataset.eventId.trim()) ||
currentVideo?.id ||
"";
if (!eventId) {
callbacks.showError?.("Unable to determine which video to block.");
return;
}
try {
const result = await accessControl.addToEventBlacklist(actorNpub, eventId);
if (result?.ok) {
callbacks.showSuccess?.("Video added to the block list.");
await callbacks
.refreshAllVideoGrids?.({
reason: "admin-event-blacklist-update",
forceMainReload: true,
})
.catch(() => {});
} else {
callbacks.showError?.(
result?.error === "forbidden"
? "Only moderators can block a video."
: "Failed to block the video. Please try again.",
);
}
} catch (error) {
userLogger.error("Failed to add video to event blacklist:", error);
callbacks.showError?.("Failed to block the video. Please try again.");
}
}
+12 -12
View File
@@ -5,6 +5,7 @@ import {
createVideoMoreMenuPanel,
} from "./components/videoMenuRenderers.js";
import createPopover from "./overlay/popoverEngine.js";
import { handleBlacklistEventAction } from "./moreMenu/blacklistEventAction.js";
import {
prepareStaticModal,
openStaticModal,
@@ -1019,9 +1020,7 @@ export default class MoreMenuController {
event.preventDefault();
event.stopPropagation();
// The modal ⋯ trigger declares its context via `data-more-dropdown`
// ("modal"), not `data-context` — reading only the latter made it
// default to "card" and skip the modal video resolution below.
// Modal ⋯ context comes from `data-more-dropdown`, not `data-context`.
const context =
button.getAttribute("data-context") ||
button.getAttribute("data-more-dropdown") ||
@@ -1034,8 +1033,7 @@ export default class MoreMenuController {
if (context === "modal") {
video = this.callbacks.getCurrentVideo();
// currentVideo can be null when the modal opened without playback
// starting (e.g. a deep link); fall back to the modal's activeVideo.
// currentVideo can be null on a deep link; fall back to activeVideo.
if (!video && this.videoModal?.activeVideo) {
video = this.videoModal.activeVideo;
}
@@ -1175,9 +1173,6 @@ export default class MoreMenuController {
break;
}
case "view-stats": {
// Prefer the card's own video; fall back to resolving it from the menu
// item's eventId (card popovers don't always carry the object), and only
// use the modal's current video for the modal context.
let targetVideo = video || null;
if (!targetVideo && dataset?.eventId) {
targetVideo =
@@ -1190,7 +1185,6 @@ export default class MoreMenuController {
this.callbacks.showError("No video selected.");
break;
}
// Dismiss the menu, then open the public popularity chart (lazy-loaded).
try {
this.activePopover?.close?.();
} catch (error) {
@@ -1469,6 +1463,15 @@ export default class MoreMenuController {
}
break;
}
case "blacklist-event": {
await handleBlacklistEventAction({
accessControl: this.accessControl,
callbacks: this.callbacks,
dataset,
currentVideo,
});
break;
}
case "block-author": {
const currentUserPubkey = this.callbacks.getCurrentUserPubkey();
if (!currentUserPubkey) {
@@ -1625,13 +1628,10 @@ export default class MoreMenuController {
break;
}
case "event-details": {
// Card context passes the card's video; modal context falls back to the
// active video (the dispatch path doesn't carry the video object).
let targetVideo = video;
if (!targetVideo && context === "modal") {
targetVideo = currentVideo;
}
// Dismiss the menu before opening the details modal.
try {
this.activePopover?.close?.();
} catch (error) {
+123 -1
View File
@@ -50,6 +50,8 @@ export class MyVideosController {
this.refreshBtn = null;
this.orphansSection = null;
this.orphanListEl = null;
this.orphanDeleteAllBtn = null;
this.orphanKeys = [];
this.autoShareRow = null;
this.autoShareInput = null;
this.loading = false;
@@ -67,6 +69,8 @@ export class MyVideosController {
document.getElementById("profileMyVideosOrphans") || null;
this.orphanListEl =
document.getElementById("profileMyVideosOrphanList") || null;
this.orphanDeleteAllBtn =
document.getElementById("profileMyVideosOrphanDeleteAll") || null;
this.autoShareRow =
document.getElementById("profileMyVideosAutoShareRow") || null;
this.autoShareInput =
@@ -88,6 +92,11 @@ export class MyVideosController {
setAutoShareEnabled(pubkey, this.autoShareInput.checked === true);
});
}
if (this.orphanDeleteAllBtn instanceof HTMLElement) {
this.orphanDeleteAllBtn.addEventListener("click", () => {
void this.handleDeleteAllOrphans();
});
}
}
refreshAutoShareToggle(pubkey) {
@@ -230,9 +239,16 @@ export class MyVideosController {
renderOrphans(keys) {
const list = Array.isArray(keys) ? keys : [];
this.orphanKeys = list;
if (this.orphansSection instanceof HTMLElement) {
this.orphansSection.classList.toggle("hidden", list.length === 0);
}
if (this.orphanDeleteAllBtn instanceof HTMLElement) {
this.orphanDeleteAllBtn.textContent =
list.length > 1 ? `Delete all (${list.length})` : "Delete all";
// Only worth a bulk action when there's more than one file.
this.orphanDeleteAllBtn.classList.toggle("hidden", list.length < 2);
}
if (!(this.orphanListEl instanceof HTMLElement)) {
return;
}
@@ -289,6 +305,56 @@ export class MyVideosController {
}
}
// Bulk "delete all orphans" — one call removes every listed orphaned object (each is
// already confirmed safe by reconcileStorage: no live note references it, under the
// user's prefix only). deleteStorageKeys takes the whole key array at once.
async handleDeleteAllOrphans() {
const r2Service = this.mainController.services?.r2Service;
const keys = Array.isArray(this.orphanKeys) ? this.orphanKeys.slice() : [];
if (!r2Service || typeof r2Service.deleteStorageKeys !== "function" || !keys.length) {
return;
}
if (typeof window !== "undefined" && typeof window.confirm === "function") {
if (
!window.confirm(
`Permanently delete all ${keys.length} orphaned files from your bucket?`,
)
) {
return;
}
}
if (this.orphanDeleteAllBtn instanceof HTMLElement) {
this.orphanDeleteAllBtn.disabled = true;
this.orphanDeleteAllBtn.textContent = "Deleting…";
}
let result;
try {
result = await r2Service.deleteStorageKeys({ keys, pubkey: this.pubkey });
} catch (err) {
devLogger.warn("[myVideos] bulk deleteStorageKeys failed:", err);
this.mainController.showError?.("Failed to delete the files. Please try again.");
return;
} finally {
if (this.orphanDeleteAllBtn instanceof HTMLElement) {
this.orphanDeleteAllBtn.disabled = false;
}
}
const deleted = result?.deleted?.length || 0;
if (result?.ok && deleted) {
const failed = keys.length - deleted;
this.mainController.showSuccess?.(
failed > 0
? `Removed ${deleted} files (${failed} could not be deleted).`
: `Removed all ${deleted} orphaned files from your bucket.`,
);
void this.populate({ forceFetch: false });
} else if (result?.reason === "storage-locked") {
this.mainController.showError?.("Unlock storage first, then delete the files.");
} else {
this.mainController.showError?.("Failed to delete the files. Please try again.");
}
}
buildSummary(rows) {
if (!rows.length) {
return "";
@@ -336,6 +402,9 @@ export class MyVideosController {
for (const video of rows) {
this.listEl.appendChild(this.buildRow(video));
}
// After the synchronous render (which used the local flag), correct the mirror
// labels from relay truth in the background. Fire-and-forget.
this.refreshMirrorStates(rows);
}
buildRow(video) {
@@ -441,10 +510,63 @@ export class MyVideosController {
btn.classList.add("opacity-50");
return btn;
}
return this.buildActionButton(
const btn = this.buildActionButton(
enabled ? "Shared ✓" : "Share to apps",
(event) => this.handleToggleMirror(video, event?.currentTarget || null),
);
// Tag with the root so the batched relay-truth refresh can update this button.
const root = video?.videoRootId;
if (root) {
btn.dataset.mirrorRoot = String(root);
}
return btn;
}
// Relay-truth detection (#34): the per-row mirror label initially uses the
// device-local flag (synchronous), which can be stale/missing on a fresh device or
// after a cache clear. After render, look up the ACTUAL published mirror events
// (one batched query for the whole list), reconcile the local flag to match, and
// correct any button labels. Fire-and-forget — never blocks the render.
async refreshMirrorStates(videos) {
if (!FEATURE_NIP71_MIRROR || !this.pubkey) {
return;
}
if (typeof nip71MirrorService.findMirrors !== "function") {
return;
}
let states;
try {
states = await nip71MirrorService.findMirrors(videos);
} catch (error) {
// Detection is best-effort; the local flag remains as a hint.
return;
}
if (!(states instanceof Map) || !(this.listEl instanceof HTMLElement)) {
return;
}
for (const video of Array.isArray(videos) ? videos : []) {
const root = video?.videoRootId ? String(video.videoRootId) : "";
if (!root) {
continue;
}
const mirrored = states.get(root)?.mirrored === true;
// Reconcile the local flag so it's a correct cache going forward.
try {
setMirrorEnabled(this.pubkey, root, mirrored);
} catch (error) {
// ignore storage errors
}
const safeRoot =
typeof CSS !== "undefined" && typeof CSS.escape === "function"
? CSS.escape(root)
: root.replace(/["\\]/g, "\\$&");
const btn = this.listEl.querySelector(
`[data-mirror-root="${safeRoot}"]`,
);
if (btn instanceof HTMLElement && !btn.disabled) {
btn.textContent = mirrored ? "Shared ✓" : "Share to apps";
}
}
}
async handleToggleMirror(video, btn) {
+199 -66
View File
@@ -1,12 +1,21 @@
import { devLogger } from "../../utils/logger.js";
import { devLogger, userLogger } from "../../utils/logger.js";
import { PROVIDERS } from "../../services/storageService.js";
import {
prepareS3Connection,
getCorsOrigins,
deriveB2Endpoint,
derivePublicBaseUrl,
} from "../../services/s3Service.js";
import { getActiveSigner } from "../../nostr/client.js";
import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js";
import { storageSyncService } from "../../services/storageSyncService.js";
import { StorageCorsHelp } from "./storageCorsHelp.js";
import {
fillStorageForm,
clearCredentialFields,
findProviderConnection,
saveProviderConnection,
} from "./storageConnections.js";
export class ProfileStorageController {
constructor(mainController) {
@@ -31,7 +40,15 @@ export class ProfileStorageController {
this.storagePrefixWarning = null;
this.storageDefaultInput = null;
this.storageR2Helper = null;
this.storageB2Helper = null;
this.storageS3Helper = null;
// Provider-aware CORS setup helper modal — owns its own DOM + behavior.
this.corsHelp = new StorageCorsHelp({
getProvider: () => this.storageProviderInput?.value || "cloudflare_r2",
getBucket: () => this.storageBucketInput?.value?.trim() || "",
getRegion: () => this.storageRegionInput?.value?.trim() || "",
getEndpoint: () => this.storageEndpointInput?.value?.trim() || "",
});
this.storageForcePathStyleInput = null;
this.storageForcePathStyleLabel = null;
@@ -63,7 +80,9 @@ export class ProfileStorageController {
this.storagePrefixWarning = document.getElementById("storagePrefixWarning") || null;
this.storageDefaultInput = document.getElementById("storageDefault") || null;
this.storageR2Helper = document.getElementById("storageR2Helper") || null;
this.storageB2Helper = document.getElementById("storageB2Helper") || null;
this.storageS3Helper = document.getElementById("storageS3Helper") || null;
this.corsHelp.cacheDom(document);
this.storageForcePathStyleInput = document.getElementById("storageForcePathStyle") || null;
this.storageForcePathStyleLabel = document.getElementById("storageForcePathStyleLabel") || null;
this.storageSyncSection = document.getElementById("profileStorageSync") || null;
@@ -99,7 +118,7 @@ export class ProfileStorageController {
if (this.storageProviderInput instanceof HTMLElement) {
this.storageProviderInput.addEventListener("change", () => {
this.updateStorageFormVisibility();
void this.handleProviderChange();
});
}
@@ -120,6 +139,8 @@ export class ProfileStorageController {
void this.handleRestoreSync();
});
}
this.corsHelp.registerEventListeners();
}
async populateStoragePane() {
@@ -170,6 +191,11 @@ export class ProfileStorageController {
} else if (this.storageUnlockFailure?.message) {
this.storageStatusText.textContent = `Locked (${this.storageUnlockFailure.message})`;
this.storageStatusText.className = "text-xs text-status-danger";
} else if (this.getLockedStoredNsecSession(pubkey)) {
// Saved nsec key not yet re-unlocked after reload — point the user at the fix.
this.storageStatusText.textContent =
"Locked — re-unlock your saved key (Login → passphrase) to manage storage.";
this.storageStatusText.className = "text-xs text-status-warning";
} else {
this.storageStatusText.textContent = "Locked";
this.storageStatusText.className = "text-xs text-status-warning";
@@ -193,7 +219,7 @@ export class ProfileStorageController {
targetConn.id
);
if (conn) {
this.fillStorageForm(conn);
fillStorageForm(this, conn);
if (this.storageStatusText) {
const label = conn.meta?.label || conn.provider || "S3";
this.storageStatusText.textContent = `Unlocked (${label})`;
@@ -336,57 +362,32 @@ export class ProfileStorageController {
}
}
fillStorageForm(conn) {
if (!conn) return;
const {
provider,
accessKeyId,
secretAccessKey,
accountId: payloadAccountId,
endpoint: payloadEndpoint,
forcePathStyle: payloadForcePathStyle,
} = conn;
const {
endpoint,
region,
bucket,
prefix,
defaultForUploads,
accountId,
forcePathStyle: metaForcePathStyle,
} = conn.meta || {};
if (this.storageProviderInput)
this.storageProviderInput.value = provider || "cloudflare_r2";
const resolvedEndpoint =
endpoint || accountId || payloadAccountId || payloadEndpoint || "";
if (this.storageEndpointInput)
this.storageEndpointInput.value = resolvedEndpoint;
if (this.storageRegionInput)
this.storageRegionInput.value = region || "auto";
if (this.storageAccessKeyInput)
this.storageAccessKeyInput.value = accessKeyId || "";
if (this.storageSecretKeyInput)
this.storageSecretKeyInput.value = secretAccessKey || "";
if (this.storageBucketInput) this.storageBucketInput.value = bucket || "";
if (this.storagePrefixInput) this.storagePrefixInput.value = prefix || "";
if (this.storageDefaultInput)
this.storageDefaultInput.checked = !!defaultForUploads;
if (this.storageForcePathStyleInput) {
if (typeof payloadForcePathStyle === "boolean") {
this.storageForcePathStyleInput.checked = payloadForcePathStyle;
} else if (typeof metaForcePathStyle === "boolean") {
this.storageForcePathStyleInput.checked = metaForcePathStyle;
} else {
this.storageForcePathStyleInput.checked = true;
}
}
// Load the saved connection for the newly-selected provider so each provider keeps
// its own credentials; clear the credential fields when that provider has none yet.
async handleProviderChange() {
this.updateStorageFormVisibility();
this.handlePublicUrlInput();
const pubkey = this.mainController.normalizeHexPubkey(
this.mainController.getActivePubkey(),
);
const storageService = this.mainController.services?.storageService;
if (!pubkey || !storageService || !storageService.isUnlocked?.(pubkey)) {
return;
}
const provider = this.storageProviderInput?.value || "cloudflare_r2";
try {
const connections = await storageService.listConnections(pubkey);
const match = findProviderConnection(connections, provider);
if (match) {
const conn = await storageService.getConnection(pubkey, match.id);
if (conn) {
fillStorageForm(this, conn);
return;
}
}
clearCredentialFields(this);
} catch (error) {
devLogger.warn("[ProfileModal] Failed to load connection for provider:", error);
}
}
handlePublicUrlInput() {
@@ -398,6 +399,8 @@ export class ProfileStorageController {
updateStorageFormVisibility() {
const provider = this.storageProviderInput?.value || "cloudflare_r2";
const isR2 = provider === "cloudflare_r2";
// B2 derives its endpoint from the region, so its raw Endpoint field is hidden.
const isB2 = provider === PROVIDERS.B2;
if (this.storageEndpointInput) {
const label =
@@ -411,6 +414,13 @@ export class ProfileStorageController {
if (this.storagePrefixInput) {
this.storagePrefixInput.placeholder = "https://pub-xxx.r2.dev";
}
} else if (isB2) {
// Endpoint is derived from the region; hide the raw endpoint input.
this.storageEndpointInput.parentElement.classList.add("hidden");
if (this.storagePrefixInput) {
this.storagePrefixInput.placeholder =
"Optional — derived from region/bucket (or your CDN domain)";
}
} else {
if (label) label.textContent = "Endpoint URL";
this.storageEndpointInput.placeholder = "https://s3.example.com";
@@ -422,25 +432,46 @@ export class ProfileStorageController {
}
}
if (this.storageRegionInput && isB2) {
this.storageRegionInput.placeholder = "us-west-004";
} else if (this.storageRegionInput) {
this.storageRegionInput.placeholder = "auto";
}
// B2 calls the secret an "Application Key" — match its terminology.
const secretLabel =
this.storageSecretKeyInput?.parentElement?.querySelector("span");
if (secretLabel) {
secretLabel.textContent = isB2 ? "Application Key" : "Secret Access Key";
}
if (this.storageR2Helper) {
if (isR2) this.storageR2Helper.classList.remove("hidden");
else this.storageR2Helper.classList.add("hidden");
this.storageR2Helper.classList.toggle("hidden", !isR2);
}
if (this.storageB2Helper) {
this.storageB2Helper.classList.toggle("hidden", !isB2);
}
if (this.storageS3Helper) {
if (!isR2) this.storageS3Helper.classList.remove("hidden");
else this.storageS3Helper.classList.add("hidden");
// Generic/custom S3 helper only — not R2, not B2 (each has its own helper).
this.storageS3Helper.classList.toggle("hidden", isR2 || isB2);
}
if (this.storageForcePathStyleLabel) {
if (!isR2)
this.storageForcePathStyleLabel.classList.remove("hidden", "flex");
else this.storageForcePathStyleLabel.classList.add("hidden");
if (!isR2) {
this.storageForcePathStyleLabel.classList.add("flex");
}
// Force-path-style is a generic-S3 knob; R2 and B2 fix it internally.
const showForcePathStyle = !isR2 && !isB2;
this.storageForcePathStyleLabel.classList.toggle(
"hidden",
!showForcePathStyle,
);
this.storageForcePathStyleLabel.classList.toggle(
"flex",
showForcePathStyle,
);
}
this.corsHelp.setVisible(true); // provider-aware; useful for all bucket providers
}
getStorageUnlockFailureMessage(error) {
@@ -451,6 +482,8 @@ export class ProfileStorageController {
return "Storage unlock requires extension encryption permission. Approve the prompt, then retry unlock.";
case "storage-unlock-no-decryptor":
return "Your signer cannot decrypt storage keys. Use a signer with NIP-44 or NIP-04 decrypt support.";
case "storage-unlock-locked-nsec-session":
return "Your saved key is locked after reloading the page. Open the Login menu and re-enter your passphrase to unlock your saved key, then unlock storage.";
case "storage-unlock-decrypt-failed":
return "Unable to decrypt your saved storage key. Retry unlock and confirm the active account matches.";
default:
@@ -481,6 +514,61 @@ export class ProfileStorageController {
this.storageUnlockFailure = null;
}
// After a page reload, a persisted nsec session restores the logged-in pubkey + UI
// but NOT the in-memory signer (the key is passphrase-encrypted). Storage unlock then
// has no usable signer, and the generic "No active signer" error is misleading.
// Detect this exact case (a saved nsec key for the account we're unlocking).
getLockedStoredNsecSession(pubkey) {
const client = this.mainController.services?.nostrClient;
if (!client || typeof client.getStoredSessionActorMetadata !== "function") {
return null;
}
let meta = null;
try {
meta = client.getStoredSessionActorMetadata();
} catch (error) {
return null;
}
if (!meta || meta.hasEncryptedKey !== true || meta.source !== "nsec") {
return null;
}
const metaPubkey = this.mainController.normalizeHexPubkey(meta.pubkey);
// Only treat it as "this account is locked" when the saved key is for the pubkey
// we're actually trying to unlock (don't hijack a different-account situation).
if (pubkey && metaPubkey && metaPubkey !== pubkey) {
return null;
}
return meta;
}
reportLockedNsecSession({ autoOpenLogin = false } = {}) {
userLogger.info(
"[storage-unlock] saved nsec key is locked (no in-memory signer after reload); prompting re-unlock.",
);
const error = new Error(
"Your saved key is locked after reloading the page. Re-enter your passphrase from the Login menu to unlock it, then unlock storage.",
);
error.code = "storage-unlock-locked-nsec-session";
this.setStorageUnlockFailureState(error);
this.mainController.showError(this.getStorageUnlockFailureMessage(error));
// On an explicit "Unlock Storage" click, open the login modal's unlock-saved-key
// (passphrase) flow so the user re-unlocks in one step (not from passive render).
if (autoOpenLogin) {
const openLoginModal = this.mainController.services?.openLoginModal;
if (typeof openLoginModal === "function") {
try {
openLoginModal();
} catch (openError) {
devLogger.warn(
"[ProfileModal] Failed to open login modal for saved-key unlock:",
openError,
);
}
}
}
}
async requestStorageUnlockPermissions() {
const client = this.mainController.services?.nostrClient;
if (!client || typeof client.ensureExtensionPermissions !== "function") {
@@ -521,6 +609,10 @@ export class ProfileStorageController {
}
if (!signer) {
if (this.getLockedStoredNsecSession(pubkey)) {
this.reportLockedNsecSession({ autoOpenLogin: true });
return;
}
this.mainController.showError("No active signer found. Please login.");
return;
}
@@ -592,6 +684,12 @@ export class ProfileStorageController {
typeof signer?.decrypt === "function";
if (!hasNip44Decrypt && !hasNip04Decrypt) {
// A restored-but-locked persisted nsec session can leave a decrypt-less stub
// signer; route it to the re-unlock guidance rather than the generic message.
if (this.getLockedStoredNsecSession(pubkey)) {
this.reportLockedNsecSession({ autoOpenLogin: true });
return;
}
const missingDecryptError = new Error(
"This signer cannot decrypt storage keys (NIP-44/NIP-04 missing)."
);
@@ -654,6 +752,17 @@ export class ProfileStorageController {
const prefix = this.storagePrefixInput?.value?.trim() || "";
const isDefault = this.storageDefaultInput?.checked || false;
if (provider === PROVIDERS.B2) {
endpointOrAccount = deriveB2Endpoint(region); // derived from region for B2
if (!endpointOrAccount) {
this.setStorageFormStatus(
"Enter your Backblaze B2 region (e.g. us-west-004).",
"error",
);
return;
}
}
if (!accessKeyId || !secretAccessKey || !bucket || !endpointOrAccount) {
this.setStorageFormStatus("Please fill in all required fields.", "error");
return;
@@ -695,6 +804,8 @@ export class ProfileStorageController {
secretAccessKey,
bucket,
forcePathStyle,
// Explicit Public Access URL (custom domain/CDN); blank → derived.
publicBaseUrl: prefix || undefined,
origins: getCorsOrigins(),
});
endpointOrAccount = normalized.endpoint;
@@ -736,7 +847,13 @@ export class ProfileStorageController {
this.setStorageFormStatus("Saving...", "info");
try {
await storageService.saveConnection(pubkey, "default", payload, meta);
// One slot per provider type so providers don't overwrite or clash.
await saveProviderConnection(storageService, pubkey, {
provider,
payload,
meta,
isDefault,
});
this.setStorageFormStatus("Connection saved.", "success");
this.mainController.showSuccess("Storage connection saved.");
@@ -777,7 +894,7 @@ export class ProfileStorageController {
}
const provider = this.storageProviderInput?.value || "cloudflare_r2";
const endpointOrAccount = this.storageEndpointInput?.value?.trim() || "";
let endpointOrAccount = this.storageEndpointInput?.value?.trim() || "";
const region = this.storageRegionInput?.value?.trim() || "auto";
const accessKeyId = this.storageAccessKeyInput?.value?.trim() || "";
const secretAccessKey = this.storageSecretKeyInput?.value?.trim() || "";
@@ -790,6 +907,17 @@ export class ProfileStorageController {
const publicBaseUrl = this.storagePrefixInput?.value?.trim() || "";
if (provider === PROVIDERS.B2) {
endpointOrAccount = deriveB2Endpoint(region); // derived from region for B2
if (!endpointOrAccount) {
this.setStorageFormStatus(
"Enter your Backblaze B2 region (e.g. us-west-004) to test.",
"error",
);
return;
}
}
if (!accessKeyId || !secretAccessKey || !endpointOrAccount) {
this.setStorageFormStatus("Missing credentials for test.", "error");
return;
@@ -825,6 +953,11 @@ export class ProfileStorageController {
} else {
config.endpoint = endpointOrAccount;
config.forcePathStyle = forcePathStyle;
// Explicit Public Access URL, else derive it (so the test doesn't falsely warn).
config.publicBaseUrl =
publicBaseUrl ||
derivePublicBaseUrl({ endpoint: endpointOrAccount, bucket, forcePathStyle });
config.baseDomain = config.publicBaseUrl;
}
try {
+126
View File
@@ -0,0 +1,126 @@
// Per-provider connection model for the Storage pane.
//
// Each provider type (Cloudflare R2 / Backblaze B2 / Custom S3) gets its OWN connection
// slot, keyed by the provider id, so their credentials don't overwrite or clash. The
// underlying storageService already stores a map of connections + syncs the whole
// account record, so this just stops the UI from collapsing everything into one slot.
export function connectionProvider(conn) {
return (conn && (conn.provider || conn.meta?.provider)) || "";
}
export function findProviderConnection(connections, provider) {
return (
(Array.isArray(connections) ? connections : []).find(
(c) => connectionProvider(c) === provider,
) || null
);
}
// Keep a sane active upload target: default when the user asked, when it's the first
// connection saved, or when re-saving the provider that was already the default.
export function computeDefaultForUploads({ isDefault, connections, provider }) {
if (isDefault) {
return true;
}
if (!Array.isArray(connections) || connections.length === 0) {
return true;
}
return (
findProviderConnection(connections, provider)?.meta?.defaultForUploads === true
);
}
// Other-id connections of the same provider (e.g. the legacy shared "default" slot)
// that should be removed after saving so they can't clash with the per-provider slot.
export function legacyDuplicateIds(connections, provider, keepId) {
return (Array.isArray(connections) ? connections : [])
.filter((c) => c.id !== keepId && connectionProvider(c) === provider)
.map((c) => c.id);
}
// Persist a connection under its per-provider slot: pick a sane default-for-uploads,
// save, then remove any legacy/duplicate same-provider connection so nothing clashes.
// Returns the connection id used. Shared by the controller + tests (one source of truth).
export async function saveProviderConnection(
storageService,
pubkey,
{ provider, payload, meta = {}, isDefault = false } = {},
) {
const existing = await storageService.listConnections(pubkey);
meta.defaultForUploads = computeDefaultForUploads({
isDefault,
connections: existing,
provider,
});
await storageService.saveConnection(pubkey, provider, payload, meta);
for (const id of legacyDuplicateIds(existing, provider, provider)) {
await storageService.deleteConnection(pubkey, id);
}
return provider;
}
// Fill the Storage form inputs from a saved connection. `c` is the controller (used for
// its cached input refs + the visibility refresh) — keeps all the element wiring in one
// place without threading a dozen refs through.
export function fillStorageForm(c, conn) {
if (!conn) {
return;
}
const {
provider,
accessKeyId,
secretAccessKey,
accountId: payloadAccountId,
endpoint: payloadEndpoint,
forcePathStyle: payloadForcePathStyle,
} = conn;
const {
endpoint,
region,
bucket,
prefix,
defaultForUploads,
accountId,
forcePathStyle: metaForcePathStyle,
} = conn.meta || {};
if (c.storageProviderInput) {
c.storageProviderInput.value = provider || "cloudflare_r2";
}
const resolvedEndpoint =
endpoint || accountId || payloadAccountId || payloadEndpoint || "";
if (c.storageEndpointInput) c.storageEndpointInput.value = resolvedEndpoint;
if (c.storageRegionInput) c.storageRegionInput.value = region || "auto";
if (c.storageAccessKeyInput) c.storageAccessKeyInput.value = accessKeyId || "";
if (c.storageSecretKeyInput) c.storageSecretKeyInput.value = secretAccessKey || "";
if (c.storageBucketInput) c.storageBucketInput.value = bucket || "";
if (c.storagePrefixInput) c.storagePrefixInput.value = prefix || "";
if (c.storageDefaultInput) c.storageDefaultInput.checked = !!defaultForUploads;
if (c.storageForcePathStyleInput) {
if (typeof payloadForcePathStyle === "boolean") {
c.storageForcePathStyleInput.checked = payloadForcePathStyle;
} else if (typeof metaForcePathStyle === "boolean") {
c.storageForcePathStyleInput.checked = metaForcePathStyle;
} else {
c.storageForcePathStyleInput.checked = true;
}
}
c.updateStorageFormVisibility();
c.handlePublicUrlInput();
}
// Clear the credential inputs but keep the selected provider (used when switching to a
// provider that has no saved connection yet).
export function clearCredentialFields(c) {
if (c.storageEndpointInput) c.storageEndpointInput.value = "";
if (c.storageRegionInput) c.storageRegionInput.value = "auto";
if (c.storageAccessKeyInput) c.storageAccessKeyInput.value = "";
if (c.storageSecretKeyInput) c.storageSecretKeyInput.value = "";
if (c.storageBucketInput) c.storageBucketInput.value = "";
if (c.storagePrefixInput) c.storagePrefixInput.value = "";
if (c.storageDefaultInput) c.storageDefaultInput.checked = false;
c.updateStorageFormVisibility();
}
+233
View File
@@ -0,0 +1,233 @@
// Storage CORS setup helper (all bucket providers).
//
// Browser uploads to a bucket use an S3 PUT/POST, which the bucket must allow via
// CORS. bitvid tries to set this for you on Save, but that needs a key with CORS-write
// permission and a provider that allows it (Backblaze B2's web presets, for example,
// only "share" downloads). This module renders a small, PROVIDER-AWARE modal with the
// exact rules (origins pre-filled) + how to apply them for the selected provider.
import { devLogger } from "../../utils/logger.js";
import { deriveB2Endpoint, getCorsOrigins } from "../../services/s3Service.js";
const PROVIDER_B2 = "backblaze_b2";
const PROVIDER_R2 = "cloudflare_r2";
// Backblaze B2 native rules (corsRuleName + allowedOperations). The web-console
// presets omit the upload operations (s3_put/s3_post), which is why uploads fail.
export function buildBucketCorsRules(origins) {
const allowedOrigins =
Array.isArray(origins) && origins.length ? origins : ["*"];
return [
{
corsRuleName: "bitvidBrowserAccess",
allowedOrigins,
allowedHeaders: ["*"],
allowedOperations: ["s3_head", "s3_get", "s3_put", "s3_post", "s3_delete"],
exposeHeaders: ["ETag", "Content-Length", "Content-Range", "Accept-Ranges"],
maxAgeSeconds: 3600,
},
];
}
// Standard S3 CORSConfiguration — accepted by the AWS CLI, S3-compatible provider
// dashboards, and Cloudflare R2's CORS-policy editor. (OPTIONS is implicit in S3 CORS
// and not a valid AllowedMethod, so it's omitted on purpose.)
export function buildS3CorsConfig(origins) {
const allowedOrigins =
Array.isArray(origins) && origins.length ? origins : ["*"];
return {
CORSRules: [
{
AllowedOrigins: allowedOrigins,
AllowedMethods: ["GET", "HEAD", "PUT", "POST", "DELETE"],
AllowedHeaders: ["*"],
ExposeHeaders: ["ETag", "Content-Length", "Content-Range", "Accept-Ranges"],
MaxAgeSeconds: 3600,
},
],
};
}
// B2 CLI command (the path B2's web console points custom-rule users to).
export function buildB2CorsCommand(bucket, rules) {
const bucketName = (bucket && bucket.trim()) || "YOUR_BUCKET";
return `b2 update-bucket --corsRules '${JSON.stringify(rules)}' ${bucketName} allPublic`;
}
// AWS CLI command against the provider's S3 endpoint (works for Custom S3 and R2).
export function buildAwsCorsCommand(bucket, endpoint, config) {
const bucketName = (bucket && bucket.trim()) || "YOUR_BUCKET";
const ep = endpoint && endpoint.trim() ? ` --endpoint-url ${endpoint.trim()}` : "";
return `aws s3api put-bucket-cors --bucket ${bucketName}${ep} --cors-configuration '${JSON.stringify(config)}'`;
}
function resolveEndpointForCors(provider, region, rawEndpoint) {
if (provider === PROVIDER_B2) {
return deriveB2Endpoint(region);
}
if (provider === PROVIDER_R2) {
return rawEndpoint ? `https://${rawEndpoint}.r2.cloudflarestorage.com` : "";
}
return rawEndpoint || "";
}
// Build the provider-specific modal content (intro / JSON / command + label / notes).
export function buildCorsHelpContent({ provider, origins, bucket, endpoint } = {}) {
if (provider === PROVIDER_B2) {
const rules = buildBucketCorsRules(origins);
return {
intro:
"Backblaze B2's web-console CORS presets (\"Share everything…\") only allow " +
"downloads. Uploading from your browser needs the custom rules below, applied " +
"with B2's command-line tool.",
json: JSON.stringify(rules, null, 2),
cmdLabel: "Apply it (B2 command-line tool)",
cmd: buildB2CorsCommand(bucket, rules),
notes:
"1. Install the B2 CLI (pipx install b2).\n" +
"2. Run the command above (it authorizes, then sets the rules).\n" +
"3. Wait ~1 minute, then retry your upload.\n" +
"Tip: a B2 application key with the writeBucketCors capability lets bitvid set " +
"this for you automatically on Save.",
};
}
const isR2 = provider === PROVIDER_R2;
const config = buildS3CorsConfig(origins);
return {
intro:
"bitvid tries to set CORS for you when you Save. If uploads still fail (the key " +
"lacks CORS-write permission, or your provider needs it set manually), apply the " +
"rules below.",
json: JSON.stringify(config, null, 2),
cmdLabel: isR2
? "Apply it (Cloudflare dashboard, or AWS CLI)"
: "Apply it (provider dashboard, or AWS CLI)",
cmd: buildAwsCorsCommand(bucket, endpoint, config),
notes: isR2
? "Cloudflare dashboard: R2 → your bucket → Settings → CORS Policy → paste the JSON.\n" +
"Or run the AWS CLI command above (it targets your R2 endpoint)."
: "Paste the JSON into your provider's bucket CORS settings, or run the AWS CLI " +
"command above against your endpoint.",
};
}
export class StorageCorsHelp {
constructor({ getProvider, getBucket, getRegion, getEndpoint } = {}) {
this.getProvider = typeof getProvider === "function" ? getProvider : () => "";
this.getBucket = typeof getBucket === "function" ? getBucket : () => "";
this.getRegion = typeof getRegion === "function" ? getRegion : () => "";
this.getEndpoint = typeof getEndpoint === "function" ? getEndpoint : () => "";
this.helpBtn = null;
this.modal = null;
this.introEl = null;
this.jsonEl = null;
this.cliEl = null;
this.cmdLabelEl = null;
this.notesEl = null;
this.copyJsonBtn = null;
this.copyCmdBtn = null;
}
cacheDom(doc = document) {
this.helpBtn = doc.getElementById("storageCorsHelpBtn") || null;
this.modal = doc.getElementById("storageCorsModal") || null;
this.introEl = doc.getElementById("storageCorsModalIntro") || null;
this.jsonEl = doc.getElementById("storageCorsJson") || null;
this.cliEl = doc.getElementById("storageCorsCli") || null;
this.cmdLabelEl = doc.getElementById("storageCorsCmdLabel") || null;
this.notesEl = doc.getElementById("storageCorsNotes") || null;
this.copyJsonBtn = doc.getElementById("storageCorsCopyJsonBtn") || null;
this.copyCmdBtn = doc.getElementById("storageCorsCopyCmdBtn") || null;
}
registerEventListeners() {
if (this.helpBtn instanceof HTMLElement) {
this.helpBtn.addEventListener("click", () => this.open());
}
if (this.modal instanceof HTMLElement) {
this.modal
.querySelectorAll("[data-storage-cors-dismiss]")
.forEach((el) => el.addEventListener("click", () => this.close()));
this.modal.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
this.close();
}
});
}
if (this.copyJsonBtn instanceof HTMLElement) {
this.copyJsonBtn.addEventListener("click", () => {
void this.copy(this.jsonEl?.textContent, this.copyJsonBtn, "Copy JSON");
});
}
if (this.copyCmdBtn instanceof HTMLElement) {
this.copyCmdBtn.addEventListener("click", () => {
void this.copy(this.cliEl?.textContent, this.copyCmdBtn, "Copy command");
});
}
}
setVisible(show) {
if (this.helpBtn instanceof HTMLElement) {
this.helpBtn.classList.toggle("hidden", !show);
}
}
open() {
if (!(this.modal instanceof HTMLElement)) {
return;
}
let origins = [];
try {
origins = getCorsOrigins();
} catch (error) {
origins = [];
}
const provider = this.getProvider();
const content = buildCorsHelpContent({
provider,
origins,
bucket: this.getBucket(),
endpoint: resolveEndpointForCors(provider, this.getRegion(), this.getEndpoint()),
});
if (this.introEl) this.introEl.textContent = content.intro;
if (this.jsonEl) this.jsonEl.textContent = content.json;
if (this.cmdLabelEl) this.cmdLabelEl.textContent = content.cmdLabel;
if (this.cliEl) this.cliEl.textContent = content.cmd;
if (this.notesEl) this.notesEl.textContent = content.notes;
this.modal.classList.remove("hidden");
const sheet = this.modal.querySelector(".modal-sheet");
if (sheet instanceof HTMLElement) {
sheet.focus();
}
}
close() {
if (this.modal instanceof HTMLElement) {
this.modal.classList.add("hidden");
}
if (this.helpBtn instanceof HTMLElement) {
this.helpBtn.focus();
}
}
async copy(text, button, restoreLabel) {
const value = typeof text === "string" ? text : "";
if (!value || !navigator?.clipboard?.writeText) {
return;
}
try {
await navigator.clipboard.writeText(value);
if (button instanceof HTMLElement) {
button.textContent = "Copied!";
setTimeout(() => {
button.textContent = restoreLabel;
}, 1500);
}
} catch (error) {
devLogger.warn("[ProfileModal] Failed to copy CORS text:", error);
}
}
}
+7
View File
@@ -51,6 +51,7 @@ import { ProfileAdminController } from "./profileModal/ProfileAdminController.js
import { ProfileModerationController } from "./profileModal/ProfileModerationController.js";
import { ProfileBlockListController } from "./profileModal/ProfileBlockListController.js";
import { ProfileEditController } from "./profileModal/ProfileEditController.js";
import { isInStackedModal } from "./focusTrapStacking.js";
const noop = () => {};
@@ -4670,6 +4671,12 @@ export class ProfileModalController {
return;
}
// Don't fight a modal stacked on top of the profile modal (e.g. the login
// modal's nsec passphrase/PIN field) — let it own focus so it stays typable.
if (isInStackedModal(event.target, modalRoot)) {
return;
}
const fallback =
this.focusableElements[0] ||
this.profileModalPanel ||
+2 -5
View File
@@ -14,12 +14,9 @@ process.env.NODE_ENV = "test";
// failing on main, unnoticed). Each entry MUST cite why and link the triage item.
// Remove an entry as soon as its file is fixed. See todo/TODO_2026-06-20_pre-launch.md todo-11b.
const QUARANTINE = new Map([
["tests/user-blocks.test.mjs", "HANG — triage todo-11b"],
["tests/nostr-count-fallback.test.mjs", "FAIL — triage todo-11b"],
["tests/admin-list-store.test.mjs", "FAIL — triage todo-11b"],
["tests/nostr-boost-actions.test.mjs", "FAIL — triage todo-11b"],
["tests/user-blocks.test.mjs", "HANG — async leak; needs a deterministic rewrite. triage todo-11b"],
["tests/nwc-client.test.mjs", "FAIL — mocks nostr-tools but is shadowed by the frozen canonical toolkit the bootstrap installs (real @noble rejects the fake keys). The underlying production bug (parseNwcUri passed a hex secret to getPublicKey, which needs bytes — NWC connection broken) is FIXED and guarded by tests/nwc-parse-uri.test.mjs; this file needs a mock-injection rework. triage todo-11b (NWC / item #3)"],
["tests/nostr-publish-rejection.test.mjs", "FAIL — triage todo-11b"],
["tests/nostr-publish-rejection.test.mjs", "FAIL — multi-precondition setup. Diagnosed 2026-06-25: (1) testPublishVideoNoteDefaultsToLiveModeInDev needs dev mode — set globalThis.__BITVID_DEV_MODE_OVERRIDE__=true BEFORE the first (dynamic) config-importing import (~line 50); (2) the publishNip71Video sub-test then fails (publishVideo's NIP-71 invocation path) — needs FEATURE_PUBLISH_NIP71/condition triage; possibly more after. triage todo-11b"],
]);
const rootDir = path.dirname(fileURLToPath(new URL("../package.json", import.meta.url)));
+149
View File
@@ -0,0 +1,149 @@
// #25 per-event (per-video) admin block list: an admin can hide a SINGLE video without
// blocking its author. The published list is a kind-30000 NIP-51 event carrying the
// blocked event ids as `e` tags. These tests cover the pure record helpers and the
// ⋯-menu action handler (editor-gated add + grid refresh).
import test, { afterEach } from "node:test";
import { strict as assert } from "node:assert";
import {
sanitizeEventIdList,
extractEventIdsFromEvent,
normalizeEventId,
buildEventBlacklistEvent,
} from "../js/adminEventBlacklistHelpers.js";
import { handleBlacklistEventAction } from "../js/ui/moreMenu/blacklistEventAction.js";
const HEX_A = "a".repeat(64);
const HEX_B = "b".repeat(64);
afterEach(() => {
delete globalThis.window;
delete globalThis.NostrTools;
});
// --- record helpers ---
test("sanitizeEventIdList keeps valid hex ids, lowercased + deduped", () => {
assert.deepEqual(
sanitizeEventIdList([HEX_A.toUpperCase(), HEX_A, HEX_B, "nope", "", 123]),
[HEX_A, HEX_B],
);
});
test("extractEventIdsFromEvent reads `e` tags only", () => {
const event = {
tags: [
["d", "bitvid:admin:event-blacklist"],
["e", HEX_A],
["p", HEX_B], // a pubkey tag must NOT be treated as a blocked event
["e", HEX_B],
],
};
assert.deepEqual(extractEventIdsFromEvent(event), [HEX_A, HEX_B]);
});
test("normalizeEventId accepts hex and decodes nevent/note pointers", () => {
assert.equal(normalizeEventId(HEX_A.toUpperCase()), HEX_A);
assert.equal(normalizeEventId("not-an-id"), "");
globalThis.NostrTools = {
nip19: {
decode: (v) =>
v === "nevent1xyz"
? { type: "nevent", data: { id: HEX_B } }
: { type: "note", data: HEX_A },
},
};
assert.equal(normalizeEventId("nevent1xyz"), HEX_B);
assert.equal(normalizeEventId("note1abc"), HEX_A);
});
test("buildEventBlacklistEvent builds a kind-30000 list with the event d-tag + `e` tags", () => {
const event = buildEventBlacklistEvent(HEX_A, [HEX_A, HEX_B, "junk"]);
assert.equal(event.kind, 30000);
const dTag = event.tags.find((t) => t[0] === "d");
assert.equal(dTag[1], "bitvid:admin:event-blacklist");
const eTags = event.tags.filter((t) => t[0] === "e").map((t) => t[1]);
assert.deepEqual(eTags, [HEX_A, HEX_B]); // junk dropped
assert.ok(!event.tags.some((t) => t[0] === "p"), "no pubkey tags");
});
// --- ⋯-menu action handler ---
function makeDeps({ canEdit = true, npub = "npub1mod", addResult = { ok: true } } = {}) {
const calls = { added: [], success: [], error: [], refresh: 0 };
const accessControl = {
ensureReady: async () => {},
canEditAdminLists: () => canEdit,
addToEventBlacklist: async (actor, id) => {
calls.added.push({ actor, id });
return addResult;
},
};
const callbacks = {
getCurrentUserNpub: () => npub,
showError: (m) => calls.error.push(m),
showSuccess: (m) => calls.success.push(m),
refreshAllVideoGrids: async () => {
calls.refresh += 1;
},
};
return { accessControl, callbacks, calls };
}
test("blocks the video's event id (editor) and refreshes the grids", async () => {
const { accessControl, callbacks, calls } = makeDeps();
await handleBlacklistEventAction({
accessControl,
callbacks,
dataset: { eventId: HEX_A },
currentVideo: { id: HEX_B },
});
assert.deepEqual(calls.added, [{ actor: "npub1mod", id: HEX_A }], "uses dataset eventId");
assert.equal(calls.success.length, 1);
assert.equal(calls.refresh, 1);
assert.equal(calls.error.length, 0);
});
test("falls back to the current video's id when the dataset has none", async () => {
const { accessControl, callbacks, calls } = makeDeps();
await handleBlacklistEventAction({
accessControl,
callbacks,
dataset: {},
currentVideo: { id: HEX_B },
});
assert.deepEqual(calls.added, [{ actor: "npub1mod", id: HEX_B }]);
});
test("a non-editor is refused and nothing is published", async () => {
const { accessControl, callbacks, calls } = makeDeps({ canEdit: false });
await handleBlacklistEventAction({
accessControl,
callbacks,
dataset: { eventId: HEX_A },
});
assert.equal(calls.added.length, 0);
assert.equal(calls.refresh, 0);
assert.match(calls.error[0], /moderators/i);
});
test("a logged-out user is prompted to login, no publish", async () => {
const { accessControl, callbacks, calls } = makeDeps({ npub: null });
await handleBlacklistEventAction({ accessControl, callbacks, dataset: { eventId: HEX_A } });
assert.equal(calls.added.length, 0);
assert.match(calls.error[0], /login/i);
});
test("a publish failure surfaces an error and does not claim success", async () => {
const { accessControl, callbacks, calls } = makeDeps({
addResult: { ok: false, error: "storage-error" },
});
await handleBlacklistEventAction({
accessControl,
callbacks,
dataset: { eventId: HEX_A },
});
assert.equal(calls.success.length, 0);
assert.equal(calls.refresh, 0);
assert.equal(calls.error.length, 1);
});
+42 -9
View File
@@ -168,14 +168,16 @@ const communityMemberNpub = "npub1communityblacklist";
const communitySecondMemberNpub = "npub1communitytwo";
const communityCuratorOneNpub = "npub1communitycurator";
const communityCuratorTwoNpub = "npub1communitycurator2";
const communityCuratorOneHex = mockNip19.decode(communityCuratorOneNpub).data;
const communityCuratorTwoHex = mockNip19.decode(communityCuratorTwoNpub).data;
const communityCuratorOneAuthorHex = mockNip19.decode(
communityCuratorOneHex
).data;
const communityCuratorTwoAuthorHex = mockNip19.decode(
communityCuratorTwoHex
).data;
// Curator coordinates in an `a` tag carry a REAL 64-char hex pubkey (kind:hex:dTag),
// which parseCommunityBlacklistReferences uses directly via isHexPubkey(). The old
// mock-"hex" values still started with "npub", so the parser's npub branch decoded
// them AGAIN (…-hex-hex), and the batched curator filter then didn't match the
// curator events' pubkeys. Use realistic hex so the test exercises the production
// path faithfully.
const communityCuratorOneHex = "c".repeat(64);
const communityCuratorTwoHex = "d".repeat(64);
const communityCuratorOneAuthorHex = communityCuratorOneHex;
const communityCuratorTwoAuthorHex = communityCuratorTwoHex;
const editorsDTag = `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.editors}`;
const whitelistDTag = `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.whitelist}`;
const blacklistDTag = `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.blacklist}`;
@@ -202,12 +204,18 @@ function registerListEvent({ dTag, authorHex = null, event }) {
}
function createListEvent({ dTag, tags, pubkey, createdAt }) {
// Real kind-30000 list events are parameterized-replaceable and ALWAYS carry a
// `d` tag. The batched community fetch matches curator events to references by
// their own `d` tag (selectNewestEventsForReferences), so the helper must emit
// one (prepended, deduped against any caller-supplied d tag).
const callerTags = Array.isArray(tags) ? tags : [];
const hasDTag = callerTags.some((tag) => Array.isArray(tag) && tag[0] === "d");
return {
kind: 30000,
pubkey: pubkey || "",
created_at: typeof createdAt === "number" ? createdAt : Math.floor(Date.now() / 1000),
id: `${dTag}:${pubkey || "anon"}:${createdAt || 0}`,
tags: Array.isArray(tags) ? tags : [],
tags: hasDTag ? callerTags : [["d", dTag], ...callerTags],
};
}
@@ -294,6 +302,31 @@ nostrClient.pool = {
},
};
// Community curator blacklists are batched through the SubscriptionManager (one
// REQ instead of one-per-curator — the cold-start relay-storm fix), NOT pool.list.
// The harness previously only mocked pool.list, so the batched curator fetch
// returned nothing and the community-merge assertion failed. Serve the same
// registry here; selectNewestEventsForReferences matches events to references by
// (event.pubkey, dTag), so returning all events matching the batched filter's
// authors+#d is sufficient and still exercises real merge/dedupe/guard logic.
nostrClient.getSubscriptionManager = () => ({
async list({ filters } = {}) {
const filter = Array.isArray(filters) && filters.length ? filters[0] : {};
const wantAuthors = new Set(
Array.isArray(filter.authors) ? filter.authors : []
);
const wantDTags = new Set(Array.isArray(filter["#d"]) ? filter["#d"] : []);
const out = [];
for (const [key, ev] of listEventRegistry.entries()) {
const dTag = key.slice(key.indexOf("::") + 2);
if (wantDTags.size && !wantDTags.has(dTag)) continue;
if (wantAuthors.size && !wantAuthors.has(ev.pubkey)) continue;
out.push(ev);
}
return out;
},
});
if (!globalThis.window.nostr) {
globalThis.window.nostr = {};
}
+57
View File
@@ -0,0 +1,57 @@
// Bug: opening the login modal's nsec passphrase/PIN field on top of the profile modal
// left it unclickable/untypable — the profile modal's focusin trap yanked focus back
// because it didn't recognize the stacked login modal. isInStackedModal() is the guard
// that makes the trap yield to a modal stacked on top.
import test from "node:test";
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { isInStackedModal } from "../js/ui/focusTrapStacking.js";
const dom = new JSDOM(`<!doctype html><html><body>
<div id="profileModal" class="bv-modal">
<input id="profileInput" />
</div>
<div id="loginModal" class="bv-modal">
<input id="pinInput" />
</div>
<div id="closedModal" class="bv-modal hidden">
<input id="closedInput" />
</div>
<input id="bareInput" />
</body></html>`);
const { document } = dom.window;
const profileModal = document.getElementById("profileModal");
test("yields focus to an input inside a different OPEN modal (the login PIN field)", () => {
assert.equal(
isInStackedModal(document.getElementById("pinInput"), profileModal),
true,
);
});
test("does NOT yield for focus moving inside the trapping modal itself", () => {
assert.equal(
isInStackedModal(document.getElementById("profileInput"), profileModal),
false,
);
});
test("does NOT yield to a HIDDEN modal's field", () => {
assert.equal(
isInStackedModal(document.getElementById("closedInput"), profileModal),
false,
);
});
test("does NOT yield for a target that isn't in any modal (trap should reclaim focus)", () => {
assert.equal(
isInStackedModal(document.getElementById("bareInput"), profileModal),
false,
);
});
test("safe for a null / non-element target", () => {
assert.equal(isInStackedModal(null, profileModal), false);
assert.equal(isInStackedModal({}, profileModal), false);
});
@@ -0,0 +1,71 @@
// Bug: with a Backblaze + Cloudflare connection both configured, uploads failed with
// "S3 endpoint is required." when the upload modal's tracked provider drifted out of
// sync with the active credentials — a Cloudflare R2 connection (accountId, no endpoint)
// got routed through the S3 service.
//
// Fix: MediaUploader routes by the CREDENTIALS' own provider, not a separately-passed
// (and possibly stale) provider. These tests exercise that via uploadThumbnail (no
// torrent generation, so no webtorrent in the test).
import test from "node:test";
import { strict as assert } from "node:assert";
import { MediaUploader } from "../js/ui/components/mediaUploader.js";
function makeUploader() {
const used = [];
const mkService = (name) => ({
prepareUpload: async (_npub, { credentials }) => {
used.push({ service: name, credentials });
return {
settings: { provider: credentials?.provider, accountId: "acct", endpoint: "" },
bucketEntry: { publicBaseUrl: "https://cdn.example", bucket: "bkt" },
};
},
uploadFile: async () => {},
});
const r2Service = mkService("r2");
const s3Service = mkService("s3");
const uploader = new MediaUploader({
r2Service,
s3Service,
storageService: null,
getCurrentPubkey: () => "f".repeat(64),
safeEncodeNpub: () => "npub1abc",
});
return { uploader, used };
}
const R2_CREDS = { provider: "cloudflare_r2", accountId: "acct" }; // no endpoint!
const B2_CREDS = { provider: "backblaze_b2", endpoint: "https://s3.us-west-004.backblazeb2.com" };
const file = { name: "thumb.jpg" };
test("a Cloudflare connection routes to the R2 service even when the passed provider is stale (Backblaze)", async () => {
const { uploader, used } = makeUploader();
await uploader.uploadThumbnail(file, { provider: "backblaze_b2", credentials: R2_CREDS });
assert.equal(used.length, 1);
assert.equal(used[0].service, "r2", "routed by credentials.provider, not the stale arg");
});
test("a Backblaze connection routes to the S3 service even when the passed provider is stale (Cloudflare)", async () => {
const { uploader, used } = makeUploader();
await uploader.uploadThumbnail(file, { provider: "cloudflare_r2", credentials: B2_CREDS });
assert.equal(used[0].service, "s3");
});
test("falls back to meta.provider when the top-level provider is absent", async () => {
const { uploader, used } = makeUploader();
await uploader.uploadThumbnail(file, {
provider: "backblaze_b2",
credentials: { meta: { provider: "cloudflare_r2" }, accountId: "acct" },
});
assert.equal(used[0].service, "r2");
});
test("falls back to the passed provider when credentials carry none", async () => {
const { uploader, used } = makeUploader();
await uploader.uploadThumbnail(file, {
provider: "cloudflare_r2",
credentials: { accountId: "acct" },
});
assert.equal(used[0].service, "r2");
});
+102
View File
@@ -0,0 +1,102 @@
// #8: a one-click "Delete all" bulk action for orphaned storage objects in My Videos.
// It must (a) send EVERY listed orphan key in a single deleteStorageKeys call, (b) be
// confirmation-gated, (c) refresh on success, and (d) surface storage-locked + partial
// outcomes honestly (never claim success when nothing was deleted).
import "./test-helpers/setup-localstorage.mjs";
import test, { afterEach } from "node:test";
import { strict as assert } from "node:assert";
import { MyVideosController } from "../js/ui/profileModal/MyVideosController.js";
const KEYS = ["u/n/a/v.mp4", "u/n/a/v.torrent", "u/n/b/old.mp4"];
function makeController({ deleteResult, confirm = true } = {}) {
const calls = { deleteKeys: [], success: [], error: [], populated: 0 };
const mainController = {
services: {
r2Service: {
deleteStorageKeys: async ({ keys, pubkey }) => {
calls.deleteKeys.push({ keys, pubkey });
return deleteResult;
},
},
},
showSuccess: (m) => calls.success.push(m),
showError: (m) => calls.error.push(m),
normalizeHexPubkey: (v) => v,
getActivePubkey: () => "abc",
};
const c = new MyVideosController(mainController);
c.pubkey = "abc";
c.orphanKeys = KEYS.slice();
c.populate = async () => {
calls.populated += 1;
};
globalThis.window = { confirm: () => confirm };
// The controller guards DOM writes with `instanceof HTMLElement`; define a stand-in
// so the check evaluates (to false, since we run without a real DOM) instead of
// throwing ReferenceError in this non-jsdom test.
if (typeof globalThis.HTMLElement === "undefined") {
globalThis.HTMLElement = class {};
}
return { c, calls };
}
afterEach(() => {
delete globalThis.window;
delete globalThis.HTMLElement;
});
test("deletes every orphan key in a single call and refreshes on full success", async () => {
const { c, calls } = makeController({
deleteResult: { ok: true, deleted: KEYS.slice() },
});
await c.handleDeleteAllOrphans();
assert.equal(calls.deleteKeys.length, 1, "exactly one bulk delete call");
assert.deepEqual(calls.deleteKeys[0].keys, KEYS, "all keys sent at once");
assert.equal(calls.deleteKeys[0].pubkey, "abc");
assert.equal(calls.populated, 1, "list refreshed after delete");
assert.equal(calls.success.length, 1);
assert.match(calls.success[0], /all 3/i);
assert.equal(calls.error.length, 0);
});
test("cancelling the confirmation deletes nothing", async () => {
const { c, calls } = makeController({
deleteResult: { ok: true, deleted: KEYS.slice() },
confirm: false,
});
await c.handleDeleteAllOrphans();
assert.equal(calls.deleteKeys.length, 0, "no delete when the user cancels");
assert.equal(calls.populated, 0);
});
test("storage-locked surfaces an unlock prompt and does not refresh or claim success", async () => {
const { c, calls } = makeController({
deleteResult: { ok: false, reason: "storage-locked" },
});
await c.handleDeleteAllOrphans();
assert.equal(calls.success.length, 0);
assert.equal(calls.populated, 0);
assert.equal(calls.error.length, 1);
assert.match(calls.error[0], /unlock storage/i);
});
test("a partial delete reports how many succeeded and how many failed", async () => {
const { c, calls } = makeController({
deleteResult: { ok: true, deleted: KEYS.slice(0, 2) }, // 2 of 3
});
await c.handleDeleteAllOrphans();
assert.equal(calls.populated, 1);
assert.equal(calls.success.length, 1);
assert.match(calls.success[0], /2/);
assert.match(calls.success[0], /1/);
});
test("an empty orphan list is a no-op (no delete call)", async () => {
const { c, calls } = makeController({ deleteResult: { ok: true, deleted: [] } });
c.orphanKeys = [];
await c.handleDeleteAllOrphans();
assert.equal(calls.deleteKeys.length, 0);
});
+127
View File
@@ -0,0 +1,127 @@
// #34 part (b): the "My Videos" mirror toggle must reflect RELAY TRUTH (the actual
// published NIP-71 mirror events), not just this device's local opt-in flag. On a
// fresh device or after a cache clear the local flag is missing/stale, so the row
// would otherwise lie about whether the video is already shared.
//
// Behavior under test (MyVideosController):
// - buildMirrorButton tags the button with its videoRootId and labels it from the
// local flag (synchronous, instant render).
// - refreshMirrorStates() looks up relay truth (batched), corrects the button
// label, AND reconciles the local flag so it becomes a correct cache.
import test, { beforeEach, afterEach } from "node:test";
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { MyVideosController } from "../js/ui/profileModal/MyVideosController.js";
import { nip71MirrorService } from "../js/services/nip71MirrorService.js";
import {
isMirrorEnabled,
setMirrorEnabled,
} from "../js/services/nip71MirrorFlags.js";
const PUBKEY = "f".repeat(64);
const ROOT_A = "11111111-1111-4111-8111-111111111111";
const ROOT_B = "22222222-2222-4222-8222-222222222222";
let dom;
let savedFindMirrors;
let savedCanMirror;
beforeEach(() => {
dom = new JSDOM("<!doctype html><html><body></body></html>");
globalThis.document = dom.window.document;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.CSS = dom.window.CSS;
// canMirror must say "eligible" so buildMirrorButton renders the active (tagged)
// button rather than the disabled "Share off" placeholder.
savedCanMirror = nip71MirrorService.canMirror;
nip71MirrorService.canMirror = () => ({ ok: true });
savedFindMirrors = nip71MirrorService.findMirrors;
});
afterEach(() => {
nip71MirrorService.findMirrors = savedFindMirrors;
nip71MirrorService.canMirror = savedCanMirror;
setMirrorEnabled(PUBKEY, ROOT_A, false);
setMirrorEnabled(PUBKEY, ROOT_B, false);
delete globalThis.document;
delete globalThis.HTMLElement;
delete globalThis.CSS;
});
function makeController() {
const controller = new MyVideosController({ showStatus() {} });
controller.pubkey = PUBKEY;
controller.listEl = dom.window.document.createElement("div");
return controller;
}
test("buildMirrorButton tags the button with its root and labels from the local flag", () => {
const controller = makeController();
setMirrorEnabled(PUBKEY, ROOT_A, true);
const btn = controller.buildMirrorButton({ videoRootId: ROOT_A, url: "https://x/v.mp4" });
assert.equal(btn.dataset.mirrorRoot, ROOT_A);
assert.equal(btn.textContent, "Shared ✓");
});
test("refreshMirrorStates promotes an unmirrored-looking row when relays prove it IS shared", async () => {
const controller = makeController();
// Local flag is FALSE (fresh device), so the row renders "Share to apps"...
setMirrorEnabled(PUBKEY, ROOT_A, false);
const btn = controller.buildMirrorButton({ videoRootId: ROOT_A, url: "https://x/v.mp4" });
controller.listEl.appendChild(btn);
assert.equal(btn.textContent, "Share to apps", "precondition: starts from local flag");
// ...but the relays actually hold a published mirror for this root.
nip71MirrorService.findMirrors = async () =>
new Map([[ROOT_A, { mirrored: true, kinds: [34235], duplicate: false }]]);
await controller.refreshMirrorStates([{ videoRootId: ROOT_A }]);
assert.equal(btn.textContent, "Shared ✓", "label corrected to relay truth");
assert.equal(
isMirrorEnabled(PUBKEY, ROOT_A),
true,
"local flag reconciled to relay truth (now a correct cache)",
);
});
test("refreshMirrorStates clears a stale 'Shared ✓' when relays prove it is NOT shared", async () => {
const controller = makeController();
// Local flag wrongly says shared (e.g. mirror was deleted elsewhere).
setMirrorEnabled(PUBKEY, ROOT_A, true);
const btn = controller.buildMirrorButton({ videoRootId: ROOT_A, url: "https://x/v.mp4" });
controller.listEl.appendChild(btn);
assert.equal(btn.textContent, "Shared ✓", "precondition: stale local flag");
// Relays hold nothing for this root.
nip71MirrorService.findMirrors = async () => new Map();
await controller.refreshMirrorStates([{ videoRootId: ROOT_A }]);
assert.equal(btn.textContent, "Share to apps", "label corrected to relay truth");
assert.equal(isMirrorEnabled(PUBKEY, ROOT_A), false, "stale local flag cleared");
});
test("refreshMirrorStates reconciles each row independently across the batch", async () => {
const controller = makeController();
setMirrorEnabled(PUBKEY, ROOT_A, false);
setMirrorEnabled(PUBKEY, ROOT_B, true);
const btnA = controller.buildMirrorButton({ videoRootId: ROOT_A, url: "https://x/a.mp4" });
const btnB = controller.buildMirrorButton({ videoRootId: ROOT_B, url: "https://x/b.mp4" });
controller.listEl.append(btnA, btnB);
// A is actually shared on relays; B is not — the opposite of the local flags.
nip71MirrorService.findMirrors = async () =>
new Map([[ROOT_A, { mirrored: true, kinds: [34236], duplicate: false }]]);
await controller.refreshMirrorStates([
{ videoRootId: ROOT_A },
{ videoRootId: ROOT_B },
]);
assert.equal(btnA.textContent, "Shared ✓");
assert.equal(btnB.textContent, "Share to apps");
assert.equal(isMirrorEnabled(PUBKEY, ROOT_A), true);
assert.equal(isMirrorEnabled(PUBKEY, ROOT_B), false);
});
+77
View File
@@ -145,3 +145,80 @@ test("remove() is unavailable without a signer", async () => {
assert.equal(result.ok, false);
assert.equal(result.error, "unavailable");
});
// #34: idempotent re-mirror. The mirror KIND is inferred from dimensions
// (34236 short for portrait, else 34235), but (kind,pubkey,d) is the addressable
// identity — so a kind flip between mirror attempts produced a DUPLICATE in NIP-71
// clients. publish() must reuse the kind of any existing mirror instead.
function makeServiceWithExisting(existing) {
const relay = makeRelay();
const signer = { signEvent: async (tpl) => ({ ...tpl, id: "id", sig: "sig" }) };
const service = createNip71MirrorService({
getActivePubkey: () => PUBKEY,
getSigner: () => signer,
getWriteRelays: () => ["wss://write.relay"],
getPool: () => relay,
publishEventToRelays,
summarizePublishResults,
signEvent: signer.signEvent,
allowNsfw: () => false,
fetchExistingMirrors: async () => existing,
});
return { service, relay };
}
test("re-mirror reuses the existing mirror's kind (idempotent — no cross-kind duplicate)", async () => {
// An existing SHORT (34236) mirror exists; the video object has no dims, so the
// inference would otherwise pick 34235 (normal) → a duplicate. Must reuse 34236.
const { service, relay } = makeServiceWithExisting([
{ kind: 34236, created_at: 100 },
]);
const result = await service.publish(baseVideo());
assert.equal(result.ok, true);
assert.equal(result.reusedExistingKind, true);
assert.equal(relay.events.length, 1, "replaces the same coordinate, no second event");
assert.equal(relay.events[0].kind, 34236, "reused the existing kind, not the inferred 34235");
});
test("self-heals a pre-existing cross-kind duplicate by NIP-09-deleting the stale kind", async () => {
// Both kinds already exist (the bug's aftermath). Reuse the newest (34236) and
// tear down the stale 34235 so the video stops showing twice.
const { service, relay } = makeServiceWithExisting([
{ kind: 34236, created_at: 200 },
{ kind: 34235, created_at: 100 },
]);
const result = await service.publish(baseVideo());
assert.equal(result.ok, true);
assert.equal(result.healedStaleKind, true);
assert.equal(relay.events.length, 2, "the mirror + a delete for the stale kind");
assert.equal(relay.events[0].kind, 34236);
const del = relay.events[1];
assert.equal(del.kind, 5, "NIP-09 delete");
assert.ok(
del.tags.some((t) => t[0] === "a" && t[1] === `34235:${PUBKEY}:root-1`),
"delete targets the stale 34235 coordinate",
);
});
test("an explicit options.short override is respected over any existing mirror", async () => {
const { service, relay } = makeServiceWithExisting([{ kind: 34236, created_at: 100 }]);
const result = await service.publish(baseVideo(), { short: false });
assert.equal(result.ok, true);
assert.equal(result.reusedExistingKind, false);
assert.equal(relay.events[0].kind, 34235, "explicit override wins");
});
test("findMirror derives mirror state from relays (truth), flagging duplicates", async () => {
const dup = makeServiceWithExisting([{ kind: 34235 }, { kind: 34236 }]).service;
assert.deepEqual(await dup.findMirror(baseVideo()), {
mirrored: true,
kinds: [34235, 34236],
duplicate: true,
});
const none = makeServiceWithExisting([]).service;
assert.deepEqual(await none.findMirror(baseVideo()), {
mirrored: false,
kinds: [],
duplicate: false,
});
});
+30 -11
View File
@@ -104,19 +104,38 @@ function createPublishClient({ actorPubkey = "", sessionPubkey = "", failPublish
assert.deepEqual(event.tags[2], ["p", "deadbeef"]);
})();
(function testBuildRepostEventRequiresRelay() {
(function testBuildRepostEventOmitsRelayHintWhenAbsent() {
const createdAt = 1_700_000_001;
assert.throws(
() =>
buildRepostEvent({
pubkey: "actorpubkey",
created_at: createdAt,
eventId: "event456",
authorPubkey: "cafebabe",
targetKind: 30078,
}),
/missing-event-relay/,
// Spec correction (SCN-repost-optional-relay): a relay hint on the `e` tag is a
// NIP-18 SHOULD, not a MUST. With no relay available the builder must still emit
// a VALID repost — a two-element `e` tag (no relay) — rather than throwing, so a
// user can always repost even without a known relay hint.
const event = buildRepostEvent({
pubkey: "actorpubkey",
created_at: createdAt,
eventId: "event456",
authorPubkey: "cafebabe",
targetKind: 30078,
});
// Non-kind-1 (addressable) target → generic repost kind 16 with a `k` tag.
assert.equal(event.kind, 16);
assert.equal(event.pubkey, "actorpubkey");
assert.equal(event.created_at, createdAt);
assert.deepEqual(
event.tags.find((tag) => tag[0] === "e"),
["e", "event456"],
"e tag omits the relay hint when none is provided (no throw)",
);
assert.deepEqual(
event.tags.find((tag) => tag[0] === "p"),
["p", "cafebabe"],
);
assert.deepEqual(
event.tags.find((tag) => tag[0] === "k"),
["k", "30078"],
"generic repost records the target kind",
);
})();
+3 -1
View File
@@ -178,8 +178,10 @@ async function testUnsupportedRelaySkipsWarningsAndFallsBack() {
assert.equal(listCalls, 1, "fallback should query list once when COUNT fails");
assert.equal(result?.fallback, true, "unsupported relay should trigger fallback");
// Unsupported-relay tracking moved from NostrClient to its ConnectionManager
// (the client only re-exposes pool/relays/etc.); assert on the manager.
assert.ok(
client.countUnsupportedRelays.has(unsupportedRelay),
client.connectionManager.countUnsupportedRelays.has(unsupportedRelay),
"unsupported relay should be remembered for future requests"
);
assert.equal(
+83
View File
@@ -0,0 +1,83 @@
// #39: profile image/banner upload silently found "no storage" because
// ProfileEditController passes a HEX pubkey to r2Service.resolveConnection(), which
// only decoded npub1… strings (safeDecodeNpub) — so a hex pubkey resolved to null and
// the upload button stayed disabled even with storage configured. resolveConnection
// must accept a hex pubkey OR an npub.
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import r2ServiceDefault from "../js/services/r2Service.js";
import storageService from "../js/services/storageService.js";
const R2Service = r2ServiceDefault.constructor;
const HEX = "a".repeat(64);
let originalWindow;
beforeEach(() => {
originalWindow = globalThis.window;
// nip19.decode THROWS on anything that isn't a real npub — exactly how the live
// decoder behaves for a hex string. So if resolveConnection still works with hex,
// it's using the hex path, not npub decoding.
globalThis.window = {
location: { origin: "" },
NostrTools: {
nip19: {
decode: (value) => {
if (typeof value === "string" && value.startsWith("npub1")) {
return { type: "npub", data: HEX };
}
throw new Error("not an npub");
},
},
},
};
mock.method(storageService, "listConnections", async () => [
{ id: "backblaze_b2", provider: "backblaze_b2", meta: { defaultForUploads: true } },
]);
mock.method(storageService, "isUnlocked", () => true);
mock.method(storageService, "getConnection", async () => ({
provider: "backblaze_b2",
accessKeyId: "b2-key",
secretAccessKey: "b2-secret",
endpoint: "https://s3.us-west-004.backblazeb2.com",
region: "us-west-004",
forcePathStyle: false,
meta: {
bucket: "bitvid",
provider: "backblaze_b2",
publicBaseUrl: "https://bitvid.s3.us-west-004.backblazeb2.com",
defaultForUploads: true,
},
}));
});
afterEach(() => {
mock.restoreAll();
globalThis.window = originalWindow;
});
describe("r2Service.resolveConnection accepts hex or npub", () => {
it("resolves the configured connection when passed a HEX pubkey (the upload bug)", async () => {
const svc = new R2Service();
const conn = await svc.resolveConnection(HEX);
assert.ok(conn, "hex pubkey must resolve a connection, not null");
assert.equal(conn.provider, "backblaze_b2");
assert.equal(conn.bucket, "bitvid");
assert.equal(conn.baseDomain, "https://bitvid.s3.us-west-004.backblazeb2.com");
assert.equal(conn.accessKeyId, "b2-key");
});
it("still resolves when passed an npub", async () => {
const svc = new R2Service();
const conn = await svc.resolveConnection("npub1anything");
assert.ok(conn);
assert.equal(conn.provider, "backblaze_b2");
});
it("returns null for empty / non-key input", async () => {
const svc = new R2Service();
assert.equal(await svc.resolveConnection(""), null);
});
});
@@ -0,0 +1,98 @@
// Bug: B2 (and generic-S3) uploads failed with "S3 endpoint is required." MediaUploader
// calls service.prepareUpload(npub, { credentials }) (the r2Service contract), but
// s3UploadService.prepareUpload used to take (settings, ...) — so the npub string became
// the settings (no endpoint). Also, a storageService.getConnection() result keeps
// bucket/endpoint under `.meta`, which validateS3Connection reads at the top level.
//
// Fix: s3UploadService.prepareUpload accepts (npub, { credentials }) AND flattens meta.
import "../test-helpers/setup-localstorage.mjs";
import { test, mock } from "node:test";
import assert from "node:assert/strict";
import { S3UploadService } from "../../js/services/s3UploadService.js";
function createDeps() {
const seen = {};
return {
deps: {
ensureS3SdkLoaded: mock.fn(async () => {}),
makeS3Client: mock.fn(() => ({})),
multipartUpload: mock.fn(async () => {}),
buildR2Key: mock.fn(() => "k"),
buildS3ObjectUrl: mock.fn(() => "https://u/file"),
getCorsOrigins: mock.fn(() => ["*"]),
prepareS3Connection: mock.fn(async (opts) => ({ ...opts })),
validateS3Connection: mock.fn((settings) => {
seen.settings = settings;
return { ...settings };
}),
userLogger: { warn: mock.fn(), error: mock.fn() },
buildStoragePointerValue: mock.fn(() => "p"),
buildStoragePrefixFromKey: mock.fn(() => "pre"),
getVideoNoteErrorMessage: mock.fn((c) => `Error: ${c}`),
normalizeVideoNotePayload: mock.fn((p) => ({ payload: p, errors: [] })),
calculateTorrentInfoHash: mock.fn(async () => "h"),
},
seen,
};
}
// A B2 connection as storageService.getConnection() returns it: keys + endpoint at the
// top level, but bucket/region/forcePathStyle/publicBaseUrl under `.meta`.
const B2_CONNECTION = {
provider: "backblaze_b2",
accessKeyId: "b2key",
secretAccessKey: "b2secret",
endpoint: "https://s3.us-west-004.backblazeb2.com",
forcePathStyle: false,
meta: {
provider: "backblaze_b2",
bucket: "bitvid",
region: "us-west-004",
publicBaseUrl: "https://bitvid.s3.us-west-004.backblazeb2.com",
forcePathStyle: false,
},
};
test("prepareUpload(npub, { credentials }) flattens the connection's meta to top-level settings", async () => {
const { deps, seen } = createDeps();
const service = new S3UploadService(deps);
const { settings, bucketEntry } = await service.prepareUpload("npub1abc", {
credentials: B2_CONNECTION,
});
// validateS3Connection must have received endpoint + bucket (the prior bug passed the
// npub string, so endpoint was undefined).
assert.equal(seen.settings.endpoint, "https://s3.us-west-004.backblazeb2.com");
assert.equal(seen.settings.bucket, "bitvid", "bucket flattened out of meta");
assert.equal(seen.settings.region, "us-west-004");
assert.equal(seen.settings.forcePathStyle, false);
assert.equal(
seen.settings.publicBaseUrl,
"https://bitvid.s3.us-west-004.backblazeb2.com",
"public URL flattened (so uploads don't get a blank base URL)",
);
assert.equal(bucketEntry.bucket, "bitvid");
});
test("the npub is ignored when credentials are supplied (not treated as settings)", async () => {
const { deps, seen } = createDeps();
const service = new S3UploadService(deps);
await service.prepareUpload("npub1ignored", { credentials: B2_CONNECTION });
assert.notEqual(seen.settings, "npub1ignored");
assert.equal(typeof seen.settings, "object");
});
test("still accepts a direct settings object (back-compat)", async () => {
const { deps, seen } = createDeps();
const service = new S3UploadService(deps);
await service.prepareUpload({
endpoint: "https://s3.mock",
bucket: "flat-bucket",
accessKeyId: "k",
secretAccessKey: "s",
});
assert.equal(seen.settings.bucket, "flat-bucket");
assert.equal(seen.settings.endpoint, "https://s3.mock");
});
+111
View File
@@ -0,0 +1,111 @@
// #38: Backblaze B2 as a first-class storage provider.
//
// B2 doesn't map onto the generic-S3 "paste an endpoint" model: its S3-compatible
// endpoint is region-scoped (s3.<region>.backblazeb2.com) and a public bucket is
// addressed virtual-hosted-style, so the public download URL is
// https://<bucket>.s3.<region>.backblazeb2.com/<key> — NOT endpoint/bucket/key.
//
// These tests pin the derivation that makes B2 work: region -> endpoint, and the
// (forcePathStyle:false) public-URL derivation, plus the explicit-override path.
import test from "node:test";
import { strict as assert } from "node:assert";
import {
deriveB2Endpoint,
derivePublicBaseUrl,
validateS3Connection,
buildS3ObjectUrl,
} from "../js/services/s3Service.js";
import { PROVIDERS } from "../js/services/storageService.js";
test("PROVIDERS exposes a dedicated Backblaze B2 id", () => {
assert.equal(PROVIDERS.B2, "backblaze_b2");
});
test("deriveB2Endpoint builds the region-scoped S3 endpoint", () => {
assert.equal(
deriveB2Endpoint("us-west-004"),
"https://s3.us-west-004.backblazeb2.com",
);
assert.equal(
deriveB2Endpoint(" EU-CENTRAL-003 "),
"https://s3.eu-central-003.backblazeb2.com",
"trims + lowercases",
);
});
test("deriveB2Endpoint returns empty for a missing/placeholder region (so callers can require it)", () => {
assert.equal(deriveB2Endpoint(""), "");
assert.equal(deriveB2Endpoint("auto"), "");
assert.equal(deriveB2Endpoint(undefined), "");
});
test("deriveB2Endpoint refuses a pasted host/endpoint in the region box", () => {
assert.equal(deriveB2Endpoint("s3.us-west-004.backblazeb2.com"), "");
assert.equal(deriveB2Endpoint("https://s3.us-west-004.backblazeb2.com"), "");
});
test("a B2 bucket's public URL derives virtual-hosted (the S3-style download host)", () => {
// This is the crux: with the derived endpoint + forcePathStyle false, the bucket
// becomes a subdomain — the URL a public B2 bucket actually serves files from.
const endpoint = deriveB2Endpoint("us-west-004");
const publicBaseUrl = derivePublicBaseUrl({
endpoint,
bucket: "my-videos",
forcePathStyle: false,
});
assert.equal(
publicBaseUrl,
"https://my-videos.s3.us-west-004.backblazeb2.com",
);
});
test("validateS3Connection derives the B2 public URL end-to-end (no manual public URL)", () => {
const normalized = validateS3Connection({
endpoint: deriveB2Endpoint("us-west-004"),
region: "us-west-004",
accessKeyId: "k",
secretAccessKey: "s",
bucket: "my-videos",
forcePathStyle: false,
// publicBaseUrl intentionally omitted -> must derive
});
assert.equal(normalized.endpoint, "https://s3.us-west-004.backblazeb2.com");
assert.equal(
normalized.publicBaseUrl,
"https://my-videos.s3.us-west-004.backblazeb2.com",
);
assert.equal(normalized.forcePathStyle, false);
});
test("an explicit Public Access URL (custom domain / CDN) overrides the derived B2 URL", () => {
const normalized = validateS3Connection({
endpoint: deriveB2Endpoint("us-west-004"),
region: "us-west-004",
accessKeyId: "k",
secretAccessKey: "s",
bucket: "my-videos",
forcePathStyle: false,
publicBaseUrl: "https://cdn.example.com",
});
assert.equal(normalized.publicBaseUrl, "https://cdn.example.com");
});
test("object URLs for a B2 connection resolve under the virtual-hosted bucket host", () => {
const endpoint = deriveB2Endpoint("us-west-004");
const url = buildS3ObjectUrl({
publicBaseUrl: derivePublicBaseUrl({
endpoint,
bucket: "my-videos",
forcePathStyle: false,
}),
endpoint,
bucket: "my-videos",
key: "u/npub1/abc/video.mp4",
forcePathStyle: false,
});
assert.equal(
url,
"https://my-videos.s3.us-west-004.backblazeb2.com/u/npub1/abc/video.mp4",
);
});
+185
View File
@@ -0,0 +1,185 @@
// Per-provider storage connections: each provider type (R2 / B2 / Custom S3) must keep
// its OWN credentials so saving one never overwrites another, and the saved set syncs
// as a whole. The UI used to collapse everything into one shared "default" slot.
import { test, describe, beforeEach } from "node:test";
import assert from "node:assert";
import * as fakeIndexedDB from "fake-indexeddb";
if (!globalThis.indexedDB) {
globalThis.indexedDB = fakeIndexedDB.indexedDB;
globalThis.IDBKeyRange = fakeIndexedDB.IDBKeyRange;
}
import storageService from "../js/services/storageService.js";
import {
connectionProvider,
findProviderConnection,
computeDefaultForUploads,
legacyDuplicateIds,
saveProviderConnection,
} from "../js/ui/profileModal/storageConnections.js";
// --- Pure decision logic ---
describe("per-provider connection helpers", () => {
test("computeDefaultForUploads: explicit choice, first-ever, and 'was already default'", () => {
// Explicit checkbox always wins.
assert.equal(
computeDefaultForUploads({ isDefault: true, connections: [{ id: "x" }], provider: "backblaze_b2" }),
true,
);
// The very first connection becomes the default automatically.
assert.equal(
computeDefaultForUploads({ isDefault: false, connections: [], provider: "backblaze_b2" }),
true,
);
// Re-saving the provider that was already the default keeps it default...
assert.equal(
computeDefaultForUploads({
isDefault: false,
connections: [{ id: "cloudflare_r2", provider: "cloudflare_r2", meta: { defaultForUploads: true } }],
provider: "cloudflare_r2",
}),
true,
);
// ...but a non-default provider stays non-default (won't steal it).
assert.equal(
computeDefaultForUploads({
isDefault: false,
connections: [
{ id: "cloudflare_r2", provider: "cloudflare_r2", meta: { defaultForUploads: true } },
{ id: "backblaze_b2", provider: "backblaze_b2", meta: { defaultForUploads: false } },
],
provider: "backblaze_b2",
}),
false,
);
});
test("findProviderConnection matches on provider (payload or meta)", () => {
const conns = [
{ id: "a", provider: "cloudflare_r2" },
{ id: "b", meta: { provider: "backblaze_b2" } },
];
assert.equal(findProviderConnection(conns, "backblaze_b2").id, "b");
assert.equal(findProviderConnection(conns, "generic_s3"), null);
assert.equal(connectionProvider(conns[1]), "backblaze_b2");
});
test("legacyDuplicateIds flags same-provider connections stored under a different id", () => {
const conns = [
{ id: "default", provider: "cloudflare_r2" }, // the legacy shared slot
{ id: "cloudflare_r2", provider: "cloudflare_r2" }, // the new per-provider slot
{ id: "backblaze_b2", provider: "backblaze_b2" },
];
assert.deepEqual(legacyDuplicateIds(conns, "cloudflare_r2", "cloudflare_r2"), ["default"]);
assert.deepEqual(legacyDuplicateIds(conns, "backblaze_b2", "backblaze_b2"), []);
});
});
// --- Integration against the real storageService (fake-indexeddb) ---
describe("saving multiple providers does not clash", () => {
const pubkey = "00".repeat(31) + "07";
const signer = {
nip44Encrypt: async (_pk, pt) => `enc_${pt}`,
nip44Decrypt: async (_pk, ct) => ct.replace(/^enc_/, ""),
};
beforeEach(async () => {
storageService.masterKeys.clear();
if (storageService.db) {
storageService.db.close();
storageService.db = null;
storageService.dbPromise = null;
}
await new Promise((resolve) => {
const req = globalThis.indexedDB.deleteDatabase("bitvid-storage");
req.onsuccess = resolve;
req.onerror = resolve;
req.onblocked = resolve;
});
await storageService.unlock(pubkey, { signer });
});
const r2Payload = { provider: "cloudflare_r2", accessKeyId: "r2k", secretAccessKey: "r2s", accountId: "acct" };
const b2Payload = { provider: "backblaze_b2", accessKeyId: "b2k", secretAccessKey: "b2s" };
test("R2 then B2 both persist under their own provider ids; only one is default", async () => {
await saveProviderConnection(storageService, pubkey, {
provider: "cloudflare_r2",
payload: r2Payload,
meta: { provider: "cloudflare_r2", bucket: "r2-bucket" },
isDefault: true,
});
await saveProviderConnection(storageService, pubkey, {
provider: "backblaze_b2",
payload: b2Payload,
meta: { provider: "backblaze_b2", bucket: "b2-bucket", region: "us-west-004" },
isDefault: false,
});
const conns = await storageService.listConnections(pubkey);
const ids = conns.map((c) => c.id).sort();
assert.deepEqual(ids, ["backblaze_b2", "cloudflare_r2"], "both providers stored, no overwrite");
const r2 = await storageService.getConnection(pubkey, "cloudflare_r2");
const b2 = await storageService.getConnection(pubkey, "backblaze_b2");
assert.equal(r2.meta.bucket, "r2-bucket");
assert.equal(b2.meta.bucket, "b2-bucket");
// Credentials are independent (B2 did not clobber R2).
assert.equal(r2.accessKeyId, "r2k");
assert.equal(b2.accessKeyId, "b2k");
// Exactly one default-for-uploads, and it's the one the user chose.
assert.equal(conns.filter((c) => c.meta?.defaultForUploads).length, 1);
assert.equal(r2.meta.defaultForUploads, true);
assert.equal(b2.meta.defaultForUploads, false);
});
test("re-saving B2 as the default moves the active target off R2 (still only one default)", async () => {
await saveProviderConnection(storageService, pubkey, { provider: "cloudflare_r2", payload: r2Payload, meta: { provider: "cloudflare_r2" }, isDefault: true });
await saveProviderConnection(storageService, pubkey, { provider: "backblaze_b2", payload: b2Payload, meta: { provider: "backblaze_b2" }, isDefault: false });
await saveProviderConnection(storageService, pubkey, { provider: "backblaze_b2", payload: b2Payload, meta: { provider: "backblaze_b2" }, isDefault: true });
const conns = await storageService.listConnections(pubkey);
assert.equal(conns.filter((c) => c.meta?.defaultForUploads).length, 1);
assert.equal((await storageService.getConnection(pubkey, "backblaze_b2")).meta.defaultForUploads, true);
assert.equal((await storageService.getConnection(pubkey, "cloudflare_r2")).meta.defaultForUploads, false);
});
test("a legacy shared 'default' slot is migrated to its per-provider id (no duplicate)", async () => {
// Simulate the old UI: an R2 connection under the shared "default" id.
await storageService.saveConnection(pubkey, "default", r2Payload, {
provider: "cloudflare_r2",
bucket: "legacy",
defaultForUploads: true,
});
// Re-saving R2 through the new path should re-key it and remove the legacy slot.
await saveProviderConnection(storageService, pubkey, {
provider: "cloudflare_r2",
payload: r2Payload,
meta: { provider: "cloudflare_r2", bucket: "legacy" },
isDefault: false,
});
const ids = (await storageService.listConnections(pubkey)).map((c) => c.id);
assert.ok(ids.includes("cloudflare_r2"));
assert.ok(!ids.includes("default"), "legacy 'default' slot removed");
// Default-for-uploads carried over from the migrated connection.
assert.equal((await storageService.getConnection(pubkey, "cloudflare_r2")).meta.defaultForUploads, true);
});
test("both connections are present in the exported account record (so sync carries them)", async () => {
await saveProviderConnection(storageService, pubkey, { provider: "cloudflare_r2", payload: r2Payload, meta: { provider: "cloudflare_r2" }, isDefault: true });
await saveProviderConnection(storageService, pubkey, { provider: "backblaze_b2", payload: b2Payload, meta: { provider: "backblaze_b2" }, isDefault: false });
const record = await storageService.exportAccountRecord(pubkey);
assert.deepEqual(
Object.keys(record.connections).sort(),
["backblaze_b2", "cloudflare_r2"],
"the synced record contains every provider's connection",
);
});
});
+167
View File
@@ -0,0 +1,167 @@
// Provider-aware storage CORS setup helper. The helper must give the RIGHT rules +
// apply-command for each provider: B2-native rules + the b2 CLI for Backblaze B2, and
// standard S3 CORS + AWS CLI / dashboard guidance for Custom S3 and Cloudflare R2.
// The invariant across all of them: the rules allow browser uploads (PUT/POST), which
// is what makes uploads stop CORS-failing.
import test, { beforeEach, afterEach } from "node:test";
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import {
buildBucketCorsRules,
buildS3CorsConfig,
buildB2CorsCommand,
buildAwsCorsCommand,
buildCorsHelpContent,
StorageCorsHelp,
} from "../js/ui/profileModal/storageCorsHelp.js";
test("B2-native rules include the upload operations the B2 presets omit", () => {
const [rule] = buildBucketCorsRules(["https://bitvid.network"]);
assert.ok(rule.allowedOperations.includes("s3_put"));
assert.ok(rule.allowedOperations.includes("s3_post"));
assert.ok(rule.allowedOperations.includes("s3_get"));
});
test("standard S3 CORS config allows upload + ranged playback methods", () => {
const cfg = buildS3CorsConfig(["https://bitvid.network"]);
const rule = cfg.CORSRules[0];
assert.deepEqual(rule.AllowedMethods, ["GET", "HEAD", "PUT", "POST", "DELETE"]);
// OPTIONS is not a valid S3 AllowedMethod and must not be present.
assert.ok(!rule.AllowedMethods.includes("OPTIONS"));
assert.deepEqual(rule.ExposeHeaders, [
"ETag",
"Content-Length",
"Content-Range",
"Accept-Ranges",
]);
});
test("origins fall back to every-origin when none are provided", () => {
assert.deepEqual(buildBucketCorsRules([])[0].allowedOrigins, ["*"]);
assert.deepEqual(buildS3CorsConfig(undefined).CORSRules[0].AllowedOrigins, ["*"]);
});
test("B2 command targets the bucket allPublic with compact one-line JSON", () => {
const cmd = buildB2CorsCommand("bitvid", buildBucketCorsRules(["https://x"]));
assert.match(cmd, /^b2 update-bucket --corsRules '.*' bitvid allPublic$/);
assert.ok(!cmd.includes("\n"));
});
test("AWS command includes the endpoint + bucket, one line", () => {
const cmd = buildAwsCorsCommand(
"vids",
"https://s3.example.com",
buildS3CorsConfig(["https://x"]),
);
assert.match(
cmd,
/^aws s3api put-bucket-cors --bucket vids --endpoint-url https:\/\/s3\.example\.com --cors-configuration '.*'$/,
);
});
test("content for Backblaze B2 uses B2-native rules + the b2 CLI", () => {
const c = buildCorsHelpContent({
provider: "backblaze_b2",
origins: ["https://bitvid.network"],
bucket: "bitvid",
endpoint: "https://s3.us-west-004.backblazeb2.com",
});
assert.match(c.cmd, /^b2 update-bucket /);
assert.match(c.cmdLabel, /B2 command-line/i);
assert.ok(JSON.parse(c.json)[0].allowedOperations.includes("s3_put"));
assert.match(c.notes, /pipx install b2/i);
});
test("content for Custom S3 uses standard S3 CORS + the AWS CLI", () => {
const c = buildCorsHelpContent({
provider: "generic_s3",
origins: ["https://bitvid.network"],
bucket: "vids",
endpoint: "https://s3.example.com",
});
assert.match(c.cmd, /^aws s3api put-bucket-cors /);
assert.match(c.cmd, /--endpoint-url https:\/\/s3\.example\.com/);
assert.ok(JSON.parse(c.json).CORSRules[0].AllowedMethods.includes("PUT"));
assert.doesNotMatch(c.cmd, /backblaze|b2 update-bucket/i);
});
test("content for Cloudflare R2 uses S3 CORS and points at the R2 dashboard", () => {
const c = buildCorsHelpContent({
provider: "cloudflare_r2",
origins: ["https://bitvid.network"],
bucket: "vids",
endpoint: "https://acct.r2.cloudflarestorage.com",
});
assert.match(c.cmd, /^aws s3api put-bucket-cors /);
assert.match(c.notes, /Cloudflare dashboard/i);
assert.match(c.cmdLabel, /Cloudflare/i);
assert.ok(JSON.parse(c.json).CORSRules);
});
// --- DOM behavior (provider switch reflected in the modal) ---
let dom;
beforeEach(() => {
dom = new JSDOM(
`<!doctype html><html><body>
<select id="prov"><option value="cloudflare_r2">r2</option></select>
<button id="storageCorsHelpBtn"></button>
<div id="storageCorsModal" class="hidden">
<div class="modal-sheet" tabindex="-1"></div>
<button data-storage-cors-dismiss id="x"></button>
<p id="storageCorsModalIntro"></p>
<code id="storageCorsJson"></code>
<span id="storageCorsCmdLabel"></span>
<code id="storageCorsCli"></code>
<p id="storageCorsNotes"></p>
<button id="storageCorsCopyJsonBtn"></button>
<button id="storageCorsCopyCmdBtn"></button>
</div>
</body></html>`,
{ url: "https://unstable.bitvid.network/" },
);
globalThis.document = dom.window.document;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.window = dom.window;
});
afterEach(() => {
delete globalThis.document;
delete globalThis.HTMLElement;
delete globalThis.window;
});
test("open() renders provider-specific content and pre-fills the current origin", () => {
let provider = "backblaze_b2";
const help = new StorageCorsHelp({
getProvider: () => provider,
getBucket: () => "bitvid",
getRegion: () => "us-west-004",
getEndpoint: () => "",
});
help.cacheDom(dom.window.document);
help.registerEventListeners();
help.open();
const doc = dom.window.document;
assert.ok(!doc.getElementById("storageCorsModal").classList.contains("hidden"));
assert.match(doc.getElementById("storageCorsCli").textContent, /^b2 update-bucket /);
const b2Rule = JSON.parse(doc.getElementById("storageCorsJson").textContent)[0];
assert.deepEqual(b2Rule.allowedOrigins, ["https://unstable.bitvid.network"]);
// Switch provider → reopening yields the AWS-CLI form.
provider = "generic_s3";
help.open();
assert.match(doc.getElementById("storageCorsCli").textContent, /^aws s3api put-bucket-cors /);
assert.ok(JSON.parse(doc.getElementById("storageCorsJson").textContent).CORSRules);
});
test("dismiss controls hide the modal again", () => {
const help = new StorageCorsHelp({ getProvider: () => "generic_s3" });
help.cacheDom(dom.window.document);
help.registerEventListeners();
help.open();
dom.window.document.getElementById("x").click();
assert.ok(
dom.window.document.getElementById("storageCorsModal").classList.contains("hidden"),
);
});
+108
View File
@@ -0,0 +1,108 @@
// #36: storage unlock fails for an nsec user AFTER A PAGE RELOAD.
//
// A persisted ("remember this key") nsec session restores the logged-in pubkey + UI
// on reload, but NOT the in-memory signer — the private key is held only passphrase-
// encrypted. Storage unlock then has no signer (or a decrypt-less stub), and the old
// code dead-ended with the misleading "No active signer found. Please login." even
// though the user still looks logged in.
//
// The fix detects this exact case (a saved nsec key for the account being unlocked)
// and surfaces an actionable error pointing the user at re-unlocking their saved key,
// instead of the generic signer/decryptor errors. These tests exercise the detection
// + reporting directly (no DOM, no module-level signer dependency).
import test from "node:test";
import { strict as assert } from "node:assert";
import { ProfileStorageController } from "../js/ui/profileModal/ProfileStorageController.js";
const PUBKEY = "a".repeat(64);
const OTHER_PUBKEY = "b".repeat(64);
function makeController({ meta } = {}) {
const shownErrors = [];
const loginOpens = [];
const mainController = {
normalizeHexPubkey: (value) =>
typeof value === "string" ? value.trim().toLowerCase() : "",
getActivePubkey: () => PUBKEY,
showError: (msg) => shownErrors.push(msg),
services: {
nostrClient: {
getStoredSessionActorMetadata: () => meta,
},
openLoginModal: (options) => {
loginOpens.push(options ?? null);
return true;
},
},
};
const controller = new ProfileStorageController(mainController);
return { controller, shownErrors, loginOpens };
}
const persistedNsecMeta = {
hasEncryptedKey: true,
pubkey: PUBKEY,
source: "nsec",
createdAt: 1234,
};
test("detects a locked persisted nsec session for the active account", () => {
const { controller } = makeController({ meta: persistedNsecMeta });
const detected = controller.getLockedStoredNsecSession(PUBKEY);
assert.ok(detected, "should detect the locked saved key");
assert.equal(detected.source, "nsec");
});
test("does NOT treat a missing saved key as a locked session", () => {
const { controller } = makeController({ meta: null });
assert.equal(controller.getLockedStoredNsecSession(PUBKEY), null);
});
test("does NOT treat a non-nsec session as a locked nsec session", () => {
const { controller } = makeController({
meta: { hasEncryptedKey: true, pubkey: PUBKEY, source: "extension" },
});
assert.equal(controller.getLockedStoredNsecSession(PUBKEY), null);
});
test("does NOT claim 'this account is locked' when the saved key is a DIFFERENT account", () => {
const { controller } = makeController({
meta: { hasEncryptedKey: true, pubkey: OTHER_PUBKEY, source: "nsec" },
});
assert.equal(controller.getLockedStoredNsecSession(PUBKEY), null);
});
test("reporting the locked session shows the actionable re-unlock message, not a generic signer error", () => {
const { controller, shownErrors } = makeController({ meta: persistedNsecMeta });
controller.reportLockedNsecSession();
assert.equal(controller.storageUnlockFailure?.code, "storage-unlock-locked-nsec-session");
assert.equal(shownErrors.length, 1);
const msg = shownErrors[0];
// Must direct the user to re-unlock the SAVED KEY (passphrase), not "please login".
assert.match(msg, /saved key/i);
assert.match(msg, /passphrase/i);
assert.doesNotMatch(msg, /no active signer/i);
});
test("autoOpenLogin opens the login modal's unlock flow (one-click re-unlock)", () => {
const { controller, loginOpens } = makeController({ meta: persistedNsecMeta });
controller.reportLockedNsecSession({ autoOpenLogin: true });
assert.equal(loginOpens.length, 1, "should open the login modal exactly once");
});
test("the passive path does NOT auto-open the login modal (no surprise popups)", () => {
const { controller, loginOpens } = makeController({ meta: persistedNsecMeta });
controller.reportLockedNsecSession();
assert.equal(loginOpens.length, 0, "default (passive) report must not open a modal");
});
test("the new error code maps to a saved-key/passphrase message", () => {
const { controller } = makeController({ meta: persistedNsecMeta });
const msg = controller.getStorageUnlockFailureMessage({
code: "storage-unlock-locked-nsec-session",
});
assert.match(msg, /saved key/i);
assert.match(msg, /passphrase/i);
});
+71
View File
@@ -0,0 +1,71 @@
// Bug: with a persisted nsec login, uploading after a page reload said "storage is
// locked" and then "No signer available to unlock storage." — the persisted nsec
// signer isn't in memory after reload (the key is passphrase-encrypted), and the
// upload modal dead-ended instead of prompting for the passphrase.
//
// promptStoredNsecUnlock() detects that exact case (a saved nsec key for the active
// account) and opens the re-unlock flow, returning true so handleUnlock stops before
// the dead-end alert. Tested via the prototype to avoid constructing the full modal.
import test from "node:test";
import { strict as assert } from "node:assert";
import { UploadModal } from "../js/ui/components/UploadModal.js";
const PUBKEY = "a".repeat(64);
const OTHER = "b".repeat(64);
const prompt = UploadModal.prototype.promptStoredNsecUnlock;
function makeCtx({ meta, withUnlock = true } = {}) {
const calls = { unlock: 0, errors: [] };
return {
ctx: {
authService: {
nostrClient: { getStoredSessionActorMetadata: () => meta },
},
onRequestUnlock: withUnlock
? () => {
calls.unlock += 1;
return true;
}
: null,
showError: (m) => calls.errors.push(m),
},
calls,
};
}
const lockedNsec = { hasEncryptedKey: true, pubkey: PUBKEY, source: "nsec" };
test("detects a locked saved nsec key for the active account and opens the unlock flow", () => {
const { ctx, calls } = makeCtx({ meta: lockedNsec });
const handled = prompt.call(ctx, PUBKEY);
assert.equal(handled, true, "returns true so handleUnlock stops before the dead-end");
assert.equal(calls.unlock, 1, "opened the re-unlock (passphrase) flow");
assert.equal(calls.errors.length, 1);
assert.match(calls.errors[0], /passphrase/i);
});
test("returns false when there is no saved session (real missing-signer case)", () => {
const { ctx } = makeCtx({ meta: null });
assert.equal(prompt.call(ctx, PUBKEY), false);
});
test("returns false for a non-nsec stored session", () => {
const { ctx } = makeCtx({
meta: { hasEncryptedKey: true, pubkey: PUBKEY, source: "extension" },
});
assert.equal(prompt.call(ctx, PUBKEY), false);
});
test("does not hijack when the saved key is for a DIFFERENT account", () => {
const { ctx, calls } = makeCtx({
meta: { hasEncryptedKey: true, pubkey: OTHER, source: "nsec" },
});
assert.equal(prompt.call(ctx, PUBKEY), false);
assert.equal(calls.unlock, 0);
});
test("returns false (falls through to the alert) when no unlock hook is wired", () => {
const { ctx } = makeCtx({ meta: lockedNsec, withUnlock: false });
assert.equal(prompt.call(ctx, PUBKEY), false);
});
+369 -15
View File
@@ -30,6 +30,56 @@ tests → `npm run build` + `npm run test:unit` green → commit + push.
## Open — high priority (launch-blocking candidates)
### 33. Login / profile / session persistence broken with NIP-46 (Amber) — AUDIT (BUG)
Reported 2026-06-25. All symptoms observed with a **NIP-46 remote signer (Amber)**.
Likely a shared root cause in session/identity persistence + multi-account handling.
- [ ] **Lost login → "unknown user".** Sometimes the site loses who is logged in: the
profile shows "unknown user", the profile area renders weird, and no videos load.
Only a **logout + hard refresh** recovers it. (Session/identity state desync —
possibly the NIP-46 signer connection or the persisted pubkey dropping after a
refresh/idle, leaving the UI without a resolved identity.)
- [ ] **Switching profiles doesn't work** for logged-in users (multi-account switch is
broken).
- [ ] **Logout logs out everyone.** Logging out the current user appears to log out
**every** signed-in account, not just the active one (shared/global session state
instead of per-account).
- [ ] **Audit the whole login + profile system + user persistence** across refresh and
repeat visits — especially the NIP-46 path: how the active pubkey + signer
connection are stored/restored, how multiple accounts are tracked, and how
logout scopes to one account. Produce a plan (likely `docs/`), then fix.
- [x] **AUDIT done 2026-06-25 (code-level).** Architecture:
`js/state/cache.js` persists a list of **saved profiles** (`bitvid:savedProfiles:v1`)
+ a single **active profile pubkey**. There is ONE active session at a time.
`SignerManager` (`js/nostr/managers/SignerManager.js`) holds a SINGLE shared
`this.nip46Client` (one remote-signer connection). Likely root cause for ALL three
symptoms: the single-shared `nip46Client` + per-profile restore/switch not being
multi-account-aware. Specifics:
- **Logout-logs-out-everyone:** `authService.logout()` IS registry-scoped
(`logoutSignerFromRegistry(previousPubkey)`), but `SignerManager.logout()`
calls `disconnectRemoteSigner()` which destroys the SHARED `nip46Client` — so
for NIP-46 every profile that depends on it loses its signer.
- **Switch-doesn't-work:** `authService.switchProfile` re-runs `requestLogin`
with `expectPubkey`; for NIP-46 the shared client must be re-established for the
target profile — if it doesn't reconnect, the new profile has no working signer.
- **Unknown-user-after-refresh:** `scheduleStoredRemoteSignerRestore` →
`useStoredRemoteSigner` silently fails (the catch logged via the gated
`devLogger`), leaving `this.pubkey` unresolved → UI shows "unknown user".
- [x] **Diagnostics SHIPPED (2026-06-25, TEMP).** Added `[nip46-diag]` `userLogger`
traces (visible on unstable, where `devLogger` is gated off) at:
`scheduleStoredRemoteSignerRestore` (hasStored/storedPubkey + succeeded/FAILED),
`disconnectRemoteSigner` (keepStored/hadClient + caller stack),
`SignerManager.logout` (previousPubkey/hadRemoteClient), and
`authService.switchProfile` (from/to + whether requestLogin resolved the expected
pubkey). **Remove these once the fix lands.**
- [ ] **VERIFY / REPRODUCE (user action):** on the phone with Amber on
unstable.bitvid.network, exercise (1) fresh load, (2) switch profiles, (3) logout,
and capture the `[nip46-diag]` console lines for each — restore succeeded/FAILED,
switchProfile `matchesTarget`, and disconnect `keepStored`/`hadClient`/caller stack.
- [ ] **THEN fix** based on the captured trace (likely: per-profile NIP-46 session keyed
by pubkey; don't tear the shared client on a scoped logout; reconnect on
switch/restore) and REMOVE the temporary `[nip46-diag]` diagnostics.
### 1. Delete is not fully working — tombstoned videos still show in the UI
- [x] **Root cause found + fixed** (`afb6200b`): deletes published only to the CAPPED
read set (<=8) while videos publish to the full write set, so relays outside
@@ -442,14 +492,31 @@ toward freshness and looked identical. Gave each a structural identity:
### 7. Mobile + video-card layout
- [ ] Improve mobile layout and the video card layout.
- [ ] **Improve mobile UI** broadly (reiterated 2026-06-25) — not just the card: nav/
sidebar, modals, the player, forms, and touch targets across the app.
- [x] **Profile modal tabs → app-grid on mobile (2026-06-25).** Below 1024px (the
full-width menu view), the profile modal's tab nav (`.profile-modal__nav`:
Account/Relays/Wallet/Storage/…) renders as a 3-column grid of icon tiles
with the title centered below each icon, instead of the vertical list.
Desktop keeps the vertical nav. Token-only; validated live in-browser.
`css/tailwind.source.css`. (NOTE: the sidebar is NOT a grid — an earlier
sidebar version was a misread and was reverted.)
- [ ] Consider removing the CDN/WebTorrent source badge from the card (clutter).
- [ ] Run `npm run test:visual` after layout changes; update baselines deliberately.
### 8. Orphan storage garbage-collection tool
- [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no
live note references and offers per-file delete (under the user's prefix only).
- [ ] Remaining (optional): a one-click **bulk** "delete all orphans" action, and/or
surfacing the same control from the Storage settings pane.
- [x] **Bulk "Delete all" orphans — DONE 2026-06-30.** Added a "Delete all (N)" button to
the orphaned-objects header in My Videos (shown only when >1 orphan). One
confirmation → a single `deleteStorageKeys({ keys: allOrphans, pubkey })` call
(already array-capable + unlock-gated + prefix-scoped). Refreshes the list on
success and reports partial outcomes honestly ("Removed 2 files (1 could not be
deleted)"); surfaces storage-locked as an unlock prompt. Tests:
`tests/my-videos-bulk-orphan-delete.test.mjs` (5 — all-keys-in-one-call, cancel,
storage-locked, partial, empty no-op). Build + lint clean; My Videos suite 37/37.
- [ ] Remaining (optional): surface the same orphan control from the Storage settings
pane (currently only in My Videos).
### 9. Cap the cold-login relay-REQ storm further
- [x] **Biggest source fixed** (`a8e9f520`): the community-blacklist load fired one
@@ -523,12 +590,31 @@ toward freshness and looked identical. Gave each a structural identity:
allow-override after the WoT mute/block checks.
### 25. Per-video / per-event admin block list (granular moderation)
- [ ] Today admin moderation is essentially a "ban hammer" (author/community level).
Add a **per-event (per-video) block list** so an admin can hide a SINGLE
offending video without blocking the whole author. Define the per-event block
record (kind/addressable + the event id / address it targets), wire it into the
render-time filter alongside the existing author/community blacklist, and expose
it in the moderation UI (and eventually the #23 admin tab).
- [x] **DONE 2026-06-30 (full vertical slice).** Admins (any editor — same gate as the
author blacklist) can now hide a SINGLE video without blocking its author.
- **Record:** a published kind-30000 NIP-51 list under d-tag
`bitvid:admin:event-blacklist`, carrying blocked event ids as `e` tags
(`NOTE_TYPES.ADMIN_EVENT_BLACKLIST` schema; `participantTagName: "e"`).
Loaded/persisted through the existing admin-list machinery
(`adminListStore` loadNostrState/persistNostrState) and cached like the others.
- **accessControl:** `eventBlacklist` Set + `getEventBlacklist()`/
`isEventBlacklisted()`/`addToEventBlacklist()`/`removeFromEventBlacklist()`
(editor-gated, normalizes hex or nevent/note) + `onEventBlacklistChange`.
- **Render filter:** already existed (`app.blacklistedEventIds`, honored by every
feed/grid/search/playback path) but was fed only by the static
`ADMIN_INITIAL_EVENT_BLACKLIST` config. Now `app._rebuildBlacklistedEventIds()`
merges that static base with the dynamic admin list, and an
`onEventBlacklistChange` listener rebuilds + refreshes the grids on change.
- **UI:** a "Block this video" item in the per-video ⋯ menu (editor-only, via
`videoMenuRenderers`), handled by `handleBlacklistEventAction`
(`js/ui/moreMenu/blacklistEventAction.js`).
- Helpers extracted to `js/adminEventBlacklistHelpers.js` to keep adminListStore /
accessControl / moreMenuController under their size caps. Tests:
`tests/admin-event-blacklist.test.mjs` (9 — record helpers + editor-gated action,
fallbacks, refusal, publish-failure). Build + lint clean; admin/access/moderation
suites 56/56.
- [ ] **Follow-up:** surface the per-event list (view/unblock) in the Admin pane /
future #23 admin tab; today removal is via `removeFromEventBlacklist` (no UI yet).
### 26. Video popularity / view-count chart (public, three-dots menu) — DONE 2026-06-24
- [x] **Shipped.** A "Popularity" item in the ⋯ menu opens a public views-over-time
@@ -676,6 +762,228 @@ to rescue a creator who *does* get WoT-hidden, so if it happens, ship this promp
a signed Nostr event per Blossom rather than S3 keys. Research the BUD spec
coverage needed for bitvid's upload/delete flows.
### 34. NIP-71 mirror: videos show "not mirrored" + duplicate on re-mirror (BUG)
Reported 2026-06-25. Relates to #17 (NIP-71 interop) / the bitvid→NIP-71 mirror.
- [ ] **Mirrored videos report as NOT mirrored.** All videos were mirrored, but the UI
now shows them un-mirrored even though the NIP-71 mirror events exist. The
"is this mirrored?" detection is wrong/stale (likely the mirror-state lookup
doesn't find the existing kind-21/22 mirror, or a flag isn't persisted/derived).
- [ ] **Re-mirroring creates DUPLICATES.** Because they read as un-mirrored, mirroring
again publishes a SECOND NIP-71 event, so NIP-71 clients show two copies of the
same video. The mirror should be idempotent (addressable/replaceable, or detect
the existing mirror and update it) instead of creating a duplicate.
- [ ] Delete/un-mirror correctly removes BOTH copies (so the dedup key is shared) —
confirms the publish path is creating a true duplicate, not a distinct event.
- [ ] Fix: make mirror detection reliable + the mirror publish idempotent. See
`js/nostr/nip71Mirror.js` / `js/services/nip71MirrorFlags.js`.
- [x] **AUDIT done 2026-06-25.** Two root causes:
1. **Detection is device-local.** `isMirrorEnabled(pubkey, videoRootId)`
(`nip71MirrorFlags.js`) reads a localStorage flag (`FLAG_KEY`), NOT the actual
published mirror events — so a cache clear / other device / lost flag reports
"not mirrored" even though the 34235/34236 events exist on relays.
2. **Kind is unstable → cross-kind duplicate.** `buildNip71MirrorEvent`
(`nip71Mirror.js:177`) uses `short = hasDims && height > width` → kind 34236,
else 34235. The d-tag is deterministic (`["d", videoRootId]`), but
`(34235,pubkey,d)` and `(34236,pubkey,d)` are DISTINCT addressable events. When
a video has dimensions on one mirror attempt but not another, the kind flips, so
re-mirroring publishes the OTHER kind → two copies in NIP-71 clients. "Delete
removes both" confirms the shared d-tag / two-kind duplicate.
- [x] **FIX (a) Idempotent publish + self-heal — DONE 2026-06-25.**
`nip71MirrorService.publish()` now looks up the author's existing mirror (both
kinds) via the SubscriptionManager chokepoint and **reuses the existing kind** so
a re-publish REPLACES the same addressable coordinate instead of creating the
cross-kind duplicate. If both kinds already exist (the bug's aftermath), it
**self-heals** by NIP-09-deleting the stale-kind coordinate. An explicit
`options.short` override still wins. Also added `findMirror(video)` (relay-truth
`{mirrored, kinds, duplicate}`). Dependency-injected; 4 new scenario tests in
`tests/nip71-mirror-service.test.mjs` (12/12 green); lint clean (no direct
pool.list — uses `getSubscriptionManager().list`).
- [x] **FIX (b) Relay-truth detection in the UI — DONE 2026-06-29.** Added batched
`nip71MirrorService.findMirrors(videos)` → `Map<videoRootId, {mirrored, kinds,
duplicate}>` (one relay lookup per root via `Promise.all`, all through the
SubscriptionManager chokepoint). `MyVideosController.renderRows` now fires
`refreshMirrorStates(rows)` after the synchronous (local-flag) render: it looks up
relay truth, **reconciles the device-local flag to match** (so the flag becomes a
correct cache, not the source of truth), and corrects each mirror button's label
("Shared ✓" / "Share to apps"). Buttons are tagged `data-mirror-root` for the
batched update. Fire-and-forget — never blocks render; detection failures fall back
to the local flag. 4 new behavioral tests in
`tests/my-videos-mirror-relay-truth.test.mjs` (jsdom: promote unmirrored→shared,
clear stale shared, per-row batch reconcile, button tagging); build + lint clean.
- [ ] **VERIFY on unstable with Amber:** re-mirror an already-mirrored video → it should
replace (one copy), and a previously-duplicated video should collapse to one.
### 35. Admin whitelisting tool is slow & cumbersome — improve with application forms
- [ ] The current admin whitelist tool is slow and clunky to use. Improve the UX when
we integrate the **application/submission forms** (#22) and the **Admin
Submissions tab** (#23) — e.g. approve-an-application → add-to-whitelist in one
action, batch operations, and faster list mutation. Couples with #22/#23.
### 36. Storage unlock doesn't work with nsec login (BUG)
- [x] **ROOT-CAUSED + FIXED 2026-06-29.** Audited the whole nsec unlock path and built
an empirical repro: the crypto layer is **correct** — with real nsec cipher
closures (`createPrivateKeyCipherClosures`), `storageService.unlock` round-trips
master-key encrypt→lock→decrypt perfectly (NIP-44). Both nsec login paths
(`registerPrivateKeySigner`, `unlockStoredSessionActor`) register a signer exposing
`nip04Decrypt`/`nip44Decrypt`. **The real failure is session-shape after a page
reload** (user-confirmed repro): a persisted "remember this key" nsec session
restores the logged-in **pubkey + UI but NOT the in-memory signer** (the private key
is only passphrase-encrypted). `ProfileStorageController`'s fallback
(`ensureActiveSignerForPubkey`) can only rebuild an *extension* signer, so an nsec
user hit a dead-end: storage unlock found no signer and showed the misleading "No
active signer found. Please login." even though they look logged in.
**Fix:** detect this exact case (`getLockedStoredNsecSession` — a saved nsec key for
the account being unlocked via `getStoredSessionActorMetadata`) and surface an
**actionable** error/status ("Your saved key is locked after reload — re-enter your
passphrase from the Login menu to unlock it, then unlock storage") in both the
Unlock-button flow and the passive Storage-pane status text, instead of the generic
no-signer / no-decryptor messages. New error code
`storage-unlock-locked-nsec-session`. Tests:
`tests/storage-unlock-locked-nsec.test.mjs` (6, behavioral). Build + lint clean.
- [x] **Follow-up (UX nicety) — DONE 2026-06-30.** Clicking "Unlock Storage" on a
locked persisted-nsec session now **auto-opens the login modal** straight to its
existing unlock-saved-key (passphrase) flow, so the user re-unlocks in one step.
Added a side-effect-free `openLoginModal` service to `profileModalServices`
(`app.loginModalController.openModal` — no add-account callback, idempotently
ensures the modal exists first); `reportLockedNsecSession({ autoOpenLogin })` calls
it only from the user-initiated unlock click, NOT the passive pane render (no
surprise popups). After the user unlocks, the signer is active app-wide and
re-opening the storage pane auto-unlocks. Tests extended (8 total: asserts auto-open
fires on click, stays closed on passive render).
- [ ] **VERIFY on unstable:** nsec-login with "remember key" + passphrase, reload the
page, open Profile → Storage → the status should read the actionable re-unlock hint
(not "No active signer"); after re-entering the passphrase via Login, storage
unlocks normally. (`[storage-unlock]` console line confirms the locked-key branch.)
- [x] **Same fix for the UPLOAD modal — DONE 2026-06-30.** The Upload modal's "Unlock
storage" had its OWN unlock path (`UploadModal.handleUnlock`) that dead-ended on a
reloaded persisted-nsec session with "No signer available to unlock storage."
Added `promptStoredNsecUnlock(pubkey)`: when the active account has a locked saved
nsec key (`getStoredSessionActorMetadata`), it shows an actionable message and opens
the login modal's unlock-saved-key (passphrase) flow (`onRequestUnlock`, wired in
`initUploadModal` → `app.loginModalController.openModal`) instead of the alert. After
the user re-enters their passphrase the signer is active and the upload proceeds.
Tests: `tests/upload-modal-nsec-unlock.test.mjs` (5). Build + lint clean.
### 37. Channel playlists — creator-curated custom note lists
- [ ] Let creators build **playlists** (custom curated lists of their videos / notes)
surfaced on their channel. Likely a NIP-51 set (kind 30005 "curation sets" or a
bitvid-specific addressable list) referencing video events, with create/edit/
reorder UI on the channel page and a playlist view. Research the list shape +
where it slots into channel profile + the feed engine.
### 38. Generic S3 doesn't work with Backblaze B2 — add B2 as a first-class provider
- [x] **DONE 2026-06-30.** Added **Backblaze B2** as its own provider
(`PROVIDERS.B2 = "backblaze_b2"`). Root issue: B2's S3 endpoint is *region-scoped*
(`s3.<region>.backblazeb2.com`) and a public bucket is addressed virtual-hosted, so
the public download URL is `https://<bucket>.s3.<region>.backblazeb2.com/<key>` —
not derivable from a pasted generic-S3 endpoint. Implementation:
- `deriveB2Endpoint(region)` (`js/storage/s3-url.js`, re-exported from `s3Service`):
region → `https://s3.<region>.backblazeb2.com`; returns "" for `auto`/blank/pasted
host so callers can require a real region.
- `storageService`: `PROVIDERS.B2` + `resolveB2Endpoint` (explicit endpoint wins,
else derive from region) + a `PROVIDER_TESTS[B2]` handler (derived endpoint,
`forcePathStyle:false`).
- **Public URL strategy = S3-style virtual-hosted** (user-chosen): auto-derives to
`https://<bucket>.s3.<region>.backblazeb2.com`; an explicit Public Access URL
(custom domain/CDN) overrides it — the save path now passes the user's URL to
`prepareS3Connection` (also fixes generic-S3 silently ignoring a custom CDN URL).
- UI (`profile-modal.html` + `ProfileStorageController`): "Backblaze B2" option +
helper; for B2 the raw Endpoint field is hidden (region drives it), Region shows a
`us-west-004` example, force-path-style toggle hidden (B2 is virtual-hosted), and
save/test derive the endpoint from region (clear "enter your region" error if
missing). `UploadModal` shows B2-specific labels.
- Tests: `tests/storage-b2-provider.test.mjs` (8 — endpoint derivation, virtual-
hosted public URL, end-to-end validate, explicit override, object URLs). All
s3/storage suites green (50); build + lint clean.
- [x] **VALIDATED against a real B2 account 2026-06-30.** Read-only `b2_authorize_account`
on the live `bitvid` bucket confirmed the derivation EXACTLY: B2's reported
`s3ApiUrl` = `https://s3.us-west-004.backblazeb2.com` = `deriveB2Endpoint("us-west-004")`;
bucket is `allPublic` → S3-style playback URL works. Key file caps
(read/write/list/delete) ✓.
- [x] **CORS helper modal (provider-aware) — DONE 2026-06-30.** Empirically confirmed
the gotcha: B2's web-console "Share everything…" presets write DOWNLOAD-only CORS
(`s3_get/s3_head`, `b2_download_*`) — **no `s3_put/s3_post`** — so browser uploads
CORS-fail. Added a "CORS setup help" button (shown for **all** bucket providers)
opening a **provider-aware** modal:
- **Backblaze B2** → B2-native rules JSON (upload + ranged-playback ops) + the
`b2 update-bucket --corsRules …` command.
- **Custom S3** → standard S3 `CORSConfiguration` JSON + `aws s3api put-bucket-cors
--endpoint-url <endpoint> …`.
- **Cloudflare R2** → standard S3 JSON + Cloudflare-dashboard guidance (R2 → bucket
→ Settings → CORS Policy) and the AWS-CLI command against the derived R2 endpoint.
Origins pre-filled from `getCorsOrigins()`; copy buttons for JSON + command. bitvid
still auto-applies CORS on Save (R2/S3 via `ensureBucketCors`) — this is the manual
fallback for when the key lacks CORS-write permission. Logic in
`js/ui/profileModal/storageCorsHelp.js` (`buildCorsHelpContent` switches on
provider; keeps the controller <1000 lines). Tests:
`tests/storage-cors-help.test.mjs` (10 — per-provider rules/command + DOM switch).
Build + lint clean.
- [ ] **VERIFY live** on unstable: pick Backblaze B2, enter region (`us-west-004`) +
bucket + key/secret, Test → passes; apply the CORS helper's rules; upload a video →
plays from `https://<bucket>.s3.<region>.backblazeb2.com/...`.
- [x] **Per-provider connections (no overwrite/clash) — DONE 2026-06-30.** The UI saved
every provider into a single shared `"default"` connection id, so configuring B2
overwrote R2 (etc.). The service layer + upload path + sync already supported a
MAP of connections keyed by id (`listConnections`/`defaultForUploads`/
`exportAccountRecord`) — only the controller collapsed them. Now each provider type
gets its own slot keyed by provider id (`cloudflare_r2`/`backblaze_b2`/`generic_s3`):
save writes that slot (preserving a single active `defaultForUploads`, and lazily
migrating the legacy `"default"` slot to its provider id), and switching the provider
dropdown loads that provider's saved credentials (or clears the fields for a fresh
entry). Sync already carries the whole record, so all providers sync together.
Logic extracted to `js/ui/profileModal/storageConnections.js` (`saveProviderConnection`
+ pure helpers; keeps the controller under the 1000-line cap). Tests:
`tests/storage-connections.test.mjs` (7 — pure default/dup logic + integration:
R2+B2 coexist with one default, re-default switch, legacy-`default` migration, and
the export carries both). Storage + profile-modal suites 69/69; build + lint clean.
- [x] **Upload wrong-service routing FIXED 2026-06-30 (multi-provider regression).** With
both B2 + Cloudflare configured, an upload could fail with **"S3 endpoint is
required."** when the upload modal's tracked `activeProvider` drifted out of sync
with `activeCredentials` (user: "selector on Backblaze but the modal set for
Cloudflare") — an R2 connection (accountId, no endpoint) got routed through the S3
service. Fix: `MediaUploader.uploadVideo`/`uploadThumbnail` now route by the
CREDENTIALS' own provider (`credentials.provider || meta.provider || provider`), so
a stale modal provider can't pick the wrong service. Test:
`tests/media-uploader-provider-routing.test.mjs` (4). Build + lint clean.
- [ ] **Follow-up (cosmetic):** the upload modal's summary provider label can still show
a stale provider until reloaded — refresh `loadFromStorage` when the storage default
changes in the Storage pane (functional routing is already correct).
- [x] **B2 / generic-S3 upload contract mismatch FIXED 2026-06-30.** Uploading to a
Backblaze B2 (or any generic-S3) bucket failed with **"S3 endpoint is required."**
Root cause: `MediaUploader` calls `service.prepareUpload(npub, { credentials })`
(the `r2Service` contract), but `s3UploadService.prepareUpload` took `(settings, …)`
— so the npub string became the settings (no endpoint). Also a
`storageService.getConnection()` result keeps `bucket`/`endpoint`/`region` under
`.meta`, which `validateS3Connection` reads at the top level. Fix:
`s3UploadService.prepareUpload` now accepts `(npub, { credentials })` and flattens
meta → top-level via `flattenS3Settings`. (R2 was unaffected because its service
already used that contract.) Also: the storage **Test** now derives/passes the B2
public URL so it stops falsely warning "Public Bucket URL is missing." Tests:
`tests/services/s3-upload-prepare-contract.test.mjs` (3 — flatten + npub-ignored +
back-compat). Build + lint clean; upload/storage suites 46/46.
### 39. Image uploads should prefer uploading to configured storage (UX)
- [x] **Profile image + banner upload — FIXED 2026-06-30.** The UI was already wired
(`ProfileEditController.handleUpload`: resolve connection → `uploadFile` → auto-fill
the URL field), but it was **broken**: it passed a **hex** pubkey to
`r2Service.resolveConnection()`, which only decoded `npub1…` strings
(`safeDecodeNpub`) → hex resolved to `null` → "Storage configuration missing" and the
Upload button stayed disabled even with storage configured. (Thumbnails worked
because `mediaUploader` uses `listConnections(pubkey)` directly.)
**Fix:** `resolveConnection` now accepts a hex pubkey OR an npub. The rest of the
chain is already provider-agnostic — `resolveConnection` returns the
`defaultForUploads` connection (any provider) with its `endpoint`/`baseDomain`/
`forcePathStyle`, `uploadFile` routes R2 vs generic-S3 by provider, and
`buildPublicUrl` builds the right URL — so this now works for R2 / B2 / Custom S3.
Test: `tests/r2-resolve-connection-hex.test.mjs` (3, with `nip19.decode` throwing on
hex to prove the hex path, not npub decoding). Build + lint clean.
- [ ] **VERIFY live:** unlock storage, Edit Profile → upload a picture/banner file →
uploads to the active provider's bucket and the URL auto-fills.
- [ ] **Extend the pattern** to any remaining image-URL inputs that still only accept a
pasted link (video thumbnail in upload/edit already uploads via `mediaUploader`).
Audit for others (e.g. future channel/branding image fields) and reuse the helper.
## Open — lower priority / infra
### 10. WebTorrent `null.fill` seeding crash
@@ -722,8 +1030,11 @@ test below was failing on `main` unnoticed).
logged-in pubkey now) → aligned; (3) same test asserted `session===true` for a
logged-in watch (the pre-fix bug) → corrected to `session!==true`. The full
2669-line file passes via the CI runner and was removed from QUARANTINE.
- `nostr-boost-actions` — "Missing expected exception" (likely stale: expects a
throw the code no longer makes).
- `nostr-boost-actions` — ✅ DONE + UN-QUARANTINED 2026-06-25: STALE SPEC. The
test required `buildRepostEvent` to throw `/missing-event-relay/` with no relay
hint, but a relay hint is a NIP-18 SHOULD (not MUST) — the builder degrades to a
two-element `e` tag so a user can always repost. Replaced with an exact assertion
of the valid output (kind 16 + e/p/k tags). See TEST_INTEGRITY.md.
- `view-counter` (#4) — ✅ DONE + UN-QUARANTINED 2026-06-24: the failures were
(a) the deterministic-d-tag accuracy fix (no more random `d` tag, so no
auto-`d` either), (b) the batched SubscriptionManager silently shadowing
@@ -743,11 +1054,26 @@ test below was failing on `main` unnoticed).
Fixed + guarded by the real-tools `tests/nwc-parse-uri.test.mjs`. The mock-based
`nwc-client.test.mjs` STAYS quarantined — its nostr-tools mock is shadowed by the
frozen canonical toolkit the bootstrap installs; needs a mock-injection rework.
- `nostr-count-fallback` — `TypeError: Cannot read 'has' of undefined` — likely
a stale harness mock missing a field.
- `admin-list-store` — community-blacklist merge mismatch.
- `nostr-publish-rejection` — rejection-path assertion.
- `user-blocks` — HANGS (async leak); needs a deterministic rewrite.
- `nostr-count-fallback` — ✅ DONE + UN-QUARANTINED 2026-06-25: STALE TEST after a
refactor. `countUnsupportedRelays` moved from `NostrClient` to its
`ConnectionManager`; the test read it off the client (undefined.has). Repointed the
assertion at `client.connectionManager.countUnsupportedRelays`. No behavior change.
- `admin-list-store` — ✅ DONE + UN-QUARANTINED 2026-06-25: STALE HARNESS (no
production change, no assertion weakened). Community curator lists are fetched via
the BATCHED SubscriptionManager (cold-start relay-storm fix) which the harness
didn't mock; the mock-"hex" curator values started with "npub" so
parseCommunityBlacklistReferences decoded them twice; and createListEvent omitted
the `d` tag that selectNewestEventsForReferences matches on. Fixed all three to be
faithful to production. See TEST_INTEGRITY.md.
- `nostr-publish-rejection` — STILL QUARANTINED. Partially diagnosed 2026-06-25:
multi-precondition setup. (1) `testPublishVideoNoteDefaultsToLiveModeInDev` needs
dev mode — set `globalThis.__BITVID_DEV_MODE_OVERRIDE__=true` BEFORE the first
(dynamic) config-importing import (~line 50). (2) The `publishNip71Video` sub-test
then fails — `publishVideo`'s NIP-71 invocation path needs FEATURE_PUBLISH_NIP71 /
condition triage; possibly more after. Quarantine note carries the steps.
- `nostr-publish-rejection`, `nwc-client`, `user-blocks` remain quarantined
(see QUARANTINE map in run-unit-tests.mjs for the precise reason on each).
- `user-blocks` — STILL QUARANTINED: HANGS (async leak); needs a deterministic rewrite.
### 12. Promotion: `unstable → beta`
- [ ] After this batch soaks and the high-priority items land, promote `unstable → beta`
@@ -1001,3 +1327,31 @@ has a persisted cache; channel/search now render optimistically from
dedupe-by-root + canAccess in `buildRenderableChannelVideos` handle
precedence/gating. Logic extracted to a helper so channelProfile.js stayed
under its size cap. Tests + mutation-verified.
### 40. Dead @media queries: `var()` in media conditions is invalid — FIXED 2026-06-25
Found 2026-06-25 while fixing the profile-modal mobile grid. Many media queries use
`@media (max-width: calc(theme("screens.lg") - var(--breakpoint-edge-offset)))`.
CSS custom properties are NOT allowed inside `@media` conditions — Chromium/Brave
drop the ENTIRE block, so all those rules are DEAD (confirmed via matchMedia: the
`var()` form returns false where it should match; the literal `calc(... - 0.02px)`
works). The `--breakpoint-edge-offset` token was introduced to satisfy the
design-token lint (no raw measurements), but it silently broke every media query
that used it.
- [ ] Audit all `var(--breakpoint-edge-offset)` (and any other `var()`) inside
`@media` conditions in `css/tailwind.source.css` and replace with a valid
literal-resolving form (e.g. `@media (max-width: theme("screens.lg"))`, which
compiles to a literal). The profile-modal nav grid (#7) already uses this.
- [ ] Decide whether the token-lint should EXEMPT @media conditions (so the offset
token can stay) or whether to drop the offset pattern entirely.
- [ ] Re-verify the responsive rules that were silently dead (e.g. the profile-modal
menu/pane-hide block, and the line ~1398/1433 player/modal rules).
- [x] **FIXED 2026-06-25.** Replaced `var(--breakpoint-edge-offset)` with the literal
`0.02px` in all 4 `@media` conditions (`css/tailwind.source.css`). `0.02px` was
ALREADY exempt in the design-token lint, so the offset token was never needed —
using it just produced invalid, dead media queries. Verified valid via matchMedia.
This RE-ACTIVATES 3 previously-dead mobile blocks (intended behavior that had
silently never worked): full-screen modals on mobile, profile-modal menu/pane
switching, and the video-card tap-active state — plus the profile-nav app-grid.
- [ ] **VERIFY live on a phone**: modals should now be full-screen; tapping a video
card shows the active lift/accent/marquee; profile modal tabs are the app-grid.